简介:一套基于微信小程序的刷题系统毕业设计源码案例,面向在线刷题学习场景,整合SpringBoot后端与Vue管理后台,适合计算机相关专业学生作为课程设计或毕业设计参考。系统实现用户登录、题目动态加载、在线答题、自动评分、错题记录与成绩统计等核心功能,前端采用微信小程序原生框架,后端提供用户管理、题库管理、答题逻辑处理等模块,并预留智能推荐扩展思路。整个压缩包共773个文件,大小25.76MB,包含101个Java源文件、93个Vue组件、89个JavaScript脚本、23个wxss样式、22个wxml页面模板,以及14个XML配置、SQL脚本、构建批处理与Maven封装文件,目录结构完整清晰,便于直接导入开发环境学习。目前已有118人学习下载,尤其适合希望快速掌握小程序与SpringBoot前后端分离开发流程的读者,对照源码理解模块划分与接口调用方式。
1. 微信小程序刷题系统:这不是“题库展示”,而是一条完整的刷题闭环
最近很多来问毕设选题的同学,一开口就是“想做在线刷题”。如果你也卡在这个标题上——基于微信小程序的刷题系统,配一个 Spring Boot 后端,我建议你先把定位想清楚:刷题系统不是把题目列出来让用户翻,而是围绕“做答、判题、错题、重复练习”这四件事把链路跑通。微信小程序解决的是触达和多端同步,Spring Boot 解决的是题库管理、判题正确性和学习记录,两者拼起来,就是一个能演示、能测试、能上线的毕业设计案例。
这套选题适合三类人:想写课程设计或毕业设计的在校生,需要给培训或考试场景做轻量刷题工具的产品同学,以及第一次接触前后端分离项目的后端初学者。接下来我会按自己做过的方案,把表结构、接口、小程序登录与答题闭环、上线前容易翻车的点,一步步给你拆开。
2. 技术选型拆解:为什么刷题场景离不开微信小程序和 Spring Boot
2.1 小程序端的三点优势:免安装、原生登录态、发布链路短
微信小程序在刷题场景里几乎是绕不开的选择。它比 H5 多一层微信登录态,用户点进来不用注册;比 App 少一道安装门槛,课程群里丢个码就能打开。更重要的是,小程序原生组件里 scroll-view、radio-group、textarea 这些都能直接服务于答题交互,做单选题、多选题、主观题都不需要额外引 UI 框架。对于 Spring Boot 后端来说,前端只要按接口规范对接,后端不用关心页面渲染,分工很干净。
从技术栈的选择上,小程序端我一般会坚持“原生 + 微信开发者工具”而不是 uni-app。原因很实际:uni-app 虽然能一套代码跑多端,但刷题页面会用到较多的原生交互(比如滑动切换题目、答题卡跳转),uni-app 在微信端的兼容处理会多出一层黑匣子;毕业设计答辩时,老师更愿意看到你对原生小程序生命周期、setData、缓存机制有真实理解。如果你毕业设计文档里要写“前端技术选型”,原生小程序也比跨端框架更好讲清楚。
顺着这个思路,后端选 Spring Boot 也不是因为它“热门”,而是因为它能帮你把“用户身份”“题库管理”“判题服务”拆成清晰的分层模块。Spring Boot 的自动化配置能把一个 Web 服务在十几分钟内拉起来,加上 MyBatis-Plus 操作数据库、Redis 做会话和排行榜缓存,整个刷题系统后端可以控制在六到八张表以内,规模刚好适合作为毕业设计,又不至于像纯 CRUD 那样缺少技术深度。
2.2 后端工程骨架:controller、service、mapper 三层与启动流程
刷题系统的后端如果按“能展示、能答辩、能扩展”三个标准来搭,我一般会分成四个部分:
- controller 层:只接收参数、调用 service、返回统一结果,不写业务判断。
- service 层:承载判题逻辑、错题生成、刷题记录的更新策略。
- mapper 层:通过 MyBatis-Plus 的 BaseMapper 直接完成单表操作,多表统计再写 XML。
- common 层:统一返回体 Result、全局异常处理、JWT/Token 工具类。
实际工程里,新建一个 Spring Boot 项目后我第一件事不是写代码,而是先改 pom.xml,把依赖范围固定下来。一个刷题后端最常用的依赖组合是这样的:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>注意这个组合里没有放 spring-boot-starter-security,因为刷题系统不需要复杂的密码流程,登录态用 JWT 自己管理更直观。MyBatis-Plus 3.5.3 这套版本我用得比较多,和 Spring Boot 2.7 配合稳定;如果你新建项目时选了 Spring Boot 3.x 或更高版本,要注意 javax.servlet 已经迁到 jakarta.servlet,第 5 章避坑部分会专门讲。
依赖确定后,工程目录建议按功能包名组织:
com.example.exam ├── ExamApplication.java ├── common │ ├── Result.java │ └── GlobalExceptionHandler.java ├── controller │ ├── AuthController.java │ ├── QuestionController.java │ └── RecordController.java ├── service │ ├── UserService.java │ ├── QuestionService.java │ └── RecordService.java ├── mapper │ ├── UserMapper.java │ ├── QuestionMapper.java │ └── RecordMapper.java └── entity ├── User.java ├── Question.java └── Record.java这个包结构看起来很常规,但它的好处在于答辩和后续替换都非常顺手。比如你想引入 Redis 做热点题排行榜,只需要在 service 层加一个缓存组件,controller 不用动。想给小程序端加一个章节练习题,只需要在 controller 层新增一个接口,前端按新的接口对接。
2.3 前后端联调约定:统一返回体和 Token 让小程序端不猜接口
小程序端和后端之间最容易扯皮的就是返回体不一致。后端返回一个 Map,小程序端就得在每次请求里判断 data 下面是什么结构。我的做法是后端统一给出一个 Result 类,每个接口都必须返回这个结构:
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> error(int code, String message) { Result<T> r = new Result<>(); r.code = code; r.message = message; return r; } }这里的参数含义分别是:code 用来区分业务状态,200 代表成功,401 代表登录过期,其他值按业务自己扩展;message 给前端直接弹提示用;data 才是真正的业务数据。小程序端在封装 request 方法时,只需要统一判断 code 是否为 200,再返回 data,页面层拿到的永远是干净的数据。
Token 的交互链路上,我没有用微信提供的 session_key 直接做鉴权,而是让后端用自己的密钥签发一个 JWT。流程是:小程序调用 wx.login 拿到 code,后端拿着 code 去微信接口换取 openid,然后生成 token 返回给小程序;小程序后续请求在 header 里带 Authorization,后端用一个拦截器统一解析。这样做的优势有两个:第一,后端对用户身份的判定不依赖微信 session 的有效期;第二,token 过期时间可以由你控制,刷题系统里我一般设 7 天,用户一周内不用重复登录。
3. 后端核心实现:题库表结构、判题逻辑与刷题接口参数
3.1 三张核心表:题目表、选项表、刷题记录表怎么设计
刷题系统的数据模型不需要很复杂,但表关系要足够支撑“一套题反复刷”。我见过不少同学把题目字段塞在一张表里,选项用逗号分隔存成一个字符串,这样写 demo 很快,但一旦做错题记录、统计正确率就会非常别扭。我一般会把题库拆成三张核心表:题目表、选项表、刷题记录表。
第一张是题目表 question 表,它只存题目本身的描述、类型、正确答案和难度分数,不存选项文本。第二张是选项表,用 question_id 关联题目,保存 A/B/C/D 文本。第三张是刷题记录表,记录某个用户某次作答的答案、对错和耗时。为什么选项要单独建表?因为一套卷子里的选项个数不固定,单选题四个选项、判断题只有对错、多选题五个选项,选项作为独立表才能在不改表结构的情况下扩展题型。
直接给一份可以抄作业的建表 SQL:
CREATE TABLE `t_question` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '题目ID', `type` tinyint NOT NULL DEFAULT '1' COMMENT '题型 1单选 2多选 3判断', `content` text NOT NULL COMMENT '题干', `correct_answer` varchar(255) NOT NULL COMMENT '正确选项,多选用逗号分隔', `analysis` text COMMENT '答案解析', `difficulty` tinyint DEFAULT '1' COMMENT '难度 1-5', `score` int DEFAULT '1' COMMENT '分值', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='题目表'; CREATE TABLE `t_option` ( `id` bigint NOT NULL AUTO_INCREMENT, `question_id` bigint NOT NULL COMMENT '题目ID', `option_key` varchar(16) NOT NULL COMMENT '选项标识 A/B/C/D', `option_text` varchar(500) NOT NULL COMMENT '选项内容', `sort_order` int DEFAULT '0' COMMENT '排序', PRIMARY KEY (`id`), KEY `idx_question_id` (`question_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选项表'; CREATE TABLE `t_record` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL COMMENT '用户ID', `question_id` bigint NOT NULL COMMENT '题目ID', `user_answer` varchar(255) NOT NULL COMMENT '用户作答', `is_correct` tinyint NOT NULL COMMENT '0错误 1正确', `duration` int DEFAULT '0' COMMENT '耗时秒数', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_question` (`user_id`,`question_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='刷题记录表';这里有几个参数要说明:type 字段用 tinyint 而不是字符串,是为了让后端在判断题型时直接用整数比较,避免“单选”“多选”这类中文值在前后端对接时出现编码问题;correct_answer 在多选题里用逗号分隔,比如 “A,C”,这样判断题(正确答案是 A 或 B)也能复用同一个字段。t_record 里故意加了 duration 字段,它看上去不起眼,但统计用户平均做题速度、生成学习报告都需要它。索引方面,t_record 的联合索引 idx_user_question 是关键,否则用户刷到一万条题目后,查询“这道题我做过没有”就会很慢。
3.2 题目接口设计:列表接口、提交接口与判题的三个边界条件
接口设计上,刷题系统至少需要五个接口:登录、获取题目列表、提交答案、获取错题本、清空错题。这里重点看两个最常见的接口,一个是带条件的题目列表接口,一个是提交答案的判题接口,它们的参数设计直接影响小程序端工作量。
题目列表接口我一般设计成:
@GetMapping("/question/list") public Result<PageResult<QuestionVO>> list( @RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "20") Integer size, @RequestParam(required = false) Integer type, @RequestParam(required = false) Integer difficulty) { return Result.ok(questionService.pageQuery(page, size, type, difficulty)); }参数说明:page 和 size 控制分页,默认 1 和 20,两者都加上限校验,size 最大不能超过 50,防止小程序端一次拉取过多题目;type 和 difficulty 是筛选条件,不传就查全部。注意不要把小程序的页面滚动深度当成分页参数传过来,直接内存里维护当前页号更稳。
提交答案接口是整个系统的核心,它的判题逻辑看起来只是“比对字符串”,但实际有三个边界条件容易出错:多选题顺序、判断题的表现形式、以及重复提交。这三个点都会让判题结果前后不一致。我常用的实现方式是把答案规范成有序字符串再比较:
@PostMapping("/record/submit") public Result<SubmitResult> submit(@RequestBody SubmitRequest request, @RequestAttribute Long userId) { Question question = questionService.getById(request.getQuestionId()); if (question == null) { return Result.error(404, "题目不存在"); } boolean correct = compareAnswer(question.getCorrectAnswer(), normalizeAnswer(request.getAnswer())); Record record = new Record(); record.setUserId(userId); record.setQuestionId(question.getId()); record.setUserAnswer(normalizeAnswer(request.getAnswer())); record.setCorrect(correct); record.setDuration(request.getDuration()); recordService.save(record); SubmitResult result = new SubmitResult(); result.setCorrect(correct); if (!correct) { result.setCorrectAnswer(question.getCorrectAnswer()); result.setAnalysis(question.getAnalysis()); } return Result.ok(result); } private boolean compareAnswer(String correct, String userAnswer) { if (correct == null || userAnswer == null) { return false; } String[] correctArr = correct.split(","); String[] userArr = userAnswer.split(","); if (correctArr.length != userArr.length) { return false; } Arrays.sort(correctArr); Arrays.sort(userArr); return Arrays.equals(correctArr, userArr); } private String normalizeAnswer(String answer) { if (answer == null) { return ""; } String[] arr = answer.toUpperCase().split(","); Arrays.sort(arr); return String.join(",", arr); }这段代码能跑通单选、多选和判断三类题型。逻辑说明:前端传来的答案是用户点选的选项标识组成的字符串,比如多选题选了 A、C 就传 “A,C”。后端先 normalizeAnswer 做强转大写并按字母排序,再比较。这里的用意很深:多选题不管用户点击顺序是 A-C 还是 C-A,排序后都会一致,不会出现“明明选对了却判错”。correctAnswer 为空时,compareAnswer 会返回 false,这样库里的脏数据至少不会产生异常。SubmitResult 里只对错误答案返回正确选项和解析,比直接把正确答案暴露给前端更适合刷题记忆。
提交接口的参数表如下:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| questionId | Long | 是 | 题目 ID |
| answer | String | 是 | 用户作答,多选用逗号分隔,如 "A,C" |
| duration | Integer | 否 | 做题耗时秒数,用于统计学习报告 |
顺带一提错题本接口,不用单独建错题表,从 t_record 里查 is_correct=0 记录聚合即可。很多毕业设计为了显得功能多会单独建一张错题表,反而导致每次提交要同时维护两张表,容易出现错题记录和刷题记录不一致。我建议错题本就用查询来实现,等有“收藏错题”需求时再单独落表。
正确率统计直接用一条 SQL 就能拿下来:
SELECT COUNT(*) AS total, SUM(is_correct = 1) AS correct_count FROM t_record WHERE user_id = #{userId};4. 小程序端从登录到答题的最小闭环:wx.login、题目渲染与答案提交
4.1 登录链路:wx.login 换 code,后端再换 openid 并返回 JWT
小程序端要做的第一件事就是登录。很多初学者会直接在 setData 里存 openid,甚至把 openid 放到请求参数里,这是非常危险的。openid 等于用户唯一身份标识,一旦被拿到,别人可以直接伪造请求。
正确流程是:用户进入小程序后,先调 wx.login 获取临时 code;把这个 code 发给后端;后端用 code 去微信的登录凭证校验接口换 openid;后端生成 token 返回给小程序;小程序把 token 存到 storage,后续请求在 header 里带头。整个过程 openid 始终不经过前端落盘。
对应的小程序端登录代码:
const login = () => { wx.login({ success: async (res) => { if (!res.code) { wx.showToast({ title: '登录失败', icon: 'none' }); return; } const loginResult = await request({ url: '/api/auth/login', method: 'POST', data: { code: res.code, nickname: '微信用户' } }); if (loginResult.code === 200) { wx.setStorageSync('token', loginResult.data.token); wx.setStorageSync('userInfo', loginResult.data.userInfo); } else { wx.showToast({ title: loginResult.message, icon: 'none' }); } } }); };这里有两个参数要注意:code 是临时凭证,有效期只有五分钟,所以后端必须马上拿去换 openid,不能把 code 存库;nickname 是可选字段,如果小程序端不做头像昵称填写,后端就用默认值创建用户,这样不会影响后续刷题记录的用户关联。
request 函数里还需要统一带上 Authorization 头,封装方式如下:
const request = (options) => { return new Promise((resolve, reject) => { const token = wx.getStorageSync('token'); wx.request({ url: getApp().globalData.baseUrl + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'Authorization': token ? `Bearer ${token}` : '', 'Content-Type': 'application/json' }, success: (res) => { if (res.data.code === 401) { wx.removeStorageSync('token'); login(); return; } resolve(res.data); }, fail: (err) => reject(err) }); }); };这段封装里值得留意的是 401 处理:token 过期后不是弹个错误就完事,而是清掉本地 token 后重新调用 login,用户几乎感觉不到登录被中断。Content-Type 必须写成 application/json,否则 Spring Boot 的 @RequestBody 拿不到参数,这是小程序端最常见的联调翻车点之一。
如果你所在的项目是培训机构、驾考这类需要手机号的场景,可以用微信的“手机号快捷验证组件”拿手机号,那需要在微信公众平台开通对应接口权限,个人主体不支持,一般毕业后你所在的公司主体才有。毕业设计阶段不建议在这上面花时间,用 openid + token 已经足够演示完整流程。
4.2 刷题页面:一题一屏的交互与答题状态管理
题目页面我建议做成“一题一屏”而不是一页长列表。这样刷题时专注度高,交互也简单:滑动或点击“下一题”切换,点击选项选中,点击提交由后端判题。页面结构大致是:顶部显示答题进度,中间是题干和选项列表,底部是提交按钮。
这里的关键状态有三个:当前题目索引 currentIndex、用户选择 answerMap、题目数据 questions。每次点击选项先更新当前题的选中项,切换题目时再把选中项填入 answerMap。用原生小程序语法实现如下:
Page({ data: { questions: [], currentIndex: 0, currentQuestion: {}, selectedKeys: [], answerMap: {}, totalCount: 0 }, async onLoad() { const res = await request({ url: '/api/question/list?page=1&size=20', method: 'GET' }); if (res.code === 200) { const questions = res.data.records; this.setData({ questions, totalCount: questions.length, currentQuestion: questions[0] }); } }, onOptionTap(event) { const key = event.currentTarget.dataset.key; const q = this.data.currentQuestion; let selected = this.data.selectedKeys; if (q.type === 1) { selected = [key]; } else { const idx = selected.indexOf(key); if (idx > -1) { selected.splice(idx, 1); } else { selected.push(key); } } this.setData({ selectedKeys: selected }); }, onNext() { const nextIndex = this.data.currentIndex + 1; if (nextIndex >= this.data.totalCount) { wx.showToast({ title: '已是最后一题', icon: 'none' }); return; } const answered = Object.assign({}, this.data.answerMap); answered[this.data.currentQuestion.id] = this.data.selectedKeys.join(','); this.setData({ currentIndex: nextIndex, currentQuestion: this.data.questions[nextIndex], selectedKeys: answered[this.data.questions[nextIndex].id] ? answered[this.data.questions[nextIndex].id].split(',') : [], answerMap: answered }); }, onSubmit() { const q = this.data.currentQuestion; if (!this.data.selectedKeys.length) { wx.showToast({ title: '请先选择答案', icon: 'none' }); return; } request({ url: '/api/record/submit', method: 'POST', data: { questionId: q.id, answer: this.data.selectedKeys.join(','), duration: 20 } }).then((res) => { if (res.code === 200) { wx.showModal({ title: res.data.correct ? '回答正确' : '回答错误', content: res.data.correct ? '继续加油' : `正确答案:${res.data.correctAnswer}\n${res.data.analysis || ''}`, showCancel: false, success: () => this.onNext() }); } }); } });这段代码里最容易出错的是单选和多选共用状态数组 selectedKeys。我的处理方式是:单选时把数组重置为只包含当前点击的那一个;多选时先判断已经存在就移除。onOptionTap 里的 event.currentTarget.dataset.key 是选项的标识,需要在 wxml 的选项 view 上通过>async loadWrongQuestions() { const res = await request({ url: '/api/record/wrong', method: 'GET' }); if (res.code === 200) { const groupMap = {}; res.data.forEach((item) => { const dateKey = item.createdAt.substring(0, 10); if (!groupMap[dateKey]) { groupMap[dateKey] = []; } groupMap[dateKey].push(item); }); this.setData({ wrongGroups: groupMap }); } }
这里按日期分组只是展示层的处理,数据还是来自后端的 t_record 查询。groupMap 这种对象结构在 setData 时需要整体赋值,注意最好不要直接改 this.data.wrongGroups 再 setData,而是先构造好新对象再一次性赋值。如果错题本数据量很大,可以改成只加载最近 20 条,并加一个“加载更多”按钮分页拉取,但这个功能的优先级不高,毕业设计答辩时能把错题展示出来已经足够完整。
5. 上线前必看的避坑清单:域名校验、并发覆盖与版本兼容
5.1 小程序请求一直失败:明明用开发者工具能打开网页,request 却报 10002
现象:小程序真机预览时,所有请求都失败,console 提示 request:fail 或 url not in domain list,错误码 10002。
原因:微信小程序对请求域名有强制校验,正式体验版只能请求已在 mp 后台配置的合法域名,而且必须是 HTTPS。本地开发时能打开,是因为开发者工具里勾选了“不校验合法域名”。这个机制让很多第一次写小程序的同学卡好多天。
解决:开发阶段,在微信开发者工具的“详情-本地设置”里勾选“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”。部署上线前,在小程序管理后台把后端接口域名加到 request 合法域名。如果你没有备案域名,本地联调阶段可以用局域网 IP 加端口配合“不校验域名”;到了生产环境,必须换成 HTTPS 域名,否则审核必被拒。这个问题和 Spring Boot 后端本身没有关系,但最容易让前后端互相甩锅。
5.2 Spring Boot 3.x 的 javax 与 jakarta 冲突,启动直接 ClassNotFound
现象:用 Spring Initializr 生成最新版 Spring Boot 3.4 项目,引入自己的工具类后启动失败,异常里出现 javax.servlet.ServletException 或 ClassNotFoundException: javax.servlet.Filter。
原因:Spring Boot 3 把 Servlet API 从 javax 迁到了 jakarta 命名空间。网上很多资料和老项目代码还是 import javax.servlet.*。直接把旧代码复制进来,Maven 可能同时引入 javax.servlet-api 和 spring-boot-starter-web,两个容器相关的 API 并存,导致类加载冲突。很多同学看到“springboot版本太高”相关的报错就不知所措,其实根因就是命名空间迁移。
解决:如果不想改代码,最简单的办法是创建项目时把 Spring Boot 版本降到 2.7.x,并使用 JDK 8 或 11。如果非要在 Spring Boot 3.x 上做,就把所有 import javax.servlet.* 改成 import jakarta.servlet.*。还有一个容易忽略的地方:拦截器里用到 HttpServletRequest 的也全部要换成 jakarta 的包。刷题系统里的登录拦截器正好会踩到这个,写的时候要注意。
5.3 并发提交导致记录覆盖:同一道题提交两次,错题状态被“洗掉”
现象:用户快速连点提交按钮,数据库里同一个 (user_id, question_id) 出现多条记录,或者后一次提交把前一次的正确状态覆盖了,统计正确率时数据对不上。
原因:如果 service 层用“先查后改”的方式写记录,两个请求同时到达会出现丢失更新:请求 A 查到 is_correct=0,请求 B 也查到 is_correct=0,A 改为正确写回,B 也把同一条改为错误写回,最终结果被最后提交覆盖。这不是微信小程序端的 bug,而是后端接口没有做幂等。
解决:两种做法。第一,给 t_record 加唯一索引 (user_id, question_id, created_at 的分钟级时间戳),从数据库层面限制同一秒重复提交;第二,更推荐的做法是提交接口幂等:同一题只允许保存一次,重复提交时走 UPDATE 而不是 INSERT。伪代码如下:
int count = queryCount(userId, questionId); if (count == 0) { recordService.save(record); } else { recordService.updateLatest(userId, questionId, correct, userAnswer); }注意,这里 queryCount 和 save 之间仍有微小窗口,真正严谨的做法是加唯一索引兜底。毕业设计如果想体现“并发意识”,可以在答辩文档里写清楚这个幂等策略,并配合前置唯一键说明。
5.4 数据库时间少了 8 小时,刷题记录时间全部偏移
现象:小程序端展示的“上次做题时间”比实际少了 8 小时,或者 t_record 里 created_at 存的是本地时间,但查出来却是 UTC 时间。
原因:MySQL 的 JDBC 连接串里没有写 serverTimezone,Spring Boot 2.x 又默认把数据库时间按 JVM 时区转换。多数国内服务器是 Asia/Shanghai,但本地开发机可能是其他时区,导致写入和读取各偏一次。这个问题不怪 MySQL,也不怪 Spring Boot,纯粹是连接参数没配全。
解决:在 application.yml 的数据库连接参数里显式指定时区:
url: jdbc:mysql://localhost:3306/exam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false并把 MySQL 的全局时区设为 +08:00:
SET GLOBAL time_zone = '+08:00';这两步做完,时间字段前后就一致了。另外,时间字段统一用 datetime,不要用 timestamp,避免 2038 年问题。虽然比较远,但答辩时提一句能加分。
5.5 页面白屏或渲染卡顿:setData 一次塞了太多数据
现象:题目列表加载后,页面白屏或者滑动很卡,打开调试面板发现 setData 的数据结构里有大量嵌套字段。
原因:微信小程序的 setData 有性能和大小限制,单次 setData 数据过大或者路径层级太深,都会导致渲染失败。直接用后端返回的嵌套对象(比如把 options 塞进 question 再塞进 questions 数组)一次 setData,数据嵌套就会越来越深。
解决:数据进入小程序端后先做一次“扁平化”。每题只保留 id、type、content、optionList 四个字段,option 里的 sort_order、question_id 等字段全部删掉。展示时用“当前题”单独 setData,而不是把整个数组替换进页面。上面第 4 章的代码里我用的就是这个策略:questions 存全量数据,但 currentQuestion 单独控制渲染,这样页面数据流很干净,也不会出现白屏。
6. 交付前的最后一步:接口验证、HTTPS 部署与一个小技巧
系统开发完不要直接点“上传”,我会先做一轮接口验证。刷题系统的核心链路是登录、选题、提交、查错题,这四个动作先用 curl 跑通,再上小程序端,能省下一大半联调时间。比如提交答案的接口,在本地直接用测试 token 验证:
curl -X POST http://localhost:8080/api/record/submit \ -H "Content-Type: application/json" \ -H "Authorization: Bearer test-token" \ -d '{"questionId":1,"answer":"C,A","duration":12}'返回里能看到 correct 字段是 true 还是 false,以及答案规范化后是否和正确选项一致。这一步过了,再打开开发者工具调页面。
部署时我习惯用 profile 区分环境。application-dev.yml 放本地数据库和日志,application-prod.yml 放云数据库和线上日志。构建命令固定为:
mvn clean package -DskipTests拿到 jar 后放到云服务器,用 nohup 启动:
nohup java -jar exam.jar --spring.profiles.active=prod > exam.log 2>&1 &nginx 里只需要一条反向代理规则,把 /api 指到后端服务:
location /api { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }小程序端的 baseUrl 可以在 app.js 里做一个环境开关,开发环境指向局域网 IP,生产环境指向 HTTPS 域名,这样切换环境时不用改业务代码。
我自己做这类系统时,最后总会花十分钟用开发者工具的“真机调试”把答题闭环过一遍,确认切题、提交、错题弹窗、再登录这四条路径没问题,再走提审。刷题系统的技术难点不多,但每一环都值得在交付前亲手验证一遍。希望帮到你。
本文还有配套的精品资源,点击获取