Ver Fonte

微信退款功能接入

skyline há 2 anos atrás
pai
commit
ba1c9aade1

+ 78 - 1
admin/src/main/resources/application-dev.yml

@@ -13,4 +13,81 @@ en-plus:
   # 最小充电余额(分)
   chargeMinAmount: 200
   # 接口地址
-  apiDomain: https://dev.en-plus.cn/Charge/evcs/v1//MA5HJNDG1/
+  apiDomain: https://dev.en-plus.cn/Charge/evcs/v1//MA5HJNDG1/
+
+# 微信支付
+wechat:
+  payment:
+    appid: wx369fcff95d387bde
+    # 微信商户号
+    mchid: 1635831469
+    mchsn: 6A45EEB068369430B2FFD45EA29F641A8E18165F
+    v3Key: iTRovdvaTUQq0b9Jr91D7Tx66JnIes5U
+    notifyUrl: https://dev.kuaiyuman.cn/api/payment/notify
+    refundNotifyUrl: https://dev.kuaiyuman.cn/api/payment/refundNotify
+    certPath: cert/apiclient_cert.pem
+    keyPath: cert/apiclient_key.pem
+
+  miniapp:
+    appid: wx369fcff95d387bde
+    secret: e36560b99afd5f744754cd09e8f6cc2a
+    # 以下需要先开通消息推送
+    token: #微信小程序消息服务器配置的token
+    aesKey: #微信小程序消息服务器配置的EncodingAESKey
+    msgDataFormat: JSON
+
+spring:
+  datasource:
+    druid: #以下是全局默认值,可以全局更改
+      #监控统计拦截的filters
+      filters: stat,slf4j
+      #配置初始化大小/最小/最大
+      initial-size: 2
+      min-idle: 2
+      max-active: 20
+      #获取连接等待超时时间
+      max-wait: 60000
+      #间隔多久进行一次检测,检测需要关闭的空闲连接
+      time-between-eviction-runs-millis: 60000
+      #一个连接在池中最小生存的时间
+      min-evictable-idle-time-millis: 300000
+      validation-query: SELECT 'x'
+      test-while-idle: true
+      test-on-borrow: false
+      test-on-return: false
+      #打开PSCache,并指定每个连接上PSCache的大小。oracle设为true,mysql设为false。分库分表较多推荐设置为false
+      pool-prepared-statements: false
+      max-pool-prepared-statement-per-connection-size: 20
+    dynamic:
+      primary: db-admin
+      strict: false #严格匹配数据源,默认false. true未匹配到指定数据源时抛异常,false使用默认数据源
+      datasource:
+        db-admin:
+          url: jdbc:mysql://121.40.98.15:3307/charge_admin?serverTimezone=Asia/Shanghai
+          username: root
+          password: 123456
+          driver-class-name: com.mysql.cj.jdbc.Driver
+        db-miniapp:
+          url: jdbc:mysql://121.40.98.15:3307/charge_app?serverTimezone=Asia/Shanghai
+          username: root
+          password: 123456
+          driver-class-name: com.mysql.cj.jdbc.Driver
+  data:
+    redis:
+      port: 6380
+      host: 121.40.98.15
+      password: 123456
+      database: 10
+      lettuce:
+        cluster:
+          refresh:
+            adaptive: true
+            period: 20
+  cache:
+    type: redis
+    redis:
+      # 缓存过期时间:7天
+      time-to-live: 604800
+
+kym:
+  notify-email: skyline@kuaiyuman.cn

+ 77 - 1
admin/src/main/resources/application-prod.yml

@@ -13,4 +13,80 @@ en-plus:
   # 最小充电余额(分)
   chargeMinAmount: 200
   # 接口地址
