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&lt;Post&gt; mapper = (rs, rowNum) -&gt; {
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&lt;Post&gt; 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&lt;Post&gt; findLatest(int size) {
return jdbc.query("SELECT * FROM posts ORDER BY id DESC LIMIT ?", mapper, size);
}
public Optional&lt;Post&gt; findById(Long id) {
List&lt;Post&gt; 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(() -&gt; 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(() -&gt; 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>
<div>
<a>上一页</a>
<span th:text="${page} + ' / ' + ${totalPages}"></span>
<a>下一页</a>
</div>
</body>
</html>
2)后台列表 admin-posts.html
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8"/>
<title>后台文章管理</title>
</head>
<body>
<h1>后台文章管理</h1>
<a href="/admin/posts/new">新建文章</a>
<table border="1" cellpadding="8">
<tr>
<th>ID</th><th>标题</th><th>操作</th>
</tr>
<tr th:each="p : ${posts}">
<td th:text="${p.id}"></td>
<td th:text="${p.title}"></td>
<td>
<a>编辑</a>
<form th:action="@{'/admin/posts/' + ${p.id} + '/delete'}" method="post" style="display:inline;">
<button type="submit" onclick="return confirm('确认删除?')">删除</button>
</form>
</td>
</tr>
</table>
<div>
<a>上一页</a>
<span th:text="${page} + ' / ' + ${totalPages}"></span>
<a>下一页</a>
</div>
</body>
</html>
3)新建文章 admin-post-form.html
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8"/>
<title>新建文章</title>
</head>
<body>
<h1>新建文章</h1>
<p th:if="${error}" th:text="${error}" style="color:red;"></p>
<form method="post" action="/admin/posts/new">
<div>
<label>标题</label>
<input name="title" style="width:400px;"/>
</div>
<div>
<label>内容</label>
<textarea name="content" rows="12" cols="80"></textarea>
</div>
<button type="submit">保存</button>
</form>
<a href="/admin/posts">返回列表</a>
</body>
</html>
4)编辑文章 admin-post-edit.html
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8"/>
<title>编辑文章</title>
</head>
<body>
<h1>编辑文章</h1>
<p th:if="${error}" th:text="${error}" style="color:red;"></p>
<form th:action="@{'/admin/posts/' + ${post.id} + '/edit'}" method="post">
<div>
<label>标题</label>
<input name="title" th:value="${post.title}" style="width:400px;"/>
</div>
<div>
<label>内容</label>
<textarea name="content" rows="12" cols="80" th:text="${post.content}"></textarea>
</div>
<button type="submit">更新</button>
</form>
<a href="/admin/posts">返回列表</a>
</body>
</html>
八、上线时建议补齐的关键点
这个后台已经“可用”,但上线前建议完善:
- 防止 XSS:前台渲染内容时建议转义或白名单 HTML
- CSRF:使用 Spring Security 会自动提供更完善的防护
- 富文本编辑器:后台可接 TinyMCE / CKEditor(注意过滤)
- 上传图片:加文件上传并限制类型/大小
- 审计日志:谁什么时候改了什么(长期维护很重要)
评论 0