VirtualPayTimeoutTask.java 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. package com.etotem.cfc.task;
  2. import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  3. import com.etotem.cfc.entity.AssessmentOrder;
  4. import com.etotem.cfc.entity.Family;
  5. import com.etotem.cfc.entity.MemberSubscription;
  6. import com.etotem.cfc.entity.MemberSubscriptionOrder;
  7. import com.etotem.cfc.entity.PaymentOrder;
  8. import com.etotem.cfc.entity.User;
  9. import com.etotem.cfc.entity.VirtualGoodsConfig;
  10. import com.etotem.cfc.mapper.AssessmentOrderMapper;
  11. import com.etotem.cfc.mapper.FamilyMapper;
  12. import com.etotem.cfc.mapper.MemberSubscriptionOrderMapper;
  13. import com.etotem.cfc.mapper.PaymentOrderMapper;
  14. import com.etotem.cfc.mapper.UserMapper;
  15. import com.etotem.cfc.service.AssessmentOrderService;
  16. import com.etotem.cfc.service.MemberSubscriptionService;
  17. import com.etotem.cfc.service.MembershipService;
  18. import com.etotem.cfc.service.SysConfigService;
  19. import com.etotem.cfc.service.VirtualGoodsConfigService;
  20. import com.etotem.cfc.service.VirtualPayService;
  21. import org.slf4j.Logger;
  22. import org.slf4j.LoggerFactory;
  23. import org.springframework.beans.factory.annotation.Value;
  24. import org.springframework.scheduling.annotation.Scheduled;
  25. import org.springframework.stereotype.Component;
  26. import javax.annotation.Resource;
  27. import java.util.Calendar;
  28. import java.util.Date;
  29. import java.util.List;
  30. import com.etotem.cfc.util.SortUtil;
  31. import com.etotem.cfc.config.DatabaseInitializer;
  32. /**
  33. * 虚拟支付超时兜底任务(spec §7:success 回调丢失防丢单)。
  34. * 每分钟扫描三张订单表(测评/会员/订阅)status=pending 且创建超过 N 分钟的订单:
  35. * 1. 调 VirtualPayService.queryOrder 向微信确认实际支付状态(兜底发货推送丢失)
  36. * 2. 已支付 → 触发对应解锁逻辑(幂等,与发货推送同路径)
  37. * 3. 明确未支付/已关闭 → 置 cancelled
  38. * 4. 查询失败/无法解析 openid → 跳过本次(防误杀已支付订单),下轮再查
  39. */
  40. @Component
  41. public class VirtualPayTimeoutTask {
  42. private static final Logger log = LoggerFactory.getLogger(VirtualPayTimeoutTask.class);
  43. @Resource
  44. private VirtualPayService virtualPayService;
  45. @Resource
  46. private AssessmentOrderService assessmentOrderService;
  47. @Resource
  48. private MembershipService membershipService;
  49. @Resource
  50. private MemberSubscriptionService memberSubscriptionService;
  51. @Resource
  52. private AssessmentOrderMapper assessmentOrderMapper;
  53. @Resource
  54. private PaymentOrderMapper paymentOrderMapper;
  55. @Resource
  56. private MemberSubscriptionOrderMapper memberSubscriptionOrderMapper;
  57. @Resource
  58. private UserMapper userMapper;
  59. @Resource
  60. private FamilyMapper familyMapper;
  61. @Resource
  62. private SysConfigService sysConfigService;
  63. @Resource
  64. private VirtualGoodsConfigService virtualGoodsConfigService;
  65. @Value("${virtual-pay.payment-timeout-minutes:30}")
  66. private int paymentTimeoutMinutes;
  67. @Scheduled(fixedRate = 60000) // 每分钟执行一次
  68. public void handleTimeoutOrders() {
  69. if (!DatabaseInitializer.isMigrationsComplete()) {
  70. log.info("数据库迁移尚未完成,跳过本轮虚拟支付超时检查");
  71. return;
  72. }
  73. Calendar cal = Calendar.getInstance();
  74. cal.add(Calendar.MINUTE, -paymentTimeoutMinutes);
  75. Date timeoutTime = cal.getTime();
  76. // 1. 扫描三张表(status=pending 且创建超过 N 分钟)
  77. LambdaQueryWrapper<AssessmentOrder> assessmentWrapper = new LambdaQueryWrapper<AssessmentOrder>()
  78. .eq(AssessmentOrder::getStatus, "pending")
  79. .lt(AssessmentOrder::getCreatedAt, timeoutTime);
  80. SortUtil.applySort(assessmentWrapper);
  81. List<AssessmentOrder> assessmentOrders = assessmentOrderMapper.selectList(assessmentWrapper);
  82. LambdaQueryWrapper<PaymentOrder> paymentWrapper = new LambdaQueryWrapper<PaymentOrder>()
  83. .eq(PaymentOrder::getStatus, "pending")
  84. .lt(PaymentOrder::getCreatedAt, timeoutTime);
  85. SortUtil.applySort(paymentWrapper);
  86. List<PaymentOrder> paymentOrders = paymentOrderMapper.selectList(paymentWrapper);
  87. LambdaQueryWrapper<MemberSubscriptionOrder> subWrapper = new LambdaQueryWrapper<MemberSubscriptionOrder>()
  88. .eq(MemberSubscriptionOrder::getStatus, "pending")
  89. .lt(MemberSubscriptionOrder::getCreatedAt, timeoutTime);
  90. SortUtil.applySort(subWrapper);
  91. List<MemberSubscriptionOrder> subOrders = memberSubscriptionOrderMapper.selectList(subWrapper);
  92. // 2. 每单处理:解析 openid → queryOrder → 已支付解锁 / 未支付关单 / 失败跳过
  93. int assessmentHandled = 0;
  94. int membershipHandled = 0;
  95. int subscriptionHandled = 0;
  96. for (AssessmentOrder order : assessmentOrders) {
  97. if (handleAssessmentOrder(order)) {
  98. assessmentHandled++;
  99. }
  100. }
  101. for (PaymentOrder order : paymentOrders) {
  102. if (handlePaymentOrder(order)) {
  103. membershipHandled++;
  104. }
  105. }
  106. for (MemberSubscriptionOrder order : subOrders) {
  107. if (handleSubscriptionOrder(order)) {
  108. subscriptionHandled++;
  109. }
  110. }
  111. // 3. 汇总日志
  112. log.info("虚拟支付超时兜底扫描完成: 测评={}/{}, 会员={}/{}, 订阅={}/{}",
  113. assessmentHandled, assessmentOrders.size(),
  114. membershipHandled, paymentOrders.size(),
  115. subscriptionHandled, subOrders.size());
  116. }
  117. /** A 前缀(测评订单)单笔处理,返回是否处理(解锁/关单) */
  118. private boolean handleAssessmentOrder(AssessmentOrder order) {
  119. // openid = order.getUserId() → User.getOpenid()(A 单买家即下单用户)
  120. User user = userMapper.selectById(order.getUserId());
  121. if (user == null || user.getOpenid() == null || user.getOpenid().isEmpty()) {
  122. log.warn("虚拟支付超时兜底: 测评订单openid无法解析, 跳过不关单: orderNo={}", order.getOrderNo());
  123. return false;
  124. }
  125. VirtualPayService.QueryOrderResult result = virtualPayService.queryOrder(order.getOrderNo(), user.getOpenid());
  126. if (result == null) {
  127. log.warn("虚拟支付超时兜底: 测评订单queryOrder失败, 跳过不关单: orderNo={}", order.getOrderNo());
  128. return false;
  129. }
  130. if (result.paid) {
  131. // 幂等:已 paid/refunded 返回 true,防重复佣金
  132. boolean ok = assessmentOrderService.handleVirtualPaySuccess(order.getOrderNo(), result.wxpayOrderId);
  133. log.info("虚拟支付超时兜底: 测评订单已支付, 触发解锁: orderNo={}, ok={}", order.getOrderNo(), ok);
  134. return true;
  135. }
  136. if (result.status == VirtualPayService.STATUS_ORDER_NOT_FOUND
  137. || result.status == 0 || result.status == 1 || result.status == 6) {
  138. // 明确未支付/已关闭 → 置 cancelled
  139. order.setStatus("cancelled");
  140. order.setUpdatedAt(new Date());
  141. assessmentOrderMapper.updateById(order);
  142. log.info("虚拟支付超时兜底: 测评订单未支付, 置cancelled: orderNo={}, status={}",
  143. order.getOrderNo(), result.status);
  144. return true;
  145. }
  146. // status∈{5,7,8,9,10} 退款相关 → 跳过,不关单
  147. log.info("虚拟支付超时兜底: 测评订单退款相关状态, 跳过: orderNo={}, status={}", order.getOrderNo(), result.status);
  148. return false;
  149. }
  150. /**
  151. * 判定订单是否属于"虚拟支付通道"(即下单时走 xpay,后续需要 xpay query_order 兜底)。
  152. * 判据与 MembershipService.createOrder 降级逻辑同源:开关开启 且 该 levelCode
  153. * 在 virtual_goods_config 中有启用的 MEMBERSHIP 道具。不满足则必然走了普通支付
  154. * (v3-notify 通道),不应被 xpay 查单任务处理。
  155. */
  156. boolean isVirtualPaymentOrder(PaymentOrder order) {
  157. if (order == null || order.getLevelCode() == null) {
  158. return false;
  159. }
  160. String switchVal = sysConfigService.getValue("member_pay_virtual_enabled");
  161. boolean virtualEnabled = !"0".equals(switchVal);
  162. if (!virtualEnabled) {
  163. return false;
  164. }
  165. VirtualGoodsConfig cfg = virtualGoodsConfigService.getEnabled("MEMBERSHIP", order.getLevelCode());
  166. return cfg != null;
  167. }
  168. /** ORD 前缀(会员订单)单笔处理,返回是否处理(解锁/关单) */
  169. private boolean handlePaymentOrder(PaymentOrder order) {
  170. // 误杀防护(2026-10-22 订单48 事件):普通支付降级订单在 xpay 侧不存在,
  171. // queryOrder 必然返回 STATUS_ORDER_NOT_FOUND 并会被误置 cancelled。
  172. // 仅虚拟支付通道订单(开关开启 + 有道具配置)才允许 xpay 查单,与普通支付通道
  173. // 订单(走 v3-notify)的查询/关单路径解耦。
  174. if (!isVirtualPaymentOrder(order)) {
  175. log.info("虚拟支付超时兜底: 非虚拟支付渠道会员订单, 跳过不查单不关单: orderNo={}, levelCode={}",
  176. order.getOrderNo(), order.getLevelCode());
  177. return false;
  178. }
  179. // family_id=NULL 的订单(下单接口未校验历史脏数据):无法向微信查单,直接置 cancelled
  180. if (order.getFamilyId() == null) {
  181. order.setStatus("cancelled");
  182. order.setUpdatedAt(new Date());
  183. paymentOrderMapper.updateById(order);
  184. log.warn("虚拟支付超时兜底: 会员订单无family_id, 置cancelled: orderNo={}", order.getOrderNo());
  185. return true;
  186. }
  187. String openid = resolveFamilyCreatorOpenid(order.getFamilyId());
  188. if (openid == null) {
  189. log.warn("虚拟支付超时兜底: 会员订单openid无法解析, 跳过不关单: orderNo={}", order.getOrderNo());
  190. return false;
  191. }
  192. VirtualPayService.QueryOrderResult result = virtualPayService.queryOrder(order.getOrderNo(), openid);
  193. if (result == null) {
  194. log.warn("虚拟支付超时兜底: 会员订单queryOrder失败, 跳过不关单: orderNo={}", order.getOrderNo());
  195. return false;
  196. }
  197. if (result.paid) {
  198. // 幂等:paid 返回 true;trial 直接 true
  199. boolean ok = membershipService.processPaymentCallback(order.getOrderNo(), result.wxpayOrderId, "virtual");
  200. log.info("虚拟支付超时兜底: 会员订单已支付, 触发开通: orderNo={}, ok={}", order.getOrderNo(), ok);
  201. return true;
  202. }
  203. if (result.status == VirtualPayService.STATUS_ORDER_NOT_FOUND
  204. || result.status == 0 || result.status == 1 || result.status == 6) {
  205. order.setStatus("cancelled");
  206. order.setUpdatedAt(new Date());
  207. paymentOrderMapper.updateById(order);
  208. log.info("虚拟支付超时兜底: 会员订单未支付, 置cancelled: orderNo={}, status={}",
  209. order.getOrderNo(), result.status);
  210. return true;
  211. }
  212. // status∈{5,7,8,9,10} 退款相关 → 跳过,不关单
  213. log.info("虚拟支付超时兜底: 会员订单退款相关状态, 跳过: orderNo={}, status={}", order.getOrderNo(), result.status);
  214. return false;
  215. }
  216. /** SUB 前缀(订阅订单)单笔处理,返回是否处理(解锁/关单) */
  217. private boolean handleSubscriptionOrder(MemberSubscriptionOrder order) {
  218. String openid = resolveFamilyCreatorOpenid(order.getFamilyId());
  219. if (openid == null) {
  220. log.warn("虚拟支付超时兜底: 订阅订单openid无法解析, 跳过不关单: orderNo={}", order.getOrderNo());
  221. return false;
  222. }
  223. VirtualPayService.QueryOrderResult result = virtualPayService.queryOrder(order.getOrderNo(), openid);
  224. if (result == null) {
  225. log.warn("虚拟支付超时兜底: 订阅订单queryOrder失败, 跳过不关单: orderNo={}", order.getOrderNo());
  226. return false;
  227. }
  228. if (result.paid) {
  229. // 幂等:已 paid/refunded return null
  230. MemberSubscription activated = memberSubscriptionService.activateSubscription(order.getFamilyId(),
  231. order.getLevel(), order.getAmount(), "virtual", result.wxpayOrderId, order.getOrderNo());
  232. log.info("虚拟支付超时兜底: 订阅订单已支付, 触发激活: orderNo={}, activated={}",
  233. order.getOrderNo(), activated != null);
  234. return true;
  235. }
  236. if (result.status == VirtualPayService.STATUS_ORDER_NOT_FOUND
  237. || result.status == 0 || result.status == 1 || result.status == 6) {
  238. order.setStatus("cancelled");
  239. memberSubscriptionOrderMapper.updateById(order);
  240. log.info("虚拟支付超时兜底: 订阅订单未支付, 置cancelled: orderNo={}, status={}",
  241. order.getOrderNo(), result.status);
  242. return true;
  243. }
  244. // status∈{5,7,8,9,10} 退款相关 → 跳过,不关单
  245. log.info("虚拟支付超时兜底: 订阅订单退款相关状态, 跳过: orderNo={}, status={}", order.getOrderNo(), result.status);
  246. return false;
  247. }
  248. /**
  249. * 解析家庭创建者(管理员)的 openid(ORD/SUB 单范式,同 MembershipService.processPaymentCallback)。
  250. * 家庭或创建者缺失 → 返回 null(调用方不得关单)。
  251. */
  252. private String resolveFamilyCreatorOpenid(Long familyId) {
  253. Long adminUserId = membershipService.resolveFamilyAdminUserId(familyId);
  254. if (adminUserId == null) {
  255. return null;
  256. }
  257. User buyer = userMapper.selectById(adminUserId);
  258. if (buyer == null || buyer.getOpenid() == null || buyer.getOpenid().isEmpty()) {
  259. return null;
  260. }
  261. return buyer.getOpenid();
  262. }
  263. }