老铁,前面咱们学了 Spring Boot、MyBatis、事务、AOP...... 理论知识堆了一箩筐,也该动手练练了。
别急,从本期开始,咱就手把手从 0 到 1 撸一个完整的博客系统 。
这个系统麻雀虽小五脏俱全:登录、发表、编辑、删除、详情、列表...... 能让你把之前学的 CRUD、事务、拦截器、JWT 令牌、统一异常处理等等统统用上。 案例里面藏着很多细节,以及很多干货,老铁们,咋们会分4篇左右文章介绍这个案例,不急不躁,咱们慢慢来。
这一篇咱先搞定 项目搭建 + 博客列表 ,让文章先"晒"出来。
放心,代码一行一行敲,道理一句一句讲,咱开始吧。🚀
写在前面:项目技术栈以及源码地址
- 后端:Spring Boot + MyBatis-Plus + JWT + 拦截器 + 统一异常处理
- 数据库:MySQL
- 前端:HTML + CSS + jQuery + editor.md(Markdown编辑器)
- 前端页面由 AI 辅助生成(AI前端更美观,开发效率更高)
项目源码已经放在 Gitee 上 ,老铁们可以边看边对照,或者直接拿过去改:
一、项目长啥样?先看效果
咱们要做的博客系统一共 5 个页面:
| 页面 | 功能 |
|---|---|
blog_login.html |
用户登录 |
blog_list.html |
博客列表(所有人可见) |
blog_detail.html |
博客详情(支持 markdown 渲染) |
blog_edit.html |
发布新博客 / 编辑博客 |
blog_update.html |
修改博客(实际和编辑页共用) |
1、项目演示视频
博客系统演示
2、登录页面

3、未登录不允许访问其他页面

4、博客列表页(显示所有作者的博客)

5、博客详情页

6、博客发布页

二、准备工作:数据库 + 项目初始化
那咱们数据库设计的话,主要设计就是实体表和关系表,就是看这两种表。

你看我们登录页面它涉及到是一个用户表,那么用户表中它要有用户名和密码,那他还应该有哪些相应的字段呢?

我们点击提交完之后,进入我们的首页,在右侧它会有我们的头像和昵称以及Gitee地址。然后你可能还会说,右侧的列表中,我们还有文章和分类,对吧?那这两个你觉得应该放在用户表中吗?其实我们不应该放在用户表中,当你发布一篇文章,那么文章数以及分类,这两个数目会更新。而如果你放在用户表中,它的耦合度就会增高,也就是说这两个属性是由文章表中文章的个数和分类去影响的,所以我们应该进行一个隔离,进行解耦,不要把它放在用户表中。如果你把文章数和文章分类数放在用户表中,那么当你的文发布一篇文章,那么此时你就得去实时更新用户表,耦合度就增高了。
所以我们就看文章表,看文章表中有哪些字段:我们这个前端的页面可以知道,文章表应该有文章的标题,内容、日期,以及作者。

