diff --git a/file/AGENTS.md b/file/AGENTS.md index 8b9d22a1a..f0fe0b229 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -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 保存 | diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index cd5b1f630..ae0b8cc5a 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -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), diff --git a/file/src/main/resources/db/file.sql b/file/src/main/resources/db/file.sql index 7c11d291a..2a4a61cb0 100644 --- a/file/src/main/resources/db/file.sql +++ b/file/src/main/resources/db/file.sql @@ -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 '对象存储键', diff --git a/file/src/main/resources/openapi/overseas-exception-evidence-v1.yaml b/file/src/main/resources/openapi/overseas-exception-evidence-v1.yaml new file mode 100644 index 000000000..858cef8d5 --- /dev/null +++ b/file/src/main/resources/openapi/overseas-exception-evidence-v1.yaml @@ -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} diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index 4aeac5255..0c1f0ee6a 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -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("归属不匹配"); + } + } diff --git a/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java b/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java new file mode 100644 index 000000000..f2f826bf9 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java @@ -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); + } + } +}