02 开发社区登录模块

发布时间 2023-07-19 15:56:18作者: 阿四与你

发送邮件

流程

  • 邮箱设置

    • 启用客户端SMTP服务
  • Spring Email

    • 导入jar包
    • 邮箱参数配置
    • 使用JavaMailSender发送邮件
  • 模板引擎

    • 使用Thymeleaf发送HTML邮件

启用客户端SMTP服务

QQ邮箱在这里开启:

image

导入邮箱包

Spring Boot Starter Mail

maven坐标:

	<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-mail -->
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-mail</artifactId>
		<version>2.1.15.RELEASE</version>
	</dependency>

试了我好久,老师的2.1.5的版本用不了,最新的也不能兼容,随便试了一个15的反而可以了。搞jdk12和idea2021版本又花了我一两个小时。

邮件参数配置

application.properties

# MailProperties
spring.mail.host=smtp.qq.com
spring.mail.por=465
spring.mail.username=1563893963@qq.com
spring.mail.password=
spring.mail.protocol=smtps
spring.mail.properties.mail.ssl.enable=true

image

qq的smtp服务

代码实现

测试纯文本

在项目包下util下创建MailClient类:

package com.nowcoder.community.util;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Component;

import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;

/**
 * @author 008
 * @create 2023-07-17 10:04
 */
@Component
public class MailClient {
    private static final Logger logger= LoggerFactory.getLogger(MailClient.class);

    @Autowired
    private JavaMailSender mailSender;

    @Value("${spring.mail.username}")
    private String from;

    /**
     * 发送邮件
     * @param to
     * @param subject
     * @param content
     */
    public void sendMail(String to,String subject,String content){
        try {
            MimeMessage message = mailSender.createMimeMessage();
            MimeMessageHelper helper=new MimeMessageHelper(message);
            //设置邮件信息
            helper.setFrom(from);
            helper.setTo(to);
            helper.setSubject(subject);
            helper.setText(content,true);//true表示支持html的文本
            mailSender.send(helper.getMimeMessage());
        } catch (MessagingException e) {
            logger.error("发送邮件失败"+e.getMessage());
        }

    }
}

ctrl+alt+t将代码用try-catch包裹起来。

写一个MailTests测试类测试纯文本的邮件:

package com.nowcoder.community;

import com.nowcoder.community.util.MailClient;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;


@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = CommunityApplication.class)
public class MailTests {
    @Autowired
    private MailClient mailClient;

    @Test
    public void testTextMail(){
        mailClient.sendMail("931967477@qq.com","Test","Welcome.");
    }
}

使用Thymeleaf发送HTML邮件

templates下新建mail文件夹,新建demo.html,注意到这里老师又多了两个文件,也顺并拷到这里来。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>邮件实例</title>
</head>
<body>
    <p>欢迎你,<span style="color: red;" th:text="${username}"></span> </p>
</body>
</html>

写测试:

    //测试发送html的邮件。设置传给模板变量的值
    @Test
    public void testHtmlMail(){
        Context context=new Context();
        context.setVariable("username","sunday");
        //将content的变量值传给引擎处理,生成我们想要的内容
        String content=templateEngine.process("/mail/demo",context);
        System.out.println(content);
        //发送邮件
        mailClient.sendMail("931967477@qq.com","Html",content);
    }

开发注册功能

流程

  • 访问注册页面

    • 点击顶部区域内的链接,打开注册页面。
  • 提交注册数据

    • 通过表单提交数据。
    • 服务端验证账号是否已存在、邮箱是否已注册。
    • 服务端发送激活邮件。
  • 激活注册账号

    • 点击邮件中的链接,访问服务端的激活服务邮箱设置

访问注册页面

处理请求,跳转页面

创建LoginController类:

package com.nowcoder.community.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

@Controller
public class LoginController {
    @RequestMapping(path="/register",method= RequestMethod.GET)
    public String getRegisterPage(){
        return "/site/register";
    }
}

处理页面信息

  • 处理register.html首尾的相对路径,使用模板引擎,和修改css<link rel="stylesheet" href="https://cdn.bootcss.com/bootstrap/4.3.1/css/bootstrap.min.css" crossorigin="anonymous">,其他的像之前一样处理。

  • 更改index.html的首页和注册内容:

    <a class="nav-link" th:href="@{/index}">首页

    <a class="nav-link" th:href="@{/register}">注册

  • index的头部header代码取别名,方便后续复用:<header class="bg-dark sticky-top" th:fragment="header">

  • register复用上面的代码:<header class="bg-dark sticky-top" th:replace="index::header">index::header表示index目录下的headder。

image

image

提交注册数据

配置好注册需要的工具类

复制Apache Commons Langmaven坐标

<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.9</version>
</dependency>

