fix(lastleg): complete relabel document contract and regression gates
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user