☰
Spring Boot个人博客系统源码实战:数据库设计与核心功能实现
2026/11/5 9:26:47 网站建设 项目流程

简介:在Java后端开发中,Spring Boot凭借约定优于配置的自动化机制,已成为构建Web应用的主流选择。一个典型的个人博客系统,涵盖了从数据库表结构设计、ORM映射到业务功能实现的完整流程,是理解分层架构与持久层技术的理想练手项目。本文以MySQL为存储基础,深入解析6张核心表的关系设计,包括文章与标签的多对多中间表、合理的索引规划以及utf8mb4字符集的选择。同时结合Spring Data JPA,演示如何通过实体映射与Repository接口快速实现文章发布、评论管理、用户认证等关键功能,并针对懒加载异常、文件上传安全、Markdown渲染等高频工程问题给出解决方案。无论是毕业设计还是技术巩固,这套基于Spring Boot的博客系统方案都能帮助开发者快速构建可部署的完整项目。 说实话,个人博客这个项目,是每一个Java后端开发者绕不开的练手作品。之前带过不少实习生,也帮人看过毕设,发现大家的第一选择几乎都是“基于Spring Boot的个人博客系统”。这个选题确实有它的道理:技术栈经典、业务功能清晰、可扩展性强,而且完成后可以直接部署上线当作自己的技术笔记站点,面试时还能作为代表性项目拿出来讲,性价比非常之高。

这篇博文我会以源码+数据库为主线,把整个系统的核心设计思路、数据库表结构、关键功能实现、踩坑实录完整地过一遍。无论你是正在准备毕业设计,还是想通过完整项目巩固Spring Boot知识,都可以直接照着我这套方案去搭,数据库文件也备好了,拿来改改就能用。

1. 项目整体设计与技术选型分析

1.1 为什么是Spring Boot,而不是SSH或SSM

先说一个很多初学者会纠结的问题:框架到底怎么选。

如果你去翻十年前的博客系统源码,基本都是SSH(Struts2 + Spring + Hibernate)或者SSM(Spring + SpringMVC + MyBatis)的天下。但现在再做新项目,SSH基本已经退出主流视野了,SSM也已经被Spring Boot全面取代。

Spring Boot最大的价值在于“约定优于配置”。传统SSM需要手写大量的XML配置,数据源配置、事务管理、组件扫描、视图解析器,每一样都要手动装配。而Spring Boot通过自动配置机制,把大多数基础设施都帮你准备好了。你只需要引入spring-boot-starter-web依赖,写一个@SpringBootApplication启动类,就能跑起一个Web应用。

这对个人博客这种中小型项目来说非常友好,因为精力可以集中在业务功能上,而不是耗在繁琐的配置里。另外Spring Boot的生态非常完善,后续想加Redis缓存、Elasticsearch搜索、Spring Security权限控制,都是直接加依赖加注解的事,扩展成本很低。

1.2 技术栈清单与各模块职责划分

整个系统的技术栈如下:

技术组件选型方案职责说明
后端框架Spring Boot 2.7.x提供Web能力、依赖注入、事务管理
ORM框架Spring Data JPA + Hibernate操作数据库,自动建表
数据库MySQL 5.7+ / 8.0持久化存储所有业务数据
模板引擎Thymeleaf服务端渲染,SEO友好
安全组件Spring Security登录认证、权限控制
富文本处理Markdown + CommonMark文章编辑与渲染
前端组件Bootstrap + jQuery页面布局与交互
构建工具Maven依赖管理与项目打包

这里有个设计取舍需要说明一下:ORM我选的是Spring Data JPA而不是MyBatis。从网上的热词来看,MyBatis在国内的使用率确实更高,很多人的毕设也都是“Spring Boot + MyBatis”的组合。

但如果是个人博客这种以单表操作为主的业务场景,JPA的开发效率明显更高。比如根据分类ID查询文章列表,JPA只需要在Repository接口中声明findByCategoryId(Long categoryId)方法,方法名即查询语句,完全不需要写XML映射。而MyBatis在这种简单CRUD场景下反而多了一层Mapper映射文件的工作量。

当然,这个决定也带来了一个需要注意的点——JPA的关联查询(多表Join)确实不如MyBatis灵活。后面在数据库设计部分我会讲如何通过合理的表结构设计来规避这个问题。

