This commit is contained in:
2026-08-25 16:40:48 +08:00
parent 1f6f1a0677
commit 87c5e18ca6
93 changed files with 1154 additions and 527 deletions
-1
View File
@@ -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
+2 -2
View File
@@ -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 @@
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、海外 Gateway 覆盖、海外安全服务部署映射、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。
- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、海外 Gateway 覆盖、Lastleg 海外安全投影映射、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
## 全量 HTTP 验收脚本路由
Vendored
+1 -3
View File
@@ -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)')
+1 -2
View File
@@ -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'
]
+1 -1
View File
@@ -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`:统一管理仓库内公共依赖版本。
+3
View File
@@ -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}
@@ -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}
+2 -2
View File
@@ -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}
@@ -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}
+3 -16
View File
@@ -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
+1
View File
@@ -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
+2 -11
View File
@@ -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
+2 -13
View File
@@ -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
-5
View File
@@ -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
+2 -1
View File
@@ -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;正式上线后的迁移策略在生产发布规范中另行冻结。
+32 -18
View File
@@ -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;
@@ -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 命名空间和部署环境。
+1 -1
View File
@@ -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` |
@@ -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());
}
@@ -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);
}
}
}
@@ -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}
@@ -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();
@@ -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");
}
}
@@ -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 字节");
}
@@ -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<Result> validate(long userId, long tenantId, long tokenVersion, long permissionVersion) {
public Mono<Result> 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) { }
}
@@ -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);
@@ -3,6 +3,6 @@ package com.baiyue.gateway.filter;
import reactor.core.publisher.Mono;
public interface OverseasSecurityValidationClient {
Mono<Result> validate(long userId, long tenantId, long tokenVersion, long permissionVersion);
Mono<Result> validate(long userId, long tenantId, long permissionVersion);
record Result(boolean valid, String failureReason) { }
}
@@ -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/**
@@ -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);
}
}
@@ -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 -> {
+2 -1
View File
@@ -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`。
## 全量验收导航
+3 -1
View File
@@ -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`:
@@ -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<Acknowledgement> receive(@RequestHeader("X-Internal-Service-Token") String token,
@@ -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 {
@@ -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();
@@ -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());
@@ -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);
@@ -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())) {
@@ -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) {
@@ -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<Long> 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<Void> response = authClient.compensate(internalToken, authUserId);
@@ -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");
+3
View File
@@ -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}"
@@ -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}");
}
}
@@ -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<AuthAccountClient.CreateAccountRequest> 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);
@@ -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());
}
}
@@ -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
@@ -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<IamUser> 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<AuthAccountClient.CreateAccountRequest> 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();
@@ -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<EventEnvelope> 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)));
}
}
@@ -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<Object[]> 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);
}
}
@@ -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
@@ -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;
}
}
+6
View File
@@ -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`。
+1
View File
@@ -19,6 +19,7 @@
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>com.tngtech.archunit</groupId><artifactId>archunit</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -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)(?:\.|$)/],
@@ -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, []);
});
@@ -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;
@@ -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
@@ -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(),
@@ -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<String> 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<ProjectionRow> rows = jdbc.query("SELECT aggregate_version,payload_hash FROM overseas_security_projection "
List<ProjectionRow> 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) { }
@@ -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<Long> 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<String> 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);
}
}
@@ -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;
@@ -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}
@@ -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');
@@ -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());
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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)));
}
}
@@ -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);
}
}
@@ -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 冲突");
}
}
@@ -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"]
-14
View File
@@ -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`
-22
View File
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent><groupId>com.baiyue.firstleg</groupId><artifactId>baiyue-firstleg-platform</artifactId>
<version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath></parent>
<artifactId>overseas-security-service</artifactId>
<dependencies>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-core</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>common-event-contract</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -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);
}
}
@@ -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, "内部服务令牌无效");
}
}
@@ -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<String> 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);
}
}
@@ -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
@@ -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安全状态与权限版本投影';
@@ -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
@@ -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);
}
}
@@ -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();
}
}
@@ -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 冲突");
}
}
-1
View File
@@ -39,7 +39,6 @@
<module>common-event-contract</module>
<module>gateway</module>
<module>gateway-overseas</module>
<module>overseas-security-service</module>
<module>auth</module>
<module>iam</module>
<module>order</module>
+48 -1
View File
@@ -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,
@@ -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/)
+16 -12
View File
@@ -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
+3 -3
View File
@@ -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')
+4 -4
View File
@@ -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) {
+19 -8
View File
@@ -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/)
@@ -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', () => {
@@ -6,7 +6,7 @@ boolean applyChanges = false
String templateName = 'baiyue-firstleg-dev-print'
List<String> 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()
+2 -2
View File
@@ -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',
@@ -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)
})
@@ -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/)
})