仿牛客网项目学习笔记(一)

684 阅读11分钟

主页讨论区分页查询功能!

1.首先设计Dao层接口(实体类略)

以下是查询功能不包括分页 (其中userId在DiscussPost类中作为外键)

//查询
//userId=0为所有帖子,1为我的帖子
//每个参数必须加@Param("")
List<DiscussPost> selectDiscussPosts(@Param("userId") int userId,@Param("offset")int offset,@Param("limit")int limit);

//为分页查询服务的查询总条数
//给参数起别名,如果只有一个参数并且要在<if>里使用,则必须加别名
int selectDiscussRows(@Param("userId")int userId);
  <!------------- Mapper.xml ------------->
  <sql id="selectFields">
      id,user_id,title,content,type,status,create_time,comment_count,score
  </sql>
  <sql id="insertFields">
      user_id,title,content,type,status,create_time,comment_count,score
  </sql>

  <!--查询不是被拉黑的帖子并且userId不为0按照type指定,时间排序-->
  <select id="selectDiscussPosts" resultType="DiscussPost">
      select <include refid="selectFields"></include>
      from discuss_post
      where status!=2
      <if test="userId!=0">
          and user_id=#{userId}
      </if>
      <if test="orderMode==0">
          order by type desc,create_time desc
      </if>
      <if test="orderMode==1">
          order by type desc,score desc,create_time desc
      </if>
      limit #{offset},#{limit}
  </select>

  <!--userId=0查所有;userId!=0查个人发帖数-->
  <select id="selectDiscussRows" resultType="int">
      select count(id)
      from discuss_post
      where status!=2
      <if test="userId!=0">
          and user_id=#{userId}
      </if>
  </select>

2.然后设计Service层调用Dao层接口

  @Autowired
  private DiscussPostMapper discussPostMapper;
  
  public List<DiscussPost> findDiscussPosts(int userId,int offset,int limit){
      return discussPostMapper.selectDiscussPosts(userId,offset,limit);
  }
  
  public int findDiscussPostRows(int userId){
      return discussPostMapper.selectDiscussRows(userId);
  }

3.其次封装分页功能

封装分页功能相关信息在Page类!!

public class Page {

    //当前页面
    private int current=1;
    //显示上限
    private int limit=6;
    //数据总数(用于计算总页数)
    private int rows;
    //查询路径(用于复用分页链接)
    private String path;

    public int getCurrent() {
        return current;
    }

    public void setCurrent(int current) {
        //要作输入判断
        if (current>=1){
            this.current = current;
        }
    }

    public int getLimit() {
        return limit;
    }

    public void setLimit(int limit) {
        if (limit>=1&&limit<=100){
            this.limit = limit;
        }
    }

    public int getRows() {
        return rows;
    }

    public void setRows(int rows) {
        if (rows>=0){
            this.rows = rows;
        }
    }

    public String getPath() {
        return path;
    }

    public void setPath(String path) {
        this.path = path;
    }
    
    /** 获取当前页的起始行**/
    public int getOffset(){
        //current*limit-limit
        return (current-1)*limit;
    }
    
    /**获取总页数**/
    public int getTotal(){
        //rows/limit[+1]
        if (rows%limit==0){
            return rows/limit;
        }else{
            return rows/limit+1;
        }
    }
    
    /**获取起始页码**/
    public int getFrom(){
        int from=current-2;
        return from < 1 ? 1 : from;
    }
    
    /**获取结束页码**/
    public int getTo(){
        int to=current+2;
        int total=getTotal();
        return to > total ? total : to;
    }
}

4.最后设计Controller层

    @Autowired
    private DiscussPostService discussPostService;

    @Autowired
    private UserService userService;

    @RequestMapping(value = "/index",method = RequestMethod.GET)
    public String getIndexPage(Model model, Page page){//传入model参数是因为要返回值给View
        /*方法调用前,springMVC自动实例化Model和Page,并将Page注入Model
          在thymeleaf中可以直接访问Page对象中的数据 */
        
        //分页
        page.setRows(discussPostService.findDiscussPostRows(0));
        page.setPath("/community/index");
        
        //查询所有,起始为page.getOffset(),终止为page.getLimit()个帖子,
        List<DiscussPost> list=discussPostService.findDiscussPosts(0, page.getOffset(), page.getLimit());
        
        /*将查询的post帖子和user用户名拼接后放入map中,最后把全部map放入新的List中,
          因为UserId是外键,需要显示的是对应的名字即可 */
        List<Map<String,Object>> discussPost =new ArrayList<>();

        if (list!=null){
            for(DiscussPost post:list){
                HashMap<String, Object> map = new HashMap<>();
                // 将查询到的帖子放入map
                map.put("post",post);
                // 将发布帖子对应的用户id作为参数
                User user = userService.findUser(post.getUserId());
                // 将发帖子的所有用户放入map
                map.put("user",user);
                // 显示帖子点赞数量
                long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, post.getId());
                map.put("likeCount", likeCount);
                
                //将组合的map放入List<>
                discussPost.add(map);
            }
        }
        model.addAttribute("discussPosts",discussPost);
        return "/index";
    }

5.前端页面设计(Thymeleaf)

