第16章-秒杀.md 92 KB

第16章-订单秒杀

1、秒杀业务分析

学习目标:

  • 能够说出秒杀业务流程
  • 独立搭建秒杀模块
  • 完成基于定时任务完成秒杀商品缓存预热
  • 完成秒杀商品列表以及详情展示
  • 完成商品秒杀(重难点)

1.1 需求分析

所谓“秒杀、抢购”,就是网络卖家发布一些超低价格的商品,所有买家在同一时间网上抢购的一种销售方式。通俗一点讲就是网络商家为促销等目的组织的网上限时抢购活动。由于商品价格低廉,往往一上架就被抢购一空,有时只用一秒钟。

秒杀商品通常有三种限制:库存限制、时间限制、购买量限制。

(1)库存限制:商家只拿出限量的商品来秒杀。比如某商品实际库存是200件,但只拿出50件来参与秒杀。我们可以将其称为“秒杀库存”。商家赔本赚吆喝,图啥?人气!

(2)时间限制:通常秒杀都是有特定的时间段,只能在设定时间段进行秒杀活动;

(3)购买量限制:同一个商品只允许用户最多购买几件。比如某手机限购1件。张某第一次买个1件,那么在该次秒杀活动中就不能再次抢购

1.2 秒杀功能分析

列表页

img

详情页

img

排队页

img

下单页

img

支付页

img

1.3 数据库表

秒杀商品表seckill_goods

img

2、搭建秒杀模块

我们先把秒杀模块搭建好,秒杀一共有三个模块,秒杀微服务模块service-activity,负责封装秒杀全部服务端业务;秒杀前端模块web-all,负责前端显示业务;service-activity-client api接口模块

提供秒杀商品基础代码

2.1 搭建service-activity模块

2.1.1 搭建service-activity

搭建方式如service-order

image-20221209014526405

2.1.2 修改pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>gmall-service</artifactId>
        <groupId>com.atguigu.gmall</groupId>
        <version>1.0</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>service-activity</artifactId>

    <dependencies>
        <dependency>
            <groupId>com.atguigu.gmall</groupId>
            <artifactId>service-user-client</artifactId>
            <version>1.0</version>
        </dependency>
        <dependency>
            <groupId>com.atguigu.gmall</groupId>
            <artifactId>service-order-client</artifactId>
            <version>1.0</version>
        </dependency>
        <dependency>
            <groupId>com.atguigu.gmall</groupId>
            <artifactId>rabbit-util</artifactId>
            <version>1.0</version>
        </dependency>
        <!--caffeine本地缓存-->
        <dependency>
            <groupId>com.github.ben-manes.caffeine</groupId>
            <artifactId>caffeine</artifactId>
            <version>2.7.0</version>
        </dependency>
    </dependencies>

    <build>
        <finalName>service-activity</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

2.1.3 启动类

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;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.data.redis.core.RedisTemplate;

import java.util.Set;

@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class ActivityApp {

    public static void main(String[] args) {
        ConfigurableApplicationContext applicationContext = SpringApplication.run(ActivityApp.class, args);
        RedisTemplate redisTemplate = applicationContext.getBean(RedisTemplate.class);

        //添加关闭服务器钩子 服务一旦停止,本地缓存数据无;但是分布式缓存中依然有数据
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            System.out.println("清理分布式缓存秒杀数据");
            Set<String> keys = redisTemplate.keys("seckill:" + "*");
            redisTemplate.delete(keys);
        }));
    }

}

2.1.3 添加配置

在resources目录下新建:bootstrap.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

image-20230317093917194

2.2 搭建service-activity-client模块

搭建service-activity-client,搭建方式如service-order-client

image-20221209014830673

2.3 添加依赖,配置网关

2.3.1 在web-all中引入依赖

<dependency>
    <groupId>com.atguigu.gmall</groupId>
    <artifactId>service-activity-client</artifactId>
    <version>1.0</version>
</dependency>

3、秒杀商品导入缓存

缓存数据实现思路:service-task模块统一管理我们的定时任务,为了减少service-task模块的耦合度,我们可以在定时任务模块只发送mq消息,需要执行定时任务的模块监听该消息即可,这样有利于我们后期动态控制,例如:每天凌晨一点我们发送定时任务信息到mq交换机,如果秒杀业务凌晨一点需要导入数据到缓存,那么秒杀业务绑定队列到交换机就可以了,其他业务也是一样,这样就做到了很好的扩展。

上面提到我们要控制库存数量,不能超卖,那么如何控制呢?在这里我们提供一种解决方案,那就我们在导入商品缓存数据时,同时将商品库存信息导入队列{list},利用redis队列的原子性,保证库存不超卖

库存加入队列实施方案

1,如果秒杀商品有N个库存,那么我就循环往队列放入N个队列数据

2,秒杀开始时,用户进入,然后就从队列里面出队,只有队列里面有数据,说明就一点有库存(redis队列保证了原子性),队列为空了说明商品售罄

3.1 编写定时任务

在service-task模块发送消息

3.1.1 搭建service-task服务

gmall-service模块下新增子模块: service-task . 搭建方式如service-mq

image-20221209015034587

3.1.2 修改配置pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>gmall-service</artifactId>
        <groupId>com.atguigu.gmall</groupId>
        <version>1.0</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>service-task</artifactId>

    <dependencies>
        <!--rabbitmq消息队列-->
        <dependency>
            <groupId>com.atguigu.gmall</groupId>
            <artifactId>rabbit-util</artifactId>
            <version>1.0</version>
        </dependency>
    </dependencies>
    <build>
        <finalName>service-task</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

说明:引入rabbit-util依赖定时发送消息

3.1.3 启动类

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

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相关常量-

/**
 * 定时任务
 */
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";

SpringTask提供的任务不能用在分布式项目中,固抛弃

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.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.scheduling.annotation.EnableScheduling;

/**
 * @author: atguigu
 * @create: 2023-03-17 10:13
 */
@Slf4j
@Component
@EnableScheduling //开启定时任务
public class ScheduledTask {

    @Autowired
    private RabbitService rabbitService;


    /**
     * cron表达式 出处:定时任务框架Quartz
     * 定时任务逻辑:
     * 每日凌晨1点执行 秒杀商品预热消息发送
     * 开发阶段:每隔30秒触发一次
     */
    //@Scheduled(cron = "0 0 1 * * ?")
    @Scheduled(cron = "0/30 * * * * ?")
    public void sendImpSeckGoodsMsg() {
        log.info("定时任务秒杀商品预热消息发送");
        rabbitService.sendMessage(MqConst.EXCHANGE_DIRECT_TASK, MqConst.ROUTING_TASK_1, "");
    }
}

3.1 分布式任务调用优化

XXL-JOB是一个分布式任务调度平台,其核心设计目标是开发迅速、学习简单、轻量级、易扩展。现已开放源代码并接入多家公司线上产品线,开箱即用。

特性

  • 1、简单:支持通过Web页面对任务进行CRUD操作,操作简单,一分钟上手;
  • 2、动态:支持动态修改任务状态、启动/停止任务,以及终止运行中任务,即时生效;
  • 3、分片广播任务:执行器集群部署时,任务路由策略选择”分片广播”情况下,一次任务调度将会广播触发集群中所有执行器执行一次任务,可根据分片参数开发分片任务;
  • 4、邮件报警:任务失败时支持邮件报警,支持配置多邮件地址群发报警邮件;

3.1.1 调度中心

  1. 克隆项目到本地 https://gitee.com/xuxueli0323/xxl-job.git

  2. 准备数据库环境

    image-20230513104020521

  3. 搭建调用中心服务 配置文件修改数据库连接信息

image-20230513104057679

  1. 启动调度中心

