本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:本项目基于SpringBoot与Thymeleaf框架,提供一套完整的个人博客系统开发方案,涵盖从前端展示到后端管理的全流程实现。内容包括SpringBoot自动配置、Thymeleaf模板引擎集成、Spring Data JPA操作MySQL数据库、RESTful API设计、权限控制及项目快速搭建与部署方法。适合Java初学者进行Web开发实践或课程设计,帮助掌握Spring生态核心技术并提升全栈开发能力。

SpringBoot 全栈开发实战:从零构建博客系统

在当今快速迭代的软件开发领域,一个高效、稳定且易于维护的技术栈是项目成功的关键。对于 Java 开发者而言, Spring Boot + Thymeleaf + Spring Data JPA + MySQL 组合无疑是一套成熟可靠的全栈解决方案。它不仅简化了传统 SSM(Spring + Spring MVC + MyBatis)架构中的繁琐配置,还通过“约定优于配置”的理念极大提升了开发效率。

你有没有遇到过这样的场景?
刚接手一个老项目,光是搞清楚 web.xml applicationContext.xml spring-mvc.xml 之间的依赖关系就花了三天;
或者为了实现一个简单的用户列表展示功能,写了上百行 XML 映射和 DAO 接口……

而如今,借助 Spring Boot 的自动装配机制,这些曾经令人头疼的问题几乎被彻底终结了。我们只需要关注业务逻辑本身,框架会帮你完成大部分底层基础设施的搭建工作。

本文将以一个完整的 博客系统 为例,带你深入理解这套技术体系是如何协同工作的——从项目初始化开始,到数据库建模、后端接口设计、前端页面渲染,再到权限控制与管理后台实现。我们将不再停留在“怎么用”的层面,而是探究其背后的运行原理,并穿插大量工程实践建议,帮助你在真实项目中避免踩坑。

准备好了吗?🚀 让我们一起开启这段全栈之旅吧!


项目初始化与自动配置原理解密

起步依赖:告别复杂的 Maven 配置地狱

还记得当年手动添加各种 jar 包的日子吗?版本冲突、依赖缺失、兼容性问题……简直是开发者的噩梦 😫。而现在,只需访问 https://start.spring.io ,选择几个核心依赖,就能一键生成标准的 Spring Boot 工程骨架。

比如我们要做一个 Web 应用,通常会选上:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

这短短一行代码背后到底发生了什么?

其实,“starter” 并不是一个真正的库,而是一个 依赖管理聚合包 。当你引入 spring-boot-starter-web 时,Maven 会自动拉取一整套与 Web 开发相关的组件,包括:

  • Spring MVC(用于处理 HTTP 请求)
  • Tomcat(内嵌服务器,默认启动)
  • Jackson(JSON 序列化/反序列化)
  • Validation API(参数校验支持)

更重要的是,所有这些依赖都已经由 Spring Boot 团队进行了严格的版本匹配测试,确保它们之间不会出现兼容性问题。换句话说,你再也不需要去查某个 Spring 版本是否兼容特定版本的 Hibernate 了。

这就是所谓的“起步依赖”(Starter Dependency),它的本质是一种 最佳实践封装

💡 小贴士:如果你看到某个依赖以 spring-boot-starter-* 命名,那它一定是某种场景下的开箱即用模板。例如:

  • spring-boot-starter-data-jpa → 数据持久化
  • spring-boot-starter-thymeleaf → 模板引擎
  • spring-boot-starter-security → 安全认证

只需按需引入即可,无需关心内部细节。

自动配置:Spring Boot 的灵魂所在

有了起步依赖还不够,真正让 Spring Boot “起飞”的,是它的 自动配置机制 (Auto-configuration)。

想象一下:以前我们需要在 XML 或 Java Config 中显式地声明 DispatcherServlet ViewResolver DataSource 等 Bean;现在呢?只要 classpath 下有相关类,Spring Boot 就能自动帮你注册对应的 Bean!

