Sfoglia il codice sorgente

Jenkins 从UAT生成生产环境镜像脚本

dujian 2 giorni fa
parent
commit
fdbd901faf
1 ha cambiato i file con 313 aggiunte e 242 eliminazioni
  1. 313 242
      docs/jenkins/Jenkinsfile-prod-promote-from-uat.groovy

+ 313 - 242
docs/jenkins/Jenkinsfile-prod-promote-from-uat.groovy

@@ -46,7 +46,9 @@
  * 与预生产「每服务一子目录」约定一致。生产 Docker 挂载 jar 时请指向 PROD 路径。
  * 与预生产「每服务一子目录」约定一致。生产 Docker 挂载 jar 时请指向 PROD 路径。
  * 若 compose 将生产 jar 挂为「../java:/app_deploy」(相对 compose 目录),则 Jenkins 内应设 PROD_DEPLOY_ROOT=/app_deploy,对应宿主机为 compose 上级目录下的 java/。
  * 若 compose 将生产 jar 挂为「../java:/app_deploy」(相对 compose 目录),则 Jenkins 内应设 PROD_DEPLOY_ROOT=/app_deploy,对应宿主机为 compose 上级目录下的 java/。
  * 【必配】若 Job 使用 PROD_DEPLOY_ROOT=/alien_produ/java 且 **未** 配置 PROD_SSH_TARGET(本机发版):Jenkins 容器必须 volume 挂载宿主机 /alien_produ/java 到同路径(见 docker-compose-jenkins-host-network.yml),否则 cp 写在 Jenkins 私有层、生产侧进程读宿主机文件时,unzip 通过仍 corrupt jarfile。
  * 【必配】若 Job 使用 PROD_DEPLOY_ROOT=/alien_produ/java 且 **未** 配置 PROD_SSH_TARGET(本机发版):Jenkins 容器必须 volume 挂载宿主机 /alien_produ/java 到同路径(见 docker-compose-jenkins-host-network.yml),否则 cp 写在 Jenkins 私有层、生产侧进程读宿主机文件时,unzip 通过仍 corrupt jarfile。
- * 【环境隔离】预生产仍在 39.106.135.88(UAT_DEPLOY_ROOT 读该机上制品);生产默认发到 **39.105.153.68**:设置 PROD_SSH_TARGET=alien_store@39.105.153.68(可改)后,制品先写入 Jenkins 工作区下暂存目录再 rsync 到目标机 PROD_DEPLOY_ROOT,docker/docker compose 经 SSH 在目标机执行(须 Jenkins 到目标机免密 sudo docker)。留空 PROD_SSH_TARGET 则保持「Jenkins 与生产 dockerd 同机」旧行为。
+ * 【环境隔离】预生产仍在 39.106.135.88(UAT_DEPLOY_ROOT 读该机上制品);生产默认发到 **39.105.153.68**:设置 PROD_SSH_TARGET=alien_store@39.105.153.68(可改)后,制品先写入 Jenkins 工作区下暂存目录再 rsync 到目标机 PROD_DEPLOY_ROOT,docker/docker compose 经 SSH 在目标机执行(须目标机 sudo 免密 docker)。留空 PROD_SSH_TARGET 则保持「Jenkins 与生产 dockerd 同机」旧行为。
+ * 【SSH】远程发版须 Jenkins 能登录 PROD_SSH_TARGET:填写 PROD_SSH_CREDENTIALS_ID(Jenkins「SSH Username with private key」凭据,需 SSH Agent 插件)或将私钥配在运行节点默认 ssh(~/.ssh)。否则 ssh 报 Permission denied (publickey)。
+ * 【凭据参数仍为空】已存在 Job 常不继承 Jenkinsfile 里 parameters 的新 defaultValue,运行时 params 仍为空串;脚本用 effectiveProdSshCredentialsId() 在「已配置 PROD_SSH_TARGET」时回退 DEFAULT_PROD_SSH_CREDENTIALS_ID,仍走 sshagent。换凭据后改脚本内该常量或显式填参数。
  *
  *
  * 生产容器须由 <PROD_DEPLOY_ROOT>/docker-compose.yml(或 PROD_COMPOSE_FILE)通过 docker compose 管理;与 docker restart / docker run 的「独立容器」不共享 Compose 元数据,见 Job 头「docker compose 启动方式」说明。
  * 生产容器须由 <PROD_DEPLOY_ROOT>/docker-compose.yml(或 PROD_COMPOSE_FILE)通过 docker compose 管理;与 docker restart / docker run 的「独立容器」不共享 Compose 元数据,见 Job 头「docker compose 启动方式」说明。
  * 若 PROD 目录下出现 alien-xxx-1.0.0.jar「目录」且为空:多为历史上 docker -v 指向不存在的文件时 Docker 在宿主机误建同名目录;须 rm -rf 该目录后再拷贝真 jar。
  * 若 PROD 目录下出现 alien-xxx-1.0.0.jar「目录」且为空:多为历史上 docker -v 指向不存在的文件时 Docker 在宿主机误建同名目录;须 rm -rf 该目录后再拷贝真 jar。
@@ -153,6 +155,22 @@ def jasyptParamToPlain(def raw) {
     return raw.toString().trim()
     return raw.toString().trim()
 }
 }
 
 
