12
0

8 Коммиты 2fb4167d0c ... 98f374bc93

Автор SHA1 Сообщение Дата
  刘云鑫 98f374bc93 feat:小程序支付。 2 недель назад
  刘云鑫 ba81772a2a Merge remote-tracking branch 'origin/sit-new-checkstand' into sit-new-checkstand 2 недель назад
  刘云鑫 5c20831ff8 feat:入驻business_code修改 2 недель назад
  刘云鑫 781c5c1b68 Merge remote-tracking branch 'origin/sit-new-checkstand' into sit-new-checkstand 2 недель назад
  刘云鑫 06f4be3706 feat:微信入驻接口成功+定时查询任务 2 недель назад
  刘云鑫 a4538bfea0 Merge remote-tracking branch 'origin/sit-new-checkstand' into sit-new-checkstand 2 недель назад
  刘云鑫 e25c07569d feat:微信入驻 3 недель назад
  刘云鑫 1e8e86bea3 feat:结账 3 недель назад
27 измененных файлов с 3819 добавлено и 9 удалено
  1. 31 4
      alien-dining/src/main/java/shop/alien/dining/controller/PaymentController.java
  2. 1 1
      alien-dining/src/main/java/shop/alien/dining/strategy/payment/PaymentStrategy.java
  3. 725 0
      alien-dining/src/main/java/shop/alien/dining/strategy/payment/impl/WeChatPartnerPaymentMininProgramStrategyImpl.java
  4. 4 0
      alien-entity/src/main/java/shop/alien/entity/store/StoreInfo.java
  5. 46 0
      alien-entity/src/main/java/shop/alien/entity/store/StoreOrder.java
  6. 14 0
      alien-entity/src/main/java/shop/alien/entity/store/StoreOrderDetail.java
  7. 101 0
      alien-entity/src/main/java/shop/alien/entity/store/WechatPartnerApplyment.java
  8. 29 0
      alien-entity/src/main/java/shop/alien/entity/store/dto/StoreBookingCheckoutLineSubmitDTO.java
  9. 75 0
      alien-entity/src/main/java/shop/alien/entity/store/dto/StoreBookingCheckoutSubmitDTO.java
  10. 42 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/StoreBookingCheckoutLineVo.java
  11. 71 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/StoreBookingCheckoutPrepVo.java
  12. 27 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/StoreBookingCheckoutResultVo.java
  13. 11 0
      alien-entity/src/main/java/shop/alien/mapper/WechatPartnerApplymentMapper.java
  14. 10 0
      alien-job/src/main/java/shop/alien/job/feign/AlienStoreFeign.java
  15. 193 0
      alien-job/src/main/java/shop/alien/job/jobhandler/WechatPartnerApplymentAuditSyncJobHandler.java
  16. 7 3
      alien-store/src/main/java/shop/alien/store/controller/PaymentController.java
  17. 48 0
      alien-store/src/main/java/shop/alien/store/controller/StoreBookingOrderController.java
  18. 117 0
      alien-store/src/main/java/shop/alien/store/controller/WeChatPartnerApplymentController.java
  19. 30 0
      alien-store/src/main/java/shop/alien/store/service/StoreBookingCheckoutService.java
  20. 16 0
      alien-store/src/main/java/shop/alien/store/service/StoreServiceFeeRuleService.java
  21. 1103 0
      alien-store/src/main/java/shop/alien/store/service/WeChatPartnerApplymentService.java
  22. 517 0
      alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingCheckoutServiceImpl.java
  23. 3 0
      alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingOrderServiceImpl.java
  24. 100 0
      alien-store/src/main/java/shop/alien/store/service/impl/StoreServiceFeeRuleServiceImpl.java
  25. 16 0
      alien-store/src/main/java/shop/alien/store/strategy/payment/PaymentStrategy.java
  26. 477 0
      alien-store/src/main/java/shop/alien/store/strategy/payment/impl/WeChatPartnerPaymentStrategyImpl.java
  27. 5 1
      alien-util/src/main/java/shop/alien/util/common/constant/PaymentEnum.java

+ 31 - 4
alien-dining/src/main/java/shop/alien/dining/controller/PaymentController.java

@@ -47,15 +47,15 @@ public class PaymentController {
             @ApiImplicitParam(name = "storeId", value = "店铺ID,用于从 MySQL 获取该店铺支付配置(StorePaymentConfig)", required = true, paramType = "query", dataType = "Integer"),
             @ApiImplicitParam(name ="couponId", value = "优惠券Id"),
             @ApiImplicitParam(name = "payerId", value = "payerId"),
-            @ApiImplicitParam(name = "tablewareFee", value = "餐具费"),
+            @ApiImplicitParam(name = "serviceFee", value = "服务费"),
             @ApiImplicitParam(name = "discountAmount", value = "优惠金额"),
             @ApiImplicitParam(name = "payAmount", value = "支付金额")
     })
     @RequestMapping("/prePay")