5.1查询页面

  <!-- 帖子列表 -->
  <ul class="list-unstyled">
  <!--th:each="map:${discussPosts}循环遍历model.addAttribute传过来的discussPosts这个集合,每次循环得到map对象-->
    <li class="media pb-3 pt-3 mb-3 border-bottom" th:each="map:${discussPosts}">
      <a href="site/profile.html">
  <!--th:src="${map.user.headerUrl}"底层是map.get("user")->user.get("headerUrl")-->
        <img th:src="${map.user.headerUrl}" class="mr-4 rounded-circle" alt="用户头像" style="width:50px;height:50px;">
      </a>
      <div class="media-body">
        <h6 class="mt-0 mb-3">
  <!--th:utext可以转义文本中特殊字符-->
          <a href="#" th:utext="${map.post.title}">备战春招,面试刷题跟他复习,一个月全搞定!</a>
          <span class="badge badge-secondary bg-primary" th:if="${map.post.type==1}">置顶</span>
          <span class="badge badge-secondary bg-danger" th:if="${map.post.status==1}">精华</span>
        </h6>
        <div class="text-muted font-size-12">
  <!--th:text="${#dates.format(map.post.createTime)} #是引用thymeleaf自带的工具-->
          <u class="mr-3" th:utext="${map.user.username}">寒江雪</u> 发布于 <b th:text="${#dates.format(map.post.createTime,'yyyy-MM-dd HH:mm:ss')}">2019-04-15 15:32:18</b>
          <ul class="d-inline float-right">
            <li class="d-inline ml-2">赞 11</li>
            <li class="d-inline ml-2">|</li>
            <li class="d-inline ml-2">回帖 7</li>
          </ul>
        </div>
      </div>            
    </li>
  </ul>

5.2分页功能页面

  <!-- 分页 -->
  <nav class="mt-5" th:if="${page.rows>0}" th:fragment="pagination">
    <ul class="pagination justify-content-center">
      <li class="page-item">
  <!--th:href="@{${page.path}(current=1,limit=5)}"等效于/index?current=1&limit=5-->
        <a class="page-link" th:href="@{${page.path}(current=1)}">首页</a>
      </li>
  <!--th:class="|page-item ${page.current==1?'disabled':''}|" 动态上一页禁用  固定数据+变量使用方法:加|| -->
      <li th:class="|page-item ${page.current==1?'disabled':''}|">
        <a class="page-link" th:href="@{${page.path}(current=${page.current-1})}">上一页</a>
      </li>
  <!--#numbers.sequence #调用thymelead自带工具numbers,从from到to的数组-->
      <li th:class="|page-item ${i==page.current?'active':''}|" th:each="i:${#numbers.sequence(page.from,page.to)}">
        <a class="page-link" href="#" th:text="${i}">1</a>
      </li>

      <li th:class="|page-item ${page.current==page.total?'disabled':''}|">
        <a class="page-link" th:href="@{${page.path}(current=${page.current+1})}">下一页</a>
      </li>
      <li class="page-item">
        <a class="page-link" th:href="@{${page.path}(current=${page.total})}">末页</a>
      </li>
    </ul>
  </nav>

注册登录功能

发送邮件

1.邮箱设置:启用SMTP服务

2.SpringEmail

2.1配置xml文件

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-mail</artifactId>
    <version>2.6.6</version>
</dependency>

2.2在application.yml配置邮箱参数

#  配置邮箱
spring:
  mail:
    host: smtp.qq.com
    port: 465
    username: xxx@qq.com //本网站的发送方
    password: xxx  //密码为生成授权码后给的密码
    protocol: smtps

2.3创建MailClient邮箱工具类

@Component
public class MailClient {

    private static final Logger logger= LoggerFactory.getLogger(MailClient.class);

    @Autowired
    private JavaMailSender javaMailSender;

    @Value("${spring.mail.username}")//将yml的属性注入到from
    private String from;

    public void sendMail(String to,String subject,String content){
        try {
        //MimeMessage用于封装邮件相关信息
            MimeMessage message = javaMailSender.createMimeMessage();
            //需要一个邮件帮助器,负责构建MimeMessage对象
            MimeMessageHelper helper = new MimeMessageHelper(message);
            helper.setFrom(from);
            helper.setTo(to);
            helper.setSubject(subject);
            //支持HTML文本
            helper.setText(content,true);
            //发送邮件都有JavaMailSender来做
            javaMailSender.send(helper.getMimeMessage());
        }catch (MessagingException e){
            logger.error("发送邮件失败:"+e.getMessage());
        }
    }
}

2.4测试类

@Autowired
private MailClient mailClient;

@Autowired
private TemplateEngine templateEngine;//注入HTML模板引擎类,模板格式化

@Test
public void testTextMail(){//发送文本类型邮件
    mailClient.sendMail("xmy981022@163.com","Test","Welcome");
}

@Test
public void testHTMLMail(){//发送thymeleaf html类型文件
    Context context = new Context();
    context.setVariable("username","Nevermore");
    String content = templateEngine.process("/mail/activation", context);
    mailClient.sendMail("xmy981022@163.com","HTML",content);
}


注意:JavaMailSender和TemplateEngine会被自动注入到spring中

注册功能

1.配置application.properties文件

community.path.domain: http://localhost:8080
server.servlet.context-path: /community

2.创建工具类(处理MD5加密、生成随机数、激活标志接口)

public class CommunityUtil {
    /*
    * 生成随机字符串
    * 用于邮件激活码,salt5位随机数加密
    **/
    public static String generateUUID(){
        return UUID.randomUUID().toString().replaceAll("-","");
    }
    /* MD5加密
    * hello-->abc123def456
    * hello + 3e4a8-->abc123def456abc
    */
    public static String md5(String key){
        if (StringUtils.isBlank(key)){
            return null;
        }
        //MD5加密方法
        return DigestUtils.md5DigestAsHex(key.getBytes());
        //参数是bytes型
    }
}
public interface CommuityConstant {
    /*      以下用于注册功能      */
    /** 激活成功*/
    int ACTIVATION_SUCCESS=0;
    /** 重复激活 */
    int ACTIVATION_REPEAT=1;
    /** 激活失败 */
    int ACTIVATION_FAILURE=2;
    
    /*      以下用于登录功能*      /
    /**  
     * 默认状态的登录凭证的超时时间
     */
    int DEFAULT_EXPIRED_SECONDS=3600*12;
    /**
     * 记住状态的登录凭证超时时间
     */
    int REMEMBER_EXPIRED_SECONDS=3600*24*7;
}

3.编写Service业务层(实现CommuityConstant接口)

3.1注册业务