-  apiDomain: https://api.en-plus.cn:8080/Charge/evcs/v1//MA5HJNDG1/
+  apiDomain: https://api.en-plus.cn:8080/Charge/evcs/v1//MA5HJNDG1/
+
+# 微信支付
+wechat:
+  payment:
+    appid: wx369fcff95d387bde
+    # 微信商户号
+    mchid: 1635831469
+    mchsn: 6A45EEB068369430B2FFD45EA29F641A8E18165F
+    v3Key: iTRovdvaTUQq0b9Jr91D7Tx66JnIes5U
+    notifyUrl: https://www.kuaiyuman.cn/api/payment/notify
+    refundNotifyUrl: https://www.kuaiyuman.cn/api/payment/refundNotify
+    certPath: cert/apiclient_cert.pem
+    keyPath: cert/apiclient_key.pem
+
+  miniapp:
+    appid: wx369fcff95d387bde
+    secret: e36560b99afd5f744754cd09e8f6cc2a
+    # 以下需要先开通消息推送
+    token: #微信小程序消息服务器配置的token
+    aesKey: #微信小程序消息服务器配置的EncodingAESKey
+    msgDataFormat: JSON
+
+spring:
+  datasource:
+    druid: #以下是全局默认值,可以全局更改
+      #监控统计拦截的filters
+      filters: stat,slf4j
+      #配置初始化大小/最小/最大
+      initial-size: 2
+      min-idle: 2
+      max-active: 20
+      #获取连接等待超时时间
+      max-wait: 60000
+      #间隔多久进行一次检测,检测需要关闭的空闲连接
+      time-between-eviction-runs-millis: 60000
+      #一个连接在池中最小生存的时间
+      min-evictable-idle-time-millis: 300000
+      validation-query: SELECT 'x'
+      test-while-idle: true
+      test-on-borrow: false
+      test-on-return: false
+      #打开PSCache,并指定每个连接上PSCache的大小。oracle设为true,mysql设为false。分库分表较多推荐设置为false
+      pool-prepared-statements: false
+      max-pool-prepared-statement-per-connection-size: 20
+    dynamic:
+      primary: db-admin
+      strict: false #严格匹配数据源,默认false. true未匹配到指定数据源时抛异常,false使用默认数据源
+      datasource:
+        db-admin:
+          url: jdbc:mysql://127.0.0.1:3306/charge_admin?serverTimezone=Asia/Shanghai
+          username: kym
+          password: qYhQLZLP6e7paVmQN5foEwRYJ1yFNpwM
+          driver-class-name: com.mysql.cj.jdbc.Driver
+        db-miniapp:
+          url: jdbc:mysql://127.0.0.1:3306/charge_app?serverTimezone=Asia/Shanghai
+          username: kym
+          password: qYhQLZLP6e7paVmQN5foEwRYJ1yFNpwM
+          driver-class-name: com.mysql.cj.jdbc.Driver
+  data:
+    redis:
+      port: 6379
+      host: 127.0.0.1
+      password: OPYCJ3mJpaU4IsP7ZeIyaus6FtHjLYmhNDGteuRc1gIFCAm7wi
+      database: 10
+      lettuce:
+        cluster:
+          refresh:
+            adaptive: true
+            period: 20
+  cache:
+    type: redis
+    redis:
+      # 缓存过期时间:7天
+      time-to-live: 604800
+kym:
+  notify-email: zaizai@kuaiyuman.cn,skyline@kuaiyuman.cn

+ 1 - 73
admin/src/main/resources/application.yml

@@ -3,62 +3,12 @@ spring:
     active: dev
   application:
     name: admin
