做在线考试系统这件事,纯粹是被一次线下考试折腾出来的念头。当时负责给一批学员安排阶段测验,几十号人挤在机房,试卷打印、答题卡回收、手工判分,一来一回大半天就没了,判分还容易出错。后来就想,干脆自己动手搭一套基于Java后端和微信小程序的模拟考试系统,把出题、组卷、答题、判分、错题回顾整条链路上能自动化的部分全部自动化。这个系统做完之后,最直观的感受是:一场考试从准备到出成绩的时间,从按天计算变成了按分钟计算。
我估计有这类需求的不止我一个人。培训机构要做阶段评测、企业内部要做岗位技能考核、学校老师要组织模拟测验,甚至个人备考刷题,本质都是同一套流程。这篇文章就把这套系统的完整设计思路、数据库表结构、后端关键接口逻辑、小程序端核心页面实现全部拆开讲一遍,代码结构也按照可以直接跑起来的标准来写,源码和文档打包放文末,需要的自取。
1. 内容整体设计与技术选型思路
1.1 需求拆解:模拟考试到底在模拟什么
先说清楚我要解决的问题。真正的线下考试,核心环节是:考生报名、身份校验、分发试卷、限时答题、回收答题卡、客观题判分、主观题人工评分、成绩公布、错题分析。模拟考试系统要做的事情就是把其中能标准化的环节搬到线上,让整个流程可重复、可追踪、可统计。
一开始我差点把系统做成在线答题平台,后来发现方向不对。模拟考试和普通刷题最大的区别在于"考试感":要有固定的试卷结构、限时机制、一次提交后不可回改、交卷后自动判分,并且需要有完整的考试记录沉淀下来,方便后续做成绩分析和错题回顾。如果只是随机出题、做完就完,那本质上就是个题库App,和真实考试场景是脱节的。
这套系统的核心需求我归纳成六条:
- 题库管理:单选题、多选题、判断题三类客观题,支持按知识点分组;
- 组卷规则:支持按题型数量、分值、难度、知识点维度自动随机抽题;
- 考试流程:限时答题、自动保存、超时自动交卷、交卷后不可修改;
- 自动判分:客观题提交后立即判分,返回得分、答题明细、正确答案;
- 考试记录:每次考试生成独立记录,便于查看历史成绩和错题;
- 错题归集:答错的题目自动进入错题本,支持按知识点筛选重练。
1.2 技术选型:为什么是Java + Spring Boot + 微信小程序
后端选Java不是因为它特别酷,而是因为这个项目的定位决定了它需要一个稳定、好维护、团队协作成本低的技术栈。Spring Boot 2.x + MyBatis-Plus + MySQL这套组合,在Java生态里太成熟了,网上资料多,踩坑成本极低,后续接Redis、消息队列、权限框架都有现成方案。
微信小程序这边,考试场景天然适合小程序承载。用户微信扫码或搜索即可进入,不需要安装App,登录链路可以直接复用微信的wx.login静默授权,对考生来说零门槛。另外一个很现实的原因是,在我当时的使用场景里,学员基本都有微信,但未必愿意装一个额外的App,小程序是覆盖成本最低的载体。
前后端交互设计上,我选择了最稳妥的RESTful API + JSON。小程序端只负责渲染和采集用户操作,所有业务规则全部放在后端处理,包括题目抽取、倒计时校验、答案合法性校验、判分逻辑。这样的好处是逻辑收敛在后端,后面想扩展PC端、H5端,只需要复用同一套接口。
1.3 系统模块划分
整个系统我拆成了三个端,服务端代码包结构如下:
com.example.exam ├── common // 通用返回体、异常处理、常量类 ├── config // MyBatis-Plus、跨域、拦截器配置 ├── controller // 对外接口层 ├── service // 业务层,核心逻辑都在这里 ├── mapper // 数据访问层 ├── entity // 数据库实体 ├── dto // 入参出参对象 └── utils // 工具类(随机数、时间处理等)小程序端目录结构:
miniprogram ├── pages │ ├── index // 首页/考试列表 │ ├── exam // 答题页(核心) │ ├── start-exam // 考试确认页(倒计时提醒) │ ├── result // 成绩报告页 │ └── wrong-book // 错题本 ├── utils // request封装、工具方法 ├── components // 复用的自定义组件 └── app.js整体上后端是单体应用,数据库MySQL,前段原生小程序,没有引入特别复杂的微服务、容器编排这些,对这个体量的系统来说没必要。单体应用部署简单,一台2核4G的云服务器就能稳定扛住几百人同时在线考试的压力。
2. 数据库设计:核心表结构逐张拆解
2.1 题库、试卷和考试记录的关系
模拟考试系统的数据模型不算复杂,但表之间的关联关系要想清楚。我最终设计了五张核心表,外加一张管理员用户表。先看整体关系:题库表存题目原子信息,试卷表定义一次考试由哪些题目组成,考试记录表对应一次具体的考试行为,答题明细表记录考生每一道题的作答情况,错题本单独拆一张表便于查询。
-- 题库表 CREATE TABLE `question` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `type` TINYINT NOT NULL COMMENT '1-单选 2-多选 3-判断', `course_id` BIGINT NOT NULL COMMENT '所属课程/知识分类', `content` TEXT NOT NULL COMMENT '题干', `options` TEXT COMMENT '选项,JSON数组', `answer` VARCHAR(20) NOT NULL COMMENT '标准答案,多选用A,B,C格式', `analysis` TEXT COMMENT '答案解析', `difficulty` TINYINT DEFAULT 3 COMMENT '难度 1-5', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, KEY `idx_type_course` (`type`, `course_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有个设计细节值得说一下:options字段直接用TEXT存JSON数组,而不是拆成四列option_a、option_b这种。一开始我用的拆列方案,结果后面调整选项数量的时候就非常痛苦,加一个E选项得改表结构。改成JSON存储后,灵活性和扩展性都好很多,解析成本也就是JSON.parse一下的事。
2.2 试卷表:一份试卷到底怎么存
试卷表需要同时解决两个问题:一是记录试卷的基本信息(名称、总时长、总分),二是保存这份卷子的题目内容。题目内容为什么要单独存一份快照而不是直接关联题库表?因为题库里的题目是可能被修改的,如果试卷里的题跟着变,那历史考试成绩的可对比性就没了。所以我把组卷完成后的题目明细单独存成一张试卷题目快照表。
-- 试卷表 CREATE TABLE `exam_paper` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `title` VARCHAR(100) NOT NULL COMMENT '试卷名称', `duration` INT NOT NULL COMMENT '考试时长(分钟)', `total_score` INT DEFAULT 100, `single_count` INT DEFAULT 0, `single_score` INT DEFAULT 2, `multi_count` INT DEFAULT 0, `multi_score` INT DEFAULT 3, `judge_count` INT DEFAULT 0, `judge_score` INT DEFAULT 2, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 试卷题目快照表 CREATE TABLE `exam_paper_question` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `paper_id` BIGINT NOT NULL, `question_id` BIGINT NOT NULL, `sort_order` INT NOT NULL COMMENT '题目序号', `score` INT NOT NULL COMMENT '该题分值', KEY `idx_paper` (`paper_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;存快照这个决策,是在我做过一次试卷调整之后才想明白的。当时我把某道题的选项文字做了修改,结果发现之前考过试的学员,历史记录里的题目和当前题库对不上了,导致成绩复核时非常混乱。从那以后所有和考试历史相关的数据,我都坚持做快照,题库是"活数据",考试记录是"定档数据",两者互不影响。
2.3 考试记录和答题明细:交卷之后怎么留存
考试记录表和答题明细表是整个系统的数据核心,它们记录了用户在什么时候、对哪些题目、做了怎样选择的完整行为轨迹。这两个表的设计直接决定了成绩计算和错题归集的查询效率。
-- 考试记录表 CREATE TABLE `exam_record` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `user_id` BIGINT NOT NULL COMMENT '用户ID', `paper_id` BIGINT NOT NULL, `score` INT DEFAULT 0, `correct_count` INT DEFAULT 0, `wrong_count` INT DEFAULT 0, `total_count` INT DEFAULT 0, `status` TINYINT DEFAULT 0 COMMENT '0-答题中 1-已交卷 2-超时自动交卷', `start_time` DATETIME NOT NULL COMMENT '开始时间', `submit_time` DATETIME COMMENT '交卷时间', KEY `idx_user` (`user_id`), KEY `idx_paper` (`paper_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 答题明细表 CREATE TABLE `exam_record_answer` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `record_id` BIGINT NOT NULL COMMENT '考试记录ID', `question_id` BIGINT NOT NULL, `user_answer` VARCHAR(20) COMMENT '用户作答,多选用A,B格式', `correct_answer` VARCHAR(20) NOT NULL COMMENT '正确答案', `is_correct` TINYINT DEFAULT 0 COMMENT '0-错误 1-正确', KEY `idx_record` (`record_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;错题本我一开始没单独建表,想着直接用SELECT * FROM exam_record_answer WHERE is_correct = 0查出来就行了,后来数据量上来之后发现两个问题:第一,查历史错题需要多表关联,SQL写起来复杂;第二,如果一道题在不同考试里错了好几次,会重复展示,不符合"错题本"的语义。所以最终加了wrong_question表,里面存用户ID、题目ID、最近一次答错的时间,每次判分时如果发现答错就往这张表里插入或更新时间,查错题本只查这张表。
3. 后端核心逻辑:组卷、答题与判分的实现
3.1 随机组卷算法
组卷是这个系统最核心的业务逻辑之一。需求是自动生成一份包含指定题型、指定数量、指定分值的试卷,题目要从题库中随机抽取。听起来简单,但有几个坑需要注意:
- 题库里某类题型数量可能不够,需要做前置校验并给出友好提示;
- 纯随机抽取可能导致同一类型的题目集中在某个知识点,覆盖率不均衡;
- 需要确保生成的试卷每次都不一样。
我的实现方案是:每个题型按知识点维度分组,从每个知识点中按比例抽取题目,最后在一次查询中完成全部抽取。为了避免ORM逐条查询带来的性能问题,我直接用MyBatis的批量查询加ORDER BY RAND()实现。数据量小的时候这么干没问题,题库到了十万级之后建议改成“先随机取ID,再IN查询”的两步式方案。
public List<Question> generatePaperQuestions(Integer type, Integer count, Long courseId) { LambdaQueryWrapper<Question> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Question::getType, type) .eq(courseId != null, Question::getCourseId, courseId) .last("ORDER BY RAND() LIMIT " + count); return questionMapper.selectList(wrapper); }抽取完成后,把题目ID按顺序写入exam_paper_question表,同时校验抽取数量是否达标,如果某个题型题库数量不足,直接抛出业务异常,提示管理员补充题库。这里我建议在组卷前加一道统计查询,把各题型的可用题目数先查出来,避免事务做到一半再回滚。
3.2 单选题和多选题的判分逻辑
判分逻辑听起来很简单——答案比对,实际上要考虑的边界情况不少:
- 一道题如果用户没作答,按错误处理,不扣分但要记录;
- 多选题的答案顺序可能不同,比如正确答案是
A,C,用户答了C,A,需要先排序再比较; - 判断题的标准答案用
A表示正确、B表示错误,和单选题共用一套答案存储结构。
判分核心代码:
public boolean checkAnswer(String userAnswer, String correctAnswer, Integer type) { if (StringUtils.isBlank(userAnswer)) { return false; } if (type == 2) { // 多选题,排序后比较 String[] user = userAnswer.split(","); String[] correct = correctAnswer.split(","); Arrays.sort(user); Arrays.sort(correct); return Arrays.equals(user, correct); } return userAnswer.trim().equalsIgnoreCase(correctAnswer.trim()); }这里有个小坑,多选题用户作答时选项顺序应该是界面顺序遍历的,用户看到的顺序也是乱序的,所以后端校验时必须先排序再比较,否则会误判。我在真实使用中还真遇到过这种情况,个别学员选对了答案但被判定错误,排查了半天发现是顺序问题。
3.3 考试记录与交卷结果计算
交卷接口是整个后端逻辑链路的汇总点,它需要完成:更新考试记录状态、批量写入答题明细、逐题判分、计算总分、维护错题本。考虑到交卷是低频操作,即使一个时间点有很多人同时交卷,对数据库的压力也可控,我用了一个事务方法承载全部逻辑:
@Transactional(rollbackFor = Exception.class) public SubmitExamResponse submitExam(SubmitExamRequest request) { ExamRecord record = examRecordMapper.selectById(request.getRecordId()); if (record == null || record.getStatus() != 0) { throw new BizException("考试记录不存在或已交卷"); } // 1. 校验超时,如果超时则按实际提交时间处理 if (System.currentTimeMillis() > record.getStartTime().getTime() + record.getDuration() * 60 * 1000L) { record.setStatus(2); // 超时自动交卷 } else { record.setStatus(1); } // 2. 批量保存答题明细 saveAnswerDetails(request, record.getId()); // 3. 判分 ExamScoreDetail scoreDetail = calculateScore(record.getId()); // 4. 更新记录 record.setScore(scoreDetail.getScore()); record.setCorrectCount(scoreDetail.getCorrectCount()); record.setWrongCount(scoreDetail.getWrongCount()); record.setTotalCount(scoreDetail.getTotalCount()); record.setSubmitTime(new Date()); examRecordMapper.updateById(record); // 5. 错题归集 updateWrongBook(record.getId(), scoreDetail.getWrongQuestions()); return new SubmitExamResponse(record); }关于超时自动交卷,我要特别说一下。最初的版本我只做了前端倒计时归零后自动调交卷接口,后来发现一个漏洞:如果用户在答题过程中切到后台很久,前端的setInterval是被小程序挂起的,倒计时不准,可能会导致超时之后前端还在正常答题并提交成功。所以后端在做超时校验时,应该以startTime + duration为准,而不是信任前端传的结束时间。这个校验逻辑一定要加在后端,前端的时间只能作为展示,不能作为最终判定依据。
4. 小程序端设计与实现:从登录到成绩报告
4.1 微信登录链路与后端会话管理
小程序的登录流程是个经典问题。核心是要拿到微信的code,然后通过后端调用微信接口换取openid,再用openid建立自己的用户体系。这里有一个比较容易绕进去的点:小程序的wx.login返回的code只能使用一次,有效期五分钟,所以后端拿到code后必须立即去调用微信的code2Session接口。
// 小程序端登录 wx.login({ success: (res) => { wx.request({ url: 'https://yourdomain.com/api/auth/login', method: 'POST', data: { code: res.code }, success: (res) => { const { token, userInfo } = res.data.data; wx.setStorageSync('token', token); wx.setStorageSync('userInfo', userInfo); } }); } });@PostMapping("/auth/login") public Result login(@RequestBody LoginRequest request) { String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + appId + "&secret=" + secret + "&js_code=" + request.getCode() + "&grant_type=authorization_code"; String result = restTemplate.getForObject(url, String.class); JSONObject obj = JSON.parseObject(result); String openid = obj.getString("openid"); // 查询或创建用户 User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getOpenid, openid)); if (user == null) { user = new User(); user.setOpenid(openid); user.setNickname("微信用户"); userMapper.insert(user); } String token = JwtUtil.generateToken(user.getId()); return Result.success(new HashMap<String, Object>() {{ put("token", token); put("userInfo", user); }}); }开发小程序登录最常遇到的坑就是appsecret泄露。前后端交互时不要把appsecret暴露在小程序代码里,必须把code2Session请求放在后端发起,小程序端只传code。另外wx.getUserProfile和wx.getUserInfo获取头像昵称的接口在小程序新版本中已经收紧了,建议直接用默认头像加"微信用户"作为初始昵称,等用户主动触发"完善资料"时才引导授权。
4.2 答题页面的状态管理与倒计时
答题页是小程序端最复杂的页面,要管理的数据包括:当前题目索引、用户答案映射、剩余时间、答题进度、还有防止误触交卷的保护机制。我没有引入额外的状态管理库,直接用Page的data托管全部状态,配合setData更新。
Page({ data: { questions: [], currentIndex: 0, answers: {}, remainSeconds: 0, paperId: null, recordId: null }, onLoad(options) { this.setData({ paperId: options.paperId }); this.initExam(); }, initExam() { // 调用创建考试记录接口 wx.request({ url: '/api/exam/start', method: 'POST', data: { paperId: this.data.paperId }, success: (res) => { const { recordId, questions, duration } = res.data.data; this.setData({ recordId, questions, remainSeconds: duration * 60 }); this.startTimer(); } }); }, startTimer() { this.timer = setInterval(() => { const remain = this.data.remainSeconds - 1; this.setData({ remainSeconds: remain }); if (remain <= 0) { this.handleSubmit(true); } }, 1000); }, chooseAnswer(e) { const index = e.currentTarget.dataset.index; const value = e.currentTarget.dataset.value; const key = `answers.${this.data.currentIndex}`; this.setData({ [key]: value }); }, preQuestion() { if (this.data.currentIndex > 0) { this.setData({ currentIndex: this.data.currentIndex - 1 }); } }, nextQuestion() { if (this.data.currentIndex < this.data.questions.length - 1) { this.setData({ currentIndex: this.data.currentIndex + 1 }); } else { this.showSubmitConfirm(); } }, handleSubmit(isTimeout = false) { clearInterval(this.timer); wx.request({ url: '/api/exam/submit', method: 'POST', data: { recordId: this.data.recordId, answers: Object.values(this.data.answers) }, success: () => { wx.redirectTo({ url: `/pages/result/result?recordId=${this.data.recordId}` }); } }); } });这个页面的核心难点在于答案的存储结构。我用了一个answers = {}对象,键是题号索引,值是用户选择的答案字符串,单选存"A",多选存"A,B"。用setData更新对象里的某个键时,注意小程序需要写成answers.0、answers.3这种路径字符串,不能直接用变量名去set。
4.3 单选、多选与判断题的渲染
三种题型的展示形式不同:单选是四个圆点,多选题是复选框,判断题只有对和错两个选项。这里我踩过一个坑:最初把三种题型分别写了三个页面,代码大量重复,改一个交互细节要动三处。后来重构成了统一页面,用question.type决定渲染风格,选择题的选项按钮通过一个wx:for循环统一渲染,用item.checked字段控制选中态。
<view class="question-content">{{currentQuestion.content}}</view> <view class="option-list"> <view class="option-item {{currentQuestion.type === 2 ? 'multi' : 'single'}}" wx:for="{{currentQuestion.optionList}}" wx:key="index" >function getNavBarHeight() { // 获取系统信息 const systemInfo = wx.getSystemInfoSync(); // 胶囊按钮位置信息 const capsuleInfo = wx.getMenuButtonBoundingClientRect(); // 导航栏高度 = 胶囊按钮底部到状态栏底部的距离 * 2 + 胶囊按钮的高度 const navBarHeight = (capsuleInfo.top - systemInfo.statusBarHeight) * 2 + capsuleInfo.height; return { statusBarHeight: systemInfo.statusBarHeight, navBarHeight, totalHeight: systemInfo.statusBarHeight + navBarHeight }; }这个公式是社区里反复验证过的,基本原理是:胶囊按钮垂直方向居中于导航栏,所以胶囊按钮顶部到状态栏底部的距离乘以2,再加上胶囊按钮自身高度,就是导航栏的总高度。在iPhone X以上的全面屏机型上,这个计算尤其重要,不然自定义导航会顶到状态栏。
5. 项目部署与配置:从源码到上线
5.1 后端配置与启动
后端项目的核心配置文件是application.yml,需要重点配置的是数据库连接、Redis(如果用了)、微信小程序appid和secret。环境上我用的是dev和prod两套配置,通过spring.profiles.active切换。
server: port: 8080 servlet: context-path: /api spring: datasource: url: jdbc:mysql://localhost:3306/exam?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl wx: appid: your-wx-appid secret: your-wx-appsecret启动方式没有特殊之处,打包后直接跑jar包:
mvn clean package -DskipTests java -jar exam-server.jar --spring.profiles.active=prod运维层面,我建议用systemd做进程守护,防止进程异常退出后没人拉起。生产环境的MySQL建议开启binlog,方便后续做数据恢复和跨机房同步。如果并发量上来了,优先考虑在exam_record表上加缓存,而不是一上来就搞读写分离,四两拨千斤。
5.2 小程序端请求域名配置
小程序上线有一个很折磨人的要求:所有请求的URL必须配置到小程序管理后台的"服务器域名"白名单里,而且必须是HTTPS。开发环境可以通过"不校验合法域名"来解决,但正式版必须配置真实域名。
这里我给出的方案是:优先使用Nginx反向代理后端服务,域名用https://api.exam.com这种,代理转发到内网的Java应用端口。同时需要注意,微信小程序要求https证书是有效的,不能在测试环境用自签名证书糊弄。
server { listen 443 ssl; server_name api.exam.com; ssl_certificate /etc/nginx/ssl/api.exam.com.pem; ssl_certificate_key /etc/nginx/ssl/api.exam.com.key; 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; } }5.3 上线前必做的五件事
模拟考试系统牵扯到用户考试数据,上线之前的检查清单我在实际部署过程中反复迭代了几轮,整理成五条:
- 数据库连接池配置。默认的HikariCP连接数可能不够,考试开始时大家同时进入答题页会有大量连接请求,建议
maximum-pool-size调整到30以上; - 接口超时设置。判分和交卷涉及事务,必须设置合理超时时间,建议3秒,超过即返回提示"交卷失败,请重试",避免前端无限等待;
- 图片或附件存储。如果考试题目有配图,建议直接走对象存储(OSS/COS),不要把图存服务器本地,小程序request合法域名限制只对接口有效,图片用云存储域名不会受限制;
- 自动交卷的兜底策略。除了前端倒计时,建议加一个定时任务,扫描超时未交卷的记录,状态统一修复为超时交卷,防止前端bug导致成绩缺失;
- 数据备份策略。考试前开启备份,考试后立即备份一次,至少保留最近7天的备份文件。
6. 常见问题与排查技巧实录
6.1 小程序登录后获取不到用户信息
这个问题的现象是:wx.login正常返回了code,后端也成功换到了openid,但页面渲染用户昵称时总是空串。排查思路:
- 确认
app.js中onLaunch里的登录逻辑是否在页面onLoad之前完成,如果页面并发请求先发出去了,token还没返回,接口会因鉴权失败返回401; - 确认后端
code2Session接口返回的数据结构,微信官方返回的字段是openid和session_key,部分教程会写成openId,拿错字段名会默默失败; - 确认用户授权弹窗是否被用户拒绝了,拒绝后
wx.getUserProfile的成功回调不会执行;后端在用户表建新用户时,应设置可人为修改的默认昵称,而不是依赖微信接口返回。
6.2 多选答案判错
多选判分最常见的问题就是选项顺序。我上面已经提到了排序后比较的方案,但还有一个容易被忽略的坑:前端从复选组件中收集答案时,选项的枚举顺序和数据模拟时的顺序不一致,导致答案字符串是"C,A"而不是"A,C"。这个问题如果不在前端排序就在后端排序,必须保证两端至少有一处对答案规范化。
6.3 微信小程序顶部导航栏高度不准确
不同机型适配是长期存在的坑,特别是刘海屏和挖孔屏机型上,wx.getSystemInfoSync()返回的statusBarHeight是准确的,但导航栏高度的计算不能简单用24px这种固定值。建议使用胶囊按钮getMenuButtonBoundingClientRect()动态计算,这也是我上面封装getNavBarHeight()方法的原因。这个方法要在onLoad里获取并存入全局变量,不要在多个页面重复调用,避免闪烁。
6.4 交卷时请求超时或失败
交卷接口是事务方法,如果数据量大,判分逻辑可能耗时较长。生产环境曾经出现过高峰期交卷接口超时的情况,排查下来有两个原因:一是数据库连接池被其他慢查询SQL拖垮;二是判分循环中对每条答案执行了一次查询,N道题就是N次数据库往返。
解决办法是:判分时一次性查出该记录涉及的所有题目和所有答案,在内存中完成比对,绝不在循环里查库。另外在事务外先做考试记录状态的前置校验,能提前拦截的非法请求不要进入事务。我把判分逻辑优化后,单次交卷接口的P99耗时从2.8秒降到了400毫秒左右,效果非常明显。
6.5 小程序推送消息方案选型
考试系统有一个常见的运营需求:考试开始前提醒考生。微信小程序的消息推送目前最常用的是订阅消息,一次订阅只能推送一次,而且用户必须主动点击授权。我的建议是:在考试确认页弹窗引导用户订阅"考试提醒",到开考前一天通过后端定时任务调用订阅消息推送API。如果用户订阅次数用完了,可以考虑短信或群通知兜底,不要把小程序订阅消息当作唯一的触达通道。
7. 从这套系统里沉淀的几点经验
说实话,这套模拟考试系统从立项到上线,我自己也走了一些弯路。最有价值的经验,不是某个技术点怎么实现,而是整个系统设计中那些看起来不起眼却决定成败的细节。
第一,考试系统的"定档"意识很重要。题库可以被持续更新,但已经结束的考试记录必须保持原样,这就是我反复强调快照表的原因。用户会来复核成绩,如果发现当初考的题和现在题库里的题不一样,信任感瞬间就没了。
第二,前端能做的时间控制都是不可靠的。用户停留在后台、手机锁屏、微信切走,都可能导致前端定时器失效。所有涉及时间判定的逻辑,比如超时交卷、考试有效时长,必须以后端服务器时间为准。后端的时间也可能被运维调整,所以如果系统里有严格的时间要求,建议接入NTP时间同步,并在每次交卷时用服务器当前时间重新校验一遍。
第三,性能瓶颈永远在数据库交互模式上,而不是在业务代码里。判分循环里少一次查询,比很多花里胡哨的优化技巧都管用。编写这类系统时,尽量把需要的数据一次性查出来,在代码里做计算,减少数据库往返。
第四,小程序的版本兼容问题比想象中多得多。设备型号、微信版本、基础库版本都会影响页面表现,适配一定要以真机为准,模拟器上看起来正常的布局,真机上可能已经错位了。建议准备一台便宜的Android测试机和一台iPhone,每次发版前至少在这两台设备上把核心流程跑一遍。
如果你正准备开发类似的小程序考试系统,最后再分享一个非常实用的小技巧:在开发环境把NODE_ENV和请求地址封装成配置文件,一行代码切换正式环境和测试环境,而不是在代码里写死URL。这个习惯能帮你省下大量联调时间,尤其当小程序端和后端不是同一个人维护的时候。