【JavaEE】(23) 综合练习--博客系统

一、功能描述

用户登录后,可查看所有人的博客。点击 "查看全文" 可查看该博客完整内容。如果该博客作者是登录用户,可以编辑或删除博客。发表博客的页面同编辑页面。

本练习的博客网站,并没有添加注册功能,以及上传作者头像功能,头像是写死的。

用户登录:

博客列表:

博客详情:

博客编辑:

二、准备工作

1、数据库

用户文章数量、文章分类数量不要放到用户表中,因为如果添加在用户表中,文章的删除/添加(博客表)会影响文章数量也改变,导致用户表也跟着改变。文章数量也没办法放到博客表中,应该实时统计才行。

一个用户对应多个博客,一个博客对应一个用户。用户与博客是一对多的关系,博客 id(多个博客是一个 id 列表,没有列表基础类)无法放到用户表,因此将用户 id 放到博客表。

sql 复制代码
-- 建表SQL
create database if not exists spring_blog charset utf8mb4;

use spring_blog;
-- 用户表
DROP TABLE IF EXISTS spring_blog.user_info;
CREATE TABLE spring_blog.user_info(
        `id` INT NOT NULL AUTO_INCREMENT,
        `user_name` VARCHAR ( 128 ) NOT NULL,
        `password` VARCHAR ( 128 ) NOT NULL,
        `github_url` VARCHAR ( 128 ) NULL,
        `delete_flag` TINYINT ( 4 ) NULL DEFAULT 0,
        `create_time` DATETIME DEFAULT now(),
        `update_time` DATETIME DEFAULT now() ON UPDATE now(),
        PRIMARY KEY ( id ),
UNIQUE INDEX user_name_UNIQUE ( user_name ASC )) ENGINE = INNODB DEFAULT CHARACTER 
SET = utf8mb4 COMMENT = '用户表';

-- 博客表
drop table if exists spring_blog.blog_info;
CREATE TABLE spring_blog.blog_info (
  `id` INT NOT NULL AUTO_INCREMENT,
  `title` VARCHAR(200) NULL,
  `content` TEXT NULL,
  `user_id` INT(11) NULL,
  `delete_flag` TINYINT(4) NULL DEFAULT 0,
  `create_time` DATETIME DEFAULT now(),
  `update_time` DATETIME DEFAULT now() ON UPDATE now(),
  PRIMARY KEY (id))
ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '博客表';

-- 新增用户信息
insert into spring_blog.user_info (user_name, password,github_url)values("zhangsan","123456","https://gitee.com/piggy-mi");
insert into spring_blog.user_info (user_name, password,github_url)values("lisi","123456","https://gitee.com/piggy-mi");

insert into spring_blog.blog_info (title,content,user_id) values("第一篇博客","111我是博客正文我是博客正文我是博客正文",1);
insert into spring_blog.blog_info (title,content,user_id) values("第二篇博客","222我是博客正文我是博客正文我是博客正文",2);

2、创建项目

创建 Spring Boot 项目,添加 lombok、Spring Web、MyBatis、MySQL Driver 依赖。配置 MyBatis-Plus 依赖。导入前端代码。配置 yml 数据库连接、MyBatis-Plus 数据库操作日志和自动转驼峰命名、Spring Boot 日志保存目录。

为了演示依赖冲突排除的过程,创建项目时引入了 MyBatis 依赖(不用),后面又加了 MyBatis-Plus 依赖(用),它们会存在依赖冲突:

但实际把 mybatis 的依赖去掉就可以了。但是真实项目中,依赖会很多很多,并且我们创建项目的方式是直接复制粘贴旧的项目(避免繁琐地重新配置东西),因此很容易遇到依赖冲突,我们通过这种方式排除不用的即可。

3、创建目录

controller(表现层)、service(业务层)、mapper(持久层)、pojo(实体类)、config(配置)、common(公共部分,如常量、统一处理、自定义工具包等)。

实体类:pojo、model、entity 都是实体类。pojo 还细分了 VO(视图对象,返回的实体)、DO(数据对象,数据表对应的实体)、DTO(service 可能存在数据库实体转换成其它)、BO(业务对象)。这些属于阿里的规范,其它公司会模仿,但是具体使用时具有偏差,按照公司之前的项目来就行。

此项目中只细分出 dataobject (数据库的信息)、request (请求信息)、response (响应信息)。这样写的好处:不会暴露多余信息给前端、让逻辑不混乱。比如 request 实体需要用到 jakarta.validation 参数校验、@JsonProperty 前后端参数命名不一致时的映射;response 实体需要隐藏隐私信息、处理格式化数据(如把 create_time 格式化);dataobject 实体需要用到 @TableId、@TableNmae 等在实体属性与表字段名不一致时的映射。

SOA 理念 :在service 层 通常会先写 service 接口再写多个版本的继承了同一个接口的 service 实体 。这样做的好处就是,可以轻松替换 controller 层调用的 service bean 版本 (因为实现的同一个接口,所以方法也是一样的,不需要修改调用方法处的源码)(只需要修改 @Resource 中的 service bean 名即可。实际工作中,@Resource 替代了 @AutoWired ,好处:@Resource 默认按命名注入,适用于一个类(一个接口类)有多个 bean (多个实现类的 bean)的情况;而 @AutoWired 默认按类注入,存在问题)(当 类只有一个 bean 时,可以不指定 @Resource 中的命名)

4、测试

运行程序,看前端页面是否能正常访问,排除错误。避免后续加了其它功能后,代码复杂不好排查错误。

三、公共部分代码

项目主要分为 controller、service、mapper、数据库、实体类、公共部分(如统一处理)。数据库已经建好了,我们先完成公共部分代码。

1、统一数据返回格式

统一数据返回格式,返回 Result 实例:如果不统一,每个接口的返回结果非常定制化,前端不方便处理;并且想区分业务成功、业务失败、程序异常、未登录等情况还需要查特定接口返回值的含义,如果用 code 表示,含义就清晰很多。

(1)response 实体类

java 复制代码
package com.edu.spring.blog.pojo.response;

import com.edu.spring.blog.common.enums.ResultCodeEnums;
import lombok.Data;

@Data
public class Result <T>{
    Integer code;
    String errMsg;
    T data;
    