但是我们文章的个数和分类的个数存在哪儿呢?唉,我们不会存在文章表中,那怎么做呢?我们一般会单独的放一个表,不要跟用户表放在一起,要么就是你直接不存,然后到时候去查询数据库的时候去统计。
也就是说第一种实现方式:你就用单独存储在MySQL中,也可以存在Redis这些组件中。
第二种方式的话:你去实时的计算,每次你去统计文章表的时候,就去统计出它的数目,显示页面你就去查询一下,然后统计出来就行。
但是我们本案例中,我们不去具体的去实现它,那如果你实现,到时候也可以进行扩展。
那我们的文章表和用户表肯定是有关联关系的,也就是说我们的用户和文章表中作者肯定是有一个关系,而且他们是一对多的关系,我们把一的一方id放在多的一方,也就是说是隶属关系隶。一个作者可以发布多个文章,这些文章隶属于这个作者,然后我们把这个外键呢添在我们的多的一方的。
那基于上述的一个分析,我们的 sql数据库的编写基本可以完成了。
🐢提示:对于头像我们本案件,不去具体的实现,但是呢它有这个字段,用于后续的扩展,如果本案例实现的话,那就得是到时候上传一个头像,同时注意我们存储的时候是存储照片的url,而不是我们照片的实际内容。同时我们用户表和文章表中还存储一个delete flag,就是我们删除的话采用逻辑删除而不用物理删除。
2.1 建数据库和表
咱先搞一个数据库 java_blog_spring,建两张表:用户表 user_info 和博客表 blog_info。
sql
create database if not exists javaee_blog_spring charset utf8mb4;
use javaee_blog_spring;
-- 用户表
drop tables if exists user_info;
create table `user_info` (
`id` int not null auto_increment comment '用户主键id',
`user_name` varchar(128) not null comment '用户名',
`password` varchar(128) not null comment '密码(加密存储)',
`avatar_url` varchar(256) default null comment '头像url',
`github_url` varchar(128) default null comment 'github地址',
`delete_flag` tinyint(4) default 0 comment '逻辑删除标记:0-未删除,1-已删除',
`create_time` datetime default current_timestamp comment '创建时间',
`update_time` datetime default current_timestamp on update current_timestamp comment '更新时间',
primary key (`id`),
unique key `idx_user_name` (`user_name`)
) engine=innodb default charset=utf8mb4 comment='用户表';
-- 博客表
drop tables if exists blog_info;
create table `blog_info` (
`id` int not null auto_increment comment '博客主键id',
`title` varchar(200) not null comment '博客标题',
`content` text not null comment '博客正文(markdown格式)',
`user_id` int not null comment '作者id,关联user_info.id',
`delete_flag` tinyint(4) default 0 comment '逻辑删除标记:0-未删除,1-已删除',
`create_time` datetime default current_timestamp comment '创建时间',
`update_time` datetime default current_timestamp on update current_timestamp comment '更新时间',
primary key (`id`),
key `idx_user_id` (`user_id`)
) engine=innodb default charset=utf8mb4 comment='博客文章表';
插几条测试数据,方便后面展示:
sql
insert into user_info (user_name, password, github_url) values ('张三', '123456', 'https://gitee.com/xxx');
insert into user_info (user_name, password, github_url) values ('李四', '123456', 'https://gitee.com/xxx');
insert into `blog_info` (`title`, `content`, `user_id`) values
('第一篇博客', '这是用markdown写的正文内容,**支持加粗**,[链接](https://example.com)', 1),
('第二篇博客', '学习Spring Boot的总结笔记...', 2);
注意:这里密码先明文存,后面咱会用 MD5 + 盐 加密,别怕。
执行sql代码 创建出库和表




2.2 创建 Spring Boot 项目
用 IDEA 或者 Spring Initializr 创建一个项目,依赖选:

如果想要让lombok生效的话,那么需要删除下述插件:

然后手动在 pom.xml 里加入 MyBatis-Plus 依赖:
SpringBoot版本适配的mybatis-plus适配的版本

依赖:
xml
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot4-starter</artifactId>
<version>3.5.15</version>
</dependency>

为啥用 MyBatis-Plus?因为它自带很多好用的 CRUD 方法,少写一堆 XML,咱把精力花在业务上。
2.3 配置 application.yml
yaml
spring:
application:
name: spring-blog
datasource:
url: jdbc:mysql://localhost:3306/javaee_blog_spring?useSSL=false&characterEncoding=utf8
username: root # 你的数据库用户名
password: 123456 # 你的数据库密码
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis-plus:
configuration:
map-underscore-to-camel-case: true # 数据库下划线自动转驼峰
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 打印 SQL
logging:
file:
name: logger/spring-blog.log # 持久化日志logging:
pattern:
console: "%clr(%d{HH:mm:ss}){yellow} %clr(%-5level) --- %msg%n" # 自定义日志格式
2.4 把前端静态页面拷进来
本案例我们核心目标还是去实现后端,那对于前端的话,我们让ai来帮我们完成,也就是我们通过ai把前端给实现了,而且前端的样式通过ai来润色之后呢,会更加的好看。
那前端的相关代码呢,老铁们可参考我的Gitee仓库并将其拷贝进来即可。

然后启动项目,访问 http://localhost:8080/blog_login.html,看到页面就说明环境好了。

2.5 项目的架构

三、公共模块:统一返回 + 统一异常处理(老套路)

3.1 统一返回结果的格式 Result
java
@Data
public class Result {
//统一返回结果三大常见信息:状态码+错误信息+具体数据
private ResultCodeEnum code; //业务状态码(不是http的) 可以给他定义枚举类
private String errMsg;//错误类型
private Object data;//具体数据
/**
* 成功
* @param data 成功的时候的数据
* @return 返回统一封装之后的数据
*/
public static Result success(Object data) {
Result result = new Result();
result.setCode(ResultCodeEnum.SUCCESS);
result.setData(data);
return result;
}
/**
* 失败
* @param errMsg 失败的原因
* @return 返回统一封装之后的数据
*/
public static Result fail(String errMsg) {
Result result = new Result();
result.setCode(ResultCodeEnum.FAIL);
result.setErrMsg(errMsg);
return result;
}
}
//业务状态码封装为枚举
@AllArgsConstructor //生成构造方法
public enum ResultCodeEnum {
//成功
SUCCESS(200),
FAIL(-1);//失败
@Setter @Getter //给这个属性生成set和get方法
private int code;
}
3.2 统一结果的返回增强版(ResponseAdvice)
我们这个类放在这个包中,如图:

为了让所有 Controller 返回的数据自动包上 Result 结构,咱还写一个 @ControllerAdvice,和图书系统一样的:目的就是做一层补充兜底,对你没有手动包上Result的,我Spring帮你自动包上,此时还有一个最大的好处就是,你不需要在我们的Controller 层去自己包装了,我们在此处统一的包装,如下图:

此处我们在之前的统一返回格式中介绍过。
java
@ControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice {
//用于序列化
@Autowired
private ObjectMapper objectMapper;
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
return true;//是否支持统一结果:是
}
@Override
public Object beforeBodyWrite(Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response) {
//对统一结果进行封装
if (body instanceof String) {
objectMapper.writeValueAsString(Result.success(body));//将Result.success(body)进行转变为JSON字符串
} else if (body instanceof Result) {
//如果你已经直接是Result的话 那么就直接返回这个Result对象即可
return body;
}
//其他类型就使用统一返回格式类进行一个包装
return Result.success(body);
}
}
再次强调逻辑 如图:

3.3 自定义异常 + 统一异常处理
自定义异常 :我们将他写咋exception包下

java
@Data
public class BlogException extends RuntimeException{//继承运行时异常就行了
//一个异常我们可能需要 状态码+异常信息
private int code;
private String errMsg;
//构造方法
public BlogException(String message, int code, String errMsg) {
super(message);
this.code = code;
this.errMsg = errMsg;
}
}
自定义完异常之后,我们进行统一的异常处理:也是使用@ControllerAdvice注解,我们将他写在如下包下:

java
@Slf4j//使用日志 方便观察
@ResponseBody//返回的是数据 不是页面
@ControllerAdvice//告诉Spring让他为我们做统一异常处理
public class ExceptionAdvice {
/**
* 处理异常的时候
* @param exception 异常对象
* @return 处理完异常之后 我们返回给前端的也是需要使用统一返回类型Result的
*/
@ExceptionHandler//异常处理类型 默认捕获的就是参数中Exception这个异常
public Result exceptionHandler(Exception exception) {
log.error("发生异常:,e: ", exception);//先打印一个日志看一下
//将处理之后的异常信息返回前端 告诉前端出错了{至于前端怎么展示给用户 就是另一码事情了}
return Result.fail(exception.getMessage());
}
//统一处理我们的自定义异常
@ExceptionHandler
public Result exceptionHandler(BlogException exception) {
log.error("发生异常:,e: ", exception);//先打印一个日志看一下
//将处理之后的异常信息返回前端 告诉前端出错了{至于前端怎么展示给用户 就是另一码事情了}
return Result.fail(exception.getMessage());
}
}

这样,不管业务成功还是失败,前端拿到的永远是 {code, errMsg, data} 格式,如此甚好也。
当然,如果老铁们看到这里,对统一返回格式、统一返回结果、统一异常处理这几个概念还有点模糊,别慌,咱之前专门写过一篇博客讲这个。点下面的链接去回顾一下,看完就门清了👇
统一结果返回+统一异常处理
四、持久层:用 MyBatis-Plus 少写代码
4.1 实体类

代码:
java
@Data//自动生成get和set方法
public class BlogInfo {
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
private String title;
private String content;
private Integer userId;
private Integer deleteFlag;
// 时间格式化:从数据库读取后,对数据库中的时间进行格式化
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createTime;
// 时间格式化:从数据库读取后,对数据库中的时间进行格式化
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime updateTime;
}
@Data//自动生成get和set方法
public class UserInfo {
@TableId(value = "id", type = IdType.AUTO)
private Integer id;//用户编号
private String userName;//用户名
private String password;//密码
private String githubUrl;//码云地址
private Byte deleteFlag;//删除标记
// 时间格式化:从数据库读取后,对数据库中的时间进行格式化
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createTime;//创建时间
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime updateTime;//修改时间
}
4.2 Mapper 接口