//..注入userMapper,mailClient,templateEngine
@Value("${community.path.domain}")
private String domain;
@Value("${server.servlet.context-path}")
private String contextPath;
//注册功能
/**为什么返回的是Map类型,因为用Map来存各种情况下的信息,返回给前端页面* */
public Map<String,Object> register(User user){
    HashMap<String, Object> map = new HashMap<>();
    /*
        判输入
     */
    if (user == null) {
        throw new IllegalArgumentException("参数不能为空!");
    }
    if (StringUtils.isBlank(user.getUsername())){
        map.put("usernameMsg","账户不能为空");
    }
    if (StringUtils.isBlank(user.getPassword())){
        map.put("passwordMsg","密码不能为空");
    }
    if (StringUtils.isBlank(user.getEmail())){
        map.put("emailMsg","邮箱不能为空");
    }
    /*
        判存在
     */
    User u = userMapper.selectByName(user.getUsername());
    if (u != null) {
        map.put("usernameMsg","该账号已存在!");
        return map;
    }
    u = userMapper.selectByEmail(user.getEmail());
    if (u != null) {
        map.put("emailMsg","该邮箱已被注册!");
        return map;
    }
    /*
        注册账户
        1.设置salt加密(随机5位数加入密码)
        2.设置密码+salt
        3.设置随机数激活码
        4.设置status,type=0,时间
        5.设置头像(动态)
          user.setHeaderUrl(String.format("http://images.nowcoder.com/head/%dt.png",new Random().nextInt(1000))
     */
    user.setSalt(CommunityUtil.generateUUID().substring(0,5));
    user.setPassword(CommunityUtil.md5(user.getPassword()+user.getSalt()));
    user.setActivationCode(CommunityUtil.generateUUID());
    user.setHeaderUrl(String.format("http://images.nowcoder.com/head/%dt.png", new Random().nextInt(1000)));
    user.setStatus(0);
    user.setType(0);
    user.setCreateTime(new Date());
    userMapper.insertUser(user);
    /*
        激活邮件
        1.创建Context对象-->context.setVariable(name,value)将name传入前端
          为thymeleaf提供变量
        2.设置email和url
        3.templateEngine.process执行相应HTML
        4.发送邮件
     */
    Context context = new Context();
    context.setVariable("email",user.getEmail());
    //http://localhost:8080/community/activation/101/code激活链接
    String url=domain+contextPath+"/activation/"+user.getId()+"/"+user.getActivationCode();
    context.setVariable("url",url);

    String content = templateEngine.process("/mail/activation", context);
    mailClient.sendMail(user.getEmail(),"激活账号",content);
    return map;
}

3.2激活邮件业务

/**激活邮件功能* */
  public int activation(int userId,String code){
      User user = userMapper.selectById(userId);
      if (user.getStatus()==1){
          return ACTIVATION_REPEAT;
      }else if (user.getActivationCode().equals(code)){
          userMapper.updateStatus(userId,1);
          return ACTIVATION_SUCCESS;
      }else {
          return ACTIVATION_FAILURE;
      }
  }

4.编写Controller层

//注册Controller
@RequestMapping(value = "/register",method = RequestMethod.POST)
public String register(Model model, User user){
    Map<String, Object> map = userService.register(user);
    if (map == null || map.isEmpty()){
        map.put("msg","注册成功,我们已经向您的邮件发送了一封激活邮件,请尽快激活!");
        map.put("target","/index");
        return "/site/operate-result";
    }else{
        model.addAttribute("usernameMsg",map.get("usernameMsg"));
        model.addAttribute("passwordMsg",map.get("passwordMsg"));
        model.addAttribute("emailMsg",map.get("emailMsg"));
        return "/site/register";
    }
}
/**激活邮件Controller**/
//http://localhost:8080/community/activation/101/code激活链接
@RequestMapping(value = "/activation/{userId}/{code}",method = RequestMethod.GET)
public String activation(Model model, @PathVariable("userId") int userId,@PathVariable("code") String code){
    int result = userService.activation(userId, code);
    if (result == ACTIVATION_SUCCESS){
        model.addAttribute("msg","激活成功,你的账号已经可以正常使用了!");
        model.addAttribute("target","/login");
    }else if (result == ACTIVATION_REPEAT){
        model.addAttribute("msg","无效操作,该账号已经激活过了!");
        model.addAttribute("target","/index");
    }else {
        model.addAttribute("msg","激活失败,你提供的激活码不正确!");
        model.addAttribute("target","/index");
    }
    return "/site/operate-result";
}

5.编写前端Thymeleaf页面核心点

/**注册页面 */
<form class="mt-5" method="post" th:action="@{/register}">
  <div class="col-sm-10">
    <input type="text"
         th:class="|form-control ${usernameMsg!=null?'is-invalid':''}|"
         th:value="${user!=null?user.username:''}"
         id="username" name="username" placeholder="请输入您的账号!" required>
    <div class="invalid-feedback" th:text="${usernameMsg}">
      该账号已存在!    <!--该div的显示与is-invalid有关-->
    </div>
    <button type="submit" class="btn btn-info text-white form-control">立即注册</button>
  </div>
</form>

/**账号激活中间页* */
<div class="jumbotron">
  <p class="lead" th:text="${msg}">激活状态信息</p>
  <p>
    系统会在 <span id="seconds" class="text-danger">8</span> 秒后自动跳转,
    您也可以点此 <a id="target" th:href="@{${target}}" class="text-primary">链接</a>, 手动跳转!
  </p>
</div>
<!--自动跳转Js -->
<script>
  $(function(){
    setInterval(function(){
      var seconds = $("#seconds").text();
      $("#seconds").text(--seconds);
      if(seconds == 0) {
        location.href = $("#target").attr("href");
      }
    }, 1000);
  });
</script>

/**邮箱模板页* */
<div>
  <p><b th:text="${email}">xxx@xxx.com</b>, 您好!</p>
  <p>
    您正在注册xxx, 这是一封激活邮件, 请点击 
    <a th:href="${url}">此链接</a>,
    激活您的xxx账号!
  </p>
