Merge branch 'dev' into dev-lyhfive

# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java
#	firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java
This commit is contained in:
2026-08-25 15:06:11 +08:00
568 changed files with 26662 additions and 2081 deletions
+1
View File
@@ -1,4 +1,5 @@
target/ target/
node_modules/
.docker-context/ .docker-context/
*.log *.log
.idea/ .idea/
+37 -1
View File
@@ -13,6 +13,34 @@ cache:
paths: paths:
- .m2/repository/ - .m2/repository/
Java拓扑合同:
stage: verify
image: node:22-alpine
cache: []
script:
- node --test scripts/java-topology-contract.test.mjs
OpenAPI有效性:
stage: verify
image: node:22-alpine
cache: []
script:
- npm ci --ignore-scripts --prefix scripts/openapi-validation
- npm test --prefix scripts/openapi-validation
- npm run validate --prefix scripts/openapi-validation
静态分析报告:
stage: verify
allow_failure: true
script:
- mvn $MAVEN_CLI_OPTS -Pstatic-analysis-report -DskipTests verify
artifacts:
when: always
expire_in: 7 days
paths:
- "**/target/spotbugs*.xml"
- "**/target/site/spotbugs.html"
校验与测试: 校验与测试:
stage: verify stage: verify
script: script:
@@ -25,10 +53,14 @@ cache:
- "**/target/surefire-reports/TEST-*.xml" - "**/target/surefire-reports/TEST-*.xml"
paths: paths:
- "**/target/surefire-reports/" - "**/target/surefire-reports/"
- "**/target/site/jacoco/"
- "**/target/jacoco.exec"
打包服务: 打包服务:
stage: package stage: package
needs: needs:
- Java拓扑合同
- OpenAPI有效性
- 校验与测试 - 校验与测试
script: script:
- mvn $MAVEN_CLI_OPTS -DskipTests package - mvn $MAVEN_CLI_OPTS -DskipTests package
@@ -36,14 +68,18 @@ cache:
expire_in: 7 days expire_in: 7 days
paths: paths:
- gateway/target/*.jar - gateway/target/*.jar
- gateway-overseas/target/*.jar
- auth/target/*.jar - auth/target/*.jar
- iam/target/*.jar - iam/target/*.jar
- order/target/*.jar - order/target/*.jar
- firstleg/target/*.jar - firstleg/target/*.jar
- finance/target/*.jar
- warehouse/target/*.jar - warehouse/target/*.jar
- warehouse-overseas-service/target/*.jar
- overseas-security-service/target/*.jar
- lastleg-service/target/*.jar
- file/target/*.jar - file/target/*.jar
- print/target/*.jar - print/target/*.jar
- audit-service/target/*.jar - audit-service/target/*.jar
- integration-service/target/*.jar - integration-service/target/*.jar
- event-bridge-service/target/*.jar - event-bridge-service/target/*.jar
+4 -2
View File
@@ -36,11 +36,13 @@
| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` | | 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` |
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | | 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | | 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
| 海外 IAM 状态与权限版本投影 | `overseas-security-service/` | `overseas-security-service/AGENTS.md`;消费三类 IAM 安全事件并为海外 Gateway 提供内部 fail-closed 校验 |
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | | 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
| 海外入仓分页、FBA 预约、PDA 卸柜照片 | `lastleg-service/` | `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`、`inboundplan/pda/` | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 |
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | | 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` |
| 海外仓 PDA 到仓、卸柜、实收、上架、合托 | `lastleg-service/`、`warehouse-overseas-service/` | 业务流程 `lastleg-service/inboundplan/pda/`;库位、托盘和资源锁归 `warehouse-overseas-service/`;设备端 `../apps/pda-overseas-uniapp/`,公网入口 `gateway-overseas/` | | 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理库位/托盘调用 `warehouse-overseas-service/`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 |
| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 |
| 海外仓库、海外库位、海外托盘、资源锁 | `warehouse-overseas-service/` | `warehouse-overseas-service/AGENTS.md` | | 海外仓库、海外库位、海外托盘、资源锁 | `warehouse-overseas-service/` | `warehouse-overseas-service/AGENTS.md` |
| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` | | PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` |
Vendored
+56 -13
View File
@@ -1,6 +1,6 @@
def serviceModules = [ def serviceModules = [
'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service'
] ]
def serviceCheckboxes = [ def serviceCheckboxes = [
@@ -17,6 +17,7 @@ def serviceCheckboxes = [
'integration-service': 'SERVICE_INTEGRATION', 'integration-service': 'SERVICE_INTEGRATION',
'lastleg-service': 'SERVICE_LASTLEG', 'lastleg-service': 'SERVICE_LASTLEG',
'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS', 'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS',
'overseas-security-service': 'SERVICE_OVERSEAS_SECURITY',
'event-bridge-service': 'SERVICE_EVENT_BRIDGE', 'event-bridge-service': 'SERVICE_EVENT_BRIDGE',
'audit-service': 'SERVICE_AUDIT' 'audit-service': 'SERVICE_AUDIT'
] ]
@@ -68,12 +69,12 @@ pipeline {
booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)') booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)')
booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)') booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)')
booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)') booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)')
booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;由 EVENT_BRIDGE_REGION 选择实例)') booleanParam(name: 'SERVICE_OVERSEAS_SECURITY', defaultValue: false, description: '海外IAM安全投影服务(overseas-security-service)')
choice(name: 'EVENT_BRIDGE_REGION', choices: ['CN', 'OVERSEAS'], description: '仅部署 event-bridge-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)') booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)')
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境') choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署') booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
booleanParam(name: 'INITIALIZE_TEST_DATABASE', defaultValue: false, description: '仅 test:调度服务前幂等初始化测试数据库')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选')
string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库地址') string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库地址')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像') string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像')
@@ -109,6 +110,9 @@ pipeline {
} }
env.RESOLVED_JOB_PREFIX = jobPrefix env.RESOLVED_JOB_PREFIX = jobPrefix
env.DATABASE_CHANGE_DETECTED = 'false' env.DATABASE_CHANGE_DETECTED = 'false'
if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS) {
error('生产环境禁止跳过测试')
}
List selected = [] List selected = []
if (params.BUILD_MODE == 'ALL') { if (params.BUILD_MODE == 'ALL') {
@@ -147,6 +151,19 @@ pipeline {
} }
selected = selected.unique() selected = selected.unique()
if (params.DEPLOY && selected.contains('event-bridge-service')) {
String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim()
if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) {
error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS')
}
if (['BOTH', 'SELECT'].contains(bridgeRegion) &&
!['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) {
error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS')
}
if (bridgeRegion == 'SELECT') {
echo '兼容旧 Jenkins 参数 SELECT:本次按 BOTH 同时部署国内和海外事件桥'
}
}
env.SELECTED_SERVICES = selected.join(',') env.SELECTED_SERVICES = selected.join(',')
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true' env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
if (!selected) { if (!selected) {
@@ -178,15 +195,30 @@ pipeline {
} }
} }
stage('测试数据库初始化(可选)') { stage('统一 Maven 测试门禁') {
when { when {
allOf { expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS }
expression { env.RESOLVED_ENVIRONMENT == 'test' && params.INITIALIZE_TEST_DATABASE } }
agent { label 'java-builder' }
tools { maven 'maven-3.8.8' }
steps {
script {
String selectedModules = env.SELECTED_SERVICES.split(',')
.collect { it.trim() }
.findAll { it }
.join(',')
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
sh """
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
"""
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
} }
} }
agent { label 'java-test' } post {
steps { always {
sh 'sh deploy/scripts/initialize-test-databases.sh' junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml'
}
} }
} }
@@ -199,6 +231,14 @@ pipeline {
String service = selectedService String service = selectedService
branches[service] = { branches[service] = {
String jobName = "${env.RESOLVED_JOB_PREFIX}${service}" String jobName = "${env.RESOLVED_JOB_PREFIX}${service}"
boolean deployBothBridges = service == 'event-bridge-service' &&
['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION)
String downstreamImageTag = deployBothBridges
? "${env.IMAGE_TAG}-both"
: env.IMAGE_TAG
String downstreamBridgeRegion = deployBothBridges
? 'CN'
: (service == 'event-bridge-service' ? params.EVENT_BRIDGE_REGION : 'CN')
echo "触发单服务 Job: ${jobName}" echo "触发单服务 Job: ${jobName}"
def downstreamBuild = build( def downstreamBuild = build(
job: jobName, job: jobName,
@@ -206,13 +246,16 @@ pipeline {
propagate: false, propagate: false,
parameters: [ parameters: [
string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT), string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT),
string(name: 'IMAGE_TAG', value: env.IMAGE_TAG), string(name: 'IMAGE_TAG', value: downstreamImageTag),
string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT), string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT),
booleanParam(name: 'DEPLOY', value: params.DEPLOY), booleanParam(name: 'DEPLOY', value: params.DEPLOY),
booleanParam(name: 'SKIP_TESTS', value: params.SKIP_TESTS), // 总控已完成统一测试,或用户已明确选择跳过;下游仅负责编译、打包和部署。
booleanParam(name: 'SKIP_TESTS', value: true),
string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY), string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY),
string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE), string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE),
string(name: 'EVENT_BRIDGE_REGION', value: params.EVENT_BRIDGE_REGION) // Choice 参数始终传递所有新旧 Job 都接受的单区值;
// 双区意图通过现有 IMAGE_TAG 参数携带,由下游还原真实镜像标签。
string(name: 'EVENT_BRIDGE_REGION', value: downstreamBridgeRegion)
] ]
) )
downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN' downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN'
+54 -18
View File
@@ -1,6 +1,6 @@
def serviceModules = [ def serviceModules = [
'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service'
] ]
def serviceJars = [ def serviceJars = [
@@ -17,6 +17,7 @@ def serviceJars = [
'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar', 'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar',
'lastleg-service': 'lastleg-service/target/lastleg-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', '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', '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' 'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar'
] ]
@@ -38,7 +39,7 @@ pipeline {
choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境') choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务') booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
choice(name: 'EVENT_BRIDGE_REGION', choices: ['CN', 'OVERSEAS'], description: '仅 event-bridge-service 使用;通常由总控 Job 传入') choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS', 'SELECT'], description: '仅 event-bridge-service 使用;BOTH 在开发/测试环境同时部署国内和海外实例;SELECT 为总控兼容值')
string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库基础路径,不包含服务名和标签') string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库基础路径,不包含服务名和标签')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像') string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像')
} }
@@ -76,6 +77,11 @@ pipeline {
if (!serviceModules.contains(service)) { if (!serviceModules.contains(service)) {
error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}") error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}")
} }
boolean deployBothBridges = service == 'event-bridge-service' &&
imageTag?.endsWith('-both')
if (deployBothBridges) {
imageTag = imageTag.substring(0, imageTag.length() - '-both'.length())
}
if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) { if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) {
error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit') error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit')
} }
@@ -93,6 +99,18 @@ pipeline {
env.RESOLVED_SOURCE_COMMIT = sourceCommit env.RESOLVED_SOURCE_COMMIT = sourceCommit
env.RESOLVED_IMAGE_TAG = imageTag env.RESOLVED_IMAGE_TAG = imageTag
env.RESOLVED_IMAGE_REPOSITORY = imageRepository env.RESOLVED_IMAGE_REPOSITORY = imageRepository
env.DEPLOY_BOTH_EVENT_BRIDGES = deployBothBridges ? 'true' : 'false'
if (service == 'event-bridge-service' && params.DEPLOY) {
String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim()
if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) {
error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS')
}
if ((deployBothBridges || ['BOTH', 'SELECT'].contains(bridgeRegion)) &&
!['development', 'test'].contains(params.ENVIRONMENT)) {
error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS')
}
}
} }
sh ''' sh '''
@@ -114,12 +132,15 @@ pipeline {
stage('Maven 测试与打包') { stage('Maven 测试与打包') {
steps { steps {
script { script {
if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS) { boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean
error('生产环境禁止跳过测试') if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) {
error('生产环境手工构建禁止跳过测试;总控流水线可在统一测试通过后执行快速打包')
} }
boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS
String testOption = skipTestExecution ? '-DskipTests' : '' String testOption = skipTestExecution ? '-DskipTests' : ''
if (params.ENVIRONMENT == 'test') { if (params.SKIP_TESTS && coordinatorPinnedCommit) {
echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包"
} else if (params.ENVIRONMENT == 'test') {
echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行' echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行'
} }
sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}" sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}"
@@ -174,28 +195,43 @@ pipeline {
steps { steps {
script { script {
String deploySlotLabel = "firstleg-${params.ENVIRONMENT}-deploy-slot" String deploySlotLabel = "firstleg-${params.ENVIRONMENT}-deploy-slot"
String serviceDeployResource = "firstleg-${params.ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy"
String credentialId = "firstleg-${params.ENVIRONMENT}-env" String credentialId = "firstleg-${params.ENVIRONMENT}-env"
String targetLabel = params.ENVIRONMENT == 'development' ? 'java-dev' : 'java-test' String targetLabel = params.ENVIRONMENT == 'development' ? 'java-dev' : 'java-test'
lock(label: deploySlotLabel, quantity: 1, inversePrecedence: true) { // 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。
node(targetLabel) { // resource 未预配置时 Lockable Resources 会创建仅在构建期间存在的临时资源。
deleteDir() lock(resource: serviceDeployResource, inversePrecedence: true) {
try { lock(label: deploySlotLabel, quantity: 1, inversePrecedence: true) {
unstash 'deployment-files' node(targetLabel) {
echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.ENVIRONMENT}" deleteDir()
withEnv([ try {
'TARGET_IS_LOCAL=true', unstash 'deployment-files'
"EVENT_BRIDGE_REGION_OVERRIDE=${params.EVENT_BRIDGE_REGION}" echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.ENVIRONMENT}"
]) { List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' &&
(env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' ||
['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION)))
? ['CN', 'OVERSEAS']
: [params.EVENT_BRIDGE_REGION]
withCredentials([ withCredentials([
file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'), file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'),
usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS,
usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD') usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')
]) { ]) {
sh "sh deploy/scripts/deploy-built-service.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" bridgeRegions.each { bridgeRegion ->
if (env.RESOLVED_SERVICE == 'event-bridge-service') {
echo "部署事件桥实例: ${bridgeRegion}"
}
withEnv([
'TARGET_IS_LOCAL=true',
"EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}"
]) {
sh "sh deploy/scripts/deploy-built-service.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
}
}
} }
} finally {
cleanWs(deleteDirs: true, notFailBuild: true)
} }
} finally {
cleanWs(deleteDirs: true, notFailBuild: true)
} }
} }
} }
+9 -5
View File
@@ -1,6 +1,6 @@
# 柏悦头程平台 # 柏悦头程平台
本仓库承载国内头程业务,负责统一登录、客户与企业成员、角色、菜单、按钮权限、订单、国内仓储、头程履约以及国内公共能力。尾程业务不进入本仓库,通过版本化跨区域事件与尾程仓库协作。 本仓库承载国内头程与海外仓后端,包含统一认证、国内订单与履约、国内外仓储资源、海外尾程与入仓业务,以及跨区域事件桥和公共能力。国内、海外仍保持独立数据库、Nacos 命名空间与部署边界,通过版本化跨区域事件协作。
> 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。 > 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。
@@ -12,20 +12,24 @@
- `firstleg`:头程揽收、入仓、装载、发运和交接。 - `firstleg`:头程揽收、入仓、装载、发运和交接。
- `warehouse`:国内仓储能力。 - `warehouse`:国内仓储能力。
- `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。 - `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。
- `event-bridge-service`:跨区域事件接收幂等和投递记录基础骨架;真实跨区发送、重试调度和死信补投仍需完成。 - `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`、`overseas-security-service`:海外公网入口、海外尾程与入仓业务、海外仓储资源及 IAM 安全投影。
- `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。
- `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。 - `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。
- `platform-bom`:发布 `com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT`,尾程仓只通过固定版本引用。 - `platform-bom`:统一管理仓库内公共依赖版本。
## 本地构建 ## 本地构建
```powershell ```powershell
mvn -DskipTests validate mvn -DskipTests validate
node --test scripts/java-topology-contract.test.mjs
mvn test mvn test
``` ```
GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。
2026-07-30 一期前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。 2026-07-30 一期前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。
本地一期完整联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。 国内本地联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。海外服务通过独立 Compose 与 Nacos 配置启动,不与国内服务共享数据库或配置命名空间。
国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。 国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。
@@ -41,4 +45,4 @@ CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com
## 仓库边界 ## 仓库边界
仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。本仓不得引入尾程源码,不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。 仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。国内与海外服务不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。
+1
View File
@@ -16,6 +16,7 @@
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 | | 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 | | 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 |
| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只保留平台海外仓/海外业务/海外主数据及 `lastleg:*` 权限前缀;前端统一认证层负责按区域 API 自动选择 | | 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只保留平台海外仓/海外业务/海外主数据及 `lastleg:*` 权限前缀;前端统一认证层负责按区域 API 自动选择 |
| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 |
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` | | Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
| 注册协作 | `service/MerchantRegistrationService.java` | | 注册协作 | `service/MerchantRegistrationService.java` |
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` | | IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
@@ -21,4 +21,12 @@ public class AuthorizationSnapshot {
private Set<String> roles; private Set<String> roles;
private Set<String> permissions; private Set<String> permissions;
private Long permissionVersion; private Long permissionVersion;
private Long overseasWarehouseId;
public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode,
String clientType, Set<String> roles, Set<String> permissions,
Long permissionVersion) {
this(userId, tenantId, membershipId, appCode, clientType, roles, permissions,
permissionVersion, null);
}
} }
@@ -3,12 +3,16 @@ package com.baiyue.auth.controller;
import com.baiyue.auth.dto.AccountManagementDtos.*; import com.baiyue.auth.dto.AccountManagementDtos.*;
import com.baiyue.auth.service.AccountManagementService; import com.baiyue.auth.service.AccountManagementService;
import com.baiyue.auth.service.AuthService; import com.baiyue.auth.service.AuthService;
import com.baiyue.auth.service.OverseasSessionValidationService;
import com.baiyue.auth.dto.OverseasSessionValidationDtos;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -18,11 +22,14 @@ import java.security.MessageDigest;
/** 仅供可信内部服务调用的账号管理接口。 */ /** 仅供可信内部服务调用的账号管理接口。 */
@RestController @RestController
@RequestMapping("/internal/auth/accounts") @RequestMapping("/internal/auth/accounts")
@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and overseas PDA session APIs")
public class InternalAccountController { public class InternalAccountController {
@Resource @Resource
private AccountManagementService service; private AccountManagementService service;
@Resource @Resource
private AuthService authService; private AuthService authService;
@Resource
private OverseasSessionValidationService overseasSessionValidationService;
@Value("${baiyue.security.internal-service-token}") @Value("${baiyue.security.internal-service-token}")
private String configuredToken; private String configuredToken;
@@ -64,6 +71,15 @@ public class InternalAccountController {
@Valid @RequestBody RevokeAccountSessionsRequest request) { @Valid @RequestBody RevokeAccountSessionsRequest request) {
verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null); verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null);
} }
@PostMapping("/overseas-session/validate")
@Operation(summary = "Validate verified overseas PDA claims / 校验已验签的海外PDA声明",
description = "Accepts only claims extracted after Gateway JWT verification; raw tokens and user credentials are forbidden.")
public ApiResponse<OverseasSessionValidationDtos.Response> validateOverseasSession(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@Valid @RequestBody OverseasSessionValidationDtos.Request request) {
verify(token);
return ApiResponse.success(overseasSessionValidationService.validate(request));
}
@PutMapping("/{id}/profile") @PutMapping("/{id}/profile")
public ApiResponse<Void> profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, public ApiResponse<Void> profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) { @PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
@@ -0,0 +1,29 @@
package com.baiyue.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
/** 海外 PDA 已验签身份的实时会话校验契约;绝不接收 raw JWT 或用户凭据。 */
public final class OverseasSessionValidationDtos {
private OverseasSessionValidationDtos() { }
@Schema(name = "OverseasSessionValidationRequest",
description = "Claims extracted only after overseas Gateway verifies the JWT signature")
public record Request(
@NotNull Long userId,
@NotNull Integer tokenVersion,
Long overseasWarehouseId) { }
@Schema(name = "OverseasSessionValidationResponse")
public record Response(boolean valid, FailureReason failureReason) {
public static Response accepted() { return new Response(true, null); }
public static Response rejected(FailureReason reason) { return new Response(false, reason); }
}
public enum FailureReason {
ACCOUNT_INVALID,
TOKEN_VERSION_MISMATCH,
WAREHOUSE_UNBOUND,
WAREHOUSE_MISMATCH
}
}
@@ -111,7 +111,7 @@ public class AuthService {
Instant issuedAt = Instant.now(); Instant issuedAt = Instant.now();
String jwtId = UUID.randomUUID().toString(); String jwtId = UUID.randomUUID().toString();
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
JwtClaimsSet claims = JwtClaimsSet.builder() JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder()
.issuer(properties.issuer()).audience(List.of(properties.audience())) .issuer(properties.issuer()).audience(List.of(properties.audience()))
.id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt) .id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
.subject(account.getId().toString()) .subject(account.getId().toString())
@@ -127,8 +127,11 @@ public class AuthService {
.claim("userId", account.getId()) .claim("userId", account.getId())
.claim("tenantId", account.getDefaultTenantId()) .claim("tenantId", account.getDefaultTenantId())
.claim("appCode", account.getDefaultAppCode()) .claim("appCode", account.getDefaultAppCode())
.claim("permissionVersion", account.getTokenVersion()) .claim("permissionVersion", account.getTokenVersion());
.build(); if (authorization.getOverseasWarehouseId() != null) {
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
}
JwtClaimsSet claims = claimsBuilder.build();
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from( String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
signingHeader(), claims)).getTokenValue(); signingHeader(), claims)).getTokenValue();
@@ -0,0 +1,38 @@
package com.baiyue.auth.service;
import com.baiyue.auth.client.AuthorizationLoader;
import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason;
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request;
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Response;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.Objects;
/** 实时核对海外 PDA token 的账号版本与当前 IAM 仓绑定。 */
@Service
public class OverseasSessionValidationService {
@Resource private AuthAccountRepository accounts;
@Resource private AuthorizationLoader authorizationLoader;
public Response validate(Request request) {
AuthAccount account = accounts.findById(request.userId());
if (account == null || !"ACTIVE".equals(account.getAccountStatus())) {
return Response.rejected(FailureReason.ACCOUNT_INVALID);
}
int currentVersion = account.getTokenVersion() == null ? 0 : account.getTokenVersion();
if (currentVersion != request.tokenVersion()) {
return Response.rejected(FailureReason.TOKEN_VERSION_MISMATCH);
}
Long currentWarehouseId = authorizationLoader.load(account).getOverseasWarehouseId();
if (currentWarehouseId == null || request.overseasWarehouseId() == null) {
return Response.rejected(FailureReason.WAREHOUSE_UNBOUND);
}
if (!Objects.equals(currentWarehouseId, request.overseasWarehouseId())) {
return Response.rejected(FailureReason.WAREHOUSE_MISMATCH);
}
return Response.accepted();
}
}
@@ -51,7 +51,7 @@ public class OverseasTokenService {
public OverseasTokenResponse issue(AuthAccount account, AuthorizationSnapshot authorization) { public OverseasTokenResponse issue(AuthAccount account, AuthorizationSnapshot authorization) {
Instant issuedAt = Instant.now(); Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
JwtClaimsSet claims = JwtClaimsSet.builder() JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder()
.issuer(properties.issuer()).audience(List.of(properties.audience())) .issuer(properties.issuer()).audience(List.of(properties.audience()))
.subject(account.getId().toString()).id(UUID.randomUUID().toString()) .subject(account.getId().toString()).id(UUID.randomUUID().toString())
.issuedAt(issuedAt).expiresAt(expiresAt) .issuedAt(issuedAt).expiresAt(expiresAt)
@@ -60,8 +60,12 @@ public class OverseasTokenService {
.claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType()) .claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType())
.claim("roles", authorization.getRoles()) .claim("roles", authorization.getRoles())
.claim("authorities", overseasAuthorities(authorization.getPermissions())) .claim("authorities", overseasAuthorities(authorization.getPermissions()))
.claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()) .claim("token_version", account.getTokenVersion() == null ? 0 : account.getTokenVersion())
.build(); .claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion());
if (authorization.getOverseasWarehouseId() != null) {
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
}
JwtClaimsSet claims = claimsBuilder.build();
String token = encoder.encode(JwtEncoderParameters.from( String token = encoder.encode(JwtEncoderParameters.from(
JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue(); JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue();
return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds()); return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds());
@@ -0,0 +1,28 @@
package com.baiyue.auth.controller;
import com.baiyue.auth.dto.OverseasSessionValidationDtos;
import io.swagger.v3.oas.annotations.Operation;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.PostMapping;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class OverseasSessionValidationControllerContractTest {
@Test
void internalEndpointAcceptsOnlyVerifiedClaimValuesAndIsDocumented() {
var method = Arrays.stream(InternalAccountController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals("validateOverseasSession"))
.findFirst().orElseThrow();
assertThat(method.getAnnotation(PostMapping.class).value())
.containsExactly("/overseas-session/validate");
assertThat(method.getAnnotation(Operation.class).description())
.contains("Gateway JWT verification", "raw tokens and user credentials are forbidden");
assertThat(OverseasSessionValidationDtos.Request.class.getRecordComponents())
.extracting(java.lang.reflect.RecordComponent::getName)
.containsExactly("userId", "tokenVersion", "overseasWarehouseId")
.doesNotContain("token", "accessToken", "authorization", "password");
}
}
@@ -23,7 +23,10 @@ import java.time.Duration;
import java.util.Set; import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -60,7 +63,7 @@ class AuthServiceSmallJwtTest {
account.setDefaultAppCode("PLATFORM"); account.setDefaultAppCode("PLATFORM");
account.setTokenVersion(1); account.setTokenVersion(1);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
"WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list"), 1L); "WEB", Set.of("WAREHOUSE_PICKER"), Set.of("platform:order:list"), 1L, 900L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class); Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(repository.findByUsername("operator")).thenReturn(account); when(repository.findByUsername("operator")).thenReturn(account);
@@ -82,12 +85,13 @@ class AuthServiceSmallJwtTest {
.containsEntry("appCode", "PLATFORM") .containsEntry("appCode", "PLATFORM")
.containsEntry("permissionVersion", 1) .containsEntry("permissionVersion", 1)
.containsEntry("membership_id", 84L) .containsEntry("membership_id", 84L)
.containsEntry("overseas_warehouse_id", 900L)
.doesNotContainKeys("roles", "authorities", "permissions", "scope"); .doesNotContainKeys("roles", "authorities", "permissions", "scope");
assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api"); assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api");
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256); assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull(); assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L), verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
org.mockito.ArgumentMatchers.eq(Set.of("PLATFORM_ADMIN")), org.mockito.ArgumentMatchers.eq(Set.of("WAREHOUSE_PICKER")),
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(), org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
@@ -123,12 +127,36 @@ class AuthServiceSmallJwtTest {
var response = service.refresh("refresh-old"); var response = service.refresh("refresh-old");
assertThat(response.accessToken()).isEqualTo("domestic-refreshed"); assertThat(response.accessToken()).isEqualTo("domestic-refreshed");
assertThat(response.tokenType()).isEqualTo("Bearer");
assertThat(response.expiresIn()).isEqualTo(1800);
assertThat(response.refreshToken()).isNotBlank().isNotEqualTo("refresh-old");
assertThat(response.refreshExpiresIn()).isEqualTo(604800);
assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed"); assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed");
assertThat(response.overseasExpiresIn()).isEqualTo(900); assertThat(response.overseasExpiresIn()).isEqualTo(900);
verify(tokenSessionStore).consume("refresh-old");
verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L),
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")),
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
org.mockito.ArgumentMatchers.eq(response.refreshToken()),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
verify(authorizationLoader).load(account); verify(authorizationLoader).load(account);
verify(overseasTokenService).issue(account, snapshot); verify(overseasTokenService).issue(account, snapshot);
} }
@Test
void consumedRefreshTokenCannotMintAnotherDomesticOrOverseasPair() {
when(tokenSessionStore.consume("refresh-consumed")).thenReturn(null);
assertThatThrownBy(() -> service.refresh("refresh-consumed"))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
.hasMessageContaining("刷新令牌无效或已过期");
verify(repository, never()).findById(any());
verify(jwtEncoder, never()).encode(any());
verify(overseasTokenService, never()).issue(any(), any());
}
@Test @Test
void impersonationTokenAlsoKeepsRolesAndPermissionsOutOfJwt() { void impersonationTokenAlsoKeepsRolesAndPermissionsOutOfJwt() {
AuthAccount account = new AuthAccount(); AuthAccount account = new AuthAccount();
@@ -0,0 +1,77 @@
package com.baiyue.auth.service;
import com.baiyue.auth.client.AuthorizationLoader;
import com.baiyue.auth.client.AuthorizationSnapshot;
import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason;
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class OverseasSessionValidationServiceTest {
private final AuthAccountRepository accounts = mock(AuthAccountRepository.class);
private final AuthorizationLoader authorizationLoader = mock(AuthorizationLoader.class);
private final OverseasSessionValidationService service = new OverseasSessionValidationService();
private AuthAccount account;
@BeforeEach
void setUp() throws Exception {
inject("accounts", accounts);
inject("authorizationLoader", authorizationLoader);
account = new AuthAccount();
account.setId(42L);
account.setAccountStatus("ACTIVE");
account.setTokenVersion(8);
when(accounts.findById(42L)).thenReturn(account);
}
@Test
void oldTokenIsRejectedAfterWarehouseChangeBumpsAccountVersion() {
assertThat(service.validate(new Request(42L, 7, 900L)))
.extracting("valid", "failureReason")
.containsExactly(false, FailureReason.TOKEN_VERSION_MISMATCH);
}
@Test
void newlyIssuedTokenWithCurrentWarehousePasses() {
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L));
assertThat(service.validate(new Request(42L, 8, 901L)).valid()).isTrue();
}
@Test
void missingOrChangedWarehouseBindingFailsClosed() {
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, null));
assertThat(service.validate(new Request(42L, 8, null)).failureReason())
.isEqualTo(FailureReason.WAREHOUSE_UNBOUND);
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L));
assertThat(service.validate(new Request(42L, 8, 900L)).failureReason())
.isEqualTo(FailureReason.WAREHOUSE_MISMATCH);
}
@Test
void disabledAccountFailsClosedBeforeIamLookup() {
account.setAccountStatus("DISABLED");
assertThat(service.validate(new Request(42L, 8, 901L)).failureReason())
.isEqualTo(FailureReason.ACCOUNT_INVALID);
}
private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) {
return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA",
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), version, warehouseId);
}
private void inject(String fieldName, Object value) throws Exception {
var field = OverseasSessionValidationService.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(service, value);
}
}
@@ -24,6 +24,7 @@ import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class}) @SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class})
@@ -51,7 +52,7 @@ class OverseasTokenServiceTest {
String lastlegAuthority = "lastleg:pda:operate"; String lastlegAuthority = "lastleg:pda:operate";
String domesticAuthority = "platform:firstleg:order:list"; String domesticAuthority = "platform:firstleg:order:list";
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB", AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB",
Set.of("PLATFORM_ADMIN"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L); Set.of("WAREHOUSE_PICKER"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L, 900L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class); Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(accounts.findById(42L)).thenReturn(account); when(accounts.findById(42L)).thenReturn(account);
when(authorizationLoader.load(account)).thenReturn(snapshot); when(authorizationLoader.load(account)).thenReturn(snapshot);
@@ -71,9 +72,61 @@ class OverseasTokenServiceTest {
.containsEntry("membership_id", 84L) .containsEntry("membership_id", 84L)
.containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB") .containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB")
.containsEntry("permission_version", 7L) .containsEntry("permission_version", 7L)
.containsEntry("roles", Set.of("PLATFORM_ADMIN")) .containsEntry("token_version", 0)
.containsEntry("overseas_warehouse_id", 900L)
.containsEntry("roles", Set.of("WAREHOUSE_PICKER"))
.containsEntry("authorities", Set.of(authority, lastlegAuthority)) .containsEntry("authorities", Set.of(authority, lastlegAuthority))
.doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion"); .doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion");
assertThat(response.expiresIn()).isEqualTo(900); assertThat(response.expiresIn()).isEqualTo(900);
} }
@Test
void refreshedAuthorizationSnapshotReplacesWarehouseBindingAndTokenIdentity() {
AuthAccount account = new AuthAccount();
account.setId(43L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(0L);
account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM");
account.setTokenVersion(8);
AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 8L, 900L);
AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 9L, 901L);
Jwt first = org.mockito.Mockito.mock(Jwt.class);
Jwt second = org.mockito.Mockito.mock(Jwt.class);
when(encoder.encode(any())).thenReturn(first, second);
when(first.getTokenValue()).thenReturn("overseas-old-binding");
when(second.getTokenValue()).thenReturn("overseas-new-binding");
var oldToken = service.issue(account, oldBinding);
var newToken = service.issue(account, newBinding);
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(encoder, times(2)).encode(captor.capture());
var oldClaims = captor.getAllValues().get(0).getClaims();
var newClaims = captor.getAllValues().get(1).getClaims();
assertThat(oldClaims.getId()).isNotEqualTo(newClaims.getId());
assertThat(oldClaims.getClaims()).containsEntry("overseas_warehouse_id", 900L)
.containsEntry("permission_version", 8L);
assertThat(newClaims.getClaims()).containsEntry("overseas_warehouse_id", 901L)
.containsEntry("permission_version", 9L);
assertThat(newClaims.getClaims()).containsEntry("token_version", 8);
assertThat(oldToken.accessToken()).isNotEqualTo(newToken.accessToken());
}
@Test
void unboundWarehouseIsOmittedSoPdaIdentityMustFailClosed() {
AuthAccount account = new AuthAccount();
account.setId(44L); account.setAccountStatus("ACTIVE");
AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA",
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 10L, null);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(encoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("overseas-unbound");
service.issue(account, unbound);
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(encoder).encode(captor.capture());
assertThat(captor.getValue().getClaims().getClaims())
.doesNotContainKey("overseas_warehouse_id");
}
} }
+4 -3
View File
@@ -14,12 +14,13 @@
| 事件 ID 与业务键 | `EventIdentifiers.java` | | 事件 ID 与业务键 | `EventIdentifiers.java` |
| Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` | | Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` |
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` | | 事件类型与区域 | `EventTypes.java`、`RegionCode.java` |
| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;冻结来源/根订单与入仓标识、客户、业务类型、逐明细目的仓/备注/托盘号/实装箱号及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` | | 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` |
| 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 | | 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 |
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` | | 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 | | IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
| 契约说明 | `package-info.java` | | 契约说明 | `package-info.java` |
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java` | | 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` |
| 真实生产/消费验收 | `../firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`、`../lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java` | | 真实生产/消费验收 | `../firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`、`../lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java` |
## 修改与验证 ## 修改与验证
@@ -10,6 +10,8 @@ public final class EventTypes {
public static final String ORDER_CANCELLED = "OrderCancelled"; public static final String ORDER_CANCELLED = "OrderCancelled";
public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted"; public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted";
public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted"; public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted";
public static final String FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED =
"FirstLegContainerShippingNodeRecorded";
public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded"; public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded";
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged"; public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged"; public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
@@ -0,0 +1,47 @@
package com.baiyue.common.event;
import java.time.Instant;
import java.util.Map;
/** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */
public record FirstLegContainerShippingNodeRecordedV1(
String queryId,
String customerId,
String oceanBookingId,
String containerBookingId,
String containerNo,
String standardNode,
int nodeSequence,
Instant observedAt,
Instant actualAt,
String locationCode,
String locationName,
String providerCode,
String providerEventId,
String providerNodeCode,
String sourceReference,
String rawPayloadHash) {
private static final Map<String, Integer> STANDARD_NODES = Map.of(
"DEPARTED_WAREHOUSE", 10, "AT_ORIGIN_PORT", 20, "LOADED_ON_VESSEL", 30,
"DEPARTED_ORIGIN_PORT", 40, "IN_TRANSIT", 50, "ARRIVED_DESTINATION_PORT", 60,
"DISCHARGED_FROM_VESSEL", 70, "PICKUP_READY", 80, "PICKED_UP", 90,
"EMPTY_RETURNED", 100);
public FirstLegContainerShippingNodeRecordedV1 {
if (queryId == null || queryId.isBlank() || customerId == null || customerId.isBlank()
|| oceanBookingId == null || oceanBookingId.isBlank()
|| containerBookingId == null || containerBookingId.isBlank()
|| containerNo == null || containerNo.isBlank()) {
throw new IllegalArgumentException("船节点交接业务标识不能为空");
}
if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode))
|| observedAt == null || actualAt == null) {
throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实");
}
if (providerCode == null || providerCode.isBlank() || providerEventId == null
|| providerEventId.isBlank() || providerNodeCode == null || providerNodeCode.isBlank()
|| rawPayloadHash == null || !rawPayloadHash.matches("(?i)^[0-9a-f]{64}$")) {
throw new IllegalArgumentException("船节点交接供应商审计字段不完整");
}
}
}
@@ -16,12 +16,25 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount, int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) { List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
String destinationType) {
public Order { public Order {
items = items == null ? List.of() : items; items = items == null ? List.of() : items;
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType; orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
pallets = pallets == null ? List.of() : pallets; pallets = pallets == null ? List.of() : pallets;
} }
public Order(String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId,
String inboundCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
}
public Order(String sourceInboundId, String rootInboundId, public Order(String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId, String sourceOrderId, String sourceOrderNo, String rootOrderId,
String inboundCode, String destinationWarehouseId, String inboundCode, String destinationWarehouseId,
@@ -32,7 +45,7 @@ public record FirstLegLoadingPlanCompletedV1(
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode, inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount, destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of()); grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
} }
} }
@@ -15,13 +15,28 @@ public record FirstLegLoadingPlanCompletedV2(
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount, int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) { List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
/** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */
String destinationType) {
public Order { public Order {
items = items == null ? List.of() : items; items = items == null ? List.of() : items;
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType; orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
pallets = pallets == null ? List.of() : pallets; pallets = pallets == null ? List.of() : pallets;
} }
public Order(String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId,
String inboundCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
}
public Order(String sourceInboundId, String rootInboundId, public Order(String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId, String sourceOrderId, String sourceOrderNo, String rootOrderId,
String inboundCode, String destinationWarehouseId, String inboundCode, String destinationWarehouseId,
@@ -32,7 +47,7 @@ public record FirstLegLoadingPlanCompletedV2(
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode, inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount, destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of()); grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
} }
} }
@@ -2,7 +2,10 @@ package com.baiyue.common.event;
import java.time.Instant; import java.time.Instant;
/** 海外仓卸柜完成事实;国内订舱 ID 是稳定聚合标识,containerNo 仅用于核对物理柜号。 */ /**
* 海外仓柜级卸柜完成事实;国内订舱 ID 是稳定聚合与幂等标识,containerNo 用于核对物理柜号。
* unloadingCompletedAt 是全柜 complete 时刻,不得使用签收拍照上传时刻代替。
*/
public record OverseasContainerUnloadedV1( public record OverseasContainerUnloadedV1(
String sourceContainerBookingId, String sourceContainerBookingId,
String containerNo, String containerNo,
@@ -0,0 +1,49 @@
package com.baiyue.common.event;
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 FirstLegContainerShippingNodeRecordedV1Test {
@Test
void roundTripsLongBusinessIdsAndActualPickupReadyFact() throws Exception {
var payload = payload();
var mapper = new ObjectMapper().findAndRegisterModules();
var restored = mapper.readValue(mapper.writeValueAsString(payload),
FirstLegContainerShippingNodeRecordedV1.class);
assertThat(restored).isEqualTo(payload);
assertThat(restored.containerBookingId()).isEqualTo("2088000000000000003");
assertThat(restored.standardNode()).isEqualTo("PICKUP_READY");
assertThat(restored.nodeSequence()).isEqualTo(80);
}
@Test
void rejectsEstimatedOnlyOrIncompleteProviderFacts() {
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
"1", "2", "3", "4", "ZCSU7881970", "PICKUP_READY", 80,
Instant.parse("2026-08-25T01:00:00Z"), null, null, null,
"ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64)))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("实际时间");
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
"1", "2", "3", "4", "ZCSU7881970", "ARRIVED", 60,
Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
null, null, "ZIM", "evt-2", "ARRIVED", "ZIM:evt-2", "b".repeat(64)))
.isInstanceOf(IllegalArgumentException.class);
}
private static FirstLegContainerShippingNodeRecordedV1 payload() {
return new FirstLegContainerShippingNodeRecordedV1(
"2088000000000000001", "2088000000000000002", "2088000000000000004",
"2088000000000000003", "ZCSU7881970", "PICKUP_READY", 80,
Instant.parse("2026-08-25T01:02:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
"USLAX", "Los Angeles", "ZIM", "evt-1", "AVAILABLE",
"ZIM:evt-1", "a".repeat(64));
}
}
@@ -47,4 +47,17 @@ class FirstLegLoadingPlanCompletedV1Test {
assertThat(restored).isEqualTo(payload); assertThat(restored).isEqualTo(payload);
assertThat(json).contains("ONE_CLICK_DROPSHIP", "PLT-01", "BOX-003", "customerId"); assertThat(json).contains("ONE_CLICK_DROPSHIP", "PLT-01", "BOX-003", "customerId");
} }
@Test
void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception {
String json = """
{"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1",
"warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100",
"destinationWarehouseId":"3","packageCount":1,"items":[],"destinationType":"OVERSEAS"}]}
""";
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS");
}
} }
@@ -40,6 +40,18 @@ class FirstLegLoadingPlanCompletedV2Test {
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2); assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
} }
@Test
void carriesFrozenDestinationType() {
var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8",
2, 20, 0, null, null, null, List.of(), "11", "FIRST_LEG", List.of(), "FBA");
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
assertThat(json.at("/orders/0/destinationType").asText()).isEqualTo("FBA");
}
@Test @Test
void mapsEachOrdinaryCartonNumberToItsSku() { void mapsEachOrdinaryCartonNumberToItsSku() {
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU-PSC2", "产品", "Product", 2, var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU-PSC2", "产品", "Product", 2,
@@ -20,5 +20,7 @@ class OverseasContainerUnloadedV1Test {
assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload); assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload);
assertThat(json).contains("sourceContainerBookingId", "1001", "MSCU1234567", "unloadingPhotoUploadedAt"); assertThat(json).contains("sourceContainerBookingId", "1001", "MSCU1234567", "unloadingPhotoUploadedAt");
assertThat(payload.unloadingCompletedAt()).isAfter(payload.unloadingPhotoUploadedAt());
assertThat(payload.unloadingCompletedAt()).isNotEqualTo(payload.unloadingPhotoUploadedAt());
} }
} }
@@ -75,6 +75,14 @@ public class BaiyueOpenApiAutoConfiguration {
.addOperationCustomizer(correlationHeaderCustomizer).build(); .addOperationCustomizer(correlationHeaderCustomizer).build();
} }
@Bean
GroupedOpenApi pdaApi(OperationCustomizer correlationHeaderCustomizer) {
return GroupedOpenApi.builder().group("pda")
.displayName("PDA APIs / PDA 作业接口")
.pathsToMatch("/api/pda/**")
.addOperationCustomizer(correlationHeaderCustomizer).build();
}
private Parameter header(String name, String description, boolean required) { private Parameter header(String name, String description, boolean required) {
return new HeaderParameter().name(name).description(description).required(required) return new HeaderParameter().name(name).description(description).required(required)
.schema(new io.swagger.v3.oas.models.media.StringSchema()); .schema(new io.swagger.v3.oas.models.media.StringSchema());
@@ -0,0 +1,17 @@
package com.baiyue.common.openapi;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PdaOpenApiGroupContractTest {
@Test
void pdaGroupOnlyMatchesDeviceApis() {
BaiyueOpenApiAutoConfiguration configuration = new BaiyueOpenApiAutoConfiguration();
var group = configuration.pdaApi(configuration.correlationHeaderCustomizer());
assertThat(group.getGroup()).isEqualTo("pda");
assertThat(group.getPathsToMatch()).containsExactly("/api/pda/**");
assertThat(group.getPathsToMatch()).noneMatch(path -> path.contains("internal"));
}
}
@@ -9,11 +9,19 @@ import java.util.Set;
public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String appCode, String clientType, String username, String appCode, String clientType,
Set<String> permissions, boolean impersonated, Long actorUserId, Set<String> permissions, boolean impersonated, Long actorUserId,
Long actorMembershipId, boolean passwordChangeRequired) implements Principal { Long actorMembershipId, boolean passwordChangeRequired,
Long overseasWarehouseId) implements Principal {
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String appCode, String clientType, Set<String> permissions) { String username, String appCode, String clientType, Set<String> permissions) {
this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType, this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType,
permissions, false, null, null, false); permissions, false, null, null, false, null);
}
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String appCode, String clientType, Set<String> permissions,
boolean impersonated, Long actorUserId, Long actorMembershipId,
boolean passwordChangeRequired) {
this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType,
permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, null);
} }
public AuthenticatedUser { public AuthenticatedUser {
permissions = permissions == null ? Set.of() : Set.copyOf(permissions); permissions = permissions == null ? Set.of() : Set.copyOf(permissions);
@@ -43,7 +43,8 @@ public class JwtClaimsMapper {
Boolean.TRUE.equals(jwt.getClaim("impersonated")), Boolean.TRUE.equals(jwt.getClaim("impersonated")),
longClaim(jwt, "actor_user_id"), longClaim(jwt, "actor_user_id"),
longClaim(jwt, "actor_membership_id"), longClaim(jwt, "actor_membership_id"),
Boolean.TRUE.equals(jwt.getClaim("password_change_required"))); Boolean.TRUE.equals(jwt.getClaim("password_change_required")),
longClaim(jwt, "overseas_warehouse_id"));
List<SimpleGrantedAuthority> authorities = permissions.stream() List<SimpleGrantedAuthority> authorities = permissions.stream()
.map(SimpleGrantedAuthority::new).toList(); .map(SimpleGrantedAuthority::new).toList();
return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities); return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities);
@@ -81,6 +81,7 @@ class JwtClaimsMapperTest {
.claim("actor_user_id", 42L) .claim("actor_user_id", 42L)
.claim("actor_membership_id", 43L) .claim("actor_membership_id", 43L)
.claim("password_change_required", true) .claim("password_change_required", true)
.claim("overseas_warehouse_id", 900L)
.build(); .build();
AuthenticatedUser user = new JwtClaimsMapper().map(jwt).getPrincipal(); AuthenticatedUser user = new JwtClaimsMapper().map(jwt).getPrincipal();
@@ -88,5 +89,6 @@ class JwtClaimsMapperTest {
assertThat(user.actorUserId()).isEqualTo(42L); assertThat(user.actorUserId()).isEqualTo(42L);
assertThat(user.actorMembershipId()).isEqualTo(43L); assertThat(user.actorMembershipId()).isEqualTo(43L);
assertThat(user.passwordChangeRequired()).isTrue(); assertThat(user.passwordChangeRequired()).isTrue();
assertThat(user.overseasWarehouseId()).isEqualTo(900L);
} }
} }
+4
View File
@@ -11,6 +11,8 @@
|---|---| |---|---|
| 链路上下文 | `src/main/java/com/baiyue/common/web/CorrelationContext.java` | | 链路上下文 | `src/main/java/com/baiyue/common/web/CorrelationContext.java` |
| 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` | | 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` |
| W3C Trace Context | `src/main/java/com/baiyue/common/web/TraceParent.java`;严格接收 version 00,响应 `traceparent`、`X-Trace-Id` 与 MDC `traceId` 使用同一 trace ID |
| Feign Trace 传播 | `FeignTraceRequestInterceptor.java`、`FeignTracePropagationAutoConfiguration.java`;仅 Feign 存在时装配,optional 依赖不向无 Feign 服务传递 |
| 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 | | 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 |
| 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 | | 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 |
| 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` | | 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` |
@@ -23,5 +25,7 @@
- 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。 - 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。
- 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。 - 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。
- 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。 - 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。
- 入站 Trace 优先级固定为:合法单值 `traceparent` > 完全缺失 `traceparent` 时的 32 位非零十六进制 `X-Trace-Id` > 新 trace。只要 `traceparent` 已出现但重复、超长、格式非法或含全零 ID,就必须生成新 trace,不得回退信任 `X-Trace-Id`;请求 ID 独立按 `X-Request-Id` 校验或生成。
- 同步 Feign 出站固定先移除调用模板中的 `X-Request-Id`、`X-Trace-Id`、`traceparent`,再仅从当前 MDC 的合法请求 ID 和 trace ID 重建;`traceparent` 沿用当前 trace ID、每次调用生成新非零 span ID、flags 固定 `01`。不得从业务参数、远端返回头或无效 MDC 建立上下文;本能力不处理 Reactor、异步线程、重试、超时或 fallback。
- 从父目录运行 `mvn -pl common-web -am test`。 - 从父目录运行 `mvn -pl common-web -am test`。
- 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。 - 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。
+5
View File
@@ -39,6 +39,11 @@
<groupId>io.micrometer</groupId> <groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId> <artifactId>micrometer-registry-prometheus</artifactId>
</dependency> </dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-core</artifactId>
<optional>true</optional>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
@@ -9,8 +9,11 @@ import org.slf4j.MDC;
public final class CorrelationContext { public final class CorrelationContext {
public static final String REQUEST_ID = "requestId"; public static final String REQUEST_ID = "requestId";
public static final String TRACE_ID = "traceId"; public static final String TRACE_ID = "traceId";
public static final String SPAN_ID = "spanId";
public static final String TRACE_PARENT = "traceparent";
public static final String REQUEST_ID_HEADER = "X-Request-Id"; public static final String REQUEST_ID_HEADER = "X-Request-Id";
public static final String TRACE_ID_HEADER = "X-Trace-Id"; public static final String TRACE_ID_HEADER = "X-Trace-Id";
public static final String TRACE_PARENT_HEADER = TraceParent.HEADER;
private CorrelationContext() {} private CorrelationContext() {}
@@ -22,6 +25,14 @@ public final class CorrelationContext {
return value(request, TRACE_ID); return value(request, TRACE_ID);
} }
public static String spanId(HttpServletRequest request) {
return value(request, SPAN_ID);
}
public static String traceParent(HttpServletRequest request) {
return value(request, TRACE_PARENT);
}
private static String value(HttpServletRequest request, String key) { private static String value(HttpServletRequest request, String key) {
Object attribute = request.getAttribute(key); Object attribute = request.getAttribute(key);
return attribute == null ? MDC.get(key) : attribute.toString(); return attribute == null ? MDC.get(key) : attribute.toString();
@@ -11,6 +11,7 @@ import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.Enumeration;
import java.util.UUID; import java.util.UUID;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -25,24 +26,60 @@ public class CorrelationIdFilter extends OncePerRequestFilter {
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException { FilterChain filterChain) throws ServletException, IOException {
String requestId = acceptedOrGenerated(request.getHeader(CorrelationContext.REQUEST_ID_HEADER), false); String requestId = requestId(request);
String traceId = acceptedOrGenerated(request.getHeader(CorrelationContext.TRACE_ID_HEADER), true); TraceParent traceParent = traceParent(request);
String traceId = traceParent.traceId();
request.setAttribute(CorrelationContext.REQUEST_ID, requestId); request.setAttribute(CorrelationContext.REQUEST_ID, requestId);
request.setAttribute(CorrelationContext.TRACE_ID, traceId); request.setAttribute(CorrelationContext.TRACE_ID, traceId);
request.setAttribute(CorrelationContext.SPAN_ID, traceParent.parentId());
request.setAttribute(CorrelationContext.TRACE_PARENT, traceParent.value());
response.setHeader(CorrelationContext.REQUEST_ID_HEADER, requestId); response.setHeader(CorrelationContext.REQUEST_ID_HEADER, requestId);
response.setHeader(CorrelationContext.TRACE_ID_HEADER, traceId); response.setHeader(CorrelationContext.TRACE_ID_HEADER, traceId);
response.setHeader(CorrelationContext.TRACE_PARENT_HEADER, traceParent.value());
try (MDC.MDCCloseable ignoredRequest = MDC.putCloseable(CorrelationContext.REQUEST_ID, requestId); try (MDC.MDCCloseable ignoredRequest = MDC.putCloseable(CorrelationContext.REQUEST_ID, requestId);
MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId)) { MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId);
MDC.MDCCloseable ignoredSpan = MDC.putCloseable(CorrelationContext.SPAN_ID, traceParent.parentId())) {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }
} }
private String acceptedOrGenerated(String candidate, boolean compact) { private String requestId(HttpServletRequest request) {
if (candidate != null && SAFE_ID.matcher(candidate).matches()) { HeaderValue header = singleHeader(request, CorrelationContext.REQUEST_ID_HEADER, 128);
return candidate; if (header.valid() && SAFE_ID.matcher(header.value()).matches()) {
return header.value();
}
return UUID.randomUUID().toString();
}
private TraceParent traceParent(HttpServletRequest request) {
HeaderValue standard = singleHeader(request, CorrelationContext.TRACE_PARENT_HEADER,
TraceParent.HEADER_LENGTH);
if (standard.present()) {
if (standard.valid()) {
return TraceParent.parse(standard.value()).map(TraceParent::childOf)
.orElseGet(() -> TraceParent.newRoot(null));
}
return TraceParent.newRoot(null);
}
HeaderValue legacy = singleHeader(request, CorrelationContext.TRACE_ID_HEADER, 32);
return TraceParent.newRoot(legacy.valid() ? legacy.value() : null);
}
private HeaderValue singleHeader(HttpServletRequest request, String name, int maxLength) {
Enumeration<String> values = request.getHeaders(name);
if (values == null || !values.hasMoreElements()) {
return HeaderValue.missing();
}
String value = values.nextElement();
boolean duplicate = values.hasMoreElements();
return new HeaderValue(true, !duplicate && value != null && value.length() <= maxLength, value);
}
private record HeaderValue(boolean present, boolean valid, String value) {
private static HeaderValue missing() {
return new HeaderValue(false, false, null);
} }
String id = UUID.randomUUID().toString();
return compact ? id.replace("-", "") : id;
} }
} }
@@ -0,0 +1,18 @@
package com.baiyue.common.web;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
/** Enables synchronous Feign trace propagation without making Feign a required dependency. */
@AutoConfiguration(afterName = "org.springframework.cloud.openfeign.FeignAutoConfiguration")
@ConditionalOnClass(name = "feign.RequestInterceptor")
public class FeignTracePropagationAutoConfiguration {
@Bean
@ConditionalOnMissingBean(FeignTraceRequestInterceptor.class)
FeignTraceRequestInterceptor feignTraceRequestInterceptor() {
return new FeignTraceRequestInterceptor();
}
}
@@ -0,0 +1,41 @@
package com.baiyue.common.web;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import java.util.regex.Pattern;
/** Propagates the trusted synchronous request trace context to every Feign invocation. */
public final class FeignTraceRequestInterceptor implements RequestInterceptor, Ordered {
private static final Pattern SAFE_REQUEST_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}");
@Override
public void apply(RequestTemplate template) {
removeManagedHeaders(template);
String requestId = MDC.get(CorrelationContext.REQUEST_ID);
if (requestId != null && SAFE_REQUEST_ID.matcher(requestId).matches()) {
template.header(CorrelationContext.REQUEST_ID_HEADER, requestId);
}
String traceId = MDC.get(CorrelationContext.TRACE_ID);
if (TraceParent.validLegacyTraceId(traceId)) {
TraceParent outbound = TraceParent.outboundChild(traceId);
template.header(CorrelationContext.TRACE_ID_HEADER, outbound.traceId());
template.header(CorrelationContext.TRACE_PARENT_HEADER, outbound.value());
}
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
private void removeManagedHeaders(RequestTemplate template) {
template.removeHeader(CorrelationContext.REQUEST_ID_HEADER);
template.removeHeader(CorrelationContext.TRACE_ID_HEADER);
template.removeHeader(CorrelationContext.TRACE_PARENT_HEADER);
}
}
@@ -0,0 +1,92 @@
package com.baiyue.common.web;
import java.security.SecureRandom;
import java.util.HexFormat;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Strict W3C {@code traceparent} version 00 value.
*
* <p>This type deliberately accepts only the currently frozen version-00 shape. Unsupported,
* ambiguous or malformed input must start a new trace instead of being partially trusted.</p>
*/
public record TraceParent(String traceId, String parentId, String traceFlags) {
public static final String HEADER = "traceparent";
public static final int HEADER_LENGTH = 55;
private static final Pattern VERSION_00 = Pattern.compile(
"00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})");
private static final Pattern LEGACY_TRACE_ID = Pattern.compile("[0-9A-Fa-f]{32}");
private static final String ZERO_TRACE_ID = "0".repeat(32);
private static final String ZERO_PARENT_ID = "0".repeat(16);
private static final SecureRandom RANDOM = new SecureRandom();
private static final HexFormat HEX = HexFormat.of();
public TraceParent {
if (!validTraceId(traceId) || !validParentId(parentId)
|| traceFlags == null || !traceFlags.matches("[0-9a-f]{2}")) {
throw new IllegalArgumentException("Invalid W3C traceparent components");
}
}
/** Parses one exact, lowercase W3C version-00 header value. */
public static Optional<TraceParent> parse(String value) {
if (value == null || value.length() != HEADER_LENGTH) {
return Optional.empty();
}
Matcher matcher = VERSION_00.matcher(value);
if (!matcher.matches() || !validTraceId(matcher.group(1)) || !validParentId(matcher.group(2))) {
return Optional.empty();
}
return Optional.of(new TraceParent(matcher.group(1), matcher.group(2), matcher.group(3)));
}
/** Creates the local inbound context for a valid remote parent. */
public static TraceParent childOf(TraceParent remoteParent) {
return new TraceParent(remoteParent.traceId(), randomNonZero(8), remoteParent.traceFlags());
}
/** Creates a new root context, optionally reusing a valid legacy trace ID. */
public static TraceParent newRoot(String legacyTraceId) {
String traceId = validLegacyTraceId(legacyTraceId)
? legacyTraceId.toLowerCase(java.util.Locale.ROOT) : randomNonZero(16);
return new TraceParent(traceId, randomNonZero(8), "00");
}
/** Creates a sampled outbound child for a trusted current trace ID. */
public static TraceParent outboundChild(String currentTraceId) {
if (!validLegacyTraceId(currentTraceId)) {
throw new IllegalArgumentException("Current trace ID is invalid");
}
return new TraceParent(currentTraceId.toLowerCase(java.util.Locale.ROOT), randomNonZero(8), "01");
}
public static boolean validLegacyTraceId(String value) {
return value != null && LEGACY_TRACE_ID.matcher(value).matches()
&& !ZERO_TRACE_ID.equalsIgnoreCase(value);
}
public String value() {
return "00-" + traceId + '-' + parentId + '-' + traceFlags;
}
private static boolean validTraceId(String value) {
return value != null && value.matches("[0-9a-f]{32}") && !ZERO_TRACE_ID.equals(value);
}
private static boolean validParentId(String value) {
return value != null && value.matches("[0-9a-f]{16}") && !ZERO_PARENT_ID.equals(value);
}
private static String randomNonZero(int byteCount) {
byte[] bytes = new byte[byteCount];
String value;
do {
RANDOM.nextBytes(bytes);
value = HEX.formatHex(bytes);
} while (value.chars().allMatch(character -> character == '0'));
return value;
}
}
@@ -1,2 +1,3 @@
com.baiyue.common.web.LongIdJacksonConfiguration com.baiyue.common.web.LongIdJacksonConfiguration
com.baiyue.common.web.CommonWebAutoConfiguration com.baiyue.common.web.CommonWebAutoConfiguration
com.baiyue.common.web.FeignTracePropagationAutoConfiguration
@@ -1,6 +1,8 @@
package com.baiyue.common.web; package com.baiyue.common.web;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.boot.test.context.runner.ApplicationContextRunner;
@@ -16,6 +18,8 @@ class CommonWebAutoConfigurationTest {
contextRunner.run(context -> { contextRunner.run(context -> {
assertThat(context).hasSingleBean(GlobalExceptionHandler.class); assertThat(context).hasSingleBean(GlobalExceptionHandler.class);
assertThat(context).hasSingleBean(CorrelationIdFilter.class); assertThat(context).hasSingleBean(CorrelationIdFilter.class);
assertThat(CorrelationIdFilter.class.getAnnotation(Order.class).value())
.isEqualTo(Ordered.HIGHEST_PRECEDENCE);
}); });
} }
@@ -6,6 +6,8 @@ import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.mock.web.MockHttpServletResponse;
import java.io.IOException;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("请求链路标识过滤功能") @DisplayName("请求链路标识过滤功能")
@@ -13,27 +15,87 @@ class CorrelationIdFilterTest {
private final CorrelationIdFilter filter = new CorrelationIdFilter(); private final CorrelationIdFilter filter = new CorrelationIdFilter();
@Test @Test
void preservesSafeIncomingIds() throws Exception { void validTraceParentWinsAndMapsToLegacyTraceIdAndMdc() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "req-123"); request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "req-123");
request.addHeader(CorrelationContext.TRACE_ID_HEADER, "trace-123"); request.addHeader(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
request.addHeader(CorrelationContext.TRACE_PARENT_HEADER,
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
MockHttpServletResponse response = new MockHttpServletResponse(); MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, new MockFilterChain()); CapturingFilterChain chain = new CapturingFilterChain();
filter.doFilter(request, response, chain);
assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER)).isEqualTo("req-123"); assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER)).isEqualTo("req-123");
assertThat(request.getAttribute(CorrelationContext.TRACE_ID)).isEqualTo("trace-123"); assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER))
.isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736");
assertThat(response.getHeader(CorrelationContext.TRACE_PARENT_HEADER))
.matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01");
assertThat(chain.traceId).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736");
assertThat(chain.spanId).matches("[0-9a-f]{16}").isNotEqualTo("0000000000000000");
assertThat(org.slf4j.MDC.get(CorrelationContext.TRACE_ID)).isNull();
assertThat(org.slf4j.MDC.get(CorrelationContext.SPAN_ID)).isNull();
} }
@Test @Test
void replacesUnsafeIncomingIds() throws Exception { void fallsBackToValidLegacyTraceIdOnlyWhenTraceParentIsMissing() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "invalid value\n"); request.addHeader(CorrelationContext.TRACE_ID_HEADER, "ABCDEF0123456789ABCDEF0123456789");
MockHttpServletResponse response = new MockHttpServletResponse(); MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, new MockFilterChain()); filter.doFilter(request, response, new MockFilterChain());
assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER))
.isEqualTo("abcdef0123456789abcdef0123456789");
assertThat(response.getHeader(CorrelationContext.TRACE_PARENT_HEADER))
.matches("00-abcdef0123456789abcdef0123456789-[0-9a-f]{16}-00");
}
@Test
void invalidTraceParentStartsNewTraceWithoutTrustingLegacyTraceId() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader(CorrelationContext.TRACE_PARENT_HEADER,
"00-00000000000000000000000000000000-00f067aa0ba902b7-01");
request.addHeader(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, new MockFilterChain());
assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER))
.matches("[0-9a-f]{32}")
.isNotEqualTo("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
.isNotEqualTo("00000000000000000000000000000000");
}
@Test
void rejectsDuplicateAndOversizedHeaders() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "a".repeat(129));
request.addHeader(CorrelationContext.TRACE_PARENT_HEADER,
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
request.addHeader(CorrelationContext.TRACE_PARENT_HEADER,
"00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00");
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, new MockFilterChain());
assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER)) assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER))
.matches("[0-9a-f-]{36}"); .matches("[0-9a-f-]{36}");
assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER)) assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER))
.matches("[0-9a-f]{32}"); .matches("[0-9a-f]{32}")
.isNotEqualTo("4bf92f3577b34da6a3ce929d0e0e4736")
.isNotEqualTo("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
}
private static final class CapturingFilterChain extends MockFilterChain {
private String traceId;
private String spanId;
@Override
public void doFilter(jakarta.servlet.ServletRequest request,
jakarta.servlet.ServletResponse response) throws IOException {
traceId = org.slf4j.MDC.get(CorrelationContext.TRACE_ID);
spanId = org.slf4j.MDC.get(CorrelationContext.SPAN_ID);
}
} }
} }
@@ -0,0 +1,37 @@
package com.baiyue.common.web;
import feign.RequestInterceptor;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.FilteredClassLoader;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
class FeignTracePropagationAutoConfigurationTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(FeignTracePropagationAutoConfiguration.class));
@Test
void registersTraceInterceptorWhenFeignIsPresent() {
contextRunner.run(context -> {
assertThat(context).hasSingleBean(FeignTraceRequestInterceptor.class);
assertThat(context.getBeansOfType(RequestInterceptor.class)).hasSize(1);
});
}
@Test
void doesNotLoadFeignConfigurationWhenFeignIsAbsent() {
contextRunner.withClassLoader(new FilteredClassLoader(RequestInterceptor.class))
.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context).doesNotHaveBean("feignTraceRequestInterceptor");
});
}
@Test
void backsOffOnlyForAnApplicationProvidedTraceInterceptor() {
contextRunner.withBean(FeignTraceRequestInterceptor.class, FeignTraceRequestInterceptor::new)
.run(context -> assertThat(context).hasSingleBean(FeignTraceRequestInterceptor.class));
}
}
@@ -0,0 +1,80 @@
package com.baiyue.common.web;
import feign.RequestTemplate;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import static org.assertj.core.api.Assertions.assertThat;
class FeignTraceRequestInterceptorTest {
private final FeignTraceRequestInterceptor interceptor = new FeignTraceRequestInterceptor();
@AfterEach
void clearMdc() {
MDC.clear();
}
@Test
void runsLastSoTrustedContextHasFinalHeaderAuthority() {
assertThat(interceptor.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE);
}
@Test
void overwritesCallerHeadersFromTrustedMdcAndCreatesNewSampledSpan() {
MDC.put(CorrelationContext.REQUEST_ID, "req-trusted-123");
MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736");
RequestTemplate template = templateWithSpoofedHeaders();
interceptor.apply(template);
assertThat(template.headers().get(CorrelationContext.REQUEST_ID_HEADER))
.containsExactly("req-trusted-123");
assertThat(template.headers().get(CorrelationContext.TRACE_ID_HEADER))
.containsExactly("4bf92f3577b34da6a3ce929d0e0e4736");
assertThat(template.headers().get(CorrelationContext.TRACE_PARENT_HEADER))
.singleElement().asString()
.matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01");
}
@Test
void createsDifferentNonZeroSpanForEachSynchronousInvocation() {
MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736");
RequestTemplate first = new RequestTemplate();
RequestTemplate second = new RequestTemplate();
interceptor.apply(first);
interceptor.apply(second);
assertThat(singleHeader(first, CorrelationContext.TRACE_PARENT_HEADER))
.isNotEqualTo(singleHeader(second, CorrelationContext.TRACE_PARENT_HEADER));
}
@Test
void stripsManagedCallerHeadersWhenTrustedContextIsMissingOrInvalid() {
MDC.put(CorrelationContext.REQUEST_ID, "invalid request id");
MDC.put(CorrelationContext.TRACE_ID, "not-a-w3c-trace-id");
RequestTemplate template = templateWithSpoofedHeaders();
interceptor.apply(template);
assertThat(template.headers()).doesNotContainKeys(
CorrelationContext.REQUEST_ID_HEADER,
CorrelationContext.TRACE_ID_HEADER,
CorrelationContext.TRACE_PARENT_HEADER);
}
private RequestTemplate templateWithSpoofedHeaders() {
RequestTemplate template = new RequestTemplate();
template.header(CorrelationContext.REQUEST_ID_HEADER, "req-business-parameter");
template.header(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
template.header(CorrelationContext.TRACE_PARENT_HEADER,
"00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00");
return template;
}
private String singleHeader(RequestTemplate template, String name) {
return template.headers().get(name).iterator().next();
}
}
@@ -0,0 +1,54 @@
package com.baiyue.common.web;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class TraceParentTest {
@Test
void parsesCanonicalVersion00AndCreatesLocalChild() {
TraceParent remote = TraceParent.parse(
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01").orElseThrow();
TraceParent local = TraceParent.childOf(remote);
assertThat(local.traceId()).isEqualTo(remote.traceId());
assertThat(local.parentId()).matches("[0-9a-f]{16}")
.isNotEqualTo(remote.parentId())
.isNotEqualTo("0000000000000000");
assertThat(local.traceFlags()).isEqualTo("01");
assertThat(local.value()).hasSize(TraceParent.HEADER_LENGTH);
}
@Test
void rejectsNonCanonicalUnsupportedAndZeroIdentifiers() {
assertThat(TraceParent.parse(
"00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01")).isEmpty();
assertThat(TraceParent.parse(
"01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")).isEmpty();
assertThat(TraceParent.parse(
"00-00000000000000000000000000000000-00f067aa0ba902b7-01")).isEmpty();
assertThat(TraceParent.parse(
"00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01")).isEmpty();
assertThat(TraceParent.parse("x".repeat(10_000))).isEmpty();
}
@Test
void createsCanonicalNonZeroRootAndNormalizesLegacyHex() {
TraceParent root = TraceParent.newRoot("ABCDEF0123456789ABCDEF0123456789");
assertThat(root.traceId()).isEqualTo("abcdef0123456789abcdef0123456789");
assertThat(root.parentId()).matches("[0-9a-f]{16}").isNotEqualTo("0000000000000000");
assertThat(root.value()).matches(
"00-abcdef0123456789abcdef0123456789-[0-9a-f]{16}-00");
}
@Test
void createsSampledOutboundChildForTrustedCurrentTrace() {
TraceParent child = TraceParent.outboundChild("4BF92F3577B34DA6A3CE929D0E0E4736");
assertThat(child.value()).matches(
"00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01");
}
}
+4
View File
@@ -8,6 +8,10 @@ baiyue:
audience: ${JWT_AUDIENCE:lastleg-api} audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
internal-service-token: ${INTERNAL_SERVICE_TOKEN} internal-service-token: ${INTERNAL_SERVICE_TOKEN}
pda-session-validation:
auth-url: ${AUTH_SESSION_VALIDATION_URL}
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s}
permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot}
openapi.enabled: ${SWAGGER_ENABLED:false} openapi.enabled: ${SWAGGER_ENABLED:false}
audit.enabled: true audit.enabled: true
@@ -10,6 +10,7 @@ baiyue:
data-residency: ${DATA_RESIDENCY:OVERSEAS} data-residency: ${DATA_RESIDENCY:OVERSEAS}
event-dispatcher: event-dispatcher:
consumer-url: ${EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events} 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}
fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000}
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10}
@@ -1,3 +1,9 @@
springdoc: springdoc:
api-docs.enabled: ${SWAGGER_ENABLED:false} api-docs.enabled: ${SWAGGER_ENABLED:false}
swagger-ui.enabled: ${SWAGGER_ENABLED:false} swagger-ui.enabled: ${SWAGGER_ENABLED:false}
baiyue:
security:
overseas-projection:
service-url: ${OVERSEAS_SECURITY_SERVICE_URL:http://overseas-security-service:9307}
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
timeout: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:2s}
+15 -1
View File
@@ -10,6 +10,20 @@ lastleg:
smart-dispatch-api: smart-dispatch-api:
enabled: true enabled: true
baiyue: baiyue:
http:
clients:
unload-photo-file:
connect-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_READ_TIMEOUT:30s}
relabel-photo-file:
connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_READ_TIMEOUT:30s}
warehouse-resource:
connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s}
pallet-physical:
connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s}
clients: clients:
domestic-warehouse: domestic-warehouse:
url: ${DOMESTIC_WAREHOUSE_URL} url: ${DOMESTIC_WAREHOUSE_URL}
@@ -21,7 +35,7 @@ baiyue:
warehouse: warehouse:
base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://warehouse-overseas-service:9305} base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://warehouse-overseas-service:9305}
firstleg: firstleg:
event-bridge-url: ${FIRSTLEG_EVENT_BRIDGE_URL} event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}}
outbox-relay: outbox-relay:
fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000} fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000}
lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60}
@@ -0,0 +1,7 @@
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}
+28 -5
View File
@@ -37,7 +37,7 @@ services:
<<: *overseas-common <<: *overseas-common
image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas
ports: ["${GATEWAY_OVERSEAS_PORT:-9300}:9300"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PORT:-9300}:9300"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway-overseas:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway-overseas:/app/logs"]
environment: environment:
<<: *overseas-environment <<: *overseas-environment
@@ -45,12 +45,16 @@ services:
SWAGGER_ENABLED: ${SWAGGER_ENABLED:-false} SWAGGER_ENABLED: ${SWAGGER_ENABLED:-false}
CORS_ALLOWED_ORIGINS: ${OVERSEAS_CORS_ALLOWED_ORIGINS:-} CORS_ALLOWED_ORIGINS: ${OVERSEAS_CORS_ALLOWED_ORIGINS:-}
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false} 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-service: lastleg-service:
<<: *overseas-common <<: *overseas-common
image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg container_name: firstleg-${DEPLOY_ENV:-test}-lastleg
ports: ["${LASTLEG_PORT:-9303}:9303"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PORT:-9303}:9303"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/lastleg-service:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/lastleg-service:/app/logs"]
environment: environment:
<<: *overseas-environment <<: *overseas-environment
@@ -58,7 +62,8 @@ services:
LASTLEG_DB_URL: ${LASTLEG_DB_URL} LASTLEG_DB_URL: ${LASTLEG_DB_URL}
LASTLEG_DB_USERNAME: ${LASTLEG_DB_USERNAME} LASTLEG_DB_USERNAME: ${LASTLEG_DB_USERNAME}
LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD} LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD}
FIRSTLEG_EVENT_BRIDGE_URL: ${FIRSTLEG_EVENT_BRIDGE_URL} CN_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312}
FIRSTLEG_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312}
DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}
FILE_SERVICE_URL: ${FILE_SERVICE_URL} FILE_SERVICE_URL: ${FILE_SERVICE_URL}
WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305} WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305}
@@ -67,7 +72,7 @@ services:
<<: *overseas-common <<: *overseas-common
image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas
ports: ["${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse-overseas-service:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse-overseas-service:/app/logs"]
environment: environment:
<<: *overseas-environment <<: *overseas-environment
@@ -80,7 +85,7 @@ services:
<<: *overseas-common <<: *overseas-common
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas
ports: ["${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service-overseas:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service-overseas:/app/logs"]
environment: environment:
<<: *overseas-environment <<: *overseas-environment
@@ -89,6 +94,24 @@ services:
EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_OVERSEAS_DB_USERNAME} EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_OVERSEAS_DB_USERNAME}
EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_OVERSEAS_DB_PASSWORD} EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_OVERSEAS_DB_PASSWORD}
EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/events} 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}
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: networks:
firstleg-overseas-network: firstleg-overseas-network:
+15 -11
View File
@@ -39,7 +39,7 @@ services:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-gateway container_name: firstleg-${DEPLOY_ENV:-test}-gateway
ports: ["${GATEWAY_PORT:-9000}:9000"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PORT:-9000}:9000"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
@@ -52,7 +52,7 @@ services:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-auth container_name: firstleg-${DEPLOY_ENV:-test}-auth
ports: ["${AUTH_PORT:-9100}:9100"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PORT:-9100}:9100"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/auth:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/auth:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
@@ -71,7 +71,7 @@ services:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-iam container_name: firstleg-${DEPLOY_ENV:-test}-iam
ports: ["${IAM_PORT:-9201}:9201"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PORT:-9201}:9201"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/iam:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/iam:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
@@ -84,7 +84,7 @@ services:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-business container_name: firstleg-${DEPLOY_ENV:-test}-business
ports: ["${FIRSTLEG_PORT:-9202}:9202"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PORT:-9202}:9202"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/firstleg:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/firstleg:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
@@ -110,7 +110,7 @@ services:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-finance container_name: firstleg-${DEPLOY_ENV:-test}-finance
ports: ["${FINANCE_PORT:-9203}:9203"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PORT:-9203}:9203"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/finance:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/finance:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
@@ -123,7 +123,7 @@ services:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-order container_name: firstleg-${DEPLOY_ENV:-test}-order
ports: ["${ORDER_PORT:-9204}:9204"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PORT:-9204}:9204"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/order:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/order:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
@@ -136,7 +136,7 @@ services:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse container_name: firstleg-${DEPLOY_ENV:-test}-warehouse
ports: ["${WAREHOUSE_PORT:-9205}:9205"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PORT:-9205}:9205"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
@@ -149,7 +149,7 @@ services:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/file:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/file:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-file container_name: firstleg-${DEPLOY_ENV:-test}-file
ports: ["${FILE_PORT:-9206}:9206"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PORT:-9206}:9206"]
volumes: volumes:
- "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files" - "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files"
- "${LOG_ROOT:-/data/logs/baiyue-firstleg}/file:/app/logs" - "${LOG_ROOT:-/data/logs/baiyue-firstleg}/file:/app/logs"
@@ -173,7 +173,7 @@ services:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-print container_name: firstleg-${DEPLOY_ENV:-test}-print
ports: ["${PRINT_PORT:-9207}:9207"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PORT:-9207}:9207"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/print:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/print:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
@@ -184,7 +184,7 @@ services:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-integration container_name: firstleg-${DEPLOY_ENV:-test}-integration
ports: ["${INTEGRATION_PORT:-9210}:9210"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PORT:-9210}:9210"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/integration-service:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/integration-service:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
@@ -234,7 +234,7 @@ services:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge
ports: ["${EVENT_BRIDGE_PORT:-9312}:9312"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_PORT:-9312}:9312"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
@@ -245,6 +245,10 @@ services:
EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events} EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events}
FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events} FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events}
SPRING_SQL_INIT_MODE: never SPRING_SQL_INIT_MODE: never
networks:
firstleg-network:
infra-network:
aliases: [event-bridge-cn]
audit-service: audit-service:
<<: *common <<: *common
+77 -3
View File
@@ -36,6 +36,16 @@ case "$DEPLOY_ENV" in
;; ;;
esac esac
if [ "$DEPLOY_ENV" = "development" ]; then
case "$SERVICE" in
gateway|gateway-overseas) PUBLISHED_BIND_ADDRESS=0.0.0.0 ;;
*) PUBLISHED_BIND_ADDRESS=127.0.0.1 ;;
esac
else
PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}"
fi
export PUBLISHED_BIND_ADDRESS
case "$SERVICE" in case "$SERVICE" in
*[!A-Za-z0-9_-]*|'') *[!A-Za-z0-9_-]*|'')
echo "非法服务名: $SERVICE" >&2 echo "非法服务名: $SERVICE" >&2
@@ -57,7 +67,7 @@ env_file_value() {
} }
case "$SERVICE" in case "$SERVICE" in
gateway-overseas|lastleg-service|warehouse-overseas-service) gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service)
COMPOSE_FILE="deploy/docker-compose.overseas.yml" COMPOSE_FILE="deploy/docker-compose.overseas.yml"
PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas" PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas"
;; ;;
@@ -84,6 +94,22 @@ case "$SERVICE" in
;; ;;
esac esac
if [ "$SERVICE" = "gateway-overseas" ]; then
if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then
echo "部署 gateway-overseas 时,环境文件必须包含非空 AUTH_SESSION_VALIDATION_URL" >&2
exit 2
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 for file in "$ENV_FILE" "$COMPOSE_FILE"; do
if [ ! -f "$file" ]; then if [ ! -f "$file" ]; then
echo "必需文件不存在: $file" >&2 echo "必需文件不存在: $file" >&2
@@ -101,7 +127,7 @@ if [ "$SERVICE" = "finance" ]; then
fi fi
case "$SERVICE" in case "$SERVICE" in
gateway-overseas|lastleg-service|warehouse-overseas-service) gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service)
for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do 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 if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
echo "部署海外服务时,环境文件必须包含非空 $key" >&2 echo "部署海外服务时,环境文件必须包含非空 $key" >&2
@@ -205,11 +231,27 @@ local_health_state() {
docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}no-health:{{.State.Status}}{{end}}' "$container_id" 2>/dev/null || echo missing docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}no-health:{{.State.Status}}{{end}}' "$container_id" 2>/dev/null || echo missing
} }
local_runtime_state() {
container_id="$(local_container_id)"
if [ -z "$container_id" ]; then
echo missing
return
fi
docker inspect --format '{{.State.Status}}' "$container_id" 2>/dev/null || echo missing
}
wait_for_local_health() { wait_for_local_health() {
elapsed=0 elapsed=0
while [ "$elapsed" -lt "$HEALTH_TIMEOUT_SECONDS" ]; do while [ "$elapsed" -lt "$HEALTH_TIMEOUT_SECONDS" ]; do
runtime_state="$(local_runtime_state)"
health_state="$(local_health_state)" health_state="$(local_health_state)"
echo "容器健康状态: $health_state(已等待 ${elapsed}s)" echo "容器运行状态: $runtime_state,健康状态: $health_state(已等待 ${elapsed}s)"
case "$runtime_state" in
restarting|exited|dead)
echo "容器未保持运行,立即停止等待: $runtime_state" >&2
return 1
;;
esac
case "$health_state" in case "$health_state" in
healthy) healthy)
return 0 return 0
@@ -246,6 +288,11 @@ deploy_local() {
new_started=false new_started=false
fi fi
if [ "$new_started" = "true" ] && wait_for_local_health; then if [ "$new_started" = "true" ] && wait_for_local_health; then
running_image="$(local_container_image || true)"
if [ "$running_image" != "$IMAGE" ]; then
echo "运行容器镜像与请求镜像不一致: expected=$IMAGE actual=${running_image:-missing}" >&2
return 1
fi
echo "新镜像健康检查通过: $IMAGE" echo "新镜像健康检查通过: $IMAGE"
local_compose ps "$SERVICE" local_compose ps "$SERVICE"
local_registry_logout local_registry_logout
@@ -254,6 +301,13 @@ deploy_local() {
fi fi
echo "新镜像健康检查失败: $IMAGE" >&2 echo "新镜像健康检查失败: $IMAGE" >&2
failed_container_id="$(local_container_id || true)"
if [ -n "$failed_container_id" ]; then
echo "新镜像容器最近日志(最多 200 行):" >&2
docker logs --tail 200 "$failed_container_id" 2>&1 || true
else
echo "新镜像容器不存在,无法读取启动日志" >&2
fi
expected_prefix="$IMAGE_REPOSITORY/$SERVICE:" expected_prefix="$IMAGE_REPOSITORY/$SERVICE:"
case "$previous_image" in case "$previous_image" in
"$expected_prefix"*) "$expected_prefix"*)
@@ -378,6 +432,14 @@ cd "$remote_dir"
export DEPLOY_ENV="$deploy_env" export DEPLOY_ENV="$deploy_env"
export IMAGE_REPOSITORY="$image_repository" export IMAGE_REPOSITORY="$image_repository"
export IMAGE_TAG="$image_tag" export IMAGE_TAG="$image_tag"
if [ "$deploy_env" = "development" ]; then
case "$service" in
gateway|gateway-overseas) export PUBLISHED_BIND_ADDRESS=0.0.0.0 ;;
*) export PUBLISHED_BIND_ADDRESS=127.0.0.1 ;;
esac
else
export PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}"
fi
compose() { compose() {
docker compose \ docker compose \
@@ -441,12 +503,24 @@ if ! compose up -d --no-deps --pull never "$service"; then
new_started=false new_started=false
fi fi
if [ "$new_started" = "true" ] && wait_for_health; then if [ "$new_started" = "true" ] && wait_for_health; then
running_image="$(container_image || true)"
if [ "$running_image" != "$new_image" ]; then
echo "运行容器镜像与请求镜像不一致: expected=$new_image actual=${running_image:-missing}" >&2
exit 1
fi
echo "新镜像健康检查通过: $new_image" echo "新镜像健康检查通过: $new_image"
compose ps "$service" compose ps "$service"
exit 0 exit 0
fi fi
echo "新镜像健康检查失败: $new_image" >&2 echo "新镜像健康检查失败: $new_image" >&2
failed_container_id="$(remote_container_id || true)"
if [ -n "$failed_container_id" ]; then
echo "新镜像容器最近日志(最多 200 行):" >&2
docker logs --tail 200 "$failed_container_id" 2>&1 || true
else
echo "新镜像容器不存在,无法读取启动日志" >&2
fi
expected_prefix="$image_repository/$service:" expected_prefix="$image_repository/$service:"
case "$previous_image" in case "$previous_image" in
"$expected_prefix"*) previous_tag="${previous_image#"$expected_prefix"}" ;; "$expected_prefix"*) previous_tag="${previous_image#"$expected_prefix"}" ;;
@@ -64,6 +64,7 @@ if [ "${DEVELOPMENT_DEPLOY_LOCAL:-false}" = "true" ]; then
cd "$REMOTE_DIR" cd "$REMOTE_DIR"
export DEPLOY_ENV=development export DEPLOY_ENV=development
export IMAGE_REPOSITORY IMAGE_TAG export IMAGE_REPOSITORY IMAGE_TAG
export PUBLISHED_BIND_ADDRESS=127.0.0.1
docker compose \ docker compose \
--project-name "$PROJECT_NAME" \ --project-name "$PROJECT_NAME" \
@@ -143,6 +144,7 @@ cd "$remote_dir"
export DEPLOY_ENV=development export DEPLOY_ENV=development
export IMAGE_REPOSITORY="$image_repository" export IMAGE_REPOSITORY="$image_repository"
export IMAGE_TAG="$image_tag" export IMAGE_TAG="$image_tag"
export PUBLISHED_BIND_ADDRESS=127.0.0.1
docker compose \ docker compose \
--project-name "$project_name" \ --project-name "$project_name" \
+22 -2
View File
@@ -39,7 +39,7 @@ env_file_value() {
DEPLOY_REGION=CN DEPLOY_REGION=CN
for service in $SERVICES; do for service in $SERVICES; do
case "$service" in case "$service" in
gateway-overseas|lastleg-service|warehouse-overseas-service) DEPLOY_REGION=OVERSEAS ;; gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) DEPLOY_REGION=OVERSEAS ;;
event-bridge-service) event-bridge-service)
BRIDGE_REGION="${EVENT_BRIDGE_REGION_OVERRIDE:-}" BRIDGE_REGION="${EVENT_BRIDGE_REGION_OVERRIDE:-}"
if [ -z "$BRIDGE_REGION" ] && env_file_has_key REGION_CODE; then if [ -z "$BRIDGE_REGION" ] && env_file_has_key REGION_CODE; then
@@ -60,7 +60,7 @@ done
if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then
for service in $SERVICES; do for service in $SERVICES; do
case "$service" in case "$service" in
gateway-overseas|lastleg-service|warehouse-overseas-service|event-bridge-service) ;; gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service|event-bridge-service) ;;
*) echo "一次部署不得混合国内与海外服务: $SERVICE_CSV" >&2; exit 2 ;; *) echo "一次部署不得混合国内与海外服务: $SERVICE_CSV" >&2; exit 2 ;;
esac esac
done done
@@ -80,6 +80,26 @@ if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then
done done
fi 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
echo "部署 gateway-overseas 时,环境文件必须包含非空 AUTH_SESSION_VALIDATION_URL" >&2
exit 2
fi
;;
esac
case " $SERVICES " in case " $SERVICES " in
*" finance "*) *" finance "*)
for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do
+5
View File
@@ -8,6 +8,11 @@ services:
jar: gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar jar: gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar
port: 9300 port: 9300
regions: [OVERSEAS] 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: auth:
artifactId: auth-service artifactId: auth-service
jar: auth/target/auth-service-1.0.0-SNAPSHOT.jar jar: auth/target/auth-service-1.0.0-SNAPSHOT.jar
+2 -2
View File
@@ -3,13 +3,13 @@
## 唯一数据库初始化与发布入口 ## 唯一数据库初始化与发布入口
- 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 - 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。
- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先执行 `lastleg-service/src/main/resources/db/lastleg.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 只作可重建镜像。
- 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。 - 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。
- 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 - 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。
- 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。
- 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。 - 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。
- 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。 - 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。
- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`,不会写入固定密码账号、演示仓库、演示快递公司或演示订单;正式平台公共仓库主数据已内联到固定入口。 - 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建最小登录账号,重复执行不覆盖现有密码。脚本不写入演示订单;正式平台公共仓库主数据已内联到固定入口。
- `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 - `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。
- 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。 - 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。
- IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。 - IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。
+271 -14
View File
@@ -129,7 +129,7 @@ CREATE TABLE IF NOT EXISTS bridge_outbox (
event_version INT NOT NULL COMMENT '事件契约版本', event_version INT NOT NULL COMMENT '事件契约版本',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域', source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
target_region VARCHAR(32) NOT NULL COMMENT '目标区域', target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', customer_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径',
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
aggregate_version BIGINT NOT NULL COMMENT '聚合版本', aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
@@ -160,7 +160,7 @@ CREATE TABLE IF NOT EXISTS bridge_inbox (
event_version INT NOT NULL COMMENT '事件契约版本', event_version INT NOT NULL COMMENT '事件契约版本',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域', source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
target_region VARCHAR(32) NOT NULL COMMENT '目标区域', target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', customer_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径',
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
aggregate_version BIGINT NOT NULL COMMENT '聚合版本', aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
@@ -184,6 +184,41 @@ CREATE TABLE IF NOT EXISTS bridge_inbox (
KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until) KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until)
) ENGINE=InnoDB COMMENT='跨区域事件已接收箱'; ) ENGINE=InnoDB COMMENT='跨区域事件已接收箱';
-- 旧开发库曾使用 tenant_id;当前 Event Bridge 实体统一为 customer_id。幂等改名并保留原数据。
SET @bridge_inbox_customer_column = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_event_bridge' AND table_name = 'bridge_inbox' AND column_name = 'customer_id'
);
SET @bridge_inbox_tenant_column = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_event_bridge' AND table_name = 'bridge_inbox' AND column_name = 'tenant_id'
);
SET @bridge_inbox_column_sql = IF(
@bridge_inbox_customer_column = 0 AND @bridge_inbox_tenant_column = 1,
'ALTER TABLE bridge_inbox RENAME COLUMN tenant_id TO customer_id',
'SELECT 1'
);
PREPARE bridge_inbox_column_statement FROM @bridge_inbox_column_sql;
EXECUTE bridge_inbox_column_statement;
DEALLOCATE PREPARE bridge_inbox_column_statement;
SET @bridge_outbox_customer_column = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_event_bridge' AND table_name = 'bridge_outbox' AND column_name = 'customer_id'
);
SET @bridge_outbox_tenant_column = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_event_bridge' AND table_name = 'bridge_outbox' AND column_name = 'tenant_id'
);
SET @bridge_outbox_column_sql = IF(
@bridge_outbox_customer_column = 0 AND @bridge_outbox_tenant_column = 1,
'ALTER TABLE bridge_outbox RENAME COLUMN tenant_id TO customer_id',
'SELECT 1'
);
PREPARE bridge_outbox_column_statement FROM @bridge_outbox_column_sql;
EXECUTE bridge_outbox_column_statement;
DEALLOCATE PREPARE bridge_outbox_column_statement;
CREATE TABLE IF NOT EXISTS bridge_delivery_attempt ( CREATE TABLE IF NOT EXISTS bridge_delivery_attempt (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识',
@@ -244,7 +279,7 @@ EXECUTE auth_password_change_required_stmt;
DEALLOCATE PREPARE auth_password_change_required_stmt; DEALLOCATE PREPARE auth_password_change_required_stmt;
-- 开发/测试环境最小登录基线:一个平台系统管理员和一个 IT 测试客户管理员。 -- 开发/测试环境最小登录基线:一个平台系统管理员和一个 IT 测试客户管理员。
-- 初始密码统一为 ChangeMe123!;只保存 BCrypt 哈希,不写入明文。 -- 缺失账号首次创建时写入开发基线 BCrypt 哈希;重复执行统一增量绝不覆盖现有密码。
INSERT INTO auth_account ( INSERT INTO auth_account (
id, username, password_hash, display_name, account_status, id, username, password_hash, display_name, account_status,
failed_login_count, token_version, password_change_required, default_tenant_id, failed_login_count, token_version, password_change_required, default_tenant_id,
@@ -255,7 +290,6 @@ INSERT INTO auth_account (
(1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', (1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
'IT 测试客户管理员', 'ACTIVE', 0, 1, 0, 10001, 10001, 3000002, 'MERCHANT', 0) 'IT 测试客户管理员', 'ACTIVE', 0, 1, 0, 10001, 10001, 3000002, 'MERCHANT', 0)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
password_hash = VALUES(password_hash),
display_name = VALUES(display_name), display_name = VALUES(display_name),
account_status = 'ACTIVE', account_status = 'ACTIVE',
failed_login_count = 0, failed_login_count = 0,
@@ -404,6 +438,7 @@ CREATE TABLE IF NOT EXISTS iam_membership (
user_id BIGINT NOT NULL COMMENT 'IAM用户资料ID', user_id BIGINT NOT NULL COMMENT 'IAM用户资料ID',
member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型:EMPLOYEE企业员工等', member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型:EMPLOYEE企业员工等',
member_no VARCHAR(64) NULL COMMENT '客户企业内部成员编号', member_no VARCHAR(64) NULL COMMENT '客户企业内部成员编号',
overseas_warehouse_id BIGINT NULL COMMENT '海外仓PDA操作员绑定的唯一实际海外仓ID',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态:ACTIVE在职、DISABLED停用、LEFT已离开', status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态:ACTIVE在职、DISABLED停用、LEFT已离开',
joined_at DATETIME(3) NULL COMMENT '加入客户企业时间', joined_at DATETIME(3) NULL COMMENT '加入客户企业时间',
left_at DATETIME(3) NULL COMMENT '离开客户企业时间', left_at DATETIME(3) NULL COMMENT '离开客户企业时间',
@@ -418,6 +453,13 @@ CREATE TABLE IF NOT EXISTS iam_membership (
KEY idx_membership_tenant (tenant_id, status) KEY idx_membership_tenant (tenant_id, status)
) ENGINE=InnoDB COMMENT='客户成员关系表:表示用户在客户企业中的成员身份,是分配企业客户端角色的主体'; ) ENGINE=InnoDB COMMENT='客户成员关系表:表示用户在客户企业中的成员身份,是分配企业客户端角色的主体';
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='iam_membership' AND column_name='overseas_warehouse_id'),
'SELECT 1', 'ALTER TABLE iam_membership ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''海外仓PDA操作员绑定的唯一实际海外仓ID'' AFTER member_no'));
PREPARE iam_membership_overseas_warehouse_stmt FROM @ddl;
EXECUTE iam_membership_overseas_warehouse_stmt;
DEALLOCATE PREPARE iam_membership_overseas_warehouse_stmt;
CREATE TABLE IF NOT EXISTS iam_role ( CREATE TABLE IF NOT EXISTS iam_role (
id BIGINT NOT NULL COMMENT '角色ID', id BIGINT NOT NULL COMMENT '角色ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '角色作用域:0为平台预置模板,正数为客户ID和数据隔离键', tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '角色作用域:0为平台预置模板,正数为客户ID和数据隔离键',
@@ -541,6 +583,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
container_mode VARCHAR(24) NULL COMMENT '装运方式:FULL_CONTAINER整柜、LCL散货拼箱', container_mode VARCHAR(24) NULL COMMENT '装运方式:FULL_CONTAINER整柜、LCL散货拼箱',
customs_inspection TINYINT(1) NULL COMMENT '是否需要商检:0否、1是', customs_inspection TINYINT(1) NULL COMMENT '是否需要商检:0否、1是',
destination_type VARCHAR(24) NULL COMMENT '目的仓类型:FBA亚马逊仓、OVERSEAS海外仓、PRIVATE私人地址', destination_type VARCHAR(24) NULL COMMENT '目的仓类型:FBA亚马逊仓、OVERSEAS海外仓、PRIVATE私人地址',
fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发',
amazon_warehouse_code VARCHAR(32) NULL COMMENT 'Amazon目的仓编码', amazon_warehouse_code VARCHAR(32) NULL COMMENT 'Amazon目的仓编码',
shipment_id VARCHAR(64) NULL COMMENT '订单级Amazon Shipment ID', shipment_id VARCHAR(64) NULL COMMENT '订单级Amazon Shipment ID',
reference_id VARCHAR(64) NULL COMMENT '订单级Amazon Reference ID', reference_id VARCHAR(64) NULL COMMENT '订单级Amazon Reference ID',
@@ -562,6 +605,26 @@ CREATE TABLE IF NOT EXISTS ord_order (
KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id)
) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'; ) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态';
SET @order_fulfillment_type_sql = IF(
EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order'
AND column_name = 'fulfillment_type'
),
'SELECT 1',
'ALTER TABLE baiyue_order.ord_order ADD COLUMN fulfillment_type VARCHAR(32) NULL COMMENT ''履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'' AFTER destination_type'
);
PREPARE order_fulfillment_type_stmt FROM @order_fulfillment_type_sql;
EXECUTE order_fulfillment_type_stmt;
DEALLOCATE PREPARE order_fulfillment_type_stmt;
UPDATE baiyue_order.ord_order
SET fulfillment_type = 'STANDARD'
WHERE fulfillment_type IS NULL OR fulfillment_type = '';
ALTER TABLE baiyue_order.ord_order
MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD'
COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发';
-- 既有开发库可能仍保留草稿入仓码 NOT NULL 约束;必须先放宽再清理草稿入仓码。 -- 既有开发库可能仍保留草稿入仓码 NOT NULL 约束;必须先放宽再清理草稿入仓码。
SET @order_inbound_code_nullable_sql = IF( SET @order_inbound_code_nullable_sql = IF(
EXISTS( EXISTS(
@@ -2489,6 +2552,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
delivery_warehouse_id BIGINT UNSIGNED NULL COMMENT '交货仓逻辑ID', delivery_warehouse_id BIGINT UNSIGNED NULL COMMENT '交货仓逻辑ID',
delivery_warehouse_name VARCHAR(128) NULL COMMENT '交货仓名称快照', delivery_warehouse_name VARCHAR(128) NULL COMMENT '交货仓名称快照',
business_type VARCHAR(32) NOT NULL COMMENT '业务类型', business_type VARCHAR(32) NOT NULL COMMENT '业务类型',
fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发',
channel_code VARCHAR(64) NULL COMMENT '渠道编码', channel_code VARCHAR(64) NULL COMMENT '渠道编码',
channel_name VARCHAR(128) NULL COMMENT '渠道名称快照', channel_name VARCHAR(128) NULL COMMENT '渠道名称快照',
carrier_id BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID', carrier_id BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID',
@@ -2539,6 +2603,26 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id) CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id)
) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照'; ) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照';
SET @inbound_fulfillment_type_sql = IF(
EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order'
AND column_name = 'fulfillment_type'
),
'SELECT 1',
'ALTER TABLE fl_inbound_order ADD COLUMN fulfillment_type VARCHAR(32) NULL COMMENT ''履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'' AFTER business_type'
);
PREPARE inbound_fulfillment_type_stmt FROM @inbound_fulfillment_type_sql;
EXECUTE inbound_fulfillment_type_stmt;
DEALLOCATE PREPARE inbound_fulfillment_type_stmt;
UPDATE fl_inbound_order
SET fulfillment_type = 'STANDARD'
WHERE fulfillment_type IS NULL OR fulfillment_type = '';
ALTER TABLE fl_inbound_order
MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD'
COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发';
SET @inbound_global_code_index_exists := ( SET @inbound_global_code_index_exists := (
SELECT COUNT(*) FROM information_schema.statistics SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order'
@@ -4281,8 +4365,10 @@ CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_id');
CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_name'); CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_name');
CALL unified_assert_column('baiyue_order', 'ord_order', 'pricing_tax_mode'); CALL unified_assert_column('baiyue_order', 'ord_order', 'pricing_tax_mode');
CALL unified_assert_column('baiyue_order', 'ord_order', 'inbound_code'); CALL unified_assert_column('baiyue_order', 'ord_order', 'inbound_code');
CALL unified_assert_column('baiyue_order', 'ord_order', 'fulfillment_type');
CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id');
CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'pricing_tax_mode'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'pricing_tax_mode');
CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'fulfillment_type');
CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_measurement_item', 'external_detail_no'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_measurement_item', 'external_detail_no');
CALL unified_assert_table('baiyue_firstleg', 'fl_price_channel_catalog'); CALL unified_assert_table('baiyue_firstleg', 'fl_price_channel_catalog');
CALL unified_assert_table('baiyue_firstleg', 'fl_price_origin_alias'); CALL unified_assert_table('baiyue_firstleg', 'fl_price_origin_alias');
@@ -5602,14 +5688,23 @@ BEGIN
SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3)); SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3));
ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN
IF EXISTS (SELECT 1 FROM fl_loading_plan_item item IF EXISTS (SELECT 1 FROM fl_loading_plan_item item
LEFT JOIN fl_loading_pick_item pick_item
ON pick_item.loading_plan_item_id = item.id
LEFT JOIN fl_loading_pick_task pick_task
ON pick_task.id = pick_item.pick_task_id
AND pick_task.loading_plan_id = OLD.id
WHERE item.loading_plan_id = OLD.id AND item.deleted = 0 WHERE item.loading_plan_id = OLD.id AND item.deleted = 0
AND (pick_task.id IS NULL AND NOT EXISTS (
OR pick_item.picked_package_count <> item.package_count)) THEN SELECT 1 FROM fl_loading_pick_item pick_item
JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id
WHERE pick_item.loading_plan_item_id = item.id
AND pick_task.loading_plan_id = OLD.id
AND pick_task.task_status = 'LOADING'
AND (
(pick_item.item_status = 'PICKED'
AND pick_item.picked_package_count = item.package_count)
OR (pick_item.item_status = 'PARTIAL'
AND pick_item.picked_package_count > 0
AND pick_item.picked_package_count = item.package_count)
OR (pick_item.item_status = 'SKIPPED'
AND pick_item.picked_package_count = 0)
)
)) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜';
END IF; END IF;
SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3)); SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3));
@@ -7492,6 +7587,9 @@ CREATE TABLE IF NOT EXISTS fl_preload_plan (
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增',
items_json JSON NOT NULL COMMENT '可编辑分柜明细JSON快照;人工确认后作为预装依据', items_json JSON NOT NULL COMMENT '可编辑分柜明细JSON快照;人工确认后作为预装依据',
remark VARCHAR(1000) NULL COMMENT '预装备注', remark VARCHAR(1000) NULL COMMENT '预装备注',
confirmed_by BIGINT UNSIGNED NULL COMMENT '确认预装的操作人ID',
confirmed_by_name VARCHAR(64) NULL COMMENT '确认预装的操作人姓名快照',
confirmed_at DATETIME(3) NULL COMMENT '预装确认时间',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建预装清单的操作人ID', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建预装清单的操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照', created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
@@ -7504,6 +7602,30 @@ CREATE TABLE IF NOT EXISTS fl_preload_plan (
CONSTRAINT fk_preload_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) CONSTRAINT fk_preload_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='订单分柜预装清单'; ) ENGINE=InnoDB COMMENT='订单分柜预装清单';
DROP PROCEDURE IF EXISTS migrate_preload_confirmation_columns;
DELIMITER $$
CREATE PROCEDURE migrate_preload_confirmation_columns()
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='fl_preload_plan' AND column_name='confirmed_by') THEN
ALTER TABLE fl_preload_plan
ADD COLUMN confirmed_by BIGINT UNSIGNED NULL COMMENT '确认预装的操作人ID' AFTER remark;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='fl_preload_plan' AND column_name='confirmed_by_name') THEN
ALTER TABLE fl_preload_plan
ADD COLUMN confirmed_by_name VARCHAR(64) NULL COMMENT '确认预装的操作人姓名快照' AFTER confirmed_by;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='fl_preload_plan' AND column_name='confirmed_at') THEN
ALTER TABLE fl_preload_plan
ADD COLUMN confirmed_at DATETIME(3) NULL COMMENT '预装确认时间' AFTER confirmed_by_name;
END IF;
END$$
DELIMITER ;
CALL migrate_preload_confirmation_columns();
DROP PROCEDURE IF EXISTS migrate_preload_confirmation_columns;
CREATE TABLE IF NOT EXISTS fl_split_operation ( CREATE TABLE IF NOT EXISTS fl_split_operation (
id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录ID', id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
@@ -10428,6 +10550,26 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation (
CONSTRAINT chk_carrier_schedule_operation_status CHECK (status IN ('PROCESSING','SUCCESS')) CONSTRAINT chk_carrier_schedule_operation_status CHECK (status IN ('PROCESSING','SUCCESS'))
) ENGINE=InnoDB COMMENT='船司查询写操作幂等与请求摘要'; ) ENGINE=InnoDB COMMENT='船司查询写操作幂等与请求摘要';
-- CREATE TABLE IF NOT EXISTS 不会更新已有表的 CHECK。历史开发库仍可能只允许
-- CREATE/ATTEMPT/MANUAL_RESULT,必须在人工节点和自动节点写入前收敛为当前动作集。
DROP PROCEDURE IF EXISTS migrate_carrier_schedule_history_action_check;
DELIMITER $$
CREATE PROCEDURE migrate_carrier_schedule_history_action_check()
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_query_history'
AND constraint_name='chk_carrier_schedule_history_action' AND constraint_type='CHECK') THEN
ALTER TABLE fl_carrier_schedule_query_history DROP CHECK chk_carrier_schedule_history_action;
END IF;
ALTER TABLE fl_carrier_schedule_query_history
ADD CONSTRAINT chk_carrier_schedule_history_action CHECK
(action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','MANUAL_NODE','NODE_OBSERVED',
'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP'));
END$$
DELIMITER ;
CALL migrate_carrier_schedule_history_action_check();
DROP PROCEDURE IF EXISTS migrate_carrier_schedule_history_action_check;
DROP TRIGGER IF EXISTS trg_carrier_schedule_query_before_insert; DROP TRIGGER IF EXISTS trg_carrier_schedule_query_before_insert;
DELIMITER $$ DELIMITER $$
CREATE TRIGGER trg_carrier_schedule_query_before_insert BEFORE INSERT ON fl_carrier_schedule_query FOR EACH ROW CREATE TRIGGER trg_carrier_schedule_query_before_insert BEFORE INSERT ON fl_carrier_schedule_query FOR EACH ROW
@@ -10862,6 +11004,7 @@ DELETE role_permission
WHERE role.tenant_id = 0 WHERE role.tenant_id = 0
AND role.app_code = 'PLATFORM' AND role.app_code = 'PLATFORM'
AND role.role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY') AND role.role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY')
AND NOT (role.role_code = 'WAREHOUSE_PICKER' AND permission.permission_code = 'lastleg:pda:operate')
AND expected.permission_code IS NULL; AND expected.permission_code IS NULL;
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
@@ -12342,7 +12485,7 @@ CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'retry_count',
CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'source_region', '来源区域'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'source_region', '来源区域');
CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'status', '消费状态'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'status', '消费状态');
CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'target_region', '目标区域'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'target_region', '目标区域');
CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'tenant_id', '客户或业务隔离标识;跨区域事件沿用来源系统口径'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'customer_id', '客户或业务隔离标识;跨区域事件沿用来源系统口径');
CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'trace_id', '链路标识'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'trace_id', '链路标识');
CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'aggregate_id', '聚合标识'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'aggregate_id', '聚合标识');
CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'aggregate_type', '聚合类型'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'aggregate_type', '聚合类型');
@@ -12367,7 +12510,7 @@ CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'retry_count',
CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'source_region', '来源区域'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'source_region', '来源区域');
CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'status', '投递状态'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'status', '投递状态');
CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'target_region', '目标区域'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'target_region', '目标区域');
CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'tenant_id', '客户或业务隔离标识;跨区域事件沿用来源系统口径'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'customer_id', '客户或业务隔离标识;跨区域事件沿用来源系统口径');
CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'trace_id', '链路标识'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'trace_id', '链路标识');
CALL queue_column_comment('baiyue_file', 'file_download_log', 'client_ip', '客户端IP'); CALL queue_column_comment('baiyue_file', 'file_download_log', 'client_ip', '客户端IP');
CALL queue_column_comment('baiyue_file', 'file_download_log', 'downloaded_at', '下载时间'); CALL queue_column_comment('baiyue_file', 'file_download_log', 'downloaded_at', '下载时间');
@@ -16166,6 +16309,7 @@ VALUES
(5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0), (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0),
(5107013,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:fba-appointment','FBA预约',NULL,'POST',713,'ACTIVE',1,0), (5107013,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:fba-appointment','FBA预约',NULL,'POST',713,'ACTIVE',1,0),
(5107014,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:smart-dispatch','智能排车',NULL,'POST',714,'ACTIVE',1,0), (5107014,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:smart-dispatch','智能排车',NULL,'POST',714,'ACTIVE',1,0),
(5107015,0,'PLATFORM','BUTTON',5107010,'lastleg:pda:operate','海外仓PDA入仓作业',NULL,'POST',715,'ACTIVE',1,0),
(5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,0), (5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,0),
(5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,0), (5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,0),
(5107022,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:status','变更海外换标单状态',NULL,'PUT',722,'ACTIVE',1,0), (5107022,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:status','变更海外换标单状态',NULL,'PUT',722,'ACTIVE',1,0),
@@ -16218,10 +16362,123 @@ INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permissio
SELECT 0, role.id, permission.id, NOW(3) SELECT 0, role.id, permission.id, NOW(3)
FROM baiyue_iam.iam_role role FROM baiyue_iam.iam_role role
JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.id BETWEEN 5107000 AND 5107299 AND permission.id BETWEEN 5107000 AND 5107299 AND permission.permission_code<>'lastleg:pda:operate'
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
DELETE role_permission FROM baiyue_iam.iam_role_permission role_permission
JOIN baiyue_iam.iam_role role ON role.id=role_permission.role_id AND role.tenant_id=role_permission.tenant_id
JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id AND permission.tenant_id=role_permission.tenant_id
WHERE permission.permission_code='lastleg:pda:operate'
AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER');
INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, NOW(3)
FROM baiyue_iam.iam_role role
JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code='lastleg:pda:operate'
WHERE role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER'
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
DROP PROCEDURE IF EXISTS baiyue_iam.assert_overseas_pda_picker_permission;
DELIMITER $$
CREATE PROCEDURE baiyue_iam.assert_overseas_pda_picker_permission()
BEGIN
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission grant_row
JOIN baiyue_iam.iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
JOIN baiyue_iam.iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id
WHERE permission.permission_code='lastleg:pda:operate'
AND role.id=4000005 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER') <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:WAREHOUSE_PICKER未精确授权';
END IF;
IF EXISTS (SELECT 1 FROM baiyue_iam.iam_role_permission grant_row
JOIN baiyue_iam.iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
JOIN baiyue_iam.iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id
WHERE permission.permission_code='lastleg:pda:operate'
AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:存在非仓库拣货员授权';
END IF;
END$$
DELIMITER ;
CALL baiyue_iam.assert_overseas_pda_picker_permission();
DROP PROCEDURE baiyue_iam.assert_overseas_pda_picker_permission;
-- 四期仓库后台角色严格最小授权:list 同时覆盖列表与详情,只读角色不得获得任何写权限。
USE baiyue_iam;
DROP TEMPORARY TABLE IF EXISTS tmp_phase4_warehouse_role_permission;
CREATE TEMPORARY TABLE tmp_phase4_warehouse_role_permission (
role_code VARCHAR(64) NOT NULL,
permission_code VARCHAR(160) NOT NULL,
PRIMARY KEY (role_code, permission_code)
);
INSERT INTO tmp_phase4_warehouse_role_permission (role_code, permission_code) VALUES
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:last-mile-order:list'),
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:inbound-order:list'),
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:relabel-order:list'),
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:inventory:list'),
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:warehouse:list'),
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:location:list'),
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:pallet:list'),
('WAREHOUSE_OPERATOR','platform:overseas-business:purchase:list'),
('WAREHOUSE_OPERATOR','platform:overseas-business:truck:list'),
('WAREHOUSE_OPERATOR','platform:overseas-business:looseboard:list'),
('WAREHOUSE_TALLY','platform:overseas-warehouse:last-mile-order:list'),
('WAREHOUSE_TALLY','platform:overseas-warehouse:inbound-order:list'),
('WAREHOUSE_TALLY','platform:overseas-warehouse:relabel-order:list'),
('WAREHOUSE_TALLY','platform:overseas-warehouse:inventory:list'),
('WAREHOUSE_TALLY','platform:overseas-warehouse:warehouse:list'),
('WAREHOUSE_TALLY','platform:overseas-warehouse:location:list'),
('WAREHOUSE_TALLY','platform:overseas-warehouse:pallet:list'),
('WAREHOUSE_TALLY','platform:overseas-business:purchase:list'),
('WAREHOUSE_TALLY','platform:overseas-business:truck:list'),
('WAREHOUSE_TALLY','platform:overseas-business:looseboard:list');
DELETE grant_row FROM baiyue_iam.iam_role_permission grant_row
JOIN baiyue_iam.iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
JOIN baiyue_iam.iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id
LEFT JOIN tmp_phase4_warehouse_role_permission expected
ON expected.role_code=role.role_code AND expected.permission_code=permission.permission_code
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
AND role.role_code IN ('WAREHOUSE_OPERATOR','WAREHOUSE_PICKER','WAREHOUSE_TALLY')
AND permission.permission_code LIKE 'platform:overseas-%'
AND expected.permission_code IS NULL;
INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, NOW(3)
FROM tmp_phase4_warehouse_role_permission expected
JOIN baiyue_iam.iam_role role ON role.tenant_id=0 AND role.app_code='PLATFORM'
AND role.role_code=expected.role_code AND role.status='ACTIVE' AND role.deleted=0
JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code=expected.permission_code
AND permission.status='ACTIVE' AND permission.deleted=0;
DROP PROCEDURE IF EXISTS baiyue_iam.assert_phase4_warehouse_role_minimum;
DELIMITER $$
CREATE PROCEDURE baiyue_iam.assert_phase4_warehouse_role_minimum()
BEGIN
IF (SELECT COUNT(*) FROM tmp_phase4_warehouse_role_permission) <> 20 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库角色最小权限矩阵数量异常';
END IF;
IF EXISTS (SELECT 1 FROM tmp_phase4_warehouse_role_permission
WHERE permission_code REGEXP ':(create|edit|status|export|adjust|delete|review)$') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库只读角色错误获得写权限';
END IF;
IF EXISTS (SELECT 1 FROM tmp_phase4_warehouse_role_permission expected
LEFT JOIN baiyue_iam.iam_role role ON role.tenant_id=0 AND role.app_code='PLATFORM'
AND role.role_code=expected.role_code AND role.status='ACTIVE' AND role.deleted=0
LEFT JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code=expected.permission_code AND permission.status='ACTIVE' AND permission.deleted=0
LEFT JOIN baiyue_iam.iam_role_permission grant_row ON grant_row.tenant_id=0
AND grant_row.role_id=role.id AND grant_row.permission_id=permission.id
WHERE grant_row.role_id IS NULL) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库角色最小权限授权不完整';
END IF;
END$$
DELIMITER ;
CALL baiyue_iam.assert_phase4_warehouse_role_minimum();
DROP PROCEDURE baiyue_iam.assert_phase4_warehouse_role_minimum;
DROP TEMPORARY TABLE tmp_phase4_warehouse_role_permission;
INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at) INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, NOW(3) SELECT 0, role.id, permission.id, NOW(3)
FROM baiyue_iam.iam_role role FROM baiyue_iam.iam_role role
+567 -6
View File
@@ -95,7 +95,8 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注',
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID',
review_remark VARCHAR(1000) NULL COMMENT '审核备注', reviewed_at DATETIME(3) NULL COMMENT '审核时间', review_remark VARCHAR(1000) NULL COMMENT '审核备注', review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键',
review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希', reviewed_at DATETIME(3) NULL COMMENT '审核时间',
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_overseas_procurement_order_no(customer_id,order_no), PRIMARY KEY(id), UNIQUE KEY uk_ll_overseas_procurement_order_no(customer_id,order_no),
@@ -104,15 +105,32 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at) KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at)
) ENGINE=InnoDB COMMENT='海外采购订单'; ) ENGINE=InnoDB COMMENT='海外采购订单';
SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_idempotency_key'
), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER review_remark'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_request_hash'
), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history ( CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history (
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE', from_status VARCHAR(32) NULL COMMENT '变更前状态', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE', from_status VARCHAR(32) NULL COMMENT '变更前状态',
to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
KEY idx_ll_overseas_procurement_history(customer_id,order_id,created_at) KEY idx_ll_overseas_procurement_history(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='海外采购操作流水'; ) ENGINE=InnoDB COMMENT='海外采购操作流水';
SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='idempotency_key'
), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_status_history ADD COLUMN idempotency_key VARCHAR(255) NULL COMMENT ''操作幂等键'' AFTER operator_id'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_us_truck_order ( CREATE TABLE IF NOT EXISTS ll_us_truck_order (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_no VARCHAR(64) NOT NULL COMMENT '订单号', id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_no VARCHAR(64) NOT NULL COMMENT '订单号',
transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运地址', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运地址',
@@ -146,11 +164,32 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet (
CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history ( CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history (
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operation_type VARCHAR(64) NOT NULL COMMENT '具名操作类型', from_status VARCHAR(32) NULL COMMENT '变更前状态',
to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键',
remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
KEY idx_ll_us_truck_history(customer_id,order_id,created_at) KEY idx_ll_us_truck_history(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='美国卡车订单状态历史'; ) ENGINE=InnoDB COMMENT='美国卡车订单状态历史';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_us_truck_order_status_history' AND column_name='operation_type'), 'SELECT 1',
'ALTER TABLE ll_us_truck_order_status_history ADD COLUMN operation_type VARCHAR(64) NOT NULL DEFAULT ''UPDATE'' COMMENT ''具名操作类型'' AFTER customer_id'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_us_truck_order_status_history' AND column_name='idempotency_key'), 'SELECT 1',
'ALTER TABLE ll_us_truck_order_status_history ADD COLUMN idempotency_key VARCHAR(255) NULL COMMENT ''操作幂等键'' AFTER operator_id'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_us_truck_order_operation (
id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '美国卡车订单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(64) NOT NULL COMMENT '具名操作类型',
idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作请求内容哈希',
result_version BIGINT NOT NULL COMMENT '操作完成后的订单版本', created_by VARCHAR(128) NULL COMMENT '操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_us_truck_operation_key(customer_id,order_id,idempotency_key),
KEY idx_ll_us_truck_operation_order(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='美国卡车订单幂等操作账';
CREATE TABLE IF NOT EXISTS ll_local_looseboard_order ( CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', lastleg_order_id BIGINT NULL COMMENT '关联尾程主订单ID', order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', lastleg_order_id BIGINT NULL COMMENT '关联尾程主订单ID', order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号',
transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载', pallet_required VARCHAR(8) NOT NULL COMMENT '是否需要打盘:YES或NO', transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载', pallet_required VARCHAR(8) NOT NULL COMMENT '是否需要打盘:YES或NO',
@@ -182,11 +221,26 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_history (
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、UPDATE或STATUS', from_status VARCHAR(32) NULL COMMENT '变更前状态', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、UPDATE或STATUS', from_status VARCHAR(32) NULL COMMENT '变更前状态',
to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注', idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
KEY idx_ll_local_looseboard_history(customer_id,order_id,created_at) KEY idx_ll_local_looseboard_history(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='本土散板操作流水'; ) ENGINE=InnoDB COMMENT='本土散板操作流水';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_local_looseboard_history' AND column_name='idempotency_key'), 'SELECT 1',
'ALTER TABLE ll_local_looseboard_history ADD COLUMN idempotency_key VARCHAR(255) NULL COMMENT ''操作幂等键'' AFTER operator_id'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_local_looseboard_operation (
id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型',
idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作请求内容哈希',
result_version BIGINT NOT NULL COMMENT '操作完成后的订单版本', created_by VARCHAR(128) NULL COMMENT '操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
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='本土散板订单幂等操作账';
-- 尾程主订单状态历史补齐客户隔离键。代码写入 customer_id,已有表需显式升级; -- 尾程主订单状态历史补齐客户隔离键。代码写入 customer_id,已有表需显式升级;
-- 先允许 NULL 以便从主订单回填,再收紧为 NOT NULL,脚本可重复执行。 -- 先允许 NULL 以便从主订单回填,再收紧为 NOT NULL,脚本可重复执行。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
@@ -204,6 +258,22 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE
'ALTER TABLE ll_loading_order_status_history ADD KEY idx_ll_loading_history_customer_order(customer_id,order_id,created_at)')); 'ALTER TABLE ll_loading_order_status_history ADD KEY idx_ll_loading_history_customer_order(customer_id,order_id,created_at)'));
PREPARE phase4_loading_history_customer_stmt FROM @ddl; EXECUTE phase4_loading_history_customer_stmt; DEALLOCATE PREPARE phase4_loading_history_customer_stmt; PREPARE phase4_loading_history_customer_stmt FROM @ddl; EXECUTE phase4_loading_history_customer_stmt; DEALLOCATE PREPARE phase4_loading_history_customer_stmt;
-- 尾程订单明细实体、事件消费者和详情查询均使用 customer_id;已有开发库需从主订单回填。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_order_detail' AND column_name='customer_id'), 'SELECT 1',
'ALTER TABLE ll_order_detail ADD COLUMN customer_id VARCHAR(128) NULL COMMENT ''客户数据隔离键'' AFTER id'));
PREPARE phase4_order_detail_customer_stmt FROM @ddl; EXECUTE phase4_order_detail_customer_stmt; DEALLOCATE PREPARE phase4_order_detail_customer_stmt;
UPDATE ll_order_detail detail
JOIN ll_order orders ON orders.id=detail.order_id
SET detail.customer_id=orders.customer_id
WHERE detail.customer_id IS NULL;
ALTER TABLE ll_order_detail
MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='ll_order_detail' AND index_name='idx_ll_detail_customer_order'), 'SELECT 1',
'ALTER TABLE ll_order_detail ADD KEY idx_ll_detail_customer_order(customer_id,order_id,id)'));
PREPARE phase4_order_detail_customer_stmt FROM @ddl; EXECUTE phase4_order_detail_customer_stmt; DEALLOCATE PREPARE phase4_order_detail_customer_stmt;
-- 四期平台排车查询、智能预排与草稿锁;不开放编辑、发车、PDA 出库或派送。 -- 四期平台排车查询、智能预排与草稿锁;不开放编辑、发车、PDA 出库或派送。
CREATE TABLE IF NOT EXISTS ll_dispatch_order ( CREATE TABLE IF NOT EXISTS ll_dispatch_order (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
@@ -303,7 +373,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton (
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID',
line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID', line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID',
warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID', warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID',
status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、BULK_RECEIVED、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间', bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark), PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark),
@@ -315,6 +385,220 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id'));
PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt; PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt;
-- 头程 V2 事件冻结目的类型;只补结构,不由部署脚本自动执行历史数据回填。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'),
'ALTER TABLE ll_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''',
'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type'));
PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'),
'SELECT 1',
'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id'));
PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt;
UPDATE ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id SET l.customer_id=p.customer_id WHERE l.customer_id IS NULL OR l.customer_id='';
ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND index_name='idx_ll_inbound_line_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD KEY idx_ll_inbound_line_customer_plan(customer_id,plan_id)'));
PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='customer_id'),
'SELECT 1',
'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id'));
PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt;
UPDATE ll_inbound_plan_pallet v JOIN ll_inbound_plan p ON p.id=v.plan_id SET v.customer_id=p.customer_id WHERE v.customer_id IS NULL OR v.customer_id='';
ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND index_name='idx_ll_inbound_pallet_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD KEY idx_ll_inbound_pallet_customer_plan(customer_id,plan_id)'));
PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='customer_id'),
'SELECT 1',
'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id'));
PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt;
UPDATE ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan p ON p.id=a.plan_id SET a.customer_id=p.customer_id WHERE a.customer_id IS NULL OR a.customer_id='';
ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND index_name='idx_ll_inbound_allocation_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD KEY idx_ll_inbound_allocation_customer_plan(customer_id,plan_id)'));
PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt;
DROP PROCEDURE IF EXISTS assert_phase4_inbound_plan_customer_scope_ready;
DELIMITER $$
CREATE PROCEDURE assert_phase4_inbound_plan_customer_scope_ready()
BEGIN
IF EXISTS (SELECT 1 FROM ll_inbound_plan_line l LEFT JOIN ll_inbound_plan p ON p.id=l.plan_id WHERE l.customer_id IS NULL OR l.customer_id='' OR p.id IS NULL OR l.customer_id<>p.customer_id)
OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet v LEFT JOIN ll_inbound_plan p ON p.id=v.plan_id WHERE v.customer_id IS NULL OR v.customer_id='' OR p.id IS NULL OR v.customer_id<>p.customer_id)
OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a LEFT JOIN ll_inbound_plan p ON p.id=a.plan_id WHERE a.customer_id IS NULL OR a.customer_id='' OR p.id IS NULL OR a.customer_id<>p.customer_id) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Inbound plan child customer scope migration is incomplete';
END IF;
END$$
DELIMITER ;
CALL assert_phase4_inbound_plan_customer_scope_ready();
DROP PROCEDURE assert_phase4_inbound_plan_customer_scope_ready;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'),
'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码''',
'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码'' AFTER inbound_code'));
PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='destination_type'),
'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''',
'ALTER TABLE ll_inbound_plan_line ADD COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER inbound_code'));
PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt;
UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id
SET l.destination_type=COALESCE(NULLIF(o.destination_type,''),CASE WHEN NULLIF(l.fba_warehouse_code,'') IS NULL THEN 'OVERSEAS' ELSE 'FBA' END)
WHERE l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS';
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_id'),
'SELECT 1','ALTER TABLE ll_order ADD COLUMN source_warehouse_id VARCHAR(128) NULL COMMENT ''来源国内装柜仓ID快照'' AFTER customer_name'));
PREPARE phase4_source_warehouse_id_stmt FROM @ddl; EXECUTE phase4_source_warehouse_id_stmt; DEALLOCATE PREPARE phase4_source_warehouse_id_stmt;
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_name'),
'SELECT 1','ALTER TABLE ll_order ADD COLUMN source_warehouse_name VARCHAR(255) NULL COMMENT ''来源国内装柜仓名称快照'' AFTER source_warehouse_id'));
PREPARE phase4_source_warehouse_name_stmt FROM @ddl; EXECUTE phase4_source_warehouse_name_stmt; DEALLOCATE PREPARE phase4_source_warehouse_name_stmt;
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='overseas_warehouse_id'),
'SELECT 1','ALTER TABLE ll_inbound_plan ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''海外实际收货共享物理仓ID,由PDA认领柜号时冻结'' AFTER container_no'));
PREPARE phase4_plan_receiving_warehouse_stmt FROM @ddl; EXECUTE phase4_plan_receiving_warehouse_stmt; DEALLOCATE PREPARE phase4_plan_receiving_warehouse_stmt;
UPDATE ll_order o JOIN ll_loading_event_inbox i ON i.event_id=o.source_event_id
SET o.source_warehouse_id=COALESCE(o.source_warehouse_id,JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId'))),
o.source_warehouse_name=COALESCE(o.source_warehouse_name,JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseName'))),
o.overseas_warehouse_id=NULL,o.overseas_warehouse_name=NULL
WHERE o.current_status<>'INBOUND_COMPLETED'
AND o.overseas_warehouse_id=JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId'));
DROP PROCEDURE IF EXISTS assert_pending_orders_do_not_use_source_warehouse;
DELIMITER $$
CREATE PROCEDURE assert_pending_orders_do_not_use_source_warehouse()
BEGIN
IF EXISTS(SELECT 1 FROM ll_order o JOIN ll_loading_event_inbox i ON i.event_id=o.source_event_id
WHERE o.current_status<>'INBOUND_COMPLETED' AND o.overseas_warehouse_id IS NOT NULL
AND o.overseas_warehouse_id=JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId'))) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='未完成尾程单仍将国内来源仓冒充海外实际收货仓';
END IF;
END$$
DELIMITER ;
CALL assert_pending_orders_do_not_use_source_warehouse();
DROP PROCEDURE assert_pending_orders_do_not_use_source_warehouse;
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照',
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间',
receiving_at DATETIME(3) NULL COMMENT '首次收货时间', completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id),
UNIQUE KEY uk_ll_receiving_active_operator(active_operator_key), UNIQUE KEY uk_ll_receiving_active_container(active_container_key),
KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at)
) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话';
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_id'),
'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''操作员Token绑定并冻结的海外实际收货共享物理仓ID'' AFTER container_no'));
PREPARE phase4_session_receiving_warehouse_stmt FROM @ddl; EXECUTE phase4_session_receiving_warehouse_stmt; DEALLOCATE PREPARE phase4_session_receiving_warehouse_stmt;
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_name'),
'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''仓库服务校验后的海外实际收货共享物理仓名称快照'' AFTER overseas_warehouse_id'));
PREPARE phase4_session_receiving_warehouse_name_stmt FROM @ddl; EXECUTE phase4_session_receiving_warehouse_name_stmt; DEALLOCATE PREPARE phase4_session_receiving_warehouse_name_stmt;
DROP PROCEDURE IF EXISTS assert_active_receiving_session_has_warehouse;
DELIMITER $$
CREATE PROCEDURE assert_active_receiving_session_has_warehouse()
BEGIN
IF EXISTS(SELECT 1 FROM ll_receiving_container_session WHERE active_container_key IS NOT NULL
AND (overseas_warehouse_id IS NULL OR overseas_warehouse_name IS NULL OR overseas_warehouse_name='')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='存在无法安全推断海外实际收货仓的未完成PDA柜会话';
END IF;
END$$
DELIMITER ;
CALL assert_active_receiving_session_has_warehouse();
DROP PROCEDURE assert_active_receiving_session_has_warehouse;
ALTER TABLE ll_receiving_container_session
MODIFY COLUMN overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID',
MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='sign_photos'),
'ALTER TABLE ll_receiving_container_session MODIFY COLUMN sign_photos JSON NULL COMMENT ''签收卸柜照片角色与文件ID列表''',
'ALTER TABLE ll_receiving_container_session ADD COLUMN sign_photos JSON NULL COMMENT ''签收卸柜照片角色与文件ID列表'' AFTER status'));
PREPARE phase4_receiving_sign_photos_stmt FROM @ddl; EXECUTE phase4_receiving_sign_photos_stmt; DEALLOCATE PREPARE phase4_receiving_sign_photos_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='photo_file_ids'),
'ALTER TABLE ll_receiving_container_session DROP COLUMN photo_file_ids', 'SELECT 1'));
PREPARE phase4_receiving_drop_raw_photos_stmt FROM @ddl; EXECUTE phase4_receiving_drop_raw_photos_stmt; DEALLOCATE PREPARE phase4_receiving_drop_raw_photos_stmt;
DROP PROCEDURE IF EXISTS assert_receiving_global_locks_migratable;
UPDATE ll_receiving_container_session
SET active_operator_key=NULL,active_container_key=NULL
WHERE status='COMPLETED' AND (active_operator_key IS NOT NULL OR active_container_key IS NOT NULL);
DELIMITER $$
CREATE PROCEDURE assert_receiving_global_locks_migratable()
BEGIN
IF EXISTS (SELECT operator_key FROM ll_receiving_container_session
WHERE active_operator_key IS NOT NULL GROUP BY operator_key HAVING COUNT(*) > 1) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局账号锁迁移失败:同一账号存在多个未完成柜';
END IF;
IF EXISTS (SELECT UPPER(TRIM(container_no)) FROM ll_receiving_container_session
WHERE active_container_key IS NOT NULL GROUP BY UPPER(TRIM(container_no)) HAVING COUNT(*) > 1) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局柜号锁迁移失败:同一柜存在多个未完成会话';
END IF;
END$$
DELIMITER ;
CALL assert_receiving_global_locks_migratable();
DROP PROCEDURE assert_receiving_global_locks_migratable;
UPDATE ll_receiving_container_session
SET active_operator_key=CONCAT('OPERATOR:',operator_key),
active_container_key=CONCAT('CONTAINER:',UPPER(TRIM(container_no)))
WHERE active_operator_key IS NOT NULL OR active_container_key IS NOT NULL;
ALTER TABLE ll_receiving_container_session
MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识',
MODIFY COLUMN active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
MODIFY COLUMN active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键';
CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt (
id BIGINT NOT NULL COMMENT 'FBA SKU实收进度ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识',
session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID',
line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', pallet_id BIGINT NOT NULL COMMENT '计划托盘ID',
order_id BIGINT NOT NULL COMMENT '尾程订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码快照',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', product_name VARCHAR(255) NULL COMMENT '产品名称快照',
received_packages INT NOT NULL COMMENT 'SKU实收箱数', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id),
UNIQUE KEY uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code),
KEY idx_ll_receiving_sku_session (customer_id,session_id,line_id),
KEY idx_ll_receiving_sku_plan (customer_id,plan_id,line_id)
) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度';
CREATE TABLE IF NOT EXISTS ll_receiving_container_operation (
id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识',
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',
request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at)
) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录';
DROP PROCEDURE IF EXISTS assert_receiving_operation_global_key_migratable;
DELIMITER $$
CREATE PROCEDURE assert_receiving_operation_global_key_migratable()
BEGIN
IF EXISTS (SELECT operator_key,idempotency_key FROM ll_receiving_container_operation
GROUP BY operator_key,idempotency_key HAVING COUNT(*) > 1) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局幂等键迁移失败:同一操作账号存在重复幂等键';
END IF;
END$$
DELIMITER ;
CALL assert_receiving_operation_global_key_migratable();
DROP PROCEDURE assert_receiving_operation_global_key_migratable;
SET @receiving_operation_global_index = (
SELECT COUNT(*) FROM (
SELECT index_name FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_operation'
AND index_name='uk_ll_receiving_operation'
GROUP BY index_name
HAVING GROUP_CONCAT(column_name ORDER BY seq_in_index)= 'operator_key,idempotency_key'
) ready_index
);
SET @receiving_operation_named_index = (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_operation'
AND index_name='uk_ll_receiving_operation'
);
SET @ddl = IF(@receiving_operation_global_index=1, 'SELECT 1',
IF(@receiving_operation_named_index>0,
'ALTER TABLE ll_receiving_container_operation DROP INDEX uk_ll_receiving_operation, ADD UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key)',
'ALTER TABLE ll_receiving_container_operation ADD UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key)'));
PREPARE phase4_receiving_operation_index_stmt FROM @ddl; EXECUTE phase4_receiving_operation_index_stmt; DEALLOCATE PREPARE phase4_receiving_operation_index_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'),
'ALTER TABLE ll_inbound_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''',
'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name'));
PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='sub_order_status'), SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='sub_order_status'),
'SELECT 1', 'SELECT 1',
'ALTER TABLE ll_order ADD COLUMN sub_order_status VARCHAR(255) NULL COMMENT ''子单据状态汇总'' AFTER current_status')); 'ALTER TABLE ll_order ADD COLUMN sub_order_status VARCHAR(255) NULL COMMENT ''子单据状态汇总'' AFTER current_status'));
@@ -388,4 +672,281 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE
'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (customer_id,task_id,allocation_id,sku_code)')); 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (customer_id,task_id,allocation_id,sku_code)'));
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
SELECT 'PASS' AS migration_status, '海外仓报价、月度汇率、车辆主数据、海外业务与入仓单目的仓库存结构已就绪' AS message; -- 海运节点达到可提柜后才允许海外 PDA 认领;装柜完成只建立等待门禁快照。
CREATE TABLE IF NOT EXISTS ll_container_shipping_gate (
container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户标识',
ocean_booking_id VARCHAR(128) NOT NULL COMMENT '来源海运订舱ID',
container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号',
status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE',
standard_node VARCHAR(64) NULL COMMENT '最新有效标准船节点',
node_sequence INT NOT NULL DEFAULT 0 COMMENT '最新有效节点单调序号',
observed_at DATETIME(3) NULL COMMENT '供应商节点观测时间',
actual_at DATETIME(3) NULL COMMENT '节点实际发生时间',
location_code VARCHAR(128) NULL COMMENT '节点地点编码',
location_name VARCHAR(255) NULL COMMENT '节点地点名称',
provider_code VARCHAR(64) NULL COMMENT '船节点供应商编码',
provider_event_id VARCHAR(128) NULL COMMENT '供应商事件ID',
provider_node_code VARCHAR(128) NULL COMMENT '供应商原始节点编码',
source_reference VARCHAR(255) NULL COMMENT '供应商来源引用',
raw_payload_hash VARCHAR(64) NULL COMMENT '供应商原始负载SHA-256',
aggregate_version BIGINT NOT NULL DEFAULT 0 COMMENT '来源查询聚合版本',
handover_version BIGINT NOT NULL DEFAULT 1 COMMENT '跨区交接版本',
last_event_id VARCHAR(128) NULL COMMENT '最近采纳的跨区事件ID',
payload_hash VARCHAR(71) NULL COMMENT '最近采纳事件规范化负载摘要',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(container_booking_id),
KEY idx_ll_shipping_gate_candidate(customer_id,container_no,status),
KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence)
) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照';
INSERT IGNORE INTO ll_container_shipping_gate (
container_booking_id,customer_id,ocean_booking_id,container_no,status,
node_sequence,aggregate_version,handover_version,created_at,updated_at
)
SELECT source_container_booking_id,customer_id,'',container_no,'AWAITING_PICKUP_READY',
0,COALESCE(MAX(source_loading_plan_version),0),COALESCE(MAX(source_handover_version),1),NOW(3),NOW(3)
FROM ll_order
WHERE source_container_booking_id IS NOT NULL AND source_container_booking_id <> ''
AND container_no IS NOT NULL AND container_no <> ''
GROUP BY source_container_booking_id,customer_id,container_no;
CREATE TABLE IF NOT EXISTS ll_pda_operation_history (
id BIGINT NOT NULL COMMENT 'PDA作业历史ID',
overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID',
customer_id VARCHAR(128) NULL COMMENT '作业涉及的客户标识',
operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识',
operation_type VARCHAR(64) NOT NULL COMMENT '作业类型',
business_key VARCHAR(255) NOT NULL COMMENT '作业业务键',
status VARCHAR(32) NOT NULL COMMENT '作业结果状态',
idempotency_key VARCHAR(255) NULL COMMENT '写操作幂等键',
request_hash VARCHAR(128) NULL COMMENT '写请求内容哈希',
summary VARCHAR(1000) NULL COMMENT '作业结果摘要',
occurred_at DATETIME(3) NOT NULL COMMENT '作业发生时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY(id),
UNIQUE KEY uk_ll_pda_history_idempotency(operator_key,idempotency_key),
KEY idx_ll_pda_history_operator(overseas_warehouse_id,operator_key,occurred_at),
KEY idx_ll_pda_history_business(overseas_warehouse_id,operation_type,business_key)
) ENGINE=InnoDB COMMENT='海外PDA真实作业历史';
CREATE TABLE IF NOT EXISTS ll_pda_pallet_operation (
id BIGINT NOT NULL COMMENT 'PDA托盘作业ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID',
operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '设备幂等键',
operation_type VARCHAR(64) NOT NULL COMMENT '托盘作业类型', request_hash VARCHAR(128) NOT NULL COMMENT '请求内容摘要',
status VARCHAR(32) NOT NULL COMMENT '处理状态:PROCESSING、COMPLETED、FAILED_COMPENSATED、COMPENSATION_REQUIRED',
result_json LONGTEXT NULL COMMENT '成功响应快照', remote_transitions_json LONGTEXT NULL COMMENT '实物托盘状态变更回执',
last_error VARCHAR(2000) NULL COMMENT '最后失败原因', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_pallet_idempotency(operator_key,idempotency_key),
KEY idx_ll_pda_pallet_warehouse(overseas_warehouse_id,status,updated_at)
) ENGINE=InnoDB COMMENT='海外PDA托盘作业幂等、补偿与结果记录';
CREATE TABLE IF NOT EXISTS ll_pda_stocktake_task (
id BIGINT NOT NULL COMMENT '盘点任务ID', task_no VARCHAR(64) NOT NULL COMMENT '盘点任务号',
overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', scope_type VARCHAR(16) NOT NULL COMMENT '盘点范围类型LOCATION或PALLET',
scope_resource_id BIGINT NOT NULL COMMENT '盘点库位或托盘资源ID', scope_code VARCHAR(128) NOT NULL COMMENT '盘点库位码或托盘码快照',
status VARCHAR(32) NOT NULL COMMENT '盘点任务状态', position_count INT NOT NULL COMMENT '库存位置总数',
counted_count INT NOT NULL DEFAULT 0 COMMENT '已盘库存位置数', difference_count INT NOT NULL DEFAULT 0 COMMENT '存在差异的库存位置数',
created_by VARCHAR(128) NOT NULL COMMENT '创建操作账号标识', submitted_by VARCHAR(128) NULL COMMENT '提交操作账号标识',
submit_reason VARCHAR(500) NULL COMMENT '盘点差异提交原因', idempotency_key VARCHAR(128) NOT NULL COMMENT '创建幂等键',
request_hash VARCHAR(128) NOT NULL COMMENT '创建请求哈希', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
submitted_at DATETIME(3) NULL COMMENT '提交完成时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_no(task_no),
UNIQUE KEY uk_ll_pda_stocktake_create(overseas_warehouse_id,created_by,idempotency_key),
KEY idx_ll_pda_stocktake_list(overseas_warehouse_id,status,created_at)
) ENGINE=InnoDB COMMENT='海外PDA库存盘点任务';
CREATE TABLE IF NOT EXISTS ll_pda_stocktake_item (
id BIGINT NOT NULL COMMENT '盘点明细ID', task_id BIGINT NOT NULL COMMENT '盘点任务ID', customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户标识',
balance_id BIGINT NOT NULL COMMENT '库存余额ID', position_id BIGINT NOT NULL COMMENT '库存位置ID', sku_code VARCHAR(128) NULL COMMENT 'SKU快照',
product_name VARCHAR(255) NULL COMMENT '品名快照', inbound_code VARCHAR(128) NULL COMMENT '入仓码快照', pallet_id BIGINT NOT NULL COMMENT '实物托盘ID',
pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', location_id BIGINT NOT NULL COMMENT '库位ID快照', location_code VARCHAR(128) NOT NULL COMMENT '库位码快照',
book_packages BIGINT NOT NULL COMMENT '任务创建时账面总箱数', actual_packages BIGINT NULL COMMENT '现场实盘总箱数',
difference_packages BIGINT NULL COMMENT '实盘与账面箱数差异', status VARCHAR(32) NOT NULL COMMENT '盘点明细状态',
position_version BIGINT NOT NULL COMMENT '任务创建时库存位置版本', counted_by VARCHAR(128) NULL COMMENT '盘点操作账号标识',
version BIGINT NOT NULL DEFAULT 0 COMMENT '明细乐观锁版本', counted_at DATETIME(3) NULL COMMENT '盘点时间',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_item(task_id,position_id), KEY idx_ll_pda_stocktake_item_task(task_id,status,id)
) ENGINE=InnoDB COMMENT='海外PDA库存盘点位置明细';
CREATE TABLE IF NOT EXISTS ll_pda_stocktake_action (
id BIGINT NOT NULL COMMENT '盘点动作ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID',
operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '动作幂等键',
action_type VARCHAR(32) NOT NULL COMMENT '盘点动作类型', task_id BIGINT NULL COMMENT '盘点任务ID', request_hash VARCHAR(128) NOT NULL COMMENT '动作请求哈希',
status VARCHAR(32) NOT NULL COMMENT '动作处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_pda_stocktake_action(overseas_warehouse_id,operator_key,idempotency_key), KEY idx_ll_pda_stocktake_action_task(task_id,created_at)
) ENGINE=InnoDB COMMENT='海外PDA盘点动作幂等记录';
CREATE TABLE IF NOT EXISTS ll_pda_inventory_move (
id BIGINT NOT NULL COMMENT '移库操作ID', move_no VARCHAR(64) NOT NULL COMMENT '移库操作单号',
overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', pallet_id BIGINT NOT NULL COMMENT '整托移库的实物托盘ID',
pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', source_location_id BIGINT NOT NULL COMMENT '来源库位ID',
source_location_code VARCHAR(128) NOT NULL COMMENT '来源库位码快照', target_location_id BIGINT NOT NULL COMMENT '目标库位ID',
target_location_code VARCHAR(128) NOT NULL COMMENT '目标库位码快照', customer_count INT NOT NULL COMMENT '托盘涉及客户数',
position_count INT NOT NULL COMMENT '迁移库存位置数', package_count BIGINT NOT NULL COMMENT '迁移总箱数',
pallet_version_before INT NOT NULL COMMENT '迁移前托盘版本', pallet_version_after INT NULL COMMENT '迁移后托盘版本',
operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', reason VARCHAR(500) NOT NULL COMMENT '移库原因',
idempotency_key VARCHAR(128) NOT NULL COMMENT '移库幂等键', request_hash VARCHAR(128) NOT NULL COMMENT '移库请求哈希',
status VARCHAR(32) NOT NULL COMMENT '移库处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', completed_at DATETIME(3) NULL COMMENT '完成时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_pda_inventory_move_no(move_no),
UNIQUE KEY uk_ll_pda_inventory_move_key(overseas_warehouse_id,operator_key,idempotency_key),
KEY idx_ll_pda_inventory_move_pallet(overseas_warehouse_id,pallet_id,created_at)
) ENGINE=InnoDB COMMENT='海外PDA同仓整托移库记录';
CREATE TABLE IF NOT EXISTS ll_relabel_pda_task (
id BIGINT NOT NULL COMMENT '换标PDA任务ID', application_id BIGINT NOT NULL COMMENT '换标申请单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', relabel_no VARCHAR(64) NOT NULL COMMENT '换标申请单号快照',
overseas_warehouse_id BIGINT NOT NULL COMMENT 'PDA令牌冻结的海外实际作业仓ID',
status VARCHAR(32) NOT NULL COMMENT '任务状态:PROCESSING、WAIT_REVIEW或COMPLETED',
operator_key VARCHAR(128) NOT NULL COMMENT '认领操作账号标识', operator_name VARCHAR(255) NOT NULL COMMENT '认领操作员名称快照',
reviewer_key VARCHAR(128) NULL COMMENT '复核账号标识', reviewer_name VARCHAR(255) NULL COMMENT '复核员名称快照',
expected_packages INT NOT NULL COMMENT '申请单通知的应换标箱数', relabelled_packages INT NOT NULL DEFAULT 0 COMMENT '已正常换标箱数',
skipped_packages INT NOT NULL DEFAULT 0 COMMENT '已说明原因跳过箱数', exception_packages INT NOT NULL DEFAULT 0 COMMENT '已登记异常箱数',
completion_photo_file_ids JSON NULL COMMENT '换标完成现场照片文件ID列表', completion_remark VARCHAR(1000) NULL COMMENT '完成提交备注',
review_decision VARCHAR(16) NULL COMMENT '最近复核结论:APPROVE或REWORK', review_remark VARCHAR(1000) NULL COMMENT '最近复核备注',
claimed_at DATETIME(3) NOT NULL COMMENT '任务认领时间', submitted_at DATETIME(3) NULL COMMENT '提交复核时间',
reviewed_at DATETIME(3) NULL COMMENT '最近复核时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id), UNIQUE KEY uk_ll_relabel_pda_task_application (customer_id,application_id),
UNIQUE KEY uk_ll_relabel_pda_task_no (relabel_no), KEY idx_ll_relabel_pda_task_warehouse (overseas_warehouse_id,status,updated_at),
KEY idx_ll_relabel_pda_task_operator (operator_key,status,updated_at)
) ENGINE=InnoDB COMMENT='海外PDA换标现场作业任务';
CREATE TABLE IF NOT EXISTS ll_relabel_pda_unit_fact (
id BIGINT NOT NULL COMMENT '逐件换标事实ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID',
application_id BIGINT NOT NULL COMMENT '换标申请单ID', item_id BIGINT NOT NULL COMMENT '换标申请明细ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', old_label VARCHAR(255) NULL COMMENT '扫描的实物旧标签或箱标',
new_label VARCHAR(255) NULL COMMENT '扫描的换标后新标签', result VARCHAR(32) NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION',
exception_code VARCHAR(32) NULL COMMENT '异常编码', reason VARCHAR(1000) NULL COMMENT '跳过或异常原因',
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识', operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照',
occurred_at DATETIME(3) NOT NULL COMMENT '逐件事实发生时间', PRIMARY KEY (id),
UNIQUE KEY uk_ll_relabel_pda_old_label (old_label), KEY idx_ll_relabel_pda_unit_task (customer_id,task_id,occurred_at,id),
KEY idx_ll_relabel_pda_unit_item (customer_id,application_id,item_id)
) ENGINE=InnoDB COMMENT='海外PDA逐件换标扫码与异常事实';
CREATE TABLE IF NOT EXISTS ll_relabel_pda_operation (
id BIGINT NOT NULL COMMENT '换标PDA幂等操作ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', operator_key VARCHAR(128) NOT NULL COMMENT '请求操作账号标识',
action VARCHAR(32) NOT NULL COMMENT '幂等操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '设备生成的幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', result_json LONGTEXT NOT NULL COMMENT '成功响应快照JSON',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id),
UNIQUE KEY uk_ll_relabel_pda_operation_key (operator_key,idempotency_key),
KEY idx_ll_relabel_pda_operation_task (customer_id,task_id,created_at)
) ENGINE=InnoDB COMMENT='海外PDA换标写操作幂等记录';
CREATE TABLE IF NOT EXISTS ll_relabel_pda_history (
id BIGINT NOT NULL COMMENT '换标PDA任务历史ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID',
application_id BIGINT NOT NULL COMMENT '换标申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
action VARCHAR(32) NOT NULL COMMENT '现场动作类型', from_status VARCHAR(32) NULL COMMENT '动作前任务状态',
to_status VARCHAR(32) NOT NULL COMMENT '动作后任务状态', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识',
operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照', detail_json LONGTEXT NULL COMMENT '动作详情JSON',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id),
KEY idx_ll_relabel_pda_history_task (customer_id,task_id,created_at,id),
KEY idx_ll_relabel_pda_history_application (customer_id,application_id,created_at)
) ENGINE=InnoDB COMMENT='海外PDA换标任务不可变操作历史';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_cargo_discard_application' AND column_name='review_idempotency_key'),
'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER request_hash'));
PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_cargo_discard_application' AND column_name='review_request_hash'),
'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key'));
PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt;
-- Warehouse Overseas 与 Lastleg 使用同一海外区域发布入口但保持独立数据库。
-- 公共托盘详情实体读取 fba_warehouse_code;旧库缺列时创建可成功但详情会 S0500。
USE baiyue_lastlag_warehouse;
CREATE TABLE IF NOT EXISTS wh_pallet_operation (
id BIGINT NOT NULL COMMENT '托盘状态操作ID', operation_key VARCHAR(128) NOT NULL COMMENT '跨服务幂等操作键',
request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID',
pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照',
before_status VARCHAR(20) NOT NULL COMMENT '变更前托盘状态', after_status VARCHAR(20) NOT NULL COMMENT '变更后托盘状态',
before_location_id BIGINT NOT NULL COMMENT '变更前库位ID', after_location_id BIGINT NOT NULL COMMENT '变更后库位ID',
before_version INT NOT NULL COMMENT '变更前托盘版本', after_version INT NOT NULL COMMENT '变更后托盘版本',
operation_status VARCHAR(32) NOT NULL COMMENT '操作状态:APPLIED已应用、COMPENSATED已补偿',
reason VARCHAR(500) NULL COMMENT '操作原因', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_operation_key (operation_key),
KEY idx_wh_pallet_operation_pallet (warehouse_id,pallet_id,created_at)
) ENGINE=InnoDB COMMENT='实物托盘状态幂等变更与补偿记录';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='fba_warehouse_code'),
'SELECT 1',
'ALTER TABLE wh_pallet ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''所属FBA仓编码'' AFTER remark'));
PREPARE phase4_warehouse_pallet_stmt FROM @ddl;
EXECUTE phase4_warehouse_pallet_stmt;
DEALLOCATE PREPARE phase4_warehouse_pallet_stmt;
SET @warehouse_pallet_fba_ready = (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema='baiyue_lastlag_warehouse' AND table_name='wh_pallet'
AND column_name='fba_warehouse_code' AND column_type='varchar(128)');
DROP PROCEDURE IF EXISTS assert_phase4_warehouse_pallet_ready;
DELIMITER $$
CREATE PROCEDURE assert_phase4_warehouse_pallet_ready()
BEGIN
IF @warehouse_pallet_fba_ready <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='wh_pallet.fba_warehouse_code migration is incomplete';
END IF;
END$$
DELIMITER ;
CALL assert_phase4_warehouse_pallet_ready();
DROP PROCEDURE assert_phase4_warehouse_pallet_ready;
USE baiyue_lastleg;
DROP PROCEDURE IF EXISTS assert_phase4_pda_operations_ready;
DELIMITER $$
CREATE PROCEDURE assert_phase4_pda_operations_ready()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='baiyue_lastleg' AND table_name IN (
'll_pda_pallet_operation','ll_pda_stocktake_task','ll_pda_stocktake_item',
'll_pda_stocktake_action','ll_pda_inventory_move','ll_relabel_pda_task',
'll_relabel_pda_unit_fact','ll_relabel_pda_operation','ll_relabel_pda_history')) <> 9 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA盘点、移库、托盘或换标表未完整部署';
END IF;
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='baiyue_lastlag_warehouse' AND table_name='wh_pallet_operation') <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA实物托盘操作表未部署';
END IF;
END$$
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,
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,
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;
DELIMITER $$
CREATE PROCEDURE assert_overseas_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
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;
SELECT 'PASS' AS migration_status, '海外仓业务、海外仓资源与IAM安全投影结构已就绪' AS message;
@@ -0,0 +1,150 @@
# 同步调用依赖与失败传播治理基线
## 目的与口径
本基线覆盖后端服务之间的 OpenFeign、`RestClient`、`WebClient` 与 JDK `HttpClient` 调用。它用于识别同步依赖方向、在线请求深度、后台中继、超时、重试、熔断、幂等、错误映射和失败传播,不改变 Finance 或四期业务状态机。
“存在双向服务边”不等于“一次在线请求会递归调用”。每条边必须继续区分:
- `REQUEST`:用户或设备请求线程内,失败直接影响当前业务结果;
- `BACKGROUND`:Outbox、Inbox、定时器或导出 Worker 中的同步传输,业务事务已与传输解耦;
- `INFRASTRUCTURE`:审计、网关会话校验等基础设施调用;
- `EXTERNAL`:Integration 对第三方供应商的调用。
逐客户端可执行清单位于 `scripts/synchronous-dependency-baseline.mjs`,离线合同为 `scripts/synchronous-dependency-contract.test.mjs`。清单逐项冻结 44 个 Feign 与 16 个原始 HTTP 客户端的执行上下文、`READ/WRITE/READ_WRITE`、幂等类型和说明,并从同一事实源输出 REQUEST、BACKGROUND、INFRASTRUCTURE、EXTERNAL 与全图。新增客户端、目标服务、在线边、强连通分量或超时配置时,必须先更新基线并完成调用方与被调用方评审,不能仅修改测试期望以放行。
## 当前服务边
### OpenFeign
| 调用方 | 被调用方 | 客户端数 | 主要上下文 |
|---|---|---:|---|
| Auth | IAM | 2 | 登录授权、注册,`REQUEST` |
| IAM | Auth | 1 | 账号开通/管理,`REQUEST` |
| IAM | Firstleg | 1 | 客户尺寸规则,`REQUEST` |
| IAM | Warehouse | 1 | 海外仓储费协作,`REQUEST` |
| IAM | Event Bridge | 1 | 安全事件中继,`BACKGROUND` |
| Order | Firstleg | 3 | 计价/统一订单查询为 `REQUEST`,入库协作为 Outbox `BACKGROUND` |
| Order | IAM / File / Warehouse | 各 1 | 资格、图片、仓库守卫,`REQUEST` |
| Firstleg | Order | 2 | 货物查询与分拆回写,`REQUEST` |
| Firstleg | IAM / Finance / Integration / Event Bridge / Print | 各 1 | IAM、Integration、Print 为 `REQUEST`;Finance 与 Event Bridge 为 `BACKGROUND` 中继 |
| Firstleg | File | 7 | 测量、装柜、凭证、报关、拖车等,`REQUEST` |
| Firstleg | Warehouse | 4 | 主数据、供应商、库位/托盘,`REQUEST` |
| Finance | Firstleg | 2 | 费用来源/成本快照,事件消费与平台操作混合 |
| Finance | File / IAM / Warehouse | 各 1 | 凭证、客户、供应商校验,`REQUEST` |
| File | IAM | 2 | 资质登记为 `REQUEST`;导出 Provider 为 `BACKGROUND` |
| File | Order / Firstleg / Finance / Warehouse | 各 1 | 导出 Provider,`BACKGROUND` |
| File | Integration | 1 | OCR 在上传处理请求线程执行,`REQUEST` |
| Lastleg | Event Bridge | 1 | 跨区事件 Outbox 中继,`BACKGROUND` |
| Lastleg | 国内 Warehouse | 1 | 合作仓报价快照,`REQUEST` |
合计 44 个 Feign 客户端。公共默认连接/读取超时为 3 秒/5 秒;File 的五个导出 Provider 客户端读取超时单独为 15 秒。
### 原始 HTTP
| 调用方 | 被调用方 | 实现 | 上下文与当前策略 |
|---|---|---|---|
| Gateway Overseas | Auth | `HttpPdaSessionValidationClient` | `INFRASTRUCTURE`;总超时 2 秒,异常由网关失败关闭 |
| Gateway Overseas | Overseas Security | `HttpOverseasSecurityValidationClient` | `INFRASTRUCTURE`;安全投影实时校验,异常失败关闭,不进入在线业务图 |
| Lastleg | Warehouse Overseas | 两个 `RestClient` Gateway | `REQUEST`;分别使用 3 秒连接、5 秒读取超时,配置可覆盖,不自动重试 |
| Lastleg | File | 两个照片 `RestClient` Gateway | `REQUEST`;分别使用 3 秒连接、30 秒读取超时,配置可覆盖;校验响应 code 且失败关闭,不自动重试 |
| Event Bridge | Firstleg / Lastleg / Overseas Security | 三个 JDK `HttpClient` | `BACKGROUND`;连接 3 秒、请求 10 秒,持久化租约和退避由 Event Bridge 专项治理;Security 投影不进入在线业务图 |
| 启用 Common Audit 的服务 | Audit | `HttpAuditSink` | `INFRASTRUCTURE`;当前捕获异常后仅告警,可能让调用方误以为审计已成功,是高优先级失败传播缺口 |
| Integration | 外部 OCR/船司 | 六个 JDK `HttpClient` | `EXTERNAL`;OCR 默认 10/60 秒,船司默认 10/30 秒,业务重试必须服从供应商和调度契约 |
#### Lastleg 四个有界 RestClient
四个客户端统一从 `LastlegRestClientFactory` 构建 JDK HTTP 传输,由 `LastlegRestClientProperties` 绑定独立超时。读取超时是单次请求的硬边界;客户端没有 Retry、fallback 或默认成功配置。
| 客户端 | 构建与调用路径 | 写操作幂等性 | 默认超时 |
|---|---|---|---|
| `HttpUnloadPhotoFileGateway` | `ReceivingContainerController → ReceivingContainerService → UnloadPhotoFileGateway → /internal/file/v1/business-files/**` | `validate` GET 可重复;`upload` 每次写文件和元数据,没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 |
| `HttpRelabelPhotoFileGateway` | `RelabelPdaController/Service → RelabelPhotoFileGateway → /internal/file/v1/business-files/**` | `validate` GET 可重复;`upload` 没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 |
| `HttpWarehouseResourceGateway` | `InboundPdaWorkflowService`、PDA 仓内查询/盘点及 Dropship → Warehouse Resource/Pallet 内部接口 | 查询可重复;`reserve` 使用 `reservationKey`,`ensureDropship` 使用业务绑定,release/scrap 由服务端状态收敛;尽管服务端具备业务幂等,客户端仍不自动重试 | 连接 3 秒、读取 5 秒 |
| `HttpPalletPhysicalGateway` | `PdaPalletOperationService`、`PdaInventoryMoveService` → `/internal/v1/pda-pallet-transitions/**` | 查询可重复;apply/compensate 使用稳定 `operationKey` 与请求哈希,服务端幂等;客户端仍不自动重试 | 连接 3 秒、读取 5 秒 |
## 分上下文依赖图
### 在线 REQUEST 图
在线图只包含用户或设备请求线程中实际执行的服务边。Event Bridge、Outbox、船节点定时任务、导出 Provider 和海外 Security 校验均不计入在线深度。
当前在线强连通分量为:
`Auth ↔ IAM ↔ Firstleg ↔ Order ↔ File`
合同冻结 22 条在线服务边;新增在线边或新增强连通分量直接失败。Finance、Lastleg 只指向该分量或叶子服务,不属于在线环;Gateway Overseas→Security 是 `INFRASTRUCTURE`,不是 REQUEST 边。
### 后台 BACKGROUND 图
后台图包含 Outbox/Inbox、导出 Worker 和定时任务,当前强连通分量为:
`Event Bridge ↔ Firstleg ↔ Finance ↔ Lastleg`
File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Overseas Security 是单向后台叶子边,不形成在线业务环。
### 全图
全上下文合并后,非平凡强连通分量仍为 8 个服务:
`Auth ↔ IAM ↔ Order ↔ Firstleg ↔ Finance ↔ File ↔ Event Bridge ↔ Lastleg`
全图只用于部署影响和故障域观察,不能再用于推导在线调用“无限深度”。
### 用户入口在线最长简单路径
默认在线预算为最多 2 个下游服务跳数。合同对每个已知用户入口枚举全部最长简单路径;下表列出的具体路径均超过预算:
| 用户入口 | 最长跳数 | 全部长路径 |
|---|---:|---|
| 认证 API | 5 | `Auth → IAM → Firstleg → Order → File → Integration` |
| IAM API | 4 | `IAM → Firstleg → Order → File → Integration` |
| 订单 API | 4 | `Order → File → IAM → Firstleg → Print`;`Order → File → IAM → Firstleg → Warehouse`;`Order → Firstleg → File → IAM → Auth`;`Order → Firstleg → File → IAM → Warehouse`;`Order → IAM → Firstleg → File → Integration` |
| 头程 API/PDA | 4 | `Firstleg → Order → File → IAM → Auth`;`Firstleg → Order → File → IAM → Warehouse` |
| 财务 API | 5 | `Finance → File → IAM → Firstleg → Order → Warehouse`;`Finance → Firstleg → Order → File → IAM → Auth`;`Finance → Firstleg → Order → File → IAM → Warehouse`;`Finance → IAM → Firstleg → Order → File → Integration` |
| 文件 API | 4 | `File → IAM → Firstleg → Order → Warehouse` |
| 海外尾程 API/PDA | 5 | `Lastleg → File → IAM → Firstleg → Order → Warehouse` |
| 国内仓储 API | 0 | 无下游同步调用 |
| 海外仓储 API | 0 | 无下游同步调用 |
这些是静态服务边可形成的最长简单路径,不表示每个具体 Controller 都会走完整链;后续入口级调用点治理必须继续以实际 Service 调用证据缩小路径,不能用静态可能性宣称线上已经发生。
### 第一个建议退出的 REQUEST 边
建议优先将 `IAM → Firstleg` 的 `CustomerDimensionRuleClient` 默认尺寸初始化从 REQUEST WRITE 改为可靠后台编排。该命令已经携带稳定 `initializationKey` 并提供补偿接口,适合使用持久化 Outbox、显式 `PENDING/FAILED` 结果和人工可诊断重试;不得在失败时返回默认成功。退出此边可把当前五服务在线强连通分量拆为更小的 Auth↔IAM 与 Order↔Firstleg 子环,同时不触碰财务、Event Bridge 传输或四期业务状态机。实施前仍需 IAM/Firstleg 业务负责人确认“客户创建成功”与“默认尺寸已就绪”的产品时点。
## 退出顺序
1. **冻结与分层。** 先用清单合同阻止未评审新边;为每个客户端补齐 `REQUEST/BACKGROUND/INFRASTRUCTURE/EXTERNAL`、读写语义、幂等键和失败映射。这个阶段不改变业务状态。
2. **统一失败传播与时间预算。** 为 Feign 和原始 HTTP 明确连接/读取/总超时;默认禁用自动重试,只有具备幂等键且由业务明确拥有退避状态的后台操作可以重试;禁止空数据、默认成功或仅日志告警替代远端失败。
3. **先退出伪在线环。** File 导出 Provider、Firstleg/Lastleg Event Bridge、Firstleg→Finance 费用投递继续作为后台链路,但必须从在线深度图剥离,并禁止 Worker 消费时无界同步回调。
4. **退出 `Order ↔ Firstleg` 在线双向命令。** 保留一个方向的权威查询,将反向状态推进改为带版本、幂等和审计的 Outbox/Inbox;不复制 Order 交易状态或 Firstleg 履约状态。
5. **退出 `Auth ↔ IAM` 在线双向写。** Auth 保持账号与 Token 唯一所有者,IAM 保持客户/RBAC 所有者;注册和账号开通使用可恢复编排,登录授权快照失败关闭,不引入第二套账号事实。
6. **收敛 `IAM ↔ Firstleg`。** 客户维度规则和客户快照确定单一事实方向;允许短期只读查询,禁止两个服务互相修正对方数据。
7. **在财务总控确认后退出 `Firstleg ↔ Finance` 回调。** 优先让版本化事件携带 Finance 校验所需的不可变快照或使用单向快照读取,保持 Finance 状态机和 Firstleg 费用事实边界不变。
8. **收敛 File 相关环。** File 只拥有文件对象与导出任务;业务服务拥有导出数据和业务归属。凭证/照片校验失败关闭,导出 Worker 使用受限批次、超时和显式失败状态。
## 错误、重试与熔断基线
- 远端 HTTP 非 2xx、响应体 `code != "200"`、空响应和 DTO 不完整是四类不同失败,均不得变成成功或空列表。
- 在线查询/命令默认不自动重试。若启用重试,必须同时证明方法幂等、请求具有稳定幂等键、最大尝试次数和总时间预算可控。
- Outbox/Inbox 重试由持久化状态拥有,Feign/JDK 客户端不得再叠加隐式重试造成次数乘法。
- 熔断打开时返回统一“依赖不可用”错误并保留 request/trace 关联信息,不配置业务成功型 fallback。
- 404 只有在契约明确“资源不存在”时可映射为领域未找到;连接失败、超时、5xx、空响应必须映射为依赖不可用。
- Common Audit 当前“只记录告警后返回”列为已知例外,不能据此推广静默降级;其可靠性与业务阻断口径需和审计/总控共同冻结。
## 直接受影响验证
| 变更范围 | 最低验证 |
|---|---|
| 图、客户端或超时配置 | `node --test scripts/synchronous-dependency-contract.test.mjs` |
| 单个 Feign/HTTP 签名或错误映射 | `mvn -pl <调用方>,<被调用方> -am test-compile`,并运行双方直接相关测试 |
| Auth/IAM | `mvn -pl auth,iam -am test` |
| Order/Firstleg | `mvn -pl order,firstleg -am test` |
| IAM/Firstleg | `mvn -pl iam,firstleg -am test` |
| Firstleg/Finance | `mvn -pl firstleg,finance -am test`;业务口径先由财务总控确认 |
| Firstleg/File 或 File/Finance | `mvn -pl firstleg,file,finance -am test`,按实际消费者缩小模块集 |
| Lastleg 原始 HTTP | `mvn -pl lastleg-service,warehouse-overseas-service,file -am test`;不改变四期状态机 |
| 全部治理阶段收口 | `mvn -DskipTests validate` 后执行 `mvn test` |
以上均为本地离线验证;未经明确授权不启动 Java/Nacos/数据库,不执行 SQL、部署或测试环境 HTTP 验收。
@@ -10,7 +10,7 @@
## 1. 项目定位 ## 1. 项目定位
`baiyue-firstleg` 是国内头程业务后端仓库,采用 Maven 多模块单仓结构。仓库内包含 7 个公共模块和 11 个可独立启动、构建、制作镜像的微服务。 `baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 23 个 Maven 模块,其中 7 个是公共/依赖模块,16 个是可独立启动、构建、制作镜像的微服务。
当前技术基线: 当前技术基线:
@@ -24,7 +24,7 @@
- SpringDoc OpenAPI / Swagger - SpringDoc OpenAPI / Swagger
- GitLab、Jenkins、Docker Compose - GitLab、Jenkins、Docker Compose
本仓库只保存头程代码。尾程业务位于独立 Git 仓库,不允许把尾程源码、数据库或配置重新放回本仓库。 国内与海外服务共享源码仓库和公共制品,但保持独立数据库、Nacos 命名空间与部署边界;跨区域业务协作使用版本化 Outbox/Inbox 事件,不共享业务数据源。
## 2. 根目录结构 ## 2. 根目录结构
@@ -39,11 +39,16 @@ baiyue-firstleg/
├─ common-event-contract/ # 国内外跨区域事件统一契约 ├─ common-event-contract/ # 国内外跨区域事件统一契约
│ │
├─ gateway/ # 统一 API 网关 ├─ gateway/ # 统一 API 网关
├─ gateway-overseas/ # 海外区域 API 网关
├─ auth/ # 统一登录与账号凭据 ├─ auth/ # 统一登录与账号凭据
├─ iam/ # 租户、成员、角色、权限、企业审核 ├─ iam/ # 租户、成员、角色、权限、企业审核
├─ order/ # 头程订单中心 ├─ order/ # 头程订单中心
├─ firstleg/ # 履约任务、二期入库、测量与计费 ├─ firstleg/ # 履约任务、二期入库、测量与计费
├─ warehouse/ # 仓库主数据、托盘与库位资源 ├─ warehouse/ # 仓库主数据、托盘与库位资源
├─ finance/ # 财务账期、账单、收付款与核销
├─ lastleg-service/ # 海外尾程、入仓、库存与本土业务
├─ warehouse-overseas-service/ # 海外仓库、库位、托盘与资源锁
├─ overseas-security-service/ # 海外IAM安全事件Inbox、投影与实时校验
├─ file/ # 资质、导出与测量图片文件中心 ├─ file/ # 资质、导出与测量图片文件中心
├─ print/ # 打印中心 ├─ print/ # 打印中心
├─ audit-service/ # 审计日志中心 ├─ audit-service/ # 审计日志中心
@@ -97,6 +102,7 @@ baiyue-firstleg/
| `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 | | `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 |
| `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存与海外本土业务;不含海外出库和派送 | | `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存与海外本土业务;不含海外出库和派送 |
| `warehouse-overseas-service` | `warehouse-overseas-service` | 9305 | 海外仓资源库 | 海外仓库、库位、托盘和资源锁 | | `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、重试和目标服务投递 | | `event-bridge-service` | `event-bridge-service` | 9312 | `baiyue_event_bridge` | 按 CN/OVERSEAS 双实例部署的跨区事件 Inbox、重试和目标服务投递 |
| `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;平台端可按权限查询已脱敏日志,业务审计发送默认开启 | | `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;平台端可按权限查询已脱敏日志,业务审计发送默认开启 |
@@ -170,7 +176,7 @@ config/nacos/
└─ cn/ # 国内服务器配置模板 └─ cn/ # 国内服务器配置模板
``` ```
每套目录包含公共配置 `baiyue-common.yml` 和 11 个服务配置。配置模板可以提交,真实密码和第三方密钥不能提交。 `local/`、`cn/`、`overseas/` 按部署区域保存公共配置和该区域需要的服务配置。配置模板可以提交,真实密码和第三方密钥不能提交。
### `deploy/` ### `deploy/`
@@ -179,7 +185,8 @@ deploy/
├─ docker/Dockerfile # 通用 Java 17 镜像 ├─ docker/Dockerfile # 通用 Java 17 镜像
├─ env/.env.test.example # 环境变量示例 ├─ env/.env.test.example # 环境变量示例
├─ scripts/deploy-services.sh # Jenkins 增量部署脚本 ├─ scripts/deploy-services.sh # Jenkins 增量部署脚本
├─ docker-compose.yml # 11 个业务服务编排 ├─ docker-compose.yml # 国内区域服务编排
├─ docker-compose.overseas.yml # 海外区域服务编排
├─ docker-compose.rocketmq.yml # 公共导出 RocketMQ 基础设施 ├─ docker-compose.rocketmq.yml # 公共导出 RocketMQ 基础设施
├─ sql/ # 测试/存量环境统一迁移入口与说明 ├─ sql/ # 测试/存量环境统一迁移入口与说明
└─ service-map.yml # 模块、JAR、端口映射 └─ service-map.yml # 模块、JAR、端口映射
@@ -276,6 +283,6 @@ HTTP 401 / `A0401`,不得再按 HTTP 200 + 空权限处理;只有使用新
- 平台端和商户端权限分别管理,接口与按钮权限都必须由后端校验。 - 平台端和商户端权限分别管理,接口与按钮权限都必须由后端校验。
- Controller 负责协议转换,Service 负责业务编排,Repository/Mapper 负责持久化。 - Controller 负责协议转换,Service 负责业务编排,Repository/Mapper 负责持久化。
- 服务之间只传 DTO 或事件契约,不能依赖其他服务的 Entity、Mapper。 - 服务之间只传 DTO 或事件契约,不能依赖其他服务的 Entity、Mapper。
- 公共模块变化会影响多个服务,Jenkins 会自动重建全部 11 个服务。 - 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 16 个服务执行受影响构建。
- 单一 Git 仓库不等于单一部署单元;每个服务仍生成独立 JAR、镜像和容器。 - 单一 Git 仓库不等于单一部署单元;每个服务仍生成独立 JAR、镜像和容器。
- 头程与尾程保持两个 Git 仓库、两套数据库和两套运行环境。 - 国内头程与海外仓共享 Git 仓库,但保持两套数据库、Nacos 命名空间和部署环境。
+4 -2
View File
@@ -12,11 +12,12 @@
|---|---| |---|---|
| 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` | | 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` |
| Inbox 接收与幂等 | `service/EventInboxService.java` | | Inbox 接收与幂等 | `service/EventInboxService.java` |
| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=OVERSEAS` 投 Lastleg,`targetRegion=CN` 回投 Firstleg | | 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 回投 Firstleg;海外业务事件投 Lastleg,三类 IAM 安全事件按 eventType 投 `overseas-security-service`,传输层不解析 payload |
| 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` | | 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` |
| 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` | | 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` |
| 人工补投 | `service/EventReplayService.java` | | 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` |
| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper | | Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper |
| 传输指标与 Inbox 采样 | `metrics/EventBridgeMetrics.java`、`EventBridgeInboxMetricsSampler.java`;SLO 与告警合同 `docs/development-observability-slo.md` |
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
| 数据库 | `src/main/resources/db/event-bridge.sql` | | 数据库 | `src/main/resources/db/event-bridge.sql` |
| 测试 | `src/test/java/com/baiyue/eventbridge/` | | 测试 | `src/test/java/com/baiyue/eventbridge/` |
@@ -27,6 +28,7 @@
- 每次投递保存 attempt、错误和时间;达到上限进入 `DEAD`,经授权人工补投后重新进入重试队列。 - 每次投递保存 attempt、错误和时间;达到上限进入 `DEAD`,经授权人工补投后重新进入重试队列。
- 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。 - 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。
- 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。 - 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。
- 指标标签只允许有限 `target_region`、`event_type`、`result`,未知区域/类型折叠为 `OTHER`,禁止 eventId、customerId、错误消息等高基数标签。Inbox 聚合按现有状态索引定时采样,Prometheus scrape 不得查询数据库;开发阶段 SLO/告警只代表代码合同,不代表监控平台已部署。
## 修改与验证 ## 修改与验证
@@ -0,0 +1,67 @@
# Event Bridge 开发阶段可观测性与 SLO 合同
## 状态与边界
本文冻结代码侧指标、SLI/SLO 和告警表达式模板。它不表示任何环境已经部署、接入或启用了
Prometheus、Alertmanager 或 Grafana,也不授权部署、访问环境或修改环境配置。
指标标签只允许 `target_region`、`event_type`、`result`:
- `target_region` 仅为 `CN`、`OVERSEAS`、`OTHER`。
- `event_type` 仅为 `EventTypes` 已登记类型或 `OTHER`。
- `result` 仅为代码中冻结的接收或传输结果枚举。
- 严禁把 `eventId`、`customerId`、聚合 ID、错误消息、请求 URL 或操作人写入指标标签。
## 指标合同
| Prometheus 指标 | 含义 |
|---|---|
| `baiyue_event_bridge_receive_total` | 接收结果;`result=RECEIVED|DUPLICATE|CONFLICT` |
| `baiyue_event_bridge_transport_total` | 传输结果;`result=DELIVERED|RETRY|DEAD|ACKNOWLEDGED_LEASE_LOST|FAILED_LEASE_LOST|MANUAL_REPLAY` |
| `baiyue_event_bridge_delivery_latency_seconds_bucket` | 从 Event Bridge `received_at` 到下游首次成功确认;固定包含 60 秒和 900 秒 SLO 桶 |
| `baiyue_event_bridge_inbox_backlog` | 当前 `RECEIVED`、`RETRY`、`PROCESSING` 数量 |
| `baiyue_event_bridge_inbox_oldest_age_seconds` | 当前最老待处理事件自 `received_at` 起的年龄 |
| `baiyue_event_bridge_inbox_dead` | 当前 `DEAD` 数量 |
`DELIVERED` 与 `ACKNOWLEDGED_LEASE_LOST` 都表示下游已经确认,因此均记录确认延迟;后者仍单独计数,
不得冒充本地 Inbox 已成功更新。`RETRY`、`DEAD`、`FAILED_LEASE_LOST` 不记录成功延迟。
Inbox Gauge 由服务每 30 秒执行一次受控聚合查询并缓存;Prometheus scrape 只读取 Micrometer Gauge,
不得触发数据库查询。查询仅处理 `RECEIVED`、`RETRY`、`PROCESSING`、`DEAD`,状态过滤以现有
`idx_bridge_inbox_consume(status, next_retry_at, lease_until)` 为索引依据;本合同不新增或修改 SQL 结构。
## 开发阶段 SLI 与 SLO
SLI 从事件首次得到 `result=RECEIVED` 开始,到下游首次成功确认结束;`DUPLICATE` 和 `CONFLICT`
不重复进入分母。滚动 30 天目标:
- 99% 的新接收跨区事件在 60 秒内得到下游确认。
- 99.9% 的新接收跨区事件在 15 分钟内得到下游确认。
代码暴露固定延迟桶,目标查询模板如下;按 `target_region` 汇总,必要时可保留有限 `event_type` 分组:
```promql
sum(rate(baiyue_event_bridge_delivery_latency_seconds_bucket{le="60.0"}[30d]))
/
sum(rate(baiyue_event_bridge_receive_total{result="RECEIVED"}[30d])) >= 0.99
```
```promql
sum(rate(baiyue_event_bridge_delivery_latency_seconds_bucket{le="900.0"}[30d]))
/
sum(rate(baiyue_event_bridge_receive_total{result="RECEIVED"}[30d])) >= 0.999
```
## 告警合同
规则由实际监控平台按 30 秒或更短评估周期配置;以下只是表达式合同:
- 新增 DEAD:下一次评估立即告警,不设置持续等待。
`sum(increase(baiyue_event_bridge_transport_total{result="DEAD"}[1m])) > 0`
- 最老待处理年龄预警:任一有限标签序列超过 120 秒。
`max(baiyue_event_bridge_inbox_oldest_age_seconds) > 120`
- 最老待处理年龄严重:任一有限标签序列超过 900 秒。
`max(baiyue_event_bridge_inbox_oldest_age_seconds) > 900`
- 当前 DEAD 存量用于持续可见性:`sum(baiyue_event_bridge_inbox_dead) > 0`;它不替代“新增 DEAD”告警。
告警通知中可通过日志和业务查询再定位 event ID;禁止为了通知便利把高基数标识加入指标标签。
@@ -51,7 +51,8 @@ public class EventBridgeController {
return ApiResponse.success(eventInboxService.receive(envelope)); return ApiResponse.success(eventInboxService.receive(envelope));
} }
@Operation(summary = "人工补投死信事件") @Operation(summary = "人工补投死信事件",
description = "仅允许 DEAD 事件转回 RETRY;保留原 eventId、payload 与 payloadHash,必须记录操作人与补投原因")
@PostMapping("/events/{eventId}/replay") @PostMapping("/events/{eventId}/replay")
public ApiResponse<Void> replay( public ApiResponse<Void> replay(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -2,6 +2,7 @@ package com.baiyue.eventbridge.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.metrics.InboxMetricsSnapshot;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
@@ -60,4 +61,21 @@ public interface BridgeInboxMapper extends BaseMapper<BridgeInbox> {
WHERE event_id=#{eventId} AND status='DEAD' WHERE event_id=#{eventId} AND status='DEAD'
""") """)
int replayDead(@Param("eventId") String eventId, @Param("reason") String reason); int replayDead(@Param("eventId") String eventId, @Param("reason") String reason);
/**
* Scheduled observability aggregation. The status predicate uses idx_bridge_inbox_consume;
* Prometheus collection never invokes this query directly.
*/
@Select("""
SELECT target_region AS targetRegion, event_type AS eventType,
SUM(CASE WHEN status IN ('RECEIVED','RETRY','PROCESSING') THEN 1 ELSE 0 END) AS backlogCount,
COALESCE(TIMESTAMPDIFF(MICROSECOND,
MIN(CASE WHEN status IN ('RECEIVED','RETRY','PROCESSING') THEN received_at END),
CURRENT_TIMESTAMP(3)) / 1000000.0, 0) AS oldestPendingAgeSeconds,
SUM(CASE WHEN status='DEAD' THEN 1 ELSE 0 END) AS deadCount
FROM bridge_inbox
WHERE status IN ('RECEIVED','RETRY','PROCESSING','DEAD')
GROUP BY target_region, event_type
""")
List<InboxMetricsSnapshot> observabilitySnapshots();
} }
@@ -0,0 +1,103 @@
package com.baiyue.eventbridge.metrics;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.MultiGauge;
import io.micrometer.core.instrument.Tags;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/** Periodically samples indexed Inbox aggregates; Prometheus scrapes only the cached gauges. */
@Component
@Slf4j
public class EventBridgeInboxMetricsSampler {
public static final String BACKLOG_METRIC = "baiyue.event.bridge.inbox.backlog";
public static final String OLDEST_AGE_METRIC = "baiyue.event.bridge.inbox.oldest.age.seconds";
public static final String DEAD_METRIC = "baiyue.event.bridge.inbox.dead";
private final BridgeInboxMapper inboxMapper;
private final MultiGauge backlog;
private final MultiGauge oldestAge;
private final MultiGauge dead;
public EventBridgeInboxMetricsSampler(BridgeInboxMapper inboxMapper, MeterRegistry registry) {
this.inboxMapper = inboxMapper;
this.backlog = MultiGauge.builder(BACKLOG_METRIC)
.description("Events waiting for downstream acknowledgement").register(registry);
this.oldestAge = MultiGauge.builder(OLDEST_AGE_METRIC)
.description("Age in seconds of the oldest pending Inbox event").register(registry);
this.dead = MultiGauge.builder(DEAD_METRIC)
.description("Current number of DEAD Inbox events").register(registry);
}
@Scheduled(initialDelayString = "${baiyue.event-bridge.metrics.initial-delay:5000}",
fixedDelayString = "${baiyue.event-bridge.metrics.sample-delay:30000}")
public void sample() {
final List<InboxMetricsSnapshot> snapshots;
try {
snapshots = inboxMapper.observabilitySnapshots();
} catch (RuntimeException exception) {
log.warn("Event Bridge Inbox 指标采样失败,保留上次成功快照", exception);
return;
}
Map<MetricKey, Aggregate> aggregates = aggregate(snapshots);
backlog.register(rows(aggregates, MetricValue.BACKLOG), true);
oldestAge.register(rows(aggregates, MetricValue.OLDEST_AGE), true);
dead.register(rows(aggregates, MetricValue.DEAD), true);
}
private Map<MetricKey, Aggregate> aggregate(List<InboxMetricsSnapshot> snapshots) {
Map<MetricKey, Aggregate> result = new LinkedHashMap<>();
if (snapshots == null) {
return result;
}
for (InboxMetricsSnapshot snapshot : snapshots) {
MetricKey key = new MetricKey(EventBridgeMetrics.targetRegion(snapshot.getTargetRegion()),
EventBridgeMetrics.eventType(snapshot.getEventType()));
result.computeIfAbsent(key, ignored -> new Aggregate()).add(snapshot);
}
return result;
}
private List<MultiGauge.Row<?>> rows(Map<MetricKey, Aggregate> aggregates, MetricValue value) {
List<MultiGauge.Row<?>> rows = new ArrayList<>();
aggregates.forEach((key, aggregate) -> rows.add(MultiGauge.Row.of(
Tags.of("target_region", key.targetRegion, "event_type", key.eventType),
value.value(aggregate))));
return rows;
}
private record MetricKey(String targetRegion, String eventType) { }
private static final class Aggregate {
private long backlog;
private double oldestAge;
private long dead;
void add(InboxMetricsSnapshot snapshot) {
backlog += snapshot.getBacklogCount();
oldestAge = Math.max(oldestAge, snapshot.getOldestPendingAgeSeconds());
dead += snapshot.getDeadCount();
}
}
private enum MetricValue {
BACKLOG {
@Override Number value(Aggregate aggregate) { return aggregate.backlog; }
},
OLDEST_AGE {
@Override Number value(Aggregate aggregate) { return aggregate.oldestAge; }
},
DEAD {
@Override Number value(Aggregate aggregate) { return aggregate.dead; }
};
abstract Number value(Aggregate aggregate);
}
}
@@ -0,0 +1,121 @@
package com.baiyue.eventbridge.metrics;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventTypes;
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.stereotype.Component;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Set;
/** Low-cardinality transport metrics for the cross-region event bridge. */
@Component
@Slf4j
public class EventBridgeMetrics {
public static final String RECEIVE_METRIC = "baiyue.event.bridge.receive";
public static final String TRANSPORT_METRIC = "baiyue.event.bridge.transport";
public static final String DELIVERY_LATENCY_METRIC = "baiyue.event.bridge.delivery.latency";
private static final Set<String> KNOWN_EVENT_TYPES = Set.of(
EventTypes.ORDER_APPROVED,
EventTypes.ORDER_CANCELLED,
EventTypes.FIRST_LEG_SHIPMENT_DEPARTED,
EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED,
EventTypes.OVERSEAS_CONTAINER_UNLOADED,
EventTypes.IAM_USER_STATUS_CHANGED,
EventTypes.IAM_TENANT_STATUS_CHANGED,
EventTypes.IAM_PERMISSION_VERSION_CHANGED,
EventTypes.LAST_MILE_HANDOFF_ACCEPTED,
EventTypes.LAST_MILE_HANDOFF_REJECTED,
EventTypes.LAST_MILE_MILESTONE_CHANGED,
EventTypes.LAST_MILE_COMPLETED,
EventTypes.TRACKING_MILESTONE_PROJECTED);
private final MeterRegistry registry;
private final Clock clock;
public EventBridgeMetrics(MeterRegistry registry) {
this(registry, Clock.systemDefaultZone());
}
EventBridgeMetrics(MeterRegistry registry, Clock clock) {
this.registry = registry;
this.clock = clock;
}
public void recordReceive(EventEnvelope envelope, ReceiveResult result) {
try {
registry.counter(RECEIVE_METRIC,
"target_region", targetRegion(envelope.targetRegion().name()),
"event_type", eventType(envelope.eventType()),
"result", result.name()).increment();
} catch (RuntimeException exception) {
log.warn("Event Bridge 接收指标记录失败,不影响事件事务", exception);
}
}
public void recordTransport(BridgeInbox event, TransportResult result) {
try {
String targetRegion = targetRegion(event.getTargetRegion());
String eventType = eventType(event.getEventType());
registry.counter(TRANSPORT_METRIC,
"target_region", targetRegion,
"event_type", eventType,
"result", result.name()).increment();
if (result.acknowledged() && event.getReceivedAt() != null) {
Duration latency = Duration.between(event.getReceivedAt(), LocalDateTime.now(clock));
if (latency.isNegative()) {
latency = Duration.ZERO;
}
Timer.builder(DELIVERY_LATENCY_METRIC)
.description("Time from Event Bridge receipt to downstream acknowledgement")
.serviceLevelObjectives(Duration.ofSeconds(60), Duration.ofMinutes(15))
.tags("target_region", targetRegion, "event_type", eventType)
.register(registry).record(latency);
}
} catch (RuntimeException exception) {
log.warn("Event Bridge 传输指标记录失败,不影响可靠性状态机", exception);
}
}
public static String targetRegion(String value) {
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
return "CN".equals(normalized) || "OVERSEAS".equals(normalized) ? normalized : "OTHER";
}
public static String eventType(String value) {
return value != null && KNOWN_EVENT_TYPES.contains(value) ? value : "OTHER";
}
public enum ReceiveResult {
RECEIVED,
DUPLICATE,
CONFLICT
}
public enum TransportResult {
DELIVERED(true),
RETRY(false),
DEAD(false),
ACKNOWLEDGED_LEASE_LOST(true),
FAILED_LEASE_LOST(false),
MANUAL_REPLAY(false);
private final boolean acknowledged;
TransportResult(boolean acknowledged) {
this.acknowledged = acknowledged;
}
boolean acknowledged() {
return acknowledged;
}
}
}
@@ -0,0 +1,50 @@
package com.baiyue.eventbridge.metrics;
/** Result row of the bounded, scheduled Inbox observability aggregation. */
public class InboxMetricsSnapshot {
private String targetRegion;
private String eventType;
private long backlogCount;
private double oldestPendingAgeSeconds;
private long deadCount;
public String getTargetRegion() {
return targetRegion;
}
public void setTargetRegion(String targetRegion) {
this.targetRegion = targetRegion;
}
public String getEventType() {
return eventType;
}
public void setEventType(String eventType) {
this.eventType = eventType;
}
public long getBacklogCount() {
return backlogCount;
}
public void setBacklogCount(long backlogCount) {
this.backlogCount = backlogCount;
}
public double getOldestPendingAgeSeconds() {
return oldestPendingAgeSeconds;
}
public void setOldestPendingAgeSeconds(double oldestPendingAgeSeconds) {
this.oldestPendingAgeSeconds = oldestPendingAgeSeconds;
}
public long getDeadCount() {
return deadCount;
}
public void setDeadCount(long deadCount) {
this.deadCount = deadCount;
}
}
@@ -5,6 +5,7 @@ import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.dto.EventReceiveResult; import com.baiyue.eventbridge.dto.EventReceiveResult;
import com.baiyue.eventbridge.metrics.EventBridgeMetrics;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -23,6 +24,7 @@ import java.util.Objects;
public class EventInboxService { public class EventInboxService {
@Resource private InboxStore inboxStore; @Resource private InboxStore inboxStore;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@Resource private EventBridgeMetrics metrics;
@Value("${baiyue.region:CN}") @Value("${baiyue.region:CN}")
private String localRegion; private String localRegion;
@@ -44,6 +46,7 @@ public class EventInboxService {
BridgeInbox inbox = toInbox(envelope); BridgeInbox inbox = toInbox(envelope);
try { try {
inboxStore.insert(inbox); inboxStore.insert(inbox);
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.RECEIVED);
return new EventReceiveResult(envelope.eventId(), "已接收", inbox.getReceivedAt()); return new EventReceiveResult(envelope.eventId(), "已接收", inbox.getReceivedAt());
} catch (DuplicateKeyException exception) { } catch (DuplicateKeyException exception) {
BridgeInbox concurrent = inboxStore.findByEventId(envelope.eventId()); BridgeInbox concurrent = inboxStore.findByEventId(envelope.eventId());
@@ -56,8 +59,10 @@ public class EventInboxService {
private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) { private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) {
if (!sameEnvelope(existing, envelope)) { if (!sameEnvelope(existing, envelope)) {
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT);
throw new IllegalArgumentException("相同 eventId 对应的事件信封或 payloadHash 不一致,拒绝覆盖既有事件"); throw new IllegalArgumentException("相同 eventId 对应的事件信封或 payloadHash 不一致,拒绝覆盖既有事件");
} }
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.DUPLICATE);
return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt()); return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt());
} }
@@ -1,13 +1,23 @@
package com.baiyue.eventbridge.service; package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import com.baiyue.eventbridge.metrics.EventBridgeMetrics;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service @Service
public class EventReplayService { public class EventReplayService {
@Resource private BridgeInboxMapper inboxMapper; @Resource private BridgeInboxMapper inboxMapper;
@Resource private BridgeDeliveryAttemptMapper attemptMapper;
@Resource private EventBridgeMetrics metrics;
@Transactional
public void replayDead(String eventId, String operatorId, String reason) { public void replayDead(String eventId, String operatorId, String reason) {
if (operatorId == null || operatorId.isBlank()) { if (operatorId == null || operatorId.isBlank()) {
throw new IllegalArgumentException("人工补投必须提供操作人"); throw new IllegalArgumentException("人工补投必须提供操作人");
@@ -19,6 +29,22 @@ public class EventReplayService {
if (inboxMapper.replayDead(eventId, auditReason) != 1) { if (inboxMapper.replayDead(eventId, auditReason) != 1) {
throw new IllegalStateException("事件不存在或未处于 DEAD 状态"); throw new IllegalStateException("事件不存在或未处于 DEAD 状态");
} }
BridgeInbox event = inboxMapper.selectById(eventId);
if (event == null) {
throw new IllegalStateException("事件补投状态已更新但事件记录不存在");
}
LocalDateTime now = LocalDateTime.now();
BridgeDeliveryAttempt audit = new BridgeDeliveryAttempt();
audit.setEventId(eventId);
audit.setSourceRegion(event.getSourceRegion());
audit.setTargetRegion(event.getTargetRegion());
audit.setAttemptNo(inboxMapper.nextAttemptNo(eventId));
audit.setStatus("MANUAL_REPLAY");
audit.setErrorMessage(auditReason);
audit.setStartedAt(now);
audit.setFinishedAt(now);
attemptMapper.insert(audit);
metrics.recordTransport(event, EventBridgeMetrics.TransportResult.MANUAL_REPLAY);
} }
private static String abbreviate(String value, int maxLength) { private static String abbreviate(String value, int maxLength) {
@@ -42,11 +42,31 @@ public class HttpFirstlegEventClient implements FirstlegEventClient {
.header("X-Internal-Service-Token", serviceToken) .header("X-Internal-Service-Token", serviceToken)
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode responseBody = objectMapper.readTree(response.body()); JsonNode responseBody;
try {
responseBody = objectMapper.readTree(response.body());
} catch (Exception ignored) {
responseBody = null;
}
if (response.statusCode() / 100 != 2 || responseBody == null if (response.statusCode() / 100 != 2 || responseBody == null
|| !"200".equals(responseBody.path("code").asText())) { || !"200".equals(responseBody.path("code").asText())) {
throw new IllegalStateException("头程消费失败: HTTP " + response.statusCode()); throw new IllegalStateException(failureMessage(response.statusCode(), responseBody));
} }
return String.valueOf(response.statusCode()); return String.valueOf(response.statusCode());
} }
static String failureMessage(int statusCode, JsonNode body) {
String code = body == null ? "missing" : body.path("code").asText("missing");
String message = body == null ? "non-json response" : body.path("message").asText("missing");
String requestId = body == null ? "missing" : body.path("requestId").asText("missing");
String traceId = body == null ? "missing" : body.path("traceId").asText("missing");
return "头程消费失败: HTTP " + statusCode + ", code=" + bounded(code)
+ ", message=" + bounded(message) + ", requestId=" + bounded(requestId)
+ ", traceId=" + bounded(traceId);
}
private static String bounded(String value) {
String safe = value == null || value.isBlank() ? "missing" : value.replaceAll("[\\r\\n]+", " ");
return safe.length() <= 240 ? safe : safe.substring(0, 240);
}
} }
@@ -45,11 +45,31 @@ public class HttpLastlegEventClient implements LastlegEventClient {
.POST(HttpRequest.BodyPublishers.ofString(body)) .POST(HttpRequest.BodyPublishers.ofString(body))
.build(); .build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode responseBody = objectMapper.readTree(response.body()); JsonNode responseBody;
try {
responseBody = objectMapper.readTree(response.body());
} catch (Exception ignored) {
responseBody = null;
}
if (response.statusCode() / 100 != 2 || responseBody == null if (response.statusCode() / 100 != 2 || responseBody == null
|| !"200".equals(responseBody.path("code").asText())) { || !"200".equals(responseBody.path("code").asText())) {
throw new IllegalStateException("尾程消费失败: HTTP " + response.statusCode()); throw new IllegalStateException(failureMessage(response.statusCode(), responseBody));
} }
return String.valueOf(response.statusCode()); return String.valueOf(response.statusCode());
} }
static String failureMessage(int statusCode, JsonNode body) {
String code = body == null ? "missing" : body.path("code").asText("missing");
String message = body == null ? "non-json response" : body.path("message").asText("missing");
String requestId = body == null ? "missing" : body.path("requestId").asText("missing");
String traceId = body == null ? "missing" : body.path("traceId").asText("missing");
return "尾程消费失败: HTTP " + statusCode + ", code=" + bounded(code)
+ ", message=" + bounded(message) + ", requestId=" + bounded(requestId)
+ ", traceId=" + bounded(traceId);
}
private static String bounded(String value) {
String safe = value == null || value.isBlank() ? "missing" : value.replaceAll("[\\r\\n]+", " ");
return safe.length() <= 240 ? safe : safe.substring(0, 240);
}
} }
@@ -0,0 +1,56 @@
package com.baiyue.eventbridge.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.RegionCode;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@Component
public class HttpSecurityProjectionEventClient implements SecurityProjectionEventClient {
private final ObjectMapper objectMapper;
private final EventDispatcherProperties properties;
@Value("${baiyue.event-dispatcher.security-consumer-url}") private URI consumerUrl;
@Value("${baiyue.security.internal-service-token:}") private String serviceToken;
private HttpClient httpClient;
public HttpSecurityProjectionEventClient(ObjectMapper objectMapper, EventDispatcherProperties properties) {
this.objectMapper = objectMapper;
this.properties = properties;
}
@PostConstruct void initialize() {
httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build();
}
@Override
public String deliver(BridgeInbox row) throws Exception {
EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(),
row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()),
row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(),
row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(),
objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash());
HttpRequest request = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout())
.header("Content-Type", "application/json")
.header("X-Internal-Service-Token", serviceToken)
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode body;
try { body = objectMapper.readTree(response.body()); }
catch (Exception exception) { body = null; }
if (response.statusCode() / 100 != 2 || body == null || !"200".equals(body.path("code").asText())
|| !row.getEventId().equals(body.path("data").path("eventId").asText())) {
throw new IllegalStateException("海外安全投影消费失败: HTTP " + response.statusCode());
}
return String.valueOf(response.statusCode());
}
}
@@ -1,10 +1,12 @@
package com.baiyue.eventbridge.service; package com.baiyue.eventbridge.service;
import com.baiyue.common.event.EventTypes;
import com.baiyue.eventbridge.config.EventDispatcherProperties; import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import com.baiyue.eventbridge.metrics.EventBridgeMetrics;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
@@ -12,6 +14,7 @@ import org.springframework.stereotype.Component;
import java.lang.management.ManagementFactory; import java.lang.management.ManagementFactory;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
@Component @Component
@@ -21,7 +24,9 @@ public class OverseasEventDispatcher {
@Resource private BridgeDeliveryAttemptMapper attemptMapper; @Resource private BridgeDeliveryAttemptMapper attemptMapper;
@Resource private LastlegEventClient eventClient; @Resource private LastlegEventClient eventClient;
@Resource private FirstlegEventClient firstlegEventClient; @Resource private FirstlegEventClient firstlegEventClient;
@Resource private SecurityProjectionEventClient securityProjectionEventClient;
@Resource private EventDispatcherProperties properties; @Resource private EventDispatcherProperties properties;
@Resource private EventBridgeMetrics metrics;
private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID(); private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID();
@Scheduled(fixedDelayString = "${baiyue.event-dispatcher.fixed-delay:3000}") @Scheduled(fixedDelayString = "${baiyue.event-dispatcher.fixed-delay:3000}")
@@ -40,26 +45,52 @@ public class OverseasEventDispatcher {
int retryAttempt = event.getRetryCount() == null ? 1 : event.getRetryCount() + 1; int retryAttempt = event.getRetryCount() == null ? 1 : event.getRetryCount() + 1;
int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId); int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId);
BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo); BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo);
String responseCode;
try { try {
String responseCode = deliver(event); responseCode = deliver(event);
if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) {
throw new IllegalStateException("事件租约已失效,无法确认投递结果");
}
finishAttempt(attempt, "DELIVERED", responseCode, null);
} catch (Exception exception) { } catch (Exception exception) {
String error = abbreviate(exception.getMessage(), 1000); String error = abbreviate(exception.getMessage(), 1000);
inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), backoffSeconds(retryAttempt), error); int marked = inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(),
finishAttempt(attempt, retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY", null, error); backoffSeconds(retryAttempt), error);
String attemptStatus = marked == 1
? (retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY")
: "FAILED_LEASE_LOST";
finishAttempt(attempt, attemptStatus, null, error);
metrics.recordTransport(event, EventBridgeMetrics.TransportResult.valueOf(attemptStatus));
log.warn("跨区事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}", log.warn("跨区事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}",
eventId, retryAttempt, deliveryAttemptNo, error); eventId, retryAttempt, deliveryAttemptNo, error);
return;
} }
if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) {
String error = "下游已确认成功,但事件租约已失效;保留当前 Inbox 状态并由新租约持有者幂等续投";
finishAttempt(attempt, "ACKNOWLEDGED_LEASE_LOST", responseCode, error);
metrics.recordTransport(event, EventBridgeMetrics.TransportResult.ACKNOWLEDGED_LEASE_LOST);
log.warn("跨区事件成功确认时租约已失效 eventId={}, deliveryAttempt={}", eventId, deliveryAttemptNo);
return;
}
finishAttempt(attempt, "DELIVERED", responseCode, null);
metrics.recordTransport(event, EventBridgeMetrics.TransportResult.DELIVERED);
} }
private String deliver(BridgeInbox event) throws Exception { private String deliver(BridgeInbox event) throws Exception {
return "CN".equals(event.getTargetRegion()) String eventType = event.getEventType();
? firstlegEventClient.deliver(event) : eventClient.deliver(event); if (eventType == null || eventType.isBlank()) {
throw new IllegalArgumentException("事件类型不能为空,无法选择下游消费者");
}
return switch (event.getTargetRegion()) {
case "CN" -> firstlegEventClient.deliver(event);
case "OVERSEAS" -> SECURITY_EVENT_TYPES.contains(eventType)
? securityProjectionEventClient.deliver(event) : eventClient.deliver(event);
default -> throw new IllegalArgumentException("不支持的事件目标区域: " + event.getTargetRegion());
};
} }
private static final Set<String> SECURITY_EVENT_TYPES = Set.of(
EventTypes.IAM_USER_STATUS_CHANGED,
EventTypes.IAM_TENANT_STATUS_CHANGED,
EventTypes.IAM_PERMISSION_VERSION_CHANGED);
private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) { private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) {
BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt(); BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt();
attempt.setEventId(event.getEventId()); attempt.setEventId(event.getEventId());
@@ -0,0 +1,7 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeInbox;
public interface SecurityProjectionEventClient {
String deliver(BridgeInbox event) throws Exception;
}
@@ -33,6 +33,7 @@ baiyue:
event-dispatcher: event-dispatcher:
consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}} 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} 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}
fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000}
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10}
@@ -0,0 +1,54 @@
package com.baiyue.eventbridge.metrics;
import com.baiyue.common.event.EventTypes;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
class EventBridgeInboxMetricsSamplerTest {
@Test
void samplesDatabaseOnceAndServesCachedLowCardinalityGauges() {
BridgeInboxMapper mapper = mock(BridgeInboxMapper.class);
SimpleMeterRegistry registry = new SimpleMeterRegistry();
InboxMetricsSnapshot known = snapshot("OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,
7, 121.5, 2);
InboxMetricsSnapshot unknown = snapshot("region-123", "dynamic-event-123", 3, 900.0, 1);
when(mapper.observabilitySnapshots()).thenReturn(List.of(known, unknown));
EventBridgeInboxMetricsSampler sampler = new EventBridgeInboxMetricsSampler(mapper, registry);
sampler.sample();
verify(mapper).observabilitySnapshots();
assertThat(gauge(registry, EventBridgeInboxMetricsSampler.BACKLOG_METRIC,
"OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)).isEqualTo(7.0);
assertThat(gauge(registry, EventBridgeInboxMetricsSampler.OLDEST_AGE_METRIC,
"OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)).isEqualTo(121.5);
assertThat(gauge(registry, EventBridgeInboxMetricsSampler.DEAD_METRIC,
"OTHER", "OTHER")).isEqualTo(1.0);
verifyNoMoreInteractions(mapper);
}
private InboxMetricsSnapshot snapshot(String region, String eventType, long backlog,
double oldestAge, long dead) {
InboxMetricsSnapshot snapshot = new InboxMetricsSnapshot();
snapshot.setTargetRegion(region);
snapshot.setEventType(eventType);
snapshot.setBacklogCount(backlog);
snapshot.setOldestPendingAgeSeconds(oldestAge);
snapshot.setDeadCount(dead);
return snapshot;
}
private double gauge(SimpleMeterRegistry registry, String name, String region, String eventType) {
return registry.get(name).tags("target_region", region, "event_type", eventType).gauge().value();
}
}
@@ -0,0 +1,58 @@
package com.baiyue.eventbridge.metrics;
import com.baiyue.eventbridge.domain.BridgeInbox;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import io.micrometer.core.instrument.MeterRegistry;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Modifier;
import java.util.Arrays;
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 collapsesUnboundedRegionAndEventTypeLabelsToOther() {
SimpleMeterRegistry registry = new SimpleMeterRegistry();
EventBridgeMetrics metrics = new EventBridgeMetrics(registry);
BridgeInbox event = new BridgeInbox();
event.setTargetRegion("customer-controlled-region");
event.setEventType("dynamic-event-123456");
metrics.recordTransport(event, EventBridgeMetrics.TransportResult.RETRY);
assertThat(registry.get(EventBridgeMetrics.TRANSPORT_METRIC)
.tags("target_region", "OTHER", "event_type", "OTHER", "result", "RETRY")
.counter().count()).isEqualTo(1.0);
assertThat(registry.getMeters()).allSatisfy(meter -> assertThat(meter.getId().getTags())
.noneMatch(tag -> tag.getValue().contains("dynamic-event")));
assertThat(EventBridgeMetrics.eventType(null)).isEqualTo("OTHER");
}
@Test
void everyFrozenEventTypeHasABoundedTagValue() {
assertThat(Arrays.stream(com.baiyue.common.event.EventTypes.class.getFields())
.filter(field -> Modifier.isStatic(field.getModifiers()) && field.getType() == String.class)
.map(field -> {
try {
return (String) field.get(null);
} catch (IllegalAccessException exception) {
throw new AssertionError(exception);
}
})).allMatch(value -> EventBridgeMetrics.eventType(value).equals(value));
}
@Test
void metricsFailureDoesNotChangeTransportControlFlow() {
EventBridgeMetrics metrics = new EventBridgeMetrics(mock(MeterRegistry.class));
BridgeInbox event = new BridgeInbox();
event.setTargetRegion("CN");
event.setEventType(com.baiyue.common.event.EventTypes.OVERSEAS_CONTAINER_UNLOADED);
assertThatCode(() -> metrics.recordTransport(event, EventBridgeMetrics.TransportResult.RETRY))
.doesNotThrowAnyException();
}
}
@@ -0,0 +1,41 @@
package com.baiyue.eventbridge.metrics;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class EventBridgeObservabilityContractTest {
@Test
void aggregationIsScheduledCacheInputAndUsesExistingStatusIndexBasis() throws Exception {
Select annotation = BridgeInboxMapper.class.getMethod("observabilitySnapshots")
.getAnnotation(Select.class);
String query = String.join(" ", annotation.value()).replaceAll("\\s+", " ");
String schema = Files.readString(Path.of("src/main/resources/db/event-bridge.sql"));
assertThat(query)
.contains("WHERE status IN ('RECEIVED','RETRY','PROCESSING','DEAD')")
.contains("GROUP BY target_region, event_type")
.doesNotContain("event_id AS", "customer_id AS", "last_error AS");
assertThat(schema).contains(
"KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until)");
}
@Test
void developmentSloAndAlertThresholdsAreFrozenWithoutDeploymentClaim() throws Exception {
String contract = Files.readString(Path.of("docs/development-observability-slo.md"));
assertThat(contract)
.contains("99% 的新接收跨区事件在 60 秒内")
.contains("99.9% 的新接收跨区事件在 15 分钟内")
.contains("result=\"DEAD\"", "> 120", "> 900")
.contains("不表示任何环境已经部署")
.contains("idx_bridge_inbox_consume(status, next_retry_at, lease_until)")
.doesNotContain("customer_id\"", "event_id\"");
}
}
@@ -0,0 +1,39 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class EventBridgeReliabilitySqlContractTest {
@Test
void schedulingAndLeaseSqlCoverRetryDeadAndExpiredLeaseTakeover() throws Exception {
String candidates = sql(BridgeInboxMapper.class.getMethod("dispatchCandidates", int.class), Select.class);
String claim = sql(BridgeInboxMapper.class.getMethod(
"claim", String.class, String.class, long.class), Update.class);
String failed = sql(BridgeInboxMapper.class.getMethod(
"markFailed", String.class, String.class, int.class, long.class, String.class), Update.class);
String processed = sql(BridgeInboxMapper.class.getMethod(
"markProcessed", String.class, String.class), Update.class);
assertThat(candidates).contains("status IN ('RECEIVED','RETRY')", "next_retry_at <= CURRENT_TIMESTAMP(3)",
"status = 'PROCESSING'", "lease_until < CURRENT_TIMESTAMP(3)", "ORDER BY received_at");
assertThat(claim).contains("status='PROCESSING'", "lease_owner=#{owner}", "lease_until=DATE_ADD",
"status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)");
assertThat(failed).contains("retry_count=retry_count+1", "status=CASE", "THEN 'DEAD' ELSE 'RETRY'",
"next_retry_at=CASE", "THEN NULL", "last_error=#{error}", "lease_owner=#{owner}");
assertThat(processed).contains("status='PROCESSED'", "next_retry_at=NULL", "last_error=NULL",
"status='PROCESSING'", "lease_owner=#{owner}");
}
private <A extends java.lang.annotation.Annotation> String sql(Method method, Class<A> type) {
A annotation = method.getAnnotation(type);
String[] statements = annotation instanceof Select select ? select.value() : ((Update) annotation).value();
return String.join(" ", Arrays.asList(statements)).replaceAll("\\s+", " ");
}
}
@@ -5,7 +5,9 @@ import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.RegionCode; import com.baiyue.common.event.RegionCode;
import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.metrics.EventBridgeMetrics;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
@@ -24,6 +26,8 @@ import static org.mockito.Mockito.when;
class EventInboxServiceTest { class EventInboxServiceTest {
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
private final InboxStore store = mock(InboxStore.class); private final InboxStore store = mock(InboxStore.class);
private final SimpleMeterRegistry registry = new SimpleMeterRegistry();
private final EventBridgeMetrics metrics = new EventBridgeMetrics(registry);
private final EventInboxService service = service("OVERSEAS"); private final EventInboxService service = service("OVERSEAS");
@Test @Test
@@ -33,6 +37,7 @@ class EventInboxServiceTest {
assertThat(service.receive(envelope).result()).isEqualTo("已接收"); assertThat(service.receive(envelope).result()).isEqualTo("已接收");
verify(store).insert(any(BridgeInbox.class)); verify(store).insert(any(BridgeInbox.class));
assertThat(receiveCount("RECEIVED")).isEqualTo(1.0);
} }
@Test @Test
@@ -57,6 +62,7 @@ class EventInboxServiceTest {
when(store.findByEventId(envelope.eventId())).thenReturn(existing); when(store.findByEventId(envelope.eventId())).thenReturn(existing);
assertThat(service.receive(envelope).result()).isEqualTo("重复"); assertThat(service.receive(envelope).result()).isEqualTo("重复");
assertThat(receiveCount("DUPLICATE")).isEqualTo(1.0);
} }
@Test @Test
@@ -67,6 +73,7 @@ class EventInboxServiceTest {
assertThatThrownBy(() -> service.receive(envelope)) assertThatThrownBy(() -> service.receive(envelope))
.isInstanceOf(IllegalArgumentException.class) .isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("拒绝覆盖"); .hasMessageContaining("拒绝覆盖");
assertThat(receiveCount("CONFLICT")).isEqualTo(1.0);
} }
@Test @Test
@@ -113,10 +120,17 @@ class EventInboxServiceTest {
EventInboxService service = new EventInboxService(); EventInboxService service = new EventInboxService();
ReflectionTestUtils.setField(service, "inboxStore", store); ReflectionTestUtils.setField(service, "inboxStore", store);
ReflectionTestUtils.setField(service, "objectMapper", objectMapper); ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
ReflectionTestUtils.setField(service, "metrics", metrics);
ReflectionTestUtils.setField(service, "localRegion", region); ReflectionTestUtils.setField(service, "localRegion", region);
return service; return service;
} }
private double receiveCount(String result) {
return registry.get(EventBridgeMetrics.RECEIVE_METRIC)
.tags("target_region", "OVERSEAS", "event_type", "OrderApproved", "result", result)
.counter().count();
}
private EventEnvelope withHash(EventEnvelope source) { private EventEnvelope withHash(EventEnvelope source) {
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(source.payload())); String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(source.payload()));
return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(), return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(),
@@ -1,23 +1,46 @@
package com.baiyue.eventbridge.service; package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import com.baiyue.eventbridge.metrics.EventBridgeMetrics;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.apache.ibatis.annotations.Update;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
class EventReplayServiceTest { class EventReplayServiceTest {
private final BridgeInboxMapper mapper = mock(BridgeInboxMapper.class); private final BridgeInboxMapper mapper = mock(BridgeInboxMapper.class);
private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class);
private final SimpleMeterRegistry registry = new SimpleMeterRegistry();
private final EventBridgeMetrics metrics = new EventBridgeMetrics(registry);
private final EventReplayService service = service(); private final EventReplayService service = service();
@Test @Test
void requeuesDeadEventWithAuditReason() { void requeuesDeadEventWithAuditReason() {
when(mapper.replayDead(eq("event-1"), contains("incident-42"))).thenReturn(1); when(mapper.replayDead(eq("event-1"), contains("incident-42"))).thenReturn(1);
BridgeInbox event = new BridgeInbox();
event.setEventId("event-1"); event.setSourceRegion("CN"); event.setTargetRegion("OVERSEAS");
event.setEventType(com.baiyue.common.event.EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED);
when(mapper.selectById("event-1")).thenReturn(event);
when(mapper.nextAttemptNo("event-1")).thenReturn(4);
service.replayDead("event-1", "operator-7", "incident-42 approved"); service.replayDead("event-1", "operator-7", "incident-42 approved");
verify(mapper).replayDead(eq("event-1"), contains("operator-7")); verify(mapper).replayDead(eq("event-1"), contains("operator-7"));
var captor = org.mockito.ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
verify(attemptMapper).insert(captor.capture());
assertThat(captor.getValue().getAttemptNo()).isEqualTo(4);
assertThat(captor.getValue().getStatus()).isEqualTo("MANUAL_REPLAY");
assertThat(captor.getValue().getErrorMessage()).contains("operator-7", "incident-42");
assertThat(registry.get(EventBridgeMetrics.TRANSPORT_METRIC)
.tags("target_region", "OVERSEAS", "event_type", event.getEventType(),
"result", "MANUAL_REPLAY").counter().count()).isEqualTo(1.0);
} }
@Test @Test
@@ -28,9 +51,21 @@ class EventReplayServiceTest {
.isInstanceOf(IllegalStateException.class); .isInstanceOf(IllegalStateException.class);
} }
@Test
void replayOnlyRequeuesDeadStateAndDoesNotRewriteFrozenEvent() throws Exception {
String sql = String.join(" ", BridgeInboxMapper.class
.getMethod("replayDead", String.class, String.class)
.getAnnotation(Update.class).value()).replaceAll("\\s+", " ");
assertThat(sql).contains("status='RETRY'", "retry_count=0", "status='DEAD'")
.doesNotContain("payload_json=", "payload_hash=");
}
private EventReplayService service() { private EventReplayService service() {
EventReplayService service = new EventReplayService(); EventReplayService service = new EventReplayService();
ReflectionTestUtils.setField(service, "inboxMapper", mapper); ReflectionTestUtils.setField(service, "inboxMapper", mapper);
ReflectionTestUtils.setField(service, "attemptMapper", attemptMapper);
ReflectionTestUtils.setField(service, "metrics", metrics);
return service; return service;
} }
} }
@@ -0,0 +1,26 @@
package com.baiyue.eventbridge.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class HttpFirstlegEventClientTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void businessFailureIsDiagnosableAndMustNotBeTreatedAsAcknowledged() throws Exception {
var body = objectMapper.readTree("""
{"code":"A0409","message":"事件摘要冲突","requestId":"request-cn-1","traceId":"trace-cn-1"}
""");
assertThat(HttpFirstlegEventClient.failureMessage(200, body))
.isEqualTo("头程消费失败: HTTP 200, code=A0409, message=事件摘要冲突, requestId=request-cn-1, traceId=trace-cn-1");
}
@Test
void nonJsonFailureIsStillDiagnosableWithoutRawBody() {
assertThat(HttpFirstlegEventClient.failureMessage(503, null))
.isEqualTo("头程消费失败: HTTP 503, code=missing, message=non-json response, requestId=missing, traceId=missing");
}
}
@@ -0,0 +1,26 @@
package com.baiyue.eventbridge.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class HttpLastlegEventClientTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void failureIncludesBusinessCodeMessageAndTraceEvidence() throws Exception {
var body = objectMapper.readTree("""
{"code":"A0400","message":"入仓计划数据无效","requestId":"request-1","traceId":"trace-1"}
""");
assertThat(HttpLastlegEventClient.failureMessage(200, body))
.isEqualTo("尾程消费失败: HTTP 200, code=A0400, message=入仓计划数据无效, requestId=request-1, traceId=trace-1");
}
@Test
void nonJsonFailureIsStillDiagnosableWithoutLeakingRawBody() {
assertThat(HttpLastlegEventClient.failureMessage(502, null))
.isEqualTo("尾程消费失败: HTTP 502, code=missing, message=non-json response, requestId=missing, traceId=missing");
}
}
@@ -1,10 +1,13 @@
package com.baiyue.eventbridge.service; package com.baiyue.eventbridge.service;
import com.baiyue.common.event.EventTypes;
import com.baiyue.eventbridge.config.EventDispatcherProperties; import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import com.baiyue.eventbridge.metrics.EventBridgeMetrics;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
@@ -12,6 +15,8 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.net.URI; import java.net.URI;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.*;
@@ -20,10 +25,14 @@ import static org.mockito.Mockito.*;
class OverseasEventDispatcherTest { class OverseasEventDispatcherTest {
private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class); private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class);
private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class); private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class);
private final LastlegEventClient eventClient = mock(LastlegEventClient.class); private final com.baiyue.eventbridge.service.LastlegEventClient eventClient =
private final FirstlegEventClient firstlegEventClient = mock(FirstlegEventClient.class); mock(com.baiyue.eventbridge.service.LastlegEventClient.class);
private final com.baiyue.eventbridge.service.FirstlegEventClient firstlegEventClient =
mock(com.baiyue.eventbridge.service.FirstlegEventClient.class);
private final SecurityProjectionEventClient securityProjectionEventClient = mock(SecurityProjectionEventClient.class);
private OverseasEventDispatcher dispatcher; private OverseasEventDispatcher dispatcher;
private BridgeInbox event; private BridgeInbox event;
private SimpleMeterRegistry registry;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
@@ -32,16 +41,21 @@ class OverseasEventDispatcherTest {
Duration.ofSeconds(30), Duration.ofSeconds(5), Duration.ofMinutes(1), Duration.ofSeconds(30), Duration.ofSeconds(5), Duration.ofMinutes(1),
Duration.ofSeconds(3), Duration.ofSeconds(10)); Duration.ofSeconds(3), Duration.ofSeconds(10));
dispatcher = new OverseasEventDispatcher(); dispatcher = new OverseasEventDispatcher();
registry = new SimpleMeterRegistry();
ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper); ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper);
ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper); ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper);
ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient); ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient);
ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient); ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient);
ReflectionTestUtils.setField(dispatcher, "securityProjectionEventClient", securityProjectionEventClient);
ReflectionTestUtils.setField(dispatcher, "properties", properties); ReflectionTestUtils.setField(dispatcher, "properties", properties);
ReflectionTestUtils.setField(dispatcher, "metrics", new EventBridgeMetrics(registry));
event = new BridgeInbox(); event = new BridgeInbox();
event.setEventId("event-1"); event.setEventId("event-1");
event.setEventType(EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED);
event.setSourceRegion("CN"); event.setSourceRegion("CN");
event.setTargetRegion("OVERSEAS"); event.setTargetRegion("OVERSEAS");
event.setRetryCount(0); event.setRetryCount(0);
event.setReceivedAt(LocalDateTime.now().minusSeconds(5));
when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1); when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1);
when(inboxMapper.selectById("event-1")).thenReturn(event); when(inboxMapper.selectById("event-1")).thenReturn(event);
when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1); when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1);
@@ -55,27 +69,50 @@ class OverseasEventDispatcherTest {
dispatcher.dispatchOne("event-1"); dispatcher.dispatchOne("event-1");
verify(inboxMapper).markProcessed(eq("event-1"), anyString()); verify(inboxMapper).markProcessed(eq("event-1"), anyString());
verify(eventClient).deliver(event);
verifyNoInteractions(securityProjectionEventClient);
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
assertThat(transportCount("DELIVERED")).isEqualTo(1.0);
assertThat(registry.get(EventBridgeMetrics.DELIVERY_LATENCY_METRIC).timer().count()).isEqualTo(1);
} }
@Test @Test
void failedDeliveryIsScheduledForRetry() throws Exception { void failedDeliveryIsScheduledForRetry() throws Exception {
when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down")); when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down"));
when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down")))
.thenReturn(1);
dispatcher.dispatchOne("event-1"); dispatcher.dispatchOne("event-1");
verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down")); verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(5L), eq("network down"));
assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY"); assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY");
assertThat(transportCount("RETRY")).isEqualTo(1.0);
} }
@Test @Test
void finalFailureIsMovedToDeadState() throws Exception { void finalFailureIsMovedToDeadState() throws Exception {
event.setRetryCount(2); event.setRetryCount(2);
when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down")); when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down"));
when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("still down")))
.thenReturn(1);
dispatcher.dispatchOne("event-1"); dispatcher.dispatchOne("event-1");
verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(20L), eq("still down"));
assertThat(updatedAttempt().getStatus()).isEqualTo("DEAD"); assertThat(updatedAttempt().getStatus()).isEqualTo("DEAD");
assertThat(transportCount("DEAD")).isEqualTo(1.0);
}
@Test
void exponentialBackoffIsCappedAtConfiguredMaximum() throws Exception {
event.setRetryCount(4);
when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down"));
when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("still down")))
.thenReturn(1);
dispatcher.dispatchOne("event-1");
verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(60L), eq("still down"));
} }
@Test @Test
@@ -91,6 +128,7 @@ class OverseasEventDispatcherTest {
@Test @Test
void cnTargetIsDeliveredToFirstleg() throws Exception { void cnTargetIsDeliveredToFirstleg() throws Exception {
event.setEventType(EventTypes.OVERSEAS_CONTAINER_UNLOADED);
event.setSourceRegion("OVERSEAS"); event.setSourceRegion("OVERSEAS");
event.setTargetRegion("CN"); event.setTargetRegion("CN");
when(firstlegEventClient.deliver(event)).thenReturn("200"); when(firstlegEventClient.deliver(event)).thenReturn("200");
@@ -100,12 +138,150 @@ class OverseasEventDispatcherTest {
verify(firstlegEventClient).deliver(event); verify(firstlegEventClient).deliver(event);
verifyNoInteractions(eventClient); verifyNoInteractions(eventClient);
verifyNoInteractions(securityProjectionEventClient);
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
} }
@Test
void iamSecurityEventsAreDeliveredToSecurityProjectionAndNeverLastleg() throws Exception {
for (String eventType : List.of(EventTypes.IAM_USER_STATUS_CHANGED,
EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED)) {
reset(inboxMapper, attemptMapper, eventClient, firstlegEventClient, securityProjectionEventClient);
event.setEventType(eventType);
when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1);
when(inboxMapper.selectById("event-1")).thenReturn(event);
when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1);
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1);
when(securityProjectionEventClient.deliver(event)).thenReturn("200");
dispatcher.dispatchOne("event-1");
verify(securityProjectionEventClient).deliver(event);
verifyNoInteractions(eventClient);
verifyNoInteractions(firstlegEventClient);
}
}
@Test
void unsupportedTargetRegionIsNeverMisroutedToLastleg() {
event.setTargetRegion("UNKNOWN");
when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), contains("不支持")))
.thenReturn(1);
dispatcher.dispatchOne("event-1");
verifyNoInteractions(eventClient);
verifyNoInteractions(firstlegEventClient);
verifyNoInteractions(securityProjectionEventClient);
assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY");
}
@Test
void missingEventTypeFailsWithExplicitDiagnosticAndIsNeverDelivered() {
event.setEventType(null);
when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(),
eq("事件类型不能为空,无法选择下游消费者"))).thenReturn(1);
dispatcher.dispatchOne("event-1");
verifyNoInteractions(eventClient);
verifyNoInteractions(firstlegEventClient);
verifyNoInteractions(securityProjectionEventClient);
BridgeDeliveryAttempt attempt = updatedAttempt();
assertThat(attempt.getStatus()).isEqualTo("RETRY");
assertThat(attempt.getErrorMessage()).isEqualTo("事件类型不能为空,无法选择下游消费者");
}
@Test
void acknowledgedDeliveryWithLostLeaseIsNotRecordedAsRetryOrDead() throws Exception {
when(eventClient.deliver(event)).thenReturn("200");
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(0);
dispatcher.dispatchOne("event-1");
verify(inboxMapper, never()).markFailed(anyString(), anyString(), anyInt(), anyLong(), anyString());
BridgeDeliveryAttempt attempt = updatedAttempt();
assertThat(attempt.getStatus()).isEqualTo("ACKNOWLEDGED_LEASE_LOST");
assertThat(attempt.getResponseCode()).isEqualTo("200");
assertThat(attempt.getErrorMessage()).contains("租约已失效");
assertThat(transportCount("ACKNOWLEDGED_LEASE_LOST")).isEqualTo(1.0);
}
@Test
void failedDeliveryAfterLeaseTakeoverDoesNotOverwriteNewOwnerState() throws Exception {
when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down"));
when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down")))
.thenReturn(0);
dispatcher.dispatchOne("event-1");
assertThat(updatedAttempt().getStatus()).isEqualTo("FAILED_LEASE_LOST");
assertThat(transportCount("FAILED_LEASE_LOST")).isEqualTo(1.0);
}
@Test
void downstream503RetriesThenDeliversWithDiagnosticAttemptsInBothDirections() throws Exception {
for (String target : List.of("OVERSEAS", "CN")) {
reset(inboxMapper, attemptMapper, eventClient, firstlegEventClient);
event.setSourceRegion("CN".equals(target) ? "OVERSEAS" : "CN");
event.setTargetRegion(target);
event.setRetryCount(0);
when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1);
when(inboxMapper.selectById("event-1")).thenReturn(event);
when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1, 2);
doAnswer(invocation -> {
event.setRetryCount(1);
event.setStatus("RETRY");
event.setLastError(invocation.getArgument(4));
event.setNextRetryAt(java.time.LocalDateTime.now().plusSeconds(
((Number) invocation.getArgument(3)).longValue()));
return 1;
}).when(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), anyLong(), anyString());
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenAnswer(invocation -> {
event.setStatus("PROCESSED");
event.setLastError(null);
event.setNextRetryAt(null);
return 1;
});
if ("CN".equals(target)) {
when(firstlegEventClient.deliver(event))
.thenThrow(new IllegalStateException("HTTP 503 code=S0503 requestId=req-cn traceId=trace-cn"))
.thenReturn("200");
} else {
when(eventClient.deliver(event))
.thenThrow(new IllegalStateException("HTTP 503 code=S0503 requestId=req-overseas traceId=trace-overseas"))
.thenReturn("200");
}
dispatcher.dispatchOne("event-1");
assertThat(event.getStatus()).isEqualTo("RETRY");
assertThat(event.getNextRetryAt()).isNotNull();
assertThat(event.getLastError()).contains("HTTP 503", "requestId=", "traceId=");
dispatcher.dispatchOne("event-1");
ArgumentCaptor<BridgeDeliveryAttempt> attempts = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
verify(attemptMapper, times(2)).updateById(attempts.capture());
assertThat(attempts.getAllValues()).extracting(BridgeDeliveryAttempt::getAttemptNo)
.containsExactly(1, 2);
assertThat(attempts.getAllValues()).extracting(BridgeDeliveryAttempt::getStatus)
.containsExactly("RETRY", "DELIVERED");
assertThat(attempts.getAllValues().get(0).getErrorMessage())
.contains("HTTP 503", "requestId=", "traceId=");
assertThat(event.getStatus()).isEqualTo("PROCESSED");
assertThat(event.getLastError()).isNull();
}
}
private BridgeDeliveryAttempt updatedAttempt() { private BridgeDeliveryAttempt updatedAttempt() {
ArgumentCaptor<BridgeDeliveryAttempt> captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); ArgumentCaptor<BridgeDeliveryAttempt> captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
verify(attemptMapper).updateById(captor.capture()); verify(attemptMapper).updateById(captor.capture());
return captor.getValue(); return captor.getValue();
} }
private double transportCount(String result) {
return registry.get(EventBridgeMetrics.TRANSPORT_METRIC)
.tags("target_region", event.getTargetRegion(),
"event_type", event.getEventType(), "result", result)
.counter().count();
}
} }
@@ -23,7 +23,8 @@ public class InternalBusinessFileService {
"LOADING_CUSTOMS_DOCUMENT", "LOADING_CUSTOMS_DOCUMENT",
"CONTAINER_DELIVERY_ORDER", "CONTAINER_DELIVERY_ORDER",
"CONTAINER_PICKUP_ORDER", "CONTAINER_PICKUP_ORDER",
"OVERSEAS_UNLOAD_PHOTO"); "OVERSEAS_UNLOAD_PHOTO",
"OVERSEAS_RELABEL_PHOTO");
@Resource private FileObjectMapper files; @Resource private FileObjectMapper files;
@Resource private BusinessFileStorage storage; @Resource private BusinessFileStorage storage;
@@ -80,6 +80,22 @@ class InternalBusinessFileServiceTest {
verifyNoInteractions(files, storage); verifyNoInteractions(files, storage);
} }
@Test void overseasRelabelPhotoPersistsExactTaskOwnership() {
MockMultipartFile file = new MockMultipartFile("file", "relabel.jpg", "image/jpeg", "photo".getBytes());
when(storage.storeLoading(100L, 81L, file)).thenReturn(new BusinessFileStorage.Stored(
"business-files/100/81/relabel.jpg", "image/jpeg", file.getSize(), "d".repeat(64), "relabel.jpg"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(91L); return 1;
});
var result = service.upload(100L, "OVERSEAS_RELABEL_PHOTO", "81", 7L, file);
assertThat(result).extracting(InternalBusinessFileService.Metadata::tenantId,
InternalBusinessFileService.Metadata::businessType,
InternalBusinessFileService.Metadata::businessId)
.containsExactly(100L, "OVERSEAS_RELABEL_PHOTO", "81");
}
@Test void unsupportedBusinessTypeFailsClosed() { @Test void unsupportedBusinessTypeFailsClosed() {
assertThatThrownBy(() -> service.upload(10001L, "ARBITRARY", "41", 7L, assertThatThrownBy(() -> service.upload(10001L, "ARBITRARY", "41", 7L,
new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes()))) new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes())))
+2 -1
View File
@@ -28,10 +28,11 @@
| 分柜与预装 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java` | | 分柜与预装 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java` |
| 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;按入仓码候选与箱数分配分别从 `/inbound-candidates`、`/plans/{planId}/inbound-assignments` 进入,明细规格拆分留在服务端 | | 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;按入仓码候选与箱数分配分别从 `/inbound-candidates`、`/plans/{planId}/inbound-assignments` 进入,明细规格拆分留在服务端 |
| PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` | | PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` |
| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;按最终实装与稳定箱码冻结 V2,真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType=FBA|OVERSEAS`,OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | | 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` | | 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` |
| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
| 拖车与出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java` | | 拖车与出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java` |
| 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` | | 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` |
@@ -0,0 +1,200 @@
# Firstleg 内部模块化基线与首切片方案
## 1. 范围与生成方式
本基线只分析 `firstleg` 同一部署单元,不拆微服务,不改变业务流程、数据库结构、OpenAPI 或跨服务契约。分析输入为:
- 首切片实施前 `src/main/java`:429 个 Java 文件;948 条可解析的模块内类依赖;66 个物理包。
- 首切片实施前 `src/test/java`:169 个 Java 文件。与主源码合并分析时共 598 个 Java 文件、1,635 条模块内类依赖、69 个物理包。
- 首切片实施后 `src/main/java`:430 个 Java 文件;949 条可解析的模块内类依赖;67 个物理包。增加项仅为只读 Out Port。
可重复命令:
```powershell
node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set main --format markdown
node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set all --format json
node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set main --format dot
```
分析器解析显式内部 `import`、静态 `import` 及同包类型引用,输出物理包图、逻辑切片图、SCC、跨切片类依赖、Entity/DTO 泄漏和 Mapper 泄漏。Spring 字符串扫描路径、反射、MyBatis SQL 中的表引用不属于 Java 类型图,因此在本报告单独标注。
## 2. 当前 SCC
### 2.1 主源码物理包 SCC
当前只有一个多节点物理包 SCC:
```text
com.baiyue.firstleg.mapper
<-> com.baiyue.firstleg.repository
```
直接证据是全局 Repository 大量依赖全局 Mapper,同时 `InboundAllocationQueryMapper` 反向引用 `repository.PriceBookQueryRows`。这说明全局数据访问层尚不能按传统横向层直接单向化。
合并测试源码后,测试对生产类型的依赖把 SCC 扩大为下列十包;这不是生产运行时环,但表示测试也直接绑定了横向实现包:
```text
controller -> loading.repository -> loading.service -> mapper
-> oceanbooking.controller -> oceanbooking.repository -> oceanbooking.service
-> pricing -> repository -> service -> controller
```
### 2.2 逻辑切片 SCC
逻辑切片图存在一个八节点 SCC:
```text
booking-customs-trucking
finance-integration
inbound
measurement-pricing
outbound
shared-technical
shipping-nodes
split-loading
```
`customer-statistics` 不在 SCC 内。当前 36 条切片间边均冻结在 `src/test/resources/architecture/firstleg-slice-dependencies.json`;合同允许删除现有边,不允许增加新边。
主要双向依赖包括:
| 双向关系 | 直接证据 |
|---|---|
| 入库 ↔ 测量计价 | 入库编排直接使用测量/费用 Entity、Mapper;计价与客户费用查询直接使用入库单、状态和 Outbox |
| 入库 ↔ 分柜装柜 | 统一入库读模型读取装柜/PDA Mapper 与 Entity;装柜结算和事件发布读取入库状态、托盘与 Outbox |
| 分柜装柜 ↔ 出仓 | 货柜聚合读出仓历史;海外卸柜回写反向更新货柜/装柜事实 |
| 订舱报关拖车 ↔ 出仓 | 出仓门禁聚合报关/船期贡献者;报关和船期贡献者反向依赖出仓 DTO |
| 订舱报关拖车 ↔ 分柜装柜 | 订舱货柜分配使用装柜实体;报关直接读取装柜计划与明细 Mapper |
| 订舱报关拖车 ↔ 测量计价 | 价格簿校验影响订舱;供应商/渠道校验又由全局计价服务消费 |
## 3. 八个业务切片现状映射
类数量和跨切片依赖数量以主源码图为准;“出/入”均为类级直接依赖条数。
| 业务切片 | 当前物理位置与识别范围 | 类数 | 出/入 | 主要边界问题 |
|---|---|---:|---:|---|
| 入库 | 全局 `controller/service/repository/mapper/entity/dto` 中 `Inbound*`、`Fulfillment*`、`Assignment*`、`UnifiedInbound*` | 67 | 26/60 | 被计价和装柜大量反向读取;`InboundWorkflowRepository` 直接持有测量/费用 Mapper |
| 测量计价 | 全局层中的 `Measurement*`、`Mojia*`、`Pricing*`、`PriceBook*`、`Fba*`、`Dimension*`、`FeeItem*`、`InboundFee*`,以及 `pricing/` | 113 | 72/25 | 最大切片;对入库有 35 条直接依赖,其中 20 条为跨切片数据模型泄漏、6 条为 Mapper 泄漏 |
| 分柜装柜 | `split/`、`loading/` 加全局 `Loading*`、`SmartLoading*`、货柜与 PDA 类 | 82 | 50/32 | 同时依赖入库、订舱、出仓和共享层;全局 Repository 直接读取 `loading/split` Mapper/Entity |
| 订舱报关拖车 | `oceanbooking/`、`customs/`,全局 `TruckAppointment*`、`WarehouseSupplier*`、出仓门禁贡献者 | 68 | 25/13 | `CustomsRepository` 直接读取入库与装柜 Mapper;订舱 Repository 直接读取全局货柜 Entity |
| 出仓 | `outbound/dispatch/` 与全局 `OutboundDispatch*`、运输历史、海外卸柜回写 | 17 | 8/10 | 与装柜、订舱门禁双向;海外回写直接使用全局货柜 Mapper/Entity |
| 船节点 | `portschedule/` 中 `CarrierScheduleQuery/Node/Adapter/Attempt/Exception*` | 22 | 8/1 | 查询服务直接读取订舱、船期、货柜 Entity;节点 Outbox 直接使用入库 Outbox Mapper/Entity |
| 客户统计 | `customerstatistics/adapter`、`customerstatistics/application` | 5 | 1/0 | 已完成首切片;唯一跨切片依赖为 Web Adapter 调用 `FirstlegScopeGuard`。SQL 只读订舱、货柜和装柜表 |
| 财务集成 | 全局 `InternalFinance*`、`FinanceFeeSource*`、`FinanceCostSnapshot*`、付款凭证/快照类 | 20 | 17/6 | 直接读取入库费、柜次费、装柜及共享 Outbox 的 Entity/Mapper;继续受财务总控冻结的业务口径约束 |
共享技术层当前有 36 类,包含应用启动/配置、Scope Guard、单据号、部分跨服务 Client 及无法安全归属的全局模型。跨服务适配器按 `client` 包独立标识,不能因逻辑切片归属而被迁入应用核心。
## 4. 泄漏与逆向依赖基线
主源码共有 88 条跨切片 Entity/DTO 泄漏、31 条跨切片 Mapper 泄漏。高频项如下:
| 来源 → 目标 | Entity/DTO | Mapper | 代表性证据 |
|---|---:|---:|---|
| 测量计价 → 入库 | 20 | 6 | `FbaPricingRepository -> InboundOutboxEntity/Mapper`;客户费用查询直接使用 `InboundOrderDetail` |
| 分柜装柜 → 入库 | 7 | 6 | `LoadingDeliveryService -> InboundStatusHistoryEntity/Mapper`;装柜 Outbox 复用入库 Outbox |
| 入库 → 测量计价 | 4 | 4 | `InboundWorkflowRepository -> InboundFee/Measurement/Review Mapper` |
| 入库 → 分柜装柜 | 4 | 3 | `UnifiedInboundReadRepository` 直接读取装柜证据、PDA 任务、预装计划 Mapper/Entity |
| 财务集成 → 入库 | 4 | 2 | 财务来源 Outbox 直接复用 `InboundOutboxEntity/Mapper` |
| 订舱报关拖车 → 分柜装柜 | 3 | 3 | `CustomsRepository -> LoadingPlan/LoadingPlanItem Mapper` |
| 分柜装柜 → 订舱报关拖车 | 3 | 0 | `ContainerService -> OceanBookingContainer*Entity` |
| 船节点 → 入库 | 1 | 1 | `CarrierScheduleNodeOutboxService -> InboundOutboxEntity/Mapper` |
逆向依赖的判定不是简单的 `controller -> service -> repository` 层次关系,而是“业务核心直接依赖另一切片的持久化实现或 Entity”。首轮治理应优先用查询/命令 Port 替代这些直接引用,不共享领域 Entity。
## 5. 首切片选择:客户统计
选择 `customer-statistics`,不是因为预设,而是因为以下实际证据:
1. 实施前仅 4 个生产类且已集中在单一顶层包;实施后为原 4 类加 1 个只读 Out Port,没有业务切片对其产生 Java 类型入向依赖。
2. 唯一跨切片 Java 依赖为 Web Controller 对共享 `FirstlegScopeGuard` 的调用,不涉及 Entity 或 Mapper 泄漏。
3. 不属于当前八节点 SCC,也没有跨服务 Feign/HTTP Client。
4. 现有调用面稳定:一个平台 GET 接口、一个 Admin API 消费者、两个后端测试。
5. 它仍包含一个明确的内部逆向依赖:应用 Service 直接依赖 MyBatis Mapper,适合用最小 Out Port 证明目标依赖方向。
限制:`CustomerTransitStatisticsMapper` 的 SQL 只读 `fl_ocean_booking`、`fl_container_booking`、`fl_loading_plan`、`fl_loading_plan_item`。首切片只隔离 Java 依赖,不宣称拥有这些表,也不改变 SQL、索引或统计口径。
## 6. 首切片目标目录与允许依赖
```text
customerstatistics/
adapter/in/web/
PlatformCustomerTransitStatisticsController.java
application/
CustomerTransitStatisticsQueryService.java
model/CustomerTransitStatisticsModels.java
port/out/CustomerTransitStatisticsReader.java
adapter/out/persistence/
CustomerTransitStatisticsMapper.java
```
允许方向:
```text
adapter.in.web -> application
adapter.out.persistence -> application.port.out
application -> application.model + common-core
```
额外允许项仅限 Web 适配器使用 `common-security`、Spring Web 和现有 `FirstlegScopeGuard`。禁止 `application` 依赖 MyBatis、Spring MVC、全局 `entity/mapper/repository/service` 或其他业务切片。
## 7. 精确迁移清单、调用方与测试
首切片已按以下清单迁移/新增:
| 当前文件 | 目标/动作 |
|---|---|
| `customerstatistics/controller/PlatformCustomerTransitStatisticsController.java` | 移到 `adapter/in/web/`,URI、权限和响应保持不变 |
| `customerstatistics/dto/CustomerTransitStatisticsDtos.java` | 移到 `application/model/`,保持 JSON 字段与数值语义不变 |
| `customerstatistics/service/CustomerTransitStatisticsService.java` | 移到 `application/` 并改名为 Query Service,只依赖 Out Port |
| `customerstatistics/mapper/CustomerTransitStatisticsMapper.java` | 移到 `adapter/out/persistence/`,实现/扩展只读 Out Port;SQL 原样保留 |
| 新增 `customerstatistics/application/port/out/CustomerTransitStatisticsReader.java` | 定义 `summarize(customerId)` 与只读投影,不暴露 MyBatis 类型 |
| `src/test/java/.../customerstatistics/CustomerTransitStatisticsServiceTest.java` | Mock Out Port,不再 Mock Mapper |
| `src/test/java/.../customerstatistics/CustomerTransitStatisticsContractTest.java` | 更新包引用,继续锁定 URI、权限、SQL、OpenAPI 与索引语义 |
| `BaiyueFirstlegApplication.java`、`BaiyueFirstlegApplicationContractTest.java` | 只有 Mapper 扫描路径因移动确需调整时修改;不改变其他扫描包 |
外部调用方只读核对,不在首切片修改:
- `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml`
- `apps/admin-web/src/modules/container-loading/api/platformContainerLoading.ts`
- `apps/admin-web/src/modules/container-loading/api/platformContainerLoading.test.ts`
## 8. 回滚边界
- 不修改表、SQL 内容、接口路径、权限码、响应字段、统计定义、Nacos、部署或跨服务契约。
- 回滚只涉及上述客户统计 Java 包移动、Out Port 和对应测试;可整体恢复到现有四类结构。
- 若 Mapper 继承 Out Port 导致 MyBatis 代理行为或扫描合同失败,停止实施并恢复包内变更,不以增加全局扫描、共享 POM 依赖或兼容 Bean 绕过。
- 首切片完成前不开始第二切片。
## 9. 当前只读合同与后续协作
本轮已增加模块内、无第三方依赖的只读合同:
- `scripts/analyze-firstleg-dependencies.mjs`:生成 JSON/Markdown/DOT 和 Tarjan SCC。
- `src/test/resources/architecture/firstleg-slice-dependencies.json`:冻结当前切片边;历史边可减少,不可增加。
- `scripts/analyze-firstleg-dependencies.test.mjs`:验证基线无新增边,并锁定客户统计目标 5 类/目录、唯一出向边、零入向边、不进入 SCC 和 application 依赖禁令。
与其他架构专项的依赖:
- 专项一(拓扑与质量门禁):决定是否引入 ArchUnit、如何把本合同接入 Maven/CI、以及共享 POM 和全局质量门禁。当前未修改 POM;建议由专项一把本脚本作为 Firstleg 局部门禁接入,再以 ArchUnit 替换/增强源码级解析。
- 专项二(同步依赖与失败传播):在为其他切片定义应用 Port 前提供 Feign/HTTP 调用图和失败语义。客户统计当前没有跨服务 Client,因此首切片无需等待专项二;后续入库、财务集成、订舱和装柜切片必须协调。
## 10. 最小验证命令
```powershell
node --test firstleg/scripts/analyze-firstleg-dependencies.test.mjs
node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set main --format markdown
mvn -pl firstleg -am test-compile
mvn -pl firstleg -am -Dtest=CustomerTransitStatisticsServiceTest,CustomerTransitStatisticsContractTest -Dsurefire.failIfNoSpecifiedTests=false test
```
首切片已经发生 Java 包与依赖签名变化,三条命令均为直接门禁;另需执行 `mvn -pl firstleg -am test-compile` 覆盖全部测试源码调用点。
## 11. 首切片实施结果
- 四个旧包类已删除,没有转发兼容层;目标五层目录已由 Node 合同锁定。
- `CustomerTransitStatisticsReader.SummaryProjection` 只包含 `customerId`、`containerCount`、`totalTeu`、`unknownContainerCount`、`asOf`,不暴露 MyBatis、Entity 或其他切片类型。
- 依赖方向由 `application Service -> MyBatis Mapper` 改为 `application Service -> application Port <- persistence Mapper`。
- 主源码由 429 类/948 条内部依赖/66 包变为 430 类/949 条内部依赖/67 包;36 条切片间边、88 条模型泄漏、31 条 Mapper 泄漏及全局 `mapper <-> repository` SCC 均未增加。客户统计仍只有 `adapter.in.web -> FirstlegScopeGuard` 一条跨切片边,且没有入向边。
- 根 Mapper 扫描只把旧精确包替换为新精确包,没有扩大扫描范围或增加兼容 Bean。
- 验证结果:Node 合同 3/3;`test-compile` 成功;客户统计加根扫描定向测试 7/7;`mvn -pl firstleg -am test` 共 854 个测试,0 失败、0 错误、3 跳过,构建成功。
- 首次并发执行 `test-compile` 时,其他专项同时写入共享 `target/classes` 导致若干无关 class 文件瞬时缺失;源符号核对正常,独立重跑 `test-compile` 与完整测试均成功。
+1
View File
@@ -23,6 +23,7 @@
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency> <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>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>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>
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency> <dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>
</dependencies> </dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build> <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
@@ -0,0 +1,319 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const moduleRoot = path.resolve(import.meta.dirname, '..');
const sourceRoots = {
main: path.join(moduleRoot, 'src', 'main', 'java'),
test: path.join(moduleRoot, 'src', 'test', 'java'),
};
const internalPrefix = 'com.baiyue.firstleg.';
const sliceRules = [
['customer-statistics', /(?:^|\.)(?:customerstatistics)(?:\.|$)/],
['finance-integration', /(?:InternalFinance|FinanceFeeSource|FinanceCostSnapshot|FeePaymentVoucher|FeePaymentSnapshot|ContainerFeeSourceSnapshot)/],
['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException)/],
['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/],
['outbound', /(?:^|\.)outbound(?:\.|$)|(?:OutboundDispatch|ContainerTransport|OverseasContainerEvent)/],
['split-loading', /(?:^|\.)(?:split|loading)(?:\.|$)|(?:Loading|SmartLoading|Container(?:Booking|Overview|Service|Dtos)|PdaPick)/],
['measurement-pricing', /(?:^|\.)pricing(?:\.|$)|(?:Measurement|MeasureReview|Mojia|Pricing|PriceBook|PriceChannel|PriceOrigin|PriceWarehouse|Fba|Dimension|FeeItem|MerchantInboundBilling|InboundFee)/],
['inbound', /(?:Inbound|Fulfillment|Assignment|UnifiedInbound|CartonLabel)/],
];
function parseArgs(argv) {
const options = { sourceSet: 'main', format: 'json', output: null, check: null };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--source-set') options.sourceSet = argv[++index];
else if (argument === '--format') options.format = argv[++index];
else if (argument === '--output') options.output = argv[++index];
else if (argument === '--check') options.check = argv[++index];
else if (argument === '--help') {
process.stdout.write('Usage: node scripts/analyze-firstleg-dependencies.mjs [--source-set main|test|all] [--format json|markdown|dot] [--output FILE] [--check CONTRACT.json]\n');
process.exit(0);
} else throw new Error(`Unknown argument: ${argument}`);
}
if (!['main', 'test', 'all'].includes(options.sourceSet)) throw new Error(`Invalid source set: ${options.sourceSet}`);
if (!['json', 'markdown', 'dot'].includes(options.format)) throw new Error(`Invalid format: ${options.format}`);
return options;
}
function walkJavaFiles(root) {
if (!fs.existsSync(root)) return [];
const result = [];
const pending = [root];
while (pending.length > 0) {
const current = pending.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
const absolute = path.join(current, entry.name);
if (entry.isDirectory()) pending.push(absolute);
else if (entry.isFile() && entry.name.endsWith('.java')) result.push(absolute);
}
}
return result.sort();
}
function stripCommentsAndStrings(source) {
return source
.replace(/\/\*[\s\S]*?\*\//g, ' ')
.replace(/\/\/[^\r\n]*/g, ' ')
.replace(/"(?:\\.|[^"\\])*"/g, '""')
.replace(/'(?:\\.|[^'\\])*'/g, "''");
}
function classifySlice(fqcn) {
for (const [slice, expression] of sliceRules) {
if (expression.test(fqcn)) return slice;
}
return 'shared-technical';
}
function classifyKind(fqcn) {
const packageName = fqcn.slice(0, fqcn.lastIndexOf('.'));
if (packageName.includes('.client')) return 'cross-service-adapter';
if (/\.(adapter|controller|repository|mapper|config|security)(?:\.|$)/.test(packageName)) return 'technical-adapter';
if (/\.(entity|dto)(?:\.|$)/.test(packageName)) return 'data-model';
return 'business-application';
}
function parseSource(file, sourceSet) {
const raw = fs.readFileSync(file, 'utf8');
const source = stripCommentsAndStrings(raw);
const packageName = source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1];
const typeName = path.basename(file, '.java');
if (!packageName) throw new Error(`Missing package declaration: ${file}`);
const fqcn = `${packageName}.${typeName}`;
const imports = [...source.matchAll(/\bimport\s+(?:static\s+)?([\w.]+)(?:\.\*)?\s*;/g)].map(match => match[1]);
return {
file: path.relative(moduleRoot, file).replaceAll(path.sep, '/'),
sourceSet,
packageName,
typeName,
fqcn,
imports,
source,
slice: classifySlice(fqcn),
kind: classifyKind(fqcn),
};
}
function collectSources(sourceSet) {
const selected = sourceSet === 'all' ? ['main', 'test'] : [sourceSet];
return selected.flatMap(set => walkJavaFiles(sourceRoots[set]).map(file => parseSource(file, set)));
}
function resolveDependencies(sources) {
const knownTypes = new Map(sources.map(source => [source.fqcn, source]));
const typesByPackage = new Map();
for (const source of sources) {
if (!typesByPackage.has(source.packageName)) typesByPackage.set(source.packageName, []);
typesByPackage.get(source.packageName).push(source);
}
const dependencies = [];
const seen = new Set();
for (const source of sources) {
const candidates = new Set();
for (const imported of source.imports) {
if (knownTypes.has(imported)) candidates.add(imported);
else if (imported.startsWith(internalPrefix)) {
const owner = [...knownTypes.keys()].find(type => imported.startsWith(`${type}.`));
if (owner) candidates.add(owner);
}
}
for (const samePackageType of typesByPackage.get(source.packageName) ?? []) {
if (samePackageType.fqcn !== source.fqcn && new RegExp(`\\b${samePackageType.typeName}\\b`).test(source.source)) {
candidates.add(samePackageType.fqcn);
}
}
for (const targetFqcn of candidates) {
if (targetFqcn === source.fqcn) continue;
const key = `${source.fqcn}->${targetFqcn}`;
if (seen.has(key)) continue;
seen.add(key);
dependencies.push({ source: source.fqcn, target: targetFqcn });
}
}
return dependencies.sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`));
}
function aggregateEdges(dependencies, nodeOf) {
const counts = new Map();
for (const dependency of dependencies) {
const source = nodeOf(dependency.source);
const target = nodeOf(dependency.target);
if (source === target) continue;
const key = `${source}\0${target}`;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return [...counts].map(([key, count]) => {
const [source, target] = key.split('\0');
return { source, target, count };
}).sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`));
}
function stronglyConnectedComponents(nodes, edges) {
const adjacency = new Map(nodes.map(node => [node, []]));
for (const edge of edges) adjacency.get(edge.source)?.push(edge.target);
const indexByNode = new Map();
const lowLink = new Map();
const stack = [];
const onStack = new Set();
const components = [];
let nextIndex = 0;
function visit(node) {
indexByNode.set(node, nextIndex);
lowLink.set(node, nextIndex);
nextIndex += 1;
stack.push(node);
onStack.add(node);
for (const target of adjacency.get(node) ?? []) {
if (!indexByNode.has(target)) {
visit(target);
lowLink.set(node, Math.min(lowLink.get(node), lowLink.get(target)));
} else if (onStack.has(target)) {
lowLink.set(node, Math.min(lowLink.get(node), indexByNode.get(target)));
}
}
if (lowLink.get(node) === indexByNode.get(node)) {
const component = [];
let current;
do {
current = stack.pop();
onStack.delete(current);
component.push(current);
} while (current !== node);
components.push(component.sort());
}
}
for (const node of [...nodes].sort()) if (!indexByNode.has(node)) visit(node);
return components.filter(component => component.length > 1).sort((a, b) => b.length - a.length || a[0].localeCompare(b[0]));
}
function buildReport(sourceSet) {
const sources = collectSources(sourceSet);
const sourceByFqcn = new Map(sources.map(source => [source.fqcn, source]));
const dependencies = resolveDependencies(sources);
const packages = [...new Set(sources.map(source => source.packageName))].sort();
const slices = [...new Set(sources.map(source => source.slice))].sort();
const packageEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).packageName);
const sliceEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).slice);
const packageSccs = stronglyConnectedComponents(packages, packageEdges);
const sliceSccs = stronglyConnectedComponents(slices, sliceEdges);
const directCrossSliceDependencies = dependencies.filter(edge => sourceByFqcn.get(edge.source).slice !== sourceByFqcn.get(edge.target).slice);
const modelLeaks = directCrossSliceDependencies.filter(edge => ['data-model'].includes(sourceByFqcn.get(edge.target).kind));
const mapperLeaks = directCrossSliceDependencies.filter(edge => /\.mapper(?:\.|$)/.test(sourceByFqcn.get(edge.target).packageName));
return {
schemaVersion: 1,
generatedFrom: sourceSet,
moduleRoot: 'firstleg',
summary: {
javaFiles: sources.length,
internalClassDependencies: dependencies.length,
packages: packages.length,
slices: slices.length,
crossSliceClassDependencies: directCrossSliceDependencies.length,
crossSliceModelLeaks: modelLeaks.length,
crossSliceMapperLeaks: mapperLeaks.length,
},
sliceDefinitions: Object.fromEntries(sliceRules.map(([name, expression]) => [name, expression.source]).concat([['shared-technical', 'fallback']])),
classes: sources.map(({ source, ...entry }) => entry),
classDependencies: dependencies,
packageEdges,
sliceEdges,
packageSccs,
sliceSccs,
directCrossSliceDependencies,
modelLeaks,
mapperLeaks,
};
}
function renderMarkdown(report) {
const lines = [
'# Firstleg internal dependency report', '',
`- Source set: ${report.generatedFrom}`,
`- Java files: ${report.summary.javaFiles}`,
`- Internal class dependencies: ${report.summary.internalClassDependencies}`,
`- Package SCCs (>1 node): ${report.packageSccs.length}`,
`- Slice SCCs (>1 node): ${report.sliceSccs.length}`,
'', '## Slice edges', '', '| From | To | Class edges |', '|---|---|---:|',
...report.sliceEdges.map(edge => `| ${edge.source} | ${edge.target} | ${edge.count} |`),
'', '## Slice SCCs', '',
...(report.sliceSccs.length ? report.sliceSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']),
'', '## Package SCCs', '',
...(report.packageSccs.length ? report.packageSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']),
'', '## Cross-slice model and mapper leaks', '',
...[...report.modelLeaks, ...report.mapperLeaks]
.filter((edge, index, entries) => entries.findIndex(candidate => candidate.source === edge.source && candidate.target === edge.target) === index)
.map(edge => `- \`${edge.source}\` -> \`${edge.target}\``),
'',
];
return lines.join('\n');
}
function renderDot(report) {
const lines = ['digraph firstleg_slices {', ' rankdir=LR;'];
for (const edge of report.sliceEdges) lines.push(` "${edge.source}" -> "${edge.target}" [label="${edge.count}"];`);
lines.push('}', '');
return lines.join('\n');
}
function checkContract(report, contractPath) {
const absolute = path.resolve(process.cwd(), contractPath);
const contract = JSON.parse(fs.readFileSync(absolute, 'utf8'));
const allowed = new Set(contract.allowedSliceEdges ?? []);
const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`);
const additions = actual.filter(edge => !allowed.has(edge));
const violations = additions.map(edge => `new slice dependency: ${edge}`);
const classesByName = new Map(report.classes.map(entry => [entry.fqcn, entry]));
for (const [fqcn, expectedFile] of Object.entries(contract.requiredClassLocations ?? {})) {
const actualClass = classesByName.get(fqcn);
if (!actualClass) violations.push(`required class missing: ${fqcn}`);
else if (actualClass.file !== expectedFile) violations.push(`class location changed: ${fqcn} (${actualClass.file})`);
}
const applicationRule = contract.applicationDependencyRule;
if (applicationRule) {
const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(applicationRule.packagePrefix));
for (const entry of applicationClasses) {
for (const imported of entry.imports) {
if ((applicationRule.forbiddenImportPrefixes ?? []).some(prefix => imported.startsWith(prefix))) {
violations.push(`forbidden application import: ${entry.fqcn} -> ${imported}`);
}
}
}
for (const edge of report.classDependencies) {
if (edge.source.startsWith(applicationRule.packagePrefix)
&& edge.target.startsWith(internalPrefix)
&& !edge.target.startsWith(applicationRule.allowedInternalPrefix)) {
violations.push(`forbidden application dependency: ${edge.source} -> ${edge.target}`);
}
}
}
for (const [slice, rule] of Object.entries(contract.sliceSpecificRules ?? {})) {
const outgoing = report.sliceEdges.filter(edge => edge.source === slice).map(edge => edge.target).sort();
const incoming = report.sliceEdges.filter(edge => edge.target === slice).map(edge => edge.source).sort();
if (JSON.stringify(outgoing) !== JSON.stringify([...(rule.allowedOutgoing ?? [])].sort())) {
violations.push(`${slice} outgoing dependencies changed: ${outgoing.join(', ')}`);
}
if (JSON.stringify(incoming) !== JSON.stringify([...(rule.allowedIncoming ?? [])].sort())) {
violations.push(`${slice} incoming dependencies changed: ${incoming.join(', ')}`);
}
}
if (violations.length > 0) {
process.stderr.write(`Firstleg dependency contract violations in ${contractPath}:\n${violations.map(item => `- ${item}`).join('\n')}\n`);
process.exitCode = 1;
}
}
const options = parseArgs(process.argv.slice(2));
const report = buildReport(options.sourceSet);
if (options.check) checkContract(report, options.check);
const rendered = options.format === 'json'
? `${JSON.stringify(report, null, 2)}\n`
: options.format === 'markdown'
? renderMarkdown(report)
: renderDot(report);
if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8');
else process.stdout.write(rendered);
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import test from 'node:test';
const moduleRoot = path.resolve(import.meta.dirname, '..');
const analyzer = path.join(moduleRoot, 'scripts', 'analyze-firstleg-dependencies.mjs');
const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'firstleg-slice-dependencies.json');
function analyze() {
return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'json'], {
cwd: moduleRoot,
encoding: 'utf8',
}));
}
test('current source does not add slice dependencies beyond the baseline', () => {
execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'dot', '--check', contract], {
cwd: moduleRoot,
encoding: 'utf8',
});
});
test('customer statistics remains an independently packaged low-coupling slice', () => {
const report = analyze();
const classes = report.classes.filter(entry => entry.slice === 'customer-statistics');
assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [
'com.baiyue.firstleg.customerstatistics.adapter.in.web.PlatformCustomerTransitStatisticsController',
'com.baiyue.firstleg.customerstatistics.adapter.out.persistence.CustomerTransitStatisticsMapper',
'com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService',
'com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos',
'com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader',
]);
const outgoing = report.sliceEdges.filter(edge => edge.source === 'customer-statistics');
const incoming = report.sliceEdges.filter(edge => edge.target === 'customer-statistics');
assert.deepEqual(outgoing, [{ source: 'customer-statistics', target: 'shared-technical', count: 1 }]);
assert.deepEqual(incoming, []);
assert.equal(report.sliceSccs.some(component => component.includes('customer-statistics')), false);
});
test('customer statistics application layer only depends inward', () => {
const report = analyze();
const prefix = 'com.baiyue.firstleg.customerstatistics.application';
const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(prefix));
assert.equal(applicationClasses.length, 3);
for (const entry of applicationClasses) {
assert.equal(entry.imports.some(imported => imported.startsWith('org.springframework.web')), false);
assert.equal(entry.imports.some(imported => imported.startsWith('org.apache.ibatis')), false);
assert.equal(entry.imports.some(imported => imported.startsWith('com.baomidou.mybatisplus')), false);
}
const outwardDependencies = report.classDependencies.filter(edge => edge.source.startsWith(prefix)
&& edge.target.startsWith('com.baiyue.firstleg.') && !edge.target.startsWith(prefix));
assert.deepEqual(outwardDependencies, []);
});
@@ -18,7 +18,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
"com.baiyue.firstleg.outbound.dispatch.mapper", "com.baiyue.firstleg.outbound.dispatch.mapper",
"com.baiyue.firstleg.portschedule.mapper", "com.baiyue.firstleg.portschedule.mapper",
"com.baiyue.firstleg.customs.mapper", "com.baiyue.firstleg.customs.mapper",
"com.baiyue.firstleg.customerstatistics.mapper" "com.baiyue.firstleg.customerstatistics.adapter.out.persistence"
}) })
@EnableFeignClients(basePackages = "com.baiyue.firstleg") @EnableFeignClients(basePackages = "com.baiyue.firstleg")
@EnableScheduling @EnableScheduling
@@ -59,6 +59,9 @@ public class InternalInboundOrderController {
|| command.warehouseId() <= 0 || command.customerName() == null || command.customerName().isBlank() || command.warehouseId() <= 0 || command.customerName() == null || command.customerName().isBlank()
|| command.inboundCode() == null || command.inboundCode().isBlank() || command.inboundCode() == null || command.inboundCode().isBlank()
|| command.inboundCode().length() > 40 || command.inboundCode().length() > 40
|| command.fulfillmentType() == null
|| (!"STANDARD".equals(command.fulfillmentType())
&& !"ONE_CLICK_DROPSHIP".equals(command.fulfillmentType()))
|| command.totalPackageCount() != null && command.totalPackageCount() < 1) { || command.totalPackageCount() != null && command.totalPackageCount() < 1) {
throw new BizException(ResultCode.BAD_REQUEST, "订单入库快照不完整"); throw new BizException(ResultCode.BAD_REQUEST, "订单入库快照不完整");
} }
@@ -142,7 +145,10 @@ public class InternalInboundOrderController {
|| command.customerName() == null || command.customerName().isBlank() || command.customerName() == null || command.customerName().isBlank()
|| command.warehouseId() == null || command.warehouseId() <= 0 || command.warehouseId() == null || command.warehouseId() <= 0
|| command.warehouseName() == null || command.warehouseName().isBlank() || command.warehouseName() == null || command.warehouseName().isBlank()
|| command.businessType() == null || command.businessType().isBlank()) { || command.businessType() == null || command.businessType().isBlank()
|| command.fulfillmentType() == null
|| (!"STANDARD".equals(command.fulfillmentType())
&& !"ONE_CLICK_DROPSHIP".equals(command.fulfillmentType()))) {
throw new BizException(ResultCode.BAD_REQUEST, "草稿预入库快照不完整"); throw new BizException(ResultCode.BAD_REQUEST, "草稿预入库快照不完整");
} }
} }
@@ -1,9 +1,9 @@
package com.baiyue.firstleg.customerstatistics.controller; package com.baiyue.firstleg.customerstatistics.adapter.in.web;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos.TransitSummary; import com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService;
import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService; import com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos.TransitSummary;
import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.FirstlegScopeGuard;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Positive;
@@ -1,14 +1,13 @@
package com.baiyue.firstleg.customerstatistics.mapper; package com.baiyue.firstleg.customerstatistics.adapter.out.persistence;
import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Mapper @Mapper
public interface CustomerTransitStatisticsMapper { public interface CustomerTransitStatisticsMapper extends CustomerTransitStatisticsReader {
@Override
@Select(""" @Select("""
SELECT #{customerId} AS customerId, SELECT #{customerId} AS customerId,
COUNT(*) AS containerCount, COUNT(*) AS containerCount,
@@ -49,8 +48,5 @@ public interface CustomerTransitStatisticsMapper {
WHERE x.customer_id = #{customerId} WHERE x.customer_id = #{customerId}
) scoped ) scoped
""") """)
SummaryRow summarize(@Param("customerId") long customerId); SummaryProjection summarize(@Param("customerId") long customerId);
record SummaryRow(Long customerId, Long containerCount, BigDecimal totalTeu,
Long unknownContainerCount, LocalDateTime asOf) {}
} }
@@ -1,9 +1,9 @@
package com.baiyue.firstleg.customerstatistics.service; package com.baiyue.firstleg.customerstatistics.application;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos.TransitSummary; import com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos.TransitSummary;
import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper; import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -12,11 +12,11 @@ import java.math.BigDecimal;
@Service @Service
public class CustomerTransitStatisticsService { public class CustomerTransitStatisticsService {
private static final String DEFINITION = "actual_sailing_at IS NOT NULL 且 actual_warehouse_arrival_at IS NULL;排除取消/失败订舱;客户+containerBookingId去重;拼柜按active LoadingPlanItem.accountId归属,整柜按OceanBooking.customerId归属。TEU:20*=1,40*=2,45*=2.25,未知箱型不计totalTeu。"; private static final String DEFINITION = "actual_sailing_at IS NOT NULL 且 actual_warehouse_arrival_at IS NULL;排除取消/失败订舱;客户+containerBookingId去重;拼柜按active LoadingPlanItem.accountId归属,整柜按OceanBooking.customerId归属。TEU:20*=1,40*=2,45*=2.25,未知箱型不计totalTeu。";
@Resource private CustomerTransitStatisticsMapper mapper; @Resource private CustomerTransitStatisticsReader reader;
public TransitSummary summarize(long customerId) { public TransitSummary summarize(long customerId) {
if (customerId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "customerId必须为正数"); if (customerId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "customerId必须为正数");
var row = mapper.summarize(customerId); var row = reader.summarize(customerId);
return new TransitSummary(customerId, return new TransitSummary(customerId,
value(row == null ? null : row.containerCount()), value(row == null ? null : row.containerCount()),
row == null || row.totalTeu() == null ? BigDecimal.ZERO : row.totalTeu(), row == null || row.totalTeu() == null ? BigDecimal.ZERO : row.totalTeu(),
@@ -1,4 +1,4 @@
package com.baiyue.firstleg.customerstatistics.dto; package com.baiyue.firstleg.customerstatistics.application.model;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -0,0 +1,11 @@
package com.baiyue.firstleg.customerstatistics.application.port.out;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public interface CustomerTransitStatisticsReader {
SummaryProjection summarize(long customerId);
record SummaryProjection(Long customerId, Long containerCount, BigDecimal totalTeu,
Long unknownContainerCount, LocalDateTime asOf) {}
}
@@ -1,7 +1,7 @@
package com.baiyue.firstleg.customs.service; package com.baiyue.firstleg.customs.service;
import com.baiyue.firstleg.entity.ContainerDocumentEntity; import com.baiyue.firstleg.customs.entity.CustomsCaseEntity;
import com.baiyue.firstleg.repository.LoadingListRepository; import com.baiyue.firstleg.customs.repository.CustomsRepository;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem; import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem;
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -9,7 +9,7 @@ import org.springframework.stereotype.Component;
@Component @Component
public class CustomsDispatchGateContributor implements OutboundDispatchGateContributor { public class CustomsDispatchGateContributor implements OutboundDispatchGateContributor {
@Resource private LoadingListRepository repository; @Resource private CustomsRepository repository;
@Override public String code() { return "CUSTOMS_CLEARANCE"; } @Override public String code() { return "CUSTOMS_CLEARANCE"; }
@@ -18,12 +18,13 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr
if (context.tenantId() <= 0 || context.customerId() <= 0) { if (context.tenantId() <= 0 || context.customerId() <= 0) {
return gate(false, "INVALID_CUSTOMER_SCOPE"); return gate(false, "INVALID_CUSTOMER_SCOPE");
} }
ContainerDocumentEntity value = repository.document(context.containerBookingId(), "CUSTOMS_DECLARATION"); CustomsCaseEntity value = repository.gateCase(context.tenantId(), context.orderId(),
context.inboundId(), context.containerBookingId());
if (value == null) return gate(false, "MISSING"); if (value == null) return gate(false, "MISSING");
return gate(value.getCompletedAt() != null, value.getCompletedAt() == null ? "UPLOADED" : "COMPLETED"); return gate("CLEARANCE_COMPLETED".equals(value.getStatus()), value.getStatus());
} }
private static GateItem gate(boolean passed, String actual) { private static GateItem gate(boolean passed, String actual) {
return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "COMPLETED"); return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "CLEARANCE_COMPLETED");
} }
} }

Some files were not shown because too many files have changed in this diff Show More