优化
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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,
|
||||
|
||||
+8
-13
@@ -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 或四期业务状态机。
|
||||
|
||||
## 退出顺序
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-4
@@ -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;
|
||||
|
||||
+7
-6
@@ -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
|
||||
+45
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
package com.baiyue.firstleg.pricebookquery.application.model;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
+22
@@ -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);
|
||||
}
|
||||
+21
@@ -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;
|
||||
|
||||
+3
-3
@@ -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;
|
||||
|
||||
+67
@@ -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);
|
||||
}
|
||||
}
|
||||
+46
@@ -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
-1
@@ -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
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+6
-6
@@ -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
@@ -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) {
|
||||
}
|
||||
|
||||
+123
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-89
@@ -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客户默认尺寸可靠后台初始化任务及诊断状态';
|
||||
@@ -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和数据隔离键',
|
||||
|
||||
@@ -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]
|
||||
|
||||
+104
@@ -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");
|
||||
}
|
||||
}
|
||||
+36
-113
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 脚本的离线合同只验证脚本结构、参数和模块边界,不访问测试环境:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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'] }
|
||||
|
||||
@@ -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`。
|
||||
|
||||
Reference in New Issue
Block a user