Parcourir la source

Jenkins 从UAT生成生产环境镜像(V1)

dujian il y a 18 heures
Parent
commit
c7be7c5e0b
1 fichiers modifiés avec 1001 ajouts et 0 suppressions
  1. 1001 0
      docs/jenkins/Jenkinsfile-prod-promote-from-uat.groovy

+ 1001 - 0
docs/jenkins/Jenkinsfile-prod-promote-from-uat.groovy

@@ -0,0 +1,1001 @@
+/**
+ * 生产环境:从 UAT 晋升 jar(及 lib),并可选同步 bootstrap-prod.yml 到生产目录。
+ *
+ * 【唯一维护点】getServiceDefinitions() 内的 registry 列表(勿用顶层 def,避免 Jenkins CPS 缺 SERVICE_REGISTRY)。
+ * 命名约定(自动派生,一般无需改):module=alien-<prodDir>,uatDir=<prodDir>-uat,container=<prodDir>-produ
+ *
+ * 发版范围(DEPLOY_MODE):
+ * - whole:整体发版
+ * - single:单服务
+ * - multi:勾选 MULTI_* 复选框(顺序与 registry 列表一致)
+ *
+ * bootstrap-prod.yml(BOOTSTRAP_SYNC_MODE):from_jar / from_git / none。
+ * from_jar 时:gateway 等为 Spring Boot 可执行包则路径为 BOOT-INF/classes/;job 等为瘦 jar 则为 jar 根目录 bootstrap-prod.yml。
+ *
+ * 运行时需 --spring.profiles.active=prod;解压到磁盘的 bootstrap 用于审计或外置挂载。
+ *
+ * environment 块作用:
+ * - 为本流水线所有 stage 注入「环境变量」,在 shell 中为 ${env.XXX},Groovy 中为 env.XXX。
+ * - GIT_URL / GIT_BRANCH / GIT_CREDENTIALS:仅在 BOOTSTRAP_SYNC_MODE=from_git 时用于 git 步骤与 reset。
+ * - UAT_DEPLOY_ROOT / PROD_DEPLOY_ROOT:与 Jenkins 宿主机(或挂载)上的预生产、生产制品根目录一致;
+ *   若 Job 跑在容器内,需与 docker-compose 里挂载到 Jenkins 的路径一致。
+ *
+ * 预生产(UAT)晋升源路径(宿主机上为 /alien_uat/java/<uatDir>/,可用 Job 参数 UAT_DEPLOY_ROOT 改「Jenkins 进程内」路径):
+ *   <UAT_DEPLOY_ROOT>/<uatDir>/alien-<prodDir>-1.0.0.jar
+ *   <UAT_DEPLOY_ROOT>/<uatDir>/lib/(若有)
+ * 其中 uatDir 与 getServiceDefinitions() 中一致(如 gateway-uat、store-uat)。
+ * Jenkins 跑在容器内时(工作区常为 /var/jenkins_home/workspace),宿主机 /alien_uat/java 需挂载进容器。
+ * 预生产 Maven+拷贝+restart 流水线见 docs/jenkins/Jenkinsfile-uat-build-deploy.groovy。
+ * 与现网 compose 一致(如 /alien_prod/jenkins/docker-compose.yml、docs/jenkins/docker-compose-jenkins-host-network.yml):volume「/alien_uat/java:/app_deploy_uat」,
+ * 容器内 UAT 根目录为 /app_deploy_uat(UAT_DEPLOY_ROOT 默认);network_mode: host 不改变上述挂载路径。
+ * 仅当 Jenkins 直接跑在宿主机且能读 /alien_uat/java 时,将 UAT_DEPLOY_ROOT 设为 /alien_uat/java。
+ *
+ * 生产发版后的文件位置(默认 PROD_DEPLOY_ROOT=/alien_produ/java,可用 Job 参数改;须与 Jenkins 容器内「能写到的」路径一致):
+ *   <PROD_DEPLOY_ROOT>/<prodDir>/alien-<prodDir>-1.0.0.jar
+ *   <PROD_DEPLOY_ROOT>/<prodDir>/lib/(若有)
+ *   <PROD_DEPLOY_ROOT>/<prodDir>/config/bootstrap-prod.yml(若同步 bootstrap)
+ * 与预生产「每服务一子目录」约定一致。生产 Docker 挂载 jar 时请指向 PROD 路径。
+ * 若 compose 将生产 jar 挂为「../java:/app_deploy」(相对 compose 目录),则 Jenkins 内应设 PROD_DEPLOY_ROOT=/app_deploy,对应宿主机为 compose 上级目录下的 java/。
+ * 【必配】若 Job 使用 PROD_DEPLOY_ROOT=/alien_produ/java,Jenkins 容器必须 volume 挂载宿主机 /alien_produ/java 到同路径(见 docker-compose-jenkins-host-network.yml),否则 cp 写在 Jenkins 私有层、*-produ 读宿主机文件,unzip 通过仍 corrupt jarfile。
+ *
+ * 若 PROD 目录下出现 alien-xxx-1.0.0.jar「目录」且为空:多为历史上 docker run -v 指向不存在的文件时 Docker 在宿主机误建同名目录;须 rm -rf 该目录后再拷贝真 jar。
+ * 目录改为文件后仅 docker restart 常报 not a directory / vice-versa:旧容器 bind 与现路径类型不一致,须 rm 后 docker run;流水线在删误建目录或 restart 报此类错时会重建(AUTO_RECREATE_IF_MOUNT_MISMATCH)。
+ * 七个 *-produ 一次性处理:宿主机执行 docs/jenkins/fix-prod-stale-jar-dirs.sh(或读其末尾注释里的手敲命令)。
+ * 容器报 Invalid or corrupt jarfile:宿主机挂载文件须为合法 jar(非空、非目录)。勿在 jar 未就绪时 docker run -v(易生成空占位);
+ * 修复后删容器重建。流水线在 restart/run 前会对 jar 做 unzip -t 校验。
+ * 若仍报 corrupt:常见原因是容器创建时 -v 指向的路径与当前 PROD_DEPLOY_ROOT 下 jar 不一致——docker restart 不会改 bind mount,须 rm 后按新路径 run。
+ * 流水线可比对 docker inspect 的挂载源与本次 jar 路径;若开启 AUTO_RECREATE_IF_MOUNT_MISMATCH 将自动删容器并按本次路径重建。
+ * 对需支付证书的服务(store 等):若宿主机已有证书目录(CERT_VOL=1)但现有容器未挂 /usr/local/alien/aliPayCert 或挂载源与本次 PAY_MOUNT 不一致,同样依赖 AUTO_RECREATE 先删后建;仅 restart 永远不能补证书卷。
+ * Jasypt:须与预生产 docker-compose 的 x-java-env.JASYPT_ENCRYPTOR_PASSWORD 一致(现网常为 alien_salt)。默认 PROD_DOCKER_ENV_FILE=<PROD_DEPLOY_ROOT>/.env.produ-docker;文件须 UTF-8(Docker --env-file 拒绝 GBK/中文注释的非 UTF-8),见 docs/jenkins/env.produ-docker.example。亦可密钥文件或构建参数 JASYPT_ENCRYPTOR_PASSWORD。容器未带该 env 时须 AUTO_RECREATE_IF_MOUNT_MISMATCH 重建。手工 compose 见 docs/docker/docker-compose-prod.example.yml。
+ * restart/run 后若立刻 docker exec 可能报 unable to upgrade to tcp, received 409(容器尚未 running);流水线会等待稳定 running 并重试 exec。
+ * 若 inspect 长期为 restarting,多为 Java 反复退出(corrupt jar、缺 lib、端口占用等),与单纯 409 不同;此时应先 docker logs 再修应用。
+ * 瘦 jar 且 MANIFEST 依赖 lib/ 时:若仅 -v 挂载单个 jar、未把宿主机 lib 挂进容器,会 NoClassDefFoundError(如 SpringApplication)并 restarting;自动创建/重建容器时已增加 -v <PROD>/.../lib:/app/lib:ro(与 -w /app、Class-Path 相对 jar 目录一致)。
+ * 证书不在流水线内拷贝。约定需证书服务在宿主机:PROD_DEPLOY_ROOT 下各 prodDir 的 alien/aliPayCert/(与 copy-uat-pay-certs-to-prod.sh 的 cp 目标一致)。
+ * 自动 docker run 时:挂载「宿主机目录」到容器 /usr/local/alien/aliPayCert(应用读 /usr/local/alien/aliPayCert/apiclient_key.pem)。
+ * 约定源目录为 PROD_DEPLOY_ROOT/<prodDir>/alien/aliPayCert/;若证书实际在 <prodDir>/alien/ 根下也会自动识别。
+ * 常见错误:把 .../alien 挂到容器 /usr/local/alien/aliPayCert(会多一层,pem 落在 .../aliPayCert/aliPayCert/ 下)——须挂 .../alien/aliPayCert,或留空让脚本自动选。
+ * 若填写 PROD_PAY_CERT_HOST 则全局覆盖。仅重建容器会带上卷,restart 不会。适用:store、store-platform、lawyer、dining。
+ * 应用日志目录:与生产 compose 一致,宿主机 <PROD_DEPLOY_ROOT>/logs/<prodDir> 挂载到容器 /app/logs(与 LOGGING_PATH=/app/logs 一致)。
+ * 自动 docker run 必带该卷;已存在容器若未挂 /app/logs 或路径不一致,开启 AUTO_RECREATE_IF_MOUNT_MISMATCH 时将删容器后按本次路径重建,否则仅 WARN。
+ * 自动 docker run 默认传 -e TZ=Asia/Shanghai(日志时间与北京时间一致);参数 PROD_CONTAINER_TZ 留空则不传 TZ(镜像多为 UTC)。
+ *
+ * 【若界面仍为 MULTI_SERVICE_NAMES 文本框】Job 未用本文件最新定义,或「参数化构建」里手动加了旧参数与脚本重复。
+ * 处理:1)Pipeline 更新为仓库中本 Jenkinsfile;2)Job→配置→删除手动参数 MULTI_SERVICE_NAMES、SYNC_BOOTSTRAP_FROM_GIT 等,保存;
+ * 3)再打开「Build with Parameters」,应出现 MULTI_gateway、MULTI_job… 复选框与 BOOTSTRAP_SYNC_MODE,无 MULTI_SERVICE_NAMES。
+ *
+ * 【multi 复选框不显示】Declarative 的 parameters{} 内不能用 .each 动态生成 booleanParam(常被忽略)。
+ * 下方 MULTI_* 已改为逐项手写;新增微服务时请在 getServiceDefinitions() 内 registry 与 parameters 中各增加对应一项。
+ *
+ * 【parameters 内勿调用 getServiceDefinitions()】部分 Jenkins 解析 parameters{} 时无法执行自定义函数,
+ * 会导致从该行起整段参数(含 MULTI_*、BOOTSTRAP_SYNC_MODE)全部不注册,界面只剩前几项。
+ * SINGLE_SERVICE 的 choices 已改为静态列表,须与 getServiceDefinitions() 内 registry 的 prodDir 一致。
+ *
+ * 【界面仍像旧版】1)SCM 的 Script Path 须指向本文件,如 docs/jenkins/Jenkinsfile-prod-promote-from-uat.groovy;
+ * 2)保存 Job 后执行一次构建以重新注册参数;3)勿在 Job 里再「手动添加参数化构建」与脚本重复。
+ */
+
+/**
+ * 端口约定:gateway 固定 8000;其余生产 5xxxx(与预生产 3xxxx 对应)。
+ * 列表数据放在 getServiceDefinitions() 内部,避免 Jenkins CPS 序列化时顶层变量 SERVICE_REGISTRY 不可用。
+ */
+
+def multiParamName(String prodDir) {
+    return 'MULTI_' + prodDir.replace('-', '_')
+}
+
+def expandServiceSpec(Map raw) {
+    def pd = raw.prodDir
+    def m = "alien-${pd}"
+    return raw + [
+            module: m,
+            uatDir: "${pd}-uat",
+            container: "${pd}-produ",
+            bootstrapRel: "${m}/src/main/resources/bootstrap-prod.yml",
+    ]
+}
+
+def getServiceDefinitions() {
+    def registry = [
+            [prodDir: 'gateway',         serverPort: '8000',  hostPort: '8000'],
+            [prodDir: 'job',             serverPort: '50008', hostPort: '50008'],
+            [prodDir: 'lawyer',          serverPort: '50007', hostPort: '50007'],
+            [prodDir: 'second',          serverPort: '50005', hostPort: '50005'],
+            [prodDir: 'store',           serverPort: '50004', hostPort: '50004'],
+            [prodDir: 'store-platform',  serverPort: '50006', hostPort: '50006'],
+            [prodDir: 'dining',          serverPort: '50009', hostPort: '50009'],
+    ]
+    return registry.collect { expandServiceSpec(it) }
+}
+
+/** Jenkins boolean 参数有时为 Boolean,有时为字符串 */
+def isMultiCheckboxChecked(String paramName) {
+    def v = params[paramName]
+    if (v instanceof Boolean) {
+        return v
+    }
+    return 'true'.equalsIgnoreCase(v?.toString())
+}
+
+def filterServices(List services) {
+    def selected = []
+    def mode = params.DEPLOY_MODE
+
+    if (mode == 'whole') {
+        selected = services
+    } else if (mode == 'single') {
+        def one = services.find { it.prodDir == params.SINGLE_SERVICE }
+        if (one == null) {
+            error("单服务发版:未找到 SINGLE_SERVICE=${params.SINGLE_SERVICE}")
+        }
+        selected = [one]
+    } else if (mode == 'multi') {
+        services.each { s ->
+            if (isMultiCheckboxChecked(multiParamName(s.prodDir))) {
+                selected << s
+            }
+        }
+        if (selected.isEmpty()) {
+            error('multi 模式:请至少勾选一项「多选服务」复选框(MULTI_*)')
+        }
+    } else {
+        error("未知 DEPLOY_MODE: ${mode}")
+    }
+
+    if (selected == null || selected.isEmpty()) {
+        error('未选中任何服务,请检查发版参数')
+    }
+    return selected
+}
+
+pipeline {
+    agent any
+
+    // 全局选项:控制日志保留、时间戳、整次构建超时(避免卡死占满 executor)
+    options {
+        buildDiscarder(logRotator(numToKeepStr: '5', artifactNumToKeepStr: '5'))
+        timestamps()
+        timeout(time: 45, unit: 'MINUTES')
+    }
+
+    // 构建参数:发版范围、bootstrap 来源、是否演练、Docker 创建策略等(修改后通常需保存 Job 再构建)
+    parameters {
+        choice(
+                name: 'DEPLOY_MODE',
+                choices: ['whole', 'single', 'multi'],
+                description: 'whole=整体发版;single=单服务;multi=勾选下方 MULTI_* 复选框'
+        )
+        // choices 必须静态:勿用 getServiceDefinitions().collect,否则部分 Jenkins 会丢弃其后所有参数
+        choice(
+                name: 'SINGLE_SERVICE',
+                choices: ['gateway', 'job', 'lawyer', 'second', 'store', 'store-platform', 'dining'],
+                description: '仅当 DEPLOY_MODE=single 时使用(须与 getServiceDefinitions() 内 registry 的 prodDir 一致)'
+        )
+        // 须逐项声明:Jenkins Declarative 不支持在 parameters 里用 each 生成 booleanParam,否则整段 MULTI_* 不会出现在界面
+        booleanParam(name: 'MULTI_gateway',         defaultValue: false, description: '[multi] gateway')
+        booleanParam(name: 'MULTI_job',               defaultValue: false, description: '[multi] job')
+        booleanParam(name: 'MULTI_lawyer',            defaultValue: false, description: '[multi] lawyer')
+        booleanParam(name: 'MULTI_second',            defaultValue: false, description: '[multi] second')
+        booleanParam(name: 'MULTI_store',             defaultValue: false, description: '[multi] store')
+        booleanParam(name: 'MULTI_store_platform',    defaultValue: false, description: '[multi] store-platform')
+        booleanParam(name: 'MULTI_dining',            defaultValue: false, description: '[multi] dining')
+        choice(
+                name: 'BOOTSTRAP_SYNC_MODE',
+                choices: ['from_jar', 'from_git', 'none'],
+                description: 'bootstrap-prod.yml:from_jar=从 UAT jar 解压(与晋升制品一致,默认);from_git=从 Git 拷贝;none=不同步'
+        )
+        booleanParam(
+                name: 'DRY_RUN',
+                defaultValue: false,
+                description: '为 true 时仅打印将要执行的命令,不拷贝文件、不重启容器'
+        )
+        booleanParam(
+                name: 'AUTO_CREATE_CONTAINER_IF_MISSING',
+                defaultValue: true,
+                description: '无 *-produ 且无短名容器时,是否自动 docker run 创建 *-produ(需本机端口未被占用)'
+        )
+        booleanParam(
+                name: 'AUTO_RECREATE_IF_MOUNT_MISMATCH',
+                defaultValue: true,
+                description: '若已存在容器的 /app/app.jar 挂载源与本次 jar 不一致、缺支付证书卷、缺 JASYPT_ENCRYPTOR_PASSWORD(流水线将注入但旧容器未带),或 jar 目录/类型不兼容,是否先 docker rm 再 docker run(否则仅报错;restart 无法补 env/卷)'
+        )
+        booleanParam(
+                name: 'REMOVE_STALE_JAR_DIR_IF_DIRECTORY',
+                defaultValue: true,
+                description: '若 PROD 目标路径 alien-xxx-1.0.0.jar 已存在但是「目录」(Docker 误建或误操作),是否自动 rm -rf 后再 cp(默认开启;若目录内有误放文件请先人工处理)'
+        )
+        string(
+                name: 'PROD_JRE_IMAGE',
+                defaultValue: 'my-openjdk8-ffmpeg:v1',
+                trim: true,
+                description: '自动创建/探测时使用的 Java 镜像(默认 my-openjdk8-ffmpeg:v1)。本地已有该镜像时流水线不会 docker pull;仅当 docker image 中不存在时才尝试 pull。仓库镜像填全名即可'
+        )
+        string(
+                name: 'PROD_DOCKER_NETWORK',
+                defaultValue: '',
+                trim: true,
+                description: '可选:docker network 名称;非空则创建容器时加 --network(与现网其它容器互通时再填)'
+        )
+        string(
+                name: 'UAT_DEPLOY_ROOT',
+                defaultValue: '/app_deploy_uat',
+                trim: true,
+                description: 'Jenkins 进程内可见的预生产制品根目录(无末尾斜杠)。宿主机为 /alien_uat/java;Jenkins 在容器内且 volume 挂载为 宿主机/alien_uat/java→容器/app_deploy_uat 时用默认 /app_deploy_uat。Jenkins 直跑宿主机时填 /alien_uat/java'
+        )
+        string(
+                name: 'PROD_DEPLOY_ROOT',
+                defaultValue: '/alien_produ/java',
+                trim: true,
+                description: 'Jenkins 进程内可见的生产制品根目录(无末尾斜杠)。现网 compose 若挂 ../java:/app_deploy,则填 /app_deploy;若生产目录在宿主机 /alien_produ/java 且已挂载到容器同路径,则填 /alien_produ/java'
+        )
+        string(
+                name: 'PROD_PAY_CERT_HOST',
+                defaultValue: '',
+                trim: true,
+                description: '可选:全局覆盖——非空时四服务的证书卷均挂此「宿主机」目录到容器 /usr/local/alien/aliPayCert。须为该目录本身(其下直接含 apiclient_key.pem 等),不要填 .../alien 除非证书就在 alien 根下。留空则自动用 PROD_DEPLOY_ROOT/<prodDir>/alien/aliPayCert 或 alien 根下 pem。仅 docker run/重建生效;路径须与运行 dockerd 的机器一致'
+        )
+        string(
+                name: 'PROD_CONTAINER_TZ',
+                defaultValue: 'Asia/Shanghai',
+                trim: true,
+                description: '自动 docker run 时增加 -e TZ=…(日志为北京时间)。留空则不设置(多为 UTC,与北京时间差 8 小时)'
+        )
+        string(
+                name: 'PROD_DOCKER_ENV_FILE',
+                defaultValue: '/alien_produ/java/.env.produ-docker',
+                trim: true,
+                description: '宿主机 docker --env-file:须 UTF-8 编码(勿 GBK/记事本默认),须含 JASYPT_ENCRYPTOR_PASSWORD=。默认 <PROD_DEPLOY_ROOT>/.env.produ-docker;从 docs/jenkins/env.produ-docker.example 复制后 chmod 600。改 PROD_DEPLOY_ROOT 须同步改路径或留空。文件存在时忽略下方 JASYPT_ENCRYPTOR_PASSWORD 参数'
+        )
+        string(
+                name: 'JASYPT_ENCRYPTOR_PASSWORD',
+                defaultValue: '',
+                trim: true,
+                description: '可选:与 PROD_DOCKER_ENV_FILE 二选一(文件优先)。须为 Pipeline 注册的「字符串」参数(Build with Parameters 里当普通文本填);非空则写入临时 env 文件并 docker run --env-file。勿在 Job→配置 里再手动添加同名 Password 类型参数'
+        )
+        string(
+                name: 'PROD_JASYPT_PASSWORD_FILE',
+                defaultValue: '',
+                trim: true,
+                description: '可选:宿主机上仅含 Jasypt 主密码的文件路径(单行、chmod 600)。留空则使用 <PROD_DEPLOY_ROOT>/.jasypt-encryptor-password。流水线将该文件内容写入临时 --env-file 注入 JASYPT_ENCRYPTOR_PASSWORD(兼容旧 jar)。可与 PROD_DOCKER_ENV_FILE / JASYPT_ENCRYPTOR_PASSWORD 并存;docker 后传入的 --env-file 优先生效'
+        )
+    }
+
+    // 见文件头「environment 块作用」。GIT_* 固定在此;UAT_/PROD_DEPLOY_ROOT 取参数,便于在 Job 里改路径而不改脚本。
+    environment {
+        GIT_URL = 'http://8.152.195.41:3000/alien/alien_cloud'
+        GIT_BRANCH = 'uat-20260202'
+        GIT_CREDENTIALS = 'zhanghaomimapingzheng'
+
+        UAT_DEPLOY_ROOT = "${params.UAT_DEPLOY_ROOT ?: '/app_deploy_uat'}"
+        PROD_DEPLOY_ROOT = "${params.PROD_DEPLOY_ROOT ?: '/alien_produ/java'}"
+    }
+
+    stages {
+
+        // 阶段:发版前摘要。不读写文件、不连 Docker,仅打印本次模式、涉及服务、bootstrap 策略,便于核对参数。
+        stage('Announce deploy plan') {
+            steps {
+                script {
+                    def services = getServiceDefinitions()
+                    def selected = filterServices(services)
+                    def names = selected.collect { it.prodDir }.join(', ')
+                    echo ">>> 发版模式: ${params.DEPLOY_MODE}"
+                    if (params.DEPLOY_MODE == 'multi') {
+                        def checked = services.findAll { isMultiCheckboxChecked(multiParamName(it.prodDir)) }.collect { it.prodDir }.join(', ')
+                        echo ">>> multi 已勾选: ${checked}"
+                    }
+                    echo ">>> 本次将处理 ${selected.size()} 个服务: ${names}"
+                    echo ">>> bootstrap 同步模式: ${params.BOOTSTRAP_SYNC_MODE}"
+                    echo ">>> UAT_DEPLOY_ROOT=${env.UAT_DEPLOY_ROOT}(Jenkins 进程内路径;宿主机 UAT 常为 /alien_uat/java 映射至容器 /app_deploy_uat)"
+                    echo ">>> PROD_DEPLOY_ROOT=${env.PROD_DEPLOY_ROOT}"
+                    echo ">>> 若 PROD_DEPLOY_ROOT 为 /alien_produ/java:Jenkins compose 须挂载 - /alien_produ/java:/alien_produ/java,否则晋升写入与 *-produ bind 不是同一文件"
+                    def pch = (params.PROD_PAY_CERT_HOST ?: '').trim()
+                    if (pch) {
+                        echo ">>> PROD_PAY_CERT_HOST=${pch}(证书卷全局覆盖:store / store-platform / lawyer / dining -> /usr/local/alien/aliPayCert)"
+                    } else {
+                        echo ">>> 证书卷按服务自动挂载(若目录存在非空):${env.PROD_DEPLOY_ROOT}/各 prodDir/alien/aliPayCert,例 ${env.PROD_DEPLOY_ROOT}/store/alien/aliPayCert"
+                    }
+                    def pe = (params.PROD_DOCKER_ENV_FILE ?: '').trim()
+                    def jpf = (params.PROD_JASYPT_PASSWORD_FILE ?: '').trim()
+                    def jpwRaw = params.JASYPT_ENCRYPTOR_PASSWORD
+                    def jpwAPlain = (jpwRaw != null && !(jpwRaw instanceof hudson.util.Secret)) ? jpwRaw.toString().trim() : ''
+                    def jasyptDefaultAnnounce = "${env.PROD_DEPLOY_ROOT}/.jasypt-encryptor-password"
+                    if (pe) {
+                        echo ">>> PROD_DOCKER_ENV_FILE=${pe}(docker run 将尝试 --env-file)"
+                    }
+                    if (jpwAPlain.length() > 0) {
+                        echo ">>> 已填写 JASYPT_ENCRYPTOR_PASSWORD(将经临时 env 文件注入)"
+                    }
+                    if (jpf) {
+                        echo ">>> PROD_JASYPT_PASSWORD_FILE=${jpf}(将从此文件生成 Jasypt 注入)"
+                    } else {
+                        echo ">>> Jasypt 密钥文件(参数未填时使用默认): ${jasyptDefaultAnnounce}"
+                    }
+                }
+            }
+        }
+
+        // 非 DRY_RUN:用 docker run 校验「Jenkins 写入的 PROD 根目录」与「Docker 守护进程看到的宿主机路径」为同一挂载(缺 volume 时晋升 unzip 通过仍 corrupt jarfile)
+        stage('Verify PROD mount vs Docker host') {
+            when {
+                expression { return !params.DRY_RUN }
+            }
+            steps {
+                script {
+                    def prodImg = params.PROD_JRE_IMAGE ?: 'my-openjdk8-ffmpeg:v1'
+                    sh """
+                        set -e
+                        PROD="${env.PROD_DEPLOY_ROOT}"
+                        IMG="${prodImg}"
+                        PFILE=".jenkins_jar_promote_mount_probe"
+                        rm -f "\$PROD/\$PFILE" 2>/dev/null || true
+                        printf ok > "\$PROD/\$PFILE"
+                        OUT=\$(docker run --rm --entrypoint cat -v "\$PROD:/__prod:ro" "\$IMG" "/__prod/\$PFILE" 2>/dev/null || true)
+                        if [ "\$OUT" != "ok" ]; then
+                            echo "ERROR: Jenkins 在 \\\$PROD 下的写入,无法从「Docker 以宿主机路径 -v \\\$PROD」挂载的容器中读到。"
+                            echo "原因:Jenkins 容器内 \\\$PROD 未绑定到宿主机同路径,cp 写在容器私有文件系统,*-produ 仍读宿主机旧/空文件。"
+                            echo "处理:在 Jenkins docker-compose 增加 volume,例如:- /alien_produ/java:/alien_produ/java(与现网生产目录一致),然后 recreate Jenkins。"
+                            rm -f "\$PROD/\$PFILE" 2>/dev/null || true
+                            exit 1
+                        fi
+                        rm -f "\$PROD/\$PFILE"
+                        echo ">>> PROD 与 Docker 宿主机挂载校验通过: \$PROD"
+                    """
+                }
+            }
+        }
+
+        // 阶段:仅当需要从 Git 取 bootstrap 时执行。拉取 GIT_BRANCH 与 UAT 发版分支一致,保证 yml 与仓库一致。
+        // 若 BOOTSTRAP_SYNC_MODE 为 from_jar 或 none,本阶段跳过(不拉代码)。
+        stage('Checkout for bootstrap-prod.yml') {
+            when {
+                expression { return params.BOOTSTRAP_SYNC_MODE == 'from_git' }
+            }
+            steps {
+                echo '>>> 拉取代码(仅用于同步 bootstrap-prod.yml)'
+                git branch: "${env.GIT_BRANCH}",
+                        credentialsId: "${env.GIT_CREDENTIALS}",
+                        url: "${env.GIT_URL}"
+                sh """
+                    set -e
+                    git fetch origin
+                    git reset --hard origin/${env.GIT_BRANCH}
+                    echo '>>> 当前构建使用的提交:'
+                    git log -1 --oneline
+                    git rev-parse HEAD
+                """
+            }
+        }
+
+        // 阶段:把 bootstrap-prod.yml 落到生产目录 .../config/,便于审计或后续外置挂载。
+        // from_jar:从 UAT jar 解压;支持 Spring Boot 可执行包(BOOT-INF/classes/)与瘦 jar(根目录,如 alien-job 的 maven-jar-plugin 产物)。
+        // from_git:从工作区拷贝(依赖上一阶段 Checkout)。
+        // none 时整阶段跳过。DRY_RUN 时只打印不写入。
+        stage('Sync bootstrap-prod.yml') {
+            when {
+                expression { return params.BOOTSTRAP_SYNC_MODE == 'from_git' || params.BOOTSTRAP_SYNC_MODE == 'from_jar' }
+            }
+            steps {
+                script {
+                    def services = getServiceDefinitions()
+                    def selected = filterServices(services)
+                    def mode = params.BOOTSTRAP_SYNC_MODE
+
+                    for (def s in selected) {
+                        def jar = "${s.module}-1.0.0.jar"
+                        def srcJar = "${env.UAT_DEPLOY_ROOT}/${s.uatDir}/${jar}"
+                        def dstDir = "${env.PROD_DEPLOY_ROOT}/${s.prodDir}/config"
+                        def dstFile = "${dstDir}/bootstrap-prod.yml"
+                        def srcGit = "${WORKSPACE}/${s.bootstrapRel}"
+
+                        if (mode == 'from_jar') {
+                            sh """
+                                set -e
+                                echo ">>> [bootstrap from jar] ${s.module}"
+                                if [ ! -f "${srcJar}" ]; then
+                                    echo "ERROR: 当前 Jenkins 进程内找不到 UAT jar: ${srcJar}"
+                                    echo "说明:宿主机上文件存在、权限 644 时仍可失败——多为 Jenkins 容器内路径未挂载到 /alien_uat/java;请用 Jenkins「能看到的」根目录。"
+                                    echo "处理:Job 参数 UAT_DEPLOY_ROOT 设为 /app_deploy_uat(与 docker-compose 宿主机 /alien_uat/java → 容器 /app_deploy_uat 一致),或修正 Jenkins 的 volume。"
+                                    echo "诊断 UAT_DEPLOY_ROOT=${env.UAT_DEPLOY_ROOT}:"
+                                    ls -la "${env.UAT_DEPLOY_ROOT}/${s.uatDir}" 2>&1 || ls -la "${env.UAT_DEPLOY_ROOT}" 2>&1 || true
+                                    exit 1
+                                fi
+                                if [ "${params.DRY_RUN}" = "true" ]; then
+                                    echo "[DRY_RUN] 尝试 BOOT-INF/classes/ 或瘦 jar 根目录 bootstrap-prod.yml -> ${dstFile}"
+                                else
+                                    mkdir -p "${dstDir}"
+                                    if unzip -l "${srcJar}" | grep -q 'BOOT-INF/classes/bootstrap-prod.yml'; then
+                                        unzip -p "${srcJar}" BOOT-INF/classes/bootstrap-prod.yml > "${dstFile}"
+                                        echo ">>> 已从 Spring Boot 可执行 jar 解压: ${dstFile}"
+                                    elif unzip -l "${srcJar}" | grep 'bootstrap-prod.yml' | grep -qv 'BOOT-INF'; then
+                                        unzip -p "${srcJar}" bootstrap-prod.yml > "${dstFile}"
+                                        echo ">>> 已从瘦 jar 根目录解压: ${dstFile}"
+                                    else
+                                        echo "ERROR: jar 内未找到 bootstrap-prod.yml(既无 BOOT-INF/classes/ 也无根目录,请确认已打包进 ${s.module})"
+                                        exit 1
+                                    fi
+                                fi
+                            """
+                        } else {
+                            sh """
+                                set -e
+                                echo ">>> [bootstrap from git] ${s.module}"
+                                if [ ! -f "${srcGit}" ]; then
+                                    echo "ERROR: 缺少文件: ${srcGit}"
+                                    exit 1
+                                fi
+                                if [ "${params.DRY_RUN}" = "true" ]; then
+                                    echo "[DRY_RUN] mkdir -p ${dstDir}"
+                                    echo "[DRY_RUN] cp -f ${srcGit} ${dstFile}"
+                                else
+                                    mkdir -p "${dstDir}"
+                                    cp -f "${srcGit}" "${dstFile}"
+                                    echo ">>> 已从 Git 拷贝: ${dstFile}"
+                                fi
+                            """
+                        }
+                    }
+                }
+            }
+        }
+
+        // 阶段:核心晋升——从 UAT 目录拷贝 jar 与 lib 到 PROD_DEPLOY_ROOT/<prodDir>/(默认 /alien_produ/java/<prodDir>/),再处理容器。证书由宿主机 cp 维护,流水线不拷贝。
+        // 顺序:先尝试重启 *-produ → 再尝试短名容器 → 可选自动 docker run(端口见 getServiceDefinitions() 内 registry)。
+        // 需 Jenkins 能访问宿主机 Docker(通常挂载 /var/run/docker.sock);生产 compose 中 volume 需与 PROD_DEPLOY_ROOT 一致。
+        stage('Promote JAR from UAT') {
+            steps {
+                script {
+                    def services = getServiceDefinitions()
+                    def selected = filterServices(services)
+
+                    def hostEnvFile = (params.PROD_DOCKER_ENV_FILE ?: '').trim()
+                    def jpwRaw2 = params.JASYPT_ENCRYPTOR_PASSWORD
+                    def jpwStr = (jpwRaw2 != null && !(jpwRaw2 instanceof hudson.util.Secret)) ? jpwRaw2.toString().trim() : ''
+                    def writtenJasyptEnv = ''
+                    def effDockerEnvFile = hostEnvFile
+                    if (!effDockerEnvFile && jpwStr) {
+                        writtenJasyptEnv = "${env.WORKSPACE}/.jenkins_jasypt_env_${System.currentTimeMillis()}"
+                        writeFile file: writtenJasyptEnv, text: "JASYPT_ENCRYPTOR_PASSWORD=${jpwStr}\n", encoding: 'UTF-8'
+                        effDockerEnvFile = writtenJasyptEnv
+                    }
+                    def jpfTrim = (params.PROD_JASYPT_PASSWORD_FILE ?: '').trim()
+                    def jasyptDefaultPath = "${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) {
+                        sh """
+                            set +e
+                            OK=0
+                            if [ -n '${effDockerEnvFile}' ] && [ -f '${effDockerEnvFile}' ]; then OK=1; fi
+                            if [ '${hasJpwPlain}' = '1' ]; then OK=1; fi
+                            JHF='${jasyptHostFile}'
+                            if [ -f "\$JHF" ]; then OK=1; fi
+                            if [ "\$OK" -ne 1 ]; then
+                              JH_DIR=\$(dirname "\$JHF")
+                              JH_BASE=\$(basename "\$JHF")
+                              if [ -n "\$JH_DIR" ] && [ -n "\$JH_BASE" ]; then
+                                if docker run --rm --entrypoint sh -v "\$JH_DIR:/__jd:ro" -e JB="\$JH_BASE" '${prodJreForJasypt}' -c 'test -f "/__jd/\$JB"' 2>/dev/null; then
+                                  OK=1
+                                  echo ">>> Jasypt: Jenkins 进程内未直接看到密钥文件,但 Docker 宿主机路径可访问: \$JHF(与 Verify PROD mount 同源)"
+                                fi
+                              fi
+                            fi
+                            if [ -n '${effDockerEnvFile}' ] && [ -f '${effDockerEnvFile}' ] && command -v iconv >/dev/null 2>&1; then
+                              if ! iconv -f UTF-8 -t UTF-8 < '${effDockerEnvFile}' >/dev/null 2>&1; then
+                                echo "ERROR: PROD_DOCKER_ENV_FILE 不是合法 UTF-8,Docker --env-file 会拒绝(常见原因:Windows 记事本或 GBK 保存)。"
+                                echo "处理:用 UTF-8 重存该文件,或 Linux: iconv -f GB18030 -t UTF-8 '${effDockerEnvFile}' > /tmp/e.utf8 && mv /tmp/e.utf8 '${effDockerEnvFile}'"
+                                echo "勿在 env 文件中使用非 UTF-8 中文注释;可用仓库 docs/jenkins/env.produ-docker.example(全 ASCII 注释)。"
+                                exit 1
+                              fi
+                            fi
+                            if [ "\$OK" -ne 1 ]; then
+                                echo "ERROR: 未检测到任何可用的 Jasypt 主密码来源,依赖解密的微服务将无法启动。"
+                                echo "请任选其一:"
+                                echo "  1) 从仓库 docs/jenkins/env.produ-docker.example 复制为 ${env.PROD_DEPLOY_ROOT}/.env.produ-docker(UTF-8)并 chmod 600"
+                                echo "  2) 或创建 ${jasyptDefaultPath}(单行密码)"
+                                echo "  3) 或参数 PROD_JASYPT_PASSWORD_FILE 指向其它密钥文件"
+                                echo "  4) 或构建参数 JASYPT_ENCRYPTOR_PASSWORD(字符串);或 Job 中将 PROD_DOCKER_ENV_FILE 留空后仅用 2/3/4"
+                                exit 1
+                            fi
+                            echo ">>> Jasypt 来源校验通过(env-file / 构建密码 / 密钥文件至少其一有效)"
+                            exit 0
+                        """
+                    }
+
+                    try {
+                        for (def s in selected) {
+                            def jar = "${s.module}-1.0.0.jar"
+                            def srcJar = "${env.UAT_DEPLOY_ROOT}/${s.uatDir}/${jar}"
+                            def dstDir = "${env.PROD_DEPLOY_ROOT}/${s.prodDir}"
+                            def logHostDir = "${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 jarHostPath = "${dstDir}/${jar}"
+                            def hostPort = s.hostPort
+                            def serverPort = s.serverPort
+                            def jreImage = params.PROD_JRE_IMAGE ?: 'my-openjdk8-ffmpeg:v1'
+                            def dockerNet = (params.PROD_DOCKER_NETWORK ?: '').trim()
+                            def netExtra = dockerNet ? "--network ${dockerNet} " : ''
+                            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 dstPayCertDir = "${dstDir}/alien/aliPayCert"
+                            def prodContainerTz = (params.PROD_CONTAINER_TZ ?: '').trim()
+                            def dockerTzEnv = prodContainerTz ? " -e TZ=${prodContainerTz}" : ''
+
+                            sh """
+                            set -e
+                            echo ">>> [jar] ${s.module}: ${s.uatDir} -> ${s.prodDir}"
+
+                            if [ ! -f "${srcJar}" ]; then
+                                echo "ERROR: 当前 Jenkins 进程内找不到 UAT 制品: ${srcJar}"
+                                echo "请先在 UAT 流水线成功部署该模块;若宿主机已有 jar,请检查 UAT_DEPLOY_ROOT(容器内常用 /app_deploy_uat,见 Sync bootstrap 阶段错误说明)。"
+                                echo "诊断 UAT_DEPLOY_ROOT=${env.UAT_DEPLOY_ROOT}:"
+                                ls -la "${env.UAT_DEPLOY_ROOT}/${s.uatDir}" 2>&1 || ls -la "${env.UAT_DEPLOY_ROOT}" 2>&1 || true
+                                exit 1
+                            fi
+
+                            if [ "${params.DRY_RUN}" = "true" ]; then
+                                echo "[DRY_RUN] mkdir -p ${dstDir}"
+                                echo "[DRY_RUN] mkdir -p ${logHostDir}(日志目录,与 compose ./logs/${s.prodDir}:/app/logs 一致)"
+                                if [ -d "${jarHostPath}" ]; then
+                                    echo "[DRY_RUN] 检测到 ${jarHostPath} 为目录(非 jar),将按 REMOVE_STALE_JAR_DIR_IF_DIRECTORY rm -rf 或人工删除"
+                                fi
+                                echo "[DRY_RUN] cp -f ${srcJar} ${dstDir}/"
+                                if [ -d "${srcLib}" ]; then
+                                    echo "[DRY_RUN] 同步 lib: ${srcLib} -> ${dstLib}"
+                                fi
+                                echo "[DRY_RUN] docker restart ${cPrimary} / ${cFallback},或 docker run 创建 ${cPrimary}(端口 ${hostPort}:${serverPort})"
+                                if [ "${payCertSvc}" = "1" ]; then
+                                    echo "[DRY_RUN] 证书卷:优先 PROD_PAY_CERT_HOST=${payCertHost ? payCertHost : '(未填)'},否则若目录存在非空则 ${dstPayCertDir} -> /usr/local/alien/aliPayCert:ro"
+                                fi
+                                if [ -n "${effDockerEnvFile}" ]; then
+                                    echo "[DRY_RUN] docker run 将尝试 --env-file ${effDockerEnvFile}(须存在且含 JASYPT_ENCRYPTOR_PASSWORD 等)"
+                                fi
+                                JHF_D='${jasyptHostFile}'
+                                if [ -f "\$JHF_D" ]; then
+                                    echo "[DRY_RUN] 将从密钥文件生成 Jasypt --env-file: ${jasyptHostFile}"
+                                elif command -v docker >/dev/null 2>&1; then
+                                    _JD=\$(dirname "\$JHF_D")
+                                    _JB=\$(basename "\$JHF_D")
+                                    if docker run --rm --entrypoint sh -v "\$_JD:/__jd:ro" -e JB="\$_JB" "${jreImage}" -c 'test -f "/__jd/\$JB"' 2>/dev/null; then
+                                        echo "[DRY_RUN] 密钥文件仅 Docker 宿主机可见,晋升时将经 docker 读取: ${jasyptHostFile}"
+                                    fi
+                                fi
+                            else
+                                JAR_WAS_STALE_DIR=0
+                                if [ -d "${jarHostPath}" ]; then
+                                    if [ "${removeStaleJarDir}" = "true" ]; then
+                                        echo ">>> WARN: ${jarHostPath} 为目录而非 jar(常见于 docker -v 时宿主机尚无该文件被误建目录),正在删除: rm -rf"
+                                        rm -rf "${jarHostPath}"
+                                        JAR_WAS_STALE_DIR=1
+                                    else
+                                        echo "ERROR: ${jarHostPath} 是目录而非 jar 文件,无法覆盖拷贝。"
+                                        echo "请宿主机执行: sudo rm -rf ${jarHostPath} 后重跑;或开启 REMOVE_STALE_JAR_DIR_IF_DIRECTORY=true"
+                                        exit 1
+                                    fi
+                                fi
+                                mkdir -p "${dstDir}"
+                                mkdir -p "${logHostDir}"
+                                cp -f "${srcJar}" "${dstDir}/"
+                                if [ -d "${srcLib}" ]; then
+                                    rm -rf "${dstLib}"
+                                    cp -rf "${srcLib}" "${dstLib}"
+                                fi
+                                if [ -d "${jarHostPath}" ]; then
+                                    echo "ERROR: ${jarHostPath} 是目录而非 jar,请删除该目录后重跑流水线"
+                                    exit 1
+                                fi
+                                if [ ! -s "${jarHostPath}" ]; then
+                                    echo "ERROR: jar 为空或不存在: ${jarHostPath}"
+                                    exit 1
+                                fi
+                                if ! unzip -t "${jarHostPath}" >/dev/null 2>&1; then
+                                    echo "ERROR: jar 不是有效 zip/jar(可能拷贝不完整或源文件损坏): ${jarHostPath}"
+                                    exit 1
+                                fi
+                                ENV_FILE_OPT=""
+                                if [ -n "${effDockerEnvFile}" ] && [ -f "${effDockerEnvFile}" ]; then
+                                    ENV_FILE_OPT="--env-file ${effDockerEnvFile}"
+                                elif [ -n "${effDockerEnvFile}" ]; then
+                                    echo ">>> WARN: docker --env-file 路径不可读或不存在: ${effDockerEnvFile}(Jasypt 等可能无法注入;检查 Jenkins/宿主机挂载或改用构建密码参数)"
+                                fi
+                                EXTRA_JASYPT_ENV_OPT=""
+                                JHF='${jasyptHostFile}'
+                                JH_DIR=\$(dirname "\$JHF")
+                                JH_BASE=\$(basename "\$JHF")
+                                JEP="/tmp/jenkins_jasypt_pwenv_\$\$"
+                                if [ -f "\$JHF" ]; then
+                                    umask 077
+                                    { printf 'JASYPT_ENCRYPTOR_PASSWORD='; head -n 1 "\$JHF" | tr -d '\r'; echo; } > "\$JEP" || { echo "ERROR: 无法读取 Jasypt 密钥文件: \$JHF"; exit 1; }
+                                    if ! grep -qE '^JASYPT_ENCRYPTOR_PASSWORD=.' "\$JEP" 2>/dev/null; then
+                                        echo "ERROR: Jasypt 密钥文件为空或仅空白: \$JHF"
+                                        rm -f "\$JEP"
+                                        exit 1
+                                    fi
+                                    EXTRA_JASYPT_ENV_OPT="--env-file \$JEP"
+                                    trap 'rm -f "\$JEP" 2>/dev/null || true' EXIT
+                                elif docker run --rm --entrypoint sh -v "\$JH_DIR:/__jd:ro" -e JB="\$JH_BASE" "${jreImage}" -c 'test -f "/__jd/\$JB"' 2>/dev/null; then
+                                    umask 077
+                                    PW=\$(docker run --rm --entrypoint sh -v "\$JH_DIR:/__jd:ro" -e JB="\$JH_BASE" "${jreImage}" -c 'head -n 1 "/__jd/\$JB" 2>/dev/null | tr -d "\\r"') || PW=
+                                    if [ -z "\$PW" ]; then
+                                        echo "ERROR: Jasypt 密钥文件在 Docker 视角下为空: \$JHF"
+                                        exit 1
+                                    fi
+                                    printf 'JASYPT_ENCRYPTOR_PASSWORD=%s\\n' "\$PW" > "\$JEP" || { echo "ERROR: 无法写入临时 Jasypt env"; exit 1; }
+                                    if ! grep -qE '^JASYPT_ENCRYPTOR_PASSWORD=.' "\$JEP" 2>/dev/null; then
+                                        echo "ERROR: Jasypt 临时 env 无效: \$JHF"
+                                        rm -f "\$JEP"
+                                        exit 1
+                                    fi
+                                    echo ">>> Jasypt: 已通过 Docker 从宿主机读取密钥文件(Jenkins 进程未直接打开该路径)"
+                                    EXTRA_JASYPT_ENV_OPT="--env-file \$JEP"
+                                    trap 'rm -f "\$JEP" 2>/dev/null || true' EXIT
+                                elif [ '${jasyptExplicit}' = '1' ]; then
+                                    echo ">>> WARN: 显式 PROD_JASYPT_PASSWORD_FILE 不可读: ${jasyptHostFile}(已依赖其它来源通过晋升前校验)"
+                                fi
+                                PAY_MOUNT=""
+                                if [ "${payCertSvc}" = "1" ]; then
+                                    if [ -n "${payCertHost}" ]; then
+                                        PAY_MOUNT="${payCertHost}"
+                                    elif [ -d "${dstPayCertDir}" ] && [ -n "\$(ls -A \"${dstPayCertDir}\" 2>/dev/null)" ]; then
+                                        PAY_MOUNT="${dstPayCertDir}"
+                                    elif [ -f "${dstDir}/alien/apiclient_key.pem" ]; then
+                                        PAY_MOUNT="${dstDir}/alien"
+                                        echo ">>> WARN: 证书在 ${dstDir}/alien/ 根目录(无 alien/aliPayCert 子目录),将该目录挂为容器内 /usr/local/alien/aliPayCert"
+                                    fi
+                                fi
+                                CERT_VOL=0
+                                [ -n "\$PAY_MOUNT" ] && CERT_VOL=1
+                                if [ "\$CERT_VOL" -eq 1 ]; then
+                                    if [ ! -d "\$PAY_MOUNT" ]; then
+                                        echo "ERROR: 支付证书挂载源不存在或非目录: \$PAY_MOUNT"
+                                        exit 1
+                                    fi
+                                    if [ ! -f "\$PAY_MOUNT/apiclient_key.pem" ] && [ -f "\$PAY_MOUNT/aliPayCert/apiclient_key.pem" ]; then
+                                        echo ">>> WARN: apiclient_key.pem 在子目录 aliPayCert 下,将挂载源改为 \$PAY_MOUNT/aliPayCert(应用路径为 /usr/local/alien/aliPayCert/apiclient_key.pem,勿把 alien 整目录挂到 .../aliPayCert)"
+                                        PAY_MOUNT="\$PAY_MOUNT/aliPayCert"
+                                    fi
+                                    if [ ! -f "\$PAY_MOUNT/apiclient_key.pem" ]; then
+                                        echo ">>> WARN: \$PAY_MOUNT/apiclient_key.pem 不存在,微信支付等可能仍启动失败"
+                                    fi
+                                    echo ">>> 支付证书将挂载: \$PAY_MOUNT -> /usr/local/alien/aliPayCert:ro"
+                                fi
+                                if [ "${payCertSvc}" = "1" ] && [ "\$CERT_VOL" -eq 1 ] && [ "${s.prodDir}" = "store" ]; then
+                                    if ! docker image inspect "${jreImage}" >/dev/null 2>&1; then
+                                        docker pull "${jreImage}" >/dev/null 2>&1 || true
+                                    fi
+                                    if ! docker run --rm --entrypoint sh -v "\$PAY_MOUNT:/__c:ro" "${jreImage}" -c 'test -f /__c/apiclient_key.pem' 2>/dev/null; then
+                                        echo "ERROR: store 微信支付:Docker 宿主机视角下在挂载源内找不到 apiclient_key.pem(当前 PAY_MOUNT=\$PAY_MOUNT)。请确认:1)宿主机上该路径确有文件;2)PROD_DEPLOY_ROOT 是 dockerd 所在机器上的绝对路径(勿用仅在 Jenkins 容器内存在、宿主机无此路径的目录);3)手工 docker run 时 -v 的左侧应为 .../<prodDir>/alien/aliPayCert 而非 .../alien。"
+                                        exit 1
+                                    fi
+                                    echo ">>> store 证书目录已通过 Docker 挂载探测(apiclient_key.pem 对 daemon 可见)"
+                                fi
+                                if [ "${payCertSvc}" = "1" ] && [ "\$CERT_VOL" -eq 0 ]; then
+                                    echo ">>> WARN: ${s.prodDir} 常需证书:未配置 PROD_PAY_CERT_HOST 且 ${dstPayCertDir} 不存在或为空,docker run 将不挂证书卷"
+                                fi
+                                # 列出全部容器名(含 Exited),合并为一行便于日志阅读;匹配时用空格边界避免 gateway 误命中 gateway-produ
+                                case \$- in *x*) __jx=1; set +x ;; *) __jx=0 ;; esac
+                                NAMES="\$(docker ps -a --format '{{.Names}}' 2>/dev/null | tr '\\n' ' ' | tr -s ' ' | sed 's/[[:space:]]*\$//')"
+                                [ "\$__jx" = 1 ] && set -x
+                                CNAME=""
+                                if echo " \${NAMES} " | grep -Fq " ${cPrimary} "; then
+                                    CNAME="${cPrimary}"
+                                elif echo " \${NAMES} " | grep -Fq " ${cFallback} "; then
+                                    CNAME="${cFallback}"
+                                fi
+                                RECREATE=0
+                                if [ -n "\$CNAME" ]; then
+                                    ACTUAL_SRC=\$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/app/app.jar"}}{{.Source}}{{end}}{{end}}' "\$CNAME" 2>/dev/null | head -1)
+                                    MISMATCH=0
+                                    if [ -n "\$ACTUAL_SRC" ] && [ "\$ACTUAL_SRC" != "${jarHostPath}" ]; then
+                                        if command -v realpath >/dev/null 2>&1; then
+                                            R1=\$(realpath "\$ACTUAL_SRC" 2>/dev/null || echo "\$ACTUAL_SRC")
+                                            R2=\$(realpath "${jarHostPath}" 2>/dev/null || echo "${jarHostPath}")
+                                            [ "\$R1" != "\$R2" ] && MISMATCH=1
+                                        else
+                                            MISMATCH=1
+                                        fi
+                                    fi
+                                    if [ "\$MISMATCH" -eq 1 ] && [ -n "\$ACTUAL_SRC" ]; then
+                                        if [ "${autoRecreate}" = "true" ]; then
+                                            echo ">>> WARN: 容器 \$CNAME 的 /app/app.jar 挂载源与本次制品路径不一致,将删除并重建(挂载源=\$ACTUAL_SRC,本次=${jarHostPath})"
+                                            docker rm -f "\$CNAME"
+                                            CNAME=""
+                                            RECREATE=1
+                                        else
+                                            echo "ERROR: 容器 \$CNAME 的 /app/app.jar 挂载源为 \$ACTUAL_SRC,本次流水线 jar 为 ${jarHostPath}。"
+                                            echo "docker restart 不会更改 bind mount。请执行 docker rm -f \$CNAME 后重跑,或开启 AUTO_RECREATE_IF_MOUNT_MISMATCH。"
+                                            exit 1
+                                        fi
+                                    fi
+                                fi
+                                if [ -n "\$CNAME" ] && [ "${payCertSvc}" = "1" ] && [ "\$CERT_VOL" -eq 1 ]; then
+                                    CERT_SRC=\$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/usr/local/alien/aliPayCert"}}{{.Source}}{{end}}{{end}}' "\$CNAME" 2>/dev/null | head -1)
+                                    CERT_MM=0
+                                    if [ -z "\$CERT_SRC" ]; then
+                                        CERT_MM=1
+                                    elif command -v realpath >/dev/null 2>&1; then
+                                        CR1=\$(realpath "\$CERT_SRC" 2>/dev/null || echo "\$CERT_SRC")
+                                        CR2=\$(realpath "\$PAY_MOUNT" 2>/dev/null || echo "\$PAY_MOUNT")
+                                        [ "\$CR1" != "\$CR2" ] && CERT_MM=1
+                                    else
+                                        [ "\$CERT_SRC" != "\$PAY_MOUNT" ] && CERT_MM=1
+                                    fi
+                                    if [ "\$CERT_MM" -eq 1 ]; then
+                                        if [ "${autoRecreate}" = "true" ]; then
+                                            echo ">>> WARN: 容器 \$CNAME 未挂载 /usr/local/alien/aliPayCert 或源路径与本次不一致(期望 \$PAY_MOUNT,实际=\${CERT_SRC:-无}),将删除并以正确证书卷重建"
+                                            docker rm -f "\$CNAME"
+                                            CNAME=""
+                                            RECREATE=1
+                                        else
+                                            echo "ERROR: 容器 \$CNAME 缺少支付证书卷或挂载源不是 \$PAY_MOUNT。docker restart 无法新增卷。请开启 AUTO_RECREATE_IF_MOUNT_MISMATCH 或: docker rm -f \$CNAME 后重跑。"
+                                            exit 1
+                                        fi
+                                    fi
+                                fi
+                                if [ -n "\$CNAME" ]; then
+                                    LOG_SRC=\$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/app/logs"}}{{.Source}}{{end}}{{end}}' "\$CNAME" 2>/dev/null | head -1)
+                                    LOG_MM=0
+                                    if [ -z "\$LOG_SRC" ]; then
+                                        LOG_MM=1
+                                    elif command -v realpath >/dev/null 2>&1; then
+                                        LR1=\$(realpath "\$LOG_SRC" 2>/dev/null || echo "\$LOG_SRC")
+                                        LR2=\$(realpath "${logHostDir}" 2>/dev/null || echo "${logHostDir}")
+                                        [ "\$LR1" != "\$LR2" ] && LOG_MM=1
+                                    else
+                                        [ "\$LOG_SRC" != "${logHostDir}" ] && LOG_MM=1
+                                    fi
+                                    if [ "\$LOG_MM" -eq 1 ]; then
+                                        if [ "${autoRecreate}" = "true" ]; then
+                                            echo ">>> WARN: 容器 \$CNAME 未挂载 /app/logs 或源路径与 compose 不一致(期望 ${logHostDir},实际=\${LOG_SRC:-无}),将删除并以日志卷重建"
+                                            docker rm -f "\$CNAME"
+                                            CNAME=""
+                                            RECREATE=1
+                                        else
+                                            echo ">>> WARN: 容器 \$CNAME 未挂宿主日志目录 ${logHostDir} -> /app/logs(与 docker-compose 不一致);restart 无法补卷。请开启 AUTO_RECREATE_IF_MOUNT_MISMATCH 或手动 docker rm 后由流水线重建。"
+                                        fi
+                                    fi
+                                fi
+                                if [ -n "\$CNAME" ] && [ "\$JAR_WAS_STALE_DIR" -eq 1 ]; then
+                                    if [ "${autoRecreate}" = "true" ]; then
+                                        echo ">>> WARN: 宿主 jar 已由「目录」改为文件,旧容器 bind 类型不兼容,删除并重建: \$CNAME(勿仅 restart)"
+                                        docker rm -f "\$CNAME"
+                                        CNAME=""
+                                        RECREATE=1
+                                    else
+                                        echo "ERROR: 已删除误建目录并写入 jar 文件,须 docker rm -f \$CNAME 后重建容器。请开启 AUTO_RECREATE_IF_MOUNT_MISMATCH。"
+                                        exit 1
+                                    fi
+                                fi
+                                if [ -n "\$CNAME" ]; then
+                                    JASYPT_INJECT=0
+                                    [ -n "\$ENV_FILE_OPT" ] && JASYPT_INJECT=1
+                                    [ -n "\$EXTRA_JASYPT_ENV_OPT" ] && JASYPT_INJECT=1
+                                    if [ "\$JASYPT_INJECT" -eq 1 ]; then
+                                        JASYPT_OK=0
+                                        docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "\$CNAME" 2>/dev/null | grep -q '^JASYPT_ENCRYPTOR_PASSWORD=' && JASYPT_OK=1
+                                        if [ "\$JASYPT_OK" -eq 0 ]; then
+                                            if [ "${autoRecreate}" = "true" ]; then
+                                                echo ">>> WARN: 容器 \$CNAME 缺少环境变量 JASYPT_ENCRYPTOR_PASSWORD,本次流水线将注入,删除并重建(仅 restart 无法注入)"
+                                                docker rm -f "\$CNAME"
+                                                CNAME=""
+                                                RECREATE=1
+                                            else
+                                                echo "ERROR: 容器 \$CNAME 创建时未带 JASYPT_ENCRYPTOR_PASSWORD,应用会启动失败。请开启 AUTO_RECREATE_IF_MOUNT_MISMATCH,或执行 docker rm -f \$CNAME 后重跑。"
+                                                exit 1
+                                            fi
+                                        fi
+                                    fi
+                                fi
+                                if [ -n "\$CNAME" ]; then
+                                    RS_TMP="/tmp/jenkins_restart_err_\$\$"
+                                    if docker restart "\$CNAME" 2>"\$RS_TMP"; then
+                                        echo ">>> [\$CNAME] 已重启"
+                                        rm -f "\$RS_TMP"
+                                    else
+                                        RS_ERR=\$(cat "\$RS_TMP" 2>/dev/null || true)
+                                        rm -f "\$RS_TMP"
+                                        if echo "\$RS_ERR" | grep -qE 'not a directory|vice-versa|expected type|mount.*app\\.jar|failed to create (task|shim)'; then
+                                            if [ "${autoRecreate}" = "true" ]; then
+                                                echo ">>> WARN: docker restart 因挂载类型/路径失败,删除并重建: \$CNAME"
+                                                docker rm -f "\$CNAME"
+                                                CNAME=""
+                                                RECREATE=1
+                                            else
+                                                echo "\$RS_ERR"
+                                                echo "ERROR: 请开启 AUTO_RECREATE_IF_MOUNT_MISMATCH 或手动删容器后重建。"
+                                                exit 1
+                                            fi
+                                        else
+                                            echo "\$RS_ERR"
+                                            exit 1
+                                        fi
+                                    fi
+                                fi
+                                DID_TRY_CREATE=0
+                                if [ -z "\$CNAME" ] && { [ "${autoCreate}" = "true" ] || [ "\$RECREATE" -eq 1 ]; }; then
+                                    DID_TRY_CREATE=1
+                                    echo ">>> 创建容器 ${cPrimary} ..."
+                                    if ! docker image inspect "${jreImage}" >/dev/null 2>&1; then
+                                        echo ">>> 本地无镜像 ${jreImage},尝试 docker pull ..."
+                                        docker pull "${jreImage}" || true
+                                    fi
+                                    if docker image inspect "${jreImage}" >/dev/null 2>&1; then
+                                        if [ -d "${dstLib}" ] && [ -n "\$(ls -A \"${dstLib}\" 2>/dev/null)" ]; then
+                                            echo ">>> 瘦 jar:同时挂载 lib -> /app/lib:ro(${dstLib})"
+                                            if [ "\$CERT_VOL" -eq 1 ]; then
+                                                docker run -d --name "${cPrimary}" --restart unless-stopped -p ${hostPort}:${serverPort} \$ENV_FILE_OPT \$EXTRA_JASYPT_ENV_OPT -v "\$PAY_MOUNT:/usr/local/alien/aliPayCert:ro" -v "${jarHostPath}:/app/app.jar" -v "${dstLib}:/app/lib:ro" -v "${logHostDir}:/app/logs" -w /app${dockerTzEnv} -e SPRING_PROFILES_ACTIVE=prod ${netExtra}"${jreImage}" java -jar /app/app.jar --spring.profiles.active=prod --server.port=${serverPort}
+                                            else
+                                                docker run -d --name "${cPrimary}" --restart unless-stopped -p ${hostPort}:${serverPort} \$ENV_FILE_OPT \$EXTRA_JASYPT_ENV_OPT -v "${jarHostPath}:/app/app.jar" -v "${dstLib}:/app/lib:ro" -v "${logHostDir}:/app/logs" -w /app${dockerTzEnv} -e SPRING_PROFILES_ACTIVE=prod ${netExtra}"${jreImage}" java -jar /app/app.jar --spring.profiles.active=prod --server.port=${serverPort}
+                                            fi
+                                        else
+                                            if [ "\$CERT_VOL" -eq 1 ]; then
+                                                docker run -d --name "${cPrimary}" --restart unless-stopped -p ${hostPort}:${serverPort} \$ENV_FILE_OPT \$EXTRA_JASYPT_ENV_OPT -v "\$PAY_MOUNT:/usr/local/alien/aliPayCert:ro" -v "${jarHostPath}:/app/app.jar" -v "${logHostDir}:/app/logs" -w /app${dockerTzEnv} -e SPRING_PROFILES_ACTIVE=prod ${netExtra}"${jreImage}" java -jar /app/app.jar --spring.profiles.active=prod --server.port=${serverPort}
+                                            else
+                                                docker run -d --name "${cPrimary}" --restart unless-stopped -p ${hostPort}:${serverPort} \$ENV_FILE_OPT \$EXTRA_JASYPT_ENV_OPT -v "${jarHostPath}:/app/app.jar" -v "${logHostDir}:/app/logs" -w /app${dockerTzEnv} -e SPRING_PROFILES_ACTIVE=prod ${netExtra}"${jreImage}" java -jar /app/app.jar --spring.profiles.active=prod --server.port=${serverPort}
+                                            fi
+                                        fi
+                                        echo ">>> [${cPrimary}] 已创建并启动(映射宿主机端口 ${hostPort} -> 容器 ${serverPort})"
+                                        CNAME="${cPrimary}"
+                                    elif [ "\$RECREATE" -eq 1 ]; then
+                                        echo "ERROR: 需重建容器但镜像 ${jreImage} 不可用(pull 失败)。"
+                                        exit 1
+                                    else
+                                        echo ">>> WARN: 本地无镜像 ${jreImage} 且 pull 失败(镜像源/DNS/网络)。已跳过 docker run,jar 已写入宿主机。"
+                                        echo ">>> 处理:1) 宿主机确保已有镜像(docker images ${jreImage} 或 docker pull/ docker load);2) 修改 Job 参数 PROD_JRE_IMAGE;3) 关闭自动创建后手动起容器。"
+                                    fi
+                                fi
+                                if [ -z "\$CNAME" ] && [ "\$DID_TRY_CREATE" -eq 0 ] && [ "\$RECREATE" -eq 0 ]; then
+                                    echo ">>> 未找到容器 [${cPrimary}] 或 [${cFallback}],且未开启自动创建,仅更新宿主机文件。"
+                                fi
+                                if [ -z "\$CNAME" ] && [ "\$RECREATE" -eq 1 ]; then
+                                    echo "ERROR: 挂载不一致需重建容器但未成功创建。"
+                                    exit 1
+                                fi
+                                if [ -n "\$CNAME" ]; then
+                                    ACTUAL_SRC2=\$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/app/app.jar"}}{{.Source}}{{end}}{{end}}' "\$CNAME" 2>/dev/null | head -1)
+                                    echo ">>> 校验: \$CNAME 挂载 /app/app.jar -> \$ACTUAL_SRC2(期望与本次 ${jarHostPath} 一致)"
+                                    if [ -n "\$ACTUAL_SRC2" ] && [ "\$ACTUAL_SRC2" != "${jarHostPath}" ]; then
+                                        if command -v realpath >/dev/null 2>&1; then
+                                            R1=\$(realpath "\$ACTUAL_SRC2" 2>/dev/null || echo "\$ACTUAL_SRC2")
+                                            R2=\$(realpath "${jarHostPath}" 2>/dev/null || echo "${jarHostPath}")
+                                            if [ "\$R1" != "\$R2" ]; then
+                                                echo "ERROR: 重建后挂载仍与本次 jar 路径不一致"
+                                                exit 1
+                                            fi
+                                        else
+                                            echo "ERROR: 重建后挂载仍与本次 jar 路径不一致"
+                                            exit 1
+                                        fi
+                                    fi
+                                    echo ">>> 等待 \$CNAME 稳定为 running(仅 Status=running 不够:崩溃循环时会在 running/restarting 间切换;exec 在 restarting 会报 is restarting)"
+                                    STABLE=0
+                                    for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45; do
+                                        ST=\$(docker inspect -f '{{.State.Status}}' "\$CNAME" 2>/dev/null | tr -d '[:space:]')
+                                        case "\$ST" in
+                                            restarting)
+                                                echo ">>> [\$i/45] 状态=restarting(Java 可能反复退出,非单纯 409),2s 后再查..."
+                                                sleep 2
+                                                ;;
+                                            running)
+                                                sleep 6
+                                                ST2=\$(docker inspect -f '{{.State.Status}}' "\$CNAME" 2>/dev/null | tr -d '[:space:]')
+                                                if [ "\$ST2" = "running" ]; then
+                                                    STABLE=1
+                                                    echo ">>> 容器已连续约 6s 保持 running"
+                                                    break
+                                                fi
+                                                echo ">>> running 未稳定,继续等待..."
+                                                sleep 2
+                                                ;;
+                                            exited|dead)
+                                                echo "ERROR: 容器 \$CNAME 已结束: \$ST"
+                                                echo ">>> docker logs --tail 50 \$CNAME:"
+                                                docker logs --tail 50 "\$CNAME" 2>&1 || true
+                                                exit 1
+                                                ;;
+                                            *)
+                                                echo ">>> [\$i/45] 状态=\${ST:-unknown},2s 后再查..."
+                                                sleep 2
+                                                ;;
+                                        esac
+                                    done
+                                    if [ "\$STABLE" -ne 1 ]; then
+                                        echo "ERROR: 容器 \$CNAME 无法在约 90s 内保持稳定 running(长期 restarting =应用崩溃循环)。请先修 jar/lib/配置。"
+                                        echo ">>> docker logs --tail 80 \$CNAME:"
+                                        docker logs --tail 80 "\$CNAME" 2>&1 || true
+                                        exit 1
+                                    fi
+                                    JAR_OK=0
+                                    for j in 1 2 3 4 5 6; do
+                                        if docker exec "\$CNAME" sh -c 'test -f /app/app.jar && test -s /app/app.jar && ! test -d /app/app.jar' 2>/dev/null; then
+                                            JAR_OK=1
+                                            break
+                                        fi
+                                        echo ">>> docker exec 重试 (\$j/6)..."
+                                        sleep 2
+                                    done
+                                    if [ "\$JAR_OK" -ne 1 ]; then
+                                        echo "ERROR: 容器内 /app/app.jar 校验失败(稳定 running 后仍 exec 失败时请检查挂载)"
+                                        docker exec "\$CNAME" ls -la /app/app.jar 2>&1 || true
+                                        echo ">>> docker logs --tail 40 \$CNAME:"
+                                        docker logs --tail 40 "\$CNAME" 2>&1 || true
+                                        exit 1
+                                    fi
+                                    if [ "${s.prodDir}" = "store" ] && [ "\$CERT_VOL" -eq 1 ]; then
+                                        if ! docker exec "\$CNAME" sh -c 'test -f /usr/local/alien/aliPayCert/apiclient_key.pem' 2>/dev/null; then
+                                            echo "ERROR: store 容器内仍无 /usr/local/alien/aliPayCert/apiclient_key.pem。请 docker inspect \$CNAME 核对 Mounts;若已开启自动重建仍失败,检查 docker run 是否带证书 -v。"
+                                            exit 1
+                                        fi
+                                        echo ">>> [\$CNAME] 已确认微信商户私钥文件在容器内可见"
+                                    fi
+                                    HAS_UNZIP=0
+                                    if docker exec "\$CNAME" sh -c 'command -v unzip >/dev/null 2>&1' 2>/dev/null; then HAS_UNZIP=1; fi
+                                    UNZ_OK=0
+                                    if [ "\$HAS_UNZIP" -eq 1 ]; then
+                                        for j in 1 2 3 4 5; do
+                                            if docker exec "\$CNAME" unzip -t /app/app.jar >/dev/null 2>&1; then
+                                                UNZ_OK=1
+                                                break
+                                            fi
+                                            echo ">>> unzip -t 重试 (\$j/5)..."
+                                            sleep 2
+                                        done
+                                        if [ "\$UNZ_OK" -ne 1 ]; then
+                                            echo "ERROR: 容器内 unzip -t /app/app.jar 失败"
+                                            exit 1
+                                        fi
+                                    else
+                                        echo ">>> WARN: 镜像内无 unzip,仅校验 PK 魔数"
+                                        for j in 1 2 3 4 5; do
+                                            if docker exec "\$CNAME" sh -c 'od -An -N2 -tx1 /app/app.jar' 2>/dev/null | grep -qE '50[[:space:]]+4b'; then
+                                                UNZ_OK=1
+                                                break
+                                            fi
+                                            echo ">>> PK 头校验重试 (\$j/5)..."
+                                            sleep 2
+                                        done
+                                        if [ "\$UNZ_OK" -ne 1 ]; then
+                                            echo "ERROR: 容器内 jar 头非 zip(PK)"
+                                            exit 1
+                                        fi
+                                    fi
+                                    echo ">>> [\$CNAME] 容器内 jar 校验通过"
+                                fi
+                            fi
+                        """
+                        }
+                    } finally {
+                        if (writtenJasyptEnv) {
+                            sh "rm -f \"${writtenJasyptEnv}\" 2>/dev/null || true"
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    // 构建结束后的固定输出:always 必跑;success/failure 分别提示后续人工检查点。
+    post {
+        always {
+            echo '>>> 生产晋升任务结束'
+        }
+        success {
+            echo '>>> 流水线成功(请确认生产容器启动参数与 Nacos 等环境一致)'
+        }
+        failure {
+            echo '>>> 流水线失败:检查 UAT 是否已部署、UAT_DEPLOY_ROOT/PROD_DEPLOY_ROOT、Git 是否可拉取'
+            echo '>>> 若 Jenkins 与生产 Docker 不在同一台机器,本 Job 无法改远程宿主机上的容器;需在运行 store-produ 的那台机上核对挂载与 jar,或在该机执行晋升/重建容器'
+            echo '>>> 若 docker run 报 invalid env file / invalid utf8:将 .env.produ-docker 改为 UTF-8(勿 GBK),或用仓库 env.produ-docker.example 全 ASCII 版;若 Jasypt 未注入则检查 PROD_DOCKER_ENV_FILE、AUTO_RECREATE'
+        }
+    }
+}