Lang为java.lang API提供了许多帮助程序实用程序,特别是字符串操作方法,基本数值方法,对象反射,并发,创建和序列化以及系统属性。此外,它还包含对java.util.Date的基本增强,以及一系列专用于构建方法的实用程序,例如hashCode,toString和equals。

在properties下配置:

# Community
community.path.domain=http://localhost:8080

util下创建CommunityUtil类:

package com.nowcoder.community.util;

import org.apache.commons.lang3.StringUtils;
import org.springframework.util.DigestUtils;

import java.util.UUID;

public class CommunityUtil {
    //生成随机字符串
    public static String generateUUID(){
        return UUID.randomUUID().toString().replaceAll("-","");
    }

    //md5加密,只能加密,不能解密--->密码+随机字符串,黑客就永远破解不出来了
    public static String md5(String key){
        if(StringUtils.isBlank(key)){
            return null;
        }
        return DigestUtils.md5DigestAsHex(key.getBytes());
    }
}

修改发送激活码的邮件模板

templates/mail/activation.html

<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link rel="icon" href="https://static.nowcoder.com/images/logo_87_87.png"/>
    <title>牛客网-激活账号</title>
</head>
<body>
	<div>
		<p>
			<b th:text="${email}">xxx@xxx.com</b>, 您好!
		</p>
		<p>
			您正在注册牛客网, 这是一封激活邮件, 请点击 
			<a th:href="${url}">此链接</a>,
			激活您的牛客账号!
		</p>
	</div>
</body>
</html>

注册用户业务实现

package com.nowcoder.community.service;

import com.mysql.cj.util.StringUtils;
import com.nowcoder.community.dao.UserMapper;
import com.nowcoder.community.entity.User;
import com.nowcoder.community.util.CommunityUtil;
import com.nowcoder.community.util.MailClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;

/**
 * @author 008
 * @create 2023-07-15 22:19
 */
@Service
public class UserService {
    @Autowired
    private UserMapper userMapper;

    @Autowired
    private MailClient mailClient;

    @Autowired
    private TemplateEngine templateEngine;

    @Value("${community.path.domain}")
    private String domain;

    @Value("${server.servlet.context-path}")
    private String contextPath;

    public User findUserById(int id){
        return userMapper.selectById(id);
    }

    /**
     * 注册用户,并发送邮件激活码
     * 返回多个错误信息才用的Map<String,Object>
     */
    public Map<String,Object> register(User user) {
        Map<String, Object> map = new HashMap<>();
        //空值处理
        if (user == null) {
            throw new IllegalArgumentException("参数不能为空");
        }
        if (StringUtils.isNullOrEmpty(user.getUsername())) {
            map.put("usernameMsg", "账号不能为空!");
            return map;
        }
        if (StringUtils.isNullOrEmpty(user.getPassword())) {
            map.put("passwordMsg", "密码不能为空!");
            return map;
        }
        if (StringUtils.isNullOrEmpty(user.getUsername())) {
            map.put("emailMsg", "邮箱不能为空!");
            return map;
        }

        //验证账号
        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;
        }

        //注册用户
        user.setSalt(CommunityUtil.generateUUID().substring(0,5));//保留五位
        user.setPassword(CommunityUtil.md5(user.getPassword()+user.getSalt()));
        user.setType(0);//普通用户
        user.setStatus(0);//没有激活
        user.setActivationCode(CommunityUtil.generateUUID());//设置激活码
        user.setHeaderUrl(String.format("http://images.nowcoder.com/head/%dt.png",new Random().nextInt(1000)));//生成默认头像
        user.setCreateTime(new Date());
        userMapper.insertUser(user);//mybatis会自动生成id

        //激活邮件
        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;
    }
}

在LoginController下处理交互

package com.nowcoder.community.controller;

import com.nowcoder.community.entity.User;
import com.nowcoder.community.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

import java.util.Map;

@Controller
public class LoginController {
    @Autowired
    private UserService userService;

    @RequestMapping(path="/register",method= RequestMethod.GET)
    public String getRegisterPage(){
        return "/site/register";
    }

    @RequestMapping(path = "/register",method = RequestMethod.POST)
    public String register(Model model, User user){
        Map<String,Object> map=userService.register(user);
        //注册成功之后
        if(map==null||map.isEmpty()){
            model.addAttribute("msg","注册成功,我们已经向您的邮箱发送了一封邮件,请尽快激活!");
            model.addAttribute("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";
        }
    }
}

修改页面

注册账号成功后的页面templates/site/operate-result.html

