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:
@@ -1,4 +1,5 @@
|
|||||||
target/
|
target/
|
||||||
|
node_modules/
|
||||||
.docker-context/
|
.docker-context/
|
||||||
*.log
|
*.log
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
+37
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,也不允许通过公网直接调用对方内部服务。
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
+28
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
+47
@@ -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("船节点交接供应商审计字段不完整");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-2
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-2
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -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,
|
||||||
|
|||||||
+49
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -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,
|
||||||
|
|||||||
+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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -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());
|
||||||
|
|||||||
+17
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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" \
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 验收。
|
||||||
+13
-6
@@ -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 命名空间和部署环境。
|
||||||
|
|||||||
@@ -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;禁止为了通知便利把高基数标识加入指标标签。
|
||||||
+2
-1
@@ -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,
|
||||||
|
|||||||
+18
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
+103
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -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
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
@@ -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) {
|
||||||
|
|||||||
+22
-2
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-2
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+56
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
-9
@@ -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());
|
||||||
|
|||||||
+7
@@ -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}
|
||||||
|
|||||||
+54
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -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\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -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+", " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -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(),
|
||||||
|
|||||||
+35
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+179
-3
@@ -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
@@ -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` 与完整测试均成功。
|
||||||
@@ -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
|
||||||
|
|||||||
+7
-1
@@ -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, "草稿预入库快照不完整");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -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;
|
||||||
+5
-9
@@ -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) {}
|
|
||||||
}
|
}
|
||||||
+5
-5
@@ -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
-1
@@ -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;
|
||||||
+11
@@ -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) {}
|
||||||
|
}
|
||||||
+7
-6
@@ -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
Reference in New Issue
Block a user