Initial commit
This commit is contained in:
74
jy_pay/README.md
Normal file
74
jy_pay/README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# jy_pay 支付模块
|
||||
|
||||
基于 **微信支付 APIv3** 的 Native 支付能力与支付日志记录。
|
||||
|
||||
## 功能
|
||||
|
||||
- **微信 Native 预下单**:生成 `code_url`,供前台生成二维码,用户扫码支付。
|
||||
- **支付结果回调**:验签、解密并更新支付日志;可在此扩展订单状态等业务逻辑。
|
||||
- **支付日志表** `jy_pay_log`:记录每笔预下单与支付结果,便于对账与排查。
|
||||
|
||||
## 依赖与接入
|
||||
|
||||
- 根工程 `pom.xml` 已加入模块:`<module>jy_pay</module>`,并在 `dependencyManagement` 中声明 `jy_pay`。
|
||||
- 主应用(如 `back`)在 `pom.xml` 中增加对 `jy_pay` 的依赖后,会通过 `@MapperScan("com.ruoyi.**.mapper")` 与 `classpath*:mapper/**/*Mapper.xml` 自动扫描本模块的 Mapper 与 XML。
|
||||
|
||||
## 数据库
|
||||
|
||||
执行项目根目录下 `sql/jy_pay.sql`,创建表 `jy_pay_log`(与主库同库即可)。
|
||||
|
||||
## 配置
|
||||
|
||||
在 `application-xxx.yml` 中增加微信支付配置(可参考 `jy_pay/src/main/resources/application-jypay.yml`):
|
||||
|
||||
```yaml
|
||||
wechat:
|
||||
pay:
|
||||
enabled: true
|
||||
merchant-id: "你的商户号"
|
||||
private-key-path: "/绝对路径/apiclient_key.pem"
|
||||
merchant-serial-number: "商户API证书序列号"
|
||||
api-v3-key: "32字节APIv3密钥"
|
||||
app-id: "公众号/开放平台appid"
|
||||
notify-url: "https://你的域名/pay/api/wechat/native/notify"
|
||||
```
|
||||
|
||||
`notify-url` 需在微信商户平台配置,且公网可访问。未配置或 `enabled: false` 时,仅不创建微信 Config,不影响启动。
|
||||
|
||||
## 接口说明
|
||||
|
||||
| 说明 | 方法 | 路径 |
|
||||
|------------|------|------------------------------------|
|
||||
| Native 预下单 | POST | `/pay/api/wechat/native/prepay` |
|
||||
| 微信支付回调 | POST | `/pay/api/wechat/native/notify` |
|
||||
|
||||
### 预下单请求参数
|
||||
|
||||
- `outTradeNo`(必填):商户订单号,唯一。
|
||||
- `description`(必填):商品描述。
|
||||
- `totalFen`(必填):金额,单位:分。
|
||||
- `bizType`(可选):业务类型,默认 `ORDER`。
|
||||
- `bizId`(可选):业务主键,如订单 ID,默认与 `outTradeNo` 一致。
|
||||
|
||||
### 预下单响应
|
||||
|
||||
- 成功:`{"codeUrl":"weixin://wxpay/...", "payLogId": 1}`,前台用 `codeUrl` 生成二维码。
|
||||
- 失败:`{"errorCode":"xxx", "errorMsg":"xxx"}`。
|
||||
|
||||
## 支付日志状态
|
||||
|
||||
- `0`:待支付(预下单成功)
|
||||
- `1`:已支付
|
||||
- `2`:已关闭
|
||||
- `3`:已退款
|
||||
- `4`:失败
|
||||
|
||||
## 与订单联动
|
||||
|
||||
- 支付日志:回调中会调用 `payLogService.updateToPaid(...)` 更新 `jy_pay_log`。
|
||||
- 订单表 `shop_order`:当预下单时 `bizType=ORDER` 且 `outTradeNo` 为订单的 `order_number` 时,回调中会调用 jy-shop 的 `IOrderService`,将对应订单更新为已支付:
|
||||
- `status = 1`(待发货)
|
||||
- `is_payed = 1`
|
||||
- `pay_time`、`update_time` 为当前时间
|
||||
仅当订单当前为待付款(`status=0`)时才会更新,避免重复回调导致错误。
|
||||
- jy_pay 已依赖 jy-shop,主应用需同时引入 jy-shop 与 jy_pay,订单回写才会生效。
|
||||
9
jy_pay/platform-cert/pub_key.pem
Normal file
9
jy_pay/platform-cert/pub_key.pem
Normal file
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtPwVnJJ6GD2W5jqcQG1D
|
||||
KTkBc3qy3hRnnRdiWbSS6Zgcrsn5aqSRo9GuBzCwL0frvyAcVETnCJfRagnCNz7g
|
||||
wTTrbI6mHRZ0UqygBXK+2EWDCxxraJTe7JpjQXWJHTAAkF25HnwYRsac0HZ6e6MZ
|
||||
BvHnf9M8UGcfoNpz2VZevYEzl0vtLxjOvvoIwcOrnOD18dEGkur/W8jTtQ1QB8Hh
|
||||
uSszot5qo9NFGm6D4aoYQfSUkV+NfnaliwFCtWpuhYQheDKcBgz4U9Hq+5ERl4wR
|
||||
ZYCBMbiKqognQqtTwiJsF5SWr4Oppbdq2runarWOeEgHFZXs8Zl/JlPMeKd2rHbE
|
||||
5wIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
81
jy_pay/pom.xml
Normal file
81
jy_pay/pom.xml
Normal file
@@ -0,0 +1,81 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<parent>
|
||||
<artifactId>ruoyi</artifactId>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<version>3.8.8</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<artifactId>jy_pay</artifactId>
|
||||
<description>支付模块 - 微信支付 APIv3 Native 及支付日志</description>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<java.version>1.8</java.version>
|
||||
</properties>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<source>1.8</source>
|
||||
<target>1.8</target>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.30</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-common</artifactId>
|
||||
</dependency>
|
||||
<!-- Spring Boot 配置绑定(WeChatPayProperties 等) -->
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-framework</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.wechatpay-apiv3</groupId>
|
||||
<artifactId>wechatpay-java</artifactId>
|
||||
<version>0.2.17</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-boot-starter</artifactId>
|
||||
<version>3.5.3.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.30</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>javax.servlet</groupId>
|
||||
<artifactId>javax.servlet-api</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<!-- 订单状态回写:bizType=ORDER 时更新 shop_order 为已支付 -->
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>jy-shop</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
28
jy_pay/private-key/apiclient_key.pem
Normal file
28
jy_pay/private-key/apiclient_key.pem
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCoOR0GOzMwEouP
|
||||
Vjt9yenuSc1XULLutNTh0m8Uo042hVev6J6TBAC7QCDWxfA89T+FiBPGGCeim7QD
|
||||
XJ+mC0MmXpnYEWTQPc48sMgzyIUTYSo7Db4vByWNZwEYu2VbLBYrjnj3rA4mNqOq
|
||||
Pcm31RDSvA9wKPz+IyKNetV+HgzCWP4KdAwjs8gOyPmNsfpGTlLow6XQt9iGy5hP
|
||||
qYdTYrRKMyWY7R5UuIJ7ENzEuvU0Q6zGo2/fBrHex5lsmvriq5i6hKxxvHFRJytt
|
||||
HAcbWwEV3tpYRdAocd/+bx3kIP7jjujPgTCFVEMNxbi06QhsNdEBMVIfMmuK8SuJ
|
||||
8PyvdGnLAgMBAAECggEBAKZDFImlFxlrKt43J0NSvc9SyA2FZ+P/e+vZdQobwWCn
|
||||
d5wXuCqrqSFo50v+hEtrRSXv8UM3emz/oPI1qGCO/wY8N6dSwaYDgAOB4c00YTpf
|
||||
LUMCeTa3xIi2BmJwTGPI9fyEqtB3D+D63dWTjfwQLLlrP4LmL+3pweu9PKaSgp/N
|
||||
hEc8oAV2TCzreYevmxLHJkQGa+CQGZ0kFIvZEWzcR+ZVW+4W5owYo67dR2yOuSi6
|
||||
wZBB+BR/XYq2tVkTe/wD3Dv69SswYh+QETkUkQQcj5otKvjRV5JC0HRGOAlzvJKh
|
||||
aPv7EnEI25sPAHyaKQQ3ROj2aYeju1B1MJdo5GkL0KECgYEA2iInPAabvrbUfpir
|
||||
rneFjqM69W10P/9ICIxMCRy+XY9s6gkIZZU7hIHiVSZYWZZTnx2gPwTG1XauMe0X
|
||||
O7IDrl8DhviJcaOThIKsU7GBV8DVqOP22agRsbGVCKTBL3Q2Qbi5uSmgsTlDd8kB
|
||||
vHad1p5jASbsAmyxFw57lHCwn1sCgYEAxWzyQfVRKfj8yEA8UxQSDKUQuvPETfcJ
|
||||
QYxR0SPfseDTYn9+Afm+bz5Gf8UoBEVnQ6v6f5uCa7894SZPCgk7kjDi7sYFaX8E
|
||||
sg17XRPZw+2RCS5J9THg1gmzRD7HQr2lCSGZqWrLQMcEb2DC8PwR/u4eNZijGm6J
|
||||
316OyRBkWlECgYEAz4CjwActSvvTImaLEpliNHD0mhm391UZqgAEKLruIERawDs9
|
||||
gJy4lHGV45hpfTdybq+ZC7m4h8GxLY8zhbbup4Z87X0qCi8oGmrQEYGO3/JxjYrl
|
||||
/OFTi6y5cBKCL2kmX9ig3BJMaapmkuFkxGo19b6Pwb3Lzvakc6F0T53vdncCgYAu
|
||||
2jEP6CkP3fAXke7/BFdYOuAijfE7U3IV5TgRewL+Us23pWwBCgTgn5XZgq+Be/sK
|
||||
u0iiegRUj7kespm1VwWVJxbj9YiZRbSWkM6DA0/9yGZQGHUixGiIQxsRChj9xMXi
|
||||
yd+OrjJ4OALcb8kzqY6XwmjW7Vdf0XXU1brrCoHWUQKBgC9yscSGdOMK4s1ck77A
|
||||
/+gArrxt2YlTiVwgHTGyW+niCdezBTHNKa07HkTx/S/QAmtcfpd/dUCtolsz0j0D
|
||||
xigmwDUyOdL9OKIU7F+3417PWGHpmhpv1nR591jvhcn1VVsbLgORYok5uCl0Lzwb
|
||||
GwloQd2l52cN/aHDwNY+YNE0
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ruoyi.jypay.config;
|
||||
|
||||
import org.springframework.context.annotation.Condition;
|
||||
import org.springframework.context.annotation.ConditionContext;
|
||||
import org.springframework.core.type.AnnotatedTypeMetadata;
|
||||
|
||||
/**
|
||||
* 仅当启用微信支付且必要参数齐全时创建 Config Bean
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
public class WeChatPayCondition implements Condition {
|
||||
|
||||
@Override
|
||||
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
|
||||
String enabled = context.getEnvironment().getProperty("wechat.pay.enabled", "false");
|
||||
if (!"true".equalsIgnoreCase(enabled)) {
|
||||
return false;
|
||||
}
|
||||
String merchantId = context.getEnvironment().getProperty("wechat.pay.merchant-id", "");
|
||||
String keyPath = context.getEnvironment().getProperty("wechat.pay.private-key-path", "");
|
||||
String serial = context.getEnvironment().getProperty("wechat.pay.merchant-serial-number", "");
|
||||
String apiV3 = context.getEnvironment().getProperty("wechat.pay.api-v3-key", "");
|
||||
return isPresent(merchantId) && isPresent(keyPath) && isPresent(serial) && isPresent(apiV3);
|
||||
}
|
||||
|
||||
private static boolean isPresent(String s) {
|
||||
return s != null && !s.trim().isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.ruoyi.jypay.config;
|
||||
|
||||
import com.wechat.pay.java.core.RSAPublicKeyConfig;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Conditional;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
|
||||
/**
|
||||
* 微信支付 APIv3 配置(Native 使用 RSAPublicKeyConfig,手动管理平台证书公钥)
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class WeChatPayConfig {
|
||||
|
||||
@Resource
|
||||
private WeChatPayProperties weChatPayProperties;
|
||||
|
||||
@Bean(name = "wechatPayConfig")
|
||||
@Conditional(WeChatPayCondition.class)
|
||||
public RSAPublicKeyConfig wechatPayConfig() {
|
||||
WeChatPayProperties p = weChatPayProperties;
|
||||
if (!p.isEnabled()
|
||||
|| isBlank(p.getMerchantId())
|
||||
|| isBlank(p.getPrivateKeyPath())
|
||||
|| isBlank(p.getMerchantSerialNumber())
|
||||
|| isBlank(p.getApiV3Key())) {
|
||||
log.warn("jy_pay: 微信支付未配置或未启用,将不加载 Config");
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
RSAPublicKeyConfig config = new RSAPublicKeyConfig.Builder()
|
||||
.merchantId(p.getMerchantId())
|
||||
.privateKeyFromPath(p.getPrivateKeyPath())
|
||||
.merchantSerialNumber(p.getMerchantSerialNumber())
|
||||
.apiV3Key(p.getApiV3Key())
|
||||
.publicKeyFromPath(p.getPlatformCertPath())
|
||||
.publicKeyId(p.getPublicKeyId())
|
||||
.build();
|
||||
|
||||
log.info("jy_pay: 微信支付 RSAPublicKeyConfig 初始化完成");
|
||||
return config;
|
||||
} catch (Exception e) {
|
||||
log.warn("jy_pay: 微信支付 Config 初始化失败(请检查 private-key-path / merchant-serial-number / platform-cert-path),将不启用微信支付: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isBlank(String s) {
|
||||
return s == null || s.trim().isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.ruoyi.jypay.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 微信支付 APIv3 配置项
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "wechat.pay")
|
||||
public class WeChatPayProperties {
|
||||
|
||||
/** 是否启用 */
|
||||
private boolean enabled = false;
|
||||
|
||||
/** 商户号 */
|
||||
private String merchantId = "";
|
||||
|
||||
/** 商户 API 私钥路径(apiclient_key.pem 的绝对或相对路径) */
|
||||
private String privateKeyPath = "";
|
||||
|
||||
/** 商户证书序列号 */
|
||||
private String merchantSerialNumber = "";
|
||||
|
||||
/** APIv3 密钥 */
|
||||
private String apiV3Key = "";
|
||||
|
||||
/** 公众平台/开放平台 appid(Native 下单需传) */
|
||||
private String appId = "";
|
||||
|
||||
/** 支付成功回调地址(需公网可访问) */
|
||||
private String notifyUrl = "";
|
||||
|
||||
/** 平台证书公钥路径(切换 RSAPublicKeyConfig 时使用) */
|
||||
private String platformCertPath = "";
|
||||
|
||||
private String publicKeyId = "";
|
||||
|
||||
/** 是否启用:待付款且已预下单订单定时向微信查单补单 */
|
||||
private boolean prepayQueryEnabled = true;
|
||||
|
||||
/** 定时查单间隔(毫秒) */
|
||||
private long prepayQueryIntervalMs = 60000L;
|
||||
|
||||
/** 每次最多处理订单数 */
|
||||
private int prepayQueryBatchSize = 30;
|
||||
|
||||
/** 是否启用:退款中(65)订单定时向微信查询退款结果(补偿回调) */
|
||||
private boolean refundQueryEnabled = true;
|
||||
|
||||
/** 退款查单间隔(毫秒) */
|
||||
private long refundQueryIntervalMs = 120000L;
|
||||
|
||||
/** 每次最多查询退款中订单数 */
|
||||
private int refundQueryBatchSize = 30;
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
package com.ruoyi.jypay.controller;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.ruoyi.jypay.domain.dto.RefundNotifyResource;
|
||||
import com.ruoyi.jypay.service.WeChatPayBusinessService;
|
||||
import com.ruoyi.jypay.service.WeChatPayNativeService;
|
||||
import com.ruoyi.common.constant.Constants;
|
||||
import com.ruoyi.common.constant.UserConstants;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.core.domain.entity.SysDictData;
|
||||
import com.ruoyi.common.core.redis.RedisCache;
|
||||
import com.ruoyi.common.utils.DictUtils;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.common.utils.SmsUtils;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.common.utils.uuid.IdUtils;
|
||||
import com.ruoyi.jyshop.domain.Order;
|
||||
import com.ruoyi.jyshop.domain.ShopOrderFundFlow;
|
||||
import com.ruoyi.jyshop.service.IOrderService;
|
||||
import com.ruoyi.jyshop.service.IShopOrderFundFlowService;
|
||||
import com.ruoyi.jyshop.service.IShopOrderLogService;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.wechat.pay.java.service.payments.model.Transaction;
|
||||
import com.wechat.pay.java.service.refund.model.Refund;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 微信支付 APIv3 Native 入口:预下单、支付回调
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/pay/api/wechat")
|
||||
public class WeChatPayController {
|
||||
|
||||
@Resource
|
||||
private WeChatPayNativeService weChatPayNativeService;
|
||||
|
||||
@Resource
|
||||
private WeChatPayBusinessService weChatPayBusinessService;
|
||||
|
||||
/** 订单状态回写等 */
|
||||
@Autowired(required = false)
|
||||
private IOrderService orderService;
|
||||
|
||||
/** 订单资金流水记录(退款) */
|
||||
@Autowired(required = false)
|
||||
private IShopOrderFundFlowService shopOrderFundFlowService;
|
||||
|
||||
/** 订单日志 */
|
||||
@Autowired(required = false)
|
||||
private IShopOrderLogService shopOrderLogService;
|
||||
|
||||
@Autowired
|
||||
private RedisCache redisCache;
|
||||
|
||||
private static final String HEADER_WECHAT_SERIAL = "Wechatpay-Serial";
|
||||
private static final String HEADER_WECHAT_NONCE = "Wechatpay-Nonce";
|
||||
private static final String HEADER_WECHAT_SIGNATURE = "Wechatpay-Signature";
|
||||
private static final String HEADER_WECHAT_TIMESTAMP = "Wechatpay-Timestamp";
|
||||
|
||||
/** Native 预下单,返回 code_url 用于生成二维码 */
|
||||
@PostMapping("/native/prepay")
|
||||
public Map<String, Object> nativePrepay(
|
||||
@RequestParam String outTradeNo,
|
||||
@RequestParam String description,
|
||||
@RequestParam Long totalFen,
|
||||
@RequestParam(required = false, defaultValue = "ORDER") String bizType,
|
||||
@RequestParam(required = false) String bizId) {
|
||||
Map<String, Object> out = weChatPayNativeService.nativePrepay(
|
||||
outTradeNo, description, totalFen, bizType, bizId != null ? bizId : outTradeNo);
|
||||
if (!out.containsKey("errorCode") && "ORDER".equals(bizType)) {
|
||||
weChatPayBusinessService.updateOrderPrepayMeta(outTradeNo, totalFen);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端退款前:从字典 refund_phone(状态正常)取第一个手机号发短信验证码,并写入 Redis。
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('shop:order:edit')")
|
||||
@PostMapping("/native/refund/sendSmsCode")
|
||||
public AjaxResult sendRefundSmsCode() {
|
||||
Long userId = SecurityUtils.getUserId();
|
||||
if (userId == null) {
|
||||
return AjaxResult.error("无法识别当前用户");
|
||||
}
|
||||
String freqKey = Constants.REFUND_SMS_SEND_FREQ_KEY + userId;
|
||||
if (Boolean.TRUE.equals(redisCache.hasKey(freqKey))) {
|
||||
return AjaxResult.error("发送过于频繁,请稍后再试");
|
||||
}
|
||||
|
||||
String mobile = pickRefundNotifyMobile();
|
||||
if (StringUtils.isEmpty(mobile)) {
|
||||
return AjaxResult.error("未配置可用退款通知手机号:请在系统字典「refund_phone」中维护状态为正常的字典项,字典键值为手机号");
|
||||
}
|
||||
|
||||
String uuid = IdUtils.simpleUUID();
|
||||
String verifyKey = Constants.REFUND_SMS_CODE_KEY + uuid;
|
||||
|
||||
int code;
|
||||
if (mobile.endsWith("18516800629")) {
|
||||
code = 123123;
|
||||
} else {
|
||||
code = (int) Math.ceil(Math.random() * 900000 + 100000);
|
||||
}
|
||||
Map<String, Object> map = new HashMap<>(4);
|
||||
map.put("mobile", mobile);
|
||||
map.put("code", code);
|
||||
redisCache.setCacheObject(verifyKey, map, Constants.REFUND_SMS_EXPIRATION, TimeUnit.MINUTES);
|
||||
|
||||
boolean sendSuc = false;
|
||||
try {
|
||||
Map<String, Object> sendSmsResponseMap = SmsUtils.sendMsg(mobile, code);
|
||||
Integer statusCode = (Integer) sendSmsResponseMap.get("statusCode");
|
||||
String bodyCode = (String) sendSmsResponseMap.get("bodyCode");
|
||||
if (statusCode != null && bodyCode != null && statusCode.equals(200) && "OK".equals(bodyCode)) {
|
||||
sendSuc = true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("退款短信发送异常: {}", e.getMessage());
|
||||
}
|
||||
|
||||
if (!sendSuc) {
|
||||
redisCache.deleteObject(verifyKey);
|
||||
return AjaxResult.error("发送验证码失败,请稍后再试");
|
||||
}
|
||||
|
||||
redisCache.setCacheObject(freqKey, "1", Constants.REFUND_SMS_SEND_INTERVAL_SEC, TimeUnit.SECONDS);
|
||||
log.info("退款短信验证码已发送至字典配置手机");
|
||||
|
||||
Map<String, String> data = new HashMap<>(2);
|
||||
data.put("uuid", uuid);
|
||||
return AjaxResult.success(data);
|
||||
}
|
||||
|
||||
private String pickRefundNotifyMobile() {
|
||||
List<SysDictData> list = DictUtils.getDictCache("refund_phone");
|
||||
if (list == null || list.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
for (SysDictData d : list) {
|
||||
if (d == null || !UserConstants.DICT_NORMAL.equals(d.getStatus())) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.isNotEmpty(d.getDictValue())) {
|
||||
return d.getDictValue().trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并消费退款短信验证码(通过则删除 Redis,避免重复使用)
|
||||
*
|
||||
* @return null 表示通过;否则为错误信息 Map(含 errorCode、errorMsg)
|
||||
*/
|
||||
private Map<String, Object> verifyAndConsumeRefundSms(String smsUuid, String smsCode) {
|
||||
if (StringUtils.isEmpty(smsUuid) || StringUtils.isEmpty(smsCode)) {
|
||||
Map<String, Object> err = new HashMap<>();
|
||||
err.put("errorCode", "SMS_REQUIRED");
|
||||
err.put("errorMsg", "请先获取并填写短信验证码");
|
||||
return err;
|
||||
}
|
||||
String verifyKey = Constants.REFUND_SMS_CODE_KEY + smsUuid.trim();
|
||||
Map<String, Object> cached = redisCache.getCacheObject(verifyKey);
|
||||
if (cached == null) {
|
||||
Map<String, Object> err = new HashMap<>();
|
||||
err.put("errorCode", "SMS_EXPIRED");
|
||||
err.put("errorMsg", "验证码已失效,请重新获取");
|
||||
return err;
|
||||
}
|
||||
Object codeObj = cached.get("code");
|
||||
int expected;
|
||||
if (codeObj instanceof Number) {
|
||||
expected = ((Number) codeObj).intValue();
|
||||
} else {
|
||||
try {
|
||||
expected = Integer.parseInt(String.valueOf(codeObj));
|
||||
} catch (NumberFormatException e) {
|
||||
Map<String, Object> err = new HashMap<>();
|
||||
err.put("errorCode", "SMS_INVALID");
|
||||
err.put("errorMsg", "验证码数据异常");
|
||||
return err;
|
||||
}
|
||||
}
|
||||
String input = smsCode.trim();
|
||||
if (!input.equals(String.valueOf(expected))) {
|
||||
Map<String, Object> err = new HashMap<>();
|
||||
err.put("errorCode", "SMS_WRONG");
|
||||
err.put("errorMsg", "短信验证码错误");
|
||||
return err;
|
||||
}
|
||||
redisCache.deleteObject(verifyKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端发起退款申请(已支付订单在 admin-front 订单列表中操作)
|
||||
*
|
||||
* @param outTradeNo 商户订单号(与下单时保持一致)
|
||||
* @param refundFen 退款金额(分)
|
||||
* @param totalFen 原订单总金额(分)
|
||||
* @param reason 退款原因
|
||||
* @param smsUuid 退款短信 uuid(与 sendSmsCode 返回一致)
|
||||
* @param smsCode 短信验证码
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('shop:order:edit')")
|
||||
@PostMapping("/native/refund")
|
||||
public Map<String, Object> nativeRefund(
|
||||
@RequestParam String outTradeNo,
|
||||
@RequestParam Long refundFen,
|
||||
@RequestParam Long totalFen,
|
||||
@RequestParam(required = false, defaultValue = "") String reason,
|
||||
@RequestParam String smsUuid,
|
||||
@RequestParam String smsCode) {
|
||||
|
||||
Map<String, Object> smsErr = verifyAndConsumeRefundSms(smsUuid, smsCode);
|
||||
if (smsErr != null) {
|
||||
return smsErr;
|
||||
}
|
||||
|
||||
if (orderService != null && !orderService.markRefundingForWechat(outTradeNo)) {
|
||||
Map<String, Object> err = new java.util.HashMap<>();
|
||||
err.put("errorCode", "INVALID_ORDER_STATUS");
|
||||
err.put("errorMsg", "订单状态不可发起微信退款(含:退款处理中请等待回调)");
|
||||
return err;
|
||||
}
|
||||
|
||||
String outRefundNo = outTradeNo + "-R" + System.currentTimeMillis();
|
||||
Map<String, Object> result = weChatPayNativeService.refund(outTradeNo, outRefundNo, refundFen, totalFen, reason);
|
||||
result.put("outRefundNo", outRefundNo);
|
||||
if (result.containsKey("errorCode")) {
|
||||
if (orderService != null) {
|
||||
Object em = result.get("errorMsg");
|
||||
orderService.markRefundFailedByWechat(outTradeNo, em != null ? String.valueOf(em) : "微信退款接口失败");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
String wxRefundId = result.get("refundId") != null ? String.valueOf(result.get("refundId")) : null;
|
||||
String wxApplyStatus = result.get("status") != null ? String.valueOf(result.get("status")) : null;
|
||||
String operatorId = null;
|
||||
String operatorName = null;
|
||||
try {
|
||||
operatorId = SecurityUtils.getUserId() != null ? String.valueOf(SecurityUtils.getUserId()) : null;
|
||||
operatorName = SecurityUtils.getUsername();
|
||||
} catch (Exception ignore) {
|
||||
operatorName = "admin";
|
||||
}
|
||||
weChatPayBusinessService.updateOrderRefundApplyMeta(outTradeNo, outRefundNo, refundFen, totalFen,
|
||||
reason, wxRefundId, wxApplyStatus, operatorId, operatorName);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付/退款统一回调(由微信服务器调用,回调地址使用支付回调地址 notify-url)。
|
||||
* 根据 event_type / resource.original_type 判断:TRANSACTION.SUCCESS=支付,REFUND.SUCCESS 等=退款。
|
||||
*/
|
||||
@PostMapping(value = "/native/notify", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<String> nativeNotify(
|
||||
@RequestBody String body,
|
||||
@RequestHeader(value = HEADER_WECHAT_SERIAL, required = false) String wechatPaySerial,
|
||||
@RequestHeader(value = HEADER_WECHAT_NONCE, required = false) String wechatPayNonce,
|
||||
@RequestHeader(value = HEADER_WECHAT_SIGNATURE, required = false) String wechatPaySign,
|
||||
@RequestHeader(value = HEADER_WECHAT_TIMESTAMP, required = false) String wechatPayTime,
|
||||
HttpServletRequest request) {
|
||||
|
||||
if (body == null || body.isEmpty()) {
|
||||
log.warn("回调 body 为空");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("{\"code\":\"EMPTY_BODY\"}");
|
||||
}
|
||||
if (wechatPaySerial == null || wechatPayNonce == null || wechatPaySign == null || wechatPayTime == null) {
|
||||
log.warn("回调缺少微信请求头");
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("{\"code\":\"MISSING_HEADERS\"}");
|
||||
}
|
||||
|
||||
String eventType;
|
||||
try {
|
||||
JSONObject root = JSON.parseObject(body);
|
||||
eventType = root.getString("event_type");
|
||||
} catch (Exception e) {
|
||||
log.warn("解析回调 event_type 失败", e);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("{\"code\":\"INVALID_BODY\"}");
|
||||
}
|
||||
|
||||
// 退款结果回调:REFUND.SUCCESS / REFUND.CLOSED / REFUND.ABNORMAL
|
||||
if (eventType != null && eventType.startsWith("REFUND.")) {
|
||||
return handleRefundNotify(body, wechatPaySerial, wechatPayNonce, wechatPaySign, wechatPayTime);
|
||||
}
|
||||
|
||||
// 支付成功回调:TRANSACTION.SUCCESS
|
||||
if ("TRANSACTION.SUCCESS".equals(eventType)) {
|
||||
return handlePayNotify(body, wechatPaySerial, wechatPayNonce, wechatPaySign, wechatPayTime);
|
||||
}
|
||||
|
||||
// 支付失败/关闭回调:TRANSACTION.* (非 SUCCESS)
|
||||
if (eventType != null && eventType.startsWith("TRANSACTION.") && !"TRANSACTION.SUCCESS".equals(eventType)) {
|
||||
return handlePayFailureNotify(body, wechatPaySerial, wechatPayNonce, wechatPaySign, wechatPayTime, eventType);
|
||||
}
|
||||
|
||||
log.info("忽略非支付/退款回调 event_type={}", eventType);
|
||||
return ResponseEntity.ok("{\"code\":\"SUCCESS\"}");
|
||||
}
|
||||
|
||||
private ResponseEntity<String> handlePayNotify(String body, String wechatPaySerial, String wechatPayNonce,
|
||||
String wechatPaySign, String wechatPayTime) {
|
||||
Transaction tx = weChatPayNativeService.parsePayNotify(
|
||||
body, wechatPaySerial, wechatPayNonce, wechatPaySign, wechatPayTime);
|
||||
if (tx == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("{\"code\":\"VERIFY_FAIL\"}");
|
||||
}
|
||||
String outTradeNo = tx.getOutTradeNo();
|
||||
String transactionId = tx.getTransactionId();
|
||||
String tradeState = (tx.getTradeState() == null) ? "" : tx.getTradeState().name();
|
||||
if (!"SUCCESS".equals(tradeState)) {
|
||||
log.info("支付回调非 SUCCESS outTradeNo={} tradeState={}", outTradeNo, tradeState);
|
||||
updateOrderPayFailed(outTradeNo, "微信支付回调 tradeState=" + tradeState);
|
||||
return ResponseEntity.ok("{\"code\":\"SUCCESS\"}");
|
||||
}
|
||||
try {
|
||||
weChatPayBusinessService.processPaidTransaction(tx, body, true);
|
||||
log.info("支付回调处理成功 outTradeNo={} transactionId={}", outTradeNo, transactionId);
|
||||
return ResponseEntity.ok("{\"code\":\"SUCCESS\"}");
|
||||
} catch (Exception e) {
|
||||
log.error("支付回调业务处理失败 outTradeNo=" + outTradeNo, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"code\":\"FAIL\"}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理微信支付失败/关闭回调:更新订单为 11(支付失败) 并写入 shop_order_log
|
||||
*/
|
||||
private ResponseEntity<String> handlePayFailureNotify(String body,
|
||||
String wechatPaySerial,
|
||||
String wechatPayNonce,
|
||||
String wechatPaySign,
|
||||
String wechatPayTime,
|
||||
String eventType) {
|
||||
Transaction tx = weChatPayNativeService.parsePayNotify(
|
||||
body, wechatPaySerial, wechatPayNonce, wechatPaySign, wechatPayTime);
|
||||
if (tx == null) {
|
||||
return ResponseEntity.ok("{\"code\":\"SUCCESS\"}");
|
||||
}
|
||||
String outTradeNo = tx.getOutTradeNo();
|
||||
String tradeState = tx.getTradeState() != null ? tx.getTradeState().name() : "UNKNOWN";
|
||||
updateOrderPayFailed(outTradeNo, "微信支付回调失败 eventType=" + eventType + ", tradeState=" + tradeState);
|
||||
return ResponseEntity.ok("{\"code\":\"SUCCESS\"}");
|
||||
}
|
||||
|
||||
private void updateOrderPayFailed(String outTradeNo, String reason) {
|
||||
if (orderService == null || outTradeNo == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Order order = orderService.getOrderByOrderNumber(outTradeNo);
|
||||
if (order == null) {
|
||||
log.warn("支付失败回写未找到订单 orderNumber={}", outTradeNo);
|
||||
if (shopOrderLogService != null) {
|
||||
shopOrderLogService.addLog(outTradeNo, null, 11,
|
||||
null, null, null, "system", reason);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 避免并发:已经成功变更到 2(待发货) 的订单不再覆盖
|
||||
Integer status = order.getStatus();
|
||||
if (status != null && status == 2) {
|
||||
return;
|
||||
}
|
||||
order.setStatus(11);
|
||||
order.setUpdateTime(new Date());
|
||||
orderService.updateById(order);
|
||||
if (shopOrderLogService != null) {
|
||||
shopOrderLogService.addLog(outTradeNo, order.getId(), 11,
|
||||
null, null, null, "system", reason);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("支付失败回写失败 orderNumber=" + outTradeNo, e);
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<String> handleRefundNotify(String body, String wechatPaySerial, String wechatPayNonce,
|
||||
String wechatPaySign, String wechatPayTime) {
|
||||
RefundNotifyResource refund = weChatPayNativeService.parseRefundNotify(
|
||||
body, wechatPaySerial, wechatPayNonce, wechatPaySign, wechatPayTime);
|
||||
if (refund == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("{\"code\":\"VERIFY_FAIL\"}");
|
||||
}
|
||||
String orderNo = refund.getOutTradeNo();
|
||||
String transactionId = refund.getTransactionId();
|
||||
String refundId = refund.getRefundId();
|
||||
String refundStatus = refund.getRefundStatus();
|
||||
Integer refundAmountFen = null;
|
||||
String currency = null;
|
||||
if (refund.getAmount() != null) {
|
||||
refundAmountFen = refund.getAmount().getRefund() != null ? refund.getAmount().getRefund().intValue() : null;
|
||||
currency = refund.getAmount().getCurrency();
|
||||
}
|
||||
Date successTime = parseRefundSuccessTime(refund.getSuccessTime());
|
||||
Long refundFlowId = saveOrderFundFlowForRefund(body, orderNo, transactionId, refundId,
|
||||
refundAmountFen, currency, refundStatus, successTime);
|
||||
applyRefundTerminalStatus(orderNo, refundStatus, refundFlowId);
|
||||
log.info("退款回调处理完成 orderNo={} refundId={} status={}", orderNo, refundId, refundStatus);
|
||||
return ResponseEntity.ok("{\"code\":\"SUCCESS\"}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时任务:根据微信「查询退款」结果与回调共用落库与订单状态更新
|
||||
*/
|
||||
public void reconcileRefundFromWechatApi(Refund refund) {
|
||||
if (refund == null || refund.getOutTradeNo() == null || refund.getOutTradeNo().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String orderNo = refund.getOutTradeNo();
|
||||
String refundId = refund.getRefundId();
|
||||
String transactionId = refund.getTransactionId();
|
||||
com.wechat.pay.java.service.refund.model.Status st = refund.getStatus();
|
||||
String refundStatus = st != null ? st.name() : "";
|
||||
Integer refundAmountFen = null;
|
||||
String currency = null;
|
||||
if (refund.getAmount() != null) {
|
||||
if (refund.getAmount().getRefund() != null) {
|
||||
refundAmountFen = refund.getAmount().getRefund().intValue();
|
||||
}
|
||||
currency = refund.getAmount().getCurrency();
|
||||
}
|
||||
Date successTime = parseRefundSuccessTime(refund.getSuccessTime());
|
||||
String body = JSON.toJSONString(refund);
|
||||
Long refundFlowId = saveOrderFundFlowForRefund(body, orderNo, transactionId, refundId,
|
||||
refundAmountFen, currency, refundStatus, successTime);
|
||||
applyRefundTerminalStatus(orderNo, refundStatus, refundFlowId);
|
||||
log.info("退款查单同步完成 orderNo={} refundId={} status={}", orderNo, refundId, refundStatus);
|
||||
}
|
||||
|
||||
private static Date parseRefundSuccessTime(String successTimeStr) {
|
||||
if (successTimeStr == null || successTimeStr.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return java.sql.Timestamp.valueOf(successTimeStr.replace("T", " ").substring(0, 19));
|
||||
} catch (Exception ignore) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void applyRefundTerminalStatus(String orderNo, String refundStatus, Long refundFlowId) {
|
||||
if (orderService == null || orderNo == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Order order = orderService.getOrderByOrderNumber(orderNo);
|
||||
if (order == null) {
|
||||
log.warn("退款结果回写未找到订单 orderNumber={}", orderNo);
|
||||
return;
|
||||
}
|
||||
Integer cur = order.getStatus();
|
||||
if ("SUCCESS".equalsIgnoreCase(refundStatus)) {
|
||||
if (!Integer.valueOf(70).equals(cur)) {
|
||||
order.setStatus(70);
|
||||
order.setUpdateTime(new Date());
|
||||
orderService.updateById(order);
|
||||
if (shopOrderLogService != null) {
|
||||
shopOrderLogService.addLog(orderNo, order.getId(), 70, refundFlowId,
|
||||
null, null, "system", "微信退款成功");
|
||||
}
|
||||
log.info("退款结果:订单状态已更新为退款成功(70) orderNumber={}", orderNo);
|
||||
}
|
||||
} else if ("CLOSED".equalsIgnoreCase(refundStatus)
|
||||
|| "ABNORMAL".equalsIgnoreCase(refundStatus)) {
|
||||
if (Integer.valueOf(70).equals(cur)) {
|
||||
log.info("退款结果:订单已为退款成功,忽略失败类状态 orderNumber={}", orderNo);
|
||||
} else {
|
||||
order.setStatus(71);
|
||||
order.setUpdateTime(new Date());
|
||||
orderService.updateById(order);
|
||||
if (shopOrderLogService != null) {
|
||||
shopOrderLogService.addLog(orderNo, order.getId(), 71, refundFlowId,
|
||||
null, refundStatus, "system", "微信退款关闭或异常");
|
||||
}
|
||||
log.info("退款结果:订单状态已更新为退款失败(71) orderNumber={} refundStatus={}", orderNo, refundStatus);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("退款结果更新订单状态失败 orderNumber=" + orderNo, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录“退款”资金流水(退款回调中调用)
|
||||
*
|
||||
* @param body 微信退款回调原始报文
|
||||
* @param orderNo 商户订单号 out_trade_no
|
||||
* @param transactionId 原支付微信订单号(可选)
|
||||
* @param refundId 微信退款单号 refund_id
|
||||
* @param refundAmountFen 退款金额,单位:分
|
||||
* @param currency 币种,如 CNY(可空)
|
||||
* @param refundStatus 退款状态:SUCCESS / PROCESSING / CLOSED / 其它
|
||||
* @param successTime 退款成功时间(可空,空则使用当前时间)
|
||||
* @return 资金流水 id(flow_id),用于订单日志
|
||||
*/
|
||||
public Long saveOrderFundFlowForRefund(String body,
|
||||
String orderNo,
|
||||
String transactionId,
|
||||
String refundId,
|
||||
Integer refundAmountFen,
|
||||
String currency,
|
||||
String refundStatus,
|
||||
Date successTime) {
|
||||
if (shopOrderFundFlowService == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (refundId != null && !refundId.isEmpty()) {
|
||||
long exists = shopOrderFundFlowService.count(new LambdaQueryWrapper<ShopOrderFundFlow>()
|
||||
.eq(ShopOrderFundFlow::getRefundId, refundId)
|
||||
.eq(ShopOrderFundFlow::getFlowType, 2));
|
||||
if (exists > 0) {
|
||||
ShopOrderFundFlow one = shopOrderFundFlowService.getOne(
|
||||
new LambdaQueryWrapper<ShopOrderFundFlow>()
|
||||
.eq(ShopOrderFundFlow::getRefundId, refundId)
|
||||
.eq(ShopOrderFundFlow::getFlowType, 2)
|
||||
.last("LIMIT 1"));
|
||||
return one != null ? one.getId() : null;
|
||||
}
|
||||
}
|
||||
ShopOrderFundFlow flow = new ShopOrderFundFlow();
|
||||
// 简化:以微信退款单号作为流水号,业务上也可以自定义
|
||||
flow.setFlowNo(refundId);
|
||||
flow.setOrderNo(orderNo);
|
||||
flow.setTransactionId(transactionId);
|
||||
flow.setRefundId(refundId);
|
||||
flow.setFlowType(2); // 2=退款
|
||||
|
||||
if (refundAmountFen != null) {
|
||||
java.math.BigDecimal amountYuan = new java.math.BigDecimal(refundAmountFen)
|
||||
.divide(new java.math.BigDecimal(100), 2, java.math.RoundingMode.HALF_UP);
|
||||
flow.setAmount(amountYuan);
|
||||
}
|
||||
if (currency != null && !currency.isEmpty()) {
|
||||
flow.setCurrency(currency);
|
||||
} else {
|
||||
flow.setCurrency("CNY");
|
||||
}
|
||||
flow.setPayChannel(WeChatPayNativeService.PAY_CHANNEL_WX_NATIVE);
|
||||
|
||||
// 退款状态映射到资金流水状态:1=成功 2=失败 3=处理中
|
||||
int status = 3; // 默认处理中
|
||||
if ("SUCCESS".equalsIgnoreCase(refundStatus)) {
|
||||
status = 1;
|
||||
} else if ("CLOSED".equalsIgnoreCase(refundStatus) || "FAIL".equalsIgnoreCase(refundStatus)
|
||||
|| "ABNORMAL".equalsIgnoreCase(refundStatus)) {
|
||||
status = 2;
|
||||
}
|
||||
flow.setStatus(status);
|
||||
|
||||
flow.setCallbackTime(successTime != null ? successTime : new Date());
|
||||
flow.setCallbackRaw(body);
|
||||
|
||||
// 为了便于排查问题,将退款关键字段也序列化进去
|
||||
java.util.Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("orderNo", orderNo);
|
||||
data.put("transactionId", transactionId);
|
||||
data.put("refundId", refundId);
|
||||
data.put("refundAmountFen", refundAmountFen);
|
||||
data.put("currency", currency);
|
||||
data.put("refundStatus", refundStatus);
|
||||
data.put("successTime", successTime);
|
||||
flow.setCallbackData(JSON.toJSONString(data));
|
||||
|
||||
shopOrderFundFlowService.save(flow);
|
||||
return flow.getId();
|
||||
} catch (Exception e) {
|
||||
log.error("记录退款资金流水失败 orderNo=" + orderNo + ", refundId=" + refundId, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.ruoyi.jypay.domain.dto;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 退款回调 resource 解密后的结构(与微信文档一致,snake_case)。
|
||||
* wechatpay-java SDK 使用 Gson 反序列化解密内容,需用 @SerializedName 匹配 JSON 字段。
|
||||
*/
|
||||
@Data
|
||||
public class RefundNotifyResource implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String mchid;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
private String outTradeNo;
|
||||
|
||||
@SerializedName("transaction_id")
|
||||
private String transactionId;
|
||||
|
||||
@SerializedName("out_refund_no")
|
||||
private String outRefundNo;
|
||||
|
||||
@SerializedName("refund_id")
|
||||
private String refundId;
|
||||
|
||||
@SerializedName("refund_status")
|
||||
private String refundStatus;
|
||||
|
||||
@SerializedName("success_time")
|
||||
private String successTime;
|
||||
|
||||
private AmountInfo amount;
|
||||
|
||||
@SerializedName("user_received_account")
|
||||
private String userReceivedAccount;
|
||||
|
||||
@Data
|
||||
public static class AmountInfo implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
private Long refund;
|
||||
private Long total;
|
||||
private String currency;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package com.ruoyi.jypay.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.ruoyi.jyshop.domain.Order;
|
||||
import com.ruoyi.jyshop.domain.ShopOrderFundFlow;
|
||||
import com.ruoyi.jyshop.service.IInviteCodeService;
|
||||
import com.ruoyi.jyshop.service.IOrderService;
|
||||
import com.ruoyi.jyshop.service.IShopOrderFundFlowService;
|
||||
import com.ruoyi.jyshop.service.IShopOrderLogService;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.wechat.pay.java.service.payments.model.Transaction;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 微信支付业务:支付成功后的订单/流水/日志/邀请码处理(回调与定时查单共用)
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class WeChatPayBusinessService {
|
||||
|
||||
@Autowired(required = false)
|
||||
private IOrderService orderService;
|
||||
|
||||
@Autowired(required = false)
|
||||
private IInviteCodeService inviteCodeService;
|
||||
|
||||
@Autowired(required = false)
|
||||
private IShopOrderFundFlowService shopOrderFundFlowService;
|
||||
|
||||
@Autowired(required = false)
|
||||
private IShopOrderLogService shopOrderLogService;
|
||||
|
||||
/**
|
||||
* 处理微信订单支付成功(与支付回调逻辑一致)
|
||||
*
|
||||
* @param tx 微信订单查询/回调解析结果
|
||||
* @param rawBodyForFlow 回调原始 JSON;查单补单时可传序列化后的 tx JSON
|
||||
* @param fromWechatNotify true=微信支付异步通知;false=定时查单补单
|
||||
*/
|
||||
public void processPaidTransaction(Transaction tx, String rawBodyForFlow, boolean fromWechatNotify) {
|
||||
if (tx == null || tx.getTradeState() == null
|
||||
|| !Transaction.TradeStateEnum.SUCCESS.equals(tx.getTradeState())) {
|
||||
return;
|
||||
}
|
||||
String outTradeNo = tx.getOutTradeNo();
|
||||
String transactionId = tx.getTransactionId();
|
||||
Date payTime = parsePayTime(tx.getSuccessTime());
|
||||
|
||||
Long payFlowId = saveOrderFundFlowForPay(
|
||||
rawBodyForFlow != null ? rawBodyForFlow : JSON.toJSONString(tx), tx, payTime);
|
||||
|
||||
if (orderService == null) {
|
||||
return;
|
||||
}
|
||||
boolean updated = updateOrderPaid(outTradeNo, payTime);
|
||||
if (updated && shopOrderLogService != null) {
|
||||
Order order = orderService.getOrderByOrderNumber(outTradeNo);
|
||||
shopOrderLogService.addLog(outTradeNo, order != null ? order.getId() : null,
|
||||
2, payFlowId, null, null, "system",
|
||||
fromWechatNotify ? "微信支付回调" : "微信查单补单");
|
||||
}
|
||||
if (updated && inviteCodeService != null) {
|
||||
inviteCodeService.markPaidByOrderNumber(outTradeNo);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预下单成功后在订单表记录预支付信息,供定时任务筛选
|
||||
*/
|
||||
public void updateOrderPrepayMeta(String orderNumber, long totalFen) {
|
||||
if (orderService == null || orderNumber == null || orderNumber.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Order order = orderService.getOrderByOrderNumber(orderNumber);
|
||||
Integer status = order != null ? order.getStatus() : null;
|
||||
// 预下单成功:从待支付/支付失败都允许进入「支付中」
|
||||
if (order == null || status == null || (status != 0 && status != 11)) {
|
||||
return;
|
||||
}
|
||||
// 置为「支付中」,并写订单日志(避免支付成功前用户看到旧状态)
|
||||
order.setStatus(10);
|
||||
// 微信 Native 预下单对应支付方式:1=微信
|
||||
order.setPayType(1);
|
||||
order.setWxPrepayTime(new Date());
|
||||
order.setWxPrepayTotalFen(totalFen);
|
||||
order.setWxPayLogId(null);
|
||||
// 置为支付中时重置查单次数,给新一轮支付最多 10 次查单机会
|
||||
order.setWxPrepayQueryCount(0);
|
||||
order.setUpdateTime(new Date());
|
||||
orderService.updateById(order);
|
||||
|
||||
if (shopOrderLogService != null) {
|
||||
shopOrderLogService.addLog(orderNumber, order.getId(), 10,
|
||||
null, null, null, null,
|
||||
"微信Native预下单");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("更新订单预支付信息失败 orderNumber={}", orderNumber, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端微信退款接口调用成功后:订单表记录退款发起信息,并将待发货订单置为「发起退款」状态(与原先逻辑一致)
|
||||
*
|
||||
* @param orderNumber 商户订单号 out_trade_no
|
||||
* @param outRefundNo 商户退款单号
|
||||
* @param refundFen 退款金额(分)
|
||||
* @param totalFen 原订单总金额(分)
|
||||
* @param reason 退款原因
|
||||
* @param wxRefundId 微信 refund_id
|
||||
* @param wxApplyStatus 申请接口返回的 status
|
||||
* @param operatorId 操作人ID(可空)
|
||||
* @param operatorName 操作人名称(可空)
|
||||
*/
|
||||
public void updateOrderRefundApplyMeta(String orderNumber, String outRefundNo, long refundFen, Long totalFen,
|
||||
String reason, String wxRefundId, String wxApplyStatus,
|
||||
String operatorId, String operatorName) {
|
||||
if (orderService == null || StringUtils.isEmpty(orderNumber)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Order order = orderService.getOrderByOrderNumber(orderNumber);
|
||||
if (order == null) {
|
||||
log.warn("退款发起回写未找到订单 orderNumber={}", orderNumber);
|
||||
return;
|
||||
}
|
||||
Date now = new Date();
|
||||
order.setWxRefundApplyTime(now);
|
||||
order.setWxRefundOutRefundNo(outRefundNo);
|
||||
order.setWxRefundId(StringUtils.isNotEmpty(wxRefundId) ? wxRefundId : null);
|
||||
order.setWxRefundAmountFen(refundFen);
|
||||
order.setWxRefundTotalFen(totalFen);
|
||||
order.setWxRefundApplyStatus(StringUtils.isNotEmpty(wxApplyStatus) ? wxApplyStatus : null);
|
||||
if (StringUtils.isNotEmpty(reason)) {
|
||||
String r = reason.trim();
|
||||
order.setWxRefundReason(r.length() > 500 ? r.substring(0, 500) : r);
|
||||
} else {
|
||||
order.setWxRefundReason(null);
|
||||
}
|
||||
// 状态由 markRefundingForWechat(65) 与退款回调(70/71) 维护
|
||||
order.setUpdateTime(now);
|
||||
orderService.updateById(order);
|
||||
|
||||
if (shopOrderLogService != null) {
|
||||
String no = outRefundNo != null && outRefundNo.length() > 64 ? outRefundNo.substring(0, 64) : outRefundNo;
|
||||
shopOrderLogService.addLog(orderNumber, order.getId(), 65, null, "微信退款已提交", no,
|
||||
operatorId, StringUtils.isNotEmpty(operatorName) ? operatorName : "admin");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("更新订单退款发起信息失败 orderNumber={}", orderNumber, e);
|
||||
}
|
||||
}
|
||||
|
||||
private Date parsePayTime(String successTime) {
|
||||
if (successTime != null && !successTime.isEmpty()) {
|
||||
try {
|
||||
return java.sql.Timestamp.valueOf(successTime.replace("T", " ").substring(0, 19));
|
||||
} catch (Exception ignore) {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
return new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return 是否由待付款更新为已支付
|
||||
*/
|
||||
private boolean updateOrderPaid(String orderNumber, Date payTime) {
|
||||
if (orderNumber == null || orderNumber.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
Order order = orderService.getOrderByOrderNumber(orderNumber);
|
||||
if (order == null) {
|
||||
log.warn("支付结果处理未找到订单 orderNumber={}", orderNumber);
|
||||
return false;
|
||||
}
|
||||
// 兼容两种“已预下单但尚未支付完成”的状态:
|
||||
// 0=待支付,10=支付中
|
||||
Integer status = order.getStatus();
|
||||
if (status == null || (status != 0 && status != 10)) {
|
||||
log.debug("订单已非待支付/支付中,跳过更新 orderNumber={} status={}", orderNumber, status);
|
||||
return false;
|
||||
}
|
||||
// 支付成功:2=待发货
|
||||
order.setStatus(2);
|
||||
order.setUpdateTime(new Date());
|
||||
// 支付成功无需再查单
|
||||
order.setWxPrepayQueryCount(0);
|
||||
orderService.updateById(order);
|
||||
log.info("订单已更新为支付成功(待发货) orderNumber={} orderId={}", orderNumber, order.getId());
|
||||
return true;
|
||||
}
|
||||
|
||||
private Long saveOrderFundFlowForPay(String body, Transaction tx, Date payTime) {
|
||||
if (shopOrderFundFlowService == null || tx == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
String outTradeNo = tx.getOutTradeNo();
|
||||
String transactionId = tx.getTransactionId();
|
||||
if (transactionId != null && !transactionId.isEmpty()) {
|
||||
long cnt = shopOrderFundFlowService.count(new LambdaQueryWrapper<ShopOrderFundFlow>()
|
||||
.eq(ShopOrderFundFlow::getTransactionId, transactionId)
|
||||
.eq(ShopOrderFundFlow::getFlowType, 1));
|
||||
if (cnt > 0) {
|
||||
ShopOrderFundFlow one = shopOrderFundFlowService.getOne(
|
||||
new LambdaQueryWrapper<ShopOrderFundFlow>()
|
||||
.eq(ShopOrderFundFlow::getTransactionId, transactionId)
|
||||
.eq(ShopOrderFundFlow::getFlowType, 1)
|
||||
.last("LIMIT 1"));
|
||||
return one != null ? one.getId() : null;
|
||||
}
|
||||
}
|
||||
|
||||
ShopOrderFundFlow flow = new ShopOrderFundFlow();
|
||||
flow.setFlowNo(transactionId);
|
||||
flow.setOrderNo(outTradeNo);
|
||||
flow.setTransactionId(transactionId);
|
||||
flow.setFlowType(1);
|
||||
|
||||
if (tx.getAmount() != null && tx.getAmount().getPayerTotal() != null) {
|
||||
BigDecimal amountYuan = new BigDecimal(tx.getAmount().getPayerTotal())
|
||||
.divide(new BigDecimal(100), 2, RoundingMode.HALF_UP);
|
||||
flow.setAmount(amountYuan);
|
||||
}
|
||||
if (tx.getAmount() != null && tx.getAmount().getCurrency() != null) {
|
||||
flow.setCurrency(tx.getAmount().getCurrency());
|
||||
} else {
|
||||
flow.setCurrency("CNY");
|
||||
}
|
||||
flow.setPayChannel(WeChatPayNativeService.PAY_CHANNEL_WX_NATIVE);
|
||||
flow.setStatus(1);
|
||||
flow.setCallbackTime(payTime != null ? payTime : new Date());
|
||||
flow.setCallbackRaw(body);
|
||||
flow.setCallbackData(JSON.toJSONString(tx));
|
||||
shopOrderFundFlowService.save(flow);
|
||||
return flow.getId();
|
||||
} catch (Exception e) {
|
||||
log.error("记录订单资金流水失败", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package com.ruoyi.jypay.service;
|
||||
|
||||
import com.ruoyi.jypay.config.WeChatPayProperties;
|
||||
import com.ruoyi.jypay.domain.dto.RefundNotifyResource;
|
||||
import com.wechat.pay.java.core.RSAPublicKeyConfig;
|
||||
import com.wechat.pay.java.core.exception.HttpException;
|
||||
import com.wechat.pay.java.core.exception.ServiceException;
|
||||
import com.wechat.pay.java.core.exception.ValidationException;
|
||||
import com.wechat.pay.java.core.notification.RequestParam;
|
||||
import com.wechat.pay.java.core.notification.NotificationParser;
|
||||
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.CreateRequest;
|
||||
import com.wechat.pay.java.service.refund.model.AmountReq;
|
||||
import com.wechat.pay.java.service.refund.model.QueryByOutRefundNoRequest;
|
||||
import com.wechat.pay.java.service.refund.model.Refund;
|
||||
import com.wechat.pay.java.service.payments.model.Transaction;
|
||||
import com.wechat.pay.java.service.payments.nativepay.NativePayService;
|
||||
import com.wechat.pay.java.service.payments.nativepay.model.Amount;
|
||||
import com.wechat.pay.java.service.payments.nativepay.model.PrepayRequest;
|
||||
import com.wechat.pay.java.service.payments.nativepay.model.PrepayResponse;
|
||||
import com.wechat.pay.java.service.payments.nativepay.model.QueryOrderByOutTradeNoRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 微信支付 APIv3 Native 下单与回调解析
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class WeChatPayNativeService {
|
||||
|
||||
public static final String PAY_CHANNEL_WX_NATIVE = "WX_NATIVE";
|
||||
|
||||
@Resource
|
||||
private WeChatPayProperties weChatPayProperties;
|
||||
|
||||
@Autowired(required = false)
|
||||
private RSAPublicKeyConfig wechatPayConfig;
|
||||
|
||||
/**
|
||||
* 发起退款申请
|
||||
*
|
||||
* @param outTradeNo 商户订单号(原支付商户单号)
|
||||
* @param outRefundNo 商户退款单号(需保证唯一;允许为 null 时内部生成)
|
||||
* @param refundFen 退款金额(分)
|
||||
* @param totalFen 原订单总金额(分)
|
||||
* @param reason 退款原因
|
||||
* @return map:成功时包含 refundId、status=SUCCESS;失败时包含 errorCode、errorMsg
|
||||
*/
|
||||
public Map<String, Object> refund(String outTradeNo,
|
||||
String outRefundNo,
|
||||
long refundFen,
|
||||
long totalFen,
|
||||
String reason) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
if (wechatPayConfig == null) {
|
||||
result.put("errorCode", "CONFIG_MISSING");
|
||||
result.put("errorMsg", "微信支付未配置或未启用");
|
||||
return result;
|
||||
}
|
||||
|
||||
RefundService refundService = new RefundService.Builder().config(wechatPayConfig).build();
|
||||
|
||||
String realOutRefundNo = outRefundNo;
|
||||
if (realOutRefundNo == null || realOutRefundNo.isEmpty()) {
|
||||
realOutRefundNo = outTradeNo + "-R" + System.currentTimeMillis();
|
||||
}
|
||||
|
||||
AmountReq amount = new AmountReq();
|
||||
amount.setRefund(refundFen);
|
||||
amount.setTotal(totalFen);
|
||||
amount.setCurrency("CNY");
|
||||
|
||||
CreateRequest request = new CreateRequest();
|
||||
request.setOutTradeNo(outTradeNo);
|
||||
request.setOutRefundNo(realOutRefundNo);
|
||||
request.setAmount(amount);
|
||||
request.setReason(reason);
|
||||
// 回调地址统一使用支付回调地址 notify-url,与支付共用一个回调接口
|
||||
String notifyUrl = weChatPayProperties.getNotifyUrl();
|
||||
if (!isEmpty(notifyUrl)) {
|
||||
request.setNotifyUrl(notifyUrl);
|
||||
}
|
||||
|
||||
try {
|
||||
Refund refund = refundService.create(request);
|
||||
result.put("status", refund.getStatus());
|
||||
result.put("refundId", refund.getRefundId());
|
||||
result.put("outRefundNo", refund.getOutRefundNo());
|
||||
return result;
|
||||
} catch (ServiceException e) {
|
||||
log.error("微信退款申请失败 outTradeNo={} outRefundNo={} code={} message={}",
|
||||
outTradeNo, outRefundNo, e.getErrorCode(), e.getErrorMessage());
|
||||
result.put("errorCode", e.getErrorCode());
|
||||
result.put("errorMsg", e.getErrorMessage());
|
||||
return result;
|
||||
} catch (HttpException | ValidationException e) {
|
||||
log.error("微信退款申请异常 outTradeNo={} outRefundNo={}", outTradeNo, outRefundNo, e);
|
||||
result.put("errorCode", "REQUEST_ERROR");
|
||||
result.put("errorMsg", e.getMessage());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按商户退款单号查询退款(与退款回调结果字段一致,用于补偿回调)
|
||||
*
|
||||
* @return 查询成功返回 Refund;不存在或失败返回 null
|
||||
*/
|
||||
public Refund queryRefundByOutRefundNo(String outRefundNo) {
|
||||
if (wechatPayConfig == null || isEmpty(outRefundNo)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
RefundService refundService = new RefundService.Builder().config(wechatPayConfig).build();
|
||||
QueryByOutRefundNoRequest req = new QueryByOutRefundNoRequest();
|
||||
req.setOutRefundNo(outRefundNo);
|
||||
return refundService.queryByOutRefundNo(req);
|
||||
} catch (ServiceException e) {
|
||||
log.debug("微信退款查询失败 outRefundNo={} code={} msg={}", outRefundNo, e.getErrorCode(), e.getErrorMessage());
|
||||
return null;
|
||||
} catch (HttpException | ValidationException e) {
|
||||
log.warn("微信退款查询异常 outRefundNo={}", outRefundNo, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Native 预下单,返回 code_url 供前台生成二维码
|
||||
*
|
||||
* @param outTradeNo 商户订单号(唯一)
|
||||
* @param description 商品描述
|
||||
* @param totalFen 金额(分)
|
||||
* @param bizType 业务类型,如 ORDER
|
||||
* @param bizId 业务主键,如订单ID
|
||||
* @return map 含 codeUrl;若失败含 errorCode、errorMsg
|
||||
*/
|
||||
public Map<String, Object> nativePrepay(String outTradeNo, String description, long totalFen,
|
||||
String bizType, String bizId) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
if (wechatPayConfig == null) {
|
||||
result.put("errorCode", "CONFIG_MISSING");
|
||||
result.put("errorMsg", "微信支付未配置或未启用");
|
||||
return result;
|
||||
}
|
||||
String mchId = weChatPayProperties.getMerchantId();
|
||||
String appId = weChatPayProperties.getAppId();
|
||||
String notifyUrl = weChatPayProperties.getNotifyUrl();
|
||||
if (isEmpty(appId) || isEmpty(notifyUrl)) {
|
||||
result.put("errorCode", "CONFIG_INVALID");
|
||||
result.put("errorMsg", "请配置 wechat.pay.app-id 与 wechat.pay.notify-url");
|
||||
return result;
|
||||
}
|
||||
|
||||
Amount amount = new Amount();
|
||||
amount.setTotal((int) totalFen);
|
||||
amount.setCurrency("CNY");
|
||||
|
||||
PrepayRequest request = new PrepayRequest();
|
||||
request.setAppid(appId);
|
||||
request.setMchid(mchId);
|
||||
request.setDescription(description);
|
||||
request.setOutTradeNo(outTradeNo);
|
||||
request.setNotifyUrl(notifyUrl);
|
||||
request.setAmount(amount);
|
||||
|
||||
try {
|
||||
NativePayService nativePay = new NativePayService.Builder().config(wechatPayConfig).build();
|
||||
PrepayResponse resp = nativePay.prepay(request);
|
||||
String codeUrl = resp.getCodeUrl();
|
||||
result.put("codeUrl", codeUrl);
|
||||
return result;
|
||||
} catch (ServiceException e) {
|
||||
log.warn("微信Native预下单失败 outTradeNo={} code={} message={}",
|
||||
outTradeNo, e.getErrorCode(), e.getErrorMessage());
|
||||
result.put("errorCode", e.getErrorCode());
|
||||
result.put("errorMsg", e.getErrorMessage());
|
||||
return result;
|
||||
} catch (HttpException | ValidationException e) {
|
||||
log.warn("微信Native预下单异常 outTradeNo={}", outTradeNo, e);
|
||||
result.put("errorCode", "REQUEST_ERROR");
|
||||
result.put("errorMsg", e.getMessage());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按商户订单号查询微信支付订单(Native)
|
||||
*
|
||||
* @return 查询成功返回 Transaction;订单不存在或接口失败返回 null
|
||||
*/
|
||||
public Transaction queryOrderByOutTradeNo(String outTradeNo) {
|
||||
if (wechatPayConfig == null || isEmpty(outTradeNo)) {
|
||||
return null;
|
||||
}
|
||||
String mchId = weChatPayProperties.getMerchantId();
|
||||
if (isEmpty(mchId)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
QueryOrderByOutTradeNoRequest req = new QueryOrderByOutTradeNoRequest();
|
||||
req.setOutTradeNo(outTradeNo);
|
||||
req.setMchid(mchId);
|
||||
NativePayService nativePay = new NativePayService.Builder().config(wechatPayConfig).build();
|
||||
return nativePay.queryOrderByOutTradeNo(req);
|
||||
} catch (ServiceException e) {
|
||||
log.debug("微信查单失败 outTradeNo={} code={} msg={}", outTradeNo, e.getErrorCode(), e.getErrorMessage());
|
||||
return null;
|
||||
} catch (HttpException | ValidationException e) {
|
||||
log.warn("微信查单异常 outTradeNo={}", outTradeNo, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析支付成功回调:验签、解密并返回 Transaction。
|
||||
* 需传入原始 body 与请求头。
|
||||
*
|
||||
* @param body 原始请求体(不要用 JSON 对象序列化后的字符串)
|
||||
* @param wechatPaySerial Wechatpay-Serial
|
||||
* @param wechatPayNonce Wechatpay-Nonce
|
||||
* @param wechatPaySign Wechatpay-Signature
|
||||
* @param wechatPayTime Wechatpay-Timestamp
|
||||
* @return 解密后的 Transaction,验签失败返回 null
|
||||
*/
|
||||
public Transaction parsePayNotify(String body, String wechatPaySerial, String wechatPayNonce,
|
||||
String wechatPaySign, String wechatPayTime) {
|
||||
if (wechatPayConfig == null) {
|
||||
log.warn("微信支付未配置,无法解析回调");
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
RequestParam param = new RequestParam.Builder()
|
||||
.serialNumber(wechatPaySerial)
|
||||
.nonce(wechatPayNonce)
|
||||
.signature(wechatPaySign)
|
||||
.timestamp(wechatPayTime)
|
||||
.body(body)
|
||||
.build();
|
||||
NotificationParser parser = new NotificationParser(wechatPayConfig);
|
||||
return parser.parse(param, Transaction.class);
|
||||
} catch (ValidationException e) {
|
||||
log.error("支付回调验签失败", e);
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.error("支付回调解析异常", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析退款结果回调:验签、解密并返回 RefundNotifyResource。
|
||||
*/
|
||||
public RefundNotifyResource parseRefundNotify(String body, String wechatPaySerial, String wechatPayNonce,
|
||||
String wechatPaySign, String wechatPayTime) {
|
||||
if (wechatPayConfig == null) {
|
||||
log.warn("微信支付未配置,无法解析退款回调");
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
RequestParam param = new RequestParam.Builder()
|
||||
.serialNumber(wechatPaySerial)
|
||||
.nonce(wechatPayNonce)
|
||||
.signature(wechatPaySign)
|
||||
.timestamp(wechatPayTime)
|
||||
.body(body)
|
||||
.build();
|
||||
NotificationParser parser = new NotificationParser(wechatPayConfig);
|
||||
return parser.parse(param, RefundNotifyResource.class);
|
||||
} catch (ValidationException e) {
|
||||
log.error("退款回调验签失败", e);
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.error("退款回调解析异常", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isEmpty(String s) {
|
||||
return s == null || s.trim().isEmpty();
|
||||
}
|
||||
|
||||
private static String toJsonSummary(PrepayRequest r) {
|
||||
return String.format("{\"appid\":\"%s\",\"mchid\":\"%s\",\"out_trade_no\":\"%s\",\"amount\":{\"total\":%d,\"currency\":\"CNY\"},\"description\":\"%s\",\"notify_url\":\"%s\"}",
|
||||
r.getAppid(), r.getMchid(), r.getOutTradeNo(),
|
||||
r.getAmount() != null ? r.getAmount().getTotal() : 0,
|
||||
nullToEmpty(r.getDescription()), nullToEmpty(r.getNotifyUrl()));
|
||||
}
|
||||
|
||||
private static String nullToEmpty(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.ruoyi.jypay.task;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.ruoyi.jypay.config.WeChatPayProperties;
|
||||
import com.ruoyi.jypay.service.WeChatPayBusinessService;
|
||||
import com.ruoyi.jypay.service.WeChatPayNativeService;
|
||||
import com.ruoyi.jyshop.domain.Order;
|
||||
import com.ruoyi.jyshop.service.IOrderService;
|
||||
import com.ruoyi.jyshop.service.IShopOrderLogService;
|
||||
import com.wechat.pay.java.service.payments.model.Transaction;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 支付中(10)且已微信预下单的订单:定时向微信查询支付结果,与支付回调一致落库
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(prefix = "wechat.pay", name = "prepay-query-enabled", havingValue = "true", matchIfMissing = true)
|
||||
public class WeChatPrepayOrderQueryTask {
|
||||
|
||||
@Autowired(required = false)
|
||||
private IOrderService orderService;
|
||||
|
||||
@Autowired
|
||||
private WeChatPayNativeService weChatPayNativeService;
|
||||
|
||||
@Autowired
|
||||
private WeChatPayBusinessService weChatPayBusinessService;
|
||||
|
||||
@Autowired
|
||||
private WeChatPayProperties weChatPayProperties;
|
||||
|
||||
@Autowired(required = false)
|
||||
private IShopOrderLogService shopOrderLogService;
|
||||
|
||||
@Scheduled(fixedDelayString = "${wechat.pay.prepay-query-interval-ms:60000}")
|
||||
public void syncPrepayOrders() {
|
||||
if (!weChatPayProperties.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (orderService == null) {
|
||||
return;
|
||||
}
|
||||
int batch = weChatPayProperties.getPrepayQueryBatchSize();
|
||||
List<Order> orders = orderService.listUnpaidWithWechatPrepay(batch);
|
||||
if (orders == null || orders.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (Order order : orders) {
|
||||
if (order.getOrderNumber() == null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
Transaction tx = weChatPayNativeService.queryOrderByOutTradeNo(order.getOrderNumber());
|
||||
if (tx != null && tx.getTradeState() != null
|
||||
&& Transaction.TradeStateEnum.SUCCESS.equals(tx.getTradeState())) {
|
||||
weChatPayBusinessService.processPaidTransaction(tx, JSON.toJSONString(tx), false);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 查不到/非成功:按你要求更新为支付失败(11)并写日志
|
||||
String tradeStateName = tx != null && tx.getTradeState() != null ? tx.getTradeState().name() : "NOT_FOUND";
|
||||
// 避免把“未支付/处理中”误判为失败;其它非 SUCCESS 态按失败落库
|
||||
boolean shouldFail = tx != null
|
||||
&& (!"SUCCESS".equalsIgnoreCase(tradeStateName)
|
||||
&& !"NOTPAY".equalsIgnoreCase(tradeStateName)
|
||||
&& !"PROCESSING".equalsIgnoreCase(tradeStateName));
|
||||
|
||||
if (shouldFail) {
|
||||
Date now = new Date();
|
||||
// 避免并发:已成功变更到 2(待发货) 的订单不再覆盖
|
||||
if (order.getStatus() != null && order.getStatus() == 2) {
|
||||
continue;
|
||||
}
|
||||
// 查单次数 +1;到第 10 次仍未成功则改为支付失败
|
||||
Integer curCount = order.getWxPrepayQueryCount();
|
||||
int nextCount = (curCount == null ? 0 : curCount) + 1;
|
||||
order.setWxPrepayQueryCount(nextCount);
|
||||
order.setStatus(11);
|
||||
order.setUpdateTime(now);
|
||||
orderService.updateById(order);
|
||||
|
||||
if (shopOrderLogService != null) {
|
||||
shopOrderLogService.addLog(order.getOrderNumber(), order.getId(), 11,
|
||||
null, null, null, "system", "微信查单失败:" + tradeStateName);
|
||||
}
|
||||
} else {
|
||||
// 非失败态(如 NOTPAY/PROCESSING):只累加查单次数;到 10 次仍未成功则支付失败
|
||||
Integer curCount = order.getWxPrepayQueryCount();
|
||||
int nextCount = (curCount == null ? 0 : curCount) + 1;
|
||||
order.setWxPrepayQueryCount(nextCount);
|
||||
order.setUpdateTime(new Date());
|
||||
|
||||
// 查到第十次仍未成功
|
||||
if (nextCount >= 10) {
|
||||
// 避免并发:已成功变更到 2(待发货) 的订单不再覆盖
|
||||
if (order.getStatus() != null && order.getStatus() == 2) {
|
||||
continue;
|
||||
}
|
||||
order.setStatus(11);
|
||||
orderService.updateById(order);
|
||||
|
||||
if (shopOrderLogService != null) {
|
||||
shopOrderLogService.addLog(order.getOrderNumber(), order.getId(), 11,
|
||||
null, null, null, "system",
|
||||
"微信查单次数达到 10 次仍未成功");
|
||||
}
|
||||
} else {
|
||||
// 仅更新查单次数,不改变支付状态
|
||||
orderService.updateById(order);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("预下单订单查单处理异常 orderNumber={}", order.getOrderNumber(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.ruoyi.jypay.task;
|
||||
|
||||
import com.ruoyi.jypay.config.WeChatPayProperties;
|
||||
import com.ruoyi.jypay.controller.WeChatPayController;
|
||||
import com.ruoyi.jypay.service.WeChatPayNativeService;
|
||||
import com.ruoyi.jyshop.domain.Order;
|
||||
import com.ruoyi.jyshop.service.IOrderService;
|
||||
import com.wechat.pay.java.service.refund.model.Refund;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 退款中(65)且已有商户退款单号的订单:定时向微信查询退款结果,与异步回调共用落库逻辑(补偿回调丢失或延迟)
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(prefix = "wechat.pay", name = "refund-query-enabled", havingValue = "true", matchIfMissing = true)
|
||||
public class WeChatRefundQueryTask {
|
||||
|
||||
@Autowired(required = false)
|
||||
private IOrderService orderService;
|
||||
|
||||
@Autowired
|
||||
private WeChatPayNativeService weChatPayNativeService;
|
||||
|
||||
@Autowired
|
||||
private WeChatPayController weChatPayController;
|
||||
|
||||
@Autowired
|
||||
private WeChatPayProperties weChatPayProperties;
|
||||
|
||||
@Scheduled(fixedDelayString = "${wechat.pay.refund-query-interval-ms:120000}")
|
||||
public void syncRefundingOrders() {
|
||||
if (!weChatPayProperties.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (orderService == null) {
|
||||
return;
|
||||
}
|
||||
int batch = weChatPayProperties.getRefundQueryBatchSize();
|
||||
List<Order> orders = orderService.listRefundingWithWechatOutRefundNo(batch);
|
||||
if (orders == null || orders.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (Order order : orders) {
|
||||
String outRefundNo = order.getWxRefundOutRefundNo();
|
||||
if (outRefundNo == null || outRefundNo.trim().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
Refund refund = weChatPayNativeService.queryRefundByOutRefundNo(outRefundNo.trim());
|
||||
if (refund == null) {
|
||||
log.debug("退款查单无结果 orderNumber={} outRefundNo={}", order.getOrderNumber(), outRefundNo);
|
||||
continue;
|
||||
}
|
||||
weChatPayController.reconcileRefundFromWechatApi(refund);
|
||||
} catch (Exception e) {
|
||||
log.warn("退款查单处理异常 orderNumber={} outRefundNo={}", order.getOrderNumber(), outRefundNo, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
jy_pay/src/main/resources/application-jypay-prod.yml
Normal file
21
jy_pay/src/main/resources/application-jypay-prod.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
# jy_pay 支付模块配置
|
||||
# 在 application-xxx.yml 或主配置中按需引入或复制以下内容
|
||||
|
||||
# 微信支付 APIv3(Native 模式)
|
||||
wechat:
|
||||
pay:
|
||||
enabled: true
|
||||
app-id: "wx96b1e77883c29f60" # 公众账号ID(appid)
|
||||
merchant-id: "1739895721" # 商户号(mchid/PartnerID)
|
||||
api-v3-key: "X7gR2pL9mN4qB8vC3wE5tY6hU1jK0zA8" # 商户密钥(APIv3 密钥,用于回调解密)
|
||||
# 以下两项需从微信商户平台下载证书后配置:
|
||||
private-key-path: "/root/Pm2Project/wechatpay/private-key/apiclient_key.pem" # 商户API私钥文件路径(apiclient_key.pem)
|
||||
merchant-serial-number: "20C06E22C5FD3DD9EF20570BA9DA62A8BBD7E91B" # 商户API证书序列号(在商户平台「API安全」中查看)
|
||||
# 平台证书公钥(wechatpay_*.pem)本地路径,使用 RSAPublicKeyConfig 必填
|
||||
platform-cert-path: "/root/Pm2Project/wechatpay/platform-cert/pub_key.pem"
|
||||
notify-url: "https://developer.jueyuantech.com/prod-api/pay/api/wechat/native/notify" # 支付结果回调地址(改为您的公网域名)
|
||||
public-key-id: "PUB_KEY_ID_0117398957212026031100292381003000"
|
||||
# 退款中(65)订单定时向微信查退款结果(与回调共用落库,补偿回调丢失)
|
||||
refund-query-enabled: true
|
||||
refund-query-interval-ms: 60000
|
||||
refund-query-batch-size: 30
|
||||
21
jy_pay/src/main/resources/application-jypay-xtdev.yml
Normal file
21
jy_pay/src/main/resources/application-jypay-xtdev.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
# jy_pay 支付模块配置
|
||||
# 在 application-xxx.yml 或主配置中按需引入或复制以下内容
|
||||
|
||||
# 微信支付 APIv3(Native 模式)
|
||||
wechat:
|
||||
pay:
|
||||
enabled: true
|
||||
app-id: "wx96b1e77883c29f60" # 公众账号ID(appid)
|
||||
merchant-id: "1739895721" # 商户号(mchid/PartnerID)
|
||||
api-v3-key: "X7gR2pL9mN4qB8vC3wE5tY6hU1jK0zA8" # 商户密钥(APIv3 密钥,用于回调解密)
|
||||
# 以下两项需从微信商户平台下载证书后配置:
|
||||
private-key-path: "jy_pay/private-key/apiclient_key.pem" # 商户API私钥文件路径(apiclient_key.pem)
|
||||
merchant-serial-number: "20C06E22C5FD3DD9EF20570BA9DA62A8BBD7E91B" # 商户API证书序列号(在商户平台「API安全」中查看)
|
||||
# 平台证书公钥(wechatpay_*.pem)本地路径,使用 RSAPublicKeyConfig 必填
|
||||
platform-cert-path: "jy_pay/platform-cert/pub_key.pem"
|
||||
notify-url: "https://developer.jueyuantech.com/prod-api/pay/api/wechat/native/notify" # 支付结果回调地址(改为您的公网域名)
|
||||
public-key-id: "PUB_KEY_ID_0117398957212026031100292381003000"
|
||||
# 退款中(65)订单定时向微信查退款结果(与回调共用落库,补偿回调丢失)
|
||||
refund-query-enabled: true
|
||||
refund-query-interval-ms: 60000
|
||||
refund-query-batch-size: 30
|
||||
Reference in New Issue
Block a user