3.1.2 执行器

  1. service-task模块中导入依赖

    <dependency>
       <groupId>com.xuxueli</groupId>
       <artifactId>xxl-job-core</artifactId>
       <version>2.4.0</version>
    </dependency>
    
  2. 提供执行器配置文件

    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
    server.port=8207
       
       
    ### 调度中心部署根地址 [选填]:如调度中心集群部署存在多个地址则用逗号分隔。执行器将会使用该地址进行"执行器心跳注册"和"任务结果回调";为空则关闭自动注册;
    xxl.job.admin.addresses=http://127.0.0.1:8080/xxl-job-admin
    ### 执行器通讯TOKEN [选填]:非空时启用;
    xxl.job.accessToken=default_token
    ### 执行器AppName [选填]:执行器心跳注册分组依据;为空则关闭自动注册
    xxl.job.executor.appname=service-task-executor
    ### 执行器注册 [选填]:优先使用该配置作为注册地址,为空时使用内嵌服务 ”IP:PORT“ 作为注册地址。从而更灵活的支持容器类型执行器动态IP和动态映射端口问题。
    xxl.job.executor.address=
    ### 执行器IP [选填]:默认为空表示自动获取IP,多网卡时可手动设置指定IP,该IP不会绑定Host仅作为通讯实用;地址信息用于 "执行器注册" 和 "调度中心请求并触发任务";
    xxl.job.executor.ip=192.168.41.45
    ### 执行器端口号 [选填]:小于等于0则自动获取;默认端口为9999,单机部署多个执行器时,注意要配置不同执行器端口;
    xxl.job.executor.port=9999
    ### 执行器运行日志文件存储磁盘路径 [选填] :需要对该路径拥有读写权限;为空则使用默认路径;
    xxl.job.executor.logpath=/data/applogs/xxl-job/jobhandler
    ### 执行器日志文件保存天数 [选填] : 过期日志自动清理, 限制值大于等于3时生效; 否则, 如-1, 关闭自动清理功能;
    xxl.job.executor.logretentiondays=30
    
  3. 执行器组件,配置内容说明

    package com.atguigu.gmall.task.config;
       
    import com.xxl.job.core.executor.impl.XxlJobSpringExecutor;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
       
    /**
    * @author: atguigu
    * @create: 2023-08-15 14:15
    */
    @Configuration
    public class XxlJobConfig {
       
       @Value("${xxl.job.admin.addresses}")
       private String adminAddresses;
       
       @Value("${xxl.job.accessToken}")
       private String accessToken;
       
       @Value("${xxl.job.executor.appname}")
       private String appname;
       
       @Value("${xxl.job.executor.address}")
       private String address;
       
       @Value("${xxl.job.executor.ip}")
       private String ip;
       
       @Value("${xxl.job.executor.port}")
       private int port;
       
       @Value("${xxl.job.executor.logpath}")
       private String logPath;
       
       @Value("${xxl.job.executor.logretentiondays}")
       private int logRetentionDays;
       
       
       @Bean
       public XxlJobSpringExecutor xxlJobExecutor() {
           XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
           xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
           xxlJobSpringExecutor.setAppname(appname);
           xxlJobSpringExecutor.setIp(ip);
           xxlJobSpringExecutor.setPort(port);
           xxlJobSpringExecutor.setAccessToken(accessToken);
           xxlJobSpringExecutor.setLogPath(logPath);
           xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);
           return xxlJobSpringExecutor;
       }
    }
    
  4. 提供定时任务逻辑

    package com.atguigu.gmall.task.job;
       
    import com.xxl.job.core.handler.annotation.XxlJob;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.scheduling.annotation.Scheduled;
    import org.springframework.stereotype.Component;
       
    /**
    * @author: atguigu
    * @create: 2023-08-15 11:32
    */
    @Slf4j
    @Component
    public class TaskJob {
       
       /**
        * spring定时任务-适用于单体项目中简单定时任务;任务无法停止,任务执行周期
        * cron表达式:秒 分 时 日 月 周 [年]
        */
       //@Scheduled(cron = "0/5 * * * * ?")
       public void stringTask(){
           System.out.println("任务执行了");
       }
       
       
       /**
        * 每日凌晨1点执行任务,发送消息通知秒杀服务缓存预热
        */
       @XxlJob("seckillImport")
       public void seckillImport(){
         log.info("[定时任务]发送秒杀商品入库消息");
       }
    }
    
  5. 在调度中心页面新增执行器,关闭本地防火墙

  6. 在调度中心页面新增任务

image-20230513111355442

image-20230513111429518

  1. 在调度中心管理页面,动态管理任务即时生效

3.2 监听定时任务信息

在service-activity模块绑定与监听消息,处理缓存逻辑,更新状态位

3.2.1 数据导入缓存

3.2.1.1 在service-util的RedisConst类中定义常量

//秒杀商品前缀
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完成缓存预热

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.RedisConst;
import com.atguigu.gmall.common.util.DateUtil;
import com.atguigu.gmall.rabbit.config.MqConst;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.rabbitmq.client.Channel;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
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;

import static com.atguigu.gmall.common.constant.RedisConst.SECKILL_STOCK_PREFIX;


/**
 * @author: atguigu
 * @create: 2023-08-15 15:13
 */
@Slf4j
@Component
public class ActivityReceiver {

    @Autowired
    private SeckillGoodsService seckillGoodsService;

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 监听每日凌晨发送秒杀预热消息,将当日参与秒杀,审核通过,秒杀数量>1放入缓存
     *
     * @param msg
     * @param message
     * @param channel
     */
    @SneakyThrows
    @RabbitListener(bindings = @QueueBinding(
            exchange = @Exchange(value = MqConst.EXCHANGE_DIRECT_TASK, durable = "true"),
            value = @Queue(value = MqConst.QUEUE_TASK_1, durable = "true"),
            key = MqConst.ROUTING_TASK_1
    ))
    public void processSeckillToCache(String msg, Message message, Channel channel) {
        if (StringUtils.isNotBlank(msg)) {
            log.info("[秒杀服务]监听到秒杀商品预热消息,执行秒杀入库");
            String today = DateUtil.formatDate(new Date());
            //1.将当日参与秒杀,审核通过,秒杀数量大于1商品列表
            LambdaQueryWrapper<SeckillGoods> queryWrapper = new LambdaQueryWrapper<>();
            queryWrapper.eq(SeckillGoods::getStatus, "1");
            queryWrapper.gt(SeckillGoods::getStockCount, 0);
            queryWrapper.apply("DATE_FORMAT(start_time, '%Y-%m-%d' ) = '" + today + "'");
            List<SeckillGoods> seckillGoodsList = seckillGoodsService.list(queryWrapper);

            //2.构建当日参与秒杀商品hash结构key,创建绑定hash操作对象
            String seckillKey = RedisConst.SECKILL_GOODS;
            BoundHashOperations<String, String, SeckillGoods> seckillHashOps = redisTemplate.boundHashOps(seckillKey);

            //3.遍历秒杀商品列表,将秒杀商品信息放入缓存Redis中hash中
            if (!CollectionUtils.isEmpty(seckillGoodsList)) {
                for (SeckillGoods seckillGoods : seckillGoodsList) {
                    String hashKey = seckillGoods.getSkuId().toString();
                    if (seckillHashOps.hasKey(hashKey)) {
                        //如果hash中包含该秒杀商品 结束本次循环;继续下一次循环
                        continue;
                    }
                    seckillHashOps.put(hashKey, seckillGoods);
                    //4.每遍历一次为当前秒杀商品设置库存
                    String stockKey = SECKILL_STOCK_PREFIX + hashKey;
                    //4.1 构建当前商品库存list的key 创建绑定List操作对象
                    BoundListOperations<String, String> stockListOps = redisTemplate.boundListOps(stockKey);
                    //4.2 循序库存数量,向库存list中添加元素
                    for (Integer i = 0; i < seckillGoods.getStockCount(); i++) {
                        stockListOps.leftPush(hashKey);
                    }
                    //5.todo 广播所有秒杀节点,修改本地缓存中商品状态位
                }
            }
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        }
    }
}

3.2.1.3 SeckillGoodsMapper

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<SeckillGoods> {
}

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发布与订阅实现

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, "receiveSeckillGoodState");
    }

    @Bean //注入操作数据的template
    StringRedisTemplate template(RedisConnectionFactory connectionFactory) {
        return new StringRedisTemplate(connectionFactory);
    }

}

监听消息

package com.atguigu.gmall.activity.redis;

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;

/**
 * 监听到Redis发布订阅话题中消息后执行业务逻辑
 */
@Slf4j
@Component
public class MessageReceive {

    @Autowired
    private Cache<String, String> cache;

    /**
     * 监听话题中商品状态,修改本地缓存商品状态位
     *  形式
     * @param state ""35:0""
     */
    public void receiveSeckillGoodState(String state) {
        if (StringUtils.isNotBlank(state)) {
            log.info("接收到Redis话题中消息:{}", state);
            state = state.replace("\"", "");
            String[] split = state.split(":");
            if (split != null && split.length == 2) {
                cache.put(split[0], split[1]);
            }
        }
    }
}

CacheHelper类本地缓存类(不考虑使用)

package com.atguigu.gmall.activity.util;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 系统缓存类
 */
public class CacheHelper {

    /**
     * 缓存容器
     */
    private final static Map<String, Object> cacheMap = new ConcurrentHashMap<String, Object>();

    /**
     * 加入缓存
     *
     * @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替换本地缓存

package com.atguigu.gmall.activity.config;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Duration;

/**
 * @author: atguigu
 * @create: 2023-07-01 10:05
 */
@Configuration
public class CacheConfig {


    /**
     * 用于缓存商品状态位本地缓存对象
     *
     * @return
     */
    @Bean
    public Cache<String, String> seckillStatusCache() {
        return Caffeine.newBuilder()
                .maximumSize(1024)
                .expireAfterWrite(Duration.ofHours(24))
                .build();
    }


}

3.2.2.2 redis发布消息

监听已经配置好,接下来我就发布消息,更改秒杀监听器{ SeckillReceiver },如下

img

完整代码如下

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.RedisConst;
import com.atguigu.gmall.common.util.DateUtil;
import com.atguigu.gmall.rabbit.config.MqConst;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.github.benmanes.caffeine.cache.Cache;
import com.rabbitmq.client.Channel;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
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;

import static com.atguigu.gmall.common.constant.RedisConst.SECKILL_STOCK_PREFIX;


/**
 * @author: atguigu
 * @create: 2023-08-15 15:13
 */
@Slf4j
@Component
public class ActivityReceiver {

