ソースを参照

Merge remote-tracking branch 'origin/sit' into uat-20260202

dujian 10 時間 前
コミット
080f276971

+ 4 - 4
alien-store/src/main/java/shop/alien/store/service/LifeUserService.java

@@ -124,11 +124,11 @@ public class LifeUserService extends ServiceImpl<LifeUserMapper, LifeUser> {
         lambdaQueryWrapper.eq(LifeFans::getFollowedId, fans.getFollowedId())
                 .eq(LifeFans::getFansId, fans.getFansId())
                 .eq(LifeFans::getDeleteFlag, 0);
-        int num = lifeFansMapper.selectCount(lambdaQueryWrapper);
-        if (num != 0) {
-            return 1;
+        int number = lifeFansMapper.selectCount(lambdaQueryWrapper);
+        if (number >= 1) {
+            return 0;
         }
-        lifeFansMapper.insert(fans);
+        int num = lifeFansMapper.insert(fans);
 
         if (num == 1) {
             LifeNotice notice = new LifeNotice();

+ 18 - 17
alien-store/src/main/java/shop/alien/store/service/impl/LifeDiscountCouponStoreFriendServiceImpl.java

@@ -476,27 +476,27 @@ public class LifeDiscountCouponStoreFriendServiceImpl extends ServiceImpl<LifeDi
 //        }
         UserReservationOrder userReservationOrder = userReservationOrderMapper.selectById(lifeDiscountCouponStoreFriendDto.getOrderId());
 
-
-        //送券规则
-        List<Integer> couponList = new ArrayList<>();
-        LambdaQueryWrapper<LifeDiscountCouponFriendRule> ruleLambdaQueryWrapper = new LambdaQueryWrapper<>();
-        ruleLambdaQueryWrapper.eq(LifeDiscountCouponFriendRule::getStoreId, userReservationOrder.getStoreId())
-                .eq(LifeDiscountCouponFriendRule::getDeleteFlag, 0);
-        List<LifeDiscountCouponFriendRule> lifeDiscountCouponFriendRules = lifeDiscountCouponFriendRuleMapper.selectList(ruleLambdaQueryWrapper);
-        lifeDiscountCouponFriendRules = lifeDiscountCouponFriendRules.stream().filter(i -> i.getMoneyLow().compareTo(userReservationOrder.getDepositAmount()) <= 0 && i.getMoneyHigh().compareTo(userReservationOrder.getDepositAmount()) >= 0).collect(Collectors.toList());
-        if (ObjectUtils.isNotEmpty(lifeDiscountCouponFriendRules)) {
-            LambdaQueryWrapper<LifeDiscountCouponFriendRuleDetail> detailLambdaQueryWrapper = new LambdaQueryWrapper<>();
-            detailLambdaQueryWrapper.in(LifeDiscountCouponFriendRuleDetail::getRuleId, lifeDiscountCouponFriendRules.stream().map(LifeDiscountCouponFriendRule::getId).collect(Collectors.toList()));
-            List<LifeDiscountCouponFriendRuleDetail> lifeDiscountCouponFriendRuleDetails = lifeDiscountCouponFriendRuleDetailMapper.selectList(detailLambdaQueryWrapper);
-            couponList = lifeDiscountCouponFriendRuleDetails.stream().map(LifeDiscountCouponFriendRuleDetail::getCouponId).collect(Collectors.toList());
-        }
+//
+//        //送券规则
+//        List<Integer> couponList = new ArrayList<>();
+//        LambdaQueryWrapper<LifeDiscountCouponFriendRule> ruleLambdaQueryWrapper = new LambdaQueryWrapper<>();
+//        ruleLambdaQueryWrapper.eq(LifeDiscountCouponFriendRule::getStoreId, userReservationOrder.getStoreId())
+//                .eq(LifeDiscountCouponFriendRule::getDeleteFlag, 0);
+//        List<LifeDiscountCouponFriendRule> lifeDiscountCouponFriendRules = lifeDiscountCouponFriendRuleMapper.selectList(ruleLambdaQueryWrapper);
+//        lifeDiscountCouponFriendRules = lifeDiscountCouponFriendRules.stream().filter(i -> i.getMoneyLow().compareTo(userReservationOrder.getDepositAmount()) <= 0 && i.getMoneyHigh().compareTo(userReservationOrder.getDepositAmount()) >= 0).collect(Collectors.toList());
+//        if (ObjectUtils.isNotEmpty(lifeDiscountCouponFriendRules)) {
+//            LambdaQueryWrapper<LifeDiscountCouponFriendRuleDetail> detailLambdaQueryWrapper = new LambdaQueryWrapper<>();
+//            detailLambdaQueryWrapper.in(LifeDiscountCouponFriendRuleDetail::getRuleId, lifeDiscountCouponFriendRules.stream().map(LifeDiscountCouponFriendRule::getId).collect(Collectors.toList()));
+//            List<LifeDiscountCouponFriendRuleDetail> lifeDiscountCouponFriendRuleDetails = lifeDiscountCouponFriendRuleDetailMapper.selectList(detailLambdaQueryWrapper);
+//            couponList = lifeDiscountCouponFriendRuleDetails.stream().map(LifeDiscountCouponFriendRuleDetail::getCouponId).collect(Collectors.toList());
+//        }
 
 
 //        lifeUserOrder.setSendDiscountCouponFlag(1);
 //        lifeUserOrderMapper.updateById(lifeUserOrder);
 
         //有符合规则的优惠券
-        if (ObjectUtils.isNotEmpty(couponList) && !couponList.isEmpty()) {
+//        if (ObjectUtils.isNotEmpty(couponList) && !couponList.isEmpty()) {
             // 获取当前消费用户的ID
             int userId = Integer.parseInt(userReservationOrder.getUserId().toString());
             LifeUser lifeUser = lifeUserMapper.selectById(userId);
@@ -512,7 +512,8 @@ public class LifeDiscountCouponStoreFriendServiceImpl extends ServiceImpl<LifeDi
                     // 使用LambdaQueryWrapper构建查询条件,筛选出店铺ID等于指定店铺ID的优惠券记录,并且发布状态为已发布
                     new LambdaQueryWrapper<LifeDiscountCouponStoreFriend>().eq(LifeDiscountCouponStoreFriend::getStoreUserId, storeId)
                             .eq(LifeDiscountCouponStoreFriend::getReleaseType, 1)
-                            .in(LifeDiscountCouponStoreFriend::getCouponId, couponList));
+   //                         .in(LifeDiscountCouponStoreFriend::getCouponId, couponList)
+            );
 
             // 获取当前日期,用于后续判断优惠券是否在有效期内
             LocalDate currentDate = LocalDate.now();
@@ -608,7 +609,7 @@ public class LifeDiscountCouponStoreFriendServiceImpl extends ServiceImpl<LifeDi
                 lifeNoticeMapper.insert(lifeNotice);
 
             }
-        }
+//        }
             // 返回成功发放的优惠券信息列表
             return result;
     }

+ 1 - 1
alien-store/src/main/java/shop/alien/store/service/impl/StoreOperationalActivityAchievementServiceImpl.java

@@ -195,7 +195,7 @@ public class StoreOperationalActivityAchievementServiceImpl implements StoreOper
         if (mediaUrls == null || mediaUrls.trim().isEmpty()) {
             return Collections.emptyList();
         }