</div>

生成验证码

参考网站 :code.google.com/archive/p/k…

注意:1.Producer是Kaptcha的核心接口 2.DefaultKaptcha是Kaptcha核心接口的默认实现类

3.Spring Boot没有为Kaptcha提供自动配置

1.引入pom.xml

<dependency>
    <groupId>com.github.penggle</groupId>
    <artifactId>kaptcha</artifactId>
    <version>2.3.2</version>
</dependency>

2.创建配置类装配第三方bean

@Configuration
public class KaptchaConfig {
    @Bean
    public Producer KaptchaProducer(){
        /**         
         * 手动创建properties.xml配置文件对象*         
         * 设置验证码图片的样式,大小,高度,边框,字体等
         */
        Properties properties=new Properties();
        properties.setProperty("kaptcha.border", "yes");
        properties.setProperty("kaptcha.border.color", "105,179,90");
        properties.setProperty("kaptcha.textproducer.font.color", "black");
        properties.setProperty("kaptcha.image.width", "110");
        properties.setProperty("kaptcha.image.height", "40");
        properties.setProperty("kaptcha.textproducer.font.size", "32");
        properties.setProperty("kaptcha.textproducer.char.string", "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ");
        properties.setProperty("kaptcha.textproducer.char.length", "4");
        properties.setProperty("kaptcha.textproducer.font.names", "宋体,楷体,微软雅黑");

        DefaultKaptcha Kaptcha=new DefaultKaptcha();
        Config config=new Config(properties);
        Kaptcha.setConfig(config);

        return Kaptcha;
    }
}

3.编写Controller接口

@RequestMapping(value = "/kaptcha",method = RequestMethod.GET)
public void getKaptcha(HttpServletResponse response, HttpSession session){
    //生成验证码
    String text = kaptchaProducer.createText();
    BufferedImage image = kaptchaProducer.createImage(text);
    //将验证码存入session
    session.setAttribute("kaptcha",text);
    //将图片输出给浏览器
    response.setContentType("image/png");
    try {
        ServletOutputStream os = response.getOutputStream();
        ImageIO.write(image,"png",os);
    }catch (IOException e){
        logger.error("响应验证码失败:"+e.getMessage());
    }
}

4.Thymeleaf前端页面核心点

<div class="col-sm-4">
  <img th:src="@{/kaptcha}" id="kaptchaImage" style="width:100px;height:40px;" class="mr-2"/>
  <a href="javascript:refresh_kaptcha();" class="font-size-12 align-bottom">刷新验证码</a>
</div>
<!--对应的Js-->
<script>
  function refresh_kaptcha() {
    var path = CONTEXT_PATH + "/kaptcha?p=" + Math.random();
    $("#kaptchaImage").attr("src", path);
  }
</script>
<!--全局Js配置文件-->
var CONTEXT_PATH="/community";

登录功能

验证账号,密码,验证码(成功:生成登录凭证ticket,发放给客户端 失败:跳转回登录页 )

1.创建登录凭证实体类(登录凭证相当于Session的作用)

注意 :为什么要搞一个登录凭证,因为最好不要将User信息存入Model返回给前端,敏感信息尽量不要返回给浏览器,不安全,而是选择ticket凭证,通过ticket可以在服务器端得到User

2.编写Dao层接口(注解方式实现)

  @Insert({
          "insert into login_ticket(user_id,ticket,status,expired) ",
          "values (#{userId},#{ticket},#{status},#{expired})"
  })
  @Options(useGeneratedKeys = true,keyProperty = "id")
  //登录功能需要添加登录凭证ticket
  int insertLoginTicket(LoginTicket loginTicket);
  
  @Select({
          "select id,user_id,ticket,status,expired ",
          "from login_ticket ",
          "where ticket=#{ticket}"
  })
  //检查登录状态
  LoginTicket selectByTicket(String ticket);
  
  /**
   *  一定要加@Param()不然会报错
   *  退出功能需要修改status状态
   *  @return error:com.mysql.jdbc.MysqlDataTruncation:Data truncation:Truncated incorrect DOUBLE value:...
   */
  @Update({
          "update login_ticket set status=#{status} where ticket=#{ticket} "
  })
  int updateStatus(@Param("ticket") String ticket, @Param("status") int status);

3.编写Service层登录业务

  /**登录功能**/
  public Map<String,Object> login(String username,String password,int expiredSeconds){
      HashMap<String, Object> map = new HashMap<>();
      //空值处理
      if(StringUtils.isBlank(username)){
          map.put("usernameMsg","号码不能为空!");
          return map;
      }
      if(StringUtils.isBlank(password)){
          map.put("passwordMsg","密码不能为空!");
          return map;
      }
      //验证账号
      User user = userMapper.selectByName(username);
      if (user==null){
          map.put("usernameMsg","该账号不存在!");
          return map;
      }
      //验证激活状态
      if (user.getStatus()==0){
          map.put("usernameMsg","该账号未激活!");
          return map;
      }
      //验证密码(先加密再对比)
      password=CommunityUtil.md5(password+user.getSalt());
      if (!user.getPassword().equals(password)){
          map.put("passwordMsg","密码输入错误!");
          return map;
      }
      //生成登录凭证(相当于记住我这个功能==session)
      LoginTicket ticket = new LoginTicket();
      ticket.setUserId(user.getId());
      ticket.setTicket(CommunityUtil.generateUUID());
      ticket.setStatus(0);
      //当前时间的毫秒数+过期时间毫秒数
      ticket.setExpired(new Date(System.currentTimeMillis() + expiredSeconds * 1000));
      loginTicketMapper.insertLoginTicket(ticket);

      map.put("ticket",ticket.getTicket());

      return map;
  }

