This commit is contained in:
2026-08-08 12:01:45 +08:00
parent 1a2f598c9a
commit a35cd868b9
56 changed files with 7178 additions and 735 deletions
+20 -48
View File
@@ -1,58 +1,30 @@
# File Service 模块指引
## 职责边界
## 职责与边界
- File 服务是企业资质文件的唯一公开上传入口。必须先持久化并验证真实文件,再调用 IAM 内部接口登记元数据;禁止只生成或改写一个不存在文件的 URL。
- 负责企业资质文件、公共导出、二期入库测量图片的上传、存储、读取和鉴权。
- 资质审核元数据归 `iam/`,入库状态归 `firstleg/`,OCR 适配归 `integration-service/`;不跨库读取业务表。
- 负责企业资质文件、公共导出文件和二期入库测量图片的上传、存储、读取及访问鉴权;当前文件内容使用本地磁盘,不能进入 Git。
- `src/main/resources/db/file.sql` 中的 `file_object` 已由测量图片链路通过 `FileObjectEntity`、`FileObjectMapper` 接入,导出任务表也已接入;企业资质文件元数据仍归 IAM,不能把不同文件链路的持久化状态混为一谈。
- 企业资质审核状态和元数据归 `iam/`;本模块通过 IAM 客户端校验访问权限,不跨库读取 IAM 数据。
- 入库单和测量状态归 `firstleg/`,重量识别适配归 `integration-service/`;File 只保存图片对象、返回逻辑文件 ID 并代理受控 OCR 调用。
## 模块入口
## 快速索引
| 功能 | 入口 |
| 目标 | 入口 |
|---|---|
| 模块使用、RocketMQ 部署与导出排查 | `README.md`;完整架构与接口契约见 `../docs/公共导出与下载中心接入说明.md` |
| 商户资质上传与读取 | `src/main/java/com/baiyue/file/controller/QualificationFileController.java` |
| 平台资质上传、替换与查看 | `controller/PlatformQualificationFileController.java`;`POST /{tenantId}` 支持 `qualificationType`、`certificateNo`、`validTo`、`replace`,下载为 `GET /{tenantId}/{fileName}` |
| 服务状态 | `controller/FileHealthController.java` |
| 当前本地磁盘实现 | `service/LocalQualificationStorage.java` |
| 公共导出 API | `controller/PlatformExportTaskController.java`、`service/ExportTaskService.java` |
| 可靠调度 | `service/ExportStateService.java`、`mq/ExportOutboxPublisher.java`、`mq/ExportCommandConsumer.java`、`service/ExportReconciler.java` |
| 通用 Provider/XLSX | `client/ExportProviderClient.java`、`service/GenericXlsxExportRunner.java`、公共契约 `../common-core/.../export/ExportContracts.java` |
| 毒消息隔离 | `mq/ExportDlqHandler.java`;定位必须使用四个 User Properties,不解析损坏 body |
| 未被当前 Controller 使用的旧实现 | `service/QualificationFileStorage.java` |
| IAM 权限协作 | `client/IamQualificationClient.java`;商户登记 `/qualifications`,平台登记/替换 `/platform-qualifications` |
| 平台称重图片上传、OCR 重试、原图与缩略图查看 | `controller/PlatformMeasurementImageController.java`(`/api/platform/v1/files/measurements`)→ `service/MeasurementImageService.java` |
| Firstleg 第三方测量证据图片 | `controller/InternalMeasurementImageController.java`(`/internal/file/v1/measurement-images`)→ `MeasurementImageService.uploadMeasurementEvidence/deleteMeasurementEvidence`;必须校验内部令牌 |
| 测量图片磁盘与元数据 | `service/MeasurementImageStorage.java`、`entity/FileObjectEntity.java`、`mapper/FileObjectMapper.java`;表结构 `src/main/resources/db/file.sql` |
| 重量 OCR 下游 | `client/WeightOcrClient.java` → `integration-service` 的 `/internal/integrations/weight-ocr/single`;尺寸测量证据不触发重量 OCR |
| 测量图片测试 | `controller/PlatformMeasurementImageControllerContractTest.java`、`InternalMeasurementImageControllerTest.java`、`service/MeasurementImageServiceEvidenceTest.java` |
| 历史资质文件迁移 | `../scripts/migrate-local-qualification-file.ps1`、`../docs/企业资质文件迁移说明.md`;只适用于当前本地/共享目录存储实现 |
| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/file-service.yml` |
| 上传契约与存储测试 | `src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java`、`service/LocalQualificationStorageTest.java` |
| 商户/平台资质 | `controller/QualificationFileController.java`、`PlatformQualificationFileController.java` |
| 导出中心 | `controller/PlatformExportTaskController.java`、`service/ExportStateService.java`、`mq/` |
| 测量图片/证据 | `controller/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` |
| 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
| 下游 OCR | `client/WeightOcrClient.java` |
| SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` |
| 测试/验证 | `src/test/java/com/baiyue/file/`;`mvn -pl file -am test` |
## 修改与验证
## 修改约束
- 严格校验客户归属、文件名/路径、大小、类型和读取权限,防止目录穿越、跨客户访问和任意文件上传;接口和存储路径中的 `tenantId`、`tenant_id` 当前是客户隔离兼容字段。
- 企业资质文件只接受 PDF、JPG 和 PNG,单个文件上限 5MB;Client、Admin 与 `LocalQualificationStorage` 的限制和提示必须保持一致。
- 生产存储应使用对象存储或共享文件存储;容器本地目录仅适合当前本地实现,不得硬编码环境路径。
- 企业资质元数据后续若迁入 File,需同时实现 Entity、Mapper、Repository、事务和迁移验证,并同步检查 IAM 资质流程及平台/商户 Controller;不得复用测量图片 `file_object` 的已接入状态宣称资质元数据已迁移。
- 测量图片必须同时按 `tenantId + inboundId + fileId + businessType` 校验归属。平台称重图片使用 `INBOUND_WEIGHT_IMAGE` 并可调用 OCR;第三方尺寸证据使用 `INBOUND_MEASUREMENT_IMAGE`,只允许内部上传/删除且不得触发重量 OCR。
- 平台测量图片权限与 Firstleg 权限码保持一致:上传和重试要求 `platform:firstleg:inbound:weight:edit`,查看要求 `platform:firstleg:inbound:measure:view`;内部证据接口只信任共享内部令牌和明确操作人。
- OCR 失败时保留已落盘文件和 `file_object` 元数据供重试或人工录入;入库称重图片和第三方尺寸证据均由其业务服务确认引用关系后通过内部接口逻辑删除,其他客户文件不得使用该接口删除。
- 从父目录运行 `mvn -pl file -am test`,并按接口影响验证商户/平台上传、替换、预览、无权限、签名伪造和磁盘故障场景。
- 非本地环境必须显式配置 `FILE_STORAGE_ROOT` 并挂载持久化可写目录;服务启动时会校验目录,存储不可用时直接启动失败,避免健康检查正常而首次上传才返回 HTTP 200 + `S0503`。
- 文件写入失败属于基础设施故障,统一返回 `S0503`,服务日志保留实际目录和底层异常;禁止将磁盘故障包装成 `B0001`。
- 迁移历史 `/phase1-seed/...` 路径时必须先写入并校验真实脱敏图片,再按 `id + tenant_id + 旧 URL` 更新 IAM 元数据;禁止只改 URL。对象存储环境必须改走 File 上传接口。
- 上传需先写新文件,再调用 IAM 登记;收到明确业务拒绝时删除新文件,传输异常、空响应或缺少 data 时远端结果未知,必须保留文件并记录兼容字段 tenant/documentUrl 供重试或对账,避免 IAM 已提交却被 File 删除。`replace=true` 成功时 IAM 返回 `replacedDocumentUrl`,File 只能在新元数据落库后按目标客户安全清理旧 UUID 文件。
- 旧文件清理只接受 File 自身生成的 `/api/merchant/v1/files/qualifications/{uuid}.{ext}`,并且不得删除本次新上传的 `documentUrl`;不能仅从任意 URL 截取文件名后删除。
- 平台上传要求平台身份以及 `platform:customer:create` 或 `platform:customer:edit`,平台下载属于客户档案只读能力并要求 `platform:customer:view`;不得复用审核权限,也不能只依赖 Gateway 路由或前端按钮隐藏。
- `LocalQualificationStorage` 当前生成的 `documentUrl` 使用商户读取路径,但文件实际按 `storage-root/qualifications/{tenantId}/{uuid.ext}` 隔离;变更 URL 形态时同步检查 IAM 已存元数据、平台下载路由和迁移脚本。
- 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。
- 所有文件按客户、入库单、文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。
- 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。
## 分期快速索引
## 分期导航
- 一期:资质文件入口为 `QualificationFileController`、`PlatformQualificationFileController`,先读 `../../docs/一期/README.md`、客户审核闭环文档;文件内容归 File,审核状态/元数据归 IAM,上传成功不等于客户已具备下单资格。
- 二期:测量图片入口为 `PlatformMeasurementImageController` 与 `InternalMeasurementImageController`,先读 `../../docs/二期/README.md`、主流程和第三方测量联调说明;重量图片可触发 OCR,第三方尺寸证据不得触发重量 OCR,必须绑定客户、入库单和业务类型。
- 三期:当前 NO-GO;暂不新增装柜、柜封、报关或出仓附件模型。待 `../../docs/三期/三期需求确认表.md` 确认后,再与 Firstleg/PDA 锁定文件证据生命周期和权限码。
- 一期:资质上传与读取;先读 `../../docs/一期/README.md`。
- 二期:测量图片和 OCR 重试;先读 `../../docs/二期/README.md` 与第三方联调说明。
- 三期:NO-GO,不新增装柜/报关附件模型;确认后再定义证据生命周期。
@@ -56,6 +56,21 @@ public class InternalMeasurementImageController {
images.uploadMeasurementEvidence(tenantId, inboundId, operatorId, file));
}
@PostMapping(value = "/receipts/{tenantId}/{receiptId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<MeasurementImageService.UploadResult> uploadReceipt(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable long receiptId,
@RequestPart("file") MultipartFile file) {
requireToken(token);
if (tenantId <= 0 || receiptId <= 0 || operatorId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "客户、收件记录和操作主体ID必须大于0");
}
return ApiResponse.success(images.uploadMojiaReceiptEvidence(
tenantId, receiptId, operatorId, file));
}
@DeleteMapping("/{tenantId}/{inboundId}/{fileId}")
public ApiResponse<Void> delete(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -71,6 +86,21 @@ public class InternalMeasurementImageController {
return ApiResponse.success(null);
}
@DeleteMapping("/receipts/{tenantId}/{receiptId}/{fileId}")
public ApiResponse<Void> deleteReceipt(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable long receiptId,
@PathVariable long fileId) {
requireToken(token);
if (tenantId <= 0 || receiptId <= 0 || fileId <= 0 || operatorId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "客户、收件记录、文件和操作主体ID必须大于0");
}
images.deleteMojiaReceiptEvidence(tenantId, receiptId, fileId, operatorId);
return ApiResponse.success(null);
}
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
public ResponseEntity<byte[]> download(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -51,6 +51,16 @@ public class MeasurementImageService {
object.getSizeBytes(), "NOT_REQUIRED", null, null, null, null, null);
}
/** 保存尚未匹配正式入库单的墨家联调图片。 */
@Transactional
public UploadResult uploadMojiaReceiptEvidence(long tenantId, long receiptId, long operatorId,
MultipartFile file) {
FileObjectEntity object = storeObject(
tenantId, receiptId, operatorId, file, "MOJIA_MEASUREMENT_RECEIPT");
return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(),
object.getSizeBytes(), "NOT_REQUIRED", null, null, null, null, null);
}
private FileObjectEntity storeObject(long tenantId, long inboundId, long operatorId,
MultipartFile file, String businessType) {
var stored = storage.store(tenantId, inboundId, file);
@@ -124,11 +134,19 @@ public class MeasurementImageService {
@Transactional
public void deleteMeasurementEvidence(long tenantId, long inboundId, long fileId, long operatorId) {
FileObjectEntity object = requireObject(tenantId, inboundId, fileId);
if (!Set.of("INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE")
.contains(object.getBusinessType())) {
throw new BizException(ResultCode.CONFLICT, "仅允许清理入库测量图片");
}
FileObjectEntity object = requireObject(tenantId, inboundId, fileId,
Set.of("INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE"));
deleteObject(object, operatorId);
}
@Transactional
public void deleteMojiaReceiptEvidence(long tenantId, long receiptId, long fileId, long operatorId) {
FileObjectEntity object = requireObject(tenantId, receiptId, fileId,
Set.of("MOJIA_MEASUREMENT_RECEIPT"));
deleteObject(object, operatorId);
}
private void deleteObject(FileObjectEntity object, long operatorId) {
object.setStatus("DELETED");
object.setUpdatedBy(operatorId);
object.setUpdatedAt(LocalDateTime.now());
@@ -139,11 +157,17 @@ public class MeasurementImageService {
}
private FileObjectEntity requireObject(long tenantId, long inboundId, long fileId) {
return requireObject(tenantId, inboundId, fileId,
Set.of("INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE"));
}
private FileObjectEntity requireObject(long tenantId, long businessId, long fileId,
Set<String> businessTypes) {
FileObjectEntity object = files.selectOne(new LambdaQueryWrapper<FileObjectEntity>()
.eq(FileObjectEntity::getTenantId, tenantId)
.eq(FileObjectEntity::getId, fileId)
.in(FileObjectEntity::getBusinessType, "INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE")
.eq(FileObjectEntity::getBusinessId, Long.toString(inboundId))
.in(FileObjectEntity::getBusinessType, businessTypes)
.eq(FileObjectEntity::getBusinessId, Long.toString(businessId))
.eq(FileObjectEntity::getStatus, "ACTIVE")
.eq(FileObjectEntity::getDeleted, false));
if (object == null) throw new BizException(ResultCode.NOT_FOUND, "称重图片不存在");
@@ -59,4 +59,18 @@ class InternalMeasurementImageControllerTest {
assertThat(response.getBody()).containsExactly(1, 2, 3);
verify(images).download(11L, 21L, 41L);
}
@Test
void storesAnUnmatchedMojiaReceiptImageWithoutAnInboundId() {
var file = new MockMultipartFile("file", "mojia.jpg", MediaType.IMAGE_JPEG_VALUE,
new byte[]{1, 2, 3});
var result = new MeasurementImageService.UploadResult(41L, "mojia.jpg",
MediaType.IMAGE_JPEG_VALUE, 3L, "NOT_REQUIRED", null, null, null, null, null);
when(images.uploadMojiaReceiptEvidence(11L, 51L, 31L, file)).thenReturn(result);
var response = controller.uploadReceipt("internal-token", 31L, 11L, 51L, file);
assertThat(response.data().fileId()).isEqualTo(41L);
verify(images).uploadMojiaReceiptEvidence(11L, 51L, 31L, file);
}
}
@@ -10,6 +10,7 @@ import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
@@ -70,6 +71,29 @@ class MeasurementImageServiceEvidenceTest {
verifyNoInteractions(ocr);
}
@Test
void storesUnmatchedMojiaReceiptUnderItsOwnBusinessType() {
var image = new MockMultipartFile("file", "mojia.jpg", MediaType.IMAGE_JPEG_VALUE,
new byte[]{1, 2, 3});
when(storage.store(11L, 51L, image)).thenReturn(new MeasurementImageStorage.Stored(
"measurements/11/51/mojia.jpg", Path.of("mojia.jpg"), MediaType.IMAGE_JPEG_VALUE,
3L, "hash", "mojia.jpg"));
ArgumentCaptor<FileObjectEntity> object = ArgumentCaptor.forClass(FileObjectEntity.class);
doAnswer(invocation -> {
FileObjectEntity value = invocation.getArgument(0);
value.setId(41L);
return 1;
}).when(files).insert(any(FileObjectEntity.class));
var result = service.uploadMojiaReceiptEvidence(11L, 51L, 31L, image);
assertThat(result.fileId()).isEqualTo(41L);
verify(files).insert(object.capture());
assertThat(object.getValue().getBusinessType()).isEqualTo("MOJIA_MEASUREMENT_RECEIPT");
assertThat(object.getValue().getBusinessId()).isEqualTo("51");
verifyNoInteractions(ocr);
}
@Test
void shouldHandleCase02() {
FileObjectEntity object = new FileObjectEntity();