十五、高并发抢票时,防止机器人刷票的令牌大闸,可减轻服务器的压力(防刷+限流)

发布时间 2023-06-07 00:05:15作者: 夏雪冬蝉

介绍

为什么引入令牌大闸?

  • 分布式锁和限流都不能解决机器人刷票问题,1000个请求抢票,900个限流快速失败,另外100个人有可能是同一个人在刷库。引入令牌功能,令牌记录用户信息,一旦用户拿到令牌,那么几秒钟之内不能重新拿到令牌。
  • 没有余票时,需要查库存才知道没票,会影响性能,不如查令牌存量来的快。

增加令牌表用以维护令牌信息

日期和车次编号与令牌关联。

drop table if exists `sk_token`;
create table `sk_token` (
  `id` bigint not null comment 'id',
  `date` date not null comment '日期',
  `train_code` varchar(20) not null comment '车次编号',
  `count` int not null comment '令牌余量',
  `create_time` datetime(3) comment '新增时间',
  `update_time` datetime(3) comment '修改时间',
  primary key (`id`),
  unique key `date_train_code_unique` (`date`, `train_code`)
) engine=innodb default charset=utf8mb4 comment='秒杀令牌';

生成持久层、服务端。

方法:令牌放在redis里,每个用户进来就加1,不涉及数据库。设计成表可以方便统计或者一些额外的工作。比如看哪些车次卖的更快。

令牌抢光后还有座位,还可以手动添加令牌。

初始化车次信息时初始化秒杀令牌信息

SkTokenService.java增加每日生成令牌方法

 1 /**
 2      * 初始化
 3      */
 4     public void genDaily(Date date, String trainCode) {
 5         LOG.info("删除日期【{}】车次【{}】的令牌记录", DateUtil.formatDate(date), trainCode);
 6         SkTokenExample skTokenExample = new SkTokenExample();
 7         skTokenExample.createCriteria().andDateEqualTo(date).andTrainCodeEqualTo(trainCode);
 8         skTokenMapper.deleteByExample(skTokenExample);
 9 
10         DateTime now = DateTime.now();
11         SkToken skToken = new SkToken();
12         skToken.setDate(date);
13         skToken.setTrainCode(trainCode);
14         skToken.setId(SnowUtil.getSnowflakeNextId());
15         skToken.setCreateTime(now);
16         skToken.setUpdateTime(now);
17 
18         int seatCount = dailyTrainSeatService.countSeat(date, trainCode);
19         LOG.info("车次【{}】座位数:{}", trainCode, seatCount);
20 
21         long stationCount = dailyTrainStationService.countByTrainCode(trainCode);
22         LOG.info("车次【{}】到站数:{}", trainCode, stationCount);
23 
24         // 3/4需要根据实际卖票比例来定,一趟火车最多可以卖(seatCount * stationCount)张火车票
25         int count = (int) (seatCount * stationCount * 3/4);
26         LOG.info("车次【{}】初始生成令牌数:{}", trainCode, count);
27         skToken.setCount(count);
28 
29         skTokenMapper.insert(skToken);
30     }

DailyTrainSeatService.java座位数不限制级别

 1     public int countSeat(Date date, String trainCode) {
 2         return countSeat(date, trainCode, null);
 3     }
 4 
 5     public int countSeat(Date date, String trainCode, String seatType) {
 6         DailyTrainSeatExample example = new DailyTrainSeatExample();
 7         DailyTrainSeatExample.Criteria criteria = example.createCriteria();
 8         criteria.andDateEqualTo(date)
 9                 .andTrainCodeEqualTo(trainCode);
10         if (StrUtil.isNotBlank(seatType)) {
11             criteria.andSeatTypeEqualTo(seatType);
12         }
13         long l = dailyTrainSeatMapper.countByExample(example);
14         if (l == 0L) {
15             return -1;
16         }
17         return (int) l;
18     }

增加校验秒杀令牌功能

购票入口doConfirm,加锁之前还要添加令牌校验,取到就继续往下走,走不到就抛出异常,中断流程。

1 // 校验令牌余量
2         boolean validSkToken = skTokenService.validSkToken(req.getDate(), req.getTrainCode(), LoginMemberContext.getId());
3         if (validSkToken) {
4             LOG.info("令牌校验通过");
5         } else {
6             LOG.info("令牌校验不通过");
7             throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_SK_TOKEN_FAIL);
8         }
skTokenService.validSkToken
/**
     * 获取令牌
     */
    public boolean validSkToken(Date date, String trainCode, Long memberId) {
        LOG.info("会员【{}】获取日期【{}】车次【{}】的令牌开始", memberId, DateUtil.formatDate(date), trainCode);
        // 令牌约等于库存,令牌没有了,就不再卖票,不需要再进入购票主流程去判断库存,判断令牌肯定比判断库存效率高
        int updateCount = skTokenMapperCust.decrease(date, trainCode);
        if (updateCount > 0) {
            return true;
        } else {
            return false;
        }
    }

使用令牌锁防止机器人抢票

 1 public boolean validSkToken(Date date, String trainCode, Long memberId) {
 2         LOG.info("会员【{}】获取日期【{}】车次【{}】的令牌开始", memberId, DateUtil.formatDate(date), trainCode);
 3 
 4         // 先获取令牌锁,再校验令牌余量,防止机器人抢票,lockKey就是令牌,用来表示【谁能做什么】的一个凭证
 5         String lockKey = DateUtil.formatDate(date) + "-" + trainCode + "-" + memberId;
 6         Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(lockKey, lockKey, 5, TimeUnit.SECONDS);
 7         if (Boolean.TRUE.equals(setIfAbsent)) {
 8             LOG.info("恭喜,抢到令牌锁了!lockKey:{}", lockKey);
 9         } else {
10             LOG.info("很遗憾,没抢到令牌锁!lockKey:{}", lockKey);
11             return false;
12         }
13 
14         // 令牌约等于库存,令牌没有了,就不再卖票,不需要再进入购票主流程去判断库存,判断令牌肯定比判断库存效率高
15         int updateCount = skTokenMapperCust.decrease(date, trainCode);
16         if (updateCount > 0) {
17             return true;
18         } else {
19             return false;
20         }
21     }

机器人抢票的特点是一个人不断的发起购票请求,用redis分布式锁保障一个人一段时间只能购票一次。

使用缓存加速令牌锁功能

如何优化:

令牌校验会判断锁,然后更新数据库。如果一群人同时请求,会加剧数据库的压力。

不实时更新数据库,引入缓存,通过缓存判断,之后更新到数据库。

对放入redis的lockKey加一个前缀,避免不同的业务共用一个key