4.编写Controller层

   /**
    * 登录功能
    * @param username
    * @param password
    * @param code 用于校验验证码
    * @param rememberme  记住我(登录凭证)
    * @param model 用于将数据传递给前端页面
    * @param session 用于获取kaptcha验证码
    * @param response 用于浏览器接受cookie
    * @return
    */
    @RequestMapping(value = "/login",method = RequestMethod.POST)
    /**注意username,password这些没有封装进model* */
    public String login(String username, String password, String code, boolean rememberme,
                        Model model, HttpSession session,HttpServletResponse response){
        //首先检验验证码
        String kaptcha = (String) session.getAttribute("kaptcha");
        if (StringUtils.isBlank(kaptcha)||StringUtils.isBlank(code)||!kaptcha.equalsIgnoreCase(code)){
            model.addAttribute("codeMsg","验证码不正确!");
            return "/site/login";
        }
        /**
         * 1.验证用户名和密码(重点)
         * 2.传入浏览器cookie=ticket
         */
        int expiredSeconds=rememberme?REMEMBER_EXPIRED_SECONDS:DEFAULT_EXPIRED_SECONDS;
        Map<String, Object> map = userService.login(username, password, expiredSeconds);
        if (map.containsKey("ticket")){
            Cookie cookie = new Cookie("ticket",map.get("ticket").toString());
            cookie.setPath(contextPath);
            cookie.setMaxAge(expiredSeconds);
            response.addCookie(cookie);
            return "redirect:/index";
        }else{
            model.addAttribute("usernameMsg",map.get("usernameMsg"));
            model.addAttribute("passwordMsg",map.get("passwordMsg"));
            return "/site/login";
        }
    }

5.编写前端Thymeleaf页面核心点

<div class="col-sm-10">
  <input type="text"
       th:class="|form-control ${usernameMsg!=null?'is-invalid':''}|"
       <!--注意param,因为username,password这些没有封装进model-->
       th:value="${param.username}"
       id="username" name="username" placeholder="请输入您的账号!" required>
  <div class="invalid-feedback" th:text="${usernameMsg}">
    该账号不存在!
  </div>
</div>
<div class="col-sm-10">
  <input type="checkbox" id="remember-me" name="rememberme"
       th:checked="${param.rememberme}">
  <label class="form-check-label" for="remember-me">记住我</label>
  <a href="forget.html" class="text-danger float-right">忘记密码?</a>
</div>

退出登录功能

将登录凭证loginTicket中的status置为无效

1.编写Service层

public void logout(String ticket){
    loginTicketMapper.updateStatus(ticket,1);//来源于LoginTicket的Dao层
}

2.编写Controller层

  /**
   * 退出登录功能
   * @CookieValue()注解:将浏览器中的Cookie值传给参数 
   */
  @RequestMapping(value = "/logout",method = RequestMethod.GET)
  public String logout(@CookieValue("ticket") String ticket){
      userService.logout(ticket);
      return "redirect:/login";//重定向
  }

显示登录信息

涉及到 :拦截器,多线程

拦截器Demo示例

注意:

   1. 拦截器需实现HandlerInterceptor接口而配置类需实现WebMvcConfigurer接口。

   2. preHandle方法在Controller之前执行,若返回false,则终止执行后续的请求。

   3. postHandle方法在Controller之后、模板页面之前执行。

   4. afterCompletion方法在模板之后执行。

   5. 通过addInterceptors方法对拦截器进行配置

1.创建拦截器类,实现HandlerInterceptor接口

@Component
public class DemoInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        System.out.println("preHandle:在Controller之前执行");
        return true;
    }
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        System.out.println("afterCompletion:在模板之后执行");
    }
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        System.out.println("postHandle:在Controller之后,前端模板引擎页面渲染之前执行");
    }
}

2.创建拦截器配置类,实现WebMvcConfigurer接口

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Autowired
    private DemoInterceptor demoInterceptor;
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(demoInterceptor)
                .excludePathPatterns("/ **/ *.css","/* */*.js","/**/ *.png","/* */*.jpg","/ **/ *.jpeg")
                .addPathPatterns("/register","/login");
}

1.首先创建两个工具类降低耦合(Request获取Cookie工具类,获取凭证ticket多线程工具类)

注意:1.ThreadLocal采用线程隔离的方式存放数据,可以避免多线程之间出现数据访问冲突。

2.ThreadLocal提供set方法,能够以当前线程为key存放数据。get方法,能够以当前线程为key获取数据。

3.ThreadLocal提供remove方法,能够以当前线程为key删除数据。

public class CookieUtil {
    public static String getValue(HttpServletRequest request,String name){
        if (request==null||name==null){
            throw new IllegalArgumentException("参数为空!");
        }
        Cookie[] cookies = request.getCookies();
        if (cookies!=null){
            for (Cookie cookie : cookies){
                if (cookie.getName().equals(name)){
                    return cookie.getValue();
                }
            }
        }
        return null;
    }
}
@Component  //放入容器里不用设为静态方法
public class HostHolder {
//key就是线程对象,值为线程的变量副本
    private ThreadLocal<User> users = new ThreadLocal<>();
    /**以线程为key存入User* */
    public void setUser(User user){
        users.set(user);
    }
    /**从ThreadLocal线程中取出User* */
    public User getUser(){
        return users.get();
    }
    /**释放线程* */
    public void clear(){
        users.remove();
    }
}

2.编写Service层

/**通过Cookie=ticket获取登录用户* */
public LoginTicket getLoginTicket(String ticket){
    return loginTicketMapper.selectByTicket(ticket);
}

3.创建登录凭证拦截器类(等同于Controller类)

@Component
public class LoginTicketInterceptor implements HandlerInterceptor {

    @Autowired
    private UserService userService;
    @Autowired
    private HostHolder hostHolder;

    @Override
    /**在Controller访问所有路径之前获取凭证* */
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        /**从浏览器Cookie中获取凭证* */
        String ticket=CookieUtil.getValue(request,"ticket");

