# seckill **Repository Path**: johndriver/seckill ## Basic Information - **Project Name**: seckill - **Description**: 一个秒杀系统的搭建 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2021-05-19 - **Last Updated**: 2022-04-11 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 一个简单秒杀系统的搭建 ## 1.环境 ```xml org.springframework.boot spring-boot-starter-thymeleaf org.springframework.boot spring-boot-starter-web mysql mysql-connector-java runtime org.projectlombok lombok true org.springframework.boot spring-boot-starter-test test org.junit.vintage junit-vintage-engine com.baomidou mybatis-plus-boot-starter Latest Version ``` * spring web * thymeleaf * mysql driver * lombok * [mybatis-puls](https://mp.baomidou.com/guide/quick-start.html#添加依赖) ## 2.登录功能 ### 数据库 ```sql CREATE TABLE t_user( id BIGINT(20) PRIMARY KEY NOT NULL COMMENT '用户id,手机密码', nickname VARCHAR(255) NOT NULL , password VARCHAR(32) DEFAULT NULL COMMENT 'md5(md5(明文+固定salt)+salt)', salt VARCHAR(10) DEFAULT NULL, head VARCHAR(128) DEFAULT NULL COMMENT '头像', register_date DATETIME DEFAULT NULL COMMENT '注册时间', last_login_date DATETIME DEFAULT NULL COMMENT '最后一次登录时间', login_count INT(11) DEFAULT '0' COMMENT '登录次数' ) ``` ### 用户密码加密逻辑 1. 前端md5加密一次: `frompass = md5(inputpass+salt)` 2. 后端md5再次加密: `dbpass = md5(formpass+salt(随机))` * 页面发送账号密码 1. `jquery.validate.js` 参数校验 2. `md5.min.js` 加密 2. `jquery.min.js` 通过 `$.ajax` 真正发起请求 密码为加密密码 ### 用户账号密码校验`UserServiceImpl` ``` public ResultBean doLogin(LoginVo loginVo) { final String mobile = loginVo.getMobile(); final String password = loginVo.getPassword(); if(StringUtils.isEmpty(mobile)||StringUtils.isEmpty(password)){ return ResultBean.error(ResultBeanEnum.LOGIN_ERROT); } if(!ValidatorUtil.isMobile(mobile)){ return ResultBean.error(ResultBeanEnum.MOBILE_ERROT); } //根据手机号获取用户 final User user = userMapper.selectById(mobile); if(null == user){ return ResultBean.error(ResultBeanEnum.LOGIN_ERROT); } //校验密码 if(!MD5Util.formPassToDBPass(password,user.getSalt()).equals(user.getPassword())){ return ResultBean.error(ResultBeanEnum.LOGIN_ERROT); } return ResultBean.success(); } ``` 问题: 1.参数健壮性判断过于繁琐,不应出现太多在业务代码 解决:`spring-boot-starter-validation`可去掉 ``` if(StringUtils.isEmpty(mobile)||StringUtils.isEmpty(password)){ return ResultBean.error(ResultBeanEnum.LOGIN_ERROT); } if(!ValidatorUtil.isMobile(mobile)){ return ResultBean.error(ResultBeanEnum.MOBILE_ERROT); } ``` * `LoginController` 使用@Valid对入参进行校验 ``` public ResultBean doLogin(@Valid LoginVo loginVo){ return userService.doLogin(loginVo); } ``` ### `LoginVo` 使用注解实现校验规则 ```java public class LoginVo { @NotNull @IsMobile(message = "手机号码格式不正确") private String mobile; @NotNull @Length(min=32) private String password; } ``` * 自定义注解 `@IsMobile` 注解 `IsMobile` ```java @Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Constraint( // 指定自定义规则 validatedBy = {IsMobileValidator.class} ) public @interface IsMobile { boolean require() default true; String message() default "{手机号码格式错误}"; Class[] groups() default {}; Class[] payload() default {}; } ``` 校验规则类 `IsMobileValidator` ```java public class IsMobileValidator implements ConstraintValidator { private boolean required = false; @Override public void initialize(IsMobile constraintAnnotation) { required = constraintAnnotation.require(); } @Override public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) { if(required){ return ValidatorUtil.isMobile(value); }else { return StringUtils.isEmpty(value); } } } ``` 由于 `spring-boot-starter-validation` 抛出的异常不会给前端返回一返回对象 `ResultBean`,我们使用异常捕获处理,返回ResultBean给前端 ### 异常处理 1. @Valid 对应异常 `org.springframework.validation.BindException` 2. @RequestParam 对应异常 `javax.validation.ConstraintViolationException` 3. @RequestBody 对应异常 `org.springframework.web.bind.MethodArgumentNotValidException` 4. 自定义异常 ```java public class GlobalException extends RuntimeException{ private ResultBeanEnum resultBeanEnum; } ``` 使用 ``` if(null == user){ throw new GlobalException(ResultBeanEnum.LOGIN_ERROT); } ``` 异常处理类 捕获异常,把异常信息放入 `ResultBean` 返回json格式 ``` @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) public ResultBean handlerException(Exception e){ // 1.自定义异常的处理 if(e instanceof GlobalException){ GlobalException ex = (GlobalException)e; return ResultBean.error(ex.getResultBeanEnum()); }else if(e instanceof BindException){ // 2.@Valid 抛出异常的处理 BindException ex = (BindException)e; final ResultBean resultBean = ResultBean.error(ResultBeanEnum.BIND_ERROR); resultBean.setMessage("参数校验异常:"+ex.getBindingResult().getAllErrors().get(0).getDefaultMessage()); return resultBean; } return ResultBean.error(ResultBeanEnum.ERROR); // 3.其他异常的处理 } } ``` 为了方便调试可在异常进入处理类进行日志打印 ``` log.info(String.valueOf(e)); ``` ### 登录成功后跳转 `/goods/toList` login.html ```js $.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() } }) ``` * 跳转到页面前,cookie+session获取用户信息 ```java @Controller @RequestMapping("/goods") public class GoodsController { @RequestMapping("/toList") public String toList(HttpSession session, Model model, @CookieValue("userTicket") String ticket){ if(StringUtils.isEmpty(ticket)){ return "login"; } final User user = (User) session.getAttribute(ticket); if(null == user){ return "login"; } model.addAttribute("user",user); return "goodsList"; } } ``` ### 更换为分布式session > 这里session只适合单服务,如果是分布式服务,多个Tomcat,一次登陆只会在一个tomcat中生效,当被ngnix分到其他Tomcat 需要重新登录 1. spring-session 方案 ``` org.springframework.boot spring-boot-starter-data-redis org.apache.commons commons-pool2 org.springframework.session spring-session-data-redis ``` 配置redis ```yaml spring: redis: # 服务器 host: 192.168.144.129 # 端口 port: 6379 # 使用的数据库号(默认有16个) database: 0 # 超时时间 timeout: 10000ms lettuce: # 连接池 pool: # 最大连接数 max-active: 8 # 最大连接阻塞等待时间,默认-1 max-wait: 10000ms # 最大空闲连接数 默认8 max-idle: 200 # 最下空闲连接时 默认0 min-idle: 5 ``` 即可,session自动存入配置的redis服务器,自动从redis服务器中取 2.直接使用Redis来存取用户信息 * `RedisTemple` 操作Redis数据库的类 ```java @Component public class RedisConfig { @Bean public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){ final RedisTemplate template = new RedisTemplate<>(); //序列化设置 //键值序列化 template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); //hash键值序列化 template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); // 设置连接工厂 template.setConnectionFactory(redisConnectionFactory); return template; } } ``` * 使用 ``` @Autowired private RedisTemplate redisTemplate; ... redisTemplate.opsForValue().set("user:"+ticket,user); User user = (User) redisTemplate.opsForValue().get("user:" + userTicket); ``` ### 优化:用户登录判断 > > 目前每一个接口都要判断用户否登录,重复且繁琐 > > 解决:自定义 `WebMvcConfigurer` 添加 自定义方法参数处理器 `HandlerMethodArgumentResolver` 1.自定义 `WebMvcConfigurer` ```java @Configuration public class MebConfig implements WebMvcConfigurer { @Autowired private UserArgumentResolver userArgumentResolver; @Override public void addArgumentResolvers(List resolvers) { resolvers.add(userArgumentResolver); } } ``` 2.自定义方法参数处理器 `HandlerMethodArgumentResolver` * 第一个方法判断controller的方法参数有User就进入第二个方法 * 第二个方法为用户登录判断逻辑,把最终得到的User给controller的方法 ```java @Component public class UserArgumentResolver implements HandlerMethodArgumentResolver { @Autowired private IUserService userService; // 条件判断 判断是否使用下面的resolveArgument方法 @Override public boolean supportsParameter(MethodParameter methodParameter) { final Class clazz = methodParameter.getParameterType(); return clazz == User.class; } @Override public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception { final HttpServletRequest request = (HttpServletRequest) nativeWebRequest.getNativeRequest(); final HttpServletResponse response = (HttpServletResponse) nativeWebRequest.getNativeResponse(); final String ticket = CookieUtil.getCookieValue(request, "userTicket"); if(StringUtils.isEmpty(ticket)){ return null; } return userService.getUserByCookie(ticket,request,response); } } ``` ### 登录功能总结 1. 两次md5密码加密 2. 定义同一返回的类 `ResultBean` 3. 传参校验优化-使用 `@Valid` 4. `@Valid` 异常需转为 `ResultBean` 异常处理 @RestControllerAdvice+@ExceptionHandler 5. 分布式Session:Spring Session 或 Redis 6. 用户登录校验- `WebMvcConfigurer` ## 3.秒杀功能 ### 数据库表 * 商品表 ```sql CREATE TABLE `t_goods` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '商品id', `goods_name` varchar(16) DEFAULT NULL COMMENT '商品名称', `goods_title` varchar(64) DEFAULT NULL COMMENT '商品标题', `goods_img` varchar(64) DEFAULT NULL COMMENT '商品图片', `gooos_detail` longtext COMMENT '商品详情', `goods_price` decimal(10,2) DEFAULT '0.00' COMMENT '商品价格', `goods_stock` int(11) DEFAULT '0' COMMENT '商品库存', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4; ``` 订单表 ```sql CREATE TABLE `t_order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '订单id', `user_id` bigint(20) DEFAULT NULL COMMENT '用户id', `goods_id` bigint(20) DEFAULT NULL COMMENT '商品id', `deliver_addr_id` bigint(20) DEFAULT NULL COMMENT '收货地址id', `gooos_name` varchar(16) DEFAULT NULL COMMENT '冗余过来的商品名称', `goods_price` decimal(10,2) DEFAULT '0.00' COMMENT '商品价格', `goods_count` int(11) DEFAULT '0' COMMENT '商品单价', `order_channel` tinyint(4) DEFAULT '0' COMMENT '订单渠道 1pc 2android 3ios', `status` tinyint(4) DEFAULT '0' COMMENT '订单状态 0新建未支付,1已经支付 2已发货 3已收货 4 已完成', `create_date` datetime DEFAULT NULL COMMENT '订单创建时间', `pay_date` datetime DEFAULT NULL COMMENT '支付时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4; ``` 秒杀商品表 ```sql CREATE TABLE `t_seckill_goods` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '秒杀商品id', `goods_id` bigint(20) DEFAULT NULL COMMENT '商品id', `seckill_price` decimal(10,2) DEFAULT '0.00' COMMENT '秒杀价', `stock_count` int(10) DEFAULT NULL COMMENT '库存数量', `start_date` datetime DEFAULT NULL COMMENT '秒杀开始时间', `end_date` datetime DEFAULT NULL COMMENT '秒杀结束时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4; ``` 秒杀订单表 ```sql CREATE TABLE `t_seckill_order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '秒杀订单id', `user_id` bigint(20) DEFAULT NULL COMMENT '用户id', `goods_id` bigint(20) DEFAULT NULL COMMENT '商品id', `order_id` bigint(20) DEFAULT NULL COMMENT '订单id', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4; ``` ### 秒杀状态js函数 `goodsDetail.html` ```javascript function countDown() { var remainSecons = $("#remainSeconds").val(); // 秒杀还未进行时 if(remainSecons>0){ $("#buyButton").attr("disabled",true) timeout = setTimeout( function () { $("#countDown").text(remainSecons-1); $("#remainSeconds").val(remainSecons-1); countDown(); }, 1000 ) //秒杀进行中 }else if(remainSecons==0){ $("#buyButton").attr("disabled",false) if(timeout){ clearTimeout(timeout) } $("#seckillTip").html("秒杀进行中") }else { $("#buyButton").attr("disabled",true) $("#seckillTip").html("秒杀已结束") } } ``` setTimeout()函数完成秒杀倒计时 ### 简单秒杀逻辑 `SecKillController` 1. 入参 user goodsid 2. goodsid 取goods->判断库存 3. user.id 和 goodid 查询历史SeckillOrder记录->判断是否重复购买 4. 秒杀:1.减一个秒杀商品表的库存数 2.生成商品订单 3.生成秒杀商品订单 5. 给前端用户对象,商品对象,订单对象 ### 页面缓存 * 对商品列表,商品详情变化少的页面缓存到redis * 实现:原来thymeleaf由springboot自己渲染,现在自己渲染,存到redis,并返回,再次取从redis取渲染好的页面即可 ```text @RequestMapping(value = "/toList",produces = "text/html;charset=utf-8") @ResponseBody public String toList(Model model, User user, HttpServletRequest request, HttpServletResponse response){ //Regis获取页面,非空,直接返回 ValueOperations valueOperations = redisTemplate.opsForValue(); String html = (String) valueOperations.get("goodsList"); if(!StringUtils.isEmpty(html)){ return html; } model.addAttribute("user",user); model.addAttribute("goodsList",goodsService.findGoodsVo()); //redis没有就手动渲染,存入redis并返回 //return "goodsList"; WebContext webContext = new WebContext(request,response,request.getServletContext(),request.getLocale(),model.asMap()); html = thymeleafViewResolver.getTemplateEngine().process("goodsList", webContext); if(!StringUtils.isEmpty(html)){ valueOperations.set("goodaList",html,60, TimeUnit.SECONDS); } return html; } ``` 1. @RequestMapping(value = "/toList",produces = "text/html;charset=utf-8") 2. @ResponseBody 3. 自定义渲染: ```text WebContext webContext = new WebContext(request,response,request.getServletContext(),request.getLocale(),model.asMap()); html = thymeleafViewResolver.getTemplateEngine().process("goodsList", webContext); ```