    public static <T> Result<T> success(T data) {
        Result<T> result = new Result<>();
        result.setCode(ResultCodeEnums.SUCCESS.getCode());
        result.setData(data);
        return result;
    }
    
    public static <T> Result<T> unLogin() {
        Result<T> result = new Result<>();
        result.setCode(ResultCodeEnums.UN_LOGIN.getCode());
        return result;
    }
    
    public static <T> Result<T> error(String errMsg) {
        Result<T> result = new Result<>();
        result.setCode(ResultCodeEnums.ERROR.getCode());
        result.setErrMsg(errMsg);
        return result;
    }
    
    public static <T> Result<T> error(Integer code, String errMsg) {
        Result<T> result = new Result<>();
        result.setCode(code);
        result.setErrMsg(errMsg);
        return result;
    }
}

将返回值 code 设计成枚举类,好处是:让无含义的数字具有含义,使用时调用有含义的枚举实例名。

java 复制代码
package com.edu.spring.blog.common.enums;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum ResultCodeEnums {
    SUCCESS(200, "业务处理成功"),
    UN_LOGIN(-1, "未登录"),
    ERROR(-2, "后端出错");

    private final Integer code;
    private final String message;
}

(2)统一处理代码

java 复制代码
package com.edu.spring.blog.common.advice;

import com.edu.spring.blog.pojo.response.Result;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import lombok.SneakyThrows;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;

@ControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice<Object> {
    @Resource
    private ObjectMapper mapper;

    @Override
    public boolean supports(MethodParameter returnType, Class converterType) {
        return true;
    }

    @SneakyThrows
    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        if (body instanceof Result<?>) {
            return body;
        }
        // body 通常不用 String,因为还得设置 response 的 content-type 为 application/json,比较麻烦
        if (body instanceof String) {
            return mapper.writeValueAsString(Result.success(body));
        }
        return Result.success(body);
    }
}

2、统一异常处理

定义自己的 Blog 异常类,也可以细分定义更多的异常,比如参数异常、内部错误异常等。我仅定义了 Blog 异常,通过 code、message 区分不同的异常。这里只是为了示范自定义异常。

因为父类也有 message 属性,所以 getMessage 获得的父类的 massage,所以要 @Getter 重写 get 方法。

java 复制代码
package com.edu.spring.blog.common.exception;

import lombok.Getter;

@Getter
public class BlogException extends RuntimeException {
    Integer code;
    String message;

    public BlogException(String message) {
        this.message = message;
    }

    public BlogException(Integer code, String message) {
        this.code = code;
        this.message = message;
    }
}
java 复制代码
package com.edu.spring.blog.common.advice;

import com.edu.spring.blog.common.exception.BlogException;
import com.edu.spring.blog.pojo.response.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;

@ControllerAdvice
@ResponseBody
@Slf4j
public class ExceptionAdvice {
    @ExceptionHandler
    public Result<?> error(Exception e) {
        log.error("服务器内部发生异常,e: ", e);
        return Result.error("服务器内部错误,请联系管理员");
    }

    @ExceptionHandler
    public Result<?> error(BlogException e) {
        log.error("发生异常,e: ", e);
        return Result.error(e.getMessage());
    }
}

3、拦截器

最后添加,因为其它功能开发过程中被拦截很麻烦,需要反复登录。

四、业务代码

1、持久层

(1)dataobject 实体类

按照数据库表创建:

java 复制代码
package com.edu.spring.blog.pojo.dataobject;

import lombok.Data;

import java.util.Date;

@Data
public class UserInfo {
    private Integer id;
    private String userName;
    private String password;
    private String githubUrl;
    private Byte deleteFlag;
    private Date createTime;
    private Date updateTime;
}
java 复制代码
package com.edu.spring.blog.pojo.dataobject;

import lombok.Data;

import java.util.Date;

@Data
public class BlogInfo {
    private Integer id;
    private String title;
    private String content;
    private Integer userId;
    private Byte deleteFlag;
    private Date createTime;
    private Date updateTime;
}

(2)mapper 接口

继承 MayBatis-Plus 框架提供的 BaseMapper<T> 类,包含基础的 mapper 操作方法。T 是操作的数据对象,一个表一个 mapper。

java 复制代码
package com.edu.spring.blog.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.edu.spring.blog.pojo.dataobject.UserInfo;

@Mapper
public interface UserInfoMapper extends BaseMapper<UserInfo> {
}
java 复制代码
package com.edu.spring.blog.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.edu.spring.blog.pojo.dataobject.BlogInfo;

@Mapper
public interface BlogInfoMapper extends BaseMapper<BlogInfo> {
}

2、博客列表

(1)接口设计

java 复制代码
请求:
/blog/getList    GET

参数:
无

响应
{
    code: 200,
    errMsg: null,
    data: [{
        "id": 1, // 需要根据 id 查询博客详情 
        "title": "我的第一篇博客",
        "content": "我可正文博客正文...不能超过 100 字"
        "createTime": "2025-09-04 18:44"
    },
    ......]
}

(2)response 实体类(@JsonFormat)

实际开发中,关于时间数据,后端更倾向于返回时间戳,这样的好处是:前端自行处理格式,若后续需要修改格式也很方便,与后端无关。使用 .getTime 便可以获得时间戳。

为了学习后端的时间格式化,我们返回格式化的时间字符串。可以用 SimpleDateFormat 类,也可以使用 @JsonFormat注解,更加方便。

格式查询 Java8 官方文档 SimpleDateFormat 类:SimpleDateFormat (Java Platform SE 8 )

在列表中,content 是显示不全的。content 可以由前端处理,也可以由后端处理。后端处理更好,因为传输的数据量更少,性能更好。

java 复制代码
package com.edu.spring.blog.pojo.response;

import com.edu.spring.blog.pojo.dataobject.BlogInfo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import org.springframework.beans.BeanUtils;

import java.text.SimpleDateFormat;
import java.util.Date;

@Data
public class BlogListResponse {
    private Integer id;
    private String title;
    private String content;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") // 纠正时区
    private Date createTime;

    // 后端更倾向于返回时间戳,前端可以自行转换
//    public Long getCreateTime() {
//        return createTime.getTime();
//    }

    // 2025-01-01 00:00:00
//    public String getCreateTime() {
//        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
//        return sdf.format(createTime);
//    }