        if (ticket!=null){
            //查询凭证
            LoginTicket loginTicket = userService.getLoginTicket(ticket);
            //检查凭证是否有效(after:当前时间之后)
            if (loginTicket!=null&&loginTicket.getStatus()==0&&loginTicket.getExpired().after(new Date())){
                //根据凭证查询用户
                User user = userService.findUserById(loginTicket.getUserId());
                /**在本次请求中持有用户
                 * 类似于存入Map,只是考虑到多线程
                 */
                hostHolder.setUser(user);
            }
        }
        return true;
    }
    @Override
    /**模板之前处理数据* */
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        User user = hostHolder.getUser();
        if (user!=null && modelAndView !=null){
            modelAndView.addObject("loginUser",user);
        }
    }
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //释放线程资源
        hostHolder.clear();
    }
}

4.编写拦截器配置类

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Autowired
    private LoginTicketInterceptor loginTicketInterceptor;
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginTicketInterceptor)
                .excludePathPatterns("/* */*.css","/**/ *.js","/* */*.png","/ **/ *.jpg","/* */*.jpeg");
    }}

5.前端页面核心点修改

th:if="${loginUser!=null}" 存在凭证显示<li>,不存在则不显示

<li class="nav-item ml-3 btn-group-vertical" th:if="${loginUser!=null}">
  <a class="nav-link position-relative" href="site/letter.html">消息<span class="badge badge-danger">12</span></a>
</li>

拦截未登录页面的路径访问(自定义拦截器注解)

常用的元注解: @Target:注解作用目标(方法or类) @Retention:注解作用时间(运行时or编译时) @Document:注解是否可以生成到文档里 @Inherited注解继承该类的子类将自动使用@Inherited修饰

注意: 若有2个拦截器,拦截器执行顺序为注册在WebMvcConfig配置类中的顺序

1.自定义拦截方法类注解(annotation包)并加在需要拦截的方法上

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
/**
 * 标记未登录时要拦截的路径访问方法
 */
public @interface LoginRequired {
}
/**加在需要拦截的方法**/
@LoginRequired

2.编写拦截器类实现HandlerInterceptor父类

  @Autowired
  //注入hostHolder工具类获取当前状态登录用户
  private HostHolder hostHolder;
  
  @Override
  /**在请求路径前执行该方法* */
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
      //判断拦截的目标是不是一个方法
      if (handler instanceof HandlerMethod){
          //如果是一个方法,将handler转化我HandlerMethod类型
          HandlerMethod handlerMethod = (HandlerMethod) handler;
          Method method = handlerMethod.getMethod();
          //获取方法上的自定义注解
          LoginRequired loginRequired = method.getAnnotation(LoginRequired.class);
         /**
          * 如果没有登录并且有自定义注解(需要登录才能访问的方法注解)
          * 通过response来重定向,这里不可以通过return 重定向
          */
          if (hostHolder.getUser()==null&&loginRequired!=null){
              response.sendRedirect(request.getContextPath() + "/login");
              return false;
          }
      }
      return true;
  }

3.注册进拦截器配置类WebMvcConfig

  @Autowired
  private LoginRequiredInterceptor loginRequiredInterceptor;
  
   @Override
  public void addInterceptors(InterceptorRegistry registry) {
      registry.addInterceptor(loginRequiredInterceptor)
              .excludePathPatterns("/* */*.css","/**/ *.js","/* */*.png","/ **/ *.jpg","/* */*.jpeg");
  }

修改密码

1.编写Dao层

int updatePassword(@Param("id") int id,@Param("password")String password);
<update id="updatePassword">
    update user set password=#{password} where id=#{id}
</update>

2.编写Service层

  /**修改密码**/
  public Map<String,Object> updatePassword(int userId,String oldPassword,String newPassword){
      HashMap<String, Object> map = new HashMap<>();
  
      // 空值处理
      if (StringUtils.isBlank(oldPassword)) {
          map.put("oldPasswordMsg", "原密码不能为空!");
          return map;
      }
      if (StringUtils.isBlank(newPassword)) {
          map.put("newPasswordMsg", "新密码不能为空!");
          return map;
      }
  
      // 验证原始密码
      User user = userMapper.selectById(userId);
      oldPassword = CommunityUtil.md5(oldPassword + user.getSalt());
  
      if (!user.getPassword().equals(oldPassword)){
          map.put("oldPasswordMsg","您输入的原密码错误!");
          return map;
      }
      newPassword = CommunityUtil.md5(newPassword + user.getSalt());
      userMapper.updatePassword(userId,newPassword);
  
      return map;
  }

3.编写Controller层

  /**修改密码 **/
  @RequestMapping(value = "/updatePassword",method = RequestMethod.POST)
  public String updatePassword(String oldPassword, String newPassword, Model model){
      User user = hostHolder.getUser();
      Map<String, Object> map = userService.updatePassword(user.getId(), oldPassword, newPassword);
      if (map == null || map.isEmpty()){
          /**如果更改密码成功,退出登录,并跳到登录页面 **/
          return "redirect:/logout";
      }else{
          model.addAttribute("oldPasswordMsg",map.get("oldPasswordMsg"));
          model.addAttribute("newPasswordMsg",map.get("newPasswordMsg"));
          return "/site/setting";
      }
  }

忘记密码

1.编写Service层

    // 判断邮箱是否已注册
    public boolean isEmailExist(String email) {
        User user = userMapper.selectByEmail(email);
        return user != null;
    }
    
     /**
      * 重置忘记密码
      */
    public Map<String, Object> resetPassword(String email, String password) {
        HashMap<String, Object> map = new HashMap<>();

        //空值处理
        if (StringUtils.isBlank(email)) {
            map.put("emailMsg", "邮箱不能为空!");
            return map;
        }
        if (StringUtils.isBlank(password)) {
            map.put("passwordMsg", "密码不能为空!");
            return map;
        }

        //根据邮箱查找用户
        User user = userMapper.selectByEmail(email);
        if (user == null) {
            map.put("emailMsg", "该邮箱尚未注册!");
            return map;
        }

        //重置密码
        password = CommunityUtil.md5(password + user.getSalt());
        userMapper.updatePassword(user.getId(), password);
        // 清理缓存
        clearCache(user.getId());

        //注意这里!
        map.put("user", user);

        return map;
    }

