(补充)Java项目实战笔记--基于SpringBoot3.0开发仿12306高并发售票系统--(二)项目实现-第六篇-(高级篇)实现高性能高并发

六、使用MQ对请求做异步削峰处理,解决吞吐量问题

在这里插入图片描述

1.购票时序图演进

原先:

在这里插入图片描述

改进后:

MQ对比springboot异步的好处:不怕中间异常导致消息丢失

在这里插入图片描述

2.RocketMQ初体验

1.下载

下载 | RocketMQ (apache.org)

在这里插入图片描述

修改配置,减小最大分配内存,防止启动失败

**注意:**windows命令行环境中,前面加 rem 表示注释

【这一步我们跳过,不然后面反而报错了】

在这里插入图片描述

2.启动NameServer和Broker

快速开始 | RocketMQ (apache.org)

Windows部署RocketMQ(超详细)-CSDN博客

  • 配置环境变量

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

3.测试消息收发

这里课程演示的是MacOS系统的,官方演示的又是Linux系统的,所以就先跳过,后面直接集成到springcloud来测试

3.RocketMQ控制台的使用

控台可以使用docker去拉镜像来启动,也可以下载maven项目源码,打包启动,这里我们采取第二种方式

在这里插入图片描述

  • 启动

    直接IDEA打开,记得切换jdk1.8,然后修改VM参数,修改端口,启动

在这里插入图片描述

这里我设置端口是18082,启动成功后访问http://localhost:18082/

在这里插入图片描述

4.使用RocketMQ将购票流程一分为二

下单购票接口,只处理验证码、令牌锁、保存订单初始化信息,车次锁和真正选座购票逻辑放在消息消费者里执行

课程里的rocketMQTemplate.convertAndSend方法发送的是 普通消息,会被消费者并发处理,所以如果不把分布式锁放在消费者部分,可能导致超卖,所以将车次锁放在了后半部分

在 Apache RocketMQ 中,RocketMQTemplate 是 RocketMQ 提供的消息发送工具类,convertAndSend 方法用于将消息对象转换为消息并发送到指定的 RocketMQ 主题。默认情况下,convertAndSend 方法发送的是普通消息(normal message),而不是顺序消息(sequential message)。

Kafka/RocketMQ 多线程消费时如何保证消费顺序? - 知乎 (zhihu.com)

【也可以使用rocketmq的顺序消息,rocketmq会为每个消息队列建一个对象锁,这样只要线程池中有该消息队列在处理,则需等待处理完才能进行下一次消费,保证在当前 Consumer 内,同一队列的消息进行串行消费】

  • 添加依赖

    父pom

    <dependency>
        <groupId>org.apache.rocketmq</groupId>
        <artifactId>rocketmq-spring-boot-starter</artifactId>
        <version>2.2.3</version>
    </dependency>
    

    business/pom.xml

    <!--RocketMQ-->
    <dependency>
        <groupId>org.apache.rocketmq</groupId>
        <artifactId>rocketmq-spring-boot-starter</artifactId>
    </dependency>
    
  • business/src/main/resources/application.properties

    # rocketmq
    rocketmq.name-server=http://localhost:9876
    rocketmq.producer.group=default
    
  • business/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

    org.apache.rocketmq.spring.autoconfigure.RocketMQAutoConfiguration
    

    解决springboot3集成rocketmq,无法注入rocketMQTemplate的问题

  • com.neilxu.train.business.enums.RocketMQTopicEnum

    package com.neilxu.train.business.enums;
    
    public enum RocketMQTopicEnum {
    
        CONFIRM_ORDER("CONFIRM_ORDER", "确认订单排队");
    
        private String code;
    
        private String desc;
    
        RocketMQTopicEnum(String code, String desc) {
            this.code = code;
            this.desc = desc;
        }
    
        @Override
        public String toString() {
            return "RocketMQTopicEnum{" +
                    "code='" + code + '\'' +
                    ", desc='" + desc + '\'' +
                    "} " + super.toString();
        }
    
        public String getCode() {
            return code;
        }
    
        public void setCode(String code) {
            this.code = code;
        }
    
        public void setDesc(String desc) {
            this.desc = desc;
        }
    
        public String getDesc() {
            return desc;
        }
    }
    
  • com.neilxu.train.business.service.BeforeConfirmOrderService

    生产者

    package com.neilxu.train.business.service;
    
    import cn.hutool.core.date.DateTime;
    import com.alibaba.csp.sentinel.annotation.SentinelResource;
    import com.alibaba.csp.sentinel.slots.block.BlockException;
    import com.alibaba.fastjson.JSON;
    import com.neilxu.train.business.domain.ConfirmOrder;
    import com.neilxu.train.business.enums.ConfirmOrderStatusEnum;
    import com.neilxu.train.business.enums.RocketMQTopicEnum;
    import com.neilxu.train.business.mapper.ConfirmOrderMapper;
    import com.neilxu.train.business.req.ConfirmOrderDoReq;
    import com.neilxu.train.business.req.ConfirmOrderTicketReq;
    import com.neilxu.train.common.context.LoginMemberContext;
    import com.neilxu.train.common.exception.BusinessException;
    import com.neilxu.train.common.exception.BusinessExceptionEnum;
    import com.neilxu.train.common.util.SnowUtil;
    import jakarta.annotation.Resource;
    import org.apache.rocketmq.spring.core.RocketMQTemplate;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Service;
    
    import java.util.Date;
    import java.util.List;
    
    @Service
    public class BeforeConfirmOrderService {
    
        private static final Logger LOG = LoggerFactory.getLogger(BeforeConfirmOrderService.class);
    
        @Resource
        private ConfirmOrderMapper confirmOrderMapper;
    
        @Autowired
        private SkTokenService skTokenService;
    
        @Resource
        public RocketMQTemplate rocketMQTemplate;
    
        @SentinelResource(value = "beforeDoConfirm", blockHandler = "beforeDoConfirmBlock")
        public void beforeDoConfirm(ConfirmOrderDoReq req) {
            req.setMemberId(LoginMemberContext.getId());
            // 校验令牌余量
            boolean validSkToken = skTokenService.validSkToken(req.getDate(), req.getTrainCode(), LoginMemberContext.getId());
            if (validSkToken) {
                LOG.info("令牌校验通过");
            } else {
                LOG.info("令牌校验不通过");
                throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_SK_TOKEN_FAIL);
            }
    
            Date date = req.getDate();
            String trainCode = req.getTrainCode();
            String start = req.getStart();
            String end = req.getEnd();
            List<ConfirmOrderTicketReq> tickets = req.getTickets();
    
            // 保存确认订单表,状态初始
            DateTime now = DateTime.now();
            ConfirmOrder confirmOrder = new ConfirmOrder();
            confirmOrder.setId(SnowUtil.getSnowflakeNextId());
            confirmOrder.setCreateTime(now);
            confirmOrder.setUpdateTime(now);
            confirmOrder.setMemberId(req.getMemberId());
            confirmOrder.setDate(date);
            confirmOrder.setTrainCode(trainCode);
            confirmOrder.setStart(start);
            confirmOrder.setEnd(end);
            confirmOrder.setDailyTrainTicketId(req.getDailyTrainTicketId());
            confirmOrder.setStatus(ConfirmOrderStatusEnum.INIT.getCode());
            confirmOrder.setTickets(JSON.toJSONString(tickets));
            confirmOrderMapper.insert(confirmOrder);
    
            // 发送MQ排队购票
            String reqJson = JSON.toJSONString(req);
            LOG.info("排队购票,发送mq开始,消息:{}", reqJson);
            rocketMQTemplate.convertAndSend(RocketMQTopicEnum.CONFIRM_ORDER.getCode(), reqJson);
            LOG.info("排队购票,发送mq结束");
    
        }
    
        /**
         * 降级方法,需包含限流方法的所有参数和BlockException参数
         * @param req
         * @param e
         */
        public void beforeDoConfirmBlock(ConfirmOrderDoReq req, BlockException e) {
            LOG.info("购票请求被限流:{}", req);
            throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_FLOW_EXCEPTION);
        }
    }
    
  • com.neilxu.train.business.mq.ConfirmOrderConsumer

    消费者

    package com.neilxu.train.business.mq;
    
    import com.alibaba.fastjson.JSON;
    import com.neilxu.train.business.req.ConfirmOrderDoReq;
    import com.neilxu.train.business.service.ConfirmOrderService;
    import jakarta.annotation.Resource;
    import org.apache.rocketmq.common.message.MessageExt;
    import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
    import org.apache.rocketmq.spring.core.RocketMQListener;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.stereotype.Service;
    
    @Service
    @RocketMQMessageListener(consumerGroup = "default", topic = "CONFIRM_ORDER")
    public class ConfirmOrderConsumer implements RocketMQListener<MessageExt> {
    
        private static final Logger LOG = LoggerFactory.getLogger(ConfirmOrderConsumer.class);
    
        @Resource
        private ConfirmOrderService confirmOrderService;
    
        @Override
        public void onMessage(MessageExt messageExt) {
            byte[] body = messageExt.getBody();
            LOG.info("ROCKETMQ收到消息:{}", new String(body));
            ConfirmOrderDoReq req = JSON.parseObject(new String(body), ConfirmOrderDoReq.class);
            confirmOrderService.doConfirm(req);
        }
    }
    
  • com.neilxu.train.business.service.AfterConfirmOrderService

    将选座购票逻辑放到消费者异步消费,mq消费者消费这个步骤是不会经过拦截器的,所以不能从线程本地变量中获取memberId了(上面的BeforeConfirmOrderService.java里的req.setMemberId(LoginMemberContext.getId());也是同理)

    //            memberTicketReq.setMemberId(LoginMemberContext.getId());
                memberTicketReq.setMemberId(confirmOrder.getMemberId());
    
  • com.neilxu.train.business.service.ConfirmOrderService

    package com.neilxu.train.business.service;
    
    import cn.hutool.core.bean.BeanUtil;
    import cn.hutool.core.collection.CollUtil;
    import cn.hutool.core.date.DateTime;
    import cn.hutool.core.date.DateUtil;
    import cn.hutool.core.util.EnumUtil;
    import cn.hutool.core.util.NumberUtil;
    import cn.hutool.core.util.ObjectUtil;
    import cn.hutool.core.util.StrUtil;
    import com.alibaba.csp.sentinel.annotation.SentinelResource;
    import com.alibaba.csp.sentinel.slots.block.BlockException;
    import com.github.pagehelper.PageHelper;
    import com.github.pagehelper.PageInfo;
    import com.neilxu.train.business.domain.*;
    import com.neilxu.train.business.enums.ConfirmOrderStatusEnum;
    import com.neilxu.train.business.enums.RedisKeyPreEnum;
    import com.neilxu.train.business.enums.SeatColEnum;
    import com.neilxu.train.business.enums.SeatTypeEnum;
    import com.neilxu.train.business.mapper.ConfirmOrderMapper;
    import com.neilxu.train.business.req.ConfirmOrderDoReq;
    import com.neilxu.train.business.req.ConfirmOrderQueryReq;
    import com.neilxu.train.business.req.ConfirmOrderTicketReq;
    import com.neilxu.train.business.resp.ConfirmOrderQueryResp;
    import com.neilxu.train.common.exception.BusinessException;
    import com.neilxu.train.common.exception.BusinessExceptionEnum;
    import com.neilxu.train.common.resp.PageResp;
    import com.neilxu.train.common.util.SnowUtil;
    import jakarta.annotation.Resource;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.stereotype.Service;
    
    import java.util.ArrayList;
    import java.util.Date;
    import java.util.List;
    import java.util.concurrent.TimeUnit;
    
    @Service
    public class ConfirmOrderService {
    
        private static final Logger LOG = LoggerFactory.getLogger(ConfirmOrderService.class);
    
        @Resource
        private ConfirmOrderMapper confirmOrderMapper;
    
        @Resource
        private DailyTrainTicketService dailyTrainTicketService;
    
        @Resource
        private DailyTrainCarriageService dailyTrainCarriageService;
    
        @Resource
        private DailyTrainSeatService dailyTrainSeatService;
    
        @Resource
        private AfterConfirmOrderService afterConfirmOrderService;
    
        @Autowired
        private StringRedisTemplate redisTemplate;
    
        @Autowired
        private SkTokenService skTokenService;
    
        // @Autowired
        // private RedissonClient redissonClient;
    
        public void save(ConfirmOrderDoReq req) {
            DateTime now = DateTime.now();
            ConfirmOrder confirmOrder = BeanUtil.copyProperties(req, ConfirmOrder.class);
            if (ObjectUtil.isNull(confirmOrder.getId())) {
                confirmOrder.setId(SnowUtil.getSnowflakeNextId());
                confirmOrder.setCreateTime(now);
                confirmOrder.setUpdateTime(now);
                confirmOrderMapper.insert(confirmOrder);
            } else {
                confirmOrder.setUpdateTime(now);
                confirmOrderMapper.updateByPrimaryKey(confirmOrder);
            }
        }
    
        public PageResp<ConfirmOrderQueryResp> queryList(ConfirmOrderQueryReq req) {
            ConfirmOrderExample confirmOrderExample = new ConfirmOrderExample();
            confirmOrderExample.setOrderByClause("id desc");
            ConfirmOrderExample.Criteria criteria = confirmOrderExample.createCriteria();
    
            LOG.info("查询页码:{}", req.getPage());
            LOG.info("每页条数:{}", req.getSize());
            PageHelper.startPage(req.getPage(), req.getSize());
            List<ConfirmOrder> confirmOrderList = confirmOrderMapper.selectByExample(confirmOrderExample);
    
            PageInfo<ConfirmOrder> pageInfo = new PageInfo<>(confirmOrderList);
            LOG.info("总行数:{}", pageInfo.getTotal());
            LOG.info("总页数:{}", pageInfo.getPages());
    
            List<ConfirmOrderQueryResp> list = BeanUtil.copyToList(confirmOrderList, ConfirmOrderQueryResp.class);
    
            PageResp<ConfirmOrderQueryResp> pageResp = new PageResp<>();
            pageResp.setTotal(pageInfo.getTotal());
            pageResp.setList(list);
            return pageResp;
        }
    
        public void delete(Long id) {
            confirmOrderMapper.deleteByPrimaryKey(id);
        }
    
        @SentinelResource(value = "doConfirm", blockHandler = "doConfirmBlock")
        public void doConfirm(ConfirmOrderDoReq req) {
    
            // // 校验令牌余量
            // boolean validSkToken = skTokenService.validSkToken(req.getDate(), req.getTrainCode(), LoginMemberContext.getId());
            // if (validSkToken) {
            //     LOG.info("令牌校验通过");
            // } else {
            //     LOG.info("令牌校验不通过");
            //     throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_SK_TOKEN_FAIL);
            // }
            //
            // 获取分布式锁
            String lockKey = RedisKeyPreEnum.CONFIRM_ORDER + "-" + DateUtil.formatDate(req.getDate()) + "-" + req.getTrainCode();
            // setIfAbsent就是对应redis的setnx
            Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(lockKey, lockKey, 10, TimeUnit.SECONDS);
            if (Boolean.TRUE.equals(setIfAbsent)) {
                LOG.info("恭喜,抢到锁了!lockKey:{}", lockKey);
            } else {
                // 只是没抢到锁,并不知道票抢完了没,所以提示稍候再试
                LOG.info("很遗憾,没抢到锁!lockKey:{}", lockKey);
                throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_LOCK_FAIL);
            }
    
            // RLock lock = null;
            /*
                关于红锁,看16.7节:
                A B C D E
                1: A B C D E
                2: C D E
                3: C
            */
            try {
                // // 使用redisson,自带看门狗
                // lock = redissonClient.getLock(lockKey);
                //
                // // 红锁的写法
                // // RedissonRedLock redissonRedLock = new RedissonRedLock(lock, lock, lock);
                // // boolean tryLock1 = redissonRedLock.tryLock(0, TimeUnit.SECONDS);
                //
                // /**
                //   waitTime – the maximum time to acquire the lock 等待获取锁时间(最大尝试获得锁的时间),超时返回false
                //   leaseTime – lease time 锁时长,即n秒后自动释放锁
                //   time unit – time unit 时间单位
                //  */
                // // boolean tryLock = lock.tryLock(30, 10, TimeUnit.SECONDS); // 不带看门狗
                // boolean tryLock = lock.tryLock(0, TimeUnit.SECONDS); // 带看门狗
                // if (tryLock) {
                //     LOG.info("恭喜,抢到锁了!");
                //     // 可以把下面这段放开,只用一个线程来测试,看看redisson的看门狗效果
                //     // for (int i = 0; i < 30; i++) {
                //     //     Long expire = redisTemplate.opsForValue().getOperations().getExpire(lockKey);
                //     //     LOG.info("锁过期时间还有:{}", expire);
                //     //     Thread.sleep(1000);
                //     // }
                // } else {
                //     // 只是没抢到锁,并不知道票抢完了没,所以提示稍候再试
                //     LOG.info("很遗憾,没抢到锁");
                //     throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_LOCK_FAIL);
                // }
    
                // 省略业务数据校验,如:车次是否存在,余票是否存在,车次是否在有效期内,tickets条数>0,同乘客同车次是否已买过
    
                Date date = req.getDate();
                String trainCode = req.getTrainCode();
                String start = req.getStart();
                String end = req.getEnd();
                List<ConfirmOrderTicketReq> tickets = req.getTickets();
                //
                // // 保存确认订单表,状态初始
                // DateTime now = DateTime.now();
                // ConfirmOrder confirmOrder = new ConfirmOrder();
                // confirmOrder.setId(SnowUtil.getSnowflakeNextId());
                // confirmOrder.setCreateTime(now);
                // confirmOrder.setUpdateTime(now);
                // confirmOrder.setMemberId(req.getMemberId());
                // confirmOrder.setDate(date);
                // confirmOrder.setTrainCode(trainCode);
                // confirmOrder.setStart(start);
                // confirmOrder.setEnd(end);
                // confirmOrder.setDailyTrainTicketId(req.getDailyTrainTicketId());
                // confirmOrder.setStatus(ConfirmOrderStatusEnum.INIT.getCode());
                // confirmOrder.setTickets(JSON.toJSONString(tickets));
                // confirmOrderMapper.insert(confirmOrder);
    
                // 从数据库里查出订单
                ConfirmOrderExample confirmOrderExample = new ConfirmOrderExample();
                confirmOrderExample.setOrderByClause("id asc");
                ConfirmOrderExample.Criteria criteria = confirmOrderExample.createCriteria();
                criteria.andDateEqualTo(req.getDate())
                        .andTrainCodeEqualTo(req.getTrainCode())
                        .andMemberIdEqualTo(req.getMemberId())
                        .andStatusEqualTo(ConfirmOrderStatusEnum.INIT.getCode());
                List<ConfirmOrder> list = confirmOrderMapper.selectByExampleWithBLOBs(confirmOrderExample);
                ConfirmOrder confirmOrder;
                if (CollUtil.isEmpty(list)) {
                    LOG.info("找不到原始订单,结束");
                    return;
                } else {
                    LOG.info("本次处理{}条确认订单", list.size());
                    confirmOrder = list.get(0);
                }
    
                // 查出余票记录,需要得到真实的库存
                DailyTrainTicket dailyTrainTicket = dailyTrainTicketService.selectByUnique(date, trainCode, start, end);
                LOG.info("查出余票记录:{}", dailyTrainTicket);
    
                // 预扣减余票数量,并判断余票是否足够
                reduceTickets(req, dailyTrainTicket);
    
                // 最终的选座结果
                List<DailyTrainSeat> finalSeatList = new ArrayList<>();
                // 计算相对第一个座位的偏移值
                // 比如选择的是C1,D2,则偏移值是:[0,5]
                // 比如选择的是A1,B1,C1,则偏移值是:[0,1,2]
                ConfirmOrderTicketReq ticketReq0 = tickets.get(0);
                if (StrUtil.isNotBlank(ticketReq0.getSeat())) {
                    LOG.info("本次购票有选座");
                    // 查出本次选座的座位类型都有哪些列,用于计算所选座位与第一个座位的偏离值
                    List<SeatColEnum> colEnumList = SeatColEnum.getColsByType(ticketReq0.getSeatTypeCode());
                    LOG.info("本次选座的座位类型包含的列:{}", colEnumList);
    
                    // 组成和前端两排选座一样的列表,用于作参照的座位列表,例:referSeatList = {A1, C1, D1, F1, A2, C2, D2, F2}
                    List<String> referSeatList = new ArrayList<>();
                    for (int i = 1; i <= 2; i++) {
                        for (SeatColEnum seatColEnum : colEnumList) {
                            referSeatList.add(seatColEnum.getCode() + i);
                        }
                    }
                    LOG.info("用于作参照的两排座位:{}", referSeatList);
    
                    List<Integer> offsetList = new ArrayList<>();
                    // 绝对偏移值,即:在参照座位列表中的位置
                    List<Integer> aboluteOffsetList = new ArrayList<>();
                    for (ConfirmOrderTicketReq ticketReq : tickets) {
                        int index = referSeatList.indexOf(ticketReq.getSeat());
                        aboluteOffsetList.add(index);
                    }
                    LOG.info("计算得到所有座位的绝对偏移值:{}", aboluteOffsetList);
                    for (Integer index : aboluteOffsetList) {
                        int offset = index - aboluteOffsetList.get(0);
                        offsetList.add(offset);
                    }
                    LOG.info("计算得到所有座位的相对第一个座位的偏移值:{}", offsetList);
    
                    getSeat(finalSeatList,
                            date,
                            trainCode,
                            ticketReq0.getSeatTypeCode(),
                            ticketReq0.getSeat().split("")[0], // 从A1得到A
                            offsetList,
                            dailyTrainTicket.getStartIndex(),
                            dailyTrainTicket.getEndIndex()
                    );
    
                } else {
                    LOG.info("本次购票没有选座");
                    for (ConfirmOrderTicketReq ticketReq : tickets) {
                        getSeat(finalSeatList,
                                date,
                                trainCode,
                                ticketReq.getSeatTypeCode(),
                                null,
                                null,
                                dailyTrainTicket.getStartIndex(),
                                dailyTrainTicket.getEndIndex()
                        );
                    }
                }
    
                LOG.info("最终选座:{}", finalSeatList);
    
                // 选中座位后事务处理:
                // 座位表修改售卖情况sell;
                // 余票详情表修改余票;
                // 为会员增加购票记录
                // 更新确认订单为成功
                try {
                    afterConfirmOrderService.afterDoConfirm(dailyTrainTicket, finalSeatList, tickets, confirmOrder);
                } catch (Exception e) {
                    LOG.error("保存购票信息失败", e);
                    throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_EXCEPTION);
                }
                // LOG.info("购票流程结束,释放锁!lockKey:{}", lockKey);
                // redisTemplate.delete(lockKey);
                // } catch (InterruptedException e) {
                //     LOG.error("购票异常", e);
            } finally {
                // try finally不能包含加锁的那段代码,否则加锁失败会走到finally里,从而释放别的线程的锁
                // LOG.info("购票流程结束,释放锁!lockKey:{}", lockKey);
                // redisTemplate.delete(lockKey);
                // LOG.info("购票流程结束,释放锁!");
                // if (null != lock && lock.isHeldByCurrentThread()) {
                //     lock.unlock();
                // }
            }
    
        }
    
        /**
         * 挑座位,如果有选座,则一次性挑完,如果无选座,则一个一个挑
         * @param date
         * @param trainCode
         * @param seatType
         * @param column
         * @param offsetList
         */
        private void getSeat(List<DailyTrainSeat> finalSeatList, Date date, String trainCode, String seatType, String column, List<Integer> offsetList, Integer startIndex, Integer endIndex) {
            List<DailyTrainSeat> getSeatList = new ArrayList<>();
            List<DailyTrainCarriage> carriageList = dailyTrainCarriageService.selectBySeatType(date, trainCode, seatType);
            LOG.info("共查出{}个符合条件的车厢", carriageList.size());
    
            // 一个车箱一个车箱的获取座位数据
            for (DailyTrainCarriage dailyTrainCarriage : carriageList) {
                LOG.info("开始从车厢{}选座", dailyTrainCarriage.getIndex());
                getSeatList = new ArrayList<>();
                List<DailyTrainSeat> seatList = dailyTrainSeatService.selectByCarriage(date, trainCode, dailyTrainCarriage.getIndex());
                LOG.info("车厢{}的座位数:{}", dailyTrainCarriage.getIndex(), seatList.size());
                for (int i = 0; i < seatList.size(); i++) {
                    DailyTrainSeat dailyTrainSeat = seatList.get(i);
                    Integer seatIndex = dailyTrainSeat.getCarriageSeatIndex();
                    String col = dailyTrainSeat.getCol();
    
                    // 判断当前座位不能被选中过
                    boolean alreadyChooseFlag = false;
                    for (DailyTrainSeat finalSeat : finalSeatList){
                        if (finalSeat.getId().equals(dailyTrainSeat.getId())) {
                            alreadyChooseFlag = true;
                            break;
                        }
                    }
                    if (alreadyChooseFlag) {
                        LOG.info("座位{}被选中过,不能重复选中,继续判断下一个座位", seatIndex);
                        continue;
                    }
    
                    // 判断column,有值的话要比对列号
                    if (StrUtil.isBlank(column)) {
                        LOG.info("无选座");
                    } else {
                        if (!column.equals(col)) {
                            LOG.info("座位{}列值不对,继续判断下一个座位,当前列值:{},目标列值:{}", seatIndex, col, column);
                            continue;
                        }
                    }
    
                    boolean isChoose = calSell(dailyTrainSeat, startIndex, endIndex);
                    if (isChoose) {
                        LOG.info("选中座位");
                        getSeatList.add(dailyTrainSeat);
                    } else {
                        continue;
                    }
    
                    // 根据offset选剩下的座位
                    boolean isGetAllOffsetSeat = true;
                    if (CollUtil.isNotEmpty(offsetList)) {
                        LOG.info("有偏移值:{},校验偏移的座位是否可选", offsetList);
                        // 从索引1开始,索引0就是当前已选中的票
                        for (int j = 1; j < offsetList.size(); j++) {
                            Integer offset = offsetList.get(j);
                            // 座位在库的索引是从1开始
                            // int nextIndex = seatIndex + offset - 1;
                            int nextIndex = i + offset;
    
                            // 有选座时,一定是在同一个车箱
                            if (nextIndex >= seatList.size()) {
                                LOG.info("座位{}不可选,偏移后的索引超出了这个车箱的座位数", nextIndex);
                                isGetAllOffsetSeat = false;
                                break;
                            }
    
                            DailyTrainSeat nextDailyTrainSeat = seatList.get(nextIndex);
                            boolean isChooseNext = calSell(nextDailyTrainSeat, startIndex, endIndex);
                            if (isChooseNext) {
                                LOG.info("座位{}被选中", nextDailyTrainSeat.getCarriageSeatIndex());
                                getSeatList.add(nextDailyTrainSeat);
                            } else {
                                LOG.info("座位{}不可选", nextDailyTrainSeat.getCarriageSeatIndex());
                                isGetAllOffsetSeat = false;
                                break;
                            }
                        }
                    }
                    if (!isGetAllOffsetSeat) {
                        getSeatList = new ArrayList<>();
                        continue;
                    }
    
                    // 保存选好的座位
                    finalSeatList.addAll(getSeatList);
                    return;
                }
            }
        }
    
        /**
         * 计算某座位在区间内是否可卖
         * 例:sell=10001,本次购买区间站1~4,则区间已售000
         * 全部是0,表示这个区间可买;只要有1,就表示区间内已售过票
         *
         * 选中后,要计算购票后的sell,比如原来是10001,本次购买区间站1~4
         * 方案:构造本次购票造成的售卖信息01110,和原sell 10001按位与,最终得到11111
         */
        private boolean calSell(DailyTrainSeat dailyTrainSeat, Integer startIndex, Integer endIndex) {
            // 00001, 00000
            String sell = dailyTrainSeat.getSell();
            //  000, 000
            String sellPart = sell.substring(startIndex, endIndex);
            if (Integer.parseInt(sellPart) > 0) {
                LOG.info("座位{}在本次车站区间{}~{}已售过票,不可选中该座位", dailyTrainSeat.getCarriageSeatIndex(), startIndex, endIndex);
                return false;
            } else {
                LOG.info("座位{}在本次车站区间{}~{}未售过票,可选中该座位", dailyTrainSeat.getCarriageSeatIndex(), startIndex, endIndex);
                //  111,   111
                String curSell = sellPart.replace('0', '1');
                // 0111,  0111
                curSell = StrUtil.fillBefore(curSell, '0', endIndex);
                // 01110, 01110
                curSell = StrUtil.fillAfter(curSell, '0', sell.length());
    
                // 当前区间售票信息curSell 01110与库里的已售信息sell 00001按位与,即可得到该座位卖出此票后的售票详情
                // 15(01111), 14(01110 = 01110|00000)
                int newSellInt = NumberUtil.binaryToInt(curSell) | NumberUtil.binaryToInt(sell);
                //  1111,  1110
                String newSell = NumberUtil.getBinaryStr(newSellInt);
                // 01111, 01110
                newSell = StrUtil.fillBefore(newSell, '0', sell.length());
                LOG.info("座位{}被选中,原售票信息:{},车站区间:{}~{},即:{},最终售票信息:{}"
                        , dailyTrainSeat.getCarriageSeatIndex(), sell, startIndex, endIndex, curSell, newSell);
                dailyTrainSeat.setSell(newSell);
                return true;
    
            }
        }
    
        private static void reduceTickets(ConfirmOrderDoReq req, DailyTrainTicket dailyTrainTicket) {
            for (ConfirmOrderTicketReq ticketReq : req.getTickets()) {
                String seatTypeCode = ticketReq.getSeatTypeCode();
                SeatTypeEnum seatTypeEnum = EnumUtil.getBy(SeatTypeEnum::getCode, seatTypeCode);
                switch (seatTypeEnum) {
                    case YDZ -> {
                        int countLeft = dailyTrainTicket.getYdz() - 1;
                        if (countLeft < 0) {
                            throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_TICKET_COUNT_ERROR);
                        }
                        dailyTrainTicket.setYdz(countLeft);
                    }
                    case EDZ -> {
                        int countLeft = dailyTrainTicket.getEdz() - 1;
                        if (countLeft < 0) {
                            throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_TICKET_COUNT_ERROR);
                        }
                        dailyTrainTicket.setEdz(countLeft);
                    }
                    case RW -> {
                        int countLeft = dailyTrainTicket.getRw() - 1;
                        if (countLeft < 0) {
                            throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_TICKET_COUNT_ERROR);
                        }
                        dailyTrainTicket.setRw(countLeft);
                    }
                    case YW -> {
                        int countLeft = dailyTrainTicket.getYw() - 1;
                        if (countLeft < 0) {
                            throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_TICKET_COUNT_ERROR);
                        }
                        dailyTrainTicket.setYw(countLeft);
                    }
                }
            }
        }
    
        /**
         * 降级方法,需包含限流方法的所有参数和BlockException参数
         * @param req
         * @param e
         */
        public void doConfirmBlock(ConfirmOrderDoReq req, BlockException e) {
            LOG.info("购票请求被限流:{}", req);
            throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_FLOW_EXCEPTION);
        }
    }
    
  • 测试

    正常页面操作买票,返回下单成功的提示,然后后台mq异步消费

