This commit is contained in:
2026-08-25 15:08:34 +08:00
parent e815abcbd7
commit 44198866d9
64 changed files with 1382 additions and 360 deletions
+21
View File
@@ -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:
+7 -3
View File
@@ -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 @@
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -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 <name>`,模块名称和静态合同先查 `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 通过不代表视觉验收完成。
## 阶段文档入口
@@ -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) { }
}
@@ -5,5 +5,6 @@ public record MerchantRegisterResponse(
Long tenantId,
Long membershipId,
String tenantStatus,
String dimensionInitializationStatus,
String nextStep
) { }
@@ -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) {
+2
View File
@@ -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`。
+2 -1
View File
@@ -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`。
@@ -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,
@@ -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 或四期业务状态机。
## 退出顺序
+4
View File
@@ -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 消费者。
@@ -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 跳过,构建成功。
@@ -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();
@@ -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, []);
});
@@ -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
@@ -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<OceanBookingStatus> 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;
@@ -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
@@ -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<ChannelRow> channels(long versionId, long originWarehouseId) {
return reader.findChannels(versionId, originWarehouseId);
}
@Override public List<ChannelTaxModeRow> channelTaxModes(long versionId, long originWarehouseId) {
return reader.findChannelTaxModes(versionId, originWarehouseId);
}
@Override public List<RateRow> fbaRates(long versionId, long originWarehouseId, String channelCode,
String destinationCode, String pricingTaxMode) {
return reader.findFbaRates(versionId, originWarehouseId, channelCode, destinationCode, pricingTaxMode);
}
@Override public List<RateRow> overseasRates(long versionId, long originWarehouseId, String channelCode,
long destinationWarehouseId, String pricingTaxMode) {
return reader.findOverseasRates(versionId, originWarehouseId, channelCode, destinationWarehouseId,
pricingTaxMode);
}
@Override public List<RateRow> 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);
}
}
@@ -1,4 +1,4 @@
package com.baiyue.firstleg.repository;
package com.baiyue.firstleg.pricebookquery.application.model;
import lombok.Getter;
import lombok.Setter;
@@ -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<ChannelRow> channels(long versionId, long originWarehouseId);
List<ChannelTaxModeRow> channelTaxModes(long versionId, long originWarehouseId);
List<RateRow> fbaRates(long versionId, long originWarehouseId, String channelCode,
String destinationCode, String pricingTaxMode);
List<RateRow> overseasRates(long versionId, long originWarehouseId, String channelCode,
long destinationWarehouseId, String pricingTaxMode);
List<RateRow> regionTiers(long versionId, long originWarehouseId, String channelCode,
String regionCode, String pricingTaxMode);
boolean channelAvailable(long versionId, long originWarehouseId, String channelCode);
ChannelPrefixRow channelPrefix(String channelCode);
}
@@ -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<ChannelRow> findChannels(long versionId, long originWarehouseId);
List<ChannelTaxModeRow> findChannelTaxModes(long versionId, long originWarehouseId);
List<RateRow> findFbaRates(long versionId, long originWarehouseId, String channelCode,
String destinationCode, String pricingTaxMode);
List<RateRow> findOverseasRates(long versionId, long originWarehouseId, String channelCode,
long destinationWarehouseId, String pricingTaxMode);
List<RateRow> findRegionTiers(long versionId, long originWarehouseId, String channelCode,
String regionCode, String pricingTaxMode);
ChannelPrefixRow findChannelPrefix(String channelCode);
}
@@ -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;
@@ -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;
@@ -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;
@@ -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<PriceBookQueryRows.ChannelRow> channels(long versionId, long originWarehouseId) {
return mapper.findChannels(versionId, originWarehouseId);
}
public List<PriceBookQueryRows.ChannelTaxModeRow> channelTaxModes(long versionId, long originWarehouseId) {
return mapper.findChannelTaxModes(versionId, originWarehouseId);
}
public List<PriceBookQueryRows.RateRow> fbaRates(long versionId, long originWarehouseId,
String channelCode, String destinationCode,
String pricingTaxMode) {
return mapper.findFbaRates(versionId, originWarehouseId, channelCode, destinationCode, pricingTaxMode);
}
public List<PriceBookQueryRows.RateRow> overseasRates(long versionId, long originWarehouseId,
String channelCode, long destinationWarehouseId,
String pricingTaxMode) {
return mapper.findOverseasRates(versionId, originWarehouseId, channelCode, destinationWarehouseId,
pricingTaxMode);
}
public List<PriceBookQueryRows.RateRow> 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);
}
}
@@ -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;
@@ -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;
@@ -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<CurrentChannel> channels(long merchantTenantId, long originWarehouseId) {
@@ -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");
}
@@ -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;
@@ -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;
@@ -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);
}
}
@@ -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);
}
}
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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"]
}
}
}
+5 -1
View File
@@ -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。
## 全量验收导航
@@ -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<TenantResponse> 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<CustomerDimensionInitializationStatus> 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 = "校验客户联系电话唯一性")
@@ -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,
@@ -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,
@@ -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;
@@ -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
) { }
@@ -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) {
}
@@ -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<CustomerDimensionInitializationStatus> findStatus(long tenantId) {
List<CustomerDimensionInitializationStatus> 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<NOW(3)");
}
public List<Long> 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;
}
}
@@ -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<String> 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<Void> response = client.compensate(
internalToken, task.tenantId(), task.initializationKey());
requireSuccess(response, false);
repository.markCancelled(task, workerId);
return;
}
ApiResponse<CustomerDimensionRuleClient.InitializationResponse> 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;
}
}
}
@@ -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<CustomerDimensionRuleClient.InitializationResponse> 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<Void> 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) { }
}
@@ -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());
}
/**
@@ -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(),
@@ -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客户默认尺寸可靠后台初始化任务及诊断状态';
+23
View File
@@ -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和数据隔离键',
+46 -2
View File
@@ -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]
@@ -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<Boolean> 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<Boolean> 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");
}
}
@@ -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<CustomerDimensionRuleClient.InitializationRequest> 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<TransactionSynchronization> 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<TransactionSynchronization> 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<TransactionSynchronization> 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");
}
}
+4 -1
View File
@@ -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`。
+2 -1
View File
@@ -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 脚本的离线合同只验证脚本结构、参数和模块边界,不访问测试环境:
+58 -2
View File
@@ -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
+175
View File
@@ -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(/<modules>([\s\S]*?)<\/modules>/)?.[1] ?? ''
return [...block.matchAll(/<module>([^<]+)<\/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 (!/<report\b/.test(xml) || !/<\/report>/.test(xml)) throw new Error('not a JaCoCo report')
const counters = [...xml.matchAll(/<counter\b([^>]*)\/?\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 (!/<BugCollection\b/.test(xml) || !/<\/BugCollection>/.test(xml)) throw new Error('not a SpotBugs report')
const findings = [...xml.matchAll(/<BugInstance\b([^>]*)>/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
})
}
+77
View File
@@ -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('<report><counter type="LINE" missed="3" covered="7"/></report>'), {
missed: 3, covered: 7, percentage: 70
})
assert.deepEqual(parseSpotbugsReport('<BugCollection><BugInstance priority="1" category="CORRECTNESS"/><BugInstance priority="2" category="STYLE"/></BugCollection>'), {
findings: 2,
byPriority: { 1: 1, 2: 1 },
byCategory: { CORRECTNESS: 1, STYLE: 1 }
})
assert.throws(() => parseJacocoReport('<report/>'), /not a JaCoCo report/)
assert.throws(() => parseSpotbugsReport('<BugCollection/>'), /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'), '<modules><module>bom</module><module>alpha</module><module>beta</module></modules>', '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'), '<report><counter type="LINE" missed="1" covered="9"/></report>', 'utf8')
await mkdir(path.join(root, 'alpha', 'target', 'site'), { recursive: true })
await writeFile(path.join(root, 'alpha', 'target', 'spotbugsXml.xml'), '<BugCollection><BugInstance priority="2" category="STYLE"/></BugCollection>', 'utf8')
await writeFile(path.join(root, 'alpha', 'target', 'site', 'spotbugs.html'), '<html></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)
})
+2 -1
View File
@@ -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',
@@ -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'] }
+4
View File
@@ -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`。