一、功能描述
用户登录后,可查看所有人的博客。点击 "查看全文" 可查看该博客完整内容。如果该博客作者是登录用户,可以编辑或删除博客。发表博客的页面同编辑页面。
本练习的博客网站,并没有添加注册功能,以及上传作者头像功能,头像是写死的。
用户登录:

博客列表:

博客详情:

博客编辑:

二、准备工作
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 + "\">查看全文>></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。