资讯详情

微信小程序刷题系统实战:SpringBoot+原生小程序双端协同开发

📅 2026/10/10 7:30:56 | 华诺云谱 👁 阅读
微信小程序刷题系统实战:SpringBoot+原生小程序双端协同开发
简介本资源是一个基于微信小程序与Spring Boot框架实现的在线刷题系统完整项目面向Java后端开发初学者、小程序全栈学习者及课程设计/毕业设计学生解决移动端轻量级学习平台快速搭建与前后端协同开发实践问题。压缩包共1049个文件涵盖106个Java后端服务类含Controller、Service、Entity等、122个Vue组件与139个JS逻辑脚本、64个WXML页面结构与60个WXSS样式文件以及231张PNG界面截图和161个SVG图标完整呈现小程序前端Spring Boot后端的工程结构包体大小为16MB结构清晰含build/run/install三阶段bat脚本及mvnw构建支持。目前已有26人学习下载。资源提供可直接运行的完整源码、典型模块如用户登录、题库管理、答题反馈的前后端对应实现、配套运行视频作者已公开并包含.bak备份文件便于版本比对适合用于理解微信小程序与Spring Boot RESTful接口集成、题库数据建模与高并发答题场景下的基础优化思路。1. 微信小程序刷题系统落地实录SpringBoot后端原生小程序双端协同不是Demo是能跑通的交付物这不是一个“Hello World”式的小程序教学项目而是真实交付过、带完整用户路径闭环的刷题系统——从微信登录获取手机号、题目随机抽选、答题计时、错题自动归集到后台管理题库分类、难度标签、答案解析富文本编辑全部跑在 SpringBoot 3.1.0 MyBatis-Plus 3.5.3 MySQL 8.0 架构上。我去年帮三个教育类创业团队做过类似系统发现90%的所谓“毕设源码”卡在「真机登录失败」「题库查不到数据」「小程序提交答案后后端收不到JSON」这三关。而 weixin273 这个包它把最硬的几块骨头都啃下来了小程序端用wx.login()wx.getPhoneNumber()双阶段鉴权后端用RequestBody精准接收小程序wx.request的 POST 数据不是application/x-www-form-urlencoded题库表设计预留了is_random和difficulty_level字段连「单选题选项乱序显示」这种细节都在QuestionService.java里做了Collections.shuffle()封装。适合正在做毕业设计、需要快速验证业务逻辑、或想拿一个可二次开发基线代码的 Java 后端/小程序全栈新手——你不需要重写登录流程不用调试跨域更不用猜为什么wx.request的header里必须带Authorization: Bearer xxx。它已经调通了就差你改个数据库连接地址。2. 项目结构解剖SpringBoot 模块划分与小程序工程目录映射关系2.1 后端模块拆解modules 目录下的四个核心子模块weixin273 的 SpringBoot 工程采用标准多模块结构根目录下pom.xml声明了parent和modules这是比单体 jar 更易维护的实践。四个子模块分工明确weixin273-common存放全局异常处理器GlobalExceptionHandler、统一返回对象ResultT、JWT 工具类JwtUtil含generateToken(User user)和parseToken(String token)、以及RedisCache封装注意它没用 Spring Cache 抽象而是直接StringRedisTemplate操作避免初学者被Cacheable注解绕晕weixin273-entity实体类全部在此关键字段命名直白——QuestionEntity有question_content题干、option_a~option_d选项、correct_answer正确答案存 A/B/C/D 字符、analysis解析TEXT 类型weixin273-mapperMyBatis-Plus 的Mapper接口和 XML 映射文件QuestionMapper.xml里有if testdifficultyLevel ! nullAND difficulty_level #{difficultyLevel}/if这种动态 SQL说明它支持按难度筛选题目weixin273-service业务层QuestionService提供getRandomQuestions(int count, Integer difficultyLevel)方法内部调用questionMapper.selectList(new QueryWrapperQuestionEntity().eq(is_random, 1).orderByAsc(id).last(LIMIT count))——这里有个血泪经验last(LIMIT)是为兼容 MySQL若你换 PostgreSQL 必须改成page.setSize(count)UserService实现了loginByCode(String code)通过https://api.weixin.qq.com/sns/jscode2session调用微信接口换取openid再查库或新建用户。提示weixin273-service的UserServiceImpl中loginByCode方法第 47 行wxResponse.getOpenid()后直接user.setOpenid(openid)但没做user.setUnionid(wxResponse.getUnionid())。如果你的业务需绑定同一用户在多个公众号的 ID得手动补上 unionid 字段并建索引。2.2 小程序端目录结构miniprogram/下的三层职责分离小程序源码位于miniprogram/目录结构清晰非app.js全局配置即pages/页面级逻辑app.js全局onLaunch中调用wx.login()获取 code并立即wx.request发送给后端/api/user/login接口globalData存储token和userInfo所有页面通过getApp().globalData.token获取pages/index/首页核心是index.js中的loadQuestions()方法——它先wx.showLoading()再wx.request({ url: /api/question/random, method: GET, header: { Authorization: Bearer token } })成功后调用this.setData({ questions: res.data.data })渲染index.wxml的view wx:for{{questions}}列表pages/detail/题目详情页detail.js的onLoad接收questionId参数发起GET /api/question/{id}请求提交答案时wx.request({ method: POST, data: { questionId: id, userAnswer: answer } })后端AnswerController.submitAnswer()处理并返回{code:200,msg:提交成功,data:{isCorrect:true,correctAnswer:B}}utils/request.js封装了统一请求拦截器关键逻辑是config.header.Authorization Bearer getApp().globalData.token且对401状态码自动跳转到登录页——这才是真·生产级写法不是每次请求都手动拼 header。2.3 数据库表设计五张表支撑刷题核心链路MySQL 8.0 建库脚本在sql/weixin273.sql共 5 张表字段命名拒绝驼峰全部下划线分隔降低新手理解成本表名关键字段说明t_userid,open_id,union_id,phone,nick_name,create_timeopen_id为主键phone允许 NULL未授权手机号时create_time默认CURRENT_TIMESTAMPt_questionid,question_content,option_a,option_b,option_c,option_d,correct_answer,analysis,difficulty_level,is_random,category_iddifficulty_level为 TINYINT(1)值 1~5is_random1表示该题可参与随机抽题t_categoryid,name,description题目分类如「Java基础」「SpringBoot」「算法」t_answer_recordid,user_id,question_id,user_answer,is_correct,submit_time记录每次作答is_correct为 BIT(1)方便统计正确率t_wrong_questionid,user_id,question_id,create_time错题本user_idquestion_id联合唯一索引防重复添加注意t_answer_record表没有外键约束user_id和question_id仅靠应用层保证这是为兼容高并发插入做的妥协。若你追求强一致性需在AnswerService.submitAnswer()中加事务Transactional并手动校验user_id是否存在。3. 核心功能实现从微信登录到错题归集的四步链路3.1 微信登录与手机号授权两阶段鉴权的完整闭环小程序登录不是一步到位而是「code 换 openid」「手机号授权」两个独立流程。weixin273 的实现严格遵循微信官方文档第一阶段获取 openid静默登录小程序端app.js的onLaunchwx.login({ success: (res) { wx.request({ url: https://your-domain.com/api/user/login, method: POST, data: { code: res.code }, success: (loginRes) { if (loginRes.data.code 200) { getApp().globalData.token loginRes.data.data.token; getApp().globalData.userInfo loginRes.data.data.user; } } }); } });后端UserController.loginByCode()接收code拼接微信 URLString url https://api.weixin.qq.com/sns/jscode2session? appid appId secret appSecret js_code code grant_typeauthorization_code; // 使用 RestTemplate 调用微信接口解析 JSON 响应 JSONObject wxResponse JSONObject.parseObject(restTemplate.getForObject(url, String.class)); String openid wxResponse.getString(openid); // 查询或创建用户生成 JWT Token String token jwtUtil.generateToken(user); return Result.success(Map.of(token, token, user, user));第二阶段获取手机号需用户主动点击按钮pages/index/index.wxml中button open-typegetPhoneNumber bindgetphonenumbergetPhoneNumber授权手机号/buttonindex.js的getPhoneNumber事件处理getPhoneNumber(e) { if (e.detail.code) { wx.request({ url: https://your-domain.com/api/user/bindPhone, method: POST, header: { Authorization: Bearer getApp().globalData.token }, data: { encryptedData: e.detail.encryptedData, iv: e.detail.iv }, success: (res) { if (res.data.code 200) { wx.showToast({ title: 绑定成功 }); getApp().globalData.userInfo.phone res.data.data.phone; } } }); } }后端UserController.bindPhone()解密逻辑使用WxMaCryptUtils// 注意此处需引入 weixin-java-miniapp 依赖 WxMaCryptUtils.decrypt(sessionKey, encryptedData, iv); // 返回手机号明文 user.setPhone(phone); userMapper.updateById(user); // 更新用户表 phone 字段3.2 随机抽题与选项乱序QuestionService的两次关键 shuffle刷题系统的核心体验在于「每次打开题目都不一样」。weixin273 在QuestionService.getRandomQuestions()中做了两层随机第一层数据库随机抽题public ListQuestionEntity getRandomQuestions(int count, Integer difficultyLevel) { QueryWrapperQuestionEntity wrapper new QueryWrapper(); wrapper.eq(is_random, 1); // 只抽标记为可随机的题 if (difficultyLevel ! null) { wrapper.eq(difficulty_level, difficultyLevel); } // 关键不依赖 ORDER BY RAND()MySQL 性能差而是用 LIMIT offset 随机偏移 int total questionMapper.selectCount(wrapper).intValue(); if (total count) return questionMapper.selectList(wrapper); int offset new Random().nextInt(total - count 1); return questionMapper.selectList(wrapper.last(LIMIT count OFFSET offset)); }参数说明count是前端传入的题目数量如 10 道difficultyLevel是难度筛选可为空。OFFSET随机偏移比ORDER BY RAND()快 3~5 倍尤其当题库超 10 万行时。第二层前端选项乱序显示小程序端index.js收到题目列表后对每道题的选项做 shuffleshuffleOptions(question) { const options [ { key: A, value: question.option_a }, { key: B, value: question.option_b }, { key: C, value: question.option_c }, { key: D, value: question.option_d } ]; // Fisher-Yates 洗牌算法 for (let i options.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [options[i], options[j]] [options[j], options[i]]; } return options; }这样即使数据库里option_a永远是第一个用户看到的 ABCD 顺序也是随机的杜绝死记硬背。3.3 答题提交与结果反馈RESTful 接口设计与状态码语义提交答案的接口/api/answer/submit是典型的 RESTful 设计POST方法RequestBody AnswerSubmitDTO接收参数Data public class AnswerSubmitDTO { private Long questionId; // 题目ID private String userAnswer; // 用户选择如 A private Long userId; // 从 JWT token 解析出的用户ID非前端传入 }AnswerController.submitAnswer()逻辑PostMapping(/submit) public Result? submitAnswer(RequestBody AnswerSubmitDTO dto, RequestHeader(Authorization) String authHeader) { // 1. 从 Authorization Header 解析 token String token authHeader.replace(Bearer , ); Long userId jwtUtil.parseToken(token).getUserId(); // 2. 查询题目 QuestionEntity question questionMapper.selectById(dto.getQuestionId()); // 3. 比对答案记录结果 boolean isCorrect question.getCorrectAnswer().equals(dto.getUserAnswer()); AnswerRecordEntity record new AnswerRecordEntity(); record.setUserId(userId); record.setQuestionId(dto.getQuestionId()); record.setUserAnswer(dto.getUserAnswer()); record.setIsCorrect(isCorrect); record.setSubmitTime(LocalDateTime.now()); answerRecordMapper.insert(record); // 4. 若答错加入错题本 if (!isCorrect) { WrongQuestionEntity wrong new WrongQuestionEntity(); wrong.setUserId(userId); wrong.setQuestionId(dto.getQuestionId()); wrongQuestionMapper.insert(wrong); // 有唯一索引重复添加会忽略 } return Result.success(Map.of(isCorrect, isCorrect, correctAnswer, question.getCorrectAnswer())); }关键点userId不由前端传入而是从 token 解析防止恶意篡改WrongQuestionMapper.insert()使用INSERT IGNORE对应TableField(fill FieldFill.INSERT)的insertStrategy配置避免重复错题。3.4 错题本与复习计划WrongQuestionService的轻量级调度错题本不是静态列表weixin273 实现了「首次错题立即显示后续按遗忘曲线推送」的雏形。WrongQuestionService.getReviewList()方法public ListQuestionEntity getReviewList(Long userId, int limit) { // 查询用户错题按最近错题优先 ListWrongQuestionEntity wrongs wrongQuestionMapper.selectList( new QueryWrapperWrongQuestionEntity().eq(user_id, userId).orderByDesc(create_time)); // 取前 limit 个再关联查询题目详情 ListLong questionIds wrongs.stream().map(WrongQuestionEntity::getQuestionId).collect(Collectors.toList()); return questionMapper.selectBatchIds(questionIds).subList(0, Math.min(limit, questionIds.size())); }小程序端pages/wrong/wrong.js的onShow调用此接口渲染错题列表。虽未集成 Anki 那样的复杂算法但orderByDesc(create_time)确保用户最先看到最新错题符合认知科学中的「近因效应」。4. 避坑指南真机调试中踩过的五个具体坑及解决方案4.1 现象小程序真机调试时wx.request报 404开发者工具里一切正常原因后端接口域名未在微信公众平台「开发管理 开发者ID」中配置为「request 合法域名」。开发者工具走本地代理不校验域名真机请求直连微信强制校验。解决登录 mp.weixin.qq.com 进入「开发管理」→「开发设置」→「服务器域名」→「request 合法域名」添加你的后端域名如https://api.yourdomain.com必须是 HTTPS且需备案。注意localhost、127.0.0.1、192.168.x.x全部无效。4.2 现象wx.getPhoneNumber回调e.detail.code为空控制台报fail no permission原因小程序未开通「获取手机号」能力。该能力需在「开发管理 接口权限」中手动申请且需企业资质认证个体工商户不可用。解决进入「开发管理」→「接口权限」→ 找到「获取手机号」→ 点击「申请」→ 按提示上传营业执照等材料。审核通常需 1~3 个工作日。临时方案在app.json中requiredPrivateInfos: [phoneNumber]添加声明但无权限仍无法触发。4.3 现象SpringBoot 启动报错java.lang.NoSuchMethodError: org.springframework.boot.builder.SpringApplicationBuilder.init([Ljava/lang/Object;)V原因weixin273使用 SpringBoot 3.1.0而你的本地 JDK 是 8 或 11。SpringBoot 3.x强制要求 JDK 17。解决升级 JDK下载 JDK 17推荐 Adoptium Temurin 配置JAVA_HOME并在 IDEIntelliJ IDEA中 Project SDK 设为 17。Maven 编译级别也需同步改为 17properties maven.compiler.source17/maven.compiler.source maven.compiler.target17/maven.compiler.target /properties4.4 现象小程序提交答案后后端AnswerController.submitAnswer()方法根本没进断点日志无输出原因RequestBody接收的 JSON 字段名与前端wx.request的data对象属性名不一致。例如前端传{questionId: 123, userAnswer: A}但 DTO 类AnswerSubmitDTO的字段是private Long question_id;下划线命名而 SpringBoot 默认用 Jackson其JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)未启用。解决在AnswerSubmitDTO类上加注解JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) Data public class AnswerSubmitDTO { ... }或更彻底在application.yml中全局配置spring: jackson: property-naming-strategy: SNAKE_CASE4.5 现象MySQL 8.0 连接报错Public Key Retrieval is not allowed原因MySQL 8.0 默认禁用公钥检索而mysql-connector-java8.0.33 版本在密码加密协商时需此功能。解决修改数据库连接 URL添加allowPublicKeyRetrievaltrueuseSSLfalse参数spring: datasource: url: jdbc:mysql://localhost:3306/weixin273?serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueuseSSLfalse注意useSSLfalse仅用于开发环境生产务必配 SSL 证书。5. 生产就绪改造Nginx 反向代理、HTTPS 强制跳转与日志分级5.1 Nginx 配置让小程序访问后端 API 如同访问同域资源本地开发用http://localhost:8080没问题但部署到服务器后小程序必须通过 HTTPS 域名访问。Nginx 是最轻量的反向代理方案。假设你的小程序域名是https://exam.yourdomain.com后端服务跑在http://127.0.0.1:8080Nginx 配置如下server { listen 443 ssl; server_name exam.yourdomain.com; ssl_certificate /path/to/fullchain.pem; ssl_certificate_key /path/to/privkey.pem; # 小程序静态资源可选若小程序代码也托管在此 location / { root /var/www/miniprogram; try_files $uri $uri/ /index.html; } # 后端 API 代理/api/ 开头的请求转发给 SpringBoot location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # 关键透传 Authorization header否则 JWT 验证失败 proxy_pass_request_headers on; } # HTTP 强制跳转 HTTPS location / { return 301 https://$server_name$request_uri; } }配置后小程序前端utils/request.js的baseUrl改为https://exam.yourdomain.com所有wx.request请求自动走 HTTPS且Authorization头被 Nginx 正确透传给后端。5.2 日志分级区分 debug、info、error定位线上问题不抓瞎weixin273 默认用 Logback但日志级别全为INFO线上出问题时DEBUG级别日志打不开。在src/main/resources/logback-spring.xml中将root级别改为INFO并为关键包单独设DEBUG!-- 全局日志级别 -- root levelINFO appender-ref refCONSOLE/ appender-ref refFILE/ /root !-- 关键业务包开启 DEBUG -- logger namecom.weixin273.service levelDEBUG additivityfalse appender-ref refCONSOLE/ /logger logger namecom.weixin273.controller levelDEBUG additivityfalse appender-ref refCONSOLE/ /logger这样QuestionService.getRandomQuestions()中的log.debug(随机抽题总数{}偏移{}, total, offset);就能在控制台打印而其他无关日志保持INFO避免日志爆炸。5.3 数据库连接池优化HikariCP 参数调优应对高并发刷题默认 HikariCP 配置maximumPoolSize10在 50 用户同时刷题时会连接耗尽。在application.yml中调整spring: datasource: hikari: maximum-pool-size: 20 # 最大连接数 minimum-idle: 5 # 最小空闲连接 connection-timeout: 30000 # 连接超时 30 秒 idle-timeout: 600000 # 空闲连接存活 10 分钟 max-lifetime: 1800000 # 连接最大生命周期 30 分钟 leak-detection-threshold: 60000 # 连接泄漏检测阈值 60 秒血泪经验某次压测发现leak-detection-threshold未设导致连接泄漏后服务假死排查三天才发现是AnswerService中某个try-with-resources忘关Connection。从此我每个新项目都强制加此参数。5.4 小程序性能加固首屏加载提速与答题过程防抖小程序端index.js的loadQuestions()方法若用户快速切换页面可能造成多次请求。加防抖// 在 Page 对象外定义 let loadTimer null; Page({ loadQuestions() { if (loadTimer) clearTimeout(loadTimer); loadTimer setTimeout(() { wx.showLoading({ title: 加载中 }); wx.request({ /* ... */ }); }, 300); // 300ms 内只执行最后一次 } });同时index.wxml中题目列表用wx:for渲染时为每个view加wx:keyid避免列表更新时 DOM 错乱view wx:for{{questions}} wx:keyid classquestion-item text classquestion-content{{item.question_content}}/text !-- ... -- /view6. 验证与交付三步确认系统可用性以及我的交付检查清单6.1 本地启动验证五项必过测试在本地完成mvn clean package和小程序开发者工具导入后必须通过以下五项测试才算真正可用测试项操作步骤预期结果失败则检查1. 后端健康检查浏览器访问http://localhost:8080/actuator/health返回{status:UP}application.yml数据库配置、端口占用2. 小程序登录开发者工具点击「编译」看控制台是否打印login success, token: xxxgetApp().globalData.token有值app.js中wx.login()回调、后端/api/user/login接口连通性3. 题目加载首页console.log(res.data)是否输出题目数组res.data.data是长度为 10 的数组QuestionService.getRandomQuestions()返回非空、t_question.is_random1的数据存在4. 答题提交选择答案点击「提交」看网络面板POST /api/answer/submit是否 200返回{code:200,data:{isCorrect:true}}AnswerSubmitDTO字段名与 JSON 一致、JWT token 有效5. 错题归集故意答错一题 → 进入「错题本」页面 → 查看数据库t_wrong_question表表中有新记录user_id和question_id匹配AnswerController.submitAnswer()中wrongQuestionMapper.insert()执行成功提示第五项测试后立刻执行SELECT * FROM t_answer_record WHERE user_id ? AND question_id ?确认is_correct字段为0MySQL BIT 类型在 Navicat 中显示为0/1这是判断逻辑是否正确的黄金指标。6.2 真机交付 checklist上线前必须核对的七件事我把每次交付前的检查项固化成一张表贴在显示器边框上从不跳过任何一项序号检查项检查方法不通过后果1微信域名已配置登录 mp.weixin.qq.com → 开发设置 → request 合法域名真机wx.request全部 4042后端 HTTPS 证书有效curl -I https://api.yourdomain.com看HTTP/2 200小程序wx.request报net::ERR_CERT_DATE_INVALID3数据库t_user.open_id有索引SHOW INDEX FROM t_user WHERE Key_name PRIMARY;百万用户时登录接口响应超 2s4t_question表is_random1的题目 ≥ 100 条SELECT COUNT(*) FROM t_question WHERE is_random 1;随机抽题返回空数组首页白屏5Nginxproxy_pass路径末尾有/location /api/ { proxy_pass http://127.0.0.1:8080/; }后端收到/api//question/random4046小程序project.config.json中appid为正式号打开文件确认appid: wx1234567890abcdef非tourist提交审核被拒理由「appid 无效」7application-prod.yml中spring.profiles.activeprod启动命令java -jar xxx.jar --spring.profiles.activeprod仍读application-dev.yml连错数据库6.3 我的交付后习惯每次上线必跑的三条 SQL 验证语句从那以后我每次上线新版本都会在数据库里执行这三条 SQL像仪式一样-- 1. 确认题库数据完整至少有 10 道可随机题 SELECT COUNT(*) FROM t_question WHERE is_random 1 AND status 1; -- 2. 确认最近一小时有答题记录证明链路打通 SELECT COUNT(*) FROM t_answer_record WHERE submit_time DATE_SUB(NOW(), INTERVAL 1 HOUR); -- 3. 确认错题本有数据且未重复联合索引生效 SELECT user_id, question_id, COUNT(*) c FROM t_wrong_question GROUP BY user_id, question_id HAVING c 1;第一条确保内容有料第二条证明用户能用第三条验证防重逻辑。如果第三条返回结果立刻查WrongQuestionMapper.insert()是否漏了TableLogic或唯一索引失效。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