Java + MySQL 搭建可用的文章后台(增删改查 + 分页)实战

Java + MySQL 搭建可用的文章后台(增删改查 + 分页)实战

当你的网站已经有了“能访问的页面”和“能登录的后台”,下一步通常就是:后台能管理内容。最常见的就是文章系统:发布公告、写博客、做教程、做更新日志,甚至后面扩展成产品管理也完全一致——核心都是 CRUD(增删改查)+ 分页 + 表单校验。

这篇文章用 Spring Boot + Thymeleaf + JDBC + MySQL(不引入复杂 ORM)做一个可上线的文章后台,包含:

  • /posts 前台文章列表
  • /posts/{id} 前台文章详情
  • /admin/posts 后台文章管理(分页)
  • /admin/posts/new 新建文章
  • /admin/posts/{id}/edit 编辑文章
  • /admin/posts/{id}/delete 删除文章
  • 简单的参数校验 + 防止空标题/空内容
  • 示例 SQL、完整 Java 代码、模板页面

> 说明:如果你更习惯 JPA / MyBatis,这套结构同样适用,只是 Repository 实现方式不同。


一、数据库准备:建库建表

先创建数据库:

CREATE DATABASE site_db DEFAULT CHARSET utf8mb4;
USE site_db;

创建文章表:

CREATE TABLE posts (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  title VARCHAR(200) NOT NULL,
  content TEXT NOT NULL,
  created_at DATETIME NOT NULL,
  updated_at DATETIME NOT NULL
);

插入几条测试数据:

INSERT INTO posts(title, content, created_at, updated_at)
VALUES
('第一篇文章', '这是内容示例。', NOW(), NOW()),
('第二篇文章', '这是第二篇内容示例。', NOW(), NOW());


二、配置 Spring Boot 连接 MySQL

application.properties

spring.datasource.url=jdbc:mysql://localhost:3306/site_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8
spring.datasource.username=root
spring.datasource.password=你的密码
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

spring.thymeleaf.cache=false server.port=8080

pom.xml 添加 MySQL Driver:

<dependency>
  <groupId>com.mysql</groupId>
  <artifactId>mysql-connector-j</artifactId>
  <scope>runtime</scope>
</dependency>


三、核心模型:Post

Post.java

package com.example.site.model;

import java.time.LocalDateTime;

public class Post { private Long id; private String title; private String content; private LocalDateTime createdAt; private LocalDateTime updatedAt;

public Post() {}

public Post(Long id, String title, String content, LocalDateTime createdAt, LocalDateTime updatedAt) {
    this.id = id;
    this.title = title;
    this.content = content;
    this.createdAt = createdAt;
    this.updatedAt = updatedAt;
}

public Long getId() { return id; }
public void setId(Long id) { this.id = id; }

public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }

public String getContent() { return content; }
public void setContent(String content) { this.content = content; }

public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }

public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }

}


四、Repository:用 JdbcTemplate 完成 CRUD + 分页

依赖:Spring Boot Web 默认就带 JdbcTemplate,你只要注入即可。

PostRepository.java

package com.example.site.repo;

import com.example.site.model.Post; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Repository;

import java.sql.Timestamp; import java.time.LocalDateTime; import java.util.List; import java.util.Optional;

@Repository public class PostRepository {

private final JdbcTemplate jdbc;

public PostRepository(JdbcTemplate jdbc) {
    this.jdbc = jdbc;
}

private final RowMapper<Post> mapper = (rs, rowNum) -> {
    Post p = new Post();
    p.setId(rs.getLong("id"));
    p.setTitle(rs.getString("title"));
    p.setContent(rs.getString("content"));
    p.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime());
    p.setUpdatedAt(rs.getTimestamp("updated_at").toLocalDateTime());
    return p;
};

public int countAll() {
    Integer n = jdbc.queryForObject("SELECT COUNT(*) FROM posts", Integer.class);
    return n == null ? 0 : n;
}

public List<Post> findPage(int page, int size) {
    int offset = (page - 1) * size;
    return jdbc.query(
            "SELECT * FROM posts ORDER BY id DESC LIMIT ? OFFSET ?",
            mapper, size, offset
    );
}

public List<Post> findLatest(int size) {
    return jdbc.query("SELECT * FROM posts ORDER BY id DESC LIMIT ?", mapper, size);
}

public Optional<Post> findById(Long id) {
    List<Post> list = jdbc.query("SELECT * FROM posts WHERE id = ?", mapper, id);
    return list.isEmpty() ? Optional.empty() : Optional.of(list.get(0));
}

