Java 秒杀方案

技术点

前端 : Thymeleaf | Bootstrap | Jquery
后端 : SpringBoot | MyBatisPlus | Lombok
中间件 : Redis | RabbitMQ

秒杀方案简介

image.png
本短文完成项目搭建, 分布式 Session 和秒杀功能 三个小模块;

秒杀系统设计

秒杀其实主要解决两个问题,一个是并发读,一个是并发写
并发读的核心优化理念是尽量减少用户到服务端来“读”数据,或者让他们读更少的数据;并发写的处理原则也一样,它要求我们在数据库层面独立出来一个库,做特殊的处理。另外,我们还要针对秒杀系统做一些保护,针对意料之外的情况设计兜底方案,以防止最坏的情况发生。
秒杀的整体架构可以概括为“稳、准、快”几个关键字。
所谓
“稳”
,就是整个系统架构要满足高可用,流量符合预期时肯定要稳定,就是超出预期时也同样不能掉链子,你要保证秒杀活动顺利完成,即秒杀商品顺利地卖出去,这个是最基本的前提。
然后就是**“准”,就是秒杀 10 台 手机,那就只能成交 10 台,多一台少一台都不行。一旦库存不对,那平台就要承担损失,所以“准”就是要求保证数据的一致性。
最后再看
“快”**,“快”其实很好理解,它就是说系统的性能要足够高,否则你怎么支撑这么大的流量呢?不光是服务端要做极致的性能优化,而且在整个请求链路上都要做协同的优化,每个地方快一点,整个系统就完美了。
所以从技术角度上看“稳、准、快”,就对应了我们架构上的高可用、一致性和高性能的要求

  • 高性能。 秒杀涉及大量的并发读和并发写,因此支持高并发访问这点非常关键。对应的方案比如动静分离方案、热点的发现与隔离、请求的削峰与分层过滤、服务端的极致优化
  • 一致性。 秒杀中商品减库存的实现方式同样关键。可想而知,有限数量的商品在同一时刻被很多倍的请求同时来减库存,减库存又分为“拍下减库存”“付款减库存”以及预扣等几种,在大并发更新的过程中都要保证数据的准确性,其难度可想而知
  • 高可用。 现实中总难免出现一些我们考虑不到的情况,所以要保证系统的高可用和正确性,我们还要设计一个 B 计划 来兜底,以便在最坏情况发生时仍然能够从容应对

环境搭建

创建项目

