fix(lastleg): complete relabel document contract and regression gates

This commit is contained in:
2026-09-09 12:54:14 +08:00
parent ac3ce3703e
commit 655237e37f
19 changed files with 474 additions and 23 deletions
+1
View File
@@ -17,6 +17,7 @@
| 装柜与业务文件证据 | `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 |
| 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,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=0`(平台专属,客户可选快照不改变文件归属,拒绝客户作用域)、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 |
@@ -24,6 +24,8 @@ 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_RELABEL_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, 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),
@@ -86,7 +88,7 @@ enum BusinessFileCategory {
return Long.toString(value);
}
enum FormatPolicy { STANDARD, ARBITRARY, XLSX }
enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF }
private enum BusinessIdPolicy { POSITIVE_LONG, ORDER_BATCH_NO }
private static final class Limits {
private static final long STANDARD = 10L * 1024 * 1024;
@@ -55,6 +55,10 @@ public class BusinessFileStorage {
String extension;
if (formatPolicy == BusinessFileCategory.FormatPolicy.ARBITRARY) {
extension = "";
} else if (formatPolicy == BusinessFileCategory.FormatPolicy.PDF) {
if (!"application/pdf".equals(type)) throw bad("附件仅支持PDF");
verifyContent(file, type);
extension = ".pdf";
} else if (formatPolicy == BusinessFileCategory.FormatPolicy.XLSX) {
if (!XLSX_TYPE.equals(type)) throw bad("附件仅支持XLSX");
verifyXlsx(file);
@@ -0,0 +1,125 @@
openapi: 3.0.3
info:
title: 海外换标 PDF 文件内部契约
version: 1.0.0
description: >-
File 只保存文件并强制客户、业务类型、业务ID与文件ID复合归属。
OVERSEAS_RELABEL_TOTAL_DOCUMENT 的 businessId 为 application.id;
OVERSEAS_RELABEL_ITEM_DOCUMENT 的 businessId 为 item.id。
Lastleg 必须先校验申请与明细存在、明细所属申请及其客户归属、业务状态和权限。
客户归属未知时失败关闭,不得使用平台归属或替代业务ID。
security:
- InternalServiceToken: []
paths:
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}:
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/BusinessType'
- $ref: '#/components/parameters/BusinessId'
post:
summary: 上传换标总文件或单品文件
description: 非空 PDF,Content-Type 必须为 application/pdf,内容需有 PDF 签名,最大 10MiB(10485760字节,含边界)。
parameters:
- name: X-Operator-Id
in: header
required: true
schema: {type: integer, format: int64, minimum: 1}
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: {type: string, format: binary}
encoding:
file: {contentType: application/pdf}
responses:
'200':
description: 统一响应体;业务失败按 code 判断
content:
application/json:
schema: {$ref: '#/components/schemas/MetadataEnvelope'}
'403': {description: 内部令牌无效}
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}:
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/BusinessType'
- $ref: '#/components/parameters/BusinessId'
- $ref: '#/components/parameters/FileId'
get:
summary: 校验复合归属并读取元数据
responses:
'200':
description: 仅返回全部归属字段匹配且有效的文件,未匹配按统一业务错误返回
content:
application/json:
schema: {$ref: '#/components/schemas/MetadataEnvelope'}
'403': {description: 内部令牌无效}
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content:
parameters:
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/BusinessType'
- $ref: '#/components/parameters/BusinessId'
- $ref: '#/components/parameters/FileId'
get:
summary: 校验复合归属并下载 PDF
responses:
'200':
description: 文件内容;Content-Disposition 附件下载,Cache-Control no-store
content:
application/pdf:
schema: {type: string, format: binary}
'403': {description: 内部令牌无效}
components:
schemas:
MetadataEnvelope:
type: object
required: [code, data]
properties:
code: {type: string}
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_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT]}
businessId: {type: string, pattern: '^[1-9][0-9]*$'}
fileName: {type: string}
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
contentType: {type: string, enum: [application/pdf]}
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760}
createdAt: {type: string, description: 持久化上传时间LocalDateTime,无时区偏移}
securitySchemes:
InternalServiceToken:
type: apiKey
in: header
name: X-Internal-Service-Token
parameters:
TenantId:
name: tenantId
in: path
required: true
schema: {type: integer, format: int64, minimum: 1}
BusinessType:
name: businessType
in: path
required: true
schema:
type: string
enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT]
BusinessId:
name: businessId
in: path
required: true
schema: {type: string, pattern: '^[1-9][0-9]*$'}
FileId:
name: fileId
in: path
required: true
schema: {type: integer, format: int64, minimum: 1}
@@ -0,0 +1,103 @@
package com.baiyue.file.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.any;
class RelabelDocumentFileTest {
@TempDir Path root;
@ParameterizedTest
@EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT"})
void pdfPolicyChecksContentTypeSignatureAndSize(BusinessFileCategory category) {
BusinessFileStorage storage = storage();
byte[] exactLimit = new byte[10 * 1024 * 1024];
System.arraycopy("%PDF-1.7".getBytes(StandardCharsets.US_ASCII), 0, exactLimit, 0, 8);
var file = new MockMultipartFile("file", "label.pdf", "application/pdf", exactLimit);
var saved = storage.store(11L, "41", file, category);
assertThat(saved.size()).isEqualTo(10 * 1024 * 1024);
assertThat(saved.objectKey()).endsWith(".pdf");
assertThat(storage.read(saved.objectKey())).isEqualTo(exactLimit);
for (String type : new String[]{"image/png", "text/plain", "application/octet-stream"}) {
assertThatThrownBy(() -> storage.store(11L, "41",
new MockMultipartFile("file", "label.pdf", type, exactLimit), category))
.isInstanceOf(BizException.class).hasMessageContaining("仅支持PDF");
}
assertThatThrownBy(() -> storage.store(11L, "41",
new MockMultipartFile("file", "fake.pdf", "application/pdf", "not-pdf".getBytes()), category))
.isInstanceOf(BizException.class).hasMessageContaining("不是有效PDF");
assertThatThrownBy(() -> storage.store(11L, "41",
new MockMultipartFile("file", "empty.pdf", "application/pdf", new byte[0]), category))
.isInstanceOf(BizException.class).hasMessageContaining("不能为空");
assertThatThrownBy(() -> storage.store(11L, "41",
new MockMultipartFile("file", "large.pdf", "application/pdf", new byte[exactLimit.length + 1]), category))
.isInstanceOf(BizException.class).hasMessageContaining("10MB");
}
@ParameterizedTest
@EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT"})
void uploadAndReadRequireExactCustomerBusinessTypeBusinessAndFile(BusinessFileCategory category) throws Exception {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "relabel-test"), FileObjectEntity.class);
FileObjectMapper files = mock(FileObjectMapper.class);
InternalBusinessFileService service = new InternalBusinessFileService();
ReflectionTestUtils.setField(service, "files", files);
ReflectionTestUtils.setField(service, "storage", storage());
FileObjectEntity[] persisted = new FileObjectEntity[1];
when(files.insert(any(FileObjectEntity.class))).thenAnswer(call -> {
persisted[0] = call.getArgument(0); persisted[0].setId(801L); return 1;
});
var pdf = new MockMultipartFile("file", "label.pdf", "application/pdf", "%PDF-1.7".getBytes());
var saved = service.upload(11L, category.name(), "41", 7L, pdf);
assertThat(saved.ownerScope()).isEqualTo("CUSTOMER");
assertThat(saved.tenantId()).isEqualTo(11L);
assertThat(saved.businessType()).isEqualTo(category.name());
assertThat(saved.businessId()).isEqualTo("41");
when(files.selectOne(any())).thenAnswer(call -> {
LambdaQueryWrapper<FileObjectEntity> query = call.getArgument(0);
assertThat(query.getSqlSegment()).contains("tenant_id", "business_type", "business_id", "id", "status", "deleted");
return Set.copyOf(query.getParamNameValuePairs().values())
.equals(Set.of(11L, category.name(), "41", 801L, "ACTIVE", false)) ? persisted[0] : null;
});
assertThat(service.download(11L, category.name(), "41", 801L).content()).isEqualTo(pdf.getBytes());
assertThatThrownBy(() -> service.download(12L, category.name(), "41", 801L)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(11L, category.name(), "42", 801L)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(11L, category.name(), "41", 802L)).isInstanceOf(BizException.class);
String other = category == BusinessFileCategory.OVERSEAS_RELABEL_TOTAL_DOCUMENT
? "OVERSEAS_RELABEL_ITEM_DOCUMENT" : "OVERSEAS_RELABEL_TOTAL_DOCUMENT";
assertThatThrownBy(() -> service.download(11L, other, "41", 801L)).isInstanceOf(BizException.class);
clearInvocations(files);
for (long owner : new long[]{0L, -1L}) {
assertThatThrownBy(() -> service.upload(owner, category.name(), "41", 7L, pdf)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.metadata(owner, category.name(), "41", 801L)).isInstanceOf(BizException.class);
}
for (String id : new String[]{"0", "-1", "invalid", "9223372036854775808"}) {
assertThatThrownBy(() -> service.upload(11L, category.name(), id, 7L, pdf)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.metadata(11L, category.name(), id, 801L)).isInstanceOf(BizException.class);
}
verifyNoInteractions(files);
}
private BusinessFileStorage storage() {
BusinessFileStorage storage = new BusinessFileStorage();
ReflectionTestUtils.setField(storage, "configuredRoot", root);
storage.initializeRoot();
return storage;
}
}