1.3 功能模块划分:从用户视角出发

在动手写代码之前,我习惯先站在使用者的角度把功能场景完整地过一遍。这个博客系统主要面向两类用户:管理员(博主)和普通访客。

访客在前台可以做的事情:浏览文章列表、查看文章详情、按分类筛选文章、按标签检索文章、发表评论、搜索文章。

管理员在后台可以做:登录、发布新文章、编辑已有文章、删除文章、管理分类、管理标签、审核删除评论。

基于这个需求分析,我把系统拆成以下几个核心功能模块:

  • 文章模块(文章发布、编辑、删除、分页查询)
  • 分类模块(分类CRUD、按分类查询文章)
  • 标签模块(标签CRUD、文章与标签的多对多关系维护)
  • 评论模块(评论发表、评论区展示、后台删除)
  • 用户模块(登录认证、会话管理、密码加密)
  • 文件模块(图片上传、访问映射)
  • 统计模块(文章总数、分类数量、标签数量等)

每个模块遵循Controller -> Service -> Repository三层架构,层次分明,代码量适中,能很好地体现分层设计思想。

2. 数据库设计与核心表结构解析

2.1 概念模型与ER关系梳理

数据库设计是整个项目的核心基础。很多人的博客系统之所以做得乱,追溯下来都是表结构设计不到位,后面写代码的时候各种拼SQL,越写越痛苦。

我设计的这套数据库共包含6张核心表:用户表、文章表、分类表、标签表、文章标签关联表、评论表。

实体之间的关系如下:

  • 一个用户可以发布多篇文章(一对多)
  • 一篇文章必须归属一个分类(多对一)
  • 一个分类下可以包含多篇文章(一对多)
  • 一篇文章可以拥有多个标签,一个标签也可以对应多篇文章(多对多)
  • 一篇文章下可以有多条评论(一对多),一条评论归属于一个用户

多对多关系必须通过中间表(关联表)来维护,这是关系型数据库设计的基本准则。比如文章和标签,不能直接在文章表里加一个tag_ids字段用逗号分隔存储,否则后续做按标签查询、标签统计的时候会非常痛苦,FIND_IN_SET这些函数写起来也别扭,还完全没法走索引。

2.2 核心建表SQL详解

这里给出核心表的建表SQL,为了控制篇幅,索引和注释也一并写上。注意字符集统一使用utf8mb4,而不是utf8。原因很简单:utf8在MySQL里最多只能存3个字节的字符,像Emoji表情(比如😂)就会报错。utf8mb4是真正的四字节编码,兼容所有Unicode字符,现在写新项目基本都用它。

