简介:这是一份基于微信小程序与SSM框架的java毕业设计项目,面向计算机专业完成毕业论文管理系统课题的本专科学生。系统功能覆盖学生管理、教师管理、师生双选、院校管理、开题答辩、答辩评审、学生推优及过程文档管理等模块,完整呈现高校毕业设计从选题到答辩的信息化管理流程,帮助学习者理解SSM分层架构与业务实现。压缩包共1385个文件,大小33.89MB,以Java源码、Vue/JS前端、微信小程序WXML/WXSS页面、PNG图片为核心,另含SQL脚本、论文文档与答辩PPT,能支撑代码研读、环境搭建与毕业设计答辩汇报。从内容预览可见带有安装、运行、构建批处理脚本及备份文件,便于本地部署和版本回退,降低上手门槛。目前已有59人学习,适合正在选题或需要二次开发同类系统的同学参考借鉴。
1. 这个毕设到底在做什么:先看清 Java 后端 + 微信小程序这套组合的边界
如果你正在挑毕设题目,看到“基于微信小程序的学生毕业论文管理系统”这种标题,第一反应通常是:这题是不是太普通了?普通确实是普通,但它属于那种“再普通也比空泛强”的题目。它的核心不是算法,而是一套完整业务流程的实现——学生选题、导师审核、开题报告上传、中期检查、论文定稿、答辩成绩发布,六个环节全部串起来,而你手头有完整的代码、论文和 PPT。对绝大多数本科生来说,这才是最稳妥的投入产出比。
这个系统要解决的真正痛点,不是“写一个网站”,而是多角色协作下的流程状态管理:学生交什么、导师审什么、教务看什么,三类人的界面和权限完全不同。技术关键词也很集中:Java(Spring Boot 做后端接口)、微信小程序(学生端+教师端共用一套前端)、MySQL(存业务数据)。换句话说,这个题目考察的是你“会不会把需求拆成表和接口”,而不是“会不会造轮子”。
接下来我会按实际开发顺序,把微信小程序端、Spring Boot 后端、数据库设计、联调踩坑和论文答辩一条线讲完,代码都是可以直接抄走的骨架。你不需要先学完整个 Spring 全家桶,跟着搭就能跑通。
2. 小程序端从零搭建:tabBar、请求封装和登录态是怎么写出来的
2.1 页面结构:一个 tabBar 怎么装下学生、导师和教务三个角色
常见做法是采用微信小程序原生框架,页面数量控制在 8~10 个以内,不引入 uni-app,因为毕设只需要跑通微信端,原生框架的编译链路最短、真机调试问题最少。第一步要做的是把 app.json 里的 tabBar 定下来,这是整个前端的地基。
{ "pages": [ "pages/login/index", "pages/home/index", "pages/topic/index", "pages/mine/index" ], "tabBar": { "list": [ { "pagePath": "pages/home/index", "text": "首页" }, { "pagePath": "pages/topic/index", "text": "选题" }, { "pagePath": "pages/mine/index", "text": "我的" } ] } }这里有一个容易被新手忽略的点:登录页不应该出现在 tabBar 里,而是要作为启动后第一个跳转的页面。实现方式是在 app.js 的 onLaunch 里检查本地是否有 token,没有就 wx.reLaunch 到登录页。tabBar 只放三个入口,是因为“消息/通知”这类页面用 wx.navigateTo 跳普通页面即可,放进 tabBar 反而会让角色切换逻辑变乱。
我一般会把页面复用策略放在首位:学生端和教师端共用“选题列表”页面,只是根据 user.role 字段决定按钮显示“我要选题”还是“审核通过”。这样页面数量少,论文里画功能结构图也干净。
2.2 请求封装:统一处理 token、错误码和 401 跳转
小程序里最容易被答辩老师追问的就是“你是怎么管理请求的”。如果每个页面都裸调 wx.request,代码会膨胀得很难看,而且 token 过期、网络失败、接口报错三种情况会散落在各处。所以第一件事是做一个 Promise 化的请求封装。
// utils/request.js const BASE_URL = 'http://localhost:8080/api' export function request({ url, method = 'GET', data = {} }) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + url, method, data, header: { 'Content-Type': 'application/json', 'Authorization': wx.getStorageSync('token') || '' }, success(res) { if (res.statusCode === 401) { wx.removeStorageSync('token') wx.reLaunch({ url: '/pages/login/index' }) reject(res) return } if (res.data.code !== 0) { wx.showToast({ title: res.data.msg, icon: 'none' }) reject(res) return } resolve(res.data.data) }, fail(err) { wx.showToast({ title: '网络异常', icon: 'none' }) reject(err) } }) }) }这段代码有三个点需要理解。第一,后端返回的统一结构是 { code, msg, data },code 为 0 表示业务成功,这样前端不用为每个接口单独写错误判断。第二,Authorization 头存的是登录后拿到的 JWT,每次请求自动带上,后端靠它识别当前用户。第三,遇到 401 直接清 token 并跳回登录页,避免用户停留在已失效的页面上继续操作——这是很多同学会漏掉的逻辑。
关于 base_url,开发阶段可以写本地局域网 IP,比如http://192.168.1.101:8080/api,因为本地 localhost 在真机上是访问不到的。上线或答辩演示前再改成已备案的 HTTPS 域名,这个点后文“避坑”章会单独说。
2.3 登录流程:wx.login 换 code,后端换 token
小程序的登录不能像网页那样直接输用户名密码,正确姿势是先调 wx.login 拿到一个临时 code,再把 code 发给后端,由后端调用微信接口换 openid,然后自己生成 token 返回。学生首次登录时,后端根据 openid 自动创建账号,也可以让前端补一次学号绑定。
// pages/login/index.js const { request } = require('../../utils/request') Page({ handleLogin() { wx.login({ success: async (res) => { if (!res.code) { wx.showToast({ title: '获取code失败', icon: 'none' }) return } const data = await request({ url: '/auth/login', method: 'POST', data: { code: res.code, role: 'student' } }) wx.setStorageSync('token', data.token) wx.setStorageSync('userInfo', data.userInfo) wx.switchTab({ url: '/pages/home/index' }) } }) } })这里有一个设计取舍要提前想清楚:教务处或管理端如果也要登录,是复用小程序端还是单独做一个 Web 后台。常见做法是答辩系统里做一个小程序管理入口,角色为 admin 的用户登录后能看到成绩发布和选题配置页面,避免再开一个大屏管理项目。
提一句热词相关的细节:顶部导航栏高度在小程序里不是固定值,iPhone X 以后的机型有刘海,用wx.getWindowInfo()拿 safeArea 再动态计算,否则自定义导航栏时按钮会被顶到状态栏外面。这个属于小程序的经典坑,论文里如果能写出“适配了安全区”这半句话,就能让答辩老师觉得你有真机经验。
3. Spring Boot 后端:JWT 鉴权与论文上传接口的落地细节
3.1 项目骨架和接口分层:为什么 Controller 要写得像“传话筒”
后端大概需要 12 个接口,分四组:认证组(login/me)、选题组(topic list/select/review)、论文组(upload/download/review)、统计组(dashboard)。Spring Boot 项目的标准分包方式是 controller/service/mapper/entity,再配一个 config 放拦截器和跨域配置。Controller 只负责收参数和返回结果,业务逻辑全部下沉到 Service,这是论文里“系统设计”一章最值得写的结构。
@RestController @RequestMapping("/api/topic") public class TopicController { @Resource private TopicService topicService; @GetMapping("/list") public Result list(@RequestParam(required = false) Integer status) { return Result.success(topicService.listByStatus(status)); } @PostMapping("/select") public Result select(@RequestParam Long topicId, @RequestParam Long studentId) { topicService.select(topicId, studentId); return Result.success(); } }说明一下代码里的三个细节。第一,AOP 思路不要做得太复杂,一个 controller 只干一件事,答辩问你“spring mvc 执行流程”时你才能讲清楚 RequestMapping 是怎么路由到方法的。第二,select接口的幂等性要关注:同一个学生重复选题,第二次再调这个接口应该返回“你已经选过题”,而不是再插入一条记录。第三,所有返回结构都用Result包装,它包含 code/msg/data 三个字段,这样前端 request.js 才能统一判断业务状态。
权限控制不能只在 controller 里写 if-else,而是要在拦截器层面干。写一个 HandlerInterceptor,把所有需要登录才能访问的接口都拦下来,再用注解区分角色,比如@RequireRole("teacher")。这样 Service 层不用关心“当前是谁”的问题,职责干净。
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口 if (request.getRequestURI().contains("/auth/login")) { return true; } String token = request.getHeader("Authorization"); if (token == null || !JwtUtil.verify(token)) { response.setStatus(401); return false; } request.setAttribute("userId", JwtUtil.getUserId(token)); return true; } }在 WebMvcConfigurer 里注册这个拦截器时要注意拦截路径。如果addPathPatterns("/api/**"),那登录接口也会被拦,所以要在前面加上排除路径/api/auth/login。JWT 的生成我用的是 jjwt 0.9.1 这个老版本,不带 jdk11 以上的模块化坑。
3.2 论文上传接口:文件校验、大小限制和嵌套事务怎么配合
上传是这个小程序里唯一的“重”操作,因为学生要传开题报告、中期报告和终稿,老师要传修改意见。这部分最容易被拷问的点是:上传到哪、怎么避免重名、怎么保证数据库和文件系统一致性。
@PostMapping("/thesis/upload") public Result upload(@RequestParam("file") MultipartFile file, @RequestParam("studentId") Long studentId, @RequestParam("stage") Integer stage) throws IOException { String originalName = file.getOriginalFilename(); String suffix = originalName.substring(originalName.lastIndexOf(".")); if (!Arrays.asList(".doc", ".docx", ".pdf").contains(suffix.toLowerCase())) { return Result.fail("仅支持 doc/docx/pdf 格式"); } long maxSize = 10 * 1024 * 1024L; if (file.getSize() > maxSize) { return Result.fail("文件大小不能超过 10MB"); } // 文件名规则:学号_阶段_时间戳.后缀,避免重名覆盖 String fileName = studentId + "_" + stage + "_" + System.currentTimeMillis() + suffix; String uploadDir = System.getProperty("user.dir") + "/upload/"; File dest = new File(uploadDir + fileName); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } file.transferTo(dest); // 数据库记录,更新论文状态 thesisRecordService.updateStage(studentId, stage, fileName); return Result.success(fileName); }上传代码有四个隐藏点需要理解。第一,MultipartFile 是从 Spring 的 CommonsMultipartResolver 解析出来的,要在 application.yml 里配置spring.servlet.multipart.max-file-size=10MB,否则超过默认 1MB 会先被框架拦下,你在代码里写的校验根本执行不到。第二,文件名用“学号+阶段+时间戳”拼接,是为了避免两个学生上传同名文件互相覆盖。第三,transferTo 之前必须确保父目录存在,mkdirs()这行不加就是经典的 FileNotFoundException。
第四也是最关键的:文件落盘成功之后才写数据库记录,两个操作要放到一个事务里。否则会出现磁盘上文件存在、数据库里查不到这条记录的情况。
@Service public class ThesisRecordService { @Resource private ThesisRecordMapper thesisRecordMapper; @Transactional(rollbackFor = Exception.class) public void updateStage(Long studentId, Integer stage, String filePath) { thesisRecordMapper.updateStage(studentId, stage); int count = thesisRecordMapper.countByStudentId(studentId); if (count == 0) { throw new RuntimeException("学生记录不存在,事务回滚"); } // 这里还可以插入 file_record 表,记录文件属性和上传时间 } }注意rollbackFor = Exception.class必须写,Spring 默认只对 RuntimeException 回滚,如果你抛的是普通 Exception,事务不会回滚,文件记录照样会被插入。这是我在真实项目里血泪踩过的坑:代码看着没问题,但数据库里多条脏数据,就是因为 rollbackFor 没设。
@Transactional只能管数据库操作,管不住文件系统。如果文件写入成功但数据库回滚了,就会出现“脏文件”,我一般会在异常 catch 块里把刚写入的文件删除,但更稳妥的做法是文件先上传到临时目录,等事务成功后再移动过去。毕设阶段做到“先写文件再写库、失败时删文件”已经能拿到不错的印象分。
4. 数据库设计:六张核心表、状态机字段和数据一致性事务
4.1 表结构与关系:用户、选题、论文记录、审核记录怎么连
MySQL 建库用 utf8mb4 字符集,避免导师意见里存 emoji 变成问号。给这个小程序做表结构设计,六张表是底线:sys_user、thesis_topic、thesis_record、review_record、file_record、notice。少一张,流程就会断。
CREATE TABLE `sys_user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `openid` VARCHAR(64) DEFAULT NULL, `student_no` VARCHAR(20) DEFAULT NULL, `name` VARCHAR(50) NOT NULL, `role` TINYINT NOT NULL DEFAULT 0 COMMENT '0学生 1教师 2管理员', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE `thesis_topic` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `title` VARCHAR(200) NOT NULL, `teacher_id` BIGINT NOT NULL, `selected_by` BIGINT DEFAULT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0可选 1已选 2已截止' ); CREATE TABLE `thesis_record` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `student_id` BIGINT NOT NULL, `topic_id` BIGINT NOT NULL, `process_status` TINYINT NOT NULL DEFAULT 1 COMMENT '1选题 2开题 3中期 4终稿 5答辩', `submit_time` DATETIME DEFAULT NULL, `archive_path` VARCHAR(255) DEFAULT NULL );sys_user 里存 openid 是为了配合 wx.login 的免密登录,但论文里要写清楚:openid 只是身份标识,不能拿它当业务主键,因为一个学生可能绑错微信再换绑,主键还是自增 id 更稳定。thesis_topic 和 thesis_record 的关系是 1:N:一个学生只有一个最新进度,但选题可以变更,所以每一步历史都要落到 review_record 里,而不是直接覆盖。
关键设计是 process_status 这个状态机字段。不要试图把“开题未审/开题通过/开题驳回”全做成独立状态,而是用“当前阶段”+“最近审核结果”两个字段组合表达。比如 process_status=2 表示人在开题阶段,review_record 里最新一条 stage=2 的结果是 pass 还是 reject,决定 he 下一步是进入中期还是重新上传。
4.2 表结构:状态更新和文件记录一起落库
论文上传时更新 thesis_record 的 archive_path 和 process_status,同时往 file_record 插一条记录。这两步必须放在同一个事务里,为什么?因为答辩成绩发布之后,审计时需要看到完整的历史痕迹——哪一天上传了什么文件、哪一天导师给了什么意见,如果文件记录和进度更新各说各话,系统就失去了可信度。
CREATE TABLE `review_record` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `record_id` BIGINT NOT NULL, `reviewer_id` BIGINT NOT NULL, `stage` TINYINT NOT NULL, `result` TINYINT NOT NULL COMMENT '0驳回 1通过', `comment` VARCHAR(500) DEFAULT NULL, `review_time` DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE `file_record` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `record_id` BIGINT NOT NULL, `file_name` VARCHAR(255) NOT NULL, `file_path` VARCHAR(255) NOT NULL, `file_size` BIGINT NOT NULL, `stage` TINYINT NOT NULL, `upload_time` DATETIME DEFAULT CURRENT_TIMESTAMP );这里有一个现在就要想明白的问题:teacher 端要展示“待我审核”的列表,如果每次都在内存里计算该审谁,SQL 会写得很难看。正确做法是给 review_record 的 (record_id, stage, result) 建一个唯一索引,保证同一个阶段只有一条有效审核记录。驳回之后学生重新提交,旧记录保留,新插入一条 result=-1 的“待审”记录,列表查询只需要WHERE result = -1。
用一段 SQL 解释后端最核心的查询逻辑:统计每个学生的当前进度。这条语句用子查询和 LEFT JOIN,是论文里“系统实现”章的经典素材。
SELECT u.name AS student_name, t.title AS topic_title, r.process_status, r.archive_path FROM sys_user u LEFT JOIN thesis_record r ON r.student_id = u.id LEFT JOIN thesis_topic t ON t.id = r.topic_id WHERE u.role = 0 AND r.process_status = (SELECT MAX(process_status) FROM thesis_record r2 WHERE r2.student_id = u.id);写出这个查询你就能应对“那些没选题的学生为什么不在列表里”——因为 LEFT JOIN 从 sys_user 出发,没选题的学生 thesis_record 为 NULL,可以在 Service 层统一填一个“未选题”状态。这样前端首页的进度条就不用再做一次二分判断。
最后补一个数据一致性细节:用SELECT ... FOR UPDATE锁住选题操作,防止两个学生同时抢到同一个题目。这个在真实并发下是必修课,毕设里能写出来就是加分项。
SELECT * FROM thesis_topic WHERE id = #{topicId} AND status = 0 FOR UPDATE;拿到锁之后先查 status 是不是 0,是再 UPDATE 成 1,否则抛“题目已被选”异常。这里的锁要加事务,不然锁会自动释放,等于白写。
5. 联调避坑:域名白名单、跨域、上传超时和真机环境四连坑
5.1 request 合法域名校验导致真机请求失败
现象:开发工具里接口全通,一点“真机预览”就白屏,Network 面板看到所有 wx.request 的请求都是 fail。
原因:微信小程序真机运行时,所有网络请求必须走 HTTPS 且域名要在小程序后台配置到 request 合法域名里。本地 localhost 或局域网 IP 在开发工具里被放行,真机上直接拦截。
解决:答辩前用一台公网服务器部署后端,域名配置 SSL 证书,然后在 mp.weixin.qq.com 后台把域名加进 request 合法域名。临时替代方案是开发工具勾选“不校验合法域名”,但这只对预览调试有效,扫码体验版同样被卡。这个坑是一票否决级的,别赌老师现场不开真机。
5.2 跨域配置不全,POST 请求被 OPTIONS 挡住
现象:后端接口在浏览器或 Postman 测得好好的,小程序一调就报 “request: fail”,后端日志里出现大量 OPTIONS 请求没有 handler。
原因:小程序算跨域请求。Spring Boot 默认不放行跨域,需要配置WebMvcConfigurer的addCorsMappings,并且注意要允许 Authorization 头。
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }这里有两个参数容易填错:allowedOriginPatterns("*")是 Spring 5.3 以后替代allowedOrigins("*")的写法,写后者配合allowCredentials(true)会抛 IllegalArgumentException;maxAge(3600)表示预检请求一小时内不用重复发,否则每个请求都会先 OPTIONS 一次拖慢速度。
5.3 文件上传偶尔超时,代码却看不出问题
现象:无线网络下上传 5MB 的 PDF,小程序端转圈十几秒后提示 “uploadFile:fail timeout”,后端日志显示没收到请求。
原因:wx.uploadFile 默认超时时间 60 秒,但真正的瓶颈往往是前端把文件先读成 base64 再做请求,导致内存和耗电双重爆炸。
解决:不走 wx.request 传 base64,改用 wx.uploadFile 直接传本地临时文件路径,同时在后端 application.yml 里把 multipart 大小往上调,并重写上传逻辑的异常返回。上传成功后,后端回传文件访问路径,展示时用 wx.downloadFile 配合 FileSystemManager 缓存到本地,否则每次看论文预览都会重新下载。
另外注意:wx.uploadFile 的 header 里不能手动设置 Content-Type,框架会自动带上 multipart boundary,你一旦手动设置,后端就会解析不到 file 字段,报 “Required part 'file' is not present”。很多同学把 header 里的 Content-Type 从 application/json 改成别的,反而把自己坑了。
5.4 数据库中文乱码和小程序侧滚动条穿透
现象:导师在审核意见里写的中文变成问号,或者学生端页面下拉刷新时底部 tabBar 跟着滚上去。
原因:数据库连的 MySQL 和表的字符集不一致,JDBC 链接没带characterEncoding=utf8mb4;小程序只处理了 onPullDownRefresh,没同步 disableScroll 或没处理好滚动边界。
解决:建库时统一CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci,JDBC 里加useUnicode=true&characterEncoding=utf8mb4;小程序页面 json 里配置"disableScroll": false且在下拉刷新时先wx.stopPullDownRefresh()。乱码问题如果出现在已跑起来的库里,改完配置后要重启并重新插入数据,旧数据即使ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4也有风险,最好直接用 navicat 导出再导入一次。
5.5 本地服务已启动,小程序端却一直报网络异常
现象:后端 IDEA 里亮着绿色三角,swagger 能打开,但小程序 request 一直 “网络异常”。
原因:八成是手机和电脑不在同一个网段,或者防火墙把 8080 端口禁了。这个坑尤其在实验室和校园网环境频发。
解决:先手机浏览器访问http://电脑IP:8080/api/auth/login,能返回 JSON 说明网络通。不通就关掉 Windows 防火墙对 Java 的拦截,或者用ipconfig确认两台设备在同一网段。更省事的是直接用内网穿透工具,但注意这在上台答辩前还是得换回配置好的正式域名。
6. 论文与 PPT 收尾:把状态机和角色权限讲成答辩亮点
论文结构建议围绕“流程”而不是“技术”展开:绪论、需求分析、系统设计、数据库设计、系统实现、系统测试。PPT 控制在 12~14 页,按“题目背景、角色分析、ER 图、界面截图、核心代码、测试结果”的顺序走,不需要把每个接口都贴出来。
最值得讲透的是两个点。第一,选题并发控制。用SELECT ... FOR UPDATE解决两个学生同时选同一题的数据竞争,这是事务与锁的落地,不是空谈。第二,状态机驱动流程。process_status 字段让开题、中期、终稿、答辩各阶段在代码里可追溯,你能画出一个清晰的“状态流转图”,老师就会认定你确实做了一整个系统,而不是拼凑几个 CRUD 页面。
PPT 里放一段你真正跑通的核心代码,我建议放 JwtInterceptor 那个拦截器,因为代码短但能引出三个问题:token 存在哪里、怎么验证、过期怎么办。你自己能接住这三个问题,比背十页八股文管用。
答辩时准备一个 2 分钟 demo 脚本:先用学生账号登录 → 选题 → 上传开题报告 → 切到教师账号看到待审列表 → 审核通过 → 切回学生账号看到进度更新。这条链路走完,整个系统的价值就立住了。最后如果时间够,再演示一下重复选题被拦截,这就是你加了并发控制的证据。
最后说一个我的习惯:无论代码是不是自己从零写的,拿到项目后先干一件事——把上传文件的路径、数据库密码、小程序 AppID 全部换成自己的,再重新跑一遍完整流程,尤其要测“脱机状态下能不能访问”。这能提前暴露一半以上的答辩翻车点。希望这份落地思路对你有帮助,做完之后你会发现,它最大的价值不是代码本身,而是让你能从头到尾讲清楚一个线上系统是怎么被组织的。
本文还有配套的精品资源,点击获取