    @Autowired
    private SeckillGoodsService seckillGoodsService;

    @Autowired
    private RedisTemplate redisTemplate;


    /**
     * 监听每日凌晨发送秒杀预热消息,将当日参与秒杀,审核通过,秒杀数量>1放入缓存
     *
     * @param msg
     * @param message
     * @param channel
     */
    @SneakyThrows
    @RabbitListener(bindings = @QueueBinding(
            exchange = @Exchange(value = MqConst.EXCHANGE_DIRECT_TASK, durable = "true"),
            value = @Queue(value = MqConst.QUEUE_TASK_1, durable = "true"),
            key = MqConst.ROUTING_TASK_1
    ))
    public void processSeckillToCache(String msg, Message message, Channel channel) {
        if (StringUtils.isNotBlank(msg)) {
            log.info("[秒杀服务]监听到秒杀商品预热消息,执行秒杀入库");
            String today = DateUtil.formatDate(new Date());
            //1.将当日参与秒杀,审核通过,秒杀数量大于1商品列表
            LambdaQueryWrapper<SeckillGoods> queryWrapper = new LambdaQueryWrapper<>();
            queryWrapper.eq(SeckillGoods::getStatus, "1");
            queryWrapper.gt(SeckillGoods::getStockCount, 0);
            queryWrapper.apply("DATE_FORMAT(start_time, '%Y-%m-%d' ) = '" + today + "'");
            List<SeckillGoods> seckillGoodsList = seckillGoodsService.list(queryWrapper);

            //2.构建当日参与秒杀商品hash结构key,创建绑定hash操作对象
            String seckillKey = RedisConst.SECKILL_GOODS;
            BoundHashOperations<String, String, SeckillGoods> seckillHashOps = redisTemplate.boundHashOps(seckillKey);

            //3.遍历秒杀商品列表,将秒杀商品信息放入缓存Redis中hash中
            if (!CollectionUtils.isEmpty(seckillGoodsList)) {
                for (SeckillGoods seckillGoods : seckillGoodsList) {
                    String hashKey = seckillGoods.getSkuId().toString();
                    if (!seckillHashOps.hasKey(hashKey)) {
                        seckillHashOps.put(hashKey, seckillGoods);
                    }
                    //4.每遍历一次为当前秒杀商品设置库存
                    String stockKey = SECKILL_STOCK_PREFIX + hashKey;
                    //4.1 构建当前商品库存list的key 创建绑定List操作对象
                    BoundListOperations<String, String> stockListOps = redisTemplate.boundListOps(stockKey);
                    //4.2 循序库存数量,向库存list中添加元素
                    for (Integer i = 0; i < seckillGoods.getStockCount(); i++) {
                        stockListOps.leftPush(hashKey);
                    }
                    //5.todo 广播所有秒杀节点,修改本地缓存中商品状态位
                    redisTemplate.convertAndSend("seckillpush", hashKey + ":1");
                }
            }
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        }
    }
}

说明:到目前我们就实现了商品信息导入缓存,同时更新状态位的工作

4、秒杀列表与详情

YAPI接口地址:

4.1 封装秒杀列表与详情接口

4.1.1 完成控制器

SeckillGoodsController

package com.atguigu.gmall.activity.controller;

import com.atguigu.gmall.activity.model.SeckillGoods;
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;

import java.util.List;

/**
 * @author: atguigu
 * @create: 2023-07-01 10:53
 */
@RestController
@RequestMapping("/api/activity")
public class SeckillGoodsController {


    @Autowired
    private SeckillGoodsService seckillGoodsService;


    /**
     * 查询当日参与秒杀商品
     *
     * @return
     */
    @GetMapping("/seckill/findAll")
    public Result getSeckillGoods() {
        List<SeckillGoods> list = seckillGoodsService.getSeckillGoods();
        return Result.ok(list);
    }

    /**
     * 查询秒杀商品详情
     *
     * @param skuId
     * @return
     */
    @GetMapping("/seckill/getSeckillGoods/{skuId}")
    public Result getSecGoodsById(@PathVariable("skuId") Long skuId) {
        SeckillGoods seckillGoods = seckillGoodsService.getSecGoodsById(skuId);
        return Result.ok(seckillGoods);
    }
}

4.1.2 业务接口

package com.atguigu.gmall.activity.service;

import com.atguigu.gmall.activity.model.SeckillGoods;
import com.baomidou.mybatisplus.extension.service.IService;

import java.util.List;

/**
 *  业务接口类
 * @author atguigu
 * @since 2023-06-30
 */
public interface SeckillGoodsService extends IService<SeckillGoods> {

    /**
     * 查询当日参与秒杀商品
     * @return
     */
    List<SeckillGoods> getSeckillGoods();

    /**
     * 查询秒杀商品详情
     * @param skuId
     * @return
     */
    SeckillGoods getSecGoodsById(Long skuId);
}

4.1.2 业务实现类

package com.atguigu.gmall.activity.service.impl;

import com.atguigu.gmall.activity.model.SeckillGoods;
import com.atguigu.gmall.activity.mapper.SeckillGoodsMapper;
import com.atguigu.gmall.activity.service.SeckillGoodsService;
import com.atguigu.gmall.common.constant.RedisConst;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
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
 * @since 2023-06-30
 */
@Service
public class SeckillGoodsServiceImpl extends ServiceImpl<SeckillGoodsMapper, SeckillGoods> implements SeckillGoodsService {

    @Autowired
    private RedisTemplate redisTemplate;


    /**
     * 查询当日参与秒杀商品
     *
     * @return
     */
    @Override
    public List<SeckillGoods> getSeckillGoods() {
        BoundHashOperations<String, String, SeckillGoods> seckillHashOps = getSeckillGoodsHashOpes();
        return seckillHashOps.values();
    }


    /**
     * 返回操作秒杀商品对象
     *
     * @return
     */
    private BoundHashOperations<String, String, SeckillGoods> getSeckillGoodsHashOpes() {
        String seckillKey = RedisConst.SECKILL_GOODS;
        BoundHashOperations<String, String, SeckillGoods> seckillHashOps = redisTemplate.boundHashOps(seckillKey);
        return seckillHashOps;
    }

    /**
     * 查询秒杀商品详情
     *
     * @param skuId
     * @return
     */
    @Override
    public SeckillGoods getSecGoodsById(Long skuId) {
        BoundHashOperations<String, String, SeckillGoods> hashOpes = getSeckillGoodsHashOpes();
        String hashKey = skuId.toString();
        Boolean flag = hashOpes.hasKey(hashKey);
        if (flag) {
            return hashOpes.get(hashKey);
        }
        return null;
    }
}

4.2 在service-activity-client模块添加接口

远程调用Feign API接口

package com.atguigu.gmall.activity.client;

import com.atguigu.gmall.activity.client.impl.ActivityDegradeFeignClient;
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;

@FeignClient(value = "service-activity", path = "/api/activity", fallback = ActivityDegradeFeignClient.class)
public interface ActivityFeignClient {


    /**
     * 查询当日参与秒杀商品
     *
     * @return
     */
    @GetMapping("/seckill/findAll")
    public Result getSeckillGoods();

    /**
     * 查询秒杀商品详情
     * @param skuId
     * @return
     */
    @GetMapping("/seckill/getSeckillGoods/{skuId}")
    public Result getSecGoodsById(@PathVariable("skuId") Long skuId);

}

服务降级类

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;

/**
 * @author: atguigu
 * @create: 2023-07-01 10:59
 */
@Component
public class ActivityDegradeFeignClient implements ActivityFeignClient {
    @Override
    public Result getSeckillGoods() {
        return null;
    }

    @Override
    public Result getSecGoodsById(Long skuId) {
        return null;
    }
}

4.3 页面渲染

4.3.1 在web-all 中编写控制器

在 web-all 项目中添加控制器

package com.atguigu.gmall.web.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;
import org.springframework.web.bind.annotation.PathVariable;

/**
 * @author: atguigu
 * @create: 2023-07-01 11:00
 */
@Controller
public class SeckillHtmlController {

    @Autowired
    private ActivityFeignClient activityFeignClient;


    /**
     * 秒杀商品列表页
     *
     * @param model
     * @return
     */
    @GetMapping("/seckill.html")
    public String seckillListHtml(Model model) {
        Result result = activityFeignClient.getSeckillGoods();
        model.addAttribute("list", result.getData());
        return "seckill/index";
    }