-- 用户表 CREATE TABLE `t_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID', `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT '密码,BCrypt加密存储', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `email` varchar(100) DEFAULT NULL COMMENT '邮箱', `avatar` varchar(255) DEFAULT NULL COMMENT '头像地址', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -- 分类表 CREATE TABLE `t_category` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '分类ID', `name` varchar(50) NOT NULL COMMENT '分类名称', `description` varchar(200) DEFAULT NULL COMMENT '分类描述', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_name` (`name`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='分类表'; -- 标签表 CREATE TABLE `t_tag` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '标签ID', `name` varchar(50) NOT NULL COMMENT '标签名称', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_name` (`name`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='标签表'; -- 文章表 CREATE TABLE `t_article` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '文章ID', `user_id` bigint(20) NOT NULL COMMENT '作者ID', `category_id` bigint(20) NOT NULL COMMENT '分类ID', `title` varchar(200) NOT NULL COMMENT '文章标题', `summary` varchar(500) DEFAULT NULL COMMENT '文章摘要', `content` longtext NOT NULL COMMENT '文章内容,Markdown格式', `cover_image` varchar(255) DEFAULT NULL COMMENT '封面图地址', `view_count` int(11) NOT NULL DEFAULT 0 COMMENT '浏览数', `comment_count` int(11) NOT NULL DEFAULT 0 COMMENT '评论数', `status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '状态:0草稿 1已发布', `published_at` datetime DEFAULT NULL COMMENT '发布时间', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_category_id` (`category_id`), KEY `idx_user_id` (`user_id`), KEY `idx_published_at` (`published_at`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='文章表'; -- 文章标签关联表 CREATE TABLE `t_article_tag` ( `article_id` bigint(20) NOT NULL COMMENT '文章ID', `tag_id` bigint(20) NOT NULL COMMENT '标签ID', PRIMARY KEY (`article_id`, `tag_id`), KEY `idx_tag_id` (`tag_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章标签关联表'; -- 评论表 CREATE TABLE `t_comment` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '评论ID', `article_id` bigint(20) NOT NULL COMMENT '文章ID', `user_id` bigint(20) NOT NULL COMMENT '评论用户ID', `content` varchar(1000) NOT NULL COMMENT '评论内容', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '评论时间', PRIMARY KEY (`id`), KEY `idx_article_id` (`article_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='评论表';

几个字段设计上的细节再强调一下:

第一,content字段一定要用longtext类型。一篇技术文章带代码块、带图片链接的话,Markdown原文轻松超过64KB(text类型上限)。我之前见过有人用varchar(255)存文章内容,那是典型的反面教材。

第二,status字段用tinyint而不是varchar,发布状态和草稿状态用数字0/1区分。数据库层面用数字做状态标志位,查询效率更高,也方便扩展更多状态(比如2表示回收站、3表示置顶)。

第三,文章表设置了idx_category_id、idx_user_id、idx_published_at三个普通索引。索引不是越多越好,但查询频率高的字段一定要覆盖到。特别是按分类查询文章列表、按时间排序文章列表这两个操作,没有索引的话,数据量过万就会明显变慢。

2.3 为什么文章标签关系需要中间表

很多人第一次设计数据库的时候不理解t_article_tag中间表的必要性。我打个比方你就明白了:文章和标签的关系就像“学生和课程”,一个学生可以选多门课,一门课也可以有多个学生。如果你把课程ID直接存在学生表里,假设一个学生选了5门课,你是存不下5个ID的,除非用逗号拼接成一个字符串。

但是用逗号拼接会带来三个严重问题:

一是查询麻烦。想查“所有选了数据库这门课的学生”,得用LIKE '%数据库%'这种模糊匹配,不仅慢,还容易误匹配(比如“数据库原理”和“数据库应用”是两个不同的课程)。

二是更新麻烦。学生退掉一门课,要先把字符串拆开,移除对应ID,再拼回去,纯纯的体力活,还容易出错。

三是没法统计。想统计每门课的选课人数,写成SQL基本不可能。

引入中间表之后,所有问题迎刃而解。选课记录成了中间表的行数据,查询、修改、统计都变成了标准的SQL操作。

-- 查询某一篇文章的所有标签 SELECT t.* FROM t_tag t INNER JOIN t_article_tag at ON t.id = at.tag_id WHERE at.article_id = 1; -- 查询某个标签下的所有文章 SELECT a.* FROM t_article a INNER JOIN t_article_tag at ON a.id = at.article_id WHERE at.tag_id = 2 AND a.status = 1 ORDER BY a.published_at DESC;

这两种查询都是走主键索引或外键索引的等值匹配,数据量再大也不怕。

3. Spring Boot环境搭建与项目基础配置

3.1 开发环境与版本选择建议

先列出我使用的开发环境,方便你对照:

软件版本说明
JDK1.8 / 8u202适配Spring Boot 2.x,兼容性最好
Maven3.6.3+依赖管理
IDEAIntelliJ IDEA 2022.3+开发IDE
MySQL5.7.32 / 8.0.28数据库
Navicat / DataGrip任意版本数据库可视化工具

这里有一个特别需要说清楚的事情:Spring Boot 2.x和3.x之间的版本差异非常大。Spring Boot 3.x最低要求JDK 17,而且底层把Java EE标准替换为了Jakarta EE规范,导致大量的依赖坐标和包路径都变了。比如javax.servlet变成了jakarta.servlet,javax.persistence变成了jakarta.persistence。

如果你是初学者,或者毕设需要,我强烈建议先用Spring Boot 2.7.x + JDK 8。原因很直接:市面上90%以上的参考资料、教程、博客文章都是基于2.x写的,遇到问题一搜就能找到答案。JDK 8又是Java生态中应用最广泛的版本,各种兼容性问题最少。等把这个项目吃透了,再去迁移Spring Boot 3.x也不迟。

3.2 项目初始化与核心依赖配置

我一般不用Spring Initializr网页版,直接在IDEA的New Project里选Spring Initializr,或者用Maven手动创建项目结构。下面是我常用的pom.xml核心依赖配置:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <properties> <java.version>1.8</java.version> </properties> <dependencies> <!-- Web 启动器,包含SpringMVC和内嵌Tomcat --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Thymeleaf 模板引擎 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- JPA 数据持久层 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!-- Spring Security 安全框架(可选,项目带了登录功能) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!-- Lombok 简化实体类 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- Markdown 渲染库 --> <dependency> <groupId>org.commonmark</groupId> <artifactId>commonmark</artifactId> <version>0.21.0</version> </dependency> </dependencies>

这里说几个容易踩的坑:

第一个坑:mysql-connector-java的版本问题。Spring Boot 2.7.x默认管理的MySQL驱动版本是8.0.33,这个版本连接MySQL 5.7也是没有问题的,因为它向下兼容。但如果你用的是MySQL 5.5或者更老版本的数据库,可能就会报Communications link failure,这时候需要手动指定驱动版本为5.1.49,并且在url连接串里加上useSSL=false。

第二个坑:JPA的ddl-auto属性。在开发阶段可以设置为update,让Hibernate根据实体类自动更新表结构。但是到了生产环境,一定要改成validate或者none。否则你哪天不小心改了实体类字段名,自动同步建表的时候可能会把整张表删了重建,数据全部清空,哭都来不及。

3.3 application.yml配置文件深度解析

application.yml是整个项目的命脉,我就直接给出一个相对完整可用的配置,然后逐项解释关键参数的含义。

server: port: 8080 servlet: context-path: / spring: datasource: url: jdbc:mysql://localhost:3306/blog_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: format_sql: true dialect: org.hibernate.dialect.MySQL5InnoDBDialect thymeleaf: cache: false encoding: UTF-8 servlet: multipart: max-file-size: 10MB max-request-size: 20MB mvc: static-path-pattern: /static/** # 自定义配置 blog: upload-path: /Users/xxx/blog_images/ page-size: 8 logging: level: org.hibernate.SQL: debug

逐个说明几个关键参数:

serverTimezone=Asia/Shanghai这个参数是必须加的。如果你用MySQL 8.0,数据库连接串里不加这个,会报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized,因为驱动的时区识别不了中文操作系统默认的时区配置。

allowPublicKeyRetrieval=true这个参数是MySQL 8.0配合新版驱动连数据库时要加的。MySQL 8.0默认使用caching_sha2_password插件做用户认证,如果不允许公钥检索,连接时会报Public Key Retrieval is not allowed。

ddl-auto: update前面提到了,开发阶段用。加一个show-sql: true可以让你在控制台看到Hibernate生成的实际SQL语句,对排查查询问题非常有帮助。

max-file-size: 10MB这里要注意,Spring Boot默认的上传大小限制是1MB,如果你不显式配置,后台传个稍微大一点的图片就会直接报FileSizeLimitExceededException。

4. 核心功能实现与关键代码解析

4.1 实体类设计与关系映射

有了建表SQL之后,接下来就是写实体类。JPA的实体映射有一个好处:配置了ddl-auto: update之后,实体类本身就可以当作建表工具来用,只要字段注解写对了,Hibernate会自动帮你把表建好。

下面给出几个核心实体类的代码:

@Data @Entity @Table(name = "t_article") public class Article { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, length = 200) private String title; @Column(columnDefinition = "text") private String summary; @Lob @Column(columnDefinition = "longtext not null") private String content; @Column(name = "cover_image") private String coverImage; @Column(name = "view_count") private Integer viewCount = 0; @Column(name = "comment_count") private Integer commentCount = 0; @Column(nullable = false) private Integer status = 1; @Column(name = "published_at") private Date publishedAt; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") private User user; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "category_id") private Category category; @ManyToMany(fetch = FetchType.LAZY) @JoinTable(name = "t_article_tag", joinColumns = @JoinColumn(name = "article_id"), inverseJoinColumns = @JoinColumn(name = "tag_id")) private Set<Tag> tags = new HashSet<>(); @OneToMany(mappedBy = "article", cascade = CascadeType.REMOVE, orphanRemoval = true) private List<Comment> comments = new ArrayList<>(); @Column(name = "create_time", updatable = false) @CreationTimestamp private Date createTime; @Column(name = "update_time") @UpdateTimestamp private Date updateTime; }

几个注解的作用我解释一下:

@ManyToOne(fetch = FetchType.LAZY):文章和分类/用户的关系是多对一,默认的fetch策略是EAGER(立即加载)。但在列表页查询文章的时候,如果不希望每次查询都连带把用户、分类、标签全部捞出来,最好显式指定为LAZY(延迟加载),等到真正访问对应属性时才发SQL查询。这对性能的影响是实打实的,特别是文章列表页要显示几十篇文章的时候,EAGER策略会引发大量多余的关联查询。

@ManyToMany+@JoinTable:这是JPA中映射多对多关系的标准写法。joinColumns指向当前实体(文章)在中间表的外键列,inverseJoinColumns指向关联实体(标签)的外键列。

cascade = CascadeType.REMOVE:删除文章时级联删除其评论。这是有意义的业务行为——文章都没了,评论自然也应该被清理掉。但要注意,级联删除要慎用,如果A和B是多对多关系,在A里配了REMOVE级联,删除A时B也会被误删,这是严重事故。

@CreationTimestamp和@UpdateTimestamp:这是Hibernate提供的审计注解,插入时自动填充创建时间,更新时自动更新时间,省去手动set的麻烦。

4.2 文章发布与管理功能实现

文章模块是核心中的核心。先看Repository层的代码:

public interface ArticleRepository extends JpaRepository<Article, Long>, JpaSpecificationExecutor<Article> { @Query("select a from Article a where a.status = 1 and a.category.id = :categoryId") Page<Article> findByCategoryAndPublished(@Param("categoryId") Long categoryId, Pageable pageable); @Query("select a from Article a where a.status = 1 and a.title like concat('%', :keyword, '%')") Page<Article> searchByKeyword(@Param("keyword") String keyword, Pageable pageable); @Query("select a from Article a where a.status = 1 order by a.publishedAt desc") Page<Article> findPublished(Pageable pageable); }

这里直接用@Query注解写JPQL查询语句,比方法名派生查询更直观,也支持更复杂的逻辑。搜索功能用了like concat('%', :keyword, '%'),而不是直接拼接%关键字,这样可以避免SQL注入风险。

再来看文章发布的Service逻辑:

@Service @Slf4j public class ArticleService { @Autowired private ArticleRepository articleRepository; @Autowired private TagRepository tagRepository; @Transactional public Article createArticle(Article article, Long categoryId, List<Long> tagIds) { // 1. 设置基础属性 article.setStatus(1); article.setViewCount(0); article.setCommentCount(0); article.setPublishedAt(new Date()); // 2. 关联分类 Category category = categoryRepository.findById(categoryId) .orElseThrow(() -> new IllegalArgumentException("分类不存在")); article.setCategory(category); // 3. 关联标签:根据tagIds找到对应标签实体 if (tagIds != null && !tagIds.isEmpty()) { List<Tag> tags = tagRepository.findAllById(tagIds); article.setTags(new HashSet<>(tags)); } // 4. 保存 Article saved = articleRepository.save(article); log.info("文章发布成功,ID: {}", saved.getId()); return saved; } }

@Transactional注解在这里至关重要。保存文章时需要同时维护文章表、文章标签关联表,这是一个组合操作,任何一个环节出错都必须回滚,不能出现文章已经插入但关联标签失败的中间状态。

编辑文章的时候有个细节要注意:因为文章和标签是多对多关系,编辑文章标签时,JPA会自动根据Set<Tag>的内容比对中间表的差异,新增缺失的关联、删除多余的关联。但如果你想更新标签集合,最稳妥的做法是先清空再重新赋值:

article.getTags().clear(); if (tagIds != null && !tagIds.isEmpty()) { List<Tag> tags = tagRepository.findAllById(tagIds); article.getTags().addAll(tags); }

4.3 评论模块与用户认证设计

评论功能涉及到用户交互,核心逻辑比较简单,就是往评论表插入一条记录,同时把文章表的comment_count累加1。

但是评论的认证方案有一个需要想清楚的点:博客这种场景,是采用登录后才能评论,还是允许游客匿名评论?

我采用的是“登录后才能评论”的方案。原因有两点:一是防止垃圾评论刷屏,二是有用户身份数据,后面做评论管理和删除比较方便。登录之后,评论用户的ID从前端页面隐藏域传递到后端,后端从Session里取出当前登录用户,验证两者一致才算合法请求。

密码加密这块,我使用的是Spring Security自带的BCryptPasswordEncoder。BCrypt算法是目前密码存储的推荐方案,它自带盐值且哈希计算耗时可控,即使数据库泄露,直接用彩虹表也破解不了。

@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }

注册用户时的密码处理:

public User register(String username, String password) { // 检查用户名是否已存在 if (userRepository.findByUsername(username) != null) { throw new RuntimeException("用户名已被占用"); } User user = new User(); user.setUsername(username); // 对明文密码进行BCrypt加密,绝不可以用明文存数据库 user.setPassword(passwordEncoder.encode(password)); return userRepository.save(user); }

登录验证逻辑:

public User login(String username, String password) { User user = userRepository.findByUsername(username); if (user != null && passwordEncoder.matches(password, user.getPassword())) { // 登录成功,把用户信息存入Session return user; } return null; }

记住一个原则:数据库中永远不要存明文密码。一旦数据库泄露,用户在其他平台复用相同密码的话,损失就无法估计了。BCrypt加密之后,即使拿到数据库文件也无法直接反推出明文。

4.4 Markdown渲染与前台文章展示

后台文章内容用Markdown格式存储,前台展示的时候需要把Markdown转换成HTML。这里我选用的是CommonMark库,轻量级、性能好、API简单。

@Service public class MarkdownService { private final Parser parser = Parser.builder().build(); private final HtmlRenderer renderer = HtmlRenderer.builder().build(); public String renderToHtml(String markdownContent) { if (markdownContent == null) { return ""; } Node document = parser.parse(markdownContent); return renderer.render(document); } }

在Controller里,查询文章详情时,将Markdown原文渲染为HTML后放入Model传递到Thymeleaf模板:

@GetMapping("/article/{id}") public String articleDetail(@PathVariable Long id, Model model) { Article article = articleRepository.findById(id).orElse(null); if (article == null) { return "error/404"; } // 浏览量+1 article.setViewCount(article.getViewCount() + 1); articleRepository.save(article); // 渲染Markdown为HTML String htmlContent = markdownService.renderToHtml(article.getContent()); model.addAttribute("article", article); model.addAttribute("htmlContent", htmlContent); return "article/detail"; }

这里我遇到过一个经典问题:Markdown渲染出来的HTML字符串放在Thymeleaf的th:text属性里会直接被转义成普通文本,显示出来的页面是<h1>标题</h1>这种乱七八糟的标签源码。

正确做法是使用th:utext属性,表示“unescaped text”,让浏览器直接解析HTML标签:

<div class="article-content" th:utext="${htmlContent}"> </div>

这里要提醒一下安全风险:th:utext直接输出HTML会带来XSS(跨站脚本攻击)的隐患。如果文章内容里包含恶意<script>标签,访客浏览器就会执行这段脚本。解决方案有几种,简单粗暴的就是文章内容只有管理员自己才能发布,自己相信自己的输入即可;严谨一点的做法是对渲染后的HTML做白名单过滤,比如引入jsoup库,只保留安全标签,清除script、iframe等危险标签。

4.5 图片上传与静态资源映射

写博客几乎离不开图片。我采用本地存储方案:上传的图片保存到服务器指定目录,然后通过Spring Boot的静态资源映射对外开放访问。

开一个配置类:

@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Value("${blog.upload-path}") private String uploadPath; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 把 /upload/** 这个URL路径映射到本地磁盘目录 registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath); } }

Controller里处理文件上传:

@PostMapping("/admin/upload") @ResponseBody public Map<String, Object> uploadImage(@RequestParam("file") MultipartFile file) { Map<String, Object> result = new HashMap<>(); try { // 生成唯一文件名:时间戳 + 随机数 + 原文件后缀 String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String filename = System.currentTimeMillis() + "_" + UUID.randomUUID().toString().substring(0, 8) + ext; // 保存文件 File dest = new File(uploadPath + filename); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } file.transferTo(dest); result.put("success", true); result.put("url", "/upload/" + filename); return result; } catch (IOException e) { result.put("success", false); result.put("message", "上传失败:" + e.getMessage()); return result; } }

这里有一个被坑过很多次的点:文件名绝对不能直接用用户上传的原始文件名。一是中文文件名可能导致编码问题,二是潜在的安全风险——攻击者可以上传一个名为shell.jsp的文件,如果服务器没有正确限制执行权限,可能直接被当作动态脚本执行,这是非常严重的Web安全漏洞。所以必须用服务端生成的新文件名,并严格校验文件后缀白名单。

// 校验文件类型,只允许图片格式 String ext = filename.substring(filename.lastIndexOf(".") + 1).toLowerCase(); List<String> allowedExt = Arrays.asList("jpg", "jpeg", "png", "gif", "webp", "bmp"); if (!allowedExt.contains(ext)) { throw new RuntimeException("不支持的文件格式:" + ext); }

校验文件类型时,除了看后缀,更严谨的做法是读取文件的MIME类型(file.getContentType())和Magic Number进行双重验证。不过对于个人博客这种场景,后缀+大小限制已经够用了。

5. 常见问题排查与避坑指南

5.1 启动与运行阶段的高频问题

这个项目从搭建到跑起来,很多初学者会在环境阶段卡住。我复盘了帮别人排错的经历,整理了下面的问题清单,按出现频率排序:

问题现象根本原因解决方案
启动报错Port 8080 was already in use端口被其他程序占用命令行执行netstat -ano | findstr 8080,找到PID后杀掉对应进程,或修改server.port
启动报Unable to connect to MySQL数据库未启动、密码错误、URL写错在Navicat中测试数据库连接是否正常,确认连接串参数
启动报Access denied for user 'root'@'localhost'数据库用户名密码不对检查application.yml的username和password
启动报Unknown database 'blog_db'数据库不存在,JPA不会自动创建库(只建表)先手动执行CREATE DATABASE blog_db DEFAULT CHARACTER SET utf8mb4
页面显示但CSS/JS样式加载不出来静态资源路径配置错误检查spring.mvc.static-path-pattern配置,以及Thymeleaf模板中的资源引用路径
中文乱码MySQL连接串缺少编码参数URL中加上useUnicode=true&characterEncoding=utf8

这里特别说一下“数据库不存在”这个坑。ddl-auto: update确实能让JPA自动建表,但它只能在你已经连接上某个数据库的前提下建表,它不会帮你创建数据库本身。所以第一步必须手动执行建库命令:

CREATE DATABASE IF NOT EXISTS blog_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

5.2 编码与乱码问题全排查

中文乱码是Java Web项目里最常见的老问题。出现乱码时,按照这个顺序逐一检查:

第一关:数据库连接串。连接串里必须有characterEncoding=utf8。没有这个参数,MySQL驱动和服务器之间传输的数据就无法保证使用UTF-8编码,中文存进去就变成问号了。

第二关:数据库表和字段的字符集。建表SQL里已经写了DEFAULT CHARSET=utf8mb4,但如果你数据库默认字符集是latin1,新建的表也没有指定字符集的话,表和字段就会继承latin1。检查方法:

SHOW TABLE STATUS FROM blog_db LIKE 't_article'; SHOW FULL COLUMNS FROM t_article;

第三关:页面编码。Thymeleaf模板文件的编码格式必须和声明的encoding="UTF-8"一致。在IDEA中可以通过右下角查看文件编码,如果是GBK就必须转成UTF-8。

第四关:请求编码。Spring Boot中,spring.http.encoding默认已经启用了CharacterEncodingFilter,所以POST请求的参数编码一般没问题。如果你是自己从零搭的传统SpringMVC项目,就需要手动配置这个过滤器。

5.3 分页查询与懒加载的经典坑

文章列表页、后台管理页都用到了分页查询。JPA分页的用法如下:

public Page<Article> getPublishedArticles(int pageNum, int pageSize) { Pageable pageable = PageRequest.of(pageNum - 1, pageSize, Sort.by(Sort.Direction.DESC, "publishedAt")); return articleRepository.findPublished(pageable); }

注意PageRequest.of的第一个参数是页码索引,从0开始计数。页面上的第1页对应的是pageNum=0,如果你直接从页面前端传page=1过来,不处理就传进去,会导致第一页数据被跳过。

另外一个坑是Jackson序列化懒加载对象时的Could not write JSON: failed to lazily initialize a collection异常。当你在Controller中直接返回Article对象并让Spring自动转JSON时,如果Article中的tags集合还没来得及加载,序列化就会报错。

解决思路有两个方向:一是把查询结果转换成自定义的DTO对象再返回,二是用@EntityGraph在查询时显式加载关联对象属性的EAGER。我更推荐第一种方案,因为DTO隔离了实体和展示层,是更规范的做法。

5.4 部署上线时需要调整的配置项

本地开发跑通之后,部署上线还有一堆事情要做。我把自己踩过的坑提前列出来:

第一件:ddl-auto必须从update改回validate或none。这是数据库安全的第一道防线,生产环境绝对不能让你随意改表结构。

第二件:show-sql要关闭,日志级别从DEBUG调回INFO。在云端服务器上打印大量SQL日志不仅消耗IO,还会把数据库查询日志暴露在公共日志文件中。

第三件:上传文件的本地路径要改成服务器上的绝对路径。开发环境用的/Users/xxx/blog_images/是Mac路径,部署到Linux服务器上要改成/home/blog/images/这样的路径,并且给目录配好读写权限。

第四件:如果用Maven打包部署,运行命令推荐:

mvn clean package -DskipTests java -jar blog-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod

用--spring.profiles.active=prod切换生产环境配置,开发与生产配置分离。Spring Boot会自动读取application-prod.yml文件中的配置覆盖默认配置。

6. 项目扩展方向与二次开发建议

项目做完之后,如果想让简历上的这个项目更有竞争力,有几个扩展方向值得投入时间去完善。

第一个是引入Redis做缓存。目前文章详情页每次访问都要查数据库,随着文章数量和访问量的增加,数据库压力会越来越大。引入Redis之后,可以把文章详情缓存到内存中,设置一个合理的过期时间,再次访问直接走缓存。

大致的伪代码如下:

public Article getArticleWithCache(Long id) { String key = "article:" + id; Object cacheValue = redisTemplate.opsForValue().get(key); if (cacheValue != null) { return (Article) cacheValue; } Article article = articleRepository.findById(id).orElse(null); if (article != null) { redisTemplate.opsForValue().set(key, article, 10, TimeUnit.MINUTES); } return article; }

文章浏览量如果每次访问都直接写数据库,高并发下会产生大量的行级锁竞争。可以把浏览量先缓存在Redis里,每访问一次执行increment,再定期批量同步到数据库,这也是很多生产项目的标准做法。

第二个是引入全文检索。目前文章搜索用的是like '%keyword%',小数据量没问题,但文章数超过几百篇之后,这种全表扫描的模糊匹配性能会明显下降。可以引入Elasticsearch做全文索引,但Elasticsearch对硬件资源要求较高,部署复杂。轻量替代方案是引入SQLite的FTS5或者直接利用MySQL的全文索引(FULLTEXT索引),对个人博客来说已经足够。

第三个是增加评论通知功能。当有人评论我的文章时,如果能收到邮件或站内消息提醒,体验会好很多。Spring Boot集成JavaMailSender非常方便,引入spring-boot-starter-mail依赖,配置一下SMTP服务器信息就能实现。这个功能虽小,但能显著提升系统的“完整感”。

第四个是考虑容器化部署。把整个项目做成Docker镜像,配合Docker Compose一键启动MySQL和博客应用。Dockerfile的写法比较简单:

FROM openjdk:8-jre-alpine VOLUME /tmp COPY target/blog-0.0.1-SNAPSHOT.jar app.jar ENTRYPOINT ["java", "-jar", "/app.jar"]

容器化部署的好处是环境一致性——你在本地能跑起来的代码,在服务器上也一定能跑起来,不会出现“本地好好的,服务器上就是报错”的魔幻问题。

我个人在实际操作中的体会是,个人博客系统这个项目最大的价值不在于代码量有多大、功能有多牛,而在于它是一条完整的“需求分析 → 数据库设计 → 后端开发 → 前端联调 → 部署上线”链路。走通这一遍之后,你才能真正理解Spring Boot的各种组件是怎么协作的,数据库设计是怎么影响业务逻辑的,线上线下环境到底有哪些差异。

最后再分享一个小技巧:部署上线之后,建议加上一个自动备份数据库的定时任务。之前我因为数据库文件损坏,辛辛苦苦写的几十篇文章差点全部丢失,从那以后就老老实实加了每天凌晨自动导出SQL的任务。博客可以没多少人看,但自己写过的内容,是真的一份都丢不起。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询