diff --git a/Jenkinsfile b/Jenkinsfile index b9f53cf7f..726108088 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -106,15 +106,37 @@ def previousAutoBuildState(def previousBuild, List allowedServices) { } def environmentChoicesForJob(String jobBaseName) { - return jobBaseName == 'backend-java-dev' - ? ['development', 'test', 'staging', 'production'] - : ['test', 'development', 'staging', 'production'] + return [fixedEnvironmentForJob(jobBaseName) ?: 'test'] } -def downstreamJobPrefixForJob(String jobBaseName) { - return jobBaseName == 'backend-java-dev' - ? 'baiyue-firstleg-dev-' - : 'baiyue-firstleg-' +def fixedEnvironmentForJob(String jobBaseName) { + switch (jobBaseName) { + case 'backend-java-dev': + return 'development' + case 'backend-java': + return 'test' + case 'backend-java-prod': + return 'production' + default: + return null + } +} + +def fixedDownstreamJobPrefixForJob(String jobBaseName) { + switch (jobBaseName) { + case 'backend-java-dev': + return 'baiyue-firstleg-dev-' + case 'backend-java': + return 'baiyue-firstleg-' + case 'backend-java-prod': + return 'baiyue-firstleg-prod-' + default: + return null + } +} + +def downstreamJobPrefixChoicesForJob(String jobBaseName) { + return [fixedDownstreamJobPrefixForJob(jobBaseName) ?: 'baiyue-firstleg-'] } pipeline { @@ -150,7 +172,7 @@ pipeline { booleanParam(name: 'SKIP_TESTS', defaultValue: false, 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: 'DOWNSTREAM_JOB_PREFIX', defaultValue: downstreamJobPrefixForJob(env.JOB_BASE_NAME), description: '单服务 Jenkins Job 名称前缀;开发总控自动使用 dev 专属 Job') + choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀') } stages { @@ -158,11 +180,15 @@ pipeline { agent { label 'java-builder' } steps { script { - // 开发环境总控 Job 固定部署到 development,避免首次构建沿用 ENVIRONMENT 的默认 test 值。 - // 其他 Job(包括现有 backend-java 测试环境)仍完全沿用原有参数。 - String resolvedEnvironment = env.JOB_BASE_NAME == 'backend-java-dev' - ? 'development' - : params.ENVIRONMENT + String resolvedEnvironment = fixedEnvironmentForJob(env.JOB_BASE_NAME) + String resolvedJobPrefix = fixedDownstreamJobPrefixForJob(env.JOB_BASE_NAME) + if (!resolvedEnvironment || !resolvedJobPrefix) { + error("未配置总控 Job ${env.JOB_BASE_NAME} 的固定部署环境,拒绝继续") + } + if (params.ENVIRONMENT != resolvedEnvironment || + params.DOWNSTREAM_JOB_PREFIX?.trim() != resolvedJobPrefix) { + echo "忽略外部传入的环境或 Job 前缀;${env.JOB_BASE_NAME} 固定使用 ${resolvedEnvironment} / ${resolvedJobPrefix}" + } env.RESOLVED_ENVIRONMENT = resolvedEnvironment echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}" @@ -176,11 +202,7 @@ pipeline { echo "${env.RESOLVED_ENVIRONMENT} 环境使用共享镜像仓库: ${requestedRepository}" env.RESOLVED_IMAGE_REPOSITORY = requestedRepository - String jobPrefix = params.DOWNSTREAM_JOB_PREFIX?.trim() - if (!jobPrefix) { - error('DOWNSTREAM_JOB_PREFIX 不能为空') - } - env.RESOLVED_JOB_PREFIX = jobPrefix + env.RESOLVED_JOB_PREFIX = resolvedJobPrefix env.DATABASE_CHANGE_DETECTED = 'false' if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS) { error('生产环境禁止跳过测试') diff --git a/Jenkinsfile.service b/Jenkinsfile.service index bd81515d2..2cd097345 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -21,6 +21,23 @@ def serviceJars = [ 'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar' ] +def fixedEnvironmentForServiceJob(String jobBaseName) { + if (jobBaseName?.startsWith('baiyue-firstleg-dev-')) { + return 'development' + } + if (jobBaseName?.startsWith('baiyue-firstleg-prod-')) { + return 'production' + } + if (jobBaseName?.startsWith('baiyue-firstleg-')) { + return 'test' + } + return null +} + +def environmentChoicesForServiceJob(String jobBaseName) { + return [fixedEnvironmentForServiceJob(jobBaseName) ?: 'test'] +} + pipeline { // 构建在任意 java-builder 节点执行,部署再回到目标环境节点。 agent none @@ -35,7 +52,7 @@ pipeline { parameters { string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入') string(name: 'IMAGE_TAG', defaultValue: '', description: '不可变镜像标签;通常使用 Git commit 前 12 位') - choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境') + choice(name: 'ENVIRONMENT', choices: environmentChoicesForServiceJob(env.JOB_BASE_NAME), description: '由当前单服务 Job 固定的部署环境') booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试') choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS', 'SELECT'], description: '仅 event-bridge-service 使用;BOTH 在开发/测试环境同时部署国内和海外实例;SELECT 为总控兼容值') @@ -65,6 +82,7 @@ pipeline { String sourceCommit = params.SOURCE_COMMIT?.trim() String imageTag = params.IMAGE_TAG?.trim() String imageRepository = params.IMAGE_REPOSITORY?.trim() + String resolvedEnvironment = fixedEnvironmentForServiceJob(env.JOB_BASE_NAME) if (!sourceCommit) { sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() @@ -76,6 +94,12 @@ pipeline { if (!serviceModules.contains(service)) { error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}") } + if (!resolvedEnvironment) { + error("未配置单服务 Job ${env.JOB_BASE_NAME} 的固定部署环境,拒绝继续") + } + if (params.ENVIRONMENT != resolvedEnvironment) { + echo "忽略外部传入的环境 ${params.ENVIRONMENT};${env.JOB_BASE_NAME} 固定使用 ${resolvedEnvironment}" + } boolean deployBothBridges = service == 'event-bridge-service' && imageTag?.endsWith('-both') if (deployBothBridges) { @@ -95,6 +119,7 @@ pipeline { } env.RESOLVED_SERVICE = service + env.RESOLVED_ENVIRONMENT = resolvedEnvironment env.RESOLVED_SOURCE_COMMIT = sourceCommit env.RESOLVED_IMAGE_TAG = imageTag env.RESOLVED_IMAGE_REPOSITORY = imageRepository @@ -106,7 +131,7 @@ pipeline { error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS') } if ((deployBothBridges || ['BOTH', 'SELECT'].contains(bridgeRegion)) && - !['development', 'test'].contains(params.ENVIRONMENT)) { + !['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) { error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS') } } @@ -122,7 +147,7 @@ pipeline { ''' script { - currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${params.ENVIRONMENT} | ${env.NODE_NAME}" + currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${env.RESOLVED_ENVIRONMENT} | ${env.NODE_NAME}" echo "独立构建服务: ${env.RESOLVED_SERVICE},提交: ${env.RESOLVED_SOURCE_COMMIT},节点: ${env.NODE_NAME}" } } @@ -132,14 +157,14 @@ pipeline { steps { script { boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean - if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) { + if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) { error('生产环境手工构建禁止跳过测试;总控流水线可在统一测试通过后执行快速打包') } - boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS + boolean skipTestExecution = env.RESOLVED_ENVIRONMENT == 'test' || params.SKIP_TESTS String testOption = skipTestExecution ? '-DskipTests' : '' if (params.SKIP_TESTS && coordinatorPinnedCommit) { echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包" - } else if (params.ENVIRONMENT == 'test') { + } else if (env.RESOLVED_ENVIRONMENT == 'test') { echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行' } sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}" @@ -188,15 +213,15 @@ pipeline { stage('部署开发或测试环境') { when { beforeAgent true - expression { params.DEPLOY && ['development', 'test'].contains(params.ENVIRONMENT) } + expression { params.DEPLOY && ['development', 'test'].contains(env.RESOLVED_ENVIRONMENT) } } agent none steps { script { - String deploySlotLabel = "firstleg-${params.ENVIRONMENT}-deploy-slot" - String serviceDeployResource = "firstleg-${params.ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy" - String credentialId = "firstleg-${params.ENVIRONMENT}-env" - String targetLabel = params.ENVIRONMENT == 'development' ? 'java-dev' : 'java-test' + String deploySlotLabel = "firstleg-${env.RESOLVED_ENVIRONMENT}-deploy-slot" + String serviceDeployResource = "firstleg-${env.RESOLVED_ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy" + String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-env" + String targetLabel = env.RESOLVED_ENVIRONMENT == 'development' ? 'java-dev' : 'java-test' // 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。 // resource 未预配置时 Lockable Resources 会创建仅在构建期间存在的临时资源。 lock(resource: serviceDeployResource, inversePrecedence: true) { @@ -205,7 +230,7 @@ pipeline { deleteDir() try { unstash 'deployment-files' - echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.ENVIRONMENT}" + echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${env.RESOLVED_ENVIRONMENT}" List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' && (env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' || ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION))) @@ -224,7 +249,7 @@ pipeline { '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\"" + sh "sh deploy/scripts/deploy-built-service.sh '${env.RESOLVED_ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" } } } @@ -241,18 +266,18 @@ pipeline { stage('部署单个服务') { when { beforeAgent true - expression { params.DEPLOY && !['development', 'test'].contains(params.ENVIRONMENT) } + expression { params.DEPLOY && !['development', 'test'].contains(env.RESOLVED_ENVIRONMENT) } } agent { - label "${params.ENVIRONMENT == 'development' ? 'java-dev' : 'master'}" + label "${env.RESOLVED_ENVIRONMENT == 'development' ? 'java-dev' : 'master'}" } steps { deleteDir() unstash 'deployment-files' script { - String credentialId = "firstleg-${params.ENVIRONMENT}-env" + String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-env" String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0] - boolean localDevelopmentDeploy = params.ENVIRONMENT == 'development' && + boolean localDevelopmentDeploy = env.RESOLVED_ENVIRONMENT == 'development' && env.NODE_LABELS?.tokenize()?.contains('java-dev') withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { @@ -263,7 +288,7 @@ pipeline { "EVENT_BRIDGE_REGION_OVERRIDE=${params.EVENT_BRIDGE_REGION}" ]) { withCredentials([file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE')]) { - sh "sh deploy/scripts/deploy-services.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" + sh "sh deploy/scripts/deploy-services.sh '${env.RESOLVED_ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" } } } finally { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java index 485ffc52b..7ab22451a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java @@ -267,7 +267,7 @@ public class CartonMeasurementService { private void applyCalculatedValues(InboundMeasurementItemEntity item, CustomerDimensionWeightingService.RuleSet dimensionRules) { - CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolve( + CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolveDeviceMeasurement( item.getLengthCm(), item.getWidthCm(), item.getHeightCm()); BigDecimal unitRaw = weighted.rawVolumeCbm(); BigDecimal unitVolume = weighted.unitVolumeCbm(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java index 2fbc2782b..a976ac6f2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java @@ -64,6 +64,25 @@ public class CustomerDimensionWeightingService { .orElseGet(() -> rules.get(rules.size() - 1) .weightedActualDimensions(lengthCm, widthCm, heightCm)); } + + public WeightedDimensions resolveDeviceMeasurement(BigDecimal lengthCm, BigDecimal widthCm, + BigDecimal heightCm) { + List measured = sortedDimensions(lengthCm, widthCm, heightCm); + return rules.stream() + .filter(rule -> rule.contains(measured)) + .findFirst() + .map(Rule::weighted) + .orElseGet(() -> actualDimensions(lengthCm, widthCm, heightCm)); + } + + private WeightedDimensions actualDimensions(BigDecimal lengthCm, BigDecimal widthCm, + BigDecimal heightCm) { + BigDecimal rawVolume = lengthCm.multiply(widthCm).multiply(heightCm) + .divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP); + return new WeightedDimensions(null, null, null, lengthCm, widthCm, heightCm, + rawVolume, rawVolume.setScale(3, RoundingMode.HALF_UP), + "ACTUAL_DIMENSION_V1"); + } } public record WeightedDimensions( diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index dd9fc812a..560845a5c 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -22,19 +22,26 @@ test('service pipeline keeps boolean operators on parseable Groovy continuation }) test('test deployments compile packages without rerunning the full unit test suite', () => { - assert.match(pipeline, /params\.ENVIRONMENT == 'test' \|\| params\.SKIP_TESTS/) + assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT == 'test' \|\| params\.SKIP_TESTS/) assert.match(pipeline, /skipTestExecution \? '-DskipTests' : ''/) assert.match(pipeline, /-pl \$\{env\.RESOLVED_SERVICE\} -am clean package/) assert.doesNotMatch(pipeline, /-Dmaven\.test\.skip=true/) }) -test('coordinator jobs present their own deployment environment as the default choice', () => { - assert.match(coordinatorPipeline, /jobBaseName == 'backend-java-dev'/) - assert.match(coordinatorPipeline, /\? \['development', 'test', 'staging', 'production'\]/) - assert.match(coordinatorPipeline, /: \['test', 'development', 'staging', 'production'\]/) +test('coordinator and service jobs enforce their own deployment environments', () => { + assert.match(coordinatorPipeline, /case 'backend-java-dev':[\s\S]*?return 'development'/) + assert.match(coordinatorPipeline, /case 'backend-java':[\s\S]*?return 'test'/) + assert.match(coordinatorPipeline, /case 'backend-java-prod':[\s\S]*?return 'production'/) assert.match(coordinatorPipeline, /environmentChoicesForJob\(env\.JOB_BASE_NAME\)/) - assert.match(coordinatorPipeline, /jobBaseName == 'backend-java-dev'[\s\S]*?'baiyue-firstleg-dev-'/) - assert.match(coordinatorPipeline, /downstreamJobPrefixForJob\(env\.JOB_BASE_NAME\)/) + assert.match(coordinatorPipeline, /env\.RESOLVED_ENVIRONMENT = resolvedEnvironment/) + assert.match(coordinatorPipeline, /env\.RESOLVED_JOB_PREFIX = resolvedJobPrefix/) + assert.match(pipeline, /startsWith\('baiyue-firstleg-dev-'\)[\s\S]*?return 'development'/) + assert.match(pipeline, /startsWith\('baiyue-firstleg-prod-'\)[\s\S]*?return 'production'/) + assert.match(pipeline, /startsWith\('baiyue-firstleg-'\)[\s\S]*?return 'test'/) + assert.match(pipeline, /environmentChoicesForServiceJob\(env\.JOB_BASE_NAME\)/) + assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT = resolvedEnvironment/) + assert.doesNotMatch(pipeline, /params\.ENVIRONMENT ==/) + assert.doesNotMatch(pipeline, /contains\(params\.ENVIRONMENT\)/) }) test('AUTO advances interrupted baselines and retries only their selected services', () => { @@ -73,10 +80,10 @@ test('production deployments cannot skip tests', () => { test('Java builds use the shared builder pool and serialize incremental target deployments', () => { assert.match(pipeline, /agent \{ label 'java-builder' \}/) - assert.match(pipeline, /\['development', 'test'\]\.contains\(params\.ENVIRONMENT\)/) - assert.match(pipeline, /params\.ENVIRONMENT == 'development' \? 'java-dev' : 'java-test'/) + assert.match(pipeline, /\['development', 'test'\]\.contains\(env\.RESOLVED_ENVIRONMENT\)/) + assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT == 'development' \? 'java-dev' : 'java-test'/) assert.match(pipeline, /'TARGET_IS_LOCAL=true'/) - assert.match(pipeline, /String deploySlotLabel = "firstleg-\$\{params\.ENVIRONMENT\}-deploy-slot"/) + assert.match(pipeline, /String deploySlotLabel = "firstleg-\$\{env\.RESOLVED_ENVIRONMENT\}-deploy-slot"/) assert.match(pipeline, /lock\(label: deploySlotLabel, quantity: 1, inversePrecedence: true\)/) assert.match(pipeline, /deploy-built-service\.sh/) assert.doesNotMatch(pipeline, /dev_deploy|test_deploy|TARGET_SSH_KEY/) @@ -92,7 +99,7 @@ test('Java builds use the shared builder pool and serialize incremental target d test('production only accepts skipped downstream tests for a coordinator-pinned commit', () => { assert.match(pipeline, /boolean coordinatorPinnedCommit = params\.SOURCE_COMMIT\?\.trim\(\) as boolean/) - assert.match(pipeline, /params\.ENVIRONMENT == 'production' && params\.SKIP_TESTS && !coordinatorPinnedCommit/) + assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT == 'production' && params\.SKIP_TESTS && !coordinatorPinnedCommit/) assert.match(pipeline, /生产环境手工构建禁止跳过测试/) }) @@ -106,8 +113,8 @@ test('development and test deployment locks are acquired before target executors const nodeIndex = directDeployStage.indexOf('node(targetLabel)') assert.match(directDeployStage, /agent none/) - assert.match(directDeployStage, /String targetLabel = params\.ENVIRONMENT == 'development' \? 'java-dev' : 'java-test'/) - assert.match(directDeployStage, /String serviceDeployResource = "firstleg-\$\{params\.ENVIRONMENT\}-\$\{env\.RESOLVED_SERVICE\}-deploy"/) + assert.match(directDeployStage, /String targetLabel = env\.RESOLVED_ENVIRONMENT == 'development' \? 'java-dev' : 'java-test'/) + assert.match(directDeployStage, /String serviceDeployResource = "firstleg-\$\{env\.RESOLVED_ENVIRONMENT\}-\$\{env\.RESOLVED_SERVICE\}-deploy"/) assert.ok(serviceLockIndex >= 0, 'per-service deployment lock must exist') assert.ok(lockIndex >= 0, 'deployment slot lock must exist') assert.ok(nodeIndex >= 0, 'target node allocation must exist')