    /**
     * 秒杀详情页
     *
     * @param skuId
     * @return
     */
    @GetMapping("/seckill/{skuId}.html")
    public String seckillItemHtml(Model model, @PathVariable("skuId") Long skuId) {
        Result result = activityFeignClient.getSecGoodsById(skuId);
        model.addAttribute("item", result.getData());
        return "seckill/item";
    }
}

列表

页面资源: \templates\seckill\index.html

<div class="goods-list" id="item">
   <ul class="seckill" id="seckill">
      <li class="seckill-item" th:each="item: ${list}">
         <div class="pic" th:@click="|detail(${item.skuId})|">
            <img th:src="${item.skuDefaultImg}" alt=''>
         </div>
         <div class="intro">
            <span th:text="${item.skuName}">手机</span>
         </div>
         <div class='price'>
            <b class='sec-price' th:text="'¥'+${item.costPrice}">¥0</b>
            <b class='ever-price' th:text="'¥'+${item.price}">¥0</b>
         </div>
         <div class='num'>
            <div th:text="'已售'+${item.num}">已售1</div>
            <div class='progress'>
               <div class='sui-progress progress-danger'>
                  <span style='width: 70%;' class='bar'></span>
               </div>
            </div>
            <div>剩余
               <b class='owned' th:text="${item.stockCount}">0</b>件</div>
         </div>
         <a class='sui-btn btn-block btn-buy' th:href="'/seckill/'+${item.skuId}+'.html'" target='_blank'>立即抢购</a>
      </li>
   </ul>
</div>

4.3.2 秒杀详情页面功能介绍

说明:

1,立即购买,该按钮我们要加以控制,该按钮就是一个链接,页面只是控制能不能点击,一般用户可以绕过去,直接点击秒杀下单,所以我们要加以控制,在秒杀没有开始前,不能进入秒杀页面

4.3.2.1 web-all添加商品详情控制器

SeckillHtmlController

/**
 * 秒杀下单排队页面(请求1:预下单请求发起  请求2:定时查询状态)
 *
 * @param model
 * @param skuId
 * @param bugCode
 * @return
 */
@GetMapping("/seckill/queue.html")
public String queueHtml(Model model, @RequestParam("skuId") Long skuId, @RequestParam("skuIdStr") String bugCode) {
    model.addAttribute("skuId", skuId);
    model.addAttribute("skuIdStr", bugCode);
    return "/seckill/queue";
}

4.3.2.2 详情页面介绍

4.3.2.2.1 基本信息渲染
<div class="product-info">
   <div class="fl preview-wrap">
      <!--放大镜效果-->
      <div class="zoom">
         <!--默认第一个预览-->
         <div id="preview" class="spec-preview">
            <span class="jqzoom"><img th:jqimg="${item.skuDefaultImg}" th:src="${item.skuDefaultImg}" width="400" height="400"/></span>
         </div>
      </div>
   </div>
   <div class="fr itemInfo-wrap">
      <div class="sku-name">
         <h4 th:text="${item.skuName}">三星</h4>
      </div>
      <div class="news">
         <span><img src="/img/_/clock.png"/>品优秒杀</span>
         <span class="overtime">{{timeTitle}}:{{timeString}}</span>
      </div>
      <div class="summary">
         <div class="summary-wrap">

            <div class="fl title">
               <i>秒杀价</i>
            </div>
            <div class="fl price">
               <i>¥</i>
               <em th:text="${item.costPrice}">0</em>
               <span th:text="'原价:'+${item.price}">原价:0</span>
            </div>
            <div class="fr remark">
               剩余库存:<span th:text="${item.stockCount}">0</span>
            </div>
         </div>
         <div class="summary-wrap">
            <div class="fl title">
               <i>促  销</i>
            </div>
            <div class="fl fix-width">
               <i class="red-bg">加价购</i>
               <em class="t-gray">满999.00另加20.00元,或满1999.00另加30.00元,或满2999.00另加40.00元,即可在购物车换购热销商品</em>
            </div>
         </div>
      </div>
      <div class="support">
         <div class="summary-wrap">
            <div class="fl title">
               <i>支  持</i>
            </div>
            <div class="fl fix-width">
               <em class="t-gray">以旧换新,闲置手机回收 4G套餐超值抢 礼品购</em>
            </div>
         </div>
         <div class="summary-wrap">
            <div class="fl title">
               <i>配 送 至</i>
            </div>
            <div class="fl fix-width">
               <em class="t-gray">满999.00另加20.00元,或满1999.00另加30.00元,或满2999.00另加40.00元,即可在购物车换购热销商品</em>
            </div>
         </div>
      </div>
      <div class="clearfix choose">
         <div class="summary-wrap">
            <div class="fl title">

            </div>
            <div class="fl">
               <ul class="btn-choose unstyled">
                  <li>
                     <a href="javascript:" v-if="isBuy" @click="queue()" class="sui-btn  btn-danger addshopcar">立即抢购</a>
                     <a href="javascript:" v-if="!isBuy" class="sui-btn  btn-danger addshopcar" disabled="disabled">立即抢购</a>
                  </li>
               </ul>
            </div>
         </div>
      </div>
   </div>
</div>
4.3.2.2.2 倒计时处理

思路:页面初始化时,拿到商品秒杀开始时间和结束时间等信息,实现距离开始时间和活动倒计时。

注意:当前系统时间从后端单独时间服务器获取当前时间.作为判断商品秒杀开始依据.

活动未开始时,显示距离开始时间倒计时;

活动开始后,显示活动结束时间倒计时。

倒计时代码片段

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);
})
},

时间转换方法

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

SeckilBizController

package com.atguigu.gmall.activity.controller;

import com.atguigu.gmall.activity.service.SeckillBizService;
import com.atguigu.gmall.common.result.Result;
import com.atguigu.gmall.common.util.AuthContextHolder;
import org.apache.commons.lang.StringUtils;
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;

import javax.servlet.http.HttpServletRequest;

/**
 * @author: atguigu
 * @create: 2023-08-16 10:51
 */
@RestController
@RequestMapping("/api/activity/seckill/auth")
public class SeckilBizController {


    @Autowired
    private SeckillBizService seckillBizService;


    /**
     * 获取当前用户秒杀商品抢购码
     *
     * @param skuId
     * @return
     */
    @GetMapping("/getSeckillSkuIdStr/{skuId}")
    public Result getSeckillSkuIdStr(HttpServletRequest request, @PathVariable("skuId") Long skuId) {
        //1.获取当前登录用户ID
        String userId = AuthContextHolder.getUserId(request);
        if (StringUtils.isBlank(userId)) {
            throw new RuntimeException("请先登录");
        }
        //2.调用业务逻辑生成用户抢购码
        String buyCode = seckillBizService.getSeckillBuyCode(userId, skuId);
        return Result.ok(buyCode);
    }
}

SeckillBizService

package com.atguigu.gmall.activity.service;

public interface SeckillBizService {

    /**
     * 生成用户秒杀商品抢购码
     * @param userId
     * @param skuId
     * @return
     */
    String getSeckillBuyCode(String userId, Long skuId);
}

SeckillBizServiceImpl

package com.atguigu.gmall.activity.service.impl;

import com.atguigu.gmall.activity.model.SeckillGoods;
import com.atguigu.gmall.activity.service.SeckillBizService;
import com.atguigu.gmall.activity.service.SeckillGoodsService;
import com.atguigu.gmall.common.util.MD5;
import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.Date;

/**
 * @author: atguigu
 * @create: 2023-08-16 10:55
 */
@Service
public class SeckillBizServiceImpl implements SeckillBizService {

    @Autowired
    private Cache<String, String> seckillCache;

    @Autowired
    private SeckillGoodsService seckillGoodsService;

    /**
     * 生成用户秒杀商品抢购码
     *
     * @param userId
     * @param skuId
     * @return
     */
    @Override
    public String getSeckillBuyCode(String userId, Long skuId) {
        //1.判断本地缓存中商品状态 必须1代表正在秒杀 0:代表售罄  2:秒杀结束
        String status = seckillCache.getIfPresent(skuId.toString());
        if ("2".equals(status)) {
            throw new RuntimeException("秒杀已结束!");
        } else if ("0".equals(status)) {
            throw new RuntimeException("商品已售罄!");
        } else {
            //状态等于1
            //2.判断秒杀商品是否在销售时间内
            SeckillGoods seckillGoods = seckillGoodsService.getSecGoodsById(skuId);
            if (seckillGoods != null) {
                Date now = new Date();  //当前时间大于开始时间 并且 小于结束时间
                Date startTime = seckillGoods.getStartTime();
                Date endTime = seckillGoods.getEndTime();
                if (now.getTime() >= startTime.getTime() && now.getTime() <= endTime.getTime()) {
                    //3.生成抢购码 保证发放验证码,再次验证是否正确 todo 将抢购码放入Redis 不放也行md5加密效率很高
                    String buyCode = MD5.encrypt(userId + skuId);
                    return buyCode;
                }
            }
        }
        throw new RuntimeException("请求非法!");
    }
}

说明:只有在商品秒杀时间范围内,才能获取下单码,这样我们就有效控制了用户非法秒杀,下单码我们可以根据业务自定义规则,目前我们定义为当前用户id MD5加密。

4.3.2.3.2 前端页面

页面获取下单码,进入秒杀场景

queue() {
       seckill.getSeckillSkuIdStr(this.skuId).then(response => {
        var skuIdStr = response.data.data
        window.location.href = '/seckill/queue.html?skuId='+this.skuId+'&skuIdStr='+skuIdStr
    })
},

前端js完整代码如下

<script src="/js/api/seckill.js"></script>
<script th:inline="javascript">
   var item = new Vue({
      el: '#item',

      data: {
         skuId: [[${item.skuId}]],
         data: [[${item}]],
            timeTitle: '距离开始',
            timeString: '00:00:00',
            isBuy: false
      },

        created() {
            this.init()
        },

        methods: {
            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);
            })
            },
            queue() {
                            seckill.getSeckillSkuIdStr(this.skuId).then(response => {
                    var skuIdStr = response.data.data
                    window.location.href = '/seckill/queue.html?skuId='+this.skuId+'&skuIdStr='+skuIdStr
                })
            },
            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;
            }
        }
   })
