简介:这份毕业设计资料包围绕基于Springboot、SpringMVC、Mybatis和小程序的图书捐赠管理系统设计与实现展开,面向计算机相关专业毕业生或需要快速搭建全栈项目的开发者,解决从后端接口、数据库设计到小程序前端联调的一体化需求。压缩包共856个文件、约42.96MB,涵盖java/class/jar等后端源码与依赖、jsp/xml/wxss/wxml等小程序与前端页面、sql数据库初始化脚本,以及项目配置、文档与毕业设计论文,目录结构清晰,便于直接导入开发工具运行与学习。已有143人学习浏览,适合毕业设计选题、项目实战复现或二次开发参考。通过该系统可掌握Springboot自动配置、SpringMVC请求分发、Mybatis持久层映射和小程序端交互的完整实现思路,同时结合捐赠流程、社区模块等业务场景,理解从表结构设计到上线部署的工程化流程。
1. 图书捐赠管理系统是什么:一条链路三个角色,这套技术栈为什么够用
“Springboot+SpringMVC+Mybatis+小程序的图书捐赠管理系统”是毕业设计和实训项目里出现频率极高的一道题:后端用 SpringBoot 搭基础框架,SpringMVC 处理路由与拦截,MyBatis 映射 SQL,前端用微信小程序承载用户端,管理端既可以单独做网页,也可以复用小程序里的管理员入口。整套系统没有高并发、没有分布式、没有微服务,但它能干净地跑通图书从登记、审核、上架、申请领取到统计归档的全流程,正好覆盖一个中小型校内场景的真实需求。适合应届生做毕设、Spring 全家桶初学者练手,也适合社团或社区快速搭一个轻量捐赠工具。
市面上同名源码包不少,但多数要么 SpringBoot 版本太旧,要么小程序端根本编译不过,要么论文和代码对不上。这篇笔记按可复现的方式把方案说透:表结构怎么设计、捐赠流程的状态机怎么定、接口怎么定义、小程序怎么对接、部署联调有哪些坑,以及怎样从“演示能过”做到“验收能过”。
2. 整体架构与服务分层:SpringBoot、SpringMVC、MyBatis 怎么各司其职
2.1 三个后端组件不是重复轮子:职责边界与选型理由
很多人拿到这个题目第一反应是“SpringBoot 不就已经包含 SpringMVC 了吗,为什么标题还要单独写一个 SpringMVC?”这个问题在开题和答辩时几乎必被问到,必须能接住。
SpringBoot 负责的是自动配置、内嵌容器和启动编排,它让项目不用再写一堆 XML 配置;SpringMVC 负责 Web 层的请求分发、参数绑定、拦截器和视图解析,在这套系统里具体表现为 Controller 层的路由定义和登录拦截;MyBatis 负责 DAO 层的 SQL 映射,把 Java 方法和 XML 里的 SQL 绑定起来。三者是“地基—楼层—管线”的关系,SpringBoot 把所有零件装好,SpringMVC 决定请求进来走哪条路,MyBatis 决定数据怎么落库、怎么查出来。
选型理由在论文里值得单独写一小节:为什么不选 JPA?图书列表的筛选条件非常动态——按书名模糊、按分类、按状态、按捐赠人,组合方式很多,MyBatis 的动态 SQL 写起来直观且可控;统计报表(捐赠排行、分类占比)需要手写聚合 SQL,用 MyBatis 可以直接贴近数据库方言调优。为什么不选更老的 SSM 手工配置?因为 SpringBoot 能省掉大量 bean 注册代码,把精力留给业务本身。为什么不选 MyBatis-Plus?因为题目明确写的是 MyBatis,论文核心概念不能和题目脱节,Plus 可以在“扩展与展望”里提,但主链路必须用原生 MyBatis 写清楚。
前端为什么用小程序而不是 H5?因为微信小程序不需要用户安装 App,扫码即用,分享到班级群或社区群很方便,图书捐赠这种低频、强信任、弱社交的场景,小程序比独立 App 轻得多,也比 H5 更容易触达用户。开发上原生小程序最稳妥,微信开发者工具直接预览,不需要额外引入 uni-app 或 Taro 的编译链路,对毕设来说少一层不确定性。
2.2 一次捐赠请求从微信到 MySQL 的完整路径
把一次完整捐赠流程画成时序,能帮助理解每一层的职责。用户在小程序里登记一本图书,提交后前端调用POST /api/book/add;请求先进入 SpringMVC 的 DispatcherServlet,被拦截器校验 token;校验通过后路由到 BookController,Controller 做参数校验后调用 BookService;Service 里处理业务规则,比如判断用户是否存在、分类是否合法;最终由 BookMapper 接口执行 XML 里的 INSERT SQL 把记录写进 MySQL。
紧接着是管理端的审核动作。管理员在后台看到待审核列表,点击通过后调用POST /api/donation/approve,同样走 Controller → Service → Mapper 的链路,但这里多了一个事务边界:要把 book 表的 status 从 0 改成 1,同时生成一条审核记录,两步必须同时成功或同时失败。这就是 Service 层加@Transactional的原因,也是论文里“事务与数据一致性”这一节的核心素材。
这条路径里还藏着两个容易被忽略的角色。一个是 SpringMVC 拦截器(HandlerInterceptor),它在请求进入 Controller 之前检查 Authorization 头,token 不存在或过期直接返回 401,不需要在每一个 Controller 方法里重复写登录判断。另一个是全局异常处理器(@RestControllerAdvice),它把业务异常统一转成{ code: 500, message: "图书状态已变化" }这样的结构,小程序端只需解析一种返回格式。
donation-api ├── pom.xml ├── src/main/java/com/example/donation │ ├── DonationApplication.java │ ├── config │ │ ├── WebMvcConfig.java │ │ └── WxConfig.java │ ├── controller │ │ ├── AuthController.java │ │ ├── BookController.java │ │ ├── DonationController.java │ │ └── FileController.java │ ├── service │ │ ├── BookService.java │ │ ├── DonationService.java │ │ └── UserService.java │ ├── mapper │ │ ├── BookMapper.java │ │ ├── DonationMapper.java │ │ └── UserMapper.java │ ├── entity │ ├── dto │ └── common │ ├── Result.java │ └── GlobalExceptionHandler.java └── src/main/resources ├── application.yml └── mapper ├── BookMapper.xml └── DonationMapper.xml这是后端项目结构,按 controller / service / mapper / entity / common 分层。controller 里只做参数接收和简单校验,业务逻辑全部下沉到 service,mapper 接口和 resources/mapper 下的 XML 一一对应。common 包里放统一返回结构 Result 和全局异常处理。这个结构在论文的系统设计章节可以直接当作模块划分图来写。
后端接口的返回体统一使用 Result 包装,前端不用再猜字段。这里给出常见写法:
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 200; r.message = "success"; r.data = data; return r; } public static <T> Result<T> fail(String message) { Result<T> r = new Result<>(); r.code = 500; r.message = message; return r; } }code 用 200 表示成功、401 表示未登录、500 表示业务失败,message 直接携带给用户看的提示。逻辑上把成功与失败封装成静态方法,业务代码里一句return Result.ok(data)就能返回标准结构。参数上如果后续要扩展分页、traceId,直接在泛型 T 里加字段即可,不用改前端解析逻辑。
然后是登录拦截器的核心逻辑:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || !TokenStore.isValid(token)) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}"); return false; } return true; } }拦截器里不要做重活,只判断 token 是否存在并且有效。TokenStore 可以是一个内存 Map,也可以替换成 Redis。注意返回 401 时要把响应体写成 JSON,否则前端按 JSON 解析会直接报错。注册这个拦截器时记得 excludePath 掉登录接口/api/auth/login和文件访问路径,否则会出现“登录取不到 token”的死循环。
2.3 项目骨架与统一返回、拦截器:先把地基打好
地基部分的坑集中在三点。第一,SpringBoot 版本与 JDK 匹配,我的习惯是直接用 SpringBoot 2.7.x + JDK 8 或 11,避开 3.x 的 jakarta 命名空间迁移问题,这与 MyBatis-Spring-Boot-Starter 的兼容性最稳。第二,统一返回结构要在一开始就定好,不要做了一半再改,前端封装的所有解析逻辑都依赖这个结构。第三,拦截器要区分“接口路径”和“静态资源路径”,本地图片上传后要通过资源映射访问,如果把静态路径也拦截了,前端图片全部裂掉。
关于 MyBatis 的配置,application.yml 里有几项建议直接写好。map-underscore-to-camel-case: true解决下划线字段到驼峰属性的映射;log-impl: org.apache.ibatis.logging.stdout.StdOutImpl在开发期打印 SQL,排查问题能直接看到参数值;cache-enabled我习惯关掉,毕设项目没有高频读场景,MyBatis 二级缓存反而容易带来“改完数据查出来还是旧的”的玄学问题,不如靠 MySQL 本身。这三项配置在论文里也能作为“系统非功能性设计”的一部分描述。
3. 表结构与捐赠流程状态机:让发布、审核、领取有据可依
3.1 五张核心表:字段、状态枚举与逻辑删除
图书捐赠系统的业务模型不复杂,但表关系要设计得能自圆其说。常见做法是五张核心表:用户表 user、图书表 book、分类表 category、捐赠申请/审核表 donation、领取记录表 record。下面这张表概括了每张表的职责:
| 表名 | 用途 | 关键字段 |
|---|---|---|
| user | 微信用户与管理员 | id, openid, nickname, avatar, role, create_time |
| book | 图书信息与状态 | id, title, author, isbn, cover_url, category_id, owner_id, status |
| category | 图书分类 | id, name, sort |
| donation | 捐赠登记与审核流 | id, book_id, applicant_id, reason, status |
| record | 领取记录 | id, book_id, user_id, take_time |
book 表的 status 是整个系统的核心状态位,用 TINYINT 而不是字符串,四个取值要写清楚:0 待审核,1 可领取,2 已捐出,3 已下架。donation 表的 status 负责审核流:0 申领中,1 同意,2 拒绝。状态用数字的好处是 MyBatis 的<if test="status != null">判断简单,SQL 也更短;代价是可读性差一点,所以建表时每个字段的 COMMENT 必须写清楚,论文的数据字典部分直接照抄。
再补充两个所有表都该有的字段:is_deleted做逻辑删除,create_time做记录时间。逻辑删除意味着业务代码里所有查询都要带WHERE is_deleted = 0,批量删除时执行的是 UPDATE 而不是 DELETE。答辩时老师喜欢问“为什么不用物理删除”,回答口径是:捐赠记录涉及用户行为留痕,删除用户或图书只应隐藏而不应抹除历史,否则统计报表的数据会断档。
核心建表脚本书如下,字段精简到能跑通业务:
CREATE TABLE `book` ( `id` int NOT NULL AUTO_INCREMENT, `title` varchar(100) NOT NULL COMMENT '书名', `author` varchar(50) DEFAULT NULL COMMENT '作者', `isbn` varchar(20) DEFAULT NULL COMMENT 'ISBN', `cover_url` varchar(255) DEFAULT NULL COMMENT '封面URL', `category_id` int DEFAULT NULL COMMENT '分类ID', `owner_id` int DEFAULT NULL COMMENT '登记人(捐赠人)', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待审核 1可领取 2已捐出 3下架', `is_deleted` tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书表';字段说明:title 用 varchar(100) 覆盖绝大多数图书标题;status 默认 0,即新登记的图书必须经过管理员审核才能被别人看到;cover_url 只存相对路径或对象存储 key,不存 base64 图片流;create_time 用数据库默认值,应用层不需要手动 set。索引只建了 status,因为主查询路径是“按状态刷列表”,等数据量上万后再加复合索引,毕设阶段不过度设计。
3.2 用 MyBatis 动态 SQL 实现图书多条件筛选
图书列表页几乎必然要支持多条件组合查询:关键词模糊搜标题、按分类过滤、按状态过滤、按捐赠人过滤。这类需求在 MyBatis 里的标准解法是<where>加<if>组合,下面这段 XML 是核心查询:
<select id="listBooks" resultType="com.example.donation.entity.Book"> SELECT id, title, author, isbn, cover_url, category_id, owner_id, status, create_time FROM book <where> is_deleted = 0 <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR author LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="status != null"> AND status = #{status} </if> <if test="ownerId != null"> AND owner_id = #{ownerId} </if> </where> ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select>逻辑说明:<where>标签会自动去掉第一个多余 AND,所以is_deleted = 0写在最前面作为恒成立条件时,后续 sql 片段直接写AND ...不会出错。keyword 的判断要同时判 null 和空串,否则前端传空字符串时会拼出LIKE '%%',造成全表扫;categoryId、status、ownerId 这些包装类型只用判!= null即可,因为状态 0 是合法值,不能按空串逻辑处理。LIMIT 用手写 offset 的方式,PageHelper 虽然方便,但毕设里手写分页更容易在答辩时把 SQL 讲清楚。
这里还要注意一个参数命名问题。Mapper 接口方法如果有多个参数,必须加@Param注解,否则 MyBatis 无法通过#{keyword}取到值。常见写法是:
List<Book> listBooks(@Param("keyword") String keyword, @Param("categoryId") Integer categoryId, @Param("status") Integer status, @Param("ownerId") Integer ownerId, @Param("offset") Integer offset, @Param("pageSize") Integer pageSize);参数说明:所有条件都允许为 null,通过动态 SQL 决定是否参与过滤;offset 和 pageSize 由前端分页组件传入,库里只做简单 limit。我第一次写这种多条件查询时翻过车,忘了给参数加 @Param,结果 MyBatis 报Parameter 'keyword' not found,这个报错信息很直接,看到就知道是注解漏了。
3.3 捐赠审核事务:用 status 做乐观锁防止重复处理
多管理员同时审核一本书,或者用户重复点击“申请领取”,会产生并发问题。处理手法是拿 status 字段做乐观锁:更新时带上“当前状态必须是某个值”的条件,影响行数为 0 就说明状态已经被别人改过。
@Transactional(rollbackFor = Exception.class) public void approveDonation(Integer donationId) { DonationApply apply = donationMapper.selectById(donationId); if (apply == null || apply.getStatus() != 0) { throw new BizException("该申请已被处理,请刷新后重试"); } int rows = bookMapper.compareAndSetStatus(apply.getBookId(), 1, 2); if (rows == 0) { throw new BizException("图书状态已变化,请刷新后重试"); } donationMapper.updateStatus(donationId, 1); recordMapper.insert(new Record(apply.getBookId(), apply.getApplicantId())); }逻辑说明:第一步查申请单并校验状态,第二步用UPDATE book SET status = 2 WHERE id = ? AND status = 1这样的原子操作把图书从“可领取”改成“已捐出”,受影响行数是 1 才继续往下走。这两步之间即使有两个管理员同时操作,数据库的行锁也会保证只有一个请求更新成功,另一个影响行数为 0 直接抛异常,不会产生“一本书被两个人同时领走”的数据错误。事务注解保证后面两步(更新申请单、插入领取记录)和第二步一起成功或一起回滚。
这里要解释一个概念:为什么不用悲观锁?毕设数据量下悲观锁(SELECT FOR UPDATE)没必要,还会占用连接;乐观锁通过条件更新实现,代码简单且并发安全够用。答辩时把这个对比讲清楚,比堆一堆理论更能得分。
4. 小程序端对接与联调:登录态、请求封装与图片上传
4.1 wx.login 换自建 token:code2Session 只能放在后端
小程序的登录链路和传统 Web 登录完全不同。前端调用wx.login拿到的 code 是一次性临时凭证,不能直接当作身份标识;真正的用户身份是后端拿 code 去微信接口换来的 openid。整个流程的正确做法是:小程序把 code 发给自己的后端,后端再携带 appid 和 secret 请求微信的 jscode2session 接口,得到 openid 和 session_key,然后为这个用户生成一个自建 token 返回前端。
为什么要绕这一圈?因为 appid 和 secret 属于后端机密配置,一旦写进小程序前端代码,等于把密钥公开在每一个用户手机上。session_key 是解密手机号等敏感数据的钥匙,也绝对不能下发到前端。这两个原则在论文安全设计部分要单独强调。
后端核心代码:
public String code2Session(String code) { String url = "https://api.weixin.qq.com/sns/jscode2session" + "?appid=" + wxConfig.getAppid() + "&secret=" + wxConfig.getSecret() + "&js_code=" + code + "&grant_type=authorization_code"; String body = restTemplate.getForObject(url, String.class); JSONObject json = JSON.parseObject(body); if (json.getString("openid") == null) { throw new BizException("微信登录失败: " + json.getString("errmsg")); } return json.getString("openid"); }逻辑说明:RestTemplate 拼参数时要注意 appid 和 secret 需要 URL 编码,不过标准的 appid 和 secret 字符集不会触发编码问题;返回的 JSON 里只要没有 openid,就说明 code 失效或参数错误,把 errmsg 原样抛给前端做提示。拿到 openid 后先查 user 表,不存在则自动注册,存在则直接更新最近登录时间,然后生成 token。
生成 token 有两种常见做法:UUID 随机串或 JWT。如果是毕设,我建议用 UUID 加上一个服务端 TokenStore 存储映射关系,好处是可控性强——管理员可以随时踢人下线,只要删掉 Store 里的记录;JWT 的优势是无状态,但在小程序场景里 token 本来就要存缓存,服务端状态化反而便于管理。if 你要写 JWT 作为加分项,注意把过期时间设置在 7 天以内,并与小程序的登录态同步失效。
这里附带说明一下“获取手机号”。微信要求手机号必须通过<button open-type="getPhoneNumber">用户主动点击授权,不能通过 wx.login 直接拿。拿到的是加密数据,需要后端用 session_key 解密。这属于用户画像增强功能,应届生毕设不必强做,做了就要额外处理密文解密和敏感信息脱敏展示,性价比不高。论文里可以作为“系统扩展功能”列出,不放进主流程。
4.2 封装 request.js:401 跳登录,统一错误提示
小程序端如果每个页面都直接调 wx.request,代码会迅速失控。我的做法是先做一层封装,把 baseURL、token 注入、错误码处理、加载态统一收口。下面的代码是完整版 request.js:
const BASE_URL = 'http://localhost:8080/api'; function request(path, method = 'GET', data = {}) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + path, method: method, data: data, header: { 'Authorization': wx.getStorageSync('token') || '' }, success(res) { const body = res.data; if (body.code === 200) { resolve(body.data); } else if (body.code === 401) { wx.removeStorageSync('token'); wx.navigateTo({ url: '/pages/login/login' }); reject(new Error(body.message)); } else { wx.showToast({ title: body.message, icon: 'none' }); reject(new Error(body.message)); } }, fail(err) { wx.showToast({ title: '网络异常,请检查后端服务', icon: 'none' }); reject(err); } }); }); } module.exports = request;逻辑说明:BASE_URL 在开发期填电脑局域网 IP 加后端端口,真机预览时手机和电脑必须在同一局域网;上线后改成 HTTPS 域名。header 里从本地缓存取 token,没有则为空字符串。成功回调先判断业务码,200 直接返回 data,401 清除本地 token 并跳登录页,其他码用 toast 展示后端给出的提示。fail 分支负责网络层错误,比如后端没启动、域名不通、证书错误。所有业务代码只需要request('/book/list', 'GET', { status: 1 })这样一行,不用关心底层细节。
参数说明:method 不传时默认 GET;data 在 GET 请求中会被 wx.request 自动拼到 query,POST 时放进 body。如果你习惯用 async/await,把函数声明改成 async 并在内部 return Promise,外层就能直接 await。一个容易被忽视的小坑是 loading 提示,我一般不在封装里强制加,而是在具体业务里根据场景用 wx.showLoading,避免列表页刷新时频繁闪动弹窗。
4.3 图片上传与静态资源映射:临时路径到持久化 URL
图书封面是捐赠系统的刚需功能。小程序端wx.chooseMedia选择图片后拿到的是本地临时路径,这个路径只在当前小程序会话内有效,必须通过上传接口把它发送到后端保存。前端代码:
wx.chooseMedia({ count: 1, mediaType: ['image'], success(res) { const tempPath = res.tempFiles[0].tempFilePath; wx.uploadFile({ url: BASE_URL + '/file/upload', filePath: tempPath, name: 'file', success(res) { const body = JSON.parse(res.data); formData.coverUrl = body.data; } }); } });逻辑说明:chooseMedia 的 tempFilePath 是本地缓存地址,直接用<image src>展示没问题,一旦用户删掉小程序缓存或换设备就失效。上传后后端返回的应该是可持久访问的 URL,前端把它存到 formData,提交图书信息时一并传给后端保存。name 字段是后端接口接收文件的参数名,前后端必须约定一致,否则报Required request part 'file' is not present。
后端接收上传时,最常见的坑是把文件写进项目 resources 目录。SpringBoot 打成 jar 包后,resources 目录在容器内部是只读的,运行时写入会报 IOException,即使开发期成功,重启后文件也可能被覆盖或丢失。正确做法是在服务器或本机指定一个外部目录,并在 WebMvcConfig 里配置静态资源映射:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Value("${upload.dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadDir + "/"); } }逻辑说明:上传接口把文件保存到 uploadDir 指定的目录,接口返回/upload/xxx.jpg这样的相对路径;浏览器或小程序访问时,请求打到/upload/**,SpringMVC 把它映射到磁盘上的绝对路径。这套方案无论开发期还是打包部署后都稳定,只要保证 uploadDir 指向一个有写权限的目录。application.yml 里用绝对路径配置 upload.dir,部署时通过启动参数覆盖,不要硬编码在代码里。这个点我在后面避坑章节还会展开,因为它几乎是毕设联调阶段必炸的一环。
5. 联调与部署避坑:5 条高频翻车记录
5.1 真机请求报 url not in domain list
现象:微信开发者工具里接口通通正常,一预览到真机,所有请求全部失败,报错信息是url not in domain list或request:fail。
原因:微信小程序对请求地址有安全限制,真机环境下只能访问已配置的业务域名,且必须 HTTPS;开发者在工具里看到的“正常”是因为工具默认打开了“不校验合法域名”选项,真机不会沿用这个设置。
解决:开发调试阶段,在微信开发者工具右上角“详情 → 本地设置”勾选“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”。注意这个勾选只对工具当前项目生效,并且真机预览时手机端的调试功能要保持开启。如果是体验版或正式版,必须在小程序后台配置 request 合法域名和 uploadFile 合法域名,域名要有备案和 HTTPS 证书。血泪经验是:每次切换电脑或换手机预览前,先检查这个勾选是不是还亮着,它经常被 IDE 重置。
5.2 MyBatis 查询结果全是 null
现象:SQL 在 Navicat 里跑得好好的,通过接口查出来的实体对象所有字段都是 null,只有 id 有值或 id 也为 null。
原因:绝大多数是数据库字段是下划线命名(create_time),实体属性是驼峰命名(createTime),MyBatis 默认不开启自动驼峰转换,导致字段映射不上。其次是 resultType 写成了基本类型或错误包名,但没有编译期报错,只在运行期返回空对象。
解决:在 application.yml 加一行配置,这是最稳的根治手段:
mybatis: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl配置说明:map-underscore-to-camel-case 开启后,create_time 会自动映射到 createTime,不用在 XML 里写大段 resultMap。log-impl 的作用是在控制台打印完整 SQL 和参数值,排查时一眼就能看出是查询条件写错还是映射问题。如果加完配置还出现 null,检查实体类字段是否真的叫 createTime 而不是 createtime,检查 resultType 是否写的全限定名。还有一个隐藏点:查询别名与原字段冲突时以别名优先,避免在 SQL 里同时出现带别名的列和不带别名的列混用。
5.3 上传图片重启服务就消失
现象:开发时上传图片,当时能显示;重启 SpringBoot 或重新打包后,图片全部 404,目录里也找不到文件。
原因:上传代码把文件写进了项目相对路径,比如new File("src/main/resources/static/upload")。开发工具运行时会解析成工作区目录,文件看似存在;一旦打成 jar 用java -jar启动,相对路径变成 jar 包内部的临时目录,重启后临时目录被清空或根本不可写。
解决:使用外部绝对路径。application.yml 中配置upload.dir: /data/donation-upload(Windows 则写D:/donation-upload),上传时用这个配置拼接文件完整路径,WebMvcConfig 里把/upload/**映射到该目录。部署到服务器后,这个目录要提前创建并确保运行用户有写权限。更进阶的做法是接对象存储,但毕设阶段本地磁盘方案完全够用,论文里把外部目录的设计讲清楚即可。
5.4 @Transactional 没生效,审核记录莫名丢失
现象:审核图书时页面提示成功,但数据库里 book 表状态变了,donation 表和 record 表却没有记录,数据对不上。
原因:事务没有生效。常见情况有三种:一是同一个类里this.approveDonation(donationId)调本类另一个@Transactional方法,Spring 的代理机制拦不住 this 调用,事务被跳过;二是方法不是 public 导致 AOP 代理不参与;三是方法内部 try-catch 把异常吞掉,事务感知不到失败就提交了。
解决:事务方法必须通过注入的 Bean 调用,不要用 this 调;方法权限用 public;不要在事务方法体内 catch 业务异常后正常返回,让异常抛给 Spring 触发回滚。审计接口属于后置操作,单独拆成一个AuditService.record()并在事务方法里调用,比在事务内写一堆 try-catch 干净得多。我自己排查这一类问题时会先看控制台有没有TransactionInterceptor的日志,再看方法调用链,最后检查异常是不是被吞了,按这个顺序基本能定位。
5.5 SpringBoot 版本太高,MyBatis 启动就报错
现象:新建项目时选了 Spring Initializr 默认的 SpringBoot 3.x,引入mybatis-spring-boot-starter后启动报AbstractMethodError或ClassNotFoundException: javax.servlet.*。
原因:SpringBoot 3.x 把 JavaEE 迁移到 Jakarta EE,包名从javax.servlet变成了jakarta.servlet;同时旧版 MyBatis starter 是基于 SpringBoot 2.x 编译的,在 3.x 环境下出现二进制不兼容。这个问题在热词里也常见,很多人问“springboot版本太高怎么办”。
解决:毕设项目直接用 SpringBoot 2.7.x,不追新。pom 里锁定版本:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent>配合 JDK 8 或 11,MyBatis 依赖用mybatis-spring-boot-starter:2.3.2,三者兼容性稳定,网上排错资料也最多。如果导师或题目强制要求 SpringBoot 3.x,那就必须换mybatis-spring-boot-starter:3.0.3+,同时检查所有涉及javax.servlet的代码改成jakarta.servlet。我的习惯是拿到项目先看 pom 和 JDK 版本,这个动作能避开后面一大半坑。
6. 从“能演示”到“能验收”:统计 SQL、接口回归与上线检查清单
6.1 给答辩加分的三张统计报表
捐赠系统光能增删改查只能算及格,论文里如果能展示统计功能,评委的印象会明显不一样。第一张是分类捐赠排行,按图书分类统计捐出数量:
SELECT c.name AS category_name, COUNT(*) AS donate_count FROM book b LEFT JOIN category c ON b.category_id = c.id WHERE b.status = 2 GROUP BY c.name ORDER BY donate_count DESC;第二张是近六个月捐赠趋势,用 DATE_FORMAT 按月份聚合:
SELECT DATE_FORMAT(create_time, '%Y-%m') AS month, COUNT(*) AS cnt FROM book WHERE status = 2 AND create_time >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH) GROUP BY DATE_FORMAT(create_time, '%Y-%m') ORDER BY month;第三张是用户捐赠贡献榜,关联 user 表统计每人捐了多少本。这三张表的数据可以放进论文的“系统测试”章节,配合图表展示,比纯文字描述有说服力得多。实际开发中,这三张 SQL 也正好验证了 MyBatis 处理聚合查询的能力,答辩时被问到“为什么不用 JPA”,这是现成的论据。
6.2 验收前用 curl 把核心链路跑一遍
正式提交前,我会按“登录 → 登记图书 → 审核 → 领取 → 统计”的顺序用 curl 做一遍接口回归。这个习惯帮我抓住了很多只在界面操作时发现不了的问题,比如 token 过期时间设置过短导致页面跳转异常。
# 登录获取 token TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \ -H 'Content-Type: application/json' \ -d '{"code":"test-code-001"}' | jq -r '.data.token') # 登记一本图书 curl -s -X POST http://localhost:8080/api/book/add \ -H "Authorization: $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"title":"实战Java","categoryId":1,"author":"张三"}' # 查询待审核列表 curl -s "http://localhost:8080/api/book/list?status=0" \ -H "Authorization: $TOKEN"参数说明:code 在开发环境可以用一个每次登录都会变化的测试值,后端用假的 code 也能走到后面的逻辑;jq 是 Linux 下解析 JSON 的工具,没装可以用 Python 或直接肉眼读返回。回归时重点看每一步的返回码和关键业务码,比如登记后图书状态应为 0,审核通过后状态应变为 1。如果条件允许,再用 JMeter 录一个简单的并发脚本模拟两个管理员同时审核同一本书,观察乐观锁是否生效。这些验证结论写进论文的测试章节,比“系统运行正常”这种空话可信得多。
6.3 上线前那几件琐碎但致命的事
第一,小程序后台的合法域名配置。request、uploadFile、downloadFile 三类域名要分别配置,且必须 HTTPS。很多项目部署完接口通,图片传不上,就是因为 uploadFile 域名没配。第二,服务器时区。MySQL 连接串加serverTimezone=Asia/Shanghai,否则日期字段差 8 小时,统计报表的月份分组会错位。第三,数据库字符集统一 utf8mb4,图书标题里可能出现生僻字或 emoji,utf8mb4 才存得下。第四,Spring 的 profile 切换。application-dev.yml 和 application-prod.yml 分开,部署时通过--spring.profiles.active=prod指定,避免开发期的调试配置泄露到生产环境。
我的个人习惯是交付前固定跑一遍“上传图片 → 重启服务 → 刷新页面”的组合拳,确保静态资源映射没有失效,再跑一遍 curl 回归确认核心链路。这套动作看着简单,但每次都能在最后一刻捞出问题。有一次就是这样发现上传目录的权限在重启后变了,花了半小时才定位是部署用户和目录属主不一致。提前把这些琐碎环节处理好,后面的人接手时会少很多怨气,希望帮到你。
本文还有配套的精品资源,点击获取