  • 头部区域的css改成相对路径,用模板引擎。
  • 更改操作信息内容:<p class="lead" th:text="${msg}">您的账号已经激活成功,可以正常使用了!<p></p>
  • 更改页面跳转:您也可以点此 <a id="target" th:href="@{${target}}"
  • 复用index的代码:<header class="bg-dark sticky-top" th:replace="index::header">
  • 底部有一个js文件需要需要路径:<script src="https://cdn.bootcss.com/bootstrap/4.3.1/js/bootstrap.min.js" crossorigin="anonymous">

register的页面:

  • 更改提交方式:<form class="mt-5" method="post" th:action="@{/register}">

  • 要补上User实体类当中相对应的username属性:<input type="text" class="form-control" id="username" name="username" placeholder="请输入您的账号!" required>。依次修改密码、邮箱。

  • 注册失败时返回页面,依然要保存数据,继续展示在页面上,此时的register页面的model有了user数据,th可以直接访问到,按照下面的方式,依次修改密码、邮箱。

    • <input type="password" class="form-control" id="password" name="password"
      								   th:value="${user!=null?user.username:''}"
      
  • 修改错误信息内容,按照下面的方式,依次修改密码(确认密码不需要)、邮箱。

    <div class="invalid-feedback" th:text="${usernameMsg}">
    								该账号已存在!
    							</div>
    
  • 修改错误信息内容展示,只有当错误信息存在才展示,所以我们必须要修改样式:

    • <input type="text" th:class="|form-control ${usernameMsg!=null?'is-valid':''}|" id="username" name="username" th:value="${user!=null?user.username:''}" placeholder="请输入您的账号!" required>
      

激活注册账号

激活有三种结果:1)失败。2)成功。3)重复激活。

定义结果常量

util下创建CommunityConstant接口:

package com.nowcoder.community.util;

public interface CommunityConstant {
    //激活成功
    int ACTIVATION_SUCCESS=0;
    //重复激活
    int ACTIVATION_REPEAT=1;
    //激活失败
    int ACTIVATION_FAILURE=2;
}

处理激活业务

UserService下,实现刚刚声明的接口,并增加下列方法:

    /**
     * 返回激活码验证情况
     * @param userId
     * @param code
     * @return
     */
    public int activation(int userId,String code){
        //我们可以从路径下获得id和激活码
        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;
        }
    }

LoginController处理激活请求

先实现CommunityConstant接口,新增以下方法:

    /**
     * 处理激活请求,跳转到正确页面
     * @param model
     * @param userId
     * @param code
     * @return
     */
    @RequestMapping(path="/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";
    }

处理登录页面

  • 处理login.html的相对路径、复用头部、使用引擎。
  • 修改index.html的登录的路径:<a class="nav-link" th:href="@{/login}">登录

在Controller下处理请求进行页面跳转:

    @RequestMapping(path="/login",method = RequestMethod.GET)
    public String getLoginPage(){
        return "/site/login";
    }

image

image

会话管理

介绍

  • HTTP的基本性质

    • HTTP是简单的
    • HTTP是可扩展的
    • HTTP是无状态的,有会话的
  • Cookie

    • 是服务器发送到浏览器,并保存在浏览器端的一小块数据。
    • 浏览器下次访问该服务器时,会自动携带块该数据,将其发送给服务器。
  • Session

    • 是JavaEE的标准,用于在服务端记录客户端信息。
    • 数据存放在服务端更加安全,但是也会增加服务端的内存压力

HTTP 无状态,但并非无会话

HTTP 是无状态的:在同一个连接中,两个执行成功的请求之间是没有关系的。这就带来了一个问题,用户没有办法在同一个网站中进行连贯的交互,比如在电商网站中使用购物车功能。尽管 HTTP 根本上来说是无状态的,但借助 HTTP Cookie 就可使用有状态的会话。利用标头的扩展性,HTTP Cookie 被加进了协议工作流程,每个请求之间就能够创建会话,让每个请求都能共享相同的上下文信息或相同的状态。

HTTP Cookie

HTTP Cookie(也叫 Web Cookie 或浏览器 Cookie)是服务器发送到用户浏览器并保存在本地的一小块数据。浏览器会存储 cookie 并在下次向同一服务器再发起请求时携带并发送到服务器上。通常,它用于告知服务端两个请求是否来自同一浏览器——如保持用户的登录状态。Cookie 使基于无状态的 HTTP 协议记录稳定的状态信息成为了可能。

实例——cookie的使用

cookie的运行模式

image

设置cookie

写在AlphaController下:

    //cookie示例
    //设置cookie
    @RequestMapping(path="/cookie/set",method = RequestMethod.GET)
    @ResponseBody
    public String setCookie(HttpServletResponse response){
        //创建cookie
        Cookie cookie=new Cookie("code", CommunityUtil.generateUUID());
        //设置cookie生效的范围
        cookie.setPath("/community/alpha");
        //设置cookie的生存时间
        cookie.setMaxAge(60*10);
        //发送cookie
        response.addCookie(cookie);
        return "set cookie";
    }