File -> New Project -> Maven -> 选择JDK版本 (8和11都可以) -> 设置项目名称和分组信息
具体操作就不截图了;
新建目录结构, 修改包名 com.itkaka.seckill ,新建三层架构的包名 和启动类, 删除test下的 App.java
完整目录如下 ;
1683772370464.png
添加依赖 (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">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.itkaka</groupId>
  <artifactId>seckill</artifactId>
  <version>1.0-SNAPSHOT</version>

  <name>seckill</name>


  <!--SpringBoot依赖-->
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.3.4.RELEASE</version>
    <relativePath/> <!-- lookup parent from repository -->
  </parent>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>11</maven.compiler.source>
    <maven.compiler.target>11</maven.compiler.target>
  </properties>

  <dependencies>
    <!--thymeleaf 组件-->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <!--web 组件-->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!--mybatisplus依赖-->
    <dependency>
      <groupId>com.baomidou</groupId>
      <artifactId>mybatis-plus-boot-starter</artifactId>
      <version>3.4.0</version>
    </dependency>
    <!--mysql-->
    <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
      <scope>runtime</scope>
    </dependency>
    <!--lombok-->
    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <optional>true</optional>
    </dependency>
    <!--test组件-->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
      <exclusions>
        <exclusion>
          <groupId>org.junit.vintage</groupId>
          <artifactId>junit-vintage-engine</artifactId>
        </exclusion>
      </exclusions>
    </dependency>
    <!-- md5 依赖 -->
    <dependency>
      <groupId>commons-codec</groupId>
      <artifactId>commons-codec</artifactId>
    </dependency>
    <!-- validation组件 -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <!--spring data redis 依赖-->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <!--commons-pool2 对象池依赖-->
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-pool2</artifactId>
    </dependency>
    <!-- AMQP依赖 -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>
    <!-- 验证码依赖 -->
    <dependency>
      <groupId>com.github.whvcse</groupId>
      <artifactId>easy-captcha</artifactId>
      <version>1.6.2</version>
    </dependency>
  </dependencies>

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

修改配置文件

application.yml

spring:
  #静态资源处理
  resources:
    #启动默认静态资源处理,默认启用
    add-mappings: true
    cache:
      cachecontrol:
        #缓存相应时间,单位秒
        max-age: 3600
    chain:
      #资源链启动缓存,默认启动
      cache: true
      #启用资源链,默认禁用
      enabled: true
      #启用压缩资源(gzip,brotli)解析,默认禁用
      compressed: true
      #启用h5应用缓存,默认禁用
      html-application-cache: true
    static-locations: classpath:/static/
  # thymelaef配置
  thymeleaf:
    # 关闭缓存
    cache: false
  # 数据源配置
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/seckill?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
    username: root
    password: root
    hikari:
      #连接池名
      pool-name: DateHikariCP
      # 最小空闲连接出
      minimum-idle: 5
      # 空闲连接存活最大时间,默认600000(10分钟)
      idle-timeout: 600000
      #最大连接数,默认10
      maximum-pool-size: 10
      # 从连接池返回的连接自动提交
      auto-commit: true
      # 连接最大存活时间,0表示永久存活,默认1800000(30分钟)
      max-lifetime: 1800000
      # 连接超时时间,默认30000(30秒)
      connection-timeout: 30000
      # 测试连接是否可用的查询语句
      connection-test-query: SELECT 1

  # redis配置
  redis:
    #服务器地址
    host: 192.168.10.100
    #端口
    port: 6379
    #数据库
    database: 0
    #超时时间
    timeout: 10000ms
    #密码
    password: root
    lettuce:
      pool:
        #最大连接数,默认8
        max-active: 8
        #最大连接阻塞等待时间,默认-1
        max-wait: 10000ms
        #最大空闲连接,默认8
        max-idle: 200
        #最小空闲连接,默认0
        min-idle: 5
  # RabbitMQ
  rabbitmq:
    # 服务器
    host: 192.168.10.101
    #用户名
    username: guest
    #密码
    password: guest
    # 虚拟主机
    virtual-host: /
    #端口
    port: 5672
    listener:
      simple:
        #消费者最小数量
        concurrency: 10
        #消费者最大数量
        max-concurrency: 10
        #限制消费者每次只处理一条消息,处理完再继续下一条消息
        prefetch: 1
        #启动时是否默认启动容器,默认true
        auto-startup: true
        #被拒绝时重新进入队列
        default-requeue-rejected: true
    template:
      retry:
        #发布重试,默认false
        enabled: true
        #重试时间,默认1000ms
        initial-interval: 1000ms
        #重试最大次数,默认3次
        max-attempts: 3
        #重试最大间隔时间,默认10000ms
        max-interval: 10000ms
        #重试的间隔乘数。比如配2.0,第一次就等10s,第二次就等20s,第三次就等40s
        multiplier: 1


#Mybatis-plus配置
mybatis-plus:
  # 配置Mapper.xml映射文件
  mapper-locations: classpath*:/mapper/*Mapper.xml
  # 配置MyBatis数据返回类型别名(默认别名是类名)
  type-aliases-package: com.itkaka.seckill.pojo


# MyBatis SQL打印(方法接口所在的包,不是Mapper.xml所在的包)
logging:
  level:
    com.itkaka.seckill.mapper: debug

    ## 端口号  上下文路径
server:
  port: 8070
  servlet:
    context-path: /seckill

测试

DemoController.java

package com.itkaka.seckill.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/demo")
public class DemoController {

    // 测试页面跳转
    @RequestMapping("/hello")
    public String hello(Model model){
        model.addAttribute("name","itkaka!");
        return "hello";
    }

}

测试页面: hello.html

<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>测试</title>
</head>
<body>
<p th:text="'hello'+${name}"></p>
</body>
</html>

启动类 App.java 运行main方法启动测试

package com.itkaka;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * Hello world!
 *
 */
@SpringBootApplication
@MapperScan("com.itkaka.seckill.mapper")
public class App 
{
    public static void main( String[] args )
    {
        SpringApplication.run(App.class,args);
        System.out.println( "Hello World!" );
    }
}

试结果如图
1683771729022.png

添加公共结果返回对象

RespBean.java

package com.itkaka.seckill.vo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/*
*  通用返回结果对象
* */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RespBean {

    private long code;
    private String message;
    private Object obj;

    // 成功返回结果
    public static RespBean success(){
        return new RespBean(RespBeanEnum.SUCCESS.getCode(),RespBeanEnum.SUCCESS.getMessage(),null);
    }

    // 成功返回结果
    public static RespBean success(Object obj){
        return new RespBean(RespBeanEnum.SUCCESS.getCode(),RespBean.success().getMessage(),obj);
    }

    // 失败返回结果
    public static RespBean error(RespBeanEnum respBeanEnum){
        return new RespBean(respBeanEnum.getCode(),respBeanEnum.getMessage(),null);
    }

    // 失败返回结果
    public static RespBean error(RespBeanEnum respBeanEnum,Object obj){
        return new RespBean(respBeanEnum.getCode(),respBeanEnum.getMessage(),obj);
    }


}

RespBeanEnum 枚举类

package com.itkaka.seckill.vo;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.ToString;
/*
*   公共返回对象枚举类
* */
@Getter
@ToString
@AllArgsConstructor
public enum RespBeanEnum {

    //通用
    SUCCESS(200, "SUCCESS"),
    ERROR(500, "服务端异常"),
    //登录模块5002xx
    LOGIN_ERROR(500210, "用户名或密码不正确"),
    MOBILE_ERROR(500211, "手机号码格式不正确"),
    BIND_ERROR(500212, "参数校验异常"),
    MOBILE_NOT_EXIST(500213, "手机号码不存在"),
    PASSWORD_UPDATE_FAIL(500214, "密码更新失败"),
    SESSION_ERROR(500215, "用户不存在"),
    //秒杀模块5005xx
    EMPTY_STOCK(500500, "库存不足"),
    REPEATE_ERROR(500501, "该商品每人限购一件"),
    REQUEST_ILLEGAL(500502, "请求非法,请重新尝试"),
    ERROR_CAPTCHA(500503, "验证码错误,请重新输入"),
    ACCESS_LIMIT_REAHCED(500504, "访问过于频繁,请稍后再试"),
    //订单模块5003xx
    ORDER_NOT_EXIST(500300, "订单信息不存在"),
    ;

    private final Integer code;
    private final String message;

}

分布式会话

实现登录功能

两次MD5加密

客户端 : 密码 = MD5(明文 + 固定Salt)
服务端 : 密码 = MD5(用户输入 + 随机Salt)
:::info
用户端MD5加密是为了防止用户密码在网络中明文传输,服务端MD5加密是为了提高密码安全性,双重保险。
Salt 加盐的意思;
:::
pom.xml 依赖进行添加

    <!-- md5 依赖 -->
    <dependency>
      <groupId>commons-codec</groupId>
      <artifactId>commons-codec</artifactId>
    </dependency>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-lang3</artifactId>
    </dependency>

MD5工具类

package com.itkaka.seckill.utils;

import org.apache.commons.codec.digest.DigestUtils;

/*
*   MD5 工具类
* */
public class MD5Util {

    public static String md5(String src){
        return DigestUtils.md5Hex(src);
    }

    private static final String salt="1a2b3c4d";


    public static String inputPassToFromPass(String inputPass){
        String str = "" +salt.charAt(0)+salt.charAt(2)+inputPass+salt.charAt(5)+salt.charAt(4);
        return md5(str);
    }

    public static String formPassToDBPass(String formPass,String salt){
        String str = "" +salt.charAt(0)+salt.charAt(2)+formPass+salt.charAt(5)+salt.charAt(4);
        return md5(str);
    }

    public static String inputPassToDBPass(String inputPass,String salt){
        String fromPass = inputPassToFromPass(inputPass);
        String dbPass = formPassToDBPass(fromPass, salt);
        return dbPass;
    }

    // 测试一下效果而已
    public static void main(String[] args) {
        // d3b1294a61a07da9b49b6e22b2cbd7f9
        System.out.println(inputPassToFromPass("123456"));
        
        System.out.println(formPassToDBPass("d3b1294a61a07da9b49b6e22b2cbd7f9","1a2b3c4d"));
        // b7797cce01b4b131b433b6acf4add449
        
        System.out.println(inputPassToDBPass("123456","1a2b3c4d"));
        // b7797cce01b4b131b433b6acf4add449
    }



}

登录功能实现

逆向工程
首先需要通过逆向工程基于 t_user 表生产对应的POJO、Mapper、Service、ServiceImpl、Controller等类,项目中使用了MybatisPlus,所以逆向工程也是用了MybatisPlus提供的AutoGenerator,代码如下。具体可去官网查看 MyBatis-Plus官网地址,点击可跳转 https://www.baomidou.com/
在 pom.xml 导入MP依赖 :

    <!--mybatis-plus 代码生成器依赖-->
    <dependency>
      <groupId>com.baomidou</groupId>
      <artifactId>mybatis-plus-generator</artifactId>
      <version>3.4.1</version>
    </dependency>

    <!-- validation组件 -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

CodeGenerator.java (新建包generator,包下新建该类)

package com.itkaka.seckill.generator;

import com.baomidou.mybatisplus.core.exceptions.MybatisPlusException;
import com.baomidou.mybatisplus.core.toolkit.StringPool;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.generator.AutoGenerator;
import com.baomidou.mybatisplus.generator.InjectionConfig;
import com.baomidou.mybatisplus.generator.config.*;
import com.baomidou.mybatisplus.generator.config.po.TableInfo;
import com.baomidou.mybatisplus.generator.config.rules.DateType;
import com.baomidou.mybatisplus.generator.config.rules.NamingStrategy;
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;

import java.util.*;


/**
 * 执行 main 方法控制台输入模块表名回车自动生成对应项目目录中
 */
public class CodeGenerator {
    /**
     * <p>
     * 读取控制台内容
     * </p>
     */
    public static String scanner(String tip) {
        Scanner scanner = new Scanner(System.in);
        StringBuilder help = new StringBuilder();
        help.append("请输入" + tip + ":");
        System.out.println(help.toString());
        if (scanner.hasNext()) {
            String ipt = scanner.next();
            if (StringUtils.isNotBlank(ipt)) {
                return ipt;
            }
        }
        throw new MybatisPlusException("请输入正确的" + tip + "!");
    }

    public static void main(String[] args) {
        // 代码生成器
        AutoGenerator mpg = new AutoGenerator();

        // 全局配置
        GlobalConfig gc = new GlobalConfig();
        String projectPath = System.getProperty("user.dir");
        gc.setOutputDir(projectPath + "/src/main/java");
        //作者
        gc.setAuthor("itkaka");
        //打开输出目录
        gc.setOpen(false);
        //xml开启 BaseResultMap
        gc.setBaseResultMap(true);
        //xml 开启BaseColumnList
        gc.setBaseColumnList(true);
        //日期格式,采用Date
        gc.setDateType(DateType.ONLY_DATE);
        // 实体属性 Swagger2 注解
        gc.setSwagger2(true);
        mpg.setGlobalConfig(gc);

        // 数据源配置
        DataSourceConfig dsc = new DataSourceConfig();
        dsc.setUrl("jdbc:mysql://localhost:3306/seckill?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia" +
                "/Shanghai");
        dsc.setDriverName("com.mysql.cj.jdbc.Driver");
        dsc.setUsername("root");
        dsc.setPassword("root");
        mpg.setDataSource(dsc);

        // 包配置
        PackageConfig pc = new PackageConfig();
        pc.setParent("com.itkaka.seckill")
                .setEntity("pojo")
                .setMapper("mapper")
                .setService("service")
                .setServiceImpl("service.impl")
                .setController("controller");
        mpg.setPackageInfo(pc);

        // 自定义配置
        InjectionConfig cfg = new InjectionConfig() {
            @Override
            public void initMap() {
                // to do nothing
                Map<String, Object> map = new HashMap<>();
                map.put("date1", "1.0.0");
                this.setMap(map);
            }
        };

        // 如果模板引擎是 freemarker
        String templatePath = "/templates/mapper.xml.ftl";
        // 如果模板引擎是 velocity
        // String templatePath = "/templates/mapper.xml.vm";

        // 自定义输出配置
        List<FileOutConfig> focList = new ArrayList<>();
        // 自定义配置会被优先输出
        focList.add(new FileOutConfig(templatePath) {
            @Override
            public String outputFile(TableInfo tableInfo) {
                // 自定义输出文件名 , 如果你 Entity 设置了前后缀、此处注意 xml 的名称会跟着发生变化!!
                return projectPath + "/src/main/resources/mapper/" + tableInfo.getEntityName() + "Mapper"
                        + StringPool.DOT_XML;
            }
        });
        cfg.setFileOutConfigList(focList);
        mpg.setCfg(cfg);

        // 配置模板
        TemplateConfig templateConfig = new TemplateConfig()
                .setEntity("templates/entity2.java")
                .setMapper("templates/mapper2.java")
                .setService("templates/service2.java")
                .setServiceImpl("templates/serviceImpl2.java")
                .setController("templates/controller2.java");

        templateConfig.setXml(null);
        mpg.setTemplate(templateConfig);
        // 策略配置
        StrategyConfig strategy = new StrategyConfig();
        //数据库表映射到实体的命名策略
        strategy.setNaming(NamingStrategy.underline_to_camel);
        //数据库表字段映射到实体的命名策略
        strategy.setColumnNaming(NamingStrategy.underline_to_camel);
        //lombok模型
        strategy.setEntityLombokModel(true);
        //生成 @RestController 控制器
        strategy.setRestControllerStyle(true);
        strategy.setInclude(scanner("表名,多个英文逗号分割").split(","));
        strategy.setControllerMappingHyphenStyle(true);
        //表前缀
        strategy.setTablePrefix("t_");
        mpg.setStrategy(strategy);
        mpg.setTemplateEngine(new FreemarkerTemplateEngine());
        mpg.execute();
    }
}

:::info
代码生成器遇到几个错误,
第一个 : 没有模板引擎配置
第二个 : 没有swagger包
对策: 更新pom.xml依赖, 修改完善yml文件配置,具体见下面文件
:::
更新后的pom文件

<?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">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.itkaka</groupId>
  <artifactId>seckill</artifactId>
  <version>1.0-SNAPSHOT</version>

  <name>seckill</name>


  <!--SpringBoot依赖-->
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.3.4.RELEASE</version>
    <relativePath/> <!-- lookup parent from repository -->
  </parent>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>11</maven.compiler.source>
    <maven.compiler.target>11</maven.compiler.target>
  </properties>

  <dependencies>
    <!--thymeleaf 组件-->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <!--web 组件-->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!--mysql-->
    <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
      <scope>runtime</scope>
    </dependency>
    <!--lombok-->
    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <optional>true</optional>
    </dependency>
    <!--test组件-->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
      <exclusions>
        <exclusion>
          <groupId>org.junit.vintage</groupId>
          <artifactId>junit-vintage-engine</artifactId>
        </exclusion>
      </exclusions>
    </dependency>
    <!-- md5 依赖 -->
    <dependency>
      <groupId>commons-codec</groupId>
      <artifactId>commons-codec</artifactId>
    </dependency>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-lang3</artifactId>
    </dependency>

    <!--mybatis-plus 代码生成器依赖-->
    <dependency>
      <groupId>com.baomidou</groupId>
      <artifactId>mybatis-plus-generator</artifactId>
      <version>3.4.1</version>
    </dependency>

    <!-- validation组件 -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-freemarker</artifactId>
    </dependency>

    <!-- swagger 依赖 -->
    <dependency>
      <groupId>com.battcn</groupId>
      <artifactId>swagger-spring-boot-starter</artifactId>
      <version>2.1.5-RELEASE</version>
    </dependency>
    <dependency>
      <groupId>io.springfox</groupId>
      <artifactId>springfox-boot-starter</artifactId>
      <version>3.0.0</version>
    </dependency>

    <!--spring data redis 依赖-->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <!--commons-pool2 对象池依赖-->
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-pool2</artifactId>
    </dependency>
    <!-- AMQP依赖 -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>
    <!-- 验证码依赖 -->
    <dependency>
      <groupId>com.github.whvcse</groupId>
      <artifactId>easy-captcha</artifactId>
      <version>1.6.2</version>
    </dependency>
  </dependencies>

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

application.yml

spring:
  #静态资源处理
  resources:
    #启动默认静态资源处理,默认启用
    add-mappings: true
    cache:
      cachecontrol:
        #缓存相应时间,单位秒
        max-age: 3600
    chain:
      #资源链启动缓存,默认启动
      cache: true
      #启用资源链,默认禁用
      enabled: true
      #启用压缩资源(gzip,brotli)解析,默认禁用
      compressed: true
      #启用h5应用缓存,默认禁用
      html-application-cache: true
    static-locations: classpath:/static/
  # thymelaef配置
  thymeleaf:
    # 关闭缓存
    cache: false
  # 数据源配置
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/seckill?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
    username: root
    password: root
  hikari:
      #连接池名
    pool-name: DateHikariCP
      # 最小空闲连接出
    minimum-idle: 5
      # 空闲连接存活最大时间,默认600000(10分钟)
    idle-timeout: 600000
      #最大连接数,默认10
    maximum-pool-size: 10
      # 从连接池返回的连接自动提交
    auto-commit: true
      # 连接最大存活时间,0表示永久存活,默认1800000(30分钟)
    max-lifetime: 1800000
      # 连接超时时间,默认30000(30秒)
    connection-timeout: 30000
      # 测试连接是否可用的查询语句
    connection-test-query: SELECT 1
  ## freemarker
  freemarker:
    suffix: .ftl
    content-type: text/html
    charset: UTF-8
    template-loader-path: classpath:/views/
    request-context-attribute: request
    settings:
      number_format: 0.##

  # redis配置
  redis:
    #服务器地址
    host: 192.168.10.100
    #端口
    port: 6379
    #数据库
    database: 0
    #超时时间
    timeout: 10000ms
    #密码
    password: root
    lettuce:
      pool:
        #最大连接数,默认8
        max-active: 8
        #最大连接阻塞等待时间,默认-1
        max-wait: 10000ms
        #最大空闲连接,默认8
        max-idle: 200
        #最小空闲连接,默认0
        min-idle: 5
  # RabbitMQ
  rabbitmq:
    # 服务器
    host: 192.168.10.101
    #用户名
    username: guest
    #密码
    password: guest
    # 虚拟主机
    virtual-host: /
    #端口
    port: 5672
    listener:
      simple:
        #消费者最小数量
        concurrency: 10
        #消费者最大数量
        max-concurrency: 10
        #限制消费者每次只处理一条消息,处理完再继续下一条消息
        prefetch: 1
        #启动时是否默认启动容器,默认true
        auto-startup: true
        #被拒绝时重新进入队列
        default-requeue-rejected: true
    template:
      retry:
        #发布重试,默认false
        enabled: true
        #重试时间,默认1000ms
        initial-interval: 1000ms
        #重试最大次数,默认3次
        max-attempts: 3
        #重试最大间隔时间,默认10000ms
        max-interval: 10000ms
        #重试的间隔乘数。比如配2.0,第一次就等10s,第二次就等20s,第三次就等40s
        multiplier: 1


#Mybatis-plus配置
mybatis-plus:
  # 配置Mapper.xml映射文件
  mapper-locations: classpath*:/mapper/*Mapper.xml
  # 配置MyBatis数据返回类型别名(默认别名是类名)
  type-aliases-package: com.itkaka.seckill.pojo
  configuration:
    # 自动驼峰命名
    map-underscore-to-camel-case: true


# MyBatis SQL打印(方法接口所在的包,不是Mapper.xml所在的包)
logging:
  level:
    com.itkaka.seckill.mapper: debug
  pattern:
    console: '%d{2100-01-01 13:14:00.666} [%thread] %-5level %logger{50} - %msg%n'

  ## 端口号  上下文路径
server:
  port: 8070
  servlet:
    context-path: /seckill

由于使用的版本是 IDEA 2020.2.3 版本,存在部分配置问题,同时
File -> setting -> Maven -> Runner -> 勾选Delegate IDE build/run actions to Maven 然后Apply并点OK,
以上,问题暂时解决,但同时有一个乱码问题,需要修改一下配置
ValidatorUtil

package com.itkaka.seckill.utils;

import org.springframework.util.StringUtils;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * 手机号码校验
 */
public class ValidatorUtil {

	private static final Pattern mobile_pattern = Pattern.compile("[1]([3-9])[0-9]{9}$");

	public static boolean isMobile(String mobile){
		if (StringUtils.isEmpty(mobile)){
			return false;
		}
		Matcher matcher = mobile_pattern.matcher(mobile);
		return matcher.matches();
	}

}

LoginController

package com.itkaka.seckill.controller;

import com.itkaka.seckill.service.IUserService;
import com.itkaka.seckill.vo.LoginVo;
import com.itkaka.seckill.vo.RespBean;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.validation.Valid;

/*
*   登录
* */

@Controller
@RequestMapping("/login")
@Slf4j
public class LoginController {

    @Autowired
    private IUserService userService;

    // 跳转登录页面
    @RequestMapping("/toLogin")
    public String toLogin(){
        return "login";
    }

    // 登录功能
    @RequestMapping("/doLogin")
    @ResponseBody
    public RespBean doLogin(LoginVo loginVo){
        log.info(loginVo.toString());
        return userService.login(loginVo);
    }

}

业务层接口和实现类

package com.itkaka.seckill.service;

import com.itkaka.seckill.pojo.User;
import com.baomidou.mybatisplus.extension.service.IService;
import com.itkaka.seckill.vo.LoginVo;
import com.itkaka.seckill.vo.RespBean;

/**
 * <p>
 *  服务类
 * </p>
 *
 * @author itkaka
 * @since 2023-05-11
 */
public interface IUserService extends IService<User> {

    RespBean login(LoginVo loginVo); // 登录
}

package com.itkaka.seckill.service.impl;

import com.itkaka.seckill.pojo.User;
import com.itkaka.seckill.mapper.UserMapper;
import com.itkaka.seckill.service.IUserService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.itkaka.seckill.utils.MD5Util;
import com.itkaka.seckill.utils.ValidatorUtil;
import com.itkaka.seckill.vo.LoginVo;
import com.itkaka.seckill.vo.RespBean;
import com.itkaka.seckill.vo.RespBeanEnum;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author itkaka
 * @since 2023-05-11
 */
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Resource
    private UserMapper userMapper;

    // 登录
    @Override
    public RespBean login(LoginVo loginVo) {

        String phone = loginVo.getMobile();
        String pwd = loginVo.getPassword();

        // 参数校验
        if (StringUtils.isEmpty(phone) || StringUtils.isEmpty(pwd)){
            return RespBean.error(RespBeanEnum.LOGIN_ERROR);
        }
        if (!ValidatorUtil.isMobile(phone)){
            return RespBean.error(RespBeanEnum.MOBILE_ERROR);
        }

        // 手机号查询用户
        User temp = userMapper.selectById(phone);
        if ( null == temp){
            return RespBean.error(RespBeanEnum.LOGIN_ERROR);
        }

        //密码加密后比对
        if (MD5Util.formPassToDBPass(pwd,temp.getSlat()).equals(temp.getPassword())){
            return RespBean.error(RespBeanEnum.LOGIN_ERROR);
        }

        return RespBean.success();
    }
}