    // 将 content 字段的长度限制为 100
    public String getContent() {
        return content.length() > 100? content.substring(0, 100) + "..." : content;
    }

    // 创建对象时,传入 dataobject,自动转换为 BlogListResponse 对象
    public BlogListResponse(BlogInfo blogInfo) {
        BeanUtils.copyProperties(blogInfo, this);
    }
}

(3)controller

java 复制代码
package com.edu.spring.blog.controller;

import com.edu.spring.blog.pojo.response.BlogListResponse;
import com.edu.spring.blog.service.BlogInfoService;
import jakarta.annotation.Resource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/blog")
public class BlogInfoController {
    @Resource(name = "blogInfoServiceImpl")
    private BlogInfoService blogInfoService;

    @GetMapping("/getList")
    public List<BlogListResponse> getBlogList() {
        return blogInfoService.getBlogList();
    }
}

(4)service

接口:

java 复制代码
package com.edu.spring.blog.service;

import com.edu.spring.blog.pojo.response.BlogListResponse;

import java.util.List;

public interface BlogInfoService {
    List<BlogListResponse> getBlogList();
}

实现类:

  • .map:对流中的每个元素应用一个函数,将其映射成另一个元素,从而生成一个新的流。
  • .collect(): 将流中的元素累积到一个可变的结果容器中,通过一个Collector来指定如何进行累积操作。
  • Collectors.toList(): 将流中的元素收集到一个List中。

BeanUtils.copyProperties 会自动将 blogInfo 复制到 response 对应属性中。每次 new 了 response 都要转换一次,代码冗余。不如直接传入 blogInfo 在构造函数里进行转换。

java 复制代码
package com.edu.spring.blog.service.impl;

import java.util.List;
import java.util.stream.Collectors;

@Service("blogInfoServiceImpl")
public class BlogInfoServiceImpl implements BlogInfoService {
    @Resource(name = "blogInfoMapper")
    private BlogInfoMapper blogInfoMapper;

    @Override
    public List<BlogListResponse> getBlogList() {
        // 查询出所有未删除的博客信息,按创建时间倒序排列
        List<BlogInfo> blogInfoList = blogInfoMapper.selectList(new LambdaQueryWrapper<BlogInfo>()
                .eq(BlogInfo::getDeleteFlag, Constants.NOT_DELETE)
                .orderByDesc(BlogInfo::getCreateTime));
        // 将 BlogInfo 转换为 BlogListResponse
        return blogInfoList.stream().map(blogInfo -> {
//            BlogListResponse response = new BlogListResponse();
//            // response.setId(blogInfo.getId());  这种方法太麻烦了,还要一个个设置属性
//            // 使用 BeanUtils 工具类
//            BeanUtils.copyProperties(blogInfo, response);
//            return response;
            // 直接在构造方法里转换
            return new BlogListResponse(blogInfo);
        }).collect(Collectors.toList());
    }
}

常量类:

java 复制代码
package com.edu.spring.blog.common.constant;

public class Constants {
    public static final Byte IS_DELETE = 1;
    public static final Byte NOT_DELETE = 0;
}

(5)前端 JS

html 复制代码
    <script>
        getList();
        function getList() {
            $.ajax({
                url: "/blog/getList",
                type: "GET",
                success: function (result) {
                    if(result.code === 200 && result.data != null) {
                        let blogs = result.data;
                        let html = "";
                        for(let blog of blogs) {
                            html += "<div class=\"blog\">"
                            html += "<div class=\"title\">" + blog.title + "</div>"
                            html += "<div class=\"date\">" + blog.createTime + "</div>"
                            html += "<div class=\"desc\">" + blog.content + "</div>"
                            html += "<a class=\"detail\" href=\"blog_detail.html?id=" + blog.id + "\">查看全文&gt;&gt;</a>"
                            html += "</div>"
                        }
                        $(".right").html(html);
                    } else {
                        alert(result.errMsg)
                    }
                }
            });
        }
    </script>

(6)测试

3、博客详情

(1)接口设计

java 复制代码
请求:
/blog/getBlogDetail?id=1    GET

参数:
无

响应:
{
    code: 200,
    errMsg: null,
    data: {
        "id": 1,
        "title": "我的第一篇博客",
        "content": "我可正文博客正文...不能超过 100 字"
        "userId": "zhangsan",
        "createTime": "2025-09-04 18:44"
    }
}

(2)response 实体类

java 复制代码
package com.edu.spring.blog.pojo.response;

import com.edu.spring.blog.pojo.dataobject.BlogInfo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import org.springframework.beans.BeanUtils;

import java.util.Date;

@Data
public class BlogDetailResponse {
    private Integer id; // 用于编辑/删除博客
    private String title;
    private String content;
    private Integer userId; // 用于显示作者信息
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") // 纠正时区
    private Date createTime;
    
    public BlogDetailResponse(BlogInfo blogInfo) {
        BeanUtils.copyProperties(blogInfo, this);
    }
}

(3)controller

Java 在编译时默认 不会把参数名编译进 .class 文件只保留参数类型,所以访问时找不到参数名去绑定,所以会报错:

第一种方法:加 @RequestParam("id") 显示绑定,但这个方法要求每个参数都要绑定,很麻烦。第二中方法配置 idea:给项目配置 -parameters,还不行就 clean 一下 target。

关于参数校验 ,用 if-else 校验很麻烦。我们使用jakarta.validation工具里的注解帮我们校验。需要加入依赖:

XML 复制代码
<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

常见注解:

|---------------------|-------------------------------------------|-----------------|
| 注解 | 说明 | 适用类型 |
| @NotBlank | 不能为 null,而且调用 trim() 后,长度必须大于 0,即必须有实际字符。 | String 类型 |
| @NotEmpty | 等不能为 null,且长度必须大于 0。 | 字符串、集合、数组 |
| @NotNull | 不为空。 | 任何类型 |
| @Min | 大等于指定的值 | Number 、 String |
| @Size(min=, max=) | 长度在给定的范围之内 | 字符串、集合、数组 |
| @Length(min=, max=) | 长度在给定的范围之内 | String 类型 |

controller:

java 复制代码
    @GetMapping("/getBlogDetail")
    public BlogDetailResponse getBlogDetail(@NotNull(message = "blogId 不能为 null")
                                                @Min(value = 1, message = "blogId 不能小于 1")
                                                Integer id) {
        log.info("获得博客详情,blogId: {}", id);
        return blogInfoService.getBlogDetail(id);
    }

前端参数不符合规范:抛出 HandlerMethodValidationException

异常信息:

统一异常处理:

java 复制代码
    @ExceptionHandler
    public Result<?> error(HandlerMethodValidationException e) {
        List<String> errors = e.getAllErrors().stream()
                .map(error -> error.getDefaultMessage()).toList();
        log.error("发生参数校验异常,errors: {}", errors);
        return Result.error(Constants.REQUEST_PARAM_ERROR, String.join("; ", errors));
    }

    @ExceptionHandler
    public Result<?> error(MethodArgumentNotValidException e) {
        List<String> errors = e.getBindingResult().getFieldErrors().stream()
                .map(error -> error.getDefaultMessage()).toList();
        log.error("发生参数校验异常,errors: {}", errors);
        return Result.error(Constants.REQUEST_PARAM_ERROR, String.join("; ", errors));
    }

(4)service

java 复制代码
    @Override
    public BlogDetailResponse getBlogDetail(Integer id) {
        // 查询出指定 id 的,未删除的博客信息
        BlogInfo blogInfo = blogInfoMapper.selectOne(new LambdaQueryWrapper<BlogInfo>()
                .eq(BlogInfo::getId, id)
                .eq(BlogInfo::getDeleteFlag, Constants.NOT_DELETE));
        // 将 BlogInfo 转换为 BlogDetailResponse
        return new BlogDetailResponse(blogInfo);
    }

(5)前端 JS

java 复制代码
    <script>
        getBlogDetail();
        function getBlogDetail() {
            $.ajax({
                url: "/blog/getBlogDetail" + location.search,
                type: "GET",
                success: function(result) {
                    if (result.code === 200 && result.data != null) {
                        let blog = result.data;
                        $(".title").text(blog.title);
                        $(".date").text(blog.createTime);
                        $(".detail").text(blog.content);
                        // TODO 显示博客作者信息
                        // TODO 编辑和删除
                    } else {
                        alert(result.errMsg);
                    }
                }
            });

        }
    </script>

(6)测试

4、用户登录

Http 是无状态的,客户端第一次请求服务器,后续再请求,服务器无法识别该客户端是否请求过。会话跟踪就是为了让服务器 "有记忆"。

(1)Session&Cookie 存在的问题

  • Session 存储在服务器内存中 ,服务器重启后 ,内存中的 Session 就会丢失。(对于现实项目中,因程序版本更新而重启服务器是很正常的需求,如果 session 丢失,某些用户刚登录又要求重新登陆,在用户看来就是 bug。)
  • Session 存储在服务器内存中,增加了服务器的负担。(登陆的用户量庞大,session 占用内存大)
  • 无法在集群环境下实现会话跟踪 。(现实中,一个公司至少有两台服务器 ,并且最好不要在同一机房甚至同一城市。一个单体应用的多个实例分别在这多个服务器上运行,这样做的目的一是分担服务器负担 、二是避免单服务器故障导致整个应用无法访问 。为了合理分配请求 给不同的服务器上的应用,请求会先经过负载均衡算法 ,根据不同服务器的性能、请求访问的接口重量等进行分配。还有就是微服务,将整个项目按功能、重量等拆分成多个微服务,一般服务中的每个接口越重,划分的接口就越少。)(在此条件下,客户端第一次的请求 可能被分配到服务器1 ,session 保存在服务器1的内存 中。客户端第二次的请求 可能被分配到其它服务器 ,其他服务器内存不含该 session,会话跟踪失败)

因此我们需要解决两个问题:1、session 持久化(如果放到 MySQL 数据库,即硬盘,硬盘存取速度慢。更优的是 Redis 缓存中间件,session 有缓存在内存提速,也有持久化防止丢失。但这些方法仍占用服务器内存,增加负担)。2、集群环境共享 session(session 持久化后,也就解决了该问题。比如每个服务器都能从数据库中获取 session)。

(2)JWT 令牌

令牌就是用户身份的标识 ,本质是一个字符串 token,类似身份证。

优点

  • session 存在客户端(cookie 或者 localStorage 浏览器提供的客户端本地存储技术),减轻服务器压力。
  • 解决了集群环境下的会议跟踪问题(客户端第一次请求分配给服务器1,生成令牌返回,存储在客户端;客户端第二次请求携带令牌分配给服务器2,令牌不可篡改,因为只有它持有密钥加密成签名,篡改了,当前令牌的签名和之前的签名就对不上)。

缺点

  • 需要自己实现令牌生成、传输、校验技术

常见的有 JWT 令牌,是第三方工具,帮我们实现了令牌。

JSON Web Tokens - jwt.iohttps://www.jwt.io/ JWT 令牌组成

参考之前写的 HTTPS 证书,令牌类似于证书:Header + Payload + 仅服务端持有的密钥加密校验和生成唯一的签名=令牌。因此令牌的 Header、Payload 无法篡改,改了的话校验和就变了;校验和也不能改,因为无法获取服务端持有的密钥加密。

JWT 令牌的使用:添加依赖

XML 复制代码
 <!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api -->
 <dependency>
 <groupId>io.jsonwebtoken</groupId>
 <artifactId>jjwt-api</artifactId>
 <version>0.11.5</version>
 </dependency>
 <!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl -->
 <dependency>
 <groupId>io.jsonwebtoken</groupId>
 <artifactId>jjwt-impl</artifactId>
 <version>0.11.5</version>
 <scope>runtime</scope>
 </dependency>
 <dependency>
 <groupId>io.jsonwebtoken</groupId>
 <artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is 
preferred -->
 <version>0.11.5</version>
 <scope>runtime</scope>
 </dependency>

使用示例:

java 复制代码
@SpringBootTest
public class JwtUtilTest {
    // 密钥字符串
    String secretString = "1mF4QaoRhmt82qmv0fqP1BJ80OmRLI+8sFwUtscTLMM=";
    // 密钥字符串转为密钥对象
    Key key = Keys.hmacShaKeyFor(secretString.getBytes(StandardCharsets.UTF_8));
    // 过期时间,单位:毫秒
    long EXPIRATION_TIME = 1000 * 60 * 60 * 24;