-  datasource:
-    druid: #以下是全局默认值,可以全局更改
-      #监控统计拦截的filters
-      filters: stat,slf4j
-      #配置初始化大小/最小/最大
-      initial-size: 2
-      min-idle: 2
-      max-active: 20
-      #获取连接等待超时时间
-      max-wait: 60000
-      #间隔多久进行一次检测,检测需要关闭的空闲连接
-      time-between-eviction-runs-millis: 60000
-      #一个连接在池中最小生存的时间
-      min-evictable-idle-time-millis: 300000
-      validation-query: SELECT 'x'
-      test-while-idle: true
-      test-on-borrow: false
-      test-on-return: false
-      #打开PSCache,并指定每个连接上PSCache的大小。oracle设为true,mysql设为false。分库分表较多推荐设置为false
-      pool-prepared-statements: false
-      max-pool-prepared-statement-per-connection-size: 20
-    dynamic:
-      primary: db-admin
-      strict: false #严格匹配数据源,默认false. true未匹配到指定数据源时抛异常,false使用默认数据源
-      datasource:
-        db-admin:
-          url: jdbc:mysql://121.40.98.15:3307/charge_admin?tinyInt1isBit=false&serverTimezone=Asia/Shanghai
-          username: root
-          password: 123456
-          driver-class-name: com.mysql.cj.jdbc.Driver
-        db-miniapp:
-          url: jdbc:mysql://121.40.98.15:3307/charge_app
-          username: root
-          password: 123456
-          driver-class-name: com.mysql.cj.jdbc.Driver
 
 
   main:
     allow-circular-references: true
 
-  data:
-    redis:
-      port: 6380
-      host: 121.40.98.15
-      password: 123456
-      database: 2
-      lettuce:
-        cluster:
-          refresh:
-            adaptive: true
-            period: 20
-  cache:
-    type: redis
-    redis:
-      # 缓存过期时间:7天
-      time-to-live: 604800
+
 #mybatis:
 #  mapper-locations: classpath:mappers/*.xml
 #  type-aliases-package: com.kym.charge.entity
@@ -95,28 +45,6 @@ sa-token:
   # 是否输出操作日志
   is-log: true
 
-# 微信支付
-wechat:
-  payment:
-    appid: wx369fcff95d387bde
-    # 微信商户号
-    mchid: 1635831469
-    mchsn: 6A45EEB068369430B2FFD45EA29F641A8E18165F
-    v3Key: iTRovdvaTUQq0b9Jr91D7Tx66JnIes5U
-    notifyUrl: https://www.kuaiyuman.cn/api/payment/notify
-    #    certPath: /data/wwwroot/charge/config/cert/apiclient_cert.pem
-    #    keyPath: /data/wwwroot/charge/config/cert/apiclient_key.pem
-    certPath: cert/apiclient_cert.pem
-    keyPath: cert/apiclient_key.pem
-
-  miniapp:
-    appid: wx369fcff95d387bde
-    secret: e36560b99afd5f744754cd09e8f6cc2a
-    # 以下需要先开通消息推送
-    token: #微信小程序消息服务器配置的token
-    aesKey: #微信小程序消息服务器配置的EncodingAESKey
-    msgDataFormat: JSON
-
 #文件上传配置
 upload:
   file:

+ 1 - 0
common/src/main/java/com/kym/common/config/WxPayConfig.java

