From 655237e37fbab8c9806c5c824dc0a87e12edf2cd Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 12:54:14 +0800 Subject: [PATCH] fix(lastleg): complete relabel document contract and regression gates --- file/AGENTS.md | 1 + .../file/service/BusinessFileCategory.java | 4 +- .../file/service/BusinessFileStorage.java | 4 + .../openapi/overseas-relabel-document-v1.yaml | 125 ++++++++++++++++++ .../file/service/RelabelDocumentFileTest.java | 103 +++++++++++++++ .../openapi/carrier-schedule-query-v1.yaml | 2 +- .../resources/openapi/notification-v1.yaml | 2 +- .../RelabelApplicationController.java | 4 +- .../HttpRelabelDocumentFileGateway.java | 4 +- .../service/RelabelApplicationService.java | 81 +++++++++++- .../openapi/overseas-business-v1.yaml | 44 +++++- .../RelabelApplicationControllerTest.java | 27 ++++ .../RelabelApplicationServiceTest.java | 4 +- .../service/RelabelDocumentServiceTest.java | 75 +++++++++++ scripts/db/incremental.sql | 6 + scripts/java-quality-baseline.json | 2 +- scripts/java-quality-summary.test.mjs | 3 +- scripts/synchronous-dependency-baseline.mjs | 2 + .../synchronous-dependency-contract.test.mjs | 4 +- 19 files changed, 474 insertions(+), 23 deletions(-) create mode 100644 file/src/main/resources/openapi/overseas-relabel-document-v1.yaml create mode 100644 file/src/test/java/com/baiyue/file/service/RelabelDocumentFileTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelDocumentServiceTest.java diff --git a/file/AGENTS.md b/file/AGENTS.md index a9deab837..bfa4e15d0 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -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 精确校验 | | 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg | +| 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,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=0`(平台专属,客户可选快照不改变文件归属,拒绝客户作用域)、`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 73613938b..a0caa102f 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -24,6 +24,8 @@ 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_RELABEL_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, 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), @@ -86,7 +88,7 @@ enum BusinessFileCategory { return Long.toString(value); } - enum FormatPolicy { STANDARD, ARBITRARY, XLSX } + enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF } private enum BusinessIdPolicy { POSITIVE_LONG, ORDER_BATCH_NO } private static final class Limits { private static final long STANDARD = 10L * 1024 * 1024; diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java index da478ec75..764b21e53 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java @@ -55,6 +55,10 @@ public class BusinessFileStorage { String extension; if (formatPolicy == BusinessFileCategory.FormatPolicy.ARBITRARY) { extension = ""; + } else if (formatPolicy == BusinessFileCategory.FormatPolicy.PDF) { + if (!"application/pdf".equals(type)) throw bad("附件仅支持PDF"); + verifyContent(file, type); + extension = ".pdf"; } else if (formatPolicy == BusinessFileCategory.FormatPolicy.XLSX) { if (!XLSX_TYPE.equals(type)) throw bad("附件仅支持XLSX"); verifyXlsx(file); diff --git a/file/src/main/resources/openapi/overseas-relabel-document-v1.yaml b/file/src/main/resources/openapi/overseas-relabel-document-v1.yaml new file mode 100644 index 000000000..9444e9a4d --- /dev/null +++ b/file/src/main/resources/openapi/overseas-relabel-document-v1.yaml @@ -0,0 +1,125 @@ +openapi: 3.0.3 +info: + title: 海外换标 PDF 文件内部契约 + version: 1.0.0 + description: >- + File 只保存文件并强制客户、业务类型、业务ID与文件ID复合归属。 + OVERSEAS_RELABEL_TOTAL_DOCUMENT 的 businessId 为 application.id; + OVERSEAS_RELABEL_ITEM_DOCUMENT 的 businessId 为 item.id。 + Lastleg 必须先校验申请与明细存在、明细所属申请及其客户归属、业务状态和权限。 + 客户归属未知时失败关闭,不得使用平台归属或替代业务ID。 +security: + - InternalServiceToken: [] +paths: + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}: + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/BusinessType' + - $ref: '#/components/parameters/BusinessId' + post: + summary: 上传换标总文件或单品文件 + description: 非空 PDF,Content-Type 必须为 application/pdf,内容需有 PDF 签名,最大 10MiB(10485760字节,含边界)。 + parameters: + - name: X-Operator-Id + in: header + required: true + schema: {type: integer, format: int64, minimum: 1} + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: {type: string, format: binary} + encoding: + file: {contentType: application/pdf} + responses: + '200': + description: 统一响应体;业务失败按 code 判断 + content: + application/json: + schema: {$ref: '#/components/schemas/MetadataEnvelope'} + '403': {description: 内部令牌无效} + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}: + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/BusinessType' + - $ref: '#/components/parameters/BusinessId' + - $ref: '#/components/parameters/FileId' + get: + summary: 校验复合归属并读取元数据 + responses: + '200': + description: 仅返回全部归属字段匹配且有效的文件,未匹配按统一业务错误返回 + content: + application/json: + schema: {$ref: '#/components/schemas/MetadataEnvelope'} + '403': {description: 内部令牌无效} + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content: + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/BusinessType' + - $ref: '#/components/parameters/BusinessId' + - $ref: '#/components/parameters/FileId' + get: + summary: 校验复合归属并下载 PDF + responses: + '200': + description: 文件内容;Content-Disposition 附件下载,Cache-Control no-store + content: + application/pdf: + schema: {type: string, format: binary} + '403': {description: 内部令牌无效} +components: + schemas: + MetadataEnvelope: + type: object + required: [code, data] + properties: + code: {type: string} + 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_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT]} + businessId: {type: string, pattern: '^[1-9][0-9]*$'} + fileName: {type: string} + sha256: {type: string, pattern: '^[0-9a-f]{64}$'} + contentType: {type: string, enum: [application/pdf]} + sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760} + createdAt: {type: string, description: 持久化上传时间LocalDateTime,无时区偏移} + securitySchemes: + InternalServiceToken: + type: apiKey + in: header + name: X-Internal-Service-Token + parameters: + TenantId: + name: tenantId + in: path + required: true + schema: {type: integer, format: int64, minimum: 1} + BusinessType: + name: businessType + in: path + required: true + schema: + type: string + enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT] + BusinessId: + name: businessId + in: path + required: true + schema: {type: string, pattern: '^[1-9][0-9]*$'} + FileId: + name: fileId + in: path + required: true + schema: {type: integer, format: int64, minimum: 1} diff --git a/file/src/test/java/com/baiyue/file/service/RelabelDocumentFileTest.java b/file/src/test/java/com/baiyue/file/service/RelabelDocumentFileTest.java new file mode 100644 index 000000000..1b8c009ed --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/RelabelDocumentFileTest.java @@ -0,0 +1,103 @@ +package com.baiyue.file.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import java.nio.file.Path; +import java.nio.charset.StandardCharsets; +import java.util.Set; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.any; + +class RelabelDocumentFileTest { + @TempDir Path root; + + @ParameterizedTest + @EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT"}) + void pdfPolicyChecksContentTypeSignatureAndSize(BusinessFileCategory category) { + BusinessFileStorage storage = storage(); + byte[] exactLimit = new byte[10 * 1024 * 1024]; + System.arraycopy("%PDF-1.7".getBytes(StandardCharsets.US_ASCII), 0, exactLimit, 0, 8); + var file = new MockMultipartFile("file", "label.pdf", "application/pdf", exactLimit); + var saved = storage.store(11L, "41", file, category); + assertThat(saved.size()).isEqualTo(10 * 1024 * 1024); + assertThat(saved.objectKey()).endsWith(".pdf"); + assertThat(storage.read(saved.objectKey())).isEqualTo(exactLimit); + for (String type : new String[]{"image/png", "text/plain", "application/octet-stream"}) { + assertThatThrownBy(() -> storage.store(11L, "41", + new MockMultipartFile("file", "label.pdf", type, exactLimit), category)) + .isInstanceOf(BizException.class).hasMessageContaining("仅支持PDF"); + } + assertThatThrownBy(() -> storage.store(11L, "41", + new MockMultipartFile("file", "fake.pdf", "application/pdf", "not-pdf".getBytes()), category)) + .isInstanceOf(BizException.class).hasMessageContaining("不是有效PDF"); + assertThatThrownBy(() -> storage.store(11L, "41", + new MockMultipartFile("file", "empty.pdf", "application/pdf", new byte[0]), category)) + .isInstanceOf(BizException.class).hasMessageContaining("不能为空"); + assertThatThrownBy(() -> storage.store(11L, "41", + new MockMultipartFile("file", "large.pdf", "application/pdf", new byte[exactLimit.length + 1]), category)) + .isInstanceOf(BizException.class).hasMessageContaining("10MB"); + } + + @ParameterizedTest + @EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT"}) + void uploadAndReadRequireExactCustomerBusinessTypeBusinessAndFile(BusinessFileCategory category) throws Exception { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "relabel-test"), FileObjectEntity.class); + FileObjectMapper files = mock(FileObjectMapper.class); + InternalBusinessFileService service = new InternalBusinessFileService(); + ReflectionTestUtils.setField(service, "files", files); + ReflectionTestUtils.setField(service, "storage", storage()); + FileObjectEntity[] persisted = new FileObjectEntity[1]; + when(files.insert(any(FileObjectEntity.class))).thenAnswer(call -> { + persisted[0] = call.getArgument(0); persisted[0].setId(801L); return 1; + }); + var pdf = new MockMultipartFile("file", "label.pdf", "application/pdf", "%PDF-1.7".getBytes()); + var saved = service.upload(11L, category.name(), "41", 7L, pdf); + assertThat(saved.ownerScope()).isEqualTo("CUSTOMER"); + assertThat(saved.tenantId()).isEqualTo(11L); + assertThat(saved.businessType()).isEqualTo(category.name()); + assertThat(saved.businessId()).isEqualTo("41"); + when(files.selectOne(any())).thenAnswer(call -> { + LambdaQueryWrapper query = call.getArgument(0); + assertThat(query.getSqlSegment()).contains("tenant_id", "business_type", "business_id", "id", "status", "deleted"); + return Set.copyOf(query.getParamNameValuePairs().values()) + .equals(Set.of(11L, category.name(), "41", 801L, "ACTIVE", false)) ? persisted[0] : null; + }); + assertThat(service.download(11L, category.name(), "41", 801L).content()).isEqualTo(pdf.getBytes()); + assertThatThrownBy(() -> service.download(12L, category.name(), "41", 801L)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(11L, category.name(), "42", 801L)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(11L, category.name(), "41", 802L)).isInstanceOf(BizException.class); + String other = category == BusinessFileCategory.OVERSEAS_RELABEL_TOTAL_DOCUMENT + ? "OVERSEAS_RELABEL_ITEM_DOCUMENT" : "OVERSEAS_RELABEL_TOTAL_DOCUMENT"; + assertThatThrownBy(() -> service.download(11L, other, "41", 801L)).isInstanceOf(BizException.class); + clearInvocations(files); + for (long owner : new long[]{0L, -1L}) { + assertThatThrownBy(() -> service.upload(owner, category.name(), "41", 7L, pdf)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.metadata(owner, category.name(), "41", 801L)).isInstanceOf(BizException.class); + } + for (String id : new String[]{"0", "-1", "invalid", "9223372036854775808"}) { + assertThatThrownBy(() -> service.upload(11L, category.name(), id, 7L, pdf)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.metadata(11L, category.name(), id, 801L)).isInstanceOf(BizException.class); + } + verifyNoInteractions(files); + } + + private BusinessFileStorage storage() { + BusinessFileStorage storage = new BusinessFileStorage(); + ReflectionTestUtils.setField(storage, "configuredRoot", root); + storage.initializeRoot(); + return storage; + } +} diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index 37dea7a29..e8585c07f 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -115,7 +115,7 @@ components: type: string enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER] description: OTHER is frozen on the ocean booking and creates a manual-only task after outbound confirmation. - TrackingType: {type: string, enum: [CONTAINER_NO], description: Only the normalized physical containerNo frozen by Firstleg is accepted; cabinetNo, orderNo, bookingNo and bill of lading are never fallbacks.} + TrackingType: {type: string, enum: [CONTAINER_NO], description: "Only the normalized physical containerNo frozen by Firstleg is accepted; cabinetNo, orderNo, bookingNo and bill of lading are never fallbacks."} QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, STOPPED]} StandardNode: type: string diff --git a/iam/src/main/resources/openapi/notification-v1.yaml b/iam/src/main/resources/openapi/notification-v1.yaml index d92f62057..941f3afc8 100644 --- a/iam/src/main/resources/openapi/notification-v1.yaml +++ b/iam/src/main/resources/openapi/notification-v1.yaml @@ -145,7 +145,7 @@ components: tenantId: {type: integer, format: int64, minimum: 0} templateCode: {type: string, maxLength: 64} recipientType: {type: string, enum: [USER, MEMBERSHIP, TENANT_ROLE, TENANT_ALL, PLATFORM_ROLE]} - recipientIds: {type: array, minItems: 1, maxItems: 1000, items: {type: integer, format: int64}, description: Required for USER or MEMBERSHIP; forbidden for role/all recipient types. For a specified enterprise member, pass the user's ID and use USER; membership IDs are reserved for explicit membership-level calls.} + recipientIds: {type: array, minItems: 1, maxItems: 1000, items: {type: integer, format: int64}, description: "Required for USER or MEMBERSHIP; forbidden for role/all recipient types. For a specified enterprise member, pass the user's ID and use USER; membership IDs are reserved for explicit membership-level calls."} roleCode: {type: string, minLength: 1, maxLength: 64, description: Required for TENANT_ROLE or PLATFORM_ROLE; forbidden otherwise.} variables: {type: object, maxProperties: 32, additionalProperties: {type: string, maxLength: 2000}} scheduledAt: {type: string, format: date-time, nullable: true} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java index 4903645ec..a5cd6f181 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -67,6 +67,8 @@ public class RelabelApplicationController { @PathVariable String relabelNo, @RequestParam String scope, @RequestParam(required = false) Long itemId, @RequestPart("file") MultipartFile file) { requirePlatform(user); + if (!"TOTAL".equalsIgnoreCase(scope) && !"ITEM".equalsIgnoreCase(scope)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "资料范围必须为 TOTAL 或 ITEM"); + if ("TOTAL".equalsIgnoreCase(scope) && itemId != null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "总资料不能指定明细"); String customerId = service.customerId(relabelNo); return ApiResponse.success("ITEM".equalsIgnoreCase(scope) ? service.uploadItemFile(customerId, relabelNo, itemId == null ? 0L : itemId, operatorLong(user), file) @@ -80,7 +82,7 @@ public class RelabelApplicationController { @RequestParam(required = false) Long itemId) { requirePlatform(user); var file = service.downloadFile(service.customerId(relabelNo), relabelNo, scope, itemId, fileId); - return ResponseEntity.ok().header("Content-Disposition", ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString()) + return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()).header("Content-Disposition", ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString()) .contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).contentLength(file.content().length).body(file.content()); } private void requirePlatform(AuthenticatedUser user) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/HttpRelabelDocumentFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/HttpRelabelDocumentFileGateway.java index 97935edc8..66c40bd81 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/HttpRelabelDocumentFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/HttpRelabelDocumentFileGateway.java @@ -37,7 +37,7 @@ public class HttpRelabelDocumentFileGateway implements RelabelDocumentFileGatewa .onStatus(HttpStatusCode::isError, (request, result) -> { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "换标PDF上传失败"); }) .body(MetadataEnvelope.class); requireSuccess(response, "换标PDF上传失败"); Metadata data = response == null ? null : response.data(); - if (data == null || data.fileId() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "文件服务未返回换标PDF信息"); + if (data == null || data.fileId() == null || data.fileId() <= 0) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "文件服务未返回换标PDF信息"); return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes()); } @@ -47,7 +47,7 @@ public class HttpRelabelDocumentFileGateway implements RelabelDocumentFileGatewa throw new ResponseStatusException(result.getStatusCode().is4xxClientError() ? HttpStatus.NOT_FOUND : HttpStatus.SERVICE_UNAVAILABLE, "换标PDF读取失败"); }).toEntity(byte[].class); String name = response.getHeaders().getContentDisposition().getFilename(); - return new DownloadedFile(response.getBody() == null ? new byte[0] : response.getBody(), name == null ? fileId + ".pdf" : name, + return new DownloadedFile(response.getBody() == null ? new byte[0] : response.getBody(), name == null ? "换标资料.pdf" : name, response.getHeaders().getContentType() == null ? MediaType.APPLICATION_PDF_VALUE : response.getHeaders().getContentType().toString()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index d91018aa3..9e21cd6ef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -16,6 +16,8 @@ import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; import com.baiyue.lastleg.relabel.dto.RelabelRequests; import com.baiyue.lastleg.relabel.dto.RelabelResponses; +import com.baiyue.lastleg.relabel.gateway.RelabelDocumentFileGateway; +import org.springframework.web.multipart.MultipartFile; import com.baiyue.lastleg.relabel.entity.RelabelApplication; import com.baiyue.lastleg.relabel.entity.RelabelItem; import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory; @@ -63,6 +65,75 @@ public class RelabelApplicationService implements WalletResultHandler { @Resource private InventoryCommandService inventoryCommands; @Resource private InventoryBalanceMapper inventoryBalances; @Resource private InventoryPositionMapper inventoryPositions; + @Resource private RelabelDocumentFileGateway documentFiles; + + @Transactional(readOnly = true) + public String customerId(String relabelNo) { return require(null, relabelNo).getCustomerId(); } + + @Transactional + public RelabelResponses.Detail uploadTotalFile(String customerId, String relabelNo, long operatorId, MultipartFile file) { + RelabelApplication application = lockForDocument(customerId, relabelNo); + requirePdf(file); + var uploaded = documentFiles.upload(application.getCustomerId(), "OVERSEAS_RELABEL_TOTAL_DOCUMENT", String.valueOf(application.getId()), operatorId, file); + application.setTotalFileId(String.valueOf(uploaded.fileId())); application.setTotalFileName(uploaded.fileName()); + application.setTotalFileUrl(documentUrl(relabelNo, uploaded.fileId(), null)); application.setUpdatedAt(LocalDateTime.now()); + applications.updateById(application); + return detail(application.getCustomerId(), relabelNo); + } + + @Transactional + public RelabelResponses.Detail uploadItemFile(String customerId, String relabelNo, long itemId, long operatorId, MultipartFile file) { + RelabelApplication application = lockForDocument(customerId, relabelNo); + RelabelItem item = requireDocumentItem(application, itemId); + requirePdf(file); + var uploaded = documentFiles.upload(application.getCustomerId(), "OVERSEAS_RELABEL_ITEM_DOCUMENT", String.valueOf(item.getId()), operatorId, file); + item.setRelabelFileId(String.valueOf(uploaded.fileId())); item.setRelabelFileName(uploaded.fileName()); + item.setRelabelFileUrl(documentUrl(relabelNo, uploaded.fileId(), itemId)); + items.updateById(item); + application.setUpdatedAt(LocalDateTime.now()); applications.updateById(application); + return detail(application.getCustomerId(), relabelNo); + } + + @Transactional(readOnly = true) + public RelabelDocumentFileGateway.DownloadedFile downloadFile(String customerId, String relabelNo, String scope, Long itemId, long fileId) { + RelabelApplication application = require(customerId, relabelNo); + if(fileId <= 0) throw notFound("换标资料不存在"); + if ("ITEM".equalsIgnoreCase(scope)) { + if (itemId == null) throw badRequest("明细资料必须指定明细"); + RelabelItem item = requireDocumentItem(application, itemId); + if (!String.valueOf(fileId).equals(item.getRelabelFileId())) throw notFound("换标资料未绑定当前明细"); + return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_ITEM_DOCUMENT", String.valueOf(item.getId()), fileId); + } + if (!"TOTAL".equalsIgnoreCase(scope) || itemId != null) throw badRequest("资料范围必须为 TOTAL 或 ITEM,总资料不指定明细"); + if (!String.valueOf(fileId).equals(application.getTotalFileId())) throw notFound("换标资料未绑定当前申请单"); + return documentFiles.download(application.getCustomerId(), "OVERSEAS_RELABEL_TOTAL_DOCUMENT", String.valueOf(application.getId()), fileId); + } + + private RelabelApplication lockForDocument(String customerId, String relabelNo) { + if (!StringUtils.hasText(customerId)) throw badRequest("客户不能为空"); + RelabelApplication application = applications.lockNo(customerId, relabelNo); + if (application == null) throw notFound("Relabel application not found"); + if (!Set.of(PENDING_PROCESSING, "PROCESSING").contains(application.getStatus())) throw conflict("当前换标状态不允许修改资料"); + return application; + } + + private RelabelItem requireDocumentItem(RelabelApplication application, long itemId) { + RelabelItem item = itemId > 0 ? items.selectById(itemId) : null; + if (item == null || !Objects.equals(item.getApplicationId(), application.getId()) || !Objects.equals(item.getCustomerId(), application.getCustomerId())) throw notFound("换标明细不属于当前申请单"); + return item; + } + + private static void requirePdf(MultipartFile file) { + if (file == null || file.isEmpty() || file.getSize() > 10L * 1024 * 1024) throw badRequest("请上传不超过10MB的PDF文件"); + if (file.getOriginalFilename() == null || !file.getOriginalFilename().toLowerCase(java.util.Locale.ROOT).endsWith(".pdf") || !"application/pdf".equalsIgnoreCase(file.getContentType())) throw badRequest("换标资料仅支持PDF文件"); + try (var stream = file.getInputStream()) { + if (!java.util.Arrays.equals(stream.readNBytes(5), "%PDF-".getBytes(StandardCharsets.US_ASCII))) throw badRequest("文件内容不是PDF"); + } catch (java.io.IOException exception) { throw badRequest("无法读取PDF文件"); } + } + + private static String documentUrl(String relabelNo, long fileId, Long itemId) { + return "/api/platform/v1/last-leg/relabel-applications/" + java.net.URLEncoder.encode(relabelNo, StandardCharsets.UTF_8) + "/files/" + fileId + "/download?scope=" + (itemId == null ? "TOTAL" : "ITEM&itemId=" + itemId); + } @Transactional(readOnly = true) public List candidates(String customerId, String operationWarehouseId, @@ -101,7 +172,6 @@ public class RelabelApplicationService implements WalletResultHandler { int total = 0; int notified = 0; List sources = new java.util.ArrayList<>(); - Set selectedPositions = new HashSet<>(); Set selectedCartonMarks = new HashSet<>(); for (RelabelRequests.Item item : request.items()) { validateOperation(item); @@ -110,9 +180,6 @@ public class RelabelApplicationService implements WalletResultHandler { } total = Math.addExact(total, item.totalPackageCount()); notified = Math.addExact(notified, item.notifiedPackageCount()); - if (!selectedPositions.add(item.sourceInventoryPositionId())) { - throw badRequest("The same inventory position cannot be selected more than once"); - } if (!selectedCartonMarks.add(item.cartonMark().trim().toUpperCase())) { throw badRequest("The same carton mark cannot create more than one relabel task"); } @@ -135,11 +202,12 @@ public class RelabelApplicationService implements WalletResultHandler { long applicationId = IdWorker.getId(); String operationWarehouseId = request.operationWarehouseId().trim(); String relabelNo = orderNoGenerator.next("RELABEL", operationWarehouseId, applicationId); + int holdIndex = 0; for (SourceInventory source : sources) { inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move( source.request().sourceInventoryPositionId(), source.request().notifiedPackageCount(), relabelNo, - null, "RELABEL:" + relabelNo, request.idempotencyKey().trim() + ":HOLD:" - + source.request().sourceInventoryPositionId(), "贴标申请冻结原入仓单库存")); + null, "RELABEL:" + relabelNo, "RELABEL:" + applicationId + ":HOLD:" + holdIndex++, + "贴标申请冻结原入仓单库存")); } LastlegOrder tailOrder = insertTailOrder(customerId, request, requestHash, relabelNo, operationWarehouseId, total, now); @@ -321,6 +389,7 @@ public class RelabelApplicationService implements WalletResultHandler { Objects.requireNonNullElse(value.getItemCount(), detailItems.size()), Objects.requireNonNullElse(value.getTotalPackageCount(), 0), Objects.requireNonNullElse(value.getNotifiedPackageCount(), 0), skuCodes, + value.getTotalFileId(), value.getTotalFileName(), value.getTotalFileUrl(), value.getChargeAmount(), value.getChargeCurrency(), value.getWalletStatus(), value.getWalletErrorMessage(), value.getCreatedAt()); } diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 822e990ea..bf6ffb88a 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -346,7 +346,7 @@ paths: application/json: schema: { $ref: '#/components/schemas/ChangeReceivingWarehouseRequest' } responses: - '200': { description: 更新后的柜级摘要, content: { application/json: { schema: { type: object, properties: { code: { type: string, enum: ['200'] }, data: { $ref: '#/components/schemas/ContainerPlanProgressSummary' } } } } } + '200': { description: 更新后的柜级摘要, content: { application/json: { schema: { type: object, properties: { code: { type: string, enum: ['200'] }, data: { $ref: '#/components/schemas/ContainerPlanProgressSummary' } } } } } } '400': { description: 请求无效或目标不是有效 OVERSEAS 仓 } '409': { description: 版本冲突、幂等键冲突或PDA接柜会话已建立 } /api/platform/v1/last-leg/orders/{id}/destination: @@ -624,7 +624,7 @@ paths: description: 权限 platform:overseas-warehouse:outbound:list;只按全局唯一 outboundNo 定位并从记录解析客户范围,不接收 customerId。 security: [{ bearerAuth: [] }] parameters: [{ name: outboundNo, in: path, required: true, schema: { type: string } }] - responses: { '200': { description: 出库单、出库托盘及关联派送单详情;pallets[].palletNo 为 Warehouse 权威实物托盘码 }, '404': { description: 出库单不存在 } } + responses: { '200': { description: "出库单、出库托盘及关联派送单详情;pallets[].palletNo 为 Warehouse 权威实物托盘码" }, '404': { description: 出库单不存在 } } /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel: post: summary: 取消未执行出库单 @@ -1172,6 +1172,38 @@ paths: - { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelStatusChange' } } } } responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } } + /api/platform/v1/last-leg/relabel-applications/{relabelNo}/files: + post: + summary: 上传总换标PDF或明细换标PDF + description: 权限 platform:overseas-warehouse:relabel-order:create。仅PENDING_PROCESSING/PROCESSING可替换,PDF内容且不超过10MiB。客户从申请单解析,ITEM必须属于该申请单同客户。File类别分别为OVERSEAS_RELABEL_TOTAL_DOCUMENT(application.id)及OVERSEAS_RELABEL_ITEM_DOCUMENT(item.id)。成功保存File返回的资料元数据,旧资料不再允许通过当前绑定下载。 + security: [{ bearerAuth: [] }] + parameters: + - { name: relabelNo, in: path, required: true, schema: { type: string } } + - { name: scope, in: query, required: true, schema: { type: string, enum: [TOTAL, ITEM] } } + - { name: itemId, in: query, description: ITEM必填,TOTAL不得传入, schema: { type: integer, format: int64, minimum: 1 } } + requestBody: + required: true + content: + multipart/form-data: + schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } + responses: + '200': { description: 换标详情;application包含totalFileId/totalFileName/totalFileUrl,items包含relabelFileId/relabelFileName/relabelFileUrl。URL为受权下载路径,文件ID按字符串返回 } + '400': { description: 非PDF、超限或资料范围无效 } + '404': { description: 申请或明细不属于当前客户 } + '409': { description: 换标状态不允许修改资料 } + /api/platform/v1/last-leg/relabel-applications/{relabelNo}/files/{fileId}/download: + get: + summary: 读取当前绑定的换标PDF + description: 权限 platform:overseas-warehouse:relabel-order:list;按申请单客户、资料类别、application.id或item.id及fileId精确授权。仅当前已保存资料可读,未绑定或替换的文件拒绝。响应Cache-Control为no-store。 + security: [{ bearerAuth: [] }] + parameters: + - { name: relabelNo, in: path, required: true, schema: { type: string } } + - { name: fileId, in: path, required: true, schema: { type: integer, format: int64, minimum: 1 } } + - { name: scope, in: query, required: true, schema: { type: string, enum: [TOTAL, ITEM] } } + - { name: itemId, in: query, schema: { type: integer, format: int64, minimum: 1 } } + responses: + '200': { description: PDF字节, content: { application/pdf: { schema: { type: string, format: binary } } } } + '404': { description: 资料未绑定或归属不匹配 } /api/merchant/v1/last-leg/return-orders: get: summary: 客户分页查询退货签收订单 @@ -1336,10 +1368,10 @@ components: properties: skuCode: { type: string, maxLength: 128 } quantity: { type: integer, minimum: 1 } - length: { type: number, exclusiveMinimum: 0, description: '箱长,CM' } - width: { type: number, exclusiveMinimum: 0, description: '箱宽,CM' } - height: { type: number, exclusiveMinimum: 0, description: '箱高,CM' } - weight: { type: number, exclusiveMinimum: 0, description: '箱重,KG' } + length: { type: number, minimum: 0, exclusiveMinimum: true, description: '箱长,CM' } + width: { type: number, minimum: 0, exclusiveMinimum: true, description: '箱宽,CM' } + height: { type: number, minimum: 0, exclusiveMinimum: true, description: '箱高,CM' } + weight: { type: number, minimum: 0, exclusiveMinimum: true, description: '箱重,KG' } idempotencyKey: { type: string, minLength: 1, maxLength: 255 } InventoryBalance: type: object diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java index ebe8452b6..7ecff3482 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java @@ -34,6 +34,33 @@ class RelabelApplicationControllerTest { assertThrows(ResponseStatusException.class, () -> controller.create(platform(), "0", request())); } + @Test + void uploadRejectsMerchantAndUnknownScopeBeforeResolvingCustomer() { + var merchant = new AuthenticatedUser(7L, 101L, 0L, 8L, "customer", "MERCHANT", "WEB", Set.of()); + assertThrows(ResponseStatusException.class, () -> controller.uploadFile(merchant, "RL-1", "TOTAL", null, null)); + assertThrows(ResponseStatusException.class, () -> controller.uploadFile(platform(), "RL-1", "PHOTO", null, null)); + assertThrows(ResponseStatusException.class, () -> controller.uploadFile(platform(), "RL-1", "TOTAL", 22L, null)); + org.mockito.Mockito.verifyNoInteractions(service); + } + + @Test + void uploadUsesCustomerResolvedFromApplication() { + org.mockito.Mockito.when(service.customerId("RL-1")).thenReturn("101"); + var file = new org.springframework.mock.web.MockMultipartFile("file", "labels.pdf", "application/pdf", "%PDF-1.7".getBytes()); + controller.uploadFile(platform(), "RL-1", "ITEM", 22L, file); + verify(service).uploadItemFile("101", "RL-1", 22L, 7L, file); + } + + @Test + void downloadUsesOwnedBindingAndForbidsCaching() { + org.mockito.Mockito.when(service.customerId("RL-1")).thenReturn("101"); + org.mockito.Mockito.when(service.downloadFile("101", "RL-1", "TOTAL", null, 99L)) + .thenReturn(new com.baiyue.lastleg.relabel.gateway.RelabelDocumentFileGateway.DownloadedFile("%PDF-1.7".getBytes(), "labels.pdf", "application/pdf")); + var response = controller.downloadFile(platform(), "RL-1", 99L, "TOTAL", null); + org.junit.jupiter.api.Assertions.assertEquals("no-store", response.getHeaders().getCacheControl()); + org.junit.jupiter.api.Assertions.assertEquals("application/pdf", response.getHeaders().getContentType().toString()); + } + private RelabelRequests.Create request() { return new RelabelRequests.Create("CUSTOMER-1", "WH-1", LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", null, null, "key-1", diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index e5f2ceb7d..69ea0dc47 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -230,7 +230,9 @@ class RelabelApplicationServiceTest { service.create("10001", "operator-1", request); - verify(inventoryCommands, times(2)).hold(eq("10001"), eq("operator-1"), any()); + ArgumentCaptor holds = ArgumentCaptor.forClass(InventoryCommandRequests.Move.class); + verify(inventoryCommands, times(2)).hold(eq("10001"), eq("operator-1"), holds.capture()); + assertEquals(2, holds.getAllValues().stream().map(InventoryCommandRequests.Move::idempotencyKey).distinct().count()); verify(items, times(2)).insert(any(RelabelItem.class)); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelDocumentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelDocumentServiceTest.java new file mode 100644 index 000000000..af5f21d97 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelDocumentServiceTest.java @@ -0,0 +1,75 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baiyue.lastleg.relabel.entity.*; +import com.baiyue.lastleg.relabel.mapper.*; +import com.baiyue.lastleg.relabel.gateway.RelabelDocumentFileGateway; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class RelabelDocumentServiceTest { + final RelabelApplicationMapper applications=mock(RelabelApplicationMapper.class); + final RelabelItemMapper items=mock(RelabelItemMapper.class); + final RelabelDocumentFileGateway files=mock(RelabelDocumentFileGateway.class); + final RelabelApplicationService service=new RelabelApplicationService(); + final RelabelApplication application=new RelabelApplication(); + final MockMultipartFile pdf=new MockMultipartFile("file","labels.pdf","application/pdf","%PDF-1.7\nfixture".getBytes()); + RelabelDocumentServiceTest(){ + application.setId(11L);application.setCustomerId("101");application.setRelabelNo("RL-1");application.setStatus("PENDING_PROCESSING"); + ReflectionTestUtils.setField(service,"applications",applications);ReflectionTestUtils.setField(service,"items",items); + ReflectionTestUtils.setField(service,"documentFiles",files);ReflectionTestUtils.setField(service,"orders",mock(LastlegOrderMapper.class)); + ReflectionTestUtils.setField(service,"histories",mock(RelabelStatusHistoryMapper.class));ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper()); + when(applications.lockNo("101","RL-1")).thenReturn(application);when(applications.findNo("101","RL-1")).thenReturn(application); + } + @Test void totalUploadPersistsReturnedMetadataAndExposesSummary(){ + when(files.upload("101","OVERSEAS_RELABEL_TOTAL_DOCUMENT","11",7L,pdf)).thenReturn(new RelabelDocumentFileGateway.UploadedFile(99L,"labels.pdf","application/pdf",15)); + var result=service.uploadTotalFile("101","RL-1",7L,pdf); + assertThat(result.application().totalFileId()).isEqualTo("99"); + assertThat(result.application().totalFileName()).isEqualTo("labels.pdf"); + assertThat(result.application().totalFileUrl()).endsWith("/files/99/download?scope=TOTAL"); + verify(applications).updateById(application); + } + @Test void itemUploadBindsOwnCustomerApplicationAndDistinctCategory(){ + RelabelItem item=item();when(items.selectById(22L)).thenReturn(item); + when(files.upload("101","OVERSEAS_RELABEL_ITEM_DOCUMENT","22",7L,pdf)).thenReturn(new RelabelDocumentFileGateway.UploadedFile(98L,"item.pdf","application/pdf",15)); + service.uploadItemFile("101","RL-1",22L,7L,pdf); + assertThat(item.getRelabelFileId()).isEqualTo("98");assertThat(item.getRelabelFileUrl()).endsWith("scope=ITEM&itemId=22"); + verify(items).updateById(item); + } + @Test void rejectsCrossCustomerAndCrossApplicationItemsBeforeFileUpload(){ + assertThatThrownBy(()->service.uploadTotalFile("102","RL-1",7L,pdf)).isInstanceOf(ResponseStatusException.class); + RelabelItem item=item();item.setApplicationId(12L);when(items.selectById(22L)).thenReturn(item); + assertThatThrownBy(()->service.uploadItemFile("101","RL-1",22L,7L,pdf)).hasMessageContaining("不属于"); + item.setApplicationId(11L);item.setCustomerId("102"); + assertThatThrownBy(()->service.uploadItemFile("101","RL-1",22L,7L,pdf)).hasMessageContaining("不属于"); + verifyNoInteractions(files); + } + @Test void rejectsFakePdfAndTerminalChangesBeforeUpload(){ + var fake=new MockMultipartFile("file","labels.pdf","application/pdf","not pdf".getBytes()); + assertThatThrownBy(()->service.uploadTotalFile("101","RL-1",7L,fake)).hasMessageContaining("不是PDF"); + application.setStatus("COMPLETED"); + assertThatThrownBy(()->service.uploadTotalFile("101","RL-1",7L,pdf)).hasMessageContaining("不允许修改资料"); + verifyNoInteractions(files); + } + @Test void downloadRequiresCurrentBindingAndExactScope(){ + application.setTotalFileId("99"); + service.downloadFile("101","RL-1","TOTAL",null,99L); + verify(files).download("101","OVERSEAS_RELABEL_TOTAL_DOCUMENT","11",99L); + assertThatThrownBy(()->service.downloadFile("101","RL-1","TOTAL",null,98L)).hasMessageContaining("未绑定"); + assertThatThrownBy(()->service.downloadFile("101","RL-1","other",null,99L)).hasMessageContaining("资料范围"); + assertThatThrownBy(()->service.downloadFile("101","RL-1","ITEM",null,99L)).hasMessageContaining("指定明细"); + verifyNoMoreInteractions(files); + } + @Test void fileFailureDoesNotPersistBindings(){ + when(files.upload(anyString(),anyString(),anyString(),anyLong(),any())).thenThrow(new IllegalStateException("offline")); + assertThatThrownBy(()->service.uploadTotalFile("101","RL-1",7L,pdf)).hasMessage("offline"); + verify(applications,never()).updateById(any(RelabelApplication.class));assertThat(application.getTotalFileId()).isNull(); + } + private RelabelItem item(){RelabelItem item=new RelabelItem();item.setId(22L);item.setApplicationId(11L);item.setCustomerId("101");return item;} +} diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 69937015a..5bfb6d56b 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -2874,6 +2874,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='charge_currency'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN charge_currency CHAR(3) NOT NULL DEFAULT ''CNY'' COMMENT ''费用币种'' AFTER charge_amount')); PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_id' AND COALESCE(column_comment,'') <> '总换标PDF文件ID'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN total_file_id VARCHAR(128) NULL COMMENT ''总换标PDF文件ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_name' AND COALESCE(column_comment,'') <> '总换标PDF文件名'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN total_file_name VARCHAR(255) NULL COMMENT ''总换标PDF文件名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_url' AND COALESCE(column_comment,'') <> '总换标PDF预览地址'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN total_file_url VARCHAR(1000) NULL COMMENT ''总换标PDF预览地址''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='wallet_status'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN wallet_status VARCHAR(32) NOT NULL DEFAULT ''FREEZE_PENDING'' COMMENT ''资金状态'' AFTER charge_currency')); PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='wallet_error_code'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN wallet_error_code VARCHAR(64) NULL COMMENT ''资金错误码'' AFTER wallet_status')); diff --git a/scripts/java-quality-baseline.json b/scripts/java-quality-baseline.json index 06cfd9174..23a42d3ea 100644 --- a/scripts/java-quality-baseline.json +++ b/scripts/java-quality-baseline.json @@ -11,7 +11,7 @@ "services": 15 }, "openapi": { - "documents": 45, + "documents": 46, "coveredServices": 10, "codeFirstServices": 5 }, diff --git a/scripts/java-quality-summary.test.mjs b/scripts/java-quality-summary.test.mjs index d3373b0f2..caddca9b8 100644 --- a/scripts/java-quality-summary.test.mjs +++ b/scripts/java-quality-summary.test.mjs @@ -65,7 +65,8 @@ test('repository freezes the current topology and non-blocking aggregate summary const summary = await buildQualitySummary(repositoryRoot) const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8')) assert.deepEqual(summary.topology, { modules: 22, codeModules: 21, services: 15 }) - assert.equal(summary.openapi.documents, 45) + assert.equal(summary.openapi.documents, 46) + assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/overseas-relabel-document-v1.yaml')) assert.ok(summary.openapi.files.includes('finance/src/main/resources/openapi/finance-container-settlement-v1.yaml')) assert.ok(summary.openapi.files.includes('warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml')) assert.equal(baseline.openapi.documents, summary.openapi.documents) diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index e816c2dc2..efd9731c1 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -169,6 +169,8 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ 'lastleg-service', 'warehouse-overseas-service', ['REQUEST'], 'READ_WRITE', 'IDEMPOTENCY_KEY', '托盘变更/补偿使用 operationKey 与请求哈希'), http('lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/HttpProcurementFileGateway.java', 'lastleg-service', 'file-service', ['REQUEST'], 'WRITE', 'NONE', '采购凭证和签收文件上传无幂等键,禁止客户端自动重试'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/HttpRelabelDocumentFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '换标PDF上传无幂等键,按客户/业务类型/业务单归属下载只读,传输层不自动重试'), http('lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java', 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '上传无幂等键,元数据校验可重复'), http('integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java', diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index fa88c3750..cf322b3aa 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -106,7 +106,7 @@ test('all 56 Feign clients have reviewed context, access and idempotency metadat assert.deepEqual(await discoverFeignEdges(), reviewed) }) -test('all 21 raw HTTP transports have reviewed context, access and idempotency metadata', async () => { +test('all 22 raw HTTP transports have reviewed context, access and idempotency metadata', async () => { const discovered = [] for (const module of ['common-audit', 'event-bridge-service', 'gateway-overseas', 'lastleg-service', 'integration-service']) { for (const file of await javaFiles(path.join(root, module, 'src', 'main', 'java'))) { @@ -118,7 +118,7 @@ test('all 21 raw HTTP transports have reviewed context, access and idempotency m } const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP') .map(client => client.path).sort() - assert.equal(reviewed.length, 21) + assert.equal(reviewed.length, 22) assert.deepEqual(discovered.sort(), reviewed) })