前台页面 login.html

<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
    <!-- jquery -->
    <script type="text/javascript" th:src="@{/js/jquery.min.js}"></script>
    <!-- bootstrap -->
    <link rel="stylesheet" type="text/css" th:href="@{/bootstrap/css/bootstrap.min.css}"/>
    <script type="text/javascript" th:src="@{/bootstrap/js/bootstrap.min.js}"></script>
    <!-- jquery-validator -->
    <script type="text/javascript" th:src="@{/jquery-validation/jquery.validate.min.js}"></script>
    <script type="text/javascript" th:src="@{/jquery-validation/localization/messages_zh.min.js}"></script>
    <!-- layer -->
    <script type="text/javascript" th:src="@{/layer/layer.js}"></script>
    <!-- md5.js -->
    <script type="text/javascript" th:src="@{/js/md5.min.js}"></script>
    <!-- common.js -->
    <script type="text/javascript" th:src="@{/js/common.js}"></script>
</head>
<body>
<form name="loginForm" id="loginForm" method="post" style="width:50%; margin:0 auto">

    <h2 style="text-align:center; margin-bottom: 20px">用户登录</h2>

    <div class="form-group">
        <div class="row">
            <label class="form-label col-md-4">请输入手机号码</label>
            <div class="col-md-5">
                <input id="mobile" name="mobile" class="form-control" type="text" placeholder="手机号码" required="true"
                       minlength="11" maxlength="11"/>
            </div>
            <div class="col-md-1">
            </div>
        </div>
    </div>

    <div class="form-group">
        <div class="row">
            <label class="form-label col-md-4">请输入密码</label>
            <div class="col-md-5">
                <input id="password" name="password" class="form-control" type="password" placeholder="密码"
                       required="true" minlength="6" maxlength="16"/>
            </div>
        </div>
    </div>

    <div class="row">
        <div class="col-md-5">
            <button class="btn btn-primary btn-block" type="reset" onclick="reset()">重置</button>
        </div>
        <div class="col-md-5">
            <button class="btn btn-primary btn-block" type="submit" onclick="login()">登录</button>
        </div>
    </div>