image

获取cookie

    //获取cookie
    @RequestMapping(path="/cookie/get",method = RequestMethod.GET)
    @ResponseBody
    public String getCookie(@CookieValue("code") String code){
        System.out.println(code);
        return "get cookie";
    }

image

实例——session的使用

session的运行模式

image

设置session

    //session实例
    @RequestMapping(path = "/session/set",method=RequestMethod.GET)
    @ResponseBody
    public String SetSession(HttpSession session){
        session.setAttribute("id",1);
        session.setAttribute("name","test");
        return "set session";
    }

image

获取session

    //获取session
    @RequestMapping(path = "/session/get",method=RequestMethod.GET)
    @ResponseBody
    public String GetSession(HttpSession session){
        System.out.println(session.getAttribute("id"));
        System.out.println(session.getAttribute("name"));
        return "get session";
    }

Session单台服务器适合用,多台服务器不用session。

在分布式部署服务器当中,服务器1先有session,后续浏览器再发送请求,因为服务器1忙碌,访问服务器3,而服务器3没有服务器1的session,就只能创建一个新的session,得不到服务器1的session。

image

所以我们必须设置服务器的负载均衡策略:

  • Session 粘滞(Sticky Sessions):同一个IP分布给同一个服务器,很难保证服务器之间是负载均衡的。
  • Session 复制:利用 Tomcat 等 Web 容器同步复制Session,一个服务器会同步给其他服务器,这会对服务器产生性能影响,还会产生耦合,对部署有影响。
  • 共享Session:将Session存到指定服务器当中,当其他服务器需要session的时候就访问这台服务器。但是由于这台服务器是单体服务器,万一挂机造成的影响就很大。

因此考虑到session的不便,我们最好存到cookie或者数据库里面,从数据库(Redis)读取数据比读内存速度要慢很多,还是有一定的瓶颈。

image

生成验证码

流程


  • 导入 jar 包
  • 编写 Kaptcha 配置类
  • 生成随机字符、生成图片

配置Kaptcha

maven坐标:

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

写一个KaptchaConfig配置类:

package com.nowcoder.community.config;

import com.google.code.kaptcha.Producer;
import com.google.code.kaptcha.util.Config;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Properties;

@Configuration
public class KaptchaConfig {
    @Bean
    public Producer kaptchaProducer(){
        Properties properties=new Properties();
        properties.setProperty("kaptcha.image.width","100");
        properties.setProperty("kaptcha.image.height","40");
        properties.setProperty("kaptcha.textproducer.font.size","32");
        properties.setProperty("kaptcha.textproducer.font.color","0,0,0");
        properties.setProperty("kaptcha.textproducer.char.string","0123456789ABCDEFGHIJKLMNOPQRSTUVWYZ");
        properties.setProperty("kaptcha.textproducer.char.length","4");//生成字符长度
        properties.setProperty("kaptcha.noise.impl","com.google.code.kaptcha.impl.NoNoise");//防止破解
        DefaultKaptcha kaptcha=new DefaultKaptcha();
        Config config=new Config(properties);
        kaptcha.setConfig(config);
        return kaptcha;
    }
}

处理验证码请求

    //生成验证码
    @RequestMapping(path="/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("img/png");
        try {
            OutputStream os = response.getOutputStream();
            ImageIO.write(image,"png",os);
        } catch (IOException e) {
            log.error("响应验证码失败:"+e.getMessage());
        }
    }

这里的日志是直接在类前加了一个@slf4j输出。

修改登录页面

修改login.html的验证码部分:

<img th:src="@{/kaptcha}" id="kaptcha" style="width:100px;height:40px;" class="mr-2"/>
							<a href="javascript:refresh_kaptcha();" class="font-size-12 align-bottom">刷新验证码</a>
  • 更改成访问请求kaptcha,加个id方便js调用。
  • 更改点击刷新验证码时,进入js方法。

global.js下新增一行:var CONTEXT_PATH="/community";用来储存路径值,方便后续在任何地方引用该变量。

在底部写js方法:

	<script>
		function refresh_kaptcha(){
			var path=CONTEXT_PATH+"/kaptcha?p="+Math.random();
			$("#kaptcha").attr("src",path);
		}
	</script>

加一个参数p是为了欺骗服务器,不然刷新一直用原本的路径,服务器就不会继续生成新的验证码。

开发登录退出功能

  • 访问登录页面

    • 点击顶部区域内的链接,打开登录页面。
  • 登录

    • 验证账号、密码、验证码。
    • 成功时,生成登录凭证,发放给客户端。
    • 失败时,跳转回登录页。
  • 退出

    • 将登录凭证修改为失效状态。
    • 跳转至网站首页。