2.编写Controller层

    /**
     * 忘记密码页面
     */
    @RequestMapping(path = "/forget", method = RequestMethod.GET)
    public String getForgetPage() {
        return "/site/forget";
    }
    
    /**
     * 重置密码
     */
    @RequestMapping(path = "/forget/password", method = RequestMethod.POST)
    public String resetPassword(String email, String verifyCode, String password, Model model, HttpSession session) {
        String code = (String) session.getAttribute(email + "_verifyCode");

        if (StringUtils.isBlank(verifyCode) || StringUtils.isBlank(code) || !code.equalsIgnoreCase(verifyCode)) {
            model.addAttribute("codeMsg", "验证码错误!");
            return "/site/forget";
        }

        Map<String, Object> map = userService.resetPassword(email, password);
        if (map.containsKey("user")) {
            return "redirect:/login";
        } else {
            model.addAttribute("emailMsg", map.get("emailMsg"));
            model.addAttribute("passwordMsg", map.get("passwordMsg"));
            return "/site/forget";
        }
    }    

3.编写前端核心部分

  <form method="post" th:action="@{/forget/password}">
      <div>
          <label class="col-sm-2" for="your-email">邮箱:</label>
          <div>
              <input id="your-email" name="email" placeholder="请输入您的邮箱!" required
                     th:class="|form-control ${emailMsg!=null?'is-invalid':''}|" th:value="${param.email}"
                     type="email">
              <div th:text="${emailMsg}">
              <input **id="your-email" name="email" placeholder="请输入您的邮箱!" required
                     th:class="|form-control ${emailMsg!=null?'is-invalid':''}|" th:value="${param.email}"
                     type="email">**
              <div **th:text="${emailMsg}"**>
                  该邮箱已被注册!
              </div>
          </div>
      </div>
      <div >
          <label class="col-sm-2" for="verifycode">验证码:</label>
          <div>
              <input id="verifycode" name="verifyCode" placeholder="请输入验证码!"
                     th:class="|form-control ${codeMsg!=null?'is-invalid':''}|" th:value="${param.verifyCode}"
                     type="text">
              <input **id="verifycode" name="verifyCode" placeholder="请输入验证码!"
                     th:class="|form-control ${codeMsg!=null?'is-invalid':''}|" th:value="${param.verifyCode}"
                     type="text">**
              <div th:text="${codeMsg}">
                  验证码不正确!
              </div>
          </div>
          <div>
              <a class="btn" id="verifyCodeBtn">获取验证码</a>
          </div>
      </div>
      <div>
          <label class="col-sm-2" for="your-password">新密码:</label>
          <div class="col-sm-10">
              <input id="your-password" name="password" placeholder="请输入新的密码!" required
                     th:class="|form-control ${passwordMsg!=null?'is-invalid':''}|"
                     th:value="${param.password}" type="password">
              <input **id="your-password" name="password" placeholder="请输入新的密码!" required
                     th:class="|form-control ${passwordMsg!=null?'is-invalid':''}|"
                     th:value="${param.password}" type="password">**
              <div class="invalid-feedback" th:text="${passwordMsg}">
                  密码长度不能小于8位!
              </div>
          </div>
      </div>
      <button type="submit" class="btn">重置密码</button>
  </form>

优化登录功能(使用Redis)

使用Redis存储验证码

1.编写RedisUtil工具类设置验证码key值

public class RedisKeyUtil {
    // 验证码
    private static final String PREFIX_KAPTCHA = "kaptcha";
    /**登录验证码**/
    public static String getKaptchaKey(String owner) {
        return PREFIX_KAPTCHA + SPLIT + owner;
    }
}

2.优化LoginController验证码相关代码(优化前是存在session中的)

    @Autowired
    private RedisTemplate redisTemplate;
    /**
     * 验证码功能 (Redis优化)
     * @param response
     * /
    @RequestMapping(value = "/kaptcha", method = RequestMethod.GET)
    public void getKaptcha(HttpServletResponse response) {
        //生成验证码
        String text = kaptchaProducer.createText();
        BufferedImage image = kaptchaProducer.createImage(text);
        //优化前:将验证码存入session.....

        //优化后:生成验证码的归属传给浏览器Cookie
        String kaptchaOwner = CommunityUtil.generateUUID();
        Cookie cookie = new Cookie("kaptchaOwner", kaptchaOwner);
        cookie.setMaxAge(60);
        cookie.setPath(contextPath);
        response.addCookie(cookie);

        //优化后:将验证码存入Redis
        String redisKey = RedisKeyUtil.getKaptchaKey(kaptchaOwner);
        redisTemplate.opsForValue().set(redisKey, text, 60 , TimeUnit.SECONDS);

        //将图片输出给浏览器
        response.setContentType("image/png");
        try {
            ServletOutputStream os = response.getOutputStream();
            ImageIO.write(image, "png", os);
        } catch (IOException e) {
            logger.error("响应验证码失败:" + e.getMessage());
        }
    }
    /**
     * 登录功能
     * @param redisKey 用于获取kaptcha验证码
     * @param @CookieValue用于浏览器接受cookie
     * @return
     */
    @RequestMapping(value = "/login", method = RequestMethod.POST)
    /**注意username,password这些没有封装进model**/
    public String login(String username, String password, String code, boolean rememberme,
                        Model model, HttpServletResponse response,
                        @CookieValue("kaptchaOwner") String kaptchaOwner) {
        /**
         * 优化前:首先检验验证码(从session取验证码)
         * String kaptcha = (String) session.getAttribute("kaptcha");
         */

        // 优化后:从redis中获取kaptcha的key
        String kaptcha = null;
        // 判断从浏览器传来的Cookie是否为空
        if (StringUtils.isNotBlank(kaptchaOwner)) {
            String redisKey = RedisKeyUtil.getKaptchaKey(kaptchaOwner);
            // 获取key为验证码的redis数据
            kaptcha  = (String) redisTemplate.opsForValue().get(redisKey);
        }

        if (StringUtils.isBlank(kaptcha) || StringUtils.isBlank(code) || !kaptcha.equalsIgnoreCase(code)) {
            model.addAttribute("codeMsg", "验证码不正确!");
            return "/site/login";
        }
        /**
         * 1.验证用户名和密码(重点)
         * 2.传入浏览器cookie=ticket
         */
        int expiredSeconds = rememberme ? REMEMBER_EXPIRED_SECONDS : DEFAULT_EXPIRED_SECONDS;
        Map<String, Object> map = userService.login(username, password, expiredSeconds);
        if (map.containsKey("ticket")) {
            Cookie cookie = new Cookie("ticket", map.get("ticket").toString());
            cookie.setPath(contextPath);
            cookie.setMaxAge(expiredSeconds);
            response.addCookie(cookie);
            return "redirect:/index";
        } else {
            model.addAttribute("usernameMsg", map.get("usernameMsg"));
            model.addAttribute("passwordMsg", map.get("passwordMsg"));
            return "/site/login";
        }
    }