在这里插入图片描述

5.为同转异增加logId,方便日志跟踪

由于mq相当于开启了新的异步线程,异步线程并不会自动继承主线程的MDC(org.slf4j.MDC)上下文信息,所以不会把日志流水号带过去,我们需要主动将主线程的LogId传递到异步线程里

  • ConfirmOrderDoReq.java

    /**
     * 日志跟踪号
     */
    private String logId;
    
    @Override
    public String toString() {
        return "ConfirmOrderDoReq{" +
                "memberId=" + memberId +
                ", date=" + date +
                ", trainCode='" + trainCode + '\'' +
                ", start='" + start + '\'' +
                ", end='" + end + '\'' +
                ", dailyTrainTicketId=" + dailyTrainTicketId +
                ", tickets=" + tickets +
                ", imageCode='" + imageCode + '\'' +
                ", imageCodeToken='" + imageCodeToken + '\'' +
                ", logId='" + logId + '\'' +
                '}';
    }
    
  • BeforeConfirmOrderService.java

    // 发送MQ排队购票
    req.setLogId(MDC.get("LOG_ID"));
    
  • ConfirmOrderConsumer.java

    @Override
    public void onMessage(MessageExt messageExt) {
        byte[] body = messageExt.getBody();
        ConfirmOrderDoReq req = JSON.parseObject(new String(body), ConfirmOrderDoReq.class);
        MDC.put("LOG_ID", req.getLogId());
        LOG.info("ROCKETMQ收到消息:{}", new String(body));
        confirmOrderService.doConfirm(req);
    }
    
  • 测试

在这里插入图片描述

问题:

  1. 页面上显示问题,点击购票后返回应该是“下单成功,正在排队中”,等轮询获得成功结果之后再弹出提示“出票成功”之类,后续再优化;
  2. 下单完后弹窗应该都隐藏掉,并且输入的验证码也应该清空(可以直接做成刷新页面)

在这里插入图片描述

6.增加排队功能思路讲解

目前的出票逻辑还存在问题:消费者消费逻辑里,如果我没拿到车次锁,就直接抛出异常了,但是实际上我应该是排队出票的,所以这里还应该改进下思路(时序图如六、1)。具体实现看后面

7.完成排队出票功能

1.修改MQ消息内容,只需要通知出哪个车次的票(即:组成锁的内容),不需要具体到哪个人

  • com.neilxu.train.business.dto.ConfirmOrderMQDto

    package com.neilxu.train.business.dto;
    
    import lombok.Data;
    
    import java.util.Date;
    
    @Data
    public class ConfirmOrderMQDto {
        /**
         * 日志流程号,用于同转异时,用同一个流水号
         */
        private String logId;
    
        /**
         * 日期
         */
        private Date date;
    
        /**
         * 车次编号
         */
        private String trainCode;
        
    }
    
  • ConfirmOrderService

    注意要有释放锁的逻辑

     finally {
        // try finally不能包含加锁的那段代码,否则加锁失败会走到finally里,从而释放别的线程的锁
         LOG.info("购票流程结束,释放锁!lockKey:{}", lockKey);
         redisTemplate.delete(lockKey);
        // LOG.info("购票流程结束,释放锁!");
        // if (null != lock && lock.isHeldByCurrentThread()) {
        //     lock.unlock();
        // }
    }
    
  • BeforeConfirmOrderService

    @SentinelResource(value = "beforeDoConfirm", blockHandler = "beforeDoConfirmBlock")
    public void beforeDoConfirm(ConfirmOrderDoReq req) {
        req.setMemberId(LoginMemberContext.getId());
        // 校验令牌余量
        boolean validSkToken = skTokenService.validSkToken(req.getDate(), req.getTrainCode(), LoginMemberContext.getId());
        if (validSkToken) {
            LOG.info("令牌校验通过");
        } else {
            LOG.info("令牌校验不通过");
            throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_SK_TOKEN_FAIL);
        }
    
        Date date = req.getDate();
        String trainCode = req.getTrainCode();
        String start = req.getStart();
        String end = req.getEnd();
        List<ConfirmOrderTicketReq> tickets = req.getTickets();
    
        // 保存确认订单表,状态初始
        DateTime now = DateTime.now();
        ConfirmOrder confirmOrder = new ConfirmOrder();
        confirmOrder.setId(SnowUtil.getSnowflakeNextId());
        confirmOrder.setCreateTime(now);
        confirmOrder.setUpdateTime(now);
        confirmOrder.setMemberId(req.getMemberId());
        confirmOrder.setDate(date);
        confirmOrder.setTrainCode(trainCode);
        confirmOrder.setStart(start);
        confirmOrder.setEnd(end);
        confirmOrder.setDailyTrainTicketId(req.getDailyTrainTicketId());
        confirmOrder.setStatus(ConfirmOrderStatusEnum.INIT.getCode());
        confirmOrder.setTickets(JSON.toJSONString(tickets));
        confirmOrderMapper.insert(confirmOrder);
    
        // 发送MQ排队购票
        ConfirmOrderMQDto confirmOrderMQDto = new ConfirmOrderMQDto();
        confirmOrderMQDto.setDate(req.getDate());
        confirmOrderMQDto.setTrainCode(req.getTrainCode());
        confirmOrderMQDto.setLogId(MDC.get("LOG_ID"));
        String reqJson = JSON.toJSONString(confirmOrderMQDto);
        LOG.info("排队购票,发送mq开始,消息:{}", reqJson);
        rocketMQTemplate.convertAndSend(RocketMQTopicEnum.CONFIRM_ORDER.getCode(), reqJson);
        LOG.info("排队购票,发送mq结束");
    
    }
    