</script>

4.3.3 编写排队控制器

web-all模块中SeckillHtmlController

/**
 * 秒杀前排队页面
 * @param skuId 商品ID
 * @param buyCode 抢购码
 * @return
 */
@GetMapping("/seckill/queue.html")
public String seckillQueueHtml(Model model, @RequestParam("skuId") Long skuId, @RequestParam("skuIdStr") String buyCode){
    model.addAttribute("skuId", skuId);
    model.addAttribute("skuIdStr", buyCode);
    return "/seckill/queue";
}

页面

页面资源: \templates\seckill\queue.html

<div class="cart py-container" id="item">
    <div class="seckill_dev" v-if="show == 1">
        排队中...
    </div>
    <div class="seckill_dev" v-if="show == 2">
        {{message}}
    </div>
    <div class="seckill_dev" v-if="show == 3">
        抢购成功&nbsp;&nbsp;

        <a href="/seckill/trade.html" target="_blank">去下单</a>
    </div>
    <div class="seckill_dev" v-if="show == 4">
        抢购成功&nbsp;&nbsp;

        <a href="/myOrder.html" target="_blank">我的订单</a>
    </div>
</div>

Js部分

<script src="/js/api/seckill.js"></script>
<script th:inline="javascript">
    var item = new Vue({
        el: '#item',

        data: {
            skuId: [[${skuId}]],
            skuIdStr: [[${skuIdStr}]],
            data: {},
            show: 1,
            code: 211,
            message: '',
            isCheckOrder: false
        },

        mounted() {
            const timer = setInterval(() => {
                if(this.code != 211) {
                    clearInterval(timer);
                }
                this.checkOrder()
            }, 3000);
            // 通过$once来监听定时器,在beforeDestroy钩子可以被清除。
            this.$once('hook:beforeDestroy', () => {
                clearInterval(timer);
            })
        },

        created() {
            this.saveOrder();
        },

        methods: {
            saveOrder() {
                seckill.seckillOrder(this.skuId, this.skuIdStr).then(response => {
                    debugger
                    console.log(JSON.stringify(response))
                    if(response.data.code == 200) {
                        this.isCheckOrder = true
                    } else {
                        this.show = 2
                        this.message = response.data.message
                    }

                })
            },

            checkOrder() {
                if(!this.isCheckOrder) return

                seckill.checkOrder(this.skuId).then(response => {
                     debugger
                    this.data = response.data.data
                    this.code = response.data.code
                    console.log(JSON.stringify(this.data))
                    //排队中
                    if(response.data.code == 211) {
                        this.show = 1
                    } else {
                        //秒杀成功
                        if(response.data.code == 215) {
                            this.show = 3
                            this.message = response.data.message
                        } else {
                            if(response.data.code == 218) {
                                this.show = 4
                                this.message = response.data.message
                            } else {
                                this.show = 2
                                this.message = response.data.message
                            }
                        }
                    }
                })
            }
        }
    })
</script>

说明:该页面直接通过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类

/**
 * 秒杀
 */
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

记录哪个用户要购买哪个商品!

@Data
public class UserRecode implements Serializable {

   private static final long serialVersionUID = 1L;

   private Long skuId;
   
   private String userId;
}

5.1.3 编写控制器

SeckilBizController

/**
 * 秒杀请求入队(将秒杀请求放入消息队列RabbitMQ);后续秒杀结果页面中定时器查询秒杀结果
 *
 * @param skuId
 * @param buyCode
 * @return
 */
@PostMapping("/seckillOrder/{skuId}")
public Result seckillOrderToQueue(HttpServletRequest request, @PathVariable("skuId") Long skuId, @RequestParam("skuIdStr") String buyCode) {
    //1.获取当前登录用户ID
    String userId = AuthContextHolder.getUserId(request);
    if (StringUtils.isBlank(userId)) {
        throw new RuntimeException("请先登录");
    }
    //2.调用业务逻辑进行秒杀入队处理
    seckillBizService.seckillOrderToQueue(userId, skuId, buyCode);
    return Result.ok();
}

5.1.4 业务层接口

SeckillBizService

/**
 * 秒杀请求入队(将秒杀意向发送MQ就结束)
 *
 * @param userId
 * @param skuId
 * @param buyCode
 */
void seckillOrderToQueue(String userId, Long skuId, String buyCode);

5.1.5 业务层实现

SeckillGoodsServiceImpl

@Autowired
private RabbitService rabbitService;

/**
 * 秒杀请求入队(将秒杀意向发送MQ就结束)
 *
 * @param userId
 * @param skuId
 * @param buyCode
 */
@Override
public void seckillOrderToQueue(String userId, Long skuId, String buyCode) {
    //1.验证用户提交抢购码是否正确
    //1.1 按照相同方式对用户ID+商品ID进行MD5加密
    String encrypt = MD5.encrypt(userId + skuId);
    //1.2 跟用户提交抢购码进行判断
    if (!buyCode.equals(encrypt)) {
        throw new RuntimeException("抢购码验证失败,请重新下单!");
    }

    //2.验证秒杀商品在本地缓存中状态
    String status = seckillCache.getIfPresent(skuId.toString());
    if ("2".equals(status)) {
        throw new RuntimeException("抢购活动已结束!");
    }
    if ("0".equals(status)) {
        throw new RuntimeException("商品已售罄!");
    }
    //3.todo 验证商品秒杀时间
    //4. 避免用户多次排队 采用setnx
    // 如果只允许买秒杀商品中其中一件
    //String reqeueKey = RedisConst.SECKILL_USER + userId + ":requeue";
    //如果允许买秒杀商品中多件商品
    String reqeueKey = RedisConst.SECKILL_USER + userId + ":" + skuId + ":requeue";
    //todo 时间动态计算 秒杀商品结束时间-当前系统时间
    Boolean flag = redisTemplate.opsForValue().setIfAbsent(reqeueKey, userId, 600, TimeUnit.MINUTES);
    if(!flag){
        //避免前段用户下单后,重新刷新浏览器,如果提示错误,前端不会发起定时检查秒杀结果
        return;
        //throw new RuntimeException("请勿重复排队!");
    }
    //5.将当前秒杀意向商品封装对象(商品ID,用户ID,商品数量) 发送到 消息队列
    if ("1".equals(status)) {
        //5.1 构建用户秒杀商品对象
        UserRecode userRecode = new UserRecode();
        userRecode.setUserId(userId);
        userRecode.setSkuId(skuId);
        userRecode.setNum(1); //从秒杀商品信息中获取
        //5.1 发送秒杀消息到秒杀队列
        rabbitService.sendMsg(MqConst.EXCHANGE_DIRECT_SECKILL_USER, MqConst.ROUTING_SECKILL_USER, userRecode);
    }
}

5.2 秒杀下单监听

思路:

1,首先判断产品状态位,我们前面不是已经判断过了吗?因为产品可能随时售罄,mq队列里面可能堆积了十万数据,但是已经售罄了,那么后续流程就没有必要再走了;

2,判断用户是否已经下过订单,这个地方就是控制用户重复下单,同一个用户只能抢购一个下单资格,怎么控制呢?很简单,我们可以利用setnx控制用户,当用户第一次进来时,返回true,可以抢购,以后进入返回false,直接返回,过期时间可以根据业务自定义,这样用户这一段咋们就控制注了

3,获取队列中的商品,如果能够获取,则商品有库存,可以下单。如果获取的商品id为空,则商品售罄,商品售罄我们要第一时间通知兄弟节点,更新状态位,所以在这里发送redis广播

4,将订单记录放入redis缓存,说明用户已经获得下单资格,秒杀成功

5,秒杀成功要更新库存

5.2.1 SeckillReceiver添加监听方法

/**
 * 监听秒杀队列,完成秒杀核心业务(处理秒杀)
 *
 * @param userRecode
 * @param channel
 * @param message
 */
@SneakyThrows
@RabbitListener(bindings = @QueueBinding(
        exchange = @Exchange(value = MqConst.EXCHANGE_DIRECT_SECKILL_USER, durable = "true"),
        value = @Queue(value = MqConst.QUEUE_SECKILL_USER, durable = "true"),
        key = MqConst.ROUTING_SECKILL_USER
))
public void processSeckillReq(UserRecode userRecode, Channel channel, Message message) {
    if (userRecode != null) {
        log.info("[秒杀服务]监听到秒杀请求:{}", userRecode);
        seckillBizService.processSeckillRequest(userRecode);
        channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
    }
}

5.2.2 秒杀下单接口

SeckillGoodsService

/**
 * 处理用户秒杀请求
 * @param userRecode
 */
void processSeckillRequest(UserRecode userRecode);

5.2.3 实现类

秒杀订单实体类

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;
}

雪花算法配置类

package com.atguigu.gmall.activity.config;

import com.atguigu.gmall.common.util.SnowFlake;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @author: atguigu
 * @create: 2023-06-25 15:27
 */
