feat(file): support overseas exception evidence

This commit is contained in:
2026-09-08 18:26:26 +08:00
parent 3b88e5f6ab
commit 047ed674b8
6 changed files with 190 additions and 1 deletions
+1
View File
@@ -16,6 +16,7 @@
| 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` |
| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` |
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 |
| 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg |
| 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 |
| 报关源文件、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 |
| 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=订舱customerId`、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 |
@@ -21,6 +21,7 @@ enum BusinessFileCategory {
CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_UNLOAD_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_RELABEL_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_EXCEPTION_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
LOADING_LIST_CUSTOMS_RESULT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
ORDER_BATCH_IMPORT_SOURCE(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO),
+1 -1
View File
@@ -6,7 +6,7 @@ USE baiyue_file;
CREATE TABLE IF NOT EXISTS file_object (
id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '所有者范围键:0为平台,正整数为客户ID',
business_type VARCHAR(32) NULL COMMENT '关联业务类型',
business_type VARCHAR(32) NULL COMMENT '关联业务类型,海外异常证据固定为OVERSEAS_EXCEPTION_EVIDENCE',
business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID;装柜证据固定为物理containerBookingId',
original_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
object_key VARCHAR(512) NOT NULL COMMENT '对象存储键',
@@ -0,0 +1,109 @@
openapi: 3.0.3
info:
title: 海外异常订单证据文件内部契约
version: 1.0.0
description: >-
File 只保存文件对象并校验复合归属;异常订单状态、完成门禁和证据引用由 Lastleg 持有。
所有接口仅接受内部服务令牌,业务失败按统一响应体 code 判断。
paths:
/internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}:
post:
summary: 上传海外异常订单证据
description: >-
tenantId 固定为异常订单所属客户ID,exceptionOrderId 固定为异常订单内部ID。
单文件最大10MiB,仅支持TXT、PDF、JPG、PNG;File不校验异常状态。
security: []
parameters:
- $ref: '#/components/parameters/InternalToken'
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ExceptionOrderId'
- {name: X-Operator-Id, in: header, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: {type: string, format: binary}
responses:
'200':
description: 返回文件元数据
content:
application/json:
schema: {$ref: '#/components/schemas/MetadataEnvelope'}
'403': {description: 内部服务令牌无效}
/internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}/{fileId}:
get:
summary: 校验并读取海外异常订单证据元数据
description: 必须同时匹配客户ID、异常订单ID、文件类型与文件ID,否则返回不存在或归属不匹配。
security: []
parameters:
- $ref: '#/components/parameters/InternalToken'
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ExceptionOrderId'
- $ref: '#/components/parameters/FileId'
responses:
'200':
description: 返回匹配复合归属的文件元数据
content:
application/json:
schema: {$ref: '#/components/schemas/MetadataEnvelope'}
'403': {description: 内部服务令牌无效}
/internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}/{fileId}/content:
get:
summary: 下载海外异常订单证据内容
description: 下载前执行与元数据接口相同的复合归属校验,响应禁止缓存。
security: []
parameters:
- $ref: '#/components/parameters/InternalToken'
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ExceptionOrderId'
- $ref: '#/components/parameters/FileId'
responses:
'200': {description: 文件二进制;携带Content-Disposition并设置Cache-Control no-store}
'403': {description: 内部服务令牌无效}
components:
parameters:
InternalToken:
name: X-Internal-Service-Token
in: header
required: true
schema: {type: string}
TenantId:
name: tenantId
in: path
required: true
schema: {type: string, pattern: '^[1-9][0-9]*$'}
ExceptionOrderId:
name: exceptionOrderId
in: path
required: true
schema: {type: string, pattern: '^[1-9][0-9]*$'}
FileId:
name: fileId
in: path
required: true
schema: {type: string, pattern: '^[1-9][0-9]*$'}
schemas:
MetadataEnvelope:
type: object
required: [code, data]
properties:
code: {type: string, example: '200'}
message: {type: string, nullable: true}
data: {$ref: '#/components/schemas/Metadata'}
Metadata:
type: object
required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes]
properties:
fileId: {type: string, pattern: '^[1-9][0-9]*$'}
ownerScope: {type: string, enum: [CUSTOMER]}
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
businessType: {type: string, enum: [OVERSEAS_EXCEPTION_EVIDENCE]}
businessId: {type: string, pattern: '^[1-9][0-9]*$', description: 异常订单内部ID}
fileName: {type: string}
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
contentType: {type: string}
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760}
@@ -315,4 +315,45 @@ class InternalBusinessFileServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
}
@Test void overseasExceptionEvidenceUsesExactCustomerAndExceptionOwnership() {
MockMultipartFile file = new MockMultipartFile(
"file", "异常处理证据.pdf", "application/pdf", "%PDF-1.4".getBytes());
when(storage.store(10001L, "701", file, BusinessFileCategory.OVERSEAS_EXCEPTION_EVIDENCE))
.thenReturn(new BusinessFileStorage.Stored("business-files/10001/701/evidence.pdf",
"application/pdf", file.getSize(), "b".repeat(64), "异常处理证据.pdf"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(801L);
return 1;
});
var result = service.upload(10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 7L, file);
assertThat(result).extracting(InternalBusinessFileService.Metadata::ownerScope,
InternalBusinessFileService.Metadata::tenantId,
InternalBusinessFileService.Metadata::businessType,
InternalBusinessFileService.Metadata::businessId)
.containsExactly("CUSTOMER", 10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "701");
verify(storage).store(10001L, "701", file, BusinessFileCategory.OVERSEAS_EXCEPTION_EVIDENCE);
assertThatThrownBy(() -> service.upload(0L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 7L, file))
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
}
@Test void overseasExceptionEvidenceMetadataFailsClosedForEveryOwnershipMismatch() {
FileObjectEntity value = new FileObjectEntity();
value.setId(801L); value.setTenantId(10001L);
value.setBusinessType("OVERSEAS_EXCEPTION_EVIDENCE"); value.setBusinessId("701");
value.setOriginalName("evidence.pdf"); value.setObjectKey("business-files/10001/701/evidence.pdf");
value.setContentType("application/pdf"); value.setSha256("b".repeat(64)); value.setSizeBytes(8L);
when(files.selectOne(any())).thenReturn(value, null, null, null);
assertThat(service.metadata(10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 801L).fileId())
.isEqualTo(801L);
assertThatThrownBy(() -> service.metadata(10002L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 801L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
assertThatThrownBy(() -> service.metadata(10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "702", 801L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
assertThatThrownBy(() -> service.metadata(10001L, "INBOUND_HOLD_EVIDENCE", "701", 801L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
}
}
@@ -0,0 +1,37 @@
package com.baiyue.file.service;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class OverseasExceptionEvidenceContractTest {
@Test
void categoryAndOpenApiFreezeCustomerExceptionOwnership() throws Exception {
BusinessFileCategory category = BusinessFileCategory.require("OVERSEAS_EXCEPTION_EVIDENCE");
assertThat(category.maxSizeBytes()).isEqualTo(10L * 1024 * 1024);
assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.STANDARD);
try (var stream = getClass().getResourceAsStream(
"/openapi/overseas-exception-evidence-v1.yaml")) {
assertThat(stream).isNotNull();
String yaml = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
assertThat(yaml).contains(
"/internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}:",
"ownerScope: {type: string, enum: [CUSTOMER]}",
"businessType: {type: string, enum: [OVERSEAS_EXCEPTION_EVIDENCE]}",
"maximum: 10485760");
}
}
@Test
void initializationSqlHasEnoughWidthAndDocumentsTheCategory() throws Exception {
try (var stream = getClass().getResourceAsStream("/db/file.sql")) {
assertThat(stream).isNotNull();
String sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
assertThat(sql).contains("business_type VARCHAR(32)", "OVERSEAS_EXCEPTION_EVIDENCE");
assertThat("OVERSEAS_EXCEPTION_EVIDENCE").hasSizeLessThanOrEqualTo(32);
}
}
}