实现登录验证功能

创建登录凭证相关功能模块

LoginTicket类:

package com.nowcoder.community.entity;

import java.util.Date;

public class LoginTicket {
    private int id;
    private int userId;
    private String ticket;
    private int status;

    @Override
    public String toString() {
        return "LoginTicket{" +
                "id=" + id +
                ", userId=" + userId +
                ", ticket='" + ticket + '\'' +
                ", status=" + status +
                ", expired=" + expired +
                '}';
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public int getUserId() {
        return userId;
    }

    public void setUserId(int userId) {
        this.userId = userId;
    }

    public String getTicket() {
        return ticket;
    }

    public void setTicket(String ticket) {
        this.ticket = ticket;
    }

    public int getStatus() {
        return status;
    }

    public void setStatus(int status) {
        this.status = status;
    }

    public Date getExpired() {
        return expired;
    }

    public void setExpired(Date expired) {
        this.expired = expired;
    }

    private Date expired;
}

LoginTicketMapper接口:

package com.nowcoder.community.dao;

import com.nowcoder.community.entity.LoginTicket;
import org.apache.ibatis.annotations.*;

@Mapper
public interface LoginTicketMapper {
    @Insert({
            "insert into login_ticket(user_id,ticket,status,expired) ",
            "values(#{userId},#{ticket},#{status},#{expired})"
    })
    @Options(useGeneratedKeys = true,keyProperty = "id")
    int insertLoginTicket(LoginTicket loginTicket);

    @Select({
            "select id,user_id,ticket,status,expired",
            "from login_ticket where ticket=#{ticket}"
    })
    LoginTicket selectByTicket(String ticket);

    @Update({
            "update login_ticket set status=#{status} where ticket=#{ticket}"
    })
    int updateStatus(String ticket,int status);
}

动态SQL演示:

image

写个测试类测试一下CRUD是否正常,记得在MapperTest下注入LoginTicketMapper

    @Test
    public void testInsertLoginTicket(){
        LoginTicket loginTicket=new LoginTicket();
        loginTicket.setUserId(101);
        loginTicket.setTicket("abc");
        loginTicket.setStatus(0);
        loginTicket.setExpired(new Date(System.currentTimeMillis()+1000*60*10));

        loginTicketMapper.insertLoginTicket(loginTicket);
    }

    @Test
    public void testSelectLoginTicket(){
        LoginTicket loginTicket= loginTicketMapper.selectByTicket("abc");
        System.out.println(loginTicket);

        loginTicketMapper.updateStatus("abc",1);//表示失效了
        loginTicket=loginTicketMapper.selectByTicket("abc");
        System.out.println(loginTicket);
    }

实现登录业务功能

UserService下:

/**
     * 验证账号密码,并生成凭证
     * @param username
     * @param password
     * @param expireSeconds
     * @return
     */
    public Map<String,Object> login(String username,String password,int expireSeconds){
        Map<String,Object> map=new HashMap<>();
        //空值处理
        if(StringUtils.isNullOrEmpty(username)){
            map.put("usernameMsg","账号不能为空");
            return map;
        }
        if(StringUtils.isNullOrEmpty(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;
        }

        //生成登陆凭证
        LoginTicket loginTicket=new LoginTicket();
        loginTicket.setUserId(user.getId());
        loginTicket.setTicket(CommunityUtil.generateUUID());
        loginTicket.setStatus(0);
        loginTicket.setExpired(new Date(System.currentTimeMillis() + expireSeconds * 1000));
        loginTicketMapper.insertLoginTicket(loginTicket);

        map.put("ticket",loginTicket.getTicket());
        return map;
    }

处理请求

CommunityConstant增加两个常量:

    //默认状态的登陆凭证的超时时间
    int DEFAULT_EXPIRED_SECONDS=3600*12;
    //记住状态的登陆凭证超时时间
    int REMEMBER_EXPIRED_SECONDS=3600*24*100;

LoginController注入路径值,并新增下列方法:

    @Value("${server.servlet.context-path}")
    private String contextPath;
    @RequestMapping(path="/login",method = RequestMethod.POST)
    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.isNullOrEmpty(kaptcha)||StringUtils.isNullOrEmpty(code)||!kaptcha.equalsIgnoreCase(code)){
            model.addAttribute("codeMsg","验证码不正确");
            return "/site/login";
        }

        //检查账号、密码
        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";
        }
    }