    // 测试生成令牌
    @Test
    public void generateToken() {
        // 自定义 Payload
        Map<String, Object> payload = new HashMap<>();
        payload.put("id", 1);
        payload.put("username", "admin");
        // 生成令牌 Token
        String token = Jwts.builder()
                .setClaims(payload)
                .signWith(key, SignatureAlgorithm.HS256) // 使用 HS256 算法进行签名
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) // 设置过期时间
                .compact();
        System.out.println(token);
    }

    // 测试生成随机密钥字符串
    @Test
    public void generateSecretString() {
        // 生成随机密钥对象
        SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
        // 将二进制密钥转换为 Base64 编码的密钥字符串
        String secretString = Encoders.BASE64.encode(key.getEncoded());
        System.out.println(secretString);
    }

    // 测试检验令牌
    @Test
    public void checkToken() {
        String token = "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsImV4cCI6MTc1NzE1NTMzMX0.Kc0Bc41u5eYKJBN397Y9mV12PjwVHaK4ed1GpzGOBZE";
        // 检验令牌,密钥不匹配、令牌被篡改、过期等都会抛出异常
        JwtParser builder = Jwts.parserBuilder()
                .setSigningKey(key) // 设置签名密钥
                .build();
        // 解析令牌,获取 Payload
        Claims body = builder.parseClaimsJws(token).getBody();
        System.out.println(body);
    }
}

生成 token:

随机生成密钥字符串:

检验 token 并获取 payload:Claims 继承了 Map,可当作 Map 使用。

将生成的 token 解析:

(3)实现用户登录

思路:客户端请求登录,服务器访问数据库,验证用户名、密码是否匹配,匹配则生成令牌,响应给客户端,客户端把令牌存到本地。

令牌保存在客户端,服务器重启不会丢失、不占内存,不同服务器上的应用实例都可以获取到该令牌,实现集群环境下的登录。

客户端登陆后请求服务器,会携带本地存储的令牌,服务器执行拦截器,解析令牌是否正确,正确则不拦截。解析令牌时需要获取用户信息,在令牌的 payload 中,用于识别不同的用户会话,需要使用 TreadLocal 存储 payload。因为在 Java Web 容器(如 Tomcat)中,每个请求会分配一个线程,请求处理完毕后线程归还线程池,而 ThreadLocal 中的数据仅在当前请求的线程处理周期内有效

接口设计:

java 复制代码
请求:
/user/login    POST

参数:
{
    "userName": "zhangsan",
    "password": "123456"
}

响应:
{
    code: 200,
    errMsg: null,
    data: null
}

token 放在 header 的 set-token 字段

使用 JWT 实现的令牌生成、校验工具:

java 复制代码
public class JwtUtil {
    // 密钥字符串
    private static final String secretString = "1mF4QaoRhmt82qmv0fqP1BJ80OmRLI+8sFwUtscTLMM=";
    // 密钥字符串转为密钥对象
    private static final Key key = Keys.hmacShaKeyFor(secretString.getBytes(StandardCharsets.UTF_8));
    // 过期时间,单位:毫秒,24小时
    private static final long EXPIRATION_TIME = 1000 * 60 * 60 * 24;

    // 根据自定义 payload 生成令牌
    public static String generateToken(Map<String, Object> payload) {
        return Jwts.builder()
                .setClaims(payload)
                .signWith(key, SignatureAlgorithm.HS256) // 使用 HS256 算法进行签名
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) // 设置过期时间
                .compact();
    }

    // 检验令牌,返回 payload 中的用户信息
    public static Claims checkToken(String token) {
        JwtParser builder = Jwts.parserBuilder()
                .setSigningKey(key) // 设置签名密钥
                .build();
        try {
            // 解析令牌,密钥不匹配、令牌被篡改、过期等都会抛出异常。并获取 Payload
            return builder.parseClaimsJws(token).getBody();
        } catch (Exception e) {
            return null;
        }
    }
    
    // 将原数据 UserInfo 转换为 Map
    public static Map<String, Object> convertMap(UserInfo userInfo) {
        Map<String, Object> map = new HashMap<>();
        map.put("userId", userInfo.getId());
        map.put("username", userInfo.getUserName());
        return map;
    }
}

单线程内共享(所有方法和接口)当前会话用户信息工具:

java 复制代码
package com.edu.spring.blog.common.util;

import java.util.Map;

public class UserContextUtil {
    private static final ThreadLocal<Map<String, Object>> userContext = new ThreadLocal<>();

    public static void setContext(Map<String, Object> context) {
        userContext.set(context);
    }

    public static Map<String, Object> getContext() {
        return userContext.get();
    }

    public static void clearContext() {
        userContext.remove();
    }
}

request 实体类:使用参数校验注解

java 复制代码
package com.edu.spring.blog.pojo.request;

import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import org.hibernate.validator.constraints.Length;

@Data
public class UserLoginRequest {
    @NotBlank(message = "用户名不能为空")
    @Length(max = 20, message = "用户名长度不能超过20个字符")
    private String userName;
    @Length(max = 20, message = "密码长度不能超过20个字符")
    @NotBlank(message = "密码不能为空")
    private String password;
}

controller:将令牌写到 response 的 header 中,对象参数检验要加 @Validated

java 复制代码
    @PostMapping("/login")
    public Result<?> login(@Validated @RequestBody UserLoginRequest request, HttpServletResponse response) {
        log.info("用户登录请求 request: {}", request);
        String token = userInfoService.login(request);
        response.setHeader(Constants.RESPONSE_HEADER_TOKEN, token);
        return Result.success(null);
    }

service:

java 复制代码
    @Override
    public String login(UserLoginRequest request) {
        UserInfo userInfo = userInfoMapper.selectOne(new LambdaQueryWrapper<UserInfo>()
                .eq(UserInfo::getUserName, request.getUserName())
                .eq(UserInfo::getDeleteFlag, Constants.NOT_DELETE));

        // 校验登录信息
        if(userInfo == null) {
            throw new BlogException("用户不存在");
        }
        if(!userInfo.getPassword().equals(request.getPassword())){
            throw new BlogException("密码错误");
        }
        // 校验正确,根据 userInfo 生成令牌
        return JwtUtil.generateToken(JwtUtil.convertMap(userInfo));
    }