2.出票功能改为循环排队出票,按车次(锁)来循环出票

  • com.neilxu.train.business.service.ConfirmOrderService

    package com.neilxu.train.business.service;
    
    import cn.hutool.core.bean.BeanUtil;
    import cn.hutool.core.collection.CollUtil;
    import cn.hutool.core.date.DateTime;
    import cn.hutool.core.date.DateUtil;
    import cn.hutool.core.util.EnumUtil;
    import cn.hutool.core.util.NumberUtil;
    import cn.hutool.core.util.ObjectUtil;
    import cn.hutool.core.util.StrUtil;
    import com.alibaba.csp.sentinel.annotation.SentinelResource;
    import com.alibaba.csp.sentinel.slots.block.BlockException;
    import com.alibaba.fastjson.JSON;
    import com.github.pagehelper.PageHelper;
    import com.github.pagehelper.PageInfo;
    import com.neilxu.train.business.domain.*;
    import com.neilxu.train.business.dto.ConfirmOrderMQDto;
    import com.neilxu.train.business.enums.ConfirmOrderStatusEnum;
    import com.neilxu.train.business.enums.RedisKeyPreEnum;
    import com.neilxu.train.business.enums.SeatColEnum;
    import com.neilxu.train.business.enums.SeatTypeEnum;
    import com.neilxu.train.business.mapper.ConfirmOrderMapper;
    import com.neilxu.train.business.req.ConfirmOrderDoReq;
    import com.neilxu.train.business.req.ConfirmOrderQueryReq;
    import com.neilxu.train.business.req.ConfirmOrderTicketReq;
    import com.neilxu.train.business.resp.ConfirmOrderQueryResp;
    import com.neilxu.train.common.exception.BusinessException;
    import com.neilxu.train.common.exception.BusinessExceptionEnum;
    import com.neilxu.train.common.resp.PageResp;
    import com.neilxu.train.common.util.SnowUtil;
    import jakarta.annotation.Resource;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.stereotype.Service;
    
    import java.util.ArrayList;
    import java.util.Date;
    import java.util.List;
    import java.util.concurrent.TimeUnit;
    
    @Service
    public class ConfirmOrderService {
    
        private static final Logger LOG = LoggerFactory.getLogger(ConfirmOrderService.class);
    
        @Resource
        private ConfirmOrderMapper confirmOrderMapper;
    
        @Resource
        private DailyTrainTicketService dailyTrainTicketService;
    
        @Resource
        private DailyTrainCarriageService dailyTrainCarriageService;
    
        @Resource
        private DailyTrainSeatService dailyTrainSeatService;
    
        @Resource
        private AfterConfirmOrderService afterConfirmOrderService;
    
        @Autowired
        private StringRedisTemplate redisTemplate;
    
        @Autowired
        private SkTokenService skTokenService;
    
        // @Autowired
        // private RedissonClient redissonClient;
    
        public void save(ConfirmOrderDoReq req) {
            DateTime now = DateTime.now();
            ConfirmOrder confirmOrder = BeanUtil.copyProperties(req, ConfirmOrder.class);
            if (ObjectUtil.isNull(confirmOrder.getId())) {
                confirmOrder.setId(SnowUtil.getSnowflakeNextId());
                confirmOrder.setCreateTime(now);
                confirmOrder.setUpdateTime(now);
                confirmOrderMapper.insert(confirmOrder);
            } else {
                confirmOrder.setUpdateTime(now);
                confirmOrderMapper.updateByPrimaryKey(confirmOrder);
            }
        }
    
        public PageResp<ConfirmOrderQueryResp> queryList(ConfirmOrderQueryReq req) {
            ConfirmOrderExample confirmOrderExample = new ConfirmOrderExample();
            confirmOrderExample.setOrderByClause("id desc");
            ConfirmOrderExample.Criteria criteria = confirmOrderExample.createCriteria();
    
            LOG.info("查询页码:{}", req.getPage());
            LOG.info("每页条数:{}", req.getSize());
            PageHelper.startPage(req.getPage(), req.getSize());
            List<ConfirmOrder> confirmOrderList = confirmOrderMapper.selectByExample(confirmOrderExample);
    
            PageInfo<ConfirmOrder> pageInfo = new PageInfo<>(confirmOrderList);
            LOG.info("总行数:{}", pageInfo.getTotal());
            LOG.info("总页数:{}", pageInfo.getPages());
    
            List<ConfirmOrderQueryResp> list = BeanUtil.copyToList(confirmOrderList, ConfirmOrderQueryResp.class);
    
            PageResp<ConfirmOrderQueryResp> pageResp = new PageResp<>();
            pageResp.setTotal(pageInfo.getTotal());
            pageResp.setList(list);
            return pageResp;
        }
    
        public void delete(Long id) {
            confirmOrderMapper.deleteByPrimaryKey(id);
        }
    
        @SentinelResource(value = "doConfirm", blockHandler = "doConfirmBlock")
        public void doConfirm(ConfirmOrderMQDto dto) {
    
            // // 校验令牌余量
            // boolean validSkToken = skTokenService.validSkToken(dto.getDate(), dto.getTrainCode(), LoginMemberContext.getId());
            // if (validSkToken) {
            //     LOG.info("令牌校验通过");
            // } else {
            //     LOG.info("令牌校验不通过");
            //     throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_SK_TOKEN_FAIL);
            // }
            //
            // 获取分布式锁
            String lockKey = RedisKeyPreEnum.CONFIRM_ORDER + "-" + DateUtil.formatDate(dto.getDate()) + "-" + dto.getTrainCode();
            // setIfAbsent就是对应redis的setnx
            Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(lockKey, lockKey, 10, TimeUnit.SECONDS);
            if (Boolean.TRUE.equals(setIfAbsent)) {
                LOG.info("恭喜,抢到锁了!lockKey:{}", lockKey);
            } else {
                // 只是没抢到锁,并不知道票抢完了没,所以提示稍候再试
                LOG.info("很遗憾,没抢到锁!lockKey:{}", lockKey);
                throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_LOCK_FAIL);
            }
    
            // RLock lock = null;
            /*
                关于红锁,看16.7节:
                A B C D E
                1: A B C D E
                2: C D E
                3: C
            */
            try {
                // // 使用redisson,自带看门狗
                // lock = redissonClient.getLock(lockKey);
                //
                // // 红锁的写法
                // // RedissonRedLock redissonRedLock = new RedissonRedLock(lock, lock, lock);
                // // boolean tryLock1 = redissonRedLock.tryLock(0, TimeUnit.SECONDS);
                //
                // /**
                //   waitTime – the maximum time to acquire the lock 等待获取锁时间(最大尝试获得锁的时间),超时返回false
                //   leaseTime – lease time 锁时长,即n秒后自动释放锁
                //   time unit – time unit 时间单位
                //  */
                // // boolean tryLock = lock.tryLock(30, 10, TimeUnit.SECONDS); // 不带看门狗
                // boolean tryLock = lock.tryLock(0, TimeUnit.SECONDS); // 带看门狗
                // if (tryLock) {
                //     LOG.info("恭喜,抢到锁了!");
                //     // 可以把下面这段放开,只用一个线程来测试,看看redisson的看门狗效果
                //     // for (int i = 0; i < 30; i++) {
                //     //     Long expire = redisTemplate.opsForValue().getOperations().getExpire(lockKey);
                //     //     LOG.info("锁过期时间还有:{}", expire);
                //     //     Thread.sleep(1000);
                //     // }
                // } else {
                //     // 只是没抢到锁,并不知道票抢完了没,所以提示稍候再试
                //     LOG.info("很遗憾,没抢到锁");
                //     throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_LOCK_FAIL);
                // }
    
                while (true) {
                    // 取确认订单表的记录,同日期车次,状态是I,分页处理,每次取N条
                    ConfirmOrderExample confirmOrderExample = new ConfirmOrderExample();
                    confirmOrderExample.setOrderByClause("id asc");
                    ConfirmOrderExample.Criteria criteria = confirmOrderExample.createCriteria();
                    criteria.andDateEqualTo(dto.getDate())
                            .andTrainCodeEqualTo(dto.getTrainCode())
                            .andStatusEqualTo(ConfirmOrderStatusEnum.INIT.getCode());
                    PageHelper.startPage(1, 5);
                    List<ConfirmOrder> list = confirmOrderMapper.selectByExampleWithBLOBs(confirmOrderExample);
    
                    if (CollUtil.isEmpty(list)) {
                        LOG.info("没有需要处理的订单,结束循环");
                        break;
                    } else {
                        LOG.info("本次处理{}条订单", list.size());
                    }
    
                    // 一条一条的卖
                    list.forEach(this::sell);
                }
    
                // LOG.info("购票流程结束,释放锁!lockKey:{}", lockKey);
                // redisTemplate.delete(lockKey);
                // } catch (InterruptedException e) {
                //     LOG.error("购票异常", e);
            } finally {
                // try finally不能包含加锁的那段代码,否则加锁失败会走到finally里,从而释放别的线程的锁
                LOG.info("购票流程结束,释放锁!lockKey:{}", lockKey);
                redisTemplate.delete(lockKey);
                // LOG.info("购票流程结束,释放锁!");
                // if (null != lock && lock.isHeldByCurrentThread()) {
                //     lock.unlock();
                // }
            }
    
        }
    
        /**
         * 售票
         * @param confirmOrder
         */
        private void sell(ConfirmOrder confirmOrder) {
            // 构造ConfirmOrderDoReq
            ConfirmOrderDoReq req = new ConfirmOrderDoReq();
            req.setMemberId(confirmOrder.getMemberId());
            req.setDate(confirmOrder.getDate());
            req.setTrainCode(confirmOrder.getTrainCode());
            req.setStart(confirmOrder.getStart());
            req.setEnd(confirmOrder.getEnd());
            req.setDailyTrainTicketId(confirmOrder.getDailyTrainTicketId());
            req.setTickets(JSON.parseArray(confirmOrder.getTickets(), ConfirmOrderTicketReq.class));
            req.setImageCode("");
            req.setImageCodeToken("");
            req.setLogId("");
    
            // 省略业务数据校验,如:车次是否存在,余票是否存在,车次是否在有效期内,tickets条数>0,同乘客同车次是否已买过
    
            Date date = req.getDate();
            String trainCode = req.getTrainCode();
            String start = req.getStart();
            String end = req.getEnd();
            List<ConfirmOrderTicketReq> tickets = req.getTickets();
            //
            // // 保存确认订单表,状态初始
            // DateTime now = DateTime.now();
            // ConfirmOrder confirmOrder = new ConfirmOrder();
            // confirmOrder.setId(SnowUtil.getSnowflakeNextId());
            // confirmOrder.setCreateTime(now);
            // confirmOrder.setUpdateTime(now);
            // confirmOrder.setMemberId(req.getMemberId());
            // confirmOrder.setDate(date);
            // confirmOrder.setTrainCode(trainCode);
            // confirmOrder.setStart(start);
            // confirmOrder.setEnd(end);
            // confirmOrder.setDailyTrainTicketId(req.getDailyTrainTicketId());
            // confirmOrder.setStatus(ConfirmOrderStatusEnum.INIT.getCode());
            // confirmOrder.setTickets(JSON.toJSONString(tickets));
            // confirmOrderMapper.insert(confirmOrder);
    
            // // 从数据库里查出订单
            // ConfirmOrderExample confirmOrderExample = new ConfirmOrderExample();
            // confirmOrderExample.setOrderByClause("id asc");
            // ConfirmOrderExample.Criteria criteria = confirmOrderExample.createCriteria();
            // criteria.andDateEqualTo(req.getDate())
            //         .andTrainCodeEqualTo(req.getTrainCode())
            //         .andStatusEqualTo(ConfirmOrderStatusEnum.INIT.getCode());
            // List<ConfirmOrder> list = confirmOrderMapper.selectByExampleWithBLOBs(confirmOrderExample);
            // ConfirmOrder confirmOrder;
            // if (CollUtil.isEmpty(list)) {
            //     LOG.info("找不到原始订单,结束");
            //     return;
            // } else {
            //     LOG.info("本次处理{}条确认订单", list.size());
            //     confirmOrder = list.get(0);
            // }
    
            // 查出余票记录,需要得到真实的库存
            DailyTrainTicket dailyTrainTicket = dailyTrainTicketService.selectByUnique(date, trainCode, start, end);
            LOG.info("查出余票记录:{}", dailyTrainTicket);
    
            // 预扣减余票数量,并判断余票是否足够
            reduceTickets(req, dailyTrainTicket);
    
            // 最终的选座结果
            List<DailyTrainSeat> finalSeatList = new ArrayList<>();
            // 计算相对第一个座位的偏移值
            // 比如选择的是C1,D2,则偏移值是:[0,5]
            // 比如选择的是A1,B1,C1,则偏移值是:[0,1,2]
            ConfirmOrderTicketReq ticketReq0 = tickets.get(0);
            if (StrUtil.isNotBlank(ticketReq0.getSeat())) {
                LOG.info("本次购票有选座");
                // 查出本次选座的座位类型都有哪些列,用于计算所选座位与第一个座位的偏离值
                List<SeatColEnum> colEnumList = SeatColEnum.getColsByType(ticketReq0.getSeatTypeCode());
                LOG.info("本次选座的座位类型包含的列:{}", colEnumList);
    
                // 组成和前端两排选座一样的列表,用于作参照的座位列表,例:referSeatList = {A1, C1, D1, F1, A2, C2, D2, F2}
                List<String> referSeatList = new ArrayList<>();
                for (int i = 1; i <= 2; i++) {
                    for (SeatColEnum seatColEnum : colEnumList) {
                        referSeatList.add(seatColEnum.getCode() + i);
                    }
                }
                LOG.info("用于作参照的两排座位:{}", referSeatList);
    
                List<Integer> offsetList = new ArrayList<>();
                // 绝对偏移值,即:在参照座位列表中的位置
                List<Integer> aboluteOffsetList = new ArrayList<>();
                for (ConfirmOrderTicketReq ticketReq : tickets) {
                    int index = referSeatList.indexOf(ticketReq.getSeat());
                    aboluteOffsetList.add(index);
                }
                LOG.info("计算得到所有座位的绝对偏移值:{}", aboluteOffsetList);
                for (Integer index : aboluteOffsetList) {
                    int offset = index - aboluteOffsetList.get(0);
                    offsetList.add(offset);
                }
                LOG.info("计算得到所有座位的相对第一个座位的偏移值:{}", offsetList);
    
                getSeat(finalSeatList,
                        date,
                        trainCode,
                        ticketReq0.getSeatTypeCode(),
                        ticketReq0.getSeat().split("")[0], // 从A1得到A
                        offsetList,
                        dailyTrainTicket.getStartIndex(),
                        dailyTrainTicket.getEndIndex()
                );
    
            } else {
                LOG.info("本次购票没有选座");
                for (ConfirmOrderTicketReq ticketReq : tickets) {
                    getSeat(finalSeatList,
                            date,
                            trainCode,
                            ticketReq.getSeatTypeCode(),
                            null,
                            null,
                            dailyTrainTicket.getStartIndex(),
                            dailyTrainTicket.getEndIndex()
                    );
                }
            }
    
            LOG.info("最终选座:{}", finalSeatList);
    
            // 选中座位后事务处理:
            // 座位表修改售卖情况sell;
            // 余票详情表修改余票;
            // 为会员增加购票记录
            // 更新确认订单为成功
            try {
                afterConfirmOrderService.afterDoConfirm(dailyTrainTicket, finalSeatList, tickets, confirmOrder);
            } catch (Exception e) {
                LOG.error("保存购票信息失败", e);
                throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_EXCEPTION);
            }
        }
    
        /**
         * 挑座位,如果有选座,则一次性挑完,如果无选座,则一个一个挑
         * @param date
         * @param trainCode
         * @param seatType
         * @param column
         * @param offsetList
         */
        private void getSeat(List<DailyTrainSeat> finalSeatList, Date date, String trainCode, String seatType, String column, List<Integer> offsetList, Integer startIndex, Integer endIndex) {
            List<DailyTrainSeat> getSeatList = new ArrayList<>();
            List<DailyTrainCarriage> carriageList = dailyTrainCarriageService.selectBySeatType(date, trainCode, seatType);
            LOG.info("共查出{}个符合条件的车厢", carriageList.size());
    
            // 一个车箱一个车箱的获取座位数据
            for (DailyTrainCarriage dailyTrainCarriage : carriageList) {
                LOG.info("开始从车厢{}选座", dailyTrainCarriage.getIndex());
                getSeatList = new ArrayList<>();
                List<DailyTrainSeat> seatList = dailyTrainSeatService.selectByCarriage(date, trainCode, dailyTrainCarriage.getIndex());
                LOG.info("车厢{}的座位数:{}", dailyTrainCarriage.getIndex(), seatList.size());
                for (int i = 0; i < seatList.size(); i++) {
                    DailyTrainSeat dailyTrainSeat = seatList.get(i);
                    Integer seatIndex = dailyTrainSeat.getCarriageSeatIndex();
                    String col = dailyTrainSeat.getCol();
    
                    // 判断当前座位不能被选中过
                    boolean alreadyChooseFlag = false;
                    for (DailyTrainSeat finalSeat : finalSeatList){
                        if (finalSeat.getId().equals(dailyTrainSeat.getId())) {
                            alreadyChooseFlag = true;
                            break;
                        }
                    }
                    if (alreadyChooseFlag) {
                        LOG.info("座位{}被选中过,不能重复选中,继续判断下一个座位", seatIndex);
                        continue;
                    }
    
                    // 判断column,有值的话要比对列号
                    if (StrUtil.isBlank(column)) {
                        LOG.info("无选座");
                    } else {
                        if (!column.equals(col)) {
                            LOG.info("座位{}列值不对,继续判断下一个座位,当前列值:{},目标列值:{}", seatIndex, col, column);
                            continue;
                        }
                    }
    
                    boolean isChoose = calSell(dailyTrainSeat, startIndex, endIndex);
                    if (isChoose) {
                        LOG.info("选中座位");
                        getSeatList.add(dailyTrainSeat);
                    } else {
                        continue;
                    }
    
                    // 根据offset选剩下的座位
                    boolean isGetAllOffsetSeat = true;
                    if (CollUtil.isNotEmpty(offsetList)) {
                        LOG.info("有偏移值:{},校验偏移的座位是否可选", offsetList);
                        // 从索引1开始,索引0就是当前已选中的票
                        for (int j = 1; j < offsetList.size(); j++) {
                            Integer offset = offsetList.get(j);
                            // 座位在库的索引是从1开始
                            // int nextIndex = seatIndex + offset - 1;
                            int nextIndex = i + offset;
    
                            // 有选座时,一定是在同一个车箱
                            if (nextIndex >= seatList.size()) {
                                LOG.info("座位{}不可选,偏移后的索引超出了这个车箱的座位数", nextIndex);
                                isGetAllOffsetSeat = false;
                                break;
                            }
    
                            DailyTrainSeat nextDailyTrainSeat = seatList.get(nextIndex);
                            boolean isChooseNext = calSell(nextDailyTrainSeat, startIndex, endIndex);
                            if (isChooseNext) {
                                LOG.info("座位{}被选中", nextDailyTrainSeat.getCarriageSeatIndex());
                                getSeatList.add(nextDailyTrainSeat);
                            } else {
                                LOG.info("座位{}不可选", nextDailyTrainSeat.getCarriageSeatIndex());
                                isGetAllOffsetSeat = false;
                                break;
                            }
                        }
                    }
                    if (!isGetAllOffsetSeat) {
                        getSeatList = new ArrayList<>();
                        continue;
                    }
    
                    // 保存选好的座位
                    finalSeatList.addAll(getSeatList);
                    return;
                }
            }
        }
    
        /**
         * 计算某座位在区间内是否可卖
         * 例:sell=10001,本次购买区间站1~4,则区间已售000
         * 全部是0,表示这个区间可买;只要有1,就表示区间内已售过票
         *
         * 选中后,要计算购票后的sell,比如原来是10001,本次购买区间站1~4
         * 方案:构造本次购票造成的售卖信息01110,和原sell 10001按位与,最终得到11111
         */
        private boolean calSell(DailyTrainSeat dailyTrainSeat, Integer startIndex, Integer endIndex) {
            // 00001, 00000
            String sell = dailyTrainSeat.getSell();
            //  000, 000
            String sellPart = sell.substring(startIndex, endIndex);
            if (Integer.parseInt(sellPart) > 0) {
                LOG.info("座位{}在本次车站区间{}~{}已售过票,不可选中该座位", dailyTrainSeat.getCarriageSeatIndex(), startIndex, endIndex);
                return false;
            } else {
                LOG.info("座位{}在本次车站区间{}~{}未售过票,可选中该座位", dailyTrainSeat.getCarriageSeatIndex(), startIndex, endIndex);
                //  111,   111
                String curSell = sellPart.replace('0', '1');
                // 0111,  0111
                curSell = StrUtil.fillBefore(curSell, '0', endIndex);
                // 01110, 01110
                curSell = StrUtil.fillAfter(curSell, '0', sell.length());
    
                // 当前区间售票信息curSell 01110与库里的已售信息sell 00001按位与,即可得到该座位卖出此票后的售票详情
                // 15(01111), 14(01110 = 01110|00000)
                int newSellInt = NumberUtil.binaryToInt(curSell) | NumberUtil.binaryToInt(sell);
                //  1111,  1110
                String newSell = NumberUtil.getBinaryStr(newSellInt);
                // 01111, 01110
                newSell = StrUtil.fillBefore(newSell, '0', sell.length());
                LOG.info("座位{}被选中,原售票信息:{},车站区间:{}~{},即:{},最终售票信息:{}"
                        , dailyTrainSeat.getCarriageSeatIndex(), sell, startIndex, endIndex, curSell, newSell);
                dailyTrainSeat.setSell(newSell);
                return true;
    
            }
        }
    
        private static void reduceTickets(ConfirmOrderDoReq req, DailyTrainTicket dailyTrainTicket) {
            for (ConfirmOrderTicketReq ticketReq : req.getTickets()) {
                String seatTypeCode = ticketReq.getSeatTypeCode();
                SeatTypeEnum seatTypeEnum = EnumUtil.getBy(SeatTypeEnum::getCode, seatTypeCode);
                switch (seatTypeEnum) {
                    case YDZ -> {
                        int countLeft = dailyTrainTicket.getYdz() - 1;
                        if (countLeft < 0) {
                            throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_TICKET_COUNT_ERROR);
                        }
                        dailyTrainTicket.setYdz(countLeft);
                    }
                    case EDZ -> {
                        int countLeft = dailyTrainTicket.getEdz() - 1;
                        if (countLeft < 0) {
                            throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_TICKET_COUNT_ERROR);
                        }
                        dailyTrainTicket.setEdz(countLeft);
                    }
                    case RW -> {
                        int countLeft = dailyTrainTicket.getRw() - 1;
                        if (countLeft < 0) {
                            throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_TICKET_COUNT_ERROR);
                        }
                        dailyTrainTicket.setRw(countLeft);
                    }
                    case YW -> {
                        int countLeft = dailyTrainTicket.getYw() - 1;
                        if (countLeft < 0) {
                            throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_TICKET_COUNT_ERROR);
                        }
                        dailyTrainTicket.setYw(countLeft);
                    }
                }
            }
        }
    
        /**
         * 降级方法,需包含限流方法的所有参数和BlockException参数
         * @param req
         * @param e
         */
        public void doConfirmBlock(ConfirmOrderDoReq req, BlockException e) {
            LOG.info("购票请求被限流:{}", req);
            throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_FLOW_EXCEPTION);
        }
    }
    
  • com.neilxu.train.business.mq.ConfirmOrderConsumer

    package com.neilxu.train.business.mq;
    
    import com.alibaba.fastjson.JSON;
    import com.neilxu.train.business.dto.ConfirmOrderMQDto;
    import com.neilxu.train.business.service.ConfirmOrderService;
    import jakarta.annotation.Resource;
    import org.apache.rocketmq.common.message.MessageExt;
    import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
    import org.apache.rocketmq.spring.core.RocketMQListener;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.slf4j.MDC;
    import org.springframework.stereotype.Service;
    
    @Service
    @RocketMQMessageListener(consumerGroup = "default", topic = "CONFIRM_ORDER")
    public class ConfirmOrderConsumer implements RocketMQListener<MessageExt> {
    
        private static final Logger LOG = LoggerFactory.getLogger(ConfirmOrderConsumer.class);
    
        @Resource
        private ConfirmOrderService confirmOrderService;
    
        @Override
        public void onMessage(MessageExt messageExt) {
            byte[] body = messageExt.getBody();
            ConfirmOrderMQDto dto = JSON.parseObject(new String(body), ConfirmOrderMQDto.class);
            MDC.put("LOG_ID", dto.getLogId());
            LOG.info("ROCKETMQ收到消息:{}", new String(body));
            confirmOrderService.doConfirm(dto);
        }
    }
    

