|
@@ -47,12 +47,15 @@ import lombok.SneakyThrows;
|
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.Logger;
|
|
|
import org.slf4j.LoggerFactory;
|
|
import org.slf4j.LoggerFactory;
|
|
|
import org.springframework.core.io.ClassPathResource;
|
|
import org.springframework.core.io.ClassPathResource;
|
|
|
|
|
+import org.springframework.dao.DuplicateKeyException;
|
|
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
|
|
import org.springframework.http.HttpStatus;
|
|
import org.springframework.http.HttpStatus;
|
|
|
import org.springframework.http.ResponseEntity;
|
|
import org.springframework.http.ResponseEntity;
|
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.stereotype.Service;
|
|
|
import org.springframework.transaction.interceptor.TransactionAspectSupport;
|
|
import org.springframework.transaction.interceptor.TransactionAspectSupport;
|
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
|
+import org.springframework.transaction.support.TransactionSynchronization;
|
|
|
|
|
+import org.springframework.transaction.support.TransactionSynchronizationManager;
|
|
|
|
|
|
|
|
import java.io.ByteArrayOutputStream;
|
|
import java.io.ByteArrayOutputStream;
|
|
|
import java.io.File;
|
|
import java.io.File;
|
|
@@ -303,77 +306,93 @@ public class WxPayServiceImpl implements WxPayService {
|
|
|
transaction.getAmount() == null ? null : transaction.getAmount().getTotal(),
|
|
transaction.getAmount() == null ? null : transaction.getAmount().getTotal(),
|
|
|
transaction.getTradeState(), transaction.getSuccessTime());
|
|
transaction.getTradeState(), transaction.getSuccessTime());
|
|
|
// 判断是否已经接收处理过通知(快路径)
|
|
// 判断是否已经接收处理过通知(快路径)
|
|
|
|
|
+ // 注意:只有确认"该笔充值已入账"才允许应答 200。微信只认 200/204 为处理成功,
|
|
|
|
|
+ // 收到成功应答即停止重试(重试阶梯 15s/15s/30s/3m…),因此任何"未入账却回 200"的
|
|
|
|
|
+ // 分支都会造成用户资金永久丢失。
|
|
|
if (payLogService.lambdaQuery().eq(PayLog::getOutTradeNo, transaction.getOutTradeNo()).one() != null) {
|
|
if (payLogService.lambdaQuery().eq(PayLog::getOutTradeNo, transaction.getOutTradeNo()).one() != null) {
|
|
|
return ResponseEntity.status(HttpStatus.OK).build();
|
|
return ResponseEntity.status(HttpStatus.OK).build();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Redis锁防并发重复处理(微信会对未应答的通知重试)
|
|
|
|
|
|
|
+ // Redis锁仅用于削减并发(微信会对未应答的通知重试);
|
|
|
|
|
+ // 幂等的最终保证是 t_pay_log.out_trade_no 唯一索引,锁失效不会导致重复入账
|
|
|
String notifyLockKey = RedisKeys.PAY_NOTIFY_LOCK + transaction.getOutTradeNo();
|
|
String notifyLockKey = RedisKeys.PAY_NOTIFY_LOCK + transaction.getOutTradeNo();
|
|
|
Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(notifyLockKey, "1", 30, TimeUnit.SECONDS);
|
|
Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(notifyLockKey, "1", 30, TimeUnit.SECONDS);
|
|
|
if (Boolean.FALSE.equals(locked)) {
|
|
if (Boolean.FALSE.equals(locked)) {
|
|
|
- LOGGER.info("微信支付回调{}:订单{}正在处理中,忽略本次重复通知", notifyRes[2], transaction.getOutTradeNo());
|
|
|
|
|
- return ResponseEntity.status(HttpStatus.OK).build();
|
|
|
|
|
|
|
+ // 未抢到锁:该订单的另一次投递正在处理,或存在残留锁。
|
|
|
|
|
+ // 此处绝不能回 200:本次没有完成任何入账,回 200 会让微信停止重试,充值将永久丢失。
|
|
|
|
|
+ // 返回 500 让微信按策略重试:重试时要么命中上面的已入账快路径,要么在锁过期后正常入账,
|
|
|
|
|
+ // 最坏只是延迟到账,不会丢单。
|
|
|
|
|
+ LOGGER.warn("微信支付回调{}:订单{}正在处理中,本次未入账,返回失败等待微信重试", notifyRes[2], transaction.getOutTradeNo());
|
|
|
|
|
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
|
|
|
|
+ .body(Map.of("code", HttpStatus.INTERNAL_SERVER_ERROR, "message", "处理中,等待微信重试"));
|
|
|
}
|
|
}
|
|
|
- try {
|
|
|
|
|
- DateTime dt = DateUtil.parse(transaction.getSuccessTime());
|
|
|
|
|
- LocalDateTime successTime = LocalDateTimeUtil.of(dt);
|
|
|
|
|
-
|
|
|
|
|
- // 钱包流水
|
|
|
|
|
- var walletDetail = walletDetailService.getWalletDetailByOrderNo(transaction.getOutTradeNo(), WalletDetail.TYPE_充值);
|
|
|
|
|
- if (walletDetail != null) {
|
|
|
|
|
- walletDetail.setStatus(WalletDetail.STATUS_已确认); //已确认
|
|
|
|
|
- walletDetail.setCurrency(transaction.getAmount().getCurrency());
|
|
|
|
|
- walletDetail.setAmount(transaction.getAmount().getTotal());
|
|
|
|
|
- walletDetail.setTransactionTime(successTime);
|
|
|
|
|
- walletDetailService.updateById(walletDetail);
|
|
|
|
|
-
|
|
|
|
|
- // 更新余额
|
|
|
|
|
- accountService.lambdaUpdate().setSql("balance = (balance+%d)".formatted(transaction.getAmount().getTotal()))
|
|
|
|
|
- .eq(Account::getUserId, walletDetail.getUserId()).update();
|
|
|
|
|
-
|
|
|
|
|
- // 异步处理充值服务费打折权益活动相关逻辑
|
|
|
|
|
- activityService.handleRechargeActivity(walletDetail.getUserId(), transaction.getAmount().getTotal());
|
|
|
|
|
-
|
|
|
|
|
- // 支付记录
|
|
|
|
|
- var payLog = new PayLog();
|
|
|
|
|
- payLog.setUserId(walletDetail.getUserId());
|
|
|
|
|
- payLog.setOpenid(transaction.getPayer().getOpenid());
|
|
|
|
|
- payLog.setBankType(transaction.getBankType());
|
|
|
|
|
- payLog.setMchId(transaction.getMchid());
|
|
|
|
|
- payLog.setOutTradeNo(transaction.getOutTradeNo());
|
|
|
|
|
- payLog.setTransactionId(transaction.getTransactionId());
|
|
|
|
|
- payLog.setSuccessTime(successTime);
|
|
|
|
|
- payLog.setTradeType(transaction.getTradeType().name());
|
|
|
|
|
- payLog.setTradeState(transaction.getTradeState().name());
|
|
|
|
|
- payLog.setAttach(transaction.getAttach());
|
|
|
|
|
- payLog.setTotal(transaction.getAmount().getTotal());
|
|
|
|
|
- payLog.setCurrency(transaction.getAmount().getCurrency());
|
|
|
|
|
- payLog.setPayerTotal(transaction.getAmount().getPayerTotal());
|
|
|
|
|
- payLog.setPayerCurrency(transaction.getAmount().getPayerCurrency());
|
|
|
|
|
|
|
+ // 锁在事务提交成功后才释放;回滚/异常时保留锁由 TTL(30s) 过期,避免并发重复入账
|
|
|
|
|
+ releaseNotifyLockAfterCommit(notifyLockKey);
|
|
|
|
|
+ DateTime dt = DateUtil.parse(transaction.getSuccessTime());
|
|
|
|
|
+ LocalDateTime successTime = LocalDateTimeUtil.of(dt);
|
|
|
|
|
+
|
|
|
|
|
+ // 钱包流水
|
|
|
|
|
+ var walletDetail = walletDetailService.getWalletDetailByOrderNo(transaction.getOutTradeNo(), WalletDetail.TYPE_充值);
|
|
|
|
|
+ if (walletDetail != null) {
|
|
|
|
|
+ walletDetail.setStatus(WalletDetail.STATUS_已确认); //已确认
|
|
|
|
|
+ walletDetail.setCurrency(transaction.getAmount().getCurrency());
|
|
|
|
|
+ walletDetail.setAmount(transaction.getAmount().getTotal());
|
|
|
|
|
+ walletDetail.setTransactionTime(successTime);
|
|
|
|
|
+ walletDetailService.updateById(walletDetail);
|
|
|
|
|
+
|
|
|
|
|
+ // 更新余额
|
|
|
|
|
+ accountService.lambdaUpdate().setSql("balance = (balance+%d)".formatted(transaction.getAmount().getTotal()))
|
|
|
|
|
+ .eq(Account::getUserId, walletDetail.getUserId()).update();
|
|
|
|
|
+
|
|
|
|
|
+ // 异步处理充值服务费打折权益活动相关逻辑
|
|
|
|
|
+ activityService.handleRechargeActivity(walletDetail.getUserId(), transaction.getAmount().getTotal());
|
|
|
|
|
+
|
|
|
|
|
+ // 支付记录
|
|
|
|
|
+ var payLog = new PayLog();
|
|
|
|
|
+ payLog.setUserId(walletDetail.getUserId());
|
|
|
|
|
+ payLog.setOpenid(transaction.getPayer().getOpenid());
|
|
|
|
|
+ payLog.setBankType(transaction.getBankType());
|
|
|
|
|
+ payLog.setMchId(transaction.getMchid());
|
|
|
|
|
+ payLog.setOutTradeNo(transaction.getOutTradeNo());
|
|
|
|
|
+ payLog.setTransactionId(transaction.getTransactionId());
|
|
|
|
|
+ payLog.setSuccessTime(successTime);
|
|
|
|
|
+ payLog.setTradeType(transaction.getTradeType().name());
|
|
|
|
|
+ payLog.setTradeState(transaction.getTradeState().name());
|
|
|
|
|
+ payLog.setAttach(transaction.getAttach());
|
|
|
|
|
+ payLog.setTotal(transaction.getAmount().getTotal());
|
|
|
|
|
+ payLog.setCurrency(transaction.getAmount().getCurrency());
|
|
|
|
|
+ payLog.setPayerTotal(transaction.getAmount().getPayerTotal());
|
|
|
|
|
+ payLog.setPayerCurrency(transaction.getAmount().getPayerCurrency());
|
|
|
|
|
+ // t_pay_log.out_trade_no 唯一索引兜底:并发投递时只有一个事务能落库,
|
|
|
|
|
+ // 落库失败的这次整体回滚(含重复增加的余额),避免重复入账
|
|
|
|
|
+ try {
|
|
|
payLogService.save(payLog);
|
|
payLogService.save(payLog);
|
|
|
- LOGGER.info("微信支付回调{}:业务处理结束", notifyRes[2]);
|
|
|
|
|
-
|
|
|
|
|
- // 如果存在进行中的订单,则向en+更新用户余额(朗新不支持此操作)
|
|
|
|
|
- // 平台同步为附加操作,失败时不能回滚充值事务,仅记录日志待补偿
|
|
|
|
|
- try {
|
|
|
|
|
- var chargingOrder = chargeOrderService.getChargingOrderByUserId(walletDetail.getUserId());
|
|
|
|
|
- if (chargingOrder != null && "EN_PLUS".equals(PlatformCache.INSTANCE.getPlatformNameByConnectorId(chargingOrder.getConnectorId()))) {
|
|
|
|
|
- var account = accountService.getAccountByUserId(walletDetail.getUserId());
|
|
|
|
|
- // TODO: 2023-11-30 快充这里考虑过充的金额要提高
|
|
|
|
|
- var res = platformApiService.updateBalanceByQueryEquipChargeStatus(PlatformCache.INSTANCE.getPlatformNameByConnectorId(chargingOrder.getConnectorId()), chargingOrder.getStartChargeSeq(), account.getBalance() - 50);
|
|
|
|
|
- LOGGER.info("用户:{}充电过程中充值,已更新EN_PLUS充电金额,en+返回数据:{}", account.getUserId(), res);
|
|
|
|
|
- }
|
|
|
|
|
- } catch (Exception e) {
|
|
|
|
|
- LOGGER.error("微信支付回调{}:充值成功,但同步EN_PLUS平台余额失败,用户:{}", notifyRes[2], walletDetail.getUserId(), e);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ } catch (DuplicateKeyException e) {
|
|
|
|
|
+ TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
|
|
|
|
|
+ LOGGER.warn("微信支付回调{}:订单{}已存在支付记录,判定为重复通知,本次回滚不入账",
|
|
|
|
|
+ notifyRes[2], transaction.getOutTradeNo());
|
|
|
return ResponseEntity.status(HttpStatus.OK).build();
|
|
return ResponseEntity.status(HttpStatus.OK).build();
|
|
|
-
|
|
|
|
|
- } else {
|
|
|
|
|
- LOGGER.error("微信支付通知处理异常,资金流水为空,回调信息:{}", transaction);
|
|
|
|
|
- return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("code", HttpStatus.INTERNAL_SERVER_ERROR, "message", "资金流水为空"));
|
|
|
|
|
}
|
|
}
|
|
|
- } finally {
|
|
|
|
|
- stringRedisTemplate.delete(notifyLockKey);
|
|
|
|
|
|
|
+ LOGGER.info("微信支付回调{}:业务处理结束", notifyRes[2]);
|
|
|
|
|
+
|
|
|
|
|
+ // 如果存在进行中的订单,则向en+更新用户余额(朗新不支持此操作)
|
|
|
|
|
+ // 平台同步为附加操作,失败时不能回滚充值事务,仅记录日志待补偿
|
|
|
|
|
+ try {
|
|
|
|
|
+ var chargingOrder = chargeOrderService.getChargingOrderByUserId(walletDetail.getUserId());
|
|
|
|
|
+ if (chargingOrder != null && "EN_PLUS".equals(PlatformCache.INSTANCE.getPlatformNameByConnectorId(chargingOrder.getConnectorId()))) {
|
|
|
|
|
+ var account = accountService.getAccountByUserId(walletDetail.getUserId());
|
|
|
|
|
+ // TODO: 2023-11-30 快充这里考虑过充的金额要提高
|
|
|
|
|
+ var res = platformApiService.updateBalanceByQueryEquipChargeStatus(PlatformCache.INSTANCE.getPlatformNameByConnectorId(chargingOrder.getConnectorId()), chargingOrder.getStartChargeSeq(), account.getBalance() - 50);
|
|
|
|
|
+ LOGGER.info("用户:{}充电过程中充值,已更新EN_PLUS充电金额,en+返回数据:{}", account.getUserId(), res);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ LOGGER.error("微信支付回调{}:充值成功,但同步EN_PLUS平台余额失败,用户:{}", notifyRes[2], walletDetail.getUserId(), e);
|
|
|
|
|
+ }
|
|
|
|
|
+ return ResponseEntity.status(HttpStatus.OK).build();
|
|
|
|
|
+
|
|
|
|
|
+ } else {
|
|
|
|
|
+ LOGGER.error("微信支付通知处理异常,资金流水为空,回调信息:{}", transaction);
|
|
|
|
|
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("code", HttpStatus.INTERNAL_SERVER_ERROR, "message", "资金流水为空"));
|
|
|
}
|
|
}
|
|
|
} catch (Exception e) {
|
|
} catch (Exception e) {
|
|
|
// 标记事务回滚,防止异常时部分提交
|
|
// 标记事务回滚,防止异常时部分提交
|
|
@@ -393,6 +412,32 @@ public class WxPayServiceImpl implements WxPayService {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 支付回调 Redis 锁:仅在事务成功提交后释放。
|
|
|
|
|
+ * <p>
|
|
|
|
|
+ * 原实现把删除放在 finally 中,而 finally 先于 {@link Transactional} 的提交执行,
|
|
|
|
|
+ * 锁并没有覆盖"入账 + 提交"这段临界区:临界区内到达的重复通知能穿过尚未提交的
|
|
|
|
|
+ * payLog 快路径再次入账(重复加钱)。改为提交后释放后,锁真正覆盖了整个临界区。
|
|
|
|
|
+ * <p>
|
|
|
|
|
+ * 回滚/异常时故意不删除,让锁随 TTL(30s) 自然过期:最坏结果是微信重试被推迟,
|
|
|
|
|
+ * 而不会出现重复入账或丢单。
|
|
|
|
|
+ */
|
|
|
|
|
+ private void releaseNotifyLockAfterCommit(String notifyLockKey) {
|
|
|
|
|
+ if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
|
|
|
|
+ // 理论上不会走到这里(wxNotify 带 @Transactional),兜底为立即释放
|
|
|
|
|
+ stringRedisTemplate.delete(notifyLockKey);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public void afterCompletion(int status) {
|
|
|
|
|
+ if (status == TransactionSynchronization.STATUS_COMMITTED) {
|
|
|
|
|
+ stringRedisTemplate.delete(notifyLockKey);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 申请退款
|
|
* 申请退款
|
|
|
*/
|
|
*/
|