前两天帮人评估一个毕设选题,对方发来一句:“基于 SpringBoot + jQuery 实现留言板功能”。我一看这个题目,第一反应是简单,再往深里想,其实不简单。留言板这个功能看着不起眼,但拆开来看,后端要做 HTTP 接口、数据库读写、参数校验、分页查询,前端要做表单提交、异步渲染、列表更新、事件绑定,一条完整的 Web 开发基本功链路全在里面。换句话说,一个留言板写明白了,前后端交互这套底子就算真正打扎实了。
这篇文章我打算把整个流程完整走一遍:从技术选型怎么定、数据表怎么设计、接口怎么规划,到 SpringBoot 后端代码怎么写、jQuery 前端怎么发 Ajax、怎么渲染页面,再讲到高频问题的排查思路。适合刚接触 SpringBoot 的初学者,也适合要交毕设、或者想在内部系统里快速搭一个反馈留言模块的在职开发。全篇基于我实际验证过的配置和代码,你可以直接照着抄。
1. 项目设计与技术选型拆解:这套组合为什么不过时
1.1 留言板功能的真实定位
很多人在评估这种项目时都会低估它,觉得“不就是个增删改查嘛”。但实际上,留言板在不同场景下的定位差别很大。教学项目里,它是为了覆盖 Web 开发的主要知识点;毕设场景里,它是系统里的用户交互入口,通常配合一个后台管理系统,给普通用户提供反馈问题的通道;真实业务里,它可能就是一个产品的意见反馈区。定位不同,功能的弹性很大,但核心链路是一样的:用户在前端输入信息,前端通过 Ajax 提交到后端接口,后端做校验并写入数据库,前端再从接口拉数据渲染成列表。
这个项目恰好是这套链路的最小完整闭环,所以我一直觉得它比“图书管理系统”更适合练手。图书管理要处理的字段多、分类多,反而容易把学习者的注意力从核心链路上移开。留言板字段少,昵称、内容、时间、IP 就够用,但交互一点都不少,该覆盖的技术点都能覆盖到。你把这个项目完整吃透,后面再去做更复杂的系统,底子是稳的。
1.2 SpringBoot + jQuery:为什么这个组合依然值得做
先说 SpringBoot。它最大的价值不是“帮你写代码”,而是帮你把环境搭起来、把配置收拢、把依赖管好。基于 SpringBoot 的 Web 项目,引入 spring-boot-starter-web 之后,内嵌 Tomcat 直接启动,不需要额外部署 war 包,web.xml、Spring 配置文件这类传统 SSM 项目里繁琐的东西都可以省掉。这对开发者的意义在于:精力可以放在业务逻辑上,而不是放在“怎么让框架跑起来”上。SpringBoot 的自动配置也是一个很实用的机制,数据源、MyBatis、Jackson 这些组件的默认行为都帮你配好了,你只需要关心要覆盖的部分。
再说 jQuery。很多人觉得它是老古董,但在留言板这种场景里价值很明显。jQuery 对 Ajax 的封装非常轻量,$.get、$.post、$.ajax几个方法就能覆盖大部分场景;DOM 操作上,$("#id").html()这种方式比原生document.getElementById写起来顺手得多,对初学者几乎没有门槛。更关键的是,大量存量系统,尤其是企业内部管理系统,前端仍然是 jQuery 主导,你会这个技能,维护老系统时就是实打实的竞争力。
这个组合还有一个隐性优点:前后端边界清楚。SpringBoot 只出 JSON 接口,前端页面用 jQuery 发异步请求渲染,分工明确,调试起来也方便。它不像某些前后端不分的 JSP 项目,页面里混着 Java 代码,改个样式都要重启服务。你先把这条链路吃透,之后再切换到 Vue、React 那套前后端分离架构,思路是相通的。
1.3 借这个话题说清楚:jQuery 到底还有没有必要学
这个问题在国内技术社区几乎每隔一段时间就要讨论一轮。我的观点很务实:如果你是零基础入门,可以直接从原生 JavaScript 加 Vue 或 React 起步,因为新项目在选型上确实很少再用 jQuery;但如果你将来要接触真实的企业项目、毕业设计、外包系统,jQuery 要求你“会读、能改、能修”,这一点是绕不开的。
我带项目的一个体会是,“有必要学”和“有必要深入学”是两回事。jQuery 不需要像 Vue、React 那样钻研组件化、状态管理、虚拟 DOM,你只需要掌握四个核心能力:选择器、事件绑定、Ajax 封装、链式操作,再配合一点原生 JS 基础,就足够维护绝大多数老系统。一句话总结:不把它当前端主力,但它是重要的存量技能。用它来做留言板,恰好是性价比最高的练法,不会陷入框架复杂度,又能把前后端交互的底子打扎实。
2. 前端与后端整体方案设计:表结构、接口与页面
2.1 数据表设计:一个字段一个字段抠清楚
留言板最少只需要一张表。我实际项目中常用的是下面这个结构:
CREATE TABLE `message` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `nickname` varchar(50) NOT NULL COMMENT '留言昵称', `content` varchar(1000) NOT NULL COMMENT '留言内容', `ip` varchar(64) DEFAULT NULL COMMENT '留言IP', `like_count` int NOT NULL DEFAULT 0 COMMENT '点赞数', `status` tinyint NOT NULL DEFAULT 0 COMMENT '状态:0显示 1隐藏 2删除', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '留言时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='留言表';这里有两个容易被忽略的点。第一,字符集一定要用 utf8mb4,不要用 utf8。MySQL 里的 utf8 实际最多只能存 3 个字节,遇到 emoji 或者生僻字就会直接报错,而 utf8mb4 是完整 UTF-8 编码,能存 4 字节内容。第二,create_time 用 datetime 而不要用 timestamp。timestamp 有 2038 年问题,而且受时区影响,跨时区场景下处理起来比较麻烦。
nickname 和 content 加上 NOT NULL 约束,同时长度一定要限制。昵称 50 个字符足够,内容 1000 也够用,但这个限制前后端都要做。后端不校验的话,一条超长内容把页面撑爆是小事,程序里可能直接内存溢出或 SQL 报错。status 字段是给管理场景预留的,没有管理端需求可以去掉,但我建议保留,它可以避免“想删留言时只能物理删除”的尴尬。物理删除数据是事后很容易后悔的操作,尤其是留言里有关键反馈信息的时候。like_count 是低成本的高频互动字段,加一个点赞功能会让项目看起来完整很多。
2.2 后端接口规划:明确每一个接口的职责
后端接口的设计会直接影响前端写起来爽不爽。我的做法是规定一个统一的返回结构,比如{ code: 0, msg: "success", data: ... },前端拿到之后先判断 code,再决定走成功逻辑还是失败逻辑,这样 Ajax 回调里的判断就完全统一了。对比一下,如果你一个接口直接返回字符串,另一个接口返回 JSON,前端每个请求都要单独写一套解析逻辑,维护起来非常痛苦。
接口列表可以定为这四个:
POST /api/message/add:提交留言GET /api/message/list:分页查询留言列表DELETE /api/message/{id}:删除留言POST /api/message/{id}/like:点赞
list 接口的分页参数用 page 和 size,排序规则固定为 create_time 倒序。关于分页,我要强调一点:一定用数据库分页,也就是 LIMIT/OFFSET,不要先查出全表数据再在内存里截取。初学者最常见的错误就是把List<Message>全查出来,然后用 subList 切割。数据量只有几十条的时候看不出来问题,一旦到了几千条,接口延迟会明显上升,数据库压力也白白浪费。
2.3 前端页面结构设计:静态页面还是模板引擎
前端我推荐两种方案:一种是纯静态 HTML 页面加 jQuery,通过 Ajax 调用后端 JSON 接口;另一种是用 Thymeleaf 模板直接渲染首屏列表,翻页和提交继续用 jQuery。两种我都验证过。第一种更贴近前后端分离的思想,适合接口型项目;第二种适合不想单独部署前端静态页、希望一个 SpringBoot 应用搞定全部的场景。
这里顺便提一下 Thymeleaf 热更新。开发环境下,在 application.yml 里关闭模板缓存,也就是设置spring.thymeleaf.cache=false,再配合 spring-boot-devtools,改完 HTML 模板后刷新浏览器就能看到效果,不用重启服务。很多人问“模板改了没反应”,八成就是缓存没关,这个问题在开发阶段特别影响效率。
页面结构上,单页面只需要四个区域:留言发布表单,包括昵称输入框、内容输入框、提交按钮;留言列表区,每条记录显示昵称、内容、时间、点赞按钮;分页条,上一页、页码、下一页;再加一个提示区,用于显示“提交中”或“提交成功”等状态。这个结构已经足够,不需要过度设计成多页面或路由。留言板的核心价值在交互和数据链路,不在页面数量。
3. 实战落地:从零把留言板跑起来
3.1 项目初始化与依赖配置
先说版本选型。我推荐 SpringBoot 2.7.18 加 JDK 8/11,再配 MyBatis-Plus 3.5.x 和 MySQL 5.7/8.0。原因很简单:2.7.18 是 SpringBoot 2.x 的最后一个版本,成熟稳定,兼容性好,网上资料多,市面上绝大多数存量项目都在这个体系内。如果你非要上 SpringBoot 3.x,那就需要 JDK 17 以上,需要注意 jakarta 命名空间、部分依赖兼容性问题,对初学者来说没必要一上来就挑战这些差异。很多人说“SpringBoot 版本太高导致各种奇怪问题”,多数就是依赖没跟上版本导致的兼容性踩坑。
用 IDEA 创建项目时选择 Spring Initializr,Group 填 com.example,Artifact 填 message-board,Java 版本选 8 或 11,依赖先勾 Spring Web,然后手动往 pom.xml 里补 MyBatis-Plus 和 MySQL 驱动。核心依赖如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency>这里有两个提醒。第一,mybatis-plus-boot-starter 版本尽量别用太老的,3.5.x 跟 SpringBoot 2.7 配合是经过大量项目验证的组合。第二,MySQL 8.0 的驱动类名是com.mysql.cj.jdbc.Driver,不是老的com.mysql.jdbc.Driver。我见过不少复制旧项目代码导致启动直接报 Driver 相关错误的,这类问题排查起来会很浪费时间。
application.yml 配置:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/message_board?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 你的密码 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: autourl 里的参数不要乱删。useUnicode=true&characterEncoding=utf8保证写入中文不乱码;useSSL=false避免 MySQL 8 在本地连接时提示 SSL 问题;serverTimezone=Asia/Shanghai解决数据库时间和 Java 时间相差 8 小时的问题。密码如果含特殊字符,比如 @、#,直接在 yml 里用单引号包起来,比如password: 'abc@123',否则 YAML 解析会报错。数据库密码这类配置,实际项目中一般会放到环境变量或配置中心,但开发阶段先写本地配置没问题。
3.2 后端核心代码实现
先写实体类。字段和数据库表对应,用 Lombok 的@Data省去 getter/setter 的重复代码:
@Data @TableName("message") public class Message { @TableId(type = IdType.AUTO) private Long id; private String nickname; private String content; private String ip; private Integer likeCount; private Integer status; private LocalDateTime createTime; }@TableId(type = IdType.AUTO)对应数据库自增主键。这个注解很容易被忽略,如果你不加,MyBatis-Plus 默认按雪花算法生成 ID,插入时就会带上一个很大的数字 ID,跟数据库自增行为对不上。实体类里createTime用LocalDateTime,比老的Date类型更符合现代 Java 开发习惯,配合 Jackson 序列化也没有时区烦恼。
Mapper 接口只需要继承 BaseMapper:
@Mapper public interface MessageMapper extends BaseMapper<Message> { }MyBatis-Plus 的 BaseMapper 已经提供了基础的单表增删改查和分页能力,这个项目规模下不需要写任何 SQL 方法。如果你熟悉 XML 映射文件,也不是不能用,但在留言板这种场景里,使用 BaseMapper 反而更省事。
分页插件配置是很容易漏的一步:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }千万别漏了这个配置。很多人照着网上的代码写分页,发现返回的 total 一直是 0,或者压根不分页,排查半天才发现是缺少分页插件。MyBatis-Plus 本身是增强包,它的分页拦截器才是真正负责把普通查询改写成带 LIMIT 语句的组件。
Controller 是项目的核心。我通常会把统一返回结果做成一个通用类:
@Data public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.code = 0; r.msg = "success"; r.data = data; return r; } public static <T> Result<T> error(String msg) { Result<T> r = new Result<>(); r.code = 1; r.msg = msg; return r; } }Controller 的完整实现:
@RestController @RequestMapping("/api/message") public class MessageController { @Resource private MessageMapper messageMapper; @PostMapping("/add") public Result<Message> add(@RequestBody Message message, HttpServletRequest request) { String nickname = message.getNickname(); String content = message.getContent(); if (nickname == null || nickname.trim().isEmpty() || nickname.length() > 20) { return Result.error("昵称不能为空且最长20个字符"); } if (content == null || content.trim().isEmpty() || content.length() > 200) { return Result.error("留言内容不能为空且最长200个字符"); } message.setNickname(cleanXss(nickname)); message.setContent(cleanXss(content)); message.setIp(getIp(request)); message.setLikeCount(0); message.setStatus(0); message.setCreateTime(LocalDateTime.now()); messageMapper.insert(message); return Result.success(message); } @GetMapping("/list") public Result<IPage<Message>> list(@RequestParam(defaultValue = "1") long page, @RequestParam(defaultValue = "5") long size) { size = Math.min(size, 50); Page<Message> pageParam = new Page<>(page, size); QueryWrapper<Message> wrapper = new QueryWrapper<>(); wrapper.eq("status", 0).orderByDesc("create_time"); return Result.success(messageMapper.selectPage(pageParam, wrapper)); } @PostMapping("/{id}/like") public Result<Integer> like(@PathVariable Long id) { Message message = messageMapper.selectById(id); if (message == null) { return Result.error("留言不存在或已删除"); } message.setLikeCount(message.getLikeCount() + 1); messageMapper.updateById(message); return Result.success(message.getLikeCount()); } private String cleanXss(String text) { if (text == null) { return null; } return text.replaceAll("<[^>]*>", "").trim(); } }add 接口里做了三层事情:基本校验、IP 记录、XSS 清洗。校验逻辑放在 Controller 层是允许的,但如果项目更正规,我建议把这些校验迁移到 Service 层,Controller 只做参数接收,Service 管业务规则。IP 获取要兼容有 Nginx 反代的情况,代码写成工具方法:
public static String getIp(HttpServletRequest request) { String ip = request.getHeader("X-Forwarded-For"); if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("X-Real-IP"); } if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } if (ip != null && ip.contains(",")) { ip = ip.split(",")[0].trim(); } return ip; }需要说明的是,X-Forwarded-For这个请求头在理论上是可以被客户端伪造的,所以这个 IP 更适合作为参考信息,而不是安全判断依据。留言板记录 IP 主要用于运营侧分析,比如看留言来源分布,不需要做到百分百精确。
3.3 前端页面与 jQuery 交互实现
前端页面我用纯静态 HTML 加 jQuery 来演示。页面文件放在src/main/resources/static目录下,SpringBoot 天然支持访问。HTML 骨架核心部分:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>留言板</title> <script src="js/jquery.min.js"></script> <script src="js/message.js"></script> </head> <body> <div class="mb-form"> <input type="text" id="nickname" placeholder="你的昵称" maxlength="20"> <textarea id="content" placeholder="说点什么..." maxlength="200"></textarea> <button id="submitBtn">提交留言</button> </div> <div id="messageList"></div> <div id="pagination"> <button id="prevBtn">上一页</button> <span id="pageInfo"></span> <button id="nextBtn">下一页</button> </div> </body> </html>提交留言的 jQuery 代码:
$('#submitBtn').on('click', function () { var nickname = $('#nickname').val().trim(); var content = $('#content').val().trim(); if (!nickname || !content) { alert('昵称和内容不能为空'); return; } $.ajax({ url: '/api/message/add', type: 'POST', contentType: 'application/json', data: JSON.stringify({ nickname: nickname, content: content }), dataType: 'json', success: function (res) { if (res.code === 0) { $('#nickname').val(''); $('#content').val(''); loadMessages(1); } else { alert(res.msg); } } }); });加载列表的 jQuery 代码是核心中的核心,翻页、删除、点赞都要依赖它:
var currentPage = 1; function loadMessages(page) { $.get('/api/message/list', { page: page, size: 5 }, function (res) { if (res.code !== 0) return; var records = res.data.records; var html = ''; records.forEach(function (item) { html += '<div class="message-item">' + '<div class="msg-head">' + '<span class="nickname">' + escapeHtml(item.nickname) + '</span>' + '<span class="time">' + formatTime(item.createTime) + '</span>' + '</div>' + '<div class="msg-content">' + escapeHtml(item.content) + '</div>' + '<button class="like-btn">function escapeHtml(text) { return String(text) .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }点赞按钮是动态渲染出来的,所以不能用$('.like-btn').on('click', ...)这种直接绑定的方式。第一次加载没问题,但翻页后新生成的按钮,事件绑定时节点还不存在。正确写法是事件委托,把监听挂在父容器上,让子元素的事件冒泡过来:
$('#messageList').on('click', '.like-btn', function () { var id = $(this).data('id'); $.post('/api/message/' + id + '/like', function (res) { if (res.code === 0) { loadMessages(currentPage); } }); });事件委托是 jQuery 动态列表项目里的标配写法,一定要养成习惯。这套代码跑通后,留言板核心功能就完成了:页面加载列表、提交留言、异步刷新、点赞交互,一条链路全部打通。
3.4 启动验证与联调要点
启动 SpringBoot 应用后,控制台会打印内嵌 Tomcat 的端口信息。先用浏览器直接访问http://localhost:8080/index.html看页面是否正常加载,再用 Postman 测接口。我建议验证顺序是:
POST /api/message/add提交一条留言,确认返回 code=0GET /api/message/list?page=1&size=5看列表是否有数据- 浏览器刷新页面,看列表能否正常渲染
- 提交按钮走一遍完整流程
如果控制台没有输出 SQL 日志,检查 application.yml 里log-impl配置是否配了StdOutImpl。不配的话 MyBatis-Plus 默认不打 SQL,出了问题很难定位到底发的什么 SQL、参数是什么。这条配置在开发阶段几乎是必备的。如果页面样式乱了,检查一下引用的 jquery.min.js 是否存在,本地静态资源 404 是新手高频问题。
4. 踩坑排雷:留言板项目里的高频问题与排查思路
我按出现频率从高到低写,这些是我实际带项目和评审代码时见过最多的问题。有些看起来基础,但越基础越容易卡人。
4.1 启动报错:Required a bean of type 'MessageMapper'
这个问题问率最高。报错信息一般是Field messageMapper in com.example...MessageController required a bean of type 'com.example...MessageMapper' that could not be found。原因就两大类:Mapper 接口没被扫描到,或者没加@Mapper注解。排查步骤是:确认 Mapper 接口上有没有@Mapper;确认启动类有没有@MapperScan("com.example.messageboard.mapper"),包名一定要对得上。注意@MapperScan扫描的是接口包,不是 Controller 包,别复制粘贴时把包路径写错。
4.2 接口 404 与静态资源访问不到
如果静态页面 404,确认页面是否放在src/main/resources/static目录下,SpringBoot 默认只在这个目录及子目录查找静态资源。如果接口 404,检查 Controller 的@RequestMapping路径和前端请求路径是否完全一致,包括大小写、末尾斜杠这两个细节。另外,如果你在 application.yml 里改了server.servlet.context-path,那前端所有请求都必须带上这个前缀,忘了这件事就会全部 404。
还有一个隐蔽的情况:Controller 类上写了@RequestMapping("/api/message"),方法上又写了@RequestMapping("/list"),正常请求/api/message/list是对的;但如果方法上写的是@RequestMapping("/list/"),访问/api/message/list反而会 404。这类小坑最耗时间,遇到接口不通时,先看控制台有没有打印请求进来了,再逐级对比路径。
4.3 中文乱码与时间差 8 小时
乱码分两种。写入数据库之前就乱码,几乎都是连接串的characterEncoding参数没加,或者数据库表的字符集不是 utf8mb4;数据库里中文正常、页面拿到的接口数据乱码,需要检查文件编码是不是 UTF-8,尤其是 HTML、JS、Java 文件。IDEA 右下角可以看到当前文件编码,统一改成 UTF-8 即可。
时间差 8 小时,通常出现在 MySQL 和 Java 时区不一致。我在配置里写了serverTimezone=Asia/Shanghai,从数据库连接这一层统一了时区;实体类里字段用LocalDateTime,也减少了很多序列化上的时区问题。如果已经出现数据差 8 小时,先检查连接串有没有时区参数,再查 MySQL 的时区变量:SHOW VARIABLES LIKE '%time_zone%'。前端显示时如果时间格式不好看,可以加上@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")注解,或者在前端做格式化处理。
4.4 分页返回 total=0 或者不分页
同样在前面提过,最典型的原因是没配分页插件。另一个原因是用了错误方法,比如查数据用了selectList而不是selectPage。还有一些人会在 QueryWrapper 里手工拼接last("limit 1"),结果 MyBatis-Plus 分页插件生成的 SQL 又带上了 LIMIT,数据库直接报语法错误。分页用selectPage加Page参数就完事,不要手工拼接 SQL。
page 和 size 的接收类型建议直接用 long,Page底层的分页参数就是 long 类型,没必要转换成 int 再转回来。前端传的 page 从 1 开始,后端不要自作聪明地做减一操作,先约定好规则,两端都按同一个规则走,免得数据错位。
4.5 动态渲染的坑:事件绑定失效与 XSS
动态渲染的按钮绑定事件,我前面已经给出解决方案:事件委托。这里再补充一个细节:$(this).data('id')拿到的可能是字符串,如果你的 ID 后续要参与算术运算或严格比较,需要先转成数字,用Number()或parseInt()处理。
XSS 攻击在留言板场景里必须正视。后端我在 Controller 里做了一个简单的去除标签处理,这能挡住一部分粗糙的脚本注入,但防不住所有情况。更严谨的做法是引入 Jsoup 这类库做 HTML 清洗,比如Jsoup.clean(input, Safelist.basic())。后端防线是必须有的,因为前端校验只针对正常用户,绕过前端直接调接口是毫无难度的事。
4.6 留言板常见问题速查表
| 现象 | 排查顺序 | 大概率原因 |
|---|---|---|
| 启动提示 MessageMapper 找不到 | 看 @Mapper/@MapperScan | 包扫描路径不对或注解漏了 |
| 静态页面 404 | 看 static 目录、文件位置 | 页面没有放在 resources/static 下 |
| 接口 404 | 对比请求路径、看控制台日志 | 路径不一致或 context-path 缺失 |
| 中文乱码 | 看连接串、文件编码 | characterEncoding 没配或文件不是 UTF-8 |
| 时间差 8 小时 | 看连接串时区、MySQL 时区 | 缺 serverTimezone 参数 |
| 分页 total=0 | 看分页插件配置 | MybatisPlus 分页拦截器缺失 |
| 动态按钮不响应 | 看事件绑定方式 | 没有使用事件委托 |
| 点赞失败 | 看请求 URL、data-id | 动态渲染的数据属性丢失或数据类型不对 |
这张表基本覆盖了留言板项目里八成以上的入门问题。如果你已经跑通了整个项目,遇到问题先对照这张表过一遍,能省下大量搜索时间。
4.7 容易忽略的细节:逻辑删除与后续扩展
留言删除不要用物理删除。MyBatis-Plus 支持@TableLogic逻辑删除,在实体类的 status 字段上加上注解后,调用deleteById会变成UPDATE message SET status=2 WHERE id=?,查询时也会自动过滤已删除数据。这样设计的好处是数据可追溯,万一哪天想恢复某条留言,或者做审计分析,数据还在。要注意 config 里的逻辑删除配置需要跟你表结构里的 status 含义对应,比如logic-delete-value: 2、logic-not-delete-value: 0。
项目跑通之后,扩展方向很自然:加一个管理端页面做留言审核和删除;留言内容支持楼层回复;点赞数改成 Redis 计数器再异步落库;静态页面换成 Vue 或 React 单页应用,后端接口不用动。这些都是常见的演化路径,但前提是你先把当前这条前后端链路吃透,后面只是换皮和加模块的问题。
最后说点个人体会。留言板这个项目,我前后带过的学生和同事加起来得有几十个版本,但几乎每次都能遇到新的问题,这恰好说明越是基础的项目,越能检验你对 Web 开发的理解是不是成体系的。如果你照着上面的代码跑通了,我建议你别急着丢开,试着再加一个功能,比如“管理员删除留言时要求输入密码”,或者“不同用户看到不同状态的留言”。加功能的过程,才是真正把它变成你自己项目的过程。
另外一个务实的建议:跑通之后,把整个项目用 Maven 打成 jar 包,用 Docker 部署到服务器上试试。中间会遇到静态资源路径、数据库连接、端口占用、时区设置等一系列问题,但这些都是生产环境必考的题目,早踩坑早省事。等你能在浏览器里通过域名或 IP 访问到自己独立部署的留言板时,SpringBoot 加 jQuery 这个组合,你就算真正拿下了。