From c55ecabbfbec9eb769403adab0c2063109cfb9e3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 2 Oct 2026 23:14:24 +0800 Subject: [PATCH] fix(order): enforce versioned platform commands and submissions --- .../MerchantFirstLegOrderController.java | 5 +- .../PlatformFirstLegOrderController.java | 16 ++- .../com/baiyue/order/dto/OrderRequests.java | 9 ++ .../service/OrderApplicationService.java | 24 +++- .../src/main/resources/openapi/order-v1.yaml | 48 ++++++- .../MerchantOrderCommandBindingTest.java | 44 ++++++- .../OrderControllerPermissionTest.java | 4 +- .../MerchantOrderCommandTransactionTest.java | 123 +++++++++++++++++- .../OrderApplicationServiceStateTest.java | 15 ++- .../service/OrderIdempotencyServiceTest.java | 2 +- scripts/README.md | 1 + scripts/admin-test-env-modules/README.md | 3 +- ...-inbound-status-fixtures-contract.test.mjs | 14 ++ .../order-inbound-status-fixtures.mjs | 14 +- ...rs-lifecycle-idempotency-contract.test.mjs | 31 ++++- scripts/admin-test-env-modules/orders.mjs | 67 ++++++++-- .../phase5-root-fulfillment-contract.test.mjs | 18 ++- .../phase5-root-fulfillment.mjs | 18 ++- ...ient-test-env-acceptance-contract.test.mjs | 14 ++ scripts/client-test-env-acceptance.mjs | 27 +++- ...te-pending-review-orders-contract.test.mjs | 11 +- scripts/create-pending-review-orders.mjs | 13 +- scripts/prod-release-acceptance.mjs | 6 +- scripts/prod-release-order-stage.mjs | 17 ++- scripts/prod-release-order-stage.test.mjs | 36 ++++- scripts/real-order-xlsx-test-env.mjs | 4 +- scripts/real-order-xlsx-test-env.test.mjs | 30 ++++- 27 files changed, 540 insertions(+), 74 deletions(-) diff --git a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java index ed4fc1218..0aef8f521 100644 --- a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java @@ -196,8 +196,9 @@ public class MerchantFirstLegOrderController { @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id, @Parameter(description = "幂等键,同一操作者的同一请求重试时必须保持不变", required = true) - @RequestHeader("Idempotency-Key") String idempotencyKey) { - service.submit(OrderScopeGuard.merchant(user), user.userId(), user.username(), id, idempotencyKey); + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody OrderRequests.SubmitRequest request) { + service.submit(OrderScopeGuard.merchant(user), user.userId(), user.username(), id, request, idempotencyKey); return ApiResponse.success(null); } diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index 1473baf38..905692984 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -122,9 +122,10 @@ public class PlatformFirstLegOrderController { @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId, @Parameter(description = "订单 ID", required = true) @PathVariable long id, - @Valid @RequestBody OrderRequests.SaveRequest request) { + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody OrderRequests.PlatformOrderUpdateRequest request) { long scopedTenant = OrderScopeGuard.platform(user, tenantId); - service.updateSubmittedOrder(scopedTenant, user.userId(), id, request); + service.updateSubmittedOrder(scopedTenant, user.userId(), id, request, idempotencyKey); return ApiResponse.success(null); } @@ -141,9 +142,10 @@ public class PlatformFirstLegOrderController { @Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId, @Parameter(description = "订单 ID", required = true) @PathVariable long id, @Parameter(description = "提交订单幂等键,同键同请求返回首次结果", required = true) - @RequestHeader("Idempotency-Key") String idempotencyKey) { + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody OrderRequests.SubmitRequest request) { long scopedTenant = OrderScopeGuard.platform(user, tenantId); - service.submit(scopedTenant, user.userId(), user.username(), id, idempotencyKey); + service.submit(scopedTenant, user.userId(), user.username(), id, request, idempotencyKey); return ApiResponse.success(null); } @@ -277,8 +279,10 @@ public class PlatformFirstLegOrderController { public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId, @Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id, - @Valid @RequestBody OrderRequests.ReasonRequest request) { - return transit(user, tenantId, id, OrderStatus.CANCELLED, request.reason()); + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody OrderRequests.CancelRequest request) { + service.cancel(OrderScopeGuard.platform(user, tenantId), user.userId(), user.username(), id, request, idempotencyKey); + return ApiResponse.success(null); } @BusinessAudit(event = "ORDER_START", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Start processing / 开始处理") diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java index 85d7a66c1..992ececd6 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java @@ -284,6 +284,15 @@ public final class OrderRequests { @NotNull @Valid SaveRequest draft ) { } + @Schema(name = "PlatformOrderUpdateRequest", description = "平台订单版本化幂等编辑") + public record PlatformOrderUpdateRequest( + @NotNull @Min(0) Integer expectedVersion, + @NotNull @Valid SaveRequest draft + ) { } + + @Schema(name = "OrderSubmitRequest", description = "订单版本化幂等提交") + public record SubmitRequest(@NotNull @Min(0) Integer expectedVersion) { } + @Schema(name = "MerchantOrderCancelRequest", description = "客户订单版本化幂等取消") public record CancelRequest( @NotNull @Min(0) Integer expectedVersion, diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index f518dbd9e..13c7dc282 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -155,8 +155,15 @@ public class OrderApplicationService { * 因此不会因为该兼容能力意外获得已提交订单的编辑权限。 */ @Transactional - public void updateSubmittedOrder(long tenantId, long userId, long id, OrderRequests.SaveRequest request) { - updateOrderData(tenantId, userId, id, request, true, null); + public void updateSubmittedOrder(long tenantId, long userId, long id, OrderRequests.PlatformOrderUpdateRequest request, + String idempotencyKey) { + requireExpectedVersion(request.expectedVersion()); + if (request.draft() == null) throw new BizException(ResultCode.VALIDATION_FAILED, "订单内容不能为空"); + IdempotencyContext command = beginIdempotency(tenantId, userId, "UPDATE_SUBMITTED_ORDER", id, idempotencyKey, + requestHash("UPDATE_SUBMITTED_ORDER", List.of(id, request))); + if (command.replayed()) return; + updateOrderData(tenantId, userId, id, request.draft(), true, request.expectedVersion()); + completeIdempotency(command, id); } private void updateOrderData(long tenantId, long userId, long id, OrderRequests.SaveRequest request, @@ -234,7 +241,10 @@ public class OrderApplicationService { requirePrintableInboundCode(order); order.setSubmittedAt(LocalDateTime.now()); } - if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update"); + if (repository.saveOrder(tenantId, order) != 1) { + if (target == OrderStatus.SUBMITTED) throw new BizException(ResultCode.CONFLICT, "Concurrent order update"); + throw new BizException("Concurrent order update"); + } record(tenantId, id, from, target, target.name(), reason, userId, username); if (target == OrderStatus.SUBMITTED) prepareSubmittedInbound(order); if (target == OrderStatus.SUBMITTED || target == OrderStatus.APPROVED || target == OrderStatus.CANCELLED) { @@ -245,13 +255,15 @@ public class OrderApplicationService { /** 提交订单的幂等入口。 */ @Transactional - public void submit(long tenantId, long userId, String username, long id, String idempotencyKey) { + public void submit(long tenantId, long userId, String username, long id, OrderRequests.SubmitRequest request, + String idempotencyKey) { + requireExpectedVersion(request.expectedVersion()); IdempotencyContext idempotency = beginIdempotency(tenantId, userId, "SUBMIT_ORDER", id, idempotencyKey, - requestHash("SUBMIT_ORDER", Map.of("orderId", id))); + requestHash("SUBMIT_ORDER", List.of(id, request))); if (idempotency.replayed()) { return; } - transit(tenantId, userId, username, id, OrderStatus.SUBMITTED, null); + transit(tenantId, userId, username, id, OrderStatus.SUBMITTED, null, request.expectedVersion()); completeIdempotency(idempotency, id); } diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 28ce196b1..30a81fd82 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -626,10 +626,15 @@ paths: /api/merchant/v1/orders/first-leg/{id}/submit: post: summary: 提交订单 - description: 每条货物的每箱数量、箱数和总数量均必填,且总数量必须等于每箱数量 × 箱数。 + description: 必须携带读取详情时的expectedVersion;原键同载荷重放先返回首次成功,不重复提交。改版本/订单的同键请求与过期首次提交为A0409。每条货物的每箱数量、箱数和总数量均必填,且总数量必须等于每箱数量 × 箱数。 parameters: - $ref: '#/components/parameters/OrderId' - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/OrderSubmitRequest'} responses: '200': {description: 成功或业务失败,按响应体 code 判断} /api/platform/v1/orders/first-leg: @@ -750,28 +755,52 @@ paths: /api/platform/v1/orders/first-leg/{id}/submit: post: summary: 平台代客提交指定订单 - description: 每条货物的每箱数量、箱数和总数量均必填,且总数量必须等于每箱数量 × 箱数。 + description: 必须携带读取详情时的expectedVersion;原键同载荷重放先返回首次成功,不重复提交。改版本/订单的同键请求与过期首次提交为A0409。每条货物的每箱数量、箱数和总数量均必填,且总数量必须等于每箱数量 × 箱数。 parameters: - $ref: '#/components/parameters/OrderId' - $ref: '#/components/parameters/TenantId' - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/OrderSubmitRequest'} responses: '200': {description: 成功或业务失败,按响应体 code 判断} '403': {description: 需要 platform:order:submit 权限} /api/platform/v1/orders/first-leg/{id}: put: - summary: 平台编辑指定订单草稿 + summary: 平台版本化幂等编辑订单 + description: 仅DRAFT/REJECTED/SUBMITTED可编辑;SUBMITTED保持不回退。原键同载荷先返回首次成功,不重写明细历史或重复准备入仓。缺版本/旧平铺体拒绝,首次版本冲突或同键异载荷为A0409。 parameters: - $ref: '#/components/parameters/OrderId' - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: - schema: {$ref: '#/components/schemas/FirstLegOrderDraft'} + schema: {$ref: '#/components/schemas/PlatformOrderUpdateRequest'} responses: '200': {description: 成功或业务失败,按响应体 code 判断} '403': {description: 需要 platform:order:edit 权限} + /api/platform/v1/orders/first-leg/{id}/cancel: + post: + summary: 平台版本化幂等取消订单 + description: 复用客户取消命令的版本、原因和幂等规则;原键同载荷成功重放不重复根聚合/历史/事件,同键异载荷或新命令重复取消为A0409。 + parameters: + - $ref: '#/components/parameters/OrderId' + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/MerchantOrderCancelRequest'} + responses: + '200': {description: 成功或业务失败,按响应体 code 判断;无业务返回数据} + '403': {description: 需要platform:order:cancel权限和平台身份} + /internal/order/v1/fulfillment-splits: post: summary: 创建父订单装柜拆分(Firstleg 内部) @@ -1279,6 +1308,17 @@ components: type: string enum: [TAX_INCLUDED, TAX_EXCLUDED] description: 报价包税维度;独立于订单 taxMode 的买单/退税语义 + PlatformOrderUpdateRequest: + type: object + required: [expectedVersion, draft] + properties: + expectedVersion: {type: integer, minimum: 0} + draft: {$ref: '#/components/schemas/FirstLegOrderDraft'} + OrderSubmitRequest: + type: object + required: [expectedVersion] + properties: + expectedVersion: {type: integer, minimum: 0} MerchantDraftUpdateRequest: type: object required: [expectedVersion, draft] diff --git a/order/src/test/java/com/baiyue/order/controller/MerchantOrderCommandBindingTest.java b/order/src/test/java/com/baiyue/order/controller/MerchantOrderCommandBindingTest.java index a215469cf..2473d5bef 100644 --- a/order/src/test/java/com/baiyue/order/controller/MerchantOrderCommandBindingTest.java +++ b/order/src/test/java/com/baiyue/order/controller/MerchantOrderCommandBindingTest.java @@ -40,14 +40,56 @@ class MerchantOrderCommandBindingTest { service = mock(OrderApplicationService.class); var controller = new MerchantFirstLegOrderController(); ReflectionTestUtils.setField(controller, "service", service); - mvc = MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { + var platform = new PlatformFirstLegOrderController(); + ReflectionTestUtils.setField(platform, "service", service); + mvc = MockMvcBuilders.standaloneSetup(controller, platform).setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { public boolean supportsParameter(MethodParameter parameter) { return parameter.getParameterType() == AuthenticatedUser.class; } public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer model, NativeWebRequest request, WebDataBinderFactory binder) { + if (parameter.getDeclaringClass() == PlatformFirstLegOrderController.class) + return new AuthenticatedUser(7L, 0L, 0L, 1L, "operator", "PLATFORM", "WEB", Set.of()); return new AuthenticatedUser(7L, 11L, 11L, 1L, "operator", "MERCHANT", "WEB", Set.of()); } }).build(); } + @Test + void platformEditRequiresVersionEnvelopeAndKey() throws Exception { + String path = "/api/platform/v1/orders/first-leg/1?tenantId=11"; + String body = "{\"expectedVersion\":2,\"draft\":" + DRAFT + "}"; + mvc.perform(put(path).contentType(MediaType.APPLICATION_JSON).content(body)).andExpect(status().isBadRequest()); + for (String invalid : new String[] {DRAFT, "{\"draft\":" + DRAFT + "}", "{\"expectedVersion\":2}"}) + mvc.perform(put(path).header("Idempotency-Key", "platform-edit").contentType(MediaType.APPLICATION_JSON).content(invalid)).andExpect(status().isBadRequest()); + verifyNoInteractions(service); + mvc.perform(put(path).header("Idempotency-Key", "platform-edit").contentType(MediaType.APPLICATION_JSON).content(body)).andExpect(status().isOk()); + verify(service).updateSubmittedOrder(eq(11L), eq(7L), eq(1L), argThat(r -> r.expectedVersion() == 2 && "Offline".equals(r.draft().customerName())), eq("platform-edit")); + } + + @Test + void bothSubmitRoutesRequireVersionAndKeepAuthenticatedScope() throws Exception { + for (String path : new String[] {PATH + "/submit", "/api/platform/v1/orders/first-leg/1/submit?tenantId=11"}) { + mvc.perform(post(path).contentType(MediaType.APPLICATION_JSON).content("{\"expectedVersion\":2}")).andExpect(status().isBadRequest()); + for (String invalid : new String[] {"{}", "{\"expectedVersion\":null}", "{\"expectedVersion\":-1}"}) + mvc.perform(post(path).header("Idempotency-Key", "submit").contentType(MediaType.APPLICATION_JSON).content(invalid)).andExpect(status().isBadRequest()); + mvc.perform(post(path).header("Idempotency-Key", "submit")).andExpect(status().isBadRequest()); + } + verifyNoInteractions(service); + for (String path : new String[] {PATH + "/submit", "/api/platform/v1/orders/first-leg/1/submit?tenantId=11"}) + mvc.perform(post(path).header("Idempotency-Key", "submit").contentType(MediaType.APPLICATION_JSON).content("{\"expectedVersion\":2}")).andExpect(status().isOk()); + verify(service, times(2)).submit(11L, 7L, "operator", 1L, new OrderRequests.SubmitRequest(2), "submit"); + } + + @Test + void platformCancelUsesVersionedCommandWhileRejectKeepsReasonOnly() throws Exception { + String path = "/api/platform/v1/orders/first-leg/1/cancel?tenantId=11"; + mvc.perform(post(path).header("Idempotency-Key", "cancel").contentType(MediaType.APPLICATION_JSON).content("{\"reason\":\"old\"}")).andExpect(status().isBadRequest()); + mvc.perform(post(path).contentType(MediaType.APPLICATION_JSON).content("{\"expectedVersion\":0,\"reason\":\"reason\"}")).andExpect(status().isBadRequest()); + verifyNoInteractions(service); + mvc.perform(post(path).header("Idempotency-Key", "cancel").contentType(MediaType.APPLICATION_JSON).content("{\"expectedVersion\":0,\"reason\":\"reason\"}")).andExpect(status().isOk()); + verify(service).cancel(11L, 7L, "operator", 1L, new OrderRequests.CancelRequest(0, "reason"), "cancel"); + mvc.perform(post("/api/platform/v1/orders/first-leg/1/reject?tenantId=11").contentType(MediaType.APPLICATION_JSON).content("{\"reason\":\"reason\"}")).andExpect(status().isOk()); + verify(service).transit(11L, 7L, "operator", 1L, com.baiyue.order.entity.OrderStatus.REJECTED, "reason"); + } + @Test void editRequiresHeaderAndVersionedDraftEnvelope() throws Exception { String body = "{\"expectedVersion\":0,\"draft\":" + DRAFT + "}"; diff --git a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java index 447ac09d4..86893c579 100644 --- a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java +++ b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java @@ -43,9 +43,9 @@ class OrderControllerPermissionTest { assertAuthority(PlatformFirstLegOrderController.class, "create", "hasAuthority('platform:order:create')", AuthenticatedUser.class, long.class, String.class, OrderRequests.SaveRequest.class); assertAuthority(PlatformFirstLegOrderController.class, "updateDraft", "hasAuthority('platform:order:edit')", - AuthenticatedUser.class, long.class, long.class, OrderRequests.SaveRequest.class); + AuthenticatedUser.class, long.class, long.class, String.class, OrderRequests.PlatformOrderUpdateRequest.class); assertAuthority(PlatformFirstLegOrderController.class, "submit", "hasAuthority('platform:order:submit')", - AuthenticatedUser.class, long.class, long.class, String.class); + AuthenticatedUser.class, long.class, long.class, String.class, OrderRequests.SubmitRequest.class); assertAuthority(PlatformFirstLegOrderController.class, "batchApprove", "hasAuthority('platform:order:approve')", AuthenticatedUser.class, long.class, OrderRequests.BatchReviewRequest.class); assertAuthority(PlatformFirstLegOrderController.class, "batchReject", "hasAuthority('platform:order:reject')", diff --git a/order/src/test/java/com/baiyue/order/service/MerchantOrderCommandTransactionTest.java b/order/src/test/java/com/baiyue/order/service/MerchantOrderCommandTransactionTest.java index 5089f6971..22c4c69df 100644 --- a/order/src/test/java/com/baiyue/order/service/MerchantOrderCommandTransactionTest.java +++ b/order/src/test/java/com/baiyue/order/service/MerchantOrderCommandTransactionTest.java @@ -42,6 +42,7 @@ class MerchantOrderCommandTransactionTest { private OrderApplicationService service; private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); private OrderRequests.SaveRequest draft; + private com.baiyue.order.client.FirstlegInboundClient firstleg; @BeforeEach void prepare() throws Exception { @@ -77,6 +78,11 @@ class MerchantOrderCommandTransactionTest { ReflectionTestUtils.setField(target, "repository", repository); ReflectionTestUtils.setField(target, "fulfillmentCompletion", proxy(completion, manager)); ReflectionTestUtils.setField(target, "json", json); + ReflectionTestUtils.setField(target, "eligibilityGuard", mock(OrderEligibilityGuard.class)); + firstleg = mock(com.baiyue.order.client.FirstlegInboundClient.class); + ReflectionTestUtils.setField(target, "firstlegProvider", new StaticListableBeanFactory(Map.of("firstleg", firstleg)) + .getBeanProvider(com.baiyue.order.client.FirstlegInboundClient.class)); + ReflectionTestUtils.setField(target, "internalToken", "offline-test-token"); ReflectionTestUtils.setField(target, "productImageGuard", mock(ProductImageGuard.class)); ReflectionTestUtils.setField(target, "inboundCodeModeProvider", mock(InboundCodeModeProvider.class)); ReflectionTestUtils.setField(target, "customerAddressServiceProvider", new StaticListableBeanFactory().getBeanProvider(CustomerAddressService.class)); @@ -90,7 +96,7 @@ class MerchantOrderCommandTransactionTest { service = proxy(target, manager); draft = json.readValue(""" {"customerName":"Offline","transportMode":"SEA","destinationCountryCode":"US", - "cargoes":[{"cargoName":"Offline cargo","packageType":"CARTON","packageCount":1}], + "cargoes":[{"cargoName":"Offline cargo","packageType":"CARTON","packageCount":1,"unitsPerPackage":1,"totalQuantity":1,"grossWeightKg":1,"volumeCbm":1,"woodenProduct":false}], "pickupMethod":"SELF_EXPRESS","receivingWarehouseId":8,"receivingWarehouseName":"Offline", "channelCode":"TEST","channelName":"Offline","taxMode":"BUY_DECLARATION", "pricingTaxMode":"TAX_INCLUDED","destinationType":"OVERSEAS"} @@ -100,6 +106,116 @@ class MerchantOrderCommandTransactionTest { db.update("INSERT INTO ord_order_party(id,tenant_id,order_id) VALUES(102,11,1)"); } + @Test + void platformSubmittedEditKeepsStateAndOriginalReplayDoesNotPrepareInboundAgain() { + db.update("UPDATE ord_order SET status='SUBMITTED' WHERE id=1"); + platformEdit(11, 1, 0, "platform"); + assertThat(status(1)).isEqualTo("SUBMITTED"); + assertThat(version(1)).isEqualTo(1); + verify(firstleg, times(1)).prepare(anyString(), any()); + var before = snapshot(); + platformEdit(11, 1, 0, "platform"); + assertThat(snapshot()).isEqualTo(before); + verify(firstleg, times(1)).prepare(anyString(), any()); + assertThatThrownBy(() -> platformEdit(11, 1, 1, "platform")).isInstanceOf(BizException.class); + assertThatThrownBy(() -> platformEdit(11, 2, 0, "platform")).isInstanceOf(BizException.class); + assertThatThrownBy(() -> platformEdit(11, 1, 0, "stale")).isInstanceOf(BizException.class); + assertThatThrownBy(() -> platformEdit(12, 1, 1, "cross")).isInstanceOf(BizException.class); + assertThat(snapshot()).isEqualTo(before); + } + + @Test + void platformEditRollbackDoesNotPublishAfterCommitPreparation() { + db.update("UPDATE ord_order SET status='SUBMITTED' WHERE id=1"); + db.execute("ALTER TABLE ord_idempotency_record ADD CONSTRAINT platform_fail CHECK(status<>'COMPLETED')"); + var before = snapshot(); + assertThatThrownBy(() -> platformEdit(11, 1, 0, "platform")).isInstanceOf(RuntimeException.class); + assertThat(snapshot()).isEqualTo(before); + verifyNoInteractions(firstleg); + db.execute("ALTER TABLE ord_idempotency_record DROP CONSTRAINT platform_fail"); + platformEdit(11, 1, 0, "platform"); + verify(firstleg).prepare(anyString(), any()); + } + + @Test + void platformEditDoesNotExpandToApprovedAndHasDistinctOperationScope() { + edit(11, 1, 0, "same-key"); + platformEdit(11, 1, 1, "same-key"); + assertThat(count("ord_idempotency_record")).isEqualTo(2); + db.update("UPDATE ord_order SET status='APPROVED' WHERE id=1"); + var before = snapshot(); + assertThatThrownBy(() -> platformEdit(11, 1, 2, "not-editable")).isInstanceOf(BizException.class); + assertThat(snapshot()).isEqualTo(before); + } + + @Test + void platformConcurrentEditsKeepOneVersionAndOnePreparation() throws Exception { + db.update("UPDATE ord_order SET status='SUBMITTED' WHERE id=1"); + assertThat(concurrent(() -> platformEdit(11, 1, 0, "same"), () -> platformEdit(11, 1, 0, "same"))).containsExactly(true, true); + assertThat(concurrent(() -> platformEdit(11, 1, 1, "a"), () -> platformEdit(11, 1, 1, "b"))).containsExactlyInAnyOrder(true, false); + assertThat(version(1)).isEqualTo(2); + assertThat(count("ord_order_status_history")).isEqualTo(2); + verify(firstleg, times(2)).prepare(anyString(), any()); + } + + @Test + void submitOriginalReplayKeepsInboundCodeHistoryOutboxAndPreparation() { + edit(11, 1, 0, "ready"); + submit(11, 1, 1, "submit"); + assertThat(status(1)).isEqualTo("SUBMITTED"); + assertThat(version(1)).isEqualTo(2); + assertThat(count("ord_outbox_event")).isEqualTo(1); + var before = snapshot(); + submit(11, 1, 1, "submit"); + assertThatThrownBy(() -> submit(11, 1, 2, "submit")).isInstanceOf(BizException.class); + assertThatThrownBy(() -> submit(11, 2, 1, "submit")).isInstanceOf(BizException.class); + assertThatThrownBy(() -> submit(11, 1, 2, "new-command")).isInstanceOf(BizException.class); + assertThat(snapshot()).isEqualTo(before); + verify(firstleg, times(1)).prepare(anyString(), any()); + } + + @Test + void submitStaleVersionWrongCustomerAndMissingVersionDoNotWrite() { + edit(11, 1, 0, "ready"); + var before = snapshot(); + assertThatThrownBy(() -> submit(11, 1, 0, "stale")).isInstanceOf(BizException.class); + assertThatThrownBy(() -> submit(12, 1, 1, "cross")).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.submit(11, 7, "operator", 1, new OrderRequests.SubmitRequest(null), "missing")).isInstanceOf(BizException.class); + assertThat(snapshot()).isEqualTo(before); + verifyNoInteractions(firstleg); + } + + @Test + void submitOutboxFailureRollsBackStateHistoryCommandAndPreparation() { + edit(11, 1, 0, "ready"); + db.execute("ALTER TABLE ord_outbox_event ADD CONSTRAINT submit_fail CHECK(event_type<>'FIRST_LEG_ORDER_PREPARED')"); + var before = snapshot(); + assertThatThrownBy(() -> submit(11, 1, 1, "submit")).isInstanceOf(RuntimeException.class); + assertThat(snapshot()).isEqualTo(before); + verifyNoInteractions(firstleg); + db.execute("ALTER TABLE ord_outbox_event DROP CONSTRAINT submit_fail"); + submit(11, 1, 1, "submit"); + verify(firstleg).prepare(anyString(), any()); + } + + @Test + void concurrentSubmitCommandsCannotDuplicateTransition() throws Exception { + edit(11, 1, 0, "ready"); + assertThat(concurrent(() -> submit(11, 1, 1, "same"), () -> submit(11, 1, 1, "same"))).containsExactly(true, true); + order(2, 11, "DRAFT", "MASTER", null); + edit(11, 2, 0, "ready-two"); + assertThat(concurrent(() -> submit(11, 2, 1, "a"), () -> submit(11, 2, 1, "b"))).containsExactlyInAnyOrder(true, false); + assertThat(count("ord_outbox_event")).isEqualTo(2); + verify(firstleg, times(2)).prepare(anyString(), any()); + } + + private void platformEdit(long customer, long id, int version, String key) { + service.updateSubmittedOrder(customer, 7, id, new OrderRequests.PlatformOrderUpdateRequest(version, draft), key); + } + private void submit(long customer, long id, int version, String key) { + service.submit(customer, 7, "operator", id, new OrderRequests.SubmitRequest(version), key); + } + @Test void editReplaysOriginalCommandBeforeCurrentVersionAndRejectsAnyChangedIdentity() { edit(11, 1, 0, "edit"); @@ -271,7 +387,10 @@ class MerchantOrderCommandTransactionTest { List> results = new ArrayList<>(); for (Runnable work : List.of(first, second)) results.add(workers.submit(() -> { start.await(); - try { work.run(); return true; } catch (BizException conflict) { return false; } + try { work.run(); return true; } catch (BizException conflict) { + assertThat(conflict.getResultCode()).isEqualTo(com.baiyue.common.core.api.ResultCode.CONFLICT); + return false; + } })); start.countDown(); return List.of(results.get(0).get(15, TimeUnit.SECONDS), results.get(1).get(15, TimeUnit.SECONDS)); diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index e9bcb4573..2de0dceaa 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -277,6 +277,7 @@ class OrderApplicationServiceStateTest { OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); OrderRequests.SaveRequest request = request(); OrderEntity order = new OrderEntity(); + order.setVersion(0); order.setId(1L); order.setTenantId(10001L); order.setStatus(OrderStatus.SUBMITTED); @@ -285,7 +286,7 @@ class OrderApplicationServiceStateTest { when(repository.saveOrder(10001L, order)).thenReturn(1); service(repository, mock(OrderEligibilityGuard.class), masterData) - .updateSubmittedOrder(10001L, 2L, 1L, request); + .updateSubmittedOrder(10001L, 2L, 1L, new OrderRequests.PlatformOrderUpdateRequest(0, request), "platform-edit"); assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED); verify(repository).replaceDraftChildren(10001L, 1L); @@ -389,7 +390,7 @@ class OrderApplicationServiceStateTest { when(repository.findCargo(20002L, 1L)).thenReturn(List.of(valid, missing)); assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)) - .submit(20002L, 2L, "u", 1L, "missing-sku")) + .submit(20002L, 2L, "u", 1L, new OrderRequests.SubmitRequest(0), "missing-sku")) .isInstanceOf(BizException.class).hasMessage("第 2 条一件代发货物必须填写 SKU NO.") .extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); assertThat(order.getStatus()).isEqualTo(OrderStatus.DRAFT); @@ -434,7 +435,7 @@ class OrderApplicationServiceStateTest { source.customsInspection(), source.destinationType(), source.amazonWarehouseCode(), source.shipmentId(), source.referenceId(), source.inboundCode(), "ONE_CLICK_DROPSHIP"); assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)) - .updateSubmittedOrder(10001L, 2L, 1L, missingSku)) + .updateSubmittedOrder(10001L, 2L, 1L, new OrderRequests.PlatformOrderUpdateRequest(0, missingSku), "platform-edit")) .isInstanceOf(BizException.class).hasMessage("第 1 条一件代发货物必须填写 SKU NO.") .extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); verify(repository, never()).saveOrder(anyLong(), any()); @@ -463,7 +464,7 @@ class OrderApplicationServiceStateTest { when(repository.findCargo(20002L, 1L)).thenReturn(List.of( mixedCargo("SKU-1", 10), mixedCargo("SKU-2", 11), third)); assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)) - .submit(20002L, 2L, "u", 1L, "mixed-carton-conflict")) + .submit(20002L, 2L, "u", 1L, new OrderRequests.SubmitRequest(0), "mixed-carton-conflict")) .isInstanceOf(BizException.class).hasMessage("同箱 SKU 的箱数、包装类型、箱规、重量和体积必须一致") .extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); assertThat(order.getStatus()).isEqualTo(OrderStatus.DRAFT); @@ -501,7 +502,7 @@ class OrderApplicationServiceStateTest { when(repository.findOwned(10001L, 1L)).thenReturn(order); var request = mixedCartonRequest(31); assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)) - .updateSubmittedOrder(10001L, 2L, 1L, request)) + .updateSubmittedOrder(10001L, 2L, 1L, new OrderRequests.PlatformOrderUpdateRequest(0, request), "platform-edit")) .isInstanceOf(BizException.class).hasMessage("同箱 SKU 的箱数、包装类型、箱规、重量和体积必须一致") .extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); verify(repository, never()).saveOrder(anyLong(), any()); @@ -717,6 +718,7 @@ class OrderApplicationServiceStateTest { OrderRepository repository = mock(OrderRepository.class); OrderEligibilityGuard eligibility = mock(OrderEligibilityGuard.class); OrderEntity order = new OrderEntity(); + order.setVersion(0); order.setId(1L); order.setTenantId(10001L); order.setStatus(OrderStatus.DRAFT); @@ -725,7 +727,7 @@ class OrderApplicationServiceStateTest { .when(eligibility).requireApproved(10001L); assertThatThrownBy(() -> service(repository, eligibility, mock(OrderMasterDataGuard.class)) - .submit(10001L, 2L, "u", 1L, "submit-before-approval")) + .submit(10001L, 2L, "u", 1L, new OrderRequests.SubmitRequest(0), "submit-before-approval")) .isInstanceOf(BizException.class) .extracting(exception -> ((BizException) exception).getResultCode()) .isEqualTo(ResultCode.FORBIDDEN); @@ -1068,6 +1070,7 @@ class OrderApplicationServiceStateTest { private OrderEntity persistedDraft(String pickupMethod, String destinationType) { OrderEntity order = new OrderEntity(); + order.setVersion(0); order.setId(1L); order.setTenantId(20002L); order.setStatus(OrderStatus.DRAFT); diff --git a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java index e8f6947de..8914aa3aa 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java @@ -121,7 +121,7 @@ class OrderIdempotencyServiceTest { when(repository.findIdempotency(10001L, 20001L, "SUBMIT_ORDER", "submit-key")) .thenReturn(existing); - service(repository).submit(10001L, 20001L, "operator", 998L, "submit-key"); + service(repository).submit(10001L, 20001L, "operator", 998L, new OrderRequests.SubmitRequest(0), "submit-key"); verify(repository, never()).findOwned(anyLong(), anyLong()); verify(repository, never()).saveOrder(anyLong(), any()); diff --git a/scripts/README.md b/scripts/README.md index e37862961..b3bb97ad6 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,6 @@ # 后端脚本管理与全量 HTTP 验收索引 +头程订单提交使用首次原始详情的 `expectedVersion` 与原 `Idempotency-Key`。生产单票的 `SUBMIT_INTENT` 必须先持久化 `submitCommand` 再发送,显式续跑不得使用当前版本替换原命令,缺少原命令的提交意图及后续检查点停止并等待人工核对。源 Excel 造数同样在提交前保存订单条目的 `submitCommand`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。 `event-delivery-fault-proxy.py` 是 TEST 环境定向事件 ACK 丢失工具,不创建或重放事件。运行者显式传入 `--upstream` 完整消费者地址、`--event-type`,并只选 `--event-id`、`--loading-plan-id` 或 `--container-booking-id`;柜次选择精确匹配 payload.sourceContainerBookingId,用于海外卸柜回写,不接受混合选择条件。`EVENT_FAULT_CONTROL_TOKEN` 从外部环境注入,保护固定本机监听的控制端口。默认 PASS;经鉴权切为 FAULT 后,仅在选定事件已真实转发且收到匹配的 APPLIED/DUPLICATE ACK 时向发送方返回 503,其他事件直通。同一选择范围内锁定首个事件后不改选其他事件 ID,原 ID 改摘要或载荷直接拒绝。日志只保留事件标识、摘要和结果,不保留正文或凭据。它会影响实际投递,仅限明确授权的测试环境;生产者配置、重试、DEAD 原事件人工恢复及最终恢复直连由运行者负责,工具退出不自动恢复服务配置。`--log` 可显式指定脱敏证据文件,失败保留原事件与日志,禁止改摘要或借其他事件重跑。`event-delivery-fault-proxy.test.mjs` 使用本机替身服务验证行为,不访问共享环境;工具离线通过不能替代真实重试验收。 `admin-test-env-acceptance.mjs` 与 `client-test-env-acceptance.mjs` 仅接受统一 TEST Gateway 和 Auth 会话;移除环境文件中的 `ADMIN_OVERSEAS_GATEWAY`,不再传 `--overseas-gateway` 或非 test 环境。平台、客户、PDA 身份仍分别登录并验证权限及客户隔离。海外资源接口使用 `/api/platform/v1/overseas-warehouse-resources/**`,仓库主档仍归 Warehouse。跨服务模块的重复 GET 不证明事件重放;真实重试/幂等证据缺失会报告部署阻断,详见模块 README 和下方事件人工重试入口。离线合同不会执行真实 HTTP;环境执行仍需本轮写入授权。 diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 4f915ea17..0b1a0c642 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -1,5 +1,6 @@ # Admin 测试环境 HTTP 模块 +头程提交、平台编辑与取消按首次原始详情版本冻结请求。原键原载荷重放成功;同键异载荷、旧版本及新键重复操作分别核验拒绝后事实不变。Platform 编辑使用平台权限与显式客户范围,客户 Token 不得调用。 `phase5-overseas-fulfillment.mjs` 的 `pickAll`、`saveReceiptAndComplete` 也供显式授权的 `../mixed-container-outbound-test-env.mjs` 留存混柜续跑入口复用;仍使用正式 PDA 拣货和签收文件绑定接口,不代替全模块验收。 总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。 @@ -8,7 +9,7 @@ `phase5-root-fulfillment.mjs` 是显式 TEST 的 F40 两箱根/余量专项,通过总入口 `--modules phase5-root-fulfillment` 准备本轮资质客户;模块用正式下单、测量、国内 PDA 拆分装柜、船节点、海外 PDA 收货、拣货发车及文件签收链创建两柜真实来源。默认根先签,编程调用 `AcceptanceRun.context.f40SignatureOrder='CHILD_FIRST'` 切换子先签;分别核对第一片签收不得根完成、最后一片签收才根完成、真实签收时间、状态历史和重放零新增。 -`AcceptanceRun.context.f40SignatureOrder='ROOT_SIGNED_REMAINDER_CANCELLED'` 单独验证合法末票余量取消:本轮新根通过 `orderStopAtApproved=true` 停在原始 `APPROVED`,只装运签收首箱;回查真实履约树及未装子单原始 `APPROVED`、入仓 `WAIT_SPLIT` 和一箱余量后,调用公开取消接口。`PROCESSING` 子单不得作为取消样本。根完成事件必须唯一发布,取消子单完成事件显式为零;原取消请求重放须 `A0409`,且根子版本、历史、摘要、履约树及完成 Outbox 零漂移。两种双签模式仍须两片实际签收,不能用取消替代第二签。 +`AcceptanceRun.context.f40SignatureOrder='ROOT_SIGNED_REMAINDER_CANCELLED'` 单独验证合法末票余量取消:本轮新根通过 `orderStopAtApproved=true` 停在原始 `APPROVED`,只装运签收首箱;回查真实履约树及未装子单原始 `APPROVED`、入仓 `WAIT_SPLIT` 和一箱余量后,调用公开取消接口。`PROCESSING` 子单不得作为取消样本。根完成事件必须唯一发布,取消子单完成事件显式为零;原键原版本原载荷取消重放成功;同键异载荷及新键重复取消返回 `A0409`,根子版本、历史、摘要、履约树及完成 Outbox 均不漂移。两种双签模式仍须两片实际签收,不能用取消替代第二签。 会写已授权测试环境,保留新客户、单据、文件及报告,临时海外 PDA 在 finally 停用;失败保留样本,不自动删除或重复执行部分已完成链。签收资料使用新 key 保存同文件时,按正式保存命令递增一次资料版本、新增一条版本历史及 `SAVE_DOCUMENTS` 审计;签收时间、状态、原完成记录、文件绑定和根子完成事件不得变化。同一新命令再用原 key、原请求重放,必须明确 `replayed=true` 且完整资料、派送事实、版本和审计历史零漂移。资料保存前后及原 key 重放响应保存在 `phase5RootFulfillment.receiptReplayProofs`,失败也保留已取得的快照。 diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs index 2ad54d750..0e2354f5e 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs @@ -4,6 +4,20 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { transition } from './order-inbound-status-fixtures.mjs' + +test('dynamic Platform cancellation freezes version and key while reject body stays unchanged',async()=>{ + const calls=[] + const context={marker:'fixture',token:'admin',apiSuccess(_m,_n,r){assert.equal(r.body.code,'200');return r.body.data}, + async request(method,path,body,token,headers){calls.push({method,path,body,token,headers});return {http:200,body:{code:'200',data:{order:{version:9}}}}}} + await transition(context,'merchant',{tenantId:'2'},'1','CANCELLED','cancel') + const command=calls.find(row=>row.method==='POST') + assert.equal(command.token,'admin');assert.equal(command.body.expectedVersion,9);assert.ok(command.headers['Idempotency-Key']) + calls.length=0 + await transition(context,'merchant',{tenantId:'2'},'1','REJECTED','reject') + assert.equal(calls.length,1);assert.deepEqual(calls[0].body,{reason:'fixture 保留 REJECTED 人工样本'}) +}) + const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'order-inbound-status-fixtures.mjs'), 'utf8') diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs index e9428979f..c30085dd9 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs @@ -1,4 +1,4 @@ -import { findPricedOrderRouteAcrossOrigins } from './orders.mjs' +import { findPricedOrderRouteAcrossOrigins, freezeOrderCommand } from './orders.mjs' const MODULE = 'order-inbound-status-fixtures' const OPTIONS = { scenario: 'manual-order-inbound-status-matrix', deploymentBlocker: true } @@ -117,17 +117,21 @@ async function createOrder(context, token, fixture, target) { return orderId } -async function transition(context, token, fixture, orderId, target, action) { +export async function transition(context, token, fixture, orderId, target, action) { const merchantPath = `/api/merchant/v1/orders/first-leg/${query(orderId)}` if (action === 'submit') { - const response = await merchantRequest(context, 'POST', `${merchantPath}/submit`, undefined, token, - { 'Idempotency-Key': key(context, `${target}-submit`) }) + const detail = context.apiSuccess(MODULE, `${target} 冻结提交版本`, await merchantRequest(context, 'GET', merchantPath, undefined, token)) + const command = freezeOrderCommand(detail, key(context, `${target}-submit`)) + const response = await merchantRequest(context, 'POST', `${merchantPath}/submit`, command.body, token, command.headers) context.apiSuccess(MODULE, `${target} 样本提交订单`, response) return } const platformPath = `/api/platform/v1/orders/first-leg/${query(orderId)}/${action}?tenantId=${query(fixture.tenantId)}` const body = action === 'reject' || action === 'cancel' ? { reason: `${context.marker} 保留 ${target} 人工样本` } : undefined - const response = await context.request('POST', platformPath, body) + const command = action === 'cancel' ? freezeOrderCommand(context.apiSuccess(MODULE, `${target} 冻结取消版本`, + await context.request('GET', `/api/platform/v1/orders/first-leg/${query(orderId)}?tenantId=${query(fixture.tenantId)}`)), + key(context, `${target}-cancel`), body) : null + const response = await context.request('POST', platformPath, command?.body ?? body, context.token, command?.headers ?? {}) context.apiSuccess(MODULE, `${target} 样本执行 ${action}`, response) } diff --git a/scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs b/scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs index 6e893fad5..d3d203340 100644 --- a/scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs +++ b/scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs @@ -2,7 +2,36 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { fixtureInboundCode, orderPersistenceFacts, requestedOrderDestinationType } from './orders.mjs' +import { fixtureInboundCode, orderPersistenceFacts, requestedOrderDestinationType, freezeOrderCommand, verifyPlatformDraftEdit } from './orders.mjs' + +test('order command freezes original detail version/key and never accepts an absent version',()=>{ + const detail={order:{version:3}},draft={customerRemark:'original'} + const command=freezeOrderCommand(detail,'key',{draft}) + detail.order.version=8;draft.customerRemark='changed' + assert.deepEqual(command,{body:{draft:{customerRemark:'original'},expectedVersion:3},headers:{'Idempotency-Key':'key'}}) + assert.ok(Object.isFrozen(command.body.draft)) + for(const version of [undefined,null,'3',-1])assert.throws(()=>freezeOrderCommand({order:{version}},'key')) +}) + +test('Platform edit uses exact customer and admin identity; replay/stale/foreign writes preserve full facts',async()=>{ + let detail={order:{id:'10',tenantId:'20',status:'DRAFT',version:3,customerRemark:'marker'},cargoes:[],parties:[],internalNotes:[],statusHistory:[]} + let saved + const writes=[] + const context={marker:'marker',token:'admin',createdResources:{orders:{}},apiSuccess(_m,_n,r){assert.equal(r.body.code,'200');return r.body.data}, + assert(_m,name,_r,ok){assert.ok(ok,name)},async request(method,path,body,token,headers){ + assert.equal(path,'/api/platform/v1/orders/first-leg/10?tenantId=20') + if(method==='GET')return {http:200,body:{code:'200',data:structuredClone(detail)}} + writes.push({body,token,headers}) + let code='200' + if(token!=='admin')code='A0403' + else if(saved)code=saved.key===headers['Idempotency-Key']&&saved.payload===JSON.stringify(body)?'200':'A0409' + else {assert.equal(body.expectedVersion,3);saved={key:headers['Idempotency-Key'],payload:JSON.stringify(body)};detail.order.version++;detail.order.customerRemark=body.draft.customerRemark;detail.statusHistory.push({action:'UPDATE'})} + return {http:200,body:{code}} + }} + const result=await verifyPlatformDraftEdit(context,'20','10','merchant',{customerRemark:'marker'}) + assert.equal(result.order.version,4);assert.equal(writes.length,5) + assert.deepEqual(writes[1],writes[2]);assert.equal(writes[0].token,'merchant') +}) const source = readFileSync(new URL('orders.mjs', import.meta.url), 'utf8') diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index 823c6a3f6..e7f286337 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -34,6 +34,47 @@ function rotate(values, offset) { return [...values.slice(shift), ...values.slice(0, shift)] } function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) } +export function freezeOrderCommand(detail, idempotencyKey, payload = {}) { + const version = detail?.order?.version + if (!Number.isSafeInteger(version) || version < 0 || !idempotencyKey) throw new Error('订单首次详情版本或幂等键缺失') + const command = structuredClone({ body: { ...payload, expectedVersion: version }, headers: { 'Idempotency-Key': idempotencyKey } }) + const freeze = value => { if (value && typeof value === 'object') { Object.values(value).forEach(freeze); Object.freeze(value) } } + freeze(command) + return command +} +export async function verifyPlatformDraftEdit(context, tenantId, orderId, merchantToken, payload) { + const path = `/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}` + const read = async label => context.apiSuccess(MODULE, label, await context.request('GET', path)) + const before = await read('Admin 编辑前冻结客户订单版本') + if (id(before?.order?.tenantId) !== id(tenantId) || id(before?.order?.id) !== id(orderId) || before.order.status !== 'DRAFT') throw new Error('平台编辑客户/草稿身份不符') + const editedPayload = structuredClone(payload) + editedPayload.customerRemark = `${payload.customerRemark} 平台编辑验收` + const command = freezeOrderCommand(before, key(context, 'platform-draft-edit'), { draft: editedPayload }) + context.createdResources.orders.platformEditCommand = command + const send = (body, headers, token = context.token) => context.request('PUT', path, body, token, headers) + const unauthorized = await send(command.body, command.headers, merchantToken) + context.assert(MODULE, 'Client不得调用Platform编辑接口', unauthorized, + [200,403].includes(unauthorized.http) && String(unauthorized.body?.code) === 'A0403', 'merchant-token/platform-route', CONTRACT_OPTIONS) + const afterDenied = await read('平台编辑越权拒绝后回查') + context.assert(MODULE, '越权平台编辑后完整事实不变', null, orderPersistenceFacts(before) === orderPersistenceFacts(afterDenied), 'no-drift', CONTRACT_OPTIONS) + context.apiSuccess(MODULE, 'Admin按客户范围版本化编辑草稿', await send(command.body, command.headers)) + const edited = await read('平台编辑后权威详情') + context.assert(MODULE, '平台编辑保持身份且版本与UPDATE历史递增', null, + id(edited?.order?.id) === id(orderId) && id(edited.order.tenantId) === id(tenantId) && edited.order.status === 'DRAFT' + && edited.order.version > before.order.version && edited.order.customerRemark === editedPayload.customerRemark + && edited.statusHistory.length === before.statusHistory.length + 1, 'versioned-edit', CONTRACT_OPTIONS) + context.apiSuccess(MODULE, 'Platform编辑原键原版本重放成功', await send(command.body, command.headers)) + for (const [label, body, headers] of [ + ['Platform编辑原键异载荷冲突', {...command.body, draft:{...editedPayload,customerRemark:`${editedPayload.customerRemark}-conflict`}},command.headers], + ['Platform编辑旧页面版本冲突', command.body, {'Idempotency-Key':key(context,'platform-edit-stale')}], + ]) { + const rejected = await send(body, headers) + context.assert(MODULE, label, rejected, rejected.http === 200 && String(rejected.body?.code) === 'A0409', 'exact-conflict', CONTRACT_OPTIONS) + } + const stable = await read('Platform重放及冲突后完整事实') + context.assert(MODULE, 'Platform重放与拒绝不重复写入', null, orderPersistenceFacts(stable) === orderPersistenceFacts(edited), 'order/cargo/parties/history', CONTRACT_OPTIONS) + return stable +} export function fixtureInboundCode(policy, marker) { if (policy?.mode === 'AUTO' && policy.manualEntryRequired === false) return {} if (policy?.mode !== 'LEGACY_MANUAL' || policy.manualEntryRequired !== true || !marker) { @@ -82,8 +123,8 @@ async function verifyCargoRequirementNegatives(context, payload, policy, merchan const orderId = id(created.body?.data) if (!orderId) throw new Error('negative draft missing order id') const before = await request(context, 'GET', `${base}/${orderId}`, undefined, merchantToken) - const submitted = await request(context, 'POST', `${base}/${orderId}/submit`, undefined, merchantToken, - { 'Idempotency-Key': key(context, `cargo-negative-${index}-submit`) }) + const submitCommand = freezeOrderCommand(context.apiSuccess(MODULE, '负向提交冻结草稿版本', before), key(context, `cargo-negative-${index}-submit`)) + const submitted = await request(context, 'POST', `${base}/${orderId}/submit`, submitCommand.body, merchantToken, submitCommand.headers) const after = await request(context, 'GET', `${base}/${orderId}`, undefined, merchantToken) context.assert(MODULE, `${payload.destinationType}/${payload.fulfillmentType} ${item.name}提交拒绝且草稿零漂移`, submitted, submitted.http === 200 && String(submitted.body?.code) === 'A0422' @@ -805,7 +846,7 @@ export async function run(context) { const clientDetailPath = `/api/merchant/v1/orders/first-leg/${encodeURIComponent(orderId)}` const draftResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken) - const draft = context.apiSuccess(MODULE, 'Client 查询隔离订单草稿', draftResponse) + let draft = context.apiSuccess(MODULE, 'Client 查询隔离订单草稿', draftResponse) const orderNo = id(draft?.order?.orderNo) context.assert(MODULE, 'Client 草稿身份、marker 和状态正确', draftResponse, id(draft?.order?.id) === orderId && draft?.order?.status === 'DRAFT' @@ -813,6 +854,7 @@ export async function run(context) { `orderNo=${orderNo || 'missing'}, status=${draft?.order?.status ?? 'missing'}`) assertCargoContract(context, draftResponse, draft?.cargoes, payload) context.createdResources.orders.orderNo = orderNo + draft = await verifyPlatformDraftEdit(context, tenantId, orderId, merchantToken, payload) const createConflictPayload = { ...payload, @@ -829,18 +871,25 @@ export async function run(context) { orderPersistenceFacts(draftAfterConflict) === orderPersistenceFacts(draft), `orderId=${orderId}, status=${draftAfterConflict?.order?.status ?? 'missing'}`, CONTRACT_OPTIONS) - const submitResponse = await requestTransportRetry(context, 'POST', `${clientDetailPath}/submit`, undefined, - merchantToken, { 'Idempotency-Key': key(context, 'client-submit') }) + const submitCommand = freezeOrderCommand(draft, key(context, 'client-submit')) + context.createdResources.orders.submitCommand = submitCommand + const submitResponse = await requestTransportRetry(context, 'POST', `${clientDetailPath}/submit`, submitCommand.body, + merchantToken, submitCommand.headers) context.apiSuccess(MODULE, 'Client 提交隔离订单', submitResponse, `orderId=${orderId}`) - const submitReplayResponse = await request(context, 'POST', `${clientDetailPath}/submit`, undefined, - merchantToken, { 'Idempotency-Key': key(context, 'client-submit') }) + const submitReplayResponse = await request(context, 'POST', `${clientDetailPath}/submit`, submitCommand.body, + merchantToken, submitCommand.headers) context.apiSuccess(MODULE, 'Client 提交订单同键同载荷精确重放', submitReplayResponse, `orderId=${orderId}`) const submittedResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken) const submitted = context.apiSuccess(MODULE, 'Client 回查已提交订单', submittedResponse) context.assert(MODULE, 'Client 订单已进入待审核状态', submittedResponse, submitted?.order?.status === 'SUBMITTED', `status=${submitted?.order?.status ?? 'missing'}`) - const duplicateSubmitResponse = await request(context, 'POST', `${clientDetailPath}/submit`, undefined, - merchantToken, { 'Idempotency-Key': key(context, 'client-submit-new-key') }) + const conflictSubmitResponse = await request(context, 'POST', `${clientDetailPath}/submit`, { expectedVersion: submitted.order.version }, + merchantToken, submitCommand.headers) + context.assert(MODULE, '提交同键异版本返回A0409', conflictSubmitResponse, + conflictSubmitResponse.http === 200 && String(conflictSubmitResponse.body?.code) === 'A0409', 'frozen-key/different-payload', CONTRACT_OPTIONS) + const duplicateCommand = freezeOrderCommand(submitted, key(context, 'client-submit-new-key')) + const duplicateSubmitResponse = await request(context, 'POST', `${clientDetailPath}/submit`, duplicateCommand.body, + merchantToken, duplicateCommand.headers) context.assert(MODULE, '已提交订单使用新键重复提交返回A0409', duplicateSubmitResponse, duplicateSubmitResponse.http === 200 && String(duplicateSubmitResponse.body?.code) === 'A0409', `http=${duplicateSubmitResponse.http}, code=${duplicateSubmitResponse.body?.code ?? 'missing'}`, CONTRACT_OPTIONS) diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs index d6fe85f94..28a651657 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs @@ -323,12 +323,16 @@ function cancellationFixture(change=null) { return cancellationOutbox() }} if(change)change({rootDetail,childDetail,childInbound,tree}) - context.request=async(method,path,body)=>{ - calls.push({method,path,body}) + let frozenCancel + context.request=async(method,path,body,token,headers)=>{ + calls.push({method,path,body,headers}) if(method==='POST'){ assert.equal(path,'/api/platform/v1/orders/first-leg/101/cancel?tenantId=90') assert.ok(body.reason.includes('offline-only')) - if(count++)return {http:200,body:{code:'A0409'}} + assert.ok(headers?.['Idempotency-Key']) + if(count++)return {http:200,body:{code:headers['Idempotency-Key']===frozenCancel.key && JSON.stringify(body)===frozenCancel.payload ? '200' : 'A0409'}} + assert.equal(body.expectedVersion,childDetail.order.version) + frozenCancel={key:headers['Idempotency-Key'],payload:JSON.stringify(body)} childDetail.order.status='CANCELLED';childDetail.order.version++ childDetail.statusHistory=history('CANCELLED') rootDetail.order.status='COMPLETED';rootDetail.order.version++ @@ -352,7 +356,13 @@ test('formal cancellation helper rereads physical eligibility, uses public cance const {context,proof,calls}=cancellationFixture() const result=await cancelUnloadedRemainder(context,proof,'90','100','101','300','301') assert.equal(validRemainderCancellationGate(result),true) - assert.equal(calls.filter(row=>row.method==='POST').length,2) + const writes=calls.filter(row=>row.method==='POST') + assert.equal(writes.length,4) + assert.deepEqual(writes[1].body,writes[0].body) + assert.deepEqual(writes[1].headers,writes[0].headers) + assert.equal(writes[2].headers['Idempotency-Key'],writes[0].headers['Idempotency-Key']) + assert.notEqual(writes[3].headers['Idempotency-Key'],writes[0].headers['Idempotency-Key']) + assert.ok(writes[3].body.expectedVersion>writes[0].body.expectedVersion) assert.equal(proof.snapshots.length,3) assert.deepEqual(proof.cancelReplayOutbox.before,proof.cancelReplayOutbox.after) assert.equal(context.steps.every(step=>step.passed),true) diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs index faba94683..8b3571905 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs @@ -1,4 +1,5 @@ import { loadOneFragment } from './phase3-order-splitting.mjs' +import { freezeOrderCommand } from './orders.mjs' import { verifyPalletLabels } from './phase3-loading-finalize.mjs' import { stableLoadedCartonMarks } from './phase3-loading.mjs' import { awaitProducerConsumption, convertReceivingContainers, runReceiving, resolveSharedWarehouse, @@ -247,9 +248,12 @@ export async function cancelUnloadedRemainder(context, proof, customerId, rootOr 'raw-approved-root/child; one-loaded-root/one-unloaded-remainder') proof.snapshots.push({phase:'BEFORE_REMAINDER_CANCELLATION',...before,tree,childInbound:childInbound.summary}) const cancelPath = query(`/api/platform/v1/orders/first-leg/${childOrderId}/cancel`,{tenantId:customerId}) - const cancelBody = {reason:`${context.context.f40BaseMarker} 未装柜余量取消验收`} + const cancelCommand = freezeOrderCommand({order:before.child.rawOrder}, `${context.context.f40BaseMarker}-cancel-remainder`.slice(0,128), + {reason:`${context.context.f40BaseMarker} 未装柜余量取消验收`}) + proof.cancelCommand = cancelCommand + const cancelBody = cancelCommand.body proof.currentStage = 'REMAINDER_CANCELLATION' - await api(context,'POST','正式取消未装柜 APPROVED 余量订单',cancelPath,cancelBody) + await api(context,'POST','正式取消未装柜 APPROVED 余量订单',cancelPath,cancelBody,context.token,cancelCommand.headers) const after = await waitState(() => state(context,customerId,rootOrderId,childOrderId,rootInboundId), validRemainderCancellationGate) check(context,'F40 最后有效未装余量取消后根仅完成一次且子未签收',validRemainderCancellationGate(after), @@ -268,9 +272,13 @@ export async function cancelUnloadedRemainder(context, proof, customerId, rootOr value => exactCancelledRemainderOutboxProof(value,customerId,rootOrderId,childOrderId),30) : null if (beforeOutbox) check(context,'F40 取消后唯一根完成Outbox且取消子完成Outbox为零', exactCancelledRemainderOutboxProof(beforeOutbox,customerId,rootOrderId,childOrderId),'exact-root-one/cancelled-child-zero') - const replay = await context.request('POST',cancelPath,cancelBody) - check(context,'F40 取消重放按当前非幂等状态接口返回 A0409', - replay?.http === 200 && String(replay?.body?.code) === 'A0409','cancel-replay-conflict',replay) + const replay = await context.request('POST',cancelPath,cancelBody,context.token,cancelCommand.headers) + check(context,'F40 原键原版本取消重放成功',success(replay),'cancel-original-key-replay',replay) + const conflict = await context.request('POST',cancelPath,{...cancelBody,reason:`${cancelBody.reason}-conflict`},context.token,cancelCommand.headers) + check(context,'F40 原键不同取消载荷返回 A0409',conflict?.http === 200 && String(conflict?.body?.code) === 'A0409','cancel-payload-conflict',conflict) + const duplicate = freezeOrderCommand({order:after.child.rawOrder}, `${context.context.f40BaseMarker}-cancel-new-key`.slice(0,128), {reason:cancelBody.reason}) + const rejected = await context.request('POST',cancelPath,duplicate.body,context.token,duplicate.headers) + check(context,'F40 新键重复取消返回 A0409',rejected?.http === 200 && String(rejected?.body?.code) === 'A0409','cancel-new-key-conflict',rejected) const stable = await state(context,customerId,rootOrderId,childOrderId,rootInboundId) const replayTree = await api(context,'GET','取消重放后回查原履约树',treePath) check(context,'F40 取消重放订单版本历史根摘要及履约台账零变化', diff --git a/scripts/client-test-env-acceptance-contract.test.mjs b/scripts/client-test-env-acceptance-contract.test.mjs index 3833f3d1e..a55e5a2d1 100644 --- a/scripts/client-test-env-acceptance-contract.test.mjs +++ b/scripts/client-test-env-acceptance-contract.test.mjs @@ -38,6 +38,20 @@ test('frozen edit command preserves original version, key and nested payload thr assert.throws(() => r.orderMutationCommand(snapshot, payload, ''), /key/) }) +test('submit command carries only frozen expectedVersion with the same original replay key',()=>{ + const r=runtime(),detail={order:{version:4}} + const command=r.orderMutationCommand(detail,{},'submit-original') + detail.order.version=9 + assert.deepEqual(plain(command),{headers:{'Idempotency-Key':'submit-original'},body:{expectedVersion:4}}) + assert.match(source,/const submitCommand = orderMutationCommand\(submitBefore, \{\}, submitKey\)/) + assert.match(source,/submit replay keeps version and history unchanged/) + assert.match(source,/submit same key different version conflicts/) + assert.match(source,/submit new key cannot submit already submitted order/) + for(const name of ['pre-approval-submit','incomplete-submit','cancelled-submit','cancelled-submit-proof']) { + assert.match(source,new RegExp(`orderMutationCommand\\([^\\n]+(?:\\n[^\\n]+)?newIdempotencyKey\\('${name}'\\)`)) + } +}) + test('zero-drift validator detects changes in every aggregate and preserves array ordering', () => { const r = runtime(), before = { order: { version: 2 }, cargoes: [{ id: '1', amount: 2 }], parties: [], internalNotes: [], statusHistory: [{ id: 'h1' }, { id: 'h2' }] } r.assertOrderUnchanged(before, structuredClone(before)) diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index 2fefd232e..e28771818 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -221,7 +221,7 @@ async function merchantBeforeApprovalModule() { await apiStep('order-before-approval', 'submit is blocked before approval', { method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken, - headers: { 'Idempotency-Key': newIdempotencyKey('pre-approval-submit') }, + ...orderMutationCommand(await ownedOrderSnapshot('order-before-approval', 'freeze pre-approval submit version', orderId), {}, newIdempotencyKey('pre-approval-submit')), }, ({ json, status }) => { assertEqual(status, 200, 'HTTP status') assertEqual(String(json?.code), 'A0403', 'business code') @@ -1118,7 +1118,7 @@ async function orderLifecycleModule() { report.resources.orderIds.push(draft.json.data) await softApiStep('order-validation', `${testCase.name} is rejected on submit`, { method: 'POST', path: `/api/merchant/v1/orders/first-leg/${draft.json.data}/submit`, token: merchantToken, - headers: { 'Idempotency-Key': newIdempotencyKey('incomplete-submit') }, + ...orderMutationCommand(await ownedOrderSnapshot('order-validation', 'freeze incomplete submit version', draft.json.data), {}, newIdempotencyKey('incomplete-submit')), }, ({ json, status }) => { assertEqual(status, 200, 'HTTP status') assertEqual(String(json?.code), 'A0422', 'business code') @@ -1269,15 +1269,29 @@ async function orderLifecycleModule() { }, ({ json }) => ({ total: json.data?.total, orderId, status: 'DRAFT' })) const submitKey = newIdempotencyKey('approved-submit') + const submitBefore = await ownedOrderSnapshot('order-lifecycle', 'freeze submission page version', orderId) + const submitCommand = orderMutationCommand(submitBefore, {}, submitKey) + recordOrderMutationRequest('initial lifecycle submit', {method:'POST',path:`/api/merchant/v1/orders/first-leg/${orderId}/submit`,...submitCommand}, submitBefore) await apiStep('order-lifecycle', 'submit order', { method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken, - headers: { 'Idempotency-Key': submitKey }, + ...submitCommand, }, ({ json }) => requireSuccess(json), () => ({ orderId })) + const submittedBeforeReplay = await ownedOrderSnapshot('order-lifecycle', 'freeze successful submission facts', orderId) await apiStep('order-lifecycle', 'submit order idempotent replay', { method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken, - headers: { 'Idempotency-Key': submitKey }, + ...submitCommand, }, ({ json }) => requireSuccess(json), () => ({ orderId, replayed: true })) + await ownedOrderSnapshot('order-lifecycle', 'submit replay keeps version and history unchanged', orderId, submittedBeforeReplay) + const submitPath = `/api/merchant/v1/orders/first-leg/${orderId}/submit` + await rejectOrderMutation('submit same key different version conflicts', orderId, { + method: 'POST', path: submitPath, token: merchantToken, headers: submitCommand.headers, + body: { expectedVersion: submittedBeforeReplay.order.version }, + }, ['A0409']) + await rejectOrderMutation('submit new key cannot submit already submitted order', orderId, { + method: 'POST', path: submitPath, token: merchantToken, + ...orderMutationCommand(submittedBeforeReplay, {}, newIdempotencyKey('new-key-submit')), + }, ['A0409']) await apiStep('order-lifecycle', 'submitted status is persisted', { method: 'GET', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, @@ -1392,7 +1406,7 @@ async function orderLifecycleModule() { }, ({ json }) => ({ orderId: cancelOrderId, rejectionCode: json.code, message: json.message })) await softApiStep('order-boundary', 'cancelled order cannot be submitted', { method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/submit`, token: merchantToken, - headers: { 'Idempotency-Key': newIdempotencyKey('cancelled-submit') }, + ...orderMutationCommand(await ownedOrderSnapshot('order-boundary', 'freeze cancelled submit version', cancelOrderId), {}, newIdempotencyKey('cancelled-submit')), }, ({ json, status }) => { assertEqual(status, 200, 'HTTP status') assertEqual(String(json?.code), 'A0409', 'business code') @@ -2290,7 +2304,8 @@ async function orderMutationEvidenceModule() { ...orderMutationCommand(cancelled, { reason }, newIdempotencyKey('new-key-repeat-cancel')), }, ['A0409']) await rejectOrderMutation('cancelled draft cannot be submitted', id, { - method: 'POST', path: `${base}/${id}/submit`, token: merchantToken, headers: { 'Idempotency-Key': newIdempotencyKey('cancelled-submit-proof') }, + method: 'POST', path: `${base}/${id}/submit`, token: merchantToken, + ...orderMutationCommand(cancelled, {}, newIdempotencyKey('cancelled-submit-proof')), }, ['A0409']) report.orderMutationLimitations = [ 'Requires deployed Merchant edit/cancel versioned idempotent protocol; local contracts do not prove TEST deployment.', diff --git a/scripts/create-pending-review-orders-contract.test.mjs b/scripts/create-pending-review-orders-contract.test.mjs index 764b20d91..c6545c7d2 100644 --- a/scripts/create-pending-review-orders-contract.test.mjs +++ b/scripts/create-pending-review-orders-contract.test.mjs @@ -2,7 +2,16 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { PRODUCT_CATALOG, buildOrderPayload, diversifyRoutes, parseArguments, randomOrderOptions, selectSameChannelRoutes, validateCount } from './create-pending-review-orders.mjs' +import { PRODUCT_CATALOG, buildOrderPayload, diversifyRoutes, parseArguments, randomOrderOptions, selectSameChannelRoutes, validateCount, pendingSubmitCommand } from './create-pending-review-orders.mjs' + +test('submission binds original detail identity version and immutable key',()=>{ + const detail={order:{id:'42',version:7}}, command=pendingSubmitCommand(detail,'42','original') + detail.order.version=99 + assert.deepEqual(command,{body:{expectedVersion:7},headers:{'Idempotency-Key':'original'}}) + assert.ok(Object.isFrozen(command.body)) + for(const version of [undefined,null,'7',-1])assert.throws(()=>pendingSubmitCommand({order:{id:'42',version}},'42','key')) + assert.throws(()=>pendingSubmitCommand(detail,'other','key')) +}) const source = readFileSync(new URL('./create-pending-review-orders.mjs', import.meta.url), 'utf8') diff --git a/scripts/create-pending-review-orders.mjs b/scripts/create-pending-review-orders.mjs index 9dc46dad4..742a04e12 100644 --- a/scripts/create-pending-review-orders.mjs +++ b/scripts/create-pending-review-orders.mjs @@ -31,6 +31,13 @@ export const PRODUCT_CATALOG = [ const DEFAULT_COUNT = 50 +export function pendingSubmitCommand(detail, orderId, idempotencyKey) { + if (String(detail?.order?.id) !== String(orderId) || !Number.isSafeInteger(detail?.order?.version) + || detail.order.version < 0 || !idempotencyKey) throw new Error('Missing original order identity/version/key for submit') + return Object.freeze({ body: Object.freeze({ expectedVersion: detail.order.version }), + headers: Object.freeze({ 'Idempotency-Key': idempotencyKey }) }) +} + export function parseArguments(values) { const options = {} const booleanOptions = new Set(['help', 'same-channel']) @@ -428,10 +435,13 @@ export async function main(argv = process.argv.slice(2)) { const orderId = String(requireSuccess(createResponse, `Create order ${index}`) ?? '') if (!orderId) throw new Error(`Create order ${index} did not return orderId`) + const submitDetail = requireSuccess(await request(gateway, timeoutMs, 'GET', + `/api/merchant/v1/orders/first-leg/${encodeURIComponent(orderId)}`, { token }), `Freeze submit version ${index}`) + const submitCommand = pendingSubmitCommand(submitDetail, orderId, `${marker}-submit-${index}`.slice(0, 128)) const submitResponse = await request(gateway, timeoutMs, 'POST', `/api/merchant/v1/orders/first-leg/${encodeURIComponent(orderId)}/submit`, { token, - headers: { 'Idempotency-Key': `${marker}-submit-${index}`.slice(0, 128) }, + ...submitCommand, }) requireSuccess(submitResponse, `Submit order ${index}`) @@ -451,6 +461,7 @@ export async function main(argv = process.argv.slice(2)) { } const result = { index, + submitCommand, orderId, orderNo, clientStatus: detail.order.status, diff --git a/scripts/prod-release-acceptance.mjs b/scripts/prod-release-acceptance.mjs index 3720cc4db..77b12c81c 100644 --- a/scripts/prod-release-acceptance.mjs +++ b/scripts/prod-release-acceptance.mjs @@ -88,7 +88,7 @@ export function client(settings, fetcher = fetch, policy = allowedRequest) { return { get requests() { return requests }, async request(region, method, path, token, body, idempotencyKey) { - if (!policy(method, path, region)) throw new Error('请求不在生产前置检查白名单内') + if (!policy(method, path, region, body, idempotencyKey)) throw new Error('请求不在生产前置检查白名单内') if (++requests > 100) throw new Error('超过单次前置检查请求上限') let response try { @@ -185,7 +185,7 @@ export async function executeOrder(settings, input, journalPath, resume, fetcher await preflight(settings, base, value => { context = value }) if (context.warehouses['CN-YIWU-01'] !== bound.origin.id) throw new Error('下单始发仓与义乌主档不符') const root = '/merchant/v1/orders/first-leg' - const policy = (method, path, region) => region === 'cn' && ( + const policy = (method, path, region, body, idempotencyKey) => region === 'cn' && ( method === 'GET' && (path === `${root}/inbound-code-policy` || state.orderId && path === `${root}/${state.orderId}` || path === `/merchant/v1/warehouses/${bound.destination.id}` @@ -193,6 +193,8 @@ export async function executeOrder(settings, input, journalPath, resume, fetcher || method === 'POST' && (path === '/merchant/v1/first-leg/pricing/estimate' || path === root && state.phase === 'CREATE_INTENT' || state.orderId && path === `${root}/${state.orderId}/submit` && state.phase === 'SUBMIT_INTENT' + && Number.isSafeInteger(state.submitCommand?.body?.expectedVersion) + && JSON.stringify(body) === JSON.stringify(state.submitCommand.body) && idempotencyKey === state.submitCommand.idempotencyKey || state.orderId && path === `/platform/v1/orders/first-leg/${state.orderId}/approve?tenantId=${settings.customer.id}` && state.phase === 'APPROVE_INTENT')) const http = client(settings, fetcher, policy) const merchant = context.logins.MERCHANT.accessToken diff --git a/scripts/prod-release-order-stage.mjs b/scripts/prod-release-order-stage.mjs index 19f1d2315..5d54f8ea8 100644 --- a/scripts/prod-release-order-stage.mjs +++ b/scripts/prod-release-order-stage.mjs @@ -10,6 +10,13 @@ const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value export const fingerprint = value => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex') +export function validateSubmitCheckpoint(state) { + if (['SUBMIT_INTENT', 'SUBMITTED', 'APPROVE_INTENT', 'APPROVED'].includes(state.phase)) { + assert(Number.isSafeInteger(state.submitCommand?.body?.expectedVersion) && state.submitCommand.body.expectedVersion >= 0 + && typeof state.submitCommand.idempotencyKey === 'string' && state.submitCommand.idempotencyKey.length > 0, + '提交检查点缺少首次版本或原幂等键,禁止续跑时补取新版') + } +} export function validateOrderInput(input) { const { batch, customer, origin, destination, payload, operator } = input @@ -66,6 +73,7 @@ export async function withOrderJournal(path, input, action) { assert(state.version === 1 && state.binding === binding(input), '批次配置、环境、客户、账号或载荷已变化') assert(['NEW', 'CREATE_INTENT', 'CREATED', 'SUBMIT_INTENT', 'SUBMITTED', 'APPROVE_INTENT', 'APPROVED'].includes(state.phase), '检查点阶段无效') assert(['NEW', 'CREATE_INTENT'].includes(state.phase) ? state.orderId === null : longId(state.orderId), '检查点订单身份无效') + validateSubmitCheckpoint(state) const save = async patch => { const next = { ...state, ...patch } const file = await open(temp, 'wx', 0o600) @@ -114,8 +122,10 @@ export function assertOrderDetail(detail, input, orderId) { export async function runOrderStage({ input, state, save, transport, authorize, resume = false }) { validateOrderInput(input) assert(state.binding === binding(input), '检查点绑定不符') + validateSubmitCheckpoint(state) await authorize(input) const key = op => `prod-release-${fingerprint({ binding: state.binding, op }).slice(0, 48)}` + if (state.submitCommand) assert(state.submitCommand.idempotencyKey === key('submit'), '提交原幂等键与批次不符') const request = (role, method, path, body, idempotencyKey) => transport({ role, method, path, body, idempotencyKey }) if (state.phase === 'CREATE_INTENT') assert(resume, '创建结果待核对;需要显式续跑') if (['NEW', 'CREATE_INTENT'].includes(state.phase)) { @@ -132,15 +142,16 @@ export async function runOrderStage({ input, state, save, transport, authorize, let order = await read() if (state.phase === 'CREATED') { assert(order.status === 'DRAFT', '创建后状态被外部修改') - await save({ phase: 'SUBMIT_INTENT' }) - await request('MERCHANT', 'POST', `${path}/submit`, undefined, key('submit')) + assert(Number.isSafeInteger(order.version) && order.version >= 0, '提交前详情缺少有效版本') + await save({ phase: 'SUBMIT_INTENT', submitCommand: { body: { expectedVersion: order.version }, idempotencyKey: key('submit') } }) + await request('MERCHANT', 'POST', `${path}/submit`, state.submitCommand.body, state.submitCommand.idempotencyKey) order = await read() assert(order.status === 'SUBMITTED', '提交后未进入待审核') await save({ phase: 'SUBMITTED', orderNo: order.orderNo, inboundCode: order.inboundCode }) } else if (state.phase === 'SUBMIT_INTENT') { assert(resume, '提交结果待核对;需要显式续跑') if (order.status === 'DRAFT') { - await request('MERCHANT', 'POST', `${path}/submit`, undefined, key('submit')) + await request('MERCHANT', 'POST', `${path}/submit`, state.submitCommand.body, state.submitCommand.idempotencyKey) order = await read() } assert(order.status === 'SUBMITTED', '提交结果无法安全核对') diff --git a/scripts/prod-release-order-stage.test.mjs b/scripts/prod-release-order-stage.test.mjs index ca5241db2..afa9b0ced 100644 --- a/scripts/prod-release-order-stage.test.mjs +++ b/scripts/prod-release-order-stage.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { fingerprint, validateOrderInput, withOrderJournal, runOrderStage } from './prod-release-order-stage.mjs' +import { fingerprint, validateOrderInput, withOrderJournal, runOrderStage, validateSubmitCheckpoint } from './prod-release-order-stage.mjs' import { executeOrder } from './prod-release-acceptance.mjs' function fixture() { @@ -28,13 +28,13 @@ function server(input, fail) { let status = null let failed = false const calls = [] - const state = { calls, created: 0, approved: 0, + const state = { calls, created: 0, approved: 0, orderVersion: 5, async transport(request) { calls.push(request) const { method, path, idempotencyKey } = request if (path.endsWith('/inbound-code-policy')) return { mode: 'AUTO', manualEntryRequired: false } if (method === 'GET') return { order: { ...input.payload, id: '5', tenantId: '1', customerId: '1', - status, orderNo: 'FIXTURE-ORDER', inboundCode: status === 'DRAFT' ? null : 'FIXTURE-INBOUND' }, cargoes: input.payload.cargoes } + status, version:state.orderVersion, orderNo: 'FIXTURE-ORDER', inboundCode: status === 'DRAFT' ? null : 'FIXTURE-INBOUND' }, cargoes: input.payload.cargoes } let op if (path.endsWith('/first-leg')) { op = 'create' @@ -43,6 +43,8 @@ function server(input, fail) { } else if (path.endsWith('/submit')) { op = 'submit' assert.ok(idempotencyKey) + assert.deepEqual(request.body,{expectedVersion:state.orderVersion}) + state.orderVersion++ status = 'SUBMITTED' } else { assert.match(path, /^\/platform\/v1\/orders\/first-leg\/5\/approve\?tenantId=1$/) @@ -67,6 +69,34 @@ async function run(path, input, api, resume = false, authorize = async () => {}) test('fingerprint is stable for reordered keys', () => { assert.equal(fingerprint({ a: 1, b: 2 }), fingerprint({ b: 2, a: 1 })) }) + +test('submit-intent without original numeric version/key is blocked rather than repaired from current detail',()=>{ + for(const submitCommand of [undefined,{body:{},idempotencyKey:'k'},{body:{expectedVersion:'5'},idempotencyKey:'k'},{body:{expectedVersion:5}}]) { + assert.throws(()=>validateSubmitCheckpoint({phase:'SUBMIT_INTENT',submitCommand}),/首次版本/) + } + validateSubmitCheckpoint({phase:'SUBMIT_INTENT',submitCommand:{body:{expectedVersion:0},idempotencyKey:'k'}}) +}) + +test('uncertain submit persists request before transport and resume retains original version even after external edit',async t=>{ + const path=await sandbox(t),input=fixture(),api=server(input) + const original=api.transport;let attempted=false + api.transport=async request=>{ + if(request.path.endsWith('/submit')&&!attempted){ + attempted=true + const disk=JSON.parse(await readFile(path,'utf8')) + assert.equal(disk.phase,'SUBMIT_INTENT');assert.deepEqual(disk.submitCommand.body,request.body) + assert.equal(disk.submitCommand.idempotencyKey,request.idempotencyKey) + throw new Error('uncertain-before-send') + } + return original(request) + } + await assert.rejects(run(path,input,api),/uncertain-before-send/) + const first=JSON.parse(await readFile(path,'utf8')).submitCommand + api.orderVersion=99 + await assert.rejects(run(path,input,api,true)) // original version 5 must not be refreshed to 99 + assert.deepEqual(api.calls.filter(call=>call.path.endsWith('/submit')).at(-1).body,first.body) + assert.deepEqual(JSON.parse(await readFile(path,'utf8')).submitCommand,first) +}) test('rejects multiple SKUs, excess cartons, incorrect company and volume', () => { for (const change of [i => i.payload.cargoes.push(i.payload.cargoes[0]), i => i.payload.cargoes[0].packageCount = 300, diff --git a/scripts/real-order-xlsx-test-env.mjs b/scripts/real-order-xlsx-test-env.mjs index 7cf881ead..859fbd5e8 100644 --- a/scripts/real-order-xlsx-test-env.mjs +++ b/scripts/real-order-xlsx-test-env.mjs @@ -416,8 +416,10 @@ export async function executeCandidates({ candidates, limit, concurrency = 1, st entry.orderNo = draft.order.orderNo entry.declarationVerified = true if (['pending-review', 'wait-split'].includes(stage)) { + if (!Number.isSafeInteger(draft.order.version) || draft.order.version < 0) throw Error('SUBMIT_VERSION_MISSING') + entry.submitCommand = { body: { expectedVersion: draft.order.version }, headers: { 'Idempotency-Key': `${marker}-submit` } } entry.stage = 'submit'; await save() - await api('POST', `${clientPath}/submit`, undefined, merchant, { 'Idempotency-Key': `${marker}-submit` }) + await api('POST', `${clientPath}/submit`, entry.submitCommand.body, merchant, entry.submitCommand.headers) } if (stage === 'pending-review') { const submitted = await api('GET', clientPath, undefined, merchant) diff --git a/scripts/real-order-xlsx-test-env.test.mjs b/scripts/real-order-xlsx-test-env.test.mjs index 1bf1fdfb5..9b31bf1f9 100644 --- a/scripts/real-order-xlsx-test-env.test.mjs +++ b/scripts/real-order-xlsx-test-env.test.mjs @@ -6,6 +6,32 @@ import { normalizeRows, channelName, matchRoute, measurementShape, buildSourceDr const header = ['进仓编号', '客户', '渠道', '亚马逊仓', '实际件数', '实际毛重', '实际立方', '预报件数', '买单/退税', '品名', 'SKU', 'FBA货件编号', '跟踪编号'] const row = ['ABCD2606010001', 'Example', '合德海卡包税', 'ABC1', 31, 100, 2, 31, '买单', 'Goods', 'SKU1', 'SHIP1', 'REF1'] const order = () => normalizeRows([header, row]).orders[0] + +test('Excel submit saves original key/version before uncertain transport and never auto-refreezes failed entries',async t=>{ + const originalExitCode=process.exitCode + t.after(()=>{process.exitCode=originalExitCode}) + const value=order(),route=matchRoute(value,catalog),state={identity:{batchId:'frozen-submit'},orders:{}} + const saved=[] + const api=async(method,path,body,_token,headers)=>{ + if(path.endsWith('/pricing/estimate'))return {channelCode:route.channel.channelCode,pricingTaxMode:value.pricingTaxMode} + if(method==='POST'&&path.endsWith('/first-leg'))return 'order-frozen' + if(method==='GET')return {order:{id:'order-frozen',version:8,orderNo:'FL',customerRemark:state.orders[value.key].marker},cargoes:[{packageCount:value.packageCount,grossWeightKg:value.weightKg,volumeCbm:value.volumeCbm}]} + if(path.endsWith('/submit')){ + const checkpoint=saved.at(-1).orders[value.key] + assert.equal(checkpoint.stage,'submit');assert.deepEqual(checkpoint.submitCommand,{body,headers}) + assert.deepEqual(body,{expectedVersion:8});throw new Error('transport lost') + } + throw new Error('unexpected call') + } + const options={candidates:[{order:value,route}],limit:1,state,save:async()=>saved.push(structuredClone(state)),api, + ctx:{},merchant:'merchant',profile:{tenantName:'Example'},sharedResources:{productImageFileId:'image'},stage:'pending-review'} + await executeCandidates(options) + assert.equal(process.exitCode,1) + assert.equal(state.orders[value.key].status,'FAILED') + assert.equal(state.orders[value.key].submitCommand.body.expectedVersion,8) + await executeCandidates({...options,api:async()=>{assert.fail('failed checkpoint must not issue another request')}}) + assert.equal(state.orders[value.key].submitCommand.body.expectedVersion,8) +}) const catalog = { receiving: { id: '1', warehouseName: 'Origin' }, channels: [{ channelName: '合德海卡', channelCode: 'CHANNEL', pricingTaxModes: ['TAX_INCLUDED'] }], destinations: [{ id: '2', warehouseType: 'FBA', warehouseCode: 'ABC1', warehouseName: 'ABC1', countryCode: 'US' }] } @@ -150,7 +176,7 @@ test('pending-review stage submits but never calls approval or measurement APIs' if (path === '/api/merchant/v1/orders/first-leg/order-1' && method === 'GET') { detailReads++ return { order: { id: 'order-1', orderNo: 'FL-1', customerRemark: Object.values(state.orders)[0].marker, - status: detailReads > 1 ? 'SUBMITTED' : 'DRAFT' }, + status: detailReads > 1 ? 'SUBMITTED' : 'DRAFT', version: 7 }, cargoes: [{ packageCount: value.packageCount, grossWeightKg: value.weightKg, volumeCbm: value.volumeCbm }] } } if (path === '/api/merchant/v1/orders/order-1') return { summary: { mainStatus: 'PENDING_REVIEW' } } @@ -175,7 +201,7 @@ test('pending-review retries an estimate-only pricing failure and records it as if (path === '/api/merchant/v1/orders/first-leg/order-2' && method === 'GET') { detailReads++ return { order: { id: 'order-2', orderNo: 'FL-2', customerRemark: Object.values(state.orders)[0].marker, - status: detailReads > 1 ? 'SUBMITTED' : 'DRAFT' }, + status: detailReads > 1 ? 'SUBMITTED' : 'DRAFT', version: 7 }, cargoes: [{ packageCount: value.packageCount, grossWeightKg: value.weightKg, volumeCbm: value.volumeCbm }] } } if (path === '/api/merchant/v1/orders/order-2') return { summary: { mainStatus: 'PENDING_REVIEW' } }