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
@@ -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;
}
}