3.要对某一订单开始出票时,先把它更新成处理中,避免重复处理

ConfirmOrderService.java

/**
 * 更新状态
 * @param confirmOrder
 */
public void updateStatus(ConfirmOrder confirmOrder) {
    ConfirmOrder confirmOrderForUpdate = new ConfirmOrder();
    confirmOrderForUpdate.setId(confirmOrder.getId());
    confirmOrderForUpdate.setUpdateTime(new Date());
    confirmOrderForUpdate.setStatus(confirmOrder.getStatus());
    confirmOrderMapper.updateByPrimaryKeySelective(confirmOrderForUpdate);
}

/**
 * 售票
 * @param confirmOrder
 */
private void sell(ConfirmOrder confirmOrder) {
    // 构造ConfirmOrderDoReq
    ConfirmOrderDoReq req = new ConfirmOrderDoReq();
    req.setMemberId(confirmOrder.getMemberId());
    req.setDate(confirmOrder.getDate());
    req.setTrainCode(confirmOrder.getTrainCode());
    req.setStart(confirmOrder.getStart());
    req.setEnd(confirmOrder.getEnd());
    req.setDailyTrainTicketId(confirmOrder.getDailyTrainTicketId());
    req.setTickets(JSON.parseArray(confirmOrder.getTickets(), ConfirmOrderTicketReq.class));
    req.setImageCode("");
    req.setImageCodeToken("");
    req.setLogId("");

    // 省略业务数据校验,如:车次是否存在,余票是否存在,车次是否在有效期内,tickets条数>0,同乘客同车次是否已买过

    // 将订单设置成处理中,避免重复处理
    LOG.info("将确认订单更新成处理中,避免重复处理,confirm_order.id: {}", confirmOrder.getId());
    confirmOrder.setStatus(ConfirmOrderStatusEnum.PENDING.getCode());
    updateStatus(confirmOrder);

    Date date = req.getDate();
    String trainCode = req.getTrainCode();
    String start = req.getStart();
    String end = req.getEnd();
    List<ConfirmOrderTicketReq> tickets = req.getTickets();
    //
    // // 保存确认订单表,状态初始
    // DateTime now = DateTime.now();
    // ConfirmOrder confirmOrder = new ConfirmOrder();
    // confirmOrder.setId(SnowUtil.getSnowflakeNextId());
    // confirmOrder.setCreateTime(now);
    // confirmOrder.setUpdateTime(now);
    // confirmOrder.setMemberId(req.getMemberId());
    // confirmOrder.setDate(date);
    // confirmOrder.setTrainCode(trainCode);
    // confirmOrder.setStart(start);
    // confirmOrder.setEnd(end);
    // confirmOrder.setDailyTrainTicketId(req.getDailyTrainTicketId());
    // confirmOrder.setStatus(ConfirmOrderStatusEnum.INIT.getCode());
    // confirmOrder.setTickets(JSON.toJSONString(tickets));
    // confirmOrderMapper.insert(confirmOrder);

    // // 从数据库里查出订单
    // ConfirmOrderExample confirmOrderExample = new ConfirmOrderExample();
    // confirmOrderExample.setOrderByClause("id asc");
    // ConfirmOrderExample.Criteria criteria = confirmOrderExample.createCriteria();
    // criteria.andDateEqualTo(req.getDate())
    //         .andTrainCodeEqualTo(req.getTrainCode())
    //         .andStatusEqualTo(ConfirmOrderStatusEnum.INIT.getCode());
    // List<ConfirmOrder> list = confirmOrderMapper.selectByExampleWithBLOBs(confirmOrderExample);
    // ConfirmOrder confirmOrder;
    // if (CollUtil.isEmpty(list)) {
    //     LOG.info("找不到原始订单,结束");
    //     return;
    // } else {
    //     LOG.info("本次处理{}条确认订单", list.size());
    //     confirmOrder = list.get(0);
    // }

    // 查出余票记录,需要得到真实的库存
    DailyTrainTicket dailyTrainTicket = dailyTrainTicketService.selectByUnique(date, trainCode, start, end);
    LOG.info("查出余票记录:{}", dailyTrainTicket);

    // 预扣减余票数量,并判断余票是否足够
    reduceTickets(req, dailyTrainTicket);

    // 最终的选座结果
    List<DailyTrainSeat> finalSeatList = new ArrayList<>();
    // 计算相对第一个座位的偏移值
    // 比如选择的是C1,D2,则偏移值是:[0,5]
    // 比如选择的是A1,B1,C1,则偏移值是:[0,1,2]
    ConfirmOrderTicketReq ticketReq0 = tickets.get(0);
    if (StrUtil.isNotBlank(ticketReq0.getSeat())) {
        LOG.info("本次购票有选座");
        // 查出本次选座的座位类型都有哪些列,用于计算所选座位与第一个座位的偏离值
        List<SeatColEnum> colEnumList = SeatColEnum.getColsByType(ticketReq0.getSeatTypeCode());
        LOG.info("本次选座的座位类型包含的列:{}", colEnumList);

        // 组成和前端两排选座一样的列表,用于作参照的座位列表,例:referSeatList = {A1, C1, D1, F1, A2, C2, D2, F2}
        List<String> referSeatList = new ArrayList<>();
        for (int i = 1; i <= 2; i++) {
            for (SeatColEnum seatColEnum : colEnumList) {
                referSeatList.add(seatColEnum.getCode() + i);
            }
        }
        LOG.info("用于作参照的两排座位:{}", referSeatList);

        List<Integer> offsetList = new ArrayList<>();
        // 绝对偏移值,即:在参照座位列表中的位置
        List<Integer> aboluteOffsetList = new ArrayList<>();
        for (ConfirmOrderTicketReq ticketReq : tickets) {
            int index = referSeatList.indexOf(ticketReq.getSeat());
            aboluteOffsetList.add(index);
        }
        LOG.info("计算得到所有座位的绝对偏移值:{}", aboluteOffsetList);
        for (Integer index : aboluteOffsetList) {
            int offset = index - aboluteOffsetList.get(0);
            offsetList.add(offset);
        }
        LOG.info("计算得到所有座位的相对第一个座位的偏移值:{}", offsetList);

        getSeat(finalSeatList,
                date,
                trainCode,
                ticketReq0.getSeatTypeCode(),
                ticketReq0.getSeat().split("")[0], // 从A1得到A
                offsetList,
                dailyTrainTicket.getStartIndex(),
                dailyTrainTicket.getEndIndex()
        );

    } else {
        LOG.info("本次购票没有选座");
        for (ConfirmOrderTicketReq ticketReq : tickets) {
            getSeat(finalSeatList,
                    date,
                    trainCode,
                    ticketReq.getSeatTypeCode(),
                    null,
                    null,
                    dailyTrainTicket.getStartIndex(),
                    dailyTrainTicket.getEndIndex()
            );
        }
    }

    LOG.info("最终选座:{}", finalSeatList);

    // 选中座位后事务处理:
    // 座位表修改售卖情况sell;
    // 余票详情表修改余票;
    // 为会员增加购票记录
    // 更新确认订单为成功
    try {
        afterConfirmOrderService.afterDoConfirm(dailyTrainTicket, finalSeatList, tickets, confirmOrder);
    } catch (Exception e) {
        LOG.error("保存购票信息失败", e);
        throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_EXCEPTION);
    }
}

4.某一订单余票不足时,继续售卖下一订单

ConfirmOrderService.java

// 一条一条的卖
list.forEach(confirmOrder -> {
    try {
        sell(confirmOrder);
    } catch (BusinessException e) {
        if (e.getE().equals(BusinessExceptionEnum.CONFIRM_ORDER_TICKET_COUNT_ERROR)) {
            LOG.info("本订单余票不足,继续售卖下一个订单");
            confirmOrder.setStatus(ConfirmOrderStatusEnum.EMPTY.getCode());
            updateStatus(confirmOrder);
        } else {
            throw e;
        }
    }
});

5.MQ消费里,没抢到锁的,表示有其它消费线程正在出票,不做任何处理

ConfirmOrderService.java

// 获取分布式锁
String lockKey = RedisKeyPreEnum.CONFIRM_ORDER + "-" + DateUtil.formatDate(dto.getDate()) + "-" + dto.getTrainCode();
// setIfAbsent就是对应redis的setnx
Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(lockKey, lockKey, 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(setIfAbsent)) {
    LOG.info("恭喜,抢到锁了!lockKey:{}", lockKey);
} else {
    // 只是没抢到锁,并不知道票抢完了没,所以提示稍候再试
    // LOG.info("很遗憾,没抢到锁!lockKey:{}", lockKey);
    // throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_LOCK_FAIL);

    LOG.info("没抢到锁,有其它消费线程正在出票,不做任何处理");
    return;
}

