1.数据库设计
秒杀用户表
-- 秒杀用户表
CREATE TABLE miaosha_user
(
id bigint(20) NOT NULL COMMENT '用户ID,手机号码',
nickname varchar(255) NOT NULL,
password varchar(32) DEFAULT NULL COMMENT 'MD5(MD5(pass明文+固定salt) salt)',
salt varchar(10) DEFAULT NULL,
head varchar(128) DEFAULT NULL COMMENT '头像,云存储的ID',
register_date datetime DEFAULT NULL COMMENT '注册时间',
last_login_date datetime DEFAULT NULL COMMENT '上登录时间',
login_count int(11) DEFAULT 0 COMMENT '录次数',
PRIMARY KEY (id)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 COMMENT ='用户表';
alter table miaosha_user
COMMENT ='秒杀用户表';
2.明文密码两次MD5加密
两次MD5
1.用户端:PASS=MD5(明文+固定Salt)
2.服务端:PASS=MD5(用户输入+随机Salt)
第一次加密防止用户明文密码在网络上传输,第二次加密防止数据库被盗后将一次加密反向破解
引入依赖commons-codec和commons-lang3,使用commons-codec依赖下的DigestUtils类的md5Hex方法加密处理
安全起见,使用固定salt(就是一个字符串)中的部分字符与密码拼接后进行第一次加密
第二次加密使用随机salt,通过入参salt来传递
引入MD5依赖
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.6</version>
</dependency>
util中MD5Util.java
//用户端的MD5加密
public class MD5Util {
public static String md5(String str){
return DigestUtils.md5Hex(str);
}
//固定salt,
private static final String salt = "1a2b3c4d";
//md5网页端
public static String inputPassToFormPass(String inputPass) {
String str = ""+salt.charAt(0)+salt.charAt(2) + inputPass +salt.charAt(5) + salt.charAt(4);
System.out.println(str);
return md5(str);
}
}
//服务端的MD5加密,存放到数据库
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 saltDB) {
String formPass = inputPassToFormPass(inputPass);
String dbPass = formPassToDBPass(formPass, saltDB);
return dbPass;
}
JS中的代码
<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/do_login",
type: "POST",
data:{
mobile:$("#mobile").val(),
password: password
},
success:function(data){
layer.closeAll();
if(data.code == 0){
layer.msg("成功");
window.location.href="/goods/to_list";
}else{
layer.msg(data.msg);
}
},
error:function(){
layer.closeAll();
}
});
}
</script>
验证手机号
public class ValidatorUtil {
private static final Pattern mobile_pattern = Pattern.compile("1\\d{10}");
public static boolean isMobile(String src) {
if(StringUtils.isEmpty(src)) {
return false;
}
Matcher m = mobile_pattern.matcher(src);
return m.matches();
}
3.JSR303参数检验+全局异常处理器
1.为了避免大量重复的判断代码出现在Controller中
引入POM依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
2、LoginController.java 删除之前的参数校验部分,在doLogin方法的LoginVo类型入参前加上@Valid注解,该注解在validation-api依赖下,然而该依赖并没有手动引入,可能是新建SpringBoot项目时自动引入了。
3、LoginVo.java 在password变量前添加@NotNull注解,@Length注解;在mobile变量前添加@NotNull注解,@IsMobile注解,其中@NotNull和@Length在validation-api依赖下,@IsMobile注解为接下来要自定义的注解。
4、IsMobile.java 新建注解,拷贝@NotNull注解中的部分内容,仿照着@NotNull注解来写。在拷贝过来的@Constraint(validateBy={})中添加将要新建的IsMobileValidator.class类,做为校验器,改完之后为@Constraint(validateBy={IsMobileValidator.class})。添加boolean required() default true; 表示标注该注解的变量必需要传值。
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
@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 { };
}
5、IsMobileValidator.class 新建类,作为校验器,需要实现ConstraintValidator<A, T>接口,A处就填IsMobile,T处填了String。实现父接口中的两个方法,initialize和isValid方法。
public class IsMobileValidator implements ConstraintValidator<IsMobile, String> {
private boolean required = false;
public void initialize(IsMobile constraintAnnotation) {
required = constraintAnnotation.required();
}
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);
}
}
}
}
6、运行测试,浏览器检查代码的Netword部分会提示需要的信息和异常信息。异常信息需要捕获,需要的信息才可以正常提示,见下节。
异常处理
标题:异常处理
1、GlobalExceptionHandler.java 新建类,添加exceptionHandler方法,该方法需标注@ExceptionHandler(value = Exception.class) 表示要拦截所有的异常,方法体内先拦截刚才的BindException,步骤依次为异常类型强转,获取所有错误信息,拿到第一个错误,拿到具体错误信息,最后拼接完后返回。拼接调用接下来的CodeMsg.java类中的fillArgs方法。
@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {
@ExceptionHandler(value=Exception.class)
//HttpServletRequest request, Exception e都是系统传过来的
public Result<String> exceptionHandler(HttpServletRequest request, Exception e){
e.printStackTrace();
if(e instanceof GlobalException) {
GlobalException ex = (GlobalException)e;
return Result.error(ex.getCm());
}else if(e instanceof BindException) {
BindException ex = (BindException)e;
//参数校验有可能有很多错误
List<ObjectError> errors = ex.getAllErrors();
//我们在这里只获取第一个
ObjectError error = errors.get(0);
String msg = error.getDefaultMessage();
return Result.error(CodeMsg.BIND_ERROR.fillArgs(msg));
}else {
return Result.error(CodeMsg.SERVER_ERROR);
}
}
}
2、CodeMsg.java 添加绑定异常静态常量BIND_ERROR,注意他的msg参数处留一个位置。添加fillArgs方法,供第一步拼接时调用。
public static CodeMsg BIND_ERROR = new CodeMsg(500101, "参数校验异常:%s");
public CodeMsg fillArgs(Object... args) {
int code = this.code;
String message = String.format(this.msg, args);
return new CodeMsg(code, message);
}
写在最后:这样,浏览器中的异常信息,经过上述代码就可以捕获并处理,最终友好的显示在浏览器页面。
标题:异常处理优化
0、导语:之前MiaoshaUserService.java类中login方法的返回值为CodeMsg类型,但是应该返回表达业务方法含义的方法,而不应该是CodeMsg类型。所以我们可以定义一个全局异常类,将异常直接抛出去,交给异常处理器处理。
1、GlobalException.java 新建异常类,封装CodeMsg类型变量,供抛出时实例化使用。
public class GlobalException extends RuntimeException{
private static final long serialVersionUID = 1L;
private CodeMsg cm;
public GlobalException(CodeMsg cm) {
super(cm.toString());
this.cm = cm;
}
public CodeMsg getCm() {
return cm;
}
}
2、MiaoshaUserService.java 修改login方法返回类型为boolean,方法体内的错误直接通过实例化GlobalException类,并入参CodeMsg类型的静态常量,来将异常抛出去。
3、GlobalExceptionHandler.java 修改exceptionHandler方法,增加处理GlobalException异常的逻辑。
4、LoginController.java 修改doLogin方法,优化登录逻辑。
@Controller
@RequestMapping("/login")
public class LoginController {
private static Logger log = LoggerFactory.getLogger(LoginController.class);
@Autowired
MiaoshaUserService userService;
@Autowired
RedisService redisService;
@RequestMapping("/to_login")
public String toLogin() {
return "login";
}
@RequestMapping("/do_login")
@ResponseBody
public Result<Boolean> doLogin(HttpServletResponse response, @Valid LoginVo loginVo) {
log.info(loginVo.toString());
//登录
userService.login(response, loginVo);
return Result.success(true);
}
}
写在最后:方法的返回类型要根据业务方法含义来判断,方法体内遇到的错误可以通过自定义异常封装类和异常处理类来拦截再处理。
4.分布式Session
用户登陆成功之后,给用户分配一个类似于SessionID的东西,取名为token。来标识用户,存到Cookie中传递给客户端,客户端在随后的访问中都在Cookie中上传这个token。服务端拿到这个token之后,就用这个token取到用户对应的Session信息。
1.首先需要一个UUIDUtil工具类
public class UUIDUtil {
public static String uuid() {
return UUID.randomUUID().toString().replace("-", "");
}
}
2.把私人信息存放到第三方缓存当中
使用工具类生成随机的token,把token写进cookie中,传递到客户端。但是需要表示token所对应的用户,所以需要把用户信息写入到redis中。
引入RedisService ,。。。写入到response中。。。。
登陆成功之后跳转到商品列表页,token需要携带到过去,需要设置session的有效时间
@Service
public class MiaoshaUserService {
public static final String COOKI_NAME_TOKEN = "token";
@Autowired
MiaoshaUserDao miaoshaUserDao;
@Autowired
RedisService redisService;
public MiaoshaUser getById(long id) {
return miaoshaUserDao.getById(id);
}
public MiaoshaUser getByToken(HttpServletResponse response, String token) {
if(StringUtils.isEmpty(token)) {
return null;
}
MiaoshaUser user = redisService.get(MiaoshaUserKey.token, token, MiaoshaUser.class);
//延长有效期
if(user != null) {
addCookie(response, token, user);
}
return user;
}
public boolean login(HttpServletResponse response, LoginVo loginVo) {
if(loginVo == null) {
throw new GlobalException(CodeMsg.SERVER_ERROR);
}
String mobile = loginVo.getMobile();
String formPass = loginVo.getPassword();
//判断手机号是否存在
MiaoshaUser user = getById(Long.parseLong(mobile));
if(user == null) {
throw new GlobalException(CodeMsg.MOBILE_NOT_EXIST);
}
//验证密码
String dbPass = user.getPassword();
String saltDB = user.getSalt();
String calcPass = MD5Util.formPassToDBPass(formPass, saltDB);
if(!calcPass.equals(dbPass)) {
throw new GlobalException(CodeMsg.PASSWORD_ERROR);
}
//生成cookie
String token = UUIDUtil.uuid();
//把cookie加入到客户端中
addCookie(response, token, user);
return true;
}
private void addCookie(HttpServletResponse response, String token, MiaoshaUser user) {
redisService.set(MiaoshaUserKey.token, token, user);
Cookie cookie = new Cookie(COOKI_NAME_TOKEN, token);
cookie.setMaxAge(MiaoshaUserKey.token.expireSeconds());
cookie.setPath("/");
response.addCookie(cookie);
}
}
3.登陆成功之后跳转到商品列表页
GoodsController中的代码
@RequestMapping("/to_list")
//取到cookie的值
/*
为了兼容手机客户端,并不会把token放到cookie中传递到客户端
会直接放到参数中
*/
public String toLogin(Model model,
@CookieValue(value = MiaoshaUserService.COOKI_NAME_TOKEN, required = false) String cookieToken,
@RequestParam(value = MiaoshaUserService.COOKI_NAME_TOKEN, required = false) String paramToken) {
if (StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)) {
return "login";
}
String token = StringUtils.isEmpty(paramToken) ? cookieToken : paramToken;
MiaoshaUser user = userService.getByToken(token);
model.addAttribute("user", user);
return "goods_list";
}
MiaoshaUserService中的代码
public MiaoshaUser getByToken(String token) {
if(StringUtils.isEmpty(token)) {
return null;
}
return redisService.get(MiaoshaUserKey.token, token, MiaoshaUser.class);
}
成功的把token映射成一个用户,redis来单独管理session
3.优化代码
session有效时间,始终是最后一次登陆加上有效时间
改写MiaoshaUser中的getByToken方法,延长有效期
public MiaoshaUser getByToken(HttpServletResponse response, String token) {
if(StringUtils.isEmpty(token)) {
return null;
}
MiaoshaUser user = redisService.get(MiaoshaUserKey.token, token, MiaoshaUser.class);
//延长有效期
if(user != null) {
addCookie(response, token, user);
}
return user;
}
GoodsController中的代码过于复杂
UserArgumentResolver.java
@Service
public class UserArgumentResolver implements HandlerMethodArgumentResolver {
@Autowired
MiaoshaUserService userService;
public boolean supportsParameter(MethodParameter parameter) {
Class<?> clazz = parameter.getParameterType();
return clazz== MiaoshaUser.class;
}
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 paramToken = request.getParameter(MiaoshaUserService.COOKI_NAME_TOKEN);
String cookieToken = getCookieValue(request, MiaoshaUserService.COOKI_NAME_TOKEN);
if(StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)) {
return null;
}
String token = StringUtils.isEmpty(paramToken)?cookieToken:paramToken;
return userService.getByToken(response, token);
}
private String getCookieValue(HttpServletRequest request, String cookiName) {
Cookie[] cookies = request.getCookies();
for(Cookie cookie : cookies) {
if(cookie.getName().equals(cookiName)) {
return cookie.getValue();
}
}
return null;
}
}
WebConfig.java
@Configuration
public class WebConfig extends WebMvcConfigurerAdapter{
@Autowired
UserArgumentResolver userArgumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(userArgumentResolver);
}
}
最终GoodsController中的代码
@Controller
@RequestMapping("/goods")
public class GoodsController {
@Autowired
MiaoshaUserService userService;
@Autowired
RedisService redisService;
@RequestMapping("/to_list")
public String list(Model model, MiaoshaUser user) {
model.addAttribute("user", user);
return "goods_list";
}