</form>
</body>
<script>
    function login() {
        $("#loginForm").validate({
            submitHandler: function (form) {
                doLogin();
            }
        });
    }

    function doLogin() {
        g_showLoading();

        var inputPass = $("#password").val();
        var salt = g_passsword_salt;
        var str = "" + salt.charAt(0) + salt.charAt(2) + inputPass + salt.charAt(5) + salt.charAt(4);
        var password = md5(str);

        $.ajax({
            url: "/login/doLogin",
            type: "POST",
            data: {
                mobile: $("#mobile").val(),
                password: password
            },
            success: function (data) {
                layer.closeAll();
                if (data.code == 200) {
                    layer.msg("成功");
                    window.location.href="/goods/toList";
                } else {
                    layer.msg(data.message);
                }
            },
            error: function () {
                layer.closeAll();
            }
        });
    }
</script>
</html>

测试

参数校验

参数校验:
每个类都写大量的健壮性判断过于麻烦,我们可以使用 validation 简化我们的代码
pom.xml

<!-- validation组件 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

手机验证规则

package com.itkaka.seckill.vo;

import com.itkaka.seckill.utils.ValidatorUtil;
import com.itkaka.seckill.validator.IsMobile;
import org.springframework.util.StringUtils;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

/**
 * 手机号码校验规则
 */
public class IsMobileValidator implements ConstraintValidator<IsMobile,String> {

	private boolean required = false;

	@Override
	public void initialize(IsMobile constraintAnnotation) {
		required = constraintAnnotation.required();
	}

	@Override
	public boolean isValid(String value, ConstraintValidatorContext context) {
		if (required){
			return ValidatorUtil.isMobile(value);
		}else {
			if (StringUtils.isEmpty(value)){
				return true;
			}else {
				return ValidatorUtil.isMobile(value);
			}
		}
	}
}

自定义注解

package com.itkaka.seckill.validator;

import com.itkaka.seckill.vo.IsMobileValidator;

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.ElementType.TYPE_USE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {IsMobileValidator.class})
public @interface IsMobile {
    boolean required() default true;

    String message() default "手机号码格式错误";

    Class<?>[] groups() default { };

    Class<? extends Payload>[] payload() default { };
}

修改LoginVo

package com.itkaka.seckill.vo;


import com.itkaka.seckill.validator.IsMobile;
import lombok.Data;
import org.hibernate.validator.constraints.Length;

import javax.validation.constraints.NotNull;

//登录参数
@Data
public class LoginVo {

    @NotNull
    @IsMobile
    private String mobile;

    @NotNull
    @Length(min = 32)
    private String password;

}

对控制层和业务层修改一点点
控制层入参添加 注解 @ Valid

package com.itkaka.seckill.controller;

import com.itkaka.seckill.service.IUserService;
import com.itkaka.seckill.vo.LoginVo;
import com.itkaka.seckill.vo.RespBean;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.validation.Valid;

/*
*   登录
* */

@Controller
@RequestMapping("/login")
@Slf4j
public class LoginController {

    @Autowired
    private IUserService userService;

    // 跳转登录页面
    @RequestMapping("/toLogin")
    public String toLogin(){
        return "login";
    }

    // 登录功能
    @RequestMapping("/doLogin")
    @ResponseBody
    public RespBean doLogin(@Valid LoginVo loginVo){
        log.info(loginVo.toString());
        return userService.login(loginVo);
    }

}

业务层的实现类取消 参数非空校验即可

package com.itkaka.seckill.service.impl;

import com.itkaka.seckill.pojo.User;
import com.itkaka.seckill.mapper.UserMapper;
import com.itkaka.seckill.service.IUserService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.itkaka.seckill.utils.MD5Util;
import com.itkaka.seckill.utils.ValidatorUtil;
import com.itkaka.seckill.vo.LoginVo;
import com.itkaka.seckill.vo.RespBean;
import com.itkaka.seckill.vo.RespBeanEnum;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author itkaka
 * @since 2023-05-11
 */
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Resource
    private UserMapper userMapper;

    // 登录
    @Override
    public RespBean login(LoginVo loginVo) {

        String phone = loginVo.getMobile();
        String pwd = loginVo.getPassword();

        // 参数校验  由于增加了手机信息校验这部分可以省略
        /*if (StringUtils.isEmpty(phone) || StringUtils.isEmpty(pwd)){
            return RespBean.error(RespBeanEnum.LOGIN_ERROR);
        }
        if (!ValidatorUtil.isMobile(phone)){
            return RespBean.error(RespBeanEnum.MOBILE_ERROR);
        }*/

        // 手机号查询用户
        User temp = userMapper.selectById(phone);
        if ( null == temp){
            return RespBean.error(RespBeanEnum.LOGIN_ERROR);
        }

        //密码加密后比对
        if (MD5Util.formPassToDBPass(pwd,temp.getSlat()).equals(temp.getPassword())){
            return RespBean.error(RespBeanEnum.LOGIN_ERROR);
        }

        return RespBean.success();
    }
}

测试 :

异常处理

系统异常包括 : 编译时异常和运行时异常 RuntimeException;
前者通过捕获异常从而获取异常信息,后者主要通过规范代码开发,测试通过手段减少运行时异常的发生.
实际开发,三层都可能抛出异常, SpringMVC中,能将所有类型异常处理解耦出来,既保证相关处理过程的功能单一,也实现了异常信息的统一处理和维护.
SpringBoot全局异常处理方式有两种:

使用 @ControllerAdvice 和 @ExceptionHandler注解使用 ErrorController类 来实现
@ControllerAdvice 方式只能处理控制器抛出的异常。此时请求已经进入控制器中。ErrorController类 方式可以处理所有的异常,包括未进入控制器的错误,比如404,401等错误
如果应用中两者共同存在,则 @ControllerAdvice 方式处理控制器抛出的异常,如果应用中两者共同存在,ErrorController类 方式处理未进入控制器的异常。
@ControllerAdvice 方式可定义多个拦截方法,拦截不同的异常类,并且可以获取抛出的异常信息,自由度更大。

新建异常的包

package com.itkaka.seckill.exception;

import com.itkaka.seckill.vo.RespBeanEnum;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 全局异常
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class GlobalException extends RuntimeException {
	private RespBeanEnum respBeanEnum;
}
package com.itkaka.seckill.exception;

import com.itkaka.seckill.vo.RespBean;
import com.itkaka.seckill.vo.RespBeanEnum;
import org.springframework.validation.BindException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;


/**
 * 全局异常处理类
 */
@RestControllerAdvice
public class GlobalExceptionHandler {

	@ExceptionHandler(Exception.class)
	public RespBean ExceptionHandler(Exception e) {
		if (e instanceof GlobalException) {
			GlobalException ex = (GlobalException) e;
			return RespBean.error(ex.getRespBeanEnum());
		} else if (e instanceof BindException) {
			BindException ex = (BindException) e;
			RespBean respBean = RespBean.error(RespBeanEnum.BIND_ERROR);
			respBean.setMessage("参数校验异常:" + ex.getBindingResult().getAllErrors().get(0).getDefaultMessage());
			return respBean;
		}
		return RespBean.error(RespBeanEnum.ERROR);
	}

}

修改一下业务层实现类的返回结果,直接抛全局异常

package com.itkaka.seckill.service.impl;

import com.itkaka.seckill.exception.GlobalException;
import com.itkaka.seckill.pojo.User;
import com.itkaka.seckill.mapper.UserMapper;
import com.itkaka.seckill.service.IUserService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.itkaka.seckill.utils.MD5Util;
import com.itkaka.seckill.utils.ValidatorUtil;
import com.itkaka.seckill.vo.LoginVo;
import com.itkaka.seckill.vo.RespBean;
import com.itkaka.seckill.vo.RespBeanEnum;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author itkaka
 * @since 2023-05-11
 */
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Resource
    private UserMapper userMapper;

    // 登录
    @Override
    public RespBean login(LoginVo loginVo) {

        String phone = loginVo.getMobile();
        String pwd = loginVo.getPassword();

        // 参数校验  由于增加了手机信息校验这部分可以省略
        /*if (StringUtils.isEmpty(phone) || StringUtils.isEmpty(pwd)){
            return RespBean.error(RespBeanEnum.LOGIN_ERROR);
        }
        if (!ValidatorUtil.isMobile(phone)){
            return RespBean.error(RespBeanEnum.MOBILE_ERROR);
        }*/

        // 手机号查询用户
        User temp = userMapper.selectById(phone);
        if ( null == temp){
            //return RespBean.error(RespBeanEnum.LOGIN_ERROR);
            throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
        }

        //密码加密后比对
        if (MD5Util.formPassToDBPass(pwd,temp.getSlat()).equals(temp.getPassword())){
           // return RespBean.error(RespBeanEnum.LOGIN_ERROR);
            throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
        }

        return RespBean.success();
    }
}

分布式 Session

完善登录功能

使用 Cookie + Session 记录用户信息
工具类

package com.itkaka.seckill.utils;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;

/**
 * Cookie工具类
 */
public final class CookieUtil {

    /**
     * 得到Cookie的值, 不编码
     */
    public static String getCookieValue(HttpServletRequest request, String cookieName) {
        return getCookieValue(request, cookieName, false);
    }