@Configuration
public class SnowFlakeConfig {

    @Bean
    public SnowFlake snowFlake(){
        return new SnowFlake(1,1,1);
    }

}

SeckillGoodsServiceImpl


@Autowired
private SnowFlake snowFlake;

/**
 * 处理用户秒杀请求
 *
 * @param userRecode
 */
@Override
public void processSeckillRequest(UserRecode userRecode) {
    //1.验证本地缓存中商品状态是否为1
    String status = seckillCache.getIfPresent(userRecode.getSkuId().toString());
    if (!"1".equals(status)) {
        log.error("[秒杀服务]处理秒杀请求,商品已售罄:{}", userRecode);
        //忽略该业务处理
        return;
    }

    //2.验证用户是否已经下过单
    String orderHashKey = RedisConst.SECKILL_ORDERS_USERS;
    BoundHashOperations<String, String, Long> orderHashOps = redisTemplate.boundHashOps(orderHashKey);
    String hashKey = userRecode.getUserId() + ":" + userRecode.getSkuId();
    if (orderHashOps.hasKey(hashKey)) {
        log.error("[秒杀服务]用户重复下单:{}", userRecode);
        return;
    }
    //3.验证秒杀库存是否充足(避免超卖)从List结构中弹出数据
    String stockListKey = RedisConst.SECKILL_STOCK_PREFIX + userRecode.getSkuId();
    BoundListOperations<String, String> stockListOps = redisTemplate.boundListOps(stockListKey);
    String hasStock = stockListOps.rightPop();
    if (StringUtils.isBlank(hasStock)) {
        log.error("[秒杀服务]秒杀商品库存为空:{}", userRecode);
        //通知所有秒杀服务节点更新本地缓存状态为:0
        redisTemplate.convertAndSend("seckillpush", userRecode.getSkuId() + ":0");
        return;
    }

    //4.为获取到秒杀资格用户产生临时订单-存入Redis(用户、商品、订单信息)
    //4.1 构建临时订单对象
    OrderRecode orderRecode = new OrderRecode();
    orderRecode.setUserId(userRecode.getUserId());
    orderRecode.setNum(orderRecode.getNum());
    orderRecode.setSeckillGoods(seckillGoodsService.getSecGoodsById(userRecode.getSkuId()));
    //临时订单编号保证唯一 SEC+日期+雪花算法
    String today = DateUtil.formatDate(new Date()).replaceAll("-", "");
    String outTradeNo = "SEC" + today + snowFlake.nextId();
    orderRecode.setOrderStr(outTradeNo);
    orderRecode.setNum(orderRecode.getNum());
    //4.2 存入Redis
    String tempOrderKey = RedisConst.SECKILL_ORDERS;
    BoundHashOperations<String, String, OrderRecode> tempOrderHashOps = redisTemplate.boundHashOps(tempOrderKey);
    tempOrderHashOps.put(hashKey, orderRecode);
    //4.3 TODO 避免用户获取到资格不下单,不付钱情况 发送延迟消息 延迟5分钟  监听该订单编号是否产生订单以及判断订单支付状态 未支付-》关单,恢复秒杀库存

    //5.发送异步MQ消息,通知秒杀服务扣减Redis、MySQL商品库存
    rabbitService.sendMsg(MqConst.EXCHANGE_DIRECT_SECKILL_STOCK, MqConst.ROUTING_SECKILL_STOCK, userRecode.getSkuId());
}

5.2.4 更新秒杀库存

MqConst中增加常量

/**
 * 秒杀减库存
 */
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";

监听秒杀减库存:

ActivityReceiver

/**
 * 监听秒杀商品扣减库存
 *
 * @param skuId
 * @param message
 * @param channel
 */
@SneakyThrows
@RabbitListener(bindings = @QueueBinding(
        exchange = @Exchange(value = MqConst.EXCHANGE_DIRECT_SECKILL_STOCK, durable = "true"),
        value = @Queue(value = MqConst.QUEUE_SECKILL_STOCK, durable = "true"),
        key = MqConst.ROUTING_SECKILL_STOCK
))
public void processDeductStock(Long skuId, Message message, Channel channel) {
    if (skuId != null) {
        log.info("[秒杀服务]监听秒杀扣减库存:{}", skuId);
        seckillBizService.processDeductStock(skuId);
        channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
    }
}

SeckillBizService

/**
 * 扣减秒杀商品库存
 * @param skuId
 */
void processDeductStock(Long skuId);

SeckillBizServiceImpl

/**
 * 扣减秒杀库存
 * * MySQL中秒杀商品库存数量
 * * Redis中Hash中商品信息库存数量
 *
 * @param skuId
 */
@Override
public void processDeductStock(Long skuId) {
    //1.查询当前商品存放在Redis中库存List获取剩余库存数
    String stockListKey = RedisConst.SECKILL_STOCK_PREFIX + skuId;
    BoundListOperations<String, String> stockListOps = redisTemplate.boundListOps(stockListKey);
    Long stockCount = stockListOps.size();
    //2.更新MySQL中秒杀商品表中剩余库存数量
    LambdaUpdateWrapper<SeckillGoods> updateWrapper = new LambdaUpdateWrapper<>();
    updateWrapper.eq(SeckillGoods::getSkuId, skuId);
    updateWrapper.set(SeckillGoods::getStockCount, stockCount);
    seckillGoodsService.update(updateWrapper);

    //3.更新Redis中秒杀商品剩余库存数量 TODO 双写失败 数据一致性问题 将操作Redis失败操作发送MQ-消费者重试
    String seckillKey = RedisConst.SECKILL_GOODS;
    BoundHashOperations<String, String, SeckillGoods> seckillHashOps = redisTemplate.boundHashOps(seckillKey);
    String hashKey = skuId.toString();
    if (seckillHashOps.hasKey(hashKey)) {
        SeckillGoods seckillGoods = seckillHashOps.get(hashKey);
        seckillGoods.setStockCount(stockCount.intValue());
        seckillHashOps.put(hashKey, seckillGoods);
    }
}

5.3 页面轮询接口

思路:

  1. 判断用户是否在缓存中存在

  2. 判断用户是否抢单成功

  3. 判断用户是否下过订单

  4. 判断状态位

YAPI接口地址:http://192.168.200.128:3000/project/11/interface/api/507

5.3.1 控制器

SeckilBizController

/**
 * 前端定时轮询用户秒杀结果
 *
 * @param skuId 秒杀商品ID
 * @return
 */
@GetMapping("/checkOrder/{skuId}")
public Result checkSeckillResult(HttpServletRequest request, @PathVariable("skuId") Long skuId) {
    //1.获取当前登录用户ID
    String userId = AuthContextHolder.getUserId(request);
    if (StringUtils.isBlank(userId)) {
        throw new RuntimeException("请先登录");
    }
    //2.调用业务层获取当前用户秒杀结果
    return seckillBizService.checkSeckillResult(userId, skuId);
}

5.3. 业务接口

SeckillBizService接口

/**
 * 检查用户秒杀下单结果
 *
 * @param skuId
 * @return
 */
Result checkSeckillResult(String userId, Long skuId);

5.3.2 实现类

SeckillBizServiceImpl

/**
 * 检查当前用户秒杀商品结果
 *
 * @param userId
 * @param skuId
 * @return
 */
@Override
public Result checkSeckillResult(String userId, Long skuId) {
    //当前登录用户对指定商品秒杀结果
    //1.如果用户在排队-有机会获取秒杀资格
    String reqeueKey = RedisConst.SECKILL_USER + userId + ":" + skuId + ":requeue";
    Boolean ifQuque = redisTemplate.hasKey(reqeueKey);
    if (ifQuque) {
        //1.1.判断用户是否产生秒杀订单-响应218
        String orderHashKey = RedisConst.SECKILL_ORDERS_USERS;
        //如果用户提交秒杀订单并支付产生 用户秒杀订单hash  其中hashValue存放秒杀订单ID
        BoundHashOperations<String, String, Long> orderHashOps = redisTemplate.boundHashOps(orderHashKey);
        String hashKey = userId + ":" + skuId;
        if (orderHashOps.hasKey(hashKey)) {
            //用户如果提交了订单,在前端页面:我的订单列表 故业务数据响应订单ID
            return Result.build(orderHashOps.get(hashKey), ResultCodeEnum.SECKILL_ORDER_SUCCESS);
        }
        //1.2.判断用户是否产生临时订单-响应215
        String tempOrderKey = RedisConst.SECKILL_ORDERS;
        BoundHashOperations<String, String, OrderRecode> tempOrderHashOps = redisTemplate.boundHashOps(tempOrderKey);
        if (tempOrderHashOps.hasKey(hashKey)) {
            //说明用户获取到秒杀资格,在前端页面:抢单成功 故响应临时订单信息
            return Result.build(tempOrderHashOps.get(hashKey), ResultCodeEnum.SECKILL_SUCCESS);
        }
        //1.3.没有满足以上两个条件,排队中。响应211
        return Result.build(null, ResultCodeEnum.SECKILL_RUN);
    }
    //2.如果用户未在排队且本地缓存中商品状态为"0"-响应213
    String status = seckillCache.getIfPresent(skuId.toString());
    if (!ifQuque && "0".equals(status)) {
        return Result.build(null, ResultCodeEnum.SECKILL_FINISH);
    }
    return Result.build(null, ResultCodeEnum.SECKILL_RUN);
}