public long create(String title, String content) {
    LocalDateTime now = LocalDateTime.now();
    jdbc.update(
            "INSERT INTO posts(title, content, created_at, updated_at) VALUES(?,?,?,?)",
            title, content, Timestamp.valueOf(now), Timestamp.valueOf(now)
    );
    Long id = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
    return id == null ? -1 : id;
}

public void update(Long id, String title, String content) {
    LocalDateTime now = LocalDateTime.now();
    jdbc.update(
            "UPDATE posts SET title=?, content=?, updated_at=? WHERE id=?",
            title, content, Timestamp.valueOf(now), id
    );
}

public void delete(Long id) {
    jdbc.update("DELETE FROM posts WHERE id = ?", id);
}

}

分页策略:

  • page 从 1 开始
  • 每页 size
  • 总数 countAll() 计算总页数


五、前台控制器:文章列表 + 详情

PublicPostController.java

package com.example.site.controller;

import com.example.site.repo.PostRepository; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*;

@Controller public class PublicPostController {

private final PostRepository repo;

public PublicPostController(PostRepository repo) {
    this.repo = repo;
}

@GetMapping("/posts")
public String posts(@RequestParam(defaultValue = "1") int page,
                    Model model) {
    int size = 8;
    int total = repo.countAll();
    int totalPages = (int) Math.ceil(total * 1.0 / size);

    model.addAttribute("posts", repo.findPage(page, size));
    model.addAttribute("page", page);
    model.addAttribute("totalPages", totalPages);

    return "posts";
}

@GetMapping("/posts/{id}")
public String detail(@PathVariable Long id, Model model) {
    var post = repo.findById(id).orElseThrow(() -> new IllegalArgumentException("Not Found"));
    model.addAttribute("post", post);
    return "post-detail";
}

}


六、后台控制器:管理、创建、编辑、删除

AdminPostController.java

package com.example.site.controller;

import com.example.site.repo.PostRepository; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*;

@Controller public class AdminPostController {

private final PostRepository repo;

public AdminPostController(PostRepository repo) {
    this.repo = repo;
}

@GetMapping("/admin/posts")
public String adminList(@RequestParam(defaultValue = "1") int page,
                        Model model) {
    int size = 10;
    int total = repo.countAll();
    int totalPages = (int) Math.ceil(total * 1.0 / size);

    model.addAttribute("posts", repo.findPage(page, size));
    model.addAttribute("page", page);
    model.addAttribute("totalPages", totalPages);
    return "admin-posts";
}

@GetMapping("/admin/posts/new")
public String newForm() {
    return "admin-post-form";
}

@PostMapping("/admin/posts/new")
public String create(@RequestParam String title,
                     @RequestParam String content,
                     Model model) {

    if (title == null || title.trim().isEmpty()) {
        model.addAttribute("error", "标题不能为空");
        return "admin-post-form";
    }
    if (content == null || content.trim().isEmpty()) {
        model.addAttribute("error", "内容不能为空");
        return "admin-post-form";
    }

    long id = repo.create(title.trim(), content.trim());
    return "redirect:/admin/posts";
}

@GetMapping("/admin/posts/{id}/edit")
public String editForm(@PathVariable Long id, Model model) {
    var post = repo.findById(id).orElseThrow(() -> new IllegalArgumentException("Not Found"));
    model.addAttribute("post", post);
    return "admin-post-edit";
}

@PostMapping("/admin/posts/{id}/edit")
public String update(@PathVariable Long id,
                     @RequestParam String title,
                     @RequestParam String content,
                     Model model) {

    if (title == null || title.trim().isEmpty()) {
        model.addAttribute("error", "标题不能为空");
        return "admin-post-edit";
    }
    if (content == null || content.trim().isEmpty()) {
        model.addAttribute("error", "内容不能为空");
        return "admin-post-edit";
    }

    repo.update(id, title.trim(), content.trim());
    return "redirect:/admin/posts";
}

@PostMapping("/admin/posts/{id}/delete")
public String delete(@PathVariable Long id) {
    repo.delete(id);
    return "redirect:/admin/posts";
}

}