String lockKey = LockKeyPreEnum.SK_TOKEN + "-" + DateUtil.formatDate(date) + "-" + trainCode + "-" + memberId;

 引入缓存

 1 /**
 2      * 校验令牌
 3      */
 4     public boolean validSkToken(Date date, String trainCode, Long memberId) {
 5         LOG.info("会员【{}】获取日期【{}】车次【{}】的令牌开始", memberId, DateUtil.formatDate(date), trainCode);
 6 
 7         // 先获取令牌锁,再校验令牌余量,防止机器人抢票,lockKey就是令牌,用来表示【谁能做什么】的一个凭证
 8         String lockKey = RedisKeyPreEnum.SK_TOKEN + "-" + DateUtil.formatDate(date) + "-" + trainCode + "-" + memberId;
 9         Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(lockKey, lockKey, 5, TimeUnit.SECONDS);
10         if (Boolean.TRUE.equals(setIfAbsent)) {
11             LOG.info("恭喜,抢到令牌锁了!lockKey:{}", lockKey);
12         } else {
13             LOG.info("很遗憾,没抢到令牌锁!lockKey:{}", lockKey);
14             return false;
15         }
16 
17         String skTokenCountKey = RedisKeyPreEnum.SK_TOKEN_COUNT + "-" + DateUtil.formatDate(date) + "-" + trainCode;
18         Object skTokenCount = redisTemplate.opsForValue().get(skTokenCountKey);
19         if (skTokenCount != null) {
20             LOG.info("缓存中有该车次令牌大闸的key:{}", skTokenCountKey);
21             Long count = redisTemplate.opsForValue().decrement(skTokenCountKey, 1);
22 
23             if (count < 0L) {
24                 LOG.error("获取令牌失败:{}", skTokenCountKey);
25                 return false;
26             } else {
27                 LOG.info("获取令牌后,令牌余数:{}", count);
28                 // 缓存不断刷新过期时间60s,防止key失效,因为令牌一直存在
29                 redisTemplate.expire(skTokenCountKey, 60, TimeUnit.SECONDS);
30                 // 每获取5个令牌更新一次数据库
31                 if (count % 5 == 0) {
32                     skTokenMapperCust.decrease(date, trainCode, 5);
33                 }
34                 return true;
35             }
36         } else {
37             LOG.info("缓存中没有该车次令牌大闸的key:{}", skTokenCountKey);
38             // 检查是否还有令牌
39             SkTokenExample skTokenExample = new SkTokenExample();
40             skTokenExample.createCriteria().andDateEqualTo(date).andTrainCodeEqualTo(trainCode);
41             List<SkToken> tokenCountList = skTokenMapper.selectByExample(skTokenExample);
42             if (CollUtil.isEmpty(tokenCountList)) {
43                 LOG.info("找不到日期【{}】车次【{}】的令牌记录", DateUtil.formatDate(date), trainCode);
44                 return false;
45             }
46 
47             SkToken skToken = tokenCountList.get(0);
48             if (skToken.getCount() <= 0) {
49                 LOG.info("日期【{}】车次【{}】的令牌余量为0", DateUtil.formatDate(date), trainCode);
50                 return false;
51             }
52 
53             // 令牌还有余量
54             // 令牌余数-1
55             Integer count = skToken.getCount() - 1;
56             skToken.setCount(count);
57             LOG.info("将该车次令牌大闸放入缓存中,key: {}, count: {}", skTokenCountKey, count);
58             // 不需要更新数据库,只要放缓存即可
59             redisTemplate.opsForValue().set(skTokenCountKey, String.valueOf(count), 60, TimeUnit.SECONDS);
60             //skTokenMapper.updateByPrimaryKey(skToken);
61             return true;
62         }
63 
64         // 令牌约等于库存,令牌没有了,就不再卖票,不需要再进入购票主流程去判断库存,判断令牌肯定比判断库存效率高
65         // int updateCount = skTokenMapperCust.decrease(date, trainCode, 1);
66         // if (updateCount > 0) {
67         //     return true;
68         // } else {
69         //     return false;
70         // }
71     }

先判断缓存,缓存有则操作缓存,缓存没有则去数据库查。

增加验证码削弱瞬时高峰并防机器人刷票

前端可以频繁发送请求,给后端造成无效请求。可以在前端增加验证码,也可以防止机器人刷票。

图形验证码依赖

<!-- 图形验证码 -->
            <dependency>
                <groupId>com.github.penggle</groupId>
                <artifactId>kaptcha</artifactId>
                <version>2.3.2</version>
                <exclusions>
                    <exclusion>
                        <groupId>javax.servlet</groupId>
                        <artifactId>javax.servlet-api</artifactId>
                    </exclusion>
                </exclusions>
            </dependency>

配置

 1 package com.zihans.train.business.config;
 2 
 3 import com.google.code.kaptcha.impl.DefaultKaptcha;
 4 import com.google.code.kaptcha.util.Config;
 5 import org.springframework.context.annotation.Bean;
 6 import org.springframework.context.annotation.Configuration;
 7 
 8 import java.util.Properties;
 9 
10 @Configuration
11 public class KaptchaConfig {
12     @Bean
13     public DefaultKaptcha getDefaultKaptcha() {
14         DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
15         Properties properties = new Properties();
16         properties.setProperty("kaptcha.border", "no");
17 //        properties.setProperty("kaptcha.border.color", "105,179,90");
18         properties.setProperty("kaptcha.textproducer.font.color", "blue");
19         properties.setProperty("kaptcha.image.width", "90");
20         properties.setProperty("kaptcha.image.height", "28");
21         properties.setProperty("kaptcha.textproducer.font.size", "20");
22         properties.setProperty("kaptcha.session.key", "code");
23         properties.setProperty("kaptcha.textproducer.char.length", "4");
24         properties.setProperty("kaptcha.textproducer.font.names", "Arial");
25         properties.setProperty("kaptcha.noise.color", "255,96,0");
26         properties.setProperty("kaptcha.noise.impl", "com.google.code.kaptcha.impl.NoNoise");
27 //        properties.setProperty("kaptcha.obscurificator.impl", "com.google.code.kaptcha.impl.WaterRipple");
28         properties.setProperty("kaptcha.obscurificator.impl", KaptchaWaterRipple.class.getName());
29         properties.setProperty("kaptcha.background.impl", KaptchaNoBackhround.class.getName());
30         Config config = new Config(properties);
31         defaultKaptcha.setConfig(config);
32         return defaultKaptcha;
33     }
34 
35     @Bean
36     public DefaultKaptcha getWebKaptcha() {
37         DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
38         Properties properties = new Properties();
39         properties.setProperty("kaptcha.border", "no");
40 //        properties.setProperty("kaptcha.border.color", "105,179,90");
41         properties.setProperty("kaptcha.textproducer.font.color", "blue");
42         properties.setProperty("kaptcha.image.width", "90");
43         properties.setProperty("kaptcha.image.height", "45");
44         properties.setProperty("kaptcha.textproducer.font.size", "30");
45         properties.setProperty("kaptcha.session.key", "code");
46         properties.setProperty("kaptcha.textproducer.char.length", "4");
47         properties.setProperty("kaptcha.textproducer.font.names", "Arial");
48         properties.setProperty("kaptcha.noise.impl", "com.google.code.kaptcha.impl.NoNoise");
49         properties.setProperty("kaptcha.obscurificator.impl", KaptchaWaterRipple.class.getName());
50         Config config = new Config(properties);
51         defaultKaptcha.setConfig(config);
52         return defaultKaptcha;
53     }
54 }
KaptchaConfig.java
 1 package com.zihans.train.business.config;
 2 
 3 import com.google.code.kaptcha.BackgroundProducer;
 4 import com.google.code.kaptcha.util.Configurable;
 5 
 6 import java.awt.*;
 7 import java.awt.geom.Rectangle2D;
 8 import java.awt.image.BufferedImage;
 9 