修改login网页

  • 修改提交方式:<form class="mt-5" th:action="@{/login}" method="post">

  • 每个input输入框加上name属性:<input type="text" class="form-control is-invalid" name="username" id="username" placeholder="请输入您的账号!" required>,包括验证码,记住我name="rememberme"

  • 更改账号和密码的默认显示,即便是登录错误,依然保留原来的账号密码:<input type="text" class="form-control is-invalid" th:value="${param.username}" name="username"${param.username}表示从request中获取username。验证码不需要设置。

  • 更改记住我的默认选项:<input type="checkbox" name="rememberme" id="remember-me" th:checked="${param.rememberme}">

  • 动态提示错误内容:

    • 显示错误内容取值:<div class="invalid-feedback" th:text="${usernameMsg}">该账号不存在!/div>
    • 更改显示样式,保证有错误才会出现:<input type="text" th:class="|form-control ${usernameMsg!=null?'is-invalid':''}|" th:value="${param.username}" name="username" id="username"

退出功能

UserService

    /**
     * 退出功能
     */
    public void logout(String ticket){
        loginTicketMapper.updateStatus(ticket,1);
    }

LoginController

    @RequestMapping(path = "/logout",method = RequestMethod.GET)
    public String logout(@CookieValue("ticket")String ticket){
        userService.logout(ticket);
        return "redirect:/login";
    }

index.html链接退出网页:<a class="dropdown-item text-center" th:href="@{/logout}">退出登录

显示登陆信息

  • 拦截器示例

    • 定义拦截器,实现HandlerInterceptor
    • 配置拦截器,为它指定拦截、排除的路径
  • 拦截器应用

    • 在请求开始时查询登录用户
    • 在本次请求中持有用户数据
    • 在模板视图上显示用户数据
    • 在请求结束时清理用户数据

拦截器的使用示例

Controller下创建Interceptor包,新建AlphaInterceptor类:

package com.nowcoder.community.controller.interceptor;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Slf4j
@Component
public class AlphaInterceptor implements HandlerInterceptor {

    //在Controller之前执行
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        log.debug("prehandle:"+handler.toString());
        return HandlerInterceptor.super.preHandle(request, response, handler);
    }

    //在Controller之后执行
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        log.debug("postHandle"+handler.toString());
        HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
    }

    //在templateEngine之后执行
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        log.debug("aftertHandle"+handler.toString());
        HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
    }
}

对比着看瑞吉外卖的拦截看:https://blog.csdn.net/weixin_46066669/article/details/131387232

Config下写配置类WebMvcConfig

package com.nowcoder.community.config;
import com.nowcoder.community.controller.interceptor.AlphaInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Autowired
    private AlphaInterceptor alphaInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(alphaInterceptor)
                .excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg")
                .addPathPatterns("/register","/login");
    }
}

用户数据处理

实现逻辑:

image

创建CookieUtil类,方便从request中获取cookie的name的值:

package com.nowcoder.community.util;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;

public class CookieUtil {
    //从request中获取cookie的name的值
    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;
    }
}

UserService类,新增方法findLoginTicket,根据凭证内容找到凭证对象:

    /**
     * 根据凭证内容找到凭证对象
     * @param ticket
     * @return
     */
    public LoginTicket findLoginTicket(String ticket){
        return loginTicketMapper.selectByTicket(ticket);
    }

创建HostHolder类,便于在请求中持有用户,只要请求没有处理完,线程就一直存在,请求处理完后,才会销毁。

package com.nowcoder.community.util;

import com.nowcoder.community.entity.User;
import org.springframework.stereotype.Component;

/**
 * 持有用户信息,用于代替session对象
 */
@Component
public class HostHolder {
    private ThreadLocal<User> users=new ThreadLocal<>();

    public void setUser(User user){
        users.set(user);
    }

    public User getUser(){
        return users.get();
    }

    public void clear(){
        users.remove();
    }
}

这个threadLocal在瑞吉外卖第三章中也用到过BaseContext工具类,是以线程为key,取对象的:

image

创建LoginTicketInterceptor类:

package com.nowcoder.community.controller.interceptor;

import com.nowcoder.community.entity.LoginTicket;
import com.nowcoder.community.entity.User;
import com.nowcoder.community.service.UserService;
import com.nowcoder.community.util.CookieUtil;
import com.nowcoder.community.util.HostHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Date;

@Component
public class LoginTicketInterceptor implements HandlerInterceptor {
    @Autowired
    private UserService userService;

    @Autowired
    private HostHolder hostHolder;

    //在请求开始时查询登录用户
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //从cookie中获取凭证
        String ticket= CookieUtil.getValue(request,"ticket");
        if(ticket!=null){
            //查询凭证
            LoginTicket loginTicket= userService.findLoginTicket(ticket);
            //检查凭证是否有效,超时时间晚于当前时间
            if(loginTicket!=null&&loginTicket.getStatus()==0&&loginTicket.getExpired().after(new Date())){
                //根据凭证查询用户
                User user = userService.findUserById(loginTicket.getUserId());
                //在本次请求中持有用户
                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();
    }
}

加拦截器

WebMvcConfig下新增拦截器:

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Autowired
    private AlphaInterceptor alphaInterceptor;

