# 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 extends Payload>[] 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);
```