10 public class KaptchaNoBackhround extends Configurable implements BackgroundProducer {
11 
12     public KaptchaNoBackhround(){
13     }
14     @Override
15     public BufferedImage addBackground(BufferedImage baseImage) {
16         int width = baseImage.getWidth();
17         int height = baseImage.getHeight();
18         BufferedImage imageWithBackground = new BufferedImage(width, height, 1);
19         Graphics2D graph = (Graphics2D)imageWithBackground.getGraphics();
20         graph.fill(new Rectangle2D.Double(0.0D, 0.0D, (double)width, (double)height));
21         graph.drawImage(baseImage, 0, 0, null);
22         return imageWithBackground;
23     }
24 }
KaptchaNoBackhround.java
 1 package com.zihans.train.business.config;
 2 
 3 import com.google.code.kaptcha.GimpyEngine;
 4 import com.google.code.kaptcha.NoiseProducer;
 5 import com.google.code.kaptcha.util.Configurable;
 6 import com.jhlabs.image.RippleFilter;
 7 
 8 import java.awt.*;
 9 import java.awt.image.BufferedImage;
10 import java.awt.image.ImageObserver;
11 import java.util.Random;
12 
13 public class KaptchaWaterRipple extends Configurable implements GimpyEngine {
14     public KaptchaWaterRipple(){}
15 
16     @Override
17     public BufferedImage getDistortedImage(BufferedImage baseImage) {
18         NoiseProducer noiseProducer = this.getConfig().getNoiseImpl();
19         BufferedImage distortedImage = new BufferedImage(baseImage.getWidth(), baseImage.getHeight(), 2);
20         Graphics2D graph = (Graphics2D)distortedImage.getGraphics();
21         Random rand = new Random();
22         RippleFilter rippleFilter = new RippleFilter();
23         rippleFilter.setXAmplitude(7.6F);
24         rippleFilter.setYAmplitude(rand.nextFloat() + 1.0F);
25         rippleFilter.setEdgeAction(1);
26         BufferedImage effectImage = rippleFilter.filter(baseImage, (BufferedImage)null);
27         graph.drawImage(effectImage, 0, 0, (Color)null, (ImageObserver)null);
28         graph.dispose();
29         noiseProducer.makeNoise(distortedImage, 0.1F, 0.1F, 0.25F, 0.25F);
30         noiseProducer.makeNoise(distortedImage, 0.1F, 0.25F, 0.5F, 0.9F);
31         return distortedImage;
32     }
33 }
KaptchaWaterRipple.java

验证码接口KaptchaController.java

验证码先存放到redis,不需要放到数据库。

前端生成唯一token,每次都不一样,用于贯穿验证码的生成和校验过程。

 1 @RestController
 2 @RequestMapping("/kaptcha")
 3 public class KaptchaController {
 4 
 5     @Qualifier("getDefaultKaptcha")
 6     @Autowired
 7     DefaultKaptcha defaultKaptcha;
 8 
 9     @Resource
10     public StringRedisTemplate stringRedisTemplate;
11 
12     @GetMapping("/image-code/{imageCodeToken}")
13     public void imageCode(@PathVariable(value = "imageCodeToken") String imageCodeToken, HttpServletResponse httpServletResponse) throws Exception{
14         ByteArrayOutputStream jpegOutputStream = new ByteArrayOutputStream();
15         try {
16             // 生成验证码字符串
17             String createText = defaultKaptcha.createText();
18 
19             // 将生成的验证码放入redis缓存中,后续验证的时候用到
20             stringRedisTemplate.opsForValue().set(imageCodeToken, createText, 300, TimeUnit.SECONDS);
21 
22             // 使用验证码字符串生成验证码图片
23             BufferedImage challenge = defaultKaptcha.createImage(createText);
24             ImageIO.write(challenge, "jpg", jpegOutputStream);
25         } catch (IllegalArgumentException e) {
26             httpServletResponse.sendError(HttpServletResponse.SC_NOT_FOUND);
27             return;
28         }
29 
30         // 定义response输出类型为image/jpeg类型,使用response输出流输出图片的byte数组
31         byte[] captchaChallengeAsJpeg = jpegOutputStream.toByteArray();
32         httpServletResponse.setHeader("Cache-Control", "no-store");
33         httpServletResponse.setHeader("Pragma", "no-cache");
34         httpServletResponse.setDateHeader("Expires", 0);
35         httpServletResponse.setContentType("image/jpeg");
36         ServletOutputStream responseOutputStream = httpServletResponse.getOutputStream();
37         responseOutputStream.write(captchaChallengeAsJpeg);
38         responseOutputStream.flush();
39         responseOutputStream.close();
40     }
41 }