6.测试

  • 针对开发环境做修改,dev环境不校验图片验证码,不校验令牌锁,方便压测

    ConfirmOrderController.java

    package com.neilxu.train.business.controller;
    
    import com.alibaba.csp.sentinel.annotation.SentinelResource;
    import com.alibaba.csp.sentinel.slots.block.BlockException;
    import com.neilxu.train.business.req.ConfirmOrderDoReq;
    import com.neilxu.train.business.service.BeforeConfirmOrderService;
    import com.neilxu.train.common.exception.BusinessExceptionEnum;
    import com.neilxu.train.common.resp.CommonResp;
    import jakarta.annotation.Resource;
    import jakarta.validation.Valid;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.util.ObjectUtils;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @RestController
    @RequestMapping("/confirm-order")
    public class ConfirmOrderController {
    
        private static final Logger LOG = LoggerFactory.getLogger(ConfirmOrderController.class);
    
        @Resource
        private BeforeConfirmOrderService beforeConfirmOrderService;
    
        @Autowired
        private StringRedisTemplate redisTemplate;
    
        @Value("${spring.profiles.active}")
        private String env;
    
        // 接口的资源名称不要和接口路径一致,会导致限流后走不到降级方法中
        @SentinelResource(value = "confirmOrderDo", blockHandler = "doConfirmBlock")
        @PostMapping("/do")
        public CommonResp<Object> doConfirm(@Valid @RequestBody ConfirmOrderDoReq req) {
            if (!env.equals("dev")) {
                // 图形验证码校验
                String imageCodeToken = req.getImageCodeToken();
                String imageCode = req.getImageCode();
                String imageCodeRedis = redisTemplate.opsForValue().get(imageCodeToken);
                LOG.info("从redis中获取到的验证码:{}", imageCodeRedis);
                if (ObjectUtils.isEmpty(imageCodeRedis)) {
                    return new CommonResp<>(false, "验证码已过期", null);
                }
                // 验证码校验,大小写忽略,提升体验,比如Oo Vv Ww容易混
                if (!imageCodeRedis.equalsIgnoreCase(imageCode)) {
                    return new CommonResp<>(false, "验证码不正确", null);
                } else {
                    // 验证通过后,移除验证码
                    redisTemplate.delete(imageCodeToken);
                }
            }
    
            beforeConfirmOrderService.beforeDoConfirm(req);
            return new CommonResp<>();
        }
    
        /** 降级方法,需包含限流方法的所有参数和BlockException参数,且返回值要保持一致
         * @param req
         * @param e
         */
        public CommonResp<Object> doConfirmBlock(ConfirmOrderDoReq req, BlockException e) {
            LOG.info("ConfirmOrderController购票请求被限流:{}", req);
            // throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_FLOW_EXCEPTION);
            CommonResp<Object> commonResp = new CommonResp<>();
            commonResp.setSuccess(false);
            commonResp.setMessage(BusinessExceptionEnum.CONFIRM_ORDER_FLOW_EXCEPTION.getDesc());
            return commonResp;
    
        }
    
    }
    

    SkTokenService.java

    package com.neilxu.train.business.service;
    
    import cn.hutool.core.bean.BeanUtil;
    import cn.hutool.core.collection.CollUtil;
    import cn.hutool.core.date.DateTime;
    import cn.hutool.core.date.DateUtil;
    import cn.hutool.core.util.ObjectUtil;
    import com.github.pagehelper.PageHelper;
    import com.github.pagehelper.PageInfo;
    import com.neilxu.train.business.domain.SkToken;
    import com.neilxu.train.business.domain.SkTokenExample;
    import com.neilxu.train.business.enums.RedisKeyPreEnum;
    import com.neilxu.train.business.mapper.SkTokenMapper;
    import com.neilxu.train.business.mapper.cust.SkTokenMapperCust;
    import com.neilxu.train.business.req.SkTokenQueryReq;
    import com.neilxu.train.business.req.SkTokenSaveReq;
    import com.neilxu.train.business.resp.SkTokenQueryResp;
    import com.neilxu.train.common.resp.PageResp;
    import com.neilxu.train.common.util.SnowUtil;
    import jakarta.annotation.Resource;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.stereotype.Service;
    
    import java.util.Date;
    import java.util.List;
    import java.util.concurrent.TimeUnit;
    
    @Service
    public class SkTokenService {
    
        private static final Logger LOG = LoggerFactory.getLogger(SkTokenService.class);
    
        @Resource
        private SkTokenMapper skTokenMapper;
    
        @Resource
        private DailyTrainSeatService dailyTrainSeatService;
    
        @Resource
        private DailyTrainStationService dailyTrainStationService;
    
        @Resource
        private SkTokenMapperCust skTokenMapperCust;
    
        @Autowired
        private StringRedisTemplate redisTemplate;
    
        @Value("${spring.profiles.active}")
        private String env;
    
        /**
         * 初始化
         */
        public void genDaily(Date date, String trainCode) {
            LOG.info("删除日期【{}】车次【{}】的令牌记录", DateUtil.formatDate(date), trainCode);
            SkTokenExample skTokenExample = new SkTokenExample();
            skTokenExample.createCriteria().andDateEqualTo(date).andTrainCodeEqualTo(trainCode);
            skTokenMapper.deleteByExample(skTokenExample);
    
            DateTime now = DateTime.now();
            SkToken skToken = new SkToken();
            skToken.setDate(date);
            skToken.setTrainCode(trainCode);
            skToken.setId(SnowUtil.getSnowflakeNextId());
            skToken.setCreateTime(now);
            skToken.setUpdateTime(now);
    
            int seatCount = dailyTrainSeatService.countSeat(date, trainCode);
            LOG.info("车次【{}】座位数:{}", trainCode, seatCount);
    
            long stationCount = dailyTrainStationService.countByTrainCode(date, trainCode);
            LOG.info("车次【{}】到站数:{}", trainCode, stationCount);
    
            // 3/4 需要根据实际卖票比例来定,一趟火车最多可以卖(seatCount * (stationCount-1))张火车票
    //        int count = (int) (seatCount * (stationCount-1) * 3/4);
            int count = (int) (seatCount * (stationCount-1));
            LOG.info("车次【{}】初始生成令牌数:{}", trainCode, count);
            skToken.setCount(count);
    
            skTokenMapper.insert(skToken);
        }
    
        public void save(SkTokenSaveReq req) {
            DateTime now = DateTime.now();
            SkToken skToken = BeanUtil.copyProperties(req, SkToken.class);
            if (ObjectUtil.isNull(skToken.getId())) {
                skToken.setId(SnowUtil.getSnowflakeNextId());
                skToken.setCreateTime(now);
                skToken.setUpdateTime(now);
                skTokenMapper.insert(skToken);
            } else {
                skToken.setUpdateTime(now);
                skTokenMapper.updateByPrimaryKey(skToken);
            }
        }
    
        public PageResp<SkTokenQueryResp> queryList(SkTokenQueryReq req) {
            SkTokenExample skTokenExample = new SkTokenExample();
            skTokenExample.setOrderByClause("id desc");
            SkTokenExample.Criteria criteria = skTokenExample.createCriteria();
    
            LOG.info("查询页码:{}", req.getPage());
            LOG.info("每页条数:{}", req.getSize());
            PageHelper.startPage(req.getPage(), req.getSize());
            List<SkToken> skTokenList = skTokenMapper.selectByExample(skTokenExample);
    
            PageInfo<SkToken> pageInfo = new PageInfo<>(skTokenList);
            LOG.info("总行数:{}", pageInfo.getTotal());
            LOG.info("总页数:{}", pageInfo.getPages());
    
            List<SkTokenQueryResp> list = BeanUtil.copyToList(skTokenList, SkTokenQueryResp.class);
    
            PageResp<SkTokenQueryResp> pageResp = new PageResp<>();
            pageResp.setTotal(pageInfo.getTotal());
            pageResp.setList(list);
            return pageResp;
        }
    
        public void delete(Long id) {
            skTokenMapper.deleteByPrimaryKey(id);
        }
    
        /**
         * 校验令牌
         */
        public boolean validSkToken(Date date, String trainCode, Long memberId) {
            LOG.info("会员【{}】获取日期【{}】车次【{}】的令牌开始", memberId, DateUtil.formatDate(date), trainCode);
    
            if (!env.equals("dev")) {
                // 先获取令牌锁,再校验令牌余量,防止机器人抢票,lockKey就是令牌,用来表示【谁能做什么】的一个凭证
                String lockKey = RedisKeyPreEnum.SK_TOKEN + "-" + DateUtil.formatDate(date) + "-" + trainCode + "-" + memberId;
                Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(lockKey, lockKey, 5, TimeUnit.SECONDS);
                if (Boolean.TRUE.equals(setIfAbsent)) {
                    LOG.info("恭喜,抢到令牌锁了!lockKey:{}", lockKey);
                } else {
                    LOG.info("很遗憾,没抢到令牌锁!lockKey:{}", lockKey);
                    return false;
                }
            }
    
            String skTokenCountKey = RedisKeyPreEnum.SK_TOKEN_COUNT + "-" + DateUtil.formatDate(date) + "-" + trainCode;
            Object skTokenCount = redisTemplate.opsForValue().get(skTokenCountKey);
            if (skTokenCount != null) {
                LOG.info("缓存中有该车次令牌大闸的key:{}", skTokenCountKey);
                Long count = redisTemplate.opsForValue().decrement(skTokenCountKey, 1);
                redisTemplate.expire(skTokenCountKey, 60, TimeUnit.SECONDS);
                if (count < 0L) {
                    LOG.error("获取令牌失败:{}", skTokenCountKey);
                    return false;
                } else {
                    LOG.info("获取令牌后,令牌余数:{}", count);
                    // 每获取5个令牌更新一次数据库
                    if (count % 5 == 0) {
                        skTokenMapperCust.decrease(date, trainCode, 5);
                    }
                    return true;
                }
            } else {
                LOG.info("缓存中没有该车次令牌大闸的key:{}", skTokenCountKey);
                // 检查是否还有令牌
                SkTokenExample skTokenExample = new SkTokenExample();
                skTokenExample.createCriteria().andDateEqualTo(date).andTrainCodeEqualTo(trainCode);
                List<SkToken> tokenCountList = skTokenMapper.selectByExample(skTokenExample);
                if (CollUtil.isEmpty(tokenCountList)) {
                    LOG.info("找不到日期【{}】车次【{}】的令牌记录", DateUtil.formatDate(date), trainCode);
                    return false;
                }
    
                SkToken skToken = tokenCountList.get(0);
                if (skToken.getCount() <= 0) {
                    LOG.info("日期【{}】车次【{}】的令牌余量为0", DateUtil.formatDate(date), trainCode);
                    return false;
                }
    
                // 令牌还有余量
                // 令牌余数-1
                Integer count = skToken.getCount() - 1;
                skToken.setCount(count);
                LOG.info("将该车次令牌大闸放入缓存中,key: {}, count: {}", skTokenCountKey, count);
                // 不需要更新数据库,只要放缓存即可
                redisTemplate.opsForValue().set(skTokenCountKey, String.valueOf(count), 60, TimeUnit.SECONDS);
    //            skTokenMapper.updateByPrimaryKey(skToken);
                return true;
            }
    
            // 令牌约等于库存,令牌没有了,就不再卖票,不需要再进入购票主流程去判断库存,判断令牌肯定比判断库存效率高
            // int updateCount = skTokenMapperCust.decrease(date, trainCode, 1);
            // if (updateCount > 0) {
            //     return true;
            // } else {
            //     return false;
            // }
        }
    }
    
  • JMeter压测

    500个线程同时抢下图的广州南-潮汕的8张一等座票,jmeter记得请求体里加上验证码和验证码token(随便填),不然会校验出空而失败

在这里插入图片描述

这里注意ramp-up时间默认是0,但是最好设置个值,比如1,不然会出现网络连接失败的问题(目前不太清楚是什么原因)

在这里插入图片描述

在这里插入图片描述

最终结果:票全部卖出去了,不会发生超卖,也没有少卖的问题(可以测试少一点线程,比如50个)

在这里插入图片描述

8.增加轮询购票结果功能

1.确认订单后,显示模态框:系统处理中

order.vue

<template>
  <div class="order-train">
    <span class="order-train-main">{{dailyTrainTicket.date}}</span>&nbsp;
    <span class="order-train-main">{{dailyTrainTicket.trainCode}}</span>次&nbsp;
    <span class="order-train-main">{{dailyTrainTicket.start}}</span>站
    <span class="order-train-main">({{dailyTrainTicket.startTime}})</span>&nbsp;
    <span class="order-train-main">——</span>&nbsp;
    <span class="order-train-main">{{dailyTrainTicket.end}}</span>站
    <span class="order-train-main">({{dailyTrainTicket.endTime}})</span>&nbsp;

    <div class="order-train-ticket">
      <span v-for="item in seatTypes" :key="item.type">
        <span>{{item.desc}}</span>:
        <span class="order-train-ticket-main">{{item.price}}¥</span>&nbsp;
        <span class="order-train-ticket-main">{{item.count}}</span>&nbsp;张票&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
      </span>
    </div>
  </div>
  <a-divider></a-divider>
  <b>勾选要购票的乘客:</b>&nbsp;
  <a-checkbox-group v-model:value="passengerChecks" :options="passengerOptions" />

  <div class="order-tickets">
    <a-row class="order-tickets-header" v-if="tickets.length > 0">
      <a-col :span="2">乘客</a-col>
      <a-col :span="6">身份证</a-col>
      <a-col :span="4">票种</a-col>
      <a-col :span="4">座位类型</a-col>
    </a-row>
    <a-row class="order-tickets-row" v-for="ticket in tickets" :key="ticket.passengerId">
      <a-col :span="2">{{ticket.passengerName}}</a-col>
      <a-col :span="6">{{ticket.passengerIdCard}}</a-col>
      <a-col :span="4">
        <a-select v-model:value="ticket.passengerType" style="width: 100%">
          <a-select-option v-for="item in PASSENGER_TYPE_ARRAY" :key="item.code" :value="item.code">
            {{item.desc}}
          </a-select-option>
        </a-select>
      </a-col>
      <a-col :span="4">
        <a-select v-model:value="ticket.seatTypeCode" style="width: 100%">
          <a-select-option v-for="item in seatTypes" :key="item.code" :value="item.code">
            {{item.desc}}
          </a-select-option>
        </a-select>
      </a-col>
    </a-row>
  </div>
  <div v-if="tickets.length > 0">
    <a-button type="primary" size="large" @click="finishCheckPassenger">提交订单</a-button>
  </div>

  <a-modal v-model:visible="visible" title="请核对以下信息"
           style="top: 50px; width: 800px"
           ok-text="确认" cancel-text="取消"
           @ok="showFirstImageCodeModal">
    <div class="order-tickets">
      <a-row class="order-tickets-header" v-if="tickets.length > 0">
        <a-col :span="3">乘客</a-col>
        <a-col :span="15">身份证</a-col>
        <a-col :span="3">票种</a-col>
        <a-col :span="3">座位类型</a-col>
      </a-row>
      <a-row class="order-tickets-row" v-for="ticket in tickets" :key="ticket.passengerId">
        <a-col :span="3">{{ticket.passengerName}}</a-col>
        <a-col :span="15">{{ticket.passengerIdCard}}</a-col>
        <a-col :span="3">
          <span v-for="item in PASSENGER_TYPE_ARRAY" :key="item.code">
            <span v-if="item.code === ticket.passengerType">
              {{item.desc}}
            </span>
          </span>
        </a-col>
        <a-col :span="3">
          <span v-for="item in seatTypes" :key="item.code">
            <span v-if="item.code === ticket.seatTypeCode">
              {{item.desc}}
            </span>
          </span>
        </a-col>
      </a-row>
      <br/>
      <div v-if="chooseSeatType === 0" style="color: red;">
        您购买的车票不支持选座
        <div>12306规则:只有全部是一等座或全部是二等座才支持选座</div>
        <div>12306规则:余票小于一定数量时,不允许选座(本项目以20为例)</div>
      </div>
      <div v-else style="text-align: center">
        <a-switch class="choose-seat-item" v-for="item in SEAT_COL_ARRAY" :key="item.code"
                  v-model:checked="chooseSeatObj[item.code + '1']" :checked-children="item.desc" :un-checked-children="item.desc" />
        <div v-if="tickets.length > 1">
          <a-switch class="choose-seat-item" v-for="item in SEAT_COL_ARRAY" :key="item.code"
                    v-model:checked="chooseSeatObj[item.code + '2']" :checked-children="item.desc" :un-checked-children="item.desc" />
        </div>
        <div style="color: #999999">提示:您可以选择{{tickets.length}}个座位</div>
      </div>
      <!--<br/>-->
      <!--最终购票:{{tickets}}-->
      <!--最终选座:{{chooseSeatObj}}-->
    </div>
  </a-modal>

  <!-- 第二层验证码 后端 -->
  <a-modal v-model:visible="imageCodeModalVisible" :title="null" :footer="null" :closable="false"
           style="top: 50px; width: 400px">
    <p style="text-align: center; font-weight: bold; font-size: 18px">
      使用服务端验证码削弱瞬时高峰<br/>
      防止机器人刷票
    </p>
    <p>
      <a-input v-model:value="imageCode" placeholder="图片验证码">
        <template #suffix>
          <img v-show="!!imageCodeSrc" :src="imageCodeSrc" alt="验证码" v-on:click="loadImageCode()"/>
        </template>
      </a-input>
    </p>
    <a-button type="danger" block @click="handleOk">输入验证码后开始购票</a-button>
  </a-modal>

  <!-- 第一层验证码 纯前端 -->
  <a-modal v-model:visible="firstImageCodeModalVisible" :title="null" :footer="null" :closable="false"
           style="top: 50px; width: 400px">
    <p style="text-align: center; font-weight: bold; font-size: 18px">
      使用纯前端验证码削弱瞬时高峰<br/>
      减小后端验证码接口的压力
    </p>
    <p>
      <a-input v-model:value="firstImageCodeTarget" placeholder="验证码">
        <template #suffix>
          {{firstImageCodeSourceA}} + {{firstImageCodeSourceB}}
        </template>
      </a-input>
    </p>
    <a-button type="danger" block @click="validFirstImageCode">提交验证码</a-button>
  </a-modal>

  <a-modal v-model:visible="lineModalVisible" :title="null" :footer="null" :maskClosable="false" :closable="false"
           style="top: 50px; width: 400px">
    <div class="book-line">
      <loading-outlined /> 系统正在处理中...
    </div>
  </a-modal>
</template>

<script>

import {defineComponent, ref, onMounted, watch, computed} from 'vue';
import axios from "axios";
import {notification} from "ant-design-vue";

