diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 865a9e4b2..3fd5c31fe 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -41,6 +41,27 @@ OpenAPI有效性: - "**/target/spotbugs*.xml" - "**/target/site/spotbugs.html" +质量摘要: + stage: verify + image: node:22-alpine + cache: [] + allow_failure: true + needs: + - job: OpenAPI有效性 + artifacts: false + - job: 校验与测试 + artifacts: true + - job: 静态分析报告 + artifacts: true + script: + - node --test scripts/java-quality-summary.test.mjs + - node scripts/java-quality-summary.mjs --output target/java-quality-summary.json --require-jacoco --require-spotbugs + artifacts: + when: always + expire_in: 7 days + paths: + - target/java-quality-summary.json + 校验与测试: stage: verify script: @@ -76,7 +97,6 @@ OpenAPI有效性: - finance/target/*.jar - warehouse/target/*.jar - warehouse-overseas-service/target/*.jar - - overseas-security-service/target/*.jar - lastleg-service/target/*.jar - file/target/*.jar - print/target/*.jar diff --git a/AGENTS.md b/AGENTS.md index 5a0872a4a..c399798f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,9 +20,11 @@ |---|---|---| | 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` | | 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` | +| 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 | | 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 | | 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` | | 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` | +| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 | | 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 | | 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` | | 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` | @@ -36,14 +38,15 @@ | 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` | | 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | | 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | -| 海外 IAM 状态与权限版本投影 | `overseas-security-service/` | `overseas-security-service/AGENTS.md`;消费三类 IAM 安全事件并为海外 Gateway 提供内部 fail-closed 校验 | +| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` | | 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | | 客户海外仓贴标、转运、废弃与退货换标 | `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` | +| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | `warehouse/AGENTS.md`、`warehouse-overseas-service/AGENTS.md`;海外库只保存国内 warehouseId 引用 | +| Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 | | PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` | ## 公共模块快速索引 @@ -52,7 +55,7 @@ |---|---| | 公共依赖版本 | `platform-bom/pom.xml`;统一管理公共组件版本,不放业务实现 | | 通用响应、分页、异常、基础实体与导出契约 | `common-core/`;从 `ApiResponse.java`、`PageResult.java`、`BizException.java`、`ExportContracts.java` 定位 | -| Web 异常、请求关联 ID、Long ID 序列化 | `common-web/`;从 `GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`LongIdJacksonConfiguration.java` 定位 | +| Web 异常、请求关联/Trace、同步出站传播、Long ID 序列化 | `common-web/`;从 `GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`TraceParent.java`、`FeignTraceRequestInterceptor.java`、`RestClientTraceInterceptor.java`、`LongIdJacksonConfiguration.java` 定位 | | JWT、JWKS、会话校验与认证用户 | `common-security/`;从 `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java`、`AccessTokenSessionValidator.java` 定位 | | Springdoc/OpenAPI 自动配置 | `common-openapi/`;从 `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` 定位 | | 业务审计注解、脱敏、Outbox 与投递 | `common-audit/`;从 `BusinessAudit.java`、`BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`AuditOutboxPublisher.java` 定位 | @@ -78,9 +81,10 @@ - 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。 - 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。 - 数据库只访问所属服务库。模块表结构位于 `/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。 +- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。 - 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - Java 签名变更的最低编译门禁是 `mvn -pl -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 -- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、海外 Gateway 覆盖、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 +- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、四期范围漂移、订舱关联候选与柜量语义、海外 Gateway 覆盖、PDA 运行配置、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 - 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。 ## 全量 HTTP 验收脚本路由 @@ -89,6 +93,7 @@ - Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules `,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。 - 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。 - 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。 +- Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。 - HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。 ## 阶段文档入口 diff --git a/Jenkinsfile b/Jenkinsfile index be2593cce..a06ba1017 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,6 +1,6 @@ def serviceModules = [ 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' ] def serviceCheckboxes = [ @@ -17,7 +17,6 @@ def serviceCheckboxes = [ 'integration-service': 'SERVICE_INTEGRATION', 'lastleg-service': 'SERVICE_LASTLEG', 'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS', - 'overseas-security-service': 'SERVICE_OVERSEAS_SECURITY', 'event-bridge-service': 'SERVICE_EVENT_BRIDGE', 'audit-service': 'SERVICE_AUDIT' ] @@ -69,7 +68,6 @@ pipeline { booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)') booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)') booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)') - booleanParam(name: 'SERVICE_OVERSEAS_SECURITY', defaultValue: false, description: '海外IAM安全投影服务(overseas-security-service)') booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;默认同时部署国内和海外实例)') choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS'], description: 'BOTH 同时部署国内和海外实例;CN/OVERSEAS 用于单区故障重跑') booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)') diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 88c8bac39..bd81515d2 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -1,6 +1,6 @@ def serviceModules = [ 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' ] def serviceJars = [ @@ -17,7 +17,6 @@ def serviceJars = [ 'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar', 'lastleg-service': 'lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar', 'warehouse-overseas-service': 'warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar', - 'overseas-security-service': 'overseas-security-service/target/overseas-security-service-1.0.0-SNAPSHOT.jar', 'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar', 'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar' ] diff --git a/README.md b/README.md index 604e465ae..0f2913388 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - `firstleg`:头程揽收、入仓、装载、发运和交接。 - `warehouse`:国内仓储能力。 - `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。 -- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`、`overseas-security-service`:海外公网入口、海外尾程与入仓业务、海外仓储资源及 IAM 安全投影。 +- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`:海外公网入口、海外尾程/入仓与 IAM 安全投影、海外仓储资源。 - `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。 - `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。 - `platform-bom`:统一管理仓库内公共依赖版本。 diff --git a/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java b/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java index b78cc2138..c0bb9d5ed 100644 --- a/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java +++ b/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java @@ -25,5 +25,6 @@ public interface IamOnboardingClient { String legalPerson, String legalPhone, String contactEmail, String countryCode, String address) { } - record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus) { } + record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus, + String dimensionInitializationStatus) { } } diff --git a/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterResponse.java b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterResponse.java index 3c357b850..09ece63c9 100644 --- a/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterResponse.java +++ b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterResponse.java @@ -5,5 +5,6 @@ public record MerchantRegisterResponse( Long tenantId, Long membershipId, String tenantStatus, + String dimensionInitializationStatus, String nextStep ) { } diff --git a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java index 09dd998cd..d3fa0f65f 100644 --- a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java +++ b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java @@ -103,7 +103,8 @@ public class MerchantRegistrationService { throw exception; } return new MerchantRegisterResponse(accountId, onboarding.tenantId(), onboarding.membershipId(), - onboarding.tenantStatus(), "LOGIN_AND_COMPLETE_QUALIFICATIONS"); + onboarding.tenantStatus(), onboarding.dimensionInitializationStatus(), + "LOGIN_AND_COMPLETE_QUALIFICATIONS"); } private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) { diff --git a/auth/src/main/resources/db/phase1-admin-accounts.sql b/auth/src/main/resources/db/phase1-admin-accounts.sql index cfa329c3d..130763a9f 100644 --- a/auth/src/main/resources/db/phase1-admin-accounts.sql +++ b/auth/src/main/resources/db/phase1-admin-accounts.sql @@ -1,4 +1,4 @@ --- Phase-one local administrator accounts. +-- Phase-one local platform administrator account. Merchant accounts are created through onboarding. -- Development-only initial password: ChangeMe123! -- The BCrypt hash is intentionally stored instead of plaintext. USE baiyue_auth; @@ -10,9 +10,7 @@ INSERT INTO auth_account ( operator_tenant_id, default_membership_id, default_app_code, deleted ) VALUES (1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', - '平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0), - (1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', - 'IT 测试客户管理员', 'ACTIVE', 0, 1, 0, 10001, 10001, 3000002, 'MERCHANT', 0) + '平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0) ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), display_name = VALUES(display_name), diff --git a/auth/src/test/java/com/baiyue/auth/repository/InitialAccountSeedSqlContractTest.java b/auth/src/test/java/com/baiyue/auth/repository/InitialAccountSeedSqlContractTest.java index 025bf6ec7..82620e90c 100644 --- a/auth/src/test/java/com/baiyue/auth/repository/InitialAccountSeedSqlContractTest.java +++ b/auth/src/test/java/com/baiyue/auth/repository/InitialAccountSeedSqlContractTest.java @@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; class InitialAccountSeedSqlContractTest { @Test - void initializesOnlyThePlatformAndItTestAdministratorAccounts() throws Exception { + void initializesOnlyThePlatformAdministratorAccount() throws Exception { assertAccountSeed(new ClassPathResource("db/phase1-admin-accounts.sql") .getContentAsString(StandardCharsets.UTF_8)); assertAccountSeed(Files.readString(unifiedSql(), StandardCharsets.UTF_8)); @@ -21,9 +21,10 @@ class InitialAccountSeedSqlContractTest { assertThat(sql) .contains("(1000001, 'platform_admin'") .contains("'平台系统管理员', 'ACTIVE'") - .contains("(1000002, 'merchant_admin'") - .contains("'IT 测试客户管理员', 'ACTIVE'") .contains("password_change_required") + .doesNotContain("merchant_admin") + .doesNotContain("IT_TEST") + .doesNotContain("(1000002, 'merchant_admin'") .doesNotContain("一期演示企业管理员"); } diff --git a/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java index 5a4541699..fd61c5925 100644 --- a/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java @@ -35,7 +35,7 @@ class MerchantRegistrationServiceTest { void registeredAuthAccountUsesTheOwnerIdentityReturnedByIamOnboarding() { when(encoder.encode("Password123")).thenReturn("encoded-password"); when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED"))); + new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING"))); var result = service.register(new MerchantRegisterRequest( "registered_owner", "Password123", "注册人", "测试企业", "张三", @@ -56,13 +56,14 @@ class MerchantRegistrationServiceTest { assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus()); assertEquals("encoded-password", storedAccount.getValue().getPasswordHash()); assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep()); + assertEquals("PENDING", result.dimensionInitializationStatus()); } @Test void failedAuthInsertCompensatesTheIamRegistration() { when(encoder.encode("Password123")).thenReturn("encoded-password"); when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED"))); + new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING"))); when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); doThrow(new IllegalStateException("auth database unavailable")).when(accounts).insert(any()); @@ -82,7 +83,7 @@ class MerchantRegistrationServiceTest { void rolledBackAuthTransactionCompensatesAfterTheLocalTransactionCompletes() { when(encoder.encode("Password123")).thenReturn("encoded-password"); when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED"))); + new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING"))); when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); TransactionSynchronizationManager.initSynchronization(); diff --git a/common-openapi/AGENTS.md b/common-openapi/AGENTS.md index 59eed2647..f91a255d8 100644 --- a/common-openapi/AGENTS.md +++ b/common-openapi/AGENTS.md @@ -13,10 +13,12 @@ | `baiyue.openapi` 配置项 | `src/main/java/com/baiyue/common/openapi/OpenApiProperties.java` | | 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` | | 长整型 OpenAPI 字符串契约 | `src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java` | +| PDA OpenAPI 分组 | `src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java` 的 `pda` GroupedOpenApi;固定匹配 `/api/pda/**`,合同测试 `src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java` | ## 修改与验证 - 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。 +- PDA Controller 必须进入独立 `pda` 分组;新增或改名 `/api/pda/**` 路径时同步检查分组合同和目标服务 OpenAPI,不得混入平台或商户分组。 - 普通业务成功和失败在 OpenAPI 中统一描述为 HTTP 200,并提示调用方检查 body `code`;只为 Security、协议、基础设施、Warehouse 503、Audit 202 和 Event Bridge 内部鉴权等真实状态边界声明非 200 响应。 - 配置项变化直接同步代码、全部消费者和 Nacos 模板,当前开发阶段不保留旧配置键兼容。 - 从父目录运行 `mvn -pl common-openapi -am test`。 diff --git a/common-security/AGENTS.md b/common-security/AGENTS.md index 5829c1000..e9e566096 100644 --- a/common-security/AGENTS.md +++ b/common-security/AGENTS.md @@ -12,7 +12,7 @@ | 安全链自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java` | | JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS);`HmacJwtDecoderAutoConfiguration.java` 仅作当前开发阶段清理前的旧实现定位,不得作为跨区域共享密钥方案 | | JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` | -| 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java` | +| 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java`;海外 PDA 仓范围由 `JwtClaimsMapper.java` 将 `overseas_warehouse_id` 映射为 `overseasWarehouseId` | | 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(国内登录 Token 即时失效)、`ClaimsAccessTokenSessionValidator.java`(海外访问 Token 本地短时 Claims)、`RedisTokenKeys.java` | | 401/403 响应 | `JsonAuthenticationEntryPoint.java`、`JsonAccessDeniedHandler.java` | | 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` | @@ -22,6 +22,7 @@ - 所有资源服务统一使用 `jwt-mode=jwks`、RS256、Issuer 和 JWKS;国内服务验证国内 Audience 并使用 `session-mode=redis`,海外服务只接受登录/刷新时签发的海外 Audience Token 并使用 `session-mode=claims`,不得访问国内 Redis。海外服务应缓存 JWKS 并在密钥轮换时刷新,不得每个请求访问国内 Auth;不得重新引入跨区域共享 HS256 密钥,也不得让海外服务接受国内 Token。 - Token Claim、Redis Key、Issuer、Audience、JWKS、会话模式或认证对象变化必须同步检查 `auth/`、`gateway/`、Nacos/部署配置和所有实际业务服务。 +- `overseas_warehouse_id` 只承载 IAM 已确认的海外仓绑定;缺失、格式非法或与实时安全投影不一致时不得放宽 PDA 仓范围,Gateway 与业务服务必须 fail-closed。 - `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。 - 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。 - 从父目录运行 `mvn -pl common-security -am test`。 diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index d2312b7cf..019c951df 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -13,6 +13,7 @@ | 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` | | W3C Trace Context | `src/main/java/com/baiyue/common/web/TraceParent.java`;严格接收 version 00,响应 `traceparent`、`X-Trace-Id` 与 MDC `traceId` 使用同一 trace ID | | Feign Trace 传播 | `FeignTraceRequestInterceptor.java`、`FeignTracePropagationAutoConfiguration.java`;仅 Feign 存在时装配,optional 依赖不向无 Feign 服务传递 | +| RestClient Trace 传播 | `RestClientTraceInterceptor.java`、`RestClientTracePropagationAutoConfiguration.java`;Spring 管理的 Builder 自动装配,原始 Builder 可显式复用拦截器 | | 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 | | 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 | | 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` | @@ -27,5 +28,6 @@ - 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。 - 入站 Trace 优先级固定为:合法单值 `traceparent` > 完全缺失 `traceparent` 时的 32 位非零十六进制 `X-Trace-Id` > 新 trace。只要 `traceparent` 已出现但重复、超长、格式非法或含全零 ID,就必须生成新 trace,不得回退信任 `X-Trace-Id`;请求 ID 独立按 `X-Request-Id` 校验或生成。 - 同步 Feign 出站固定先移除调用模板中的 `X-Request-Id`、`X-Trace-Id`、`traceparent`,再仅从当前 MDC 的合法请求 ID 和 trace ID 重建;`traceparent` 沿用当前 trace ID、每次调用生成新非零 span ID、flags 固定 `01`。不得从业务参数、远端返回头或无效 MDC 建立上下文;本能力不处理 Reactor、异步线程、重试、超时或 fallback。 +- 同步 RestClient 出站统一覆盖 `X-Request-Id`、`X-Trace-Id`、`traceparent`,从当前 MDC 生成 child span;MDC 缺失或非法时安全新建 trace。不得从业务参数或远端响应头建立上下文,生产代码应使用 Spring 管理的 `RestClient.Builder`,原始 Builder 必须显式安装同一拦截器。 - 从父目录运行 `mvn -pl common-web -am test`。 - 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。 diff --git a/common-web/src/main/java/com/baiyue/common/web/OutboundTraceHeaders.java b/common-web/src/main/java/com/baiyue/common/web/OutboundTraceHeaders.java new file mode 100644 index 000000000..bbd1be9a4 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/OutboundTraceHeaders.java @@ -0,0 +1,40 @@ +package com.baiyue.common.web; + +import org.slf4j.MDC; + +import java.util.UUID; +import java.util.regex.Pattern; + +/** Immutable standard headers for one synchronous outbound child span. */ +public record OutboundTraceHeaders(String requestId, String traceId, String traceParent) { + private static final Pattern SAFE_REQUEST_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}"); + + /** Builds a child span from the current MDC, safely starting a new trace when absent/invalid. */ + public static OutboundTraceHeaders fromCurrentOrNew() { + String requestId = safeRequestId(MDC.get(CorrelationContext.REQUEST_ID)); + String traceId = TraceParent.normalizeTrustedTraceId(MDC.get(CorrelationContext.TRACE_ID)) + .orElseGet(() -> TraceParent.newRoot(null).traceId()); + return child(requestId, traceId); + } + + /** Builds a local child from a trusted persisted trace value, or safely starts a new trace. */ + public static OutboundTraceHeaders fromTrustedTraceOrNew(String trustedTraceId) { + String traceId = TraceParent.normalizeTrustedTraceId(trustedTraceId) + .orElseGet(() -> TraceParent.newRoot(null).traceId()); + return child(UUID.randomUUID().toString(), traceId); + } + + public String spanId() { + return TraceParent.parse(traceParent).orElseThrow().parentId(); + } + + private static OutboundTraceHeaders child(String requestId, String traceId) { + TraceParent child = TraceParent.outboundChild(traceId); + return new OutboundTraceHeaders(requestId, child.traceId(), child.value()); + } + + private static String safeRequestId(String value) { + return value != null && SAFE_REQUEST_ID.matcher(value).matches() + ? value : UUID.randomUUID().toString(); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/RestClientTraceCustomizer.java b/common-web/src/main/java/com/baiyue/common/web/RestClientTraceCustomizer.java new file mode 100644 index 000000000..0fcf7ad9f --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/RestClientTraceCustomizer.java @@ -0,0 +1,24 @@ +package com.baiyue.common.web; + +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.core.Ordered; +import org.springframework.web.client.RestClient; + +/** Installs the trace interceptor after ordinary RestClient customizers. */ +public final class RestClientTraceCustomizer implements RestClientCustomizer, Ordered { + private final RestClientTraceInterceptor interceptor; + + public RestClientTraceCustomizer(RestClientTraceInterceptor interceptor) { + this.interceptor = interceptor; + } + + @Override + public void customize(RestClient.Builder builder) { + builder.requestInterceptor(interceptor); + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java b/common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java new file mode 100644 index 000000000..488cd808f --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java @@ -0,0 +1,21 @@ +package com.baiyue.common.web; + +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; + +import java.io.IOException; + +/** Applies the trusted synchronous trace context to Spring RestClient requests. */ +public final class RestClientTraceInterceptor implements ClientHttpRequestInterceptor { + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, + ClientHttpRequestExecution execution) throws IOException { + OutboundTraceHeaders outbound = OutboundTraceHeaders.fromCurrentOrNew(); + request.getHeaders().set(CorrelationContext.REQUEST_ID_HEADER, outbound.requestId()); + request.getHeaders().set(CorrelationContext.TRACE_ID_HEADER, outbound.traceId()); + request.getHeaders().set(CorrelationContext.TRACE_PARENT_HEADER, outbound.traceParent()); + return execution.execute(request, body); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java new file mode 100644 index 000000000..6acccad5d --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java @@ -0,0 +1,25 @@ +package com.baiyue.common.web; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.web.client.RestClient; + +/** Adds trace propagation to Spring-managed RestClient builders. */ +@AutoConfiguration(afterName = "org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration") +@ConditionalOnClass({RestClient.class, RestClientCustomizer.class}) +public class RestClientTracePropagationAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(RestClientTraceInterceptor.class) + RestClientTraceInterceptor restClientTraceInterceptor() { + return new RestClientTraceInterceptor(); + } + + @Bean + RestClientTraceCustomizer baiyueTraceRestClientCustomizer(RestClientTraceInterceptor interceptor) { + return new RestClientTraceCustomizer(interceptor); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/TraceParent.java b/common-web/src/main/java/com/baiyue/common/web/TraceParent.java index 2ece6f081..88ae5829f 100644 --- a/common-web/src/main/java/com/baiyue/common/web/TraceParent.java +++ b/common-web/src/main/java/com/baiyue/common/web/TraceParent.java @@ -3,6 +3,7 @@ package com.baiyue.common.web; import java.security.SecureRandom; import java.util.HexFormat; import java.util.Optional; +import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -63,6 +64,24 @@ public record TraceParent(String traceId, String parentId, String traceFlags) { return new TraceParent(currentTraceId.toLowerCase(java.util.Locale.ROOT), randomNonZero(8), "01"); } + /** + * Normalizes a trace ID from a trusted persisted/internal context. + * + *

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

+ */ + public static Optional normalizeTrustedTraceId(String value) { + if (validLegacyTraceId(value)) { + return Optional.of(value.toLowerCase(Locale.ROOT)); + } + if (value != null && value.matches( + "[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}")) { + String compact = value.replace("-", "").toLowerCase(Locale.ROOT); + return validLegacyTraceId(compact) ? Optional.of(compact) : Optional.empty(); + } + return Optional.empty(); + } + public static boolean validLegacyTraceId(String value) { return value != null && LEGACY_TRACE_ID.matcher(value).matches() && !ZERO_TRACE_ID.equalsIgnoreCase(value); diff --git a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 06a277bae..2d417843c 100644 --- a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,3 +1,4 @@ com.baiyue.common.web.LongIdJacksonConfiguration com.baiyue.common.web.CommonWebAutoConfiguration com.baiyue.common.web.FeignTracePropagationAutoConfiguration +com.baiyue.common.web.RestClientTracePropagationAutoConfiguration diff --git a/common-web/src/test/java/com/baiyue/common/web/OutboundTraceHeadersTest.java b/common-web/src/test/java/com/baiyue/common/web/OutboundTraceHeadersTest.java new file mode 100644 index 000000000..c0f3c006a --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/OutboundTraceHeadersTest.java @@ -0,0 +1,41 @@ +package com.baiyue.common.web; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +import static org.assertj.core.api.Assertions.assertThat; + +class OutboundTraceHeadersTest { + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void currentContextCreatesAValidChildWithoutUsingBusinessValues() { + MDC.put(CorrelationContext.REQUEST_ID, "req-trusted"); + MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736"); + + OutboundTraceHeaders headers = OutboundTraceHeaders.fromCurrentOrNew(); + + assertThat(headers.requestId()).isEqualTo("req-trusted"); + assertThat(headers.traceId()).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(headers.traceParent()) + .matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + } + + @Test + void canonicalPersistedUuidIsCompactedAndInvalidValueStartsNewTrace() { + OutboundTraceHeaders uuid = OutboundTraceHeaders.fromTrustedTraceOrNew( + "550e8400-e29b-41d4-a716-446655440000"); + OutboundTraceHeaders invalid = OutboundTraceHeaders.fromTrustedTraceOrNew( + "ORDER:customer-99:booking-123"); + + assertThat(uuid.traceId()).isEqualTo("550e8400e29b41d4a716446655440000"); + assertThat(invalid.traceId()).matches("[0-9a-f]{32}") + .isNotEqualTo("ORDER:customer-99:booking-123"); + assertThat(invalid.requestId()).matches("[0-9a-f-]{36}"); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java b/common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java new file mode 100644 index 000000000..b8c6e74e7 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java @@ -0,0 +1,60 @@ +package com.baiyue.common.web; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.mock.http.client.MockClientHttpResponse; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class RestClientTraceInterceptorTest { + private final RestClientTraceInterceptor interceptor = new RestClientTraceInterceptor(); + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void trustedContextOverwritesAllCallerProvidedTraceHeaders() throws Exception { + MDC.put(CorrelationContext.REQUEST_ID, "req-rest-client"); + MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736"); + MockClientHttpRequest request = new MockClientHttpRequest(); + request.getHeaders().add(CorrelationContext.REQUEST_ID_HEADER, "business-request-id"); + request.getHeaders().add(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + request.getHeaders().add(CorrelationContext.TRACE_PARENT_HEADER, + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00"); + AtomicReference captured = new AtomicReference<>(); + + interceptor.intercept(request, new byte[0], (outbound, body) -> { + captured.set(outbound.getHeaders()); + return new MockClientHttpResponse(new byte[0], org.springframework.http.HttpStatus.OK); + }); + + assertThat(captured.get().getValuesAsList(CorrelationContext.REQUEST_ID_HEADER)) + .containsExactly("req-rest-client"); + assertThat(captured.get().getValuesAsList(CorrelationContext.TRACE_ID_HEADER)) + .containsExactly("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(captured.get().getFirst(CorrelationContext.TRACE_PARENT_HEADER)) + .matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + } + + @Test + void missingOrInvalidMdcSafelyCreatesNewStandardHeaders() throws Exception { + MDC.put(CorrelationContext.TRACE_ID, "business-order-123"); + MockClientHttpRequest request = new MockClientHttpRequest(); + + interceptor.intercept(request, new byte[0], (outbound, body) -> + new MockClientHttpResponse(new byte[0], org.springframework.http.HttpStatus.OK)); + + assertThat(request.getHeaders().getFirst(CorrelationContext.REQUEST_ID_HEADER)) + .matches("[0-9a-f-]{36}"); + assertThat(request.getHeaders().getFirst(CorrelationContext.TRACE_ID_HEADER)) + .matches("[0-9a-f]{32}").isNotEqualTo("business-order-123"); + assertThat(request.getHeaders().getFirst(CorrelationContext.TRACE_PARENT_HEADER)) + .matches("00-[0-9a-f]{32}-[0-9a-f]{16}-01"); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/RestClientTracePropagationAutoConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/RestClientTracePropagationAutoConfigurationTest.java new file mode 100644 index 000000000..f2edb83d2 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/RestClientTracePropagationAutoConfigurationTest.java @@ -0,0 +1,37 @@ +package com.baiyue.common.web; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.core.Ordered; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class RestClientTracePropagationAutoConfigurationTest { + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(RestClientTracePropagationAutoConfiguration.class)); + + @Test + void registersInterceptorAndIndependentCustomizer() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(RestClientTraceInterceptor.class); + assertThat(context).hasBean("baiyueTraceRestClientCustomizer"); + assertThat(context.getBeansOfType(RestClientCustomizer.class)).hasSize(1); + RestClientTraceCustomizer customizer = context.getBean(RestClientTraceCustomizer.class); + assertThat(customizer.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE); + RestClient.Builder builder = mock(RestClient.Builder.class); + customizer.customize(builder); + verify(builder).requestInterceptor(context.getBean(RestClientTraceInterceptor.class)); + }); + } + + @Test + void reusesApplicationProvidedTraceInterceptor() { + contextRunner.withBean(RestClientTraceInterceptor.class, RestClientTraceInterceptor::new) + .run(context -> assertThat(context).hasSingleBean(RestClientTraceInterceptor.class)); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java b/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java index e59be28aa..e67870a1c 100644 --- a/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java @@ -51,4 +51,12 @@ class TraceParentTest { assertThat(child.value()).matches( "00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); } + + @Test + void trustedPersistedUuidNormalizationDoesNotBroadenHttpParsing() { + assertThat(TraceParent.normalizeTrustedTraceId("550E8400-E29B-41D4-A716-446655440000")) + .contains("550e8400e29b41d4a716446655440000"); + assertThat(TraceParent.parse( + "00-550E8400E29B41D4A716446655440000-00f067aa0ba902b7-01")).isEmpty(); + } } diff --git a/config/nacos/cn/iam-service.yml b/config/nacos/cn/iam-service.yml index 32dcc05c7..f735164e7 100644 --- a/config/nacos/cn/iam-service.yml +++ b/config/nacos/cn/iam-service.yml @@ -5,6 +5,11 @@ spring: password: ${IAM_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver baiyue: + security-event: + event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} + customer-dimension-initialization: + fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} + max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} diff --git a/config/nacos/local/iam-service.yml b/config/nacos/local/iam-service.yml index 4639f7ca1..cc1ad14ab 100644 --- a/config/nacos/local/iam-service.yml +++ b/config/nacos/local/iam-service.yml @@ -4,6 +4,9 @@ spring.datasource: username: ${IAM_DB_USERNAME:root} password: ${IAM_DB_PASSWORD:123456} baiyue: + customer-dimension-initialization: + fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} + max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} diff --git a/config/nacos/overseas/event-bridge-service.yml b/config/nacos/overseas/event-bridge-service.yml index bbc198d5c..00a8ac755 100644 --- a/config/nacos/overseas/event-bridge-service.yml +++ b/config/nacos/overseas/event-bridge-service.yml @@ -10,7 +10,7 @@ baiyue: data-residency: ${DATA_RESIDENCY:OVERSEAS} event-dispatcher: consumer-url: ${EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events} - security-consumer-url: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:http://overseas-security-service:9307/internal/overseas-security/v1/events} + security-consumer-url: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/security/v1/events} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} diff --git a/config/nacos/overseas/gateway-overseas.yml b/config/nacos/overseas/gateway-overseas.yml index 11e2deb34..a9c1eadf2 100644 --- a/config/nacos/overseas/gateway-overseas.yml +++ b/config/nacos/overseas/gateway-overseas.yml @@ -4,6 +4,6 @@ springdoc: baiyue: security: overseas-projection: - service-url: ${OVERSEAS_SECURITY_SERVICE_URL:http://overseas-security-service:9307} + service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303} internal-service-token: ${INTERNAL_SERVICE_TOKEN} - timeout: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:2s} + timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s} diff --git a/config/nacos/overseas/overseas-security-service.yml b/config/nacos/overseas/overseas-security-service.yml deleted file mode 100644 index 80565c445..000000000 --- a/config/nacos/overseas/overseas-security-service.yml +++ /dev/null @@ -1,7 +0,0 @@ -springdoc: - api-docs.enabled: ${SWAGGER_ENABLED:false} - swagger-ui.enabled: ${SWAGGER_ENABLED:false} -baiyue: - security: - internal-service-token: ${INTERNAL_SERVICE_TOKEN} - permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} diff --git a/config/nacos/overseas/warehouse-overseas-service.yml b/config/nacos/overseas/warehouse-overseas-service.yml index a05d57b33..53f24467e 100644 --- a/config/nacos/overseas/warehouse-overseas-service.yml +++ b/config/nacos/overseas/warehouse-overseas-service.yml @@ -4,3 +4,8 @@ spring: username: ${WAREHOUSE_DB_USERNAME} password: ${WAREHOUSE_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver +baiyue: + clients: + domestic-warehouse: + url: ${DOMESTIC_WAREHOUSE_URL} + cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index f41f67f15..24729ca6a 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -47,8 +47,8 @@ services: CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false} AUTH_SESSION_VALIDATION_URL: ${AUTH_SESSION_VALIDATION_URL} PDA_SESSION_VALIDATION_TIMEOUT: ${PDA_SESSION_VALIDATION_TIMEOUT:-2s} - OVERSEAS_SECURITY_SERVICE_URL: ${OVERSEAS_SECURITY_SERVICE_URL:-http://overseas-security-service:9307} - OVERSEAS_SECURITY_VALIDATION_TIMEOUT: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:-2s} + LASTLEG_SECURITY_SERVICE_URL: ${LASTLEG_SECURITY_SERVICE_URL:-http://lastleg-service:9303} + LASTLEG_SECURITY_VALIDATION_TIMEOUT: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:-2s} lastleg-service: <<: *overseas-common @@ -80,6 +80,7 @@ services: WAREHOUSE_DB_URL: ${WAREHOUSE_OVERSEAS_DB_URL} WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME} WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD} + DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} event-bridge-service: <<: *overseas-common @@ -94,25 +95,12 @@ services: EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_OVERSEAS_DB_USERNAME} EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_OVERSEAS_DB_PASSWORD} EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/events} - OVERSEAS_SECURITY_EVENT_CONSUMER_URL: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:-http://overseas-security-service:9307/internal/overseas-security/v1/events} + LASTLEG_SECURITY_EVENT_CONSUMER_URL: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/security/v1/events} networks: firstleg-overseas-network: infra-network: aliases: [event-bridge-overseas] - overseas-security-service: - <<: *overseas-common - image: ${IMAGE_REPOSITORY}/overseas-security-service:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-overseas-security - ports: ["${PUBLISHED_BIND_ADDRESS:-127.0.0.1}:${OVERSEAS_SECURITY_PORT:-9307}:9307"] - volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/overseas-security-service:/app/logs"] - environment: - <<: *overseas-environment - SERVER_PORT: 9307 - OVERSEAS_SECURITY_DB_URL: ${OVERSEAS_SECURITY_DB_URL} - OVERSEAS_SECURITY_DB_USERNAME: ${OVERSEAS_SECURITY_DB_USERNAME} - OVERSEAS_SECURITY_DB_PASSWORD: ${OVERSEAS_SECURITY_DB_PASSWORD} - networks: firstleg-overseas-network: name: firstleg-${DEPLOY_ENV:-test}-overseas-network diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 4f6700005..8e61b9ec7 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -79,6 +79,7 @@ services: IAM_DB_URL: ${IAM_DB_URL} IAM_DB_USERNAME: ${IAM_DB_USERNAME} IAM_DB_PASSWORD: ${IAM_DB_PASSWORD} + SECURITY_EVENT_BRIDGE_URL: ${SECURITY_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312} firstleg: <<: *common diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index b8d703d8c..d4ad1ad7d 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -37,10 +37,7 @@ case "$DEPLOY_ENV" in esac if [ "$DEPLOY_ENV" = "development" ]; then - case "$SERVICE" in - gateway|gateway-overseas) PUBLISHED_BIND_ADDRESS=0.0.0.0 ;; - *) PUBLISHED_BIND_ADDRESS=127.0.0.1 ;; - esac + PUBLISHED_BIND_ADDRESS=127.0.0.1 else PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" fi @@ -67,7 +64,7 @@ env_file_value() { } case "$SERVICE" in - gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) + gateway-overseas|lastleg-service|warehouse-overseas-service) COMPOSE_FILE="deploy/docker-compose.overseas.yml" PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas" ;; @@ -101,15 +98,6 @@ if [ "$SERVICE" = "gateway-overseas" ]; then fi fi -if [ "$SERVICE" = "overseas-security-service" ]; then - for key in OVERSEAS_SECURITY_DB_URL OVERSEAS_SECURITY_DB_USERNAME OVERSEAS_SECURITY_DB_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 overseas-security-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done -fi - for file in "$ENV_FILE" "$COMPOSE_FILE"; do if [ ! -f "$file" ]; then echo "必需文件不存在: $file" >&2 @@ -127,7 +115,7 @@ if [ "$SERVICE" = "finance" ]; then fi case "$SERVICE" in - gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) + gateway-overseas|lastleg-service|warehouse-overseas-service) for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then echo "部署海外服务时,环境文件必须包含非空 $key" >&2 @@ -433,10 +421,7 @@ export DEPLOY_ENV="$deploy_env" export IMAGE_REPOSITORY="$image_repository" export IMAGE_TAG="$image_tag" if [ "$deploy_env" = "development" ]; then - case "$service" in - gateway|gateway-overseas) export PUBLISHED_BIND_ADDRESS=0.0.0.0 ;; - *) export PUBLISHED_BIND_ADDRESS=127.0.0.1 ;; - esac + export PUBLISHED_BIND_ADDRESS=127.0.0.1 else export PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" fi diff --git a/deploy/scripts/deploy-services.sh b/deploy/scripts/deploy-services.sh index abdbafef5..7c9983f82 100644 --- a/deploy/scripts/deploy-services.sh +++ b/deploy/scripts/deploy-services.sh @@ -39,7 +39,7 @@ env_file_value() { DEPLOY_REGION=CN for service in $SERVICES; do case "$service" in - gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) DEPLOY_REGION=OVERSEAS ;; + gateway-overseas|lastleg-service|warehouse-overseas-service) DEPLOY_REGION=OVERSEAS ;; event-bridge-service) BRIDGE_REGION="${EVENT_BRIDGE_REGION_OVERRIDE:-}" if [ -z "$BRIDGE_REGION" ] && env_file_has_key REGION_CODE; then @@ -60,7 +60,7 @@ done if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then for service in $SERVICES; do case "$service" in - gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service|event-bridge-service) ;; + gateway-overseas|lastleg-service|warehouse-overseas-service|event-bridge-service) ;; *) echo "一次部署不得混合国内与海外服务: $SERVICE_CSV" >&2; exit 2 ;; esac done @@ -80,17 +80,6 @@ if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then done fi -case " $SERVICES " in - *" overseas-security-service "*) - for key in OVERSEAS_SECURITY_DB_URL OVERSEAS_SECURITY_DB_USERNAME OVERSEAS_SECURITY_DB_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 overseas-security-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done - ;; -esac - case " $SERVICES " in *" gateway-overseas "*) if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then diff --git a/deploy/service-map.yml b/deploy/service-map.yml index e78b8a6e5..e22eec804 100644 --- a/deploy/service-map.yml +++ b/deploy/service-map.yml @@ -8,11 +8,6 @@ services: jar: gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar port: 9300 regions: [OVERSEAS] - overseas-security-service: - artifactId: overseas-security-service - jar: overseas-security-service/target/overseas-security-service-1.0.0-SNAPSHOT.jar - port: 9307 - regions: [OVERSEAS] auth: artifactId: auth-service jar: auth/target/auth-service-1.0.0-SNAPSHOT.jar diff --git a/deploy/sql/README.md b/deploy/sql/README.md index dc681dba9..5a946294c 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -3,13 +3,14 @@ ## 唯一数据库初始化与发布入口 - 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 -- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`、`warehouse-overseas-service/src/main/resources/db/warehouse.sql` 与 `overseas-security-service/src/main/resources/db/overseas-security.sql` 基线,再执行该入口。固定入口升级 `baiyue_lastleg`、`baiyue_lastlag_warehouse` 与 `baiyue_overseas_security`,后者保存 IAM 安全事件消费者 Inbox 和可恢复投影;Redis 只作可重建镜像。 +- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`与 `warehouse-overseas-service/src/main/resources/db/warehouse.sql` 基线,再执行该入口。固定入口会创建并校验独立的 `baiyue_event_bridge_overseas`,再升级 `baiyue_lastleg`与 `baiyue_lastlag_warehouse`;IAM 安全事件 Inbox 和可恢复投影已并入 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。 +- Lastleg 基线和海外固定增量仅确定性初始化国内固定 `platform_admin`(`authUserId=1000001`、`tenantId=0`)的 USER/AUTHORIZATION version=0 安全投影;其他既有开发账号不建立兼容表或长期补投入口。 - 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。 - 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 - 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。 - 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。 -- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建最小登录账号,重复执行不覆盖现有密码。脚本不写入演示订单;正式平台公共仓库主数据已内联到固定入口。 +- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建平台管理员,重复执行不覆盖现有密码。脚本不创建固定客户、客户账号或演示订单;客户必须通过正式建档/注册流程创建。正式平台公共仓库主数据已内联到固定入口;义乌、东莞、洛杉矶、纽约四个固定作业仓均初始化 `RECEIVING`,其中洛杉矶和纽约使用海外接柜入仓收货语义,FBA 目的地址不创建作业能力。 - `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 - 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。 - IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index f9bd01018..7d593825a 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -278,7 +278,7 @@ PREPARE auth_password_change_required_stmt FROM @auth_password_change_required_s EXECUTE auth_password_change_required_stmt; DEALLOCATE PREPARE auth_password_change_required_stmt; --- 开发/测试环境最小登录基线:一个平台系统管理员和一个 IT 测试客户管理员。 +-- 开发/测试环境最小登录基线只保留平台系统管理员;客户必须走正式建档/注册流程创建。 -- 缺失账号首次创建时写入开发基线 BCrypt 哈希;重复执行统一增量绝不覆盖现有密码。 INSERT INTO auth_account ( id, username, password_hash, display_name, account_status, @@ -286,9 +286,7 @@ INSERT INTO auth_account ( operator_tenant_id, default_membership_id, default_app_code, deleted ) VALUES (1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', - '平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0), - (1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', - 'IT 测试客户管理员', 'ACTIVE', 0, 1, 0, 10001, 10001, 3000002, 'MERCHANT', 0) + '平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0) ON DUPLICATE KEY UPDATE display_name = VALUES(display_name), account_status = 'ACTIVE', @@ -1194,10 +1192,7 @@ INSERT INTO iam_tenant ( approved_at, approved_by, created_by, deleted, version ) VALUES (0, 'PLATFORM', '虫虫出海平台', 'PLATFORM', 'ACTIVE', '虫虫出海平台', - NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0), - (10001, 'IT_TEST', 'IT 测试客户', 'MERCHANT', 'ACTIVE', 'IT 测试客户', - '91310000ITTEST0001', 'IT 测试客户管理员', '13800000000', 'it-test@example.com', - NOW(3), 1000001, 1000001, 0, 0) + NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0) ON DUPLICATE KEY UPDATE tenant_code = VALUES(tenant_code), tenant_name = VALUES(tenant_name), tenant_type = VALUES(tenant_type), status = 'ACTIVE', legal_name = VALUES(legal_name), @@ -1206,28 +1201,10 @@ ON DUPLICATE KEY UPDATE contact_email = VALUES(contact_email), approved_at = COALESCE(approved_at, NOW(3)), approved_by = VALUES(approved_by), deleted = 0; --- The built-in IT customer must be immediately usable for order acceptance tests. --- These approved metadata rows intentionally have no document URL: initialization must not --- point at qualification files that do not exist in File service storage. -INSERT INTO iam_tenant_qualification ( - id, tenant_id, qualification_type, certificate_no, document_url, - valid_from, valid_to, status, review_comment, deleted -) VALUES - (2100001, 10001, 'BUSINESS_LICENSE', '91310000ITTEST0001', NULL, - NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0), - (2100002, 10001, 'LEGAL_PERSON_ID', NULL, NULL, - NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0) -ON DUPLICATE KEY UPDATE - tenant_id = VALUES(tenant_id), qualification_type = VALUES(qualification_type), - certificate_no = VALUES(certificate_no), status = 'APPROVED', - review_comment = VALUES(review_comment), deleted = 0; - INSERT INTO iam_user ( id, auth_user_id, user_type, display_name, phone, email, status, deleted, version ) VALUES - (2000001, 1000001, 'INTERNAL', '平台系统管理员', NULL, NULL, 'ACTIVE', 0, 0), - (2000002, 1000002, 'ENTERPRISE', 'IT 测试客户管理员', '13800000000', - 'it-test@example.com', 'ACTIVE', 0, 0) + (2000001, 1000001, 'INTERNAL', '平台系统管理员', NULL, NULL, 'ACTIVE', 0, 0) ON DUPLICATE KEY UPDATE display_name = VALUES(display_name), phone = VALUES(phone), email = VALUES(email), status = 'ACTIVE', deleted = 0; @@ -1235,8 +1212,7 @@ ON DUPLICATE KEY UPDATE INSERT INTO iam_membership ( id, tenant_id, user_id, member_type, member_no, status, joined_at, deleted, version ) VALUES - (3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0), - (3000002, 10001, 2000002, 'OWNER', 'MERCHANT-OWNER-001', 'ACTIVE', NOW(3), 0, 0) + (3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0) ON DUPLICATE KEY UPDATE member_type = VALUES(member_type), member_no = VALUES(member_no), status = 'ACTIVE', joined_at = COALESCE(joined_at, NOW(3)), left_at = NULL, deleted = 0; @@ -1246,9 +1222,7 @@ INSERT INTO iam_role ( ) VALUES (4000001, 0, 'PLATFORM', 'PLATFORM_ADMIN', '平台超级管理员', 'SYSTEM', 'ACTIVE', '一期平台全部菜单和按钮', 0, 0), (4000002, 0, 'PLATFORM', 'CUSTOMER_REVIEWER', '企业审核员', 'SYSTEM', 'ACTIVE', '企业资料与入驻审核', 0, 0), - (4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0), - (4000101, 10001, 'MERCHANT', 'MERCHANT_OWNER', '企业管理员', 'SYSTEM', 'ACTIVE', '本企业一期全部菜单和按钮', 0, 0), - (4000102, 10001, 'MERCHANT', 'MERCHANT_ORDER_MANAGER', '企业订单管理员', 'SYSTEM', 'ACTIVE', '本企业头程订单操作', 0, 0) + (4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0) ON DUPLICATE KEY UPDATE role_name = VALUES(role_name), role_type = VALUES(role_type), status = 'ACTIVE', description = VALUES(description), deleted = 0; @@ -1335,39 +1309,8 @@ SELECT 4000003, id, 0 FROM iam_permission WHERE id IN (5102019, 5102024, 5102025) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); --- Merchant: level-one menus, level-two menus and buttons. -INSERT INTO iam_permission - (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) -VALUES - (5200000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0), - (5201000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0), - (5202000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0), - (5200010, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0), - (5200020, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0), - (5200030, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0), - (5201010, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), - (5201020, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0), - (5202010, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), - (5202020, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0), - (5200011, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0), - (5200012, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0), - (5200021, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0), - (5200022, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:grant-role', '分配角色', NULL, NULL, 20, 'ACTIVE', 0), - (5200031, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), - (5200032, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), - (5201011, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0), - (5201012, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0), - (5201021, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0), - (5201022, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0), - (5201023, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0) -ON DUPLICATE KEY UPDATE - permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), - permission_name = VALUES(permission_name), route_path = VALUES(route_path), - http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; - INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES - (3000001, 4000001, 0), - (3000002, 4000101, 10001) + (3000001, 4000001, 0) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -- Full administrator roles receive all phase-one permissions in their application scope. @@ -1376,11 +1319,6 @@ SELECT 4000001, id, 0 FROM iam_permission WHERE app_code = 'PLATFORM' AND tenant_id = 0 AND permission_type IN ('MENU', 'BUTTON') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE app_code = 'MERCHANT' AND tenant_id = 10001 AND permission_type IN ('MENU', 'BUTTON') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - -- Limited platform and merchant operational roles. INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000002, id, 0 FROM iam_permission @@ -1396,17 +1334,10 @@ SELECT 4000003, id, 0 FROM iam_permission WHERE permission_code LIKE 'platform:order%' OR permission_code LIKE 'platform:firstleg%' ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000102, id, 10001 FROM iam_permission -WHERE permission_code LIKE 'merchant:order%' OR permission_code IN ('merchant:master-data', 'merchant:warehouse:list') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression) VALUES (6000001, 4000001, 0, 'TENANT', NULL), (6000002, 4000002, 0, 'TENANT', NULL), - (6000003, 4000003, 0, 'TENANT', NULL), - (6000101, 4000101, 10001, 'TENANT', NULL), - (6000102, 4000102, 10001, 'TENANT', NULL) + (6000003, 4000003, 0, 'TENANT', NULL) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id), scope_type = VALUES(scope_type), scope_expression = NULL; -- ============================================================================ @@ -1455,17 +1386,34 @@ INSERT INTO iam_role ( ) ON DUPLICATE KEY UPDATE status='ACTIVE', deleted=0; --- Clone the一期 merchant menu/button template into the global scope. +-- Global merchant permission template used by every formally onboarded customer. INSERT INTO iam_permission ( id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted ) -SELECT id + 100000, 0, app_code, permission_type, - CASE WHEN parent_id = 0 THEN 0 ELSE parent_id + 100000 END, - permission_code, permission_name, route_path, NULL, sort_no, 'ACTIVE', 0 -FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_type IN ('MENU', 'BUTTON') +VALUES + (5300000, 0, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0), + (5301000, 0, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0), + (5302000, 0, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0), + (5300010, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0), + (5300020, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0), + (5300030, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0), + (5301010, 0, 'MERCHANT', 'MENU', 5301000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), + (5301020, 0, 'MERCHANT', 'MENU', 5301000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0), + (5302010, 0, 'MERCHANT', 'MENU', 5302000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), + (5302020, 0, 'MERCHANT', 'MENU', 5302000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0), + (5300011, 0, 'MERCHANT', 'BUTTON', 5300010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0), + (5300012, 0, 'MERCHANT', 'BUTTON', 5300010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0), + (5300021, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0), + (5300022, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:grant-role', '分配成员角色', NULL, NULL, 20, 'ACTIVE', 0), + (5300031, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), + (5300032, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), + (5301011, 0, 'MERCHANT', 'BUTTON', 5301010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0), + (5301012, 0, 'MERCHANT', 'BUTTON', 5301010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0), + (5301021, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0), + (5301022, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0), + (5301023, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0), + (5301024, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), parent_id=VALUES(parent_id), sort_no=VALUES(sort_no), status='ACTIVE', deleted=0; @@ -1507,20 +1455,11 @@ DEALLOCATE PREPARE member_no_index_stmt; INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, sort_no, status, deleted) VALUES - (5200023, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), - (5200024, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), - (5200025, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0), (5300023, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), (5300024, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), (5300025, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), status = VALUES(status), deleted = 0; -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE tenant_id = 10001 AND permission_code IN - ('merchant:member:edit', 'merchant:member:status', 'merchant:member:reset-password') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000100, id, 0 FROM iam_permission WHERE tenant_id = 0 AND permission_code IN @@ -1548,9 +1487,6 @@ VALUES (5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停用户', 30, 'ACTIVE', 0), (5101014, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:reset-password', '重置密码', 40, 'ACTIVE', 0), (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 0), - (5200033, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), - (5200034, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), - (5200035, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0), (5300033, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), (5300034, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), (5300035, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0) @@ -1569,12 +1505,6 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM' 'platform:user:grant-role') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_code IN ('merchant:role:edit', 'merchant:role:status', 'merchant:role:delete') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000100, id, 0 FROM iam_permission WHERE tenant_id = 0 AND app_code = 'MERCHANT' @@ -4286,23 +4216,9 @@ SET @unified_ddl = IF( PREPARE unified_stmt FROM @unified_ddl; EXECUTE unified_stmt; DEALLOCATE PREPARE unified_stmt; -- ============================================================================ --- 重新同步一期商户管理员全局模板,包含一期后续追加的菜单和按钮 +-- 重新授权一期商户管理员全局模板,包含一期后续追加的菜单和按钮 -- ============================================================================ USE baiyue_iam; -INSERT INTO iam_permission ( - id, tenant_id, app_code, permission_type, parent_id, permission_code, - permission_name, route_path, http_method, sort_no, status, deleted -) -SELECT id + 100000, 0, app_code, permission_type, - CASE WHEN parent_id = 0 THEN 0 ELSE parent_id + 100000 END, - permission_code, permission_name, route_path, NULL, sort_no, 'ACTIVE', 0 -FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_type IN ('MENU', 'BUTTON') -ON DUPLICATE KEY UPDATE - permission_name = VALUES(permission_name), route_path = VALUES(route_path), - parent_id = VALUES(parent_id), sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT owner_role.id, permission.id, 0 FROM iam_role owner_role @@ -5983,7 +5899,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = NEW.container_booking_id; IF current_plan_status = 'COMPLETED' - AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; END IF; END$$ @@ -5999,7 +5915,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.container_booking_id; IF current_plan_status = 'COMPLETED' - AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改'; END IF; END$$ @@ -6440,7 +6356,7 @@ JOIN wh_warehouse warehouse ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); --- 国内固定作业仓必须启用收货能力,FBA 目的地址仍不得创建作业能力。 +-- 四个固定平台作业仓必须启用收货能力,FBA 目的地址仍不得创建作业能力。 INSERT INTO wh_service_capability ( id, tenant_id, warehouse_id, capability_code, capability_name, enabled, cutoff_time, description, created_by, created_at, updated_by, updated_at, deleted, version @@ -6449,7 +6365,11 @@ VALUES (193000000000400001,0,193000000000200001,'RECEIVING','头程收货',1,'16:00:00', '义乌仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0), (193000000000400002,0,193000000000200002,'RECEIVING','头程收货',1,'16:00:00', - '东莞仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0) + '东莞仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0), + (193000000000400003,0,193000000000200003,'RECEIVING','海外收货',1,'16:00:00', + '洛杉矶仓海外接柜入仓收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0), + (193000000000400004,0,193000000000200004,'RECEIVING','海外收货',1,'16:00:00', + '纽约仓海外接柜入仓收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0) ON DUPLICATE KEY UPDATE capability_name = VALUES(capability_name), enabled = VALUES(enabled), @@ -7096,11 +7016,11 @@ BEGIN IF (SELECT COUNT(*) FROM wh_service_capability capability WHERE capability.tenant_id = 0 - AND capability.warehouse_id IN (193000000000200001,193000000000200002) + AND capability.warehouse_id IN (193000000000200001,193000000000200002,193000000000200003,193000000000200004) AND capability.capability_code = 'RECEIVING' AND capability.enabled = 1 - AND capability.deleted = 0) <> 2 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '国内固定作业仓收货能力写入校验失败'; + AND capability.deleted = 0) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台作业仓收货能力写入校验失败'; END IF; END// DELIMITER ; @@ -9109,6 +9029,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', shipping_company_id BIGINT UNSIGNED NULL COMMENT '船公司逻辑ID快照', shipping_company_name VARCHAR(128) NULL COMMENT '船公司名称快照', + carrier_code VARCHAR(16) NULL COMMENT '船司标准编码:MATSON/SML/ZIM/COSCO/EMC/HEDE/OTHER;发布前必填', booking_agent_id BIGINT UNSIGNED NULL COMMENT '订舱代理逻辑ID快照', booking_agent_name VARCHAR(128) NULL COMMENT '订舱代理名称快照', vessel_name VARCHAR(128) NULL COMMENT '当前确认船名快照', @@ -9137,10 +9058,38 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( CONSTRAINT chk_ocean_booking_customer CHECK (customer_id > 0), CONSTRAINT chk_ocean_booking_status CHECK (status IN ('DRAFT','RELEASED')), + CONSTRAINT chk_ocean_booking_carrier_code CHECK + (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')), CONSTRAINT chk_ocean_booking_schedule CHECK (estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at) ) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载'; +SET @ocean_add_carrier_code = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE fl_ocean_booking ADD COLUMN carrier_code VARCHAR(16) NULL COMMENT ''船司标准编码:MATSON/SML/ZIM/COSCO/EMC/HEDE/OTHER;发布前必填'' AFTER shipping_company_name', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_ocean_booking' AND column_name='carrier_code' +); +PREPARE ocean_add_carrier_code_stmt FROM @ocean_add_carrier_code; +EXECUTE ocean_add_carrier_code_stmt; +DEALLOCATE PREPARE ocean_add_carrier_code_stmt; + +SET @ocean_drop_carrier_code_check = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE fl_ocean_booking DROP CHECK chk_ocean_booking_carrier_code', + 'SELECT 1') + FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='fl_ocean_booking' + AND constraint_name='chk_ocean_booking_carrier_code' AND constraint_type='CHECK' +); +PREPARE ocean_drop_carrier_code_check_stmt FROM @ocean_drop_carrier_code_check; +EXECUTE ocean_drop_carrier_code_check_stmt; +DEALLOCATE PREPARE ocean_drop_carrier_code_check_stmt; +ALTER TABLE fl_ocean_booking + ADD CONSTRAINT chk_ocean_booking_carrier_code CHECK + (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')); + -- CREATE TABLE IF NOT EXISTS does not reconcile a database initialized before customer became optional. ALTER TABLE fl_ocean_booking MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照', @@ -10377,11 +10326,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '关联海运订舱ID', container_booking_id BIGINT UNSIGNED NULL COMMENT '指定查询的柜次ID;整票订舱查询时为空', container_scope_id BIGINT UNSIGNED GENERATED ALWAYS AS (COALESCE(container_booking_id,0)) STORED COMMENT '柜次唯一作用域计算值;整票订舱查询固定为0', - carrier_code VARCHAR(16) NOT NULL COMMENT '船公司编码:MATSON美森、SML森罗、ZIM以星、COSCO中远海运、EMC长荣、HEDE合德', + carrier_code VARCHAR(16) NOT NULL COMMENT '船公司标准编码;OTHER表示人工跟踪', tracking_type VARCHAR(32) NOT NULL COMMENT '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号', tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码', official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照', - access_mode VARCHAR(32) NOT NULL COMMENT '船司访问方式:PUBLIC_FORM、PUBLIC_WEB_PROXY、PUBLIC_HTML、HCAPTCHA、OFFICIAL_API', + access_mode VARCHAR(32) NOT NULL COMMENT '船司访问方式;MANUAL表示仅人工补录节点', status VARCHAR(24) NOT NULL COMMENT '任务操作状态:READY、MANUAL_REQUIRED、STOPPED', trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL', latest_standard_node VARCHAR(32) NULL COMMENT '最近实际标准船节点;预计事实不推进', @@ -10423,11 +10372,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( CONSTRAINT fk_carrier_schedule_query_result FOREIGN KEY (result_port_schedule_id) REFERENCES fl_port_schedule(id), CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id > 0), CONSTRAINT chk_carrier_schedule_query_carrier CHECK - (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE')), + (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')), CONSTRAINT chk_carrier_schedule_query_tracking CHECK (tracking_type IN ('BOOKING_NO','BILL_OF_LADING','CONTAINER_NO')), CONSTRAINT chk_carrier_schedule_query_access CHECK - (access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API')), + (access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API','MANUAL')), CONSTRAINT chk_carrier_schedule_query_status CHECK (status IN ('READY','MANUAL_REQUIRED','STOPPED')), CONSTRAINT chk_carrier_schedule_query_node CHECK @@ -10786,6 +10735,7 @@ DROP PROCEDURE IF EXISTS migrate_carrier_schedule_retry_columns; -- CREATE TABLE IF NOT EXISTS 不会替换历史 CHECK 与唯一索引。统一收敛到当前 -- READY/MANUAL_REQUIRED/STOPPED、五种访问方式、十个实际节点和每业务范围唯一任务。 ALTER TABLE fl_carrier_schedule_query + DROP CHECK chk_carrier_schedule_query_carrier, DROP CHECK chk_carrier_schedule_query_access, DROP CHECK chk_carrier_schedule_query_status; DROP PROCEDURE IF EXISTS drop_legacy_carrier_schedule_node_check; @@ -10802,8 +10752,10 @@ DELIMITER ; CALL drop_legacy_carrier_schedule_node_check(); DROP PROCEDURE IF EXISTS drop_legacy_carrier_schedule_node_check; ALTER TABLE fl_carrier_schedule_query + ADD CONSTRAINT chk_carrier_schedule_query_carrier CHECK + (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')), ADD CONSTRAINT chk_carrier_schedule_query_access CHECK - (access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API')), + (access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API','MANUAL')), ADD CONSTRAINT chk_carrier_schedule_query_status CHECK (status IN ('READY','MANUAL_REQUIRED','STOPPED')), ADD CONSTRAINT chk_carrier_schedule_query_node CHECK @@ -13928,6 +13880,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_mojia_measurement_receipt', 'up CALL queue_column_comment('baiyue_firstleg', 'fl_mojia_measurement_receipt', 'weight_kg', '设备实测重量kg'); CALL queue_column_comment('baiyue_firstleg', 'fl_mojia_measurement_receipt', 'width_cm', '设备实测宽度cm'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'bill_of_lading_no', '提单号'); +CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'carrier_code', '船司标准编码:MATSON/SML/ZIM/COSCO/EMC/HEDE/OTHER;发布前必填'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'booking_agent_id', '订舱代理逻辑ID快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'booking_agent_name', '订舱代理名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'booking_no', '平台海运订舱编号'); @@ -14892,6 +14845,21 @@ BEGIN 'baiyue_firstleg','baiyue_file','baiyue_audit','baiyue_event_bridge','baiyue_finance'))<>9 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '统一迁移失败:九个业务数据库未完整创建'; END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema='baiyue_iam' AND table_name='iam_tenant' + AND column_name='dimension_initialization_status') <> 1 + OR (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_iam' + AND table_name='iam_customer_dimension_initialization_task') <> 1 + OR (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema='baiyue_iam' + AND table_name='iam_customer_dimension_initialization_task' + AND column_name IN ('tenant_id','initialization_key','action','status','attempt_count', + 'next_retry_at','lease_owner','lease_until','last_error_code','last_error_message', + 'task_version')) <> 11 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:IAM客户默认尺寸后台初始化状态或可靠任务结构不完整'; + END IF; IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_carrier_schedule_query' AND column_name IN ('trigger_source','latest_standard_node','latest_node_observed_at', @@ -15335,10 +15303,9 @@ VALUES (5105019, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:cost:create', '登记成本', NULL, 'POST', 100, 'ACTIVE', 0), (5105020, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:customer-terms:view', '查看客户账期', NULL, 'GET', 110, 'ACTIVE', 0), (5105021, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:customer-terms:update', '修改客户账期', NULL, 'PUT', 120, 'ACTIVE', 0), - (5205000, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:finance:statement', '财务账单', '/finance/statements', NULL, 40, 'ACTIVE', 0), - (5205010, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:list', '查看账单', NULL, 'GET', 10, 'ACTIVE', 0), - (5205011, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:view', '查看账单详情及收款', NULL, 'GET', 20, 'ACTIVE', 0), - (5205012, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:confirm', '确认客户对账单', NULL, 'POST', 30, 'ACTIVE', 0), + (5305000, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:finance:statement', '财务账单', '/finance/statements', NULL, 40, 'ACTIVE', 0), + (5305010, 0, 'MERCHANT', 'BUTTON', 5305000, 'merchant:finance:statement:list', '查看账单', NULL, 'GET', 10, 'ACTIVE', 0), + (5305011, 0, 'MERCHANT', 'BUTTON', 5305000, 'merchant:finance:statement:view', '查看账单详情及收款', NULL, 'GET', 20, 'ACTIVE', 0), (5305012, 0, 'MERCHANT', 'BUTTON', 5305000, 'merchant:finance:statement:confirm', '确认客户对账单', NULL, 'POST', 30, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), parent_id=VALUES(parent_id), route_path=VALUES(route_path), http_method=VALUES(http_method), status='ACTIVE', deleted=0; @@ -15471,11 +15438,6 @@ WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ( 'platform:finance:settlement-ap:create', 'platform:finance:payment:create', 'platform:finance:settlement:void') ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id); -INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM baiyue_iam.iam_permission - WHERE tenant_id=10001 AND app_code='MERCHANT' AND (permission_code='merchant:finance:statement' OR permission_code LIKE 'merchant:finance:%') -ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id); - -- 开发期 list-only 收口:先移除角色关联并停用旧只读资源,再执行最终 Finance 断言。 DELETE rp FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id @@ -15851,7 +15813,7 @@ CREATE TRIGGER trg_container_document_before_insert BEFORE INSERT ON fl_containe BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=NEW.container_booking_id; - IF current_plan_status='COMPLETED' AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION') THEN + IF current_plan_status='COMPLETED' AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可新增'; END IF; END$$ @@ -15862,7 +15824,7 @@ CREATE TRIGGER trg_container_document_before_update BEFORE UPDATE ON fl_containe BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=OLD.container_booking_id; - IF current_plan_status='COMPLETED' AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION') THEN + IF current_plan_status='COMPLETED' AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可修改'; END IF; END$$ @@ -15941,22 +15903,18 @@ SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND p.permission_code IN ('platform:finance:customer-account:list','platform:finance:customer-arap:list','platform:finance:receivable-statistics:list','platform:finance:customer-statement:list','platform:finance:settlement-ar:list','platform:finance:settlement-ap:list','platform:finance:customer-account:export','platform:finance:customer-arap:export','platform:finance:receivable-statistics:export','platform:finance:customer-statement:export','platform:finance:settlement-ar:export','platform:finance:settlement-ap:export','platform:download:list'); UPDATE iam_permission p JOIN (SELECT 'platform:finance:fee-source:review' code,5105041 parent_id UNION ALL SELECT 'platform:finance:fee-source:edit',5105041 UNION ALL SELECT 'platform:finance:reconciliation:create',5105042 UNION ALL SELECT 'platform:finance:reconciliation:review',5105042 UNION ALL SELECT 'platform:finance:reconciliation:issue',5105042 UNION ALL SELECT 'platform:finance:reconciliation:adjust',5105042 UNION ALL SELECT 'platform:finance:reconciliation:void',5105042 UNION ALL SELECT 'platform:finance:settlement-ar:create',5105043 UNION ALL SELECT 'platform:finance:settlement-ap:create',5105044 UNION ALL SELECT 'platform:finance:payment:create',5105044) mapping ON mapping.code=p.permission_code SET p.parent_id=mapping.parent_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON'; -INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) -SELECT 10001, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=10001 AND p.app_code='MERCHANT' -WHERE r.tenant_id=10001 AND r.app_code='MERCHANT' AND r.role_code IN ('MERCHANT_OWNER','MERCHANT_ADMIN') - AND p.permission_code='merchant:finance:statement:confirm'; - INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='MERCHANT' WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 - AND p.permission_code='merchant:finance:statement:confirm' + AND p.permission_code IN ('merchant:finance:statement','merchant:finance:statement:list', + 'merchant:finance:statement:view','merchant:finance:statement:confirm') AND p.status='ACTIVE' AND p.deleted=0; INSERT INTO baiyue_iam.iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) VALUES - (5201024, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) + (5301024, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), permission_name = VALUES(permission_name), route_path = VALUES(route_path), sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; @@ -16172,11 +16130,8 @@ BEGIN IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台 Finance 权限不完整'; END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=10001 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement:list','merchant:finance:statement:view','merchant:finance:statement:confirm') AND status='ACTIVE' AND deleted=0) <> 3 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户 Finance 查询与确认权限不完整'; - END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='MERCHANT' AND permission_code='merchant:finance:statement:confirm' AND status='ACTIVE' AND deleted=0) <> 1 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局对账单确认权限不完整'; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement','merchant:finance:statement:list','merchant:finance:statement:view','merchant:finance:statement:confirm') AND status='ACTIVE' AND deleted=0) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局 Finance 权限模板不完整'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND (p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 授权不完整'; @@ -16184,11 +16139,8 @@ BEGIN IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND p.status='ACTIVE' AND p.deleted=0 AND p.permission_code IN ('platform:download:list','platform:finance:customer-account:export','platform:finance:customer-arap:export','platform:finance:receivable-statistics:export','platform:finance:customer-statement:export','platform:finance:settlement-ar:export','platform:finance:settlement-ap:export')) <> 7 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 导出和下载中心授权不完整'; END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000101 AND rp.tenant_id=10001 AND (p.permission_code='merchant:finance:statement' OR p.permission_code LIKE 'merchant:finance:%')) <> 4 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户管理员 Finance 查询与确认授权不完整'; - END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 AND p.permission_code='merchant:finance:statement:confirm' AND p.status='ACTIVE' AND p.deleted=0) <> 1 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局管理员缺少对账单确认权限'; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE r.id=4000100 AND r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 AND p.permission_code IN ('merchant:finance:statement','merchant:finance:statement:list','merchant:finance:statement:view','merchant:finance:statement:confirm') AND p.status='ACTIVE' AND p.deleted=0) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局管理员 Finance 模板授权不完整'; END IF; END$$ DELIMITER ; @@ -16632,6 +16584,38 @@ SELECT 0,role.id,permission.id,NOW(3) FROM baiyue_iam.iam_role role JOIN baiyue_iam.iam_permission permission ON permission.id BETWEEN 5107070 AND 5107082 AND permission.tenant_id=0 WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' AND role.status='ACTIVE' AND role.deleted=0; +-- 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 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客户默认尺寸可靠后台初始化任务及诊断状态'; + CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 6fc9929f5..61a281a6c 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1,6 +1,113 @@ -- 海外区域固定增量入口。空库先执行 lastleg-service/src/main/resources/db/lastleg.sql 基线; +-- 本入口同时初始化海外 Event Bridge 独立库,避免国内/海外实例误共用 Inbox。 -- 本文件只由人工审核执行,服务部署默认不自动执行 SQL。 SET NAMES utf8mb4; + +CREATE DATABASE IF NOT EXISTS baiyue_event_bridge_overseas + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_event_bridge_overseas; + +CREATE TABLE IF NOT EXISTS bridge_outbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + idempotency_key VARCHAR(191) NOT NULL COMMENT '发送幂等键', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', + aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', + handover_version BIGINT NULL COMMENT '跨区交接版本', + trace_id VARCHAR(128) NOT NULL COMMENT '链路标识', + correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识', + causation_id VARCHAR(128) NULL COMMENT '上游事件标识', + occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要', + payload_json JSON NOT NULL COMMENT '事件负载', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态', + retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', + lease_owner VARCHAR(128) NULL COMMENT '租约持有者', + lease_until DATETIME(3) NULL COMMENT '租约截止时间', + last_error VARCHAR(1000) NULL COMMENT '最后错误', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + delivered_at DATETIME(3) NULL COMMENT '投递成功时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_bridge_outbox_idempotency (idempotency_key), + KEY idx_bridge_outbox_schedule (target_region,status,next_retry_at,lease_until) +) ENGINE=InnoDB COMMENT='海外跨区域事件待投递箱'; + +CREATE TABLE IF NOT EXISTS bridge_inbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + idempotency_key VARCHAR(191) NOT NULL COMMENT '接收幂等键', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', + aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', + handover_version BIGINT NULL COMMENT '跨区交接版本', + trace_id VARCHAR(128) NOT NULL COMMENT '链路标识', + correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识', + causation_id VARCHAR(128) NULL COMMENT '上游事件标识', + occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要', + payload_json JSON NOT NULL COMMENT '事件负载', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态', + lease_owner VARCHAR(128) NULL COMMENT '消费租约持有者', + lease_until DATETIME(3) NULL COMMENT '消费租约截止时间', + retry_count INT NOT NULL DEFAULT 0 COMMENT '消费重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次消费时间', + last_error VARCHAR(1000) NULL COMMENT '最后错误', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '消费完成时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key), + KEY idx_bridge_inbox_consume (status,next_retry_at,lease_until) +) ENGINE=InnoDB COMMENT='海外跨区域事件已接收箱'; + +CREATE TABLE IF NOT EXISTS bridge_delivery_attempt ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + attempt_no INT NOT NULL COMMENT '投递序号', + status VARCHAR(32) NOT NULL COMMENT '投递结果', + response_code VARCHAR(64) NULL COMMENT '对端响应码', + error_message VARCHAR(1000) NULL COMMENT '错误信息', + started_at DATETIME(3) NOT NULL COMMENT '开始时间', + finished_at DATETIME(3) NULL COMMENT '完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_bridge_attempt (event_id,attempt_no), + KEY idx_bridge_attempt_event (event_id,started_at) +) ENGINE=InnoDB COMMENT='海外跨区域事件投递尝试记录'; + +DROP PROCEDURE IF EXISTS assert_overseas_event_bridge_schema; +DELIMITER $$ +CREATE PROCEDURE assert_overseas_event_bridge_schema() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_event_bridge_overseas' + AND table_name IN ('bridge_outbox','bridge_inbox','bridge_delivery_attempt')) <> 3 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT='海外区域初始化失败:Event Bridge 三张表未完整创建'; + END IF; + IF (SELECT COUNT(DISTINCT CONCAT(table_name, ':', index_name)) FROM information_schema.statistics + WHERE table_schema='baiyue_event_bridge_overseas' + AND ((table_name='bridge_outbox' AND index_name IN ('PRIMARY','uk_bridge_outbox_idempotency','idx_bridge_outbox_schedule')) + OR (table_name='bridge_inbox' AND index_name IN ('PRIMARY','uk_bridge_inbox_idempotency','idx_bridge_inbox_consume')) + OR (table_name='bridge_delivery_attempt' AND index_name IN ('PRIMARY','uk_bridge_attempt','idx_bridge_attempt_event')))) <> 9 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT='海外区域初始化失败:Event Bridge 索引未完整创建'; + END IF; +END$$ +DELIMITER ; +CALL assert_overseas_event_bridge_schema(); +DROP PROCEDURE IF EXISTS assert_overseas_event_bridge_schema; + CREATE DATABASE IF NOT EXISTS baiyue_lastleg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE baiyue_lastleg; @@ -862,6 +969,12 @@ PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt -- Warehouse Overseas 与 Lastleg 使用同一海外区域发布入口但保持独立数据库。 -- 公共托盘详情实体读取 fba_warehouse_code;旧库缺列时创建可成功但详情会 S0500。 USE baiyue_lastlag_warehouse; +-- 仓库主档唯一权威已收口至国内 baiyue_warehouse;海外库只保留 warehouse_id 引用。 +-- 当前开发阶段不兼容旧海外主档数据,按依赖顺序移除重复表。 +DROP TABLE IF EXISTS wh_service_capability; +DROP TABLE IF EXISTS wh_warehouse_address; +DROP TABLE IF EXISTS wh_warehouse; + CREATE TABLE IF NOT EXISTS wh_pallet_operation ( id BIGINT NOT NULL COMMENT '托盘状态操作ID', operation_key VARCHAR(128) NOT NULL COMMENT '跨服务幂等操作键', request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', @@ -939,33 +1052,47 @@ CREATE TABLE IF NOT EXISTS ll_order_operation_log ( PRIMARY KEY(id), KEY idx_ll_order_operation_customer_order(customer_id,order_id,created_at) ) ENGINE=InnoDB COMMENT='尾程订单操作记录'; -CREATE DATABASE IF NOT EXISTS baiyue_overseas_security DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; -USE baiyue_overseas_security; -CREATE TABLE IF NOT EXISTS overseas_security_event_inbox ( - event_id VARCHAR(64) NOT NULL, event_type VARCHAR(96) NOT NULL, payload_hash CHAR(64) NOT NULL, - payload_json JSON NOT NULL, status VARCHAR(16) NOT NULL, result VARCHAR(32) NULL, - received_at DATETIME(3) NOT NULL, processed_at DATETIME(3) NULL, +USE baiyue_lastleg; +CREATE TABLE IF NOT EXISTS ll_security_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '跨区安全事件ID', event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型', + payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256哈希', payload_json JSON NOT NULL COMMENT '冻结事件载荷JSON', + status VARCHAR(16) NOT NULL COMMENT '消费状态:RECEIVED或PROCESSED', + result VARCHAR(32) NULL COMMENT '处理结果:APPLIED、STALE、DUPLICATE或DUPLICATE_VERSION', + received_at DATETIME(3) NOT NULL COMMENT '接收时间', processed_at DATETIME(3) NULL COMMENT '处理完成时间', PRIMARY KEY (event_id), KEY idx_security_inbox_type_time (event_type,received_at) -) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者Inbox'; -CREATE TABLE IF NOT EXISTS overseas_security_projection ( - aggregate_type VARCHAR(32) NOT NULL, aggregate_id VARCHAR(64) NOT NULL, tenant_id BIGINT NOT NULL, - security_status VARCHAR(24) NULL, permission_version BIGINT NULL, aggregate_version BIGINT NOT NULL, - payload_hash CHAR(64) NOT NULL, last_event_id VARCHAR(64) NOT NULL, - changed_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL, +) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者收件箱'; +CREATE TABLE IF NOT EXISTS ll_security_projection ( + aggregate_type VARCHAR(32) NOT NULL COMMENT '聚合类型:USER、TENANT或AUTHORIZATION', + aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID', tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID', + security_status VARCHAR(24) NULL COMMENT '当前安全状态:ACTIVE或DISABLED', + permission_version BIGINT NULL COMMENT '当前单调权限版本', + aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本', payload_hash CHAR(64) NOT NULL COMMENT '当前版本载荷哈希', + last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用的事件ID', + changed_at DATETIME(3) NOT NULL COMMENT '国内IAM事实发生时间', updated_at DATETIME(3) NOT NULL COMMENT '投影更新时间', PRIMARY KEY (aggregate_type,aggregate_id), KEY idx_security_projection_tenant (tenant_id), KEY idx_security_projection_permission (aggregate_id,permission_version) ) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; -DROP PROCEDURE IF EXISTS assert_overseas_security_projection_ready; +-- 与国内固定 auth_account.id=1000001、tenant_id=0 的 platform_admin 一致;真实事件从版本1覆盖。 +INSERT IGNORE INTO ll_security_projection ( + aggregate_type,aggregate_id,tenant_id,security_status,permission_version,aggregate_version, + payload_hash,last_event_id,changed_at,updated_at +) VALUES + ('USER','1000001',0,'ACTIVE',0,0,SHA2('lastleg-baseline:platform_admin:USER:0',256), + 'baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'), + ('AUTHORIZATION','1000001',0,NULL,0,0,SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0',256), + 'baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); + +DROP PROCEDURE IF EXISTS assert_lastleg_security_projection_ready; DELIMITER $$ -CREATE PROCEDURE assert_overseas_security_projection_ready() +CREATE PROCEDURE assert_lastleg_security_projection_ready() BEGIN - IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_overseas_security' - AND table_name IN ('overseas_security_event_inbox','overseas_security_projection')) <> 2 THEN + IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_lastleg' + AND table_name IN ('ll_security_event_inbox','ll_security_projection')) <> 2 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外IAM安全Inbox或投影表未完整部署'; END IF; END$$ DELIMITER ; -CALL assert_overseas_security_projection_ready(); -DROP PROCEDURE assert_overseas_security_projection_ready; +CALL assert_lastleg_security_projection_ready(); +DROP PROCEDURE assert_lastleg_security_projection_ready; SELECT 'PASS' AS migration_status, '海外仓业务、海外仓资源与IAM安全投影结构已就绪' AS message; diff --git a/docs/Order-Firstleg在线依赖解环设计.md b/docs/Order-Firstleg在线依赖解环设计.md new file mode 100644 index 000000000..d8828c29e --- /dev/null +++ b/docs/Order-Firstleg在线依赖解环设计.md @@ -0,0 +1,60 @@ +# Order↔Firstleg 在线依赖解环设计 + +## 结论 + +当前不能在不改变已确认产品成功时点的前提下直接实施解环,业务代码保持不变。在线图仍包含 +`Order→Firstleg` 与 `Firstleg→Order`,所以非平凡强连通分量仍是 `Firstleg↔Order`。 + +建议的最小退出方向是 **退出全部 `Firstleg→Order` REQUEST 边**。该方向只有 +`OrderCargoClient` 和 `OrderFulfillmentSplitClient` 两个客户端;Order 继续拥有订单、货物、父子订单和 +拆分台账,Firstleg 继续拥有入库、测量、分柜装柜、PDA 拣货和库位结算。完成后只保留 +`Order→Firstleg` 在线查询/门禁边以及双方后台边,在线 SCC 才会实际消失。 + +## 当前可执行调用 + +| 方向/客户端 | Controller→Service→Client→被调用入口 | 时点与调用者期望 | 失败传播 | 幂等性 | +|---|---|---|---|---| +| Order→Firstleg `FirstlegPricingClient` | `MerchantFirstLegOrderController`、`PlatformFirstLegOrderController` 的创建/编辑/提交 → `OrderApplicationService` → `OrderPricingChannelGuard` → `/internal/firstleg/v1/pricing/channels/validate` | 保存或提交前实时确认渠道有效;提交还必须取得入仓码前缀 | 业务错误原码上传;连接、空响应或不完整数据映射依赖不可用/冲突,订单不提交 | 纯读取 `SAFE_READ` | +| Order→Firstleg `FirstlegUnifiedOrderClient` | `PlatformUnifiedOrderController`、`MerchantUnifiedOrderController` 列表/详情 → `UnifiedOrderQueryService` → Firstleg unified query/summaries/detail | 一个响应内组合 Order 主数据与 Firstleg 实时履约、测量、装柜和时间线 | 远端异常、失败码、空数据均失败关闭,不返回伪造履约摘要 | 纯读取 `SAFE_READ` | +| Order→Firstleg `FirstlegInboundClient` | `MerchantFirstLegOrderController.page` → `MerchantInboundOrderListService` → references/prepareBatch;已提交订单编辑提交后 → `OrderApplicationService.prepareDraftInbound` → prepare;审核 Outbox → `OrderInboundOutboxPublisher` → create | 列表需要入库引用;草稿/待审编辑尝试同步预入库;审核成功已与 Firstleg 建单解耦 | references 失败关闭;prepare/prepareBatch 当前捕获异常并允许列表缺少入库引用,是现有失败传播缺口;审核 Outbox 持久化重试 | `tenantId+orderId` 业务键;Firstleg 对订单唯一且入仓码一致时幂等 | +| Firstleg→Order `OrderCargoClient` | 箱号 Controller/Order 审核后台建单 → `InboundCartonLabelService`;测量 Controller/OpenAPI → `InboundMeasurementService`/`InboundMeasurementOpenApiService`;智能装柜 Controller → `SmartLoadingService` → Order cargo/snapshot endpoints | 首次箱号、测量明细、申报回填和装柜清单均要求当前订单货物事实 | 服务不可用、空数据、归属不一致均阻断当前操作;没有默认货物或空列表成功 | 纯读取 `SAFE_READ`;同时存在 REQUEST 和审核 Outbox 回调产生的 BACKGROUND 上下文 | +| Firstleg→Order `OrderFulfillmentSplitClient` | `PlatformLoadingPdaController.complete` → `LoadingPickService.complete @Transactional` → `PdaPickSettlementService.settle` → `/internal/order/v1/fulfillment-splits` → `OrderFulfillmentSplitService.split @Transactional` | PDA 完成请求必须先取得 Order 真实余量子单 ID/单号,再在同一 Firstleg 事务写分片、测量、库位、审计并推进装柜完成 | Order 不可用或响应数量/柜次/父子关系不一致时整个 Firstleg 完成事务回滚;不得返回完成成功 | Firstleg 使用 `idempotencyKey=pda-task-{taskId}`、`operationId=PDA-SPLIT-{taskId}-{inboundId}`;Order 保存请求哈希并以唯一键重放 | + +## 事实所有权 + +- Order:订单交易状态、订单货物及允许编辑的字段、父子订单 ID/单号、履约拆分台账和订单版本。 +- Firstleg:入库单、测量事实、分柜/装柜计划、PDA 拣货数量、库位结算、装柜证据和 `LOADING→LOADED` 门禁。 +- 允许复制的仅是带来源版本和哈希的 Order 快照;Firstleg 不得成为订单货物或父子订单的第二写入方。 +- 统一订单查询是组合读模型,不把 Firstleg 履约状态写回 Order 交易状态。 + +## 推荐退出方案 + +### 1. 先退出货物在线读取 + +复用 Order 已有 Outbox 和 Firstleg 已有订单入库接收入口,将完整货物快照、订单版本和规范化哈希随草稿准备、待审编辑和审核事件可靠投递;Firstleg 以 +`tenantId+orderId+orderVersion` 单调 Inbox/快照落库。箱号、测量和智能装柜只读本地快照,缺失、版本冲突或哈希冲突均失败关闭。 + +不能只在审核时复制一次:当前契约允许 `LOADED` 前按权限和状态修改业务资料。必须先冻结货物在哪个节点停止修改,或要求每次允许的 Order 编辑都可靠发布版本事件,并明确 Firstleg 在投影落后期间阻断哪些操作。 + +### 2. 再将履约拆分改为可靠后台命令 + +Firstleg 在本地持久化稳定 operation/idempotency key、数量行、柜次和请求哈希;后台 Relay 调用现有 Order 幂等拆分接口并保存完整响应。Order 现有拆分台账和唯一键可承担 Inbox/业务幂等,不需要新服务或 MQ。 + +Firstleg 只有在后台结果为 `READY` 且父子关系、数量和柜次全部校验通过后,才能消费余量子订单 ID并执行本地结算。失败进入可诊断重试/DEAD,禁止默认成功。远端成功、本地未完成时只允许向前恢复,不得自动删除 Order 子订单。 + +这一方案会引入技术态 `PENDING/READY/FAILED`,但不能擅自增加产品主状态。若在 PDA 首次点击完成时只创建任务并返回待处理,成功时点发生变化;若提前创建 Order 子单,则子单可能在装柜硬门禁完成前可见,同样改变现有语义。 + +## 必须由产品冻结的决策 + +1. **货物冻结点**:Order 货物在审核通过、首次生成箱号、确认测量还是确认预装后不可再改;若仍允许修改,哪些 Firstleg 操作必须等待最新 `orderVersion` 投影。 +2. **PDA 完成成功时点**:必须继续在一次请求中同时完成 Order 子单与 Firstleg 结算,还是允许首次请求返回“处理中”,后台完成后才成为 `LOADED`。同时确认后台已创建 Order 子单但 Firstleg 最终失败时采用持续向前修复,不提供在线冲正。 + +上述任一决策未冻结前,不实施业务解环。 + +## 最小实施与验证边界(决策冻结后) + +- Order:扩充版本化货物快照 Outbox;保留 `OrderFulfillmentSplitService` 事实所有权和现有幂等唯一键。 +- Firstleg:货物快照 Inbox/表、拆分命令 Outbox/Relay/诊断查询;替换两个在线 Client 的业务调用点。 +- SQL:仅更新当前模块基线与统一增量,不引入 Flyway/Liquibase,不执行 SQL。 +- 合同:证明生产请求路径不再引用两个客户端、`Firstleg→Order` 只剩 BACKGROUND,REQUEST SCC 中不再出现两者同一分量。 +- 测试:Order/Firstleg 全量直接测试、重复/异哈希事件、租约恢复、远端成功本地失败重放、DEAD 人工重试、PDA 未 READY 失败关闭,以及同步依赖合同。 diff --git a/docs/同步调用依赖与失败传播治理基线.md b/docs/同步调用依赖与失败传播治理基线.md index 0562b97bb..f7cba31aa 100644 --- a/docs/同步调用依赖与失败传播治理基线.md +++ b/docs/同步调用依赖与失败传播治理基线.md @@ -2,7 +2,7 @@ ## 目的与口径 -本基线覆盖后端服务之间的 OpenFeign、`RestClient`、`WebClient` 与 JDK `HttpClient` 调用。它用于识别同步依赖方向、在线请求深度、后台中继、超时、重试、熔断、幂等、错误映射和失败传播,不改变 Finance 或四期业务状态机。 +本基线覆盖当前官方 22 个 Maven 模块、15 个可执行服务之间的 OpenFeign、`RestClient`、`WebClient` 与 JDK `HttpClient` 调用。海外安全 Inbox/投影属于 Lastleg 内部子域,不再作为独立服务节点。基线用于识别同步依赖方向、在线请求深度、后台中继、超时、重试、熔断、幂等、错误映射和失败传播,不改变 Finance 或四期业务状态机。 “存在双向服务边”不等于“一次在线请求会递归调用”。每条边必须继续区分: @@ -21,12 +21,12 @@ |---|---|---:|---| | Auth | IAM | 2 | 登录授权、注册,`REQUEST` | | IAM | Auth | 1 | 账号开通/管理,`REQUEST` | -| IAM | Firstleg | 1 | 客户尺寸规则,`REQUEST` | +| IAM | Firstleg | 1 | 客户尺寸默认规则可靠任务投递,`BACKGROUND` | | IAM | Warehouse | 1 | 海外仓储费协作,`REQUEST` | | IAM | Event Bridge | 1 | 安全事件中继,`BACKGROUND` | | Order | Firstleg | 3 | 计价/统一订单查询为 `REQUEST`,入库协作为 Outbox `BACKGROUND` | | Order | IAM / File / Warehouse | 各 1 | 资格、图片、仓库守卫,`REQUEST` | -| Firstleg | Order | 2 | 货物查询与分拆回写,`REQUEST` | +| Firstleg | Order | 2 | 货物查询同时存在 `REQUEST/BACKGROUND`;PDA 履约分拆为 `REQUEST` | | Firstleg | IAM / Finance / Integration / Event Bridge / Print | 各 1 | IAM、Integration、Print 为 `REQUEST`;Finance 与 Event Bridge 为 `BACKGROUND` 中继 | | Firstleg | File | 7 | 测量、装柜、凭证、报关、拖车等,`REQUEST` | | Firstleg | Warehouse | 4 | 主数据、供应商、库位/托盘,`REQUEST` | @@ -45,10 +45,10 @@ | 调用方 | 被调用方 | 实现 | 上下文与当前策略 | |---|---|---|---| | Gateway Overseas | Auth | `HttpPdaSessionValidationClient` | `INFRASTRUCTURE`;总超时 2 秒,异常由网关失败关闭 | -| Gateway Overseas | Overseas Security | `HttpOverseasSecurityValidationClient` | `INFRASTRUCTURE`;安全投影实时校验,异常失败关闭,不进入在线业务图 | +| Gateway Overseas | Lastleg Security 子域 | `HttpOverseasSecurityValidationClient` | `INFRASTRUCTURE`;Lastleg 内部安全投影实时校验,异常失败关闭,不进入在线业务图 | | Lastleg | Warehouse Overseas | 两个 `RestClient` Gateway | `REQUEST`;分别使用 3 秒连接、5 秒读取超时,配置可覆盖,不自动重试 | | Lastleg | File | 两个照片 `RestClient` Gateway | `REQUEST`;分别使用 3 秒连接、30 秒读取超时,配置可覆盖;校验响应 code 且失败关闭,不自动重试 | -| Event Bridge | Firstleg / Lastleg / Overseas Security | 三个 JDK `HttpClient` | `BACKGROUND`;连接 3 秒、请求 10 秒,持久化租约和退避由 Event Bridge 专项治理;Security 投影不进入在线业务图 | +| Event Bridge | Firstleg / Lastleg(业务事件与安全投影两个入口) | 三个 JDK `HttpClient` | `BACKGROUND`;连接 3 秒、请求 10 秒,持久化租约和退避由 Event Bridge 专项治理;Lastleg Security 投影不进入在线业务图 | | 启用 Common Audit 的服务 | Audit | `HttpAuditSink` | `INFRASTRUCTURE`;当前捕获异常后仅告警,可能让调用方误以为审计已成功,是高优先级失败传播缺口 | | Integration | 外部 OCR/船司 | 六个 JDK `HttpClient` | `EXTERNAL`;OCR 默认 10/60 秒,船司默认 10/30 秒,业务重试必须服从供应商和调度契约 | @@ -67,21 +67,21 @@ ### 在线 REQUEST 图 -在线图只包含用户或设备请求线程中实际执行的服务边。Event Bridge、Outbox、船节点定时任务、导出 Provider 和海外 Security 校验均不计入在线深度。 +在线图只包含用户或设备请求线程中实际执行的服务边。Event Bridge、Outbox、船节点定时任务、导出 Provider 和 Gateway→Lastleg Security 基础设施校验均不计入在线深度。 当前在线强连通分量为: -`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→Lastleg Security 是 `INFRASTRUCTURE`,不是 REQUEST 边。 ### 后台 BACKGROUND 图 后台图包含 Outbox/Inbox、导出 Worker 和定时任务,当前强连通分量为: -`Event Bridge ↔ Firstleg ↔ Finance ↔ Lastleg` +`Event Bridge ↔ Firstleg ↔ Finance ↔ Lastleg ↔ Order` -File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Overseas Security 是单向后台叶子边,不形成在线业务环。 +File 的五个导出 Provider、Order→Firstleg 入库 Outbox、该入库消费者为生成箱号而回读 Order 货物的后台回调、IAM 默认尺寸可靠任务、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Lastleg Security 与 Event Bridge→Lastleg 业务事件在服务图上收敛为同一条后台服务边,不形成新的在线业务边或独立安全节点。 ### 全图 @@ -97,21 +97,20 @@ 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 或四期业务状态机。 + +### Order↔Firstleg 第二阶段结论 + +具体入口、所有权与可执行退出方案见 `docs/Order-Firstleg在线依赖解环设计.md`。当前 `COMPLETE_LOADING` 在 Firstleg 本地事务中同步取得 Order 余量子单后才完成结算,且 Order 货物在 `LOADED` 前仍允许修改;成功时点和货物冻结点尚未冻结,因此本阶段不改业务代码。REQUEST SCC 仍为 `Firstleg↔Order`,合同明确阻止仅改变分类却未退出生产调用的伪解环。 ## 退出顺序 diff --git a/docs/头程项目目录结构与服务说明.md b/docs/头程项目目录结构与服务说明.md index 2ed49423a..f16f95e37 100644 --- a/docs/头程项目目录结构与服务说明.md +++ b/docs/头程项目目录结构与服务说明.md @@ -10,7 +10,7 @@ ## 1. 项目定位 -`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 23 个 Maven 模块,其中 7 个是公共/依赖模块,16 个是可独立启动、构建、制作镜像的微服务。 +`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 22 个 Maven 模块,其中 7 个是公共/依赖模块,15 个是可独立启动、构建、制作镜像的微服务。 当前技术基线: @@ -48,7 +48,6 @@ baiyue-firstleg/ ├─ finance/ # 财务账期、账单、收付款与核销 ├─ lastleg-service/ # 海外尾程、入仓、库存与本土业务 ├─ warehouse-overseas-service/ # 海外仓库、库位、托盘与资源锁 -├─ overseas-security-service/ # 海外IAM安全事件Inbox、投影与实时校验 ├─ file/ # 资质、导出与测量图片文件中心 ├─ print/ # 打印中心 ├─ audit-service/ # 审计日志中心 @@ -100,9 +99,8 @@ baiyue-firstleg/ | `file` | `file-service` | 9206 | `baiyue_file` | 企业资质文件、公共异步导出下载、入库测量图片及重量 OCR 调用协作 | | `print` | `print-service` | 9207 | 无 | 无状态 PDF 渲染服务;调用方传入完整标签数据,服务在内存中生成一次性多页 PDF | | `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 | -| `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存与海外本土业务;不含海外出库和派送 | +| `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存、海外本土业务及 IAM 安全事件投影/实时校验;不含海外出库和派送 | | `warehouse-overseas-service` | `warehouse-overseas-service` | 9305 | 海外仓资源库 | 海外仓库、库位、托盘和资源锁 | -| `overseas-security-service` | `overseas-security-service` | 9307 | `baiyue_overseas_security` | IAM安全事件可靠Inbox、单调投影及海外Token即时失效校验 | | `event-bridge-service` | `event-bridge-service` | 9312 | `baiyue_event_bridge` | 按 CN/OVERSEAS 双实例部署的跨区事件 Inbox、重试和目标服务投递 | | `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;平台端可按权限查询已脱敏日志,业务审计发送默认开启 | @@ -283,6 +281,6 @@ HTTP 401 / `A0401`,不得再按 HTTP 200 + 空权限处理;只有使用新 - 平台端和商户端权限分别管理,接口与按钮权限都必须由后端校验。 - Controller 负责协议转换,Service 负责业务编排,Repository/Mapper 负责持久化。 - 服务之间只传 DTO 或事件契约,不能依赖其他服务的 Entity、Mapper。 -- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 16 个服务执行受影响构建。 +- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 15 个服务执行受影响构建。 - 单一 Git 仓库不等于单一部署单元;每个服务仍生成独立 JAR、镜像和容器。 - 国内头程与海外仓共享 Git 仓库,但保持两套数据库、Nacos 命名空间和部署环境。 diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index 14ed05d26..99d0553fb 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -12,12 +12,13 @@ |---|---| | 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` | | Inbox 接收与幂等 | `service/EventInboxService.java` | -| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 回投 Firstleg;海外业务事件投 Lastleg,三类 IAM 安全事件按 eventType 投 `overseas-security-service`,传输层不解析 payload | +| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 回投 Firstleg;海外业务事件投 Lastleg 通用入口,三类 IAM 安全事件按 eventType 投 Lastleg `/internal/lastleg/security/v1/events`,传输层不解析 payload | | 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` | | 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` | | 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` | | Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper | | 传输指标与 Inbox 采样 | `metrics/EventBridgeMetrics.java`、`EventBridgeInboxMetricsSampler.java`;SLO 与告警合同 `docs/development-observability-slo.md` | +| 事件 Trace 与原始 HTTP 传播 | `trace/EventBridgeTraceScope.java`、`EventBridgeHttpTracePropagation.java`;契约与已知缺口 `docs/event-trace-propagation.md` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 数据库 | `src/main/resources/db/event-bridge.sql` | | 测试 | `src/test/java/com/baiyue/eventbridge/` | @@ -29,6 +30,7 @@ - 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。 - 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。 - 指标标签只允许有限 `target_region`、`event_type`、`result`,未知区域/类型折叠为 `OTHER`,禁止 eventId、customerId、错误消息等高基数标签。Inbox 聚合按现有状态索引定时采样,Prometheus scrape 不得查询数据库;开发阶段 SLO/告警只代表代码合同,不代表监控平台已部署。 +- 投递线程只从 Inbox 已冻结 `trace_id` 建立 MDC;合法 32 位 trace ID 或规范 UUID 可贯通,非法历史值安全重建。每次 JDK HttpClient 投递生成 child span,并覆盖三类标准 Trace 头;不得把 correlationId、eventId、customerId、聚合 ID、JWT 或内部令牌写入 Trace 字段。 ## 修改与验证 diff --git a/event-bridge-service/docs/event-trace-propagation.md b/event-bridge-service/docs/event-trace-propagation.md new file mode 100644 index 000000000..00c2e42c3 --- /dev/null +++ b/event-bridge-service/docs/event-trace-propagation.md @@ -0,0 +1,16 @@ +# Event Bridge Trace 传播基线 + +- Event Bridge 领取 Inbox 后,仅从事件信封已冻结的 `traceId` 建立投递线程 MDC。 +- 32 位非零十六进制 trace ID 直接沿用;历史规范 UUID 去除连字符后沿用。 +- 其他旧值不满足 W3C Trace ID 约束,投递时安全重建新 trace,但不覆盖或迁移原事件信封。 +- 每次 HTTP 投递生成新的 `X-Request-Id` 和 child span,并统一覆盖 `X-Trace-Id`、`traceparent`。 +- `correlationId`、`eventId`、customer ID、聚合 ID、JWT 和内部服务令牌不得写入 Trace 标准字段。 +- 投递日志在 MDC 和显式 `traceId` 字段中记录本次标准 trace;投递 attempt 仍通过 `event_id` + 关联 `bridge_inbox.trace_id`,本轮不增加数据库字段。 + +## 共享契约缺口 + +当前 `EventEnvelope.traceId` 仍是通用字符串契约,没有强制 32 位 W3C Trace ID;因此历史任意字符串不能 +无损映射为 W3C trace。若后续要求所有事件跨重试、跨语言绝对保持同一标准 trace,必须由 +`common-event-contract` 生产者、消费者和 Event Bridge 共同冻结字段校验后再收敛。本轮没有修改共享 +Payload、信封字段、哈希规则或可靠性状态机。 diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java index 50a3bbe66..85756ae0e 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java @@ -6,6 +6,7 @@ import com.baiyue.eventbridge.domain.BridgeInbox; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.time.Clock; @@ -41,6 +42,7 @@ public class EventBridgeMetrics { private final MeterRegistry registry; private final Clock clock; + @Autowired public EventBridgeMetrics(MeterRegistry registry) { this(registry, Clock.systemDefaultZone()); } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java index f4adf8f3d..f904c7984 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java @@ -14,7 +14,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import jakarta.annotation.Resource; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.Objects; /** @@ -78,7 +80,7 @@ public class EventInboxService { && Objects.equals(existing.getAggregateId(), envelope.aggregateId()) && Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion()) && Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion()) - && Objects.equals(existing.getOccurredAt(), envelope.occurredAt()) + && Objects.equals(normalizeOccurredAt(existing.getOccurredAt()), normalizeOccurredAt(envelope.occurredAt())) && Objects.equals(existing.getTraceId(), envelope.traceId()) && Objects.equals(existing.getCorrelationId(), envelope.correlationId()) && Objects.equals(existing.getCausationId(), envelope.causationId()); @@ -100,7 +102,7 @@ public class EventInboxService { inbox.setTraceId(envelope.traceId()); inbox.setCorrelationId(envelope.correlationId()); inbox.setCausationId(envelope.causationId()); - inbox.setOccurredAt(envelope.occurredAt()); + inbox.setOccurredAt(normalizeOccurredAt(envelope.occurredAt())); inbox.setPayloadHash(envelope.payloadHash().toLowerCase(java.util.Locale.ROOT)); try { inbox.setPayloadJson(objectMapper.writeValueAsString(envelope.payload())); @@ -111,4 +113,8 @@ public class EventInboxService { inbox.setReceivedAt(LocalDateTime.now()); return inbox; } + + private static Instant normalizeOccurredAt(Instant value) { + return value == null ? null : value.truncatedTo(ChronoUnit.MILLIS); + } } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java index fe26409f2..f888cd04f 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java @@ -4,6 +4,7 @@ import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.RegionCode; import com.baiyue.eventbridge.config.EventDispatcherProperties; import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; @@ -20,6 +21,7 @@ import java.net.http.HttpResponse; public class HttpFirstlegEventClient implements FirstlegEventClient { @Resource private ObjectMapper objectMapper; @Resource private EventDispatcherProperties properties; + @Resource private EventBridgeHttpTracePropagation tracePropagation; @Value("${baiyue.event-dispatcher.firstleg-consumer-url:http://127.0.0.1:9203/internal/firstleg/v1/container-events}") private URI consumerUrl; @Value("${baiyue.security.internal-service-token:}") private String serviceToken; @@ -37,9 +39,10 @@ public class HttpFirstlegEventClient implements FirstlegEventClient { row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(), row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); - HttpRequest request = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) .header("Content-Type", "application/json") - .header("X-Internal-Service-Token", serviceToken) + .header("X-Internal-Service-Token", serviceToken); + HttpRequest request = tracePropagation.apply(requestBuilder) .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); JsonNode responseBody; diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java index e3b3dd5b8..2e01d3981 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java @@ -4,6 +4,7 @@ import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.RegionCode; import com.baiyue.eventbridge.config.EventDispatcherProperties; import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; @@ -19,6 +20,7 @@ import java.net.http.HttpResponse; public class HttpLastlegEventClient implements LastlegEventClient { @Resource private ObjectMapper objectMapper; @Resource private EventDispatcherProperties properties; + @Resource private EventBridgeHttpTracePropagation tracePropagation; @Value("${baiyue.security.internal-service-token:}") private String serviceToken; @@ -38,10 +40,11 @@ public class HttpLastlegEventClient implements LastlegEventClient { row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); String body = objectMapper.writeValueAsString(envelope); - HttpRequest request = HttpRequest.newBuilder(properties.consumerUrl()) + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(properties.consumerUrl()) .timeout(properties.requestTimeout()) .header("Content-Type", "application/json") - .header("X-Internal-Service-Token", serviceToken) + .header("X-Internal-Service-Token", serviceToken); + HttpRequest request = tracePropagation.apply(requestBuilder) .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java index e18cf8714..9e55d56bd 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java @@ -4,9 +4,11 @@ import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.RegionCode; import com.baiyue.eventbridge.config.EventDispatcherProperties; import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -19,6 +21,7 @@ import java.net.http.HttpResponse; public class HttpSecurityProjectionEventClient implements SecurityProjectionEventClient { private final ObjectMapper objectMapper; private final EventDispatcherProperties properties; + @Resource private EventBridgeHttpTracePropagation tracePropagation; @Value("${baiyue.event-dispatcher.security-consumer-url}") private URI consumerUrl; @Value("${baiyue.security.internal-service-token:}") private String serviceToken; private HttpClient httpClient; @@ -39,18 +42,23 @@ public class HttpSecurityProjectionEventClient implements SecurityProjectionEven row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(), row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); - HttpRequest request = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) .header("Content-Type", "application/json") - .header("X-Internal-Service-Token", serviceToken) + .header("X-Internal-Service-Token", serviceToken); + HttpRequest request = tracePropagation.apply(requestBuilder) .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); JsonNode body; try { body = objectMapper.readTree(response.body()); } catch (Exception exception) { body = null; } - if (response.statusCode() / 100 != 2 || body == null || !"200".equals(body.path("code").asText()) - || !row.getEventId().equals(body.path("data").path("eventId").asText())) { - throw new IllegalStateException("海外安全投影消费失败: HTTP " + response.statusCode()); - } + requireAcknowledgement(response.statusCode(), body, row.getEventId()); return String.valueOf(response.statusCode()); } + + static void requireAcknowledgement(int statusCode, JsonNode body, String eventId) { + if (statusCode / 100 != 2 || body == null || !"200".equals(body.path("code").asText()) + || !eventId.equals(body.path("data").path("eventId").asText())) { + throw new IllegalStateException("Lastleg 安全投影消费失败: HTTP " + statusCode); + } + } } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java index 54cd77867..b20be547f 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java @@ -7,10 +7,13 @@ import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; import com.baiyue.eventbridge.metrics.EventBridgeMetrics; +import com.baiyue.eventbridge.trace.EventBridgeTraceScope; +import com.baiyue.common.web.CorrelationContext; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import org.slf4j.MDC; import java.lang.management.ManagementFactory; import java.time.LocalDateTime; @@ -42,6 +45,12 @@ public class OverseasEventDispatcher { } BridgeInbox event = inboxMapper.selectById(eventId); + try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) { + dispatchClaimed(eventId, event); + } + } + + private void dispatchClaimed(String eventId, BridgeInbox event) { int retryAttempt = event.getRetryCount() == null ? 1 : event.getRetryCount() + 1; int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId); BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo); @@ -57,8 +66,8 @@ public class OverseasEventDispatcher { : "FAILED_LEASE_LOST"; finishAttempt(attempt, attemptStatus, null, error); metrics.recordTransport(event, EventBridgeMetrics.TransportResult.valueOf(attemptStatus)); - log.warn("跨区事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}", - eventId, retryAttempt, deliveryAttemptNo, error); + log.warn("跨区事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, traceId={}, reason={}", + eventId, retryAttempt, deliveryAttemptNo, MDC.get(CorrelationContext.TRACE_ID), error); return; } @@ -66,7 +75,8 @@ public class OverseasEventDispatcher { String error = "下游已确认成功,但事件租约已失效;保留当前 Inbox 状态并由新租约持有者幂等续投"; finishAttempt(attempt, "ACKNOWLEDGED_LEASE_LOST", responseCode, error); metrics.recordTransport(event, EventBridgeMetrics.TransportResult.ACKNOWLEDGED_LEASE_LOST); - log.warn("跨区事件成功确认时租约已失效 eventId={}, deliveryAttempt={}", eventId, deliveryAttemptNo); + log.warn("跨区事件成功确认时租约已失效 eventId={}, deliveryAttempt={}, traceId={}", + eventId, deliveryAttemptNo, MDC.get(CorrelationContext.TRACE_ID)); return; } finishAttempt(attempt, "DELIVERED", responseCode, null); diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java new file mode 100644 index 000000000..dc2c78bd3 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java @@ -0,0 +1,19 @@ +package com.baiyue.eventbridge.trace; + +import com.baiyue.common.web.CorrelationContext; +import com.baiyue.common.web.OutboundTraceHeaders; +import org.springframework.stereotype.Component; + +import java.net.http.HttpRequest; + +/** Applies a fresh child span to one raw JDK HttpClient request. */ +@Component +public class EventBridgeHttpTracePropagation { + public HttpRequest.Builder apply(HttpRequest.Builder builder) { + OutboundTraceHeaders outbound = OutboundTraceHeaders.fromCurrentOrNew(); + return builder + .setHeader(CorrelationContext.REQUEST_ID_HEADER, outbound.requestId()) + .setHeader(CorrelationContext.TRACE_ID_HEADER, outbound.traceId()) + .setHeader(CorrelationContext.TRACE_PARENT_HEADER, outbound.traceParent()); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java new file mode 100644 index 000000000..82139ab51 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java @@ -0,0 +1,35 @@ +package com.baiyue.eventbridge.trace; + +import com.baiyue.common.web.CorrelationContext; +import com.baiyue.common.web.OutboundTraceHeaders; +import com.baiyue.eventbridge.domain.BridgeInbox; +import org.slf4j.MDC; + +import java.util.Map; + +/** Establishes one trusted MDC context for an Event Bridge delivery attempt. */ +public final class EventBridgeTraceScope implements AutoCloseable { + private final Map previous; + + private EventBridgeTraceScope(Map previous) { + this.previous = previous; + } + + public static EventBridgeTraceScope open(BridgeInbox event) { + Map previous = MDC.getCopyOfContextMap(); + OutboundTraceHeaders local = OutboundTraceHeaders.fromTrustedTraceOrNew(event.getTraceId()); + MDC.put(CorrelationContext.REQUEST_ID, local.requestId()); + MDC.put(CorrelationContext.TRACE_ID, local.traceId()); + MDC.put(CorrelationContext.SPAN_ID, local.spanId()); + return new EventBridgeTraceScope(previous); + } + + @Override + public void close() { + if (previous == null) { + MDC.clear(); + } else { + MDC.setContextMap(previous); + } + } +} diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml index fc5524546..b20743a41 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -33,7 +33,7 @@ baiyue: event-dispatcher: consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}} firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9202/internal/firstleg/v1/container-events} - security-consumer-url: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9307/internal/overseas-security/v1/events} + security-consumer-url: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/security/v1/events} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java index 39a229241..636db964f 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java @@ -8,12 +8,32 @@ import org.junit.jupiter.api.Test; import java.lang.reflect.Modifier; import java.util.Arrays; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.Mockito.mock; class EventBridgeMetricsTest { + @Test + void productionConstructorIsExplicitForSpringWhenTestClockConstructorAlsoExists() throws Exception { + assertThat(EventBridgeMetrics.class.getConstructor(MeterRegistry.class) + .isAnnotationPresent(Autowired.class)).isTrue(); + } + + @Test + void springContextCreatesMetricsBeanWithTheAnnotatedProductionConstructor() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.registerBean(MeterRegistry.class, SimpleMeterRegistry::new); + context.register(EventBridgeMetrics.class); + context.refresh(); + + assertThat(context.getBean(EventBridgeMetrics.class)).isNotNull(); + } + } + @Test void collapsesUnboundedRegionAndEventTypeLabelsToOther() { SimpleMeterRegistry registry = new SimpleMeterRegistry(); diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java index c8ee93232..6789ed520 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java @@ -65,6 +65,22 @@ class EventInboxServiceTest { assertThat(receiveCount("DUPLICATE")).isEqualTo(1.0); } + @Test + void 数据库毫秒精度截断后相同事件仍判定为重复() { + EventEnvelope source = envelope("sha256:placeholder"); + EventEnvelope precise = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), + Instant.parse("2026-07-29T08:30:00.123456789Z"), source.sourceRegion(), source.targetRegion(), + source.tenantId(), source.aggregateType(), source.aggregateId(), source.aggregateVersion(), + source.handoverVersion(), source.traceId(), source.correlationId(), source.causationId(), + source.payload(), source.payloadHash()); + EventEnvelope envelope = withHash(precise); + BridgeInbox existing = existing(envelope, envelope.payloadHash()); + existing.setOccurredAt(Instant.parse("2026-07-29T08:30:00.123Z")); + when(store.findByEventId(envelope.eventId())).thenReturn(existing); + + assertThat(service.receive(envelope).result()).isEqualTo("重复"); + } + @Test void 相同事件但摘要不同时拒绝覆盖() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java new file mode 100644 index 000000000..9a31b6db0 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java @@ -0,0 +1,44 @@ +package com.baiyue.eventbridge.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class HttpSecurityProjectionEventClientTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void onlyMatchingLastlegAcknowledgementCompletesBridgeDelivery() throws Exception { + var acknowledged = objectMapper.readTree(""" + {"code":"200","data":{"eventId":"event-1","result":"APPLIED"}} + """); + var businessFailure = objectMapper.readTree(""" + {"code":"A0409","message":"payloadHash 冲突","data":{"eventId":"event-1"}} + """); + var wrongEvent = objectMapper.readTree(""" + {"code":"200","data":{"eventId":"event-2","result":"DUPLICATE"}} + """); + + assertThatCode(() -> HttpSecurityProjectionEventClient.requireAcknowledgement( + 200, acknowledged, "event-1")).doesNotThrowAnyException(); + assertThatThrownBy(() -> HttpSecurityProjectionEventClient.requireAcknowledgement( + 200, businessFailure, "event-1")).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> HttpSecurityProjectionEventClient.requireAcknowledgement( + 200, wrongEvent, "event-1")).isInstanceOf(IllegalStateException.class); + } + + @Test + void defaultConsumerUrlTargetsLastlegSecurityEndpoint() throws Exception { + String application = Files.readString(Path.of("src/main/resources/application.yml")); + + assertThat(application).contains( + "LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/security/v1/events") + .doesNotContain("/internal/overseas-security/v1/events"); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java index aa9f11d14..5224a11e5 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java @@ -7,11 +7,13 @@ import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; import com.baiyue.eventbridge.metrics.EventBridgeMetrics; +import com.baiyue.common.web.CorrelationContext; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.test.util.ReflectionTestUtils; +import org.slf4j.MDC; import java.net.URI; import java.time.Duration; @@ -63,7 +65,12 @@ class OverseasEventDispatcherTest { @Test void successfulDeliveryIsConfirmedAndAudited() throws Exception { - when(eventClient.deliver(event)).thenReturn("200"); + event.setTraceId("550e8400-e29b-41d4-a716-446655440000"); + when(eventClient.deliver(event)).thenAnswer(invocation -> { + assertThat(MDC.get(CorrelationContext.TRACE_ID)) + .isEqualTo("550e8400e29b41d4a716446655440000"); + return "200"; + }); when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); dispatcher.dispatchOne("event-1"); @@ -74,6 +81,7 @@ class OverseasEventDispatcherTest { assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); assertThat(transportCount("DELIVERED")).isEqualTo(1.0); assertThat(registry.get(EventBridgeMetrics.DELIVERY_LATENCY_METRIC).timer().count()).isEqualTo(1); + assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull(); } @Test diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java new file mode 100644 index 000000000..1dbf17567 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java @@ -0,0 +1,65 @@ +package com.baiyue.eventbridge.trace; + +import com.baiyue.common.web.CorrelationContext; +import com.baiyue.eventbridge.domain.BridgeInbox; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +import java.net.URI; +import java.net.http.HttpRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventBridgeTracePropagationTest { + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void envelopeUuidFlowsThroughMdcAndRawHttpAsAChildSpan() { + BridgeInbox event = new BridgeInbox(); + event.setTraceId("550e8400-e29b-41d4-a716-446655440000"); + event.setCorrelationId("ORDER:customer-99:booking-123"); + event.setEventId("event-business-id"); + + try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) { + String localSpan = MDC.get(CorrelationContext.SPAN_ID); + HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create("http://127.0.0.1/internal")) + .header(CorrelationContext.REQUEST_ID_HEADER, event.getEventId()) + .header(CorrelationContext.TRACE_ID_HEADER, event.getCorrelationId()) + .header(CorrelationContext.TRACE_PARENT_HEADER, + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00"); + HttpRequest request = new EventBridgeHttpTracePropagation().apply(builder).GET().build(); + + assertThat(MDC.get(CorrelationContext.TRACE_ID)) + .isEqualTo("550e8400e29b41d4a716446655440000"); + assertThat(request.headers().firstValue(CorrelationContext.TRACE_ID_HEADER)) + .contains("550e8400e29b41d4a716446655440000"); + assertThat(request.headers().firstValue(CorrelationContext.REQUEST_ID_HEADER).orElseThrow()) + .matches("[0-9a-f-]{36}").isNotEqualTo(event.getEventId()); + assertThat(request.headers().firstValue(CorrelationContext.TRACE_PARENT_HEADER).orElseThrow()) + .matches("00-550e8400e29b41d4a716446655440000-[0-9a-f]{16}-01") + .doesNotContain(localSpan); + } + + assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull(); + } + + @Test + void invalidLegacyEnvelopeTraceIsSafelyRebuiltAndPreviousMdcIsRestored() { + MDC.put("existing", "preserved"); + BridgeInbox event = new BridgeInbox(); + event.setTraceId("JWT-or-business-id-value"); + + try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) { + assertThat(MDC.get(CorrelationContext.TRACE_ID)).matches("[0-9a-f]{32}") + .isNotEqualTo(event.getTraceId()); + } + + assertThat(MDC.get("existing")).isEqualTo("preserved"); + assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull(); + } +} diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformProductImageController.java b/file/src/main/java/com/baiyue/file/controller/PlatformProductImageController.java new file mode 100644 index 000000000..4ffcf47d0 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/PlatformProductImageController.java @@ -0,0 +1,55 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.service.ProductImageService; +import jakarta.annotation.Resource; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; + +/** 鐢ㄤ簬骞冲彴绔鍗曡鎯呮煡鐪嬪晢鎴峰晢鍝佸浘鐗囥€? */ +@RestController +@RequestMapping("/api/platform/v1/files/product-images") +public class PlatformProductImageController { + @Resource private ProductImageService images; + + @GetMapping("/{tenantId}/{fileId}") + @PreAuthorize("hasAuthority('platform:order:list')") + public ResponseEntity download( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long tenantId, + @PathVariable long fileId) { + requirePlatform(user); + if (tenantId <= 0 || fileId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "瀹㈡埛鍜屾枃浠禝D蹇呴』澶т簬0"); + } + var image = images.download(tenantId, fileId); + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .contentType(MediaType.parseMediaType(image.contentType())) + .contentLength(image.content().length) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline() + .filename(image.fileName(), StandardCharsets.UTF_8).build().toString()) + .body(image.content()); + } + + private static void requirePlatform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode()) + || user.tenantId() == null || user.tenantId() != 0L + || user.userId() == null || user.userId() <= 0) { + throw new BizException(ResultCode.FORBIDDEN, "闇€瑕佸钩鍙扮韬唤"); + } + } +} diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index 6ee3f5737..2ec986c99 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -20,7 +20,7 @@ public class InternalBusinessFileService { "CUSTOMS_RECEIPT", "FINANCE_VOUCHER", "CONTAINER_CLEARANCE_DOCUMENT", - "LOADING_CUSTOMS_DOCUMENT", + "LOADING_CUSTOMS_DECLARATION", "CONTAINER_DELIVERY_ORDER", "CONTAINER_PICKUP_ORDER", "OVERSEAS_UNLOAD_PHOTO", diff --git a/file/src/test/java/com/baiyue/file/client/ExportProviderFeignConfigurationTest.java b/file/src/test/java/com/baiyue/file/client/ExportProviderFeignConfigurationTest.java index 11fbd6567..964b44893 100644 --- a/file/src/test/java/com/baiyue/file/client/ExportProviderFeignConfigurationTest.java +++ b/file/src/test/java/com/baiyue/file/client/ExportProviderFeignConfigurationTest.java @@ -9,9 +9,14 @@ import org.springframework.boot.autoconfigure.http.HttpMessageConverters; import java.nio.charset.StandardCharsets; import java.util.Map; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; class ExportProviderFeignConfigurationTest { + @Test void keepsOpenFeignFormEncoderOnTheRuntimeClasspath() { + assertDoesNotThrow(() -> Class.forName("feign.form.spring.SpringFormEncoder")); + } + @Test void rejectsAnOversizedProviderResponseBeforeJsonDeserialization() { ExportProviderFeignConfiguration configuration=new ExportProviderFeignConfiguration(); Decoder decoder=configuration.exportProviderDecoder(HttpMessageConverters::new); diff --git a/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java b/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java index 96c7bb064..04819c77e 100644 --- a/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java +++ b/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java @@ -35,14 +35,14 @@ class InternalBusinessFileControllerTest { } @Test void zeroTenantDownloadReturnsOwnedContentWithoutCaching() { - when(service.download(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L)) + when(service.download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L)) .thenReturn(new InternalBusinessFileService.Download("declaration.pdf", "application/pdf", new byte[]{1, 2})); - var response = controller.download("internal-token", 0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L); + var response = controller.download("internal-token", 0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L); assertThat(response.getBody()).containsExactly(1, 2); assertThat(response.getHeaders().getCacheControl()).contains("no-store"); - verify(service).download(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L); + verify(service).download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L); } @Test void rejectsInvalidTokenAndNegativeTenantBeforeServiceAccess() { diff --git a/file/src/test/java/com/baiyue/file/controller/ProductImageControllerContractTest.java b/file/src/test/java/com/baiyue/file/controller/ProductImageControllerContractTest.java index d9828b371..eaff89224 100644 --- a/file/src/test/java/com/baiyue/file/controller/ProductImageControllerContractTest.java +++ b/file/src/test/java/com/baiyue/file/controller/ProductImageControllerContractTest.java @@ -77,6 +77,26 @@ class ProductImageControllerContractTest { assertThatThrownBy(() -> controller.upload(platform, file)).isInstanceOf(BizException.class); } + @Test + void platformDownloadUsesTenantScopeAndOrderListPermission() throws Exception { + RequestMapping mapping = PlatformProductImageController.class.getAnnotation(RequestMapping.class); + assertThat(mapping.value()).contains("/api/platform/v1/files/product-images"); + var download = PlatformProductImageController.class.getDeclaredMethod( + "download", AuthenticatedUser.class, long.class, long.class); + assertThat(download.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:order:list')"); + + ProductImageService service = mock(ProductImageService.class); + PlatformProductImageController controller = new PlatformProductImageController(); + ReflectionTestUtils.setField(controller, "images", service); + when(service.download(10001L, 31L)).thenReturn( + new ProductImageService.Download("product.png", "image/png", new byte[] {1})); + + var platform = new AuthenticatedUser(1L, 0L, 0L, 1L, "admin", "PLATFORM", "WEB", Set.of()); + assertThat(controller.download(platform, 10001L, 31L).getBody()).containsExactly(1); + verify(service).download(10001L, 31L); + } + private static AuthenticatedUser merchant(long tenantId) { return new AuthenticatedUser(2L, tenantId, tenantId, 2L, "merchant", "MERCHANT", "WEB", Set.of()); } diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index f0cc94a06..b5a5100e1 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -47,7 +47,7 @@ class InternalBusinessFileServiceTest { }); var clearance = service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file); - var customs = service.upload(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 7L, file); + var customs = service.upload(0L, "LOADING_CUSTOMS_DECLARATION", "51", 7L, file); var deliveryOrder = service.upload(0L, "CONTAINER_DELIVERY_ORDER", "41", 7L, file); var pickupOrder = service.upload(0L, "CONTAINER_PICKUP_ORDER", "41", 7L, file); var unloadPhoto = service.upload(11L, "OVERSEAS_UNLOAD_PHOTO", "51", 7L, file); @@ -57,7 +57,7 @@ class InternalBusinessFileServiceTest { InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41"); assertThat(customs).extracting(InternalBusinessFileService.Metadata::tenantId, InternalBusinessFileService.Metadata::businessType, - InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "LOADING_CUSTOMS_DOCUMENT", "51"); + InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "LOADING_CUSTOMS_DECLARATION", "51"); assertThat(deliveryOrder.businessType()).isEqualTo("CONTAINER_DELIVERY_ORDER"); assertThat(pickupOrder.businessType()).isEqualTo("CONTAINER_PICKUP_ORDER"); assertThat(unloadPhoto).extracting(InternalBusinessFileService.Metadata::tenantId, @@ -119,22 +119,23 @@ class InternalBusinessFileServiceTest { @Test void platformMetadataAndDownloadRequireExactCompositeOwnership() { FileObjectEntity value = new FileObjectEntity(); value.setId(91L); value.setTenantId(0L); - value.setBusinessType("LOADING_CUSTOMS_DOCUMENT"); value.setBusinessId("51"); + value.setBusinessType("LOADING_CUSTOMS_DECLARATION"); value.setBusinessId("51"); value.setOriginalName("declaration.pdf"); value.setObjectKey("business-files/0/51/declaration.pdf"); value.setContentType("application/pdf"); value.setSha256("c".repeat(64)); value.setSizeBytes(8L); when(files.selectOne(any())).thenReturn(value, value, null, null, null); when(storage.read(value.getObjectKey())).thenReturn("document".getBytes()); - assertThat(service.metadata(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L).fileName()) + assertThat(service.metadata(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L).fileName()) .isEqualTo("declaration.pdf"); - assertThat(service.download(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L).content()) + assertThat(service.download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L).content()) .isEqualTo("document".getBytes()); assertThatThrownBy(() -> service.metadata(0L, "CONTAINER_CLEARANCE_DOCUMENT", "51", 91L)) .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); - assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DOCUMENT", "52", 91L)) + assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DECLARATION", "52", 91L)) .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); - assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 92L)) + assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DECLARATION", "51", 92L)) .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); verify(storage).read(value.getObjectKey()); } + } diff --git a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java index cb2a5669e..7311d5375 100644 --- a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java @@ -68,7 +68,9 @@ public class ReconciliationStatementService { CustomerProfile customer = customer(request.customerId()); long subjectId = activeSubject(); List sources = eligibleSources(request); - if (sources.isEmpty()) throw error("FIN_409", "没有符合结算条件的CNY应收费用来源"); + if (sources.isEmpty()) { + throw error("FIN_409", "当前客户在所选账期内没有可生成的CNY应收费用,请调整账期或选择其他客户后重试"); + } BigDecimal total = sources.stream().map(Source::amount).reduce(BigDecimal.ZERO, BigDecimal::add); LocalDate dueDate = request.periodEnd().plusDays(customer.creditDays()); String statementNo = documentNumbers.next("REC"); diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index a9afeb4a0..36b45e079 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -10,6 +10,7 @@ | 目标 | 入口 | |---|---| +| 内部模块化基线与依赖分析 | `docs/firstleg-internal-modularization-baseline.md`、`scripts/analyze-firstleg-dependencies.mjs`、`scripts/analyze-firstleg-dependencies.test.mjs`、`src/test/resources/architecture/firstleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | | 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` | | 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` | | 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`;手工单箱测量也从该 Controller 与 `CartonMeasurementService` 进入 | @@ -22,6 +23,7 @@ | 客户费用查询 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java` | | 客户运营统计/在途柜量 | `customerstatistics/controller/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/service/CustomerTransitStatisticsService.java`;契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` | | 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` | +| 价格簿查询内部切片 | `pricebookquery/application/port/in/PriceBookQueryUseCase.java`、`application/PriceBookQueryService.java`、`application/port/out/PriceBookQueryReader.java`、`adapter/out/persistence/PriceBookQueryMapper.java`;查询行模型位于 `application/model/PriceBookQueryRows.java` | | 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 | | 柜次费用明细 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` | | 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` | @@ -30,7 +32,7 @@ | PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` | | 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType=FBA|OVERSEAS`,OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | -| 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | +| 三期海运订舱、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;关联候选入口 `/api/platform/v1/ocean-bookings/link-candidates`,订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | | 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` | | 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | @@ -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 消费者。 @@ -52,6 +56,7 @@ - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 - 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 +- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 - 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。 - 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。 - Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 diff --git a/firstleg/docs/firstleg-internal-modularization-baseline.md b/firstleg/docs/firstleg-internal-modularization-baseline.md index 7dbecb0d3..df1b0fc9e 100644 --- a/firstleg/docs/firstleg-internal-modularization-baseline.md +++ b/firstleg/docs/firstleg-internal-modularization-baseline.md @@ -1,4 +1,4 @@ -# Firstleg 内部模块化基线与首切片方案 +# Firstleg 内部模块化基线与切片实施记录 ## 1. 范围与生成方式 @@ -198,3 +198,53 @@ mvn -pl firstleg -am -Dtest=CustomerTransitStatisticsServiceTest,CustomerTransit - 根 Mapper 扫描只把旧精确包替换为新精确包,没有扩大扫描范围或增加兼容 Bean。 - 验证结果:Node 合同 3/3;`test-compile` 成功;客户统计加根扫描定向测试 7/7;`mvn -pl firstleg -am test` 共 854 个测试,0 失败、0 错误、3 跳过,构建成功。 - 首次并发执行 `test-compile` 时,其他专项同时写入共享 `target/classes` 导致若干无关 class 文件瞬时缺失;源符号核对正常,独立重跑 `test-compile` 与完整测试均成功。 + +## 12. 第二切片:价格簿只读查询 + +### 12.1 选择证据 + +第二切片选择 `pricebook-query`,而没有选择报关、拖车、出仓或船节点: + +- 原实现只有 `PriceBookQueryRows`、`PriceBookQueryRepository`、`PriceBookQueryMapper` 三个核心文件,全部为只读查询,不承载 Controller、权限或状态机。 +- `PriceBookQueryMapper -> repository.PriceBookQueryRows` 是主源码唯一 `mapper -> repository` 反向依赖,也是物理包 SCC `mapper <-> repository` 的唯一成因。 +- 报关和拖车均涉及 MultipartFile、Feign、装柜或出仓门禁;出仓和船节点属于当前业务热点,风险高于只读价格簿查询。 +- 本切片不触碰 `LoadingPickService`、客户尺寸初始化、IAM 解环、POM、CI、AGENTS、SQL 或部署配置。 + +### 12.2 目标结构与依赖方向 + +```text +pricebookquery/ + application/ + PriceBookQueryService.java + model/PriceBookQueryRows.java + port/in/PriceBookQueryUseCase.java + port/out/PriceBookQueryReader.java + adapter/out/persistence/ + PriceBookQueryMapper.java +``` + +依赖由“多个业务 Service -> 全局 Repository -> 全局 Mapper -> Repository Rows”改为: + +```text +业务消费者 -> PriceBookQueryUseCase +PriceBookQueryService -> PriceBookQueryReader +PriceBookQueryMapper -> PriceBookQueryReader + PriceBookQueryRows +``` + +消费者只能访问 In Port 和只读模型;application 不依赖 Spring MVC、MyBatis、MyBatis-Plus 或其他 Firstleg 切片。Mapper 的七段 `@Select` SQL 文本和方法参数保持不变。 + +### 12.3 依赖图变化 + +- 主源码由 430 类/949 条内部依赖/67 包变为 432 类/953 条内部依赖/72 包;新增项为 In Port、Out Port 和 Application Service,原 Repository 被删除。 +- 物理包多节点 SCC 从 1 个降为 0 个。 +- `pricebook-query` 是零出向边的叶子,不进入逻辑 SCC;显式入向仅为订舱、测量计价、分柜装柜三个消费者切片。 +- 原 `split-loading -> measurement-pricing` 两条价格簿引用被替换为显式 `split-loading -> pricebook-query`;Node 合同没有简单放宽旧边。 +- 既有 88 条跨切片 Entity/DTO 泄漏和 31 条 Mapper 泄漏均未增加。 + +### 12.4 棘轮与回滚边界 + +- Node 合同锁定五个目标类位置、application 依赖禁令、零出向/三类入向和消费者只能访问 In Port/模型,并断言物理包 SCC 为空。 +- `PriceBookQueryArchitectureTest` 用 ArchUnit 执行同等边界,持久化 Mapper 必须实现 Out Port。 +- 回滚仅恢复原三文件及消费者类型引用;不涉及 URI、权限、JSON、SQL、表结构、业务状态或环境配置。 +- 只读模型仍保留原 Lombok 可变 Bean 形态以维持 MyBatis 映射行为;本轮不顺带改造成不可变领域对象。 +- 验证结果:七段 `@Select` SQL 文本逐块完全一致;Node 合同 5/5;`test-compile` 成功;价格簿、ArchUnit、根扫描及三个消费者切片定向测试 78/78;Firstleg 聚合全量 878 个测试,0 失败、0 错误、3 跳过,构建成功。 diff --git a/firstleg/scripts/analyze-firstleg-dependencies.mjs b/firstleg/scripts/analyze-firstleg-dependencies.mjs index 5b252d7a8..302d466c7 100644 --- a/firstleg/scripts/analyze-firstleg-dependencies.mjs +++ b/firstleg/scripts/analyze-firstleg-dependencies.mjs @@ -13,6 +13,7 @@ const internalPrefix = 'com.baiyue.firstleg.'; const sliceRules = [ ['customer-statistics', /(?:^|\.)(?:customerstatistics)(?:\.|$)/], + ['pricebook-query', /(?:^|\.)(?:pricebookquery)(?:\.|$)/], ['finance-integration', /(?:InternalFinance|FinanceFeeSource|FinanceCostSnapshot|FeePaymentVoucher|FeePaymentSnapshot|ContainerFeeSourceSnapshot)/], ['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException)/], ['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/], @@ -273,8 +274,9 @@ function checkContract(report, contractPath) { if (!actualClass) violations.push(`required class missing: ${fqcn}`); else if (actualClass.file !== expectedFile) violations.push(`class location changed: ${fqcn} (${actualClass.file})`); } - const applicationRule = contract.applicationDependencyRule; - if (applicationRule) { + const applicationRules = contract.applicationDependencyRules + ?? (contract.applicationDependencyRule ? [contract.applicationDependencyRule] : []); + for (const applicationRule of applicationRules) { const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(applicationRule.packagePrefix)); for (const entry of applicationClasses) { for (const imported of entry.imports) { @@ -291,6 +293,15 @@ function checkContract(report, contractPath) { } } } + for (const exposureRule of contract.sliceExposureRules ?? []) { + for (const edge of report.classDependencies) { + if (!edge.source.startsWith(exposureRule.slicePrefix) + && edge.target.startsWith(exposureRule.slicePrefix) + && !(exposureRule.allowedTargetPrefixes ?? []).some(prefix => edge.target.startsWith(prefix))) { + violations.push(`forbidden slice implementation dependency: ${edge.source} -> ${edge.target}`); + } + } + } for (const [slice, rule] of Object.entries(contract.sliceSpecificRules ?? {})) { const outgoing = report.sliceEdges.filter(edge => edge.source === slice).map(edge => edge.target).sort(); const incoming = report.sliceEdges.filter(edge => edge.target === slice).map(edge => edge.source).sort(); diff --git a/firstleg/scripts/analyze-firstleg-dependencies.test.mjs b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs index 9bcb61d64..101c2f807 100644 --- a/firstleg/scripts/analyze-firstleg-dependencies.test.mjs +++ b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs @@ -52,3 +52,43 @@ test('customer statistics application layer only depends inward', () => { && edge.target.startsWith('com.baiyue.firstleg.') && !edge.target.startsWith(prefix)); assert.deepEqual(outwardDependencies, []); }); + +test('price book query is a leaf slice and removes the package cycle', () => { + const report = analyze(); + const classes = report.classes.filter(entry => entry.slice === 'pricebook-query'); + assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [ + 'com.baiyue.firstleg.pricebookquery.adapter.out.persistence.PriceBookQueryMapper', + 'com.baiyue.firstleg.pricebookquery.application.PriceBookQueryService', + 'com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows', + 'com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase', + 'com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader', + ]); + assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'pricebook-query'), []); + assert.deepEqual(report.sliceEdges.filter(edge => edge.target === 'pricebook-query') + .map(edge => edge.source).sort(), ['booking-customs-trucking', 'measurement-pricing', 'split-loading']); + assert.equal(report.sliceSccs.some(component => component.includes('pricebook-query')), false); + assert.deepEqual(report.packageSccs, []); +}); + +test('price book query application and consumers respect ports', () => { + const report = analyze(); + const prefix = 'com.baiyue.firstleg.pricebookquery.application'; + const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(prefix)); + assert.equal(applicationClasses.length, 4); + for (const entry of applicationClasses) { + assert.equal(entry.imports.some(imported => imported.startsWith('org.springframework.web')), false); + assert.equal(entry.imports.some(imported => imported.startsWith('org.apache.ibatis')), false); + assert.equal(entry.imports.some(imported => imported.startsWith('com.baomidou.mybatisplus')), false); + } + const outwardDependencies = report.classDependencies.filter(edge => edge.source.startsWith(prefix) + && edge.target.startsWith('com.baiyue.firstleg.') && !edge.target.startsWith(prefix)); + assert.deepEqual(outwardDependencies, []); + const allowedTargets = [ + 'com.baiyue.firstleg.pricebookquery.application.model.', + 'com.baiyue.firstleg.pricebookquery.application.port.in.', + ]; + const implementationLeaks = report.classDependencies.filter(edge => !edge.source.startsWith('com.baiyue.firstleg.pricebookquery.') + && edge.target.startsWith('com.baiyue.firstleg.pricebookquery.') + && !allowedTargets.some(prefixValue => edge.target.startsWith(prefixValue))); + assert.deepEqual(implementationLeaks, []); +}); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java index 8c071e0be..595ec1411 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java @@ -18,7 +18,8 @@ import org.springframework.scheduling.annotation.EnableScheduling; "com.baiyue.firstleg.outbound.dispatch.mapper", "com.baiyue.firstleg.portschedule.mapper", "com.baiyue.firstleg.customs.mapper", - "com.baiyue.firstleg.customerstatistics.adapter.out.persistence" + "com.baiyue.firstleg.customerstatistics.adapter.out.persistence", + "com.baiyue.firstleg.pricebookquery.adapter.out.persistence" }) @EnableFeignClients(basePackages = "com.baiyue.firstleg") @EnableScheduling diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java index 0decb1063..cfbb44d48 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java @@ -128,6 +128,21 @@ public class PlatformContainerController { return ApiResponse.success(loadingLists.containerClearance(id)); } + @GetMapping("/{id}/clearance/document/{fileId}/download") + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @Operation(summary = "下载货柜目的港清关附件", description = "校验当前清关附件和 File 业务归属,完成态仍可下载。") + public ResponseEntity downloadClearance( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, + @PathVariable @Positive long fileId) { + FirstlegScopeGuard.platform(user); + var download = loadingLists.downloadContainerClearance(id, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment() + .filename(download.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(download.contentType()).contentLength(download.content().length) + .body(download.content()); + } + @PostMapping("/{id}/clearance/document") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @Operation(summary = "上传或替换货柜目的港清关资料") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java index 153ae3d48..803be7cda 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -25,6 +25,10 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; import org.springframework.http.MediaType; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; @@ -40,6 +44,8 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.multipart.MultipartFile; +import java.nio.charset.StandardCharsets; + /** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */ @Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询") @Validated @@ -124,6 +130,21 @@ public class PlatformLoadingListController { return ApiResponse.success(service.loadingDeclaration(planId, warehouseId)); } + @Operation(summary = "下载装柜清单出口报关附件", description = "完成态仍可下载;服务端校验装柜计划、仓库、当前附件及 File 业务归属。") + @GetMapping("/loading-lists/{planId}/customs/document/{fileId}/download") + @PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')") + public ResponseEntity downloadDeclaration( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, + @PathVariable @Positive long fileId, @RequestParam @Positive long warehouseId) { + FirstlegScopeGuard.platform(user); + var download = service.downloadLoadingDeclaration(planId, warehouseId, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(download.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(download.contentType()).contentLength(download.content().length) + .body(download.content()); + } + @Operation(summary = "上传或替换装柜清单出口报关文档") @PostMapping("/loading-lists/{planId}/customs/document") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java index 8f12886f8..68a968aa4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java @@ -2,4 +2,10 @@ package com.baiyue.firstleg.customs.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; import org.apache.ibatis.annotations.Mapper; -@Mapper public interface CustomsCaseMapper extends BaseMapper {} +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +@Mapper public interface CustomsCaseMapper extends BaseMapper { + @Select("SELECT COUNT(*) FROM fl_container_document WHERE container_booking_id=#{containerBookingId} " + + "AND document_type='CUSTOMS_DECLARATION' AND completed_at IS NOT NULL") + int completedLoadingDeclaration(@Param("containerBookingId") long containerBookingId); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java index f33063e95..3d8a35e42 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java @@ -81,6 +81,9 @@ public class CustomsRepository { .eq(CustomsCaseEntity::getContainerBookingId, containerBookingId) .orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1")); } + public boolean loadingDeclarationCompleted(long containerBookingId) { + return cases.completedLoadingDeclaration(containerBookingId) == 1; + } public boolean containerCarriesOrder(long containerId, long tenantId, long orderId) { LoadingPlanEntity plan = loadingPlans.selectOne(new LambdaQueryWrapper() .eq(LoadingPlanEntity::getContainerBookingId, containerId)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java index 94eb4cc89..61a048f5a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java @@ -1,6 +1,5 @@ package com.baiyue.firstleg.customs.service; -import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; import com.baiyue.firstleg.customs.repository.CustomsRepository; import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor; @@ -18,13 +17,11 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr if (context.tenantId() <= 0 || context.customerId() <= 0) { return gate(false, "INVALID_CUSTOMER_SCOPE"); } - CustomsCaseEntity value = repository.gateCase(context.tenantId(), context.orderId(), - context.inboundId(), context.containerBookingId()); - if (value == null) return gate(false, "MISSING"); - return gate("CLEARANCE_COMPLETED".equals(value.getStatus()), value.getStatus()); + boolean completed = repository.loadingDeclarationCompleted(context.containerBookingId()); + return gate(completed, completed ? "COMPLETED" : "MISSING"); } private static GateItem gate(boolean passed, String actual) { - return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "CLEARANCE_COMPLETED"); + return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "COMPLETED"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java index 260ac1924..6f899c510 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java @@ -41,9 +41,15 @@ public final class InboundMeasurementRequests { @NotBlank @Size(max = 32) String itemNo, @NotBlank @Size(max = 64) String boxSpecification, @NotNull @Positive @Max(1000000) Integer packageCount, - @NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal lengthCm, - @NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal widthCm, - @NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal heightCm, + @NotNull @DecimalMin(value = "0.001", message = "箱规长度必须大于等于 0.001 cm") + @DecimalMax(value = "999999999.999", message = "箱规长度不能超过 999,999,999.999 cm") + @Digits(integer = 9, fraction = 3, message = "箱规长度最多 9 位整数、3 位小数") BigDecimal lengthCm, + @NotNull @DecimalMin(value = "0.001", message = "箱规宽度必须大于等于 0.001 cm") + @DecimalMax(value = "999999999.999", message = "箱规宽度不能超过 999,999,999.999 cm") + @Digits(integer = 9, fraction = 3, message = "箱规宽度最多 9 位整数、3 位小数") BigDecimal widthCm, + @NotNull @DecimalMin(value = "0.001", message = "箱规高度必须大于等于 0.001 cm") + @DecimalMax(value = "999999999.999", message = "箱规高度不能超过 999,999,999.999 cm") + @Digits(integer = 9, fraction = 3, message = "箱规高度最多 9 位整数、3 位小数") BigDecimal heightCm, @Positive Long intelligentImageFileId, @Size(max = 500) String overLimitWarning, @DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerLengthCm, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index f5e905ab8..62599bc9f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -171,8 +171,6 @@ public interface ContainerBookingMapper extends BaseMapper#{customerId}") int hasMismatchedCustomerItems(@Param("id") long id, @Param("customerId") long customerId); - @Update(""" UPDATE fl_container_booking SET container_no=#{containerNo}, voyage_no=#{voyageNo}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java index 2614509e7..f19381c0c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java @@ -4,7 +4,7 @@ import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity; import org.apache.ibatis.annotations.*; import java.util.List; @Mapper public interface ContainerOverviewMapper { - @Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List documents(@Param("id") long id); + @Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List documents(@Param("id") long id); @Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{id} ORDER BY created_at,id") List transport(@Param("id") long id); @Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY occurred_at,id") List exceptions(@Param("id") long id); @Insert("INSERT INTO fl_container_exception_history(id,container_booking_id,action,reason,operator_id,operator_name,operation_id,request_hash,occurred_at) VALUES(#{id},#{bookingId},#{action},#{reason},#{operatorId},#{operatorName},#{operationId},#{requestHash},CURRENT_TIMESTAMP(3))") int insertException(@Param("id") long id,@Param("bookingId") long bookingId,@Param("action") String action,@Param("reason") String reason,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName,@Param("operationId") String operationId,@Param("requestHash") String requestHash); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index a5032b3d4..691cd03d3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -29,6 +29,14 @@ public interface LoadingCompletedEventMapper { FROM fl_loading_plan p JOIN fl_container_booking b ON b.id = p.container_booking_id JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 + JOIN fl_loading_pick_item actual_pick + ON actual_pick.loading_plan_item_id = i.id + AND actual_pick.item_status IN ('PICKED','PARTIAL') + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count = i.package_count + JOIN fl_loading_pick_task actual_task + ON actual_task.id = actual_pick.pick_task_id + AND actual_task.loading_plan_id = p.id JOIN fl_inbound_order o ON o.id = i.inbound_id AND o.tenant_id = i.account_id AND o.deleted = 0 LEFT JOIN fl_inbound_order root_order ON root_order.id = COALESCE(o.root_inbound_id, o.id) WHERE p.id = #{planId} AND p.plan_status = 'COMPLETED' @@ -46,12 +54,20 @@ public interface LoadingCompletedEventMapper { i.product_name_zh, i.package_count, i.product_quantity, label.box_code FROM fl_loading_plan_item i + JOIN fl_loading_pick_item actual_pick + ON actual_pick.loading_plan_item_id = i.id + AND actual_pick.item_status IN ('PICKED','PARTIAL') + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count = i.package_count + JOIN fl_loading_pick_task actual_task + ON actual_task.id = actual_pick.pick_task_id + AND actual_task.loading_plan_id = #{planId} JOIN fl_inbound_pallet_allocation a ON a.tenant_id = i.account_id AND a.inbound_id = i.inbound_id AND a.allocation_status IN ('CONFIRMED','TRANSFERRED','RELEASED') AND a.picked_package_count > 0 LEFT JOIN fl_inbound_carton_label label - ON label.tenant_id = i.account_id AND label.inbound_id = i.inbound_id + ON label.tenant_id = i.account_id AND label.inbound_detail_id = i.inbound_detail_id AND label.batch_status = 'ACTIVE' WHERE i.loading_plan_id = #{planId} AND i.deleted = 0 ORDER BY i.account_id, i.inbound_id, a.allocation_no, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java index a6ed9d70d..486fb960e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java @@ -27,6 +27,11 @@ public class PlatformOceanBookingController { @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query)); } + @GetMapping("/link-candidates") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") + public ApiResponse> linkCandidates( + @AuthenticationPrincipal AuthenticatedUser user, @Valid LinkCandidateQuery query) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.linkCandidates(query)); + } @GetMapping("/channels") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") public ApiResponse> channels(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long originWarehouseId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java index 044d6b76f..6d049a277 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java @@ -26,6 +26,22 @@ public final class OceanBookingDtos { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } } + public record LinkCandidateQuery(@NotNull @Positive Long warehouseId, + @NotBlank @Size(max = 64) String channelCode, + @NotBlank @Size(max = 32) String containerType, + @Min(1) Long page, + @Min(1) @Max(200) Long size) { + public LinkCandidateQuery { + if (page == null) page = 1L; + if (size == null) size = 20L; + } + } + + public record LinkCandidate(Long oceanBookingId, String bookingNo, Long requirementId, + String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg, + int requiredQuantity, long allocatedQuantity, long remainingQuantity, + String customerName, String warehouseName, String channelName) {} + public record PartyRequest(@NotBlank @Size(max = 24) String partyType, @NotBlank @Size(max = 255) String partyName, @Size(max = 1000) String address, @@ -44,6 +60,7 @@ public final class OceanBookingDtos { @Size(max = 128) String billOfLadingNo, @Positive Long shippingCompanyId, @Size(max = 128) String shippingCompanyName, + @Size(max = 16) String carrierCode, @Positive Long bookingAgentId, @Size(max = 128) String bookingAgentName, @Size(max = 128) String vesselName, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java index f7cc765f5..4e79948fd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java @@ -27,6 +27,7 @@ public class OceanBookingEntity { private String billOfLadingNo; private Long shippingCompanyId; private String shippingCompanyName; + private String carrierCode; private Long bookingAgentId; private String bookingAgentName; private String vesselName; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java index cca20bf30..77b31e191 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java @@ -3,8 +3,21 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; @Mapper public interface OceanBookingContainerAllocationMapper extends BaseMapper { @Update("UPDATE fl_ocean_booking_container_allocation SET container_booking_id=#{targetId} WHERE container_booking_id=#{sourceId}") int reassign(@Param("sourceId") long sourceId, @Param("targetId") long targetId); + + @Select(""" + SELECT COUNT(*) FROM fl_ocean_booking_container_allocation allocation + JOIN fl_container_booking container ON container.id=allocation.container_booking_id + AND container.record_status='ACTIVE' + AND container.ocean_booking_id=allocation.ocean_booking_id + AND container.ocean_booking_requirement_id=allocation.requirement_id + WHERE allocation.ocean_booking_id=#{oceanBookingId} + AND allocation.requirement_id=#{requirementId} + """) + long countActiveLinked(@Param("oceanBookingId") long oceanBookingId, + @Param("requirementId") long requirementId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java index d806df7a9..a8aa3e557 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java @@ -3,6 +3,8 @@ package com.baiyue.firstleg.oceanbooking.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidate; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Delete; @@ -13,7 +15,7 @@ import org.apache.ibatis.annotations.Update; @Mapper public interface OceanBookingMapper extends BaseMapper { - @Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},service_term=#{value.serviceTerm},freight_term=#{value.freightTerm},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}") + @Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},service_term=#{value.serviceTerm},freight_term=#{value.freightTerm},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}") int updateDraft(@Param("id") long id, @Param("value") OceanBookingEntity value, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); @Delete("DELETE FROM fl_ocean_booking WHERE id=#{id} AND status='DRAFT' AND version=#{version}") int deleteDraft(@Param("id") long id, @Param("version") int version); @@ -24,9 +26,9 @@ public interface OceanBookingMapper extends BaseMapper { WHERE party.ocean_booking_id=booking.id AND party.party_type='SHIPPER' LIMIT 1) shipper_name, (SELECT party.party_name FROM fl_ocean_booking_party party WHERE party.ocean_booking_id=booking.id AND party.party_type='CONSIGNEE' LIMIT 1) consignee_name, - (SELECT COUNT(*) FROM fl_ocean_booking_container_allocation allocation - JOIN fl_container_booking container ON container.id=allocation.container_booking_id - WHERE allocation.ocean_booking_id=booking.id AND container.record_status='ACTIVE') container_count + COALESCE((SELECT SUM(requirement.quantity) + FROM fl_ocean_booking_container_requirement requirement + WHERE requirement.ocean_booking_id=booking.id),0) container_count FROM fl_ocean_booking booking WHERE (#{query.customerId} IS NULL OR booking.customer_id=#{query.customerId}) AND booking.status IN ('DRAFT','RELEASED') @@ -41,6 +43,36 @@ public interface OceanBookingMapper extends BaseMapper { """) Page selectPage(Page page, @Param("query") Query query); + @Select(""" + SELECT booking.id ocean_booking_id,booking.booking_no,requirement.id requirement_id, + requirement.container_type,requirement.capacity_cbm,requirement.max_weight_kg, + requirement.quantity required_quantity, + COALESCE(allocated.allocated_quantity,0) allocated_quantity, + requirement.quantity-COALESCE(allocated.allocated_quantity,0) remaining_quantity, + booking.customer_name,booking.warehouse_name,booking.channel_name + FROM fl_ocean_booking booking + JOIN fl_ocean_booking_container_requirement requirement + ON requirement.ocean_booking_id=booking.id + LEFT JOIN ( + SELECT allocation.ocean_booking_id,allocation.requirement_id,COUNT(*) allocated_quantity + FROM fl_ocean_booking_container_allocation allocation + JOIN fl_container_booking container ON container.id=allocation.container_booking_id + AND container.record_status='ACTIVE' + AND container.ocean_booking_id=allocation.ocean_booking_id + AND container.ocean_booking_requirement_id=allocation.requirement_id + GROUP BY allocation.ocean_booking_id,allocation.requirement_id + ) allocated ON allocated.ocean_booking_id=booking.id + AND allocated.requirement_id=requirement.id + WHERE booking.status='RELEASED' + AND booking.warehouse_id=#{query.warehouseId} + AND booking.channel_code=#{query.channelCode} + AND requirement.container_type=#{query.containerType} + AND requirement.quantity>COALESCE(allocated.allocated_quantity,0) + ORDER BY booking.updated_at DESC,booking.id DESC,requirement.sort_no ASC,requirement.id ASC + """) + Page selectLinkCandidates(Page page, + @Param("query") LinkCandidateQuery query); + @Update(""" UPDATE fl_ocean_booking SET status=#{toStatus}, updated_by=#{operatorId},updated_by_name=#{operatorName}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java index 1f5f9b27c..ca7f77216 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java @@ -5,6 +5,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.RealContainer; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidate; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery; import com.baiyue.firstleg.oceanbooking.entity.*; import com.baiyue.firstleg.oceanbooking.mapper.*; import com.baiyue.firstleg.entity.ContainerBookingEntity; @@ -30,6 +32,9 @@ public class OceanBookingRepository { public PageResult page(Query query) { return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query)); } + public PageResult linkCandidates(LinkCandidateQuery query) { + return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query)); + } public OceanBookingEntity find(long id) { return bookings.selectById(id); } public void insert(OceanBookingEntity value) { bookings.insert(value); } public int transition(long id, String from, String to, int version, long operatorId, String operatorName) { @@ -44,6 +49,9 @@ public class OceanBookingRepository { public List parties(long id) { return parties.selectList(new LambdaQueryWrapper().eq(OceanBookingPartyEntity::getOceanBookingId, id).orderByAsc(OceanBookingPartyEntity::getId)); } public List requirements(long id) { return requirements.selectList(new LambdaQueryWrapper().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerRequirementEntity::getSortNo)); } public List allocations(long id) { return allocations.selectList(new LambdaQueryWrapper().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerAllocationEntity::getAllocatedAt)); } + public long activeLinkedCount(long oceanBookingId, long requirementId) { + return allocations.countActiveLinked(oceanBookingId, requirementId); + } public List containers(long id) { Map requirementById = requirements(id).stream() .collect(Collectors.toMap(OceanBookingContainerRequirementEntity::getId, Function.identity())); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index 5db6c6f52..624a2f2b8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -11,9 +11,9 @@ import com.baiyue.firstleg.domain.ContainerSpecificationCatalog; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*; import com.baiyue.firstleg.oceanbooking.entity.*; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; import com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator; import com.fasterxml.jackson.core.JsonProcessingException; @@ -36,10 +36,11 @@ import java.util.Set; @Service public class OceanBookingService { private static final Set PARTY_TYPES = Set.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY"); + private static final Set CARRIER_CODES = Set.of("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE", "OTHER"); private static final Set OPERATIONAL_LIST_STATUSES = Set.of( OceanBookingStatus.DRAFT, OceanBookingStatus.RELEASED); @Resource private OceanBookingRepository repository; - @Resource private PriceBookQueryRepository priceBooks; + @Resource private PriceBookQueryUseCase priceBooks; @Resource private ObjectMapper objectMapper; @Resource private WarehouseSupplierSnapshotService suppliers; @Resource private WarehouseMasterDataClient warehouses; @@ -55,6 +56,11 @@ public class OceanBookingService { return repository.page(new Query(query.customerId(), status, trim(query.keyword()), query.page(), query.size())); } + public PageResult linkCandidates(LinkCandidateQuery query) { + return repository.linkCandidates(new LinkCandidateQuery(query.warehouseId(), + query.channelCode().trim(), query.containerType().trim(), query.page(), query.size())); + } + public List channels(long originWarehouseId) { if (originWarehouseId <= 0) throw badRequest("请选择有效起运仓"); Long versionId = priceBooks.activeVersionId(); @@ -191,6 +197,10 @@ public class OceanBookingService { ContainerSpecificationCatalog.requireMatch(container.containerType(), container.capacityCbm(), container.maxWeightKg()); } } + if (StringUtils.hasText(data.carrierCode()) + && !CARRIER_CODES.contains(data.carrierCode().trim().toUpperCase())) { + throw badRequest("carrierCode仅支持MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER"); + } } private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); } private static void validateRelease(OceanBookingEntity booking, List parties, @@ -201,6 +211,7 @@ public class OceanBookingService { || !StringUtils.hasText(booking.getVoyageNo()) || !StringUtils.hasText(booking.getServiceTerm()) || !StringUtils.hasText(booking.getFreightTerm()) || booking.getEstimatedSailingAt() == null || booking.getEstimatedArrivalAt() == null || booking.getShippingCompanyId() == null + || !StringUtils.hasText(booking.getCarrierCode()) || booking.getBookingAgentId() == null || parties == null || parties.size() != 3 || requirements == null || requirements.isEmpty()) throw badRequest("订舱资料尚未填写完整,无法发布"); validateRange(booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt()); @@ -247,6 +258,7 @@ public class OceanBookingService { v.setWarehouseId(d.warehouseId()); v.setWarehouseName(resolved.warehouseName()); v.setBillOfLadingNo(trim(d.billOfLadingNo())); v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(trim(d.shippingCompanyName())); + v.setCarrierCode(d.carrierCode() == null ? null : d.carrierCode().trim().toUpperCase()); v.setBookingAgentId(d.bookingAgentId()); v.setBookingAgentName(resolved.bookingAgentName()); v.setVesselName(trim(d.vesselName())); v.setVoyageNo(trim(d.voyageNo())); v.setDeparturePortCode(trim(d.departurePortCode())); v.setDeparturePortName(trim(d.departurePortName())); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java index 36f8e7bd7..73e73b70c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java @@ -28,33 +28,39 @@ public class PlatformOutboundDispatchController { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.list(query)); } - @GetMapping("/detail") + @GetMapping("/{containerBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:view')") - public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(query)); + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @jakarta.validation.constraints.Positive long containerBookingId) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(containerBookingId)); } - @GetMapping("/check-gate") + @GetMapping("/{containerBookingId}/gate") @PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:check')") - public ApiResponse checkGate(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.checkGate(query)); + public ApiResponse checkGate(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @jakarta.validation.constraints.Positive long containerBookingId) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.checkGate(containerBookingId)); } - @PostMapping("/confirm-dispatch") + @PostMapping("/{containerBookingId}/confirm") @PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:confirm')") - @BusinessAudit(event="OUTBOUND_CONFIRM_DISPATCH",resourceType="INBOUND",resourceId="#request.inboundId",description="确认国内出仓") + @BusinessAudit(event="OUTBOUND_CONFIRM_DISPATCH",resourceType="CONTAINER_BOOKING",resourceId="#containerBookingId",description="柜级确认国内出仓") public ApiResponse confirm(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @jakarta.validation.constraints.Positive long containerBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirm(user.userId(), user.username(), key, request)); + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirm( + containerBookingId, user.userId(), user.username(), key, request)); } - @PostMapping("/reverse-dispatch") + @PostMapping("/{containerBookingId}/reverse") @PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:reverse')") - @BusinessAudit(event="OUTBOUND_REVERSE_DISPATCH",resourceType="INBOUND",resourceId="#request.inboundId",description="撤销国内出仓") + @BusinessAudit(event="OUTBOUND_REVERSE_DISPATCH",resourceType="CONTAINER_BOOKING",resourceId="#containerBookingId",description="柜级撤销国内出仓") public ApiResponse reverse(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @jakarta.validation.constraints.Positive long containerBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.reverse(user.userId(), user.username(), key, request)); + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.reverse( + containerBookingId, user.userId(), user.username(), key, request)); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java index 38c848ca6..b7c8dc1b6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.outbound.dispatch.dto; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import java.time.LocalDateTime; @@ -10,21 +11,27 @@ import java.util.List; public final class OutboundDispatchDtos { private OutboundDispatchDtos() {} - public record Query(@Positive Long orderId, @Positive Long inboundId, @Positive Long containerBookingId) {} + public record Query(@NotNull @Positive Long containerBookingId) {} - public record ActionRequest(@Positive Long inboundId, @Positive Long containerBookingId, - @PositiveOrZero int expectedVersion, @Size(max = 500) String remark) {} + public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion, + @Size(max = 500) String remark) {} public record GateItem(String code, String name, boolean passed, String actual, String requirement) {} public record GateResult(boolean passed, List items, LocalDateTime checkedAt) {} - public record Aggregate(Long orderId, String orderNo, Long inboundId, String inboundCode, Long tenantId, - Long customerId, String customerName, String inboundStatus, - Long containerBookingId, String cabinetNo, String containerNo, + public record InboundBinding(Long orderId, String orderNo, Long inboundId, String inboundCode, + Long tenantId, Long customerId, String customerName, + String inboundStatus, Long pickTaskId, String pickTaskStatus) {} + + public record InboundGate(Long inboundId, String inboundCode, GateResult gate) {} + public record ContainerGateResult(boolean passed, List inbounds, LocalDateTime checkedAt) {} + + public record Aggregate(Long containerBookingId, String cabinetNo, String containerNo, Long loadingPlanId, String loadingPlanStatus, - Long pickTaskId, String pickTaskStatus, Long oceanBookingId, String oceanBookingStatus, + Long oceanBookingId, String oceanBookingStatus, Long truckAppointmentId, String truckAppointmentStatus, - String dispatchStatus, Integer version, GateResult gate) {} + String dispatchStatus, Integer version, List inbounds, + ContainerGateResult gate) {} public record TimelineItem(String module, String action, String fromStatus, String toStatus, Long operatorId, String operatorName, String remark, LocalDateTime occurredAt) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java index 09fb7a8a7..9bbcf97e8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java @@ -14,6 +14,11 @@ public interface OutboundDispatchMapper extends BaseMapper SELECT DISTINCT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId, inbound.inbound_code inboundCode,inbound.tenant_id tenantId,inbound.customer_id customerId, inbound.customer_name customerName,inbound.process_status inboundStatus, @@ -22,45 +21,62 @@ public interface OutboundDispatchReadMapper { JOIN fl_container_booking booking ON booking.id=plan.container_booking_id JOIN fl_inbound_order inbound ON inbound.id=item.inbound_id AND inbound.tenant_id=item.account_id AND inbound.deleted=0 WHERE item.deleted=0 - AND inbound.order_id=#{orderId} - AND inbound.id=#{inboundId} - AND booking.id=#{containerBookingId} + AND booking.id=#{containerBookingId} ORDER BY inbound.id,booking.id - """) - List bindings(@Param("orderId") Long orderId, @Param("inboundId") Long inboundId, - @Param("containerBookingId") Long containerBookingId); + List bindings(@Param("containerBookingId") long containerBookingId); + + @Select(""" + SELECT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId, + inbound.inbound_code inboundCode,inbound.tenant_id tenantId,inbound.customer_id customerId, + inbound.customer_name customerName,inbound.process_status inboundStatus, + booking.id containerBookingId,booking.cabinet_no cabinetNo,booking.container_no containerNo, + plan.id loadingPlanId,plan.plan_status loadingPlanStatus + FROM fl_loading_plan_item item + JOIN fl_loading_plan plan ON plan.id=item.loading_plan_id + JOIN fl_container_booking booking ON booking.id=plan.container_booking_id + JOIN fl_inbound_order inbound ON inbound.id=item.inbound_id AND inbound.tenant_id=item.account_id AND inbound.deleted=0 + WHERE item.deleted=0 AND booking.id=#{containerBookingId} + ORDER BY inbound.id,booking.id + FOR UPDATE + """) + List bindingsForUpdate(@Param("containerBookingId") long containerBookingId); @Select("SELECT id,task_status status FROM fl_loading_pick_task WHERE loading_plan_id=#{planId} " + "ORDER BY started_at DESC,id DESC LIMIT 1") IdStatus pick(@Param("planId") long planId); @Select(""" - SELECT booking.id,booking.status + SELECT booking.id,booking.status,booking.customer_id customerId FROM fl_ocean_booking_container_allocation allocation JOIN fl_ocean_booking booking ON booking.id=allocation.ocean_booking_id - WHERE allocation.container_booking_id=#{containerBookingId} AND booking.customer_id=#{tenantId} + WHERE allocation.container_booking_id=#{containerBookingId} ORDER BY booking.updated_at DESC,booking.id DESC LIMIT 1 """) - IdStatus ocean(@Param("containerBookingId") long containerBookingId, @Param("tenantId") long tenantId); + OceanBinding ocean(@Param("containerBookingId") long containerBookingId); @Select(""" SELECT id,status FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND loading_plan_id=#{loadingPlanId} - AND customer_id=#{tenantId} ORDER BY updated_at DESC,id DESC LIMIT 1 """) - IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId, - @Param("tenantId") long tenantId); + IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId); @Select(""" - SELECT COUNT(DISTINCT CASE WHEN status='ACTIVE' AND file_id IS NOT NULL AND evidence_type IN - ('PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL') THEN evidence_type END) evidenceCount, - MAX(CASE WHEN status='ACTIVE' AND evidence_type='SEAL' AND file_id IS NOT NULL AND seal_no IS NOT NULL - AND seal_no<>'' THEN 1 ELSE 0 END) validSeal - FROM fl_loading_evidence WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} + SELECT COUNT(DISTINCT CASE WHEN evidence.status='ACTIVE' AND evidence.file_id IS NOT NULL + AND evidence.evidence_type IN ('PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL') + THEN evidence.evidence_type END) evidenceCount, + MAX(CASE WHEN evidence.status='ACTIVE' AND evidence.evidence_type='SEAL' + AND evidence.file_id IS NOT NULL AND evidence.seal_no IS NOT NULL + AND evidence.seal_no<>'' THEN 1 ELSE 0 END) validSeal + FROM fl_loading_plan plan + JOIN fl_loading_plan_item item ON item.loading_plan_id=plan.id AND item.deleted=0 + JOIN fl_loading_evidence evidence ON evidence.inbound_id=item.inbound_id + AND evidence.tenant_id=item.account_id + WHERE plan.id=#{loadingPlanId} AND plan.container_booking_id=#{containerBookingId} """) - Evidence evidence(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + Evidence evidence(@Param("containerBookingId") long containerBookingId, + @Param("loadingPlanId") long loadingPlanId); @Select(""" SELECT module,action,fromStatus,toStatus,operatorId,operatorName,remark,occurredAt FROM ( @@ -122,5 +138,6 @@ public interface OutboundDispatchReadMapper { Long customerId, String customerName, String inboundStatus, Long containerBookingId, String cabinetNo, String containerNo, Long loadingPlanId, String loadingPlanStatus) {} record IdStatus(Long id, String status) {} + record OceanBinding(Long id, String status, Long customerId) {} record Evidence(Integer evidenceCount, Integer validSeal) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java index a0f87004a..cf510a1e8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java @@ -1,7 +1,6 @@ package com.baiyue.firstleg.outbound.dispatch.repository; import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.Query; import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.TimelineItem; import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchEntity; import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchHistoryEntity; @@ -22,22 +21,24 @@ public class OutboundDispatchRepository { @Resource private OutboundDispatchOperationMapper operations; @Resource private OutboundDispatchReadMapper reads; - public List bindings(Query query) { - return reads.bindings(query.orderId(), query.inboundId(), query.containerBookingId()); + public List bindings(long containerId) { return reads.bindings(containerId); } + public List lockBindings(long containerId) { + return reads.bindingsForUpdate(containerId); } public OutboundDispatchReadMapper.IdStatus pick(long planId) { return reads.pick(planId); } - public OutboundDispatchReadMapper.IdStatus ocean(long containerId, long tenantId) { - return reads.ocean(containerId, tenantId); + public OutboundDispatchReadMapper.OceanBinding ocean(long containerId) { return reads.ocean(containerId); } + public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId) { + return reads.truck(containerId, planId); } - public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId, long tenantId) { - return reads.truck(containerId, planId, tenantId); - } - public OutboundDispatchReadMapper.Evidence evidence(long tenantId, long inboundId) { - return reads.evidence(tenantId, inboundId); + public OutboundDispatchReadMapper.Evidence evidence(long containerId, long loadingPlanId) { + return reads.evidence(containerId, loadingPlanId); } public OutboundDispatchEntity dispatch(long tenantId, long inboundId, long containerId) { return dispatches.find(tenantId, inboundId, containerId); } + public OutboundDispatchEntity dispatchLocked(long tenantId, long inboundId, long containerId) { + return dispatches.findForUpdate(tenantId, inboundId, containerId); + } public void insert(OutboundDispatchEntity value) { dispatches.insert(value); } public int confirm(long id, int version, long operatorId, String operatorName, String remark) { return dispatches.confirm(id, version, operatorId, operatorName, remark); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java index fad3762c1..0568fef73 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java @@ -23,8 +23,7 @@ import java.util.*; @Service public class OutboundDispatchService { - private static final Set REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE"); - private static final Set TRUCK_READY = Set.of("DEPARTED", "COMPLETED"); + private static final Set REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE"); private final OutboundDispatchRepository repository; private final Map contributors; @@ -51,94 +50,134 @@ public class OutboundDispatchService { } public List list(Query query) { - requireSelector(query); - return repository.bindings(query).stream().map(this::aggregate).toList(); + return List.of(aggregate(bindings(query.containerBookingId()))); } - public Detail detail(Query query) { - OutboundDispatchReadMapper.Binding binding = single(query); - Aggregate aggregate = aggregate(binding); - return new Detail(aggregate, repository.timeline(binding, aggregate.oceanBookingId(), aggregate.truckAppointmentId())); + public Detail detail(long containerBookingId) { + List bindings = bindings(containerBookingId); + Aggregate aggregate = aggregate(bindings); + List timeline = bindings.stream() + .flatMap(binding -> repository.timeline(binding, aggregate.oceanBookingId(), aggregate.truckAppointmentId()).stream()) + .distinct() + .sorted(Comparator.comparing(TimelineItem::occurredAt, + Comparator.nullsLast(Comparator.naturalOrder())).thenComparing(TimelineItem::module)) + .toList(); + return new Detail(aggregate, timeline); } - public GateResult checkGate(Query query) { return aggregate(single(query)).gate(); } + public ContainerGateResult checkGate(long containerBookingId) { + return aggregate(bindings(containerBookingId)).gate(); + } @Transactional - public Detail confirm(long operatorId, String operatorName, String key, ActionRequest request) { + public Detail confirm(long containerBookingId, long operatorId, String operatorName, + String key, ActionRequest request) { validateKey(key); - Query query = new Query(null, request.inboundId(), request.containerBookingId()); - OutboundDispatchReadMapper.Binding binding = single(query); - String hash = hash("CONFIRM", request); - Detail replay = replay(operatorId, "CONFIRM_DISPATCH", key, hash, query); + String hash = hash("CONFIRM", containerBookingId, request); + Detail replay = replay(containerBookingId, operatorId, "CONFIRM_DISPATCH", key, hash); if (replay != null) return replay; - Aggregate before = aggregate(binding); - if (!before.gate().passed()) throw conflict("国内出仓门禁未通过"); - OutboundDispatchEntity state = state(binding); - if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent(); - if (OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(state.getStatus())) { - throw conflict("当前出仓单已确认派车"); + List bindings = lockedBindings(containerBookingId); + Aggregate before = aggregate(bindings); + if (!before.gate().passed()) throw conflict("柜内全部入库绑定尚未通过国内出仓门禁"); + List states = bindings.stream().map(this::stateLocked).toList(); + StateView visible = visibleState(states); + if (!Objects.equals(visible.version(), request.expectedVersion())) throw concurrent(); + if (OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(visible.status())) { + throw conflict("当前货柜已确认离仓"); } - if (repository.confirm(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { - throw concurrent(); + for (OutboundDispatchEntity state : states) { + if (repository.confirm(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { + throw concurrent(); + } } - history(state, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(), + OutboundDispatchEntity representative = states.get(0); + history(representative, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(), OutboundDispatchStatus.WAITING_FOR_LOADING.name(), request.remark(), operatorId, operatorName); + + OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId); if (carrierSchedules != null) { - carrierSchedules.ensureFromOutboundConfirm(binding.tenantId(), before.oceanBookingId(), - binding.containerBookingId(), operatorId, operatorName); + if (ocean == null || ocean.id() == null || ocean.customerId() == null) { + throw conflict("当前货柜未关联有效订舱,无法创建船司查询任务"); + } + carrierSchedules.ensureFromOutboundConfirm(ocean.customerId(), ocean.id(), containerBookingId, + operatorId, operatorName); } - if (repository.completeOperation(state.getId(), operatorId, "CONFIRM_DISPATCH", key) != 1) throw concurrent(); - return detail(query); + if (repository.completeOperation(representative.getId(), operatorId, "CONFIRM_DISPATCH", key) != 1) { + throw concurrent(); + } + return detail(containerBookingId); } @Transactional - public Detail reverse(long operatorId, String operatorName, String key, ActionRequest request) { + public Detail reverse(long containerBookingId, long operatorId, String operatorName, + String key, ActionRequest request) { validateKey(key); - Query query = new Query(null, request.inboundId(), request.containerBookingId()); - OutboundDispatchReadMapper.Binding binding = single(query); - String hash = hash("REVERSE", request); - Detail replay = replay(operatorId, "REVERSE_DISPATCH", key, hash, query); + String hash = hash("REVERSE", containerBookingId, request); + Detail replay = replay(containerBookingId, operatorId, "REVERSE_DISPATCH", key, hash); if (replay != null) return replay; - OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); - if (state == null || !OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(state.getStatus())) { - throw conflict("当前出仓单不在等待装船状态"); + List bindings = lockedBindings(containerBookingId); + List states = bindings.stream().map(this::existingStateLocked).toList(); + StateView visible = visibleState(states); + if (!OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(visible.status())) { + throw conflict("当前货柜不在已确认离仓状态"); } - if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent(); - OutboundDispatchReadMapper.IdStatus ocean = repository.ocean(binding.containerBookingId(), binding.tenantId()); - if (carrierSchedules != null && ocean != null && carrierSchedules.hasOutboundDepartureFact( - binding.tenantId(), ocean.id(), binding.containerBookingId())) { - throw conflict("已产生实际DEPARTED_WAREHOUSE船节点,禁止撤销国内出仓"); + if (!Objects.equals(visible.version(), request.expectedVersion())) throw concurrent(); + OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId); + if (carrierSchedules != null) { + if (ocean == null || ocean.id() == null || ocean.customerId() == null) { + throw conflict("无法核验货柜后续船节点,禁止撤销国内出仓"); + } + if (carrierSchedules.hasOutboundDepartureFact(ocean.customerId(), ocean.id(), containerBookingId)) { + throw conflict("已产生实际DEPARTED_WAREHOUSE船节点,禁止撤销国内出仓"); + } } - if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { + for (OutboundDispatchEntity state : states) { + if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { + throw concurrent(); + } + } + OutboundDispatchEntity representative = states.get(0); + history(representative, "REVERSE_DISPATCH", OutboundDispatchStatus.WAITING_FOR_LOADING.name(), + OutboundDispatchStatus.READY_FOR_DISPATCH.name(), request.remark(), operatorId, operatorName); + if (repository.completeOperation(representative.getId(), operatorId, "REVERSE_DISPATCH", key) != 1) { throw concurrent(); } - history(state, "REVERSE_DISPATCH", OutboundDispatchStatus.WAITING_FOR_LOADING.name(), - OutboundDispatchStatus.READY_FOR_DISPATCH.name(), request.remark(), operatorId, operatorName); - if (repository.completeOperation(state.getId(), operatorId, "REVERSE_DISPATCH", key) != 1) throw concurrent(); - return detail(query); + return detail(containerBookingId); } - private Aggregate aggregate(OutboundDispatchReadMapper.Binding binding) { - var pick = repository.pick(binding.loadingPlanId()); - var ocean = repository.ocean(binding.containerBookingId(), binding.tenantId()); - var truck = repository.truck(binding.containerBookingId(), binding.loadingPlanId(), binding.tenantId()); - var evidence = repository.evidence(binding.tenantId(), binding.inboundId()); - var dispatch = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); - GateResult gate = gate(binding, pick, ocean, truck, evidence); - return new Aggregate(binding.orderId(), binding.orderNo(), binding.inboundId(), binding.inboundCode(), - binding.tenantId(), binding.customerId(), binding.customerName(), binding.inboundStatus(), - binding.containerBookingId(), binding.cabinetNo(), binding.containerNo(), - binding.loadingPlanId(), binding.loadingPlanStatus(), - id(pick), status(pick), id(ocean), status(ocean), id(truck), status(truck), - dispatch == null ? OutboundDispatchStatus.READY_FOR_DISPATCH.name() : dispatch.getStatus(), - dispatch == null ? 0 : dispatch.getVersion(), gate); + private Aggregate aggregate(List bindings) { + OutboundDispatchReadMapper.Binding first = validateContainer(bindings); + var pick = repository.pick(first.loadingPlanId()); + var ocean = repository.ocean(first.containerBookingId()); + var truck = repository.truck(first.containerBookingId(), first.loadingPlanId()); + var evidence = repository.evidence(first.containerBookingId(), first.loadingPlanId()); + List containerContributorGates = contributorGates(first, ocean); + List inboundGates = new ArrayList<>(); + List inboundDtos = new ArrayList<>(); + List states = new ArrayList<>(); + for (OutboundDispatchReadMapper.Binding binding : bindings) { + GateResult gate = gate(binding, pick, ocean, evidence, containerContributorGates); + inboundGates.add(new InboundGate(binding.inboundId(), binding.inboundCode(), gate)); + inboundDtos.add(new InboundBinding(binding.orderId(), binding.orderNo(), binding.inboundId(), + binding.inboundCode(), binding.tenantId(), binding.customerId(), binding.customerName(), + binding.inboundStatus(), id(pick), status(pick))); + OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), + binding.containerBookingId()); + if (state != null) states.add(state); + } + StateView visible = visibleStateForRead(states, bindings.size()); + ContainerGateResult containerGate = new ContainerGateResult( + inboundGates.stream().allMatch(value -> value.gate().passed()), List.copyOf(inboundGates), LocalDateTime.now()); + return new Aggregate(first.containerBookingId(), first.cabinetNo(), first.containerNo(), + first.loadingPlanId(), first.loadingPlanStatus(), id(ocean), status(ocean), id(truck), status(truck), + visible.status(), visible.version(), List.copyOf(inboundDtos), containerGate); } private GateResult gate(OutboundDispatchReadMapper.Binding binding, OutboundDispatchReadMapper.IdStatus pick, - OutboundDispatchReadMapper.IdStatus ocean, OutboundDispatchReadMapper.IdStatus truck, - OutboundDispatchReadMapper.Evidence evidence) { + OutboundDispatchReadMapper.OceanBinding ocean, OutboundDispatchReadMapper.Evidence evidence, + List containerContributorGates) { List items = new ArrayList<>(); items.add(item("INBOUND_LOADED", "入库单装柜完成", "LOADED".equals(binding.inboundStatus()), binding.inboundStatus(), "LOADED")); @@ -148,14 +187,19 @@ public class OutboundDispatchService { status(pick), "COMPLETED")); items.add(item("OCEAN_BOOKING_RELEASED", "订舱已放舱且分配当前柜次", ocean != null && "RELEASED".equals(ocean.status()), status(ocean), "RELEASED")); - items.add(item("TRUCK_DEPARTED", "关联拖车已离仓", truck != null && TRUCK_READY.contains(truck.status()), - status(truck), "DEPARTED或COMPLETED")); boolean evidenceReady = evidence != null && evidence.evidenceCount() != null && evidence.evidenceCount() >= 5 && Objects.equals(evidence.validSeal(), 1); items.add(item("LOADING_EVIDENCE_COMPLETE", "四步照片与铅封完整", evidenceReady, evidence == null ? null : evidence.evidenceCount() + "个证据/有效铅封=" + evidence.validSeal(), "PHOTO_STEP_1..4和含铅封号的SEAL")); + items.addAll(containerContributorGates); + return new GateResult(items.stream().allMatch(GateItem::passed), List.copyOf(items), LocalDateTime.now()); + } + + private List contributorGates(OutboundDispatchReadMapper.Binding binding, + OutboundDispatchReadMapper.OceanBinding ocean) { + List items = new ArrayList<>(); var context = new OutboundDispatchGateContributor.Context(binding.tenantId(), binding.customerId(), binding.orderId(), binding.inboundId(), binding.containerBookingId(), binding.loadingPlanId(), id(ocean)); for (String code : REQUIRED_CONTRIBUTORS.stream().sorted().toList()) { @@ -171,77 +215,136 @@ public class OutboundDispatchService { items.add(item(code, contributorName(code), false, "ASSESSMENT_ERROR", "模块返回可放行状态")); } } - return new GateResult(items.stream().allMatch(GateItem::passed), List.copyOf(items), LocalDateTime.now()); + return List.copyOf(items); } - private Detail replay(long operatorId, String operation, String key, String hash, Query query) { + private Detail replay(long containerBookingId, long operatorId, String operation, String key, String hash) { if (repository.claim(operatorId, operation, key, hash)) return null; var existing = repository.operation(operatorId, operation, key); if (existing == null || !Objects.equals(existing.getRequestHash(), hash)) { throw conflict("Idempotency-Key已用于不同请求"); } if (!"SUCCESS".equals(existing.getStatus())) throw conflict("相同请求正在处理中"); - return detail(query); + return detail(containerBookingId); } - private OutboundDispatchEntity state(OutboundDispatchReadMapper.Binding binding) { - OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); + private OutboundDispatchEntity stateLocked(OutboundDispatchReadMapper.Binding binding) { + OutboundDispatchEntity state = repository.dispatchLocked(binding.tenantId(), binding.inboundId(), + binding.containerBookingId()); if (state != null) return state; state = new OutboundDispatchEntity(); - state.setId(IdWorker.getId()); state.setTenantId(binding.tenantId()); state.setOrderId(binding.orderId()); - state.setInboundId(binding.inboundId()); state.setContainerBookingId(binding.containerBookingId()); - state.setStatus(OutboundDispatchStatus.READY_FOR_DISPATCH.name()); state.setVersion(0); - try { repository.insert(state); } - catch (DuplicateKeyException ex) { - state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); + state.setId(IdWorker.getId()); + state.setTenantId(binding.tenantId()); + state.setOrderId(binding.orderId()); + state.setInboundId(binding.inboundId()); + state.setContainerBookingId(binding.containerBookingId()); + state.setStatus(OutboundDispatchStatus.READY_FOR_DISPATCH.name()); + state.setVersion(0); + try { + repository.insert(state); + } catch (DuplicateKeyException ex) { + state = repository.dispatchLocked(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); if (state == null) throw ex; } return state; } + private OutboundDispatchEntity existingStateLocked(OutboundDispatchReadMapper.Binding binding) { + OutboundDispatchEntity state = repository.dispatchLocked(binding.tenantId(), binding.inboundId(), + binding.containerBookingId()); + if (state == null) throw conflict("当前货柜尚未确认离仓"); + return state; + } + private void history(OutboundDispatchEntity state, String action, String from, String to, String remark, long operatorId, String operatorName) { OutboundDispatchHistoryEntity history = new OutboundDispatchHistoryEntity(); - history.setId(IdWorker.getId()); history.setDispatchId(state.getId()); history.setTenantId(state.getTenantId()); - history.setInboundId(state.getInboundId()); history.setContainerBookingId(state.getContainerBookingId()); - history.setAction(action); history.setFromStatus(from); history.setToStatus(to); history.setRemark(remark); - history.setOperatorId(operatorId); history.setOperatorName(operatorName); repository.history(history); + history.setId(IdWorker.getId()); + history.setDispatchId(state.getId()); + history.setTenantId(state.getTenantId()); + history.setInboundId(state.getInboundId()); + history.setContainerBookingId(state.getContainerBookingId()); + history.setAction(action); + history.setFromStatus(from); + history.setToStatus(to); + history.setRemark(remark); + history.setOperatorId(operatorId); + history.setOperatorName(operatorName); + repository.history(history); } - private OutboundDispatchReadMapper.Binding single(Query query) { - requireSelector(query); - List bindings = repository.bindings(query); - if (bindings.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "未找到匹配的入库单与柜次关联"); - if (bindings.size() != 1) throw new BizException(ResultCode.BAD_REQUEST, "查询条件匹配多个出仓聚合,请同时指定inboundId和containerBookingId"); - return bindings.get(0); + private List bindings(long containerBookingId) { + return normalize(repository.bindings(containerBookingId)); } - private static void requireSelector(Query query) { - if (query == null || query.orderId() == null && query.inboundId() == null && query.containerBookingId() == null) { - throw new BizException(ResultCode.BAD_REQUEST, "orderId、inboundId、containerBookingId至少提供一个"); + private List lockedBindings(long containerBookingId) { + return normalize(repository.lockBindings(containerBookingId)); + } + + private static List normalize(List values) { + if (values == null || values.isEmpty()) { + throw new BizException(ResultCode.NOT_FOUND, "未找到当前货柜的真实入库绑定"); } + LinkedHashMap unique = new LinkedHashMap<>(); + for (OutboundDispatchReadMapper.Binding value : values) unique.putIfAbsent(value.inboundId(), value); + return List.copyOf(unique.values()); } + + private static OutboundDispatchReadMapper.Binding validateContainer(List bindings) { + OutboundDispatchReadMapper.Binding first = bindings.get(0); + boolean inconsistent = bindings.stream().anyMatch(binding -> + !Objects.equals(first.containerBookingId(), binding.containerBookingId()) + || !Objects.equals(first.loadingPlanId(), binding.loadingPlanId())); + if (inconsistent) throw conflict("货柜入库绑定存在跨柜次数据冲突"); + return first; + } + + private static StateView visibleStateForRead(List states, int bindingCount) { + if (states.isEmpty()) return new StateView(OutboundDispatchStatus.READY_FOR_DISPATCH.name(), 0); + if (states.size() != bindingCount) throw conflict("货柜出仓状态不完整,请联系管理员处理"); + return visibleState(states); + } + + private static StateView visibleState(List states) { + if (states.isEmpty()) throw conflict("货柜出仓状态不存在"); + String status = states.get(0).getStatus(); + Integer version = states.get(0).getVersion(); + if (states.stream().anyMatch(value -> !Objects.equals(status, value.getStatus()) + || !Objects.equals(version, value.getVersion()))) { + throw conflict("货柜出仓状态或版本不一致,请联系管理员处理"); + } + return new StateView(status, version); + } + private static void validateKey(String key) { if (key == null || key.isBlank() || key.length() > 128) { throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空且长度不能超过128"); } } - private static String hash(String action, ActionRequest request) { - String source = action + "|" + request.inboundId() + "|" + request.containerBookingId() + "|" - + request.expectedVersion() + "|" + Objects.toString(request.remark(), ""); + + private static String hash(String action, long containerBookingId, ActionRequest request) { + String source = action + "|" + containerBookingId + "|" + request.expectedVersion() + "|" + + Objects.toString(request.remark(), ""); try { byte[] digest = MessageDigest.getInstance("SHA-256").digest(source.getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(digest); - } catch (Exception ex) { throw new IllegalStateException(ex); } + } catch (Exception ex) { + throw new IllegalStateException(ex); + } } + private static Long id(OutboundDispatchReadMapper.IdStatus value) { return value == null ? null : value.id(); } + private static Long id(OutboundDispatchReadMapper.OceanBinding value) { return value == null ? null : value.id(); } private static String status(OutboundDispatchReadMapper.IdStatus value) { return value == null ? null : value.status(); } + private static String status(OutboundDispatchReadMapper.OceanBinding value) { return value == null ? null : value.status(); } private static GateItem item(String code, String name, boolean pass, String actual, String requirement) { return new GateItem(code, name, pass, actual, requirement); } private static String contributorName(String code) { - return "CUSTOMS_CLEARANCE".equals(code) ? "报关协同完成" : "船期有效且未过期"; + return "报关协同完成"; } private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); } + + private record StateView(String status, Integer version) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java index 4153fa1e2..4b76ab3a0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java @@ -97,6 +97,8 @@ public class PlatformCarrierScheduleQueryController { @PostMapping("/{queryId}/manual-nodes") @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')") + @Operation(summary = "人工补录预计或实际标准船节点", + description = "预计节点不推进实际进度;人工实际节点必须严格晚于当前最新实际节点,同级或更早节点返回 A0409。") @BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_NODE", resourceType = "CARRIER_SCHEDULE_QUERY", resourceId = "#queryId", description = "人工补录船节点事实") public ApiResponse manualNode(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java index 50a5919ec..cd7ed02d5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java @@ -36,23 +36,26 @@ public class CarrierScheduleAdapterRegistry { public PreparedAttempt prepare(String carrierCode, String trackingType, String trackingNo) { Definition definition = required(carrierCode); + boolean automated = automated(definition); return new PreparedAttempt(definition.code(), definition.accessMode(), - officialUrl(definition, trackingType, trackingNo), automated(definition) ? "READY" : "MANUAL_REQUIRED", - definition.coverageGapCode(), definition.manualReason()); + officialUrl(definition, trackingType, trackingNo), automated ? "READY" : "MANUAL_REQUIRED", + definition.coverageGapCode(), automated ? null : manualReason(definition)); + } + + /** Outbound confirmation is a physical fact and must not be blocked by provider coverage. */ + public PreparedAttempt prepareOutbound(String carrierCode, String trackingType, String trackingNo) { + String normalized = carrierCode == null ? "" : carrierCode.trim().toUpperCase(Locale.ROOT); + if (normalized.isEmpty() || "OTHER".equals(normalized)) { + return new PreparedAttempt("OTHER", "MANUAL", "", "MANUAL_REQUIRED", + "UNSUPPORTED_CARRIER", "船司未接入自动查询,需人工补录船节点"); + } + return prepare(normalized, trackingType, trackingNo); } public String normalize(String carrierCode) { return required(carrierCode).code(); } - public String resolve(String carrierName) { - String normalized = carrierName == null ? "" : carrierName.trim().toUpperCase(Locale.ROOT); - return DEFINITIONS.values().stream().filter(value -> value.matches(normalized)) - .findFirst().map(Definition::code) - .orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, - "出仓订舱船司无法映射至已支持的船节点供应商:" + carrierName)); - } - private static Definition required(String carrierCode) { String normalized = carrierCode == null ? "" : carrierCode.trim().toUpperCase(Locale.ROOT); Definition value = DEFINITIONS.get(normalized); @@ -105,6 +108,12 @@ public class CarrierScheduleAdapterRegistry { }; } + private static String manualReason(Definition definition) { + return definition.manualReason() == null + ? definition.name() + "自动查询当前未启用,需人工补录船节点" + : definition.manualReason(); + } + private record Definition(String code, String name, String officialUrl, String accessMode, String coverageGapCode, String manualReason) { CarrierDescriptor descriptor(boolean automated) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java index 4ab755ac0..417ab2d60 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java @@ -35,6 +35,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -95,7 +96,6 @@ public class CarrierScheduleQueryService { ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId); if (booking == null || container == null) throw new BizException(ResultCode.NOT_FOUND, "出仓订舱或柜次不存在"); validateLinks(customerId, oceanBookingId, containerBookingId); - String carrierCode = adapters.resolve(booking.getShippingCompanyName()); String trackingType; String trackingNo; if (StringUtils.hasText(container.getContainerNo())) { @@ -107,36 +107,58 @@ public class CarrierScheduleQueryService { } else { throw badRequest("确认国内出仓前必须具备柜号、提单号或订舱号之一"); } - PreparedAttempt strategy = adapters.prepare(carrierCode, trackingType, trackingNo); + PreparedAttempt strategy = adapters.prepareOutbound(booking.getCarrierCode(), trackingType, trackingNo); LocalDateTime now = now(); CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId()); value.setCustomerId(customerId); value.setOceanBookingId(oceanBookingId); - value.setContainerBookingId(containerBookingId); value.setCarrierCode(carrierCode); + value.setContainerBookingId(containerBookingId); value.setCarrierCode(strategy.carrierCode()); value.setTrackingType(trackingType); value.setTrackingNo(trackingNo); value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode()); - value.setStatus("READY"); value.setTriggerSource("OUTBOUND_CONFIRM"); + value.setStatus(strategy.status()); value.setTriggerSource("OUTBOUND_CONFIRM"); value.setLatestStandardNode(CarrierScheduleNode.DEPARTED_WAREHOUSE.name()); value.setLatestNodeObservedAt(now); value.setWindowStartedAt(now); value.setAttemptCount(0); - value.setConsecutiveFailures(0); value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now)); + value.setConsecutiveFailures(0); value.setNextAllowedAt("MANUAL".equals(strategy.accessMode()) + ? null : CarrierScheduleQuerySlots.nextSlot(now)); + value.setManualReason(outboundManualReason(strategy, booking)); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); - try { repository.insert(value); } - catch (DuplicateKeyException exception) { - CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId, - containerBookingId); - if (concurrent != null) return concurrent; - throw exception; + String persistenceStage = "QUERY_TASK"; + try { + try { repository.insert(value); } + catch (DuplicateKeyException exception) { + CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId, + containerBookingId); + if (concurrent != null) return concurrent; + throw exception; + } + persistenceStage = "NODE_FACT"; + CarrierScheduleNodeFactEntity departed = addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM", + CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM", + sha256("OUTBOUND_CONFIRM|" + containerBookingId)); + if (departed != null) { + persistenceStage = "CROSS_REGION_OUTBOX"; + nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId)); + } + persistenceStage = "QUERY_HISTORY"; + addHistory(value.getId(), "AUTO_CREATE", null, 0, "国内确认出仓自动创建", null, value, + operatorId, operatorName); + } catch (DataIntegrityViolationException exception) { + throw conflict("船节点持久化契约与当前服务不一致(" + persistenceStage + + "),请确认OTHER/MANUAL约束及节点事实表已初始化"); } - CarrierScheduleNodeFactEntity departed = addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM", - CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM", - sha256("OUTBOUND_CONFIRM|" + containerBookingId)); - if (departed != null) nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId)); - addHistory(value.getId(), "AUTO_CREATE", null, 0, "国内确认出仓自动创建", null, value, - operatorId, operatorName); return value; } + private static String outboundManualReason(PreparedAttempt strategy, OceanBookingEntity booking) { + if (!"MANUAL_REQUIRED".equals(strategy.status())) return null; + String reason = strategy.manualReason(); + if (!"MANUAL".equals(strategy.accessMode())) return reason; + return reason + ";carrierCode=" + Objects.toString(booking.getCarrierCode(), "") + + ";shippingCompanyId=" + booking.getShippingCompanyId() + + ";shippingCompanyName=" + Objects.toString(booking.getShippingCompanyName(), ""); + } + @Transactional public Detail applyProviderObservation(long id, ProviderObservationRequest request) { CarrierScheduleQueryEntity value = requiredLocked(id); @@ -183,6 +205,7 @@ public class CarrierScheduleQueryService { } CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion()); CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); + requireManualActualAdvance(value, node, request.actualAt()); CarrierScheduleQueryEntity before = copy(value); String providerEventId = sha256("MANUAL|" + id + '|' + key); CarrierScheduleNodeFactEntity fact = addNode(id, "MANUAL", providerEventId, node.name(), node, @@ -204,6 +227,15 @@ public class CarrierScheduleQueryService { return complete(id, operatorId, key, detail(after)); } + private void requireManualActualAdvance(CarrierScheduleQueryEntity value, CarrierScheduleNode node, + LocalDateTime actualAt) { + if (actualAt == null || value.getLatestStandardNode() == null) return; + CarrierScheduleNode current = CarrierScheduleNode.require(value.getLatestStandardNode()); + if (node.sequence() <= current.sequence()) { + throw conflict("人工实际节点必须严格晚于当前最新实际节点"); + } + } + private boolean advanceActualNode(CarrierScheduleQueryEntity value, CarrierScheduleNode node, LocalDateTime actualAt, LocalDateTime observedAt, long operatorId, String operatorName) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java similarity index 93% rename from firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookQueryMapper.java rename to firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java index f86961189..8b708f74d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java @@ -1,9 +1,10 @@ -package com.baiyue.firstleg.mapper; +package com.baiyue.firstleg.pricebookquery.adapter.out.persistence; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; @@ -12,7 +13,7 @@ import java.util.List; /** Dedicated read model for merchant channel selection and reference estimates. */ @Mapper -public interface PriceBookQueryMapper { +public interface PriceBookQueryMapper extends PriceBookQueryReader { @Select(""" SELECT channel_code AS channelCode, inbound_code_prefix AS inboundCodePrefix, overseas_inbound_code_prefix AS overseasInboundCodePrefix diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java new file mode 100644 index 000000000..094003ec2 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java @@ -0,0 +1,45 @@ +package com.baiyue.firstleg.pricebookquery.application; + +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; +import com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class PriceBookQueryService implements PriceBookQueryUseCase { + @Resource private PriceBookQueryReader reader; + + @Override public Long activeVersionId() { return reader.findActiveVersionId(); } + @Override public List channels(long versionId, long originWarehouseId) { + return reader.findChannels(versionId, originWarehouseId); + } + @Override public List channelTaxModes(long versionId, long originWarehouseId) { + return reader.findChannelTaxModes(versionId, originWarehouseId); + } + @Override public List fbaRates(long versionId, long originWarehouseId, String channelCode, + String destinationCode, String pricingTaxMode) { + return reader.findFbaRates(versionId, originWarehouseId, channelCode, destinationCode, pricingTaxMode); + } + @Override public List overseasRates(long versionId, long originWarehouseId, String channelCode, + long destinationWarehouseId, String pricingTaxMode) { + return reader.findOverseasRates(versionId, originWarehouseId, channelCode, destinationWarehouseId, + pricingTaxMode); + } + @Override public List regionTiers(long versionId, long originWarehouseId, String channelCode, + String regionCode, String pricingTaxMode) { + return reader.findRegionTiers(versionId, originWarehouseId, channelCode, regionCode, pricingTaxMode); + } + @Override public boolean channelAvailable(long versionId, long originWarehouseId, String channelCode) { + return channels(versionId, originWarehouseId).stream() + .anyMatch(channel -> channelCode.equals(channel.getChannelCode())); + } + @Override public ChannelPrefixRow channelPrefix(String channelCode) { + return reader.findChannelPrefix(channelCode); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java similarity index 96% rename from firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java rename to firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java index dc8395fc6..0ca9c0b8a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java @@ -1,4 +1,4 @@ -package com.baiyue.firstleg.repository; +package com.baiyue.firstleg.pricebookquery.application.model; import lombok.Getter; import lombok.Setter; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java new file mode 100644 index 000000000..980c86ad7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java @@ -0,0 +1,22 @@ +package com.baiyue.firstleg.pricebookquery.application.port.in; + +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; + +import java.util.List; + +public interface PriceBookQueryUseCase { + Long activeVersionId(); + List channels(long versionId, long originWarehouseId); + List channelTaxModes(long versionId, long originWarehouseId); + List fbaRates(long versionId, long originWarehouseId, String channelCode, + String destinationCode, String pricingTaxMode); + List overseasRates(long versionId, long originWarehouseId, String channelCode, + long destinationWarehouseId, String pricingTaxMode); + List regionTiers(long versionId, long originWarehouseId, String channelCode, + String regionCode, String pricingTaxMode); + boolean channelAvailable(long versionId, long originWarehouseId, String channelCode); + ChannelPrefixRow channelPrefix(String channelCode); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java new file mode 100644 index 000000000..04d852aab --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.pricebookquery.application.port.out; + +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; + +import java.util.List; + +public interface PriceBookQueryReader { + Long findActiveVersionId(); + List findChannels(long versionId, long originWarehouseId); + List findChannelTaxModes(long versionId, long originWarehouseId); + List findFbaRates(long versionId, long originWarehouseId, String channelCode, + String destinationCode, String pricingTaxMode); + List findOverseasRates(long versionId, long originWarehouseId, String channelCode, + long destinationWarehouseId, String pricingTaxMode); + List findRegionTiers(long versionId, long originWarehouseId, String channelCode, + String regionCode, String pricingTaxMode); + ChannelPrefixRow findChannelPrefix(String channelCode); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java index e1733566e..003b4085e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java @@ -7,9 +7,9 @@ import com.baiyue.firstleg.domain.PriceRegion; import com.baiyue.firstleg.domain.PricingDestinationType; import com.baiyue.firstleg.domain.PricingTaxMode; import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import jakarta.annotation.Resource; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -21,7 +21,7 @@ import java.util.Locale; /** Single active-price-book lookup used by merchant estimates and inbound automatic billing. */ @Component public class ActivePriceBookEstimator { - @Resource private PriceBookQueryRepository repository; + @Resource private PriceBookQueryUseCase repository; @Resource private FbaWarehouseRateEstimator fbaEstimator; @Resource private RegionWeightTierEstimator regionEstimator; @Resource private PriceBookWarehouseRegionResolver warehouseRegionResolver; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimator.java index 463252d34..897aef553 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimator.java @@ -2,7 +2,7 @@ package com.baiyue.firstleg.pricing; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import org.springframework.stereotype.Component; import java.math.BigDecimal; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimator.java index 426593a10..3b9d25f6e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimator.java @@ -2,7 +2,7 @@ package com.baiyue.firstleg.pricing; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import org.springframework.stereotype.Component; import java.math.BigDecimal; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java index 60fbe9d8c..9a425a5e9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java @@ -46,7 +46,6 @@ public class ContainerRepository { public boolean hasPickTask(long id) { return mapper.hasPickTask(id) > 0; } public boolean hasEvidence(long id) { return mapper.hasEvidence(id) > 0; } public boolean hasDocuments(long id) { return mapper.hasDocuments(id) > 0; } - public boolean hasMismatchedCustomerItems(long id, long customerId) { return mapper.hasMismatchedCustomerItems(id, customerId) > 0; } public int updateTransport(long id, String containerNo, String voyageNo, int version, long operatorId, String operatorName) { return mapper.updateTransport(id, containerNo, voyageNo, version, operatorId, operatorName); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java index 86fad576e..e363c9a2d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java @@ -106,9 +106,13 @@ public class LoadingListRepository { String fromStatus, String toStatus, String beforeSnapshot, String afterSnapshot, long operatorId, String operatorName, String operationReason) { - mapper.insertHistory(new HistoryWrite(IdWorker.getId(), planId, operationId, actionType, + int changed = mapper.insertHistory(new HistoryWrite(IdWorker.getId(), planId, operationId, actionType, fromStatus, toStatus, beforeSnapshot, afterSnapshot, operatorId, operatorName, operationReason)); + if (changed != 1) { + throw new com.baiyue.common.core.exception.BizException( + com.baiyue.common.core.api.ResultCode.CONFLICT, "装柜历史已变化,请刷新后重试"); + } } private Map> customerMap(List rows) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java deleted file mode 100644 index 1161bf9e3..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.baiyue.firstleg.repository; - -import com.baiyue.firstleg.mapper.PriceBookQueryMapper; -import jakarta.annotation.Resource; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public class PriceBookQueryRepository { - @Resource - private PriceBookQueryMapper mapper; - - public Long activeVersionId() { - return mapper.findActiveVersionId(); - } - - public List channels(long versionId, long originWarehouseId) { - return mapper.findChannels(versionId, originWarehouseId); - } - - public List channelTaxModes(long versionId, long originWarehouseId) { - return mapper.findChannelTaxModes(versionId, originWarehouseId); - } - - public List fbaRates(long versionId, long originWarehouseId, - String channelCode, String destinationCode, - String pricingTaxMode) { - return mapper.findFbaRates(versionId, originWarehouseId, channelCode, destinationCode, pricingTaxMode); - } - - public List overseasRates(long versionId, long originWarehouseId, - String channelCode, long destinationWarehouseId, - String pricingTaxMode) { - return mapper.findOverseasRates(versionId, originWarehouseId, channelCode, destinationWarehouseId, - pricingTaxMode); - } - - public List regionTiers(long versionId, long originWarehouseId, - String channelCode, String regionCode, - String pricingTaxMode) { - return mapper.findRegionTiers(versionId, originWarehouseId, channelCode, regionCode, pricingTaxMode); - } - - public boolean channelAvailable(long versionId, long originWarehouseId, String channelCode) { - return channels(versionId, originWarehouseId).stream() - .anyMatch(channel -> channelCode.equals(channel.getChannelCode())); - } - - public PriceBookQueryRows.ChannelPrefixRow channelPrefix(String channelCode) { - return mapper.findChannelPrefix(channelCode); - } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index cfc7553c4..8a9d5571e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -22,8 +22,8 @@ import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper; import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity; @@ -44,7 +44,7 @@ public class ContainerService { @Resource private SmartLoadingRepository smartLoadingRepository; @Resource private LoadingPickRepository loadingPickRepository; @Resource private LoadingPickService loadingPickService; - @Resource private PriceBookQueryRepository priceBooks; + @Resource private PriceBookQueryUseCase priceBooks; @Resource private OceanBookingRepository oceanBookings; @Resource private WarehouseSupplierSnapshotService suppliers; @Resource private FirstlegDocumentNumberGenerator documentNumbers; @@ -85,17 +85,14 @@ public class ContainerService { if (booking == null || !"RELEASED".equals(booking.getStatus())) throw conflict("只能关联已发布订舱"); OceanBookingContainerRequirementEntity requirement = oceanBookings.requirement(request.oceanBookingId(), request.requirementId()); if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "订舱箱型需求不存在"); - long allocated = oceanBookings.allocations(request.oceanBookingId()).stream().filter(a -> request.requirementId().equals(a.getRequirementId())).count(); + long allocated = oceanBookings.activeLinkedCount(request.oceanBookingId(), request.requirementId()); if (allocated >= requirement.getQuantity()) throw conflict("该订舱箱型需求已达到数量上限"); if (repository.containerNoExists(request.containerNo().trim(), id)) throw conflict("集装箱号已存在"); if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId()) || !java.util.Objects.equals(container.getChannelCode(), booking.getChannelCode()) - || !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType()) - || container.getCapacityCbm() == null || requirement.getCapacityCbm() == null - || container.getMaxWeightKg() == null || requirement.getMaxWeightKg() == null - || container.getCapacityCbm().compareTo(requirement.getCapacityCbm()) != 0 - || container.getMaxWeightKg().compareTo(requirement.getMaxWeightKg()) != 0) throw conflict("模拟柜与真实订舱柜仓库、渠道、箱型或容量不一致"); - if (booking.getCustomerId() != null && repository.hasMismatchedCustomerItems(id, booking.getCustomerId())) throw conflict("模拟柜已排货客户与真实订舱客户范围不一致"); + || !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) { + throw conflict("模拟柜与真实订舱柜仓库、渠道或柜型不一致"); + } if (repository.linkReleasedBooking(id, request.oceanBookingId(), request.requirementId(), request.containerNo().trim(), booking, request.expectedVersion(), operatorId, operatorName) != 1) { throw conflict("真实订舱柜已被消费或货柜版本发生变化,请刷新后重试"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index a5f92a869..025f1776b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -14,7 +14,7 @@ import com.baiyue.firstleg.entity.InboundFeeItemEntity; import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import com.baiyue.firstleg.repository.FbaPricingRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 2b287f49d..c77be6c49 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -31,6 +31,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker; import feign.FeignException; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.context.annotation.Lazy; import org.springframework.transaction.annotation.Transactional; @@ -55,7 +56,7 @@ public class LoadingListService { public static final String DELIVERY_ORDER = "DELIVERY_ORDER"; public static final String PICKUP_ORDER = "PICKUP_ORDER"; private static final String CLEARANCE_FILE_TYPE = "CONTAINER_CLEARANCE_DOCUMENT"; - private static final String DECLARATION_FILE_TYPE = "LOADING_CUSTOMS_DOCUMENT"; + private static final String DECLARATION_FILE_TYPE = "LOADING_CUSTOMS_DECLARATION"; private static final String DO_FILE_TYPE = "CONTAINER_DELIVERY_ORDER"; private static final String PU_FILE_TYPE = "CONTAINER_PICKUP_ORDER"; @Resource @@ -128,6 +129,41 @@ public class LoadingListService { } catch(FeignException e){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} } + public DocumentDownload downloadContainerClearance(long bookingId, long fileId) { + requireBookingPlan(bookingId); + ContainerDocumentEntity document = repository.document(bookingId, DESTINATION_CLEARANCE); + if (document == null || !Long.valueOf(fileId).equals(document.getFileId())) { + throw notFound("目的港清关附件不存在或不属于当前货柜"); + } + String businessId = Long.toString(bookingId); + try { + var metadata = documentFiles.metadata(internalToken, 0, CLEARANCE_FILE_TYPE, businessId, fileId); + if (metadata == null || !metadata.successful() || metadata.data() == null + || !Long.valueOf(fileId).equals(metadata.data().fileId()) + || !Long.valueOf(0).equals(metadata.data().tenantId()) + || !CLEARANCE_FILE_TYPE.equals(metadata.data().businessType()) + || !businessId.equals(metadata.data().businessId())) { + throw notFound("目的港清关附件不存在或归属不匹配"); + } + var response = documentFiles.download(internalToken, 0, CLEARANCE_FILE_TYPE, businessId, fileId); + if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + throw notFound("目的港清关附件不存在"); + } + MediaType contentType; + try { + contentType = MediaType.parseMediaType(metadata.data().contentType()); + } catch (RuntimeException exception) { + contentType = MediaType.APPLICATION_OCTET_STREAM; + } + String fileName = StringUtils.hasText(document.getFileName()) + ? document.getFileName() : metadata.data().fileName(); + return new DocumentDownload(fileName, contentType, response.getBody()); + } catch (FeignException.NotFound exception) { + throw notFound("目的港清关附件不存在或归属不匹配"); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); + } + } public record DocumentDownload(String fileName,MediaType contentType,byte[] content) {} private static void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)&&!PICKUP_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER"); } private static String fileType(String type){ return switch(type){case DELIVERY_ORDER->DO_FILE_TYPE; case PICKUP_ORDER->PU_FILE_TYPE; default->throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER");}; } @@ -169,6 +205,44 @@ public class LoadingListService { return document(repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION)); } + public DocumentDownload downloadLoadingDeclaration(long planId, long warehouseId, long fileId) { + AggregateRow plan = requirePlan(planId); + requireWarehouse(plan, warehouseId); + requireCompletedPlan(plan); + ContainerDocumentEntity document = repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION); + if (document == null || !Long.valueOf(fileId).equals(document.getFileId())) { + throw notFound("出口报关附件不存在或不属于当前装柜计划"); + } + String businessId = Long.toString(planId); + try { + var metadata = documentFiles.metadata(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId); + if (metadata == null || !metadata.successful() || metadata.data() == null + || !Long.valueOf(fileId).equals(metadata.data().fileId()) + || !Long.valueOf(0).equals(metadata.data().tenantId()) + || !DECLARATION_FILE_TYPE.equals(metadata.data().businessType()) + || !businessId.equals(metadata.data().businessId())) { + throw notFound("出口报关附件不存在或归属不匹配"); + } + var response = documentFiles.download(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId); + if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + throw notFound("出口报关附件不存在"); + } + MediaType contentType; + try { + contentType = MediaType.parseMediaType(metadata.data().contentType()); + } catch (RuntimeException exception) { + contentType = MediaType.APPLICATION_OCTET_STREAM; + } + String fileName = StringUtils.hasText(document.getFileName()) + ? document.getFileName() : metadata.data().fileName(); + return new DocumentDownload(fileName, contentType, response.getBody()); + } catch (FeignException.NotFound exception) { + throw notFound("出口报关附件不存在或归属不匹配"); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); + } + } + @Transactional @BusinessAudit(event = "LOADING_CUSTOMS_DOCUMENT_ATTACH", resourceType = "LOADING_PLAN", resourceId = "#planId", description = "上传出口报关文档") @@ -264,12 +338,18 @@ public class LoadingListService { if (changed != 1) throw conflict("资料版本已变化,请刷新后重试"); } catch (DuplicateKeyException exception) { throw conflict("资料已被其他操作上传,请刷新后重试"); + } catch (DataIntegrityViolationException exception) { + throw conflict("资料状态或数据库约束已变化,请刷新后重试"); } ContainerDocumentEntity saved = requireDocumentEntity(plan.getContainerBookingId(), documentType); - repository.appendHistory(plan.getPlanId(), operationId, actionType, - plan.getPlanStatus(), plan.getPlanStatus(), json(beforeSnapshot), - json(values("requestHash", requestHash, "document", documentSnapshot(saved))), - operatorId, trim(operatorName), trim(operationReason)); + try { + repository.appendHistory(plan.getPlanId(), operationId, actionType, + plan.getPlanStatus(), plan.getPlanStatus(), json(beforeSnapshot), + json(values("requestHash", requestHash, "document", documentSnapshot(saved))), + operatorId, trim(operatorName), trim(operationReason)); + } catch (DataIntegrityViolationException exception) { + throw conflict("资料历史约束已变化,请刷新后重试"); + } return document(saved); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 4faf88928..1dcd6ad1a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -462,10 +462,12 @@ public class LoadingPickService { for (LoadingPickItemMapper.InboundOwner owner : owners) { LoadingInboundMapper.Snapshot snapshot = repository.inboundForUpdate(owner.tenantId(), owner.inboundId()); if (snapshot == null) throw conflict("PDA任务关联入库单不存在"); - loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId(), - evidenceOwner.inboundId()); + completionStage(taskId, "完成前业务门禁", () -> + loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId(), + evidenceOwner.inboundId())); } - settlementService.settle(taskId, operatorId, operatorName); + completionStage(taskId, "拆单与库位结算", () -> + settlementService.settle(taskId, operatorId, operatorName)); if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) { throw conflict("柜次仍有未完成PDA拣货的箱数"); } @@ -473,18 +475,33 @@ public class LoadingPickService { throw conflict("PDA任务已经复核"); } insertOperation(taskId, operationId, requestHash, "COMPLETE_LOADING", operatorId, operatorName); - loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName); + completionStage(taskId, "装柜计划完成", () -> + loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName)); repository.syncInboundProjections(taskId, operatorId, operatorName); for (LoadingPickItemMapper.InboundOwner owner : owners) { - loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(), - evidenceOwner.inboundId(), operatorId, operatorName, - "PDA-" + operationId + '-' + owner.inboundId(), remark); + completionStage(taskId, "入库单状态完成", () -> + loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(), + evidenceOwner.inboundId(), operatorId, operatorName, + "PDA-" + operationId + '-' + owner.inboundId(), remark)); } - loadingListService.publishCompletedEvent(task.getLoadingPlanId()); + completionStage(taskId, "完成事件发布", () -> + loadingListService.publishCompletedEvent(task.getLoadingPlanId())); completeTruckAfterCommit(task.getContainerBookingId(), taskId, operatorId, operatorName); return view(requiredTask(taskId), null); } + private void completionStage(long taskId, String stage, Runnable action) { + try { + action.run(); + } catch (BizException exception) { + throw exception; + } catch (RuntimeException exception) { + log.error("Unexpected PDA loading completion failure taskId={} stage={}", taskId, stage, exception); + throw new BizException(ResultCode.INTERNAL_ERROR, + "装柜完成在【" + stage + "】阶段处理异常,请联系管理员并提供请求编号"); + } + } + private void completeTruckAfterCommit(long containerBookingId, long taskId, long operatorId, String operatorName) { Runnable completion = () -> { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java index eca3c10c2..960dcb8b1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java @@ -35,6 +35,8 @@ public class MeasurementBatchService { /** 已接收第三方测量数据,待平台提交审核。 */ static final String STATUS_RECEIVED = "RECEIVED"; static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167"); + private static final BigDecimal MAX_RAW_VOLUME_CBM = new BigDecimal("999999999999.999999"); + private static final BigDecimal MAX_TOTAL_VOLUME_CBM = new BigDecimal("99999999999999.9999"); @Resource private InboundMeasurementRepository repository; @Resource @@ -140,7 +142,16 @@ public class MeasurementBatchService { BigDecimal unitVolume = weighted.unitVolumeCbm(); BigDecimal packageCount = BigDecimal.valueOf(source.packageCount()); BigDecimal totalVolumeRaw = unitVolumeRaw.multiply(packageCount).setScale(6, RoundingMode.HALF_UP); - BigDecimal totalVolume = unitVolume.multiply(packageCount).setScale(4, RoundingMode.UNNECESSARY); + BigDecimal totalVolume; + try { + totalVolume = unitVolume.multiply(packageCount).setScale(4, RoundingMode.UNNECESSARY); + } catch (ArithmeticException exception) { + throw measurementRangeExceeded(source); + } + if (totalVolumeRaw.compareTo(MAX_RAW_VOLUME_CBM) > 0 + || totalVolume.compareTo(MAX_TOTAL_VOLUME_CBM) > 0) { + throw measurementRangeExceeded(source); + } InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); item.setInboundDetailId(source.inboundDetailId()); @@ -169,6 +180,13 @@ public class MeasurementBatchService { }).toList(); } + private static BizException measurementRangeExceeded(MeasurementBatchCommand.Item source) { + return new BizException(ResultCode.VALIDATION_FAILED, + "箱规尺寸或箱数超出系统允许范围,明细 " + source.itemNo() + ":" + + source.lengthCm() + " × " + source.widthCm() + " × " + source.heightCm() + + " cm,箱数 " + source.packageCount()); + } + private void applyTotals(InboundMeasurementEntity measurement, List items) { long packages = items.stream().mapToLong(InboundMeasurementItemEntity::getPackageCount).sum(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java index 87d608bca..d913613d4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java @@ -9,9 +9,9 @@ import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest; import com.baiyue.firstleg.dto.PriceBookDtos.EstimateResponse; import com.baiyue.firstleg.dto.PriceBookChannelValidation.Response; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -26,7 +26,7 @@ import java.util.Map; public class MerchantPriceBookService { public static final String DISCLAIMER = "仅供参考,不含附加费,最终以实际结算为准"; - @Resource private PriceBookQueryRepository repository; + @Resource private PriceBookQueryUseCase repository; @Resource private ActivePriceBookEstimator estimator; public List channels(long merchantTenantId, long originWarehouseId) { diff --git a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql index f37916125..0f47efd54 100644 --- a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql +++ b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql @@ -8,11 +8,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '关联海运订舱ID', container_booking_id BIGINT UNSIGNED NULL COMMENT '指定查询的柜次ID;整票订舱查询时为空', container_scope_id BIGINT UNSIGNED GENERATED ALWAYS AS (COALESCE(container_booking_id,0)) STORED COMMENT '柜次唯一作用域计算值;整票订舱查询固定为0', - carrier_code VARCHAR(16) NOT NULL COMMENT '船公司编码:MATSON美森、SML森罗、ZIM以星、COSCO中远海运、EMC长荣、HEDE合德', + carrier_code VARCHAR(16) NOT NULL COMMENT '船公司标准编码;OTHER表示人工跟踪', tracking_type VARCHAR(32) NOT NULL COMMENT '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号', tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码', official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照', - access_mode VARCHAR(32) NOT NULL COMMENT '船司访问方式:PUBLIC_FORM、PUBLIC_WEB_PROXY、PUBLIC_HTML、HCAPTCHA、OFFICIAL_API', + access_mode VARCHAR(32) NOT NULL COMMENT '船司访问方式;MANUAL表示仅人工补录节点', status VARCHAR(24) NOT NULL COMMENT '任务操作状态:READY、MANUAL_REQUIRED、STOPPED', trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL', latest_standard_node VARCHAR(32) NULL COMMENT '最近实际标准船节点;预计事实不推进', @@ -54,11 +54,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( CONSTRAINT fk_carrier_schedule_query_result FOREIGN KEY (result_port_schedule_id) REFERENCES fl_port_schedule(id), CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id > 0), CONSTRAINT chk_carrier_schedule_query_carrier CHECK - (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE')), + (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')), CONSTRAINT chk_carrier_schedule_query_tracking CHECK (tracking_type IN ('BOOKING_NO','BILL_OF_LADING','CONTAINER_NO')), CONSTRAINT chk_carrier_schedule_query_access CHECK - (access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API')), + (access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API','MANUAL')), CONSTRAINT chk_carrier_schedule_query_status CHECK (status IN ('READY','MANUAL_REQUIRED','STOPPED')), CONSTRAINT chk_carrier_schedule_query_node CHECK diff --git a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql index 6512d8b88..3e9a30339 100644 --- a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql +++ b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql @@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', shipping_company_id BIGINT UNSIGNED NULL COMMENT '船公司逻辑ID快照', shipping_company_name VARCHAR(128) NULL COMMENT '船公司名称快照', + carrier_code VARCHAR(16) NULL COMMENT '船司标准编码:MATSON/SML/ZIM/COSCO/EMC/HEDE/OTHER;发布前必填', booking_agent_id BIGINT UNSIGNED NULL COMMENT '订舱代理逻辑ID快照', booking_agent_name VARCHAR(128) NULL COMMENT '订舱代理名称快照', vessel_name VARCHAR(128) NULL COMMENT '当前确认船名快照', @@ -42,6 +43,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( CONSTRAINT chk_ocean_booking_customer CHECK (customer_id > 0), CONSTRAINT chk_ocean_booking_status CHECK (status IN ('DRAFT','RELEASED')), + CONSTRAINT chk_ocean_booking_carrier_code CHECK + (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')), CONSTRAINT chk_ocean_booking_schedule CHECK (estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at) ) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载'; diff --git a/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql b/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql index 552d13600..1ee186d2b 100644 --- a/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql +++ b/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql @@ -70,7 +70,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = NEW.container_booking_id; IF current_plan_status = 'COMPLETED' - AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可新增'; END IF; END$$ @@ -84,7 +84,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.container_booking_id; IF current_plan_status = 'COMPLETED' - AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可修改'; END IF; END$$ diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index ab4efc31c..5033d5924 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -1122,7 +1122,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = NEW.container_booking_id; IF current_plan_status = 'COMPLETED' - AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; END IF; END$$ @@ -1138,7 +1138,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.container_booking_id; IF current_plan_status = 'COMPLETED' - AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改'; END IF; END$$ diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index 4f6dd8f73..5fac3399f 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -18,7 +18,7 @@ paths: - {name: customerId, in: query, schema: {type: integer, format: int64, minimum: 1}} - {name: oceanBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}} - {name: containerBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}} - - {name: carrierCode, in: query, schema: {$ref: '#/components/schemas/CarrierCode'}} + - {name: carrierCode, in: query, schema: {$ref: '#/components/schemas/TaskCarrierCode'}} - {name: status, in: query, schema: {$ref: '#/components/schemas/QueryStatus'}} - {name: expired, in: query, schema: {type: boolean}} - {name: keyword, in: query, schema: {type: string, maxLength: 128}} @@ -82,7 +82,9 @@ paths: schema: {$ref: '#/components/schemas/ManualNodeRequest'} responses: '200': - description: Idempotent fact append; only actualAt advances and actual EMPTY_RETURNED stops + description: Idempotent fact append; estimated facts never advance actual progress, while a manual actual fact must have a strictly greater standard-node sequence than latestActualNode and actual EMPTY_RETURNED stops + '409': + description: A0409 when expectedVersion is stale or a manual actual node is equal to or earlier than latestActualNode content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}} components: parameters: @@ -90,6 +92,10 @@ components: IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}} schemas: CarrierCode: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]} + TaskCarrierCode: + type: string + enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER] + description: OTHER is frozen on the ocean booking and creates a manual-only task after outbound confirmation. TrackingType: {type: string, enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]} QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, STOPPED]} StandardNode: @@ -118,7 +124,7 @@ components: ManualNodeRequest: type: object required: [expectedVersion, standardNode, observedAt, reason] - description: estimatedAt and actualAt cannot both be null; estimatedAt never advances the latest actual node. + description: estimatedAt and actualAt cannot both be null; estimatedAt never advances the latest actual node; when actualAt is present, standardNode must have a strictly greater sequence than latestActualNode. properties: expectedVersion: {type: integer, minimum: 0} standardNode: {$ref: '#/components/schemas/StandardNode'} @@ -137,11 +143,15 @@ components: customerId: {type: integer, format: int64} oceanBookingId: {type: integer, format: int64} containerBookingId: {type: integer, format: int64, nullable: true} - carrierCode: {$ref: '#/components/schemas/CarrierCode'} + carrierCode: {$ref: '#/components/schemas/TaskCarrierCode'} trackingType: {$ref: '#/components/schemas/TrackingType'} trackingNo: {type: string} officialUrl: {type: string, nullable: true} - accessMode: {type: string, nullable: true} + accessMode: + type: string + enum: [PUBLIC_FORM, PUBLIC_WEB_PROXY, PUBLIC_HTML, HCAPTCHA, OFFICIAL_API, MANUAL] + nullable: true + description: MANUAL tasks preserve physical outbound confirmation and accept platform manual node facts without scheduler/provider calls. status: {$ref: '#/components/schemas/QueryStatus'} triggerSource: {type: string} latestActualNode: {allOf: [{$ref: '#/components/schemas/StandardNode'}], nullable: true} @@ -150,7 +160,10 @@ components: sourceUpdatedAt: {type: string, format: date-time, nullable: true} expiresAt: {type: string, format: date-time, nullable: true} resultPortScheduleId: {type: integer, format: int64, nullable: true} - manualReason: {type: string, nullable: true} + manualReason: + type: string + nullable: true + description: Explicit provider-disabled or unsupported-carrier reason; unsupported outbound carriers retain the booking shippingCompanyId/name here and through oceanBookingId. lastErrorCode: {type: string, nullable: true} lastErrorMessage: {type: string, nullable: true} stoppedAt: {type: string, format: date-time, nullable: true} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 652cd8003..6b4dc5e8e 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -356,6 +356,16 @@ paths: required: true content: {application/json: {schema: {$ref: '#/components/schemas/ContainerDocumentAttachRequest'}}} responses: {'200': {description: 成功、文件归属不匹配或版本冲突}} + /api/platform/v1/containers/{id}/clearance/document/{fileId}/download: + get: + summary: 下载货柜目的港清关附件 + description: 需要 platform:firstleg:customs:manage 权限;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存 + parameters: + - {$ref: '#/components/parameters/ContainerBookingId'} + - {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': {description: 附件二进制内容,包含 Content-Disposition 与 Cache-Control no-store} + '404': {description: 当前附件不存在或归属不匹配} /api/platform/v1/containers/{id}/clearance/document/upload: post: summary: 直接选择文件上传或替换货柜目的港清关资料 diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 59c5fbc0d..18b436f15 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -99,6 +99,29 @@ paths: content: {application/json: {schema: {$ref: '#/components/schemas/DocumentAttachRequest'}}} responses: '200': {$ref: '#/components/responses/BusinessDocumentResponse'} + /api/platform/v1/first-leg/loading-lists/{planId}/customs/document/{fileId}/download: + get: + summary: 下载装柜清单出口报关附件 + description: 完成态仍可下载。服务端校验 planId、warehouseId、当前 CUSTOMS_DECLARATION 的 fileId,以及 File 元数据 tenantId=0、businessType=LOADING_CUSTOMS_DECLARATION、businessId=planId;错误 fileId 或归属返回 A0404。响应使用 attachment 文件名并禁止缓存。 + x-permission: platform:firstleg:loading-list:list + parameters: + - {$ref: '#/components/parameters/PlanId'} + - {$ref: '#/components/parameters/WarehouseId'} + - name: fileId + in: path + required: true + description: File 服务长整型文件 ID + schema: {$ref: '#/components/schemas/LongId'} + responses: + '200': + description: 报关附件二进制内容;包含 Content-Disposition,Cache-Control 为 no-store + headers: + Content-Disposition: {schema: {type: string}} + Cache-Control: {schema: {type: string, example: no-store}} + content: + application/octet-stream: + schema: {type: string, format: binary} + '404': {description: 装柜计划、仓库、当前附件或 File 归属不匹配} /api/platform/v1/first-leg/loading-lists/{planId}/customs/document/upload: post: summary: 直接选择文件上传或替换装柜清单出口报关文档 diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 363e22b73..85d652016 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -2,7 +2,12 @@ openapi: 3.0.3 info: {title: Ocean Booking API, version: 2.0.0} paths: /api/platform/v1/ocean-bookings: - get: {summary: List DRAFT and RELEASED bookings, responses: {'200': {description: Booking page}}} + get: + summary: List DRAFT and RELEASED bookings + description: containerCount is the sum of quantity across all container requirements for the booking. It is zero when no requirement exists and never represents linked or ACTIVE containers. + responses: + '200': + description: Booking page; each row uses the frozen requirement-total containerCount semantic post: summary: Create an editable DRAFT description: Creates only the booking aggregate and container requirements; no containerNo, container row, or allocation is created. @@ -28,6 +33,23 @@ paths: parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}} responses: {'200': {description: RELEASED detail}} + /api/platform/v1/ocean-bookings/link-candidates: + get: + summary: Page released booking requirement candidates for one container + description: Requires platform:firstleg:ocean-booking:list. Returns only linkable requirement candidates: RELEASED, remainingQuantity greater than zero, and exact warehouseId, channelCode and containerType. Capacity and max weight are derived display snapshots and never eligibility fields. Allocated quantity counts only ACTIVE containers still pointing to the same booking requirement and is calculated by one bounded set query; booking detail calls are not used. An empty result is a normal successful page. Final target-container mutability is revalidated by the link command. + x-permission: platform:firstleg:ocean-booking:list + parameters: + - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} + - {name: channelCode, in: query, required: true, schema: {type: string, maxLength: 64}} + - {name: containerType, in: query, required: true, schema: {type: string, maxLength: 32}} + - {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}} + - {name: size, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}} + responses: + '200': + description: Business envelope containing a bounded candidate page + content: + application/json: + schema: {$ref: '#/components/schemas/LinkCandidateResponse'} /api/platform/v1/ocean-bookings/channels: get: summary: List ACTIVE channels for an origin warehouse @@ -58,6 +80,11 @@ components: billOfLadingNo: {type: string, nullable: true} shippingCompanyId: {type: integer, format: int64, nullable: true} shippingCompanyName: {type: string, nullable: true} + carrierCode: + type: string + enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER] + nullable: true + description: Frozen standard carrier code. Required before RELEASE; OTHER means manual carrier-node tracking. bookingAgentId: type: integer format: int64 @@ -94,6 +121,46 @@ components: channelName: {type: string} priceModel: {type: string} pricingTaxModes: {type: array, items: {type: string}} + LinkCandidate: + type: object + required: [oceanBookingId, bookingNo, requirementId, containerType, capacityCbm, maxWeightKg, requiredQuantity, allocatedQuantity, remainingQuantity, customerName, warehouseName, channelName] + properties: + oceanBookingId: {type: string, pattern: '^[0-9]+$'} + bookingNo: {type: string} + requirementId: {type: string, pattern: '^[0-9]+$'} + containerType: {type: string} + capacityCbm: {type: number} + maxWeightKg: {type: number} + requiredQuantity: {type: integer, minimum: 1} + allocatedQuantity: {type: integer, format: int64, minimum: 0} + remainingQuantity: {type: integer, format: int64, minimum: 1} + customerName: {type: string} + warehouseName: {type: string} + channelName: {type: string} + LinkCandidatePage: + type: object + required: [records, page, size, total, pages] + properties: + records: {type: array, items: {$ref: '#/components/schemas/LinkCandidate'}} + page: {type: integer, format: int64, minimum: 1} + size: {type: integer, format: int64, minimum: 1} + total: {type: integer, format: int64, minimum: 0} + pages: {type: integer, format: int64, minimum: 0} + LinkCandidateResponse: + type: object + required: [code, data] + properties: + code: {type: string, example: '200'} + message: {type: string, nullable: true} + data: {$ref: '#/components/schemas/LinkCandidatePage'} + OceanBookingListRow: + type: object + properties: + containerCount: + type: integer + format: int64 + minimum: 0 + description: Sum of all container requirement quantities; independent of allocation, linking or unlinking. Detail: type: object required: [oceanBooking, parties, containerRequirements, containers, histories] diff --git a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml index 2b7420a4d..47bf7403f 100644 --- a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml @@ -36,6 +36,12 @@ paths: description: '权限:platform:firstleg:container:list;Firstleg代理File内部下载,不向调用方暴露内部令牌' parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}] responses: {'200': {description: file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}} + /api/platform/v1/containers/{id}/clearance/document/{fileId}/download: + get: + operationId: downloadContainerDestinationClearance + description: '权限:platform:firstleg:customs:manage;校验当前 DESTINATION_CLEARANCE fileId 以及 File tenantId=0、businessType=CONTAINER_CLEARANCE_DOCUMENT、businessId=id;返回 attachment 与 no-store,完成态仍可下载' + parameters: [{$ref: '#/components/parameters/ContainerId'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}] + responses: {'200': {description: clearance file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}, '404': {description: current file or ownership mismatch}} components: parameters: ContainerId: {name: id, in: path, required: true, schema: {type: integer, format: int64}} @@ -51,7 +57,7 @@ components: container: {$ref: '#/components/schemas/Container'} loadingPlanId: {type: integer, format: int64} pda: {$ref: '#/components/schemas/PdaSummary'} - documents: {type: array, items: {$ref: '#/components/schemas/Document'}} + documents: {type: array, description: '按 documentType 稳定排序返回 DESTINATION_CLEARANCE、CUSTOMS_DECLARATION、DELIVERY_ORDER、PICKUP_ORDER 的当前唯一记录和真实 version', items: {$ref: '#/components/schemas/Document'}} exception: {$ref: '#/components/schemas/ExceptionState'} exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}} timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}} @@ -59,7 +65,7 @@ components: PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}} PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}} Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}} - Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer}, completed: {type: boolean}}} + Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string, enum: [DESTINATION_CLEARANCE, CUSTOMS_DECLARATION, DELIVERY_ORDER, PICKUP_ORDER]}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer, description: '当前唯一记录的真实乐观锁版本;更新时必须原样作为 expectedVersion'}, completed: {type: boolean}}} ExceptionState: {type: object, properties: {active: {type: boolean}, reason: {type: string}, markedBy: {type: integer, format: int64}, markedByName: {type: string}, markedAt: {type: string, format: date-time}}} ExceptionHistory: {type: object, properties: {id: {type: integer, format: int64}, action: {type: string, enum: [MARK, CANCEL]}, reason: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}} TimelineEntry: {type: object, properties: {eventType: {type: string}, detail: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}} diff --git a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml index 8553a2767..364c0b142 100644 --- a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml @@ -1,85 +1,117 @@ openapi: 3.0.3 info: - title: Phase 3 Domestic Outbound Dispatch API - version: 1.0.0 + title: Phase 3 Container-level Domestic Outbound Dispatch API + version: 2.0.0 paths: /api/platform/v1/first-leg/outbound-dispatches: get: - summary: Query outbound aggregates by order, inbound or container + summary: Query the outbound aggregate for one container booking + description: Returns one container-level aggregate containing every real inbound binding. No per-inbound partial outbound aggregate is exposed. + x-permission: platform:firstleg:outbound-dispatch:list parameters: - - {$ref: '#/components/parameters/OrderId'} - - {$ref: '#/components/parameters/InboundId'} - - {$ref: '#/components/parameters/ContainerBookingId'} + - {$ref: '#/components/parameters/ContainerBookingIdQuery'} responses: '200': - description: Business envelope containing matching aggregates, each with stable cabinetNo and real containerNo + description: Business envelope containing a single matching aggregate in a list content: application/json: schema: type: object properties: data: {type: array, items: {$ref: '#/components/schemas/Aggregate'}} - /api/platform/v1/first-leg/outbound-dispatches/detail: + /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}: get: - summary: Query one aggregate and its unified timeline + summary: Query one container-level aggregate and unified timeline + x-permission: platform:firstleg:outbound-dispatch:view parameters: - - {$ref: '#/components/parameters/OrderId'} - - {$ref: '#/components/parameters/InboundId'} - - {$ref: '#/components/parameters/ContainerBookingId'} + - {$ref: '#/components/parameters/ContainerBookingIdPath'} responses: '200': - description: Business envelope containing aggregate detail + description: Business envelope containing all inbound bindings and one visible dispatch version content: application/json: schema: type: object properties: - data: - type: object - properties: - aggregate: {$ref: '#/components/schemas/Aggregate'} - /api/platform/v1/first-leg/outbound-dispatches/check-gate: + data: {$ref: '#/components/schemas/Detail'} + /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/gate: get: - summary: Evaluate the fail-closed domestic outbound gate + summary: Evaluate every inbound binding in the fail-closed container outbound gate + description: Loading evidence is evaluated once for the physical container/loading plan and the same result applies to every inbound binding. The customs gate is resolved once from the current container CUSTOMS_DECLARATION whose completedAt must be non-null; upload alone remains blocked and fl_customs_case is not an alternative gate fact. A missing standalone sailing schedule is reference-only and never affects gate.passed. + x-permission: platform:firstleg:outbound-dispatch:check parameters: - - {$ref: '#/components/parameters/OrderId'} - - {$ref: '#/components/parameters/InboundId'} - - {$ref: '#/components/parameters/ContainerBookingId'} + - {$ref: '#/components/parameters/ContainerBookingIdPath'} responses: - '200': {description: Business envelope containing every gate item} - /api/platform/v1/first-leg/outbound-dispatches/confirm-dispatch: + '200': {description: Business envelope containing the container gate and per-inbound gate results} + /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/confirm: post: - summary: Confirm domestic dispatch and enter WAITING_FOR_LOADING + summary: Atomically confirm domestic departure for the entire container + description: Locks every real inbound binding, requires every gate to pass, advances all technical rows in one transaction, records one visible history, and creates the CarrierScheduleQuery/DEPARTED_WAREHOUSE fact once. No partial success is possible. + x-permission: platform:firstleg:outbound-dispatch:confirm parameters: + - {$ref: '#/components/parameters/ContainerBookingIdPath'} - {$ref: '#/components/parameters/IdempotencyKey'} requestBody: required: true content: application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}} responses: - '200': {description: Business envelope; gate or version failures use business error code} - /api/platform/v1/first-leg/outbound-dispatches/reverse-dispatch: + '200': {description: Same key and same payload replays the single container-level result; stale version or changed payload returns A0409} + /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/reverse: post: - summary: Reverse WAITING_FOR_LOADING only before maritime tracking starts - description: Once an actual DEPARTED_WAREHOUSE node exists, reversal is rejected with business code A0409 so READY_FOR_DISPATCH cannot coexist with maritime tracking. + summary: Atomically reverse the entire container before any actual shipping node exists + description: Once an actual DEPARTED_WAREHOUSE node exists, reversal is rejected with business code A0409 so an outbound fact can never coexist with READY_FOR_DISPATCH. + x-permission: platform:firstleg:outbound-dispatch:reverse parameters: + - {$ref: '#/components/parameters/ContainerBookingIdPath'} - {$ref: '#/components/parameters/IdempotencyKey'} requestBody: required: true content: application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}} responses: - '200': {description: Business envelope containing reverted aggregate} + '200': {description: Business envelope containing the reverted container aggregate} components: parameters: - OrderId: {name: orderId, in: query, schema: {type: integer, format: int64, minimum: 1}} - InboundId: {name: inboundId, in: query, schema: {type: integer, format: int64, minimum: 1}} - ContainerBookingId: {name: containerBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}} - IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} + ContainerBookingIdQuery: + name: containerBookingId + in: query + required: true + schema: {type: integer, format: int64, minimum: 1} + ContainerBookingIdPath: + name: containerBookingId + in: path + required: true + schema: {type: integer, format: int64, minimum: 1} + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: {type: string, maxLength: 128} schemas: Aggregate: type: object - required: [orderId, orderNo, inboundId, inboundCode, tenantId, inboundStatus, containerBookingId, cabinetNo, loadingPlanId, loadingPlanStatus, dispatchStatus, version, gate] + required: [containerBookingId, cabinetNo, loadingPlanId, loadingPlanStatus, dispatchStatus, version, inbounds, gate] + properties: + containerBookingId: {type: integer, format: int64} + cabinetNo: {type: string, description: Stable cabinet number} + containerNo: {type: string, nullable: true, description: Real container number} + loadingPlanId: {type: integer, format: int64} + loadingPlanStatus: {type: string} + oceanBookingId: {type: integer, format: int64, nullable: true} + oceanBookingStatus: {type: string, nullable: true, description: Booking reference retained independently from the non-blocking standalone sailing schedule} + truckAppointmentId: {type: integer, format: int64, nullable: true, description: Reference-only truck appointment; absence never blocks manual outbound confirmation} + truckAppointmentStatus: {type: string, nullable: true, description: Reference-only status; PENDING or any non-terminal state never affects gate.passed} + dispatchStatus: {type: string, description: One visible container-level status} + version: {type: integer, minimum: 0, description: One optimistic-lock version shared by all technical inbound rows} + inbounds: + type: array + minItems: 1 + items: {$ref: '#/components/schemas/InboundBinding'} + gate: {$ref: '#/components/schemas/ContainerGateResult'} + InboundBinding: + type: object + required: [orderId, orderNo, inboundId, inboundCode, tenantId, inboundStatus] properties: orderId: {type: integer, format: int64} orderNo: {type: string} @@ -89,25 +121,35 @@ components: customerId: {type: integer, format: int64, nullable: true} customerName: {type: string, nullable: true} inboundStatus: {type: string} - containerBookingId: {type: integer, format: int64} - cabinetNo: {type: string, description: 稳定柜次号} - containerNo: {type: string, nullable: true, description: 真实集装箱号} - loadingPlanId: {type: integer, format: int64} - loadingPlanStatus: {type: string} pickTaskId: {type: integer, format: int64, nullable: true} pickTaskStatus: {type: string, nullable: true} - oceanBookingId: {type: integer, format: int64, nullable: true} - oceanBookingStatus: {type: string, nullable: true} - truckAppointmentId: {type: integer, format: int64, nullable: true} - truckAppointmentStatus: {type: string, nullable: true} - dispatchStatus: {type: string} - version: {type: integer, minimum: 0} - gate: {type: object} + ContainerGateResult: + type: object + required: [passed, inbounds, checkedAt] + properties: + passed: {type: boolean} + inbounds: + type: array + items: + type: object + required: [inboundId, inboundCode, gate] + properties: + inboundId: {type: integer, format: int64} + inboundCode: {type: string} + gate: {type: object} + checkedAt: {type: string, format: date-time} + Detail: + type: object + required: [aggregate, timeline] + properties: + aggregate: {$ref: '#/components/schemas/Aggregate'} + timeline: + type: array + description: Unified immutable facts; a container confirm or reverse contributes one visible history item. + items: {type: object} ActionRequest: type: object - required: [inboundId, containerBookingId, expectedVersion] + required: [expectedVersion] properties: - inboundId: {type: integer, format: int64, minimum: 1} - containerBookingId: {type: integer, format: int64, minimum: 1} expectedVersion: {type: integer, minimum: 0} remark: {type: string, maxLength: 500} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java index 2b9409161..c9ca3a8af 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java @@ -21,6 +21,7 @@ class BaiyueFirstlegApplicationContractTest { .contains("com.baiyue.firstleg.portschedule.mapper") .contains("com.baiyue.firstleg.customs.mapper") .contains("com.baiyue.firstleg.customerstatistics.adapter.out.persistence") + .contains("com.baiyue.firstleg.pricebookquery.adapter.out.persistence") .doesNotContain("com.baiyue.firstleg.customerstatistics.mapper"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java index d94c7e2b7..33327afb2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java @@ -33,6 +33,7 @@ class ContainerControllerContractTest { assertPermission("logisticsDocument", "platform:firstleg:container:list"); assertPermission("uploadLogisticsDocument", "platform:firstleg:container:edit"); assertPermission("downloadDocument", "platform:firstleg:container:list"); + assertPermission("downloadClearance", "platform:firstleg:customs:manage"); } @Test void overviewOpenApiFreezesContainerIdBasedPdaContract() throws Exception { @@ -40,6 +41,8 @@ class ContainerControllerContractTest { .getContentAsString(StandardCharsets.UTF_8); assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:", "/documents/{documentType}/{fileId}/download:", "ExceptionRequest", + "/clearance/document/{fileId}/download:", + "DESTINATION_CLEARANCE、CUSTOMS_DECLARATION、DELIVERY_ORDER、PICKUP_ORDER", "enum: [DELIVERY_ORDER, PICKUP_ORDER]", "Idempotency-Key", "photoCounts:", "/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:", "AWAITING_REVIEW", "version: {type: integer, minimum: 0}", @@ -77,6 +80,8 @@ class ContainerControllerContractTest { .doesNotContain("tenantId") .doesNotContain("accountId"); assertThat(openapi) + .contains("/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:") + .contains("Content-Disposition 与 Cache-Control no-store") .contains("required: [oceanBookingId, requirementId, containerNo, expectedVersion]") .contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus") .contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index d228c6137..28be884a7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -16,6 +16,8 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.http.ResponseEntity; import org.springframework.web.multipart.MultipartFile; import java.lang.reflect.Method; @@ -41,6 +43,8 @@ class LoadingListControllerContractTest { ConfirmedQuery.class); authority("confirmedDetail", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class); authority("declaration", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class); + authority("downloadDeclaration", "platform:firstleg:loading-list:list", AuthenticatedUser.class, + long.class, long.class, long.class); authority("attachDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class, long.class, long.class, String.class, DocumentAttachRequest.class); authority("uploadDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class, @@ -88,6 +92,8 @@ class LoadingListControllerContractTest { .contains("oceanBookingStatus") .contains("pdaTaskId") .contains("cabinetNo") + .contains("LOADING_CUSTOMS_DECLARATION") + .contains("Cache-Control 为 no-store") .doesNotContain("internalContainerNo") .doesNotContain("rollback") .doesNotContain("delete:") @@ -118,6 +124,18 @@ class LoadingListControllerContractTest { assertThat(method.getParameters()[6].getAnnotation(RequestPart.class)).isNull(); } + @Test + void customsDownloadUsesStablePlanPathAndBinaryResponse() throws Exception { + Method method = PlatformLoadingListController.class.getDeclaredMethod("downloadDeclaration", + AuthenticatedUser.class, long.class, long.class, long.class); + assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly( + "/loading-lists/{planId}/customs/document/{fileId}/download"); + assertThat(method.getReturnType()).isEqualTo(ResponseEntity.class); + assertThat(method.getParameters()[1].getName()).isEqualTo("planId"); + assertThat(method.getParameters()[2].getName()).isEqualTo("fileId"); + assertThat(method.getParameters()[3].getName()).isEqualTo("warehouseId"); + } + @Test void yamlParses() throws Exception { String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml") @@ -134,6 +152,7 @@ class LoadingListControllerContractTest { "/api/platform/v1/first-leg/loading-lists/{planId}", "/api/platform/v1/first-leg/loading-lists/{planId}/customs", "/api/platform/v1/first-leg/loading-lists/{planId}/customs/document", + "/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/{fileId}/download", "/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/upload", "/api/platform/v1/first-leg/loading-lists/{planId}/customs/complete"); Map components = stringMap(document.get("components")); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java index cccb0a153..5a9a269a2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java @@ -6,7 +6,7 @@ import com.baiyue.firstleg.domain.PriceModel; import com.baiyue.firstleg.domain.PricingTaxMode; import com.baiyue.firstleg.domain.PricingDestinationType; import com.baiyue.firstleg.dto.PriceBookDtos; -import com.baiyue.firstleg.mapper.PriceBookQueryMapper; +import com.baiyue.firstleg.pricebookquery.adapter.out.persistence.PriceBookQueryMapper; import org.apache.ibatis.annotations.Select; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java index 6695ea5d8..dc499e353 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.customs.service; -import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; import com.baiyue.firstleg.customs.repository.CustomsRepository; +import com.baiyue.firstleg.customs.mapper.CustomsCaseMapper; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -22,17 +22,36 @@ class CustomsDispatchGateContributorTest { .isEqualTo("INVALID_CUSTOMER_SCOPE"); } - @Test void passesOnlyCanonicalClearanceCompletedCase() { - CustomsCaseEntity value = new CustomsCaseEntity(); - value.setStatus("RECEIPT_AVAILABLE"); - when(repository.gateCase(10001L, 21L, 31L, 81L)).thenReturn(value); - assertThat(contributor.assess(context()).passed()).isFalse(); - value.setStatus("CLEARANCE_COMPLETED"); - assertThat(contributor.assess(context())) - .satisfies(item -> { - assertThat(item.passed()).isTrue(); - assertThat(item.requirement()).isEqualTo("CLEARANCE_COMPLETED"); - }); + @Test void uploadedButIncompleteDeclarationRemainsBlocked() { + when(repository.loadingDeclarationCompleted(81L)).thenReturn(false); + + assertThat(contributor.assess(context())).satisfies(item -> { + assertThat(item.passed()).isFalse(); + assertThat(item.actual()).isEqualTo("MISSING"); + assertThat(item.requirement()).isEqualTo("COMPLETED"); + }); + } + + @Test void completedLoadingDeclarationPassesWithoutReadingCustomsCase() { + when(repository.loadingDeclarationCompleted(81L)).thenReturn(true); + + assertThat(contributor.assess(context())).satisfies(item -> { + assertThat(item.passed()).isTrue(); + assertThat(item.actual()).isEqualTo("COMPLETED"); + assertThat(item.requirement()).isEqualTo("COMPLETED"); + }); + verify(repository).loadingDeclarationCompleted(81L); + verify(repository, never()).gateCase(anyLong(), anyLong(), anyLong(), anyLong()); + } + + @Test void mapperUsesOnlyCompletedLoadingDeclarationDocumentFact() throws Exception { + String sql = String.join(" ", CustomsCaseMapper.class + .getMethod("completedLoadingDeclaration", long.class) + .getAnnotation(org.apache.ibatis.annotations.Select.class).value()); + assertThat(sql) + .contains("FROM fl_container_document", "container_booking_id=#{containerBookingId}") + .contains("document_type='CUSTOMS_DECLARATION'", "completed_at IS NOT NULL") + .doesNotContain("fl_customs_case", "CLEARANCE_COMPLETED"); } private static Context context() { return new Context(10001L, 10002L, 21L, 31L, 81L, 91L, null); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java index df43c1be5..df0b2fca1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java @@ -20,6 +20,7 @@ class PlatformOceanBookingControllerContractTest { .containsExactly("/api/platform/v1/ocean-bookings"); Map permissions = Map.ofEntries( Map.entry("page", "platform:firstleg:ocean-booking:list"), + Map.entry("linkCandidates", "platform:firstleg:ocean-booking:list"), Map.entry("channels", "platform:firstleg:ocean-booking:list"), Map.entry("detail", "platform:firstleg:ocean-booking:list"), Map.entry("create", "platform:firstleg:ocean-booking:create"), @@ -30,7 +31,8 @@ class PlatformOceanBookingControllerContractTest { Method method = method(name); assertThat(method.getAnnotation(PreAuthorize.class).value()) .isEqualTo("hasAuthority('" + permission + "')"); - if (!name.equals("page") && !name.equals("detail") && !name.equals("channels")) { + if (!name.equals("page") && !name.equals("detail") && !name.equals("channels") + && !name.equals("linkCandidates")) { assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull(); } }); @@ -57,6 +59,43 @@ class PlatformOceanBookingControllerContractTest { .doesNotContain("change-request", "change-confirm", "/cancel:", "containers[].containerNo"); } + @Test + void linkCandidatesUsesDedicatedGetPathAndStablePageContract() throws Exception { + Method method = method("linkCandidates"); + assertThat(method.getAnnotation(org.springframework.web.bind.annotation.GetMapping.class).value()) + .containsExactly("/link-candidates"); + String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")); + assertThat(yaml) + .contains("/api/platform/v1/ocean-bookings/link-candidates:") + .contains("x-permission: platform:firstleg:ocean-booking:list") + .contains("warehouseId", "channelCode", "containerType") + .contains("LinkCandidateResponse", "requiredQuantity", "allocatedQuantity", "remainingQuantity") + .contains("Capacity and max weight are derived display snapshots and never eligibility fields") + .contains("Returns only linkable requirement candidates") + .contains("An empty result is a normal successful page") + .contains("booking detail calls are not used") + .doesNotContain("mismatchReasons", "disabledReason"); + } + + @Test + void openApiDefinesContainerCountAsRequirementQuantityTotal() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")); + assertThat(yaml) + .contains("containerCount is the sum of quantity across all container requirements") + .contains("never represents linked or ACTIVE containers") + .contains("Sum of all container requirement quantities; independent of allocation, linking or unlinking."); + } + + @Test + void openApiFreezesStandardCarrierCodeOnBooking() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")); + assertThat(yaml) + .contains("carrierCode:") + .contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER]") + .contains("Required before RELEASE") + .contains("OTHER means manual carrier-node tracking"); + } + private static Method method(String name) { return Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods()) .filter(method -> method.getName().equals(name)).findFirst().orElseThrow(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java index 050195e76..d4557b21e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java @@ -1,6 +1,8 @@ package com.baiyue.firstleg.oceanbooking.repository; import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingMapper; +import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingContainerAllocationMapper; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; import org.junit.jupiter.api.Test; @@ -20,7 +22,10 @@ class OceanBookingMapperContractTest { .contains("booking.status=#{query.status}") .contains("booking.booking_no LIKE") .contains("party.party_type='SHIPPER'", "party.party_type='CONSIGNEE'") - .contains("fl_ocean_booking_container_allocation", "container.record_status='ACTIVE'", "container_count") + .contains("COALESCE((SELECT SUM(requirement.quantity)") + .contains("FROM fl_ocean_booking_container_requirement requirement") + .contains("requirement.ocean_booking_id=booking.id),0) container_count") + .doesNotContain("fl_ocean_booking_container_allocation allocation\n JOIN fl_container_booking") .contains("ORDER BY booking.updated_at DESC,booking.id DESC"); } @@ -36,4 +41,39 @@ class OceanBookingMapperContractTest { .contains("status='DRAFT' AND version=#{version}") .doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED"); } + + @Test + void linkCandidatesUsesOneBoundedSetQueryAndExactRemainingSemantics() throws Exception { + String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectLinkCandidates", + com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, LinkCandidateQuery.class) + .getAnnotation(Select.class).value()); + assertThat(sql) + .contains("booking.status='RELEASED'") + .contains("booking.warehouse_id=#{query.warehouseId}") + .contains("booking.channel_code=#{query.channelCode}") + .contains("requirement.container_type=#{query.containerType}") + .contains("FROM fl_ocean_booking_container_allocation") + .contains("container.record_status='ACTIVE'") + .contains("container.ocean_booking_id=allocation.ocean_booking_id") + .contains("container.ocean_booking_requirement_id=allocation.requirement_id") + .contains("GROUP BY allocation.ocean_booking_id,allocation.requirement_id") + .contains("requirement.quantity-COALESCE(allocated.allocated_quantity,0) remaining_quantity") + .contains("requirement.quantity>COALESCE(allocated.allocated_quantity,0)") + .doesNotContain("SELECT booking.*", + "requirement.capacity_cbm=#{query.capacityCbm}", + "requirement.max_weight_kg=#{query.maxWeightKg}"); + } + + @Test + void finalLinkLimitCountsOnlyActiveContainersStillPointingAtTheSameRequirement() throws Exception { + String sql = String.join("\n", OceanBookingContainerAllocationMapper.class + .getDeclaredMethod("countActiveLinked", long.class, long.class) + .getAnnotation(Select.class).value()); + assertThat(sql) + .contains("container.record_status='ACTIVE'") + .contains("container.ocean_booking_id=allocation.ocean_booking_id") + .contains("container.ocean_booking_requirement_id=allocation.requirement_id") + .contains("allocation.ocean_booking_id=#{oceanBookingId}") + .contains("allocation.requirement_id=#{requirementId}"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java index 9f2df34e0..5d73959ff 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java @@ -24,6 +24,8 @@ class OceanBookingSqlContractTest { .contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'") .contains("service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填'") .contains("freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填'") + .contains("carrier_code VARCHAR(16) NULL COMMENT '船司标准编码") + .contains("carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')") .contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'") .contains("REFERENCES fl_container_booking(id)") .contains("REFERENCES fl_ocean_booking(id) ON DELETE CASCADE") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index 06019bc33..2b846c7ba 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -1,6 +1,7 @@ package com.baiyue.firstleg.oceanbooking.service; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.WarehouseMasterDataClient; import com.baiyue.firstleg.client.WarehouseSupplierClient; @@ -9,8 +10,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 +34,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; @@ -170,6 +171,60 @@ class OceanBookingServiceTest { verify(repository, times(2)).page(any()); } + @Test + void pagePreservesRequirementTotalContainerCountsForSingleMultipleAndEmptyDrafts() { + OceanBookingEntity one45Hc = booking("RELEASED", 1); one45Hc.setContainerCount(1L); + OceanBookingEntity multipleTypes = booking("RELEASED", 1); multipleTypes.setId(2L); + multipleTypes.setContainerCount(4L); + OceanBookingEntity emptyDraft = booking("DRAFT", 0); emptyDraft.setId(3L); + emptyDraft.setContainerCount(0L); + when(repository.page(any())).thenReturn(new PageResult<>( + List.of(one45Hc, multipleTypes, emptyDraft), 1, 20, 3, 1)); + + PageResult result = service.page(new Query(null, null, null, 1, 20)); + + assertThat(result.records()).extracting(OceanBookingEntity::getContainerCount) + .containsExactly(1L, 4L, 0L); + } + + @Test + void linkCandidatesDefaultsPaginationAndDelegatesOneNormalizedSetQuery() { + LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HC", + new BigDecimal("76"), new BigDecimal("28000"), 3, 1, 2, + "客户A", "深圳中转仓", "美西海运"); + when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1)); + + PageResult result = service.linkCandidates(new LinkCandidateQuery(701L, + " SEA-US ", " 40HC ", null, null)); + + assertThat(result.records()).containsExactly(candidate); + assertThat(result.records().get(0).remainingQuantity()).isEqualTo(2); + ArgumentCaptor query = ArgumentCaptor.forClass(LinkCandidateQuery.class); + verify(repository).linkCandidates(query.capture()); + assertThat(query.getValue().channelCode()).isEqualTo("SEA-US"); + assertThat(query.getValue().containerType()).isEqualTo("40HC"); + assertThat(query.getValue().page()).isEqualTo(1L); + assertThat(query.getValue().size()).isEqualTo(20L); + verify(repository, never()).find(anyLong()); + verify(repository, never()).requirements(anyLong()); + verify(repository, never()).containers(anyLong()); + } + + @Test + void linkCandidatesReturnsNormalEmptyPageWithOneSetQueryAndNoDiagnosticFields() { + when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + + PageResult result = service.linkCandidates( + new LinkCandidateQuery(701L, "SEA-US", "45HC", null, null)); + + assertThat(result.records()).isEmpty(); + assertThat(result.total()).isZero(); + assertThat(result.pages()).isZero(); + verify(repository, times(1)).linkCandidates(any()); + assertThat(LinkCandidate.class.getRecordComponents()).extracting(java.lang.reflect.RecordComponent::getName) + .doesNotContain("mismatchReasons", "disabledReason", "warehouseId", "channelCode"); + } + @Test void releaseRejectsInvalidScheduleWithoutTransition() { OceanBookingEntity draft = completeBooking("DRAFT", 3); @@ -204,12 +259,12 @@ class OceanBookingServiceTest { } private static DraftData partialDraft(String remark) { - return new DraftData(null, null, null, null, null, null, null, null, null, + return new DraftData(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, remark, null, null); } private static DraftData completeDraft() { - return new DraftData(100L, "客户", "SEA-US", 701L, "BL-001", 71L, "船公司", + return new DraftData(100L, "客户", "SEA-US", 701L, "BL-001", 71L, "船公司", "MATSON", 81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶", "LAX", "CY-CY", "PREPAID", LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 20, 0, 0), null, @@ -224,6 +279,7 @@ class OceanBookingServiceTest { OceanBookingEntity value = booking(status, version); value.setCustomerId(100L); value.setCustomerName("客户"); value.setChannelCode("SEA-US"); value.setWarehouseId(701L); value.setBillOfLadingNo("BL-001"); value.setShippingCompanyId(71L); + value.setCarrierCode("MATSON"); value.setBookingAgentId(81L); value.setVesselName("VESSEL"); value.setVoyageNo("V001"); value.setServiceTerm("CY-CY"); value.setFreightTerm("PREPAID"); value.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java index d4f2eebfd..1efcea3a3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java @@ -10,9 +10,25 @@ class OutboundDispatchReadMapperContractTest { @Test void bindingProjectsStableCabinetAndRealContainerNumbersSeparately() throws Exception { String sql = String.join("\n", OutboundDispatchReadMapper.class - .getMethod("bindings", Long.class, Long.class, Long.class) + .getMethod("bindings", long.class) .getAnnotation(Select.class).value()); - assertThat(sql).contains("booking.cabinet_no cabinetNo", "booking.container_no containerNo"); + assertThat(sql) + .contains("booking.cabinet_no cabinetNo", "booking.container_no containerNo") + .contains("booking.id=#{containerBookingId}") + .doesNotContain("#{orderId}", "#{inboundId}"); + } + + @Test + void actionQueryLocksEveryRealInboundBindingForTheContainer() throws Exception { + String sql = String.join("\n", OutboundDispatchReadMapper.class + .getMethod("bindingsForUpdate", long.class) + .getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("booking.id=#{containerBookingId}") + .contains("item.deleted=0", "inbound.deleted=0") + .contains("FOR UPDATE") + .doesNotContain("LIMIT 1"); } @Test @@ -31,19 +47,37 @@ class OutboundDispatchReadMapperContractTest { .getAnnotation(Select.class).value()[0]; assertThat(sql) - .contains("seal_no<>''") + .contains("evidence.seal_no<>''") .doesNotContain("<", ">"); } @Test - void oceanAndTruckQueriesUseTenantIsolationKey() throws Exception { - String oceanSql = OutboundDispatchReadMapper.class.getMethod("ocean", long.class, long.class) - .getAnnotation(Select.class).value()[0]; - String truckSql = OutboundDispatchReadMapper.class.getMethod("truck", long.class, long.class, long.class) + void evidenceGateAggregatesOnePhysicalEvidenceSetAcrossTheWholeLoadingPlan() throws Exception { + String sql = OutboundDispatchReadMapper.class.getMethod("evidence", long.class, long.class) .getAnnotation(Select.class).value()[0]; - assertThat(oceanSql).contains("booking.customer_id=#{tenantId}").doesNotContain("#{customerId}"); - assertThat(truckSql).contains("customer_id=#{tenantId}").doesNotContain("#{customerId}"); + assertThat(sql) + .contains("plan.id=#{loadingPlanId}", "plan.container_booking_id=#{containerBookingId}") + .contains("item.loading_plan_id=plan.id", "item.deleted=0") + .contains("evidence.inbound_id=item.inbound_id", "evidence.tenant_id=item.account_id") + .contains("COUNT(DISTINCT CASE", "evidence.evidence_type") + .doesNotContain("WHERE tenant_id=#{tenantId}", "inbound_id=#{inboundId}"); + } + + @Test + void oceanAndTruckQueriesAreResolvedOnceFromTheContainerRoot() throws Exception { + String oceanSql = OutboundDispatchReadMapper.class.getMethod("ocean", long.class) + .getAnnotation(Select.class).value()[0]; + String truckSql = OutboundDispatchReadMapper.class.getMethod("truck", long.class, long.class) + .getAnnotation(Select.class).value()[0]; + + assertThat(oceanSql) + .contains("allocation.container_booking_id=#{containerBookingId}") + .contains("booking.customer_id customerId") + .doesNotContain("#{tenantId}"); + assertThat(truckSql) + .contains("container_booking_id=#{containerBookingId}", "loading_plan_id=#{loadingPlanId}") + .doesNotContain("#{tenantId}"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java index 8c4bf583f..09c38aef4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java @@ -9,9 +9,12 @@ import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper; import com.baiyue.firstleg.outbound.dispatch.repository.OutboundDispatchRepository; import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService; import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.LongStream; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -19,159 +22,264 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class OutboundDispatchServiceTest { - private final OutboundDispatchRepository repository = mock(OutboundDispatchRepository.class); + private OutboundDispatchRepository repository; + + @BeforeEach + void setUp() { + repository = mock(OutboundDispatchRepository.class); + when(repository.timeline(any(), any(), any())).thenReturn(List.of()); + } @Test - void gateFailsClosedWhenCustomsAndScheduleModulesAreUnavailable() { - OutboundDispatchService service = new OutboundDispatchService(repository, List.of()); - stubReadyChain(); + void containerEvidenceFailureFailsEveryBindingConsistently() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1)); - GateResult result = service.checkGate(new Query(null, 11L, 22L)); + ContainerGateResult result = service.checkGate(22L); assertThat(result.passed()).isFalse(); - assertThat(result.items()).filteredOn(item -> !item.passed()) - .extracting(GateItem::code).containsExactlyInAnyOrder("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE"); + assertThat(result.inbounds()).hasSize(2); + assertThat(result.inbounds()).allSatisfy(inbound -> assertThat(inbound.gate().items()) + .filteredOn(item -> "LOADING_EVIDENCE_COMPLETE".equals(item.code())) + .extracting(GateItem::passed).containsExactly(false)); + verify(repository, times(1)).evidence(22L, 44L); } @Test - void aggregateUsesTenantKeyWhenCustomerEntityIdDiffers() { - OutboundDispatchService service = new OutboundDispatchService(repository, List.of()); - stubReadyChain(); + void tenInboundBindingsShareOneCompleteEvidenceSetAndOneContainerCustomsResult() { + OutboundDispatchGateContributor customs = mock(OutboundDispatchGateContributor.class); + when(customs.code()).thenReturn("CUSTOMS_CLEARANCE"); + when(customs.assess(any())).thenReturn(new GateItem( + "CUSTOMS_CLEARANCE", "报关完成", true, "CLEARANCE_COMPLETED", "CLEARANCE_COMPLETED")); + OutboundDispatchService service = new OutboundDispatchService(repository, List.of(customs), null); + List tenBindings = LongStream.rangeClosed(11L, 20L) + .mapToObj(id -> binding(id, "I-" + id)).toList(); + stubContainer(tenBindings); - service.checkGate(new Query(null, 11L, 22L)); + ContainerGateResult result = service.checkGate(22L); - verify(repository).ocean(22L, 7L); - verify(repository).truck(22L, 44L, 7L); - verify(repository, never()).ocean(22L, 8L); - verify(repository, never()).truck(22L, 44L, 8L); + assertThat(result.passed()).isTrue(); + assertThat(result.inbounds()).hasSize(10).allSatisfy(value -> assertThat(value.gate().passed()).isTrue()); + verify(repository, times(1)).evidence(22L, 44L); + verify(customs, times(1)).assess(any()); } @Test - void confirmAdvancesDispatchWithoutMutatingLoadedInbound() { - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE"))); - stubReadyChain(); - OutboundDispatchEntity state = new OutboundDispatchEntity(); - state.setId(33L); state.setTenantId(7L); state.setOrderId(10L); state.setInboundId(11L); - state.setContainerBookingId(22L); state.setStatus("READY_FOR_DISPATCH"); state.setVersion(0); - when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); - when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-1"), anyString())).thenReturn(true); - when(repository.confirm(33L, 0, 9L, "admin", "ok")).thenReturn(1); - when(repository.completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1")).thenReturn(1); - when(repository.timeline(any(), any(), any())).thenReturn(List.of()); + void confirmAllowsMissingTruckAppointmentWhenEveryAuthoritativeGatePasses() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.truck(22L, 44L)).thenReturn(null); + stubConfirmMutation("no-truck"); - service.confirm(9L, "admin", "confirm-1", new ActionRequest(11L, 22L, 0, "ok")); + Detail result = service.confirm(22L, 9L, "admin", "no-truck", new ActionRequest(0, null)); - verify(repository).confirm(33L, 0, 9L, "admin", "ok"); - verify(repository).history(argThat(history -> "CONFIRM_DISPATCH".equals(history.getAction()) - && "WAITING_FOR_LOADING".equals(history.getToStatus()))); - verify(repository).completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1"); + assertThat(result.aggregate().truckAppointmentId()).isNull(); + assertThat(result.aggregate().gate().passed()).isTrue(); + verify(repository, times(2)).confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull()); } @Test - void confirmCreatesCanonicalCarrierScheduleTaskInTheSameTransaction() { + void confirmAllowsPendingTruckAppointmentWhenEveryAuthoritativeGatePasses() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.truck(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "PENDING")); + stubConfirmMutation("pending-truck"); + + Detail result = service.confirm(22L, 9L, "admin", "pending-truck", new ActionRequest(0, null)); + + assertThat(result.aggregate().truckAppointmentStatus()).isEqualTo("PENDING"); + assertThat(result.aggregate().gate().passed()).isTrue(); + verify(repository, times(2)).confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull()); + } + + @Test + void confirmAllowsMissingSailingScheduleContributor() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + stubConfirmMutation("no-schedule"); + + Detail result = service.confirm(22L, 9L, "admin", "no-schedule", new ActionRequest(0, null)); + + assertThat(result.aggregate().gate().passed()).isTrue(); + assertThat(result.aggregate().gate().inbounds()).allSatisfy(inbound -> assertThat(inbound.gate().items()) + .extracting(GateItem::code).doesNotContain("SAILING_SCHEDULE")); + } + + @Test + void detailReturnsOneVersionAndAllInboundBindings() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + OutboundDispatchEntity first = state(31L, 11L, "WAITING_FOR_LOADING", 3); + OutboundDispatchEntity second = state(32L, 12L, "WAITING_FOR_LOADING", 3); + when(repository.dispatch(7L, 11L, 22L)).thenReturn(first); + when(repository.dispatch(7L, 12L, 22L)).thenReturn(second); + + Detail detail = service.detail(22L); + + assertThat(detail.aggregate().inbounds()).extracting(InboundBinding::inboundId).containsExactly(11L, 12L); + assertThat(detail.aggregate().dispatchStatus()).isEqualTo("WAITING_FOR_LOADING"); + assertThat(detail.aggregate().version()).isEqualTo(3); + } + + @Test + void confirmLocksAndAdvancesAllBindingsButWritesOneHistoryAndOneShippingFact() { CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class); - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")), carrierSchedules); - stubReadyChain(); - OutboundDispatchEntity state = new OutboundDispatchEntity(); - state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); - state.setContainerBookingId(22L); state.setStatus("READY_FOR_DISPATCH"); state.setVersion(0); - when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); - when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-carrier"), anyString())).thenReturn(true); - when(repository.confirm(33L, 0, 9L, "admin", null)).thenReturn(1); - when(repository.completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-carrier")).thenReturn(1); - when(repository.timeline(any(), any(), any())).thenReturn(List.of()); + OutboundDispatchService service = service(carrierSchedules); + stubContainer(); + when(repository.lockBindings(22L)).thenReturn(bindings()); + OutboundDispatchEntity first = state(31L, 11L, "READY_FOR_DISPATCH", 0); + OutboundDispatchEntity second = state(32L, 12L, "READY_FOR_DISPATCH", 0); + when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(first); + when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(second); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-container"), anyString())).thenReturn(true); + when(repository.confirm(anyLong(), eq(0), eq(9L), eq("admin"), eq("ok"))).thenReturn(1); + when(repository.completeOperation(31L, 9L, "CONFIRM_DISPATCH", "confirm-container")).thenReturn(1); - service.confirm(9L, "admin", "confirm-carrier", new ActionRequest(11L, 22L, 0, null)); + service.confirm(22L, 9L, "admin", "confirm-container", new ActionRequest(0, "ok")); - verify(carrierSchedules).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin"); + verify(repository).lockBindings(22L); + verify(repository).confirm(31L, 0, 9L, "admin", "ok"); + verify(repository).confirm(32L, 0, 9L, "admin", "ok"); + verify(repository, times(1)).history(argThat(value -> "CONFIRM_DISPATCH".equals(value.getAction()) + && value.getContainerBookingId().equals(22L))); + verify(carrierSchedules, times(1)).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin"); } @Test - void confirmNeverWritesStateWhenAnyRealGateFails() { - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE"))); - stubReadyChain(); - when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "LOADING")); - when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-2"), anyString())).thenReturn(true); + void confirmDoesNotWriteAnyRowWhenOneInboundGateFails() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.lockBindings(22L)).thenReturn(bindings()); + when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1)); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("blocked"), anyString())).thenReturn(true); - assertThatThrownBy(() -> service.confirm(9L, "admin", "confirm-2", - new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class).hasMessageContaining("门禁"); + assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "blocked", new ActionRequest(0, null))) + .isInstanceOf(BizException.class).hasMessageContaining("全部入库绑定"); + + verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any()); + verify(repository, never()).history(any()); + } + + @Test + void differingTechnicalRowVersionsAreRejectedAsContainerConflict() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.lockBindings(22L)).thenReturn(bindings()); + when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "READY_FOR_DISPATCH", 0)); + when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "READY_FOR_DISPATCH", 1)); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("mixed"), anyString())).thenReturn(true); + + assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "mixed", new ActionRequest(0, null))) + .isInstanceOf(BizException.class).hasMessageContaining("版本不一致"); verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any()); } @Test - void reverseIsRejectedAfterActualDepartedWarehouseFactExists() { + void actualDepartedWarehouseFactBlocksContainerReverseBeforeAnyRowChanges() { CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class); - OutboundDispatchService service = new OutboundDispatchService(repository, List.of(), carrierSchedules); - stubReadyChain(); - OutboundDispatchEntity state = new OutboundDispatchEntity(); - state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); state.setContainerBookingId(22L); - state.setStatus("WAITING_FOR_LOADING"); state.setVersion(1); - when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); + OutboundDispatchService service = service(carrierSchedules); + stubContainer(); + when(repository.lockBindings(22L)).thenReturn(bindings()); + when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "WAITING_FOR_LOADING", 1)); + when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "WAITING_FOR_LOADING", 1)); when(repository.claim(eq(9L), eq("REVERSE_DISPATCH"), eq("reverse-blocked"), anyString())).thenReturn(true); when(carrierSchedules.hasOutboundDepartureFact(7L, 66L, 22L)).thenReturn(true); - assertThatThrownBy(() -> service.reverse(9L, "admin", "reverse-blocked", - new ActionRequest(11L, 22L, 1, "误操作"))) - .isInstanceOf(BizException.class).hasMessageContaining("DEPARTED_WAREHOUSE"); + assertThatThrownBy(() -> service.reverse(22L, 9L, "admin", "reverse-blocked", + new ActionRequest(1, "误操作"))).isInstanceOf(BizException.class) + .hasMessageContaining("DEPARTED_WAREHOUSE"); verify(repository, never()).reverse(anyLong(), anyInt(), anyLong(), anyString(), any()); } @Test - void selectorsThatMatchMultipleContainerBindingsRequireDisambiguation() { - OutboundDispatchService service = new OutboundDispatchService(repository, List.of()); - when(repository.bindings(any())).thenReturn(List.of(binding(), binding())); - assertThatThrownBy(() -> service.detail(new Query(10L, null, null))) - .isInstanceOf(BizException.class).hasMessageContaining("多个出仓聚合"); + void sameIdempotencyKeyAndPayloadReplaysContainerResultWithoutMutation() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + AtomicReference requestHash = new AtomicReference<>(); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("replay"), anyString())).thenAnswer(invocation -> { + requestHash.set(invocation.getArgument(3)); + return false; + }); + OutboundDispatchOperationEntity existing = new OutboundDispatchOperationEntity(); + existing.setStatus("SUCCESS"); + when(repository.operation(9L, "CONFIRM_DISPATCH", "replay")).thenAnswer(invocation -> { + existing.setRequestHash(requestHash.get()); + return existing; + }); + + Detail replay = service.confirm(22L, 9L, "admin", "replay", new ActionRequest(0, null)); + + assertThat(replay.aggregate().containerBookingId()).isEqualTo(22L); + verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any()); } @Test - void staleDispatchVersionIsRejectedWithoutPartialInboundAdvance() { - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE"))); - stubReadyChain(); - OutboundDispatchEntity state = new OutboundDispatchEntity(); - state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); state.setContainerBookingId(22L); - state.setStatus("READY_FOR_DISPATCH"); state.setVersion(2); - when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); - when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("stale"), anyString())).thenReturn(true); - - assertThatThrownBy(() -> service.confirm(9L, "admin", "stale", - new ActionRequest(11L, 22L, 1, null))).isInstanceOf(BizException.class).hasMessageContaining("刷新"); - } - - @Test - void reusedIdempotencyKeyWithDifferentHashIsRejected() { - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE"))); - stubReadyChain(); + void reusedIdempotencyKeyWithDifferentPayloadIsRejected() { + OutboundDispatchService service = serviceWithoutCarrier(); when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("reused"), anyString())).thenReturn(false); OutboundDispatchOperationEntity existing = new OutboundDispatchOperationEntity(); - existing.setRequestHash("different"); existing.setStatus("SUCCESS"); + existing.setRequestHash("different"); + existing.setStatus("SUCCESS"); when(repository.operation(9L, "CONFIRM_DISPATCH", "reused")).thenReturn(existing); - assertThatThrownBy(() -> service.confirm(9L, "admin", "reused", - new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class) - .hasMessageContaining("用于不同请求"); + assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "reused", new ActionRequest(0, null))) + .isInstanceOf(BizException.class).hasMessageContaining("用于不同请求"); } - private void stubReadyChain() { - when(repository.bindings(any())).thenReturn(List.of(binding())); + private OutboundDispatchService serviceWithoutCarrier() { + return service(null); + } + + private OutboundDispatchService service(CarrierScheduleQueryService carrierSchedules) { + return new OutboundDispatchService(repository, + List.of(passing("CUSTOMS_CLEARANCE")), carrierSchedules); + } + + private void stubContainer() { + stubContainer(bindings()); + } + + private void stubContainer(List values) { + when(repository.bindings(22L)).thenReturn(values); when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "COMPLETED")); - when(repository.ocean(22L, 7L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(66L, "RELEASED")); - when(repository.truck(22L, 44L, 7L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED")); - when(repository.evidence(7L, 11L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1)); + when(repository.ocean(22L)).thenReturn(new OutboundDispatchReadMapper.OceanBinding(66L, "RELEASED", 7L)); + when(repository.truck(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED")); + when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1)); } - private static OutboundDispatchReadMapper.Binding binding() { - return new OutboundDispatchReadMapper.Binding(10L, "O-10", 11L, "I-11", 7L, 8L, "客户A", + private void stubConfirmMutation(String key) { + when(repository.lockBindings(22L)).thenReturn(bindings()); + when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "READY_FOR_DISPATCH", 0)); + when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "READY_FOR_DISPATCH", 0)); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq(key), anyString())).thenReturn(true); + when(repository.confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull())).thenReturn(1); + when(repository.completeOperation(31L, 9L, "CONFIRM_DISPATCH", key)).thenReturn(1); + } + + private static List bindings() { + return List.of(binding(11L, "I-11"), binding(12L, "I-12")); + } + + private static OutboundDispatchReadMapper.Binding binding(long inboundId, String inboundCode) { + return new OutboundDispatchReadMapper.Binding(10L, "O-10", inboundId, inboundCode, 7L, 8L, "客户A", "LOADED", 22L, "CAB-22", "C-22", 44L, "COMPLETED"); } + private static OutboundDispatchEntity state(long id, long inboundId, String status, int version) { + OutboundDispatchEntity value = new OutboundDispatchEntity(); + value.setId(id); + value.setTenantId(7L); + value.setOrderId(10L); + value.setInboundId(inboundId); + value.setContainerBookingId(22L); + value.setStatus(status); + value.setVersion(version); + return value; + } + private static OutboundDispatchGateContributor passing(String code) { return new OutboundDispatchGateContributor() { @Override public String code() { return code; } @@ -180,4 +288,5 @@ class OutboundDispatchServiceTest { } }; } + } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java index e587f38f1..0480e784e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java @@ -38,17 +38,25 @@ class OutboundDispatchSqlContractTest { } @Test - void openApiPublishesSelectorsGateAndBothVersionedActions() throws Exception { + void openApiPublishesOnlyContainerLevelGateDetailAndVersionedActions() throws Exception { String openApi = resource("openapi/phase3-outbound-dispatch-v1.yaml"); assertThat(openApi) .contains("outbound-dispatches:") - .contains("outbound-dispatches/detail:") - .contains("outbound-dispatches/check-gate:") - .contains("outbound-dispatches/confirm-dispatch:") - .contains("outbound-dispatches/reverse-dispatch:") + .contains("outbound-dispatches/{containerBookingId}:") + .contains("outbound-dispatches/{containerBookingId}/gate:") + .contains("outbound-dispatches/{containerBookingId}/confirm:") + .contains("outbound-dispatches/{containerBookingId}/reverse:") + .contains("every real inbound binding", "No partial success is possible") .contains("Once an actual DEPARTED_WAREHOUSE node exists") + .contains("absence never blocks manual outbound confirmation") + .contains("PENDING or any non-terminal state never affects gate.passed") + .contains("Loading evidence is evaluated once for the physical container/loading plan") + .contains("CUSTOMS_DECLARATION whose completedAt must be non-null") + .contains("fl_customs_case is not an alternative gate fact") + .contains("missing standalone sailing schedule is reference-only") .contains("expectedVersion") - .contains("Idempotency-Key"); + .contains("Idempotency-Key") + .doesNotContain("confirm-dispatch", "reverse-dispatch", "check-gate", "inboundId, containerBookingId"); } private static String migration() throws Exception { return resource("db/phase3-outbound-dispatch-schema.sql"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/PlatformOutboundDispatchControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/PlatformOutboundDispatchControllerContractTest.java new file mode 100644 index 000000000..83af6945e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/PlatformOutboundDispatchControllerContractTest.java @@ -0,0 +1,65 @@ +package com.baiyue.firstleg.outbound.dispatch; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.firstleg.outbound.dispatch.controller.PlatformOutboundDispatchController; +import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.ActionRequest; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformOutboundDispatchControllerContractTest { + @Test + void exposesOnlyContainerLevelResourcePathsAndExistingPermissions() { + assertThat(PlatformOutboundDispatchController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/api/platform/v1/first-leg/outbound-dispatches"); + Map permissions = Map.of( + "list", "platform:firstleg:outbound-dispatch:list", + "detail", "platform:firstleg:outbound-dispatch:view", + "checkGate", "platform:firstleg:outbound-dispatch:check", + "confirm", "platform:firstleg:outbound-dispatch:confirm", + "reverse", "platform:firstleg:outbound-dispatch:reverse"); + permissions.forEach((name, permission) -> assertThat(method(name).getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('" + permission + "')")); + + assertThat(method("detail").getAnnotation(GetMapping.class).value()).containsExactly("/{containerBookingId}"); + assertThat(method("checkGate").getAnnotation(GetMapping.class).value()) + .containsExactly("/{containerBookingId}/gate"); + assertThat(method("confirm").getAnnotation(PostMapping.class).value()) + .containsExactly("/{containerBookingId}/confirm"); + assertThat(method("reverse").getAnnotation(PostMapping.class).value()) + .containsExactly("/{containerBookingId}/reverse"); + } + + @Test + void actionBodyContainsOnlyContainerVersionAndRemarkAndAuditsContainerRoot() { + assertThat(Arrays.stream(ActionRequest.class.getRecordComponents()).map(value -> value.getName())) + .containsExactly("expectedVersion", "remark") + .doesNotContain("inboundId", "containerBookingId"); + for (String name : List.of("confirm", "reverse")) { + BusinessAudit audit = method(name).getAnnotation(BusinessAudit.class); + assertThat(audit.resourceType()).isEqualTo("CONTAINER_BOOKING"); + assertThat(audit.resourceId()).isEqualTo("#containerBookingId"); + } + } + + @Test + void everyNonListOperationUsesOnlyContainerBookingIdPathTarget() { + for (String name : List.of("detail", "checkGate", "confirm", "reverse")) { + assertThat(Arrays.stream(method(name).getParameters()) + .filter(value -> value.isAnnotationPresent(PathVariable.class)) + .map(value -> value.getName())).containsExactly("containerBookingId"); + } + } + + private static Method method(String name) { + return Arrays.stream(PlatformOutboundDispatchController.class.getDeclaredMethods()) + .filter(value -> value.getName().equals(name)).findFirst().orElseThrow(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java index 563955f74..9c3fccd91 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java @@ -24,6 +24,8 @@ class CarrierScheduleQueryOpenApiContractTest { .contains("enum: [READY, MANUAL_REQUIRED, STOPPED]") .contains("latestActualNode:") .contains("latestActualAt:") + .contains("manual actual node is equal to or earlier than latestActualNode") + .contains("strictly greater sequence than latestActualNode") .contains("internal execution fields are intentionally absent") .doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED") .doesNotContain("/{queryId}/attempts:") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java index ed8d27e52..1c8463923 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java @@ -63,6 +63,8 @@ class CarrierScheduleQuerySqlContractTest { .contains("'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP'") .contains("provider_event_id") .contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'") + .contains("'MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'") + .contains("'OFFICIAL_API','MANUAL'") .contains("lease_owner") .contains("consecutive_failures") .doesNotContain("status IN ('READY','MANUAL_REQUIRED','ARRIVED'"); @@ -102,11 +104,14 @@ class CarrierScheduleQuerySqlContractTest { .contains("ADD COLUMN next_allowed_at DATETIME(3) NULL") .contains("ADD KEY idx_carrier_schedule_query_due (status,next_allowed_at,id)") .contains("DROP CHECK chk_carrier_schedule_query_access") + .contains("DROP CHECK chk_carrier_schedule_query_carrier") .contains("DROP CHECK chk_carrier_schedule_query_status") .contains("DROP INDEX uk_carrier_schedule_query_scope") .contains("ADD UNIQUE KEY uk_carrier_schedule_query_scope") .contains("(customer_id,ocean_booking_id,container_scope_id)") .contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'") + .contains("'MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'") + .contains("'OFFICIAL_API','MANUAL'") .contains("status IN ('READY','MANUAL_REQUIRED','STOPPED')") .contains("ARRIVED_DESTINATION_PORT") .contains("EMPTY_RETURNED") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java index 04f896a2b..2778ca65e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java @@ -39,4 +39,16 @@ class CarrierScheduleAdapterRegistryTest { assertThatThrownBy(() -> registry.prepare("UNKNOWN", "BOOKING_NO", "X")) .hasMessageContaining("仅支持MATSON"); } + + @Test + void outboundUnknownCarrierFallsBackToManualWithoutPretendingToBeAnotherProvider() { + var result = registry.prepareOutbound("OTHER", "CONTAINER_NO", "CSNU1234567"); + + assertThat(result.carrierCode()).isEqualTo("OTHER"); + assertThat(result.accessMode()).isEqualTo("MANUAL"); + assertThat(result.status()).isEqualTo("MANUAL_REQUIRED"); + assertThat(result.coverageGapCode()).isEqualTo("UNSUPPORTED_CARRIER"); + assertThat(result.officialUrl()).isEmpty(); + assertThat(registry.prepareOutbound(null, "BOOKING_NO", "OB-1").carrierCode()).isEqualTo("OTHER"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java index ee314b6d1..0f5bea2a6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java @@ -61,6 +61,25 @@ class CarrierScheduleNodeOutboxServiceTest { verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)); } + @Test + void initialOutboundDepartureUsesVersionZeroWithoutEnvelopeFailure() throws Exception { + CarrierScheduleQueryEntity query = query(); + query.setVersion(0); + CarrierScheduleNodeFactEntity fact = fact(); + fact.setStandardNode("DEPARTED_WAREHOUSE"); fact.setNodeSequence(10); + fact.setProviderCode("FIRSTLEG"); fact.setProviderEventId("OUTBOUND_CONFIRM"); + fact.setProviderNodeCode("OUTBOUND_CONFIRM"); + + service.publish(query, fact, "CSNU1234567"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(mapper).insert(captor.capture()); + EventEnvelope envelope = objectMapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class); + assertThat(envelope.aggregateVersion()).isZero(); + assertThat(envelope.handoverVersion()).isEqualTo(1L); + assertThat(envelope.payload().path("standardNode").asText()).isEqualTo("DEPARTED_WAREHOUSE"); + } + private static CarrierScheduleQueryEntity query() { CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); value.setId(2088000000000000001L); value.setCustomerId(2088000000000000002L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index e88ed6f9c..78b425d42 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -23,6 +23,7 @@ import org.mockito.Mock; import org.mockito.ArgumentCaptor; import org.mockito.MockitoAnnotations; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.dao.DataIntegrityViolationException; import java.time.Clock; import java.time.Instant; @@ -47,12 +48,14 @@ class CarrierScheduleQueryServiceTest { @Mock private PortScheduleRepository portSchedules; @Mock private CarrierScheduleNodeOutboxService nodeOutbox; @InjectMocks private CarrierScheduleQueryService service; + private CarrierScheduleAdapterRegistry adapters; @BeforeEach void setUp() throws Exception { MockitoAnnotations.openMocks(this); set("objectMapper", new ObjectMapper().findAndRegisterModules()); - set("adapters", new CarrierScheduleAdapterRegistry()); + adapters = new CarrierScheduleAdapterRegistry(); + set("adapters", adapters); set("clock", Clock.fixed(Instant.parse("2026-08-10T04:00:00Z"), ZoneOffset.UTC)); when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString())).thenReturn(1); @@ -85,7 +88,7 @@ class CarrierScheduleQueryServiceTest { @Test void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() { OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); - booking.setShippingCompanyName("中远海运"); booking.setBookingNo("OB-260810-0001"); + booking.setShippingCompanyName("中远海运"); booking.setCarrierCode("COSCO"); booking.setBookingNo("OB-260810-0001"); var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); container.setContainerNo("CSNU1234567"); when(portSchedules.oceanBooking(10L)).thenReturn(booking); @@ -96,12 +99,94 @@ class CarrierScheduleQueryServiceTest { assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM"); assertThat(result.getCarrierCode()).isEqualTo("COSCO"); + assertThat(result.getStatus()).isEqualTo("MANUAL_REQUIRED"); + assertThat(result.getAccessMode()).isEqualTo("OFFICIAL_API"); + assertThat(result.getManualReason()).contains("Cargo Tracking API"); assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO"); assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE"); assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)); } + @Test + void outboundUnknownCarrierCreatesManualTaskAndPreservesBookingCarrierIdentity() { + OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); + booking.setShippingCompanyId(9988L); booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER"); + var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + container.setContainerNo("CSNU1234567"); + when(portSchedules.oceanBooking(10L)).thenReturn(booking); + when(portSchedules.containerBooking(20L)).thenReturn(container); + when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); + + CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin"); + + assertThat(result.getCarrierCode()).isEqualTo("OTHER"); + assertThat(result.getAccessMode()).isEqualTo("MANUAL"); + assertThat(result.getStatus()).isEqualTo("MANUAL_REQUIRED"); + assertThat(result.getManualReason()).contains("carrierCode=OTHER", "shippingCompanyId=9988", "shippingCompanyName=示例船公司"); + assertThat(result.getNextAllowedAt()).isNull(); + assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE"); + } + + @Test + void outboundSupportedCarrierCreatesReadyTask() { + ReflectionTestUtils.setField(adapters, "matsonAutomated", true); + OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); + booking.setShippingCompanyName("美森"); booking.setCarrierCode("MATSON"); + var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + container.setContainerNo("MATU4646247"); + when(portSchedules.oceanBooking(10L)).thenReturn(booking); + when(portSchedules.containerBooking(20L)).thenReturn(container); + when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); + + CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin"); + + assertThat(result.getCarrierCode()).isEqualTo("MATSON"); + assertThat(result.getStatus()).isEqualTo("READY"); + assertThat(result.getAccessMode()).isEqualTo("PUBLIC_WEB_PROXY"); + assertThat(result.getManualReason()).isNull(); + assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + } + + @Test + void outboundReplayKeepsSingleTaskAndSingleDepartureFact() { + OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); + booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER"); + var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + container.setContainerNo("CSNU1234567"); + when(portSchedules.oceanBooking(10L)).thenReturn(booking); + when(portSchedules.containerBooking(20L)).thenReturn(container); + when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); + + CarrierScheduleQueryEntity first = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin"); + when(repository.findByBusinessScope(10001L, 10L, 20L)).thenReturn(first); + CarrierScheduleQueryEntity replay = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin"); + + assertThat(replay).isSameAs(first); + verify(repository, times(1)).insert(any(CarrierScheduleQueryEntity.class)); + verify(repository, times(1)).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class)); + } + + @Test + void outboundPersistenceContractMismatchReturnsExplicitBusinessConflict() { + OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); + booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER"); + var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + container.setContainerNo("CSNU1234567"); + when(portSchedules.oceanBooking(10L)).thenReturn(booking); + when(portSchedules.containerBooking(20L)).thenReturn(container); + when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); + org.mockito.Mockito.doThrow(new DataIntegrityViolationException("check constraint rejected")) + .when(repository).insert(any(CarrierScheduleQueryEntity.class)); + + assertThatThrownBy(() -> service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin")) + .isInstanceOf(BizException.class) + .hasMessageContaining("船节点持久化契约与当前服务不一致") + .hasMessageContaining("QUERY_TASK") + .hasMessageContaining("OTHER/MANUAL"); + verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class)); + } + @Test void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() { CarrierScheduleQueryEntity task = task(0, 0); @@ -207,6 +292,34 @@ class CarrierScheduleQueryServiceTest { verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString()); } + @Test + void manualActualRejectsSameAndEarlierNodesIncludingSameKeyRetryWithoutDrift() { + CarrierScheduleQueryEntity task = task(0, 3); + task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP"); + task.setLatestNodeObservedAt(NOW.minusDays(1)); + when(repository.lock(1L)).thenReturn(task); + + ManualNodeRequest sameNode = new ManualNodeRequest(3, "PICKED_UP", NOW, null, + NOW.plusDays(1), null, "Los Angeles", "重复确认提柜"); + assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-picked-up-again", sameNode)) + .isInstanceOf(BizException.class) + .hasMessage("人工实际节点必须严格晚于当前最新实际节点"); + assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-picked-up-again", sameNode)) + .isInstanceOf(BizException.class) + .hasMessage("人工实际节点必须严格晚于当前最新实际节点"); + assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-pickup-ready-late", + new ManualNodeRequest(3, "PICKUP_READY", NOW, null, NOW.plusDays(2), null, + "Los Angeles", "倒序补录可提柜"))) + .isInstanceOf(BizException.class) + .hasMessage("人工实际节点必须严格晚于当前最新实际节点"); + + verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class)); + verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString()); + verify(repository, never()).insert(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity.class)); + verify(repository, never()).completeOperation(anyLong(), anyLong(), anyString(), anyString()); + verify(nodeOutbox, never()).publish(any(), any(), anyString()); + } + @Test void manualEstimatedFactAdvancesVersionWithoutAdvancingActualProgress() { CarrierScheduleQueryEntity before = task(0, 3); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryArchitectureTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryArchitectureTest.java new file mode 100644 index 000000000..3f9734d86 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryArchitectureTest.java @@ -0,0 +1,67 @@ +package com.baiyue.firstleg.pricebookquery; + +import com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +class PriceBookQueryArchitectureTest { + private static final String FIRSTLEG = "com.baiyue.firstleg.."; + private static final String SLICE = "com.baiyue.firstleg.pricebookquery.."; + private static final String APPLICATION = "com.baiyue.firstleg.pricebookquery.application.."; + private static final String INPUT_PORT = "com.baiyue.firstleg.pricebookquery.application.port.in.."; + private static final String OUTPUT_PORT = "com.baiyue.firstleg.pricebookquery.application.port.out.."; + private static final String MODEL = "com.baiyue.firstleg.pricebookquery.application.model.."; + private static final String PERSISTENCE = "com.baiyue.firstleg.pricebookquery.adapter.out.persistence.."; + + private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS) + .importPackages("com.baiyue.firstleg"); + + @Test + void applicationStaysFreeOfWebAndPersistenceFrameworks() { + noClasses().that().resideInAPackage(APPLICATION) + .should().dependOnClassesThat().resideInAnyPackage( + "org.springframework.web..", + "org.apache.ibatis..", + "com.baomidou.mybatisplus..") + .check(PRODUCTION_CLASSES); + } + + @Test + void applicationOnlyDependsOnItsOwnSliceWithinFirstleg() { + classes().that().resideInAPackage(APPLICATION) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(FIRSTLEG).or(resideInAPackage(APPLICATION))) + .check(PRODUCTION_CLASSES); + } + + @Test + void persistenceAdapterCollaboratesThroughTheOutputPortAndModel() { + classes().that().resideInAPackage(PERSISTENCE) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(SLICE) + .or(resideInAPackage(PERSISTENCE)) + .or(resideInAPackage(OUTPUT_PORT)) + .or(resideInAPackage(MODEL))) + .andShould().beAssignableTo(PriceBookQueryReader.class) + .check(PRODUCTION_CLASSES); + } + + @Test + void consumersOnlySeeTheInputPortAndReadModel() { + noClasses().that().resideOutsideOfPackage(SLICE) + .should().dependOnClassesThat( + resideInAPackage(SLICE) + .and(resideOutsideOfPackage(INPUT_PORT)) + .and(resideOutsideOfPackage(MODEL))) + .check(PRODUCTION_CLASSES); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java new file mode 100644 index 000000000..db339e20a --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java @@ -0,0 +1,46 @@ +package com.baiyue.firstleg.pricebookquery; + +import com.baiyue.firstleg.pricebookquery.application.PriceBookQueryService; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PriceBookQueryServiceTest { + @Mock private PriceBookQueryReader reader; + @InjectMocks private PriceBookQueryService service; + + @Test + void delegatesStableReadOperationsToTheOutputPort() { + ChannelPrefixRow prefix = new ChannelPrefixRow("SEA", "IN", "OUT"); + when(reader.findActiveVersionId()).thenReturn(9L); + when(reader.findChannelPrefix("SEA")).thenReturn(prefix); + + assertThat(service.activeVersionId()).isEqualTo(9L); + assertThat(service.channelPrefix("SEA")).isSameAs(prefix); + verify(reader).findActiveVersionId(); + verify(reader).findChannelPrefix("SEA"); + } + + @Test + void keepsChannelAvailabilityAsApplicationLogic() { + ChannelRow channel = new ChannelRow(); + channel.setChannelCode("SEA"); + when(reader.findChannels(9L, 3L)).thenReturn(List.of(channel)); + + assertThat(service.channelAvailable(9L, 3L, "SEA")).isTrue(); + assertThat(service.channelAvailable(9L, 3L, "AIR")).isFalse(); + verify(reader, org.mockito.Mockito.times(2)).findChannels(9L, 3L); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimatorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimatorTest.java index c1b94e43d..7fdcef6cd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimatorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimatorTest.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.pricing; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import org.junit.jupiter.api.Test; import java.math.BigDecimal; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimatorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimatorTest.java index 06fe39203..6c9af0572 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimatorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimatorTest.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.pricing; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import org.junit.jupiter.api.Test; import java.math.BigDecimal; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java index 68a9ec0f3..16944ca9c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java @@ -53,15 +53,6 @@ class ContainerBookingMapperContractTest { .contains("customer_item.account_id = #{query.customerId}"); } - @Test - @DisplayName("首次关联客户隔离查询使用原生不等号") - void customerMismatchQueryUsesNativeSqlOperator() throws Exception { - String sql = ContainerBookingMapper.class - .getDeclaredMethod("hasMismatchedCustomerItems", long.class, long.class) - .getAnnotation(Select.class).value()[0]; - assertThat(sql).contains("i.account_id<>#{customerId}").doesNotContain("<>"); - } - @Test @DisplayName("关联订舱 SQL 的预计到港参数与 Mapper 参数名一致") void linkReleasedBookingUsesDeclaredEstimatedArrivalParameter() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java index afecb8b9e..b19652ad9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java @@ -12,6 +12,14 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class ContainerOverviewSqlContractTest { + @Test void overviewIncludesExactlyTheFourCurrentBusinessDocumentTypes() throws Exception { + String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("documents", long.class) + .getAnnotation(Select.class).value()); + assertThat(sql) + .contains("'DESTINATION_CLEARANCE'", "'CUSTOMS_DECLARATION'", "'DELIVERY_ORDER'", "'PICKUP_ORDER'") + .contains("ORDER BY document_type"); + } + @Test void pdaSummarySelectsRecordFieldsExplicitlyIncludingTaskVersion() throws Exception { String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("pickTask", long.class) .getAnnotation(Select.class).value()); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java index 11e77ed09..d25d76a32 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -116,6 +116,21 @@ class LoadingListQueryMapperContractTest { .contains("operator_id"); } + @Test + void deliveryAndPickupUseTheCanonicalDocumentInsertAndPlanHistoryMapper() throws Exception { + String documentInsert = sql("insertDocument", Insert.class); + String historyInsert = sql("insertHistory", Insert.class); + assertThat(documentInsert) + .contains("INSERT INTO fl_container_document") + .contains("#{documentType}", "#{fileId}", "#{fileHash}") + .contains("CURRENT_TIMESTAMP(3)", "version") + .doesNotContain("CUSTOMS_DECLARATION", "DESTINATION_CLEARANCE"); + assertThat(historyInsert) + .contains("INSERT INTO fl_loading_plan_history") + .contains("#{planId}", "#{operationId}", "#{actionType}") + .contains("#{beforeSnapshot}", "#{afterSnapshot}"); + } + private static String sql(String methodName, Class annotationType) throws Exception { Method method = java.util.Arrays.stream(LoadingListQueryMapper.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(methodName)) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index 27c6c17a3..e5c1ddc08 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -151,8 +151,8 @@ class SmartLoadingSqlContractTest { .contains("CREATE TRIGGER trg_container_booking_before_update") .contains("current_plan_status IN ('LOADING','COMPLETED')") .contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据") - .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')") - .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')"); + .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')") + .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')"); } private static void assertSimpleCustomsDocumentContract(String sql) { @@ -161,8 +161,8 @@ class SmartLoadingSqlContractTest { .contains("file_hash VARCHAR(64) NULL") .contains("completed_by BIGINT UNSIGNED NULL") .contains("completed_at DATETIME(3) NULL") - .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')") - .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')") + .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')") + .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')") .doesNotContain("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN\n SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java index 0a7ea777f..114968d92 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java @@ -47,7 +47,13 @@ class ContainerOverviewServiceTest { 71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "door.jpg", "ACTIVE", LocalDateTime.now()), new ContainerOverviewMapper.PhotoRow( 72L, 81L, 91L, "LOADING_DOOR_OPEN", 102L, "void.jpg", "VOID", LocalDateTime.now()))); - when(mapper.documents(41L)).thenReturn(java.util.List.of()); + com.baiyue.firstleg.entity.ContainerDocumentEntity customs = new com.baiyue.firstleg.entity.ContainerDocumentEntity(); + customs.setId(103L); customs.setDocumentType("CUSTOMS_DECLARATION"); customs.setFileId(104L); + customs.setFileName("customs.pdf"); customs.setVersion(3); + com.baiyue.firstleg.entity.ContainerDocumentEntity clearance = new com.baiyue.firstleg.entity.ContainerDocumentEntity(); + clearance.setId(105L); clearance.setDocumentType("DESTINATION_CLEARANCE"); clearance.setFileId(106L); + clearance.setFileName("clearance.pdf"); clearance.setVersion(1); + when(mapper.documents(41L)).thenReturn(java.util.List.of(customs, clearance)); when(mapper.exceptions(41L)).thenReturn(java.util.List.of()); when(mapper.transport(41L)).thenReturn(java.util.List.of()); when(mapper.pickTimeline(61L)).thenReturn(java.util.List.of()); @@ -66,6 +72,15 @@ class ContainerOverviewServiceTest { assertThat(count.evidenceType()).isEqualTo("LOADING_DOOR_OPEN"); assertThat(count.count()).isEqualTo(1); }); + assertThat(overview.documents()).extracting(document -> document.documentType()) + .containsExactly("CUSTOMS_DECLARATION", "DESTINATION_CLEARANCE"); + assertThat(overview.documents()).filteredOn(document -> "CUSTOMS_DECLARATION".equals(document.documentType())) + .singleElement().satisfies(document -> assertThat(document.version()).isEqualTo(3)); + assertThat(overview.documents()).filteredOn(document -> "DESTINATION_CLEARANCE".equals(document.documentType())) + .singleElement().satisfies(document -> { + assertThat(document.fileId()).isEqualTo(106L); + assertThat(document.version()).isEqualTo(1); + }); } @Test void downloadsOnlyActivePhotoOwnedByContainerLoadingPlan() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 9ac608ba6..4dba3b293 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -17,8 +17,8 @@ import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper; import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; @@ -51,7 +51,7 @@ class ContainerServiceTest { @MockBean private SmartLoadingRepository smartLoadingRepository; @MockBean private LoadingPickRepository loadingPickRepository; @MockBean private LoadingPickService loadingPickService; - @MockBean private PriceBookQueryRepository priceBooks; + @MockBean private PriceBookQueryUseCase priceBooks; @MockBean private OceanBookingRepository oceanBookings; @MockBean private WarehouseSupplierSnapshotService suppliers; @MockBean private FirstlegDocumentNumberGenerator documentNumbers; @@ -288,7 +288,7 @@ class ContainerServiceTest { void linksReleasedBookingAfterPreloading() { ContainerBookingEntity container = booking("EDITING", 2); container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC"); - container.setCapacityCbm(new BigDecimal("76.0000")); container.setMaxWeightKg(new BigDecimal("28000.000")); + container.setCapacityCbm(new BigDecimal("85.5000")); container.setMaxWeightKg(new BigDecimal("27500.000")); OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US"); OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); @@ -296,7 +296,7 @@ class ContainerServiceTest { when(repository.find(11L)).thenReturn(container, container); when(oceanBookings.find(21L)).thenReturn(booking); when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); - when(oceanBookings.allocations(21L)).thenReturn(List.of()); + when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L); when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU7654321"), same(booking), eq(2), eq(9L), eq("admin"))).thenReturn(1); @@ -311,6 +311,24 @@ class ContainerServiceTest { verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class)); } + @Test + @DisplayName("首次关联拒绝装柜仓库不一致") + void rejectsLinkWhenWarehouseDiffers() { + assertThreeFieldMismatch(102L, "SEA-US", "40HC"); + } + + @Test + @DisplayName("首次关联拒绝物流渠道不一致") + void rejectsLinkWhenChannelDiffers() { + assertThreeFieldMismatch(101L, "SEA-EU", "40HC"); + } + + @Test + @DisplayName("首次关联拒绝柜型不一致") + void rejectsLinkWhenContainerTypeDiffers() { + assertThreeFieldMismatch(101L, "SEA-US", "45HC"); + } + @Test @DisplayName("未发布订舱不可关联") void rejectsDraftBooking() { @@ -333,12 +351,10 @@ class ContainerServiceTest { booking.setId(21L); booking.setStatus("RELEASED"); OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); requirement.setId(31L); requirement.setQuantity(1); - OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity(); - allocation.setRequirementId(31L); when(repository.find(11L)).thenReturn(container); when(oceanBookings.find(21L)).thenReturn(booking); when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); - when(oceanBookings.allocations(21L)).thenReturn(List.of(allocation)); + when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(1L); assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2))) @@ -347,6 +363,28 @@ class ContainerServiceTest { any(), anyInt(), anyLong(), anyString()); } + private void assertThreeFieldMismatch(long bookingWarehouseId, String bookingChannel, + String requirementType) { + ContainerBookingEntity container = booking("WAIT_LOADING", 2); + container.setWarehouseId(101L); container.setChannelCode("SEA-US"); + container.setContainerType("40HC"); + OceanBookingEntity oceanBooking = new OceanBookingEntity(); + oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED"); + oceanBooking.setWarehouseId(bookingWarehouseId); oceanBooking.setChannelCode(bookingChannel); + OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); + requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType(requirementType); + when(repository.find(11L)).thenReturn(container); + when(oceanBookings.find(21L)).thenReturn(oceanBooking); + when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); + when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L); + + assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2))) + .isInstanceOf(BizException.class).hasMessageContaining("仓库、渠道或柜型不一致"); + verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(), + any(), anyInt(), anyLong(), anyString()); + } + @Test @DisplayName("完全空的模拟柜直接解绑并删除当前allocation,不依赖MERGED来源柜") void unlinksEmptySimulationDirectly() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java index 6f2434cf3..33616c9f5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java @@ -4,7 +4,7 @@ import com.baiyue.firstleg.domain.CustomerDimensionBoxType; import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity; import com.baiyue.firstleg.pricing.FbaWarehouseRateEstimator; import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import org.junit.jupiter.api.Test; import java.math.BigDecimal; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 823e35dd3..0cefc7b02 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -11,7 +11,7 @@ import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; import com.baiyue.firstleg.repository.FbaPricingRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 461aca213..50ff2452d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -138,11 +138,30 @@ class LoadingCompletedOutboxServiceTest { } @Test void snapshotOnlyPublishesFinalizedLoadedInbounds() throws Exception { - String sql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class) + String rowsSql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class) + .getAnnotation(Select.class).value()).replaceAll("\\s+", " "); + String palletsSql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("palletRows", long.class) .getAnnotation(Select.class).value()).replaceAll("\\s+", " "); - assertThat(sql).contains("p.plan_status = 'COMPLETED'") + assertThat(rowsSql).contains("p.plan_status = 'COMPLETED'") .contains("o.process_status = 'LOADED'"); + for (String sql : List.of(rowsSql, palletsSql)) { + assertThat(sql) + .contains("actual_pick.loading_plan_item_id = i.id") + .contains("actual_pick.item_status IN ('PICKED','PARTIAL')") + .contains("actual_pick.picked_package_count > 0") + .contains("actual_pick.picked_package_count = i.package_count") + .contains("actual_task.id = actual_pick.pick_task_id") + .contains("actual_task.loading_plan_id =") + .doesNotContain("'SKIPPED'") + .doesNotContain("'PENDING'") + .doesNotContain("'PICKING'"); + } + assertThat(palletsSql) + .contains("label.tenant_id = i.account_id") + .contains("label.inbound_detail_id = i.inbound_detail_id") + .contains("label.batch_status = 'ACTIVE'") + .doesNotContain("label.inbound_id = i.inbound_id"); } @Test void publishesDropshipPalletSkuAndCartonSnapshots() throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index aac76e2e6..212b488d3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -36,7 +36,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -450,16 +452,16 @@ class LoadingListServiceTest { String contentHash = sha256Bytes(content); var file = new MockMultipartFile("file", "customs.pdf", "application/pdf", content); var metadata = new LoadingDocumentFileClient.Metadata(101L, 0L, - "LOADING_CUSTOMS_DOCUMENT", "10", "customs.pdf", contentHash, "application/pdf", + "LOADING_CUSTOMS_DECLARATION", "10", "customs.pdf", contentHash, "application/pdf", (long) content.length); ContainerDocumentEntity saved = document(41L, LoadingListService.CUSTOMS_DECLARATION, 0, false); saved.setFileId(101L); saved.setFileName("customs.pdf"); saved.setFileHash(contentHash); when(repository.aggregate(10L)).thenReturn(plan); when(repository.history(anyString())).thenReturn(null); when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(null, saved); - when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DOCUMENT"), + when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), eq("10"), eq(file))).thenReturn(ApiResponse.success(metadata)); - when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DOCUMENT"), + when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), eq("10"), eq(101L))).thenReturn(ApiResponse.success(metadata)); when(repository.createDocument(org.mockito.ArgumentMatchers.any())).thenReturn(1); @@ -467,10 +469,108 @@ class LoadingListServiceTest { 10L, 100L, 9L, "operator", "customs-upload", 0, null, file); assertThat(result.documentType()).isEqualTo(LoadingListService.CUSTOMS_DECLARATION); - verify(documentFiles).upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DOCUMENT"), + verify(documentFiles).upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), eq("10"), eq(file)); } + @Test + void downloadsCompletedLoadingDeclarationWithExactLongFileOwnership() { + long planId = 9_007_199_254_740_993L; + long fileId = 9_007_199_254_740_995L; + AggregateRow plan = row("COMPLETED", 4); + plan.setPlanId(planId); + ContainerDocumentEntity document = document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true); + document.setFileId(fileId); + document.setFileName("出口报关单.pdf"); + byte[] content = new byte[]{1, 2, 3}; + var metadata = new LoadingDocumentFileClient.Metadata(fileId, 0L, + "LOADING_CUSTOMS_DECLARATION", Long.toString(planId), "出口报关单.pdf", hash(), + "application/pdf", (long) content.length); + when(repository.aggregate(planId)).thenReturn(plan); + when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(document); + when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), + eq(Long.toString(planId)), eq(fileId))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.download(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), + eq(Long.toString(planId)), eq(fileId))).thenReturn(org.springframework.http.ResponseEntity.ok(content)); + + var result = service.downloadLoadingDeclaration(planId, 100L, fileId); + + assertThat(result.fileName()).isEqualTo("出口报关单.pdf"); + assertThat(result.content()).containsExactly(1, 2, 3); + verify(documentFiles).download(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), + eq("9007199254740993"), eq(fileId)); + } + + @Test + void rejectsNonCurrentLoadingDeclarationFileIdBeforeFileService() { + AggregateRow plan = row("COMPLETED", 4); + when(repository.aggregate(10L)).thenReturn(plan); + when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)) + .thenReturn(document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true)); + + assertThatThrownBy(() -> service.downloadLoadingDeclaration(10L, 100L, 100L)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND); + verify(documentFiles, never()).metadata(anyString(), anyLong(), anyString(), anyString(), anyLong()); + } + + @Test + void rejectsLoadingDeclarationFileWithMismatchedBusinessOwnership() { + AggregateRow plan = row("COMPLETED", 4); + ContainerDocumentEntity document = document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true); + when(repository.aggregate(10L)).thenReturn(plan); + when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(document); + var metadata = new LoadingDocumentFileClient.Metadata(99L, 0L, + "LOADING_CUSTOMS_DECLARATION", "other-plan", "customs.pdf", hash(), + "application/pdf", 3L); + when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), + eq("10"), eq(99L))).thenReturn(ApiResponse.success(metadata)); + + assertThatThrownBy(() -> service.downloadLoadingDeclaration(10L, 100L, 99L)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND); + verify(documentFiles, never()).download(anyString(), anyLong(), anyString(), anyString(), anyLong()); + } + + @Test + void downloadsCurrentDestinationClearanceWithExactContainerOwnership() { + long bookingId = 9_007_199_254_740_993L; + long fileId = 9_007_199_254_740_995L; + AggregateRow plan = row("COMPLETED", 4); + ContainerDocumentEntity document = document(41L, LoadingListService.DESTINATION_CLEARANCE, 1, true); + document.setContainerBookingId(bookingId); + document.setFileId(fileId); + document.setFileName("destination-clearance.pdf"); + byte[] content = new byte[]{4, 5, 6}; + var metadata = new LoadingDocumentFileClient.Metadata(fileId, 0L, + "CONTAINER_CLEARANCE_DOCUMENT", Long.toString(bookingId), "destination-clearance.pdf", + hash(), "application/pdf", (long) content.length); + when(repository.aggregateByBooking(bookingId)).thenReturn(plan); + when(repository.document(bookingId, LoadingListService.DESTINATION_CLEARANCE)).thenReturn(document); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"), + eq(Long.toString(bookingId)), eq(fileId))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.download(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"), + eq(Long.toString(bookingId)), eq(fileId))).thenReturn(org.springframework.http.ResponseEntity.ok(content)); + + var result = service.downloadContainerClearance(bookingId, fileId); + + assertThat(result.fileName()).isEqualTo("destination-clearance.pdf"); + assertThat(result.content()).containsExactly(4, 5, 6); + } + + @Test + void rejectsNonCurrentDestinationClearanceFileIdAsNotFound() { + AggregateRow plan = row("COMPLETED", 4); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)) + .thenReturn(document(41L, LoadingListService.DESTINATION_CLEARANCE, 1, true)); + + assertThatThrownBy(() -> service.downloadContainerClearance(30L, 100L)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND); + verify(documentFiles, never()).metadata(anyString(), anyLong(), anyString(), anyString(), anyLong()); + } + @Test void rejectsReusedIdempotencyKeyWithDifferentAttachRequest() { AggregateRow plan = row("LOADING", 2); @@ -499,6 +599,125 @@ class LoadingListServiceTest { verify(repository, never()).aggregateByBooking(30L); } + @Test + void firstDeliveryOrderUploadPersistsDocumentAndImmutablePlanHistory() { + AggregateRow plan = row("COMPLETED", 4); + byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content); + String contentHash = sha256Bytes(content); + var metadata = new LoadingDocumentFileClient.Metadata(101L, 0L, + "CONTAINER_DELIVERY_ORDER", "30", "do.pdf", contentHash, "application/pdf", (long) content.length); + ContainerDocumentEntity saved = document(51L, LoadingListService.DELIVERY_ORDER, 0, false); + saved.setFileId(101L); saved.setFileName("do.pdf"); saved.setFileHash(contentHash); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DELIVERY_ORDER)).thenReturn(null, saved); + when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(101L))).thenReturn(ApiResponse.success(metadata)); + when(repository.createDocument(any())).thenReturn(1); + + var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.DELIVERY_ORDER, + 9L, "operator", "do-first", 0, null, file); + + assertThat(result).extracting(value -> value.documentType(), value -> value.fileId(), value -> value.version()) + .containsExactly(LoadingListService.DELIVERY_ORDER, 101L, 0); + verify(repository).appendHistory(eq(10L), anyString(), eq("UPLOAD_DELIVERY_ORDER"), + eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull()); + } + + @Test + void pickupOrderUpdateUsesCurrentVersionAndReturnsUpdatedDocument() { + AggregateRow plan = row("COMPLETED", 4); + byte[] content = "pickup-order".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var file = new MockMultipartFile("file", "pu.pdf", "application/pdf", content); + String contentHash = sha256Bytes(content); + ContainerDocumentEntity current = document(52L, LoadingListService.PICKUP_ORDER, 2, false); + ContainerDocumentEntity saved = document(52L, LoadingListService.PICKUP_ORDER, 3, false); + saved.setFileId(102L); saved.setFileName("pu.pdf"); saved.setFileHash(contentHash); + var metadata = new LoadingDocumentFileClient.Metadata(102L, 0L, + "CONTAINER_PICKUP_ORDER", "30", "pu.pdf", contentHash, "application/pdf", (long) content.length); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.PICKUP_ORDER)).thenReturn(current, saved); + when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_PICKUP_ORDER"), + eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_PICKUP_ORDER"), + eq("30"), eq(102L))).thenReturn(ApiResponse.success(metadata)); + when(repository.replaceDocument(any())).thenReturn(1); + + var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.PICKUP_ORDER, + 9L, "operator", "pu-update", 2, null, file); + + assertThat(result.fileId()).isEqualTo(102L); + assertThat(result.version()).isEqualTo(3); + verify(repository).replaceDocument(any()); + verify(repository).appendHistory(eq(10L), anyString(), eq("UPLOAD_PICKUP_ORDER"), + eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull()); + } + + @Test + void staleDeliveryOrderVersionReturnsConflictBeforeUploadingAnotherFile() { + AggregateRow plan = row("COMPLETED", 4); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DELIVERY_ORDER)) + .thenReturn(document(51L, LoadingListService.DELIVERY_ORDER, 2, false)); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", "do".getBytes()); + + assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L, + LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-stale", 1, null, file)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.CONFLICT); + verify(documentFiles, never()).upload(anyString(), anyLong(), anyLong(), anyString(), anyString(), any()); + } + + @Test + void sameDeliveryOrderUploadKeyReplaysWithoutUploadingAgain() { + AggregateRow plan = row("COMPLETED", 4); + byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content); + String requestHash = sha256("UPLOAD|DELIVERY_ORDER|30|0|null|" + sha256Bytes(content)); + HistoryRow replay = new HistoryRow(); + replay.setAfterSnapshot("{\"requestHash\":\"" + requestHash + "\"}"); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(replay); + when(repository.document(30L, LoadingListService.DELIVERY_ORDER)) + .thenReturn(document(51L, LoadingListService.DELIVERY_ORDER, 0, false)); + + var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.DELIVERY_ORDER, + 9L, "operator", "do-replay", 0, null, file); + + assertThat(result.documentType()).isEqualTo(LoadingListService.DELIVERY_ORDER); + verify(documentFiles, never()).upload(anyString(), anyLong(), anyLong(), anyString(), anyString(), any()); + } + + @Test + void deliveryOrderDatabaseConstraintFailureReturnsBusinessConflictInsteadOfUnknownError() { + AggregateRow plan = row("COMPLETED", 4); + byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content); + String contentHash = sha256Bytes(content); + var metadata = new LoadingDocumentFileClient.Metadata(103L, 0L, + "CONTAINER_DELIVERY_ORDER", "30", "do.pdf", contentHash, "application/pdf", (long) content.length); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DELIVERY_ORDER)).thenReturn(null); + when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(103L))).thenReturn(ApiResponse.success(metadata)); + when(repository.createDocument(any())).thenThrow( + new org.springframework.dao.DataIntegrityViolationException("trigger rejected")); + + assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L, + LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-db-fail", 0, null, file)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.CONFLICT); + } + + @Test void confirmsEveryLinkedPreloadAndCreatesSinglePdaTaskAtomically() { AggregateRow before = row("EDITING", 3); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 6279bf6bd..437c62dcd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -591,6 +591,28 @@ class LoadingPickServiceTest { verify(loadingListService, never()).publishCompletedEvent(anyLong()); } + @Test + void translatesUnexpectedSettlementFailureIntoActionableStageMessage() { + LoadingPickTaskEntity task = task(); + when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.allTaskItemsResolved(20L)).thenReturn(true); + var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L); + when(repository.inboundOwners(20L)).thenReturn(List.of(owner)); + when(repository.inboundForUpdate(101L, 50L)).thenReturn( + new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); + doThrow(new IllegalStateException("unexpected database failure")) + .when(settlementService).settle(20L, 9L, "operator"); + + assertThatThrownBy(() -> service.complete(20L, + new CompleteLoadingRequest(0, "complete-stage-error", "checked"), 9L, "operator")) + .isInstanceOf(BizException.class) + .hasMessageContaining("拆单与库位结算") + .hasMessageContaining("请求编号"); + + verify(repository, never()).approve(anyLong(), anyLong(), anyString(), anyString()); + verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); + } + @Test void atomicallySubmitsCompleteBatchAndReplaysSameResult() { LoadingPickTaskEntity task = task(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java index 9412f0928..8f6329212 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java @@ -13,11 +13,11 @@ import com.baiyue.firstleg.pricing.FbaWarehouseRateEstimator; import com.baiyue.firstleg.pricing.PriceBookWarehouseRegionResolver; import com.baiyue.firstleg.pricing.PriceRegionResolver; import com.baiyue.firstleg.pricing.RegionWeightTierEstimator; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelPrefixRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; @@ -38,7 +38,7 @@ import static org.mockito.Mockito.when; PriceBookWarehouseRegionResolver.class}) @TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class MerchantPriceBookServiceTest { - @MockBean private PriceBookQueryRepository repository; + @MockBean private PriceBookQueryUseCase repository; @MockBean private PriceBookWarehouseClient warehouses; @Resource private MerchantPriceBookService service; diff --git a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json index 308cda8bc..4ffb1a909 100644 --- a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json +++ b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json @@ -5,6 +5,7 @@ "booking-customs-trucking->inbound", "booking-customs-trucking->measurement-pricing", "booking-customs-trucking->outbound", + "booking-customs-trucking->pricebook-query", "booking-customs-trucking->shared-technical", "booking-customs-trucking->split-loading", "customer-statistics->shared-technical", @@ -19,6 +20,7 @@ "measurement-pricing->booking-customs-trucking", "measurement-pricing->finance-integration", "measurement-pricing->inbound", + "measurement-pricing->pricebook-query", "measurement-pricing->shared-technical", "outbound->booking-customs-trucking", "outbound->shared-technical", @@ -35,8 +37,8 @@ "shipping-nodes->split-loading", "split-loading->booking-customs-trucking", "split-loading->inbound", - "split-loading->measurement-pricing", "split-loading->outbound", + "split-loading->pricebook-query", "split-loading->shared-technical" ], "requiredClassLocations": { @@ -44,21 +46,50 @@ "com.baiyue.firstleg.customerstatistics.adapter.out.persistence.CustomerTransitStatisticsMapper": "src/main/java/com/baiyue/firstleg/customerstatistics/adapter/out/persistence/CustomerTransitStatisticsMapper.java", "com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService": "src/main/java/com/baiyue/firstleg/customerstatistics/application/CustomerTransitStatisticsService.java", "com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos": "src/main/java/com/baiyue/firstleg/customerstatistics/application/model/CustomerTransitStatisticsDtos.java", - "com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader": "src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java" - }, - "applicationDependencyRule": { - "packagePrefix": "com.baiyue.firstleg.customerstatistics.application", - "allowedInternalPrefix": "com.baiyue.firstleg.customerstatistics.application", - "forbiddenImportPrefixes": [ - "org.springframework.web", - "org.apache.ibatis", - "com.baomidou.mybatisplus" - ] + "com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader": "src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java", + "com.baiyue.firstleg.pricebookquery.adapter.out.persistence.PriceBookQueryMapper": "src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java", + "com.baiyue.firstleg.pricebookquery.application.PriceBookQueryService": "src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java", + "com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows": "src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java", + "com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase": "src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java", + "com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader": "src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java" }, + "applicationDependencyRules": [ + { + "packagePrefix": "com.baiyue.firstleg.customerstatistics.application", + "allowedInternalPrefix": "com.baiyue.firstleg.customerstatistics.application", + "forbiddenImportPrefixes": [ + "org.springframework.web", + "org.apache.ibatis", + "com.baomidou.mybatisplus" + ] + }, + { + "packagePrefix": "com.baiyue.firstleg.pricebookquery.application", + "allowedInternalPrefix": "com.baiyue.firstleg.pricebookquery.application", + "forbiddenImportPrefixes": [ + "org.springframework.web", + "org.apache.ibatis", + "com.baomidou.mybatisplus" + ] + } + ], + "sliceExposureRules": [ + { + "slicePrefix": "com.baiyue.firstleg.pricebookquery.", + "allowedTargetPrefixes": [ + "com.baiyue.firstleg.pricebookquery.application.model.", + "com.baiyue.firstleg.pricebookquery.application.port.in." + ] + } + ], "sliceSpecificRules": { "customer-statistics": { "allowedOutgoing": ["shared-technical"], "allowedIncoming": [] + }, + "pricebook-query": { + "allowedOutgoing": [], + "allowedIncoming": ["booking-customs-trucking", "measurement-pricing", "split-loading"] } } } diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java index 9edcfad81..e488beaa9 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java @@ -7,7 +7,7 @@ import java.time.Duration; @ConfigurationProperties(prefix = "baiyue.security.overseas-projection") public record OverseasSecurityProjectionProperties(String serviceUrl, String internalServiceToken, Duration timeout) { public OverseasSecurityProjectionProperties { - if (serviceUrl == null || serviceUrl.isBlank()) throw new IllegalArgumentException("必须配置 OVERSEAS_SECURITY_SERVICE_URL"); + if (serviceUrl == null || serviceUrl.isBlank()) throw new IllegalArgumentException("必须配置 LASTLEG_SECURITY_SERVICE_URL"); if (internalServiceToken == null || internalServiceToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) { throw new IllegalArgumentException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); } diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java index 6d75282df..17f3af48b 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java @@ -9,7 +9,7 @@ import reactor.core.publisher.Mono; @Component public class HttpOverseasSecurityValidationClient implements OverseasSecurityValidationClient { - private static final String PATH = "/internal/overseas-security/v1/validate"; + private static final String PATH = "/internal/lastleg/security/v1/validate"; private final WebClient client; private final OverseasSecurityProjectionProperties properties; @@ -19,10 +19,10 @@ public class HttpOverseasSecurityValidationClient implements OverseasSecurityVal } @Override - public Mono validate(long userId, long tenantId, long tokenVersion, long permissionVersion) { + public Mono validate(long userId, long tenantId, long permissionVersion) { return client.post().uri(properties.serviceUrl() + PATH).contentType(MediaType.APPLICATION_JSON) .header("X-Internal-Service-Token", properties.internalServiceToken()) - .bodyValue(new Request(userId, tenantId, tokenVersion, permissionVersion)) + .bodyValue(new Request(userId, tenantId, permissionVersion)) .retrieve().bodyToMono(JsonNode.class).timeout(properties.timeout()).flatMap(body -> { JsonNode data = body == null ? null : body.path("data"); if (data == null || data.isMissingNode() || !data.has("valid")) { @@ -33,5 +33,5 @@ public class HttpOverseasSecurityValidationClient implements OverseasSecurityVal }); } - private record Request(long userId, long tenantId, long tokenVersion, long permissionVersion) { } + private record Request(long userId, long tenantId, long permissionVersion) { } } diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java index cdbb4e727..91fc516cb 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java @@ -38,12 +38,11 @@ public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered { JwtAuthenticationToken authentication) { Long userId = number(authentication.getToken().getSubject()); Long tenantId = number(authentication.getToken().getClaim("tenant_id")); - Long tokenVersion = number(authentication.getToken().getClaim("token_version")); Long permissionVersion = number(authentication.getToken().getClaim("permission_version")); - if (userId == null || tenantId == null || tokenVersion == null || permissionVersion == null) { + if (userId == null || tenantId == null || permissionVersion == null) { return Mono.error(new SecurityProjectionFailure("A0401", "海外令牌安全版本声明缺失")); } - return client.validate(userId, tenantId, tokenVersion, permissionVersion) + return client.validate(userId, tenantId, permissionVersion) .switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "海外安全状态校验不可用"))) .flatMap(result -> { if (result.valid()) return chain.filter(exchange); diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java index 2ce22a244..a267cb233 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java @@ -3,6 +3,6 @@ package com.baiyue.gateway.filter; import reactor.core.publisher.Mono; public interface OverseasSecurityValidationClient { - Mono validate(long userId, long tenantId, long tokenVersion, long permissionVersion); + Mono validate(long userId, long tenantId, long permissionVersion); record Result(boolean valid, String failureReason) { } } diff --git a/gateway-overseas/src/main/resources/application.yml b/gateway-overseas/src/main/resources/application.yml index 3e7ce51f2..d61d0ec19 100644 --- a/gateway-overseas/src/main/resources/application.yml +++ b/gateway-overseas/src/main/resources/application.yml @@ -47,9 +47,9 @@ baiyue: internal-service-token: ${INTERNAL_SERVICE_TOKEN} timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} overseas-projection: - service-url: ${OVERSEAS_SECURITY_SERVICE_URL} + service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303} internal-service-token: ${INTERNAL_SERVICE_TOKEN} - timeout: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:2s} + timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s} permit-paths: - /actuator/health - /swagger-ui/** diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java new file mode 100644 index 000000000..45bd73e1d --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java @@ -0,0 +1,42 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasSecurityValidationDeploymentContractTest { + @Test + void gatewayAndEventBridgeUseLastlegSecurityEndpointsWithoutTheRetiredService() throws Exception { + String client = read("gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java"); + String gatewayApplication = read("gateway-overseas/src/main/resources/application.yml"); + String gatewayNacos = read("config/nacos/overseas/gateway-overseas.yml"); + String bridgeApplication = read("event-bridge-service/src/main/resources/application.yml"); + String bridgeNacos = read("config/nacos/overseas/event-bridge-service.yml"); + String compose = read("deploy/docker-compose.overseas.yml"); + + assertThat(client).contains("/internal/lastleg/security/v1/validate") + .contains("new Request(userId, tenantId, permissionVersion)") + .doesNotContain("new Request(userId, tenantId, tokenVersion") + .doesNotContain("/internal/overseas-security/"); + for (String source : new String[]{gatewayApplication, gatewayNacos, compose}) { + assertThat(source).contains("LASTLEG_SECURITY_SERVICE_URL", "http://lastleg-service:9303"); + } + for (String source : new String[]{bridgeApplication, bridgeNacos, compose}) { + assertThat(source).contains("LASTLEG_SECURITY_EVENT_CONSUMER_URL", + "/internal/lastleg/security/v1/events"); + } + assertThat(gatewayApplication + gatewayNacos + bridgeApplication + bridgeNacos + compose) + .doesNotContain("overseas-security-service", "9307", "OVERSEAS_SECURITY_DB_URL", + "OVERSEAS_SECURITY_SERVICE_URL", "OVERSEAS_SECURITY_EVENT_CONSUMER_URL"); + } + + private static String read(String relative) throws Exception { + Path path = Path.of(relative); + if (!Files.exists(path)) path = Path.of("..").resolve(relative); + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java index ff9f935f0..1857158e8 100644 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java @@ -26,7 +26,7 @@ class OverseasSecurityProjectionFilterTest { private final OverseasSecurityProjectionFilter filter = new OverseasSecurityProjectionFilter(client, new ObjectMapper()); @Test void disabledUserFailsWithA0401() { - when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + when(client.validate(42, 0, 8)).thenReturn(Mono.just( new OverseasSecurityValidationClient.Result(false, "USER_DISABLED"))); var exchange = exchange("/api/platform/v1/last-leg/orders"); StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); @@ -36,7 +36,7 @@ class OverseasSecurityProjectionFilterTest { @Test void disabledTenantAndStalePermissionFailWithA0403() { for (String reason : new String[]{"TENANT_DISABLED", "PERMISSION_VERSION_STALE"}) { - when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + when(client.validate(42, 0, 8)).thenReturn(Mono.just( new OverseasSecurityValidationClient.Result(false, reason))); var exchange = exchange("/api/merchant/v1/last-leg/orders"); StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); @@ -46,14 +46,14 @@ class OverseasSecurityProjectionFilterTest { } @Test void projectionUnavailableFailsClosed() { - when(client.validate(42, 0, 3, 8)).thenReturn(Mono.error(new IllegalStateException("down"))); + when(client.validate(42, 0, 8)).thenReturn(Mono.error(new IllegalStateException("down"))); var exchange = exchange("/api/platform/v1/last-leg/orders"); StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } @Test void acceptedSecurityStateContinuesAndNonApiPathsDoNotCallProjection() { - when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + when(client.validate(42, 0, 8)).thenReturn(Mono.just( new OverseasSecurityValidationClient.Result(true, null))); AtomicBoolean routed = new AtomicBoolean(); StepVerifier.create(filter.filter(exchange("/api/platform/v1/last-leg/orders"), ignored -> { diff --git a/iam/AGENTS.md b/iam/AGENTS.md index b085e38c4..be2a0b5e5 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -12,10 +12,12 @@ | 目标 | 入口 | |---|---| | 客户资料/审核 | `controller/PlatformTenantController.java`、`service/TenantService.java` | +| 客户默认尺寸初始化、重试与补偿 | `service/CustomerDimensionRuleInitializationService.java`、`CustomerDimensionInitializationRelay.java`、`repository/CustomerDimensionInitializationRepository.java`、`client/CustomerDimensionRuleClient.java`、`model/CustomerDimensionInitializationTask.java`;持久任务异步调用 Firstleg `/internal/firstleg/v1/customer-dimension-configs/defaults` | | 商户成员与个人资料 | `controller/MerchantTenantController.java`、`service/MemberService.java`、`service/PersonalProfileService.java` | -| 平台用户 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java` | +| 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`WAREHOUSE_PICKER` 必须且只能绑定一个 `overseasWarehouseId` | | 角色/权限/RBAC | `controller/RbacController.java`、`service/RbacService.java` | | 授权快照/内部资格 | `controller/AuthorizationController.java`、`InternalAuthorizationController.java`、`service/AuthorizationSnapshotService.java` | +| 海外安全状态事件 Outbox | `service/SecurityEventOutboxService.java`、`SecurityEventOutboxRelay.java`、`client/SecurityEventBridgeClient.java`;账号、授权或客户状态变更经版本化事件投影到 `../lastleg-service/security/` | | 消息中心/站内通知 | `notification/controller/NotificationInboxController.java`、`notification/controller/PlatformNotificationController.java`、`notification/service/NotificationService.java`;模板、任务、收件箱及事件消费均在 `notification/` | | File 资质元数据协作 | `controller/InternalTenantController.java`、`service/ProfileResourceService.java`、`client/AuthAccountClient.java` | | DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` | @@ -26,7 +28,10 @@ - 所有客户查询按 `tenant_id` 过滤;平台用户只能 `tenant_id=0`。必填资质审核通过才具备下单资格。 - 成员/账号跨 IAM/Auth 保持幂等补偿;资质跨 IAM/File 不保存文件内容。 +- 客户建档只在 IAM 事务内落持久初始化意图,远端投递由带租约、退避和最大次数的 Relay 完成;只有状态为 `READY` 才能审核通过。注册补偿必须投递 `COMPENSATE`,不得在建档事务中同步依赖 Firstleg。 - 权限资源 code/type 不可变;按钮隐藏不能替代后端权限校验。 +- 只有平台 `WAREHOUSE_PICKER` 可保存 `overseasWarehouseId`,且必须为单个有效海外仓;非拣货员必须为空。仓绑定、账号状态、角色权限或客户状态变化要在同一业务事务内推进安全事件 Outbox,不得依赖海外服务回查国内 IAM。 +- `IamTenantStatusChanged` 是安全可用性快照,不暴露客户建档/审核流程状态:仅 IAM 业务状态 `ACTIVE` 发布 `ACTIVE`,`PROFILE_REQUIRED`、`PENDING`、`REJECTED`、`DISABLED`、`ARCHIVED` 及其他不可用状态统一发布 `DISABLED`。 ## 全量验收导航 diff --git a/iam/SECURITY-EVENT-AUDIT.md b/iam/SECURITY-EVENT-AUDIT.md index 0f0a49f51..856b61edf 100644 --- a/iam/SECURITY-EVENT-AUDIT.md +++ b/iam/SECURITY-EVENT-AUDIT.md @@ -25,9 +25,11 @@ IAM 使用独立的 `iam_security_event_outbox`。业务状态、权限版本与 ## 当前接入 - 企业成员、平台内部用户和平台用户启停均写 `IamUserStatusChanged`。 -- 客户启停写 `IamTenantStatusChanged`。 +- 客户创建、审核结果及启停写 `IamTenantStatusChanged`。 - 成员角色、平台内部用户角色及角色权限变更写 `IamPermissionVersionChanged`。 +普通 Admin/Client 的即时失效只覆盖用户停用、客户停用和权限版本推进。`token_version` 不进入 V1 投影,也不传给 Lastleg 校验;其即时权威比对只用于海外 PDA 向 Auth 核验仓绑定与会话版本。 + ## 尾程权限 `lastleg-permissions.sql` 初始化以下平台权限,并授予 `PLATFORM_ADMIN`: diff --git a/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java b/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java index 4adf81539..c68eac775 100644 --- a/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java +++ b/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java @@ -7,7 +7,8 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; -@FeignClient(name = "event-bridge-service", contextId = "iamSecurityEventBridgeClient") +@FeignClient(name = "event-bridge-service", contextId = "iamSecurityEventBridgeClient", + url = "${baiyue.security-event.event-bridge-url:}") public interface SecurityEventBridgeClient { @PostMapping("/internal/event-bridge/events") ApiResponse receive(@RequestHeader("X-Internal-Service-Token") String token, diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java index d51fe0878..9a5e0642c 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -17,6 +17,7 @@ import com.baiyue.iam.dto.TenantDtos.PendingReviewResponse; import com.baiyue.iam.service.TenantGuard; import com.baiyue.iam.service.TenantService; import com.baiyue.iam.service.MemberService; +import com.baiyue.iam.model.CustomerDimensionInitializationStatus; import com.baiyue.iam.dto.PlatformCustomerMemberDtos; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -77,6 +78,24 @@ public class PlatformTenantController { return ApiResponse.success(service.merchant(tenantId)); } + @PostMapping("/tenants/{tenantId:\\d+}/dimension-initialization/retry") + @PreAuthorize("hasAuthority('platform:customer:edit')") + @Operation(summary = "重试客户默认尺寸初始化") + public ApiResponse retryDimensionInitialization( + @PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.retryDimensionInitialization(tenantId)); + } + + @GetMapping("/tenants/{tenantId:\\d+}/dimension-initialization") + @PreAuthorize("hasAuthority('platform:customer:list')") + @Operation(summary = "查询客户默认尺寸初始化诊断") + public ApiResponse dimensionInitializationStatus( + @PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.dimensionInitializationStatus(tenantId)); + } + @GetMapping("/tenants/contact-phone-availability") @PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')") @Operation(summary = "校验客户联系电话唯一性") diff --git a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java index b00df77b8..266bd6c2d 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java @@ -20,7 +20,8 @@ public final class OnboardingDtos { String address ) { } - public record MerchantRegistrationResponse(Long tenantId, Long membershipId, String tenantStatus) { } + public record MerchantRegistrationResponse(Long tenantId, Long membershipId, String tenantStatus, + String dimensionInitializationStatus) { } public record QualificationSubmissionRequest( @NotNull Long tenantId, diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java index 40e3422e9..69a08ec1c 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -133,6 +133,7 @@ public final class TenantDtos { @Schema(name = "TenantResponse", description = "Enterprise tenant summary / 企业租户概要") public record TenantResponse(Long id, String tenantCode, String tenantName, String status, + String dimensionInitializationStatus, String legalName, String legalRepresentative, String unifiedSocialCreditCode, String contactName, String contactPhone, String contactEmail, String contactWechat, String servicePackage, String salesOwnerName, Integer paymentTermDays, String enterpriseType, diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java b/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java index 1761b7a81..bc3971d9b 100644 --- a/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java +++ b/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java @@ -22,6 +22,7 @@ public class IamTenant extends BaseEntity { private String tenantName; private String tenantType; private String status; + private String dimensionInitializationStatus; private String legalName; private String legalRepresentative; private String unifiedSocialCreditCode; diff --git a/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationStatus.java b/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationStatus.java new file mode 100644 index 000000000..eaf32fa8b --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationStatus.java @@ -0,0 +1,13 @@ +package com.baiyue.iam.model; + +import java.time.LocalDateTime; + +public record CustomerDimensionInitializationStatus( + long tenantId, + String status, + int attemptCount, + LocalDateTime nextRetryAt, + String lastErrorCode, + String lastErrorMessage, + LocalDateTime updatedAt +) { } diff --git a/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationTask.java b/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationTask.java new file mode 100644 index 000000000..51b3aea9a --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationTask.java @@ -0,0 +1,12 @@ +package com.baiyue.iam.model; + +public record CustomerDimensionInitializationTask( + long tenantId, + long operatorId, + String initializationKey, + String action, + String status, + int attemptCount, + int taskVersion) { +} + diff --git a/iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java b/iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java new file mode 100644 index 000000000..cc9d35ba8 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java @@ -0,0 +1,123 @@ +package com.baiyue.iam.repository; + +import com.baiyue.iam.model.CustomerDimensionInitializationTask; +import com.baiyue.iam.model.CustomerDimensionInitializationStatus; +import jakarta.annotation.Resource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public class CustomerDimensionInitializationRepository { + @Resource private JdbcTemplate jdbc; + + public void enqueue(long tenantId, long operatorId, String initializationKey) { + jdbc.update("INSERT INTO iam_customer_dimension_initialization_task(tenant_id,operator_id," + + "initialization_key,action,status,attempt_count,next_retry_at,task_version) " + + "VALUES(?,?,?,'INITIALIZE','PENDING',0,NOW(3),0)", + tenantId, operatorId, initializationKey); + } + + public void requestCompensation(long tenantId, long operatorId, String initializationKey) { + int updated = jdbc.update("UPDATE iam_customer_dimension_initialization_task SET operator_id=?," + + "initialization_key=?,action='COMPENSATE',status='PENDING',attempt_count=0," + + "next_retry_at=NOW(3),lease_owner=NULL,lease_until=NULL,last_error_code=NULL," + + "last_error_message=NULL,task_version=task_version+1,updated_at=NOW(3) WHERE tenant_id=?", + operatorId, initializationKey, tenantId); + if (updated == 0) { + jdbc.update("INSERT INTO iam_customer_dimension_initialization_task(tenant_id,operator_id," + + "initialization_key,action,status,attempt_count,next_retry_at,task_version) " + + "VALUES(?,?,?,'COMPENSATE','PENDING',0,NOW(3),0)", + tenantId, operatorId, initializationKey); + } + } + + public boolean manualRetry(long tenantId) { + return jdbc.update("UPDATE iam_customer_dimension_initialization_task SET action='INITIALIZE'," + + "status='PENDING',attempt_count=0,next_retry_at=NOW(3),lease_owner=NULL,lease_until=NULL," + + "last_error_code=NULL,last_error_message=NULL,task_version=task_version+1,updated_at=NOW(3) " + + "WHERE tenant_id=? AND status='FAILED'", tenantId) == 1; + } + + public Optional findStatus(long tenantId) { + List rows = jdbc.query( + "SELECT tenant_id,status,attempt_count,next_retry_at,last_error_code,last_error_message,updated_at " + + "FROM iam_customer_dimension_initialization_task WHERE tenant_id=?", + (rs, row) -> new CustomerDimensionInitializationStatus( + rs.getLong("tenant_id"), rs.getString("status"), rs.getInt("attempt_count"), + rs.getTimestamp("next_retry_at").toLocalDateTime(), rs.getString("last_error_code"), + rs.getString("last_error_message"), rs.getTimestamp("updated_at").toLocalDateTime()), tenantId); + return rows.stream().findFirst(); + } + + public void recoverExpiredLeases() { + jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status='PENDING',lease_owner=NULL," + + "lease_until=NULL,next_retry_at=NOW(3),task_version=task_version+1,updated_at=NOW(3) " + + "WHERE status='SENDING' AND lease_until candidates(int limit) { + return jdbc.queryForList("SELECT tenant_id FROM iam_customer_dimension_initialization_task " + + "WHERE status='PENDING' AND next_retry_at<=NOW(3) ORDER BY next_retry_at,tenant_id LIMIT " + + Math.max(1, Math.min(limit, 200)), Long.class); + } + + @Transactional + public CustomerDimensionInitializationTask claim(long tenantId, String workerId, LocalDateTime now) { + int claimed = jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status='SENDING'," + + "lease_owner=?,lease_until=?,task_version=task_version+1,updated_at=? " + + "WHERE tenant_id=? AND status='PENDING' AND next_retry_at<=?", + workerId, now.plusMinutes(2), now, tenantId, now); + if (claimed != 1) return null; + return jdbc.queryForObject("SELECT tenant_id,operator_id,initialization_key,action,status," + + "attempt_count,task_version FROM iam_customer_dimension_initialization_task " + + "WHERE tenant_id=? AND status='SENDING' AND lease_owner=?", + (rs, row) -> new CustomerDimensionInitializationTask( + rs.getLong("tenant_id"), rs.getLong("operator_id"), rs.getString("initialization_key"), + rs.getString("action"), rs.getString("status"), rs.getInt("attempt_count"), + rs.getInt("task_version")), tenantId, workerId); + } + + @Transactional + public boolean markReady(CustomerDimensionInitializationTask task, String workerId, Long configId) { + int updated = jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status='READY'," + + "remote_config_id=?,lease_owner=NULL,lease_until=NULL,last_error_code=NULL," + + "last_error_message=NULL,completed_at=NOW(3),updated_at=NOW(3) " + + "WHERE tenant_id=? AND action='INITIALIZE' AND status='SENDING' AND lease_owner=? AND task_version=?", + configId, task.tenantId(), workerId, task.taskVersion()); + if (updated == 1) { + jdbc.update("UPDATE iam_tenant SET dimension_initialization_status='READY',updated_at=NOW(3) " + + "WHERE id=? AND deleted=0", task.tenantId()); + } + return updated == 1; + } + + public boolean markCancelled(CustomerDimensionInitializationTask task, String workerId) { + return jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status='CANCELLED'," + + "lease_owner=NULL,lease_until=NULL,last_error_code=NULL,last_error_message=NULL," + + "completed_at=NOW(3),updated_at=NOW(3) WHERE tenant_id=? AND action='COMPENSATE' " + + "AND status='SENDING' AND lease_owner=? AND task_version=?", + task.tenantId(), workerId, task.taskVersion()) == 1; + } + + @Transactional + public boolean recordFailure(CustomerDimensionInitializationTask task, String workerId, int nextAttempt, + LocalDateTime nextRetryAt, String code, String message, boolean exhausted) { + String status = exhausted ? "FAILED" : "PENDING"; + int updated = jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status=?,attempt_count=?," + + "next_retry_at=?,lease_owner=NULL,lease_until=NULL,last_error_code=?,last_error_message=?," + + "task_version=task_version+1,updated_at=NOW(3) WHERE tenant_id=? AND action=? " + + "AND status='SENDING' AND lease_owner=? AND task_version=?", + status, nextAttempt, nextRetryAt, code, message, task.tenantId(), task.action(), workerId, + task.taskVersion()); + if (updated == 1 && exhausted && "INITIALIZE".equals(task.action())) { + jdbc.update("UPDATE iam_tenant SET dimension_initialization_status='FAILED',updated_at=NOW(3) " + + "WHERE id=? AND deleted=0", task.tenantId()); + } + return updated == 1; + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java new file mode 100644 index 000000000..817d7eb81 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java @@ -0,0 +1,101 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.iam.client.CustomerDimensionRuleClient; +import com.baiyue.iam.model.CustomerDimensionInitializationTask; +import com.baiyue.iam.repository.CustomerDimensionInitializationRepository; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.Set; +import java.util.UUID; + +@Component +@Slf4j +public class CustomerDimensionInitializationRelay { + private static final Set PERMANENT_CODES = Set.of("A0400", "A0403", "A0404", "A0409", "A0422"); + @Resource private CustomerDimensionInitializationRepository repository; + @Resource private CustomerDimensionRuleClient client; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + @Value("${baiyue.customer-dimension-initialization.max-attempts:12}") private int maxAttempts; + private final String workerId = UUID.randomUUID().toString(); + + @Scheduled(fixedDelayString = "${baiyue.customer-dimension-initialization.fixed-delay-ms:5000}") + public void relay() { + repository.recoverExpiredLeases(); + for (Long tenantId : repository.candidates(50)) deliver(tenantId); + } + + void deliver(long tenantId) { + CustomerDimensionInitializationTask task = repository.claim(tenantId, workerId, LocalDateTime.now()); + if (task == null) return; + try { + if ("COMPENSATE".equals(task.action())) { + ApiResponse response = client.compensate( + internalToken, task.tenantId(), task.initializationKey()); + requireSuccess(response, false); + repository.markCancelled(task, workerId); + return; + } + ApiResponse response = client.initialize( + internalToken, new CustomerDimensionRuleClient.InitializationRequest( + task.tenantId(), task.operatorId(), task.initializationKey())); + requireSuccess(response, true); + if (response.data() == null || response.data().configId() == null) { + throw new DeliveryFailure("S0503", "Firstleg 未返回默认尺寸配置ID", false); + } + repository.markReady(task, workerId, response.data().configId()); + } catch (Exception failure) { + int nextAttempt = task.attemptCount() + 1; + boolean permanent = failure instanceof DeliveryFailure delivery && delivery.permanent; + boolean exhausted = permanent || nextAttempt >= maxAttempts; + String code = failure instanceof DeliveryFailure delivery ? delivery.code : "S0503"; + String message = safeMessage(failure); + repository.recordFailure(task, workerId, nextAttempt, + LocalDateTime.now().plusSeconds(backoffSeconds(nextAttempt)), code, message, exhausted); + log.warn("客户默认尺寸后台初始化失败 tenantId={}, action={}, attempt={}, exhausted={}, code={}, reason={}", + tenantId, task.action(), nextAttempt, exhausted, code, message); + } + } + + private static void requireSuccess(ApiResponse response, boolean requireData) { + if (response != null && response.successful() && (!requireData || response.data() != null)) return; + String code = response == null || response.code() == null ? "S0503" : response.code(); + String message = response == null || response.message() == null || response.message().isBlank() + ? "Firstleg 服务未确认客户默认尺寸初始化" : response.message(); + throw new DeliveryFailure(code, message, PERMANENT_CODES.contains(code)); + } + + private static long backoffSeconds(int attempt) { + return switch (Math.min(Math.max(attempt, 1), 5)) { + case 1 -> 10; + case 2 -> 30; + case 3 -> 120; + case 4 -> 600; + default -> 1800; + }; + } + + private static String safeMessage(Exception failure) { + String value = failure.getMessage(); + if (value == null || value.isBlank()) value = failure.getClass().getSimpleName(); + value = value.replaceAll("[\\r\\n\\t]+", " "); + value = value.replaceAll("(?i)Bearer\\s+[^\\s,;]+", "Bearer ***") + .replaceAll("(?i)(token|password)=([^\\s&,;]+)", "$1=***"); + return value.length() > 1000 ? value.substring(0, 1000) : value; + } + + private static final class DeliveryFailure extends RuntimeException { + private final String code; + private final boolean permanent; + private DeliveryFailure(String code, String message, boolean permanent) { + super(message); + this.code = code; + this.permanent = permanent; + } + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java index 32bd7217c..c5aaca617 100644 --- a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java @@ -1,119 +1,50 @@ package com.baiyue.iam.service; -import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.iam.client.CustomerDimensionRuleClient; +import com.baiyue.iam.repository.CustomerDimensionInitializationRepository; +import com.baiyue.iam.model.CustomerDimensionInitializationStatus; import jakarta.annotation.Resource; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; -/** Provisions four default box-size dimensions and compensates them when customer creation rolls back. */ +/** Owns IAM's durable customer-dimension initialization intent; remote delivery is always background. */ @Service public class CustomerDimensionRuleInitializationService { - private static final Logger log = LoggerFactory.getLogger(CustomerDimensionRuleInitializationService.class); - private static final int MAX_ATTEMPTS = 3; private static final String KEY_PREFIX = "iam-customer-create:"; - - @Resource - private CustomerDimensionRuleClient client; - @Value("${baiyue.security.internal-service-token}") - private String internalToken; + @Resource private CustomerDimensionInitializationRepository repository; public InitializationResult initialize(long tenantId, long operatorId) { String initializationKey = initializationKey(tenantId); - RuntimeException lastFailure = null; - for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - try { - ApiResponse response = client.initialize( - internalToken, - new CustomerDimensionRuleClient.InitializationRequest( - tenantId, operatorId, initializationKey)); - if (response != null && response.successful() && response.data() != null) { - InitializationResult result = new InitializationResult( - response.data().createdByThisInitialization(), initializationKey); - registerRollbackCompensation(tenantId, result); - return result; - } - lastFailure = unavailable(response); - log.warn("客户4条默认箱规尺寸初始化失败,tenantId={}, attempt={}, code={}, requestId={}, traceId={}", - tenantId, attempt, response == null ? null : response.code(), - response == null ? null : response.requestId(), - response == null ? null : response.traceId()); - } catch (RuntimeException failure) { - lastFailure = failure; - log.warn("客户4条默认箱规尺寸初始化调用异常,tenantId={}, attempt={}", tenantId, attempt, failure); - } - } - compensateAfterUncertainInitialization(tenantId, initializationKey); - throw unavailable(lastFailure); + repository.enqueue(tenantId, operatorId, initializationKey); + return new InitializationResult(false, initializationKey, "PENDING"); } /** Used when Auth compensates a committed client registration. */ public void compensateRegistration(long tenantId) { - compensateRequired(tenantId, initializationKey(tenantId)); + repository.requestCompensation(tenantId, 0L, initializationKey(tenantId)); } - private void registerRollbackCompensation(long tenantId, InitializationResult result) { - if (!result.created() || !TransactionSynchronizationManager.isSynchronizationActive()) return; - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCompletion(int status) { - if (status != STATUS_ROLLED_BACK) return; - try { - compensateRequired(tenantId, result.initializationKey()); - } catch (RuntimeException compensationFailure) { - log.error("客户创建事务回滚后4条默认箱规尺寸补偿失败,tenantId={}, initializationKey={}", - tenantId, result.initializationKey(), compensationFailure); - } - } - }); - } - - private void compensateAfterUncertainInitialization(long tenantId, String initializationKey) { - try { - compensateRequired(tenantId, initializationKey); - } catch (RuntimeException compensationFailure) { - log.error("客户4条默认箱规尺寸初始化结果不确定且补偿失败,tenantId={}, initializationKey={}", - tenantId, initializationKey, compensationFailure); + public void retry(long tenantId) { + if (!repository.manualRetry(tenantId)) { + throw new BizException(ResultCode.CONFLICT, "默认尺寸初始化当前不可人工重试"); } } - private void compensateRequired(long tenantId, String initializationKey) { - RuntimeException lastFailure = null; - for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - try { - ApiResponse response = client.compensate(internalToken, tenantId, initializationKey); - if (response != null && response.successful()) return; - lastFailure = unavailable(response); - } catch (RuntimeException failure) { - lastFailure = failure; - } + public CustomerDimensionInitializationStatus status(long tenantId) { + return repository.findStatus(tenantId).orElseThrow( + () -> new BizException(ResultCode.NOT_FOUND, "默认尺寸初始化任务不存在")); + } + + public void requireReady(String status) { + if (!"READY".equals(status)) { + throw new BizException(ResultCode.CONFLICT, + "客户默认尺寸尚未就绪,当前状态: " + (status == null ? "PENDING" : status)); } - throw new BizException(ResultCode.SERVICE_UNAVAILABLE, - "客户4条默认箱规尺寸补偿失败,请根据客户ID修复数据一致性: " + tenantId); - } - - private static BizException unavailable(ApiResponse response) { - String detail = response == null || response.message() == null || response.message().isBlank() - ? "Firstleg 服务未返回有效响应" : response.message(); - return new BizException(ResultCode.SERVICE_UNAVAILABLE, "4条默认箱规尺寸初始化失败: " + detail); - } - - private static BizException unavailable(RuntimeException cause) { - String detail = cause == null || cause.getMessage() == null || cause.getMessage().isBlank() - ? "Firstleg 服务暂时不可用" : cause.getMessage(); - return new BizException(ResultCode.SERVICE_UNAVAILABLE, - "4条默认箱规尺寸初始化失败,已重试 3 次: " + detail); } private static String initializationKey(long tenantId) { return KEY_PREFIX + tenantId; } - public record InitializationResult(boolean created, String initializationKey) { } + public record InitializationResult(boolean created, String initializationKey, String status) { } } diff --git a/iam/src/main/java/com/baiyue/iam/service/MemberService.java b/iam/src/main/java/com/baiyue/iam/service/MemberService.java index a2a9088a2..cc35971c5 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MemberService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java @@ -104,6 +104,8 @@ public class MemberService { membership.setMemberType(request.memberType()); membership.setMemberNo(request.memberNo()); membership.setStatus("ACTIVE"); membership.setJoinedAt(LocalDateTime.now()); repository.insert(membership); repository.replaceRoles(tenantId, membershipId, request.roleIds()); + securityEvents.userStatus(authId, tenantId, "MERCHANT", "ACTIVE"); + securityEvents.permissionChanged(authId, tenantId, "MERCHANT", "INITIAL_AUTHORIZATION"); return response(tenantId, membership, user); } catch (RuntimeException exception) { try { diff --git a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java index db8c43964..a393d5cac 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java @@ -42,6 +42,7 @@ public class MerchantOnboardingService { @Resource private IamTenantReviewMapper reviewMapper; @Resource private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @Resource private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; + @Resource private SecurityEventOutboxService securityEvents; @Transactional public MerchantRegistrationResponse register(MerchantRegistrationRequest request) { @@ -69,6 +70,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()); @@ -77,6 +79,7 @@ public class MerchantOnboardingService { tenant.setDeleted(false); tenant.setVersion(0); tenantMapper.insert(tenant); + securityEvents.tenantStatus(tenantId, tenant.getStatus()); IamUser user = new IamUser(); user.setId(userId); @@ -108,6 +111,8 @@ public class MerchantOnboardingService { membershipRole.setTenantId(tenantId); membershipRole.setCreatedAt(LocalDateTime.now()); membershipRoleMapper.insert(membershipRole); + securityEvents.userStatus(request.authUserId(), tenantId, "MERCHANT", "ACTIVE"); + securityEvents.permissionChanged(request.authUserId(), tenantId, "MERCHANT", "INITIAL_AUTHORIZATION"); if (StringUtils.hasText(request.address())) { IamTenantAddress address = new IamTenantAddress(); @@ -122,7 +127,8 @@ public class MerchantOnboardingService { } dimensionRuleInitialization.initialize(tenantId, request.authUserId()); overseasStorageFeeInitialization.initialize(tenantId, request.authUserId(), tenant.getTenantName()); - return new MerchantRegistrationResponse(tenantId, membershipId, tenant.getStatus()); + return new MerchantRegistrationResponse(tenantId, membershipId, tenant.getStatus(), + tenant.getDimensionInitializationStatus()); } /** diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java index f0285949a..baa3d4658 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java @@ -77,6 +77,7 @@ public class PlatformInternalUserService { repository.insertUser(user); repository.insert(membership); repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); + publishInitialSecurityProjection(user); return response(membership, user, request.username(), roleIds); } catch (RuntimeException localFailure) { compensateCreate(authUserId, localFailure); @@ -196,9 +197,16 @@ public class PlatformInternalUserService { IamMembership membership = newMembership(membershipId, user.getId(), memberNo); repository.insert(membership); repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); + publishInitialSecurityProjection(user); return response(membership, user, authAccount.username(), roleIds); } + private void publishInitialSecurityProjection(IamUser user) { + securityEvents.userStatus(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", user.getStatus()); + securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", + "INITIAL_AUTHORIZATION"); + } + private InternalUserResponse response(IamMembership membership) { IamUser user = requiredUser(membership.getUserId()); AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId()); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index 296100ffb..b7e719747 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -106,6 +106,8 @@ public class PlatformUserManagementService { membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); members.insert(membership); members.replaceRoles(tenantId, membershipId, roleIds); + securityEvents.userStatus(authUserId, tenantId, appCode, request.status()); + securityEvents.permissionChanged(authUserId, tenantId, appCode, "INITIAL_AUTHORIZATION"); return response(membership); } catch (RuntimeException localFailure) { compensateCreate(authUserId, localFailure); diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java index 32b9bd9e4..de41c5d9d 100644 --- a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java +++ b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java @@ -2,6 +2,7 @@ package com.baiyue.iam.service; import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventEnvelopeValidator; +import com.baiyue.common.event.RegionCode; import com.baiyue.iam.client.SecurityEventBridgeClient; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -45,6 +46,9 @@ public class SecurityEventOutboxRelay { try { EventEnvelope envelope = objectMapper.readValue(json, EventEnvelope.class); EventEnvelopeValidator.requireValid(envelope); + if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) { + throw new IllegalStateException("IAM 安全事件必须从 CN 投影到 OVERSEAS"); + } var response = client.receive(internalToken, envelope); if (response == null || !response.successful() || response.data() == null || !eventId.equals(response.data().eventId())) { diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java index fcc158420..520689a86 100644 --- a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java +++ b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java @@ -32,8 +32,9 @@ public class SecurityEventOutboxService { + "ON DUPLICATE KEY UPDATE status_version=status_version+1", tenantId); long version = jdbc.queryForObject( "SELECT status_version FROM iam_security_tenant_version WHERE tenant_id=?", Long.class, tenantId); + String securityStatus = "ACTIVE".equals(status) ? "ACTIVE" : "DISABLED"; write(EventTypes.IAM_TENANT_STATUS_CHANGED, "TENANT", String.valueOf(tenantId), tenantId, version, - new IamSecurityEventsV1.TenantStatusChanged(tenantId, status, Instant.now())); + new IamSecurityEventsV1.TenantStatusChanged(tenantId, securityStatus, Instant.now())); } public void permissionChanged(long authUserId, long tenantId, String appCode, String reason) { diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java index 7eb7d502b..6e1a6adbc 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java @@ -43,6 +43,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni private IamRoleMapper roles; @Resource private AuthAccountClient authClient; + @Resource + private SecurityEventOutboxService securityEvents; @Value("${baiyue.security.internal-service-token}") private String internalToken; private SecureRandom random = new SecureRandom(); @@ -112,6 +114,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni membership.setUpdatedBy(operatorId); members.insert(membership); members.replaceRoles(tenant.getId(), membershipId, List.of(ownerRole.getId())); + publishInitialSecurityProjection(authUserId, tenant.getId()); return result; } catch (RuntimeException localFailure) { compensate(context, result); @@ -152,6 +155,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni String password = temporaryPassword(); String username = createAuthAccountWithUniqueUsername(user.getAuthUserId(), password, tenant, owner.getId()); + publishInitialSecurityProjection(user.getAuthUserId(), tenant.getId()); return ProvisioningResult.created(user.getAuthUserId(), username, password); } if (!"ACTIVE".equals(account.status()) @@ -166,6 +170,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni List repairedRoleIds = new java.util.ArrayList<>(roleIds); repairedRoleIds.add(ownerRole.getId()); members.replaceRoles(tenant.getId(), owner.getId(), repairedRoleIds.stream().distinct().toList()); + securityEvents.permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT", + "MEMBER_ROLES_CHANGED"); } return ProvisioningResult.existing(); } @@ -230,6 +236,11 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni } } + private void publishInitialSecurityProjection(long authUserId, long tenantId) { + securityEvents.userStatus(authUserId, tenantId, "MERCHANT", "ACTIVE"); + securityEvents.permissionChanged(authUserId, tenantId, "MERCHANT", "INITIAL_AUTHORIZATION"); + } + private void compensate(long authUserId) { try { ApiResponse response = authClient.compensate(internalToken, authUserId); diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index 5d4195185..f93c268cc 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -16,6 +16,7 @@ import com.baiyue.iam.client.AuthAccountClient; import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.entity.IamMembership; import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.model.CustomerDimensionInitializationStatus; import com.baiyue.iam.dto.TenantDtos.ResetCustomerOwnerPasswordRequest; import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse; @@ -169,6 +170,7 @@ public class TenantService { tenant.setTenantName(request.tenantName()); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); + tenant.setDimensionInitializationStatus("PENDING"); tenant.setLegalName(request.legalName()); tenant.setLegalRepresentative(request.legalRepresentative()); tenant.setUnifiedSocialCreditCode(null); @@ -188,6 +190,7 @@ public class TenantService { tenant.setCreatedBy(operatorId); tenant.setUpdatedBy(operatorId); repository.insertTenant(tenant); + securityEvents.tenantStatus(tenant.getId(), tenant.getStatus()); dimensionRuleInitialization.initialize(tenant.getId(), operatorId); overseasStorageFeeInitialization.initialize(tenant.getId(), operatorId, tenant.getTenantName()); var administrator = administratorProvisioning.provisionPlatformCreatedTenant(tenant, operatorId); @@ -271,6 +274,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); @@ -292,6 +298,7 @@ public class TenantService { tenant.setApprovedAt(approve ? LocalDateTime.now() : null); tenant.setApprovedBy(approve ? reviewerId : null); repository.saveTenant(tenant); + securityEvents.tenantStatus(tenant.getId(), tenant.getStatus()); for (var qualification : profileRepository.findQualifications(tenant.getId())) { if ("PENDING".equals(qualification.getStatus())) { qualification.setStatus(approve ? "APPROVED" : "REJECTED"); @@ -321,6 +328,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 +349,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 +532,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 +555,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 +566,7 @@ public class TenantService { private TenantResponse merchantVisibleResponse(IamTenant tenant) { TenantResponse response = response(tenant); return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(), + response.dimensionInitializationStatus(), response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(), response.contactPhone(), response.contactEmail(), response.contactWechat(), response.servicePackage(), response.salesOwnerName(), response.paymentTermDays(), response.enterpriseType(), response.establishedDate(), diff --git a/iam/src/main/resources/application.yml b/iam/src/main/resources/application.yml index e724fd6a4..5faf2522f 100644 --- a/iam/src/main/resources/application.yml +++ b/iam/src/main/resources/application.yml @@ -23,6 +23,11 @@ spring: namespace: ${NACOS_NAMESPACE:public} encode: UTF-8 baiyue: + security-event: + event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} + customer-dimension-initialization: + fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} + max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} diff --git a/iam/src/main/resources/db/admin-prototype-menu-permissions.sql b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql index 4508de070..097bed02a 100644 --- a/iam/src/main/resources/db/admin-prototype-menu-permissions.sql +++ b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql @@ -29,7 +29,9 @@ VALUES (5105053,0,'PLATFORM','BUTTON',5105043,'platform:finance:settlement-ar:export','导出应收结算单',NULL,'POST',90,'ACTIVE',1,0), (5105054,0,'PLATFORM','BUTTON',5105044,'platform:finance:settlement-ap:export','导出应付结算单',NULL,'POST',90,'ACTIVE',1,0), (5105055,0,'PLATFORM','BUTTON',5105045,'platform:finance:receivable-statistics:export','导出应收款统计表',NULL,'POST',90,'ACTIVE',1,0), - (5205012,10001,'MERCHANT','BUTTON',5205000,'merchant:finance:statement:confirm','确认客户对账单',NULL,'POST',30,'ACTIVE',1,0), + (5305000,0,'MERCHANT','MENU',5300000,'merchant:finance:statement','财务账单','/finance/statements',NULL,40,'ACTIVE',1,0), + (5305010,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:list','查看账单',NULL,'GET',10,'ACTIVE',1,0), + (5305011,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:view','查看账单详情及收款',NULL,'GET',20,'ACTIVE',1,0), (5305012,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:confirm','确认客户对账单',NULL,'POST',30,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), @@ -101,18 +103,12 @@ UPDATE iam_permission p SET p.parent_id=mapping.parent_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON'; -INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) -SELECT 10001, r.id, p.id, CURRENT_TIMESTAMP(3) - FROM iam_role r - JOIN iam_permission p ON p.tenant_id=10001 AND p.app_code='MERCHANT' - WHERE r.tenant_id=10001 AND r.app_code='MERCHANT' AND r.role_code IN ('MERCHANT_OWNER','MERCHANT_ADMIN') - AND p.permission_code='merchant:finance:statement:confirm'; - INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='MERCHANT' WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 - AND p.permission_code='merchant:finance:statement:confirm' + AND p.permission_code IN ('merchant:finance:statement','merchant:finance:statement:list', + 'merchant:finance:statement:view','merchant:finance:statement:confirm') AND p.status='ACTIVE' AND p.deleted=0; diff --git a/iam/src/main/resources/db/customer-dimension-initialization-task.sql b/iam/src/main/resources/db/customer-dimension-initialization-task.sql new file mode 100644 index 000000000..2f1794d8e --- /dev/null +++ b/iam/src/main/resources/db/customer-dimension-initialization-task.sql @@ -0,0 +1,33 @@ +SET NAMES utf8mb4; +USE baiyue_iam; + +SET @customer_dimension_status_ddl = (SELECT IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='iam_tenant' AND column_name='dimension_initialization_status'), + 'SELECT 1', + 'ALTER TABLE iam_tenant ADD COLUMN dimension_initialization_status VARCHAR(16) NOT NULL DEFAULT ''READY'' COMMENT ''默认尺寸初始化状态:PENDING待处理、READY已就绪、FAILED失败'' AFTER status')); +PREPARE customer_dimension_status_stmt FROM @customer_dimension_status_ddl; +EXECUTE customer_dimension_status_stmt; +DEALLOCATE PREPARE customer_dimension_status_stmt; + +CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task ( + tenant_id BIGINT NOT NULL COMMENT '客户ID及任务聚合ID', + operator_id BIGINT NOT NULL COMMENT '触发初始化的操作人ID;系统补偿为0', + initialization_key VARCHAR(128) NOT NULL COMMENT '稳定初始化幂等键:iam-customer-create:{tenantId}', + action VARCHAR(16) NOT NULL DEFAULT 'INITIALIZE' COMMENT '目标动作:INITIALIZE初始化、COMPENSATE补偿', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING待投递、SENDING投递中、READY已就绪、FAILED失败、CANCELLED已补偿', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '当前动作已失败尝试次数', + next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许投递时间', + lease_owner VARCHAR(128) NULL COMMENT '当前任务租约持有者', + lease_until DATETIME(3) NULL COMMENT '当前任务租约截止时间', + last_error_code VARCHAR(32) NULL COMMENT '最后下游错误码', + last_error_message VARCHAR(1000) NULL COMMENT '最后诊断错误,不保存敏感凭证', + remote_config_id BIGINT NULL COMMENT 'Firstleg返回的首条默认尺寸配置ID', + task_version INT NOT NULL DEFAULT 0 COMMENT '任务乐观版本;切换动作或认领时递增', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '任务创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '任务更新时间', + completed_at DATETIME(3) NULL COMMENT 'READY或CANCELLED完成时间', + PRIMARY KEY (tenant_id), + KEY idx_customer_dimension_task_dispatch (status,next_retry_at,lease_until), + KEY idx_customer_dimension_task_diagnosis (status,updated_at) +) ENGINE=InnoDB COMMENT='IAM客户默认尺寸可靠后台初始化任务及诊断状态'; diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index c2dfaffb3..bc250abe7 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant ( tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称', tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户', status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用、ARCHIVED已归档', + dimension_initialization_status VARCHAR(16) NOT NULL DEFAULT 'READY' COMMENT '默认尺寸初始化状态:PENDING待处理、READY已就绪、FAILED失败', legal_name VARCHAR(128) NULL COMMENT '企业法定名称', legal_representative VARCHAR(64) NULL COMMENT '法定代表人', unified_social_credit_code VARCHAR(32) NULL COMMENT '统一社会信用代码', @@ -42,6 +43,28 @@ CREATE TABLE IF NOT EXISTS iam_tenant ( KEY idx_iam_tenant_status (status, created_at) ) ENGINE=InnoDB COMMENT='客户企业主档表:保存企业资料、审核结果和启停状态;ID同时作为客户数据隔离键'; +CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task ( + tenant_id BIGINT NOT NULL COMMENT '客户ID及任务聚合ID', + operator_id BIGINT NOT NULL COMMENT '触发初始化的操作人ID;系统补偿为0', + initialization_key VARCHAR(128) NOT NULL COMMENT '稳定初始化幂等键:iam-customer-create:{tenantId}', + action VARCHAR(16) NOT NULL DEFAULT 'INITIALIZE' COMMENT '目标动作:INITIALIZE初始化、COMPENSATE补偿', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING待投递、SENDING投递中、READY已就绪、FAILED失败、CANCELLED已补偿', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '当前动作已失败尝试次数', + next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许投递时间', + lease_owner VARCHAR(128) NULL COMMENT '当前任务租约持有者', + lease_until DATETIME(3) NULL COMMENT '当前任务租约截止时间', + last_error_code VARCHAR(32) NULL COMMENT '最后下游错误码', + last_error_message VARCHAR(1000) NULL COMMENT '最后诊断错误,不保存敏感凭证', + remote_config_id BIGINT NULL COMMENT 'Firstleg返回的首条默认尺寸配置ID', + task_version INT NOT NULL DEFAULT 0 COMMENT '任务乐观版本;切换动作或认领时递增', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '任务创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '任务更新时间', + completed_at DATETIME(3) NULL COMMENT 'READY或CANCELLED完成时间', + PRIMARY KEY (tenant_id), + KEY idx_customer_dimension_task_dispatch (status,next_retry_at,lease_until), + KEY idx_customer_dimension_task_diagnosis (status,updated_at) +) ENGINE=InnoDB COMMENT='IAM客户默认尺寸可靠后台初始化任务及诊断状态'; + CREATE TABLE IF NOT EXISTS iam_tenant_review ( id BIGINT NOT NULL COMMENT '审核记录ID', tenant_id BIGINT NOT NULL COMMENT '被审核客户ID和数据隔离键', diff --git a/iam/src/main/resources/db/phase1-admin-rbac.sql b/iam/src/main/resources/db/phase1-admin-rbac.sql index fcc5d4bde..0f00deb81 100644 --- a/iam/src/main/resources/db/phase1-admin-rbac.sql +++ b/iam/src/main/resources/db/phase1-admin-rbac.sql @@ -26,10 +26,7 @@ INSERT INTO iam_tenant ( approved_at, approved_by, created_by, deleted, version ) VALUES (0, 'PLATFORM', '虫虫出海平台', 'PLATFORM', 'ACTIVE', '虫虫出海平台', - NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0), - (10001, 'IT_TEST', 'IT 测试客户', 'MERCHANT', 'ACTIVE', 'IT 测试客户', - '91310000ITTEST0001', 'IT 测试客户管理员', '13800000000', 'it-test@example.com', - NOW(3), 1000001, 1000001, 0, 0) + NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0) ON DUPLICATE KEY UPDATE tenant_code = VALUES(tenant_code), tenant_name = VALUES(tenant_name), tenant_type = VALUES(tenant_type), status = 'ACTIVE', legal_name = VALUES(legal_name), @@ -38,28 +35,10 @@ ON DUPLICATE KEY UPDATE contact_email = VALUES(contact_email), approved_at = COALESCE(approved_at, NOW(3)), approved_by = VALUES(approved_by), deleted = 0; --- The built-in IT customer must be immediately usable for order acceptance tests. --- These approved metadata rows intentionally have no document URL: initialization must not --- point at qualification files that do not exist in File service storage. -INSERT INTO iam_tenant_qualification ( - id, tenant_id, qualification_type, certificate_no, document_url, - valid_from, valid_to, status, review_comment, deleted -) VALUES - (2100001, 10001, 'BUSINESS_LICENSE', '91310000ITTEST0001', NULL, - NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0), - (2100002, 10001, 'LEGAL_PERSON_ID', NULL, NULL, - NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0) -ON DUPLICATE KEY UPDATE - tenant_id = VALUES(tenant_id), qualification_type = VALUES(qualification_type), - certificate_no = VALUES(certificate_no), status = 'APPROVED', - review_comment = VALUES(review_comment), deleted = 0; - INSERT INTO iam_user ( id, auth_user_id, user_type, display_name, phone, email, status, deleted, version ) VALUES - (2000001, 1000001, 'INTERNAL', '平台系统管理员', NULL, NULL, 'ACTIVE', 0, 0), - (2000002, 1000002, 'ENTERPRISE', 'IT 测试客户管理员', '13800000000', - 'it-test@example.com', 'ACTIVE', 0, 0) + (2000001, 1000001, 'INTERNAL', '平台系统管理员', NULL, NULL, 'ACTIVE', 0, 0) ON DUPLICATE KEY UPDATE display_name = VALUES(display_name), phone = VALUES(phone), email = VALUES(email), status = 'ACTIVE', deleted = 0; @@ -67,8 +46,7 @@ ON DUPLICATE KEY UPDATE INSERT INTO iam_membership ( id, tenant_id, user_id, member_type, member_no, status, joined_at, deleted, version ) VALUES - (3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0), - (3000002, 10001, 2000002, 'OWNER', 'MERCHANT-OWNER-001', 'ACTIVE', NOW(3), 0, 0) + (3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0) ON DUPLICATE KEY UPDATE member_type = VALUES(member_type), member_no = VALUES(member_no), status = 'ACTIVE', joined_at = COALESCE(joined_at, NOW(3)), left_at = NULL, deleted = 0; @@ -78,9 +56,7 @@ INSERT INTO iam_role ( ) VALUES (4000001, 0, 'PLATFORM', 'PLATFORM_ADMIN', '平台超级管理员', 'SYSTEM', 'ACTIVE', '一期平台全部菜单和按钮', 0, 0), (4000002, 0, 'PLATFORM', 'CUSTOMER_REVIEWER', '企业审核员', 'SYSTEM', 'ACTIVE', '企业资料与入驻审核', 0, 0), - (4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0), - (4000101, 10001, 'MERCHANT', 'MERCHANT_OWNER', '企业管理员', 'SYSTEM', 'ACTIVE', '本企业一期全部菜单和按钮', 0, 0), - (4000102, 10001, 'MERCHANT', 'MERCHANT_ORDER_MANAGER', '企业订单管理员', 'SYSTEM', 'ACTIVE', '本企业头程订单操作', 0, 0) + (4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0) ON DUPLICATE KEY UPDATE role_name = VALUES(role_name), role_type = VALUES(role_type), status = 'ACTIVE', description = VALUES(description), deleted = 0; @@ -167,40 +143,8 @@ SELECT 4000003, id, 0 FROM iam_permission WHERE id IN (5102019, 5102024, 5102025) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); --- Merchant: level-one menus, level-two menus and buttons. -INSERT INTO iam_permission - (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) -VALUES - (5200000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0), - (5201000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0), - (5202000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0), - (5200010, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0), - (5200020, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0), - (5200030, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0), - (5201010, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), - (5201020, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0), - (5202010, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), - (5202020, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0), - (5200011, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0), - (5200012, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0), - (5200021, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0), - (5200022, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:grant-role', '分配角色', NULL, NULL, 20, 'ACTIVE', 0), - (5200031, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), - (5200032, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), - (5201011, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0), - (5201012, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0), - (5201021, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0), - (5201022, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0), - (5201023, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0), - (5201024, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) -ON DUPLICATE KEY UPDATE - permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), - permission_name = VALUES(permission_name), route_path = VALUES(route_path), - http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; - INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES - (3000001, 4000001, 0), - (3000002, 4000101, 10001) + (3000001, 4000001, 0) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -- Full administrator roles receive all phase-one permissions in their application scope. @@ -209,11 +153,6 @@ SELECT 4000001, id, 0 FROM iam_permission WHERE app_code = 'PLATFORM' AND tenant_id = 0 AND permission_type IN ('MENU', 'BUTTON') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE app_code = 'MERCHANT' AND tenant_id = 10001 AND permission_type IN ('MENU', 'BUTTON') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - -- Limited platform and merchant operational roles. INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000002, id, 0 FROM iam_permission @@ -229,15 +168,8 @@ SELECT 4000003, id, 0 FROM iam_permission WHERE permission_code LIKE 'platform:order%' OR permission_code LIKE 'platform:firstleg%' ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000102, id, 10001 FROM iam_permission -WHERE permission_code LIKE 'merchant:order%' OR permission_code IN ('merchant:master-data', 'merchant:warehouse:list') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression) VALUES (6000001, 4000001, 0, 'TENANT', NULL), (6000002, 4000002, 0, 'TENANT', NULL), - (6000003, 4000003, 0, 'TENANT', NULL), - (6000101, 4000101, 10001, 'TENANT', NULL), - (6000102, 4000102, 10001, 'TENANT', NULL) + (6000003, 4000003, 0, 'TENANT', NULL) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id), scope_type = VALUES(scope_type), scope_expression = NULL; diff --git a/iam/src/main/resources/db/phase1-member-permissions.sql b/iam/src/main/resources/db/phase1-member-permissions.sql index 8a88a7896..bddb3b44d 100644 --- a/iam/src/main/resources/db/phase1-member-permissions.sql +++ b/iam/src/main/resources/db/phase1-member-permissions.sql @@ -17,20 +17,11 @@ DEALLOCATE PREPARE member_no_index_stmt; INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, sort_no, status, deleted) VALUES - (5200023, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), - (5200024, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), - (5200025, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0), (5300023, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), (5300024, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), (5300025, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), status = VALUES(status), deleted = 0; -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE tenant_id = 10001 AND permission_code IN - ('merchant:member:edit', 'merchant:member:status', 'merchant:member:reset-password') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000100, id, 0 FROM iam_permission WHERE tenant_id = 0 AND permission_code IN diff --git a/iam/src/main/resources/db/phase1-onboarding.sql b/iam/src/main/resources/db/phase1-onboarding.sql index a31f31127..27a7da36e 100644 --- a/iam/src/main/resources/db/phase1-onboarding.sql +++ b/iam/src/main/resources/db/phase1-onboarding.sql @@ -41,17 +41,34 @@ INSERT INTO iam_role ( ) ON DUPLICATE KEY UPDATE status='ACTIVE', deleted=0; --- Clone the一期 merchant menu/button template into the global scope. +-- Global merchant permission template used by every formally onboarded customer. INSERT INTO iam_permission ( id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted ) -SELECT id + 100000, 0, app_code, permission_type, - CASE WHEN parent_id = 0 THEN 0 ELSE parent_id + 100000 END, - permission_code, permission_name, route_path, NULL, sort_no, 'ACTIVE', 0 -FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_type IN ('MENU', 'BUTTON') +VALUES + (5300000, 0, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0), + (5301000, 0, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0), + (5302000, 0, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0), + (5300010, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0), + (5300020, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0), + (5300030, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0), + (5301010, 0, 'MERCHANT', 'MENU', 5301000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), + (5301020, 0, 'MERCHANT', 'MENU', 5301000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0), + (5302010, 0, 'MERCHANT', 'MENU', 5302000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), + (5302020, 0, 'MERCHANT', 'MENU', 5302000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0), + (5300011, 0, 'MERCHANT', 'BUTTON', 5300010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0), + (5300012, 0, 'MERCHANT', 'BUTTON', 5300010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0), + (5300021, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0), + (5300022, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:grant-role', '分配成员角色', NULL, NULL, 20, 'ACTIVE', 0), + (5300031, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), + (5300032, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), + (5301011, 0, 'MERCHANT', 'BUTTON', 5301010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0), + (5301012, 0, 'MERCHANT', 'BUTTON', 5301010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0), + (5301021, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0), + (5301022, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0), + (5301023, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0), + (5301024, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), parent_id=VALUES(parent_id), sort_no=VALUES(sort_no), status='ACTIVE', deleted=0; diff --git a/iam/src/main/resources/db/rbac-role-management-v2.sql b/iam/src/main/resources/db/rbac-role-management-v2.sql index 5228b9b71..87f2ea5de 100644 --- a/iam/src/main/resources/db/rbac-role-management-v2.sql +++ b/iam/src/main/resources/db/rbac-role-management-v2.sql @@ -19,9 +19,6 @@ VALUES (5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停用户', 30, 'ACTIVE', 0), (5101014, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:reset-password', '重置密码', 40, 'ACTIVE', 0), (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 0), - (5200033, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), - (5200034, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), - (5200035, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0), (5300033, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), (5300034, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), (5300035, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0) @@ -40,12 +37,6 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM' 'platform:user:grant-role') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_code IN ('merchant:role:edit', 'merchant:role:status', 'merchant:role:delete') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000100, id, 0 FROM iam_permission WHERE tenant_id = 0 AND app_code = 'MERCHANT' diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 4fa22a7e6..a3d146541 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -59,7 +59,7 @@ paths: post: tags: [Platform Tenant / 平台租户] summary: Register enterprise tenant / 创建企业客户草稿 - description: Create a `PROFILE_REQUIRED` enterprise draft and its OWNER account, returning one-time credentials. Saving the customer does not create a review task. / 创建待完善企业客户草稿及 OWNER 账号并返回一次性凭据,保存客户不会创建审核单。 + description: Create a `PROFILE_REQUIRED` enterprise draft and its OWNER account, returning one-time credentials. Customer creation succeeds after IAM commits; default dimensions initialize reliably in background and are exposed as `PENDING/READY/FAILED`. / 创建待完善企业客户草稿及 OWNER 账号并返回一次性凭据;IAM 提交即代表客户创建成功,默认尺寸在后台可靠初始化并显式返回 PENDING/READY/FAILED。 requestBody: required: true content: @@ -123,6 +123,38 @@ paths: $ref: '#/components/schemas/PlatformCustomerMembersApiResponse' '403': {$ref: '#/components/responses/Forbidden'} '404': {$ref: '#/components/responses/NotFound'} + /api/platform/v1/iam/tenants/{tenantId}/dimension-initialization: + parameters: + - {$ref: '#/components/parameters/TenantId'} + get: + tags: [Platform Tenant / 平台租户] + summary: 查询客户默认尺寸初始化诊断 + description: 返回任务状态、尝试次数、下次重试时间和最后失败原因;不会把远端失败转换为空数据或成功。 + responses: + '200': + description: 初始化诊断查询成功 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {$ref: '#/components/schemas/CustomerDimensionInitializationStatus'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + /api/platform/v1/iam/tenants/{tenantId}/dimension-initialization/retry: + parameters: + - {$ref: '#/components/parameters/TenantId'} + post: + tags: [Platform Tenant / 平台租户] + summary: 人工重试失败的客户默认尺寸初始化 + description: 只允许 FAILED 任务重新进入 PENDING;稳定 initializationKey 不变。 + responses: + '200': {$ref: '#/components/responses/TenantSuccess'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} /api/platform/v1/iam/tenants/{tenantId}/submit-review: parameters: - {$ref: '#/components/parameters/TenantId'} @@ -1085,12 +1117,13 @@ components: pages: {type: integer, minimum: 0} TenantResponse: type: object - required: [id, tenantCode, tenantName, status, servicePackage] + required: [id, tenantCode, tenantName, status, dimensionInitializationStatus, servicePackage] properties: id: {type: string, pattern: '^\d+$', example: '190000000000000001'} tenantCode: {type: string} tenantName: {type: string} status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]} + dimensionInitializationStatus: {type: string, enum: [PENDING, READY, FAILED], description: 客户默认尺寸初始化状态;未 READY 时依赖该配置的业务失败关闭} legalName: {type: string} legalRepresentative: {type: string, maxLength: 64} unifiedSocialCreditCode: {type: string} @@ -1109,6 +1142,17 @@ components: internalRemark: {type: string, maxLength: 1000, description: 仅平台端可见} createdAt: {type: string, format: date-time, description: 入驻时间} approvedAt: {type: string, format: date-time, description: 审批通过时间} + CustomerDimensionInitializationStatus: + type: object + required: [tenantId, status, attemptCount, nextRetryAt, updatedAt] + properties: + tenantId: {type: string, pattern: '^\d+$'} + status: {type: string, enum: [PENDING, SENDING, READY, FAILED, CANCELLED]} + attemptCount: {type: integer, minimum: 0} + nextRetryAt: {type: string, format: date-time} + lastErrorCode: {type: string, nullable: true} + lastErrorMessage: {type: string, nullable: true, description: 已清洗且截断的最后失败诊断} + updatedAt: {type: string, format: date-time} FinanceCustomerSummary: type: object required: [id, tenantCode, tenantName, status] diff --git a/iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java b/iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java new file mode 100644 index 000000000..92692f92d --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java @@ -0,0 +1,35 @@ +package com.baiyue.iam.client; + +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.FeignClient; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class SecurityEventBridgeClientContractTest { + private static final Path REPOSITORY_ROOT = Path.of("..").toAbsolutePath().normalize(); + + @Test + void feignUsesTheExplicitOverseasBridgePropertyInsteadOfSameNameDiscovery() { + FeignClient annotation = SecurityEventBridgeClient.class.getAnnotation(FeignClient.class); + + assertThat(annotation.name()).isEqualTo("event-bridge-service"); + assertThat(annotation.url()).isEqualTo("${baiyue.security-event.event-bridge-url:}"); + } + + @Test + void cnIamConfigurationTargetsTheOverseasBridgeNetworkAlias() throws Exception { + String application = Files.readString(REPOSITORY_ROOT.resolve("iam/src/main/resources/application.yml")); + String nacos = Files.readString(REPOSITORY_ROOT.resolve("config/nacos/cn/iam-service.yml")); + String compose = Files.readString(REPOSITORY_ROOT.resolve("deploy/docker-compose.yml")); + + assertThat(application).contains( + "event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}"); + assertThat(nacos).contains( + "event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}"); + assertThat(compose).contains( + "SECURITY_EVENT_BRIDGE_URL: ${SECURITY_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312}"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java index 2190fbe70..f8cd21e76 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java @@ -31,6 +31,9 @@ class AdminPrototypeMenuPermissionSqlContractTest { assertThat(sql).contains("(" + (5105050 + index) + ",0,'PLATFORM','BUTTON'," + (5105040 + index) + ",'" + FINANCE_EXPORTS[index] + "'"); } assertThat(sql) + .contains("(5305000,0,'MERCHANT','MENU',5300000,'merchant:finance:statement'") + .contains("(5305010,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:list'") + .contains("(5305011,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:view'") .contains("(5305012,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:confirm'") .contains("r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER'") .contains("'platform:finance:fee-source:review' code, 5105041 parent_id") @@ -60,8 +63,8 @@ class AdminPrototypeMenuPermissionSqlContractTest { .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32") .contains("(p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33") .contains("Finance 导出和下载中心授权不完整") - .contains("merchant:finance:statement:confirm") - .contains("客户全局管理员缺少对账单确认权限") + .contains("客户全局 Finance 权限模板不完整") + .contains("客户全局管理员 Finance 模板授权不完整") .satisfies(content -> assertThat(content.indexOf("(5105050,0,'PLATFORM','BUTTON'")) .isLessThan(content.indexOf("CREATE PROCEDURE assert_finance_permissions()"))); } diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java new file mode 100644 index 000000000..0c22f9ecf --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java @@ -0,0 +1,64 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class CustomerDimensionInitializationSqlContractTest { + @Test + void latestIamSqlContainsStatusAndDurableTask() throws Exception { + String sql = new ClassPathResource("db/iam.sql").getContentAsString(StandardCharsets.UTF_8); + assertContract(sql); + } + + @Test + void idempotentStandaloneAndUnifiedSqlContainTheSameTaskContract() throws Exception { + String standalone = new ClassPathResource("db/customer-dimension-initialization-task.sql") + .getContentAsString(StandardCharsets.UTF_8); + Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + Path unifiedPath = Files.exists(direct) ? direct + : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8); + + assertContract(standalone); + assertContract(unified); + assertThat(standalone).contains("information_schema.columns", "CREATE TABLE IF NOT EXISTS"); + assertThat(unified).contains("information_schema.columns", "CREATE TABLE IF NOT EXISTS"); + } + + @Test + void schedulerRetryBudgetHasSafeOverridableDefaultsInAllDomesticProfiles() throws Exception { + String application = new ClassPathResource("application.yml").getContentAsString(StandardCharsets.UTF_8); + assertSchedulerConfig(application); + for (String profile : new String[] {"local", "cn"}) { + Path direct = Path.of("config", "nacos", profile, "iam-service.yml"); + Path config = Files.exists(direct) ? direct + : Path.of("..", "config", "nacos", profile, "iam-service.yml"); + assertSchedulerConfig(Files.readString(config, StandardCharsets.UTF_8)); + } + } + + private static void assertContract(String sql) { + assertThat(sql) + .contains("dimension_initialization_status") + .contains("iam_customer_dimension_initialization_task") + .contains("initialization_key") + .contains("attempt_count") + .contains("next_retry_at") + .contains("lease_until") + .contains("last_error_code") + .contains("task_version"); + } + + private static void assertSchedulerConfig(String yaml) { + assertThat(yaml) + .contains("customer-dimension-initialization:") + .contains("${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000}") + .contains("${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12}"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java index 86f26ec04..64008a223 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java @@ -11,25 +11,42 @@ import static org.assertj.core.api.Assertions.assertThat; class InitialIdentitySeedSqlContractTest { @Test - void initializesPlatformAndItTestCustomerIdentitiesWithStableIds() throws Exception { - assertIdentitySeed(new ClassPathResource("db/phase1-admin-rbac.sql") - .getContentAsString(StandardCharsets.UTF_8)); - assertIdentitySeed(Files.readString(unifiedSql(), StandardCharsets.UTF_8)); + void initializesOnlyPlatformIdentityAndGlobalMerchantTemplates() throws Exception { + String identitySeed = new ClassPathResource("db/phase1-admin-rbac.sql") + .getContentAsString(StandardCharsets.UTF_8); + String templateSeed = new ClassPathResource("db/phase1-onboarding.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unified = Files.readString(unifiedSql(), StandardCharsets.UTF_8); + assertPlatformIdentitySeed(identitySeed); + assertPlatformIdentitySeed(unified); + assertGlobalMerchantTemplate(templateSeed); + assertGlobalMerchantTemplate(unified); } - private static void assertIdentitySeed(String sql) { + private static void assertPlatformIdentitySeed(String sql) { assertThat(sql) .contains("(0, 'PLATFORM', '虫虫出海平台'") - .contains("(10001, 'IT_TEST', 'IT 测试客户'") .contains("(2000001, 1000001, 'INTERNAL', '平台系统管理员'") - .contains("(2000002, 1000002, 'ENTERPRISE', 'IT 测试客户管理员'") .contains("(3000001, 0, 2000001, 'EMPLOYEE'") - .contains("(3000002, 10001, 2000002, 'OWNER'") .contains("tenant_code = VALUES(tenant_code)") + .doesNotContain("merchant_admin") + .doesNotContain("IT_TEST") + .doesNotContain("(10001,") + .doesNotContain("(2000002,") + .doesNotContain("(3000002,") + .doesNotContain("4000101, 10001") .doesNotContain("PHASE1_DEMO") .doesNotContain("一期演示企业"); } + private static void assertGlobalMerchantTemplate(String sql) { + assertThat(sql) + .contains("4000100, 0, 'MERCHANT', 'MERCHANT_OWNER'") + .contains("5300000, 0, 'MERCHANT', 'MENU'") + .contains("5301024, 0, 'MERCHANT', 'BUTTON'") + .doesNotContain("WHERE tenant_id = 10001 AND app_code = 'MERCHANT'"); + } + private static Path unifiedSql() { Path path = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); return Files.exists(path) ? path : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); diff --git a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java new file mode 100644 index 000000000..bdd692767 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java @@ -0,0 +1,105 @@ +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.anyInt; +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(), anyInt(), any(), anyString(), + anyString(), any(Boolean.class)); + } + + @Test + void transientFailureStaysPendingWithDiagnosticAndBackoff() { + CustomerDimensionInitializationTask task = task("INITIALIZE", 0); + when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task); + when(client.initialize(anyString(), any())).thenThrow(new RuntimeException("read timeout")); + + relay.deliver(10001L); + + ArgumentCaptor exhausted = ArgumentCaptor.forClass(Boolean.class); + verify(repository).recordFailure(any(), anyString(), anyInt(), any(), anyString(), + anyString(), exhausted.capture()); + assertFalse(exhausted.getValue()); + } + + @Test + void permanentRemoteRejectionBecomesFailedWithoutDefaultSuccess() { + CustomerDimensionInitializationTask task = task("INITIALIZE", 0); + when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task); + when(client.initialize(anyString(), any())).thenReturn(ApiResponse.failure( + ResultCode.CONFLICT, "invalid customer", "request-1", "trace-1")); + + relay.deliver(10001L); + + ArgumentCaptor exhausted = ArgumentCaptor.forClass(Boolean.class); + verify(repository).recordFailure(any(), anyString(), anyInt(), any(), anyString(), + anyString(), exhausted.capture()); + assertTrue(exhausted.getValue()); + verify(repository, never()).markReady(any(), anyString(), any()); + } + + @Test + void compensationIsAlsoDurableAndUsesTheStableKey() { + CustomerDimensionInitializationTask task = task("COMPENSATE", 0); + when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task); + when(client.compensate("internal-token", 10001L, "iam-customer-create:10001")) + .thenReturn(ApiResponse.success(null)); + + relay.deliver(10001L); + + verify(repository).markCancelled(task, workerId()); + } + + private CustomerDimensionInitializationTask task(String action, int attempts) { + return new CustomerDimensionInitializationTask(10001L, 9001L, "iam-customer-create:10001", + action, "SENDING", attempts, 3); + } + + private String workerId() { + return (String) ReflectionTestUtils.getField(relay, "workerId"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java index 459a72fab..b56e9c63d 100644 --- a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java @@ -1,138 +1,61 @@ package com.baiyue.iam.service; -import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.iam.client.CustomerDimensionRuleClient; -import jakarta.annotation.Resource; -import org.junit.jupiter.api.AfterEach; +import com.baiyue.iam.repository.CustomerDimensionInitializationRepository; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; - -import java.util.List; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@SpringJUnitConfig(CustomerDimensionRuleInitializationService.class) -@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +@ExtendWith(MockitoExtension.class) class CustomerDimensionRuleInitializationServiceTest { - @MockBean private CustomerDimensionRuleClient client; - @Resource private CustomerDimensionRuleInitializationService service; - - @AfterEach - void clearSynchronization() { - if (TransactionSynchronizationManager.isSynchronizationActive()) { - TransactionSynchronizationManager.clearSynchronization(); - } - } + @Mock private CustomerDimensionInitializationRepository repository; + @InjectMocks private CustomerDimensionRuleInitializationService service; @Test - void retriesOneAtomicFourBoxDefaultsCallWithAStableKey() { - when(client.initialize(anyString(), any())) - .thenThrow(new RuntimeException("timeout")) - .thenReturn(ApiResponse.failure(ResultCode.SERVICE_UNAVAILABLE, - "temporarily unavailable", "request-1", "trace-1")) - .thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, false))); - + void customerCreationOnlyPersistsBackgroundIntentWithStableKey() { var result = service.initialize(10001L, 9001L); assertFalse(result.created()); - ArgumentCaptor requests = - ArgumentCaptor.forClass(CustomerDimensionRuleClient.InitializationRequest.class); - verify(client, times(3)).initialize(anyString(), requests.capture()); - assertEquals(List.of("iam-customer-create:10001", "iam-customer-create:10001", - "iam-customer-create:10001"), - requests.getAllValues().stream().map( - CustomerDimensionRuleClient.InitializationRequest::initializationKey).toList()); - assertEquals(List.of(9001L, 9001L, 9001L), requests.getAllValues().stream() - .map(CustomerDimensionRuleClient.InitializationRequest::operatorId).toList()); - assertEquals(List.of(10001L, 10001L, 10001L), requests.getAllValues().stream() - .map(CustomerDimensionRuleClient.InitializationRequest::tenantId).toList()); + assertEquals("PENDING", result.status()); + assertEquals("iam-customer-create:10001", result.initializationKey()); + verify(repository).enqueue(10001L, 9001L, "iam-customer-create:10001"); } @Test - void reportsServiceUnavailableWhenFourBoxDefaultsFailThreeTimesAndTriesCompensation() { - when(client.initialize(anyString(), any())).thenThrow(new RuntimeException("timeout")); - when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null)); - - BizException failure = assertThrows(BizException.class, () -> service.initialize(10001L, 9001L)); - - assertEquals(ResultCode.SERVICE_UNAVAILABLE, failure.getResultCode()); - verify(client, times(3)).initialize(anyString(), any()); - verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001"); - } - - @Test - void localRollbackCompensatesFourBoxDefaultsCreatedByThisRegistration() { - when(client.initialize(anyString(), any())) - .thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, true))); - when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null)); - TransactionSynchronizationManager.initSynchronization(); - - var result = service.initialize(10001L, 9001L); - List synchronizations = - TransactionSynchronizationManager.getSynchronizations(); - TransactionSynchronizationManager.clearSynchronization(); - synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK)); - - assertTrue(result.created()); - verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001"); - } - - @Test - void localRollbackKeepsFourBoxDefaultsThatAlreadyExisted() { - when(client.initialize(anyString(), any())) - .thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, false))); - TransactionSynchronizationManager.initSynchronization(); - - service.initialize(10001L, 9001L); - List synchronizations = - TransactionSynchronizationManager.getSynchronizations(); - TransactionSynchronizationManager.clearSynchronization(); - synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK)); - - verify(client, never()).compensate(anyString(), anyLong(), anyString()); - } - - @Test - void responseTimeoutThenIdempotentFourBoxSuccessStillCompensatesOnLocalRollback() { - when(client.initialize(anyString(), any())) - .thenThrow(new RuntimeException("response timeout")) - .thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, true))); - when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null)); - TransactionSynchronizationManager.initSynchronization(); - - service.initialize(10001L, 9001L); - List synchronizations = - TransactionSynchronizationManager.getSynchronizations(); - TransactionSynchronizationManager.clearSynchronization(); - synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK)); - - verify(client, times(2)).initialize(anyString(), any()); - verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001"); - } - - @Test - void committedClientRegistrationCanBeCompensatedWithItsDeterministicKey() { - when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null)); - + void committedRegistrationCompensationUsesTheSameStableKey() { service.compensateRegistration(10001L); + verify(repository).requestCompensation(10001L, 0L, "iam-customer-create:10001"); + } - verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001"); + @Test + void failedTaskCanBeManuallyRequeued() { + when(repository.manualRetry(10001L)).thenReturn(true); + service.retry(10001L); + verify(repository).manualRetry(10001L); + } + + @Test + void nonFailedTaskCannotBeSilentlyRequeued() { + when(repository.manualRetry(10001L)).thenReturn(false); + BizException failure = assertThrows(BizException.class, () -> service.retry(10001L)); + assertEquals(ResultCode.CONFLICT, failure.getResultCode()); + } + + @Test + void dimensionsDependentFlowFailsClosedUntilReady() { + BizException pending = assertThrows(BizException.class, () -> service.requireReady("PENDING")); + BizException failed = assertThrows(BizException.class, () -> service.requireReady("FAILED")); + assertEquals(ResultCode.CONFLICT, pending.getResultCode()); + assertEquals(ResultCode.CONFLICT, failed.getResultCode()); + service.requireReady("READY"); } } diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java index e653c4c01..b69d85274 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java @@ -14,6 +14,7 @@ import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.repository.TenantRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import jakarta.annotation.Resource; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; @@ -135,6 +136,27 @@ class MemberServiceTest { verify(repository, never()).insert(any()); } + @Test void createPublishesActiveUserAndInitialAuthorizationAfterRoleAssignment() { + IamRole role = activeMerchantRole(50L, 100L); + when(roleMapper.selectById(50L)).thenReturn(role); + when(auth.create(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); + when(auth.summary(eq("internal-token"), anyLong())).thenAnswer(invocation -> ApiResponse.success( + new AuthAccountClient.AccountSummary(invocation.getArgument(1), "member02", "成员", "ACTIVE"))); + when(repository.findRoleIds(eq(100L), anyLong())).thenReturn(List.of(50L)); + CreateMemberRequest request = new CreateMemberRequest("member02", "Password1", "成员", + null, null, "EMPLOYEE", "M-002", List.of(50L)); + + service.create(100L, request, Set.of("merchant:member:create", "merchant:member:grant-role")); + + ArgumentCaptor account = + ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class); + verify(auth).create(eq("internal-token"), account.capture()); + long authUserId = account.getValue().accountId(); + verify(repository).replaceRoles(eq(100L), anyLong(), eq(List.of(50L))); + verify(securityEvents).userStatus(authUserId, 100L, "MERCHANT", "ACTIVE"); + verify(securityEvents).permissionChanged(authUserId, 100L, "MERCHANT", "INITIAL_AUTHORIZATION"); + } + @Test void shouldHandleCase08() { IamRole role = activeMerchantRole(50L, 100L); when(roleMapper.selectById(50L)).thenReturn(role); diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java index ade730ea0..2b48686ef 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java @@ -41,6 +41,7 @@ class MerchantOnboardingServiceTest { @MockBean private IamTenantReviewMapper reviews; @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; + @MockBean private SecurityEventOutboxService securityEvents; @Resource private MerchantOnboardingService service; @Test void clientRegistrationCreatesOwnerAndAssignsGlobalMerchantOwnerRole() { @@ -67,8 +68,14 @@ class MerchantOnboardingServiceTest { verify(membershipRoles).insert(roleGrant.capture()); verify(dimensionRuleInitialization).initialize(result.tenantId(), 9001L); verify(overseasStorageFeeInitialization).initialize(result.tenantId(), 9001L, "测试企业"); + verify(securityEvents).tenantStatus(result.tenantId(), "PROFILE_REQUIRED"); + verify(securityEvents).userStatus(9001L, result.tenantId(), "MERCHANT", "ACTIVE"); + verify(securityEvents).permissionChanged(9001L, result.tenantId(), "MERCHANT", + "INITIAL_AUTHORIZATION"); assertEquals("PROFILE_REQUIRED", result.tenantStatus()); + assertEquals("PENDING", result.dimensionInitializationStatus()); + assertEquals("PENDING", tenant.getValue().getDimensionInitializationStatus()); assertEquals(result.tenantId(), tenant.getValue().getId()); assertEquals("测试企业", tenant.getValue().getLegalName()); assertEquals("张三", tenant.getValue().getLegalRepresentative()); @@ -117,7 +124,7 @@ class MerchantOnboardingServiceTest { } @Test - void clientRegistrationFailsWhenFourDefaultBoxDimensionsCannotBeInitialized() { + void clientRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() { IamRole ownerRole = new IamRole(); ownerRole.setId(4000100L); ownerRole.setTenantId(0L); diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java index adaccd1b1..6e8da47c5 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java @@ -85,6 +85,39 @@ class PlatformInternalUserServiceTest { verify(repository).insertUser(any(IamUser.class)); verify(repository).insert(any(IamMembership.class)); verify(repository).replaceRoles(eq(0L), anyLong(), eq(List.of(40L))); + long authUserId = captor.getValue().accountId(); + verify(securityEvents).userStatus(authUserId, 0L, "PLATFORM", "ACTIVE"); + verify(securityEvents).permissionChanged(authUserId, 0L, "PLATFORM", "INITIAL_AUTHORIZATION"); + } + + @Test + void linkingExistingAccountPublishesInitialProjectionOnlyForNewMembership() { + IamRole role = new IamRole(); + role.setId(40L); role.setTenantId(0L); role.setAppCode("PLATFORM"); role.setStatus("ACTIVE"); + when(roleMapper.selectById(40L)).thenReturn(role); + when(repository.findUserByAuthId(10L)).thenReturn(user); + when(repository.findByTenantAndAuthUser(0L, 10L)).thenReturn(null); + CreateInternalUserRequest request = new CreateInternalUserRequest(10L, null, "平台运营", + "13800138000", null, null, null, "PLATFORM-LINK", List.of(40L)); + + service.create(request); + + verify(repository).replaceRoles(eq(0L), anyLong(), eq(List.of(40L))); + verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "ACTIVE"); + verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", "INITIAL_AUTHORIZATION"); + verify(auth, never()).create(anyString(), any()); + } + + @Test + void relinkingExistingMembershipDoesNotDuplicateInitialProjection() { + when(repository.findByTenantAndAuthUser(0L, 10L)).thenReturn(membership); + CreateInternalUserRequest request = new CreateInternalUserRequest(10L, null, "平台运营", + "13800138000", null, null, null, "PLATFORM-30", List.of()); + + service.create(request); + + verifyNoInteractions(securityEvents); + verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java index 4dd7ddfb6..fb4949914 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -12,6 +12,7 @@ import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.repository.MemberRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import jakarta.annotation.Resource; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; @@ -20,6 +21,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.time.LocalDateTime; import java.util.List; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -85,6 +87,30 @@ class PlatformUserManagementServiceTest { verify(auth, never()).create(anyString(), any()); } + @Test + void createPublishesActualStatusAndInitialAuthorizationAfterRolesAreStored() { + AtomicReference createdUser = new AtomicReference<>(); + doAnswer(invocation -> { createdUser.set(invocation.getArgument(0)); return null; }) + .when(members).insertUser(any(IamUser.class)); + when(members.findUser(anyLong())).thenAnswer(invocation -> createdUser.get()); + when(members.findRoleIds(eq(0L), anyLong())).thenReturn(List.of(40001L)); + when(auth.create(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); + when(auth.summary(eq("internal-token"), anyLong())).thenAnswer(invocation -> ApiResponse.success( + new AuthAccountClient.AccountSummary(invocation.getArgument(1), "newuser", "新用户", "DISABLED"))); + CreateUserRequest request = new CreateUserRequest("newuser", "新用户", "13900139000", + "new@example.com", 0L, List.of(40001L), "Password1", "DISABLED"); + + service.create(request); + + ArgumentCaptor account = + ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class); + verify(auth).create(eq("internal-token"), account.capture()); + long authUserId = account.getValue().accountId(); + verify(members).replaceRoles(eq(0L), anyLong(), eq(List.of(40001L))); + verify(securityEvents).userStatus(authUserId, 0L, "PLATFORM", "DISABLED"); + verify(securityEvents).permissionChanged(authUserId, 0L, "PLATFORM", "INITIAL_AUTHORIZATION"); + } + @Test void optionsContainOnlyPlatformTenantAndPlatformRoles() { UserOptions result = service.options(); diff --git a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java new file mode 100644 index 000000000..aa212d14b --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java @@ -0,0 +1,91 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.IamSecurityEventsV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.iam.client.SecurityEventBridgeClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SecurityEventOutboxRelayTest { + @Mock private JdbcTemplate jdbc; + @Mock private SecurityEventBridgeClient client; + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + private SecurityEventOutboxRelay relay; + + @BeforeEach + void setUp() { + relay = new SecurityEventOutboxRelay(); + ReflectionTestUtils.setField(relay, "jdbc", jdbc); + ReflectionTestUtils.setField(relay, "client", client); + ReflectionTestUtils.setField(relay, "objectMapper", objectMapper); + ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); + ReflectionTestUtils.setField(relay, "maxAttempts", 12); + } + + @Test + void cnToOverseasEventIsSentOnlyAfterMatchingBridgeAcknowledgement() throws Exception { + EventEnvelope envelope = envelope(RegionCode.OVERSEAS); + when(jdbc.update(anyString(), any(Object[].class))).thenReturn(1); + when(jdbc.queryForObject(anyString(), eq(String.class), any(), any())) + .thenReturn(objectMapper.writeValueAsString(envelope)); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success( + new SecurityEventBridgeClient.Acknowledgement(envelope.eventId(), "ACCEPTED", "2026-08-25T07:00:00Z"))); + + relay.deliver(envelope.eventId()); + + ArgumentCaptor sent = ArgumentCaptor.forClass(EventEnvelope.class); + verify(client).receive(eq("internal-token"), sent.capture()); + assertThat(sent.getValue().sourceRegion()).isEqualTo(RegionCode.CN); + assertThat(sent.getValue().targetRegion()).isEqualTo(RegionCode.OVERSEAS); + verify(jdbc).update(anyString(), any(), any(), eq(envelope.eventId()), anyString()); + } + + @Test + void nonOverseasTargetNeverReachesTheBridge() throws Exception { + EventEnvelope envelope = envelope(RegionCode.CN); + when(jdbc.update(anyString(), any(Object[].class))).thenReturn(1); + when(jdbc.queryForObject(anyString(), eq(String.class), any(), any())) + .thenReturn(objectMapper.writeValueAsString(envelope)); + when(jdbc.queryForObject(anyString(), eq(Integer.class), eq(envelope.eventId()))).thenReturn(0); + + relay.deliver(envelope.eventId()); + + verify(client, never()).receive(anyString(), any(EventEnvelope.class)); + verify(jdbc).update(anyString(), eq("PENDING"), eq(1), any(), anyString(), any(), + eq(envelope.eventId()), anyString()); + } + + private EventEnvelope envelope(RegionCode target) { + var value = new IamSecurityEventsV1.PermissionVersionChanged( + 101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z")); + var payload = objectMapper.valueToTree(value); + return new EventEnvelope("00000000-0000-4000-8000-000000000081", + EventTypes.IAM_PERMISSION_VERSION_CHANGED, IamSecurityEventsV1.VERSION, + Instant.parse("2026-08-25T07:00:00Z"), RegionCode.CN, target, "0", + "AUTHORIZATION", "101", 3L, null, "trace-12345678", "correlation-12345678", + null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java index 432587f3f..fb1ce9e8b 100644 --- a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java @@ -1,10 +1,13 @@ package com.baiyue.iam.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.IamSecurityEventsV1; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -43,4 +46,29 @@ class SecurityEventOutboxServiceTest { EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class); assertThat(envelope.requireValid().correlationId()).isNotBlank().isEqualTo(envelope.traceId()); } + + @ParameterizedTest + @CsvSource({ + "ACTIVE,ACTIVE", + "PROFILE_REQUIRED,DISABLED", + "PENDING,DISABLED", + "REJECTED,DISABLED", + "DISABLED,DISABLED", + "ARCHIVED,DISABLED" + }) + void tenantWorkflowStatusIsNormalizedToTheSecurityContract(String businessStatus, + String expectedSecurityStatus) throws Exception { + when(jdbc.queryForObject(anyString(), eq(Long.class), any())).thenReturn(3L); + + service.tenantStatus(10001L, businessStatus); + + ArgumentCaptor parameters = ArgumentCaptor.forClass(Object[].class); + verify(jdbc).update(eq("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) " + + "VALUES (?,?,?,?,CAST(? AS JSON))"), parameters.capture()); + EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class); + IamSecurityEventsV1.TenantStatusChanged payload = objectMapper.treeToValue( + envelope.payload(), IamSecurityEventsV1.TenantStatusChanged.class); + assertThat(payload.status()).isEqualTo(expectedSecurityStatus); + assertThat(envelope.aggregateVersion()).isEqualTo(3L); + } } diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java index 8dfbe466c..8e76d8a1d 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java @@ -34,6 +34,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; @@ -43,6 +44,7 @@ class TenantAdministratorProvisioningServiceTest { @MockBean private MemberRepository members; @MockBean private IamRoleMapper roles; @MockBean private AuthAccountClient auth; + @MockBean private SecurityEventOutboxService securityEvents; @Resource private TenantAdministratorProvisioningService service; private IamTenant tenant; @@ -101,6 +103,9 @@ class TenantAdministratorProvisioningServiceTest { verify(members).insertUser(any(IamUser.class)); verify(members).insert(any(IamMembership.class)); verify(members).replaceRoles(eq(tenant.getId()), anyLong(), eq(List.of(4000100L))); + verify(securityEvents).userStatus(account.getValue().accountId(), tenant.getId(), "MERCHANT", "ACTIVE"); + verify(securityEvents).permissionChanged(account.getValue().accountId(), tenant.getId(), "MERCHANT", + "INITIAL_AUTHORIZATION"); } @Test @@ -135,6 +140,7 @@ class TenantAdministratorProvisioningServiceTest { assertEquals(null, result.temporaryPassword()); verify(auth, never()).create(anyString(), any()); verify(members, never()).replaceRoles(anyLong(), anyLong(), any()); + verifyNoInteractions(securityEvents); } @Test @@ -153,6 +159,8 @@ class TenantAdministratorProvisioningServiceTest { assertFalse(result.created()); verify(auth, never()).create(anyString(), any()); verify(members).replaceRoles(tenant.getId(), owner.getId(), List.of(4000200L, 4000100L)); + verify(securityEvents).permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT", + "MEMBER_ROLES_CHANGED"); } @Test @@ -190,6 +198,9 @@ class TenantAdministratorProvisioningServiceTest { verify(members, never()).insertUser(any()); verify(members, never()).insert(any()); verify(members, never()).replaceRoles(anyLong(), anyLong(), any()); + verify(securityEvents).userStatus(user.getAuthUserId(), tenant.getId(), "MERCHANT", "ACTIVE"); + verify(securityEvents).permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT", + "INITIAL_AUTHORIZATION"); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index ca4291de3..eb7bb54dc 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -313,6 +313,7 @@ class TenantServiceTest { 9001L); assertEquals("PROFILE_REQUIRED", created.tenant().status()); + assertEquals("PENDING", created.tenant().dimensionInitializationStatus()); assertEquals("Bob", created.tenant().legalRepresentative()); assertEquals("基础版", created.tenant().servicePackage()); assertEquals("13800138000", created.administratorUsername()); @@ -320,12 +321,13 @@ class TenantServiceTest { verify(repository).insertTenant(argThat(tenant -> Long.valueOf(9001L).equals(tenant.getCreatedBy()) && Long.valueOf(9001L).equals(tenant.getUpdatedBy()))); verify(dimensionRuleInitialization).initialize(created.tenant().id(), 9001L); + verify(securityEvents).tenantStatus(created.tenant().id(), "PROFILE_REQUIRED"); verify(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L)); verify(repository, never()).insertReview(any()); } @Test - void platformRegistrationFailsWhenFourDefaultBoxDimensionsCannotBeInitialized() { + void platformRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() { doThrow(new BizException(ResultCode.SERVICE_UNAVAILABLE, "初始化失败")) .when(dimensionRuleInitialization).initialize(any(Long.class), any(Long.class)); @@ -428,6 +430,7 @@ class TenantServiceTest { assertEquals("ACTIVE", tenant.getStatus()); verify(repository).saveReview(review); verify(repository).saveTenant(tenant); + verify(securityEvents).tenantStatus(10001L, "ACTIVE"); } @Test @@ -489,12 +492,26 @@ class TenantServiceTest { assertEquals(true, withQualification.eligible()); } + @Test + void activeCustomerCannotOrderUntilDefaultDimensionsAreReady() { + IamTenant tenant = merchant(10001L, "ACTIVE"); + tenant.setDimensionInitializationStatus("FAILED"); + when(repository.findTenant(10001L)).thenReturn(tenant); + + var result = service.orderEligibility(10001L); + + assertEquals(false, result.eligible()); + assertEquals("Customer default dimensions are not ready", result.reason()); + verify(profileRepository, never()).findQualifications(10001L); + } + private IamTenant merchant(long id, String status) { IamTenant tenant = new IamTenant(); tenant.setId(id); tenant.setTenantCode("TEN" + id); tenant.setTenantType("MERCHANT"); tenant.setStatus(status); + tenant.setDimensionInitializationStatus("READY"); tenant.setTenantName("Example Merchant"); tenant.setLegalName("Example Merchant Ltd"); tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001"); diff --git a/iam/src/test/resources/db/verify-phase1-admin-rbac.sql b/iam/src/test/resources/db/verify-phase1-admin-rbac.sql index e2942472f..24dce2749 100644 --- a/iam/src/test/resources/db/verify-phase1-admin-rbac.sql +++ b/iam/src/test/resources/db/verify-phase1-admin-rbac.sql @@ -1,13 +1,17 @@ -- Any returned row represents a failed phase-one seed assertion. SELECT 'missing_auth_accounts' AS failure WHERE (SELECT COUNT(*) FROM baiyue_auth.auth_account - WHERE username IN ('platform_admin', 'merchant_admin') AND account_status = 'ACTIVE') <> 2 + WHERE username = 'platform_admin' AND account_status = 'ACTIVE') <> 1 UNION ALL SELECT 'missing_active_identities' WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_user u JOIN baiyue_iam.iam_membership m ON m.user_id = u.id - WHERE u.auth_user_id IN (1000001, 1000002) - AND u.status = 'ACTIVE' AND m.status = 'ACTIVE') <> 2 + WHERE u.auth_user_id = 1000001 + AND u.status = 'ACTIVE' AND m.status = 'ACTIVE') <> 1 +UNION ALL +SELECT 'unexpected_fixed_customer_seed' +WHERE EXISTS (SELECT 1 FROM baiyue_iam.iam_tenant WHERE tenant_code = 'IT_TEST') + OR EXISTS (SELECT 1 FROM baiyue_auth.auth_account WHERE username = 'merchant_admin') UNION ALL SELECT 'unexpected_permission_type' WHERE EXISTS (SELECT 1 FROM baiyue_iam.iam_permission @@ -34,10 +38,6 @@ SELECT 'platform_admin_not_fully_granted' WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission WHERE role_id = 4000001) <> (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND permission_type IN ('MENU', 'BUTTON')) UNION ALL -SELECT 'merchant_owner_not_fully_granted' -WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission WHERE role_id = 4000101) - <> (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 10001 AND app_code = 'MERCHANT' AND permission_type IN ('MENU', 'BUTTON')) -UNION ALL SELECT 'missing_global_merchant_owner_template' WHERE NOT EXISTS ( SELECT 1 FROM baiyue_iam.iam_role diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 0c374a698..477b6158d 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -2,7 +2,7 @@ ## 职责与边界 -- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立尾程订单、明细冻结快照和初始状态历史。 +- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立待分配尾程订单、明细冻结快照和初始状态历史;事件不得自动创建入仓计划,平台人工调用 `/api/platform/v1/last-leg/inbound-plans/convert` 后才生成计划并推进订单。 - 负责尾程订单列表、详情、导出和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业;PDA 卸柜完成后通过本地 Outbox 向国内头程回传货柜报空事实。 - 不查询头程数据库,不调用头程接口补齐建单字段;事件快照缺失时应拒绝消费并保留可诊断错误。 @@ -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 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 | @@ -19,6 +20,7 @@ | 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper | | 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 | | 内部令牌保护 | `security/InternalServiceTokenGuard.java` | +| IAM 海外安全投影与实时校验 | 内部入口 `security/controller/LastlegSecurityController.java`,固定 `/internal/lastleg/security/v1/events`、`/validate` 且复用 `InternalServiceTokenGuard`;投影/校验 `security/service/`,DTO `security/dto/`,表固定 `ll_security_event_inbox`、`ll_security_projection`;MySQL 投影缺失、落后或不可用一律 fail-closed,Redis 仅为提交后可重建镜像;业务切片不得直接依赖投影内部类,边界由 `SecurityProjectionArchitectureTest` 与 Node 合同锁定 | | 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java` | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | @@ -36,7 +38,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 建单、独立入仓单快照及下游转运/换标可消费性 | @@ -46,7 +49,7 @@ - 四期在本服务内只进入尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购和本土卡车;本土散板新增实现也归本服务的独立稳定子域。 - `dispatch/`、`delivery/`、出库单、PDA 出库拣货、派送和其他库存离仓能力虽然已存在于服务代码或表结构中,但不属于当前四期开发与验收范围,不得因代码已存在而自动纳入。 - 历史排车编辑/取消/出库、PDA 出库和 `delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。平台排车列表与详情由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。预排只能创建待出库排车并锁定托盘,不得借此开放编辑、取消、depart、PDA 出库或派送。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。 -- 海外仓库、库位、实物托盘和资源锁归 `warehouse-overseas-service/`;浏览器 API 统一经 `gateway-overseas/`,本服务不得复制仓储资源主数据或另建公网认证入口。 +- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway-overseas/`。 ## 数据库表索引与注释约束 @@ -60,6 +63,7 @@ | 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 | | 采购、卡车与本土散板 | `ll_overseas_procurement_*`、`ll_us_truck_*`、`ll_local_looseboard_*` | 海外采购、美国卡车运输及本土散板订单 | | 通用资料 | `ll_overseas_quote*`、`ll_monthly_exchange_rate*`、`ll_vehicle` | 海外仓报价、月度汇率及车辆档案;不保存车辆派送履约历史 | +| 海外安全投影 | `ll_security_event_inbox`、`ll_security_projection` | IAM 安全事件可靠收件箱与 Lastleg 本地授权投影;只允许内部服务入口访问,不作为 loading、shipping、inbound 等业务模型 | - 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义,不得只写英文、字段名或类型名。 - `SchemaChineseCommentContractTest` 自动扫描全部 `ll_` 表,无需维护表名白名单;新增字段必须同步更新基线 SQL、建表增量和存量备注迁移,增量脚本的 `ADD/MODIFY COLUMN` 不得丢失中文备注。 @@ -73,7 +77,11 @@ - 其他订单类型建立入仓单维度的尾程订单与冻结明细,并保留头程推荐托数;尾程入仓计划至少按推荐托数生成计划托盘,PDA 再把箱级事实绑定到海外仓实物托盘。 - 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。 - 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 +- 装柜完成事件只把尾程订单建立为 `PENDING_ASSIGNMENT`。`FIRST_LEG` 在途库存使用不可变来源入仓码作为计划生成前的 `inbound_order_no` 展示快照;平台人工转换成功后,同一事务将订单推进为 `INBOUND_PLANNED`,并把对应库存余额的该快照收口为新 `planNo`。 - 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 +- IAM 安全事件只能进入 `/internal/lastleg/security/v1/events`;通用 `/internal/lastleg/events` 只白名单接收装柜完成与实际船节点事件,禁止把 IAM 事件交给 `LoadingCompletedConsumer`。 +- Lastleg 安全投影只接受 `ACTIVE`/`DISABLED` 两态安全语义;客户建档和审核的 `PROFILE_REQUIRED`/`PENDING`/`REJECTED`/`ARCHIVED` 由 IAM 在发布前统一折叠为 `DISABLED`,Lastleg 不兼容或解释国内工作流状态。 +- 开发基线只为国内固定 `authUserId=1000001`、`tenantId=0` 的 `platform_admin` 写 USER/AUTHORIZATION version=0 投影;使用 `INSERT IGNORE`,后续 IAM version>=1 事件单调覆盖。不得扩大为历史账号运行时补齐或缺失投影默认放行。 - PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 - 海外 PDA 柜号签收只允许平台 `WAREHOUSE_PICKER` 使用 `lastleg:pda:operate`;PDA 不接收 `customerId`,候选跨客户读取,选柜时从柜内计划唯一解析并冻结真实客户。未完成会话按平台操作账号和柜号分别全局唯一,后续照片、扫码、实收、库存与完成操作只使用会话客户范围;平台管理员和客户账号不得进入该作业入口。 @@ -91,6 +99,8 @@ ## 修改与验证 +- 移动包、拆分端口/适配器或引入新的跨子域依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-lastleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。 +- 安全投影边界变更同时运行 `SecurityProjectionArchitectureTest` 与 `LastlegSecurityContractTest`;`security/controller|dto|service` 只允许依赖本子域和根 `InternalServiceTokenGuard`,其他 Lastleg 业务包不得直接依赖这些投影内部包。 - 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。 - 事件契约变化同时检查 `common-event-contract` 版本、Event Bridge 消费方和旧 Payload 哈希兼容。 - 定向验证:`mvn -pl lastleg -am test`。 diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md index 0acced4c7..f0627b41b 100644 --- a/lastleg-service/docs/lastleg-internal-modularization-baseline.md +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -2,7 +2,7 @@ ## 1. 范围与生成方式 -本基线只分析 `lastleg-service` 同一部署单元,不拆微服务,不修改业务代码、POM、数据库、OpenAPI、权限、接口或跨服务契约。统计时工作区同时存在 RestClient 超时专项的在途修改;本专项不修改 `client/`、`config/*RestClient*` 或各业务 HTTP Gateway。 +本基线只分析 `lastleg-service` 同一部署单元,不拆微服务,不修改业务流程、数据库、OpenAPI、权限、接口或跨服务契约。统计时工作区同时存在其他专项的在途修改;本专项不修改 `client/`、`config/*RestClient*` 或各业务 HTTP Gateway。模块内 POM 只增加由根 POM 统一版本管理的 ArchUnit 测试依赖。 可重复命令: @@ -16,15 +16,15 @@ node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all - node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format dot --graph slice ``` -分析器解析显式内部 `import`、静态 `import` 及同包类型引用,输出类依赖、物理包图、逻辑切片图、Tarjan SCC、Entity/DTO/Model 泄漏与 Mapper 泄漏。`test` 单独输出测试包节点及测试类之间的边;当前测试主要引用生产类型,因此 test-only 图有 67 个包节点但没有测试类之间的内部边。`all` 把 `src/main/java` 和 `src/test/java` 合并解析,是观察测试对生产类型耦合的主图。Spring 扫描字符串、反射、MyBatis SQL 表引用和跨服务运行时调用不属于 Java 类型图。 +分析器解析显式内部 `import`、静态 `import` 及同包类型引用,输出类依赖、物理包图、逻辑切片图、Tarjan SCC、Entity/DTO/Model 泄漏与 Mapper 泄漏。`test` 单独输出测试包节点及测试类之间的边;当前测试主要引用生产类型,因此 test-only 图有 68 个包节点但没有测试类之间的内部边。`all` 把 `src/main/java` 和 `src/test/java` 合并解析,是观察测试对生产类型耦合的主图。Spring 扫描字符串、反射、MyBatis SQL 表引用和跨服务运行时调用不属于 Java 类型图。 ## 2. 当前规模与 SCC | 源集 | Java 文件 | 内部类依赖 | 物理包 | 跨切片类依赖 | 模型泄漏 | Mapper 泄漏 | |---|---:|---:|---:|---:|---:|---:| -| main | 347 | 672 | 157 | 137 | 60 | 38 | -| test only | 98 | 0 | 67 | 0 | 0 | 0 | -| main + test | 445 | 1,195 | 161 | 256 | 108 | 76 | +| main | 353 | 679 | 160 | 138 | 60 | 38 | +| test only | 103 | 0 | 68 | 0 | 0 | 0 | +| main + test | 456 | 1,206 | 164 | 257 | 108 | 76 | 主源码物理包图没有多节点 SCC。主源码逻辑切片图存在一个四节点 SCC: @@ -57,7 +57,8 @@ com.baiyue.lastleg.transfer.service | 换标/转运/废弃 `relabel-transfer-discard` | `relabel/`、`transfer/`、`discard/`,其 PDA 子包保持本域归属 | 61 | 31/0 | 直接使用尾程、库存和一件代发持久化模型;业务事务边界复杂 | | 一件代发 `dropship` | `dropship/` | 12 | 4/11 | 与尾程消费及废弃库存双向耦合,位于主 SCC | | 采购/卡车/散板 `procurement-trucking-looseboard` | `procurement/`、`trucking/`、`looseboard/` | 35 | 11/0 | 依赖尾程核心,采购另读取报价目录;仍由四期业务专项维护 | -| 海外主数据 `overseas-master-data` | `masterdata/quote`、`exchange`、`vehicle` | 26 | 3/1 | 不在任何 SCC;仅出向共享权限常量,入向来自采购对报价目录的使用 | +| 海外主数据 `overseas-master-data` | `masterdata/quote`、`exchange`、`vehicle` | 28 | 3/1 | 不在任何 SCC;仅出向共享权限常量,入向来自采购对报价目录的使用 | +| 海外安全投影 `security-projection` | `security/controller`、`security/dto`、`security/service` | 6 | 1/0 | 独立于业务切片与主 SCC;唯一出向为内部 Controller 复用根 `InternalServiceTokenGuard`,无业务切片入向依赖 | 另有 6 个 `shared-technical` 类:应用启动类、Event Bridge Client、RestClient 配置、内部令牌保护和海外权限常量。该分类不授权移动这些共享类。 @@ -86,12 +87,13 @@ com.baiyue.lastleg.transfer.service | relabel-transfer-discard | inventory | 6 | | relabel-transfer-discard | shared-technical | 3 | | relabel-transfer-discard | tailleg-handoff | 14 | +| security-projection | shared-technical | 1 | | tailleg-handoff | dropship | 2 | | tailleg-handoff | inbound-pda | 6 | | tailleg-handoff | inventory | 2 | | tailleg-handoff | shared-technical | 2 | -当前 25 条切片边冻结在 `src/test/resources/architecture/lastleg-slice-dependencies.json`。合同允许删除历史边,不允许增加新边;海外主数据继续锁定为只允许出向 `shared-technical`,只允许采购/卡车/散板切片入向。 +当前 26 条切片边冻结在 `src/test/resources/architecture/lastleg-slice-dependencies.json`。合同允许删除历史边,不允许增加新边;海外主数据继续锁定为只允许出向 `shared-technical`,只允许采购/卡车/散板切片入向;安全投影只允许出向 `shared-technical` 且禁止任何业务切片入向依赖。 ## 5. Entity/DTO/Mapper 泄漏基线 @@ -175,8 +177,9 @@ Web Adapter 可继续依赖 Spring Web、common-security 和 `OverseasPermission ## 9. 当前只读合同 - `scripts/analyze-lastleg-dependencies.mjs`:生成 JSON、Markdown、逻辑切片 DOT、main/test/all 物理包 DOT 和 Tarjan SCC。 -- `src/test/resources/architecture/lastleg-slice-dependencies.json`:冻结 25 条 main 切片边;历史边可减少,不可增加。 -- `scripts/analyze-lastleg-dependencies.test.mjs`:校验基线、main/test/all 包图输出,以及车辆候选的 5 类、零入向、唯一权限出向、零 Client/RestClient 和不进入 SCC。 +- `src/test/resources/architecture/lastleg-slice-dependencies.json`:冻结 26 条 main 切片边;历史边可减少,不可增加,并锁定安全投影 6 个类的位置、单一出向与零入向。 +- `scripts/analyze-lastleg-dependencies.test.mjs`:校验基线、main/test/all 包图输出、车辆切片边界及安全投影隔离。 +- `src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java`:用 ArchUnit 锁定投影内部只依赖本子域和根安全技术类、业务包不得反向依赖投影、DTO/Service 不依赖内部 Web Adapter。 最小验证: @@ -197,3 +200,11 @@ node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all - - 验证结果:Node 合同 4/4;`mvn -pl lastleg-service -am test-compile` 成功;车辆定向测试 5/5;`mvn -pl lastleg-service -am test` 中 lastleg-service 370 个测试,0 失败、0 错误、2 个环境验收跳过,构建成功。 首切片完成后停止,不进入第二切片。 + +## 11. 海外安全投影合入后的边界 + +- 用户确认安全投影保留在 `lastleg-service` 部署单元内,不拆新微服务;逻辑上独立为 `security-projection` 切片。 +- Controller 仅映射 `/internal/lastleg/security/v1/events` 与 `/validate`,使用 `@Hidden` 并在进入投影或校验服务前调用根 `InternalServiceTokenGuard`。 +- MySQL 表固定使用 Lastleg 前缀 `ll_security_event_inbox`、`ll_security_projection`;没有 `overseas_security_*` 表或独立数据库归属。 +- `loading`、`shipping`、`inboundplan` 及其他业务切片不得直接依赖 `security/controller|dto|service`;当前 Java 类型图零入向,Node 与 ArchUnit 双重锁定。 +- 安全投影不进入主逻辑 SCC,唯一出向为内部 Controller 复用共享令牌守卫,不注册为 loading/shipping/inbound 的模型、Mapper 或应用服务。 diff --git a/lastleg-service/pom.xml b/lastleg-service/pom.xml index 938233013..d1274b7a8 100644 --- a/lastleg-service/pom.xml +++ b/lastleg-service/pom.xml @@ -19,6 +19,7 @@ org.springframework.bootspring-boot-starter-actuator com.mysqlmysql-connector-jruntime org.springframework.bootspring-boot-starter-testtest + com.tngtech.archunitarchunittest org.springframework.bootspring-boot-maven-plugin diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs index c639c1bcd..a2488d164 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs @@ -12,6 +12,7 @@ const sourceRoots = { const internalPrefix = 'com.baiyue.lastleg.'; const sliceRules = [ + ['security-projection', /(?:^|\.)security\.(?:controller|dto|service)(?:\.|$)/], ['dispatch-delivery', /(?:^|\.)(?:dispatch|delivery|outbound)(?:\.|$)/], ['relabel-transfer-discard', /(?:^|\.)(?:relabel|transfer|discard)(?:\.|$)/], ['inbound-pda', /(?:^|\.)(?:inboundplan|palletops|pda)(?:\.|$)/], diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index bb8a6a945..28469687c 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -74,3 +74,25 @@ test('vehicle application only depends inward and stays free of adapter framewor && edge.target.startsWith('com.baiyue.lastleg.') && !edge.target.startsWith(prefix)); assert.deepEqual(outwardDependencies, []); }); + +test('security projection stays isolated from Lastleg business slices', () => { + const report = analyze(); + const projectionClasses = report.classes.filter(entry => entry.slice === 'security-projection'); + const projectionNames = new Set(projectionClasses.map(entry => entry.fqcn)); + assert.deepEqual(projectionClasses.map(entry => entry.fqcn).sort(), [ + 'com.baiyue.lastleg.security.controller.LastlegSecurityController', + 'com.baiyue.lastleg.security.dto.LastlegSecurityDtos', + 'com.baiyue.lastleg.security.service.LastlegSecurityProjectionRedisMirror', + 'com.baiyue.lastleg.security.service.LastlegSecurityProjectionService', + 'com.baiyue.lastleg.security.service.LastlegSecurityValidationService', + 'com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity', + ]); + assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'security-projection'), [ + { source: 'security-projection', target: 'shared-technical', count: 1 }, + ]); + assert.deepEqual(report.sliceEdges.filter(edge => edge.target === 'security-projection'), []); + assert.equal(report.sliceSccs.some(component => component.includes('security-projection')), false); + const businessConsumers = report.classDependencies.filter(edge => projectionNames.has(edge.target) + && !projectionNames.has(edge.source)); + assert.deepEqual(businessConsumers, []); +}); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 3bdb1f82d..3f8c3da74 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -44,8 +44,9 @@ public class InboundPlanController { @PostMapping("/convert") @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") - /** 将入仓单转换为入仓计划。 */ - @Operation(summary = "入仓单转入仓计划", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) + /** 由平台人工将待分配尾程订单转换为入仓计划。 */ + @Operation(summary = "人工创建入仓计划", description = "头程装柜完成只生成待分配尾程订单;平台确认后调用本接口创建入仓计划。Required authority: " + + OverseasPermissions.INBOUND_ORDER_CREATE) public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ConvertInboundPlanRequest request) { return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java index 4b3e93b8b..1b02cf639 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.pda.dto; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; @@ -14,7 +15,8 @@ public final class ReceivingContainerRequests { public record Action(@NotBlank @Size(max = 255) String idempotencyKey) { } public record Sign(@NotNull @Size(min = 3, max = 3) List<@Valid SignedPhoto> photos, @NotBlank @Size(max = 255) String idempotencyKey) { } - public record SignedPhoto(@NotNull ReceivingPhotoRole role, @Positive long fileId) { } + public record SignedPhoto(@NotNull ReceivingPhotoRole role, + @NotBlank @Pattern(regexp = "^[1-9][0-9]{0,18}$") String fileId) { } public record CartonResolve(@NotBlank String cartonMark) { } public record SkuResolve(@NotBlank @Size(max = 128) String skuCode) { } public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java index 656c83a1a..dda326fbe 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java @@ -17,7 +17,7 @@ public final class ReceivingContainerResponses { int totalPackages, int receivedPackages, int pendingPackages, List inbounds) { } - public record SignedPhoto(ReceivingPhotoRole role, long fileId) { } + public record SignedPhoto(ReceivingPhotoRole role, String fileId) { } public record Inbound(String inboundCode, String destinationType, String overseasWarehouseId, String overseasWarehouseName, @@ -48,5 +48,5 @@ public final class ReceivingContainerResponses { String destinationType, String palletCode, int acceptedPackages, int receivedPackages, int pendingPackages, String sessionStatus) { } - public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { } + public record Photo(String fileId, String fileName, String contentType, long sizeBytes) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 092e88c99..cab86a93b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -169,7 +169,7 @@ public class ReceivingContainerService { String customerId = session.getCustomerId(); if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Unload photos can only be uploaded before signing"); UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(customerId, session.getId(), operatorId, file); - return new ReceivingContainerResponses.Photo(uploaded.fileId(), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes()); + return new ReceivingContainerResponses.Photo(Long.toString(uploaded.fileId()), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes()); } @Transactional @@ -181,7 +181,7 @@ public class ReceivingContainerService { String customerId = session.getCustomerId(); if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed"); List photos = validateSignPhotos(request); - List photoFileIds = photos.stream().map(ReceivingContainerResponses.SignedPhoto::fileId).toList(); + List photoFileIds = photos.stream().map(photo -> positiveFileId(photo.fileId())).toList(); photoFileIds.forEach(fileId -> unloadPhotos.validate(customerId, session.getId(), fileId)); LocalDateTime now = LocalDateTime.now(); session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now); @@ -513,9 +513,9 @@ public class ReceivingContainerService { Set roles = request.photos().stream() .map(ReceivingContainerRequests.SignedPhoto::role).collect(Collectors.toSet()); if (roles.size() != 3 || roles.contains(null)) throw conflict("Each unload photo role is required exactly once"); - Set fileIds = request.photos().stream().map(ReceivingContainerRequests.SignedPhoto::fileId) + Set fileIds = request.photos().stream().map(value -> positiveFileId(value.fileId())) .collect(Collectors.toSet()); - if (fileIds.size() != 3 || fileIds.stream().anyMatch(value -> value == null || value <= 0)) { + if (fileIds.size() != 3) { throw conflict("Unload photo file IDs must be positive and unique"); } return request.photos().stream() @@ -523,6 +523,16 @@ public class ReceivingContainerService { .map(value -> new ReceivingContainerResponses.SignedPhoto(value.role(), value.fileId())).toList(); } + private long positiveFileId(String value) { + try { + long fileId = Long.parseLong(value); + if (fileId > 0) return fileId; + } catch (NumberFormatException ignored) { + // Validation normally rejects this before the service; keep the domain fail-closed. + } + throw conflict("Unload photo file ID must be a positive decimal string"); + } + private SkuRef requireSku(InboundRef ref, String skuCode) { SkuRef result = skuRef(ref, skuCode); if (result == null) throw notFound("SKU is not part of the selected inbound code"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 7e43558ff..90e8c7df0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -18,6 +18,7 @@ import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanQueryMapper; import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -41,6 +42,7 @@ public class InboundPlanService { @Resource private InboundPlanPalletAllocationMapper allocationMapper; @Resource private InboundPlanOrderMapper orderMapper; @Resource private InboundPlanQueryMapper queryMapper; + @Resource private InventoryBalanceMapper inventoryBalanceMapper; @Transactional(readOnly = true) public PageResult page(String customerId, InboundPlanRequests.Query query) { @@ -147,6 +149,8 @@ public class InboundPlanService { if (orderMapper.markPlanned(customerId, order.orderId(), plan.getPlanNo(), count, now) != 1) { throw new IllegalStateException("Failed to update order " + order.orderId()); } + inventoryBalanceMapper.bindInboundPlan(customerId, order.orderId(), order.inboundCode(), + plan.getPlanNo(), now); orderMapper.insertHistory(customerId, order.orderId(), order.currentStatus(), "Inbound order converted to inbound plan " + plan.getPlanNo(), now); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java index 437a9dcab..9fddae72c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java @@ -5,7 +5,9 @@ import com.baiyue.lastleg.inventory.model.InventorySummary; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import java.time.LocalDateTime; import java.util.List; public interface InventoryBalanceMapper extends BaseMapper { @@ -29,6 +31,14 @@ public interface InventoryBalanceMapper extends BaseMapper { @Param("inboundOrderId") Long inboundOrderId, @Param("inboundCode") String inboundCode); + @Update("UPDATE ll_inventory_balance SET inbound_order_no=#{planNo},updated_at=#{updatedAt} " + + "WHERE customer_id=#{customerId} AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode}") + int bindInboundPlan(@Param("customerId") String customerId, + @Param("inboundOrderId") Long inboundOrderId, + @Param("inboundCode") String inboundCode, + @Param("planNo") String planNo, + @Param("updatedAt") LocalDateTime updatedAt); + @Select("