news 2026/9/25 1:39:42

基于微信小程序与Spring Boot的刷题系统开发与部署全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于微信小程序与Spring Boot的刷题系统开发与部署全解析

简介:基于微信小程序与Spring Boot的在线刷题系统完整源码包,适合想要学习前后端分离开发、Java后端接口设计以及小程序页面实现的开发者参考。压缩包一共包含一千零四十九个文件,体积大约十六兆字节,具体涵盖一百零六个Java源码文件、一百二十二个Vue组件文件、一百三十九个JavaScript脚本,以及六十四个wxml与六十个wxss前端页面文件、两百三十一个png图片资源、SQL数据库脚本和启动构建批处理文件,整个项目目录清楚,便于直接导入开发环境进行学习。目前已有二十六人学习浏览,资源覆盖了题库管理、用户交互、后端RESTful接口、数据存储与检索等关键环节,同时保留了原有的配置备份与组件样式参考。通过通读源码并执行构建脚本,可以快速梳理小程序端与Spring Boot服务端的请求响应流程,并在此基础上修改复用,搭建面向校园或企业的刷题练习平台。

1. 基于微信小程序的刷题系统+Spring Boot:解压源码后真正要解决的四件事

每年毕业设计季节,这类“基于微信小程序的刷题系统的设计与实现+springboot.rar”资源包会大量出现。解压后通常是三样东西:Spring Boot 后端工程、微信小程序前端目录、一份设计文档。不少人以为导入 IDE 就能跑,实际上登录、抽题、判分、错题本这几条链路都要自己重新对一遍才能闭环。这个方向解决的不是“写一个刷题工具”,而是用微信小程序承载练习交互、用 Spring Boot 提供接口与数据存储的一套前后端分离系统。适合正在做毕设选题、或想低成本验证一个小程序教育产品的开发者。动手前先想清楚四件事:题库数据怎么建模、登录态怎么交换、判分放在哪一端、部署后域名怎么过小程序校验。把这四条理顺,源码包里的代码才接得住你自己的业务。

2. Spring Boot 后端先把题库数据底座立住:表设计与接口拆分

2.1 刷题系统的数据建模:5 张表与答案字段的设计理由

刷题系统表面是 CRUD,实际上数据关联比普通表单复杂。我一般拆五张表:用户表、题库表、题目表、练习记录表、错题表。用户表承接微信 openid;题库表只放题库元信息;题目表存题干、选项、答案、解析;练习记录表记录每次答题明细;错题表是冗余表,方便个人中心直接展示。

题目表是核心,字段设计直接决定判分逻辑好不好写。下面是一个常见的建表结构:

CREATE TABLE `question` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `bank_id` bigint(20) NOT NULL COMMENT '所属题库ID', `type` tinyint(4) NOT NULL COMMENT '1单选 2多选 3判断', `content` text NOT NULL COMMENT '题干', `options` text COMMENT '选项JSON,如[{"key":"A","text":"Java是编译型语言"}]', `answer` varchar(20) NOT NULL COMMENT '答案:单选填A;多选填ABD;判断填T或F', `analysis` text COMMENT '题目解析,答完展示', `score` int(11) NOT NULL DEFAULT '1' COMMENT '分值', `create_time` datetime NOT NULL, PRIMARY KEY (`id`), KEY `idx_bank` (`bank_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='题目表';

几个容易踩的点:多选答案不要用逗号分隔,直接存“ABD”,前端和后端都按字符串包含关系判断,解析成本最低。options 用 JSON 字符串存而不是单独建选项表,对刷题系统足够,避免 join 太多。题干里可能有富文本图片,所以 content 用 text 而不是 varchar。判断题的答案用 T/F 而不是 true/false,和单选统一成字符串比较逻辑。

题库表保持轻量,只维护 name、subject(学科分类)、question_count 这类冗余统计字段。每次新增题目后更新 question_count 可以做,也可以不管,首页展示用 count 聚合查询替代。记录表和错题表都带 user_id,但重点要记得建联合索引:

CREATE TABLE `record` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL, `bank_id` bigint(20) NOT NULL, `question_id` bigint(20) NOT NULL, `user_answer` varchar(20) DEFAULT NULL COMMENT '用户提交的答案', `is_correct` tinyint(4) NOT NULL COMMENT '1对 0错', `create_time` datetime NOT NULL, PRIMARY KEY (`id`), KEY `idx_user_bank` (`user_id`, `bank_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='练习记录表';

错题表设计上有一个细节:不要每次答错都 insert 一条,否则错题本越滚越乱。常见做法是加唯一索引uk_user_question(user_id, question_id),答错时做 insert ... on duplicate key update,把 wrong_times 加一、update_time 刷新。这样个人中心能看到每道错题错了几次,比单纯列一堆重复记录更有价值。

2.2 接口按小程序页面反推:首页、练习页、错题页各自要什么

后端接口数量不必追求多,按小程序页面反推最省事。首页要题库列表,练习页要随机抽题,答题完成要交卷判分,个人中心要用户信息与错题本。小程序端每一个页面调用什么接口,在写后端时同步列出来,前后端联调就不会出现接口对不上的情况。

前端页面接口路径Method说明
首页/api/wx/bank/listGET返回启用状态的题库列表
登录/api/wx/loginPOST用 wx.login 的 code 换后端 token
练习页/api/wx/question/randomGET按题库 ID 随机抽 N 道题
答题提交/api/wx/record/submitPOST提交答案,返回得分与错题列表
错题本/api/wx/wrong/listGET分页查询当前用户错题
个人中心/api/wx/user/infoGET用户信息与练习统计

这里建议把接口路径都放在/api/wx/前缀下,管理端接口用另一个前缀/api/admin/,后续做权限拦截时按前缀切分即可。练习页的抽题接口最值得花心思。常见源码包喜欢直接写SELECT * FROM question ORDER BY RAND() LIMIT 10,数据量小的时候没问题,题库到了几千题就会明显变慢,而且用户会有很大概率抽到重复题。

抽题接口的职责应该包括:按题库过滤、排除当前用户已经做过的题、限制每次抽取数量。真正做到这三件事,需要拿到用户已做题目 ID 列表,再用 NOT IN 排除。后面第五章会专门讲这个坑。

2.3 把登录和随机抽题接口写出来:Spring Boot 常用实现与参数配置

先看登录接口。小程序端调用 wx.login 拿到一个临时 code,后端拿这个 code 去微信服务端换取 openid。正式实现里这一步要请求微信的 jscode2session 接口,以下代码保留了这个过程,方便你替换成自己的真实接口:

@PostMapping("/api/wx/login") public Result<Map<String, Object>> login(@RequestBody LoginReq req) { // 用 code 调用微信接口换取 openid // 这里省略 HTTP 请求细节,实际替换成 HttpClient 调用即可 WxSession session = wxService.code2Session(req.getCode()); User user = userMapper.findByOpenid(session.getOpenid()); if (user == null) { user = new User(); user.setOpenid(session.getOpenid()); user.setNickname("微信用户"); user.setCreateTime(new Date()); userMapper.insert(user); } String token = JwtUtil.createToken(user.getId(), 7 * 24 * 60 * 60 * 1000L); Map<String, Object> map = new HashMap<>(); map.put("token", token); map.put("userId", user.getId()); return Result.ok(map); }

登录逻辑里最关键的一点是:用户第一次进来就自动注册,不需要单独走注册流程。这是微信小程序项目和传统 Web 项目的最大区别。token 用 JWT 生成,过期时间设为 7 天,与小程序本地缓存保持一致。实际源码包可能用 localStorage 或微信 storage,无所谓,但两端过期时间必须同步,否则会出现小程序里显示已登录、后端却返回 401 的玄学问题。

随机抽题接口是刷题系统最重要的接口,参数设计要能覆盖两种场景:正常练习抽新题、错题重刷抽全部题。下面这个实现是常见的合体写法:

@GetMapping("/api/wx/question/random") public Result<List<QuestionVO>> random( @RequestParam Long bankId, @RequestParam(defaultValue = "10") Integer limit, @RequestParam(defaultValue = "false") Boolean reviewMode, @RequestHeader("Authorization") String token) { Long userId = JwtUtil.getUserId(token); List<Long> excludeIds = Collections.emptyList(); if (!reviewMode) { // 排除已做过的题,避免反复抽到同一批 excludeIds = recordMapper.findDoneQuestionIds(userId, bankId); } List<QuestionVO> questions = questionMapper.selectRandomByBank(bankId, limit, excludeIds); return Result.ok(questions); }

对应 Mapper XML 里的 SQL 大概是下面这样,注意 NOT IN 的数量如果过多,可以改成 NOT EXISTS:

SELECT * FROM question WHERE bank_id = #{bankId} <if test="excludeIds != null and excludeIds.size() > 0"> AND id NOT IN <foreach collection="excludeIds" item="id" open="(" separator="," close=")"> #{id} </foreach> </if> ORDER BY RAND() LIMIT #{limit}

参数说明:limit是本次练习的题目数量,小程序端可以固定传 10 或 15,后端要做上限校验,建议最大不超过 50,否则一次查询压力大且答题体验差;reviewMode为 true 时忽略 excludeIds,用于错题重刷;token 从请求头取,后端拦截器要放行/api/wx/login,其余接口必须校验。数据库配置上,IDEA 新建 Spring Boot 项目时建议选 Spring Boot 2.7.x 加 JDK 8 或 11,很多老源码包还在用 javax 命名空间,直接配 JDK 17 加 Boot 3 会编译不过,这个坑后文专门说。

3. 微信小程序端把刷题手感做出来:登录、出题与提交判分

3.1 小程序登录与 token 缓存:wx.login 到 Spring Boot 的完整链路

小程序端的登录链路和传统表单登录完全不同。用户打开小程序时不需要输入账号密码,前端调用 wx.login 拿到临时 code,传给后端,后端用 code 换 openid 并返回自定义 token,前端把 token 存进 wx.setStorageSync。整个过程用户无感知,但开发者要处理三个细节:请求封装、token 过期、缓存清理。

先封装一个全局请求方法,避免每个页面重复写 wx.request。下面这份代码是我在刷题项目里常用的模板,统一处理了登录态和错误提示:

const request = (url, method = 'GET', data = {}) => { const token = wx.getStorageSync('token') return new Promise((resolve, reject) => { wx.request({ url: getApp().globalData.baseUrl + url, method: method, data: data, header: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '' }, success: (res) => { if (res.statusCode === 200) { resolve(res.data) } else if (res.statusCode === 401) { // token 过期,清掉本地缓存并回到登录页 wx.removeStorageSync('token') wx.removeStorageSync('expireTime') wx.navigateTo({ url: '/pages/login/login' }) reject(res) } else { wx.showToast({ title: '请求失败', icon: 'none' }) reject(res) } }, fail: (err) => reject(err) }) }) } module.exports = { request }

wx.login 的调用时机最好放在 App.onLaunch 里,页面 onLoad 时先检查本地 token 是否存在。看到这里你可能会问:为什么不直接每次启动都重新登录?因为 wx.login 每次生成的 code 只能用一次,频繁调用会浪费微信接口配额,也没必要。更合理的做法是第一次登录后把 token 和过期时间都存下来,下次启动时判断是否在有效期内。

缓存时间怎么设?我一般在前端存一个 expireTime 字段,和后端 JWT 的 7 天保持一致:

wx.setStorageSync('token', data.token) wx.setStorageSync('expireTime', Date.now() + 7 * 24 * 60 * 60 * 1000)

在请求封装里加一个前置判断:expireTime 小于当前时间就直接跳登录页,不发请求。这里要特别注意一个坑:有些人只存 token 不存过期时间,导致后端 JWT 明明过期了,前端还拿着 token 去请求,等到 401 再处理,体验就多了一次无效请求。设置缓存时间不是为了省事,是要让前端和后端的失效时点尽量一致。

3.2 刷题页渲染与选项交互:单选、多选、切题回填

题库列表页比较简单,重点在刷题页面。页面结构通常分三块:顶部进度条、题目卡片区、底部操作栏。题目卡片区用 swiper 或 scroll-view 呈现,我建议用 scroll-view 配合纵向滚动,而不是 swiper,因为题目内容可能很长,用户需要自由滚动看题干,swiper 的滑动判断容易误触。

题目选项的渲染不要用原生 radio-group。原生单选框的样式很难调,刷题场景需要展示选中态、正确态、错误态三种颜色,自定义 view 加 bindtap 更灵活。这样可以顺带把“微信小程序单选框”这个交互场景下的样式问题绕开。核心结构如下:

<view class="question-card" wx:for="{{questions}}" wx:key="id"> <view class="q-title">{{index + 1}}. {{item.content}}</view> <view class="q-options"> <view wx:for="{{item.options}}" wx:for-item="opt" wx:for-index="optIndex" wx:key="key" class="option {{answers[item.id] === opt.key ? 'active' : ''}}" >onOptionTap(e) { const { qIndex, optIndex } = e.currentTarget.dataset const q = this.data.questions[qIndex] const opt = q.options[optIndex] const questionId = q.id if (q.type === 2) { // 多选:按字母顺序拼接答案,如 A B D -> ABD const old = this.data.answers[questionId] || '' const keys = old ? old.split('') : [] const idx = keys.indexOf(opt.key) if (idx >= 0) { keys.splice(idx, 1) } else { keys.push(opt.key) } this.setData({ [`answers.${questionId}`]: keys.sort().join('') }) } else { // 单选/判断:直接覆盖 this.setData({ [`answers.${questionId}`]: opt.key }) } }

切题回填的答案存在 data.answers 里,key 是题目 ID,value 是用户已选的答案。这样用户滑动到下一题再回来,答案不会丢。页面销毁时如果有未提交的答案,可以给个弹窗提示“还有题目未作答”,防止用户误退出丢进度。

3.3 提交判分与错题写入:前后端职责划分

判分放前端还是后端?答案是后端。前端判分看起来省一次请求,但用户只要用开发者工具改一下本地代码,就能把 score 改成满分,刷题记录就失真了。正确的做法是前端把用户所有答案传给后端,后端拿着数据库里的正确答案逐题比对,返回得分、正确数、错误题号列表。

后端 submit 接口的核心实现:

@PostMapping("/api/wx/record/submit") public Result<SubmitResult> submit(@RequestBody SubmitReq req, @RequestHeader("Authorization") String token) { Long userId = JwtUtil.getUserId(token); int totalScore = 0; int correctScore = 0; List<Long> wrongIds = new ArrayList<>(); List<Question> questions = questionMapper.selectBatchIds(req.getQuestionIds()); for (Question q : questions) { totalScore += q.getScore(); String userAnswer = req.getAnswers().get(q.getId().toString()); boolean correct = q.getAnswer().equalsIgnoreCase(userAnswer); if (correct) { correctScore += q.getScore(); } else { wrongIds.add(q.getId()); } recordMapper.insert(new Record(userId, q.getBankId(), q.getId(), userAnswer, correct)); } // 答错的题写入错题本,用 upsert 方式累计错误次数 if (!wrongIds.isEmpty()) { wrongBookMapper.upsertBatch(userId, wrongIds); } SubmitResult result = new SubmitResult(); result.setTotalScore(totalScore); result.setCorrectScore(correctScore); result.setWrongQuestionIds(wrongIds); return Result.ok(result); }

这段逻辑有两点值得说明。第一,selectBatchIds 查出来的题目列表顺序与传入的 questionIds 不一定一致,所以判分时不能依赖遍历顺序;但最后返回 wrongQuestionIds 时保持数据库查询顺序无所谓,前端是根据题目 ID 去匹配的。第二,record 表记录的是每次答题行为,而错题本存的是汇总结果。如果用户连续两次答错同一道题,record 表会有两条记录,wrong_book 表只把 wrong_times 变成 2,这是刻意设计的。

前端拿到结果后,用 wx.showModal 展示得分,然后跳转到结果页。结果页除了得分,还要把错题解析列出来。这里我习惯把 analysis 字段在抽题接口里一并返回,省去结果页再逐题请求的麻烦。刷题的手感好不好,很大程度取决于答完能不能立刻看到解析,而不是只给一个红色叉号。

4. 管理后台、打包部署与发布配置:从源码到线上的最后一公里

4.1 管理端权限边界:题目维护接口为什么要单独拆 admin

刷题系统不能只有学生端,还得有题目维护入口。但管理端和小程序端不能共用同一套接口,否则任何人都能通过小程序接口把题目和答案改掉。常见做法是在后端增加一个简单的管理端模块,用账号密码登录,签发带有角色的 JWT。

接口前缀区分是第一步:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AdminAuthInterceptor()) .addPathPatterns("/api/admin/**") .excludePathPatterns("/api/admin/login"); } }

AdminAuthInterceptor 做的事很直观:从请求头取 token,解析出 role 字段,如果 role 不是 ADMIN 就返回 403。管理端的登录接口可以复用 JwtUtil,只是多塞一个 role 进去:

Map<String, Object> claims = new HashMap<>(); claims.put("role", "ADMIN"); String token = JwtUtil.createToken(admin.getId(), claims, 2 * 60 * 60 * 1000L);

管理端 token 的过期时间我建议设短一点,2 小时足够,安全边界更好。小程序端的 token 是 7 天。如果你拿到手的源码包把管理端和小程序端接口写在一起,优先按路径前缀切分重构,而不是在 Controller 里加 if 判断当前用户角色。分离之后,管理端的 Swagger 文档也可以单独开一组,避免把内部接口暴露给小程序端用户。

4.2 打包部署:maven 打 jar、Docker 启动和 JVM 参数

后端部署的常规路径是 Maven 打包加 Docker 运行。先确认 pom.xml 里没有把源码包自带的本地 JAR 用 systemPath 引用,这类问题在网上下载的源码包里很常见,一旦用了本地依赖,打出来的包换一台机器就起不来。遇到这种情况,把本地 JAR 装进本地 Maven 仓库,或者改成公共仓库里的坐标。

打包命令和启动命令:

mvn clean package -DskipTests java -jar target/exam-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod

生产环境不建议直接 java -jar 挂在前台,用 Docker 更便于管理。一个参考 Dockerfile:

FROM openjdk:8-jre-alpine COPY target/exam-0.0.1-SNAPSHOT.jar /app/exam.jar WORKDIR /app EXPOSE 8080 ENTRYPOINT ["java", "-Xms256m", "-Xmx512m", "-jar", "exam.jar"]

JVM 参数这里给一个参考值:个人毕设项目或小型练习平台,1 核 2G 的云服务器足够支撑几十个并发练习场景,Xms 和 Xmx 设 256m 到 512m 即可。如果刷题量集中在某一时间段,比如晚上八点,可以后续接入 Redis 做接口频控,而不是一上来就堆机器内存。

数据库连接串和 Redis 地址要放到 application-prod.yml 里外置,不要在 jar 包里写死。这点听起来是常识,但我见过太多源码包把 localhost 数据库地址留在配置里,部署到服务器后项目能启动,但接口一查数据库就报连接拒绝。外置配置的常见写法是启动时加参数覆盖:

java -jar exam.jar --spring.datasource.url=jdbc:mysql://内网地址:3306/exam?useUnicode=true&characterEncoding=utf8 --spring.datasource.username=root --spring.datasource.password=***

4.3 小程序发布前配置:合法域名、HTTPS 证书和导航栏设置

小程序端上线前有三个配置绕不开。第一,后端必须用 HTTPS 域名,且域名要备案。微信公众平台后台的「开发管理 - 开发设置 - 服务器域名」里,把 request 合法域名加上,例如https://api.example.com。开发阶段可以在微信开发者工具里勾选“不校验合法域名、web-view、TLS 版本以及 HTTPS 证书”,但体验版和正式版都会强制校验,所以本地跑通后第一件事就是把线上域名配好。

第二,小程序里的所有请求地址不要写成 IP。微信小程序对 request 域名有严格校验:必须是 HTTPS、必须备案、IP 地址直接拒绝。因此开发时如果连本地后端,用开发者工具的“不校验合法域名”开关;真机预览时,要在同一局域网用电脑 IP 加端口,但也只能开发调试,无法正式发布。

第三,页面顶部导航栏。小程序默认导航栏高度在不同机型上不一样,尤其是有刘海的 iPhone,导航栏会更高。如果刷题页顶部有固定进度条,建议直接用 navigationStyle 默认导航栏,让微信自己处理高度适配,省掉自定义导航栏带来的胶囊按钮遮挡问题。如果你确实要自定义导航栏,务必用 wx.getMenuButtonBoundingClientRect() 动态获取胶囊位置来计算布局,不要写死 44px。

app.json 里一个完整的配置片段:

{ "pages": [ "pages/index/index", "pages/practice/practice", "pages/result/result", "pages/wrong/wrong", "pages/user/user" ], "window": { "navigationBarTitleText": "在线刷题", "navigationBarBackgroundColor": "#ffffff", "navigationBarTextStyle": "black" } }

导航栏标题在 practice 页可以动态改,比如进入《Java基础》题库后把标题改成题库名。这个用 wx.setNavigationBarTitle 实现,在页面 onLoad 里根据参数调用即可。

5. 刷题系统落地避坑:5 个让我返工过的典型问题

5.1 真机预览请求失败:request 合法域名与 HTTPS

现象:微信开发者工具里接口一切正常,一换成真机预览,所有 wx.request 全部进入 fail 回调,控制台报“不在以下 request 合法域名列表中”。

原因:开发者工具默认勾选了“不校验合法域名”,所以本地调试没问题;真机上微信强制读取公众平台配置的合法域名列表,没配置就直接拦掉,而且是 network 层的拦截,代码里根本拿不到后端响应。

解决:开发期间在公众平台把线上域名加到 request 合法域名列表;如果只是临时真机调试,可以在开发者工具详情里勾选“不校验合法域名”,但扫码预览用的是开发版,体验版和正式版仍然强制校验。另一个容易被忽略的点是:域名必须是 HTTPS,且证书链完整,不能用自签名证书。所以部署后端时要提前申请 SSL 证书,不能拖到最后一刻。

5.2 @RequestBody 接收 null:小程序 Content-Type 不一致

现象:后端接口写的是@RequestBody LoginReq req,小程序端明明传了{code: "xxx"},后端却打印出 req 为 null,或者 req.code 为 null,有时干脆报 400 Bad Request。

原因:这是个黑匣子问题。小程序 wx.request 在部分基础库版本里,如果 header 中没有显式指定 content-type,POST 请求会按 application/x-www-form-urlencoded 发送,而 Spring Boot 的 @RequestBody 期望 application/json,两边解析方式不一致,body 就变成空的。

解决:在封装的 request 方法里强制设置请求头:

header: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '' }

同时后端接口里不要又是 @RequestBody 又接收表单参数。统一用 JSON 传参最省事。排查时先在 Network 面板看请求头里的 content-type 是什么,再决定改前端还是改后端。

5.3 Long 型 ID 精度丢失:JSON 序列化成 Number 的坑

现象:题库列表接口返回的 bankId 是 1656925691822124546,点击进入练习页后,发现加载出来的题目属于另一个题库,或者直接查不到题目。

原因:Java 后端用 Long 表示主键,雪花算法生成的 ID 超过 JavaScript Number 的安全整数范围 2^53 - 1。JSON 序列化时把 Long 当 number 输出,小程序拿到后精度已经丢失,末尾几位被截断。

解决:让后端把 Long 类型的 ID 字段序列化成字符串。Jackson 全局配置是对 Long 和 long 类型用 ToStringSerializer:

@Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() { return builder -> builder.serializerByType(Long.class, ToStringSerializer.instance) .serializerByType(Long.TYPE, ToStringSerializer.instance); } }

如果你不想动全局配置,也可以在实体类的 id 字段上加@JsonSerialize(using = ToStringSerializer.class)。注意:这个坑在联调时不容易发现,因为前端打印 ID 看起来是正常的,只有点击跳转后行为不对,排查时要先检查两边拿到的 ID 字符串是否一致。

5.4 随机抽题又慢又重复:ORDER BY RAND 的下限与替代

现象:题库 5000 道题,每次抽 10 道,刚做过的题过两题又出现;题库越大接口响应越慢,从几十毫秒涨到几百毫秒。

原因:ORDER BY RAND()在 MySQL 里会对全表数据排序生成随机数,数据量一大性能直线下降。且抽题逻辑没有排除已做过的题,导致重复题目反复出现。

解决:至少做到按题库过滤加 NOT IN 排除已做题目。如果题库超过一万题,可以考虑更高效的随机策略:先查出当前题库里未做过的 ID 范围,再取随机主键偏移量。只按已完成记录排除的方式还不够,因为如果用户做了 4000 题,NOT IN 列表会非常长,这条 SQL 也会变慢。我通常的做法是在题库表加一个 done_count 字段做冗余计数,或者用 Redis 的 SET 存已完成题目 ID,查询时直接从 Redis 取差集。对毕设级别的数据量,NOT IN 已经够用,但不要写成ORDER BY RAND()就交差。

5.5 提交按钮被胶囊和底部横条遮挡:导航栏与安全区适配

现象:答题页底部 fixed 定位的“提交”按钮,在 iPhone 上被屏幕底部的 home indicator 挡住一部分;自定义顶部导航栏时,标题文字被右上角胶囊按钮盖住。

原因:微信小程序的导航栏和底部安全区高度不是固定值。不同机型顶部状态栏高度不同,全面屏手机底部还有安全区。写死 px 的布局在特定机型上必翻车。

解决:顶部导航栏用默认样式,标题通过 wx.setNavigationBarTitle 动态设置。底部操作栏预留安全区,CSS 里加:

.submit-bar { position: fixed; left: 0; right: 0; bottom: 0; padding-bottom: env(safe-area-inset-bottom); background-color: #ffffff; }

如果页面还需要自定义顶部导航,用小程序的wx.getMenuButtonBoundingClientRect()获取胶囊按钮位置,动态计算导航栏高度和内容区 padding-top。这个适配代码建议在 App.onLaunch 里计算一次,存到 globalData,避免每个页面重复调用。卡在 UI 适配上的时间,往往比写业务逻辑还多,提前做适配能省很多返工。

6. 把刷题系统当产品打磨:组卷策略、防刷限流与数据闭环验证

刷题系统跑通之后,最值得投入的是出题策略。固定随机抽题会让用户觉得“题目没有针对性”,常见改进是让组卷按难度比例出题。在 question 表增加 difficulty 字段(1 简单,2 中等,3 困难),抽题时分别从三个难度等级里取一定比例。后端可以拆成三次查询,也可以一次查出候选集后在内存里做权重分配,数据量不大时后者更灵活:

List<Question> easy = questionMapper.selectRandomByDifficulty(bankId, 1, 6, excludeIds); List<Question> medium = questionMapper.selectRandomByDifficulty(bankId, 2, 3, excludeIds); List<Question> hard = questionMapper.selectRandomByDifficulty(bankId, 3, 1, excludeIds);

配合组卷策略,还要给抽题接口加上频控,否则一个用户疯狂调用 random 接口,能把题库刷空。轻量做法是在拦截器里用本地缓存记录同一 userId 一分钟内的请求次数,超过 30 次直接返回 429。等用户量上来再替换成 Redis 分布式限流。防刷做完,最后验证数据闭环:登录新用户,抽题答对两题答错一题,检查 record 表和 wrong_book 表的数据变化,答错题在错题本里出现,答对两次后错题本对应记录被移除。这套数据核对流程我每次改完判分逻辑都跑一遍,比盯着页面看靠谱得多。如果你也在改一套类似的刷题源码,先把这三处补上,体验会明显比原生源码包好一截,希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/25 1:38:20

I2C信号测量实战:万用表、示波器与ACK波形判读指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/25 1:37:36

ESP32-C5深度解析:Wi-Fi 6双频并发与OFDMA/TWT工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/25 1:37:33

互动式座位图实现指南:Canvas渲染、状态机与避坑实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/25 1:36:51

卫星通信入网验证与系统测试:链路预算、误码率与避坑清单

简介&#xff1a;这是一份关于卫星通信网络建立、入网验证与系统测试的完整讲解课件&#xff0c;面向通信工程、网络运维及卫星通信初学者&#xff0c;用于系统理解新地球站从入网申请、验证测试到开通运行的整套流程。内容涵盖新地球站入网运行程序、地球站必备工作特性&#…

作者头像 李华