java
@Mapper
public interface BlogInfoMapper extends BaseMapper<BlogInfo> {
}
@Mapper
public interface UserInfoMapper extends BaseMapper<UserInfo> {
}
就这两行,MyBatis-Plus 自动提供了 insert、selectList、selectById、updateById 等方法,咱连 SQL 都不用写。
五、实现博客列表(让文章晒出来)
5.1 约定接口
前端给后端发一个 GET /blog/getList,后端返回所有未删除的博客列表,格式如下:
json
{
"code": 200,
"errMsg": null,
"data": [
{
"id": 1,
"title": "第一篇博客",
"content": "这是用markdown写的正文内容,**支持加粗**,[链接](https://example.com)",
"userId": 1,
"createTime": "2024-08-22 11:27:03"
}
]
}
5.2 Service 层
代码初步版本:
java
//接口
public interface BlogService {
List<BlogInfo> getList();
}
//实现类
@Service
public class BlogServiceImpl implements BlogService {
//调用持久层
@Autowired
private BlogInfoMapper blogInfoMapper;
@Override
public List<BlogInfo> getList() {
/**
* 查询的时候 由于数据库中有一个字段:就是我们的删除标记delete_flag
* 那么0表示没有删除 如果是1就表示删除
* 所以我们是有条件的我们只能查询出非1的数据 所以此时就需要条件构造器了
*/
//创建条件构造器
QueryWrapper<BlogInfo> queryWrapper = new QueryWrapper<>();
//我们使用拉姆达的方式
queryWrapper.lambda().eq(BlogInfo::getDeleteFlag, 0);
List<BlogInfo> blogInfos = blogInfoMapper.selectList(queryWrapper);
return blogInfos;
}
}
服务层接口和服务层实现如下所示:

为什么需要这样设计?为了解耦,让控制层调用服务层接口,面向接口编程。
服务层中
@Service注解不加载接口上 要加载实现类上


5.3 Controller 层
初步代码:
java
@Slf4j//方便看日志
@RequestMapping("/blog")
@RestController
public class BlogController {
@Resource(name = "blogServiceImpl")//指定你要注入哪一个
private BlogService blogService;
/**
* 获取博客列表
* @return 博客列表-集合
*/
@RequestMapping("/getList")
public List<BlogInfo> getList() {
log.info("获取博客列表>>>");
return blogService.getList();
}
}
控制层写在如下包下:

解释补充:

因为咱有
ResponseAdvice,返回的List会被自动包装成Result,前端拿到的就是带code的标准结构。
注入服务层解释:

5.4 测试并思考

于是我们就需要vo将前端需要的返回,所以请继续往下阅读
5.5 后端:定义返回数据的 VO
vo我们弄在下图包中:


VO:是视图用到的对象
咱不用直接把 BlogInfo 整个吐出去,可以定义一个 BlogInfoResponse,只挑需要的字段,顺便格式化日期。
java
@Data
public class BlogInfoResponse {
private Integer id;//文章id
private String title;//文章标题
private String content;//文章内容
private Integer userId;//作者
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;//创建时间(发布时间)
}
5.6 最终版本控制层和服务层代码

🤔服务层持久层给我们的对象如何变为VO?

代码:
控制层
java
@Slf4j//方便看日志
@RequestMapping("/blog")
@RestController
public class BlogController {
@Resource(name = "blogServiceImpl")//指定你要注入哪一个
private BlogService blogService;
/**
* 获取博客列表
* @return 博客列表-集合
*/
@RequestMapping("/getList")
public List<BlogInfoResponse> getList() {
log.info("获取博客列表>>>");
return blogService.getList();
}
}
服务层
java
//接口
public interface BlogService {
List<BlogInfoResponse> getList();
}
//实现类
@Service
public class BlogServiceImpl implements BlogService {
//调用持久层
@Autowired
private BlogInfoMapper blogInfoMapper;
@Override
public List<BlogInfoResponse> getList() {
/**
* 查询的时候 由于数据库中有一个字段:就是我们的删除标记delete_flag
* 那么0表示没有删除 如果是1就表示删除
* 所以我们是有条件的我们只能查询出非1的数据 所以此时就需要条件构造器了
*/
//创建条件构造器
QueryWrapper<BlogInfo> queryWrapper = new QueryWrapper<>();
//我们使用拉姆达的方式
queryWrapper.lambda().eq(BlogInfo::getDeleteFlag, 0);
List<BlogInfo> blogInfos = blogInfoMapper.selectList(queryWrapper);
List<BlogInfoResponse> blogInfoResponses = new ArrayList<>();
//直接遍历 blogInfos 将需要的属性搞到我们的 VO中 即可
for (BlogInfo blogInfo : blogInfos) {
BlogInfoResponse blogInfoResponse = new BlogInfoResponse();
blogInfoResponse.setId(blogInfo.getId());
blogInfoResponse.setTitle(blogInfo.getTitle());
blogInfoResponse.setContent(blogInfo.getContent());
blogInfoResponse.setCreateTime(blogInfo.getCreateTime());
blogInfoResponse.setUserId(blogInfo.getUserId());
//加入到集合中
blogInfoResponses.add(blogInfoResponse);
}
//最后返回VO组成的集合
return blogInfoResponses;
}
}
此时再次测试前端:符合我们的需求

