From 015fc090912fcca694a77efede5f58558786decb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 20:47:13 +0800 Subject: [PATCH 01/42] =?UTF-8?q?=E8=A1=A5=E5=85=85PDA=E8=A3=85=E6=9F=9C?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E9=98=B6=E6=AE=B5=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../firstleg/service/LoadingPickService.java | 29 +++++++++++++++---- .../service/LoadingPickServiceTest.java | 22 ++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 6fda57d36..1e5f3cd45 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -459,9 +459,11 @@ public class LoadingPickService { for (LoadingPickItemMapper.InboundOwner owner : owners) { LoadingInboundMapper.Snapshot snapshot = repository.inboundForUpdate(owner.tenantId(), owner.inboundId()); if (snapshot == null) throw conflict("PDA任务关联入库单不存在"); - loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId()); + completionStage(taskId, "完成前业务门禁", () -> + loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId())); } - settlementService.settle(taskId, operatorId, operatorName); + completionStage(taskId, "拆单与库位结算", () -> + settlementService.settle(taskId, operatorId, operatorName)); if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) { throw conflict("柜次仍有未完成PDA拣货的箱数"); } @@ -469,17 +471,32 @@ public class LoadingPickService { throw conflict("PDA任务已经复核"); } insertOperation(taskId, operationId, requestHash, "COMPLETE_LOADING", operatorId, operatorName); - loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName); + completionStage(taskId, "装柜计划完成", () -> + loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName)); repository.syncInboundProjections(taskId, operatorId, operatorName); for (LoadingPickItemMapper.InboundOwner owner : owners) { - loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(), operatorId, - operatorName, "PDA-" + operationId + '-' + owner.inboundId(), remark); + completionStage(taskId, "入库单状态完成", () -> + loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(), operatorId, + operatorName, "PDA-" + operationId + '-' + owner.inboundId(), remark)); } - loadingListService.publishCompletedEvent(task.getLoadingPlanId()); + completionStage(taskId, "完成事件发布", () -> + loadingListService.publishCompletedEvent(task.getLoadingPlanId())); completeTruckAfterCommit(task.getContainerBookingId(), taskId, operatorId, operatorName); return view(requiredTask(taskId), null); } + private void completionStage(long taskId, String stage, Runnable action) { + try { + action.run(); + } catch (BizException exception) { + throw exception; + } catch (RuntimeException exception) { + log.error("Unexpected PDA loading completion failure taskId={} stage={}", taskId, stage, exception); + throw new BizException(ResultCode.INTERNAL_ERROR, + "装柜完成在【" + stage + "】阶段处理异常,请联系管理员并提供请求编号"); + } + } + private void completeTruckAfterCommit(long containerBookingId, long taskId, long operatorId, String operatorName) { Runnable completion = () -> { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index dbaefc6b3..2c47d3736 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -585,6 +585,28 @@ class LoadingPickServiceTest { verify(loadingListService, never()).publishCompletedEvent(anyLong()); } + @Test + void translatesUnexpectedSettlementFailureIntoActionableStageMessage() { + LoadingPickTaskEntity task = task(); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L); + when(repository.inboundOwners(20L)).thenReturn(List.of(owner)); + when(repository.inboundForUpdate(101L, 50L)).thenReturn( + new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); + doThrow(new IllegalStateException("unexpected database failure")) + .when(settlementService).settle(20L, 9L, "operator"); + + assertThatThrownBy(() -> service.complete(20L, + new CompleteLoadingRequest(0, "complete-stage-error", "checked"), 9L, "operator")) + .isInstanceOf(BizException.class) + .hasMessageContaining("拆单与库位结算") + .hasMessageContaining("请求编号"); + + verify(repository, never()).approve(anyLong(), anyLong(), anyString(), anyString()); + verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); + } + @Test void atomicallySubmitsCompleteBatchAndReplaysSameResult() { LoadingPickTaskEntity task = task(); From 39988cbaa51607d7b39c974ffa8f2d58f2cb0088 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 15:00:03 +0800 Subject: [PATCH 02/42] fix(firstleg): publish only actually loaded items --- .../mapper/LoadingCompletedEventMapper.java | 16 ++++++++++++++++ .../LoadingCompletedOutboxServiceTest.java | 18 ++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index 8675643a5..f4e907a2a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -29,6 +29,14 @@ public interface LoadingCompletedEventMapper { FROM fl_loading_plan p JOIN fl_container_booking b ON b.id = p.container_booking_id JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 + JOIN fl_loading_pick_item actual_pick + ON actual_pick.loading_plan_item_id = i.id + AND actual_pick.item_status IN ('PICKED','PARTIAL') + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count = i.package_count + JOIN fl_loading_pick_task actual_task + ON actual_task.id = actual_pick.pick_task_id + AND actual_task.loading_plan_id = p.id JOIN fl_inbound_order o ON o.id = i.inbound_id AND o.tenant_id = i.account_id AND o.deleted = 0 LEFT JOIN fl_inbound_order root_order ON root_order.id = COALESCE(o.root_inbound_id, o.id) WHERE p.id = #{planId} AND p.plan_status = 'COMPLETED' @@ -46,6 +54,14 @@ public interface LoadingCompletedEventMapper { i.product_name_zh, i.package_count, i.product_quantity, label.box_code FROM fl_loading_plan_item i + JOIN fl_loading_pick_item actual_pick + ON actual_pick.loading_plan_item_id = i.id + AND actual_pick.item_status IN ('PICKED','PARTIAL') + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count = i.package_count + JOIN fl_loading_pick_task actual_task + ON actual_task.id = actual_pick.pick_task_id + AND actual_task.loading_plan_id = #{planId} JOIN fl_inbound_pallet_allocation a ON a.tenant_id = i.account_id AND a.inbound_id = i.inbound_id AND a.allocation_status IN ('CONFIRMED','TRANSFERRED','RELEASED') diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 461aca213..785252006 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -138,11 +138,25 @@ class LoadingCompletedOutboxServiceTest { } @Test void snapshotOnlyPublishesFinalizedLoadedInbounds() throws Exception { - String sql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class) + String rowsSql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class) + .getAnnotation(Select.class).value()).replaceAll("\\s+", " "); + String palletsSql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("palletRows", long.class) .getAnnotation(Select.class).value()).replaceAll("\\s+", " "); - assertThat(sql).contains("p.plan_status = 'COMPLETED'") + assertThat(rowsSql).contains("p.plan_status = 'COMPLETED'") .contains("o.process_status = 'LOADED'"); + for (String sql : List.of(rowsSql, palletsSql)) { + assertThat(sql) + .contains("actual_pick.loading_plan_item_id = i.id") + .contains("actual_pick.item_status IN ('PICKED','PARTIAL')") + .contains("actual_pick.picked_package_count > 0") + .contains("actual_pick.picked_package_count = i.package_count") + .contains("actual_task.id = actual_pick.pick_task_id") + .contains("actual_task.loading_plan_id =") + .doesNotContain("'SKIPPED'") + .doesNotContain("'PENDING'") + .doesNotContain("'PICKING'"); + } } @Test void publishesDropshipPalletSkuAndCartonSnapshots() throws Exception { From e815abcbd7a17d728bede91d01d336aedeb93cf0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 15:07:06 +0800 Subject: [PATCH 03/42] fix(firstleg): retain dropship carton label pool --- .../baiyue/firstleg/mapper/LoadingCompletedEventMapper.java | 2 +- .../firstleg/service/LoadingCompletedOutboxServiceTest.java | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index f4e907a2a..84786775d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -67,7 +67,7 @@ public interface LoadingCompletedEventMapper { AND a.allocation_status IN ('CONFIRMED','TRANSFERRED','RELEASED') AND a.picked_package_count > 0 LEFT JOIN fl_inbound_carton_label label - ON label.tenant_id = i.account_id AND label.inbound_id = i.inbound_id + ON label.tenant_id = i.account_id AND label.inbound_detail_id = i.inbound_detail_id AND label.batch_status = 'ACTIVE' WHERE i.loading_plan_id = #{planId} AND i.deleted = 0 ORDER BY i.account_id, i.inbound_id, a.allocation_no, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 785252006..50ff2452d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -157,6 +157,11 @@ class LoadingCompletedOutboxServiceTest { .doesNotContain("'PENDING'") .doesNotContain("'PICKING'"); } + assertThat(palletsSql) + .contains("label.tenant_id = i.account_id") + .contains("label.inbound_detail_id = i.inbound_detail_id") + .contains("label.batch_status = 'ACTIVE'") + .doesNotContain("label.inbound_id = i.inbound_id"); } @Test void publishesDropshipPalletSkuAndCartonSnapshots() throws Exception { From 44198866d9af725d9192b8e82b924454502119f5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 15:08:34 +0800 Subject: [PATCH 04/42] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitlab-ci.yml | 21 +++ AGENTS.md | 10 +- .../auth/client/IamOnboardingClient.java | 3 +- .../auth/dto/MerchantRegisterResponse.java | 1 + .../service/MerchantRegistrationService.java | 3 +- common-openapi/AGENTS.md | 2 + common-security/AGENTS.md | 3 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 32 ++++ docs/同步调用依赖与失败传播治理基线.md | 21 +-- firstleg/AGENTS.md | 4 + ...rstleg-internal-modularization-baseline.md | 52 +++++- .../scripts/analyze-firstleg-dependencies.mjs | 15 +- .../analyze-firstleg-dependencies.test.mjs | 40 ++++ .../firstleg/BaiyueFirstlegApplication.java | 3 +- .../service/OceanBookingService.java | 8 +- .../persistence}/PriceBookQueryMapper.java | 13 +- .../application/PriceBookQueryService.java | 45 +++++ .../model}/PriceBookQueryRows.java | 2 +- .../port/in/PriceBookQueryUseCase.java | 22 +++ .../port/out/PriceBookQueryReader.java | 21 +++ .../pricing/ActivePriceBookEstimator.java | 8 +- .../pricing/FbaWarehouseRateEstimator.java | 2 +- .../pricing/RegionWeightTierEstimator.java | 2 +- .../repository/PriceBookQueryRepository.java | 53 ------ .../firstleg/service/ContainerService.java | 6 +- .../firstleg/service/FbaPricingService.java | 2 +- .../service/MerchantPriceBookService.java | 8 +- ...BaiyueFirstlegApplicationContractTest.java | 1 + .../controller/PriceBookContractTest.java | 2 +- .../service/OceanBookingServiceTest.java | 6 +- .../PriceBookQueryArchitectureTest.java | 67 +++++++ .../PriceBookQueryServiceTest.java | 46 +++++ .../FbaWarehouseRateEstimatorTest.java | 2 +- .../RegionWeightTierEstimatorTest.java | 2 +- .../service/ContainerServiceTest.java | 6 +- .../CustomerDimensionPricingFlowTest.java | 2 +- .../service/FbaPricingServiceTest.java | 2 +- .../service/MerchantPriceBookServiceTest.java | 12 +- .../firstleg-slice-dependencies.json | 53 ++++-- iam/AGENTS.md | 6 +- .../controller/PlatformTenantController.java | 19 ++ .../com/baiyue/iam/dto/OnboardingDtos.java | 3 +- .../java/com/baiyue/iam/dto/TenantDtos.java | 1 + .../java/com/baiyue/iam/entity/IamTenant.java | 1 + ...CustomerDimensionInitializationStatus.java | 13 ++ .../CustomerDimensionInitializationTask.java | 12 ++ ...omerDimensionInitializationRepository.java | 123 ++++++++++++ .../CustomerDimensionInitializationRelay.java | 99 ++++++++++ ...merDimensionRuleInitializationService.java | 109 ++--------- .../service/MerchantOnboardingService.java | 4 +- .../com/baiyue/iam/service/TenantService.java | 28 +++ ...customer-dimension-initialization-task.sql | 33 ++++ iam/src/main/resources/db/iam.sql | 23 +++ iam/src/main/resources/openapi/iam-v1.yaml | 48 ++++- ...tomerDimensionInitializationRelayTest.java | 104 +++++++++++ ...imensionRuleInitializationServiceTest.java | 149 ++++----------- lastleg-service/AGENTS.md | 5 +- scripts/README.md | 3 +- scripts/agents-index-contract.ps1 | 60 +++++- scripts/java-quality-summary.mjs | 175 ++++++++++++++++++ scripts/java-quality-summary.test.mjs | 77 ++++++++ scripts/synchronous-dependency-baseline.mjs | 3 +- .../synchronous-dependency-contract.test.mjs | 37 ++-- warehouse-overseas-service/AGENTS.md | 4 + 64 files changed, 1382 insertions(+), 360 deletions(-) rename firstleg/src/main/java/com/baiyue/firstleg/{mapper => pricebookquery/adapter/out/persistence}/PriceBookQueryMapper.java (93%) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java rename firstleg/src/main/java/com/baiyue/firstleg/{repository => pricebookquery/application/model}/PriceBookQueryRows.java (96%) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryArchitectureTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java create mode 100644 iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationStatus.java create mode 100644 iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationTask.java create mode 100644 iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java create mode 100644 iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java create mode 100644 iam/src/main/resources/db/customer-dimension-initialization-task.sql create mode 100644 iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java create mode 100644 scripts/java-quality-summary.mjs create mode 100644 scripts/java-quality-summary.test.mjs diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 865a9e4b2..5d86bd1dc 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -41,6 +41,27 @@ OpenAPI有效性: - "**/target/spotbugs*.xml" - "**/target/site/spotbugs.html" +质量摘要: + stage: verify + image: node:22-alpine + cache: [] + allow_failure: true + needs: + - job: OpenAPI有效性 + artifacts: false + - job: 校验与测试 + artifacts: true + - job: 静态分析报告 + artifacts: true + script: + - node --test scripts/java-quality-summary.test.mjs + - node scripts/java-quality-summary.mjs --output target/java-quality-summary.json --require-jacoco --require-spotbugs + artifacts: + when: always + expire_in: 7 days + paths: + - target/java-quality-summary.json + 校验与测试: stage: verify script: diff --git a/AGENTS.md b/AGENTS.md index 5a0872a4a..cf6ed7318 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,9 +20,11 @@ |---|---|---| | 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` | | 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` | +| 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 | | 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 | | 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` | | 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` | +| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 | | 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 | | 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` | | 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` | @@ -36,14 +38,15 @@ | 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` | | 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | | 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | -| 海外 IAM 状态与权限版本投影 | `overseas-security-service/` | `overseas-security-service/AGENTS.md`;消费三类 IAM 安全事件并为海外 Gateway 提供内部 fail-closed 校验 | +| 海外 IAM 状态与权限版本投影 | `overseas-security-service/` | `overseas-security-service/AGENTS.md`;内部入口 `/internal/overseas-security/v1/events`、`/validate`,契约 `src/main/resources/openapi/internal-overseas-security-v1.yaml`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验 | | 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | | 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | -| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理库位/托盘调用 `warehouse-overseas-service/`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 | +| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 | | 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 | | 海外仓库、海外库位、海外托盘、资源锁 | `warehouse-overseas-service/` | `warehouse-overseas-service/AGENTS.md` | +| Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 | | PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` | ## 公共模块快速索引 @@ -80,7 +83,7 @@ - 数据库只访问所属服务库。模块表结构位于 `/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。 - 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - Java 签名变更的最低编译门禁是 `mvn -pl -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 -- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、海外 Gateway 覆盖、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 +- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、海外 Gateway 覆盖、海外安全服务部署映射、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 - 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。 ## 全量 HTTP 验收脚本路由 @@ -89,6 +92,7 @@ - Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules `,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。 - 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。 - 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。 +- Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。 - HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。 ## 阶段文档入口 diff --git a/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java b/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java index b78cc2138..c0bb9d5ed 100644 --- a/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java +++ b/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java @@ -25,5 +25,6 @@ public interface IamOnboardingClient { String legalPerson, String legalPhone, String contactEmail, String countryCode, String address) { } - record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus) { } + record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus, + String dimensionInitializationStatus) { } } diff --git a/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterResponse.java b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterResponse.java index 3c357b850..09ece63c9 100644 --- a/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterResponse.java +++ b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterResponse.java @@ -5,5 +5,6 @@ public record MerchantRegisterResponse( Long tenantId, Long membershipId, String tenantStatus, + String dimensionInitializationStatus, String nextStep ) { } diff --git a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java index 09dd998cd..d3fa0f65f 100644 --- a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java +++ b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java @@ -103,7 +103,8 @@ public class MerchantRegistrationService { throw exception; } return new MerchantRegisterResponse(accountId, onboarding.tenantId(), onboarding.membershipId(), - onboarding.tenantStatus(), "LOGIN_AND_COMPLETE_QUALIFICATIONS"); + onboarding.tenantStatus(), onboarding.dimensionInitializationStatus(), + "LOGIN_AND_COMPLETE_QUALIFICATIONS"); } private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) { diff --git a/common-openapi/AGENTS.md b/common-openapi/AGENTS.md index 59eed2647..f91a255d8 100644 --- a/common-openapi/AGENTS.md +++ b/common-openapi/AGENTS.md @@ -13,10 +13,12 @@ | `baiyue.openapi` 配置项 | `src/main/java/com/baiyue/common/openapi/OpenApiProperties.java` | | 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` | | 长整型 OpenAPI 字符串契约 | `src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java` | +| PDA OpenAPI 分组 | `src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java` 的 `pda` GroupedOpenApi;固定匹配 `/api/pda/**`,合同测试 `src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java` | ## 修改与验证 - 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。 +- PDA Controller 必须进入独立 `pda` 分组;新增或改名 `/api/pda/**` 路径时同步检查分组合同和目标服务 OpenAPI,不得混入平台或商户分组。 - 普通业务成功和失败在 OpenAPI 中统一描述为 HTTP 200,并提示调用方检查 body `code`;只为 Security、协议、基础设施、Warehouse 503、Audit 202 和 Event Bridge 内部鉴权等真实状态边界声明非 200 响应。 - 配置项变化直接同步代码、全部消费者和 Nacos 模板,当前开发阶段不保留旧配置键兼容。 - 从父目录运行 `mvn -pl common-openapi -am test`。 diff --git a/common-security/AGENTS.md b/common-security/AGENTS.md index 5829c1000..e9e566096 100644 --- a/common-security/AGENTS.md +++ b/common-security/AGENTS.md @@ -12,7 +12,7 @@ | 安全链自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java` | | JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS);`HmacJwtDecoderAutoConfiguration.java` 仅作当前开发阶段清理前的旧实现定位,不得作为跨区域共享密钥方案 | | JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` | -| 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java` | +| 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java`;海外 PDA 仓范围由 `JwtClaimsMapper.java` 将 `overseas_warehouse_id` 映射为 `overseasWarehouseId` | | 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(国内登录 Token 即时失效)、`ClaimsAccessTokenSessionValidator.java`(海外访问 Token 本地短时 Claims)、`RedisTokenKeys.java` | | 401/403 响应 | `JsonAuthenticationEntryPoint.java`、`JsonAccessDeniedHandler.java` | | 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` | @@ -22,6 +22,7 @@ - 所有资源服务统一使用 `jwt-mode=jwks`、RS256、Issuer 和 JWKS;国内服务验证国内 Audience 并使用 `session-mode=redis`,海外服务只接受登录/刷新时签发的海外 Audience Token 并使用 `session-mode=claims`,不得访问国内 Redis。海外服务应缓存 JWKS 并在密钥轮换时刷新,不得每个请求访问国内 Auth;不得重新引入跨区域共享 HS256 密钥,也不得让海外服务接受国内 Token。 - Token Claim、Redis Key、Issuer、Audience、JWKS、会话模式或认证对象变化必须同步检查 `auth/`、`gateway/`、Nacos/部署配置和所有实际业务服务。 +- `overseas_warehouse_id` 只承载 IAM 已确认的海外仓绑定;缺失、格式非法或与实时安全投影不一致时不得放宽 PDA 仓范围,Gateway 与业务服务必须 fail-closed。 - `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。 - 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。 - 从父目录运行 `mvn -pl common-security -am test`。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index e71adf7d2..ca30df085 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16563,6 +16563,38 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE 'ALTER TABLE ll_local_looseboard_order ADD UNIQUE KEY uk_ll_local_looseboard_lastleg(lastleg_order_id)')); PREPARE lastleg_looseboard_tail_stmt FROM @ddl; EXECUTE lastleg_looseboard_tail_stmt; DEALLOCATE PREPARE lastleg_looseboard_tail_stmt; +-- IAM 客户提交即成功,默认尺寸通过持久化任务后台初始化;已有客户按历史事实标记 READY。 +USE baiyue_iam; +SET @customer_dimension_status_ddl = (SELECT IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='iam_tenant' AND column_name='dimension_initialization_status'), + 'SELECT 1', + 'ALTER TABLE iam_tenant ADD COLUMN dimension_initialization_status VARCHAR(16) NOT NULL DEFAULT ''READY'' COMMENT ''默认尺寸初始化状态:PENDING待处理、READY已就绪、FAILED失败'' AFTER status')); +PREPARE customer_dimension_status_stmt FROM @customer_dimension_status_ddl; +EXECUTE customer_dimension_status_stmt; +DEALLOCATE PREPARE customer_dimension_status_stmt; +CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task ( + tenant_id BIGINT NOT NULL COMMENT '客户ID及任务聚合ID', + operator_id BIGINT NOT NULL COMMENT '触发初始化的操作人ID;系统补偿为0', + initialization_key VARCHAR(128) NOT NULL COMMENT '稳定初始化幂等键:iam-customer-create:{tenantId}', + action VARCHAR(16) NOT NULL DEFAULT 'INITIALIZE' COMMENT '目标动作:INITIALIZE初始化、COMPENSATE补偿', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING、SENDING、READY、FAILED、CANCELLED', + attempt_count INT NOT NULL DEFAULT 0, + next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + lease_owner VARCHAR(128) NULL, + lease_until DATETIME(3) NULL, + last_error_code VARCHAR(32) NULL, + last_error_message VARCHAR(1000) NULL, + remote_config_id BIGINT NULL, + task_version INT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + completed_at DATETIME(3) NULL, + PRIMARY KEY (tenant_id), + KEY idx_customer_dimension_task_dispatch (status,next_retry_at,lease_until), + KEY idx_customer_dimension_task_diagnosis (status,updated_at) +) ENGINE=InnoDB COMMENT='IAM客户默认尺寸可靠后台初始化任务及诊断状态'; + CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, diff --git a/docs/同步调用依赖与失败传播治理基线.md b/docs/同步调用依赖与失败传播治理基线.md index 0562b97bb..cf7410e43 100644 --- a/docs/同步调用依赖与失败传播治理基线.md +++ b/docs/同步调用依赖与失败传播治理基线.md @@ -71,9 +71,9 @@ 当前在线强连通分量为: -`Auth ↔ IAM ↔ Firstleg ↔ Order ↔ File` +`Auth ↔ IAM`;`Firstleg ↔ Order` -合同冻结 22 条在线服务边;新增在线边或新增强连通分量直接失败。Finance、Lastleg 只指向该分量或叶子服务,不属于在线环;Gateway Overseas→Security 是 `INFRASTRUCTURE`,不是 REQUEST 边。 +`IAM → Firstleg` 默认尺寸初始化已退出请求线程,合同现冻结 21 条在线服务边;新增在线边或新增强连通分量直接失败。File 不再属于在线强连通分量;Finance、Lastleg 只指向上述分量或叶子服务,不属于在线环;Gateway Overseas→Security 是 `INFRASTRUCTURE`,不是 REQUEST 边。 ### 后台 BACKGROUND 图 @@ -81,7 +81,7 @@ `Event Bridge ↔ Firstleg ↔ Finance ↔ Lastleg` -File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Overseas Security 是单向后台叶子边,不形成在线业务环。 +File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 默认尺寸可靠任务、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Overseas Security 是单向后台叶子边,不形成在线业务环。 ### 全图 @@ -97,21 +97,16 @@ File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 安全 Outb | 用户入口 | 最长跳数 | 全部长路径 | |---|---:|---| -| 认证 API | 5 | `Auth → IAM → Firstleg → Order → File → Integration` | -| IAM API | 4 | `IAM → Firstleg → Order → File → Integration` | -| 订单 API | 4 | `Order → File → IAM → Firstleg → Print`;`Order → File → IAM → Firstleg → Warehouse`;`Order → Firstleg → File → IAM → Auth`;`Order → Firstleg → File → IAM → Warehouse`;`Order → IAM → Firstleg → File → Integration` | +| 订单 API | 4 | `Order → Firstleg → File → IAM → Auth`;`Order → Firstleg → File → IAM → Warehouse` | | 头程 API/PDA | 4 | `Firstleg → Order → File → IAM → Auth`;`Firstleg → Order → File → IAM → Warehouse` | -| 财务 API | 5 | `Finance → File → IAM → Firstleg → Order → Warehouse`;`Finance → Firstleg → Order → File → IAM → Auth`;`Finance → Firstleg → Order → File → IAM → Warehouse`;`Finance → IAM → Firstleg → Order → File → Integration` | -| 文件 API | 4 | `File → IAM → Firstleg → Order → Warehouse` | -| 海外尾程 API/PDA | 5 | `Lastleg → File → IAM → Firstleg → Order → Warehouse` | -| 国内仓储 API | 0 | 无下游同步调用 | -| 海外仓储 API | 0 | 无下游同步调用 | +| 财务 API | 5 | `Finance → Firstleg → Order → File → IAM → Auth`;`Finance → Firstleg → Order → File → IAM → Warehouse` | +| 海外尾程 API/PDA | 3 | `Lastleg → File → IAM → Auth`;`Lastleg → File → IAM → Warehouse` | 这些是静态服务边可形成的最长简单路径,不表示每个具体 Controller 都会走完整链;后续入口级调用点治理必须继续以实际 Service 调用证据缩小路径,不能用静态可能性宣称线上已经发生。 -### 第一个建议退出的 REQUEST 边 +### 已完成的第一个 REQUEST 边退出 -建议优先将 `IAM → Firstleg` 的 `CustomerDimensionRuleClient` 默认尺寸初始化从 REQUEST WRITE 改为可靠后台编排。该命令已经携带稳定 `initializationKey` 并提供补偿接口,适合使用持久化 Outbox、显式 `PENDING/FAILED` 结果和人工可诊断重试;不得在失败时返回默认成功。退出此边可把当前五服务在线强连通分量拆为更小的 Auth↔IAM 与 Order↔Firstleg 子环,同时不触碰财务、Event Bridge 传输或四期业务状态机。实施前仍需 IAM/Firstleg 业务负责人确认“客户创建成功”与“默认尺寸已就绪”的产品时点。 +`IAM → Firstleg` 的 `CustomerDimensionRuleClient` 默认尺寸初始化已经从 REQUEST WRITE 改为可靠后台编排。IAM 与客户主档同事务持久化稳定 `initializationKey` 的任务,显式维护 `PENDING/READY/FAILED`,由租约调度重试并提供诊断查询和人工重试;审批通过与下单资格在未 READY 时失败关闭。此边现在仅属于 BACKGROUND,在线五服务强连通分量实际拆为 Auth↔IAM 与 Order↔Firstleg 两个子环,未触碰财务、Event Bridge 或四期业务状态机。 ## 退出顺序 diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index a9afeb4a0..041535f39 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -10,6 +10,7 @@ | 目标 | 入口 | |---|---| +| 内部模块化基线与依赖分析 | `docs/firstleg-internal-modularization-baseline.md`、`scripts/analyze-firstleg-dependencies.mjs`、`scripts/analyze-firstleg-dependencies.test.mjs`、`src/test/resources/architecture/firstleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | | 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` | | 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` | | 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`;手工单箱测量也从该 Controller 与 `CartonMeasurementService` 进入 | @@ -22,6 +23,7 @@ | 客户费用查询 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java` | | 客户运营统计/在途柜量 | `customerstatistics/controller/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/service/CustomerTransitStatisticsService.java`;契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` | | 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` | +| 价格簿查询内部切片 | `pricebookquery/application/port/in/PriceBookQueryUseCase.java`、`application/PriceBookQueryService.java`、`application/port/out/PriceBookQueryReader.java`、`adapter/out/persistence/PriceBookQueryMapper.java`;查询行模型位于 `application/model/PriceBookQueryRows.java` | | 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 | | 柜次费用明细 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` | | 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` | @@ -44,7 +46,9 @@ ## 修改约束 +- 移动包、拆分端口/适配器或引入新的跨切片依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-firstleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。 - 平台接口必须校验目标客户、权限、乐观锁、幂等键、历史和审计;商户查询只能使用登录态客户。 +- 价格簿查询消费者依赖 `PriceBookQueryUseCase` 或稳定查询端口,不得反向依赖持久化 Mapper;移动旧查询类时同步更新生产调用、测试、Mock 与 import,避免同一职责在根 `repository/mapper` 和 `pricebookquery/` 双写。 - 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。 - 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。 - 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。 diff --git a/firstleg/docs/firstleg-internal-modularization-baseline.md b/firstleg/docs/firstleg-internal-modularization-baseline.md index 7dbecb0d3..df1b0fc9e 100644 --- a/firstleg/docs/firstleg-internal-modularization-baseline.md +++ b/firstleg/docs/firstleg-internal-modularization-baseline.md @@ -1,4 +1,4 @@ -# Firstleg 内部模块化基线与首切片方案 +# Firstleg 内部模块化基线与切片实施记录 ## 1. 范围与生成方式 @@ -198,3 +198,53 @@ mvn -pl firstleg -am -Dtest=CustomerTransitStatisticsServiceTest,CustomerTransit - 根 Mapper 扫描只把旧精确包替换为新精确包,没有扩大扫描范围或增加兼容 Bean。 - 验证结果:Node 合同 3/3;`test-compile` 成功;客户统计加根扫描定向测试 7/7;`mvn -pl firstleg -am test` 共 854 个测试,0 失败、0 错误、3 跳过,构建成功。 - 首次并发执行 `test-compile` 时,其他专项同时写入共享 `target/classes` 导致若干无关 class 文件瞬时缺失;源符号核对正常,独立重跑 `test-compile` 与完整测试均成功。 + +## 12. 第二切片:价格簿只读查询 + +### 12.1 选择证据 + +第二切片选择 `pricebook-query`,而没有选择报关、拖车、出仓或船节点: + +- 原实现只有 `PriceBookQueryRows`、`PriceBookQueryRepository`、`PriceBookQueryMapper` 三个核心文件,全部为只读查询,不承载 Controller、权限或状态机。 +- `PriceBookQueryMapper -> repository.PriceBookQueryRows` 是主源码唯一 `mapper -> repository` 反向依赖,也是物理包 SCC `mapper <-> repository` 的唯一成因。 +- 报关和拖车均涉及 MultipartFile、Feign、装柜或出仓门禁;出仓和船节点属于当前业务热点,风险高于只读价格簿查询。 +- 本切片不触碰 `LoadingPickService`、客户尺寸初始化、IAM 解环、POM、CI、AGENTS、SQL 或部署配置。 + +### 12.2 目标结构与依赖方向 + +```text +pricebookquery/ + application/ + PriceBookQueryService.java + model/PriceBookQueryRows.java + port/in/PriceBookQueryUseCase.java + port/out/PriceBookQueryReader.java + adapter/out/persistence/ + PriceBookQueryMapper.java +``` + +依赖由“多个业务 Service -> 全局 Repository -> 全局 Mapper -> Repository Rows”改为: + +```text +业务消费者 -> PriceBookQueryUseCase +PriceBookQueryService -> PriceBookQueryReader +PriceBookQueryMapper -> PriceBookQueryReader + PriceBookQueryRows +``` + +消费者只能访问 In Port 和只读模型;application 不依赖 Spring MVC、MyBatis、MyBatis-Plus 或其他 Firstleg 切片。Mapper 的七段 `@Select` SQL 文本和方法参数保持不变。 + +### 12.3 依赖图变化 + +- 主源码由 430 类/949 条内部依赖/67 包变为 432 类/953 条内部依赖/72 包;新增项为 In Port、Out Port 和 Application Service,原 Repository 被删除。 +- 物理包多节点 SCC 从 1 个降为 0 个。 +- `pricebook-query` 是零出向边的叶子,不进入逻辑 SCC;显式入向仅为订舱、测量计价、分柜装柜三个消费者切片。 +- 原 `split-loading -> measurement-pricing` 两条价格簿引用被替换为显式 `split-loading -> pricebook-query`;Node 合同没有简单放宽旧边。 +- 既有 88 条跨切片 Entity/DTO 泄漏和 31 条 Mapper 泄漏均未增加。 + +### 12.4 棘轮与回滚边界 + +- Node 合同锁定五个目标类位置、application 依赖禁令、零出向/三类入向和消费者只能访问 In Port/模型,并断言物理包 SCC 为空。 +- `PriceBookQueryArchitectureTest` 用 ArchUnit 执行同等边界,持久化 Mapper 必须实现 Out Port。 +- 回滚仅恢复原三文件及消费者类型引用;不涉及 URI、权限、JSON、SQL、表结构、业务状态或环境配置。 +- 只读模型仍保留原 Lombok 可变 Bean 形态以维持 MyBatis 映射行为;本轮不顺带改造成不可变领域对象。 +- 验证结果:七段 `@Select` SQL 文本逐块完全一致;Node 合同 5/5;`test-compile` 成功;价格簿、ArchUnit、根扫描及三个消费者切片定向测试 78/78;Firstleg 聚合全量 878 个测试,0 失败、0 错误、3 跳过,构建成功。 diff --git a/firstleg/scripts/analyze-firstleg-dependencies.mjs b/firstleg/scripts/analyze-firstleg-dependencies.mjs index 5b252d7a8..302d466c7 100644 --- a/firstleg/scripts/analyze-firstleg-dependencies.mjs +++ b/firstleg/scripts/analyze-firstleg-dependencies.mjs @@ -13,6 +13,7 @@ const internalPrefix = 'com.baiyue.firstleg.'; const sliceRules = [ ['customer-statistics', /(?:^|\.)(?:customerstatistics)(?:\.|$)/], + ['pricebook-query', /(?:^|\.)(?:pricebookquery)(?:\.|$)/], ['finance-integration', /(?:InternalFinance|FinanceFeeSource|FinanceCostSnapshot|FeePaymentVoucher|FeePaymentSnapshot|ContainerFeeSourceSnapshot)/], ['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException)/], ['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/], @@ -273,8 +274,9 @@ function checkContract(report, contractPath) { if (!actualClass) violations.push(`required class missing: ${fqcn}`); else if (actualClass.file !== expectedFile) violations.push(`class location changed: ${fqcn} (${actualClass.file})`); } - const applicationRule = contract.applicationDependencyRule; - if (applicationRule) { + const applicationRules = contract.applicationDependencyRules + ?? (contract.applicationDependencyRule ? [contract.applicationDependencyRule] : []); + for (const applicationRule of applicationRules) { const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(applicationRule.packagePrefix)); for (const entry of applicationClasses) { for (const imported of entry.imports) { @@ -291,6 +293,15 @@ function checkContract(report, contractPath) { } } } + for (const exposureRule of contract.sliceExposureRules ?? []) { + for (const edge of report.classDependencies) { + if (!edge.source.startsWith(exposureRule.slicePrefix) + && edge.target.startsWith(exposureRule.slicePrefix) + && !(exposureRule.allowedTargetPrefixes ?? []).some(prefix => edge.target.startsWith(prefix))) { + violations.push(`forbidden slice implementation dependency: ${edge.source} -> ${edge.target}`); + } + } + } for (const [slice, rule] of Object.entries(contract.sliceSpecificRules ?? {})) { const outgoing = report.sliceEdges.filter(edge => edge.source === slice).map(edge => edge.target).sort(); const incoming = report.sliceEdges.filter(edge => edge.target === slice).map(edge => edge.source).sort(); diff --git a/firstleg/scripts/analyze-firstleg-dependencies.test.mjs b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs index 9bcb61d64..101c2f807 100644 --- a/firstleg/scripts/analyze-firstleg-dependencies.test.mjs +++ b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs @@ -52,3 +52,43 @@ test('customer statistics application layer only depends inward', () => { && edge.target.startsWith('com.baiyue.firstleg.') && !edge.target.startsWith(prefix)); assert.deepEqual(outwardDependencies, []); }); + +test('price book query is a leaf slice and removes the package cycle', () => { + const report = analyze(); + const classes = report.classes.filter(entry => entry.slice === 'pricebook-query'); + assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [ + 'com.baiyue.firstleg.pricebookquery.adapter.out.persistence.PriceBookQueryMapper', + 'com.baiyue.firstleg.pricebookquery.application.PriceBookQueryService', + 'com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows', + 'com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase', + 'com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader', + ]); + assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'pricebook-query'), []); + assert.deepEqual(report.sliceEdges.filter(edge => edge.target === 'pricebook-query') + .map(edge => edge.source).sort(), ['booking-customs-trucking', 'measurement-pricing', 'split-loading']); + assert.equal(report.sliceSccs.some(component => component.includes('pricebook-query')), false); + assert.deepEqual(report.packageSccs, []); +}); + +test('price book query application and consumers respect ports', () => { + const report = analyze(); + const prefix = 'com.baiyue.firstleg.pricebookquery.application'; + const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(prefix)); + assert.equal(applicationClasses.length, 4); + for (const entry of applicationClasses) { + assert.equal(entry.imports.some(imported => imported.startsWith('org.springframework.web')), false); + assert.equal(entry.imports.some(imported => imported.startsWith('org.apache.ibatis')), false); + assert.equal(entry.imports.some(imported => imported.startsWith('com.baomidou.mybatisplus')), false); + } + const outwardDependencies = report.classDependencies.filter(edge => edge.source.startsWith(prefix) + && edge.target.startsWith('com.baiyue.firstleg.') && !edge.target.startsWith(prefix)); + assert.deepEqual(outwardDependencies, []); + const allowedTargets = [ + 'com.baiyue.firstleg.pricebookquery.application.model.', + 'com.baiyue.firstleg.pricebookquery.application.port.in.', + ]; + const implementationLeaks = report.classDependencies.filter(edge => !edge.source.startsWith('com.baiyue.firstleg.pricebookquery.') + && edge.target.startsWith('com.baiyue.firstleg.pricebookquery.') + && !allowedTargets.some(prefixValue => edge.target.startsWith(prefixValue))); + assert.deepEqual(implementationLeaks, []); +}); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java index 8c071e0be..595ec1411 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java @@ -18,7 +18,8 @@ import org.springframework.scheduling.annotation.EnableScheduling; "com.baiyue.firstleg.outbound.dispatch.mapper", "com.baiyue.firstleg.portschedule.mapper", "com.baiyue.firstleg.customs.mapper", - "com.baiyue.firstleg.customerstatistics.adapter.out.persistence" + "com.baiyue.firstleg.customerstatistics.adapter.out.persistence", + "com.baiyue.firstleg.pricebookquery.adapter.out.persistence" }) @EnableFeignClients(basePackages = "com.baiyue.firstleg") @EnableScheduling diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index 5db6c6f52..7b4ad9e48 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -11,9 +11,9 @@ import com.baiyue.firstleg.domain.ContainerSpecificationCatalog; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*; import com.baiyue.firstleg.oceanbooking.entity.*; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; import com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator; import com.fasterxml.jackson.core.JsonProcessingException; @@ -39,7 +39,7 @@ public class OceanBookingService { private static final Set OPERATIONAL_LIST_STATUSES = Set.of( OceanBookingStatus.DRAFT, OceanBookingStatus.RELEASED); @Resource private OceanBookingRepository repository; - @Resource private PriceBookQueryRepository priceBooks; + @Resource private PriceBookQueryUseCase priceBooks; @Resource private ObjectMapper objectMapper; @Resource private WarehouseSupplierSnapshotService suppliers; @Resource private WarehouseMasterDataClient warehouses; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java similarity index 93% rename from firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookQueryMapper.java rename to firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java index f86961189..8b708f74d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java @@ -1,9 +1,10 @@ -package com.baiyue.firstleg.mapper; +package com.baiyue.firstleg.pricebookquery.adapter.out.persistence; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; @@ -12,7 +13,7 @@ import java.util.List; /** Dedicated read model for merchant channel selection and reference estimates. */ @Mapper -public interface PriceBookQueryMapper { +public interface PriceBookQueryMapper extends PriceBookQueryReader { @Select(""" SELECT channel_code AS channelCode, inbound_code_prefix AS inboundCodePrefix, overseas_inbound_code_prefix AS overseasInboundCodePrefix diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java new file mode 100644 index 000000000..094003ec2 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java @@ -0,0 +1,45 @@ +package com.baiyue.firstleg.pricebookquery.application; + +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; +import com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class PriceBookQueryService implements PriceBookQueryUseCase { + @Resource private PriceBookQueryReader reader; + + @Override public Long activeVersionId() { return reader.findActiveVersionId(); } + @Override public List channels(long versionId, long originWarehouseId) { + return reader.findChannels(versionId, originWarehouseId); + } + @Override public List channelTaxModes(long versionId, long originWarehouseId) { + return reader.findChannelTaxModes(versionId, originWarehouseId); + } + @Override public List fbaRates(long versionId, long originWarehouseId, String channelCode, + String destinationCode, String pricingTaxMode) { + return reader.findFbaRates(versionId, originWarehouseId, channelCode, destinationCode, pricingTaxMode); + } + @Override public List overseasRates(long versionId, long originWarehouseId, String channelCode, + long destinationWarehouseId, String pricingTaxMode) { + return reader.findOverseasRates(versionId, originWarehouseId, channelCode, destinationWarehouseId, + pricingTaxMode); + } + @Override public List regionTiers(long versionId, long originWarehouseId, String channelCode, + String regionCode, String pricingTaxMode) { + return reader.findRegionTiers(versionId, originWarehouseId, channelCode, regionCode, pricingTaxMode); + } + @Override public boolean channelAvailable(long versionId, long originWarehouseId, String channelCode) { + return channels(versionId, originWarehouseId).stream() + .anyMatch(channel -> channelCode.equals(channel.getChannelCode())); + } + @Override public ChannelPrefixRow channelPrefix(String channelCode) { + return reader.findChannelPrefix(channelCode); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java similarity index 96% rename from firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java rename to firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java index dc8395fc6..0ca9c0b8a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java @@ -1,4 +1,4 @@ -package com.baiyue.firstleg.repository; +package com.baiyue.firstleg.pricebookquery.application.model; import lombok.Getter; import lombok.Setter; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java new file mode 100644 index 000000000..980c86ad7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java @@ -0,0 +1,22 @@ +package com.baiyue.firstleg.pricebookquery.application.port.in; + +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; + +import java.util.List; + +public interface PriceBookQueryUseCase { + Long activeVersionId(); + List channels(long versionId, long originWarehouseId); + List channelTaxModes(long versionId, long originWarehouseId); + List fbaRates(long versionId, long originWarehouseId, String channelCode, + String destinationCode, String pricingTaxMode); + List overseasRates(long versionId, long originWarehouseId, String channelCode, + long destinationWarehouseId, String pricingTaxMode); + List regionTiers(long versionId, long originWarehouseId, String channelCode, + String regionCode, String pricingTaxMode); + boolean channelAvailable(long versionId, long originWarehouseId, String channelCode); + ChannelPrefixRow channelPrefix(String channelCode); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java new file mode 100644 index 000000000..04d852aab --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.pricebookquery.application.port.out; + +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; + +import java.util.List; + +public interface PriceBookQueryReader { + Long findActiveVersionId(); + List findChannels(long versionId, long originWarehouseId); + List findChannelTaxModes(long versionId, long originWarehouseId); + List findFbaRates(long versionId, long originWarehouseId, String channelCode, + String destinationCode, String pricingTaxMode); + List findOverseasRates(long versionId, long originWarehouseId, String channelCode, + long destinationWarehouseId, String pricingTaxMode); + List findRegionTiers(long versionId, long originWarehouseId, String channelCode, + String regionCode, String pricingTaxMode); + ChannelPrefixRow findChannelPrefix(String channelCode); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java index e1733566e..003b4085e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java @@ -7,9 +7,9 @@ import com.baiyue.firstleg.domain.PriceRegion; import com.baiyue.firstleg.domain.PricingDestinationType; import com.baiyue.firstleg.domain.PricingTaxMode; import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import jakarta.annotation.Resource; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -21,7 +21,7 @@ import java.util.Locale; /** Single active-price-book lookup used by merchant estimates and inbound automatic billing. */ @Component public class ActivePriceBookEstimator { - @Resource private PriceBookQueryRepository repository; + @Resource private PriceBookQueryUseCase repository; @Resource private FbaWarehouseRateEstimator fbaEstimator; @Resource private RegionWeightTierEstimator regionEstimator; @Resource private PriceBookWarehouseRegionResolver warehouseRegionResolver; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimator.java index 463252d34..897aef553 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimator.java @@ -2,7 +2,7 @@ package com.baiyue.firstleg.pricing; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import org.springframework.stereotype.Component; import java.math.BigDecimal; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimator.java index 426593a10..3b9d25f6e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimator.java @@ -2,7 +2,7 @@ package com.baiyue.firstleg.pricing; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import org.springframework.stereotype.Component; import java.math.BigDecimal; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java deleted file mode 100644 index 1161bf9e3..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.baiyue.firstleg.repository; - -import com.baiyue.firstleg.mapper.PriceBookQueryMapper; -import jakarta.annotation.Resource; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public class PriceBookQueryRepository { - @Resource - private PriceBookQueryMapper mapper; - - public Long activeVersionId() { - return mapper.findActiveVersionId(); - } - - public List channels(long versionId, long originWarehouseId) { - return mapper.findChannels(versionId, originWarehouseId); - } - - public List channelTaxModes(long versionId, long originWarehouseId) { - return mapper.findChannelTaxModes(versionId, originWarehouseId); - } - - public List fbaRates(long versionId, long originWarehouseId, - String channelCode, String destinationCode, - String pricingTaxMode) { - return mapper.findFbaRates(versionId, originWarehouseId, channelCode, destinationCode, pricingTaxMode); - } - - public List overseasRates(long versionId, long originWarehouseId, - String channelCode, long destinationWarehouseId, - String pricingTaxMode) { - return mapper.findOverseasRates(versionId, originWarehouseId, channelCode, destinationWarehouseId, - pricingTaxMode); - } - - public List regionTiers(long versionId, long originWarehouseId, - String channelCode, String regionCode, - String pricingTaxMode) { - return mapper.findRegionTiers(versionId, originWarehouseId, channelCode, regionCode, pricingTaxMode); - } - - public boolean channelAvailable(long versionId, long originWarehouseId, String channelCode) { - return channels(versionId, originWarehouseId).stream() - .anyMatch(channel -> channelCode.equals(channel.getChannelCode())); - } - - public PriceBookQueryRows.ChannelPrefixRow channelPrefix(String channelCode) { - return mapper.findChannelPrefix(channelCode); - } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index cfc7553c4..7b52c4c8e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -22,8 +22,8 @@ import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper; import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity; @@ -44,7 +44,7 @@ public class ContainerService { @Resource private SmartLoadingRepository smartLoadingRepository; @Resource private LoadingPickRepository loadingPickRepository; @Resource private LoadingPickService loadingPickService; - @Resource private PriceBookQueryRepository priceBooks; + @Resource private PriceBookQueryUseCase priceBooks; @Resource private OceanBookingRepository oceanBookings; @Resource private WarehouseSupplierSnapshotService suppliers; @Resource private FirstlegDocumentNumberGenerator documentNumbers; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index a5f92a869..025f1776b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -14,7 +14,7 @@ import com.baiyue.firstleg.entity.InboundFeeItemEntity; import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import com.baiyue.firstleg.repository.FbaPricingRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java index 87d608bca..d913613d4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java @@ -9,9 +9,9 @@ import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest; import com.baiyue.firstleg.dto.PriceBookDtos.EstimateResponse; import com.baiyue.firstleg.dto.PriceBookChannelValidation.Response; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -26,7 +26,7 @@ import java.util.Map; public class MerchantPriceBookService { public static final String DISCLAIMER = "仅供参考,不含附加费,最终以实际结算为准"; - @Resource private PriceBookQueryRepository repository; + @Resource private PriceBookQueryUseCase repository; @Resource private ActivePriceBookEstimator estimator; public List channels(long merchantTenantId, long originWarehouseId) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java index 2b9409161..c9ca3a8af 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java @@ -21,6 +21,7 @@ class BaiyueFirstlegApplicationContractTest { .contains("com.baiyue.firstleg.portschedule.mapper") .contains("com.baiyue.firstleg.customs.mapper") .contains("com.baiyue.firstleg.customerstatistics.adapter.out.persistence") + .contains("com.baiyue.firstleg.pricebookquery.adapter.out.persistence") .doesNotContain("com.baiyue.firstleg.customerstatistics.mapper"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java index cccb0a153..5a9a269a2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java @@ -6,7 +6,7 @@ import com.baiyue.firstleg.domain.PriceModel; import com.baiyue.firstleg.domain.PricingTaxMode; import com.baiyue.firstleg.domain.PricingDestinationType; import com.baiyue.firstleg.dto.PriceBookDtos; -import com.baiyue.firstleg.mapper.PriceBookQueryMapper; +import com.baiyue.firstleg.pricebookquery.adapter.out.persistence.PriceBookQueryMapper; import org.apache.ibatis.annotations.Select; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index 06019bc33..f9b3ce64a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -9,8 +9,8 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementE import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; import com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator; import com.fasterxml.jackson.databind.ObjectMapper; @@ -33,7 +33,7 @@ import static org.mockito.Mockito.*; class OceanBookingServiceTest { @Mock private OceanBookingRepository repository; - @Mock private PriceBookQueryRepository priceBooks; + @Mock private PriceBookQueryUseCase priceBooks; @Mock private WarehouseSupplierSnapshotService suppliers; @Mock private WarehouseMasterDataClient warehouses; @Mock private FirstlegDocumentNumberGenerator documentNumbers; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryArchitectureTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryArchitectureTest.java new file mode 100644 index 000000000..3f9734d86 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryArchitectureTest.java @@ -0,0 +1,67 @@ +package com.baiyue.firstleg.pricebookquery; + +import com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +class PriceBookQueryArchitectureTest { + private static final String FIRSTLEG = "com.baiyue.firstleg.."; + private static final String SLICE = "com.baiyue.firstleg.pricebookquery.."; + private static final String APPLICATION = "com.baiyue.firstleg.pricebookquery.application.."; + private static final String INPUT_PORT = "com.baiyue.firstleg.pricebookquery.application.port.in.."; + private static final String OUTPUT_PORT = "com.baiyue.firstleg.pricebookquery.application.port.out.."; + private static final String MODEL = "com.baiyue.firstleg.pricebookquery.application.model.."; + private static final String PERSISTENCE = "com.baiyue.firstleg.pricebookquery.adapter.out.persistence.."; + + private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS) + .importPackages("com.baiyue.firstleg"); + + @Test + void applicationStaysFreeOfWebAndPersistenceFrameworks() { + noClasses().that().resideInAPackage(APPLICATION) + .should().dependOnClassesThat().resideInAnyPackage( + "org.springframework.web..", + "org.apache.ibatis..", + "com.baomidou.mybatisplus..") + .check(PRODUCTION_CLASSES); + } + + @Test + void applicationOnlyDependsOnItsOwnSliceWithinFirstleg() { + classes().that().resideInAPackage(APPLICATION) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(FIRSTLEG).or(resideInAPackage(APPLICATION))) + .check(PRODUCTION_CLASSES); + } + + @Test + void persistenceAdapterCollaboratesThroughTheOutputPortAndModel() { + classes().that().resideInAPackage(PERSISTENCE) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(SLICE) + .or(resideInAPackage(PERSISTENCE)) + .or(resideInAPackage(OUTPUT_PORT)) + .or(resideInAPackage(MODEL))) + .andShould().beAssignableTo(PriceBookQueryReader.class) + .check(PRODUCTION_CLASSES); + } + + @Test + void consumersOnlySeeTheInputPortAndReadModel() { + noClasses().that().resideOutsideOfPackage(SLICE) + .should().dependOnClassesThat( + resideInAPackage(SLICE) + .and(resideOutsideOfPackage(INPUT_PORT)) + .and(resideOutsideOfPackage(MODEL))) + .check(PRODUCTION_CLASSES); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java new file mode 100644 index 000000000..db339e20a --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java @@ -0,0 +1,46 @@ +package com.baiyue.firstleg.pricebookquery; + +import com.baiyue.firstleg.pricebookquery.application.PriceBookQueryService; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PriceBookQueryServiceTest { + @Mock private PriceBookQueryReader reader; + @InjectMocks private PriceBookQueryService service; + + @Test + void delegatesStableReadOperationsToTheOutputPort() { + ChannelPrefixRow prefix = new ChannelPrefixRow("SEA", "IN", "OUT"); + when(reader.findActiveVersionId()).thenReturn(9L); + when(reader.findChannelPrefix("SEA")).thenReturn(prefix); + + assertThat(service.activeVersionId()).isEqualTo(9L); + assertThat(service.channelPrefix("SEA")).isSameAs(prefix); + verify(reader).findActiveVersionId(); + verify(reader).findChannelPrefix("SEA"); + } + + @Test + void keepsChannelAvailabilityAsApplicationLogic() { + ChannelRow channel = new ChannelRow(); + channel.setChannelCode("SEA"); + when(reader.findChannels(9L, 3L)).thenReturn(List.of(channel)); + + assertThat(service.channelAvailable(9L, 3L, "SEA")).isTrue(); + assertThat(service.channelAvailable(9L, 3L, "AIR")).isFalse(); + verify(reader, org.mockito.Mockito.times(2)).findChannels(9L, 3L); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimatorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimatorTest.java index c1b94e43d..7fdcef6cd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimatorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimatorTest.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.pricing; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import org.junit.jupiter.api.Test; import java.math.BigDecimal; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimatorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimatorTest.java index 06fe39203..6c9af0572 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimatorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimatorTest.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.pricing; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import org.junit.jupiter.api.Test; import java.math.BigDecimal; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 9ac608ba6..8c6f0a5b3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -17,8 +17,8 @@ import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper; import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; @@ -51,7 +51,7 @@ class ContainerServiceTest { @MockBean private SmartLoadingRepository smartLoadingRepository; @MockBean private LoadingPickRepository loadingPickRepository; @MockBean private LoadingPickService loadingPickService; - @MockBean private PriceBookQueryRepository priceBooks; + @MockBean private PriceBookQueryUseCase priceBooks; @MockBean private OceanBookingRepository oceanBookings; @MockBean private WarehouseSupplierSnapshotService suppliers; @MockBean private FirstlegDocumentNumberGenerator documentNumbers; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java index 6f2434cf3..33616c9f5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java @@ -4,7 +4,7 @@ import com.baiyue.firstleg.domain.CustomerDimensionBoxType; import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity; import com.baiyue.firstleg.pricing.FbaWarehouseRateEstimator; import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import org.junit.jupiter.api.Test; import java.math.BigDecimal; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 823e35dd3..0cefc7b02 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -11,7 +11,7 @@ import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; import com.baiyue.firstleg.repository.FbaPricingRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java index 9412f0928..8f6329212 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java @@ -13,11 +13,11 @@ import com.baiyue.firstleg.pricing.FbaWarehouseRateEstimator; import com.baiyue.firstleg.pricing.PriceBookWarehouseRegionResolver; import com.baiyue.firstleg.pricing.PriceRegionResolver; import com.baiyue.firstleg.pricing.RegionWeightTierEstimator; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelPrefixRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; @@ -38,7 +38,7 @@ import static org.mockito.Mockito.when; PriceBookWarehouseRegionResolver.class}) @TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class MerchantPriceBookServiceTest { - @MockBean private PriceBookQueryRepository repository; + @MockBean private PriceBookQueryUseCase repository; @MockBean private PriceBookWarehouseClient warehouses; @Resource private MerchantPriceBookService service; diff --git a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json index 308cda8bc..4ffb1a909 100644 --- a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json +++ b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json @@ -5,6 +5,7 @@ "booking-customs-trucking->inbound", "booking-customs-trucking->measurement-pricing", "booking-customs-trucking->outbound", + "booking-customs-trucking->pricebook-query", "booking-customs-trucking->shared-technical", "booking-customs-trucking->split-loading", "customer-statistics->shared-technical", @@ -19,6 +20,7 @@ "measurement-pricing->booking-customs-trucking", "measurement-pricing->finance-integration", "measurement-pricing->inbound", + "measurement-pricing->pricebook-query", "measurement-pricing->shared-technical", "outbound->booking-customs-trucking", "outbound->shared-technical", @@ -35,8 +37,8 @@ "shipping-nodes->split-loading", "split-loading->booking-customs-trucking", "split-loading->inbound", - "split-loading->measurement-pricing", "split-loading->outbound", + "split-loading->pricebook-query", "split-loading->shared-technical" ], "requiredClassLocations": { @@ -44,21 +46,50 @@ "com.baiyue.firstleg.customerstatistics.adapter.out.persistence.CustomerTransitStatisticsMapper": "src/main/java/com/baiyue/firstleg/customerstatistics/adapter/out/persistence/CustomerTransitStatisticsMapper.java", "com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService": "src/main/java/com/baiyue/firstleg/customerstatistics/application/CustomerTransitStatisticsService.java", "com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos": "src/main/java/com/baiyue/firstleg/customerstatistics/application/model/CustomerTransitStatisticsDtos.java", - "com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader": "src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java" - }, - "applicationDependencyRule": { - "packagePrefix": "com.baiyue.firstleg.customerstatistics.application", - "allowedInternalPrefix": "com.baiyue.firstleg.customerstatistics.application", - "forbiddenImportPrefixes": [ - "org.springframework.web", - "org.apache.ibatis", - "com.baomidou.mybatisplus" - ] + "com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader": "src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java", + "com.baiyue.firstleg.pricebookquery.adapter.out.persistence.PriceBookQueryMapper": "src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java", + "com.baiyue.firstleg.pricebookquery.application.PriceBookQueryService": "src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java", + "com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows": "src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java", + "com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase": "src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java", + "com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader": "src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java" }, + "applicationDependencyRules": [ + { + "packagePrefix": "com.baiyue.firstleg.customerstatistics.application", + "allowedInternalPrefix": "com.baiyue.firstleg.customerstatistics.application", + "forbiddenImportPrefixes": [ + "org.springframework.web", + "org.apache.ibatis", + "com.baomidou.mybatisplus" + ] + }, + { + "packagePrefix": "com.baiyue.firstleg.pricebookquery.application", + "allowedInternalPrefix": "com.baiyue.firstleg.pricebookquery.application", + "forbiddenImportPrefixes": [ + "org.springframework.web", + "org.apache.ibatis", + "com.baomidou.mybatisplus" + ] + } + ], + "sliceExposureRules": [ + { + "slicePrefix": "com.baiyue.firstleg.pricebookquery.", + "allowedTargetPrefixes": [ + "com.baiyue.firstleg.pricebookquery.application.model.", + "com.baiyue.firstleg.pricebookquery.application.port.in." + ] + } + ], "sliceSpecificRules": { "customer-statistics": { "allowedOutgoing": ["shared-technical"], "allowedIncoming": [] + }, + "pricebook-query": { + "allowedOutgoing": [], + "allowedIncoming": ["booking-customs-trucking", "measurement-pricing", "split-loading"] } } } diff --git a/iam/AGENTS.md b/iam/AGENTS.md index b085e38c4..ff4a63a71 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -12,10 +12,12 @@ | 目标 | 入口 | |---|---| | 客户资料/审核 | `controller/PlatformTenantController.java`、`service/TenantService.java` | +| 客户默认尺寸初始化、重试与补偿 | `service/CustomerDimensionRuleInitializationService.java`、`CustomerDimensionInitializationRelay.java`、`repository/CustomerDimensionInitializationRepository.java`、`client/CustomerDimensionRuleClient.java`、`model/CustomerDimensionInitializationTask.java`;持久任务异步调用 Firstleg `/internal/firstleg/v1/customer-dimension-configs/defaults` | | 商户成员与个人资料 | `controller/MerchantTenantController.java`、`service/MemberService.java`、`service/PersonalProfileService.java` | -| 平台用户 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java` | +| 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`WAREHOUSE_PICKER` 必须且只能绑定一个 `overseasWarehouseId` | | 角色/权限/RBAC | `controller/RbacController.java`、`service/RbacService.java` | | 授权快照/内部资格 | `controller/AuthorizationController.java`、`InternalAuthorizationController.java`、`service/AuthorizationSnapshotService.java` | +| 海外安全状态事件 Outbox | `service/SecurityEventOutboxService.java`、`SecurityEventOutboxRelay.java`、`client/SecurityEventBridgeClient.java`;账号、授权或客户状态变更经版本化事件投影到 `../overseas-security-service/` | | 消息中心/站内通知 | `notification/controller/NotificationInboxController.java`、`notification/controller/PlatformNotificationController.java`、`notification/service/NotificationService.java`;模板、任务、收件箱及事件消费均在 `notification/` | | File 资质元数据协作 | `controller/InternalTenantController.java`、`service/ProfileResourceService.java`、`client/AuthAccountClient.java` | | DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` | @@ -26,7 +28,9 @@ - 所有客户查询按 `tenant_id` 过滤;平台用户只能 `tenant_id=0`。必填资质审核通过才具备下单资格。 - 成员/账号跨 IAM/Auth 保持幂等补偿;资质跨 IAM/File 不保存文件内容。 +- 客户建档只在 IAM 事务内落持久初始化意图,远端投递由带租约、退避和最大次数的 Relay 完成;只有状态为 `READY` 才能审核通过。注册补偿必须投递 `COMPENSATE`,不得在建档事务中同步依赖 Firstleg。 - 权限资源 code/type 不可变;按钮隐藏不能替代后端权限校验。 +- 只有平台 `WAREHOUSE_PICKER` 可保存 `overseasWarehouseId`,且必须为单个有效海外仓;非拣货员必须为空。仓绑定、账号状态、角色权限或客户状态变化要在同一业务事务内推进安全事件 Outbox,不得依赖海外服务回查国内 IAM。 ## 全量验收导航 diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java index d51fe0878..9a5e0642c 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -17,6 +17,7 @@ import com.baiyue.iam.dto.TenantDtos.PendingReviewResponse; import com.baiyue.iam.service.TenantGuard; import com.baiyue.iam.service.TenantService; import com.baiyue.iam.service.MemberService; +import com.baiyue.iam.model.CustomerDimensionInitializationStatus; import com.baiyue.iam.dto.PlatformCustomerMemberDtos; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -77,6 +78,24 @@ public class PlatformTenantController { return ApiResponse.success(service.merchant(tenantId)); } + @PostMapping("/tenants/{tenantId:\\d+}/dimension-initialization/retry") + @PreAuthorize("hasAuthority('platform:customer:edit')") + @Operation(summary = "重试客户默认尺寸初始化") + public ApiResponse retryDimensionInitialization( + @PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.retryDimensionInitialization(tenantId)); + } + + @GetMapping("/tenants/{tenantId:\\d+}/dimension-initialization") + @PreAuthorize("hasAuthority('platform:customer:list')") + @Operation(summary = "查询客户默认尺寸初始化诊断") + public ApiResponse dimensionInitializationStatus( + @PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.dimensionInitializationStatus(tenantId)); + } + @GetMapping("/tenants/contact-phone-availability") @PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')") @Operation(summary = "校验客户联系电话唯一性") diff --git a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java index b00df77b8..266bd6c2d 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java @@ -20,7 +20,8 @@ public final class OnboardingDtos { String address ) { } - public record MerchantRegistrationResponse(Long tenantId, Long membershipId, String tenantStatus) { } + public record MerchantRegistrationResponse(Long tenantId, Long membershipId, String tenantStatus, + String dimensionInitializationStatus) { } public record QualificationSubmissionRequest( @NotNull Long tenantId, diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java index 40e3422e9..69a08ec1c 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -133,6 +133,7 @@ public final class TenantDtos { @Schema(name = "TenantResponse", description = "Enterprise tenant summary / 企业租户概要") public record TenantResponse(Long id, String tenantCode, String tenantName, String status, + String dimensionInitializationStatus, String legalName, String legalRepresentative, String unifiedSocialCreditCode, String contactName, String contactPhone, String contactEmail, String contactWechat, String servicePackage, String salesOwnerName, Integer paymentTermDays, String enterpriseType, diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java b/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java index 1761b7a81..bc3971d9b 100644 --- a/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java +++ b/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java @@ -22,6 +22,7 @@ public class IamTenant extends BaseEntity { private String tenantName; private String tenantType; private String status; + private String dimensionInitializationStatus; private String legalName; private String legalRepresentative; private String unifiedSocialCreditCode; diff --git a/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationStatus.java b/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationStatus.java new file mode 100644 index 000000000..eaf32fa8b --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationStatus.java @@ -0,0 +1,13 @@ +package com.baiyue.iam.model; + +import java.time.LocalDateTime; + +public record CustomerDimensionInitializationStatus( + long tenantId, + String status, + int attemptCount, + LocalDateTime nextRetryAt, + String lastErrorCode, + String lastErrorMessage, + LocalDateTime updatedAt +) { } diff --git a/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationTask.java b/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationTask.java new file mode 100644 index 000000000..51b3aea9a --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationTask.java @@ -0,0 +1,12 @@ +package com.baiyue.iam.model; + +public record CustomerDimensionInitializationTask( + long tenantId, + long operatorId, + String initializationKey, + String action, + String status, + int attemptCount, + int taskVersion) { +} + diff --git a/iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java b/iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java new file mode 100644 index 000000000..cc9d35ba8 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java @@ -0,0 +1,123 @@ +package com.baiyue.iam.repository; + +import com.baiyue.iam.model.CustomerDimensionInitializationTask; +import com.baiyue.iam.model.CustomerDimensionInitializationStatus; +import jakarta.annotation.Resource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public class CustomerDimensionInitializationRepository { + @Resource private JdbcTemplate jdbc; + + public void enqueue(long tenantId, long operatorId, String initializationKey) { + jdbc.update("INSERT INTO iam_customer_dimension_initialization_task(tenant_id,operator_id," + + "initialization_key,action,status,attempt_count,next_retry_at,task_version) " + + "VALUES(?,?,?,'INITIALIZE','PENDING',0,NOW(3),0)", + tenantId, operatorId, initializationKey); + } + + public void requestCompensation(long tenantId, long operatorId, String initializationKey) { + int updated = jdbc.update("UPDATE iam_customer_dimension_initialization_task SET operator_id=?," + + "initialization_key=?,action='COMPENSATE',status='PENDING',attempt_count=0," + + "next_retry_at=NOW(3),lease_owner=NULL,lease_until=NULL,last_error_code=NULL," + + "last_error_message=NULL,task_version=task_version+1,updated_at=NOW(3) WHERE tenant_id=?", + operatorId, initializationKey, tenantId); + if (updated == 0) { + jdbc.update("INSERT INTO iam_customer_dimension_initialization_task(tenant_id,operator_id," + + "initialization_key,action,status,attempt_count,next_retry_at,task_version) " + + "VALUES(?,?,?,'COMPENSATE','PENDING',0,NOW(3),0)", + tenantId, operatorId, initializationKey); + } + } + + public boolean manualRetry(long tenantId) { + return jdbc.update("UPDATE iam_customer_dimension_initialization_task SET action='INITIALIZE'," + + "status='PENDING',attempt_count=0,next_retry_at=NOW(3),lease_owner=NULL,lease_until=NULL," + + "last_error_code=NULL,last_error_message=NULL,task_version=task_version+1,updated_at=NOW(3) " + + "WHERE tenant_id=? AND status='FAILED'", tenantId) == 1; + } + + public Optional findStatus(long tenantId) { + List rows = jdbc.query( + "SELECT tenant_id,status,attempt_count,next_retry_at,last_error_code,last_error_message,updated_at " + + "FROM iam_customer_dimension_initialization_task WHERE tenant_id=?", + (rs, row) -> new CustomerDimensionInitializationStatus( + rs.getLong("tenant_id"), rs.getString("status"), rs.getInt("attempt_count"), + rs.getTimestamp("next_retry_at").toLocalDateTime(), rs.getString("last_error_code"), + rs.getString("last_error_message"), rs.getTimestamp("updated_at").toLocalDateTime()), tenantId); + return rows.stream().findFirst(); + } + + public void recoverExpiredLeases() { + jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status='PENDING',lease_owner=NULL," + + "lease_until=NULL,next_retry_at=NOW(3),task_version=task_version+1,updated_at=NOW(3) " + + "WHERE status='SENDING' AND lease_until candidates(int limit) { + return jdbc.queryForList("SELECT tenant_id FROM iam_customer_dimension_initialization_task " + + "WHERE status='PENDING' AND next_retry_at<=NOW(3) ORDER BY next_retry_at,tenant_id LIMIT " + + Math.max(1, Math.min(limit, 200)), Long.class); + } + + @Transactional + public CustomerDimensionInitializationTask claim(long tenantId, String workerId, LocalDateTime now) { + int claimed = jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status='SENDING'," + + "lease_owner=?,lease_until=?,task_version=task_version+1,updated_at=? " + + "WHERE tenant_id=? AND status='PENDING' AND next_retry_at<=?", + workerId, now.plusMinutes(2), now, tenantId, now); + if (claimed != 1) return null; + return jdbc.queryForObject("SELECT tenant_id,operator_id,initialization_key,action,status," + + "attempt_count,task_version FROM iam_customer_dimension_initialization_task " + + "WHERE tenant_id=? AND status='SENDING' AND lease_owner=?", + (rs, row) -> new CustomerDimensionInitializationTask( + rs.getLong("tenant_id"), rs.getLong("operator_id"), rs.getString("initialization_key"), + rs.getString("action"), rs.getString("status"), rs.getInt("attempt_count"), + rs.getInt("task_version")), tenantId, workerId); + } + + @Transactional + public boolean markReady(CustomerDimensionInitializationTask task, String workerId, Long configId) { + int updated = jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status='READY'," + + "remote_config_id=?,lease_owner=NULL,lease_until=NULL,last_error_code=NULL," + + "last_error_message=NULL,completed_at=NOW(3),updated_at=NOW(3) " + + "WHERE tenant_id=? AND action='INITIALIZE' AND status='SENDING' AND lease_owner=? AND task_version=?", + configId, task.tenantId(), workerId, task.taskVersion()); + if (updated == 1) { + jdbc.update("UPDATE iam_tenant SET dimension_initialization_status='READY',updated_at=NOW(3) " + + "WHERE id=? AND deleted=0", task.tenantId()); + } + return updated == 1; + } + + public boolean markCancelled(CustomerDimensionInitializationTask task, String workerId) { + return jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status='CANCELLED'," + + "lease_owner=NULL,lease_until=NULL,last_error_code=NULL,last_error_message=NULL," + + "completed_at=NOW(3),updated_at=NOW(3) WHERE tenant_id=? AND action='COMPENSATE' " + + "AND status='SENDING' AND lease_owner=? AND task_version=?", + task.tenantId(), workerId, task.taskVersion()) == 1; + } + + @Transactional + public boolean recordFailure(CustomerDimensionInitializationTask task, String workerId, int nextAttempt, + LocalDateTime nextRetryAt, String code, String message, boolean exhausted) { + String status = exhausted ? "FAILED" : "PENDING"; + int updated = jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status=?,attempt_count=?," + + "next_retry_at=?,lease_owner=NULL,lease_until=NULL,last_error_code=?,last_error_message=?," + + "task_version=task_version+1,updated_at=NOW(3) WHERE tenant_id=? AND action=? " + + "AND status='SENDING' AND lease_owner=? AND task_version=?", + status, nextAttempt, nextRetryAt, code, message, task.tenantId(), task.action(), workerId, + task.taskVersion()); + if (updated == 1 && exhausted && "INITIALIZE".equals(task.action())) { + jdbc.update("UPDATE iam_tenant SET dimension_initialization_status='FAILED',updated_at=NOW(3) " + + "WHERE id=? AND deleted=0", task.tenantId()); + } + return updated == 1; + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java new file mode 100644 index 000000000..730b8908a --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java @@ -0,0 +1,99 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.iam.client.CustomerDimensionRuleClient; +import com.baiyue.iam.model.CustomerDimensionInitializationTask; +import com.baiyue.iam.repository.CustomerDimensionInitializationRepository; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.Set; +import java.util.UUID; + +@Component +@Slf4j +public class CustomerDimensionInitializationRelay { + private static final Set PERMANENT_CODES = Set.of("A0400", "A0403", "A0404", "A0409", "A0422"); + @Resource private CustomerDimensionInitializationRepository repository; + @Resource private CustomerDimensionRuleClient client; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + @Value("${baiyue.customer-dimension-initialization.max-attempts:12}") private int maxAttempts; + private final String workerId = UUID.randomUUID().toString(); + + @Scheduled(fixedDelayString = "${baiyue.customer-dimension-initialization.fixed-delay-ms:5000}") + public void relay() { + repository.recoverExpiredLeases(); + for (Long tenantId : repository.candidates(50)) deliver(tenantId); + } + + void deliver(long tenantId) { + CustomerDimensionInitializationTask task = repository.claim(tenantId, workerId, LocalDateTime.now()); + if (task == null) return; + try { + if ("COMPENSATE".equals(task.action())) { + ApiResponse response = client.compensate( + internalToken, task.tenantId(), task.initializationKey()); + requireSuccess(response, false); + repository.markCancelled(task, workerId); + return; + } + ApiResponse response = client.initialize( + internalToken, new CustomerDimensionRuleClient.InitializationRequest( + task.tenantId(), task.operatorId(), task.initializationKey())); + requireSuccess(response, true); + if (response.data() == null || response.data().configId() == null) { + throw new DeliveryFailure("S0503", "Firstleg 未返回默认尺寸配置ID", false); + } + repository.markReady(task, workerId, response.data().configId()); + } catch (Exception failure) { + int nextAttempt = task.attemptCount() + 1; + boolean permanent = failure instanceof DeliveryFailure delivery && delivery.permanent; + boolean exhausted = permanent || nextAttempt >= maxAttempts; + String code = failure instanceof DeliveryFailure delivery ? delivery.code : "S0503"; + String message = safeMessage(failure); + repository.recordFailure(task, workerId, nextAttempt, + LocalDateTime.now().plusSeconds(backoffSeconds(nextAttempt)), code, message, exhausted); + log.warn("客户默认尺寸后台初始化失败 tenantId={}, action={}, attempt={}, exhausted={}, code={}, reason={}", + tenantId, task.action(), nextAttempt, exhausted, code, message); + } + } + + private static void requireSuccess(ApiResponse response, boolean requireData) { + if (response != null && response.successful() && (!requireData || response.data() != null)) return; + String code = response == null || response.code() == null ? "S0503" : response.code(); + String message = response == null || response.message() == null || response.message().isBlank() + ? "Firstleg 服务未确认客户默认尺寸初始化" : response.message(); + throw new DeliveryFailure(code, message, PERMANENT_CODES.contains(code)); + } + + private static long backoffSeconds(int attempt) { + return switch (Math.min(Math.max(attempt, 1), 5)) { + case 1 -> 10; + case 2 -> 30; + case 3 -> 120; + case 4 -> 600; + default -> 1800; + }; + } + + private static String safeMessage(Exception failure) { + String value = failure.getMessage(); + if (value == null || value.isBlank()) value = failure.getClass().getSimpleName(); + value = value.replaceAll("[\\r\\n\\t]+", " "); + return value.length() > 1000 ? value.substring(0, 1000) : value; + } + + private static final class DeliveryFailure extends RuntimeException { + private final String code; + private final boolean permanent; + private DeliveryFailure(String code, String message, boolean permanent) { + super(message); + this.code = code; + this.permanent = permanent; + } + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java index 32bd7217c..c5aaca617 100644 --- a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java @@ -1,119 +1,50 @@ package com.baiyue.iam.service; -import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.iam.client.CustomerDimensionRuleClient; +import com.baiyue.iam.repository.CustomerDimensionInitializationRepository; +import com.baiyue.iam.model.CustomerDimensionInitializationStatus; import jakarta.annotation.Resource; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; -/** Provisions four default box-size dimensions and compensates them when customer creation rolls back. */ +/** Owns IAM's durable customer-dimension initialization intent; remote delivery is always background. */ @Service public class CustomerDimensionRuleInitializationService { - private static final Logger log = LoggerFactory.getLogger(CustomerDimensionRuleInitializationService.class); - private static final int MAX_ATTEMPTS = 3; private static final String KEY_PREFIX = "iam-customer-create:"; - - @Resource - private CustomerDimensionRuleClient client; - @Value("${baiyue.security.internal-service-token}") - private String internalToken; + @Resource private CustomerDimensionInitializationRepository repository; public InitializationResult initialize(long tenantId, long operatorId) { String initializationKey = initializationKey(tenantId); - RuntimeException lastFailure = null; - for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - try { - ApiResponse response = client.initialize( - internalToken, - new CustomerDimensionRuleClient.InitializationRequest( - tenantId, operatorId, initializationKey)); - if (response != null && response.successful() && response.data() != null) { - InitializationResult result = new InitializationResult( - response.data().createdByThisInitialization(), initializationKey); - registerRollbackCompensation(tenantId, result); - return result; - } - lastFailure = unavailable(response); - log.warn("客户4条默认箱规尺寸初始化失败,tenantId={}, attempt={}, code={}, requestId={}, traceId={}", - tenantId, attempt, response == null ? null : response.code(), - response == null ? null : response.requestId(), - response == null ? null : response.traceId()); - } catch (RuntimeException failure) { - lastFailure = failure; - log.warn("客户4条默认箱规尺寸初始化调用异常,tenantId={}, attempt={}", tenantId, attempt, failure); - } - } - compensateAfterUncertainInitialization(tenantId, initializationKey); - throw unavailable(lastFailure); + repository.enqueue(tenantId, operatorId, initializationKey); + return new InitializationResult(false, initializationKey, "PENDING"); } /** Used when Auth compensates a committed client registration. */ public void compensateRegistration(long tenantId) { - compensateRequired(tenantId, initializationKey(tenantId)); + repository.requestCompensation(tenantId, 0L, initializationKey(tenantId)); } - private void registerRollbackCompensation(long tenantId, InitializationResult result) { - if (!result.created() || !TransactionSynchronizationManager.isSynchronizationActive()) return; - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCompletion(int status) { - if (status != STATUS_ROLLED_BACK) return; - try { - compensateRequired(tenantId, result.initializationKey()); - } catch (RuntimeException compensationFailure) { - log.error("客户创建事务回滚后4条默认箱规尺寸补偿失败,tenantId={}, initializationKey={}", - tenantId, result.initializationKey(), compensationFailure); - } - } - }); - } - - private void compensateAfterUncertainInitialization(long tenantId, String initializationKey) { - try { - compensateRequired(tenantId, initializationKey); - } catch (RuntimeException compensationFailure) { - log.error("客户4条默认箱规尺寸初始化结果不确定且补偿失败,tenantId={}, initializationKey={}", - tenantId, initializationKey, compensationFailure); + public void retry(long tenantId) { + if (!repository.manualRetry(tenantId)) { + throw new BizException(ResultCode.CONFLICT, "默认尺寸初始化当前不可人工重试"); } } - private void compensateRequired(long tenantId, String initializationKey) { - RuntimeException lastFailure = null; - for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - try { - ApiResponse response = client.compensate(internalToken, tenantId, initializationKey); - if (response != null && response.successful()) return; - lastFailure = unavailable(response); - } catch (RuntimeException failure) { - lastFailure = failure; - } + public CustomerDimensionInitializationStatus status(long tenantId) { + return repository.findStatus(tenantId).orElseThrow( + () -> new BizException(ResultCode.NOT_FOUND, "默认尺寸初始化任务不存在")); + } + + public void requireReady(String status) { + if (!"READY".equals(status)) { + throw new BizException(ResultCode.CONFLICT, + "客户默认尺寸尚未就绪,当前状态: " + (status == null ? "PENDING" : status)); } - throw new BizException(ResultCode.SERVICE_UNAVAILABLE, - "客户4条默认箱规尺寸补偿失败,请根据客户ID修复数据一致性: " + tenantId); - } - - private static BizException unavailable(ApiResponse response) { - String detail = response == null || response.message() == null || response.message().isBlank() - ? "Firstleg 服务未返回有效响应" : response.message(); - return new BizException(ResultCode.SERVICE_UNAVAILABLE, "4条默认箱规尺寸初始化失败: " + detail); - } - - private static BizException unavailable(RuntimeException cause) { - String detail = cause == null || cause.getMessage() == null || cause.getMessage().isBlank() - ? "Firstleg 服务暂时不可用" : cause.getMessage(); - return new BizException(ResultCode.SERVICE_UNAVAILABLE, - "4条默认箱规尺寸初始化失败,已重试 3 次: " + detail); } private static String initializationKey(long tenantId) { return KEY_PREFIX + tenantId; } - public record InitializationResult(boolean created, String initializationKey) { } + public record InitializationResult(boolean created, String initializationKey, String status) { } } diff --git a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java index db8c43964..9e71f4848 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java @@ -69,6 +69,7 @@ public class MerchantOnboardingService { tenant.setTenantName(request.companyName()); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); + tenant.setDimensionInitializationStatus("PENDING"); tenant.setLegalName(request.companyName()); tenant.setLegalRepresentative(request.legalPerson()); tenant.setContactName(request.displayName()); @@ -122,7 +123,8 @@ public class MerchantOnboardingService { } dimensionRuleInitialization.initialize(tenantId, request.authUserId()); overseasStorageFeeInitialization.initialize(tenantId, request.authUserId(), tenant.getTenantName()); - return new MerchantRegistrationResponse(tenantId, membershipId, tenant.getStatus()); + return new MerchantRegistrationResponse(tenantId, membershipId, tenant.getStatus(), + tenant.getDimensionInitializationStatus()); } /** diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index 5d4195185..f29f58f63 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -16,6 +16,7 @@ import com.baiyue.iam.client.AuthAccountClient; import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.entity.IamMembership; import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.model.CustomerDimensionInitializationStatus; import com.baiyue.iam.dto.TenantDtos.ResetCustomerOwnerPasswordRequest; import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse; @@ -169,6 +170,7 @@ public class TenantService { tenant.setTenantName(request.tenantName()); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); + tenant.setDimensionInitializationStatus("PENDING"); tenant.setLegalName(request.legalName()); tenant.setLegalRepresentative(request.legalRepresentative()); tenant.setUnifiedSocialCreditCode(null); @@ -271,6 +273,9 @@ public class TenantService { if (tenant == null) { throw new BizException(ResultCode.NOT_FOUND, "Tenant not found / 企业不存在"); } + if (approve) { + dimensionRuleInitialization.requireReady(tenant.getDimensionInitializationStatus()); + } var initializationContext = new TenantApprovalInitializer.ApprovalContext( tenant, reviewId, reviewerId, comment); @@ -321,6 +326,10 @@ public class TenantService { return new OrderEligibilityResponse(false, tenant.getStatus(), "Enterprise information must be approved before ordering"); } + if (!"READY".equals(tenant.getDimensionInitializationStatus())) { + return new OrderEligibilityResponse(false, tenant.getStatus(), + "Customer default dimensions are not ready"); + } if (!hasAllQualificationsWithStatus(profileRepository.findQualifications(tenantId), "APPROVED")) { return new OrderEligibilityResponse(false, tenant.getStatus(), "All required enterprise qualifications must be approved"); @@ -338,6 +347,22 @@ public class TenantService { return response(requireMerchant(tenantId)); } + @Transactional + @BusinessAudit(event = "PLATFORM_CUSTOMER_DIMENSION_INITIALIZATION_RETRY", resourceType = "TENANT", + resourceId = "#tenantId", description = "平台重试客户默认尺寸初始化", captureBefore = false) + public TenantResponse retryDimensionInitialization(long tenantId) { + IamTenant tenant = requireMerchantForUpdate(tenantId); + dimensionRuleInitialization.retry(tenantId); + tenant.setDimensionInitializationStatus("PENDING"); + repository.saveTenant(tenant); + return response(tenant); + } + + public CustomerDimensionInitializationStatus dimensionInitializationStatus(long tenantId) { + requireMerchant(tenantId); + return dimensionRuleInitialization.status(tenantId); + } + public boolean contactPhoneAvailable(String contactPhone, Long tenantId) { String normalized = normalizeContactPhone(contactPhone); return tenantId == null @@ -505,6 +530,7 @@ public class TenantService { private TenantResponse response(IamTenant tenant) { return new TenantResponse( tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(), + tenant.getDimensionInitializationStatus(), tenant.getLegalName(), tenant.getLegalRepresentative(), tenant.getUnifiedSocialCreditCode(), tenant.getContactName(), tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(), tenant.getServicePackage(), tenant.getSalesOwnerName(), tenant.getPaymentTermDays(), tenant.getEnterpriseType(), @@ -527,6 +553,7 @@ public class TenantService { private TenantResponse summaryResponse(IamTenant tenant) { TenantResponse response = response(tenant); return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(), + response.dimensionInitializationStatus(), response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(), maskPhone(response.contactPhone()), maskEmail(response.contactEmail()), response.contactWechat(), response.servicePackage(), response.salesOwnerName(), response.paymentTermDays(), response.enterpriseType(), @@ -537,6 +564,7 @@ public class TenantService { private TenantResponse merchantVisibleResponse(IamTenant tenant) { TenantResponse response = response(tenant); return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(), + response.dimensionInitializationStatus(), response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(), response.contactPhone(), response.contactEmail(), response.contactWechat(), response.servicePackage(), response.salesOwnerName(), response.paymentTermDays(), response.enterpriseType(), response.establishedDate(), diff --git a/iam/src/main/resources/db/customer-dimension-initialization-task.sql b/iam/src/main/resources/db/customer-dimension-initialization-task.sql new file mode 100644 index 000000000..2f1794d8e --- /dev/null +++ b/iam/src/main/resources/db/customer-dimension-initialization-task.sql @@ -0,0 +1,33 @@ +SET NAMES utf8mb4; +USE baiyue_iam; + +SET @customer_dimension_status_ddl = (SELECT IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='iam_tenant' AND column_name='dimension_initialization_status'), + 'SELECT 1', + 'ALTER TABLE iam_tenant ADD COLUMN dimension_initialization_status VARCHAR(16) NOT NULL DEFAULT ''READY'' COMMENT ''默认尺寸初始化状态:PENDING待处理、READY已就绪、FAILED失败'' AFTER status')); +PREPARE customer_dimension_status_stmt FROM @customer_dimension_status_ddl; +EXECUTE customer_dimension_status_stmt; +DEALLOCATE PREPARE customer_dimension_status_stmt; + +CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task ( + tenant_id BIGINT NOT NULL COMMENT '客户ID及任务聚合ID', + operator_id BIGINT NOT NULL COMMENT '触发初始化的操作人ID;系统补偿为0', + initialization_key VARCHAR(128) NOT NULL COMMENT '稳定初始化幂等键:iam-customer-create:{tenantId}', + action VARCHAR(16) NOT NULL DEFAULT 'INITIALIZE' COMMENT '目标动作:INITIALIZE初始化、COMPENSATE补偿', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING待投递、SENDING投递中、READY已就绪、FAILED失败、CANCELLED已补偿', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '当前动作已失败尝试次数', + next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许投递时间', + lease_owner VARCHAR(128) NULL COMMENT '当前任务租约持有者', + lease_until DATETIME(3) NULL COMMENT '当前任务租约截止时间', + last_error_code VARCHAR(32) NULL COMMENT '最后下游错误码', + last_error_message VARCHAR(1000) NULL COMMENT '最后诊断错误,不保存敏感凭证', + remote_config_id BIGINT NULL COMMENT 'Firstleg返回的首条默认尺寸配置ID', + task_version INT NOT NULL DEFAULT 0 COMMENT '任务乐观版本;切换动作或认领时递增', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '任务创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '任务更新时间', + completed_at DATETIME(3) NULL COMMENT 'READY或CANCELLED完成时间', + PRIMARY KEY (tenant_id), + KEY idx_customer_dimension_task_dispatch (status,next_retry_at,lease_until), + KEY idx_customer_dimension_task_diagnosis (status,updated_at) +) ENGINE=InnoDB COMMENT='IAM客户默认尺寸可靠后台初始化任务及诊断状态'; diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index c2dfaffb3..bc250abe7 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant ( tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称', tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户', status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用、ARCHIVED已归档', + dimension_initialization_status VARCHAR(16) NOT NULL DEFAULT 'READY' COMMENT '默认尺寸初始化状态:PENDING待处理、READY已就绪、FAILED失败', legal_name VARCHAR(128) NULL COMMENT '企业法定名称', legal_representative VARCHAR(64) NULL COMMENT '法定代表人', unified_social_credit_code VARCHAR(32) NULL COMMENT '统一社会信用代码', @@ -42,6 +43,28 @@ CREATE TABLE IF NOT EXISTS iam_tenant ( KEY idx_iam_tenant_status (status, created_at) ) ENGINE=InnoDB COMMENT='客户企业主档表:保存企业资料、审核结果和启停状态;ID同时作为客户数据隔离键'; +CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task ( + tenant_id BIGINT NOT NULL COMMENT '客户ID及任务聚合ID', + operator_id BIGINT NOT NULL COMMENT '触发初始化的操作人ID;系统补偿为0', + initialization_key VARCHAR(128) NOT NULL COMMENT '稳定初始化幂等键:iam-customer-create:{tenantId}', + action VARCHAR(16) NOT NULL DEFAULT 'INITIALIZE' COMMENT '目标动作:INITIALIZE初始化、COMPENSATE补偿', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING待投递、SENDING投递中、READY已就绪、FAILED失败、CANCELLED已补偿', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '当前动作已失败尝试次数', + next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许投递时间', + lease_owner VARCHAR(128) NULL COMMENT '当前任务租约持有者', + lease_until DATETIME(3) NULL COMMENT '当前任务租约截止时间', + last_error_code VARCHAR(32) NULL COMMENT '最后下游错误码', + last_error_message VARCHAR(1000) NULL COMMENT '最后诊断错误,不保存敏感凭证', + remote_config_id BIGINT NULL COMMENT 'Firstleg返回的首条默认尺寸配置ID', + task_version INT NOT NULL DEFAULT 0 COMMENT '任务乐观版本;切换动作或认领时递增', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '任务创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '任务更新时间', + completed_at DATETIME(3) NULL COMMENT 'READY或CANCELLED完成时间', + PRIMARY KEY (tenant_id), + KEY idx_customer_dimension_task_dispatch (status,next_retry_at,lease_until), + KEY idx_customer_dimension_task_diagnosis (status,updated_at) +) ENGINE=InnoDB COMMENT='IAM客户默认尺寸可靠后台初始化任务及诊断状态'; + CREATE TABLE IF NOT EXISTS iam_tenant_review ( id BIGINT NOT NULL COMMENT '审核记录ID', tenant_id BIGINT NOT NULL COMMENT '被审核客户ID和数据隔离键', diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 4fa22a7e6..a3d146541 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -59,7 +59,7 @@ paths: post: tags: [Platform Tenant / 平台租户] summary: Register enterprise tenant / 创建企业客户草稿 - description: Create a `PROFILE_REQUIRED` enterprise draft and its OWNER account, returning one-time credentials. Saving the customer does not create a review task. / 创建待完善企业客户草稿及 OWNER 账号并返回一次性凭据,保存客户不会创建审核单。 + description: Create a `PROFILE_REQUIRED` enterprise draft and its OWNER account, returning one-time credentials. Customer creation succeeds after IAM commits; default dimensions initialize reliably in background and are exposed as `PENDING/READY/FAILED`. / 创建待完善企业客户草稿及 OWNER 账号并返回一次性凭据;IAM 提交即代表客户创建成功,默认尺寸在后台可靠初始化并显式返回 PENDING/READY/FAILED。 requestBody: required: true content: @@ -123,6 +123,38 @@ paths: $ref: '#/components/schemas/PlatformCustomerMembersApiResponse' '403': {$ref: '#/components/responses/Forbidden'} '404': {$ref: '#/components/responses/NotFound'} + /api/platform/v1/iam/tenants/{tenantId}/dimension-initialization: + parameters: + - {$ref: '#/components/parameters/TenantId'} + get: + tags: [Platform Tenant / 平台租户] + summary: 查询客户默认尺寸初始化诊断 + description: 返回任务状态、尝试次数、下次重试时间和最后失败原因;不会把远端失败转换为空数据或成功。 + responses: + '200': + description: 初始化诊断查询成功 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {$ref: '#/components/schemas/CustomerDimensionInitializationStatus'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + /api/platform/v1/iam/tenants/{tenantId}/dimension-initialization/retry: + parameters: + - {$ref: '#/components/parameters/TenantId'} + post: + tags: [Platform Tenant / 平台租户] + summary: 人工重试失败的客户默认尺寸初始化 + description: 只允许 FAILED 任务重新进入 PENDING;稳定 initializationKey 不变。 + responses: + '200': {$ref: '#/components/responses/TenantSuccess'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} /api/platform/v1/iam/tenants/{tenantId}/submit-review: parameters: - {$ref: '#/components/parameters/TenantId'} @@ -1085,12 +1117,13 @@ components: pages: {type: integer, minimum: 0} TenantResponse: type: object - required: [id, tenantCode, tenantName, status, servicePackage] + required: [id, tenantCode, tenantName, status, dimensionInitializationStatus, servicePackage] properties: id: {type: string, pattern: '^\d+$', example: '190000000000000001'} tenantCode: {type: string} tenantName: {type: string} status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]} + dimensionInitializationStatus: {type: string, enum: [PENDING, READY, FAILED], description: 客户默认尺寸初始化状态;未 READY 时依赖该配置的业务失败关闭} legalName: {type: string} legalRepresentative: {type: string, maxLength: 64} unifiedSocialCreditCode: {type: string} @@ -1109,6 +1142,17 @@ components: internalRemark: {type: string, maxLength: 1000, description: 仅平台端可见} createdAt: {type: string, format: date-time, description: 入驻时间} approvedAt: {type: string, format: date-time, description: 审批通过时间} + CustomerDimensionInitializationStatus: + type: object + required: [tenantId, status, attemptCount, nextRetryAt, updatedAt] + properties: + tenantId: {type: string, pattern: '^\d+$'} + status: {type: string, enum: [PENDING, SENDING, READY, FAILED, CANCELLED]} + attemptCount: {type: integer, minimum: 0} + nextRetryAt: {type: string, format: date-time} + lastErrorCode: {type: string, nullable: true} + lastErrorMessage: {type: string, nullable: true, description: 已清洗且截断的最后失败诊断} + updatedAt: {type: string, format: date-time} FinanceCustomerSummary: type: object required: [id, tenantCode, tenantName, status] diff --git a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java new file mode 100644 index 000000000..899ead0e7 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java @@ -0,0 +1,104 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.iam.client.CustomerDimensionRuleClient; +import com.baiyue.iam.model.CustomerDimensionInitializationTask; +import com.baiyue.iam.repository.CustomerDimensionInitializationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CustomerDimensionInitializationRelayTest { + @Mock private CustomerDimensionInitializationRepository repository; + @Mock private CustomerDimensionRuleClient client; + @InjectMocks private CustomerDimensionInitializationRelay relay; + + @BeforeEach + void configure() { + ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); + ReflectionTestUtils.setField(relay, "maxAttempts", 12); + } + + @Test + void successfulBackgroundInitializationMarksTenantReady() { + CustomerDimensionInitializationTask task = task("INITIALIZE", 0); + when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task); + when(client.initialize(anyString(), any())).thenReturn(ApiResponse.success( + new CustomerDimensionRuleClient.InitializationResponse(91L, true))); + + relay.deliver(10001L); + + verify(repository).markReady(task, workerId(), 91L); + verify(repository, never()).recordFailure(any(), anyString(), anyLong(), any(), anyString(), + anyString(), any(Boolean.class)); + } + + @Test + void transientFailureStaysPendingWithDiagnosticAndBackoff() { + CustomerDimensionInitializationTask task = task("INITIALIZE", 0); + when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task); + when(client.initialize(anyString(), any())).thenThrow(new RuntimeException("read timeout")); + + relay.deliver(10001L); + + ArgumentCaptor exhausted = ArgumentCaptor.forClass(Boolean.class); + verify(repository).recordFailure(any(), anyString(), anyLong(), any(), anyString(), + anyString(), exhausted.capture()); + assertFalse(exhausted.getValue()); + } + + @Test + void permanentRemoteRejectionBecomesFailedWithoutDefaultSuccess() { + CustomerDimensionInitializationTask task = task("INITIALIZE", 0); + when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task); + when(client.initialize(anyString(), any())).thenReturn(ApiResponse.failure( + ResultCode.CONFLICT, "invalid customer", "request-1", "trace-1")); + + relay.deliver(10001L); + + ArgumentCaptor exhausted = ArgumentCaptor.forClass(Boolean.class); + verify(repository).recordFailure(any(), anyString(), anyLong(), any(), anyString(), + anyString(), exhausted.capture()); + assertTrue(exhausted.getValue()); + verify(repository, never()).markReady(any(), anyString(), any()); + } + + @Test + void compensationIsAlsoDurableAndUsesTheStableKey() { + CustomerDimensionInitializationTask task = task("COMPENSATE", 0); + when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task); + when(client.compensate("internal-token", 10001L, "iam-customer-create:10001")) + .thenReturn(ApiResponse.success(null)); + + relay.deliver(10001L); + + verify(repository).markCancelled(task, workerId()); + } + + private CustomerDimensionInitializationTask task(String action, int attempts) { + return new CustomerDimensionInitializationTask(10001L, 9001L, "iam-customer-create:10001", + action, "SENDING", attempts, 3); + } + + private String workerId() { + return (String) ReflectionTestUtils.getField(relay, "workerId"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java index 459a72fab..b56e9c63d 100644 --- a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java @@ -1,138 +1,61 @@ package com.baiyue.iam.service; -import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.iam.client.CustomerDimensionRuleClient; -import jakarta.annotation.Resource; -import org.junit.jupiter.api.AfterEach; +import com.baiyue.iam.repository.CustomerDimensionInitializationRepository; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; - -import java.util.List; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@SpringJUnitConfig(CustomerDimensionRuleInitializationService.class) -@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +@ExtendWith(MockitoExtension.class) class CustomerDimensionRuleInitializationServiceTest { - @MockBean private CustomerDimensionRuleClient client; - @Resource private CustomerDimensionRuleInitializationService service; - - @AfterEach - void clearSynchronization() { - if (TransactionSynchronizationManager.isSynchronizationActive()) { - TransactionSynchronizationManager.clearSynchronization(); - } - } + @Mock private CustomerDimensionInitializationRepository repository; + @InjectMocks private CustomerDimensionRuleInitializationService service; @Test - void retriesOneAtomicFourBoxDefaultsCallWithAStableKey() { - when(client.initialize(anyString(), any())) - .thenThrow(new RuntimeException("timeout")) - .thenReturn(ApiResponse.failure(ResultCode.SERVICE_UNAVAILABLE, - "temporarily unavailable", "request-1", "trace-1")) - .thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, false))); - + void customerCreationOnlyPersistsBackgroundIntentWithStableKey() { var result = service.initialize(10001L, 9001L); assertFalse(result.created()); - ArgumentCaptor requests = - ArgumentCaptor.forClass(CustomerDimensionRuleClient.InitializationRequest.class); - verify(client, times(3)).initialize(anyString(), requests.capture()); - assertEquals(List.of("iam-customer-create:10001", "iam-customer-create:10001", - "iam-customer-create:10001"), - requests.getAllValues().stream().map( - CustomerDimensionRuleClient.InitializationRequest::initializationKey).toList()); - assertEquals(List.of(9001L, 9001L, 9001L), requests.getAllValues().stream() - .map(CustomerDimensionRuleClient.InitializationRequest::operatorId).toList()); - assertEquals(List.of(10001L, 10001L, 10001L), requests.getAllValues().stream() - .map(CustomerDimensionRuleClient.InitializationRequest::tenantId).toList()); + assertEquals("PENDING", result.status()); + assertEquals("iam-customer-create:10001", result.initializationKey()); + verify(repository).enqueue(10001L, 9001L, "iam-customer-create:10001"); } @Test - void reportsServiceUnavailableWhenFourBoxDefaultsFailThreeTimesAndTriesCompensation() { - when(client.initialize(anyString(), any())).thenThrow(new RuntimeException("timeout")); - when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null)); - - BizException failure = assertThrows(BizException.class, () -> service.initialize(10001L, 9001L)); - - assertEquals(ResultCode.SERVICE_UNAVAILABLE, failure.getResultCode()); - verify(client, times(3)).initialize(anyString(), any()); - verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001"); - } - - @Test - void localRollbackCompensatesFourBoxDefaultsCreatedByThisRegistration() { - when(client.initialize(anyString(), any())) - .thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, true))); - when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null)); - TransactionSynchronizationManager.initSynchronization(); - - var result = service.initialize(10001L, 9001L); - List synchronizations = - TransactionSynchronizationManager.getSynchronizations(); - TransactionSynchronizationManager.clearSynchronization(); - synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK)); - - assertTrue(result.created()); - verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001"); - } - - @Test - void localRollbackKeepsFourBoxDefaultsThatAlreadyExisted() { - when(client.initialize(anyString(), any())) - .thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, false))); - TransactionSynchronizationManager.initSynchronization(); - - service.initialize(10001L, 9001L); - List synchronizations = - TransactionSynchronizationManager.getSynchronizations(); - TransactionSynchronizationManager.clearSynchronization(); - synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK)); - - verify(client, never()).compensate(anyString(), anyLong(), anyString()); - } - - @Test - void responseTimeoutThenIdempotentFourBoxSuccessStillCompensatesOnLocalRollback() { - when(client.initialize(anyString(), any())) - .thenThrow(new RuntimeException("response timeout")) - .thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, true))); - when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null)); - TransactionSynchronizationManager.initSynchronization(); - - service.initialize(10001L, 9001L); - List synchronizations = - TransactionSynchronizationManager.getSynchronizations(); - TransactionSynchronizationManager.clearSynchronization(); - synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK)); - - verify(client, times(2)).initialize(anyString(), any()); - verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001"); - } - - @Test - void committedClientRegistrationCanBeCompensatedWithItsDeterministicKey() { - when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null)); - + void committedRegistrationCompensationUsesTheSameStableKey() { service.compensateRegistration(10001L); + verify(repository).requestCompensation(10001L, 0L, "iam-customer-create:10001"); + } - verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001"); + @Test + void failedTaskCanBeManuallyRequeued() { + when(repository.manualRetry(10001L)).thenReturn(true); + service.retry(10001L); + verify(repository).manualRetry(10001L); + } + + @Test + void nonFailedTaskCannotBeSilentlyRequeued() { + when(repository.manualRetry(10001L)).thenReturn(false); + BizException failure = assertThrows(BizException.class, () -> service.retry(10001L)); + assertEquals(ResultCode.CONFLICT, failure.getResultCode()); + } + + @Test + void dimensionsDependentFlowFailsClosedUntilReady() { + BizException pending = assertThrows(BizException.class, () -> service.requireReady("PENDING")); + BizException failed = assertThrows(BizException.class, () -> service.requireReady("FAILED")); + assertEquals(ResultCode.CONFLICT, pending.getResultCode()); + assertEquals(ResultCode.CONFLICT, failed.getResultCode()); + service.requireReady("READY"); } } diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index f70c92a95..cc6a8ccc8 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -10,6 +10,7 @@ | 功能 | 入口 | |---|---| +| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | | 事件接收、平台订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | | 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 | @@ -36,7 +37,8 @@ | 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | | 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO | | 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | -| 海外仓报价、月度汇率、车辆主数据 | `masterdata/quote/`、`masterdata/exchange/`、`masterdata/vehicle/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`;车辆档案不含派送履约历史 | +| 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 | +| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java`、`config/LastlegRestClientProperties.java`;目标地址与内部服务令牌只来自配置,不在业务子域手工创建客户端 | | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` | | 测试 | `src/test/java/com/baiyue/lastleg/` | | 真实事件消费验收 | `src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java`;覆盖真实 V2 建单、独立入仓单快照及下游转运/换标可消费性 | @@ -91,6 +93,7 @@ ## 修改与验证 +- 移动包、拆分端口/适配器或引入新的跨子域依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-lastleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。 - 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。 - 事件契约变化同时检查 `common-event-contract` 版本、Event Bridge 消费方和旧 Payload 哈希兼容。 - 定向验证:`mvn -pl lastleg -am test`。 diff --git a/scripts/README.md b/scripts/README.md index bfb9577f0..4b1eb9629 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -31,7 +31,8 @@ node scripts/create-pending-review-orders.mjs | Admin/Client 本地测试与类型检查 | `scripts/frontend-full-regression.ps1` | | PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` | | 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` | -| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、海外 Gateway 覆盖、PDA 注册页面、Maven 模块、关键入口与排除目录 | +| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、海外 Gateway 覆盖、PDA 注册页面、Maven 模块、海外安全服务部署映射、关键入口与排除目录 | +| Java 质量聚合摘要 | `node --test scripts/java-quality-summary.test.mjs`、`node scripts/java-quality-summary.mjs --output target/java-quality-summary.json`;聚合 Maven/服务拓扑、OpenAPI 覆盖、JaCoCo 和 SpotBugs 报告,默认只报告存量缺口,CI 仅用显式 `--require-spotbugs` 检查报告完整性且当前允许失败 | | 单服务部署失败回滚合同 | `scripts/deploy-built-service-rollback.test.mjs`;模拟成功、健康失败和启动失败,确认仅恢复同服务先前健康镜像且部署任务仍失败 | Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块边界,不访问测试环境: diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index ad8e06621..7ebbd6ded 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -156,6 +156,15 @@ foreach ($gatewayPath in @( Assert-Contains $overseasGatewayConfig 'uri: lb://lastleg-service' 'Overseas Gateway Lastleg 路由' Assert-Contains $overseasGatewayConfig 'uri: lb://warehouse-overseas-service' 'Overseas Gateway Warehouse 路由' +# 海外安全投影是独立部署单元,服务映射、海外编排和 Jenkins 构建入口必须同时存在。 +$serviceMap = Read-RequiredText (Join-Path $backendRoot 'deploy/service-map.yml') +$overseasCompose = Read-RequiredText (Join-Path $backendRoot 'deploy/docker-compose.overseas.yml') +$serviceJenkinsfile = Read-RequiredText (Join-Path $backendRoot 'Jenkinsfile.service') +Assert-Contains $serviceMap 'overseas-security-service:' '服务部署映射' +Assert-Contains $overseasCompose 'overseas-security-service:' '海外 Compose' +Assert-Contains $overseasCompose '/internal/overseas-security/v1/events' '海外安全事件消费地址' +Assert-Contains $serviceJenkinsfile "'overseas-security-service'" '服务 Jenkins 构建入口' + # Maven 聚合声明的全部历史/当前模块必须在后端根指引中可定位。 [xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw foreach ($module in $pom.project.modules.module) { @@ -174,6 +183,8 @@ $criticalPaths = @( 'apps/admin-web/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue', 'apps/admin-web/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue', 'apps/admin-web/src/modules/container-loading/api/platformContainerLoading.ts', + 'apps/admin-web/src/modules/iam/pages/InternalUsersView.vue', + 'apps/admin-web/src/modules/iam/api/platformIam.ts', 'apps/client-web/src/api/overseasHttp.ts', 'apps/client-web/src/modules/overseas-warehouse/routes.ts', 'apps/client-web/src/modules/overseas-warehouse/api/warehouseServices.ts', @@ -203,10 +214,35 @@ $criticalPaths = @( 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java', + 'baiyue-firstleg/firstleg/docs/firstleg-internal-modularization-baseline.md', + 'baiyue-firstleg/firstleg/scripts/analyze-firstleg-dependencies.mjs', + 'baiyue-firstleg/firstleg/scripts/analyze-firstleg-dependencies.test.mjs', + 'baiyue-firstleg/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json', 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java', 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java', + 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java', + 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java', + 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java', + 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java', + 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java', + 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java', + 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java', + 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java', + 'baiyue-firstleg/iam/src/main/resources/db/customer-dimension-initialization-task.sql', + 'baiyue-firstleg/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java', + 'baiyue-firstleg/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java', + 'baiyue-firstleg/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java', 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java', 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', + 'baiyue-firstleg/overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java', + 'baiyue-firstleg/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java', + 'baiyue-firstleg/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java', + 'baiyue-firstleg/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml', + 'baiyue-firstleg/overseas-security-service/src/main/resources/db/overseas-security.sql', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java', @@ -220,6 +256,18 @@ $criticalPaths = @( 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java', 'baiyue-firstleg/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml', + 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java', + 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java', + 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java', + 'baiyue-firstleg/lastleg-service/docs/lastleg-internal-modularization-baseline.md', + 'baiyue-firstleg/lastleg-service/scripts/analyze-lastleg-dependencies.mjs', + 'baiyue-firstleg/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs', + 'baiyue-firstleg/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/controller/MerchantLastlegOrderController.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderType.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java', @@ -240,6 +288,8 @@ $criticalPaths = @( 'baiyue-firstleg/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java', 'baiyue-firstleg/deploy/scripts/deploy-built-service.sh', 'baiyue-firstleg/scripts/deploy-built-service-rollback.test.mjs', + 'baiyue-firstleg/scripts/java-quality-summary.mjs', + 'baiyue-firstleg/scripts/java-quality-summary.test.mjs', 'docs/四期/船节点标准化契约.md' ) $criticalPaths | ForEach-Object { Assert-Path $_ } @@ -255,13 +305,19 @@ $indexedAgentFiles = @( (Join-Path $backendRoot 'firstleg/AGENTS.md'), (Join-Path $backendRoot 'finance/AGENTS.md'), (Join-Path $backendRoot 'lastleg-service/AGENTS.md'), + (Join-Path $backendRoot 'iam/AGENTS.md'), + (Join-Path $backendRoot 'common-security/AGENTS.md'), + (Join-Path $backendRoot 'common-openapi/AGENTS.md'), + (Join-Path $backendRoot 'warehouse-overseas-service/AGENTS.md'), + (Join-Path $backendRoot 'overseas-security-service/AGENTS.md'), (Join-Path $backendRoot 'event-bridge-service/AGENTS.md'), (Join-Path $backendRoot 'file/AGENTS.md'), (Join-Path $backendRoot 'common-event-contract/AGENTS.md') ) -$excludedTargetPattern = '(?i)`(?:[^`]*/)?(node_modules|node_modules\.broken-by-path|dist|target|reports|output|outputs|uni_modules|\.git|\.codex[^/`]*|\.runtime[^/`]*)(?:/|`)' +$excludedTargetPattern = '(?i)`(?:[^`]*/)?(node_modules(?:\.[^/`]*)?|dist|target|reports|output|outputs|uni_modules|\.git|\.codex[^/`]*|\.runtime[^/`]*)(?:/|`)' $excludedPatternSamples = @( '| 错误入口 | `node_modules/pkg/` |', + '| 错误入口 | `node_modules.corrupt/.bin/vite` |', '| 错误入口 | `src/target/classes/` |', '| 错误入口 | `.codex-temp/task/` |', '| 错误入口 | `.runtime-tailleg/DbQuery.java` |' @@ -293,5 +349,5 @@ if ($failures.Count -gt 0) { exit 1 } -Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、Gateway 覆盖、PDA 注册页面与能力开关、Maven 模块、关键入口和排除目录。" -ForegroundColor Green +Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、Gateway 覆盖、PDA 注册页面与能力开关、Maven 模块、海外安全服务部署映射、关键入口和排除目录。" -ForegroundColor Green exit 0 diff --git a/scripts/java-quality-summary.mjs b/scripts/java-quality-summary.mjs new file mode 100644 index 000000000..f54740dca --- /dev/null +++ b/scripts/java-quality-summary.mjs @@ -0,0 +1,175 @@ +import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +export const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))) + +const normalize = (value) => value.replaceAll('\\', '/') +const attribute = (source, name) => source.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1] + +async function exists(file) { + try { + await stat(file) + return true + } catch (error) { + if (error?.code === 'ENOENT') return false + throw error + } +} + +async function javaSourceCount(moduleDirectory) { + const root = path.join(moduleDirectory, 'src', 'main', 'java') + if (!await exists(root)) return 0 + let count = 0 + async function walk(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name) + if (entry.isDirectory()) await walk(target) + else if (entry.isFile() && entry.name.endsWith('.java')) count += 1 + } + } + await walk(root) + return count +} + +async function openApiFiles(moduleDirectory, module) { + const root = path.join(moduleDirectory, 'src', 'main', 'resources', 'openapi') + if (!await exists(root)) return [] + const files = [] + async function walk(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name) + if (entry.isDirectory()) await walk(target) + else if (entry.isFile() && /\.(?:json|ya?ml)$/i.test(entry.name)) { + files.push(normalize(path.join(module, path.relative(moduleDirectory, target)))) + } + } + } + await walk(root) + return files.sort() +} + +export function parseModules(pom) { + const block = pom.match(/([\s\S]*?)<\/modules>/)?.[1] ?? '' + return [...block.matchAll(/([^<]+)<\/module>/g)].map((match) => match[1].trim()) +} + +export function parseServices(serviceMap) { + return [...serviceMap.matchAll(/^ ([a-z][a-z0-9-]+):$/gm)].map((match) => match[1]).sort() +} + +export function parseJacocoReport(xml) { + if (!//.test(xml)) throw new Error('not a JaCoCo report') + const counters = [...xml.matchAll(/]*)\/?\s*>/g)] + .map((match) => match[1]) + .filter((source) => attribute(source, 'type') === 'LINE') + if (counters.length === 0) throw new Error('JaCoCo LINE counter is missing') + const summary = counters.at(-1) + const missed = Number(attribute(summary, 'missed')) + const covered = Number(attribute(summary, 'covered')) + if (!Number.isInteger(missed) || !Number.isInteger(covered)) throw new Error('invalid JaCoCo LINE counter') + const total = missed + covered + return { missed, covered, percentage: total === 0 ? null : Number((covered * 100 / total).toFixed(2)) } +} + +export function parseSpotbugsReport(xml) { + if (!//.test(xml)) throw new Error('not a SpotBugs report') + const findings = [...xml.matchAll(/]*)>/g)].map((match) => match[1]) + const byPriority = {} + const byCategory = {} + for (const finding of findings) { + const priority = attribute(finding, 'priority') ?? 'unknown' + const category = attribute(finding, 'category') ?? 'unknown' + byPriority[priority] = (byPriority[priority] ?? 0) + 1 + byCategory[category] = (byCategory[category] ?? 0) + 1 + } + return { findings: findings.length, byPriority, byCategory } +} + +async function reportState(file, parser, root) { + const relative = normalize(path.relative(root, file)) + if (!await exists(file)) return { status: 'missing', path: relative } + try { + return { status: 'valid', path: relative, ...parser(await readFile(file, 'utf8')) } + } catch (error) { + return { status: 'invalid', path: relative, error: error instanceof Error ? error.message : String(error) } + } +} + +export async function buildQualitySummary(root = repositoryRoot) { + const modules = parseModules(await readFile(path.join(root, 'pom.xml'), 'utf8')) + const services = parseServices((await readFile(path.join(root, 'deploy', 'service-map.yml'), 'utf8')).replaceAll('\r\n', '\n')) + const moduleRows = [] + const documents = [] + for (const module of modules) { + const moduleDirectory = path.join(root, module) + const sources = await javaSourceCount(moduleDirectory) + documents.push(...await openApiFiles(moduleDirectory, module)) + if (sources === 0) continue + const jacoco = await reportState(path.join(moduleDirectory, 'target', 'site', 'jacoco', 'jacoco.xml'), parseJacocoReport, root) + const spotbugs = await reportState(path.join(moduleDirectory, 'target', 'spotbugsXml.xml'), parseSpotbugsReport, root) + spotbugs.html = await exists(path.join(moduleDirectory, 'target', 'site', 'spotbugs.html')) + moduleRows.push({ module, javaSources: sources, jacoco, spotbugs }) + } + const coveredServices = [...new Set(documents.map((file) => file.split('/')[0]))] + .filter((module) => services.includes(module)).sort() + const codeFirstServices = services.filter((service) => !coveredServices.includes(service)) + const count = (kind, status) => moduleRows.filter((row) => row[kind].status === status).length + return { + topology: { modules: modules.length, codeModules: moduleRows.length, services: services.length }, + openapi: { documents: documents.length, coveredServices, codeFirstServices, files: documents.sort() }, + jacoco: { + present: count('jacoco', 'valid'), invalid: count('jacoco', 'invalid'), missing: count('jacoco', 'missing') + }, + spotbugs: { + present: count('spotbugs', 'valid'), invalid: count('spotbugs', 'invalid'), + missing: count('spotbugs', 'missing'), findings: moduleRows.reduce((sum, row) => sum + (row.spotbugs.findings ?? 0), 0) + }, + modules: moduleRows + } +} + +export function requirementFailures(summary, options = {}) { + const failures = [] + for (const row of summary.modules) { + if (row.jacoco.status === 'invalid') failures.push(`${row.module}: invalid JaCoCo report (${row.jacoco.error})`) + if (row.spotbugs.status === 'invalid') failures.push(`${row.module}: invalid SpotBugs report (${row.spotbugs.error})`) + if (row.spotbugs.status === 'valid' && !row.spotbugs.html) failures.push(`${row.module}: SpotBugs HTML report is missing`) + if (options.requireJacoco && row.jacoco.status === 'missing') failures.push(`${row.module}: JaCoCo report is missing`) + if (options.requireSpotbugs && row.spotbugs.status === 'missing') failures.push(`${row.module}: SpotBugs report is missing`) + } + return failures +} + +function printSummary(summary) { + process.stdout.write(`Java quality summary: ${summary.topology.modules} modules, ${summary.topology.codeModules} code modules, ${summary.topology.services} services.\n`) + process.stdout.write(`OpenAPI: ${summary.openapi.documents} documents, ${summary.openapi.coveredServices.length}/${summary.topology.services} services; gaps: ${summary.openapi.codeFirstServices.join(', ')}.\n`) + process.stdout.write(`JaCoCo: ${summary.jacoco.present} valid, ${summary.jacoco.missing} missing, ${summary.jacoco.invalid} invalid.\n`) + process.stdout.write(`SpotBugs: ${summary.spotbugs.present} valid, ${summary.spotbugs.missing} missing, ${summary.spotbugs.invalid} invalid, ${summary.spotbugs.findings} findings.\n`) +} + +async function main() { + const args = process.argv.slice(2) + const rootIndex = args.indexOf('--root') + const outputIndex = args.indexOf('--output') + const root = rootIndex >= 0 ? path.resolve(args[rootIndex + 1]) : repositoryRoot + const summary = await buildQualitySummary(root) + printSummary(summary) + if (outputIndex >= 0) { + const output = path.resolve(args[outputIndex + 1]) + await mkdir(path.dirname(output), { recursive: true }) + await writeFile(output, `${JSON.stringify(summary, null, 2)}\n`, 'utf8') + } + const failures = requirementFailures(summary, { + requireJacoco: args.includes('--require-jacoco'), + requireSpotbugs: args.includes('--require-spotbugs') + }) + if (failures.length > 0) throw new Error(`Quality report contract failed:\n${failures.map((failure) => `- ${failure}`).join('\n')}`) +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/scripts/java-quality-summary.test.mjs b/scripts/java-quality-summary.test.mjs new file mode 100644 index 000000000..8e3831751 --- /dev/null +++ b/scripts/java-quality-summary.test.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { + buildQualitySummary, + parseJacocoReport, + parseSpotbugsReport, + requirementFailures, + repositoryRoot +} from './java-quality-summary.mjs' + +test('parses JaCoCo line coverage and SpotBugs findings without quality thresholds', () => { + assert.deepEqual(parseJacocoReport(''), { + missed: 3, covered: 7, percentage: 70 + }) + assert.deepEqual(parseSpotbugsReport(''), { + findings: 2, + byPriority: { 1: 1, 2: 1 }, + byCategory: { CORRECTNESS: 1, STYLE: 1 } + }) + assert.throws(() => parseJacocoReport(''), /not a JaCoCo report/) + assert.throws(() => parseSpotbugsReport(''), /not a SpotBugs report/) +}) + +test('aggregates code modules, report presence and the OpenAPI service matrix', async (t) => { + const root = await mkdtemp(path.join(tmpdir(), 'baiyue-quality-summary-')) + t.after(() => rm(root, { recursive: true, force: true })) + await writeFile(path.join(root, 'pom.xml'), 'bomalphabeta', 'utf8') + await mkdir(path.join(root, 'deploy'), { recursive: true }) + await writeFile(path.join(root, 'deploy', 'service-map.yml'), 'services:\n alpha:\n port: 1\n beta:\n port: 2\n', 'utf8') + await mkdir(path.join(root, 'alpha', 'src', 'main', 'java'), { recursive: true }) + await mkdir(path.join(root, 'beta', 'src', 'main', 'java'), { recursive: true }) + await writeFile(path.join(root, 'alpha', 'src', 'main', 'java', 'Alpha.java'), 'class Alpha {}', 'utf8') + await writeFile(path.join(root, 'beta', 'src', 'main', 'java', 'Beta.java'), 'class Beta {}', 'utf8') + await mkdir(path.join(root, 'alpha', 'src', 'main', 'resources', 'openapi'), { recursive: true }) + await writeFile(path.join(root, 'alpha', 'src', 'main', 'resources', 'openapi', 'alpha.yaml'), 'openapi: 3.0.3\n', 'utf8') + await mkdir(path.join(root, 'alpha', 'target', 'site', 'jacoco'), { recursive: true }) + await writeFile(path.join(root, 'alpha', 'target', 'site', 'jacoco', 'jacoco.xml'), '', 'utf8') + await mkdir(path.join(root, 'alpha', 'target', 'site'), { recursive: true }) + await writeFile(path.join(root, 'alpha', 'target', 'spotbugsXml.xml'), '', 'utf8') + await writeFile(path.join(root, 'alpha', 'target', 'site', 'spotbugs.html'), '', 'utf8') + + const summary = await buildQualitySummary(root) + assert.deepEqual(summary.topology, { modules: 3, codeModules: 2, services: 2 }) + assert.equal(summary.openapi.documents, 1) + assert.deepEqual(summary.openapi.coveredServices, ['alpha']) + assert.deepEqual(summary.openapi.codeFirstServices, ['beta']) + assert.deepEqual(summary.jacoco, { present: 1, invalid: 0, missing: 1 }) + assert.deepEqual(summary.spotbugs, { present: 1, invalid: 0, missing: 1, findings: 1 }) + assert.deepEqual(requirementFailures(summary), []) + assert.deepEqual(requirementFailures(summary, { requireJacoco: true, requireSpotbugs: true }), [ + 'beta: JaCoCo report is missing', 'beta: SpotBugs report is missing' + ]) +}) + +test('repository freezes the current topology and non-blocking aggregate summary wiring', async () => { + const summary = await buildQualitySummary(repositoryRoot) + assert.deepEqual(summary.topology, { modules: 23, codeModules: 22, services: 16 }) + assert.equal(summary.openapi.documents, 27) + assert.equal(summary.openapi.coveredServices.length, 8) + assert.equal(summary.openapi.codeFirstServices.length, 8) + + const gitlab = (await readFile(path.join(repositoryRoot, '.gitlab-ci.yml'), 'utf8')).replaceAll('\r\n', '\n') + const job = gitlab.match(/^质量摘要:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? '' + const packageJob = gitlab.match(/^打包服务:\n([\s\S]*)/m)?.[1] ?? '' + assert.match(job, /^ allow_failure: true$/m) + assert.match(job, /java-quality-summary\.test\.mjs/) + assert.match(job, /java-quality-summary\.mjs --output target\/java-quality-summary\.json --require-jacoco --require-spotbugs/) + assert.match(job, /^ - job: 校验与测试$/m) + assert.match(job, /^ - job: 静态分析报告$/m) + assert.match(job, /^ - target\/java-quality-summary\.json$/m) + assert.doesNotMatch(job, /threshold|ratchet/i) + assert.doesNotMatch(packageJob, /^ - 质量摘要$/m) +}) diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index 0e829bb6f..8cea72284 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -88,7 +88,8 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ feign('iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java', 'iam-service', 'auth-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '账号查询可重复,写操作按账号/命令业务键收敛'), feign('iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java', - 'iam-service', 'firstleg-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', '初始化/补偿使用 initializationKey'), + 'iam-service', 'firstleg-service', ['BACKGROUND'], 'WRITE', 'BUSINESS_KEY', + 'IAM 持久化任务租约投递;初始化/补偿复用稳定 initializationKey,不在客户创建请求内调用'), feign('iam/src/main/java/com/baiyue/iam/client/OverseasStorageFeeClient.java', 'iam-service', 'warehouse-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', '初始化/补偿使用 initializationKey'), feign('iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java', diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index a36533f8c..030af7e92 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -32,7 +32,6 @@ const expectedOnlineEdges = [ 'firstleg-service->print-service', 'firstleg-service->warehouse-service', 'iam-service->auth-service', - 'iam-service->firstleg-service', 'iam-service->warehouse-service', 'lastleg-service->file-service', 'lastleg-service->warehouse-overseas-service', @@ -57,6 +56,7 @@ const expectedBackgroundEdges = [ 'firstleg-service->finance-service', 'firstleg-service->integration-service', 'iam-service->event-bridge-service', + 'iam-service->firstleg-service', 'lastleg-service->event-bridge-service', 'order-service->firstleg-service' ] @@ -172,9 +172,10 @@ test('REQUEST, BACKGROUND and full graphs remain separate executable baselines', 'gateway-overseas->auth-service', 'gateway-overseas->overseas-security-service' ]) - assert.deepEqual(stronglyConnectedComponents(graphEdges('REQUEST')), [[ - 'auth-service', 'file-service', 'firstleg-service', 'iam-service', 'order-service' - ]]) + assert.deepEqual(stronglyConnectedComponents(graphEdges('REQUEST')), [ + ['auth-service', 'iam-service'], + ['firstleg-service', 'order-service'] + ]) assert.deepEqual(stronglyConnectedComponents(graphEdges('BACKGROUND')), [[ 'event-bridge-service', 'finance-service', 'firstleg-service', 'lastleg-service' ]]) @@ -196,34 +197,32 @@ test('known user entries expose every longest simple REQUEST path over the two-h return [entry.service, { hops: paths[0].length - 1, paths: paths.map(path => path.join('->')) }] })) assert.deepEqual(summary, { - 'auth-service': { hops: 5, paths: [ - 'auth-service->iam-service->firstleg-service->order-service->file-service->integration-service' + 'auth-service': { hops: 2, paths: [ + 'auth-service->iam-service->warehouse-service' ] }, - 'iam-service': { hops: 4, paths: [ - 'iam-service->firstleg-service->order-service->file-service->integration-service' + 'iam-service': { hops: 1, paths: [ + 'iam-service->auth-service', + 'iam-service->warehouse-service' ] }, 'order-service': { hops: 4, paths: [ - 'order-service->file-service->iam-service->firstleg-service->print-service', - 'order-service->file-service->iam-service->firstleg-service->warehouse-service', 'order-service->firstleg-service->file-service->iam-service->auth-service', - 'order-service->firstleg-service->file-service->iam-service->warehouse-service', - 'order-service->iam-service->firstleg-service->file-service->integration-service' + 'order-service->firstleg-service->file-service->iam-service->warehouse-service' ] }, 'firstleg-service': { hops: 4, paths: [ 'firstleg-service->order-service->file-service->iam-service->auth-service', 'firstleg-service->order-service->file-service->iam-service->warehouse-service' ] }, 'finance-service': { hops: 5, paths: [ - 'finance-service->file-service->iam-service->firstleg-service->order-service->warehouse-service', 'finance-service->firstleg-service->order-service->file-service->iam-service->auth-service', - 'finance-service->firstleg-service->order-service->file-service->iam-service->warehouse-service', - 'finance-service->iam-service->firstleg-service->order-service->file-service->integration-service' + 'finance-service->firstleg-service->order-service->file-service->iam-service->warehouse-service' ] }, - 'file-service': { hops: 4, paths: [ - 'file-service->iam-service->firstleg-service->order-service->warehouse-service' + 'file-service': { hops: 2, paths: [ + 'file-service->iam-service->auth-service', + 'file-service->iam-service->warehouse-service' ] }, - 'lastleg-service': { hops: 5, paths: [ - 'lastleg-service->file-service->iam-service->firstleg-service->order-service->warehouse-service' + 'lastleg-service': { hops: 3, paths: [ + 'lastleg-service->file-service->iam-service->auth-service', + 'lastleg-service->file-service->iam-service->warehouse-service' ] }, 'warehouse-service': { hops: 0, paths: ['warehouse-service'] }, 'warehouse-overseas-service': { hops: 0, paths: ['warehouse-overseas-service'] } diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md index c0357deda..c161ebf92 100644 --- a/warehouse-overseas-service/AGENTS.md +++ b/warehouse-overseas-service/AGENTS.md @@ -13,6 +13,8 @@ | 商户仓库查询 | `controller/MerchantWarehouseController.java` | | 平台/商户库位与托盘 | `controller/PlatformWarehouseResourceController.java`、`MerchantWarehouseResourceController.java` | | 内部资源锁定、释放、报废 | `controller/InternalWarehouseResourceLockController.java` | +| PDA 托盘状态流转与补偿 | `controller/InternalPdaPalletTransitionController.java`、`service/WarehousePalletTransitionService.java`、`mapper/WarehousePalletTransitionMapper.java`;内部前缀 `/internal/v1/pda-pallet-transitions` | +| 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` | | 仓库与资源服务 | `service/WarehouseApplicationService.java`、`WarehouseResourceService.java`、`WarehouseResourceLockService.java` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` | @@ -36,6 +38,7 @@ | 仓库地址 | `wh_warehouse_address` | 海外仓地址 | | 服务能力 | `wh_service_capability` | 海外仓服务能力 | | 托盘绑定 | `wh_pallet_business_binding` | 托盘业务绑定关系 | +| 托盘操作 | `wh_pallet_operation` | PDA 托盘物理状态流转、幂等与补偿记录 | - 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义。 - `SchemaChineseCommentContractTest` 自动扫描全部 `wh_` 表;新增字段必须同步更新基线 SQL、统一增量 SQL 和存量备注迁移。 @@ -43,4 +46,5 @@ ## 修改与验证 - 字段、注释和索引同步维护基线 SQL 与统一增量 SQL。 +- PDA 托盘流转必须校验内部服务令牌、仓库范围、当前物理状态和幂等键;跨服务业务失败只能通过显式补偿接口恢复,不得由 Lastleg 直接修改 `wh_` 资源表。 - 定向验证:`mvn -pl warehouse-overseas-service -am test`。 From fa92936406bac5b1f55442a6f71a415806592f3b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 15:16:41 +0800 Subject: [PATCH 05/42] fix(deploy): bind development gateways to loopback --- deploy/scripts/deploy-built-service.sh | 10 ++-------- scripts/test-environment-pipeline-contract.test.mjs | 9 ++++----- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index b8d703d8c..29707e71e 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -37,10 +37,7 @@ case "$DEPLOY_ENV" in esac if [ "$DEPLOY_ENV" = "development" ]; then - case "$SERVICE" in - gateway|gateway-overseas) PUBLISHED_BIND_ADDRESS=0.0.0.0 ;; - *) PUBLISHED_BIND_ADDRESS=127.0.0.1 ;; - esac + PUBLISHED_BIND_ADDRESS=127.0.0.1 else PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" fi @@ -433,10 +430,7 @@ export DEPLOY_ENV="$deploy_env" export IMAGE_REPOSITORY="$image_repository" export IMAGE_TAG="$image_tag" if [ "$deploy_env" = "development" ]; then - case "$service" in - gateway|gateway-overseas) export PUBLISHED_BIND_ADDRESS=0.0.0.0 ;; - *) export PUBLISHED_BIND_ADDRESS=127.0.0.1 ;; - esac + export PUBLISHED_BIND_ADDRESS=127.0.0.1 else export PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" fi diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index 22eaf133c..4d324af00 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -85,13 +85,12 @@ test('development and test deployment locks are acquired before target executors assert.ok(lockIndex < nodeIndex, 'deployment lock must be acquired before target node allocation') }) -test('development exposes gateways for remote acceptance and keeps internal services on loopback', () => { +test('development publishes every service on loopback without changing test defaults', () => { assert.match(serviceCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_PORT:-9000\}:9000/) assert.match(overseasCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_OVERSEAS_PORT:-9300\}:9300/) - assert.match(directDeployScript, /gateway\|gateway-overseas\) PUBLISHED_BIND_ADDRESS=0\.0\.0\.0/) - assert.match(directDeployScript, /\*\) PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) - assert.match(directDeployScript, /gateway\|gateway-overseas\) export PUBLISHED_BIND_ADDRESS=0\.0\.0\.0/) - assert.match(directDeployScript, /\*\) export PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) + assert.match(directDeployScript, /if \[ "\$DEPLOY_ENV" = "development" \]; then\s+PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) + assert.match(directDeployScript, /if \[ "\$deploy_env" = "development" \]; then\s+export PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) + assert.doesNotMatch(directDeployScript, /gateway\|gateway-overseas\) (?:export )?PUBLISHED_BIND_ADDRESS=0\.0\.0\.0/) }) test('parallel image publications isolate registry credentials per Jenkins job', () => { From 1f6f1a0677d6579fb9343db291d8149bccd5f870 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 15:27:26 +0800 Subject: [PATCH 06/42] test: complete phase4 PDA shipping prerequisites --- scripts/admin-test-env-acceptance.mjs | 70 ++++++++++++++++--- ...-phase4-producer-fixture-contract.test.mjs | 34 ++++++++- ...ase4-cross-region-events-contract.test.mjs | 9 ++- .../phase4-overseas-pda-contract.test.mjs | 12 ++++ .../phase4-overseas-pda.mjs | 66 ++++++++++++++--- ...verseas-acceptance-entry-contract.test.mjs | 8 ++- 6 files changed, 177 insertions(+), 22 deletions(-) diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 91fa0303d..49f40a477 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -987,11 +987,15 @@ export class AcceptanceRun { } } - async runPhase4ProducerVariant(label, destinationType, fulfillmentType = 'STANDARD') { + async runPhase4ProducerVariant(label, destinationType, fulfillmentType = 'STANDARD', pickupRequired = false) { const matrixMarker = this.marker const variantMarker = `${matrixMarker}-P4-${label}` const contextKeys = ['orderDestinationType', 'orderFulfillmentType', 'phase4ProducerMatrixMarker'] - const createdKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking'] + const createdKeys = [ + 'phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking', + 'phase3CustomsSupplier', 'phase3Customs', 'serviceCenterFlow', 'portSchedule', + 'phase3OutboundDispatch', 'phase3CarrierScheduleQuery', 'phase4CarrierPickupReady', + ] const contextSnapshot = new Map(contextKeys.map((key) => [key, { present: Object.hasOwn(this.context, key), value: this.context[key], }])) @@ -1009,6 +1013,45 @@ export class AcceptanceRun { await this.executeModule(`phase3-loading-phase4-${label}`, () => loading.run(this)) const finalize = await import(new URL('./admin-test-env-modules/phase3-loading-finalize.mjs', import.meta.url)) await this.executeModule(`phase3-loading-finalize-phase4-${label}`, () => finalize.run(this)) + const producerKey = this.createdResources.phase3LoadingCompletedProducer?.resourceKey + const writeShippingEvidence = (shippingReady, carrierScheduleQueryId = null) => { + const matrix = this.createdResources.phase4InboundProducerFixtures + const records = [matrix?.containers, matrix?.byDestinationType?.FBA, + matrix?.byDestinationType?.OVERSEAS].filter(Array.isArray) + for (const values of records) { + const target = values.find((item) => item?.resourceKey === producerKey) + if (target) Object.assign(target, { shippingReady, carrierScheduleQueryId }) + } + } + if (producerKey) writeShippingEvidence(false) + if (pickupRequired && !producerKey) { + this.assert('phase4-overseas-pda-fixture', `${label}产生者身份完整`, null, false, + 'phase3LoadingCompletedProducer.resourceKey=missing', + { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) + } + if (pickupRequired && producerKey) { + const customs = await import(new URL('./admin-test-env-modules/phase3-customs.mjs', import.meta.url)) + await this.executeModule(`phase3-customs-phase4-${label}`, () => customs.run(this)) + const serviceCenter = await import(new URL('./admin-test-env-modules/service-center-flow.mjs', import.meta.url)) + await this.executeModule(`service-center-flow-phase4-${label}`, () => serviceCenter.run(this)) + const portSchedules = await import(new URL('./admin-test-env-modules/phase3-port-schedules.mjs', import.meta.url)) + await this.executeModule(`phase3-port-schedules-phase4-${label}`, () => portSchedules.run(this)) + const outbound = await import(new URL('./admin-test-env-modules/phase3-outbound-dispatch.mjs', import.meta.url)) + await this.executeModule(`phase3-outbound-dispatch-phase4-${label}`, () => outbound.run(this)) + const carrierQueries = await import(new URL('./admin-test-env-modules/phase3-carrier-schedule-queries.mjs', import.meta.url)) + await this.executeModule(`phase3-carrier-schedule-queries-phase4-${label}`, () => carrierQueries.run(this)) + const carrierNodes = await import(new URL('./admin-test-env-modules/phase4-carrier-nodes.mjs', import.meta.url)) + await this.executeModule(`phase4-carrier-pickup-ready-phase4-${label}`, + () => carrierNodes.runUntilPickupReady(this)) + const pickup = this.createdResources.phase4CarrierPickupReady + const shippingReady = pickup?.marker === variantMarker && pickup?.ready === true + && Boolean(pickup?.queryId) + writeShippingEvidence(shippingReady, shippingReady ? String(pickup.queryId) : null) + this.assert('phase4-overseas-pda-fixture', `${label}通过真实出仓与船节点进入PICKUP_READY`, null, + shippingReady, + `resourceKey=${producerKey}, queryId=${pickup?.queryId || 'missing'}, ready=${pickup?.ready === true}`, + { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) + } } finally { if (restoreFixture) restoreFixture() this.marker = matrixMarker @@ -1026,21 +1069,32 @@ export class AcceptanceRun { async ensurePhase4PdaProducerMatrix() { const readiness = () => this.createdResources.phase4InboundProducerFixtures?.readiness || {} if (Number(readiness().dropshipContainerCount || 0) < 1) { - await this.runPhase4ProducerVariant('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP') + await this.runPhase4ProducerVariant('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true) } if (Number(readiness().fbaContainerCount || 0) < 2) { - await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA') + await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA', 'STANDARD', false) } if (Number(readiness().overseasContainerCount || 0) < 1) { - await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS') + await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS', 'STANDARD', true) } if (Number(readiness().distinctContainerCount || 0) < 4) { - await this.runPhase4ProducerVariant('SECONDARY', 'FBA') + await this.runPhase4ProducerVariant('SECONDARY', 'FBA', 'STANDARD', false) } const matrix = this.createdResources.phase4InboundProducerFixtures + const containers = Array.isArray(matrix?.containers) ? matrix.containers : [] + const dropshipReady = containers.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP') + const overseasReady = containers.find((item) => item?.destinationType === 'OVERSEAS') + const awaitingVariants = containers.filter((item) => ['FBA-STANDARD', 'SECONDARY'] + .some((label) => String(item?.resourceKey || '').includes(`-P4-${label}:`))) + const shippingMatrixReady = dropshipReady?.shippingReady === true + && Boolean(dropshipReady?.carrierScheduleQueryId) + && overseasReady?.shippingReady === true && Boolean(overseasReady?.carrierScheduleQueryId) + && awaitingVariants.length >= 1 + && awaitingVariants.every((item) => item.shippingReady === false + && item.carrierScheduleQueryId == null) this.assert('phase4-overseas-pda-fixture', '本轮三期跨区生产矩阵已就绪', null, - matrix?.marker === this.marker && matrix?.readiness?.readyForPhase4Pda === true, - `marker=${matrix?.marker || 'missing'}, FBA=${matrix?.readiness?.fbaContainerCount ?? 0}, OVERSEAS=${matrix?.readiness?.overseasContainerCount ?? 0}, DROPSHIP=${matrix?.readiness?.dropshipContainerCount ?? 0}, containers=${matrix?.readiness?.distinctContainerCount ?? 0}`, + matrix?.marker === this.marker && matrix?.readiness?.readyForPhase4Pda === true && shippingMatrixReady, + `marker=${matrix?.marker || 'missing'}, FBA=${matrix?.readiness?.fbaContainerCount ?? 0}, OVERSEAS=${matrix?.readiness?.overseasContainerCount ?? 0}, DROPSHIP=${matrix?.readiness?.dropshipContainerCount ?? 0}, containers=${matrix?.readiness?.distinctContainerCount ?? 0}, shippingReady=${containers.filter((item) => item?.shippingReady === true).length}, awaiting=${awaitingVariants.length}`, { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) } diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index 6574c4e2c..32699b741 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -81,7 +81,7 @@ test('measurement and loading pass destination identity into the LOADED producer assert.match(finalize, /resourceKey: `\$\{context\.marker\}:\$\{fixture\.destinationType \|\| 'UNKNOWN'\}:\$\{fixture\.containerNo \|\| containerBookingId\}`/) assert.match(finalize, /phase4InboundProducerFixtures = collectPhase4InboundProducerFixture/) const entry = read('../admin-test-env-acceptance.mjs') - assert.match(entry, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP'\)/) + assert.match(entry, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/) assert.match(entry, /orderFulfillmentType/) assert.match(entry, /fulfillmentType: fixtureOrder\.fulfillmentType \|\| 'STANDARD'/) assert.match(entry, /skuCodes: Array\.isArray\(fixtureOrder\.skuCodes\) \? fixtureOrder\.skuCodes : \[\]/) @@ -90,3 +90,35 @@ test('measurement and loading pass destination identity into the LOADED producer assert.match(pda, /find\(\(item\) => item\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'\)/) assert.match(pda, /skuFixtures\(context, producer\)/) }) + +test('only PDA-operated variants traverse real outbound and carrier nodes before overseas receiving', () => { + const entry = read('../admin-test-env-acceptance.mjs') + assert.match(entry, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'STANDARD', true\)/) + assert.match(entry, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', false\)/) + assert.match(entry, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/) + const start = entry.indexOf('if (pickupRequired && producerKey)') + const end = entry.indexOf('} finally {', start) + const pickupChain = entry.slice(start, end) + const ordered = [ + 'phase3-customs.mjs', 'service-center-flow.mjs', 'phase3-port-schedules.mjs', + 'phase3-outbound-dispatch.mjs', 'phase3-carrier-schedule-queries.mjs', + 'phase4-carrier-nodes.mjs', 'runUntilPickupReady(this)', + ] + let cursor = -1 + for (const token of ordered) { + const next = pickupChain.indexOf(token) + assert.ok(next > cursor, `${token} must follow the real prerequisite chain`) + cursor = next + } + for (const key of ['phase3Customs', 'serviceCenterFlow', 'portSchedule', 'phase3OutboundDispatch', + 'phase3CarrierScheduleQuery', 'phase4CarrierPickupReady']) assert.match(entry, new RegExp(`'${key}'`)) + assert.match(pickupChain, /shippingReady/) + assert.match(entry, /carrierScheduleQueryId/) + assert.match(pickupChain, /pickup\?\.marker === variantMarker/) + assert.doesNotMatch(pickupChain, /internal\/event-bridge|ll_container_shipping_gate|requestOverseas/) + assert.match(entry, /dropshipReady\?\.shippingReady === true/) + assert.match(entry, /overseasReady\?\.shippingReady === true/) + assert.match(entry, /awaitingVariants\.length >= 1/) + assert.doesNotMatch(entry, /awaitingVariants\.length === 2/) + assert.match(entry, /awaitingVariants\.every\(\(item\) => item\.shippingReady === false/) +}) diff --git a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs index 7405b5832..dc99c6d41 100644 --- a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs @@ -80,7 +80,14 @@ test('cross-region normalization keeps existing producers ahead of every phase-t test('phase-four producer variants restore the primary orchestration fixture after each matrix branch', async () => { const source = await import('node:fs/promises').then((fs) => fs.readFile( new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')) - assert.match(source, /const createdKeys = \['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking'\]/) + const start = source.indexOf('const createdKeys = [', source.indexOf('async runPhase4ProducerVariant')) + const end = source.indexOf(']', start) + const createdKeys = source.slice(start, end) + for (const key of [ + 'phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking', + 'phase3CustomsSupplier', 'phase3Customs', 'serviceCenterFlow', 'portSchedule', + 'phase3OutboundDispatch', 'phase3CarrierScheduleQuery', 'phase4CarrierPickupReady', + ]) assert.match(createdKeys, new RegExp(`'${key}'`)) }) test('contract covers dual audience, canonical 403, both event directions and diagnostics', () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 6b0e61040..ee95227c6 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -55,6 +55,18 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /phase4OverseasPda\.customerId = id\(order\.customerId\)/) assert.match(source, /现场库存查询只返回Token绑定仓且包含库存位置/) assert.match(source, /作业历史包含本轮柜号收货动作且分页稳定/) + assert.match(source, /awaitProducerConsumption\(context, allProducers\)/) + assert.match(source, /last-leg\/orders\?page=1&size=200&containerNo=/) + assert.match(source, /跨区消费生成尾程订单/) + assert.match(source, /awaitPickupReadyCandidates\(context, primary\.token, producers, allProducers\)/) + assert.match(source, /missingPickupReady=/) + assert.match(source, /item\?\.shippingReady === false/) + assert.match(source, /!expectedSet\.has\(id\(item\?\.containerNo\)\)/) + assert.match(source, /unexpectedAwaiting=/) + assert.match(source, /missingPickupReady\.length === 0 && unexpectedAwaiting\.length === 0/) + assert.doesNotMatch(source, /shippingReady == null|shippingReady !== true/) + assert.match(source, /unexpectedHistorical=/) + assert.doesNotMatch(source, /awaitProducerCandidates/) }) test('missing WAREHOUSE_PICKER overseas authority is a deployment blocker', async () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index e7e7040c1..b1f177e91 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -205,22 +205,63 @@ async function uploadPhotos(context, token) { return photos } -async function awaitProducerCandidates(context, token, producers) { +async function awaitProducerConsumption(context, producers) { const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))] + if (!expected.length) return true + const consumed = new Set() + const lastResponses = new Map() + for (let attempt = 1; attempt <= 30; attempt += 1) { + const missing = expected.filter((containerNo) => !consumed.has(containerNo)) + const results = await Promise.all(missing.map(async (containerNo) => { + const response = await context.requestOverseas('GET', + `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`) + return { containerNo, response } + })) + for (const { containerNo, response } of results) { + lastResponses.set(containerNo, response) + const rows = records(response?.body?.data) + if (success(response) && rows.some((item) => id(item?.containerNo) === containerNo)) { + consumed.add(containerNo) + } + } + if (expected.every((containerNo) => consumed.has(containerNo))) break + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + for (const containerNo of expected) { + const response = lastResponses.get(containerNo) || null + context.addStep(MODULE, `跨区消费生成尾程订单 ${containerNo}`, response, + consumed.has(containerNo), + `containerNo=${containerNo}, consumed=${consumed.has(containerNo)}; ${trace(response)}`, OPTIONS) + } + return expected.every((containerNo) => consumed.has(containerNo)) +} + +async function awaitPickupReadyCandidates(context, token, producers, allProducers) { + const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))] + const expectedSet = new Set(expected) + const awaiting = [...new Set(allProducers + .filter((item) => item?.shippingReady === false && !expectedSet.has(id(item?.containerNo))) + .map((item) => id(item?.containerNo)).filter(nonBlank))] + const known = new Set(allProducers.map((item) => id(item?.containerNo)).filter(nonBlank)) if (!expected.length) return false let response = null let seen = new Set() for (let attempt = 1; attempt <= 30; attempt += 1) { response = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, token) - const records = success(response) && Array.isArray(response.body?.data) ? response.body.data : [] - seen = new Set(records.map((item) => id(item?.containerNo)).filter(nonBlank)) - if (expected.every((containerNo) => seen.has(containerNo))) break + const candidateRows = success(response) && Array.isArray(response.body?.data) ? response.body.data : [] + seen = new Set(candidateRows.map((item) => id(item?.containerNo)).filter(nonBlank)) + if (expected.every((containerNo) => seen.has(containerNo)) + && awaiting.every((containerNo) => !seen.has(containerNo))) break await new Promise((resolve) => setTimeout(resolve, 1000)) } - const ready = expected.every((containerNo) => seen.has(containerNo)) - context.addStep(MODULE, '三期生产矩阵全部进入海外待收候选后再开始PDA', response, ready, - `expected=${expected.join('|')}, seen=${[...seen].join('|')}; ${trace(response)}`, OPTIONS) + const seenExpected = expected.filter((containerNo) => seen.has(containerNo)) + const missingPickupReady = expected.filter((containerNo) => !seen.has(containerNo)) + const unexpectedAwaiting = awaiting.filter((containerNo) => seen.has(containerNo)) + const unexpectedHistorical = [...seen].filter((containerNo) => !known.has(containerNo)) + const ready = missingPickupReady.length === 0 && unexpectedAwaiting.length === 0 + context.addStep(MODULE, '实际处理柜全部达到PICKUP_READY并进入PDA候选', response, ready, + `expected=${expected.join('|')}, awaiting=${awaiting.join('|') || 'none'}, seenExpected=${seenExpected.join('|') || 'none'}, missingPickupReady=${missingPickupReady.join('|') || 'none'}, unexpectedAwaiting=${unexpectedAwaiting.join('|') || 'none'}, unexpectedHistorical=${unexpectedHistorical.join('|') || 'none'}; ${trace(response)}`, OPTIONS) return ready } @@ -501,9 +542,14 @@ export async function run(context) { const producers = [dropship || matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]] .filter((item) => nonBlank(item?.containerNo)) const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers - if (allProducers.length && !(await awaitProducerCandidates(context, primary.token, allProducers))) { - blockerGap(context, '海外待收柜生产矩阵', - '三期已发布的FBA、OVERSEAS和第二柜尚未全部被海外事件消费者提交,禁止与消费事务并发入库') + if (allProducers.length && !(await awaitProducerConsumption(context, allProducers))) { + blockerGap(context, '跨区消费生产矩阵', + '三期已发布的四柜尚未全部由海外消费者生成尾程订单,禁止把PICKUP_READY缺口与事件消费混为一谈') + return + } + if (producers.length && !(await awaitPickupReadyCandidates(context, primary.token, producers, allProducers))) { + blockerGap(context, '实际处理柜PICKUP_READY门禁', + 'Dropship/FBA与OVERSEAS实际处理柜尚未全部达到PICKUP_READY;跨区消费已独立验证,不回退处理历史柜') return } for (const [index, producer] of (producers.length ? producers : [null]).entries()) { diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index 05baae375..6bfe64c05 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -80,8 +80,12 @@ test('PDA entry expands the real phase-three producer chain and builds an FBA/OV assert.ok(modules.indexOf(prerequisite) >= 0 && modules.indexOf(prerequisite) < modules.indexOf('phase4-overseas-pda')) } assert.match(source, /async ensurePhase4PdaProducerMatrix\(\)/) - assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS'\)/) - assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA'\)/) + assert.match(source, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/) + assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'STANDARD', true\)/) + assert.match(source, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', false\)/) + assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/) + assert.match(source, /runUntilPickupReady\(this\)/) + assert.match(source, /carrierScheduleQueryId/) assert.match(source, /readyForPhase4Pda === true/) assert.equal(modules.indexOf('phase4-overseas-pda-operations'), modules.indexOf('phase4-overseas-pda') + 1) }) From 87c5e18ca6179791597a910302c852ff3b3c511c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 16:40:48 +0800 Subject: [PATCH 07/42] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitlab-ci.yml | 1 - AGENTS.md | 4 +- Jenkinsfile | 4 +- Jenkinsfile.service | 3 +- README.md | 2 +- config/nacos/cn/iam-service.yml | 3 + .../nacos/overseas/event-bridge-service.yml | 2 +- config/nacos/overseas/gateway-overseas.yml | 4 +- .../overseas/overseas-security-service.yml | 7 -- deploy/docker-compose.overseas.yml | 19 +--- deploy/docker-compose.yml | 1 + deploy/scripts/deploy-built-service.sh | 13 +-- deploy/scripts/deploy-services.sh | 15 +-- deploy/service-map.yml | 5 - deploy/sql/README.md | 3 +- deploy/sql/海外区域统一增量-MySQL8.sql | 50 ++++++---- docs/头程项目目录结构与服务说明.md | 8 +- event-bridge-service/AGENTS.md | 2 +- .../metrics/EventBridgeMetrics.java | 2 + .../HttpSecurityProjectionEventClient.java | 12 ++- .../src/main/resources/application.yml | 2 +- .../metrics/EventBridgeMetricsTest.java | 20 ++++ ...HttpSecurityProjectionEventClientTest.java | 44 +++++++++ .../OverseasSecurityProjectionProperties.java | 2 +- .../HttpOverseasSecurityValidationClient.java | 8 +- .../OverseasSecurityProjectionFilter.java | 5 +- .../OverseasSecurityValidationClient.java | 2 +- .../src/main/resources/application.yml | 4 +- ...urityValidationDeploymentContractTest.java | 42 ++++++++ .../OverseasSecurityProjectionFilterTest.java | 8 +- iam/AGENTS.md | 3 +- iam/SECURITY-EVENT-AUDIT.md | 4 +- .../iam/client/SecurityEventBridgeClient.java | 3 +- .../com/baiyue/iam/service/MemberService.java | 2 + .../service/MerchantOnboardingService.java | 4 + .../service/PlatformInternalUserService.java | 8 ++ .../PlatformUserManagementService.java | 2 + .../iam/service/SecurityEventOutboxRelay.java | 4 + .../service/SecurityEventOutboxService.java | 3 +- ...enantAdministratorProvisioningService.java | 11 +++ .../com/baiyue/iam/service/TenantService.java | 2 + iam/src/main/resources/application.yml | 3 + ...SecurityEventBridgeClientContractTest.java | 35 +++++++ .../baiyue/iam/service/MemberServiceTest.java | 22 +++++ .../MerchantOnboardingServiceTest.java | 6 ++ .../PlatformInternalUserServiceTest.java | 33 +++++++ .../PlatformUserManagementServiceTest.java | 26 +++++ .../service/SecurityEventOutboxRelayTest.java | 91 ++++++++++++++++++ .../SecurityEventOutboxServiceTest.java | 28 ++++++ ...tAdministratorProvisioningServiceTest.java | 11 +++ .../baiyue/iam/service/TenantServiceTest.java | 3 + lastleg-service/AGENTS.md | 6 ++ lastleg-service/pom.xml | 1 + .../scripts/analyze-lastleg-dependencies.mjs | 1 + .../analyze-lastleg-dependencies.test.mjs | 22 +++++ .../controller/LastlegSecurityController.java | 27 +++--- .../security/dto/LastlegSecurityDtos.java | 13 ++- .../LastlegSecurityProjectionRedisMirror.java | 8 +- .../LastlegSecurityProjectionService.java | 95 +++++++++++++------ .../LastlegSecurityValidationService.java | 60 ++++++++++++ .../SecurityProjectionMonotonicity.java | 6 +- .../src/main/resources/application.yml | 2 +- .../src/main/resources/db/lastleg.sql | 39 ++++++++ .../InternalServiceTokenGuardTest.java | 6 ++ .../security/LastlegSecurityContractTest.java | 75 +++++++++++++++ .../SecurityProjectionArchitectureTest.java | 49 ++++++++++ ...astlegSecurityProjectionTransportTest.java | 54 +++++++++++ .../LastlegSecurityValidationServiceTest.java | 67 +++++++++++++ .../SecurityProjectionMonotonicityTest.java | 28 ++++++ .../lastleg-slice-dependencies.json | 13 ++- overseas-security-service/AGENTS.md | 14 --- overseas-security-service/pom.xml | 22 ----- .../OverseasSecurityApplication.java | 11 --- .../security/InternalServiceTokenGuard.java | 26 ----- .../OverseasSecurityValidationService.java | 44 --------- .../src/main/resources/application.yml | 28 ------ .../main/resources/db/overseas-security.sql | 31 ------ .../internal-overseas-security-v1.yaml | 26 ----- .../OverseasSecurityContractTest.java | 51 ---------- ...OverseasSecurityValidationServiceTest.java | 36 ------- .../service/ProjectionMonotonicityTest.java | 28 ------ pom.xml | 1 - scripts/admin-test-env-acceptance.mjs | 49 +++++++++- .../customer-system-contract.test.mjs | 10 ++ scripts/agents-index-contract.ps1 | 28 +++--- scripts/java-quality-summary.test.mjs | 6 +- scripts/java-topology-contract.test.mjs | 8 +- scripts/overseas-deployment-contract.test.mjs | 27 ++++-- ...verseas-acceptance-entry-contract.test.mjs | 39 +++++++- .../provision-development-service-jobs.groovy | 2 +- scripts/synchronous-dependency-baseline.mjs | 4 +- .../synchronous-dependency-contract.test.mjs | 15 +-- ...est-environment-pipeline-contract.test.mjs | 2 + 93 files changed, 1154 insertions(+), 527 deletions(-) delete mode 100644 config/nacos/overseas/overseas-security-service.yml create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java create mode 100644 gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java rename overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java => lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java (60%) rename overseas-security-service/src/main/java/com/baiyue/overseassecurity/dto/OverseasSecurityDtos.java => lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java (74%) rename overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java => lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java (82%) rename overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java => lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java (66%) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java rename overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/ProjectionMonotonicity.java => lastleg-service/src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java (82%) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicityTest.java delete mode 100644 overseas-security-service/AGENTS.md delete mode 100644 overseas-security-service/pom.xml delete mode 100644 overseas-security-service/src/main/java/com/baiyue/overseassecurity/OverseasSecurityApplication.java delete mode 100644 overseas-security-service/src/main/java/com/baiyue/overseassecurity/security/InternalServiceTokenGuard.java delete mode 100644 overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java delete mode 100644 overseas-security-service/src/main/resources/application.yml delete mode 100644 overseas-security-service/src/main/resources/db/overseas-security.sql delete mode 100644 overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml delete mode 100644 overseas-security-service/src/test/java/com/baiyue/overseassecurity/OverseasSecurityContractTest.java delete mode 100644 overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationServiceTest.java delete mode 100644 overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/ProjectionMonotonicityTest.java diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5d86bd1dc..3fd5c31fe 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -97,7 +97,6 @@ OpenAPI有效性: - finance/target/*.jar - warehouse/target/*.jar - warehouse-overseas-service/target/*.jar - - overseas-security-service/target/*.jar - lastleg-service/target/*.jar - file/target/*.jar - print/target/*.jar diff --git a/AGENTS.md b/AGENTS.md index cf6ed7318..8fd132bd1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ | 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` | | 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | | 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | -| 海外 IAM 状态与权限版本投影 | `overseas-security-service/` | `overseas-security-service/AGENTS.md`;内部入口 `/internal/overseas-security/v1/events`、`/validate`,契约 `src/main/resources/openapi/internal-overseas-security-v1.yaml`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验 | +| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` | | 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | @@ -83,7 +83,7 @@ - 数据库只访问所属服务库。模块表结构位于 `/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。 - 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - Java 签名变更的最低编译门禁是 `mvn -pl -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 -- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、海外 Gateway 覆盖、海外安全服务部署映射、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 +- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、海外 Gateway 覆盖、Lastleg 海外安全投影映射、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 - 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。 ## 全量 HTTP 验收脚本路由 diff --git a/Jenkinsfile b/Jenkinsfile index be2593cce..a06ba1017 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,6 +1,6 @@ def serviceModules = [ 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' ] def serviceCheckboxes = [ @@ -17,7 +17,6 @@ def serviceCheckboxes = [ 'integration-service': 'SERVICE_INTEGRATION', 'lastleg-service': 'SERVICE_LASTLEG', 'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS', - 'overseas-security-service': 'SERVICE_OVERSEAS_SECURITY', 'event-bridge-service': 'SERVICE_EVENT_BRIDGE', 'audit-service': 'SERVICE_AUDIT' ] @@ -69,7 +68,6 @@ pipeline { booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)') booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)') booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)') - booleanParam(name: 'SERVICE_OVERSEAS_SECURITY', defaultValue: false, description: '海外IAM安全投影服务(overseas-security-service)') booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;默认同时部署国内和海外实例)') choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS'], description: 'BOTH 同时部署国内和海外实例;CN/OVERSEAS 用于单区故障重跑') booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)') diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 88c8bac39..bd81515d2 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -1,6 +1,6 @@ def serviceModules = [ 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' ] def serviceJars = [ @@ -17,7 +17,6 @@ def serviceJars = [ 'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar', 'lastleg-service': 'lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar', 'warehouse-overseas-service': 'warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar', - 'overseas-security-service': 'overseas-security-service/target/overseas-security-service-1.0.0-SNAPSHOT.jar', 'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar', 'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar' ] diff --git a/README.md b/README.md index 604e465ae..0f2913388 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - `firstleg`:头程揽收、入仓、装载、发运和交接。 - `warehouse`:国内仓储能力。 - `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。 -- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`、`overseas-security-service`:海外公网入口、海外尾程与入仓业务、海外仓储资源及 IAM 安全投影。 +- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`:海外公网入口、海外尾程/入仓与 IAM 安全投影、海外仓储资源。 - `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。 - `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。 - `platform-bom`:统一管理仓库内公共依赖版本。 diff --git a/config/nacos/cn/iam-service.yml b/config/nacos/cn/iam-service.yml index 32dcc05c7..48a0d8795 100644 --- a/config/nacos/cn/iam-service.yml +++ b/config/nacos/cn/iam-service.yml @@ -5,7 +5,10 @@ spring: password: ${IAM_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver baiyue: + security-event: + event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} mq-consumer-enabled: ${NOTIFICATION_MQ_CONSUMER_ENABLED:false} + diff --git a/config/nacos/overseas/event-bridge-service.yml b/config/nacos/overseas/event-bridge-service.yml index bbc198d5c..00a8ac755 100644 --- a/config/nacos/overseas/event-bridge-service.yml +++ b/config/nacos/overseas/event-bridge-service.yml @@ -10,7 +10,7 @@ baiyue: data-residency: ${DATA_RESIDENCY:OVERSEAS} event-dispatcher: consumer-url: ${EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events} - security-consumer-url: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:http://overseas-security-service:9307/internal/overseas-security/v1/events} + security-consumer-url: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/security/v1/events} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} diff --git a/config/nacos/overseas/gateway-overseas.yml b/config/nacos/overseas/gateway-overseas.yml index 11e2deb34..a9c1eadf2 100644 --- a/config/nacos/overseas/gateway-overseas.yml +++ b/config/nacos/overseas/gateway-overseas.yml @@ -4,6 +4,6 @@ springdoc: baiyue: security: overseas-projection: - service-url: ${OVERSEAS_SECURITY_SERVICE_URL:http://overseas-security-service:9307} + service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303} internal-service-token: ${INTERNAL_SERVICE_TOKEN} - timeout: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:2s} + timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s} diff --git a/config/nacos/overseas/overseas-security-service.yml b/config/nacos/overseas/overseas-security-service.yml deleted file mode 100644 index 80565c445..000000000 --- a/config/nacos/overseas/overseas-security-service.yml +++ /dev/null @@ -1,7 +0,0 @@ -springdoc: - api-docs.enabled: ${SWAGGER_ENABLED:false} - swagger-ui.enabled: ${SWAGGER_ENABLED:false} -baiyue: - security: - internal-service-token: ${INTERNAL_SERVICE_TOKEN} - permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index f41f67f15..aae9708e7 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -47,8 +47,8 @@ services: CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false} AUTH_SESSION_VALIDATION_URL: ${AUTH_SESSION_VALIDATION_URL} PDA_SESSION_VALIDATION_TIMEOUT: ${PDA_SESSION_VALIDATION_TIMEOUT:-2s} - OVERSEAS_SECURITY_SERVICE_URL: ${OVERSEAS_SECURITY_SERVICE_URL:-http://overseas-security-service:9307} - OVERSEAS_SECURITY_VALIDATION_TIMEOUT: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:-2s} + LASTLEG_SECURITY_SERVICE_URL: ${LASTLEG_SECURITY_SERVICE_URL:-http://lastleg-service:9303} + LASTLEG_SECURITY_VALIDATION_TIMEOUT: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:-2s} lastleg-service: <<: *overseas-common @@ -94,25 +94,12 @@ services: EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_OVERSEAS_DB_USERNAME} EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_OVERSEAS_DB_PASSWORD} EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/events} - OVERSEAS_SECURITY_EVENT_CONSUMER_URL: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:-http://overseas-security-service:9307/internal/overseas-security/v1/events} + LASTLEG_SECURITY_EVENT_CONSUMER_URL: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/security/v1/events} networks: firstleg-overseas-network: infra-network: aliases: [event-bridge-overseas] - overseas-security-service: - <<: *overseas-common - image: ${IMAGE_REPOSITORY}/overseas-security-service:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-overseas-security - ports: ["${PUBLISHED_BIND_ADDRESS:-127.0.0.1}:${OVERSEAS_SECURITY_PORT:-9307}:9307"] - volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/overseas-security-service:/app/logs"] - environment: - <<: *overseas-environment - SERVER_PORT: 9307 - OVERSEAS_SECURITY_DB_URL: ${OVERSEAS_SECURITY_DB_URL} - OVERSEAS_SECURITY_DB_USERNAME: ${OVERSEAS_SECURITY_DB_USERNAME} - OVERSEAS_SECURITY_DB_PASSWORD: ${OVERSEAS_SECURITY_DB_PASSWORD} - networks: firstleg-overseas-network: name: firstleg-${DEPLOY_ENV:-test}-overseas-network diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 4f6700005..8e61b9ec7 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -79,6 +79,7 @@ services: IAM_DB_URL: ${IAM_DB_URL} IAM_DB_USERNAME: ${IAM_DB_USERNAME} IAM_DB_PASSWORD: ${IAM_DB_PASSWORD} + SECURITY_EVENT_BRIDGE_URL: ${SECURITY_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312} firstleg: <<: *common diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index 29707e71e..d4ad1ad7d 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -64,7 +64,7 @@ env_file_value() { } case "$SERVICE" in - gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) + gateway-overseas|lastleg-service|warehouse-overseas-service) COMPOSE_FILE="deploy/docker-compose.overseas.yml" PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas" ;; @@ -98,15 +98,6 @@ if [ "$SERVICE" = "gateway-overseas" ]; then fi fi -if [ "$SERVICE" = "overseas-security-service" ]; then - for key in OVERSEAS_SECURITY_DB_URL OVERSEAS_SECURITY_DB_USERNAME OVERSEAS_SECURITY_DB_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 overseas-security-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done -fi - for file in "$ENV_FILE" "$COMPOSE_FILE"; do if [ ! -f "$file" ]; then echo "必需文件不存在: $file" >&2 @@ -124,7 +115,7 @@ if [ "$SERVICE" = "finance" ]; then fi case "$SERVICE" in - gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) + gateway-overseas|lastleg-service|warehouse-overseas-service) for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then echo "部署海外服务时,环境文件必须包含非空 $key" >&2 diff --git a/deploy/scripts/deploy-services.sh b/deploy/scripts/deploy-services.sh index abdbafef5..7c9983f82 100644 --- a/deploy/scripts/deploy-services.sh +++ b/deploy/scripts/deploy-services.sh @@ -39,7 +39,7 @@ env_file_value() { DEPLOY_REGION=CN for service in $SERVICES; do case "$service" in - gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) DEPLOY_REGION=OVERSEAS ;; + gateway-overseas|lastleg-service|warehouse-overseas-service) DEPLOY_REGION=OVERSEAS ;; event-bridge-service) BRIDGE_REGION="${EVENT_BRIDGE_REGION_OVERRIDE:-}" if [ -z "$BRIDGE_REGION" ] && env_file_has_key REGION_CODE; then @@ -60,7 +60,7 @@ done if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then for service in $SERVICES; do case "$service" in - gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service|event-bridge-service) ;; + gateway-overseas|lastleg-service|warehouse-overseas-service|event-bridge-service) ;; *) echo "一次部署不得混合国内与海外服务: $SERVICE_CSV" >&2; exit 2 ;; esac done @@ -80,17 +80,6 @@ if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then done fi -case " $SERVICES " in - *" overseas-security-service "*) - for key in OVERSEAS_SECURITY_DB_URL OVERSEAS_SECURITY_DB_USERNAME OVERSEAS_SECURITY_DB_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 overseas-security-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done - ;; -esac - case " $SERVICES " in *" gateway-overseas "*) if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then diff --git a/deploy/service-map.yml b/deploy/service-map.yml index e78b8a6e5..e22eec804 100644 --- a/deploy/service-map.yml +++ b/deploy/service-map.yml @@ -8,11 +8,6 @@ services: jar: gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar port: 9300 regions: [OVERSEAS] - overseas-security-service: - artifactId: overseas-security-service - jar: overseas-security-service/target/overseas-security-service-1.0.0-SNAPSHOT.jar - port: 9307 - regions: [OVERSEAS] auth: artifactId: auth-service jar: auth/target/auth-service-1.0.0-SNAPSHOT.jar diff --git a/deploy/sql/README.md b/deploy/sql/README.md index dc681dba9..f857cf41f 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -3,7 +3,8 @@ ## 唯一数据库初始化与发布入口 - 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 -- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`、`warehouse-overseas-service/src/main/resources/db/warehouse.sql` 与 `overseas-security-service/src/main/resources/db/overseas-security.sql` 基线,再执行该入口。固定入口升级 `baiyue_lastleg`、`baiyue_lastlag_warehouse` 与 `baiyue_overseas_security`,后者保存 IAM 安全事件消费者 Inbox 和可恢复投影;Redis 只作可重建镜像。 +- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`与 `warehouse-overseas-service/src/main/resources/db/warehouse.sql` 基线,再执行该入口。固定入口仅升级 `baiyue_lastleg`与 `baiyue_lastlag_warehouse`;IAM 安全事件 Inbox 和可恢复投影已并入 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。 +- Lastleg 基线和海外固定增量仅确定性初始化国内固定 `platform_admin`(`authUserId=1000001`、`tenantId=0`)的 USER/AUTHORIZATION version=0 安全投影;其他既有开发账号不建立兼容表或长期补投入口。 - 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。 - 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 41f10d449..882d2a042 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -920,33 +920,47 @@ DELIMITER ; CALL assert_phase4_pda_operations_ready(); DROP PROCEDURE assert_phase4_pda_operations_ready; -CREATE DATABASE IF NOT EXISTS baiyue_overseas_security DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; -USE baiyue_overseas_security; -CREATE TABLE IF NOT EXISTS overseas_security_event_inbox ( - event_id VARCHAR(64) NOT NULL, event_type VARCHAR(96) NOT NULL, payload_hash CHAR(64) NOT NULL, - payload_json JSON NOT NULL, status VARCHAR(16) NOT NULL, result VARCHAR(32) NULL, - received_at DATETIME(3) NOT NULL, processed_at DATETIME(3) NULL, +USE baiyue_lastleg; +CREATE TABLE IF NOT EXISTS ll_security_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '跨区安全事件ID', event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型', + payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256哈希', payload_json JSON NOT NULL COMMENT '冻结事件载荷JSON', + status VARCHAR(16) NOT NULL COMMENT '消费状态:RECEIVED或PROCESSED', + result VARCHAR(32) NULL COMMENT '处理结果:APPLIED、STALE、DUPLICATE或DUPLICATE_VERSION', + received_at DATETIME(3) NOT NULL COMMENT '接收时间', processed_at DATETIME(3) NULL COMMENT '处理完成时间', PRIMARY KEY (event_id), KEY idx_security_inbox_type_time (event_type,received_at) -) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者Inbox'; -CREATE TABLE IF NOT EXISTS overseas_security_projection ( - aggregate_type VARCHAR(32) NOT NULL, aggregate_id VARCHAR(64) NOT NULL, tenant_id BIGINT NOT NULL, - security_status VARCHAR(24) NULL, permission_version BIGINT NULL, aggregate_version BIGINT NOT NULL, - payload_hash CHAR(64) NOT NULL, last_event_id VARCHAR(64) NOT NULL, - changed_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL, +) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者收件箱'; +CREATE TABLE IF NOT EXISTS ll_security_projection ( + aggregate_type VARCHAR(32) NOT NULL COMMENT '聚合类型:USER、TENANT或AUTHORIZATION', + aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID', tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID', + security_status VARCHAR(24) NULL COMMENT '当前安全状态:ACTIVE或DISABLED', + permission_version BIGINT NULL COMMENT '当前单调权限版本', + aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本', payload_hash CHAR(64) NOT NULL COMMENT '当前版本载荷哈希', + last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用的事件ID', + changed_at DATETIME(3) NOT NULL COMMENT '国内IAM事实发生时间', updated_at DATETIME(3) NOT NULL COMMENT '投影更新时间', PRIMARY KEY (aggregate_type,aggregate_id), KEY idx_security_projection_tenant (tenant_id), KEY idx_security_projection_permission (aggregate_id,permission_version) ) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; -DROP PROCEDURE IF EXISTS assert_overseas_security_projection_ready; +-- 与国内固定 auth_account.id=1000001、tenant_id=0 的 platform_admin 一致;真实事件从版本1覆盖。 +INSERT IGNORE INTO ll_security_projection ( + aggregate_type,aggregate_id,tenant_id,security_status,permission_version,aggregate_version, + payload_hash,last_event_id,changed_at,updated_at +) VALUES + ('USER','1000001',0,'ACTIVE',0,0,SHA2('lastleg-baseline:platform_admin:USER:0',256), + 'baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'), + ('AUTHORIZATION','1000001',0,NULL,0,0,SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0',256), + 'baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); + +DROP PROCEDURE IF EXISTS assert_lastleg_security_projection_ready; DELIMITER $$ -CREATE PROCEDURE assert_overseas_security_projection_ready() +CREATE PROCEDURE assert_lastleg_security_projection_ready() BEGIN - IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_overseas_security' - AND table_name IN ('overseas_security_event_inbox','overseas_security_projection')) <> 2 THEN + IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_lastleg' + AND table_name IN ('ll_security_event_inbox','ll_security_projection')) <> 2 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外IAM安全Inbox或投影表未完整部署'; END IF; END$$ DELIMITER ; -CALL assert_overseas_security_projection_ready(); -DROP PROCEDURE assert_overseas_security_projection_ready; +CALL assert_lastleg_security_projection_ready(); +DROP PROCEDURE assert_lastleg_security_projection_ready; SELECT 'PASS' AS migration_status, '海外仓业务、海外仓资源与IAM安全投影结构已就绪' AS message; diff --git a/docs/头程项目目录结构与服务说明.md b/docs/头程项目目录结构与服务说明.md index 2ed49423a..f16f95e37 100644 --- a/docs/头程项目目录结构与服务说明.md +++ b/docs/头程项目目录结构与服务说明.md @@ -10,7 +10,7 @@ ## 1. 项目定位 -`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 23 个 Maven 模块,其中 7 个是公共/依赖模块,16 个是可独立启动、构建、制作镜像的微服务。 +`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 22 个 Maven 模块,其中 7 个是公共/依赖模块,15 个是可独立启动、构建、制作镜像的微服务。 当前技术基线: @@ -48,7 +48,6 @@ baiyue-firstleg/ ├─ finance/ # 财务账期、账单、收付款与核销 ├─ lastleg-service/ # 海外尾程、入仓、库存与本土业务 ├─ warehouse-overseas-service/ # 海外仓库、库位、托盘与资源锁 -├─ overseas-security-service/ # 海外IAM安全事件Inbox、投影与实时校验 ├─ file/ # 资质、导出与测量图片文件中心 ├─ print/ # 打印中心 ├─ audit-service/ # 审计日志中心 @@ -100,9 +99,8 @@ baiyue-firstleg/ | `file` | `file-service` | 9206 | `baiyue_file` | 企业资质文件、公共异步导出下载、入库测量图片及重量 OCR 调用协作 | | `print` | `print-service` | 9207 | 无 | 无状态 PDF 渲染服务;调用方传入完整标签数据,服务在内存中生成一次性多页 PDF | | `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 | -| `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存与海外本土业务;不含海外出库和派送 | +| `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存、海外本土业务及 IAM 安全事件投影/实时校验;不含海外出库和派送 | | `warehouse-overseas-service` | `warehouse-overseas-service` | 9305 | 海外仓资源库 | 海外仓库、库位、托盘和资源锁 | -| `overseas-security-service` | `overseas-security-service` | 9307 | `baiyue_overseas_security` | IAM安全事件可靠Inbox、单调投影及海外Token即时失效校验 | | `event-bridge-service` | `event-bridge-service` | 9312 | `baiyue_event_bridge` | 按 CN/OVERSEAS 双实例部署的跨区事件 Inbox、重试和目标服务投递 | | `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;平台端可按权限查询已脱敏日志,业务审计发送默认开启 | @@ -283,6 +281,6 @@ HTTP 401 / `A0401`,不得再按 HTTP 200 + 空权限处理;只有使用新 - 平台端和商户端权限分别管理,接口与按钮权限都必须由后端校验。 - Controller 负责协议转换,Service 负责业务编排,Repository/Mapper 负责持久化。 - 服务之间只传 DTO 或事件契约,不能依赖其他服务的 Entity、Mapper。 -- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 16 个服务执行受影响构建。 +- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 15 个服务执行受影响构建。 - 单一 Git 仓库不等于单一部署单元;每个服务仍生成独立 JAR、镜像和容器。 - 国内头程与海外仓共享 Git 仓库,但保持两套数据库、Nacos 命名空间和部署环境。 diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index 14ed05d26..4b88bd092 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -12,7 +12,7 @@ |---|---| | 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` | | Inbox 接收与幂等 | `service/EventInboxService.java` | -| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 回投 Firstleg;海外业务事件投 Lastleg,三类 IAM 安全事件按 eventType 投 `overseas-security-service`,传输层不解析 payload | +| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 回投 Firstleg;海外业务事件投 Lastleg 通用入口,三类 IAM 安全事件按 eventType 投 Lastleg `/internal/lastleg/security/v1/events`,传输层不解析 payload | | 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` | | 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` | | 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` | diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java index 50a3bbe66..85756ae0e 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java @@ -6,6 +6,7 @@ import com.baiyue.eventbridge.domain.BridgeInbox; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.time.Clock; @@ -41,6 +42,7 @@ public class EventBridgeMetrics { private final MeterRegistry registry; private final Clock clock; + @Autowired public EventBridgeMetrics(MeterRegistry registry) { this(registry, Clock.systemDefaultZone()); } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java index e18cf8714..6c4b7666e 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java @@ -47,10 +47,14 @@ public class HttpSecurityProjectionEventClient implements SecurityProjectionEven JsonNode body; try { body = objectMapper.readTree(response.body()); } catch (Exception exception) { body = null; } - if (response.statusCode() / 100 != 2 || body == null || !"200".equals(body.path("code").asText()) - || !row.getEventId().equals(body.path("data").path("eventId").asText())) { - throw new IllegalStateException("海外安全投影消费失败: HTTP " + response.statusCode()); - } + requireAcknowledgement(response.statusCode(), body, row.getEventId()); return String.valueOf(response.statusCode()); } + + static void requireAcknowledgement(int statusCode, JsonNode body, String eventId) { + if (statusCode / 100 != 2 || body == null || !"200".equals(body.path("code").asText()) + || !eventId.equals(body.path("data").path("eventId").asText())) { + throw new IllegalStateException("Lastleg 安全投影消费失败: HTTP " + statusCode); + } + } } diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml index fc5524546..b20743a41 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -33,7 +33,7 @@ baiyue: event-dispatcher: consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}} firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9202/internal/firstleg/v1/container-events} - security-consumer-url: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9307/internal/overseas-security/v1/events} + security-consumer-url: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/security/v1/events} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java index 39a229241..636db964f 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java @@ -8,12 +8,32 @@ import org.junit.jupiter.api.Test; import java.lang.reflect.Modifier; import java.util.Arrays; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.Mockito.mock; class EventBridgeMetricsTest { + @Test + void productionConstructorIsExplicitForSpringWhenTestClockConstructorAlsoExists() throws Exception { + assertThat(EventBridgeMetrics.class.getConstructor(MeterRegistry.class) + .isAnnotationPresent(Autowired.class)).isTrue(); + } + + @Test + void springContextCreatesMetricsBeanWithTheAnnotatedProductionConstructor() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.registerBean(MeterRegistry.class, SimpleMeterRegistry::new); + context.register(EventBridgeMetrics.class); + context.refresh(); + + assertThat(context.getBean(EventBridgeMetrics.class)).isNotNull(); + } + } + @Test void collapsesUnboundedRegionAndEventTypeLabelsToOther() { SimpleMeterRegistry registry = new SimpleMeterRegistry(); diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java new file mode 100644 index 000000000..9a31b6db0 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java @@ -0,0 +1,44 @@ +package com.baiyue.eventbridge.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class HttpSecurityProjectionEventClientTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void onlyMatchingLastlegAcknowledgementCompletesBridgeDelivery() throws Exception { + var acknowledged = objectMapper.readTree(""" + {"code":"200","data":{"eventId":"event-1","result":"APPLIED"}} + """); + var businessFailure = objectMapper.readTree(""" + {"code":"A0409","message":"payloadHash 冲突","data":{"eventId":"event-1"}} + """); + var wrongEvent = objectMapper.readTree(""" + {"code":"200","data":{"eventId":"event-2","result":"DUPLICATE"}} + """); + + assertThatCode(() -> HttpSecurityProjectionEventClient.requireAcknowledgement( + 200, acknowledged, "event-1")).doesNotThrowAnyException(); + assertThatThrownBy(() -> HttpSecurityProjectionEventClient.requireAcknowledgement( + 200, businessFailure, "event-1")).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> HttpSecurityProjectionEventClient.requireAcknowledgement( + 200, wrongEvent, "event-1")).isInstanceOf(IllegalStateException.class); + } + + @Test + void defaultConsumerUrlTargetsLastlegSecurityEndpoint() throws Exception { + String application = Files.readString(Path.of("src/main/resources/application.yml")); + + assertThat(application).contains( + "LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/security/v1/events") + .doesNotContain("/internal/overseas-security/v1/events"); + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java index 9edcfad81..e488beaa9 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java @@ -7,7 +7,7 @@ import java.time.Duration; @ConfigurationProperties(prefix = "baiyue.security.overseas-projection") public record OverseasSecurityProjectionProperties(String serviceUrl, String internalServiceToken, Duration timeout) { public OverseasSecurityProjectionProperties { - if (serviceUrl == null || serviceUrl.isBlank()) throw new IllegalArgumentException("必须配置 OVERSEAS_SECURITY_SERVICE_URL"); + if (serviceUrl == null || serviceUrl.isBlank()) throw new IllegalArgumentException("必须配置 LASTLEG_SECURITY_SERVICE_URL"); if (internalServiceToken == null || internalServiceToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) { throw new IllegalArgumentException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); } diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java index 6d75282df..17f3af48b 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java @@ -9,7 +9,7 @@ import reactor.core.publisher.Mono; @Component public class HttpOverseasSecurityValidationClient implements OverseasSecurityValidationClient { - private static final String PATH = "/internal/overseas-security/v1/validate"; + private static final String PATH = "/internal/lastleg/security/v1/validate"; private final WebClient client; private final OverseasSecurityProjectionProperties properties; @@ -19,10 +19,10 @@ public class HttpOverseasSecurityValidationClient implements OverseasSecurityVal } @Override - public Mono validate(long userId, long tenantId, long tokenVersion, long permissionVersion) { + public Mono validate(long userId, long tenantId, long permissionVersion) { return client.post().uri(properties.serviceUrl() + PATH).contentType(MediaType.APPLICATION_JSON) .header("X-Internal-Service-Token", properties.internalServiceToken()) - .bodyValue(new Request(userId, tenantId, tokenVersion, permissionVersion)) + .bodyValue(new Request(userId, tenantId, permissionVersion)) .retrieve().bodyToMono(JsonNode.class).timeout(properties.timeout()).flatMap(body -> { JsonNode data = body == null ? null : body.path("data"); if (data == null || data.isMissingNode() || !data.has("valid")) { @@ -33,5 +33,5 @@ public class HttpOverseasSecurityValidationClient implements OverseasSecurityVal }); } - private record Request(long userId, long tenantId, long tokenVersion, long permissionVersion) { } + private record Request(long userId, long tenantId, long permissionVersion) { } } diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java index cdbb4e727..91fc516cb 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java @@ -38,12 +38,11 @@ public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered { JwtAuthenticationToken authentication) { Long userId = number(authentication.getToken().getSubject()); Long tenantId = number(authentication.getToken().getClaim("tenant_id")); - Long tokenVersion = number(authentication.getToken().getClaim("token_version")); Long permissionVersion = number(authentication.getToken().getClaim("permission_version")); - if (userId == null || tenantId == null || tokenVersion == null || permissionVersion == null) { + if (userId == null || tenantId == null || permissionVersion == null) { return Mono.error(new SecurityProjectionFailure("A0401", "海外令牌安全版本声明缺失")); } - return client.validate(userId, tenantId, tokenVersion, permissionVersion) + return client.validate(userId, tenantId, permissionVersion) .switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "海外安全状态校验不可用"))) .flatMap(result -> { if (result.valid()) return chain.filter(exchange); diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java index 2ce22a244..a267cb233 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java @@ -3,6 +3,6 @@ package com.baiyue.gateway.filter; import reactor.core.publisher.Mono; public interface OverseasSecurityValidationClient { - Mono validate(long userId, long tenantId, long tokenVersion, long permissionVersion); + Mono validate(long userId, long tenantId, long permissionVersion); record Result(boolean valid, String failureReason) { } } diff --git a/gateway-overseas/src/main/resources/application.yml b/gateway-overseas/src/main/resources/application.yml index 3e7ce51f2..d61d0ec19 100644 --- a/gateway-overseas/src/main/resources/application.yml +++ b/gateway-overseas/src/main/resources/application.yml @@ -47,9 +47,9 @@ baiyue: internal-service-token: ${INTERNAL_SERVICE_TOKEN} timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} overseas-projection: - service-url: ${OVERSEAS_SECURITY_SERVICE_URL} + service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303} internal-service-token: ${INTERNAL_SERVICE_TOKEN} - timeout: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:2s} + timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s} permit-paths: - /actuator/health - /swagger-ui/** diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java new file mode 100644 index 000000000..45bd73e1d --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java @@ -0,0 +1,42 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasSecurityValidationDeploymentContractTest { + @Test + void gatewayAndEventBridgeUseLastlegSecurityEndpointsWithoutTheRetiredService() throws Exception { + String client = read("gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java"); + String gatewayApplication = read("gateway-overseas/src/main/resources/application.yml"); + String gatewayNacos = read("config/nacos/overseas/gateway-overseas.yml"); + String bridgeApplication = read("event-bridge-service/src/main/resources/application.yml"); + String bridgeNacos = read("config/nacos/overseas/event-bridge-service.yml"); + String compose = read("deploy/docker-compose.overseas.yml"); + + assertThat(client).contains("/internal/lastleg/security/v1/validate") + .contains("new Request(userId, tenantId, permissionVersion)") + .doesNotContain("new Request(userId, tenantId, tokenVersion") + .doesNotContain("/internal/overseas-security/"); + for (String source : new String[]{gatewayApplication, gatewayNacos, compose}) { + assertThat(source).contains("LASTLEG_SECURITY_SERVICE_URL", "http://lastleg-service:9303"); + } + for (String source : new String[]{bridgeApplication, bridgeNacos, compose}) { + assertThat(source).contains("LASTLEG_SECURITY_EVENT_CONSUMER_URL", + "/internal/lastleg/security/v1/events"); + } + assertThat(gatewayApplication + gatewayNacos + bridgeApplication + bridgeNacos + compose) + .doesNotContain("overseas-security-service", "9307", "OVERSEAS_SECURITY_DB_URL", + "OVERSEAS_SECURITY_SERVICE_URL", "OVERSEAS_SECURITY_EVENT_CONSUMER_URL"); + } + + private static String read(String relative) throws Exception { + Path path = Path.of(relative); + if (!Files.exists(path)) path = Path.of("..").resolve(relative); + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java index ff9f935f0..1857158e8 100644 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java @@ -26,7 +26,7 @@ class OverseasSecurityProjectionFilterTest { private final OverseasSecurityProjectionFilter filter = new OverseasSecurityProjectionFilter(client, new ObjectMapper()); @Test void disabledUserFailsWithA0401() { - when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + when(client.validate(42, 0, 8)).thenReturn(Mono.just( new OverseasSecurityValidationClient.Result(false, "USER_DISABLED"))); var exchange = exchange("/api/platform/v1/last-leg/orders"); StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); @@ -36,7 +36,7 @@ class OverseasSecurityProjectionFilterTest { @Test void disabledTenantAndStalePermissionFailWithA0403() { for (String reason : new String[]{"TENANT_DISABLED", "PERMISSION_VERSION_STALE"}) { - when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + when(client.validate(42, 0, 8)).thenReturn(Mono.just( new OverseasSecurityValidationClient.Result(false, reason))); var exchange = exchange("/api/merchant/v1/last-leg/orders"); StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); @@ -46,14 +46,14 @@ class OverseasSecurityProjectionFilterTest { } @Test void projectionUnavailableFailsClosed() { - when(client.validate(42, 0, 3, 8)).thenReturn(Mono.error(new IllegalStateException("down"))); + when(client.validate(42, 0, 8)).thenReturn(Mono.error(new IllegalStateException("down"))); var exchange = exchange("/api/platform/v1/last-leg/orders"); StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } @Test void acceptedSecurityStateContinuesAndNonApiPathsDoNotCallProjection() { - when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + when(client.validate(42, 0, 8)).thenReturn(Mono.just( new OverseasSecurityValidationClient.Result(true, null))); AtomicBoolean routed = new AtomicBoolean(); StepVerifier.create(filter.filter(exchange("/api/platform/v1/last-leg/orders"), ignored -> { diff --git a/iam/AGENTS.md b/iam/AGENTS.md index ff4a63a71..be2a0b5e5 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -17,7 +17,7 @@ | 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`WAREHOUSE_PICKER` 必须且只能绑定一个 `overseasWarehouseId` | | 角色/权限/RBAC | `controller/RbacController.java`、`service/RbacService.java` | | 授权快照/内部资格 | `controller/AuthorizationController.java`、`InternalAuthorizationController.java`、`service/AuthorizationSnapshotService.java` | -| 海外安全状态事件 Outbox | `service/SecurityEventOutboxService.java`、`SecurityEventOutboxRelay.java`、`client/SecurityEventBridgeClient.java`;账号、授权或客户状态变更经版本化事件投影到 `../overseas-security-service/` | +| 海外安全状态事件 Outbox | `service/SecurityEventOutboxService.java`、`SecurityEventOutboxRelay.java`、`client/SecurityEventBridgeClient.java`;账号、授权或客户状态变更经版本化事件投影到 `../lastleg-service/security/` | | 消息中心/站内通知 | `notification/controller/NotificationInboxController.java`、`notification/controller/PlatformNotificationController.java`、`notification/service/NotificationService.java`;模板、任务、收件箱及事件消费均在 `notification/` | | File 资质元数据协作 | `controller/InternalTenantController.java`、`service/ProfileResourceService.java`、`client/AuthAccountClient.java` | | DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` | @@ -31,6 +31,7 @@ - 客户建档只在 IAM 事务内落持久初始化意图,远端投递由带租约、退避和最大次数的 Relay 完成;只有状态为 `READY` 才能审核通过。注册补偿必须投递 `COMPENSATE`,不得在建档事务中同步依赖 Firstleg。 - 权限资源 code/type 不可变;按钮隐藏不能替代后端权限校验。 - 只有平台 `WAREHOUSE_PICKER` 可保存 `overseasWarehouseId`,且必须为单个有效海外仓;非拣货员必须为空。仓绑定、账号状态、角色权限或客户状态变化要在同一业务事务内推进安全事件 Outbox,不得依赖海外服务回查国内 IAM。 +- `IamTenantStatusChanged` 是安全可用性快照,不暴露客户建档/审核流程状态:仅 IAM 业务状态 `ACTIVE` 发布 `ACTIVE`,`PROFILE_REQUIRED`、`PENDING`、`REJECTED`、`DISABLED`、`ARCHIVED` 及其他不可用状态统一发布 `DISABLED`。 ## 全量验收导航 diff --git a/iam/SECURITY-EVENT-AUDIT.md b/iam/SECURITY-EVENT-AUDIT.md index 0f0a49f51..856b61edf 100644 --- a/iam/SECURITY-EVENT-AUDIT.md +++ b/iam/SECURITY-EVENT-AUDIT.md @@ -25,9 +25,11 @@ IAM 使用独立的 `iam_security_event_outbox`。业务状态、权限版本与 ## 当前接入 - 企业成员、平台内部用户和平台用户启停均写 `IamUserStatusChanged`。 -- 客户启停写 `IamTenantStatusChanged`。 +- 客户创建、审核结果及启停写 `IamTenantStatusChanged`。 - 成员角色、平台内部用户角色及角色权限变更写 `IamPermissionVersionChanged`。 +普通 Admin/Client 的即时失效只覆盖用户停用、客户停用和权限版本推进。`token_version` 不进入 V1 投影,也不传给 Lastleg 校验;其即时权威比对只用于海外 PDA 向 Auth 核验仓绑定与会话版本。 + ## 尾程权限 `lastleg-permissions.sql` 初始化以下平台权限,并授予 `PLATFORM_ADMIN`: diff --git a/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java b/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java index 4adf81539..c68eac775 100644 --- a/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java +++ b/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java @@ -7,7 +7,8 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; -@FeignClient(name = "event-bridge-service", contextId = "iamSecurityEventBridgeClient") +@FeignClient(name = "event-bridge-service", contextId = "iamSecurityEventBridgeClient", + url = "${baiyue.security-event.event-bridge-url:}") public interface SecurityEventBridgeClient { @PostMapping("/internal/event-bridge/events") ApiResponse receive(@RequestHeader("X-Internal-Service-Token") String token, diff --git a/iam/src/main/java/com/baiyue/iam/service/MemberService.java b/iam/src/main/java/com/baiyue/iam/service/MemberService.java index a2a9088a2..cc35971c5 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MemberService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java @@ -104,6 +104,8 @@ public class MemberService { membership.setMemberType(request.memberType()); membership.setMemberNo(request.memberNo()); membership.setStatus("ACTIVE"); membership.setJoinedAt(LocalDateTime.now()); repository.insert(membership); repository.replaceRoles(tenantId, membershipId, request.roleIds()); + securityEvents.userStatus(authId, tenantId, "MERCHANT", "ACTIVE"); + securityEvents.permissionChanged(authId, tenantId, "MERCHANT", "INITIAL_AUTHORIZATION"); return response(tenantId, membership, user); } catch (RuntimeException exception) { try { diff --git a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java index 9e71f4848..a393d5cac 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java @@ -42,6 +42,7 @@ public class MerchantOnboardingService { @Resource private IamTenantReviewMapper reviewMapper; @Resource private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @Resource private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; + @Resource private SecurityEventOutboxService securityEvents; @Transactional public MerchantRegistrationResponse register(MerchantRegistrationRequest request) { @@ -78,6 +79,7 @@ public class MerchantOnboardingService { tenant.setDeleted(false); tenant.setVersion(0); tenantMapper.insert(tenant); + securityEvents.tenantStatus(tenantId, tenant.getStatus()); IamUser user = new IamUser(); user.setId(userId); @@ -109,6 +111,8 @@ public class MerchantOnboardingService { membershipRole.setTenantId(tenantId); membershipRole.setCreatedAt(LocalDateTime.now()); membershipRoleMapper.insert(membershipRole); + securityEvents.userStatus(request.authUserId(), tenantId, "MERCHANT", "ACTIVE"); + securityEvents.permissionChanged(request.authUserId(), tenantId, "MERCHANT", "INITIAL_AUTHORIZATION"); if (StringUtils.hasText(request.address())) { IamTenantAddress address = new IamTenantAddress(); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java index f0285949a..baa3d4658 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java @@ -77,6 +77,7 @@ public class PlatformInternalUserService { repository.insertUser(user); repository.insert(membership); repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); + publishInitialSecurityProjection(user); return response(membership, user, request.username(), roleIds); } catch (RuntimeException localFailure) { compensateCreate(authUserId, localFailure); @@ -196,9 +197,16 @@ public class PlatformInternalUserService { IamMembership membership = newMembership(membershipId, user.getId(), memberNo); repository.insert(membership); repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); + publishInitialSecurityProjection(user); return response(membership, user, authAccount.username(), roleIds); } + private void publishInitialSecurityProjection(IamUser user) { + securityEvents.userStatus(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", user.getStatus()); + securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", + "INITIAL_AUTHORIZATION"); + } + private InternalUserResponse response(IamMembership membership) { IamUser user = requiredUser(membership.getUserId()); AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId()); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index 296100ffb..b7e719747 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -106,6 +106,8 @@ public class PlatformUserManagementService { membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); members.insert(membership); members.replaceRoles(tenantId, membershipId, roleIds); + securityEvents.userStatus(authUserId, tenantId, appCode, request.status()); + securityEvents.permissionChanged(authUserId, tenantId, appCode, "INITIAL_AUTHORIZATION"); return response(membership); } catch (RuntimeException localFailure) { compensateCreate(authUserId, localFailure); diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java index 32b9bd9e4..de41c5d9d 100644 --- a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java +++ b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java @@ -2,6 +2,7 @@ package com.baiyue.iam.service; import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventEnvelopeValidator; +import com.baiyue.common.event.RegionCode; import com.baiyue.iam.client.SecurityEventBridgeClient; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -45,6 +46,9 @@ public class SecurityEventOutboxRelay { try { EventEnvelope envelope = objectMapper.readValue(json, EventEnvelope.class); EventEnvelopeValidator.requireValid(envelope); + if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) { + throw new IllegalStateException("IAM 安全事件必须从 CN 投影到 OVERSEAS"); + } var response = client.receive(internalToken, envelope); if (response == null || !response.successful() || response.data() == null || !eventId.equals(response.data().eventId())) { diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java index fcc158420..520689a86 100644 --- a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java +++ b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java @@ -32,8 +32,9 @@ public class SecurityEventOutboxService { + "ON DUPLICATE KEY UPDATE status_version=status_version+1", tenantId); long version = jdbc.queryForObject( "SELECT status_version FROM iam_security_tenant_version WHERE tenant_id=?", Long.class, tenantId); + String securityStatus = "ACTIVE".equals(status) ? "ACTIVE" : "DISABLED"; write(EventTypes.IAM_TENANT_STATUS_CHANGED, "TENANT", String.valueOf(tenantId), tenantId, version, - new IamSecurityEventsV1.TenantStatusChanged(tenantId, status, Instant.now())); + new IamSecurityEventsV1.TenantStatusChanged(tenantId, securityStatus, Instant.now())); } public void permissionChanged(long authUserId, long tenantId, String appCode, String reason) { diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java index 7eb7d502b..6e1a6adbc 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java @@ -43,6 +43,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni private IamRoleMapper roles; @Resource private AuthAccountClient authClient; + @Resource + private SecurityEventOutboxService securityEvents; @Value("${baiyue.security.internal-service-token}") private String internalToken; private SecureRandom random = new SecureRandom(); @@ -112,6 +114,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni membership.setUpdatedBy(operatorId); members.insert(membership); members.replaceRoles(tenant.getId(), membershipId, List.of(ownerRole.getId())); + publishInitialSecurityProjection(authUserId, tenant.getId()); return result; } catch (RuntimeException localFailure) { compensate(context, result); @@ -152,6 +155,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni String password = temporaryPassword(); String username = createAuthAccountWithUniqueUsername(user.getAuthUserId(), password, tenant, owner.getId()); + publishInitialSecurityProjection(user.getAuthUserId(), tenant.getId()); return ProvisioningResult.created(user.getAuthUserId(), username, password); } if (!"ACTIVE".equals(account.status()) @@ -166,6 +170,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni List repairedRoleIds = new java.util.ArrayList<>(roleIds); repairedRoleIds.add(ownerRole.getId()); members.replaceRoles(tenant.getId(), owner.getId(), repairedRoleIds.stream().distinct().toList()); + securityEvents.permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT", + "MEMBER_ROLES_CHANGED"); } return ProvisioningResult.existing(); } @@ -230,6 +236,11 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni } } + private void publishInitialSecurityProjection(long authUserId, long tenantId) { + securityEvents.userStatus(authUserId, tenantId, "MERCHANT", "ACTIVE"); + securityEvents.permissionChanged(authUserId, tenantId, "MERCHANT", "INITIAL_AUTHORIZATION"); + } + private void compensate(long authUserId) { try { ApiResponse response = authClient.compensate(internalToken, authUserId); diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index f29f58f63..f93c268cc 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -190,6 +190,7 @@ public class TenantService { tenant.setCreatedBy(operatorId); tenant.setUpdatedBy(operatorId); repository.insertTenant(tenant); + securityEvents.tenantStatus(tenant.getId(), tenant.getStatus()); dimensionRuleInitialization.initialize(tenant.getId(), operatorId); overseasStorageFeeInitialization.initialize(tenant.getId(), operatorId, tenant.getTenantName()); var administrator = administratorProvisioning.provisionPlatformCreatedTenant(tenant, operatorId); @@ -297,6 +298,7 @@ public class TenantService { tenant.setApprovedAt(approve ? LocalDateTime.now() : null); tenant.setApprovedBy(approve ? reviewerId : null); repository.saveTenant(tenant); + securityEvents.tenantStatus(tenant.getId(), tenant.getStatus()); for (var qualification : profileRepository.findQualifications(tenant.getId())) { if ("PENDING".equals(qualification.getStatus())) { qualification.setStatus(approve ? "APPROVED" : "REJECTED"); diff --git a/iam/src/main/resources/application.yml b/iam/src/main/resources/application.yml index e724fd6a4..c7f0fe556 100644 --- a/iam/src/main/resources/application.yml +++ b/iam/src/main/resources/application.yml @@ -23,6 +23,8 @@ spring: namespace: ${NACOS_NAMESPACE:public} encode: UTF-8 baiyue: + security-event: + event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} @@ -45,3 +47,4 @@ logging: max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" + diff --git a/iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java b/iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java new file mode 100644 index 000000000..92692f92d --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java @@ -0,0 +1,35 @@ +package com.baiyue.iam.client; + +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.FeignClient; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class SecurityEventBridgeClientContractTest { + private static final Path REPOSITORY_ROOT = Path.of("..").toAbsolutePath().normalize(); + + @Test + void feignUsesTheExplicitOverseasBridgePropertyInsteadOfSameNameDiscovery() { + FeignClient annotation = SecurityEventBridgeClient.class.getAnnotation(FeignClient.class); + + assertThat(annotation.name()).isEqualTo("event-bridge-service"); + assertThat(annotation.url()).isEqualTo("${baiyue.security-event.event-bridge-url:}"); + } + + @Test + void cnIamConfigurationTargetsTheOverseasBridgeNetworkAlias() throws Exception { + String application = Files.readString(REPOSITORY_ROOT.resolve("iam/src/main/resources/application.yml")); + String nacos = Files.readString(REPOSITORY_ROOT.resolve("config/nacos/cn/iam-service.yml")); + String compose = Files.readString(REPOSITORY_ROOT.resolve("deploy/docker-compose.yml")); + + assertThat(application).contains( + "event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}"); + assertThat(nacos).contains( + "event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}"); + assertThat(compose).contains( + "SECURITY_EVENT_BRIDGE_URL: ${SECURITY_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312}"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java index e653c4c01..b69d85274 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java @@ -14,6 +14,7 @@ import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.repository.TenantRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import jakarta.annotation.Resource; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; @@ -135,6 +136,27 @@ class MemberServiceTest { verify(repository, never()).insert(any()); } + @Test void createPublishesActiveUserAndInitialAuthorizationAfterRoleAssignment() { + IamRole role = activeMerchantRole(50L, 100L); + when(roleMapper.selectById(50L)).thenReturn(role); + when(auth.create(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); + when(auth.summary(eq("internal-token"), anyLong())).thenAnswer(invocation -> ApiResponse.success( + new AuthAccountClient.AccountSummary(invocation.getArgument(1), "member02", "成员", "ACTIVE"))); + when(repository.findRoleIds(eq(100L), anyLong())).thenReturn(List.of(50L)); + CreateMemberRequest request = new CreateMemberRequest("member02", "Password1", "成员", + null, null, "EMPLOYEE", "M-002", List.of(50L)); + + service.create(100L, request, Set.of("merchant:member:create", "merchant:member:grant-role")); + + ArgumentCaptor account = + ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class); + verify(auth).create(eq("internal-token"), account.capture()); + long authUserId = account.getValue().accountId(); + verify(repository).replaceRoles(eq(100L), anyLong(), eq(List.of(50L))); + verify(securityEvents).userStatus(authUserId, 100L, "MERCHANT", "ACTIVE"); + verify(securityEvents).permissionChanged(authUserId, 100L, "MERCHANT", "INITIAL_AUTHORIZATION"); + } + @Test void shouldHandleCase08() { IamRole role = activeMerchantRole(50L, 100L); when(roleMapper.selectById(50L)).thenReturn(role); diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java index ade730ea0..51159c7d7 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java @@ -41,6 +41,7 @@ class MerchantOnboardingServiceTest { @MockBean private IamTenantReviewMapper reviews; @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; + @MockBean private SecurityEventOutboxService securityEvents; @Resource private MerchantOnboardingService service; @Test void clientRegistrationCreatesOwnerAndAssignsGlobalMerchantOwnerRole() { @@ -67,6 +68,10 @@ class MerchantOnboardingServiceTest { verify(membershipRoles).insert(roleGrant.capture()); verify(dimensionRuleInitialization).initialize(result.tenantId(), 9001L); verify(overseasStorageFeeInitialization).initialize(result.tenantId(), 9001L, "测试企业"); + verify(securityEvents).tenantStatus(result.tenantId(), "PROFILE_REQUIRED"); + verify(securityEvents).userStatus(9001L, result.tenantId(), "MERCHANT", "ACTIVE"); + verify(securityEvents).permissionChanged(9001L, result.tenantId(), "MERCHANT", + "INITIAL_AUTHORIZATION"); assertEquals("PROFILE_REQUIRED", result.tenantStatus()); assertEquals(result.tenantId(), tenant.getValue().getId()); @@ -194,3 +199,4 @@ class MerchantOnboardingServiceTest { verify(tenants, never()).deleteById((java.io.Serializable) any()); } } + diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java index adaccd1b1..6e8da47c5 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java @@ -85,6 +85,39 @@ class PlatformInternalUserServiceTest { verify(repository).insertUser(any(IamUser.class)); verify(repository).insert(any(IamMembership.class)); verify(repository).replaceRoles(eq(0L), anyLong(), eq(List.of(40L))); + long authUserId = captor.getValue().accountId(); + verify(securityEvents).userStatus(authUserId, 0L, "PLATFORM", "ACTIVE"); + verify(securityEvents).permissionChanged(authUserId, 0L, "PLATFORM", "INITIAL_AUTHORIZATION"); + } + + @Test + void linkingExistingAccountPublishesInitialProjectionOnlyForNewMembership() { + IamRole role = new IamRole(); + role.setId(40L); role.setTenantId(0L); role.setAppCode("PLATFORM"); role.setStatus("ACTIVE"); + when(roleMapper.selectById(40L)).thenReturn(role); + when(repository.findUserByAuthId(10L)).thenReturn(user); + when(repository.findByTenantAndAuthUser(0L, 10L)).thenReturn(null); + CreateInternalUserRequest request = new CreateInternalUserRequest(10L, null, "平台运营", + "13800138000", null, null, null, "PLATFORM-LINK", List.of(40L)); + + service.create(request); + + verify(repository).replaceRoles(eq(0L), anyLong(), eq(List.of(40L))); + verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "ACTIVE"); + verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", "INITIAL_AUTHORIZATION"); + verify(auth, never()).create(anyString(), any()); + } + + @Test + void relinkingExistingMembershipDoesNotDuplicateInitialProjection() { + when(repository.findByTenantAndAuthUser(0L, 10L)).thenReturn(membership); + CreateInternalUserRequest request = new CreateInternalUserRequest(10L, null, "平台运营", + "13800138000", null, null, null, "PLATFORM-30", List.of()); + + service.create(request); + + verifyNoInteractions(securityEvents); + verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java index 4dd7ddfb6..fb4949914 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -12,6 +12,7 @@ import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.repository.MemberRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import jakarta.annotation.Resource; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; @@ -20,6 +21,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.time.LocalDateTime; import java.util.List; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -85,6 +87,30 @@ class PlatformUserManagementServiceTest { verify(auth, never()).create(anyString(), any()); } + @Test + void createPublishesActualStatusAndInitialAuthorizationAfterRolesAreStored() { + AtomicReference createdUser = new AtomicReference<>(); + doAnswer(invocation -> { createdUser.set(invocation.getArgument(0)); return null; }) + .when(members).insertUser(any(IamUser.class)); + when(members.findUser(anyLong())).thenAnswer(invocation -> createdUser.get()); + when(members.findRoleIds(eq(0L), anyLong())).thenReturn(List.of(40001L)); + when(auth.create(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); + when(auth.summary(eq("internal-token"), anyLong())).thenAnswer(invocation -> ApiResponse.success( + new AuthAccountClient.AccountSummary(invocation.getArgument(1), "newuser", "新用户", "DISABLED"))); + CreateUserRequest request = new CreateUserRequest("newuser", "新用户", "13900139000", + "new@example.com", 0L, List.of(40001L), "Password1", "DISABLED"); + + service.create(request); + + ArgumentCaptor account = + ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class); + verify(auth).create(eq("internal-token"), account.capture()); + long authUserId = account.getValue().accountId(); + verify(members).replaceRoles(eq(0L), anyLong(), eq(List.of(40001L))); + verify(securityEvents).userStatus(authUserId, 0L, "PLATFORM", "DISABLED"); + verify(securityEvents).permissionChanged(authUserId, 0L, "PLATFORM", "INITIAL_AUTHORIZATION"); + } + @Test void optionsContainOnlyPlatformTenantAndPlatformRoles() { UserOptions result = service.options(); diff --git a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java new file mode 100644 index 000000000..aa212d14b --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java @@ -0,0 +1,91 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.IamSecurityEventsV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.iam.client.SecurityEventBridgeClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SecurityEventOutboxRelayTest { + @Mock private JdbcTemplate jdbc; + @Mock private SecurityEventBridgeClient client; + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + private SecurityEventOutboxRelay relay; + + @BeforeEach + void setUp() { + relay = new SecurityEventOutboxRelay(); + ReflectionTestUtils.setField(relay, "jdbc", jdbc); + ReflectionTestUtils.setField(relay, "client", client); + ReflectionTestUtils.setField(relay, "objectMapper", objectMapper); + ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); + ReflectionTestUtils.setField(relay, "maxAttempts", 12); + } + + @Test + void cnToOverseasEventIsSentOnlyAfterMatchingBridgeAcknowledgement() throws Exception { + EventEnvelope envelope = envelope(RegionCode.OVERSEAS); + when(jdbc.update(anyString(), any(Object[].class))).thenReturn(1); + when(jdbc.queryForObject(anyString(), eq(String.class), any(), any())) + .thenReturn(objectMapper.writeValueAsString(envelope)); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success( + new SecurityEventBridgeClient.Acknowledgement(envelope.eventId(), "ACCEPTED", "2026-08-25T07:00:00Z"))); + + relay.deliver(envelope.eventId()); + + ArgumentCaptor sent = ArgumentCaptor.forClass(EventEnvelope.class); + verify(client).receive(eq("internal-token"), sent.capture()); + assertThat(sent.getValue().sourceRegion()).isEqualTo(RegionCode.CN); + assertThat(sent.getValue().targetRegion()).isEqualTo(RegionCode.OVERSEAS); + verify(jdbc).update(anyString(), any(), any(), eq(envelope.eventId()), anyString()); + } + + @Test + void nonOverseasTargetNeverReachesTheBridge() throws Exception { + EventEnvelope envelope = envelope(RegionCode.CN); + when(jdbc.update(anyString(), any(Object[].class))).thenReturn(1); + when(jdbc.queryForObject(anyString(), eq(String.class), any(), any())) + .thenReturn(objectMapper.writeValueAsString(envelope)); + when(jdbc.queryForObject(anyString(), eq(Integer.class), eq(envelope.eventId()))).thenReturn(0); + + relay.deliver(envelope.eventId()); + + verify(client, never()).receive(anyString(), any(EventEnvelope.class)); + verify(jdbc).update(anyString(), eq("PENDING"), eq(1), any(), anyString(), any(), + eq(envelope.eventId()), anyString()); + } + + private EventEnvelope envelope(RegionCode target) { + var value = new IamSecurityEventsV1.PermissionVersionChanged( + 101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z")); + var payload = objectMapper.valueToTree(value); + return new EventEnvelope("00000000-0000-4000-8000-000000000081", + EventTypes.IAM_PERMISSION_VERSION_CHANGED, IamSecurityEventsV1.VERSION, + Instant.parse("2026-08-25T07:00:00Z"), RegionCode.CN, target, "0", + "AUTHORIZATION", "101", 3L, null, "trace-12345678", "correlation-12345678", + null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java index 432587f3f..fb1ce9e8b 100644 --- a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java @@ -1,10 +1,13 @@ package com.baiyue.iam.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.IamSecurityEventsV1; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -43,4 +46,29 @@ class SecurityEventOutboxServiceTest { EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class); assertThat(envelope.requireValid().correlationId()).isNotBlank().isEqualTo(envelope.traceId()); } + + @ParameterizedTest + @CsvSource({ + "ACTIVE,ACTIVE", + "PROFILE_REQUIRED,DISABLED", + "PENDING,DISABLED", + "REJECTED,DISABLED", + "DISABLED,DISABLED", + "ARCHIVED,DISABLED" + }) + void tenantWorkflowStatusIsNormalizedToTheSecurityContract(String businessStatus, + String expectedSecurityStatus) throws Exception { + when(jdbc.queryForObject(anyString(), eq(Long.class), any())).thenReturn(3L); + + service.tenantStatus(10001L, businessStatus); + + ArgumentCaptor parameters = ArgumentCaptor.forClass(Object[].class); + verify(jdbc).update(eq("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) " + + "VALUES (?,?,?,?,CAST(? AS JSON))"), parameters.capture()); + EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class); + IamSecurityEventsV1.TenantStatusChanged payload = objectMapper.treeToValue( + envelope.payload(), IamSecurityEventsV1.TenantStatusChanged.class); + assertThat(payload.status()).isEqualTo(expectedSecurityStatus); + assertThat(envelope.aggregateVersion()).isEqualTo(3L); + } } diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java index 8dfbe466c..8e76d8a1d 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java @@ -34,6 +34,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; @@ -43,6 +44,7 @@ class TenantAdministratorProvisioningServiceTest { @MockBean private MemberRepository members; @MockBean private IamRoleMapper roles; @MockBean private AuthAccountClient auth; + @MockBean private SecurityEventOutboxService securityEvents; @Resource private TenantAdministratorProvisioningService service; private IamTenant tenant; @@ -101,6 +103,9 @@ class TenantAdministratorProvisioningServiceTest { verify(members).insertUser(any(IamUser.class)); verify(members).insert(any(IamMembership.class)); verify(members).replaceRoles(eq(tenant.getId()), anyLong(), eq(List.of(4000100L))); + verify(securityEvents).userStatus(account.getValue().accountId(), tenant.getId(), "MERCHANT", "ACTIVE"); + verify(securityEvents).permissionChanged(account.getValue().accountId(), tenant.getId(), "MERCHANT", + "INITIAL_AUTHORIZATION"); } @Test @@ -135,6 +140,7 @@ class TenantAdministratorProvisioningServiceTest { assertEquals(null, result.temporaryPassword()); verify(auth, never()).create(anyString(), any()); verify(members, never()).replaceRoles(anyLong(), anyLong(), any()); + verifyNoInteractions(securityEvents); } @Test @@ -153,6 +159,8 @@ class TenantAdministratorProvisioningServiceTest { assertFalse(result.created()); verify(auth, never()).create(anyString(), any()); verify(members).replaceRoles(tenant.getId(), owner.getId(), List.of(4000200L, 4000100L)); + verify(securityEvents).permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT", + "MEMBER_ROLES_CHANGED"); } @Test @@ -190,6 +198,9 @@ class TenantAdministratorProvisioningServiceTest { verify(members, never()).insertUser(any()); verify(members, never()).insert(any()); verify(members, never()).replaceRoles(anyLong(), anyLong(), any()); + verify(securityEvents).userStatus(user.getAuthUserId(), tenant.getId(), "MERCHANT", "ACTIVE"); + verify(securityEvents).permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT", + "INITIAL_AUTHORIZATION"); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index ca4291de3..c9f7f82c6 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -320,6 +320,7 @@ class TenantServiceTest { verify(repository).insertTenant(argThat(tenant -> Long.valueOf(9001L).equals(tenant.getCreatedBy()) && Long.valueOf(9001L).equals(tenant.getUpdatedBy()))); verify(dimensionRuleInitialization).initialize(created.tenant().id(), 9001L); + verify(securityEvents).tenantStatus(created.tenant().id(), "PROFILE_REQUIRED"); verify(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L)); verify(repository, never()).insertReview(any()); } @@ -428,6 +429,7 @@ class TenantServiceTest { assertEquals("ACTIVE", tenant.getStatus()); verify(repository).saveReview(review); verify(repository).saveTenant(tenant); + verify(securityEvents).tenantStatus(10001L, "ACTIVE"); } @Test @@ -527,3 +529,4 @@ class TenantServiceTest { return review; } } + diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index cc6a8ccc8..828e0e050 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -20,6 +20,7 @@ | 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper | | 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 | | 内部令牌保护 | `security/InternalServiceTokenGuard.java` | +| IAM 海外安全投影与实时校验 | 内部入口 `security/controller/LastlegSecurityController.java`,固定 `/internal/lastleg/security/v1/events`、`/validate` 且复用 `InternalServiceTokenGuard`;投影/校验 `security/service/`,DTO `security/dto/`,表固定 `ll_security_event_inbox`、`ll_security_projection`;MySQL 投影缺失、落后或不可用一律 fail-closed,Redis 仅为提交后可重建镜像;业务切片不得直接依赖投影内部类,边界由 `SecurityProjectionArchitectureTest` 与 Node 合同锁定 | | 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java` | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | @@ -62,6 +63,7 @@ | 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 | | 采购、卡车与本土散板 | `ll_overseas_procurement_*`、`ll_us_truck_*`、`ll_local_looseboard_*` | 海外采购、美国卡车运输及本土散板订单 | | 通用资料 | `ll_overseas_quote*`、`ll_monthly_exchange_rate*`、`ll_vehicle` | 海外仓报价、月度汇率及车辆档案;不保存车辆派送履约历史 | +| 海外安全投影 | `ll_security_event_inbox`、`ll_security_projection` | IAM 安全事件可靠收件箱与 Lastleg 本地授权投影;只允许内部服务入口访问,不作为 loading、shipping、inbound 等业务模型 | - 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义,不得只写英文、字段名或类型名。 - `SchemaChineseCommentContractTest` 自动扫描全部 `ll_` 表,无需维护表名白名单;新增字段必须同步更新基线 SQL、建表增量和存量备注迁移,增量脚本的 `ADD/MODIFY COLUMN` 不得丢失中文备注。 @@ -76,6 +78,9 @@ - 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。 - 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 - 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 +- IAM 安全事件只能进入 `/internal/lastleg/security/v1/events`;通用 `/internal/lastleg/events` 只白名单接收装柜完成与实际船节点事件,禁止把 IAM 事件交给 `LoadingCompletedConsumer`。 +- Lastleg 安全投影只接受 `ACTIVE`/`DISABLED` 两态安全语义;客户建档和审核的 `PROFILE_REQUIRED`/`PENDING`/`REJECTED`/`ARCHIVED` 由 IAM 在发布前统一折叠为 `DISABLED`,Lastleg 不兼容或解释国内工作流状态。 +- 开发基线只为国内固定 `authUserId=1000001`、`tenantId=0` 的 `platform_admin` 写 USER/AUTHORIZATION version=0 投影;使用 `INSERT IGNORE`,后续 IAM version>=1 事件单调覆盖。不得扩大为历史账号运行时补齐或缺失投影默认放行。 - PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 - 海外 PDA 柜号签收只允许平台 `WAREHOUSE_PICKER` 使用 `lastleg:pda:operate`;PDA 不接收 `customerId`,候选跨客户读取,选柜时从柜内计划唯一解析并冻结真实客户。未完成会话按平台操作账号和柜号分别全局唯一,后续照片、扫码、实收、库存与完成操作只使用会话客户范围;平台管理员和客户账号不得进入该作业入口。 @@ -94,6 +99,7 @@ ## 修改与验证 - 移动包、拆分端口/适配器或引入新的跨子域依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-lastleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。 +- 安全投影边界变更同时运行 `SecurityProjectionArchitectureTest` 与 `LastlegSecurityContractTest`;`security/controller|dto|service` 只允许依赖本子域和根 `InternalServiceTokenGuard`,其他 Lastleg 业务包不得直接依赖这些投影内部包。 - 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。 - 事件契约变化同时检查 `common-event-contract` 版本、Event Bridge 消费方和旧 Payload 哈希兼容。 - 定向验证:`mvn -pl lastleg -am test`。 diff --git a/lastleg-service/pom.xml b/lastleg-service/pom.xml index 938233013..d1274b7a8 100644 --- a/lastleg-service/pom.xml +++ b/lastleg-service/pom.xml @@ -19,6 +19,7 @@ org.springframework.bootspring-boot-starter-actuator com.mysqlmysql-connector-jruntime org.springframework.bootspring-boot-starter-testtest + com.tngtech.archunitarchunittest org.springframework.bootspring-boot-maven-plugin diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs index c639c1bcd..a2488d164 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs @@ -12,6 +12,7 @@ const sourceRoots = { const internalPrefix = 'com.baiyue.lastleg.'; const sliceRules = [ + ['security-projection', /(?:^|\.)security\.(?:controller|dto|service)(?:\.|$)/], ['dispatch-delivery', /(?:^|\.)(?:dispatch|delivery|outbound)(?:\.|$)/], ['relabel-transfer-discard', /(?:^|\.)(?:relabel|transfer|discard)(?:\.|$)/], ['inbound-pda', /(?:^|\.)(?:inboundplan|palletops|pda)(?:\.|$)/], diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index bb8a6a945..28469687c 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -74,3 +74,25 @@ test('vehicle application only depends inward and stays free of adapter framewor && edge.target.startsWith('com.baiyue.lastleg.') && !edge.target.startsWith(prefix)); assert.deepEqual(outwardDependencies, []); }); + +test('security projection stays isolated from Lastleg business slices', () => { + const report = analyze(); + const projectionClasses = report.classes.filter(entry => entry.slice === 'security-projection'); + const projectionNames = new Set(projectionClasses.map(entry => entry.fqcn)); + assert.deepEqual(projectionClasses.map(entry => entry.fqcn).sort(), [ + 'com.baiyue.lastleg.security.controller.LastlegSecurityController', + 'com.baiyue.lastleg.security.dto.LastlegSecurityDtos', + 'com.baiyue.lastleg.security.service.LastlegSecurityProjectionRedisMirror', + 'com.baiyue.lastleg.security.service.LastlegSecurityProjectionService', + 'com.baiyue.lastleg.security.service.LastlegSecurityValidationService', + 'com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity', + ]); + assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'security-projection'), [ + { source: 'security-projection', target: 'shared-technical', count: 1 }, + ]); + assert.deepEqual(report.sliceEdges.filter(edge => edge.target === 'security-projection'), []); + assert.equal(report.sliceSccs.some(component => component.includes('security-projection')), false); + const businessConsumers = report.classDependencies.filter(edge => projectionNames.has(edge.target) + && !projectionNames.has(edge.source)); + assert.deepEqual(businessConsumers, []); +}); diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java similarity index 60% rename from overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java index 05fe60b65..6b3cb5093 100644 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java @@ -1,13 +1,13 @@ -package com.baiyue.overseassecurity.controller; +package com.baiyue.lastleg.security.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.event.EventEnvelope; -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.Acknowledgement; -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationRequest; -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationResponse; -import com.baiyue.overseassecurity.security.InternalServiceTokenGuard; -import com.baiyue.overseassecurity.service.OverseasSecurityValidationService; -import com.baiyue.overseassecurity.service.SecurityProjectionService; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.Acknowledgement; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationResponse; +import com.baiyue.lastleg.security.service.LastlegSecurityProjectionService; +import com.baiyue.lastleg.security.service.LastlegSecurityValidationService; import io.swagger.v3.oas.annotations.Hidden; import jakarta.validation.Valid; import org.springframework.web.bind.annotation.PostMapping; @@ -20,14 +20,15 @@ import java.time.Instant; @Hidden @RestController -@RequestMapping("/internal/overseas-security/v1") -public class OverseasSecurityController { +@RequestMapping("/internal/lastleg/security/v1") +public class LastlegSecurityController { private final InternalServiceTokenGuard tokenGuard; - private final SecurityProjectionService projections; - private final OverseasSecurityValidationService validations; + private final LastlegSecurityProjectionService projections; + private final LastlegSecurityValidationService validations; - public OverseasSecurityController(InternalServiceTokenGuard tokenGuard, SecurityProjectionService projections, - OverseasSecurityValidationService validations) { + public LastlegSecurityController(InternalServiceTokenGuard tokenGuard, + LastlegSecurityProjectionService projections, + LastlegSecurityValidationService validations) { this.tokenGuard = tokenGuard; this.projections = projections; this.validations = validations; diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/dto/OverseasSecurityDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java similarity index 74% rename from overseas-security-service/src/main/java/com/baiyue/overseassecurity/dto/OverseasSecurityDtos.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java index 7560da9b2..cb301fd7f 100644 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/dto/OverseasSecurityDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java @@ -1,26 +1,29 @@ -package com.baiyue.overseassecurity.dto; +package com.baiyue.lastleg.security.dto; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import java.time.Instant; -public final class OverseasSecurityDtos { - private OverseasSecurityDtos() { } +public final class LastlegSecurityDtos { + private LastlegSecurityDtos() { } public record Acknowledgement(String eventId, String result, Instant receivedAt) { } public record ValidationRequest(@NotNull @Min(1) Long userId, @NotNull @Min(0) Long tenantId, - @NotNull @Min(0) Long tokenVersion, @NotNull @Min(0) Long permissionVersion) { } public record ValidationResponse(boolean valid, FailureReason failureReason) { public static ValidationResponse accepted() { return new ValidationResponse(true, null); } - public static ValidationResponse rejected(FailureReason reason) { return new ValidationResponse(false, reason); } + public static ValidationResponse rejected(FailureReason reason) { + return new ValidationResponse(false, reason); + } } public enum FailureReason { + SECURITY_PROJECTION_MISSING, + SECURITY_PROJECTION_NOT_READY, USER_DISABLED, TENANT_DISABLED, PERMISSION_VERSION_STALE diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java similarity index 82% rename from overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java index e022e01cb..5e888487b 100644 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java @@ -1,4 +1,4 @@ -package com.baiyue.overseassecurity.service; +package com.baiyue.lastleg.security.service; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; @@ -11,19 +11,19 @@ import org.springframework.transaction.event.TransactionalEventListener; /** 非授权真相的可重建 Redis 镜像;失败不影响已提交的 MySQL 投影。 */ @Component @Slf4j -public class SecurityProjectionRedisMirror { +public class LastlegSecurityProjectionRedisMirror { private final StringRedisTemplate redis; private final ObjectMapper objectMapper; @Value("${baiyue.security.permission-snapshot-key-prefix:baiyue:permission-snapshot}") private String keyPrefix; - public SecurityProjectionRedisMirror(StringRedisTemplate redis, ObjectMapper objectMapper) { + public LastlegSecurityProjectionRedisMirror(StringRedisTemplate redis, ObjectMapper objectMapper) { this.redis = redis; this.objectMapper = objectMapper; } @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void mirror(SecurityProjectionService.ProjectionChanged event) { + public void mirror(LastlegSecurityProjectionService.ProjectionChanged event) { var value = event.projection(); try { redis.opsForValue().set(keyPrefix + ':' + value.aggregateType() + ':' + value.aggregateId(), diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java similarity index 66% rename from overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java index d76d77932..dcfe0ab4b 100644 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java @@ -1,8 +1,10 @@ -package com.baiyue.overseassecurity.service; +package com.baiyue.lastleg.security.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.IamSecurityEventsV1; import com.baiyue.common.event.RegionCode; @@ -15,17 +17,19 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.List; +import java.util.Locale; import java.util.Objects; @Service -public class SecurityProjectionService { +public class LastlegSecurityProjectionService { private static final List TYPES = List.of(EventTypes.IAM_USER_STATUS_CHANGED, EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED); private final JdbcTemplate jdbc; private final ObjectMapper objectMapper; private final ApplicationEventPublisher events; - public SecurityProjectionService(JdbcTemplate jdbc, ObjectMapper objectMapper, ApplicationEventPublisher events) { + public LastlegSecurityProjectionService(JdbcTemplate jdbc, ObjectMapper objectMapper, + ApplicationEventPublisher events) { this.jdbc = jdbc; this.objectMapper = objectMapper; this.events = events; @@ -35,35 +39,38 @@ public class SecurityProjectionService { public ConsumeResult consume(EventEnvelope envelope) { envelope.requireValid(); requireSecurityEnvelope(envelope); + String payloadHash = validatedStorageHash(envelope); String payloadJson = envelope.payload().toString(); try { - jdbc.update("INSERT INTO overseas_security_event_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) " + jdbc.update("INSERT INTO ll_security_event_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) " + "VALUES (?,?,?,?, 'RECEIVED', NOW(3))", envelope.eventId(), envelope.eventType(), - envelope.payloadHash(), payloadJson); + payloadHash, payloadJson); } catch (DuplicateKeyException duplicate) { - InboxRow existing = jdbc.queryForObject("SELECT event_type,payload_hash,status FROM overseas_security_event_inbox " + InboxRow existing = jdbc.queryForObject( + "SELECT event_type,payload_hash,status FROM ll_security_event_inbox " + "WHERE event_id=? FOR UPDATE", (rs, row) -> new InboxRow(rs.getString(1), rs.getString(2), rs.getString(3)), envelope.eventId()); if (existing == null || !Objects.equals(existing.eventType(), envelope.eventType()) - || !Objects.equals(existing.payloadHash(), envelope.payloadHash())) { + || existing.payloadHash() == null + || !existing.payloadHash().equalsIgnoreCase(payloadHash)) { throw new BizException(ResultCode.CONFLICT, "相同 eventId 的 payloadHash 冲突"); } if ("PROCESSED".equals(existing.status())) return new ConsumeResult("DUPLICATE", null); } - ProjectionInput input = input(envelope); + ProjectionInput input = input(envelope, payloadHash); ProjectionRow current = findForUpdate(input.aggregateType(), input.aggregateId()); - ProjectionMonotonicity.Decision decision = ProjectionMonotonicity.decide( + SecurityProjectionMonotonicity.Decision decision = SecurityProjectionMonotonicity.decide( current == null ? null : current.aggregateVersion(), current == null ? null : current.payloadHash(), input.aggregateVersion(), input.payloadHash()); String result = decision.name(); - if (decision == ProjectionMonotonicity.Decision.APPLY) { + if (decision == SecurityProjectionMonotonicity.Decision.APPLY) { upsert(input); result = "APPLIED"; events.publishEvent(new ProjectionChanged(input)); } - jdbc.update("UPDATE overseas_security_event_inbox SET status='PROCESSED',result=?,processed_at=NOW(3) " + jdbc.update("UPDATE ll_security_event_inbox SET status='PROCESSED',result=?,processed_at=NOW(3) " + "WHERE event_id=?", result, envelope.eventId()); return new ConsumeResult(result, input); } @@ -75,31 +82,48 @@ public class SecurityProjectionService { } } - private ProjectionInput input(EventEnvelope envelope) { + static String validatedStorageHash(EventEnvelope envelope) { + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), + envelope.payloadHash())) { + throw new BizException(ResultCode.BAD_REQUEST, "payloadHash 与 IAM 安全事件负载不一致"); + } + // 表内只保存固定 64 位摘要;信封传输契约仍保留 sha256: 前缀。 + return envelope.payloadHash().substring("sha256:".length()).toLowerCase(Locale.ROOT); + } + + private ProjectionInput input(EventEnvelope envelope, String payloadHash) { try { return switch (envelope.eventType()) { case EventTypes.IAM_USER_STATUS_CHANGED -> { - var value = objectMapper.treeToValue(envelope.payload(), IamSecurityEventsV1.UserStatusChanged.class); + var value = objectMapper.treeToValue(envelope.payload(), + IamSecurityEventsV1.UserStatusChanged.class); requireIdentity(envelope, "USER", value.authUserId(), value.tenantId()); - requirePayload(value.status(), value.changedAt(), value.permissionVersion(), envelope.aggregateVersion()); + requirePayload(value.status(), value.changedAt(), value.permissionVersion(), + envelope.aggregateVersion()); + requireStatus(value.status()); yield new ProjectionInput("USER", String.valueOf(value.authUserId()), value.tenantId(), - value.status(), value.permissionVersion(), envelope.aggregateVersion(), envelope.payloadHash(), - envelope.eventId(), value.changedAt()); + value.status(), value.permissionVersion(), envelope.aggregateVersion(), + payloadHash, envelope.eventId(), value.changedAt()); } case EventTypes.IAM_TENANT_STATUS_CHANGED -> { - var value = objectMapper.treeToValue(envelope.payload(), IamSecurityEventsV1.TenantStatusChanged.class); + var value = objectMapper.treeToValue(envelope.payload(), + IamSecurityEventsV1.TenantStatusChanged.class); requireIdentity(envelope, "TENANT", value.tenantId(), value.tenantId()); - requirePayload(value.status(), value.changedAt(), envelope.aggregateVersion(), envelope.aggregateVersion()); + requirePayload(value.status(), value.changedAt(), envelope.aggregateVersion(), + envelope.aggregateVersion()); + requireStatus(value.status()); yield new ProjectionInput("TENANT", String.valueOf(value.tenantId()), value.tenantId(), - value.status(), null, envelope.aggregateVersion(), envelope.payloadHash(), envelope.eventId(), - value.changedAt()); + value.status(), null, envelope.aggregateVersion(), payloadHash, + envelope.eventId(), value.changedAt()); } case EventTypes.IAM_PERMISSION_VERSION_CHANGED -> { - var value = objectMapper.treeToValue(envelope.payload(), IamSecurityEventsV1.PermissionVersionChanged.class); + var value = objectMapper.treeToValue(envelope.payload(), + IamSecurityEventsV1.PermissionVersionChanged.class); requireIdentity(envelope, "AUTHORIZATION", value.authUserId(), value.tenantId()); - requirePayload(value.appCode(), value.changedAt(), value.permissionVersion(), envelope.aggregateVersion()); + requirePayload(value.appCode(), value.changedAt(), value.permissionVersion(), + envelope.aggregateVersion()); yield new ProjectionInput("AUTHORIZATION", String.valueOf(value.authUserId()), value.tenantId(), - null, value.permissionVersion(), envelope.aggregateVersion(), envelope.payloadHash(), + null, value.permissionVersion(), envelope.aggregateVersion(), payloadHash, envelope.eventId(), value.changedAt()); } default -> throw new IllegalArgumentException("unsupported type"); @@ -111,7 +135,8 @@ public class SecurityProjectionService { } } - private static void requireIdentity(EventEnvelope envelope, String aggregateType, long aggregateId, long tenantId) { + private static void requireIdentity(EventEnvelope envelope, String aggregateType, + long aggregateId, long tenantId) { if (!aggregateType.equals(envelope.aggregateType()) || !String.valueOf(aggregateId).equals(envelope.aggregateId()) || !String.valueOf(tenantId).equals(envelope.tenantId())) { @@ -119,28 +144,38 @@ public class SecurityProjectionService { } } - private static void requirePayload(String requiredText, Instant changedAt, long payloadVersion, long aggregateVersion) { - if (requiredText == null || requiredText.isBlank() || changedAt == null || payloadVersion != aggregateVersion) { + private static void requirePayload(String requiredText, Instant changedAt, + long payloadVersion, long aggregateVersion) { + if (requiredText == null || requiredText.isBlank() || changedAt == null + || payloadVersion != aggregateVersion) { throw new BizException(ResultCode.CONFLICT, "IAM 安全事件 payload 版本或必填字段无效"); } } + private static void requireStatus(String status) { + if (!"ACTIVE".equals(status) && !"DISABLED".equals(status)) { + throw new BizException(ResultCode.BAD_REQUEST, "IAM 安全状态仅允许 ACTIVE 或 DISABLED"); + } + } + private ProjectionRow findForUpdate(String type, String id) { - List rows = jdbc.query("SELECT aggregate_version,payload_hash FROM overseas_security_projection " + List rows = jdbc.query( + "SELECT aggregate_version,payload_hash FROM ll_security_projection " + "WHERE aggregate_type=? AND aggregate_id=? FOR UPDATE", (rs, row) -> new ProjectionRow(rs.getLong(1), rs.getString(2)), type, id); return rows.isEmpty() ? null : rows.get(0); } private void upsert(ProjectionInput value) { - jdbc.update("INSERT INTO overseas_security_projection(aggregate_type,aggregate_id,tenant_id,security_status," + jdbc.update("INSERT INTO ll_security_projection(aggregate_type,aggregate_id,tenant_id,security_status," + "permission_version,aggregate_version,payload_hash,last_event_id,changed_at,updated_at) " + "VALUES (?,?,?,?,?,?,?,?,?,NOW(3)) ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id)," + "security_status=VALUES(security_status),permission_version=VALUES(permission_version)," + "aggregate_version=VALUES(aggregate_version),payload_hash=VALUES(payload_hash)," + "last_event_id=VALUES(last_event_id),changed_at=VALUES(changed_at),updated_at=NOW(3)", - value.aggregateType(), value.aggregateId(), value.tenantId(), value.status(), value.permissionVersion(), - value.aggregateVersion(), value.payloadHash(), value.eventId(), value.changedAt()); + value.aggregateType(), value.aggregateId(), value.tenantId(), value.status(), + value.permissionVersion(), value.aggregateVersion(), value.payloadHash(), value.eventId(), + value.changedAt()); } private record InboxRow(String eventType, String payloadHash, String status) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java new file mode 100644 index 000000000..b23605379 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.security.service; + +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.FailureReason; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationResponse; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class LastlegSecurityValidationService { + private final JdbcTemplate jdbc; + + public LastlegSecurityValidationService(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public ValidationResponse validate(ValidationRequest request) { + String userStatus = status("USER", String.valueOf(request.userId()), request.tenantId()); + if (userStatus == null) { + return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); + } + if (!"ACTIVE".equals(userStatus)) { + return ValidationResponse.rejected(FailureReason.USER_DISABLED); + } + if (request.tenantId() > 0) { + String tenantStatus = status("TENANT", String.valueOf(request.tenantId()), request.tenantId()); + if (tenantStatus == null) { + return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); + } + if (!"ACTIVE".equals(tenantStatus)) { + return ValidationResponse.rejected(FailureReason.TENANT_DISABLED); + } + } + List projectedVersions = jdbc.query( + "SELECT MAX(permission_version) FROM ll_security_projection " + + "WHERE aggregate_id=? AND tenant_id=? " + + "AND aggregate_type IN ('USER','AUTHORIZATION') HAVING COUNT(permission_version)>0", + (rs, row) -> rs.getLong(1), String.valueOf(request.userId()), request.tenantId()); + if (projectedVersions.isEmpty()) { + return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); + } + long projectedVersion = projectedVersions.get(0); + if (request.permissionVersion() < projectedVersion) { + return ValidationResponse.rejected(FailureReason.PERMISSION_VERSION_STALE); + } + if (request.permissionVersion() > projectedVersion) { + return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_NOT_READY); + } + return ValidationResponse.accepted(); + } + + private String status(String type, String id, long tenantId) { + List values = jdbc.query("SELECT security_status FROM ll_security_projection " + + "WHERE aggregate_type=? AND aggregate_id=? AND tenant_id=?", + (rs, row) -> rs.getString(1), type, id, tenantId); + return values.isEmpty() ? null : values.get(0); + } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/ProjectionMonotonicity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java similarity index 82% rename from overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/ProjectionMonotonicity.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java index 958e5e44b..4085bc4de 100644 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/ProjectionMonotonicity.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java @@ -1,12 +1,12 @@ -package com.baiyue.overseassecurity.service; +package com.baiyue.lastleg.security.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import java.util.Objects; -final class ProjectionMonotonicity { - private ProjectionMonotonicity() { } +final class SecurityProjectionMonotonicity { + private SecurityProjectionMonotonicity() { } static Decision decide(Long currentVersion, String currentHash, long incomingVersion, String incomingHash) { if (currentVersion == null || incomingVersion > currentVersion) return Decision.APPLY; diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 3699c27ba..9a76d798a 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -70,7 +70,7 @@ baiyue: lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30} security: - internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} + internal-service-token: ${INTERNAL_SERVICE_TOKEN:} jwt-mode: jwks session-mode: claims issuer: ${JWT_ISSUER:auth-service} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 893cea457..44d82c7d3 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1624,3 +1624,42 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_operation ( UNIQUE KEY uk_ll_local_looseboard_operation_key(customer_id,order_id,idempotency_key), KEY idx_ll_local_looseboard_operation_order(customer_id,order_id,created_at) ) ENGINE=InnoDB COMMENT='本土散板订单幂等操作账'; + +CREATE TABLE IF NOT EXISTS ll_security_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '跨区安全事件ID', + event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型', + payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256哈希', + payload_json JSON NOT NULL COMMENT '冻结事件载荷JSON', + status VARCHAR(16) NOT NULL COMMENT '消费状态:RECEIVED或PROCESSED', + result VARCHAR(32) NULL COMMENT '处理结果:APPLIED、STALE、DUPLICATE或DUPLICATE_VERSION', + received_at DATETIME(3) NOT NULL COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + PRIMARY KEY (event_id), + KEY idx_security_inbox_type_time (event_type,received_at) +) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者收件箱'; + +CREATE TABLE IF NOT EXISTS ll_security_projection ( + aggregate_type VARCHAR(32) NOT NULL COMMENT '聚合类型:USER、TENANT或AUTHORIZATION', + aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID', + tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID', + security_status VARCHAR(24) NULL COMMENT '当前安全状态:ACTIVE或DISABLED', + permission_version BIGINT NULL COMMENT '当前单调权限版本', + aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本', + payload_hash CHAR(64) NOT NULL COMMENT '当前版本载荷哈希', + last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用的事件ID', + changed_at DATETIME(3) NOT NULL COMMENT '国内IAM事实发生时间', + updated_at DATETIME(3) NOT NULL COMMENT '投影更新时间', + PRIMARY KEY (aggregate_type,aggregate_id), + KEY idx_security_projection_tenant (tenant_id), + KEY idx_security_projection_permission (aggregate_id,permission_version) +) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; + +-- 开发基线固定管理员;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。 +INSERT IGNORE INTO ll_security_projection ( + aggregate_type,aggregate_id,tenant_id,security_status,permission_version,aggregate_version, + payload_hash,last_event_id,changed_at,updated_at +) VALUES + ('USER','1000001',0,'ACTIVE',0,0,SHA2('lastleg-baseline:platform_admin:USER:0',256), + 'baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'), + ('AUTHORIZATION','1000001',0,NULL,0,0,SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0',256), + 'baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java index 49b0a41a3..400699222 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java @@ -9,4 +9,10 @@ class InternalServiceTokenGuardTest { assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify("wrong")).getStatusCode().value()); assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify(null)).getStatusCode().value()); } + + @Test void missingServerConfigurationFailsClosed() { + InternalServiceTokenGuard guard = new InternalServiceTokenGuard(""); + assertEquals(503, assertThrows(ResponseStatusException.class, + () -> guard.verify("any-token")).getStatusCode().value()); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java new file mode 100644 index 000000000..a004dd8d4 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java @@ -0,0 +1,75 @@ +package com.baiyue.lastleg.security; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class LastlegSecurityContractTest { + @Test void securityProjectionLivesInLastlegDatabaseWithReliableInbox() throws Exception { + String baseline = read("lastleg-service/src/main/resources/db/lastleg.sql"); + String migration = read("deploy/sql/海外区域统一增量-MySQL8.sql"); + String authSeed = read("auth/src/main/resources/db/phase1-admin-accounts.sql"); + String service = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java"); + String validation = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java"); + String validationDto = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java"); + String mirror = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java"); + assertThat(baseline).contains("CREATE TABLE IF NOT EXISTS ll_security_event_inbox", + "PRIMARY KEY (event_id)", "payload_hash CHAR(64)", + "CREATE TABLE IF NOT EXISTS ll_security_projection", + "PRIMARY KEY (aggregate_type,aggregate_id)", "aggregate_version BIGINT", + "permission_version BIGINT"); + for (String sql : new String[]{baseline, migration}) { + assertThat(sql).contains("('USER','1000001',0,'ACTIVE',0,0", + "('AUTHORIZATION','1000001',0,NULL,0,0", + "baseline-platform-admin-user-v0", + "baseline-platform-admin-authorization-v0") + .doesNotContain("baseline-merchant-admin"); + } + assertThat(authSeed).contains("(1000001, 'platform_admin'", "default_tenant_id", + "default_app_code"); + assertThat(migration).contains("USE baiyue_lastleg;", "table_schema='baiyue_lastleg'") + .doesNotContain("CREATE DATABASE IF NOT EXISTS baiyue_overseas_security", + "USE baiyue_overseas_security;", "CREATE TABLE IF NOT EXISTS overseas_security_event_inbox", + "CREATE TABLE IF NOT EXISTS overseas_security_projection"); + assertThat(service).contains("SecurityProjectionMonotonicity.decide", "Decision.APPLY"); + assertThat(service).contains("EventPayloadHash.matches", "validatedStorageHash") + .doesNotContain("LoadingCompletedConsumer"); + assertThat(mirror).contains("TransactionPhase.AFTER_COMMIT", "Redis 镜像写入失败"); + assertThat(validation).contains("SELECT MAX(permission_version)", "tenant_id=?", + "SECURITY_PROJECTION_MISSING", "SECURITY_PROJECTION_NOT_READY", "USER_DISABLED", + "TENANT_DISABLED", "PERMISSION_VERSION_STALE"); + assertThat(validationDto).contains("Long userId", "Long tenantId", "Long permissionVersion") + .doesNotContain("Long tokenVersion"); + } + + @Test void genericLastlegEventEndpointWhitelistsBusinessEventsOnly() throws Exception { + String controller = read("lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java"); + assertThat(controller).contains("FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED", + "FIRST_LEG_LOADING_PLAN_COMPLETED", "Lastleg 不消费该事件类型") + .doesNotContain("IAM_USER_STATUS_CHANGED", "IAM_TENANT_STATUS_CHANGED", + "IAM_PERMISSION_VERSION_CHANGED"); + } + + @Test void internalControllerUsesLastlegOwnedPathsAndExistingTokenGuard() throws Exception { + String controller = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java"); + String guard = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java"); + String application = read("lastleg-service/src/main/resources/application.yml"); + assertThat(controller).contains("@RequestMapping(\"/internal/lastleg/security/v1\")", + "@PostMapping(\"/events\")", "@PostMapping(\"/validate\")", + "com.baiyue.lastleg.security.InternalServiceTokenGuard") + .doesNotContain("/internal/overseas-security/v1"); + assertThat(guard).contains("MessageDigest.isEqual", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"); + assertThat(application).contains("internal-service-token: ${INTERNAL_SERVICE_TOKEN:}") + .doesNotContain("phase1-local-internal-token"); + } + + private static String read(String relative) throws Exception { + Path path = Path.of(relative); + if (!Files.exists(path)) path = Path.of("..").resolve(relative); + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java new file mode 100644 index 000000000..27a8de243 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java @@ -0,0 +1,49 @@ +package com.baiyue.lastleg.security; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +class SecurityProjectionArchitectureTest { + private static final String LASTLEG = "com.baiyue.lastleg.."; + private static final String CONTROLLER = "com.baiyue.lastleg.security.controller.."; + private static final String DTO = "com.baiyue.lastleg.security.dto.."; + private static final String SERVICE = "com.baiyue.lastleg.security.service.."; + + private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS) + .importPackages("com.baiyue.lastleg"); + + @Test + void projectionInternalsDoNotDependOnLastlegBusinessSlices() { + classes().that().resideInAnyPackage(CONTROLLER, DTO, SERVICE) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(LASTLEG) + .or(resideInAPackage(CONTROLLER)) + .or(resideInAPackage(DTO)) + .or(resideInAPackage(SERVICE)) + .or(resideInAPackage("com.baiyue.lastleg.security"))) + .check(PRODUCTION_CLASSES); + } + + @Test + void businessSlicesDoNotDependOnProjectionInternals() { + noClasses().that().resideOutsideOfPackages(CONTROLLER, DTO, SERVICE) + .should().dependOnClassesThat().resideInAnyPackage(CONTROLLER, DTO, SERVICE) + .check(PRODUCTION_CLASSES); + } + + @Test + void dtoAndServicesStayIndependentFromInternalWebAdapter() { + noClasses().that().resideInAnyPackage(DTO, SERVICE) + .should().dependOnClassesThat().resideInAPackage(CONTROLLER) + .check(PRODUCTION_CLASSES); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java new file mode 100644 index 000000000..39d78d387 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.security.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.IamSecurityEventsV1; +import com.baiyue.common.event.RegionCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LastlegSecurityProjectionTransportTest { + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + + @Test + void validatesEnvelopeHashAndStoresTheSixtyFourCharacterDigest() { + EventEnvelope envelope = envelope(); + + String stored = LastlegSecurityProjectionService.validatedStorageHash(envelope); + + assertThat(stored).hasSize(64).doesNotStartWith("sha256:"); + assertThat(envelope.payloadHash()).isEqualTo("sha256:" + stored); + } + + @Test + void rejectsTamperedPayloadBeforeInboxMutation() { + EventEnvelope source = envelope(); + EventEnvelope tampered = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), + source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(), + source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), + source.traceId(), source.correlationId(), source.causationId(), source.payload(), + "sha256:" + "0".repeat(64)); + + assertThatThrownBy(() -> LastlegSecurityProjectionService.validatedStorageHash(tampered)) + .isInstanceOf(BizException.class).hasMessageContaining("payloadHash"); + } + + private EventEnvelope envelope() { + var value = new IamSecurityEventsV1.PermissionVersionChanged( + 101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z")); + var payload = objectMapper.valueToTree(value); + return new EventEnvelope("00000000-0000-4000-8000-000000000091", + EventTypes.IAM_PERMISSION_VERSION_CHANGED, IamSecurityEventsV1.VERSION, + Instant.parse("2026-08-25T07:00:00Z"), RegionCode.CN, RegionCode.OVERSEAS, "0", + "AUTHORIZATION", "101", 3L, null, "trace-12345678", "correlation-12345678", + null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java new file mode 100644 index 000000000..5b934e0c6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java @@ -0,0 +1,67 @@ +package com.baiyue.lastleg.security.service; + +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.FailureReason; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +class LastlegSecurityValidationServiceTest { + private final JdbcTemplate jdbc = mock(JdbcTemplate.class); + private final LastlegSecurityValidationService service = new LastlegSecurityValidationService(jdbc); + + @Test void disabledUserFailsClosed() { + doReturn(List.of("DISABLED")).when(jdbc) + .query(anyString(), any(RowMapper.class), any(Object[].class)); + assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason()) + .isEqualTo(FailureReason.USER_DISABLED); + } + + @Test void disabledCustomerFailsClosed() { + doReturn(List.of("ACTIVE"), List.of("DISABLED")).when(jdbc) + .query(anyString(), any(RowMapper.class), any(Object[].class)); + assertThat(service.validate(new ValidationRequest(42L, 100L, 8L)).failureReason()) + .isEqualTo(FailureReason.TENANT_DISABLED); + } + + @Test void missingUserProjectionFailsClosed() { + doReturn(List.of()).when(jdbc) + .query(anyString(), any(RowMapper.class), any(Object[].class)); + assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason()) + .isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING); + } + + @Test void customerProjectionIsMandatoryButPlatformTenantDoesNotNeedOne() { + doReturn(List.of("ACTIVE"), List.of(), List.of("ACTIVE"), List.of(8L)).when(jdbc) + .query(anyString(), any(RowMapper.class), any(Object[].class)); + assertThat(service.validate(new ValidationRequest(42L, 100L, 8L)).failureReason()) + .isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING); + assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).valid()).isTrue(); + } + + @Test void permissionVersionMustExactlyMatchReliableProjection() { + doReturn(List.of("ACTIVE"), List.of(9L), List.of("ACTIVE"), List.of(9L), + List.of("ACTIVE"), List.of(9L)).when(jdbc) + .query(anyString(), any(RowMapper.class), any(Object[].class)); + assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason()) + .isEqualTo(FailureReason.PERMISSION_VERSION_STALE); + assertThat(service.validate(new ValidationRequest(42L, 0L, 10L)).failureReason()) + .isEqualTo(FailureReason.SECURITY_PROJECTION_NOT_READY); + assertThat(service.validate(new ValidationRequest(42L, 0L, 9L)).valid()).isTrue(); + } + + @Test void missingPermissionProjectionFailsClosed() { + doReturn(List.of("ACTIVE"), List.of()).when(jdbc) + .query(anyString(), any(RowMapper.class), any(Object[].class)); + assertThat(service.validate(new ValidationRequest(42L, 0L, 9L)).failureReason()) + .isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicityTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicityTest.java new file mode 100644 index 000000000..b385e2aea --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicityTest.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.security.service; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SecurityProjectionMonotonicityTest { + @Test void appliesFirstAndNewerVersions() { + assertThat(SecurityProjectionMonotonicity.decide(null, null, 1, "hash-1")) + .isEqualTo(SecurityProjectionMonotonicity.Decision.APPLY); + assertThat(SecurityProjectionMonotonicity.decide(1L, "hash-1", 2, "hash-2")) + .isEqualTo(SecurityProjectionMonotonicity.Decision.APPLY); + } + + @Test void ignoresOlderAndSameHashVersions() { + assertThat(SecurityProjectionMonotonicity.decide(3L, "hash-3", 2, "hash-2")) + .isEqualTo(SecurityProjectionMonotonicity.Decision.STALE); + assertThat(SecurityProjectionMonotonicity.decide(3L, "hash-3", 3, "hash-3")) + .isEqualTo(SecurityProjectionMonotonicity.Decision.DUPLICATE_VERSION); + } + + @Test void rejectsSameVersionWithDifferentHash() { + assertThatThrownBy(() -> SecurityProjectionMonotonicity.decide(3L, "hash-a", 3, "hash-b")) + .isInstanceOf(BizException.class).hasMessageContaining("payloadHash 冲突"); + } +} diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json index cc5660ecb..2966a26c2 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -23,6 +23,7 @@ "relabel-transfer-discard->inventory", "relabel-transfer-discard->shared-technical", "relabel-transfer-discard->tailleg-handoff", + "security-projection->shared-technical", "tailleg-handoff->dropship", "tailleg-handoff->inbound-pda", "tailleg-handoff->inventory", @@ -35,7 +36,13 @@ "com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleRecord": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java", "com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java", "com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java", - "com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java" + "com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java", + "com.baiyue.lastleg.security.controller.LastlegSecurityController": "src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java", + "com.baiyue.lastleg.security.dto.LastlegSecurityDtos": "src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java", + "com.baiyue.lastleg.security.service.LastlegSecurityProjectionRedisMirror": "src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java", + "com.baiyue.lastleg.security.service.LastlegSecurityProjectionService": "src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java", + "com.baiyue.lastleg.security.service.LastlegSecurityValidationService": "src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java", + "com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity": "src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java" }, "applicationDependencyRule": { "packagePrefix": "com.baiyue.lastleg.masterdata.vehicle.application", @@ -49,6 +56,10 @@ ] }, "sliceSpecificRules": { + "security-projection": { + "allowedOutgoing": ["shared-technical"], + "allowedIncoming": [] + }, "overseas-master-data": { "allowedOutgoing": ["shared-technical"], "allowedIncoming": ["procurement-trucking-looseboard"] diff --git a/overseas-security-service/AGENTS.md b/overseas-security-service/AGENTS.md deleted file mode 100644 index 01cb5953f..000000000 --- a/overseas-security-service/AGENTS.md +++ /dev/null @@ -1,14 +0,0 @@ -# Overseas Security Service 模块指引 - -## 职责边界 - -- 消费 IAM 三类跨区安全事件,维护海外可靠 Inbox 与单调安全投影,并为海外 Gateway 提供内部实时校验。 -- MySQL 是恢复源;Redis 仅为可重建镜像,不能成为授权真相或在失败时默认放行。 -- 不签发 Token、不保存 raw Token/凭据、不承载 Lastleg 业务,也不解释 Event Bridge 的重试状态。 - -## 入口 - -- 内部事件与校验:`controller/OverseasSecurityController.java` -- 幂等、单调投影:`service/SecurityProjectionService.java` -- Token 校验:`service/OverseasSecurityValidationService.java` -- SQL:`src/main/resources/db/overseas-security.sql` diff --git a/overseas-security-service/pom.xml b/overseas-security-service/pom.xml deleted file mode 100644 index a8c5cd7fd..000000000 --- a/overseas-security-service/pom.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - 4.0.0 - com.baiyue.firstlegbaiyue-firstleg-platform - 1.0.0-SNAPSHOT../pom.xml - overseas-security-service - - com.baiyue.sharedbaiyue-common-core${project.version} - com.baiyue.sharedbaiyue-common-web${project.version} - com.baiyue.sharedbaiyue-common-openapi${project.version} - com.baiyue.sharedcommon-event-contract${project.version} - org.springframework.bootspring-boot-starter-jdbc - org.springframework.bootspring-boot-starter-data-redis - com.mysqlmysql-connector-jruntime - com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery - com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config - org.springframework.bootspring-boot-starter-actuator - org.springframework.bootspring-boot-starter-testtest - - org.springframework.bootspring-boot-maven-plugin - diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/OverseasSecurityApplication.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/OverseasSecurityApplication.java deleted file mode 100644 index cfe7c82a6..000000000 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/OverseasSecurityApplication.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.baiyue.overseassecurity; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class OverseasSecurityApplication { - public static void main(String[] args) { - SpringApplication.run(OverseasSecurityApplication.class, args); - } -} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/security/InternalServiceTokenGuard.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/security/InternalServiceTokenGuard.java deleted file mode 100644 index 1a6aa6b7c..000000000 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/security/InternalServiceTokenGuard.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.baiyue.overseassecurity.security; - -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import jakarta.annotation.PostConstruct; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; - -@Component -public class InternalServiceTokenGuard { - @Value("${baiyue.security.internal-service-token:}") private String configuredToken; - private byte[] expected; - - @PostConstruct void initialize() { - expected = configuredToken == null ? new byte[0] : configuredToken.getBytes(StandardCharsets.UTF_8); - if (expected.length < 32) throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); - } - - public void verify(String supplied) { - byte[] actual = supplied == null ? new byte[0] : supplied.getBytes(StandardCharsets.UTF_8); - if (!MessageDigest.isEqual(expected, actual)) throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效"); - } -} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java deleted file mode 100644 index 4b7ee66cf..000000000 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.baiyue.overseassecurity.service; - -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.FailureReason; -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationRequest; -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationResponse; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -public class OverseasSecurityValidationService { - private final JdbcTemplate jdbc; - - public OverseasSecurityValidationService(JdbcTemplate jdbc) { - this.jdbc = jdbc; - } - - public ValidationResponse validate(ValidationRequest request) { - String userStatus = status("USER", String.valueOf(request.userId())); - if (userStatus != null && !"ACTIVE".equals(userStatus)) { - return ValidationResponse.rejected(FailureReason.USER_DISABLED); - } - String tenantStatus = status("TENANT", String.valueOf(request.tenantId())); - if (tenantStatus != null && !"ACTIVE".equals(tenantStatus)) { - return ValidationResponse.rejected(FailureReason.TENANT_DISABLED); - } - Long projectedVersion = jdbc.queryForObject( - "SELECT MAX(permission_version) FROM overseas_security_projection " - + "WHERE aggregate_id=? AND aggregate_type IN ('USER','AUTHORIZATION')", - Long.class, String.valueOf(request.userId())); - if (projectedVersion != null && request.permissionVersion() < projectedVersion) { - return ValidationResponse.rejected(FailureReason.PERMISSION_VERSION_STALE); - } - return ValidationResponse.accepted(); - } - - private String status(String type, String id) { - List values = jdbc.query("SELECT security_status FROM overseas_security_projection " - + "WHERE aggregate_type=? AND aggregate_id=?", - (rs, row) -> rs.getString(1), type, id); - return values.isEmpty() ? null : values.get(0); - } -} diff --git a/overseas-security-service/src/main/resources/application.yml b/overseas-security-service/src/main/resources/application.yml deleted file mode 100644 index 6817568d2..000000000 --- a/overseas-security-service/src/main/resources/application.yml +++ /dev/null @@ -1,28 +0,0 @@ -server: - port: ${OVERSEAS_SECURITY_PORT:9307} -spring: - application.name: overseas-security-service - config.import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${OVERSEAS_SECURITY_DB_URL} - username: ${OVERSEAS_SECURITY_DB_USERNAME} - password: ${OVERSEAS_SECURITY_DB_PASSWORD} - data.redis: - host: ${REDIS_HOST:127.0.0.1} - port: ${REDIS_PORT:6379} - password: ${REDIS_PASSWORD:} - database: ${REDIS_DATABASE:0} - cloud.nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} - discovery: - group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:prod-overseas} -baiyue.security: - internal-service-token: ${INTERNAL_SERVICE_TOKEN} - permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} -management.endpoints.web.exposure.include: health,info,prometheus diff --git a/overseas-security-service/src/main/resources/db/overseas-security.sql b/overseas-security-service/src/main/resources/db/overseas-security.sql deleted file mode 100644 index 7553fba93..000000000 --- a/overseas-security-service/src/main/resources/db/overseas-security.sql +++ /dev/null @@ -1,31 +0,0 @@ -CREATE DATABASE IF NOT EXISTS baiyue_overseas_security DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; -USE baiyue_overseas_security; - -CREATE TABLE IF NOT EXISTS overseas_security_event_inbox ( - event_id VARCHAR(64) NOT NULL COMMENT '跨区事件ID', - event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型', - payload_hash CHAR(64) NOT NULL COMMENT '规范化Payload SHA-256', - payload_json JSON NOT NULL COMMENT '冻结事件Payload', - status VARCHAR(16) NOT NULL COMMENT 'RECEIVED/PROCESSED', - result VARCHAR(32) NULL COMMENT 'APPLIED/STALE/DUPLICATE_VERSION', - received_at DATETIME(3) NOT NULL, - processed_at DATETIME(3) NULL, - PRIMARY KEY (event_id), - KEY idx_security_inbox_type_time (event_type, received_at) -) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者Inbox'; - -CREATE TABLE IF NOT EXISTS overseas_security_projection ( - aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER/TENANT/AUTHORIZATION', - aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID', - tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID', - security_status VARCHAR(24) NULL COMMENT 'ACTIVE/DISABLED等当前状态', - permission_version BIGINT NULL COMMENT '当前单调权限版本', - aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本', - payload_hash CHAR(64) NOT NULL COMMENT '当前版本Payload哈希', - last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用事件ID', - changed_at DATETIME(3) NOT NULL COMMENT '国内IAM事实发生时间', - updated_at DATETIME(3) NOT NULL, - PRIMARY KEY (aggregate_type, aggregate_id), - KEY idx_security_projection_tenant (tenant_id), - KEY idx_security_projection_permission (aggregate_id, permission_version) -) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; diff --git a/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml b/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml deleted file mode 100644 index e228e9386..000000000 --- a/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml +++ /dev/null @@ -1,26 +0,0 @@ -openapi: 3.0.3 -info: - title: Overseas Security Projection Internal API - version: 1.0.0 -paths: - /internal/overseas-security/v1/events: - post: - summary: Consume one IAM security EventEnvelope - security: [{ internalServiceToken: [] }] - responses: - '200': - description: "Acknowledgement contains eventId, result and receivedAt" - /internal/overseas-security/v1/validate: - post: - summary: Validate locally verified overseas JWT claim versions - description: Accepts userId, tenantId, tokenVersion and permissionVersion; raw tokens and credentials are forbidden. - security: [{ internalServiceToken: [] }] - responses: - '200': - description: "valid plus optional USER_DISABLED, TENANT_DISABLED or PERMISSION_VERSION_STALE reason" -components: - securitySchemes: - internalServiceToken: - type: apiKey - in: header - name: X-Internal-Service-Token diff --git a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/OverseasSecurityContractTest.java b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/OverseasSecurityContractTest.java deleted file mode 100644 index b626bb89b..000000000 --- a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/OverseasSecurityContractTest.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.baiyue.overseassecurity; - -import org.junit.jupiter.api.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; - -class OverseasSecurityContractTest { - @Test void sqlFreezesReliableInboxProjectionAndRedisRecoveryBoundary() throws Exception { - String sql = read("overseas-security-service/src/main/resources/db/overseas-security.sql"); - String service = read("overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java"); - String validation = read("overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java"); - String mirror = read("overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java"); - assertThat(sql).contains("PRIMARY KEY (event_id)", "payload_hash CHAR(64)", - "PRIMARY KEY (aggregate_type, aggregate_id)", "aggregate_version BIGINT", "permission_version BIGINT"); - assertThat(service).contains("ProjectionMonotonicity.decide", "Decision.APPLY"); - assertThat(mirror).contains("TransactionPhase.AFTER_COMMIT", "Redis 镜像写入失败"); - assertThat(validation).contains("SELECT MAX(permission_version)", "USER_DISABLED", - "TENANT_DISABLED", "PERMISSION_VERSION_STALE"); - } - - @Test void eventBridgeRoutesIamTypesWithoutInspectingTheirPayload() throws Exception { - String dispatcher = read("event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java"); - String client = read("event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java"); - String lastleg = read("lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java"); - assertThat(dispatcher).contains("IAM_USER_STATUS_CHANGED", "IAM_TENANT_STATUS_CHANGED", - "IAM_PERMISSION_VERSION_CHANGED", "securityProjectionEventClient.deliver(event)"); - assertThat(dispatcher).doesNotContain("IamSecurityEventsV1", "permissionVersion()", "status()"); - assertThat(client).contains("path(\"data\").path(\"eventId\")", "row.getEventId().equals") - .doesNotContain("IamSecurityEventsV1"); - assertThat(lastleg).contains("Lastleg 不消费该事件类型") - .doesNotContain("? shippingNodeConsumer.consume(event) : consumer.consume(event)"); - } - - @Test void internalOpenApiNeverAcceptsRawTokensOrCredentials() throws Exception { - String openapi = read("overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml"); - assertThat(openapi).contains("/internal/overseas-security/v1/events:", - "/internal/overseas-security/v1/validate:", "X-Internal-Service-Token", - "userId, tenantId, tokenVersion and permissionVersion"); - assertThat(openapi).doesNotContain("accessToken", "refreshToken", "password:", "Authorization"); - } - - private static String read(String relative) throws Exception { - Path path = Path.of(relative); - if (!Files.exists(path)) path = Path.of("..").resolve(relative); - return Files.readString(path, StandardCharsets.UTF_8); - } -} diff --git a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationServiceTest.java b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationServiceTest.java deleted file mode 100644 index f38834f99..000000000 --- a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationServiceTest.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.baiyue.overseassecurity.service; - -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.FailureReason; -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationRequest; -import org.junit.jupiter.api.Test; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowMapper; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class OverseasSecurityValidationServiceTest { - private final JdbcTemplate jdbc = mock(JdbcTemplate.class); - private final OverseasSecurityValidationService service = new OverseasSecurityValidationService(jdbc); - - @Test void disabledUserAndTenantFailClosed() { - when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))) - .thenReturn(List.of("DISABLED")); - assertThat(service.validate(new ValidationRequest(42L, 0L, 3L, 8L)).failureReason()) - .isEqualTo(FailureReason.USER_DISABLED); - } - - @Test void stalePermissionVersionFailsAndCurrentVersionPasses() { - when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))) - .thenReturn(List.of(), List.of()); - when(jdbc.queryForObject(anyString(), any(Class.class), any(Object[].class))).thenReturn(9L, 9L); - assertThat(service.validate(new ValidationRequest(42L, 0L, 3L, 8L)).failureReason()) - .isEqualTo(FailureReason.PERMISSION_VERSION_STALE); - assertThat(service.validate(new ValidationRequest(42L, 0L, 3L, 9L)).valid()).isTrue(); - } -} diff --git a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/ProjectionMonotonicityTest.java b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/ProjectionMonotonicityTest.java deleted file mode 100644 index 89560963c..000000000 --- a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/ProjectionMonotonicityTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.baiyue.overseassecurity.service; - -import com.baiyue.common.core.exception.BizException; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class ProjectionMonotonicityTest { - @Test void appliesFirstAndNewerVersions() { - assertThat(ProjectionMonotonicity.decide(null, null, 1, "hash-1")) - .isEqualTo(ProjectionMonotonicity.Decision.APPLY); - assertThat(ProjectionMonotonicity.decide(1L, "hash-1", 2, "hash-2")) - .isEqualTo(ProjectionMonotonicity.Decision.APPLY); - } - - @Test void ignoresOlderAndSameHashVersions() { - assertThat(ProjectionMonotonicity.decide(3L, "hash-3", 2, "hash-2")) - .isEqualTo(ProjectionMonotonicity.Decision.STALE); - assertThat(ProjectionMonotonicity.decide(3L, "hash-3", 3, "hash-3")) - .isEqualTo(ProjectionMonotonicity.Decision.DUPLICATE_VERSION); - } - - @Test void rejectsSameVersionWithDifferentHash() { - assertThatThrownBy(() -> ProjectionMonotonicity.decide(3L, "hash-a", 3, "hash-b")) - .isInstanceOf(BizException.class).hasMessageContaining("payloadHash 冲突"); - } -} diff --git a/pom.xml b/pom.xml index 2cd812fb5..20134475d 100644 --- a/pom.xml +++ b/pom.xml @@ -39,7 +39,6 @@ common-event-contract gateway gateway-overseas - overseas-security-service auth iam order diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 49f40a477..6bb27b5ea 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -350,7 +350,9 @@ export class AcceptanceRun { this.coverageGaps = [] this.context = {} this.token = '' + this.refreshToken = '' this.overseasToken = '' + this.overseasExpiresAt = 0 this.fatal = null } @@ -553,6 +555,30 @@ export class AcceptanceRun { this.modules.push({ name, startedAt, finishedAt: new Date().toISOString(), success: fatal === null && steps.every((s) => s.passed), passed: steps.filter((s) => s.passed).length, failed: steps.filter((s) => !s.passed).length, fatal }) } + async refreshSession(reason) { + const response = await this.request('POST', '/api/auth/refresh', + { refreshToken: this.refreshToken }, '') + const next = this.apiSuccess('authentication', '国内 refresh 签发新双区域会话', response, + `reason=${reason}`, { scenario: 'dual-token-refresh', deploymentBlocker: true }) + const valid = Boolean(next?.accessToken) && Boolean(next?.refreshToken) + && Boolean(next?.overseasAccessToken) && Number(next?.overseasExpiresIn) > 0 + this.assert('authentication', '刷新响应完整后原子替换国内与海外Token', response, valid, + `accessTokenPresent=${Boolean(next?.accessToken)}, refreshTokenPresent=${Boolean(next?.refreshToken)}, overseasTokenPresent=${Boolean(next?.overseasAccessToken)}, overseasExpiresIn=${next?.overseasExpiresIn ?? 'missing'}`, + { scenario: 'dual-token-refresh', deploymentBlocker: true }) + if (!valid) return false + const replacement = { + token: next.accessToken, + refreshToken: next.refreshToken, + overseasToken: next.overseasAccessToken, + overseasExpiresAt: Date.now() + Number(next.overseasExpiresIn) * 1000, + } + this.token = replacement.token + this.refreshToken = replacement.refreshToken + this.overseasToken = replacement.overseasToken + this.overseasExpiresAt = replacement.overseasExpiresAt + return true + } + async findAvailablePhone(module = 'customers') { const seed = BigInt(Date.now() % 10_000_000_000) for (let attempt = 0; attempt < 20; attempt += 1) { @@ -1159,7 +1185,10 @@ export class AcceptanceRun { if (!login?.accessToken) this.fatal = '平台管理员登录失败,未执行业务模块' else { this.token = login.accessToken + this.refreshToken = login.refreshToken || '' this.overseasToken = login.overseasAccessToken || '' + this.overseasExpiresAt = Number(login.overseasExpiresIn) > 0 + ? Date.now() + Number(login.overseasExpiresIn) * 1000 : 0 const requiresOverseas = this.options.modules.some((module) => module.startsWith('phase4-overseas-')) if (requiresOverseas) { this.assert('authentication', '登录响应包含有效海外访问令牌', loginResponse, @@ -1170,6 +1199,10 @@ export class AcceptanceRun { const handlers = { customers: () => this.runCustomers() } for (let moduleIndex = 0; moduleIndex < this.options.modules.length; moduleIndex += 1) { const module = this.options.modules[moduleIndex] + if (module.startsWith('phase4-overseas-') + && this.overseasExpiresAt > 0 && Date.now() + 120_000 >= this.overseasExpiresAt) { + await this.refreshSession(`海外模块 ${module} 执行前Token临近到期`) + } const fullFinanceChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'].every((name) => this.options.modules.includes(name)) if (module === 'finance' && fullFinanceChain) { this.modules.push({ name: module, startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), success: true, passed: 0, failed: 0, fatal: null, skipped: 'finance-full-chain owns the finance source locks' }) @@ -1233,6 +1266,19 @@ export class AcceptanceRun { } finally { if (restoreFixture) restoreFixture() } + const laterOverseasModule = this.options.modules.slice(moduleIndex + 1) + .some((name) => name.startsWith('phase4-overseas-')) + if (module === 'system' && laterOverseasModule) { + const refreshed = await this.refreshSession('system模块权限与角色变更完成') + if (refreshed) { + const probe = await this.requestOverseas('GET', + '/api/platform/v1/last-leg/orders?page=1&size=1') + this.assert('authentication', 'system后新admin海外Token只读探针可用', probe, + probe.http === 200 && String(probe.body?.code) === '200', + `HTTP=${probe.http}, code=${probe.body?.code ?? 'missing'}, requestId=${probe.requestId || 'missing'}, traceId=${probe.traceId || 'missing'}`, + { scenario: 'dual-token-refresh', deploymentBlocker: true }) + } + } } } const reportSurface = JSON.stringify({ @@ -1240,7 +1286,8 @@ export class AcceptanceRun { deploymentBlockers: this.deploymentBlockers, coverageGaps: this.coverageGaps, createdResources: this.createdResources, }) - const forbiddenValues = [this.options.password, this.token, this.overseasToken].filter((value) => typeof value === 'string' && value.length >= 8) + const forbiddenValues = [this.options.password, this.token, this.refreshToken, this.overseasToken] + .filter((value) => typeof value === 'string' && value.length >= 8) const sensitivePatterns = [ /"(?:password|newPassword|temporaryPassword|accessToken|refreshToken|authorization)"\s*:/i, /\bBearer\s+[A-Za-z0-9._~-]{16,}/i, diff --git a/scripts/admin-test-env-modules/customer-system-contract.test.mjs b/scripts/admin-test-env-modules/customer-system-contract.test.mjs index 64527c588..80f236ad0 100644 --- a/scripts/admin-test-env-modules/customer-system-contract.test.mjs +++ b/scripts/admin-test-env-modules/customer-system-contract.test.mjs @@ -30,6 +30,16 @@ test('系统验收覆盖后端四种权限资源类型并清理测试资源', () assert.match(system, /DELETE.*permissions\/\$\{additionalPermissionId\}/s) }) +test('system后刷新双Token且受限海外权限只接受403语义', () => { + assert.match(acceptance, /module === 'system' && laterOverseasModule/) + assert.match(acceptance, /refreshSession\('system模块权限与角色变更完成'\)/) + assert.match(acceptance, /system后新admin海外Token只读探针可用/) + assert.match(acceptance, /Date\.now\(\) \+ 120_000 >= this\.overseasExpiresAt/) + assert.match(system, /缺少canonical海外订单权限必须403/) + assert.match(system, /denied\.http === 403 \|\| \['403', 'A0403'\]\.includes/) + assert.doesNotMatch(system, /A0401[^\r\n]{0,120}(必须403|includes)/) +}) + test('客户验收覆盖客户维度操作日志列表和详情快照', () => { assert.match(acceptance, /audit\/logs\?accountId=\$\{tenantId\}&eventType=PLATFORM_TENANT_UPDATE&resourceType=TENANT&resourceId=\$\{tenantId\}/) assert.match(acceptance, /PLATFORM_TENANT_UPDATE/) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 7ebbd6ded..bc11e7d09 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -28,6 +28,13 @@ function Assert-Contains([string]$text, [string]$needle, [string]$scope) { } } +function Assert-NotContains([string]$text, [string]$needle, [string]$scope) { + $script:checked++ + if ($text.Contains($needle)) { + Add-Failure "$scope 仍残留: $needle" + } +} + function Assert-Matches([string]$text, [string]$pattern, [string]$scope) { $script:checked++ if ($text -notmatch $pattern) { @@ -156,14 +163,14 @@ foreach ($gatewayPath in @( Assert-Contains $overseasGatewayConfig 'uri: lb://lastleg-service' 'Overseas Gateway Lastleg 路由' Assert-Contains $overseasGatewayConfig 'uri: lb://warehouse-overseas-service' 'Overseas Gateway Warehouse 路由' -# 海外安全投影是独立部署单元,服务映射、海外编排和 Jenkins 构建入口必须同时存在。 +# 海外安全投影已并入 Lastleg,Gateway 校验和 Event Bridge 消费均指向同一海外服务。 $serviceMap = Read-RequiredText (Join-Path $backendRoot 'deploy/service-map.yml') $overseasCompose = Read-RequiredText (Join-Path $backendRoot 'deploy/docker-compose.overseas.yml') $serviceJenkinsfile = Read-RequiredText (Join-Path $backendRoot 'Jenkinsfile.service') -Assert-Contains $serviceMap 'overseas-security-service:' '服务部署映射' -Assert-Contains $overseasCompose 'overseas-security-service:' '海外 Compose' -Assert-Contains $overseasCompose '/internal/overseas-security/v1/events' '海外安全事件消费地址' -Assert-Contains $serviceJenkinsfile "'overseas-security-service'" '服务 Jenkins 构建入口' +Assert-Contains $serviceMap 'lastleg-service:' 'Lastleg 服务部署映射' +Assert-Contains $overseasCompose '/internal/lastleg/security/v1/events' 'Lastleg 安全事件消费地址' +Assert-Contains $overseasCompose 'LASTLEG_SECURITY_SERVICE_URL' 'Lastleg 安全校验地址' +Assert-NotContains $serviceJenkinsfile "'overseas-security-service'" '独立海外安全服务构建入口' # Maven 聚合声明的全部历史/当前模块必须在后端根指引中可定位。 [xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw @@ -238,11 +245,9 @@ $criticalPaths = @( 'baiyue-firstleg/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java', 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java', 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', - 'baiyue-firstleg/overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java', - 'baiyue-firstleg/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java', - 'baiyue-firstleg/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java', - 'baiyue-firstleg/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml', - 'baiyue-firstleg/overseas-security-service/src/main/resources/db/overseas-security.sql', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java', @@ -309,7 +314,6 @@ $indexedAgentFiles = @( (Join-Path $backendRoot 'common-security/AGENTS.md'), (Join-Path $backendRoot 'common-openapi/AGENTS.md'), (Join-Path $backendRoot 'warehouse-overseas-service/AGENTS.md'), - (Join-Path $backendRoot 'overseas-security-service/AGENTS.md'), (Join-Path $backendRoot 'event-bridge-service/AGENTS.md'), (Join-Path $backendRoot 'file/AGENTS.md'), (Join-Path $backendRoot 'common-event-contract/AGENTS.md') @@ -349,5 +353,5 @@ if ($failures.Count -gt 0) { exit 1 } -Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、Gateway 覆盖、PDA 注册页面与能力开关、Maven 模块、海外安全服务部署映射、关键入口和排除目录。" -ForegroundColor Green +Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、Gateway 覆盖、PDA 注册页面与能力开关、Maven 模块、Lastleg 海外安全投影映射、关键入口和排除目录。" -ForegroundColor Green exit 0 diff --git a/scripts/java-quality-summary.test.mjs b/scripts/java-quality-summary.test.mjs index 8e3831751..8744660a1 100644 --- a/scripts/java-quality-summary.test.mjs +++ b/scripts/java-quality-summary.test.mjs @@ -58,9 +58,9 @@ test('aggregates code modules, report presence and the OpenAPI service matrix', test('repository freezes the current topology and non-blocking aggregate summary wiring', async () => { const summary = await buildQualitySummary(repositoryRoot) - assert.deepEqual(summary.topology, { modules: 23, codeModules: 22, services: 16 }) - assert.equal(summary.openapi.documents, 27) - assert.equal(summary.openapi.coveredServices.length, 8) + assert.deepEqual(summary.topology, { modules: 22, codeModules: 21, services: 15 }) + assert.equal(summary.openapi.documents, 26) + assert.equal(summary.openapi.coveredServices.length, 7) assert.equal(summary.openapi.codeFirstServices.length, 8) const gitlab = (await readFile(path.join(repositoryRoot, '.gitlab-ci.yml'), 'utf8')).replaceAll('\r\n', '\n') diff --git a/scripts/java-topology-contract.test.mjs b/scripts/java-topology-contract.test.mjs index 6c094c6e3..31f96cf24 100644 --- a/scripts/java-topology-contract.test.mjs +++ b/scripts/java-topology-contract.test.mjs @@ -62,13 +62,13 @@ const parseCompose = (yaml) => { })) } -test('root POM freezes 23 modules and service-map freezes 16 executable services', async () => { +test('root POM freezes 22 modules and service-map freezes 15 executable services', async () => { const modules = parseRootModules(await read('pom.xml')) const services = parseServiceMap(await read('deploy/service-map.yml')) - assert.equal(modules.length, 23) - assert.equal(new Set(modules).size, 23) - assert.equal(services.size, 16) + assert.equal(modules.length, 22) + assert.equal(new Set(modules).size, 22) + assert.equal(services.size, 15) const bootModules = [] for (const module of modules) { diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index b1f89decb..d5c3efac4 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -8,18 +8,18 @@ const read = async (path) => (await readFile(new URL(`../${path}`, import.meta.u test('Jenkins pipelines recognize all overseas deployable modules', async () => { const controller = await read('Jenkinsfile') const service = await read('Jenkinsfile.service') - for (const module of ['gateway-overseas', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service']) { + for (const module of ['gateway-overseas', 'lastleg-service', 'warehouse-overseas-service']) { assert.match(controller, new RegExp(`'${module}'`)) assert.match(service, new RegExp(`'${module}'`)) } assert.match(controller, /'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS'/) assert.match(controller, /'lastleg-service': 'SERVICE_LASTLEG'/) assert.match(controller, /'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS'/) - assert.match(controller, /'overseas-security-service': 'SERVICE_OVERSEAS_SECURITY'/) assert.match(service, /gateway-overseas\/target\/gateway-overseas-1\.0\.0-SNAPSHOT\.jar/) assert.match(service, /lastleg-service\/target\/lastleg-service-1\.0\.0-SNAPSHOT\.jar/) assert.match(service, /warehouse-overseas-service\/target\/warehouse-overseas-service-1\.0\.0-SNAPSHOT\.jar/) - assert.match(service, /overseas-security-service\/target\/overseas-security-service-1\.0\.0-SNAPSHOT\.jar/) + assert.doesNotMatch(controller, /overseas-security-service|SERVICE_OVERSEAS_SECURITY/) + assert.doesNotMatch(service, /overseas-security-service/) assert.match(controller, /EVENT_BRIDGE_REGION/) assert.match(service, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{bridgeRegion\}/) assert.match(controller, /choices: \['BOTH', 'CN', 'OVERSEAS'\]/) @@ -32,12 +32,11 @@ test('service map freezes unique internal ports and moves event bridge away from assert.match(map, /^ gateway-overseas:[\s\S]*?port: 9300$/m) assert.match(map, /^ lastleg-service:[\s\S]*?port: 9303$/m) assert.match(map, /^ warehouse-overseas-service:[\s\S]*?port: 9305$/m) - assert.match(map, /^ overseas-security-service:[\s\S]*?port: 9307$/m) assert.match(map, /^ event-bridge-service:[\s\S]*?port: 9312$/m) assert.match(map, /^ gateway-overseas:[\s\S]*?regions: \[OVERSEAS\]$/m) assert.match(map, /^ lastleg-service:[\s\S]*?regions: \[OVERSEAS\]$/m) assert.match(map, /^ warehouse-overseas-service:[\s\S]*?regions: \[OVERSEAS\]$/m) - assert.match(map, /^ overseas-security-service:[\s\S]*?regions: \[OVERSEAS\]$/m) + assert.doesNotMatch(map, /overseas-security-service|9307/) assert.match(map, /^ event-bridge-service:[\s\S]*?regions: \[CN, OVERSEAS\]$/m) }) @@ -50,7 +49,6 @@ test('overseas compose is isolated from CN defaults and disables implicit schema assert.match(compose, /^ gateway-overseas:\s*$/m) assert.match(compose, /^ lastleg-service:\s*$/m) assert.match(compose, /^ warehouse-overseas-service:\s*$/m) - assert.match(compose, /^ overseas-security-service:\s*$/m) assert.match(compose, /^ event-bridge-service:\s*$/m) assert.match(compose, /EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312/) assert.match(compose, /INTERNAL_SERVICE_TOKEN: \$\{INTERNAL_SERVICE_TOKEN\}/) @@ -58,8 +56,9 @@ test('overseas compose is isolated from CN defaults and disables implicit schema assert.match(compose, /EVENT_BRIDGE_DB_URL: \$\{EVENT_BRIDGE_OVERSEAS_DB_URL\}/) assert.match(compose, /EVENT_BRIDGE_DB_USERNAME: \$\{EVENT_BRIDGE_OVERSEAS_DB_USERNAME\}/) assert.match(compose, /EVENT_BRIDGE_DB_PASSWORD: \$\{EVENT_BRIDGE_OVERSEAS_DB_PASSWORD\}/) - assert.match(compose, /OVERSEAS_SECURITY_DB_URL: \$\{OVERSEAS_SECURITY_DB_URL\}/) - assert.match(compose, /OVERSEAS_SECURITY_EVENT_CONSUMER_URL:/) + assert.match(compose, /LASTLEG_SECURITY_SERVICE_URL: \$\{LASTLEG_SECURITY_SERVICE_URL:-http:\/\/lastleg-service:9303\}/) + assert.match(compose, /LASTLEG_SECURITY_EVENT_CONSUMER_URL: \$\{LASTLEG_SECURITY_EVENT_CONSUMER_URL:-http:\/\/lastleg-service:9303\/internal\/lastleg\/security\/v1\/events\}/) + assert.doesNotMatch(compose, /overseas-security-service|9307|OVERSEAS_SECURITY_DB|OVERSEAS_SECURITY_SERVICE_URL|OVERSEAS_SECURITY_EVENT_CONSUMER_URL/) assert.match(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) assert.match(compose, /FIRSTLEG_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) assert.doesNotMatch(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:/) @@ -72,7 +71,11 @@ test('CN and overseas event bridges have explicit opposite-region consumers', as const cn = await read('config/nacos/cn/event-bridge-service.yml') const overseas = await read('config/nacos/overseas/event-bridge-service.yml') const firstleg = await read('config/nacos/cn/firstleg-service.yml') + const iam = await read('config/nacos/cn/iam-service.yml') + const iamApplication = await read('iam/src/main/resources/application.yml') + const iamClient = await read('iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java') const lastleg = await read('config/nacos/overseas/lastleg-service.yml') + const gatewayOverseas = await read('config/nacos/overseas/gateway-overseas.yml') const scripts = `${await read('deploy/scripts/deploy-built-service.sh')}\n${await read('deploy/scripts/deploy-services.sh')}` assert.match(cn, /EVENT_CONSUMER_URL:http:\/\/firstleg:9202\/internal\/firstleg\/v1\/container-events/) assert.match(overseas, /EVENT_CONSUMER_URL:http:\/\/lastleg-service:9303\/internal\/lastleg\/events/) @@ -80,9 +83,17 @@ test('CN and overseas event bridges have explicit opposite-region consumers', as assert.match(cnCompose, /aliases: \[event-bridge-cn\]/) assert.match(overseasCompose, /EVENT_CONSUMER_URL: \$\{EVENT_CONSUMER_URL:-http:\/\/lastleg-service:9303\/internal\/lastleg\/events\}/) assert.match(firstleg, /LASTLEG_EVENT_BRIDGE_URL/) + assert.match(iamClient, /url = "\$\{baiyue\.security-event\.event-bridge-url:\}"/) + assert.match(iam, /SECURITY_EVENT_BRIDGE_URL:http:\/\/event-bridge-overseas:9312/) + assert.match(iamApplication, /SECURITY_EVENT_BRIDGE_URL:http:\/\/event-bridge-overseas:9312/) + assert.match(cnCompose, /SECURITY_EVENT_BRIDGE_URL: \$\{SECURITY_EVENT_BRIDGE_URL:-http:\/\/event-bridge-overseas:9312\}/) + assert.match(overseasCompose, /aliases: \[event-bridge-overseas\]/) assert.match(firstleg, /LASTLEG_OUTBOX_RELAY_LEASE_SECONDS/) assert.match(lastleg, /CN_EVENT_BRIDGE_URL:\$\{FIRSTLEG_EVENT_BRIDGE_URL\}/) assert.match(lastleg, /WAREHOUSE_OVERSEAS_SERVICE_URL:http:\/\/warehouse-overseas-service:9305/) + assert.match(gatewayOverseas, /LASTLEG_SECURITY_SERVICE_URL:http:\/\/lastleg-service:9303/) + assert.match(overseas, /LASTLEG_SECURITY_EVENT_CONSUMER_URL:http:\/\/lastleg-service:9303\/internal\/lastleg\/security\/v1\/events/) + assert.doesNotMatch(`${gatewayOverseas}\n${overseas}`, /overseas-security-service|9307|OVERSEAS_SECURITY_/) assert.match(scripts, /FIRSTLEG_EVENT_BRIDGE_URL/) assert.match(scripts, /LASTLEG_EVENT_BRIDGE_URL/) assert.match(scripts, /docker-compose\.overseas\.yml/) diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index 6bfe64c05..03fdfdd11 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -44,10 +44,47 @@ test('entry accepts a separate overseas Gateway without exposing token state', ( assert.equal(options.gateway, 'http://localhost:9000') assert.equal(options.overseasGateway, 'http://localhost:9300') assert.match(source, /this\.overseasToken = login\.overseasAccessToken \|\| ''/) + assert.match(source, /this\.refreshToken = login\.refreshToken \|\| ''/) + assert.match(source, /this\.overseasExpiresAt = Number\(login\.overseasExpiresIn\)/) + assert.match(source, /async refreshSession\(reason\)/) + assert.match(source, /refreshToken: this\.refreshToken/) + assert.match(source, /const replacement = \{[\s\S]*token: next\.accessToken[\s\S]*refreshToken: next\.refreshToken[\s\S]*overseasToken: next\.overseasAccessToken/) + assert.match(source, /module === 'system' && laterOverseasModule/) + assert.match(source, /system后新admin海外Token只读探针可用/) + assert.match(source, /module\.startsWith\('phase4-overseas-'\)[\s\S]*Date\.now\(\) \+ 120_000 >= this\.overseasExpiresAt/) + assert.match(source, /海外模块 \$\{module\} 执行前Token临近到期/) assert.match(source, /async requestOverseas\(/) assert.match(source, /async requestOverseasBinary\(/) assert.match(source, /async requestOverseasMultipart\(/) - assert.match(source, /forbiddenValues = \[this\.options\.password, this\.token, this\.overseasToken\]/) + assert.match(source, /forbiddenValues = \[this\.options\.password, this\.token, this\.refreshToken, this\.overseasToken\]/) +}) + +test('dual-token refresh replaces the whole session only after a complete response', async () => { + const run = new AcceptanceRun({ gateway: 'http://localhost:9000', overseasGateway: 'http://localhost:9300', + username: 'contract-user', password: 'contract-password', modules: [], output: '' }) + Object.assign(run, { token: 'old-access', refreshToken: 'old-refresh', overseasToken: 'old-overseas', overseasExpiresAt: 1 }) + run.request = async (method, path, body, token) => { + assert.equal(method, 'POST'); assert.equal(path, '/api/auth/refresh') + assert.deepEqual(body, { refreshToken: 'old-refresh' }); assert.equal(token, '') + return { method, path, http: 200, body: { code: '200', data: { + accessToken: 'new-access', refreshToken: 'new-refresh', overseasAccessToken: 'new-overseas', + overseasExpiresIn: 900, + } }, requestId: 'refresh-request', traceId: 'refresh-trace' } + } + assert.equal(await run.refreshSession('contract'), true) + assert.equal(run.token, 'new-access') + assert.equal(run.refreshToken, 'new-refresh') + assert.equal(run.overseasToken, 'new-overseas') + assert.ok(run.overseasExpiresAt > Date.now()) + + const frozen = { token: run.token, refreshToken: run.refreshToken, + overseasToken: run.overseasToken, overseasExpiresAt: run.overseasExpiresAt } + run.request = async (method, path) => ({ method, path, http: 200, + body: { code: '200', data: { accessToken: 'partial-only' } }, + requestId: 'partial-request', traceId: 'partial-trace' }) + assert.equal(await run.refreshSession('incomplete-contract'), false) + assert.deepEqual({ token: run.token, refreshToken: run.refreshToken, + overseasToken: run.overseasToken, overseasExpiresAt: run.overseasExpiresAt }, frozen) }) test('entry has no built-in environment address or password and requires explicit execution options', () => { diff --git a/scripts/provision-development-service-jobs.groovy b/scripts/provision-development-service-jobs.groovy index baa57b467..e4c7ced35 100644 --- a/scripts/provision-development-service-jobs.groovy +++ b/scripts/provision-development-service-jobs.groovy @@ -6,7 +6,7 @@ boolean applyChanges = false String templateName = 'baiyue-firstleg-dev-print' List serviceModules = [ 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' ] Jenkins jenkins = Jenkins.get() diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index 8cea72284..6c69f4df7 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -121,9 +121,9 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java', 'event-bridge-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'Inbox 租约投递,目标按 eventId 幂等'), http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java', - 'event-bridge-service', 'overseas-security-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全事件投影按 eventId/版本单调幂等'), + 'event-bridge-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全事件投影按 eventId/版本单调幂等'), http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java', - 'gateway-overseas', 'overseas-security-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST 实时校验,失败关闭'), + 'gateway-overseas', 'lastleg-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST 实时校验,失败关闭'), http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', 'gateway-overseas', 'auth-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST PDA 会话校验,失败关闭'), http('lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index 030af7e92..485ac8d42 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -13,7 +13,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const serviceModules = [ 'auth', 'iam', 'order', 'firstleg', 'finance', 'file', 'warehouse', 'integration-service', 'print', 'event-bridge-service', 'lastleg-service', - 'warehouse-overseas-service', 'overseas-security-service' + 'warehouse-overseas-service' ] const canonicalSource = new Map(serviceModules.map(name => [name, `${name}${name.endsWith('-service') ? '' : '-service'}`])) @@ -45,7 +45,6 @@ const expectedOnlineEdges = [ const expectedBackgroundEdges = [ 'event-bridge-service->firstleg-service', 'event-bridge-service->lastleg-service', - 'event-bridge-service->overseas-security-service', 'file-service->finance-service', 'file-service->firstleg-service', 'file-service->iam-service', @@ -131,6 +130,7 @@ test('every reviewed synchronous client has a complete and exclusive executable } }) + test('all four Lastleg RestClient gateways use explicit bounded transports', async () => { const clients = new Map([ ['lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', 'getUnloadPhotoFile'], @@ -170,7 +170,7 @@ test('REQUEST, BACKGROUND and full graphs remain separate executable baselines', assert.deepEqual(graphEdges('INFRASTRUCTURE'), [ 'common-audit->audit-service', 'gateway-overseas->auth-service', - 'gateway-overseas->overseas-security-service' + 'gateway-overseas->lastleg-service' ]) assert.deepEqual(stronglyConnectedComponents(graphEdges('REQUEST')), [ ['auth-service', 'iam-service'], @@ -185,10 +185,10 @@ test('REQUEST, BACKGROUND and full graphs remain separate executable baselines', ]]) }) -test('overseas security edges never become online business dependencies', () => { - assert.ok(!graphEdges('REQUEST').some(edge => edge.includes('overseas-security-service'))) - assert.ok(graphEdges('INFRASTRUCTURE').includes('gateway-overseas->overseas-security-service')) - assert.ok(graphEdges('BACKGROUND').includes('event-bridge-service->overseas-security-service')) +test('overseas security projection stays in Lastleg infrastructure and background edges', () => { + assert.ok(!graphEdges('REQUEST').some(edge => edge === 'gateway-overseas->lastleg-service')) + assert.ok(graphEdges('INFRASTRUCTURE').includes('gateway-overseas->lastleg-service')) + assert.ok(graphEdges('BACKGROUND').includes('event-bridge-service->lastleg-service')) }) test('known user entries expose every longest simple REQUEST path over the two-hop budget', () => { @@ -242,3 +242,4 @@ test('shared Feign timeout defaults and long-running export overrides do not dri const fileConfig = await readFile(path.join(root, 'file', 'src', 'main', 'resources', 'application.yml'), 'utf8') assert.equal((fileConfig.match(/read-timeout:\s*15000/g) ?? []).length, 5) }) + diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index 4d324af00..2215862cb 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -57,6 +57,8 @@ test('Java builds use the shared builder pool and serialize incremental target d assert.doesNotMatch(directDeployScript, /docker save "\$IMAGE"/) assert.match(directDeployScript, /up -d --no-deps --pull never/) assert.doesNotMatch(directDeployScript, /--remove-orphans/) + assert.match(directDeployScript, /--project-name "\$PROJECT_NAME"/) + assert.doesNotMatch(directDeployScript, /overseas-security-service|remove_legacy_overseas_security_container/) assert.match(directDeployScript, /--pull never/) }) From eb1a8895ac4eeda80a56479ee324381d22a7b8a0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 16:40:50 +0800 Subject: [PATCH 08/42] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MerchantRegistrationServiceTest.java | 7 +- config/nacos/cn/iam-service.yml | 4 +- config/nacos/local/iam-service.yml | 3 + deploy/sql/头程数据库统一增量-MySQL8.sql | 37 +++++++---- docs/同步调用依赖与失败传播治理基线.md | 14 ++-- .../CustomerDimensionInitializationRelay.java | 2 + iam/src/main/resources/application.yml | 4 +- ...imensionInitializationSqlContractTest.java | 64 +++++++++++++++++++ ...tomerDimensionInitializationRelayTest.java | 7 +- .../MerchantOnboardingServiceTest.java | 5 +- .../baiyue/iam/service/TenantServiceTest.java | 18 +++++- ...astleg-internal-modularization-baseline.md | 29 ++++++--- scripts/README.md | 4 +- .../synchronous-dependency-contract.test.mjs | 14 +++- 14 files changed, 170 insertions(+), 42 deletions(-) create mode 100644 iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java diff --git a/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java index 5a4541699..fd61c5925 100644 --- a/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java @@ -35,7 +35,7 @@ class MerchantRegistrationServiceTest { void registeredAuthAccountUsesTheOwnerIdentityReturnedByIamOnboarding() { when(encoder.encode("Password123")).thenReturn("encoded-password"); when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED"))); + new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING"))); var result = service.register(new MerchantRegisterRequest( "registered_owner", "Password123", "注册人", "测试企业", "张三", @@ -56,13 +56,14 @@ class MerchantRegistrationServiceTest { assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus()); assertEquals("encoded-password", storedAccount.getValue().getPasswordHash()); assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep()); + assertEquals("PENDING", result.dimensionInitializationStatus()); } @Test void failedAuthInsertCompensatesTheIamRegistration() { when(encoder.encode("Password123")).thenReturn("encoded-password"); when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED"))); + new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING"))); when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); doThrow(new IllegalStateException("auth database unavailable")).when(accounts).insert(any()); @@ -82,7 +83,7 @@ class MerchantRegistrationServiceTest { void rolledBackAuthTransactionCompensatesAfterTheLocalTransactionCompletes() { when(encoder.encode("Password123")).thenReturn("encoded-password"); when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED"))); + new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING"))); when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); TransactionSynchronizationManager.initSynchronization(); diff --git a/config/nacos/cn/iam-service.yml b/config/nacos/cn/iam-service.yml index 48a0d8795..f735164e7 100644 --- a/config/nacos/cn/iam-service.yml +++ b/config/nacos/cn/iam-service.yml @@ -7,8 +7,10 @@ spring: baiyue: security-event: event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} + customer-dimension-initialization: + fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} + max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} mq-consumer-enabled: ${NOTIFICATION_MQ_CONSUMER_ENABLED:false} - diff --git a/config/nacos/local/iam-service.yml b/config/nacos/local/iam-service.yml index 4639f7ca1..cc1ad14ab 100644 --- a/config/nacos/local/iam-service.yml +++ b/config/nacos/local/iam-service.yml @@ -4,6 +4,9 @@ spring.datasource: username: ${IAM_DB_USERNAME:root} password: ${IAM_DB_PASSWORD:123456} baiyue: + customer-dimension-initialization: + fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} + max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index ca30df085..22b4dabb6 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -14892,6 +14892,21 @@ BEGIN 'baiyue_firstleg','baiyue_file','baiyue_audit','baiyue_event_bridge','baiyue_finance'))<>9 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '统一迁移失败:九个业务数据库未完整创建'; END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema='baiyue_iam' AND table_name='iam_tenant' + AND column_name='dimension_initialization_status') <> 1 + OR (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_iam' + AND table_name='iam_customer_dimension_initialization_task') <> 1 + OR (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema='baiyue_iam' + AND table_name='iam_customer_dimension_initialization_task' + AND column_name IN ('tenant_id','initialization_key','action','status','attempt_count', + 'next_retry_at','lease_owner','lease_until','last_error_code','last_error_message', + 'task_version')) <> 11 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:IAM客户默认尺寸后台初始化状态或可靠任务结构不完整'; + END IF; IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_carrier_schedule_query' AND column_name IN ('trigger_source','latest_standard_node','latest_node_observed_at', @@ -16579,17 +16594,17 @@ CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task ( initialization_key VARCHAR(128) NOT NULL COMMENT '稳定初始化幂等键:iam-customer-create:{tenantId}', action VARCHAR(16) NOT NULL DEFAULT 'INITIALIZE' COMMENT '目标动作:INITIALIZE初始化、COMPENSATE补偿', status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING、SENDING、READY、FAILED、CANCELLED', - attempt_count INT NOT NULL DEFAULT 0, - next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - lease_owner VARCHAR(128) NULL, - lease_until DATETIME(3) NULL, - last_error_code VARCHAR(32) NULL, - last_error_message VARCHAR(1000) NULL, - remote_config_id BIGINT NULL, - task_version INT NOT NULL DEFAULT 0, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - completed_at DATETIME(3) NULL, + attempt_count INT NOT NULL DEFAULT 0 COMMENT '当前动作已失败尝试次数', + next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许投递时间', + lease_owner VARCHAR(128) NULL COMMENT '当前任务租约持有者', + lease_until DATETIME(3) NULL COMMENT '当前任务租约截止时间', + last_error_code VARCHAR(32) NULL COMMENT '最后下游错误码', + last_error_message VARCHAR(1000) NULL COMMENT '最后诊断错误,不保存敏感凭证', + remote_config_id BIGINT NULL COMMENT 'Firstleg返回的首条默认尺寸配置ID', + task_version INT NOT NULL DEFAULT 0 COMMENT '任务乐观版本;切换动作或认领时递增', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '任务创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '任务更新时间', + completed_at DATETIME(3) NULL COMMENT 'READY或CANCELLED完成时间', PRIMARY KEY (tenant_id), KEY idx_customer_dimension_task_dispatch (status,next_retry_at,lease_until), KEY idx_customer_dimension_task_diagnosis (status,updated_at) diff --git a/docs/同步调用依赖与失败传播治理基线.md b/docs/同步调用依赖与失败传播治理基线.md index cf7410e43..b9c4dde5f 100644 --- a/docs/同步调用依赖与失败传播治理基线.md +++ b/docs/同步调用依赖与失败传播治理基线.md @@ -2,7 +2,7 @@ ## 目的与口径 -本基线覆盖后端服务之间的 OpenFeign、`RestClient`、`WebClient` 与 JDK `HttpClient` 调用。它用于识别同步依赖方向、在线请求深度、后台中继、超时、重试、熔断、幂等、错误映射和失败传播,不改变 Finance 或四期业务状态机。 +本基线覆盖当前官方 22 个 Maven 模块、15 个可执行服务之间的 OpenFeign、`RestClient`、`WebClient` 与 JDK `HttpClient` 调用。海外安全 Inbox/投影属于 Lastleg 内部子域,不再作为独立服务节点。基线用于识别同步依赖方向、在线请求深度、后台中继、超时、重试、熔断、幂等、错误映射和失败传播,不改变 Finance 或四期业务状态机。 “存在双向服务边”不等于“一次在线请求会递归调用”。每条边必须继续区分: @@ -21,7 +21,7 @@ |---|---|---:|---| | Auth | IAM | 2 | 登录授权、注册,`REQUEST` | | IAM | Auth | 1 | 账号开通/管理,`REQUEST` | -| IAM | Firstleg | 1 | 客户尺寸规则,`REQUEST` | +| IAM | Firstleg | 1 | 客户尺寸默认规则可靠任务投递,`BACKGROUND` | | IAM | Warehouse | 1 | 海外仓储费协作,`REQUEST` | | IAM | Event Bridge | 1 | 安全事件中继,`BACKGROUND` | | Order | Firstleg | 3 | 计价/统一订单查询为 `REQUEST`,入库协作为 Outbox `BACKGROUND` | @@ -45,10 +45,10 @@ | 调用方 | 被调用方 | 实现 | 上下文与当前策略 | |---|---|---|---| | Gateway Overseas | Auth | `HttpPdaSessionValidationClient` | `INFRASTRUCTURE`;总超时 2 秒,异常由网关失败关闭 | -| Gateway Overseas | Overseas Security | `HttpOverseasSecurityValidationClient` | `INFRASTRUCTURE`;安全投影实时校验,异常失败关闭,不进入在线业务图 | +| Gateway Overseas | Lastleg Security 子域 | `HttpOverseasSecurityValidationClient` | `INFRASTRUCTURE`;Lastleg 内部安全投影实时校验,异常失败关闭,不进入在线业务图 | | Lastleg | Warehouse Overseas | 两个 `RestClient` Gateway | `REQUEST`;分别使用 3 秒连接、5 秒读取超时,配置可覆盖,不自动重试 | | Lastleg | File | 两个照片 `RestClient` Gateway | `REQUEST`;分别使用 3 秒连接、30 秒读取超时,配置可覆盖;校验响应 code 且失败关闭,不自动重试 | -| Event Bridge | Firstleg / Lastleg / Overseas Security | 三个 JDK `HttpClient` | `BACKGROUND`;连接 3 秒、请求 10 秒,持久化租约和退避由 Event Bridge 专项治理;Security 投影不进入在线业务图 | +| Event Bridge | Firstleg / Lastleg(业务事件与安全投影两个入口) | 三个 JDK `HttpClient` | `BACKGROUND`;连接 3 秒、请求 10 秒,持久化租约和退避由 Event Bridge 专项治理;Lastleg Security 投影不进入在线业务图 | | 启用 Common Audit 的服务 | Audit | `HttpAuditSink` | `INFRASTRUCTURE`;当前捕获异常后仅告警,可能让调用方误以为审计已成功,是高优先级失败传播缺口 | | Integration | 外部 OCR/船司 | 六个 JDK `HttpClient` | `EXTERNAL`;OCR 默认 10/60 秒,船司默认 10/30 秒,业务重试必须服从供应商和调度契约 | @@ -67,13 +67,13 @@ ### 在线 REQUEST 图 -在线图只包含用户或设备请求线程中实际执行的服务边。Event Bridge、Outbox、船节点定时任务、导出 Provider 和海外 Security 校验均不计入在线深度。 +在线图只包含用户或设备请求线程中实际执行的服务边。Event Bridge、Outbox、船节点定时任务、导出 Provider 和 Gateway→Lastleg Security 基础设施校验均不计入在线深度。 当前在线强连通分量为: `Auth ↔ IAM`;`Firstleg ↔ Order` -`IAM → Firstleg` 默认尺寸初始化已退出请求线程,合同现冻结 21 条在线服务边;新增在线边或新增强连通分量直接失败。File 不再属于在线强连通分量;Finance、Lastleg 只指向上述分量或叶子服务,不属于在线环;Gateway Overseas→Security 是 `INFRASTRUCTURE`,不是 REQUEST 边。 +`IAM → Firstleg` 默认尺寸初始化已退出请求线程,合同现冻结 21 条在线服务边;新增在线边或新增强连通分量直接失败。File 不再属于在线强连通分量;Finance、Lastleg 只指向上述分量或叶子服务,不属于在线环;Gateway Overseas→Lastleg Security 是 `INFRASTRUCTURE`,不是 REQUEST 边。 ### 后台 BACKGROUND 图 @@ -81,7 +81,7 @@ `Event Bridge ↔ Firstleg ↔ Finance ↔ Lastleg` -File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 默认尺寸可靠任务、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Overseas Security 是单向后台叶子边,不形成在线业务环。 +File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 默认尺寸可靠任务、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Lastleg Security 与 Event Bridge→Lastleg 业务事件在服务图上收敛为同一条后台服务边,不形成新的在线业务边或独立安全节点。 ### 全图 diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java index 730b8908a..817d7eb81 100644 --- a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java @@ -84,6 +84,8 @@ public class CustomerDimensionInitializationRelay { String value = failure.getMessage(); if (value == null || value.isBlank()) value = failure.getClass().getSimpleName(); value = value.replaceAll("[\\r\\n\\t]+", " "); + value = value.replaceAll("(?i)Bearer\\s+[^\\s,;]+", "Bearer ***") + .replaceAll("(?i)(token|password)=([^\\s&,;]+)", "$1=***"); return value.length() > 1000 ? value.substring(0, 1000) : value; } diff --git a/iam/src/main/resources/application.yml b/iam/src/main/resources/application.yml index c7f0fe556..5faf2522f 100644 --- a/iam/src/main/resources/application.yml +++ b/iam/src/main/resources/application.yml @@ -25,6 +25,9 @@ spring: baiyue: security-event: event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} + customer-dimension-initialization: + fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} + max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} @@ -47,4 +50,3 @@ logging: max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" - diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java new file mode 100644 index 000000000..0c22f9ecf --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java @@ -0,0 +1,64 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class CustomerDimensionInitializationSqlContractTest { + @Test + void latestIamSqlContainsStatusAndDurableTask() throws Exception { + String sql = new ClassPathResource("db/iam.sql").getContentAsString(StandardCharsets.UTF_8); + assertContract(sql); + } + + @Test + void idempotentStandaloneAndUnifiedSqlContainTheSameTaskContract() throws Exception { + String standalone = new ClassPathResource("db/customer-dimension-initialization-task.sql") + .getContentAsString(StandardCharsets.UTF_8); + Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + Path unifiedPath = Files.exists(direct) ? direct + : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8); + + assertContract(standalone); + assertContract(unified); + assertThat(standalone).contains("information_schema.columns", "CREATE TABLE IF NOT EXISTS"); + assertThat(unified).contains("information_schema.columns", "CREATE TABLE IF NOT EXISTS"); + } + + @Test + void schedulerRetryBudgetHasSafeOverridableDefaultsInAllDomesticProfiles() throws Exception { + String application = new ClassPathResource("application.yml").getContentAsString(StandardCharsets.UTF_8); + assertSchedulerConfig(application); + for (String profile : new String[] {"local", "cn"}) { + Path direct = Path.of("config", "nacos", profile, "iam-service.yml"); + Path config = Files.exists(direct) ? direct + : Path.of("..", "config", "nacos", profile, "iam-service.yml"); + assertSchedulerConfig(Files.readString(config, StandardCharsets.UTF_8)); + } + } + + private static void assertContract(String sql) { + assertThat(sql) + .contains("dimension_initialization_status") + .contains("iam_customer_dimension_initialization_task") + .contains("initialization_key") + .contains("attempt_count") + .contains("next_retry_at") + .contains("lease_until") + .contains("last_error_code") + .contains("task_version"); + } + + private static void assertSchedulerConfig(String yaml) { + assertThat(yaml) + .contains("customer-dimension-initialization:") + .contains("${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000}") + .contains("${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12}"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java index 899ead0e7..bdd692767 100644 --- a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java @@ -20,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -47,7 +48,7 @@ class CustomerDimensionInitializationRelayTest { relay.deliver(10001L); verify(repository).markReady(task, workerId(), 91L); - verify(repository, never()).recordFailure(any(), anyString(), anyLong(), any(), anyString(), + verify(repository, never()).recordFailure(any(), anyString(), anyInt(), any(), anyString(), anyString(), any(Boolean.class)); } @@ -60,7 +61,7 @@ class CustomerDimensionInitializationRelayTest { relay.deliver(10001L); ArgumentCaptor exhausted = ArgumentCaptor.forClass(Boolean.class); - verify(repository).recordFailure(any(), anyString(), anyLong(), any(), anyString(), + verify(repository).recordFailure(any(), anyString(), anyInt(), any(), anyString(), anyString(), exhausted.capture()); assertFalse(exhausted.getValue()); } @@ -75,7 +76,7 @@ class CustomerDimensionInitializationRelayTest { relay.deliver(10001L); ArgumentCaptor exhausted = ArgumentCaptor.forClass(Boolean.class); - verify(repository).recordFailure(any(), anyString(), anyLong(), any(), anyString(), + verify(repository).recordFailure(any(), anyString(), anyInt(), any(), anyString(), anyString(), exhausted.capture()); assertTrue(exhausted.getValue()); verify(repository, never()).markReady(any(), anyString(), any()); diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java index 51159c7d7..2b48686ef 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java @@ -74,6 +74,8 @@ class MerchantOnboardingServiceTest { "INITIAL_AUTHORIZATION"); assertEquals("PROFILE_REQUIRED", result.tenantStatus()); + assertEquals("PENDING", result.dimensionInitializationStatus()); + assertEquals("PENDING", tenant.getValue().getDimensionInitializationStatus()); assertEquals(result.tenantId(), tenant.getValue().getId()); assertEquals("测试企业", tenant.getValue().getLegalName()); assertEquals("张三", tenant.getValue().getLegalRepresentative()); @@ -122,7 +124,7 @@ class MerchantOnboardingServiceTest { } @Test - void clientRegistrationFailsWhenFourDefaultBoxDimensionsCannotBeInitialized() { + void clientRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() { IamRole ownerRole = new IamRole(); ownerRole.setId(4000100L); ownerRole.setTenantId(0L); @@ -199,4 +201,3 @@ class MerchantOnboardingServiceTest { verify(tenants, never()).deleteById((java.io.Serializable) any()); } } - diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index c9f7f82c6..eb7bb54dc 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -313,6 +313,7 @@ class TenantServiceTest { 9001L); assertEquals("PROFILE_REQUIRED", created.tenant().status()); + assertEquals("PENDING", created.tenant().dimensionInitializationStatus()); assertEquals("Bob", created.tenant().legalRepresentative()); assertEquals("基础版", created.tenant().servicePackage()); assertEquals("13800138000", created.administratorUsername()); @@ -326,7 +327,7 @@ class TenantServiceTest { } @Test - void platformRegistrationFailsWhenFourDefaultBoxDimensionsCannotBeInitialized() { + void platformRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() { doThrow(new BizException(ResultCode.SERVICE_UNAVAILABLE, "初始化失败")) .when(dimensionRuleInitialization).initialize(any(Long.class), any(Long.class)); @@ -491,12 +492,26 @@ class TenantServiceTest { assertEquals(true, withQualification.eligible()); } + @Test + void activeCustomerCannotOrderUntilDefaultDimensionsAreReady() { + IamTenant tenant = merchant(10001L, "ACTIVE"); + tenant.setDimensionInitializationStatus("FAILED"); + when(repository.findTenant(10001L)).thenReturn(tenant); + + var result = service.orderEligibility(10001L); + + assertEquals(false, result.eligible()); + assertEquals("Customer default dimensions are not ready", result.reason()); + verify(profileRepository, never()).findQualifications(10001L); + } + private IamTenant merchant(long id, String status) { IamTenant tenant = new IamTenant(); tenant.setId(id); tenant.setTenantCode("TEN" + id); tenant.setTenantType("MERCHANT"); tenant.setStatus(status); + tenant.setDimensionInitializationStatus("READY"); tenant.setTenantName("Example Merchant"); tenant.setLegalName("Example Merchant Ltd"); tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001"); @@ -529,4 +544,3 @@ class TenantServiceTest { return review; } } - diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md index 0acced4c7..f0627b41b 100644 --- a/lastleg-service/docs/lastleg-internal-modularization-baseline.md +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -2,7 +2,7 @@ ## 1. 范围与生成方式 -本基线只分析 `lastleg-service` 同一部署单元,不拆微服务,不修改业务代码、POM、数据库、OpenAPI、权限、接口或跨服务契约。统计时工作区同时存在 RestClient 超时专项的在途修改;本专项不修改 `client/`、`config/*RestClient*` 或各业务 HTTP Gateway。 +本基线只分析 `lastleg-service` 同一部署单元,不拆微服务,不修改业务流程、数据库、OpenAPI、权限、接口或跨服务契约。统计时工作区同时存在其他专项的在途修改;本专项不修改 `client/`、`config/*RestClient*` 或各业务 HTTP Gateway。模块内 POM 只增加由根 POM 统一版本管理的 ArchUnit 测试依赖。 可重复命令: @@ -16,15 +16,15 @@ node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all - node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format dot --graph slice ``` -分析器解析显式内部 `import`、静态 `import` 及同包类型引用,输出类依赖、物理包图、逻辑切片图、Tarjan SCC、Entity/DTO/Model 泄漏与 Mapper 泄漏。`test` 单独输出测试包节点及测试类之间的边;当前测试主要引用生产类型,因此 test-only 图有 67 个包节点但没有测试类之间的内部边。`all` 把 `src/main/java` 和 `src/test/java` 合并解析,是观察测试对生产类型耦合的主图。Spring 扫描字符串、反射、MyBatis SQL 表引用和跨服务运行时调用不属于 Java 类型图。 +分析器解析显式内部 `import`、静态 `import` 及同包类型引用,输出类依赖、物理包图、逻辑切片图、Tarjan SCC、Entity/DTO/Model 泄漏与 Mapper 泄漏。`test` 单独输出测试包节点及测试类之间的边;当前测试主要引用生产类型,因此 test-only 图有 68 个包节点但没有测试类之间的内部边。`all` 把 `src/main/java` 和 `src/test/java` 合并解析,是观察测试对生产类型耦合的主图。Spring 扫描字符串、反射、MyBatis SQL 表引用和跨服务运行时调用不属于 Java 类型图。 ## 2. 当前规模与 SCC | 源集 | Java 文件 | 内部类依赖 | 物理包 | 跨切片类依赖 | 模型泄漏 | Mapper 泄漏 | |---|---:|---:|---:|---:|---:|---:| -| main | 347 | 672 | 157 | 137 | 60 | 38 | -| test only | 98 | 0 | 67 | 0 | 0 | 0 | -| main + test | 445 | 1,195 | 161 | 256 | 108 | 76 | +| main | 353 | 679 | 160 | 138 | 60 | 38 | +| test only | 103 | 0 | 68 | 0 | 0 | 0 | +| main + test | 456 | 1,206 | 164 | 257 | 108 | 76 | 主源码物理包图没有多节点 SCC。主源码逻辑切片图存在一个四节点 SCC: @@ -57,7 +57,8 @@ com.baiyue.lastleg.transfer.service | 换标/转运/废弃 `relabel-transfer-discard` | `relabel/`、`transfer/`、`discard/`,其 PDA 子包保持本域归属 | 61 | 31/0 | 直接使用尾程、库存和一件代发持久化模型;业务事务边界复杂 | | 一件代发 `dropship` | `dropship/` | 12 | 4/11 | 与尾程消费及废弃库存双向耦合,位于主 SCC | | 采购/卡车/散板 `procurement-trucking-looseboard` | `procurement/`、`trucking/`、`looseboard/` | 35 | 11/0 | 依赖尾程核心,采购另读取报价目录;仍由四期业务专项维护 | -| 海外主数据 `overseas-master-data` | `masterdata/quote`、`exchange`、`vehicle` | 26 | 3/1 | 不在任何 SCC;仅出向共享权限常量,入向来自采购对报价目录的使用 | +| 海外主数据 `overseas-master-data` | `masterdata/quote`、`exchange`、`vehicle` | 28 | 3/1 | 不在任何 SCC;仅出向共享权限常量,入向来自采购对报价目录的使用 | +| 海外安全投影 `security-projection` | `security/controller`、`security/dto`、`security/service` | 6 | 1/0 | 独立于业务切片与主 SCC;唯一出向为内部 Controller 复用根 `InternalServiceTokenGuard`,无业务切片入向依赖 | 另有 6 个 `shared-technical` 类:应用启动类、Event Bridge Client、RestClient 配置、内部令牌保护和海外权限常量。该分类不授权移动这些共享类。 @@ -86,12 +87,13 @@ com.baiyue.lastleg.transfer.service | relabel-transfer-discard | inventory | 6 | | relabel-transfer-discard | shared-technical | 3 | | relabel-transfer-discard | tailleg-handoff | 14 | +| security-projection | shared-technical | 1 | | tailleg-handoff | dropship | 2 | | tailleg-handoff | inbound-pda | 6 | | tailleg-handoff | inventory | 2 | | tailleg-handoff | shared-technical | 2 | -当前 25 条切片边冻结在 `src/test/resources/architecture/lastleg-slice-dependencies.json`。合同允许删除历史边,不允许增加新边;海外主数据继续锁定为只允许出向 `shared-technical`,只允许采购/卡车/散板切片入向。 +当前 26 条切片边冻结在 `src/test/resources/architecture/lastleg-slice-dependencies.json`。合同允许删除历史边,不允许增加新边;海外主数据继续锁定为只允许出向 `shared-technical`,只允许采购/卡车/散板切片入向;安全投影只允许出向 `shared-technical` 且禁止任何业务切片入向依赖。 ## 5. Entity/DTO/Mapper 泄漏基线 @@ -175,8 +177,9 @@ Web Adapter 可继续依赖 Spring Web、common-security 和 `OverseasPermission ## 9. 当前只读合同 - `scripts/analyze-lastleg-dependencies.mjs`:生成 JSON、Markdown、逻辑切片 DOT、main/test/all 物理包 DOT 和 Tarjan SCC。 -- `src/test/resources/architecture/lastleg-slice-dependencies.json`:冻结 25 条 main 切片边;历史边可减少,不可增加。 -- `scripts/analyze-lastleg-dependencies.test.mjs`:校验基线、main/test/all 包图输出,以及车辆候选的 5 类、零入向、唯一权限出向、零 Client/RestClient 和不进入 SCC。 +- `src/test/resources/architecture/lastleg-slice-dependencies.json`:冻结 26 条 main 切片边;历史边可减少,不可增加,并锁定安全投影 6 个类的位置、单一出向与零入向。 +- `scripts/analyze-lastleg-dependencies.test.mjs`:校验基线、main/test/all 包图输出、车辆切片边界及安全投影隔离。 +- `src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java`:用 ArchUnit 锁定投影内部只依赖本子域和根安全技术类、业务包不得反向依赖投影、DTO/Service 不依赖内部 Web Adapter。 最小验证: @@ -197,3 +200,11 @@ node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all - - 验证结果:Node 合同 4/4;`mvn -pl lastleg-service -am test-compile` 成功;车辆定向测试 5/5;`mvn -pl lastleg-service -am test` 中 lastleg-service 370 个测试,0 失败、0 错误、2 个环境验收跳过,构建成功。 首切片完成后停止,不进入第二切片。 + +## 11. 海外安全投影合入后的边界 + +- 用户确认安全投影保留在 `lastleg-service` 部署单元内,不拆新微服务;逻辑上独立为 `security-projection` 切片。 +- Controller 仅映射 `/internal/lastleg/security/v1/events` 与 `/validate`,使用 `@Hidden` 并在进入投影或校验服务前调用根 `InternalServiceTokenGuard`。 +- MySQL 表固定使用 Lastleg 前缀 `ll_security_event_inbox`、`ll_security_projection`;没有 `overseas_security_*` 表或独立数据库归属。 +- `loading`、`shipping`、`inboundplan` 及其他业务切片不得直接依赖 `security/controller|dto|service`;当前 Java 类型图零入向,Node 与 ArchUnit 双重锁定。 +- 安全投影不进入主逻辑 SCC,唯一出向为内部 Controller 复用共享令牌守卫,不注册为 loading/shipping/inbound 的模型、Mapper 或应用服务。 diff --git a/scripts/README.md b/scripts/README.md index 4b1eb9629..fac0cd8b6 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -31,8 +31,8 @@ node scripts/create-pending-review-orders.mjs | Admin/Client 本地测试与类型检查 | `scripts/frontend-full-regression.ps1` | | PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` | | 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` | -| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、海外 Gateway 覆盖、PDA 注册页面、Maven 模块、海外安全服务部署映射、关键入口与排除目录 | -| Java 质量聚合摘要 | `node --test scripts/java-quality-summary.test.mjs`、`node scripts/java-quality-summary.mjs --output target/java-quality-summary.json`;聚合 Maven/服务拓扑、OpenAPI 覆盖、JaCoCo 和 SpotBugs 报告,默认只报告存量缺口,CI 仅用显式 `--require-spotbugs` 检查报告完整性且当前允许失败 | +| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、海外 Gateway 覆盖、PDA 注册页面、Maven 模块、Lastleg 海外安全投影映射、关键入口与排除目录 | +| Java 质量聚合摘要 | `node --test scripts/java-quality-summary.test.mjs`、`node scripts/java-quality-summary.mjs --output target/java-quality-summary.json`;聚合 Maven/服务拓扑、OpenAPI 覆盖、JaCoCo 和 SpotBugs 报告,默认只报告存量缺口,CI 用显式 `--require-jacoco --require-spotbugs` 检查报告完整性且当前允许失败 | | 单服务部署失败回滚合同 | `scripts/deploy-built-service-rollback.test.mjs`;模拟成功、健康失败和启动失败,确认仅恢复同服务先前健康镜像且部署任务仍失败 | Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块边界,不访问测试环境: diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index 485ac8d42..fa082a7fd 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -130,6 +130,19 @@ test('every reviewed synchronous client has a complete and exclusive executable } }) +test('IAM customer dimension Feign is reachable only from the background relay', async () => { + const references = [] + for (const file of await javaFiles(path.join(root, 'iam', 'src', 'main', 'java'))) { + const source = await readFile(file, 'utf8') + if (source.includes('CustomerDimensionRuleClient')) { + references.push(path.relative(root, file).replaceAll('\\', '/')) + } + } + assert.deepEqual(references.sort(), [ + 'iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java', + 'iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java' + ]) +}) test('all four Lastleg RestClient gateways use explicit bounded transports', async () => { const clients = new Map([ @@ -242,4 +255,3 @@ test('shared Feign timeout defaults and long-running export overrides do not dri const fileConfig = await readFile(path.join(root, 'file', 'src', 'main', 'resources', 'application.yml'), 'utf8') assert.equal((fileConfig.match(/read-timeout:\s*15000/g) ?? []).length, 5) }) - From 2dcfdd5fc8977303fb1e67c22fb2babfb73fdb84 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 16:40:54 +0800 Subject: [PATCH 09/42] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/nacos/cn/iam-service.yml | 6 +-- iam/src/main/resources/application.yml | 6 +-- .../MerchantOnboardingServiceTest.java | 30 ++++++----- .../baiyue/iam/service/TenantServiceTest.java | 52 +++++++------------ .../synchronous-dependency-contract.test.mjs | 13 ----- 5 files changed, 43 insertions(+), 64 deletions(-) diff --git a/config/nacos/cn/iam-service.yml b/config/nacos/cn/iam-service.yml index f735164e7..52b571129 100644 --- a/config/nacos/cn/iam-service.yml +++ b/config/nacos/cn/iam-service.yml @@ -7,10 +7,10 @@ spring: baiyue: security-event: event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} - customer-dimension-initialization: - fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} - max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} + security-event: + event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} mq-consumer-enabled: ${NOTIFICATION_MQ_CONSUMER_ENABLED:false} + diff --git a/iam/src/main/resources/application.yml b/iam/src/main/resources/application.yml index 5faf2522f..c01cc552b 100644 --- a/iam/src/main/resources/application.yml +++ b/iam/src/main/resources/application.yml @@ -25,9 +25,8 @@ spring: baiyue: security-event: event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} - customer-dimension-initialization: - fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} - max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} + security-event: + event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} @@ -50,3 +49,4 @@ logging: max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" + diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java index 2b48686ef..c0d166639 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java @@ -42,6 +42,7 @@ class MerchantOnboardingServiceTest { @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; @MockBean private SecurityEventOutboxService securityEvents; + @MockBean private SecurityEventOutboxService securityEvents; @Resource private MerchantOnboardingService service; @Test void clientRegistrationCreatesOwnerAndAssignsGlobalMerchantOwnerRole() { @@ -55,8 +56,8 @@ class MerchantOnboardingServiceTest { when(roles.selectOne(any())).thenReturn(ownerRole); var result = service.register(new MerchantRegistrationRequest( - 9001L, "注册人", "测试企业", "张三", "13800138000", - "owner@example.com", "CN", "深圳市宝安区")); + 9001L, "ע²áÈË", "²âÊÔÆóҵ", "ÕÅÈý", "13800138000", + "owner@example.com", "CN", "ÉîÛÚÊб¦°²Çø")); ArgumentCaptor tenant = ArgumentCaptor.forClass(IamTenant.class); ArgumentCaptor user = ArgumentCaptor.forClass(IamUser.class); @@ -67,19 +68,21 @@ class MerchantOnboardingServiceTest { verify(memberships).insert(membership.capture()); verify(membershipRoles).insert(roleGrant.capture()); verify(dimensionRuleInitialization).initialize(result.tenantId(), 9001L); - verify(overseasStorageFeeInitialization).initialize(result.tenantId(), 9001L, "测试企业"); + verify(overseasStorageFeeInitialization).initialize(result.tenantId(), 9001L, "²âÊÔÆóҵ"); + verify(securityEvents).tenantStatus(result.tenantId(), "PROFILE_REQUIRED"); + verify(securityEvents).userStatus(9001L, result.tenantId(), "MERCHANT", "ACTIVE"); + verify(securityEvents).permissionChanged(9001L, result.tenantId(), "MERCHANT", + "INITIAL_AUTHORIZATION"); verify(securityEvents).tenantStatus(result.tenantId(), "PROFILE_REQUIRED"); verify(securityEvents).userStatus(9001L, result.tenantId(), "MERCHANT", "ACTIVE"); verify(securityEvents).permissionChanged(9001L, result.tenantId(), "MERCHANT", "INITIAL_AUTHORIZATION"); assertEquals("PROFILE_REQUIRED", result.tenantStatus()); - assertEquals("PENDING", result.dimensionInitializationStatus()); - assertEquals("PENDING", tenant.getValue().getDimensionInitializationStatus()); assertEquals(result.tenantId(), tenant.getValue().getId()); - assertEquals("测试企业", tenant.getValue().getLegalName()); - assertEquals("张三", tenant.getValue().getLegalRepresentative()); - assertEquals("注册人", tenant.getValue().getContactName()); + assertEquals("²âÊÔÆóҵ", tenant.getValue().getLegalName()); + assertEquals("ÕÅÈý", tenant.getValue().getLegalRepresentative()); + assertEquals("ע²áÈË", tenant.getValue().getContactName()); assertEquals(9001L, user.getValue().getAuthUserId()); assertEquals("ACTIVE", user.getValue().getStatus()); assertEquals(result.tenantId(), membership.getValue().getTenantId()); @@ -124,7 +127,7 @@ class MerchantOnboardingServiceTest { } @Test - void clientRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() { + void clientRegistrationFailsWhenFourDefaultBoxDimensionsCannotBeInitialized() { IamRole ownerRole = new IamRole(); ownerRole.setId(4000100L); ownerRole.setTenantId(0L); @@ -133,14 +136,14 @@ class MerchantOnboardingServiceTest { ownerRole.setStatus("ACTIVE"); when(roles.selectOne(any())).thenReturn(ownerRole); org.mockito.Mockito.doThrow(new com.baiyue.common.core.exception.BizException( - com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "初始化失败")) + com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "³õʼ»¯ʧ°Ü")) .when(dimensionRuleInitialization).initialize( org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong()); var failure = assertThrows(com.baiyue.common.core.exception.BizException.class, () -> service.register(new MerchantRegistrationRequest( - 9001L, "注册人", "测试企业", "张三", "13800138000", - "owner@example.com", "CN", "深圳市宝安区"))); + 9001L, "ע²áÈË", "²âÊÔÆóҵ", "ÕÅÈý", "13800138000", + "owner@example.com", "CN", "ÉîÛÚÊб¦°²Çø"))); assertEquals(com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, failure.getResultCode()); } @@ -190,7 +193,7 @@ class MerchantOnboardingServiceTest { when(qualifications.selectCount(any())).thenReturn(0L); when(reviews.selectCount(any())).thenReturn(0L); org.mockito.Mockito.doThrow(new com.baiyue.common.core.exception.BizException( - com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "补偿失败")) + com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "²¹³¥ʧ°Ü")) .when(dimensionRuleInitialization).compensateRegistration(10001L); assertThrows(com.baiyue.common.core.exception.BizException.class, @@ -201,3 +204,4 @@ class MerchantOnboardingServiceTest { verify(tenants, never()).deleteById((java.io.Serializable) any()); } } + diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index eb7bb54dc..a7886a304 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -151,22 +151,22 @@ class TenantServiceTest { var updated = service.updateMerchant(10001L, new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", "alice@example.com", - "专业版", "alice_wechat", "Sales Zhang", 45, "LIMITED_COMPANY", LocalDate.of(2020, 1, 2), - new BigDecimal("5000000.00"), "CNY", "深圳市南山区示例路 1 号", "一期重点客户")); + "רҵ°æ", "alice_wechat", "Sales Zhang", 45, "LIMITED_COMPANY", LocalDate.of(2020, 1, 2), + new BigDecimal("5000000.00"), "CNY", "ÉîÛÚÊÐÄÏɽÇøʾÀý· 1 ºÅ", "һÆÚÖصã¿ͻ§")); var disabled = service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED")); assertEquals("New name", updated.tenantName()); assertEquals("Bob", updated.legalRepresentative()); assertEquals("alice_wechat", updated.contactWechat()); - assertEquals("专业版", updated.servicePackage()); + assertEquals("רҵ°æ", updated.servicePackage()); assertEquals("Sales Zhang", updated.salesOwnerName()); assertEquals(45, updated.paymentTermDays()); assertEquals("LIMITED_COMPANY", updated.enterpriseType()); assertEquals(LocalDate.of(2020, 1, 2), updated.establishedDate()); assertEquals(new BigDecimal("5000000.00"), updated.registeredCapital()); assertEquals("CNY", updated.registeredCapitalCurrency()); - assertEquals("深圳市南山区示例路 1 号", updated.registeredAddress()); - assertEquals("一期重点客户", updated.internalRemark()); + assertEquals("ÉîÛÚÊÐÄÏɽÇøʾÀý· 1 ºÅ", updated.registeredAddress()); + assertEquals("һÆÚÖصã¿ͻ§", updated.internalRemark()); assertEquals("DISABLED", disabled.status()); verify(repository, org.mockito.Mockito.times(2)).saveTenant(tenant); } @@ -178,7 +178,7 @@ class TenantServiceTest { BizException exception = assertThrows(BizException.class, () -> service.updateMerchant(10001L, new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13900139000", null, - "基础版", null, null, 30, null, null, null, "CNY", null, null))); + "»ù´¡°æ", null, null, 30, null, null, null, "CNY", null, null))); assertEquals(ResultCode.CONFLICT, exception.getResultCode()); assertEquals("13800138000", tenant.getContactPhone()); @@ -273,7 +273,7 @@ class TenantServiceTest { BizException platformFailure = assertThrows(BizException.class, () -> service.updateMerchant(10001L, new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", null, - "基础版", null, null, 30, null, null, null, "CNY", null, null))); + "»ù´¡°æ", null, null, 30, null, null, null, "CNY", null, null))); BizException merchantFailure = assertThrows(BizException.class, () -> service.update(10001L, new UpdateProfileRequest("New name", "New legal", "Bob", "Alice", "13800138000", "alice@example.com"))); @@ -308,32 +308,32 @@ class TenantServiceTest { var created = service.register(new RegisterRequest( "Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000", - "alice@example.com", "基础版", "alice_wechat", "Sales Zhang", 30, "LIMITED_COMPANY", - LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区", "草稿客户"), + "alice@example.com", "»ù´¡°æ", "alice_wechat", "Sales Zhang", 30, "LIMITED_COMPANY", + LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "ÉîÛÚÊÐÄÏɽÇø", "²ݸå¿ͻ§"), 9001L); assertEquals("PROFILE_REQUIRED", created.tenant().status()); - assertEquals("PENDING", created.tenant().dimensionInitializationStatus()); assertEquals("Bob", created.tenant().legalRepresentative()); - assertEquals("基础版", created.tenant().servicePackage()); + assertEquals("»ù´¡°æ", created.tenant().servicePackage()); assertEquals("13800138000", created.administratorUsername()); assertEquals("Password1234", created.temporaryPassword()); verify(repository).insertTenant(argThat(tenant -> Long.valueOf(9001L).equals(tenant.getCreatedBy()) && Long.valueOf(9001L).equals(tenant.getUpdatedBy()))); verify(dimensionRuleInitialization).initialize(created.tenant().id(), 9001L); verify(securityEvents).tenantStatus(created.tenant().id(), "PROFILE_REQUIRED"); + verify(securityEvents).tenantStatus(created.tenant().id(), "PROFILE_REQUIRED"); verify(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L)); verify(repository, never()).insertReview(any()); } @Test - void platformRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() { - doThrow(new BizException(ResultCode.SERVICE_UNAVAILABLE, "初始化失败")) + void platformRegistrationFailsWhenFourDefaultBoxDimensionsCannotBeInitialized() { + doThrow(new BizException(ResultCode.SERVICE_UNAVAILABLE, "³õʼ»¯ʧ°Ü")) .when(dimensionRuleInitialization).initialize(any(Long.class), any(Long.class)); BizException failure = assertThrows(BizException.class, () -> service.register(new RegisterRequest( "Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000", - "alice@example.com", "基础版", null, null, 30, null, + "alice@example.com", "»ù´¡°æ", null, null, 30, null, null, null, "CNY", null, null), 9001L)); assertEquals(ResultCode.SERVICE_UNAVAILABLE, failure.getResultCode()); @@ -347,7 +347,7 @@ class TenantServiceTest { var created = service.register(new RegisterRequest( "Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000", - "alice@example.com", "基础版", null, null, 30, null, + "alice@example.com", "»ù´¡°æ", null, null, 30, null, null, null, "CNY", null, null), 9001L); assertEquals(null, created.tenant().unifiedSocialCreditCode()); @@ -420,7 +420,7 @@ class TenantServiceTest { TenantAdministratorProvisioningService.ProvisioningResult.created( 81L, "merchant_10001", "Password1234")).when(provisioning).initialize(any()); - var result = service.review(71L, 9001L, true, "资料完整"); + var result = service.review(71L, 9001L, true, "×ÊÁÏÍêÕû"); assertEquals(10001L, result.tenantId()); assertEquals(true, result.administratorCreated()); @@ -431,6 +431,7 @@ class TenantServiceTest { verify(repository).saveReview(review); verify(repository).saveTenant(tenant); verify(securityEvents).tenantStatus(10001L, "ACTIVE"); + verify(securityEvents).tenantStatus(10001L, "ACTIVE"); } @Test @@ -446,7 +447,7 @@ class TenantServiceTest { doReturn(TenantAdministratorProvisioningService.ProvisioningResult.existing()) .when(provisioning).initialize(any()); - var result = service.review(71L, 9001L, true, "资料完整"); + var result = service.review(71L, 9001L, true, "×ÊÁÏÍêÕû"); assertEquals(false, result.administratorCreated()); assertEquals(null, result.administratorUsername()); @@ -470,7 +471,7 @@ class TenantServiceTest { doReturn(administrator).when(provisioning).initialize(any()); doThrow(new RuntimeException("review write failed")).when(repository).saveReview(review); - assertThrows(RuntimeException.class, () -> service.review(71L, 9001L, true, "资料完整")); + assertThrows(RuntimeException.class, () -> service.review(71L, 9001L, true, "×ÊÁÏÍêÕû")); verify(provisioning).compensate(any(), eq(administrator)); verify(repository, never()).saveTenant(tenant); @@ -492,26 +493,12 @@ class TenantServiceTest { assertEquals(true, withQualification.eligible()); } - @Test - void activeCustomerCannotOrderUntilDefaultDimensionsAreReady() { - IamTenant tenant = merchant(10001L, "ACTIVE"); - tenant.setDimensionInitializationStatus("FAILED"); - when(repository.findTenant(10001L)).thenReturn(tenant); - - var result = service.orderEligibility(10001L); - - assertEquals(false, result.eligible()); - assertEquals("Customer default dimensions are not ready", result.reason()); - verify(profileRepository, never()).findQualifications(10001L); - } - private IamTenant merchant(long id, String status) { IamTenant tenant = new IamTenant(); tenant.setId(id); tenant.setTenantCode("TEN" + id); tenant.setTenantType("MERCHANT"); tenant.setStatus(status); - tenant.setDimensionInitializationStatus("READY"); tenant.setTenantName("Example Merchant"); tenant.setLegalName("Example Merchant Ltd"); tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001"); @@ -544,3 +531,4 @@ class TenantServiceTest { return review; } } + diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index fa082a7fd..a9b69530f 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -130,19 +130,6 @@ test('every reviewed synchronous client has a complete and exclusive executable } }) -test('IAM customer dimension Feign is reachable only from the background relay', async () => { - const references = [] - for (const file of await javaFiles(path.join(root, 'iam', 'src', 'main', 'java'))) { - const source = await readFile(file, 'utf8') - if (source.includes('CustomerDimensionRuleClient')) { - references.push(path.relative(root, file).replaceAll('\\', '/')) - } - } - assert.deepEqual(references.sort(), [ - 'iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java', - 'iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java' - ]) -}) test('all four Lastleg RestClient gateways use explicit bounded transports', async () => { const clients = new Map([ From 505d448850428ec70ce5a9e5e4bfc534fa932828 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 16:41:02 +0800 Subject: [PATCH 10/42] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/nacos/cn/iam-service.yml | 6 +-- iam/src/main/resources/application.yml | 6 +-- .../MerchantOnboardingServiceTest.java | 30 +++++------ .../baiyue/iam/service/TenantServiceTest.java | 52 ++++++++++++------- .../synchronous-dependency-contract.test.mjs | 13 +++++ 5 files changed, 64 insertions(+), 43 deletions(-) diff --git a/config/nacos/cn/iam-service.yml b/config/nacos/cn/iam-service.yml index 52b571129..f735164e7 100644 --- a/config/nacos/cn/iam-service.yml +++ b/config/nacos/cn/iam-service.yml @@ -7,10 +7,10 @@ spring: baiyue: security-event: event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} - security-event: - event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} + customer-dimension-initialization: + fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} + max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} mq-consumer-enabled: ${NOTIFICATION_MQ_CONSUMER_ENABLED:false} - diff --git a/iam/src/main/resources/application.yml b/iam/src/main/resources/application.yml index c01cc552b..5faf2522f 100644 --- a/iam/src/main/resources/application.yml +++ b/iam/src/main/resources/application.yml @@ -25,8 +25,9 @@ spring: baiyue: security-event: event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} - security-event: - event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} + customer-dimension-initialization: + fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} + max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} @@ -49,4 +50,3 @@ logging: max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" - diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java index c0d166639..2b48686ef 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java @@ -42,7 +42,6 @@ class MerchantOnboardingServiceTest { @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; @MockBean private SecurityEventOutboxService securityEvents; - @MockBean private SecurityEventOutboxService securityEvents; @Resource private MerchantOnboardingService service; @Test void clientRegistrationCreatesOwnerAndAssignsGlobalMerchantOwnerRole() { @@ -56,8 +55,8 @@ class MerchantOnboardingServiceTest { when(roles.selectOne(any())).thenReturn(ownerRole); var result = service.register(new MerchantRegistrationRequest( - 9001L, "ע²áÈË", "²âÊÔÆóҵ", "ÕÅÈý", "13800138000", - "owner@example.com", "CN", "ÉîÛÚÊб¦°²Çø")); + 9001L, "注册人", "测试企业", "张三", "13800138000", + "owner@example.com", "CN", "深圳市宝安区")); ArgumentCaptor tenant = ArgumentCaptor.forClass(IamTenant.class); ArgumentCaptor user = ArgumentCaptor.forClass(IamUser.class); @@ -68,21 +67,19 @@ class MerchantOnboardingServiceTest { verify(memberships).insert(membership.capture()); verify(membershipRoles).insert(roleGrant.capture()); verify(dimensionRuleInitialization).initialize(result.tenantId(), 9001L); - verify(overseasStorageFeeInitialization).initialize(result.tenantId(), 9001L, "²âÊÔÆóҵ"); - verify(securityEvents).tenantStatus(result.tenantId(), "PROFILE_REQUIRED"); - verify(securityEvents).userStatus(9001L, result.tenantId(), "MERCHANT", "ACTIVE"); - verify(securityEvents).permissionChanged(9001L, result.tenantId(), "MERCHANT", - "INITIAL_AUTHORIZATION"); + verify(overseasStorageFeeInitialization).initialize(result.tenantId(), 9001L, "测试企业"); verify(securityEvents).tenantStatus(result.tenantId(), "PROFILE_REQUIRED"); verify(securityEvents).userStatus(9001L, result.tenantId(), "MERCHANT", "ACTIVE"); verify(securityEvents).permissionChanged(9001L, result.tenantId(), "MERCHANT", "INITIAL_AUTHORIZATION"); assertEquals("PROFILE_REQUIRED", result.tenantStatus()); + assertEquals("PENDING", result.dimensionInitializationStatus()); + assertEquals("PENDING", tenant.getValue().getDimensionInitializationStatus()); assertEquals(result.tenantId(), tenant.getValue().getId()); - assertEquals("²âÊÔÆóҵ", tenant.getValue().getLegalName()); - assertEquals("ÕÅÈý", tenant.getValue().getLegalRepresentative()); - assertEquals("ע²áÈË", tenant.getValue().getContactName()); + assertEquals("测试企业", tenant.getValue().getLegalName()); + assertEquals("张三", tenant.getValue().getLegalRepresentative()); + assertEquals("注册人", tenant.getValue().getContactName()); assertEquals(9001L, user.getValue().getAuthUserId()); assertEquals("ACTIVE", user.getValue().getStatus()); assertEquals(result.tenantId(), membership.getValue().getTenantId()); @@ -127,7 +124,7 @@ class MerchantOnboardingServiceTest { } @Test - void clientRegistrationFailsWhenFourDefaultBoxDimensionsCannotBeInitialized() { + void clientRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() { IamRole ownerRole = new IamRole(); ownerRole.setId(4000100L); ownerRole.setTenantId(0L); @@ -136,14 +133,14 @@ class MerchantOnboardingServiceTest { ownerRole.setStatus("ACTIVE"); when(roles.selectOne(any())).thenReturn(ownerRole); org.mockito.Mockito.doThrow(new com.baiyue.common.core.exception.BizException( - com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "³õʼ»¯ʧ°Ü")) + com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "初始化失败")) .when(dimensionRuleInitialization).initialize( org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong()); var failure = assertThrows(com.baiyue.common.core.exception.BizException.class, () -> service.register(new MerchantRegistrationRequest( - 9001L, "ע²áÈË", "²âÊÔÆóҵ", "ÕÅÈý", "13800138000", - "owner@example.com", "CN", "ÉîÛÚÊб¦°²Çø"))); + 9001L, "注册人", "测试企业", "张三", "13800138000", + "owner@example.com", "CN", "深圳市宝安区"))); assertEquals(com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, failure.getResultCode()); } @@ -193,7 +190,7 @@ class MerchantOnboardingServiceTest { when(qualifications.selectCount(any())).thenReturn(0L); when(reviews.selectCount(any())).thenReturn(0L); org.mockito.Mockito.doThrow(new com.baiyue.common.core.exception.BizException( - com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "²¹³¥ʧ°Ü")) + com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "补偿失败")) .when(dimensionRuleInitialization).compensateRegistration(10001L); assertThrows(com.baiyue.common.core.exception.BizException.class, @@ -204,4 +201,3 @@ class MerchantOnboardingServiceTest { verify(tenants, never()).deleteById((java.io.Serializable) any()); } } - diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index a7886a304..eb7bb54dc 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -151,22 +151,22 @@ class TenantServiceTest { var updated = service.updateMerchant(10001L, new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", "alice@example.com", - "רҵ°æ", "alice_wechat", "Sales Zhang", 45, "LIMITED_COMPANY", LocalDate.of(2020, 1, 2), - new BigDecimal("5000000.00"), "CNY", "ÉîÛÚÊÐÄÏɽÇøʾÀý· 1 ºÅ", "һÆÚÖصã¿ͻ§")); + "专业版", "alice_wechat", "Sales Zhang", 45, "LIMITED_COMPANY", LocalDate.of(2020, 1, 2), + new BigDecimal("5000000.00"), "CNY", "深圳市南山区示例路 1 号", "一期重点客户")); var disabled = service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED")); assertEquals("New name", updated.tenantName()); assertEquals("Bob", updated.legalRepresentative()); assertEquals("alice_wechat", updated.contactWechat()); - assertEquals("רҵ°æ", updated.servicePackage()); + assertEquals("专业版", updated.servicePackage()); assertEquals("Sales Zhang", updated.salesOwnerName()); assertEquals(45, updated.paymentTermDays()); assertEquals("LIMITED_COMPANY", updated.enterpriseType()); assertEquals(LocalDate.of(2020, 1, 2), updated.establishedDate()); assertEquals(new BigDecimal("5000000.00"), updated.registeredCapital()); assertEquals("CNY", updated.registeredCapitalCurrency()); - assertEquals("ÉîÛÚÊÐÄÏɽÇøʾÀý· 1 ºÅ", updated.registeredAddress()); - assertEquals("һÆÚÖصã¿ͻ§", updated.internalRemark()); + assertEquals("深圳市南山区示例路 1 号", updated.registeredAddress()); + assertEquals("一期重点客户", updated.internalRemark()); assertEquals("DISABLED", disabled.status()); verify(repository, org.mockito.Mockito.times(2)).saveTenant(tenant); } @@ -178,7 +178,7 @@ class TenantServiceTest { BizException exception = assertThrows(BizException.class, () -> service.updateMerchant(10001L, new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13900139000", null, - "»ù´¡°æ", null, null, 30, null, null, null, "CNY", null, null))); + "基础版", null, null, 30, null, null, null, "CNY", null, null))); assertEquals(ResultCode.CONFLICT, exception.getResultCode()); assertEquals("13800138000", tenant.getContactPhone()); @@ -273,7 +273,7 @@ class TenantServiceTest { BizException platformFailure = assertThrows(BizException.class, () -> service.updateMerchant(10001L, new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", null, - "»ù´¡°æ", null, null, 30, null, null, null, "CNY", null, null))); + "基础版", null, null, 30, null, null, null, "CNY", null, null))); BizException merchantFailure = assertThrows(BizException.class, () -> service.update(10001L, new UpdateProfileRequest("New name", "New legal", "Bob", "Alice", "13800138000", "alice@example.com"))); @@ -308,32 +308,32 @@ class TenantServiceTest { var created = service.register(new RegisterRequest( "Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000", - "alice@example.com", "»ù´¡°æ", "alice_wechat", "Sales Zhang", 30, "LIMITED_COMPANY", - LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "ÉîÛÚÊÐÄÏɽÇø", "²ݸå¿ͻ§"), + "alice@example.com", "基础版", "alice_wechat", "Sales Zhang", 30, "LIMITED_COMPANY", + LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区", "草稿客户"), 9001L); assertEquals("PROFILE_REQUIRED", created.tenant().status()); + assertEquals("PENDING", created.tenant().dimensionInitializationStatus()); assertEquals("Bob", created.tenant().legalRepresentative()); - assertEquals("»ù´¡°æ", created.tenant().servicePackage()); + assertEquals("基础版", created.tenant().servicePackage()); assertEquals("13800138000", created.administratorUsername()); assertEquals("Password1234", created.temporaryPassword()); verify(repository).insertTenant(argThat(tenant -> Long.valueOf(9001L).equals(tenant.getCreatedBy()) && Long.valueOf(9001L).equals(tenant.getUpdatedBy()))); verify(dimensionRuleInitialization).initialize(created.tenant().id(), 9001L); verify(securityEvents).tenantStatus(created.tenant().id(), "PROFILE_REQUIRED"); - verify(securityEvents).tenantStatus(created.tenant().id(), "PROFILE_REQUIRED"); verify(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L)); verify(repository, never()).insertReview(any()); } @Test - void platformRegistrationFailsWhenFourDefaultBoxDimensionsCannotBeInitialized() { - doThrow(new BizException(ResultCode.SERVICE_UNAVAILABLE, "³õʼ»¯ʧ°Ü")) + void platformRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() { + doThrow(new BizException(ResultCode.SERVICE_UNAVAILABLE, "初始化失败")) .when(dimensionRuleInitialization).initialize(any(Long.class), any(Long.class)); BizException failure = assertThrows(BizException.class, () -> service.register(new RegisterRequest( "Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000", - "alice@example.com", "»ù´¡°æ", null, null, 30, null, + "alice@example.com", "基础版", null, null, 30, null, null, null, "CNY", null, null), 9001L)); assertEquals(ResultCode.SERVICE_UNAVAILABLE, failure.getResultCode()); @@ -347,7 +347,7 @@ class TenantServiceTest { var created = service.register(new RegisterRequest( "Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000", - "alice@example.com", "»ù´¡°æ", null, null, 30, null, + "alice@example.com", "基础版", null, null, 30, null, null, null, "CNY", null, null), 9001L); assertEquals(null, created.tenant().unifiedSocialCreditCode()); @@ -420,7 +420,7 @@ class TenantServiceTest { TenantAdministratorProvisioningService.ProvisioningResult.created( 81L, "merchant_10001", "Password1234")).when(provisioning).initialize(any()); - var result = service.review(71L, 9001L, true, "×ÊÁÏÍêÕû"); + var result = service.review(71L, 9001L, true, "资料完整"); assertEquals(10001L, result.tenantId()); assertEquals(true, result.administratorCreated()); @@ -431,7 +431,6 @@ class TenantServiceTest { verify(repository).saveReview(review); verify(repository).saveTenant(tenant); verify(securityEvents).tenantStatus(10001L, "ACTIVE"); - verify(securityEvents).tenantStatus(10001L, "ACTIVE"); } @Test @@ -447,7 +446,7 @@ class TenantServiceTest { doReturn(TenantAdministratorProvisioningService.ProvisioningResult.existing()) .when(provisioning).initialize(any()); - var result = service.review(71L, 9001L, true, "×ÊÁÏÍêÕû"); + var result = service.review(71L, 9001L, true, "资料完整"); assertEquals(false, result.administratorCreated()); assertEquals(null, result.administratorUsername()); @@ -471,7 +470,7 @@ class TenantServiceTest { doReturn(administrator).when(provisioning).initialize(any()); doThrow(new RuntimeException("review write failed")).when(repository).saveReview(review); - assertThrows(RuntimeException.class, () -> service.review(71L, 9001L, true, "×ÊÁÏÍêÕû")); + assertThrows(RuntimeException.class, () -> service.review(71L, 9001L, true, "资料完整")); verify(provisioning).compensate(any(), eq(administrator)); verify(repository, never()).saveTenant(tenant); @@ -493,12 +492,26 @@ class TenantServiceTest { assertEquals(true, withQualification.eligible()); } + @Test + void activeCustomerCannotOrderUntilDefaultDimensionsAreReady() { + IamTenant tenant = merchant(10001L, "ACTIVE"); + tenant.setDimensionInitializationStatus("FAILED"); + when(repository.findTenant(10001L)).thenReturn(tenant); + + var result = service.orderEligibility(10001L); + + assertEquals(false, result.eligible()); + assertEquals("Customer default dimensions are not ready", result.reason()); + verify(profileRepository, never()).findQualifications(10001L); + } + private IamTenant merchant(long id, String status) { IamTenant tenant = new IamTenant(); tenant.setId(id); tenant.setTenantCode("TEN" + id); tenant.setTenantType("MERCHANT"); tenant.setStatus(status); + tenant.setDimensionInitializationStatus("READY"); tenant.setTenantName("Example Merchant"); tenant.setLegalName("Example Merchant Ltd"); tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001"); @@ -531,4 +544,3 @@ class TenantServiceTest { return review; } } - diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index a9b69530f..fa082a7fd 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -130,6 +130,19 @@ test('every reviewed synchronous client has a complete and exclusive executable } }) +test('IAM customer dimension Feign is reachable only from the background relay', async () => { + const references = [] + for (const file of await javaFiles(path.join(root, 'iam', 'src', 'main', 'java'))) { + const source = await readFile(file, 'utf8') + if (source.includes('CustomerDimensionRuleClient')) { + references.push(path.relative(root, file).replaceAll('\\', '/')) + } + } + assert.deepEqual(references.sort(), [ + 'iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java', + 'iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java' + ]) +}) test('all four Lastleg RestClient gateways use explicit bounded transports', async () => { const clients = new Map([ From 3ab1a9d1734f2b5f359b4bc4fca1854aaf9517fe Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 17:01:52 +0800 Subject: [PATCH 11/42] fix(lastleg): remove duplicate vehicle repository mapper --- .../exchange/ExchangeMapperConfiguration.java | 10 ---------- .../masterdata/MasterDataMapperConfigurationTest.java | 8 ++++++++ 2 files changed, 8 insertions(+), 10 deletions(-) delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/ExchangeMapperConfiguration.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/ExchangeMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/ExchangeMapperConfiguration.java deleted file mode 100644 index 7a1d22ad4..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/ExchangeMapperConfiguration.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.baiyue.lastleg.masterdata.exchange; - -import org.mybatis.spring.annotation.MapperScan; -import org.springframework.context.annotation.Configuration; - -/** 注册尾程主数据域的所有 Mapper。 */ -@Configuration -@MapperScan("com.baiyue.lastleg.masterdata") -public class ExchangeMapperConfiguration { -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/MasterDataMapperConfigurationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/MasterDataMapperConfigurationTest.java index 9bf3ebe8d..a38935e41 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/MasterDataMapperConfigurationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/MasterDataMapperConfigurationTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import org.mybatis.spring.annotation.MapperScan; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class MasterDataMapperConfigurationTest { @Test @@ -14,4 +15,11 @@ class MasterDataMapperConfigurationTest { assertThat(mapperScan.basePackages()).containsExactly("com.baiyue.lastleg.masterdata"); assertThat(mapperScan.annotationClass()).isEqualTo(Mapper.class); } + + @Test + void broadExchangeMapperScanMustNotBeReintroduced() { + assertThatThrownBy(() -> Class.forName( + "com.baiyue.lastleg.masterdata.exchange.ExchangeMapperConfiguration")) + .isInstanceOf(ClassNotFoundException.class); + } } From 4e8c15dbfa2bd57038c795e6d4ba8dcd50e8f36e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 17:17:32 +0800 Subject: [PATCH 12/42] test: wait for customer dimension initialization --- ...nt-qualification-fixture-contract.test.mjs | 3 +++ .../merchant-qualification-fixture.mjs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index 8d080ea93..6560cab8f 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -15,6 +15,9 @@ test('默认全量验收先准备已审核商户再运行订单链路', () => { test('商户夹具通过正式 File 和 IAM 审核流程建立下单资格', () => { assert.match(fixture, /files\/qualifications\/\$\{encodeURIComponent\(tenantId\)\}\/required-package/) assert.match(fixture, /iam\/tenants\/\$\{encodeURIComponent\(tenantId\)\}\/submit-review/) + assert.match(fixture, /iam\/tenants\/\$\{encodeURIComponent\(tenantId\)\}\/dimension-initialization/) + assert.match(fixture, /status === 'READY'/) + assert.match(fixture, /setTimeout\(resolve, 500\)/) assert.match(fixture, /iam\/reviews\/\$\{encodeURIComponent\(review\.id\)\}\/approve/) assert.match(fixture, /item\.status === 'APPROVED'/) assert.match(fixture, /MERCHANT_TEST_USERNAME/) diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs index 6e586c0e5..61c541603 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs @@ -9,6 +9,23 @@ function id(value) { return value === undefined || value === null ? '' : String(value) } +async function waitForDimensionInitialization(context, tenantId) { + let response = null + let status = 'PENDING' + let attempt = 0 + for (; attempt < 30; attempt += 1) { + response = await context.request('GET', + `/api/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/dimension-initialization`) + status = response.http === 200 && String(response.body?.code) === '200' + ? response.body?.data?.status : 'REQUEST_FAILED' + if (status === 'READY' || status === 'FAILED') break + await new Promise((resolve) => setTimeout(resolve, 500)) + } + context.assert(MODULE, '客户默认尺寸后台初始化已就绪', response, status === 'READY', + `tenantId=${tenantId}, status=${status}, attempts=${attempt + 1}`, { deploymentBlocker: true }) + if (status !== 'READY') throw new Error(`客户默认尺寸初始化未就绪: ${status}`) +} + export async function run(context) { const suffix = context.marker.replace(/[^0-9A-Z]/gi, '').slice(-10).padStart(10, '0') const phone = await context.findAvailablePhone(MODULE) @@ -63,6 +80,8 @@ export async function run(context) { `tenantId=${tenantId}, reviewId=${id(review?.id) || 'missing'}`, { deploymentBlocker: true }) if (!review?.id) throw new Error('未找到隔离下单客户的待审核单') + await waitForDimensionInitialization(context, tenantId) + const approveResponse = await context.request('POST', `/api/platform/v1/iam/reviews/${encodeURIComponent(review.id)}/approve`, { comment: `${context.marker} 全量验收正式资质审核通过` }) From 25cb3bfa54e114eaad6212985db46ac134ada028 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 17:25:38 +0800 Subject: [PATCH 13/42] test: await PDA projection and pending receipts --- .../phase4-overseas-pda-contract.test.mjs | 5 +++++ .../phase4-overseas-pda.mjs | 20 +++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index ee95227c6..2f54f9709 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -35,9 +35,14 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /第二仓库操作员抢占同柜返回409/) assert.match(source, /WAREHOUSE_PICKER/) assert.match(source, /lastleg:pda:operate/) + assert.match(source, /安全投影已收敛/) + assert.match(source, /projectionReady = success\(projectionResponse\) \|\| notFound\(projectionResponse\)/) + assert.match(source, /setTimeout\(resolve, 500\)/) assert.match(source, /超收被拒绝/) assert.match(source, /重复箱新键被拒绝/) assert.match(source, /key\(context, `fba-bulk-\$\{index\}`\)/) + assert.match(source, /packageCount = Number\(resolved\.data\?\.pendingPackages \|\| 0\)/) + assert.match(source, /if \(packageCount <= 0\) continue/) assert.match(source, /key\(context, `overseas-carton-\$\{index\}`\)/) assert.match(source, /模式硬拒绝/) assert.match(source, /current返回404正常空态/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index b1f177e91..e04e67ca4 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -111,7 +111,21 @@ async function createPicker(context, index, overseasWarehouseId, access) { const token = success(login) ? login.body?.data?.overseasAccessToken : null context.addStep(MODULE, `海外PDA操作员${index}登录取得双区域海外令牌`, login, nonBlank(token), `overseasTokenPresent=${nonBlank(token)}; ${trace(login)}`, OPTIONS) - return { membershipId, username, token: nonBlank(token) ? token : null } + if (!nonBlank(token)) return { membershipId, username, token: null } + + let projectionResponse = null + let projectionReady = false + let projectionAttempt = 0 + for (; projectionAttempt < 30; projectionAttempt += 1) { + projectionResponse = await context.requestOverseas('GET', + '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token) + projectionReady = success(projectionResponse) || notFound(projectionResponse) + if (projectionReady || !forbidden(projectionResponse)) break + await new Promise((resolve) => setTimeout(resolve, 500)) + } + context.addStep(MODULE, `海外PDA操作员${index}安全投影已收敛`, projectionResponse, projectionReady, + `attempts=${projectionAttempt + 1}; ${trace(projectionResponse)}`, OPTIONS) + return { membershipId, username, token: projectionReady ? token : null } } async function disablePicker(context, picker, index) { @@ -294,13 +308,15 @@ async function receiveFba(context, token, inbound, pallet, producer) { } let accepted = 0 for (const [index, sku] of skus.entries()) { - const { skuCode, packageCount } = sku + const { skuCode } = sku const resolvedResponse = await context.requestOverseas('POST', '/api/pda/v1/last-leg/receiving-containers/current/skus/resolve', { skuCode }, {}, token) const resolvable = success(resolvedResponse) || notFound(resolvedResponse) context.addStep(MODULE, `解析FBA SKU候选 ${index + 1}`, resolvedResponse, resolvable, trace(resolvedResponse), OPTIONS) const resolved = { response: resolvedResponse, data: success(resolvedResponse) ? resolvedResponse.body?.data : null } if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode || resolved.data?.destinationType !== 'FBA') continue + const packageCount = Number(resolved.data?.pendingPackages || 0) + if (packageCount <= 0) continue const body = { inboundCode: inbound.inboundCode, skuCode, palletCode: pallet.palletCode, packageCount, idempotencyKey: key(context, `fba-bulk-${index}`) } const first = await pda(context, token, 'POST', `FBA SKU ${skuCode}按箱数实收`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) From 0def6114ee67c3072e87fb54f0c735545cd830e8 Mon Sep 17 00:00:00 2001 From: wujinliang <2318309077@qq.com> Date: Tue, 25 Aug 2026 17:42:38 +0800 Subject: [PATCH 14/42] 1 --- .../dto/InboundMeasurementRequests.java | 12 ++++++++--- .../service/MeasurementBatchService.java | 20 ++++++++++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java index 260ac1924..6f899c510 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java @@ -41,9 +41,15 @@ public final class InboundMeasurementRequests { @NotBlank @Size(max = 32) String itemNo, @NotBlank @Size(max = 64) String boxSpecification, @NotNull @Positive @Max(1000000) Integer packageCount, - @NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal lengthCm, - @NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal widthCm, - @NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal heightCm, + @NotNull @DecimalMin(value = "0.001", message = "箱规长度必须大于等于 0.001 cm") + @DecimalMax(value = "999999999.999", message = "箱规长度不能超过 999,999,999.999 cm") + @Digits(integer = 9, fraction = 3, message = "箱规长度最多 9 位整数、3 位小数") BigDecimal lengthCm, + @NotNull @DecimalMin(value = "0.001", message = "箱规宽度必须大于等于 0.001 cm") + @DecimalMax(value = "999999999.999", message = "箱规宽度不能超过 999,999,999.999 cm") + @Digits(integer = 9, fraction = 3, message = "箱规宽度最多 9 位整数、3 位小数") BigDecimal widthCm, + @NotNull @DecimalMin(value = "0.001", message = "箱规高度必须大于等于 0.001 cm") + @DecimalMax(value = "999999999.999", message = "箱规高度不能超过 999,999,999.999 cm") + @Digits(integer = 9, fraction = 3, message = "箱规高度最多 9 位整数、3 位小数") BigDecimal heightCm, @Positive Long intelligentImageFileId, @Size(max = 500) String overLimitWarning, @DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerLengthCm, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java index eca3c10c2..960dcb8b1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java @@ -35,6 +35,8 @@ public class MeasurementBatchService { /** 已接收第三方测量数据,待平台提交审核。 */ static final String STATUS_RECEIVED = "RECEIVED"; static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167"); + private static final BigDecimal MAX_RAW_VOLUME_CBM = new BigDecimal("999999999999.999999"); + private static final BigDecimal MAX_TOTAL_VOLUME_CBM = new BigDecimal("99999999999999.9999"); @Resource private InboundMeasurementRepository repository; @Resource @@ -140,7 +142,16 @@ public class MeasurementBatchService { BigDecimal unitVolume = weighted.unitVolumeCbm(); BigDecimal packageCount = BigDecimal.valueOf(source.packageCount()); BigDecimal totalVolumeRaw = unitVolumeRaw.multiply(packageCount).setScale(6, RoundingMode.HALF_UP); - BigDecimal totalVolume = unitVolume.multiply(packageCount).setScale(4, RoundingMode.UNNECESSARY); + BigDecimal totalVolume; + try { + totalVolume = unitVolume.multiply(packageCount).setScale(4, RoundingMode.UNNECESSARY); + } catch (ArithmeticException exception) { + throw measurementRangeExceeded(source); + } + if (totalVolumeRaw.compareTo(MAX_RAW_VOLUME_CBM) > 0 + || totalVolume.compareTo(MAX_TOTAL_VOLUME_CBM) > 0) { + throw measurementRangeExceeded(source); + } InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); item.setInboundDetailId(source.inboundDetailId()); @@ -169,6 +180,13 @@ public class MeasurementBatchService { }).toList(); } + private static BizException measurementRangeExceeded(MeasurementBatchCommand.Item source) { + return new BizException(ResultCode.VALIDATION_FAILED, + "箱规尺寸或箱数超出系统允许范围,明细 " + source.itemNo() + ":" + + source.lengthCm() + " × " + source.widthCm() + " × " + source.heightCm() + + " cm,箱数 " + source.packageCount()); + } + private void applyTotals(InboundMeasurementEntity measurement, List items) { long packages = items.stream().mapToLong(InboundMeasurementItemEntity::getPackageCount).sum(); From 62407610cbafe0ca029c7663f0ef62f8b6596bf4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 17:54:50 +0800 Subject: [PATCH 15/42] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mapper/PdaPalletOperationMapper.java | 2 +- .../mapper/PdaStocktakeItemMapper.java | 2 +- ...PalletOperationControllerContractTest.java | 10 ++++ .../PdaInventoryOperationContractTest.java | 9 ++++ .../phase4-overseas-pda-contract.test.mjs | 8 ++- .../phase4-overseas-pda.mjs | 51 +++++++++++++++---- 6 files changed, 68 insertions(+), 14 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java index 91be448d6..f28f4c05a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java @@ -24,7 +24,7 @@ public interface PdaPalletOperationMapper extends BaseMapper @Param("idempotencyKey") String idempotencyKey); @Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " + - "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + "ORDER BY b.customer_id,b.inbound_code,p.id") List content(@Param("warehouseId") String warehouseId, @Param("palletId") long palletId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java index e74e61729..4347c6a68 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java @@ -55,7 +55,7 @@ public interface PdaStocktakeItemMapper extends BaseMapper { int counted(@Param("taskId") long taskId); @Select("SELECT COUNT(*) FROM ll_pda_stocktake_item WHERE task_id=#{taskId} " + - "AND actual_packages IS NOT NULL AND difference_packages<>0") + "AND actual_packages IS NOT NULL AND difference_packages<>0") int differences(@Param("taskId") long taskId); @Select("SELECT id item_id,customer_id,balance_id,position_id,sku_code,product_name,inbound_code," + diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java index 1f728cc1d..fc8416556 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java @@ -1,5 +1,7 @@ package com.baiyue.lastleg.palletops.controller; +import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper; +import org.apache.ibatis.annotations.Select; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PostMapping; @@ -24,4 +26,12 @@ class PdaPalletOperationControllerContractTest { .flatMap(annotation -> Arrays.stream(annotation.value())).collect(Collectors.toSet()); assertEquals(Set.of("/pallets/resolve", "/assemble", "/disassemble", "/swap", "/unbind"), paths); } + + @Test void palletContentAnnotationUsesNativeGreaterThanOperator() throws Exception { + String sql = String.join("", PdaPalletOperationMapper.class.getMethod("content", String.class, long.class) + .getAnnotation(Select.class).value()); + assertTrue(sql.contains("receiving_packages)>0")); + assertFalse(sql.contains("<")); + assertFalse(sql.contains(">")); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java index eaef73a37..b67a31d65 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java @@ -4,6 +4,8 @@ import com.baiyue.lastleg.pda.move.controller.PdaInventoryMoveController; import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest; import com.baiyue.lastleg.pda.stocktake.controller.PdaStocktakeController; import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeItemMapper; +import org.apache.ibatis.annotations.Select; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.RequestMapping; @@ -38,4 +40,11 @@ class PdaInventoryOperationContractTest { assertThat(fields).doesNotContainAnyElementsOf(forbidden); } } + + @Test + void stocktakeAnnotationSqlUsesNativeComparisonOperators() throws Exception { + String sql = String.join("", PdaStocktakeItemMapper.class.getMethod("differences", long.class) + .getAnnotation(Select.class).value()); + assertThat(sql).contains("difference_packages<>0").doesNotContain("<", ">"); + } } diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 2f54f9709..d59239abe 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -54,7 +54,13 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /目的仓 .* 不得代替物理仓/) assert.match(source, /retained as acceptance evidence/) assert.match(source, /preferredProducer\?\.cartonMarks\?\.length/) - assert.match(source, /for \(const \[index, cartonMark\] of cartonMarks\.entries\(\)\)/) + assert.match(source, /const cartonCandidates =/) + assert.match(source, /candidateExcluded = notFound\(response\)/) + assert.match(source, /if \(candidateExcluded\) continue/) + assert.match(source, /const fixturesConserved = cartonFixtures\.length === expectedPending/) + assert.match(source, /OVERSEAS实装箱唛与待收箱数守恒/) + assert.match(source, /for \(const \[index, fixture\] of cartonFixtures\.entries\(\)\)/) + assert.doesNotMatch(source, /for \(const \[index, cartonMark\] of cartonMarks\.entries\(\)\)/) assert.match(source, /last-leg\/orders\?page=1&size=200&containerNo=/) assert.match(source, /phase4OverseasPda\.orderId = id\(order\.id\)/) assert.match(source, /phase4OverseasPda\.customerId = id\(order\.customerId\)/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index e04e67ca4..2fd9bf5c8 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -338,23 +338,52 @@ async function receiveFba(context, token, inbound, pallet, producer) { } async function receiveOverseas(context, token, inbounds, pallets, producerCartonMarks = []) { - const cartonMarks = [...new Set((Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks]) + const cartonCandidates = [...new Set((Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks]) .map(id).filter(nonBlank))] - if (!cartonMarks.length) { + if (!cartonCandidates.length) { blockerGap(context, 'OVERSEAS逐箱链', '缺少本轮PDA任务冻结箱唛,逐箱实收不能使用伪造箱号') return false } - for (const [index, cartonMark] of cartonMarks.entries()) { - const resolved = await pda(context, token, 'POST', `解析OVERSEAS冻结箱唛${index + 1}`, - '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }) - const inbound = inbounds.find((item) => item.inboundCode === resolved.data?.inboundCode) - const pallet = inbound ? pallets.get(inbound.inboundCode) : null - if (!success(resolved.response) || !inbound || !pallet) { - blockerGap(context, `OVERSEAS箱唛${cartonMark}归属`, - `冻结箱唛未解析到带真实托盘的本柜入仓码;inboundCode=${resolved.data?.inboundCode || 'missing'}`, - 'fixture-mismatch', resolved.response) + const expectedPending = inbounds.reduce((total, inbound) => total + Number(inbound.pendingPackages || 0), 0) + const cartonFixtures = [] + let lastResponse = null + for (const [index, cartonMark] of cartonCandidates.entries()) { + const response = await context.requestOverseas('POST', + '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }, {}, token) + lastResponse = response + const candidateExcluded = notFound(response) + context.addStep(MODULE, `筛选OVERSEAS实装箱唛候选${index + 1}`, response, + success(response) || candidateExcluded, + `${candidateExcluded ? 'candidateSelected=false' : 'candidateSelected=true'}; ${trace(response)}`, OPTIONS) + if (candidateExcluded) continue + if (!success(response)) { + blockerGap(context, `OVERSEAS箱唛候选${cartonMark}预检`, + '候选箱唛既非本柜冻结事实,也不是未被最终实装选中的A0404;禁止继续收货', + 'fixture-mismatch', response) return false } + const resolved = response.body?.data + const inbound = inbounds.find((item) => item.inboundCode === resolved?.inboundCode) + const pallet = inbound ? pallets.get(inbound.inboundCode) : null + if (!inbound || !pallet) { + blockerGap(context, `OVERSEAS箱唛${cartonMark}归属`, + `冻结箱唛未解析到带真实托盘的本柜入仓码;inboundCode=${resolved?.inboundCode || 'missing'}`, + 'fixture-mismatch', response) + return false + } + cartonFixtures.push({ cartonMark, pallet }) + } + const fixturesConserved = cartonFixtures.length === expectedPending + context.assert(MODULE, 'OVERSEAS实装箱唛与待收箱数守恒', lastResponse, fixturesConserved, + `resolvedFixtures=${cartonFixtures.length}, pendingPackages=${expectedPending}; ${trace(lastResponse)}`, OPTIONS) + if (!fixturesConserved) { + blockerGap(context, 'OVERSEAS逐箱冻结事实', + `候选池未完整覆盖本柜真实冻结箱唛;resolvedFixtures=${cartonFixtures.length}, pendingPackages=${expectedPending}`, + 'fixture-mismatch', lastResponse) + return false + } + for (const [index, fixture] of cartonFixtures.entries()) { + const { cartonMark, pallet } = fixture const body = { cartonMark, palletCode: pallet.palletCode, idempotencyKey: key(context, `overseas-carton-${index}`) } const first = await pda(context, token, 'POST', `OVERSEAS逐箱实收${index + 1}`, From 335ad8269f062d2650a0c8fa3415105607a0be31 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 18:07:48 +0800 Subject: [PATCH 16/42] fix: execute overseas pallet transitions --- .../WarehousePalletTransitionMapper.java | 4 +-- ...usePalletTransitionMapperContractTest.java | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapperContractTest.java diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java index 751a848c0..9f4b423ba 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java @@ -20,12 +20,12 @@ public interface WarehousePalletTransitionMapper extends BaseMapper'INACTIVE' AND deleted=0") long countActiveLocation(@Param("warehouseId") long warehouseId, @Param("locationId") long locationId); @Update("UPDATE wh_pallet SET status=#{targetStatus},location_id=#{targetLocationId},version=version+1," + "updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{palletId} AND customer_id=0 " + - "AND warehouse_id=#{warehouseId} AND version=#{expectedVersion} AND status<>'SCRAPPED' AND deleted=0") + "AND warehouse_id=#{warehouseId} AND version=#{expectedVersion} AND status<>'SCRAPPED' AND deleted=0") int transition(@Param("warehouseId") long warehouseId, @Param("palletId") long palletId, @Param("targetStatus") String targetStatus, @Param("targetLocationId") long targetLocationId, @Param("expectedVersion") int expectedVersion); diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapperContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapperContractTest.java new file mode 100644 index 000000000..06f4c4d57 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapperContractTest.java @@ -0,0 +1,32 @@ +package com.baiyue.warehouse.mapper; + +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WarehousePalletTransitionMapperContractTest { + + @Test + void annotationSqlUsesNativeComparisonOperatorsInsteadOfXmlEntities() throws Exception { + Method locationQuery = WarehousePalletTransitionMapper.class.getMethod( + "countActiveLocation", long.class, long.class); + String selectSql = String.join(" ", locationQuery.getAnnotation(Select.class).value()); + + Method transition = Arrays.stream(WarehousePalletTransitionMapper.class.getMethods()) + .filter(method -> method.getName().equals("transition")) + .findFirst() + .orElseThrow(); + String updateSql = String.join(" ", transition.getAnnotation(Update.class).value()); + + assertTrue(selectSql.contains("status<>'INACTIVE'")); + assertTrue(updateSql.contains("status<>'SCRAPPED'")); + assertFalse(selectSql.contains("<") || selectSql.contains(">")); + assertFalse(updateSql.contains("<") || updateSql.contains(">")); + } +} From ca69434698371134b27fcf880ecacbe7991e5d70 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 18:11:29 +0800 Subject: [PATCH 17/42] fix: persist PDA movement state semantics --- .../service/PdaPalletOperationService.java | 3 ++- .../move/service/PdaInventoryMoveService.java | 11 ++++---- .../PdaPalletOperationServiceTest.java | 25 +++++++++++++++++++ .../service/PdaInventoryMoveServiceTest.java | 14 ++++++++--- 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java index 3bc77d10c..076d3c66d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java @@ -264,7 +264,8 @@ public class PdaPalletOperationService { ledger.setBusinessType(operation.getOperationType()); ledger.setBusinessOrderId(operation.getId()); ledger.setBusinessOrderNo(String.valueOf(operation.getId())); ledger.setInboundCode(balance.getInboundCode()); ledger.setInboundOrderNo(balance.getInboundOrderNo()); ledger.setIdempotencyKey(idempotencyKey); - ledger.setStatusFrom("PALLET:" + sourcePallet); ledger.setStatusTo("PALLET:" + targetPallet); + ledger.setStatusFrom("OCCUPIED"); + ledger.setStatusTo("UNBOUND".equals(targetPallet) ? "UNBOUND" : "OCCUPIED"); ledger.setBeforePackages(before); ledger.setDeltaPackages(delta); ledger.setAfterPackages(after); ledger.setOperatorId(identity.operatorKey()); ledger.setRemark(trim(reason)); ledger.setOccurredAt(LocalDateTime.now()); ledger.setCreatedAt(ledger.getOccurredAt()); ledgers.insert(ledger); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java index 03be9225d..ab7b71d58 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java @@ -117,8 +117,7 @@ public class PdaInventoryMoveService { position.setWarehouseLocationId(target.locationId()); position.setWarehouseLocationCode(target.locationCode()); position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(completedAt); positions.updateById(position); - ledger(operation, position, balance, operatorKey, key, request.reason(), source.locationCode(), - target.locationCode(), completedAt); + ledger(operation, position, balance, operatorKey, key, request.reason(), completedAt); } operation.setPalletVersionAfter(relocated.afterVersion()); operation.setStatus("COMPLETED"); operation.setCompletedAt(completedAt); @@ -150,18 +149,18 @@ public class PdaInventoryMoveService { } private void ledger(PdaInventoryMove operation, InventoryPosition position, InventoryBalance balance, - String operator, String key, String reason, String source, String target, - LocalDateTime now) { + String operator, String key, String reason, LocalDateTime now) { InventoryLedger row = new InventoryLedger(); row.setId(IdWorker.getId()); row.setCustomerId(position.getCustomerId()); row.setBalanceId(balance.getId()); row.setPositionId(position.getId()); row.setOverseasWarehouseId(String.valueOf(operation.getOverseasWarehouseId())); row.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); row.setInboundOrderId(balance.getInboundOrderId()); row.setPalletId(position.getPalletId()); row.setPalletNo(position.getPalletNo()); row.setBatchNo(position.getBatchNo()); - row.setSkuCode(balance.getSkuCode()); row.setWarehouseLocationCode(target); row.setBusinessType("INTERNAL_MOVE"); + row.setSkuCode(balance.getSkuCode()); row.setWarehouseLocationCode(position.getWarehouseLocationCode()); + row.setBusinessType("INTERNAL_MOVE"); row.setBusinessOrderId(operation.getId()); row.setBusinessOrderNo(operation.getMoveNo()); row.setInboundCode(balance.getInboundCode()); row.setInboundOrderNo(balance.getInboundOrderNo()); row.setIdempotencyKey("MOVE:" + operation.getId() + ":" + position.getId()); - row.setStatusFrom(source); row.setStatusTo(target); long packages = physical(position); + row.setStatusFrom("OCCUPIED"); row.setStatusTo("OCCUPIED"); long packages = physical(position); row.setBeforePackages(packages); row.setDeltaPackages(0L); row.setAfterPackages(packages); row.setOperatorId(operator); row.setRemark(reason.trim()); row.setOccurredAt(now); row.setCreatedAt(now); ledgers.insert(row); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java index 59301bc76..d13c5b887 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java @@ -3,6 +3,9 @@ package com.baiyue.lastleg.palletops.service; import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; import com.baiyue.lastleg.palletops.dto.PalletOperationRequests; import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; @@ -15,6 +18,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; +import org.mockito.ArgumentCaptor; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.TransactionDefinition; @@ -94,6 +98,27 @@ class PdaPalletOperationServiceTest { verifyNoInteractions(ledgers, history); } + @Test void inventoryLedgerStoresStateSemanticsInsteadOfLongPalletCodes() { + InventoryBalance balance = new InventoryBalance(); balance.setId(200L); balance.setCustomerId("100"); + balance.setOverseasWarehouseId("88"); balance.setInboundOrderId(300L); balance.setInboundCode("IN-1"); + InventoryPosition position = new InventoryPosition(); position.setId(100L); position.setPalletId(20L); + position.setPalletNo("PDAOPS-ADMIN-API-20260825095854036-SWAP-TARGET"); + position.setBatchNo("DEFAULT"); position.setWarehouseLocationCode("PDAOPS-LONG-LOCATION-CODE"); + PdaPalletOperation operation = new PdaPalletOperation(); operation.setId(400L); + operation.setOperationType("PALLET_SWAP"); + + ReflectionTestUtils.invokeMethod(service, "ledger", identity(), operation, balance, position, + "idem:out", 5L, -5L, 0L, + "PDAOPS-ADMIN-API-20260825095854036-SOURCE", + "PDAOPS-ADMIN-API-20260825095854036-TARGET", "swap"); + + ArgumentCaptor ledger = ArgumentCaptor.forClass(InventoryLedger.class); + verify(ledgers).insert(ledger.capture()); + assertEquals("OCCUPIED", ledger.getValue().getStatusFrom()); + assertEquals("OCCUPIED", ledger.getValue().getStatusTo()); + assertEquals("PDAOPS-LONG-LOCATION-CODE", ledger.getValue().getWarehouseLocationCode()); + } + private static PdaWarehouseIdentity identity() { return new PdaWarehouseIdentity("7", "picker", 88L); } private static PalletPhysicalGateway.PalletReference pallet(long id, String code, long warehouse, int version) { return new PalletPhysicalGateway.PalletReference(id, code, warehouse, 1000L + id, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java index cdb3714ae..7207e9c2f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java @@ -60,9 +60,10 @@ class PdaInventoryMoveServiceTest { @Test void atomicallyRelocatesPhysicalPalletAndEveryWarehouseScopedPosition() { + String targetLocationCode = "PDAOPS-ADMIN-API-20260825095854036-MOVE"; when(warehouse.resolvePallet("PAL-1")).thenReturn(pallet(900L, 20L, 2)); when(warehouse.resolveLocation(900L, 20L)).thenReturn(location(20L, "A-01")); - when(warehouse.resolveLocation(900L, "B-01")).thenReturn(location(21L, "B-01")); + when(warehouse.resolveLocation(900L, targetLocationCode)).thenReturn(location(21L, targetLocationCode)); InventoryPosition position = new InventoryPosition(); position.setId(11L); position.setCustomerId("100"); position.setBalanceId(10L); position.setOverseasWarehouseId("900"); position.setPalletId(30L); position.setPalletNo("PAL-1"); position.setWarehouseLocationId(20L); position.setWarehouseLocationCode("A-01"); @@ -76,7 +77,7 @@ class PdaInventoryMoveServiceTest { eq(21L), anyString(), eq("库位优化"))).thenReturn(transition(false)); var response = service.move(900L, "operator-1", - new PdaInventoryMoveRequest("PAL-1", "B-01", 2, "move-1", "库位优化")); + new PdaInventoryMoveRequest("PAL-1", targetLocationCode, 2, "move-1", "库位优化")); verify(physical).apply(eq(900L), argThat(value -> value.palletId() == 30L && value.locationId() == 20L && value.version() == 2), eq("OCCUPIED"), @@ -84,11 +85,16 @@ class PdaInventoryMoveServiceTest { ArgumentCaptor updated = ArgumentCaptor.forClass(InventoryPosition.class); verify(positions).updateById(updated.capture()); assertThat(updated.getValue().getWarehouseLocationId()).isEqualTo(21L); - assertThat(updated.getValue().getWarehouseLocationCode()).isEqualTo("B-01"); + assertThat(updated.getValue().getWarehouseLocationCode()).isEqualTo(targetLocationCode); assertThat(response.packageCount()).isEqualTo(6L); assertThat(response.palletVersion()).isEqualTo(3); assertThat(response.status()).isEqualTo("COMPLETED"); - verify(ledgers).insert(any(InventoryLedger.class)); verify(moves).updateById(any(PdaInventoryMove.class)); + ArgumentCaptor ledger = ArgumentCaptor.forClass(InventoryLedger.class); + verify(ledgers).insert(ledger.capture()); + assertThat(ledger.getValue().getStatusFrom()).isEqualTo("OCCUPIED"); + assertThat(ledger.getValue().getStatusTo()).isEqualTo("OCCUPIED"); + assertThat(ledger.getValue().getWarehouseLocationCode()).isEqualTo(targetLocationCode); + verify(moves).updateById(any(PdaInventoryMove.class)); } @Test From b6226f1ca6a3a3da644780500bbe153720d3bb4c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 18:21:39 +0800 Subject: [PATCH 18/42] fix: replay completed pallet operations --- .../service/PdaPalletOperationService.java | 21 +++++++++++++++++++ .../PdaPalletOperationServiceTest.java | 20 ++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java index 076d3c66d..35bcdee74 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java @@ -71,6 +71,9 @@ public class PdaPalletOperationService { public PalletOperationResponses.Operation assemble(PdaWarehouseIdentity identity, PalletOperationRequests.Assemble request) { + PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_ASSEMBLE", + request.idempotencyKey(), request); + if (replay != null) return replay; Map pallets = resolvePallets(identity.overseasWarehouseId(), request.target(), request.sources()); PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode())); @@ -85,6 +88,9 @@ public class PdaPalletOperationService { public PalletOperationResponses.Operation disassemble(PdaWarehouseIdentity identity, PalletOperationRequests.Disassemble request) { + PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_DISASSEMBLE", + request.idempotencyKey(), request); + if (replay != null) return replay; LinkedHashMap expectations = new LinkedHashMap<>(); expectations.put(code(request.source().palletCode()), request.source()); request.targets().forEach(value -> expectations.put(code(value.target().palletCode()), value.target())); @@ -109,6 +115,9 @@ public class PdaPalletOperationService { public PalletOperationResponses.Operation swap(PdaWarehouseIdentity identity, PalletOperationRequests.Swap request) { + PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_SWAP", + request.idempotencyKey(), request); + if (replay != null) return replay; Map pallets = resolvePallets(identity.overseasWarehouseId(), request.source(), List.of(request.target())); PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode())); @@ -129,6 +138,9 @@ public class PdaPalletOperationService { public PalletOperationResponses.Operation unbind(PdaWarehouseIdentity identity, PalletOperationRequests.Unbind request) { + PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_UNBIND", + request.idempotencyKey(), request); + if (replay != null) return replay; Map pallets = resolvePallets(identity.overseasWarehouseId(), List.of(request.source())); PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode())); @@ -413,6 +425,15 @@ public class PdaPalletOperationService { return value; } + private PalletOperationResponses.Operation replayIfPresent(PdaWarehouseIdentity identity, String type, + String key, Object request) { + String normalizedKey = required(key, "幂等键不能为空"); + PdaPalletOperation existing = mapper.findOperation(identity.operatorKey(), normalizedKey); + if (existing == null) return null; + validateExisting(existing, type, hash(request)); + return read(existing).asReplay(); + } + private void saveTransitions(long operationId, List transitions) { newTransaction.executeWithoutResult(status -> { PdaPalletOperation operation = mapper.selectById(operationId); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java index d13c5b887..ed82fcf29 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java @@ -27,6 +27,7 @@ import org.springframework.transaction.support.DefaultTransactionStatus; import org.springframework.web.server.ResponseStatusException; import java.util.List; +import java.time.LocalDateTime; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; @@ -119,6 +120,25 @@ class PdaPalletOperationServiceTest { assertEquals("PDAOPS-LONG-LOCATION-CODE", ledger.getValue().getWarehouseLocationCode()); } + @Test void exactReplayReturnsStoredResultBeforeValidatingChangedPalletVersions() throws Exception { + var request = new PalletOperationRequests.Swap("idem-replay", + new PalletOperationRequests.PalletExpectation("SOURCE", 1), + new PalletOperationRequests.PalletExpectation("TARGET", 2), "swap"); + PdaPalletOperation existing = new PdaPalletOperation(); existing.setId(500L); + existing.setOperationType("PALLET_SWAP"); existing.setStatus("COMPLETED"); + existing.setRequestHash(ReflectionTestUtils.invokeMethod(service, "hash", request)); + var stored = new com.baiyue.lastleg.palletops.dto.PalletOperationResponses.Operation( + 500L, "PALLET_SWAP", "idem-replay", "COMPLETED", false, LocalDateTime.now(), List.of()); + existing.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(stored)); + when(mapper.findOperation("7", "idem-replay")).thenReturn(existing); + + var replay = service.swap(identity(), request); + + assertTrue(replay.replayed()); + assertEquals(500L, replay.operationId()); + verifyNoInteractions(physical); + } + private static PdaWarehouseIdentity identity() { return new PdaWarehouseIdentity("7", "picker", 88L); } private static PalletPhysicalGateway.PalletReference pallet(long id, String code, long warehouse, int version) { return new PalletPhysicalGateway.PalletReference(id, code, warehouse, 1000L + id, From 65c01f8b3d30a7e64142b9761067322fe06bbe34 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 18:32:12 +0800 Subject: [PATCH 19/42] test: isolate phase4 carrier fixtures --- scripts/admin-test-env-acceptance.mjs | 4 ++++ .../phase3-phase4-producer-fixture-contract.test.mjs | 1 + 2 files changed, 5 insertions(+) diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 6bb27b5ea..1b6f37546 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -1029,6 +1029,10 @@ export class AcceptanceRun { present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key], }])) let restoreFixture = null + // Each producer variant must create and advance its own loading, outbound and carrier facts. + // Keeping the outer run's objects here can make a later container write PICKUP_READY nodes + // to an earlier carrier task while still reporting the variant as ready. + for (const key of createdKeys) delete this.createdResources[key] this.marker = variantMarker this.context.orderDestinationType = destinationType this.context.orderFulfillmentType = fulfillmentType diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index 32699b741..df0841e3e 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -112,6 +112,7 @@ test('only PDA-operated variants traverse real outbound and carrier nodes before } for (const key of ['phase3Customs', 'serviceCenterFlow', 'portSchedule', 'phase3OutboundDispatch', 'phase3CarrierScheduleQuery', 'phase4CarrierPickupReady']) assert.match(entry, new RegExp(`'${key}'`)) + assert.match(entry, /for \(const key of createdKeys\) delete this\.createdResources\[key\]/) assert.match(pickupChain, /shippingReady/) assert.match(entry, /carrierScheduleQueryId/) assert.match(pickupChain, /pickup\?\.marker === variantMarker/) From 7d36759f3f7eb4c215410b412bec295c170394ed Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 19:07:34 +0800 Subject: [PATCH 20/42] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common-web/AGENTS.md | 2 + .../common/web/OutboundTraceHeaders.java | 40 ++++++++++++ .../common/web/RestClientTraceCustomizer.java | 24 +++++++ .../web/RestClientTraceInterceptor.java | 21 ++++++ ...ientTracePropagationAutoConfiguration.java | 25 +++++++ .../com/baiyue/common/web/TraceParent.java | 19 ++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../common/web/OutboundTraceHeadersTest.java | 41 ++++++++++++ .../web/RestClientTraceInterceptorTest.java | 60 +++++++++++++++++ ...TracePropagationAutoConfigurationTest.java | 37 +++++++++++ .../baiyue/common/web/TraceParentTest.java | 8 +++ docs/Order-Firstleg在线依赖解环设计.md | 60 +++++++++++++++++ docs/同步调用依赖与失败传播治理基线.md | 10 ++- event-bridge-service/AGENTS.md | 2 + .../docs/event-trace-propagation.md | 16 +++++ .../service/HttpFirstlegEventClient.java | 7 +- .../service/HttpLastlegEventClient.java | 7 +- .../HttpSecurityProjectionEventClient.java | 8 ++- .../service/OverseasEventDispatcher.java | 16 ++++- .../EventBridgeHttpTracePropagation.java | 19 ++++++ .../trace/EventBridgeTraceScope.java | 35 ++++++++++ .../service/OverseasEventDispatcherTest.java | 10 ++- .../EventBridgeTracePropagationTest.java | 65 +++++++++++++++++++ scripts/java-quality-baseline.json | 59 +++++++++++++++++ scripts/java-quality-summary.mjs | 2 +- scripts/java-quality-summary.test.mjs | 10 +++ scripts/synchronous-dependency-baseline.mjs | 3 +- .../synchronous-dependency-contract.test.mjs | 22 ++++++- 28 files changed, 613 insertions(+), 16 deletions(-) create mode 100644 common-web/src/main/java/com/baiyue/common/web/OutboundTraceHeaders.java create mode 100644 common-web/src/main/java/com/baiyue/common/web/RestClientTraceCustomizer.java create mode 100644 common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java create mode 100644 common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java create mode 100644 common-web/src/test/java/com/baiyue/common/web/OutboundTraceHeadersTest.java create mode 100644 common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java create mode 100644 common-web/src/test/java/com/baiyue/common/web/RestClientTracePropagationAutoConfigurationTest.java create mode 100644 docs/Order-Firstleg在线依赖解环设计.md create mode 100644 event-bridge-service/docs/event-trace-propagation.md create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java create mode 100644 scripts/java-quality-baseline.json diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index d2312b7cf..019c951df 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -13,6 +13,7 @@ | 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` | | W3C Trace Context | `src/main/java/com/baiyue/common/web/TraceParent.java`;严格接收 version 00,响应 `traceparent`、`X-Trace-Id` 与 MDC `traceId` 使用同一 trace ID | | Feign Trace 传播 | `FeignTraceRequestInterceptor.java`、`FeignTracePropagationAutoConfiguration.java`;仅 Feign 存在时装配,optional 依赖不向无 Feign 服务传递 | +| RestClient Trace 传播 | `RestClientTraceInterceptor.java`、`RestClientTracePropagationAutoConfiguration.java`;Spring 管理的 Builder 自动装配,原始 Builder 可显式复用拦截器 | | 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 | | 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 | | 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` | @@ -27,5 +28,6 @@ - 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。 - 入站 Trace 优先级固定为:合法单值 `traceparent` > 完全缺失 `traceparent` 时的 32 位非零十六进制 `X-Trace-Id` > 新 trace。只要 `traceparent` 已出现但重复、超长、格式非法或含全零 ID,就必须生成新 trace,不得回退信任 `X-Trace-Id`;请求 ID 独立按 `X-Request-Id` 校验或生成。 - 同步 Feign 出站固定先移除调用模板中的 `X-Request-Id`、`X-Trace-Id`、`traceparent`,再仅从当前 MDC 的合法请求 ID 和 trace ID 重建;`traceparent` 沿用当前 trace ID、每次调用生成新非零 span ID、flags 固定 `01`。不得从业务参数、远端返回头或无效 MDC 建立上下文;本能力不处理 Reactor、异步线程、重试、超时或 fallback。 +- 同步 RestClient 出站统一覆盖 `X-Request-Id`、`X-Trace-Id`、`traceparent`,从当前 MDC 生成 child span;MDC 缺失或非法时安全新建 trace。不得从业务参数或远端响应头建立上下文,生产代码应使用 Spring 管理的 `RestClient.Builder`,原始 Builder 必须显式安装同一拦截器。 - 从父目录运行 `mvn -pl common-web -am test`。 - 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。 diff --git a/common-web/src/main/java/com/baiyue/common/web/OutboundTraceHeaders.java b/common-web/src/main/java/com/baiyue/common/web/OutboundTraceHeaders.java new file mode 100644 index 000000000..bbd1be9a4 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/OutboundTraceHeaders.java @@ -0,0 +1,40 @@ +package com.baiyue.common.web; + +import org.slf4j.MDC; + +import java.util.UUID; +import java.util.regex.Pattern; + +/** Immutable standard headers for one synchronous outbound child span. */ +public record OutboundTraceHeaders(String requestId, String traceId, String traceParent) { + private static final Pattern SAFE_REQUEST_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}"); + + /** Builds a child span from the current MDC, safely starting a new trace when absent/invalid. */ + public static OutboundTraceHeaders fromCurrentOrNew() { + String requestId = safeRequestId(MDC.get(CorrelationContext.REQUEST_ID)); + String traceId = TraceParent.normalizeTrustedTraceId(MDC.get(CorrelationContext.TRACE_ID)) + .orElseGet(() -> TraceParent.newRoot(null).traceId()); + return child(requestId, traceId); + } + + /** Builds a local child from a trusted persisted trace value, or safely starts a new trace. */ + public static OutboundTraceHeaders fromTrustedTraceOrNew(String trustedTraceId) { + String traceId = TraceParent.normalizeTrustedTraceId(trustedTraceId) + .orElseGet(() -> TraceParent.newRoot(null).traceId()); + return child(UUID.randomUUID().toString(), traceId); + } + + public String spanId() { + return TraceParent.parse(traceParent).orElseThrow().parentId(); + } + + private static OutboundTraceHeaders child(String requestId, String traceId) { + TraceParent child = TraceParent.outboundChild(traceId); + return new OutboundTraceHeaders(requestId, child.traceId(), child.value()); + } + + private static String safeRequestId(String value) { + return value != null && SAFE_REQUEST_ID.matcher(value).matches() + ? value : UUID.randomUUID().toString(); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/RestClientTraceCustomizer.java b/common-web/src/main/java/com/baiyue/common/web/RestClientTraceCustomizer.java new file mode 100644 index 000000000..0fcf7ad9f --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/RestClientTraceCustomizer.java @@ -0,0 +1,24 @@ +package com.baiyue.common.web; + +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.core.Ordered; +import org.springframework.web.client.RestClient; + +/** Installs the trace interceptor after ordinary RestClient customizers. */ +public final class RestClientTraceCustomizer implements RestClientCustomizer, Ordered { + private final RestClientTraceInterceptor interceptor; + + public RestClientTraceCustomizer(RestClientTraceInterceptor interceptor) { + this.interceptor = interceptor; + } + + @Override + public void customize(RestClient.Builder builder) { + builder.requestInterceptor(interceptor); + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java b/common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java new file mode 100644 index 000000000..488cd808f --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java @@ -0,0 +1,21 @@ +package com.baiyue.common.web; + +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; + +import java.io.IOException; + +/** Applies the trusted synchronous trace context to Spring RestClient requests. */ +public final class RestClientTraceInterceptor implements ClientHttpRequestInterceptor { + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, + ClientHttpRequestExecution execution) throws IOException { + OutboundTraceHeaders outbound = OutboundTraceHeaders.fromCurrentOrNew(); + request.getHeaders().set(CorrelationContext.REQUEST_ID_HEADER, outbound.requestId()); + request.getHeaders().set(CorrelationContext.TRACE_ID_HEADER, outbound.traceId()); + request.getHeaders().set(CorrelationContext.TRACE_PARENT_HEADER, outbound.traceParent()); + return execution.execute(request, body); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java new file mode 100644 index 000000000..6acccad5d --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java @@ -0,0 +1,25 @@ +package com.baiyue.common.web; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.web.client.RestClient; + +/** Adds trace propagation to Spring-managed RestClient builders. */ +@AutoConfiguration(afterName = "org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration") +@ConditionalOnClass({RestClient.class, RestClientCustomizer.class}) +public class RestClientTracePropagationAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(RestClientTraceInterceptor.class) + RestClientTraceInterceptor restClientTraceInterceptor() { + return new RestClientTraceInterceptor(); + } + + @Bean + RestClientTraceCustomizer baiyueTraceRestClientCustomizer(RestClientTraceInterceptor interceptor) { + return new RestClientTraceCustomizer(interceptor); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/TraceParent.java b/common-web/src/main/java/com/baiyue/common/web/TraceParent.java index 2ece6f081..88ae5829f 100644 --- a/common-web/src/main/java/com/baiyue/common/web/TraceParent.java +++ b/common-web/src/main/java/com/baiyue/common/web/TraceParent.java @@ -3,6 +3,7 @@ package com.baiyue.common.web; import java.security.SecureRandom; import java.util.HexFormat; import java.util.Optional; +import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -63,6 +64,24 @@ public record TraceParent(String traceId, String parentId, String traceFlags) { return new TraceParent(currentTraceId.toLowerCase(java.util.Locale.ROOT), randomNonZero(8), "01"); } + /** + * Normalizes a trace ID from a trusted persisted/internal context. + * + *

Unlike an untrusted HTTP header, a canonical UUID is accepted and compacted because + * existing event envelopes historically persisted UUID trace IDs.

+ */ + public static Optional normalizeTrustedTraceId(String value) { + if (validLegacyTraceId(value)) { + return Optional.of(value.toLowerCase(Locale.ROOT)); + } + if (value != null && value.matches( + "[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}")) { + String compact = value.replace("-", "").toLowerCase(Locale.ROOT); + return validLegacyTraceId(compact) ? Optional.of(compact) : Optional.empty(); + } + return Optional.empty(); + } + public static boolean validLegacyTraceId(String value) { return value != null && LEGACY_TRACE_ID.matcher(value).matches() && !ZERO_TRACE_ID.equalsIgnoreCase(value); diff --git a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 06a277bae..2d417843c 100644 --- a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,3 +1,4 @@ com.baiyue.common.web.LongIdJacksonConfiguration com.baiyue.common.web.CommonWebAutoConfiguration com.baiyue.common.web.FeignTracePropagationAutoConfiguration +com.baiyue.common.web.RestClientTracePropagationAutoConfiguration diff --git a/common-web/src/test/java/com/baiyue/common/web/OutboundTraceHeadersTest.java b/common-web/src/test/java/com/baiyue/common/web/OutboundTraceHeadersTest.java new file mode 100644 index 000000000..c0f3c006a --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/OutboundTraceHeadersTest.java @@ -0,0 +1,41 @@ +package com.baiyue.common.web; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +import static org.assertj.core.api.Assertions.assertThat; + +class OutboundTraceHeadersTest { + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void currentContextCreatesAValidChildWithoutUsingBusinessValues() { + MDC.put(CorrelationContext.REQUEST_ID, "req-trusted"); + MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736"); + + OutboundTraceHeaders headers = OutboundTraceHeaders.fromCurrentOrNew(); + + assertThat(headers.requestId()).isEqualTo("req-trusted"); + assertThat(headers.traceId()).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(headers.traceParent()) + .matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + } + + @Test + void canonicalPersistedUuidIsCompactedAndInvalidValueStartsNewTrace() { + OutboundTraceHeaders uuid = OutboundTraceHeaders.fromTrustedTraceOrNew( + "550e8400-e29b-41d4-a716-446655440000"); + OutboundTraceHeaders invalid = OutboundTraceHeaders.fromTrustedTraceOrNew( + "ORDER:customer-99:booking-123"); + + assertThat(uuid.traceId()).isEqualTo("550e8400e29b41d4a716446655440000"); + assertThat(invalid.traceId()).matches("[0-9a-f]{32}") + .isNotEqualTo("ORDER:customer-99:booking-123"); + assertThat(invalid.requestId()).matches("[0-9a-f-]{36}"); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java b/common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java new file mode 100644 index 000000000..b8c6e74e7 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java @@ -0,0 +1,60 @@ +package com.baiyue.common.web; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.mock.http.client.MockClientHttpResponse; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class RestClientTraceInterceptorTest { + private final RestClientTraceInterceptor interceptor = new RestClientTraceInterceptor(); + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void trustedContextOverwritesAllCallerProvidedTraceHeaders() throws Exception { + MDC.put(CorrelationContext.REQUEST_ID, "req-rest-client"); + MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736"); + MockClientHttpRequest request = new MockClientHttpRequest(); + request.getHeaders().add(CorrelationContext.REQUEST_ID_HEADER, "business-request-id"); + request.getHeaders().add(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + request.getHeaders().add(CorrelationContext.TRACE_PARENT_HEADER, + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00"); + AtomicReference captured = new AtomicReference<>(); + + interceptor.intercept(request, new byte[0], (outbound, body) -> { + captured.set(outbound.getHeaders()); + return new MockClientHttpResponse(new byte[0], org.springframework.http.HttpStatus.OK); + }); + + assertThat(captured.get().getValuesAsList(CorrelationContext.REQUEST_ID_HEADER)) + .containsExactly("req-rest-client"); + assertThat(captured.get().getValuesAsList(CorrelationContext.TRACE_ID_HEADER)) + .containsExactly("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(captured.get().getFirst(CorrelationContext.TRACE_PARENT_HEADER)) + .matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + } + + @Test + void missingOrInvalidMdcSafelyCreatesNewStandardHeaders() throws Exception { + MDC.put(CorrelationContext.TRACE_ID, "business-order-123"); + MockClientHttpRequest request = new MockClientHttpRequest(); + + interceptor.intercept(request, new byte[0], (outbound, body) -> + new MockClientHttpResponse(new byte[0], org.springframework.http.HttpStatus.OK)); + + assertThat(request.getHeaders().getFirst(CorrelationContext.REQUEST_ID_HEADER)) + .matches("[0-9a-f-]{36}"); + assertThat(request.getHeaders().getFirst(CorrelationContext.TRACE_ID_HEADER)) + .matches("[0-9a-f]{32}").isNotEqualTo("business-order-123"); + assertThat(request.getHeaders().getFirst(CorrelationContext.TRACE_PARENT_HEADER)) + .matches("00-[0-9a-f]{32}-[0-9a-f]{16}-01"); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/RestClientTracePropagationAutoConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/RestClientTracePropagationAutoConfigurationTest.java new file mode 100644 index 000000000..f2edb83d2 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/RestClientTracePropagationAutoConfigurationTest.java @@ -0,0 +1,37 @@ +package com.baiyue.common.web; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.core.Ordered; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class RestClientTracePropagationAutoConfigurationTest { + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(RestClientTracePropagationAutoConfiguration.class)); + + @Test + void registersInterceptorAndIndependentCustomizer() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(RestClientTraceInterceptor.class); + assertThat(context).hasBean("baiyueTraceRestClientCustomizer"); + assertThat(context.getBeansOfType(RestClientCustomizer.class)).hasSize(1); + RestClientTraceCustomizer customizer = context.getBean(RestClientTraceCustomizer.class); + assertThat(customizer.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE); + RestClient.Builder builder = mock(RestClient.Builder.class); + customizer.customize(builder); + verify(builder).requestInterceptor(context.getBean(RestClientTraceInterceptor.class)); + }); + } + + @Test + void reusesApplicationProvidedTraceInterceptor() { + contextRunner.withBean(RestClientTraceInterceptor.class, RestClientTraceInterceptor::new) + .run(context -> assertThat(context).hasSingleBean(RestClientTraceInterceptor.class)); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java b/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java index e59be28aa..e67870a1c 100644 --- a/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java @@ -51,4 +51,12 @@ class TraceParentTest { assertThat(child.value()).matches( "00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); } + + @Test + void trustedPersistedUuidNormalizationDoesNotBroadenHttpParsing() { + assertThat(TraceParent.normalizeTrustedTraceId("550E8400-E29B-41D4-A716-446655440000")) + .contains("550e8400e29b41d4a716446655440000"); + assertThat(TraceParent.parse( + "00-550E8400E29B41D4A716446655440000-00f067aa0ba902b7-01")).isEmpty(); + } } diff --git a/docs/Order-Firstleg在线依赖解环设计.md b/docs/Order-Firstleg在线依赖解环设计.md new file mode 100644 index 000000000..d8828c29e --- /dev/null +++ b/docs/Order-Firstleg在线依赖解环设计.md @@ -0,0 +1,60 @@ +# Order↔Firstleg 在线依赖解环设计 + +## 结论 + +当前不能在不改变已确认产品成功时点的前提下直接实施解环,业务代码保持不变。在线图仍包含 +`Order→Firstleg` 与 `Firstleg→Order`,所以非平凡强连通分量仍是 `Firstleg↔Order`。 + +建议的最小退出方向是 **退出全部 `Firstleg→Order` REQUEST 边**。该方向只有 +`OrderCargoClient` 和 `OrderFulfillmentSplitClient` 两个客户端;Order 继续拥有订单、货物、父子订单和 +拆分台账,Firstleg 继续拥有入库、测量、分柜装柜、PDA 拣货和库位结算。完成后只保留 +`Order→Firstleg` 在线查询/门禁边以及双方后台边,在线 SCC 才会实际消失。 + +## 当前可执行调用 + +| 方向/客户端 | Controller→Service→Client→被调用入口 | 时点与调用者期望 | 失败传播 | 幂等性 | +|---|---|---|---|---| +| Order→Firstleg `FirstlegPricingClient` | `MerchantFirstLegOrderController`、`PlatformFirstLegOrderController` 的创建/编辑/提交 → `OrderApplicationService` → `OrderPricingChannelGuard` → `/internal/firstleg/v1/pricing/channels/validate` | 保存或提交前实时确认渠道有效;提交还必须取得入仓码前缀 | 业务错误原码上传;连接、空响应或不完整数据映射依赖不可用/冲突,订单不提交 | 纯读取 `SAFE_READ` | +| Order→Firstleg `FirstlegUnifiedOrderClient` | `PlatformUnifiedOrderController`、`MerchantUnifiedOrderController` 列表/详情 → `UnifiedOrderQueryService` → Firstleg unified query/summaries/detail | 一个响应内组合 Order 主数据与 Firstleg 实时履约、测量、装柜和时间线 | 远端异常、失败码、空数据均失败关闭,不返回伪造履约摘要 | 纯读取 `SAFE_READ` | +| Order→Firstleg `FirstlegInboundClient` | `MerchantFirstLegOrderController.page` → `MerchantInboundOrderListService` → references/prepareBatch;已提交订单编辑提交后 → `OrderApplicationService.prepareDraftInbound` → prepare;审核 Outbox → `OrderInboundOutboxPublisher` → create | 列表需要入库引用;草稿/待审编辑尝试同步预入库;审核成功已与 Firstleg 建单解耦 | references 失败关闭;prepare/prepareBatch 当前捕获异常并允许列表缺少入库引用,是现有失败传播缺口;审核 Outbox 持久化重试 | `tenantId+orderId` 业务键;Firstleg 对订单唯一且入仓码一致时幂等 | +| Firstleg→Order `OrderCargoClient` | 箱号 Controller/Order 审核后台建单 → `InboundCartonLabelService`;测量 Controller/OpenAPI → `InboundMeasurementService`/`InboundMeasurementOpenApiService`;智能装柜 Controller → `SmartLoadingService` → Order cargo/snapshot endpoints | 首次箱号、测量明细、申报回填和装柜清单均要求当前订单货物事实 | 服务不可用、空数据、归属不一致均阻断当前操作;没有默认货物或空列表成功 | 纯读取 `SAFE_READ`;同时存在 REQUEST 和审核 Outbox 回调产生的 BACKGROUND 上下文 | +| Firstleg→Order `OrderFulfillmentSplitClient` | `PlatformLoadingPdaController.complete` → `LoadingPickService.complete @Transactional` → `PdaPickSettlementService.settle` → `/internal/order/v1/fulfillment-splits` → `OrderFulfillmentSplitService.split @Transactional` | PDA 完成请求必须先取得 Order 真实余量子单 ID/单号,再在同一 Firstleg 事务写分片、测量、库位、审计并推进装柜完成 | Order 不可用或响应数量/柜次/父子关系不一致时整个 Firstleg 完成事务回滚;不得返回完成成功 | Firstleg 使用 `idempotencyKey=pda-task-{taskId}`、`operationId=PDA-SPLIT-{taskId}-{inboundId}`;Order 保存请求哈希并以唯一键重放 | + +## 事实所有权 + +- Order:订单交易状态、订单货物及允许编辑的字段、父子订单 ID/单号、履约拆分台账和订单版本。 +- Firstleg:入库单、测量事实、分柜/装柜计划、PDA 拣货数量、库位结算、装柜证据和 `LOADING→LOADED` 门禁。 +- 允许复制的仅是带来源版本和哈希的 Order 快照;Firstleg 不得成为订单货物或父子订单的第二写入方。 +- 统一订单查询是组合读模型,不把 Firstleg 履约状态写回 Order 交易状态。 + +## 推荐退出方案 + +### 1. 先退出货物在线读取 + +复用 Order 已有 Outbox 和 Firstleg 已有订单入库接收入口,将完整货物快照、订单版本和规范化哈希随草稿准备、待审编辑和审核事件可靠投递;Firstleg 以 +`tenantId+orderId+orderVersion` 单调 Inbox/快照落库。箱号、测量和智能装柜只读本地快照,缺失、版本冲突或哈希冲突均失败关闭。 + +不能只在审核时复制一次:当前契约允许 `LOADED` 前按权限和状态修改业务资料。必须先冻结货物在哪个节点停止修改,或要求每次允许的 Order 编辑都可靠发布版本事件,并明确 Firstleg 在投影落后期间阻断哪些操作。 + +### 2. 再将履约拆分改为可靠后台命令 + +Firstleg 在本地持久化稳定 operation/idempotency key、数量行、柜次和请求哈希;后台 Relay 调用现有 Order 幂等拆分接口并保存完整响应。Order 现有拆分台账和唯一键可承担 Inbox/业务幂等,不需要新服务或 MQ。 + +Firstleg 只有在后台结果为 `READY` 且父子关系、数量和柜次全部校验通过后,才能消费余量子订单 ID并执行本地结算。失败进入可诊断重试/DEAD,禁止默认成功。远端成功、本地未完成时只允许向前恢复,不得自动删除 Order 子订单。 + +这一方案会引入技术态 `PENDING/READY/FAILED`,但不能擅自增加产品主状态。若在 PDA 首次点击完成时只创建任务并返回待处理,成功时点发生变化;若提前创建 Order 子单,则子单可能在装柜硬门禁完成前可见,同样改变现有语义。 + +## 必须由产品冻结的决策 + +1. **货物冻结点**:Order 货物在审核通过、首次生成箱号、确认测量还是确认预装后不可再改;若仍允许修改,哪些 Firstleg 操作必须等待最新 `orderVersion` 投影。 +2. **PDA 完成成功时点**:必须继续在一次请求中同时完成 Order 子单与 Firstleg 结算,还是允许首次请求返回“处理中”,后台完成后才成为 `LOADED`。同时确认后台已创建 Order 子单但 Firstleg 最终失败时采用持续向前修复,不提供在线冲正。 + +上述任一决策未冻结前,不实施业务解环。 + +## 最小实施与验证边界(决策冻结后) + +- Order:扩充版本化货物快照 Outbox;保留 `OrderFulfillmentSplitService` 事实所有权和现有幂等唯一键。 +- Firstleg:货物快照 Inbox/表、拆分命令 Outbox/Relay/诊断查询;替换两个在线 Client 的业务调用点。 +- SQL:仅更新当前模块基线与统一增量,不引入 Flyway/Liquibase,不执行 SQL。 +- 合同:证明生产请求路径不再引用两个客户端、`Firstleg→Order` 只剩 BACKGROUND,REQUEST SCC 中不再出现两者同一分量。 +- 测试:Order/Firstleg 全量直接测试、重复/异哈希事件、租约恢复、远端成功本地失败重放、DEAD 人工重试、PDA 未 READY 失败关闭,以及同步依赖合同。 diff --git a/docs/同步调用依赖与失败传播治理基线.md b/docs/同步调用依赖与失败传播治理基线.md index b9c4dde5f..f7cba31aa 100644 --- a/docs/同步调用依赖与失败传播治理基线.md +++ b/docs/同步调用依赖与失败传播治理基线.md @@ -26,7 +26,7 @@ | IAM | Event Bridge | 1 | 安全事件中继,`BACKGROUND` | | Order | Firstleg | 3 | 计价/统一订单查询为 `REQUEST`,入库协作为 Outbox `BACKGROUND` | | Order | IAM / File / Warehouse | 各 1 | 资格、图片、仓库守卫,`REQUEST` | -| Firstleg | Order | 2 | 货物查询与分拆回写,`REQUEST` | +| Firstleg | Order | 2 | 货物查询同时存在 `REQUEST/BACKGROUND`;PDA 履约分拆为 `REQUEST` | | Firstleg | IAM / Finance / Integration / Event Bridge / Print | 各 1 | IAM、Integration、Print 为 `REQUEST`;Finance 与 Event Bridge 为 `BACKGROUND` 中继 | | Firstleg | File | 7 | 测量、装柜、凭证、报关、拖车等,`REQUEST` | | Firstleg | Warehouse | 4 | 主数据、供应商、库位/托盘,`REQUEST` | @@ -79,9 +79,9 @@ 后台图包含 Outbox/Inbox、导出 Worker 和定时任务,当前强连通分量为: -`Event Bridge ↔ Firstleg ↔ Finance ↔ Lastleg` +`Event Bridge ↔ Firstleg ↔ Finance ↔ Lastleg ↔ Order` -File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 默认尺寸可靠任务、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Lastleg Security 与 Event Bridge→Lastleg 业务事件在服务图上收敛为同一条后台服务边,不形成新的在线业务边或独立安全节点。 +File 的五个导出 Provider、Order→Firstleg 入库 Outbox、该入库消费者为生成箱号而回读 Order 货物的后台回调、IAM 默认尺寸可靠任务、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Lastleg Security 与 Event Bridge→Lastleg 业务事件在服务图上收敛为同一条后台服务边,不形成新的在线业务边或独立安全节点。 ### 全图 @@ -108,6 +108,10 @@ File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 默认尺 `IAM → Firstleg` 的 `CustomerDimensionRuleClient` 默认尺寸初始化已经从 REQUEST WRITE 改为可靠后台编排。IAM 与客户主档同事务持久化稳定 `initializationKey` 的任务,显式维护 `PENDING/READY/FAILED`,由租约调度重试并提供诊断查询和人工重试;审批通过与下单资格在未 READY 时失败关闭。此边现在仅属于 BACKGROUND,在线五服务强连通分量实际拆为 Auth↔IAM 与 Order↔Firstleg 两个子环,未触碰财务、Event Bridge 或四期业务状态机。 +### Order↔Firstleg 第二阶段结论 + +具体入口、所有权与可执行退出方案见 `docs/Order-Firstleg在线依赖解环设计.md`。当前 `COMPLETE_LOADING` 在 Firstleg 本地事务中同步取得 Order 余量子单后才完成结算,且 Order 货物在 `LOADED` 前仍允许修改;成功时点和货物冻结点尚未冻结,因此本阶段不改业务代码。REQUEST SCC 仍为 `Firstleg↔Order`,合同明确阻止仅改变分类却未退出生产调用的伪解环。 + ## 退出顺序 1. **冻结与分层。** 先用清单合同阻止未评审新边;为每个客户端补齐 `REQUEST/BACKGROUND/INFRASTRUCTURE/EXTERNAL`、读写语义、幂等键和失败映射。这个阶段不改变业务状态。 diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index 4b88bd092..99d0553fb 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -18,6 +18,7 @@ | 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` | | Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper | | 传输指标与 Inbox 采样 | `metrics/EventBridgeMetrics.java`、`EventBridgeInboxMetricsSampler.java`;SLO 与告警合同 `docs/development-observability-slo.md` | +| 事件 Trace 与原始 HTTP 传播 | `trace/EventBridgeTraceScope.java`、`EventBridgeHttpTracePropagation.java`;契约与已知缺口 `docs/event-trace-propagation.md` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 数据库 | `src/main/resources/db/event-bridge.sql` | | 测试 | `src/test/java/com/baiyue/eventbridge/` | @@ -29,6 +30,7 @@ - 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。 - 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。 - 指标标签只允许有限 `target_region`、`event_type`、`result`,未知区域/类型折叠为 `OTHER`,禁止 eventId、customerId、错误消息等高基数标签。Inbox 聚合按现有状态索引定时采样,Prometheus scrape 不得查询数据库;开发阶段 SLO/告警只代表代码合同,不代表监控平台已部署。 +- 投递线程只从 Inbox 已冻结 `trace_id` 建立 MDC;合法 32 位 trace ID 或规范 UUID 可贯通,非法历史值安全重建。每次 JDK HttpClient 投递生成 child span,并覆盖三类标准 Trace 头;不得把 correlationId、eventId、customerId、聚合 ID、JWT 或内部令牌写入 Trace 字段。 ## 修改与验证 diff --git a/event-bridge-service/docs/event-trace-propagation.md b/event-bridge-service/docs/event-trace-propagation.md new file mode 100644 index 000000000..00c2e42c3 --- /dev/null +++ b/event-bridge-service/docs/event-trace-propagation.md @@ -0,0 +1,16 @@ +# Event Bridge Trace 传播基线 + +- Event Bridge 领取 Inbox 后,仅从事件信封已冻结的 `traceId` 建立投递线程 MDC。 +- 32 位非零十六进制 trace ID 直接沿用;历史规范 UUID 去除连字符后沿用。 +- 其他旧值不满足 W3C Trace ID 约束,投递时安全重建新 trace,但不覆盖或迁移原事件信封。 +- 每次 HTTP 投递生成新的 `X-Request-Id` 和 child span,并统一覆盖 `X-Trace-Id`、`traceparent`。 +- `correlationId`、`eventId`、customer ID、聚合 ID、JWT 和内部服务令牌不得写入 Trace 标准字段。 +- 投递日志在 MDC 和显式 `traceId` 字段中记录本次标准 trace;投递 attempt 仍通过 `event_id` + 关联 `bridge_inbox.trace_id`,本轮不增加数据库字段。 + +## 共享契约缺口 + +当前 `EventEnvelope.traceId` 仍是通用字符串契约,没有强制 32 位 W3C Trace ID;因此历史任意字符串不能 +无损映射为 W3C trace。若后续要求所有事件跨重试、跨语言绝对保持同一标准 trace,必须由 +`common-event-contract` 生产者、消费者和 Event Bridge 共同冻结字段校验后再收敛。本轮没有修改共享 +Payload、信封字段、哈希规则或可靠性状态机。 diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java index fe26409f2..f888cd04f 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java @@ -4,6 +4,7 @@ import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.RegionCode; import com.baiyue.eventbridge.config.EventDispatcherProperties; import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; @@ -20,6 +21,7 @@ import java.net.http.HttpResponse; public class HttpFirstlegEventClient implements FirstlegEventClient { @Resource private ObjectMapper objectMapper; @Resource private EventDispatcherProperties properties; + @Resource private EventBridgeHttpTracePropagation tracePropagation; @Value("${baiyue.event-dispatcher.firstleg-consumer-url:http://127.0.0.1:9203/internal/firstleg/v1/container-events}") private URI consumerUrl; @Value("${baiyue.security.internal-service-token:}") private String serviceToken; @@ -37,9 +39,10 @@ public class HttpFirstlegEventClient implements FirstlegEventClient { row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(), row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); - HttpRequest request = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) .header("Content-Type", "application/json") - .header("X-Internal-Service-Token", serviceToken) + .header("X-Internal-Service-Token", serviceToken); + HttpRequest request = tracePropagation.apply(requestBuilder) .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); JsonNode responseBody; diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java index e3b3dd5b8..2e01d3981 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java @@ -4,6 +4,7 @@ import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.RegionCode; import com.baiyue.eventbridge.config.EventDispatcherProperties; import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; @@ -19,6 +20,7 @@ import java.net.http.HttpResponse; public class HttpLastlegEventClient implements LastlegEventClient { @Resource private ObjectMapper objectMapper; @Resource private EventDispatcherProperties properties; + @Resource private EventBridgeHttpTracePropagation tracePropagation; @Value("${baiyue.security.internal-service-token:}") private String serviceToken; @@ -38,10 +40,11 @@ public class HttpLastlegEventClient implements LastlegEventClient { row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); String body = objectMapper.writeValueAsString(envelope); - HttpRequest request = HttpRequest.newBuilder(properties.consumerUrl()) + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(properties.consumerUrl()) .timeout(properties.requestTimeout()) .header("Content-Type", "application/json") - .header("X-Internal-Service-Token", serviceToken) + .header("X-Internal-Service-Token", serviceToken); + HttpRequest request = tracePropagation.apply(requestBuilder) .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java index 6c4b7666e..9e55d56bd 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java @@ -4,9 +4,11 @@ import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.RegionCode; import com.baiyue.eventbridge.config.EventDispatcherProperties; import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -19,6 +21,7 @@ import java.net.http.HttpResponse; public class HttpSecurityProjectionEventClient implements SecurityProjectionEventClient { private final ObjectMapper objectMapper; private final EventDispatcherProperties properties; + @Resource private EventBridgeHttpTracePropagation tracePropagation; @Value("${baiyue.event-dispatcher.security-consumer-url}") private URI consumerUrl; @Value("${baiyue.security.internal-service-token:}") private String serviceToken; private HttpClient httpClient; @@ -39,9 +42,10 @@ public class HttpSecurityProjectionEventClient implements SecurityProjectionEven row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(), row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); - HttpRequest request = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) .header("Content-Type", "application/json") - .header("X-Internal-Service-Token", serviceToken) + .header("X-Internal-Service-Token", serviceToken); + HttpRequest request = tracePropagation.apply(requestBuilder) .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); JsonNode body; diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java index 54cd77867..b20be547f 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java @@ -7,10 +7,13 @@ import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; import com.baiyue.eventbridge.metrics.EventBridgeMetrics; +import com.baiyue.eventbridge.trace.EventBridgeTraceScope; +import com.baiyue.common.web.CorrelationContext; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import org.slf4j.MDC; import java.lang.management.ManagementFactory; import java.time.LocalDateTime; @@ -42,6 +45,12 @@ public class OverseasEventDispatcher { } BridgeInbox event = inboxMapper.selectById(eventId); + try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) { + dispatchClaimed(eventId, event); + } + } + + private void dispatchClaimed(String eventId, BridgeInbox event) { int retryAttempt = event.getRetryCount() == null ? 1 : event.getRetryCount() + 1; int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId); BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo); @@ -57,8 +66,8 @@ public class OverseasEventDispatcher { : "FAILED_LEASE_LOST"; finishAttempt(attempt, attemptStatus, null, error); metrics.recordTransport(event, EventBridgeMetrics.TransportResult.valueOf(attemptStatus)); - log.warn("跨区事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}", - eventId, retryAttempt, deliveryAttemptNo, error); + log.warn("跨区事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, traceId={}, reason={}", + eventId, retryAttempt, deliveryAttemptNo, MDC.get(CorrelationContext.TRACE_ID), error); return; } @@ -66,7 +75,8 @@ public class OverseasEventDispatcher { String error = "下游已确认成功,但事件租约已失效;保留当前 Inbox 状态并由新租约持有者幂等续投"; finishAttempt(attempt, "ACKNOWLEDGED_LEASE_LOST", responseCode, error); metrics.recordTransport(event, EventBridgeMetrics.TransportResult.ACKNOWLEDGED_LEASE_LOST); - log.warn("跨区事件成功确认时租约已失效 eventId={}, deliveryAttempt={}", eventId, deliveryAttemptNo); + log.warn("跨区事件成功确认时租约已失效 eventId={}, deliveryAttempt={}, traceId={}", + eventId, deliveryAttemptNo, MDC.get(CorrelationContext.TRACE_ID)); return; } finishAttempt(attempt, "DELIVERED", responseCode, null); diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java new file mode 100644 index 000000000..dc2c78bd3 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java @@ -0,0 +1,19 @@ +package com.baiyue.eventbridge.trace; + +import com.baiyue.common.web.CorrelationContext; +import com.baiyue.common.web.OutboundTraceHeaders; +import org.springframework.stereotype.Component; + +import java.net.http.HttpRequest; + +/** Applies a fresh child span to one raw JDK HttpClient request. */ +@Component +public class EventBridgeHttpTracePropagation { + public HttpRequest.Builder apply(HttpRequest.Builder builder) { + OutboundTraceHeaders outbound = OutboundTraceHeaders.fromCurrentOrNew(); + return builder + .setHeader(CorrelationContext.REQUEST_ID_HEADER, outbound.requestId()) + .setHeader(CorrelationContext.TRACE_ID_HEADER, outbound.traceId()) + .setHeader(CorrelationContext.TRACE_PARENT_HEADER, outbound.traceParent()); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java new file mode 100644 index 000000000..82139ab51 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java @@ -0,0 +1,35 @@ +package com.baiyue.eventbridge.trace; + +import com.baiyue.common.web.CorrelationContext; +import com.baiyue.common.web.OutboundTraceHeaders; +import com.baiyue.eventbridge.domain.BridgeInbox; +import org.slf4j.MDC; + +import java.util.Map; + +/** Establishes one trusted MDC context for an Event Bridge delivery attempt. */ +public final class EventBridgeTraceScope implements AutoCloseable { + private final Map previous; + + private EventBridgeTraceScope(Map previous) { + this.previous = previous; + } + + public static EventBridgeTraceScope open(BridgeInbox event) { + Map previous = MDC.getCopyOfContextMap(); + OutboundTraceHeaders local = OutboundTraceHeaders.fromTrustedTraceOrNew(event.getTraceId()); + MDC.put(CorrelationContext.REQUEST_ID, local.requestId()); + MDC.put(CorrelationContext.TRACE_ID, local.traceId()); + MDC.put(CorrelationContext.SPAN_ID, local.spanId()); + return new EventBridgeTraceScope(previous); + } + + @Override + public void close() { + if (previous == null) { + MDC.clear(); + } else { + MDC.setContextMap(previous); + } + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java index aa9f11d14..5224a11e5 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java @@ -7,11 +7,13 @@ import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; import com.baiyue.eventbridge.metrics.EventBridgeMetrics; +import com.baiyue.common.web.CorrelationContext; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.test.util.ReflectionTestUtils; +import org.slf4j.MDC; import java.net.URI; import java.time.Duration; @@ -63,7 +65,12 @@ class OverseasEventDispatcherTest { @Test void successfulDeliveryIsConfirmedAndAudited() throws Exception { - when(eventClient.deliver(event)).thenReturn("200"); + event.setTraceId("550e8400-e29b-41d4-a716-446655440000"); + when(eventClient.deliver(event)).thenAnswer(invocation -> { + assertThat(MDC.get(CorrelationContext.TRACE_ID)) + .isEqualTo("550e8400e29b41d4a716446655440000"); + return "200"; + }); when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); dispatcher.dispatchOne("event-1"); @@ -74,6 +81,7 @@ class OverseasEventDispatcherTest { assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); assertThat(transportCount("DELIVERED")).isEqualTo(1.0); assertThat(registry.get(EventBridgeMetrics.DELIVERY_LATENCY_METRIC).timer().count()).isEqualTo(1); + assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull(); } @Test diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java new file mode 100644 index 000000000..1dbf17567 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java @@ -0,0 +1,65 @@ +package com.baiyue.eventbridge.trace; + +import com.baiyue.common.web.CorrelationContext; +import com.baiyue.eventbridge.domain.BridgeInbox; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +import java.net.URI; +import java.net.http.HttpRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventBridgeTracePropagationTest { + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void envelopeUuidFlowsThroughMdcAndRawHttpAsAChildSpan() { + BridgeInbox event = new BridgeInbox(); + event.setTraceId("550e8400-e29b-41d4-a716-446655440000"); + event.setCorrelationId("ORDER:customer-99:booking-123"); + event.setEventId("event-business-id"); + + try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) { + String localSpan = MDC.get(CorrelationContext.SPAN_ID); + HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create("http://127.0.0.1/internal")) + .header(CorrelationContext.REQUEST_ID_HEADER, event.getEventId()) + .header(CorrelationContext.TRACE_ID_HEADER, event.getCorrelationId()) + .header(CorrelationContext.TRACE_PARENT_HEADER, + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00"); + HttpRequest request = new EventBridgeHttpTracePropagation().apply(builder).GET().build(); + + assertThat(MDC.get(CorrelationContext.TRACE_ID)) + .isEqualTo("550e8400e29b41d4a716446655440000"); + assertThat(request.headers().firstValue(CorrelationContext.TRACE_ID_HEADER)) + .contains("550e8400e29b41d4a716446655440000"); + assertThat(request.headers().firstValue(CorrelationContext.REQUEST_ID_HEADER).orElseThrow()) + .matches("[0-9a-f-]{36}").isNotEqualTo(event.getEventId()); + assertThat(request.headers().firstValue(CorrelationContext.TRACE_PARENT_HEADER).orElseThrow()) + .matches("00-550e8400e29b41d4a716446655440000-[0-9a-f]{16}-01") + .doesNotContain(localSpan); + } + + assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull(); + } + + @Test + void invalidLegacyEnvelopeTraceIsSafelyRebuiltAndPreviousMdcIsRestored() { + MDC.put("existing", "preserved"); + BridgeInbox event = new BridgeInbox(); + event.setTraceId("JWT-or-business-id-value"); + + try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) { + assertThat(MDC.get(CorrelationContext.TRACE_ID)).matches("[0-9a-f]{32}") + .isNotEqualTo(event.getTraceId()); + } + + assertThat(MDC.get("existing")).isEqualTo("preserved"); + assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull(); + } +} diff --git a/scripts/java-quality-baseline.json b/scripts/java-quality-baseline.json new file mode 100644 index 000000000..1ed545842 --- /dev/null +++ b/scripts/java-quality-baseline.json @@ -0,0 +1,59 @@ +{ + "schemaVersion": 1, + "mode": "report-only", + "tools": { + "jacocoMavenPlugin": "0.8.12", + "spotbugsMavenPlugin": "4.9.8.5" + }, + "topology": { + "modules": 22, + "codeModules": 21, + "services": 15 + }, + "openapi": { + "documents": 26, + "coveredServices": 7, + "codeFirstServices": 8 + }, + "reports": { + "jacocoPresent": 21, + "spotbugsPresent": 21, + "spotbugsFindings": 780, + "spotbugsByPriority": { + "1": 1, + "2": 779 + }, + "spotbugsByCategory": { + "BAD_PRACTICE": 10, + "CORRECTNESS": 3, + "EXPERIMENTAL": 4, + "MALICIOUS_CODE": 699, + "MT_CORRECTNESS": 2, + "PERFORMANCE": 18, + "STYLE": 44 + } + }, + "modules": [ + { "module": "common-core", "jacocoCovered": 32, "jacocoTotal": 78, "jacocoPercentage": 41.03, "spotbugsFindings": 6 }, + { "module": "common-web", "jacocoCovered": 164, "jacocoTotal": 188, "jacocoPercentage": 87.23, "spotbugsFindings": 1 }, + { "module": "common-security", "jacocoCovered": 109, "jacocoTotal": 202, "jacocoPercentage": 53.96, "spotbugsFindings": 6 }, + { "module": "common-openapi", "jacocoCovered": 17, "jacocoTotal": 37, "jacocoPercentage": 45.95, "spotbugsFindings": 0 }, + { "module": "common-audit", "jacocoCovered": 168, "jacocoTotal": 244, "jacocoPercentage": 68.85, "spotbugsFindings": 8 }, + { "module": "common-event-contract", "jacocoCovered": 146, "jacocoTotal": 164, "jacocoPercentage": 89.02, "spotbugsFindings": 18 }, + { "module": "gateway", "jacocoCovered": 110, "jacocoTotal": 156, "jacocoPercentage": 70.51, "spotbugsFindings": 4 }, + { "module": "gateway-overseas", "jacocoCovered": 134, "jacocoTotal": 272, "jacocoPercentage": 49.26, "spotbugsFindings": 2 }, + { "module": "auth", "jacocoCovered": 372, "jacocoTotal": 619, "jacocoPercentage": 60.10, "spotbugsFindings": 5 }, + { "module": "iam", "jacocoCovered": 1872, "jacocoTotal": 3032, "jacocoPercentage": 61.74, "spotbugsFindings": 60 }, + { "module": "order", "jacocoCovered": 1116, "jacocoTotal": 1605, "jacocoPercentage": 69.53, "spotbugsFindings": 47 }, + { "module": "firstleg", "jacocoCovered": 7891, "jacocoTotal": 11592, "jacocoPercentage": 68.07, "spotbugsFindings": 223 }, + { "module": "warehouse", "jacocoCovered": 1767, "jacocoTotal": 2922, "jacocoPercentage": 60.47, "spotbugsFindings": 64 }, + { "module": "warehouse-overseas-service", "jacocoCovered": 403, "jacocoTotal": 778, "jacocoPercentage": 51.80, "spotbugsFindings": 8 }, + { "module": "lastleg-service", "jacocoCovered": 5088, "jacocoTotal": 7832, "jacocoPercentage": 64.96, "spotbugsFindings": 162 }, + { "module": "file", "jacocoCovered": 1046, "jacocoTotal": 1614, "jacocoPercentage": 64.81, "spotbugsFindings": 33 }, + { "module": "print", "jacocoCovered": 596, "jacocoTotal": 695, "jacocoPercentage": 85.76, "spotbugsFindings": 12 }, + { "module": "audit-service", "jacocoCovered": 76, "jacocoTotal": 153, "jacocoPercentage": 49.67, "spotbugsFindings": 0 }, + { "module": "finance", "jacocoCovered": 1665, "jacocoTotal": 2236, "jacocoPercentage": 74.46, "spotbugsFindings": 97 }, + { "module": "integration-service", "jacocoCovered": 1040, "jacocoTotal": 1420, "jacocoPercentage": 73.24, "spotbugsFindings": 23 }, + { "module": "event-bridge-service", "jacocoCovered": 318, "jacocoTotal": 419, "jacocoPercentage": 75.89, "spotbugsFindings": 1 } + ] +} diff --git a/scripts/java-quality-summary.mjs b/scripts/java-quality-summary.mjs index f54740dca..23c84d407 100644 --- a/scripts/java-quality-summary.mjs +++ b/scripts/java-quality-summary.mjs @@ -5,7 +5,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url' export const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))) const normalize = (value) => value.replaceAll('\\', '/') -const attribute = (source, name) => source.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1] +const attribute = (source, name) => source.match(new RegExp(`\\b${name}=(?:"([^"]*)"|'([^']*)')`))?.slice(1).find((value) => value !== undefined) async function exists(file) { try { diff --git a/scripts/java-quality-summary.test.mjs b/scripts/java-quality-summary.test.mjs index 8744660a1..989dcfe43 100644 --- a/scripts/java-quality-summary.test.mjs +++ b/scripts/java-quality-summary.test.mjs @@ -21,6 +21,11 @@ test('parses JaCoCo line coverage and SpotBugs findings without quality threshol byPriority: { 1: 1, 2: 1 }, byCategory: { CORRECTNESS: 1, STYLE: 1 } }) + assert.deepEqual(parseSpotbugsReport(""), { + findings: 1, + byPriority: { 2: 1 }, + byCategory: { MALICIOUS_CODE: 1 } + }) assert.throws(() => parseJacocoReport(''), /not a JaCoCo report/) assert.throws(() => parseSpotbugsReport(''), /not a SpotBugs report/) }) @@ -58,10 +63,15 @@ test('aggregates code modules, report presence and the OpenAPI service matrix', test('repository freezes the current topology and non-blocking aggregate summary wiring', async () => { 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, 26) assert.equal(summary.openapi.coveredServices.length, 7) assert.equal(summary.openapi.codeFirstServices.length, 8) + assert.equal(baseline.mode, 'report-only') + assert.deepEqual(baseline.topology, summary.topology) + assert.equal(baseline.modules.length, 21) + assert.equal(baseline.modules.reduce((total, row) => total + row.spotbugsFindings, 0), baseline.reports.spotbugsFindings) const gitlab = (await readFile(path.join(repositoryRoot, '.gitlab-ci.yml'), 'utf8')).replaceAll('\r\n', '\n') const job = gitlab.match(/^质量摘要:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? '' diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index 6c69f4df7..de60f65a6 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -64,7 +64,8 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ feign('firstleg/src/main/java/com/baiyue/firstleg/client/MeasurementImageClient.java', 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '图片上传/作废非安全重放,查询可重复'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java', - 'firstleg-service', 'order-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取订单货物/来源快照'), + 'firstleg-service', 'order-service', ['REQUEST', 'BACKGROUND'], 'READ', 'SAFE_READ', + '在线测量/箱号/排柜读取;Order 审核 Outbox 创建 Firstleg 入库及箱号时也会后台回读订单货物'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClient.java', 'firstleg-service', 'order-service', ['REQUEST'], 'WRITE', 'IDEMPOTENCY_KEY', 'PDA 结算拆单携带 idempotencyKey/operationId'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookFileClient.java', diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index fa082a7fd..bb8fcb0e7 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -54,6 +54,7 @@ const expectedBackgroundEdges = [ 'firstleg-service->event-bridge-service', 'firstleg-service->finance-service', 'firstleg-service->integration-service', + 'firstleg-service->order-service', 'iam-service->event-bridge-service', 'iam-service->firstleg-service', 'lastleg-service->event-bridge-service', @@ -190,7 +191,7 @@ test('REQUEST, BACKGROUND and full graphs remain separate executable baselines', ['firstleg-service', 'order-service'] ]) assert.deepEqual(stronglyConnectedComponents(graphEdges('BACKGROUND')), [[ - 'event-bridge-service', 'finance-service', 'firstleg-service', 'lastleg-service' + 'event-bridge-service', 'finance-service', 'firstleg-service', 'lastleg-service', 'order-service' ]]) assert.deepEqual(stronglyConnectedComponents(graphEdges('ALL')), [[ 'auth-service', 'event-bridge-service', 'file-service', 'finance-service', @@ -204,6 +205,25 @@ test('overseas security projection stays in Lastleg infrastructure and backgroun assert.ok(graphEdges('BACKGROUND').includes('event-bridge-service->lastleg-service')) }) +test('Order and Firstleg remain a bilateral REQUEST SCC until loading-complete timing is frozen', () => { + const requestClients = SYNCHRONOUS_CLIENTS.filter(client => client.contexts.includes('REQUEST')) + const orderToFirstleg = requestClients.filter(client => + client.source === 'order-service' && client.target === 'firstleg-service') + const firstlegToOrder = requestClients.filter(client => + client.source === 'firstleg-service' && client.target === 'order-service') + assert.deepEqual(orderToFirstleg.map(client => client.path).sort(), [ + 'order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java', + 'order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java', + 'order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java' + ]) + assert.deepEqual(firstlegToOrder.map(client => client.path).sort(), [ + 'firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java', + 'firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClient.java' + ]) + assert.ok(stronglyConnectedComponents(graphEdges('REQUEST')).some(component => + component.includes('firstleg-service') && component.includes('order-service'))) +}) + test('known user entries expose every longest simple REQUEST path over the two-hop budget', () => { const summary = Object.fromEntries(USER_ENTRIES.map(entry => { const paths = longestSimplePaths(entry.service) From 6e55d94f3336d4225428181cc06b7f93b961e8a8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 19:44:10 +0800 Subject: [PATCH 21/42] fix: add ocean booking link candidates query --- .../PlatformOceanBookingController.java | 5 ++ .../oceanbooking/dto/OceanBookingDtos.java | 19 +++++++ .../mapper/OceanBookingMapper.java | 30 +++++++++++ .../repository/OceanBookingRepository.java | 5 ++ .../service/OceanBookingService.java | 6 +++ .../resources/openapi/ocean-bookings-v1.yaml | 51 +++++++++++++++++++ ...ormOceanBookingControllerContractTest.java | 18 ++++++- .../OceanBookingMapperContractTest.java | 20 ++++++++ .../service/OceanBookingServiceTest.java | 24 +++++++++ 9 files changed, 177 insertions(+), 1 deletion(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java index a6ed9d70d..486fb960e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java @@ -27,6 +27,11 @@ public class PlatformOceanBookingController { @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query)); } + @GetMapping("/link-candidates") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") + public ApiResponse> linkCandidates( + @AuthenticationPrincipal AuthenticatedUser user, @Valid LinkCandidateQuery query) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.linkCandidates(query)); + } @GetMapping("/channels") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") public ApiResponse> channels(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long originWarehouseId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java index 044d6b76f..b74f650d3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java @@ -12,6 +12,7 @@ import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.DecimalMin; import java.time.LocalDateTime; import java.math.BigDecimal; @@ -26,6 +27,24 @@ public final class OceanBookingDtos { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } } + public record LinkCandidateQuery(@NotNull @Positive Long warehouseId, + @NotBlank @Size(max = 64) String channelCode, + @NotBlank @Size(max = 32) String containerType, + @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm, + @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg, + @Min(1) Long page, + @Min(1) @Max(200) Long size) { + public LinkCandidateQuery { + if (page == null) page = 1L; + if (size == null) size = 20L; + } + } + + public record LinkCandidate(Long oceanBookingId, String bookingNo, Long requirementId, + String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg, + int requiredQuantity, long allocatedQuantity, long remainingQuantity, + String customerName, String warehouseName, String channelName) {} + public record PartyRequest(@NotBlank @Size(max = 24) String partyType, @NotBlank @Size(max = 255) String partyName, @Size(max = 1000) String address, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java index d806df7a9..519d10053 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java @@ -3,6 +3,8 @@ package com.baiyue.firstleg.oceanbooking.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidate; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Delete; @@ -41,6 +43,34 @@ public interface OceanBookingMapper extends BaseMapper { """) Page selectPage(Page page, @Param("query") Query query); + @Select(""" + SELECT booking.id ocean_booking_id,booking.booking_no,requirement.id requirement_id, + requirement.container_type,requirement.capacity_cbm,requirement.max_weight_kg, + requirement.quantity required_quantity, + COALESCE(allocated.allocated_quantity,0) allocated_quantity, + requirement.quantity-COALESCE(allocated.allocated_quantity,0) remaining_quantity, + booking.customer_name,booking.warehouse_name,booking.channel_name + FROM fl_ocean_booking booking + JOIN fl_ocean_booking_container_requirement requirement + ON requirement.ocean_booking_id=booking.id + LEFT JOIN ( + SELECT ocean_booking_id,requirement_id,COUNT(*) allocated_quantity + FROM fl_ocean_booking_container_allocation + GROUP BY ocean_booking_id,requirement_id + ) allocated ON allocated.ocean_booking_id=booking.id + AND allocated.requirement_id=requirement.id + WHERE booking.status='RELEASED' + AND booking.warehouse_id=#{query.warehouseId} + AND booking.channel_code=#{query.channelCode} + AND requirement.container_type=#{query.containerType} + AND requirement.capacity_cbm=#{query.capacityCbm} + AND requirement.max_weight_kg=#{query.maxWeightKg} + AND requirement.quantity>COALESCE(allocated.allocated_quantity,0) + ORDER BY booking.updated_at DESC,booking.id DESC,requirement.sort_no ASC,requirement.id ASC + """) + Page selectLinkCandidates(Page page, + @Param("query") LinkCandidateQuery query); + @Update(""" UPDATE fl_ocean_booking SET status=#{toStatus}, updated_by=#{operatorId},updated_by_name=#{operatorName}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java index 1f5f9b27c..747c91ebc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java @@ -5,6 +5,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.RealContainer; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidate; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery; import com.baiyue.firstleg.oceanbooking.entity.*; import com.baiyue.firstleg.oceanbooking.mapper.*; import com.baiyue.firstleg.entity.ContainerBookingEntity; @@ -30,6 +32,9 @@ public class OceanBookingRepository { public PageResult page(Query query) { return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query)); } + public PageResult linkCandidates(LinkCandidateQuery query) { + return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query)); + } public OceanBookingEntity find(long id) { return bookings.selectById(id); } public void insert(OceanBookingEntity value) { bookings.insert(value); } public int transition(long id, String from, String to, int version, long operatorId, String operatorName) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index 7b4ad9e48..2d68dfedb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -55,6 +55,12 @@ public class OceanBookingService { return repository.page(new Query(query.customerId(), status, trim(query.keyword()), query.page(), query.size())); } + public PageResult linkCandidates(LinkCandidateQuery query) { + return repository.linkCandidates(new LinkCandidateQuery(query.warehouseId(), + query.channelCode().trim(), query.containerType().trim(), query.capacityCbm(), + query.maxWeightKg(), query.page(), query.size())); + } + public List channels(long originWarehouseId) { if (originWarehouseId <= 0) throw badRequest("请选择有效起运仓"); Long versionId = priceBooks.activeVersionId(); diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 363e22b73..5900284ee 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -28,6 +28,25 @@ paths: parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}} responses: {'200': {description: RELEASED detail}} + /api/platform/v1/ocean-bookings/link-candidates: + get: + summary: Page released booking requirement candidates for one container + description: Requires platform:firstleg:ocean-booking:list. Returns only exact warehouse, channel and fixed container specification matches with remainingQuantity greater than zero. Allocation counts are calculated by one bounded set query; booking detail calls are not used. + x-permission: platform:firstleg:ocean-booking:list + parameters: + - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} + - {name: channelCode, in: query, required: true, schema: {type: string, maxLength: 64}} + - {name: containerType, in: query, required: true, schema: {type: string, maxLength: 32}} + - {name: capacityCbm, in: query, required: true, schema: {type: number, exclusiveMinimum: true, minimum: 0}} + - {name: maxWeightKg, in: query, required: true, schema: {type: number, exclusiveMinimum: true, minimum: 0}} + - {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}} + - {name: size, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}} + responses: + '200': + description: Business envelope containing a bounded candidate page + content: + application/json: + schema: {$ref: '#/components/schemas/LinkCandidateResponse'} /api/platform/v1/ocean-bookings/channels: get: summary: List ACTIVE channels for an origin warehouse @@ -94,6 +113,38 @@ components: channelName: {type: string} priceModel: {type: string} pricingTaxModes: {type: array, items: {type: string}} + LinkCandidate: + type: object + required: [oceanBookingId, bookingNo, requirementId, containerType, capacityCbm, maxWeightKg, requiredQuantity, allocatedQuantity, remainingQuantity, customerName, warehouseName, channelName] + properties: + oceanBookingId: {type: string, pattern: '^[0-9]+$'} + bookingNo: {type: string} + requirementId: {type: string, pattern: '^[0-9]+$'} + containerType: {type: string} + capacityCbm: {type: number} + maxWeightKg: {type: number} + requiredQuantity: {type: integer, minimum: 1} + allocatedQuantity: {type: integer, format: int64, minimum: 0} + remainingQuantity: {type: integer, format: int64, minimum: 1} + customerName: {type: string} + warehouseName: {type: string} + channelName: {type: string} + LinkCandidatePage: + type: object + required: [records, page, size, total, pages] + properties: + records: {type: array, items: {$ref: '#/components/schemas/LinkCandidate'}} + page: {type: integer, format: int64, minimum: 1} + size: {type: integer, format: int64, minimum: 1} + total: {type: integer, format: int64, minimum: 0} + pages: {type: integer, format: int64, minimum: 0} + LinkCandidateResponse: + type: object + required: [code, data] + properties: + code: {type: string, example: '200'} + message: {type: string, nullable: true} + data: {$ref: '#/components/schemas/LinkCandidatePage'} Detail: type: object required: [oceanBooking, parties, containerRequirements, containers, histories] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java index df43c1be5..6bdfc820d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java @@ -20,6 +20,7 @@ class PlatformOceanBookingControllerContractTest { .containsExactly("/api/platform/v1/ocean-bookings"); Map permissions = Map.ofEntries( Map.entry("page", "platform:firstleg:ocean-booking:list"), + Map.entry("linkCandidates", "platform:firstleg:ocean-booking:list"), Map.entry("channels", "platform:firstleg:ocean-booking:list"), Map.entry("detail", "platform:firstleg:ocean-booking:list"), Map.entry("create", "platform:firstleg:ocean-booking:create"), @@ -30,7 +31,8 @@ class PlatformOceanBookingControllerContractTest { Method method = method(name); assertThat(method.getAnnotation(PreAuthorize.class).value()) .isEqualTo("hasAuthority('" + permission + "')"); - if (!name.equals("page") && !name.equals("detail") && !name.equals("channels")) { + if (!name.equals("page") && !name.equals("detail") && !name.equals("channels") + && !name.equals("linkCandidates")) { assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull(); } }); @@ -57,6 +59,20 @@ class PlatformOceanBookingControllerContractTest { .doesNotContain("change-request", "change-confirm", "/cancel:", "containers[].containerNo"); } + @Test + void linkCandidatesUsesDedicatedGetPathAndStablePageContract() throws Exception { + Method method = method("linkCandidates"); + assertThat(method.getAnnotation(org.springframework.web.bind.annotation.GetMapping.class).value()) + .containsExactly("/link-candidates"); + String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")); + assertThat(yaml) + .contains("/api/platform/v1/ocean-bookings/link-candidates:") + .contains("x-permission: platform:firstleg:ocean-booking:list") + .contains("warehouseId", "channelCode", "containerType", "capacityCbm", "maxWeightKg") + .contains("LinkCandidateResponse", "requiredQuantity", "allocatedQuantity", "remainingQuantity") + .contains("booking detail calls are not used"); + } + private static Method method(String name) { return Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods()) .filter(method -> method.getName().equals(name)).findFirst().orElseThrow(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java index 050195e76..835f826b8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java @@ -1,6 +1,7 @@ package com.baiyue.firstleg.oceanbooking.repository; import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingMapper; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; import org.junit.jupiter.api.Test; @@ -36,4 +37,23 @@ class OceanBookingMapperContractTest { .contains("status='DRAFT' AND version=#{version}") .doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED"); } + + @Test + void linkCandidatesUsesOneBoundedSetQueryAndExactRemainingSemantics() throws Exception { + String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectLinkCandidates", + com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, LinkCandidateQuery.class) + .getAnnotation(Select.class).value()); + assertThat(sql) + .contains("booking.status='RELEASED'") + .contains("booking.warehouse_id=#{query.warehouseId}") + .contains("booking.channel_code=#{query.channelCode}") + .contains("requirement.container_type=#{query.containerType}") + .contains("requirement.capacity_cbm=#{query.capacityCbm}") + .contains("requirement.max_weight_kg=#{query.maxWeightKg}") + .contains("FROM fl_ocean_booking_container_allocation") + .contains("GROUP BY ocean_booking_id,requirement_id") + .contains("requirement.quantity-COALESCE(allocated.allocated_quantity,0) remaining_quantity") + .contains("requirement.quantity>COALESCE(allocated.allocated_quantity,0)") + .doesNotContain("fl_container_booking", "SELECT booking.*"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index f9b3ce64a..2bea449df 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -1,6 +1,7 @@ package com.baiyue.firstleg.oceanbooking.service; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.WarehouseMasterDataClient; import com.baiyue.firstleg.client.WarehouseSupplierClient; @@ -170,6 +171,29 @@ class OceanBookingServiceTest { verify(repository, times(2)).page(any()); } + @Test + void linkCandidatesDefaultsPaginationAndDelegatesOneNormalizedSetQuery() { + LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HC", + new BigDecimal("76"), new BigDecimal("28000"), 3, 1, 2, + "客户A", "深圳中转仓", "美西海运"); + when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1)); + + PageResult result = service.linkCandidates(new LinkCandidateQuery(701L, + " SEA-US ", " 40HC ", new BigDecimal("76.0"), new BigDecimal("28000.00"), null, null)); + + assertThat(result.records()).containsExactly(candidate); + assertThat(result.records().get(0).remainingQuantity()).isEqualTo(2); + ArgumentCaptor query = ArgumentCaptor.forClass(LinkCandidateQuery.class); + verify(repository).linkCandidates(query.capture()); + assertThat(query.getValue().channelCode()).isEqualTo("SEA-US"); + assertThat(query.getValue().containerType()).isEqualTo("40HC"); + assertThat(query.getValue().page()).isEqualTo(1L); + assertThat(query.getValue().size()).isEqualTo(20L); + verify(repository, never()).find(anyLong()); + verify(repository, never()).requirements(anyLong()); + verify(repository, never()).containers(anyLong()); + } + @Test void releaseRejectsInvalidScheduleWithoutTransition() { OceanBookingEntity draft = completeBooking("DRAFT", 3); From ead9565a377a90d315d05f2365660676f5925e04 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 20:13:48 +0800 Subject: [PATCH 22/42] fix: align ocean booking link eligibility --- .../mapper/ContainerBookingMapper.java | 2 - .../oceanbooking/dto/OceanBookingDtos.java | 3 -- ...OceanBookingContainerAllocationMapper.java | 13 +++++ .../mapper/OceanBookingMapper.java | 18 +++---- .../repository/OceanBookingRepository.java | 3 ++ .../service/OceanBookingService.java | 3 +- .../repository/ContainerRepository.java | 1 - .../firstleg/service/ContainerService.java | 11 ++--- .../resources/openapi/ocean-bookings-v1.yaml | 19 ++++++-- ...ormOceanBookingControllerContractTest.java | 12 ++++- .../OceanBookingMapperContractTest.java | 30 ++++++++++-- .../service/OceanBookingServiceTest.java | 18 ++++++- .../ContainerBookingMapperContractTest.java | 9 ---- .../service/ContainerServiceTest.java | 48 +++++++++++++++++-- 14 files changed, 142 insertions(+), 48 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index f5e905ab8..62599bc9f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -171,8 +171,6 @@ public interface ContainerBookingMapper extends BaseMapper#{customerId}") int hasMismatchedCustomerItems(@Param("id") long id, @Param("customerId") long customerId); - @Update(""" UPDATE fl_container_booking SET container_no=#{containerNo}, voyage_no=#{voyageNo}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java index b74f650d3..7481453cb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java @@ -12,7 +12,6 @@ import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; -import jakarta.validation.constraints.DecimalMin; import java.time.LocalDateTime; import java.math.BigDecimal; @@ -30,8 +29,6 @@ public final class OceanBookingDtos { public record LinkCandidateQuery(@NotNull @Positive Long warehouseId, @NotBlank @Size(max = 64) String channelCode, @NotBlank @Size(max = 32) String containerType, - @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm, - @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg, @Min(1) Long page, @Min(1) @Max(200) Long size) { public LinkCandidateQuery { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java index cca20bf30..77b31e191 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java @@ -3,8 +3,21 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; @Mapper public interface OceanBookingContainerAllocationMapper extends BaseMapper { @Update("UPDATE fl_ocean_booking_container_allocation SET container_booking_id=#{targetId} WHERE container_booking_id=#{sourceId}") int reassign(@Param("sourceId") long sourceId, @Param("targetId") long targetId); + + @Select(""" + SELECT COUNT(*) FROM fl_ocean_booking_container_allocation allocation + JOIN fl_container_booking container ON container.id=allocation.container_booking_id + AND container.record_status='ACTIVE' + AND container.ocean_booking_id=allocation.ocean_booking_id + AND container.ocean_booking_requirement_id=allocation.requirement_id + WHERE allocation.ocean_booking_id=#{oceanBookingId} + AND allocation.requirement_id=#{requirementId} + """) + long countActiveLinked(@Param("oceanBookingId") long oceanBookingId, + @Param("requirementId") long requirementId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java index 519d10053..598aad9ef 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java @@ -26,9 +26,9 @@ public interface OceanBookingMapper extends BaseMapper { WHERE party.ocean_booking_id=booking.id AND party.party_type='SHIPPER' LIMIT 1) shipper_name, (SELECT party.party_name FROM fl_ocean_booking_party party WHERE party.ocean_booking_id=booking.id AND party.party_type='CONSIGNEE' LIMIT 1) consignee_name, - (SELECT COUNT(*) FROM fl_ocean_booking_container_allocation allocation - JOIN fl_container_booking container ON container.id=allocation.container_booking_id - WHERE allocation.ocean_booking_id=booking.id AND container.record_status='ACTIVE') container_count + COALESCE((SELECT SUM(requirement.quantity) + FROM fl_ocean_booking_container_requirement requirement + WHERE requirement.ocean_booking_id=booking.id),0) container_count FROM fl_ocean_booking booking WHERE (#{query.customerId} IS NULL OR booking.customer_id=#{query.customerId}) AND booking.status IN ('DRAFT','RELEASED') @@ -54,17 +54,19 @@ public interface OceanBookingMapper extends BaseMapper { JOIN fl_ocean_booking_container_requirement requirement ON requirement.ocean_booking_id=booking.id LEFT JOIN ( - SELECT ocean_booking_id,requirement_id,COUNT(*) allocated_quantity - FROM fl_ocean_booking_container_allocation - GROUP BY ocean_booking_id,requirement_id + SELECT allocation.ocean_booking_id,allocation.requirement_id,COUNT(*) allocated_quantity + FROM fl_ocean_booking_container_allocation allocation + JOIN fl_container_booking container ON container.id=allocation.container_booking_id + AND container.record_status='ACTIVE' + AND container.ocean_booking_id=allocation.ocean_booking_id + AND container.ocean_booking_requirement_id=allocation.requirement_id + GROUP BY allocation.ocean_booking_id,allocation.requirement_id ) allocated ON allocated.ocean_booking_id=booking.id AND allocated.requirement_id=requirement.id WHERE booking.status='RELEASED' AND booking.warehouse_id=#{query.warehouseId} AND booking.channel_code=#{query.channelCode} AND requirement.container_type=#{query.containerType} - AND requirement.capacity_cbm=#{query.capacityCbm} - AND requirement.max_weight_kg=#{query.maxWeightKg} AND requirement.quantity>COALESCE(allocated.allocated_quantity,0) ORDER BY booking.updated_at DESC,booking.id DESC,requirement.sort_no ASC,requirement.id ASC """) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java index 747c91ebc..ca7f77216 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java @@ -49,6 +49,9 @@ public class OceanBookingRepository { public List parties(long id) { return parties.selectList(new LambdaQueryWrapper().eq(OceanBookingPartyEntity::getOceanBookingId, id).orderByAsc(OceanBookingPartyEntity::getId)); } public List requirements(long id) { return requirements.selectList(new LambdaQueryWrapper().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerRequirementEntity::getSortNo)); } public List allocations(long id) { return allocations.selectList(new LambdaQueryWrapper().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerAllocationEntity::getAllocatedAt)); } + public long activeLinkedCount(long oceanBookingId, long requirementId) { + return allocations.countActiveLinked(oceanBookingId, requirementId); + } public List containers(long id) { Map requirementById = requirements(id).stream() .collect(Collectors.toMap(OceanBookingContainerRequirementEntity::getId, Function.identity())); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index 2d68dfedb..a8b265082 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -57,8 +57,7 @@ public class OceanBookingService { public PageResult linkCandidates(LinkCandidateQuery query) { return repository.linkCandidates(new LinkCandidateQuery(query.warehouseId(), - query.channelCode().trim(), query.containerType().trim(), query.capacityCbm(), - query.maxWeightKg(), query.page(), query.size())); + query.channelCode().trim(), query.containerType().trim(), query.page(), query.size())); } public List channels(long originWarehouseId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java index 60fbe9d8c..9a425a5e9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java @@ -46,7 +46,6 @@ public class ContainerRepository { public boolean hasPickTask(long id) { return mapper.hasPickTask(id) > 0; } public boolean hasEvidence(long id) { return mapper.hasEvidence(id) > 0; } public boolean hasDocuments(long id) { return mapper.hasDocuments(id) > 0; } - public boolean hasMismatchedCustomerItems(long id, long customerId) { return mapper.hasMismatchedCustomerItems(id, customerId) > 0; } public int updateTransport(long id, String containerNo, String voyageNo, int version, long operatorId, String operatorName) { return mapper.updateTransport(id, containerNo, voyageNo, version, operatorId, operatorName); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index 7b52c4c8e..8a9d5571e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -85,17 +85,14 @@ public class ContainerService { if (booking == null || !"RELEASED".equals(booking.getStatus())) throw conflict("只能关联已发布订舱"); OceanBookingContainerRequirementEntity requirement = oceanBookings.requirement(request.oceanBookingId(), request.requirementId()); if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "订舱箱型需求不存在"); - long allocated = oceanBookings.allocations(request.oceanBookingId()).stream().filter(a -> request.requirementId().equals(a.getRequirementId())).count(); + long allocated = oceanBookings.activeLinkedCount(request.oceanBookingId(), request.requirementId()); if (allocated >= requirement.getQuantity()) throw conflict("该订舱箱型需求已达到数量上限"); if (repository.containerNoExists(request.containerNo().trim(), id)) throw conflict("集装箱号已存在"); if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId()) || !java.util.Objects.equals(container.getChannelCode(), booking.getChannelCode()) - || !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType()) - || container.getCapacityCbm() == null || requirement.getCapacityCbm() == null - || container.getMaxWeightKg() == null || requirement.getMaxWeightKg() == null - || container.getCapacityCbm().compareTo(requirement.getCapacityCbm()) != 0 - || container.getMaxWeightKg().compareTo(requirement.getMaxWeightKg()) != 0) throw conflict("模拟柜与真实订舱柜仓库、渠道、箱型或容量不一致"); - if (booking.getCustomerId() != null && repository.hasMismatchedCustomerItems(id, booking.getCustomerId())) throw conflict("模拟柜已排货客户与真实订舱客户范围不一致"); + || !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) { + throw conflict("模拟柜与真实订舱柜仓库、渠道或柜型不一致"); + } if (repository.linkReleasedBooking(id, request.oceanBookingId(), request.requirementId(), request.containerNo().trim(), booking, request.expectedVersion(), operatorId, operatorName) != 1) { throw conflict("真实订舱柜已被消费或货柜版本发生变化,请刷新后重试"); diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 5900284ee..8b7097c85 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -2,7 +2,12 @@ openapi: 3.0.3 info: {title: Ocean Booking API, version: 2.0.0} paths: /api/platform/v1/ocean-bookings: - get: {summary: List DRAFT and RELEASED bookings, responses: {'200': {description: Booking page}}} + get: + summary: List DRAFT and RELEASED bookings + description: containerCount is the sum of quantity across all container requirements for the booking. It is zero when no requirement exists and never represents linked or ACTIVE containers. + responses: + '200': + description: Booking page; each row uses the frozen requirement-total containerCount semantic post: summary: Create an editable DRAFT description: Creates only the booking aggregate and container requirements; no containerNo, container row, or allocation is created. @@ -31,14 +36,12 @@ paths: /api/platform/v1/ocean-bookings/link-candidates: get: summary: Page released booking requirement candidates for one container - description: Requires platform:firstleg:ocean-booking:list. Returns only exact warehouse, channel and fixed container specification matches with remainingQuantity greater than zero. Allocation counts are calculated by one bounded set query; booking detail calls are not used. + description: Requires platform:firstleg:ocean-booking:list. Eligibility matches only warehouseId, channelCode and containerType, with remainingQuantity greater than zero. Capacity and max weight are derived display snapshots and never eligibility fields. Allocated quantity counts only ACTIVE containers still pointing to the same booking requirement and is calculated by one bounded set query; booking detail calls are not used. x-permission: platform:firstleg:ocean-booking:list parameters: - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} - {name: channelCode, in: query, required: true, schema: {type: string, maxLength: 64}} - {name: containerType, in: query, required: true, schema: {type: string, maxLength: 32}} - - {name: capacityCbm, in: query, required: true, schema: {type: number, exclusiveMinimum: true, minimum: 0}} - - {name: maxWeightKg, in: query, required: true, schema: {type: number, exclusiveMinimum: true, minimum: 0}} - {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}} - {name: size, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}} responses: @@ -145,6 +148,14 @@ components: code: {type: string, example: '200'} message: {type: string, nullable: true} data: {$ref: '#/components/schemas/LinkCandidatePage'} + OceanBookingListRow: + type: object + properties: + containerCount: + type: integer + format: int64 + minimum: 0 + description: Sum of all container requirement quantities; independent of allocation, linking or unlinking. Detail: type: object required: [oceanBooking, parties, containerRequirements, containers, histories] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java index 6bdfc820d..a7200cd27 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java @@ -68,11 +68,21 @@ class PlatformOceanBookingControllerContractTest { assertThat(yaml) .contains("/api/platform/v1/ocean-bookings/link-candidates:") .contains("x-permission: platform:firstleg:ocean-booking:list") - .contains("warehouseId", "channelCode", "containerType", "capacityCbm", "maxWeightKg") + .contains("warehouseId", "channelCode", "containerType") .contains("LinkCandidateResponse", "requiredQuantity", "allocatedQuantity", "remainingQuantity") + .contains("Capacity and max weight are derived display snapshots and never eligibility fields") .contains("booking detail calls are not used"); } + @Test + void openApiDefinesContainerCountAsRequirementQuantityTotal() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")); + assertThat(yaml) + .contains("containerCount is the sum of quantity across all container requirements") + .contains("never represents linked or ACTIVE containers") + .contains("Sum of all container requirement quantities; independent of allocation, linking or unlinking."); + } + private static Method method(String name) { return Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods()) .filter(method -> method.getName().equals(name)).findFirst().orElseThrow(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java index 835f826b8..c4dcec3dd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java @@ -1,6 +1,7 @@ package com.baiyue.firstleg.oceanbooking.repository; import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingMapper; +import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingContainerAllocationMapper; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; @@ -21,7 +22,10 @@ class OceanBookingMapperContractTest { .contains("booking.status=#{query.status}") .contains("booking.booking_no LIKE") .contains("party.party_type='SHIPPER'", "party.party_type='CONSIGNEE'") - .contains("fl_ocean_booking_container_allocation", "container.record_status='ACTIVE'", "container_count") + .contains("COALESCE((SELECT SUM(requirement.quantity)") + .contains("FROM fl_ocean_booking_container_requirement requirement") + .contains("requirement.ocean_booking_id=booking.id),0) container_count") + .doesNotContain("fl_ocean_booking_container_allocation allocation\n JOIN fl_container_booking") .contains("ORDER BY booking.updated_at DESC,booking.id DESC"); } @@ -48,12 +52,28 @@ class OceanBookingMapperContractTest { .contains("booking.warehouse_id=#{query.warehouseId}") .contains("booking.channel_code=#{query.channelCode}") .contains("requirement.container_type=#{query.containerType}") - .contains("requirement.capacity_cbm=#{query.capacityCbm}") - .contains("requirement.max_weight_kg=#{query.maxWeightKg}") .contains("FROM fl_ocean_booking_container_allocation") - .contains("GROUP BY ocean_booking_id,requirement_id") + .contains("container.record_status='ACTIVE'") + .contains("container.ocean_booking_id=allocation.ocean_booking_id") + .contains("container.ocean_booking_requirement_id=allocation.requirement_id") + .contains("GROUP BY allocation.ocean_booking_id,allocation.requirement_id") .contains("requirement.quantity-COALESCE(allocated.allocated_quantity,0) remaining_quantity") .contains("requirement.quantity>COALESCE(allocated.allocated_quantity,0)") - .doesNotContain("fl_container_booking", "SELECT booking.*"); + .doesNotContain("fl_container_booking", "SELECT booking.*", + "requirement.capacity_cbm=#{query.capacityCbm}", + "requirement.max_weight_kg=#{query.maxWeightKg}"); + } + + @Test + void finalLinkLimitCountsOnlyActiveContainersStillPointingAtTheSameRequirement() throws Exception { + String sql = String.join("\n", OceanBookingContainerAllocationMapper.class + .getDeclaredMethod("countActiveLinked", long.class, long.class) + .getAnnotation(Select.class).value()); + assertThat(sql) + .contains("container.record_status='ACTIVE'") + .contains("container.ocean_booking_id=allocation.ocean_booking_id") + .contains("container.ocean_booking_requirement_id=allocation.requirement_id") + .contains("allocation.ocean_booking_id=#{oceanBookingId}") + .contains("allocation.requirement_id=#{requirementId}"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index 2bea449df..3af6f71b7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -171,6 +171,22 @@ class OceanBookingServiceTest { verify(repository, times(2)).page(any()); } + @Test + void pagePreservesRequirementTotalContainerCountsForSingleMultipleAndEmptyDrafts() { + OceanBookingEntity one45Hc = booking("RELEASED", 1); one45Hc.setContainerCount(1L); + OceanBookingEntity multipleTypes = booking("RELEASED", 1); multipleTypes.setId(2L); + multipleTypes.setContainerCount(4L); + OceanBookingEntity emptyDraft = booking("DRAFT", 0); emptyDraft.setId(3L); + emptyDraft.setContainerCount(0L); + when(repository.page(any())).thenReturn(new PageResult<>( + List.of(one45Hc, multipleTypes, emptyDraft), 1, 20, 3, 1)); + + PageResult result = service.page(new Query(null, null, null, 1, 20)); + + assertThat(result.records()).extracting(OceanBookingEntity::getContainerCount) + .containsExactly(1L, 4L, 0L); + } + @Test void linkCandidatesDefaultsPaginationAndDelegatesOneNormalizedSetQuery() { LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HC", @@ -179,7 +195,7 @@ class OceanBookingServiceTest { when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1)); PageResult result = service.linkCandidates(new LinkCandidateQuery(701L, - " SEA-US ", " 40HC ", new BigDecimal("76.0"), new BigDecimal("28000.00"), null, null)); + " SEA-US ", " 40HC ", null, null)); assertThat(result.records()).containsExactly(candidate); assertThat(result.records().get(0).remainingQuantity()).isEqualTo(2); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java index 68a9ec0f3..16944ca9c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java @@ -53,15 +53,6 @@ class ContainerBookingMapperContractTest { .contains("customer_item.account_id = #{query.customerId}"); } - @Test - @DisplayName("首次关联客户隔离查询使用原生不等号") - void customerMismatchQueryUsesNativeSqlOperator() throws Exception { - String sql = ContainerBookingMapper.class - .getDeclaredMethod("hasMismatchedCustomerItems", long.class, long.class) - .getAnnotation(Select.class).value()[0]; - assertThat(sql).contains("i.account_id<>#{customerId}").doesNotContain("<>"); - } - @Test @DisplayName("关联订舱 SQL 的预计到港参数与 Mapper 参数名一致") void linkReleasedBookingUsesDeclaredEstimatedArrivalParameter() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 8c6f0a5b3..4dba3b293 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -288,7 +288,7 @@ class ContainerServiceTest { void linksReleasedBookingAfterPreloading() { ContainerBookingEntity container = booking("EDITING", 2); container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC"); - container.setCapacityCbm(new BigDecimal("76.0000")); container.setMaxWeightKg(new BigDecimal("28000.000")); + container.setCapacityCbm(new BigDecimal("85.5000")); container.setMaxWeightKg(new BigDecimal("27500.000")); OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US"); OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); @@ -296,7 +296,7 @@ class ContainerServiceTest { when(repository.find(11L)).thenReturn(container, container); when(oceanBookings.find(21L)).thenReturn(booking); when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); - when(oceanBookings.allocations(21L)).thenReturn(List.of()); + when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L); when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU7654321"), same(booking), eq(2), eq(9L), eq("admin"))).thenReturn(1); @@ -311,6 +311,24 @@ class ContainerServiceTest { verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class)); } + @Test + @DisplayName("首次关联拒绝装柜仓库不一致") + void rejectsLinkWhenWarehouseDiffers() { + assertThreeFieldMismatch(102L, "SEA-US", "40HC"); + } + + @Test + @DisplayName("首次关联拒绝物流渠道不一致") + void rejectsLinkWhenChannelDiffers() { + assertThreeFieldMismatch(101L, "SEA-EU", "40HC"); + } + + @Test + @DisplayName("首次关联拒绝柜型不一致") + void rejectsLinkWhenContainerTypeDiffers() { + assertThreeFieldMismatch(101L, "SEA-US", "45HC"); + } + @Test @DisplayName("未发布订舱不可关联") void rejectsDraftBooking() { @@ -333,12 +351,10 @@ class ContainerServiceTest { booking.setId(21L); booking.setStatus("RELEASED"); OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); requirement.setId(31L); requirement.setQuantity(1); - OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity(); - allocation.setRequirementId(31L); when(repository.find(11L)).thenReturn(container); when(oceanBookings.find(21L)).thenReturn(booking); when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); - when(oceanBookings.allocations(21L)).thenReturn(List.of(allocation)); + when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(1L); assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2))) @@ -347,6 +363,28 @@ class ContainerServiceTest { any(), anyInt(), anyLong(), anyString()); } + private void assertThreeFieldMismatch(long bookingWarehouseId, String bookingChannel, + String requirementType) { + ContainerBookingEntity container = booking("WAIT_LOADING", 2); + container.setWarehouseId(101L); container.setChannelCode("SEA-US"); + container.setContainerType("40HC"); + OceanBookingEntity oceanBooking = new OceanBookingEntity(); + oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED"); + oceanBooking.setWarehouseId(bookingWarehouseId); oceanBooking.setChannelCode(bookingChannel); + OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); + requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType(requirementType); + when(repository.find(11L)).thenReturn(container); + when(oceanBookings.find(21L)).thenReturn(oceanBooking); + when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); + when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L); + + assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2))) + .isInstanceOf(BizException.class).hasMessageContaining("仓库、渠道或柜型不一致"); + verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(), + any(), anyInt(), anyLong(), anyString()); + } + @Test @DisplayName("完全空的模拟柜直接解绑并删除当前allocation,不依赖MERGED来源柜") void unlinksEmptySimulationDirectly() { From 5d2b6c22e6348e6b04829f85bafcb0b90a28b0ec Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 20:17:12 +0800 Subject: [PATCH 23/42] test: freeze server-filtered booking candidates --- .../main/resources/openapi/ocean-bookings-v1.yaml | 2 +- ...latformOceanBookingControllerContractTest.java | 5 ++++- .../service/OceanBookingServiceTest.java | 15 +++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 8b7097c85..a7ac3f556 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -36,7 +36,7 @@ paths: /api/platform/v1/ocean-bookings/link-candidates: get: summary: Page released booking requirement candidates for one container - description: Requires platform:firstleg:ocean-booking:list. Eligibility matches only warehouseId, channelCode and containerType, with remainingQuantity greater than zero. Capacity and max weight are derived display snapshots and never eligibility fields. Allocated quantity counts only ACTIVE containers still pointing to the same booking requirement and is calculated by one bounded set query; booking detail calls are not used. + description: Requires platform:firstleg:ocean-booking:list. Returns only linkable requirement candidates: RELEASED, remainingQuantity greater than zero, and exact warehouseId, channelCode and containerType. Capacity and max weight are derived display snapshots and never eligibility fields. Allocated quantity counts only ACTIVE containers still pointing to the same booking requirement and is calculated by one bounded set query; booking detail calls are not used. An empty result is a normal successful page. Final target-container mutability is revalidated by the link command. x-permission: platform:firstleg:ocean-booking:list parameters: - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java index a7200cd27..7a4ba2a08 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java @@ -71,7 +71,10 @@ class PlatformOceanBookingControllerContractTest { .contains("warehouseId", "channelCode", "containerType") .contains("LinkCandidateResponse", "requiredQuantity", "allocatedQuantity", "remainingQuantity") .contains("Capacity and max weight are derived display snapshots and never eligibility fields") - .contains("booking detail calls are not used"); + .contains("Returns only linkable requirement candidates") + .contains("An empty result is a normal successful page") + .contains("booking detail calls are not used") + .doesNotContain("mismatchReasons", "disabledReason"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index 3af6f71b7..7cbc45b12 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -210,6 +210,21 @@ class OceanBookingServiceTest { verify(repository, never()).containers(anyLong()); } + @Test + void linkCandidatesReturnsNormalEmptyPageWithOneSetQueryAndNoDiagnosticFields() { + when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + + PageResult result = service.linkCandidates( + new LinkCandidateQuery(701L, "SEA-US", "45HC", null, null)); + + assertThat(result.records()).isEmpty(); + assertThat(result.total()).isZero(); + assertThat(result.pages()).isZero(); + verify(repository, times(1)).linkCandidates(any()); + assertThat(LinkCandidate.class.getRecordComponents()).extracting(java.lang.reflect.RecordComponent::getName) + .doesNotContain("mismatchReasons", "disabledReason", "warehouseId", "channelCode"); + } + @Test void releaseRejectsInvalidScheduleWithoutTransition() { OceanBookingEntity draft = completeBooking("DRAFT", 3); From 44fee01761b57483106002e103b1f0a0d76f53d6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 20:21:42 +0800 Subject: [PATCH 24/42] test: align active booking allocation contract --- .../oceanbooking/repository/OceanBookingMapperContractTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java index c4dcec3dd..d4557b21e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java @@ -59,7 +59,7 @@ class OceanBookingMapperContractTest { .contains("GROUP BY allocation.ocean_booking_id,allocation.requirement_id") .contains("requirement.quantity-COALESCE(allocated.allocated_quantity,0) remaining_quantity") .contains("requirement.quantity>COALESCE(allocated.allocated_quantity,0)") - .doesNotContain("fl_container_booking", "SELECT booking.*", + .doesNotContain("SELECT booking.*", "requirement.capacity_cbm=#{query.capacityCbm}", "requirement.max_weight_kg=#{query.maxWeightKg}"); } From 897ddd476f99f6534bc3b06807d962da7aa4080c Mon Sep 17 00:00:00 2001 From: wujinliang <2318309077@qq.com> Date: Tue, 25 Aug 2026 22:08:44 +0800 Subject: [PATCH 25/42] 1 --- .../PlatformProductImageController.java | 55 +++++++++++++++++++ .../ProductImageControllerContractTest.java | 20 +++++++ .../ReconciliationStatementService.java | 4 +- .../service/UnifiedOrderQueryService.java | 53 ++++++++++++++---- .../service/UnifiedOrderQueryServiceTest.java | 11 +++- 5 files changed, 128 insertions(+), 15 deletions(-) create mode 100644 file/src/main/java/com/baiyue/file/controller/PlatformProductImageController.java diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformProductImageController.java b/file/src/main/java/com/baiyue/file/controller/PlatformProductImageController.java new file mode 100644 index 000000000..4ffcf47d0 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/PlatformProductImageController.java @@ -0,0 +1,55 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.service.ProductImageService; +import jakarta.annotation.Resource; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; + +/** 鐢ㄤ簬骞冲彴绔鍗曡鎯呮煡鐪嬪晢鎴峰晢鍝佸浘鐗囥€? */ +@RestController +@RequestMapping("/api/platform/v1/files/product-images") +public class PlatformProductImageController { + @Resource private ProductImageService images; + + @GetMapping("/{tenantId}/{fileId}") + @PreAuthorize("hasAuthority('platform:order:list')") + public ResponseEntity download( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long tenantId, + @PathVariable long fileId) { + requirePlatform(user); + if (tenantId <= 0 || fileId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "瀹㈡埛鍜屾枃浠禝D蹇呴』澶т簬0"); + } + var image = images.download(tenantId, fileId); + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .contentType(MediaType.parseMediaType(image.contentType())) + .contentLength(image.content().length) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline() + .filename(image.fileName(), StandardCharsets.UTF_8).build().toString()) + .body(image.content()); + } + + private static void requirePlatform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode()) + || user.tenantId() == null || user.tenantId() != 0L + || user.userId() == null || user.userId() <= 0) { + throw new BizException(ResultCode.FORBIDDEN, "闇€瑕佸钩鍙扮韬唤"); + } + } +} diff --git a/file/src/test/java/com/baiyue/file/controller/ProductImageControllerContractTest.java b/file/src/test/java/com/baiyue/file/controller/ProductImageControllerContractTest.java index d9828b371..eaff89224 100644 --- a/file/src/test/java/com/baiyue/file/controller/ProductImageControllerContractTest.java +++ b/file/src/test/java/com/baiyue/file/controller/ProductImageControllerContractTest.java @@ -77,6 +77,26 @@ class ProductImageControllerContractTest { assertThatThrownBy(() -> controller.upload(platform, file)).isInstanceOf(BizException.class); } + @Test + void platformDownloadUsesTenantScopeAndOrderListPermission() throws Exception { + RequestMapping mapping = PlatformProductImageController.class.getAnnotation(RequestMapping.class); + assertThat(mapping.value()).contains("/api/platform/v1/files/product-images"); + var download = PlatformProductImageController.class.getDeclaredMethod( + "download", AuthenticatedUser.class, long.class, long.class); + assertThat(download.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:order:list')"); + + ProductImageService service = mock(ProductImageService.class); + PlatformProductImageController controller = new PlatformProductImageController(); + ReflectionTestUtils.setField(controller, "images", service); + when(service.download(10001L, 31L)).thenReturn( + new ProductImageService.Download("product.png", "image/png", new byte[] {1})); + + var platform = new AuthenticatedUser(1L, 0L, 0L, 1L, "admin", "PLATFORM", "WEB", Set.of()); + assertThat(controller.download(platform, 10001L, 31L).getBody()).containsExactly(1); + verify(service).download(10001L, 31L); + } + private static AuthenticatedUser merchant(long tenantId) { return new AuthenticatedUser(2L, tenantId, tenantId, 2L, "merchant", "MERCHANT", "WEB", Set.of()); } diff --git a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java index cb2a5669e..7311d5375 100644 --- a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java @@ -68,7 +68,9 @@ public class ReconciliationStatementService { CustomerProfile customer = customer(request.customerId()); long subjectId = activeSubject(); List sources = eligibleSources(request); - if (sources.isEmpty()) throw error("FIN_409", "没有符合结算条件的CNY应收费用来源"); + if (sources.isEmpty()) { + throw error("FIN_409", "当前客户在所选账期内没有可生成的CNY应收费用,请调整账期或选择其他客户后重试"); + } BigDecimal total = sources.stream().map(Source::amount).reduce(BigDecimal.ZERO, BigDecimal::add); LocalDate dueDate = request.periodEnd().plusDays(customer.creditDays()); String statementNo = documentNumbers.next("REC"); diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index 5e3401d97..b50c0d987 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -19,6 +19,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; +import java.math.BigDecimal; import java.util.ArrayList; import java.time.LocalDateTime; import java.util.Comparator; @@ -181,10 +182,10 @@ public class UnifiedOrderQueryService { order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null, order.getInboundCode(), - order.getExpectedShipDate(), totalPackageCount(cargo), null, null, null, - null, null, null, null, null, order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(), + order.getExpectedShipDate(), totalPackageCount(cargo), null, null, grossWeightKg(cargo), volumeCbm(cargo), + null, null, null, null, order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(), order.getAmazonWarehouseCode(), - null, null, null, null, null, null, null, order.getExpressCompany(), order.getExpressTrackingNo(), + null, null, null, null, netWeightKg(cargo), null, null, order.getExpressCompany(), order.getExpressTrackingNo(), string(order.getCreatedBy()), skuNos(cargo), totalQuantity(cargo), order.getShipmentId(), order.getReferenceId(), null, null, null, null, null, order.getCustomerRemark(), latestInternalNote, @@ -196,11 +197,13 @@ public class UnifiedOrderQueryService { order.getCustomerId(), order.getCustomerName(), order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(), - order.getExpectedShipDate(), inbound.totalPackageCount(), null, inbound.locationCodes(), - inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), inbound.expectedArrivalAt(), inbound.feeReviewStatus(), + order.getExpectedShipDate(), inbound.totalPackageCount() != null ? inbound.totalPackageCount() : totalPackageCount(cargo), null, inbound.locationCodes(), + inbound.actualGrossWeightKg() != null ? inbound.actualGrossWeightKg() : grossWeightKg(cargo), + inbound.actualVolumeCbm() != null ? inbound.actualVolumeCbm() : volumeCbm(cargo), inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(), inbound.businessType(), order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(), inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(), - inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg(), + inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), + inbound.actualNetWeightKg() != null ? inbound.actualNetWeightKg() : netWeightKg(cargo), inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(), inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), order.getShipmentId(), order.getReferenceId(), measurement(inbound), preload(summary.preload()), loading(summary.loading()), @@ -217,12 +220,16 @@ public class UnifiedOrderQueryService { parseOrderStatus(inbound.orderStatusSnapshot()), inbound.customerId(), inbound.customerName(), inbound.channelName(), inbound.warehouseId(), inbound.warehouseName(), inbound.deliveryWarehouseId(), inbound.deliveryWarehouseName(), - inbound.id(), inbound.version(), inbound.inboundCode(), null, inbound.totalPackageCount(), - null, inbound.locationCodes(), inbound.actualGrossWeightKg(), - inbound.actualVolumeCbm(), inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(), + inbound.id(), inbound.version(), inbound.inboundCode(), null, + inbound.totalPackageCount() != null ? inbound.totalPackageCount() : totalPackageCount(cargo), + null, inbound.locationCodes(), + inbound.actualGrossWeightKg() != null ? inbound.actualGrossWeightKg() : grossWeightKg(cargo), + inbound.actualVolumeCbm() != null ? inbound.actualVolumeCbm() : volumeCbm(cargo), + inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(), inbound.businessType(), order == null ? null : order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(), inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(), - inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg(), + inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), + inbound.actualNetWeightKg() != null ? inbound.actualNetWeightKg() : netWeightKg(cargo), inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(), inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), order == null ? null : order.getShipmentId(), order == null ? null : order.getReferenceId(), measurement(inbound), preload(summary.preload()), loading(summary.loading()), @@ -282,7 +289,20 @@ public class UnifiedOrderQueryService { return cargo == null ? null : cargo.totalPackageCount(); } - private record CargoSummary(List skuNos, Integer totalQuantity, Integer totalPackageCount) { + private BigDecimal grossWeightKg(CargoSummary cargo) { + return cargo == null ? null : cargo.grossWeightKg(); + } + + private BigDecimal netWeightKg(CargoSummary cargo) { + return cargo == null ? null : cargo.netWeightKg(); + } + + private BigDecimal volumeCbm(CargoSummary cargo) { + return cargo == null ? null : cargo.volumeCbm(); + } + + private record CargoSummary(List skuNos, Integer totalQuantity, Integer totalPackageCount, + BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm) { private static CargoSummary from(List cargoes) { List skuNos = cargoes.stream().map(OrderCargoEntity::getSkuNo) .filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).distinct().toList(); @@ -292,7 +312,16 @@ public class UnifiedOrderQueryService { .filter(Objects::nonNull).toList(); Integer totalPackageCount = packageCounts.isEmpty() ? null : Math.toIntExact(packageCounts.stream().mapToLong(Integer::longValue).sum()); - return new CargoSummary(skuNos, totalQuantity, totalPackageCount); + BigDecimal grossWeightKg = sum(cargoes, OrderCargoEntity::getGrossWeightKg); + BigDecimal netWeightKg = sum(cargoes, OrderCargoEntity::getNetWeightKg); + BigDecimal volumeCbm = sum(cargoes, OrderCargoEntity::getVolumeCbm); + return new CargoSummary(skuNos, totalQuantity, totalPackageCount, grossWeightKg, netWeightKg, volumeCbm); + } + + private static BigDecimal sum(List cargoes, + Function value) { + List values = cargoes.stream().map(value).filter(Objects::nonNull).toList(); + return values.isEmpty() ? null : values.stream().reduce(BigDecimal.ZERO, BigDecimal::add); } } private String inboundMainStatus(String status) { diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index aa5c9a3d6..b0e6e56e6 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -84,13 +84,20 @@ class UnifiedOrderQueryServiceTest { OrderCargoEntity cargo = new OrderCargoEntity(); cargo.setOrderId(77L); cargo.setPackageCount(100); + cargo.setGrossWeightKg(new BigDecimal("12.5")); + cargo.setNetWeightKg(new BigDecimal("11.2")); + cargo.setVolumeCbm(new BigDecimal("0.375")); when(orders.unifiedPage(eq(10001L), eq(query), any())) .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); when(firstleg.summaries(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of())); when(orders.findCargoByOrderIds(10001L, List.of(77L))).thenReturn(List.of(cargo)); - assertThat(service.page(10001L, query).records()).singleElement() - .extracting(UnifiedOrderResponse.Row::totalPackageCount).isEqualTo(100); + assertThat(service.page(10001L, query).records()).singleElement().satisfies(row -> { + assertThat(row.totalPackageCount()).isEqualTo(100); + assertThat(row.actualGrossWeightKg()).isEqualByComparingTo("12.5"); + assertThat(row.totalNetWeightKg()).isEqualByComparingTo("11.2"); + assertThat(row.actualVolumeCbm()).isEqualByComparingTo("0.375"); + }); verify(orders).findCargoByOrderIds(10001L, List.of(77L)); } From 15421db4215f57d5eb908e3ab68265d570f4f96b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 11:06:24 +0800 Subject: [PATCH 26/42] fix(event-bridge): restore overseas event delivery --- deploy/sql/海外区域统一增量-MySQL8.sql | 107 ++++++++++++++++++ .../service/EventInboxService.java | 10 +- .../service/EventInboxServiceTest.java | 16 +++ scripts/overseas-deployment-contract.test.mjs | 18 +++ 4 files changed, 149 insertions(+), 2 deletions(-) diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 882d2a042..7504a0f81 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1,6 +1,113 @@ -- 海外区域固定增量入口。空库先执行 lastleg-service/src/main/resources/db/lastleg.sql 基线; +-- 本入口同时初始化海外 Event Bridge 独立库,避免国内/海外实例误共用 Inbox。 -- 本文件只由人工审核执行,服务部署默认不自动执行 SQL。 SET NAMES utf8mb4; + +CREATE DATABASE IF NOT EXISTS baiyue_event_bridge_overseas + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_event_bridge_overseas; + +CREATE TABLE IF NOT EXISTS bridge_outbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + idempotency_key VARCHAR(191) NOT NULL COMMENT '发送幂等键', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', + aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', + handover_version BIGINT NULL COMMENT '跨区交接版本', + trace_id VARCHAR(128) NOT NULL COMMENT '链路标识', + correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识', + causation_id VARCHAR(128) NULL COMMENT '上游事件标识', + occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要', + payload_json JSON NOT NULL COMMENT '事件负载', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态', + retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', + lease_owner VARCHAR(128) NULL COMMENT '租约持有者', + lease_until DATETIME(3) NULL COMMENT '租约截止时间', + last_error VARCHAR(1000) NULL COMMENT '最后错误', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + delivered_at DATETIME(3) NULL COMMENT '投递成功时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_bridge_outbox_idempotency (idempotency_key), + KEY idx_bridge_outbox_schedule (target_region,status,next_retry_at,lease_until) +) ENGINE=InnoDB COMMENT='海外跨区域事件待投递箱'; + +CREATE TABLE IF NOT EXISTS bridge_inbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + idempotency_key VARCHAR(191) NOT NULL COMMENT '接收幂等键', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', + aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', + handover_version BIGINT NULL COMMENT '跨区交接版本', + trace_id VARCHAR(128) NOT NULL COMMENT '链路标识', + correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识', + causation_id VARCHAR(128) NULL COMMENT '上游事件标识', + occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要', + payload_json JSON NOT NULL COMMENT '事件负载', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态', + lease_owner VARCHAR(128) NULL COMMENT '消费租约持有者', + lease_until DATETIME(3) NULL COMMENT '消费租约截止时间', + retry_count INT NOT NULL DEFAULT 0 COMMENT '消费重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次消费时间', + last_error VARCHAR(1000) NULL COMMENT '最后错误', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '消费完成时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key), + KEY idx_bridge_inbox_consume (status,next_retry_at,lease_until) +) ENGINE=InnoDB COMMENT='海外跨区域事件已接收箱'; + +CREATE TABLE IF NOT EXISTS bridge_delivery_attempt ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + attempt_no INT NOT NULL COMMENT '投递序号', + status VARCHAR(32) NOT NULL COMMENT '投递结果', + response_code VARCHAR(64) NULL COMMENT '对端响应码', + error_message VARCHAR(1000) NULL COMMENT '错误信息', + started_at DATETIME(3) NOT NULL COMMENT '开始时间', + finished_at DATETIME(3) NULL COMMENT '完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_bridge_attempt (event_id,attempt_no), + KEY idx_bridge_attempt_event (event_id,started_at) +) ENGINE=InnoDB COMMENT='海外跨区域事件投递尝试记录'; + +DROP PROCEDURE IF EXISTS assert_overseas_event_bridge_schema; +DELIMITER $$ +CREATE PROCEDURE assert_overseas_event_bridge_schema() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_event_bridge_overseas' + AND table_name IN ('bridge_outbox','bridge_inbox','bridge_delivery_attempt')) <> 3 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT='海外区域初始化失败:Event Bridge 三张表未完整创建'; + END IF; + IF (SELECT COUNT(DISTINCT CONCAT(table_name, ':', index_name)) FROM information_schema.statistics + WHERE table_schema='baiyue_event_bridge_overseas' + AND ((table_name='bridge_outbox' AND index_name IN ('PRIMARY','uk_bridge_outbox_idempotency','idx_bridge_outbox_schedule')) + OR (table_name='bridge_inbox' AND index_name IN ('PRIMARY','uk_bridge_inbox_idempotency','idx_bridge_inbox_consume')) + OR (table_name='bridge_delivery_attempt' AND index_name IN ('PRIMARY','uk_bridge_attempt','idx_bridge_attempt_event')))) <> 9 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT='海外区域初始化失败:Event Bridge 索引未完整创建'; + END IF; +END$$ +DELIMITER ; +CALL assert_overseas_event_bridge_schema(); +DROP PROCEDURE IF EXISTS assert_overseas_event_bridge_schema; + CREATE DATABASE IF NOT EXISTS baiyue_lastleg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE baiyue_lastleg; diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java index f4adf8f3d..f904c7984 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java @@ -14,7 +14,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import jakarta.annotation.Resource; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.Objects; /** @@ -78,7 +80,7 @@ public class EventInboxService { && Objects.equals(existing.getAggregateId(), envelope.aggregateId()) && Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion()) && Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion()) - && Objects.equals(existing.getOccurredAt(), envelope.occurredAt()) + && Objects.equals(normalizeOccurredAt(existing.getOccurredAt()), normalizeOccurredAt(envelope.occurredAt())) && Objects.equals(existing.getTraceId(), envelope.traceId()) && Objects.equals(existing.getCorrelationId(), envelope.correlationId()) && Objects.equals(existing.getCausationId(), envelope.causationId()); @@ -100,7 +102,7 @@ public class EventInboxService { inbox.setTraceId(envelope.traceId()); inbox.setCorrelationId(envelope.correlationId()); inbox.setCausationId(envelope.causationId()); - inbox.setOccurredAt(envelope.occurredAt()); + inbox.setOccurredAt(normalizeOccurredAt(envelope.occurredAt())); inbox.setPayloadHash(envelope.payloadHash().toLowerCase(java.util.Locale.ROOT)); try { inbox.setPayloadJson(objectMapper.writeValueAsString(envelope.payload())); @@ -111,4 +113,8 @@ public class EventInboxService { inbox.setReceivedAt(LocalDateTime.now()); return inbox; } + + private static Instant normalizeOccurredAt(Instant value) { + return value == null ? null : value.truncatedTo(ChronoUnit.MILLIS); + } } diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java index c8ee93232..6789ed520 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java @@ -65,6 +65,22 @@ class EventInboxServiceTest { assertThat(receiveCount("DUPLICATE")).isEqualTo(1.0); } + @Test + void 数据库毫秒精度截断后相同事件仍判定为重复() { + EventEnvelope source = envelope("sha256:placeholder"); + EventEnvelope precise = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), + Instant.parse("2026-07-29T08:30:00.123456789Z"), source.sourceRegion(), source.targetRegion(), + source.tenantId(), source.aggregateType(), source.aggregateId(), source.aggregateVersion(), + source.handoverVersion(), source.traceId(), source.correlationId(), source.causationId(), + source.payload(), source.payloadHash()); + EventEnvelope envelope = withHash(precise); + BridgeInbox existing = existing(envelope, envelope.payloadHash()); + existing.setOccurredAt(Instant.parse("2026-07-29T08:30:00.123Z")); + when(store.findByEventId(envelope.eventId())).thenReturn(existing); + + assertThat(service.receive(envelope).result()).isEqualTo("重复"); + } + @Test void 相同事件但摘要不同时拒绝覆盖() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index d5c3efac4..72155a2ea 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -131,3 +131,21 @@ test('domestic event bridge SQL converges legacy tenant columns to the customer assert.match(incremental, /bridge_inbox_customer_column = 0 AND @bridge_inbox_tenant_column = 1/) assert.match(incremental, /bridge_outbox_customer_column = 0 AND @bridge_outbox_tenant_column = 1/) }) + +test('overseas unified SQL initializes the isolated Event Bridge database before services start', async () => { + const overseas = await read('deploy/sql/海外区域统一增量-MySQL8.sql') + assert.match(overseas, /CREATE DATABASE IF NOT EXISTS baiyue_event_bridge_overseas/) + assert.match(overseas, /USE baiyue_event_bridge_overseas;/) + for (const table of ['bridge_outbox', 'bridge_inbox', 'bridge_delivery_attempt']) { + assert.match(overseas, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`)) + } + for (const index of [ + 'uk_bridge_outbox_idempotency', 'idx_bridge_outbox_schedule', + 'uk_bridge_inbox_idempotency', 'idx_bridge_inbox_consume', + 'uk_bridge_attempt', 'idx_bridge_attempt_event' + ]) assert.match(overseas, new RegExp(index)) + assert.match(overseas, /COUNT\(DISTINCT CONCAT\(table_name, ':', index_name\)\)/) + assert.match(overseas, /CALL assert_overseas_event_bridge_schema\(\);/) + assert.ok(overseas.indexOf('USE baiyue_event_bridge_overseas;') < overseas.indexOf('USE baiyue_lastleg;'), + 'overseas Event Bridge schema must be initialized before Lastleg tables') +}) From cf5d603a215b8c05cd801efce33493a3644468c2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 11:10:42 +0800 Subject: [PATCH 27/42] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 +- .../resources/db/phase1-admin-accounts.sql | 6 +- .../InitialAccountSeedSqlContractTest.java | 7 +- deploy/sql/README.md | 4 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 186 ++++-------------- firstleg/AGENTS.md | 3 +- .../db/admin-prototype-menu-permissions.sql | 14 +- .../main/resources/db/phase1-admin-rbac.sql | 80 +------- .../db/phase1-member-permissions.sql | 9 - .../main/resources/db/phase1-onboarding.sql | 31 ++- .../resources/db/rbac-role-management-v2.sql | 9 - ...rototypeMenuPermissionSqlContractTest.java | 7 +- .../InitialIdentitySeedSqlContractTest.java | 33 +++- .../resources/db/verify-phase1-admin-rbac.sql | 14 +- scripts/README.md | 2 +- scripts/admin-test-env-acceptance.mjs | 185 ++++++++++++++++- scripts/admin-test-env-modules/fee-items.mjs | 4 +- .../finance-demo-fixtures-contract.test.mjs | 10 +- .../finance-demo-fixtures.mjs | 36 ++-- ...und-measurement-box-code-contract.test.mjs | 4 +- .../inbound-measurement.mjs | 122 ++++++------ .../initialization-baseline-contract.test.mjs | 20 +- ...nt-qualification-fixture-contract.test.mjs | 34 ++++ .../merchant-qualification-fixture.mjs | 5 +- .../order-inbound-status-fixtures.mjs | 4 +- scripts/admin-test-env-modules/orders.mjs | 162 +++++++++++---- .../admin-test-env-modules/phase3-customs.mjs | 4 +- .../admin-test-env-modules/phase3-loading.mjs | 4 +- .../phase3-order-splitting.mjs | 4 +- .../admin-test-env-modules/supplemental.mjs | 10 +- scripts/admin-test-env-modules/suppliers.mjs | 4 +- scripts/agents-index-contract.ps1 | 64 +++++- scripts/client-phase2-test-env-acceptance.mjs | 6 +- scripts/client-test-env-acceptance.mjs | 6 +- ...verseas-acceptance-entry-contract.test.mjs | 2 +- 35 files changed, 664 insertions(+), 435 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8fd132bd1..c35577029 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ |---|---| | 公共依赖版本 | `platform-bom/pom.xml`;统一管理公共组件版本,不放业务实现 | | 通用响应、分页、异常、基础实体与导出契约 | `common-core/`;从 `ApiResponse.java`、`PageResult.java`、`BizException.java`、`ExportContracts.java` 定位 | -| Web 异常、请求关联 ID、Long ID 序列化 | `common-web/`;从 `GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`LongIdJacksonConfiguration.java` 定位 | +| Web 异常、请求关联/Trace、同步出站传播、Long ID 序列化 | `common-web/`;从 `GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`TraceParent.java`、`FeignTraceRequestInterceptor.java`、`RestClientTraceInterceptor.java`、`LongIdJacksonConfiguration.java` 定位 | | JWT、JWKS、会话校验与认证用户 | `common-security/`;从 `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java`、`AccessTokenSessionValidator.java` 定位 | | Springdoc/OpenAPI 自动配置 | `common-openapi/`;从 `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` 定位 | | 业务审计注解、脱敏、Outbox 与投递 | `common-audit/`;从 `BusinessAudit.java`、`BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`AuditOutboxPublisher.java` 定位 | @@ -83,7 +83,7 @@ - 数据库只访问所属服务库。模块表结构位于 `/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。 - 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - Java 签名变更的最低编译门禁是 `mvn -pl -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 -- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、海外 Gateway 覆盖、Lastleg 海外安全投影映射、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 +- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、四期范围漂移、订舱关联候选与柜量语义、海外 Gateway 覆盖、PDA 运行配置、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 - 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。 ## 全量 HTTP 验收脚本路由 diff --git a/auth/src/main/resources/db/phase1-admin-accounts.sql b/auth/src/main/resources/db/phase1-admin-accounts.sql index cfa329c3d..130763a9f 100644 --- a/auth/src/main/resources/db/phase1-admin-accounts.sql +++ b/auth/src/main/resources/db/phase1-admin-accounts.sql @@ -1,4 +1,4 @@ --- Phase-one local administrator accounts. +-- Phase-one local platform administrator account. Merchant accounts are created through onboarding. -- Development-only initial password: ChangeMe123! -- The BCrypt hash is intentionally stored instead of plaintext. USE baiyue_auth; @@ -10,9 +10,7 @@ INSERT INTO auth_account ( operator_tenant_id, default_membership_id, default_app_code, deleted ) VALUES (1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', - '平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0), - (1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', - 'IT 测试客户管理员', 'ACTIVE', 0, 1, 0, 10001, 10001, 3000002, 'MERCHANT', 0) + '平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0) ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), display_name = VALUES(display_name), diff --git a/auth/src/test/java/com/baiyue/auth/repository/InitialAccountSeedSqlContractTest.java b/auth/src/test/java/com/baiyue/auth/repository/InitialAccountSeedSqlContractTest.java index 025bf6ec7..82620e90c 100644 --- a/auth/src/test/java/com/baiyue/auth/repository/InitialAccountSeedSqlContractTest.java +++ b/auth/src/test/java/com/baiyue/auth/repository/InitialAccountSeedSqlContractTest.java @@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; class InitialAccountSeedSqlContractTest { @Test - void initializesOnlyThePlatformAndItTestAdministratorAccounts() throws Exception { + void initializesOnlyThePlatformAdministratorAccount() throws Exception { assertAccountSeed(new ClassPathResource("db/phase1-admin-accounts.sql") .getContentAsString(StandardCharsets.UTF_8)); assertAccountSeed(Files.readString(unifiedSql(), StandardCharsets.UTF_8)); @@ -21,9 +21,10 @@ class InitialAccountSeedSqlContractTest { assertThat(sql) .contains("(1000001, 'platform_admin'") .contains("'平台系统管理员', 'ACTIVE'") - .contains("(1000002, 'merchant_admin'") - .contains("'IT 测试客户管理员', 'ACTIVE'") .contains("password_change_required") + .doesNotContain("merchant_admin") + .doesNotContain("IT_TEST") + .doesNotContain("(1000002, 'merchant_admin'") .doesNotContain("一期演示企业管理员"); } diff --git a/deploy/sql/README.md b/deploy/sql/README.md index f857cf41f..aabc7dd7d 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -3,14 +3,14 @@ ## 唯一数据库初始化与发布入口 - 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 -- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`与 `warehouse-overseas-service/src/main/resources/db/warehouse.sql` 基线,再执行该入口。固定入口仅升级 `baiyue_lastleg`与 `baiyue_lastlag_warehouse`;IAM 安全事件 Inbox 和可恢复投影已并入 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。 +- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`与 `warehouse-overseas-service/src/main/resources/db/warehouse.sql` 基线,再执行该入口。固定入口会创建并校验独立的 `baiyue_event_bridge_overseas`,再升级 `baiyue_lastleg`与 `baiyue_lastlag_warehouse`;IAM 安全事件 Inbox 和可恢复投影已并入 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。 - Lastleg 基线和海外固定增量仅确定性初始化国内固定 `platform_admin`(`authUserId=1000001`、`tenantId=0`)的 USER/AUTHORIZATION version=0 安全投影;其他既有开发账号不建立兼容表或长期补投入口。 - 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。 - 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 - 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。 - 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。 -- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建最小登录账号,重复执行不覆盖现有密码。脚本不写入演示订单;正式平台公共仓库主数据已内联到固定入口。 +- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建平台管理员,重复执行不覆盖现有密码。脚本不创建固定客户、客户账号或演示订单;客户必须通过正式建档/注册流程创建。正式平台公共仓库主数据已内联到固定入口。 - `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 - 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。 - IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 22b4dabb6..3943ecee3 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -278,7 +278,7 @@ PREPARE auth_password_change_required_stmt FROM @auth_password_change_required_s EXECUTE auth_password_change_required_stmt; DEALLOCATE PREPARE auth_password_change_required_stmt; --- 开发/测试环境最小登录基线:一个平台系统管理员和一个 IT 测试客户管理员。 +-- 开发/测试环境最小登录基线只保留平台系统管理员;客户必须走正式建档/注册流程创建。 -- 缺失账号首次创建时写入开发基线 BCrypt 哈希;重复执行统一增量绝不覆盖现有密码。 INSERT INTO auth_account ( id, username, password_hash, display_name, account_status, @@ -286,9 +286,7 @@ INSERT INTO auth_account ( operator_tenant_id, default_membership_id, default_app_code, deleted ) VALUES (1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', - '平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0), - (1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', - 'IT 测试客户管理员', 'ACTIVE', 0, 1, 0, 10001, 10001, 3000002, 'MERCHANT', 0) + '平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0) ON DUPLICATE KEY UPDATE display_name = VALUES(display_name), account_status = 'ACTIVE', @@ -1194,10 +1192,7 @@ INSERT INTO iam_tenant ( approved_at, approved_by, created_by, deleted, version ) VALUES (0, 'PLATFORM', '虫虫出海平台', 'PLATFORM', 'ACTIVE', '虫虫出海平台', - NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0), - (10001, 'IT_TEST', 'IT 测试客户', 'MERCHANT', 'ACTIVE', 'IT 测试客户', - '91310000ITTEST0001', 'IT 测试客户管理员', '13800000000', 'it-test@example.com', - NOW(3), 1000001, 1000001, 0, 0) + NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0) ON DUPLICATE KEY UPDATE tenant_code = VALUES(tenant_code), tenant_name = VALUES(tenant_name), tenant_type = VALUES(tenant_type), status = 'ACTIVE', legal_name = VALUES(legal_name), @@ -1206,28 +1201,10 @@ ON DUPLICATE KEY UPDATE contact_email = VALUES(contact_email), approved_at = COALESCE(approved_at, NOW(3)), approved_by = VALUES(approved_by), deleted = 0; --- The built-in IT customer must be immediately usable for order acceptance tests. --- These approved metadata rows intentionally have no document URL: initialization must not --- point at qualification files that do not exist in File service storage. -INSERT INTO iam_tenant_qualification ( - id, tenant_id, qualification_type, certificate_no, document_url, - valid_from, valid_to, status, review_comment, deleted -) VALUES - (2100001, 10001, 'BUSINESS_LICENSE', '91310000ITTEST0001', NULL, - NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0), - (2100002, 10001, 'LEGAL_PERSON_ID', NULL, NULL, - NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0) -ON DUPLICATE KEY UPDATE - tenant_id = VALUES(tenant_id), qualification_type = VALUES(qualification_type), - certificate_no = VALUES(certificate_no), status = 'APPROVED', - review_comment = VALUES(review_comment), deleted = 0; - INSERT INTO iam_user ( id, auth_user_id, user_type, display_name, phone, email, status, deleted, version ) VALUES - (2000001, 1000001, 'INTERNAL', '平台系统管理员', NULL, NULL, 'ACTIVE', 0, 0), - (2000002, 1000002, 'ENTERPRISE', 'IT 测试客户管理员', '13800000000', - 'it-test@example.com', 'ACTIVE', 0, 0) + (2000001, 1000001, 'INTERNAL', '平台系统管理员', NULL, NULL, 'ACTIVE', 0, 0) ON DUPLICATE KEY UPDATE display_name = VALUES(display_name), phone = VALUES(phone), email = VALUES(email), status = 'ACTIVE', deleted = 0; @@ -1235,8 +1212,7 @@ ON DUPLICATE KEY UPDATE INSERT INTO iam_membership ( id, tenant_id, user_id, member_type, member_no, status, joined_at, deleted, version ) VALUES - (3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0), - (3000002, 10001, 2000002, 'OWNER', 'MERCHANT-OWNER-001', 'ACTIVE', NOW(3), 0, 0) + (3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0) ON DUPLICATE KEY UPDATE member_type = VALUES(member_type), member_no = VALUES(member_no), status = 'ACTIVE', joined_at = COALESCE(joined_at, NOW(3)), left_at = NULL, deleted = 0; @@ -1246,9 +1222,7 @@ INSERT INTO iam_role ( ) VALUES (4000001, 0, 'PLATFORM', 'PLATFORM_ADMIN', '平台超级管理员', 'SYSTEM', 'ACTIVE', '一期平台全部菜单和按钮', 0, 0), (4000002, 0, 'PLATFORM', 'CUSTOMER_REVIEWER', '企业审核员', 'SYSTEM', 'ACTIVE', '企业资料与入驻审核', 0, 0), - (4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0), - (4000101, 10001, 'MERCHANT', 'MERCHANT_OWNER', '企业管理员', 'SYSTEM', 'ACTIVE', '本企业一期全部菜单和按钮', 0, 0), - (4000102, 10001, 'MERCHANT', 'MERCHANT_ORDER_MANAGER', '企业订单管理员', 'SYSTEM', 'ACTIVE', '本企业头程订单操作', 0, 0) + (4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0) ON DUPLICATE KEY UPDATE role_name = VALUES(role_name), role_type = VALUES(role_type), status = 'ACTIVE', description = VALUES(description), deleted = 0; @@ -1335,39 +1309,8 @@ SELECT 4000003, id, 0 FROM iam_permission WHERE id IN (5102019, 5102024, 5102025) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); --- Merchant: level-one menus, level-two menus and buttons. -INSERT INTO iam_permission - (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) -VALUES - (5200000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0), - (5201000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0), - (5202000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0), - (5200010, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0), - (5200020, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0), - (5200030, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0), - (5201010, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), - (5201020, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0), - (5202010, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), - (5202020, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0), - (5200011, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0), - (5200012, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0), - (5200021, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0), - (5200022, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:grant-role', '分配角色', NULL, NULL, 20, 'ACTIVE', 0), - (5200031, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), - (5200032, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), - (5201011, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0), - (5201012, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0), - (5201021, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0), - (5201022, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0), - (5201023, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0) -ON DUPLICATE KEY UPDATE - permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), - permission_name = VALUES(permission_name), route_path = VALUES(route_path), - http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; - INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES - (3000001, 4000001, 0), - (3000002, 4000101, 10001) + (3000001, 4000001, 0) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -- Full administrator roles receive all phase-one permissions in their application scope. @@ -1376,11 +1319,6 @@ SELECT 4000001, id, 0 FROM iam_permission WHERE app_code = 'PLATFORM' AND tenant_id = 0 AND permission_type IN ('MENU', 'BUTTON') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE app_code = 'MERCHANT' AND tenant_id = 10001 AND permission_type IN ('MENU', 'BUTTON') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - -- Limited platform and merchant operational roles. INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000002, id, 0 FROM iam_permission @@ -1396,17 +1334,10 @@ SELECT 4000003, id, 0 FROM iam_permission WHERE permission_code LIKE 'platform:order%' OR permission_code LIKE 'platform:firstleg%' ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000102, id, 10001 FROM iam_permission -WHERE permission_code LIKE 'merchant:order%' OR permission_code IN ('merchant:master-data', 'merchant:warehouse:list') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression) VALUES (6000001, 4000001, 0, 'TENANT', NULL), (6000002, 4000002, 0, 'TENANT', NULL), - (6000003, 4000003, 0, 'TENANT', NULL), - (6000101, 4000101, 10001, 'TENANT', NULL), - (6000102, 4000102, 10001, 'TENANT', NULL) + (6000003, 4000003, 0, 'TENANT', NULL) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id), scope_type = VALUES(scope_type), scope_expression = NULL; -- ============================================================================ @@ -1455,17 +1386,34 @@ INSERT INTO iam_role ( ) ON DUPLICATE KEY UPDATE status='ACTIVE', deleted=0; --- Clone the一期 merchant menu/button template into the global scope. +-- Global merchant permission template used by every formally onboarded customer. INSERT INTO iam_permission ( id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted ) -SELECT id + 100000, 0, app_code, permission_type, - CASE WHEN parent_id = 0 THEN 0 ELSE parent_id + 100000 END, - permission_code, permission_name, route_path, NULL, sort_no, 'ACTIVE', 0 -FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_type IN ('MENU', 'BUTTON') +VALUES + (5300000, 0, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0), + (5301000, 0, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0), + (5302000, 0, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0), + (5300010, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0), + (5300020, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0), + (5300030, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0), + (5301010, 0, 'MERCHANT', 'MENU', 5301000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), + (5301020, 0, 'MERCHANT', 'MENU', 5301000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0), + (5302010, 0, 'MERCHANT', 'MENU', 5302000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), + (5302020, 0, 'MERCHANT', 'MENU', 5302000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0), + (5300011, 0, 'MERCHANT', 'BUTTON', 5300010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0), + (5300012, 0, 'MERCHANT', 'BUTTON', 5300010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0), + (5300021, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0), + (5300022, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:grant-role', '分配成员角色', NULL, NULL, 20, 'ACTIVE', 0), + (5300031, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), + (5300032, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), + (5301011, 0, 'MERCHANT', 'BUTTON', 5301010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0), + (5301012, 0, 'MERCHANT', 'BUTTON', 5301010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0), + (5301021, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0), + (5301022, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0), + (5301023, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0), + (5301024, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), parent_id=VALUES(parent_id), sort_no=VALUES(sort_no), status='ACTIVE', deleted=0; @@ -1507,20 +1455,11 @@ DEALLOCATE PREPARE member_no_index_stmt; INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, sort_no, status, deleted) VALUES - (5200023, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), - (5200024, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), - (5200025, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0), (5300023, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), (5300024, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), (5300025, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), status = VALUES(status), deleted = 0; -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE tenant_id = 10001 AND permission_code IN - ('merchant:member:edit', 'merchant:member:status', 'merchant:member:reset-password') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000100, id, 0 FROM iam_permission WHERE tenant_id = 0 AND permission_code IN @@ -1548,9 +1487,6 @@ VALUES (5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停用户', 30, 'ACTIVE', 0), (5101014, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:reset-password', '重置密码', 40, 'ACTIVE', 0), (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 0), - (5200033, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), - (5200034, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), - (5200035, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0), (5300033, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), (5300034, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), (5300035, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0) @@ -1569,12 +1505,6 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM' 'platform:user:grant-role') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_code IN ('merchant:role:edit', 'merchant:role:status', 'merchant:role:delete') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000100, id, 0 FROM iam_permission WHERE tenant_id = 0 AND app_code = 'MERCHANT' @@ -4286,23 +4216,9 @@ SET @unified_ddl = IF( PREPARE unified_stmt FROM @unified_ddl; EXECUTE unified_stmt; DEALLOCATE PREPARE unified_stmt; -- ============================================================================ --- 重新同步一期商户管理员全局模板,包含一期后续追加的菜单和按钮 +-- 重新授权一期商户管理员全局模板,包含一期后续追加的菜单和按钮 -- ============================================================================ USE baiyue_iam; -INSERT INTO iam_permission ( - id, tenant_id, app_code, permission_type, parent_id, permission_code, - permission_name, route_path, http_method, sort_no, status, deleted -) -SELECT id + 100000, 0, app_code, permission_type, - CASE WHEN parent_id = 0 THEN 0 ELSE parent_id + 100000 END, - permission_code, permission_name, route_path, NULL, sort_no, 'ACTIVE', 0 -FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_type IN ('MENU', 'BUTTON') -ON DUPLICATE KEY UPDATE - permission_name = VALUES(permission_name), route_path = VALUES(route_path), - parent_id = VALUES(parent_id), sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT owner_role.id, permission.id, 0 FROM iam_role owner_role @@ -15350,10 +15266,9 @@ VALUES (5105019, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:cost:create', '登记成本', NULL, 'POST', 100, 'ACTIVE', 0), (5105020, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:customer-terms:view', '查看客户账期', NULL, 'GET', 110, 'ACTIVE', 0), (5105021, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:customer-terms:update', '修改客户账期', NULL, 'PUT', 120, 'ACTIVE', 0), - (5205000, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:finance:statement', '财务账单', '/finance/statements', NULL, 40, 'ACTIVE', 0), - (5205010, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:list', '查看账单', NULL, 'GET', 10, 'ACTIVE', 0), - (5205011, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:view', '查看账单详情及收款', NULL, 'GET', 20, 'ACTIVE', 0), - (5205012, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:confirm', '确认客户对账单', NULL, 'POST', 30, 'ACTIVE', 0), + (5305000, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:finance:statement', '财务账单', '/finance/statements', NULL, 40, 'ACTIVE', 0), + (5305010, 0, 'MERCHANT', 'BUTTON', 5305000, 'merchant:finance:statement:list', '查看账单', NULL, 'GET', 10, 'ACTIVE', 0), + (5305011, 0, 'MERCHANT', 'BUTTON', 5305000, 'merchant:finance:statement:view', '查看账单详情及收款', NULL, 'GET', 20, 'ACTIVE', 0), (5305012, 0, 'MERCHANT', 'BUTTON', 5305000, 'merchant:finance:statement:confirm', '确认客户对账单', NULL, 'POST', 30, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), parent_id=VALUES(parent_id), route_path=VALUES(route_path), http_method=VALUES(http_method), status='ACTIVE', deleted=0; @@ -15486,11 +15401,6 @@ WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ( 'platform:finance:settlement-ap:create', 'platform:finance:payment:create', 'platform:finance:settlement:void') ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id); -INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM baiyue_iam.iam_permission - WHERE tenant_id=10001 AND app_code='MERCHANT' AND (permission_code='merchant:finance:statement' OR permission_code LIKE 'merchant:finance:%') -ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id); - -- 开发期 list-only 收口:先移除角色关联并停用旧只读资源,再执行最终 Finance 断言。 DELETE rp FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id @@ -15956,22 +15866,18 @@ SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND p.permission_code IN ('platform:finance:customer-account:list','platform:finance:customer-arap:list','platform:finance:receivable-statistics:list','platform:finance:customer-statement:list','platform:finance:settlement-ar:list','platform:finance:settlement-ap:list','platform:finance:customer-account:export','platform:finance:customer-arap:export','platform:finance:receivable-statistics:export','platform:finance:customer-statement:export','platform:finance:settlement-ar:export','platform:finance:settlement-ap:export','platform:download:list'); UPDATE iam_permission p JOIN (SELECT 'platform:finance:fee-source:review' code,5105041 parent_id UNION ALL SELECT 'platform:finance:fee-source:edit',5105041 UNION ALL SELECT 'platform:finance:reconciliation:create',5105042 UNION ALL SELECT 'platform:finance:reconciliation:review',5105042 UNION ALL SELECT 'platform:finance:reconciliation:issue',5105042 UNION ALL SELECT 'platform:finance:reconciliation:adjust',5105042 UNION ALL SELECT 'platform:finance:reconciliation:void',5105042 UNION ALL SELECT 'platform:finance:settlement-ar:create',5105043 UNION ALL SELECT 'platform:finance:settlement-ap:create',5105044 UNION ALL SELECT 'platform:finance:payment:create',5105044) mapping ON mapping.code=p.permission_code SET p.parent_id=mapping.parent_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON'; -INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) -SELECT 10001, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=10001 AND p.app_code='MERCHANT' -WHERE r.tenant_id=10001 AND r.app_code='MERCHANT' AND r.role_code IN ('MERCHANT_OWNER','MERCHANT_ADMIN') - AND p.permission_code='merchant:finance:statement:confirm'; - INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='MERCHANT' WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 - AND p.permission_code='merchant:finance:statement:confirm' + AND p.permission_code IN ('merchant:finance:statement','merchant:finance:statement:list', + 'merchant:finance:statement:view','merchant:finance:statement:confirm') AND p.status='ACTIVE' AND p.deleted=0; INSERT INTO baiyue_iam.iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) VALUES - (5201024, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) + (5301024, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), permission_name = VALUES(permission_name), route_path = VALUES(route_path), sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; @@ -16187,11 +16093,8 @@ BEGIN IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台 Finance 权限不完整'; END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=10001 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement:list','merchant:finance:statement:view','merchant:finance:statement:confirm') AND status='ACTIVE' AND deleted=0) <> 3 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户 Finance 查询与确认权限不完整'; - END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='MERCHANT' AND permission_code='merchant:finance:statement:confirm' AND status='ACTIVE' AND deleted=0) <> 1 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局对账单确认权限不完整'; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement','merchant:finance:statement:list','merchant:finance:statement:view','merchant:finance:statement:confirm') AND status='ACTIVE' AND deleted=0) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局 Finance 权限模板不完整'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND (p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 授权不完整'; @@ -16199,11 +16102,8 @@ BEGIN IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND p.status='ACTIVE' AND p.deleted=0 AND p.permission_code IN ('platform:download:list','platform:finance:customer-account:export','platform:finance:customer-arap:export','platform:finance:receivable-statistics:export','platform:finance:customer-statement:export','platform:finance:settlement-ar:export','platform:finance:settlement-ap:export')) <> 7 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 导出和下载中心授权不完整'; END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000101 AND rp.tenant_id=10001 AND (p.permission_code='merchant:finance:statement' OR p.permission_code LIKE 'merchant:finance:%')) <> 4 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户管理员 Finance 查询与确认授权不完整'; - END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 AND p.permission_code='merchant:finance:statement:confirm' AND p.status='ACTIVE' AND p.deleted=0) <> 1 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局管理员缺少对账单确认权限'; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE r.id=4000100 AND r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 AND p.permission_code IN ('merchant:finance:statement','merchant:finance:statement:list','merchant:finance:statement:view','merchant:finance:statement:confirm') AND p.status='ACTIVE' AND p.deleted=0) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局管理员 Finance 模板授权不完整'; END IF; END$$ DELIMITER ; diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 041535f39..36b45e079 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -32,7 +32,7 @@ | PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` | | 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType=FBA|OVERSEAS`,OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | -| 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | +| 三期海运订舱、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;关联候选入口 `/api/platform/v1/ocean-bookings/link-candidates`,订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | | 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` | | 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | @@ -56,6 +56,7 @@ - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 - 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 +- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 - 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。 - 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。 - Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 diff --git a/iam/src/main/resources/db/admin-prototype-menu-permissions.sql b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql index 4508de070..097bed02a 100644 --- a/iam/src/main/resources/db/admin-prototype-menu-permissions.sql +++ b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql @@ -29,7 +29,9 @@ VALUES (5105053,0,'PLATFORM','BUTTON',5105043,'platform:finance:settlement-ar:export','导出应收结算单',NULL,'POST',90,'ACTIVE',1,0), (5105054,0,'PLATFORM','BUTTON',5105044,'platform:finance:settlement-ap:export','导出应付结算单',NULL,'POST',90,'ACTIVE',1,0), (5105055,0,'PLATFORM','BUTTON',5105045,'platform:finance:receivable-statistics:export','导出应收款统计表',NULL,'POST',90,'ACTIVE',1,0), - (5205012,10001,'MERCHANT','BUTTON',5205000,'merchant:finance:statement:confirm','确认客户对账单',NULL,'POST',30,'ACTIVE',1,0), + (5305000,0,'MERCHANT','MENU',5300000,'merchant:finance:statement','财务账单','/finance/statements',NULL,40,'ACTIVE',1,0), + (5305010,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:list','查看账单',NULL,'GET',10,'ACTIVE',1,0), + (5305011,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:view','查看账单详情及收款',NULL,'GET',20,'ACTIVE',1,0), (5305012,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:confirm','确认客户对账单',NULL,'POST',30,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), @@ -101,18 +103,12 @@ UPDATE iam_permission p SET p.parent_id=mapping.parent_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON'; -INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) -SELECT 10001, r.id, p.id, CURRENT_TIMESTAMP(3) - FROM iam_role r - JOIN iam_permission p ON p.tenant_id=10001 AND p.app_code='MERCHANT' - WHERE r.tenant_id=10001 AND r.app_code='MERCHANT' AND r.role_code IN ('MERCHANT_OWNER','MERCHANT_ADMIN') - AND p.permission_code='merchant:finance:statement:confirm'; - INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='MERCHANT' WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 - AND p.permission_code='merchant:finance:statement:confirm' + AND p.permission_code IN ('merchant:finance:statement','merchant:finance:statement:list', + 'merchant:finance:statement:view','merchant:finance:statement:confirm') AND p.status='ACTIVE' AND p.deleted=0; diff --git a/iam/src/main/resources/db/phase1-admin-rbac.sql b/iam/src/main/resources/db/phase1-admin-rbac.sql index fcc5d4bde..0f00deb81 100644 --- a/iam/src/main/resources/db/phase1-admin-rbac.sql +++ b/iam/src/main/resources/db/phase1-admin-rbac.sql @@ -26,10 +26,7 @@ INSERT INTO iam_tenant ( approved_at, approved_by, created_by, deleted, version ) VALUES (0, 'PLATFORM', '虫虫出海平台', 'PLATFORM', 'ACTIVE', '虫虫出海平台', - NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0), - (10001, 'IT_TEST', 'IT 测试客户', 'MERCHANT', 'ACTIVE', 'IT 测试客户', - '91310000ITTEST0001', 'IT 测试客户管理员', '13800000000', 'it-test@example.com', - NOW(3), 1000001, 1000001, 0, 0) + NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0) ON DUPLICATE KEY UPDATE tenant_code = VALUES(tenant_code), tenant_name = VALUES(tenant_name), tenant_type = VALUES(tenant_type), status = 'ACTIVE', legal_name = VALUES(legal_name), @@ -38,28 +35,10 @@ ON DUPLICATE KEY UPDATE contact_email = VALUES(contact_email), approved_at = COALESCE(approved_at, NOW(3)), approved_by = VALUES(approved_by), deleted = 0; --- The built-in IT customer must be immediately usable for order acceptance tests. --- These approved metadata rows intentionally have no document URL: initialization must not --- point at qualification files that do not exist in File service storage. -INSERT INTO iam_tenant_qualification ( - id, tenant_id, qualification_type, certificate_no, document_url, - valid_from, valid_to, status, review_comment, deleted -) VALUES - (2100001, 10001, 'BUSINESS_LICENSE', '91310000ITTEST0001', NULL, - NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0), - (2100002, 10001, 'LEGAL_PERSON_ID', NULL, NULL, - NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0) -ON DUPLICATE KEY UPDATE - tenant_id = VALUES(tenant_id), qualification_type = VALUES(qualification_type), - certificate_no = VALUES(certificate_no), status = 'APPROVED', - review_comment = VALUES(review_comment), deleted = 0; - INSERT INTO iam_user ( id, auth_user_id, user_type, display_name, phone, email, status, deleted, version ) VALUES - (2000001, 1000001, 'INTERNAL', '平台系统管理员', NULL, NULL, 'ACTIVE', 0, 0), - (2000002, 1000002, 'ENTERPRISE', 'IT 测试客户管理员', '13800000000', - 'it-test@example.com', 'ACTIVE', 0, 0) + (2000001, 1000001, 'INTERNAL', '平台系统管理员', NULL, NULL, 'ACTIVE', 0, 0) ON DUPLICATE KEY UPDATE display_name = VALUES(display_name), phone = VALUES(phone), email = VALUES(email), status = 'ACTIVE', deleted = 0; @@ -67,8 +46,7 @@ ON DUPLICATE KEY UPDATE INSERT INTO iam_membership ( id, tenant_id, user_id, member_type, member_no, status, joined_at, deleted, version ) VALUES - (3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0), - (3000002, 10001, 2000002, 'OWNER', 'MERCHANT-OWNER-001', 'ACTIVE', NOW(3), 0, 0) + (3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0) ON DUPLICATE KEY UPDATE member_type = VALUES(member_type), member_no = VALUES(member_no), status = 'ACTIVE', joined_at = COALESCE(joined_at, NOW(3)), left_at = NULL, deleted = 0; @@ -78,9 +56,7 @@ INSERT INTO iam_role ( ) VALUES (4000001, 0, 'PLATFORM', 'PLATFORM_ADMIN', '平台超级管理员', 'SYSTEM', 'ACTIVE', '一期平台全部菜单和按钮', 0, 0), (4000002, 0, 'PLATFORM', 'CUSTOMER_REVIEWER', '企业审核员', 'SYSTEM', 'ACTIVE', '企业资料与入驻审核', 0, 0), - (4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0), - (4000101, 10001, 'MERCHANT', 'MERCHANT_OWNER', '企业管理员', 'SYSTEM', 'ACTIVE', '本企业一期全部菜单和按钮', 0, 0), - (4000102, 10001, 'MERCHANT', 'MERCHANT_ORDER_MANAGER', '企业订单管理员', 'SYSTEM', 'ACTIVE', '本企业头程订单操作', 0, 0) + (4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0) ON DUPLICATE KEY UPDATE role_name = VALUES(role_name), role_type = VALUES(role_type), status = 'ACTIVE', description = VALUES(description), deleted = 0; @@ -167,40 +143,8 @@ SELECT 4000003, id, 0 FROM iam_permission WHERE id IN (5102019, 5102024, 5102025) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); --- Merchant: level-one menus, level-two menus and buttons. -INSERT INTO iam_permission - (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) -VALUES - (5200000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0), - (5201000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0), - (5202000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0), - (5200010, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0), - (5200020, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0), - (5200030, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0), - (5201010, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), - (5201020, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0), - (5202010, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), - (5202020, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0), - (5200011, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0), - (5200012, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0), - (5200021, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0), - (5200022, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:grant-role', '分配角色', NULL, NULL, 20, 'ACTIVE', 0), - (5200031, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), - (5200032, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), - (5201011, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0), - (5201012, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0), - (5201021, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0), - (5201022, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0), - (5201023, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0), - (5201024, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) -ON DUPLICATE KEY UPDATE - permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), - permission_name = VALUES(permission_name), route_path = VALUES(route_path), - http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; - INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES - (3000001, 4000001, 0), - (3000002, 4000101, 10001) + (3000001, 4000001, 0) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -- Full administrator roles receive all phase-one permissions in their application scope. @@ -209,11 +153,6 @@ SELECT 4000001, id, 0 FROM iam_permission WHERE app_code = 'PLATFORM' AND tenant_id = 0 AND permission_type IN ('MENU', 'BUTTON') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE app_code = 'MERCHANT' AND tenant_id = 10001 AND permission_type IN ('MENU', 'BUTTON') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - -- Limited platform and merchant operational roles. INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000002, id, 0 FROM iam_permission @@ -229,15 +168,8 @@ SELECT 4000003, id, 0 FROM iam_permission WHERE permission_code LIKE 'platform:order%' OR permission_code LIKE 'platform:firstleg%' ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000102, id, 10001 FROM iam_permission -WHERE permission_code LIKE 'merchant:order%' OR permission_code IN ('merchant:master-data', 'merchant:warehouse:list') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression) VALUES (6000001, 4000001, 0, 'TENANT', NULL), (6000002, 4000002, 0, 'TENANT', NULL), - (6000003, 4000003, 0, 'TENANT', NULL), - (6000101, 4000101, 10001, 'TENANT', NULL), - (6000102, 4000102, 10001, 'TENANT', NULL) + (6000003, 4000003, 0, 'TENANT', NULL) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id), scope_type = VALUES(scope_type), scope_expression = NULL; diff --git a/iam/src/main/resources/db/phase1-member-permissions.sql b/iam/src/main/resources/db/phase1-member-permissions.sql index 8a88a7896..bddb3b44d 100644 --- a/iam/src/main/resources/db/phase1-member-permissions.sql +++ b/iam/src/main/resources/db/phase1-member-permissions.sql @@ -17,20 +17,11 @@ DEALLOCATE PREPARE member_no_index_stmt; INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, sort_no, status, deleted) VALUES - (5200023, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), - (5200024, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), - (5200025, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0), (5300023, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), (5300024, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), (5300025, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), status = VALUES(status), deleted = 0; -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE tenant_id = 10001 AND permission_code IN - ('merchant:member:edit', 'merchant:member:status', 'merchant:member:reset-password') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000100, id, 0 FROM iam_permission WHERE tenant_id = 0 AND permission_code IN diff --git a/iam/src/main/resources/db/phase1-onboarding.sql b/iam/src/main/resources/db/phase1-onboarding.sql index a31f31127..27a7da36e 100644 --- a/iam/src/main/resources/db/phase1-onboarding.sql +++ b/iam/src/main/resources/db/phase1-onboarding.sql @@ -41,17 +41,34 @@ INSERT INTO iam_role ( ) ON DUPLICATE KEY UPDATE status='ACTIVE', deleted=0; --- Clone the一期 merchant menu/button template into the global scope. +-- Global merchant permission template used by every formally onboarded customer. INSERT INTO iam_permission ( id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted ) -SELECT id + 100000, 0, app_code, permission_type, - CASE WHEN parent_id = 0 THEN 0 ELSE parent_id + 100000 END, - permission_code, permission_name, route_path, NULL, sort_no, 'ACTIVE', 0 -FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_type IN ('MENU', 'BUTTON') +VALUES + (5300000, 0, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0), + (5301000, 0, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0), + (5302000, 0, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0), + (5300010, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0), + (5300020, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0), + (5300030, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0), + (5301010, 0, 'MERCHANT', 'MENU', 5301000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), + (5301020, 0, 'MERCHANT', 'MENU', 5301000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0), + (5302010, 0, 'MERCHANT', 'MENU', 5302000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), + (5302020, 0, 'MERCHANT', 'MENU', 5302000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0), + (5300011, 0, 'MERCHANT', 'BUTTON', 5300010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0), + (5300012, 0, 'MERCHANT', 'BUTTON', 5300010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0), + (5300021, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0), + (5300022, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:grant-role', '分配成员角色', NULL, NULL, 20, 'ACTIVE', 0), + (5300031, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), + (5300032, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), + (5301011, 0, 'MERCHANT', 'BUTTON', 5301010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0), + (5301012, 0, 'MERCHANT', 'BUTTON', 5301010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0), + (5301021, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0), + (5301022, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0), + (5301023, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0), + (5301024, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), parent_id=VALUES(parent_id), sort_no=VALUES(sort_no), status='ACTIVE', deleted=0; diff --git a/iam/src/main/resources/db/rbac-role-management-v2.sql b/iam/src/main/resources/db/rbac-role-management-v2.sql index 5228b9b71..87f2ea5de 100644 --- a/iam/src/main/resources/db/rbac-role-management-v2.sql +++ b/iam/src/main/resources/db/rbac-role-management-v2.sql @@ -19,9 +19,6 @@ VALUES (5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停用户', 30, 'ACTIVE', 0), (5101014, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:reset-password', '重置密码', 40, 'ACTIVE', 0), (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 0), - (5200033, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), - (5200034, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), - (5200035, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0), (5300033, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), (5300034, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), (5300035, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0) @@ -40,12 +37,6 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM' 'platform:user:grant-role') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_code IN ('merchant:role:edit', 'merchant:role:status', 'merchant:role:delete') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000100, id, 0 FROM iam_permission WHERE tenant_id = 0 AND app_code = 'MERCHANT' diff --git a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java index 2190fbe70..f8cd21e76 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java @@ -31,6 +31,9 @@ class AdminPrototypeMenuPermissionSqlContractTest { assertThat(sql).contains("(" + (5105050 + index) + ",0,'PLATFORM','BUTTON'," + (5105040 + index) + ",'" + FINANCE_EXPORTS[index] + "'"); } assertThat(sql) + .contains("(5305000,0,'MERCHANT','MENU',5300000,'merchant:finance:statement'") + .contains("(5305010,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:list'") + .contains("(5305011,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:view'") .contains("(5305012,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:confirm'") .contains("r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER'") .contains("'platform:finance:fee-source:review' code, 5105041 parent_id") @@ -60,8 +63,8 @@ class AdminPrototypeMenuPermissionSqlContractTest { .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32") .contains("(p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33") .contains("Finance 导出和下载中心授权不完整") - .contains("merchant:finance:statement:confirm") - .contains("客户全局管理员缺少对账单确认权限") + .contains("客户全局 Finance 权限模板不完整") + .contains("客户全局管理员 Finance 模板授权不完整") .satisfies(content -> assertThat(content.indexOf("(5105050,0,'PLATFORM','BUTTON'")) .isLessThan(content.indexOf("CREATE PROCEDURE assert_finance_permissions()"))); } diff --git a/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java index 86f26ec04..64008a223 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java @@ -11,25 +11,42 @@ import static org.assertj.core.api.Assertions.assertThat; class InitialIdentitySeedSqlContractTest { @Test - void initializesPlatformAndItTestCustomerIdentitiesWithStableIds() throws Exception { - assertIdentitySeed(new ClassPathResource("db/phase1-admin-rbac.sql") - .getContentAsString(StandardCharsets.UTF_8)); - assertIdentitySeed(Files.readString(unifiedSql(), StandardCharsets.UTF_8)); + void initializesOnlyPlatformIdentityAndGlobalMerchantTemplates() throws Exception { + String identitySeed = new ClassPathResource("db/phase1-admin-rbac.sql") + .getContentAsString(StandardCharsets.UTF_8); + String templateSeed = new ClassPathResource("db/phase1-onboarding.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unified = Files.readString(unifiedSql(), StandardCharsets.UTF_8); + assertPlatformIdentitySeed(identitySeed); + assertPlatformIdentitySeed(unified); + assertGlobalMerchantTemplate(templateSeed); + assertGlobalMerchantTemplate(unified); } - private static void assertIdentitySeed(String sql) { + private static void assertPlatformIdentitySeed(String sql) { assertThat(sql) .contains("(0, 'PLATFORM', '虫虫出海平台'") - .contains("(10001, 'IT_TEST', 'IT 测试客户'") .contains("(2000001, 1000001, 'INTERNAL', '平台系统管理员'") - .contains("(2000002, 1000002, 'ENTERPRISE', 'IT 测试客户管理员'") .contains("(3000001, 0, 2000001, 'EMPLOYEE'") - .contains("(3000002, 10001, 2000002, 'OWNER'") .contains("tenant_code = VALUES(tenant_code)") + .doesNotContain("merchant_admin") + .doesNotContain("IT_TEST") + .doesNotContain("(10001,") + .doesNotContain("(2000002,") + .doesNotContain("(3000002,") + .doesNotContain("4000101, 10001") .doesNotContain("PHASE1_DEMO") .doesNotContain("一期演示企业"); } + private static void assertGlobalMerchantTemplate(String sql) { + assertThat(sql) + .contains("4000100, 0, 'MERCHANT', 'MERCHANT_OWNER'") + .contains("5300000, 0, 'MERCHANT', 'MENU'") + .contains("5301024, 0, 'MERCHANT', 'BUTTON'") + .doesNotContain("WHERE tenant_id = 10001 AND app_code = 'MERCHANT'"); + } + private static Path unifiedSql() { Path path = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); return Files.exists(path) ? path : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); diff --git a/iam/src/test/resources/db/verify-phase1-admin-rbac.sql b/iam/src/test/resources/db/verify-phase1-admin-rbac.sql index e2942472f..24dce2749 100644 --- a/iam/src/test/resources/db/verify-phase1-admin-rbac.sql +++ b/iam/src/test/resources/db/verify-phase1-admin-rbac.sql @@ -1,13 +1,17 @@ -- Any returned row represents a failed phase-one seed assertion. SELECT 'missing_auth_accounts' AS failure WHERE (SELECT COUNT(*) FROM baiyue_auth.auth_account - WHERE username IN ('platform_admin', 'merchant_admin') AND account_status = 'ACTIVE') <> 2 + WHERE username = 'platform_admin' AND account_status = 'ACTIVE') <> 1 UNION ALL SELECT 'missing_active_identities' WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_user u JOIN baiyue_iam.iam_membership m ON m.user_id = u.id - WHERE u.auth_user_id IN (1000001, 1000002) - AND u.status = 'ACTIVE' AND m.status = 'ACTIVE') <> 2 + WHERE u.auth_user_id = 1000001 + AND u.status = 'ACTIVE' AND m.status = 'ACTIVE') <> 1 +UNION ALL +SELECT 'unexpected_fixed_customer_seed' +WHERE EXISTS (SELECT 1 FROM baiyue_iam.iam_tenant WHERE tenant_code = 'IT_TEST') + OR EXISTS (SELECT 1 FROM baiyue_auth.auth_account WHERE username = 'merchant_admin') UNION ALL SELECT 'unexpected_permission_type' WHERE EXISTS (SELECT 1 FROM baiyue_iam.iam_permission @@ -34,10 +38,6 @@ SELECT 'platform_admin_not_fully_granted' WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission WHERE role_id = 4000001) <> (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND permission_type IN ('MENU', 'BUTTON')) UNION ALL -SELECT 'merchant_owner_not_fully_granted' -WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission WHERE role_id = 4000101) - <> (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 10001 AND app_code = 'MERCHANT' AND permission_type IN ('MENU', 'BUTTON')) -UNION ALL SELECT 'missing_global_merchant_owner_template' WHERE NOT EXISTS ( SELECT 1 FROM baiyue_iam.iam_role diff --git a/scripts/README.md b/scripts/README.md index fac0cd8b6..360871a62 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -31,7 +31,7 @@ node scripts/create-pending-review-orders.mjs | Admin/Client 本地测试与类型检查 | `scripts/frontend-full-regression.ps1` | | PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` | | 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` | -| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、海外 Gateway 覆盖、PDA 注册页面、Maven 模块、Lastleg 海外安全投影映射、关键入口与排除目录 | +| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、四期范围漂移、订舱候选与柜量语义、海外 Gateway 覆盖、PDA 页面/运行配置/能力开关、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口与排除目录 | | Java 质量聚合摘要 | `node --test scripts/java-quality-summary.test.mjs`、`node scripts/java-quality-summary.mjs --output target/java-quality-summary.json`;聚合 Maven/服务拓扑、OpenAPI 覆盖、JaCoCo 和 SpotBugs 报告,默认只报告存量缺口,CI 用显式 `--require-jacoco --require-spotbugs` 检查报告完整性且当前允许失败 | | 单服务部署失败回滚合同 | `scripts/deploy-built-service-rollback.test.mjs`;模拟成功、健康失败和启动失败,确认仅恢复同服务先前健康镜像且部署任务仍失败 | diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 1b6f37546..89bfa0a4c 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -8,6 +8,8 @@ import process from 'node:process' const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] const MODULE_DEPENDENCIES = new Map([ + ['suppliers', ['merchant-qualification-fixture']], + ['supplemental', ['merchant-qualification-fixture']], ['orders', ['merchant-qualification-fixture']], ['inbound-measurement', ['orders']], ['phase3-container-loading', ['inbound-measurement']], @@ -232,11 +234,20 @@ function usage() { --username 必填,管理员账号 --password 必填,管理员密码 --modules 逗号分隔,默认 ${DEFAULT_MODULES.join(',')} + --customer-name 指定本轮隔离客户名称,默认自动生成 + --merchant-username 复用既有已审核客户账号,不再创建客户 + --merchant-password 复用客户账号密码,不写入报告 + --origin-warehouse-name 固定国内起运仓名称 + --channel-name 固定物流渠道名称 + --channel-code 固定物流渠道编码 + --order-profile-mode 订单档案模式:fixed 或 randomized,默认fixed + --wait-split-count 同一客户创建的 WAIT_SPLIT 订单数,1-100,默认1 --output JSON 报告路径,默认 reports/ --help 显示帮助 也可使用环境变量: ADMIN_TEST_GATEWAY, ADMIN_OVERSEAS_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD, -ADMIN_TEST_WEIGHT_IMAGE_PATH`) +ADMIN_TEST_WEIGHT_IMAGE_PATH, ADMIN_TEST_CUSTOMER_NAME, MERCHANT_TEST_USERNAME, +MERCHANT_TEST_PASSWORD, ADMIN_WAIT_SPLIT_COUNT`) } export function parseArguments(argv) { @@ -255,12 +266,27 @@ export function parseArguments(argv) { values.set(name, next); index += 1 } } + const merchantUsername = values.get('merchant-username') || process.env.MERCHANT_TEST_USERNAME || '' + const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || '' + const modules = normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean)) + if (merchantUsername && merchantPassword) { + const fixtureIndex = modules.indexOf('merchant-qualification-fixture') + if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1) + } return { gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, ''), overseasGateway: (values.get('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY || '').replace(/\/$/, ''), username: values.get('username') || process.env.ADMIN_TEST_USERNAME || '', password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || '', - modules: normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean)), + modules, + customerName: values.get('customer-name') || process.env.ADMIN_TEST_CUSTOMER_NAME || '', + merchantUsername, + merchantPassword, + originWarehouseName: values.get('origin-warehouse-name') || '', + channelName: values.get('channel-name') || '', + channelCode: values.get('channel-code') || '', + orderProfileMode: values.get('order-profile-mode') || 'fixed', + waitSplitCount: Number(values.get('wait-split-count') || process.env.ADMIN_WAIT_SPLIT_COUNT || 1), output: values.get('output') || '', } } @@ -273,6 +299,32 @@ export function validateExecutionOptions(options) { && (!options.overseasGateway || options.overseasGateway.trim() === '')) { throw new Error('四期海外模块缺少必填执行参数: overseas-gateway') } + if (!Number.isInteger(options.waitSplitCount) || options.waitSplitCount < 1 || options.waitSplitCount > 100) { + throw new Error('wait-split-count必须是1-100的整数') + } + if (options.customerName && options.customerName.trim().length > 128) { + throw new Error('customer-name不能超过128个字符') + } + if (Boolean(options.merchantUsername) !== Boolean(options.merchantPassword)) { + throw new Error('merchant-username和merchant-password必须同时提供') + } + for (const [name, value] of [['origin-warehouse-name', options.originWarehouseName], + ['channel-name', options.channelName], ['channel-code', options.channelCode]]) { + if (value && value.trim().length > 128) throw new Error(`${name}不能超过128个字符`) + } + if (!['fixed', 'randomized'].includes(options.orderProfileMode)) { + throw new Error('order-profile-mode必须为fixed或randomized') + } + if (options.orderProfileMode === 'randomized' + && (!options.originWarehouseName || !options.channelName || !options.channelCode)) { + throw new Error('randomized订单档案必须同时固定起运仓、渠道名称和渠道编码') + } + if (options.waitSplitCount > 1) { + const fixtureModules = new Set(['merchant-qualification-fixture', 'orders', 'inbound-measurement']) + if (options.modules.some((module) => !fixtureModules.has(module))) { + throw new Error('批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement') + } + } return options } @@ -555,6 +607,124 @@ export class AcceptanceRun { this.modules.push({ name, startedAt, finishedAt: new Date().toISOString(), success: fatal === null && steps.every((s) => s.passed), passed: steps.filter((s) => s.passed).length, failed: steps.filter((s) => !s.passed).length, fatal }) } + captureWaitSplitFixture(sequence) { + const order = this.createdResources.orders || {} + const measurement = this.createdResources.inboundMeasurement || {} + const ready = Boolean(order.orderId && measurement.inboundId + && String(order.tenantId) === String(measurement.tenantId) + && measurement.processStatus === 'WAIT_SPLIT' && measurement.finalStatus === 'APPROVED') + this.assert('wait-split-fixtures', `固定客户第 ${sequence} 单停在待分柜`, null, ready, + `tenantId=${measurement.tenantId || 'missing'}, orderId=${order.orderId || 'missing'}, inboundId=${measurement.inboundId || 'missing'}, processStatus=${measurement.processStatus || 'missing'}, reviewStatus=${measurement.finalStatus || 'missing'}`, + { scenario: 'fixed-customer-wait-split', deploymentBlocker: true }) + if (!ready) return false + this.createdResources.waitSplitFixtures ??= [] + this.createdResources.waitSplitFixtures.push({ + sequence, tenantId: String(measurement.tenantId), orderId: String(order.orderId), + orderNo: order.orderNo || null, inboundId: String(measurement.inboundId), + inboundCode: order.inboundCode || null, processStatus: measurement.processStatus, + reviewStatus: measurement.finalStatus, skuCount: order.skuLines?.length || null, + totalPackageCount: (order.skuLines || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0), + palletCount: measurement.palletCount || null, + actualGrossWeightKg: measurement.actualGrossWeightKg || null, + actualNetWeightKg: measurement.actualNetWeightKg || null, + measurementProfile: this.context.waitSplitVariant?.measurementProfile || null, + receivingWarehouseId: order.receivingWarehouseId || null, + receivingWarehouseName: order.receivingWarehouseName || null, + destinationWarehouseId: order.destinationWarehouseId || null, + destinationWarehouseCode: order.destinationWarehouseCode || null, + destinationWarehouseName: order.destinationWarehouseName || null, + channelCode: order.channelCode || null, + channelName: order.channelName || null, + pricingTaxMode: order.pricingTaxMode || null, + destinationType: order.destinationType || null, + fulfillmentType: order.fulfillmentType || null, + pickupMethod: order.pickupMethod || null, + taxMode: order.taxMode || null, + containerMode: order.containerMode || null, + customsInspection: order.customsInspection ?? null, + }) + return true + } + + waitSplitVariant(sequence) { + const dimensionProfiles = [ + { code: 'SMALL', first: { length: 29.1, width: 19.1, height: 18.1 }, replacement: { length: 29.5, width: 19.5, height: 18.5 }, outer: { length: 34, width: 24, height: 24 } }, + { code: 'MEDIUM', first: { length: 39.1, width: 29.1, height: 28.1 }, replacement: { length: 39.5, width: 29.5, height: 28.5 }, outer: { length: 44, width: 34, height: 34 } }, + { code: 'LARGE', first: { length: 49.1, width: 39.1, height: 38.1 }, replacement: { length: 49.5, width: 39.5, height: 38.5 }, outer: { length: 54, width: 44, height: 44 } }, + { code: 'EXTRA_LARGE', first: { length: 59.1, width: 49.1, height: 48.1 }, replacement: { length: 59.5, width: 49.5, height: 48.5 }, outer: { length: 64, width: 54, height: 54 } }, + ] + const skuCount = 1 + ((sequence - 1) % 4) + const packageCountPerSku = 1 + (Math.floor((sequence - 1) / 4) % 5) + const palletCount = 1 + ((sequence - 1) % 3) + const grossWeightPerPalletKg = 45 + ((sequence - 1) % 6) * 15 + return { + sequence, skuCount, packageCountPerSku, palletCount, grossWeightPerPalletKg, + measurementProfile: dimensionProfiles[(sequence - 1) % dimensionProfiles.length], + } + } + + randomizedOrderProfile(sequence) { + const combinations = [ + ['FBA', 'STANDARD'], + ['OVERSEAS', 'STANDARD'], + ['PRIVATE', 'STANDARD'], + ['FBA', 'ONE_CLICK_DROPSHIP'], + ['OVERSEAS', 'ONE_CLICK_DROPSHIP'], + ['PRIVATE', 'ONE_CLICK_DROPSHIP'], + ] + const [destinationType, fulfillmentType] = combinations[(sequence * 5 + 1) % combinations.length] + const pickupMethods = ['PICKUP', 'SELF_EXPRESS', 'SELF_TRUCK'] + return { + destinationType, + fulfillmentType, + pickupMethod: pickupMethods[(sequence * 7 + 2) % pickupMethods.length], + taxMode: sequence % 2 === 0 ? 'TAX_REFUND' : 'BUY_DECLARATION', + containerMode: sequence % 2 === 0 ? 'FULL_CONTAINER' : 'LCL', + customsInspection: sequence % 4 === 0, + } + } + + applyWaitSplitVariant(sequence) { + const variant = this.waitSplitVariant(sequence) + this.context.waitSplitVariant = variant + this.context.phase3LoadingIncrementalDetailCount = variant.skuCount + this.context.phase3LoadingIncrementalPackageCount = variant.packageCountPerSku + const profile = this.options.orderProfileMode === 'randomized' + ? this.randomizedOrderProfile(sequence) + : { destinationType: 'FBA', fulfillmentType: 'STANDARD', pickupMethod: 'PICKUP', + taxMode: 'BUY_DECLARATION', containerMode: 'LCL', customsInspection: false } + this.context.orderProfile = profile + this.context.orderDestinationType = profile.destinationType + this.context.orderFulfillmentType = profile.fulfillmentType + this.context.waitSplitRoute = { + originWarehouseName: this.options.originWarehouseName || '', + channelName: this.options.channelName || '', + channelCode: this.options.channelCode || '', + destinationIndex: (sequence * 17 + 11), + taxModeIndex: sequence - 1, + } + } + + async createAdditionalWaitSplitFixtures() { + const total = this.options.waitSplitCount + if (total <= 1) return + const baseMarker = this.marker + const orders = await import(new URL('./admin-test-env-modules/orders.mjs', import.meta.url)) + const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url)) + try { + for (let sequence = 2; sequence <= total; sequence += 1) { + this.marker = `${baseMarker}-WS${String(sequence).padStart(2, '0')}` + this.applyWaitSplitVariant(sequence) + await this.executeModule('orders', () => orders.run(this)) + if (this.modules.at(-1)?.success !== true) break + await this.executeModule('inbound-measurement', () => measurement.run(this)) + if (this.modules.at(-1)?.success !== true || !this.captureWaitSplitFixture(sequence)) break + } + } finally { + this.marker = baseMarker + } + } + async refreshSession(reason) { const response = await this.request('POST', '/api/auth/refresh', { refreshToken: this.refreshToken }, '') @@ -1184,6 +1354,11 @@ export class AcceptanceRun { } async run() { + if (this.options.merchantUsername && this.options.merchantPassword) { + process.env.MERCHANT_TEST_USERNAME = this.options.merchantUsername + process.env.MERCHANT_TEST_PASSWORD = this.options.merchantPassword + } + if (this.options.waitSplitCount > 1) this.applyWaitSplitVariant(1) const loginResponse = await this.request('POST', '/api/auth/login', { username: this.options.username, password: this.options.password }, '') const login = this.apiSuccess('authentication', '平台管理员登录', loginResponse) if (!login?.accessToken) this.fatal = '平台管理员登录失败,未执行业务模块' @@ -1270,6 +1445,9 @@ export class AcceptanceRun { } finally { if (restoreFixture) restoreFixture() } + if (module === 'inbound-measurement' && this.captureWaitSplitFixture(1)) { + await this.createAdditionalWaitSplitFixtures() + } const laterOverseasModule = this.options.modules.slice(moduleIndex + 1) .some((name) => name.startsWith('phase4-overseas-')) if (module === 'system' && laterOverseasModule) { @@ -1290,7 +1468,8 @@ export class AcceptanceRun { deploymentBlockers: this.deploymentBlockers, coverageGaps: this.coverageGaps, createdResources: this.createdResources, }) - const forbiddenValues = [this.options.password, this.token, this.refreshToken, this.overseasToken] + const forbiddenValues = [this.options.password, this.options.merchantPassword, + this.token, this.refreshToken, this.overseasToken] .filter((value) => typeof value === 'string' && value.length >= 8) const sensitivePatterns = [ /"(?:password|newPassword|temporaryPassword|accessToken|refreshToken|authorization)"\s*:/i, diff --git a/scripts/admin-test-env-modules/fee-items.mjs b/scripts/admin-test-env-modules/fee-items.mjs index dd3472bdd..f943c9e96 100644 --- a/scripts/admin-test-env-modules/fee-items.mjs +++ b/scripts/admin-test-env-modules/fee-items.mjs @@ -5,8 +5,8 @@ const BASE = '/api/platform/v1/fee-items' const BASELINE_COUNT = 111 const DOMESTIC_COUNT = 84 const INTERNATIONAL_COUNT = 27 -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' function id(value) { return value == null ? '' : String(value) } function records(value) { return Array.isArray(value?.records) ? value.records : [] } diff --git a/scripts/admin-test-env-modules/finance-demo-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/finance-demo-fixtures-contract.test.mjs index 6482476d2..a67d138ee 100644 --- a/scripts/admin-test-env-modules/finance-demo-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-demo-fixtures-contract.test.mjs @@ -7,10 +7,12 @@ import { fileURLToPath } from 'node:url' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'finance-demo-fixtures.mjs'), 'utf8') -test('演示数据只消费本轮固定 marker 和客户 10001', () => { +test('演示数据只消费本轮固定 marker 和动态正式客户 fixture', () => { assert.match(source, /const MARKER = 'FIN-DEMO-20260812'/) assert.match(source, /fixture\.marker !== runMarker/) - assert.match(source, /fixture\.customerId !== '10001'/) + assert.match(source, /customerId !== id\(qualificationFixture\?\.tenantId\)/) + assert.match(source, /context\.context\.merchantQualificationFixture/) + assert.doesNotMatch(source, /customerId:\s*['"]?10001|accountId=10001/) assert.match(source, /const runMarker = `\$\{MARKER\}-/) assert.match(source, /禁止借历史来源/) assert.doesNotMatch(source, /DELETE|cleanup|remove/i) @@ -41,8 +43,8 @@ test('所有财务写操作使用 marker 幂等键并上传真实凭证', () => }) test('额外应付审核路径先追加 feeId 再追加 accountId 查询参数', () => { - assert.match(source, /`\$\{basePath\}\/\$\{created\.id\}\/approve\?accountId=10001`/) - assert.doesNotMatch(source, /fee-details\?accountId=10001\/\$\{created\.id\}/) + assert.match(source, /`\$\{basePath\}\/\$\{created\.id\}\/approve\?accountId=\$\{encodeURIComponent\(customerId\)\}`/) + assert.doesNotMatch(source, /fee-details\?accountId=.*\/\$\{created\.id\}/) }) test('费用来源使用 operationVersion,应付供应商长整型保持字符串精度', () => { diff --git a/scripts/admin-test-env-modules/finance-demo-fixtures.mjs b/scripts/admin-test-env-modules/finance-demo-fixtures.mjs index 4ce0c5dc7..255375182 100644 --- a/scripts/admin-test-env-modules/finance-demo-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-demo-fixtures.mjs @@ -30,10 +30,10 @@ function blocker(context, name, response, evidence, category) { }) } -async function waitForSources(context, sourceFeeIds) { +async function waitForSources(context, customerId, sourceFeeIds) { let pool = [] for (let attempt = 0; attempt < 30; attempt += 1) { - const response = await context.request('GET', `${BASE}/fee-pool?customerId=10001&direction=ALL`) + const response = await context.request('GET', `${BASE}/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=ALL`) pool = rows(data(response)).concat(rows(data(response)?.items)) const visible = new Set(pool.map((item) => id(item.sourceId))) if (sourceFeeIds.every((sourceId) => visible.has(sourceId))) return pool @@ -80,10 +80,11 @@ async function createExtraPayable(context, fixture, index) { unitPrice: 90 + index * 10, amount: 90 + index * 10, currency: 'CNY', supplierId, remark: `${MARKER}-AP-${index}` } const basePath = `/api/platform/v1/inbounds/${inboundId}/fee-details` - const createdResponse = await write(context, 'POST', `${basePath}?accountId=10001`, body, `extra-payable-${index}`) + const customerId = id(fixture.customerId) + const createdResponse = await write(context, 'POST', `${basePath}?accountId=${encodeURIComponent(customerId)}`, body, `extra-payable-${index}`) const created = data(createdResponse) if (!ok(createdResponse) || !created?.id) return null - const approveResponse = await write(context, 'POST', `${basePath}/${created.id}/approve?accountId=10001`, undefined, `extra-payable-${index}-approve`) + const approveResponse = await write(context, 'POST', `${basePath}/${created.id}/approve?accountId=${encodeURIComponent(customerId)}`, undefined, `extra-payable-${index}-approve`) record(context, `创建第 ${index} 条额外应付来源`, approveResponse, ok(approveResponse), `sourceFeeId=${created.id}, inboundId=${inboundId}, supplierId=${supplierId}`) return { sourceFeeId: id(created.id), direction: 'PAYABLE', businessDate: body.businessDate, @@ -91,7 +92,7 @@ async function createExtraPayable(context, fixture, index) { } async function createStatement(context, source, role, targetStatus) { - const body = { customerId: '10001', currency: 'CNY', generationMode: 'SELECTED', + const body = { customerId: id(source.customerId), currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: [source.feeSourceId], periodStart: source.businessDate, periodEnd: source.businessDate, remark: `${MARKER}-${role}` } const response = await write(context, 'POST', `${BASE}/statements`, body, `statement-${role}-create`) @@ -120,7 +121,7 @@ async function createAr(context, statement, role) { async function receive(context, fixture, settlement, amount, role) { const response = await write(context, 'POST', `${BASE}/receipts`, { - customerId: 10001, receivedDate: new Date().toISOString().slice(0, 10), amount, currency: 'CNY', + customerId: id(fixture.customerId), receivedDate: new Date().toISOString().slice(0, 10), amount, currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: [fixture.voucherFileId], remark: `${MARKER}-${role}`, allocations: [{ billId: Number(settlement.reconciliationStatementId), amount }], }, `receipt-${role}`) @@ -130,7 +131,7 @@ async function receive(context, fixture, settlement, amount, role) { async function createAp(context, fixture, source, role) { const response = await write(context, 'POST', `${BASE}/payable-settlements`, { - customerId: 10001, supplierId: id(source.supplierId), feeSourceIds: [Number(source.feeSourceId)], + customerId: id(fixture.customerId), supplierId: id(source.supplierId), feeSourceIds: [Number(source.feeSourceId)], dueDate: new Date().toISOString().slice(0, 10), currency: 'CNY', }, `ap-${role}-${source.feeSourceId}-${source.supplierId}-create`) const value = data(response) @@ -165,8 +166,9 @@ async function recoverExistingDemo(context, reportPath) { const report = JSON.parse(await readFile(reportPath, 'utf8')) const fixture = report?.createdResources?.financeFullFixtures const matrix = structuredClone(report?.createdResources?.financeDemo ?? {}) - if (fixture?.marker !== matrix.runMarker || matrix.marker !== MARKER || matrix.customerId !== '10001') { - throw new Error('恢复报告不是 FIN-DEMO-20260812 / 客户 10001 的同一轮 fixture') + if (fixture?.marker !== matrix.runMarker || matrix.marker !== MARKER + || !id(matrix.customerId) || id(matrix.customerId) !== id(fixture.customerId)) { + throw new Error('恢复报告不是 FIN-DEMO-20260812 同一轮动态客户 fixture') } context.marker = matrix.runMarker await verifyUnfilteredLists(context) @@ -189,7 +191,7 @@ async function recoverExistingDemo(context, reportPath) { const role = roles[index] const detail = await sourceDetail(context, payableIds[index]) const source = detail.value - if (!ok(detail.response) || source?.direction !== 'PAYABLE' || source?.customerId !== '10001' + if (!ok(detail.response) || source?.direction !== 'PAYABLE' || id(source?.customerId) !== id(matrix.customerId) || !id(source?.sourceFeeId)) { blocker(context, `${role} 恢复来源属于本轮 fixture`, detail.response, `feeSourceId=${payableIds[index]}, sourceFeeId=${source?.sourceFeeId ?? 'missing'}`, @@ -276,9 +278,12 @@ export async function run(context) { const producer = await import('./finance-full-fixtures.mjs') await producer.run(context) const fixture = context.createdResources.financeFullFixtures - if (!fixture || fixture.marker !== runMarker || fixture.customerId !== '10001') { + const qualificationFixture = context.context.merchantQualificationFixture + const customerId = id(fixture?.customerId) + if (!fixture || fixture.marker !== runMarker || !customerId + || customerId !== id(qualificationFixture?.tenantId)) { blocker(context, '取得演示企业真实 Finance fixture', null, - 'finance-full-fixtures 未返回 marker=FIN-DEMO-20260812、customerId=10001 的独立资源,禁止借历史来源', + 'finance-full-fixtures 未返回与 merchant-qualification-fixture 同客户的本轮独立资源,禁止借历史来源', 'missing-finance-demo-fixture') return } @@ -289,10 +294,10 @@ export async function run(context) { if (value) extraPayables.push(value) } const produced = [...fixture.feeSources.map((item) => ({ ...item, sourceFeeId: id(item.feeSourceId) })), ...extraPayables] - const pool = await waitForSources(context, produced.map((item) => item.sourceFeeId).filter(Boolean)) + const pool = await waitForSources(context, customerId, produced.map((item) => item.sourceFeeId).filter(Boolean)) const poolBySource = new Map(pool.map((item) => [id(item.sourceId), item])) const sources = produced.map((source) => ({ ...source, - feeSourceId: financeId(poolBySource.get(source.sourceFeeId)), marker: runMarker, + feeSourceId: financeId(poolBySource.get(source.sourceFeeId)), marker: runMarker, customerId, supplierId: id(source.supplierId ?? fixture.supplier?.supplierId) })) .filter((source) => source.feeSourceId) const receivables = sources.filter((source) => source.direction === 'RECEIVABLE') @@ -304,7 +309,8 @@ export async function run(context) { return } - const matrix = { marker: MARKER, runMarker, customerId: '10001', customerLabel: '一期演示企业 10001', + const matrix = { marker: MARKER, runMarker, customerId, + customerLabel: qualificationFixture?.tenantName || `动态验收客户 ${customerId}`, feeSources: {}, statements: {}, receivableSettlements: {}, payableSettlements: {}, voucherFileId: id(fixture.voucherFileId), container: fixture.container, allocations: fixture.allocations } diff --git a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs index 4704d790d..0fed93283 100644 --- a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs @@ -22,7 +22,9 @@ test('measurement submission confirms weight evidence before submit', () => { }) test('measurement fixture derives pallet resources from confirmed image count', () => { - assert.match(source, /palletCount: WEIGHING_IMAGE_COUNT/) + assert.match(source, /waitSplitVariant\?\.palletCount \|\| 1/) + assert.match(source, /palletIds: warehouseResourcesReady\.palletIds/) + assert.match(source, /confirmedRows\.length === palletIds\.length/) assert.doesNotMatch(source, /Math\.ceil\(Number\(totalVolumeCbm\)/) assert.doesNotMatch(source, /Math\.ceil\(totalVolume \/ DEFAULT_PALLET_CAPACITY_CBM\)/) }) diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 6c2f7e14d..f68f7d3d0 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -1,7 +1,6 @@ const MODULE = 'inbound-measurement' const SCENARIO = 'mojia-carton-measurement' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } -const WEIGHING_IMAGE_COUNT = 1 const FIXTURE_PALLET_CAPACITY_CBM = 1.2 const WEIGHT_IMAGE = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, @@ -44,16 +43,17 @@ async function provisionWarehouseResources(context, { inboundId, warehouseId, pa if (!warehouseId || !Number.isInteger(palletCount) || palletCount < 1) return null const suffix = `${String(context.marker).replace(/\D/g, '').slice(-8)}${String(inboundId).slice(-6)}` - const palletResponse = await context.request('POST', '/api/platform/v1/pallets?tenantId=0', { - warehouseId, palletType: 'STANDARD', capacityCbm: FIXTURE_PALLET_CAPACITY_CBM, - weightKg: 8, color: '蓝色', remark: `${context.marker}-INBOUND-${inboundId}`, - }) - const palletId = asId(context.apiSuccess(MODULE, '创建称重托盘主数据', palletResponse, - `warehouseId=${warehouseId}`, OPTIONS)) - if (!palletId) return null - const resources = [] + const palletIds = [] for (let index = 1; index <= palletCount; index += 1) { + const palletResponse = await context.request('POST', '/api/platform/v1/pallets?tenantId=0', { + warehouseId, palletType: 'STANDARD', capacityCbm: FIXTURE_PALLET_CAPACITY_CBM, + weightKg: 8, color: '蓝色', remark: `${context.marker}-INBOUND-${inboundId}-PALLET-${index}`, + }) + const palletId = asId(context.apiSuccess(MODULE, `创建称重托盘主数据 ${index}/${palletCount}`, + palletResponse, `warehouseId=${warehouseId}`, OPTIONS)) + if (!palletId) return null + palletIds.push(palletId) const resourceSuffix = `${suffix}${String(index).padStart(2, '0')}` let locationId = null if (context.context?.phase3LoadingRequireSharedOverflow !== true) { @@ -75,54 +75,65 @@ async function provisionWarehouseResources(context, { inboundId, warehouseId, pa marker: context.marker, tenantId: '0', warehouseId: asId(warehouseId), palletCount, palletCapacityCbm: FIXTURE_PALLET_CAPACITY_CBM, resources, } - return { palletId, palletCount, resources } + return { palletId: palletIds[0], palletIds, palletCount, resources } } -async function createConfirmedWeightEvidence(context, { inboundId, tenantId, palletId }) { - const uploadResponse = await context.requestMultipart('POST', - `/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, { - content: WEIGHT_IMAGE, type: 'image/png', name: `weight-${inboundId}.png`, - }) - const upload = context.apiSuccess(MODULE, '上传称重图片并执行 OCR', uploadResponse, - `inboundId=${inboundId}`, OPTIONS) - const fileId = asId(upload?.fileId) - if (!fileId) return null +async function createConfirmedWeightEvidence(context, { + inboundId, tenantId, palletIds, grossWeightPerPalletKg, +}) { + if (!Array.isArray(palletIds) || palletIds.length < 1) return null + const confirmedResults = [] + for (let index = 0; index < palletIds.length; index += 1) { + const palletId = palletIds[index] + const uploadResponse = await context.requestMultipart('POST', + `/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, { + content: WEIGHT_IMAGE, type: 'image/png', name: `weight-${inboundId}-${index + 1}.png`, + }) + const upload = context.apiSuccess(MODULE, `上传称重图片并执行 OCR ${index + 1}/${palletIds.length}`, + uploadResponse, `inboundId=${inboundId}`, OPTIONS) + const fileId = asId(upload?.fileId) + if (!fileId) return null - const createResponse = await context.request('POST', - `/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results?${query(tenantId)}`, { - fileId, palletId, ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null, - ocrWeightValue: null, ocrWeightUnit: null, confidence: null, - }) - const created = context.apiSuccess(MODULE, '登记称重图片与托盘快照', createResponse, - `fileId=${fileId}, palletId=${palletId}`, OPTIONS) - if (!created?.id) return null + const createResponse = await context.request('POST', + `/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results?${query(tenantId)}`, { + fileId, palletId, ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null, + ocrWeightValue: null, ocrWeightUnit: null, confidence: null, + }) + const created = context.apiSuccess(MODULE, `登记称重图片与托盘快照 ${index + 1}/${palletIds.length}`, + createResponse, `fileId=${fileId}, palletId=${palletId}`, OPTIONS) + if (!created?.id) return null - const confirmResponse = await context.request('POST', - `/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results/${created.id}/confirm?${query(tenantId)}`, { - version: Number(created.version), adjustedWeightValue: 100, - adjustedWeightUnit: 'KG', adjustmentReason: `${context.marker} 自动化人工确认称重`, - }) - const confirmed = context.apiSuccess(MODULE, '人工确认称重图片净重', confirmResponse, - `resultId=${created.id}, palletId=${palletId}`, OPTIONS) - const netWeightKg = Number(confirmed?.netWeightKg) - const grossWeightKg = Number(confirmed?.grossWeightKg) - if (confirmed?.recognitionStatus !== 'CONFIRMED' || !Number.isFinite(netWeightKg) || netWeightKg <= 0) { - return null + const grossWeight = Number(grossWeightPerPalletKg) + index * 5 + const confirmResponse = await context.request('POST', + `/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results/${created.id}/confirm?${query(tenantId)}`, { + version: Number(created.version), adjustedWeightValue: grossWeight, + adjustedWeightUnit: 'KG', adjustmentReason: `${context.marker} 自动化人工确认称重`, + }) + const confirmed = context.apiSuccess(MODULE, `人工确认称重图片净重 ${index + 1}/${palletIds.length}`, + confirmResponse, `resultId=${created.id}, palletId=${palletId}`, OPTIONS) + const netWeightKg = Number(confirmed?.netWeightKg) + const grossWeightKg = Number(confirmed?.grossWeightKg) + if (confirmed?.recognitionStatus !== 'CONFIRMED' || !Number.isFinite(netWeightKg) || netWeightKg <= 0) { + return null + } + confirmedResults.push({ id: asId(created.id), palletId, netWeightKg, grossWeightKg }) } const workspaceResponse = await context.request('GET', `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement?${query(tenantId)}`) const workspace = context.apiSuccess(MODULE, '回查已确认称重图片和货物总重量', workspaceResponse, - `resultId=${created.id}`, OPTIONS) + `resultIds=${confirmedResults.map((result) => result.id).join(',')}`, OPTIONS) const confirmedRows = (workspace?.weightResults || []) .filter((result) => result?.recognitionStatus === 'CONFIRMED') - const persisted = confirmedRows.length === WEIGHING_IMAGE_COUNT - && asId(confirmedRows[0]?.id) === asId(created.id) - && asId(confirmedRows[0]?.palletId) === palletId + const grossWeightKg = confirmedResults.reduce((sum, result) => sum + result.grossWeightKg, 0) + const netWeightKg = confirmedResults.reduce((sum, result) => sum + result.netWeightKg, 0) + const persisted = confirmedRows.length === palletIds.length + && confirmedResults.every((expected) => confirmedRows.some((row) => + asId(row?.id) === expected.id && asId(row?.palletId) === expected.palletId)) && closeTo(workspace?.actualGrossWeightKg, grossWeightKg, 0.001) && closeTo(workspace?.actualNetWeightKg, netWeightKg, 0.001) context.assert(MODULE, '图片张数决定托数且净重汇总已落地', workspaceResponse, persisted, - `confirmedImages=${confirmedRows.length}, expectedPallets=${WEIGHING_IMAGE_COUNT}, grossWeight=${grossWeightKg}, netWeight=${netWeightKg}, actualGross=${workspace?.actualGrossWeightKg ?? 'missing'}, actualNet=${workspace?.actualNetWeightKg ?? 'missing'}`, + `confirmedImages=${confirmedRows.length}, expectedPallets=${palletIds.length}, grossWeight=${grossWeightKg}, netWeight=${netWeightKg}, actualGross=${workspace?.actualGrossWeightKg ?? 'missing'}, actualNet=${workspace?.actualNetWeightKg ?? 'missing'}`, OPTIONS) return persisted ? { count: confirmedRows.length, grossWeightKg, netWeightKg } : null } @@ -280,8 +291,8 @@ export async function run(context) { } const scanPath = `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/carton-scans?${query(tenantId)}` - // Keep the isolated sample inside the customer's largest configured inner box (60x50x50cm). - const firstDimensions = { length: 59.1, width: 40.2, height: 30.3 } + const measurementProfile = context.context?.waitSplitVariant?.measurementProfile + const firstDimensions = measurementProfile?.first || { length: 59.1, width: 40.2, height: 30.3 } const firstShipmentId = `FBA-${context.marker.slice(-12)}-01` const firstScanResponse = await multipartRequest(context, scanPath, { boxCode, ...firstDimensions, scannedShipmentId: firstShipmentId, @@ -325,7 +336,7 @@ export async function run(context) { && Number(countResult?.measurementVersion) === Number(firstScan.measurementVersion) + 1, `version=${countResult?.measurementVersion ?? 'missing'}`, OPTIONS) - const replacement = { length: 59.5, width: 41.2, height: 31.3 } + const replacement = measurementProfile?.replacement || { length: 59.5, width: 41.2, height: 31.3 } const replacementShipmentId = `FBA-${context.marker.slice(-12)}-02` const rescanResponse = await multipartRequest(context, scanPath, { boxCode, ...replacement, scannedShipmentId: replacementShipmentId, @@ -342,8 +353,7 @@ export async function run(context) { let measuredResponse = await context.request('GET', measurementPath) let measured = context.apiSuccess(MODULE, '回查覆盖后的尺寸与体积结果', measuredResponse, '', OPTIONS) let measuredItem = measured?.items?.find((item) => asId(item.id) === asId(firstScan.itemId)) - // The verified customer rule maps this scan to EXTRA_LARGE 60x50x50cm and adds 2cm per edge. - const weightedOuter = { length: 64, width: 54, height: 54 } + const weightedOuter = measurementProfile?.outer || { length: 64, width: 54, height: 54 } const unitVolume = halfUp( weightedOuter.length * weightedOuter.width * weightedOuter.height / 1_000_000, 3) const totalVolume = unitVolume * originalPackageCount @@ -374,7 +384,7 @@ export async function run(context) { `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/items/${firstScan.itemId}/confirmed-package-count?${query(tenantId)}`, { measurementVersion: Number(measured?.version), confirmedPackageCount: packagesPerDetail }) const resetFirstCount = context.apiSuccess(MODULE, - '增量 fixture 将首条货物确认箱数恢复为自身两箱', resetFirstCountResponse, + '增量 fixture 将首条货物确认箱数恢复为自身箱数', resetFirstCountResponse, `itemId=${asId(firstScan.itemId)}, confirmedPackageCount=${packagesPerDetail}`, OPTIONS) context.assert(MODULE, '增量首条货物箱数和版本更新正确', resetFirstCountResponse, Number(resetFirstCount?.confirmedPackageCount) === packagesPerDetail @@ -409,10 +419,10 @@ export async function run(context) { `measurementVersion=${countDetail?.measurementVersion ?? 'missing'}`, OPTIONS) } measuredResponse = await context.request('GET', measurementPath) - measured = context.apiSuccess(MODULE, '回查增量 fixture 四条测量明细', measuredResponse, '', OPTIONS) + measured = context.apiSuccess(MODULE, '回查增量 fixture 动态测量明细', measuredResponse, '', OPTIONS) measuredItem = measured?.items?.find((item) => asId(item.id) === asId(firstScan.itemId)) const detailIds = new Set((measured?.items || []).map((item) => asId(item?.inboundDetailId)).filter(Boolean)) - context.assert(MODULE, '增量 fixture 形成四条真实独立货物测量明细', measuredResponse, + context.assert(MODULE, '增量 fixture 形成动态真实独立货物测量明细', measuredResponse, detailIds.size >= incrementalDetailCount && measured.items.filter((item) => detailIds.has(asId(item?.inboundDetailId))).length >= incrementalDetailCount, `measurementItems=${measured?.items?.length ?? 'missing'}, distinctInboundDetails=${detailIds.size}`, OPTIONS) @@ -482,12 +492,13 @@ export async function run(context) { return } + const palletCount = Math.max(1, Number(context.context?.waitSplitVariant?.palletCount || 1)) const warehouseResourcesReady = await provisionWarehouseResources(context, { - inboundId, warehouseId: asId(summary?.warehouseId), palletCount: WEIGHING_IMAGE_COUNT, + inboundId, warehouseId: asId(summary?.warehouseId), palletCount, }) context.assert(MODULE, '测量提交前按称重图片数准备真实库位', draftResponse, Boolean(warehouseResourcesReady), - `warehouseId=${asId(summary?.warehouseId) || 'missing'}, palletCount=${WEIGHING_IMAGE_COUNT}`, + `warehouseId=${asId(summary?.warehouseId) || 'missing'}, palletCount=${palletCount}`, OPTIONS) if (!warehouseResourcesReady) { finishScenario(context, start) @@ -495,11 +506,12 @@ export async function run(context) { } const weightEvidence = await createConfirmedWeightEvidence(context, { - inboundId, tenantId, palletId: warehouseResourcesReady.palletId, + inboundId, tenantId, palletIds: warehouseResourcesReady.palletIds, + grossWeightPerPalletKg: Number(context.context?.waitSplitVariant?.grossWeightPerPalletKg || 100), }) context.assert(MODULE, '测量提交前已通过正式接口确认称重图片', draftResponse, Boolean(weightEvidence), - `confirmedImages=${weightEvidence?.count ?? 0}, expected=${WEIGHING_IMAGE_COUNT}`, + `confirmedImages=${weightEvidence?.count ?? 0}, expected=${palletCount}`, OPTIONS) if (!weightEvidence) { finishScenario(context, start) diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 746730c2a..f462e5104 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -7,23 +7,23 @@ const iamSeed = readFileSync(new URL('../../iam/src/main/resources/db/phase1-adm const oceanBookingSchema = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql', import.meta.url), 'utf8') const truckAppointmentSchema = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql', import.meta.url), 'utf8') -test('统一初始化 SQL 保留最小登录与客户主体', () => { - assert.match(sql, /一个平台系统管理员和一个 IT 测试客户管理员/) +test('统一初始化 SQL 只保留平台管理员且不创建固定客户', () => { + assert.match(sql, /最小登录基线只保留平台系统管理员/) assert.equal((sql.match(/'platform_admin'/g) || []).length, 1) - assert.equal((sql.match(/'merchant_admin'/g) || []).length, 1) - assert.match(sql, /\(10001, 'IT_TEST', 'IT 测试客户'/) + assert.doesNotMatch(sql, /'merchant_admin'|'IT_TEST'|'IT 测试客户'/) + assert.doesNotMatch(iamSeed, /'IT_TEST'|'IT 测试客户'|\(10001,/) const accountSeed = sql.match(/INSERT INTO auth_account \([\s\S]*?deleted = 0;/)?.[0] assert.ok(accountSeed, 'missing auth account seed') assert.doesNotMatch(accountSeed, /ON DUPLICATE KEY UPDATE[\s\S]*?password_hash\s*=/, 're-running the unified SQL must not reset an existing account password') }) -test('IT 测试客户初始化后具备已审核的必填资质和下单资格', () => { - for (const source of [sql, iamSeed]) { - assert.match(source, /\(2100001, 10001, 'BUSINESS_LICENSE', '91310000ITTEST0001', NULL,[\s\S]*?'APPROVED'/) - assert.match(source, /\(2100002, 10001, 'LEGAL_PERSON_ID', NULL, NULL,[\s\S]*?'APPROVED'/) - assert.match(source, /status = 'APPROVED'/) - } +test('统一初始化直接提供tenant0商户角色与权限模板', () => { + assert.match(sql, /4000100, 0, 'MERCHANT', 'MERCHANT_OWNER'/) + assert.match(sql, /5300000, 0, 'MERCHANT', 'MENU',[\s\S]*?'merchant:enterprise'/) + assert.match(sql, /5301024, 0, 'MERCHANT', 'BUTTON',[\s\S]*?'merchant:order:quick-create'/) + assert.match(sql, /5305000, 0, 'MERCHANT', 'MENU',[\s\S]*?'merchant:finance:statement'/) + assert.match(sql, /r\.id=4000100[\s\S]*?客户全局管理员 Finance 模板授权不完整/) }) test('统一初始化 SQL 提供人工验收所需公共仓储主数据', () => { diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index 6560cab8f..ac4d215a1 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -4,6 +4,7 @@ import test from 'node:test' const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') const fixture = readFileSync(new URL('./merchant-qualification-fixture.mjs', import.meta.url), 'utf8') +const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8') test('默认全量验收先准备已审核商户再运行订单链路', () => { const modules = acceptance.match(/const CONFIGURED_MODULES = \[([^\n]+)\]/)?.[1] || '' @@ -22,5 +23,38 @@ test('商户夹具通过正式 File 和 IAM 审核流程建立下单资格', () assert.match(fixture, /item\.status === 'APPROVED'/) assert.match(fixture, /MERCHANT_TEST_USERNAME/) assert.match(fixture, /MERCHANT_TEST_PASSWORD/) + assert.match(fixture, /context\.options\?\.customerName/) assert.doesNotMatch(fixture, /internal\/iam|UPDATE\s+iam_|INSERT\s+INTO\s+iam_/i) }) + +test('批量待分柜参数固定同一客户并限制为订单测量链', () => { + assert.match(acceptance, /--customer-name /) + assert.match(acceptance, /--merchant-username /) + assert.match(acceptance, /--origin-warehouse-name /) + assert.match(acceptance, /--channel-name /) + assert.match(acceptance, /--wait-split-count /) + assert.match(acceptance, /--order-profile-mode /) + assert.match(acceptance, /merchant-username和merchant-password必须同时提供/) + assert.match(acceptance, /modules\.splice\(fixtureIndex, 1\)/) + assert.match(acceptance, /wait-split-count必须是1-100的整数/) + assert.match(acceptance, /批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement/) + assert.match(acceptance, /createAdditionalWaitSplitFixtures/) + assert.match(acceptance, /measurement\.processStatus === 'WAIT_SPLIT'/) + assert.match(acceptance, /measurement\.finalStatus === 'APPROVED'/) + assert.match(acceptance, /waitSplitVariant\(sequence\)/) + assert.match(acceptance, /packageCountPerSku/) + assert.match(acceptance, /grossWeightPerPalletKg/) + assert.match(acceptance, /randomizedOrderProfile\(sequence\)/) + assert.match(acceptance, /\['PRIVATE', 'STANDARD'\]/) + assert.match(acceptance, /ONE_CLICK_DROPSHIP/) + assert.match(acceptance, /destinationWarehouseCode: order\.destinationWarehouseCode/) + assert.match(acceptance, /channelName: order\.channelName/) + assert.match(acceptance, /pricingTaxMode: order\.pricingTaxMode/) + assert.match(orders, /customerId: profile\.id/) + assert.match(orders, /customerName: companyName/) + assert.match(orders, /\['FBA', 'OVERSEAS', 'PRIVATE'\]\.includes/) + assert.match(orders, /destinationType === 'PRIVATE'/) + assert.match(orders, /PRIVATE_ADDRESS/) + assert.match(orders, /eligible\.length < 1/) + assert.doesNotMatch(orders, /customerName: master\.partner\?\.partnerName/) +}) diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs index 61c541603..a21a9053c 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs @@ -28,10 +28,11 @@ async function waitForDimensionInitialization(context, tenantId) { export async function run(context) { const suffix = context.marker.replace(/[^0-9A-Z]/gi, '').slice(-10).padStart(10, '0') + const customerName = String(context.options?.customerName || '').trim() const phone = await context.findAvailablePhone(MODULE) const createResponse = await context.request('POST', '/api/platform/v1/iam/tenants', { - tenantName: `全量验收下单客户-${suffix}`, - legalName: `全量验收下单有限公司-${suffix}`, + tenantName: customerName || `全量验收下单客户-${suffix}`, + legalName: customerName ? `${customerName}有限公司` : `全量验收下单有限公司-${suffix}`, legalRepresentative: '全量验收法人', contactName: '全量验收管理员', contactPhone: phone, 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 fdfe9b1b9..246386f9e 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs @@ -2,8 +2,8 @@ import { findPricedOrderRouteAcrossOrigins } from './orders.mjs' const MODULE = 'order-inbound-status-fixtures' const OPTIONS = { scenario: 'manual-order-inbound-status-matrix', deploymentBlocker: true } -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' const ORDER_TARGETS = [ ['DRAFT', []], diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index 3e7b83c41..d3d3c5954 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -1,7 +1,7 @@ const MODULE = 'orders' const CONTRACT_OPTIONS = { scenario: 'isolated-order-chain', deploymentBlocker: true } -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' const BOXES = [ { boxType: 'SMALL', lengthCm: 30, widthCm: 20, heightCm: 20 }, { boxType: 'MEDIUM', lengthCm: 40, widthCm: 30, heightCm: 30 }, @@ -21,8 +21,8 @@ function success(response) { return Boolean(response) && response.http === 200 & function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) } export function requestedOrderDestinationType(context) { - return String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase() === 'OVERSEAS' - ? 'OVERSEAS' : 'FBA' + const value = String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase() + return ['FBA', 'OVERSEAS', 'PRIVATE'].includes(value) ? value : 'FBA' } export function approvedInboundReady(orderDetail, taskResponse, tenantId, orderId) { const summary = orderDetail?.summary @@ -202,35 +202,88 @@ async function assertProductImageDownload(context, merchantToken, fileId) { async function loadMasterData(context, merchantToken) { const destinationType = requestedOrderDestinationType(context) + const fixedRoute = context.context?.waitSplitRoute || {} const domesticResponse = await request(context, 'GET', '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', undefined, merchantToken) const domesticPage = context.apiSuccess(MODULE, 'Client 查询国内收货仓', domesticResponse) context.assert(MODULE, '存在可用国内收货仓', domesticResponse, records(domesticPage).length > 0, `count=${records(domesticPage).length}`) - const destinationResponse = await request(context, 'GET', - `/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`, - undefined, merchantToken) - const destinationPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓`, destinationResponse) - context.assert(MODULE, `存在可用 ${destinationType} 目的仓`, destinationResponse, - records(destinationPage).length > 0, `count=${records(destinationPage).length}`) + let destinationResponse = null + let destinationPage = null + if (destinationType !== 'PRIVATE') { + destinationResponse = await request(context, 'GET', + `/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`, + undefined, merchantToken) + destinationPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓`, destinationResponse) + context.assert(MODULE, `存在可用 ${destinationType} 目的仓`, destinationResponse, + records(destinationPage).length > 0, `count=${records(destinationPage).length}`) + } const receivings = records(domesticPage).filter((warehouse) => warehouse?.status === 'ACTIVE') - const destinations = records(destinationPage).filter((warehouse) => warehouse?.status === 'ACTIVE') - if (receivings.length === 0 || destinations.length === 0) throw new Error('缺少创建测试订单所需的收货仓或目的仓') + let destinations = records(destinationPage).filter((warehouse) => warehouse?.status === 'ACTIVE') + if (Number(destinationPage?.total) > destinations.length) { + const nextResponse = await request(context, 'GET', + `/api/merchant/v1/warehouses?page=2&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`, + undefined, merchantToken) + const nextPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓第二页`, nextResponse) + destinations = [...destinations, ...records(nextPage).filter((warehouse) => warehouse?.status === 'ACTIVE')] + } + if (receivings.length === 0 || (destinationType !== 'PRIVATE' && destinations.length === 0)) { + throw new Error('缺少创建测试订单所需的收货仓或目的仓') + } - const pricingResult = await findPricedOrderRouteAcrossOrigins({ - receivings, destinations, - loadChannels: (receiving) => request(context, 'GET', + let pricingResult + if (fixedRoute.originWarehouseName && (fixedRoute.channelCode || fixedRoute.channelName)) { + const receiving = receivings.find((warehouse) => warehouse.warehouseName === fixedRoute.originWarehouseName) + if (!receiving) throw new Error(`未找到固定起运仓: ${fixedRoute.originWarehouseName}`) + const channelsResponse = await request(context, 'GET', `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`, - undefined, merchantToken), - estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken), - destinationType, - }) + undefined, merchantToken) + const channels = context.apiSuccess(MODULE, 'Client 查询固定起运仓物流渠道', channelsResponse) + const channel = (Array.isArray(channels) ? channels : []).find((item) => + (!fixedRoute.channelCode || item.channelCode === fixedRoute.channelCode) + && (!fixedRoute.channelName || item.channelName === fixedRoute.channelName)) + if (!channel) throw new Error(`未找到固定物流渠道: ${fixedRoute.channelCode || fixedRoute.channelName}`) + const modes = Array.isArray(channel.pricingTaxModes) ? [...channel.pricingTaxModes] : [] + if (modes.length === 0) throw new Error('固定物流渠道没有可用报价税制') + const shift = modes.length ? Number(fixedRoute.taxModeIndex || 0) % modes.length : 0 + const selectedChannel = { ...channel, pricingTaxModes: [...modes.slice(shift), ...modes.slice(0, shift)] } + if (destinationType === 'PRIVATE') { + pricingResult = { receiving, priced: { channel: selectedChannel, + pricingTaxMode: selectedChannel.pricingTaxModes[0], destination: null }, + response: channelsResponse, attempts: 0, failures: [], originFailures: [] } + } else { + const detailResponse = await context.request('GET', + `/api/platform/v1/fba-pricing/carrier-rates/price-books/${encodeURIComponent(channel.priceBookVersionId)}`) + const detail = context.apiSuccess(MODULE, 'Admin 查询固定渠道当前价格明细', detailResponse) + const pricedCodes = new Set((detail?.rates || []) + .filter((rate) => rate.channelCode === channel.channelCode && rate.destinationType === destinationType) + .map((rate) => String(rate.destinationCode || '').trim()).filter(Boolean)) + const eligible = destinations.filter((destination) => pricedCodes.has(String(destination.warehouseCode))) + if (eligible.length < 1) throw new Error(`固定渠道没有可计价目的仓: ${destinationType}`) + const destination = eligible[Number(fixedRoute.destinationIndex || 0) % eligible.length] + const result = await findPricedOrderRoute({ + receiving, destinations: [destination], channels: [selectedChannel], + estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken), + destinationType, + }) + pricingResult = { receiving, ...result, originFailures: [] } + } + } else { + pricingResult = await findPricedOrderRouteAcrossOrigins({ + receivings, destinations, + loadChannels: (receiving) => request(context, 'GET', + `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`, + undefined, merchantToken), + estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken), + destinationType, + }) + } const priced = pricingResult.priced const receiving = pricingResult.receiving context.addStep(MODULE, 'Client 从 ACTIVE 国内仓选择具有真实价格覆盖的渠道和目的仓', pricingResult.response, Boolean(priced), priced - ? `origin=${receiving.id}, priceBookVersionId=${priced.channel.priceBookVersionId}, channel=${priced.channel.channelCode}, pricingTaxMode=${priced.pricingTaxMode}, destination=${priced.destination.warehouseCode}, attempts=${pricingResult.attempts}` + ? `origin=${receiving.id}, priceBookVersionId=${priced.channel.priceBookVersionId}, channel=${priced.channel.channelCode}, pricingTaxMode=${priced.pricingTaxMode}, destination=${priced.destination?.warehouseCode || 'PRIVATE_ADDRESS'}, attempts=${pricingResult.attempts}` : originPricingFailureEvidence(pricingResult), CONTRACT_OPTIONS) if (!priced) throw new Error(`当前 ACTIVE 国内收货仓、渠道、报价税制和 ${destinationType} 目的仓没有可计价组合`) @@ -249,22 +302,39 @@ function buildPayload(context, profile, master, productImageFileId) { const shipmentId = `SHIP-${context.marker}`.slice(0, 64) const referenceId = `REF-${context.marker}`.slice(0, 64) const companyName = profile.tenantName || `订单验收客户-${context.marker}` + const requestedSkuCount = Number(context.context?.waitSplitVariant?.skuCount || BOXES.length) + const skuCount = Math.max(1, Math.min(BOXES.length, requestedSkuCount)) + const packageCountPerSku = Math.max(1, + Number(context.context?.waitSplitVariant?.packageCountPerSku + || context.context?.phase3LoadingIncrementalPackageCount || 1)) + const selectedBoxes = BOXES.slice(0, skuCount) + const orderProfile = context.context?.orderProfile || {} + const isPrivate = master.destinationType === 'PRIVATE' + const pickupMethod = orderProfile.pickupMethod || 'PICKUP' + const receiver = isPrivate ? { + companyName: `${companyName} 私人收件人`, contactName: `收件人-${context.marker}`.slice(0, 64), + phone: '12025550188', email: 'private-receiver@example.com', countryCode: 'US', + province: 'New York', city: 'New York', district: 'Queens', + addressLine: `${context.marker} 88 Randomized Test Ave`, postalCode: '11354', + } : null return { fulfillmentType: String(context.context?.orderFulfillmentType || 'STANDARD').trim().toUpperCase(), - customerId: master.partner?.id ?? null, - customerName: master.partner?.partnerName ?? companyName, + customerId: profile.id, + customerName: companyName, transportMode: 'SEA', tradeTerm: 'FOB', - destinationCountryCode: master.destination.countryCode || 'US', - destinationWarehouseId: master.destination.id, - destinationWarehouseName: master.destination.warehouseName, + destinationCountryCode: master.destination?.countryCode || receiver?.countryCode || 'US', + destinationWarehouseId: master.destination?.id || null, + destinationWarehouseName: master.destination?.warehouseName || (isPrivate ? '私人地址' : null), expectedShipDate: tomorrow(), customerRemark: `${context.marker} Client 创建隔离订单`, - pickupMethod: 'PICKUP', receivingWarehouseId: master.receiving.id, + pickupMethod, requestedPickupAt: pickupMethod === 'PICKUP' + ? new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 19) : null, + receivingWarehouseId: master.receiving.id, receivingWarehouseName: master.receiving.warehouseName, channelCode: master.channel.channelCode, channelName: master.channel.channelName, - carrierId: null, carrierName: null, taxMode: 'BUY_DECLARATION', - pricingTaxMode: master.pricingTaxMode, containerMode: 'LCL', - customsInspection: false, destinationType: master.destinationType, - amazonWarehouseCode: master.destinationType === 'FBA' ? master.destination.warehouseCode : null, + carrierId: null, carrierName: null, taxMode: orderProfile.taxMode || 'BUY_DECLARATION', + pricingTaxMode: master.pricingTaxMode, containerMode: orderProfile.containerMode || 'LCL', + customsInspection: orderProfile.customsInspection ?? false, destinationType: master.destinationType, + amazonWarehouseCode: master.destinationType === 'FBA' ? master.destination?.warehouseCode : null, shipmentId: master.destinationType === 'FBA' ? shipmentId : null, referenceId: master.destinationType === 'FBA' ? referenceId : null, sender: { @@ -272,11 +342,11 @@ function buildPayload(context, profile, master, productImageFileId) { email: 'order-acceptance@example.com', countryCode: 'CN', province: '广东省', city: '深圳市', district: '南山区', addressLine: `${context.marker} 测试地址`, postalCode: '518000', }, - receiver: null, - cargoes: BOXES.map((box, index) => { + receiver, + cargoes: selectedBoxes.map((box, index) => { const volumeCbm = box.lengthCm * box.widthCm * box.heightCm / 1_000_000 const unitsPerPackage = 10 + index - const packageCount = Number(context.context?.phase3LoadingIncrementalPackageCount) === 2 ? 2 : 1 + const packageCount = packageCountPerSku return { skuNo: `SKU-${box.boxType}-${context.marker}`.slice(0, 64), cargoName: `${box.boxType} 测试产品`, cargoNameEn: `${box.boxType} test product`, @@ -300,11 +370,12 @@ function buildPayload(context, profile, master, productImageFileId) { } function assertCargoContract(context, response, cargoes, payload) { - const complete = Array.isArray(cargoes) && cargoes.length === BOXES.length - && BOXES.every((box, index) => { - const actual = cargoes[index]; const expected = payload.cargoes[index] - return Number(actual?.lengthCm) === box.lengthCm && Number(actual?.widthCm) === box.widthCm - && Number(actual?.heightCm) === box.heightCm && id(actual?.productImageFileId) === id(expected.productImageFileId) + const expectedCargoes = payload.cargoes || [] + const complete = Array.isArray(cargoes) && cargoes.length === expectedCargoes.length + && expectedCargoes.every((expected, index) => { + const actual = cargoes[index] + return Number(actual?.lengthCm) === expected.lengthCm && Number(actual?.widthCm) === expected.widthCm + && Number(actual?.heightCm) === expected.heightCm && id(actual?.productImageFileId) === id(expected.productImageFileId) && actual?.skuNo === expected.skuNo && actual?.cargoName === expected.cargoName && actual?.cargoNameEn === expected.cargoNameEn && actual?.customsHsCode === expected.customsHsCode && Number(actual?.unitsPerPackage) === expected.unitsPerPackage @@ -320,7 +391,7 @@ function assertCargoContract(context, response, cargoes, payload) { && (actual?.referenceId ?? null) === (expected.referenceId ?? null) && actual?.upcNo === expected.upcNo && actual?.cargoRemark.includes(context.marker) }) - context.assert(MODULE, '四箱型产品字段及图片契约完整回显', response, complete, + context.assert(MODULE, '动态SKU产品字段及图片契约完整回显', response, complete, `cargoes=${Array.isArray(cargoes) ? cargoes.length : 'n/a'}, imageFileId=${payload.cargoes[0].productImageFileId}`, CONTRACT_OPTIONS) } @@ -405,9 +476,18 @@ export async function run(context) { })), boxTypes: BOXES.map((box) => box.boxType), inboundId: null, receivingWarehouseId: id(master.receiving.id), + receivingWarehouseName: master.receiving.warehouseName, destinationType: master.destinationType, - destinationWarehouseId: id(master.destination.id), - destinationWarehouseCode: master.destination.warehouseCode, + destinationWarehouseId: id(master.destination?.id), + destinationWarehouseCode: master.destination?.warehouseCode || null, + destinationWarehouseName: master.destination?.warehouseName || (master.destinationType === 'PRIVATE' ? '私人地址' : null), + channelCode: master.channel.channelCode, + channelName: master.channel.channelName, + pricingTaxMode: master.pricingTaxMode, + pickupMethod: payload.pickupMethod, + taxMode: payload.taxMode, + containerMode: payload.containerMode, + customsInspection: payload.customsInspection, } const replayResponse = await request(context, 'POST', createPath, payload, merchantToken, diff --git a/scripts/admin-test-env-modules/phase3-customs.mjs b/scripts/admin-test-env-modules/phase3-customs.mjs index de3230435..18a4d7be2 100644 --- a/scripts/admin-test-env-modules/phase3-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-customs.mjs @@ -4,8 +4,8 @@ const MODULE = 'phase3-customs' const OPTIONS = { scenario: 'phase3-customs-collaboration', deploymentBlocker: true } const PLATFORM = '/api/platform/v1/customs' const MERCHANT = '/api/merchant/v1/customs' -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 9059f6a91..060f643d0 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -3,8 +3,8 @@ import { createHash } from 'node:crypto' const MODULE = 'phase3-loading' const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true } -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' const MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司' function id(value) { return value == null ? '' : String(value) } diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index d42339d61..eaddf6079 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -1,8 +1,8 @@ const MODULE = 'phase3-order-splitting' const SCENARIO = 'source-loaded-single-remainder-chain' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' const CONTAINERS = '/api/platform/v1/containers' const SMART = '/api/platform/v1/first-leg/smart-loading' const PDA = '/api/platform/v1/first-leg/pda-loading' diff --git a/scripts/admin-test-env-modules/supplemental.mjs b/scripts/admin-test-env-modules/supplemental.mjs index 24a95aa72..28bef19f7 100644 --- a/scripts/admin-test-env-modules/supplemental.mjs +++ b/scripts/admin-test-env-modules/supplemental.mjs @@ -46,6 +46,10 @@ export async function run(context) { const module = 'supplemental' const scenario = 'service-health-and-auth-boundary' const blocker = { scenario, deploymentBlocker: true } + const customerId = String(context.context.merchantQualificationFixture?.tenantId || '') + context.assert(module, '客户作用域负向检查使用本轮正式客户', null, Boolean(customerId), + `customerId=${customerId || 'missing'}`, blocker) + if (!customerId) return // Health endpoints are part of the deployed service contract. Warehouse is // special: a schema/readiness failure is intentionally HTTP 503, not a fake @@ -102,7 +106,7 @@ export async function run(context) { } for (const [name, path] of [ ['仓库非法状态', '/api/platform/v1/warehouse-admin?page=1&size=5&status=NOT_A_STATUS'], - ['仓库客户作用域', '/api/platform/v1/warehouse-admin?page=1&size=5&tenantId=10001'], + ['仓库客户作用域', `/api/platform/v1/warehouse-admin?page=1&size=5&tenantId=${encodeURIComponent(customerId)}`], ]) { const response = await context.request('GET', path) context.addStep(module, `${name}安全失败`, response, isBusinessFailure(response), @@ -118,8 +122,8 @@ export async function run(context) { await checkPage(context, module, '托盘仓库筛选', `${palletBase}&warehouseId=${warehouseId}`, { scenario: 'warehouse-resources' }) } const resourceScopeChecks = [ - ['库位客户作用域', '/api/platform/v1/locations?page=1&size=5&tenantId=10001'], - ['托盘客户作用域', '/api/platform/v1/pallets?page=1&size=5&tenantId=10001'], + ['库位客户作用域', `/api/platform/v1/locations?page=1&size=5&tenantId=${encodeURIComponent(customerId)}`], + ['托盘客户作用域', `/api/platform/v1/pallets?page=1&size=5&tenantId=${encodeURIComponent(customerId)}`], ['库位非法状态', `${locationBase}&status=ALLOCATED`], ] for (const [name, path] of resourceScopeChecks) { diff --git a/scripts/admin-test-env-modules/suppliers.mjs b/scripts/admin-test-env-modules/suppliers.mjs index abb38b91a..ae2b96255 100644 --- a/scripts/admin-test-env-modules/suppliers.mjs +++ b/scripts/admin-test-env-modules/suppliers.mjs @@ -1,8 +1,8 @@ const MODULE = 'suppliers' const OPTIONS = { scenario: 'platform-public-suppliers', deploymentBlocker: true } const BASE = '/api/platform/v1/suppliers' -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' function ok(response) { return response?.http === 200 && String(response?.body?.code) === '200' } function code(response, expected) { return response?.http === 200 && String(response?.body?.code) === expected } diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index bc11e7d09..fe61a180d 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -172,6 +172,48 @@ Assert-Contains $overseasCompose '/internal/lastleg/security/v1/events' 'Lastleg Assert-Contains $overseasCompose 'LASTLEG_SECURITY_SERVICE_URL' 'Lastleg 安全校验地址' Assert-NotContains $serviceJenkinsfile "'overseas-security-service'" '独立海外安全服务构建入口' +# 高变化语义必须在最近指引与服务契约中保持一致。 +Assert-Contains $adminAgents '海外出库(范围外现存代码)' 'Admin 四期范围边界' +Assert-Contains $pdaAgents 'VITE_H5_API_BASE_URL' '国内 PDA H5 运行配置' +Assert-Contains $overseasPdaAgents 'tests/app-icons.test.mjs' '海外 PDA 图标合同' +Assert-Contains $backendAgents 'RestClientTraceInterceptor.java' '后端同步 Trace 入口' +$oceanBookingOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml') +Assert-Contains $oceanBookingOpenApi 'never represents linked or ACTIVE containers' '订舱柜量语义' +Assert-Contains $oceanBookingOpenApi '/api/platform/v1/ocean-bookings/link-candidates:' '订舱关联候选契约' +Assert-Contains $oceanBookingOpenApi 'exact warehouseId, channelCode and containerType' '订舱关联候选筛选语义' + +$adminOceanBookingApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts') +$adminOceanBookingView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.vue') +$adminOceanBookingViewTest = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts') +Assert-Contains $adminOceanBookingApi '/link-candidates' 'Admin 订舱关联候选调用' +Assert-Contains $adminOceanBookingView "label: '订舱柜量'" 'Admin 订舱柜量文案' +Assert-Contains $adminOceanBookingViewTest 'without detail N+1' 'Admin 订舱列表无详情扇出合同' + +# 已注册但超出当前四期边界的海外出库只能作为范围漂移事实存在,不得进入根正式业务路由。 +$adminOverseasRoutes = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/routes.ts') +Assert-Contains $adminOverseasRoutes "path: 'overseas-warehouse/outbounds'" 'Admin 海外出库现存路由' +Assert-Contains $adminAgents '海外出库(范围外现存代码)' 'Admin 海外出库范围警示' +Assert-NotContains $rootAgents 'OutboundsView.vue' '根正式四期业务路由' + +# Admin 仅对静态资源过期做一次受控恢复,不能吞掉普通路由错误或覆盖未保存编辑。 +$navigationRecovery = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/core/routing/navigationRecovery.ts') +Assert-Contains $navigationRecovery 'const RECOVERY_WINDOW_MS = 30_000' 'Admin 路由恢复窗口' +Assert-Contains $navigationRecovery 'if (dependencies.hasUnsavedWork() && !await dependencies.confirmDiscard()) return true' 'Admin 未保存编辑保护' +Assert-Contains $navigationRecovery 'if (!isStaleRouteAssetError(error))' 'Admin 普通路由错误边界' + +# 国内 PDA 的 H5 地址必须来自当前构建和同源代理,不能被历史登录缓存覆盖。 +$domesticPdaRuntime = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/config/runtime.js') +$domesticPdaVite = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/vite.config.js') +$domesticPdaSession = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/stores/session.js') +Assert-Contains $domesticPdaRuntime "VITE_H5_API_BASE_URL || '/api'" '国内 PDA H5 同源 API' +Assert-Contains $domesticPdaVite 'API_PROXY_TARGET' '国内 PDA H5 代理上游' +Assert-Contains $domesticPdaSession 'session.apiBase = PLATFORM_API_ROOT' '国内 PDA 当前构建 API' +Assert-NotContains $domesticPdaSession 'apiBase: session.apiBase' '国内 PDA 会话持久化' + +# 同步 RestClient Trace 必须注册为公共自动配置,不能只有孤立拦截器源码。 +$commonWebAutoConfigurations = Read-RequiredText (Join-Path $backendRoot 'common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports') +Assert-Contains $commonWebAutoConfigurations 'com.baiyue.common.web.RestClientTracePropagationAutoConfiguration' 'RestClient Trace 自动配置注册' + # Maven 聚合声明的全部历史/当前模块必须在后端根指引中可定位。 [xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw foreach ($module in $pom.project.modules.module) { @@ -189,7 +231,15 @@ $criticalPaths = @( 'apps/admin-web/src/modules/overseas-warehouse/pages/DispatchDetailView.vue', 'apps/admin-web/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue', 'apps/admin-web/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue', + 'apps/admin-web/src/modules/container-loading/pages/ContainerDetailView.vue', + 'apps/admin-web/src/modules/container-loading/components/ContainerOverviewPanel.vue', 'apps/admin-web/src/modules/container-loading/api/platformContainerLoading.ts', + 'apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts', + 'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.vue', + 'apps/admin-web/src/core/routing/navigationRecovery.ts', + 'apps/admin-web/src/core/routing/navigationRecovery.test.ts', + 'apps/admin-web/src/modules/overseas-warehouse/api/outbounds.ts', + 'apps/admin-web/src/modules/overseas-warehouse/pages/OutboundsView.vue', 'apps/admin-web/src/modules/iam/pages/InternalUsersView.vue', 'apps/admin-web/src/modules/iam/api/platformIam.ts', 'apps/client-web/src/api/overseasHttp.ts', @@ -199,6 +249,9 @@ $criticalPaths = @( 'apps/admin-web/src/modules/overseas-business/api/platformOverseasBusiness.ts', 'apps/admin-web/src/modules/overseas-master-data/api/overseasMasterData.ts', 'apps/pda-uniapp/pages/tasks/tasks.vue', + 'apps/pda-uniapp/config/runtime.js', + 'apps/pda-uniapp/.env.example', + 'apps/pda-uniapp/vite.config.js', 'apps/pda-uniapp/utils/evidence-upload-queue.js', 'apps/pda-overseas-uniapp/package.json', 'apps/pda-overseas-uniapp/src/pages.json', @@ -210,6 +263,8 @@ $criticalPaths = @( 'apps/pda-overseas-uniapp/src/api/receiving-adapter.js', 'apps/pda-overseas-uniapp/src/api/overseas.js', 'apps/pda-overseas-uniapp/src/utils/scan-broadcast.js', + 'apps/pda-overseas-uniapp/src/manifest.json', + 'apps/pda-overseas-uniapp/tests/app-icons.test.mjs', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java', @@ -221,6 +276,9 @@ $criticalPaths = @( 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java', + 'baiyue-firstleg/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java', @@ -243,6 +301,9 @@ $criticalPaths = @( 'baiyue-firstleg/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java', 'baiyue-firstleg/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java', 'baiyue-firstleg/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java', + 'baiyue-firstleg/common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java', + 'baiyue-firstleg/common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java', + 'baiyue-firstleg/common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java', 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java', 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java', @@ -312,6 +373,7 @@ $indexedAgentFiles = @( (Join-Path $backendRoot 'lastleg-service/AGENTS.md'), (Join-Path $backendRoot 'iam/AGENTS.md'), (Join-Path $backendRoot 'common-security/AGENTS.md'), + (Join-Path $backendRoot 'common-web/AGENTS.md'), (Join-Path $backendRoot 'common-openapi/AGENTS.md'), (Join-Path $backendRoot 'warehouse-overseas-service/AGENTS.md'), (Join-Path $backendRoot 'event-bridge-service/AGENTS.md'), @@ -353,5 +415,5 @@ if ($failures.Count -gt 0) { exit 1 } -Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、Gateway 覆盖、PDA 注册页面与能力开关、Maven 模块、Lastleg 海外安全投影映射、关键入口和排除目录。" -ForegroundColor Green +Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、四期范围漂移、订舱候选与柜量语义、Gateway 覆盖、PDA 页面/运行配置/能力开关、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green exit 0 diff --git a/scripts/client-phase2-test-env-acceptance.mjs b/scripts/client-phase2-test-env-acceptance.mjs index b3981a78a..1815ee4d5 100644 --- a/scripts/client-phase2-test-env-acceptance.mjs +++ b/scripts/client-phase2-test-env-acceptance.mjs @@ -19,7 +19,7 @@ const args = parseArgs(process.argv.slice(2)) const gateway = trimSlash(args.gateway ?? process.env.CLIENT_PHASE2_TEST_GATEWAY ?? 'http://8.163.30.128:9000') const clientUsername = args['client-username'] ?? process.env.CLIENT_PHASE2_USERNAME - ?? await promptText('二期客户端账号', 'merchant_admin') + ?? await promptText('二期客户端账号(必须由正式注册/建档产生)', '') const clientPassword = args['client-password'] ?? process.env.CLIENT_PHASE2_PASSWORD ?? await promptSecret('二期客户端密码') const platformUsername = args['platform-username'] ?? process.env.PLATFORM_USERNAME @@ -30,8 +30,8 @@ const forcedOrderId = args['order-id'] ?? process.env.CLIENT_PHASE2_ORDER_ID ?? const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_PHASE2_REPORT_DIR ?? 'reports') const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_PHASE2_TIMEOUT_MS ?? 30_000) -if (!clientPassword || !platformPassword) { - console.error('Client and platform passwords are required') +if (!clientUsername || !clientPassword || !platformPassword) { + console.error('An onboarded Client account and platform credentials are required') process.exit(2) } if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) { diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index 034057e97..b7afa8553 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -24,14 +24,14 @@ const platformUsername = args['platform-username'] ?? process.env.PLATFORM_USERN const platformPassword = args['platform-password'] ?? process.env.PLATFORM_PASSWORD ?? await promptSecret('平台审核密码') const existingClientUsername = args['existing-client-username'] ?? process.env.EXISTING_CLIENT_USERNAME - ?? await promptText('跨客户隔离测试账号', 'merchant_admin') + ?? await promptText('跨客户隔离测试账号(必须由正式注册/建档产生)', '') const existingClientPassword = args['existing-client-password'] ?? process.env.EXISTING_CLIENT_PASSWORD ?? await promptSecret('跨客户隔离测试密码') const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_TEST_REPORT_DIR ?? 'reports') const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000) -if (!platformPassword) { - console.error('Platform password is required') +if (!platformPassword || !existingClientUsername || !existingClientPassword) { + console.error('Platform password and an independently onboarded client account are required') process.exit(2) } if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) { diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index 03fdfdd11..11b3dfb67 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -56,7 +56,7 @@ test('entry accepts a separate overseas Gateway without exposing token state', ( assert.match(source, /async requestOverseas\(/) assert.match(source, /async requestOverseasBinary\(/) assert.match(source, /async requestOverseasMultipart\(/) - assert.match(source, /forbiddenValues = \[this\.options\.password, this\.token, this\.refreshToken, this\.overseasToken\]/) + assert.match(source, /forbiddenValues = \[this\.options\.password, this\.options\.merchantPassword,[\s\S]*this\.token, this\.refreshToken, this\.overseasToken\]/) }) test('dual-token refresh replaces the whole session only after a complete response', async () => { From 8eeb52ba77e85c34671fb558ebe7598afd22c9da Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 12:05:42 +0800 Subject: [PATCH 28/42] feat(firstleg): confirm domestic departure by container --- .../PlatformOutboundDispatchController.java | 30 +- .../dispatch/dto/OutboundDispatchDtos.java | 23 +- .../mapper/OutboundDispatchMapper.java | 5 + .../mapper/OutboundDispatchReadMapper.java | 36 ++- .../OutboundDispatchRepository.java | 17 +- .../service/OutboundDispatchService.java | 268 ++++++++++++------ .../openapi/phase3-outbound-dispatch-v1.yaml | 141 +++++---- ...utboundDispatchReadMapperContractTest.java | 35 ++- .../dispatch/OutboundDispatchServiceTest.java | 241 +++++++++------- .../OutboundDispatchSqlContractTest.java | 14 +- ...utboundDispatchControllerContractTest.java | 65 +++++ 11 files changed, 583 insertions(+), 292 deletions(-) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/PlatformOutboundDispatchControllerContractTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java index 36f8e7bd7..73e73b70c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java @@ -28,33 +28,39 @@ public class PlatformOutboundDispatchController { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.list(query)); } - @GetMapping("/detail") + @GetMapping("/{containerBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:view')") - public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(query)); + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @jakarta.validation.constraints.Positive long containerBookingId) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(containerBookingId)); } - @GetMapping("/check-gate") + @GetMapping("/{containerBookingId}/gate") @PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:check')") - public ApiResponse checkGate(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.checkGate(query)); + public ApiResponse checkGate(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @jakarta.validation.constraints.Positive long containerBookingId) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.checkGate(containerBookingId)); } - @PostMapping("/confirm-dispatch") + @PostMapping("/{containerBookingId}/confirm") @PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:confirm')") - @BusinessAudit(event="OUTBOUND_CONFIRM_DISPATCH",resourceType="INBOUND",resourceId="#request.inboundId",description="确认国内出仓") + @BusinessAudit(event="OUTBOUND_CONFIRM_DISPATCH",resourceType="CONTAINER_BOOKING",resourceId="#containerBookingId",description="柜级确认国内出仓") public ApiResponse confirm(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @jakarta.validation.constraints.Positive long containerBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirm(user.userId(), user.username(), key, request)); + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirm( + containerBookingId, user.userId(), user.username(), key, request)); } - @PostMapping("/reverse-dispatch") + @PostMapping("/{containerBookingId}/reverse") @PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:reverse')") - @BusinessAudit(event="OUTBOUND_REVERSE_DISPATCH",resourceType="INBOUND",resourceId="#request.inboundId",description="撤销国内出仓") + @BusinessAudit(event="OUTBOUND_REVERSE_DISPATCH",resourceType="CONTAINER_BOOKING",resourceId="#containerBookingId",description="柜级撤销国内出仓") public ApiResponse reverse(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @jakarta.validation.constraints.Positive long containerBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.reverse(user.userId(), user.username(), key, request)); + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.reverse( + containerBookingId, user.userId(), user.username(), key, request)); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java index 38c848ca6..b7c8dc1b6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.outbound.dispatch.dto; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import java.time.LocalDateTime; @@ -10,21 +11,27 @@ import java.util.List; public final class OutboundDispatchDtos { private OutboundDispatchDtos() {} - public record Query(@Positive Long orderId, @Positive Long inboundId, @Positive Long containerBookingId) {} + public record Query(@NotNull @Positive Long containerBookingId) {} - public record ActionRequest(@Positive Long inboundId, @Positive Long containerBookingId, - @PositiveOrZero int expectedVersion, @Size(max = 500) String remark) {} + public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion, + @Size(max = 500) String remark) {} public record GateItem(String code, String name, boolean passed, String actual, String requirement) {} public record GateResult(boolean passed, List items, LocalDateTime checkedAt) {} - public record Aggregate(Long orderId, String orderNo, Long inboundId, String inboundCode, Long tenantId, - Long customerId, String customerName, String inboundStatus, - Long containerBookingId, String cabinetNo, String containerNo, + public record InboundBinding(Long orderId, String orderNo, Long inboundId, String inboundCode, + Long tenantId, Long customerId, String customerName, + String inboundStatus, Long pickTaskId, String pickTaskStatus) {} + + public record InboundGate(Long inboundId, String inboundCode, GateResult gate) {} + public record ContainerGateResult(boolean passed, List inbounds, LocalDateTime checkedAt) {} + + public record Aggregate(Long containerBookingId, String cabinetNo, String containerNo, Long loadingPlanId, String loadingPlanStatus, - Long pickTaskId, String pickTaskStatus, Long oceanBookingId, String oceanBookingStatus, + Long oceanBookingId, String oceanBookingStatus, Long truckAppointmentId, String truckAppointmentStatus, - String dispatchStatus, Integer version, GateResult gate) {} + String dispatchStatus, Integer version, List inbounds, + ContainerGateResult gate) {} public record TimelineItem(String module, String action, String fromStatus, String toStatus, Long operatorId, String operatorName, String remark, LocalDateTime occurredAt) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java index 09fb7a8a7..9bbcf97e8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java @@ -14,6 +14,11 @@ public interface OutboundDispatchMapper extends BaseMapper SELECT DISTINCT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId, inbound.inbound_code inboundCode,inbound.tenant_id tenantId,inbound.customer_id customerId, inbound.customer_name customerName,inbound.process_status inboundStatus, @@ -22,36 +21,46 @@ public interface OutboundDispatchReadMapper { JOIN fl_container_booking booking ON booking.id=plan.container_booking_id JOIN fl_inbound_order inbound ON inbound.id=item.inbound_id AND inbound.tenant_id=item.account_id AND inbound.deleted=0 WHERE item.deleted=0 - AND inbound.order_id=#{orderId} - AND inbound.id=#{inboundId} - AND booking.id=#{containerBookingId} + AND booking.id=#{containerBookingId} ORDER BY inbound.id,booking.id - """) - List bindings(@Param("orderId") Long orderId, @Param("inboundId") Long inboundId, - @Param("containerBookingId") Long containerBookingId); + List bindings(@Param("containerBookingId") long containerBookingId); + + @Select(""" + SELECT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId, + inbound.inbound_code inboundCode,inbound.tenant_id tenantId,inbound.customer_id customerId, + inbound.customer_name customerName,inbound.process_status inboundStatus, + booking.id containerBookingId,booking.cabinet_no cabinetNo,booking.container_no containerNo, + plan.id loadingPlanId,plan.plan_status loadingPlanStatus + FROM fl_loading_plan_item item + JOIN fl_loading_plan plan ON plan.id=item.loading_plan_id + JOIN fl_container_booking booking ON booking.id=plan.container_booking_id + JOIN fl_inbound_order inbound ON inbound.id=item.inbound_id AND inbound.tenant_id=item.account_id AND inbound.deleted=0 + WHERE item.deleted=0 AND booking.id=#{containerBookingId} + ORDER BY inbound.id,booking.id + FOR UPDATE + """) + List bindingsForUpdate(@Param("containerBookingId") long containerBookingId); @Select("SELECT id,task_status status FROM fl_loading_pick_task WHERE loading_plan_id=#{planId} " + "ORDER BY started_at DESC,id DESC LIMIT 1") IdStatus pick(@Param("planId") long planId); @Select(""" - SELECT booking.id,booking.status + SELECT booking.id,booking.status,booking.customer_id customerId FROM fl_ocean_booking_container_allocation allocation JOIN fl_ocean_booking booking ON booking.id=allocation.ocean_booking_id - WHERE allocation.container_booking_id=#{containerBookingId} AND booking.customer_id=#{tenantId} + WHERE allocation.container_booking_id=#{containerBookingId} ORDER BY booking.updated_at DESC,booking.id DESC LIMIT 1 """) - IdStatus ocean(@Param("containerBookingId") long containerBookingId, @Param("tenantId") long tenantId); + OceanBinding ocean(@Param("containerBookingId") long containerBookingId); @Select(""" SELECT id,status FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND loading_plan_id=#{loadingPlanId} - AND customer_id=#{tenantId} ORDER BY updated_at DESC,id DESC LIMIT 1 """) - IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId, - @Param("tenantId") long tenantId); + IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId); @Select(""" SELECT COUNT(DISTINCT CASE WHEN status='ACTIVE' AND file_id IS NOT NULL AND evidence_type IN @@ -122,5 +131,6 @@ public interface OutboundDispatchReadMapper { Long customerId, String customerName, String inboundStatus, Long containerBookingId, String cabinetNo, String containerNo, Long loadingPlanId, String loadingPlanStatus) {} record IdStatus(Long id, String status) {} + record OceanBinding(Long id, String status, Long customerId) {} record Evidence(Integer evidenceCount, Integer validSeal) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java index a0f87004a..3ad889820 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java @@ -1,7 +1,6 @@ package com.baiyue.firstleg.outbound.dispatch.repository; import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.Query; import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.TimelineItem; import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchEntity; import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchHistoryEntity; @@ -22,15 +21,14 @@ public class OutboundDispatchRepository { @Resource private OutboundDispatchOperationMapper operations; @Resource private OutboundDispatchReadMapper reads; - public List bindings(Query query) { - return reads.bindings(query.orderId(), query.inboundId(), query.containerBookingId()); + public List bindings(long containerId) { return reads.bindings(containerId); } + public List lockBindings(long containerId) { + return reads.bindingsForUpdate(containerId); } public OutboundDispatchReadMapper.IdStatus pick(long planId) { return reads.pick(planId); } - public OutboundDispatchReadMapper.IdStatus ocean(long containerId, long tenantId) { - return reads.ocean(containerId, tenantId); - } - public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId, long tenantId) { - return reads.truck(containerId, planId, tenantId); + public OutboundDispatchReadMapper.OceanBinding ocean(long containerId) { return reads.ocean(containerId); } + public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId) { + return reads.truck(containerId, planId); } public OutboundDispatchReadMapper.Evidence evidence(long tenantId, long inboundId) { return reads.evidence(tenantId, inboundId); @@ -38,6 +36,9 @@ public class OutboundDispatchRepository { public OutboundDispatchEntity dispatch(long tenantId, long inboundId, long containerId) { return dispatches.find(tenantId, inboundId, containerId); } + public OutboundDispatchEntity dispatchLocked(long tenantId, long inboundId, long containerId) { + return dispatches.findForUpdate(tenantId, inboundId, containerId); + } public void insert(OutboundDispatchEntity value) { dispatches.insert(value); } public int confirm(long id, int version, long operatorId, String operatorName, String remark) { return dispatches.confirm(id, version, operatorId, operatorName, remark); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java index fad3762c1..4192ec0b2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java @@ -51,93 +51,132 @@ public class OutboundDispatchService { } public List list(Query query) { - requireSelector(query); - return repository.bindings(query).stream().map(this::aggregate).toList(); + return List.of(aggregate(bindings(query.containerBookingId()))); } - public Detail detail(Query query) { - OutboundDispatchReadMapper.Binding binding = single(query); - Aggregate aggregate = aggregate(binding); - return new Detail(aggregate, repository.timeline(binding, aggregate.oceanBookingId(), aggregate.truckAppointmentId())); + public Detail detail(long containerBookingId) { + List bindings = bindings(containerBookingId); + Aggregate aggregate = aggregate(bindings); + List timeline = bindings.stream() + .flatMap(binding -> repository.timeline(binding, aggregate.oceanBookingId(), aggregate.truckAppointmentId()).stream()) + .distinct() + .sorted(Comparator.comparing(TimelineItem::occurredAt, + Comparator.nullsLast(Comparator.naturalOrder())).thenComparing(TimelineItem::module)) + .toList(); + return new Detail(aggregate, timeline); } - public GateResult checkGate(Query query) { return aggregate(single(query)).gate(); } + public ContainerGateResult checkGate(long containerBookingId) { + return aggregate(bindings(containerBookingId)).gate(); + } @Transactional - public Detail confirm(long operatorId, String operatorName, String key, ActionRequest request) { + public Detail confirm(long containerBookingId, long operatorId, String operatorName, + String key, ActionRequest request) { validateKey(key); - Query query = new Query(null, request.inboundId(), request.containerBookingId()); - OutboundDispatchReadMapper.Binding binding = single(query); - String hash = hash("CONFIRM", request); - Detail replay = replay(operatorId, "CONFIRM_DISPATCH", key, hash, query); + String hash = hash("CONFIRM", containerBookingId, request); + Detail replay = replay(containerBookingId, operatorId, "CONFIRM_DISPATCH", key, hash); if (replay != null) return replay; - Aggregate before = aggregate(binding); - if (!before.gate().passed()) throw conflict("国内出仓门禁未通过"); - OutboundDispatchEntity state = state(binding); - if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent(); - if (OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(state.getStatus())) { - throw conflict("当前出仓单已确认派车"); + List bindings = lockedBindings(containerBookingId); + Aggregate before = aggregate(bindings); + if (!before.gate().passed()) throw conflict("柜内全部入库绑定尚未通过国内出仓门禁"); + List states = bindings.stream().map(this::stateLocked).toList(); + StateView visible = visibleState(states); + if (!Objects.equals(visible.version(), request.expectedVersion())) throw concurrent(); + if (OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(visible.status())) { + throw conflict("当前货柜已确认离仓"); } - if (repository.confirm(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { - throw concurrent(); + for (OutboundDispatchEntity state : states) { + if (repository.confirm(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { + throw concurrent(); + } } - history(state, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(), + OutboundDispatchEntity representative = states.get(0); + history(representative, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(), OutboundDispatchStatus.WAITING_FOR_LOADING.name(), request.remark(), operatorId, operatorName); + + OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId); if (carrierSchedules != null) { - carrierSchedules.ensureFromOutboundConfirm(binding.tenantId(), before.oceanBookingId(), - binding.containerBookingId(), operatorId, operatorName); + if (ocean == null || ocean.id() == null || ocean.customerId() == null) { + throw conflict("当前货柜未关联有效订舱,无法创建船司查询任务"); + } + carrierSchedules.ensureFromOutboundConfirm(ocean.customerId(), ocean.id(), containerBookingId, + operatorId, operatorName); } - if (repository.completeOperation(state.getId(), operatorId, "CONFIRM_DISPATCH", key) != 1) throw concurrent(); - return detail(query); + if (repository.completeOperation(representative.getId(), operatorId, "CONFIRM_DISPATCH", key) != 1) { + throw concurrent(); + } + return detail(containerBookingId); } @Transactional - public Detail reverse(long operatorId, String operatorName, String key, ActionRequest request) { + public Detail reverse(long containerBookingId, long operatorId, String operatorName, + String key, ActionRequest request) { validateKey(key); - Query query = new Query(null, request.inboundId(), request.containerBookingId()); - OutboundDispatchReadMapper.Binding binding = single(query); - String hash = hash("REVERSE", request); - Detail replay = replay(operatorId, "REVERSE_DISPATCH", key, hash, query); + String hash = hash("REVERSE", containerBookingId, request); + Detail replay = replay(containerBookingId, operatorId, "REVERSE_DISPATCH", key, hash); if (replay != null) return replay; - OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); - if (state == null || !OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(state.getStatus())) { - throw conflict("当前出仓单不在等待装船状态"); + List bindings = lockedBindings(containerBookingId); + List states = bindings.stream().map(this::existingStateLocked).toList(); + StateView visible = visibleState(states); + if (!OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(visible.status())) { + throw conflict("当前货柜不在已确认离仓状态"); } - if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent(); - OutboundDispatchReadMapper.IdStatus ocean = repository.ocean(binding.containerBookingId(), binding.tenantId()); - if (carrierSchedules != null && ocean != null && carrierSchedules.hasOutboundDepartureFact( - binding.tenantId(), ocean.id(), binding.containerBookingId())) { - throw conflict("已产生实际DEPARTED_WAREHOUSE船节点,禁止撤销国内出仓"); + if (!Objects.equals(visible.version(), request.expectedVersion())) throw concurrent(); + OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId); + if (carrierSchedules != null) { + if (ocean == null || ocean.id() == null || ocean.customerId() == null) { + throw conflict("无法核验货柜后续船节点,禁止撤销国内出仓"); + } + if (carrierSchedules.hasOutboundDepartureFact(ocean.customerId(), ocean.id(), containerBookingId)) { + throw conflict("已产生实际DEPARTED_WAREHOUSE船节点,禁止撤销国内出仓"); + } } - if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { + for (OutboundDispatchEntity state : states) { + if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { + throw concurrent(); + } + } + OutboundDispatchEntity representative = states.get(0); + history(representative, "REVERSE_DISPATCH", OutboundDispatchStatus.WAITING_FOR_LOADING.name(), + OutboundDispatchStatus.READY_FOR_DISPATCH.name(), request.remark(), operatorId, operatorName); + if (repository.completeOperation(representative.getId(), operatorId, "REVERSE_DISPATCH", key) != 1) { throw concurrent(); } - history(state, "REVERSE_DISPATCH", OutboundDispatchStatus.WAITING_FOR_LOADING.name(), - OutboundDispatchStatus.READY_FOR_DISPATCH.name(), request.remark(), operatorId, operatorName); - if (repository.completeOperation(state.getId(), operatorId, "REVERSE_DISPATCH", key) != 1) throw concurrent(); - return detail(query); + return detail(containerBookingId); } - private Aggregate aggregate(OutboundDispatchReadMapper.Binding binding) { - var pick = repository.pick(binding.loadingPlanId()); - var ocean = repository.ocean(binding.containerBookingId(), binding.tenantId()); - var truck = repository.truck(binding.containerBookingId(), binding.loadingPlanId(), binding.tenantId()); - var evidence = repository.evidence(binding.tenantId(), binding.inboundId()); - var dispatch = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); - GateResult gate = gate(binding, pick, ocean, truck, evidence); - return new Aggregate(binding.orderId(), binding.orderNo(), binding.inboundId(), binding.inboundCode(), - binding.tenantId(), binding.customerId(), binding.customerName(), binding.inboundStatus(), - binding.containerBookingId(), binding.cabinetNo(), binding.containerNo(), - binding.loadingPlanId(), binding.loadingPlanStatus(), - id(pick), status(pick), id(ocean), status(ocean), id(truck), status(truck), - dispatch == null ? OutboundDispatchStatus.READY_FOR_DISPATCH.name() : dispatch.getStatus(), - dispatch == null ? 0 : dispatch.getVersion(), gate); + private Aggregate aggregate(List bindings) { + OutboundDispatchReadMapper.Binding first = validateContainer(bindings); + var pick = repository.pick(first.loadingPlanId()); + var ocean = repository.ocean(first.containerBookingId()); + var truck = repository.truck(first.containerBookingId(), first.loadingPlanId()); + List inboundGates = new ArrayList<>(); + List inboundDtos = new ArrayList<>(); + List states = new ArrayList<>(); + for (OutboundDispatchReadMapper.Binding binding : bindings) { + var evidence = repository.evidence(binding.tenantId(), binding.inboundId()); + GateResult gate = gate(binding, pick, ocean, truck, evidence); + inboundGates.add(new InboundGate(binding.inboundId(), binding.inboundCode(), gate)); + inboundDtos.add(new InboundBinding(binding.orderId(), binding.orderNo(), binding.inboundId(), + binding.inboundCode(), binding.tenantId(), binding.customerId(), binding.customerName(), + binding.inboundStatus(), id(pick), status(pick))); + OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), + binding.containerBookingId()); + if (state != null) states.add(state); + } + StateView visible = visibleStateForRead(states, bindings.size()); + ContainerGateResult containerGate = new ContainerGateResult( + inboundGates.stream().allMatch(value -> value.gate().passed()), List.copyOf(inboundGates), LocalDateTime.now()); + return new Aggregate(first.containerBookingId(), first.cabinetNo(), first.containerNo(), + first.loadingPlanId(), first.loadingPlanStatus(), id(ocean), status(ocean), id(truck), status(truck), + visible.status(), visible.version(), List.copyOf(inboundDtos), containerGate); } private GateResult gate(OutboundDispatchReadMapper.Binding binding, OutboundDispatchReadMapper.IdStatus pick, - OutboundDispatchReadMapper.IdStatus ocean, OutboundDispatchReadMapper.IdStatus truck, + OutboundDispatchReadMapper.OceanBinding ocean, OutboundDispatchReadMapper.IdStatus truck, OutboundDispatchReadMapper.Evidence evidence) { List items = new ArrayList<>(); items.add(item("INBOUND_LOADED", "入库单装柜完成", "LOADED".equals(binding.inboundStatus()), @@ -174,68 +213,125 @@ public class OutboundDispatchService { return new GateResult(items.stream().allMatch(GateItem::passed), List.copyOf(items), LocalDateTime.now()); } - private Detail replay(long operatorId, String operation, String key, String hash, Query query) { + private Detail replay(long containerBookingId, long operatorId, String operation, String key, String hash) { if (repository.claim(operatorId, operation, key, hash)) return null; var existing = repository.operation(operatorId, operation, key); if (existing == null || !Objects.equals(existing.getRequestHash(), hash)) { throw conflict("Idempotency-Key已用于不同请求"); } if (!"SUCCESS".equals(existing.getStatus())) throw conflict("相同请求正在处理中"); - return detail(query); + return detail(containerBookingId); } - private OutboundDispatchEntity state(OutboundDispatchReadMapper.Binding binding) { - OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); + private OutboundDispatchEntity stateLocked(OutboundDispatchReadMapper.Binding binding) { + OutboundDispatchEntity state = repository.dispatchLocked(binding.tenantId(), binding.inboundId(), + binding.containerBookingId()); if (state != null) return state; state = new OutboundDispatchEntity(); - state.setId(IdWorker.getId()); state.setTenantId(binding.tenantId()); state.setOrderId(binding.orderId()); - state.setInboundId(binding.inboundId()); state.setContainerBookingId(binding.containerBookingId()); - state.setStatus(OutboundDispatchStatus.READY_FOR_DISPATCH.name()); state.setVersion(0); - try { repository.insert(state); } - catch (DuplicateKeyException ex) { - state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); + state.setId(IdWorker.getId()); + state.setTenantId(binding.tenantId()); + state.setOrderId(binding.orderId()); + state.setInboundId(binding.inboundId()); + state.setContainerBookingId(binding.containerBookingId()); + state.setStatus(OutboundDispatchStatus.READY_FOR_DISPATCH.name()); + state.setVersion(0); + try { + repository.insert(state); + } catch (DuplicateKeyException ex) { + state = repository.dispatchLocked(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); if (state == null) throw ex; } return state; } + private OutboundDispatchEntity existingStateLocked(OutboundDispatchReadMapper.Binding binding) { + OutboundDispatchEntity state = repository.dispatchLocked(binding.tenantId(), binding.inboundId(), + binding.containerBookingId()); + if (state == null) throw conflict("当前货柜尚未确认离仓"); + return state; + } + private void history(OutboundDispatchEntity state, String action, String from, String to, String remark, long operatorId, String operatorName) { OutboundDispatchHistoryEntity history = new OutboundDispatchHistoryEntity(); - history.setId(IdWorker.getId()); history.setDispatchId(state.getId()); history.setTenantId(state.getTenantId()); - history.setInboundId(state.getInboundId()); history.setContainerBookingId(state.getContainerBookingId()); - history.setAction(action); history.setFromStatus(from); history.setToStatus(to); history.setRemark(remark); - history.setOperatorId(operatorId); history.setOperatorName(operatorName); repository.history(history); + history.setId(IdWorker.getId()); + history.setDispatchId(state.getId()); + history.setTenantId(state.getTenantId()); + history.setInboundId(state.getInboundId()); + history.setContainerBookingId(state.getContainerBookingId()); + history.setAction(action); + history.setFromStatus(from); + history.setToStatus(to); + history.setRemark(remark); + history.setOperatorId(operatorId); + history.setOperatorName(operatorName); + repository.history(history); } - private OutboundDispatchReadMapper.Binding single(Query query) { - requireSelector(query); - List bindings = repository.bindings(query); - if (bindings.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "未找到匹配的入库单与柜次关联"); - if (bindings.size() != 1) throw new BizException(ResultCode.BAD_REQUEST, "查询条件匹配多个出仓聚合,请同时指定inboundId和containerBookingId"); - return bindings.get(0); + private List bindings(long containerBookingId) { + return normalize(repository.bindings(containerBookingId)); } - private static void requireSelector(Query query) { - if (query == null || query.orderId() == null && query.inboundId() == null && query.containerBookingId() == null) { - throw new BizException(ResultCode.BAD_REQUEST, "orderId、inboundId、containerBookingId至少提供一个"); + private List lockedBindings(long containerBookingId) { + return normalize(repository.lockBindings(containerBookingId)); + } + + private static List normalize(List values) { + if (values == null || values.isEmpty()) { + throw new BizException(ResultCode.NOT_FOUND, "未找到当前货柜的真实入库绑定"); } + LinkedHashMap unique = new LinkedHashMap<>(); + for (OutboundDispatchReadMapper.Binding value : values) unique.putIfAbsent(value.inboundId(), value); + return List.copyOf(unique.values()); } + + private static OutboundDispatchReadMapper.Binding validateContainer(List bindings) { + OutboundDispatchReadMapper.Binding first = bindings.get(0); + boolean inconsistent = bindings.stream().anyMatch(binding -> + !Objects.equals(first.containerBookingId(), binding.containerBookingId()) + || !Objects.equals(first.loadingPlanId(), binding.loadingPlanId())); + if (inconsistent) throw conflict("货柜入库绑定存在跨柜次数据冲突"); + return first; + } + + private static StateView visibleStateForRead(List states, int bindingCount) { + if (states.isEmpty()) return new StateView(OutboundDispatchStatus.READY_FOR_DISPATCH.name(), 0); + if (states.size() != bindingCount) throw conflict("货柜出仓状态不完整,请联系管理员处理"); + return visibleState(states); + } + + private static StateView visibleState(List states) { + if (states.isEmpty()) throw conflict("货柜出仓状态不存在"); + String status = states.get(0).getStatus(); + Integer version = states.get(0).getVersion(); + if (states.stream().anyMatch(value -> !Objects.equals(status, value.getStatus()) + || !Objects.equals(version, value.getVersion()))) { + throw conflict("货柜出仓状态或版本不一致,请联系管理员处理"); + } + return new StateView(status, version); + } + private static void validateKey(String key) { if (key == null || key.isBlank() || key.length() > 128) { throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空且长度不能超过128"); } } - private static String hash(String action, ActionRequest request) { - String source = action + "|" + request.inboundId() + "|" + request.containerBookingId() + "|" - + request.expectedVersion() + "|" + Objects.toString(request.remark(), ""); + + private static String hash(String action, long containerBookingId, ActionRequest request) { + String source = action + "|" + containerBookingId + "|" + request.expectedVersion() + "|" + + Objects.toString(request.remark(), ""); try { byte[] digest = MessageDigest.getInstance("SHA-256").digest(source.getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(digest); - } catch (Exception ex) { throw new IllegalStateException(ex); } + } catch (Exception ex) { + throw new IllegalStateException(ex); + } } + private static Long id(OutboundDispatchReadMapper.IdStatus value) { return value == null ? null : value.id(); } + private static Long id(OutboundDispatchReadMapper.OceanBinding value) { return value == null ? null : value.id(); } private static String status(OutboundDispatchReadMapper.IdStatus value) { return value == null ? null : value.status(); } + private static String status(OutboundDispatchReadMapper.OceanBinding value) { return value == null ? null : value.status(); } private static GateItem item(String code, String name, boolean pass, String actual, String requirement) { return new GateItem(code, name, pass, actual, requirement); } @@ -244,4 +340,6 @@ public class OutboundDispatchService { } private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); } + + private record StateView(String status, Integer version) {} } diff --git a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml index 8553a2767..b590dfc68 100644 --- a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml @@ -1,85 +1,116 @@ openapi: 3.0.3 info: - title: Phase 3 Domestic Outbound Dispatch API - version: 1.0.0 + title: Phase 3 Container-level Domestic Outbound Dispatch API + version: 2.0.0 paths: /api/platform/v1/first-leg/outbound-dispatches: get: - summary: Query outbound aggregates by order, inbound or container + summary: Query the outbound aggregate for one container booking + description: Returns one container-level aggregate containing every real inbound binding. No per-inbound partial outbound aggregate is exposed. + x-permission: platform:firstleg:outbound-dispatch:list parameters: - - {$ref: '#/components/parameters/OrderId'} - - {$ref: '#/components/parameters/InboundId'} - - {$ref: '#/components/parameters/ContainerBookingId'} + - {$ref: '#/components/parameters/ContainerBookingIdQuery'} responses: '200': - description: Business envelope containing matching aggregates, each with stable cabinetNo and real containerNo + description: Business envelope containing a single matching aggregate in a list content: application/json: schema: type: object properties: data: {type: array, items: {$ref: '#/components/schemas/Aggregate'}} - /api/platform/v1/first-leg/outbound-dispatches/detail: + /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}: get: - summary: Query one aggregate and its unified timeline + summary: Query one container-level aggregate and unified timeline + x-permission: platform:firstleg:outbound-dispatch:view parameters: - - {$ref: '#/components/parameters/OrderId'} - - {$ref: '#/components/parameters/InboundId'} - - {$ref: '#/components/parameters/ContainerBookingId'} + - {$ref: '#/components/parameters/ContainerBookingIdPath'} responses: '200': - description: Business envelope containing aggregate detail + description: Business envelope containing all inbound bindings and one visible dispatch version content: application/json: schema: type: object properties: - data: - type: object - properties: - aggregate: {$ref: '#/components/schemas/Aggregate'} - /api/platform/v1/first-leg/outbound-dispatches/check-gate: + data: {$ref: '#/components/schemas/Detail'} + /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/gate: get: - summary: Evaluate the fail-closed domestic outbound gate + summary: Evaluate every inbound binding in the fail-closed container outbound gate + x-permission: platform:firstleg:outbound-dispatch:check parameters: - - {$ref: '#/components/parameters/OrderId'} - - {$ref: '#/components/parameters/InboundId'} - - {$ref: '#/components/parameters/ContainerBookingId'} + - {$ref: '#/components/parameters/ContainerBookingIdPath'} responses: - '200': {description: Business envelope containing every gate item} - /api/platform/v1/first-leg/outbound-dispatches/confirm-dispatch: + '200': {description: Business envelope containing the container gate and per-inbound gate results} + /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/confirm: post: - summary: Confirm domestic dispatch and enter WAITING_FOR_LOADING + summary: Atomically confirm domestic departure for the entire container + description: Locks every real inbound binding, requires every gate to pass, advances all technical rows in one transaction, records one visible history, and creates the CarrierScheduleQuery/DEPARTED_WAREHOUSE fact once. No partial success is possible. + x-permission: platform:firstleg:outbound-dispatch:confirm parameters: + - {$ref: '#/components/parameters/ContainerBookingIdPath'} - {$ref: '#/components/parameters/IdempotencyKey'} requestBody: required: true content: application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}} responses: - '200': {description: Business envelope; gate or version failures use business error code} - /api/platform/v1/first-leg/outbound-dispatches/reverse-dispatch: + '200': {description: Same key and same payload replays the single container-level result; stale version or changed payload returns A0409} + /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/reverse: post: - summary: Reverse WAITING_FOR_LOADING only before maritime tracking starts - description: Once an actual DEPARTED_WAREHOUSE node exists, reversal is rejected with business code A0409 so READY_FOR_DISPATCH cannot coexist with maritime tracking. + summary: Atomically reverse the entire container before any actual shipping node exists + description: Once an actual DEPARTED_WAREHOUSE node exists, reversal is rejected with business code A0409 so an outbound fact can never coexist with READY_FOR_DISPATCH. + x-permission: platform:firstleg:outbound-dispatch:reverse parameters: + - {$ref: '#/components/parameters/ContainerBookingIdPath'} - {$ref: '#/components/parameters/IdempotencyKey'} requestBody: required: true content: application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}} responses: - '200': {description: Business envelope containing reverted aggregate} + '200': {description: Business envelope containing the reverted container aggregate} components: parameters: - OrderId: {name: orderId, in: query, schema: {type: integer, format: int64, minimum: 1}} - InboundId: {name: inboundId, in: query, schema: {type: integer, format: int64, minimum: 1}} - ContainerBookingId: {name: containerBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}} - IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} + ContainerBookingIdQuery: + name: containerBookingId + in: query + required: true + schema: {type: integer, format: int64, minimum: 1} + ContainerBookingIdPath: + name: containerBookingId + in: path + required: true + schema: {type: integer, format: int64, minimum: 1} + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: {type: string, maxLength: 128} schemas: Aggregate: type: object - required: [orderId, orderNo, inboundId, inboundCode, tenantId, inboundStatus, containerBookingId, cabinetNo, loadingPlanId, loadingPlanStatus, dispatchStatus, version, gate] + required: [containerBookingId, cabinetNo, loadingPlanId, loadingPlanStatus, dispatchStatus, version, inbounds, gate] + properties: + containerBookingId: {type: integer, format: int64} + cabinetNo: {type: string, description: Stable cabinet number} + containerNo: {type: string, nullable: true, description: Real container number} + loadingPlanId: {type: integer, format: int64} + loadingPlanStatus: {type: string} + oceanBookingId: {type: integer, format: int64, nullable: true} + oceanBookingStatus: {type: string, nullable: true} + truckAppointmentId: {type: integer, format: int64, nullable: true} + truckAppointmentStatus: {type: string, nullable: true} + dispatchStatus: {type: string, description: One visible container-level status} + version: {type: integer, minimum: 0, description: One optimistic-lock version shared by all technical inbound rows} + inbounds: + type: array + minItems: 1 + items: {$ref: '#/components/schemas/InboundBinding'} + gate: {$ref: '#/components/schemas/ContainerGateResult'} + InboundBinding: + type: object + required: [orderId, orderNo, inboundId, inboundCode, tenantId, inboundStatus] properties: orderId: {type: integer, format: int64} orderNo: {type: string} @@ -89,25 +120,35 @@ components: customerId: {type: integer, format: int64, nullable: true} customerName: {type: string, nullable: true} inboundStatus: {type: string} - containerBookingId: {type: integer, format: int64} - cabinetNo: {type: string, description: 稳定柜次号} - containerNo: {type: string, nullable: true, description: 真实集装箱号} - loadingPlanId: {type: integer, format: int64} - loadingPlanStatus: {type: string} pickTaskId: {type: integer, format: int64, nullable: true} pickTaskStatus: {type: string, nullable: true} - oceanBookingId: {type: integer, format: int64, nullable: true} - oceanBookingStatus: {type: string, nullable: true} - truckAppointmentId: {type: integer, format: int64, nullable: true} - truckAppointmentStatus: {type: string, nullable: true} - dispatchStatus: {type: string} - version: {type: integer, minimum: 0} - gate: {type: object} + ContainerGateResult: + type: object + required: [passed, inbounds, checkedAt] + properties: + passed: {type: boolean} + inbounds: + type: array + items: + type: object + required: [inboundId, inboundCode, gate] + properties: + inboundId: {type: integer, format: int64} + inboundCode: {type: string} + gate: {type: object} + checkedAt: {type: string, format: date-time} + Detail: + type: object + required: [aggregate, timeline] + properties: + aggregate: {$ref: '#/components/schemas/Aggregate'} + timeline: + type: array + description: Unified immutable facts; a container confirm or reverse contributes one visible history item. + items: {type: object} ActionRequest: type: object - required: [inboundId, containerBookingId, expectedVersion] + required: [expectedVersion] properties: - inboundId: {type: integer, format: int64, minimum: 1} - containerBookingId: {type: integer, format: int64, minimum: 1} expectedVersion: {type: integer, minimum: 0} remark: {type: string, maxLength: 500} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java index d4f2eebfd..2a15df4bf 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java @@ -10,9 +10,25 @@ class OutboundDispatchReadMapperContractTest { @Test void bindingProjectsStableCabinetAndRealContainerNumbersSeparately() throws Exception { String sql = String.join("\n", OutboundDispatchReadMapper.class - .getMethod("bindings", Long.class, Long.class, Long.class) + .getMethod("bindings", long.class) .getAnnotation(Select.class).value()); - assertThat(sql).contains("booking.cabinet_no cabinetNo", "booking.container_no containerNo"); + assertThat(sql) + .contains("booking.cabinet_no cabinetNo", "booking.container_no containerNo") + .contains("booking.id=#{containerBookingId}") + .doesNotContain("#{orderId}", "#{inboundId}"); + } + + @Test + void actionQueryLocksEveryRealInboundBindingForTheContainer() throws Exception { + String sql = String.join("\n", OutboundDispatchReadMapper.class + .getMethod("bindingsForUpdate", long.class) + .getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("booking.id=#{containerBookingId}") + .contains("item.deleted=0", "inbound.deleted=0") + .contains("FOR UPDATE") + .doesNotContain("LIMIT 1"); } @Test @@ -36,14 +52,19 @@ class OutboundDispatchReadMapperContractTest { } @Test - void oceanAndTruckQueriesUseTenantIsolationKey() throws Exception { - String oceanSql = OutboundDispatchReadMapper.class.getMethod("ocean", long.class, long.class) + void oceanAndTruckQueriesAreResolvedOnceFromTheContainerRoot() throws Exception { + String oceanSql = OutboundDispatchReadMapper.class.getMethod("ocean", long.class) .getAnnotation(Select.class).value()[0]; - String truckSql = OutboundDispatchReadMapper.class.getMethod("truck", long.class, long.class, long.class) + String truckSql = OutboundDispatchReadMapper.class.getMethod("truck", long.class, long.class) .getAnnotation(Select.class).value()[0]; - assertThat(oceanSql).contains("booking.customer_id=#{tenantId}").doesNotContain("#{customerId}"); - assertThat(truckSql).contains("customer_id=#{tenantId}").doesNotContain("#{customerId}"); + assertThat(oceanSql) + .contains("allocation.container_booking_id=#{containerBookingId}") + .contains("booking.customer_id customerId") + .doesNotContain("#{tenantId}"); + assertThat(truckSql) + .contains("container_booking_id=#{containerBookingId}", "loading_plan_id=#{loadingPlanId}") + .doesNotContain("#{tenantId}"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java index 8c4bf583f..e85d69d17 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java @@ -9,9 +9,11 @@ import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper; import com.baiyue.firstleg.outbound.dispatch.repository.OutboundDispatchRepository; import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService; import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -19,159 +21,191 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class OutboundDispatchServiceTest { - private final OutboundDispatchRepository repository = mock(OutboundDispatchRepository.class); + private OutboundDispatchRepository repository; + + @BeforeEach + void setUp() { + repository = mock(OutboundDispatchRepository.class); + when(repository.timeline(any(), any(), any())).thenReturn(List.of()); + } @Test - void gateFailsClosedWhenCustomsAndScheduleModulesAreUnavailable() { - OutboundDispatchService service = new OutboundDispatchService(repository, List.of()); - stubReadyChain(); + void gateAggregatesEveryRealInboundAndFailsClosedWhenOneBindingFails() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1)); - GateResult result = service.checkGate(new Query(null, 11L, 22L)); + ContainerGateResult result = service.checkGate(22L); assertThat(result.passed()).isFalse(); - assertThat(result.items()).filteredOn(item -> !item.passed()) - .extracting(GateItem::code).containsExactlyInAnyOrder("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE"); + assertThat(result.inbounds()).hasSize(2); + assertThat(result.inbounds().get(1).gate().items()) + .filteredOn(item -> "LOADING_EVIDENCE_COMPLETE".equals(item.code())) + .extracting(GateItem::passed).containsExactly(false); } @Test - void aggregateUsesTenantKeyWhenCustomerEntityIdDiffers() { - OutboundDispatchService service = new OutboundDispatchService(repository, List.of()); - stubReadyChain(); + void detailReturnsOneVersionAndAllInboundBindings() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + OutboundDispatchEntity first = state(31L, 11L, "WAITING_FOR_LOADING", 3); + OutboundDispatchEntity second = state(32L, 12L, "WAITING_FOR_LOADING", 3); + when(repository.dispatch(7L, 11L, 22L)).thenReturn(first); + when(repository.dispatch(7L, 12L, 22L)).thenReturn(second); - service.checkGate(new Query(null, 11L, 22L)); + Detail detail = service.detail(22L); - verify(repository).ocean(22L, 7L); - verify(repository).truck(22L, 44L, 7L); - verify(repository, never()).ocean(22L, 8L); - verify(repository, never()).truck(22L, 44L, 8L); + assertThat(detail.aggregate().inbounds()).extracting(InboundBinding::inboundId).containsExactly(11L, 12L); + assertThat(detail.aggregate().dispatchStatus()).isEqualTo("WAITING_FOR_LOADING"); + assertThat(detail.aggregate().version()).isEqualTo(3); } @Test - void confirmAdvancesDispatchWithoutMutatingLoadedInbound() { - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE"))); - stubReadyChain(); - OutboundDispatchEntity state = new OutboundDispatchEntity(); - state.setId(33L); state.setTenantId(7L); state.setOrderId(10L); state.setInboundId(11L); - state.setContainerBookingId(22L); state.setStatus("READY_FOR_DISPATCH"); state.setVersion(0); - when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); - when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-1"), anyString())).thenReturn(true); - when(repository.confirm(33L, 0, 9L, "admin", "ok")).thenReturn(1); - when(repository.completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1")).thenReturn(1); - when(repository.timeline(any(), any(), any())).thenReturn(List.of()); - - service.confirm(9L, "admin", "confirm-1", new ActionRequest(11L, 22L, 0, "ok")); - - verify(repository).confirm(33L, 0, 9L, "admin", "ok"); - verify(repository).history(argThat(history -> "CONFIRM_DISPATCH".equals(history.getAction()) - && "WAITING_FOR_LOADING".equals(history.getToStatus()))); - verify(repository).completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1"); - } - - @Test - void confirmCreatesCanonicalCarrierScheduleTaskInTheSameTransaction() { + void confirmLocksAndAdvancesAllBindingsButWritesOneHistoryAndOneShippingFact() { CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class); - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")), carrierSchedules); - stubReadyChain(); - OutboundDispatchEntity state = new OutboundDispatchEntity(); - state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); - state.setContainerBookingId(22L); state.setStatus("READY_FOR_DISPATCH"); state.setVersion(0); - when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); - when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-carrier"), anyString())).thenReturn(true); - when(repository.confirm(33L, 0, 9L, "admin", null)).thenReturn(1); - when(repository.completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-carrier")).thenReturn(1); - when(repository.timeline(any(), any(), any())).thenReturn(List.of()); + OutboundDispatchService service = service(carrierSchedules); + stubContainer(); + when(repository.lockBindings(22L)).thenReturn(bindings()); + OutboundDispatchEntity first = state(31L, 11L, "READY_FOR_DISPATCH", 0); + OutboundDispatchEntity second = state(32L, 12L, "READY_FOR_DISPATCH", 0); + when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(first); + when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(second); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-container"), anyString())).thenReturn(true); + when(repository.confirm(anyLong(), eq(0), eq(9L), eq("admin"), eq("ok"))).thenReturn(1); + when(repository.completeOperation(31L, 9L, "CONFIRM_DISPATCH", "confirm-container")).thenReturn(1); - service.confirm(9L, "admin", "confirm-carrier", new ActionRequest(11L, 22L, 0, null)); + service.confirm(22L, 9L, "admin", "confirm-container", new ActionRequest(0, "ok")); - verify(carrierSchedules).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin"); + verify(repository).lockBindings(22L); + verify(repository).confirm(31L, 0, 9L, "admin", "ok"); + verify(repository).confirm(32L, 0, 9L, "admin", "ok"); + verify(repository, times(1)).history(argThat(value -> "CONFIRM_DISPATCH".equals(value.getAction()) + && value.getContainerBookingId().equals(22L))); + verify(carrierSchedules, times(1)).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin"); } @Test - void confirmNeverWritesStateWhenAnyRealGateFails() { - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE"))); - stubReadyChain(); - when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "LOADING")); - when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-2"), anyString())).thenReturn(true); + void confirmDoesNotWriteAnyRowWhenOneInboundGateFails() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.lockBindings(22L)).thenReturn(bindings()); + when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1)); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("blocked"), anyString())).thenReturn(true); - assertThatThrownBy(() -> service.confirm(9L, "admin", "confirm-2", - new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class).hasMessageContaining("门禁"); + assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "blocked", new ActionRequest(0, null))) + .isInstanceOf(BizException.class).hasMessageContaining("全部入库绑定"); + + verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any()); + verify(repository, never()).history(any()); + } + + @Test + void differingTechnicalRowVersionsAreRejectedAsContainerConflict() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.lockBindings(22L)).thenReturn(bindings()); + when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "READY_FOR_DISPATCH", 0)); + when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "READY_FOR_DISPATCH", 1)); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("mixed"), anyString())).thenReturn(true); + + assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "mixed", new ActionRequest(0, null))) + .isInstanceOf(BizException.class).hasMessageContaining("版本不一致"); verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any()); } @Test - void reverseIsRejectedAfterActualDepartedWarehouseFactExists() { + void actualDepartedWarehouseFactBlocksContainerReverseBeforeAnyRowChanges() { CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class); - OutboundDispatchService service = new OutboundDispatchService(repository, List.of(), carrierSchedules); - stubReadyChain(); - OutboundDispatchEntity state = new OutboundDispatchEntity(); - state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); state.setContainerBookingId(22L); - state.setStatus("WAITING_FOR_LOADING"); state.setVersion(1); - when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); + OutboundDispatchService service = service(carrierSchedules); + stubContainer(); + when(repository.lockBindings(22L)).thenReturn(bindings()); + when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "WAITING_FOR_LOADING", 1)); + when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "WAITING_FOR_LOADING", 1)); when(repository.claim(eq(9L), eq("REVERSE_DISPATCH"), eq("reverse-blocked"), anyString())).thenReturn(true); when(carrierSchedules.hasOutboundDepartureFact(7L, 66L, 22L)).thenReturn(true); - assertThatThrownBy(() -> service.reverse(9L, "admin", "reverse-blocked", - new ActionRequest(11L, 22L, 1, "误操作"))) - .isInstanceOf(BizException.class).hasMessageContaining("DEPARTED_WAREHOUSE"); + assertThatThrownBy(() -> service.reverse(22L, 9L, "admin", "reverse-blocked", + new ActionRequest(1, "误操作"))).isInstanceOf(BizException.class) + .hasMessageContaining("DEPARTED_WAREHOUSE"); verify(repository, never()).reverse(anyLong(), anyInt(), anyLong(), anyString(), any()); } @Test - void selectorsThatMatchMultipleContainerBindingsRequireDisambiguation() { - OutboundDispatchService service = new OutboundDispatchService(repository, List.of()); - when(repository.bindings(any())).thenReturn(List.of(binding(), binding())); - assertThatThrownBy(() -> service.detail(new Query(10L, null, null))) - .isInstanceOf(BizException.class).hasMessageContaining("多个出仓聚合"); + void sameIdempotencyKeyAndPayloadReplaysContainerResultWithoutMutation() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + AtomicReference requestHash = new AtomicReference<>(); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("replay"), anyString())).thenAnswer(invocation -> { + requestHash.set(invocation.getArgument(3)); + return false; + }); + OutboundDispatchOperationEntity existing = new OutboundDispatchOperationEntity(); + existing.setStatus("SUCCESS"); + when(repository.operation(9L, "CONFIRM_DISPATCH", "replay")).thenAnswer(invocation -> { + existing.setRequestHash(requestHash.get()); + return existing; + }); + + Detail replay = service.confirm(22L, 9L, "admin", "replay", new ActionRequest(0, null)); + + assertThat(replay.aggregate().containerBookingId()).isEqualTo(22L); + verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any()); } @Test - void staleDispatchVersionIsRejectedWithoutPartialInboundAdvance() { - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE"))); - stubReadyChain(); - OutboundDispatchEntity state = new OutboundDispatchEntity(); - state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); state.setContainerBookingId(22L); - state.setStatus("READY_FOR_DISPATCH"); state.setVersion(2); - when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); - when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("stale"), anyString())).thenReturn(true); - - assertThatThrownBy(() -> service.confirm(9L, "admin", "stale", - new ActionRequest(11L, 22L, 1, null))).isInstanceOf(BizException.class).hasMessageContaining("刷新"); - } - - @Test - void reusedIdempotencyKeyWithDifferentHashIsRejected() { - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE"))); - stubReadyChain(); + void reusedIdempotencyKeyWithDifferentPayloadIsRejected() { + OutboundDispatchService service = serviceWithoutCarrier(); when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("reused"), anyString())).thenReturn(false); OutboundDispatchOperationEntity existing = new OutboundDispatchOperationEntity(); - existing.setRequestHash("different"); existing.setStatus("SUCCESS"); + existing.setRequestHash("different"); + existing.setStatus("SUCCESS"); when(repository.operation(9L, "CONFIRM_DISPATCH", "reused")).thenReturn(existing); - assertThatThrownBy(() -> service.confirm(9L, "admin", "reused", - new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class) - .hasMessageContaining("用于不同请求"); + assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "reused", new ActionRequest(0, null))) + .isInstanceOf(BizException.class).hasMessageContaining("用于不同请求"); } - private void stubReadyChain() { - when(repository.bindings(any())).thenReturn(List.of(binding())); + private OutboundDispatchService serviceWithoutCarrier() { + return service(null); + } + + private OutboundDispatchService service(CarrierScheduleQueryService carrierSchedules) { + return new OutboundDispatchService(repository, + List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")), carrierSchedules); + } + + private void stubContainer() { + when(repository.bindings(22L)).thenReturn(bindings()); when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "COMPLETED")); - when(repository.ocean(22L, 7L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(66L, "RELEASED")); - when(repository.truck(22L, 44L, 7L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED")); + when(repository.ocean(22L)).thenReturn(new OutboundDispatchReadMapper.OceanBinding(66L, "RELEASED", 7L)); + when(repository.truck(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED")); when(repository.evidence(7L, 11L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1)); + when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1)); } - private static OutboundDispatchReadMapper.Binding binding() { - return new OutboundDispatchReadMapper.Binding(10L, "O-10", 11L, "I-11", 7L, 8L, "客户A", + private static List bindings() { + return List.of(binding(11L, "I-11"), binding(12L, "I-12")); + } + + private static OutboundDispatchReadMapper.Binding binding(long inboundId, String inboundCode) { + return new OutboundDispatchReadMapper.Binding(10L, "O-10", inboundId, inboundCode, 7L, 8L, "客户A", "LOADED", 22L, "CAB-22", "C-22", 44L, "COMPLETED"); } + private static OutboundDispatchEntity state(long id, long inboundId, String status, int version) { + OutboundDispatchEntity value = new OutboundDispatchEntity(); + value.setId(id); + value.setTenantId(7L); + value.setOrderId(10L); + value.setInboundId(inboundId); + value.setContainerBookingId(22L); + value.setStatus(status); + value.setVersion(version); + return value; + } + private static OutboundDispatchGateContributor passing(String code) { return new OutboundDispatchGateContributor() { @Override public String code() { return code; } @@ -180,4 +214,5 @@ class OutboundDispatchServiceTest { } }; } + } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java index e587f38f1..2bba4fc07 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java @@ -38,17 +38,19 @@ class OutboundDispatchSqlContractTest { } @Test - void openApiPublishesSelectorsGateAndBothVersionedActions() throws Exception { + void openApiPublishesOnlyContainerLevelGateDetailAndVersionedActions() throws Exception { String openApi = resource("openapi/phase3-outbound-dispatch-v1.yaml"); assertThat(openApi) .contains("outbound-dispatches:") - .contains("outbound-dispatches/detail:") - .contains("outbound-dispatches/check-gate:") - .contains("outbound-dispatches/confirm-dispatch:") - .contains("outbound-dispatches/reverse-dispatch:") + .contains("outbound-dispatches/{containerBookingId}:") + .contains("outbound-dispatches/{containerBookingId}/gate:") + .contains("outbound-dispatches/{containerBookingId}/confirm:") + .contains("outbound-dispatches/{containerBookingId}/reverse:") + .contains("every real inbound binding", "No partial success is possible") .contains("Once an actual DEPARTED_WAREHOUSE node exists") .contains("expectedVersion") - .contains("Idempotency-Key"); + .contains("Idempotency-Key") + .doesNotContain("confirm-dispatch", "reverse-dispatch", "check-gate", "inboundId, containerBookingId"); } private static String migration() throws Exception { return resource("db/phase3-outbound-dispatch-schema.sql"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/PlatformOutboundDispatchControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/PlatformOutboundDispatchControllerContractTest.java new file mode 100644 index 000000000..83af6945e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/PlatformOutboundDispatchControllerContractTest.java @@ -0,0 +1,65 @@ +package com.baiyue.firstleg.outbound.dispatch; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.firstleg.outbound.dispatch.controller.PlatformOutboundDispatchController; +import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.ActionRequest; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformOutboundDispatchControllerContractTest { + @Test + void exposesOnlyContainerLevelResourcePathsAndExistingPermissions() { + assertThat(PlatformOutboundDispatchController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/api/platform/v1/first-leg/outbound-dispatches"); + Map permissions = Map.of( + "list", "platform:firstleg:outbound-dispatch:list", + "detail", "platform:firstleg:outbound-dispatch:view", + "checkGate", "platform:firstleg:outbound-dispatch:check", + "confirm", "platform:firstleg:outbound-dispatch:confirm", + "reverse", "platform:firstleg:outbound-dispatch:reverse"); + permissions.forEach((name, permission) -> assertThat(method(name).getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('" + permission + "')")); + + assertThat(method("detail").getAnnotation(GetMapping.class).value()).containsExactly("/{containerBookingId}"); + assertThat(method("checkGate").getAnnotation(GetMapping.class).value()) + .containsExactly("/{containerBookingId}/gate"); + assertThat(method("confirm").getAnnotation(PostMapping.class).value()) + .containsExactly("/{containerBookingId}/confirm"); + assertThat(method("reverse").getAnnotation(PostMapping.class).value()) + .containsExactly("/{containerBookingId}/reverse"); + } + + @Test + void actionBodyContainsOnlyContainerVersionAndRemarkAndAuditsContainerRoot() { + assertThat(Arrays.stream(ActionRequest.class.getRecordComponents()).map(value -> value.getName())) + .containsExactly("expectedVersion", "remark") + .doesNotContain("inboundId", "containerBookingId"); + for (String name : List.of("confirm", "reverse")) { + BusinessAudit audit = method(name).getAnnotation(BusinessAudit.class); + assertThat(audit.resourceType()).isEqualTo("CONTAINER_BOOKING"); + assertThat(audit.resourceId()).isEqualTo("#containerBookingId"); + } + } + + @Test + void everyNonListOperationUsesOnlyContainerBookingIdPathTarget() { + for (String name : List.of("detail", "checkGate", "confirm", "reverse")) { + assertThat(Arrays.stream(method(name).getParameters()) + .filter(value -> value.isAnnotationPresent(PathVariable.class)) + .map(value -> value.getName())).containsExactly("containerBookingId"); + } + } + + private static Method method(String name) { + return Arrays.stream(PlatformOutboundDispatchController.class.getDeclaredMethods()) + .filter(value -> value.getName().equals(name)).findFirst().orElseThrow(); + } +} From 1584c7a0a33c2b7dee47735abade9d8d0b8326ed Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 12:36:14 +0800 Subject: [PATCH 29/42] fix(firstleg): make truck status informational for departure --- .../service/OutboundDispatchService.java | 3 -- .../openapi/phase3-outbound-dispatch-v1.yaml | 4 +- .../dispatch/OutboundDispatchServiceTest.java | 37 +++++++++++++++++++ .../OutboundDispatchSqlContractTest.java | 2 + 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java index 4192ec0b2..2ba9538d6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java @@ -24,7 +24,6 @@ import java.util.*; @Service public class OutboundDispatchService { private static final Set REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE"); - private static final Set TRUCK_READY = Set.of("DEPARTED", "COMPLETED"); private final OutboundDispatchRepository repository; private final Map contributors; @@ -187,8 +186,6 @@ public class OutboundDispatchService { status(pick), "COMPLETED")); items.add(item("OCEAN_BOOKING_RELEASED", "订舱已放舱且分配当前柜次", ocean != null && "RELEASED".equals(ocean.status()), status(ocean), "RELEASED")); - items.add(item("TRUCK_DEPARTED", "关联拖车已离仓", truck != null && TRUCK_READY.contains(truck.status()), - status(truck), "DEPARTED或COMPLETED")); boolean evidenceReady = evidence != null && evidence.evidenceCount() != null && evidence.evidenceCount() >= 5 && Objects.equals(evidence.validSeal(), 1); items.add(item("LOADING_EVIDENCE_COMPLETE", "四步照片与铅封完整", evidenceReady, diff --git a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml index b590dfc68..f2dc216bb 100644 --- a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml @@ -99,8 +99,8 @@ components: loadingPlanStatus: {type: string} oceanBookingId: {type: integer, format: int64, nullable: true} oceanBookingStatus: {type: string, nullable: true} - truckAppointmentId: {type: integer, format: int64, nullable: true} - truckAppointmentStatus: {type: string, nullable: true} + truckAppointmentId: {type: integer, format: int64, nullable: true, description: Reference-only truck appointment; absence never blocks manual outbound confirmation} + truckAppointmentStatus: {type: string, nullable: true, description: Reference-only status; PENDING or any non-terminal state never affects gate.passed} dispatchStatus: {type: string, description: One visible container-level status} version: {type: integer, minimum: 0, description: One optimistic-lock version shared by all technical inbound rows} inbounds: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java index e85d69d17..3b5ec26d7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java @@ -44,6 +44,34 @@ class OutboundDispatchServiceTest { .extracting(GateItem::passed).containsExactly(false); } + @Test + void confirmAllowsMissingTruckAppointmentWhenEveryAuthoritativeGatePasses() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.truck(22L, 44L)).thenReturn(null); + stubConfirmMutation("no-truck"); + + Detail result = service.confirm(22L, 9L, "admin", "no-truck", new ActionRequest(0, null)); + + assertThat(result.aggregate().truckAppointmentId()).isNull(); + assertThat(result.aggregate().gate().passed()).isTrue(); + verify(repository, times(2)).confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull()); + } + + @Test + void confirmAllowsPendingTruckAppointmentWhenEveryAuthoritativeGatePasses() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.truck(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "PENDING")); + stubConfirmMutation("pending-truck"); + + Detail result = service.confirm(22L, 9L, "admin", "pending-truck", new ActionRequest(0, null)); + + assertThat(result.aggregate().truckAppointmentStatus()).isEqualTo("PENDING"); + assertThat(result.aggregate().gate().passed()).isTrue(); + verify(repository, times(2)).confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull()); + } + @Test void detailReturnsOneVersionAndAllInboundBindings() { OutboundDispatchService service = serviceWithoutCarrier(); @@ -185,6 +213,15 @@ class OutboundDispatchServiceTest { when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1)); } + private void stubConfirmMutation(String key) { + when(repository.lockBindings(22L)).thenReturn(bindings()); + when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "READY_FOR_DISPATCH", 0)); + when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "READY_FOR_DISPATCH", 0)); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq(key), anyString())).thenReturn(true); + when(repository.confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull())).thenReturn(1); + when(repository.completeOperation(31L, 9L, "CONFIRM_DISPATCH", key)).thenReturn(1); + } + private static List bindings() { return List.of(binding(11L, "I-11"), binding(12L, "I-12")); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java index 2bba4fc07..076c56f63 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java @@ -48,6 +48,8 @@ class OutboundDispatchSqlContractTest { .contains("outbound-dispatches/{containerBookingId}/reverse:") .contains("every real inbound binding", "No partial success is possible") .contains("Once an actual DEPARTED_WAREHOUSE node exists") + .contains("absence never blocks manual outbound confirmation") + .contains("PENDING or any non-terminal state never affects gate.passed") .contains("expectedVersion") .contains("Idempotency-Key") .doesNotContain("confirm-dispatch", "reverse-dispatch", "check-gate", "inboundId, containerBookingId"); From fc0a6ea7cacda2ce5512db4e475f3533c6dfff8d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 13:07:30 +0800 Subject: [PATCH 30/42] fix(firstleg): expose container documents and aggregate departure gates --- .../PlatformContainerController.java | 15 +++ .../PlatformLoadingListController.java | 21 ++++ .../customs/repository/CustomsRepository.java | 6 + .../CustomsDispatchGateContributor.java | 3 +- .../mapper/ContainerOverviewMapper.java | 2 +- .../mapper/OutboundDispatchReadMapper.java | 19 +++- .../OutboundDispatchRepository.java | 4 +- .../service/OutboundDispatchService.java | 22 ++-- .../firstleg/service/LoadingListService.java | 75 ++++++++++++- .../main/resources/openapi/firstleg-v1.yaml | 10 ++ .../resources/openapi/loading-lists-v1.yaml | 23 ++++ .../openapi/phase3-container-overview-v1.yaml | 10 +- .../openapi/phase3-outbound-dispatch-v1.yaml | 3 +- .../ContainerControllerContractTest.java | 5 + .../LoadingListControllerContractTest.java | 19 ++++ .../CustomsDispatchGateContributorTest.java | 5 +- ...utboundDispatchReadMapperContractTest.java | 15 ++- .../dispatch/OutboundDispatchServiceTest.java | 55 +++++++-- .../OutboundDispatchSqlContractTest.java | 3 + .../ContainerOverviewSqlContractTest.java | 8 ++ .../service/ContainerOverviewServiceTest.java | 17 ++- .../service/LoadingListServiceTest.java | 106 +++++++++++++++++- 22 files changed, 407 insertions(+), 39 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java index 0decb1063..cfbb44d48 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java @@ -128,6 +128,21 @@ public class PlatformContainerController { return ApiResponse.success(loadingLists.containerClearance(id)); } + @GetMapping("/{id}/clearance/document/{fileId}/download") + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @Operation(summary = "下载货柜目的港清关附件", description = "校验当前清关附件和 File 业务归属,完成态仍可下载。") + public ResponseEntity downloadClearance( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, + @PathVariable @Positive long fileId) { + FirstlegScopeGuard.platform(user); + var download = loadingLists.downloadContainerClearance(id, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment() + .filename(download.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(download.contentType()).contentLength(download.content().length) + .body(download.content()); + } + @PostMapping("/{id}/clearance/document") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @Operation(summary = "上传或替换货柜目的港清关资料") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java index 153ae3d48..803be7cda 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -25,6 +25,10 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; import org.springframework.http.MediaType; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; @@ -40,6 +44,8 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.multipart.MultipartFile; +import java.nio.charset.StandardCharsets; + /** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */ @Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询") @Validated @@ -124,6 +130,21 @@ public class PlatformLoadingListController { return ApiResponse.success(service.loadingDeclaration(planId, warehouseId)); } + @Operation(summary = "下载装柜清单出口报关附件", description = "完成态仍可下载;服务端校验装柜计划、仓库、当前附件及 File 业务归属。") + @GetMapping("/loading-lists/{planId}/customs/document/{fileId}/download") + @PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')") + public ResponseEntity downloadDeclaration( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, + @PathVariable @Positive long fileId, @RequestParam @Positive long warehouseId) { + FirstlegScopeGuard.platform(user); + var download = service.downloadLoadingDeclaration(planId, warehouseId, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(download.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(download.contentType()).contentLength(download.content().length) + .body(download.content()); + } + @Operation(summary = "上传或替换装柜清单出口报关文档") @PostMapping("/loading-lists/{planId}/customs/document") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java index f33063e95..6bf93297d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java @@ -81,6 +81,12 @@ public class CustomsRepository { .eq(CustomsCaseEntity::getContainerBookingId, containerBookingId) .orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1")); } + public CustomsCaseEntity clearedContainerCase(long containerBookingId) { + return cases.selectOne(new LambdaQueryWrapper() + .eq(CustomsCaseEntity::getContainerBookingId, containerBookingId) + .eq(CustomsCaseEntity::getStatus, "CLEARANCE_COMPLETED") + .orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1")); + } public boolean containerCarriesOrder(long containerId, long tenantId, long orderId) { LoadingPlanEntity plan = loadingPlans.selectOne(new LambdaQueryWrapper() .eq(LoadingPlanEntity::getContainerBookingId, containerId)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java index 94eb4cc89..ed2c87fc9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java @@ -18,8 +18,7 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr if (context.tenantId() <= 0 || context.customerId() <= 0) { return gate(false, "INVALID_CUSTOMER_SCOPE"); } - CustomsCaseEntity value = repository.gateCase(context.tenantId(), context.orderId(), - context.inboundId(), context.containerBookingId()); + CustomsCaseEntity value = repository.clearedContainerCase(context.containerBookingId()); if (value == null) return gate(false, "MISSING"); return gate("CLEARANCE_COMPLETED".equals(value.getStatus()), value.getStatus()); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java index 2614509e7..f19381c0c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java @@ -4,7 +4,7 @@ import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity; import org.apache.ibatis.annotations.*; import java.util.List; @Mapper public interface ContainerOverviewMapper { - @Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List documents(@Param("id") long id); + @Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List documents(@Param("id") long id); @Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{id} ORDER BY created_at,id") List transport(@Param("id") long id); @Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY occurred_at,id") List exceptions(@Param("id") long id); @Insert("INSERT INTO fl_container_exception_history(id,container_booking_id,action,reason,operator_id,operator_name,operation_id,request_hash,occurred_at) VALUES(#{id},#{bookingId},#{action},#{reason},#{operatorId},#{operatorName},#{operationId},#{requestHash},CURRENT_TIMESTAMP(3))") int insertException(@Param("id") long id,@Param("bookingId") long bookingId,@Param("action") String action,@Param("reason") String reason,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName,@Param("operationId") String operationId,@Param("requestHash") String requestHash); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java index 39b7d12e9..519cc03e8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java @@ -63,13 +63,20 @@ public interface OutboundDispatchReadMapper { IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId); @Select(""" - SELECT COUNT(DISTINCT CASE WHEN status='ACTIVE' AND file_id IS NOT NULL AND evidence_type IN - ('PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL') THEN evidence_type END) evidenceCount, - MAX(CASE WHEN status='ACTIVE' AND evidence_type='SEAL' AND file_id IS NOT NULL AND seal_no IS NOT NULL - AND seal_no<>'' THEN 1 ELSE 0 END) validSeal - FROM fl_loading_evidence WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} + SELECT COUNT(DISTINCT CASE WHEN evidence.status='ACTIVE' AND evidence.file_id IS NOT NULL + AND evidence.evidence_type IN ('PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL') + THEN evidence.evidence_type END) evidenceCount, + MAX(CASE WHEN evidence.status='ACTIVE' AND evidence.evidence_type='SEAL' + AND evidence.file_id IS NOT NULL AND evidence.seal_no IS NOT NULL + AND evidence.seal_no<>'' THEN 1 ELSE 0 END) validSeal + FROM fl_loading_plan plan + JOIN fl_loading_plan_item item ON item.loading_plan_id=plan.id AND item.deleted=0 + JOIN fl_loading_evidence evidence ON evidence.inbound_id=item.inbound_id + AND evidence.tenant_id=item.account_id + WHERE plan.id=#{loadingPlanId} AND plan.container_booking_id=#{containerBookingId} """) - Evidence evidence(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + Evidence evidence(@Param("containerBookingId") long containerBookingId, + @Param("loadingPlanId") long loadingPlanId); @Select(""" SELECT module,action,fromStatus,toStatus,operatorId,operatorName,remark,occurredAt FROM ( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java index 3ad889820..cf510a1e8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java @@ -30,8 +30,8 @@ public class OutboundDispatchRepository { public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId) { return reads.truck(containerId, planId); } - public OutboundDispatchReadMapper.Evidence evidence(long tenantId, long inboundId) { - return reads.evidence(tenantId, inboundId); + public OutboundDispatchReadMapper.Evidence evidence(long containerId, long loadingPlanId) { + return reads.evidence(containerId, loadingPlanId); } public OutboundDispatchEntity dispatch(long tenantId, long inboundId, long containerId) { return dispatches.find(tenantId, inboundId, containerId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java index 2ba9538d6..0568fef73 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java @@ -23,7 +23,7 @@ import java.util.*; @Service public class OutboundDispatchService { - private static final Set REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE"); + private static final Set REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE"); private final OutboundDispatchRepository repository; private final Map contributors; @@ -152,12 +152,13 @@ public class OutboundDispatchService { var pick = repository.pick(first.loadingPlanId()); var ocean = repository.ocean(first.containerBookingId()); var truck = repository.truck(first.containerBookingId(), first.loadingPlanId()); + var evidence = repository.evidence(first.containerBookingId(), first.loadingPlanId()); + List containerContributorGates = contributorGates(first, ocean); List inboundGates = new ArrayList<>(); List inboundDtos = new ArrayList<>(); List states = new ArrayList<>(); for (OutboundDispatchReadMapper.Binding binding : bindings) { - var evidence = repository.evidence(binding.tenantId(), binding.inboundId()); - GateResult gate = gate(binding, pick, ocean, truck, evidence); + GateResult gate = gate(binding, pick, ocean, evidence, containerContributorGates); inboundGates.add(new InboundGate(binding.inboundId(), binding.inboundCode(), gate)); inboundDtos.add(new InboundBinding(binding.orderId(), binding.orderNo(), binding.inboundId(), binding.inboundCode(), binding.tenantId(), binding.customerId(), binding.customerName(), @@ -175,8 +176,8 @@ public class OutboundDispatchService { } private GateResult gate(OutboundDispatchReadMapper.Binding binding, OutboundDispatchReadMapper.IdStatus pick, - OutboundDispatchReadMapper.OceanBinding ocean, OutboundDispatchReadMapper.IdStatus truck, - OutboundDispatchReadMapper.Evidence evidence) { + OutboundDispatchReadMapper.OceanBinding ocean, OutboundDispatchReadMapper.Evidence evidence, + List containerContributorGates) { List items = new ArrayList<>(); items.add(item("INBOUND_LOADED", "入库单装柜完成", "LOADED".equals(binding.inboundStatus()), binding.inboundStatus(), "LOADED")); @@ -192,6 +193,13 @@ public class OutboundDispatchService { evidence == null ? null : evidence.evidenceCount() + "个证据/有效铅封=" + evidence.validSeal(), "PHOTO_STEP_1..4和含铅封号的SEAL")); + items.addAll(containerContributorGates); + return new GateResult(items.stream().allMatch(GateItem::passed), List.copyOf(items), LocalDateTime.now()); + } + + private List contributorGates(OutboundDispatchReadMapper.Binding binding, + OutboundDispatchReadMapper.OceanBinding ocean) { + List items = new ArrayList<>(); var context = new OutboundDispatchGateContributor.Context(binding.tenantId(), binding.customerId(), binding.orderId(), binding.inboundId(), binding.containerBookingId(), binding.loadingPlanId(), id(ocean)); for (String code : REQUIRED_CONTRIBUTORS.stream().sorted().toList()) { @@ -207,7 +215,7 @@ public class OutboundDispatchService { items.add(item(code, contributorName(code), false, "ASSESSMENT_ERROR", "模块返回可放行状态")); } } - return new GateResult(items.stream().allMatch(GateItem::passed), List.copyOf(items), LocalDateTime.now()); + return List.copyOf(items); } private Detail replay(long containerBookingId, long operatorId, String operation, String key, String hash) { @@ -333,7 +341,7 @@ public class OutboundDispatchService { return new GateItem(code, name, pass, actual, requirement); } private static String contributorName(String code) { - return "CUSTOMS_CLEARANCE".equals(code) ? "报关协同完成" : "船期有效且未过期"; + return "报关协同完成"; } private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 2b287f49d..fc96d5cf4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -55,7 +55,7 @@ public class LoadingListService { public static final String DELIVERY_ORDER = "DELIVERY_ORDER"; public static final String PICKUP_ORDER = "PICKUP_ORDER"; private static final String CLEARANCE_FILE_TYPE = "CONTAINER_CLEARANCE_DOCUMENT"; - private static final String DECLARATION_FILE_TYPE = "LOADING_CUSTOMS_DOCUMENT"; + private static final String DECLARATION_FILE_TYPE = "LOADING_CUSTOMS_DECLARATION"; private static final String DO_FILE_TYPE = "CONTAINER_DELIVERY_ORDER"; private static final String PU_FILE_TYPE = "CONTAINER_PICKUP_ORDER"; @Resource @@ -128,6 +128,41 @@ public class LoadingListService { } catch(FeignException e){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} } + public DocumentDownload downloadContainerClearance(long bookingId, long fileId) { + requireBookingPlan(bookingId); + ContainerDocumentEntity document = repository.document(bookingId, DESTINATION_CLEARANCE); + if (document == null || !Long.valueOf(fileId).equals(document.getFileId())) { + throw notFound("目的港清关附件不存在或不属于当前货柜"); + } + String businessId = Long.toString(bookingId); + try { + var metadata = documentFiles.metadata(internalToken, 0, CLEARANCE_FILE_TYPE, businessId, fileId); + if (metadata == null || !metadata.successful() || metadata.data() == null + || !Long.valueOf(fileId).equals(metadata.data().fileId()) + || !Long.valueOf(0).equals(metadata.data().tenantId()) + || !CLEARANCE_FILE_TYPE.equals(metadata.data().businessType()) + || !businessId.equals(metadata.data().businessId())) { + throw notFound("目的港清关附件不存在或归属不匹配"); + } + var response = documentFiles.download(internalToken, 0, CLEARANCE_FILE_TYPE, businessId, fileId); + if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + throw notFound("目的港清关附件不存在"); + } + MediaType contentType; + try { + contentType = MediaType.parseMediaType(metadata.data().contentType()); + } catch (RuntimeException exception) { + contentType = MediaType.APPLICATION_OCTET_STREAM; + } + String fileName = StringUtils.hasText(document.getFileName()) + ? document.getFileName() : metadata.data().fileName(); + return new DocumentDownload(fileName, contentType, response.getBody()); + } catch (FeignException.NotFound exception) { + throw notFound("目的港清关附件不存在或归属不匹配"); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); + } + } public record DocumentDownload(String fileName,MediaType contentType,byte[] content) {} private static void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)&&!PICKUP_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER"); } private static String fileType(String type){ return switch(type){case DELIVERY_ORDER->DO_FILE_TYPE; case PICKUP_ORDER->PU_FILE_TYPE; default->throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER");}; } @@ -169,6 +204,44 @@ public class LoadingListService { return document(repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION)); } + public DocumentDownload downloadLoadingDeclaration(long planId, long warehouseId, long fileId) { + AggregateRow plan = requirePlan(planId); + requireWarehouse(plan, warehouseId); + requireCompletedPlan(plan); + ContainerDocumentEntity document = repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION); + if (document == null || !Long.valueOf(fileId).equals(document.getFileId())) { + throw notFound("出口报关附件不存在或不属于当前装柜计划"); + } + String businessId = Long.toString(planId); + try { + var metadata = documentFiles.metadata(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId); + if (metadata == null || !metadata.successful() || metadata.data() == null + || !Long.valueOf(fileId).equals(metadata.data().fileId()) + || !Long.valueOf(0).equals(metadata.data().tenantId()) + || !DECLARATION_FILE_TYPE.equals(metadata.data().businessType()) + || !businessId.equals(metadata.data().businessId())) { + throw notFound("出口报关附件不存在或归属不匹配"); + } + var response = documentFiles.download(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId); + if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + throw notFound("出口报关附件不存在"); + } + MediaType contentType; + try { + contentType = MediaType.parseMediaType(metadata.data().contentType()); + } catch (RuntimeException exception) { + contentType = MediaType.APPLICATION_OCTET_STREAM; + } + String fileName = StringUtils.hasText(document.getFileName()) + ? document.getFileName() : metadata.data().fileName(); + return new DocumentDownload(fileName, contentType, response.getBody()); + } catch (FeignException.NotFound exception) { + throw notFound("出口报关附件不存在或归属不匹配"); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); + } + } + @Transactional @BusinessAudit(event = "LOADING_CUSTOMS_DOCUMENT_ATTACH", resourceType = "LOADING_PLAN", resourceId = "#planId", description = "上传出口报关文档") diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 652cd8003..6b4dc5e8e 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -356,6 +356,16 @@ paths: required: true content: {application/json: {schema: {$ref: '#/components/schemas/ContainerDocumentAttachRequest'}}} responses: {'200': {description: 成功、文件归属不匹配或版本冲突}} + /api/platform/v1/containers/{id}/clearance/document/{fileId}/download: + get: + summary: 下载货柜目的港清关附件 + description: 需要 platform:firstleg:customs:manage 权限;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存 + parameters: + - {$ref: '#/components/parameters/ContainerBookingId'} + - {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': {description: 附件二进制内容,包含 Content-Disposition 与 Cache-Control no-store} + '404': {description: 当前附件不存在或归属不匹配} /api/platform/v1/containers/{id}/clearance/document/upload: post: summary: 直接选择文件上传或替换货柜目的港清关资料 diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 59c5fbc0d..18b436f15 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -99,6 +99,29 @@ paths: content: {application/json: {schema: {$ref: '#/components/schemas/DocumentAttachRequest'}}} responses: '200': {$ref: '#/components/responses/BusinessDocumentResponse'} + /api/platform/v1/first-leg/loading-lists/{planId}/customs/document/{fileId}/download: + get: + summary: 下载装柜清单出口报关附件 + description: 完成态仍可下载。服务端校验 planId、warehouseId、当前 CUSTOMS_DECLARATION 的 fileId,以及 File 元数据 tenantId=0、businessType=LOADING_CUSTOMS_DECLARATION、businessId=planId;错误 fileId 或归属返回 A0404。响应使用 attachment 文件名并禁止缓存。 + x-permission: platform:firstleg:loading-list:list + parameters: + - {$ref: '#/components/parameters/PlanId'} + - {$ref: '#/components/parameters/WarehouseId'} + - name: fileId + in: path + required: true + description: File 服务长整型文件 ID + schema: {$ref: '#/components/schemas/LongId'} + responses: + '200': + description: 报关附件二进制内容;包含 Content-Disposition,Cache-Control 为 no-store + headers: + Content-Disposition: {schema: {type: string}} + Cache-Control: {schema: {type: string, example: no-store}} + content: + application/octet-stream: + schema: {type: string, format: binary} + '404': {description: 装柜计划、仓库、当前附件或 File 归属不匹配} /api/platform/v1/first-leg/loading-lists/{planId}/customs/document/upload: post: summary: 直接选择文件上传或替换装柜清单出口报关文档 diff --git a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml index 2b7420a4d..47bf7403f 100644 --- a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml @@ -36,6 +36,12 @@ paths: description: '权限:platform:firstleg:container:list;Firstleg代理File内部下载,不向调用方暴露内部令牌' parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}] responses: {'200': {description: file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}} + /api/platform/v1/containers/{id}/clearance/document/{fileId}/download: + get: + operationId: downloadContainerDestinationClearance + description: '权限:platform:firstleg:customs:manage;校验当前 DESTINATION_CLEARANCE fileId 以及 File tenantId=0、businessType=CONTAINER_CLEARANCE_DOCUMENT、businessId=id;返回 attachment 与 no-store,完成态仍可下载' + parameters: [{$ref: '#/components/parameters/ContainerId'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}] + responses: {'200': {description: clearance file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}, '404': {description: current file or ownership mismatch}} components: parameters: ContainerId: {name: id, in: path, required: true, schema: {type: integer, format: int64}} @@ -51,7 +57,7 @@ components: container: {$ref: '#/components/schemas/Container'} loadingPlanId: {type: integer, format: int64} pda: {$ref: '#/components/schemas/PdaSummary'} - documents: {type: array, items: {$ref: '#/components/schemas/Document'}} + documents: {type: array, description: '按 documentType 稳定排序返回 DESTINATION_CLEARANCE、CUSTOMS_DECLARATION、DELIVERY_ORDER、PICKUP_ORDER 的当前唯一记录和真实 version', items: {$ref: '#/components/schemas/Document'}} exception: {$ref: '#/components/schemas/ExceptionState'} exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}} timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}} @@ -59,7 +65,7 @@ components: PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}} PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}} Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}} - Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer}, completed: {type: boolean}}} + Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string, enum: [DESTINATION_CLEARANCE, CUSTOMS_DECLARATION, DELIVERY_ORDER, PICKUP_ORDER]}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer, description: '当前唯一记录的真实乐观锁版本;更新时必须原样作为 expectedVersion'}, completed: {type: boolean}}} ExceptionState: {type: object, properties: {active: {type: boolean}, reason: {type: string}, markedBy: {type: integer, format: int64}, markedByName: {type: string}, markedAt: {type: string, format: date-time}}} ExceptionHistory: {type: object, properties: {id: {type: integer, format: int64}, action: {type: string, enum: [MARK, CANCEL]}, reason: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}} TimelineEntry: {type: object, properties: {eventType: {type: string}, detail: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}} diff --git a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml index f2dc216bb..aeeeb7b18 100644 --- a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml @@ -37,6 +37,7 @@ paths: /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/gate: get: summary: Evaluate every inbound binding in the fail-closed container outbound gate + description: Loading evidence is evaluated once for the physical container/loading plan and the same result applies to every inbound binding. Customs clearance is also resolved once at container scope. A missing standalone sailing schedule is reference-only and never affects gate.passed. x-permission: platform:firstleg:outbound-dispatch:check parameters: - {$ref: '#/components/parameters/ContainerBookingIdPath'} @@ -98,7 +99,7 @@ components: loadingPlanId: {type: integer, format: int64} loadingPlanStatus: {type: string} oceanBookingId: {type: integer, format: int64, nullable: true} - oceanBookingStatus: {type: string, nullable: true} + oceanBookingStatus: {type: string, nullable: true, description: Booking reference retained independently from the non-blocking standalone sailing schedule} truckAppointmentId: {type: integer, format: int64, nullable: true, description: Reference-only truck appointment; absence never blocks manual outbound confirmation} truckAppointmentStatus: {type: string, nullable: true, description: Reference-only status; PENDING or any non-terminal state never affects gate.passed} dispatchStatus: {type: string, description: One visible container-level status} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java index d94c7e2b7..33327afb2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java @@ -33,6 +33,7 @@ class ContainerControllerContractTest { assertPermission("logisticsDocument", "platform:firstleg:container:list"); assertPermission("uploadLogisticsDocument", "platform:firstleg:container:edit"); assertPermission("downloadDocument", "platform:firstleg:container:list"); + assertPermission("downloadClearance", "platform:firstleg:customs:manage"); } @Test void overviewOpenApiFreezesContainerIdBasedPdaContract() throws Exception { @@ -40,6 +41,8 @@ class ContainerControllerContractTest { .getContentAsString(StandardCharsets.UTF_8); assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:", "/documents/{documentType}/{fileId}/download:", "ExceptionRequest", + "/clearance/document/{fileId}/download:", + "DESTINATION_CLEARANCE、CUSTOMS_DECLARATION、DELIVERY_ORDER、PICKUP_ORDER", "enum: [DELIVERY_ORDER, PICKUP_ORDER]", "Idempotency-Key", "photoCounts:", "/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:", "AWAITING_REVIEW", "version: {type: integer, minimum: 0}", @@ -77,6 +80,8 @@ class ContainerControllerContractTest { .doesNotContain("tenantId") .doesNotContain("accountId"); assertThat(openapi) + .contains("/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:") + .contains("Content-Disposition 与 Cache-Control no-store") .contains("required: [oceanBookingId, requirementId, containerNo, expectedVersion]") .contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus") .contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index d228c6137..28be884a7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -16,6 +16,8 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.http.ResponseEntity; import org.springframework.web.multipart.MultipartFile; import java.lang.reflect.Method; @@ -41,6 +43,8 @@ class LoadingListControllerContractTest { ConfirmedQuery.class); authority("confirmedDetail", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class); authority("declaration", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class); + authority("downloadDeclaration", "platform:firstleg:loading-list:list", AuthenticatedUser.class, + long.class, long.class, long.class); authority("attachDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class, long.class, long.class, String.class, DocumentAttachRequest.class); authority("uploadDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class, @@ -88,6 +92,8 @@ class LoadingListControllerContractTest { .contains("oceanBookingStatus") .contains("pdaTaskId") .contains("cabinetNo") + .contains("LOADING_CUSTOMS_DECLARATION") + .contains("Cache-Control 为 no-store") .doesNotContain("internalContainerNo") .doesNotContain("rollback") .doesNotContain("delete:") @@ -118,6 +124,18 @@ class LoadingListControllerContractTest { assertThat(method.getParameters()[6].getAnnotation(RequestPart.class)).isNull(); } + @Test + void customsDownloadUsesStablePlanPathAndBinaryResponse() throws Exception { + Method method = PlatformLoadingListController.class.getDeclaredMethod("downloadDeclaration", + AuthenticatedUser.class, long.class, long.class, long.class); + assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly( + "/loading-lists/{planId}/customs/document/{fileId}/download"); + assertThat(method.getReturnType()).isEqualTo(ResponseEntity.class); + assertThat(method.getParameters()[1].getName()).isEqualTo("planId"); + assertThat(method.getParameters()[2].getName()).isEqualTo("fileId"); + assertThat(method.getParameters()[3].getName()).isEqualTo("warehouseId"); + } + @Test void yamlParses() throws Exception { String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml") @@ -134,6 +152,7 @@ class LoadingListControllerContractTest { "/api/platform/v1/first-leg/loading-lists/{planId}", "/api/platform/v1/first-leg/loading-lists/{planId}/customs", "/api/platform/v1/first-leg/loading-lists/{planId}/customs/document", + "/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/{fileId}/download", "/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/upload", "/api/platform/v1/first-leg/loading-lists/{planId}/customs/complete"); Map components = stringMap(document.get("components")); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java index 6695ea5d8..be11e6816 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java @@ -24,15 +24,16 @@ class CustomsDispatchGateContributorTest { @Test void passesOnlyCanonicalClearanceCompletedCase() { CustomsCaseEntity value = new CustomsCaseEntity(); - value.setStatus("RECEIPT_AVAILABLE"); - when(repository.gateCase(10001L, 21L, 31L, 81L)).thenReturn(value); assertThat(contributor.assess(context()).passed()).isFalse(); value.setStatus("CLEARANCE_COMPLETED"); + when(repository.clearedContainerCase(81L)).thenReturn(value); assertThat(contributor.assess(context())) .satisfies(item -> { assertThat(item.passed()).isTrue(); assertThat(item.requirement()).isEqualTo("CLEARANCE_COMPLETED"); }); + verify(repository, times(2)).clearedContainerCase(81L); + verify(repository, never()).gateCase(anyLong(), anyLong(), anyLong(), anyLong()); } private static Context context() { return new Context(10001L, 10002L, 21L, 31L, 81L, 91L, null); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java index 2a15df4bf..1efcea3a3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java @@ -47,10 +47,23 @@ class OutboundDispatchReadMapperContractTest { .getAnnotation(Select.class).value()[0]; assertThat(sql) - .contains("seal_no<>''") + .contains("evidence.seal_no<>''") .doesNotContain("<", ">"); } + @Test + void evidenceGateAggregatesOnePhysicalEvidenceSetAcrossTheWholeLoadingPlan() throws Exception { + String sql = OutboundDispatchReadMapper.class.getMethod("evidence", long.class, long.class) + .getAnnotation(Select.class).value()[0]; + + assertThat(sql) + .contains("plan.id=#{loadingPlanId}", "plan.container_booking_id=#{containerBookingId}") + .contains("item.loading_plan_id=plan.id", "item.deleted=0") + .contains("evidence.inbound_id=item.inbound_id", "evidence.tenant_id=item.account_id") + .contains("COUNT(DISTINCT CASE", "evidence.evidence_type") + .doesNotContain("WHERE tenant_id=#{tenantId}", "inbound_id=#{inboundId}"); + } + @Test void oceanAndTruckQueriesAreResolvedOnceFromTheContainerRoot() throws Exception { String oceanSql = OutboundDispatchReadMapper.class.getMethod("ocean", long.class) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java index 3b5ec26d7..09c38aef4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java @@ -14,6 +14,7 @@ import org.junit.jupiter.api.Test; import java.util.List; import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.LongStream; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -30,18 +31,38 @@ class OutboundDispatchServiceTest { } @Test - void gateAggregatesEveryRealInboundAndFailsClosedWhenOneBindingFails() { + void containerEvidenceFailureFailsEveryBindingConsistently() { OutboundDispatchService service = serviceWithoutCarrier(); stubContainer(); - when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1)); + when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1)); ContainerGateResult result = service.checkGate(22L); assertThat(result.passed()).isFalse(); assertThat(result.inbounds()).hasSize(2); - assertThat(result.inbounds().get(1).gate().items()) + assertThat(result.inbounds()).allSatisfy(inbound -> assertThat(inbound.gate().items()) .filteredOn(item -> "LOADING_EVIDENCE_COMPLETE".equals(item.code())) - .extracting(GateItem::passed).containsExactly(false); + .extracting(GateItem::passed).containsExactly(false)); + verify(repository, times(1)).evidence(22L, 44L); + } + + @Test + void tenInboundBindingsShareOneCompleteEvidenceSetAndOneContainerCustomsResult() { + OutboundDispatchGateContributor customs = mock(OutboundDispatchGateContributor.class); + when(customs.code()).thenReturn("CUSTOMS_CLEARANCE"); + when(customs.assess(any())).thenReturn(new GateItem( + "CUSTOMS_CLEARANCE", "报关完成", true, "CLEARANCE_COMPLETED", "CLEARANCE_COMPLETED")); + OutboundDispatchService service = new OutboundDispatchService(repository, List.of(customs), null); + List tenBindings = LongStream.rangeClosed(11L, 20L) + .mapToObj(id -> binding(id, "I-" + id)).toList(); + stubContainer(tenBindings); + + ContainerGateResult result = service.checkGate(22L); + + assertThat(result.passed()).isTrue(); + assertThat(result.inbounds()).hasSize(10).allSatisfy(value -> assertThat(value.gate().passed()).isTrue()); + verify(repository, times(1)).evidence(22L, 44L); + verify(customs, times(1)).assess(any()); } @Test @@ -72,6 +93,19 @@ class OutboundDispatchServiceTest { verify(repository, times(2)).confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull()); } + @Test + void confirmAllowsMissingSailingScheduleContributor() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + stubConfirmMutation("no-schedule"); + + Detail result = service.confirm(22L, 9L, "admin", "no-schedule", new ActionRequest(0, null)); + + assertThat(result.aggregate().gate().passed()).isTrue(); + assertThat(result.aggregate().gate().inbounds()).allSatisfy(inbound -> assertThat(inbound.gate().items()) + .extracting(GateItem::code).doesNotContain("SAILING_SCHEDULE")); + } + @Test void detailReturnsOneVersionAndAllInboundBindings() { OutboundDispatchService service = serviceWithoutCarrier(); @@ -117,7 +151,7 @@ class OutboundDispatchServiceTest { OutboundDispatchService service = serviceWithoutCarrier(); stubContainer(); when(repository.lockBindings(22L)).thenReturn(bindings()); - when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1)); + when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1)); when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("blocked"), anyString())).thenReturn(true); assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "blocked", new ActionRequest(0, null))) @@ -201,16 +235,19 @@ class OutboundDispatchServiceTest { private OutboundDispatchService service(CarrierScheduleQueryService carrierSchedules) { return new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")), carrierSchedules); + List.of(passing("CUSTOMS_CLEARANCE")), carrierSchedules); } private void stubContainer() { - when(repository.bindings(22L)).thenReturn(bindings()); + stubContainer(bindings()); + } + + private void stubContainer(List values) { + when(repository.bindings(22L)).thenReturn(values); when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "COMPLETED")); when(repository.ocean(22L)).thenReturn(new OutboundDispatchReadMapper.OceanBinding(66L, "RELEASED", 7L)); when(repository.truck(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED")); - when(repository.evidence(7L, 11L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1)); - when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1)); + when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1)); } private void stubConfirmMutation(String key) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java index 076c56f63..024cefac7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java @@ -50,6 +50,9 @@ class OutboundDispatchSqlContractTest { .contains("Once an actual DEPARTED_WAREHOUSE node exists") .contains("absence never blocks manual outbound confirmation") .contains("PENDING or any non-terminal state never affects gate.passed") + .contains("Loading evidence is evaluated once for the physical container/loading plan") + .contains("Customs clearance is also resolved once at container scope") + .contains("missing standalone sailing schedule is reference-only") .contains("expectedVersion") .contains("Idempotency-Key") .doesNotContain("confirm-dispatch", "reverse-dispatch", "check-gate", "inboundId, containerBookingId"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java index afecb8b9e..b19652ad9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java @@ -12,6 +12,14 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class ContainerOverviewSqlContractTest { + @Test void overviewIncludesExactlyTheFourCurrentBusinessDocumentTypes() throws Exception { + String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("documents", long.class) + .getAnnotation(Select.class).value()); + assertThat(sql) + .contains("'DESTINATION_CLEARANCE'", "'CUSTOMS_DECLARATION'", "'DELIVERY_ORDER'", "'PICKUP_ORDER'") + .contains("ORDER BY document_type"); + } + @Test void pdaSummarySelectsRecordFieldsExplicitlyIncludingTaskVersion() throws Exception { String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("pickTask", long.class) .getAnnotation(Select.class).value()); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java index 0a7ea777f..114968d92 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java @@ -47,7 +47,13 @@ class ContainerOverviewServiceTest { 71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "door.jpg", "ACTIVE", LocalDateTime.now()), new ContainerOverviewMapper.PhotoRow( 72L, 81L, 91L, "LOADING_DOOR_OPEN", 102L, "void.jpg", "VOID", LocalDateTime.now()))); - when(mapper.documents(41L)).thenReturn(java.util.List.of()); + com.baiyue.firstleg.entity.ContainerDocumentEntity customs = new com.baiyue.firstleg.entity.ContainerDocumentEntity(); + customs.setId(103L); customs.setDocumentType("CUSTOMS_DECLARATION"); customs.setFileId(104L); + customs.setFileName("customs.pdf"); customs.setVersion(3); + com.baiyue.firstleg.entity.ContainerDocumentEntity clearance = new com.baiyue.firstleg.entity.ContainerDocumentEntity(); + clearance.setId(105L); clearance.setDocumentType("DESTINATION_CLEARANCE"); clearance.setFileId(106L); + clearance.setFileName("clearance.pdf"); clearance.setVersion(1); + when(mapper.documents(41L)).thenReturn(java.util.List.of(customs, clearance)); when(mapper.exceptions(41L)).thenReturn(java.util.List.of()); when(mapper.transport(41L)).thenReturn(java.util.List.of()); when(mapper.pickTimeline(61L)).thenReturn(java.util.List.of()); @@ -66,6 +72,15 @@ class ContainerOverviewServiceTest { assertThat(count.evidenceType()).isEqualTo("LOADING_DOOR_OPEN"); assertThat(count.count()).isEqualTo(1); }); + assertThat(overview.documents()).extracting(document -> document.documentType()) + .containsExactly("CUSTOMS_DECLARATION", "DESTINATION_CLEARANCE"); + assertThat(overview.documents()).filteredOn(document -> "CUSTOMS_DECLARATION".equals(document.documentType())) + .singleElement().satisfies(document -> assertThat(document.version()).isEqualTo(3)); + assertThat(overview.documents()).filteredOn(document -> "DESTINATION_CLEARANCE".equals(document.documentType())) + .singleElement().satisfies(document -> { + assertThat(document.fileId()).isEqualTo(106L); + assertThat(document.version()).isEqualTo(1); + }); } @Test void downloadsOnlyActivePhotoOwnedByContainerLoadingPlan() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index aac76e2e6..8d2e33874 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -450,16 +450,16 @@ class LoadingListServiceTest { String contentHash = sha256Bytes(content); var file = new MockMultipartFile("file", "customs.pdf", "application/pdf", content); var metadata = new LoadingDocumentFileClient.Metadata(101L, 0L, - "LOADING_CUSTOMS_DOCUMENT", "10", "customs.pdf", contentHash, "application/pdf", + "LOADING_CUSTOMS_DECLARATION", "10", "customs.pdf", contentHash, "application/pdf", (long) content.length); ContainerDocumentEntity saved = document(41L, LoadingListService.CUSTOMS_DECLARATION, 0, false); saved.setFileId(101L); saved.setFileName("customs.pdf"); saved.setFileHash(contentHash); when(repository.aggregate(10L)).thenReturn(plan); when(repository.history(anyString())).thenReturn(null); when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(null, saved); - when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DOCUMENT"), + when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), eq("10"), eq(file))).thenReturn(ApiResponse.success(metadata)); - when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DOCUMENT"), + when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), eq("10"), eq(101L))).thenReturn(ApiResponse.success(metadata)); when(repository.createDocument(org.mockito.ArgumentMatchers.any())).thenReturn(1); @@ -467,10 +467,108 @@ class LoadingListServiceTest { 10L, 100L, 9L, "operator", "customs-upload", 0, null, file); assertThat(result.documentType()).isEqualTo(LoadingListService.CUSTOMS_DECLARATION); - verify(documentFiles).upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DOCUMENT"), + verify(documentFiles).upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), eq("10"), eq(file)); } + @Test + void downloadsCompletedLoadingDeclarationWithExactLongFileOwnership() { + long planId = 9_007_199_254_740_993L; + long fileId = 9_007_199_254_740_995L; + AggregateRow plan = row("COMPLETED", 4); + plan.setPlanId(planId); + ContainerDocumentEntity document = document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true); + document.setFileId(fileId); + document.setFileName("出口报关单.pdf"); + byte[] content = new byte[]{1, 2, 3}; + var metadata = new LoadingDocumentFileClient.Metadata(fileId, 0L, + "LOADING_CUSTOMS_DECLARATION", Long.toString(planId), "出口报关单.pdf", hash(), + "application/pdf", (long) content.length); + when(repository.aggregate(planId)).thenReturn(plan); + when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(document); + when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), + eq(Long.toString(planId)), eq(fileId))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.download(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), + eq(Long.toString(planId)), eq(fileId))).thenReturn(org.springframework.http.ResponseEntity.ok(content)); + + var result = service.downloadLoadingDeclaration(planId, 100L, fileId); + + assertThat(result.fileName()).isEqualTo("出口报关单.pdf"); + assertThat(result.content()).containsExactly(1, 2, 3); + verify(documentFiles).download(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), + eq("9007199254740993"), eq(fileId)); + } + + @Test + void rejectsNonCurrentLoadingDeclarationFileIdBeforeFileService() { + AggregateRow plan = row("COMPLETED", 4); + when(repository.aggregate(10L)).thenReturn(plan); + when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)) + .thenReturn(document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true)); + + assertThatThrownBy(() -> service.downloadLoadingDeclaration(10L, 100L, 100L)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND); + verify(documentFiles, never()).metadata(anyString(), anyLong(), anyString(), anyString(), anyLong()); + } + + @Test + void rejectsLoadingDeclarationFileWithMismatchedBusinessOwnership() { + AggregateRow plan = row("COMPLETED", 4); + ContainerDocumentEntity document = document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true); + when(repository.aggregate(10L)).thenReturn(plan); + when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(document); + var metadata = new LoadingDocumentFileClient.Metadata(99L, 0L, + "LOADING_CUSTOMS_DECLARATION", "other-plan", "customs.pdf", hash(), + "application/pdf", 3L); + when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), + eq("10"), eq(99L))).thenReturn(ApiResponse.success(metadata)); + + assertThatThrownBy(() -> service.downloadLoadingDeclaration(10L, 100L, 99L)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND); + verify(documentFiles, never()).download(anyString(), anyLong(), anyString(), anyString(), anyLong()); + } + + @Test + void downloadsCurrentDestinationClearanceWithExactContainerOwnership() { + long bookingId = 9_007_199_254_740_993L; + long fileId = 9_007_199_254_740_995L; + AggregateRow plan = row("COMPLETED", 4); + ContainerDocumentEntity document = document(41L, LoadingListService.DESTINATION_CLEARANCE, 1, true); + document.setContainerBookingId(bookingId); + document.setFileId(fileId); + document.setFileName("destination-clearance.pdf"); + byte[] content = new byte[]{4, 5, 6}; + var metadata = new LoadingDocumentFileClient.Metadata(fileId, 0L, + "CONTAINER_CLEARANCE_DOCUMENT", Long.toString(bookingId), "destination-clearance.pdf", + hash(), "application/pdf", (long) content.length); + when(repository.aggregateByBooking(bookingId)).thenReturn(plan); + when(repository.document(bookingId, LoadingListService.DESTINATION_CLEARANCE)).thenReturn(document); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"), + eq(Long.toString(bookingId)), eq(fileId))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.download(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"), + eq(Long.toString(bookingId)), eq(fileId))).thenReturn(org.springframework.http.ResponseEntity.ok(content)); + + var result = service.downloadContainerClearance(bookingId, fileId); + + assertThat(result.fileName()).isEqualTo("destination-clearance.pdf"); + assertThat(result.content()).containsExactly(4, 5, 6); + } + + @Test + void rejectsNonCurrentDestinationClearanceFileIdAsNotFound() { + AggregateRow plan = row("COMPLETED", 4); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)) + .thenReturn(document(41L, LoadingListService.DESTINATION_CLEARANCE, 1, true)); + + assertThatThrownBy(() -> service.downloadContainerClearance(30L, 100L)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND); + verify(documentFiles, never()).metadata(anyString(), anyLong(), anyString(), anyString(), anyLong()); + } + @Test void rejectsReusedIdempotencyKeyWithDifferentAttachRequest() { AggregateRow plan = row("LOADING", 2); From 4398e8b347ca8ce71a56b99b395a60c31ca779fc Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 13:58:33 +0800 Subject: [PATCH 31/42] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- .../overseas/warehouse-overseas-service.yml | 5 + deploy/docker-compose.overseas.yml | 1 + deploy/sql/README.md | 2 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 22 +- deploy/sql/海外区域统一增量-MySQL8.sql | 6 + file/pom.xml | 7 +- .../InternalBusinessFileController.java | 15 ++ .../baiyue/file/mapper/FileObjectMapper.java | 9 + .../file/service/BusinessFileStorage.java | 10 + .../service/InternalBusinessFileService.java | 13 +- .../InternalBusinessFileControllerTest.java | 14 +- .../InternalBusinessFileServiceTest.java | 39 +++- .../client/LoadingDocumentFileClient.java | 7 + .../customs/mapper/CustomsCaseMapper.java | 8 +- .../customs/repository/CustomsRepository.java | 7 +- .../CustomsDispatchGateContributor.java | 8 +- .../repository/LoadingListRepository.java | 6 +- .../firstleg/service/LoadingListService.java | 43 +++- ...ase3-simple-customs-document-migration.sql | 4 +- .../db/phase3-smart-loading-schema.sql | 4 +- .../openapi/phase3-outbound-dispatch-v1.yaml | 2 +- .../CustomsDispatchGateContributorTest.java | 42 +++- .../OutboundDispatchSqlContractTest.java | 3 +- .../LoadingListQueryMapperContractTest.java | 15 ++ .../SmartLoadingSqlContractTest.java | 8 +- .../service/LoadingListServiceTest.java | 125 +++++++++++ lastleg-service/AGENTS.md | 5 +- .../controller/InboundPlanController.java | 5 +- .../service/InboundPlanService.java | 4 + .../mapper/InventoryBalanceMapper.java | 10 + .../service/LoadingCompletedConsumer.java | 12 +- .../service/InboundPlanServiceTest.java | 7 + .../service/LoadingCompletedConsumerTest.java | 10 +- scripts/admin-test-env-modules/README.md | 2 + .../initialization-baseline-contract.test.mjs | 3 + .../批量待分柜数据生成指南.md | 155 +++++++++++++ scripts/synchronous-dependency-baseline.mjs | 3 + .../synchronous-dependency-contract.test.mjs | 11 +- warehouse-overseas-service/AGENTS.md | 15 +- warehouse-overseas-service/pom.xml | 4 + .../BaiyueWarehouseOverseasApplication.java | 2 + .../DomesticWarehouseDirectoryClient.java | 25 +++ ...ternalWarehouseResourceLockController.java | 6 +- .../MerchantWarehouseController.java | 67 ------ .../PlatformWarehouseController.java | 171 --------------- .../dto/WarehouseDetailResponse.java | 19 -- .../warehouse/dto/WarehouseRequests.java | 120 ---------- .../entity/ServiceCapabilityEntity.java | 57 ----- .../entity/WarehouseAddressEntity.java | 77 ------- .../warehouse/entity/WarehouseEntity.java | 71 ------ .../mapper/ServiceCapabilityMapper.java | 16 -- .../mapper/WarehouseAddressMapper.java | 16 -- .../warehouse/mapper/WarehouseMapper.java | 6 - .../repository/WarehouseRepository.java | 137 ------------ .../WarehouseResourceRepository.java | 19 -- .../service/DomesticWarehouseDirectory.java | 69 ++++++ .../service/WarehouseApplicationService.java | 206 ------------------ .../service/WarehouseResourceService.java | 30 +-- .../src/main/resources/application.yml | 4 + .../src/main/resources/db/warehouse.sql | 145 +----------- .../MerchantWarehouseControllerTest.java | 36 --- .../PlatformWarehouseControllerTest.java | 83 ------- .../repository/WarehouseRepositoryTest.java | 42 ---- .../SchemaChineseCommentContractTest.java | 5 +- ...rehouseUnifiedIncrementalContractTest.java | 3 + .../DomesticWarehouseDirectoryTest.java | 63 ++++++ .../WarehouseApplicationServiceTest.java | 109 --------- .../service/WarehouseResourceServiceTest.java | 38 +--- .../InternalWarehouseController.java | 16 ++ .../dto/SharedOverseasWarehouseResponse.java | 19 ++ .../service/WarehouseApplicationService.java | 27 +++ .../db/warehouse-public-master-data.sql | 14 +- .../InternalWarehouseControllerTest.java | 14 ++ .../WarehouseSchemaContractTest.java | 8 +- .../WarehouseApplicationServiceTest.java | 19 ++ .../verify-warehouse-public-master-data.sql | 32 +++ ...house-public-master-data-contract.test.mjs | 5 +- 78 files changed, 920 insertions(+), 1549 deletions(-) create mode 100644 scripts/admin-test-env-modules/批量待分柜数据生成指南.md create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/client/DomesticWarehouseDirectoryClient.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseDetailResponse.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/ServiceCapabilityEntity.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseAddressEntity.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/ServiceCapabilityMapper.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseAddressMapper.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseMapper.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/DomesticWarehouseDirectory.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java delete mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/MerchantWarehouseControllerTest.java delete mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java delete mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/repository/WarehouseRepositoryTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/DomesticWarehouseDirectoryTest.java delete mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/dto/SharedOverseasWarehouseResponse.java diff --git a/AGENTS.md b/AGENTS.md index c35577029..2b039d350 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ | 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | | 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 | | 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 | -| 海外仓库、海外库位、海外托盘、资源锁 | `warehouse-overseas-service/` | `warehouse-overseas-service/AGENTS.md` | +| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | `warehouse/AGENTS.md`、`warehouse-overseas-service/AGENTS.md`;海外库只保存国内 warehouseId 引用 | | Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 | | PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` | diff --git a/config/nacos/overseas/warehouse-overseas-service.yml b/config/nacos/overseas/warehouse-overseas-service.yml index a05d57b33..53f24467e 100644 --- a/config/nacos/overseas/warehouse-overseas-service.yml +++ b/config/nacos/overseas/warehouse-overseas-service.yml @@ -4,3 +4,8 @@ spring: username: ${WAREHOUSE_DB_USERNAME} password: ${WAREHOUSE_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver +baiyue: + clients: + domestic-warehouse: + url: ${DOMESTIC_WAREHOUSE_URL} + cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index aae9708e7..24729ca6a 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -80,6 +80,7 @@ services: WAREHOUSE_DB_URL: ${WAREHOUSE_OVERSEAS_DB_URL} WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME} WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD} + DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} event-bridge-service: <<: *overseas-common diff --git a/deploy/sql/README.md b/deploy/sql/README.md index aabc7dd7d..5a946294c 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -10,7 +10,7 @@ - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 - 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。 - 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。 -- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建平台管理员,重复执行不覆盖现有密码。脚本不创建固定客户、客户账号或演示订单;客户必须通过正式建档/注册流程创建。正式平台公共仓库主数据已内联到固定入口。 +- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建平台管理员,重复执行不覆盖现有密码。脚本不创建固定客户、客户账号或演示订单;客户必须通过正式建档/注册流程创建。正式平台公共仓库主数据已内联到固定入口;义乌、东莞、洛杉矶、纽约四个固定作业仓均初始化 `RECEIVING`,其中洛杉矶和纽约使用海外接柜入仓收货语义,FBA 目的地址不创建作业能力。 - `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 - 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。 - IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 3943ecee3..e5963fffd 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5899,7 +5899,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = NEW.container_booking_id; IF current_plan_status = 'COMPLETED' - AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; END IF; END$$ @@ -5915,7 +5915,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.container_booking_id; IF current_plan_status = 'COMPLETED' - AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改'; END IF; END$$ @@ -6356,7 +6356,7 @@ JOIN wh_warehouse warehouse ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); --- 国内固定作业仓必须启用收货能力,FBA 目的地址仍不得创建作业能力。 +-- 四个固定平台作业仓必须启用收货能力,FBA 目的地址仍不得创建作业能力。 INSERT INTO wh_service_capability ( id, tenant_id, warehouse_id, capability_code, capability_name, enabled, cutoff_time, description, created_by, created_at, updated_by, updated_at, deleted, version @@ -6365,7 +6365,11 @@ VALUES (193000000000400001,0,193000000000200001,'RECEIVING','头程收货',1,'16:00:00', '义乌仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0), (193000000000400002,0,193000000000200002,'RECEIVING','头程收货',1,'16:00:00', - '东莞仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0) + '东莞仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0), + (193000000000400003,0,193000000000200003,'RECEIVING','海外收货',1,'16:00:00', + '洛杉矶仓海外接柜入仓收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0), + (193000000000400004,0,193000000000200004,'RECEIVING','海外收货',1,'16:00:00', + '纽约仓海外接柜入仓收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0) ON DUPLICATE KEY UPDATE capability_name = VALUES(capability_name), enabled = VALUES(enabled), @@ -7012,11 +7016,11 @@ BEGIN IF (SELECT COUNT(*) FROM wh_service_capability capability WHERE capability.tenant_id = 0 - AND capability.warehouse_id IN (193000000000200001,193000000000200002) + AND capability.warehouse_id IN (193000000000200001,193000000000200002,193000000000200003,193000000000200004) AND capability.capability_code = 'RECEIVING' AND capability.enabled = 1 - AND capability.deleted = 0) <> 2 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '国内固定作业仓收货能力写入校验失败'; + AND capability.deleted = 0) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台作业仓收货能力写入校验失败'; END IF; END// DELIMITER ; @@ -15776,7 +15780,7 @@ CREATE TRIGGER trg_container_document_before_insert BEFORE INSERT ON fl_containe BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=NEW.container_booking_id; - IF current_plan_status='COMPLETED' AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION') THEN + IF current_plan_status='COMPLETED' AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可新增'; END IF; END$$ @@ -15787,7 +15791,7 @@ CREATE TRIGGER trg_container_document_before_update BEFORE UPDATE ON fl_containe BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=OLD.container_booking_id; - IF current_plan_status='COMPLETED' AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION') THEN + IF current_plan_status='COMPLETED' AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可修改'; END IF; END$$ diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 7504a0f81..f7a4db970 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -969,6 +969,12 @@ PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt -- Warehouse Overseas 与 Lastleg 使用同一海外区域发布入口但保持独立数据库。 -- 公共托盘详情实体读取 fba_warehouse_code;旧库缺列时创建可成功但详情会 S0500。 USE baiyue_lastlag_warehouse; +-- 仓库主档唯一权威已收口至国内 baiyue_warehouse;海外库只保留 warehouse_id 引用。 +-- 当前开发阶段不兼容旧海外主档数据,按依赖顺序移除重复表。 +DROP TABLE IF EXISTS wh_service_capability; +DROP TABLE IF EXISTS wh_warehouse_address; +DROP TABLE IF EXISTS wh_warehouse; + CREATE TABLE IF NOT EXISTS wh_pallet_operation ( id BIGINT NOT NULL COMMENT '托盘状态操作ID', operation_key VARCHAR(128) NOT NULL COMMENT '跨服务幂等操作键', request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', diff --git a/file/pom.xml b/file/pom.xml index 7c0efeae4..dabcc9edb 100644 --- a/file/pom.xml +++ b/file/pom.xml @@ -14,7 +14,12 @@ com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config com.alibaba.cloudspring-cloud-starter-alibaba-sentinel - org.springframework.cloudspring-cloud-starter-openfeign + + org.springframework.cloudspring-cloud-starter-openfeign + + io.github.openfeign.formfeign-form-spring + + org.springframework.cloudspring-cloud-starter-loadbalancer org.springframework.bootspring-boot-starter-actuator org.apache.rocketmqrocketmq-spring-boot-starter diff --git a/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java b/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java index 046b1ac3a..05d90c270 100644 --- a/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java @@ -13,6 +13,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestHeader; @@ -74,6 +75,20 @@ public class InternalBusinessFileController { .body(value.content()); } + @DeleteMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") + public ApiResponse delete( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long tenantId, + @PathVariable @NotBlank String businessType, + @PathVariable @NotBlank String businessId, + @PathVariable @Positive long fileId) { + check(token); + checkTenantId(tenantId); + service.delete(tenantId, businessType, businessId, fileId, operatorId); + return ApiResponse.success(null); + } + private void check(String token) { if (token == null || !MessageDigest.isEqual( token.getBytes(StandardCharsets.UTF_8), expected.getBytes(StandardCharsets.UTF_8))) { diff --git a/file/src/main/java/com/baiyue/file/mapper/FileObjectMapper.java b/file/src/main/java/com/baiyue/file/mapper/FileObjectMapper.java index 729476614..f0faec11e 100644 --- a/file/src/main/java/com/baiyue/file/mapper/FileObjectMapper.java +++ b/file/src/main/java/com/baiyue/file/mapper/FileObjectMapper.java @@ -3,7 +3,16 @@ package com.baiyue.file.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.file.entity.FileObjectEntity; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; @Mapper public interface FileObjectMapper extends BaseMapper { + @Update("UPDATE file_object SET status='VOID',deleted=1,updated_by=#{operatorId}," + + "updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{fileId} AND tenant_id=#{tenantId} " + + "AND business_type=#{businessType} AND business_id=#{businessId} AND status='ACTIVE' " + + "AND deleted=0 AND version=#{version}") + int voidOwned(@Param("tenantId") long tenantId, @Param("businessType") String businessType, + @Param("businessId") String businessId, @Param("fileId") long fileId, + @Param("version") int version, @Param("operatorId") long operatorId); } 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 5087c9ae9..f528b7cda 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java @@ -65,6 +65,16 @@ public class BusinessFileStorage { } } + boolean delete(String objectKey) { + Path target = root.resolve(objectKey).normalize(); + if (!target.startsWith(root)) return false; + try { + return Files.deleteIfExists(target); + } catch (IOException exception) { + return false; + } + } + private void verifyContent(MultipartFile file, String type) { try (InputStream input = file.getInputStream()) { byte[] prefix = input.readNBytes(4096); diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index 86268a298..7ac68d48b 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -7,6 +7,7 @@ import com.baiyue.file.entity.FileObjectEntity; import com.baiyue.file.mapper.FileObjectMapper; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; @@ -20,7 +21,7 @@ public class InternalBusinessFileService { "CUSTOMS_RECEIPT", "FINANCE_VOUCHER", "CONTAINER_CLEARANCE_DOCUMENT", - "LOADING_CUSTOMS_DOCUMENT", + "LOADING_CUSTOMS_DECLARATION", "CONTAINER_DELIVERY_ORDER", "CONTAINER_PICKUP_ORDER", "OVERSEAS_UNLOAD_PHOTO", @@ -70,6 +71,16 @@ public class InternalBusinessFileService { return new Download(value.getOriginalName(), value.getContentType(), storage.read(value.getObjectKey())); } + @Transactional + public void delete(long tenantId, String businessType, String businessId, long fileId, long operatorId) { + if (operatorId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "操作人必须有效"); + FileObjectEntity value = required(tenantId, businessType, businessId, fileId); + int changed = files.voidOwned(tenantId, businessType, businessId, fileId, + value.getVersion(), operatorId); + if (changed != 1) throw new BizException(ResultCode.CONFLICT, "业务附件已变化,请刷新后重试"); + storage.delete(value.getObjectKey()); + } + private FileObjectEntity required(long tenantId, String businessType, String businessId, long fileId) { validateScope(tenantId, businessType, businessId, fileId); FileObjectEntity value = files.selectOne(new LambdaQueryWrapper() diff --git a/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java b/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java index 96c7bb064..65ea3cbae 100644 --- a/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java +++ b/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java @@ -35,14 +35,22 @@ class InternalBusinessFileControllerTest { } @Test void zeroTenantDownloadReturnsOwnedContentWithoutCaching() { - when(service.download(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L)) + when(service.download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L)) .thenReturn(new InternalBusinessFileService.Download("declaration.pdf", "application/pdf", new byte[]{1, 2})); - var response = controller.download("internal-token", 0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L); + var response = controller.download("internal-token", 0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L); assertThat(response.getBody()).containsExactly(1, 2); assertThat(response.getHeaders().getCacheControl()).contains("no-store"); - verify(service).download(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L); + verify(service).download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L); + } + + @Test void exactOwnershipCompensationDeleteRemainsBehindInternalToken() { + var response = controller.delete("internal-token", 7L, 0L, + "CONTAINER_DELIVERY_ORDER", "41", 91L); + + assertThat(response.successful()).isTrue(); + verify(service).delete(0L, "CONTAINER_DELIVERY_ORDER", "41", 91L, 7L); } @Test void rejectsInvalidTokenAndNegativeTenantBeforeServiceAccess() { diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index f0cc94a06..2840fa856 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -47,7 +47,7 @@ class InternalBusinessFileServiceTest { }); var clearance = service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file); - var customs = service.upload(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 7L, file); + var customs = service.upload(0L, "LOADING_CUSTOMS_DECLARATION", "51", 7L, file); var deliveryOrder = service.upload(0L, "CONTAINER_DELIVERY_ORDER", "41", 7L, file); var pickupOrder = service.upload(0L, "CONTAINER_PICKUP_ORDER", "41", 7L, file); var unloadPhoto = service.upload(11L, "OVERSEAS_UNLOAD_PHOTO", "51", 7L, file); @@ -57,7 +57,7 @@ class InternalBusinessFileServiceTest { InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41"); assertThat(customs).extracting(InternalBusinessFileService.Metadata::tenantId, InternalBusinessFileService.Metadata::businessType, - InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "LOADING_CUSTOMS_DOCUMENT", "51"); + InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "LOADING_CUSTOMS_DECLARATION", "51"); assertThat(deliveryOrder.businessType()).isEqualTo("CONTAINER_DELIVERY_ORDER"); assertThat(pickupOrder.businessType()).isEqualTo("CONTAINER_PICKUP_ORDER"); assertThat(unloadPhoto).extracting(InternalBusinessFileService.Metadata::tenantId, @@ -119,22 +119,47 @@ class InternalBusinessFileServiceTest { @Test void platformMetadataAndDownloadRequireExactCompositeOwnership() { FileObjectEntity value = new FileObjectEntity(); value.setId(91L); value.setTenantId(0L); - value.setBusinessType("LOADING_CUSTOMS_DOCUMENT"); value.setBusinessId("51"); + value.setBusinessType("LOADING_CUSTOMS_DECLARATION"); value.setBusinessId("51"); value.setOriginalName("declaration.pdf"); value.setObjectKey("business-files/0/51/declaration.pdf"); value.setContentType("application/pdf"); value.setSha256("c".repeat(64)); value.setSizeBytes(8L); when(files.selectOne(any())).thenReturn(value, value, null, null, null); when(storage.read(value.getObjectKey())).thenReturn("document".getBytes()); - assertThat(service.metadata(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L).fileName()) + assertThat(service.metadata(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L).fileName()) .isEqualTo("declaration.pdf"); - assertThat(service.download(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L).content()) + assertThat(service.download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L).content()) .isEqualTo("document".getBytes()); assertThatThrownBy(() -> service.metadata(0L, "CONTAINER_CLEARANCE_DOCUMENT", "51", 91L)) .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); - assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DOCUMENT", "52", 91L)) + assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DECLARATION", "52", 91L)) .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); - assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 92L)) + assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DECLARATION", "51", 92L)) .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); verify(storage).read(value.getObjectKey()); } + + @Test void compensationDeleteRequiresExactOwnershipAndLogicallyVoidsBeforeStorageCleanup() { + FileObjectEntity value = new FileObjectEntity(); + value.setId(91L); value.setTenantId(0L); value.setBusinessType("CONTAINER_DELIVERY_ORDER"); + value.setBusinessId("41"); value.setStatus("ACTIVE"); value.setDeleted(false); value.setVersion(0); + value.setObjectKey("business-files/0/41/do.pdf"); + when(files.selectOne(any())).thenReturn(value); + when(files.voidOwned(0L, "CONTAINER_DELIVERY_ORDER", "41", 91L, 0, 7L)).thenReturn(1); + when(storage.delete(value.getObjectKey())).thenReturn(true); + + service.delete(0L, "CONTAINER_DELIVERY_ORDER", "41", 91L, 7L); + + verify(files).voidOwned(0L, "CONTAINER_DELIVERY_ORDER", "41", 91L, 0, 7L); + verify(storage).delete(value.getObjectKey()); + } + + @Test void compensationMapperUsesExactCompositeOwnershipAndOptimisticVersion() throws Exception { + String sql = String.join(" ", FileObjectMapper.class.getMethod("voidOwned", long.class, + String.class, String.class, long.class, int.class, long.class) + .getAnnotation(org.apache.ibatis.annotations.Update.class).value()); + assertThat(sql) + .contains("id=#{fileId}", "tenant_id=#{tenantId}", "business_type=#{businessType}") + .contains("business_id=#{businessId}", "status='ACTIVE'", "deleted=0", "version=#{version}") + .contains("status='VOID'", "deleted=1", "version=version+1"); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java index e025b7d3b..307360ec6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.client; import com.baiyue.common.core.api.ApiResponse; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestHeader; @@ -29,6 +30,12 @@ public interface LoadingDocumentFileClient { ResponseEntity download(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable long tenantId,@PathVariable String businessType,@PathVariable String businessId,@PathVariable long fileId); + @DeleteMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") + ApiResponse delete(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long tenantId, @PathVariable String businessType, + @PathVariable String businessId, @PathVariable long fileId); + record Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String sha256, String contentType, Long sizeBytes) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java index 8f12886f8..68a968aa4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java @@ -2,4 +2,10 @@ package com.baiyue.firstleg.customs.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; import org.apache.ibatis.annotations.Mapper; -@Mapper public interface CustomsCaseMapper extends BaseMapper {} +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +@Mapper public interface CustomsCaseMapper extends BaseMapper { + @Select("SELECT COUNT(*) FROM fl_container_document WHERE container_booking_id=#{containerBookingId} " + + "AND document_type='CUSTOMS_DECLARATION' AND completed_at IS NOT NULL") + int completedLoadingDeclaration(@Param("containerBookingId") long containerBookingId); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java index 6bf93297d..3d8a35e42 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java @@ -81,11 +81,8 @@ public class CustomsRepository { .eq(CustomsCaseEntity::getContainerBookingId, containerBookingId) .orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1")); } - public CustomsCaseEntity clearedContainerCase(long containerBookingId) { - return cases.selectOne(new LambdaQueryWrapper() - .eq(CustomsCaseEntity::getContainerBookingId, containerBookingId) - .eq(CustomsCaseEntity::getStatus, "CLEARANCE_COMPLETED") - .orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1")); + public boolean loadingDeclarationCompleted(long containerBookingId) { + return cases.completedLoadingDeclaration(containerBookingId) == 1; } public boolean containerCarriesOrder(long containerId, long tenantId, long orderId) { LoadingPlanEntity plan = loadingPlans.selectOne(new LambdaQueryWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java index ed2c87fc9..61a048f5a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java @@ -1,6 +1,5 @@ package com.baiyue.firstleg.customs.service; -import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; import com.baiyue.firstleg.customs.repository.CustomsRepository; import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor; @@ -18,12 +17,11 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr if (context.tenantId() <= 0 || context.customerId() <= 0) { return gate(false, "INVALID_CUSTOMER_SCOPE"); } - CustomsCaseEntity value = repository.clearedContainerCase(context.containerBookingId()); - if (value == null) return gate(false, "MISSING"); - return gate("CLEARANCE_COMPLETED".equals(value.getStatus()), value.getStatus()); + boolean completed = repository.loadingDeclarationCompleted(context.containerBookingId()); + return gate(completed, completed ? "COMPLETED" : "MISSING"); } private static GateItem gate(boolean passed, String actual) { - return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "CLEARANCE_COMPLETED"); + return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "COMPLETED"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java index 86fad576e..e363c9a2d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java @@ -106,9 +106,13 @@ public class LoadingListRepository { String fromStatus, String toStatus, String beforeSnapshot, String afterSnapshot, long operatorId, String operatorName, String operationReason) { - mapper.insertHistory(new HistoryWrite(IdWorker.getId(), planId, operationId, actionType, + int changed = mapper.insertHistory(new HistoryWrite(IdWorker.getId(), planId, operationId, actionType, fromStatus, toStatus, beforeSnapshot, afterSnapshot, operatorId, operatorName, operationReason)); + if (changed != 1) { + throw new com.baiyue.common.core.exception.BizException( + com.baiyue.common.core.api.ResultCode.CONFLICT, "装柜历史已变化,请刷新后重试"); + } } private Map> customerMap(List rows) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index fc96d5cf4..4d6d63f49 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -31,12 +31,15 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker; import feign.FeignException; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.context.annotation.Lazy; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; import org.springframework.http.MediaType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -49,6 +52,7 @@ import java.util.Set; @Service public class LoadingListService { + private static final Logger log = LoggerFactory.getLogger(LoadingListService.class); private static final Set PRELOAD_STATUSES = Set.of("EDITING", "LOADING"); public static final String DESTINATION_CLEARANCE = "DESTINATION_CLEARANCE"; public static final String CUSTOMS_DECLARATION = "CUSTOMS_DECLARATION"; @@ -317,8 +321,16 @@ public class LoadingListService { requireReplaceableDocument(current, expectedVersion); LoadingDocumentFileClient.Metadata metadata = uploadDocumentFile( businessType, businessId, operatorId, file, fileHash); - return persistDocument(plan, documentType, current, metadata, operatorId, operatorName, - operationId, "UPLOAD_" + documentType, requestHash, operationReason); + try { + return persistDocument(plan, documentType, current, metadata, operatorId, operatorName, + operationId, "UPLOAD_" + documentType, requestHash, operationReason); + } catch (RuntimeException exception) { + compensateUploadedFile(businessType, businessId, metadata.fileId(), operatorId); + if (exception instanceof DataIntegrityViolationException) { + throw conflict("资料状态或数据库约束已变化,请刷新后重试"); + } + throw exception; + } } private BusinessDocument persistDocument(AggregateRow plan, String documentType, @@ -337,12 +349,18 @@ public class LoadingListService { if (changed != 1) throw conflict("资料版本已变化,请刷新后重试"); } catch (DuplicateKeyException exception) { throw conflict("资料已被其他操作上传,请刷新后重试"); + } catch (DataIntegrityViolationException exception) { + throw conflict("资料状态或数据库约束已变化,请刷新后重试"); } ContainerDocumentEntity saved = requireDocumentEntity(plan.getContainerBookingId(), documentType); - repository.appendHistory(plan.getPlanId(), operationId, actionType, - plan.getPlanStatus(), plan.getPlanStatus(), json(beforeSnapshot), - json(values("requestHash", requestHash, "document", documentSnapshot(saved))), - operatorId, trim(operatorName), trim(operationReason)); + try { + repository.appendHistory(plan.getPlanId(), operationId, actionType, + plan.getPlanStatus(), plan.getPlanStatus(), json(beforeSnapshot), + json(values("requestHash", requestHash, "document", documentSnapshot(saved))), + operatorId, trim(operatorName), trim(operationReason)); + } catch (DataIntegrityViolationException exception) { + throw conflict("资料历史约束已变化,请刷新后重试"); + } return document(saved); } @@ -414,6 +432,19 @@ public class LoadingListService { } } + private void compensateUploadedFile(String businessType, String businessId, long fileId, long operatorId) { + try { + var response = documentFiles.delete(internalToken, operatorId, 0, businessType, businessId, fileId); + if (response == null || !response.successful()) { + log.error("Business file compensation failed: type={}, businessId={}, fileId={}", + businessType, businessId, fileId); + } + } catch (RuntimeException exception) { + log.error("Business file compensation request failed: type={}, businessId={}, fileId={}", + businessType, businessId, fileId, exception); + } + } + private static void requireReplaceableDocument(ContainerDocumentEntity current, int expectedVersion) { if (current == null && expectedVersion != 0) throw conflict("资料版本已变化,请刷新后重试"); if (current != null && !java.util.Objects.equals(current.getVersion(), expectedVersion)) { diff --git a/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql b/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql index 552d13600..1ee186d2b 100644 --- a/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql +++ b/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql @@ -70,7 +70,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = NEW.container_booking_id; IF current_plan_status = 'COMPLETED' - AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可新增'; END IF; END$$ @@ -84,7 +84,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.container_booking_id; IF current_plan_status = 'COMPLETED' - AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可修改'; END IF; END$$ diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index ab4efc31c..5033d5924 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -1122,7 +1122,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = NEW.container_booking_id; IF current_plan_status = 'COMPLETED' - AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; END IF; END$$ @@ -1138,7 +1138,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.container_booking_id; IF current_plan_status = 'COMPLETED' - AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改'; END IF; END$$ diff --git a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml index aeeeb7b18..364c0b142 100644 --- a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml @@ -37,7 +37,7 @@ paths: /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/gate: get: summary: Evaluate every inbound binding in the fail-closed container outbound gate - description: Loading evidence is evaluated once for the physical container/loading plan and the same result applies to every inbound binding. Customs clearance is also resolved once at container scope. A missing standalone sailing schedule is reference-only and never affects gate.passed. + description: Loading evidence is evaluated once for the physical container/loading plan and the same result applies to every inbound binding. The customs gate is resolved once from the current container CUSTOMS_DECLARATION whose completedAt must be non-null; upload alone remains blocked and fl_customs_case is not an alternative gate fact. A missing standalone sailing schedule is reference-only and never affects gate.passed. x-permission: platform:firstleg:outbound-dispatch:check parameters: - {$ref: '#/components/parameters/ContainerBookingIdPath'} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java index be11e6816..dc499e353 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.customs.service; -import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; import com.baiyue.firstleg.customs.repository.CustomsRepository; +import com.baiyue.firstleg.customs.mapper.CustomsCaseMapper; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -22,19 +22,37 @@ class CustomsDispatchGateContributorTest { .isEqualTo("INVALID_CUSTOMER_SCOPE"); } - @Test void passesOnlyCanonicalClearanceCompletedCase() { - CustomsCaseEntity value = new CustomsCaseEntity(); - assertThat(contributor.assess(context()).passed()).isFalse(); - value.setStatus("CLEARANCE_COMPLETED"); - when(repository.clearedContainerCase(81L)).thenReturn(value); - assertThat(contributor.assess(context())) - .satisfies(item -> { - assertThat(item.passed()).isTrue(); - assertThat(item.requirement()).isEqualTo("CLEARANCE_COMPLETED"); - }); - verify(repository, times(2)).clearedContainerCase(81L); + @Test void uploadedButIncompleteDeclarationRemainsBlocked() { + when(repository.loadingDeclarationCompleted(81L)).thenReturn(false); + + assertThat(contributor.assess(context())).satisfies(item -> { + assertThat(item.passed()).isFalse(); + assertThat(item.actual()).isEqualTo("MISSING"); + assertThat(item.requirement()).isEqualTo("COMPLETED"); + }); + } + + @Test void completedLoadingDeclarationPassesWithoutReadingCustomsCase() { + when(repository.loadingDeclarationCompleted(81L)).thenReturn(true); + + assertThat(contributor.assess(context())).satisfies(item -> { + assertThat(item.passed()).isTrue(); + assertThat(item.actual()).isEqualTo("COMPLETED"); + assertThat(item.requirement()).isEqualTo("COMPLETED"); + }); + verify(repository).loadingDeclarationCompleted(81L); verify(repository, never()).gateCase(anyLong(), anyLong(), anyLong(), anyLong()); } + @Test void mapperUsesOnlyCompletedLoadingDeclarationDocumentFact() throws Exception { + String sql = String.join(" ", CustomsCaseMapper.class + .getMethod("completedLoadingDeclaration", long.class) + .getAnnotation(org.apache.ibatis.annotations.Select.class).value()); + assertThat(sql) + .contains("FROM fl_container_document", "container_booking_id=#{containerBookingId}") + .contains("document_type='CUSTOMS_DECLARATION'", "completed_at IS NOT NULL") + .doesNotContain("fl_customs_case", "CLEARANCE_COMPLETED"); + } + private static Context context() { return new Context(10001L, 10002L, 21L, 31L, 81L, 91L, null); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java index 024cefac7..0480e784e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java @@ -51,7 +51,8 @@ class OutboundDispatchSqlContractTest { .contains("absence never blocks manual outbound confirmation") .contains("PENDING or any non-terminal state never affects gate.passed") .contains("Loading evidence is evaluated once for the physical container/loading plan") - .contains("Customs clearance is also resolved once at container scope") + .contains("CUSTOMS_DECLARATION whose completedAt must be non-null") + .contains("fl_customs_case is not an alternative gate fact") .contains("missing standalone sailing schedule is reference-only") .contains("expectedVersion") .contains("Idempotency-Key") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java index 11e77ed09..d25d76a32 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -116,6 +116,21 @@ class LoadingListQueryMapperContractTest { .contains("operator_id"); } + @Test + void deliveryAndPickupUseTheCanonicalDocumentInsertAndPlanHistoryMapper() throws Exception { + String documentInsert = sql("insertDocument", Insert.class); + String historyInsert = sql("insertHistory", Insert.class); + assertThat(documentInsert) + .contains("INSERT INTO fl_container_document") + .contains("#{documentType}", "#{fileId}", "#{fileHash}") + .contains("CURRENT_TIMESTAMP(3)", "version") + .doesNotContain("CUSTOMS_DECLARATION", "DESTINATION_CLEARANCE"); + assertThat(historyInsert) + .contains("INSERT INTO fl_loading_plan_history") + .contains("#{planId}", "#{operationId}", "#{actionType}") + .contains("#{beforeSnapshot}", "#{afterSnapshot}"); + } + private static String sql(String methodName, Class annotationType) throws Exception { Method method = java.util.Arrays.stream(LoadingListQueryMapper.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(methodName)) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index 27c6c17a3..e5c1ddc08 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -151,8 +151,8 @@ class SmartLoadingSqlContractTest { .contains("CREATE TRIGGER trg_container_booking_before_update") .contains("current_plan_status IN ('LOADING','COMPLETED')") .contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据") - .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')") - .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')"); + .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')") + .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')"); } private static void assertSimpleCustomsDocumentContract(String sql) { @@ -161,8 +161,8 @@ class SmartLoadingSqlContractTest { .contains("file_hash VARCHAR(64) NULL") .contains("completed_by BIGINT UNSIGNED NULL") .contains("completed_at DATETIME(3) NULL") - .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')") - .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')") + .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')") + .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')") .doesNotContain("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN\n SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 8d2e33874..de1ef024c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -36,7 +36,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -597,6 +599,129 @@ class LoadingListServiceTest { verify(repository, never()).aggregateByBooking(30L); } + @Test + void firstDeliveryOrderUploadPersistsDocumentAndImmutablePlanHistory() { + AggregateRow plan = row("COMPLETED", 4); + byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content); + String contentHash = sha256Bytes(content); + var metadata = new LoadingDocumentFileClient.Metadata(101L, 0L, + "CONTAINER_DELIVERY_ORDER", "30", "do.pdf", contentHash, "application/pdf", (long) content.length); + ContainerDocumentEntity saved = document(51L, LoadingListService.DELIVERY_ORDER, 0, false); + saved.setFileId(101L); saved.setFileName("do.pdf"); saved.setFileHash(contentHash); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DELIVERY_ORDER)).thenReturn(null, saved); + when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(101L))).thenReturn(ApiResponse.success(metadata)); + when(repository.createDocument(any())).thenReturn(1); + + var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.DELIVERY_ORDER, + 9L, "operator", "do-first", 0, null, file); + + assertThat(result).extracting(value -> value.documentType(), value -> value.fileId(), value -> value.version()) + .containsExactly(LoadingListService.DELIVERY_ORDER, 101L, 0); + verify(repository).appendHistory(eq(10L), anyString(), eq("UPLOAD_DELIVERY_ORDER"), + eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull()); + verify(documentFiles, never()).delete(anyString(), anyLong(), anyLong(), anyString(), anyString(), anyLong()); + } + + @Test + void pickupOrderUpdateUsesCurrentVersionAndReturnsUpdatedDocument() { + AggregateRow plan = row("COMPLETED", 4); + byte[] content = "pickup-order".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var file = new MockMultipartFile("file", "pu.pdf", "application/pdf", content); + String contentHash = sha256Bytes(content); + ContainerDocumentEntity current = document(52L, LoadingListService.PICKUP_ORDER, 2, false); + ContainerDocumentEntity saved = document(52L, LoadingListService.PICKUP_ORDER, 3, false); + saved.setFileId(102L); saved.setFileName("pu.pdf"); saved.setFileHash(contentHash); + var metadata = new LoadingDocumentFileClient.Metadata(102L, 0L, + "CONTAINER_PICKUP_ORDER", "30", "pu.pdf", contentHash, "application/pdf", (long) content.length); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.PICKUP_ORDER)).thenReturn(current, saved); + when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_PICKUP_ORDER"), + eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_PICKUP_ORDER"), + eq("30"), eq(102L))).thenReturn(ApiResponse.success(metadata)); + when(repository.replaceDocument(any())).thenReturn(1); + + var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.PICKUP_ORDER, + 9L, "operator", "pu-update", 2, null, file); + + assertThat(result.fileId()).isEqualTo(102L); + assertThat(result.version()).isEqualTo(3); + verify(repository).replaceDocument(any()); + verify(repository).appendHistory(eq(10L), anyString(), eq("UPLOAD_PICKUP_ORDER"), + eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull()); + } + + @Test + void staleDeliveryOrderVersionReturnsConflictBeforeUploadingAnotherFile() { + AggregateRow plan = row("COMPLETED", 4); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DELIVERY_ORDER)) + .thenReturn(document(51L, LoadingListService.DELIVERY_ORDER, 2, false)); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", "do".getBytes()); + + assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L, + LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-stale", 1, null, file)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.CONFLICT); + verify(documentFiles, never()).upload(anyString(), anyLong(), anyLong(), anyString(), anyString(), any()); + } + + @Test + void sameDeliveryOrderUploadKeyReplaysWithoutUploadingAgain() { + AggregateRow plan = row("COMPLETED", 4); + byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content); + String requestHash = sha256("UPLOAD|DELIVERY_ORDER|30|0|null|" + sha256Bytes(content)); + HistoryRow replay = new HistoryRow(); + replay.setAfterSnapshot("{\"requestHash\":\"" + requestHash + "\"}"); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(replay); + when(repository.document(30L, LoadingListService.DELIVERY_ORDER)) + .thenReturn(document(51L, LoadingListService.DELIVERY_ORDER, 0, false)); + + var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.DELIVERY_ORDER, + 9L, "operator", "do-replay", 0, null, file); + + assertThat(result.documentType()).isEqualTo(LoadingListService.DELIVERY_ORDER); + verify(documentFiles, never()).upload(anyString(), anyLong(), anyLong(), anyString(), anyString(), any()); + } + + @Test + void databaseFailureAfterFileUploadTriggersExactOwnershipCompensationAndBusinessConflict() { + AggregateRow plan = row("COMPLETED", 4); + byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content); + String contentHash = sha256Bytes(content); + var metadata = new LoadingDocumentFileClient.Metadata(103L, 0L, + "CONTAINER_DELIVERY_ORDER", "30", "do.pdf", contentHash, "application/pdf", (long) content.length); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DELIVERY_ORDER)).thenReturn(null); + when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(103L))).thenReturn(ApiResponse.success(metadata)); + when(repository.createDocument(any())).thenThrow( + new org.springframework.dao.DataIntegrityViolationException("trigger rejected")); + when(documentFiles.delete(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(103L))).thenReturn(ApiResponse.success(null)); + + assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L, + LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-db-fail", 0, null, file)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.CONFLICT); + verify(documentFiles).delete(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(103L)); + } + @Test void confirmsEveryLinkedPreloadAndCreatesSinglePdaTaskAtomically() { AggregateRow before = row("EDITING", 3); diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 828e0e050..6a028f73e 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -2,7 +2,7 @@ ## 职责与边界 -- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立尾程订单、明细冻结快照和初始状态历史。 +- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立待分配尾程订单、明细冻结快照和初始状态历史;事件不得自动创建入仓计划,平台人工调用 `/api/platform/v1/last-leg/inbound-plans/convert` 后才生成计划并推进订单。 - 负责尾程订单列表、详情、导出和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业;PDA 卸柜完成后通过本地 Outbox 向国内头程回传货柜报空事实。 - 不查询头程数据库,不调用头程接口补齐建单字段;事件快照缺失时应拒绝消费并保留可诊断错误。 @@ -49,7 +49,7 @@ - 四期在本服务内只进入尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购和本土卡车;本土散板新增实现也归本服务的独立稳定子域。 - `dispatch/`、`delivery/`、出库单、PDA 出库拣货、派送和其他库存离仓能力虽然已存在于服务代码或表结构中,但不属于当前四期开发与验收范围,不得因代码已存在而自动纳入。 - 历史排车编辑/取消/出库、PDA 出库和 `delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。平台排车列表与详情由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。预排只能创建待出库排车并锁定托盘,不得借此开放编辑、取消、depart、PDA 出库或派送。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。 -- 海外仓库、库位、实物托盘和资源锁归 `warehouse-overseas-service/`;浏览器 API 统一经 `gateway-overseas/`,本服务不得复制仓储资源主数据或另建公网认证入口。 +- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway-overseas/`。 ## 数据库表索引与注释约束 @@ -77,6 +77,7 @@ - 其他订单类型只建立入仓单维度的尾程订单与冻结明细,来源 `palletCount` 强制为 0;后续 PDA 托盘属于海外仓作业资源。 - 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。 - 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 +- 装柜完成事件只把尾程订单建立为 `PENDING_ASSIGNMENT`。`FIRST_LEG` 在途库存使用不可变来源入仓码作为计划生成前的 `inbound_order_no` 展示快照;平台人工转换成功后,同一事务将订单推进为 `INBOUND_PLANNED`,并把对应库存余额的该快照收口为新 `planNo`。 - 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 - IAM 安全事件只能进入 `/internal/lastleg/security/v1/events`;通用 `/internal/lastleg/events` 只白名单接收装柜完成与实际船节点事件,禁止把 IAM 事件交给 `LoadingCompletedConsumer`。 - Lastleg 安全投影只接受 `ACTIVE`/`DISABLED` 两态安全语义;客户建档和审核的 `PROFILE_REQUIRED`/`PENDING`/`REJECTED`/`ARCHIVED` 由 IAM 在发布前统一折叠为 `DISABLED`,Lastleg 不兼容或解释国内工作流状态。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 3bdb1f82d..3f8c3da74 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -44,8 +44,9 @@ public class InboundPlanController { @PostMapping("/convert") @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") - /** 将入仓单转换为入仓计划。 */ - @Operation(summary = "入仓单转入仓计划", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) + /** 由平台人工将待分配尾程订单转换为入仓计划。 */ + @Operation(summary = "人工创建入仓计划", description = "头程装柜完成只生成待分配尾程订单;平台确认后调用本接口创建入仓计划。Required authority: " + + OverseasPermissions.INBOUND_ORDER_CREATE) public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ConvertInboundPlanRequest request) { return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 5dd754553..ec121e6d7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -18,6 +18,7 @@ import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanQueryMapper; import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -40,6 +41,7 @@ public class InboundPlanService { @Resource private InboundPlanPalletAllocationMapper allocationMapper; @Resource private InboundPlanOrderMapper orderMapper; @Resource private InboundPlanQueryMapper queryMapper; + @Resource private InventoryBalanceMapper inventoryBalanceMapper; @Transactional(readOnly = true) public PageResult page(String customerId, InboundPlanRequests.Query query) { @@ -108,6 +110,8 @@ public class InboundPlanService { if (orderMapper.markPlanned(customerId, order.orderId(), plan.getPlanNo(), count, now) != 1) { throw new IllegalStateException("Failed to update order " + order.orderId()); } + inventoryBalanceMapper.bindInboundPlan(customerId, order.orderId(), order.inboundCode(), + plan.getPlanNo(), now); orderMapper.insertHistory(customerId, order.orderId(), order.currentStatus(), "Inbound order converted to inbound plan " + plan.getPlanNo(), now); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java index 437a9dcab..9fddae72c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java @@ -5,7 +5,9 @@ import com.baiyue.lastleg.inventory.model.InventorySummary; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import java.time.LocalDateTime; import java.util.List; public interface InventoryBalanceMapper extends BaseMapper { @@ -29,6 +31,14 @@ public interface InventoryBalanceMapper extends BaseMapper { @Param("inboundOrderId") Long inboundOrderId, @Param("inboundCode") String inboundCode); + @Update("UPDATE ll_inventory_balance SET inbound_order_no=#{planNo},updated_at=#{updatedAt} " + + "WHERE customer_id=#{customerId} AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode}") + int bindInboundPlan(@Param("customerId") String customerId, + @Param("inboundOrderId") Long inboundOrderId, + @Param("inboundCode") String inboundCode, + @Param("planNo") String planNo, + @Param("updatedAt") LocalDateTime updatedAt); + @Select("