开发Kaptcha验证码功能

说明:

(1) 本篇博客内容说明:

●【在【图书详情页开发之显示评论列表】中,我们实现了在图书详情页显示短评内容】→【这些短评需要添加,给短评点赞,和,写短评的功能】→【为了完成点赞和写短评功能,我们首先需要登录】→【一般来说,我们在登录或注册时,安全考虑,需要使用验证码】→【因此,本篇博客先介绍一款常用的一款验证码工具Kaptcha】

● 然后,本篇博客仅仅单独开发了Kaptcha的功能;与前台界面的交互工作尚未开发(这在下篇博客中会介绍);


一:验证码简述;

在一个系统中,注册和登录的时候,一般都要添加验证码;主要目的是进行人机校验,防止脚本恶意注册和登录;

验证码有很多种,比如图形验证码、字符验证码、滑块验证码;

这儿我们先学习比较简单的字符验证码;


二:验证码生成工具:Kaptcha:简述;

1.Kaptcha简介;

说明:

(0) Kaptcha是行业中非常注明的验证码生成工具;

(3) 比如,银行在线申请信用卡,就需要添加验证码,以确保是一个真实的人在操作;

2.Kaptcha使用步骤;

说明:

(1) 验证码生成参数包括生成的字体、大小、颜色等;

(2) 开发kaptchaController,用于生成验证码的图片;

(3) 实际使用验证码时,将【前台输入的验证码】和【后台保存在Tomcat的Session中的验证码】比对;


三:在项目中使用Kaptcha;

1.在pom.xml中,引入Kaptcha依赖;

             <!--引入Kaptcha验证码依赖-->
             <dependency>
                 <groupId>com.github.penggle</groupId>
                 <artifactId>kaptcha</artifactId>
                 <version>2.3.2</version>
             </dependency>

老生常谈的问题:引入新的依赖后,如有需要记得要把这个依赖添加到发布中去;

2.在applicationContext.xml配置文件中,配置Kaptcha;

    <!--配置Kaptcha验证码-->
    <bean id="kaptchaProducer" class="com.google.code.kaptcha.impl.DefaultKaptcha">
        <property name="config">
            <bean class="com.google.code.kaptcha.util.Config">
                <constructor-arg>
                    <props>
                        <!--验证码图片不生成边框-->
                        <prop key="kaptcha.border">no</prop>
                        <!--验证码图片宽度为120像素-->
                        <prop key="kaptcha.image.width">120</prop>
                        <!--验证码图片中字符颜色为蓝色-->
                        <prop key="kaptcha.textproducer.font.color">blue</prop>
                        <!--验证码图片中的单个字符最大占用40像素;(如果图片长度不够的话,其会自动适当缩小字体大小)-->
                        <prop key="kaptcha.textproducer.font.size">40</prop>
                        <!--验证码图片,包含4个字符-->
                        <prop key="kaptcha.textproducer.char.length">4</prop>
                    </props>
                </constructor-arg>
            </bean>
        </property>
    </bean>

说明:

(1) 配置说明;

(2) 上面的配置结果是:在实际生成验证码图片时,会生成一个没有边框、总长度为120像素、字体为蓝色、单个字符最大占用40像素(如果总长度不够,字符会自动缩小)、每个验证码包含4个字符的图片;

2.创建KaptchaController控制器;

(这个Controller类的名字,可以随便起)

KaptchaController类:

package com.imooc.reader.controller;
 
import com.google.code.kaptcha.Producer;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
 
import javax.annotation.Resource;
import javax.imageio.ImageIO;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.IOException;
 
@Controller
public class KaptchaController {
    @Resource
    private Producer kaptchaProducer;//注入我们在applicationContext.xml中定义的<bean>
 
    @GetMapping("/verify_code")
    public void createVerifyCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
        response.setDateHeader("Expires", 0);//设置过期时间,0代表响应立即过期;
        response.setHeader("Cache-Control", "no-store,no-cache,must-revalidate");
        response.setHeader("Cache-Control", "post-check=0,pre-check=0");
        response.setHeader("Pragma", "no-cache");
 
        response.setContentType("image/png");
 
        //生成验证码字符文本
        String verifyCode = kaptchaProducer.createText();
        request.getSession().setAttribute("kaptchaVerifyCode", verifyCode);
        System.out.println(request.getSession().getAttribute("kaptchaVerifyCode"));
        BufferedImage image = kaptchaProducer.createImage(verifyCode);//创建 验证码图片
        ServletOutputStream out = response.getOutputStream();
        ImageIO.write(image, "png", out);
        out.flush();
        out.close();
    }
 
 
}

说明:

(1) 说明1;

(2) 说明2;

(3) 说明3;

(4) 设置这个方法的url;

(5) 效果,启动Tomcat,访问【“/verify_code”】:发现我们的Kaptcha验证码没问题;


我们开发好了Kaptcha验证码后,具体要把这个东西应用项目中,就需要涉及到与前台界面的交互工作了,这在下篇博客中介绍;

Kaptcha验证码功能应用到注册模块

说明:

(1) 本篇博客的内容:本篇博客主要是把开发的验证码功能,应用到注册模块上;只是开发到了这一步,注册模块的完整业务逻辑并没有开发;

(2) 本篇博客合理性说明:【我们开发了Kaptcha验证码】→【但是,这个验证码,需要和前台界面(注册和登录页)联系在一起后,才有实际使用的价值】→【然后,本篇博客结合开发注册功能时,把前面开发的Kaptcha验证码给用上】

(3) 本篇博客遇到的点:

● 遇到了【在访问get请求时,在url后面增加ts时间戳,用来解决浏览器缓存可能带来的问题】;