    @Autowired
    private LoginTicketInterceptor loginTicketInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(alphaInterceptor)
                .excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg")
                .addPathPatterns("/register","/login");
        registry.addInterceptor(loginTicketInterceptor)
                .excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg");
    }
}

修改登录信息显示

index.html

  • 没有登录就不显示消息、下拉菜单

    <li class="nav-item ml-3 btn-group-vertical" th:if="${loginUser!=null}">
    								<a class="nav-link position-relative" href="site/letter.html">消息
    
  • 登录了就不显示注册、登录

    <li class="nav-item ml-3 btn-group-vertical" th:if="${loginUser==null}">
    								<a class="nav-link" th:href="@{/register}">注册</a>
    
  • 动态显示头像:<img th:src="${loginUser.headerUrl}" class="rounded-circle" style="width:30px;"/>

  • 动态显示用户名:<span class="dropdown-item text-center text-secondary" th:utext="${loginUser.username}">nowcoder

image

账号设置

  • 开发步骤

    • 访问账号设置页面
    • 上传头像
    • 获取头像
  • 上传文件

    • 请求:必须是POST请求
    • 表单:enctype=“multipart/form-data”
    • Spring MVC:通过 MultipartFile 处理上传文件

访问账号设置页面

处理网页setting,使用模板引擎,复用头部,处理相对路径。修改index的账号设置链接:<a class="dropdown-item text-center" th:href="@{/user/setting}">账号设置

创建UserController类,处理请求页面跳转:

package com.nowcoder.community.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

@Controller
@RequestMapping("/user")
public class UserController {
    @RequestMapping(path = "/setting",method = RequestMethod.GET)
    public String getSettingPage(){
        return "/site/setting";
    }
}

上传头像

这里和瑞吉外卖的第四章对比着看。

在配置文件内设置上传路径:community.path.upload=d:/data/upload

UserService下新增更新头像的方法:

    /**
     * 更新头像
     * @param userId
     * @param headerUrl
     * @return
     */
    public int updateHeader(int userId,String headerUrl){
        return userMapper.updateHeader(userId,headerUrl);
    }

UserController下注入属性:

    @Value("${community.path.upload}")
    private String upload;

    @Value("${community.path.domain}")
    private String domain;

    @Value("${server.servlet.context-path}")
    private String contextPath;

    @Autowired
    private UserService userService;

    @Autowired
    private HostHolder hostHolder;

UserController下新增方法:

    //上传头像
    @RequestMapping(path = "/upload",method = RequestMethod.POST)
    public String uploadHeader(MultipartFile headerImage, Model model){
        if(headerImage==null){
            model.addAttribute("error","您还没有选择图片");
            return "/site/setting";
        }
        //获取文件后缀
        String fileName=headerImage.getOriginalFilename();
        String suffix = fileName.substring(fileName.lastIndexOf(".")+1);
        if(StringUtils.isNullOrEmpty(suffix)){
            model.addAttribute("error","文件的格式不正确");
            return "/site/setting";
        }

        //生成随机文件名
        fileName= CommunityUtil.generateUUID()+suffix;

        //确定文件存放路径
        File dest=new File(upload+"/"+fileName);
        try {
            headerImage.transferTo(dest);
        } catch (IOException e) {
            log.error("上传文件失败:"+e.getMessage());
            throw new RuntimeException("上传文件失败,服务器发生异常!",e);
        }

        //更新当前用户的头像的路径(web访问路径)http://localhost:8080/community/user/header/xxx.png
        User user=hostHolder.getUser();
        String headerUrl=domain+contextPath+"/user/header/"+fileName;
        userService.updateHeader(user.getId(),headerUrl);
        return "redirect:/index";
    }
  
    //更新头像
    @RequestMapping(path = "/header/{fileName}",method = RequestMethod.GET)
    public void getHeader(@PathVariable("fileName") String fileName, HttpServletResponse response){
        //服务器存放路径
        fileName=upload+"/"+fileName;
        //文件后缀
        String suffix=fileName.substring(fileName.lastIndexOf("."));
        //响应图片
        response.setContentType("image/"+suffix);
        try {
            FileInputStream fis=new FileInputStream(fileName);
            OutputStream os = response.getOutputStream();
            {
                byte[] buffer=new byte[1024];
                int b=0;
                while ((b=fis.read(buffer))!=-1){
                    os.write(buffer,0,b);
                }
        }
        }catch (IOException e) {
            e.printStackTrace();
        }
    }