前端 JS:

javascript 复制代码
        function login() {
            $.ajax({
                url: "/user/login",
                type: "post",
                contentType: "application/json",
                data: JSON.stringify({
                    "userName": $("#username").val(),
                    "password": $("#password").val()
                }),
                success: function(result, textStatus, xhr) {
                    if (result.code === 200) {
                        // 把 token 存到 localStorage 中
                        localStorage.setItem("token", xhr.getResponseHeader("set-token"));
                        location.href = "blog_list.html";
                    } else {
                        alert(result.errMsg);
                    }
                }
            });
        }

(4)实现强制登陆

拦截器定义:

java 复制代码
package com.edu.spring.blog.common.interceptor;

@Component
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 从 request 的 header 中获取令牌
        String token = request.getHeader(Constants.REQUEST_HEADER_TOKEN);
        // 校验令牌,获取 payload
        Claims payload = JwtUtil.checkToken(token);
        // 校验无效,拦截请求
        if (payload == null) {
            log.error("无效的令牌 {},拦截请求", token);
            // 设置响应头状态码,告诉浏览器未授权
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            return false;
        }
        // 校验有效,将 payload 存入 ThreadLocal 中,后续可通过 ThreadLocal 获取当前用户信息
        UserContextUtil.setContext(payload);
        // 放行请求
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        // 请求处理之后,清除 ThreadLocal 中的用户信息,防止内存泄漏
        UserContextUtil.clearContext();
    }
}

拦截器注册:

java 复制代码
package com.edu.spring.blog.common.config;

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Resource
    private LoginInterceptor loginInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        List<String> excludePathPatterns = List.of(
                "/user/login",
                "/**/*.html",
                "/blog-editormd/**",
                "/css/**",
                "/js/**",
                "/pic/**",
                "/**/*.ico");
        registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns(excludePathPatterns);
    }
}

前端 JS:因为每次请求都要经过拦截器,都可能触发 401 状态码,前端需要统一处理。登录后,每次请求都要携带 token,也需要统一处理。放到 common.js 中,所有 html 都要加 common.js:

javascript 复制代码
// 统一异常状态码处理
$(document).ajaxError(function(event,xhr){
    if(xhr.status === 401){
        location.href = "/blog_login.html";
    }
});

// 统一携带 token
$(document).ajaxSend(function (e, xhr) {
    let userToken = localStorage.getItem("token");
    xhr.setRequestHeader("token", userToken);
});

5、显示用户信息

用户发表文章数:每次直接用 SQL 查询比较慢。优化方向:使用 redis 缓存。缓存没有文章数量,则 SQL 查询,有则读取缓存。新增数据时,更新缓存值;删除数据时,更新缓存值或者直接删除缓存值。

可扩展,用户博客分类:如果一个博客只有一个分类,则把分类加入博客表。如果一个博客可对应多个分类,则抽出一个分类表,分类 id、博客 id、分类名。

可扩展,用户头像:重写 WebMvcConfigurer 类的 addResourceHandlers 方法,将 url 的路径映射到存放静态资源的路径。有条件可以将文件单独存放在一个服务器。然后在用户表中加上图片路径字段。

java 复制代码
public void addResourceHandlers(ResourceHandlerRegistry registry) {
    // 映射自定义静态资源
    registry.addResourceHandler("/img/**")
    .addResourceLocations("file:D:/pic/");
}

访问 url:http://127.0.0.1:8080/img/头像.jpg

获取到文件资源:
本地找 D:/pic/头像.jpg

(1)接口设计

java 复制代码
请求:
/user/getUserInfo    GET

参数:
无

响应:
{
    "code": 200,
    "errMsg": null,
    "data": {
        "userName": "zhangsan",
        "githubUrl": "https://gitee.com/zhangsan",
        "blogNum": 2
    }
}

(2)response 实体类

java 复制代码
package com.edu.spring.blog.pojo.response;

@Data
public class UserInfoResponse {
    private String userName;
    private String githubUrl;
    private Long blogNum;

    public UserInfoResponse(UserInfo userInfo, Long blogNum) {
        BeanUtils.copyProperties(userInfo, this);
        this.blogNum = blogNum;
    }
}

(3)后端

controller:

java 复制代码
    @GetMapping("/getUserInfo")
    public UserInfoResponse getUserInfo() {
        return userInfoService.getUserInfo();
    }

service:

java 复制代码
    @Override
    public UserInfoResponse getUserInfo() {
        // 从上下文中获取 userId
        Integer userId = (Integer) UserContextUtil.getContext().get("userId");
        return getUserInfoById(userId);
    }
    
    public UserInfoResponse getUserInfoById(Integer userId) {
        // 根据 userId 查询用户信息
        UserInfo userInfo = userInfoMapper.selectById(userId);
        // 根据 userId 查询博客数量
        Long blogNum = blogInfoMapper.selectCount(new LambdaQueryWrapper<BlogInfo>()
                .eq(BlogInfo::getUserId, userId)
                .eq(BlogInfo::getDeleteFlag, Constants.NOT_DELETE));
        return new UserInfoResponse(userInfo, blogNum);
    }

(4)前端 JS

javascript 复制代码
        function getUserInfo() {
            $.ajax({
                url: "/user/getUserInfo",
                type: "GET",
                success: function (result) {
                    if(result.code === 200 && result.data != null) {
                        let userInfo = result.data;
                        $(".container .left .card h3").text(userInfo.userName);
                        $(".container .left .card a").attr("href", userInfo.githubUrl);
                        $(".container .left .card .blog-count").text(userInfo.blogNum);
                    } else {
                        alert(result.errMsg)
                    }
                }
            });
        }

同理,显示作者信息、编辑删除按钮:

controller:

java 复制代码
    @GetMapping("/getAuthorInfo")
    public UserInfoResponse getAuthorInfo(Integer authorId) {
        return userInfoService.getAuthorInfo(authorId);
    }

service:

java 复制代码
    @Override
    public UserInfoResponse getAuthorInfo(Integer authorId) {
        // 获得作者信息
        UserInfoResponse userInfoResponse = getUserInfoById(authorId);
        // 判断当前登录用户是否为作者
        userInfoResponse.setIsUserVerified(authorId.equals(UserContextUtil.getContext().get("userId")));
        return userInfoResponse;
    }