这一切都归功于 @EnableAutoConfiguration 注解(虽然我们通常不直接写它,而是通过 @SpringBootApplication 间接启用)。

那么它是如何做到的呢?

🧠 核心原理剖析

Spring Boot 在启动过程中会扫描所有 jar 包下的这个文件路径:

META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

这个文件里记录了一长串自动配置类的名字,比如:

org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration
...

Spring 容器会逐个加载这些类,并根据一系列 @ConditionalOnXXX 条件注解来决定是否生效。举个例子:

@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass({ Servlet.class, DispatcherServlet.class })
public class WebMvcAutoConfiguration {
    // ...
}

这意味着只有当应用是一个 Web 应用,且 classpath 中存在 Servlet DispatcherServlet 类时,该配置才会被激活。

是不是很聪明?🧠

这种基于条件判断的自动装配机制,使得 Spring Boot 能够根据不同环境灵活调整行为。比如:

  • 如果检测到 H2 数据库驱动,就自动配置内存数据库;
  • 如果发现 Redis 客户端库,则自动连接 Redis 实例;
  • 如果没有指定视图解析器,就默认使用 Thymeleaf。

整个过程对开发者透明,真正做到“开箱即用”。

⚠️ 注意:自动配置虽好,但也可能导致意料之外的行为。建议在生产环境中明确关闭不必要的模块,或使用 @ConditionalOnMissingBean 防止 Bean 冲突。


Thymeleaf:服务端模板的现代演绎

为什么还需要服务端模板?

听到“Thymeleaf”,也许你会疑惑:现在不是都流行前后端分离了吗?React/Vue 打天下,谁还用服务端渲染啊?

的确,在大型复杂应用中,前后端分离+RESTful API 是主流趋势。但在某些场景下, 服务端模板依然有着不可替代的优势

场景 优势
内部管理系统 开发成本低,响应快,无需额外部署前端工程
SEO 敏感型网站 HTML 内容直出,搜索引擎友好
快速原型验证 几乎零配置即可展示动态数据

而且,Thymeleaf 的设计理念非常人性化——它支持 自然模板 (Natural Templates),也就是说 .html 文件可以直接在浏览器中打开预览,不需要启动后端服务!这对前端协作特别友好 👏。

表达式语言:简洁而强大

Thymeleaf 提供了一套直观的表达式语法,全部基于标准 HTML 属性扩展,使用 th:* 命名空间注入动态行为。这种设计既保持了 HTML 的合法性,又赋予了页面强大的动态能力。

来看看几种最常用的表达式类型:

✅ 变量表达式 ${...}

这是最基础也是最频繁使用的语法,用于获取上下文中的变量值。

控制器传参示例:

@Controller
public class HomeController {
    @GetMapping("/")
    public String home(Model model) {
        model.addAttribute("username", "zhangsan");
        model.addAttribute("age", 28);
        return "home";
    }
}

对应模板写法:

<p th:text="${username}"></p>
<p th:text="'Age: ' + ${age}"></p>

执行后输出:

<p>zhangsan</p>
<p>Age: 28</p>

可以看到, th:text 会替换元素的文本内容,而不是像 jQuery 那样操作 DOM。整个过程发生在服务端,最终发送给浏览器的是纯静态 HTML。

✅ 选择表达式 *{...}

当你已经通过 th:object 指定了当前作用域对象时,可以用 *{...} 简化访问路径。

<div th:object="${user}">
    <span th:text="*{name}"></span>
    <span th:text="*{email}"></span>
</div>

等价于:

<span th:text="${user.name}"></span>
<span th:text="${user.email}"></span>

但前者更清晰,尤其适合嵌套对象结构。

✅ 消息表达式 #{...}

国际化支持是企业级应用的基本要求。Thymeleaf 配合 Spring 的 MessageSource 可轻松实现多语言切换。

资源文件 messages.properties

welcome.message=Hello, {0}!
button.save=Save

模板调用:

<p th:text="#{welcome.message(${username})}"></p>
<button th:text="#{button.save}">Default Save</button>