+/** 与 parameters 中 PROD_SSH_CREDENTIALS_ID 的 defaultValue 对齐;已存在 Job 运行时 params 常仍为空,须脚本层回退。 */
+def DEFAULT_PROD_SSH_CREDENTIALS_ID = 'e611a045-2fdc-4613-babd-a72d69bf9814'
+
+/** 仅当 PROD_SSH_TARGET 非空时才需要凭据:参数非空用参数,否则用内置 ID;本机发版(无 SSH 目标)返回空串。 */
+def effectiveProdSshCredentialsId(String prodSshTargetTrimmed) {
+    def sshT = (prodSshTargetTrimmed ?: '').toString().trim()
+    if (!sshT) {
+        return ''
+    }
+    def p = (params.PROD_SSH_CREDENTIALS_ID != null) ? params.PROD_SSH_CREDENTIALS_ID.toString().trim() : ''
+    if (p) {
+        return p
+    }
+    return DEFAULT_PROD_SSH_CREDENTIALS_ID
+}
+
 def filterServices(List services) {
 def filterServices(List services) {
     def selected = []
     def selected = []
     def mode = params.DEPLOY_MODE
     def mode = params.DEPLOY_MODE
@@ -304,7 +322,13 @@ pipeline {
                 name: 'PROD_SSH_TARGET',
                 name: 'PROD_SSH_TARGET',
                 defaultValue: 'alien_store@39.105.153.68',
                 defaultValue: 'alien_store@39.105.153.68',
                 trim: true,
                 trim: true,
-                description: '生产 Docker 所在主机 SSH 目标(如 alien_store@39.105.153.68)。留空=旧行为:在本机 PROD_DEPLOY_ROOT 写文件并用本机 docker compose。非空时:先写入工作区 .jenkins_remote_prod_staging 再 rsync 到对方 PROD_DEPLOY_ROOT,且 docker 命令经「ssh 目标 sudo docker」执行;须配置免密登录与目标机 sudo 免密 docker'
+                description: '生产 Docker 所在主机 SSH 目标(如 alien_store@39.105.153.68)。留空=旧行为:在本机 PROD_DEPLOY_ROOT 写文件并用本机 docker compose。非空时:先写入工作区 .jenkins_remote_prod_staging 再 rsync 到对方 PROD_DEPLOY_ROOT,且 docker 经「ssh 目标 sudo docker」;须目标机 sudo 免密 docker,且 Jenkins 能 SSH 登录(见 PROD_SSH_CREDENTIALS_ID)'
+        )
+        string(
+                name: 'PROD_SSH_CREDENTIALS_ID',
+                defaultValue: 'e611a045-2fdc-4613-babd-a72d69bf9814',
+                trim: true,
+                description: 'Jenkins「SSH Username with private key」凭据 ID(需 SSH Agent 插件);非空时 Verify/rsync/scp/远程 docker 在 sshagent 内执行。默认值为当前环境 alien_store 凭据 ID。注意:已创建的旧 Job 可能仍得到空串,脚本在配置 PROD_SSH_TARGET 时会回退脚本内 DEFAULT_PROD_SSH_CREDENTIALS_ID;换凭据后请改参数或改脚本常量'
         )
         )
         string(
         string(
                 name: 'PROD_PAY_CERT_HOST',
                 name: 'PROD_PAY_CERT_HOST',
@@ -354,6 +378,7 @@ pipeline {
         stage('Announce deploy plan') {
         stage('Announce deploy plan') {
             steps {
             steps {
                 script {
                 script {
+                    echo '>>> [Jenkinsfile] prod-ssh-cred-fallback-20260213:远程时参数 PROD_SSH_CREDENTIALS_ID 为空仍走 sshagent(effectiveProdSshCredentialsId)。若本行缺失,说明本 Job 未执行仓库中当前 docs/jenkins/Jenkinsfile-prod-promote-from-uat.groovy(请检查 Pipeline 来源:SCM 分支/路径或是否仍为「粘贴脚本」旧副本)'
                     def services = getServiceDefinitions()
                     def services = getServiceDefinitions()
                     def selected = filterServices(services)
                     def selected = filterServices(services)
                     def names = selected.collect { it.prodDir }.join(', ')
                     def names = selected.collect { it.prodDir }.join(', ')
@@ -370,6 +395,13 @@ pipeline {
                     if (_ssh) {
                     if (_ssh) {
                         echo ">>> PROD_SSH_TARGET=${_ssh}(远程生产:制品 rsync 到对方 PROD_DEPLOY_ROOT,docker compose 在目标机执行)"
                         echo ">>> PROD_SSH_TARGET=${_ssh}(远程生产:制品 rsync 到对方 PROD_DEPLOY_ROOT,docker compose 在目标机执行)"
                         echo ">>> 本地暂存目录(写入后同步): ${env.WORKSPACE}/.jenkins_remote_prod_staging"
                         echo ">>> 本地暂存目录(写入后同步): ${env.WORKSPACE}/.jenkins_remote_prod_staging"
+                        def _paramCred = (params.PROD_SSH_CREDENTIALS_ID != null) ? params.PROD_SSH_CREDENTIALS_ID.toString().trim() : ''
+                        def _sc = effectiveProdSshCredentialsId(_ssh)
+                        if (_paramCred) {
+                            echo ">>> PROD_SSH_CREDENTIALS_ID=${_sc}(来自构建参数,SSH Agent)"
+                        } else {
+                            echo ">>> PROD_SSH_CREDENTIALS_ID: 构建参数为空,已用脚本内置回退 ${_sc}(SSH Agent;旧 Job 常不继承 parameters 的 defaultValue)"
+                        }
                     } else {
                     } else {
                         echo ">>> PROD_SSH_TARGET: (空)本机生产路径 + 本机 Docker"
                         echo ">>> PROD_SSH_TARGET: (空)本机生产路径 + 本机 Docker"
                     }
                     }
@@ -440,22 +472,41 @@ pipeline {
                     def prodImg = params.PROD_JRE_IMAGE ?: 'my-openjdk8-ffmpeg:v1'
                     def prodImg = params.PROD_JRE_IMAGE ?: 'my-openjdk8-ffmpeg:v1'
                     def prodSshV = (params.PROD_SSH_TARGET ?: '').trim()
                     def prodSshV = (params.PROD_SSH_TARGET ?: '').trim()
                     def prodRemote = (params.PROD_DEPLOY_ROOT ?: env.PROD_DEPLOY_ROOT ?: '/alien_produ/java').trim()
                     def prodRemote = (params.PROD_DEPLOY_ROOT ?: env.PROD_DEPLOY_ROOT ?: '/alien_produ/java').trim()
+                    def prodSshCredV = effectiveProdSshCredentialsId(prodSshV)
                     if (prodSshV) {
                     if (prodSshV) {
-                        sh """
+                        def verifyRemote = {
+                            def prSq = prodRemote.replace("'", "'\\''")
+                            def imgSq = prodImg.replace("'", "'\\''")
+                            def sshSq = prodSshV.replace("'", "'\\''")
+                            def remoteBody = """set -e
+rm -f '${prSq}/.jenkins_jar_promote_mount_probe' 2>/dev/null || true
+printf ok | sudo tee '${prSq}/.jenkins_jar_promote_mount_probe' >/dev/null
+OUT=\$(sudo docker run --rm --entrypoint cat -v '${prSq}:/__prod:ro' '${imgSq}' '/__prod/.jenkins_jar_promote_mount_probe' 2>/dev/null || true)
+sudo rm -f '${prSq}/.jenkins_jar_promote_mount_probe' 2>/dev/null || true
+printf '%s' "\$OUT"
+"""
+                            def rpath = "${env.WORKSPACE}/.jenkins_remote_mount_verify.sh".replace('\\', '/')
+                            writeFile file: rpath, text: remoteBody, encoding: 'UTF-8'
+                            def rpathSh = rpath.replace("'", "'\\''")
+                            sh """
                             set -e
                             set -e
-                            SSH='${prodSshV.replace("'", "'\\''")}'
-                            PROD='${prodRemote.replace("'", "'\\''")}'
-                            IMG='${prodImg.replace("'", "'\\''")}'
-                            PFILE=".jenkins_jar_promote_mount_probe"
-                            ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new "\$SSH" "rm -f \"\$PROD/\$PFILE\" 2>/dev/null; printf ok | sudo tee \"\$PROD/\$PFILE\" >/dev/null"
-                            OUT=\$(ssh -o BatchMode=yes "\$SSH" "sudo docker run --rm --entrypoint cat -v \"\$PROD:/__prod:ro\" \"\$IMG\" \"/__prod/\$PFILE\" 2>/dev/null" || true)
-                            ssh -o BatchMode=yes "\$SSH" "sudo rm -f \"\$PROD/\$PFILE\" 2>/dev/null || true"
+                            SSH='${sshSq}'
+                            OUT=\$(ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new "\$SSH" 'bash -s' < '${rpathSh}')
                             if [ "\$OUT" != "ok" ]; then
                             if [ "\$OUT" != "ok" ]; then
-                                echo "ERROR: 远程 \\\$SSH 上路径 \\\$PROD 无法被该机的 docker 挂载读取(请核对目录权限、docker 与 ssh 用户)。"
+                                echo "ERROR: 远程 \\\$SSH 上路径 ${prodRemote} 无法被该机的 docker 挂载读取(请核对目录权限、docker、sudo 免密、镜像 ${prodImg})。"
+                                echo "若同一日志中更早出现 Permission denied(publickey):属 SSH 认证失败(凭据私钥须与生产机 alien_store 的 authorized_keys 中公钥为同一对);与 docker 挂载校验无关。"
                                 exit 1
                                 exit 1
                             fi
                             fi
-                            echo ">>> 远程 PROD 与 Docker 挂载校验通过: \$SSH:\$PROD"
+                            echo ">>> 远程 PROD 与 Docker 挂载校验通过: \$SSH:${prodRemote}"
                         """
                         """
+                        }
+                        if (prodSshCredV) {
+                            sshagent([prodSshCredV]) {
+                                verifyRemote()
+                            }
+                        } else {
+                            verifyRemote()
+                        }
                     } else {
                     } else {
                         sh """
                         sh """
                             set -e
                             set -e
@@ -522,24 +573,26 @@ pipeline {
                     def selected = filterServices(services)
                     def selected = filterServices(services)
                     def mode = params.BOOTSTRAP_SYNC_MODE
                     def mode = params.BOOTSTRAP_SYNC_MODE
                     def dryRunSh = dryRunForShell()
                     def dryRunSh = dryRunForShell()
+                    def bootSshCred = effectiveProdSshCredentialsId(prodSsh)
 
 
-                    for (def s in selected) {
-                        def jar = "${s.module}-1.0.0.jar"
-                        def srcJar = "${env.UAT_DEPLOY_ROOT}/${s.uatDir}/${(jar)}"
-                        def dstDir = "${prodLocalRoot}/${s.prodDir}/config"
-                        def dstFile = "${dstDir}/bootstrap-prod.yml"
-                        def srcGit = "${WORKSPACE}/${s.bootstrapRel}"
-                        def dstDirSh = dstDir.replace("'", "'\\''")
-                        def rsyncBootstrapBlock = ''
-                        if (prodSsh) {
-                            rsyncBootstrapBlock = """
+                    def bootstrapLoop = {
+                        for (def s in selected) {
+                            def jar = "${s.module}-1.0.0.jar"
+                            def srcJar = "${env.UAT_DEPLOY_ROOT}/${s.uatDir}/${(jar)}"
+                            def dstDir = "${prodLocalRoot}/${s.prodDir}/config"
+                            def dstFile = "${dstDir}/bootstrap-prod.yml"
+                            def srcGit = "${WORKSPACE}/${s.bootstrapRel}"
+                            def dstDirSh = dstDir.replace("'", "'\\''")
+                            def rsyncBootstrapBlock = ''
+                            if (prodSsh) {
+                                rsyncBootstrapBlock = """
                                     ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new '${sshQE}' "mkdir -p '${remQE}/${s.prodDir}/config'"
                                     ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new '${sshQE}' "mkdir -p '${remQE}/${s.prodDir}/config'"
                                     rsync -az '${dstDirSh}/' '${sshQE}:${remQE}/${s.prodDir}/config/'
                                     rsync -az '${dstDirSh}/' '${sshQE}:${remQE}/${s.prodDir}/config/'
 """
 """
-                        }
+                            }
 
 
-                        if (mode == 'from_jar') {
-                            sh """
+                            if (mode == 'from_jar') {
+                                sh """
                                 set -e
                                 set -e
                                 JENKINS_DRY_RUN=${dryRunSh}
                                 JENKINS_DRY_RUN=${dryRunSh}
                                 echo ">>> [bootstrap from jar] ${s.module}"
                                 echo ">>> [bootstrap from jar] ${s.module}"
@@ -571,8 +624,8 @@ pipeline {
                                     ${rsyncBootstrapBlock}
                                     ${rsyncBootstrapBlock}
                                 fi
                                 fi
                             """
                             """
-                        } else {
-                            sh """
+                            } else {
+                                sh """
                                 set -e
                                 set -e
                                 JENKINS_DRY_RUN=${dryRunSh}
                                 JENKINS_DRY_RUN=${dryRunSh}
                                 echo ">>> [bootstrap from git] ${s.module}"
                                 echo ">>> [bootstrap from git] ${s.module}"
@@ -593,7 +646,15 @@ pipeline {
                                     ${rsyncBootstrapBlock}
                                     ${rsyncBootstrapBlock}
                                 fi
                                 fi
                             """
                             """
+                            }
+                        }
+                    }
+                    if (prodSsh && bootSshCred) {
+                        sshagent([bootSshCred]) {
+                            bootstrapLoop()
                         }
                         }
+                    } else {
+                        bootstrapLoop()
                     }
                     }
                 }
                 }
             }
             }
@@ -614,6 +675,7 @@ pipeline {
                     def prodLocalRoot = useRemoteProd ? "${env.WORKSPACE}/.jenkins_remote_prod_staging".replace('\\', '/') : (env.PROD_DEPLOY_ROOT ?: '/alien_produ/java')
                     def prodLocalRoot = useRemoteProd ? "${env.WORKSPACE}/.jenkins_remote_prod_staging".replace('\\', '/') : (env.PROD_DEPLOY_ROOT ?: '/alien_produ/java')
                     def sshQE = prodSsh.replace("'", "'\\''")
                     def sshQE = prodSsh.replace("'", "'\\''")
                     def remQE = prodRemoteRoot.replace("'", "'\\''")
                     def remQE = prodRemoteRoot.replace("'", "'\\''")
+                    def prodSshCred = effectiveProdSshCredentialsId(prodSsh)
 
 
                     def hostEnvFile = (params.PROD_DOCKER_ENV_FILE ?: '').trim()
                     def hostEnvFile = (params.PROD_DOCKER_ENV_FILE ?: '').trim()
                     def jpwStr = jasyptParamToPlain(params.JASYPT_ENCRYPTOR_PASSWORD)
                     def jpwStr = jasyptParamToPlain(params.JASYPT_ENCRYPTOR_PASSWORD)
@@ -624,28 +686,29 @@ pipeline {
                         writeFile file: writtenJasyptEnv, text: "JASYPT_ENCRYPTOR_PASSWORD=${(jpwStr)}\n", encoding: 'UTF-8'
                         writeFile file: writtenJasyptEnv, text: "JASYPT_ENCRYPTOR_PASSWORD=${(jpwStr)}\n", encoding: 'UTF-8'
                         effDockerEnvFile = writtenJasyptEnv
                         effDockerEnvFile = writtenJasyptEnv
                     }
                     }
-                    if (useRemoteProd && hostEnvFile && !params.DRY_RUN) {
-                        def pulledEnv = "${env.WORKSPACE}/.jenkins_remote_pulled_env_${System.currentTimeMillis()}"
-                        def hfEsc = hostEnvFile.replace("'", "'\\''")
-                        sh """
+                    def runPromoteRemoteSteps = {
+                        if (useRemoteProd && hostEnvFile && !params.DRY_RUN) {
+                            def pulledEnv = "${env.WORKSPACE}/.jenkins_remote_pulled_env_${System.currentTimeMillis()}"
+                            def hfEsc = hostEnvFile.replace("'", "'\\''")
+                            sh """
                             set -e
                             set -e
                             scp -o BatchMode=yes -o StrictHostKeyChecking=accept-new '${sshQE}:${hfEsc}' '${pulledEnv}'
                             scp -o BatchMode=yes -o StrictHostKeyChecking=accept-new '${sshQE}:${hfEsc}' '${pulledEnv}'
                         """
                         """
-                        effDockerEnvFile = pulledEnv
-                    }
-                    def jpfTrim = (params.PROD_JASYPT_PASSWORD_FILE ?: '').trim()
-                    def jasyptDefaultPath = useRemoteProd ? "${prodRemoteRoot}/.jasypt-encryptor-password" : "${env.PROD_DEPLOY_ROOT}/.jasypt-encryptor-password"
-                    // 须在 for/sh 外定义:Jenkins CPS 对 sh 字符串插值只认 script 块顶层/同层 Binding 中的变量
-                    def jasyptHostFile = jpfTrim ? jpfTrim : jasyptDefaultPath
-                    def hasJpwPlain = jpwStr.length() > 0 ? '1' : '0'
-                    def jasyptExplicit = jpfTrim ? '1' : '0'
-                    def prodJreForJasypt = params.PROD_JRE_IMAGE ?: 'my-openjdk8-ffmpeg:v1'
+                            effDockerEnvFile = pulledEnv
+                        }
+                        def jpfTrim = (params.PROD_JASYPT_PASSWORD_FILE ?: '').trim()
+                        def jasyptDefaultPath = useRemoteProd ? "${prodRemoteRoot}/.jasypt-encryptor-password" : "${env.PROD_DEPLOY_ROOT}/.jasypt-encryptor-password"
+                        // 须在 for/sh 外定义:Jenkins CPS 对 sh 字符串插值只认 script 块顶层/同层 Binding 中的变量
+                        def jasyptHostFile = jpfTrim ? jpfTrim : jasyptDefaultPath
+                        def hasJpwPlain = jpwStr.length() > 0 ? '1' : '0'
+                        def jasyptExplicit = jpfTrim ? '1' : '0'
+                        def prodJreForJasypt = params.PROD_JRE_IMAGE ?: 'my-openjdk8-ffmpeg:v1'
 
 
-                    if (!params.DRY_RUN) {
-                        def effEsc = (effDockerEnvFile ?: '').replace("'", "'\\''")
-                        def jhfEsc = jasyptHostFile.toString().replace("'", "'\\''")
-                        if (useRemoteProd) {
-                            sh """
+                        if (!params.DRY_RUN) {
+                            def effEsc = (effDockerEnvFile ?: '').replace("'", "'\\''")
+                            def jhfEsc = jasyptHostFile.toString().replace("'", "'\\''")
+                            if (useRemoteProd) {
+                                sh """
                             set +e
                             set +e
                             JENKINS_HAS_JPW=${hasJpwPlain}
                             JENKINS_HAS_JPW=${hasJpwPlain}
                             OK=0
                             OK=0
@@ -667,8 +730,8 @@ pipeline {
                             echo ">>> Jasypt 来源校验通过(远程)"
                             echo ">>> Jasypt 来源校验通过(远程)"
                             exit 0
                             exit 0
                         """
                         """
-                        } else {
-                            sh """
+                            } else {
+                                sh """
                             set +e
                             set +e
                             JENKINS_HAS_JPW=${hasJpwPlain}
                             JENKINS_HAS_JPW=${hasJpwPlain}
                             OK=0
                             OK=0
@@ -706,178 +769,178 @@ pipeline {
                             echo ">>> Jasypt 来源校验通过(env-file / 构建密码 / 密钥文件至少其一有效)"
                             echo ">>> Jasypt 来源校验通过(env-file / 构建密码 / 密钥文件至少其一有效)"
                             exit 0
                             exit 0
                         """
                         """
+                            }
                         }
                         }
-                    }
-
-                    try {
-                        def composeFileForJob = (params.PROD_COMPOSE_FILE ?: '').trim() ?: "${prodRemoteRoot}/docker-compose.yml"
-                        def jicForJob = (params.PROD_JAR_IN_CONTAINER ?: 'app.jar').trim() ?: 'app.jar'
-                        def effEnvPathForSh = (effDockerEnvFile ?: '')
-                        def dcpExplicitPath = (params.PROD_DOCKER_COMPOSE_PATH ?: '').trim()
-                        def dcpShellQuoted = dcpExplicitPath.replace("'", "'\\''")
-                        def dcpV2PluginPath = (params.PROD_DOCKER_COMPOSE_V2_PLUGIN ?: '').trim()
-                        def dcpV2ShellQuoted = dcpV2PluginPath.replace("'", "'\\''")
-                        def dcpDindT = (params.PROD_DOCKER_COMPOSE_DIND_IMAGE != null) ? (params.PROD_DOCKER_COMPOSE_DIND_IMAGE ?: 'docker:24.0.9-cli').toString().trim() : 'docker:24.0.9-cli'
-                        def dcpDindParam = dcpDindT ? dcpDindT : 'docker:24.0.9-cli'
-                        def dcpDindOff = dcpDindParam.equalsIgnoreCase('off') || dcpDindParam == '0'
-                        def dcpDindSh = dcpDindOff ? '' : dcpDindParam.replace("'", "'\\''")
-                        if (useRemoteProd) {
-                            dcpDindSh = ''
-                        }
-                        def extNRaw = params.PROD_DOCKER_CREATE_EXTERNAL_NETWORKS
-                        def extNOff = extNRaw != null && (extNRaw.toString().trim().equalsIgnoreCase('off') || extNRaw.toString().trim() == '0' || extNRaw.toString().trim().equalsIgnoreCase('none'))
-                        def extNList = extNOff ? '' : ((extNRaw == null || extNRaw.toString().trim() == '') ? 'common-network-produ' : extNRaw.toString().trim())
-                        def extNetsSh = extNOff ? '' : extNList.replace("'", "'\\''")
 
 
-                        for (def s in selected) {
-                            def jar = "${s.module}-1.0.0.jar"
-                            def srcJar = "${env.UAT_DEPLOY_ROOT}/${s.uatDir}/${(jar)}"
-                            def dstDir = "${prodLocalRoot}/${s.prodDir}"
-                            def logHostDir = useRemoteProd ? "${prodRemoteRoot}/logs/${s.prodDir}" : "${env.PROD_DEPLOY_ROOT}/logs/${s.prodDir}"
-                            def srcLib = "${env.UAT_DEPLOY_ROOT}/${s.uatDir}/lib"
-                            def dstLib = "${dstDir}/lib"
-                            def cPrimary = s.container
-                            def cFallback = s.prodDir
-                            def composeSvc = s.composeService
-                            def jarHostPath = "${dstDir}/${(jar)}"
-                            def jarOnProdServer = "${prodRemoteRoot}/${s.prodDir}/${(jar)}"
-                            def jarOnProdServerSh = jarOnProdServer.replace("'", "'\\''")
-                            def jarHostPathSh = jarHostPath.replace("'", "'\\''")
-                            def srcLibSh = srcLib.toString().replace("'", "'\\''")
-                            def dstLibSh = dstLib.toString().replace("'", "'\\''")
-                            def jarInContainerPath = "/app/${(jicForJob)}"
-                            def jarInContainerAlt = "/app/${(jar)}"
-                            def jcpPrimaryEscaped = jarInContainerPath.replace("'", "'\\''")
-                            def jcpAltEscaped = (jarInContainerPath == jarInContainerAlt) ? '' : jarInContainerAlt.replace("'", "'\\''")
-                            def jreImage = params.PROD_JRE_IMAGE ?: 'my-openjdk8-ffmpeg:v1'
-                            def jreImageSh = jreImage.toString().replace("'", "'\\''")
-                            def autoCreate = params.AUTO_CREATE_CONTAINER_IF_MISSING ? 'true' : 'false'
-                            def autoRecreate = params.AUTO_RECREATE_IF_MOUNT_MISMATCH ? 'true' : 'false'
-                            def removeStaleJarDir = params.REMOVE_STALE_JAR_DIR_IF_DIRECTORY ? 'true' : 'false'
-                            def payCertHost = (params.PROD_PAY_CERT_HOST ?: '').trim()
-                            def payCertProdDirs = ['store', 'store-platform', 'lawyer', 'dining'] as Set
-                            def payCertSvc = payCertProdDirs.contains(s.prodDir) ? '1' : '0'
-                            def dstPayCertDirInspect = useRemoteProd ? "${prodRemoteRoot}/${s.prodDir}/alien/aliPayCert" : "${dstDir}/alien/aliPayCert"
-                            // payCertHost 不得写进 promoteShell 的 GString(含 PAY_MOUNT="..."),CPS 会报 illegal $;内层 if/elif 整段在 Groovy 中拼接
-                            def payCertHostShellEsc = (payCertHost ?: '').toString().replace('"', '\\"')
-                            def payCertPayMountInner
+                        try {
+                            def composeFileForJob = (params.PROD_COMPOSE_FILE ?: '').trim() ?: "${prodRemoteRoot}/docker-compose.yml"
+                            def jicForJob = (params.PROD_JAR_IN_CONTAINER ?: 'app.jar').trim() ?: 'app.jar'
+                            def effEnvPathForSh = (effDockerEnvFile ?: '')
+                            def dcpExplicitPath = (params.PROD_DOCKER_COMPOSE_PATH ?: '').trim()
+                            def dcpShellQuoted = dcpExplicitPath.replace("'", "'\\''")
+                            def dcpV2PluginPath = (params.PROD_DOCKER_COMPOSE_V2_PLUGIN ?: '').trim()
+                            def dcpV2ShellQuoted = dcpV2PluginPath.replace("'", "'\\''")
+                            def dcpDindT = (params.PROD_DOCKER_COMPOSE_DIND_IMAGE != null) ? (params.PROD_DOCKER_COMPOSE_DIND_IMAGE ?: 'docker:24.0.9-cli').toString().trim() : 'docker:24.0.9-cli'
+                            def dcpDindParam = dcpDindT ? dcpDindT : 'docker:24.0.9-cli'
+                            def dcpDindOff = dcpDindParam.equalsIgnoreCase('off') || dcpDindParam == '0'
+                            def dcpDindSh = dcpDindOff ? '' : dcpDindParam.replace("'", "'\\''")
                             if (useRemoteProd) {
                             if (useRemoteProd) {
-                                payCertPayMountInner = "                                    if [ -n \"" + payCertHostShellEsc + "\" ]; then\n" +
-                                        "                                        PAY_MOUNT=\"" + payCertHostShellEsc + "\"\n" +
-                                        '                                    elif [ -n "$JENKINS_PROD_SSH" ] && ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$JENKINS_PROD_SSH" "test -d \"$JENKINS_PAY_CERT_DIR\""; then\n' +
-                                        '                                        PAY_MOUNT="$JENKINS_PAY_CERT_DIR"\n' +
-                                        '                                    elif [ -n "$JENKINS_PROD_SSH" ] && ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$JENKINS_PROD_SSH" "test -f \"$JENKINS_REMOTE_PROD_ROOT/$PROMOTE_PROD_DIR/alien/apiclient_key.pem\""; then\n' +
-                                        '                                        PAY_MOUNT="$JENKINS_REMOTE_PROD_ROOT/$PROMOTE_PROD_DIR/alien"\n' +
-                                        '                                        echo ">>> WARN: 证书在远端 $PROMOTE_PROD_DIR/alien/ 根目录,将该目录挂为容器内 /usr/local/alien/aliPayCert"\n' +
-                                        '                                    fi\n'
-                            } else {
-                                payCertPayMountInner = "                                    if [ -n \"" + payCertHostShellEsc + "\" ]; then\n" +
-                                        "                                        PAY_MOUNT=\"" + payCertHostShellEsc + "\"\n" +
-                                        '                                    elif [ -d "$JENKINS_PAY_CERT_DIR" ] && [ -n "$(ls -A "$JENKINS_PAY_CERT_DIR" 2>/dev/null)" ]; then\n' +
-                                        '                                        PAY_MOUNT="$JENKINS_PAY_CERT_DIR"\n' +
-                                        '                                    elif [ -f "$PROMOTE_DST_DIR/alien/apiclient_key.pem" ]; then\n' +
-                                        '                                        PAY_MOUNT="$PROMOTE_DST_DIR/alien"\n' +
-                                        '                                        echo ">>> WARN: 证书在 $PROMOTE_DST_DIR/alien/ 根目录(无 alien/aliPayCert 子目录),将该目录挂为容器内 /usr/local/alien/aliPayCert"\n' +
-                                        '                                    fi\n'
+                                dcpDindSh = ''
                             }
                             }
-                            def dryRunPayCertEcho = '                                echo "[DRY_RUN] 证书卷:优先 PROD_PAY_CERT_HOST=' + (payCertHost ? payCertHost : '(未填)') + ',否则若目录存在非空则 $JENKINS_PAY_CERT_DIR 到 /usr/local/alien/aliPayCert:ro"'
-                            // jasyptHostFile / effDockerEnvFile 在 promoteShell 的 GString 中接中文全角「(」等时 CPS 报 illegal $
-                            def jhPath = jasyptHostFile.toString()
-                            def jhPathSh = jhPath.replace("'", "'\\''")
-                            def jhDq = jhPath.replace('\\', '\\\\').replace('"', '\\"')
-                            def effPath = (effDockerEnvFile ?: '').toString()
-                            def effPathN = effPath.replace('"', '\\"')
-                            def dryRunEffIfFi = "                                if [ -n \"" + effPathN + "\" ]; then\n" +
-                                    "                                    echo \"[DRY_RUN] 将在 compose 前对 shell set -a source: " + effPath + "(yml 中 \${JASYPT_*} 等插值用)\"\n" +
-                                    "                                fi\n"
-                            def jasyptDryHfDBlock = "                                JHF_D='" + jhPathSh + "'\n" +
-                                    "                                if [ -f \"\$JHF_D\" ]; then\n" +
-                                    "                                    echo \"[DRY_RUN] 将生成临时 Jasypt env 并在 compose 前 source: " + jhDq + "\"\n" +
-                                    "                                elif command -v docker >/dev/null 2>&1; then\n" +
-                                    "                                    _JD=\$(dirname \"\$JHF_D\")\n" +
-                                    "                                    _JB=\$(basename \"\$JHF_D\")\n" +
-                                    "                                    if docker run --rm --entrypoint sh -v \"\$_JD:/__jd:ro\" \"\$JRE_IMG\" -c \"test -f /__jd/\$_JB\" 2>/dev/null; then\n" +
-                                    "                                        echo \"[DRY_RUN] 密钥文件仅 Docker 宿主机可见: " + jhDq + "\"\n" +
-                                    "                                    fi\n" +
-                                    "                                fi\n"
-                            def envFileOptIfElifFi = "                                if [ -n \"" + effPathN + "\" ] && [ -f \"" + effPathN + "\" ]; then\n" +
-                                    "                                    ENV_FILE_OPT=\"--env-file " + effPath + "\"\n" +
-                                    "                                elif [ -n \"" + effPathN + "\" ]; then\n" +
-                                    "                                    echo \">>> WARN: docker --env-file 路径不可读或不存在: " + effPath + "(Jasypt 等可能无法注入;检查 Jenkins/宿主机挂载或改用构建密码参数)\"\n" +
-                                    "                                fi\n"
-                            def jasyptLineJhf = "                                JHF='" + jhPathSh + "'"
-                            def jasyptWarnExplJpfUnread = '                                    echo ">>> WARN: 显式 PROD_JASYPT_PASSWORD_FILE 不可读: ' + jhDq + '(已依赖其它来源通过晋升前校验)"'
-                            // 避免 promoteShell 中 sh "\$JCPR" 触发 CPS 对 \$\J 的误解析,用 (char)36 生成字面量 $
-                            def dlr = (char) 36
-                            def lineDockerExecOdJcpr = "                                            if docker exec \"${dlr}CNAME\" sh -c 'od -An -N2 -tx1 \"${dlr}1\"' sh \"${dlr}JCPR\" 2>/dev/null | grep -qE '50[[:space:]]+4b'; then"
+                            def extNRaw = params.PROD_DOCKER_CREATE_EXTERNAL_NETWORKS
+                            def extNOff = extNRaw != null && (extNRaw.toString().trim().equalsIgnoreCase('off') || extNRaw.toString().trim() == '0' || extNRaw.toString().trim().equalsIgnoreCase('none'))
+                            def extNList = extNOff ? '' : ((extNRaw == null || extNRaw.toString().trim() == '') ? 'common-network-produ' : extNRaw.toString().trim())
+                            def extNetsSh = extNOff ? '' : extNList.replace("'", "'\\''")
 
 
-                            def jenkinsHomeSh = (env.JENKINS_HOME ?: '/var/jenkins_home').replace("'", "'\\''")
-                            def promoteProdDirSh = s.prodDir.replace("'", "'\\''")
-                            def logHostDirSh = logHostDir.replace("'", "'\\''")
-                            def jarBasenameSh = jar.replace("'", "'\\''")
-                            def cPrimarySh = cPrimary.toString().replace("'", "'\\''")
-                            def cFallbackSh = cFallback.toString().replace("'", "'\\''")
-                            def dstPayCertDirSh = dstPayCertDirInspect.replace("'", "'\\''")
-                            def dstDirSh = dstDir.replace("'", "'\\''")
-                            def uatDeployRoot = env.UAT_DEPLOY_ROOT ?: ''
-                            def uatDeployRootSh = uatDeployRoot.toString().replace("'", "'\\''")
-                            def uatUatServiceDirSh = "${(uatDeployRoot)}/${(s.uatDir)}".toString().replace("'", "'\\''")
-                            def uatDiagLsSvc = "ls -la '" + uatUatServiceDirSh + "' 2>&1"
-                            def uatDiagLsRoot = "ls -la '" + uatDeployRootSh + "' 2>&1"
-                            def missingJarDiag = "                                echo \"诊断 UAT_DEPLOY_ROOT: " + uatDeployRoot + ":\"\n                                " + uatDiagLsSvc + "\n                                " + uatDiagLsRoot
-                            def srcJarSh = srcJar.toString().replace("'", "'\\''")
-                            def ifNoUatJar = "                            if [ ! -f '" + srcJarSh + "' ]; then"
-                            def errEchoUatMissing = "                                echo \"ERROR: 当前 Jenkins 进程内找不到 UAT 制品: " + srcJar + "\""
-                            // 须输出 shell 的 $PROMOTE_DST_DIR;Groovy 中勿用 '$$'(在 bash 里会展开为 PID+字面量,如 17253PROMOTE)
-                            def dryRunCpEcho = "                                echo \"[DRY_RUN] cp -f " + srcJar + " \$PROMOTE_DST_DIR/\""
-                            def cpSrcToDst = "                                cp -f '" + srcJarSh + "' " + "\$" + "PROMOTE_DST_DIR/"
-                            def echoJarLine = '                            echo ">>> [jar] ' + s.module + ': ' + s.uatDir + ' -> ' + s.prodDir + '(compose 服务名: $COMPOSE_SVC, 容器内 jar: $JCP)"'
-                            def dryRunLogDirEcho = useRemoteProd ?
-                                    '                                echo "[DRY_RUN] mkdir -p 本地: $(dirname $PROMOTE_DST_DIR)/logs/$PROMOTE_PROD_DIR -> rsync -> 生产: ' + logHostDir + '"' :
-                                    "                                echo \"[DRY_RUN] mkdir -p " + logHostDir + "(日志目录,与 compose ./logs/" + s.prodDir + ":/app/logs 一致)\""
-                            def jasyptExplLine = "                            JENKINS_JASYPT_EXPL=" + jasyptExplicit
-                            // 无引号 JENKINS_*= 赋值不得写进 promoteShell 的 GString,否则如 ${autoCreate} 会触发非法 $ 转义
-                            def jenkinsEnvBools = "                            JENKINS_DRY_RUN=" + dryRunSh + "\n" +
-                                    "                            JENKINS_PAY_CERT_SVC=" + payCertSvc + "\n" +
-                                    "                            JENKINS_STALE_JAR_RM=" + removeStaleJarDir + "\n" +
-                                    "                            JENKINS_AUTO_RECREATE=" + autoRecreate + "\n" +
-                                    "                            JENKINS_AUTO_CREATE=" + autoCreate
-                            // DCP_*='...' 含 dcpShellQuoted 类变量名时,CPS 对大 GString 仍报 “illegal $”;整段在 Groovy 中拼接
-                            def dcpThreeLines = "                            DCP_EXPLICIT='" + dcpShellQuoted + "'\n" +
-                                    "                            DCP_V2_PLUGIN='" + dcpV2ShellQuoted + "'\n" +
-                                    "                            DCP_DIND_IMAGE='" + dcpDindSh + "'"
-                            def dcpExtNetsLine = "                            DCP_EXT_NETS='" + extNetsSh + "'"
-                            def jarOnProdExportSh = useRemoteProd ? jarOnProdServerSh : ''
-                            def sshExport = useRemoteProd ? sshQE : ''
-                            def remExport = useRemoteProd ? remQE : ''
-                            // export 与环境变量行整段用 + 拼接;大 GString 中 ANY 'VAR=''${...}'' 均可能被 CPS 报 “illegal $”(与 eff* / ext* 等标识符相关)
-                            def promoteExportBlock = "                            export JENKINS_HOME='" + jenkinsHomeSh + "'\n" +
-                                    "                            COMPOSE_FILE='" + composeFileForJob + "'\n" +
-                                    "                            COMPOSE_SVC='" + composeSvc + "'\n" +
-                                    "                            PROMOTE_PROD_DIR='" + promoteProdDirSh + "'\n" +
-                                    "                            PROMOTE_DST_DIR='" + dstDirSh + "'\n" +
-                                    "                            JAR_HOST='" + jarHostPathSh + "'\n" +
-                                    "                            JAR_ON_PROD_SERVER='" + jarOnProdExportSh + "'\n" +
-                                    "                            JENKINS_PROD_SSH='" + sshExport + "'\n" +
-                                    "                            JENKINS_REMOTE_PROD_ROOT='" + remExport + "'\n" +
-                                    "                            PROMOTE_SRC_LIB='" + srcLibSh + "'\n" +
-                                    "                            PROMOTE_DST_LIB='" + dstLibSh + "'\n" +
-                                    "                            JAR_BASENAME='" + jarBasenameSh + "'\n" +
-                                    "                            C_PRIMARY='" + cPrimarySh + "'\n" +
-                                    "                            C_FALLBACK='" + cFallbackSh + "'\n" +
-                                    "                            JENKINS_LOG_DIR='" + logHostDirSh + "'\n" +
-                                    "                            JENKINS_PAY_CERT_DIR='" + dstPayCertDirSh + "'\n" +
-                                    "                            JRE_IMG='" + jreImageSh + "'\n" +
-                                    "                            JCP='" + jcpPrimaryEscaped + "'\n" +
-                                    "                            JCP_ALT='" + jcpAltEscaped + "'\n" +
-                                    "                            JENKINS_PROD_ENV_FILE='" + effEnvPathForSh + "'\n"
+                            for (def s in selected) {
+                                def jar = "${s.module}-1.0.0.jar"
+                                def srcJar = "${env.UAT_DEPLOY_ROOT}/${s.uatDir}/${(jar)}"
+                                def dstDir = "${prodLocalRoot}/${s.prodDir}"
+                                def logHostDir = useRemoteProd ? "${prodRemoteRoot}/logs/${s.prodDir}" : "${env.PROD_DEPLOY_ROOT}/logs/${s.prodDir}"
+                                def srcLib = "${env.UAT_DEPLOY_ROOT}/${s.uatDir}/lib"
+                                def dstLib = "${dstDir}/lib"
+                                def cPrimary = s.container
+                                def cFallback = s.prodDir
+                                def composeSvc = s.composeService
+                                def jarHostPath = "${dstDir}/${(jar)}"
+                                def jarOnProdServer = "${prodRemoteRoot}/${s.prodDir}/${(jar)}"
+                                def jarOnProdServerSh = jarOnProdServer.replace("'", "'\\''")
+                                def jarHostPathSh = jarHostPath.replace("'", "'\\''")
+                                def srcLibSh = srcLib.toString().replace("'", "'\\''")
+                                def dstLibSh = dstLib.toString().replace("'", "'\\''")
+                                def jarInContainerPath = "/app/${(jicForJob)}"
+                                def jarInContainerAlt = "/app/${(jar)}"
+                                def jcpPrimaryEscaped = jarInContainerPath.replace("'", "'\\''")
+                                def jcpAltEscaped = (jarInContainerPath == jarInContainerAlt) ? '' : jarInContainerAlt.replace("'", "'\\''")
+                                def jreImage = params.PROD_JRE_IMAGE ?: 'my-openjdk8-ffmpeg:v1'
+                                def jreImageSh = jreImage.toString().replace("'", "'\\''")
+                                def autoCreate = params.AUTO_CREATE_CONTAINER_IF_MISSING ? 'true' : 'false'
+                                def autoRecreate = params.AUTO_RECREATE_IF_MOUNT_MISMATCH ? 'true' : 'false'
+                                def removeStaleJarDir = params.REMOVE_STALE_JAR_DIR_IF_DIRECTORY ? 'true' : 'false'
+                                def payCertHost = (params.PROD_PAY_CERT_HOST ?: '').trim()
+                                def payCertProdDirs = ['store', 'store-platform', 'lawyer', 'dining'] as Set
+                                def payCertSvc = payCertProdDirs.contains(s.prodDir) ? '1' : '0'
+                                def dstPayCertDirInspect = useRemoteProd ? "${prodRemoteRoot}/${s.prodDir}/alien/aliPayCert" : "${dstDir}/alien/aliPayCert"
+                                // payCertHost 不得写进 promoteShell 的 GString(含 PAY_MOUNT="..."),CPS 会报 illegal $;内层 if/elif 整段在 Groovy 中拼接
+                                def payCertHostShellEsc = (payCertHost ?: '').toString().replace('"', '\\"')
+                                def payCertPayMountInner
+                                if (useRemoteProd) {
+                                    payCertPayMountInner = "                                    if [ -n \"" + payCertHostShellEsc + "\" ]; then\n" +
+                                            "                                        PAY_MOUNT=\"" + payCertHostShellEsc + "\"\n" +
+                                            '                                    elif [ -n "$JENKINS_PROD_SSH" ] && ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$JENKINS_PROD_SSH" "test -d \"$JENKINS_PAY_CERT_DIR\""; then\n' +
+                                            '                                        PAY_MOUNT="$JENKINS_PAY_CERT_DIR"\n' +
+                                            '                                    elif [ -n "$JENKINS_PROD_SSH" ] && ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$JENKINS_PROD_SSH" "test -f \"$JENKINS_REMOTE_PROD_ROOT/$PROMOTE_PROD_DIR/alien/apiclient_key.pem\""; then\n' +
+                                            '                                        PAY_MOUNT="$JENKINS_REMOTE_PROD_ROOT/$PROMOTE_PROD_DIR/alien"\n' +
+                                            '                                        echo ">>> WARN: 证书在远端 $PROMOTE_PROD_DIR/alien/ 根目录,将该目录挂为容器内 /usr/local/alien/aliPayCert"\n' +
+                                            '                                    fi\n'
+                                } else {
+                                    payCertPayMountInner = "                                    if [ -n \"" + payCertHostShellEsc + "\" ]; then\n" +
+                                            "                                        PAY_MOUNT=\"" + payCertHostShellEsc + "\"\n" +
+                                            '                                    elif [ -d "$JENKINS_PAY_CERT_DIR" ] && [ -n "$(ls -A "$JENKINS_PAY_CERT_DIR" 2>/dev/null)" ]; then\n' +
+                                            '                                        PAY_MOUNT="$JENKINS_PAY_CERT_DIR"\n' +
+                                            '                                    elif [ -f "$PROMOTE_DST_DIR/alien/apiclient_key.pem" ]; then\n' +
+                                            '                                        PAY_MOUNT="$PROMOTE_DST_DIR/alien"\n' +
+                                            '                                        echo ">>> WARN: 证书在 $PROMOTE_DST_DIR/alien/ 根目录(无 alien/aliPayCert 子目录),将该目录挂为容器内 /usr/local/alien/aliPayCert"\n' +
+                                            '                                    fi\n'
+                                }
+                                def dryRunPayCertEcho = '                                echo "[DRY_RUN] 证书卷:优先 PROD_PAY_CERT_HOST=' + (payCertHost ? payCertHost : '(未填)') + ',否则若目录存在非空则 $JENKINS_PAY_CERT_DIR 到 /usr/local/alien/aliPayCert:ro"'
+                                // jasyptHostFile / effDockerEnvFile 在 promoteShell 的 GString 中接中文全角「(」等时 CPS 报 illegal $
+                                def jhPath = jasyptHostFile.toString()
+                                def jhPathSh = jhPath.replace("'", "'\\''")
+                                def jhDq = jhPath.replace('\\', '\\\\').replace('"', '\\"')
+                                def effPath = (effDockerEnvFile ?: '').toString()
+                                def effPathN = effPath.replace('"', '\\"')
+                                def dryRunEffIfFi = "                                if [ -n \"" + effPathN + "\" ]; then\n" +
+                                        "                                    echo \"[DRY_RUN] 将在 compose 前对 shell set -a source: " + effPath + "(yml 中 \${JASYPT_*} 等插值用)\"\n" +
+                                        "                                fi\n"
+                                def jasyptDryHfDBlock = "                                JHF_D='" + jhPathSh + "'\n" +
+                                        "                                if [ -f \"\$JHF_D\" ]; then\n" +
+                                        "                                    echo \"[DRY_RUN] 将生成临时 Jasypt env 并在 compose 前 source: " + jhDq + "\"\n" +
+                                        "                                elif command -v docker >/dev/null 2>&1; then\n" +
+                                        "                                    _JD=\$(dirname \"\$JHF_D\")\n" +
+                                        "                                    _JB=\$(basename \"\$JHF_D\")\n" +
+                                        "                                    if docker run --rm --entrypoint sh -v \"\$_JD:/__jd:ro\" \"\$JRE_IMG\" -c \"test -f /__jd/\$_JB\" 2>/dev/null; then\n" +
+                                        "                                        echo \"[DRY_RUN] 密钥文件仅 Docker 宿主机可见: " + jhDq + "\"\n" +
+                                        "                                    fi\n" +
+                                        "                                fi\n"
+                                def envFileOptIfElifFi = "                                if [ -n \"" + effPathN + "\" ] && [ -f \"" + effPathN + "\" ]; then\n" +
+                                        "                                    ENV_FILE_OPT=\"--env-file " + effPath + "\"\n" +
+                                        "                                elif [ -n \"" + effPathN + "\" ]; then\n" +
+                                        "                                    echo \">>> WARN: docker --env-file 路径不可读或不存在: " + effPath + "(Jasypt 等可能无法注入;检查 Jenkins/宿主机挂载或改用构建密码参数)\"\n" +
+                                        "                                fi\n"
+                                def jasyptLineJhf = "                                JHF='" + jhPathSh + "'"
+                                def jasyptWarnExplJpfUnread = '                                    echo ">>> WARN: 显式 PROD_JASYPT_PASSWORD_FILE 不可读: ' + jhDq + '(已依赖其它来源通过晋升前校验)"'
+                                // 避免 promoteShell 中 sh "\$JCPR" 触发 CPS 对 \$\J 的误解析,用 (char)36 生成字面量 $
+                                def dlr = (char) 36
+                                def lineDockerExecOdJcpr = "                                            if docker exec \"${dlr}CNAME\" sh -c 'od -An -N2 -tx1 \"${dlr}1\"' sh \"${dlr}JCPR\" 2>/dev/null | grep -qE '50[[:space:]]+4b'; then"
 
 
-                            def remoteDockerShimBlock = useRemoteProd ? '\n                            if [ -n "$JENKINS_PROD_SSH" ]; then\n                              docker() { ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$JENKINS_PROD_SSH" sudo docker "$@"; }\n                            fi\n' : '\n'
-                            def rsyncRemoteBlock = useRemoteProd ? '\n                                if [ -n "$JENKINS_PROD_SSH" ]; then\n                                    echo ">>> rsync 制品到 $JENKINS_PROD_SSH:$JENKINS_REMOTE_PROD_ROOT/$PROMOTE_PROD_DIR/"\n                                    ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$JENKINS_PROD_SSH" "sudo mkdir -p $JENKINS_REMOTE_PROD_ROOT/$PROMOTE_PROD_DIR $JENKINS_REMOTE_PROD_ROOT/logs/$PROMOTE_PROD_DIR"\n                                    rsync -az "$PROMOTE_DST_DIR/" "$JENKINS_PROD_SSH:$JENKINS_REMOTE_PROD_ROOT/$PROMOTE_PROD_DIR/"\n                                    rsync -az "$(dirname "$PROMOTE_DST_DIR")/logs/$PROMOTE_PROD_DIR/" "$JENKINS_PROD_SSH:$JENKINS_REMOTE_PROD_ROOT/logs/$PROMOTE_PROD_DIR/" 2>/dev/null || true\n                                fi\n' : '\n'
-                            def composeExistsBlockSh = useRemoteProd ? '\n                                if ! ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$JENKINS_PROD_SSH" "test -f $COMPOSE_FILE"; then\n                                    echo "ERROR: 远程未找到 docker compose 文件: $COMPOSE_FILE"\n                                    exit 1\n                                fi\n' : '\n                                if [ ! -f "$COMPOSE_FILE" ]; then\n                                    echo "ERROR: 未找到 docker compose 文件: $COMPOSE_FILE。请将 PROD_COMPOSE_FILE/PROD_DEPLOY_ROOT 与现网 yml 对齐,例如 /alien_produ/java/docker-compose.yml"\n                                    exit 1\n                                fi\n'
-                            def composeDirBlockSh = useRemoteProd ? '                                COMPOSE_DIR=$(dirname "$COMPOSE_FILE")' : '                                COMPOSE_DIR=$(cd "$(dirname "$COMPOSE_FILE")" && pwd)'
+                                def jenkinsHomeSh = (env.JENKINS_HOME ?: '/var/jenkins_home').replace("'", "'\\''")
+                                def promoteProdDirSh = s.prodDir.replace("'", "'\\''")
+                                def logHostDirSh = logHostDir.replace("'", "'\\''")
+                                def jarBasenameSh = jar.replace("'", "'\\''")
+                                def cPrimarySh = cPrimary.toString().replace("'", "'\\''")
+                                def cFallbackSh = cFallback.toString().replace("'", "'\\''")
+                                def dstPayCertDirSh = dstPayCertDirInspect.replace("'", "'\\''")
+                                def dstDirSh = dstDir.replace("'", "'\\''")
+                                def uatDeployRoot = env.UAT_DEPLOY_ROOT ?: ''
+                                def uatDeployRootSh = uatDeployRoot.toString().replace("'", "'\\''")
+                                def uatUatServiceDirSh = "${(uatDeployRoot)}/${(s.uatDir)}".toString().replace("'", "'\\''")
+                                def uatDiagLsSvc = "ls -la '" + uatUatServiceDirSh + "' 2>&1"
+                                def uatDiagLsRoot = "ls -la '" + uatDeployRootSh + "' 2>&1"
+                                def missingJarDiag = "                                echo \"诊断 UAT_DEPLOY_ROOT: " + uatDeployRoot + ":\"\n                                " + uatDiagLsSvc + "\n                                " + uatDiagLsRoot
+                                def srcJarSh = srcJar.toString().replace("'", "'\\''")
+                                def ifNoUatJar = "                            if [ ! -f '" + srcJarSh + "' ]; then"
+                                def errEchoUatMissing = "                                echo \"ERROR: 当前 Jenkins 进程内找不到 UAT 制品: " + srcJar + "\""
+                                // 须输出 shell 的 $PROMOTE_DST_DIR;Groovy 中勿用 '$$'(在 bash 里会展开为 PID+字面量,如 17253PROMOTE)
+                                def dryRunCpEcho = "                                echo \"[DRY_RUN] cp -f " + srcJar + " \$PROMOTE_DST_DIR/\""
+                                def cpSrcToDst = "                                cp -f '" + srcJarSh + "' " + "\$" + "PROMOTE_DST_DIR/"
+                                def echoJarLine = '                            echo ">>> [jar] ' + s.module + ': ' + s.uatDir + ' -> ' + s.prodDir + '(compose 服务名: $COMPOSE_SVC, 容器内 jar: $JCP)"'
+                                def dryRunLogDirEcho = useRemoteProd ?
+                                        '                                echo "[DRY_RUN] mkdir -p 本地: $(dirname $PROMOTE_DST_DIR)/logs/$PROMOTE_PROD_DIR -> rsync -> 生产: ' + logHostDir + '"' :
+                                        "                                echo \"[DRY_RUN] mkdir -p " + logHostDir + "(日志目录,与 compose ./logs/" + s.prodDir + ":/app/logs 一致)\""
+                                def jasyptExplLine = "                            JENKINS_JASYPT_EXPL=" + jasyptExplicit
+                                // 无引号 JENKINS_*= 赋值不得写进 promoteShell 的 GString,否则如 ${autoCreate} 会触发非法 $ 转义
+                                def jenkinsEnvBools = "                            JENKINS_DRY_RUN=" + dryRunSh + "\n" +
+                                        "                            JENKINS_PAY_CERT_SVC=" + payCertSvc + "\n" +
+                                        "                            JENKINS_STALE_JAR_RM=" + removeStaleJarDir + "\n" +
+                                        "                            JENKINS_AUTO_RECREATE=" + autoRecreate + "\n" +
+                                        "                            JENKINS_AUTO_CREATE=" + autoCreate
+                                // DCP_*='...' 含 dcpShellQuoted 类变量名时,CPS 对大 GString 仍报 “illegal $”;整段在 Groovy 中拼接
+                                def dcpThreeLines = "                            DCP_EXPLICIT='" + dcpShellQuoted + "'\n" +
+                                        "                            DCP_V2_PLUGIN='" + dcpV2ShellQuoted + "'\n" +
+                                        "                            DCP_DIND_IMAGE='" + dcpDindSh + "'"
+                                def dcpExtNetsLine = "                            DCP_EXT_NETS='" + extNetsSh + "'"
+                                def jarOnProdExportSh = useRemoteProd ? jarOnProdServerSh : ''
+                                def sshExport = useRemoteProd ? sshQE : ''
+                                def remExport = useRemoteProd ? remQE : ''
+                                // export 与环境变量行整段用 + 拼接;大 GString 中 ANY 'VAR=''${...}'' 均可能被 CPS 报 “illegal $”(与 eff* / ext* 等标识符相关)
+                                def promoteExportBlock = "                            export JENKINS_HOME='" + jenkinsHomeSh + "'\n" +
+                                        "                            COMPOSE_FILE='" + composeFileForJob + "'\n" +
+                                        "                            COMPOSE_SVC='" + composeSvc + "'\n" +
+                                        "                            PROMOTE_PROD_DIR='" + promoteProdDirSh + "'\n" +
+                                        "                            PROMOTE_DST_DIR='" + dstDirSh + "'\n" +
+                                        "                            JAR_HOST='" + jarHostPathSh + "'\n" +
+                                        "                            JAR_ON_PROD_SERVER='" + jarOnProdExportSh + "'\n" +
+                                        "                            JENKINS_PROD_SSH='" + sshExport + "'\n" +
+                                        "                            JENKINS_REMOTE_PROD_ROOT='" + remExport + "'\n" +
+                                        "                            PROMOTE_SRC_LIB='" + srcLibSh + "'\n" +
+                                        "                            PROMOTE_DST_LIB='" + dstLibSh + "'\n" +
+                                        "                            JAR_BASENAME='" + jarBasenameSh + "'\n" +
+                                        "                            C_PRIMARY='" + cPrimarySh + "'\n" +
+                                        "                            C_FALLBACK='" + cFallbackSh + "'\n" +
+                                        "                            JENKINS_LOG_DIR='" + logHostDirSh + "'\n" +
+                                        "                            JENKINS_PAY_CERT_DIR='" + dstPayCertDirSh + "'\n" +
+                                        "                            JRE_IMG='" + jreImageSh + "'\n" +
+                                        "                            JCP='" + jcpPrimaryEscaped + "'\n" +
+                                        "                            JCP_ALT='" + jcpAltEscaped + "'\n" +
+                                        "                            JENKINS_PROD_ENV_FILE='" + effEnvPathForSh + "'\n"
 
 
-                            def promoteShell = """
+                                def remoteDockerShimBlock = useRemoteProd ? '\n                            if [ -n "$JENKINS_PROD_SSH" ]; then\n                              docker() { ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$JENKINS_PROD_SSH" sudo docker "$@"; }\n                            fi\n' : '\n'
+                                def rsyncRemoteBlock = useRemoteProd ? '\n                                if [ -n "$JENKINS_PROD_SSH" ]; then\n                                    echo ">>> rsync 制品到 $JENKINS_PROD_SSH:$JENKINS_REMOTE_PROD_ROOT/$PROMOTE_PROD_DIR/"\n                                    ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$JENKINS_PROD_SSH" "sudo mkdir -p $JENKINS_REMOTE_PROD_ROOT/$PROMOTE_PROD_DIR $JENKINS_REMOTE_PROD_ROOT/logs/$PROMOTE_PROD_DIR"\n                                    rsync -az "$PROMOTE_DST_DIR/" "$JENKINS_PROD_SSH:$JENKINS_REMOTE_PROD_ROOT/$PROMOTE_PROD_DIR/"\n                                    rsync -az "$(dirname "$PROMOTE_DST_DIR")/logs/$PROMOTE_PROD_DIR/" "$JENKINS_PROD_SSH:$JENKINS_REMOTE_PROD_ROOT/logs/$PROMOTE_PROD_DIR/" 2>/dev/null || true\n                                fi\n' : '\n'
+                                def composeExistsBlockSh = useRemoteProd ? '\n                                if ! ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$JENKINS_PROD_SSH" "test -f $COMPOSE_FILE"; then\n                                    echo "ERROR: 远程未找到 docker compose 文件: $COMPOSE_FILE"\n                                    exit 1\n                                fi\n' : '\n                                if [ ! -f "$COMPOSE_FILE" ]; then\n                                    echo "ERROR: 未找到 docker compose 文件: $COMPOSE_FILE。请将 PROD_COMPOSE_FILE/PROD_DEPLOY_ROOT 与现网 yml 对齐,例如 /alien_produ/java/docker-compose.yml"\n                                    exit 1\n                                fi\n'
+                                def composeDirBlockSh = useRemoteProd ? '                                COMPOSE_DIR=$(dirname "$COMPOSE_FILE")' : '                                COMPOSE_DIR=$(cd "$(dirname "$COMPOSE_FILE")" && pwd)'
+
+                                def promoteShell = """
                             set -e
                             set -e
                             #JENKINS_PLACE_EXPORT_ENV
                             #JENKINS_PLACE_EXPORT_ENV
                             #JENKINS_PLACE_REMOTE_DOCKER_SHIM
                             #JENKINS_PLACE_REMOTE_DOCKER_SHIM
@@ -1487,37 +1550,45 @@ pipeline {
                                 fi
                                 fi
                             fi
                             fi
                         """
                         """
-                            sh( promoteShell.toString()
-                            // replaceAll 的替换串含 shell 的 $CNAME 等,Java 会当「组引用」;须 quoteReplacement
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_EXPORT_ENV\n/, java.util.regex.Matcher.quoteReplacement("\n" + promoteExportBlock))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_REMOTE_DOCKER_SHIM\n/, java.util.regex.Matcher.quoteReplacement(remoteDockerShimBlock))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_RSYNC_REMOTE\n/, java.util.regex.Matcher.quoteReplacement(rsyncRemoteBlock))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_COMPOSE_EXISTS\n/, java.util.regex.Matcher.quoteReplacement(composeExistsBlockSh))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_COMPOSE_DIR\n/, java.util.regex.Matcher.quoteReplacement("\n" + composeDirBlockSh + "\n"))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_DRYRUN_PAYCERT_ECHO\n/, java.util.regex.Matcher.quoteReplacement("\n" + dryRunPayCertEcho + "\n"))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_DRYRUN_EFF\n/, java.util.regex.Matcher.quoteReplacement("\n" + dryRunEffIfFi + "\n"))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_DRYRUN_JHF_D\n/, java.util.regex.Matcher.quoteReplacement("\n" + jasyptDryHfDBlock + "\n"))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_PAY_CERT_MOUNT_INNER\n/, java.util.regex.Matcher.quoteReplacement("\n" + payCertPayMountInner + "\n"))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_DCP_THREE\n/, java.util.regex.Matcher.quoteReplacement("\n" + dcpThreeLines + "\n"))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_DCP_EXT_NETS\n/, java.util.regex.Matcher.quoteReplacement("\n" + dcpExtNetsLine + "\n"))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_ENV_BOOLS\n/, java.util.regex.Matcher.quoteReplacement("\n" + jenkinsEnvBools + "\n"))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_JASYPT_EXPL\n/, java.util.regex.Matcher.quoteReplacement("\n" + jasyptExplLine + "\n"))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_ECHO_JARLINE\n/, java.util.regex.Matcher.quoteReplacement("\n" + echoJarLine + "\n"))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_IF_NO_UAT_JAR\n/, java.util.regex.Matcher.quoteReplacement("\n" + ifNoUatJar + "\n"))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_ECHO_ERR_UAT_JAR\n/, java.util.regex.Matcher.quoteReplacement("\n" + errEchoUatMissing + "\n"))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_MISSING_JAR_DIAG\n/, java.util.regex.Matcher.quoteReplacement("\n" + missingJarDiag + "\n"))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_DRYRUN_LOGDIR_ECHO\n/, java.util.regex.Matcher.quoteReplacement("\n" + dryRunLogDirEcho + "\n"))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_DRYRUN_CP\n/, java.util.regex.Matcher.quoteReplacement("\n" + dryRunCpEcho + "\n"))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_CP_PROD\n/, java.util.regex.Matcher.quoteReplacement("\n" + cpSrcToDst + "\n"))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_ENV_FILE_OPT\n/, java.util.regex.Matcher.quoteReplacement("\n" + envFileOptIfElifFi + "\n"))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_JHF\n/, java.util.regex.Matcher.quoteReplacement("\n" + jasyptLineJhf + "\n"))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_JASYPT_WARN_EXPL_UNREAD\n/, java.util.regex.Matcher.quoteReplacement("\n" + jasyptWarnExplJpfUnread + "\n"))
-                                    .replaceAll(~/\n[ \t]*#JENKINS_PLACE_DOCKER_EXEC_OD_JCPR\n/, java.util.regex.Matcher.quoteReplacement("\n" + lineDockerExecOdJcpr + "\n")) )
+                                sh( promoteShell.toString()
+                                // replaceAll 的替换串含 shell 的 $CNAME 等,Java 会当「组引用」;须 quoteReplacement
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_EXPORT_ENV\n/, java.util.regex.Matcher.quoteReplacement("\n" + promoteExportBlock))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_REMOTE_DOCKER_SHIM\n/, java.util.regex.Matcher.quoteReplacement(remoteDockerShimBlock))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_RSYNC_REMOTE\n/, java.util.regex.Matcher.quoteReplacement(rsyncRemoteBlock))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_COMPOSE_EXISTS\n/, java.util.regex.Matcher.quoteReplacement(composeExistsBlockSh))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_COMPOSE_DIR\n/, java.util.regex.Matcher.quoteReplacement("\n" + composeDirBlockSh + "\n"))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_DRYRUN_PAYCERT_ECHO\n/, java.util.regex.Matcher.quoteReplacement("\n" + dryRunPayCertEcho + "\n"))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_DRYRUN_EFF\n/, java.util.regex.Matcher.quoteReplacement("\n" + dryRunEffIfFi + "\n"))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_DRYRUN_JHF_D\n/, java.util.regex.Matcher.quoteReplacement("\n" + jasyptDryHfDBlock + "\n"))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_PAY_CERT_MOUNT_INNER\n/, java.util.regex.Matcher.quoteReplacement("\n" + payCertPayMountInner + "\n"))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_DCP_THREE\n/, java.util.regex.Matcher.quoteReplacement("\n" + dcpThreeLines + "\n"))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_DCP_EXT_NETS\n/, java.util.regex.Matcher.quoteReplacement("\n" + dcpExtNetsLine + "\n"))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_ENV_BOOLS\n/, java.util.regex.Matcher.quoteReplacement("\n" + jenkinsEnvBools + "\n"))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_JASYPT_EXPL\n/, java.util.regex.Matcher.quoteReplacement("\n" + jasyptExplLine + "\n"))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_ECHO_JARLINE\n/, java.util.regex.Matcher.quoteReplacement("\n" + echoJarLine + "\n"))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_IF_NO_UAT_JAR\n/, java.util.regex.Matcher.quoteReplacement("\n" + ifNoUatJar + "\n"))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_ECHO_ERR_UAT_JAR\n/, java.util.regex.Matcher.quoteReplacement("\n" + errEchoUatMissing + "\n"))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_MISSING_JAR_DIAG\n/, java.util.regex.Matcher.quoteReplacement("\n" + missingJarDiag + "\n"))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_DRYRUN_LOGDIR_ECHO\n/, java.util.regex.Matcher.quoteReplacement("\n" + dryRunLogDirEcho + "\n"))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_DRYRUN_CP\n/, java.util.regex.Matcher.quoteReplacement("\n" + dryRunCpEcho + "\n"))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_CP_PROD\n/, java.util.regex.Matcher.quoteReplacement("\n" + cpSrcToDst + "\n"))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_ENV_FILE_OPT\n/, java.util.regex.Matcher.quoteReplacement("\n" + envFileOptIfElifFi + "\n"))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_JHF\n/, java.util.regex.Matcher.quoteReplacement("\n" + jasyptLineJhf + "\n"))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_JASYPT_WARN_EXPL_UNREAD\n/, java.util.regex.Matcher.quoteReplacement("\n" + jasyptWarnExplJpfUnread + "\n"))
+                                        .replaceAll(~/\n[ \t]*#JENKINS_PLACE_DOCKER_EXEC_OD_JCPR\n/, java.util.regex.Matcher.quoteReplacement("\n" + lineDockerExecOdJcpr + "\n")) )
+                            }
+                        } finally {
+                            if (writtenJasyptEnv) {
+                                sh "rm -f \"${writtenJasyptEnv}\" 2>/dev/null || true"
+                            }
                         }
                         }