前端 JS:

javascript 复制代码
        function getAuthorInfo(authorId) {
            $.ajax({
                url: "/user/getAuthorInfo?authorId=" + authorId,
                type: "GET",
                success: function(result) {
                    if (result.code === 200 && result.data != null) {
                        let author = result.data;
                        $(".container .left .card h3").text(author.userName);
                        $(".container .left .card a").attr("href", author.githubUrl);
                        $(".container .left .card .blog-count").text(author.blogNum);
                        // 如果登录用户是博客作者,显示编辑、删除按钮
                        if (author.isUserVerified === true) {
                            let html = "<div class=\"operating\">";
                            html += "<button onclick=\"window.location.href='blog_update.html'\">编辑</button>";
                            html += "<button onclick=\"deleteBlog()\">删除</button></div>";
                            $(".right .content").append(html);
                        }
                    } else {
                        alert(result.errMsg);
                    }
                }
            });
        }

6、用户退出

删除本地 token,转到登陆页面。前端退出方法,放到 common.js:

javascript 复制代码
// 注销登录
function logout() {
    localStorage.removeItem("token");
    location.href = "/blog_login.html";
}

7、发布博客

(1)接口设计

java 复制代码
请求:
/blog/publishBlog    POST

参数:
application/json
{
    "id": 1,
    "title": "我的第一篇博客",
    "content": "博客正文博客正文博客正文博客正文"
}

响应:
{
    "code": 200,
    "errMsg": null,
    "data": true
}

(2)request 实体类

java 复制代码
@Data
public class BlogPublishRequest {
    @NotBlank(message = "博客标题不能为空")
    private String title;
    @NotBlank(message = "博客内容不能为空")
    private String content;
}

(3)后端

controller:

java 复制代码
    @PostMapping("/publishBlog")
    public Boolean publishBlog(@Validated @RequestBody BlogPublishRequest blogPublishRequest) {
        log.info("发布博客,blogPublishRequest: {}", blogPublishRequest);
        return blogInfoService.publishBlog(blogPublishRequest) == 1;
    }

service:

java 复制代码
    @Override
    public Integer publishBlog(BlogPublishRequest blogPublishRequest) {
        // blog 数据库对象
        BlogInfo blogInfo = new BlogInfo();
        blogInfo.setUserId(UserContextUtil.getUserId());
        // 复制 blogPublishRequest 到 blogInfo 对象
        BeanUtils.copyProperties(blogPublishRequest, blogInfo);
        return blogInfoMapper.insert(blogInfo);
    }

(4)前端

editor.md 是⼀个开源的⻚⾯ markdown 编辑器组件。

Editor.md - 开源在线 Markdown 编辑器http://editor.md.ipandao.com/ 使用:

Markdown 文本转 HTML 本文:

java 复制代码
editormd.markdownToHTML("detail", markdown: blog.content});

同理编辑博客:

java 复制代码
请求实体类:
@Data
public class BlogUpdateRequest {
    @NotNull(message = "博客ID不能为空")
    private Integer id;
    @NotBlank(message = "博客标题不能为空")
    private String title;
    @NotBlank(message = "博客内容不能为空")
    private String content;
}
    
controller:
    @PostMapping("/updateBlog")
    public Boolean updateBlog(@Validated @RequestBody BlogUpdateRequest blogUpdateRequest) {
        log.info("更新博客,blogUpdateRequest: {}", blogUpdateRequest);
        return blogInfoService.updateBlog(blogUpdateRequest) == 1;
    }

service:
    @Override
    public Integer updateBlog(BlogUpdateRequest blogUpdateRequest) {
        BlogInfo blogInfo = new BlogInfo();
        // 按 id 更新博客
        BeanUtils.copyProperties(blogUpdateRequest, blogInfo);
        return blogInfoMapper.updateById(blogInfo);
    }


        // 获取博客详情并显示
        function getBlogInfo() {
            $.ajax({
                type: "get",
                url: "/blog/getBlogDetail" + location.search,
                success: function (result) {
                    if (result.code === 200 && result.data != null) {
                        let blogInfo = result.data;
                        $("#blogId").val(blogInfo.id);
                        $("#title").val(blogInfo.title);
                        // $("#content").val(blogInfo.content);
                        let editor = editormd("editor", {
                            width: "100%",
                            height: "550px",
                            path: "blog-editormd/lib/",
                            // 刷新,避免缓存导致显示不正确
                            onload: function () {
                                this.watch();
                                this.setMarkdown(blogInfo.content);
                            }
                        });
                    } else {
                        alert(result.errMsg);
                    }
                }
            });
        }
        getBlogInfo();

前端JS:        
        // 更新博客
        function submit() {
            $.ajax({
                url: "/blog/updateBlog",
                type: "POST",
                contentType: "application/json;charset=UTF-8",
                data: JSON.stringify({
                    "id": $('#blogId').val(),
                    "title": $('#title').val(),
                    "content": $('#content').val()
                }),
                success: function (result) {
                    if (result.code === 200 && result.data === true) {
                        location.href = "blog_list.html";
                    } else if (result.code === 200 && result.data === false) {
                        alert("更新失败!")
                    } else {
                        alert(result.errMsg)
                    }
                }
            });
        }

8、删除博客

(1)接口设计

java 复制代码
请求:
/blog/deleteBlog?id=1

参数:
无

响应:
{
    "data": 200,
    "errMsg": null,
    "data": true
}

(2)代码

java 复制代码
controller:
    @DeleteMapping("/deleteBlog")
    public Boolean deleteBlog(@NotNull(message = "blogId 不能为 null") Integer id) {
        log.info("删除博客,blogId: {}", id);
        return blogInfoService.deleteBlog(id) == 1;
    }

service:
    @Override
    public Integer deleteBlog(Integer id) {
        return blogInfoMapper.update(new LambdaUpdateWrapper<BlogInfo>()
                .set(BlogInfo::getDeleteFlag, Constants.IS_DELETE)
                .eq(BlogInfo::getId, id));
    }