    /**
     * 得到Cookie的值,
     */
    public static String getCookieValue(HttpServletRequest request, String cookieName, boolean isDecoder) {
        Cookie[] cookieList = request.getCookies();
        if (cookieList == null || cookieName == null) {
            return null;
        }
        String retValue = null;
        try {
            for (int i = 0; i < cookieList.length; i++) {
                if (cookieList[i].getName().equals(cookieName)) {
                    if (isDecoder) {
                        retValue = URLDecoder.decode(cookieList[i].getValue(), "UTF-8");
                    } else {
                        retValue = cookieList[i].getValue();
                    }
                    break;
                }
            }
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        return retValue;
    }

    /**
     * 得到Cookie的值,
     */
    public static String getCookieValue(HttpServletRequest request, String cookieName, String encodeString) {
        Cookie[] cookieList = request.getCookies();
        if (cookieList == null || cookieName == null) {
            return null;
        }
        String retValue = null;
        try {
            for (int i = 0; i < cookieList.length; i++) {
                if (cookieList[i].getName().equals(cookieName)) {
                    retValue = URLDecoder.decode(cookieList[i].getValue(), encodeString);
                    break;
                }
            }
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        return retValue;
    }

    /**
     * 设置Cookie的值 不设置生效时间默认浏览器关闭即失效,也不编码
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
                                 String cookieValue) {
        setCookie(request, response, cookieName, cookieValue, -1);
    }

    /**
     * 设置Cookie的值 在指定时间内生效,但不编码
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
                                 String cookieValue, int cookieMaxage) {
        setCookie(request, response, cookieName, cookieValue, cookieMaxage, false);
    }

    /**
     * 设置Cookie的值 不设置生效时间,但编码
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
                                 String cookieValue, boolean isEncode) {
        setCookie(request, response, cookieName, cookieValue, -1, isEncode);
    }

    /**
     * 设置Cookie的值 在指定时间内生效, 编码参数
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
                                 String cookieValue, int cookieMaxage, boolean isEncode) {
        doSetCookie(request, response, cookieName, cookieValue, cookieMaxage, isEncode);
    }

    /**
     * 设置Cookie的值 在指定时间内生效, 编码参数(指定编码)
     */
    public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName,
                                 String cookieValue, int cookieMaxage, String encodeString) {
        doSetCookie(request, response, cookieName, cookieValue, cookieMaxage, encodeString);
    }

    /**
     * 删除Cookie带cookie域名
     */
    public static void deleteCookie(HttpServletRequest request, HttpServletResponse response,
                                    String cookieName) {
        doSetCookie(request, response, cookieName, "", -1, false);
    }

    /**
     * 设置Cookie的值,并使其在指定时间内生效
     *
     * @param cookieMaxage cookie生效的最大秒数
     */
    private static final void doSetCookie(HttpServletRequest request, HttpServletResponse response,
                                          String cookieName, String cookieValue, int cookieMaxage, boolean isEncode) {
        try {
            if (cookieValue == null) {
                cookieValue = "";
            } else if (isEncode) {
                cookieValue = URLEncoder.encode(cookieValue, "utf-8");
            }
            Cookie cookie = new Cookie(cookieName, cookieValue);
            if (cookieMaxage > 0)
                cookie.setMaxAge(cookieMaxage);
            if (null != request) {// 设置域名的cookie
                String domainName = getDomainName(request);
                System.out.println(domainName);
                if (!"localhost".equals(domainName)) {
                    cookie.setDomain(domainName);
                }
            }
            cookie.setPath("/");
            response.addCookie(cookie);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 设置Cookie的值,并使其在指定时间内生效
     *
     * @param cookieMaxage cookie生效的最大秒数
     */
    private static final void doSetCookie(HttpServletRequest request, HttpServletResponse response,
                                          String cookieName, String cookieValue, int cookieMaxage, String encodeString) {
        try {
            if (cookieValue == null) {
                cookieValue = "";
            } else {
                cookieValue = URLEncoder.encode(cookieValue, encodeString);
            }
            Cookie cookie = new Cookie(cookieName, cookieValue);
            if (cookieMaxage > 0) {
               cookie.setMaxAge(cookieMaxage);
            }
            if (null != request) {// 设置域名的cookie
                String domainName = getDomainName(request);
                System.out.println(domainName);
                if (!"localhost".equals(domainName)) {
                    cookie.setDomain(domainName);
                }
            }
            cookie.setPath("/");
            response.addCookie(cookie);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 得到cookie的域名
     */
    private static final String getDomainName(HttpServletRequest request) {
        String domainName = null;
        // 通过request对象获取访问的url地址
        String serverName = request.getRequestURL().toString();
        if (serverName == null || serverName.equals("")) {
            domainName = "";
        } else {
            // 将url地下转换为小写
            serverName = serverName.toLowerCase();
            // 如果url地址是以http://开头  将http://截取
            if (serverName.startsWith("http://")) {
                serverName = serverName.substring(7);
            }
            int end = serverName.length();
            // 判断url地址是否包含"/"
            if (serverName.contains("/")) {
                //得到第一个"/"出现的位置
                end = serverName.indexOf("/");
            }

            // 截取
            serverName = serverName.substring(0, end);
            // 根据"."进行分割
            final String[] domains = serverName.split("\\.");
            int len = domains.length;
            if (len > 3) {
                // www.xxx.com.cn
                domainName = domains[len - 3] + "." + domains[len - 2] + "." + domains[len - 1];
            } else if (len <= 3 && len > 1) {
                // xxx.com or xxx.cn
                domainName = domains[len - 2] + "." + domains[len - 1];
            } else {
                domainName = serverName;
            }
        }

        if (domainName != null && domainName.indexOf(":") > 0) {
            String[] ary = domainName.split("\\:");
            domainName = ary[0];
        }
        return domainName;
    }
}
package com.itkaka.seckill.utils;

import java.util.UUID;

/**
 * UUID工具类
 */
public class UUIDUtil {

   public static String uuid() {
      return UUID.randomUUID().toString().replace("-", "");
   }

}

完善业务层 接口和实现类 (增加请求和响应两个参数等)

package com.itkaka.seckill.service;

import com.itkaka.seckill.pojo.User;
import com.baomidou.mybatisplus.extension.service.IService;
import com.itkaka.seckill.vo.LoginVo;
import com.itkaka.seckill.vo.RespBean;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * <p>
 *  服务类
 * </p>
 *
 * @author itkaka
 * @since 2023-05-11
 */
public interface IUserService extends IService<User> {

    RespBean login(LoginVo loginVo, HttpServletRequest request, HttpServletResponse response); // 登录
}

package com.itkaka.seckill.service.impl;

import com.itkaka.seckill.exception.GlobalException;
import com.itkaka.seckill.pojo.User;
import com.itkaka.seckill.mapper.UserMapper;
import com.itkaka.seckill.service.IUserService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.itkaka.seckill.utils.CookieUtil;
import com.itkaka.seckill.utils.MD5Util;
import com.itkaka.seckill.utils.UUIDUtil;
import com.itkaka.seckill.utils.ValidatorUtil;
import com.itkaka.seckill.vo.LoginVo;
import com.itkaka.seckill.vo.RespBean;
import com.itkaka.seckill.vo.RespBeanEnum;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author itkaka
 * @since 2023-05-11
 */
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Resource
    private UserMapper userMapper;

    // 登录
    @Override
    public RespBean login(LoginVo loginVo, HttpServletRequest request, HttpServletResponse response) {

        String phone = loginVo.getMobile();
        String pwd = loginVo.getPassword();

        // 参数校验  由于增加了手机信息校验这部分可以省略
        /*if (StringUtils.isEmpty(phone) || StringUtils.isEmpty(pwd)){
            return RespBean.error(RespBeanEnum.LOGIN_ERROR);
        }
        if (!ValidatorUtil.isMobile(phone)){
            return RespBean.error(RespBeanEnum.MOBILE_ERROR);
        }*/

        // 手机号查询用户
        User temp = userMapper.selectById(phone);
        if ( null == temp){
            //return RespBean.error(RespBeanEnum.LOGIN_ERROR);
            throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
        }

        //密码加密后比对
        if (MD5Util.formPassToDBPass(pwd,temp.getSlat()).equals(temp.getPassword())){
           // return RespBean.error(RespBeanEnum.LOGIN_ERROR);
            throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
        }

        //生成cookie
        String ticket = UUIDUtil.uuid();
        request.getSession().setAttribute(ticket,temp);
        CookieUtil.setCookie(request,response,"userTicket",ticket);

        return RespBean.success();
    }
}

控制层修改

package com.itkaka.seckill.controller;

import com.itkaka.seckill.service.IUserService;
import com.itkaka.seckill.vo.LoginVo;
import com.itkaka.seckill.vo.RespBean;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;

/*
*   登录
* */

@Controller
@RequestMapping("/login")
@Slf4j
public class LoginController {

    @Autowired
    private IUserService userService;

    // 跳转登录页面
    @RequestMapping("/toLogin")
    public String toLogin(){
        return "login";
    }

    // 登录功能
    @RequestMapping("/doLogin")
    @ResponseBody
    public RespBean doLogin(@Valid LoginVo loginVo, HttpServletRequest request, HttpServletResponse response){
        log.info(loginVo.toString());
        return userService.login(loginVo,request,response);
    }

}

测试

分布式 Session 问题

之前项目代码都在一台应用系统, 所有操作都在一台Tomcat上, 当我们部署多台系统,配合Nginx的时候会出现用户登录的问题
原因: 由于 Nginx 使用默认负载均衡策略(轮询),请求将会按照时间顺序逐一分发到后端应用上。
当用户在 Tomcat1 登录之后,用户信息放在 Tomcat1 的 Session 里。过了一会,请求又被 Nginx 分发到了 Tomcat2 上,这时 Tomcat2 上 Session 里还没有用户信息,于是又要登录
**解决方案 : **
Session 复制:

  • 优点
    • 无需修改代码,只需要修改Tomcat配置
  • 缺点
    • Session同步传输占用内网带宽
    • 多台Tomcat同步性能指数级下降
    • Session占用内存,无法有效水平扩展

前端存储

  • 优点
    • 不占用服务端内存
  • 缺点
    • 存在安全风险
    • 数据大小受cookie限制
    • 占用外网带宽

Session粘滞

  • 优点
    • 无需修改代码
    • 服务端可以水平扩展
  • 缺点
    • 增加新机器,会重新Hash,导致重新登录
    • 应用重启,需要重新登录

后端集中存储

  • 优点
    • 安全
    • 容易水平扩展
  • 缺点
    • 增加复杂度
    • 需要修改代码

Redis 安装

下载地址 : http://redis.io/
将下载好的安装包上传至服务器
image.png
解压,安装依赖
tar zxvf redis-5.0.3.tar.gz
yum -y install gcc-c++ autoconf automake
image.png
预编译
#切换到解压目录
cd redis-5.0.5/
#预编译
make
image.png
安装 修改配配置文件 修改内容 启动

#创建安装目录
mkdir -p /usr/local/redis
#安装
make PREFIX=/usr/local/redis/ instal

#复制redis.conf至安装路径下
cp redis.conf /usr/local/redis/bin/
#修改配置文件
vim /usr/local/redis/bin/redis.conf

#方便测试,注释掉该行。可以使所有ip访问redis
#bind 127.0.0.1
#关闭保护模式
protected-mode no
#后台启动
daemonize yes
#添加访问认证
requirepass root


# 启动 Redis
./redis-server redis.conf

image.png

Redis 实现分布式 Session

方法一 ; 使用 SpringSession 实现
添加依赖

<!-- spring data redis 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- commons-pool2 对象池依赖 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!-- spring-session 依赖 -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>

添加配置

spring:
redis:
  #超时时间
 timeout: 10000ms
  #服务器地址
 host: 192.168.10.100
  #服务器端口
 port: 6379
  #数据库
 database: 0
  #密码
 password: root
 lettuce:
  pool:
    #最大连接数,默认8
   max-active: 1024
    #最大连接阻塞等待时间,默认-1
   max-wait: 10000ms
    #最大空闲连接
   max-idle: 200
    #最小空闲连接
   min-idle: 5

测试
其余代码暂时不动,重新登录测试。会发现session已经存储在Redis上
方法二 : 将用户信息存入 Redis

<!-- spring data redis 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- commons-pool2 对象池依赖 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>

添加配置 (一定要注意缩进, 不然会报错)

spring:
redis:
  #超时时间
 timeout: 10000ms
  #服务器地址
 host: 192.168.10.100
  #服务器端口
 port: 6379
  #数据库
 database: 0
  #密码
 password: root
 lettuce:
  pool:
    #最大连接数,默认8
   max-active: 1024
    #最大连接阻塞等待时间,默认-1
   max-wait: 10000ms
	#最大空闲连接
   max-idle: 200
    #最小空闲连接
   min-idle: 5

Redis 配置

package com.itkaka.seckill.config;

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.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

// Redis 配置类
@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        //key序列化
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        //value序列化
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        //hash类型 key序列化
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        //hash类型 value序列化
        redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
        //注入连接工厂
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        return redisTemplate;
    }

}

工具类

package com.itkaka.seckill.utils;

import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;
import java.util.List;

/**
 * Json工具类
 */
public class JsonUtil {
    private static ObjectMapper objectMapper = new ObjectMapper();

    /**
     * 将对象转换成json字符串
     */
    public static String object2JsonStr(Object obj) {
        try {
            return objectMapper.writeValueAsString(obj);
        } catch (JsonProcessingException e) {
            //打印异常信息
            e.printStackTrace();
        }
        return null;
    }

    /**
     * 将字符串转换为对象
     *
     * @param <T> 泛型
     */
    public static <T> T jsonStr2Object(String jsonStr, Class<T> clazz) {
        try {
            return objectMapper.readValue(jsonStr.getBytes("UTF-8"), clazz);
        } catch (JsonParseException e) {
            e.printStackTrace();
        } catch (JsonMappingException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    /**
     * 将json数据转换成pojo对象list
     * <p>Title: jsonToList</p>
     * <p>Description: </p>
     *
     * @param jsonStr
     * @param beanType
     * @return
     */
    public static <T> List<T> jsonToList(String jsonStr, Class<T> beanType) {
        JavaType javaType = objectMapper.getTypeFactory().constructParametricType(List.class, beanType);
        try {
            List<T> list = objectMapper.readValue(jsonStr, javaType);
            return list;
        } catch (Exception e) {
            e.printStackTrace();
        }

        return null;
    }
}

修改之前代码
业务层接口和实现类

package com.itkaka.seckill.service.impl;

import com.itkaka.seckill.exception.GlobalException;
import com.itkaka.seckill.pojo.User;
import com.itkaka.seckill.mapper.UserMapper;
import com.itkaka.seckill.service.IUserService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.itkaka.seckill.utils.CookieUtil;
import com.itkaka.seckill.utils.MD5Util;
import com.itkaka.seckill.utils.UUIDUtil;
import com.itkaka.seckill.utils.ValidatorUtil;
import com.itkaka.seckill.vo.LoginVo;
import com.itkaka.seckill.vo.RespBean;
import com.itkaka.seckill.vo.RespBeanEnum;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author itkaka
 * @since 2023-05-11
 */
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Resource
    private UserMapper userMapper;
    @Autowired
    private RedisTemplate redisTemplate;

    // 登录
    @Override
    public RespBean login(LoginVo loginVo, HttpServletRequest request, HttpServletResponse response) {

        String phone = loginVo.getMobile();
        String pwd = loginVo.getPassword();

        // 参数校验  由于增加了手机信息校验这部分可以省略
        /*if (StringUtils.isEmpty(phone) || StringUtils.isEmpty(pwd)){
            return RespBean.error(RespBeanEnum.LOGIN_ERROR);
        }
        if (!ValidatorUtil.isMobile(phone)){
            return RespBean.error(RespBeanEnum.MOBILE_ERROR);
        }*/

        // 手机号查询用户
        User temp = userMapper.selectById(phone);
        if ( null == temp){
            //return RespBean.error(RespBeanEnum.LOGIN_ERROR);
            throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
        }

        //密码加密后比对
        if (MD5Util.formPassToDBPass(pwd,temp.getSlat()).equals(temp.getPassword())){
           // return RespBean.error(RespBeanEnum.LOGIN_ERROR);
            throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
        }

        //生成cookie
        String ticket = UUIDUtil.uuid();
        redisTemplate.opsForValue().set("user:"+ticket,temp);
        //request.getSession().setAttribute(ticket,temp);
        CookieUtil.setCookie(request,response,"userTicket",ticket);
        CookieUtil.setCookie(request,response,"userTicket",ticket);

        return RespBean.success();
    }

    // 根据 cookie 拿用户
    @Override
    public User getUserByCookie(String userTicket, HttpServletRequest request, HttpServletResponse response) {

        if (StringUtils.isEmpty(userTicket)){
            return null;
        }
        User user = (User) redisTemplate.opsForValue().get("user:" + userTicket);
        if (user != null){
            CookieUtil.setCookie(request,response,"userTicket",userTicket);
        }

        return user;
    }
}

package com.itkaka.seckill.service;

import com.itkaka.seckill.pojo.User;
import com.baomidou.mybatisplus.extension.service.IService;
import com.itkaka.seckill.vo.LoginVo;
import com.itkaka.seckill.vo.RespBean;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * <p>
 *  服务类
 * </p>
 *
 * @author itkaka
 * @since 2023-05-11
 */
public interface IUserService extends IService<User> {

    RespBean login(LoginVo loginVo, HttpServletRequest request, HttpServletResponse response); // 登录

    User getUserByCookie(String userTicket,HttpServletRequest request,HttpServletResponse response); //根据cookie拿用户信息

}

商品控制层

package com.itkaka.seckill.controller;


import com.itkaka.seckill.pojo.User;
import com.itkaka.seckill.service.IUserService;
import org.apache.commons.lang3.StringUtils;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.RequestMapping;

import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

/**
 * <p>
 *  前端控制器  商品
 * </p>
 *
 * @author itkaka
 * @since 2023-05-11
 */
@RestController
@RequestMapping("/goods")
public class GoodsController {
    @Resource
    private IUserService userService;

    // 跳转商品列表页
    @RequestMapping(value = "/toList", produces = "text/html;charset=utf-8")
    @ResponseBody
    public String toList(Model model, @CookieValue("userTicket") String ticket, HttpServletRequest request,HttpServletResponse response){
        //
        if (StringUtils.isEmpty(ticket)) {
            return "login";
        }
        //User user = (User) session.getAttribute(ticket);
        User user = userService.getUserByCookie(ticket,request,response);
        if (null == user) {
            return "login";
        }
        model.addAttribute("user", user);
        return "goodsList";

    }

}

测试 :
image.png

优化登录功能

增加三个配置类

package com.itkaka.seckill.config;

import com.itkaka.seckill.pojo.User;

/**
 * 
 */
public class UserContext {

	private static ThreadLocal<User> userHolder = new ThreadLocal<User>();

	public static void setUser(User user) {
		userHolder.set(user);
	}

	public static User getUser() {
		return userHolder.get();
	}
}

package com.itkaka.seckill.config;
/**
 * MVC配置类
 */
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {

	@Autowired
	private UserArgumentResolver userArgumentResolver;
	/*@Autowired
	private AccessLimitInterceptor accessLimitInterceptor;*/

	@Override
	public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
		resolvers.add(userArgumentResolver);
	}
}
package com.itkaka.seckill.config;

import com.itkaka.seckill.pojo.User;
import com.itkaka.seckill.service.IUserService;
import com.itkaka.seckill.utils.CookieUtil;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * 自定义用户参数
 */
@Component
public class UserArgumentResolver implements HandlerMethodArgumentResolver {
	@Autowired
	private IUserService userService;

	@Override
	public boolean supportsParameter(MethodParameter parameter) {
		Class<?> clazz = parameter.getParameterType();
		return clazz == User.class;
	}

	@Override
	public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
	                              NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {

		HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
		HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
		String ticket = CookieUtil.getCookieValue(request,"userTicket");
		if (StringUtils.isEmpty(ticket)){
			return null;
		}

		return userService.getUserByCookie(ticket,request,response);
	}
}

修改商品控制层

@Controller
@RequestMapping("/goods")
public class GoodsController {
 /**
  * 跳转登录页
  *
  * @return
  */
 @RequestMapping("/toList")
 public String toLogin(Model model,User user) {
   model.addAttribute("user", user);
   return "goodsList";
 }
}

秒杀功能

商品列表页

逆向工程生成所有需要的类, 三层以及实体类 和 mapper.xml文件
GoodsVo : VO对象, 在继承 po 属性之外,额外定义几个秒杀场景需要的属性;

package com.itkaka.seckill.vo;

import com.itkaka.seckill.pojo.Goods;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;
import java.util.Date;

// 商品返回对象
@Data
@NoArgsConstructor
@AllArgsConstructor
public class GoodsVo extends Goods {

    private BigDecimal seckillPrice;
    private Integer stockCount;
    private Date startDate;
    private Date endDate;

}

**持久层 **: 持久层接口和 XML 中的SQL查询语句(包括商品列表和商品详情)

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.itkaka.seckill.mapper.GoodsMapper">

  <!-- 通用查询映射结果 -->
  <resultMap id="BaseResultMap" type="com.itkaka.seckill.pojo.Goods">
    <id column="id" property="id" />
    <result column="goods_name" property="goodsName" />
    <result column="goods_title" property="goodsTitle" />
    <result column="goods_img" property="goodsImg" />
    <result column="goods_detail" property="goodsDetail" />
    <result column="goods_price" property="goodsPrice" />
    <result column="goods_stock" property="goodsStock" />
  </resultMap>

  <!-- 通用查询结果列 -->
  <sql id="Base_Column_List">
    id, goods_name, goods_title, goods_img, goods_detail, goods_price, goods_stock
  </sql>

  <!--   查询商品列表  -->
  <select id="queryGoodsVo" resultType="com.itkaka.seckill.vo.GoodsVo">
    select
    g.id,
    g.goods_name,
    g.goods_title,
    g.goods_img,
    g.goods_detail,
    g.goods_price,
    g.goods_stock,
    sg.seckill_price,
    sg.stock_count,
    sg.start_date,
    sg.end_date
    from t_goods g
    left join t_seckill_goods sg on g.id = sg.goods_id
  </select>

  <!--  根据商品 ID 查询商品详情  -->
  <select id="queryGoodsVoByGoodsId" resultType="com.itkaka.seckill.vo.GoodsVo">
    select g.id,
    g.goods_name,
    g.goods_title,
    g.goods_img,
    g.goods_detail,
    g.goods_price,
    g.goods_stock,
    sg.seckill_price,
    sg.stock_count,
    sg.start_date,
    sg.end_date
    from t_goods g
    left join t_seckill_goods sg on g.id = sg.goods_id
    where g.id = #{goodsId}
  </select>

</mapper>

package com.itkaka.seckill.mapper;

import com.itkaka.seckill.pojo.Goods;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.itkaka.seckill.vo.GoodsVo;

import java.util.List;

/**
 * <p>
 *  Mapper 接口
 * </p>
 *
 * @author itkaka
 * @since 2023-05-11
 */
public interface GoodsMapper extends BaseMapper<Goods> {

    // 查询商品列表
    List<GoodsVo> queryGoodsVo();

    // 根据商品 ID 查询商品详情
    GoodsVo queryGoodsVoByGoodsId(Long goodsId);

}

业务层 接口和实现类

package com.itkaka.seckill.service;

import com.itkaka.seckill.pojo.Goods;
import com.baomidou.mybatisplus.extension.service.IService;
import com.itkaka.seckill.vo.GoodsVo;

import java.util.List;

/**
 * <p>
 *  服务类
 * </p>
 *
 * @author itkaka
 * @since 2023-05-11
 */
public interface IGoodsService extends IService<Goods> {

    // 查商品列表
    List<GoodsVo> queryGoodsVo();

    // 根据商品 ID 查商品详情
    GoodsVo queryGoodsVoByGoodsId(Long goodsId);

}

package com.itkaka.seckill.service.impl;

import com.itkaka.seckill.pojo.Goods;
import com.itkaka.seckill.mapper.GoodsMapper;
import com.itkaka.seckill.service.IGoodsService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.itkaka.seckill.vo.GoodsVo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.List;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author itkaka
 * @since 2023-05-11
 */
@Service
public class GoodsServiceImpl extends ServiceImpl<GoodsMapper, Goods> implements IGoodsService {
    @Resource
    private GoodsMapper goodsMapper;

    @Override
    public List<GoodsVo> queryGoodsVo() {
        return goodsMapper.queryGoodsVo();
    }

    @Override
    public GoodsVo queryGoodsVoByGoodsId(Long goodsId) {
        return goodsMapper.queryGoodsVoByGoodsId(goodsId);
    }
}

控制层接口 GoodsController (商品列表和商品详情)

package com.itkaka.seckill.controller;


import com.itkaka.seckill.pojo.User;
import com.itkaka.seckill.service.IGoodsService;
import com.itkaka.seckill.service.IUserService;
import com.itkaka.seckill.vo.GoodsVo;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.Date;

/**
 * <p>
 *  前端控制器  商品
 * </p>
 *
 * @author itkaka
 * @since 2023-05-11
 */
@Controller
@RequestMapping("/goods")
public class GoodsController {
    @Resource
    private IUserService userService;
    @Resource
    private IGoodsService goodsService;
    @Resource
    private RedisTemplate redisTemplate;

    // 跳转商品列表页
    @RequestMapping(value = "/toList", produces = "text/html;charset=utf-8")
    @ResponseBody
    public String toList(Model model, @CookieValue("userTicket") String ticket, HttpServletRequest request,HttpServletResponse response){
        // Redis中获取页面,如果不为空,直接返回页面
        if (StringUtils.isEmpty(ticket)) {
            return "login";
        }
        //User user = (User) session.getAttribute(ticket);
        User user = userService.getUserByCookie(ticket,request,response);
        if (null == user) {
            return "login";
        }
        model.addAttribute("user", user);

        model.addAttribute("goodList",goodsService.queryGoodsVo());

        return "goodsList";

    }

    // 跳转商品详情页
    @RequestMapping("/toDetail/{goodsId}")
    public String toDetail(Model model, User user, @PathVariable Long goodsId){
        model.addAttribute("user",user);
        GoodsVo goodsVo = goodsService.queryGoodsVoByGoodsId(goodsId);
        model.addAttribute("goods",goodsVo);
        Date startDate = goodsVo.getStartDate();
        Date endDate = goodsVo.getEndDate();
        Date nowDate = new Date();
        // 秒杀状态
        int seckillStatus = 0;
        // 剩余开始时间
        int remainSeconds = 0;
        // 秒杀还未开始
        if (nowDate.before(startDate)){
            remainSeconds = (int) ((startDate.getTime()-nowDate.getTime())/1000);
        } else if (nowDate.after(endDate)){
            // 秒杀已经结束
            seckillStatus = 2;
            remainSeconds = -1;
        } else{
            // 秒杀中
            seckillStatus = 1;
            remainSeconds = 0;
        }
        model.addAttribute("seckillStatus",seckillStatus);
        model.addAttribute("remainSeconds",remainSeconds);
        return "goodsDetail";
    }

}

配置类修改

package com.itkaka.seckill.config;

/**
 * MVC配置类
 */
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {

	@Autowired
	private UserArgumentResolver userArgumentResolver;
	/*@Autowired
	private AccessLimitInterceptor accessLimitInterceptor;*/

	@Override
	public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
		resolvers.add(userArgumentResolver);
	}

	@Override
	public void addResourceHandlers(ResourceHandlerRegistry registry) {
		registry.addResourceHandler("/**").addResourceLocations("classpath:/static/");
	}

}

添加前端页面

<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>商品详情</title>
    <!-- jquery -->
    <script type="text/javascript" th:src="@{/js/jquery.min.js}"></script>
    <!-- bootstrap -->
    <link rel="stylesheet" type="text/css" th:href="@{/bootstrap/css/bootstrap.min.css}"/>
    <script type="text/javascript" th:src="@{/bootstrap/js/bootstrap.min.js}"></script>
    <!-- layer -->
    <script type="text/javascript" th:src="@{/layer/layer.js}"></script>
    <!-- common.js -->
    <script type="text/javascript" th:src="@{/js/common.js}"></script>
</head>
<body>
<div class="panel panel-default">
    <div class="panel-heading">秒杀商品详情</div>
    <div class="panel-body">
        <span th:if="${user eq null}"> 您还没有登录,请登陆后再操作<br/></span>
        <span>没有收货地址的提示。。。</span>
    </div>
    <table class="table" id="goods">
        <tr>
            <td>商品名称</td>
            <td colspan="3" th:text="${goods.goodsName}"></td>
        </tr>
        <tr>
            <td>商品图片</td>
            <td colspan="3"><img th:src="@{${goods.goodsImg}}" width="200" height="200"/></td>
        </tr>
        <tr>
            <td>秒杀开始时间</td>
            <td th:text="${#dates.format(goods.startDate,'yyyy-MM-dd HH:mm:ss')}"></td>
            <td id="seckillTip">
                <input type="hidden" id="remainSeconds" th:value="${remainSeconds}">
                <span th:if="${secKillStatus eq 0}">秒杀倒计时: <span id="countDown" th:text="${remainSeconds}"></span></span>
                <span th:if="${secKillStatus eq 1}">秒杀进行中</span>
                <span th:if="${secKillStatus eq 2}">秒杀已结束</span>
            </td>
            <td>
                <form id="secKillForm" method="post" action="/seckill/doSeckill">
                    <input type="hidden" name="goodsId" th:value="${goods.id}">
                    <button class="btn btn-primary btn-block" type="submit" id="buyButton">立即秒杀</button>
                </form>
            </td>
        </tr>
        <tr>
            <td>商品原价</td>
            <td colspan="3" th:text="${goods.goodsPrice}"></td>
        </tr>
        <tr>
            <td>秒杀价</td>
            <td colspan="3" th:text="${goods.seckillPrice}"></td>
        </tr>
        <tr>
            <td>库存数量</td>
            <td colspan="3" th:text="${goods.stockCount}"></td>
        </tr>
    </table>
</div>
</body>
<script>
    $(function () {
        countDown();
    });

