feat(file): support overseas exception evidence
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
| 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` |
|
||||
| 装柜与业务文件证据 | `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 |
|
||||
| 入库扣货凭证 | 通用内部业务文件入口;固定 `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=订舱customerId`、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 |
|
||||
|
||||
@@ -21,6 +21,7 @@ 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_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),
|
||||
ORDER_BATCH_IMPORT_SOURCE(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO),
|
||||
|
||||
@@ -6,7 +6,7 @@ USE baiyue_file;
|
||||
CREATE TABLE IF NOT EXISTS file_object (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '所有者范围键:0为平台,正整数为客户ID',
|
||||
business_type VARCHAR(32) NULL COMMENT '关联业务类型',
|
||||
business_type VARCHAR(32) NULL COMMENT '关联业务类型,海外异常证据固定为OVERSEAS_EXCEPTION_EVIDENCE',
|
||||
business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID;装柜证据固定为物理containerBookingId',
|
||||
original_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
|
||||
object_key VARCHAR(512) NOT NULL COMMENT '对象存储键',
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 海外异常订单证据文件内部契约
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
File 只保存文件对象并校验复合归属;异常订单状态、完成门禁和证据引用由 Lastleg 持有。
|
||||
所有接口仅接受内部服务令牌,业务失败按统一响应体 code 判断。
|
||||
paths:
|
||||
/internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}:
|
||||
post:
|
||||
summary: 上传海外异常订单证据
|
||||
description: >-
|
||||
tenantId 固定为异常订单所属客户ID,exceptionOrderId 固定为异常订单内部ID。
|
||||
单文件最大10MiB,仅支持TXT、PDF、JPG、PNG;File不校验异常状态。
|
||||
security: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/InternalToken'
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/ExceptionOrderId'
|
||||
- {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':
|
||||
description: 返回文件元数据
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/MetadataEnvelope'}
|
||||
'403': {description: 内部服务令牌无效}
|
||||
/internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}/{fileId}:
|
||||
get:
|
||||
summary: 校验并读取海外异常订单证据元数据
|
||||
description: 必须同时匹配客户ID、异常订单ID、文件类型与文件ID,否则返回不存在或归属不匹配。
|
||||
security: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/InternalToken'
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/ExceptionOrderId'
|
||||
- $ref: '#/components/parameters/FileId'
|
||||
responses:
|
||||
'200':
|
||||
description: 返回匹配复合归属的文件元数据
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/MetadataEnvelope'}
|
||||
'403': {description: 内部服务令牌无效}
|
||||
/internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}/{fileId}/content:
|
||||
get:
|
||||
summary: 下载海外异常订单证据内容
|
||||
description: 下载前执行与元数据接口相同的复合归属校验,响应禁止缓存。
|
||||
security: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/InternalToken'
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/ExceptionOrderId'
|
||||
- $ref: '#/components/parameters/FileId'
|
||||
responses:
|
||||
'200': {description: 文件二进制;携带Content-Disposition并设置Cache-Control no-store}
|
||||
'403': {description: 内部服务令牌无效}
|
||||
components:
|
||||
parameters:
|
||||
InternalToken:
|
||||
name: X-Internal-Service-Token
|
||||
in: header
|
||||
required: true
|
||||
schema: {type: string}
|
||||
TenantId:
|
||||
name: tenantId
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
ExceptionOrderId:
|
||||
name: exceptionOrderId
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
FileId:
|
||||
name: fileId
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
schemas:
|
||||
MetadataEnvelope:
|
||||
type: object
|
||||
required: [code, data]
|
||||
properties:
|
||||
code: {type: string, example: '200'}
|
||||
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_EXCEPTION_EVIDENCE]}
|
||||
businessId: {type: string, pattern: '^[1-9][0-9]*$', description: 异常订单内部ID}
|
||||
fileName: {type: string}
|
||||
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
contentType: {type: string}
|
||||
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760}
|
||||
@@ -315,4 +315,45 @@ class InternalBusinessFileServiceTest {
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
|
||||
}
|
||||
|
||||
@Test void overseasExceptionEvidenceUsesExactCustomerAndExceptionOwnership() {
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "异常处理证据.pdf", "application/pdf", "%PDF-1.4".getBytes());
|
||||
when(storage.store(10001L, "701", file, BusinessFileCategory.OVERSEAS_EXCEPTION_EVIDENCE))
|
||||
.thenReturn(new BusinessFileStorage.Stored("business-files/10001/701/evidence.pdf",
|
||||
"application/pdf", file.getSize(), "b".repeat(64), "异常处理证据.pdf"));
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
|
||||
((FileObjectEntity) invocation.getArgument(0)).setId(801L);
|
||||
return 1;
|
||||
});
|
||||
|
||||
var result = service.upload(10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 7L, file);
|
||||
|
||||
assertThat(result).extracting(InternalBusinessFileService.Metadata::ownerScope,
|
||||
InternalBusinessFileService.Metadata::tenantId,
|
||||
InternalBusinessFileService.Metadata::businessType,
|
||||
InternalBusinessFileService.Metadata::businessId)
|
||||
.containsExactly("CUSTOMER", 10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "701");
|
||||
verify(storage).store(10001L, "701", file, BusinessFileCategory.OVERSEAS_EXCEPTION_EVIDENCE);
|
||||
assertThatThrownBy(() -> service.upload(0L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 7L, file))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
|
||||
}
|
||||
|
||||
@Test void overseasExceptionEvidenceMetadataFailsClosedForEveryOwnershipMismatch() {
|
||||
FileObjectEntity value = new FileObjectEntity();
|
||||
value.setId(801L); value.setTenantId(10001L);
|
||||
value.setBusinessType("OVERSEAS_EXCEPTION_EVIDENCE"); value.setBusinessId("701");
|
||||
value.setOriginalName("evidence.pdf"); value.setObjectKey("business-files/10001/701/evidence.pdf");
|
||||
value.setContentType("application/pdf"); value.setSha256("b".repeat(64)); value.setSizeBytes(8L);
|
||||
when(files.selectOne(any())).thenReturn(value, null, null, null);
|
||||
|
||||
assertThat(service.metadata(10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 801L).fileId())
|
||||
.isEqualTo(801L);
|
||||
assertThatThrownBy(() -> service.metadata(10002L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 801L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
assertThatThrownBy(() -> service.metadata(10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "702", 801L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
assertThatThrownBy(() -> service.metadata(10001L, "INBOUND_HOLD_EVIDENCE", "701", 801L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OverseasExceptionEvidenceContractTest {
|
||||
@Test
|
||||
void categoryAndOpenApiFreezeCustomerExceptionOwnership() throws Exception {
|
||||
BusinessFileCategory category = BusinessFileCategory.require("OVERSEAS_EXCEPTION_EVIDENCE");
|
||||
assertThat(category.maxSizeBytes()).isEqualTo(10L * 1024 * 1024);
|
||||
assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.STANDARD);
|
||||
|
||||
try (var stream = getClass().getResourceAsStream(
|
||||
"/openapi/overseas-exception-evidence-v1.yaml")) {
|
||||
assertThat(stream).isNotNull();
|
||||
String yaml = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
assertThat(yaml).contains(
|
||||
"/internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}:",
|
||||
"ownerScope: {type: string, enum: [CUSTOMER]}",
|
||||
"businessType: {type: string, enum: [OVERSEAS_EXCEPTION_EVIDENCE]}",
|
||||
"maximum: 10485760");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void initializationSqlHasEnoughWidthAndDocumentsTheCategory() throws Exception {
|
||||
try (var stream = getClass().getResourceAsStream("/db/file.sql")) {
|
||||
assertThat(stream).isNotNull();
|
||||
String sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
assertThat(sql).contains("business_type VARCHAR(32)", "OVERSEAS_EXCEPTION_EVIDENCE");
|
||||
assertThat("OVERSEAS_EXCEPTION_EVIDENCE").hasSizeLessThanOrEqualTo(32);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user