feat(file): support seven delivery document categories

This commit is contained in:
2026-09-09 11:11:32 +08:00
parent cde8acf111
commit 3627239708
11 changed files with 240 additions and 3 deletions
+1
View File
@@ -22,6 +22,7 @@
| 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=订舱customerId`、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 | | 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=订舱customerId`、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 |
| 拖车预约附件 | 通用内部业务文件入口;固定 `tenantId=0`、`businessType=TRUCK_APPOINTMENT_ATTACHMENT`、`businessId=appointmentId`,只允许派车单、司机/车辆资料和其他平台附件;提单文件归订舱 `OCEAN_BOOKING_DOCUMENT`,不得复制为拖车附件 | | 拖车预约附件 | 通用内部业务文件入口;固定 `tenantId=0`、`businessType=TRUCK_APPOINTMENT_ATTACHMENT`、`businessId=appointmentId`,只允许派车单、司机/车辆资料和其他平台附件;提单文件归订舱 `OCEAN_BOOKING_DOCUMENT`,不得复制为拖车附件 |
| 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` | | 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
| 派送单七类资料 | `src/main/resources/openapi/delivery-files-v1.yaml`;七类 `DELIVERY_*` 对象均按客户、派送内部ID、类别隔离,任意格式单文件50MiB,元数据返回真实createdAt;Lastleg持有分类绑定、保存、替换删除和历史可见性,File保留不可变对象 |
| 下游 OCR | `client/WeightOcrClient.java` | | 下游 OCR | `client/WeightOcrClient.java` |
| SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` | | SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` |
| 测试/验证 | `src/test/java/com/baiyue/file/`;`mvn -pl file -am test` | | 测试/验证 | `src/test/java/com/baiyue/file/`;`mvn -pl file -am test` |
@@ -0,0 +1,32 @@
# 派送资料 File 交付及打印 BOL 核查
范围:测试工作区当前 test 分支;File/Print。未执行部署、真实 HTTP 或数据库操作。
## 能力矩阵
| 能力 | 状态与证据 |
|---|---|
| 七类上传 | 新增七类白名单,映射详见 `src/main/resources/openapi/delivery-files-v1.yaml`;任意格式,非空且每文件≤52428800字节 |
| 所有权与元数据 | 复用 InternalBusinessFileService:tenantId=真实客户>0、businessId=deliveryOrderId内部Long十进制字符串、businessType和fileId精确过滤;同时只读ACTIVE且未删除对象 |
| 上传时间 | Metadata增加真实持久化createdAt,LocalDateTime无偏移;已有其他通用业务文件OpenAPI同步字段 |
| 下载 | 内部服务令牌必需,复合归属查询成功后读取真实对象;attachment、UTF-8文件名、no-store |
| 替换与删除 | 新上传产生独立对象并保留旧内容;当前/历史引用、原子保存、版本和可见性归Lastleg。File未新增物理删除入口 |
| 类别计数 | Lastleg每类最多20文件,计数按已绑定非空类别x/7,不要求全部必传;File不复制绑定或状态 |
| 数据库 | 现有business_type VARCHAR(32)、business_id VARCHAR(64)、created_at足够,无结构变更或SQL执行 |
## 打印 BOL 现状
判定:Print通用渲染能力已实现,派送BOL业务打印链路未实现/待业务契约确认,未作环境验收。
- `print/src/main/java/com/baiyue/print/controller/InternalPdfController.java` 提供内部 inbound-marks、freight-a4-documents 两个入口,校验内部令牌,返回真实PDF、页数、Content-Disposition和no-store。
- `print/src/main/java/com/baiyue/print/dto/FreightA4PdfDtos.java` 仅定义海运托运书和提单确认件A4模板;没有派送BOL专用模板。
- `print/src/test/java/com/baiyue/print/service/FreightA4PdfServiceGenerationTest.java` 实际生成字节并用PDFBox解析页数、尺寸与业务字段。
- 定向检索 `lastleg-service/src/main/java/com/baiyue/lastleg/delivery/{controller,service}` 未发现调用Print的派送BOL生成链;当前BOL号和七类资料绑定不能视为生成打印能力。已上传BOL的鉴权下载也不等于生成派送BOL。
- 派送专属打印权限、状态门禁、字段映射、模板是否复用和输出入口尚不能由现有File/Print证明,本次未新增打印方案。其余五个按钮由Admin/后端16核查。
## 验证
- `mvn -pl file -am test`:File 200项,0失败/错误/跳过;其中新增DeliveryBusinessFileTest 21项,覆盖七类各自不可变上传、真实时间、50MiB边界和完整查询隔离。
- `mvn -pl print -am -Dtest=FreightA4PdfServiceGenerationTest,InternalPdfControllerTest -Dsurefire.failIfNoSpecifiedTests=false test`:10项通过,含PDFBox真实解析。没有把它作为派送业务打印已实现的证据。
- AGENTS索引合同未通过:现有柜费分摊金额守恒、整组操作契约两项索引缺失;不属于本次File变更。
- Lastleg绑定/替换删除后的用户可见性需由后端16专项测试及后续获准环境验收确认。
@@ -7,6 +7,13 @@ import java.util.Arrays;
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */ /** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
enum BusinessFileCategory { enum BusinessFileCategory {
DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_CUSTOMER_DOCUMENT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_BILL_OF_LADING(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_POD(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_EXPRESS_RECEIPT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
TRUCK_APPOINTMENT_ATTACHMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), TRUCK_APPOINTMENT_ATTACHMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OCEAN_BOOKING_DOCUMENT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), OCEAN_BOOKING_DOCUMENT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
CUSTOMS_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CUSTOMS_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
@@ -84,10 +84,11 @@ public class InternalBusinessFileService {
private static Metadata metadata(FileObjectEntity value) { private static Metadata metadata(FileObjectEntity value) {
return new Metadata(value.getId(), value.getTenantId() == 0 ? "PLATFORM" : "CUSTOMER", return new Metadata(value.getId(), value.getTenantId() == 0 ? "PLATFORM" : "CUSTOMER",
value.getTenantId(), value.getBusinessType(), value.getBusinessId(), value.getTenantId(), value.getBusinessType(), value.getBusinessId(),
value.getOriginalName(), value.getSha256(), value.getContentType(), value.getSizeBytes()); value.getOriginalName(), value.getSha256(), value.getContentType(), value.getSizeBytes(), value.getCreatedAt());
} }
public record Metadata(Long fileId, String ownerScope, Long tenantId, String businessType, String businessId, public record Metadata(Long fileId, String ownerScope, Long tenantId, String businessType, String businessId,
String fileName, String sha256, String contentType, Long sizeBytes) {} String fileName, String sha256, String contentType, Long sizeBytes,
LocalDateTime createdAt) {}
public record Download(String fileName, String contentType, byte[] content) {} public record Download(String fileName, String contentType, byte[] content) {}
} }
@@ -118,6 +118,7 @@ components:
sha256: {type: string} sha256: {type: string}
contentType: {type: string} contentType: {type: string}
sizeBytes: {type: integer, format: int64, maximum: 52428800} sizeBytes: {type: integer, format: int64, maximum: 52428800}
createdAt: {type: string, description: 持久化上传时间LocalDateTime,无时区偏移}
ExportBatchRequest: ExportBatchRequest:
type: object type: object
required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit] required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit]
@@ -0,0 +1,101 @@
openapi: 3.0.3
info:
title: 派送单七类资料 File 内部契约
version: 1.0.0
description: >-
tenantId为派送单所属客户,businessId为deliveryOrderId内部Long的十进制字符串,不是派送单号或BOL号。
任意格式,非空单文件最多50MiB(52428800字节)。每类最多20文件由Lastleg控制,七类不强制必传。
上传只创建不可变对象,不自动绑定。Lastleg原子保存分类绑定与版本,替换/删除仅解绑并保留历史证据,
用户下载前由Lastleg检查当前引用或获准历史引用和权限,再调用File精确归属下载。不得按BOL号跨客户复用。
x-category-mapping:
BOL: DELIVERY_BOL
CUSTOMER_DOCUMENT: DELIVERY_CUSTOMER_DOCUMENT
BILL_OF_LADING: DELIVERY_BILL_OF_LADING
POD: DELIVERY_POD
DELIVERY_PHOTO: DELIVERY_PHOTO
LOADING_PHOTO: DELIVERY_LOADING_PHOTO
EXPRESS_RECEIPT: DELIVERY_EXPRESS_RECEIPT
paths:
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}:
parameters:
- {$ref: '#/components/parameters/Token'}
- {$ref: '#/components/parameters/Tenant'}
- {$ref: '#/components/parameters/Type'}
- {$ref: '#/components/parameters/Business'}
post:
summary: 上传派送资料不可变对象
parameters:
- {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': {$ref: '#/components/responses/Metadata'}
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}:
parameters:
- {$ref: '#/components/parameters/Token'}
- {$ref: '#/components/parameters/Tenant'}
- {$ref: '#/components/parameters/Type'}
- {$ref: '#/components/parameters/Business'}
- {$ref: '#/components/parameters/File'}
get:
summary: 校验客户、派送单、类别和文件ID,读取有效未删除文件元数据
responses:
'200': {$ref: '#/components/responses/Metadata'}
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content:
parameters:
- {$ref: '#/components/parameters/Token'}
- {$ref: '#/components/parameters/Tenant'}
- {$ref: '#/components/parameters/Type'}
- {$ref: '#/components/parameters/Business'}
- {$ref: '#/components/parameters/File'}
get:
summary: 按完整归属下载;内部令牌必需,不提供匿名下载
responses:
'200':
description: 二进制成功响应;失败按统一JSON code判断
headers:
Cache-Control: {schema: {type: string, enum: [no-store]}}
Content-Disposition: {schema: {type: string}, description: attachment及UTF-8原文件名}
components:
parameters:
Token: {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}}
Tenant: {name: tenantId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
Business: {name: businessId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
File: {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
Type:
name: businessType
in: path
required: true
schema:
type: string
enum: [DELIVERY_BOL, DELIVERY_CUSTOMER_DOCUMENT, DELIVERY_BILL_OF_LADING, DELIVERY_POD, DELIVERY_PHOTO, DELIVERY_LOADING_PHOTO, DELIVERY_EXPRESS_RECEIPT]
responses:
Metadata:
description: 业务成功/失败使用HTTP200,成功code为字符串200;无效内部令牌拒绝
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data:
type: object
properties:
fileId: {type: string}
ownerScope: {type: string, enum: [CUSTOMER]}
tenantId: {type: string}
businessType: {type: string}
businessId: {type: string}
fileName: {type: string}
sha256: {type: string}
contentType: {type: string}
sizeBytes: {type: integer, format: int64, maximum: 52428800}
createdAt: {type: string, description: 持久化created_at原值,LocalDateTime格式yyyy-MM-ddTHH:mm:ss,无时区偏移}
@@ -112,3 +112,4 @@ components:
sha256: {type: string, pattern: '^[0-9a-f]{64}$'} sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
contentType: {type: string} contentType: {type: string}
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 52428800} sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 52428800}
createdAt: {type: string, description: 持久化上传时间LocalDateTime,无时区偏移}
@@ -134,3 +134,4 @@ components:
sha256: {type: string, pattern: '^[0-9a-f]{64}$'} sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
contentType: {type: string, enum: [application/vnd.openxmlformats-officedocument.spreadsheetml.sheet]} contentType: {type: string, enum: [application/vnd.openxmlformats-officedocument.spreadsheetml.sheet]}
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 5242880} sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 5242880}
createdAt: {type: string, description: 持久化上传时间LocalDateTime,无时区偏移}
@@ -107,3 +107,4 @@ components:
sha256: {type: string, pattern: '^[0-9a-f]{64}$'} sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
contentType: {type: string} contentType: {type: string}
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760} sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760}
createdAt: {type: string, description: 持久化上传时间LocalDateTime,无时区偏移}
@@ -24,7 +24,7 @@ class InternalBusinessFileControllerTest {
@Test void zeroTenantUploadAndMetadataRemainBehindInternalToken() { @Test void zeroTenantUploadAndMetadataRemainBehindInternalToken() {
var file = new MockMultipartFile("file", "clearance.pdf", "application/pdf", "%PDF-1.4".getBytes()); var file = new MockMultipartFile("file", "clearance.pdf", "application/pdf", "%PDF-1.4".getBytes());
var metadata = new InternalBusinessFileService.Metadata(81L, "PLATFORM", 0L, "CONTAINER_CLEARANCE_DOCUMENT", var metadata = new InternalBusinessFileService.Metadata(81L, "PLATFORM", 0L, "CONTAINER_CLEARANCE_DOCUMENT",
"41", "clearance.pdf", "hash", "application/pdf", file.getSize()); "41", "clearance.pdf", "hash", "application/pdf", file.getSize(), java.time.LocalDateTime.of(2026, 9, 9, 12, 0));
when(service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file)).thenReturn(metadata); when(service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file)).thenReturn(metadata);
when(service.metadata(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 81L)).thenReturn(metadata); when(service.metadata(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 81L)).thenReturn(metadata);
@@ -0,0 +1,91 @@
package com.baiyue.file.service;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import com.baiyue.file.controller.InternalBusinessFileController;
import com.baiyue.common.core.exception.BizException;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class DeliveryBusinessFileTest {
@TempDir Path root;
@BeforeAll static void metadata() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "delivery-files"), FileObjectEntity.class);
}
@ParameterizedTest
@ValueSource(strings={"DELIVERY_BOL","DELIVERY_CUSTOMER_DOCUMENT","DELIVERY_BILL_OF_LADING","DELIVERY_POD","DELIVERY_PHOTO","DELIVERY_LOADING_PHOTO","DELIVERY_EXPRESS_RECEIPT"})
void immutableUploadsPreserveOldContentAndRealTimestamp(String type) {
var storage = storage();
var files = mock(FileObjectMapper.class);
var service = service(files, storage);
var ids = new AtomicLong(800);
when(files.insert(any(FileObjectEntity.class))).thenAnswer(call -> {
FileObjectEntity row = call.getArgument(0); row.setId(ids.incrementAndGet()); return 1;
});
var first = service.upload(11, type, "71", 9, new MockMultipartFile("file", "资料 🚢.bin", "application/octet-stream", new byte[]{0,1,2}));
var second = service.upload(11, type, "71", 9, new MockMultipartFile("file", "资料 🚢.bin", "application/octet-stream", new byte[]{3,4}));
var captor = org.mockito.ArgumentCaptor.forClass(FileObjectEntity.class);
verify(files, times(2)).insert(captor.capture());
assertThat(first.fileId()).isNotEqualTo(second.fileId());
assertThat(first.createdAt()).isEqualTo(captor.getAllValues().get(0).getCreatedAt()).isNotNull();
assertThat(first.ownerScope()).isEqualTo("CUSTOMER");
assertThat(storage.read(captor.getAllValues().get(0).getObjectKey())).containsExactly(0,1,2);
assertThat(storage.read(captor.getAllValues().get(1).getObjectKey())).containsExactly(3,4);
verify(files, never()).updateById(any(FileObjectEntity.class));
}
@ParameterizedTest
@ValueSource(strings={"DELIVERY_BOL","DELIVERY_CUSTOMER_DOCUMENT","DELIVERY_BILL_OF_LADING","DELIVERY_POD","DELIVERY_PHOTO","DELIVERY_LOADING_PHOTO","DELIVERY_EXPRESS_RECEIPT"})
void enforcesFiftyMibAndNonemptyBoundary(String type) throws Exception {
var storage = storage(); var category = BusinessFileCategory.require(type);
assertThatThrownBy(() -> storage.store(11,"71",new MockMultipartFile("file",new byte[0]),category)).isInstanceOf(BizException.class);
var file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false); when(file.getSize()).thenReturn(52428801L);
assertThatThrownBy(() -> storage.store(11,"71",file,category)).isInstanceOf(BizException.class).hasMessageContaining("50MB");
when(file.getSize()).thenReturn(52428800L); when(file.getContentType()).thenReturn("application/x-custom");
when(file.getOriginalFilename()).thenReturn("boundary.custom");
doAnswer(call -> { try (var out=new java.io.RandomAccessFile(((Path)call.getArgument(0)).toFile(),"rw")) { out.setLength(52428800L); } return null; }).when(file).transferTo(any(Path.class));
var stored=storage.store(11,"71",file,category);
assertThat(java.nio.file.Files.size(root.resolve(stored.objectKey()))).isEqualTo(52428800L);
}
@ParameterizedTest
@ValueSource(strings={"DELIVERY_BOL","DELIVERY_CUSTOMER_DOCUMENT","DELIVERY_BILL_OF_LADING","DELIVERY_POD","DELIVERY_PHOTO","DELIVERY_LOADING_PHOTO","DELIVERY_EXPRESS_RECEIPT"})
void downloadQueriesEveryOwnershipKeyAndRejectsUnauthorizedAccess(String type) {
var files=mock(FileObjectMapper.class); var storage=mock(BusinessFileStorage.class); var service=service(files,storage);
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");
assertThat(query.getParamNameValuePairs().values()).containsExactlyInAnyOrder(12L,type,"72",802L,"ACTIVE",false);
return null;
});
assertThatThrownBy(() -> service.download(12,type,"72",802)).isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
assertThatThrownBy(() -> service.metadata(0,type,"71",801)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.metadata(11,type,"not-an-id",801)).isInstanceOf(BizException.class);
var controller=new InternalBusinessFileController();
ReflectionTestUtils.setField(controller,"expected","test-internal"); ReflectionTestUtils.setField(controller,"service",service);
assertThatThrownBy(() -> controller.download("bad",11,type,"71",801)).isInstanceOf(BizException.class);
verifyNoInteractions(storage);
}
private BusinessFileStorage storage() {
var storage=new BusinessFileStorage(); ReflectionTestUtils.setField(storage,"configuredRoot",root); storage.initializeRoot(); return storage;
}
private InternalBusinessFileService service(FileObjectMapper files, BusinessFileStorage storage) {
var service=new InternalBusinessFileService(); ReflectionTestUtils.setField(service,"files",files); ReflectionTestUtils.setField(service,"storage",storage); return service;
}
}