    function countDown() {
        var remainSeconds = $("#remainSeconds").val();
        var timeout;
        //秒杀还未开始
        if (remainSeconds > 0) {
            $("#buyButton").attr("disabled", true);
            timeout = setTimeout(function () {
                $("#countDown").text(remainSeconds - 1);
                $("#remainSeconds").val(remainSeconds - 1);
                countDown();
            }, 1000);
            // 秒杀进行中
        } else if (remainSeconds == 0) {
            $("#buyButton").attr("disabled", false);
            if (timeout) {
                clearTimeout(timeout);
            }
            $("#seckillTip").html("秒杀进行中")
        } else {
            $("#buyButton").attr("disabled", true);
            $("#seckillTip").html("秒杀已经结束");
        }
    };

</script>
</html>
<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>商品列表</title>
    <!-- jquery -->
    <script type="text/javascript" th:src="@{/js/jquery.min.js}"></script>
    <!-- bootstrap -->
    <link rel="stylesheet" type="text/css" th:href="@{/bootstrap/css/bootstrap.min.css}"/>
    <script type="text/javascript" th:src="@{/bootstrap/js/bootstrap.min.js}"></script>
    <!-- layer -->
    <script type="text/javascript" th:src="@{/layer/layer.js}"></script>
    <!-- common.js -->
    <script type="text/javascript" th:src="@{/js/common.js}"></script>
</head>
<body>
<div class="panel panel-default">
    <div class="panel-heading">秒杀商品列表</div>
    <table class="table" id="goodslist">
        <tr>
            <td>商品名称</td>
            <td>商品图片</td>
            <td>商品原价</td>
            <td>秒杀价</td>
            <td>库存数量</td>
            <td>详情</td>
        </tr>
        <tr th:each="goods,goodsStat : ${goodsList}">
            <td th:text="${goods.goodsName}"></td>
            <td><img th:src="@{${goods.goodsImg}}" width="100" height="100"/></td>
            <td th:text="${goods.goodsPrice}"></td>
            <td th:text="${goods.seckillPrice}"></td>
            <td th:text="${goods.stockCount}"></td>
            <td><a th:href="'/goodsDetail.htm?goodsId='+${goods.id}">详情</a></td>
        </tr>
    </table>
</div>
</body>
</html>

秒杀功能实现

业务层

package com.itkaka.seckill.service;

import com.itkaka.seckill.pojo.Order;
import com.baomidou.mybatisplus.extension.service.IService;
import com.itkaka.seckill.pojo.User;
import com.itkaka.seckill.vo.GoodsVo;

/**
 * <p>
 *  服务类
 * </p>
 *
 * @author itkaka
 * @since 2023-05-11
 */
public interface IOrderService extends IService<Order> {