export default defineComponent({
  name: "order-view",
  setup() {
    const passengers = ref([]);
    const passengerOptions = ref([]);
    const passengerChecks = ref([]);
    const dailyTrainTicket = SessionStorage.get(SESSION_ORDER) || {};
    console.log("下单的车次信息", dailyTrainTicket);

    const SEAT_TYPE = window.SEAT_TYPE;
    console.log(SEAT_TYPE)
    // 本车次提供的座位类型seatTypes,含票价,余票等信息,例:
    // {
    //   type: "YDZ",
    //   code: "1",
    //   desc: "一等座",
    //   count: "100",
    //   price: "50",
    // }
    // 关于SEAT_TYPE[KEY]:当知道某个具体的属性xxx时,可以用obj.xxx,当属性名是个变量时,可以使用obj[xxx]
    const seatTypes = [];
    for (let KEY in SEAT_TYPE) {
      let key = KEY.toLowerCase();
      if (dailyTrainTicket[key] >= 0) {
        seatTypes.push({
          type: KEY,
          code: SEAT_TYPE[KEY]["code"],
          desc: SEAT_TYPE[KEY]["desc"],
          count: dailyTrainTicket[key],
          price: dailyTrainTicket[key + 'Price'],
        })
      }
    }
    console.log("本车次提供的座位:", seatTypes)
    // 购票列表,用于界面展示,并传递到后端接口,用来描述:哪个乘客购买什么座位的票
    // {
    //   passengerId: 123,
    //   passengerType: "1",
    //   passengerName: "张三",
    //   passengerIdCard: "12323132132",
    //   seatTypeCode: "1",
    //   seat: "C1"
    // }
    const tickets = ref([]);
    const PASSENGER_TYPE_ARRAY = window.PASSENGER_TYPE_ARRAY;
    const visible = ref(false);
    const lineModalVisible = ref(false);

    // 勾选或去掉某个乘客时,在购票列表中加上或去掉一张表
    watch(() => passengerChecks.value, (newVal, oldVal)=>{
      console.log("勾选乘客发生变化", newVal, oldVal)
      // 每次有变化时,把购票列表清空,重新构造列表
      tickets.value = [];
      passengerChecks.value.forEach((item) => tickets.value.push({
        passengerId: item.id,
        passengerType: item.type,
        seatTypeCode: seatTypes[0].code,
        passengerName: item.name,
        passengerIdCard: item.idCard
      }))
    }, {immediate: true});

    // 0:不支持选座;1:选一等座;2:选二等座
    const chooseSeatType = ref(0);
    // 根据选择的座位类型,计算出对应的列,比如要选的是一等座,就筛选出ACDF,要选的是二等座,就筛选出ABCDF
    const SEAT_COL_ARRAY = computed(() => {
      return window.SEAT_COL_ARRAY.filter(item => item.type === chooseSeatType.value);
    });
    // 选择的座位
    // {
    //   A1: false, C1: true,D1: false, F1: false,
    //   A2: false, C2: false,D2: true, F2: false
    // }
    const chooseSeatObj = ref({});
    watch(() => SEAT_COL_ARRAY.value, () => {
      chooseSeatObj.value = {};
      for (let i = 1; i <= 2; i++) {
        SEAT_COL_ARRAY.value.forEach((item) => {
          chooseSeatObj.value[item.code + i] = false;
        })
      }
      console.log("初始化两排座位,都是未选中:", chooseSeatObj.value);
    }, {immediate: true});

    const handleQueryPassenger = () => {
      axios.get("/member/passenger/query-mine").then((response) => {
        let data = response.data;
        if (data.success) {
          passengers.value = data.content;
          passengers.value.forEach((item) => passengerOptions.value.push({
            label: item.name,
            value: item
          }))
        } else {
          notification.error({description: data.message});
        }
      });
    };

    const finishCheckPassenger = () => {
      console.log("购票列表:", tickets.value);

      if (tickets.value.length > 5) {
        notification.error({description: '最多只能购买5张车票'});
        return;
      }

      // 校验余票是否充足,购票列表中的每个座位类型,都去车次座位余票信息中,看余票是否充足
      // 前端校验不一定准,但前端校验可以减轻后端很多压力
      // 注意:这段只是校验,必须copy出seatTypesTemp变量来扣减,用原始的seatTypes去扣减,会影响真实的库存
      let seatTypesTemp = Tool.copy(seatTypes);
      for (let i = 0; i < tickets.value.length; i++) {
        let ticket = tickets.value[i];
        for (let j = 0; j < seatTypesTemp.length; j++) {
          let seatType = seatTypesTemp[j];
          // 同类型座位余票-1,这里扣减的是临时copy出来的库存,不是真正的库存,只是为了校验
          if (ticket.seatTypeCode === seatType.code) {
            seatType.count--;
            if (seatType.count < 0) {
              notification.error({description: seatType.desc + '余票不足'});
              return;
            }
          }
        }
      }
      console.log("前端余票校验通过");

      // 判断是否支持选座,只有纯一等座和纯二等座支持选座
      // 先筛选出购票列表中的所有座位类型,比如四张表:[1, 1, 2, 2]
      let ticketSeatTypeCodes = [];
      for (let i = 0; i < tickets.value.length; i++) {
        let ticket = tickets.value[i];
        ticketSeatTypeCodes.push(ticket.seatTypeCode);
      }
      // 为购票列表中的所有座位类型去重:[1, 2]
      const ticketSeatTypeCodesSet = Array.from(new Set(ticketSeatTypeCodes));
      console.log("选好的座位类型:", ticketSeatTypeCodesSet);
      if (ticketSeatTypeCodesSet.length !== 1) {
        console.log("选了多种座位,不支持选座");
        chooseSeatType.value = 0;
      } else {
        // ticketSeatTypeCodesSet.length === 1,即只选择了一种座位(不是一个座位,是一种座位)
        if (ticketSeatTypeCodesSet[0] === SEAT_TYPE.YDZ.code) {
          console.log("一等座选座");
          chooseSeatType.value = SEAT_TYPE.YDZ.code;
        } else if (ticketSeatTypeCodesSet[0] === SEAT_TYPE.EDZ.code) {
          console.log("二等座选座");
          chooseSeatType.value = SEAT_TYPE.EDZ.code;
        } else {
          console.log("不是一等座或二等座,不支持选座");
          chooseSeatType.value = 0;
        }

        // 余票小于20张时,不允许选座,否则选座成功率不高,影响出票
        if (chooseSeatType.value !== 0) {
          for (let i = 0; i < seatTypes.length; i++) {
            let seatType = seatTypes[i];
            // 找到同类型座位
            if (ticketSeatTypeCodesSet[0] === seatType.code) {
              // 判断余票,小于20张就不支持选座
              if (seatType.count < 20) {
                console.log("余票小于20张就不支持选座")
                chooseSeatType.value = 0;
                break;
              }
            }
          }
        }
      }

      // 弹出确认界面
      visible.value = true;

    };

    const handleOk = () => {
      if (Tool.isEmpty(imageCode.value)) {
        notification.error({description: '验证码不能为空'});
        return;
      }

      console.log("选好的座位:", chooseSeatObj.value);

      // 设置每张票的座位
      // 先清空购票列表的座位,有可能之前选了并设置座位了,但选座数不对被拦截了,又重新选一遍
      for (let i = 0; i < tickets.value.length; i++) {
        tickets.value[i].seat = null;
      }
      let i = -1;
      // 要么不选座位,要么所选座位应该等于购票数,即i === (tickets.value.length - 1)
      for (let key in chooseSeatObj.value) {
        if (chooseSeatObj.value[key]) {
          i++;
          if (i > tickets.value.length - 1) {
            notification.error({description: '所选座位数大于购票数'});
            return;
          }
          tickets.value[i].seat = key;
        }
      }
      if (i > -1 && i < (tickets.value.length - 1)) {
        notification.error({description: '所选座位数小于购票数'});
        return;
      }

      console.log("最终购票:", tickets.value);

      axios.post("/business/confirm-order/do", {
        dailyTrainTicketId: dailyTrainTicket.id,
        date: dailyTrainTicket.date,
        trainCode: dailyTrainTicket.trainCode,
        start: dailyTrainTicket.start,
        end: dailyTrainTicket.end,
        tickets: tickets.value,
        imageCodeToken: imageCodeToken.value,
        imageCode: imageCode.value,
      }).then((response) => {
        let data = response.data;
        if (data.success) {
          // notification.success({description: "下单成功!"});
          visible.value = false;
          imageCodeModalVisible.value = false;
          lineModalVisible.value = true;
        } else {
          notification.error({description: data.message});
        }
      });
    }

    /* ------------------- 第二层验证码 --------------------- */
    const imageCodeModalVisible = ref();
    const imageCodeToken = ref();
    const imageCodeSrc = ref();
    const imageCode = ref();
    /**
     * 加载图形验证码
     */
    const loadImageCode = () => {
      imageCodeToken.value = Tool.uuid(8);
      imageCodeSrc.value = process.env.VUE_APP_SERVER + '/business/kaptcha/image-code/' + imageCodeToken.value;
    };

    const showImageCodeModal = () => {
      loadImageCode();
      imageCodeModalVisible.value = true;
    };

    /* ------------------- 第一层验证码 --------------------- */
    const firstImageCodeSourceA = ref();
    const firstImageCodeSourceB = ref();
    const firstImageCodeTarget = ref();
    const firstImageCodeModalVisible = ref();

    /**
     * 加载第一层验证码
     */
    const loadFirstImageCode = () => {
      // 获取1~10的数:Math.floor(Math.random()*10 + 1)
      firstImageCodeSourceA.value = Math.floor(Math.random()*10 + 1) + 10;
      firstImageCodeSourceB.value = Math.floor(Math.random()*10 + 1) + 20;
    };

    /**
     * 显示第一层验证码弹出框
     */
    const showFirstImageCodeModal = () => {
      loadFirstImageCode();
      firstImageCodeModalVisible.value = true;
    };

    /**
     * 校验第一层验证码
     */
    const validFirstImageCode = () => {
      if (parseInt(firstImageCodeTarget.value) === parseInt(firstImageCodeSourceA.value + firstImageCodeSourceB.value)) {
        // 第一层验证通过
        firstImageCodeModalVisible.value = false;
        showImageCodeModal();
      } else {
        notification.error({description: '验证码错误'});
      }
    };

    onMounted(() => {
      handleQueryPassenger();
    });

    return {
      passengers,
      dailyTrainTicket,
      seatTypes,
      passengerOptions,
      passengerChecks,
      tickets,
      PASSENGER_TYPE_ARRAY,
      visible,
      finishCheckPassenger,
      chooseSeatType,
      chooseSeatObj,
      SEAT_COL_ARRAY,
      handleOk,
      imageCodeToken,
      imageCodeSrc,
      imageCode,
      showImageCodeModal,
      imageCodeModalVisible,
      loadImageCode,
      firstImageCodeSourceA,
      firstImageCodeSourceB,
      firstImageCodeTarget,
      firstImageCodeModalVisible,
      showFirstImageCodeModal,
      validFirstImageCode,
      lineModalVisible
    };
  },
});
</script>

<style>
.order-train .order-train-main {
  font-size: 18px;
  font-weight: bold;
}
.order-train .order-train-ticket {
  margin-top: 15px;
}
.order-train .order-train-ticket .order-train-ticket-main {
  color: red;
  font-size: 18px;
}

.order-tickets {
  margin: 10px 0;
}
.order-tickets .ant-col {
  padding: 5px 10px;
}
.order-tickets .order-tickets-header {
  background-color: cornflowerblue;
  border: solid 1px cornflowerblue;
  color: white;
  font-size: 16px;
  padding: 5px 0;
}
.order-tickets .order-tickets-row {
  border: solid 1px cornflowerblue;
  border-top: none;
  vertical-align: middle;
  line-height: 30px;
}

.order-tickets .choose-seat-item {
  margin: 5px 5px;
}
</style>

2.确认订单接口返回确认订单ID,方便后续做排队查询

  • BeforeConfirmOrderService.java

    @SentinelResource(value = "beforeDoConfirm", blockHandler = "beforeDoConfirmBlock")
    public Long beforeDoConfirm(ConfirmOrderDoReq req) {
        req.setMemberId(LoginMemberContext.getId());
        // 校验令牌余量
        boolean validSkToken = skTokenService.validSkToken(req.getDate(), req.getTrainCode(), LoginMemberContext.getId());
        if (validSkToken) {
            LOG.info("令牌校验通过");
        } else {
            LOG.info("令牌校验不通过");
            throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_SK_TOKEN_FAIL);
        }
    
        Date date = req.getDate();
        String trainCode = req.getTrainCode();
        String start = req.getStart();
        String end = req.getEnd();
        List<ConfirmOrderTicketReq> tickets = req.getTickets();
    
        // 保存确认订单表,状态初始
        DateTime now = DateTime.now();
        ConfirmOrder confirmOrder = new ConfirmOrder();
        confirmOrder.setId(SnowUtil.getSnowflakeNextId());
        confirmOrder.setCreateTime(now);
        confirmOrder.setUpdateTime(now);
        confirmOrder.setMemberId(req.getMemberId());
        confirmOrder.setDate(date);
        confirmOrder.setTrainCode(trainCode);
        confirmOrder.setStart(start);
        confirmOrder.setEnd(end);
        confirmOrder.setDailyTrainTicketId(req.getDailyTrainTicketId());
        confirmOrder.setStatus(ConfirmOrderStatusEnum.INIT.getCode());
        confirmOrder.setTickets(JSON.toJSONString(tickets));
        confirmOrderMapper.insert(confirmOrder);
    
        // 发送MQ排队购票
        ConfirmOrderMQDto confirmOrderMQDto = new ConfirmOrderMQDto();
        confirmOrderMQDto.setDate(req.getDate());
        confirmOrderMQDto.setTrainCode(req.getTrainCode());
        confirmOrderMQDto.setLogId(MDC.get("LOG_ID"));
        String reqJson = JSON.toJSONString(confirmOrderMQDto);
        LOG.info("排队购票,发送mq开始,消息:{}", reqJson);
        rocketMQTemplate.convertAndSend(RocketMQTopicEnum.CONFIRM_ORDER.getCode(), reqJson);
        LOG.info("排队购票,发送mq结束");
    
        return confirmOrder.getId();
    
    }
    
  • ConfirmOrderController.java

    @SentinelResource(value = "confirmOrderDo", blockHandler = "doConfirmBlock")
    @PostMapping("/do")
    public CommonResp<Object> doConfirm(@Valid @RequestBody ConfirmOrderDoReq req) {
        if (!env.equals("dev")) {
            // 图形验证码校验
            String imageCodeToken = req.getImageCodeToken();
            String imageCode = req.getImageCode();
            String imageCodeRedis = redisTemplate.opsForValue().get(imageCodeToken);
            LOG.info("从redis中获取到的验证码:{}", imageCodeRedis);
            if (ObjectUtils.isEmpty(imageCodeRedis)) {
                return new CommonResp<>(false, "验证码已过期", null);
            }
            // 验证码校验,大小写忽略,提升体验,比如Oo Vv Ww容易混
            if (!imageCodeRedis.equalsIgnoreCase(imageCode)) {
                return new CommonResp<>(false, "验证码不正确", null);
            } else {
                // 验证通过后,移除验证码
                redisTemplate.delete(imageCodeToken);
            }
        }
    
        Long id = beforeConfirmOrderService.beforeDoConfirm(req);
        return new CommonResp<>(String.valueOf(id));
    }
    
  • order.vue

    <template>
      <div class="order-train">
        <span class="order-train-main">{{dailyTrainTicket.date}}</span>&nbsp;
        <span class="order-train-main">{{dailyTrainTicket.trainCode}}</span>次&nbsp;
        <span class="order-train-main">{{dailyTrainTicket.start}}</span>站
        <span class="order-train-main">({{dailyTrainTicket.startTime}})</span>&nbsp;
        <span class="order-train-main">——</span>&nbsp;
        <span class="order-train-main">{{dailyTrainTicket.end}}</span>站
        <span class="order-train-main">({{dailyTrainTicket.endTime}})</span>&nbsp;
    
        <div class="order-train-ticket">
          <span v-for="item in seatTypes" :key="item.type">
            <span>{{item.desc}}</span>:
            <span class="order-train-ticket-main">{{item.price}}¥</span>&nbsp;
            <span class="order-train-ticket-main">{{item.count}}</span>&nbsp;张票&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
          </span>
        </div>
      </div>
      <a-divider></a-divider>
      <b>勾选要购票的乘客:</b>&nbsp;
      <a-checkbox-group v-model:value="passengerChecks" :options="passengerOptions" />
    
      <div class="order-tickets">
        <a-row class="order-tickets-header" v-if="tickets.length > 0">
          <a-col :span="2">乘客</a-col>
          <a-col :span="6">身份证</a-col>
          <a-col :span="4">票种</a-col>
          <a-col :span="4">座位类型</a-col>
        </a-row>
        <a-row class="order-tickets-row" v-for="ticket in tickets" :key="ticket.passengerId">
          <a-col :span="2">{{ticket.passengerName}}</a-col>
          <a-col :span="6">{{ticket.passengerIdCard}}</a-col>
          <a-col :span="4">
            <a-select v-model:value="ticket.passengerType" style="width: 100%">
              <a-select-option v-for="item in PASSENGER_TYPE_ARRAY" :key="item.code" :value="item.code">
                {{item.desc}}
              </a-select-option>
            </a-select>
          </a-col>
          <a-col :span="4">
            <a-select v-model:value="ticket.seatTypeCode" style="width: 100%">
              <a-select-option v-for="item in seatTypes" :key="item.code" :value="item.code">
                {{item.desc}}
              </a-select-option>
            </a-select>
          </a-col>
        </a-row>
      </div>
      <div v-if="tickets.length > 0">
        <a-button type="primary" size="large" @click="finishCheckPassenger">提交订单</a-button>
      </div>
    
      <a-modal v-model:visible="visible" title="请核对以下信息"
               style="top: 50px; width: 800px"
               ok-text="确认" cancel-text="取消"
               @ok="showFirstImageCodeModal">
        <div class="order-tickets">
          <a-row class="order-tickets-header" v-if="tickets.length > 0">
            <a-col :span="3">乘客</a-col>
            <a-col :span="15">身份证</a-col>
            <a-col :span="3">票种</a-col>
            <a-col :span="3">座位类型</a-col>
          </a-row>
          <a-row class="order-tickets-row" v-for="ticket in tickets" :key="ticket.passengerId">
            <a-col :span="3">{{ticket.passengerName}}</a-col>
            <a-col :span="15">{{ticket.passengerIdCard}}</a-col>
            <a-col :span="3">
              <span v-for="item in PASSENGER_TYPE_ARRAY" :key="item.code">
                <span v-if="item.code === ticket.passengerType">
                  {{item.desc}}
                </span>
              </span>
            </a-col>
            <a-col :span="3">
              <span v-for="item in seatTypes" :key="item.code">
                <span v-if="item.code === ticket.seatTypeCode">
                  {{item.desc}}
                </span>
              </span>
            </a-col>
          </a-row>
          <br/>
          <div v-if="chooseSeatType === 0" style="color: red;">
            您购买的车票不支持选座
            <div>12306规则:只有全部是一等座或全部是二等座才支持选座</div>
            <div>12306规则:余票小于一定数量时,不允许选座(本项目以20为例)</div>
          </div>
          <div v-else style="text-align: center">
            <a-switch class="choose-seat-item" v-for="item in SEAT_COL_ARRAY" :key="item.code"
                      v-model:checked="chooseSeatObj[item.code + '1']" :checked-children="item.desc" :un-checked-children="item.desc" />
            <div v-if="tickets.length > 1">
              <a-switch class="choose-seat-item" v-for="item in SEAT_COL_ARRAY" :key="item.code"
                        v-model:checked="chooseSeatObj[item.code + '2']" :checked-children="item.desc" :un-checked-children="item.desc" />
            </div>
            <div style="color: #999999">提示:您可以选择{{tickets.length}}个座位</div>
          </div>
          <!--<br/>-->
          <!--最终购票:{{tickets}}-->
          <!--最终选座:{{chooseSeatObj}}-->
        </div>
      </a-modal>
    
      <!-- 第二层验证码 后端 -->
      <a-modal v-model:visible="imageCodeModalVisible" :title="null" :footer="null" :closable="false"
               style="top: 50px; width: 400px">
        <p style="text-align: center; font-weight: bold; font-size: 18px">
          使用服务端验证码削弱瞬时高峰<br/>
          防止机器人刷票
        </p>
        <p>
          <a-input v-model:value="imageCode" placeholder="图片验证码">
            <template #suffix>
              <img v-show="!!imageCodeSrc" :src="imageCodeSrc" alt="验证码" v-on:click="loadImageCode()"/>
            </template>
          </a-input>
        </p>
        <a-button type="danger" block @click="handleOk">输入验证码后开始购票</a-button>
      </a-modal>
    
      <!-- 第一层验证码 纯前端 -->
      <a-modal v-model:visible="firstImageCodeModalVisible" :title="null" :footer="null" :closable="false"
               style="top: 50px; width: 400px">
        <p style="text-align: center; font-weight: bold; font-size: 18px">
          使用纯前端验证码削弱瞬时高峰<br/>
          减小后端验证码接口的压力
        </p>
        <p>
          <a-input v-model:value="firstImageCodeTarget" placeholder="验证码">
            <template #suffix>
              {{firstImageCodeSourceA}} + {{firstImageCodeSourceB}}
            </template>
          </a-input>
        </p>
        <a-button type="danger" block @click="validFirstImageCode">提交验证码</a-button>
      </a-modal>
    
      <a-modal v-model:visible="lineModalVisible" :title="null" :footer="null" :maskClosable="false" :closable="false"
               style="top: 50px; width: 400px">
        <div class="book-line">
          <loading-outlined /> 确认订单:{{confirmOrderId}},系统正在处理中...
        </div>
      </a-modal>
    </template>
    
    <script>
    
    import {defineComponent, ref, onMounted, watch, computed} from 'vue';
    import axios from "axios";
    import {notification} from "ant-design-vue";
    
    export default defineComponent({
      name: "order-view",
      setup() {
        const passengers = ref([]);
        const passengerOptions = ref([]);
        const passengerChecks = ref([]);
        const dailyTrainTicket = SessionStorage.get(SESSION_ORDER) || {};
        console.log("下单的车次信息", dailyTrainTicket);
    
        const SEAT_TYPE = window.SEAT_TYPE;
        console.log(SEAT_TYPE)
        // 本车次提供的座位类型seatTypes,含票价,余票等信息,例:
        // {
        //   type: "YDZ",
        //   code: "1",
        //   desc: "一等座",
        //   count: "100",
        //   price: "50",
        // }
        // 关于SEAT_TYPE[KEY]:当知道某个具体的属性xxx时,可以用obj.xxx,当属性名是个变量时,可以使用obj[xxx]
        const seatTypes = [];
        for (let KEY in SEAT_TYPE) {
          let key = KEY.toLowerCase();
          if (dailyTrainTicket[key] >= 0) {
            seatTypes.push({
              type: KEY,
              code: SEAT_TYPE[KEY]["code"],
              desc: SEAT_TYPE[KEY]["desc"],
              count: dailyTrainTicket[key],
              price: dailyTrainTicket[key + 'Price'],
            })
          }
        }
        console.log("本车次提供的座位:", seatTypes)
        // 购票列表,用于界面展示,并传递到后端接口,用来描述:哪个乘客购买什么座位的票
        // {
        //   passengerId: 123,
        //   passengerType: "1",
        //   passengerName: "张三",
        //   passengerIdCard: "12323132132",
        //   seatTypeCode: "1",
        //   seat: "C1"
        // }
        const tickets = ref([]);
        const PASSENGER_TYPE_ARRAY = window.PASSENGER_TYPE_ARRAY;
        const visible = ref(false);
        const lineModalVisible = ref(false);
        const confirmOrderId = ref();
    
        // 勾选或去掉某个乘客时,在购票列表中加上或去掉一张表
        watch(() => passengerChecks.value, (newVal, oldVal)=>{
          console.log("勾选乘客发生变化", newVal, oldVal)
          // 每次有变化时,把购票列表清空,重新构造列表
          tickets.value = [];
          passengerChecks.value.forEach((item) => tickets.value.push({
            passengerId: item.id,
            passengerType: item.type,
            seatTypeCode: seatTypes[0].code,
            passengerName: item.name,
            passengerIdCard: item.idCard
          }))
        }, {immediate: true});
    
        // 0:不支持选座;1:选一等座;2:选二等座
        const chooseSeatType = ref(0);
        // 根据选择的座位类型,计算出对应的列,比如要选的是一等座,就筛选出ACDF,要选的是二等座,就筛选出ABCDF
        const SEAT_COL_ARRAY = computed(() => {
          return window.SEAT_COL_ARRAY.filter(item => item.type === chooseSeatType.value);
        });
        // 选择的座位
        // {
        //   A1: false, C1: true,D1: false, F1: false,
        //   A2: false, C2: false,D2: true, F2: false
        // }
        const chooseSeatObj = ref({});
        watch(() => SEAT_COL_ARRAY.value, () => {
          chooseSeatObj.value = {};
          for (let i = 1; i <= 2; i++) {
            SEAT_COL_ARRAY.value.forEach((item) => {
              chooseSeatObj.value[item.code + i] = false;
            })
          }
          console.log("初始化两排座位,都是未选中:", chooseSeatObj.value);
        }, {immediate: true});
    
        const handleQueryPassenger = () => {
          axios.get("/member/passenger/query-mine").then((response) => {
            let data = response.data;
            if (data.success) {
              passengers.value = data.content;
              passengers.value.forEach((item) => passengerOptions.value.push({
                label: item.name,
                value: item
              }))
            } else {
              notification.error({description: data.message});
            }
          });
        };
    
        const finishCheckPassenger = () => {
          console.log("购票列表:", tickets.value);
    
          if (tickets.value.length > 5) {
            notification.error({description: '最多只能购买5张车票'});
            return;
          }
    
          // 校验余票是否充足,购票列表中的每个座位类型,都去车次座位余票信息中,看余票是否充足
          // 前端校验不一定准,但前端校验可以减轻后端很多压力
          // 注意:这段只是校验,必须copy出seatTypesTemp变量来扣减,用原始的seatTypes去扣减,会影响真实的库存
          let seatTypesTemp = Tool.copy(seatTypes);
          for (let i = 0; i < tickets.value.length; i++) {
            let ticket = tickets.value[i];
            for (let j = 0; j < seatTypesTemp.length; j++) {
              let seatType = seatTypesTemp[j];
              // 同类型座位余票-1,这里扣减的是临时copy出来的库存,不是真正的库存,只是为了校验
              if (ticket.seatTypeCode === seatType.code) {
                seatType.count--;
                if (seatType.count < 0) {
                  notification.error({description: seatType.desc + '余票不足'});
                  return;
                }
              }
            }
          }
          console.log("前端余票校验通过");
    
          // 判断是否支持选座,只有纯一等座和纯二等座支持选座
          // 先筛选出购票列表中的所有座位类型,比如四张表:[1, 1, 2, 2]
          let ticketSeatTypeCodes = [];
          for (let i = 0; i < tickets.value.length; i++) {
            let ticket = tickets.value[i];
            ticketSeatTypeCodes.push(ticket.seatTypeCode);
          }
          // 为购票列表中的所有座位类型去重:[1, 2]
          const ticketSeatTypeCodesSet = Array.from(new Set(ticketSeatTypeCodes));
          console.log("选好的座位类型:", ticketSeatTypeCodesSet);
          if (ticketSeatTypeCodesSet.length !== 1) {
            console.log("选了多种座位,不支持选座");
            chooseSeatType.value = 0;
          } else {
            // ticketSeatTypeCodesSet.length === 1,即只选择了一种座位(不是一个座位,是一种座位)
            if (ticketSeatTypeCodesSet[0] === SEAT_TYPE.YDZ.code) {
              console.log("一等座选座");
              chooseSeatType.value = SEAT_TYPE.YDZ.code;
            } else if (ticketSeatTypeCodesSet[0] === SEAT_TYPE.EDZ.code) {
              console.log("二等座选座");
              chooseSeatType.value = SEAT_TYPE.EDZ.code;
            } else {
              console.log("不是一等座或二等座,不支持选座");
              chooseSeatType.value = 0;
            }
    
            // 余票小于20张时,不允许选座,否则选座成功率不高,影响出票
            if (chooseSeatType.value !== 0) {
              for (let i = 0; i < seatTypes.length; i++) {
                let seatType = seatTypes[i];
                // 找到同类型座位
                if (ticketSeatTypeCodesSet[0] === seatType.code) {
                  // 判断余票,小于20张就不支持选座
                  if (seatType.count < 20) {
                    console.log("余票小于20张就不支持选座")
                    chooseSeatType.value = 0;
                    break;
                  }
                }
              }
            }
          }
    
          // 弹出确认界面
          visible.value = true;
    
        };
    
        const handleOk = () => {
          if (Tool.isEmpty(imageCode.value)) {
            notification.error({description: '验证码不能为空'});
            return;
          }
    
          console.log("选好的座位:", chooseSeatObj.value);
    
          // 设置每张票的座位
          // 先清空购票列表的座位,有可能之前选了并设置座位了,但选座数不对被拦截了,又重新选一遍
          for (let i = 0; i < tickets.value.length; i++) {
            tickets.value[i].seat = null;
          }
          let i = -1;
          // 要么不选座位,要么所选座位应该等于购票数,即i === (tickets.value.length - 1)
          for (let key in chooseSeatObj.value) {
            if (chooseSeatObj.value[key]) {
              i++;
              if (i > tickets.value.length - 1) {
                notification.error({description: '所选座位数大于购票数'});
                return;
              }
              tickets.value[i].seat = key;
            }
          }
          if (i > -1 && i < (tickets.value.length - 1)) {
            notification.error({description: '所选座位数小于购票数'});
            return;
          }
    
          console.log("最终购票:", tickets.value);
    
          axios.post("/business/confirm-order/do", {
            dailyTrainTicketId: dailyTrainTicket.id,
            date: dailyTrainTicket.date,
            trainCode: dailyTrainTicket.trainCode,
            start: dailyTrainTicket.start,
            end: dailyTrainTicket.end,
            tickets: tickets.value,
            imageCodeToken: imageCodeToken.value,
            imageCode: imageCode.value,
          }).then((response) => {
            let data = response.data;
            if (data.success) {
              // notification.success({description: "下单成功!"});
              visible.value = false;
              imageCodeModalVisible.value = false;
              lineModalVisible.value = true;
              confirmOrderId.value = data.content;
            } else {
              notification.error({description: data.message});
            }
          });
        }
    
        /* ------------------- 第二层验证码 --------------------- */
        const imageCodeModalVisible = ref();
        const imageCodeToken = ref();
        const imageCodeSrc = ref();
        const imageCode = ref();
        /**
         * 加载图形验证码
         */
        const loadImageCode = () => {
          imageCodeToken.value = Tool.uuid(8);
          imageCodeSrc.value = process.env.VUE_APP_SERVER + '/business/kaptcha/image-code/' + imageCodeToken.value;
        };
    
        const showImageCodeModal = () => {
          loadImageCode();
          imageCodeModalVisible.value = true;
        };
    
        /* ------------------- 第一层验证码 --------------------- */
        const firstImageCodeSourceA = ref();
        const firstImageCodeSourceB = ref();
        const firstImageCodeTarget = ref();
        const firstImageCodeModalVisible = ref();
    
        /**
         * 加载第一层验证码
         */
        const loadFirstImageCode = () => {
          // 获取1~10的数:Math.floor(Math.random()*10 + 1)
          firstImageCodeSourceA.value = Math.floor(Math.random()*10 + 1) + 10;
          firstImageCodeSourceB.value = Math.floor(Math.random()*10 + 1) + 20;
        };
    
        /**
         * 显示第一层验证码弹出框
         */
        const showFirstImageCodeModal = () => {
          loadFirstImageCode();
          firstImageCodeModalVisible.value = true;
        };
    
        /**
         * 校验第一层验证码
         */
        const validFirstImageCode = () => {
          if (parseInt(firstImageCodeTarget.value) === parseInt(firstImageCodeSourceA.value + firstImageCodeSourceB.value)) {
            // 第一层验证通过
            firstImageCodeModalVisible.value = false;
            showImageCodeModal();
          } else {
            notification.error({description: '验证码错误'});
          }
        };
    
        onMounted(() => {
          handleQueryPassenger();
        });
    
        return {
          passengers,
          dailyTrainTicket,
          seatTypes,
          passengerOptions,
          passengerChecks,
          tickets,
          PASSENGER_TYPE_ARRAY,
          visible,
          finishCheckPassenger,
          chooseSeatType,
          chooseSeatObj,
          SEAT_COL_ARRAY,
          handleOk,
          imageCodeToken,
          imageCodeSrc,
          imageCode,
          showImageCodeModal,
          imageCodeModalVisible,
          loadImageCode,
          firstImageCodeSourceA,
          firstImageCodeSourceB,
          firstImageCodeTarget,
          firstImageCodeModalVisible,
          showFirstImageCodeModal,
          validFirstImageCode,
          lineModalVisible,
          confirmOrderId
        };
      },
    });
    </script>
    
    <style>
    .order-train .order-train-main {
      font-size: 18px;
      font-weight: bold;
    }
    .order-train .order-train-ticket {
      margin-top: 15px;
    }
    .order-train .order-train-ticket .order-train-ticket-main {
      color: red;
      font-size: 18px;
    }
    
    .order-tickets {
      margin: 10px 0;
    }
    .order-tickets .ant-col {
      padding: 5px 10px;
    }
    .order-tickets .order-tickets-header {
      background-color: cornflowerblue;
      border: solid 1px cornflowerblue;
      color: white;
      font-size: 16px;
      padding: 5px 0;
    }
    .order-tickets .order-tickets-row {
      border: solid 1px cornflowerblue;
      border-top: none;
      vertical-align: middle;
      line-height: 30px;
    }
    
    .order-tickets .choose-seat-item {
      margin: 5px 5px;
    }
    </style>
    

3.增加查询排队数量接口,返回排队数量或订单终态(成功、失败、无票、取消等)

  • ConfirmOrderService.java

    这里判断订单生成时间的代码 课程里是andCreateTimeLessThan,也是可以的,因为数据库存储时间datatime精确到了毫秒,同一时间产生票的概率不是很大,所以判断前面多少人直接小于当前订单时间就行。不过我这里改了下小于等于,然后最后count还有个-1的计算,这样就更加精准一点。

    /**
         * 查询前面有几个人在排队
         * @param id
         */
    public Integer queryLineCount(Long id) {
        ConfirmOrder confirmOrder = confirmOrderMapper.selectByPrimaryKey(id);
        ConfirmOrderStatusEnum statusEnum = EnumUtil.getBy(ConfirmOrderStatusEnum::getCode, confirmOrder.getStatus());
        int result = switch (statusEnum) {
            case PENDING -> 0; // 排队0
            case SUCCESS -> -1; // 成功
            case FAILURE -> -2; // 失败
            case EMPTY -> -3; // 无票
            case CANCEL -> -4; // 取消
            case INIT -> 999; // 需要查表得到实际排队数量
        };
    
        if (result == 999) {
            // 前面有几位,下面的写法:where a=1 and (b=1 or c=1) 等价于 where (a=1 and b=1) or (a=1 and c=1)
            ConfirmOrderExample confirmOrderExample = new ConfirmOrderExample();
            confirmOrderExample.or().andDateEqualTo(confirmOrder.getDate())
                .andTrainCodeEqualTo(confirmOrder.getTrainCode())
                .andCreateTimeLessThanOrEqualTo(confirmOrder.getCreateTime())
                .andStatusEqualTo(ConfirmOrderStatusEnum.INIT.getCode());
            confirmOrderExample.or().andDateEqualTo(confirmOrder.getDate())
                .andTrainCodeEqualTo(confirmOrder.getTrainCode())
                .andCreateTimeLessThanOrEqualTo(confirmOrder.getCreateTime())
                .andStatusEqualTo(ConfirmOrderStatusEnum.PENDING.getCode());
            int count = Math.toIntExact(confirmOrderMapper.countByExample(confirmOrderExample));
            return count > 0 ? count-1 : count;
        } else {
            return result;
        }
    }
    
  • ConfirmOrderController.java

    @GetMapping("/query-line-count/{id}")
    public CommonResp<Integer> queryLineCount(@PathVariable Long id) {
        Integer count = confirmOrderService.queryLineCount(id);
        return new CommonResp<>(count);
    }
    
  • 测试

    这里我们先测试下接口,再去做前端对接

    http/business-confirm-order.http

    GET http://localhost:8000/business/confirm-order/query-line-count/1771808812299325440
    Content-Type: application/json
    token: {{token}}
    

    如下图,我查询第一行这个订单,接口返回的是1,也就是前面还有1个人,结果正确

在这里插入图片描述