> 注意:如果你上一篇已经做了 Session 拦截器,这里的 /admin/** 会自动受保护。


七、模板页面(Thymeleaf)

1)前台列表 posts.html

<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="utf-8"/>
  <title>文章列表</title>
</head>
<body>
<h1>文章列表</h1>

<ul> <li> <a></a> </li> </ul>

&lt;div&gt; <a>上一页</a> &lt;span th:text="${page} + ' / ' + ${totalPages}"&gt;&lt;/span&gt; <a>下一页</a> &lt;/div&gt;

&lt;/body&gt; &lt;/html&gt;

2)后台列表 admin-posts.html

&lt;html lang="zh" xmlns:th="http://www.thymeleaf.org"&gt;
&lt;head&gt;
  &lt;meta charset="utf-8"/&gt;
  &lt;title&gt;后台文章管理&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;h1&gt;后台文章管理&lt;/h1&gt;
<a href="/admin/posts/new">新建文章</a>

&lt;table border="1" cellpadding="8"&gt;
  &lt;tr&gt;
    &lt;th&gt;ID&lt;/th&gt;&lt;th&gt;标题&lt;/th&gt;&lt;th&gt;操作&lt;/th&gt;
  &lt;/tr&gt;
  &lt;tr th:each="p : ${posts}"&gt;
    &lt;td th:text="${p.id}"&gt;&lt;/td&gt;
    &lt;td th:text="${p.title}"&gt;&lt;/td&gt;
    &lt;td&gt;
      <a>编辑</a>
      &lt;form th:action="@{'/admin/posts/' + ${p.id} + '/delete'}" method="post" style="display:inline;"&gt;
        &lt;button type="submit" onclick="return confirm('确认删除?')"&gt;删除&lt;/button&gt;
      &lt;/form&gt;
    &lt;/td&gt;
  &lt;/tr&gt;
&lt;/table&gt;

&lt;div&gt;
  <a>上一页</a>
  &lt;span th:text="${page} + ' / ' + ${totalPages}"&gt;&lt;/span&gt;
  <a>下一页</a>
&lt;/div&gt;

&lt;/body&gt;
&lt;/html&gt;

3)新建文章 admin-post-form.html

&lt;html lang="zh" xmlns:th="http://www.thymeleaf.org"&gt;
&lt;head&gt;
  &lt;meta charset="utf-8"/&gt;
  &lt;title&gt;新建文章&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;h1&gt;新建文章&lt;/h1&gt;

&lt;p th:if="${error}" th:text="${error}" style="color:red;"&gt;&lt;/p&gt;

&lt;form method="post" action="/admin/posts/new"&gt;
  &lt;div&gt;
    &lt;label&gt;标题&lt;/label&gt;
    &lt;input name="title" style="width:400px;"/&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;label&gt;内容&lt;/label&gt;
    &lt;textarea name="content" rows="12" cols="80"&gt;&lt;/textarea&gt;
  &lt;/div&gt;
  &lt;button type="submit"&gt;保存&lt;/button&gt;
&lt;/form&gt;

<a href="/admin/posts">返回列表</a>
&lt;/body&gt;
&lt;/html&gt;

4)编辑文章 admin-post-edit.html

&lt;html lang="zh" xmlns:th="http://www.thymeleaf.org"&gt;
&lt;head&gt;
  &lt;meta charset="utf-8"/&gt;
  &lt;title&gt;编辑文章&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;h1&gt;编辑文章&lt;/h1&gt;

&lt;p th:if="${error}" th:text="${error}" style="color:red;"&gt;&lt;/p&gt;

&lt;form th:action="@{'/admin/posts/' + ${post.id} + '/edit'}" method="post"&gt;
  &lt;div&gt;
    &lt;label&gt;标题&lt;/label&gt;
    &lt;input name="title" th:value="${post.title}" style="width:400px;"/&gt;
  &lt;/div&gt;
  &lt;div&gt;
    &lt;label&gt;内容&lt;/label&gt;
    &lt;textarea name="content" rows="12" cols="80" th:text="${post.content}"&gt;&lt;/textarea&gt;
  &lt;/div&gt;
  &lt;button type="submit"&gt;更新&lt;/button&gt;
&lt;/form&gt;

<a href="/admin/posts">返回列表</a>
&lt;/body&gt;
&lt;/html&gt;


八、上线时建议补齐的关键点

这个后台已经“可用”,但上线前建议完善:

  1. 防止 XSS:前台渲染内容时建议转义或白名单 HTML
  2. CSRF:使用 Spring Security 会自动提供更完善的防护
  3. 富文本编辑器:后台可接 TinyMCE / CKEditor(注意过滤)
  4. 上传图片:加文件上传并限制类型/大小
  5. 审计日志:谁什么时候改了什么(长期维护很重要)

评论 0