购票页面,调用购票接口之前,显示验证码

  1 <template>
  2   <div class="order-train">
  3     <span class="order-train-main">{{dailyTrainTicket.date}}</span>&nbsp;
  4     <span class="order-train-main">{{dailyTrainTicket.trainCode}}</span>次&nbsp;
  5     <span class="order-train-main">{{dailyTrainTicket.start}}</span>站
  6     <span class="order-train-main">({{dailyTrainTicket.startTime}})</span>&nbsp;
  7     <span class="order-train-main">——</span>&nbsp;
  8     <span class="order-train-main">{{dailyTrainTicket.end}}</span>站
  9     <span class="order-train-main">({{dailyTrainTicket.endTime}})</span>&nbsp;
 10 
 11     <div class="order-train-ticket">
 12       <span v-for="item in seatTypes" :key="item.type">
 13         <span>{{item.desc}}</span>:
 14         <span class="order-train-ticket-main">{{item.price}}¥</span>&nbsp;
 15         <span class="order-train-ticket-main">{{item.count}}</span>&nbsp;张票&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
 16       </span>
 17     </div>
 18   </div>
 19   <a-divider></a-divider>
 20   <b>勾选要购票的乘客:</b>&nbsp;
 21   <a-checkbox-group v-model:value="passengerChecks" :options="passengerOptions" />
 22 
 23   <div class="order-tickets">
 24     <a-row class="order-tickets-header" v-if="tickets.length > 0">
 25       <a-col :span="2">乘客</a-col>
 26       <a-col :span="6">身份证</a-col>
 27       <a-col :span="4">票种</a-col>
 28       <a-col :span="4">座位类型</a-col>
 29     </a-row>
 30     <a-row class="order-tickets-row" v-for="ticket in tickets" :key="ticket.passengerId">
 31       <a-col :span="2">{{ticket.passengerName}}</a-col>
 32       <a-col :span="6">{{ticket.passengerIdCard}}</a-col>
 33       <a-col :span="4">
 34         <a-select v-model:value="ticket.passengerType" style="width: 100%">
 35           <a-select-option v-for="item in PASSENGER_TYPE_ARRAY" :key="item.code" :value="item.code">
 36             {{item.desc}}
 37           </a-select-option>
 38         </a-select>
 39       </a-col>
 40       <a-col :span="4">
 41         <a-select v-model:value="ticket.seatTypeCode" style="width: 100%">
 42           <a-select-option v-for="item in seatTypes" :key="item.code" :value="item.code">
 43             {{item.desc}}
 44           </a-select-option>
 45         </a-select>
 46       </a-col>
 47     </a-row>
 48   </div>
 49   <div v-if="tickets.length > 0">
 50     <a-button type="primary" size="large" @click="finishCheckPassenger">提交订单</a-button>
 51   </div>
 52 
 53   <a-modal v-model:visible="visible" title="请核对以下信息"
 54            style="top: 50px; width: 800px"
 55            ok-text="确认" cancel-text="取消"
 56            @ok="showImageCodeModal">
 57     <div class="order-tickets">
 58       <a-row class="order-tickets-header" v-if="tickets.length > 0">
 59         <a-col :span="3">乘客</a-col>
 60         <a-col :span="15">身份证</a-col>
 61         <a-col :span="3">票种</a-col>
 62         <a-col :span="3">座位类型</a-col>
 63       </a-row>
 64       <a-row class="order-tickets-row" v-for="ticket in tickets" :key="ticket.passengerId">
 65         <a-col :span="3">{{ticket.passengerName}}</a-col>
 66         <a-col :span="15">{{ticket.passengerIdCard}}</a-col>
 67         <a-col :span="3">
 68           <span v-for="item in PASSENGER_TYPE_ARRAY" :key="item.code">
 69             <span v-if="item.code === ticket.passengerType">
 70               {{item.desc}}
 71             </span>
 72           </span>
 73         </a-col>
 74         <a-col :span="3">
 75           <span v-for="item in seatTypes" :key="item.code">
 76             <span v-if="item.code === ticket.seatTypeCode">
 77               {{item.desc}}
 78             </span>
 79           </span>
 80         </a-col>
 81       </a-row>
 82       <br/>
 83       <div v-if="chooseSeatType === 0" style="color: red;">
 84         您购买的车票不支持选座
 85         <div>12306规则:只有全部是一等座或全部是二等座才支持选座</div>
 86         <div>12306规则:余票小于一定数量时,不允许选座(本项目以20为例)</div>
 87       </div>
 88       <div v-else style="text-align: center">
 89         <a-switch class="choose-seat-item" v-for="item in SEAT_COL_ARRAY" :key="item.code"
 90                   v-model:checked="chooseSeatObj[item.code + '1']" :checked-children="item.desc" :un-checked-children="item.desc" />
 91         <div v-if="tickets.length > 1">
 92           <a-switch class="choose-seat-item" v-for="item in SEAT_COL_ARRAY" :key="item.code"
 93                     v-model:checked="chooseSeatObj[item.code + '2']" :checked-children="item.desc" :un-checked-children="item.desc" />
 94         </div>
 95         <div style="color: #999999">提示:您可以选择{{tickets.length}}个座位</div>
 96       </div>
 97       <!--<br/>-->
 98       <!--最终购票:{{tickets}}-->
 99       <!--最终选座:{{chooseSeatObj}}-->