4.前端增加轮询购票结果功能,根据结果提示终态或排队人数

  • ConfirmOrderService.java

    private void sell(ConfirmOrder confirmOrder) {
    
        // 为了演示排队效果,每次出票增加200毫秒延时
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    
  • order.vue

    <template>
      <div class="order-train">
        <span class="order-train-main">{{dailyTrainTicket.date}}</span>&nbsp;
        <span class="order-train-main">{{dailyTrainTicket.trainCode}}</span>次&nbsp;
        <span class="order-train-main">{{dailyTrainTicket.start}}</span>站
        <span class="order-train-main">({{dailyTrainTicket.startTime}})</span>&nbsp;
        <span class="order-train-main">——</span>&nbsp;
        <span class="order-train-main">{{dailyTrainTicket.end}}</span>站
        <span class="order-train-main">({{dailyTrainTicket.endTime}})</span>&nbsp;
    
        <div class="order-train-ticket">
          <span v-for="item in seatTypes" :key="item.type">
            <span>{{item.desc}}</span>:
            <span class="order-train-ticket-main">{{item.price}}¥</span>&nbsp;
            <span class="order-train-ticket-main">{{item.count}}</span>&nbsp;张票&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
          </span>
        </div>
      </div>
      <a-divider></a-divider>
      <b>勾选要购票的乘客:</b>&nbsp;
      <a-checkbox-group v-model:value="passengerChecks" :options="passengerOptions" />
    
      <div class="order-tickets">
        <a-row class="order-tickets-header" v-if="tickets.length > 0">
          <a-col :span="2">乘客</a-col>
          <a-col :span="6">身份证</a-col>
          <a-col :span="4">票种</a-col>
          <a-col :span="4">座位类型</a-col>
        </a-row>
        <a-row class="order-tickets-row" v-for="ticket in tickets" :key="ticket.passengerId">
          <a-col :span="2">{{ticket.passengerName}}</a-col>
          <a-col :span="6">{{ticket.passengerIdCard}}</a-col>
          <a-col :span="4">
            <a-select v-model:value="ticket.passengerType" style="width: 100%">
              <a-select-option v-for="item in PASSENGER_TYPE_ARRAY" :key="item.code" :value="item.code">
                {{item.desc}}
              </a-select-option>
            </a-select>
          </a-col>
          <a-col :span="4">
            <a-select v-model:value="ticket.seatTypeCode" style="width: 100%">
              <a-select-option v-for="item in seatTypes" :key="item.code" :value="item.code">
                {{item.desc}}
              </a-select-option>
            </a-select>
          </a-col>
        </a-row>
      </div>
      <div v-if="tickets.length > 0">
        <a-button type="primary" size="large" @click="finishCheckPassenger">提交订单</a-button>
      </div>
    
      <a-modal v-model:visible="visible" title="请核对以下信息"
               style="top: 50px; width: 800px"
               ok-text="确认" cancel-text="取消"
               @ok="showFirstImageCodeModal">
        <div class="order-tickets">
          <a-row class="order-tickets-header" v-if="tickets.length > 0">
            <a-col :span="3">乘客</a-col>
            <a-col :span="15">身份证</a-col>
            <a-col :span="3">票种</a-col>
            <a-col :span="3">座位类型</a-col>
          </a-row>
          <a-row class="order-tickets-row" v-for="ticket in tickets" :key="ticket.passengerId">
            <a-col :span="3">{{ticket.passengerName}}</a-col>
            <a-col :span="15">{{ticket.passengerIdCard}}</a-col>
            <a-col :span="3">
              <span v-for="item in PASSENGER_TYPE_ARRAY" :key="item.code">
                <span v-if="item.code === ticket.passengerType">
                  {{item.desc}}
                </span>
              </span>
            </a-col>
            <a-col :span="3">
              <span v-for="item in seatTypes" :key="item.code">
                <span v-if="item.code === ticket.seatTypeCode">
                  {{item.desc}}
                </span>
              </span>
            </a-col>
          </a-row>
          <br/>
          <div v-if="chooseSeatType === 0" style="color: red;">
            您购买的车票不支持选座
            <div>12306规则:只有全部是一等座或全部是二等座才支持选座</div>
            <div>12306规则:余票小于一定数量时,不允许选座(本项目以20为例)</div>
          </div>
          <div v-else style="text-align: center">
            <a-switch class="choose-seat-item" v-for="item in SEAT_COL_ARRAY" :key="item.code"
                      v-model:checked="chooseSeatObj[item.code + '1']" :checked-children="item.desc" :un-checked-children="item.desc" />
            <div v-if="tickets.length > 1">
              <a-switch class="choose-seat-item" v-for="item in SEAT_COL_ARRAY" :key="item.code"
                        v-model:checked="chooseSeatObj[item.code + '2']" :checked-children="item.desc" :un-checked-children="item.desc" />
            </div>
            <div style="color: #999999">提示:您可以选择{{tickets.length}}个座位</div>
          </div>
          <!--<br/>-->
          <!--最终购票:{{tickets}}-->
          <!--最终选座:{{chooseSeatObj}}-->
        </div>
      </a-modal>
    
      <!-- 第二层验证码 后端 -->
      <a-modal v-model:visible="imageCodeModalVisible" :title="null" :footer="null" :closable="false"
               style="top: 50px; width: 400px">
        <p style="text-align: center; font-weight: bold; font-size: 18px">
          使用服务端验证码削弱瞬时高峰<br/>
          防止机器人刷票
        </p>
        <p>
          <a-input v-model:value="imageCode" placeholder="图片验证码">
            <template #suffix>
              <img v-show="!!imageCodeSrc" :src="imageCodeSrc" alt="验证码" v-on:click="loadImageCode()"/>
            </template>
          </a-input>
        </p>
        <a-button type="danger" block @click="handleOk">输入验证码后开始购票</a-button>
      </a-modal>
    
      <!-- 第一层验证码 纯前端 -->
      <a-modal v-model:visible="firstImageCodeModalVisible" :title="null" :footer="null" :closable="false"
               style="top: 50px; width: 400px">
        <p style="text-align: center; font-weight: bold; font-size: 18px">
          使用纯前端验证码削弱瞬时高峰<br/>
          减小后端验证码接口的压力
        </p>
        <p>
          <a-input v-model:value="firstImageCodeTarget" placeholder="验证码">
            <template #suffix>
              {{firstImageCodeSourceA}} + {{firstImageCodeSourceB}}
            </template>
          </a-input>
        </p>
        <a-button type="danger" block @click="validFirstImageCode">提交验证码</a-button>
      </a-modal>
    
      <a-modal v-model:visible="lineModalVisible" title="排队购票" :footer="null" :maskClosable="false" :closable="false"
               style="top: 50px; width: 400px">
        <div class="book-line">
          <div v-show="confirmOrderLineCount < 0">
            <loading-outlined /> 系统正在处理中...
          </div>
          <div v-show="confirmOrderLineCount >= 0">
            <loading-outlined /> 您前面还有{{confirmOrderLineCount}}位用户在购票,排队中,请稍候
          </div>
        </div>
      </a-modal>
    </template>
    
    <script>
    
    import {defineComponent, ref, onMounted, watch, computed} from 'vue';
    import axios from "axios";
    import {notification} from "ant-design-vue";
    
    export default defineComponent({
      name: "order-view",
      setup() {
        const passengers = ref([]);
        const passengerOptions = ref([]);
        const passengerChecks = ref([]);
        const dailyTrainTicket = SessionStorage.get(SESSION_ORDER) || {};
        console.log("下单的车次信息", dailyTrainTicket);
    
        const SEAT_TYPE = window.SEAT_TYPE;
        console.log(SEAT_TYPE)
        // 本车次提供的座位类型seatTypes,含票价,余票等信息,例:
        // {
        //   type: "YDZ",
        //   code: "1",
        //   desc: "一等座",
        //   count: "100",
        //   price: "50",
        // }
        // 关于SEAT_TYPE[KEY]:当知道某个具体的属性xxx时,可以用obj.xxx,当属性名是个变量时,可以使用obj[xxx]
        const seatTypes = [];
        for (let KEY in SEAT_TYPE) {
          let key = KEY.toLowerCase();
          if (dailyTrainTicket[key] >= 0) {
            seatTypes.push({
              type: KEY,
              code: SEAT_TYPE[KEY]["code"],
              desc: SEAT_TYPE[KEY]["desc"],
              count: dailyTrainTicket[key],
              price: dailyTrainTicket[key + 'Price'],
            })
          }
        }
        console.log("本车次提供的座位:", seatTypes)
        // 购票列表,用于界面展示,并传递到后端接口,用来描述:哪个乘客购买什么座位的票
        // {
        //   passengerId: 123,
        //   passengerType: "1",
        //   passengerName: "张三",
        //   passengerIdCard: "12323132132",
        //   seatTypeCode: "1",
        //   seat: "C1"
        // }
        const tickets = ref([]);
        const PASSENGER_TYPE_ARRAY = window.PASSENGER_TYPE_ARRAY;
        const visible = ref(false);
        const lineModalVisible = ref(false);
        const confirmOrderId = ref();
        const confirmOrderLineCount = ref(-1);
    
        // 勾选或去掉某个乘客时,在购票列表中加上或去掉一张表
        watch(() => passengerChecks.value, (newVal, oldVal)=>{
          console.log("勾选乘客发生变化", newVal, oldVal)
          // 每次有变化时,把购票列表清空,重新构造列表
          tickets.value = [];
          passengerChecks.value.forEach((item) => tickets.value.push({
            passengerId: item.id,
            passengerType: item.type,
            seatTypeCode: seatTypes[0].code,
            passengerName: item.name,
            passengerIdCard: item.idCard
          }))
        }, {immediate: true});
    
        // 0:不支持选座;1:选一等座;2:选二等座
        const chooseSeatType = ref(0);
        // 根据选择的座位类型,计算出对应的列,比如要选的是一等座,就筛选出ACDF,要选的是二等座,就筛选出ABCDF
        const SEAT_COL_ARRAY = computed(() => {
          return window.SEAT_COL_ARRAY.filter(item => item.type === chooseSeatType.value);
        });
        // 选择的座位
        // {
        //   A1: false, C1: true,D1: false, F1: false,
        //   A2: false, C2: false,D2: true, F2: false
        // }
        const chooseSeatObj = ref({});
        watch(() => SEAT_COL_ARRAY.value, () => {
          chooseSeatObj.value = {};
          for (let i = 1; i <= 2; i++) {
            SEAT_COL_ARRAY.value.forEach((item) => {
              chooseSeatObj.value[item.code + i] = false;
            })
          }
          console.log("初始化两排座位,都是未选中:", chooseSeatObj.value);
        }, {immediate: true});
    
        const handleQueryPassenger = () => {
          axios.get("/member/passenger/query-mine").then((response) => {
            let data = response.data;
            if (data.success) {
              passengers.value = data.content;
              passengers.value.forEach((item) => passengerOptions.value.push({
                label: item.name,
                value: item
              }))
            } else {
              notification.error({description: data.message});
            }
          });
        };
    
        const finishCheckPassenger = () => {
          console.log("购票列表:", tickets.value);
    
          if (tickets.value.length > 5) {
            notification.error({description: '最多只能购买5张车票'});
            return;
          }
    
          // 校验余票是否充足,购票列表中的每个座位类型,都去车次座位余票信息中,看余票是否充足
          // 前端校验不一定准,但前端校验可以减轻后端很多压力
          // 注意:这段只是校验,必须copy出seatTypesTemp变量来扣减,用原始的seatTypes去扣减,会影响真实的库存
          let seatTypesTemp = Tool.copy(seatTypes);
          for (let i = 0; i < tickets.value.length; i++) {
            let ticket = tickets.value[i];
            for (let j = 0; j < seatTypesTemp.length; j++) {
              let seatType = seatTypesTemp[j];
              // 同类型座位余票-1,这里扣减的是临时copy出来的库存,不是真正的库存,只是为了校验
              if (ticket.seatTypeCode === seatType.code) {
                seatType.count--;
                if (seatType.count < 0) {
                  notification.error({description: seatType.desc + '余票不足'});
                  return;
                }
              }
            }
          }
          console.log("前端余票校验通过");
    
          // 判断是否支持选座,只有纯一等座和纯二等座支持选座
          // 先筛选出购票列表中的所有座位类型,比如四张表:[1, 1, 2, 2]
          let ticketSeatTypeCodes = [];
          for (let i = 0; i < tickets.value.length; i++) {
            let ticket = tickets.value[i];
            ticketSeatTypeCodes.push(ticket.seatTypeCode);
          }
          // 为购票列表中的所有座位类型去重:[1, 2]
          const ticketSeatTypeCodesSet = Array.from(new Set(ticketSeatTypeCodes));
          console.log("选好的座位类型:", ticketSeatTypeCodesSet);
          if (ticketSeatTypeCodesSet.length !== 1) {
            console.log("选了多种座位,不支持选座");
            chooseSeatType.value = 0;
          } else {
            // ticketSeatTypeCodesSet.length === 1,即只选择了一种座位(不是一个座位,是一种座位)
            if (ticketSeatTypeCodesSet[0] === SEAT_TYPE.YDZ.code) {
              console.log("一等座选座");
              chooseSeatType.value = SEAT_TYPE.YDZ.code;
            } else if (ticketSeatTypeCodesSet[0] === SEAT_TYPE.EDZ.code) {
              console.log("二等座选座");
              chooseSeatType.value = SEAT_TYPE.EDZ.code;
            } else {
              console.log("不是一等座或二等座,不支持选座");
              chooseSeatType.value = 0;
            }
    
            // 余票小于20张时,不允许选座,否则选座成功率不高,影响出票
            if (chooseSeatType.value !== 0) {
              for (let i = 0; i < seatTypes.length; i++) {
                let seatType = seatTypes[i];
                // 找到同类型座位
                if (ticketSeatTypeCodesSet[0] === seatType.code) {
                  // 判断余票,小于20张就不支持选座
                  if (seatType.count < 20) {
                    console.log("余票小于20张就不支持选座")
                    chooseSeatType.value = 0;
                    break;
                  }
                }
              }
            }
          }
    
          // 弹出确认界面
          visible.value = true;
    
        };
    
        const handleOk = () => {
          if (Tool.isEmpty(imageCode.value)) {
            notification.error({description: '验证码不能为空'});
            return;
          }
    
          console.log("选好的座位:", chooseSeatObj.value);
    
          // 设置每张票的座位
          // 先清空购票列表的座位,有可能之前选了并设置座位了,但选座数不对被拦截了,又重新选一遍
          for (let i = 0; i < tickets.value.length; i++) {
            tickets.value[i].seat = null;
          }
          let i = -1;
          // 要么不选座位,要么所选座位应该等于购票数,即i === (tickets.value.length - 1)
          for (let key in chooseSeatObj.value) {
            if (chooseSeatObj.value[key]) {
              i++;
              if (i > tickets.value.length - 1) {
                notification.error({description: '所选座位数大于购票数'});
                return;
              }
              tickets.value[i].seat = key;
            }
          }
          if (i > -1 && i < (tickets.value.length - 1)) {
            notification.error({description: '所选座位数小于购票数'});
            return;
          }
    
          console.log("最终购票:", tickets.value);
    
          axios.post("/business/confirm-order/do", {
            dailyTrainTicketId: dailyTrainTicket.id,
            date: dailyTrainTicket.date,
            trainCode: dailyTrainTicket.trainCode,
            start: dailyTrainTicket.start,
            end: dailyTrainTicket.end,
            tickets: tickets.value,
            imageCodeToken: imageCodeToken.value,
            imageCode: imageCode.value,
          }).then((response) => {
            let data = response.data;
            if (data.success) {
              // notification.success({description: "下单成功!"});
              visible.value = false;
              imageCodeModalVisible.value = false;
              lineModalVisible.value = true;
              confirmOrderId.value = data.content;
              queryLineCount();
            } else {
              notification.error({description: data.message});
            }
          });
        }
    
        /* ------------------- 定时查询订单状态 --------------------- */
        // 确认订单后定时查询
        let queryLineCountInterval;
    
        // 定时查询订单结果/排队数量
        const queryLineCount = () => {
          confirmOrderLineCount.value = -1;
          queryLineCountInterval = setInterval(function () {
            axios.get("/business/confirm-order/query-line-count/" + confirmOrderId.value).then((response) => {
              let data = response.data;
              if (data.success) {
                let result = data.content;
                switch (result) {
                  case -1 :
                    notification.success({description: "购票成功!"});
                    lineModalVisible.value = false;
                    clearInterval(queryLineCountInterval);
                    break;
                  case -2:
                    notification.error({description: "购票失败!"});
                    lineModalVisible.value = false;
                    clearInterval(queryLineCountInterval);
                    break;
                  case -3:
                    notification.error({description: "抱歉,没票了!"});
                    lineModalVisible.value = false;
                    clearInterval(queryLineCountInterval);
                    break;
                  default:
                    confirmOrderLineCount.value = result;
                }
              } else {
                notification.error({description: data.message});
              }
            });
          }, 500);
        };
    
        /* ------------------- 第二层验证码 --------------------- */
        const imageCodeModalVisible = ref();
        const imageCodeToken = ref();
        const imageCodeSrc = ref();
        const imageCode = ref();
        /**
         * 加载图形验证码
         */
        const loadImageCode = () => {
          imageCodeToken.value = Tool.uuid(8);
          imageCodeSrc.value = process.env.VUE_APP_SERVER + '/business/kaptcha/image-code/' + imageCodeToken.value;
        };
    
        const showImageCodeModal = () => {
          loadImageCode();
          imageCodeModalVisible.value = true;
        };
    
        /* ------------------- 第一层验证码 --------------------- */
        const firstImageCodeSourceA = ref();
        const firstImageCodeSourceB = ref();
        const firstImageCodeTarget = ref();
        const firstImageCodeModalVisible = ref();
    
        /**
         * 加载第一层验证码
         */
        const loadFirstImageCode = () => {
          // 获取1~10的数:Math.floor(Math.random()*10 + 1)
          firstImageCodeSourceA.value = Math.floor(Math.random()*10 + 1) + 10;
          firstImageCodeSourceB.value = Math.floor(Math.random()*10 + 1) + 20;
        };
    
        /**
         * 显示第一层验证码弹出框
         */
        const showFirstImageCodeModal = () => {
          loadFirstImageCode();
          firstImageCodeModalVisible.value = true;
        };
    
        /**
         * 校验第一层验证码
         */
        const validFirstImageCode = () => {
          if (parseInt(firstImageCodeTarget.value) === parseInt(firstImageCodeSourceA.value + firstImageCodeSourceB.value)) {
            // 第一层验证通过
            firstImageCodeModalVisible.value = false;
            showImageCodeModal();
          } else {
            notification.error({description: '验证码错误'});
          }
        };
    
        onMounted(() => {
          handleQueryPassenger();
        });
    
        return {
          passengers,
          dailyTrainTicket,
          seatTypes,
          passengerOptions,
          passengerChecks,
          tickets,
          PASSENGER_TYPE_ARRAY,
          visible,
          finishCheckPassenger,
          chooseSeatType,
          chooseSeatObj,
          SEAT_COL_ARRAY,
          handleOk,
          imageCodeToken,
          imageCodeSrc,
          imageCode,
          showImageCodeModal,
          imageCodeModalVisible,
          loadImageCode,
          firstImageCodeSourceA,
          firstImageCodeSourceB,
          firstImageCodeTarget,
          firstImageCodeModalVisible,
          showFirstImageCodeModal,
          validFirstImageCode,
          lineModalVisible,
          confirmOrderId,
          confirmOrderLineCount
        };
      },
    });
    </script>
    
    <style>
    .order-train .order-train-main {
      font-size: 18px;
      font-weight: bold;
    }
    .order-train .order-train-ticket {
      margin-top: 15px;
    }
    .order-train .order-train-ticket .order-train-ticket-main {
      color: red;
      font-size: 18px;
    }
    
    .order-tickets {
      margin: 10px 0;
    }
    .order-tickets .ant-col {
      padding: 5px 10px;
    }
    .order-tickets .order-tickets-header {
      background-color: cornflowerblue;
      border: solid 1px cornflowerblue;
      color: white;
      font-size: 16px;
      padding: 5px 0;
    }
    .order-tickets .order-tickets-row {
      border: solid 1px cornflowerblue;
      border-top: none;
      vertical-align: middle;
      line-height: 30px;
    }
    
    .order-tickets .choose-seat-item {
      margin: 5px 5px;
    }
    </style>
    
  • 测试

    上述的逻辑是每500毫秒请求一次后端查询购票结果,然后为了演示效果,我们后端又每次出票都延时200毫秒,最终可以看到的效果就是类似倒计时,从排队,前面x人,一直减少,最终购票成功

    首先手改数据库表将订单设置5单状态是处理中,然后效果正确的话,会显示前面排队X人一直减少到成功购票(余票也需要保证充足)

    结果如下:

          default:
                confirmOrderLineCount.value = result;
            }
          } else {
            notification.error({description: data.message});
          }
        });
      }, 500);
    };
    
    /* ------------------- 第二层验证码 --------------------- */
    const imageCodeModalVisible = ref();
    const imageCodeToken = ref();
    const imageCodeSrc = ref();
    const imageCode = ref();
    /**
     * 加载图形验证码
     */
    const loadImageCode = () => {
      imageCodeToken.value = Tool.uuid(8);
      imageCodeSrc.value = process.env.VUE_APP_SERVER + '/business/kaptcha/image-code/' + imageCodeToken.value;
    };
    
    const showImageCodeModal = () => {
      loadImageCode();
      imageCodeModalVisible.value = true;
    };
    
    /* ------------------- 第一层验证码 --------------------- */
    const firstImageCodeSourceA = ref();
    const firstImageCodeSourceB = ref();
    const firstImageCodeTarget = ref();
    const firstImageCodeModalVisible = ref();
    
    /**
     * 加载第一层验证码
     */
    const loadFirstImageCode = () => {
      // 获取1~10的数:Math.floor(Math.random()*10 + 1)
      firstImageCodeSourceA.value = Math.floor(Math.random()*10 + 1) + 10;
      firstImageCodeSourceB.value = Math.floor(Math.random()*10 + 1) + 20;
    };
    
    /**
     * 显示第一层验证码弹出框
     */
    const showFirstImageCodeModal = () => {
      loadFirstImageCode();
      firstImageCodeModalVisible.value = true;
    };
    
    /**
     * 校验第一层验证码
     */
    const validFirstImageCode = () => {
      if (parseInt(firstImageCodeTarget.value) === parseInt(firstImageCodeSourceA.value + firstImageCodeSourceB.value)) {
        // 第一层验证通过
        firstImageCodeModalVisible.value = false;
        showImageCodeModal();
      } else {
        notification.error({description: '验证码错误'});
      }
    };
    
    onMounted(() => {
      handleQueryPassenger();
    });
    
    return {
      passengers,
      dailyTrainTicket,
      seatTypes,
      passengerOptions,
      passengerChecks,
      tickets,
      PASSENGER_TYPE_ARRAY,
      visible,
      finishCheckPassenger,
      chooseSeatType,
      chooseSeatObj,
      SEAT_COL_ARRAY,
      handleOk,
      imageCodeToken,
      imageCodeSrc,
      imageCode,
      showImageCodeModal,
      imageCodeModalVisible,
      loadImageCode,
      firstImageCodeSourceA,
      firstImageCodeSourceB,
      firstImageCodeTarget,
      firstImageCodeModalVisible,
      showFirstImageCodeModal,
      validFirstImageCode,
      lineModalVisible,
      confirmOrderId,
      confirmOrderLineCount
    };
    

    },
    });

    
    
  • 测试

    上述的逻辑是每500毫秒请求一次后端查询购票结果,然后为了演示效果,我们后端又每次出票都延时200毫秒,最终可以看到的效果就是类似倒计时,从排队,前面x人,一直减少,最终购票成功

    首先手改数据库表将订单设置5单状态是处理中,然后效果正确的话,会显示前面排队X人一直减少到成功购票(余票也需要保证充足)

    结果如下:

在这里插入图片描述

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值