-                    } finally {
-                        if (writtenJasyptEnv) {
-                            sh "rm -f \"${writtenJasyptEnv}\" 2>/dev/null || true"
+                    }
+                    if (useRemoteProd && prodSshCred) {
+                        sshagent([prodSshCred]) {
+                            runPromoteRemoteSteps()
                         }
                         }
+                    } else {
+                        runPromoteRemoteSteps()
                     }
                     }
                 }
                 }
             }
             }
@@ -1535,7 +1606,7 @@ pipeline {
         failure {
         failure {
             echo '>>> 流水线失败:优先检查 UAT 是否已发版、UAT_DEPLOY_ROOT/PROD_DEPLOY_ROOT、compose 路径、Docker 与挂载'
             echo '>>> 流水线失败:优先检查 UAT 是否已发版、UAT_DEPLOY_ROOT/PROD_DEPLOY_ROOT、compose 路径、Docker 与挂载'
             echo '>>> 制品与 jar 来自 UAT 目录(UAT_DEPLOY_ROOT/<uatDir>/),不依赖 Git;仅当「BOOTSTRAP_SYNC_MODE=from_git」才会拉取 Git 取 bootstrap-prod.yml。默认 from_jar=从 UAT jar 解压,无需 Git'
             echo '>>> 制品与 jar 来自 UAT 目录(UAT_DEPLOY_ROOT/<uatDir>/),不依赖 Git;仅当「BOOTSTRAP_SYNC_MODE=from_git」才会拉取 Git 取 bootstrap-prod.yml。默认 from_jar=从 UAT jar 解压,无需 Git'
-            echo '>>> 若 Jenkins 与生产 Docker 不在同一台机器:须配置 PROD_SSH_TARGET(免密 ssh + 目标机 sudo docker);制品经 rsync 到对方 PROD_DEPLOY_ROOT;留空 PROD_SSH_TARGET 则仍为本机 docker compose'
+            echo '>>> 若 Jenkins 与生产 Docker 不在同一台机器:须 PROD_SSH_TARGET;凭据用 PROD_SSH_CREDENTIALS_ID(SSH Agent + 私钥凭据)。界面参数仍为空时脚本会回退 DEFAULT_PROD_SSH_CREDENTIALS_ID;若报 Permission denied 检查凭据 ID/私钥是否匹配目标机'
             echo '>>> 若 source / compose 报 invalid env file / invalid utf8:将 .env.produ-docker 改为 UTF-8,或用 env.produ-docker.example;Jasypt 需进入容器 env,检查 compose、.env 与 dcp 前 set -a、AUTO_RECREATE'
             echo '>>> 若 source / compose 报 invalid env file / invalid utf8:将 .env.produ-docker 改为 UTF-8,或用 env.produ-docker.example;Jasypt 需进入容器 env,检查 compose、.env 与 dcp 前 set -a、AUTO_RECREATE'
             echo '>>> 若报 unknown shorthand flag: f 或本机无 compose:默认已用 PROD_DOCKER_COMPOSE_DIND_IMAGE(如 docker:24.0.9-cli)通过 docker run 回退;失败则检查能 pull 该镜像、/var/run/docker.sock、或设 PROD_DOCKER_COMPOSE_PATH/PROD_DOCKER_COMPOSE_V2_PLUGIN/挂 cli-plugins'
             echo '>>> 若报 unknown shorthand flag: f 或本机无 compose:默认已用 PROD_DOCKER_COMPOSE_DIND_IMAGE(如 docker:24.0.9-cli)通过 docker run 回退;失败则检查能 pull 该镜像、/var/run/docker.sock、或设 PROD_DOCKER_COMPOSE_PATH/PROD_DOCKER_COMPOSE_V2_PLUGIN/挂 cli-plugins'
             echo '>>> 若报 network ... declared as external, but could not be found:设 PROD_DOCKER_CREATE_EXTERNAL_NETWORKS 为 yml 中网名(默认预建 common-network-produ),或宿主机 docker network create'
             echo '>>> 若报 network ... declared as external, but could not be found:设 PROD_DOCKER_CREATE_EXTERNAL_NETWORKS 为 yml 中网名(默认预建 common-network-produ),或宿主机 docker network create'