Initial commit

This commit is contained in:
一休哥哥6666
2026-06-14 14:39:51 +08:00
commit e186d45bec
1484 changed files with 305925 additions and 0 deletions

74
jy_pay/README.md Normal file
View 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订单回写才会生效。

View 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
View 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>

View 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-----

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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 = "";
/** 公众平台/开放平台 appidNative 下单需传) */
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;
}

View File

@@ -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 资金流水 idflow_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;
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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);
}
}
}
}

View File

@@ -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);
}
}
}
}

View File

@@ -0,0 +1,21 @@
# jy_pay 支付模块配置
# 在 application-xxx.yml 或主配置中按需引入或复制以下内容
# 微信支付 APIv3Native 模式)
wechat:
pay:
enabled: true
app-id: "wx96b1e77883c29f60" # 公众账号IDappid
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

View File

@@ -0,0 +1,21 @@
# jy_pay 支付模块配置
# 在 application-xxx.yml 或主配置中按需引入或复制以下内容
# 微信支付 APIv3Native 模式)
wechat:
pay:
enabled: true
app-id: "wx96b1e77883c29f60" # 公众账号IDappid
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