feat(file-print): support ocean booking documents
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 精确校验 |
|
||||
| 报关源文件、完成文件与买单明细导出 | `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`,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 |
|
||||
| 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
|
||||
| 下游 OCR | `client/WeightOcrClient.java` |
|
||||
| SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` |
|
||||
|
||||
@@ -8,6 +8,7 @@ import java.util.Arrays;
|
||||
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
|
||||
enum BusinessFileCategory {
|
||||
TRUCK_APPOINTMENT_ATTACHMENT(false, false),
|
||||
OCEAN_BOOKING_DOCUMENT(true, false),
|
||||
CUSTOMS_DOCUMENT(false, false),
|
||||
CUSTOMS_RECEIPT(false, false),
|
||||
FINANCE_VOUCHER(false, false),
|
||||
|
||||
@@ -93,11 +93,12 @@ public class InternalBusinessFileService {
|
||||
}
|
||||
|
||||
private static Metadata metadata(FileObjectEntity value) {
|
||||
return new Metadata(value.getId(), value.getTenantId(), value.getBusinessType(), value.getBusinessId(),
|
||||
return new Metadata(value.getId(), value.getTenantId() == 0 ? "PLATFORM" : "CUSTOMER",
|
||||
value.getTenantId(), value.getBusinessType(), value.getBusinessId(),
|
||||
value.getOriginalName(), value.getSha256(), value.getContentType(), value.getSizeBytes());
|
||||
}
|
||||
|
||||
public record Metadata(Long fileId, 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) {}
|
||||
public record Download(String fileName, String contentType, byte[] content) {}
|
||||
}
|
||||
|
||||
@@ -107,9 +107,10 @@ components:
|
||||
data: {$ref: '#/components/schemas/BusinessFileMetadata'}
|
||||
BusinessFileMetadata:
|
||||
type: object
|
||||
required: [fileId, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes]
|
||||
required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes]
|
||||
properties:
|
||||
fileId: {type: string, description: File不可变对象ID,跨前端按字符串传输}
|
||||
ownerScope: {type: string, enum: [CUSTOMER]}
|
||||
tenantId: {type: string, description: CUSTOMER owner scope客户ID}
|
||||
businessType: {type: string, description: fileCategory}
|
||||
businessId: {type: string, description: loadingPlanId}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Ocean Booking Document Internal API
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
订舱文件归属订舱聚合,不归属拖车。固定ownerScope=CUSTOMER、tenantId=订舱customerId、
|
||||
businessType=OCEAN_BOOKING_DOCUMENT、businessId=oceanBookingId。BOOKING_CONFIRMATION与
|
||||
BILL_OF_LADING文档类型、版本和有效状态归Firstleg;File不判断订舱业务状态。
|
||||
x-firstleg-document-types: [BOOKING_CONFIRMATION, BILL_OF_LADING]
|
||||
paths:
|
||||
/internal/file/v1/business-files/{tenantId}/OCEAN_BOOKING_DOCUMENT/{oceanBookingId}:
|
||||
post:
|
||||
summary: Firstleg上传订舱附件
|
||||
security: [{internalServiceToken: []}]
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/OperatorId'}
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
- {$ref: '#/components/parameters/OceanBookingId'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
description: TXT/PDF/JPG/PNG,最大10MiB
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Metadata'}
|
||||
/internal/file/v1/business-files/{tenantId}/OCEAN_BOOKING_DOCUMENT/{oceanBookingId}/{fileId}:
|
||||
get:
|
||||
summary: Firstleg校验订舱附件元数据
|
||||
security: [{internalServiceToken: []}]
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
- {$ref: '#/components/parameters/OceanBookingId'}
|
||||
- {$ref: '#/components/parameters/FileId'}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Metadata'}
|
||||
/internal/file/v1/business-files/{tenantId}/OCEAN_BOOKING_DOCUMENT/{oceanBookingId}/{fileId}/content:
|
||||
get:
|
||||
summary: Firstleg下载订舱附件
|
||||
security: [{internalServiceToken: []}]
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
- {$ref: '#/components/parameters/OceanBookingId'}
|
||||
- {$ref: '#/components/parameters/FileId'}
|
||||
responses:
|
||||
'200':
|
||||
description: 附件内容;错误订舱ID、拖车业务类型或文件ID不得命中
|
||||
headers:
|
||||
Cache-Control: {schema: {type: string, enum: [no-store]}}
|
||||
content:
|
||||
application/octet-stream: {schema: {type: string, format: binary}}
|
||||
components:
|
||||
securitySchemes:
|
||||
internalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token}
|
||||
parameters:
|
||||
OperatorId:
|
||||
name: X-Operator-Id
|
||||
in: header
|
||||
required: true
|
||||
schema: {$ref: '#/components/schemas/Id'}
|
||||
TenantId:
|
||||
name: tenantId
|
||||
in: path
|
||||
required: true
|
||||
description: 订舱customerId
|
||||
schema: {$ref: '#/components/schemas/Id'}
|
||||
OceanBookingId:
|
||||
name: oceanBookingId
|
||||
in: path
|
||||
required: true
|
||||
schema: {$ref: '#/components/schemas/Id'}
|
||||
FileId:
|
||||
name: fileId
|
||||
in: path
|
||||
required: true
|
||||
schema: {$ref: '#/components/schemas/Id'}
|
||||
responses:
|
||||
Metadata:
|
||||
description: 精确订舱归属元数据
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [code, data]
|
||||
properties:
|
||||
code: {type: string, enum: ['200']}
|
||||
data: {$ref: '#/components/schemas/Metadata'}
|
||||
schemas:
|
||||
Id: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
Metadata:
|
||||
type: object
|
||||
required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes]
|
||||
properties:
|
||||
fileId: {$ref: '#/components/schemas/Id'}
|
||||
ownerScope: {type: string, enum: [CUSTOMER]}
|
||||
tenantId:
|
||||
allOf: [{$ref: '#/components/schemas/Id'}]
|
||||
description: 订舱customerId
|
||||
businessType: {type: string, enum: [OCEAN_BOOKING_DOCUMENT]}
|
||||
businessId:
|
||||
allOf: [{$ref: '#/components/schemas/Id'}]
|
||||
description: oceanBookingId
|
||||
fileName: {type: string}
|
||||
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
contentType: {type: string, enum: [text/plain, application/pdf, image/jpeg, image/png]}
|
||||
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.file.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OceanBookingFileContractTest {
|
||||
@Test
|
||||
void freezesInternalCustomerOwnedBookingDocumentContract() throws Exception {
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-booking-file-v1.yaml"));
|
||||
|
||||
assertThat(yaml)
|
||||
.contains("/internal/file/v1/business-files/{tenantId}/OCEAN_BOOKING_DOCUMENT/{oceanBookingId}:")
|
||||
.contains("ownerScope: {type: string, enum: [CUSTOMER]}")
|
||||
.contains("description: 订舱customerId")
|
||||
.contains("description: oceanBookingId")
|
||||
.contains("x-firstleg-document-types: [BOOKING_CONFIRMATION, BILL_OF_LADING]")
|
||||
.contains("name: X-Internal-Service-Token", "Cache-Control")
|
||||
.doesNotContain("TRUCK_APPOINTMENT_ATTACHMENT", "/api/platform/", "/api/merchant/");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -23,7 +23,7 @@ class InternalBusinessFileControllerTest {
|
||||
|
||||
@Test void zeroTenantUploadAndMetadataRemainBehindInternalToken() {
|
||||
var file = new MockMultipartFile("file", "clearance.pdf", "application/pdf", "%PDF-1.4".getBytes());
|
||||
var metadata = new InternalBusinessFileService.Metadata(81L, 0L, "CONTAINER_CLEARANCE_DOCUMENT",
|
||||
var metadata = new InternalBusinessFileService.Metadata(81L, "PLATFORM", 0L, "CONTAINER_CLEARANCE_DOCUMENT",
|
||||
"41", "clearance.pdf", "hash", "application/pdf", file.getSize());
|
||||
when(service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file)).thenReturn(metadata);
|
||||
when(service.metadata(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 81L)).thenReturn(metadata);
|
||||
|
||||
@@ -69,6 +69,44 @@ class InternalBusinessFileServiceTest {
|
||||
verify(storage).store(11L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false);
|
||||
}
|
||||
|
||||
@Test void oceanBookingDocumentUsesCustomerAndBookingOwnership() {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "booking.pdf", "application/pdf",
|
||||
"%PDF-1.4".getBytes());
|
||||
when(storage.store(10001L, 61L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false))
|
||||
.thenReturn(new BusinessFileStorage.Stored("business-files/10001/61/booking.pdf",
|
||||
"application/pdf", file.getSize(), "f".repeat(64), "booking.pdf"));
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
|
||||
((FileObjectEntity) invocation.getArgument(0)).setId(111L); return 1;
|
||||
});
|
||||
|
||||
var metadata = service.upload(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file);
|
||||
|
||||
assertThat(metadata).extracting(InternalBusinessFileService.Metadata::ownerScope,
|
||||
InternalBusinessFileService.Metadata::tenantId,
|
||||
InternalBusinessFileService.Metadata::businessType,
|
||||
InternalBusinessFileService.Metadata::businessId)
|
||||
.containsExactly("CUSTOMER", 10001L, "OCEAN_BOOKING_DOCUMENT", "61");
|
||||
assertThatThrownBy(() -> service.upload(0L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
|
||||
}
|
||||
|
||||
@Test void oceanBookingDocumentRejectsWrongCustomerBookingAndTruckOwnership() {
|
||||
FileObjectEntity value = new FileObjectEntity(); value.setId(111L); value.setTenantId(10001L);
|
||||
value.setBusinessType("OCEAN_BOOKING_DOCUMENT"); value.setBusinessId("61");
|
||||
value.setOriginalName("booking.pdf"); value.setObjectKey("business-files/10001/61/booking.pdf");
|
||||
value.setContentType("application/pdf"); value.setSha256("f".repeat(64)); value.setSizeBytes(8L);
|
||||
when(files.selectOne(any())).thenReturn(value, null, null, null);
|
||||
|
||||
assertThat(service.metadata(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 111L).ownerScope())
|
||||
.isEqualTo("CUSTOMER");
|
||||
assertThatThrownBy(() -> service.metadata(10002L, "OCEAN_BOOKING_DOCUMENT", "61", 111L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
assertThatThrownBy(() -> service.metadata(10001L, "OCEAN_BOOKING_DOCUMENT", "62", 111L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
assertThatThrownBy(() -> service.metadata(10001L, "TRUCK_APPOINTMENT_ATTACHMENT", "61", 111L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
}
|
||||
|
||||
@Test void rejectsNegativeTenantAndInvalidOwnershipInputs() {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes());
|
||||
assertThatThrownBy(() -> service.upload(-1L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file))
|
||||
|
||||
Reference in New Issue
Block a user