Spring Boot项目实现订单超时未支付自动取消策略

需求说明

在涉及到支付相关的应用中,通常需要实现个功能,用户在生成订单一段时间未完成支付,系统将会自动取消这个订单。本文将基于SpringBoot项目实现订单超时未支付的几种方案策略。

方案1:定时任务

  • 利用SpringBoot中的 @Scheduled 注解,实现定时任务。周期性的检查数据库中是否存在超时未支付的订单,如果存在则取消。代码如下:(cron在线生成表达式参考
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @Scheduled(cron = "0 0/1 * * * ?  ")
    public void cancelUnpaidOrders() {
    log.info("每分钟扫描超过30分钟未支付的订单");
    List<FuOrder> list = orderService.list();
    for (FuOrder fuOrder : list) {
    if (fuOrder.getCreateTime().plusMinutes(30).isBefore(LocalDateTime.now())
    && fuOrder.getStatus() == 1) {
    FuOrder newInfo = new FuOrder();
    BeanUtils.copyProperties(fuOrder, newInfo);
    newInfo.setStatus(0);
    newInfo.setClosetTime(LocalDateTime.now());
    newInfo.setUpdateTime(LocalDateTime.now());
    orderService.updateById(newInfo);
    }
    }
    log.info("定时处理订单表所有未支付订单结束");
    }
  • PS:注意不要忘记在启动类中添加开启定时任务 @EnableScheduling 注解。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    @SpringBootApplication
    @Slf4j
    @EnableTransactionManagement
    @EnableScheduling //开启定时任务
    @MapperScan("com.fu99999.*.**.mapper")
    public class NoteApplication {
    public static void main(String[] args) throws UnknownHostException {
    ConfigurableApplicationContext application = SpringApplication.run(NoteApplication.class, args);
    TomcatServletWebServerFactory tomcatServlet = application.getBean(TomcatServletWebServerFactory.class);
    String ip = InetAddress.getLocalHost().getHostAddress();
    int port = tomcatServlet.getPort();
    String path = tomcatServlet.getContextPath();
    log.info("\n----------------------------------------------------------\n\t" +
    "External: \thttp://" + ip + ":" + port + path + "/\n\t" +
    "swagger-ui: \thttp://" + ip + ":" + port + path + "/swagger-ui/index.html\n\t" +
    "Doc: \t\thttp://" + ip + ":" + port + path + "/doc.html\n" +
    "----------------------------------------------------------");
    }
    }

方案2:Redis过期事件

  • 利用redis的键过期事件功能,在用户下单时,生成一个令牌(有效期)30分钟,存放到redis。通过redis的过期事件通知功能触发订单取消操作。
  1. 首先写个创建订单并存入redis的接口(这里只演示Impl的代码),在写一个过期时,处理订单的方法用于redis自动调用。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    @PostMapping("createOrderByRedisKey")
    public R createOrderByRedisKey(@RequestBody FuOrder fuOrder, HttpServletRequest request) {
    String token = request.getHeader("token");
    Random random = new Random();
    String orderNo = String.valueOf(random.nextLong()).replace("-", "");
    fuOrder.setOrderNo(Long.valueOf(orderNo));
    fuOrder.setCreateTime(LocalDateTime.now());
    fuOrder.setUserId(Math.toIntExact(JwtUtils.getUserId(token)));
    boolean order = orderService.save(fuOrder);
    if (order) {
    redisTemplate.opsForValue().set("fu99999:order:" + fuOrder.getId(), fuOrder,30 , TimeUnit.MINUTES);
    return R.ok().message("创建订单成功");
    } else {
    return R.error().message("创建订单失败");
    }
    }
  2. 然后要确保redis的配置(Windows系统通常是叫redis.windows.conf)中开启了键空间通知功能。通过在配置文件中添加或修改如下配置实现。
    1
    notify-keyspace-events "Ex"
  3. 在项目中新建Listener 并继承自 KeyExpirationEventMessageListener
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    @Component
    @Slf4j
    public class RedisKeyExpirationListener extends KeyExpirationEventMessageListener {

    @Resource
    private FuOrderService orderService;

    public RedisKeyExpirationListener(RedisMessageListenerContainer listenerContainer) {
    super(listenerContainer);
    }

    /**
    * 重写onMessage()方法处理过期Key
    * 用于对订单超时的的逻辑处理
    */
    @Override
    public void onMessage(Message message, byte[] pattern) {
    String expiredKey = message.toString();
    log.info("------------------redis key 失效; key = " + expiredKey);
    if (expiredKey.startsWith("fu99999:order:")) {
    // 处理订单超时逻辑
    String orderId = expiredKey.split(":")[2];
    orderService.cancelOrder(Long.valueOf(orderId));
    }
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @Override
    public void cancelOrder(Long orderId) {
    FuOrder fuOrder = baseMapper.selectById(orderId);
    FuOrder overdueOrder = new FuOrder();
    BeanUtils.copyProperties(fuOrder,overdueOrder);
    overdueOrder.setStatus(0);
    overdueOrder.setClosetTime(LocalDateTime.now());
    overdueOrder.setUpdateTime(LocalDateTime.now());
    baseMapper.updateById(overdueOrder);
    log.info("redis过期未支付订单处理结束");
    }
  4. 新建配置类 RedisListenerConfig
    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Configuration
    public class RedisListenerConfig {
    @Bean
    RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory) {
    RedisMessageListenerContainer container = new RedisMessageListenerContainer();
    container.setConnectionFactory(connectionFactory);
    return container;
    }
    }

方案3:延迟队列(死信队列)

  • 使用消息队列(如RabbitMQ)的延迟队列功能,当订单生成时将订单ID推送到延迟队列,设置30分钟后过期,过期后消费该消息,取消订单。
    具体实现代码详见 RabbitMQ实现订单30分钟超时自动关闭 一文。