|
|
@@ -41,6 +41,7 @@ import lombok.SneakyThrows;
|
|
|
import org.slf4j.Logger;
|
|
|
import org.slf4j.LoggerFactory;
|
|
|
import org.springframework.core.io.ClassPathResource;
|
|
|
+import org.springframework.dao.DuplicateKeyException;
|
|
|
import org.springframework.http.HttpStatus;
|
|
|
import org.springframework.http.ResponseEntity;
|
|
|
import org.springframework.stereotype.Service;
|
|
|
@@ -90,6 +91,7 @@ public class WxPayServiceImpl implements WxPayService {
|
|
|
private final WashOrderService washOrderService;
|
|
|
private final MpMsgTemplateService mpMsgTemplateService;
|
|
|
private final RechargePromotionService rechargePromotionService;
|
|
|
+ private final UserService userService;
|
|
|
|
|
|
|
|
|
/**
|
|
|
@@ -104,7 +106,8 @@ public class WxPayServiceImpl implements WxPayService {
|
|
|
ActivityService activityService,
|
|
|
RechargeConfigService rechargeConfigService, SplitRecordService splitRecordService,
|
|
|
WashOrderService washOrderService, MpMsgTemplateService mpMsgTemplateService,
|
|
|
- RechargePromotionService rechargePromotionService) {
|
|
|
+ RechargePromotionService rechargePromotionService,
|
|
|
+ UserService userService) {
|
|
|
this.conf = conf;
|
|
|
this.walletDetailService = walletDetailService;
|
|
|
this.payLogService = payLogService;
|
|
|
@@ -115,6 +118,7 @@ public class WxPayServiceImpl implements WxPayService {
|
|
|
this.washOrderService = washOrderService;
|
|
|
this.mpMsgTemplateService = mpMsgTemplateService;
|
|
|
this.rechargePromotionService = rechargePromotionService;
|
|
|
+ this.userService = userService;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -213,7 +217,7 @@ public class WxPayServiceImpl implements WxPayService {
|
|
|
*/
|
|
|
@Override
|
|
|
@Transactional(rollbackFor = Exception.class)
|
|
|
- public PrepayWithRequestPaymentResponse wxPay(Long rechargeConfigId, String stationId) {
|
|
|
+ public PrepayWithRequestPaymentResponse wxPay(Long rechargeConfigId) {
|
|
|
// 充值配置
|
|
|
var rechargeConfig = rechargeConfigService.getById(rechargeConfigId);
|
|
|
|
|
|
@@ -224,6 +228,8 @@ public class WxPayServiceImpl implements WxPayService {
|
|
|
var rechargeAmount = rechargeConfig.getRechargeAmount();
|
|
|
var openid = StpUtil.getSession().getString("openid");
|
|
|
var userId = StpUtil.getLoginIdAsLong();
|
|
|
+ // 充值归属站点以服务端用户归属为准,不信任客户端传值(防止分账归属被篡改)
|
|
|
+ var stationId = getUserStationId(userId);
|
|
|
// 生成订单号
|
|
|
String outTradeNo = OrderUtils.getOrderNo();
|
|
|
// 创建钱包流水
|
|
|
@@ -261,7 +267,7 @@ public class WxPayServiceImpl implements WxPayService {
|
|
|
*/
|
|
|
@Override
|
|
|
@Transactional(rollbackFor = Exception.class)
|
|
|
- public PrepayWithRequestPaymentResponse promotionPay(String promotionToken, String stationId) {
|
|
|
+ public PrepayWithRequestPaymentResponse promotionPay(String promotionToken) {
|
|
|
var promotion = rechargePromotionService.getByToken(promotionToken);
|
|
|
if (promotion == null) {
|
|
|
throw new BusinessException("优惠活动不存在或已过期");
|
|
|
@@ -273,6 +279,8 @@ public class WxPayServiceImpl implements WxPayService {
|
|
|
}
|
|
|
|
|
|
var openid = StpUtil.getSession().getString("openid");
|
|
|
+ // 充值归属站点以服务端用户归属为准,不信任客户端传值(防止分账归属被篡改)
|
|
|
+ var stationId = getUserStationId(userId);
|
|
|
var outTradeNo = OrderUtils.getOrderNo();
|
|
|
|
|
|
var walletDetail = new WalletDetail()
|
|
|
@@ -314,6 +322,18 @@ public class WxPayServiceImpl implements WxPayService {
|
|
|
return jsapiService.queryOrderById(request);
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 服务端获取用户归属站点(KymCache 优先,未命中查库兜底),用于充值分账归属
|
|
|
+ */
|
|
|
+ private String getUserStationId(Long userId) {
|
|
|
+ var stationId = KymCache.INSTANCE.getUserStationId(userId);
|
|
|
+ if (stationId == null) {
|
|
|
+ var user = userService.getById(userId);
|
|
|
+ stationId = user != null ? user.getStationId() : null;
|
|
|
+ }
|
|
|
+ return stationId;
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 关闭订单
|
|
|
*
|
|
|
@@ -371,24 +391,8 @@ public class WxPayServiceImpl implements WxPayService {
|
|
|
grantsAmount = rechargeConfig.getGrantsAmount();
|
|
|
}
|
|
|
|
|
|
- // 更新余额(赠款计入不可退优惠金额)
|
|
|
- var account = accountService.getAccountByUserId(walletDetail.getUserId());
|
|
|
- accountService.lambdaUpdate().setSql("balance = balance + {0}, recharge_balance = recharge_balance + {0}, grants_balance = grants_balance + {1}, discount_amount = discount_amount + {1}", transaction.getAmount().getTotal(), grantsAmount)
|
|
|
- .eq(Account::getUserId, walletDetail.getUserId()).update();
|
|
|
-
|
|
|
- walletDetail.setStatus(WalletDetail.STATUS_已确认); //已确认
|
|
|
- walletDetail.setSource("WX_PAY");
|
|
|
- walletDetail.setCurrency(transaction.getAmount().getCurrency());
|
|
|
- walletDetail.setAmount(transaction.getAmount().getTotal());
|
|
|
- walletDetail.setGrantsAmount(grantsAmount);
|
|
|
- walletDetail.setBeforeBalance(account.getBalance());
|
|
|
- walletDetail.setAfterBalance(account.getBalance() + walletDetail.getAmount());
|
|
|
- walletDetail.setBeforeGrantsBalance(account.getGrantsBalance());
|
|
|
- walletDetail.setAfterGrantsBalance(account.getGrantsBalance() + grantsAmount);
|
|
|
- walletDetail.setTransactionTime(successTime);
|
|
|
- walletDetailService.updateById(walletDetail);
|
|
|
-
|
|
|
- // 支付记录
|
|
|
+ // 支付记录(幂等门闩:必须先落库 payLog,唯一索引 uk_out_trade_no 兜底并发重复回调;
|
|
|
+ // 若放在余额更新之后,重复回调会先重复加余额再被拦截)
|
|
|
var payLog = new PayLog();
|
|
|
payLog.setUserId(walletDetail.getUserId());
|
|
|
payLog.setOpenid(transaction.getPayer().getOpenid());
|
|
|
@@ -405,7 +409,30 @@ public class WxPayServiceImpl implements WxPayService {
|
|
|
payLog.setCurrency(transaction.getAmount().getCurrency());
|
|
|
payLog.setPayerTotal(transaction.getAmount().getPayerTotal());
|
|
|
payLog.setPayerCurrency(transaction.getAmount().getPayerCurrency());
|
|
|
- payLogService.save(payLog);
|
|
|
+ try {
|
|
|
+ payLogService.save(payLog);
|
|
|
+ } catch (DuplicateKeyException e) {
|
|
|
+ // 并发重复回调:唯一索引拦截,视为已处理
|
|
|
+ LOGGER.warn("微信支付回调重复投递已被唯一索引拦截,outTradeNo={}", transaction.getOutTradeNo());
|
|
|
+ return ResponseEntity.status(HttpStatus.OK).build();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 更新余额(赠款计入不可退优惠金额)
|
|
|
+ var account = accountService.getAccountByUserId(walletDetail.getUserId());
|
|
|
+ accountService.lambdaUpdate().setSql("balance = balance + {0}, recharge_balance = recharge_balance + {0}, grants_balance = grants_balance + {1}, discount_amount = discount_amount + {1}", transaction.getAmount().getTotal(), grantsAmount)
|
|
|
+ .eq(Account::getUserId, walletDetail.getUserId()).update();
|
|
|
+
|
|
|
+ walletDetail.setStatus(WalletDetail.STATUS_已确认); //已确认
|
|
|
+ walletDetail.setSource("WX_PAY");
|
|
|
+ walletDetail.setCurrency(transaction.getAmount().getCurrency());
|
|
|
+ walletDetail.setAmount(transaction.getAmount().getTotal());
|
|
|
+ walletDetail.setGrantsAmount(grantsAmount);
|
|
|
+ walletDetail.setBeforeBalance(account.getBalance());
|
|
|
+ walletDetail.setAfterBalance(account.getBalance() + walletDetail.getAmount());
|
|
|
+ walletDetail.setBeforeGrantsBalance(account.getGrantsBalance());
|
|
|
+ walletDetail.setAfterGrantsBalance(account.getGrantsBalance() + grantsAmount);
|
|
|
+ walletDetail.setTransactionTime(successTime);
|
|
|
+ walletDetailService.updateById(walletDetail);
|
|
|
|
|
|
// V2 结算方案:充值资金记录分账流水,结算日统一处理,不再即时转入站点账户
|
|
|
// 无归属站点的用户充值暂不生成分账记录,首次消费时追溯补建
|
|
|
@@ -429,15 +456,14 @@ public class WxPayServiceImpl implements WxPayService {
|
|
|
LOGGER.error("微信支付通知处理异常,资金流水为空,回调信息:{}", transaction);
|
|
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("code", HttpStatus.INTERNAL_SERVER_ERROR, "message", "资金流水为空"));
|
|
|
}
|
|
|
- } catch (Exception e) {
|
|
|
- if (e instanceof ValidationException) {
|
|
|
- // 签名验证失败,返回 401 UNAUTHORIZED 状态码
|
|
|
- LOGGER.error("微信支付通知验签失败", e);
|
|
|
- }
|
|
|
- if (e instanceof BusinessException) {
|
|
|
- LOGGER.error("业务异常", e);
|
|
|
- }
|
|
|
+ } catch (ValidationException e) {
|
|
|
+ // 签名验证失败,返回 401 UNAUTHORIZED 状态码
|
|
|
+ LOGGER.error("微信支付通知验签失败", e);
|
|
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("code", HttpStatus.UNAUTHORIZED, "message", "验签失败"));
|
|
|
+ } catch (Exception e) {
|
|
|
+ // 业务处理失败返回 500,微信会重试投递;重试时由唯一索引/幂等快路径保证不会重复入账
|
|
|
+ LOGGER.error("微信支付通知处理异常", e);
|
|
|
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("code", HttpStatus.INTERNAL_SERVER_ERROR, "message", "处理失败"));
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -502,9 +528,14 @@ public class WxPayServiceImpl implements WxPayService {
|
|
|
throw new BusinessException("存在未完结的订单,请完成所有订单完结之后重试");
|
|
|
}
|
|
|
|
|
|
- var account = accountService.getAccountByUserId(userId);
|
|
|
- if (account.getBalance() <= 0) {
|
|
|
- throw new BusinessException("账户余额不足,无需退款");
|
|
|
+ // 行锁读取账户,防止并发退款请求同时通过余额校验造成重复退款
|
|
|
+ var account = accountService.lambdaQuery().eq(Account::getUserId, userId).last("FOR UPDATE").one();
|
|
|
+ if (account == null) {
|
|
|
+ throw new BusinessException("用户账户不存在");
|
|
|
+ }
|
|
|
+ // 退款金额以充值余额为准(纯赠款余额不可退),校验与冻结/退款口径一致
|
|
|
+ if (account.getRechargeBalance() <= 0) {
|
|
|
+ throw new BusinessException("充值余额不足,无需退款");
|
|
|
}
|
|
|
// // 校验余额大于优惠金额
|
|
|
// if (account.getBalance() <= account.getDiscountAmount()) {
|
|
|
@@ -699,9 +730,13 @@ public class WxPayServiceImpl implements WxPayService {
|
|
|
RefundNotification refundNotification = ((NotificationParser) notifyRes[1]).parse((RequestParam) notifyRes[0], RefundNotification.class);
|
|
|
LOGGER.info("微信退款回调{}:验签解密完毕,数据:\n{}", notifyRes[2], refundNotification);
|
|
|
|
|
|
- //退款日志在申请时插入,接收通知时更新
|
|
|
+ // 退款日志在申请时插入,接收通知时更新
|
|
|
var refundLog = refundLogService.lambdaQuery().eq(RefundLog::getOutRefundNo, refundNotification.getOutRefundNo()).one();
|
|
|
- // 防止重复处理消息
|
|
|
+ if (refundLog == null) {
|
|
|
+ LOGGER.error("微信退款回调:未找到退款记录,outRefundNo={}", refundNotification.getOutRefundNo());
|
|
|
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("code", HttpStatus.INTERNAL_SERVER_ERROR, "message", "退款记录不存在"));
|
|
|
+ }
|
|
|
+ // 幂等快路径:已处理成功直接返回
|
|
|
if (RefundLog.STATUS_退款成功.equals(refundLog.getStatus())) {
|
|
|
return ResponseEntity.status(HttpStatus.OK).build();
|
|
|
}
|
|
|
@@ -709,28 +744,41 @@ public class WxPayServiceImpl implements WxPayService {
|
|
|
DateTime dt = DateUtil.parse(refundNotification.getSuccessTime());
|
|
|
LocalDateTime successTime = LocalDateTimeUtil.of(dt);
|
|
|
|
|
|
- refundLogService.lambdaUpdate()
|
|
|
- .set(RefundLog::getRefundId, refundNotification.getRefundId())
|
|
|
- .set(RefundLog::getTransactionId, refundNotification.getTransactionId())
|
|
|
- .set(RefundLog::getUserReceivedAccount, refundNotification.getUserReceivedAccount())
|
|
|
- .set(RefundLog::getSuccessTime, successTime)
|
|
|
- .set(RefundLog::getStatus, refundNotification.getRefundStatus().name())
|
|
|
- .set(RefundLog::getTotal, refundNotification.getAmount().getTotal().intValue())
|
|
|
- .set(RefundLog::getRefund, refundNotification.getAmount().getRefund().intValue())
|
|
|
- .eq(RefundLog::getId, refundLog.getId()).update();
|
|
|
-
|
|
|
if (RefundLog.STATUS_退款成功.equals(refundNotification.getRefundStatus().name())) {
|
|
|
+ // 资金流水必须在申请退款时已存在;检查放在门闩之前,避免门闩已置成功但后续失败造成不一致
|
|
|
+ var walletDetail = walletDetailService.getWalletDetailByOrderNo(refundNotification.getOutRefundNo(), WalletDetail.TYPE_退款);
|
|
|
+ if (walletDetail == null) {
|
|
|
+ LOGGER.error("微信退款回调:未找到退款资金流水,outRefundNo={}", refundNotification.getOutRefundNo());
|
|
|
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("code", HttpStatus.INTERNAL_SERVER_ERROR, "message", "退款流水不存在"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // 幂等门闩:条件更新(status <> 已成功),并发重复回调第二个事务更新 0 行直接跳过
|
|
|
+ boolean claimed = refundLogService.lambdaUpdate()
|
|
|
+ .set(RefundLog::getRefundId, refundNotification.getRefundId())
|
|
|
+ .set(RefundLog::getTransactionId, refundNotification.getTransactionId())
|
|
|
+ .set(RefundLog::getUserReceivedAccount, refundNotification.getUserReceivedAccount())
|
|
|
+ .set(RefundLog::getSuccessTime, successTime)
|
|
|
+ .set(RefundLog::getStatus, refundNotification.getRefundStatus().name())
|
|
|
+ .set(RefundLog::getTotal, refundNotification.getAmount().getTotal().intValue())
|
|
|
+ .set(RefundLog::getRefund, refundNotification.getAmount().getRefund().intValue())
|
|
|
+ .eq(RefundLog::getId, refundLog.getId())
|
|
|
+ .ne(RefundLog::getStatus, RefundLog.STATUS_退款成功)
|
|
|
+ .update();
|
|
|
+ if (!claimed) {
|
|
|
+ return ResponseEntity.status(HttpStatus.OK).build();
|
|
|
+ }
|
|
|
+
|
|
|
// 冻结金额扣减此次(退款金额+优惠金额),优惠金额字段减去申请退款时的优惠金额
|
|
|
var account = accountService.getAccountByUserId(refundLog.getUserId());
|
|
|
- accountService.lambdaUpdate().setSql("frozen_amount = (frozen_amount - %d) , discount_amount = (discount_amount - %d)"
|
|
|
- .formatted(refundNotification.getAmount().getRefund().intValue(), refundLog.getDiscountAmount()))
|
|
|
+ var refundAmount = refundNotification.getAmount().getRefund().intValue();
|
|
|
+ accountService.lambdaUpdate()
|
|
|
+ .setSql("frozen_amount = (frozen_amount - {0}), discount_amount = (discount_amount - {1})",
|
|
|
+ refundAmount, refundLog.getDiscountAmount())
|
|
|
.eq(Account::getUserId, refundLog.getUserId()).update();
|
|
|
|
|
|
// 更新资金流水
|
|
|
// 注意:此时 balance 已在 applyWxRefund 阶段清零,
|
|
|
// beforeBalance 应反映退款前余额(即退款金额),afterBalance 为 0
|
|
|
- var walletDetail = walletDetailService.getWalletDetailByOrderNo(refundNotification.getOutRefundNo(), WalletDetail.TYPE_退款);
|
|
|
- var refundAmount = refundNotification.getAmount().getRefund().intValue();
|
|
|
walletDetailService.lambdaUpdate()
|
|
|
.set(WalletDetail::getStatus, WalletDetail.STATUS_已确认)
|
|
|
.set(WalletDetail::getTransactionId, refundNotification.getTransactionId())
|
|
|
@@ -738,7 +786,6 @@ public class WxPayServiceImpl implements WxPayService {
|
|
|
.set(WalletDetail::getAmount, refundAmount)
|
|
|
.set(WalletDetail::getBeforeBalance, account.getBalance() + refundAmount)
|
|
|
.set(WalletDetail::getAfterBalance, account.getBalance())
|
|
|
- .set(WalletDetail::getTransactionTime, successTime)
|
|
|
.eq(WalletDetail::getId, walletDetail.getId()).update();
|
|
|
LOGGER.info("微信退款回调{}:业务处理结束", notifyRes[2]);
|
|
|
|
|
|
@@ -748,7 +795,7 @@ public class WxPayServiceImpl implements WxPayService {
|
|
|
.setFromStationId(stationId)
|
|
|
.setToStationId(stationId)
|
|
|
.setTradeNo(refundNotification.getTransactionId())
|
|
|
- .setAmount(refundNotification.getAmount().getRefund().intValue())
|
|
|
+ .setAmount(refundAmount)
|
|
|
.setType(SplitRecord.TYPE_REFUND);
|
|
|
splitRecordService.save(refundSplit);
|
|
|
|