Przeglądaj źródła

feat:小程序支付。

刘云鑫 1 tydzień temu
rodzic
commit
98f374bc93

+ 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;
+    }
+}

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

@@ -11,10 +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");