如果当前语言为中文,且 username="李四" ,则输出:

<p>Hello, 李四!</p>
<button>保存</button>
✅ URL 表达式 @{...}

生成动态链接时,硬编码路径容易出错。 @{...} 能自动拼接上下文路径(Context Path),保证链接正确。

<a th:href="@{/article/{id}(id=${article.id})}">查看文章</a>
<form th:action="@{/submit}" method="post">

假设应用部署在 /blog 路径下, @{/submit} 会被解析为 /blog/submit ,避免 404 错误。

下面是 Thymeleaf 整体处理流程的可视化表示:

graph TD
    A[Controller返回ModelAndView] --> B{Thymeleaf Template Resolver}
    B --> C[解析HTML模板]
    C --> D[执行表达式引擎]
    D --> E[替换${...}, #{...}, @{...}]
    E --> F[输出最终HTML响应]

整个链条高度自动化,开发者只需专注于数据填充和视图设计。


动态逻辑控制:条件判断与循环遍历

再好的模板引擎也离不开流程控制。Thymeleaf 提供了丰富的属性指令来实现常见的 UI 逻辑。

条件渲染: th:if / th:unless / th:switch

<!-- 登录欢迎 -->
<div th:if="${not #strings.isEmpty(username)}">
    Welcome back, <b th:text="${username}"></b>!
</div>

<!-- 非管理员提示 -->
<div th:unless="${isAdmin}">
    You don't have admin privileges.
</div>

<!-- 角色面板分支 -->
<div th:switch="${user.role}">
    <p th:case="'ADMIN'">Administrator Panel</p>
    <p th:case="#{'USER'}">User Dashboard</p>
    <p th:case="*">Guest View</p>
</div>

这里有几个关键点要注意:

  • #strings.isEmpty() 是 Thymeleaf 内置的工具方法,属于 utility objects
  • th:unless 相当于 !condition ,语义更清晰。
  • th:case="*" 表示默认情况,类似 switch-case 中的 default

列表遍历: th:each 的艺术

展示集合数据几乎是每个系统的刚需。 th:each 指令可以轻松应对表格、菜单、文章列表等结构化内容。

Java 控制器传递数据:

model.addAttribute("articles", Arrays.asList(
    new Article(1L, "First Post", "Intro to Thymeleaf"),
    new Article(2L, "Second Post", "JPA Best Practices")
));

前端模板渲染:

<table>
    <tr th:each="article, iter : ${articles}">
        <td th:text="${iter.index + 1}">序号</td>
        <td th:text="${article.title}">标题</td>
        <td th:text="${article.summary}">摘要</td>
        <td th:text="${article.createdAt?.format(#dates.format('yyyy-MM-dd'))}">
            创建时间
        </td>
    </tr>
</table>

其中:

  • iter.index 从 0 开始,加 1 后变成自然序号;
  • ?. 是安全导航操作符,防止空指针异常;
  • #dates.format() 是日期格式化工具函数。

th:each 还提供了一个强大的状态变量(status variable),包含以下属性:

属性 含义 示例
index 当前索引(从0开始) iter.index → 0,1,2…
count 当前计数(从1开始) iter.count → 1,2,3…
size 集合总长度 iter.size
even/odd 是否偶数/奇数行 iter.even → true/false
first/last 是否首/尾元素 iter.first

利用这些状态信息,我们可以轻松实现隔行变色效果:

<tr th:class="${iter.odd}? 'odd-row' : 'even-row'">

配合 CSS:

.odd-row { background-color: #f9f9f9; }
.even-row { background-color: #fff; }

甚至还能做嵌套循环,比如分类目录下的文章展示:

<ul th:each="category : ${categories}">
    <li th:text="${category.name}"></li>
    <ul>
        <li th:each="article : ${category.articles}" 
            th:text="${article.title}"></li>
    </ul>
</ul>

完美呈现层级结构 ✅


Spring Data JPA:让 CRUD 成为过去式

ORM 的终极进化形态

在 Java 持久层技术演进史上,JDBC → MyBatis → JPA 是一条清晰的发展脉络。而 Spring Data JPA 更是在 JPA 基础上进一步抽象,提出了“ 仓库模式 ”(Repository Pattern),让我们只需定义接口,就能获得完整的 CRUD 能力。

看看这是多么不可思议:

@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
}

没错,接口里什么方法都没写!但只要你把它注入 Service 层,就可以直接调用:

@Autowired
private PostRepository postRepo;

// 新增
postRepo.save(post);

// 查询全部
postRepo.findAll();

// 分页查询
Pageable pageable = PageRequest.of(0, 10);
postRepo.findAll(pageable);

// 删除
postRepo.deleteById(id);

这些方法哪来的?其实是 Spring Data JPA 在运行时为你的接口生成了代理实现,自动绑定到 JPA 的 EntityManager 上。

方法名解析:会说话的查询语句

除了内置方法,Spring Data JPA 最惊艳的功能莫过于 方法名解析机制 。你可以通过命名规则自动生成查询逻辑,完全不用写 SQL!

看几个例子:

public interface PostRepository extends JpaRepository<Post, Long> {

    // 根据标题模糊查询
    List<Post> findByTitleContaining(String title);

    // 查找已发布文章并按时间倒序
    List<Post> findByStatusOrderByCreatedAtDesc(String status);

    // 查找某作者的文章并分页
    Page<Post> findByAuthorName(String authorName, Pageable pageable);

    // 统计某分类下的文章数量
    long countByCategory_Id(Long categoryId);
}

框架会根据这些方法名自动推导出对应的 JPQL 查询:

  • findByTitleContaining("Spring") SELECT p FROM Post p WHERE p.title LIKE '%Spring%'
  • findByStatusOrderByCreatedAtDesc("PUBLISHED") SELECT p FROM Post p WHERE p.status = 'PUBLISHED' ORDER BY p.createdAt DESC

这简直就像是在用自然语言描述需求 🤯

当然,也不是所有关键字都能记住。下面这张表值得收藏:

关键字 示例方法 对应操作
Containing findByTitleContaining LIKE '%?%'
Like findByTitleLike LIKE ?
Between findByPriceBetween BETWEEN ? AND ?
In findByIdIn(List ids) IN (?)
IsNull findByAuthorIsNull IS NULL
GreaterThan findByViewsGreaterThan > ?
OrderBy findByStatusOrderByViewsDesc ORDER BY views DESC

💡 提示:方法名越长,可读性越好,但也要注意不要过度复杂。超过三个条件建议改用 @Query

复杂查询: @Query 注解登场

当业务逻辑变得复杂时,比如涉及多表联查、子查询或性能优化,推荐使用 @Query 注解直接编写 JPQL 或原生 SQL。

public interface PostRepository extends JpaRepository<Post, Long> {

    // 使用 JPQL 查询带标签的文章
    @Query("SELECT DISTINCT p FROM Post p LEFT JOIN p.tags t WHERE t.name IN :tagNames")
    Page<Post> findPostsByTags(@Param("tagNames") List<String> tagNames, Pageable pageable);

    // 使用原生 SQL 查询热门文章(阅读量 > 1000)
    @Query(value = "SELECT * FROM blog_post WHERE view_count > 1000", nativeQuery = true)
    List<Post> findPopularPosts();

    // 更新操作需配合 @Modifying
    @Modifying
    @Query("UPDATE Post p SET p.viewCount = p.viewCount + 1 WHERE p.id = ?1")
    int incrementViewCount(Long id);
}

几点说明:

  • JPQL 面向实体模型,不暴露数据库表名;
  • 原生 SQL 性能更高,适合复杂查询;
  • 修改类操作必须加上 @Modifying ,否则会报错;
  • @Param 注解增强参数可读性和安全性。

来看一下方法调用时的内部执行流程:

sequenceDiagram
    participant Client
    participant RepositoryProxy
    participant QueryLookupStrategy
    participant EntityManager

    Client->>RepositoryProxy: findByTitleContaining("Java")
    RepositoryProxy->>QueryLookupStrategy: 解析方法名
    QueryLookupStrategy-->>RepositoryProxy: 生成 JPQL: SELECT p FROM Post p WHERE p.title LIKE %?%
    RepositoryProxy->>EntityManager: 创建 TypedQuery 执行
    EntityManager-->>RepositoryProxy: 返回结果集
    RepositoryProxy-->>Client: List<Post>

整个过程由 Spring AOP 动态代理拦截完成,对开发者完全透明。


数据库设计:从 E-R 图到物理表

博客系统的业务模型抽象

一个好的数据库设计,始于对业务的深刻理解。我们的博客系统主要包括以下几个核心实体:

  • 用户(User) :注册账户,发布文章
  • 文章(Post) :主要内容单元
  • 分类(Category) :主题归类
  • 标签(Tag) :关键词标记
  • 评论(Comment) :互动交流

它们之间的关系如下:

erDiagram
    USER ||--o{ POST : writes
    CATEGORY ||--o{ POST : contains
    CATEGORY }o--|| CATEGORY : "sub-category"
    POST ||--o{ COMMENT : has
    POST ||--o{ POST_TAG : has
    TAG ||--o{ POST_TAG : tagged_in

    USER {
        bigint id PK
        varchar username
        varchar password
        varchar email
        datetime created_time
    }

    POST {
        bigint id PK
        varchar title
        text content
        datetime publish_time
        tinyint status
        bigint user_id FK
        bigint category_id FK
    }

    CATEGORY {
        bigint id PK
        varchar name
        bigint parent_id FK
    }

    TAG {
        bigint id PK
        varchar name
    }

    POST_TAG {
        bigint post_id PK,FK
        bigint tag_id PK,FK
    }

    COMMENT {
        bigint id PK
        text content
        datetime create_time
        bigint post_id FK
        bigint parent_comment_id FK
    }

这张 E-R 图清晰表达了各实体间的关联方式,尤其是:

  • 用户与文章:一对多
  • 文章与分类:多对一
  • 文章与标签:多对多(通过中间表 post_tag 实现)
  • 分类自关联:支持无限层级嵌套

范式理论的应用与权衡

设计表结构时,我们应遵循三大范式原则:

范式 要求 目标
1NF 字段原子性 消除重复组
2NF 完全依赖主键 防止部分依赖
3NF 无传递依赖 消除冗余

POST 表为例:

CREATE TABLE post (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    title VARCHAR(255) NOT NULL,
    content TEXT,
    publish_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    status TINYINT DEFAULT 1,
    user_id BIGINT NOT NULL,
    category_id BIGINT,
    FOREIGN KEY (user_id) REFERENCES user(id),
    FOREIGN KEY (category_id) REFERENCES category(id)
);

该表满足 1NF~3NF。但如果把 category_name 冗余进来:

ALTER TABLE post ADD COLUMN category_name VARCHAR(100); -- ❌ 反模式

就会导致违反 3NF,因为 category_name 实际上依赖于 category_id ,形成传递依赖。一旦分类名变更,所有相关文章都要同步更新,极易出错。

所以正确的做法是:只存外键,查询时 JOIN 获取名称。

不过,在高并发读取场景下,频繁 JOIN 可能成为瓶颈。这时可以适度进行 反规范化 ,比如:

  • 缓存作者昵称、分类名到 Redis;
  • 使用物化视图预计算统计结果;
  • 引入 Elasticsearch 做全文检索。

但一定要记住: 先规范化,再根据性能需求局部反规范 ,切忌一开始就堆大宽表。

索引优化:B+树的秘密武器

MySQL 默认使用 B+ 树作为索引结构,特点是:

  • 支持范围查询
  • 叶子节点形成链表,便于顺序扫描
  • 高扇出,减少磁盘 I/O

常见查询场景及索引建议:

查询语句 推荐索引
WHERE username = ? UNIQUE(username)
WHERE category_id = ? INDEX(category_id)
ORDER BY publish_time DESC INDEX(publish_time DESC)
WHERE category_id=? AND status=? INDEX(category_id, status)

联合索引要遵守 最左前缀原则

-- 联合索引 (A, B, C)
ALTER TABLE t ADD INDEX idx_abc (A, B, C);

它可以加速:

  • WHERE A = ?
  • WHERE A = ? AND B = ?
  • WHERE A = ? AND B = ? AND C = ?

但不能有效支持:

  • WHERE B = ? (跳过 A)
  • WHERE C = ? (跳过 A 和 B)

因此索引顺序很重要!一般把区分度高的字段放前面。

验证索引是否命中:

EXPLAIN SELECT * FROM post WHERE category_id = 3 AND status = 1;

查看 key 字段是否显示预期索引名。

定期清理无用索引也很重要:

SELECT * FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE object_schema = 'blog_db' AND count_read = 0;

count_read = 0 的索引可以考虑删除,节省存储空间和写入开销。


数据库初始化:SQL 脚本自动化加载

使用 schema.sql data.sql 快速搭建环境

在 Spring Boot 中,我们可以通过两个简单脚本来实现数据库自动初始化:

  • src/main/resources/schema.sql :建表语句
  • src/main/resources/data.sql :初始数据

示例 schema.sql

DROP TABLE IF EXISTS post_tag;
DROP TABLE IF EXISTS tag;
DROP TABLE IF EXISTS comment;
DROP TABLE IF EXISTS post;
DROP TABLE IF EXISTS category;
DROP TABLE IF EXISTS user;

CREATE TABLE user (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(50) UNIQUE NOT NULL,
    password VARCHAR(100) NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    created_time DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

示例 data.sql

INSERT INTO user (username, password, email) VALUES 
('admin', '$2a$10$GRLdNyeVRuKUHILmHcJZlO5QvYtVwx/xHAUw2fW7T9SxPRqIUkOR6', 'admin@example.com');

INSERT INTO category (name, parent_id) VALUES 
('技术', NULL),
('生活', NULL),
('Java', 1);

🔐 密码已用 BCrypt 加密,绝不能明文存储!

启动流程如下:

flowchart TD
    A[Spring Boot Application Start] --> B{spring.sql.init.mode}
    B -->|equal to always or embedded| C[Execute schema.sql]
    C --> D[Create Tables]
    D --> E[Execute data.sql]
    E --> F[Insert Initial Data]
    F --> G[Start ApplicationContext]
    G --> H[Application Ready]

生产环境注意事项

虽然 schema.sql 很方便,但不适合用于生产环境的数据迁移。建议:

  • 开发/测试环境:使用 spring.sql.init.mode=always
  • 生产环境:关闭自动初始化,改用 Flyway 或 Liquibase 做版本化管理

配置示例:

spring:
  sql:
    init:
      mode: always
      schema-locations: classpath:schema.sql
      data-locations: classpath:data.sql

如果需要更精细控制,也可以自定义 DataSourceInitializer Bean:

@Configuration
public class DatabaseInitConfig {

    @Bean
    public DataSourceInitializer dataSourceInitializer(final DataSource dataSource) {
        ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
        populator.addScript(new ClassPathResource("custom-schema.sql"));
        populator.addScript(new ClassPathResource("custom-data.sql"));

        DataSourceInitializer initializer = new DataSourceInitializer();
        initializer.setDataSource(dataSource);
        initializer.setDatabasePopulator(populator);
        return initializer;
    }
}

这样可以在不同环境下动态选择脚本路径。


前端功能实现:首页、分类、归档一体化设计

首页内容聚合与分页展示

首页是用户的第一印象,必须兼顾美观与性能。我们采用分页机制避免一次性加载过多数据。

Repository 层定义分页查询:

@Query("SELECT a FROM Article a WHERE a.status = 'PUBLISHED' ORDER BY a.createdAt DESC")
Page<Article> findPublishedArticles(Pageable pageable);

Service 层封装参数:

public Page<Article> getPublishedArticles(int page, int size) {
    Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
    return articleRepository.findPublishedArticles(pageable);
}

Controller 传递模型:

@GetMapping("/home")
public String home(@RequestParam(defaultValue = "0") int page,
                   @RequestParam(defaultValue = "10") int size,
                   Model model) {
    Page<Article> articlePage = articleService.getPublishedArticles(page, size);
    model.addAttribute("articles", articlePage.getContent());
    model.addAttribute("currentPage", articlePage.getNumber());
    model.addAttribute("totalPages", articlePage.getTotalPages());
    return "index";
}

Thymeleaf 渲染分页控件:

<nav aria-label="Page navigation">
    <ul class="pagination">
        <li class="page-item" th:classappend="${currentPage == 0} ? 'disabled' : ''">
            <a class="page-link" th:href="@{/home(page=${currentPage - 1}, size=${#request.getParameter('size')})}" th:if="${currentPage > 0}">上一页</a>
        </li>
        <li th:each="i : ${#numbers.sequence(0, totalPages - 1)}" 
            class="page-item" th:classappend="${i == currentPage} ? 'active' : ''">
            <a class="page-link" th:href="@{/home(page=${i}, size=${#request.getParameter('size')})}" th:text="${i + 1}">1</a>
        </li>
        <li class="page-item" th:classappend="${currentPage == totalPages - 1} ? 'disabled' : ''">
            <a class="page-link" th:href="@{/home(page=${currentPage + 1}, size=${#request.getParameter('size')})}" th:if="${currentPage < totalPages - 1}">下一页</a>
        </li>
    </ul>
</nav>

还可以结合 AJAX 实现无刷新分页,提升用户体验。

分类树形结构递归渲染

分类常采用自引用表设计:

CREATE TABLE category (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL,
    parent_id BIGINT,
    FOREIGN KEY (parent_id) REFERENCES category(id)
);

Java 实体:

@Entity
public class Category {
    @Id
    private Long id;
    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    private Category parent;

    @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL)
    private List<Category> children = new ArrayList<>();
}

构建树形结构:

public List<Category> buildTree() {
    List<Category> all = categoryRepo.findAll();
    Map<Long, Category> map = all.stream().collect(Collectors.toMap(Category::getId, c -> c));

    List<Category> roots = new ArrayList<>();
    for (Category c : all) {
        if (c.getParent() != null) {
            map.get(c.getParent().getId()).getChildren().add(c);
        } else {
            roots.add(c);
        }
    }
    return roots;
}

Thymeleaf 递归模板:

<ul class="category-tree">
    <li th:each="cat : ${categories}">
        <span th:text="${cat.name}"></span>
        <div th:replace="this :: subtree(${cat.children})"></div>
    </li>
</ul>

<th:block th:fragment="subtree(children)">
    <ul th:if="${not #lists.isEmpty(children)}">
        <li th:each="child : ${children}">
            <span th:text="${child.name}"></span>
            <div th:replace="this :: subtree(${child.children})"></div>
        </li>
    </ul>
</block>

标签云权重算法

标签热度可通过字体大小体现:

public Map<Tag, Integer> calculateTagWeights(List<Tag> tags) {
    if (tags.isEmpty()) return Collections.emptyMap();
    int max = tags.stream().mapToInt(Tag::getCount).max().orElse(1);
    int min = tags.stream().mapToInt(Tag::getCount).min().orElse(1);
    int range = Math.max(max - min, 1);

    return tags.stream().collect(Collectors.toMap(
        Function.identity(),
        tag -> 12 + (int)(((double)(tag.getCount() - min) / range) * 18) // 12~30px
    ));
}

前端样式联动:

<div class="tag-cloud">
    <a th:each="entry : ${tagWeights}"
       th:text="${entry.key.name}"
       th:style="'--weight:' + ${entry.value} + 'px'"
       th:href="@{/tags/{id}(id=${entry.key.id})}">标签</a>
</div>

CSS:

.tag-cloud a {
    font-size: var(--weight);
    margin: 5px;
}

时间轴归档:Java 8 时间 API 的妙用

按月归档文章:

public Map<YearMonth, List<Article>> groupByMonth(List<Article> articles) {
    return articles.stream()
        .filter(a -> "PUBLISHED".equals(a.getStatus()))
        .collect(Collectors.groupingBy(
            a -> YearMonth.from(a.getCreatedAt()),
            LinkedHashMap::new,
            Collectors.toList()
        ));
}

Thymeleaf 渲染:

<div class="archive-timeline">
    <div th:each="entry : ${archives}" class="month-group">
        <h4 th:text="${#temporals.format(entry.key.atDay(1), 'yyyy年MM月')}"></h4>
        <ul>
            <li th:each="article : ${entry.value}">
                <a th:href="@{/article/{id}(id=${article.id})}" th:text="${article.title}"></a>
                (<span th:text="${#temporals.format(article.createdAt, 'dd日')}"></span>)
            </li>
        </ul>
    </div>
</div>

视觉上可用垂直时间线布局增强可读性。


后端管理功能:Spring Security 权限控制实战

管理员认证流程集成

引入依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

配置类:

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/admin/login", "/css/**").permitAll()
                .antMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            .and()
            .formLogin()
                .loginPage("/admin/login")
                .loginProcessingUrl("/admin/doLogin")
                .defaultSuccessUrl("/admin/index", true)
                .failureUrl("/admin/login?error=true")
            .and()
            .logout()
                .logoutUrl("/admin/logout")
                .logoutSuccessUrl("/admin/login")
            .and()
            .csrf().disable();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }
}

自定义 UserDetailsService

@Service
public class AdminUserDetailsService implements UserDetailsService {
    @Autowired
    private UserRepository userRepo;

    @Override
    public UserDetails loadUserByUsername(String username) {
        User user = userRepo.findByUsername(username);
        if (user == null || !"ADMIN".equals(user.getRole())) {
            throw new UsernameNotFoundException("Invalid admin user");
        }
        return User.withUsername(user.getUsername())
                   .password(user.getPassword())
                   .roles(user.getRole())
                   .build();
    }
}

登录页面:

<form th:action="@{/admin/doLogin}" method="post">
    <input type="text" name="username" required />
    <input type="password" name="password" required />
    <button type="submit">登录</button>
    <div th:if="${param.error}">
        <span style="color:red;">用户名或密码错误</span>
    </div>
</form>

完整认证流程:

sequenceDiagram
    participant User
    participant Browser
    participant SpringSecurity
    participant UserDetailsService

    User->>Browser: 访问 /admin/index
    Browser->>SpringSecurity: 重定向到 /admin/login
    User->>Browser: 输入用户名密码
    Browser->>SpringSecurity: POST /admin/doLogin
    SpringSecurity->>UserDetailsService: loadUserByUsername
    UserDetailsService-->>SpringSecurity: 返回 UserDetails
    SpringSecurity->>SpringSecurity: 校验密码 & 权限
    alt 认证成功
        SpringSecurity-->>Browser: 302 跳转 /admin/index
    else 认证失败
        SpringSecurity-->>Browser: 重定向至 failureUrl
    end

这套机制保障了后台入口的安全性,是现代 Web 应用不可或缺的一环。


这种高度集成的设计思路,正引领着智能音频设备向更可靠、更高效的方向演进。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:本项目基于SpringBoot与Thymeleaf框架,提供一套完整的个人博客系统开发方案,涵盖从前端展示到后端管理的全流程实现。内容包括SpringBoot自动配置、Thymeleaf模板引擎集成、Spring Data JPA操作MySQL数据库、RESTful API设计、权限控制及项目快速搭建与部署方法。适合Java初学者进行Web开发实践或课程设计,帮助掌握Spring生态核心技术并提升全栈开发能力。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