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] =?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`。