100     </div>
101   </a-modal>
102 
103   <!-- 验证码 -->
104   <a-modal v-model:visible="imageCodeModalVisible" :title="null" :footer="null" :closable="false"
105            style="top: 50px; width: 400px">
106     <p style="text-align: center; font-weight: bold; font-size: 18px">使用验证码削弱瞬时高峰</p>
107     <p>
108       <a-input v-model:value="imageCode" placeholder="图片验证码">
109         <template #suffix>
110           <img v-show="!!imageCodeSrc" :src="imageCodeSrc" alt="验证码" v-on:click="loadImageCode()"/>
111         </template>
112       </a-input>
113     </p>
114     <a-button type="danger" block @click="handleOk">输入验证码后开始购票</a-button>
115   </a-modal>
116 </template>
117 
118 <script>
119 
120 import {defineComponent, ref, onMounted, watch, computed} from 'vue';
121 import axios from "axios";
122 import {notification} from "ant-design-vue";
123 
124 export default defineComponent({
125   name: "order-view",
126   setup() {
127     const passengers = ref([]);
128     const passengerOptions = ref([]);
129     const passengerChecks = ref([]);
130     const dailyTrainTicket = SessionStorage.get(SESSION_ORDER) || {};
131     console.log("下单的车次信息", dailyTrainTicket);
132 
133     const SEAT_TYPE = window.SEAT_TYPE;
134     console.log(SEAT_TYPE)
135     // 本车次提供的座位类型seatTypes,含票价,余票等信息,例:
136     // {
137     //   type: "YDZ",
138     //   code: "1",
139     //   desc: "一等座",
140     //   count: "100",
141     //   price: "50",
142     // }
143     // 关于SEAT_TYPE[KEY]:当知道某个具体的属性xxx时,可以用obj.xxx,当属性名是个变量时,可以使用obj[xxx]
144     const seatTypes = [];
145     for (let KEY in SEAT_TYPE) {
146       let key = KEY.toLowerCase();
147       if (dailyTrainTicket[key] >= 0) {
148         seatTypes.push({
149           type: KEY,
150           code: SEAT_TYPE[KEY]["code"],
151           desc: SEAT_TYPE[KEY]["desc"],
152           count: dailyTrainTicket[key],
153           price: dailyTrainTicket[key + 'Price'],
154         })
155       }
156     }
157     console.log("本车次提供的座位:", seatTypes)
158     // 购票列表,用于界面展示,并传递到后端接口,用来描述:哪个乘客购买什么座位的票
159     // {
160     //   passengerId: 123,
161     //   passengerType: "1",
162     //   passengerName: "张三",
163     //   passengerIdCard: "12323132132",
164     //   seatTypeCode: "1",
165     //   seat: "C1"
166     // }
167     const tickets = ref([]);
168     const PASSENGER_TYPE_ARRAY = window.PASSENGER_TYPE_ARRAY;
169     const visible = ref(false);
170 
171     // 勾选或去掉某个乘客时,在购票列表中加上或去掉一张表
172     watch(() => passengerChecks.value, (newVal, oldVal)=>{
173       console.log("勾选乘客发生变化", newVal, oldVal)
174       // 每次有变化时,把购票列表清空,重新构造列表
175       tickets.value = [];
176       passengerChecks.value.forEach((item) => tickets.value.push({
177         passengerId: item.id,
178         passengerType: item.type,
179         seatTypeCode: seatTypes[0].code,
180         passengerName: item.name,
181         passengerIdCard: item.idCard
182       }))
183     }, {immediate: true});
184 
185     // 0:不支持选座;1:选一等座;2:选二等座
186     const chooseSeatType = ref(0);
187     // 根据选择的座位类型,计算出对应的列,比如要选的是一等座,就筛选出ACDF,要选的是二等座,就筛选出ABCDF
188     const SEAT_COL_ARRAY = computed(() => {
189       return window.SEAT_COL_ARRAY.filter(item => item.type === chooseSeatType.value);
190     });
191     // 选择的座位
192     // {
193     //   A1: false, C1: true,D1: false, F1: false,
194     //   A2: false, C2: false,D2: true, F2: false
195     // }
196     const chooseSeatObj = ref({});
197     watch(() => SEAT_COL_ARRAY.value, () => {
198       chooseSeatObj.value = {};
199       for (let i = 1; i <= 2; i++) {
200         SEAT_COL_ARRAY.value.forEach((item) => {
201           chooseSeatObj.value[item.code + i] = false;
202         })
203       }
204       console.log("初始化两排座位,都是未选中:", chooseSeatObj.value);
205     }, {immediate: true});
206 
207     const handleQueryPassenger = () => {
208       axios.get("/member/passenger/query-mine").then((response) => {
209         let data = response.data;
210         if (data.success) {
211           passengers.value = data.content;
212           passengers.value.forEach((item) => passengerOptions.value.push({
213             label: item.name,
214             value: item
215           }))
216         } else {
217           notification.error({description: data.message});
218         }
219       });
220     };
221 
222     const finishCheckPassenger = () => {
223       console.log("购票列表:", tickets.value);
224 
225       if (tickets.value.length > 5) {
226         notification.error({description: '最多只能购买5张车票'});
227         return;
228       }
229 
230       // 校验余票是否充足,购票列表中的每个座位类型,都去车次座位余票信息中,看余票是否充足
231       // 前端校验不一定准,但前端校验可以减轻后端很多压力
232       // 注意:这段只是校验,必须copy出seatTypesTemp变量来扣减,用原始的seatTypes去扣减,会影响真实的库存
233       let seatTypesTemp = Tool.copy(seatTypes);
234       for (let i = 0; i < tickets.value.length; i++) {
235         let ticket = tickets.value[i];
236         for (let j = 0; j < seatTypesTemp.length; j++) {
237           let seatType = seatTypesTemp[j];
238           // 同类型座位余票-1,这里扣减的是临时copy出来的库存,不是真正的库存,只是为了校验
239           if (ticket.seatTypeCode === seatType.code) {
240             seatType.count--;
241             if (seatType.count < 0) {
242               notification.error({description: seatType.desc + '余票不足'});
243               return;
244             }
245           }
246         }
247       }
248       console.log("前端余票校验通过");
249 
250       // 判断是否支持选座,只有纯一等座和纯二等座支持选座
251       // 先筛选出购票列表中的所有座位类型,比如四张表:[1, 1, 2, 2]
252       let ticketSeatTypeCodes = [];
253       for (let i = 0; i < tickets.value.length; i++) {
254         let ticket = tickets.value[i];
255         ticketSeatTypeCodes.push(ticket.seatTypeCode);
256       }
257       // 为购票列表中的所有座位类型去重:[1, 2]
258       const ticketSeatTypeCodesSet = Array.from(new Set(ticketSeatTypeCodes));
259       console.log("选好的座位类型:", ticketSeatTypeCodesSet);
260       if (ticketSeatTypeCodesSet.length !== 1) {
261         console.log("选了多种座位,不支持选座");
262         chooseSeatType.value = 0;
263       } else {
264         // ticketSeatTypeCodesSet.length === 1,即只选择了一种座位(不是一个座位,是一种座位)
265         if (ticketSeatTypeCodesSet[0] === SEAT_TYPE.YDZ.code) {
266           console.log("一等座选座");
267           chooseSeatType.value = SEAT_TYPE.YDZ.code;
268         } else if (ticketSeatTypeCodesSet[0] === SEAT_TYPE.EDZ.code) {
269           console.log("二等座选座");
270           chooseSeatType.value = SEAT_TYPE.EDZ.code;
271         } else {
272           console.log("不是一等座或二等座,不支持选座");
273           chooseSeatType.value = 0;
274         }
275 
276         // 余票小于20张时,不允许选座,否则选座成功率不高,影响出票
277         if (chooseSeatType.value !== 0) {
278           for (let i = 0; i < seatTypes.length; i++) {
279             let seatType = seatTypes[i];
280             // 找到同类型座位
281             if (ticketSeatTypeCodesSet[0] === seatType.code) {
282               // 判断余票,小于20张就不支持选座
283               if (seatType.count < 20) {
284                 console.log("余票小于20张就不支持选座")
285                 chooseSeatType.value = 0;
286                 break;
287               }
288             }
289           }
290         }
291       }
292 
293       // 弹出确认界面
294       visible.value = true;
295 
296     };
297 
298     const handleOk = () => {
299       if (Tool.isEmpty(imageCode.value)) {
300         notification.error({description: '验证码不能为空'});
301         return;
302       }
303 
304       console.log("选好的座位:", chooseSeatObj.value);
305 
306       // 设置每张票的座位
307       // 先清空购票列表的座位,有可能之前选了并设置座位了,但选座数不对被拦截了,又重新选一遍
308       for (let i = 0; i < tickets.value.length; i++) {
309         tickets.value[i].seat = null;
310       }
311       let i = -1;
312       // 要么不选座位,要么所选座位应该等于购票数,即i === (tickets.value.length - 1)
313       for (let key in chooseSeatObj.value) {
314         if (chooseSeatObj.value[key]) {
315           i++;
316           if (i > tickets.value.length - 1) {
317             notification.error({description: '所选座位数大于购票数'});
318             return;
319           }
320           tickets.value[i].seat = key;
321         }
322       }
323       if (i > -1 && i < (tickets.value.length - 1)) {
324         notification.error({description: '所选座位数小于购票数'});
325         return;
326       }
327 
328       console.log("最终购票:", tickets.value);
329 
330       axios.post("/business/confirm-order/do", {
331         dailyTrainTicketId: dailyTrainTicket.id,
332         date: dailyTrainTicket.date,
333         trainCode: dailyTrainTicket.trainCode,
334         start: dailyTrainTicket.start,
335         end: dailyTrainTicket.end,
336         tickets: tickets.value
337       }).then((response) => {
338         let data = response.data;
339         if (data.success) {
340           notification.success({description: "下单成功!"});
341         } else {
342           notification.error({description: data.message});
343         }
344       });
345     }
346 
347     /* ------------------- 验证码 --------------------- */
348     const imageCodeModalVisible = ref();
349     const imageCodeToken = ref();
350     const imageCodeSrc = ref();
351     const imageCode = ref();
352     /**
353      * 加载图形验证码
354      */
355     const loadImageCode = () => {
356       imageCodeToken.value = Tool.uuid(8);
357       imageCodeSrc.value = process.env.VUE_APP_SERVER + '/business/kaptcha/image-code/' + imageCodeToken.value;
358     };
359 
360     const showImageCodeModal = () => {
361       loadImageCode();
362       imageCodeModalVisible.value = true;
363     };
364 
365     onMounted(() => {
366       handleQueryPassenger();
367     });
368 
369     return {
370       passengers,
371       dailyTrainTicket,
372       seatTypes,
373       passengerOptions,
374       passengerChecks,
375       tickets,
376       PASSENGER_TYPE_ARRAY,
377       visible,
378       finishCheckPassenger,
379       chooseSeatType,
380       chooseSeatObj,
381       SEAT_COL_ARRAY,
382       handleOk,
383       imageCodeToken,
384       imageCodeSrc,
385       imageCode,
386       showImageCodeModal,
387       imageCodeModalVisible,
388       loadImageCode
389     };
390   },
391 });
392 </script>
393 
394 <style>
395 .order-train .order-train-main {
396   font-size: 18px;
397   font-weight: bold;
398 }
399 .order-train .order-train-ticket {
400   margin-top: 15px;
401 }
402 .order-train .order-train-ticket .order-train-ticket-main {
403   color: red;
404   font-size: 18px;
405 }
406 
407 .order-tickets {
408   margin: 10px 0;
409 }
410 .order-tickets .ant-col {
411   padding: 5px 10px;
412 }
413 .order-tickets .order-tickets-header {
414   background-color: cornflowerblue;
415   border: solid 1px cornflowerblue;
416   color: white;
417   font-size: 16px;
418   padding: 5px 0;
419 }
420 .order-tickets .order-tickets-row {
421   border: solid 1px cornflowerblue;
422   border-top: none;
423   vertical-align: middle;
424   line-height: 30px;
425 }
426 
427 .order-tickets .choose-seat-item {
428   margin: 5px 5px;
429 }
430 </style>
order.vue