● 本篇博客通过【data:$(“#frmLogin”).serialize()】,把表单的数据给序列化了;serialize()方法是JQuery中的ajax方法,其作用是编码表单元素集为字符串以便提交;然后,自己想起了【后台系统FileUpload组件】这篇博客;这篇博客主要内容是【提交的表单的时候,涉及到了文件上传】,然后我们通过【enctype=“multipart/form-data”】设置表单的编码方式,以便能够上传文件;

● 在本案例中,我们把验证码的校验工作放在了Controller层;而没有放任到Service层;这应该是个普遍采用的开发习惯和规则;


一:在项目中,引入注册页的前端文件:register.ftl;

register.ftl初始内容:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>会员注册-MK书评网</title>
    <meta name="viewport" content="width=device-width,initial-scale=1.0, maximum-scale=1.0,user-scalable=no">
 
	<link rel="stylesheet" href="./resources/bootstrap/bootstrap.css">
    <link rel="stylesheet" href="./resources/raty/lib/jquery.raty.css">
    <script src="./resources/jquery.3.3.1.min.js"></script>
    <script src="./resources/bootstrap/bootstrap.min.js"></script>
    <style>
        .container {
            padding: 0px;
            margin: 0px;
        }
 
        .row {
            padding: 0px;
            margin: 0px;
        }
 
        .col- * {
            padding: 0px;
        }
 
        .description p {
            text-indent: 2em;
        }
 
        .description img {
            width: 100%;
        }
 
    </style>
 
</head>
<body>
<!--<div style="width: 375px;margin-left: auto;margin-right: auto;">-->
<div class="container ">
    <nav class="navbar navbar-light bg-white shadow">
        <ul class="nav">
            <li class="nav-item">
                <a href="/">
                    <img src="https://m.imooc.com/static/wap/static/common/img/logo2.png" class="mt-1"
                         style="width: 100px">
                </a>
            </li>
        </ul>
    </nav>
 
 
    <div class="container mt-2 p-2 m-0">
        <form id="frmLogin">
            <div class="passport bg-white">
                <h4 class="float-left">会员注册</h4>
                <h6 class="float-right pt-2"><a href="/login.html">会员登录</a></h6>
                <div class="clearfix"></div>
                <div class="alert d-none mt-2" id="tips" role="alert">
 
                </div>
 
                <div class="input-group  mt-2 ">
                    <input type="text" id="username" name="username" class="form-control p-4" placeholder="请输入用户名">
                </div>
 
                <div class="input-group  mt-4 ">
                    <input id="password" name="password" class="form-control p-4" placeholder="请输入密码" type="password">
                </div>
 
                <div class="input-group  mt-4 ">
                    <input type="text" id="nickname" name="nickname" class="form-control p-4" placeholder="请输入用户名"
                    >
                </div>
 
                <div class="input-group mt-4 ">
                    <div class="col-5 p-0">
                        <input type="text" id="verifyCode" name="vc" class="form-control p-4" placeholder="验证码">
                    </div>
                    <div class="col-4 p-0 pl-2 pt-0">
						<!-- 验证码图片 -->
                        <img id="imgVerifyCode" src=""
                             style="width: 120px;height:50px;cursor: pointer">
                    </div>
 
                </div>
 
                <a id="btnSubmit" class="btn btn-success  btn-block mt-4 text-white pt-3 pb-3">注    册</a>
            </div>
        </form>
 
    </div>
</div>
 
 
<!-- Modal -->
<div class="modal fade" id="exampleModalCenter" tabindex="-1" role="dialog" aria-labelledby="exampleModalCenterTitle" aria-hidden="true">
    <div class="modal-dialog modal-dialog-centered" role="document">
        <div class="modal-content">
            <div class="modal-body">
                您已注册成功
            </div>
            <div class="modal-footer">
                <a href="/login.html" type="button" class="btn btn-primary">去登录</a>
            </div>
        </div>
    </div>
</div>
 
 
<script>
	//控制错误信息的显示与隐藏
    function showTips(isShow, css, text) {
        if (isShow) {
            $("#tips").removeClass("d-none")
            $("#tips").hide();
            $("#tips").addClass(css);
            $("#tips").text(text);
            $("#tips").fadeIn(200);
        } else {
            $("#tips").text("");
            $("#tips").fadeOut(200);
            $("#tips").removeClass();
            $("#tips").addClass("alert")
        }
    }
	//重新发送请求,刷新验证码
    function reloadVerifyCode(){
        //请在这里实现刷新验证码
    }
 
	//点击验证码图片刷新验证码
    $("#imgVerifyCode").click(function () {
        reloadVerifyCode();
    });
 
	//点击提交按钮,向/registe发起ajax请求
	//提交请求包含四个参数
	//vc:前台输入验证码  username:用户名 password:密码 nickname:昵称
    $("#btnSubmit").click(function () {
		//表单校验
        var username = $.trim($("#username").val());
        var regex = /^.{6,10}$/;
        if (!regex.test(username)) {
            showTips(true, "alert-danger", "用户名请输入正确格式(6-10位)");
            return;
        } else {
            showTips(false);
        }
 
        var password = $.trim($("#password").val());
 
        if (!regex.test(password)) {
            showTips(true, "alert-danger", "密码请输入正确格式(6-10位)");
            return;
        } else {
            showTips(false);
        }
 
        $btnReg = $(this);
 
        $btnReg.text("正在处理...");
        $btnReg.attr("disabled", "disabled");
 
		//发送ajax请求
        $.ajax({
            url: "/registe",
            type: "post",
            dataType: "json",
            data: $("#frmLogin").serialize(),
            success: function (data) {
				//结果处理,根据服务器返回code判断服务器处理状态
				//服务器要求返回JSON格式:
				//{"code":"0","msg":"处理消息"}
                console.info("服务器响应:" , data);
                if (data.code == "0") {
					//显示注册成功对话框
                    $("#exampleModalCenter").modal({});
                    $("#exampleModalCenter").modal("show");
                } else {
					//服务器校验异常,提示错误信息
                    showTips(true, "alert-danger", data.msg);
                    reloadVerifyCode();
                    $btnReg.text("注    册");
                    $btnReg.removeAttr("disabled");
                }
            }
        });
        return false;
    });
 
 
</script>
</body>
</html>

二:开发;

1.创建MemberController类:开发【访问注册页的入口方法】;(也就是,开发register.ftl注册页的访问url)

MemberController类是会员控制器类。因为用户注册和用户登录,都是与会员相关的功能;所以,注册和登录相关的内容,都可以放在这个类中;

MemberController类:

package com.imooc.reader.controller;
 
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.servlet.ModelAndView;
 
/**
 * member会员Controller
 */
@Controller
public class MemberController {
    /**
     * 跳转显示register.ftl注册页;
     * @return
     */
    @GetMapping("/register.html")
    public ModelAndView showRegister() {
        return new ModelAndView("/register");
    }
}

说明:

(1) 类内容说明;

(2) 这样以后,比如这儿,我们通过【localhost/register.html】就能够访问到注册页了;

2.在注册页上,显示验证码;

重启Tomcat,观察效果:

3.点击验证码图片时,更换验证码;

这就需要我们编写 img id=“imgVerifyCode” 这个验证码图片所在的img标签的单击事件了;

重启Tomcat,观察效果:

4.注册页点击【注册】按钮后,提交注册表单信息;(这儿目前,专注于验证码信息的提交)

在前台我们填写了验证码,提交了以后,将其与服务器端Session中的验证码,进行比对;如果一致,校验通过;如果不一致,就表示输入的验证码是不对的;

说明:

(1) 这儿我们通过【data:$(“#frmLogin”).serialize(),】:把表单的数据给序列化了;serialize()方法是JQuery中的ajax方法,其作用是编码表单元素集为字符串以便提交;(如有需要可以参考【jQuery中Ajax函数:.post()、$.get()的使用、区别;】)

(2) 然后,上面的表单serialize()序列化;让自己想起了【后台系统FileUpload组件】这篇博客;这篇博客主要内容是【提交的表单的时候,涉及到了文件上传】,然后我们通过【enctype=“multipart/form-data”】设置表单的编码方式,以便能够上传文件;

(3) 接下来,我们要做的就是开发一个处理注册请求的Controller(并且该Controller处理注册的方法的url需要是“/registe”);

5.前端点击【注册】发起注册请求后,后端处理请求;(这儿目前,只专注于验证码的校验)

在MemberController这个用于处理用户的Controller类中,添加处理注册的方法;

package com.imooc.reader.controller;
 
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.ModelAndView;
 
import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;
 
/**
 * member会员Controller
 */
@Controller
public class MemberController {
 
    @PostMapping("/registe")
    @ResponseBody
    public Map registe(String vc, String username, String password, String nickname, HttpServletRequest request) {
        Map result = new HashMap();
        //首先从Session会话对象中,获取存储在后端的验证码
        String verifyCode = (String) request.getSession().getAttribute("kaptchaVerifyCode");
        //如果【前端没有输入验证码】或者【当前Session中没有验证码】或者【前端输入的验证码和后端的验证码,在忽略大小写后不一致】:表示验证失败;
        if (vc == null || verifyCode == null ||!vc.equalsIgnoreCase(verifyCode)) {
            result.put("code", "VC01");
            result.put("msg", "验证码错误");
        } else {
            result.put("code", "0");
            result.put("msg", "success处理成功");
        }
        return result;
    }
}

说明:

(1) 后端的url和方式,需要和前端对应上;

(2) 后端使用方法参数的方式,接收前端数据时,参数名需要写对;(如有需要可以参考【后台Controller使用【方法参数】接收【前端传过来的,请求中的数据】

(3) 上篇博客提到过:如果需要HttpServletRequest或者HttpServletResponse的话,直接在方法参数中写上就行,Spring IoC容器会帮我们注入;

(4) 后台处理后,需要根据前端要求返回合适的信息;

(5) 这个方法直接返回了Map对象,但其可以被序列化为JSON字符串; 通过Json序列化可知,当我们在Spring MVC中使用jackson时,服务器端Controller中的方法,直接返回实体对象就可以了,jackson会帮我们把对象序列化为JSON;

(6) 效果:启动Tomcat,观察效果:分别输入正确验证码和错误的验证码,其是OK的;


至此,在注册这块,我们就成功的把验证码功能给引入了;下面我们就是去实现注册的完整业务逻辑了;

完成会员注册功能

说明:

(1) 本篇博客合理性说明:【我们把Kaptcha验证码应用到了注册功能上】→【然后,本篇博客就彻底完成会员注册功能】;

(2) 本篇博客遇到的点:我们在实现注册功能的时候,有些部分需要考虑:【开启事务】、【判断用户名是否重复,这儿包括了自定义异常】、【密码的MD5和加盐混淆】、【java.util.Date】【java.sql.Date的转换说明】等;

(3) 其实,本文最重要的就是熟悉【注册,这种业务逻辑】;


零:前置的一点说明;


一:开发Service;

1.创建,MemberService接口;

MemberService接口:

 
     package com.imooc.reader.service;
 
     import com.imooc.reader.entity.Member;
 
     public interface MemberService {
         /**
          * 会员注册,创建新会员
          * @param username 用户名
          * @param password 密码
          * @param nickname 昵称
          * @return
          */
         public Member createMember(String username, String password, String nickname);
     }
 

2.创建,MemberServiceImpl实现类;

然后,创建其实现类;可以通过【Alt + Enter】快捷键来快速创建;

 
     package com.imooc.reader.service.impl;
 
     import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
     import com.imooc.reader.entity.Member;
     import com.imooc.reader.mapper.MemberMapper;
     import com.imooc.reader.service.MemberService;
     import com.imooc.reader.service.exception.BussinessException;
     import com.imooc.reader.utils.MD5Utils;
     import org.springframework.stereotype.Service;
     import org.springframework.transaction.annotation.Transactional;
 
     import javax.annotation.Resource;
     import java.util.Date;
     import java.util.List;
     import java.util.Random;
 
     @Service("memberService")
     @Transactional
     public class MemberServiceImpl implements MemberService {
         @Resource
         private MemberMapper memberMapper;
 
         /**
          * 会员注册,创建新会员
          *
          * @param username 用户名
          * @param password 密码
          * @param nickname 昵称
          * @return
          */
         public Member createMember(String username, String password, String nickname) {
             QueryWrapper<Member> queryWrapper = new QueryWrapper<Member();
             queryWrapper.eq("username", username);
             List<Member> memberList = memberMapper.selectList(queryWrapper);
             if (memberList.size() > 0) {
                 throw new BussinessException("M01", "用户名已存在");
             }
             Member member = new Member();
             member.setUsername(username);
             member.setNickname(nickname);
             int salt = new Random().nextInt(1000) + 1000;
             String md5 = MD5Utils.md5Digest(password, salt);
             member.setPassword(md5);
             member.setSalt(salt);
             member.setCreateTime(new Date());
             memberMapper.insert(member);
             return member;
         }
     }

说明:

(1) 首先,Service实现类在IoC容器中的beanId,需要与接口保持一致;;;然后,因为这个会员的实现类,其主要的操作是与会员相关的功能,比如会员注册、登录、评价、点赞等功能;而这些功能大多是“写操作”;所以,我们配置这个类的声明式事务,默认类中的方法全部开启事务;

(2) 因为我们要求新创建的会员的username,不能与已经存在的username重复;所以,我们首先要检查一下;(这是业务逻辑层面的需要)

(3) 上面我们用到了自定义异常;自定义异常类作为一种【自己定义的警报器】还是很给力的;上一次遇到在项目中自定义异常可以参考【实现后台登陆】;然后,有关自定义异常的内容,如有需要可以快速参考【Java自定义异常】; 主要是,在开发某一个业务的时候,当需要的时候,得有自定义异常的意识,这个非常重要!

BussinessException自定义异常类:

 
     package com.imooc.reader.service.exception;
 
     /**
      * 自定义异常类;与业务逻辑相关的异常
      */
     public class BussinessException extends RuntimeException{
         private String code;
         private String msg;
 
         public BussinessException(String code, String msg) {
             super(code + ":" + msg);
             this.code = code;
             this.msg = msg;
         }
 
         public String getCode() {
             return code;
         }
 
         public void setCode(String code) {
             this.code = code;
         }
 
         public String getMsg() {
             return msg;
         }
 
         public void setMsg(String msg) {
             this.msg = msg;
         }
     }
 

如果对于这个自定义异常类有不明白的地方,可以参考上面提到的两篇文章;这儿只重复说明两点:

然后,这个异常类定义成了BussinessException;可以看到其是两个s;Bussiness表示业务逻辑,Business表示商业;在很多业务系统中,因为业务主要服务于商业,所以在这些业务系统中,也可能看到异常类写成了BusinessException;一个s和两个s的情况,差不多,都可以看成是与业务相关的异常;

(4) 当判断用户名没有出现重复后,就可以正式开始新增过工作了;其中密码部分,用到了【MD5+加盐混淆】策略;

(5) 有关【MD5算法】+【加盐混淆】策略的内容,如有需要可以快速参考【封装Md5Utils加密工具类】;

MD5Utils类:

     package com.imooc.reader.utils;
 
     import org.apache.commons.codec.digest.DigestUtils;
 
     /**
      * MD5加盐混淆
      */
     public class MD5Utils {
         /**
          * 字符串加盐混淆+MD5加密方法
          * @param source 原始字符串
          * @param salt 盐值
          * @return 混淆+MD5加密后的字符串
          */
         public static String md5Digest(String source, Integer salt) {
             //首先,对字符串进行混淆:这儿采用的策略是加盐混淆。
             char[] ca = source.toCharArray();//得到原始字符串的字符数组
             for(int i = 0; i < ca.length; i++){
                 ca[i] = (char)(ca[i] + salt); //背后的基础是:字符和整数可以互相转换;
             }
             String target = new String(ca);
             //然后,对加盐后的字符串再MD5加密
             String md5 = DigestUtils.md5Hex(target);
             return md5;
         }
     }

这个类没什么好说的,如有不明白的地方,可以快速参考【Md5加密】中的内容;

(6) 然后,就是【java.util.Date】和【java.sql.Date】转化的问题;

有关这个问题,可以参考【附加:【原始JDBC】,【DbUtils】,【mybatis】,【Spring JDBC】批处理;(还没写,不要看……)】;虽然这个问题,目前并没有彻底、完全搞明白,但是目前能勉强糊弄自己,勉强形成自洽;

3.及时的单元测试很重要,【Ctrl + Shift + T】快速创建测试类;


二:完善Controller;

1.在Controller中,完善补全注册功能;

在前面,我们已经创建过了MemberController类,并且创建了注册方法registe()方法(只是,当时我们只在registe()方法中编写了验证码的逻辑);下面我们把注册的逻辑,写在registe()方法中;

 
     package com.imooc.reader.controller;
 
     import com.imooc.reader.service.MemberService;
     import com.imooc.reader.service.exception.BussinessException;
     import org.springframework.stereotype.Controller;
     import org.springframework.web.bind.annotation.GetMapping;
     import org.springframework.web.bind.annotation.PostMapping;
     import org.springframework.web.bind.annotation.ResponseBody;
     import org.springframework.web.servlet.ModelAndView;
 
     import javax.annotation.Resource;
     import javax.servlet.http.HttpServletRequest;
     import java.util.HashMap;
     import java.util.Map;
 
     /**
      * member会员Controller
      */
     @Controller
     public class MemberController {
         @Resource
         private MemberService memberService;
         /**
          * 跳转显示register.ftl注册页;
          * @return
          */
         @GetMapping("/register.html")
         public ModelAndView showRegister() {
             return new ModelAndView("/register");
         }
 
         /**
          * 会员注册
          * @param vc
          * @param username
          * @param password
          * @param nickname
          * @param request
          * @return
          */
         @PostMapping("/registe")
         @ResponseBody
         public Map registe(String vc, String username, String password,String nickname, HttpServletRequest request) {
             Map result = new HashMap();
             //首先从Session会话对象中,获取存储在后端的验证码
             String verifyCode = (String)request.getSession().getAttribute("kaptchaVerifyCode");
 
 //如果【前端没有输入验证码】或者【当前Session中没有验证码】或者【前端输入的验证码和后端的验证码,在忽略大小写后不一致】:表示验证失败;
             if (vc == null || verifyCode == null||!vc.equalsIgnoreCase(verifyCode)) {
                 result.put("code", "VC01");
                 result.put("msg", "验证码错误");
             } else {
                 try {
                     memberService.createMember(username, password,nickname);
                     result.put("code", "0");
                     result.put("msg", "success处理成功");
                 } catch (BussinessException ex) {
                     ex.printStackTrace();
                     result.put("code", ex.getCode());
                     result.put("msg", ex.getMsg());
                 }
             }
             return result;
         }
     }
 

说明:

(1) 前后的变化:

(2) 后台再向前台传递处理结果时,要根据前台的情况来决定要传什么样的数据;

2.启动Tomcat,观察效果;

正确的情况下:(用户名没有重复,用户名和密码符合格式,验证码输对了)

如果输入错误;(比如用户名重复):此时就会抛出异常:


至此,会员注册功能就开发完成了;下篇博客将开发会员登录功能;

实现会员登录功能

说明:

(1) 本篇博客主要内容:实现会员登录功能;(包括会员登录的前端和后端业务逻辑)

(2) 本篇博客的几点说明:

● 在前面的【实现用户登陆】中,也实现过会员登录;可以快速参考下那儿的内容,与本节内容对比一下,能帮助理解【登录,这种业务逻辑】;(虽然,二者的复杂程度不一样,但看一下呗,花不了多长时间)

(3) 本篇博客,登录功能开发流程:【先确定登录页的前端文件login.ftl,编写登录的前端文件】→【然后编写Controller方法,设置访问login.ftl登录页的入口方法,也就是设置访问login.ftl登录的url】→【然后,设置首页上的,登录超链接,让其指向我们在上一步设置的url】→【编写登录功能的Service,主要是编写登录的处理逻辑】→【编写登录功能的Controller方法,接收登录页的登录请求,调用Service逻辑,向login.ftl登录页返回登录结果】→【如果登录成功了,别忘了把当前登录用户的信息,存到Session会话对象中去;这样以后,登录成功后,在需要显示当前登录用户的地方,我们就能直接取数据了】;

(4)其实,本文最重要的就是熟悉【登录,这种业务逻辑】;包括在处理登录时,需要注意的点、习惯采取的操作等;


一:登录页前端文件login.ftl简介;

引入登录页login.ftl;

login.ftl:

 
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>会员登录-MK书评网</title>
    <meta name="viewport" content="width=device-width,initial-scale=1.0, maximum-scale=1.0,user-scalable=no">
    <link rel="stylesheet" href="http://cdn.itlaoqi.com./resources/bootstrap4/css/bootstrap.css">
    <link rel="stylesheet" href="./resources/raty/lib/jquery.raty.css">
 
    <script src="http://cdn.itlaoqi.com./resources/jquery.3.3.1.min.js"></script>
    <script src="http://cdn.itlaoqi.com./resources/bootstrap4/js/bootstrap.min.js"></script>
    <style>
        .container {
            padding: 0px;
            margin: 0px;
        }
 
        .row {
            padding: 0px;
            margin: 0px;
        }
 
        .col- * {
            padding: 0px;
        }
 
        .description p {
            text-indent: 2em;
        }
 
        .description img {
            width: 100%;
        }
 
    </style>
 
</head>
<body>
<!--<div style="width: 375px;margin-left: auto;margin-right: auto;">-->
<div class="container ">
    <nav class="navbar navbar-light bg-white shadow">
        <ul class="nav">
            <li class="nav-item">
                <a href="/">
                    <img src="https://m.imooc.com/static/wap/static/common/img/logo2.png" class="mt-1"
                         style="width: 100px">
                </a>
            </li>
        </ul>
    </nav>
 
 
    <div class="container mt-2 p-2 m-0">
        <form id="frmLogin">
            <div class="passport bg-white">
                <h4 class="float-left">会员登录</h4>
                <h6 class="float-right pt-2"><a href="/register.html">会员注册</a></h6>
                <div class="clearfix"></div>
                <div class="alert d-none mt-2" id="tips" role="alert">
 
                </div>
 
                <div class="input-group  mt-2 ">
                    <input type="text" id="username" name="username" class="form-control p-4" placeholder="请输入用户名"
                           aria-label="Username" aria-describedby="basic-addon1">
                </div>
 
                <div class="input-group  mt-4 ">
                    <input id="password" name="password" class="form-control p-4" placeholder="请输入密码" type="password"
                           aria-describedby="basic-addon1">
                </div>
 
                <div class="input-group mt-4 ">
                    <div class="col-5 p-0">
                        <input type="text" id="verifyCode" name="vc" class="form-control p-4" placeholder="验证码">
                    </div>
                    <div class="col-4 p-0 pl-2 pt-0">
                        <img id="imgVerifyCode" src="/verify_code"
                             style="width: 120px;height:50px;cursor: pointer">
                    </div>
 
                </div>
 
                <a id="btnSubmit" class="btn btn-success  btn-block mt-4 text-white pt-3 pb-3">登    录</a>
            </div>
        </form>
 
    </div>
</div>
 
<script>
    function showTips(isShow, css, text) {
        if (isShow) {
            $("#tips").removeClass("d-none")
            $("#tips").hide();
            $("#tips").addClass(css);
            $("#tips").text(text);
            $("#tips").fadeIn(200);
        } else {
            $("#tips").text("");
            $("#tips").fadeOut(200);
            $("#tips").removeClass();
            $("#tips").addClass("alert")
        }
    }
    function reloadVerifyCode(){
        $("#imgVerifyCode").attr("src", "/verify_code?ts=" + new Date().getTime());
    }
    $("#imgVerifyCode").click(function () {
        reloadVerifyCode();
    });
 
    $("#btnSubmit").click(function () {
        var username = $.trim($("#username").val());
        var regex = /^.{1,10}$/;
        if (!regex.test(username)) {
            showTips(true, "alert-danger", "用户名请输入正确格式(1-10位)");
            return;
        } else {
            showTips(false);
        }
 
        var password = $.trim($("#password").val());
 
        if (!regex.test(password)) {
            showTips(true, "alert-danger", "密码请输入正确格式(1-10位)");
            return;
        } else {
            showTips(false);
        }
 
        $btnReg = $(this);
 
        $btnReg.text("正在处理...");
        $btnReg.attr("disabled", "disabled");
        $.ajax({
            url: "/check_login",
            type: "post",
            dataType: "json",
            data: $("#frmLogin").serialize(),
            success: function (data) {
                console.info(data);
                if (data.code == "0") {
                    window.location = "/?ts=" + new Date().getTime();
                } else {
                    showTips(true, "alert-danger", data.msg);
                    reloadVerifyCode();
                    $btnReg.text("登录");
                    $btnReg.removeAttr("disabled");
                }
            }
        });
        return false;
    });
 
 
</script>
</body>
</html>

说明:

(1) 脚本内容说明1;

(2) 脚本内容说明2;

前端登录页login.ftl已经准备好了,我们主要的任务就是按照要求,完成登录功能的后台逻辑了;


二:登录功能后台逻辑;

1.在MemberController类:开发【访问登录页的入口方法】;(也就是,开发login.ftl登录页的访问url)

说明:

(1) 之所以url后面要附带.html,这是因为:该系统的登录页是一个允许被外界访问地方,加上.html后,会有助于百度谷歌等搜索引擎的抓取,有助于网站的营销和宣传;(在【完成注册功能】中第一次遇到的)

(2) 然后,启动Tomcat,我们通过【localhost/login.html】就能够访问到登录页了;

2.在MemberService接口中,定义一个登录的方法:checkLogin()方法;

3.在MemberServiceImpl实现类中,去实现登录的方法:checkLogin()方法;

说明:

(1) 这个方法逻辑很明确:【先根据用户输入的用户名,去数据库查】→【如果没有查到,则说明,该用户不存在】→【如果查到了,但是密码不对,则说明密码错误】→【如果一切OK,就返回查到的那个Member对象】;

4.在MemberController中编写处理登录的方法checkLogin(),接收前端的请求,调用到Service中定义的逻辑;

 
     package com.imooc.reader.controller;
 
     import com.imooc.reader.entity.Member;
     import com.imooc.reader.service.MemberService;
     import com.imooc.reader.service.exception.BussinessException;
     import org.springframework.stereotype.Controller;
     import org.springframework.web.bind.annotation.GetMapping;
     import org.springframework.web.bind.annotation.PostMapping;
     import org.springframework.web.bind.annotation.ResponseBody;
     import org.springframework.web.servlet.ModelAndView;
 
     import javax.annotation.Resource;
     import javax.servlet.http.HttpServletRequest;
     import javax.servlet.http.HttpSession;
     import java.util.HashMap;
     import java.util.Map;
 
     /**
      * member会员Controller
      */
     @Controller
     public class MemberController {
         @Resource
         private MemberService memberService;
 
         @PostMapping("check_login")
         @ResponseBody
         public Map checkLogin(String username, String password, String vc, HttpSession session) {
             Map result = new HashMap();
             //首先从Session会话对象中,获取存储在后端的验证码
             String verifyCode = (String)session.getAttribute("kaptchaVerifyCode");
 
 //如果【前端没有输入验证码】或者【当前Session中没有验证码】或者【前端输入的验证码和后端的验证码,在忽略大小写后不一致】:表示验证失败;
             if (vc == null || verifyCode == null||!vc.equalsIgnoreCase(verifyCode)) {
                 result.put("code", "VC01");
                 result.put("msg", "验证码错误");
             } else {
                 try {
                     Member member = memberService.checkLogin(username,password);
                     result.put("code", "0");
                     result.put("msg", "success处理成功");
                 } catch (BussinessException ex) {
                     ex.printStackTrace();
                     result.put("code", ex.getCode());
                     result.put("msg", ex.getMsg());
                 }
             }
             return result;
         }
     }
 

说明:

(1) 这儿的很多内容,和前面的【完成会员注册功能】等文章中的内容很类似,前面的几篇文章都提到过;这儿就不重复啰嗦了;

(2) 只是强调一点:和HttpServletRequest等一样,HttpSession对象想用的使用,直接在方法参数中写上就行,Spring会自动帮我们注入;

启动Tomcat,观察效果;

如果输入的用户名、密码、验证码都正确:发现其登录OK,并且跳转到了默认首页(即根路径);

补充1,登录成功后,把【Service层返回的Member用户对象】存到Session中;然后,我们就可以在需要的地方,获取当前登录用户的信息了;

补充2,用户登录成功后、跳转到默认首页后,在页面右上角显示“当前登录用户的会员名”;(其实就是获取补充1中存储的用户信息)(背后的与原理是,FreeMarker也可以去Session对象中的数据)

说明:

(1) 可以看到,首页上的这个登录是个超链接,其地址对应了【/login.html】登录页;即,当我们点击这个登录按钮后,其就会跳转到登录页;

(2) 内容说明;

(3) 很显然,当我们把数据存到ModelAndView中时,其实是存到了HttpServletRequest对象中,我们在前端可以通过FreeMarker去取;那么如果我们把数据存在了Session对象中,我们在前端也可以通过FreeMarker去取;

(4) <#if loginMember??的??的意思是,如果loginMember存在的话,才进入这个判断结构;有关FreeMarker语法,如有需要可以参考【FreeMarker基本语法-分支判断】及附近的文章;

启动Tomcat,观察效果;

至此,登录功能就实现了;接下来就开始【与用户相关的操作】;如变更阅读状态、发布短评、点赞短评;


三:临时Summary:【后端返回JSON】,【数据放在了session对象中】,【数据放在了ModelAndView中】时:前端取值的分析;(暂时性总结,以后有了新认识,随时修正补充)

1.把数据放在了ModelAndView中时:其实,数据是放在HttpServletRequest对象中的;在前端,我们可以使用FreeMarker去取;

前端,ftl直接取就行了;

有精力可以研究下【SpringMvc中使用ModelAndView返回值,为什么将数据存放在request域中【源码分析】_撸码的男人的博客-CSDN博客】;

2.把数据放在了Session对象中时:其实,数据是放在HttpServletRequest域中的;在前端,我们也可以直接用FreeMarker去取;

3.后端返回JSON数据:这种情况,一般对应的是页面ajax请求(比如登录、注册、页面局部刷新),跨接口传值等不涉及页面跳转的情况;

(即此时,我们的数据仅仅是JSON,其只是在响应中;数据并没有在三大作用域对象中;)

因为JavaScript对JSON有着良好的支持,所以可以直接使用JavaScript去取JSON的值;

获取会员阅读状态

说明:

(1) 本篇博客开发内容:前面我们实现了会员注册和登录功能;那么会员登录后,就开始介绍与会员交互有关的功能;第一个功能就是【想看/看过的阅读状态变更】;

然后,本篇博客的主要内容就是获取会员状态:即,当某个会员登录后,该会员点击查看某本图书的详情;就显示该会员针对该本书的阅读状态(如果该会员以前点击过这本书的【想看】或【看过】的话)

(2) 一点说明:SSM开发很多细节,在本专栏前面的几篇博客中都详细介绍了;自本篇博客开始,如果没有遇到新的内容或者需要强调的内容,那么就不再重复啰嗦说明了;

(3) 通过本篇博客能够感受到一个点:当我们的业务需要多表查询的时候,我们一般不在Dao层面使用多表查询的SQL语句来解决这个问题,而是在Service业务层面来化解,从而使得我们我们每次操作数据库的语句都是针对单表的;(PS:但是这点,我感觉不靠谱;)


零:前置说明与分析;

1.需求说明;

2.底层数据表分析;


一:获取会员阅读状态;

1.创建与memner_read_state表对应的实体类:MemberReadState类;

MemberReadState类:

 
     package com.imooc.reader.entity;
 
     import com.baomidou.mybatisplus.annotation.IdType;
     import com.baomidou.mybatisplus.annotation.TableId;
     import com.baomidou.mybatisplus.annotation.TableName;
 
     import java.lang.reflect.Type;
     import java.util.Date;
 
     @TableName("member_read_state")
     public class MemberReadState {
         @TableId(type = IdType.AUTO)
         private Long rsId;
         private Long bookId;
         private Long memberId;
         private Integer readState;
         private Date createTime;
 
         public Long getRsId() {
             return rsId;
         }
 
         public void setRsId(Long rsId) {
             this.rsId = rsId;
         }
 
         public Long getBookId() {
             return bookId;
         }
 
         public void setBookId(Long bookId) {
             this.bookId = bookId;
         }
 
         public Long getMemberId() {
             return memberId;
         }
 
         public void setMemberId(Long memberId) {
             this.memberId = memberId;
         }
 
         public Integer getReadState() {
             return readState;
         }
 
         public void setReadState(Integer readState) {
             this.readState = readState;
         }
 
         public Date getCreateTime() {
             return createTime;
         }
 
         public void setCreateTime(Date createTime) {
             this.createTime = createTime;
         }
     }
 

2.创建操作member_read_state表的Mapper接口:MemberReadStateMapper接口;

MemberReadStateMapper接口:

 
     package com.imooc.reader.mapper;
 
     import com.baomidou.mybatisplus.core.mapper.BaseMapper;
     import com.imooc.reader.entity.MemberReadState;
 
     /**
      * 操作member_read_state表的接口;(阅读状态)
      */
     public interface MemberReadStateMapper extends BaseMapper<MemberReadState> {
     }

3.创建MemberReadStateMapper接口对应的xml:member_read_state.xml;

member_read_state.xml:

<?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.imooc.reader.mapper.MemberReadStateMapper">
</mapper>

说明:

(1) 通过这个案例,能够感受到,我们的xml文件的名字,没有驼峰命名的feel,而是要尽量保持与数据表的表名对应;

4.在MemberService接口中,定义【获取阅读状态的方法】:selectMemberReadState()方法;

说明:

(1) 这个方法的查询结果,包括两种情况:

● 情况一:会员从来没有看到过这本书,自然这个会员和本书,自然没有阅读状态,即在member_read_state表,就没有这个会员对于这本书的阅读状态;

● 情况二:对于这个本书来说,这个会员之前点过【想看】或者【看过】按钮;自然在member_read_state表就有对应的阅读状态数据;

5.在MemberServiceImpl实现类中,实现【获取阅读状态的方法】:selectMemberReadState()方法;

其实,我们目前编写了阅读状态的Service层逻辑; 那么在什么时候,调用这个逻辑嘞? QueryWrapper使用方法

经过分析项目的业务情况,可以分析出,当我们在显示图书详情页的时候,可以调用【阅读状态的Service层逻辑】;也就是在BookController类的showDetail()方法中去调用;

即,不是MemberService的逻辑,一定得是MemberController去调用的;

6.在BookController类的,显示图书详情页的showDetail()方法中,去调用【阅读状态的Service层逻辑】;

说明:

(1) 自然因为要用到【阅读状态的Service层逻辑】,所以要先注入MemberService对象;

(2) 内容简单说明;

(3) 新增内容说明;

7.在detail.ftl图书详情的前端界面中,获取阅读状态;

(1) 首先,可以看到,前端页面上有两个button,分别对应【想看】和【看过】两个按钮;默认情况下,这两个按钮都没有施加highlight高亮效果;

(2) 当某会员查看某本图书详情时,我们会去查到对应的阅读状态记录,并且把会员阅读状态数据memberReadState保存到ModelAndView(其实就是保存在request中)中,然后返回给detail.ftl前端;

(3) 如果该会员没有设置过针对这本书的阅读状态;那么,在member_read_state表中,就没有对应的记录;即,会员阅读状态数据memberReadState就是空的;是空的,就代表该会员针对该本书,没有阅读状态;我们什么都不用做;

(4) 如果该会员曾经设置过针对这本书的阅读状态;那么,在member_read_state表中,就会有对应的记录;即,会员阅读状态数据memberReadState就是非空的;

(5) 所以,我们可以在detail.ftl图书详情页上,添加一个页面就绪函数;这个函数判断会员阅读状态数据memberReadState是否为空,如果是非空的;那么,就获取对应的阅读状态;然后,根据阅读状态,去获取【想看】或者【看过】的页面元素;然后,在对应元素上添加highlight高亮效果;

如下:

不难,很简单;

8.启动Tomcat,观察效果;

(1) 如果没有登录的话:

(2) 如果登录的话;


至此,我们就实现了获取会员阅读状态的功能;下一篇博客将介绍更新会员阅读状态的功能;

更新会员阅读状态

说明:

(1) 本篇博客开发内容:前面我们实现了会员注册和登录功能;那么会员登录后,就开始介绍与会员交互有关的功能;第一个功能就是【想看/看过的阅读状态变更】;

然后,本篇博客的主要内容就是更新会员状态:即,某个会员登录后,该会员点击查看某本图书的详情;当该会员点击【想看】或者【看过】按钮后:如果原先没有阅读状态,就创建一条;如果原先有阅读状态,就更新原先的阅读状态;

(2) 一点说明:SSM开发很多细节,在本专栏前面的几篇博客中都详细介绍了;自本篇博客开始,如果没有遇到新的内容或者需要强调的内容,那么就不再重复啰嗦说明了;

(3.1)本篇博客特别需要说明的一个点:

(3.2) 由(3.1)就引出了一个问题:【前端的一个请求,到底使用哪个Controller去接收;Controller要调用的处理逻辑,到底要写在哪个Service中;某个Dao层方法,到底在哪个Service中被调用;】:

● 对于这个问题,能感觉到,如果自己开发,自己有能力严格按照规范开发,是没问题的;;;只是,这样做,项目可能会显得死板、臃肿;

●(3)中也提到了所谓的灵活性();自己能够感觉到,要想做到很好的灵活性,【深入理解业务,合理规划程序、模块结构】是很重要的;虽然觉得这个能力也不是那么遥不可及,但需要自己留意并逐渐加强;

● 有一点澄清:整个项目肯定要严格按照规范,可能只有个别地方需要灵活性;

(4) 前端的部分,目前没必要过于深究;能看懂,能基本上手,就可以了;


零:点击【想看】或【看过】时:需要先检测,会员是否已登录;

更新阅读状态的操作,其前提得是会员登录的状态下;所以,我们正式开始前,需要先检查下,当前是否有会员登录;

1.在页面就绪函数中,编写代码:点击【想看】或【看过】时,如果没有登录,就弹出提示登录的提示框;

在detail.ftl的head中的script中的页面就绪函数中,编写代码;其作用是,如果当前没有会员登录,如果点击了【想看】或【看过】按钮,就弹出提示框,提示要先去登录;

                $(function() {
                 <!--更新会员阅读状态前,登录检查-->
                 <#if !loginMember ??>
                     $("*[data-read-state]").click(function () {
                         $("#exampleModalCenter").modal("show");
                     })
                 </#if>
                })

说明:

(1) 如果没有登录的话,在页面就绪函数中,编写【想看】和【看过】按钮的单机事件:如果单击了这两个按钮,就弹出请登录的提示;

(上图有个书写错误,不是JavaScript选择器,而是jQuery选择器)

(2) 弹出请登录的提示;我们是通过Bootstrap来实现的;

2.启动Tomcat,测试观察效果;

启动Tomcat,观察效果:可以看到,没有登录的时候,点击【想看】或【看过】按钮时,会弹出提示登录的弹出框;

如果登录的时候,点击【想看】或【看过】按钮时,就不会弹出提示登录的弹出框;

3.【给短评点赞】和【写短评】也需要登录;所以,顺手也给这两个功能上也添加这个判断逻辑吧;

说明:前后变化;

这样以后,在没有登录的情况下,点击写短评的按钮或者点赞短评时,也会弹出提示登录的弹出框;


重启Tomcat,观察效果;

没有登录的时候,点击写短评的按钮或者点赞短评时,会弹出提示登录的弹出框;

登录的时候,点击写短评的按钮或者点赞短评时,就不会弹出提示登录的弹出框;


一:接下里就是任务就是,会员登录后,更新阅读状态;

1.在MemberService接口中,定义更新阅读状态的方法:updateMemberReadState()方法;

 
         /**
          * 更新阅读状态
          * @param memberId 会员id
          * @param bookId 图书id
          * @param readState 阅读状态
          * @return
          */
         public MemberReadState updateMemberReadState(Long memberId, Long bookId, Integer readState);

说明:

(1) 在MemberService接口中,定义updateMemberReadState()方法;

(2) 已经知道,为了操作member_read_state,我们前面已经定义了MemberReadStateMapper接口;那么,为什么一个涉及到操作member_read_state表的逻辑,要定义在MemberService接口中;而不是创建MemberReadStateService接口,然后在这个接口中定义逻辑嘞?:这其中的原因,估计就是在本篇博客一开头时的说明中写的那样,这就是在【深入理解业务,合理规划程序、模块结构】的基础上,不死板,灵活处理的表现;(好吧,我承认我在胡扯;但是,这条对于目前的自己来说,还是比较重要的;)

2.在MemberServiceImpl实现类中,去实现更新阅读状态的方法:updateMemberReadState()方法;

 
     /**
          * 更新阅读状态
          *
          * @param memberId  会员id
          * @param bookId    图书id
          * @param readState 阅读状态
          * @return
          */
         public MemberReadState updateMemberReadState(Long memberId, Long bookId, Integer readState) {
             //首先,要查询;当前用户针对这本书的阅读状态
             QueryWrapper queryWrapper = new QueryWrapper();
             queryWrapper.eq("member_id", memberId);
             queryWrapper.eq("book_id", bookId);
             MemberReadState memberReadState = memberReadStateMapper.selectOne(queryWrapper);
             // 如果这个会员针对这本书,没有阅读状态:那么我们需要做的是新建一条阅读状态,保存到member_read_state表;
             if (memberReadState == null) {
                 memberReadState = new MemberReadState();
                 memberReadState.setBookId(bookId);
                 memberReadState.setMemberId(memberId);
                 memberReadState.setReadState(readState);
                 memberReadState.setCreateTime(new Date());
                 memberReadStateMapper.insert(memberReadState);
             } else {        // 如果这个会员针对这本书,已经有了阅读状态:那么我们需要做的是更新这条阅读状态;
                 memberReadState.setReadState(readState);
                 memberReadStateMapper.updateById(memberReadState);
             }
             return memberReadState;
         }

说明:

(1) 聪明如我,在前面我们就设置了MemberServiceImpl类的方法,默认开启事务了;

一个笔误的地方,上图中,我们写了两次setBookId(),忘记了serMemberId()方法;

(2) 这个方法的逻辑很简单,看下代码注释;

(3) 还是那个点,在啰嗦一遍:如下图

(插)把MemberServiceImpl类中的,selectMemberReadState()查询阅读状态的方法,设置为不开启事务;

这儿算是一个临时的补充;

3.在MemberController类中,创建更新阅读状态的方法:updateReadState();

 
         /**
          * 更新阅读状态
          * @param memberId 会员id
          * @param bookId 图书id
          * @param readState 阅读状态
          * @return
          */
         @PostMapping("/update_read_state")
         @ResponseBody
         public Map updateReadState(Long memberId, Long bookId, Integer readState) {
             Map result = new HashMap();
             try {
                 memberService.updateMemberReadState(memberId, bookId, readState);
                 result.put("code", "0");
                 result.put("msg", "success");
             } catch (BussinessException ex) {
                 ex.printStackTrace();
                 result.put("code", ex.getCode());
                 result.put("msg", ex.getMsg());
             }
             return result;
         }

说明:

(1) 一点说明;

(2) 接下来,我们就需要detail.ftl前端文件中编写逻辑了,具体是:在页面就绪函数中,编写点击【想看】,,待写………………

4.在detail.ftl前端页面中,向【MemberController类中的updateReadState()方法】,发起ajax请求;

有关jQuery中ajax请求的内容可以参考【jQuery中Ajax函数:.post()、$.get()的使用、区别;

5.启动Tomcat,观察效果;

前端看起来是没问题的:无论是修改已有的图书状态,还是创建新的图书状态,都没问题;

写短评

说明:

(1) 本篇博客开发内容:前面我们实现了会员注册和登录功能;那么会员登录后,就开始介绍与会员交互有关的功能;本篇博客介绍第二个功能【为图书写短评】;

(2) 本篇博客的一个需要强调的【开发技巧】:比如我们Service层的一个方法,这个方法是向数据表插入数据的方法;虽然这个方法不是查询方法,但是这个方法最好还是返回我们插入的实体对象;万一有的地方,调用这个插入方法时,想得到插入的对象呐,是吧。这样做,让程序的更给力,更全面;


一:需求分析;


二:正式开发;

1.在detail.ftl前端编写:点击【写短评】按钮后,在页面上弹出一个写短评的对话框;

启动Tomcat,观察效果:没问题;

接下来就是,写完了短评,点击【提交】,会向后台发起ajax请求;但是,我们后端目前还没有处理写短评的逻辑;所以,接下来我们就去后端编写对应的逻辑;

2.在MemberService接口中,定义发布新短评的方法:evaluate()方法;

 
         /**
          * 发布新的短评
          * @param memberId 用户id
          * @param bookId 图书id
          * @param score 评分
          * @param content 短评内容
          * @return 短评对象
          */
         public Evaluation evaluate(Long memberId, Long bookId, Integer score, String content);

说明:

(1) 这儿有遇到了上篇博客提到的所谓的【灵活性】;

3.在MemberServiceImpl实现类中,实现发布新短评的方法:evaluate()方法;

 
         /**
          * 发布新的短评
          *
          * @param memberId 用户id
          * @param bookId   图书id
          * @param score    评分
          * @param content  短评内容
          * @return 短评对象
          */
         public Evaluation evaluate(Long memberId, Long bookId, Integer score, String content) {
             Evaluation evaluation = new Evaluation();
             evaluation.setBookId(bookId);
             evaluation.setMemberId(memberId);
             evaluation.setScore(score);
             evaluation.setContent(content);
             evaluation.setCreateTime(new Date());
             evaluation.setState("enable");//设置审核状态,默认为enable
             evaluation.setEnjoy(0);//设置初始点赞数量
             evaluationMapper.insert(evaluation);
             return evaluation;
         }

说明:

(1) 这个方法很简单,就是根据传过来的参数,和数据表的要求,实例化Evaluation对象,然后调用Dao层方法,去插数据就行了;

(2) 自然要注入EvaluationMapper对象;同时,因为MemberServiceImpl类默认设置全部方法开启事务,所以我们不用再在evaluate()方法上设置事务了;

4.在MemberController类中,增加前后端交互的方法:evaluate()方法;(Controller中的方法可以随便起,但为了见名知意,也起做evaluate了)

 
         /**
          * 发布新的短评
          * @param memberId 用户id
          * @param bookId 图书id
          * @param score 评分
          * @param content 短评内容
          * @return
          */
         @PostMapping("/evaluate")
         @ResponseBody
         public Map evaluate(Long memberId, Long bookId, Integer score, String content) {
             Map result = new HashMap();
             try {
                 memberService.evaluate(memberId, bookId, score, content);
                 result.put("code", "0");
                 result.put("msg", "success");
             } catch (BussinessException ex) {
                 ex.printStackTrace();
                 result.put("code", ex.getCode());
                 result.put("msg", ex.getMsg());
             }
             return result;
         }

说明:

(1) 这个方法很简单,没什么好说的;

(2) Service层返回值的一点说明:插入更新类的方法,最好也要返回【插入、更新】的数据;

5.在detail.ftl前端编写:点击【写短评】按钮后,向后端发起ajax请求;

启动Tomcat,观察效果:没问题;

在数据库中,也有对应的数据;



至此,写短评的功能就完成了;下篇博客将介绍会员给短评点赞的功能;

给短评点赞

说明:

(1) 本篇博客开发内容:前面我们实现了会员注册和登录功能;那么会员登录后,就开始介绍与会员交互有关的功能;本篇博客介绍第三个功能【为喜欢的短评点赞】;

(2) 本篇博客的功能比较简单;遇到的很多开发技巧,在前几篇博客也都遇到过;这儿就不重复啰嗦了; 只是,其中【防止重复点赞的功能】尚未完成……


一:需求分析;

某会员登录后,看到喜欢的短评,可以点赞这个短评;

其底层的数据表是evaluation表;


上面的一个问题:(这个问题挺重要的)


二:正式开发;

1.在MemberService接口中,定义点赞短评的方法:enjoy()方法;

说明:

(1) 是前面提到过的两点:【更新的方法,最好也返回更新的对象】;【操作evaluation表的逻辑方法,可以在MemberService中定义:就是前面提到过的所谓灵活性】;

2.在MemberServiceImpl实现类中,实现点赞短评的方法:enjoy()方法;

3.在MemberController类中,增加前后端交互的方法:enjoy()方法;(Controller中的方法可以随便起,但为了见名知意,也起做enjoy了)

接下来,我们就是在detail.ftl前端中编写:当点击点赞按钮后,向前端发起ajax请求了;

4.在detail.ftl中,点击【点赞按钮】后,向后端发起ajax请求;

启动Tomcat,观察效果:

5.补:一个会员,在一定的时间内,只能为某条评论,点赞一次;(这是个很常见的业务需求)(待完成……)

目前能想到的思路就是:

重新建一张表tt,这张表中主要有这几个字段:用户id,evaluationId,点赞时间;

每次用户点赞的时候,就去tt表中去查,看下这个用户在一定时间内是否给该条评论点过赞;;;如果没有,就顺利进行;如果有,不能点赞,给出提示;

同时,用户每次点赞时:如果是该用户第一次点赞,就在tt表中新增记录;如果是该用户的重复点赞,就更新tt表中记录的时间;


不过,上面的解决方案,会明显拖慢后台的处理速度;

这个问题,先搁置吧,等以后,遇到了或者想到了更好的思路~~

Spring-Task定时任务

说明:

(1) 本篇博客的内容说明:利用Spring-Task模块,实现对所有图书【更新评分、评价人数】的功能;

(2) 本篇博客的几个点:

● Mybatis-Plus可以帮助我们生成SQL语句;对于基本的增删改查,Mybatis-Plus是OK的;但是,对于复杂的SQL语句,Mybatis-Plus就比较吃力了;又因为,Mybatis-Plus只是扩展了Mybatis,其并没有修改Mybatis的任何内容;所以,我们在这儿,依旧可以使用Mybatis的内容;自然,这儿需要遵循Mybatis的开发规范,该对应的要对应好;

● 本篇博客的演示了Spring Task的基本使用;这是本篇博客的重点;

● 本篇博客的基本逻辑是:【分析业务逻辑,得到底层SQL】→【根据业务逻辑,编写业务代码】→【创建实现定时任务的类,调用业务逻辑代码,设置定时任务】;


一:【Spring-Task】定时任务模块,简介;

1.定时任务,简介;

定时任务概念:

**** 我们想在 [几点几分几秒] 或者 [一个固定的时间间隔内] ,执行Java中的某一段代码;

定时任务是一种非常常见的应用场景:

比如,开发一个闹钟应用,设置其每天早上6:30时,自动播放闹铃,此时就是定时任务;

再比如,金融业中:银行每天需要根据当天的业务情况,生成数据统计报表;又因为,银行每天的业务量可能百万千万,数据量如此庞大,计算这些业务,生成统计报表,是需要花费一定的时间;所以,我们不能在需要统计报表的时,即时先算;为此,银行就提出了一种延时处理的方案:通常在凌晨2:00-4:00,由银行的服务器自动执行处理数据的任务(这在银行的专业术语中,被称为日终处理),生成统计报表;第二天,当需要查看昨天的业务的统计报表时,只需要直接读取昨晚生成的数据就行了,而不需要现算;那么,如果保证在每天的凌晨2:00-4:00,去执行统计任务呐?其底层就是通过一些定时任务来处理的;

2.Spring-Task定时任务模块;

3.Cron表达式;

说明:

(1) Cron表达式,不是Java独有的技术;

(2) Cron表达式,是一个最多7位的字符串;这7位,分别对应秒、分、小时、日、月、星期、年;

(3) 其中,第7位年,可以省略;日和星期是互斥的,即,写了日,星期就只能是?;写了星期,日就只能是?;(如果星期哪儿是?,就表示忽略星期)

(4) 如上图中的第一个例子:表示:在任意年的、任意月、任意日、任意小时、任意分、0的时候,执行一次任务;;;;其实,也就是每分钟执行一次任务;

(5) 上图的第二个例子:表示:在2000年、任意月、任意日、任意小时、每小时的前五分钟、 第0和第30秒 ,执行任务;(这儿可以看到,我们可以【0,30】这样写,来指定分散的时间点)

(6) 上图的第三个例子:表示:在任意年、任意月、星期三、第9-18小时、第0分、第0秒,执行任务;;;;其实,也就是每周三,上午9点到下午6点,整点的时候,执行任务;(这儿可以看到,当我们写了星期,就不能写日了;)


二:编写【更新评分、评价人数】的逻辑代码;

0.情况说明;

在前面,会员可以对图书进行评价;

那么,在新增评分的时候,需要重新计算一下该图书的评分;


此时,我们就可以使用【Spring-Task】,来实现;每分钟,来重新计算图书的平均评分;

1.【更新评分、评价人数】的:SQL分析;

这儿的业务主要是:查询某本书的【平均评分】和【评价数量】;下面的SQL,就可以完成这个功能;

 
     UPDATE book b SET evaluation_score =(
     SELECT IFNULL(AVG(score),0) FROM evaluation WHERE book_id = b.book_id AND state = 'enable'
     ),
     evaluation_quantity=(
     SELECT IFNULL(COUNT(*),0) FROM evaluation WHERE book_id = b.book_id AND state = 'enable'
     )

说明:

(1) 上面的SQL很简单,其中涉及到了AVG,COUNT聚合函数(如有需要,可以参考【AVG函数】);子查询(如有需要,可以参考【子查询】);上面的SQL,很简单,没什么好说的;

(2) 但是,上面有一个需要注意的地方:

(3) 目前,暂时不要过于考虑数据库优化的内容;

至此,自动计算评分和评价人数的SQL语句就OK了;但是,我们希望,每分钟去执行一次这个SQL;在Spring-Task模块中,如何使用嘞?

2.在BookMapper接口中,定义操作底层数据库,实现【更新评分、评价人数】的方法:updateEvaluation()方法;

 
     package com.imooc.reader.mapper;
 
     import com.baomidou.mybatisplus.core.mapper.BaseMapper;
     import com.imooc.reader.entity.Book;
 
     public interface BookMapper extends BaseMapper<Book>{
         /**
          * 更新图书评分、评价数量
          */
         public void updateEvaluation();
     }
 

说明:

(1) 因为【更新评分、评价人数】的SQL逻辑比较复杂;Mybatis的BaseMapper接口中,没有对应的可以处理这个业务的方法;所以,我们需要自己定义一个方法

3.在book.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.imooc.reader.mapper.BookMapper">
    <update id="updateEvaluation">
        UPDATE book b SET evaluation_score =(SELECT IFNULL(AVG(score),0) FROM evaluation WHERE book_id = b.book_id AND state = 'enable'),
            evaluation_quantity=(SELECT IFNULL(COUNT(*),0) FROM evaluation WHERE book_id = b.book_id AND state = 'enable')
    </update>
</mapper>

说明:

(1) Mybatis-Plus可以帮助我们生成SQL语句;对于基本的增删改查,Mybatis-Plus是OK的;但是,对于上面的那种复杂的SQL语句,Mybatis-Plus就比较吃力了;

(2) 又因为,Mybatis-Plus只是扩展了Mybatis,其并没有修改Mybatis的任何内容;所以,我们在这儿,依旧可以使用Mybatis的内容;

(3) 自然,这儿需要遵循Mybatis的开发规范,该对应的要对应好:

4.在BookService接口中,定义【更新评分、评价人数】的方法:updateEvaluation()方法;(PS:Service中的可以起其他名字,无需和Mapper中的保持一致)

5.在BookServiceImpl实现类中,去实现【更新评分、评价人数】的方法:updateEvaluation()方法;

【更新评分、评价人数】逻辑代码写完了;那么我们如何每分钟,执行Service层中定义的updateEvaluation()方法呐?

这就需要Spring-Task模块了;


三:【Spring-Task】定时任务模块:在本项目上的应用;

1.不需要额外引入依赖;

2.在applicationContext.xml配置文件中,配置:去开始Spring Task定时任务注解模式;

说明:

(1) 聪明如我,在前面我们开发的时候,就引入了task命名空间;

3.创建task包,创建ComputeTask类:调用Service中的逻辑,设置定时任务;

ComputeTask:

     package com.imooc.reader.task;
 
     import com.imooc.reader.service.BookService;
     import org.springframework.scheduling.annotation.Scheduled;
     import org.springframework.stereotype.Component;
 
     import javax.annotation.Resource;
 
     @Component
     public class ComputeTask {
         @Resource
         private BookService bookService;
 
         //任务调度
         @Scheduled(cron = "0 * * * * ?")
         public void updateEvaluation() {
             bookService.updateEvaluation();
             System.out.println("已经更新所有图书的评分、评价人数");
         }
     }

说明:

(1) 因为这个类不是Service,不是Controller;而我们又想让IoC容器管理这个类的对象,所以我们使用了@Component注解;有关@Component这种组件注解,如果有需要,可以参考【Compoment注解】;

(1.2) 由(1)联想到,在SSM项目中;Mapper接口没有使用@Repository注解;对于这个问题,可以参考【彻底搞懂使用MyBatis时为什么Dao层不需要@Repository】;这篇文章写的很清楚;

(3) 方法说明;

4.启动Tomcat,观察效果;

经过在页面上实操,发现【评分和评价人数】,实现了在每分钟的第0秒实现更新的功能;

而且在控制台这儿,也可以看到每分钟的第0秒,也输出了【已经更新所有图书的评分、评价人数】;


至此,【MK书评网】的前台都已经完成了;(前台是给用户用的)

接下来,我们要开发【MK书评网】的后台了;(后台是给管理用的)