@@ -17,6 +17,7 @@ public class WxPayConfig {
     private String mchid;
     private String mchsn;
     private String notifyUrl;
+    private String refundNotifyUrl;
     private String v3key;
     private String certPath;
     private String keyPath;

+ 1 - 0
miniapp/src/main/resources/application-dev.yml

@@ -24,6 +24,7 @@ wechat:
     mchsn: 6A45EEB068369430B2FFD45EA29F641A8E18165F
     v3Key: iTRovdvaTUQq0b9Jr91D7Tx66JnIes5U
     notifyUrl: https://dev.kuaiyuman.cn/api/payment/notify
+    refundNotifyUrl: https://dev.kuaiyuman.cn/api/payment/refundNotify
     certPath: cert/apiclient_cert.pem
     keyPath: cert/apiclient_key.pem
 

+ 1 - 0
miniapp/src/main/resources/application-prod.yml

@@ -24,6 +24,7 @@ wechat:
     mchsn: 6A45EEB068369430B2FFD45EA29F641A8E18165F
     v3Key: iTRovdvaTUQq0b9Jr91D7Tx66JnIes5U
     notifyUrl: https://www.kuaiyuman.cn/api/payment/notify
+    refundNotifyUrl: https://www.kuaiyuman.cn/api/payment/refundNotify
     certPath: cert/apiclient_cert.pem
     keyPath: cert/apiclient_key.pem
 

+ 9 - 0
service/src/main/java/com/kym/service/wechat/WxPayService.java

@@ -2,7 +2,9 @@ package com.kym.service.wechat;
 
 import com.alibaba.fastjson2.JSONObject;
 import com.wechat.pay.java.service.payments.jsapi.model.PrepayWithRequestPaymentResponse;
+import com.wechat.pay.java.service.refund.model.Refund;
 import jakarta.servlet.http.HttpServletRequest;
+import lombok.SneakyThrows;
 import org.springframework.http.ResponseEntity;
 
 import java.io.IOException;
@@ -13,6 +15,13 @@ import java.io.IOException;
  * @date 2023-08-11 19:05
  */
 public interface WxPayService {
+    Refund wxRefund(JSONObject params);
+
+    Refund queryByOutRefundNo(String outRefundNo);
+
+    @SneakyThrows
+    ResponseEntity.BodyBuilder wxRefundNotify(HttpServletRequest request);
+
     PrepayWithRequestPaymentResponse wxPay(JSONObject rechargeAmount);
 
     ResponseEntity.BodyBuilder wxNotify(HttpServletRequest request) throws IOException;

+ 147 - 50
service/src/main/java/com/kym/service/wechat/impl/WxPayServiceImpl.java

@@ -26,8 +26,11 @@ import com.wechat.pay.java.core.notification.NotificationParser;
 import com.wechat.pay.java.core.notification.RequestParam;
 import com.wechat.pay.java.service.payments.jsapi.JsapiService;
 import com.wechat.pay.java.service.payments.jsapi.JsapiServiceExtension;
+import com.wechat.pay.java.service.payments.jsapi.model.Amount;
 import com.wechat.pay.java.service.payments.jsapi.model.*;
 import com.wechat.pay.java.service.payments.model.Transaction;
+import com.wechat.pay.java.service.refund.RefundService;
+import com.wechat.pay.java.service.refund.model.*;
 import jakarta.annotation.PostConstruct;
 import jakarta.servlet.http.HttpServletRequest;
 import lombok.SneakyThrows;
@@ -57,7 +60,9 @@ public class WxPayServiceImpl implements WxPayService {
 
     private static final Logger LOGGER = LoggerFactory.getLogger(WxPayServiceImpl.class);
 
-    public static JsapiService service;
+    public static JsapiService jsapiService;
+
+    public static RefundService refundService;
 
     public static Config config;
 
@@ -77,17 +82,10 @@ public class WxPayServiceImpl implements WxPayService {
     }
 
     /**
-     * 商户订单号查询订单
+     * 初始化
+     *
+     * @throws IOException
      */
-    public static Transaction queryOrderByOutTradeNo() {
-
-        QueryOrderByOutTradeNoRequest request = new QueryOrderByOutTradeNoRequest();
-        // 调用request.setXxx(val)设置所需参数,具体参数可见Request定义
-        // 调用接口
-        return service.queryOrderByOutTradeNo(request);
-    }
-
-
     @PostConstruct
     void init() throws IOException {
         String privateKey;
@@ -104,9 +102,62 @@ public class WxPayServiceImpl implements WxPayService {
                 .merchantSerialNumber(conf.getMchsn()) // 商户证书序列号
                 .apiV3Key(conf.getV3key()) // 商户APIV3密钥
                 .build();
-        service = new JsapiService.Builder().config(config).build();
+        jsapiService = new JsapiService.Builder().config(config).build();
+        refundService = new RefundService.Builder().config(config).build();
+    }
+
+    /**
+     * 商户订单号查询订单
+     */
+    public static Transaction queryOrderByOutTradeNo() {
+
+        QueryOrderByOutTradeNoRequest request = new QueryOrderByOutTradeNoRequest();
+        // 调用request.setXxx(val)设置所需参数,具体参数可见Request定义
+        // 调用接口
+        return jsapiService.queryOrderByOutTradeNo(request);
+    }
+
+
+
+    @SneakyThrows
+    Object[] handleWxNotify(HttpServletRequest request) {
+        var no = RandomUtil.randomInt(1000, 9999);
+        var signature = request.getHeader("Wechatpay-Signature");
+        var serial = request.getHeader("Wechatpay-Serial");
+        var nonce = request.getHeader("Wechatpay-Nonce");
+        var timestamp = request.getHeader("Wechatpay-Timestamp");
+        var signatureType = request.getHeader("Wechatpay-Signature-Type");
+
+        LOGGER.info("微信支付回调{}:\n Request参数:\n signature:{},serial:{},nonce:{},timestamp:{},signatureType:{}", no, signature, serial, nonce, timestamp, signatureType);
+
+        // request中获取body
+        BufferedReader br = request.getReader();
+        String str;
+        var requestBody = new StringBuilder();
+        while ((str = br.readLine()) != null) {
+            requestBody.append(str);
+        }
+
+        LOGGER.info("微信支付回调{}:\nBody数据:\n{}", no, requestBody);
+
+        // 构造 RequestParam
+        RequestParam requestParam = new RequestParam.Builder()
+                .serialNumber(serial)
+                .nonce(nonce) // 随机数
+                .signature(signature)
+                .timestamp(timestamp)
+                .body(requestBody.toString())
+                .build();
+        LOGGER.info("微信支付回调{}:构造 RequestParam完毕", no);
+
+        // 如果已经初始化了 RSAAutoCertificateConfig,可直接使用
+        // 初始化 NotificationParser
+        NotificationParser parser = new NotificationParser((NotificationConfig) config);
+        return new Object[]{requestParam, parser, no};
     }
 
+
+
     /**
      * JSAPI支付下单
      */
@@ -156,7 +207,7 @@ public class WxPayServiceImpl implements WxPayService {
         request.setMchid(conf.getMchid());
         request.setTransactionId(transactionId);
         // 调用接口
-        return service.queryOrderById(request);
+        return jsapiService.queryOrderById(request);
     }
 
     /**
@@ -168,51 +219,25 @@ public class WxPayServiceImpl implements WxPayService {
         request.setMchid(conf.getMchid());
         request.setOutTradeNo(outTradeNo);
         // 调用接口
-        service.closeOrder(request);
+        jsapiService.closeOrder(request);
     }
 
+    /**
+     * 微信支付结果通知
+     *
+     * @param request
+     * @return
+     */
     @SneakyThrows
     @Override
     @Transactional(rollbackFor = Exception.class)
     public ResponseEntity.BodyBuilder wxNotify(HttpServletRequest request) {
-        var no = RandomUtil.randomInt(1000, 9999);
-        var signature = request.getHeader("Wechatpay-Signature");
-        var serial = request.getHeader("Wechatpay-Serial");
-        var nonce = request.getHeader("Wechatpay-Nonce");
-        var timestamp = request.getHeader("Wechatpay-Timestamp");
-        var signatureType = request.getHeader("Wechatpay-Signature-Type");
-
-        LOGGER.info("微信支付回调{}:\n Request参数:\n signature:{},serial:{},nonce:{},timestamp:{},signatureType:{}", no, signature, serial, nonce, timestamp, signatureType);
-
-        // request中获取body
-        BufferedReader br = request.getReader();
-        String str;
-        var requestBody = new StringBuilder();
-        while ((str = br.readLine()) != null) {
-            requestBody.append(str);
-        }
-
-        LOGGER.info("微信支付回调{}:\nBody数据:\n{}", no, requestBody);
-
-        // 构造 RequestParam
-        RequestParam requestParam = new RequestParam.Builder()
-                .serialNumber(serial)
-                .nonce(nonce) // 随机数
-                .signature(signature)
-                .timestamp(timestamp)
-                .body(requestBody.toString())
-                .build();
-        LOGGER.info("微信支付回调{}:构造 RequestParam完毕", no);
-
-        // 如果已经初始化了 RSAAutoCertificateConfig,可直接使用
-        // 初始化 NotificationParser
-        NotificationParser parser = new NotificationParser((NotificationConfig) config);
+        var notifyRes = handleWxNotify(request);
 
-        LOGGER.info("微信支付回调{}:初始化NotificationParser完毕", no);
         try {
             // 以支付通知回调为例,验签、解密并转换成 Transaction
-            Transaction transaction = parser.parse(requestParam, Transaction.class);
-            LOGGER.info("微信支付回调{}:验签解密完毕,数据:\n{}", no, transaction);
+            Transaction transaction = ((NotificationParser) notifyRes[1]).parse((RequestParam) notifyRes[0], Transaction.class);
+            LOGGER.info("微信支付回调{}:验签解密完毕,数据:\n{}", notifyRes[2], transaction);
             // 判断是否已经接收处理过通知
             if (payLogService.lambdaQuery().eq(PayLog::getOutTradeNo, transaction.getOutTradeNo()).one() != null) {
                 return ResponseEntity.status(HttpStatus.OK);
@@ -251,7 +276,7 @@ public class WxPayServiceImpl implements WxPayService {
                 payLog.setPayerTotal(transaction.getAmount().getPayerTotal());
                 payLog.setPayerCurrency(transaction.getAmount().getPayerCurrency());
                 payLogService.save(payLog);
-                LOGGER.info("微信支付回调{}:业务处理结束", no);
+                LOGGER.info("微信支付回调{}:业务处理结束", notifyRes[2]);
 
             } else {
                 LOGGER.error("微信支付通知处理异常,资金流水为空,回调信息:{}", transaction);
@@ -266,4 +291,76 @@ public class WxPayServiceImpl implements WxPayService {
         // 处理成功,返回 200 OK 状态码
         return ResponseEntity.status(HttpStatus.OK);
     }
+
+
+    /**
+     * 创建退款申请
+     *
+     * @return
+     */
+    @Override
+    public Refund wxRefund(JSONObject params) {
+        CreateRequest request = new CreateRequest();
+        // 调用request.setXxx(val)设置所需参数,具体参数可见Request定义
+        // 原始第三方订单号
+        // TODO: 2023-09-05 填入业务数据
+        request.setOutTradeNo("");
+        // 退款订单号
+        request.setOutRefundNo("");
+        // 退款原因
+        request.setReason("用户申请退款");
+        // 回调通知地址
+        request.setNotifyUrl(conf.getRefundNotifyUrl());
+        var amount = new AmountReq();
+        // 退款金额
+        amount.setRefund(params.getLong("amount"));
+        request.setAmount(amount);
+        return refundService.create(request);
+    }
+
+    /**
+     * 查询单笔退款(通过商户退款单号)
+     *
+     * @return
+     */
+    @Override
+    public Refund queryByOutRefundNo(String outRefundNo) {
+
+        QueryByOutRefundNoRequest request = new QueryByOutRefundNoRequest();
+        // 调用request.setXxx(val)设置所需参数,具体参数可见Request定义
+        request.setOutRefundNo(outRefundNo);
+        // 调用接口
+        return refundService.queryByOutRefundNo(request);
+    }
+
+    /**
+     * 微信退款结果通知
+     *
+     * @param request
+     * @return
+     */
+    @SneakyThrows
+    @Override
+    public ResponseEntity.BodyBuilder wxRefundNotify(HttpServletRequest request) {
+        var notifyRes = handleWxNotify(request);
+        try {
+            // 以支付通知回调为例,验签、解密并转换成 RefundNotification
+            RefundNotification refundNotification = ((NotificationParser) notifyRes[1]).parse((RequestParam) notifyRes[0], RefundNotification.class);
+            // TODO: 2023-09-05 处理业务逻辑
+        } catch (ValidationException e) {
+            // 签名验证失败,返回 401 UNAUTHORIZED 状态码
+            LOGGER.error("sign verification failed", e);
+            return ResponseEntity.status(HttpStatus.UNAUTHORIZED);
+        }
+
+        // 如果处理失败,应返回 4xx/5xx 的状态码,例如 500 INTERNAL_SERVER_ERROR
+        if (false) {
+            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR);
+        }
+
+        // 处理成功,返回 200 OK 状态码
+        return ResponseEntity.status(HttpStatus.OK);
+
+    }
+
 }