## 第16章-订单秒杀 # 1、秒杀业务分析 **学习目标:** - 能够说出秒杀业务流程 - 独立搭建秒杀模块 - 完成基于定时任务完成秒杀商品缓存预热 - 完成秒杀商品列表以及详情展示 - 完成商品秒杀 ## 1.1 需求分析 所谓“秒杀”,就是网络[卖家](https://baike.baidu.com/item/卖家)发布一些超低价格的商品,所有买家在同一时间网上抢购的一种销售方式。通俗一点讲就是网络商家为促销等目的组织的网上限时抢购活动。由于商品价格低廉,往往一上架就被抢购一空,有时只用一秒钟。 秒杀商品通常有三种限制:库存限制、时间限制、购买量限制。 (1)库存限制:商家只拿出限量的商品来秒杀。比如某商品实际库存是200件,但只拿出50件来参与秒杀。我们可以将其称为“秒杀库存”。商家赔本赚吆喝,图啥?人气! (2)时间限制:通常秒杀都是有特定的时间段,只能在设定时间段进行秒杀活动; (3)购买量限制:同一个商品只允许用户最多购买几件。比如某手机限购1件。张某第一次买个1件,那么在该次秒杀活动中就不能再次抢购 ## 1.2 秒杀功能分析 列表页 ![img](assets/day16/wps26.jpg) 详情页 ![img](assets/day16/wps27.jpg) 排队页 ![img](assets/day16/wps28.jpg) 下单页 ![img](assets/day16/wps29.jpg) 支付页 ![img](assets/day16/wps30.jpg) ## 1.3 数据库表 秒杀商品表seckill_goods ![img](assets/day16/wps31.jpg) # 2、搭建秒杀模块 我们先把秒杀模块搭建好,秒杀一共有三个模块,秒杀微服务模块service-activity,负责封装秒杀全部服务端业务;秒杀前端模块web-all,负责前端显示业务;service-activity-client api接口模块 提供秒杀商品基础代码 ## 2.1 搭建service-activity模块 ### 2.1.1 搭建service-activity 搭建方式如service-order ![image-20221209014526405](assets/day16/image-20221209014526405.png) ### 2.1.2 修改pom.xml ```xml gmall-service com.atguigu.gmall 1.0 4.0.0 service-activity com.atguigu.gmall service-user-client 1.0 com.atguigu.gmall service-order-client 1.0 com.atguigu.gmall rabbit-util 1.0 service-activity org.springframework.boot spring-boot-maven-plugin ``` ### 2.1.3 启动类 ```java package com.atguigu.gmall; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; import org.springframework.cloud.openfeign.EnableFeignClients; @SpringBootApplication @EnableDiscoveryClient @EnableFeignClients public class ActivityApp { public static void main(String[] args) { SpringApplication.run(ActivityApp.class, args); } } ``` ### 2.1.3 添加配置 在resources目录下新建:bootstrap.properties ```properties spring.application.name=service-activity spring.profiles.active=dev spring.cloud.nacos.discovery.server-addr=192.168.200.128:8848 spring.cloud.nacos.config.server-addr=192.168.200.128:8848 spring.cloud.nacos.config.prefix=${spring.application.name} spring.cloud.nacos.config.file-extension=yaml spring.cloud.nacos.config.shared-configs[0].data-id=common.yaml ``` ## 2.2 搭建service-activity-client模块 搭建service-activity-client,搭建方式如service-order-client ![image-20221209014830673](assets/day16/image-20221209014830673.png) ## 2.3 添加依赖,配置网关 ### 2.3.1 在web-all中引入依赖 ```xml com.atguigu.gmall service-activity-client 1.0 ``` # 3、秒杀商品导入缓存 缓存数据实现思路:service-task模块统一管理我们的定时任务,为了减少service-task模块的耦合度,我们可以在定时任务模块只发送mq消息,需要执行定时任务的模块监听该消息即可,这样有利于我们后期动态控制,例如:每天凌晨一点我们发送定时任务信息到mq交换机,如果秒杀业务凌晨一点需要导入数据到缓存,那么秒杀业务绑定队列到交换机就可以了,其他业务也是一样,这样就做到了很好的扩展。 上面提到我们要控制库存数量,不能超卖,那么如何控制呢?在这里我们提供一种解决方案,那就我们在导入商品缓存数据时,同时将商品库存信息导入队列{list},利用redis队列的原子性,保证库存不超卖 库存加入队列实施方案 **1,**如果秒杀商品有N个库存,那么我就循环往队列放入N个队列数据 **2,**秒杀开始时,用户进入,然后就从队列里面出队,只有队列里面有数据,说明就一点有库存(redis队列保证了原子性),队列为空了说明商品售罄 ## 3.1 编写定时任务 在service-task模块发送消息 ### 3.1.1 搭建service-task服务 搭建方式如service-mq ![image-20221209015034587](assets/day16/image-20221209015034587.png) ### 3.1.2 修改配置pom.xml ```xml gmall-service com.atguigu.gmall 1.0 4.0.0 service-task com.atguigu.gmall rabbit-util 1.0 service-task org.springframework.boot spring-boot-maven-plugin ``` 说明:引入rabbit-util依赖定时发送消息 ### 3.1.3 启动类 ```java package com.atguigu.gmall; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; @SpringBootApplication(exclude = DataSourceAutoConfiguration.class)//取消数据源自动配置 @EnableDiscoveryClient public class TaskApp { public static void main(String[] args) { SpringApplication.run(TaskApp.class, args); } } ``` ### 3.1.4 添加配置文件 bootstrap.properties ```properties spring.application.name=service-task spring.profiles.active=dev spring.cloud.nacos.discovery.server-addr=192.168.200.128:8848 spring.cloud.nacos.config.server-addr=192.168.200.128:8848 spring.cloud.nacos.config.prefix=${spring.application.name} spring.cloud.nacos.config.file-extension=yaml spring.cloud.nacos.config.shared-configs[0].data-id=common.yaml ``` ### 3.1.4 添加定时任务 定义凌晨一点mq相关常量 ```java /** * 定时任务 */ public static final String EXCHANGE_DIRECT_TASK = "exchange.direct.task"; public static final String ROUTING_TASK_1 = "seckill.task.1"; //队列 public static final String QUEUE_TASK_1 = "queue.task.1"; ``` ```java package com.atguigu.gmall.task.scheduled; import com.atguigu.gmall.common.constant.MqConst; import com.atguigu.gmall.common.service.RabbitService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @Slf4j @Component @EnableScheduling public class ScheduledTask { @Autowired private RabbitService rabbitService; /** * 正式每天凌晨1点执行 * 测试每隔30s执行 */ //@Scheduled(cron = "0/30 * * * * ?") @Scheduled(cron = "0 0 1 * * ?") public void task1() { rabbitService.sendMessage(MqConst.EXCHANGE_DIRECT_TASK, MqConst.ROUTING_TASK_1, ""); } } ``` ## 3.2 监听定时任务信息 在service-activity模块绑定与监听消息,处理缓存逻辑,更新状态位 ### 3.2.1 数据导入缓存 #### 3.2.1.1 在service-util的RedisConst类中定义常量 ```java //秒杀商品前缀 public static final String SECKILL_GOODS = "seckill:goods"; public static final String SECKILL_ORDERS = "seckill:orders"; public static final String SECKILL_ORDERS_USERS = "seckill:orders:users"; public static final String SECKILL_STOCK_PREFIX = "seckill:stock:"; public static final String SECKILL_USER = "seckill:user:"; //用户锁定时间 单位:秒 public static final int SECKILL__TIMEOUT = 60 * 60; ``` #### 3.2.1.2 监听消息 在`service-activity`秒杀微服务中监听消息,批量导入秒杀商品到Redis完成缓存预热 ```java package com.atguigu.gmall.activity.receiver; import com.atguigu.gmall.activity.model.SeckillGoods; import com.atguigu.gmall.activity.service.SeckillGoodsService; import com.atguigu.gmall.common.constant.MqConst; import com.atguigu.gmall.common.constant.RedisConst; import com.atguigu.gmall.common.util.DateUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.rabbitmq.client.Channel; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.core.Message; import org.springframework.amqp.rabbit.annotation.Exchange; import org.springframework.amqp.rabbit.annotation.Queue; import org.springframework.amqp.rabbit.annotation.QueueBinding; import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import java.util.Date; import java.util.List; /** * @author: atguigu * @create: 2023-01-27 21:38 */ @Slf4j @Component public class SeckillReceiver { @Autowired private SeckillGoodsService seckillGoodsService; @Autowired private RedisTemplate redisTemplate; /** * 将秒杀商品进行预热 * * @param message * @param channel */ @RabbitListener(bindings = @QueueBinding( exchange = @Exchange(MqConst.EXCHANGE_DIRECT_TASK), value = @Queue(MqConst.QUEUE_TASK_1), key = {MqConst.ROUTING_TASK_1} )) public void importSeckillGoodsToRedis(Message message, Channel channel) { try { // 将当天的秒杀商品放入缓存!通过mapper 执行sql 语句! // 条件当天 ,剩余库存>0 , 审核状态 = 1 LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(SeckillGoods::getStatus, "1").gt(SeckillGoods::getStockCount, 0); // select DATE_FORMAT(start_time,'%Y-%m-%d') from seckill_goods; yyyy-mm-dd queryWrapper.apply("DATE_FORMAT(start_time,'%Y-%m-%d') = " + "'" + DateUtil.formatDate(new Date()) + "'"); // 获取到当天秒杀的商品列表! List seckillGoodsList = seckillGoodsService.list(queryWrapper); // 将seckillGoodsList 这个集合数据放入缓存! for (SeckillGoods seckillGoods : seckillGoodsList) { // 考虑使用哪种数据类型,以及缓存的key!使用hash! hset key field value hget key field // 定义key = SECKILL_GOODS field = skuId value = seckillGoods // 判断当前缓存key 中是否有 秒杀商品的skuId Boolean flag = redisTemplate.boundHashOps(RedisConst.SECKILL_GOODS).hasKey(seckillGoods.getSkuId().toString()); // 判断 if (flag) { // 表示缓存中已经当前的商品了。 continue; } // 没有就放入缓存! redisTemplate.boundHashOps(RedisConst.SECKILL_GOODS).put(seckillGoods.getSkuId().toString(), seckillGoods); // 将每个商品对应的库存剩余数,放入redis-list 集合中! for (Integer i = 0; i < seckillGoods.getStockCount(); i++) { // 放入list key = seckill:stock:skuId; String key = RedisConst.SECKILL_STOCK_PREFIX + seckillGoods.getSkuId(); redisTemplate.opsForList().leftPush(key, seckillGoods.getSkuId().toString()); } // 秒杀商品在初始化的时候:状态位初始化 1 // publish seckillpush 46:1 | 后续业务如果说商品被秒杀完了! publish seckillpush 46:0 redisTemplate.convertAndSend("seckillpush", seckillGoods.getSkuId() + ":1"); } // 手动确认消息 channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); } catch (Exception e) { e.printStackTrace(); log.error("【秒杀服务】秒杀商品预热异常:{}", e); } } } ``` #### 3.2.1.3 SeckillGoodsMapper ```java package com.atguigu.gmall.activity.mapper; import com.atguigu.gmall.model.activity.SeckillGoods; import com.baomidou.mybatisplus.core.mapper.BaseMapper; public interface SeckillGoodsMapper extends BaseMapper { } ``` ### 3.2.2 更新状态位 由于我们的秒杀服务时集群部署service-activity的,我们面临一个问题?RabbitMQ 如何实现对同一个应用的多个节点进行广播呢? RabbitMQ 只能对绑定到交换机上面的不同队列实现广播,对于同一队列的消费者他们存在竞争关系,同一个消息只会被同一个队列下其中一个消费者接收,达不到广播效果; 我们目前的需求是定时任务发送消息,我们将秒杀商品导入缓存,同事更新集群的状态位,既然RabbitMQ 达不到广播的效果,我们就放弃吗?当然不是,我们很容易就想到一种解决方案,通过redis的发布订阅模式来通知其他兄弟节点,这不问题就解决了吗? 过程大致如下: - 应用启动,多个节点监听同一个队列(此时多个节点是竞争关系,一条消息只会发到其中一个节点上) - 消息生产者发送消息,同一条消息只被其中一个节点收到收到消息的节点通过redis的发布订阅模式来通知其他兄弟节点 接下来配置redis发布与订阅 参考资料: https://www.runoob.com/redis/redis-pub-sub.html #### 3.2.2.1 redis发布与订阅实现 ```java package com.atguigu.gmall.activity.redis; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.listener.PatternTopic; import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.springframework.data.redis.listener.adapter.MessageListenerAdapter; @Configuration public class RedisChannelConfig { /* docker exec -it bc92 redis-cli subscribe seckillpush // 订阅 接收消息 publish seckillpush admin // 发布消息 */ /** * 注入订阅主题 * @param connectionFactory redis 链接工厂 * @param listenerAdapter 消息监听适配器 * @return 订阅主题对象 */ @Bean RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory, MessageListenerAdapter listenerAdapter) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); //订阅主题 container.addMessageListener(listenerAdapter, new PatternTopic("seckillpush")); //这个container 可以添加多个 messageListener return container; } /** * 返回消息监听器 * @param receiver 创建接收消息对象 * @return */ @Bean MessageListenerAdapter listenerAdapter(MessageReceive receiver) { //这个地方 是给 messageListenerAdapter 传入一个消息接受的处理器,利用反射的方法调用“receiveMessage” //也有好几个重载方法,这边默认调用处理器的方法 叫handleMessage 可以自己到源码里面看 return new MessageListenerAdapter(receiver, "receiveMessage"); } @Bean //注入操作数据的template StringRedisTemplate template(RedisConnectionFactory connectionFactory) { return new StringRedisTemplate(connectionFactory); } } ``` 监听消息 ```java package com.atguigu.gmall.activity.redis; import com.atguigu.gmall.activity.util.CacheHelper; import com.github.benmanes.caffeine.cache.Cache; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; /** * @author: atguigu * @create: 2023-01-30 15:19 */ @Slf4j @Component public class MessageReceive { @Autowired private Cache seckillCache; /** * 本地缓存重复服务后没了,需要清理分布式缓存,再重新加入缓存 * 订阅主题seckillpush中消息 * * * @param msg 形式 ""37:1"" * 消息格式 * skuId:0 表示没有商品 * skuId:1 表示有商品 */ public void receiveMessage(String msg) { log.info("监听到广播消息:" + msg); if (StringUtils.isNotBlank(msg)) { //去除多余引号 msg = msg.replaceAll("\"", ""); //将商品状态位 存入本地缓存 自定义或者Caffeine均可 String[] split = msg.split(":"); if (split != null && split.length == 2) { CacheHelper.put(split[0], split[1]); //seckillCache.put(split[0], split[1]); } } } } ``` CacheHelper类本地缓存类 ```java package com.atguigu.gmall.activity.util; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * 系统缓存类 */ public class CacheHelper { /** * 缓存容器 */ private final static Map cacheMap = new ConcurrentHashMap(); /** * 加入缓存 * * @param key * @param cacheObject */ public static void put(String key, Object cacheObject) { cacheMap.put(key, cacheObject); } /** * 获取缓存 * @param key * @return */ public static Object get(String key) { return cacheMap.get(key); } /** * 清除缓存 * * @param key * @return */ public static void remove(String key) { cacheMap.remove(key); } public static synchronized void removeAll() { cacheMap.clear(); } } ``` 说明: 1,RedisChannelConfig 类配置redis监听的主题和消息处理器 2,MessageReceive 类为消息处理器,消息message为:商品id与状态位,如:1:1 表示商品id为1,状态位为1 或者采用Caffeine基于JDK8的高能效本地缓存 注册缓存对象 ```java /** * Caffine本地缓存应用 * @return */ @Bean public Cache cache(){ Cache cache = Caffeine.newBuilder() .initialCapacity(0) //初始化容量 .maximumSize(3) //缓存最大数量 //.expireAfterWrite(1, TimeUnit.DAYS) //设置有效期 .build(); return cache; } ``` #### 3.2.2.2 redis发布消息 监听已经配置好,接下来我就发布消息,更改秒杀监听器{ SeckillReceiver },如下 ![img](assets/day16/wps32.jpg) 完整代码如下 ```java package com.atguigu.gmall.activity.recevier; import com.atguigu.gmall.activity.model.SeckillGoods; import com.atguigu.gmall.activity.service.SeckillGoodsService; import com.atguigu.gmall.common.constant.MqConst; import com.atguigu.gmall.common.constant.RedisConst; import com.atguigu.gmall.common.util.DateUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.rabbitmq.client.Channel; import org.springframework.amqp.core.Message; import org.springframework.amqp.rabbit.annotation.Exchange; import org.springframework.amqp.rabbit.annotation.Queue; import org.springframework.amqp.rabbit.annotation.QueueBinding; import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.BoundHashOperations; import org.springframework.data.redis.core.BoundListOperations; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; import java.util.Date; import java.util.List; /** * @author: atguigu * @create: 2023-01-30 14:10 */ @Component public class SeckillReceiver { @Autowired private SeckillGoodsService seckillGoodsService; @Autowired private RedisTemplate redisTemplate; /** * 缓存预热 * 监听定时服务消息,将秒杀库中商品加入到缓存 * 所有秒杀商品 hash 结构 redisKey seckill:goods hashKey:skuId hashVal:秒杀商品表中商品信息 * 某个秒杀商品库存 List结构 Key seckill:stock:+skuId vals skuId * * @param message * @param channel */ @RabbitListener(bindings = @QueueBinding( exchange = @Exchange(MqConst.EXCHANGE_DIRECT_TASK), value = @Queue(MqConst.QUEUE_TASK_1), key = MqConst.ROUTING_TASK_1 )) public void importSeckillGoodsToRedis(Message message, Channel channel) { //1.查询秒杀表中商品 条件:审核状态必须为“1” 秒杀商品库存大于0 秒杀开始时间是当日 LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(SeckillGoods::getStatus, "1"); queryWrapper.gt(SeckillGoods::getStockCount, 0); queryWrapper.apply("DATE_FORMAT(start_time,'%Y-%m-%d') = '" + DateUtil.formatDate(new Date()) + "'"); List seckillGoodsList = seckillGoodsService.list(queryWrapper); //2.将秒杀商品遍历存入缓存 if (!CollectionUtils.isEmpty(seckillGoodsList)) { String redisKey = RedisConst.SECKILL_GOODS; BoundHashOperations hashOps = redisTemplate.boundHashOps(redisKey); for (SeckillGoods seckillGoods : seckillGoodsList) { //2.1 判断缓存中已有秒杀商品 Boolean flag = hashOps.hasKey(seckillGoods.getSkuId().toString()); if (flag) { continue; } //2.2 将秒杀商品存入Redis Hash结构存储所有秒杀商品 hashOps.put(seckillGoods.getSkuId().toString(), seckillGoods); //2.3 为每个秒杀商品设置库存避免超卖 List结构 String stockKey = RedisConst.SECKILL_STOCK_PREFIX + seckillGoods.getSkuId(); BoundListOperations listOps = redisTemplate.boundListOps(stockKey); for (Integer i = 0; i < seckillGoods.getStockCount(); i++) { listOps.leftPush(seckillGoods.getSkuId().toString()); } //2.4 TODO 商品被加入到分布式缓存后,需要通知每个秒杀服务更新本地缓存中 商品状态 redisTemplate.convertAndSend("seckillpush", seckillGoods.getSkuId()+":1"); } } } } ``` 说明:到目前我们就实现了商品信息导入缓存,同时更新状态位的工作 # 4、秒杀列表与详情 > YAPI接口地址: > > - 秒杀商品列表:http://192.168.200.128:3000/project/11/interface/api/539 > - 秒杀商品详情:http://192.168.200.128:3000/project/11/interface/api/499 ## 4.1 封装秒杀列表与详情接口 ### 4.1.1 完成控制器 ```java package com.atguigu.gmall.activity.controller; import com.atguigu.gmall.activity.service.SeckillGoodsService; import com.atguigu.gmall.common.result.Result; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/activity/seckill") public class SeckillGoodsApiController { @Autowired private SeckillGoodsService seckillGoodsService; /** * 返回全部列表 * * @return */ @GetMapping("/findAll") public Result findAll() { return Result.ok(seckillGoodsService.findAll()); } /** * 获取实体 * * @param skuId * @return */ @GetMapping("/getSeckillGoods/{skuId}") public Result getSeckillGoods(@PathVariable("skuId") Long skuId) { return Result.ok(seckillGoodsService.getSeckillGoods(skuId)); } } ``` ### 4.1.2 业务接口 ```java package com.atguigu.gmall.activity.service; import com.atguigu.gmall.activity.model.SeckillGoods; import com.baomidou.mybatisplus.extension.service.IService; import java.util.List; public interface SeckillGoodsService extends IService { /** * 查询当日参与秒杀商品列表 * @return */ List findAll(); /** * 查询指定秒杀商品信息 * @param skuId * @return */ SeckillGoods getSeckillGoods(Long skuId); } ``` ### 4.1.2 业务实现类 ```java package com.atguigu.gmall.activity.service.impl; import com.atguigu.gmall.activity.mapper.SeckillGoodsMapper; import com.atguigu.gmall.activity.model.SeckillGoods; import com.atguigu.gmall.activity.service.SeckillGoodsService; import com.atguigu.gmall.common.constant.RedisConst; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.BoundHashOperations; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import java.util.List; /** * @author: atguigu * @create: 2023-01-30 11:47 */ @Slf4j @Service public class SeckillGoodsServiceImpl extends ServiceImpl implements SeckillGoodsService { @Autowired private RedisTemplate redisTemplate; /** * 查询当日参与秒杀商品列表 * * @return */ @Override public List findAll() { String redisKey = RedisConst.SECKILL_GOODS; BoundHashOperations hashOps = redisTemplate.boundHashOps(redisKey); return hashOps.values(); } /** * 查询指定秒杀商品信息 * * @param skuId * @return */ @Override public SeckillGoods getSeckillGoods(Long skuId) { String redisKey = RedisConst.SECKILL_GOODS; BoundHashOperations hashOps = redisTemplate.boundHashOps(redisKey); return hashOps.get(skuId.toString()); } } ``` ## 4.2 在service-activity-client模块添加接口 远程调用Feign API接口 ```java package com.atguigu.gmall.activity.client; import com.atguigu.gmall.activity.client.impl.ActivityDegradeFeignClient; import com.atguigu.gmall.activity.model.SeckillGoods; import com.atguigu.gmall.common.result.Result; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import java.util.List; @FeignClient(value = "service-activity", fallback = ActivityDegradeFeignClient.class) public interface ActivityFeignClient { /** * 返回全部列表 * * @return */ @GetMapping("/api/activity/seckill/findAll") Result> findAll(); /** * 获取实体 * * @param skuId * @return */ @GetMapping("/api/activity/seckill/getSeckillGoods/{skuId}") Result getSeckillGoods(@PathVariable("skuId") Long skuId); } ``` 服务降级类 ```java package com.atguigu.gmall.activity.client.impl; import com.atguigu.gmall.activity.client.ActivityFeignClient; import com.atguigu.gmall.common.result.Result; import org.springframework.stereotype.Component; @Component public class ActivityDegradeFeignClient implements ActivityFeignClient { @Override public Result findAll() { return Result.fail(); } @Override public Result getSeckillGoods(Long skuId) { return Result.fail(); } } ``` ## 4.3 页面渲染 ### 4.3.1 在web-all 中编写控制器 在 web-all 项目中添加控制器 ```java package com.atguigu.gmall.all.controller; import com.atguigu.gmall.activity.client.ActivityFeignClient; import com.atguigu.gmall.common.result.Result; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @Controller public class SeckillController { @Autowired private ActivityFeignClient activityFeignClient; /** * 秒杀列表 * @param model * @return */ @GetMapping("seckill.html") public String index(Model model) { Result result = activityFeignClient.findAll(); model.addAttribute("list", result.getData()); return "seckill/index"; } } ``` 列表 页面资源: \templates\seckill\index.html ```html
``` ### 4.3.2 秒杀详情页面功能介绍 说明: 1,立即购买,该按钮我们要加以控制,该按钮就是一个链接,页面只是控制能不能点击,一般用户可以绕过去,直接点击秒杀下单,所以我们要加以控制,在秒杀没有开始前,不能进入秒杀页面 #### 4.3.2.1 web-all添加商品详情控制器 SeckillController ```java @GetMapping("seckill/{skuId}.html") public String getItem(@PathVariable Long skuId, Model model){ // 通过skuId 查询skuInfo Result result = activityFeignClient.getSeckillGoods(skuId); model.addAttribute("item", result.getData()); return "seckill/item"; } ``` #### 4.3.2.2 详情页面介绍 ##### **4.3.2.2.1** **基本信息渲染** ```html

三星

品优秒杀 {{timeTitle}}:{{timeString}}
秒杀价
¥ 0 原价:0
剩余库存:0
促  销
加价购 满999.00另加20.00元,或满1999.00另加30.00元,或满2999.00另加40.00元,即可在购物车换购热销商品
支  持
以旧换新,闲置手机回收 4G套餐超值抢 礼品购
配 送 至
满999.00另加20.00元,或满1999.00另加30.00元,或满2999.00另加40.00元,即可在购物车换购热销商品
``` ##### **4.3.2.2.2 倒计时处理** 思路:页面初始化时,拿到商品秒杀开始时间和结束时间等信息,实现距离开始时间和活动倒计时。 活动未开始时,显示距离开始时间倒计时; 活动开始后,显示活动结束时间倒计时。 倒计时代码片段 ```js init() { // debugger // 计算出剩余时间 var startTime = new Date(this.data.startTime).getTime(); var endTime = new Date(this.data.endTime).getTime(); var nowTime = new Date().getTime(); //存在问题的 实际当前系统时间,从时间服务器获取统一时间标准 var secondes = 0; // 还未开始抢购 if(startTime > nowTime) { this.timeTitle = '距离开始' secondes = Math.floor((startTime - nowTime) / 1000); } if(nowTime > startTime && nowTime < endTime) { this.isBuy = true this.timeTitle = '距离结束' secondes = Math.floor((endTime - nowTime) / 1000); } if(nowTime > endTime) { this.timeTitle = '抢购结束' secondes = 0; } const timer = setInterval(() => { secondes = secondes - 1 this.timeString = this.convertTimeString(secondes) }, 1000); // 通过$once来监听定时器,在beforeDestroy钩子可以被清除。 this.$once('hook:beforeDestroy', () => { clearInterval(timer); }) }, ``` 时间转换方法 ```js convertTimeString(allseconds) { if(allseconds <= 0) return '00:00:00' // 计算天数 var days = Math.floor(allseconds / (60 * 60 * 24)); // 小时 var hours = Math.floor((allseconds - (days * 60 * 60 * 24)) / (60 * 60)); // 分钟 var minutes = Math.floor((allseconds - (days * 60 * 60 * 24) - (hours * 60 * 60)) / 60); // 秒 var seconds = allseconds - (days * 60 * 60 * 24) - (hours * 60 * 60) - (minutes * 60); //拼接时间 var timString = ""; if (days > 0) { timString = days + "天:"; } return timString += hours + ":" + minutes + ":" + seconds; } ``` #### 4.3.2.3 秒杀按钮控制 在进入秒杀功能前,我们加一个下单码,只有你获取到该下单码,才能够进入秒杀方法进行秒杀 避免:用户提前知悉秒杀地址,通过恶意刷单软件进行非法下单。下单码生成条件:本地缓存中商品状态必须“1”;当前用户购买商品在销售时间内。 ##### 4.3.2.3.1 获取抢购码 > YAPI接口地址:http://192.168.200.128:3000/project/11/interface/api/531 SeckillGoodsApiController ```java /** * 为当前用户购买意向生成抢购码 * * @param skuId 商品ID * @return */ @GetMapping("/auth/getSeckillSkuIdStr/{skuId}") public Result getSeckillSkuStr(HttpServletRequest request, @PathVariable("skuId") Long skuId) { //1.验证本地缓存中商品状态 必须是 “1” String state = (String) CacheHelper.get(skuId.toString()); if (StringUtils.isBlank(state)) { return Result.build(null, ResultCodeEnum.ILLEGAL_REQUEST); } if ("0".equals(state)) { return Result.build(null, ResultCodeEnum.SECKILL_FINISH); } if ("1".equals(state)) { //2.根据SKUID 查询分布式缓存中秒杀商品 获取开始时间,结束时间 SeckillGoods seckillGoods = seckillGoodsService.getSeckillGoods(skuId); if (seckillGoods != null) { Date startTime = seckillGoods.getStartTime(); Date endTime = seckillGoods.getEndTime(); Date now = new Date(); if (DateUtil.dateCompare(seckillGoods.getStartTime(), now) && DateUtil.dateCompare(now, seckillGoods.getEndTime())) { //3.获取登录用户ID,按照规则生成 或者 将生成的强购码加入缓存Redis String userId = AuthContextHolder.getUserId(request); String encrypt = MD5.encrypt(userId + skuId); //4.返回商品抢购码 return Result.ok(encrypt); } } } return Result.build(null, ResultCodeEnum.ILLEGAL_REQUEST); } ``` 说明:只有在商品秒杀时间范围内,才能获取下单码,这样我们就有效控制了用户非法秒杀,下单码我们可以根据业务自定义规则,目前我们定义为当前用户id MD5加密。 ##### **4.3.2.3.2 前端页面** 页面获取下单码,进入秒杀场景 ```js queue() { seckill.getSeckillSkuIdStr(this.skuId).then(response => { var skuIdStr = response.data.data window.location.href = '/seckill/queue.html?skuId='+this.skuId+'&skuIdStr='+skuIdStr }) }, ``` 前端js完整代码如下 ```js ``` ### 4.3.3 编写排队控制器 `web-all`模块中SeckillController ```java /** * 渲染排队页面 * @param skuId * @param skuIdStr * @param model * @return */ @GetMapping("seckill/queue.html") public String queue(@RequestParam(name = "skuId") Long skuId, @RequestParam(name = "skuIdStr") String skuIdStr, Model model) { model.addAttribute("skuId", skuId); model.addAttribute("skuIdStr", skuIdStr); return "seckill/queue"; } ``` 页面 页面资源: \templates\seckill\queue.html ```html
排队中...
{{message}}
抢购成功   去下单
抢购成功   我的订单
``` Js部分 ```js ``` 说明:该页面直接通过controller返回页面,进入页面后显示排队中,然后通过异步执行秒杀下单,提交成功,页面通过轮询后台方法查询秒杀状态 # 5、整合秒杀业务 秒杀的主要目的就是获取一个下单资格,拥有下单资格就可以去下单支付,获取下单资格后的流程就与正常下单流程一样,只是没有购物车这一步,总结起来就是,秒杀根据库存获取下单资格,拥有下单资格进入下单页面(选择地址,支付方式,提交订单,然后支付订单) 步骤: 1,校验抢购码,只有正确获得抢购码的请求才是合法请求 2,校验状态位state, State为null,说明非法请求; State为0说明已经售罄; State为1,说明可以抢购 状态位是在内存中判断,效率极高,如果售罄,直接就返回了,不会给服务器造成太大压力 3,前面条件都成立,将秒杀用户加入队列,然后直接返回 4,前端轮询秒杀状态,查询秒杀结果 ## 5.1 秒杀下单 > YAPI接口地址:http://192.168.200.128:3000/project/11/interface/api/515 ### 5.1.1 添加mq常量MqConst类 ```java /** * 秒杀 */ public static final String EXCHANGE_DIRECT_SECKILL_USER = "exchange.direct.seckill.user"; public static final String ROUTING_SECKILL_USER = "seckill.user"; //队列 public static final String QUEUE_SECKILL_USER = "queue.seckill.user"; ``` ### 5.1.2 定义实体UserRecode 记录哪个用户要购买哪个商品! ```java @Data public class UserRecode implements Serializable { private static final long serialVersionUID = 1L; private Long skuId; private String userId; } ``` ### 5.1.3 编写控制器 SeckillGoodsApiController ```java /** * 秒杀请求入队 * @param request * @param skuId 商品ID * @param skuIdStr 用户抢购码 * @return */ @PostMapping("/auth/seckillOrder/{skuId}") public Result seckillRequestToQueue(HttpServletRequest request, @PathVariable("skuId") Long skuId, String skuIdStr){ String userId = AuthContextHolder.getUserId(request); return seckillGoodsService.seckillRequestToQueue(userId, skuId, skuIdStr); } ``` ### 5.1.4 业务层接口 SeckillGoodsService ```java /** * 秒杀请求入队 * @param skuId 商品ID * @param skuIdStr 用户抢购码 * @return */ Result seckillRequestToQueue(String userId, Long skuId, String skuIdStr); ``` ### 5.1.5 业务层实现 SeckillGoodsServiceImpl ```java /** * 秒杀请求入队 1.校验抢购码2.校验本地缓存中商品状态3.构建用户购买意向发送消息MQ * * @param skuId 商品ID * @param skuIdStr 用户抢购码 * @return */ @Override public Result seckillRequestToQueue(String userId, Long skuId, String skuIdStr) { //1.校验抢购码 //1.1 按照以前生成规则 String encrypt = MD5.encrypt(userId + skuId); //1.2 判断抢购码是否正确 if (StringUtils.isBlank(skuIdStr) || !encrypt.equals(skuIdStr)) { return Result.build(null, ResultCodeEnum.ILLEGAL_REQUEST); } //2.校验本地缓存中商品状态 String state = (String) CacheHelper.get(skuId.toString()); if (StringUtils.isBlank(state)) { return Result.build(null, ResultCodeEnum.ILLEGAL_REQUEST); } if ("0".equals(state)) { return Result.build(null, ResultCodeEnum.SECKILL_FINISH); } if ("1".equals(state)) { //3.构建用户购买意向发送消息MQ UserRecode userRecode = new UserRecode(); userRecode.setUserId(userId); userRecode.setSkuId(skuId); rabbitService.sendMessage(MqConst.EXCHANGE_DIRECT_SECKILL_USER, MqConst.ROUTING_SECKILL_USER, userRecode); return Result.build(null, ResultCodeEnum.SUCCESS); } return Result.build(null, ResultCodeEnum.SECKILL_RUN); } ``` ## 5.2 秒杀下单监听 思路: 1,首先判断产品状态位,我们前面不是已经判断过了吗?因为产品可能随时售罄,mq队列里面可能堆积了十万数据,但是已经售罄了,那么后续流程就没有必要再走了; 2,判断用户是否已经下过订单,这个地方就是控制用户重复下单,同一个用户只能抢购一个下单资格,怎么控制呢?很简单,我们可以利用setnx控制用户,当用户第一次进来时,返回true,可以抢购,以后进入返回false,直接返回,过期时间可以根据业务自定义,这样用户这一段咋们就控制注了 3,获取队列中的商品,如果能够获取,则商品有库存,可以下单。如果获取的商品id为空,则商品售罄,商品售罄我们要第一时间通知兄弟节点,更新状态位,所以在这里发送redis广播 4,将订单记录放入redis缓存,说明用户已经获得下单资格,秒杀成功 5,秒杀成功要更新库存 ### 5.2.1 SeckillReceiver添加监听方法 ```java /** * 从秒杀队列中获取获取秒杀请求,进行秒杀订单预处理 * @param userRecode * @param message * @param channel */ @RabbitListener(bindings = @QueueBinding( exchange = @Exchange(MqConst.EXCHANGE_DIRECT_SECKILL_USER), value = @Queue(MqConst.QUEUE_SECKILL_USER), key = MqConst.ROUTING_SECKILL_USER )) public void processSeckillRequest(UserRecode userRecode, Message message, Channel channel) { try { if (userRecode != null) { log.info("【秒杀服务】监听到秒杀请求:{}", userRecode); seckillGoodsService.processPreSeckillOrder(userRecode); } channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); } catch (IOException e) { e.printStackTrace(); log.error("【秒杀服务】监听到秒杀请求异常:", e); } } ``` ### 5.2.2 预下单接口 ```java /** * 根据用户和商品ID实现秒杀下单 * @param skuId * @param userId */ void seckillOrder(Long skuId, String userId); ``` ### 5.2.3 实现类 秒杀订单实体类 ```java package com.atguigu.gmall.model.activity; @Data public class OrderRecode implements Serializable { private static final long serialVersionUID = 1L; private String userId; private SeckillGoods seckillGoods; private Integer num; private String orderStr; } ``` SeckillGoodsServiceImpl ```java /** * 处理队列中下单请求,进行秒杀订单预处理 * * @param userRecode */ @Override public void processPreSeckillOrder(UserRecode userRecode) { //1.二次验证本地缓存中状态 String skuId = userRecode.getSkuId().toString(); String userId = userRecode.getUserId(); String state = (String) CacheHelper.get(skuId); if ("0".equals(state)) { //商品以及售罄 return; } //2.验证用户是否重复下单 setnx //允许用户同一时间抢购多件商品 String key = RedisConst.SECKILL_USER + userId + ":" + skuId; Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, skuId); if (!flag) { //当前商品不允许重复下单 return; } //3.校验库存同时移除商品库存List中元素-同时缓存中扣减库存 String goodsId = (String) redisTemplate.boundListOps(RedisConst.SECKILL_STOCK_PREFIX + skuId).rightPop(); if (StringUtils.isBlank(goodsId)) { //当前商品以及售罄 更新 所有的秒杀服务实例本地缓存中商品状态 redisTemplate.convertAndSend("seckillpush", skuId + ":0"); return; } //4.生成临时订单-抢购成功/抢购资格标记;将临时订单存入Redis缓存 OrderRecode orderRecode = new OrderRecode(); orderRecode.setUserId(userId); orderRecode.setSeckillGoods(this.getSeckillGoods(Long.valueOf(skuId))); orderRecode.setNum(1); //临时订单订单码 采用MD5生成 orderRecode.setOrderStr(MD5.encrypt(userId + skuId)); redisTemplate.boundHashOps(RedisConst.SECKILL_ORDERS).put(userId, orderRecode); //5.发送更新库存消息-异步更新分布式缓存Redis以及数据库Mysql中库存 rabbitService.sendMessage(MqConst.EXCHANGE_DIRECT_SECKILL_STOCK, MqConst.ROUTING_SECKILL_STOCK, skuId); } ``` ### 5.2.4 更新库存 ```java /** * 秒杀减库存 */ public static final String EXCHANGE_DIRECT_SECKILL_STOCK = "exchange.direct.seckill.stock"; public static final String ROUTING_SECKILL_STOCK = "seckill.stock"; //队列 public static final String QUEUE_SECKILL_STOCK = "queue.seckill.stock"; ``` **监听秒杀减库存:** ```java /** * 监听秒杀扣减库存 * * @param skuId 商品ID * @param message * @param channel */ @RabbitListener(bindings = @QueueBinding( exchange = @Exchange(MqConst.EXCHANGE_DIRECT_SECKILL_STOCK), value = @Queue(MqConst.QUEUE_SECKILL_STOCK), key = MqConst.ROUTING_SECKILL_STOCK )) public void updateStock(Long skuId, Message message, Channel channel) { try { if (skuId != null) { log.info("【秒杀服务】监听到秒杀扣减库存消息:{}", skuId); seckillGoodsService.updateStock(skuId); } channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); } catch (IOException e) { e.printStackTrace(); log.error("【秒杀服务】监听到秒杀扣减异常:{}", e); } } ``` SeckillGoodsService ```java /** * 更新秒杀商品库存 * @param skuId */ void updateStock(Long skuId); ``` SeckillGoodsServiceImpl ```java /** * 扣减秒杀库存 缓存Redis以及MySQL * * @param skuId */ @Override @Transactional(rollbackFor = Exception.class) public void updateStock(Long skuId) { //1.根据SKUID查询剩余的库存数量 Long count = redisTemplate.boundListOps(RedisConst.SECKILL_STOCK_PREFIX + skuId).size(); //2.根据SKUID查询秒杀商品-等同于从数据库中查询 SeckillGoods seckillGoods = this.getSeckillGoods(skuId); seckillGoods.setStockCount(count.intValue()); //3.更新缓存Redis中秒杀商品 redisTemplate.boundHashOps(RedisConst.SECKILL_GOODS).put(skuId.toString(), seckillGoods); //4.更新数据库中秒杀商品数量 this.updateById(seckillGoods); } ``` ## 5.3 页面轮询接口 思路: 1. 判断用户是否在缓存中存在 2. 判断用户是否抢单成功 3. 判断用户是否下过订单 4. 判断状态位 > YAPI接口地址:http://192.168.200.128:3000/project/11/interface/api/507 ### 5.3.1 控制器 SeckillGoodsApiController ```java /** * 检查用户秒杀商品结果 * @param skuId * @param request * @return */ @GetMapping(value = "auth/checkOrder/{skuId}") public Result checkOrder(@PathVariable("skuId") Long skuId, HttpServletRequest request) { //当前登录用户 String userId = AuthContextHolder.getUserId(request); return seckillGoodsService.checkOrder(userId, skuId); } ``` ### 5.3. 业务接口 SeckillGoodsService接口 ```java /** * 检查用户秒杀商品结果 * @param userId * @param skuId * @return */ Result checkOrder(String userId, Long skuId); ``` ### 5.3.2 实现类 SeckillGoodsServiceImpl ```java /** * 检查秒杀结果 * * @param userId 用户ID * @param skuId 商品SKUID * @return */ @Override public Result checkOrder(String userId, Long skuId) { //1.判断用户在缓存是否存在,有机会秒杀到商品 Boolean flag = redisTemplate.hasKey(RedisConst.SECKILL_USER + userId); if (flag) { //2.判断用户是否抢单成功 临时订单hash结构 flag = redisTemplate.boundHashOps(RedisConst.SECKILL_ORDERS).hasKey(userId); if (flag) { //抢购成功;响应临时订单数据 OrderRecode orderRecode = (OrderRecode) redisTemplate.boundHashOps(RedisConst.SECKILL_ORDERS).get(userId); return Result.build(orderRecode, ResultCodeEnum.SECKILL_SUCCESS); } } //3.判断用户是否提交秒杀订单 Boolean aBoolean = redisTemplate.boundHashOps(RedisConst.SECKILL_ORDERS_USERS).hasKey(userId); if (aBoolean) { //下单成功 响应提交的秒杀订单 Long orderId = (Long) redisTemplate.boundHashOps(RedisConst.SECKILL_ORDERS_USERS).get(userId); return Result.build(orderId, ResultCodeEnum.SECKILL_ORDER_SUCCESS); } //4.判断本地缓存状态 String state = (String) CacheHelper.get(skuId.toString()); if ("0".equals(state)) { return Result.build(null, ResultCodeEnum.SECKILL_FINISH); } return Result.build(null, ResultCodeEnum.SECKILL_RUN); } ``` ## 5.4 轮询排队页面 该页面有四种状态: 1,排队中 2,各种提示(非法、已售罄等) 3,抢购成功,去下单 4,抢购成功,已下单,显示我的订单 抢购成功,页面显示去下单,跳转下单确认页面 ```html
抢购成功   去下单
``` ## 5.5 下单页面 ![img](assets/day16/wps33.jpg) 我们已经把下单信息记录到redis缓存中,所以接下来我们要组装下单页数据 > YAPI接口地址:http://192.168.200.128:3000/project/11/interface/api/523 ### 5.5.1 下单页数据数据接口 SeckillGoodsApiController ```java /** * 秒杀订单确认页面渲染需要参数汇总接口 * @return */ @GetMapping("/auth/trade") public Result seckillTradeData(HttpServletRequest request){ //1.获取当前登录用户ID String userId = AuthContextHolder.getUserId(request); //2.调用业务逻辑汇总数据 return seckillGoodsService.seckillTradeData(userId); } ``` SeckillGoodsService ```java /** * 秒杀订单确认页面渲染需要参数汇总接口 * @return */ Result seckillTradeData(String userId); ``` SeckillGoodsServiceImpl ```java /** * 秒杀订单确认页面渲染需要参数汇总接口 * * @return */ @Override public Result seckillTradeData(String userId) { //1.根据用户ID查询 临时订单信息包含商品信息 BoundHashOperations hashOps = redisTemplate.boundHashOps(RedisConst.SECKILL_ORDERS); //1.1 获取临时订单 OrderRecode orderRecode = hashOps.get(userId); if (orderRecode == null) { return Result.build(null, ResultCodeEnum.ILLEGAL_REQUEST); } //1.2 获取秒杀商品 SeckillGoods seckillGoods = orderRecode.getSeckillGoods(); if (seckillGoods == null) { return Result.build(null, ResultCodeEnum.ILLEGAL_REQUEST); } //2.构建秒杀订单对象以及秒杀商品明细集合 //2.1 构建订单 OrderInfo orderInfo = new OrderInfo(); //2.2 构建订单明细 OrderDetail orderDetail = new OrderDetail(); orderDetail.setSkuName(seckillGoods.getSkuName()); orderDetail.setOrderPrice(seckillGoods.getCostPrice()); orderDetail.setImgUrl(seckillGoods.getSkuDefaultImg()); orderDetail.setSkuId(seckillGoods.getSkuId()); orderDetail.setSkuNum(orderRecode.getNum()); List orderDetails = Arrays.asList(orderDetail); orderInfo.setOrderDetailList(orderDetails); //计算订单总金额 orderInfo.sumTotalAmount(); //3.远程调用用户微服务得到收件地址列表 List userAddressList = userFeignClient.findUserAddressListByUserId(Long.valueOf(userId)); //4.封装响应数据Map HashMap resultMap = new HashMap<>(); resultMap.put("detailArrayList", orderDetails); resultMap.put("totalNum", orderDetails.size()); resultMap.put("totalAmount", orderInfo.getTotalAmount()); resultMap.put("userAddressList", userAddressList); return Result.ok(resultMap); } ``` ### 5.5.2 service-activity-client添加接口 ActivityFeignClient ```java /** * 秒杀订单确认页面渲染需要参数汇总接口 * * @return */ @GetMapping("/api/activity/seckill/auth/trade") public Result> seckillTradeData(); ``` ### 5.5.3 web-all 编写去下单控制器 SeckillController ```java /** * 确认订单 * @param model * @return */ @GetMapping("seckill/trade.html") public String trade(Model model) { Result> result = activityFeignClient.seckillTradeData(); if(result.isOk()){ model.addAllAttributes(result.getData()); return "seckill/trade"; }else{ model.addAttribute("message", result.getMessage()); return "seckill/fail"; } } ``` ### 5.5.4 提交订单 该页面与正常下单页面类似,只是下单提交接口不一样,因为秒杀下单不需要正常下单的各种判断,因此我们要在订单服务提供一个秒杀下单接口,直接下单 #### 5.5.4.1 service-order模块提供秒杀下单接口 > YAPI接口地址:http://192.168.200.128:3000/project/11/interface/api/795 OrderApiController ```java /** * 秒杀提交订单,秒杀订单不需要做前置判断,直接下单 * * @param orderInfo * @return */ @PostMapping("inner/seckill/submitOrder") public Long submitSeckillOrder(@RequestBody OrderInfo orderInfo) { return orderInfoService.submitSeckillOrder(orderInfo); } ``` OrderInfoService ```java /** * 秒杀订单保存 * @param orderInfo * @return */ Long submitSeckillOrder(OrderInfo orderInfo); ``` OrderInfoServiceImpl ```java /** * 保存秒杀订单方法 * * @param orderInfo * @return */ @Override public Long submitSeckillOrder(OrderInfo orderInfo) { //保存秒杀订单 this.saveOrderInfo(orderInfo); //发送延迟关闭订单消息 rabbitService.sendDelayMessage(MqConst.EXCHANGE_DIRECT_ORDER_CANCEL, MqConst.ROUTING_ORDER_CANCEL, orderInfo.getId(), 15 * 60); //返回保存后订单ID return orderInfo.getId(); } /** * 抽取出来保存订单方法 * * @param orderInfo */ private void saveOrderInfo(OrderInfo orderInfo) { List orderDetails = orderInfo.getOrderDetailList(); //3.1 封装订单相关属性 orderInfo.setOrderStatus(OrderStatus.UNPAID.name()); orderInfo.sumTotalAmount(); orderInfo.setPaymentWay(PaymentWay.ONLINE.name()); String outTradeNo = "ATGUIGU" + System.currentTimeMillis() + new Random().nextInt(1000); orderInfo.setOutTradeNo(outTradeNo); orderInfo.setProcessStatus(ProcessStatus.UNPAID.name()); StringBuilder stringBuilder = new StringBuilder(); for (OrderDetail orderDetail : orderDetails) { stringBuilder.append(orderDetail.getSkuName() + " "); } if (stringBuilder.toString().length() > 100) { orderInfo.setTradeBody(stringBuilder.toString().substring(0, 100)); } else { orderInfo.setTradeBody(stringBuilder.toString()); } orderInfo.setImgUrl(orderDetails.get(0).getImgUrl()); orderInfo.setOperateTime(new Date()); Calendar calendar = Calendar.getInstance(); calendar.add(Calendar.DATE, 1); orderInfo.setExpireTime(calendar.getTime()); //3.2 将订单保存 this.save(orderInfo); //3.3 封装订单详情对象 保存订单详情 List orderDetailList = orderDetails.stream().map(orderDetail -> { orderDetail.setOrderId(orderInfo.getId()); return orderDetail; }).collect(Collectors.toList()); //3.4 批量新增订单详情 if (!CollectionUtils.isEmpty(orderDetailList)) { orderDetailService.saveBatch(orderDetailList); } //TODO 4.购物车中中结算商品删除 作业 //5.返回订单ID } ``` #### 5.5.4.2 service-order-client模块暴露接口 远程调用API接口方法 ```java /** * 秒杀提交订单,秒杀订单不需要做前置判断,直接下单 * * @param orderInfo * @return */ @PostMapping("/api/order/inner/seckill/submitOrder") public Long submitSeckillOrder(@RequestBody OrderInfo orderInfo); ``` 服务降级方法 ```java @Override public Long submitSeckillOrder(OrderInfo orderInfo) { return null; } ``` #### 5.5.4.3 service-activity模块秒杀下单 SeckillGoodsApiController ```java /** * 保存秒杀订单 * @param orderInfo * @return */ @PostMapping("/auth/submitOrder") public Result submitSeckillOrder(HttpServletRequest request, @RequestBody OrderInfo orderInfo){ String userId = AuthContextHolder.getUserId(request); orderInfo.setUserId(Long.valueOf(userId)); return seckillGoodsService.submitSeckillOrder(orderInfo); } ``` SeckillGoodsService ```java /** * 保存秒杀订单 * @param orderInfo * @return */ Result submitSeckillOrder(OrderInfo orderInfo); ``` SeckillGoodsServiceImpl ```java /** * 保存秒杀订单 * @param orderInfo * @return */ @Override public Result submitSeckillOrder(OrderInfo orderInfo) { Long userId = orderInfo.getUserId(); //1.远程调用订单微服务保存秒杀订单 Long orderId = orderFeignClient.submitSeckillOrder(orderInfo); //2.删除Redis中临时订单 redisTemplate.boundHashOps(RedisConst.SECKILL_ORDERS).delete(userId.toString()); //3.将用户下单记录存入缓存Redis redisTemplate.boundHashOps(RedisConst.SECKILL_ORDERS_USERS).put(userId.toString(), orderId.toString()); //4.返回保存后订单ID - 跟支付页面进行对接 return Result.ok(orderId); } ``` 页面提交订单代码片段 ```js submitOrder() { seckill.submitOrder(this.order).then(response => { if (response.data.code == 200) { window.location.href = 'http://payment.gmall.com/pay.html?orderId=' + response.data.data } else { alert(response.data.message) } }) }, ``` 说明:下单成功后,后续流程与正常订单一致 ## 5.6 秒杀结束清空redis缓存 秒杀过程中我们写入了大量redis缓存,我们可以在秒杀结束或每天固定时间清除缓存,释放缓存空间; 实现思路:假如根据业务,我们确定每天18点所有秒杀业务结束,那么我们编写定时任务,每天18点发送mq消息,service-activity模块监听消息清理缓存 service-task发送消息 ### 5.6.1 添加常量MqConst类 ```java /** * 定时任务 */ public static final String ROUTING_TASK_18 = "seckill.task.18"; //队列 public static final String QUEUE_TASK_18 = "queue.task.18"; ``` ### 5.6.2 编写定时任务发送消息 `service-task`模块ScheduledTask增加定时任务 ```java /** * 每天晚上18点发送消息,通知秒杀服务清理Redis缓存 */ //@Scheduled(cron = "0 0 18 * * ?") //正式 @Scheduled(cron = "0/30 * * * * ?") public void sendClearSeckillGoodsCache() { log.info("定时任务执行了"); rabbitService.sendMessage(MqConst.EXCHANGE_DIRECT_TASK, MqConst.ROUTING_TASK_18, ""); } ``` ### 5.6.3 接收消息并处理 Service-activity接收消息 SeckillReceiver ```java /** * 监听清理Redis缓存中秒杀相关数据 * * @param message * @param channel */ @RabbitListener(bindings = @QueueBinding( value = @Queue(value = MqConst.QUEUE_TASK_18, durable = "true", autoDelete = "false"), exchange = @Exchange(value = MqConst.EXCHANGE_DIRECT_TASK), key = {MqConst.ROUTING_TASK_18} )) public void deleteRedisData(Message message, Channel channel) { try { //1.查询秒杀结束商品 LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(SeckillGoods::getStatus, 1); queryWrapper.le(SeckillGoods::getEndTime, new Date()); List seckillGoodsList = seckillGoodsService.list(queryWrapper); if (!CollectionUtils.isEmpty(seckillGoodsList)) { //2.对应将秒杀结束缓存中的数据删除! for (SeckillGoods seckillGoods : seckillGoodsList) { //2.1 删除商品库存 list redisTemplate.delete(RedisConst.SECKILL_STOCK_PREFIX + seckillGoods.getSkuId()); //2.2 删除秒杀商品 hash redisTemplate.delete(RedisConst.SECKILL_GOODS); //2.3 删除秒杀期间产生的临时订单 hash redisTemplate.delete(RedisConst.SECKILL_ORDERS); //2.4 删除秒杀成功的订单 redisTemplate.delete(RedisConst.SECKILL_ORDERS_USERS); //3.修改数据库中秒杀商品的状态 1:审核通过 2:秒杀结束 seckillGoods.setStatus("2"); seckillGoodsService.updateById(seckillGoods); } } // 手动确认消息 channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); } catch (IOException e) { e.printStackTrace(); } } ``` 说明:清空redis缓存,同时更改秒杀商品活动结束 秒杀下单,提交成功,页面通过轮询后台方法查询秒杀状态