购票接口,增加验证码校验

public CommonResp<Object> doConfirm(@Valid @RequestBody ConfirmOrderDoReq req) {

        // 图形验证码校验
        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);
        }

        confirmOrderService.doConfirm(req);
        return new CommonResp<>();
    }

增加第一层验证码削弱瞬时高峰,减小第二层验证码接口的压力

  1 <template>
  2   <div class="order-train">
  3     <span class="order-train-main">{{dailyTrainTicket.date}}</span>&nbsp;
  4     <span class="order-train-main">{{dailyTrainTicket.trainCode}}</span>次&nbsp;
  5     <span class="order-train-main">{{dailyTrainTicket.start}}</span>站
  6     <span class="order-train-main">({{dailyTrainTicket.startTime}})</span>&nbsp;
  7     <span class="order-train-main">——</span>&nbsp;
  8     <span class="order-train-main">{{dailyTrainTicket.end}}</span>站
  9     <span class="order-train-main">({{dailyTrainTicket.endTime}})</span>&nbsp;
 10 
 11     <div class="order-train-ticket">
 12       <span v-for="item in seatTypes" :key="item.type">
 13         <span>{{item.desc}}</span>:
 14         <span class="order-train-ticket-main">{{item.price}}¥</span>&nbsp;
 15         <span class="order-train-ticket-main">{{item.count}}</span>&nbsp;张票&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
 16       </span>
 17     </div>
 18   </div>
 19   <a-divider></a-divider>
 20   <b>勾选要购票的乘客:</b>&nbsp;
 21   <a-checkbox-group v-model:value="passengerChecks" :options="passengerOptions" />
 22 
 23   <div class="order-tickets">
 24     <a-row class="order-tickets-header" v-if="tickets.length > 0">
 25       <a-col :span="2">乘客</a-col>
 26       <a-col :span="6">身份证</a-col>
 27       <a-col :span="4">票种</a-col>
 28       <a-col :span="4">座位类型</a-col>
 29     </a-row>
 30     <a-row class="order-tickets-row" v-for="ticket in tickets" :key="ticket.passengerId">
 31       <a-col :span="2">{{ticket.passengerName}}</a-col>
 32       <a-col :span="6">{{ticket.passengerIdCard}}</a-col>
 33       <a-col :span="4">
 34         <a-select v-model:value="ticket.passengerType" style="width: 100%">
 35           <a-select-option v-for="item in PASSENGER_TYPE_ARRAY" :key="item.code" :value="item.code">
 36             {{item.desc}}
 37           </a-select-option>
 38         </a-select>
 39       </a-col>
 40       <a-col :span="4">
 41         <a-select v-model:value="ticket.seatTypeCode" style="width: 100%">
 42           <a-select-option v-for="item in seatTypes" :key="item.code" :value="item.code">
 43             {{item.desc}}
 44           </a-select-option>
 45         </a-select>
 46       </a-col>
 47     </a-row>
 48   </div>
 49   <div v-if="tickets.length > 0">
 50     <a-button type="primary" size="large" @click="finishCheckPassenger">提交订单</a-button>
 51   </div>
 52 
 53   <a-modal v-model:visible="visible" title="请核对以下信息"
 54            style="top: 50px; width: 800px"
 55            ok-text="确认" cancel-text="取消"
 56            @ok="showFirstImageCodeModal">
 57     <div class="order-tickets">
 58       <a-row class="order-tickets-header" v-if="tickets.length > 0">
 59         <a-col :span="3">乘客</a-col>
 60         <a-col :span="15">身份证</a-col>
 61         <a-col :span="3">票种</a-col>
 62         <a-col :span="3">座位类型</a-col>
 63       </a-row>
 64       <a-row class="order-tickets-row" v-for="ticket in tickets" :key="ticket.passengerId">
 65         <a-col :span="3">{{ticket.passengerName}}</a-col>
 66         <a-col :span="15">{{ticket.passengerIdCard}}</a-col>
 67         <a-col :span="3">
 68           <span v-for="item in PASSENGER_TYPE_ARRAY" :key="item.code">
 69             <span v-if="item.code === ticket.passengerType">
 70               {{item.desc}}
 71             </span>
 72           </span>
 73         </a-col>
 74         <a-col :span="3">
 75           <span v-for="item in seatTypes" :key="item.code">
 76             <span v-if="item.code === ticket.seatTypeCode">
 77               {{item.desc}}
 78             </span>
 79           </span>
 80         </a-col>
 81       </a-row>
 82       <br/>
 83       <div v-if="chooseSeatType === 0" style="color: red;">
 84         您购买的车票不支持选座
 85         <div>12306规则:只有全部是一等座或全部是二等座才支持选座</div>
 86         <div>12306规则:余票小于一定数量时,不允许选座(本项目以20为例)</div>
 87       </div>
 88       <div v-else style="text-align: center">
 89         <a-switch class="choose-seat-item" v-for="item in SEAT_COL_ARRAY" :key="item.code"
 90                   v-model:checked="chooseSeatObj[item.code + '1']" :checked-children="item.desc" :un-checked-children="item.desc" />
 91         <div v-if="tickets.length > 1">
 92           <a-switch class="choose-seat-item" v-for="item in SEAT_COL_ARRAY" :key="item.code"
 93                     v-model:checked="chooseSeatObj[item.code + '2']" :checked-children="item.desc" :un-checked-children="item.desc" />
 94         </div>
 95         <div style="color: #999999">提示:您可以选择{{tickets.length}}个座位</div>
 96       </div>
 97       <!--<br/>-->
 98       <!--最终购票:{{tickets}}-->
 99       <!--最终选座:{{chooseSeatObj}}-->