前端:
        function deleteBlog() {
            $.ajax({
                url: "/blog/deleteBlog" + location.search,
                type: "DELETE",
                success: function(result) {
                    if (result.code === 200 && result.data === true) {
                        location.href = "blog_list.html";
                    } else if (result.code === 200 && result.data === false) {
                        alert("删除失败!");
                    } else {
                        alert(result.errMsg);
                    }
                }
            });
        }

9、加密/加盐

(1)加密的作用

数据库的信息非常隐私及重要,为了防止黑客 获取到数据库信息后利用隐私信息 ,我们需要对隐私信息加密(比如身份证、密码等。我们把项目部署到服务器上后,很可能就被黑客侵入了数据库,以此找你要钱。)。

加密算法分为三类:

  • 对称加密:加密/解密的密钥相同,常见的算法有:AES、DES。
  • 非对称加密:加密/解密的密钥不同,通常用公钥加密、私钥解密,常见的算法有:RSE、DSE。
  • 摘要算法 :把任意长度的消息加密为固定长度 的字符串,不论平台、语言,相同消息加密后的摘要相同的(排除小概率事件:消息不同,也可能摘要相同)。常见算法有:MD5、CRC。

对称、非对称加密算法可逆,摘要算法不可逆 ,但简单消息的摘要可破解。破解过程:通过枚举得到数字、英文字母组合的信息,然后计算相应的摘要,存储到 map 中。只要服务器无限大,就能破解摘要。但代价很高,不法分子在利益和代价的权衡中,只能破解较简单的信息的摘要。可以尝试简单/复杂信息的加/解密:

MD5在线加密/解密/破解---MD5在线https://www.sojson.com/encrypt_md5.html

(2)基础加密思路

基础加密思路:可能用户注册时,使用了简单密码,容易被破解。但我们作为服务提供者 ,需要帮助用户 保护信息,增强密码的复杂度

(3)加盐加密思路

给用户密码加上一段随机字符串 ,即加盐,让密码更复杂,计算出来的摘要更难破解。为什么不用固定的盐值?如果所有用户密码加上同一个盐值,一旦黑客破解了这一个盐值,就能破解所有简单用户的密码了,因此每个密码应加随机盐值,安全性更高。

既然是随机的,那么就需要把随机盐值存到用户表中,因为后续登录需要用该盐值计算摘要 对比是否与数据库存储的摘要相同。如果直接存放在表中的一个字段,显然不行,这跟不加盐没区别。我们需要根据自定义的规则将加盐后的密码摘要、盐值摘要拼接,这个规则只有自己人知道:

(4)Spring 内置 MD5 工具使用

加密/解密工具包:

java 复制代码
public class Md5Util {
    // 加盐加密
    public static String md5(String password) {
        // 生成随机盐值
        // UUID 是唯一的标识,会生成带"-"的字符串
        // 去掉"-"后,与 md5 加密后的摘要长度相同,无法分辨是盐值还是摘要
        String salt = UUID.randomUUID().toString().replace("-", "");
        // md5(盐值 + 明文)
        String md5 = DigestUtils.md5DigestAsHex((password + salt).getBytes(StandardCharsets.UTF_8));
        // 按自定义规则,拼接盐值和摘要:盐值 + 密文
        return salt + md5;
    }

    // 检验密码是否正确
    public static Boolean checkPassword(String password, String sqlPassword) {
        // 密码不能为空
        if (!StringUtils.hasLength(password)) {
            return false;
        }
        // 取出盐值
        String salt = sqlPassword.substring(0, 32);
        // 取出数据库摘要
        String md5 = sqlPassword.substring(32);
        // 生成输入密码的摘要
        String md5Input = DigestUtils.md5DigestAsHex((password + salt).getBytes(StandardCharsets.UTF_8));
        // 比较两个摘要是否相同
        return md5.equals(md5Input);
    }
}

service 登录业务:

java 复制代码
    @Override
    public String login(UserLoginRequest request) {
        UserInfo userInfo = userInfoMapper.selectOne(new LambdaQueryWrapper<UserInfo>()
                .eq(UserInfo::getUserName, request.getUserName())
                .eq(UserInfo::getDeleteFlag, Constants.NOT_DELETE));

        // 校验登录信息
        if(userInfo == null) {
            throw new BlogException("用户不存在");
        }
//        if(!userInfo.getPassword().equals(request.getPassword())){
//            throw new BlogException("密码错误");
//        }
        if(!Md5Util.checkPassword(request.getPassword(), userInfo.getPassword())) {
            throw new BlogException("密码错误");
        }
        // 校验正确,根据 userInfo 生成令牌
        return JwtUtil.generateToken(JwtUtil.convertMap(userInfo));
    }

补充:什么是 UUId? uuid 是唯一标识符,重复的概率很低,应用中,它可以用来标识未登陆的用户。比如购物平台,对于登陆的用户,可以用 userId 来标识他,从而根据它的喜好推荐商品。但对于未登录的用户,也能推荐商品,就是靠 uuid,相当于 mac 地址根据设备标识。比如一个设备有一个 uuid,未登录时会记录搜索喜好;登陆时,也会把 userId 的喜好绑定给 uuid。

相关推荐
周航宇JoeZhou5 小时前
JP4-7-MyLesson后台前端(五)
java·前端·vue·elementplus·前端项目·mylesson·管理平台
David爱编程5 小时前
从 JVM 到内核:synchronized 与操作系统互斥量的深度联系
java·后端
bikong75 小时前
一种高效绘制余晖波形的方法Qt/C++
数据库·c++·qt
渣哥5 小时前
Java Set 不会重复?原来它有“记仇”的本事!
java
一叶飘零_sweeeet5 小时前
从 0 到 1 攻克订单表分表分库:亿级流量下的数据库架构实战指南
java·数据库·mysql·数据库架构·分库分表
苹果醋35 小时前
数据库索引设计:在 MongoDB 中创建高效索引的策略
java·运维·spring boot·mysql·nginx
Dontla5 小时前
Dockerfile解析器指令(Parser Directive)指定语法版本,如:# syntax=docker/dockerfile:1
java·docker·eureka
xianyinsuifeng5 小时前
Oracle 10g → Oracle 19c 升级后问题解决方案(Pro*C 项目)
c语言·数据库·oracle
彭于晏Yan5 小时前
SpringBoot优化树形结构数据查询
java·spring boot·后端