ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

微信小程序刷题系统设计与Spring Boot后端实践:从数据库到部署全解析

微信小程序刷题系统设计与Spring Boot后端实践:从数据库到部署全解析 简介这是一套基于微信小程序与 Spring Boot 的在线刷题系统完整工程面向 Java 全栈学习者及毕业设计、课程设计场景帮助理解小程序前端与后端接口的协作方式。压缩包共 1049 个文件、约 16MB核心代码包括 106 个 Java 后端文件、139 个 JS 脚本、122 个 Vue 页面以及 WXML/WXSS 小程序界面、JSON 配置与 SQL 数据库脚本PNG/SVG 图片多用于界面素材与示意图另含少量字体、图标等静态资源。资源还附带运行、安装、打包等批处理脚本和 Maven 相关配置便于本地快速启动环境。已有 26 人学习下载。通过这套项目可以掌握 Spring Boot 提供 RESTful API、管理题库与用户数据的方法也能借鉴前端登录交互、刷题反馈与页面布局逻辑工程将小程序端、后台管理端与后端服务分层组织对完成同类系统设计或理解微信小程序后端开发流程有直接的参考价值。1. 微信小程序刷题系统不是套个前端壳就能交差的活做过在线刷题类毕业设计或者小团队自研备考工具的人应该都有同感翻页卡顿、答题到一半进度丢了、错题本里永远少几道题这三件事几乎能把一个刷题项目的体验拖垮。标题里的“基于微信小程序的刷题系统的设计与实现springboot”本质上是一套前后端分离的标准结构——微信小程序端负责答题交互、倒计时和错题展示Spring Boot 后端负责题库管理、答题记录落库和用户鉴权中间靠 HTTPS 接口通信。它能解决的核心问题是让用户按试卷或按知识点刷题系统自动判分、记录错题、统计正确率避免“练了但不知道练得怎么样”的黑匣子状态。适合正在做课程设计、毕业设计或者想给团队内部快速搭一套答题练习平台的开发者参考。下面按我实际落地这个方案时的完整路径来讲。2. 数据库设计与 Spring Boot 核心接口先把五张表立住刷题系统看着功能不多但背后要撑住“试卷、题目、答题记录、错题本、用户”这五类数据缺一张表都会让后面的功能写得别扭。我见过不少项目直接一张表存所有题目、另一张表存所有答题记录结果跑通后发现错题本查不出来、统计正确率要靠遍历全部记录硬算。所以从建表开始就要按业务关系设计。2.1 五张核心业务表的结构设计与建表 SQL常见做法是拆成用户表、试卷表、题目表、答题记录表、错题表。题目与试卷是多对多关系一张题可能同时被多套模拟卷引用所以还要加一张中间关联表为了控制篇幅下面用paper_id直接挂在题目表上适合“一套卷一套题”的简单模型若要支持抽题组卷再把中间表补上即可。CREATE TABLE sys_user ( id bigint(20) NOT NULL AUTO_INCREMENT, openid varchar(64) NOT NULL COMMENT 微信openid唯一标识, nickname varchar(50) DEFAULT COMMENT 昵称, avatar varchar(255) DEFAULT COMMENT 头像URL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_openid (openid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE t_paper ( id bigint(20) NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL COMMENT 试卷/练习集名称, duration_minutes int(11) DEFAULT 30 COMMENT 答题时长分钟, total_score int(11) DEFAULT 100 COMMENT 总分, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT试卷表; CREATE TABLE t_question ( id bigint(20) NOT NULL AUTO_INCREMENT, paper_id bigint(20) NOT NULL COMMENT 所属试卷ID, type tinyint(4) NOT NULL COMMENT 1单选 2多选, content text NOT NULL COMMENT 题干, option_json text NOT NULL COMMENT 选项JSON如[{key:A,text:...}], answer varchar(10) NOT NULL COMMENT 标准答案多选用逗号分隔, analysis text COMMENT 答案解析, sort int(11) DEFAULT 0 COMMENT 题目顺序, PRIMARY KEY (id), KEY idx_paper (paper_id, sort) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT题目表;答题记录表是整套系统里写入压力最大的表每做一道题就会产生一条记录。如果用户反复刷同一套卷会不断插入新记录所以答案录表不需要唯一索引但要按user_id paper_id建联合索引否则“查询某用户某套卷的答题历史”会全表扫描。错题表必须做唯一约束防止用户连续答错同一题时插入多行错题数据。CREATE TABLE t_answer_record ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL, paper_id bigint(20) NOT NULL, question_id bigint(20) NOT NULL, user_answer varchar(10) NOT NULL COMMENT 用户提交答案, is_correct tinyint(4) NOT NULL DEFAULT 0 COMMENT 0错误 1正确, score decimal(10,2) DEFAULT 0.00, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_paper (user_id, paper_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT答题记录表; CREATE TABLE t_wrong_book ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL, question_id bigint(20) NOT NULL, answer_snapshot varchar(10) DEFAULT COMMENT 用户错误答案快照, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_question (user_id, question_id), KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT错题表;这里answer_snapshot很容易被忽略。没有它用户错题本里只能看到题目看不到当时自己选了什么复习价值直接砍半。所以建表时把错误答案快照一并存下来后续展示“你上次选了 A正确答案是 C”就有据可查。option_json用 TEXT 存 JSON 字符串而不是单独建选项表理由是刷题系统的选项结构固定、不需要高频编辑反序列化成本远低于多表 join 的复杂度。2.2 Spring Boot 接口分层与关键路径说明后端我一般采用 Controller-Service-Mapper 三层结构Controller 只做参数接收和结果封装Service 层集中写业务逻辑Mapper 层用 MyBatis-Plus 直接操作单表省去大量手写 XML。刷题系统里最典型的接口就四组登录换 token、拉试卷列表、拉题目列表、提交答案。RestController RequestMapping(/api) public class PaperController { Resource private PaperService paperService; GetMapping(/paper/list) public ResultListPaperVO list() { return Result.ok(paperService.listPapers()); } GetMapping(/paper/{id}/questions) public ResultListQuestionVO questions(PathVariable Long id) { return Result.ok(paperService.listQuestions(id)); } }Service 层要做的第一件事是把题目里的answer字段截掉。前端拉题时拿到标准答案就失去了刷题意义而且用户抓包能直接看到所有答案这属于低级翻车。常见做法是持久层查询后用 VO 对象转换VO 里不包含 answer 和 analysis只有交卷后才单独返回解析。public ListQuestionVO listQuestions(Long paperId) { ListTQuestion questions questionMapper.selectList( new LambdaQueryWrapperTQuestion() .eq(TQuestion::getPaperId, paperId) .orderByAsc(TQuestion::getSort)); return questions.stream().map(q - { QuestionVO vo new QuestionVO(); vo.setId(q.getId()); vo.setType(q.getType()); vo.setContent(q.getContent()); vo.setOptionJson(q.getOptionJson()); // 注意vo.setAnswer(...) 这行代码绝对不能出现 return vo; }).collect(Collectors.toList()); }这个截断逻辑放在 Service 层而不放在 SQL 里有实际原因某些统计接口需要在后端判断正确率如果 Mapper 层直接select answer那同一套TQuestion实体类就会在不同场景下出现“带不带答案”两种形态容易误用。换成 VO 后接口返回什么字段完全由 Controller 决定误带答案只可能是人为失误不容易形成系统性漏洞。2.3 微信小程序登录用 code 换 openid 然后签发 JWT小程序端wx.login()拿到的是一次性 code后端拿着 code 调微信接口换 openid随后用 JWT 维护会话状态。这个流程是微信生态下的固定套路但 JWT 的密钥和过期时间设置有不少讲究。// 小程序端 wx.login() 拿到 code 后传给后端 PostMapping(/auth/login) public ResultMapString, Object login(RequestBody LoginRequest req) { String code req.getCode(); // 1. 调微信接口code 换 openid String openid wechatService.code2Session(code); if (openid null) { return Result.error(登录失败code 无效); } // 2. 查用户表不存在则自动注册 SysUser user userMapper.selectOne( new LambdaQueryWrapperSysUser().eq(SysUser::getOpenid, openid)); if (user null) { user new SysUser(); user.setOpenid(openid); userMapper.insert(user); } // 3. 签发 JWT过期时间设为 7 天 String token Jwts.builder() .setSubject(String.valueOf(user.getId())) .setExpiration(new Date(System.currentTimeMillis() 7 * 24 * 3600 * 1000L)) .signWith(Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8))) .compact(); MapString, Object data new HashMap(); data.put(token, token); data.put(userId, user.getId()); data.put(nickname, user.getNickname()); return Result.ok(data); }这里最容易翻车的是 JWT 密钥太短。Keys.hmacShaKeyFor要求密钥长度至少 256 位很多人随手写一个abc123启动直接报错。另一个坑是setExpiration的过期时间不要设太久刷题系统用户一旦 token 永久有效换手机登录就会出现老设备上错题本与题库不同步的情况。7 天到期后小程序端拦截 401 自动跳登录页重新wx.login()静默换取新 token用户感知不到中断。3. 小程序端刷题交互落地请求封装、答题状态与本地缓存后端接口就绪后重头戏在小程序端。刷题页面的关键交互是进入页面拉题目、点击选项判断对错、答题过程中保留进度、倒计时结束自动交卷。微信小程序默认没有全局 axios所有请求走wx.request这决定了封装一个统一请求层是绕不开的第一步。3.1 request.js 统一封装baseUrl、token 注入与 401 处理// utils/request.js const request (url, method, data, needAuth true) { return new Promise((resolve, reject) { wx.request({ url: getApp().globalData.baseUrl url, method: method || GET, data: data || {}, header: { Content-Type: application/json, Authorization: needAuth ? wx.getStorageSync(token) : }, success(res) { if (res.statusCode 401) { wx.removeStorageSync(token) wx.navigateTo({ url: /pages/login/login }) reject(new Error(登录已过期)) return } if (res.statusCode 200 res.statusCode 300) { if (res.data.code 200) { resolve(res.data.data) } else { reject(new Error(res.data.message || 接口异常)) } } else { reject(new Error(res.data.message || 网络异常)) } }, fail(err) { reject(new Error(err.errMsg || 请求失败)) } }) }) } module.exports { request }needAuth参数很关键登录接口本身不能带 token否则会出现循环依赖。401 的处理直接清 token 跳登录页不留中间态。消息提示层我在项目里通常不放在 request.js 内部因为不同页面需要不同的错误文案统一弹 toast 会遮挡部分页面自定义错误展示。baseUrl 放在app.js的globalData里开发环境填http://localhost:8080上线前统一替换成正式域名避免全局搜索替换遗漏。3.2 答题页核心逻辑倒计时用服务端时间戳而非本地倒减答题页最常见的实现错误是用setInterval每秒减一这种做法在用户切后台、小程序被挂起时会直接失效。正确做法是进入页面时从后端拉取“试卷时长”用当前时间加上时长得到截止时间戳然后每秒用Date.now()计算剩余秒数。即使 setInterval 被系统冻结页面onShow恢复后也会自动校正。// pages/quiz/quiz.js Page({ data: { questions: [], currentIndex: 0, current: null, selected: [], remaining: 0, submitted: false }, onLoad(options) { this.paperId options.paperId this.endTime 0 this.loadQuestions() }, async loadQuestions() { const questions await request(/api/paper/${this.paperId}/questions, GET) this.setData({ questions }) // 拉取试卷详情拿到总时长 const paper await request(/api/paper/${this.paperId}/info, GET) const durationMs paper.durationMinutes * 60 * 1000 // 以服务器返回时间为基准避免前端本机时间不准 this.endTime paper.serverTime durationMs this.setData({ current: questions[0], remaining: Math.floor(durationMs / 1000) }) this.startTimer() }, startTimer() { this.timer setInterval(() { const remaining Math.max(0, Math.floor((this.endTime - Date.now()) / 1000)) this.setData({ remaining }) if (remaining 0 !this.data.submitted) { this.submitPaper() } }, 500) }, selectOption(e) { const { value } e.currentTarget.dataset const { current, selected } this.data if (current.type 1) { this.setData({ selected: [value] }) } else { const idx selected.indexOf(value) if (idx -1) { this.setData({ selected: selected.filter(v v ! value) }) } else { this.setData({ selected: [...selected, value] }) } } }, next() { if (!this.data.selected.length) { wx.showToast({ title: 请先作答, icon: none }) return } // 先暂存当前题答案再进入下一题 this.saveAnswerToCache() const nextIndex this.data.currentIndex 1 if (nextIndex this.data.questions.length) { this.submitPaper() } else { this.setData({ currentIndex: nextIndex, current: this.data.questions[nextIndex], selected: [] }) } }, saveAnswerToCache() { const batch wx.getStorageSync(pendingAnswers) || [] batch.push({ questionId: this.data.current.id, userAnswer: this.data.selected.join(,), paperId: this.paperId }) wx.setStorageSync(pendingAnswers, batch) }, submitPaper() { clearInterval(this.timer) this.flushBatch() }, async flushBatch() { const batch wx.getStorageSync(pendingAnswers) || [] if (!batch.length) return try { await request(/api/answer/batchSubmit, POST, { answers: batch }) wx.removeStorageSync(pendingAnswers) this.setData({ submitted: true }) wx.redirectTo({ url: /pages/result/result?paperId${this.paperId} }) } catch (e) { wx.showToast({ title: 交卷失败请重试, icon: none }) } }, onUnload() { clearInterval(this.timer) } })这里有两个设计取舍要说明。第一单题交而不是全卷交是考虑到用户答题过程中随时可能退出小程序如果只在最后统一提交中途断网或误关页面会丢掉全部进度用pendingAnswers缓存每道题的答案页面卸载时触发flushBatch保证进度落到服务端。第二submitPaper里先clearInterval再提交防止倒计时归零后重复触发交卷同时submitted标志做二次保险。batchSubmit接口的设计配合缓存机制后即使网络请求失败数据依然留在本地缓存里下次进来可以重新提交。这个“本地暂存 批量落库”方案比每点一次选项就发一个请求的做法更稳健也大幅减少了后端写入压力。3.3 错题本与答题记录的联动提交后按结果写错题表交卷成功后后端拿到批量答案需要逐题判断对错并写入t_answer_record同时把错题写入t_wrong_book。这里最容易犯的错误是在“判错”时直接覆盖已有错题记录或重复插入导致唯一索引冲突。Transactional(rollbackFor Exception.class) public void submitAnswers(Long userId, Long paperId, ListAnswerItem answers) { for (AnswerItem item : answers) { TQuestion question questionMapper.selectById(item.getQuestionId()); String standard question.getAnswer(); boolean correct standard.equals(item.getUserAnswer()); TAnswerRecord record new TAnswerRecord(); record.setUserId(userId); record.setPaperId(paperId); record.setQuestionId(item.getQuestionId()); record.setUserAnswer(item.getUserAnswer()); record.setIsCorrect(correct ? 1 : 0); answerRecordMapper.insert(record); if (!correct) { TWrongBook wrong wrongBookMapper.selectOne( new LambdaQueryWrapperTWrongBook() .eq(TWrongBook::getUserId, userId) .eq(TWrongBook::getQuestionId, item.getQuestionId())); if (wrong null) { wrong new TWrongBook(); wrong.setUserId(userId); wrong.setQuestionId(item.getQuestionId()); wrong.setAnswerSnapshot(item.getUserAnswer()); wrongBookMapper.insert(wrong); } else { // 已存在则更新快照让错题本始终保留最近一次错误答案 wrong.setAnswerSnapshot(item.getUserAnswer()); wrongBookMapper.updateById(wrong); } } } }写事务时必须加Transactional否则同一套题里前几题插入成功、后几题失败会导致数据不一致。这个场景在真实运行中很常见用户交了一套 50 题的卷中途后端抛了一个字段超长异常前 30 题已经落库后 20 题丢掉了用户重交又产生一堆重复记录。事务加上之后要么全成功要么全回滚配合小程序端的“交卷失败请重试”提示体验才立得住。4. 避开这 4 个开发期的坑多数人不是功能没写完是死在了环境和数据一致性上这章是来还愿的。刷题系统功能本身不难但我在完整跑通这个项目的过程中有几个坑在开发期会连续卡住两三天写出来让后来的人少走弯路。4.1 真机预览请求直接 fail没有任何返回现象开发者工具里接口全通一上真机预览所有wx.request直接走 fail 回调控制台看到的只有request:fail接口状态码都是 0。原因是微信小程序真机环境强制要求请求域名必须是 HTTPS 且在后台配置了合法域名开发者工具默认勾选了“不校验合法域名”真机不认这个设置。解决开发阶段在开发者工具右上角「详情 - 本地设置」里勾选“不校验合法域名、web-view业务域名、TLS 版本以及 HTTPS 证书”这样真机预览可以请求局域网内的 Spring Boot 服务。上线前在微信公众平台配置 HTTPS 域名并上传证书。这里提醒不要偷懒跳过 HTTPS 配置即使你只想做毕设演示评审时如果现场网络环境特殊域名没配好会让整个演示翻车。4.2 倒计时在页面切后台后出现时间漂移现象用户答题中途切出去看微信消息回来发现倒计时少了十几秒甚至提前自动交卷了。原因是小程序在后台时setInterval被系统挂起回调不执行即便恢复执行基于本地递减的计时器也已经落后于真实时间。解决倒计时一律基于时间戳差值计算而不是每次回调减一。页面onHide时记录当前剩余秒数onShow时用this.endTime - Date.now()重新计算并重置setInterval。这样的逻辑同时解决了一个隐藏问题用户手动改了手机时间导致倒计时错乱因为endTime是服务端返回的绝对时间戳与本地时钟调节无关。4.3 快速连续交卷时答题记录丢失或重复现象用户做完一套题快速点到交卷后端只收到一半记录或者同一道题出现两条完全相同的记录。原因是小程序端使用批量缓存后交卷动作没有做“提交中”的状态锁用户连续点击交卷按钮两个flushBatch并发执行后一个覆盖前一个的缓存。解决前端在flushBatch执行期间加一个isSubmitting标志提交中禁用按钮并忽略重复调用后端对t_answer_record不做唯一约束但建议给user_id question_id create_time的组合增加业务校验或者用 Redis 做幂等标记。幂等键用userId paperId timestamp同一秒内重复提交直接返回第一次的结果。4.4 错题本里看不到答错的题排查一圈发现是答案格式不匹配现象用户明明答错了错题表里就是没有记录手动比对数据库发现user_answer存的是A,B标准答案存的是AB。原因是我在生成答案时多选用的是逗号分隔而前端把用户选择的多选项也转成了带逗号的字符串两边格式不一致导致equals永远为 false于是所有多选题都被判定为错误。解决统一答案格式多选答案统一存A,B服务端判断时先拆分排序再比较不计较用户点击选项的顺序。java public static boolean checkAnswer(String standard, String userAnswer) { List s Arrays.asList(standard.split(,)); List u Arrays.asList(userAnswer.split(,)); Collections.sort(s); Collections.sort(u); return s.equals(u); }这个问题隐蔽在“大多数单选的逻辑没问题只有多选出错”容易让人误以为是随机 bug。加入排序比较后A、C 和 C、A 视为相同答案更符合刷题场景的判分预期。 ## 5. 上线前最后一公里部署顺序、自测清单与小程序审核注意 整个项目开发完成到真正能交出去或者上线中间还有几个环节不能省。我自己每次交付前都会按固定顺序过一遍省得到现场手忙脚乱。 先在本地跑通“Spring Boot 小程序”的完整链路。数据库用 MySQL 8.x后端服务的 application.yml 里注意数据库时区配置serverTimezoneAsia/Shanghai 不配的话答题记录的 create_time 会差 8 小时。然后用 Maven 打包mvn clean package -DskipTests这是默认跳过测试的打包命令避免本地测试用例因环境不一致导致构建失败。 部署时有两个选择小项目用 java -jar xxx.jar 直接起规范一点用 Docker 挂载配置文件。Docker 部署时注意把 MySQL 连接串里的 localhost 改成容器网络内的服务名不少人第一次部署翻车就是漏了这一步。上线小程序前把 globalData.baseUrl 从 http://192.168.x.x:8080 换成正式 HTTPS 域名并在微信公众平台「开发管理 - 服务器域名」里把 request 合法域名填上。 接口自测我建议列一张表逐项打勾不要只测“能通”就以为没问题 | 接口 | 关键参数 | 风险点 | 自测预期 | |------|---------|--------|---------| | POST /api/auth/login | code | code 一次性重复调用必失败 | 首次登录自动注册二次登录返回同一 userId | | GET /api/paper/list | 无 | 返回列表不含题目答案 | 接口响应里不出现 answer 字段 | | GET /api/paper/{id}/questions | paperId | 未登录请求应返回 401 | 不带 token 时被拦截 | | POST /api/answer/batchSubmit | answers 数组 | 空数组、非法 questionId | 空数组返回成功但不落库 | | GET /api/wrong/list | userId | 分页是否正确快照字段非空 | 错题列表含最近一次错误答案 | 审核方面微信小程序对“在线刷题”类目没有特殊资质要求但涉及用户自定义上传题目内容时需要留意内容安全接口接入 security.msgSecCheck 对用户提交的文本做校验即可。整个项目的验收重点通常放在“答题是否实时判分、错题本是否准确、倒计时是否可靠”三个核心体验点上。 最后一句话留给我自己的习惯交项目前在“弱网 切后台 重复点击交卷”三个场景下各跑一遍多数翻车都在这三个操作里。希望帮到你。 p a hrefhttps://download.csdn.net/download/2501_94315688/92613927 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p
返回列表