100     </div>
101   </a-modal>
102 
103   <!-- 第二层验证码 后端 -->
104   <a-modal v-model:visible="imageCodeModalVisible" :title="null" :footer="null" :closable="false"
105            style="top: 50px; width: 400px">
106     <p style="text-align: center; font-weight: bold; font-size: 18px">
107       使用服务端验证码削弱瞬时高峰<br/>
108       防止机器人刷票
109     </p>
110     <p>
111       <a-input v-model:value="imageCode" placeholder="图片验证码">
112         <template #suffix>
113           <img v-show="!!imageCodeSrc" :src="imageCodeSrc" alt="验证码" v-on:click="loadImageCode()"/>
114         </template>
115       </a-input>
116     </p>
117     <a-button type="danger" block @click="handleOk">输入验证码后开始购票</a-button>
118   </a-modal>
119 
120   <!-- 第一层验证码 纯前端 -->
121   <a-modal v-model:visible="firstImageCodeModalVisible" :title="null" :footer="null" :closable="false"
122            style="top: 50px; width: 400px">
123     <p style="text-align: center; font-weight: bold; font-size: 18px">
124       使用纯前端验证码削弱瞬时高峰<br/>
125       减小后端验证码接口的压力
126     </p>
127     <p>
128       <a-input v-model:value="firstImageCodeTarget" placeholder="验证码">
129         <template #suffix>
130           {{firstImageCodeSourceA}} + {{firstImageCodeSourceB}}
131         </template>
132       </a-input>
133     </p>
134     <a-button type="danger" block @click="validFirstImageCode">提交验证码</a-button>
135   </a-modal>
136 </template>
137 
138 <script>
139 
140 import {defineComponent, ref, onMounted, watch, computed} from 'vue';
141 import axios from "axios";
142 import {notification} from "ant-design-vue";
143 
144 export default defineComponent({
145   name: "order-view",
146   setup() {
147     const passengers = ref([]);
148     const passengerOptions = ref([]);
149     const passengerChecks = ref([]);
150     const dailyTrainTicket = SessionStorage.get(SESSION_ORDER) || {};
151     console.log("下单的车次信息", dailyTrainTicket);
152 
153     const SEAT_TYPE = window.SEAT_TYPE;
154     console.log(SEAT_TYPE)
155     // 本车次提供的座位类型seatTypes,含票价,余票等信息,例:
156     // {
157     //   type: "YDZ",
158     //   code: "1",
159     //   desc: "一等座",
160     //   count: "100",
161     //   price: "50",
162     // }
163     // 关于SEAT_TYPE[KEY]:当知道某个具体的属性xxx时,可以用obj.xxx,当属性名是个变量时,可以使用obj[xxx]
164     const seatTypes = [];
165     for (let KEY in SEAT_TYPE) {
166       let key = KEY.toLowerCase();
167       if (dailyTrainTicket[key] >= 0) {
168         seatTypes.push({
169           type: KEY,
170           code: SEAT_TYPE[KEY]["code"],
171           desc: SEAT_TYPE[KEY]["desc"],
172           count: dailyTrainTicket[key],
173           price: dailyTrainTicket[key + 'Price'],
174         })
175       }
176     }
177     console.log("本车次提供的座位:", seatTypes)
178     // 购票列表,用于界面展示,并传递到后端接口,用来描述:哪个乘客购买什么座位的票
179     // {
180     //   passengerId: 123,
181     //   passengerType: "1",
182     //   passengerName: "张三",
183     //   passengerIdCard: "12323132132",
184     //   seatTypeCode: "1",
185     //   seat: "C1"
186     // }
187     const tickets = ref([]);
188     const PASSENGER_TYPE_ARRAY = window.PASSENGER_TYPE_ARRAY;
189     const visible = ref(false);
190 
191     // 勾选或去掉某个乘客时,在购票列表中加上或去掉一张表
192     watch(() => passengerChecks.value, (newVal, oldVal)=>{
193       console.log("勾选乘客发生变化", newVal, oldVal)
194       // 每次有变化时,把购票列表清空,重新构造列表
195       tickets.value = [];
196       passengerChecks.value.forEach((item) => tickets.value.push({
197         passengerId: item.id,
198         passengerType: item.type,
199         seatTypeCode: seatTypes[0].code,
200         passengerName: item.name,
201         passengerIdCard: item.idCard
202       }))
203     }, {immediate: true});
204 
205     // 0:不支持选座;1:选一等座;2:选二等座
206     const chooseSeatType = ref(0);
207     // 根据选择的座位类型,计算出对应的列,比如要选的是一等座,就筛选出ACDF,要选的是二等座,就筛选出ABCDF
208     const SEAT_COL_ARRAY = computed(() => {
209       return window.SEAT_COL_ARRAY.filter(item => item.type === chooseSeatType.value);
210     });
211     // 选择的座位
212     // {
213     //   A1: false, C1: true,D1: false, F1: false,
214     //   A2: false, C2: false,D2: true, F2: false
215     // }
216     const chooseSeatObj = ref({});
217     watch(() => SEAT_COL_ARRAY.value, () => {
218       chooseSeatObj.value = {};
219       for (let i = 1; i <= 2; i++) {
220         SEAT_COL_ARRAY.value.forEach((item) => {
221           chooseSeatObj.value[item.code + i] = false;
222         })
223       }
224       console.log("初始化两排座位,都是未选中:", chooseSeatObj.value);
225     }, {immediate: true});
226 
227     const handleQueryPassenger = () => {
228       axios.get("/member/passenger/query-mine").then((response) => {
229         let data = response.data;
230         if (data.success) {
231           passengers.value = data.content;
232           passengers.value.forEach((item) => passengerOptions.value.push({
233             label: item.name,
234             value: item
235           }))
236         } else {
237           notification.error({description: data.message});
238         }
239       });
240     };
241 
242     const finishCheckPassenger = () => {
243       console.log("购票列表:", tickets.value);
244 
245       if (tickets.value.length > 5) {
246         notification.error({description: '最多只能购买5张车票'});
247         return;
248       }
249 
250       // 校验余票是否充足,购票列表中的每个座位类型,都去车次座位余票信息中,看余票是否充足
251       // 前端校验不一定准,但前端校验可以减轻后端很多压力
252       // 注意:这段只是校验,必须copy出seatTypesTemp变量来扣减,用原始的seatTypes去扣减,会影响真实的库存
253       let seatTypesTemp = Tool.copy(seatTypes);
254       for (let i = 0; i < tickets.value.length; i++) {
255         let ticket = tickets.value[i];
256         for (let j = 0; j < seatTypesTemp.length; j++) {
257           let seatType = seatTypesTemp[j];
258           // 同类型座位余票-1,这里扣减的是临时copy出来的库存,不是真正的库存,只是为了校验
259           if (ticket.seatTypeCode === seatType.code) {
260             seatType.count--;
261             if (seatType.count < 0) {
262               notification.error({description: seatType.desc + '余票不足'});
263               return;
264             }
265           }
266         }
267       }
268       console.log("前端余票校验通过");
269 
270       // 判断是否支持选座,只有纯一等座和纯二等座支持选座
271       // 先筛选出购票列表中的所有座位类型,比如四张表:[1, 1, 2, 2]
272       let ticketSeatTypeCodes = [];
273       for (let i = 0; i < tickets.value.length; i++) {
274         let ticket = tickets.value[i];
275         ticketSeatTypeCodes.push(ticket.seatTypeCode);
276       }
277       // 为购票列表中的所有座位类型去重:[1, 2]
278       const ticketSeatTypeCodesSet = Array.from(new Set(ticketSeatTypeCodes));
279       console.log("选好的座位类型:", ticketSeatTypeCodesSet);
280       if (ticketSeatTypeCodesSet.length !== 1) {
281         console.log("选了多种座位,不支持选座");
282         chooseSeatType.value = 0;
283       } else {
284         // ticketSeatTypeCodesSet.length === 1,即只选择了一种座位(不是一个座位,是一种座位)
285         if (ticketSeatTypeCodesSet[0] === SEAT_TYPE.YDZ.code) {
286           console.log("一等座选座");
287           chooseSeatType.value = SEAT_TYPE.YDZ.code;
288         } else if (ticketSeatTypeCodesSet[0] === SEAT_TYPE.EDZ.code) {
289           console.log("二等座选座");
290           chooseSeatType.value = SEAT_TYPE.EDZ.code;
291         } else {
292           console.log("不是一等座或二等座,不支持选座");
293           chooseSeatType.value = 0;
294         }
295 
296         // 余票小于20张时,不允许选座,否则选座成功率不高,影响出票
297         if (chooseSeatType.value !== 0) {
298           for (let i = 0; i < seatTypes.length; i++) {
299             let seatType = seatTypes[i];
300             // 找到同类型座位
301             if (ticketSeatTypeCodesSet[0] === seatType.code) {
302               // 判断余票,小于20张就不支持选座
303               if (seatType.count < 20) {
304                 console.log("余票小于20张就不支持选座")
305                 chooseSeatType.value = 0;
306                 break;
307               }
308             }
309           }
310         }
311       }
312 
313       // 弹出确认界面
314       visible.value = true;
315 
316     };
317 
318     const handleOk = () => {
319       if (Tool.isEmpty(imageCode.value)) {
320         notification.error({description: '验证码不能为空'});
321         return;
322       }
323 
324       console.log("选好的座位:", chooseSeatObj.value);
325 
326       // 设置每张票的座位
327       // 先清空购票列表的座位,有可能之前选了并设置座位了,但选座数不对被拦截了,又重新选一遍
328       for (let i = 0; i < tickets.value.length; i++) {
329         tickets.value[i].seat = null;
330       }
331       let i = -1;
332       // 要么不选座位,要么所选座位应该等于购票数,即i === (tickets.value.length - 1)
333       for (let key in chooseSeatObj.value) {
334         if (chooseSeatObj.value[key]) {
335           i++;
336           if (i > tickets.value.length - 1) {
337             notification.error({description: '所选座位数大于购票数'});
338             return;
339           }
340           tickets.value[i].seat = key;
341         }
342       }
343       if (i > -1 && i < (tickets.value.length - 1)) {
344         notification.error({description: '所选座位数小于购票数'});
345         return;
346       }
347 
348       console.log("最终购票:", tickets.value);
349 
350       axios.post("/business/confirm-order/do", {
351         dailyTrainTicketId: dailyTrainTicket.id,
352         date: dailyTrainTicket.date,
353         trainCode: dailyTrainTicket.trainCode,
354         start: dailyTrainTicket.start,
355         end: dailyTrainTicket.end,
356         tickets: tickets.value,
357         imageCodeToken: imageCodeToken.value,
358         imageCode: imageCode.value,
359       }).then((response) => {
360         let data = response.data;
361         if (data.success) {
362           notification.success({description: "下单成功!"});
363         } else {
364           notification.error({description: data.message});
365         }
366       });
367     }
368 
369     /* ------------------- 第二层验证码 --------------------- */
370     const imageCodeModalVisible = ref();
371     const imageCodeToken = ref();
372     const imageCodeSrc = ref();
373     const imageCode = ref();
374     /**
375      * 加载图形验证码
376      */
377     const loadImageCode = () => {
378       imageCodeToken.value = Tool.uuid(8);
379       imageCodeSrc.value = process.env.VUE_APP_SERVER + '/business/kaptcha/image-code/' + imageCodeToken.value;
380     };
381 
382     const showImageCodeModal = () => {
383       loadImageCode();
384       imageCodeModalVisible.value = true;
385     };
386 
387     /* ------------------- 第一层验证码 --------------------- */
388     const firstImageCodeSourceA = ref();
389     const firstImageCodeSourceB = ref();
390     const firstImageCodeTarget = ref();
391     const firstImageCodeModalVisible = ref();
392 
393     /**
394      * 加载第一层验证码
395      */
396     const loadFirstImageCode = () => {
397       // 获取1~10的数:Math.floor(Math.random()*10 + 1)
398       firstImageCodeSourceA.value = Math.floor(Math.random()*10 + 1) + 10;
399       firstImageCodeSourceB.value = Math.floor(Math.random()*10 + 1) + 20;
400     };
401 
402     /**
403      * 显示第一层验证码弹出框
404      */
405     const showFirstImageCodeModal = () => {
406       loadFirstImageCode();
407       firstImageCodeModalVisible.value = true;
408     };
409 
410     /**
411      * 校验第一层验证码
412      */
413     const validFirstImageCode = () => {
414       if (parseInt(firstImageCodeTarget.value) === parseInt(firstImageCodeSourceA.value + firstImageCodeSourceB.value)) {
415         // 第一层验证通过
416         firstImageCodeModalVisible.value = false;
417         showImageCodeModal();
418       } else {
419         notification.error({description: '验证码错误'});
420       }
421     };
422 
423     onMounted(() => {
424       handleQueryPassenger();
425     });
426 
427     return {
428       passengers,
429       dailyTrainTicket,
430       seatTypes,
431       passengerOptions,
432       passengerChecks,
433       tickets,
434       PASSENGER_TYPE_ARRAY,
435       visible,
436       finishCheckPassenger,
437       chooseSeatType,
438       chooseSeatObj,
439       SEAT_COL_ARRAY,
440       handleOk,
441       imageCodeToken,
442       imageCodeSrc,
443       imageCode,
444       showImageCodeModal,
445       imageCodeModalVisible,
446       loadImageCode,
447       firstImageCodeSourceA,
448       firstImageCodeSourceB,
449       firstImageCodeTarget,
450       firstImageCodeModalVisible,
451       showFirstImageCodeModal,
452       validFirstImageCode,
453     };
454   },
455 });
456 </script>
457 
458 <style>
459 .order-train .order-train-main {
460   font-size: 18px;
461   font-weight: bold;
462 }
463 .order-train .order-train-ticket {
464   margin-top: 15px;
465 }
466 .order-train .order-train-ticket .order-train-ticket-main {
467   color: red;
468   font-size: 18px;
469 }
470 
471 .order-tickets {
472   margin: 10px 0;
473 }
474 .order-tickets .ant-col {
475   padding: 5px 10px;
476 }
477 .order-tickets .order-tickets-header {
478   background-color: cornflowerblue;
479   border: solid 1px cornflowerblue;
480   color: white;
481   font-size: 16px;
482   padding: 5px 0;
483 }
484 .order-tickets .order-tickets-row {
485   border: solid 1px cornflowerblue;
486   border-top: none;
487   vertical-align: middle;
488   line-height: 30px;
489 }
490 
491 .order-tickets .choose-seat-item {
492   margin: 5px 5px;
493 }
494 </style>
order.vue