    // 简单 秒杀功能
    Order seckill(User user, GoodsVo goodsVo);

}

package com.itkaka.seckill.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.itkaka.seckill.pojo.Order;
import com.itkaka.seckill.mapper.OrderMapper;
import com.itkaka.seckill.pojo.SeckillGoods;
import com.itkaka.seckill.pojo.SeckillOrder;
import com.itkaka.seckill.pojo.User;
import com.itkaka.seckill.service.IGoodsService;
import com.itkaka.seckill.service.IOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.itkaka.seckill.service.ISeckillGoodsService;
import com.itkaka.seckill.service.ISeckillOrderService;
import com.itkaka.seckill.vo.GoodsVo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;
import java.util.Date;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author itkaka
 * @since 2023-05-11
 */
@Service
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements IOrderService {

    @Resource
    private ISeckillGoodsService seckillGoodsService;
    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private ISeckillOrderService seckillOrderService;
    @Autowired
    private IGoodsService goodsService;
    @Autowired
    private RedisTemplate redisTemplate;

    // 秒杀
    @Override
    @Transactional
    public Order seckill(User user, GoodsVo goodsVo) {

        // 秒杀商品减库存
        SeckillGoods seckillGoods = seckillGoodsService.getOne(new
                QueryWrapper<SeckillGoods>().eq("goods_id",goodsVo.getId()));
        seckillGoods.setStockCount(seckillGoods.getStockCount()-1);
        seckillGoodsService.updateById(seckillGoods);

        // 生成订单
        Order order = new Order();
        order.setUserId(user.getId());
        order.setGoodsId(goodsVo.getId());
        order.setDeliverAddrId(0);
        order.setGoodsName(goodsVo.getGoodsName());
        order.setGoodsCount(1);
        order.setGoodsPrice(seckillGoods.getSeckillPrice());
        order.setOrderChannel(1);
        order.setStatus(0);
        order.setCreateDate(new Date());
        orderMapper.insert(order);
        //生成秒杀订单
        SeckillOrder seckillOrder = new SeckillOrder();
        seckillOrder.setUserId(user.getId());
        seckillOrder.setOrderId(order.getId());
        seckillOrder.setGoodsId(goodsVo.getId());
        seckillOrderService.save(seckillOrder);
        //valueOperations.set("order:" + user.getId() + ":" + goods.getId(), seckillOrder);

        return order;
    }
}

控制层 : 新建 SeckillController 接口

package com.itkaka.seckill.controller;

/**
 * 秒杀
 */
@Slf4j
@Controller
@RequestMapping("/seckill")
public class SecKillController  {