5.4 轮询排队页面

该页面有四种状态:

1,排队中

2,各种提示(非法、已售罄等)

3,抢购成功,去下单

4,抢购成功,已下单,显示我的订单

抢购成功,页面显示去下单,跳转下单确认页面

<div class="seckill_dev" v-if="show == 3">
    抢购成功&nbsp;&nbsp;
    <a href="/seckill/trade.html" target="_blank">去下单</a>
</div>

5.5 下单页面

img

我们已经把下单信息记录到redis缓存中,所以接下来我们要组装下单页数据

YAPI接口地址:http://192.168.200.128:3000/project/11/interface/api/523

5.5.0 前端页面代码修改

注意:这个需要每位同学都要修改

<div class="seckill_dev" v-if="show == 3">
    抢购成功&nbsp;&nbsp;
    <a href="#" @click="toTradePage()" target="_blank">去下单</a>
</div>
methods: {
    //增加函数
    toTradePage() {
        window.location.href = "/seckill/trade.html?skuId=" + this.skuId;
    },
}

5.5.1 下单页数据数据接口

SeckilBizController

/**
 * 汇总秒杀订单确认页面数据汇总
 * @param userId 用户ID
 * @param skuId 秒杀商品ID
 * @return
 */
@GetMapping("/trade/{skuId}")
public Result getSeckillOrderTradeData(@RequestHeader("userId") String userId, @PathVariable("skuId") Long skuId) {
    Map<String, Object> mapResult = seckillBizService.getSeckillOrderTradeData(userId, skuId);
    return Result.ok(mapResult);
}

SeckillBizService

/**
 * 汇总秒杀订单确认页面数据汇总
 * @param userId 用户ID
 * @param skuId 秒杀商品ID
 * @return
 */
Map<String, Object> getSeckillOrderTradeData(String userId, Long skuId);

SeckillBizServiceImpl

/**
 * 汇总秒杀订单确认页面数据汇总
 *
 * @param userId 用户ID
 * @param skuId  秒杀商品ID
 * @return ${userAddressList}:地址列表
 * ${detailArrayList}:订单明细列表
 * ${totalNum}:总数
 * ${totalAmount}:总金额
 */
@Override
public Map<String, Object> getSeckillOrderTradeData(String userId, Long skuId) {
    Map<String, Object> mapResult = new HashMap<>();
    //1.远程调用用户服务获取收件地址-todo 异步任务
    List<UserAddress> userAddressList = userFeignClient.getUserAddressListByUserId(Long.valueOf(userId));
    if (!CollectionUtils.isEmpty(userAddressList)) {
        mapResult.put("userAddressList", userAddressList);
    }

    //2.查询秒杀过程中生成临时订单hash-获取秒杀商品信息-得到订单明细
    String tempOrderKey = RedisConst.SECKILL_ORDERS;
    BoundHashOperations<String, String, OrderRecode> tempOrderHashOps = redisTemplate.boundHashOps(tempOrderKey);
    String hashKey = userId +":"+ skuId;
    if (tempOrderHashOps.hasKey(hashKey)) {
        //获取到秒杀商品
        OrderRecode orderRecode = tempOrderHashOps.get(hashKey);
        SeckillGoods seckillGoods = orderRecode.getSeckillGoods();
        //将秒杀商品对象转为订单明细对象
        OrderDetail orderDetail = new OrderDetail();
        orderDetail.setSkuId(seckillGoods.getSkuId());
        orderDetail.setImgUrl(seckillGoods.getSkuDefaultImg());
        orderDetail.setOrderPrice(seckillGoods.getCostPrice());
        orderDetail.setSkuName(seckillGoods.getSkuName());
        orderDetail.setSkuNum(orderRecode.getNum());
        List<OrderDetail> orderDetailList = Arrays.asList(orderDetail);
        mapResult.put("detailArrayList", orderDetailList);
        mapResult.put("totalNum", orderRecode.getNum());
        mapResult.put("totalAmount", seckillGoods.getCostPrice());
    }
    return mapResult;
}

5.5.2 service-activity-client添加接口

ActivityFeignClient

package com.atguigu.gmall.activity.client;

import com.atguigu.gmall.activity.client.impl.ActivityDegradeFeignClient;
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 org.springframework.web.bind.annotation.RequestHeader;

import java.util.Map;

@FeignClient(value = "service-activity", path = "/api/activity", fallback = ActivityDegradeFeignClient.class)
public interface ActivityFeignClient {

    /**
     * 注意映射路径
     * 汇总秒杀订单确认页面数据汇总
     * @param userId 用户ID
     * @param skuId 秒杀商品ID
     * @return
     */
    @GetMapping("/seckill/auth/trade/{skuId}")
    public Result<Map> getSeckillOrderTradeData(@RequestHeader("userId") String userId, @PathVariable("skuId") Long skuId);

}

ActivityDegradeFeignClient服务降级方法

@Override
public Result<Map> getSeckillOrderTradeData(String userId, Long skuId) {
    return null;
}

5.5.3 web-all 编写去下单控制器

SeckillHtmlController

/**
 * 渲染秒杀订单确认页面
 *
 * @param model
 * @param skuId 秒杀商品ID
 * @return
 */
@GetMapping("/seckill/trade.html")
public String seckillTradeHtml(HttpServletRequest request, Model model, @RequestParam("skuId") Long skuId) {
    //获取从网关路由转发来用户ID
    String userId = AuthContextHolder.getUserId(request);
    //远程调用秒杀服务获取汇总数据
    Result<Map> result = activityFeignClient.getSeckillOrderTradeData(userId, skuId);
    model.addAllAttributes(result.getData());
    return "/seckill/trade";
}

5.5.4 提交订单

该页面与正常下单页面类似,只是下单提交接口不一样,因为秒杀下单不需要正常下单的各种判断,因此我们要在订单服务提供一个秒杀下单接口,直接下单

5.5.4.1 service-order模块提供秒杀下单接口

YAPI接口地址:http://192.168.200.128:3000/project/11/interface/api/795

OrderController

/**
 * 提供给秒杀服务调用保存秒杀订单
 *
 * @param orderInfo
 * @return
 */
@PostMapping("/inner/seckill/submitOrder")
public Long submitSeckillOrder(@RequestBody OrderInfo orderInfo) {
    return orderInfoService.submitSeckillOrder(orderInfo);
}

OrderInfoService

/**
 * 保存秒杀订单
 * @param orderInfo
 * @return
 */
Long submitSeckillOrder(OrderInfo orderInfo);

OrderInfoServiceImpl

/**
 * 保存秒杀订单
 *
 * @param orderInfo
 * @return
 */
@Override
public Long submitSeckillOrder(OrderInfo orderInfo) {
    //调用保存订单方法
    return this.saveOrder(orderInfo, "2");
}


/**
 * 保存订单以及订单明细
 *
 * @param orderInfo
 * @param orderType "1":普通商城订单 "2":秒杀订单
 * @return
 */
@Override
public Long saveOrder(OrderInfo orderInfo, String orderType) {
    //1.封装订单对象中若干个属性
    //1.1 计算订单总金额
    orderInfo.sumTotalAmount();
    //1.2 订单状态 消费者  工作人员
    orderInfo.setOrderStatus(OrderStatus.UNPAID.name());
    orderInfo.setProcessStatus(ProcessStatus.UNPAID.name());
    //1.3 生成订单编号 后续支付发货重要字段 规则:SPH年月日雪花算法
    Date now = new Date();
    if ("1".equals(orderType)) {
        //普通商城订单编号
        String today = DateUtil.formatDate(now).replaceAll("-", "");
        long id = snowFlake.nextId();
        String outTradeNo = "SPH" + today + id;
        orderInfo.setOutTradeNo(outTradeNo);
    } else {
        //秒杀订单 订单编号,在秒杀服务中调用前赋值
    }
    //1.4 订单商品名称信息 商品图片
    String tradeBody = "";
    List<OrderDetail> orderDetailList = orderInfo.getOrderDetailList();
    if (!CollectionUtils.isEmpty(orderDetailList)) {
        String skuNameStr = orderDetailList.stream().map(OrderDetail::getSkuName).collect(Collectors.joining(","));
        if (skuNameStr.length() > 100) {
            tradeBody = skuNameStr.substring(0, 100); //对接支付宝支付,如果订单中名称太长会导致调用支付接口失败
        } else {
            tradeBody = skuNameStr;
        }
        orderInfo.setTradeBody(tradeBody);
        orderInfo.setImgUrl(orderDetailList.get(0).getImgUrl());
    }
    //1.5 过期时间 普通商城订单 1小时不支付,自动关闭订单  秒杀订单:5分钟
    Calendar calendar = Calendar.getInstance();
    if ("1".equals(orderType)) {
        calendar.add(Calendar.HOUR, 1);
    }
    if("2".equals(orderType)){
        calendar.add(Calendar.MINUTE, 5);
    }
    orderInfo.setExpireTime(calendar.getTime());
    //1.4 其他属性封装
    orderInfo.setPaymentWay(PaymentWay.ONLINE.name());
    orderInfo.setOperateTime(now);
    orderInfo.setProvinceId(1L);
    //2.执行订单保存
    this.save(orderInfo);
    Long orderId = orderInfo.getId();

    //3.封装订单明细集合
    if (!CollectionUtils.isEmpty(orderDetailList)) {
        orderDetailList.stream().forEach(orderDetail -> {
            orderDetail.setOrderId(orderId);
            if("1".equals(orderType)){
                orderDetail.setSourceType("MALL");
                orderDetail.setSourceId(1L);
            }
            if("2".equals(orderType)){
                orderDetail.setSourceType("SECKILL");
                orderDetail.setSourceId(2L);
            }

        });
        //4.批量保存订单明细集合
        orderDetailService.saveBatch(orderDetailList);
    }
    return orderId;
}