-    public R prePay(String price, String subject, String payType, String payer, String orderNo, Integer storeId,Integer couponId, Integer payerId,String tablewareFee,String discountAmount,String payAmount) {
-        log.info("PaymentController:prePay, price: {}, subject: {}, payType: {}, payer: {}, orderNo: {}, storeId: {},couponId:{},payerId: {},tablewareFee: {},discountAmount: {},payAmount:{}", price, subject, payType, payer, orderNo, storeId,couponId,payerId,tablewareFee,discountAmount,payAmount);
+    public R prePay(String price, String subject, String payType, String payer, String orderNo, Integer storeId,Integer couponId, Integer payerId,String serviceFee,String discountAmount,String payAmount) {
+        log.info("PaymentController:prePay, price: {}, subject: {}, payType: {}, payer: {}, orderNo: {}, storeId: {},couponId:{},payerId: {},serviceFee: {},discountAmount: {},payAmount:{}", price, subject, payType, payer, orderNo, storeId,couponId,payerId,serviceFee,discountAmount,payAmount);
         try {
-            return paymentStrategyFactory.getStrategy(payType).createPrePayOrder(price, subject, payer, orderNo, storeId, couponId,payerId,tablewareFee,discountAmount,payAmount);
+            return paymentStrategyFactory.getStrategy(payType).createPrePayOrder(price, subject, payer, orderNo, storeId, couponId,payerId,serviceFee,discountAmount,payAmount);
         } catch (Exception e) {
             log.info("createPrePayOrder, orderNo: {}, error: {}", orderNo, e.getMessage());
             return R.fail(e.getMessage());
@@ -68,6 +68,33 @@ public class PaymentController {
      * @param notifyData 回调 JSON 报文
      * @return 204 无 body 或 5XX + 失败报文
      */
+    /**
+     * 服务商模式小程序支付回调(与直连小程序回调分离,需在商户平台配置 notify_url 指向本地址)
+     */
+    @RequestMapping("/weChatPartnerMininNotify")
+    public ResponseEntity<?> weChatPartnerMininNotify(@RequestBody String notifyData, HttpServletRequest request) throws Exception {
+        log.info("[微信服务商小程序回调] 收到请求, Content-Length={}, Wechatpay-Serial={}",
+                request.getContentLength(), request.getHeader("Wechatpay-Serial"));
+        try {
+            R result = paymentStrategyFactory.getStrategy(PaymentEnum.WECHAT_PAY_PARTNER_MININ_PROGRAM.getType()).handleNotify(notifyData, request);
+            if (R.isSuccess(result)) {
+                return ResponseEntity.noContent().build();
+            }
+            String message = result.getMsg() != null ? result.getMsg() : "失败";
+            Map<String, String> failBody = new HashMap<>(2);
+            failBody.put("code", "FAIL");
+            failBody.put("message", message);
+            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(failBody);
+        } catch (Exception e) {
+            log.error("[微信服务商小程序回调] 处理异常", e);
+            String msg = e.getMessage() != null ? e.getMessage() : "失败";
+            Map<String, String> failBody = new HashMap<>(2);
+            failBody.put("code", "FAIL");
+            failBody.put("message", msg);
+            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(failBody);
+        }
+    }
+
     @RequestMapping("/weChatMininNotify")
     public ResponseEntity<?> notify(@RequestBody String notifyData, HttpServletRequest request) throws Exception {
         log.info("[微信支付回调] 收到回调请求, Content-Length={}, Wechatpay-Serial={}", request.getContentLength(), request.getHeader("Wechatpay-Serial"));

+ 1 - 1
alien-dining/src/main/java/shop/alien/dining/strategy/payment/PaymentStrategy.java

@@ -23,7 +23,7 @@ public interface PaymentStrategy {
      * @return 预支付订单信息
      * @throws Exception 生成异常
      */
-    R createPrePayOrder(String price, String subject, String payer, String orderNo, Integer storeId, Integer couponId, Integer payerId,String tablewareFee,String discountAmount,String payAmount) throws Exception;
+    R createPrePayOrder(String price, String subject, String payer, String orderNo, Integer storeId, Integer couponId, Integer payerId,String serviceFee,String discountAmount,String payAmount) throws Exception;
 
 
     /**

+ 725 - 0
alien-dining/src/main/java/shop/alien/dining/strategy/payment/impl/WeChatPartnerPaymentMininProgramStrategyImpl.java

@@ -0,0 +1,725 @@
+package shop.alien.dining.strategy.payment.impl;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.gson.annotations.SerializedName;
+import com.wechat.pay.java.service.refund.model.QueryByOutRefundNoRequest;
+import com.wechat.pay.java.service.refund.model.Refund;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.*;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import shop.alien.dining.service.StoreOrderService;
+import shop.alien.dining.strategy.payment.PaymentStrategy;
+import shop.alien.dining.util.WXPayUtility;
+import shop.alien.dining.util.WeChatPayUtil;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.LifeDiscountCouponUser;
+import shop.alien.entity.store.StoreInfo;
+import shop.alien.entity.store.StoreOrder;
+import shop.alien.entity.store.StorePaymentConfig;
+import shop.alien.mapper.LifeDiscountCouponUserMapper;
+import shop.alien.mapper.StoreInfoMapper;
+import shop.alien.mapper.StorePaymentConfigMapper;
+import shop.alien.util.common.UniqueRandomNumGenerator;
+import shop.alien.util.common.constant.DiscountCouponEnum;
+import shop.alien.util.common.constant.PaymentEnum;
+import shop.alien.util.system.OSUtil;
+
+import javax.annotation.PostConstruct;
+import javax.servlet.http.HttpServletRequest;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * 微信支付小程序 — 服务商模式(特约商户 JSAPI)
+ * <p>
+ * 下单走 {@code POST /v3/pay/partner/transactions/jsapi},使用服务商证书签名;
+ * {@code sub_mchid} 来自 {@link StoreInfo#getWechatSubMchid()};
+ * 子商户小程序 {@code sub_appid} 优先取门店 {@link StorePaymentConfig},否则取配置项 {@code payment.wechatPartnerPay.business.subAppId}。
+ * </p>
+ *
+ * @see shop.alien.store.strategy.payment.impl.WeChatPartnerPaymentStrategyImpl
+ * @see WeChatPaymentMininProgramStrategyImpl
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class WeChatPartnerPaymentMininProgramStrategyImpl implements PaymentStrategy {
+
+    private final StoreOrderService storeOrderService;
+    private final StorePaymentConfigMapper storePaymentConfigMapper;
+    private final StoreInfoMapper storeInfoMapper;
+    private final LifeDiscountCouponUserMapper lifeDiscountCouponUserMapper;
+    private final ObjectMapper objectMapper;
+
+    @Value("${payment.wechatPartnerPay.host:https://api.mch.weixin.qq.com}")
+    private String wechatPayApiHost;
+
+    /** 服务商 JSAPI(小程序)预下单路径 */
+    @Value("${payment.wechatPartnerPay.miniProgram.prePayPath:/v3/pay/partner/transactions/jsapi}")
+    private String prePayPath;
+
+    @Value("${payment.wechatPartnerPay.searchOrderByOutTradeNoPath:/v3/pay/partner/transactions/out-trade-no/{out_trade_no}}")
+    private String searchOrderByOutTradeNoPath;
+
+    @Value("${payment.wechatPartnerPay.refundPath:/v3/refund/domestic/refunds}")
+    private String refundPath;
+
+    @Value("${payment.wechatPartnerPay.miniProgram.searchRefundStatusByOutRefundNoPath:/v3/refund/domestic/refunds/{out_refund_no}}")
+    private String searchRefundStatusByOutRefundNoPath;
+
+    @Value("${wechat.miniprogram.appId}")
+    private String spAppId;
+
+    @Value("${payment.wechatPartnerPay.business.spMchId}")
+    private String spMchId;
+
+    /** 全局默认子商户小程序 AppID,门店未配置 StorePaymentConfig 时可兜底 */
+    @Value("${payment.wechatPartnerPay.business.subAppId:}")
+    private String defaultSubAppId;
+
+    @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;
+
+    @Value("${payment.wechatPartnerPay.miniProgram.prePayNotifyUrl}")
+    private String prePayNotifyUrl;
+
+    @Value("${payment.wechatPartnerPay.miniProgram.refundNotifyUrl}")
+    private String refundNotifyUrl;
+
+    /** 服务商 APIv3 密钥,用于支付回调 resource 解密(与直连服务商一致) */
+    @Value("${payment.wechatPartnerPay.business.apiV3Key:}")
+    private String apiV3Key;
+
+    private PrivateKey privateKey;
+    private PublicKey wechatPayPublicKey;
+
+    private static final String POST_METHOD = "POST";
+    private static final String GET_METHOD = "GET";
+
+    @PostConstruct
+    public void loadPartnerCertificates() {
+        String keyPath;
+        String pubPath;
+        if ("windows".equals(OSUtil.getOsName())) {
+            keyPath = privateWinKeyPath;
+            pubPath = wechatWinPayPublicKeyFilePath;
+        } else {
+            keyPath = privateLinuxKeyPath;
+            pubPath = wechatLinuxPayPublicKeyFilePath;
+        }
+        log.info("[WeChatPartnerMinin] 加载服务商证书,os={}, privateKeyPath={}", OSUtil.getOsName(), keyPath);
+        this.privateKey = WXPayUtility.loadPrivateKeyFromPath(keyPath);
+        this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(pubPath);
+    }
+
+    private StorePaymentConfig getConfigByStoreId(Integer storeId) {
+        if (storeId == null) {
+            return null;
+        }
+        LambdaQueryWrapper<StorePaymentConfig> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(StorePaymentConfig::getStoreId, storeId);
+        return storePaymentConfigMapper.selectOne(wrapper);
+    }
+
+    /**
+     * 解析特约商户小程序 AppID:门店配置优先,否则全局 defaultSubAppId。
+     */
+    private String resolveSubAppId(StorePaymentConfig config) {
+        if (config != null) {
+            if (org.springframework.util.StringUtils.hasText(config.getWechatMiniAppId())) {
+                return config.getWechatMiniAppId().trim();
+            }
+            if (org.springframework.util.StringUtils.hasText(config.getWechatAppId())) {
+                return config.getWechatAppId().trim();
+            }
+        }
+        if (StringUtils.isNotBlank(defaultSubAppId)) {
+            return defaultSubAppId.trim();
+        }
+        return null;
+    }
+
+    private String resolveSubMchidFromStore(Integer storeId) {
+        if (storeId == null) {
+            log.warn("[WeChatPartnerMinin] storeId 为空,无法解析 wechat_sub_mchid");
+            return null;
+        }
+        StoreInfo info = storeInfoMapper.selectById(storeId);
+        if (info == null) {
+            log.warn("[WeChatPartnerMinin] 门店不存在 storeId={}", storeId);
+            return null;
+        }
+        if (StringUtils.isBlank(info.getWechatSubMchid())) {
+            log.warn("[WeChatPartnerMinin] 门店未配置 wechat_sub_mchid storeId={}", storeId);
+            return null;
+        }
+        return info.getWechatSubMchid().trim();
+    }
+
+    @Override
+    public R createPrePayOrder(String price, String subject, String payer, String orderNo, Integer storeId,
+                                 Integer couponId, Integer payerId, String serviceFee, String discountAmount,
+                                 String payAmount) throws Exception {
+        if (storeId == null) {
+            log.warn("[WeChatPartnerMinin] createPrePayOrder 缺少 storeId");
+            return R.fail("店铺ID不能为空");
+        }
+        String subMchid = resolveSubMchidFromStore(storeId);
+        if (subMchid == null) {
+            return R.fail("请维护门店特约商户号 store_info.wechat_sub_mchid");
+        }
+
+        PartnerJsapiPrepayRequest request = new PartnerJsapiPrepayRequest();
+        request.spAppid = spAppId;
+        request.spMchid = spMchId;
+        request.subMchid = subMchid;
+        request.description = subject;
+        request.notifyUrl = prePayNotifyUrl;
+        request.amount = new CommonAmountInfo();
+        request.amount.total = Long.parseLong(price);
+        request.amount.currency = "CNY";
+        request.payer = new JsapiReqPayerInfo();
+        request.payer.spOpenid = payer.trim();
+
+        String wechatOutTradeNo = orderNo;
+        StoreOrder storeOrder = storeOrderService.getOrderByOrderNo(orderNo);
+        log.info("[WeChatPartnerMinin] createPrePayOrder orderNo={}, storeOrder={}, subMchid={}", orderNo, storeOrder != null, subMchid);
+
+        if (storeOrder != null) {
+            if (storeOrder.getPayStatus() != null && storeOrder.getPayStatus() == 1) {
+                return R.fail("订单已支付");
+            }
+            if (storeOrder.getPayTradeNo() != null) {
+                try {
+                    DirectAPIv3QueryResponse wxOrder = partnerSearchOrderRun(storeOrder.getPayTradeNo(), subMchid);
+                    if (wxOrder != null && "SUCCESS".equals(wxOrder.tradeState)) {
+                        return R.fail("该支付单已在微信侧支付成功,请勿重复发起支付");
+                    }
+                } catch (WXPayUtility.ApiException e) {
+                    if (e.getStatusCode() != 404 && !"ORDER_NOT_EXIST".equals(e.getErrorCode())) {
+                        log.error("[WeChatPartnerMinin] 预支付前查单失败 payTradeNo={}, code={}, msg={}",
+                                storeOrder.getPayTradeNo(), e.getErrorCode(), e.getErrorMessage());
+                        return R.fail("查询微信支付订单失败:" + (e.getErrorMessage() != null ? e.getErrorMessage() : e.getMessage()));
+                    }
+                }
+            }
+            String newPayTradeNo = "WX" + storeOrder.getId() + "_" + System.currentTimeMillis();
+            storeOrder.setPayTradeNo(newPayTradeNo);
+            wechatOutTradeNo = newPayTradeNo;
+            log.info("[WeChatPartnerMinin] 换新商户单号 orderNo={}, payTradeNo={}", orderNo, newPayTradeNo);
+            storeOrder.setCouponId(couponId);
+            storeOrder.setPayUserId(payerId);
+            storeOrder.setServiceFee(new BigDecimal(serviceFee));
+            storeOrder.setDiscountAmount(new BigDecimal(discountAmount));
+            storeOrder.setPayAmount(new BigDecimal(payAmount));
+            if (!storeOrderService.updateById(storeOrder)) {
+                log.error("[WeChatPartnerMinin] 更新订单失败 orderNo={}", orderNo);
+                return R.fail("更新订单失败");
+            }
+        }
+        request.outTradeNo = wechatOutTradeNo;
+
+        try {
+            DirectAPIv3JsapiPrepayResponse response = partnerPrePayOrderRun(request);
+            log.info("[WeChatPartnerMinin] 预下单成功 prepayId={}, outTradeNo={}", response.prepayId, request.outTradeNo);
+
+            Map<String, String> result = new HashMap<>();
+            String prepayForSign = "prepay_id=" + response.prepayId;
+            result.put("prepayId", prepayForSign);
+            result.put("appId", spAppId);
+            result.put("spAppId", spAppId);
+            result.put("spMchId", spMchId);
+            result.put("subMchId", subMchid);
+            result.put("mchId", spMchId);
+            result.put("orderNo", request.outTradeNo);
+
+            long timestamp = System.currentTimeMillis() / 1000;
+            String nonce = WXPayUtility.createNonce(32);
+            String message = String.format("%s\n%s\n%s\n%s\n", spAppId, timestamp, nonce, prepayForSign);
+            Signature sign = Signature.getInstance("SHA256withRSA");
+            sign.initSign(privateKey);
+            sign.update(message.getBytes(StandardCharsets.UTF_8));
+            result.put("signType", "RSA");
+            result.put("sign", Base64.getEncoder().encodeToString(sign.sign()));
+            result.put("timestamp", String.valueOf(timestamp));
+            result.put("nonce", nonce);
+            result.put("transactionId", request.outTradeNo);
+            return R.data(result);
+        } catch (WXPayUtility.ApiException e) {
+            log.error("[WeChatPartnerMinin] 预下单失败 code={}, body={}", e.getErrorCode(), e.getMessage());
+            return R.fail(e.getMessage());
+        }
+    }
+
+    private DirectAPIv3JsapiPrepayResponse partnerPrePayOrderRun(PartnerJsapiPrepayRequest request) {
+        String uri = prePayPath;
+        String reqBody = WXPayUtility.toJson(request);
+        log.debug("[WeChatPartnerMinin] POST {} bodyLen={}", uri, reqBody.length());
+
+        Request.Builder reqBuilder = new Request.Builder().url(wechatPayApiHost + uri);
+        reqBuilder.addHeader("Accept", "application/json");
+        reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
+        reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(spMchId, merchantSerialNumber, privateKey, POST_METHOD, uri, reqBody));
+        reqBuilder.addHeader("Content-Type", "application/json");
+        RequestBody body = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
+        reqBuilder.method(POST_METHOD, body);
+        Request httpRequest = reqBuilder.build();
+
+        OkHttpClient client = new OkHttpClient.Builder().build();
+        try (Response httpResponse = client.newCall(httpRequest).execute()) {
+            String respBody = WXPayUtility.extractBody(httpResponse);
+            if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
+                WXPayUtility.validateResponse(wechatPayPublicKeyId, wechatPayPublicKey, httpResponse.headers(), respBody);
+                return WXPayUtility.fromJson(respBody, DirectAPIv3JsapiPrepayResponse.class);
+            }
+            throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
+        } catch (IOException e) {
+            throw new UncheckedIOException("WeChatPartnerMinin prepay failed: " + uri, e);
+        }
+    }
+
+    private DirectAPIv3QueryResponse partnerSearchOrderRun(String outTradeNo, String subMchid) {
+        String uri = searchOrderByOutTradeNoPath.replace("{out_trade_no}", WXPayUtility.urlEncode(outTradeNo));
+        Map<String, Object> args = new HashMap<>(4);
+        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", wechatPayPublicKeyId);
+        reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(spMchId, merchantSerialNumber, privateKey, GET_METHOD, uri, null));
+        reqBuilder.method(GET_METHOD, null);
+        Request httpRequest = reqBuilder.build();
+
+        OkHttpClient client = new OkHttpClient.Builder().build();
+        try (Response httpResponse = client.newCall(httpRequest).execute()) {
+            String respBody = WXPayUtility.extractBody(httpResponse);
+            if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
+                WXPayUtility.validateResponse(wechatPayPublicKeyId, wechatPayPublicKey, httpResponse.headers(), respBody);
+                return WXPayUtility.fromJson(respBody, DirectAPIv3QueryResponse.class);
+            }
+            throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
+        } catch (IOException e) {
+            throw new UncheckedIOException("WeChatPartnerMinin query order failed: " + uri, e);
+        }
+    }
+
+    @Override
+    public R handleNotify(String notifyData, HttpServletRequest request) throws Exception {
+        log.info("[WeChatPartnerMinin回调] 进入 handleNotify, len={}", notifyData != null ? notifyData.length() : 0);
+        String serial = request.getHeader("Wechatpay-Serial");
+        String signature = request.getHeader("Wechatpay-Signature");
+        String timestamp = request.getHeader("Wechatpay-Timestamp");
+        String nonce = request.getHeader("Wechatpay-Nonce");
+
+        if (serial == null || signature == null || timestamp == null || nonce == null) {
+            log.warn("[WeChatPartnerMinin回调] 验签参数缺失 serial={}, signature={}, timestamp={}, nonce={}",
+                    serial, signature != null, timestamp, nonce);
+            return R.fail("验签参数缺失");
+        }
+
+        if (signature.startsWith("WECHATPAY/SIGNTEST/")) {
+            log.info("[WeChatPartnerMinin回调] 签名探测请求,直接成功");
+            return R.success("OK");
+        }
+
+        if (!StringUtils.equals(serial, wechatPayPublicKeyId)) {
+            log.warn("[WeChatPartnerMinin回调] Wechatpay-Serial 与配置不符 serial={}, expected={}", serial, wechatPayPublicKeyId);
+            return R.fail("公钥序列号不匹配");
+        }
+        if (wechatPayPublicKey == null) {
+            log.error("[WeChatPartnerMinin回调] 平台公钥未加载");
+            return R.fail("平台公钥未就绪");
+        }
+
+        StringBuilder signStr = new StringBuilder();
+        signStr.append(timestamp).append("\n");
+        signStr.append(nonce).append("\n");
+        signStr.append(notifyData).append("\n");
+        Signature sign = Signature.getInstance("SHA256withRSA");
+        byte[] signatureBytes = Base64.getDecoder().decode(signature);
+        sign.initVerify(wechatPayPublicKey);
+        sign.update(signStr.toString().getBytes(StandardCharsets.UTF_8));
+
+        if (!sign.verify(signatureBytes)) {
+            log.warn("[WeChatPartnerMinin回调] 验签失败");
+            return R.fail("Verified error");
+        }
+
+        if (!org.springframework.util.StringUtils.hasText(apiV3Key)) {
+            log.error("[WeChatPartnerMinin回调] 未配置 payment.wechatPartnerPay.business.apiV3Key,无法解密");
+            return R.fail("APIv3 密钥未配置");
+        }
+
+        final String notifyDataCopy = notifyData;
+        CompletableFuture.runAsync(() -> processNotifyBusiness(notifyDataCopy));
+        return R.success("OK");
+    }
+
+    /**
+     * 异步处理支付成功:解密 resource、更新订单与优惠券(与 {@link WeChatPaymentMininProgramStrategyImpl} 行为对齐)
+     */
+    private void processNotifyBusiness(String notifyData) {
+        try {
+            JsonNode rootNode = objectMapper.readTree(notifyData);
+            JsonNode resourceNode = rootNode.get("resource");
+            if (resourceNode == null) {
+                log.warn("[WeChatPartnerMinin回调] 报文无 resource");
+                return;
+            }
+            String encryptAlgorithm = resourceNode.get("algorithm").asText();
+            String resourceNonce = resourceNode.get("nonce").asText();
+            String associatedData = resourceNode.has("associated_data") && !resourceNode.get("associated_data").isNull()
+                    ? resourceNode.get("associated_data").asText() : "";
+            String ciphertext = resourceNode.get("ciphertext").asText();
+            if (!"AEAD_AES_256_GCM".equals(encryptAlgorithm)) {
+                log.warn("[WeChatPartnerMinin回调] 不支持的算法 {}", encryptAlgorithm);
+                return;
+            }
+            String plainBusinessData = WeChatPayUtil.decrypt(apiV3Key, resourceNonce, associatedData, ciphertext);
+            log.info("[WeChatPartnerMinin回调] 解密成功,业务数据长度={}", plainBusinessData.length());
+            JSONObject jsonObject = JSONObject.parseObject(plainBusinessData);
+            String tradeState = jsonObject.getString("trade_state");
+            if (!"SUCCESS".equals(tradeState)) {
+                log.info("[WeChatPartnerMinin回调] trade_state 非 SUCCESS: {}", tradeState);
+                return;
+            }
+            String outTradeNo = jsonObject.getString("out_trade_no");
+            StoreOrder storeOrder = storeOrderService.getOne(new QueryWrapper<StoreOrder>().eq("order_no", outTradeNo));
+            if (storeOrder == null && org.springframework.util.StringUtils.hasText(outTradeNo)) {
+                storeOrder = storeOrderService.getOne(new QueryWrapper<StoreOrder>().eq("pay_trade_no", outTradeNo));
+            }
+            if (storeOrder != null && storeOrder.getPayStatus() != 1) {
+                storeOrder.setPayStatus(1);
+                storeOrder.setOrderStatus(1);
+                storeOrder.setPayType(1);
+                storeOrder.setPayTime(new Date());
+                if (storeOrderService.updateById(storeOrder)) {
+                    log.info("[WeChatPartnerMinin] 更新订单支付成功 outTradeNo={}", outTradeNo);
+                    if (storeOrder.getCouponId() != null && storeOrder.getPayUserId() != null) {
+                        LambdaQueryWrapper<LifeDiscountCouponUser> couponUserWrapper = new LambdaQueryWrapper<>();
+                        couponUserWrapper.eq(LifeDiscountCouponUser::getUserId, storeOrder.getPayUserId());
+                        couponUserWrapper.eq(LifeDiscountCouponUser::getCouponId, storeOrder.getCouponId());
+                        couponUserWrapper.eq(LifeDiscountCouponUser::getStatus,
+                                Integer.parseInt(DiscountCouponEnum.WAITING_USED.getValue()));
+                        couponUserWrapper.eq(LifeDiscountCouponUser::getDeleteFlag, 0);
+                        couponUserWrapper.orderByDesc(LifeDiscountCouponUser::getCreatedTime);
+                        couponUserWrapper.last("LIMIT 1");
+                        LifeDiscountCouponUser couponUser = lifeDiscountCouponUserMapper.selectOne(couponUserWrapper);
+                        if (couponUser != null) {
+                            couponUser.setStatus(Integer.parseInt(DiscountCouponEnum.HAVE_BEEN_USED.getValue()));
+                            couponUser.setUseTime(new Date());
+                            lifeDiscountCouponUserMapper.updateById(couponUser);
+                            log.info("[WeChatPartnerMinin] 优惠券已使用 id={}", couponUser.getId());
+                        }
+                    }
+                    try {
+                        storeOrderService.resetTableAfterPayment(storeOrder.getTableId(), storeOrder.getMenuType());
+                        log.info("[WeChatPartnerMinin] 支付后重置餐桌 tableId={}", storeOrder.getTableId());
+                    } catch (Exception e) {
+                        log.error("[WeChatPartnerMinin] 重置餐桌失败 tableId={}", storeOrder.getTableId(), e);
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.error("[WeChatPartnerMinin回调] 异步处理异常", e);
+        }
+    }
+
+    @Override
+    public R searchOrderByOutTradeNoPath(String transactionId, Integer storeId) throws Exception {
+        log.info("[WeChatPartnerMinin] 查单 transactionId={}, storeId={}", transactionId, storeId);
+        if (storeId == null) {
+            return R.fail("店铺ID不能为空");
+        }
+        String subMchid = resolveSubMchidFromStore(storeId);
+        if (subMchid == null) {
+            return R.fail("请维护门店特约商户号 store_info.wechat_sub_mchid");
+        }
+        try {
+            DirectAPIv3QueryResponse response = partnerSearchOrderRun(transactionId, subMchid);
+            return R.data(response);
+        } catch (WXPayUtility.ApiException e) {
+            log.error("[WeChatPartnerMinin] 查单失败 code={}, msg={}", e.getErrorCode(), e.getMessage());
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @Override
+    public String handleRefund(Map<String, String> params) throws Exception {
+        Integer refundStoreId = parseStoreIdFromParams(params);
+        String subMchid = resolveSubMchidFromStore(refundStoreId);
+        log.info("[WeChatPartnerMinin] 退款 storeId={}, subMchid={}, outTradeNo={}",
+                refundStoreId, subMchid != null ? subMchid : "(空)", params != null ? params.get("outTradeNo") : null);
+        if (subMchid == null) {
+            return "退款失败:请在参数中传入 storeId,且门店需维护 wechat_sub_mchid";
+        }
+        if (params == null || params.isEmpty()) {
+            return "退款失败:参数为空";
+        }
+        PartnerRefundCreateRequest request = new PartnerRefundCreateRequest();
+        request.subMchid = subMchid;
+        request.outTradeNo = params.get("outTradeNo");
+        request.outRefundNo = UniqueRandomNumGenerator.generateUniqueCode(19);
+        request.reason = params.get("reason");
+        request.notifyUrl = refundNotifyUrl;
+        request.amount = new AmountReq();
+        request.amount.refund = new BigDecimal(params.get("refundAmount")).longValue();
+        request.amount.total = new BigDecimal(params.get("totalAmount")).longValue();
+        request.amount.currency = "CNY";
+
+        try {
+            PartnerRefundResponse response = partnerRefundRun(request);
+            String status = response.status != null ? response.status : "UNKNOWN";
+            if ("SUCCESS".equals(status) || "PROCESSING".equals(status)) {
+                log.info("[WeChatPartnerMinin] 退款受理成功 outTradeNo={}, outRefundNo={}, status={}",
+                        request.outTradeNo, request.outRefundNo, status);
+                return "调用成功";
+            }
+            log.error("[WeChatPartnerMinin] 退款未成功 status={}, outTradeNo={}", status, request.outTradeNo);
+            return "退款失败";
+        } catch (Exception e) {
+            log.error("[WeChatPartnerMinin] 退款异常 outTradeNo={}", request.outTradeNo, e);
+            return "退款处理异常:" + e.getMessage();
+        }
+    }
+
+    private PartnerRefundResponse partnerRefundRun(PartnerRefundCreateRequest request) {
+        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", wechatPayPublicKeyId);
+        reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(spMchId, merchantSerialNumber, privateKey, POST_METHOD, uri, reqBody));
+        reqBuilder.addHeader("Content-Type", "application/json");
+        RequestBody body = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
+        reqBuilder.method(POST_METHOD, body);
+        Request httpRequest = reqBuilder.build();
+
+        OkHttpClient client = new OkHttpClient.Builder().build();
+        try (Response httpResponse = client.newCall(httpRequest).execute()) {
+            String respBody = WXPayUtility.extractBody(httpResponse);
+            if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
+                WXPayUtility.validateResponse(wechatPayPublicKeyId, wechatPayPublicKey, httpResponse.headers(), respBody);
+                return WXPayUtility.fromJson(respBody, PartnerRefundResponse.class);
+            }
+            throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
+        } catch (IOException e) {
+            throw new UncheckedIOException("WeChatPartnerMinin refund failed: " + uri, e);
+        }
+    }
+
+    private static Integer parseStoreIdFromParams(Map<String, String> params) {
+        if (params == null) {
+            return null;
+        }
+        String raw = params.get("storeId");
+        if (raw == null || raw.trim().isEmpty()) {
+            return null;
+        }
+        try {
+            return Integer.valueOf(raw.trim());
+        } catch (NumberFormatException e) {
+            log.warn("[WeChatPartnerMinin] 退款参数 storeId 非法: {}", raw);
+            return null;
+        }
+    }
+
+    @Override
+    public R searchRefundRecordByOutRefundNo(String outRefundNo, Integer storeId) throws Exception {
+        if (outRefundNo == null || outRefundNo.trim().isEmpty()) {
+            log.error("[WeChatPartnerMinin] 查询退款失败:outRefundNo 为空");
+            return R.fail("外部退款单号不能为空");
+        }
+        if (storeId == null) {
+            return R.fail("店铺ID不能为空");
+        }
+        String subMchid = resolveSubMchidFromStore(storeId);
+        if (subMchid == null) {
+            return R.fail("请维护门店特约商户号 store_info.wechat_sub_mchid");
+        }
+        QueryByOutRefundNoRequest request = new QueryByOutRefundNoRequest();
+        request.setOutRefundNo(outRefundNo);
+        try {
+            Refund response = doSearchRefundByOutRefundNo(request, subMchid);
+            if (response == null) {
+                return R.fail("微信支付返回为空");
+            }
+            String refundStatus = String.valueOf(response.getStatus());
+            log.info("[WeChatPartnerMinin] 退款查询 outRefundNo={}, status={}", outRefundNo, refundStatus);
+            switch (refundStatus) {
+                case "SUCCESS":
+                    return R.data(response);
+                case "REFUNDCLOSE":
+                    return R.fail("微信支付退款已关闭");
+                case "PROCESSING":
+                    return R.fail("微信支付退款处理中,请稍后再查");
+                case "CHANGE":
+                    return R.fail("微信支付退款异常");
+                default:
+                    return R.fail("未知退款状态: " + refundStatus);
+            }
+        } catch (WXPayUtility.ApiException e) {
+            log.error("[WeChatPartnerMinin] 查询退款 API 异常 outRefundNo={}", outRefundNo, e);
+            return R.fail("微信支付查询退款失败:" + e.getMessage());
+        }
+    }
+
+    private Refund doSearchRefundByOutRefundNo(QueryByOutRefundNoRequest request, String subMchid) {
+        String uri = searchRefundStatusByOutRefundNoPath.replace("{out_refund_no}", WXPayUtility.urlEncode(request.getOutRefundNo()));
+        Map<String, Object> args = new HashMap<>(4);
+        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", wechatPayPublicKeyId);
+        reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(spMchId, merchantSerialNumber, privateKey, GET_METHOD, uri, null));
+        reqBuilder.method(GET_METHOD, null);
+        Request httpRequest = reqBuilder.build();
+
+        OkHttpClient client = new OkHttpClient.Builder().build();
+        try (Response httpResponse = client.newCall(httpRequest).execute()) {
+            String respBody = WXPayUtility.extractBody(httpResponse);
+            if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
+                WXPayUtility.validateResponse(wechatPayPublicKeyId, wechatPayPublicKey, httpResponse.headers(), respBody);
+                return WXPayUtility.fromJson(respBody, Refund.class);
+            }
+            throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
+        } catch (IOException e) {
+            throw new UncheckedIOException("WeChatPartnerMinin refund query failed: " + uri, e);
+        }
+    }
+
+    @Override
+    public String getType() {
+        return PaymentEnum.WECHAT_PAY_PARTNER_MININ_PROGRAM.getType();
+    }
+
+    // ———— 请求/响应模型(字段名与微信 APIv3 一致) ————
+
+    public static class PartnerJsapiPrepayRequest {
+        @SerializedName("sp_appid")
+        public String spAppid;
+        @SerializedName("sp_mchid")
+        public String spMchid;
+        @SerializedName("sub_appid")
+        public String subAppid;
+        @SerializedName("sub_mchid")
+        public String subMchid;
+        @SerializedName("description")
+        public String description;
+        @SerializedName("out_trade_no")
+        public String outTradeNo;
+        @SerializedName("notify_url")
+        public String notifyUrl;
+        @SerializedName("amount")
+        public CommonAmountInfo amount;
+        @SerializedName("payer")
+        public JsapiReqPayerInfo payer;
+    }
+
+    public static class CommonAmountInfo {
+        @SerializedName("total")
+        public Long total;
+        @SerializedName("currency")
+        public String currency;
+    }
+
+    public static class JsapiReqPayerInfo {
+        @SerializedName("sp_openid")
+        public String spOpenid;
+    }
+
+    public static class DirectAPIv3JsapiPrepayResponse {
+        @SerializedName("prepay_id")
+        public String prepayId;
+    }
+
+    public static class DirectAPIv3QueryResponse {
+        @SerializedName("appid")
+        public String appid;
+        @SerializedName("mchid")
+        public String mchid;
+        @SerializedName("out_trade_no")
+        public String outTradeNo;
+        @SerializedName("transaction_id")
+        public String transactionId;
+        @SerializedName("trade_type")
+        public String tradeType;
+        @SerializedName("trade_state")
+        public String tradeState;
+        @SerializedName("trade_state_desc")
+        public String tradeStateDesc;
+    }
+
+    public static class PartnerRefundCreateRequest {
+        @SerializedName("sub_mchid")
+        public String subMchid;
+        @SerializedName("out_trade_no")
+        public String outTradeNo;
+        @SerializedName("out_refund_no")
+        public String outRefundNo;
+        @SerializedName("reason")
+        public String reason;
+        @SerializedName("notify_url")
+        public String notifyUrl;
+        @SerializedName("amount")
+        public AmountReq amount;
+    }
+
+    public static class AmountReq {
+        @SerializedName("refund")
+        public Long refund;
+        @SerializedName("total")
+        public Long total;
+        @SerializedName("currency")
+        public String currency;
+    }
+
+    public static class PartnerRefundResponse {
+        @SerializedName("status")
+        public String status;
+        @SerializedName("out_refund_no")
+        public String outRefundNo;
+        @SerializedName("out_trade_no")
+        public String outTradeNo;
+    }
+}

+ 4 - 0
alien-entity/src/main/java/shop/alien/entity/store/StoreInfo.java

@@ -362,4 +362,8 @@ public class StoreInfo {
     @ApiModelProperty(value = "预约服务 0不提供 1提供")
     @TableField("booking_service")
     private Integer bookingService;
+
+    @ApiModelProperty(value = "微信支付特约商户号(服务商进件审核通过后回写 sub_mchid)")
+    @TableField("wechat_sub_mchid")
+    private String wechatSubMchid;
 }

+ 46 - 0
alien-entity/src/main/java/shop/alien/entity/store/StoreOrder.java

@@ -136,6 +136,52 @@ public class StoreOrder {
     @TableField("remark")
     private String remark;
 
+    // ---------- 以下字段为「预订结账」提交时写入,用于记录加收/减免/整单折扣/免单及原因(与优惠券 discount_amount 区分) ----------
+
+    @ApiModelProperty(value = "是否有其他费用:0否 1是")
+    @TableField("has_other_fee")
+    private Integer hasOtherFee;
+
+    @ApiModelProperty(value = "其他费用金额")
+    @TableField("other_fee_amount")
+    private BigDecimal otherFeeAmount;
+
+    @ApiModelProperty(value = "其他费用收款原因")
+    @TableField("other_fee_reason")
+    private String otherFeeReason;
+
+    @ApiModelProperty(value = "是否有手动减免:0否 1是")
+    @TableField("has_manual_reduction")
+    private Integer hasManualReduction;
+
+    @ApiModelProperty(value = "手动减免金额")
+    @TableField("manual_reduction_amount")
+    private BigDecimal manualReductionAmount;
+
+    @ApiModelProperty(value = "手动减免原因")
+    @TableField("manual_reduction_reason")
+    private String manualReductionReason;
+
+    @ApiModelProperty(value = "整单折扣:0否 1是")
+    @TableField("has_whole_discount")
+    private Integer hasWholeDiscount;
+
+    @ApiModelProperty(value = "整单折扣比例(0-1)")
+    @TableField("whole_discount_ratio")
+    private BigDecimal wholeDiscountRatio;
+
+    @ApiModelProperty(value = "整单折扣原因")
+    @TableField("whole_discount_reason")
+    private String wholeDiscountReason;
+
+    @ApiModelProperty(value = "整单免单:0否 1是")
+    @TableField("is_free_order")
+    private Integer isFreeOrder;
+
+    @ApiModelProperty(value = "整单免单原因")
+    @TableField("free_order_reason")
+    private String freeOrderReason;
+
     @ApiModelProperty(value = "删除标记, 0:未删除, 1:已删除")
     @TableField("delete_flag")
     @TableLogic

+ 14 - 0
alien-entity/src/main/java/shop/alien/entity/store/StoreOrderDetail.java

@@ -70,6 +70,20 @@ public class StoreOrderDetail {
     @TableField("subtotal_amount")
     private BigDecimal subtotalAmount;
 
+    // ---------- 以下字段为「预订结账」时按行维护;提交结账后 subtotal_amount 会按免单/折扣重算 ----------
+
+    @ApiModelProperty(value = "行免单:0否 1是")
+    @TableField("free_dish")
+    private Integer freeDish;
+
+    @ApiModelProperty(value = "行折扣:0否 1是")
+    @TableField("discount_flag")
+    private Integer discountFlag;
+
+    @ApiModelProperty(value = "行折扣数:比例0-1(如0.8);若大于1按百分制折算")
+    @TableField("discount_number")
+    private BigDecimal discountNumber;
+
     @ApiModelProperty(value = "添加该菜品的用户ID")
     @TableField("add_user_id")
     private Integer addUserId;

+ 101 - 0
alien-entity/src/main/java/shop/alien/entity/store/WechatPartnerApplyment.java

@@ -0,0 +1,101 @@
+package shop.alien.entity.store;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 微信支付服务商 - 特约商户进件申请单(本地落库主表)
+ */
+@Data
+@ApiModel(value = "WechatPartnerApplyment对象", description = "微信支付服务商-特约商户进件申请单(本地落库)")
+@TableName("wechat_partner_applyment")
+public class WechatPartnerApplyment {
+
+    @ApiModelProperty("主键")
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    @ApiModelProperty("服务商商户号sp_mchid")
+    @TableField("sp_mchid")
+    private String spMchid;
+
+    @ApiModelProperty("业务申请编号business_code")
+    @TableField("business_code")
+    private String businessCode;
+
+    @ApiModelProperty("幂等键Idempotency-Key")
+    @TableField("idempotency_key")
+    private String idempotencyKey;
+
+    /**
+     * 关联本系统门店 {@link StoreInfo#getId()},审核通过后用于回写 {@code store_info.wechat_sub_mchid};
+     * 提交进件时通过 query 参数 {@code storeId} 传入并落库(非微信请求字段)。
+     */
+    @ApiModelProperty("关联门店ID store_info.id")
+    @TableField("store_id")
+    private Integer storeId;
+
+    @ApiModelProperty("微信支付申请单号applyment_id")
+    @TableField("applyment_id")
+    private Long applymentId;
+
+    @ApiModelProperty("特约商户号sub_mchid")
+    @TableField("sub_mchid")
+    private String subMchid;
+
+    @ApiModelProperty("超级管理员签约链接sign_url")
+    @TableField("sign_url")
+    private String signUrl;
+
+    @ApiModelProperty("申请单状态applyment_state")
+    @TableField("applyment_state")
+    private String applymentState;
+
+    @ApiModelProperty("申请状态描述applyment_state_msg")
+    @TableField("applyment_state_msg")
+    private String applymentStateMsg;
+
+    @ApiModelProperty("是否通过:0未知/处理中 1通过 2驳回")
+    @TableField("is_approved")
+    private Integer isApproved;
+
+    @ApiModelProperty("不通过理由(聚合audit_detail.reject_reason等)")
+    @TableField("reject_reason")
+    private String rejectReason;
+
+    @ApiModelProperty("最后一次提交时间")
+    @TableField("last_submit_time")
+    private Date lastSubmitTime;
+
+    @ApiModelProperty("最后一次查询时间")
+    @TableField("last_query_time")
+    private Date lastQueryTime;
+
+    @ApiModelProperty("提交申请单请求体JSON(建议存最终提交给微信的那份)")
+    @TableField("request_json")
+    private String requestJson;
+
+    @ApiModelProperty("最后一次提交微信响应原文JSON")
+    @TableField("last_submit_resp_json")
+    private String lastSubmitRespJson;
+
+    @ApiModelProperty("最后一次查询微信响应原文JSON")
+    @TableField("last_query_resp_json")
+    private String lastQueryRespJson;
+
+    @ApiModelProperty("创建时间")
+    @TableField("created_at")
+    private Date createdAt;
+
+    @ApiModelProperty("更新时间")
+    @TableField("updated_at")
+    private Date updatedAt;
+}
+

+ 29 - 0
alien-entity/src/main/java/shop/alien/entity/store/dto/StoreBookingCheckoutLineSubmitDTO.java

@@ -0,0 +1,29 @@
+package shop.alien.entity.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+import java.math.BigDecimal;
+
+/**
+ * 预订结账提交:单行明细调整
+ */
+@Data
+@ApiModel(description = "预订结账提交-明细行")
+public class StoreBookingCheckoutLineSubmitDTO {
+
+    @NotNull(message = "明细ID不能为空")
+    @ApiModelProperty(value = "订单明细ID", required = true)
+    private Integer detailId;
+
+    @ApiModelProperty("行免单:0否 1是")
+    private Integer freeDish;
+
+    @ApiModelProperty("行折扣:0否 1是")
+    private Integer discountFlag;
+
+    @ApiModelProperty("行折扣数:0-1 比例;若传 80 表示 8 折(按百分制折算)")
+    private BigDecimal discountNumber;
+}

+ 75 - 0
alien-entity/src/main/java/shop/alien/entity/store/dto/StoreBookingCheckoutSubmitDTO.java

@@ -0,0 +1,75 @@
+package shop.alien.entity.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.Valid;
+import javax.validation.constraints.NotNull;
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 预订结账提交(对应 POST /store/booking/order/checkout/submit)。
+ * lines 必须包含本单全部明细行且 detailId 与库中一一对应;clearTable 为 true 时会释放所有 current_order_id 指向本单的桌台。
+ */
+@Data
+@ApiModel(description = "预订结账提交")
+public class StoreBookingCheckoutSubmitDTO {
+
+    @NotNull(message = "门店ID不能为空")
+    @ApiModelProperty(value = "门店ID", required = true)
+    private Integer storeId;
+
+    @NotNull(message = "桌台ID不能为空")
+    @ApiModelProperty(value = "当前操作的桌台ID", required = true)
+    private Integer tableId;
+
+    @ApiModelProperty("订单ID(可选;不传则按桌台解析待支付订单)")
+    private Integer orderId;
+
+    @NotNull(message = "支付方式不能为空")
+    @ApiModelProperty(value = "支付方式:1微信 2支付宝 3现金", required = true)
+    private Integer payType;
+
+    @ApiModelProperty("true:结账并清桌(释放本单关联的所有桌台);false:仅结账(不写回桌台空闲)")
+    private Boolean clearTable;
+
+    @ApiModelProperty("是否有其他费用:0否 1是")
+    private Integer hasOtherFee;
+
+    @ApiModelProperty("其他费用金额")
+    private BigDecimal otherFeeAmount;
+
+    @ApiModelProperty("其他费用收款原因")
+    private String otherFeeReason;
+
+    @ApiModelProperty("是否有手动减免:0否 1是")
+    private Integer hasManualReduction;
+
+    @ApiModelProperty("手动减免金额")
+    private BigDecimal manualReductionAmount;
+
+    @ApiModelProperty("手动减免原因")
+    private String manualReductionReason;
+
+    @ApiModelProperty("整单折扣:0否 1是")
+    private Integer hasWholeDiscount;
+
+    @ApiModelProperty("整单折扣比例 0-1")
+    private BigDecimal wholeDiscountRatio;
+
+    @ApiModelProperty("整单折扣原因")
+    private String wholeDiscountReason;
+
+    @ApiModelProperty("整单免单:0否 1是")
+    private Integer isFreeOrder;
+
+    @ApiModelProperty("整单免单原因")
+    private String freeOrderReason;
+
+    @Valid
+    @NotNull(message = "明细行不能为空")
+    @ApiModelProperty(value = "全部明细行(须覆盖本单所有明细)", required = true)
+    private List<StoreBookingCheckoutLineSubmitDTO> lines;
+}

+ 42 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/StoreBookingCheckoutLineVo.java

@@ -0,0 +1,42 @@
+package shop.alien.entity.store.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 预订结账页:订单明细行(含行免单/折扣)
+ */
+@Data
+@ApiModel(description = "预订结账-菜品明细行")
+public class StoreBookingCheckoutLineVo {
+
+    @ApiModelProperty("明细ID")
+    private Integer detailId;
+
+    @ApiModelProperty("菜品ID")
+    private Integer cuisineId;
+
+    @ApiModelProperty("菜品名称")
+    private String cuisineName;
+
+    @ApiModelProperty("成交单价")
+    private BigDecimal unitPrice;
+
+    @ApiModelProperty("数量")
+    private Integer quantity;
+
+    @ApiModelProperty("小计金额(当前库中值;结账提交后会按免单/折扣重算)")
+    private BigDecimal subtotalAmount;
+
+    @ApiModelProperty("行免单:0否 1是")
+    private Integer freeDish;
+
+    @ApiModelProperty("行折扣:0否 1是")
+    private Integer discountFlag;
+
+    @ApiModelProperty("行折扣数:比例0-1")
+    private BigDecimal discountNumber;
+}

+ 71 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/StoreBookingCheckoutPrepVo.java

@@ -0,0 +1,71 @@
+package shop.alien.entity.store.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 预订结账页:进入结账时的订单与费用预览(对应 GET /store/booking/order/checkout/prep)。
+ * goodsAmountRaw 为单价×数量之和,供服务费「按消费金额」规则作基数;serviceFeeForCurrentSlot 为当前时刻规则命中值。
+ */
+@Data
+@ApiModel(description = "预订结账-页面数据")
+public class StoreBookingCheckoutPrepVo {
+
+    @ApiModelProperty("订单ID")
+    private Integer orderId;
+
+    @ApiModelProperty("订单号")
+    private String orderNo;
+
+    @ApiModelProperty("门店ID")
+    private Integer storeId;
+
+    @ApiModelProperty("当前点击的桌台ID")
+    private Integer tableId;
+
+    @ApiModelProperty("预约ID(无预约则为空)")
+    private Integer userReservationId;
+
+    @ApiModelProperty("同一预约/同一订单下的桌号展示(如:A01、A02)")
+    private String tableNumbersDisplay;
+
+    @ApiModelProperty("同一预约下桌台ID列表")
+    private List<Integer> tableIdsUnderReservation;
+
+    @ApiModelProperty("就餐人数")
+    private Integer dinerCount;
+
+    @ApiModelProperty("订单状态")
+    private Integer orderStatus;
+
+    @ApiModelProperty("支付状态")
+    private Integer payStatus;
+
+    @ApiModelProperty("菜品原价合计(明细小计之和,未扣行免单/行折扣)")
+    private BigDecimal goodsAmountRaw;
+
+    @ApiModelProperty("餐具费")
+    private BigDecimal tablewareFee;
+
+    @ApiModelProperty("订单上已快照的服务费(下单时可能已写入)")
+    private BigDecimal serviceFeeOnOrder;
+
+    @ApiModelProperty("按当前时刻+桌台匹配规则计算的当前时段服务费合计(预览)")
+    private BigDecimal serviceFeeForCurrentSlot;
+
+    @ApiModelProperty("优惠券优惠金额")
+    private BigDecimal couponDiscountAmount;
+
+    @ApiModelProperty("当前库中订单总金额")
+    private BigDecimal totalAmount;
+
+    @ApiModelProperty("当前库中实付金额")
+    private BigDecimal payAmount;
+
+    @ApiModelProperty("明细列表(含行免单/折扣字段)")
+    private List<StoreBookingCheckoutLineVo> lines;
+}

+ 27 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/StoreBookingCheckoutResultVo.java

@@ -0,0 +1,27 @@
+package shop.alien.entity.store.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 预订结账提交结果
+ */
+@Data
+@ApiModel(description = "预订结账提交结果")
+public class StoreBookingCheckoutResultVo {
+
+    @ApiModelProperty("订单ID")
+    private Integer orderId;
+
+    @ApiModelProperty("订单号")
+    private String orderNo;
+
+    @ApiModelProperty("实付金额")
+    private BigDecimal payAmount;
+
+    @ApiModelProperty("是否已执行清桌")
+    private Boolean clearedTables;
+}

+ 11 - 0
alien-entity/src/main/java/shop/alien/mapper/WechatPartnerApplymentMapper.java

@@ -0,0 +1,11 @@
+package shop.alien.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import shop.alien.entity.store.WechatPartnerApplyment;
+
+/**
+ * 微信支付服务商-特约商户进件申请单 Mapper
+ */
+public interface WechatPartnerApplymentMapper extends BaseMapper<WechatPartnerApplyment> {
+}
+

+ 10 - 0
alien-job/src/main/java/shop/alien/job/feign/AlienStoreFeign.java

@@ -3,6 +3,7 @@ package shop.alien.job.feign;
 import com.alibaba.fastjson.JSONObject;
 import org.springframework.cloud.openfeign.FeignClient;
 import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
@@ -109,4 +110,13 @@ public interface AlienStoreFeign {
     @org.springframework.web.bind.annotation.PostMapping("/store/productDiscount/job/autoCloseExpiredCustomRules")
     R<Integer> autoCloseExpiredProductDiscountRules();
 
+    /**
+     * 特约商户进件:申请单号查询申请状态(转发微信 GET .../applyment_id/{applyment_id})。
+     * 需在配置中提供 {@code feign.alienStore.url} 指向 alien-store 根地址。
+     *
+     * @param applymentId 微信支付申请单号
+     */
+    @GetMapping("/payment/wechatPartner/v3/applyment4sub/applyment/applyment_id/{applyment_id}")
+    R<Map<String, Object>> queryWechatPartnerApplymentState(@PathVariable("applyment_id") Long applymentId);
+
 }

+ 193 - 0
alien-job/src/main/java/shop/alien/job/jobhandler/WechatPartnerApplymentAuditSyncJobHandler.java

@@ -0,0 +1,193 @@
+package shop.alien.job.jobhandler;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.xxl.job.core.biz.model.ReturnT;
+import com.xxl.job.core.context.XxlJobHelper;
+import com.xxl.job.core.handler.annotation.XxlJob;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.WechatPartnerApplyment;
+import shop.alien.job.feign.AlienStoreFeign;
+import shop.alien.mapper.WechatPartnerApplymentMapper;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * XXL-JOB:定时查询微信进件审核信息,并更新本地表 {@code wechat_partner_applyment}。
+ * <p>
+ * 查询方式:通过 {@link AlienStoreFeign}(OpenFeign)调用 alien-store 转发查询接口,由 alien-store 负责对微信签名请求+验签;\n
+ * 本 Handler 解析返回数据,并按通过/驳回回写本地表。\n
+ * </p>
+ * <p>
+ * 待处理记录<strong>仅从数据库查询</strong>,不通过 XXL-Job 任务参数传入申请单号;\n
+ * 条数上限由配置 {@code job.wechatPartnerApplyment.batchSize} 控制。\n
+ * </p>
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class WechatPartnerApplymentAuditSyncJobHandler {
+
+    private final AlienStoreFeign alienStoreFeign;
+    private final WechatPartnerApplymentMapper wechatPartnerApplymentMapper;
+
+    /**
+     * 【需配置】alien-store 根地址,与 {@link AlienStoreFeign} 的 {@code feign.alienStore.url} 一致;\n
+     * 仅用于 XXL 日志展示,实际请求走 Feign。
+     */
+    @Value("${feign.alienStore.url:}")
+    private String alienStoreFeignUrl;
+
+    /** 默认批量大小 */
+    @Value("${job.wechatPartnerApplyment.batchSize:1}")
+    private int batchSize;
+
+    /**
+     * XXL-JOB 任务:同步进件审核状态(通过/驳回均更新)。
+     * JobHandler 名称:{@code wechatPartnerApplymentAuditSync}
+     */
+    @XxlJob("wechatPartnerApplymentAuditSync")
+    public ReturnT<String> wechatPartnerApplymentAuditSync(String param) {
+        int shardIndex = XxlJobHelper.getShardIndex();
+        int shardTotal = XxlJobHelper.getShardTotal();
+
+        int limit = Math.max(1, Math.min(batchSize, 200));
+
+        XxlJobHelper.log("[进件XXL] start shardIndex={}, shardTotal={}, limit={}, feign.alienStore.url={}",
+                shardIndex, shardTotal, limit, alienStoreFeignUrl);
+
+        // 仅从库查询待同步:未终态(0) 且已有 applyment_id;分片广播:按 applyment_id % shardTotal 分片
+        List<WechatPartnerApplyment> list = wechatPartnerApplymentMapper.selectList(
+                new LambdaQueryWrapper<WechatPartnerApplyment>()
+                        .eq(WechatPartnerApplyment::getIsApproved, 0)
+                        .isNotNull(WechatPartnerApplyment::getApplymentId)
+                        .apply("MOD(applyment_id, {0}) = {1}", shardTotal, shardIndex)
+                        .orderByAsc(WechatPartnerApplyment::getUpdatedAt)
+                        .last("limit " + limit)
+        );
+
+        int ok = 0;
+        int fail = 0;
+        for (WechatPartnerApplyment r : list) {
+            Long id = r.getApplymentId();
+            if (id == null || id <= 0) {
+                continue;
+            }
+            try {
+                R<Map<String, Object>> result = alienStoreFeign.queryWechatPartnerApplymentState(id);
+                if (!R.isSuccess(result)) {
+                    fail++;
+                    String msg = result != null ? result.getMsg() : "响应为空";
+                    XxlJobHelper.log("[进件XXL] queryFail applyment_id={}, msg={}", id, msg);
+                    continue;
+                }
+                Map<String, Object> dataMap = result != null ? result.getData() : null;
+                if (dataMap == null || dataMap.isEmpty()) {
+                    fail++;
+                    XxlJobHelper.log("[进件XXL] queryFail applyment_id={}, data为空", id);
+                    continue;
+                }
+                JSONObject data = JSON.parseObject(JSON.toJSONString(dataMap));
+                String rawResp = JSON.toJSONString(result);
+                // 回写本地表(通过/驳回/进行中都更新状态字段)
+                upsertFromWechatQueryResult(r, data, rawResp);
+                ok++;
+                XxlJobHelper.log("[进件XXL] ok applyment_id={}, state={}", id, data.getString("applyment_state"));
+            } catch (Exception e) {
+                fail++;
+                log.error("[进件XXL] Feign 查询异常 applyment_id={}", id, e);
+                XxlJobHelper.log("[进件XXL] exception applyment_id={}, err={}", id, e.getMessage());
+            }
+        }
+
+        XxlJobHelper.log("[进件XXL] done candidate={}, ok={}, fail={}", list.size(), ok, fail);
+        return ReturnT.SUCCESS;
+    }
+
+    private void upsertFromWechatQueryResult(WechatPartnerApplyment existed, JSONObject data, String rawResp) {
+        Date now = new Date();
+        WechatPartnerApplyment record = existed != null ? existed : new WechatPartnerApplyment();
+        record.setLastQueryTime(now);
+        record.setLastQueryRespJson(rawResp);
+        record.setUpdatedAt(now);
+
+        record.setBusinessCode(blankToNull(data.getString("business_code")));
+        record.setSubMchid(blankToNull(data.getString("sub_mchid")));
+        record.setSignUrl(blankToNull(data.getString("sign_url")));
+        record.setApplymentState(blankToNull(data.getString("applyment_state")));
+        record.setApplymentStateMsg(blankToNull(data.getString("applyment_state_msg")));
+
+        Integer approved = calcIsApproved(record.getApplymentState());
+        record.setIsApproved(approved);
+        if (approved != null && approved == 2) {
+            record.setRejectReason(extractRejectReason(data));
+        } else if (approved != null && approved == 1) {
+            // 通过则清空驳回原因(避免旧值残留)
+            record.setRejectReason(null);
+        }
+
+        // 仅更新当前行
+        if (record.getId() != null) {
+            wechatPartnerApplymentMapper.updateById(record);
+        } else {
+            // 兜底:按 applyment_id 找不到 id 的情况,插入一条(不建议频繁发生)
+            record.setApplymentId(data.getLong("applyment_id"));
+            if (record.getCreatedAt() == null) {
+                record.setCreatedAt(now);
+            }
+            wechatPartnerApplymentMapper.insert(record);
+        }
+    }
+
+    private static Integer calcIsApproved(String applymentState) {
+        if (StringUtils.isBlank(applymentState)) {
+            return 0;
+        }
+        if ("APPLYMENT_STATE_FINISHED".equalsIgnoreCase(applymentState)) {
+            return 1;
+        }
+        if ("APPLYMENT_STATE_REJECTED".equalsIgnoreCase(applymentState)) {
+            return 2;
+        }
+        return 0;
+    }
+
+    private static String extractRejectReason(JSONObject data) {
+        try {
+            if (data == null || data.getJSONArray("audit_detail") == null) {
+                return null;
+            }
+            StringBuilder sb = new StringBuilder();
+            for (int i = 0; i < data.getJSONArray("audit_detail").size(); i++) {
+                JSONObject it = data.getJSONArray("audit_detail").getJSONObject(i);
+                if (it == null) continue;
+                String fieldName = it.getString("field_name");
+                String field = it.getString("field");
+                String reason = it.getString("reject_reason");
+                if (StringUtils.isBlank(reason)) continue;
+                if (sb.length() > 0) sb.append(";");
+                String left = StringUtils.isNotBlank(fieldName) ? fieldName : (StringUtils.isNotBlank(field) ? field : "");
+                if (StringUtils.isNotBlank(left)) sb.append(left).append(":");
+                sb.append(reason);
+            }
+            String s = sb.toString();
+            return s.length() <= 2048 ? s : s.substring(0, 2048);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private static String blankToNull(String s) {
+        String t = s != null ? s.trim() : null;
+        return StringUtils.isNotBlank(t) ? t : null;
+    }
+}
+

+ 7 - 3
alien-store/src/main/java/shop/alien/store/controller/PaymentController.java

@@ -47,7 +47,8 @@ public class PaymentController {
     @ApiImplicitParams({
             @ApiImplicitParam(name = "price", value = "订单金额", required = true, paramType = "query", dataType = "String"),
             @ApiImplicitParam(name = "subject", value = "订单标题", required = true, paramType = "query", dataType = "String"),
-            @ApiImplicitParam(name = "payType", value = "支付类型(alipay:支付宝, wechatPay:微信支付, wechatPayMininProgram:微信小程序点餐)", required = true, paramType = "query", dataType = "String")
+            @ApiImplicitParam(name = "payType", value = "支付类型(alipay:支付宝, wechatPay:微信支付, wechatPayPartner:微信服务商模式, wechatPayMininProgram:微信小程序点餐)", required = true, paramType = "query", dataType = "String"),
+            @ApiImplicitParam(name = "storeId", value = "门店ID(wechatPayPartner 必填,用于读取 store_info.wechat_sub_mchid)", required = false, paramType = "query", dataType = "int")
     })
     @RequestMapping("/prePay")
     public R prePay(
@@ -70,7 +71,7 @@ public class PaymentController {
                         authHeader(request), price, subject, payType, payer, orderNo, storeId,
                         couponId, payerId, tablewareFee, discountAmount, payAmount);
             }
-            return paymentStrategyFactory.getStrategy(payType).createPrePayOrder(price, subject);
+            return paymentStrategyFactory.getStrategy(payType).createPrePayOrder(price, subject, storeId);
         } catch (Exception e) {
             return R.fail(e.getMessage());
         }
@@ -92,6 +93,9 @@ public class PaymentController {
      * @param payType 支付类型
      * @return 订单状态信息
      */
+    /**
+     * wechatPayPartner 查单需传 storeId,与预下单一致,用于解析子商户号。
+     */
     @RequestMapping("/searchOrderByOutTradeNoPath")
     public R searchOrderByOutTradeNoPath(
             @RequestParam String transactionId,
@@ -101,7 +105,7 @@ public class PaymentController {
             if ("wechatPayMininProgram".equals(payType) && storeId != null) {
                 return diningServiceFeign.searchOrderByOutTradeNoPath(transactionId, payType, storeId);
             }
-            return paymentStrategyFactory.getStrategy(payType).searchOrderByOutTradeNoPath(transactionId);
+            return paymentStrategyFactory.getStrategy(payType).searchOrderByOutTradeNoPath(transactionId, storeId);
         } catch (Exception e) {
             return R.fail(e.getMessage());
         }

+ 48 - 0
alien-store/src/main/java/shop/alien/store/controller/StoreBookingOrderController.java

@@ -8,9 +8,13 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
 import shop.alien.entity.result.R;
 import shop.alien.entity.store.dto.StoreBookingChangeTableDTO;
+import shop.alien.entity.store.dto.StoreBookingCheckoutSubmitDTO;
 import shop.alien.entity.store.dto.StoreBookingPlaceOrderDTO;
 import shop.alien.entity.store.vo.StoreBookingChangeTableUnifiedResultVo;
+import shop.alien.entity.store.vo.StoreBookingCheckoutPrepVo;
+import shop.alien.entity.store.vo.StoreBookingCheckoutResultVo;
 import shop.alien.entity.store.vo.StoreBookingPlaceOrderResultVo;
+import shop.alien.store.service.StoreBookingCheckoutService;
 import shop.alien.store.service.StoreBookingOrderService;
 
 import javax.validation.Valid;
@@ -26,7 +30,10 @@ import javax.validation.Valid;
 @RequiredArgsConstructor
 public class StoreBookingOrderController {
 
+    /** 下单、换桌 */
     private final StoreBookingOrderService storeBookingOrderService;
+    /** 结账页查询与提交(与下单服务拆分,职责单一) */
+    private final StoreBookingCheckoutService storeBookingCheckoutService;
 
     @ApiOperationSupport(order = 1)
     @ApiOperation("提交订单(主表 store_order,明细 store_order_detail;联系人 dining_contact_name / dining_gender 1男2女。若该桌已有待支付订单则为加餐,可多次加餐;可选 targetOrderId 指定订单)")
@@ -64,4 +71,45 @@ public class StoreBookingOrderController {
             return R.fail("换桌失败:" + e.getMessage());
         }
     }
+
+    /**
+     * GET /checkout/prep:只读,给前端渲染结账页;异常分业务异常与系统异常,便于前端提示。
+     */
+    @ApiOperationSupport(order = 3)
+    @ApiOperation("结账页数据:按桌解析待支付订单(多桌共享时以 current_order_id 为准)、预约下复合桌号、明细含行免单/折扣、当前时段服务费合计")
+    @GetMapping("/checkout/prep")
+    public R<StoreBookingCheckoutPrepVo> checkoutPrep(
+            @RequestParam Integer storeId,
+            @RequestParam Integer tableId) {
+        log.info("StoreBookingOrderController.checkoutPrep?storeId={}, tableId={}", storeId, tableId);
+        try {
+            return R.data(storeBookingCheckoutService.prepCheckout(storeId, tableId));
+        } catch (IllegalArgumentException | IllegalStateException e) {
+            log.warn("checkoutPrep biz: {}", e.getMessage());
+            return R.fail(e.getMessage());
+        } catch (Exception e) {
+            log.error("checkoutPrep error", e);
+            return R.fail("查询结账数据失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * POST /checkout/submit:写库;@Valid 校验非空字段,明细行由 DTO 内嵌校验配合服务层全集校验。
+     */
+    @ApiOperationSupport(order = 4)
+    @ApiOperation("提交结账:写回订单其他费用/减免/整单折扣/免单及明细行;完成支付;clearTable=true 时结账并清桌(释放本单关联桌台)")
+    @PostMapping("/checkout/submit")
+    public R<StoreBookingCheckoutResultVo> checkoutSubmit(@Valid @RequestBody StoreBookingCheckoutSubmitDTO dto) {
+        log.info("StoreBookingOrderController.checkoutSubmit?storeId={}, tableId={}, orderId={}, clearTable={}",
+                dto.getStoreId(), dto.getTableId(), dto.getOrderId(), dto.getClearTable());
+        try {
+            return R.data(storeBookingCheckoutService.submitCheckout(dto));
+        } catch (IllegalArgumentException | IllegalStateException e) {
+            log.warn("checkoutSubmit biz: {}", e.getMessage());
+            return R.fail(e.getMessage());
+        } catch (Exception e) {
+            log.error("checkoutSubmit error", e);
+            return R.fail("结账失败:" + e.getMessage());
+        }
+    }
 }

+ 117 - 0
alien-store/src/main/java/shop/alien/store/controller/WeChatPartnerApplymentController.java

@@ -0,0 +1,117 @@
+package shop.alien.store.controller;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiImplicitParam;
+import io.swagger.annotations.ApiImplicitParams;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.WechatPartnerApplyment;
+import shop.alien.store.service.WeChatPartnerApplymentService;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 微信支付服务商 — 特约商户进件
+ * <p>
+ * 对外路径与微信 API 路径段对齐:{@code .../v3/applyment4sub/applyment/},
+ * 实际转发至 {@code https://api.mch.weixin.qq.com/v3/applyment4sub/applyment/}。
+ * </p>
+ */
+@Slf4j
+@Api(tags = {"微信支付服务商-特约进件"})
+@CrossOrigin
+@RestController
+@RequestMapping("/payment/wechatPartner")
+@RequiredArgsConstructor
+public class WeChatPartnerApplymentController {
+
+    private final WeChatPartnerApplymentService weChatPartnerApplymentService;
+
+    /**
+     * 提交特约商户进件申请单(对应微信 {@code POST /v3/applyment4sub/applyment/})
+     * <p>
+     * 请求体 JSON 字段以微信支付官方文档为准(如 business_code、contact_info、subject_info、
+     * bank_account_info 等),本接口不做字段级校验,原样转发。
+     * </p>
+     *
+     * @param requestBody    与微信文档一致的 JSON 字符串
+     * @param idempotencyKey 可选,传入 {@code Idempotency-Key},同一业务单重试时请保持不变
+     * @param storeId        可选,本系统门店 {@code store_info.id};审核通过后将微信 {@code sub_mchid} 写入该门店
+     */
+    @ApiOperation("特约商户进件-提交申请单(转发微信 /v3/applyment4sub/applyment/)")
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "Idempotency-Key", value = "幂等键,可选;重试同一进件时请与首次一致", paramType = "header", dataType = "String"),
+            @ApiImplicitParam(name = "storeId", value = "门店ID store_info.id,可选;用于审核通过后回写 wechat_sub_mchid", paramType = "query", dataType = "Integer")
+    })
+    @PostMapping(value = "/v3/applyment4sub/applyment", consumes = MediaType.APPLICATION_JSON_VALUE)
+    public R<Map<String, Object>> submitApplyment(
+            @RequestBody String requestBody,
+            @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey,
+            @RequestParam(value = "storeId", required = false) Integer storeId) {
+        log.info("WeChatPartnerApplymentController.submitApplyment bodyLen={}, idempotencyKeyPresent={}, storeId={}",
+                requestBody != null ? requestBody.length() : 0, idempotencyKey != null && !idempotencyKey.isEmpty(), storeId);
+        return weChatPartnerApplymentService.submitApplyment(requestBody, idempotencyKey, storeId);
+    }
+
+    /**
+     * 商户图片上传,对应微信 {@code POST /v3/merchant/media/upload},成功返回 {@code media_id}。
+     */
+    @ApiOperation("特约商户进件-图片上传(转发微信 /v3/merchant/media/upload,返回 media_id)")
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "file", value = "图片文件 JPG/PNG/BMP,≤5MB", required = true, dataType = "file", paramType = "form")
+    })
+    @PostMapping(value = "/v3/merchant/media/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+    public R<Map<String, Object>> uploadMerchantMedia(@RequestParam("file") MultipartFile file) {
+        if (file == null || file.isEmpty()) {
+            return R.fail("请选择图片文件");
+        }
+        try {
+            return weChatPartnerApplymentService.uploadMerchantMedia(file.getBytes(), file.getOriginalFilename());
+        } catch (IOException e) {
+            return R.fail("读取上传文件失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 申请单号查询申请状态,对应微信
+     * {@code GET /v3/applyment4sub/applyment/applyment_id/{applyment_id}}
+     * (<a href="https://pay.weixin.qq.com/doc/v3/partner/4012697052">文档</a>)。
+     *
+     * @param applymentId 微信支付分配的申请单号
+     */
+    @ApiOperation("特约商户进件-申请单号查询申请状态(转发微信 GET .../applyment_id/{applyment_id})")
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "applyment_id", value = "微信支付申请单号", required = true, paramType = "path", dataType = "Long")
+    })
+    @GetMapping("/v3/applyment4sub/applyment/applyment_id/{applyment_id}")
+    public R<Map<String, Object>> queryApplymentStateByApplymentId(
+            @PathVariable("applyment_id") Long applymentId) {
+        if (applymentId == null) {
+            return R.fail("applyment_id 不能为空");
+        }
+        log.info("WeChatPartnerApplymentController.queryApplymentStateByApplymentId applyment_id={}", applymentId);
+        return weChatPartnerApplymentService.queryApplymentStateByApplymentId(applymentId);
+    }
+
+    /**
+     * 按门店查询特约商户进件状态:读本地 {@code wechat_partner_applyment} 该店最新一条记录(不调微信)。
+     *
+     * @param storeId 门店主键 {@code store_info.id}(进件提交时需已传同一 storeId)
+     */
+    @ApiOperation("特约商户进件-按门店查询进件状态(读本地库)")
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "storeId", value = "门店ID store_info.id", required = true, paramType = "path", dataType = "int")
+    })
+    @GetMapping("/applyment/store/{storeId}")
+    public R<List<WechatPartnerApplyment>> queryApplymentStateByStoreId(@PathVariable("storeId") Integer storeId) {
+        log.info("WeChatPartnerApplymentController.queryApplymentStateByStoreId storeId={}", storeId);
+        return weChatPartnerApplymentService.queryApplymentStateByStoreId(storeId);
+    }
+}

+ 30 - 0
alien-store/src/main/java/shop/alien/store/service/StoreBookingCheckoutService.java

@@ -0,0 +1,30 @@
+package shop.alien.store.service;
+
+import shop.alien.entity.store.dto.StoreBookingCheckoutSubmitDTO;
+import shop.alien.entity.store.vo.StoreBookingCheckoutPrepVo;
+import shop.alien.entity.store.vo.StoreBookingCheckoutResultVo;
+
+/**
+ * 预订场景:结账页查询与提交(仅操作 store_order / store_order_detail,不经 alien-dining)。
+ * <p>
+ * 订单解析顺序与换桌/加餐一致:多桌共一单时以 {@link shop.alien.entity.store.StoreTable#getCurrentOrderId()} 为准。
+ */
+public interface StoreBookingCheckoutService {
+
+    /**
+     * 结账页「只读预览」:校验门店与桌台,解析待支付订单,组装复合桌号、明细行(含行免单/折扣字段)、
+     * 并按当前时刻匹配服务费规则得到 {@link shop.alien.entity.store.vo.StoreBookingCheckoutPrepVo#getServiceFeeForCurrentSlot()}。
+     *
+     * @param storeId 门店 ID,用于校验桌台归属
+     * @param tableId 用户点击结账的桌台 ID(服务费按此桌匹配规则)
+     */
+    StoreBookingCheckoutPrepVo prepCheckout(Integer storeId, Integer tableId);
+
+    /**
+     * 结账「写库提交」:校验订单与桌台会话一致后,按请求重算每行小计、订单总金额与实付,写入结账扩展字段,
+     * 将订单置为已支付;若 {@link shop.alien.entity.store.dto.StoreBookingCheckoutSubmitDTO#getClearTable()} 为 true 则释放本单关联桌台。
+     *
+     * @param dto 必须包含与本单明细行一一对应的行列表(不可漏行、不可重复 detailId)
+     */
+    StoreBookingCheckoutResultVo submitCheckout(StoreBookingCheckoutSubmitDTO dto);
+}

+ 16 - 0
alien-store/src/main/java/shop/alien/store/service/StoreServiceFeeRuleService.java

@@ -7,6 +7,8 @@ import shop.alien.entity.store.vo.StoreBookingTableVo;
 import shop.alien.entity.store.vo.StoreServiceFeeRuleDetailVo;
 import shop.alien.entity.store.vo.StoreServiceFeeRuleListVo;
 
+import java.math.BigDecimal;
+import java.util.Date;
 import java.util.List;
 
 public interface StoreServiceFeeRuleService {
@@ -29,5 +31,19 @@ public interface StoreServiceFeeRuleService {
      * @return 本次关闭数量
      */
     int autoCloseExpiredCustomRules();
+
+    /**
+     * 按门店、桌台与指定时刻,汇总当前命中的服务费规则金额(规则类型:1 按人数 × 单价、2 按桌固定、3 按消费金额 × 比例)。
+     * <p>
+     * 实现要点:先查出该桌所有启用规则,再过滤「日期区间」「星期掩码」「时段 [start,end)」,对每条命中规则累加金额。
+     *
+     * @param storeId       门店
+     * @param tableId       桌台(规则按桌维度配置)
+     * @param dinerCount    就餐人数,用于类型 1;为空或 ≤0 时按 1 人计
+     * @param consumeAmount 类型 3 的基数,一般为菜品折后合计(结账提交时用重算后的 goodsSum)
+     * @param at              判定「当前时段」的时间点(预览与提交可用同一时刻)
+     * @return 合计服务费,保留两位小数;参数不全时返回 0
+     */
+    BigDecimal computeMatchedServiceFee(Integer storeId, Integer tableId, Integer dinerCount, BigDecimal consumeAmount, Date at);
 }
 

+ 1103 - 0
alien-store/src/main/java/shop/alien/store/service/WeChatPartnerApplymentService.java

@@ -0,0 +1,1103 @@
+package shop.alien.store.service;
+
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.*;
+import org.apache.commons.lang3.StringUtils;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import shop.alien.entity.store.StoreInfo;
+import shop.alien.entity.store.WechatPartnerApplyment;
+import shop.alien.entity.result.R;
+import shop.alien.mapper.StoreInfoMapper;
+import shop.alien.mapper.WechatPartnerApplymentMapper;
+import shop.alien.store.util.WXPayUtility;
+import shop.alien.util.system.OSUtil;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.Resource;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.lang.reflect.Type;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * 微信支付服务商 — 特约商户进件(提交申请单)
+ * <p>
+ * 对应微信接口:{@code POST /v3/applyment4sub/applyment/},请求体结构以
+ * <a href="https://pay.weixin.qq.com/doc/v3/partner/4012719997">官方文档(提交申请单)</a> 为准;
+ * 提交前会经 {@link #normalizeApplymentJsonForV3(String)} 将旧版扁平 {@code identity_info} 等写法规范为当前文档结构。
+ * </p>
+ * <p>
+ * 使用与 {@code payment.wechatPartnerPay} 相同的服务商商户证书、{@code sp_mchid} 签名。
+ * </p>
+ * <p>
+ * 前端可传敏感字段明文,由 {@link #encryptSensitiveFieldsInApplymentJson(String)} 使用<strong>微信支付平台公钥</strong>
+ * 按文档做 RSA-OAEP 加密后再提交(与 {@link WXPayUtility#encrypt(PublicKey, String)} 一致)。
+ * </p>
+ * <p>
+ * 若微信返回「请确认待处理的消息是否为加密后的密文」:① 核对商户平台下载的<strong>微信支付平台公钥</strong>文件与
+ * {@code wechatPayPublicKeyId}(请求头 {@code Wechatpay-Serial})为同一组;② 勿对已是密文或 media_id 的字段再加密;
+ * ③ 可临时设 {@code payment.wechatPartnerPay.applymentSkipSensitiveEncrypt=true} 判断是否加密链路问题(仅调试)。
+ * </p>
+ */
+@Slf4j
+@Service
+public class WeChatPartnerApplymentService {
+
+    private static final String POST = "POST";
+    private static final String GET = "GET";
+    /** 微信商户图片上传:单文件最大 5MB */
+    private static final long MERCHANT_MEDIA_MAX_BYTES = 5L * 1024 * 1024;
+    private static final Gson GSON = new Gson();
+    private static final Type MAP_TYPE = new TypeToken<Map<String, Object>>() {}.getType();
+
+    /**
+     * 特约商户进件 JSON 中,凡键名在此集合内且值为非空字符串(或数字),将视为需加密的敏感信息。
+     * 名称与微信「提交申请单」文档中要求加密的字段保持一致;若文档新增字段,可在此补充。
+     */
+    private static final Set<String> APPLYMENT_SENSITIVE_FIELD_NAMES;
+
+    static {
+        Set<String> s = new HashSet<>(Arrays.asList(
+                // contact_info
+                "contact_name",
+                "contact_id_number",
+                "mobile_phone",
+                "contact_email",
+                // subject_info.identity_info.id_card_info / id_doc_info
+                "id_card_name",
+                "id_card_number",
+                "id_card_address",
+                "id_doc_name",
+                "id_doc_number",
+                "id_doc_address",
+                // ubo_info_list[]
+                "ubo_id_doc_name",
+                "ubo_id_doc_number",
+                "ubo_id_doc_address",
+                // bank_account_info(微信字段名为 account_number,非 bank_account)
+                "account_name",
+                "account_number",
+                "bank_account"
+        ));
+        APPLYMENT_SENSITIVE_FIELD_NAMES = Collections.unmodifiableSet(s);
+    }
+
+    @Value("${payment.wechatPartnerPay.host:https://api.mch.weixin.qq.com}")
+    private String wechatPayApiHost;
+
+    /**
+     * 【需配置】进件提交路径,须与微信 API 一致(注意末尾斜杠与签名用 path 一致)
+     */
+    @Value("${payment.wechatPartnerPay.applyment4subPath:/v3/applyment4sub/applyment/}")
+    private String applyment4subPath;
+
+    /**
+     * 商户图片上传(用于进件等材料),与微信 {@code POST /v3/merchant/media/upload} 一致
+     */
+    @Value("${payment.wechatPartnerPay.mediaUploadPath:/v3/merchant/media/upload}")
+    private String mediaUploadPath;
+
+    /**
+     * 【需配置】申请单号查询申请状态路径模板,须含占位符 {@code {applyment_id}},与微信签名用 URI 一致。
+     * 文档:{@code GET /v3/applyment4sub/applyment/applyment_id/{applyment_id}}
+     */
+    @Value("${payment.wechatPartnerPay.applymentQueryByIdPath:/v3/applyment4sub/applyment/applyment_id/{applyment_id}}")
+    private String applymentQueryByIdPath;
+
+    @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;
+
+    /**
+     * 为 true 时<strong>不对</strong>进件 JSON 做敏感字段 RSA 加密,直接提交(仅联调排查「密文」类报错时使用)。
+     * 生产环境必须为 false。
+     */
+    @Value("${payment.wechatPartnerPay.applymentSkipSensitiveEncrypt:false}")
+    private boolean applymentSkipSensitiveEncrypt;
+
+    private PrivateKey privateKey;
+    private PublicKey wechatPayPublicKey;
+
+    @Resource
+    private WechatPartnerApplymentMapper wechatPartnerApplymentMapper;
+
+    @Resource
+    private StoreInfoMapper storeInfoMapper;
+
+    @PostConstruct
+    public void loadCertificates() {
+        String privateKeyPath;
+        String pubPath;
+        if ("windows".equals(OSUtil.getOsName())) {
+            privateKeyPath = privateWinKeyPath;
+            pubPath = wechatWinPayPublicKeyFilePath;
+        } else {
+            privateKeyPath = privateLinuxKeyPath;
+            pubPath = wechatLinuxPayPublicKeyFilePath;
+        }
+        log.info("[进件] 加载服务商证书 privateKeyPath={}", privateKeyPath);
+        this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyPath);
+        this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(pubPath);
+    }
+
+    /**
+     * 调用微信支付「商户图片上传」接口,返回 {@code media_id} 供进件等场景引用。
+     * <p>
+     * 签名原文为 {@code meta} 的 JSON 字符串(与 multipart 中 meta 段一致),算法见微信文档。
+     * </p>
+     *
+     * @param fileContent      图片二进制(JPG/PNG/BMP,≤5MB)
+     * @param originalFilename 原始文件名,需带合法后缀;仅用于 meta.filename 与 file 段展示名
+     * @return 成功时 data 中含 {@code media_id};失败为 R.fail
+     */
+    public R<Map<String, Object>> uploadMerchantMedia(byte[] fileContent, String originalFilename) {
+        if (fileContent == null || fileContent.length == 0) {
+            log.warn("[图片上传] 文件为空");
+            return R.fail("文件不能为空");
+        }
+        if (fileContent.length > MERCHANT_MEDIA_MAX_BYTES) {
+            log.warn("[图片上传] 超过大小限制 size={}", fileContent.length);
+            return R.fail("图片大小不能超过 5MB");
+        }
+        String safeName = sanitizeUploadFilename(originalFilename);
+        if (!hasAllowedImageExtension(safeName)) {
+            log.warn("[图片上传] 后缀不允许 filename={}", safeName);
+            return R.fail("仅支持 JPG、JPEG、BMP、PNG 格式");
+        }
+        String sha256Hex;
+        try {
+            sha256Hex = sha256HexLower(fileContent);
+        } catch (NoSuchAlgorithmException e) {
+            log.error("[图片上传] SHA256 不可用", e);
+            return R.fail("服务器摘要算法不可用");
+        }
+        // 顺序固定,保证 meta JSON 与 Authorization 签名原文、multipart 中 meta 段字节完全一致
+        Map<String, String> metaMap = new LinkedHashMap<>();
+        metaMap.put("filename", safeName);
+        metaMap.put("sha256", sha256Hex);
+        String metaJson = GSON.toJson(metaMap);
+
+        String uri = mediaUploadPath;
+        if (StringUtils.isBlank(uri)) {
+            uri = "/v3/merchant/media/upload";
+        }
+        String url = wechatPayApiHost + uri;
+        log.info("[图片上传] 调用微信 url={}, filename={}, size={}, sha256={}",
+                url, safeName, fileContent.length, sha256Hex);
+
+        MediaType fileMediaType = guessImageMediaType(safeName);
+        RequestBody filePart = RequestBody.create(fileContent, fileMediaType);
+        MultipartBody multipartBody = new MultipartBody.Builder()
+                .setType(MultipartBody.FORM)
+                .addFormDataPart("meta", metaJson)
+                .addFormDataPart("file", safeName, filePart)
+                .build();
+
+        Request.Builder builder = new Request.Builder().url(url);
+        builder.addHeader("Accept", "application/json");
+        builder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
+        builder.addHeader("Authorization", WXPayUtility.buildAuthorization(
+                spMchId, merchantSerialNumber, privateKey, POST, uri, metaJson));
+        builder.post(multipartBody);
+        Request request = builder.build();
+
+        OkHttpClient client = new OkHttpClient.Builder().build();
+        try (Response response = client.newCall(request).execute()) {
+            String respBody = WXPayUtility.extractBody(response);
+            if (response.code() >= 200 && response.code() < 300) {
+                WXPayUtility.validateResponse(wechatPayPublicKeyId, wechatPayPublicKey,
+                        response.headers(), respBody);
+                Map<String, Object> map = parseJsonToMap(respBody);
+                log.info("[图片上传] 成功 media_id={}", map != null ? map.get("media_id") : null);
+                return R.data(map);
+            }
+            log.error("[图片上传] 微信返回失败 httpCode={}, body={}", response.code(), respBody);
+            WXPayUtility.ApiException ex = new WXPayUtility.ApiException(response.code(), respBody, response.headers());
+            String errMsg = StringUtils.isNotBlank(ex.getErrorMessage()) ? ex.getErrorMessage()
+                    : (StringUtils.isNotBlank(respBody) ? respBody : "图片上传失败");
+            return R.fail(errMsg);
+        } catch (IOException e) {
+            log.error("[图片上传] HTTP 异常 url={}", url, e);
+            return R.fail("图片上传网络异常: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 去掉路径、仅保留文件名;若无后缀则默认 {@code upload.png}
+     */
+    private static String sanitizeUploadFilename(String originalFilename) {
+        String name = StringUtils.isNotBlank(originalFilename) ? originalFilename.trim() : "upload.png";
+        int slash = Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\'));
+        if (slash >= 0 && slash < name.length() - 1) {
+            name = name.substring(slash + 1);
+        }
+        if (StringUtils.isBlank(name) || !name.contains(".")) {
+            name = "upload.png";
+        }
+        return name;
+    }
+
+    private static boolean hasAllowedImageExtension(String filename) {
+        String lower = filename.toLowerCase(Locale.ROOT);
+        return lower.endsWith(".jpg") || lower.endsWith(".jpeg")
+                || lower.endsWith(".png") || lower.endsWith(".bmp");
+    }
+
+    private static MediaType guessImageMediaType(String filename) {
+        String lower = filename.toLowerCase(Locale.ROOT);
+        if (lower.endsWith(".png")) {
+            return MediaType.parse("image/png");
+        }
+        if (lower.endsWith(".bmp")) {
+            return MediaType.parse("image/bmp");
+        }
+        return MediaType.parse("image/jpeg");
+    }
+
+    private static String sha256HexLower(byte[] data) throws NoSuchAlgorithmException {
+        MessageDigest md = MessageDigest.getInstance("SHA-256");
+        byte[] digest = md.digest(data);
+        StringBuilder sb = new StringBuilder(digest.length * 2);
+        for (byte b : digest) {
+            sb.append(String.format(Locale.ROOT, "%02x", b));
+        }
+        return sb.toString();
+    }
+
+    /**
+     * 对进件请求 JSON 中需要加密的敏感字段(见 {@link #APPLYMENT_SENSITIVE_FIELD_NAMES})逐层遍历:
+     * 若存在对应键且值为非空明文,则使用<strong>当前配置的微信支付平台公钥</strong>加密为 Base64 密文后写回。
+     * <p>
+     * 算法与 {@link WXPayUtility#encrypt(PublicKey, String)} 一致(RSA/ECB/OAEPWithSHA-1AndMGF1Padding)。
+     * 若某字段在 JSON 中不存在或为空字符串,则跳过。
+     * </p>
+     *
+     * @param plainApplymentJson 前端传入的进件 JSON(敏感字段可为明文)
+     * @return 加密后的 JSON 字符串,用于签名与请求体
+     * @throws com.google.gson.JsonSyntaxException JSON 无法解析
+     * @throws IllegalArgumentException           某字段明文过长导致 RSA 加密失败等
+     */
+    public String encryptSensitiveFieldsInApplymentJson(String plainApplymentJson) {
+        if (applymentSkipSensitiveEncrypt) {
+            log.warn("[进件] 已配置 applymentSkipSensitiveEncrypt=true,跳过敏感字段加密(仅调试用)");
+            return plainApplymentJson;
+        }
+        if (StringUtils.isBlank(plainApplymentJson)) {
+            return plainApplymentJson;
+        }
+        Map<String, Object> root = GSON.fromJson(plainApplymentJson, MAP_TYPE);
+        if (root == null || root.isEmpty()) {
+            return plainApplymentJson;
+        }
+        AtomicInteger encryptedCount = new AtomicInteger(0);
+        encryptSensitiveFieldsRecursive(root, wechatPayPublicKey, encryptedCount);
+        log.info("[进件] 敏感字段加密完成,已加密字段数={}", encryptedCount.get());
+        return GSON.toJson(root);
+    }
+
+    /**
+     * 递归遍历 Map / List,对命中键名的字符串(或数字转字符串)做加密替换。
+     */
+    @SuppressWarnings("unchecked")
+    private void encryptSensitiveFieldsRecursive(Object node, PublicKey platformPublicKey, AtomicInteger encryptedCount) {
+        if (node == null) {
+            return;
+        }
+        if (node instanceof Map) {
+            Map<String, Object> map = (Map<String, Object>) node;
+            for (Map.Entry<String, Object> e : map.entrySet()) {
+                String key = e.getKey();
+                Object val = e.getValue();
+                if (APPLYMENT_SENSITIVE_FIELD_NAMES.contains(key)) {
+                    String plain = null;
+                    if (val instanceof String) {
+                        plain = (String) val;
+                    } else if (val instanceof Number) {
+                        plain = String.valueOf(val);
+                    }
+                    if (StringUtils.isNotBlank(plain)) {
+                        if (looksLikeAlreadyEncryptedOrNonPlain(plain)) {
+                            log.info("[进件] 跳过加密(疑似已是密文或非明文) key={}, len={}", key,
+                                    plain != null ? plain.length() : 0);
+                            continue;
+                        }
+                        try {
+                            String cipher = WXPayUtility.encrypt(platformPublicKey, plain);
+                            e.setValue(cipher);
+                            encryptedCount.incrementAndGet();
+                            log.debug("[进件] 已加密字段 key={}", key);
+                        } catch (RuntimeException ex) {
+                            log.error("[进件] 加密字段失败 key={}, plainLen={}", key,
+                                    plain != null ? plain.length() : 0, ex);
+                            throw ex;
+                        }
+                    }
+                } else if (val instanceof Map) {
+                    encryptSensitiveFieldsRecursive(val, platformPublicKey, encryptedCount);
+                } else if (val instanceof List) {
+                    for (Object item : (List<?>) val) {
+                        encryptSensitiveFieldsRecursive(item, platformPublicKey, encryptedCount);
+                    }
+                }
+            }
+        } else if (node instanceof List) {
+            for (Object item : (List<?>) node) {
+                encryptSensitiveFieldsRecursive(item, platformPublicKey, encryptedCount);
+            }
+        }
+    }
+
+    /**
+     * 避免对已是微信返回的密文、media_id、或长 Base64 再套一层 RSA(否则会触发「请确认待处理的消息是否为加密后的密文」)。
+     */
+    private static boolean looksLikeAlreadyEncryptedOrNonPlain(String plain) {
+        if (plain.length() < 32) {
+            return false;
+        }
+        // 图片/材料 media_id 常见形态
+        if (plain.startsWith("V") && plain.contains("_") && plain.length() > 40) {
+            return true;
+        }
+        // 典型 RSA2048+OAEP 密文 Base64 长度较大,且仅含 Base64 字符
+        if (plain.length() >= 200 && plain.matches("^[A-Za-z0-9+/=_-]+$")) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * 将进件 JSON 规范为微信支付 APIv3《提交申请单》当前文档结构(身份证信息须放在 {@code identity_info.id_card_info} 下)。
+     * <ul>
+     *   <li>若 {@code subject_info.identity_info} 在根级存放了 {@code id_card_copy} 等(旧写法),且未在 {@code id_card_info} 中给出,
+     *       则迁入 {@code id_card_info}。</li>
+     *   <li>将 {@code id_card_valid_time_begin} / {@code id_card_valid_time} 重命名为 {@code card_period_begin} / {@code card_period_end}。</li>
+     *   <li>将各 {@code *_period_end}、营业执照等 {@code period_end} 中误写的英文 {@code forever} 规范为文档用语「长期」。</li>
+     *   <li>{@code settlement_info.settlement_id} 微信要求 string;若前端传为 JSON 数字(如 {@code 716}、{@code 716.0}),转为字符串避免 {@code PARAM_ERROR}。</li>
+     * </ul>
+     *
+     * @param json 前端或历史模板传入的 JSON 字符串
+     * @return 规范后的 JSON;解析失败时原样返回并打日志
+     */
+    public String normalizeApplymentJsonForV3(String json) {
+        if (StringUtils.isBlank(json)) {
+            return json;
+        }
+        Map<String, Object> root;
+        try {
+            root = GSON.fromJson(json, MAP_TYPE);
+        } catch (Exception e) {
+            log.warn("[进件] normalizeApplymentJsonForV3 解析失败,原样提交: {}", e.getMessage());
+            return json;
+        }
+        if (root == null || root.isEmpty()) {
+            return json;
+        }
+        try {
+            normalizeIdentityInfoIdCardNested(root);
+            normalizeForeverToLongPeriodRecursive(root);
+            coerceSettlementInfoSettlementIdToString(root);
+        } catch (Exception e) {
+            log.error("[进件] normalizeApplymentJsonForV3 处理异常,原样提交", e);
+            return json;
+        }
+        String out = GSON.toJson(root);
+        log.info("[进件] 已按 APIv3 文档规范 identity_info.id_card_info 等结构,输出长度={}", out.length());
+        return out;
+    }
+
+    /**
+     * 微信「入驻结算规则 ID」字段类型为 string;Gson 解析 JSON 数字会得到 {@link Number},序列化回 JSON 仍为数字,导致接口报错。
+     * 前置将 {@code settlement_info.settlement_id} 若为数字则转为十进制字符串(如 {@code 716.0} → {@code "716"})。
+     */
+    @SuppressWarnings("unchecked")
+    private void coerceSettlementInfoSettlementIdToString(Map<String, Object> root) {
+        Object si = root.get("settlement_info");
+        if (!(si instanceof Map)) {
+            return;
+        }
+        Map<String, Object> settlementInfo = (Map<String, Object>) si;
+        Object sid = settlementInfo.get("settlement_id");
+        if (!(sid instanceof Number)) {
+            return;
+        }
+        Number n = (Number) sid;
+        String asStr = new BigDecimal(n.toString()).stripTrailingZeros().toPlainString();
+        settlementInfo.put("settlement_id", asStr);
+        log.info("[进件] settlement_info.settlement_id 已由数字 {} 转为字符串 \"{}\"", n, asStr);
+    }
+
+    /**
+     * 将 {@code subject_info.identity_info} 下扁平身份证字段迁入 {@code id_card_info},并重命名有效期字段。
+     */
+    @SuppressWarnings("unchecked")
+    private static void normalizeIdentityInfoIdCardNested(Map<String, Object> root) {
+        Object subj = root.get("subject_info");
+        if (!(subj instanceof Map)) {
+            return;
+        }
+        Map<String, Object> subjectInfo = (Map<String, Object>) subj;
+        Object idObj = subjectInfo.get("identity_info");
+        if (!(idObj instanceof Map)) {
+            return;
+        }
+        Map<String, Object> identityInfo = (Map<String, Object>) idObj;
+
+        Object existing = identityInfo.get("id_card_info");
+        Map<String, Object> idCardInfo;
+        if (existing instanceof Map) {
+            idCardInfo = (Map<String, Object>) existing;
+        } else {
+            idCardInfo = new LinkedHashMap<>();
+            identityInfo.put("id_card_info", idCardInfo);
+        }
+
+        String[] migrateKeys = {
+                "id_card_copy", "id_card_national", "id_card_name", "id_card_number", "id_card_address"
+        };
+        for (String mk : migrateKeys) {
+            if (!identityInfo.containsKey(mk)) {
+                continue;
+            }
+            Object v = identityInfo.get(mk);
+            Object cur = idCardInfo.get(mk);
+            if (isNullOrBlankish(cur) && !isNullOrBlankish(v)) {
+                idCardInfo.put(mk, v);
+            }
+            identityInfo.remove(mk);
+        }
+        if (identityInfo.containsKey("id_card_valid_time_begin")) {
+            if (!idCardInfo.containsKey("card_period_begin")) {
+                idCardInfo.put("card_period_begin", identityInfo.get("id_card_valid_time_begin"));
+            }
+            identityInfo.remove("id_card_valid_time_begin");
+        }
+        if (identityInfo.containsKey("id_card_valid_time")) {
+            if (!idCardInfo.containsKey("card_period_end")) {
+                idCardInfo.put("card_period_end", identityInfo.get("id_card_valid_time"));
+            }
+            identityInfo.remove("id_card_valid_time");
+        }
+
+        renameMapKeyIfPresent(idCardInfo, "id_card_valid_time_begin", "card_period_begin");
+        renameMapKeyIfPresent(idCardInfo, "id_card_valid_time", "card_period_end");
+
+        if (idCardInfo.isEmpty()) {
+            identityInfo.remove("id_card_info");
+        }
+    }
+
+    private static boolean isNullOrBlankish(Object v) {
+        if (v == null) {
+            return true;
+        }
+        if (v instanceof String) {
+            return StringUtils.isBlank((String) v);
+        }
+        return false;
+    }
+
+    private static void renameMapKeyIfPresent(Map<String, Object> map, String oldKey, String newKey) {
+        if (!map.containsKey(oldKey)) {
+            return;
+        }
+        if (map.containsKey(newKey)) {
+            map.remove(oldKey);
+            return;
+        }
+        Object val = map.remove(oldKey);
+        map.put(newKey, val);
+    }
+
+    /**
+     * 文档要求身份证等到期填「长期」,部分示例使用 forever;统一为「长期」避免校验失败。
+     */
+    @SuppressWarnings("unchecked")
+    private static void normalizeForeverToLongPeriodRecursive(Object node) {
+        if (node instanceof Map) {
+            Map<String, Object> m = (Map<String, Object>) node;
+            for (Map.Entry<String, Object> e : m.entrySet()) {
+                String k = e.getKey();
+                Object v = e.getValue();
+                if (v instanceof String && "forever".equalsIgnoreCase((String) v)) {
+                    if ("period_end".equals(k) || (k != null && k.endsWith("period_end"))) {
+                        e.setValue("长期");
+                    }
+                } else if (v instanceof Map || v instanceof List) {
+                    normalizeForeverToLongPeriodRecursive(v);
+                }
+            }
+        } else if (node instanceof List) {
+            for (Object item : (List<?>) node) {
+                normalizeForeverToLongPeriodRecursive(item);
+            }
+        }
+    }
+
+    /**
+     * 提交特约商户进件申请单,请求体为微信要求的 JSON(含 business_code、subject_info、bank_account_info 等)。
+     *
+     * @param requestJson  与微信文档一致的 JSON 字符串(可先经 {@link #normalizeApplymentJsonForV3(String)} 规范结构)。
+     *                     若传入 {@code storeId},服务端会写入必填字段 {@code business_code}(规则见 {@link #mergeBusinessCodeIntoApplymentJson(String, Integer)});
+     *                     未传 {@code storeId} 时须在 JSON 中自行填写非空的 {@code business_code}。
+     * @param idempotencyKey 可选;传入则写入 {@code Idempotency-Key} 请求头,用于幂等重试(同一进件业务请固定同一值)
+     * @param storeId        推荐传入;本系统门店 {@code store_info.id}。传入时后端生成 {@code business_code = 服务商商户号(sp_mchid) + "_" + storeId};审核通过后将 {@code sub_mchid} 写入该门店
+     * @return 成功时 data 为微信返回的 JSON 解析后的 Map;失败为 R.fail
+     */
+    public R<Map<String, Object>> submitApplyment(String requestJson, String idempotencyKey, Integer storeId) {
+        if (StringUtils.isBlank(requestJson)) {
+            log.warn("[进件] 请求体为空");
+            return R.fail("请求体不能为空");
+        }
+        final String normalizedJson = normalizeApplymentJsonForV3(requestJson);
+        final String jsonWithBusinessCode;
+        try {
+            jsonWithBusinessCode = mergeBusinessCodeIntoApplymentJson(normalizedJson, storeId);
+        } catch (IllegalArgumentException e) {
+            log.warn("[进件] business_code 处理失败: {}", e.getMessage());
+            return R.fail(e.getMessage());
+        }
+        final String payloadJson;
+        try {
+            payloadJson = encryptSensitiveFieldsInApplymentJson(jsonWithBusinessCode);
+        } catch (Exception e) {
+            log.error("[进件] 敏感字段加密失败", e);
+            return R.fail("进件请求敏感信息加密失败: " + e.getMessage());
+        }
+
+        // 落库:提交前先写入/更新一条记录(以 sp_mchid + business_code 唯一)
+        String businessCode = extractBusinessCodeSafe(jsonWithBusinessCode);
+        upsertApplymentOnSubmitStart(businessCode, idempotencyKey, payloadJson, storeId);
+
+        String uri = applyment4subPath;
+        String url = wechatPayApiHost + uri;
+        log.info("[进件] 调用微信进件接口 url={}, bodyLen={}, hasIdempotencyKey={}",
+                url, payloadJson.length(), StringUtils.isNotBlank(idempotencyKey));
+
+        Request.Builder builder = new Request.Builder().url(url);
+        builder.addHeader("Accept", "application/json");
+        builder.addHeader("Content-Type", "application/json; charset=utf-8");
+        builder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
+        builder.addHeader("Authorization", WXPayUtility.buildAuthorization(
+                spMchId, merchantSerialNumber, privateKey, POST, uri, payloadJson));
+        if (StringUtils.isNotBlank(idempotencyKey)) {
+            builder.addHeader("Idempotency-Key", idempotencyKey);
+        }
+
+        MediaType jsonMediaType = MediaType.parse("application/json; charset=utf-8");
+        RequestBody body = RequestBody.create(payloadJson, jsonMediaType);
+        builder.method(POST, body);
+        Request request = builder.build();
+
+        OkHttpClient client = new OkHttpClient.Builder().build();
+        try (Response response = client.newCall(request).execute()) {
+            String respBody = WXPayUtility.extractBody(response);
+            if (response.code() >= 200 && response.code() < 300) {
+                WXPayUtility.validateResponse(wechatPayPublicKeyId, wechatPayPublicKey,
+                        response.headers(), respBody);
+                Map<String, Object> map = parseJsonToMap(respBody);
+                log.info("[进件] 微信受理成功 httpCode={}", response.code());
+                upsertApplymentOnSubmitResult(businessCode, map, respBody);
+                return R.data(map);
+            }
+            log.error("[进件] 微信返回失败 httpCode={}, body={}", response.code(), respBody);
+            WXPayUtility.ApiException ex = new WXPayUtility.ApiException(response.code(), respBody, response.headers());
+            String errMsg = StringUtils.isNotBlank(ex.getErrorMessage()) ? ex.getErrorMessage()
+                    : (StringUtils.isNotBlank(respBody) ? respBody : "进件请求失败");
+            upsertApplymentOnSubmitError(businessCode, errMsg, respBody);
+            return R.fail(errMsg);
+        } catch (IOException e) {
+            log.error("[进件] HTTP 调用异常 url={}", url, e);
+            upsertApplymentOnSubmitError(businessCode, "进件请求网络异常: " + e.getMessage(), null);
+            return R.fail("进件请求网络异常: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 申请单号查询申请状态,对应微信
+     * <a href="https://pay.weixin.qq.com/doc/v3/partner/4012697052">GET /v3/applyment4sub/applyment/applyment_id/{applyment_id}</a>。
+     * <p>
+     * 成功时返回体含 {@code applyment_state}、{@code applyment_state_msg}、{@code sign_url}、
+     * {@code audit_detail}(驳回时)等,与官方文档一致。
+     * </p>
+     *
+     * @param applymentId 微信支付分配的申请单号
+     * @return 成功时 data 为微信 JSON 解析后的 Map;失败为 R.fail
+     */
+    public R<Map<String, Object>> queryApplymentStateByApplymentId(long applymentId) {
+        if (applymentId <= 0) {
+            log.warn("[进件查询] applyment_id 非法 applymentId={}", applymentId);
+            return R.fail("applyment_id 必须为正整数");
+        }
+        String template = StringUtils.isNotBlank(applymentQueryByIdPath)
+                ? applymentQueryByIdPath
+                : "/v3/applyment4sub/applyment/applyment_id/{applyment_id}";
+        String uri = template.replace("{applyment_id}", String.valueOf(applymentId));
+        String url = wechatPayApiHost + uri;
+        log.info("[进件查询] 调用微信查询申请单状态 url={}, uri={}", url, uri);
+
+        Request.Builder builder = new Request.Builder().url(url);
+        builder.addHeader("Accept", "application/json");
+        builder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
+        builder.addHeader("Authorization", WXPayUtility.buildAuthorization(
+                spMchId, merchantSerialNumber, privateKey, GET, uri, null));
+        builder.get();
+        Request request = builder.build();
+
+        OkHttpClient client = new OkHttpClient.Builder().build();
+        try (Response response = client.newCall(request).execute()) {
+            String respBody = WXPayUtility.extractBody(response);
+            if (response.code() >= 200 && response.code() < 300) {
+                WXPayUtility.validateResponse(wechatPayPublicKeyId, wechatPayPublicKey,
+                        response.headers(), respBody);
+                Map<String, Object> map = parseJsonToMap(respBody);
+                log.info("[进件查询] 成功 applymentId={}, state={}",
+                        applymentId, map != null ? map.get("applyment_state") : null);
+                upsertApplymentOnQueryResult(applymentId, map, respBody);
+                return R.data(map);
+            }
+            log.error("[进件查询] 微信返回失败 applymentId={}, httpCode={}, body={}",
+                    applymentId, response.code(), respBody);
+            WXPayUtility.ApiException ex = new WXPayUtility.ApiException(response.code(), respBody, response.headers());
+            String errMsg = StringUtils.isNotBlank(ex.getErrorMessage()) ? ex.getErrorMessage()
+                    : (StringUtils.isNotBlank(respBody) ? respBody : "查询申请单状态失败");
+            upsertApplymentOnQueryError(applymentId, errMsg, respBody);
+            return R.fail(errMsg);
+        } catch (IOException e) {
+            log.error("[进件查询] HTTP 异常 applymentId={}, url={}", applymentId, url, e);
+            upsertApplymentOnQueryError(applymentId, "查询申请单状态网络异常: " + e.getMessage(), null);
+            return R.fail("查询申请单状态网络异常: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 按门店查询最近一次特约商户进件状态(仅读本地库,不调微信接口)。
+     * <p>
+     * 从 {@code wechat_partner_applyment} 按 {@code store_id} 取最新一条(按更新时间、主键倒序)。
+     * 进件提交、定时任务或查询接口已把微信侧状态写入本表,此处直接返回落库数据即可。
+     * </p>
+     *
+     * @param storeId 门店主键 {@link StoreInfo#getId()},须与进件提交时 query 参数 {@code storeId} 一致
+     * @return 成功时 data 为列表:有记录时含一条 Map,无进件记录时为空列表(不返回业务错误)
+     */
+    public R<List<WechatPartnerApplyment>> queryApplymentStateByStoreId(Integer storeId) {
+        if (storeId == null || storeId <= 0) {
+            log.warn("[进件查询] storeId 非法 storeId={}", storeId);
+            return R.fail("门店 storeId 不能为空且必须大于 0");
+        }
+        List<WechatPartnerApplyment> record = findLatestApplymentByStoreId(storeId);
+        if (record == null) {
+            log.info("[进件查询] 按门店无进件记录,返回空列表 storeId={}", storeId);
+            return R.data(Collections.emptyList());
+        }
+        log.info("[进件查询] 按门店返回本地库状态 storeId={}", storeId);
+        return R.data(record);
+    }
+
+    /**
+     * 同一门店可能存在多次进件记录时,取最新一条用于展示与查询。
+     */
+    private List<WechatPartnerApplyment> findLatestApplymentByStoreId(int storeId) {
+        LambdaQueryWrapper<WechatPartnerApplyment> qw = new LambdaQueryWrapper<WechatPartnerApplyment>()
+                .eq(WechatPartnerApplyment::getStoreId, storeId)
+                .orderByDesc(WechatPartnerApplyment::getUpdatedAt)
+                .orderByDesc(WechatPartnerApplyment::getId);
+        return wechatPartnerApplymentMapper.selectList(qw);
+    }
+
+
+    /**
+     * 合并 {@code business_code} 到进件根节点。
+     * <p>
+     * 微信要求 {@code business_code}(业务申请编号)必填且唯一。当请求带 {@code storeId} 时,由后端统一生成为:
+     * {@code 服务商商户号(sp_mchid) + "_" + storeId}(下划线分隔,与配置项 {@code payment.wechatPartnerPay.business.spMchId} 一致),
+     * 避免前端漏传或传空串导致 {@code PARAM_ERROR}。
+     * </p>
+     * <p>
+     * 未传 {@code storeId} 时,若 JSON 中 {@code business_code} 已非空则保留;否则抛出 {@link IllegalArgumentException}。
+     * </p>
+     *
+     * @param normalizedJson 已规范化的进件 JSON 字符串
+     * @param storeId        可选,有值则覆盖/写入 {@code business_code}
+     * @return 合并后的 JSON 字符串
+     */
+    private String mergeBusinessCodeIntoApplymentJson(String normalizedJson, Integer storeId) {
+        Map<String, Object> root;
+        try {
+            root = GSON.fromJson(normalizedJson, MAP_TYPE);
+        } catch (Exception e) {
+            throw new IllegalArgumentException("进件 JSON 解析失败,无法写入 business_code: " + e.getMessage());
+        }
+        if (root == null) {
+            root = new LinkedHashMap<>();
+        }
+        if (storeId != null) {
+            String sp = StringUtils.trimToEmpty(spMchId);
+            if (StringUtils.isBlank(sp)) {
+                throw new IllegalArgumentException("未配置服务商商户号 payment.wechatPartnerPay.business.spMchId,无法生成 business_code");
+            }
+            String code = sp + "_" + storeId + "_" + System.currentTimeMillis();
+            root.put("business_code", code);
+            log.info("[进件] 已生成并写入 business_code={}(sp_mchid_storeId 格式,storeId={})", code, storeId);
+            return GSON.toJson(root);
+        }
+        Object v = root.get("business_code");
+        String existing = v == null ? "" : String.valueOf(v).trim();
+        if (StringUtils.isBlank(existing)) {
+            throw new IllegalArgumentException(
+                    "请传入查询参数 storeId(后端将生成 business_code=服务商商户号_storeId),或在 JSON 根节点填写非空的 business_code");
+        }
+        return GSON.toJson(root);
+    }
+
+    /**
+     * 从进件 JSON 中提取 business_code(用于落库幂等主键);解析失败则返回 null。
+     */
+    private static String extractBusinessCodeSafe(String requestJson) {
+        if (StringUtils.isBlank(requestJson)) {
+            return null;
+        }
+        try {
+            Map<String, Object> root = GSON.fromJson(requestJson, MAP_TYPE);
+            Object v = root != null ? root.get("business_code") : null;
+            if (v == null) {
+                return null;
+            }
+            String s = String.valueOf(v).trim();
+            return StringUtils.isNotBlank(s) ? s : null;
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private void upsertApplymentOnSubmitStart(String businessCode, String idempotencyKey, String payloadJson, Integer storeId) {
+        if (StringUtils.isBlank(businessCode)) {
+            log.warn("[进件落库] business_code 为空,跳过落库(请在请求体中填写 business_code)");
+            return;
+        }
+        String dbSpMchid = spMchidForDb();
+        Date now = new Date();
+        WechatPartnerApplyment record = findByBusinessCode(businessCode);
+        if (record == null) {
+            record = new WechatPartnerApplyment();
+            record.setSpMchid(dbSpMchid);
+            record.setBusinessCode(businessCode);
+            record.setCreatedAt(now);
+            record.setIsApproved(0);
+            record.setApplymentState("APPLYMENT_STATE_EDITTING");
+        }
+        // 若历史记录的 sp_mchid 为空(或 UNKNOWN),且现在已可确定,则补齐
+        if (StringUtils.isBlank(record.getSpMchid()) || "UNKNOWN".equalsIgnoreCase(record.getSpMchid())) {
+            record.setSpMchid(dbSpMchid);
+        }
+        if (storeId != null) {
+            record.setStoreId(storeId);
+        }
+        record.setIdempotencyKey(idempotencyKey);
+        record.setLastSubmitTime(now);
+        record.setRequestJson(payloadJson);
+        record.setUpdatedAt(now);
+        if (record.getId() == null) {
+            wechatPartnerApplymentMapper.insert(record);
+        } else {
+            wechatPartnerApplymentMapper.updateById(record);
+        }
+        log.info("[进件落库] submitStart sp_mchid={}, business_code={}, storeId={}, id={}", record.getSpMchid(), businessCode, record.getStoreId(), record.getId());
+    }
+
+    private void upsertApplymentOnSubmitResult(String businessCode, Map<String, Object> respMap, String respBody) {
+        if (StringUtils.isBlank(businessCode)) {
+            return;
+        }
+        String dbSpMchid = spMchidForDb();
+        WechatPartnerApplyment record = findByBusinessCode(businessCode);
+        if (record == null) {
+            record = new WechatPartnerApplyment();
+            record.setSpMchid(dbSpMchid);
+            record.setBusinessCode(businessCode);
+            record.setCreatedAt(new Date());
+        }
+        if (StringUtils.isBlank(record.getSpMchid()) || "UNKNOWN".equalsIgnoreCase(record.getSpMchid())) {
+            record.setSpMchid(dbSpMchid);
+        }
+        Date now = new Date();
+        record.setLastSubmitRespJson(respBody);
+        record.setUpdatedAt(now);
+        record.setApplymentId(asLong(respMap != null ? respMap.get("applyment_id") : null));
+        record.setSubMchid(asString(respMap != null ? respMap.get("sub_mchid") : null));
+        record.setSignUrl(asString(respMap != null ? respMap.get("sign_url") : null));
+        record.setApplymentState(asString(respMap != null ? respMap.get("applyment_state") : null));
+        record.setApplymentStateMsg(asString(respMap != null ? respMap.get("applyment_state_msg") : null));
+        record.setIsApproved(calcIsApproved(record.getApplymentState()));
+        if (record.getIsApproved() != null && record.getIsApproved() == 2) {
+            record.setRejectReason(extractRejectReason(respMap));
+        }
+        if (record.getId() == null) {
+            wechatPartnerApplymentMapper.insert(record);
+        } else {
+            wechatPartnerApplymentMapper.updateById(record);
+        }
+        log.info("[进件落库] submitResult business_code={}, applyment_id={}, state={}",
+                businessCode, record.getApplymentId(), record.getApplymentState());
+        syncStoreInfoWechatSubMchidIfApproved(record);
+    }
+
+    private void upsertApplymentOnSubmitError(String businessCode, String errMsg, String respBody) {
+        if (StringUtils.isBlank(businessCode)) {
+            return;
+        }
+        String dbSpMchid = spMchidForDb();
+        WechatPartnerApplyment record = findByBusinessCode(businessCode);
+        if (record == null) {
+            record = new WechatPartnerApplyment();
+            record.setSpMchid(dbSpMchid);
+            record.setBusinessCode(businessCode);
+            record.setCreatedAt(new Date());
+        }
+        if (StringUtils.isBlank(record.getSpMchid()) || "UNKNOWN".equalsIgnoreCase(record.getSpMchid())) {
+            record.setSpMchid(dbSpMchid);
+        }
+        Date now = new Date();
+        record.setLastSubmitRespJson(respBody);
+        record.setRejectReason(trimTo(errMsg, 2048));
+        record.setUpdatedAt(now);
+        // 错误时不强制设置 isApproved=2,因为可能是参数错误/系统错误;保留 0
+        if (record.getIsApproved() == null) {
+            record.setIsApproved(0);
+        }
+        if (record.getId() == null) {
+            wechatPartnerApplymentMapper.insert(record);
+        } else {
+            wechatPartnerApplymentMapper.updateById(record);
+        }
+        log.warn("[进件落库] submitError business_code={}, msg={}", businessCode, errMsg);
+    }
+
+    private void upsertApplymentOnQueryResult(long applymentId, Map<String, Object> respMap, String respBody) {
+        Date now = new Date();
+        String dbSpMchid = spMchidForDb();
+        WechatPartnerApplyment record = findByApplymentId(applymentId);
+        if (record == null) {
+            record = new WechatPartnerApplyment();
+            record.setSpMchid(dbSpMchid);
+            record.setApplymentId(applymentId);
+            record.setCreatedAt(now);
+            // 兜底:尝试从响应里带回 business_code,方便后续用业务编号对齐
+            record.setBusinessCode(asString(respMap != null ? respMap.get("business_code") : null));
+        }
+        if (StringUtils.isBlank(record.getSpMchid()) || "UNKNOWN".equalsIgnoreCase(record.getSpMchid())) {
+            record.setSpMchid(dbSpMchid);
+        }
+        record.setLastQueryTime(now);
+        record.setLastQueryRespJson(respBody);
+        record.setUpdatedAt(now);
+        record.setSubMchid(asString(respMap != null ? respMap.get("sub_mchid") : null));
+        record.setSignUrl(asString(respMap != null ? respMap.get("sign_url") : null));
+        record.setApplymentState(asString(respMap != null ? respMap.get("applyment_state") : null));
+        record.setApplymentStateMsg(asString(respMap != null ? respMap.get("applyment_state_msg") : null));
+        record.setIsApproved(calcIsApproved(record.getApplymentState()));
+        if (record.getIsApproved() != null && record.getIsApproved() == 2) {
+            record.setRejectReason(extractRejectReason(respMap));
+        }
+        if (record.getId() == null) {
+            wechatPartnerApplymentMapper.insert(record);
+        } else {
+            wechatPartnerApplymentMapper.updateById(record);
+        }
+        syncStoreInfoWechatSubMchidIfApproved(record);
+    }
+
+    private void upsertApplymentOnQueryError(long applymentId, String errMsg, String respBody) {
+        WechatPartnerApplyment record = findByApplymentId(applymentId);
+        if (record == null) {
+            // 查询失败但本地无记录时,不强行插入;避免污染
+            log.warn("[进件落库] queryError 本地无记录 applyment_id={}, msg={}", applymentId, errMsg);
+            return;
+        }
+        Date now = new Date();
+        record.setLastQueryTime(now);
+        record.setLastQueryRespJson(respBody);
+        record.setRejectReason(trimTo(errMsg, 2048));
+        record.setUpdatedAt(now);
+        wechatPartnerApplymentMapper.updateById(record);
+    }
+
+    /**
+     * 进件状态为「已完成」且微信返回 {@code sub_mchid} 时,将特约商户号写入关联门店 {@code store_info.wechat_sub_mchid}。
+     */
+    private void syncStoreInfoWechatSubMchidIfApproved(WechatPartnerApplyment record) {
+        if (record == null || record.getStoreId() == null) {
+            return;
+        }
+        if (record.getIsApproved() == null || record.getIsApproved() != 1) {
+            return;
+        }
+        if (StringUtils.isBlank(record.getSubMchid())) {
+            log.warn("[进件] 状态已完成但 sub_mchid 为空,跳过写入门店 storeId={}", record.getStoreId());
+            return;
+        }
+        try {
+            StoreInfo si = storeInfoMapper.selectById(record.getStoreId());
+            if (si == null) {
+                log.warn("[进件] 门店不存在,跳过写入 wechat_sub_mchid storeId={}", record.getStoreId());
+                return;
+            }
+            storeInfoMapper.update(null, new LambdaUpdateWrapper<StoreInfo>()
+                    .eq(StoreInfo::getId, record.getStoreId())
+                    .set(StoreInfo::getWechatSubMchid, record.getSubMchid()));
+            log.info("[进件] 已写入门店 wechat_sub_mchid storeId={}, sub_mchid={}", record.getStoreId(), record.getSubMchid());
+        } catch (Exception e) {
+            log.error("[进件] 写入 store_info.wechat_sub_mchid 失败 storeId={}", record.getStoreId(), e);
+        }
+    }
+
+    private WechatPartnerApplyment findByBusinessCode(String businessCode) {
+        LambdaQueryWrapper<WechatPartnerApplyment> qw = new LambdaQueryWrapper<WechatPartnerApplyment>()
+                .eq(WechatPartnerApplyment::getBusinessCode, businessCode)
+                .last("limit 1");
+        // 兼容:提交初期可能未能确定 sp_mchid,因此此处不强制按 sp_mchid 过滤
+        return wechatPartnerApplymentMapper.selectOne(qw);
+    }
+
+    private WechatPartnerApplyment findByApplymentId(long applymentId) {
+        LambdaQueryWrapper<WechatPartnerApplyment> qw = new LambdaQueryWrapper<WechatPartnerApplyment>()
+                .eq(WechatPartnerApplyment::getApplymentId, applymentId)
+                .last("limit 1");
+        return wechatPartnerApplymentMapper.selectOne(qw);
+    }
+
+    /**
+     * 提交初期某些环境可能尚未配置/注入 sp_mchid。为保证落库可用:
+     * - 若已配置,则返回真实 sp_mchid;
+     * - 若未配置,则返回固定占位值 UNKNOWN(避免 NOT NULL/唯一键问题)。
+     */
+    private String spMchidForDb() {
+        if (StringUtils.isNotBlank(spMchId)) {
+            return spMchId;
+        }
+        log.warn("[进件落库] sp_mchid 未配置,使用占位 UNKNOWN(建议尽快补齐 payment.wechatPartnerPay.business.spMchId)");
+        return "UNKNOWN";
+    }
+
+    private static Integer calcIsApproved(String applymentState) {
+        if (StringUtils.isBlank(applymentState)) {
+            return 0;
+        }
+        if ("APPLYMENT_STATE_FINISHED".equalsIgnoreCase(applymentState)) {
+            return 1;
+        }
+        if ("APPLYMENT_STATE_REJECTED".equalsIgnoreCase(applymentState)) {
+            return 2;
+        }
+        return 0;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static String extractRejectReason(Map<String, Object> respMap) {
+        if (respMap == null) {
+            return null;
+        }
+        Object audit = respMap.get("audit_detail");
+        if (!(audit instanceof List)) {
+            return null;
+        }
+        List<Object> list = (List<Object>) audit;
+        List<String> reasons = new ArrayList<>();
+        for (Object item : list) {
+            if (!(item instanceof Map)) {
+                continue;
+            }
+            Map<String, Object> m = (Map<String, Object>) item;
+            String field = asString(m.get("field"));
+            String name = asString(m.get("field_name"));
+            String reason = asString(m.get("reject_reason"));
+            if (StringUtils.isBlank(reason)) {
+                continue;
+            }
+            String line = StringUtils.isNotBlank(name) ? name : (StringUtils.isNotBlank(field) ? field : "");
+            if (StringUtils.isNotBlank(line)) {
+                reasons.add(line + ":" + reason);
+            } else {
+                reasons.add(reason);
+            }
+        }
+        return trimTo(String.join(";", reasons), 2048);
+    }
+
+    private static String asString(Object v) {
+        if (v == null) {
+            return null;
+        }
+        String s = String.valueOf(v);
+        s = s != null ? s.trim() : null;
+        return StringUtils.isNotBlank(s) ? s : null;
+    }
+
+    private static Long asLong(Object v) {
+        if (v == null) {
+            return null;
+        }
+        try {
+            if (v instanceof Number) {
+                return ((Number) v).longValue();
+            }
+            String s = String.valueOf(v).trim();
+            if (StringUtils.isBlank(s)) {
+                return null;
+            }
+            return Long.parseLong(s);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private static String trimTo(String s, int maxLen) {
+        if (s == null) {
+            return null;
+        }
+        if (s.length() <= maxLen) {
+            return s;
+        }
+        return s.substring(0, maxLen);
+    }
+
+    private static Map<String, Object> parseJsonToMap(String json) {
+        if (StringUtils.isBlank(json)) {
+            return new HashMap<>();
+        }
+        try {
+            return GSON.fromJson(json, MAP_TYPE);
+        } catch (Exception e) {
+            log.warn("[进件] 响应 JSON 解析为 Map 失败,返回原始字符串封装", e);
+            Map<String, Object> m = new HashMap<>();
+            m.put("raw", json);
+            return m;
+        }
+    }
+}

+ 517 - 0
alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingCheckoutServiceImpl.java

@@ -0,0 +1,517 @@
+package shop.alien.store.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+import shop.alien.entity.store.StoreOrder;
+import shop.alien.entity.store.StoreOrderDetail;
+import shop.alien.entity.store.StoreTable;
+import shop.alien.entity.store.UserReservationTable;
+import shop.alien.entity.store.dto.StoreBookingCheckoutLineSubmitDTO;
+import shop.alien.entity.store.dto.StoreBookingCheckoutSubmitDTO;
+import shop.alien.entity.store.vo.StoreBookingCheckoutLineVo;
+import shop.alien.entity.store.vo.StoreBookingCheckoutPrepVo;
+import shop.alien.entity.store.vo.StoreBookingCheckoutResultVo;
+import shop.alien.mapper.StoreOrderDetailMapper;
+import shop.alien.mapper.StoreOrderMapper;
+import shop.alien.mapper.StoreTableMapper;
+import shop.alien.mapper.UserReservationTableMapper;
+import shop.alien.store.service.StoreBookingCheckoutService;
+import shop.alien.store.service.StoreServiceFeeRuleService;
+import shop.alien.util.common.JwtUtil;
+import com.alibaba.fastjson.JSONObject;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 预订结账核心实现。
+ * prepCheckout:只读,用于前端进入结账页展示订单、复合桌号、行级免单/折扣字段、当前时段服务费预览。
+ * submitCheckout:事务内写明细与主单,计算实付,置支付成功;可选按订单批量释放桌台(清桌)。
+ * 实付公式(摘要):先按行重算小计,再 goodsSum + 餐具 + 服务费 + 其他费用 - 优惠券 - 手动减免,
+ * 可选再乘整单折扣比例;若整单免单则实付为 0;totalAmount 存折前含费毛额 gross,payAmount 存实付。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(rollbackFor = Exception.class)
+public class StoreBookingCheckoutServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOrder> implements StoreBookingCheckoutService {
+
+    private final StoreOrderDetailMapper storeOrderDetailMapper;
+    private final StoreTableMapper storeTableMapper;
+    private final UserReservationTableMapper userReservationTableMapper;
+    private final StoreServiceFeeRuleService storeServiceFeeRuleService;
+
+    /**
+     * 结账页预览:不修改数据库。
+     */
+    @Override
+    public StoreBookingCheckoutPrepVo prepCheckout(Integer storeId, Integer tableId) {
+        // 1) 入参校验:门店与桌台必须同时传入
+        if (storeId == null || tableId == null) {
+            throw new IllegalArgumentException("门店ID与桌台ID不能为空");
+        }
+        // 2) 加载桌台并校验归属,防止跨门店查单
+        StoreTable table = storeTableMapper.selectById(tableId);
+        if (table == null || !storeId.equals(table.getStoreId())) {
+            throw new IllegalArgumentException("桌台不存在或不属于该门店");
+        }
+        // 3) 解析「本桌当前应对应的待支付订单」(优先 current_order_id,解决一桌多单台共一单)
+        StoreOrder order = resolvePendingOrderForCheckout(storeId, table);
+        if (order == null) {
+            throw new IllegalArgumentException("当前桌台没有待支付订单");
+        }
+
+        // 4) 读出订单全部明细,组装前端行 VO,并累加「菜品原价合计」goodsRaw(单价×数量,不受行免单/折扣影响,供服务费类型3作基数)
+        List<StoreOrderDetail> details = listDetails(order.getId());
+        BigDecimal goodsRaw = BigDecimal.ZERO;
+        List<StoreBookingCheckoutLineVo> lines = new ArrayList<>();
+        for (StoreOrderDetail d : details) {
+            StoreBookingCheckoutLineVo lv = new StoreBookingCheckoutLineVo();
+            lv.setDetailId(d.getId());
+            lv.setCuisineId(d.getCuisineId());
+            lv.setCuisineName(d.getCuisineName());
+            lv.setUnitPrice(d.getUnitPrice());
+            lv.setQuantity(d.getQuantity());
+            lv.setSubtotalAmount(d.getSubtotalAmount());
+            // nz:库中 null 视为 0,避免前端展示 null
+            lv.setFreeDish(nz(d.getFreeDish()));
+            lv.setDiscountFlag(nz(d.getDiscountFlag()));
+            lv.setDiscountNumber(d.getDiscountNumber());
+            lines.add(lv);
+
+            BigDecimal unit = d.getUnitPrice() != null ? d.getUnitPrice() : BigDecimal.ZERO;
+            int qty = d.getQuantity() != null ? d.getQuantity() : 0;
+            goodsRaw = goodsRaw.add(unit.multiply(BigDecimal.valueOf(qty)).setScale(2, RoundingMode.HALF_UP));
+        }
+
+        // 5) 用「当前时间 + 本桌 + 就餐人数 + 消费基数」匹配规则,得到预览服务费(与提交时算法一致,仅时刻可能不同)
+        Date now = new Date();
+        BigDecimal servicePreview = storeServiceFeeRuleService.computeMatchedServiceFee(
+                storeId, tableId, order.getDinerCount(), goodsRaw, now);
+
+        // 6) 填充返回 VO:主单快照 + 复合桌号 + 明细行
+        StoreBookingCheckoutPrepVo vo = new StoreBookingCheckoutPrepVo();
+        vo.setOrderId(order.getId());
+        vo.setOrderNo(order.getOrderNo());
+        vo.setStoreId(order.getStoreId());
+        vo.setTableId(tableId);
+        vo.setUserReservationId(order.getUserReservationId());
+        vo.setDinerCount(order.getDinerCount());
+        vo.setOrderStatus(order.getOrderStatus());
+        vo.setPayStatus(order.getPayStatus());
+        vo.setGoodsAmountRaw(goodsRaw);
+        vo.setTablewareFee(nzAmount(order.getTablewareFee()));
+        vo.setServiceFeeOnOrder(order.getServiceFee());
+        vo.setServiceFeeForCurrentSlot(servicePreview);
+        vo.setCouponDiscountAmount(nzAmount(order.getDiscountAmount()));
+        vo.setTotalAmount(order.getTotalAmount());
+        vo.setPayAmount(order.getPayAmount());
+        vo.setLines(lines);
+
+        // 7) 复合桌号:优先从预约关联表取多桌;无预约则取 current_order_id 绑定的桌;再兜底订单主桌
+        TableDisplay td = buildTableNumbersDisplay(order, tableId);
+        vo.setTableNumbersDisplay(td.display);
+        vo.setTableIdsUnderReservation(td.tableIds);
+        return vo;
+    }
+
+    /**
+     * 提交结账:写明细、写主单、置已支付,可选清桌。
+     */
+    @Override
+    public StoreBookingCheckoutResultVo submitCheckout(StoreBookingCheckoutSubmitDTO dto) {
+        if (dto == null) {
+            throw new IllegalArgumentException("请求体不能为空");
+        }
+        Integer storeId = dto.getStoreId();
+        Integer tableId = dto.getTableId();
+        if (storeId == null || tableId == null) {
+            throw new IllegalArgumentException("门店ID与桌台ID不能为空");
+        }
+        StoreTable table = storeTableMapper.selectById(tableId);
+        if (table == null || !storeId.equals(table.getStoreId())) {
+            throw new IllegalArgumentException("桌台不存在或不属于该门店");
+        }
+
+        // 1) 定位订单:可显式传 orderId,否则与 prep 相同规则从桌台解析
+        StoreOrder order = null;
+        if (dto.getOrderId() != null) {
+            order = this.getById(dto.getOrderId());
+            if (order == null || !storeId.equals(order.getStoreId())) {
+                throw new IllegalArgumentException("订单不存在或不属于该门店");
+            }
+        }
+        if (order == null) {
+            order = resolvePendingOrderForCheckout(storeId, table);
+        }
+        if (order == null) {
+            throw new IllegalArgumentException("当前桌台没有待支付订单");
+        }
+        // 2) 必须是待支付(orderStatus=0 且 payStatus=0)
+        if (!isPending(order)) {
+            throw new IllegalArgumentException("订单不是待支付状态,无法结账");
+        }
+        // 3) 会话一致性:防止拿 A 桌订单在 B 桌提交(含预约多桌场景)
+        if (!belongsToTableSession(order, table)) {
+            throw new IllegalArgumentException("订单与当前桌台会话不一致,无法结账");
+        }
+
+        // 4) 校验「开关为 1 时必填金额/原因」等业务规则
+        validateCheckoutFlags(dto);
+
+        // 5) 明细行必须全集匹配:请求中的 detailId 集合与库中完全一致(防止漏改行或重复行)
+        List<StoreOrderDetail> dbLines = listDetails(order.getId());
+        Map<Integer, StoreOrderDetail> byId = dbLines.stream().collect(Collectors.toMap(StoreOrderDetail::getId, x -> x));
+        Set<Integer> expected = new HashSet<>(byId.keySet());
+        Set<Integer> got = dto.getLines().stream().map(StoreBookingCheckoutLineSubmitDTO::getDetailId).collect(Collectors.toSet());
+        if (!expected.equals(got)) {
+            throw new IllegalArgumentException("明细行须覆盖本单全部菜品行且不可重复");
+        }
+
+        Map<Integer, StoreBookingCheckoutLineSubmitDTO> patch = dto.getLines().stream()
+                .collect(Collectors.toMap(StoreBookingCheckoutLineSubmitDTO::getDetailId, x -> x));
+
+        // 6) 逐行写回免单/折扣,并重算 subtotal_amount;累加 goodsSum(折后菜品合计)
+        BigDecimal goodsSum = BigDecimal.ZERO;
+        Date now = new Date();
+        Integer userId = currentUserId();
+
+        for (StoreOrderDetail d : dbLines) {
+            StoreBookingCheckoutLineSubmitDTO p = patch.get(d.getId());
+            int free = p.getFreeDish() != null && p.getFreeDish() == 1 ? 1 : 0;
+            int disc = p.getDiscountFlag() != null && p.getDiscountFlag() == 1 ? 1 : 0;
+            BigDecimal lineSub = computeLineSubtotal(d, free, disc, p.getDiscountNumber());
+            goodsSum = goodsSum.add(lineSub);
+
+            d.setFreeDish(free);
+            d.setDiscountFlag(disc);
+            // 未开折扣时清空 discount_number,避免脏数据;开折扣时存归一化后的比例(0~1)
+            d.setDiscountNumber(disc == 1 ? normalizeDiscountRate(p.getDiscountNumber()) : null);
+            d.setSubtotalAmount(lineSub);
+            d.setUpdatedTime(now);
+            d.setUpdatedUserId(userId);
+            storeOrderDetailMapper.updateById(d);
+        }
+
+        // 7) 费用层:餐具取订单原值;服务费按「重算后的 goodsSum」再算一遍(与预览可能因行折扣而变化)
+        BigDecimal tableware = nzAmount(order.getTablewareFee());
+        BigDecimal serviceFee = storeServiceFeeRuleService.computeMatchedServiceFee(
+                storeId, tableId, order.getDinerCount(), goodsSum, now);
+
+        // 8) 其他费用、优惠券、手动减免(后两者来自订单或请求)
+        BigDecimal other = Objects.equals(dto.getHasOtherFee(), 1)
+                ? nzAmount(dto.getOtherFeeAmount())
+                : BigDecimal.ZERO;
+        BigDecimal coupon = nzAmount(order.getDiscountAmount());
+        BigDecimal manual = Objects.equals(dto.getHasManualReduction(), 1)
+                ? nzAmount(dto.getManualReductionAmount())
+                : BigDecimal.ZERO;
+
+        // 9) 毛额 gross = 菜品 + 餐具 + 服务费 + 其他费用;中间值 x 先减券、减手动减免,再按需乘整单折扣
+        BigDecimal gross = goodsSum.add(tableware).add(serviceFee).add(other);
+        BigDecimal x = gross.subtract(coupon).subtract(manual);
+        if (Objects.equals(dto.getHasWholeDiscount(), 1)) {
+            x = x.multiply(normalizeDiscountRate(dto.getWholeDiscountRatio())).setScale(2, RoundingMode.HALF_UP);
+        }
+        BigDecimal pay;
+        if (Objects.equals(dto.getIsFreeOrder(), 1)) {
+            pay = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+        } else {
+            // 避免出现负实付
+            pay = x.max(BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP);
+        }
+
+        // 10) 回写主单金额与结账扩展字段
+        order.setTotalAmount(gross.setScale(2, RoundingMode.HALF_UP));
+        order.setPayAmount(pay);
+        order.setServiceFee(serviceFee);
+        order.setHasOtherFee(nz(dto.getHasOtherFee()));
+        order.setOtherFeeAmount(Objects.equals(dto.getHasOtherFee(), 1) ? dto.getOtherFeeAmount() : null);
+        order.setOtherFeeReason(trimToNull(dto.getOtherFeeReason()));
+        order.setHasManualReduction(nz(dto.getHasManualReduction()));
+        order.setManualReductionAmount(Objects.equals(dto.getHasManualReduction(), 1) ? dto.getManualReductionAmount() : null);
+        order.setManualReductionReason(trimToNull(dto.getManualReductionReason()));
+        order.setHasWholeDiscount(nz(dto.getHasWholeDiscount()));
+        order.setWholeDiscountRatio(Objects.equals(dto.getHasWholeDiscount(), 1) ? normalizeDiscountRate(dto.getWholeDiscountRatio()) : null);
+        order.setWholeDiscountReason(trimToNull(dto.getWholeDiscountReason()));
+        order.setIsFreeOrder(nz(dto.getIsFreeOrder()));
+        order.setFreeOrderReason(trimToNull(dto.getFreeOrderReason()));
+
+        // 11) 标记已支付(与 alien-dining 约定:orderStatus=1 已支付,payStatus=1)
+        order.setOrderStatus(1);
+        order.setPayStatus(1);
+        order.setPayType(dto.getPayType());
+        order.setPayTime(now);
+        order.setPayTradeNo("CHK" + System.currentTimeMillis());
+        order.setUpdatedTime(now);
+        order.setUpdatedUserId(userId);
+        this.updateById(order);
+
+        // 12) 仅当 clearTable=true:释放所有 current_order_id 指向本单的桌台(多桌一单时一并清空)
+        boolean clear = Boolean.TRUE.equals(dto.getClearTable());
+        if (clear) {
+            clearTablesForOrder(storeId, order.getId(), userId, now);
+        }
+
+        StoreBookingCheckoutResultVo vo = new StoreBookingCheckoutResultVo();
+        vo.setOrderId(order.getId());
+        vo.setOrderNo(order.getOrderNo());
+        vo.setPayAmount(pay);
+        vo.setClearedTables(clear);
+        log.info("预订结账完成 orderId={} pay={} clearTable={}", order.getId(), pay, clear);
+        return vo;
+    }
+
+    /**
+     * 判断当前操作的桌台是否允许操作该订单:
+     * 1) 桌台 current_order_id 指向本单(多桌绑同一订单时的主路径);
+     * 2) 订单主表 table_id 等于本桌(单桌或主桌);
+     * 3) 预约关联表包含本桌(预约多桌、指针未同步时的兜底)。
+     */
+    private boolean belongsToTableSession(StoreOrder order, StoreTable table) {
+        if (table.getCurrentOrderId() != null && table.getCurrentOrderId().equals(order.getId())) {
+            return true;
+        }
+        if (order.getTableId() != null && order.getTableId().equals(table.getId())) {
+            return true;
+        }
+        if (order.getUserReservationId() != null) {
+            LambdaQueryWrapper<UserReservationTable> q = new LambdaQueryWrapper<>();
+            q.eq(UserReservationTable::getReservationId, order.getUserReservationId())
+                    .eq(UserReservationTable::getTableId, table.getId());
+            if (userReservationTableMapper.selectCount(q) > 0) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 校验「开关为 1」时依赖字段是否齐全,避免写入半成数据。
+     */
+    private void validateCheckoutFlags(StoreBookingCheckoutSubmitDTO dto) {
+        if (Objects.equals(dto.getHasOtherFee(), 1)) {
+            if (dto.getOtherFeeAmount() == null || dto.getOtherFeeAmount().compareTo(BigDecimal.ZERO) < 0) {
+                throw new IllegalArgumentException("开启其他费用时请填写有效金额");
+            }
+            if (!StringUtils.hasText(dto.getOtherFeeReason())) {
+                throw new IllegalArgumentException("开启其他费用时请填写收款原因");
+            }
+        }
+        if (Objects.equals(dto.getHasManualReduction(), 1)) {
+            if (dto.getManualReductionAmount() == null || dto.getManualReductionAmount().compareTo(BigDecimal.ZERO) < 0) {
+                throw new IllegalArgumentException("开启手动减免时请填写有效减免金额");
+            }
+            if (!StringUtils.hasText(dto.getManualReductionReason())) {
+                throw new IllegalArgumentException("开启手动减免时请填写减免原因");
+            }
+        }
+        if (Objects.equals(dto.getHasWholeDiscount(), 1)) {
+            if (dto.getWholeDiscountRatio() == null || dto.getWholeDiscountRatio().compareTo(BigDecimal.ZERO) <= 0) {
+                throw new IllegalArgumentException("开启整单折扣时请填写折扣比例");
+            }
+            if (!StringUtils.hasText(dto.getWholeDiscountReason())) {
+                throw new IllegalArgumentException("开启整单折扣时请填写折扣原因");
+            }
+        }
+        if (Objects.equals(dto.getIsFreeOrder(), 1) && !StringUtils.hasText(dto.getFreeOrderReason())) {
+            throw new IllegalArgumentException("整单免单时请填写免单原因");
+        }
+        for (StoreBookingCheckoutLineSubmitDTO line : dto.getLines()) {
+            if (Objects.equals(line.getDiscountFlag(), 1) && line.getDiscountNumber() == null) {
+                throw new IllegalArgumentException("明细行开启折扣时请填写折扣数,detailId=" + line.getDetailId());
+            }
+        }
+    }
+
+    /**
+     * 计算单行折后小计:免单为 0;否则为 单价×数量,若开折扣再 × 折扣比例。
+     */
+    private static BigDecimal computeLineSubtotal(StoreOrderDetail d, int free, int discountFlag, BigDecimal discountNumber) {
+        BigDecimal unit = d.getUnitPrice() != null ? d.getUnitPrice() : BigDecimal.ZERO;
+        int qty = d.getQuantity() != null ? d.getQuantity() : 0;
+        BigDecimal base = unit.multiply(BigDecimal.valueOf(qty)).setScale(2, RoundingMode.HALF_UP);
+        if (free == 1) {
+            return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+        }
+        if (discountFlag == 1) {
+            return base.multiply(normalizeDiscountRate(discountNumber)).setScale(2, RoundingMode.HALF_UP);
+        }
+        return base;
+    }
+
+    /**
+     * 将前端传入的折扣数统一为 (0,1] 乘数:≤1 视为小数比例;&gt;1 视为「百分制」如 80→0.8。
+     */
+    private static BigDecimal normalizeDiscountRate(BigDecimal n) {
+        if (n == null) {
+            return BigDecimal.ONE;
+        }
+        if (n.compareTo(BigDecimal.ZERO) <= 0) {
+            return BigDecimal.ZERO;
+        }
+        if (n.compareTo(BigDecimal.ONE) > 0) {
+            return n.divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP).min(BigDecimal.ONE);
+        }
+        return n.min(BigDecimal.ONE);
+    }
+
+    /** 查询订单下全部明细,按 id 升序,保证展示与提交顺序稳定 */
+    private List<StoreOrderDetail> listDetails(Integer orderId) {
+        LambdaQueryWrapper<StoreOrderDetail> q = new LambdaQueryWrapper<>();
+        q.eq(StoreOrderDetail::getOrderId, orderId).orderByAsc(StoreOrderDetail::getId);
+        return storeOrderDetailMapper.selectList(q);
+    }
+
+    /**
+     * 与下单模块一致:优先信任桌台 {@code current_order_id},否则查「本桌 table_id + 待支付」且至多一笔。
+     */
+    private StoreOrder resolvePendingOrderForCheckout(Integer storeId, StoreTable table) {
+        if (table.getCurrentOrderId() != null) {
+            StoreOrder byPtr = this.getById(table.getCurrentOrderId());
+            if (isPending(byPtr) && storeId.equals(byPtr.getStoreId())) {
+                return byPtr;
+            }
+        }
+        LambdaQueryWrapper<StoreOrder> q = new LambdaQueryWrapper<>();
+        q.eq(StoreOrder::getStoreId, storeId)
+                .eq(StoreOrder::getTableId, table.getId())
+                .eq(StoreOrder::getDeleteFlag, 0)
+                .eq(StoreOrder::getOrderStatus, 0)
+                .eq(StoreOrder::getPayStatus, 0)
+                .orderByDesc(StoreOrder::getId);
+        List<StoreOrder> list = this.list(q);
+        if (list.size() > 1) {
+            throw new IllegalStateException("该桌存在多笔待支付订单,数据异常");
+        }
+        return list.isEmpty() ? null : list.get(0);
+    }
+
+    /** 未删除且 orderStatus=0、payStatus=0 视为待支付 */
+    private static boolean isPending(StoreOrder o) {
+        return o != null
+                && (o.getDeleteFlag() == null || o.getDeleteFlag() == 0)
+                && Objects.equals(o.getOrderStatus(), 0)
+                && Objects.equals(o.getPayStatus(), 0);
+    }
+
+    /**
+     * 清桌:将所有「当前仍指向本单」的桌台置空闲(current_order_id 清空、状态 0、人数清空)。
+     * 条件中带 currentOrderId=orderId,避免并发下误改他单。
+     */
+    private void clearTablesForOrder(Integer storeId, Integer orderId, Integer userId, Date now) {
+        LambdaQueryWrapper<StoreTable> q = new LambdaQueryWrapper<>();
+        q.eq(StoreTable::getStoreId, storeId).eq(StoreTable::getCurrentOrderId, orderId);
+        List<StoreTable> tables = storeTableMapper.selectList(q);
+        for (StoreTable t : tables) {
+            LambdaUpdateWrapper<StoreTable> u = new LambdaUpdateWrapper<>();
+            u.eq(StoreTable::getId, t.getId())
+                    .eq(StoreTable::getCurrentOrderId, orderId)
+                    .set(StoreTable::getCurrentOrderId, null)
+                    .set(StoreTable::getStatus, 0)
+                    .set(StoreTable::getDinerCount, null)
+                    .set(StoreTable::getUpdatedTime, now);
+            if (userId != null) {
+                u.set(StoreTable::getUpdatedUserId, userId);
+            }
+            storeTableMapper.update(null, u);
+        }
+    }
+
+    /**
+     * 生成「A01、A02」展示串:优先预约下多桌;若无预约数据则找所有绑定本订单的桌;最后兜底订单主桌。
+     *
+     * @param requestTableId 预留参数(例如将来按「点击桌」高亮);当前未参与计算
+     */
+    private TableDisplay buildTableNumbersDisplay(StoreOrder order, Integer requestTableId) {
+        List<Integer> ids = new ArrayList<>();
+        if (order.getUserReservationId() != null) {
+            LambdaQueryWrapper<UserReservationTable> q = new LambdaQueryWrapper<>();
+            q.eq(UserReservationTable::getReservationId, order.getUserReservationId())
+                    .orderByAsc(UserReservationTable::getSort);
+            List<UserReservationTable> urs = userReservationTableMapper.selectList(q);
+            for (UserReservationTable ur : urs) {
+                if (ur.getTableId() != null) {
+                    ids.add(ur.getTableId());
+                }
+            }
+        }
+        if (ids.isEmpty()) {
+            LambdaQueryWrapper<StoreTable> tq = new LambdaQueryWrapper<>();
+            tq.eq(StoreTable::getStoreId, order.getStoreId())
+                    .eq(StoreTable::getCurrentOrderId, order.getId());
+            List<StoreTable> bound = storeTableMapper.selectList(tq);
+            ids = bound.stream().map(StoreTable::getId).collect(Collectors.toList());
+        }
+        if (ids.isEmpty() && order.getTableId() != null) {
+            ids.add(order.getTableId());
+        }
+        ids = ids.stream().distinct().collect(Collectors.toList());
+
+        List<String> names = new ArrayList<>();
+        for (Integer tid : ids) {
+            StoreTable t = storeTableMapper.selectById(tid);
+            if (t != null && StringUtils.hasText(t.getTableNumber())) {
+                names.add(t.getTableNumber().trim());
+            }
+        }
+        names.sort(Comparator.naturalOrder());
+        String display = names.isEmpty() ? "" : String.join("、", names);
+        return new TableDisplay(ids, display);
+    }
+
+    /** 从 JWT 取操作人,未登录时返回 null,不阻断结账 */
+    private static Integer currentUserId() {
+        try {
+            JSONObject j = JwtUtil.getCurrentUserInfo();
+            return j != null ? j.getInteger("userId") : null;
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    /** Integer 空值当 0,用于前端展示 flags */
+    private static Integer nz(Integer v) {
+        return v != null ? v : 0;
+    }
+
+    /** 金额空值当 0 */
+    private static BigDecimal nzAmount(BigDecimal v) {
+        return v != null ? v : BigDecimal.ZERO;
+    }
+
+    /** 全空白字符串存库为 null,避免仅空格占位 */
+    private static String trimToNull(String s) {
+        if (!StringUtils.hasText(s)) {
+            return null;
+        }
+        String t = s.trim();
+        return t.isEmpty() ? null : t;
+    }
+
+    /** 复合桌号构建的临时结果:tableIds 用于列表,display 用于「、」分隔展示 */
+    private static final class TableDisplay {
+        final List<Integer> tableIds;
+        final String display;
+
+        TableDisplay(List<Integer> tableIds, String display) {
+            this.tableIds = tableIds;
+            this.display = display;
+        }
+    }
+}

+ 3 - 0
alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingOrderServiceImpl.java

@@ -643,6 +643,9 @@ public class StoreBookingOrderServiceImpl extends ServiceImpl<StoreOrderMapper,
             detail.setUnitPrice(unit);
             detail.setQuantity(qty);
             detail.setSubtotalAmount(sub);
+            // 新写入的明细默认未做行免单/行折扣;结账时由 StoreBookingCheckoutServiceImpl 再改
+            detail.setFreeDish(0);
+            detail.setDiscountFlag(0);
             detail.setAddUserId(userId);
             detail.setAddUserPhone(userPhone);
             detail.setIsAddDish(addDish ? 1 : 0);

+ 100 - 0
alien-store/src/main/java/shop/alien/store/service/impl/StoreServiceFeeRuleServiceImpl.java

@@ -22,11 +22,14 @@ import shop.alien.store.service.StoreBookingTableService;
 import shop.alien.store.service.StoreServiceFeeRuleService;
 import shop.alien.util.common.JwtUtil;
 
+import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.text.ParseException;
 import java.text.SimpleDateFormat;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.LocalTime;
+import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -47,6 +50,7 @@ public class StoreServiceFeeRuleServiceImpl implements StoreServiceFeeRuleServic
 
     private static final String MODE_PERMANENT = "PERMANENT";
     private static final String MODE_CUSTOM = "CUSTOM";
+    private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai");
     private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");
     private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd");
 
@@ -582,5 +586,101 @@ public class StoreServiceFeeRuleServiceImpl implements StoreServiceFeeRuleServic
         }
         return null;
     }
+
+    /**
+     * 结账/预览用:在指定时间点,汇总某店某桌「当前命中」的服务费。
+     * 每条规则行对应一个时段切片;同一桌可有多条记录,命中多条则金额相加。
+     */
+    @Override
+    public BigDecimal computeMatchedServiceFee(Integer storeId, Integer tableId, Integer dinerCount, BigDecimal consumeAmount, Date at) {
+        if (storeId == null || tableId == null || at == null) {
+            return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+        }
+        // 人数至少按 1 人计费,避免 null/0 导致按人规则为 0
+        int people = dinerCount != null && dinerCount > 0 ? dinerCount : 1;
+        BigDecimal consume = consumeAmount != null ? consumeAmount : BigDecimal.ZERO;
+
+        // 统一用上海时区拆出「日期」「时刻」「星期位图」:weekdayBit 与 weekday_mask 按位与判断是否生效
+        LocalDateTime ldt = at.toInstant().atZone(SHANGHAI).toLocalDateTime();
+        LocalDate today = ldt.toLocalDate();
+        LocalTime nowTime = ldt.toLocalTime();
+        int weekdayBit = 1 << (ldt.getDayOfWeek().getValue() - 1);
+
+        java.sql.Date todaySql = java.sql.Date.valueOf(today);
+
+        // 拉出该桌下所有启用规则(含多条时段),再在内存里过滤日期/星期/时间窗
+        LambdaQueryWrapper<StoreServiceFeeRule> w = new LambdaQueryWrapper<>();
+        w.eq(StoreServiceFeeRule::getStoreId, storeId)
+                .eq(StoreServiceFeeRule::getTableId, tableId)
+                .eq(StoreServiceFeeRule::getStatus, 1);
+        List<StoreServiceFeeRule> rules = ruleMapper.selectList(w);
+        if (rules == null || rules.isEmpty()) {
+            return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+        }
+
+        BigDecimal sum = BigDecimal.ZERO;
+        for (StoreServiceFeeRule r : rules) {
+            if (!isRuleDateActive(r, todaySql)) {
+                continue;
+            }
+            if (r.getWeekdayMask() == null || (r.getWeekdayMask() & weekdayBit) == 0) {
+                continue;
+            }
+            if (r.getStartTime() == null || r.getEndTime() == null) {
+                continue;
+            }
+            // 时间窗左闭右开 [start, end),与配置端「开始必须早于结束」一致
+            if (nowTime.isBefore(r.getStartTime()) || !nowTime.isBefore(r.getEndTime())) {
+                continue;
+            }
+            sum = sum.add(computeSingleRuleAmount(r, people, consume));
+        }
+        return sum.setScale(2, RoundingMode.HALF_UP);
+    }
+
+    /** 自定义日期是否在区间内;永久生效则始终为 true */
+    private boolean isRuleDateActive(StoreServiceFeeRule r, java.sql.Date todaySql) {
+        if (MODE_PERMANENT.equals(r.getEffectiveMode())) {
+            return true;
+        }
+        if (!MODE_CUSTOM.equals(r.getEffectiveMode())) {
+            return false;
+        }
+        if (r.getStartDate() == null || r.getEndDate() == null) {
+            return false;
+        }
+        LocalDate s = new java.sql.Date(r.getStartDate().getTime()).toLocalDate();
+        LocalDate e = new java.sql.Date(r.getEndDate().getTime()).toLocalDate();
+        LocalDate t = todaySql.toLocalDate();
+        return !t.isBefore(s) && !t.isAfter(e);
+    }
+
+    /**
+     * 单条规则金额:feeType 1=人数×单价;2=每桌固定;3=消费额×比例(feeValue&gt;1 时按百分比理解,先 /100)。
+     */
+    private BigDecimal computeSingleRuleAmount(StoreServiceFeeRule r, int dinerCount, BigDecimal consumeAmount) {
+        BigDecimal v = r.getFeeValue() != null ? r.getFeeValue() : BigDecimal.ZERO;
+        Integer ft = r.getFeeType();
+        if (ft == null) {
+            return BigDecimal.ZERO;
+        }
+        if (ft == 1) {
+            return v.multiply(BigDecimal.valueOf(dinerCount)).setScale(2, RoundingMode.HALF_UP);
+        }
+        if (ft == 2) {
+            return v.setScale(2, RoundingMode.HALF_UP);
+        }
+        if (ft == 3) {
+            if (consumeAmount.compareTo(BigDecimal.ZERO) <= 0) {
+                return BigDecimal.ZERO;
+            }
+            BigDecimal ratio = v;
+            if (ratio.compareTo(BigDecimal.ONE) > 0) {
+                ratio = ratio.divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP);
+            }
+            return consumeAmount.multiply(ratio).setScale(2, RoundingMode.HALF_UP);
+        }
+        return BigDecimal.ZERO;
+    }
 }
 

+ 16 - 0
alien-store/src/main/java/shop/alien/store/strategy/payment/PaymentStrategy.java

@@ -22,6 +22,15 @@ public interface PaymentStrategy {
      */
     R createPrePayOrder(String price, String subject) throws Exception;
 
+    /**
+     * 创建预支付订单(可选门店维度:微信服务商等策略需传 storeId 以解析子商户号)
+     *
+     * @param storeId 门店主键,非服务商策略可忽略
+     */
+    default R createPrePayOrder(String price, String subject, Integer storeId) throws Exception {
+        return createPrePayOrder(price, subject);
+    }
+
 
     /**
      * 处理支付通知
@@ -41,6 +50,13 @@ public interface PaymentStrategy {
      */
     R searchOrderByOutTradeNoPath(String transactionId) throws Exception;
 
+    /**
+     * 按商户订单号查单(可选门店维度:微信服务商需传 storeId 以解析子商户号)
+     */
+    default R searchOrderByOutTradeNoPath(String transactionId, Integer storeId) throws Exception {
+        return searchOrderByOutTradeNoPath(transactionId);
+    }
+
      /**
      * 处理退款请求
      *

+ 477 - 0
alien-store/src/main/java/shop/alien/store/strategy/payment/impl/WeChatPartnerPaymentStrategyImpl.java

@@ -0,0 +1,477 @@
+package shop.alien.store.strategy.payment.impl;
+
+import com.google.gson.annotations.SerializedName;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.*;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.StoreInfo;
+import shop.alien.entity.store.RefundRecord;
+import shop.alien.mapper.StoreInfoMapper;
+import shop.alien.store.service.RefundRecordService;
+import shop.alien.store.strategy.payment.PaymentStrategy;
+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.io.UncheckedIOException;
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 微信支付 — 服务商模式(特约商户 / partner)
+ * <p>
+ * 使用微信支付 APIv3 服务商接口:下单、查单、退款均走 sp_mchid + sub_mchid。
+ * </p>
+ * <p>
+ * <b>【需您提供并写入配置】</b>(前缀 {@code payment.wechatPartnerPay},见各字段注释)
+ * </p>
+ * <ul>
+ *   <li>服务商商户号 {@code sp_mchid}、子商户号 {@code sub_mchid}</li>
+ *   <li>服务商 AppID {@code sp_appid};若子商户有独立移动应用 AppID 则配置 {@code sub_appid}</li>
+ *   <li>API 证书私钥、证书序列号、平台公钥、APIv3 密钥、回调 URL 等(与直连类似,但商户号为服务商)</li>
+ *   <li>各接口路径若微信有变更,以官方文档为准,可通过配置覆盖默认值</li>
+ * </ul>
+ *
+ * @see <a href="https://pay.weixin.qq.com/doc/v3/merchant/4012062547">服务商模式产品文档</a>
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class WeChatPartnerPaymentStrategyImpl implements PaymentStrategy {
+
+    private final RefundRecordService refundRecordService;
+    /** 按门店读取 store_info.wechat_sub_mchid(特约商户号),与前端传入的 storeId 对应 */
+    private final StoreInfoMapper storeInfoMapper;
+    /** 复用直连实现中的退款单模型、HTTP 工具与 RefundRecord 构建逻辑(构建后覆盖 payType 为服务商类型) */
+    private final WeChatPaymentStrategyImpl weChatPaymentStrategy;
+
+    // ———— 【需您配置】基础域名(一般保持默认) ————
+    @Value("${payment.wechatPartnerPay.host:https://api.mch.weixin.qq.com}")
+    private String wechatPayApiHost;
+
+    /** 【需您配置】服务商 APP 下单路径,默认服务商 APP 支付 */
+    @Value("${payment.wechatPartnerPay.prePayPath:/v3/pay/partner/transactions/app}")
+    private String prePayPath;
+
+    /** 【需您配置】按商户订单号查单(路径模板中的 {out_trade_no} 与直连一致) */
+    @Value("${payment.wechatPartnerPay.searchOrderByOutTradeNoPath:/v3/pay/partner/transactions/out-trade-no/{out_trade_no}}")
+    private String searchOrderByOutTradeNoPath;
+
+    /** 【需您配置】退款申请路径(与直连相同) */
+    @Value("${payment.wechatPartnerPay.refundPath:/v3/refund/domestic/refunds}")
+    private String refundPath;
+
+    // ———— 【需您配置】服务商与子商户标识 ————
+    /** 【需您配置】服务商 AppID(sp_appid) */
+    @Value("${payment.wechatPartnerPay.business.spAppId}")
+    private String spAppId;
+
+    /** 【需您配置】服务商商户号(sp_mchid),请求 Authorization 与查单参数均使用 */
+    @Value("${payment.wechatPartnerPay.business.spMchId}")
+    private String spMchId;
+
+    /**
+     * 子商户号 sub_mchid 从 {@link StoreInfo#getWechatSubMchid()}(表 store_info.wechat_sub_mchid)按前端传入的 storeId 查询,不再使用全局配置。
+     */
+
+    /**
+     * 【可选配置】子商户 AppID(sub_appid)。
+     * 若与服务商共用或仅 sp_appid 调起支付,可留空;调起客户端支付包签名时优先用此值。
+     */
+    @Value("${payment.wechatPartnerPay.business.subAppId:}")
+    private String subAppId;
+
+    // ———— 【需您配置】证书与密钥(与直连结构一致,一般为服务商商户证书) ————
+    @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 */
+    @Value("${payment.wechatPartnerPay.business.prePayNotifyUrl}")
+    private String prePayNotifyUrl;
+
+    /** 【需您配置】退款结果通知 URL */
+    @Value("${payment.wechatPartnerPay.business.refundNotifyUrl}")
+    private String refundNotifyUrl;
+
+    private PrivateKey privateKey;
+    private PublicKey wechatPayPublicKey;
+
+    private static final String POST_METHOD = "POST";
+    private static final String GET_METHOD = "GET";
+
+    @PostConstruct
+    public void loadPartnerCertificates() {
+        String privateKeyPath;
+        String wechatPayPublicKeyFilePath;
+        if ("windows".equals(OSUtil.getOsName())) {
+            privateKeyPath = privateWinKeyPath;
+            wechatPayPublicKeyFilePath = wechatWinPayPublicKeyFilePath;
+        } else {
+            privateKeyPath = privateLinuxKeyPath;
+            wechatPayPublicKeyFilePath = wechatLinuxPayPublicKeyFilePath;
+        }
+        log.info("[WeChatPartner] 加载服务商商户证书私钥与平台公钥,os={}, keyPath={}", OSUtil.getOsName(), privateKeyPath);
+        this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyPath);
+        this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
+    }
+
+    @Override
+    public R createPrePayOrder(String price, String subject) throws Exception {
+        return createPrePayOrder(price, subject, null);
+    }
+
+    @Override
+    public R createPrePayOrder(String price, String subject, Integer storeId) throws Exception {
+        String subMchid = resolveSubMchidFromStore(storeId);
+        log.info("[WeChatPartner] 创建预支付订单,price={}, subject={}, spMchId={}, storeId={}, subMchid={}",
+                price, subject, spMchId, storeId, subMchid != null ? subMchid : "(未解析)");
+        if (subMchid == null) {
+            return R.fail("请传入门店 storeId,且门店需在 store_info 中维护微信特约商户号 wechat_sub_mchid");
+        }
+        if (price == null || price.trim().isEmpty()) {
+            return R.fail("价格不能为空");
+        }
+        if (subject == null || subject.trim().isEmpty()) {
+            return R.fail("订单描述不能为空");
+        }
+        try {
+            BigDecimal amount = new BigDecimal(price);
+            if (amount.compareTo(BigDecimal.ZERO) <= 0) {
+                return R.fail("价格必须大于0");
+            }
+        } catch (NumberFormatException e) {
+            return R.fail("价格格式不正确");
+        }
+
+        PartnerAppPrepayRequest request = new PartnerAppPrepayRequest();
+        request.spAppid = spAppId;
+        request.spMchid = spMchId;
+        request.subMchid = subMchid;
+        if (StringUtils.isNotBlank(subAppId)) {
+            request.subAppid = subAppId;
+        }
+        request.description = subject;
+        request.outTradeNo = UniqueRandomNumGenerator.generateUniqueCode(19);
+        request.notifyUrl = prePayNotifyUrl;
+        request.amount = new WeChatPaymentStrategyImpl.CommonAmountInfo();
+        request.amount.total = new BigDecimal(price).multiply(new BigDecimal(100)).longValue();
+        request.amount.currency = "CNY";
+
+        try {
+            WeChatPaymentStrategyImpl.DirectAPIv3AppPrepayResponse response = partnerPrePayOrderRun(request);
+            log.info("[WeChatPartner] 预下单成功 prepayId={}, outTradeNo={}", response.prepayId, request.outTradeNo);
+
+            String clientAppId = StringUtils.isNotBlank(subAppId) ? subAppId : spAppId;
+            Map<String, String> result = new HashMap<>();
+            result.put("prepayId", response.prepayId);
+            result.put("appId", clientAppId);
+            result.put("spAppId", spAppId);
+            result.put("subAppId", subAppId);
+            result.put("spMchId", spMchId);
+            result.put("subMchId", subMchid);
+            result.put("orderNo", request.outTradeNo);
+
+            long timestamp = System.currentTimeMillis() / 1000;
+            String nonce = WXPayUtility.createNonce(32);
+            String prepayId = response.prepayId;
+            String message = String.format("%s\n%s\n%s\n%s\n", clientAppId, timestamp, nonce, prepayId);
+            Signature sign = Signature.getInstance("SHA256withRSA");
+            sign.initSign(privateKey);
+            sign.update(message.getBytes(StandardCharsets.UTF_8));
+            result.put("sign", Base64.getEncoder().encodeToString(sign.sign()));
+            result.put("timestamp", String.valueOf(timestamp));
+            result.put("nonce", nonce);
+            return R.data(result);
+        } catch (WXPayUtility.ApiException e) {
+            log.error("[WeChatPartner] 预下单失败 code={}, body={}", e.getErrorCode(), e.getMessage());
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @Override
+    public R handleNotify(String notifyData) throws Exception {
+        return null;
+    }
+
+    @Override
+    public R searchOrderByOutTradeNoPath(String outTradeNo) throws Exception {
+        return searchOrderByOutTradeNoPath(outTradeNo, null);
+    }
+
+    @Override
+    public R searchOrderByOutTradeNoPath(String outTradeNo, Integer storeId) throws Exception {
+        String subMchid = resolveSubMchidFromStore(storeId);
+        log.info("[WeChatPartner] 查单 outTradeNo={}, storeId={}, subMchid={}", outTradeNo, storeId,
+                subMchid != null ? subMchid : "(未解析)");
+        if (subMchid == null) {
+            return R.fail("请传入门店 storeId,且门店需在 store_info 中维护微信特约商户号 wechat_sub_mchid");
+        }
+        try {
+            WeChatPaymentStrategyImpl.DirectAPIv3QueryResponse response = partnerSearchOrderRun(outTradeNo, subMchid);
+            return R.data(response);
+        } catch (WXPayUtility.ApiException e) {
+            log.error("[WeChatPartner] 查单失败 code={}, msg={}", e.getErrorCode(), e.getMessage());
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @Override
+    public String handleRefund(Map<String, String> params) throws Exception {
+        Integer refundStoreId = parseStoreIdFromParams(params);
+        String subMchid = resolveSubMchidFromStore(refundStoreId);
+        log.info("[WeChatPartner] 退款请求 storeId={}, subMchid={}, outTradeNo={}",
+                refundStoreId, subMchid != null ? subMchid : "(未解析)", params.get("outTradeNo"));
+        if (subMchid == null) {
+            return "退款失败:请在参数中传入 storeId,且门店需在 store_info 中维护微信特约商户号 wechat_sub_mchid";
+        }
+        PartnerRefundCreateRequest request = new PartnerRefundCreateRequest();
+        request.subMchid = subMchid;
+        request.outTradeNo = params.get("outTradeNo");
+        request.outRefundNo = UniqueRandomNumGenerator.generateUniqueCode(19);
+        request.reason = params.get("reason");
+        request.notifyUrl = refundNotifyUrl;
+        request.amount = new WeChatPaymentStrategyImpl.AmountReq();
+        request.amount.refund = new BigDecimal(params.get("refundAmount")).longValue();
+        request.amount.total = new BigDecimal(params.get("totalAmount")).longValue();
+        request.amount.currency = "CNY";
+
+        log.info("[WeChatPartner] 退款 outTradeNo={}, outRefundNo={}, refundFen={}, totalFen={}",
+                request.outTradeNo, request.outRefundNo, request.amount.refund, request.amount.total);
+
+        try {
+            WeChatPaymentStrategyImpl.Refund response = partnerRefundRun(request);
+            String status = response.status != null ? response.status.name() : "UNKNOWN";
+            if ("SUCCESS".equals(status) || "PROCESSING".equals(status)) {
+                saveRefundRecord(() -> patchPayType(weChatPaymentStrategy.buildRefundRecordFromWeChatResponse(response,
+                        request, params)));
+                return "调用成功";
+            }
+            log.error("[WeChatPartner] 退款未成功 status={}, outTradeNo={}", status, request.outTradeNo);
+            saveRefundRecord(() -> patchPayType(weChatPaymentStrategy.buildRefundRecordFromWeChatError(response, request, params, status)));
+            return "退款失败";
+        } catch (Exception e) {
+            log.error("[WeChatPartner] 退款异常 outTradeNo={}", request.outTradeNo, e);
+            saveRefundRecord(() -> patchPayType(weChatPaymentStrategy.buildRefundRecordFromWeChatException(request, params, e)));
+            return "退款处理异常:" + e.getMessage();
+        }
+    }
+
+    private RefundRecord patchPayType(RefundRecord record) {
+        if (record != null) {
+            record.setPayType(PaymentEnum.WECHAT_PAY_PARTNER.getType());
+        }
+        return record;
+    }
+
+    private void saveRefundRecord(java.util.function.Supplier<RefundRecord> supplier) {
+        try {
+            RefundRecord refundRecord = supplier.get();
+            if (refundRecord == null) {
+                return;
+            }
+            long count = refundRecordService.lambdaQuery()
+                    .eq(RefundRecord::getOutRefundNo, refundRecord.getOutRefundNo())
+                    .count();
+            if (count == 0) {
+                refundRecordService.save(refundRecord);
+                log.info("[WeChatPartner] 已写入 RefundRecord,outRefundNo={}", refundRecord.getOutRefundNo());
+            }
+        } catch (Exception e) {
+            log.error("[WeChatPartner] 保存 RefundRecord 失败", e);
+        }
+    }
+
+    @Override
+    public R searchRefundRecordByOutRefundNo(String outRefundNo) throws Exception {
+        return null;
+    }
+
+    @Override
+    public String getType() {
+        return PaymentEnum.WECHAT_PAY_PARTNER.getType();
+    }
+
+    /**
+     * 根据门店主键查询特约商户号;缺失或空则返回 null 并打日志,便于排查配置问题。
+     */
+    private String resolveSubMchidFromStore(Integer storeId) {
+        if (storeId == null) {
+            log.warn("[WeChatPartner] storeId 为空,无法解析 wechat_sub_mchid");
+            return null;
+        }
+        StoreInfo info = storeInfoMapper.selectById(storeId);
+        if (info == null) {
+            log.warn("[WeChatPartner] 门店不存在 storeId={}", storeId);
+            return null;
+        }
+        if (StringUtils.isBlank(info.getWechatSubMchid())) {
+            log.warn("[WeChatPartner] 门店未配置 wechat_sub_mchid storeId={}", storeId);
+            return null;
+        }
+        return info.getWechatSubMchid().trim();
+    }
+
+    private static Integer parseStoreIdFromParams(Map<String, String> params) {
+        if (params == null) {
+            return null;
+        }
+        String raw = params.get("storeId");
+        if (raw == null || raw.trim().isEmpty()) {
+            return null;
+        }
+        try {
+            return Integer.valueOf(raw.trim());
+        } catch (NumberFormatException e) {
+            log.warn("[WeChatPartner] 退款参数 storeId 非整数: {}", raw);
+            return null;
+        }
+    }
+
+    private WeChatPaymentStrategyImpl.DirectAPIv3AppPrepayResponse partnerPrePayOrderRun(PartnerAppPrepayRequest request) {
+        String uri = prePayPath;
+        String reqBody = WXPayUtility.toJson(request);
+        log.debug("[WeChatPartner] POST {} bodyLen={}", uri, reqBody.length());
+
+        Request.Builder reqBuilder = new Request.Builder().url(wechatPayApiHost + uri);
+        reqBuilder.addHeader("Accept", "application/json");
+        reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
+        reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(spMchId, merchantSerialNumber, privateKey, POST_METHOD, uri, reqBody));
+        reqBuilder.addHeader("Content-Type", "application/json");
+        RequestBody body = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
+        reqBuilder.method(POST_METHOD, body);
+        Request httpRequest = reqBuilder.build();
+
+        OkHttpClient client = new OkHttpClient.Builder().build();
+        try (Response httpResponse = client.newCall(httpRequest).execute()) {
+            String respBody = WXPayUtility.extractBody(httpResponse);
+            if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
+                WXPayUtility.validateResponse(wechatPayPublicKeyId, wechatPayPublicKey, httpResponse.headers(), respBody);
+                return WXPayUtility.fromJson(respBody, WeChatPaymentStrategyImpl.DirectAPIv3AppPrepayResponse.class);
+            }
+            throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
+        } catch (IOException e) {
+            throw new UncheckedIOException("WeChatPartner prepay request failed: " + uri, e);
+        }
+    }
+
+    private WeChatPaymentStrategyImpl.DirectAPIv3QueryResponse partnerSearchOrderRun(String outTradeNo, String subMchid) {
+        String uri = searchOrderByOutTradeNoPath.replace("{out_trade_no}", WXPayUtility.urlEncode(outTradeNo));
+        Map<String, Object> args = new HashMap<>();
+        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", wechatPayPublicKeyId);
+        reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(spMchId, merchantSerialNumber, privateKey, GET_METHOD, uri, null));
+        reqBuilder.method(GET_METHOD, null);
+        Request httpRequest = reqBuilder.build();
+
+        OkHttpClient client = new OkHttpClient.Builder().build();
+        try (Response httpResponse = client.newCall(httpRequest).execute()) {
+            String respBody = WXPayUtility.extractBody(httpResponse);
+            if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
+                WXPayUtility.validateResponse(wechatPayPublicKeyId, wechatPayPublicKey, httpResponse.headers(), respBody);
+                return WXPayUtility.fromJson(respBody, WeChatPaymentStrategyImpl.DirectAPIv3QueryResponse.class);
+            }
+            throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
+        } catch (IOException e) {
+            throw new UncheckedIOException("WeChatPartner query order failed: " + uri, e);
+        }
+    }
+
+    private WeChatPaymentStrategyImpl.Refund partnerRefundRun(PartnerRefundCreateRequest request) {
+        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", wechatPayPublicKeyId);
+        reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(spMchId, merchantSerialNumber, privateKey, POST_METHOD, uri, reqBody));
+        reqBuilder.addHeader("Content-Type", "application/json");
+        RequestBody body = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
+        reqBuilder.method(POST_METHOD, body);
+        Request httpRequest = reqBuilder.build();
+
+        OkHttpClient client = new OkHttpClient.Builder().build();
+        try (Response httpResponse = client.newCall(httpRequest).execute()) {
+            String respBody = WXPayUtility.extractBody(httpResponse);
+            if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
+                WXPayUtility.validateResponse(wechatPayPublicKeyId, wechatPayPublicKey, httpResponse.headers(), respBody);
+                return WXPayUtility.fromJson(respBody, WeChatPaymentStrategyImpl.Refund.class);
+            }
+            throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
+        } catch (IOException e) {
+            throw new UncheckedIOException("WeChatPartner refund failed: " + uri, e);
+        }
+    }
+
+    /**
+     * 服务商 APP 下单请求体(字段名与微信 APIv3 一致)
+     */
+    public static class PartnerAppPrepayRequest {
+        @SerializedName("sp_appid")
+        public String spAppid;
+
+        @SerializedName("sp_mchid")
+        public String spMchid;
+
+        @SerializedName("sub_appid")
+        public String subAppid;
+
+        @SerializedName("sub_mchid")
+        public String subMchid;
+
+        @SerializedName("description")
+        public String description;
+
+        @SerializedName("out_trade_no")
+        public String outTradeNo;
+
+        @SerializedName("notify_url")
+        public String notifyUrl;
+
+        @SerializedName("amount")
+        public WeChatPaymentStrategyImpl.CommonAmountInfo amount;
+    }
+
+    /**
+     * 退款请求:在直连 {@link WeChatPaymentStrategyImpl.CreateRequest} 基础上增加 sub_mchid(服务商退款必填)
+     */
+    public static class PartnerRefundCreateRequest extends WeChatPaymentStrategyImpl.CreateRequest {
+        @SerializedName("sub_mchid")
+        public String subMchid;
+    }
+}

+ 5 - 1
alien-util/src/main/java/shop/alien/util/common/constant/PaymentEnum.java

@@ -11,8 +11,12 @@ public enum PaymentEnum {
     ALIPAY("alipay"),
     /** 微信支付 */
     WECHAT_PAY("wechatPay"),
-    /** 微信支付小程序 */
+    /** 微信支付小程序 废弃 */
     WECHAT_PAY_MININ_PROGRAM("wechatPayMininProgram"),
+    /** 微信支付(服务商模式 / 特约商户) */
+    WECHAT_PAY_PARTNER("wechatPayPartner"),
+    /** 微信支付小程序(服务商模式 / 特约商户 JSAPI) */
+    WECHAT_PAY_PARTNER_MININ_PROGRAM("wechatPayPartnerMininProgram"),
     /** 银联支付 */
     UNION_PAY("unionPay");