	@Autowired
	private IGoodsService goodsService;
	@Autowired
	private ISeckillOrderService seckillOrderService;
	@Autowired
	private IOrderService orderService;
	@Autowired
	private RedisTemplate redisTemplate;

	@RequestMapping("/doSeckill")
	public String doSeckill(Model model,User user,Long goodsId){
		if (null == user){
			return "login";
		}
		model.addAttribute("user",user);
		GoodsVo goodsVo = goodsService.queryGoodsVoByGoodsId(goodsId);
		// 判断库存
		if (goodsVo.getStockCount() < 1){
			model.addAttribute("errmsg",RespBeanEnum.EMPTY_STOCK.getMessage());
			return "seckillFail";
		}
		// 判断是否重复抢购
		SeckillOrder seckillOrder = seckillOrderService.getOne(new
				QueryWrapper<SeckillOrder>().eq("user_id",user.getId()).eq("goods_id",goodsId));
		if (seckillOrder != null){
			model.addAttribute("errmsg",RespBeanEnum.REPEATE_ERROR.getMessage());
			return "seckillFail";
		}
		Order order = orderService.seckill(user,goodsVo);
		model.addAttribute("order",order);
		model.addAttribute("goods",goodsVo);
		return "orderDetail";
	}
}

测试 :
秒杀成功进去定安详情 [注意查看库存是否正确扣减,订单是否正确生成]
两个限制条件 : ①限制超卖(库存不足) ②单品每人限购一件(重复抢购)

订单详情页

本次简单秒杀, 订单详情制作简单页面展示, 其余比如支付等功能不体现
OrderDetail.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>订单详情</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <!-- jquery -->
    <script type="text/javascript" th:src="@{/js/jquery.min.js}"></script>
    <!-- bootstrap -->
    <link rel="stylesheet" type="text/css" th:href="@{/bootstrap/css/bootstrap.min.css}" />
    <script type="text/javascript" th:src="@{/bootstrap/js/bootstrap.min.js}"></script>
    <!-- layer -->
    <script type="text/javascript" th:src="@{/layer/layer.js}"></script>
    <!-- common.js -->
    <script type="text/javascript" th:src="@{/js/common.js}"></script>
</head>
<body>
<div class="panel panel-default">
    <div class="panel-heading">秒杀订单详情</div>
    <table class="table" id="order">
        <tr>
            <td>商品名称</td>
            <td th:text="${goods.goodsName}" colspan="3"></td>
        </tr>
        <tr>
            <td>商品图片</td>
            <td colspan="2"><img th:src="@{${goods.goodsImg}}" width="200" height="200" /></td>
        </tr>
        <tr>
            <td>订单价格</td>
            <td colspan="2" th:text="${order.goodsPrice}"></td>
        </tr>
        <tr>
            <td>下单时间</td>
            <td th:text="${#dates.format(order.createDate, 'yyyy-MM-dd HH:mm:ss')}" colspan="2"></td>
        </tr>
        <tr>
            <td>订单状态</td>
            <td >
                <span th:if="${order.status eq 0}">未支付</span>
                <span th:if="${order.status eq 1}">待发货</span>
                <span th:if="${order.status eq 2}">已发货</span>
                <span th:if="${order.status eq 3}">已收货</span>
                <span th:if="${order.status eq 4}">已退款</span>
                <span th:if="${order.status eq 5}">已完成</span>
            </td>
            <td>
                <button class="btn btn-primary btn-block" type="submit" id="payButton">立即支付</button>
            </td>
        </tr>
        <tr>
            <td>收货人</td>
            <td colspan="2">XXX  18012345678</td>
        </tr>
        <tr>
            <td>收货地址</td>
            <td colspan="2">上海市浦东区世纪大道</td>
        </tr>
    </table>
</div>

</body>
</html>

测试 : 目前,简单版本的秒杀功能逻辑已经完成,后续进行优化阶段!

系统压测

JMeter 入门

自定义变量

正式压测

页面优化

缓存

页面静态化

服务优化

RabbitMQ 入门

RabbitMQ交换机

接口优化

安全优化

隐藏秒杀接口地址

图形验证码

接口限流

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值