• 第2章 Spring Boot实践,开发社区登录模块(下)


    5. 开发登录、退出功能

    image-20220710214648224

    1. 访问登录页面

    点击顶部区域的“登录”按钮,打开登录页面。这个功能之前我们已经实现过了,所以我们无须实现

    2. 登录

    生成的登录凭证最终是要发送一个key给客户端,然后让它记住,下次提交给服务端,以便能够识别。但是这个登录凭证里面包括了一些敏感的数据,包括用户的id、用户名、密码,这些数据不能发送给客户端要存到服务端,我们可以存到session里或者数据库里,这里我们把它存到数据库里,将来我们重构的时候再把它存到redis里。

    我们先看一些登录表

    CREATE TABLE `login_ticket` (
      `id` int(11) NOT NULL AUTO_INCREMENT,
      `user_id` int(11) NOT NULL,
      `ticket` varchar(45) NOT NULL,
      `status` int(11) DEFAULT '0' COMMENT '0-有效; 1-无效;',
      `expired` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
      PRIMARY KEY (`id`),
      KEY `index_ticket` (`ticket`(20))
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    image-20220711100711300

    开发顺序 数据访问层(dao)—> 业务层(service)—> 视图层(controller、themeleaf模板)

    与登录表login_ticket对应的实体类

    public class LoginTicket {
    
        private int id;
        private int userId;         // 用户id
        private String ticket;      // 登录凭证
        private int status;      // 登录状态(当前登录凭证是否有效)
        private Date expired;     // 过期时间(凭证失效时间)
    
    		// 为了影响阅读体验,这里没有粘get、set、toString方法,实际上是有的    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    数据访问层(dao)

    dao接口

    之前我们实现dao接口是新建一个mapper配置文件写sql,除了这种方式我们还可以在mapper接口中写注解去实现方法。这里我们使用注解的方式实现一下dao接口中的方法。

    实现dao方法的注解之中允许书写多个字符串,用逗号隔开即可,到时候会自动拼接

    
    @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);      // 根据凭证查询LoginTicket
    
        @Update({
                "update login_ticket set status = #{status} where ticket = #{ticket}"
        })
        int updateStatus(String ticket, int status);    // 更新凭证状态为status
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    image-20220711115458882

    关于 if 标签如何写

    image-20220711142204638

    数据访问层写完之后最好做一个测试,因为这里比较容易出错。

    测试类:

    @SpringBootTest
    @RunWith(SpringRunner.class)
    @ContextConfiguration(classes = CommunityApplication.class)
    public class MailTests {
    
        @Autowired
        private LoginTicketMapper 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 testSelectAndUpdateTicket(){
            LoginTicket loginTicket = loginTicketMapper.selectByTicket("abc");
            System.out.println(loginTicket);
            loginTicketMapper.updateStatus("abc", 1);       // 失效改为1
            loginTicket = loginTicketMapper.selectByTicket("abc");
            System.out.println(loginTicket);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    业务层(service)

    登录是属于用户的行为,所以我们在UserService写登录的业务。

    注入 LoginTicketMapper

    登陆的时候可能成功、失败,失败原因有可能是账号没输入,账号不存在, 账号没有激活,等等,所以我们返回一个map,可以封装多种情况的返回结果,

    // 为了保证UserService的完整性,所以全部粘过来了,其实我们这里写的只是1.注入LoginTicket 2.登录方法
    @Service
    public class UserService implements CommunityConstant {
    
        @Autowired
        private UserMapper userMapper;
    
        @Autowired
        private MailClient mailClient;
    
        @Autowired
        private TemplateEngine templateEngine;
    
        @Autowired
        private LoginTicketMapper loginTicketMapper;
    
        @Value("${community.path.domain}")      // @Value 注解会将 application.properties 配置文件中的指定值注入给它
        private String domain;                  // 域名
    
        @Value("${server.servlet.context-path}")
        private String contextPath;             // 项目名
    
        public User findUserById(int id){
            return userMapper.selectById(id);
        }
    
        // 注册方法:注册需要 username、password、email,所以传入一个 user
        // 返回的应该是相关信息,比如“账号已存在、邮箱不能为空”等等,所以为了封装多个内容,返回Map
        public Map<String, Object> register(User user){
            Map<String, Object> map = new HashMap<>();
    
            // 先进行空值处理 user 为 null
            // (username为null、password为null、email为null 或者 全部为 null)
    
            // 空值处理
            if(user == null){
                throw new IllegalArgumentException("参数不能为空");
            }
    
            // 账号/密码/邮箱 为空是业务上的漏洞,但是不是程序的错误,因此我们把信息封装到map里面返回给客户端
            if(StringUtils.isBlank(user.getUsername())){
                map.put("usernameMsg", "账号不能为空!");
                return map;
            }
            if(StringUtils.isBlank(user.getPassword())){
                map.put("passwordMsg", "密码不能为空!");
                return map;
            }
            if(StringUtils.isBlank(user.getEmail())){
                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;
            }
    
            // 如果可以执行到这里说明账号/密码/邮箱都不为空,且账号/邮箱都未注册
    
            // 这里我们要对用户输入的密码后面加一个salt(随机字符串)(password+salt 之后md5加密之后才真正存到数据库的)
            // salt 一般 5 位就够了
            user.setSalt(CommunityUtil.generateUUID().substring(0, 5));
            user.setPassword(CommunityUtil.md5(user.getPassword() + user.getSalt()));
            user.setType(0);            // 0 表示普通用户
            user.setStatus(0);          // 0 表示没有激活
            user.setActivationCode(CommunityUtil.generateUUID());   //  设置激活码
            // 图片url:https://images.nowcoder.com/head/0t.png   注:从 0 - 1000 都是图片
            user.setHeaderUrl(String.format("https://images.nowcoder.com/head/%dt.png", new Random().nextInt(1000))); //设置图片的路径
            user.setCreateTime(new Date());     // 设置注册时间
    
            userMapper.insertUser(user);        // 存入数据库,本来user的id是null,直行这句之后mysql生成id赋值给了它
    
            // 给用户发送 html 激活邮件,好带有链接
            // 给用户发送发送邮件
    
            // 给 themeleaf 模板传参
            Context context = new Context();       // themeleaf 包下的 Context
            context.setVariable("email", user.getEmail());
    
    
            // 项目路径下某个功能哪个用户在激活激活码是什么
            // http://localhost:8080/community/activation/101/code
            String url = domain + contextPath + "/activation/" + user.getId() + "/" + user.getActivationCode();
    
            // 由于拼接url可能会造成空格问题,空格在浏览器地址栏中会解析成 %20 ,造成错误,所以我们要将url中的空格去掉
            url = url.replaceAll(" ", "");
            context.setVariable("url", url);
    
    
            // 调模板引擎生成动态网页   参数1:模板引擎的路径   参数2:数据
            // 会生成一个动态网页,其实就是一个字符串,模板引擎主要的作用就是生成动态网页
            String content = templateEngine.process("/mail/activation", context);
    
            // 发邮件    参数1:收件人    参数2:邮件标题      参数3:邮件内容
            System.out.println(user.getEmail());
    
            try {
                mailClient.sendMail(user.getEmail(), "激活账号", content);
            } catch (Exception e) {
                e.printStackTrace();
            }
    
            // 最后没有问题的话也返回map,且这里map是空的
    
            return map;
        }
    
        // 激活方法   参数1:用户id      参数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;
            }
        }
    
        // 参数1:用户名  参数2:密码  参数3:过期的秒数
        public Map<String, Object> login(String username, String password, int expiredSeconds){
            Map<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;
            }
    
            // 如果上面的都正确说明 账号和密码 都正确
            // 生成登录凭证
            LoginTicket loginTicket = new LoginTicket();
            loginTicket.setUserId(user.getId());
            loginTicket.setTicket(CommunityUtil.generateUUID());
            loginTicket.setStatus(0);       // 0 表示当前凭证有效
            loginTicket.setExpired(new Date(System.currentTimeMillis() + expiredSeconds * 1000)); // *1000 是从毫秒换算成秒
            loginTicketMapper.insertLoginTicket(loginTicket);
    
            map.put("ticket", loginTicket.getTicket());  // 返回登录凭证给客户端
            return map;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165
    • 166
    • 167
    • 168
    • 169
    • 170
    • 171
    • 172
    • 173
    • 174
    • 175
    • 176
    • 177
    • 178
    • 179
    • 180
    • 181

    image-20220711163608928

    image-20220711163721654

    image-20220711163913712

    视图层(Controller、themeleaf)

    因为是登录,所以我们在LoginController里写Controller

    UserService以前已经注入过了,不需要重新注入了。

    注:controller中的不同方法的请求路径可以相同,但是请求方式一定要区分开

    @Controller
    public class LoginController implements CommunityConstant {
    
        private static Logger logger = LoggerFactory.getLogger(LoginController.class);
    
        @Autowired
        private UserService userService;
    
        @Autowired
        private Producer kaptchaProducer;
    
        @Value("${server.servlet.context-path}")
        private String contextPath;
    
        @RequestMapping(path = "/register", method = RequestMethod.GET)
        public String getRegisterPage(){
            return "/site/register";
        }
    
        // 处理请求,因为是浏览器向我们提交数据,所以请求是POST方式
        @RequestMapping(path = "/register", method = RequestMethod.POST)
        public String register(Model model, User user){
            // 注意:因为User声明在了方法的参数里,SpringMVC会自动把user存到model里
    
            Map<String, Object> map = userService.register(user);
    
            // map为空说明注册成功,我们应该提示浏览器注册成功,然后跳转到首页页面,之后激活之后才跳转到登录页面
            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";
            }
        }
    
        // http://localhost:8080/community/activation/101/code
        @RequestMapping(path = "activation/{userId}/{code}", method = RequestMethod.GET)
        public String activation(Model model,
                                 @PathVariable("userId") int userId,
                                 @PathVariable("code") String code){
            // 这个结果的含义可以从结果中识别,所以也需让LoginController实现CommunityConstant接口
            int result = userService.activation(userId, code);
            // 无论成功还是失败,都跳转到中转页面只是返回给中转页面的提示信息不同,然后从中转页面跳转到哪里根据激活是否成功决定
            if(result == ACTIVATION_SUCCESS){
                // 激活成功跳转到登录页面
                model.addAttribute("msg", "激活成功,您的账号已经可以正常使用了!");
                model.addAttribute("target", "/login");  // 返回给服务器,服务器跳转到登录的controller
            } else if(result == ACTIVATION_REPEAT){
                // 邮箱之前已经激活过了,重复了
                model.addAttribute("msg", "无效操作,该账号已经激活过了!");
                model.addAttribute("target", "/index");  // 跳转到展示首页的controller
            } else {
                // 激活失败
                model.addAttribute("msg", "激活失败,您提供的激活码不正确!");
                model.addAttribute("target", "/index");  // 跳转到展示首页的controller
            }
            return "/site/operate-result";
        }
    
        @RequestMapping(path = "/login", method = RequestMethod.GET)
        public String getLoginPage(){
            return "/site/login";
        }
    
        @RequestMapping(path = "/kaptcha", method = RequestMethod.GET)
        public void getLKaptcha(HttpServletResponse response, HttpSession session){
            // 生成验证码
            String text = kaptchaProducer.createText();
            BufferedImage image = kaptchaProducer.createImage(text);    // 将验证码传入生成图片
    
            // 将验证码存入session
            session.setAttribute("kaptcha", text);
    
            // 将图片输出给浏览器
            response.setContentType("image/png");       // 声明返回给浏览器的是什么可视的数据
            // response向浏览器做响应我们需要获取它的输出流
            try {
                ServletOutputStream os = response.getOutputStream();
                // 这个流不用关,因为是SpringMVC维护,会自动关
                ImageIO.write(image, "png", os);    // 输出哪个图片; 格式; 哪个流输出
            } catch (IOException e) {
                logger.error("响应验证码失败:" + e.getMessage());
            }
        }
    
        @RequestMapping(path = "/login", method = RequestMethod.POST)
        //              参数1:用户名   参数2:密码   参数3:验证码  参数4:是否勾上(登录页面有一个"记住我",这个参数表示)
        // 用户打开登录页面生成了验证码,放到了session里,所以也要声明session,把验证码从session里取出来
        // 如果登陆成功了,我们最终要把ticket发放给客户端好让它保存,要想使用cookie,我们还需要HttpServletResponse对象
        public String login(String username, String password, String code, boolean rememberme,
                            Model model, HttpSession session, HttpServletResponse response){
            /**
             * 如果方法中有复杂参数,会自动添加到model,但是String、boolean这样的简单参数不会自动加到model里
             * 解决方法:1. 手动加到model里  2. 或者从request里面取
             */
            // 检查验证码
            // 获取session中的验证码
            String kaptcha = (String)session.getAttribute("kaptcha");
            if(StringUtils.isBlank(kaptcha) || StringUtils.isBlank(code) || !kaptcha.equalsIgnoreCase(code)){
                // 如果获取的验证码为空或者用户传入的验证码为空或者获取的验证码和用户输入的验证码不同
                model.addAttribute("codeMsg", "验证码不正确!");
                return "/site/login";       // 返回登录页面
            }
    
            // 检查账号、密码(交给Service处理)
            int expiredSeconds = rememberme ? REMEMBER_EXPIRED_SECONDS : DEFAULT_EXPIRED_SECONDS;
            Map<String, Object> map = userService.login(username, password, expiredSeconds);
            if(map.containsKey("ticket")){
                // 成功登录
                // 给客户端发一个cookie,里面包含登录凭证
                Cookie cookie = new Cookie("ticket", map.get("ticket").toString());
                // 设置这个cookie生效的路径,凭证有效的路径应该包含在整个项目之内
                cookie.setPath(contextPath);
                cookie.setMaxAge(expiredSeconds);       // 设置cookie的生存时间
                response.addCookie(cookie);
                // 重定向到首页
                return "redirect:/index";
            } else {
                // 登陆失败
                model.addAttribute("usernameMsg", map.get("usernameMsg"));
                model.addAttribute("passwordMsg", map.get("passwordMsg"));
                return "/site/login";       // 返回登录页面
            }
        }
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131

    登陆的时候没有勾上“记住我”,那依然是把它存到库里,只是存的时间短一点;如果勾上“记住我”,也是把它存到库里,但是存的时间很长, 所以我们定义两个常量,常量我们还是写到CommunityConstant这个接口中因为之前 LoginController 已经实现了CommunityConstant这个接口,所以我们可以直接开始用这个里面的两个时间常量了。

    image-20220711175614042

    image-20220711181711348

    参数不要写死,注入 contextPath

    image-20220711193610007

    最后处理一下 login.html 表单,

    image-20220711193823108

    image-20220711193921355

    param.username 相当于 request.getUserName()

    开发到这里之后就可以进行测试了,看是否可以登陆成功

    3. 退出

    因为之前dao层已经实现过更新,所以这里直接写业务层(service)

    public void logout(String ticket){
        loginTicketMapper.updateStatus(ticket, 1);      // 改变凭证状态为1,表示凭证无效
    }
    
    • 1
    • 2
    • 3

    image-20220711195445756

    接下来是Controller

    @RequestMapping(path = "/logout", method = RequestMethod.GET)
    public String logout(@CookieValue("ticket") String ticket){
        userService.logout(ticket);
        return "redirect:/login";   // 重定向的时候默认就是get请求的login
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    image-20220711195532055

    最后我们还要修改一下 index.html 的退出按钮

    image-20220711195634830

    之后进行测试就可以了

    6. 显示登录信息

    image-20220712091339538

    显示登录信息的意思是在头部把登录用户的头像显示出来,另外点开头像旁边的**“倒三角”,要显示登录用户的名字,还有,根据用户登录与否我们还要调整头部显示的内容,比如“登录”“注册”**。

    拦截器可以拦截浏览器的请求,它可以在请求的开始或结束插入一些代码,从而可以解决多个请求共有的业务

    关于拦截器详细内容这里不过多赘述,更详细内容可以看我之前的博客:SpringBoot中文件下载、拦截器、war包部署、jar包部署

    拦截器的简单使用(只是测试,不涉及项目内容)

    @Component
    public class AlphaInterceptor implements HandlerInterceptor {
    
        private static Logger logger = LoggerFactory.getLogger(AlphaInterceptor.class);
    
        // 在Controller之前执行
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            logger.debug("preHandle: " + handler.toString());
            return true;
        }
    
        // 在Controller之后执行(在模板引擎之前执行)
        @Override
        public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
            logger.debug("postHandle: " + handler.toString());
        }
    
        // 在模板引擎之后执行
        @Override
        public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
            logger.debug("afterCompletion: " + handler.toString());
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    image-20220712102845535

    拦截器的配置类

    @Configuration
    public class WebMvcConfig implements WebMvcConfigurer {
    
        @Autowired
        private AlphaInterceptor alphaInterceptor;
    
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            registry.addInterceptor(alphaInterceptor)       // 将需要配置的拦截器传给它
                    //  /** 表示static目录下所有的文件夹,排除哪些路径不需要拦截
                    .excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg")
                    // 明确拦截注册、登录请求
                    .addPathPatterns("/register", "/login");
    
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    image-20220712102947799

    之后启动项目访问登录注册页面查看控制台是否输出拦截器内的日志即可查看测试是否成功。

    用拦截器处理在页面上显示用户的登录状态

    image-20220712104026516

    登录成功的话上面凭证查询然后在模板上显示每次请求都要干的,因为每次请求模板上都要显示用户信息,因此这套逻辑应该用拦截器处理,而不是写多次。

    cookie是从request传过来的,从request我们可以得到cookie,从request取cookie有点麻烦,我们把它封装一下,方便以后复用。

    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;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    image-20220712171916990

    首先因为UserService里面没有查询凭证的方法,所以我们在UserService后面追加查询凭证的方法

    public LoginTicket findLoginTicket(String ticket){
        return  loginTicketMapper.selectByTicket(ticket);
    }
    
    • 1
    • 2
    • 3

    image-20220712172028909

    在拦截其中我们本次请求中持有用户,因为浏览器对服务器是多对一,所以这里我们要考虑在多线程间隔离存这个对象, 需要用到threadLocal,这里我们封装成小工具,方便以后复用

    /**
     * 持有用户信息,用于代替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();
        }
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    image-20220712172135212

    拦截器

    @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);
                // 检查凭证是否有效,1. 凭证存在   2. 凭证状态为0   3. 凭证超时时间晚于当前时间
                if(loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())){
                    // 根据凭证查询用户
                    User user = userService.findUserById(loginTicket.getUserId());
                    // 在本次请求中持有用户
                    // 因为浏览器对服务器是多对一,所以这里我们要考虑在多线程间隔离存这个对象,
                    // 需要用到threadLocal,这里我们封装成小工具,方便以后复用
                    hostHolder.setUser(user);
                }
            }
            return true;
        }
    
        // 我们之前preHandle存的user应该在模板引擎之中用,所以我们在拦截器的postHandle方法中将user存到model里
        @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();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46

    image-20220712172321270

    image-20220712172434371

    拦截器写完了,接下来我们对拦截器进行一个配置

    @Configuration
    public class WebMvcConfig implements WebMvcConfigurer {
    
        @Autowired
        private AlphaInterceptor alphaInterceptor;
    
        @Autowired
        private LoginTicketInterceptor loginTicketInterceptor;
    
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            registry.addInterceptor(alphaInterceptor)       // 将需要配置的拦截器传给它
                    //  /** 表示static目录下所有的文件夹,排除哪些路径不需要拦截
                    .excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg")
                    // 明确拦截注册、登录请求
                    .addPathPatterns("/register", "/login");
    
            registry.addInterceptor(loginTicketInterceptor)       // 将需要配置的拦截器传给它
                    //  /** 表示static目录下所有的文件夹,排除哪些路径不需要拦截
                    .excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
                    // 注:没有写addPathPatterns表示没有明确拦截哪个路径,那就是拦截所有
        }
    
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    image-20220712172554074

    然后我们需要在模板上进行一个处理,因为所有的页面都是复用 index.html的header,所以我们需要去改写index.html的header部分

    image-20220712171749946

    测试:

    image-20220712172630689

    image-20220712172652848

    7. 账号设置

    image-20220713093142281

    上传头像

    1. 访问账号设置页面

    访问很简单,就是写一个controller跳转到**“账号设置页面”,然后修改一下“账号设置页面”的路径即可,最后因为所有页面都使用了index.html,我们呢还要设置一下index.html头部的“账号设置”的路径**。

    因为访问账号设置页面是属于用户的内容,所以我们新创建一个 UserController,在里面写访问“账号设置页面”的方法。

    @Controller
    @RequestMapping("/user")
    public class UserController {
    
        @RequestMapping(path = "/setting", method = RequestMethod.GET)
        public String getSettingPage(){
            return "/site/setting";
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    image-20220713095221891

    账号设置页面 setting.html

    image-20220713095404756

    image-20220713095445354

    因为所有页面都复用了 index.html 的头部,我们要修改一下 index.html 头部的“账号设置” 的链接

    image-20220713095716535

    访问账号设置页面写完之后建议启动项目测试一下

    2. 上传头像

    上传文件我们要在配置文件 application.properties 配置一下这个文件最终要存放到哪个硬盘上

    注:文件上传路径必须存在,即 upload 必须存在

    community.path.upload=d:/work/data/upload
    
    • 1

    image-20220713104423969

    image-20220713104454709

    数据访问层dao

    因为之前更新头像的url之前写过,还有是把文件存到硬盘里,而不是数据库里,所以 数据访问层dao没有什么可做的。


    业务层service

    业务层service只处理更新路径的需求

    public int updateHeader(int userId, String headerUrl){
        return userMapper.updateHeader(userId, headerUrl);
    }
    
    • 1
    • 2
    • 3

    image-20220713104337388

    表现层controller

    表现层controller负责上传文件的动作

    
    @Controller
    @RequestMapping("/user")
    public class UserController {
        // 日志
        private static final Logger logger = LoggerFactory.getLogger(UserController.class);
        // 注入配置文件中的上传路径
        @Value("${community.path.upload}")
        private String uploadPath;
        // 注入配置文件中的项目的域名
        @Value("${community.path.domain}")
        private String domain;
        // 注入配置文件中的项目名
        @Value("${server.servlet.context-path}")
        private String contextPath;
        // 更新图片url需要用到 UserService
        @Autowired
        private UserService userService;
        // 当前用户得从hostHoldear里取,所以注入 HostHolder
        @Autowired
        private HostHolder hostHolder;
    
        // 访问“账号设置”页面
        @RequestMapping(path = "/setting", method = RequestMethod.GET)
        public String getSettingPage(){
            return "/site/setting";
        }
    
        // 上传文件
        // "上传"的表单的提交方式必须为POST
        // MultipartFile 用来接收上传的文件,Model 用来给模板携带数据
        @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("."));
            if (StringUtils.isBlank(suffix)) {
                // 文件后缀为空
                model.addAttribute("error", "文件的格式不正确!");
                return "/site/setting";
            }
            // 生成随机文件名
            fileName = CommunityUtil.generateUUID() + suffix;
            // 确定文件存放的路径
            File dest = new File(uploadPath + "/" + fileName);
            try {
                // 存储文件
                headerImage.transferTo(dest);
            } catch (IOException e) {
                logger.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;
            headerUrl = headerUrl.replace(" ", "");
            userService.updateHeader(user.getId(), headerUrl);
    
            return "redirect:/index";
        }
    
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69

    image-20220713104608601

    image-20220713104706169

    image-20220713120043758

    3. 获取头像

    controller获取头像

    UserController

    // 获取头像
    // 这个方法向浏览器响应的不是一个网页,不是一个字符串,而是一个图片,二进制的数据
    // 我们需要通过流手动向浏览器输出,所以返回值为void,方法内部调response写
    @RequestMapping(path = "/header/{fileName}", method = RequestMethod.GET)
    public void getHeader(@PathVariable("fileName") String fileName, HttpServletResponse response){
        // 服务器存放路径
        fileName = uploadPath + "/" + fileName;
        // 文件后缀
        String suffix = fileName.substring(fileName.lastIndexOf(".") + 1);
        // 响应图片
        response.setContentType("image/" + suffix);
        try (
            // java7语法,在这里写的会自动在后面加上finally,执行close关闭方法
            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) {
            logger.error("读取头像失败: " + e.getMessage());
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    image-20220713114023904

    配置setting.html表单

    image-20220713120358417

    8.检查登录状态

    用户没登陆的时候有一些功能不能访问的,但是如果我们后台没有“检查登录状态”,在没登录的情况下通过在地址栏输入http://localhost:8080/community/user/setting 可以访问 “上传文件”的功能,这种行为会对我们的系统造成很大的安全隐患,所以我们需要去杜绝这种行为。

    因此不是说不让他(她)看见就行,得一定是他(她)无论如何都访问不到才可以。

    解决:在用户没有登录的时候,在访问不可以访问的功能的时候,应该在服务端进行一个判断,登陆了可以访问,没登录拒绝。

    可以想到的是,很多功能都需要这样的判断,众多请求都有一样的逻辑,我们用拦截器处理。

    image-20220713152102515

    image-20220713152115947

    拦截器拦截哪个方法有两种设置:

    一种是写拦截器的配置文件

    还有一种是拦截哪个的话在哪个方法/类上加上注解(这种方式我们需要自定义注解)

    这两种可以只使用一种也可以两种都用,接下来我们两种方式都用开发一下检查登录状态的拦截器。

    1. 自定义注解

    自定义注解

    我们在定义自己的注解的时候需要用元注解定义我们自己的注解

    • @Target
      • 用来声明自定义的注解可以写在哪个位置:类 / 方法
    • @Retention
      • 用来声明自定义注解保留的时间或有效时间:编译时有效 / 运行时有效
    • @Document
      • 用来声明自定义注解在生成文档时要不要把这个注解也带上去
    • @Inherited
      • 表示这个自定义注解可不可以被子类继承

    自定义注解的时候前两个元注解基本上是必须写的

    自定义注解:

    @Target(ElementType.METHOD)         // 表示自定义的这个注解应该写在方法之上
    @Retention(RetentionPolicy.RUNTIME) // 表示这个注解在程序运行的时候有效
    public @interface LoginRequired {
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    image-20220713163002175

    这个注解是用来标识这个方法它需不需要在登陆的状态下才能访问,

    这个注解相当于”标识“的作用,打上这个标记就必须登录才能访问,不打上这个标记表示随便

    标记一下,哪些请求需要登录以后才能访问呢:

    image-20220713160004849

    2. 书写拦截器

    拦截器

    接下来使用拦截器去拦截带有注解的方法,在拦截到方法之后判断登没登录,登录了可以,没登录拒绝

    @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){
                    // 不等于 null 表示这个方法是需要登录才能访问的
                    // 但是 hostHolder.getUser() == null 表示没有登录
                    // 这个时候应该拒绝后续的请求,强制重定向到登录页面 request.getContextPath() 表示取到应用的路径
                    response.sendRedirect(request.getContextPath() + "/login");
                    return false;
                }
            }
            return true;
        }
    
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    image-20220713163226270

    3. 拦截器的配置文件

    配置文件

    最后我们配一下不拦截静态资源的请求,其他请求都拦截,从中找到带注解的。

    image-20220713162810948

    不去处理静态的资源,而其他动态资源都处理,但处理的时候人为的筛选了带有注解的那一部分,其他的不管,这样的好处是希望它处理谁就在谁上加注解,而不是一个一个加路径(加路径也可以,两种方式都行)。


    最后建议测试一下:

    在没有登陆的状态下,输入 http://localhost:8080/community/user/setting 看它是否会跳转到

    http://localhost:8080/community/login 登录页面

  • 相关阅读:
    鲸探发布点评:9月8日发售《汝阳黄河巨龙》数字藏品
    Linux内核的安装
    ssm基于小程序的医院预约挂号系统毕业设计源码260839
    SCAU 编译原理 实验1 词法分析实验
    为特征向量数据(1D数组)叠加噪声实现数据增强
    SIEM 中不同类型日志监控及分析
    ClickHouse 创建数据库建表视图字典 SQL
    diskGenius专业版使用:windows系统下加载ext4 linux系统分区并备份还原资源(文件的拷贝进、出)
    .NET周刊【6月第5期 2024-06-30】
    【C语言 模拟实现strcmp函数】
  • 原文地址:https://blog.csdn.net/qq_50313418/article/details/126153601