本章目录(接上篇)
六、使用MQ对请求做异步削峰处理,解决吞吐量问题
1.购票时序图演进
原先:
改进后:
MQ对比springboot异步的好处:不怕中间异常导致消息丢失
2.RocketMQ初体验
1.下载
修改配置,减小最大分配内存,防止启动失败
**注意:**windows命令行环境中,前面加 rem 表示注释
【这一步我们跳过,不然后面反而报错了】
2.启动NameServer和Broker
- 配置环境变量
-
启动
到bin目录下cmd,先启动namesrv、然后启动broker
start mqnamesrv.cmd
start mqbroker.cmd -n 0.0.0.0:9876
注意:
这里我执行后报错了:
解决办法:
windows下报错,可以这样做:
框住的需要删除
runserver.cmd
runbroker.cmd
-
停止mq
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); }
-
测试
问题:
- 页面上显示问题,点击购票后返回应该是“下单成功,正在排队中”,等轮询获得成功结果之后再弹出提示“出票成功”之类,后续再优化;
- 下单完后弹窗应该都隐藏掉,并且输入的验证码也应该清空(可以直接做成刷新页面)
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>
<span class="order-train-main">{{dailyTrainTicket.trainCode}}</span>次
<span class="order-train-main">{{dailyTrainTicket.start}}</span>站
<span class="order-train-main">({{dailyTrainTicket.startTime}})</span>
<span class="order-train-main">——</span>
<span class="order-train-main">{{dailyTrainTicket.end}}</span>站
<span class="order-train-main">({{dailyTrainTicket.endTime}})</span>
<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>
<span class="order-train-ticket-main">{{item.count}}</span> 张票
</span>
</div>
</div>
<a-divider></a-divider>
<b>勾选要购票的乘客:</b>
<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> <span class="order-train-main">{{dailyTrainTicket.trainCode}}</span>次 <span class="order-train-main">{{dailyTrainTicket.start}}</span>站 <span class="order-train-main">({{dailyTrainTicket.startTime}})</span> <span class="order-train-main">——</span> <span class="order-train-main">{{dailyTrainTicket.end}}</span>站 <span class="order-train-main">({{dailyTrainTicket.endTime}})</span> <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> <span class="order-train-ticket-main">{{item.count}}</span> 张票 </span> </div> </div> <a-divider></a-divider> <b>勾选要购票的乘客:</b> <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> <span class="order-train-main">{{dailyTrainTicket.trainCode}}</span>次 <span class="order-train-main">{{dailyTrainTicket.start}}</span>站 <span class="order-train-main">({{dailyTrainTicket.startTime}})</span> <span class="order-train-main">——</span> <span class="order-train-main">{{dailyTrainTicket.end}}</span>站 <span class="order-train-main">({{dailyTrainTicket.endTime}})</span> <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> <span class="order-train-ticket-main">{{item.count}}</span> 张票 </span> </div> </div> <a-divider></a-divider> <b>勾选要购票的乘客:</b> <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人一直减少到成功购票(余票也需要保证充足)
结果如下: