diff --git a/file/AGENTS.md b/file/AGENTS.md index 1cc72b100..0a2318652 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -19,7 +19,7 @@ | 海外异常订单证据 | 通用内部业务文件入口;固定 `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 保存 | +| 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=0`(平台专属,客户可选快照不改变文件归属,拒绝客户作用域)、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 | | 拖车预约附件 | 通用内部业务文件入口;固定 `tenantId=0`、`businessType=TRUCK_APPOINTMENT_ATTACHMENT`、`businessId=appointmentId`,只允许派车单、司机/车辆资料和其他平台附件;提单文件归订舱 `OCEAN_BOOKING_DOCUMENT`,不得复制为拖车附件 | | 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` | | 派送单七类资料 | `src/main/resources/openapi/delivery-files-v1.yaml`;七类 `DELIVERY_*` 对象均按客户、派送内部ID、类别隔离,任意格式单文件50MiB,元数据返回真实createdAt;Lastleg持有分类绑定、保存、替换删除和历史可见性,File保留不可变对象 | 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 b94137b05..8602f9184 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -15,7 +15,7 @@ enum BusinessFileCategory { 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), - OCEAN_BOOKING_DOCUMENT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), + OCEAN_BOOKING_DOCUMENT(false, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), CUSTOMS_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CUSTOMS_RECEIPT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FINANCE_VOUCHER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), @@ -57,6 +57,9 @@ enum BusinessFileCategory { } void validateOwner(long tenantId) { + if (this == OCEAN_BOOKING_DOCUMENT && tenantId != 0) { + throw new BizException(ResultCode.BAD_REQUEST, "订舱附件必须归属平台"); + } if (customerOwned && tenantId <= 0) { throw new BizException(ResultCode.BAD_REQUEST, "该附件类型必须归属明确客户"); } diff --git a/file/src/main/resources/openapi/ocean-booking-file-v1.yaml b/file/src/main/resources/openapi/ocean-booking-file-v1.yaml index 044f0c694..dc3843701 100644 --- a/file/src/main/resources/openapi/ocean-booking-file-v1.yaml +++ b/file/src/main/resources/openapi/ocean-booking-file-v1.yaml @@ -3,9 +3,9 @@ info: title: Ocean Booking Document Internal API version: 1.0.0 description: >- - 订舱文件归属订舱聚合,不归属拖车。固定ownerScope=CUSTOMER、tenantId=订舱customerId、 + 订舱文件归属订舱聚合,不归属拖车。固定ownerScope=PLATFORM、tenantId=0、 businessType=OCEAN_BOOKING_DOCUMENT、businessId=oceanBookingId。BOOKING_CONFIRMATION与 - BILL_OF_LADING文档类型、版本和有效状态归Firstleg;File不判断订舱业务状态。 + BILL_OF_LADING文档类型、版本和有效状态归Firstleg;File不判断订舱业务状态。客户为可选快照,有值、为空或清空均不改变文件归属,客户作用域上传读取一律拒绝。 x-firstleg-document-types: [BOOKING_CONFIRMATION, BILL_OF_LADING] x-lifecycle-owner: FIRSTLEG x-file-object-semantics: IMMUTABLE_CREATE_ONLY @@ -70,8 +70,8 @@ components: name: tenantId in: path required: true - description: 订舱customerId - schema: {$ref: '#/components/schemas/Id'} + description: 平台固定0,与订舱客户快照无关 + schema: {type: string, enum: ['0']} OceanBookingId: name: oceanBookingId in: path @@ -100,10 +100,11 @@ components: required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes] properties: fileId: {$ref: '#/components/schemas/Id'} - ownerScope: {type: string, enum: [CUSTOMER]} + ownerScope: {type: string, enum: [PLATFORM]} tenantId: - allOf: [{$ref: '#/components/schemas/Id'}] - description: 订舱customerId + type: string + enum: ['0'] + description: 平台固定0,与订舱客户快照无关 businessType: {type: string, enum: [OCEAN_BOOKING_DOCUMENT]} businessId: allOf: [{$ref: '#/components/schemas/Id'}] diff --git a/file/src/test/java/com/baiyue/file/config/OceanBookingFileContractTest.java b/file/src/test/java/com/baiyue/file/config/OceanBookingFileContractTest.java index 33b12f2d8..724226afa 100644 --- a/file/src/test/java/com/baiyue/file/config/OceanBookingFileContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/OceanBookingFileContractTest.java @@ -9,13 +9,13 @@ import static org.assertj.core.api.Assertions.assertThat; class OceanBookingFileContractTest { @Test - void freezesInternalCustomerOwnedBookingDocumentContract() throws Exception { + void freezesInternalPlatformOwnedBookingDocumentContract() 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("ownerScope: {type: string, enum: [PLATFORM]}") + .contains("description: 平台固定0,与订舱客户快照无关") .contains("description: oceanBookingId") .contains("x-firstleg-document-types: [BOOKING_CONFIRMATION, BILL_OF_LADING]") .contains("x-lifecycle-owner: FIRSTLEG") diff --git a/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java b/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java index 9ec61dbce..fcc1536a2 100644 --- a/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java +++ b/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java @@ -59,11 +59,11 @@ class InternalBusinessFileControllerTest { @Test void unicodeSpecialFileNameUsesUtf8AttachmentDisposition() { String fileName = "订舱 确认 🚢 #[]()+,最终版.xlsx"; - when(service.download(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 93L)) + when(service.download(0L, "OCEAN_BOOKING_DOCUMENT", "61", 93L)) .thenReturn(new InternalBusinessFileService.Download(fileName, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{4})); - var response = controller.download("internal-token", 10001L, + var response = controller.download("internal-token", 0L, "OCEAN_BOOKING_DOCUMENT", "61", 93L); assertThat(response.getHeaders().getContentDisposition().getType()).isEqualTo("attachment"); 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 0c1f0ee6a..9da25ae57 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -75,46 +75,61 @@ class InternalBusinessFileServiceTest { verify(storage).store(11L, "51", file, BusinessFileCategory.OVERSEAS_UNLOAD_PHOTO); } - @Test void oceanBookingDocumentUsesCustomerAndBookingOwnership() { + @Test void oceanBookingDocumentUsesPlatformAndBookingOwnership() { MockMultipartFile file = new MockMultipartFile("file", "booking.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx".getBytes()); - when(storage.store(10001L, "61", file, BusinessFileCategory.OCEAN_BOOKING_DOCUMENT)) - .thenReturn(new BusinessFileStorage.Stored("business-files/10001/61/booking", + when(storage.store(0L, "61", file, BusinessFileCategory.OCEAN_BOOKING_DOCUMENT)) + .thenReturn(new BusinessFileStorage.Stored("business-files/0/61/booking", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", file.getSize(), "f".repeat(64), "booking.xlsx")); 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); + var metadata = service.upload(0L, "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("明确客户"); + .containsExactly("PLATFORM", 0L, "OCEAN_BOOKING_DOCUMENT", "61"); + assertThatThrownBy(() -> service.upload(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("必须归属平台"); } @Test void oceanBookingDocumentRejectsWrongCustomerBookingAndTruckOwnership() { - FileObjectEntity value = new FileObjectEntity(); value.setId(111L); value.setTenantId(10001L); + FileObjectEntity value = new FileObjectEntity(); value.setId(111L); value.setTenantId(0L); value.setBusinessType("OCEAN_BOOKING_DOCUMENT"); value.setBusinessId("61"); - value.setOriginalName("booking.xlsx"); value.setObjectKey("business-files/10001/61/booking"); + value.setOriginalName("booking.xlsx"); value.setObjectKey("business-files/0/61/booking"); value.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); value.setSha256("f".repeat(64)); value.setSizeBytes(8L); - when(files.selectOne(any())).thenReturn(value, null, null, null); + when(files.selectOne(any())).thenReturn(value, null, null); - assertThat(service.metadata(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 111L).ownerScope()) - .isEqualTo("CUSTOMER"); + assertThat(service.metadata(0L, "OCEAN_BOOKING_DOCUMENT", "61", 111L).ownerScope()) + .isEqualTo("PLATFORM"); 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(0L, "OCEAN_BOOKING_DOCUMENT", "62", 111L)) .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); assertThatThrownBy(() -> service.metadata(10001L, "TRUCK_APPOINTMENT_ATTACHMENT", "61", 111L)) .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); } + @Test void bookingFileScopeRemainsPlatformRegardlessOfOptionalCustomerSnapshot() { + FileObjectEntity value = new FileObjectEntity(); value.setId(111L); value.setTenantId(0L); + value.setBusinessType("OCEAN_BOOKING_DOCUMENT"); value.setBusinessId("61"); + value.setOriginalName("booking.bin"); value.setObjectKey("business-files/0/61/object"); + when(files.selectOne(any())).thenReturn(value); + when(storage.read(value.getObjectKey())).thenReturn(new byte[]{1, 2}); + // Customer snapshot is deliberately absent from File commands before and after clearing it. + assertThat(service.metadata(0L, "OCEAN_BOOKING_DOCUMENT", "61", 111L).ownerScope()).isEqualTo("PLATFORM"); + assertThat(service.download(0L, "OCEAN_BOOKING_DOCUMENT", "61", 111L).content()).containsExactly(1, 2); + assertThat(service.metadata(0L, "OCEAN_BOOKING_DOCUMENT", "61", 111L).fileId()).isEqualTo(111L); + assertThatThrownBy(() -> service.download(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 111L)) + .isInstanceOf(BizException.class).hasMessageContaining("必须归属平台"); + verify(storage, times(1)).read(value.getObjectKey()); + } + @Test void rejectsNegativeTenantAndInvalidOwnershipInputs() { MockMultipartFile file = new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes()); assertThatThrownBy(() -> service.upload(-1L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file)) @@ -269,7 +284,7 @@ class InternalBusinessFileServiceTest { @Test void concurrentRepeatedUploadsCreateDistinctImmutableObjectsForFirstlegToVersion() throws Exception { MockMultipartFile file = new MockMultipartFile("file", "booking.bin", "application/x-booking", new byte[]{1, 2, 3}); - when(storage.store(10001L, "61", file, BusinessFileCategory.OCEAN_BOOKING_DOCUMENT)) + when(storage.store(0L, "61", file, BusinessFileCategory.OCEAN_BOOKING_DOCUMENT)) .thenReturn(new BusinessFileStorage.Stored("business-files/10001/61/object", "application/x-booking", 3, "a".repeat(64), "booking.bin")); AtomicLong ids = new AtomicLong(200); @@ -280,9 +295,9 @@ class InternalBusinessFileServiceTest { var executor = Executors.newFixedThreadPool(2); try { var firstFuture = executor.submit(() -> - service.upload(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file)); + service.upload(0L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file)); var secondFuture = executor.submit(() -> - service.upload(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file)); + service.upload(0L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file)); var first = firstFuture.get(); var second = secondFuture.get();