这里好像有个小问题,按照老师的写文件格式有问题的代码的逻辑是有问题的,文件上传没有后缀的话,lastIndexOf会返回-1,再放到substring函数里面用,就会报错,那个error错误代码不会执行。

image

处理后就正常了:

image

修改页面

修改setting

  • 更改提交方式:<form class="mt-5" method="post" enctype="multipart/form-data" th:action="@{/user/upload}">

  • 更改头像的路径:<input type="file" class="custom-file-input" id="head-image" name="headerImage" lang="es" required="">

  • 增加错误提示:

    <div class="invalid-feedback" th:text="${error}">
    									头像有误!
    								</div>
    
  • 必要时显示错误提示样式:<input type="file" th:class="|custom-file-input ${error!=null?'is-invalid':''}|" id="head-image" name="headerImage" lang="es" required="">

修改密码

在更新密码的时候加md5,判断是否与原密码一致,判断两次新密码输入是否一致。

UserService

    /**
     * 更新密码
     */
    public int updatePassword(int userId,String password){
        password=CommunityUtil.md5(password+userMapper.selectById(userId).getSalt());
        return userMapper.updatePassword(userId,password);
    }

UserController

    //更新密码
    @RequestMapping(path = "/updatePassword",method = RequestMethod.POST)
    public String updatePassword(Model model,String oldPassword,String newPassword,String secPassword){
        //验证原密码是否正确
        User user = hostHolder.getUser();
        oldPassword=CommunityUtil.md5(oldPassword+user.getSalt());
        if(!user.getPassword().equals(oldPassword)){
            model.addAttribute("oldPasswordMsg","密码不正确");
            return "/site/setting";
        }else if(!newPassword.equals(secPassword)){
            model.addAttribute("secPasswordMsg","两次密码不一致");
            return "/site/setting";
        }
        userService.updatePassword(user.getId(),secPassword);
        return "redirect:/login";
    }

setting

<form class="mt-5" method="post" th:action="@{/user/updatePassword}">
					<div class="form-group row mt-4" >
						<label for="old-password" class="col-sm-2 col-form-label text-right"> 原密码:</label>
						<div class="col-sm-10">
							<input type="password" th:class="|form-control ${oldPasswordMsg!=null?'is-invalid':''}|" id="old-password" name="oldPassword" placeholder="请输入原始密码!" required>
							<div class="invalid-feedback" th:text="${oldPasswordMsg}">
								密码长度不能小于8位!
							</div>					
						</div>
					</div>
					<div class="form-group row mt-4">
						<label for="new-password" class="col-sm-2 col-form-label text-right">新密码:</label>
						<div class="col-sm-10">
							<input type="password" class="form-control" id="new-password" name="newPassword" placeholder="请输入新的密码!" required>
							<div class="invalid-feedback">
								密码长度不能小于8位!
							</div>					
						</div>
					</div>
					<div class="form-group row mt-4">
						<label for="confirm-password" class="col-sm-2 col-form-label text-right">确认密码:</label>
						<div class="col-sm-10">
							<input type="password" th:class="|form-control ${secPasswordMsg!=null?'is-invalid':''}|" id="confirm-password" name="secPassword" placeholder="再次输入新密码!" required>
							<div class="invalid-feedback" th:text="${secPasswordMsg}">
								两次输入的密码不一致!
							</div>
						</div>
					</div>

检查登录状态

  • 使用拦截器

    • 在方法前标注自定义注解
    • 拦截所有请求,只处理带有该注解的方法
  • 自定义注解

    • 常用的元注解:
      @Target、@Retention、@Document、@Inherited
    • 如何读取注解:
      Method.getDeclaredAnnotations()
      Method.getAnnotation(Class annotationClass)

创建annotation包,创建LoginRequired注解:

package com.nowcoder.community.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginRequired {

}

UserControllergetSettingPageuploadHeader两个方法前加上注解。

新建拦截器LoginRequiredInterceptor

package com.nowcoder.community.controller.interceptor;

import com.nowcoder.community.annotation.LoginRequired;
import com.nowcoder.community.util.HostHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;

@Component
public class LoginRequiredInterceptor implements HandlerInterceptor {
    @Autowired
    private HostHolder hostHolder;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if(handler instanceof HandlerMethod){
            HandlerMethod handlerMethod=(HandlerMethod) handler;
            Method method=handlerMethod.getMethod();
            LoginRequired loginRequired = method.getAnnotation(LoginRequired.class);
            if(loginRequired!=null&&hostHolder.getUser()==null){
                response.sendRedirect(request.getContextPath()+"/login");
                return false;
            }
        }
        return true;
    }
}

WebMvcConfig加上拦截器,就能在没有登录页面的时候防止用户访问到设置页面了。