使用Redis存存登录凭证

1.编写RedisUtil工具类设置登录凭证key值

    // 登录凭证
    private static final String PREFIX_TICKET = "ticket";
    /**登录凭证**/
    public static String getTicketKey(String ticket) {
        return PREFIX_TICKET + SPLIT + ticket;
    }

2.优化UserService中LoginTicket相关代码(废弃LoginTicket数据库表,使用redis)

    @Autowired
    private RedisTemplate redisTemplate;
    /**
     * 登录功能(redis优化)
     */
    public Map<String, Object> login(String username, String password, int expiredSeconds) {
        HashMap<String, Object> map = new HashMap<>();
        //空值处理
        if (StringUtils.isBlank(username)) {
            map.put("usernameMsg", "号码不能为空!");
            return map;
        }
        if (StringUtils.isBlank(password)) {
            map.put("passwordMsg", "密码不能为空!");
            return map;
        }
        //验证账号
        User user = userMapper.selectByName(username);
        if (user == null) {
            map.put("usernameMsg", "该账号不存在!");
            return map;
        }
        //验证激活状态
        if (user.getStatus() == 0) {
            map.put("usernameMsg", "该账号未激活!");
            return map;
        }
        //验证密码(先加密再对比)
        password = CommunityUtil.md5(password + user.getSalt());
        if (!user.getPassword().equals(password)) {
            map.put("passwordMsg", "密码输入错误!");
            return map;
        }
        //生成登录凭证(相当于记住我这个功能==session)
        LoginTicket ticket = new LoginTicket();
        ticket.setUserId(user.getId());
        ticket.setTicket(CommunityUtil.generateUUID());
        ticket.setStatus(0);
        //当前时间的毫秒数+过期时间毫秒数
        ticket.setExpired(new Date(System.currentTimeMillis() + expiredSeconds*  1000));
        // 优化前:loginTicketMapper.insertLoginTicket(ticket);
        
        // 优化后:loginticket对象放入redis中
        String redisKey = RedisKeyUtil.getTicketKey(ticket.getTicket());
        // opsForValue将ticket对象序列化为json字符串
        redisTemplate.opsForValue().set(redisKey, ticket);

        map.put("ticket", ticket.getTicket());

        return map;
    }
    /**
    * 通过Cookie=ticket获取登录用户(redis优化)
    */
    public LoginTicket getLoginTicket(String ticket) {
        //优化前: return loginTicketMapper.selectByTicket(ticket);
        String redisKey = RedisKeyUtil.getTicketKey(ticket);
        return (LoginTicket) redisTemplate.opsForValue().get(redisKey);
    }

使用Redis缓存用户信息

1.编写RedisUtil工具类设置用户缓存key值

    // 用户缓存
    private static final String PREFIX_USER = "user";
    /**用户缓存**/
    public static String getUserKey(int userId) {
        return PREFIX_USER + SPLIT + userId;
    }

2.优化UserService中findUserById和userMapper.updateXXX方法

     /**
     * 因为经常使用这个方法,所以将它用redis缓存优化
     * 若缓存中有访问的用户直接从缓存中取出,否则从数据库查询后加入redis中作为缓存
     */
    public User findUserById(int userId) {
        // return userMapper.selectById(userId);
        // 从redis缓存中取值
        User user = getCache(userId);
        if (user == null) {
            user = initCache(userId);
        }
        return user;
    }
    
    /**
    * QQ:260602448 --作者:xumingyu
    * 更新头像
    */
    public int updateHeader(int userId, String headerUrl) {
        /** 同时处理mysql和redis事务的方法,报错回滚* */
        int rows = userMapper.updateHeader(userId, headerUrl);
        clearCache(userId);
        return rows;
    }
   
    // 1.优先从缓存中取值
    private User getCache(int userId) {
        String redisKey = RedisKeyUtil.getUserKey(userId);
        return (User) redisTemplate.opsForValue().get(redisKey);
    }
    // 2.取不到时初始化缓存数据(redis存值)
    private User initCache(int userId) {
        User user = userMapper.selectById(userId);
        String redisKey = RedisKeyUtil.getUserKey(userId);

        redisTemplate.opsForValue().set(redisKey, user, 3600, TimeUnit.SECONDS);
        return user;
    }
    // 3.数据变更时清除缓存(删除redis的key)
    private void clearCache(int userId) {
        String redisKey = RedisKeyUtil.getUserKey(userId);
        redisTemplate.delete(redisKey);
    }