十五、高并发抢票时,防止机器人刷票的令牌大闸,可减轻服务器的压力(防刷+限流)
介绍
为什么引入令牌大闸?
- 分布式锁和限流都不能解决机器人刷票问题,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 }
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 }
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 }
验证码接口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> 4 <span class="order-train-main">{{dailyTrainTicket.trainCode}}</span>次 5 <span class="order-train-main">{{dailyTrainTicket.start}}</span>站 6 <span class="order-train-main">({{dailyTrainTicket.startTime}})</span> 7 <span class="order-train-main">——</span> 8 <span class="order-train-main">{{dailyTrainTicket.end}}</span>站 9 <span class="order-train-main">({{dailyTrainTicket.endTime}})</span> 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> 15 <span class="order-train-ticket-main">{{item.count}}</span> 张票 16 </span> 17 </div> 18 </div> 19 <a-divider></a-divider> 20 <b>勾选要购票的乘客:</b> 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>
购票接口,增加验证码校验
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> 4 <span class="order-train-main">{{dailyTrainTicket.trainCode}}</span>次 5 <span class="order-train-main">{{dailyTrainTicket.start}}</span>站 6 <span class="order-train-main">({{dailyTrainTicket.startTime}})</span> 7 <span class="order-train-main">——</span> 8 <span class="order-train-main">{{dailyTrainTicket.end}}</span>站 9 <span class="order-train-main">({{dailyTrainTicket.endTime}})</span> 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> 15 <span class="order-train-ticket-main">{{item.count}}</span> 张票 16 </span> 17 </div> 18 </div> 19 <a-divider></a-divider> 20 <b>勾选要购票的乘客:</b> 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>