只不过还有一个细节就是:我们前端展示的是年月日 没有时分秒

那么 此时 我们有几个处理方法
1、改变类型
2、改变日期格式化(我使用的是这种)

3、直接使用一个工具类来转换

代码:
java
//工具类
public class DateUtils {
/**
* 日期格式化工具
* @param date 时间
* @return 返回格式化之后的日期
*/
public static String dateFormat(Date date) {
//日期格式处理类
SimpleDateFormat dateFormat = new SimpleDateFormat("yyy-MM-dd");
return dateFormat.format(date);
}
}
//使用
@Data
public class BlogInfoResponse {
private Integer id;//文章id
private String title;//文章标题
private String content;//文章内容
private Integer userId;//作者
// @JsonFormat(pattern = "yyyy-MM-dd") 不使用这种方式
private Date createTime;//创建时间(发布时间) 注意要使用Date类型才可以
//重写get方法 因为返回的时候其实执行的是get方法
public String getCreateTime() {
return DateUtils.dateFormat(createTime);
}
}
此时运行结果:

5.7 前端:动态渲染博客列表
打开 blog_list.html,咱用 AJAX 动态生成。
javascript
$(function () {
$.ajax({
type: "get",
url: "/blog/getList",
success: function (result) {
if (result.code == 200 && result.data != null && result.data.length > 0) {
var finalHtml = "";
for (var blog of result.data) {
finalHtml += '<div class="blog">';
finalHtml += '<div class="title">' + blog.title + '</div>';
finalHtml += '<div class="date">' + blog.updateTime + '</div>';
finalHtml += '<div class="desc">' + blog.content + '</div>';
finalHtml += '<a class="detail" href="blog_detail.html?blogId=' + blog.id + '">查看全文>></a>';
finalHtml += '</div>';
}
$(".right").html(finalHtml);
}
}
});
});


处理博客内容:

我们一篇博客的内容是很多的,你不可能全部去展示出来,而是要去截取它。那怎么截断呢?
首先我们这个content文本内容是从数据库中读取出来的,从数据库中读取出来的话,我们用的是set的方法,把数据set到我们的Java内存对象中,然后我们把Java内存对象中的数据拿到前端使用的是get方法,所以我们这里只需要重写一个get方法就行了:如下

当然我们还可以通过前端去获取,就是后端直接返回全部内容,然后前端自己去写逻辑截取,不过这个啊我们使用的是上面这种方式,这个方式老铁们可自行选择。
Ok,然后此时的话我们就来运行一下结果分析会报错:

后端错误日志

所以我们需要注意,截取的时候,你不是所有的文本你都截取,因为有些文章内容字数小于50,那此时的话你去截取就会报错,所以呢我们就取一个最小值。你字数小于50我就只全部返回,如果你字数大于50我就返回50个字数。

现在访问 http://localhost:8080/blog_list.html,就能看到从数据库动态加载的博客列表了。

5.8 控制层和服务层的调用关系


六、小结 + 下期预告
今天咱干了这几件事:
- 建库建表,初始化数据。
- 创建 Spring Boot 项目,集成 MyBatis-Plus。
- 搭建公共模块(
Result、统一返回、统一异常)。 - 编写实体、Mapper,用 MyBatis-Plus 少写代码。
- 实现博客列表接口,前端动态渲染。
现在博客能"晒"出来了,但还有好多功能没做:用户登录、JWT 令牌、博客详情、发布、编辑、删除、拦截器、加密......
整理这篇我花了不少心思,排版、代码、截图、解释......生怕哪个地方没说清楚。
老铁如果你觉得有用,点赞、收藏、关注 走一波,这是对我最大的鼓励,咱下期见!🚀
下期干货预警 !咱们重点搞定 登录 + JWT 令牌 ,再顺手加上强制登录拦截器,给系统配个严实的"门卫"。内容满到要溢出来,老铁们,戳这里 👉 进入下一篇:登录+JWT令牌~