5.5.4.2 service-order-client模块暴露接口

OrderFeignClient远程调用API接口方法

package com.atguigu.gmall.order.client;

import com.atguigu.gmall.common.result.Result;
import com.atguigu.gmall.order.client.impl.OrderDegradeFeignClient;
import com.atguigu.gmall.order.model.OrderInfo;
import io.swagger.annotations.ApiOperation;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

import javax.servlet.http.HttpServletRequest;
import java.util.Map;

@FeignClient(value = "service-order", path = "/api/order", fallback = OrderDegradeFeignClient.class)
public interface OrderFeignClient {
    /**
     * 提供给秒杀服务调用保存秒杀订单
     *
     * @param orderInfo
     * @return
     */
    @PostMapping("/inner/seckill/submitOrder")
    public Long submitSeckillOrder(@RequestBody OrderInfo orderInfo);
}

服务降级方法

@Override
public Long submitSeckillOrder(OrderInfo orderInfo) {
    return null;
}

5.5.4.3 service-activity模块秒杀下单

YAPI接口地址:http://192.168.200.128:3000/project/11/interface/api/491

SeckilBizController

/**
 * 提交用户秒杀订单
 *
 * @param orderInfo
 * @return
 */
@PostMapping("/submitOrder")
public Result submitSeckillOrder(HttpServletRequest request, @RequestBody OrderInfo orderInfo) {
    //1.获取当前登录用户ID
    String userId = AuthContextHolder.getUserId(request);
    if (StringUtils.isBlank(userId)) {
        throw new RuntimeException("请先登录");
    }
    orderInfo.setUserId(Long.valueOf(userId));
    //2.调用业务逻辑保存秒杀订单
    Long orderId = seckillBizService.submitSeckillOrder(orderInfo);
    return Result.ok(orderId);
}

SeckillBizService

/**
 * 保存秒杀订单
 * @param orderInfo
 * @return
 */
Long submitSeckillOrder(OrderInfo orderInfo);

SeckillGoodsServiceImpl

@Autowired
private OrderFeignClient orderFeignClient;


/**
 * 保存秒杀订单
 *
 * @param orderInfo
 * @return
 */
@Override
public Long submitSeckillOrder(OrderInfo orderInfo) {
    //1.远程调用订单微服务保存秒杀订单 注意:查询临时订单,设置秒杀订单编号
    //1.1 查询临时订单获取临时订单信息

    String tempOrderKey = RedisConst.SECKILL_ORDERS;
    BoundHashOperations<String, String, OrderRecode> tempOrderHashOps = redisTemplate.boundHashOps(tempOrderKey);

    List<OrderDetail> orderDetailList = orderInfo.getOrderDetailList();
    if (!CollectionUtils.isEmpty(orderDetailList)) {
        Long skuId = orderDetailList.get(0).getSkuId();
        String hashKey = orderInfo.getUserId() + ":" + skuId;
        OrderRecode orderRecode = tempOrderHashOps.get(hashKey);
        orderInfo.setOutTradeNo(orderRecode.getOrderStr());
        Long orderId = orderFeignClient.submitSeckillOrder(orderInfo);
        //2.删除临时订单hash
        tempOrderHashOps.delete(hashKey);

        //3.新增用户下单记录 hash
        String orderHashKey = RedisConst.SECKILL_ORDERS_USERS;
        BoundHashOperations<String, String, Long> orderHashOps = redisTemplate.boundHashOps(orderHashKey);
        orderHashOps.put(hashKey, orderId);
        return orderId;
    }
    return null;
}

说明:下单成功后,后续流程与正常订单一致

5.6 秒杀结束清空redis缓存

秒杀过程中我们写入了大量redis缓存,我们可以在秒杀结束或每天固定时间清除缓存,释放缓存空间;

实现思路:假如根据业务,我们确定每天18点所有秒杀业务结束,那么我们编写定时任务,每天18点发送mq消息,service-activity模块监听消息清理缓存

service-task发送消息

5.6.1 添加常量MqConst类

/**
 * 定时任务
 */
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增加定时任务

TaskJob

/**
 * 每日晚上18点执行任务,发送消息通知秒杀服务清空缓存
 */
@XxlJob("seckillClean")
public void seckillClean() {
    log.info("[定时任务]发送秒杀秒杀情况消息");
    rabbitService.sendMsg(MqConst.EXCHANGE_DIRECT_TASK, MqConst.ROUTING_TASK_18, "下班吧");
}

5.6.3 接收消息并处理

Service-activity接收消息

ActivityReceiver

/**
 * 监听每天晚上发送秒杀情况缓存消息,将当日秒杀过程中产生缓存全部删除
 *
 * @param msg
 * @param message
 * @param channel
 */
@SneakyThrows
@RabbitListener(bindings = @QueueBinding(
        exchange = @Exchange(value = MqConst.EXCHANGE_DIRECT_TASK, durable = "true"),
        value = @Queue(value = MqConst.QUEUE_TASK_18, durable = "true"),
        key = MqConst.ROUTING_TASK_18
))
public void processSeckillClean(String msg, Message message, Channel channel) {
    if(StringUtils.isNotBlank(msg)){
        log.info("[秒杀服务]监听到清理秒杀缓存消息");
        seckillBizService.processSeckillClean();
        channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
    }
}

SeckillBizService

/**
 * 定时清理秒杀缓存
 * @param channel
 * @param message
 */
@SneakyThrows
@RabbitListener(bindings = @QueueBinding(
        exchange = @Exchange(value = MqConst.EXCHANGE_DIRECT_TASK, durable = "true"),
        value = @Queue(value = MqConst.QUEUE_TASK_18, durable = "true"),
        key = MqConst.ROUTING_TASK_18
))
public void processCleanCache(Channel channel, Message message) {
    seckillGoodsService.processCleanCache();
    channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
}
/**
 * 清理秒杀缓存
 */
@Override
public void processSeckillClean() {
    //1.将分布式缓存中Redis中所有秒杀信息全部删除
    Set<String> keys = redisTemplate.keys("seckill:" + "*");
    redisTemplate.delete(keys);

    //2.将本地缓存中商品状态位全部清理(将所有的秒杀微服务实例(JVM)) TODO 发送发布订阅消息广播所有秒杀节点清理本地缓存(参考以前预热)
    seckillCache.invalidateAll();

    //3.修改秒杀商品表中商品状态
    LambdaUpdateWrapper<SeckillGoods> updateWrapper = new LambdaUpdateWrapper<>();
    updateWrapper.set(SeckillGoods::getStatus, "0");
    updateWrapper.lt(SeckillGoods::getEndTime, new Date());
    seckillGoodsService.update(updateWrapper);
}

说明:清空redis缓存,同时更改秒杀商品活动结束

秒杀下单,提交成功,页面通过轮询后台方法查询秒杀状态

XXL-job分片任务

/**
 * 执行器集群部署时,任务路由策略选择”分片广播”情况下,一次任务调度将会广播触发对应集群中所有执行器执行一次任务,同时系统自动传递分片参数;可根据分片参数开发分片任务;
 * “分片广播” 以执行器为维度进行分片,支持动态扩容执行器集群从而动态增加分片数量,协同进行业务处理;在进行大数据量业务操作时可显著提升任务处理能力和速度。
 * “分片广播” 和普通任务开发流程一致,不同之处在于可以获取分片参数,获取分片参数进行分片业务处理。
 * 分片任务:需要给数据库中30W会员发送营销短信
 */
@XxlJob("shardJob")
public void shardJob() {
// 可参考Sample示例执行器中的示例任务"ShardingJobHandler"了解试用
    int shardTotal = XxlJobHelper.getShardTotal();
    log.info("总执行器数量/分片数量:{}", shardTotal);
    int shardIndex = XxlJobHelper.getShardIndex();
    log.info("当前执行器索引:{}", shardIndex);
}

业务层执行SQL-加载当前节点需要处理任务

# 模拟 两个执行器 每个执行器都应该负责一部分短信发送业务

# 第一个节点 需要执行业务
SELECT * FROM user_info where MOD(id, 2) = 0 where `status`='未发送' limit 5 ;

# 第一个节点 需要执行业务
SELECT * FROM user_info where MOD(id, 2) = 1 where `status`='未发送' limit 5;