-        String[] parts = mediaUrls.split(",");
+        String[] parts = mediaUrls.split(";");
         List<String> results = new ArrayList<>();
         for (String part : parts) {
             if (part != null && !part.trim().isEmpty()) {

+ 2 - 1
alien-store/src/main/java/shop/alien/store/service/impl/StoreStaffConfigServiceImpl.java

@@ -447,7 +447,8 @@ public class StoreStaffConfigServiceImpl implements StoreStaffConfigService {
 
         // 构建查询条件
         LambdaQueryWrapper<StoreStaffConfig> queryWrapper = buildStaffListQueryWrapper(storeId, status, staffPosition);
-
+        // 只查询上线的员工
+        queryWrapper.eq(StoreStaffConfig::getOnlineStatus,"0");
         // 执行查询
         IPage<StoreStaffConfig> result = storeStaffConfigMapper.selectPage(staffPage, queryWrapper);
 

+ 141 - 76
alien-store/src/main/java/shop/alien/store/strategy/merchantPayment/impl/MerchantWechatPaymentStrategyImpl.java

@@ -11,22 +11,25 @@ import org.springframework.stereotype.Component;
 import shop.alien.entity.result.R;
 import shop.alien.entity.store.MerchantPaymentOrder;
 import shop.alien.entity.store.RefundRecord;
-import shop.alien.entity.store.StorePaymentConfig;
+import shop.alien.entity.store.StoreInfo;
 import shop.alien.entity.store.UserReservation;
 import shop.alien.entity.store.UserReservationOrder;
+import shop.alien.mapper.StoreInfoMapper;
 import shop.alien.store.service.MerchantPaymentOrderService;
 import shop.alien.store.service.RefundRecordAsyncService;
 import shop.alien.store.service.RefundRecordService;
-import shop.alien.store.service.StorePaymentConfigService;
 import shop.alien.store.service.ReservationOrderPaymentTimeoutService;
 import shop.alien.store.service.UserReservationOrderService;
 import shop.alien.store.service.UserReservationService;
 import shop.alien.store.strategy.merchantPayment.MerchantPaymentStrategy;
+import shop.alien.store.strategy.payment.impl.WeChatPartnerPaymentStrategyImpl;
 import shop.alien.store.strategy.payment.impl.WeChatPaymentStrategyImpl;
 import shop.alien.store.util.WXPayUtility;
 import shop.alien.util.common.UniqueRandomNumGenerator;
 import shop.alien.util.common.constant.PaymentEnum;
+import shop.alien.util.system.OSUtil;
 
+import javax.annotation.PostConstruct;
 import java.io.IOException;
 import java.math.BigDecimal;
 import java.nio.charset.StandardCharsets;
@@ -37,7 +40,9 @@ import java.util.*;
 import java.util.concurrent.TimeUnit;
 
 /**
- * 商户微信支付策略(预订订单,使用 StorePaymentConfig 按门店配置,业务逻辑与支付宝一致)
+ * 商户微信支付策略(预订订单):业务与支付宝侧一致;支付接口走微信服务商模式(特约商户),
+ * 与 {@link WeChatPartnerPaymentStrategyImpl} 一致使用 sp_mchid + sub_mchid、服务商证书;
+ * 不查 {@code StorePaymentConfig};预下单请求体不设 sub_appid(与 Partner 的 APP 预下单一致)。
  *
  * @author system
  */
@@ -59,20 +64,47 @@ public class MerchantWechatPaymentStrategyImpl implements MerchantPaymentStrateg
     /** 退款记录状态:退款失败 */
     private static final String REFUND_STATUS_FAIL = "FAIL";
 
-    @Value("${payment.wechatPay.host:https://api.mch.weixin.qq.com}")
+    @Value("${payment.wechatPartnerPay.host:https://api.mch.weixin.qq.com}")
     private String wechatPayApiHost;
-    @Value("${payment.wechatPay.prePayPath:/v3/pay/transactions/app}")
+    @Value("${payment.wechatPartnerPay.prePayPath:/v3/pay/partner/transactions/app}")
     private String prePayPath;
-    @Value("${payment.wechatPay.searchOrderByOutTradeNoPath:/v3/pay/transactions/out-trade-no/{out_trade_no}}")
+    @Value("${payment.wechatPartnerPay.searchOrderByOutTradeNoPath:/v3/pay/partner/transactions/out-trade-no/{out_trade_no}}")
     private String searchOrderByOutTradeNoPath;
-    @Value("${payment.wechatPay.refundPath:/v3/refund/domestic/refunds}")
+    @Value("${payment.wechatPartnerPay.refundPath:/v3/refund/domestic/refunds}")
     private String refundPath;
+
+    @Value("${payment.wechatPartnerPay.business.spAppId}")
+    private String spAppId;
+    @Value("${payment.wechatPartnerPay.business.spMchId}")
+    private String spMchId;
+
+    @Value("${payment.wechatPartnerPay.business.win.privateKeyPath}")
+    private String privateWinKeyPath;
+    @Value("${payment.wechatPartnerPay.business.linux.privateKeyPath}")
+    private String privateLinuxKeyPath;
+    @Value("${payment.wechatPartnerPay.business.win.wechatPayPublicKeyFilePath}")
+    private String wechatWinPayPublicKeyFilePath;
+    @Value("${payment.wechatPartnerPay.business.linux.wechatPayPublicKeyFilePath}")
+    private String wechatLinuxPayPublicKeyFilePath;
+    @Value("${payment.wechatPartnerPay.business.merchantSerialNumber}")
+    private String merchantSerialNumber;
+    @Value("${payment.wechatPartnerPay.business.wechatPayPublicKeyId}")
+    private String wechatPayPublicKeyId;
+
+    /** 服务商下单回调 URL:优先 partner 配置(与 {@link WeChatPartnerPaymentStrategyImpl} 一致),否则回退直连配置 */
+    @Value("${payment.wechatPartnerPay.business.prePayNotifyUrl:}")
+    private String partnerPrePayNotifyUrl;
     @Value("${payment.wechatPay.business.prePayNotifyUrl:}")
-    private String prePayNotifyUrl;
+    private String directPrePayNotifyUrl;
+    @Value("${payment.wechatPartnerPay.business.refundNotifyUrl:}")
+    private String partnerRefundNotifyUrl;
     @Value("${payment.wechatPay.business.refundNotifyUrl:}")
-    private String refundNotifyUrl;
+    private String directRefundNotifyUrl;
+
+    private PrivateKey partnerPrivateKey;
+    private PublicKey partnerWechatPayPublicKey;
 
-    private final StorePaymentConfigService storePaymentConfigService;
+    private final StoreInfoMapper storeInfoMapper;
     private final UserReservationOrderService userReservationOrderService;
     private final MerchantPaymentOrderService merchantPaymentOrderService;
     private final RefundRecordAsyncService refundRecordAsyncService;
@@ -81,6 +113,22 @@ public class MerchantWechatPaymentStrategyImpl implements MerchantPaymentStrateg
     private final UserReservationService userReservationService;
     private final StringRedisTemplate stringRedisTemplate;
 
+    @PostConstruct
+    public void loadPartnerCertificates() {
+        String privateKeyPath;
+        String wechatPayPublicKeyFilePath;
+        if ("windows".equals(OSUtil.getOsName())) {
+            privateKeyPath = privateWinKeyPath;
+            wechatPayPublicKeyFilePath = wechatWinPayPublicKeyFilePath;
+        } else {
+            privateKeyPath = privateLinuxKeyPath;
+            wechatPayPublicKeyFilePath = wechatLinuxPayPublicKeyFilePath;
+        }
+        log.info("[MerchantWechat] 加载服务商商户证书私钥与平台公钥,os={}, keyPath={}", OSUtil.getOsName(), privateKeyPath);
+        this.partnerPrivateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyPath);
+        this.partnerWechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
+    }
+
     @Override
     public R<Map<String, Object>> createPrePay(Integer storeId, Integer orderId, String amountYuan, String subject, Integer userId, String smid) {
         if (storeId == null || orderId == null) {
@@ -92,16 +140,15 @@ public class MerchantWechatPaymentStrategyImpl implements MerchantPaymentStrateg
         if (StringUtils.isBlank(subject)) {
             return R.fail("订单描述不能为空");
         }
-        StorePaymentConfig config = storePaymentConfigService.getByStoreId(storeId);
-        if (config == null) {
-            return R.fail("该门店未配置支付参数");
+        String subMchid = resolveSubMchidFromStore(storeId);
+        if (StringUtils.isBlank(subMchid)) {
+            return R.fail("门店未配置微信特约商户号 wechat_sub_mchid");
         }
-        if (StringUtils.isBlank(config.getWechatAppId()) || StringUtils.isBlank(config.getWechatMchId())
-                || StringUtils.isBlank(config.getMerchantSerialNumber()) || StringUtils.isBlank(config.getApiV3Key())
-                || StringUtils.isBlank(config.getWechatPayPublicKeyId())
-                || config.getWechatPrivateKeyFile() == null || config.getWechatPrivateKeyFile().length == 0
-                || config.getWechatPayPublicKeyFile() == null || config.getWechatPayPublicKeyFile().length == 0) {
-            return R.fail("门店微信支付配置不完整");
+        if (StringUtils.isBlank(spMchId) || StringUtils.isBlank(spAppId)) {
+            return R.fail("服务商微信支付未配置(payment.wechatPartnerPay.business.spMchId / spAppId)");
+        }
+        if (partnerPrivateKey == null || partnerWechatPayPublicKey == null) {
+            return R.fail("服务商微信证书未加载,请检查 payment.wechatPartnerPay 私钥与平台公钥路径");
         }
         UserReservationOrder order = userReservationOrderService.getById(orderId);
         if (order == null) {
@@ -162,28 +209,29 @@ public class MerchantWechatPaymentStrategyImpl implements MerchantPaymentStrateg
         merchantPaymentOrderService.save(paymentOrder);
 
         try {
-            PrivateKey privateKey = WXPayUtility.loadPrivateKeyFromString(bytesToUtf8String(config.getWechatPrivateKeyFile()));
-
-            WeChatPaymentStrategyImpl.CommonPrepayRequest request = new WeChatPaymentStrategyImpl.CommonPrepayRequest();
-            request.appid = config.getWechatAppId();
-            request.mchid = config.getWechatMchId();
+            WeChatPartnerPaymentStrategyImpl.PartnerAppPrepayRequest request = new WeChatPartnerPaymentStrategyImpl.PartnerAppPrepayRequest();
+            request.spAppid = spAppId;
+            request.spMchid = spMchId;
+            request.subMchid = subMchid;
             request.description = subject;
             request.outTradeNo = outTradeNo;
-            request.notifyUrl = StringUtils.isNotBlank(prePayNotifyUrl) ? prePayNotifyUrl : "";
+            request.notifyUrl = resolveNotifyUrl(partnerPrePayNotifyUrl, directPrePayNotifyUrl);
             request.amount = new WeChatPaymentStrategyImpl.CommonAmountInfo();
             request.amount.total = amount.multiply(new BigDecimal(100)).longValue();
             request.amount.currency = "CNY";
 
-            WeChatPaymentStrategyImpl.DirectAPIv3AppPrepayResponse response = prePayOrderRun(config, privateKey, request);
+            WeChatPaymentStrategyImpl.DirectAPIv3AppPrepayResponse response = partnerPrePayOrderRun(request);
             if (response == null || StringUtils.isBlank(response.prepayId)) {
                 return R.fail("微信预支付失败");
             }
 
             long timestamp = System.currentTimeMillis() / 1000;
             String nonce = WXPayUtility.createNonce(32);
-            String message = String.format("%s\n%s\n%s\n%s\n", config.getWechatAppId(), timestamp, nonce, response.prepayId);
+            // APP 调起支付验签串(与微信文档一致):appId\ntimeStamp\nnonceStr\nprepayId\n
+            // 须与传给 OpenSDK 的 appId、timeStamp、nonceStr、prepayId 完全一致;此处与直连 {@link WeChatPaymentStrategyImpl} 一致
+            String message = String.format("%s\n%s\n%s\n%s\n", spAppId, timestamp, nonce, response.prepayId);
             Signature sign = Signature.getInstance("SHA256withRSA");
-            sign.initSign(privateKey);
+            sign.initSign(partnerPrivateKey);
             sign.update(message.getBytes(StandardCharsets.UTF_8));
             String signStr = Base64.getEncoder().encodeToString(sign.sign());
 
@@ -193,8 +241,11 @@ public class MerchantWechatPaymentStrategyImpl implements MerchantPaymentStrateg
             data.put("orderId", order.getId());
             data.put("paymentNo", paymentOrder.getPaymentNo());
             data.put("prepayId", response.prepayId);
-            data.put("appId", config.getWechatAppId());
-            data.put("mchId", config.getWechatMchId());
+            data.put("appId", spAppId);
+            data.put("spAppId", spAppId);
+            data.put("spMchId", spMchId);
+            data.put("subMchId", subMchid);
+            data.put("mchId", subMchid);
             data.put("sign", signStr);
             data.put("timestamp", String.valueOf(timestamp));
             data.put("nonce", nonce);
@@ -220,10 +271,14 @@ public class MerchantWechatPaymentStrategyImpl implements MerchantPaymentStrateg
             log.info("queryPayStatus 结束 storeId={} outTradeNo={} result=fail reason=参数为空", storeId, outTradeNo);
             return R.fail("门店ID和商户订单号不能为空");
         }
-        StorePaymentConfig config = storePaymentConfigService.getByStoreId(storeId);
-        if (config == null) {
-            log.info("queryPayStatus 结束 storeId={} outTradeNo={} result=fail reason=未配置支付参数", storeId, outTradeNo);
-            return R.fail("该门店未配置支付参数");
+        String subMchid = resolveSubMchidFromStore(storeId);
+        if (StringUtils.isBlank(subMchid)) {
+            log.info("queryPayStatus 结束 storeId={} outTradeNo={} result=fail reason=未配置特约商户号", storeId, outTradeNo);
+            return R.fail("门店未配置微信特约商户号 wechat_sub_mchid");
+        }
+        if (partnerPrivateKey == null || partnerWechatPayPublicKey == null) {
+            log.info("queryPayStatus 结束 storeId={} outTradeNo={} result=fail reason=服务商证书未加载", storeId, outTradeNo);
+            return R.fail("服务商微信证书未加载");
         }
         MerchantPaymentOrder paymentOrder = merchantPaymentOrderService.getByOutTradeNo(outTradeNo);
         if (paymentOrder == null) {
@@ -236,13 +291,7 @@ public class MerchantWechatPaymentStrategyImpl implements MerchantPaymentStrateg
             return R.fail("预订订单不存在");
         }
         try {
-            PrivateKey privateKey = WXPayUtility.loadPrivateKeyFromString(bytesToUtf8String(config.getWechatPrivateKeyFile()));
-            PublicKey wechatPayPublicKey = WXPayUtility.loadPublicKeyFromString(bytesToUtf8String(config.getWechatPayPublicKeyFile()));
-
-            WeChatPaymentStrategyImpl.QueryByWxTradeNoRequest req = new WeChatPaymentStrategyImpl.QueryByWxTradeNoRequest();
-            req.transactionId = outTradeNo;
-            req.mchid = config.getWechatMchId();
-            WeChatPaymentStrategyImpl.DirectAPIv3QueryResponse response = searchOrderRun(config, privateKey, wechatPayPublicKey, req);
+            WeChatPaymentStrategyImpl.DirectAPIv3QueryResponse response = partnerSearchOrderRun(outTradeNo, subMchid);
             if (response == null) {
                 log.info("queryPayStatus 结束 storeId={} outTradeNo={} result=fail reason=微信返回为空", storeId, outTradeNo);
                 return R.fail("查询失败");
@@ -306,9 +355,12 @@ public class MerchantWechatPaymentStrategyImpl implements MerchantPaymentStrateg
         if (StringUtils.isBlank(refundAmount) || new BigDecimal(refundAmount).compareTo(BigDecimal.ZERO) <= 0) {
             return R.fail("退款金额必须大于0");
         }
-        StorePaymentConfig config = storePaymentConfigService.getByStoreId(storeId);
-        if (config == null) {
-            return R.fail("该门店未配置支付参数");
+        String subMchid = resolveSubMchidFromStore(storeId);
+        if (StringUtils.isBlank(subMchid)) {
+            return R.fail("门店未配置微信特约商户号 wechat_sub_mchid");
+        }
+        if (partnerPrivateKey == null || partnerWechatPayPublicKey == null) {
+            return R.fail("服务商微信证书未加载");
         }
         MerchantPaymentOrder paymentOrder = merchantPaymentOrderService.getByOutTradeNo(outTradeNo);
         if (paymentOrder == null) {
@@ -322,14 +374,12 @@ public class MerchantWechatPaymentStrategyImpl implements MerchantPaymentStrateg
             return R.fail("订单未支付或已退款,无法退款");
         }
         try {
-            PrivateKey privateKey = WXPayUtility.loadPrivateKeyFromString(bytesToUtf8String(config.getWechatPrivateKeyFile()));
-            PublicKey wechatPayPublicKey = WXPayUtility.loadPublicKeyFromString(bytesToUtf8String(config.getWechatPayPublicKeyFile()));
-
-            WeChatPaymentStrategyImpl.CreateRequest request = new WeChatPaymentStrategyImpl.CreateRequest();
+            WeChatPartnerPaymentStrategyImpl.PartnerRefundCreateRequest request = new WeChatPartnerPaymentStrategyImpl.PartnerRefundCreateRequest();
+            request.subMchid = subMchid;
             request.outTradeNo = outTradeNo;
             request.outRefundNo = UniqueRandomNumGenerator.generateUniqueCode(19);
             request.reason = StringUtils.isNotBlank(refundReason) ? refundReason : "用户申请退款";
-            request.notifyUrl = StringUtils.isNotBlank(refundNotifyUrl) ? refundNotifyUrl : "";
+            request.notifyUrl = resolveNotifyUrl(partnerRefundNotifyUrl, directRefundNotifyUrl);
             request.amount = new WeChatPaymentStrategyImpl.AmountReq();
             request.amount.refund = new BigDecimal(refundAmount).multiply(new BigDecimal(100)).longValue();
             // 微信 V3 amount.total 单位为分,depositAmount 为元,需 *100 转分
@@ -341,7 +391,7 @@ public class MerchantWechatPaymentStrategyImpl implements MerchantPaymentStrateg
             int maxAttempts = 3;
             for (int attempt = 1; attempt <= maxAttempts; attempt++) {
                 try {
-                    response = refundRun(config, privateKey, wechatPayPublicKey, request);
+                    response = partnerRefundRun(request);
                     break;
                 } catch (WXPayUtility.ApiException e) {
                     lastApiException = e;
@@ -472,15 +522,14 @@ public class MerchantWechatPaymentStrategyImpl implements MerchantPaymentStrateg
         return PaymentEnum.WECHAT_PAY.getType();
     }
 
-    private WeChatPaymentStrategyImpl.DirectAPIv3AppPrepayResponse prePayOrderRun(StorePaymentConfig config,
-                                                                                  PrivateKey privateKey,
-                                                                                  WeChatPaymentStrategyImpl.CommonPrepayRequest request) throws IOException {
+    private WeChatPaymentStrategyImpl.DirectAPIv3AppPrepayResponse partnerPrePayOrderRun(
+            WeChatPartnerPaymentStrategyImpl.PartnerAppPrepayRequest request) throws IOException {
         String uri = prePayPath;
         String reqBody = WXPayUtility.toJson(request);
         Request.Builder reqBuilder = new Request.Builder().url(wechatPayApiHost + uri);
         reqBuilder.addHeader("Accept", "application/json");
-        reqBuilder.addHeader("Wechatpay-Serial", config.getWechatPayPublicKeyId());
-        reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(config.getWechatMchId(), config.getMerchantSerialNumber(), privateKey, POSTMETHOD, uri, reqBody));
+        reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
+        reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(spMchId, merchantSerialNumber, partnerPrivateKey, POSTMETHOD, uri, reqBody));
         reqBuilder.addHeader("Content-Type", "application/json");
         MediaType jsonMediaType = MediaType.parse("application/json; charset=utf-8");
         RequestBody body = RequestBody.create(reqBody, jsonMediaType);
@@ -489,29 +538,26 @@ public class MerchantWechatPaymentStrategyImpl implements MerchantPaymentStrateg
         try (Response httpResponse = client.newCall(reqBuilder.build()).execute()) {
             String respBody = WXPayUtility.extractBody(httpResponse);
             if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
-                PublicKey publicKey = WXPayUtility.loadPublicKeyFromString(bytesToUtf8String(config.getWechatPayPublicKeyFile()));
-                WXPayUtility.validateResponse(config.getWechatPayPublicKeyId(), publicKey, httpResponse.headers(), respBody);
+                WXPayUtility.validateResponse(wechatPayPublicKeyId, partnerWechatPayPublicKey, httpResponse.headers(), respBody);
                 return WXPayUtility.fromJson(respBody, WeChatPaymentStrategyImpl.DirectAPIv3AppPrepayResponse.class);
             }
             throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
         }
     }
 
-    private WeChatPaymentStrategyImpl.DirectAPIv3QueryResponse searchOrderRun(StorePaymentConfig config,
-                                                                              PrivateKey privateKey,
-                                                                              PublicKey wechatPayPublicKey,
-                                                                              WeChatPaymentStrategyImpl.QueryByWxTradeNoRequest request) throws IOException {
-        String uri = searchOrderByOutTradeNoPath.replace("{out_trade_no}", WXPayUtility.urlEncode(request.transactionId));
+    private WeChatPaymentStrategyImpl.DirectAPIv3QueryResponse partnerSearchOrderRun(String outTradeNo, String subMchid) throws IOException {
+        String uri = searchOrderByOutTradeNoPath.replace("{out_trade_no}", WXPayUtility.urlEncode(outTradeNo));
         Map<String, Object> args = new HashMap<>();
-        args.put("mchid", config.getWechatMchId());
+        args.put("sp_mchid", spMchId);
+        args.put("sub_mchid", subMchid);
         String queryString = WXPayUtility.urlEncode(args);
         if (!queryString.isEmpty()) {
             uri = uri + "?" + queryString;
         }
         Request.Builder reqBuilder = new Request.Builder().url(wechatPayApiHost + uri);
         reqBuilder.addHeader("Accept", "application/json");
-        reqBuilder.addHeader("Wechatpay-Serial", config.getWechatPayPublicKeyId());
-        reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(config.getWechatMchId(), config.getMerchantSerialNumber(), privateKey, GETMETHOD, uri, null));
+        reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
+        reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(spMchId, merchantSerialNumber, partnerPrivateKey, GETMETHOD, uri, null));
         reqBuilder.method(GETMETHOD, null);
         OkHttpClient client = new OkHttpClient.Builder()
                 .connectTimeout(10, TimeUnit.SECONDS)
@@ -520,23 +566,20 @@ public class MerchantWechatPaymentStrategyImpl implements MerchantPaymentStrateg
         try (Response httpResponse = client.newCall(reqBuilder.build()).execute()) {
             String respBody = WXPayUtility.extractBody(httpResponse);
             if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
-                WXPayUtility.validateResponse(config.getWechatPayPublicKeyId(), wechatPayPublicKey, httpResponse.headers(), respBody);
+                WXPayUtility.validateResponse(wechatPayPublicKeyId, partnerWechatPayPublicKey, httpResponse.headers(), respBody);
                 return WXPayUtility.fromJson(respBody, WeChatPaymentStrategyImpl.DirectAPIv3QueryResponse.class);
             }
             throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
         }
     }
 
-    private WeChatPaymentStrategyImpl.Refund refundRun(StorePaymentConfig config,
-                                                       PrivateKey privateKey,
-                                                       PublicKey wechatPayPublicKey,
-                                                       WeChatPaymentStrategyImpl.CreateRequest request) throws IOException {
+    private WeChatPaymentStrategyImpl.Refund partnerRefundRun(WeChatPartnerPaymentStrategyImpl.PartnerRefundCreateRequest request) throws IOException {
         String uri = refundPath;
         String reqBody = WXPayUtility.toJson(request);
         Request.Builder reqBuilder = new Request.Builder().url(wechatPayApiHost + uri);
         reqBuilder.addHeader("Accept", "application/json");
-        reqBuilder.addHeader("Wechatpay-Serial", config.getWechatPayPublicKeyId());
-        reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(config.getWechatMchId(), config.getMerchantSerialNumber(), privateKey, POSTMETHOD, uri, reqBody));
+        reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
+        reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(spMchId, merchantSerialNumber, partnerPrivateKey, POSTMETHOD, uri, reqBody));
         reqBuilder.addHeader("Content-Type", "application/json");
         MediaType jsonMediaType = MediaType.parse("application/json; charset=utf-8");
         RequestBody body = RequestBody.create(reqBody, jsonMediaType);
@@ -545,15 +588,37 @@ public class MerchantWechatPaymentStrategyImpl implements MerchantPaymentStrateg
         try (Response httpResponse = client.newCall(reqBuilder.build()).execute()) {
             String respBody = WXPayUtility.extractBody(httpResponse);
             if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
-                WXPayUtility.validateResponse(config.getWechatPayPublicKeyId(), wechatPayPublicKey, httpResponse.headers(), respBody);
+                WXPayUtility.validateResponse(wechatPayPublicKeyId, partnerWechatPayPublicKey, httpResponse.headers(), respBody);
                 return WXPayUtility.fromJson(respBody, WeChatPaymentStrategyImpl.Refund.class);
             }
             throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
         }
     }
 
-    /** 仅在使用处将证书 byte[] 转为 String(UTF-8),不参与存储,保证存取一致 */
-    private static String bytesToUtf8String(byte[] bytes) {
-        return bytes == null ? null : new String(bytes, StandardCharsets.UTF_8);
+    private static String resolveNotifyUrl(String primary, String fallback) {
+        if (StringUtils.isNotBlank(primary)) {
+            return primary.trim();
+        }
+        if (StringUtils.isNotBlank(fallback)) {
+            return fallback.trim();
+        }
+        return "";
+    }
+
+    private String resolveSubMchidFromStore(Integer storeId) {
+        if (storeId == null) {
+            log.warn("[MerchantWechat] storeId 为空,无法解析 wechat_sub_mchid");
+            return null;
+        }
+        StoreInfo info = storeInfoMapper.selectById(storeId);
+        if (info == null) {
+            log.warn("[MerchantWechat] 门店不存在 storeId={}", storeId);
+            return null;
+        }
+        if (StringUtils.isBlank(info.getWechatSubMchid())) {
+            log.warn("[MerchantWechat] 门店未配置 wechat_sub_mchid storeId={}", storeId);
+            return null;
+        }
+        return info.getWechatSubMchid().trim();
     }
 }

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

@@ -0,0 +1,1409 @@
+/**
+ * 生产环境:从 UAT 晋升 jar(及 lib),并可选同步 bootstrap-prod.yml 到生产目录。
+ *
+ * 【唯一维护点】getServiceDefinitions() 内的 registry 列表(勿用顶层 def,避免 Jenkins CPS 缺 SERVICE_REGISTRY)。
+ * 命名约定(自动派生,一般无需改):module=alien-<prodDir>,uatDir=<prodDir>-uat,container=<prodDir>-produ,
+ * 生产宿主机制品子目录与 prodDir 同名的短名(如 store、lawyer、store-platform):<PROD_DEPLOY_ROOT>/<prodDir> 下放 jar/lib/config;日志仍用 logs/<prodDir> 以配合现网 compose
+ *
+ * 发版范围(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。
+ * 【docker compose 启动方式】发版在拷贝 jar/lib 后,在 compose 工作目录下执行
+ *   docker compose(V2 插件)或回退到 docker-compose(V1 独立可执行,未装 compose 插件时常用:unknown shorthand flag f in f 多系未装 V2)
+ *   若缺 compose 子命令:可填 PROD_DOCKER_COMPOSE_V2_PLUGIN(或把宿主机 .../cli-plugins 挂进 Jenkins,脚本会临时 DOCKER_CONFIG 链到该插件)或 PROD_DOCKER_COMPOSE_PATH(V1 可执行件);或依赖 PROD_DOCKER_COMPOSE_DIND_IMAGE 用 docker run 持 socket 在默认 docker:24.0.9-cli 中执行 compose
+ *   同一套参数:-f <PROD_COMPOSE_FILE> --project-directory <yml 所在目录> 以及 restart|up -d --no-deps <服务名>。
+ *   compose 若声明 networks.external(如 common-network-produ),宿主机须已有该网络;PROD_DOCKER_CREATE_EXTERNAL_NETWORKS 可在 compose 前自动 docker network create(填 off 则不自建)。
+ * 工作目录(--project-directory)为 compose 文件所在目录,以解析 yml 内 ./store、./logs 等相对卷。
+ * 现网 yml 示例:/alien_produ/java/docker-compose.yml,项目名可含顶层 name:(如 alien-java-produ),服务名与 container_name 如 gateway-produ、store-produ(须与 getServiceDefinitions() 派生 composeService 一致)。
+ * Jasypt/代理等:yml 内 ${JASYPT_ENCRYPTOR_PASSWORD} 等变量依赖 compose 同目录的 .env(可 ln 到 .env.produ-docker);若 Job 中填写了 PROD_DOCKER_ENV_FILE 临时 env,流水线在 compose 前会 set -a 以 shell source 之,使 compose 时变量可用。
+ * 与 Jenkins 侧挂载:见 docs/jenkins/docker-compose-jenkins-host-network.yml 等(如「/alien_uat/java:/app_deploy_uat」),
+ * 容器内 UAT 根目录为 /app_deploy_uat(UAT_DEPLOY_ROOT 默认);network_mode: host 不改变上述 Jenkins 内路径。
+ * 仅当 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 私有层、生产侧进程读宿主机文件时,unzip 通过仍 corrupt jarfile。
+ *
+ * 生产容器须由 <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。
+ * 目录改为文件后仅 docker restart 常报 not a directory / vice-versa:旧容器 bind 与现路径类型不一致,须 rm 后由 compose 重建;流水线在删误建目录或 restart 报此类错时会重建(AUTO_RECREATE_IF_MOUNT_MISMATCH)。
+ * 七个 *-produ 一次性处理:宿主机执行 docs/jenkins/fix-prod-stale-jar-dirs.sh(或读其末尾注释里的手敲命令)。
+ * 容器报 Invalid or corrupt jarfile:宿主机挂载文件须为合法 jar(非空、非目录)。勿在 jar 未就绪时 -v 挂载(易生成空占位);
+ * 修复后删容器并 docker compose 重建。流水线在 restart 前会对 jar 做 unzip -t 校验。
+ * 若仍报 corrupt:常见原因是容器创建时 -v 指向的路径与当前 PROD_DEPLOY_ROOT 下 jar 不一致——bind mount 不随 hot restart 更新,须 docker rm 后 compose up。
+ * 流水线可比对 docker inspect 的挂载源与本次 jar 路径;若开启 AUTO_RECREATE_IF_MOUNT_MISMATCH 将先删容器再 `docker compose up`。
+ * 对需支付证书的服务(store 等):若宿主机已有证书目录(CERT_VOL=1)但现有容器未挂 /usr/local/alien/aliPayCert 或挂载源与本次 PAY_MOUNT 不一致,同样依赖 AUTO_RECREATE 先删后 up;仅 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 目标一致)。
+ * 生产 compose 中:将「宿主机」证书目录挂到 /usr/local/alien/aliPayCert(应用读 /usr/local/alien/aliPayCert/apiclient_key.pem);流水线比对期望路径与现有容器 mount。
+ * 约定源目录为 PROD_DEPLOY_ROOT/<prodDir>/alien/aliPayCert/;若证书实际在 <prodDir>/alien/ 根下也会自动识别。
+ * 常见错误:把 .../alien 挂到容器 /usr/local/alien/aliPayCert(会多一层,pem 落在 .../aliPayCert/aliPayCert/ 下)——须挂 .../alien/aliPayCert,或留空让脚本自动选。
+ * 若填写 PROD_PAY_CERT_HOST 则全局覆盖。仅先 docker rm 再 docker compose up 会按 yml 带齐卷,纯 restart 不会。适用:store、store-platform、lawyer、dining。
+ * 应用日志:与生产 compose 一致,宿主机 <PROD_DEPLOY_ROOT>/logs/<prodDir> -> 容器 /app/logs(与 LOGGING_PATH=/app/logs 一致)。
+ * yml 须挂上述日志卷;已存在容器若未挂 /app/logs 或路径不一致,开启 AUTO_RECREATE 时将先删后 compose up 重建,否则仅 WARN。
+ * 时区/TZ 由生产 compose 的 environment 等决定;参数 PROD_CONTAINER_TZ 不注入容器。
+ *
+ * 【若界面仍为 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 里再「手动添加参数化构建」与脚本重复。
+ */
+
+/**
+ * 端口约定:与现网 docker-compose 中 *-produ 映射一致(gateway 8000;store/second/… 见 getServiceDefinitions)。
+ * 列表数据放在 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",
+            // docker-compose.yml 内 services: 的键,须与 yml 一致,一般为 <prodDir>-produ
+            composeService: "${pd}-produ",
+            bootstrapRel: "${m}/src/main/resources/bootstrap-prod.yml",
+    ]
+}
+
+def getServiceDefinitions() {
+    // 与现网 /alien_produ/java/docker-compose.yml 中各 *-produ 服务 ports 一致(例:store-produ 50014:50014、lawyer-produ 50017:50017、job 50108:50108)
+    def registry = [
+            [prodDir: 'gateway',         serverPort: '8000',  hostPort: '8000'],
+            [prodDir: 'store',           serverPort: '50014', hostPort: '50014'],
+            [prodDir: 'second',          serverPort: '50015', hostPort: '50015'],
+            [prodDir: 'store-platform',  serverPort: '50016', hostPort: '50016'],
+            [prodDir: 'lawyer',          serverPort: '50017', hostPort: '50017'],
+            [prodDir: 'job',             serverPort: '50108', hostPort: '50108'],
+            [prodDir: 'dining',          serverPort: '50019', hostPort: '50019'],
+    ]
+    return registry.collect { expandServiceSpec(it) }
+}
+
+/** 供 shell 中 if [ "x" = "true" ] 使用,避免在 GString 中写 \${params.DRY_RUN} 等触发 Groovy 解析问题 */
+def dryRunForShell() {
+    if (params.DRY_RUN == null) {
+        return 'false'
+    }
+    if (params.DRY_RUN instanceof Boolean) {
+        return (params.DRY_RUN) ? 'true' : 'false'
+    }
+    return 'true'.equalsIgnoreCase(params.DRY_RUN.toString()) ? 'true' : 'false'
+}
+
+/** Jenkins boolean 参数有时为 Boolean,有时为字符串 */
+def isMultiCheckboxChecked(String paramName) {
+    def v = params[paramName]
+    if (v instanceof Boolean) {
+        return v
+    }
+    return 'true'.equalsIgnoreCase(v?.toString())
+}
+
+/** string 参数或 Jenkins Password(hudson.util.Secret)转明文;勿用 toString() 处理 Secret */
+def jasyptParamToPlain(def raw) {
+    if (raw == null) {
+        return ''
+    }
+    if (raw instanceof hudson.util.Secret) {
+        def p = raw.getPlainText()
+        return p != null ? p.trim() : ''
+    }
+    return raw.toString().trim()
+}
+
+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: '当 compose 定义的服务/容器未运行时,是否执行 docker compose up -d --no-deps 创建/启动该服务'
+        )
+        booleanParam(
+                name: 'AUTO_RECREATE_IF_MOUNT_MISMATCH',
+                defaultValue: true,
+                description: '若已存在容器的 jar/证书/日志挂载与本次不一致、缺 JASYPT(旧容器无 env 需重建)、或 jar 被误建为「目录」导致类型不兼容,是否先 docker rm 再 docker compose up(否则仅报错;仅 restart 无法改 bind/卷/ 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_COMPOSE_FILE',
+                defaultValue: '/alien_produ/java/docker-compose.yml',
+                trim: true,
+                description: '生产环境 docker compose 文件路径(与 dockerd 宿主机同一路径,Jenkins 在容器内时常为 /alien_produ/java/docker-compose.yml 且与 PROD_DEPLOY_ROOT 下 yml 一致)'
+        )
+        string(
+                name: 'PROD_JAR_IN_CONTAINER',
+                defaultValue: 'app.jar',
+                trim: true,
+                description: '容器内 jar 目标路径为 /app/<本值>。与 compose 卷右侧一致:单文件挂(如 host.jar:/app/app.jar)时填 app.jar;整目录挂到 /app 且 jar 名为 alien-模块-1.0.0.jar 时,可仍填 app.jar,流水线会再试 /app/alien-xxx-1.0.0.jar。若 yml 显式为其它路径,填全名。'
+        )
+        string(
+                name: 'PROD_DOCKER_COMPOSE_PATH',
+                defaultValue: '',
+                trim: true,
+                description: '可选:V1 独立可执行文件 docker-compose 的绝对路径。非空时最优先。留空则自动试 docker compose、V2 插件+DOCKER_CONFIG、/usr/bin 等'
+        )
+        string(
+                name: 'PROD_DOCKER_COMPOSE_V2_PLUGIN',
+                defaultValue: '',
+                trim: true,
+                description: '可选:宿主机上 Docker Compose v2 插件可执行文件路径(常见为 .../cli-plugins/docker-compose,与跑 dockerd 的机器上一致)。Jenkins 内若缺 compose 子命令,脚本会建临时 DOCKER_CONFIG 并链到该文件再执行 docker compose。可留空,脚本会试 $HOME/.docker 与 /usr/libexec|/usr/lib/.../cli-plugins/docker-compose'
+        )
+        string(
+                name: 'PROD_DOCKER_COMPOSE_DIND_IMAGE',
+                defaultValue: 'docker:24.0.9-cli',
+                trim: true,
+                description: '本机无 compose 时最后回退:docker run 本镜像、挂 docker.sock 与 COMPOSE_DIR,执行与宿主机「docker compose」等价的 compose 子命令。默认 library 官方 docker:24.0.9-cli(含 compose 插件,须能 pull;内网可打 tag 到私库后改此参数)。填 off 关闭回退'
+        )
+        string(
+                name: 'PROD_DOCKER_CREATE_EXTERNAL_NETWORKS',
+                defaultValue: 'common-network-produ',
+                trim: true,
+                description: '逗号分隔。compose 中 external: true 的 networks 在宿主机须已存在,否则 up 会报 external 未找到。对列表中不存在的网执行 docker network create(默认同 yml: common-network-produ)。填 off 关闭自动建网。Swarm/overlay 须手工建网后填 off'
+        )
+        string(
+                name: 'PROD_JRE_IMAGE',
+                defaultValue: 'my-openjdk8-ffmpeg:v1',
+                trim: true,
+                description: 'Verify PROD mount、证书与 Jasypt 路径探测时使用的测试镜像。运行应用镜像由 PROD 侧 docker-compose.yml 的 image: 决定'
+        )
+        string(
+                name: 'PROD_DOCKER_NETWORK',
+                defaultValue: '',
+                trim: true,
+                description: '(保留)现由 compose 管理网络;发版不消费此参数'
+        )
+        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: '可选:全局覆盖——非空时四服务证书与期望的 PROD/.../alien/aliPayCert 做比对;compose 的 volume 与之一致时无 mismatch。仅重建(docker rm + compose up)时生效。路径须与运行 dockerd 的宿主机一致'
+        )
+        string(
+                name: 'PROD_CONTAINER_TZ',
+                defaultValue: 'Asia/Shanghai',
+                trim: true,
+                description: '(保留)时区由生产 compose 的 environment/TZ 等配置;发版不注入本参数至容器'
+        )
+        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 二选一(文件优先)。支持 string 或 Password 参数;非空则写临时文件并在 docker compose 前以 shell set -a source 注入(yml 中 \${JASYPT_*} 可引用)'
+        )
+        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}"
+                    def _cf = (params.PROD_COMPOSE_FILE ?: '').trim() ?: "${env.PROD_DEPLOY_ROOT}/docker-compose.yml"
+                    def _jic = (params.PROD_JAR_IN_CONTAINER ?: 'app.jar').trim() ?: 'app.jar'
+                    echo ">>> PROD_COMPOSE_FILE 实际使用: ${_cf}(docker compose -f 且 --project-directory=所在目录)"
+                    def _dcpP = (params.PROD_DOCKER_COMPOSE_PATH ?: '').trim()
+                    if (_dcpP) {
+                        echo ">>> PROD_DOCKER_COMPOSE_PATH: ${_dcpP}(显式 V1 可执行文件,优先于自动探测)"
+                    } else {
+                        echo ">>> PROD_DOCKER_COMPOSE_PATH: 未填(自动:docker compose → V2 插件+DOCKER_CONFIG → 常见 V1 路径 或 PATH)"
+                    }
+                    def _dcpV2 = (params.PROD_DOCKER_COMPOSE_V2_PLUGIN ?: '').trim()
+                    if (_dcpV2) {
+                        echo ">>> PROD_DOCKER_COMPOSE_V2_PLUGIN: ${_dcpV2}(显式,优先于按路径自动发现)"
+                    } else {
+                        echo ">>> PROD_DOCKER_COMPOSE_V2_PLUGIN: 未填(将试 /var/jenkins_home(root Agent 常见)、JENKINS_HOME、HOME 下 .docker/cli-plugins 及 /usr/lib*、snap 等)"
+                    }
+                    def _dindRaw = (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 _dind = _dindRaw ? _dindRaw : 'docker:24.0.9-cli'
+                    if (!_dind || _dind.equalsIgnoreCase('off') || _dind == '0') {
+                        echo ">>> PROD_DOCKER_COMPOSE_DIND_IMAGE: 已关闭(不尝试 docker run 辅助 compose 镜像)"
+                    } else {
+                        echo ">>> PROD_DOCKER_COMPOSE_DIND_IMAGE: ${_dind}(本机无 compose 时回退,须能 docker pull;见参数说明)"
+                    }
+                    def _enRaw = params.PROD_DOCKER_CREATE_EXTERNAL_NETWORKS
+                    def _enOff = _enRaw != null && ( _enRaw.toString().trim().equalsIgnoreCase('off') || _enRaw.toString().trim() == '0' || _enRaw.toString().trim().equalsIgnoreCase('none') )
+                    def _enList = _enOff ? '' : ( ( _enRaw == null || _enRaw.toString().trim() == '' ) ? 'common-network-produ' : _enRaw.toString().trim() )
+                    if (_enOff) {
+                        echo ">>> PROD_DOCKER_CREATE_EXTERNAL_NETWORKS: off(不自动建网,external 须已存在)"
+                    } else {
+                        echo ">>> PROD_DOCKER_CREATE_EXTERNAL_NETWORKS: ${_enList}(缺失则 create;多网用逗号)"
+                    }
+                    echo ">>> PROD_JAR_IN_CONTAINER: ${_jic} => 容器内 /app/${_jic}(与 yml 卷目标一致)"
+                    echo ">>> 若 PROD_DEPLOY_ROOT 为 /alien_produ/java:Jenkins 须 volume 挂载宿主机与 compose 同路径,否则 cp 的 jar 与生产容器读到的不是同一份文件"
+                    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 jpwAPlain = jasyptParamToPlain(params.JASYPT_ENCRYPTOR_PASSWORD)
+                    def jasyptDefaultAnnounce = "${env.PROD_DEPLOY_ROOT}/.jasypt-encryptor-password"
+                    if (pe) {
+                        echo ">>> PROD_DOCKER_ENV_FILE=${pe}(在 docker compose 前以 shell set -a source 供 yml 中 \${JASYPT_*} 等插值)"
+                    }
+                    if (jpwAPlain.length() > 0) {
+                        echo ">>> 已填写 JASYPT_ENCRYPTOR_PASSWORD(将经临时 env 文件注入)"
+                    }
+                    if (jpf) {
+                        echo ">>> PROD_JASYPT_PASSWORD_FILE=${(jpf)}(将从此文件生成 Jasypt 注入)"
+                    } else {
+                        echo ">>> Jasypt 密钥文件(参数未填时使用默认): ${(jasyptDefaultAnnounce)}"
+                    }
+                }
+            }
+        }
+
+        // 非 DRY_RUN:用轻量临时容器 -v 校验「Jenkins 写入的 PROD 根」与「Dockerd 所见的宿主机路径」一致
+        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 写在容器私有文件系统,生产侧仍可能读宿主机旧/空文件。"
+                            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
+                    def dryRunSh = dryRunForShell()
+
+                    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
+                                JENKINS_DRY_RUN=${dryRunSh}
+                                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 [ "\$JENKINS_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
+                                JENKINS_DRY_RUN=${dryRunSh}
+                                echo ">>> [bootstrap from git] ${s.module}"
+                                if [ ! -f "${srcGit}" ]; then
+                                    echo "ERROR: 缺少文件: ${srcGit}"
+                                    exit 1
+                                fi
+                                if [ "\$JENKINS_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/<prodDir>/ 后,用「PROD 侧」docker compose(如 /alien_produ/java/docker-compose.yml)对 compose 服务 <prodDir>-produ 执行 restart 或 up -d --no-deps。证书不由此流水线拷贝,见 yml。
+        // 需 Jenkins 能连 dockerd(通常 /var/run/docker.sock);compose 相对路径卷须与 PROD 目录一致,且 PROD_JAR_IN_CONTAINER 与 yml 中 /app/xxx 目标一致。
+        stage('Promote JAR from UAT') {
+            steps {
+                script {
+                    def services = getServiceDefinitions()
+                    def selected = filterServices(services)
+                    def dryRunSh = dryRunForShell()
+
+                    def hostEnvFile = (params.PROD_DOCKER_ENV_FILE ?: '').trim()
+                    def jpwStr = jasyptParamToPlain(params.JASYPT_ENCRYPTOR_PASSWORD)
+                    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
+                            JENKINS_HAS_JPW=${hasJpwPlain}
+                            OK=0
+                            if [ -n '${effDockerEnvFile}' ] && [ -f '${effDockerEnvFile}' ]; then OK=1; fi
+                            if [ "\$JENKINS_HAS_JPW" = '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" '${prodJreForJasypt}' -c "test -f /__jd/\$JH_BASE" 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 {
+                        def composeFileForJob = (params.PROD_COMPOSE_FILE ?: '').trim() ?: "${env.PROD_DEPLOY_ROOT}/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("'", "'\\''")
+                        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 = "${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 composeSvc = s.composeService
+                            def jarHostPath = "${dstDir}/${(jar)}"
+                            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 dstPayCertDir = "${dstDir}/alien/aliPayCert"
+                            // payCertHost 不得写进 promoteShell 的 GString(含 PAY_MOUNT="..."),CPS 会报 illegal $;内层 if/elif 整段在 Groovy 中拼接
+                            def payCertHostShellEsc = (payCertHost ?: '').toString().replace('"', '\\"')
+                            def 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 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 = dstPayCertDir.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 = "                                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 + "'"
+                            // 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" +
+                                    "                            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 = """
+                            set -e
+                            #JENKINS_PLACE_EXPORT_ENV
+                            #JENKINS_PLACE_DCP_THREE
+                            #JENKINS_PLACE_DCP_EXT_NETS
+                            #JENKINS_PLACE_ENV_BOOLS
+                            #JENKINS_PLACE_JASYPT_EXPL
+                            #JENKINS_PLACE_ECHO_JARLINE
+
+                            #JENKINS_PLACE_IF_NO_UAT_JAR
+                            #JENKINS_PLACE_ECHO_ERR_UAT_JAR
+                                echo "请先在 UAT 流水线成功部署该模块;若宿主机已有 jar,请检查 UAT_DEPLOY_ROOT(容器内常用 /app_deploy_uat,见 Sync bootstrap 阶段错误说明)。"
+                                #JENKINS_PLACE_MISSING_JAR_DIAG
+                                exit 1
+                            fi
+
+                            if [ "\$JENKINS_DRY_RUN" = 'true' ]; then
+                                if [ ! -f "\$COMPOSE_FILE" ]; then
+                                    echo "[DRY_RUN] WARN: 将使用的 compose 文件目前不存在: \$COMPOSE_FILE"
+                                else
+                                    echo "[DRY_RUN] compose: \$COMPOSE_FILE(--project-directory 为 yml 文件所在父目录,见下方 COMPOSE_DIR=)"
+                                fi
+                                echo "[DRY_RUN] mkdir -p \$PROMOTE_DST_DIR"
+                            #JENKINS_PLACE_DRYRUN_LOGDIR_ECHO
+                                if [ -d "\$JAR_HOST" ]; then
+                                    echo "[DRY_RUN] 检测到 \$JAR_HOST 为目录(非 jar),将按 REMOVE_STALE_JAR_DIR_IF_DIRECTORY rm -rf 或人工删除"
+                                fi
+                            #JENKINS_PLACE_DRYRUN_CP
+                                if [ -d "\$PROMOTE_SRC_LIB" ]; then
+                                    echo "[DRY_RUN] 同步 lib: \$PROMOTE_SRC_LIB -> \$PROMOTE_DST_LIB"
+                                fi
+                                echo "[DRY_RUN] 将执行: docker compose -f \$COMPOSE_FILE --project-directory \$COMPOSE_DIR(restart 或 up -d --no-deps)\$COMPOSE_SVC;非 DRY 时脚本会设 COMPOSE_DIR"
+                                if [ "\$JENKINS_PAY_CERT_SVC" = '1' ]; then
+                                    #JENKINS_PLACE_DRYRUN_PAYCERT_ECHO
+                                fi
+                                #JENKINS_PLACE_DRYRUN_EFF
+                                #JENKINS_PLACE_DRYRUN_JHF_D
+                            else
+                                JAR_WAS_STALE_DIR=0
+                                if [ -d "\$JAR_HOST" ]; then
+                                    if [ "\$JENKINS_STALE_JAR_RM" = 'true' ]; then
+                                        echo ">>> WARN: \$JAR_HOST 为目录而非 jar(常见于 docker -v 时宿主机尚无该文件被误建目录),正在删除: rm -rf"
+                                        rm -rf "\$JAR_HOST"
+                                        JAR_WAS_STALE_DIR=1
+                                    else
+                                        echo "ERROR: \$JAR_HOST 是目录而非 jar 文件,无法覆盖拷贝。"
+                                        echo "请宿主机执行: sudo rm -rf \$JAR_HOST 后重跑;或开启 REMOVE_STALE_JAR_DIR_IF_DIRECTORY=true"
+                                        exit 1
+                                    fi
+                                fi
+                                mkdir -p "\$PROMOTE_DST_DIR"
+                                mkdir -p "\$JENKINS_LOG_DIR"
+                            #JENKINS_PLACE_CP_PROD
+                                if [ -d "\$PROMOTE_SRC_LIB" ]; then
+                                    rm -rf "\$PROMOTE_DST_LIB"
+                                    cp -rf "\$PROMOTE_SRC_LIB" "\$PROMOTE_DST_LIB"
+                                fi
+                                if [ -d "\$JAR_HOST" ]; then
+                                    echo "ERROR: \$JAR_HOST 是目录而非 jar,请删除该目录后重跑流水线"
+                                    exit 1
+                                fi
+                                if [ ! -s "\$JAR_HOST" ]; then
+                                    echo "ERROR: jar 为空或不存在: \$JAR_HOST"
+                                    exit 1
+                                fi
+                                if ! unzip -t "\$JAR_HOST" >/dev/null 2>&1; then
+                                    echo "ERROR: jar 不是有效 zip/jar(可能拷贝不完整或源文件损坏): \$JAR_HOST"
+                                    exit 1
+                                fi
+                                if [ ! -f "\$COMPOSE_FILE" ]; then
+                                    echo "ERROR: 未找到 docker compose 文件: \$COMPOSE_FILE。请将 PROD_COMPOSE_FILE/PROD_DEPLOY_ROOT 与现网 yml 对齐,例如 /alien_produ/java/docker-compose.yml"
+                                    exit 1
+                                fi
+                                COMPOSE_DIR=\$(cd "\$(dirname "\$COMPOSE_FILE")" && pwd)
+                                dcp_ensure_external_nets() {
+                                    if [ -z "\$DCP_EXT_NETS" ]; then
+                                        return 0
+                                    fi
+                                    if ! command -v docker >/dev/null 2>&1; then
+                                        return 0
+                                    fi
+                                    if [ ! -S /var/run/docker.sock ]; then
+                                        return 0
+                                    fi
+                                    echo ">>> 检查/创建 yml 中的 external 网络: \$DCP_EXT_NETS(参数 PROD_DOCKER_CREATE_EXTERNAL_NETWORKS)" >&2
+                                    _DCP_ENS=\$(echo "\$DCP_EXT_NETS" | tr ',' ' ')
+                                    for _n in \$_DCP_ENS; do
+                                        _n=\$(echo "\$_n" | tr -d '[:space:]')
+                                        [ -z "\$_n" ] && continue
+                                        if docker network inspect "\$_n" >/dev/null 2>&1; then
+                                            echo ">>> 已存在: \$_n" >&2
+                                        else
+                                            echo ">>> 正在创建: \$_n" >&2
+                                            docker network create "\$_n" || { echo "ERROR: docker network create 失败: \$_n(可宿主机手建: docker network create \$_n)" >&2; return 1; }
+                                        fi
+                                    done
+                                    return 0
+                                }
+                                dcp_try_v2_plugin() {
+                                    _plug="\$1"
+                                    [ -n "\$_plug" ] || return 1
+                                    [ -e "\$_plug" ] || return 1
+                                    [ -f "\$_plug" ] || [ -L "\$_plug" ] || return 1
+                                    _pl_run="\$_plug"
+                                    if [ -f "\$_plug" ] && [ ! -x "\$_plug" ]; then
+                                        _pl_run=\$(mktemp) || return 1
+                                        cp -f "\$_plug" "\$_pl_run" && chmod a+x "\$_pl_run" 2>/dev/null || { rm -f "\$_pl_run" 2>/dev/null; return 1; }
+                                    fi
+                                    _dcp_cfg=\$(mktemp -d) || { [ "\$_pl_run" != "\$_plug" ] && rm -f "\$_pl_run" 2>/dev/null; return 1; }
+                                    mkdir -p "\$_dcp_cfg/cli-plugins" || { rm -rf "\$_dcp_cfg" 2>/dev/null; [ "\$_pl_run" != "\$_plug" ] && rm -f "\$_pl_run" 2>/dev/null; return 1; }
+                                    ln -sf "\$_pl_run" "\$_dcp_cfg/cli-plugins/docker-compose" 2>/dev/null || { rm -rf "\$_dcp_cfg" 2>/dev/null; [ "\$_pl_run" != "\$_plug" ] && rm -f "\$_pl_run" 2>/dev/null; return 1; }
+                                    if DOCKER_CONFIG="\$_dcp_cfg" docker compose version >/dev/null 2>&1; then
+                                        DOCKER_CONFIG="\$_dcp_cfg" docker compose -f "\$COMPOSE_FILE" --project-directory "\$COMPOSE_DIR" "\$@"
+                                        _dcp_r=\$?
+                                    else
+                                        _dcp_r=1
+                                    fi
+                                    rm -rf "\$_dcp_cfg" 2>/dev/null
+                                    [ "\$_pl_run" != "\$_plug" ] && rm -f "\$_pl_run" 2>/dev/null
+                                    return \$_dcp_r
+                                }
+                                dcp_dind() {
+                                    if [ -z "\$DCP_DIND_IMAGE" ] || { [ -z "\$COMPOSE_DIR" ] || [ -z "\$COMPOSE_FILE" ]; }; then
+                                        return 1
+                                    fi
+                                    if [ \$# -eq 0 ]; then
+                                        echo "ERROR: dcp 调用 dcp_dind 时未传入 compose 子命令(如 up -d、restart),属于脚本缺陷;应使用 dcp_dind \\\"\\\$@\\\"" >&2
+                                        return 1
+                                    fi
+                                    if ! command -v docker >/dev/null 2>&1; then
+                                        return 1
+                                    fi
+                                    if [ ! -S /var/run/docker.sock ]; then
+                                        echo "ERROR: dcp 回退需要可访问的 /var/run/docker.sock" >&2
+                                        return 1
+                                    fi
+                                    if ! docker image inspect "\$DCP_DIND_IMAGE" >/dev/null 2>&1; then
+                                        echo ">>> dcp: pull 辅助 compose 镜像(本地无缓存,略慢,见 PROD_DOCKER_COMPOSE_DIND_IMAGE)" >&2
+                                        docker pull "\$DCP_DIND_IMAGE" || { echo "ERROR: docker pull 失败:请预拉或改 PROD_DOCKER_COMPOSE_DIND_IMAGE(内网可 tag 到私库)" >&2; return 1; }
+                                    fi
+                                    DIND_MERGE="/tmp/dcp_dind_merge_\$\$.env"
+                                    umask 077
+                                    if ! :> "\$DIND_MERGE" 2>/dev/null; then
+                                        return 1
+                                    fi
+                                    if [ -n "\$JENKINS_PROD_ENV_FILE" ] && [ -f "\$JENKINS_PROD_ENV_FILE" ]; then
+                                        cat "\$JENKINS_PROD_ENV_FILE" >> "\$DIND_MERGE" 2>/dev/null || true
+                                    fi
+                                    if [ -n "\$JEP" ] && [ -f "\$JEP" ]; then
+                                        { echo ""; cat "\$JEP"; } >> "\$DIND_MERGE" 2>/dev/null || true
+                                    fi
+                                    echo ">>> dcp 回退: docker run 执行 compose 子命令: \\\$*(须含 up/restart 等,否则只打印帮助)" >&2
+                                    DIND_LOG="/tmp/dcp_dind_out_\$\$.log"
+                                    set +e
+                                    if [ -s "\$DIND_MERGE" ]; then
+                                        docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v "\$COMPOSE_DIR":"\$COMPOSE_DIR" -w "\$COMPOSE_DIR" --env-file "\$DIND_MERGE" "\$DCP_DIND_IMAGE" compose -f "\$COMPOSE_FILE" --project-directory "\$COMPOSE_DIR" "\$@" > "\$DIND_LOG" 2>&1
+                                    else
+                                        docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v "\$COMPOSE_DIR":"\$COMPOSE_DIR" -w "\$COMPOSE_DIR" "\$DCP_DIND_IMAGE" compose -f "\$COMPOSE_FILE" --project-directory "\$COMPOSE_DIR" "\$@" > "\$DIND_LOG" 2>&1
+                                    fi
+                                    _dr=\$?
+                                    set -e
+                                    echo ">>> dcp_dind 完整输出(----):" >&2
+                                    cat "\$DIND_LOG" 2>/dev/null || true
+                                    echo ">>> (---- 结束)" >&2
+                                    if [ \$_dr -eq 0 ] && grep -qE 'Usage:[[:space:]]*docker compose' "\$DIND_LOG" 2>/dev/null; then
+                                        if ! grep -qEi 'Creating|Recreating|Starting|Restarting|Container.*Started|Pulling' "\$DIND_LOG" 2>/dev/null; then
+                                            echo "ERROR: compose 仅输出 Usage 帮助、未出现创建/启动容器等关键字;几乎总是未传入 up/restart 子命令" >&2
+                                            _dr=1
+                                        fi
+                                    fi
+                                    if [ \$_dr -ne 0 ] && [ -f "\$DIND_LOG" ]; then
+                                        if grep -qiE 'declared as external, but could not be found' "\$DIND_LOG" 2>/dev/null; then
+                                            echo "ERROR: yml 中 external 网络在宿主机不存在。请设 PROD_DOCKER_CREATE_EXTERNAL_NETWORKS=网名(或 pipeline 已默认预建 common-network-produ);或宿主机: docker network create <名>" >&2
+                                        else
+                                            echo "ERROR: dcp_dind 失败 exit=\$_dr。见上方面板完整输出" >&2
+                                        fi
+                                    elif [ \$_dr -ne 0 ]; then
+                                        echo "ERROR: dcp_dind 失败 exit=\$_dr(无日志文件,异常)" >&2
+                                    fi
+                                    rm -f "\$DIND_LOG" 2>/dev/null
+                                    rm -f "\$DIND_MERGE" 2>/dev/null
+                                    return \$_dr
+                                }
+                                dcp() {
+                                    if [ -n "\$DCP_EXPLICIT" ] && [ -f "\$DCP_EXPLICIT" ] && [ -x "\$DCP_EXPLICIT" ]; then
+                                        "\$DCP_EXPLICIT" -f "\$COMPOSE_FILE" --project-directory "\$COMPOSE_DIR" "\$@"
+                                        return \$?
+                                    fi
+                                    if docker compose version >/dev/null 2>&1; then
+                                        docker compose -f "\$COMPOSE_FILE" --project-directory "\$COMPOSE_DIR" "\$@"
+                                        return \$?
+                                    fi
+                                    if [ -n "\$DCP_V2_PLUGIN" ]; then
+                                        dcp_try_v2_plugin "\$DCP_V2_PLUGIN" && return 0
+                                    fi
+                                    for _plug in /var/jenkins_home/.docker/cli-plugins/docker-compose "\$JENKINS_HOME/.docker/cli-plugins/docker-compose" "\$HOME/.docker/cli-plugins/docker-compose" /usr/libexec/docker/cli-plugins/docker-compose /usr/lib/docker/cli-plugins/docker-compose /snap/docker/current/usr/libexec/docker/cli-plugins/docker-compose; do
+                                        dcp_try_v2_plugin "\$_plug" && return 0
+                                    done
+                                    _dc=
+                                    for _p in /usr/bin/docker-compose /usr/local/bin/docker-compose /opt/docker-compose/docker-compose /snap/bin/docker-compose; do
+                                        if [ -x "\$_p" ] 2>/dev/null; then
+                                            if "\$_p" version >/dev/null 2>&1 || "\$_p" --version >/dev/null 2>&1; then
+                                                _dc="\$_p"
+                                                break
+                                            fi
+                                        fi
+                                    done
+                                    if [ -z "\$_dc" ] && command -v docker-compose >/dev/null 2>&1; then
+                                        _dc=\$(command -v docker-compose)
+                                    fi
+                                    if [ -n "\$_dc" ] && [ -x "\$_dc" ] && { "\$_dc" version >/dev/null 2>&1 || "\$_dc" --version >/dev/null 2>&1; }; then
+                                        "\$_dc" -f "\$COMPOSE_FILE" --project-directory "\$COMPOSE_DIR" "\$@"
+                                        return \$?
+                                    fi
+                                    if [ -n "\$DCP_DIND_IMAGE" ] && dcp_dind "\$@"; then
+                                        return 0
+                                    fi
+                                    echo "ERROR: 本机无可用 Docker Compose,且 docker run 回退未成功。可:1) 填 PROD_DOCKER_COMPOSE_DIND_IMAGE(默认可 pull 的 compose 工具镜像,勿填 off)并保证联网 pull;2) 填 PROD_DOCKER_COMPOSE_PATH(V1 1.28+)或 PROD_DOCKER_COMPOSE_V2_PLUGIN;3) 在 Jenkins 中安装 docker compose 插件/复制 cli-plugins" >&2
+                                    return 1
+                                }
+                                dcp_ensure_external_nets
+                                ENV_FILE_OPT=""
+                                #JENKINS_PLACE_ENV_FILE_OPT
+                                EXTRA_JASYPT_ENV_OPT=""
+                                #JENKINS_PLACE_JHF
+                                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" "\$JRE_IMG" -c "test -f /__jd/\$JH_BASE" 2>/dev/null; then
+                                    umask 077
+                                    PW=\$(docker run --rm --entrypoint sh -v "\$JH_DIR:/__jd:ro" "\$JRE_IMG" -c "head -n 1 /__jd/\$JH_BASE 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 [ "\$JENKINS_JASYPT_EXPL" = '1' ]; then
+                                    #JENKINS_PLACE_JASYPT_WARN_EXPL_UNREAD
+                                fi
+                                dcp_bootstrap_env() {
+                                    if [ -n "\$JENKINS_PROD_ENV_FILE" ] && [ -f "\$JENKINS_PROD_ENV_FILE" ]; then
+                                        set -a
+                                        if ! . "\$JENKINS_PROD_ENV_FILE" 2>/dev/null; then
+                                            echo "ERROR: 无法以 shell source 读取: \$JENKINS_PROD_ENV_FILE(须为 KEY=VALUE 行;UTF-8)"
+                                            exit 1
+                                        fi
+                                        set +a
+                                    fi
+                                    if [ -n "\$JEP" ] && [ -f "\$JEP" ]; then
+                                        set -a
+                                        . "\$JEP" || { echo "ERROR: 无法 source 临时 Jasypt env: \$JEP"; exit 1; }
+                                        set +a
+                                    fi
+                                }
+                                PAY_MOUNT=""
+                                if [ "\$JENKINS_PAY_CERT_SVC" = '1' ]; then
+                                    #JENKINS_PLACE_PAY_CERT_MOUNT_INNER
+                                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 [ "\$JENKINS_PAY_CERT_SVC" = '1' ] && [ "\$CERT_VOL" -eq 1 ] && [ "\$PROMOTE_PROD_DIR" = "store" ]; then
+                                    if ! docker image inspect "\$JRE_IMG" >/dev/null 2>&1; then
+                                        docker pull "\$JRE_IMG" >/dev/null 2>&1 || true
+                                    fi
+                                    if ! docker run --rm --entrypoint sh -v "\$PAY_MOUNT:/__c:ro" "\$JRE_IMG" -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 所在机器上的绝对路径;3)生产 compose 中证书卷左值应为 .../store/alien/aliPayCert 而非 .../alien。"
+                                        exit 1
+                                    fi
+                                    echo ">>> store 证书目录已通过 Docker 挂载探测(apiclient_key.pem 对 daemon 可见)"
+                                fi
+                                if [ "\$JENKINS_PAY_CERT_SVC" = '1' ] && [ "\$CERT_VOL" -eq 0 ]; then
+                                    echo ">>> WARN: \$PROMOTE_PROD_DIR 常需证书:未配置 PROD_PAY_CERT_HOST 且 \$JENKINS_PAY_CERT_DIR 不存在或为空,yml/compose 可能未挂 /usr/local/alien/aliPayCert"
+                                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 " \$C_PRIMARY "; then
+                                    CNAME="\$C_PRIMARY"
+                                elif echo " \${NAMES} " | grep -Fq " \$C_FALLBACK "; then
+                                    CNAME="\$C_FALLBACK"
+                                fi
+                                RECREATE=0
+                                if [ -n "\$CNAME" ]; then
+                                    FMT_MNT=\$(printf '{{range .Mounts}}{{if eq .Destination "%s"}}{{.Source}}{{end}}{{end}}' "\$JCP")
+                                    ACTUAL_SRC=\$(docker inspect -f "\$FMT_MNT" "\$CNAME" 2>/dev/null | head -1)
+                                    MISMATCH=0
+                                    if [ -n "\$ACTUAL_SRC" ] && [ "\$ACTUAL_SRC" != "\$JAR_HOST" ]; then
+                                        if command -v realpath >/dev/null 2>&1; then
+                                            R1=\$(realpath "\$ACTUAL_SRC" 2>/dev/null || echo "\$ACTUAL_SRC")
+                                            R2=\$(realpath "\$JAR_HOST" 2>/dev/null || echo "\$JAR_HOST")
+                                            [ "\$R1" != "\$R2" ] && MISMATCH=1
+                                        else
+                                            MISMATCH=1
+                                        fi
+                                    fi
+                                    if [ "\$MISMATCH" -eq 1 ] && [ -n "\$ACTUAL_SRC" ]; then
+                                        if [ "\$JENKINS_AUTO_RECREATE" = 'true' ]; then
+                                            echo ">>> WARN: 容器 \$CNAME 的 \$JCP 挂载源与本次制品路径不一致,将删除并 compose 重建(挂载源=\$ACTUAL_SRC,本次=\$JAR_HOST)"
+                                            docker rm -f "\$CNAME"
+                                            CNAME=""
+                                            RECREATE=1
+                                        else
+                                            echo "ERROR: 容器 \$CNAME 的 \$JCP 挂载源为 \$ACTUAL_SRC,本次流水线 jar 为 \$JAR_HOST。"
+                                            echo "仅 restart 不会更改 bind mount。请执行 docker rm 后 dcp up,或开启 AUTO_RECREATE_IF_MOUNT_MISMATCH。"
+                                            exit 1
+                                        fi
+                                    fi
+                                fi
+                                if [ -n "\$CNAME" ] && [ "\$JENKINS_PAY_CERT_SVC" = '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 [ "\$JENKINS_AUTO_RECREATE" = '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 "\$JENKINS_LOG_DIR" 2>/dev/null || echo "\$JENKINS_LOG_DIR")
+                                        [ "\$LR1" != "\$LR2" ] && LOG_MM=1
+                                    else
+                                        [ "\$LOG_SRC" != "\$JENKINS_LOG_DIR" ] && LOG_MM=1
+                                    fi
+                                    if [ "\$LOG_MM" -eq 1 ]; then
+                                        if [ "\$JENKINS_AUTO_RECREATE" = 'true' ]; then
+                                            echo ">>> WARN: 容器 \$CNAME 未挂载 /app/logs 或源路径与 compose 不一致(期望 \$JENKINS_LOG_DIR,实际=\${LOG_SRC:-无}),将删除并以日志卷重建"
+                                            docker rm -f "\$CNAME"
+                                            CNAME=""
+                                            RECREATE=1
+                                        else
+                                            echo ">>> WARN: 容器 \$CNAME 未挂宿主日志目录 \$JENKINS_LOG_DIR 到 /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 [ "\$JENKINS_AUTO_RECREATE" = 'true' ]; then
+                                        echo ">>> WARN: 宿主 jar 已由「目录」改为文件,旧容器 bind 类型不兼容,删除并以 compose 重建: \$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 [ "\$JENKINS_AUTO_RECREATE" = 'true' ]; then
+                                                echo ">>> WARN: 容器 \$CNAME 无 JASYPT_ENCRYPTOR_PASSWORD 环境,删除并以 compose 重建(dcp 前会 source 临时 env/PROD 文件,且 yml 中须能注入)"
+                                                docker rm -f "\$CNAME"
+                                                CNAME=""
+                                                RECREATE=1
+                                            else
+                                                echo "ERROR: 容器 \$CNAME 无 JASYPT_ENCRYPTOR_PASSWORD。请开启 AUTO_RECREATE_IF_MOUNT_MISMATCH 或修正 compose 的 .env/ environment。"
+                                                exit 1
+                                            fi
+                                        fi
+                                    fi
+                                fi
+                                if [ -n "\$CNAME" ]; then
+                                    RS_TMP="/tmp/jenkins_restart_err_\$\$"
+                                    dcp_bootstrap_env
+                                    if dcp restart "\$COMPOSE_SVC" 2>"\$RS_TMP"; then
+                                        echo ">>> [compose: \$COMPOSE_SVC] 已 docker compose restart;容器名: \$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|/app/)|failed to create (task|shim)'; then
+                                            if [ "\$JENKINS_AUTO_RECREATE" = 'true' ]; then
+                                                echo ">>> WARN: compose restart 因挂载/类型失败,删除并重建: \$CNAME / \$COMPOSE_SVC"
+                                                docker rm -f "\$CNAME" 2>/dev/null || true
+                                                CNAME=""
+                                                RECREATE=1
+                                            else
+                                                echo "\$RS_ERR"
+                                                echo "ERROR: 请开启 AUTO_RECREATE_IF_MOUNT_MISMATCH 或手动删容器后由 compose 重建。"
+                                                exit 1
+                                            fi
+                                        else
+                                            UPLOG=\$(mktemp) || { echo "ERROR: mktemp 失败"; exit 1; }
+                                            if dcp up -d --no-deps "\$COMPOSE_SVC" 2>"\$UPLOG"; then
+                                                echo ">>> [compose: \$COMPOSE_SVC] restart 失败,已用 docker compose up -d 拉起"
+                                                CNAME="\$C_PRIMARY"
+                                                rm -f "\$UPLOG" 2>/dev/null || true
+                                            else
+                                                echo "\$RS_ERR" >&2
+                                                cat "\$UPLOG" 2>/dev/null || true
+                                                rm -f "\$UPLOG" 2>/dev/null || true
+                                                echo "ERROR: docker compose up 失败: \$COMPOSE_SVC。请核对该服务是否在 yml 中、compose 与 dockerd 同机。"
+                                                exit 1
+                                            fi
+                                        fi
+                                    fi
+                                fi
+                                DID_TRY_CREATE=0
+                                if [ -z "\$CNAME" ] && { [ "\$JENKINS_AUTO_CREATE" = 'true' ] || [ "\$RECREATE" -eq 1 ]; }; then
+                                    DID_TRY_CREATE=1
+                                    echo ">>> 通过 docker compose 创建/启动: \$COMPOSE_SVC(见 \$COMPOSE_FILE)"
+                                    dcp_bootstrap_env
+                                    if dcp up -d --no-deps "\$COMPOSE_SVC"; then
+                                        echo ">>> [compose: \$COMPOSE_SVC] 已 up -d,容器名通常 \$C_PRIMARY。CNAME 指向 \$C_PRIMARY"
+                                        CNAME="\$C_PRIMARY"
+                                    else
+                                        echo "ERROR: docker compose up 失败: \$COMPOSE_SVC。请核对该服务是否在 yml 中、镜像/卷路径是否可访问。"
+                                        exit 1
+                                    fi
+                                fi
+                                if [ -z "\$CNAME" ] && [ "\$DID_TRY_CREATE" -eq 0 ] && [ "\$RECREATE" -eq 0 ]; then
+                                    echo ">>> 未找到现有容器 [\$C_PRIMARY] 或 [\$C_FALLBACK],且未开启自动创建/重建,已仅更新宿主机 jar。请手动作 docker compose -f <yml> up 服务。"
+                                fi
+                                if [ -z "\$CNAME" ] && [ "\$RECREATE" -eq 1 ]; then
+                                    echo "ERROR: 挂载不一致需重建容器,但 compose 启动后未检测到容器(检查 COMPOSE_SVC/日志)。"
+                                    exit 1
+                                fi
+                                if [ -n "\$CNAME" ]; then
+                                    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:-空}(空=无此容器名或 inspect 失败),2s 后再查..."
+                                                sleep 2
+                                                ;;
+                                        esac
+                                    done
+                                    if [ "\$STABLE" -ne 1 ]; then
+                                        echo "ERROR: 约 90s 内未在 \$CNAME 上得到稳定 running。空状态反复出现表示:1) 无此容器 2) container_name 与 yml 不一致 3) compose 未真正 up"
+                                        if ! docker inspect "\$CNAME" >/dev/null 2>&1; then
+                                            echo "ERROR(详细): docker 中无名为「\$CNAME」的容器。若 dcp 曾显示 Usage 无 Creating,多半是 dcp 未把 up/restart 传给 dcp_dind(应已随脚本修复)"
+                                            echo ">>> docker ps -a(前 30 行)"
+                                            docker ps -a 2>&1 | head -30
+                                        else
+                                            docker inspect -f 'Status={{.State.Status}} OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}} Error={{.State.Error}}' "\$CNAME" 2>&1 || true
+                                        fi
+                                        echo ">>> docker logs --tail 80 \$CNAME"
+                                        docker logs --tail 80 "\$CNAME" 2>&1 || true
+                                        exit 1
+                                    fi
+                                    JCP_RESOLVED=
+                                    for j in 1 2 3 4 5 6; do
+                                        for try_dest in "\$JCP" "\$JCP_ALT"; do
+                                            [ -n "\$try_dest" ] || continue
+                                            if docker exec "\$CNAME" sh -c 'test -f "\$1" && test -s "\$1" && ! test -d "\$1"' sh "\$try_dest" 2>/dev/null; then
+                                                JCP_RESOLVED="\$try_dest"
+                                                break 2
+                                            fi
+                                        done
+                                        [ -n "\$JCP_RESOLVED" ] && break
+                                        echo ">>> docker exec 重试 (\$j/6)(JCP=参数路径;JCP_ALT=/app/\$JAR_BASENAME 目录挂常见)"
+                                        sleep 2
+                                    done
+                                    if [ -z "\$JCP_RESOLVED" ]; then
+                                        echo "ERROR: 容器内未找到可读 jar。已试 JCP 与 JCP_ALT。请核对 compose:单文件 target=/app/app.jar 时保持 PROD_JAR_IN_CONTAINER=app.jar;整目录挂 /app 且与制品同名 jar 为 \$JAR_BASENAME 时脚本已试次选路径。其它命名请把 PROD_JAR_IN_CONTAINER 设成 yml 右侧 basename。"
+                                        docker exec "\$CNAME" sh -c "ls -la /app" 2>&1 || true
+                                        echo ">>> docker logs --tail 40 \$CNAME:"
+                                        docker logs --tail 40 "\$CNAME" 2>&1 || true
+                                        exit 1
+                                    fi
+                                    if [ "\$JCP_RESOLVED" != "\$JCP" ]; then
+                                        echo ">>> 已解析容器内实际 jar: \$JCP_RESOLVED(与默认 /app/app.jar 或 PROD_JAR_IN_CONTAINER 不一致为正常:yml 常将目录挂到 /app)"
+                                    fi
+                                    FMT2=\$(printf '{{range .Mounts}}{{if eq .Destination "%s"}}{{.Source}}{{end}}{{end}}' "\$JCP_RESOLVED")
+                                    ACTUAL_SRC2=\$(docker inspect -f "\$FMT2" "\$CNAME" 2>/dev/null | head -1)
+                                    echo ">>> 单文件型挂载校验: Dest=\$JCP_RESOLVED -> Source=\${ACTUAL_SRC2:-(无/或为目录挂 /app 属正常)} 期望宿主机 jar: \$JAR_HOST"
+                                    if [ -n "\$ACTUAL_SRC2" ] && [ "\$ACTUAL_SRC2" != "\$JAR_HOST" ]; then
+                                        if command -v realpath >/dev/null 2>&1; then
+                                            R1=\$(realpath "\$ACTUAL_SRC2" 2>/dev/null || echo "\$ACTUAL_SRC2")
+                                            R2=\$(realpath "\$JAR_HOST" 2>/dev/null || echo "\$JAR_HOST")
+                                            if [ "\$R1" != "\$R2" ]; then
+                                                echo "ERROR: 单文件卷宿主机 Source 与本次 cp 的 jar 仍不一致"
+                                                exit 1
+                                            fi
+                                        else
+                                            echo "ERROR: 单文件卷宿主机 Source 与本次 cp 的 jar 仍不一致"
+                                            exit 1
+                                        fi
+                                    fi
+                                    if [ -z "\$ACTUAL_SRC2" ]; then
+                                        echo ">>> 提示: 未在 Mounts 中看到 Destination 精确为 \$JCP_RESOLVED(常为目录 bind /app,可忽略本项)"
+                                    fi
+                                    if [ "\$PROMOTE_PROD_DIR" = "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 与生产 compose 中证书卷。"
+                                            exit 1
+                                        fi
+                                        echo ">>> [\$CNAME] 已确认微信商户私钥文件在容器内可见"
+                                    fi
+                                    JCPR="\$JCP_RESOLVED"
+                                    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 "\$JCPR" >/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 失败(JCPR=\\\$JCPR)"
+                                            exit 1
+                                        fi
+                                    else
+                                        echo ">>> WARN: 镜像内无 unzip,仅校验 PK 魔数"
+                                        for j in 1 2 3 4 5; do
+                                            #JENKINS_PLACE_DOCKER_EXEC_OD_JCPR
+                                                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 校验通过: \$JCPR"
+                                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_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"
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    // 构建结束后的固定输出:always 必跑;success/failure 分别提示后续人工检查点。
+    post {
+        always {
+            echo '>>> 生产晋升任务结束'
+        }
+        success {
+            echo '>>> 流水线成功(请确认生产容器启动参数与 Nacos 等环境一致)'
+        }
+        failure {
+            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 '>>> 若 Jenkins 与生产 Docker 不在同一台机器,本 Job 无法改远程宿主机上的容器;需在生产 dockerd 所在宿主机上核对挂载与 jar,或在该机执行晋升/重建'
+            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 '>>> 若报 network ... declared as external, but could not be found:设 PROD_DOCKER_CREATE_EXTERNAL_NETWORKS 为 yml 中网名(默认预建 common-network-produ),或宿主机 docker network create'
+            echo '>>> 若报容器内 jar 路径校验失败:生产 compose 可能是「目录挂 /app」而非单文件 /app/app.jar;脚本会再试 /app/alien-模块-1.0.0.jar,仍失败请对照 yml 调整 PROD_JAR_IN_CONTAINER'
+        }
+    }
+}