diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5d86bd1dc..3fd5c31fe 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -97,7 +97,6 @@ OpenAPI有效性: - finance/target/*.jar - warehouse/target/*.jar - warehouse-overseas-service/target/*.jar - - overseas-security-service/target/*.jar - lastleg-service/target/*.jar - file/target/*.jar - print/target/*.jar diff --git a/AGENTS.md b/AGENTS.md index cf6ed7318..8fd132bd1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ | 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` | | 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | | 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | -| 海外 IAM 状态与权限版本投影 | `overseas-security-service/` | `overseas-security-service/AGENTS.md`;内部入口 `/internal/overseas-security/v1/events`、`/validate`,契约 `src/main/resources/openapi/internal-overseas-security-v1.yaml`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验 | +| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` | | 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | @@ -83,7 +83,7 @@ - 数据库只访问所属服务库。模块表结构位于 `/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。 - 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - Java 签名变更的最低编译门禁是 `mvn -pl -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 -- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、海外 Gateway 覆盖、海外安全服务部署映射、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 +- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、海外 Gateway 覆盖、Lastleg 海外安全投影映射、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 - 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。 ## 全量 HTTP 验收脚本路由 diff --git a/Jenkinsfile b/Jenkinsfile index be2593cce..a06ba1017 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,6 +1,6 @@ def serviceModules = [ 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' ] def serviceCheckboxes = [ @@ -17,7 +17,6 @@ def serviceCheckboxes = [ 'integration-service': 'SERVICE_INTEGRATION', 'lastleg-service': 'SERVICE_LASTLEG', 'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS', - 'overseas-security-service': 'SERVICE_OVERSEAS_SECURITY', 'event-bridge-service': 'SERVICE_EVENT_BRIDGE', 'audit-service': 'SERVICE_AUDIT' ] @@ -69,7 +68,6 @@ pipeline { booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)') booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)') booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)') - booleanParam(name: 'SERVICE_OVERSEAS_SECURITY', defaultValue: false, description: '海外IAM安全投影服务(overseas-security-service)') booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;默认同时部署国内和海外实例)') choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS'], description: 'BOTH 同时部署国内和海外实例;CN/OVERSEAS 用于单区故障重跑') booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)') diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 88c8bac39..bd81515d2 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -1,6 +1,6 @@ def serviceModules = [ 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' ] def serviceJars = [ @@ -17,7 +17,6 @@ def serviceJars = [ 'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar', 'lastleg-service': 'lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar', 'warehouse-overseas-service': 'warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar', - 'overseas-security-service': 'overseas-security-service/target/overseas-security-service-1.0.0-SNAPSHOT.jar', 'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar', 'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar' ] diff --git a/README.md b/README.md index 604e465ae..0f2913388 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - `firstleg`:头程揽收、入仓、装载、发运和交接。 - `warehouse`:国内仓储能力。 - `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。 -- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`、`overseas-security-service`:海外公网入口、海外尾程与入仓业务、海外仓储资源及 IAM 安全投影。 +- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`:海外公网入口、海外尾程/入仓与 IAM 安全投影、海外仓储资源。 - `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。 - `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。 - `platform-bom`:统一管理仓库内公共依赖版本。 diff --git a/config/nacos/cn/iam-service.yml b/config/nacos/cn/iam-service.yml index 32dcc05c7..48a0d8795 100644 --- a/config/nacos/cn/iam-service.yml +++ b/config/nacos/cn/iam-service.yml @@ -5,7 +5,10 @@ spring: password: ${IAM_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver baiyue: + security-event: + event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} mq-consumer-enabled: ${NOTIFICATION_MQ_CONSUMER_ENABLED:false} + diff --git a/config/nacos/overseas/event-bridge-service.yml b/config/nacos/overseas/event-bridge-service.yml index bbc198d5c..00a8ac755 100644 --- a/config/nacos/overseas/event-bridge-service.yml +++ b/config/nacos/overseas/event-bridge-service.yml @@ -10,7 +10,7 @@ baiyue: data-residency: ${DATA_RESIDENCY:OVERSEAS} event-dispatcher: consumer-url: ${EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events} - security-consumer-url: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:http://overseas-security-service:9307/internal/overseas-security/v1/events} + security-consumer-url: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/security/v1/events} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} diff --git a/config/nacos/overseas/gateway-overseas.yml b/config/nacos/overseas/gateway-overseas.yml index 11e2deb34..a9c1eadf2 100644 --- a/config/nacos/overseas/gateway-overseas.yml +++ b/config/nacos/overseas/gateway-overseas.yml @@ -4,6 +4,6 @@ springdoc: baiyue: security: overseas-projection: - service-url: ${OVERSEAS_SECURITY_SERVICE_URL:http://overseas-security-service:9307} + service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303} internal-service-token: ${INTERNAL_SERVICE_TOKEN} - timeout: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:2s} + timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s} diff --git a/config/nacos/overseas/overseas-security-service.yml b/config/nacos/overseas/overseas-security-service.yml deleted file mode 100644 index 80565c445..000000000 --- a/config/nacos/overseas/overseas-security-service.yml +++ /dev/null @@ -1,7 +0,0 @@ -springdoc: - api-docs.enabled: ${SWAGGER_ENABLED:false} - swagger-ui.enabled: ${SWAGGER_ENABLED:false} -baiyue: - security: - internal-service-token: ${INTERNAL_SERVICE_TOKEN} - permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index f41f67f15..aae9708e7 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -47,8 +47,8 @@ services: CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false} AUTH_SESSION_VALIDATION_URL: ${AUTH_SESSION_VALIDATION_URL} PDA_SESSION_VALIDATION_TIMEOUT: ${PDA_SESSION_VALIDATION_TIMEOUT:-2s} - OVERSEAS_SECURITY_SERVICE_URL: ${OVERSEAS_SECURITY_SERVICE_URL:-http://overseas-security-service:9307} - OVERSEAS_SECURITY_VALIDATION_TIMEOUT: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:-2s} + LASTLEG_SECURITY_SERVICE_URL: ${LASTLEG_SECURITY_SERVICE_URL:-http://lastleg-service:9303} + LASTLEG_SECURITY_VALIDATION_TIMEOUT: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:-2s} lastleg-service: <<: *overseas-common @@ -94,25 +94,12 @@ services: EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_OVERSEAS_DB_USERNAME} EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_OVERSEAS_DB_PASSWORD} EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/events} - OVERSEAS_SECURITY_EVENT_CONSUMER_URL: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:-http://overseas-security-service:9307/internal/overseas-security/v1/events} + LASTLEG_SECURITY_EVENT_CONSUMER_URL: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/security/v1/events} networks: firstleg-overseas-network: infra-network: aliases: [event-bridge-overseas] - overseas-security-service: - <<: *overseas-common - image: ${IMAGE_REPOSITORY}/overseas-security-service:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-overseas-security - ports: ["${PUBLISHED_BIND_ADDRESS:-127.0.0.1}:${OVERSEAS_SECURITY_PORT:-9307}:9307"] - volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/overseas-security-service:/app/logs"] - environment: - <<: *overseas-environment - SERVER_PORT: 9307 - OVERSEAS_SECURITY_DB_URL: ${OVERSEAS_SECURITY_DB_URL} - OVERSEAS_SECURITY_DB_USERNAME: ${OVERSEAS_SECURITY_DB_USERNAME} - OVERSEAS_SECURITY_DB_PASSWORD: ${OVERSEAS_SECURITY_DB_PASSWORD} - networks: firstleg-overseas-network: name: firstleg-${DEPLOY_ENV:-test}-overseas-network diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 4f6700005..8e61b9ec7 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -79,6 +79,7 @@ services: IAM_DB_URL: ${IAM_DB_URL} IAM_DB_USERNAME: ${IAM_DB_USERNAME} IAM_DB_PASSWORD: ${IAM_DB_PASSWORD} + SECURITY_EVENT_BRIDGE_URL: ${SECURITY_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312} firstleg: <<: *common diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index 29707e71e..d4ad1ad7d 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -64,7 +64,7 @@ env_file_value() { } case "$SERVICE" in - gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) + gateway-overseas|lastleg-service|warehouse-overseas-service) COMPOSE_FILE="deploy/docker-compose.overseas.yml" PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas" ;; @@ -98,15 +98,6 @@ if [ "$SERVICE" = "gateway-overseas" ]; then fi fi -if [ "$SERVICE" = "overseas-security-service" ]; then - for key in OVERSEAS_SECURITY_DB_URL OVERSEAS_SECURITY_DB_USERNAME OVERSEAS_SECURITY_DB_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 overseas-security-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done -fi - for file in "$ENV_FILE" "$COMPOSE_FILE"; do if [ ! -f "$file" ]; then echo "必需文件不存在: $file" >&2 @@ -124,7 +115,7 @@ if [ "$SERVICE" = "finance" ]; then fi case "$SERVICE" in - gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) + gateway-overseas|lastleg-service|warehouse-overseas-service) for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then echo "部署海外服务时,环境文件必须包含非空 $key" >&2 diff --git a/deploy/scripts/deploy-services.sh b/deploy/scripts/deploy-services.sh index abdbafef5..7c9983f82 100644 --- a/deploy/scripts/deploy-services.sh +++ b/deploy/scripts/deploy-services.sh @@ -39,7 +39,7 @@ env_file_value() { DEPLOY_REGION=CN for service in $SERVICES; do case "$service" in - gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) DEPLOY_REGION=OVERSEAS ;; + gateway-overseas|lastleg-service|warehouse-overseas-service) DEPLOY_REGION=OVERSEAS ;; event-bridge-service) BRIDGE_REGION="${EVENT_BRIDGE_REGION_OVERRIDE:-}" if [ -z "$BRIDGE_REGION" ] && env_file_has_key REGION_CODE; then @@ -60,7 +60,7 @@ done if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then for service in $SERVICES; do case "$service" in - gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service|event-bridge-service) ;; + gateway-overseas|lastleg-service|warehouse-overseas-service|event-bridge-service) ;; *) echo "一次部署不得混合国内与海外服务: $SERVICE_CSV" >&2; exit 2 ;; esac done @@ -80,17 +80,6 @@ if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then done fi -case " $SERVICES " in - *" overseas-security-service "*) - for key in OVERSEAS_SECURITY_DB_URL OVERSEAS_SECURITY_DB_USERNAME OVERSEAS_SECURITY_DB_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 overseas-security-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done - ;; -esac - case " $SERVICES " in *" gateway-overseas "*) if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then diff --git a/deploy/service-map.yml b/deploy/service-map.yml index e78b8a6e5..e22eec804 100644 --- a/deploy/service-map.yml +++ b/deploy/service-map.yml @@ -8,11 +8,6 @@ services: jar: gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar port: 9300 regions: [OVERSEAS] - overseas-security-service: - artifactId: overseas-security-service - jar: overseas-security-service/target/overseas-security-service-1.0.0-SNAPSHOT.jar - port: 9307 - regions: [OVERSEAS] auth: artifactId: auth-service jar: auth/target/auth-service-1.0.0-SNAPSHOT.jar diff --git a/deploy/sql/README.md b/deploy/sql/README.md index dc681dba9..f857cf41f 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -3,7 +3,8 @@ ## 唯一数据库初始化与发布入口 - 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 -- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`、`warehouse-overseas-service/src/main/resources/db/warehouse.sql` 与 `overseas-security-service/src/main/resources/db/overseas-security.sql` 基线,再执行该入口。固定入口升级 `baiyue_lastleg`、`baiyue_lastlag_warehouse` 与 `baiyue_overseas_security`,后者保存 IAM 安全事件消费者 Inbox 和可恢复投影;Redis 只作可重建镜像。 +- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`与 `warehouse-overseas-service/src/main/resources/db/warehouse.sql` 基线,再执行该入口。固定入口仅升级 `baiyue_lastleg`与 `baiyue_lastlag_warehouse`;IAM 安全事件 Inbox 和可恢复投影已并入 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。 +- Lastleg 基线和海外固定增量仅确定性初始化国内固定 `platform_admin`(`authUserId=1000001`、`tenantId=0`)的 USER/AUTHORIZATION version=0 安全投影;其他既有开发账号不建立兼容表或长期补投入口。 - 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。 - 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 41f10d449..882d2a042 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -920,33 +920,47 @@ DELIMITER ; CALL assert_phase4_pda_operations_ready(); DROP PROCEDURE assert_phase4_pda_operations_ready; -CREATE DATABASE IF NOT EXISTS baiyue_overseas_security DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; -USE baiyue_overseas_security; -CREATE TABLE IF NOT EXISTS overseas_security_event_inbox ( - event_id VARCHAR(64) NOT NULL, event_type VARCHAR(96) NOT NULL, payload_hash CHAR(64) NOT NULL, - payload_json JSON NOT NULL, status VARCHAR(16) NOT NULL, result VARCHAR(32) NULL, - received_at DATETIME(3) NOT NULL, processed_at DATETIME(3) NULL, +USE baiyue_lastleg; +CREATE TABLE IF NOT EXISTS ll_security_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '跨区安全事件ID', event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型', + payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256哈希', payload_json JSON NOT NULL COMMENT '冻结事件载荷JSON', + status VARCHAR(16) NOT NULL COMMENT '消费状态:RECEIVED或PROCESSED', + result VARCHAR(32) NULL COMMENT '处理结果:APPLIED、STALE、DUPLICATE或DUPLICATE_VERSION', + received_at DATETIME(3) NOT NULL COMMENT '接收时间', processed_at DATETIME(3) NULL COMMENT '处理完成时间', PRIMARY KEY (event_id), KEY idx_security_inbox_type_time (event_type,received_at) -) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者Inbox'; -CREATE TABLE IF NOT EXISTS overseas_security_projection ( - aggregate_type VARCHAR(32) NOT NULL, aggregate_id VARCHAR(64) NOT NULL, tenant_id BIGINT NOT NULL, - security_status VARCHAR(24) NULL, permission_version BIGINT NULL, aggregate_version BIGINT NOT NULL, - payload_hash CHAR(64) NOT NULL, last_event_id VARCHAR(64) NOT NULL, - changed_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL, +) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者收件箱'; +CREATE TABLE IF NOT EXISTS ll_security_projection ( + aggregate_type VARCHAR(32) NOT NULL COMMENT '聚合类型:USER、TENANT或AUTHORIZATION', + aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID', tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID', + security_status VARCHAR(24) NULL COMMENT '当前安全状态:ACTIVE或DISABLED', + permission_version BIGINT NULL COMMENT '当前单调权限版本', + aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本', payload_hash CHAR(64) NOT NULL COMMENT '当前版本载荷哈希', + last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用的事件ID', + changed_at DATETIME(3) NOT NULL COMMENT '国内IAM事实发生时间', updated_at DATETIME(3) NOT NULL COMMENT '投影更新时间', PRIMARY KEY (aggregate_type,aggregate_id), KEY idx_security_projection_tenant (tenant_id), KEY idx_security_projection_permission (aggregate_id,permission_version) ) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; -DROP PROCEDURE IF EXISTS assert_overseas_security_projection_ready; +-- 与国内固定 auth_account.id=1000001、tenant_id=0 的 platform_admin 一致;真实事件从版本1覆盖。 +INSERT IGNORE INTO ll_security_projection ( + aggregate_type,aggregate_id,tenant_id,security_status,permission_version,aggregate_version, + payload_hash,last_event_id,changed_at,updated_at +) VALUES + ('USER','1000001',0,'ACTIVE',0,0,SHA2('lastleg-baseline:platform_admin:USER:0',256), + 'baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'), + ('AUTHORIZATION','1000001',0,NULL,0,0,SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0',256), + 'baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); + +DROP PROCEDURE IF EXISTS assert_lastleg_security_projection_ready; DELIMITER $$ -CREATE PROCEDURE assert_overseas_security_projection_ready() +CREATE PROCEDURE assert_lastleg_security_projection_ready() BEGIN - IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_overseas_security' - AND table_name IN ('overseas_security_event_inbox','overseas_security_projection')) <> 2 THEN + IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_lastleg' + AND table_name IN ('ll_security_event_inbox','ll_security_projection')) <> 2 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外IAM安全Inbox或投影表未完整部署'; END IF; END$$ DELIMITER ; -CALL assert_overseas_security_projection_ready(); -DROP PROCEDURE assert_overseas_security_projection_ready; +CALL assert_lastleg_security_projection_ready(); +DROP PROCEDURE assert_lastleg_security_projection_ready; SELECT 'PASS' AS migration_status, '海外仓业务、海外仓资源与IAM安全投影结构已就绪' AS message; diff --git a/docs/头程项目目录结构与服务说明.md b/docs/头程项目目录结构与服务说明.md index 2ed49423a..f16f95e37 100644 --- a/docs/头程项目目录结构与服务说明.md +++ b/docs/头程项目目录结构与服务说明.md @@ -10,7 +10,7 @@ ## 1. 项目定位 -`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 23 个 Maven 模块,其中 7 个是公共/依赖模块,16 个是可独立启动、构建、制作镜像的微服务。 +`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 22 个 Maven 模块,其中 7 个是公共/依赖模块,15 个是可独立启动、构建、制作镜像的微服务。 当前技术基线: @@ -48,7 +48,6 @@ baiyue-firstleg/ ├─ finance/ # 财务账期、账单、收付款与核销 ├─ lastleg-service/ # 海外尾程、入仓、库存与本土业务 ├─ warehouse-overseas-service/ # 海外仓库、库位、托盘与资源锁 -├─ overseas-security-service/ # 海外IAM安全事件Inbox、投影与实时校验 ├─ file/ # 资质、导出与测量图片文件中心 ├─ print/ # 打印中心 ├─ audit-service/ # 审计日志中心 @@ -100,9 +99,8 @@ baiyue-firstleg/ | `file` | `file-service` | 9206 | `baiyue_file` | 企业资质文件、公共异步导出下载、入库测量图片及重量 OCR 调用协作 | | `print` | `print-service` | 9207 | 无 | 无状态 PDF 渲染服务;调用方传入完整标签数据,服务在内存中生成一次性多页 PDF | | `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 | -| `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存与海外本土业务;不含海外出库和派送 | +| `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存、海外本土业务及 IAM 安全事件投影/实时校验;不含海外出库和派送 | | `warehouse-overseas-service` | `warehouse-overseas-service` | 9305 | 海外仓资源库 | 海外仓库、库位、托盘和资源锁 | -| `overseas-security-service` | `overseas-security-service` | 9307 | `baiyue_overseas_security` | IAM安全事件可靠Inbox、单调投影及海外Token即时失效校验 | | `event-bridge-service` | `event-bridge-service` | 9312 | `baiyue_event_bridge` | 按 CN/OVERSEAS 双实例部署的跨区事件 Inbox、重试和目标服务投递 | | `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;平台端可按权限查询已脱敏日志,业务审计发送默认开启 | @@ -283,6 +281,6 @@ HTTP 401 / `A0401`,不得再按 HTTP 200 + 空权限处理;只有使用新 - 平台端和商户端权限分别管理,接口与按钮权限都必须由后端校验。 - Controller 负责协议转换,Service 负责业务编排,Repository/Mapper 负责持久化。 - 服务之间只传 DTO 或事件契约,不能依赖其他服务的 Entity、Mapper。 -- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 16 个服务执行受影响构建。 +- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 15 个服务执行受影响构建。 - 单一 Git 仓库不等于单一部署单元;每个服务仍生成独立 JAR、镜像和容器。 - 国内头程与海外仓共享 Git 仓库,但保持两套数据库、Nacos 命名空间和部署环境。 diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index 14ed05d26..4b88bd092 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -12,7 +12,7 @@ |---|---| | 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` | | Inbox 接收与幂等 | `service/EventInboxService.java` | -| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 回投 Firstleg;海外业务事件投 Lastleg,三类 IAM 安全事件按 eventType 投 `overseas-security-service`,传输层不解析 payload | +| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 回投 Firstleg;海外业务事件投 Lastleg 通用入口,三类 IAM 安全事件按 eventType 投 Lastleg `/internal/lastleg/security/v1/events`,传输层不解析 payload | | 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` | | 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` | | 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` | diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java index 50a3bbe66..85756ae0e 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java @@ -6,6 +6,7 @@ import com.baiyue.eventbridge.domain.BridgeInbox; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.time.Clock; @@ -41,6 +42,7 @@ public class EventBridgeMetrics { private final MeterRegistry registry; private final Clock clock; + @Autowired public EventBridgeMetrics(MeterRegistry registry) { this(registry, Clock.systemDefaultZone()); } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java index e18cf8714..6c4b7666e 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java @@ -47,10 +47,14 @@ public class HttpSecurityProjectionEventClient implements SecurityProjectionEven JsonNode body; try { body = objectMapper.readTree(response.body()); } catch (Exception exception) { body = null; } - if (response.statusCode() / 100 != 2 || body == null || !"200".equals(body.path("code").asText()) - || !row.getEventId().equals(body.path("data").path("eventId").asText())) { - throw new IllegalStateException("海外安全投影消费失败: HTTP " + response.statusCode()); - } + requireAcknowledgement(response.statusCode(), body, row.getEventId()); return String.valueOf(response.statusCode()); } + + static void requireAcknowledgement(int statusCode, JsonNode body, String eventId) { + if (statusCode / 100 != 2 || body == null || !"200".equals(body.path("code").asText()) + || !eventId.equals(body.path("data").path("eventId").asText())) { + throw new IllegalStateException("Lastleg 安全投影消费失败: HTTP " + statusCode); + } + } } diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml index fc5524546..b20743a41 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -33,7 +33,7 @@ baiyue: event-dispatcher: consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}} firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9202/internal/firstleg/v1/container-events} - security-consumer-url: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9307/internal/overseas-security/v1/events} + security-consumer-url: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/security/v1/events} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java index 39a229241..636db964f 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java @@ -8,12 +8,32 @@ import org.junit.jupiter.api.Test; import java.lang.reflect.Modifier; import java.util.Arrays; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.Mockito.mock; class EventBridgeMetricsTest { + @Test + void productionConstructorIsExplicitForSpringWhenTestClockConstructorAlsoExists() throws Exception { + assertThat(EventBridgeMetrics.class.getConstructor(MeterRegistry.class) + .isAnnotationPresent(Autowired.class)).isTrue(); + } + + @Test + void springContextCreatesMetricsBeanWithTheAnnotatedProductionConstructor() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.registerBean(MeterRegistry.class, SimpleMeterRegistry::new); + context.register(EventBridgeMetrics.class); + context.refresh(); + + assertThat(context.getBean(EventBridgeMetrics.class)).isNotNull(); + } + } + @Test void collapsesUnboundedRegionAndEventTypeLabelsToOther() { SimpleMeterRegistry registry = new SimpleMeterRegistry(); diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java new file mode 100644 index 000000000..9a31b6db0 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java @@ -0,0 +1,44 @@ +package com.baiyue.eventbridge.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class HttpSecurityProjectionEventClientTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void onlyMatchingLastlegAcknowledgementCompletesBridgeDelivery() throws Exception { + var acknowledged = objectMapper.readTree(""" + {"code":"200","data":{"eventId":"event-1","result":"APPLIED"}} + """); + var businessFailure = objectMapper.readTree(""" + {"code":"A0409","message":"payloadHash 冲突","data":{"eventId":"event-1"}} + """); + var wrongEvent = objectMapper.readTree(""" + {"code":"200","data":{"eventId":"event-2","result":"DUPLICATE"}} + """); + + assertThatCode(() -> HttpSecurityProjectionEventClient.requireAcknowledgement( + 200, acknowledged, "event-1")).doesNotThrowAnyException(); + assertThatThrownBy(() -> HttpSecurityProjectionEventClient.requireAcknowledgement( + 200, businessFailure, "event-1")).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> HttpSecurityProjectionEventClient.requireAcknowledgement( + 200, wrongEvent, "event-1")).isInstanceOf(IllegalStateException.class); + } + + @Test + void defaultConsumerUrlTargetsLastlegSecurityEndpoint() throws Exception { + String application = Files.readString(Path.of("src/main/resources/application.yml")); + + assertThat(application).contains( + "LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/security/v1/events") + .doesNotContain("/internal/overseas-security/v1/events"); + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java index 9edcfad81..e488beaa9 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java @@ -7,7 +7,7 @@ import java.time.Duration; @ConfigurationProperties(prefix = "baiyue.security.overseas-projection") public record OverseasSecurityProjectionProperties(String serviceUrl, String internalServiceToken, Duration timeout) { public OverseasSecurityProjectionProperties { - if (serviceUrl == null || serviceUrl.isBlank()) throw new IllegalArgumentException("必须配置 OVERSEAS_SECURITY_SERVICE_URL"); + if (serviceUrl == null || serviceUrl.isBlank()) throw new IllegalArgumentException("必须配置 LASTLEG_SECURITY_SERVICE_URL"); if (internalServiceToken == null || internalServiceToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) { throw new IllegalArgumentException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); } diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java index 6d75282df..17f3af48b 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java @@ -9,7 +9,7 @@ import reactor.core.publisher.Mono; @Component public class HttpOverseasSecurityValidationClient implements OverseasSecurityValidationClient { - private static final String PATH = "/internal/overseas-security/v1/validate"; + private static final String PATH = "/internal/lastleg/security/v1/validate"; private final WebClient client; private final OverseasSecurityProjectionProperties properties; @@ -19,10 +19,10 @@ public class HttpOverseasSecurityValidationClient implements OverseasSecurityVal } @Override - public Mono validate(long userId, long tenantId, long tokenVersion, long permissionVersion) { + public Mono validate(long userId, long tenantId, long permissionVersion) { return client.post().uri(properties.serviceUrl() + PATH).contentType(MediaType.APPLICATION_JSON) .header("X-Internal-Service-Token", properties.internalServiceToken()) - .bodyValue(new Request(userId, tenantId, tokenVersion, permissionVersion)) + .bodyValue(new Request(userId, tenantId, permissionVersion)) .retrieve().bodyToMono(JsonNode.class).timeout(properties.timeout()).flatMap(body -> { JsonNode data = body == null ? null : body.path("data"); if (data == null || data.isMissingNode() || !data.has("valid")) { @@ -33,5 +33,5 @@ public class HttpOverseasSecurityValidationClient implements OverseasSecurityVal }); } - private record Request(long userId, long tenantId, long tokenVersion, long permissionVersion) { } + private record Request(long userId, long tenantId, long permissionVersion) { } } diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java index cdbb4e727..91fc516cb 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java @@ -38,12 +38,11 @@ public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered { JwtAuthenticationToken authentication) { Long userId = number(authentication.getToken().getSubject()); Long tenantId = number(authentication.getToken().getClaim("tenant_id")); - Long tokenVersion = number(authentication.getToken().getClaim("token_version")); Long permissionVersion = number(authentication.getToken().getClaim("permission_version")); - if (userId == null || tenantId == null || tokenVersion == null || permissionVersion == null) { + if (userId == null || tenantId == null || permissionVersion == null) { return Mono.error(new SecurityProjectionFailure("A0401", "海外令牌安全版本声明缺失")); } - return client.validate(userId, tenantId, tokenVersion, permissionVersion) + return client.validate(userId, tenantId, permissionVersion) .switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "海外安全状态校验不可用"))) .flatMap(result -> { if (result.valid()) return chain.filter(exchange); diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java index 2ce22a244..a267cb233 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java @@ -3,6 +3,6 @@ package com.baiyue.gateway.filter; import reactor.core.publisher.Mono; public interface OverseasSecurityValidationClient { - Mono validate(long userId, long tenantId, long tokenVersion, long permissionVersion); + Mono validate(long userId, long tenantId, long permissionVersion); record Result(boolean valid, String failureReason) { } } diff --git a/gateway-overseas/src/main/resources/application.yml b/gateway-overseas/src/main/resources/application.yml index 3e7ce51f2..d61d0ec19 100644 --- a/gateway-overseas/src/main/resources/application.yml +++ b/gateway-overseas/src/main/resources/application.yml @@ -47,9 +47,9 @@ baiyue: internal-service-token: ${INTERNAL_SERVICE_TOKEN} timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} overseas-projection: - service-url: ${OVERSEAS_SECURITY_SERVICE_URL} + service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303} internal-service-token: ${INTERNAL_SERVICE_TOKEN} - timeout: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:2s} + timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s} permit-paths: - /actuator/health - /swagger-ui/** diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java new file mode 100644 index 000000000..45bd73e1d --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java @@ -0,0 +1,42 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasSecurityValidationDeploymentContractTest { + @Test + void gatewayAndEventBridgeUseLastlegSecurityEndpointsWithoutTheRetiredService() throws Exception { + String client = read("gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java"); + String gatewayApplication = read("gateway-overseas/src/main/resources/application.yml"); + String gatewayNacos = read("config/nacos/overseas/gateway-overseas.yml"); + String bridgeApplication = read("event-bridge-service/src/main/resources/application.yml"); + String bridgeNacos = read("config/nacos/overseas/event-bridge-service.yml"); + String compose = read("deploy/docker-compose.overseas.yml"); + + assertThat(client).contains("/internal/lastleg/security/v1/validate") + .contains("new Request(userId, tenantId, permissionVersion)") + .doesNotContain("new Request(userId, tenantId, tokenVersion") + .doesNotContain("/internal/overseas-security/"); + for (String source : new String[]{gatewayApplication, gatewayNacos, compose}) { + assertThat(source).contains("LASTLEG_SECURITY_SERVICE_URL", "http://lastleg-service:9303"); + } + for (String source : new String[]{bridgeApplication, bridgeNacos, compose}) { + assertThat(source).contains("LASTLEG_SECURITY_EVENT_CONSUMER_URL", + "/internal/lastleg/security/v1/events"); + } + assertThat(gatewayApplication + gatewayNacos + bridgeApplication + bridgeNacos + compose) + .doesNotContain("overseas-security-service", "9307", "OVERSEAS_SECURITY_DB_URL", + "OVERSEAS_SECURITY_SERVICE_URL", "OVERSEAS_SECURITY_EVENT_CONSUMER_URL"); + } + + private static String read(String relative) throws Exception { + Path path = Path.of(relative); + if (!Files.exists(path)) path = Path.of("..").resolve(relative); + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java index ff9f935f0..1857158e8 100644 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java @@ -26,7 +26,7 @@ class OverseasSecurityProjectionFilterTest { private final OverseasSecurityProjectionFilter filter = new OverseasSecurityProjectionFilter(client, new ObjectMapper()); @Test void disabledUserFailsWithA0401() { - when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + when(client.validate(42, 0, 8)).thenReturn(Mono.just( new OverseasSecurityValidationClient.Result(false, "USER_DISABLED"))); var exchange = exchange("/api/platform/v1/last-leg/orders"); StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); @@ -36,7 +36,7 @@ class OverseasSecurityProjectionFilterTest { @Test void disabledTenantAndStalePermissionFailWithA0403() { for (String reason : new String[]{"TENANT_DISABLED", "PERMISSION_VERSION_STALE"}) { - when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + when(client.validate(42, 0, 8)).thenReturn(Mono.just( new OverseasSecurityValidationClient.Result(false, reason))); var exchange = exchange("/api/merchant/v1/last-leg/orders"); StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); @@ -46,14 +46,14 @@ class OverseasSecurityProjectionFilterTest { } @Test void projectionUnavailableFailsClosed() { - when(client.validate(42, 0, 3, 8)).thenReturn(Mono.error(new IllegalStateException("down"))); + when(client.validate(42, 0, 8)).thenReturn(Mono.error(new IllegalStateException("down"))); var exchange = exchange("/api/platform/v1/last-leg/orders"); StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } @Test void acceptedSecurityStateContinuesAndNonApiPathsDoNotCallProjection() { - when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + when(client.validate(42, 0, 8)).thenReturn(Mono.just( new OverseasSecurityValidationClient.Result(true, null))); AtomicBoolean routed = new AtomicBoolean(); StepVerifier.create(filter.filter(exchange("/api/platform/v1/last-leg/orders"), ignored -> { diff --git a/iam/AGENTS.md b/iam/AGENTS.md index ff4a63a71..be2a0b5e5 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -17,7 +17,7 @@ | 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`WAREHOUSE_PICKER` 必须且只能绑定一个 `overseasWarehouseId` | | 角色/权限/RBAC | `controller/RbacController.java`、`service/RbacService.java` | | 授权快照/内部资格 | `controller/AuthorizationController.java`、`InternalAuthorizationController.java`、`service/AuthorizationSnapshotService.java` | -| 海外安全状态事件 Outbox | `service/SecurityEventOutboxService.java`、`SecurityEventOutboxRelay.java`、`client/SecurityEventBridgeClient.java`;账号、授权或客户状态变更经版本化事件投影到 `../overseas-security-service/` | +| 海外安全状态事件 Outbox | `service/SecurityEventOutboxService.java`、`SecurityEventOutboxRelay.java`、`client/SecurityEventBridgeClient.java`;账号、授权或客户状态变更经版本化事件投影到 `../lastleg-service/security/` | | 消息中心/站内通知 | `notification/controller/NotificationInboxController.java`、`notification/controller/PlatformNotificationController.java`、`notification/service/NotificationService.java`;模板、任务、收件箱及事件消费均在 `notification/` | | File 资质元数据协作 | `controller/InternalTenantController.java`、`service/ProfileResourceService.java`、`client/AuthAccountClient.java` | | DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` | @@ -31,6 +31,7 @@ - 客户建档只在 IAM 事务内落持久初始化意图,远端投递由带租约、退避和最大次数的 Relay 完成;只有状态为 `READY` 才能审核通过。注册补偿必须投递 `COMPENSATE`,不得在建档事务中同步依赖 Firstleg。 - 权限资源 code/type 不可变;按钮隐藏不能替代后端权限校验。 - 只有平台 `WAREHOUSE_PICKER` 可保存 `overseasWarehouseId`,且必须为单个有效海外仓;非拣货员必须为空。仓绑定、账号状态、角色权限或客户状态变化要在同一业务事务内推进安全事件 Outbox,不得依赖海外服务回查国内 IAM。 +- `IamTenantStatusChanged` 是安全可用性快照,不暴露客户建档/审核流程状态:仅 IAM 业务状态 `ACTIVE` 发布 `ACTIVE`,`PROFILE_REQUIRED`、`PENDING`、`REJECTED`、`DISABLED`、`ARCHIVED` 及其他不可用状态统一发布 `DISABLED`。 ## 全量验收导航 diff --git a/iam/SECURITY-EVENT-AUDIT.md b/iam/SECURITY-EVENT-AUDIT.md index 0f0a49f51..856b61edf 100644 --- a/iam/SECURITY-EVENT-AUDIT.md +++ b/iam/SECURITY-EVENT-AUDIT.md @@ -25,9 +25,11 @@ IAM 使用独立的 `iam_security_event_outbox`。业务状态、权限版本与 ## 当前接入 - 企业成员、平台内部用户和平台用户启停均写 `IamUserStatusChanged`。 -- 客户启停写 `IamTenantStatusChanged`。 +- 客户创建、审核结果及启停写 `IamTenantStatusChanged`。 - 成员角色、平台内部用户角色及角色权限变更写 `IamPermissionVersionChanged`。 +普通 Admin/Client 的即时失效只覆盖用户停用、客户停用和权限版本推进。`token_version` 不进入 V1 投影,也不传给 Lastleg 校验;其即时权威比对只用于海外 PDA 向 Auth 核验仓绑定与会话版本。 + ## 尾程权限 `lastleg-permissions.sql` 初始化以下平台权限,并授予 `PLATFORM_ADMIN`: diff --git a/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java b/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java index 4adf81539..c68eac775 100644 --- a/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java +++ b/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java @@ -7,7 +7,8 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; -@FeignClient(name = "event-bridge-service", contextId = "iamSecurityEventBridgeClient") +@FeignClient(name = "event-bridge-service", contextId = "iamSecurityEventBridgeClient", + url = "${baiyue.security-event.event-bridge-url:}") public interface SecurityEventBridgeClient { @PostMapping("/internal/event-bridge/events") ApiResponse receive(@RequestHeader("X-Internal-Service-Token") String token, diff --git a/iam/src/main/java/com/baiyue/iam/service/MemberService.java b/iam/src/main/java/com/baiyue/iam/service/MemberService.java index a2a9088a2..cc35971c5 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MemberService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java @@ -104,6 +104,8 @@ public class MemberService { membership.setMemberType(request.memberType()); membership.setMemberNo(request.memberNo()); membership.setStatus("ACTIVE"); membership.setJoinedAt(LocalDateTime.now()); repository.insert(membership); repository.replaceRoles(tenantId, membershipId, request.roleIds()); + securityEvents.userStatus(authId, tenantId, "MERCHANT", "ACTIVE"); + securityEvents.permissionChanged(authId, tenantId, "MERCHANT", "INITIAL_AUTHORIZATION"); return response(tenantId, membership, user); } catch (RuntimeException exception) { try { diff --git a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java index 9e71f4848..a393d5cac 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java @@ -42,6 +42,7 @@ public class MerchantOnboardingService { @Resource private IamTenantReviewMapper reviewMapper; @Resource private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @Resource private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; + @Resource private SecurityEventOutboxService securityEvents; @Transactional public MerchantRegistrationResponse register(MerchantRegistrationRequest request) { @@ -78,6 +79,7 @@ public class MerchantOnboardingService { tenant.setDeleted(false); tenant.setVersion(0); tenantMapper.insert(tenant); + securityEvents.tenantStatus(tenantId, tenant.getStatus()); IamUser user = new IamUser(); user.setId(userId); @@ -109,6 +111,8 @@ public class MerchantOnboardingService { membershipRole.setTenantId(tenantId); membershipRole.setCreatedAt(LocalDateTime.now()); membershipRoleMapper.insert(membershipRole); + securityEvents.userStatus(request.authUserId(), tenantId, "MERCHANT", "ACTIVE"); + securityEvents.permissionChanged(request.authUserId(), tenantId, "MERCHANT", "INITIAL_AUTHORIZATION"); if (StringUtils.hasText(request.address())) { IamTenantAddress address = new IamTenantAddress(); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java index f0285949a..baa3d4658 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java @@ -77,6 +77,7 @@ public class PlatformInternalUserService { repository.insertUser(user); repository.insert(membership); repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); + publishInitialSecurityProjection(user); return response(membership, user, request.username(), roleIds); } catch (RuntimeException localFailure) { compensateCreate(authUserId, localFailure); @@ -196,9 +197,16 @@ public class PlatformInternalUserService { IamMembership membership = newMembership(membershipId, user.getId(), memberNo); repository.insert(membership); repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); + publishInitialSecurityProjection(user); return response(membership, user, authAccount.username(), roleIds); } + private void publishInitialSecurityProjection(IamUser user) { + securityEvents.userStatus(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", user.getStatus()); + securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", + "INITIAL_AUTHORIZATION"); + } + private InternalUserResponse response(IamMembership membership) { IamUser user = requiredUser(membership.getUserId()); AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId()); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index 296100ffb..b7e719747 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -106,6 +106,8 @@ public class PlatformUserManagementService { membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); members.insert(membership); members.replaceRoles(tenantId, membershipId, roleIds); + securityEvents.userStatus(authUserId, tenantId, appCode, request.status()); + securityEvents.permissionChanged(authUserId, tenantId, appCode, "INITIAL_AUTHORIZATION"); return response(membership); } catch (RuntimeException localFailure) { compensateCreate(authUserId, localFailure); diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java index 32b9bd9e4..de41c5d9d 100644 --- a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java +++ b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java @@ -2,6 +2,7 @@ package com.baiyue.iam.service; import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventEnvelopeValidator; +import com.baiyue.common.event.RegionCode; import com.baiyue.iam.client.SecurityEventBridgeClient; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -45,6 +46,9 @@ public class SecurityEventOutboxRelay { try { EventEnvelope envelope = objectMapper.readValue(json, EventEnvelope.class); EventEnvelopeValidator.requireValid(envelope); + if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) { + throw new IllegalStateException("IAM 安全事件必须从 CN 投影到 OVERSEAS"); + } var response = client.receive(internalToken, envelope); if (response == null || !response.successful() || response.data() == null || !eventId.equals(response.data().eventId())) { diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java index fcc158420..520689a86 100644 --- a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java +++ b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java @@ -32,8 +32,9 @@ public class SecurityEventOutboxService { + "ON DUPLICATE KEY UPDATE status_version=status_version+1", tenantId); long version = jdbc.queryForObject( "SELECT status_version FROM iam_security_tenant_version WHERE tenant_id=?", Long.class, tenantId); + String securityStatus = "ACTIVE".equals(status) ? "ACTIVE" : "DISABLED"; write(EventTypes.IAM_TENANT_STATUS_CHANGED, "TENANT", String.valueOf(tenantId), tenantId, version, - new IamSecurityEventsV1.TenantStatusChanged(tenantId, status, Instant.now())); + new IamSecurityEventsV1.TenantStatusChanged(tenantId, securityStatus, Instant.now())); } public void permissionChanged(long authUserId, long tenantId, String appCode, String reason) { diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java index 7eb7d502b..6e1a6adbc 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java @@ -43,6 +43,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni private IamRoleMapper roles; @Resource private AuthAccountClient authClient; + @Resource + private SecurityEventOutboxService securityEvents; @Value("${baiyue.security.internal-service-token}") private String internalToken; private SecureRandom random = new SecureRandom(); @@ -112,6 +114,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni membership.setUpdatedBy(operatorId); members.insert(membership); members.replaceRoles(tenant.getId(), membershipId, List.of(ownerRole.getId())); + publishInitialSecurityProjection(authUserId, tenant.getId()); return result; } catch (RuntimeException localFailure) { compensate(context, result); @@ -152,6 +155,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni String password = temporaryPassword(); String username = createAuthAccountWithUniqueUsername(user.getAuthUserId(), password, tenant, owner.getId()); + publishInitialSecurityProjection(user.getAuthUserId(), tenant.getId()); return ProvisioningResult.created(user.getAuthUserId(), username, password); } if (!"ACTIVE".equals(account.status()) @@ -166,6 +170,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni List repairedRoleIds = new java.util.ArrayList<>(roleIds); repairedRoleIds.add(ownerRole.getId()); members.replaceRoles(tenant.getId(), owner.getId(), repairedRoleIds.stream().distinct().toList()); + securityEvents.permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT", + "MEMBER_ROLES_CHANGED"); } return ProvisioningResult.existing(); } @@ -230,6 +236,11 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni } } + private void publishInitialSecurityProjection(long authUserId, long tenantId) { + securityEvents.userStatus(authUserId, tenantId, "MERCHANT", "ACTIVE"); + securityEvents.permissionChanged(authUserId, tenantId, "MERCHANT", "INITIAL_AUTHORIZATION"); + } + private void compensate(long authUserId) { try { ApiResponse response = authClient.compensate(internalToken, authUserId); diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index f29f58f63..f93c268cc 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -190,6 +190,7 @@ public class TenantService { tenant.setCreatedBy(operatorId); tenant.setUpdatedBy(operatorId); repository.insertTenant(tenant); + securityEvents.tenantStatus(tenant.getId(), tenant.getStatus()); dimensionRuleInitialization.initialize(tenant.getId(), operatorId); overseasStorageFeeInitialization.initialize(tenant.getId(), operatorId, tenant.getTenantName()); var administrator = administratorProvisioning.provisionPlatformCreatedTenant(tenant, operatorId); @@ -297,6 +298,7 @@ public class TenantService { tenant.setApprovedAt(approve ? LocalDateTime.now() : null); tenant.setApprovedBy(approve ? reviewerId : null); repository.saveTenant(tenant); + securityEvents.tenantStatus(tenant.getId(), tenant.getStatus()); for (var qualification : profileRepository.findQualifications(tenant.getId())) { if ("PENDING".equals(qualification.getStatus())) { qualification.setStatus(approve ? "APPROVED" : "REJECTED"); diff --git a/iam/src/main/resources/application.yml b/iam/src/main/resources/application.yml index e724fd6a4..c7f0fe556 100644 --- a/iam/src/main/resources/application.yml +++ b/iam/src/main/resources/application.yml @@ -23,6 +23,8 @@ spring: namespace: ${NACOS_NAMESPACE:public} encode: UTF-8 baiyue: + security-event: + event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} @@ -45,3 +47,4 @@ logging: max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" + diff --git a/iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java b/iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java new file mode 100644 index 000000000..92692f92d --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java @@ -0,0 +1,35 @@ +package com.baiyue.iam.client; + +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.FeignClient; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class SecurityEventBridgeClientContractTest { + private static final Path REPOSITORY_ROOT = Path.of("..").toAbsolutePath().normalize(); + + @Test + void feignUsesTheExplicitOverseasBridgePropertyInsteadOfSameNameDiscovery() { + FeignClient annotation = SecurityEventBridgeClient.class.getAnnotation(FeignClient.class); + + assertThat(annotation.name()).isEqualTo("event-bridge-service"); + assertThat(annotation.url()).isEqualTo("${baiyue.security-event.event-bridge-url:}"); + } + + @Test + void cnIamConfigurationTargetsTheOverseasBridgeNetworkAlias() throws Exception { + String application = Files.readString(REPOSITORY_ROOT.resolve("iam/src/main/resources/application.yml")); + String nacos = Files.readString(REPOSITORY_ROOT.resolve("config/nacos/cn/iam-service.yml")); + String compose = Files.readString(REPOSITORY_ROOT.resolve("deploy/docker-compose.yml")); + + assertThat(application).contains( + "event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}"); + assertThat(nacos).contains( + "event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}"); + assertThat(compose).contains( + "SECURITY_EVENT_BRIDGE_URL: ${SECURITY_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312}"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java index e653c4c01..b69d85274 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java @@ -14,6 +14,7 @@ import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.repository.TenantRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import jakarta.annotation.Resource; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; @@ -135,6 +136,27 @@ class MemberServiceTest { verify(repository, never()).insert(any()); } + @Test void createPublishesActiveUserAndInitialAuthorizationAfterRoleAssignment() { + IamRole role = activeMerchantRole(50L, 100L); + when(roleMapper.selectById(50L)).thenReturn(role); + when(auth.create(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); + when(auth.summary(eq("internal-token"), anyLong())).thenAnswer(invocation -> ApiResponse.success( + new AuthAccountClient.AccountSummary(invocation.getArgument(1), "member02", "成员", "ACTIVE"))); + when(repository.findRoleIds(eq(100L), anyLong())).thenReturn(List.of(50L)); + CreateMemberRequest request = new CreateMemberRequest("member02", "Password1", "成员", + null, null, "EMPLOYEE", "M-002", List.of(50L)); + + service.create(100L, request, Set.of("merchant:member:create", "merchant:member:grant-role")); + + ArgumentCaptor account = + ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class); + verify(auth).create(eq("internal-token"), account.capture()); + long authUserId = account.getValue().accountId(); + verify(repository).replaceRoles(eq(100L), anyLong(), eq(List.of(50L))); + verify(securityEvents).userStatus(authUserId, 100L, "MERCHANT", "ACTIVE"); + verify(securityEvents).permissionChanged(authUserId, 100L, "MERCHANT", "INITIAL_AUTHORIZATION"); + } + @Test void shouldHandleCase08() { IamRole role = activeMerchantRole(50L, 100L); when(roleMapper.selectById(50L)).thenReturn(role); diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java index ade730ea0..51159c7d7 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java @@ -41,6 +41,7 @@ class MerchantOnboardingServiceTest { @MockBean private IamTenantReviewMapper reviews; @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; + @MockBean private SecurityEventOutboxService securityEvents; @Resource private MerchantOnboardingService service; @Test void clientRegistrationCreatesOwnerAndAssignsGlobalMerchantOwnerRole() { @@ -67,6 +68,10 @@ class MerchantOnboardingServiceTest { verify(membershipRoles).insert(roleGrant.capture()); verify(dimensionRuleInitialization).initialize(result.tenantId(), 9001L); verify(overseasStorageFeeInitialization).initialize(result.tenantId(), 9001L, "测试企业"); + verify(securityEvents).tenantStatus(result.tenantId(), "PROFILE_REQUIRED"); + verify(securityEvents).userStatus(9001L, result.tenantId(), "MERCHANT", "ACTIVE"); + verify(securityEvents).permissionChanged(9001L, result.tenantId(), "MERCHANT", + "INITIAL_AUTHORIZATION"); assertEquals("PROFILE_REQUIRED", result.tenantStatus()); assertEquals(result.tenantId(), tenant.getValue().getId()); @@ -194,3 +199,4 @@ class MerchantOnboardingServiceTest { verify(tenants, never()).deleteById((java.io.Serializable) any()); } } + diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java index adaccd1b1..6e8da47c5 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java @@ -85,6 +85,39 @@ class PlatformInternalUserServiceTest { verify(repository).insertUser(any(IamUser.class)); verify(repository).insert(any(IamMembership.class)); verify(repository).replaceRoles(eq(0L), anyLong(), eq(List.of(40L))); + long authUserId = captor.getValue().accountId(); + verify(securityEvents).userStatus(authUserId, 0L, "PLATFORM", "ACTIVE"); + verify(securityEvents).permissionChanged(authUserId, 0L, "PLATFORM", "INITIAL_AUTHORIZATION"); + } + + @Test + void linkingExistingAccountPublishesInitialProjectionOnlyForNewMembership() { + IamRole role = new IamRole(); + role.setId(40L); role.setTenantId(0L); role.setAppCode("PLATFORM"); role.setStatus("ACTIVE"); + when(roleMapper.selectById(40L)).thenReturn(role); + when(repository.findUserByAuthId(10L)).thenReturn(user); + when(repository.findByTenantAndAuthUser(0L, 10L)).thenReturn(null); + CreateInternalUserRequest request = new CreateInternalUserRequest(10L, null, "平台运营", + "13800138000", null, null, null, "PLATFORM-LINK", List.of(40L)); + + service.create(request); + + verify(repository).replaceRoles(eq(0L), anyLong(), eq(List.of(40L))); + verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "ACTIVE"); + verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", "INITIAL_AUTHORIZATION"); + verify(auth, never()).create(anyString(), any()); + } + + @Test + void relinkingExistingMembershipDoesNotDuplicateInitialProjection() { + when(repository.findByTenantAndAuthUser(0L, 10L)).thenReturn(membership); + CreateInternalUserRequest request = new CreateInternalUserRequest(10L, null, "平台运营", + "13800138000", null, null, null, "PLATFORM-30", List.of()); + + service.create(request); + + verifyNoInteractions(securityEvents); + verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java index 4dd7ddfb6..fb4949914 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -12,6 +12,7 @@ import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.repository.MemberRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import jakarta.annotation.Resource; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; @@ -20,6 +21,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.time.LocalDateTime; import java.util.List; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -85,6 +87,30 @@ class PlatformUserManagementServiceTest { verify(auth, never()).create(anyString(), any()); } + @Test + void createPublishesActualStatusAndInitialAuthorizationAfterRolesAreStored() { + AtomicReference createdUser = new AtomicReference<>(); + doAnswer(invocation -> { createdUser.set(invocation.getArgument(0)); return null; }) + .when(members).insertUser(any(IamUser.class)); + when(members.findUser(anyLong())).thenAnswer(invocation -> createdUser.get()); + when(members.findRoleIds(eq(0L), anyLong())).thenReturn(List.of(40001L)); + when(auth.create(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); + when(auth.summary(eq("internal-token"), anyLong())).thenAnswer(invocation -> ApiResponse.success( + new AuthAccountClient.AccountSummary(invocation.getArgument(1), "newuser", "新用户", "DISABLED"))); + CreateUserRequest request = new CreateUserRequest("newuser", "新用户", "13900139000", + "new@example.com", 0L, List.of(40001L), "Password1", "DISABLED"); + + service.create(request); + + ArgumentCaptor account = + ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class); + verify(auth).create(eq("internal-token"), account.capture()); + long authUserId = account.getValue().accountId(); + verify(members).replaceRoles(eq(0L), anyLong(), eq(List.of(40001L))); + verify(securityEvents).userStatus(authUserId, 0L, "PLATFORM", "DISABLED"); + verify(securityEvents).permissionChanged(authUserId, 0L, "PLATFORM", "INITIAL_AUTHORIZATION"); + } + @Test void optionsContainOnlyPlatformTenantAndPlatformRoles() { UserOptions result = service.options(); diff --git a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java new file mode 100644 index 000000000..aa212d14b --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java @@ -0,0 +1,91 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.IamSecurityEventsV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.iam.client.SecurityEventBridgeClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SecurityEventOutboxRelayTest { + @Mock private JdbcTemplate jdbc; + @Mock private SecurityEventBridgeClient client; + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + private SecurityEventOutboxRelay relay; + + @BeforeEach + void setUp() { + relay = new SecurityEventOutboxRelay(); + ReflectionTestUtils.setField(relay, "jdbc", jdbc); + ReflectionTestUtils.setField(relay, "client", client); + ReflectionTestUtils.setField(relay, "objectMapper", objectMapper); + ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); + ReflectionTestUtils.setField(relay, "maxAttempts", 12); + } + + @Test + void cnToOverseasEventIsSentOnlyAfterMatchingBridgeAcknowledgement() throws Exception { + EventEnvelope envelope = envelope(RegionCode.OVERSEAS); + when(jdbc.update(anyString(), any(Object[].class))).thenReturn(1); + when(jdbc.queryForObject(anyString(), eq(String.class), any(), any())) + .thenReturn(objectMapper.writeValueAsString(envelope)); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success( + new SecurityEventBridgeClient.Acknowledgement(envelope.eventId(), "ACCEPTED", "2026-08-25T07:00:00Z"))); + + relay.deliver(envelope.eventId()); + + ArgumentCaptor sent = ArgumentCaptor.forClass(EventEnvelope.class); + verify(client).receive(eq("internal-token"), sent.capture()); + assertThat(sent.getValue().sourceRegion()).isEqualTo(RegionCode.CN); + assertThat(sent.getValue().targetRegion()).isEqualTo(RegionCode.OVERSEAS); + verify(jdbc).update(anyString(), any(), any(), eq(envelope.eventId()), anyString()); + } + + @Test + void nonOverseasTargetNeverReachesTheBridge() throws Exception { + EventEnvelope envelope = envelope(RegionCode.CN); + when(jdbc.update(anyString(), any(Object[].class))).thenReturn(1); + when(jdbc.queryForObject(anyString(), eq(String.class), any(), any())) + .thenReturn(objectMapper.writeValueAsString(envelope)); + when(jdbc.queryForObject(anyString(), eq(Integer.class), eq(envelope.eventId()))).thenReturn(0); + + relay.deliver(envelope.eventId()); + + verify(client, never()).receive(anyString(), any(EventEnvelope.class)); + verify(jdbc).update(anyString(), eq("PENDING"), eq(1), any(), anyString(), any(), + eq(envelope.eventId()), anyString()); + } + + private EventEnvelope envelope(RegionCode target) { + var value = new IamSecurityEventsV1.PermissionVersionChanged( + 101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z")); + var payload = objectMapper.valueToTree(value); + return new EventEnvelope("00000000-0000-4000-8000-000000000081", + EventTypes.IAM_PERMISSION_VERSION_CHANGED, IamSecurityEventsV1.VERSION, + Instant.parse("2026-08-25T07:00:00Z"), RegionCode.CN, target, "0", + "AUTHORIZATION", "101", 3L, null, "trace-12345678", "correlation-12345678", + null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java index 432587f3f..fb1ce9e8b 100644 --- a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java @@ -1,10 +1,13 @@ package com.baiyue.iam.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.IamSecurityEventsV1; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -43,4 +46,29 @@ class SecurityEventOutboxServiceTest { EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class); assertThat(envelope.requireValid().correlationId()).isNotBlank().isEqualTo(envelope.traceId()); } + + @ParameterizedTest + @CsvSource({ + "ACTIVE,ACTIVE", + "PROFILE_REQUIRED,DISABLED", + "PENDING,DISABLED", + "REJECTED,DISABLED", + "DISABLED,DISABLED", + "ARCHIVED,DISABLED" + }) + void tenantWorkflowStatusIsNormalizedToTheSecurityContract(String businessStatus, + String expectedSecurityStatus) throws Exception { + when(jdbc.queryForObject(anyString(), eq(Long.class), any())).thenReturn(3L); + + service.tenantStatus(10001L, businessStatus); + + ArgumentCaptor parameters = ArgumentCaptor.forClass(Object[].class); + verify(jdbc).update(eq("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) " + + "VALUES (?,?,?,?,CAST(? AS JSON))"), parameters.capture()); + EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class); + IamSecurityEventsV1.TenantStatusChanged payload = objectMapper.treeToValue( + envelope.payload(), IamSecurityEventsV1.TenantStatusChanged.class); + assertThat(payload.status()).isEqualTo(expectedSecurityStatus); + assertThat(envelope.aggregateVersion()).isEqualTo(3L); + } } diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java index 8dfbe466c..8e76d8a1d 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java @@ -34,6 +34,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; @@ -43,6 +44,7 @@ class TenantAdministratorProvisioningServiceTest { @MockBean private MemberRepository members; @MockBean private IamRoleMapper roles; @MockBean private AuthAccountClient auth; + @MockBean private SecurityEventOutboxService securityEvents; @Resource private TenantAdministratorProvisioningService service; private IamTenant tenant; @@ -101,6 +103,9 @@ class TenantAdministratorProvisioningServiceTest { verify(members).insertUser(any(IamUser.class)); verify(members).insert(any(IamMembership.class)); verify(members).replaceRoles(eq(tenant.getId()), anyLong(), eq(List.of(4000100L))); + verify(securityEvents).userStatus(account.getValue().accountId(), tenant.getId(), "MERCHANT", "ACTIVE"); + verify(securityEvents).permissionChanged(account.getValue().accountId(), tenant.getId(), "MERCHANT", + "INITIAL_AUTHORIZATION"); } @Test @@ -135,6 +140,7 @@ class TenantAdministratorProvisioningServiceTest { assertEquals(null, result.temporaryPassword()); verify(auth, never()).create(anyString(), any()); verify(members, never()).replaceRoles(anyLong(), anyLong(), any()); + verifyNoInteractions(securityEvents); } @Test @@ -153,6 +159,8 @@ class TenantAdministratorProvisioningServiceTest { assertFalse(result.created()); verify(auth, never()).create(anyString(), any()); verify(members).replaceRoles(tenant.getId(), owner.getId(), List.of(4000200L, 4000100L)); + verify(securityEvents).permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT", + "MEMBER_ROLES_CHANGED"); } @Test @@ -190,6 +198,9 @@ class TenantAdministratorProvisioningServiceTest { verify(members, never()).insertUser(any()); verify(members, never()).insert(any()); verify(members, never()).replaceRoles(anyLong(), anyLong(), any()); + verify(securityEvents).userStatus(user.getAuthUserId(), tenant.getId(), "MERCHANT", "ACTIVE"); + verify(securityEvents).permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT", + "INITIAL_AUTHORIZATION"); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index ca4291de3..c9f7f82c6 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -320,6 +320,7 @@ class TenantServiceTest { verify(repository).insertTenant(argThat(tenant -> Long.valueOf(9001L).equals(tenant.getCreatedBy()) && Long.valueOf(9001L).equals(tenant.getUpdatedBy()))); verify(dimensionRuleInitialization).initialize(created.tenant().id(), 9001L); + verify(securityEvents).tenantStatus(created.tenant().id(), "PROFILE_REQUIRED"); verify(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L)); verify(repository, never()).insertReview(any()); } @@ -428,6 +429,7 @@ class TenantServiceTest { assertEquals("ACTIVE", tenant.getStatus()); verify(repository).saveReview(review); verify(repository).saveTenant(tenant); + verify(securityEvents).tenantStatus(10001L, "ACTIVE"); } @Test @@ -527,3 +529,4 @@ class TenantServiceTest { return review; } } + diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index cc6a8ccc8..828e0e050 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -20,6 +20,7 @@ | 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper | | 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 | | 内部令牌保护 | `security/InternalServiceTokenGuard.java` | +| IAM 海外安全投影与实时校验 | 内部入口 `security/controller/LastlegSecurityController.java`,固定 `/internal/lastleg/security/v1/events`、`/validate` 且复用 `InternalServiceTokenGuard`;投影/校验 `security/service/`,DTO `security/dto/`,表固定 `ll_security_event_inbox`、`ll_security_projection`;MySQL 投影缺失、落后或不可用一律 fail-closed,Redis 仅为提交后可重建镜像;业务切片不得直接依赖投影内部类,边界由 `SecurityProjectionArchitectureTest` 与 Node 合同锁定 | | 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java` | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | @@ -62,6 +63,7 @@ | 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 | | 采购、卡车与本土散板 | `ll_overseas_procurement_*`、`ll_us_truck_*`、`ll_local_looseboard_*` | 海外采购、美国卡车运输及本土散板订单 | | 通用资料 | `ll_overseas_quote*`、`ll_monthly_exchange_rate*`、`ll_vehicle` | 海外仓报价、月度汇率及车辆档案;不保存车辆派送履约历史 | +| 海外安全投影 | `ll_security_event_inbox`、`ll_security_projection` | IAM 安全事件可靠收件箱与 Lastleg 本地授权投影;只允许内部服务入口访问,不作为 loading、shipping、inbound 等业务模型 | - 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义,不得只写英文、字段名或类型名。 - `SchemaChineseCommentContractTest` 自动扫描全部 `ll_` 表,无需维护表名白名单;新增字段必须同步更新基线 SQL、建表增量和存量备注迁移,增量脚本的 `ADD/MODIFY COLUMN` 不得丢失中文备注。 @@ -76,6 +78,9 @@ - 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。 - 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 - 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 +- IAM 安全事件只能进入 `/internal/lastleg/security/v1/events`;通用 `/internal/lastleg/events` 只白名单接收装柜完成与实际船节点事件,禁止把 IAM 事件交给 `LoadingCompletedConsumer`。 +- Lastleg 安全投影只接受 `ACTIVE`/`DISABLED` 两态安全语义;客户建档和审核的 `PROFILE_REQUIRED`/`PENDING`/`REJECTED`/`ARCHIVED` 由 IAM 在发布前统一折叠为 `DISABLED`,Lastleg 不兼容或解释国内工作流状态。 +- 开发基线只为国内固定 `authUserId=1000001`、`tenantId=0` 的 `platform_admin` 写 USER/AUTHORIZATION version=0 投影;使用 `INSERT IGNORE`,后续 IAM version>=1 事件单调覆盖。不得扩大为历史账号运行时补齐或缺失投影默认放行。 - PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 - 海外 PDA 柜号签收只允许平台 `WAREHOUSE_PICKER` 使用 `lastleg:pda:operate`;PDA 不接收 `customerId`,候选跨客户读取,选柜时从柜内计划唯一解析并冻结真实客户。未完成会话按平台操作账号和柜号分别全局唯一,后续照片、扫码、实收、库存与完成操作只使用会话客户范围;平台管理员和客户账号不得进入该作业入口。 @@ -94,6 +99,7 @@ ## 修改与验证 - 移动包、拆分端口/适配器或引入新的跨子域依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-lastleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。 +- 安全投影边界变更同时运行 `SecurityProjectionArchitectureTest` 与 `LastlegSecurityContractTest`;`security/controller|dto|service` 只允许依赖本子域和根 `InternalServiceTokenGuard`,其他 Lastleg 业务包不得直接依赖这些投影内部包。 - 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。 - 事件契约变化同时检查 `common-event-contract` 版本、Event Bridge 消费方和旧 Payload 哈希兼容。 - 定向验证:`mvn -pl lastleg -am test`。 diff --git a/lastleg-service/pom.xml b/lastleg-service/pom.xml index 938233013..d1274b7a8 100644 --- a/lastleg-service/pom.xml +++ b/lastleg-service/pom.xml @@ -19,6 +19,7 @@ org.springframework.bootspring-boot-starter-actuator com.mysqlmysql-connector-jruntime org.springframework.bootspring-boot-starter-testtest + com.tngtech.archunitarchunittest org.springframework.bootspring-boot-maven-plugin diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs index c639c1bcd..a2488d164 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs @@ -12,6 +12,7 @@ const sourceRoots = { const internalPrefix = 'com.baiyue.lastleg.'; const sliceRules = [ + ['security-projection', /(?:^|\.)security\.(?:controller|dto|service)(?:\.|$)/], ['dispatch-delivery', /(?:^|\.)(?:dispatch|delivery|outbound)(?:\.|$)/], ['relabel-transfer-discard', /(?:^|\.)(?:relabel|transfer|discard)(?:\.|$)/], ['inbound-pda', /(?:^|\.)(?:inboundplan|palletops|pda)(?:\.|$)/], diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index bb8a6a945..28469687c 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -74,3 +74,25 @@ test('vehicle application only depends inward and stays free of adapter framewor && edge.target.startsWith('com.baiyue.lastleg.') && !edge.target.startsWith(prefix)); assert.deepEqual(outwardDependencies, []); }); + +test('security projection stays isolated from Lastleg business slices', () => { + const report = analyze(); + const projectionClasses = report.classes.filter(entry => entry.slice === 'security-projection'); + const projectionNames = new Set(projectionClasses.map(entry => entry.fqcn)); + assert.deepEqual(projectionClasses.map(entry => entry.fqcn).sort(), [ + 'com.baiyue.lastleg.security.controller.LastlegSecurityController', + 'com.baiyue.lastleg.security.dto.LastlegSecurityDtos', + 'com.baiyue.lastleg.security.service.LastlegSecurityProjectionRedisMirror', + 'com.baiyue.lastleg.security.service.LastlegSecurityProjectionService', + 'com.baiyue.lastleg.security.service.LastlegSecurityValidationService', + 'com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity', + ]); + assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'security-projection'), [ + { source: 'security-projection', target: 'shared-technical', count: 1 }, + ]); + assert.deepEqual(report.sliceEdges.filter(edge => edge.target === 'security-projection'), []); + assert.equal(report.sliceSccs.some(component => component.includes('security-projection')), false); + const businessConsumers = report.classDependencies.filter(edge => projectionNames.has(edge.target) + && !projectionNames.has(edge.source)); + assert.deepEqual(businessConsumers, []); +}); diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java similarity index 60% rename from overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java index 05fe60b65..6b3cb5093 100644 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java @@ -1,13 +1,13 @@ -package com.baiyue.overseassecurity.controller; +package com.baiyue.lastleg.security.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.event.EventEnvelope; -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.Acknowledgement; -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationRequest; -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationResponse; -import com.baiyue.overseassecurity.security.InternalServiceTokenGuard; -import com.baiyue.overseassecurity.service.OverseasSecurityValidationService; -import com.baiyue.overseassecurity.service.SecurityProjectionService; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.Acknowledgement; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationResponse; +import com.baiyue.lastleg.security.service.LastlegSecurityProjectionService; +import com.baiyue.lastleg.security.service.LastlegSecurityValidationService; import io.swagger.v3.oas.annotations.Hidden; import jakarta.validation.Valid; import org.springframework.web.bind.annotation.PostMapping; @@ -20,14 +20,15 @@ import java.time.Instant; @Hidden @RestController -@RequestMapping("/internal/overseas-security/v1") -public class OverseasSecurityController { +@RequestMapping("/internal/lastleg/security/v1") +public class LastlegSecurityController { private final InternalServiceTokenGuard tokenGuard; - private final SecurityProjectionService projections; - private final OverseasSecurityValidationService validations; + private final LastlegSecurityProjectionService projections; + private final LastlegSecurityValidationService validations; - public OverseasSecurityController(InternalServiceTokenGuard tokenGuard, SecurityProjectionService projections, - OverseasSecurityValidationService validations) { + public LastlegSecurityController(InternalServiceTokenGuard tokenGuard, + LastlegSecurityProjectionService projections, + LastlegSecurityValidationService validations) { this.tokenGuard = tokenGuard; this.projections = projections; this.validations = validations; diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/dto/OverseasSecurityDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java similarity index 74% rename from overseas-security-service/src/main/java/com/baiyue/overseassecurity/dto/OverseasSecurityDtos.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java index 7560da9b2..cb301fd7f 100644 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/dto/OverseasSecurityDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java @@ -1,26 +1,29 @@ -package com.baiyue.overseassecurity.dto; +package com.baiyue.lastleg.security.dto; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import java.time.Instant; -public final class OverseasSecurityDtos { - private OverseasSecurityDtos() { } +public final class LastlegSecurityDtos { + private LastlegSecurityDtos() { } public record Acknowledgement(String eventId, String result, Instant receivedAt) { } public record ValidationRequest(@NotNull @Min(1) Long userId, @NotNull @Min(0) Long tenantId, - @NotNull @Min(0) Long tokenVersion, @NotNull @Min(0) Long permissionVersion) { } public record ValidationResponse(boolean valid, FailureReason failureReason) { public static ValidationResponse accepted() { return new ValidationResponse(true, null); } - public static ValidationResponse rejected(FailureReason reason) { return new ValidationResponse(false, reason); } + public static ValidationResponse rejected(FailureReason reason) { + return new ValidationResponse(false, reason); + } } public enum FailureReason { + SECURITY_PROJECTION_MISSING, + SECURITY_PROJECTION_NOT_READY, USER_DISABLED, TENANT_DISABLED, PERMISSION_VERSION_STALE diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java similarity index 82% rename from overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java index e022e01cb..5e888487b 100644 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java @@ -1,4 +1,4 @@ -package com.baiyue.overseassecurity.service; +package com.baiyue.lastleg.security.service; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; @@ -11,19 +11,19 @@ import org.springframework.transaction.event.TransactionalEventListener; /** 非授权真相的可重建 Redis 镜像;失败不影响已提交的 MySQL 投影。 */ @Component @Slf4j -public class SecurityProjectionRedisMirror { +public class LastlegSecurityProjectionRedisMirror { private final StringRedisTemplate redis; private final ObjectMapper objectMapper; @Value("${baiyue.security.permission-snapshot-key-prefix:baiyue:permission-snapshot}") private String keyPrefix; - public SecurityProjectionRedisMirror(StringRedisTemplate redis, ObjectMapper objectMapper) { + public LastlegSecurityProjectionRedisMirror(StringRedisTemplate redis, ObjectMapper objectMapper) { this.redis = redis; this.objectMapper = objectMapper; } @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void mirror(SecurityProjectionService.ProjectionChanged event) { + public void mirror(LastlegSecurityProjectionService.ProjectionChanged event) { var value = event.projection(); try { redis.opsForValue().set(keyPrefix + ':' + value.aggregateType() + ':' + value.aggregateId(), diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java similarity index 66% rename from overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java index d76d77932..dcfe0ab4b 100644 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java @@ -1,8 +1,10 @@ -package com.baiyue.overseassecurity.service; +package com.baiyue.lastleg.security.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.IamSecurityEventsV1; import com.baiyue.common.event.RegionCode; @@ -15,17 +17,19 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.List; +import java.util.Locale; import java.util.Objects; @Service -public class SecurityProjectionService { +public class LastlegSecurityProjectionService { private static final List TYPES = List.of(EventTypes.IAM_USER_STATUS_CHANGED, EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED); private final JdbcTemplate jdbc; private final ObjectMapper objectMapper; private final ApplicationEventPublisher events; - public SecurityProjectionService(JdbcTemplate jdbc, ObjectMapper objectMapper, ApplicationEventPublisher events) { + public LastlegSecurityProjectionService(JdbcTemplate jdbc, ObjectMapper objectMapper, + ApplicationEventPublisher events) { this.jdbc = jdbc; this.objectMapper = objectMapper; this.events = events; @@ -35,35 +39,38 @@ public class SecurityProjectionService { public ConsumeResult consume(EventEnvelope envelope) { envelope.requireValid(); requireSecurityEnvelope(envelope); + String payloadHash = validatedStorageHash(envelope); String payloadJson = envelope.payload().toString(); try { - jdbc.update("INSERT INTO overseas_security_event_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) " + jdbc.update("INSERT INTO ll_security_event_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) " + "VALUES (?,?,?,?, 'RECEIVED', NOW(3))", envelope.eventId(), envelope.eventType(), - envelope.payloadHash(), payloadJson); + payloadHash, payloadJson); } catch (DuplicateKeyException duplicate) { - InboxRow existing = jdbc.queryForObject("SELECT event_type,payload_hash,status FROM overseas_security_event_inbox " + InboxRow existing = jdbc.queryForObject( + "SELECT event_type,payload_hash,status FROM ll_security_event_inbox " + "WHERE event_id=? FOR UPDATE", (rs, row) -> new InboxRow(rs.getString(1), rs.getString(2), rs.getString(3)), envelope.eventId()); if (existing == null || !Objects.equals(existing.eventType(), envelope.eventType()) - || !Objects.equals(existing.payloadHash(), envelope.payloadHash())) { + || existing.payloadHash() == null + || !existing.payloadHash().equalsIgnoreCase(payloadHash)) { throw new BizException(ResultCode.CONFLICT, "相同 eventId 的 payloadHash 冲突"); } if ("PROCESSED".equals(existing.status())) return new ConsumeResult("DUPLICATE", null); } - ProjectionInput input = input(envelope); + ProjectionInput input = input(envelope, payloadHash); ProjectionRow current = findForUpdate(input.aggregateType(), input.aggregateId()); - ProjectionMonotonicity.Decision decision = ProjectionMonotonicity.decide( + SecurityProjectionMonotonicity.Decision decision = SecurityProjectionMonotonicity.decide( current == null ? null : current.aggregateVersion(), current == null ? null : current.payloadHash(), input.aggregateVersion(), input.payloadHash()); String result = decision.name(); - if (decision == ProjectionMonotonicity.Decision.APPLY) { + if (decision == SecurityProjectionMonotonicity.Decision.APPLY) { upsert(input); result = "APPLIED"; events.publishEvent(new ProjectionChanged(input)); } - jdbc.update("UPDATE overseas_security_event_inbox SET status='PROCESSED',result=?,processed_at=NOW(3) " + jdbc.update("UPDATE ll_security_event_inbox SET status='PROCESSED',result=?,processed_at=NOW(3) " + "WHERE event_id=?", result, envelope.eventId()); return new ConsumeResult(result, input); } @@ -75,31 +82,48 @@ public class SecurityProjectionService { } } - private ProjectionInput input(EventEnvelope envelope) { + static String validatedStorageHash(EventEnvelope envelope) { + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), + envelope.payloadHash())) { + throw new BizException(ResultCode.BAD_REQUEST, "payloadHash 与 IAM 安全事件负载不一致"); + } + // 表内只保存固定 64 位摘要;信封传输契约仍保留 sha256: 前缀。 + return envelope.payloadHash().substring("sha256:".length()).toLowerCase(Locale.ROOT); + } + + private ProjectionInput input(EventEnvelope envelope, String payloadHash) { try { return switch (envelope.eventType()) { case EventTypes.IAM_USER_STATUS_CHANGED -> { - var value = objectMapper.treeToValue(envelope.payload(), IamSecurityEventsV1.UserStatusChanged.class); + var value = objectMapper.treeToValue(envelope.payload(), + IamSecurityEventsV1.UserStatusChanged.class); requireIdentity(envelope, "USER", value.authUserId(), value.tenantId()); - requirePayload(value.status(), value.changedAt(), value.permissionVersion(), envelope.aggregateVersion()); + requirePayload(value.status(), value.changedAt(), value.permissionVersion(), + envelope.aggregateVersion()); + requireStatus(value.status()); yield new ProjectionInput("USER", String.valueOf(value.authUserId()), value.tenantId(), - value.status(), value.permissionVersion(), envelope.aggregateVersion(), envelope.payloadHash(), - envelope.eventId(), value.changedAt()); + value.status(), value.permissionVersion(), envelope.aggregateVersion(), + payloadHash, envelope.eventId(), value.changedAt()); } case EventTypes.IAM_TENANT_STATUS_CHANGED -> { - var value = objectMapper.treeToValue(envelope.payload(), IamSecurityEventsV1.TenantStatusChanged.class); + var value = objectMapper.treeToValue(envelope.payload(), + IamSecurityEventsV1.TenantStatusChanged.class); requireIdentity(envelope, "TENANT", value.tenantId(), value.tenantId()); - requirePayload(value.status(), value.changedAt(), envelope.aggregateVersion(), envelope.aggregateVersion()); + requirePayload(value.status(), value.changedAt(), envelope.aggregateVersion(), + envelope.aggregateVersion()); + requireStatus(value.status()); yield new ProjectionInput("TENANT", String.valueOf(value.tenantId()), value.tenantId(), - value.status(), null, envelope.aggregateVersion(), envelope.payloadHash(), envelope.eventId(), - value.changedAt()); + value.status(), null, envelope.aggregateVersion(), payloadHash, + envelope.eventId(), value.changedAt()); } case EventTypes.IAM_PERMISSION_VERSION_CHANGED -> { - var value = objectMapper.treeToValue(envelope.payload(), IamSecurityEventsV1.PermissionVersionChanged.class); + var value = objectMapper.treeToValue(envelope.payload(), + IamSecurityEventsV1.PermissionVersionChanged.class); requireIdentity(envelope, "AUTHORIZATION", value.authUserId(), value.tenantId()); - requirePayload(value.appCode(), value.changedAt(), value.permissionVersion(), envelope.aggregateVersion()); + requirePayload(value.appCode(), value.changedAt(), value.permissionVersion(), + envelope.aggregateVersion()); yield new ProjectionInput("AUTHORIZATION", String.valueOf(value.authUserId()), value.tenantId(), - null, value.permissionVersion(), envelope.aggregateVersion(), envelope.payloadHash(), + null, value.permissionVersion(), envelope.aggregateVersion(), payloadHash, envelope.eventId(), value.changedAt()); } default -> throw new IllegalArgumentException("unsupported type"); @@ -111,7 +135,8 @@ public class SecurityProjectionService { } } - private static void requireIdentity(EventEnvelope envelope, String aggregateType, long aggregateId, long tenantId) { + private static void requireIdentity(EventEnvelope envelope, String aggregateType, + long aggregateId, long tenantId) { if (!aggregateType.equals(envelope.aggregateType()) || !String.valueOf(aggregateId).equals(envelope.aggregateId()) || !String.valueOf(tenantId).equals(envelope.tenantId())) { @@ -119,28 +144,38 @@ public class SecurityProjectionService { } } - private static void requirePayload(String requiredText, Instant changedAt, long payloadVersion, long aggregateVersion) { - if (requiredText == null || requiredText.isBlank() || changedAt == null || payloadVersion != aggregateVersion) { + private static void requirePayload(String requiredText, Instant changedAt, + long payloadVersion, long aggregateVersion) { + if (requiredText == null || requiredText.isBlank() || changedAt == null + || payloadVersion != aggregateVersion) { throw new BizException(ResultCode.CONFLICT, "IAM 安全事件 payload 版本或必填字段无效"); } } + private static void requireStatus(String status) { + if (!"ACTIVE".equals(status) && !"DISABLED".equals(status)) { + throw new BizException(ResultCode.BAD_REQUEST, "IAM 安全状态仅允许 ACTIVE 或 DISABLED"); + } + } + private ProjectionRow findForUpdate(String type, String id) { - List rows = jdbc.query("SELECT aggregate_version,payload_hash FROM overseas_security_projection " + List rows = jdbc.query( + "SELECT aggregate_version,payload_hash FROM ll_security_projection " + "WHERE aggregate_type=? AND aggregate_id=? FOR UPDATE", (rs, row) -> new ProjectionRow(rs.getLong(1), rs.getString(2)), type, id); return rows.isEmpty() ? null : rows.get(0); } private void upsert(ProjectionInput value) { - jdbc.update("INSERT INTO overseas_security_projection(aggregate_type,aggregate_id,tenant_id,security_status," + jdbc.update("INSERT INTO ll_security_projection(aggregate_type,aggregate_id,tenant_id,security_status," + "permission_version,aggregate_version,payload_hash,last_event_id,changed_at,updated_at) " + "VALUES (?,?,?,?,?,?,?,?,?,NOW(3)) ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id)," + "security_status=VALUES(security_status),permission_version=VALUES(permission_version)," + "aggregate_version=VALUES(aggregate_version),payload_hash=VALUES(payload_hash)," + "last_event_id=VALUES(last_event_id),changed_at=VALUES(changed_at),updated_at=NOW(3)", - value.aggregateType(), value.aggregateId(), value.tenantId(), value.status(), value.permissionVersion(), - value.aggregateVersion(), value.payloadHash(), value.eventId(), value.changedAt()); + value.aggregateType(), value.aggregateId(), value.tenantId(), value.status(), + value.permissionVersion(), value.aggregateVersion(), value.payloadHash(), value.eventId(), + value.changedAt()); } private record InboxRow(String eventType, String payloadHash, String status) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java new file mode 100644 index 000000000..b23605379 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.security.service; + +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.FailureReason; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationResponse; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class LastlegSecurityValidationService { + private final JdbcTemplate jdbc; + + public LastlegSecurityValidationService(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public ValidationResponse validate(ValidationRequest request) { + String userStatus = status("USER", String.valueOf(request.userId()), request.tenantId()); + if (userStatus == null) { + return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); + } + if (!"ACTIVE".equals(userStatus)) { + return ValidationResponse.rejected(FailureReason.USER_DISABLED); + } + if (request.tenantId() > 0) { + String tenantStatus = status("TENANT", String.valueOf(request.tenantId()), request.tenantId()); + if (tenantStatus == null) { + return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); + } + if (!"ACTIVE".equals(tenantStatus)) { + return ValidationResponse.rejected(FailureReason.TENANT_DISABLED); + } + } + List projectedVersions = jdbc.query( + "SELECT MAX(permission_version) FROM ll_security_projection " + + "WHERE aggregate_id=? AND tenant_id=? " + + "AND aggregate_type IN ('USER','AUTHORIZATION') HAVING COUNT(permission_version)>0", + (rs, row) -> rs.getLong(1), String.valueOf(request.userId()), request.tenantId()); + if (projectedVersions.isEmpty()) { + return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); + } + long projectedVersion = projectedVersions.get(0); + if (request.permissionVersion() < projectedVersion) { + return ValidationResponse.rejected(FailureReason.PERMISSION_VERSION_STALE); + } + if (request.permissionVersion() > projectedVersion) { + return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_NOT_READY); + } + return ValidationResponse.accepted(); + } + + private String status(String type, String id, long tenantId) { + List values = jdbc.query("SELECT security_status FROM ll_security_projection " + + "WHERE aggregate_type=? AND aggregate_id=? AND tenant_id=?", + (rs, row) -> rs.getString(1), type, id, tenantId); + return values.isEmpty() ? null : values.get(0); + } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/ProjectionMonotonicity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java similarity index 82% rename from overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/ProjectionMonotonicity.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java index 958e5e44b..4085bc4de 100644 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/ProjectionMonotonicity.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java @@ -1,12 +1,12 @@ -package com.baiyue.overseassecurity.service; +package com.baiyue.lastleg.security.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import java.util.Objects; -final class ProjectionMonotonicity { - private ProjectionMonotonicity() { } +final class SecurityProjectionMonotonicity { + private SecurityProjectionMonotonicity() { } static Decision decide(Long currentVersion, String currentHash, long incomingVersion, String incomingHash) { if (currentVersion == null || incomingVersion > currentVersion) return Decision.APPLY; diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 3699c27ba..9a76d798a 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -70,7 +70,7 @@ baiyue: lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30} security: - internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} + internal-service-token: ${INTERNAL_SERVICE_TOKEN:} jwt-mode: jwks session-mode: claims issuer: ${JWT_ISSUER:auth-service} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 893cea457..44d82c7d3 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1624,3 +1624,42 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_operation ( UNIQUE KEY uk_ll_local_looseboard_operation_key(customer_id,order_id,idempotency_key), KEY idx_ll_local_looseboard_operation_order(customer_id,order_id,created_at) ) ENGINE=InnoDB COMMENT='本土散板订单幂等操作账'; + +CREATE TABLE IF NOT EXISTS ll_security_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '跨区安全事件ID', + event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型', + payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256哈希', + payload_json JSON NOT NULL COMMENT '冻结事件载荷JSON', + status VARCHAR(16) NOT NULL COMMENT '消费状态:RECEIVED或PROCESSED', + result VARCHAR(32) NULL COMMENT '处理结果:APPLIED、STALE、DUPLICATE或DUPLICATE_VERSION', + received_at DATETIME(3) NOT NULL COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + PRIMARY KEY (event_id), + KEY idx_security_inbox_type_time (event_type,received_at) +) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者收件箱'; + +CREATE TABLE IF NOT EXISTS ll_security_projection ( + aggregate_type VARCHAR(32) NOT NULL COMMENT '聚合类型:USER、TENANT或AUTHORIZATION', + aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID', + tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID', + security_status VARCHAR(24) NULL COMMENT '当前安全状态:ACTIVE或DISABLED', + permission_version BIGINT NULL COMMENT '当前单调权限版本', + aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本', + payload_hash CHAR(64) NOT NULL COMMENT '当前版本载荷哈希', + last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用的事件ID', + changed_at DATETIME(3) NOT NULL COMMENT '国内IAM事实发生时间', + updated_at DATETIME(3) NOT NULL COMMENT '投影更新时间', + PRIMARY KEY (aggregate_type,aggregate_id), + KEY idx_security_projection_tenant (tenant_id), + KEY idx_security_projection_permission (aggregate_id,permission_version) +) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; + +-- 开发基线固定管理员;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。 +INSERT IGNORE INTO ll_security_projection ( + aggregate_type,aggregate_id,tenant_id,security_status,permission_version,aggregate_version, + payload_hash,last_event_id,changed_at,updated_at +) VALUES + ('USER','1000001',0,'ACTIVE',0,0,SHA2('lastleg-baseline:platform_admin:USER:0',256), + 'baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'), + ('AUTHORIZATION','1000001',0,NULL,0,0,SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0',256), + 'baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java index 49b0a41a3..400699222 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java @@ -9,4 +9,10 @@ class InternalServiceTokenGuardTest { assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify("wrong")).getStatusCode().value()); assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify(null)).getStatusCode().value()); } + + @Test void missingServerConfigurationFailsClosed() { + InternalServiceTokenGuard guard = new InternalServiceTokenGuard(""); + assertEquals(503, assertThrows(ResponseStatusException.class, + () -> guard.verify("any-token")).getStatusCode().value()); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java new file mode 100644 index 000000000..a004dd8d4 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java @@ -0,0 +1,75 @@ +package com.baiyue.lastleg.security; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class LastlegSecurityContractTest { + @Test void securityProjectionLivesInLastlegDatabaseWithReliableInbox() throws Exception { + String baseline = read("lastleg-service/src/main/resources/db/lastleg.sql"); + String migration = read("deploy/sql/海外区域统一增量-MySQL8.sql"); + String authSeed = read("auth/src/main/resources/db/phase1-admin-accounts.sql"); + String service = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java"); + String validation = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java"); + String validationDto = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java"); + String mirror = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java"); + assertThat(baseline).contains("CREATE TABLE IF NOT EXISTS ll_security_event_inbox", + "PRIMARY KEY (event_id)", "payload_hash CHAR(64)", + "CREATE TABLE IF NOT EXISTS ll_security_projection", + "PRIMARY KEY (aggregate_type,aggregate_id)", "aggregate_version BIGINT", + "permission_version BIGINT"); + for (String sql : new String[]{baseline, migration}) { + assertThat(sql).contains("('USER','1000001',0,'ACTIVE',0,0", + "('AUTHORIZATION','1000001',0,NULL,0,0", + "baseline-platform-admin-user-v0", + "baseline-platform-admin-authorization-v0") + .doesNotContain("baseline-merchant-admin"); + } + assertThat(authSeed).contains("(1000001, 'platform_admin'", "default_tenant_id", + "default_app_code"); + assertThat(migration).contains("USE baiyue_lastleg;", "table_schema='baiyue_lastleg'") + .doesNotContain("CREATE DATABASE IF NOT EXISTS baiyue_overseas_security", + "USE baiyue_overseas_security;", "CREATE TABLE IF NOT EXISTS overseas_security_event_inbox", + "CREATE TABLE IF NOT EXISTS overseas_security_projection"); + assertThat(service).contains("SecurityProjectionMonotonicity.decide", "Decision.APPLY"); + assertThat(service).contains("EventPayloadHash.matches", "validatedStorageHash") + .doesNotContain("LoadingCompletedConsumer"); + assertThat(mirror).contains("TransactionPhase.AFTER_COMMIT", "Redis 镜像写入失败"); + assertThat(validation).contains("SELECT MAX(permission_version)", "tenant_id=?", + "SECURITY_PROJECTION_MISSING", "SECURITY_PROJECTION_NOT_READY", "USER_DISABLED", + "TENANT_DISABLED", "PERMISSION_VERSION_STALE"); + assertThat(validationDto).contains("Long userId", "Long tenantId", "Long permissionVersion") + .doesNotContain("Long tokenVersion"); + } + + @Test void genericLastlegEventEndpointWhitelistsBusinessEventsOnly() throws Exception { + String controller = read("lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java"); + assertThat(controller).contains("FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED", + "FIRST_LEG_LOADING_PLAN_COMPLETED", "Lastleg 不消费该事件类型") + .doesNotContain("IAM_USER_STATUS_CHANGED", "IAM_TENANT_STATUS_CHANGED", + "IAM_PERMISSION_VERSION_CHANGED"); + } + + @Test void internalControllerUsesLastlegOwnedPathsAndExistingTokenGuard() throws Exception { + String controller = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java"); + String guard = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java"); + String application = read("lastleg-service/src/main/resources/application.yml"); + assertThat(controller).contains("@RequestMapping(\"/internal/lastleg/security/v1\")", + "@PostMapping(\"/events\")", "@PostMapping(\"/validate\")", + "com.baiyue.lastleg.security.InternalServiceTokenGuard") + .doesNotContain("/internal/overseas-security/v1"); + assertThat(guard).contains("MessageDigest.isEqual", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"); + assertThat(application).contains("internal-service-token: ${INTERNAL_SERVICE_TOKEN:}") + .doesNotContain("phase1-local-internal-token"); + } + + private static String read(String relative) throws Exception { + Path path = Path.of(relative); + if (!Files.exists(path)) path = Path.of("..").resolve(relative); + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java new file mode 100644 index 000000000..27a8de243 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java @@ -0,0 +1,49 @@ +package com.baiyue.lastleg.security; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +class SecurityProjectionArchitectureTest { + private static final String LASTLEG = "com.baiyue.lastleg.."; + private static final String CONTROLLER = "com.baiyue.lastleg.security.controller.."; + private static final String DTO = "com.baiyue.lastleg.security.dto.."; + private static final String SERVICE = "com.baiyue.lastleg.security.service.."; + + private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS) + .importPackages("com.baiyue.lastleg"); + + @Test + void projectionInternalsDoNotDependOnLastlegBusinessSlices() { + classes().that().resideInAnyPackage(CONTROLLER, DTO, SERVICE) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(LASTLEG) + .or(resideInAPackage(CONTROLLER)) + .or(resideInAPackage(DTO)) + .or(resideInAPackage(SERVICE)) + .or(resideInAPackage("com.baiyue.lastleg.security"))) + .check(PRODUCTION_CLASSES); + } + + @Test + void businessSlicesDoNotDependOnProjectionInternals() { + noClasses().that().resideOutsideOfPackages(CONTROLLER, DTO, SERVICE) + .should().dependOnClassesThat().resideInAnyPackage(CONTROLLER, DTO, SERVICE) + .check(PRODUCTION_CLASSES); + } + + @Test + void dtoAndServicesStayIndependentFromInternalWebAdapter() { + noClasses().that().resideInAnyPackage(DTO, SERVICE) + .should().dependOnClassesThat().resideInAPackage(CONTROLLER) + .check(PRODUCTION_CLASSES); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java new file mode 100644 index 000000000..39d78d387 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.security.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.IamSecurityEventsV1; +import com.baiyue.common.event.RegionCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LastlegSecurityProjectionTransportTest { + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + + @Test + void validatesEnvelopeHashAndStoresTheSixtyFourCharacterDigest() { + EventEnvelope envelope = envelope(); + + String stored = LastlegSecurityProjectionService.validatedStorageHash(envelope); + + assertThat(stored).hasSize(64).doesNotStartWith("sha256:"); + assertThat(envelope.payloadHash()).isEqualTo("sha256:" + stored); + } + + @Test + void rejectsTamperedPayloadBeforeInboxMutation() { + EventEnvelope source = envelope(); + EventEnvelope tampered = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), + source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(), + source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), + source.traceId(), source.correlationId(), source.causationId(), source.payload(), + "sha256:" + "0".repeat(64)); + + assertThatThrownBy(() -> LastlegSecurityProjectionService.validatedStorageHash(tampered)) + .isInstanceOf(BizException.class).hasMessageContaining("payloadHash"); + } + + private EventEnvelope envelope() { + var value = new IamSecurityEventsV1.PermissionVersionChanged( + 101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z")); + var payload = objectMapper.valueToTree(value); + return new EventEnvelope("00000000-0000-4000-8000-000000000091", + EventTypes.IAM_PERMISSION_VERSION_CHANGED, IamSecurityEventsV1.VERSION, + Instant.parse("2026-08-25T07:00:00Z"), RegionCode.CN, RegionCode.OVERSEAS, "0", + "AUTHORIZATION", "101", 3L, null, "trace-12345678", "correlation-12345678", + null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java new file mode 100644 index 000000000..5b934e0c6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java @@ -0,0 +1,67 @@ +package com.baiyue.lastleg.security.service; + +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.FailureReason; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +class LastlegSecurityValidationServiceTest { + private final JdbcTemplate jdbc = mock(JdbcTemplate.class); + private final LastlegSecurityValidationService service = new LastlegSecurityValidationService(jdbc); + + @Test void disabledUserFailsClosed() { + doReturn(List.of("DISABLED")).when(jdbc) + .query(anyString(), any(RowMapper.class), any(Object[].class)); + assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason()) + .isEqualTo(FailureReason.USER_DISABLED); + } + + @Test void disabledCustomerFailsClosed() { + doReturn(List.of("ACTIVE"), List.of("DISABLED")).when(jdbc) + .query(anyString(), any(RowMapper.class), any(Object[].class)); + assertThat(service.validate(new ValidationRequest(42L, 100L, 8L)).failureReason()) + .isEqualTo(FailureReason.TENANT_DISABLED); + } + + @Test void missingUserProjectionFailsClosed() { + doReturn(List.of()).when(jdbc) + .query(anyString(), any(RowMapper.class), any(Object[].class)); + assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason()) + .isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING); + } + + @Test void customerProjectionIsMandatoryButPlatformTenantDoesNotNeedOne() { + doReturn(List.of("ACTIVE"), List.of(), List.of("ACTIVE"), List.of(8L)).when(jdbc) + .query(anyString(), any(RowMapper.class), any(Object[].class)); + assertThat(service.validate(new ValidationRequest(42L, 100L, 8L)).failureReason()) + .isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING); + assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).valid()).isTrue(); + } + + @Test void permissionVersionMustExactlyMatchReliableProjection() { + doReturn(List.of("ACTIVE"), List.of(9L), List.of("ACTIVE"), List.of(9L), + List.of("ACTIVE"), List.of(9L)).when(jdbc) + .query(anyString(), any(RowMapper.class), any(Object[].class)); + assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason()) + .isEqualTo(FailureReason.PERMISSION_VERSION_STALE); + assertThat(service.validate(new ValidationRequest(42L, 0L, 10L)).failureReason()) + .isEqualTo(FailureReason.SECURITY_PROJECTION_NOT_READY); + assertThat(service.validate(new ValidationRequest(42L, 0L, 9L)).valid()).isTrue(); + } + + @Test void missingPermissionProjectionFailsClosed() { + doReturn(List.of("ACTIVE"), List.of()).when(jdbc) + .query(anyString(), any(RowMapper.class), any(Object[].class)); + assertThat(service.validate(new ValidationRequest(42L, 0L, 9L)).failureReason()) + .isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicityTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicityTest.java new file mode 100644 index 000000000..b385e2aea --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicityTest.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.security.service; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SecurityProjectionMonotonicityTest { + @Test void appliesFirstAndNewerVersions() { + assertThat(SecurityProjectionMonotonicity.decide(null, null, 1, "hash-1")) + .isEqualTo(SecurityProjectionMonotonicity.Decision.APPLY); + assertThat(SecurityProjectionMonotonicity.decide(1L, "hash-1", 2, "hash-2")) + .isEqualTo(SecurityProjectionMonotonicity.Decision.APPLY); + } + + @Test void ignoresOlderAndSameHashVersions() { + assertThat(SecurityProjectionMonotonicity.decide(3L, "hash-3", 2, "hash-2")) + .isEqualTo(SecurityProjectionMonotonicity.Decision.STALE); + assertThat(SecurityProjectionMonotonicity.decide(3L, "hash-3", 3, "hash-3")) + .isEqualTo(SecurityProjectionMonotonicity.Decision.DUPLICATE_VERSION); + } + + @Test void rejectsSameVersionWithDifferentHash() { + assertThatThrownBy(() -> SecurityProjectionMonotonicity.decide(3L, "hash-a", 3, "hash-b")) + .isInstanceOf(BizException.class).hasMessageContaining("payloadHash 冲突"); + } +} diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json index cc5660ecb..2966a26c2 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -23,6 +23,7 @@ "relabel-transfer-discard->inventory", "relabel-transfer-discard->shared-technical", "relabel-transfer-discard->tailleg-handoff", + "security-projection->shared-technical", "tailleg-handoff->dropship", "tailleg-handoff->inbound-pda", "tailleg-handoff->inventory", @@ -35,7 +36,13 @@ "com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleRecord": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java", "com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java", "com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java", - "com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java" + "com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java", + "com.baiyue.lastleg.security.controller.LastlegSecurityController": "src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java", + "com.baiyue.lastleg.security.dto.LastlegSecurityDtos": "src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java", + "com.baiyue.lastleg.security.service.LastlegSecurityProjectionRedisMirror": "src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java", + "com.baiyue.lastleg.security.service.LastlegSecurityProjectionService": "src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java", + "com.baiyue.lastleg.security.service.LastlegSecurityValidationService": "src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java", + "com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity": "src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java" }, "applicationDependencyRule": { "packagePrefix": "com.baiyue.lastleg.masterdata.vehicle.application", @@ -49,6 +56,10 @@ ] }, "sliceSpecificRules": { + "security-projection": { + "allowedOutgoing": ["shared-technical"], + "allowedIncoming": [] + }, "overseas-master-data": { "allowedOutgoing": ["shared-technical"], "allowedIncoming": ["procurement-trucking-looseboard"] diff --git a/overseas-security-service/AGENTS.md b/overseas-security-service/AGENTS.md deleted file mode 100644 index 01cb5953f..000000000 --- a/overseas-security-service/AGENTS.md +++ /dev/null @@ -1,14 +0,0 @@ -# Overseas Security Service 模块指引 - -## 职责边界 - -- 消费 IAM 三类跨区安全事件,维护海外可靠 Inbox 与单调安全投影,并为海外 Gateway 提供内部实时校验。 -- MySQL 是恢复源;Redis 仅为可重建镜像,不能成为授权真相或在失败时默认放行。 -- 不签发 Token、不保存 raw Token/凭据、不承载 Lastleg 业务,也不解释 Event Bridge 的重试状态。 - -## 入口 - -- 内部事件与校验:`controller/OverseasSecurityController.java` -- 幂等、单调投影:`service/SecurityProjectionService.java` -- Token 校验:`service/OverseasSecurityValidationService.java` -- SQL:`src/main/resources/db/overseas-security.sql` diff --git a/overseas-security-service/pom.xml b/overseas-security-service/pom.xml deleted file mode 100644 index a8c5cd7fd..000000000 --- a/overseas-security-service/pom.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - 4.0.0 - com.baiyue.firstlegbaiyue-firstleg-platform - 1.0.0-SNAPSHOT../pom.xml - overseas-security-service - - com.baiyue.sharedbaiyue-common-core${project.version} - com.baiyue.sharedbaiyue-common-web${project.version} - com.baiyue.sharedbaiyue-common-openapi${project.version} - com.baiyue.sharedcommon-event-contract${project.version} - org.springframework.bootspring-boot-starter-jdbc - org.springframework.bootspring-boot-starter-data-redis - com.mysqlmysql-connector-jruntime - com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery - com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config - org.springframework.bootspring-boot-starter-actuator - org.springframework.bootspring-boot-starter-testtest - - org.springframework.bootspring-boot-maven-plugin - diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/OverseasSecurityApplication.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/OverseasSecurityApplication.java deleted file mode 100644 index cfe7c82a6..000000000 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/OverseasSecurityApplication.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.baiyue.overseassecurity; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class OverseasSecurityApplication { - public static void main(String[] args) { - SpringApplication.run(OverseasSecurityApplication.class, args); - } -} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/security/InternalServiceTokenGuard.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/security/InternalServiceTokenGuard.java deleted file mode 100644 index 1a6aa6b7c..000000000 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/security/InternalServiceTokenGuard.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.baiyue.overseassecurity.security; - -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import jakarta.annotation.PostConstruct; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; - -@Component -public class InternalServiceTokenGuard { - @Value("${baiyue.security.internal-service-token:}") private String configuredToken; - private byte[] expected; - - @PostConstruct void initialize() { - expected = configuredToken == null ? new byte[0] : configuredToken.getBytes(StandardCharsets.UTF_8); - if (expected.length < 32) throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); - } - - public void verify(String supplied) { - byte[] actual = supplied == null ? new byte[0] : supplied.getBytes(StandardCharsets.UTF_8); - if (!MessageDigest.isEqual(expected, actual)) throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效"); - } -} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java deleted file mode 100644 index 4b7ee66cf..000000000 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.baiyue.overseassecurity.service; - -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.FailureReason; -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationRequest; -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationResponse; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -public class OverseasSecurityValidationService { - private final JdbcTemplate jdbc; - - public OverseasSecurityValidationService(JdbcTemplate jdbc) { - this.jdbc = jdbc; - } - - public ValidationResponse validate(ValidationRequest request) { - String userStatus = status("USER", String.valueOf(request.userId())); - if (userStatus != null && !"ACTIVE".equals(userStatus)) { - return ValidationResponse.rejected(FailureReason.USER_DISABLED); - } - String tenantStatus = status("TENANT", String.valueOf(request.tenantId())); - if (tenantStatus != null && !"ACTIVE".equals(tenantStatus)) { - return ValidationResponse.rejected(FailureReason.TENANT_DISABLED); - } - Long projectedVersion = jdbc.queryForObject( - "SELECT MAX(permission_version) FROM overseas_security_projection " - + "WHERE aggregate_id=? AND aggregate_type IN ('USER','AUTHORIZATION')", - Long.class, String.valueOf(request.userId())); - if (projectedVersion != null && request.permissionVersion() < projectedVersion) { - return ValidationResponse.rejected(FailureReason.PERMISSION_VERSION_STALE); - } - return ValidationResponse.accepted(); - } - - private String status(String type, String id) { - List values = jdbc.query("SELECT security_status FROM overseas_security_projection " - + "WHERE aggregate_type=? AND aggregate_id=?", - (rs, row) -> rs.getString(1), type, id); - return values.isEmpty() ? null : values.get(0); - } -} diff --git a/overseas-security-service/src/main/resources/application.yml b/overseas-security-service/src/main/resources/application.yml deleted file mode 100644 index 6817568d2..000000000 --- a/overseas-security-service/src/main/resources/application.yml +++ /dev/null @@ -1,28 +0,0 @@ -server: - port: ${OVERSEAS_SECURITY_PORT:9307} -spring: - application.name: overseas-security-service - config.import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${OVERSEAS_SECURITY_DB_URL} - username: ${OVERSEAS_SECURITY_DB_USERNAME} - password: ${OVERSEAS_SECURITY_DB_PASSWORD} - data.redis: - host: ${REDIS_HOST:127.0.0.1} - port: ${REDIS_PORT:6379} - password: ${REDIS_PASSWORD:} - database: ${REDIS_DATABASE:0} - cloud.nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} - discovery: - group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:prod-overseas} -baiyue.security: - internal-service-token: ${INTERNAL_SERVICE_TOKEN} - permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} -management.endpoints.web.exposure.include: health,info,prometheus diff --git a/overseas-security-service/src/main/resources/db/overseas-security.sql b/overseas-security-service/src/main/resources/db/overseas-security.sql deleted file mode 100644 index 7553fba93..000000000 --- a/overseas-security-service/src/main/resources/db/overseas-security.sql +++ /dev/null @@ -1,31 +0,0 @@ -CREATE DATABASE IF NOT EXISTS baiyue_overseas_security DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; -USE baiyue_overseas_security; - -CREATE TABLE IF NOT EXISTS overseas_security_event_inbox ( - event_id VARCHAR(64) NOT NULL COMMENT '跨区事件ID', - event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型', - payload_hash CHAR(64) NOT NULL COMMENT '规范化Payload SHA-256', - payload_json JSON NOT NULL COMMENT '冻结事件Payload', - status VARCHAR(16) NOT NULL COMMENT 'RECEIVED/PROCESSED', - result VARCHAR(32) NULL COMMENT 'APPLIED/STALE/DUPLICATE_VERSION', - received_at DATETIME(3) NOT NULL, - processed_at DATETIME(3) NULL, - PRIMARY KEY (event_id), - KEY idx_security_inbox_type_time (event_type, received_at) -) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者Inbox'; - -CREATE TABLE IF NOT EXISTS overseas_security_projection ( - aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER/TENANT/AUTHORIZATION', - aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID', - tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID', - security_status VARCHAR(24) NULL COMMENT 'ACTIVE/DISABLED等当前状态', - permission_version BIGINT NULL COMMENT '当前单调权限版本', - aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本', - payload_hash CHAR(64) NOT NULL COMMENT '当前版本Payload哈希', - last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用事件ID', - changed_at DATETIME(3) NOT NULL COMMENT '国内IAM事实发生时间', - updated_at DATETIME(3) NOT NULL, - PRIMARY KEY (aggregate_type, aggregate_id), - KEY idx_security_projection_tenant (tenant_id), - KEY idx_security_projection_permission (aggregate_id, permission_version) -) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; diff --git a/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml b/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml deleted file mode 100644 index e228e9386..000000000 --- a/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml +++ /dev/null @@ -1,26 +0,0 @@ -openapi: 3.0.3 -info: - title: Overseas Security Projection Internal API - version: 1.0.0 -paths: - /internal/overseas-security/v1/events: - post: - summary: Consume one IAM security EventEnvelope - security: [{ internalServiceToken: [] }] - responses: - '200': - description: "Acknowledgement contains eventId, result and receivedAt" - /internal/overseas-security/v1/validate: - post: - summary: Validate locally verified overseas JWT claim versions - description: Accepts userId, tenantId, tokenVersion and permissionVersion; raw tokens and credentials are forbidden. - security: [{ internalServiceToken: [] }] - responses: - '200': - description: "valid plus optional USER_DISABLED, TENANT_DISABLED or PERMISSION_VERSION_STALE reason" -components: - securitySchemes: - internalServiceToken: - type: apiKey - in: header - name: X-Internal-Service-Token diff --git a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/OverseasSecurityContractTest.java b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/OverseasSecurityContractTest.java deleted file mode 100644 index b626bb89b..000000000 --- a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/OverseasSecurityContractTest.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.baiyue.overseassecurity; - -import org.junit.jupiter.api.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; - -class OverseasSecurityContractTest { - @Test void sqlFreezesReliableInboxProjectionAndRedisRecoveryBoundary() throws Exception { - String sql = read("overseas-security-service/src/main/resources/db/overseas-security.sql"); - String service = read("overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java"); - String validation = read("overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java"); - String mirror = read("overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java"); - assertThat(sql).contains("PRIMARY KEY (event_id)", "payload_hash CHAR(64)", - "PRIMARY KEY (aggregate_type, aggregate_id)", "aggregate_version BIGINT", "permission_version BIGINT"); - assertThat(service).contains("ProjectionMonotonicity.decide", "Decision.APPLY"); - assertThat(mirror).contains("TransactionPhase.AFTER_COMMIT", "Redis 镜像写入失败"); - assertThat(validation).contains("SELECT MAX(permission_version)", "USER_DISABLED", - "TENANT_DISABLED", "PERMISSION_VERSION_STALE"); - } - - @Test void eventBridgeRoutesIamTypesWithoutInspectingTheirPayload() throws Exception { - String dispatcher = read("event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java"); - String client = read("event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java"); - String lastleg = read("lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java"); - assertThat(dispatcher).contains("IAM_USER_STATUS_CHANGED", "IAM_TENANT_STATUS_CHANGED", - "IAM_PERMISSION_VERSION_CHANGED", "securityProjectionEventClient.deliver(event)"); - assertThat(dispatcher).doesNotContain("IamSecurityEventsV1", "permissionVersion()", "status()"); - assertThat(client).contains("path(\"data\").path(\"eventId\")", "row.getEventId().equals") - .doesNotContain("IamSecurityEventsV1"); - assertThat(lastleg).contains("Lastleg 不消费该事件类型") - .doesNotContain("? shippingNodeConsumer.consume(event) : consumer.consume(event)"); - } - - @Test void internalOpenApiNeverAcceptsRawTokensOrCredentials() throws Exception { - String openapi = read("overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml"); - assertThat(openapi).contains("/internal/overseas-security/v1/events:", - "/internal/overseas-security/v1/validate:", "X-Internal-Service-Token", - "userId, tenantId, tokenVersion and permissionVersion"); - assertThat(openapi).doesNotContain("accessToken", "refreshToken", "password:", "Authorization"); - } - - private static String read(String relative) throws Exception { - Path path = Path.of(relative); - if (!Files.exists(path)) path = Path.of("..").resolve(relative); - return Files.readString(path, StandardCharsets.UTF_8); - } -} diff --git a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationServiceTest.java b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationServiceTest.java deleted file mode 100644 index f38834f99..000000000 --- a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationServiceTest.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.baiyue.overseassecurity.service; - -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.FailureReason; -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationRequest; -import org.junit.jupiter.api.Test; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowMapper; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class OverseasSecurityValidationServiceTest { - private final JdbcTemplate jdbc = mock(JdbcTemplate.class); - private final OverseasSecurityValidationService service = new OverseasSecurityValidationService(jdbc); - - @Test void disabledUserAndTenantFailClosed() { - when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))) - .thenReturn(List.of("DISABLED")); - assertThat(service.validate(new ValidationRequest(42L, 0L, 3L, 8L)).failureReason()) - .isEqualTo(FailureReason.USER_DISABLED); - } - - @Test void stalePermissionVersionFailsAndCurrentVersionPasses() { - when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))) - .thenReturn(List.of(), List.of()); - when(jdbc.queryForObject(anyString(), any(Class.class), any(Object[].class))).thenReturn(9L, 9L); - assertThat(service.validate(new ValidationRequest(42L, 0L, 3L, 8L)).failureReason()) - .isEqualTo(FailureReason.PERMISSION_VERSION_STALE); - assertThat(service.validate(new ValidationRequest(42L, 0L, 3L, 9L)).valid()).isTrue(); - } -} diff --git a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/ProjectionMonotonicityTest.java b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/ProjectionMonotonicityTest.java deleted file mode 100644 index 89560963c..000000000 --- a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/ProjectionMonotonicityTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.baiyue.overseassecurity.service; - -import com.baiyue.common.core.exception.BizException; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class ProjectionMonotonicityTest { - @Test void appliesFirstAndNewerVersions() { - assertThat(ProjectionMonotonicity.decide(null, null, 1, "hash-1")) - .isEqualTo(ProjectionMonotonicity.Decision.APPLY); - assertThat(ProjectionMonotonicity.decide(1L, "hash-1", 2, "hash-2")) - .isEqualTo(ProjectionMonotonicity.Decision.APPLY); - } - - @Test void ignoresOlderAndSameHashVersions() { - assertThat(ProjectionMonotonicity.decide(3L, "hash-3", 2, "hash-2")) - .isEqualTo(ProjectionMonotonicity.Decision.STALE); - assertThat(ProjectionMonotonicity.decide(3L, "hash-3", 3, "hash-3")) - .isEqualTo(ProjectionMonotonicity.Decision.DUPLICATE_VERSION); - } - - @Test void rejectsSameVersionWithDifferentHash() { - assertThatThrownBy(() -> ProjectionMonotonicity.decide(3L, "hash-a", 3, "hash-b")) - .isInstanceOf(BizException.class).hasMessageContaining("payloadHash 冲突"); - } -} diff --git a/pom.xml b/pom.xml index 2cd812fb5..20134475d 100644 --- a/pom.xml +++ b/pom.xml @@ -39,7 +39,6 @@ common-event-contract gateway gateway-overseas - overseas-security-service auth iam order diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 49f40a477..6bb27b5ea 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -350,7 +350,9 @@ export class AcceptanceRun { this.coverageGaps = [] this.context = {} this.token = '' + this.refreshToken = '' this.overseasToken = '' + this.overseasExpiresAt = 0 this.fatal = null } @@ -553,6 +555,30 @@ export class AcceptanceRun { this.modules.push({ name, startedAt, finishedAt: new Date().toISOString(), success: fatal === null && steps.every((s) => s.passed), passed: steps.filter((s) => s.passed).length, failed: steps.filter((s) => !s.passed).length, fatal }) } + async refreshSession(reason) { + const response = await this.request('POST', '/api/auth/refresh', + { refreshToken: this.refreshToken }, '') + const next = this.apiSuccess('authentication', '国内 refresh 签发新双区域会话', response, + `reason=${reason}`, { scenario: 'dual-token-refresh', deploymentBlocker: true }) + const valid = Boolean(next?.accessToken) && Boolean(next?.refreshToken) + && Boolean(next?.overseasAccessToken) && Number(next?.overseasExpiresIn) > 0 + this.assert('authentication', '刷新响应完整后原子替换国内与海外Token', response, valid, + `accessTokenPresent=${Boolean(next?.accessToken)}, refreshTokenPresent=${Boolean(next?.refreshToken)}, overseasTokenPresent=${Boolean(next?.overseasAccessToken)}, overseasExpiresIn=${next?.overseasExpiresIn ?? 'missing'}`, + { scenario: 'dual-token-refresh', deploymentBlocker: true }) + if (!valid) return false + const replacement = { + token: next.accessToken, + refreshToken: next.refreshToken, + overseasToken: next.overseasAccessToken, + overseasExpiresAt: Date.now() + Number(next.overseasExpiresIn) * 1000, + } + this.token = replacement.token + this.refreshToken = replacement.refreshToken + this.overseasToken = replacement.overseasToken + this.overseasExpiresAt = replacement.overseasExpiresAt + return true + } + async findAvailablePhone(module = 'customers') { const seed = BigInt(Date.now() % 10_000_000_000) for (let attempt = 0; attempt < 20; attempt += 1) { @@ -1159,7 +1185,10 @@ export class AcceptanceRun { if (!login?.accessToken) this.fatal = '平台管理员登录失败,未执行业务模块' else { this.token = login.accessToken + this.refreshToken = login.refreshToken || '' this.overseasToken = login.overseasAccessToken || '' + this.overseasExpiresAt = Number(login.overseasExpiresIn) > 0 + ? Date.now() + Number(login.overseasExpiresIn) * 1000 : 0 const requiresOverseas = this.options.modules.some((module) => module.startsWith('phase4-overseas-')) if (requiresOverseas) { this.assert('authentication', '登录响应包含有效海外访问令牌', loginResponse, @@ -1170,6 +1199,10 @@ export class AcceptanceRun { const handlers = { customers: () => this.runCustomers() } for (let moduleIndex = 0; moduleIndex < this.options.modules.length; moduleIndex += 1) { const module = this.options.modules[moduleIndex] + if (module.startsWith('phase4-overseas-') + && this.overseasExpiresAt > 0 && Date.now() + 120_000 >= this.overseasExpiresAt) { + await this.refreshSession(`海外模块 ${module} 执行前Token临近到期`) + } const fullFinanceChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'].every((name) => this.options.modules.includes(name)) if (module === 'finance' && fullFinanceChain) { this.modules.push({ name: module, startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), success: true, passed: 0, failed: 0, fatal: null, skipped: 'finance-full-chain owns the finance source locks' }) @@ -1233,6 +1266,19 @@ export class AcceptanceRun { } finally { if (restoreFixture) restoreFixture() } + const laterOverseasModule = this.options.modules.slice(moduleIndex + 1) + .some((name) => name.startsWith('phase4-overseas-')) + if (module === 'system' && laterOverseasModule) { + const refreshed = await this.refreshSession('system模块权限与角色变更完成') + if (refreshed) { + const probe = await this.requestOverseas('GET', + '/api/platform/v1/last-leg/orders?page=1&size=1') + this.assert('authentication', 'system后新admin海外Token只读探针可用', probe, + probe.http === 200 && String(probe.body?.code) === '200', + `HTTP=${probe.http}, code=${probe.body?.code ?? 'missing'}, requestId=${probe.requestId || 'missing'}, traceId=${probe.traceId || 'missing'}`, + { scenario: 'dual-token-refresh', deploymentBlocker: true }) + } + } } } const reportSurface = JSON.stringify({ @@ -1240,7 +1286,8 @@ export class AcceptanceRun { deploymentBlockers: this.deploymentBlockers, coverageGaps: this.coverageGaps, createdResources: this.createdResources, }) - const forbiddenValues = [this.options.password, this.token, this.overseasToken].filter((value) => typeof value === 'string' && value.length >= 8) + const forbiddenValues = [this.options.password, this.token, this.refreshToken, this.overseasToken] + .filter((value) => typeof value === 'string' && value.length >= 8) const sensitivePatterns = [ /"(?:password|newPassword|temporaryPassword|accessToken|refreshToken|authorization)"\s*:/i, /\bBearer\s+[A-Za-z0-9._~-]{16,}/i, diff --git a/scripts/admin-test-env-modules/customer-system-contract.test.mjs b/scripts/admin-test-env-modules/customer-system-contract.test.mjs index 64527c588..80f236ad0 100644 --- a/scripts/admin-test-env-modules/customer-system-contract.test.mjs +++ b/scripts/admin-test-env-modules/customer-system-contract.test.mjs @@ -30,6 +30,16 @@ test('系统验收覆盖后端四种权限资源类型并清理测试资源', () assert.match(system, /DELETE.*permissions\/\$\{additionalPermissionId\}/s) }) +test('system后刷新双Token且受限海外权限只接受403语义', () => { + assert.match(acceptance, /module === 'system' && laterOverseasModule/) + assert.match(acceptance, /refreshSession\('system模块权限与角色变更完成'\)/) + assert.match(acceptance, /system后新admin海外Token只读探针可用/) + assert.match(acceptance, /Date\.now\(\) \+ 120_000 >= this\.overseasExpiresAt/) + assert.match(system, /缺少canonical海外订单权限必须403/) + assert.match(system, /denied\.http === 403 \|\| \['403', 'A0403'\]\.includes/) + assert.doesNotMatch(system, /A0401[^\r\n]{0,120}(必须403|includes)/) +}) + test('客户验收覆盖客户维度操作日志列表和详情快照', () => { assert.match(acceptance, /audit\/logs\?accountId=\$\{tenantId\}&eventType=PLATFORM_TENANT_UPDATE&resourceType=TENANT&resourceId=\$\{tenantId\}/) assert.match(acceptance, /PLATFORM_TENANT_UPDATE/) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 7ebbd6ded..bc11e7d09 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -28,6 +28,13 @@ function Assert-Contains([string]$text, [string]$needle, [string]$scope) { } } +function Assert-NotContains([string]$text, [string]$needle, [string]$scope) { + $script:checked++ + if ($text.Contains($needle)) { + Add-Failure "$scope 仍残留: $needle" + } +} + function Assert-Matches([string]$text, [string]$pattern, [string]$scope) { $script:checked++ if ($text -notmatch $pattern) { @@ -156,14 +163,14 @@ foreach ($gatewayPath in @( Assert-Contains $overseasGatewayConfig 'uri: lb://lastleg-service' 'Overseas Gateway Lastleg 路由' Assert-Contains $overseasGatewayConfig 'uri: lb://warehouse-overseas-service' 'Overseas Gateway Warehouse 路由' -# 海外安全投影是独立部署单元,服务映射、海外编排和 Jenkins 构建入口必须同时存在。 +# 海外安全投影已并入 Lastleg,Gateway 校验和 Event Bridge 消费均指向同一海外服务。 $serviceMap = Read-RequiredText (Join-Path $backendRoot 'deploy/service-map.yml') $overseasCompose = Read-RequiredText (Join-Path $backendRoot 'deploy/docker-compose.overseas.yml') $serviceJenkinsfile = Read-RequiredText (Join-Path $backendRoot 'Jenkinsfile.service') -Assert-Contains $serviceMap 'overseas-security-service:' '服务部署映射' -Assert-Contains $overseasCompose 'overseas-security-service:' '海外 Compose' -Assert-Contains $overseasCompose '/internal/overseas-security/v1/events' '海外安全事件消费地址' -Assert-Contains $serviceJenkinsfile "'overseas-security-service'" '服务 Jenkins 构建入口' +Assert-Contains $serviceMap 'lastleg-service:' 'Lastleg 服务部署映射' +Assert-Contains $overseasCompose '/internal/lastleg/security/v1/events' 'Lastleg 安全事件消费地址' +Assert-Contains $overseasCompose 'LASTLEG_SECURITY_SERVICE_URL' 'Lastleg 安全校验地址' +Assert-NotContains $serviceJenkinsfile "'overseas-security-service'" '独立海外安全服务构建入口' # Maven 聚合声明的全部历史/当前模块必须在后端根指引中可定位。 [xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw @@ -238,11 +245,9 @@ $criticalPaths = @( 'baiyue-firstleg/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java', 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java', 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', - 'baiyue-firstleg/overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java', - 'baiyue-firstleg/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java', - 'baiyue-firstleg/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java', - 'baiyue-firstleg/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml', - 'baiyue-firstleg/overseas-security-service/src/main/resources/db/overseas-security.sql', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java', @@ -309,7 +314,6 @@ $indexedAgentFiles = @( (Join-Path $backendRoot 'common-security/AGENTS.md'), (Join-Path $backendRoot 'common-openapi/AGENTS.md'), (Join-Path $backendRoot 'warehouse-overseas-service/AGENTS.md'), - (Join-Path $backendRoot 'overseas-security-service/AGENTS.md'), (Join-Path $backendRoot 'event-bridge-service/AGENTS.md'), (Join-Path $backendRoot 'file/AGENTS.md'), (Join-Path $backendRoot 'common-event-contract/AGENTS.md') @@ -349,5 +353,5 @@ if ($failures.Count -gt 0) { exit 1 } -Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、Gateway 覆盖、PDA 注册页面与能力开关、Maven 模块、海外安全服务部署映射、关键入口和排除目录。" -ForegroundColor Green +Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、Gateway 覆盖、PDA 注册页面与能力开关、Maven 模块、Lastleg 海外安全投影映射、关键入口和排除目录。" -ForegroundColor Green exit 0 diff --git a/scripts/java-quality-summary.test.mjs b/scripts/java-quality-summary.test.mjs index 8e3831751..8744660a1 100644 --- a/scripts/java-quality-summary.test.mjs +++ b/scripts/java-quality-summary.test.mjs @@ -58,9 +58,9 @@ test('aggregates code modules, report presence and the OpenAPI service matrix', test('repository freezes the current topology and non-blocking aggregate summary wiring', async () => { const summary = await buildQualitySummary(repositoryRoot) - assert.deepEqual(summary.topology, { modules: 23, codeModules: 22, services: 16 }) - assert.equal(summary.openapi.documents, 27) - assert.equal(summary.openapi.coveredServices.length, 8) + assert.deepEqual(summary.topology, { modules: 22, codeModules: 21, services: 15 }) + assert.equal(summary.openapi.documents, 26) + assert.equal(summary.openapi.coveredServices.length, 7) assert.equal(summary.openapi.codeFirstServices.length, 8) const gitlab = (await readFile(path.join(repositoryRoot, '.gitlab-ci.yml'), 'utf8')).replaceAll('\r\n', '\n') diff --git a/scripts/java-topology-contract.test.mjs b/scripts/java-topology-contract.test.mjs index 6c094c6e3..31f96cf24 100644 --- a/scripts/java-topology-contract.test.mjs +++ b/scripts/java-topology-contract.test.mjs @@ -62,13 +62,13 @@ const parseCompose = (yaml) => { })) } -test('root POM freezes 23 modules and service-map freezes 16 executable services', async () => { +test('root POM freezes 22 modules and service-map freezes 15 executable services', async () => { const modules = parseRootModules(await read('pom.xml')) const services = parseServiceMap(await read('deploy/service-map.yml')) - assert.equal(modules.length, 23) - assert.equal(new Set(modules).size, 23) - assert.equal(services.size, 16) + assert.equal(modules.length, 22) + assert.equal(new Set(modules).size, 22) + assert.equal(services.size, 15) const bootModules = [] for (const module of modules) { diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index b1f89decb..d5c3efac4 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -8,18 +8,18 @@ const read = async (path) => (await readFile(new URL(`../${path}`, import.meta.u test('Jenkins pipelines recognize all overseas deployable modules', async () => { const controller = await read('Jenkinsfile') const service = await read('Jenkinsfile.service') - for (const module of ['gateway-overseas', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service']) { + for (const module of ['gateway-overseas', 'lastleg-service', 'warehouse-overseas-service']) { assert.match(controller, new RegExp(`'${module}'`)) assert.match(service, new RegExp(`'${module}'`)) } assert.match(controller, /'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS'/) assert.match(controller, /'lastleg-service': 'SERVICE_LASTLEG'/) assert.match(controller, /'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS'/) - assert.match(controller, /'overseas-security-service': 'SERVICE_OVERSEAS_SECURITY'/) assert.match(service, /gateway-overseas\/target\/gateway-overseas-1\.0\.0-SNAPSHOT\.jar/) assert.match(service, /lastleg-service\/target\/lastleg-service-1\.0\.0-SNAPSHOT\.jar/) assert.match(service, /warehouse-overseas-service\/target\/warehouse-overseas-service-1\.0\.0-SNAPSHOT\.jar/) - assert.match(service, /overseas-security-service\/target\/overseas-security-service-1\.0\.0-SNAPSHOT\.jar/) + assert.doesNotMatch(controller, /overseas-security-service|SERVICE_OVERSEAS_SECURITY/) + assert.doesNotMatch(service, /overseas-security-service/) assert.match(controller, /EVENT_BRIDGE_REGION/) assert.match(service, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{bridgeRegion\}/) assert.match(controller, /choices: \['BOTH', 'CN', 'OVERSEAS'\]/) @@ -32,12 +32,11 @@ test('service map freezes unique internal ports and moves event bridge away from assert.match(map, /^ gateway-overseas:[\s\S]*?port: 9300$/m) assert.match(map, /^ lastleg-service:[\s\S]*?port: 9303$/m) assert.match(map, /^ warehouse-overseas-service:[\s\S]*?port: 9305$/m) - assert.match(map, /^ overseas-security-service:[\s\S]*?port: 9307$/m) assert.match(map, /^ event-bridge-service:[\s\S]*?port: 9312$/m) assert.match(map, /^ gateway-overseas:[\s\S]*?regions: \[OVERSEAS\]$/m) assert.match(map, /^ lastleg-service:[\s\S]*?regions: \[OVERSEAS\]$/m) assert.match(map, /^ warehouse-overseas-service:[\s\S]*?regions: \[OVERSEAS\]$/m) - assert.match(map, /^ overseas-security-service:[\s\S]*?regions: \[OVERSEAS\]$/m) + assert.doesNotMatch(map, /overseas-security-service|9307/) assert.match(map, /^ event-bridge-service:[\s\S]*?regions: \[CN, OVERSEAS\]$/m) }) @@ -50,7 +49,6 @@ test('overseas compose is isolated from CN defaults and disables implicit schema assert.match(compose, /^ gateway-overseas:\s*$/m) assert.match(compose, /^ lastleg-service:\s*$/m) assert.match(compose, /^ warehouse-overseas-service:\s*$/m) - assert.match(compose, /^ overseas-security-service:\s*$/m) assert.match(compose, /^ event-bridge-service:\s*$/m) assert.match(compose, /EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312/) assert.match(compose, /INTERNAL_SERVICE_TOKEN: \$\{INTERNAL_SERVICE_TOKEN\}/) @@ -58,8 +56,9 @@ test('overseas compose is isolated from CN defaults and disables implicit schema assert.match(compose, /EVENT_BRIDGE_DB_URL: \$\{EVENT_BRIDGE_OVERSEAS_DB_URL\}/) assert.match(compose, /EVENT_BRIDGE_DB_USERNAME: \$\{EVENT_BRIDGE_OVERSEAS_DB_USERNAME\}/) assert.match(compose, /EVENT_BRIDGE_DB_PASSWORD: \$\{EVENT_BRIDGE_OVERSEAS_DB_PASSWORD\}/) - assert.match(compose, /OVERSEAS_SECURITY_DB_URL: \$\{OVERSEAS_SECURITY_DB_URL\}/) - assert.match(compose, /OVERSEAS_SECURITY_EVENT_CONSUMER_URL:/) + assert.match(compose, /LASTLEG_SECURITY_SERVICE_URL: \$\{LASTLEG_SECURITY_SERVICE_URL:-http:\/\/lastleg-service:9303\}/) + assert.match(compose, /LASTLEG_SECURITY_EVENT_CONSUMER_URL: \$\{LASTLEG_SECURITY_EVENT_CONSUMER_URL:-http:\/\/lastleg-service:9303\/internal\/lastleg\/security\/v1\/events\}/) + assert.doesNotMatch(compose, /overseas-security-service|9307|OVERSEAS_SECURITY_DB|OVERSEAS_SECURITY_SERVICE_URL|OVERSEAS_SECURITY_EVENT_CONSUMER_URL/) assert.match(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) assert.match(compose, /FIRSTLEG_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) assert.doesNotMatch(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:/) @@ -72,7 +71,11 @@ test('CN and overseas event bridges have explicit opposite-region consumers', as const cn = await read('config/nacos/cn/event-bridge-service.yml') const overseas = await read('config/nacos/overseas/event-bridge-service.yml') const firstleg = await read('config/nacos/cn/firstleg-service.yml') + const iam = await read('config/nacos/cn/iam-service.yml') + const iamApplication = await read('iam/src/main/resources/application.yml') + const iamClient = await read('iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java') const lastleg = await read('config/nacos/overseas/lastleg-service.yml') + const gatewayOverseas = await read('config/nacos/overseas/gateway-overseas.yml') const scripts = `${await read('deploy/scripts/deploy-built-service.sh')}\n${await read('deploy/scripts/deploy-services.sh')}` assert.match(cn, /EVENT_CONSUMER_URL:http:\/\/firstleg:9202\/internal\/firstleg\/v1\/container-events/) assert.match(overseas, /EVENT_CONSUMER_URL:http:\/\/lastleg-service:9303\/internal\/lastleg\/events/) @@ -80,9 +83,17 @@ test('CN and overseas event bridges have explicit opposite-region consumers', as assert.match(cnCompose, /aliases: \[event-bridge-cn\]/) assert.match(overseasCompose, /EVENT_CONSUMER_URL: \$\{EVENT_CONSUMER_URL:-http:\/\/lastleg-service:9303\/internal\/lastleg\/events\}/) assert.match(firstleg, /LASTLEG_EVENT_BRIDGE_URL/) + assert.match(iamClient, /url = "\$\{baiyue\.security-event\.event-bridge-url:\}"/) + assert.match(iam, /SECURITY_EVENT_BRIDGE_URL:http:\/\/event-bridge-overseas:9312/) + assert.match(iamApplication, /SECURITY_EVENT_BRIDGE_URL:http:\/\/event-bridge-overseas:9312/) + assert.match(cnCompose, /SECURITY_EVENT_BRIDGE_URL: \$\{SECURITY_EVENT_BRIDGE_URL:-http:\/\/event-bridge-overseas:9312\}/) + assert.match(overseasCompose, /aliases: \[event-bridge-overseas\]/) assert.match(firstleg, /LASTLEG_OUTBOX_RELAY_LEASE_SECONDS/) assert.match(lastleg, /CN_EVENT_BRIDGE_URL:\$\{FIRSTLEG_EVENT_BRIDGE_URL\}/) assert.match(lastleg, /WAREHOUSE_OVERSEAS_SERVICE_URL:http:\/\/warehouse-overseas-service:9305/) + assert.match(gatewayOverseas, /LASTLEG_SECURITY_SERVICE_URL:http:\/\/lastleg-service:9303/) + assert.match(overseas, /LASTLEG_SECURITY_EVENT_CONSUMER_URL:http:\/\/lastleg-service:9303\/internal\/lastleg\/security\/v1\/events/) + assert.doesNotMatch(`${gatewayOverseas}\n${overseas}`, /overseas-security-service|9307|OVERSEAS_SECURITY_/) assert.match(scripts, /FIRSTLEG_EVENT_BRIDGE_URL/) assert.match(scripts, /LASTLEG_EVENT_BRIDGE_URL/) assert.match(scripts, /docker-compose\.overseas\.yml/) diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index 6bfe64c05..03fdfdd11 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -44,10 +44,47 @@ test('entry accepts a separate overseas Gateway without exposing token state', ( assert.equal(options.gateway, 'http://localhost:9000') assert.equal(options.overseasGateway, 'http://localhost:9300') assert.match(source, /this\.overseasToken = login\.overseasAccessToken \|\| ''/) + assert.match(source, /this\.refreshToken = login\.refreshToken \|\| ''/) + assert.match(source, /this\.overseasExpiresAt = Number\(login\.overseasExpiresIn\)/) + assert.match(source, /async refreshSession\(reason\)/) + assert.match(source, /refreshToken: this\.refreshToken/) + assert.match(source, /const replacement = \{[\s\S]*token: next\.accessToken[\s\S]*refreshToken: next\.refreshToken[\s\S]*overseasToken: next\.overseasAccessToken/) + assert.match(source, /module === 'system' && laterOverseasModule/) + assert.match(source, /system后新admin海外Token只读探针可用/) + assert.match(source, /module\.startsWith\('phase4-overseas-'\)[\s\S]*Date\.now\(\) \+ 120_000 >= this\.overseasExpiresAt/) + assert.match(source, /海外模块 \$\{module\} 执行前Token临近到期/) assert.match(source, /async requestOverseas\(/) assert.match(source, /async requestOverseasBinary\(/) assert.match(source, /async requestOverseasMultipart\(/) - assert.match(source, /forbiddenValues = \[this\.options\.password, this\.token, this\.overseasToken\]/) + assert.match(source, /forbiddenValues = \[this\.options\.password, this\.token, this\.refreshToken, this\.overseasToken\]/) +}) + +test('dual-token refresh replaces the whole session only after a complete response', async () => { + const run = new AcceptanceRun({ gateway: 'http://localhost:9000', overseasGateway: 'http://localhost:9300', + username: 'contract-user', password: 'contract-password', modules: [], output: '' }) + Object.assign(run, { token: 'old-access', refreshToken: 'old-refresh', overseasToken: 'old-overseas', overseasExpiresAt: 1 }) + run.request = async (method, path, body, token) => { + assert.equal(method, 'POST'); assert.equal(path, '/api/auth/refresh') + assert.deepEqual(body, { refreshToken: 'old-refresh' }); assert.equal(token, '') + return { method, path, http: 200, body: { code: '200', data: { + accessToken: 'new-access', refreshToken: 'new-refresh', overseasAccessToken: 'new-overseas', + overseasExpiresIn: 900, + } }, requestId: 'refresh-request', traceId: 'refresh-trace' } + } + assert.equal(await run.refreshSession('contract'), true) + assert.equal(run.token, 'new-access') + assert.equal(run.refreshToken, 'new-refresh') + assert.equal(run.overseasToken, 'new-overseas') + assert.ok(run.overseasExpiresAt > Date.now()) + + const frozen = { token: run.token, refreshToken: run.refreshToken, + overseasToken: run.overseasToken, overseasExpiresAt: run.overseasExpiresAt } + run.request = async (method, path) => ({ method, path, http: 200, + body: { code: '200', data: { accessToken: 'partial-only' } }, + requestId: 'partial-request', traceId: 'partial-trace' }) + assert.equal(await run.refreshSession('incomplete-contract'), false) + assert.deepEqual({ token: run.token, refreshToken: run.refreshToken, + overseasToken: run.overseasToken, overseasExpiresAt: run.overseasExpiresAt }, frozen) }) test('entry has no built-in environment address or password and requires explicit execution options', () => { diff --git a/scripts/provision-development-service-jobs.groovy b/scripts/provision-development-service-jobs.groovy index baa57b467..e4c7ced35 100644 --- a/scripts/provision-development-service-jobs.groovy +++ b/scripts/provision-development-service-jobs.groovy @@ -6,7 +6,7 @@ boolean applyChanges = false String templateName = 'baiyue-firstleg-dev-print' List serviceModules = [ 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' ] Jenkins jenkins = Jenkins.get() diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index 8cea72284..6c69f4df7 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -121,9 +121,9 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java', 'event-bridge-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'Inbox 租约投递,目标按 eventId 幂等'), http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java', - 'event-bridge-service', 'overseas-security-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全事件投影按 eventId/版本单调幂等'), + 'event-bridge-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全事件投影按 eventId/版本单调幂等'), http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java', - 'gateway-overseas', 'overseas-security-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST 实时校验,失败关闭'), + 'gateway-overseas', 'lastleg-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST 实时校验,失败关闭'), http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', 'gateway-overseas', 'auth-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST PDA 会话校验,失败关闭'), http('lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index 030af7e92..485ac8d42 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -13,7 +13,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const serviceModules = [ 'auth', 'iam', 'order', 'firstleg', 'finance', 'file', 'warehouse', 'integration-service', 'print', 'event-bridge-service', 'lastleg-service', - 'warehouse-overseas-service', 'overseas-security-service' + 'warehouse-overseas-service' ] const canonicalSource = new Map(serviceModules.map(name => [name, `${name}${name.endsWith('-service') ? '' : '-service'}`])) @@ -45,7 +45,6 @@ const expectedOnlineEdges = [ const expectedBackgroundEdges = [ 'event-bridge-service->firstleg-service', 'event-bridge-service->lastleg-service', - 'event-bridge-service->overseas-security-service', 'file-service->finance-service', 'file-service->firstleg-service', 'file-service->iam-service', @@ -131,6 +130,7 @@ test('every reviewed synchronous client has a complete and exclusive executable } }) + test('all four Lastleg RestClient gateways use explicit bounded transports', async () => { const clients = new Map([ ['lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', 'getUnloadPhotoFile'], @@ -170,7 +170,7 @@ test('REQUEST, BACKGROUND and full graphs remain separate executable baselines', assert.deepEqual(graphEdges('INFRASTRUCTURE'), [ 'common-audit->audit-service', 'gateway-overseas->auth-service', - 'gateway-overseas->overseas-security-service' + 'gateway-overseas->lastleg-service' ]) assert.deepEqual(stronglyConnectedComponents(graphEdges('REQUEST')), [ ['auth-service', 'iam-service'], @@ -185,10 +185,10 @@ test('REQUEST, BACKGROUND and full graphs remain separate executable baselines', ]]) }) -test('overseas security edges never become online business dependencies', () => { - assert.ok(!graphEdges('REQUEST').some(edge => edge.includes('overseas-security-service'))) - assert.ok(graphEdges('INFRASTRUCTURE').includes('gateway-overseas->overseas-security-service')) - assert.ok(graphEdges('BACKGROUND').includes('event-bridge-service->overseas-security-service')) +test('overseas security projection stays in Lastleg infrastructure and background edges', () => { + assert.ok(!graphEdges('REQUEST').some(edge => edge === 'gateway-overseas->lastleg-service')) + assert.ok(graphEdges('INFRASTRUCTURE').includes('gateway-overseas->lastleg-service')) + assert.ok(graphEdges('BACKGROUND').includes('event-bridge-service->lastleg-service')) }) test('known user entries expose every longest simple REQUEST path over the two-hop budget', () => { @@ -242,3 +242,4 @@ test('shared Feign timeout defaults and long-running export overrides do not dri const fileConfig = await readFile(path.join(root, 'file', 'src', 'main', 'resources', 'application.yml'), 'utf8') assert.equal((fileConfig.match(/read-timeout:\s*15000/g) ?? []).length, 5) }) + diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index 4d324af00..2215862cb 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -57,6 +57,8 @@ test('Java builds use the shared builder pool and serialize incremental target d assert.doesNotMatch(directDeployScript, /docker save "\$IMAGE"/) assert.match(directDeployScript, /up -d --no-deps --pull never/) assert.doesNotMatch(directDeployScript, /--remove-orphans/) + assert.match(directDeployScript, /--project-name "\$PROJECT_NAME"/) + assert.doesNotMatch(directDeployScript, /overseas-security-service|remove_legacy_overseas_security_container/) assert.match(directDeployScript, /--pull never/) })