简介这套基于微信小程序的考试系统设计与实现项目可作为计算机专业毕业设计、课程设计或实训参考。系统后台采用SSM框架与MySQL数据库小程序端使用微信开发者工具开发管理员端覆盖用户管理、考试资料管理、用户交流、试卷管理、留言板、试题管理、系统管理等模块用户端支持注册登录、查看考试资料并在线参加考试既能满足完整考试流程也便于二次扩展。压缩包共1107个文件约16.19MB主要包含Java后台源码、小程序前端wxml/wxss/js、Vue管理端vue/scss、SQL初始化脚本及图片样式等资源覆盖开发、配置与部署所需。目前已有343人学习浏览适合作为课程答辩或课题实现底稿。其中还附带1-install.bat、2-run.bat等辅助脚本能帮助快速配置本地运行环境前后端代码结构清晰通过阅读核心模块可以理解微信小程序与SSM的整合方式也可直接抽取功能模块优化复用对同类考试系统开发者有实际参考价值。1. 基于微信小程序的考试系统一个被低估的全栈闭环毕设每年毕设季都有人被「基于微信小程序的考试系统」这个题目卡住。它听起来只是一个答题小程序实际拆开是一个完整的三端闭环小程序端负责登录、答题、交卷管理端负责题库维护和组卷服务端负责鉴权、判分和成绩统计。把这个题目做透等于把微信登录、接口鉴权、事务幂等、联表统计这些面试高频考点一次性过了一遍这也是它常年被当作精品毕设推荐的原因。这个题目适合两类人一类是后端基础一般、想通过一个完整项目补齐全栈经验的应届生另一类是已经写过 CRUD、但缺一个「移动端 服务端」真实交互案例的求职者。难点不在功能数量而在登录态续期、交卷幂等、考试计时这些细节有没有处理干净。答辩时老师最爱问的恰恰就是这些地方。下文按我实际做这类项目的顺序展开先定架构和选型再写核心链路代码然后落地数据库最后把最容易翻车的现场和能加分的进阶技巧一并讲清楚。全程用最小可运行的代码说话你照着敲就能跑通。2. 先定架构再写代码小程序考试系统的技术选型与三层拆分拿到这个题目第一步不是写代码而是先定架构。很多同学一上来就在微信开发者工具里写页面写到一半发现管理端没地方放判分逻辑不知道该塞哪后端接口也没想好最后全推倒重来。这个题目看着是单个小程序实际是三个端要配合任何一个端没想清楚后期都免不了返工。我一般会先画一张职责图小程序端只做两件事——展示和采集服务端做所有业务判断——你是谁、能不能考、答得对不对管理端做数据维护——出题、组卷、看统计。把这三个边界立住后面写代码就是往里填内容。2.1 三层架构拆解小程序端、管理端、服务端各自要做什么先看一张职责拆解表这也是我每次做同类项目时固定用的划分方式端核心功能技术载体小程序端学生微信登录、考试列表、答题、交卷、成绩查询微信原生小程序或 uni-app管理端管理员题库维护、组卷、发布试卷、成绩统计Web 后台或小程序管理入口服务端登录换 openid、鉴权、试卷下发、判分、报表Spring Boot / SSM MySQL Redis小程序端是最容易理解的一层学生打开小程序授权登录看到可参加的考试列表进入答题页交卷后看到分数。这里要特别注意小程序端只是「展示题目和收集答案」它不应该知道正确答案是什么。如果小程序端拿到题目时带着答案字段抓包就能看到考试就失去意义了。管理端很多同学会忽略但它恰恰是答辩时最容易被追问的部分。常见做法有两种一是管理端做成独立的 Web 页面用 Vue 或原生 HTML 都行二是在同一个小程序里做角色区分学生端和管理员端共用一个工程靠登录后的角色字段决定渲染哪些页面。我建议选第一种原因是管理端有大量表格和表单操作小程序的表单体验差做进去会让你的小程序包体变大、页面变多答辩演示时反而容易慌张。服务端是整件事的中枢负责三块登录时拿 code 换 openid生成自定义 token考试时下发不带答案的试卷、接收答案并判分管理端查询时做成绩统计和报表聚合。服务端的接口设计直接决定前端写起来顺不顺手我习惯所有接口统一返回一层 Result 包装前端拿到 code 和 data 后再决定渲染逻辑这样前后端联调时不用各猜各的。2.2 技术选型对比原生小程序还是 uni-appSpring Boot 还是 SSM选型决定了你接下来两三个月的开发体验我把这两组最常见的纠结列成对比表结论也一并给出来。对比项微信原生小程序uni-app学习成本要学 WXML、WXSS、小程序 API会 Vue 语法就能直接写跨端能力只能跑微信可编译到支付宝、百度等多端调试体验微信开发者工具直接调报错直观多一层编译报错定位要绕一下考试场景适配完全够用完全够用答辩风险展示时只用微信无额外风险多端编译是加分项但也可能被追问后端也一样对比项Spring BootSSM起步速度自动配置一个注解跑起来XML 配置多光搭环境要半天社区资料多踩坑基本都能搜到老项目存量多资料也不少简历价值现在招聘普遍默认 Spring Boot偏教学体系面试认可度略低我的建议比较直接小程序端用原生不要为了「多端」这种用不到的卖点引入 uni-app少一层编译就少一层坑微信开发者工具里报错能直接定位到页面和行号对新手友好得多。后端如果你在学校只学过 SSM也直接上 Spring Boot业务逻辑和 MyBatis 用法差别不大但省掉的配置时间足够你把判分逻辑多写两遍。选型这件事上追求稳妥永远比追求炫技更划算。2.3 环境初始化从空项目到第一个能返回 JSON 的接口环境搭建是最容易劝退新手的地方我按顺序列一遍每一步都是做这类项目必须有的安装微信开发者工具新建小程序项目AppID 先用测试号就能开发不影响写代码。本地装 JDK、Maven、MySQL启动 MySQL 后建一个考试系统数据库名称随意比如 exam_db。用 Spring Initializr 或 Maven 创建一个 Spring Boot 工程依赖只需要 Web、MySQL 驱动和 MyBatis 系持久层框架。写一个健康检查接口确认工程能跑起来再做任何业务代码。如果用的是 Maven核心依赖在 pom.xml 里长这样dependencies !-- Web 层提供 REST 接口 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 持久层MyBatis-Plus 写 CRUD 最快也方便做分页和条件查询 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId /dependency !-- MySQL 驱动 -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency !-- Lombok省掉实体类的 getter/setter -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里不做任何版本号标注是因为 Spring Boot 父工程会统一管理版本你自己写代码时也不需要关心具体版本。第三行 MyBatis-Plus 是一个很好的选择它把单表 CRUD 全封装好了考试系统里用户表、题目表这类简单操作基本上不用手写 XML。配置文件 application.yml 是另一个关键点server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/exam_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 wechat: app-id: your_app_id app-secret: your_app_secretURL 里的 characterEncodingutf8 是防中文乱码的serverTimezoneAsia/Shanghai 是防数据库时区报错的这两个参数少一个都会在启动或查询时出幺蛾子。wechat.app-id 和 app-secret 先填空等你去微信公众平台申请到小程序账号后填真实值注意这两项只能写在服务端配置里绝不能下发到小程序端。接着写一个健康检查接口验证链路RestController RequestMapping(/api) public class HealthController { GetMapping(/health) public ResultString health() { return Result.ok(up); } }这个接口没有任何业务逻辑只验证三件事工程能启动、端口能访问、统一返回结构能正常工作。用 curl 验证curl http://localhost:8080/api/health如果返回的 JSON 里包含 code 和 data 字段说明服务端已经就绪。前端小程序里把请求域名指向 http://localhost:8080开发阶段要在微信开发者工具里勾选「不校验合法域名」上线前再换成 HTTPS 域名并在小程序后台配置白名单。这一步是很多同学在真机调试时突然白屏的总根源真机无法访问 localhost必须配局域网 IP 或线上域名。3. 核心链路实现微信登录、答题状态与交卷判分的完整代码路径环境跑通之后优先实现三条核心链路登录、答题、交卷。这三条链路走通了你的考试系统就完成了七成。我把每条链路的前后端代码拆开讲包含参数含义和常见误用你合到一起就是一个可演示的纵向切片。很多同学喜欢先做题库管理再做考试流程这是顺序上的错误。题库管理是管理端的事做完之后学生端还是空的答辩演示时你只能展示「添加题目」展示不了「学生考试」。反过来先把考试闭环跑通哪怕题库里只有三题演示效果也已经是完整的。3.1 微信登录换 openidwx.login code2Session 后端换 token微信小程序的登录跟传统账号密码不一样核心逻辑是前端调 wx.login 拿到一个临时 code把这个 code 发给后端后端拿 code 加上自己的 appid 和 secret 去微信服务器换 openid。openid 是用户在小程序里的唯一标识后端用 openid 查用户表没有就自动注册然后再签一个自定义 token 返回给前端后续所有请求都带这个 token。小程序端登录封装我一般单独放一个 auth.js// utils/auth.js function wxLogin() { return new Promise((resolve, reject) { // 1. wx.login 获取临时登录凭证 code wx.login({ success: (res) { if (res.code) { // 2. 将 code 发给后端换取自定义 token wx.request({ url: ${getApp().globalData.baseUrl}/api/auth/login, method: POST, data: { code: res.code }, success: (resp) { if (resp.data.code 200) { // 3. 保存 token 和 openid后续请求通过 header 携带 wx.setStorageSync(token, resp.data.data.token) wx.setStorageSync(openid, resp.data.data.openid) resolve(resp.data.data.token) } else { reject(new Error(resp.data.message)) } }, fail: reject }) } else { reject(new Error(wx.login 获取 code 失败)) } }, fail: reject }) }) } module.exports { wxLogin }这里的重点是code 是一次性的有效期只有几分钟后端换完 openid 后这个 code 就作废了所以不能把 code 存起来重复用。getApp().globalData.baseUrl 是全局配置开发环境指本地局域网 IP上线指 HTTPS 域名不要在页面里写死。后端接收 code 后要调用微信官方接口 jscode2session 来换 openidRestController RequestMapping(/api/auth) public class AuthController { Resource private AuthService authService; Resource private RestTemplate restTemplate; PostMapping(/login) public ResultLoginVO login(RequestBody LoginRequest req) { // 1. 用 code appid secret 换 openid String url String.format( https://api.weixin.qq.com/sns/jscode2session?appid%ssecret%sjs_code%sgrant_typeauthorization_code, wechatConfig.getAppId(), wechatConfig.getAppSecret(), req.getCode()); String body restTemplate.getForObject(url, String.class); // 2. 解析返回结果拿到 openid JSONObject json JSON.parseObject(body); if (json.getInteger(errcode) ! null) { throw new BizException(微信登录失败 json.getString(errmsg)); } String openid json.getString(openid); // 3. 查库或注册生成自定义 token return Result.ok(authService.loginByOpenid(openid)); } }这段代码有三个必须注意的参数appid 和 secret 是微信公众平台分配给你的小程序凭证只能放在服务端grant_type 固定是 authorization_code这是微信接口规定的参数名一个字都不能改。返回的 body 里除了 openid 还有 session_key考试系统一般用不到它不要把它下发给前端session_key 只在需要解密用户手机号、UnionID 等敏感信息时才用得上。openid 换到之后authService 里做用户查找和 token 签发。登录成功后的 token 建议用 UUID 或 JWT存到 Redis 里并设置过期时间后续每个接口都通过拦截器校验Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { throw new BizException(未登录); } // 从 Redis 查 token拿 userId放进 ThreadLocal 供后续使用 String userId redisTemplate.opsForValue().get(login:token: token); if (userId null) { throw new BizException(登录已过期请重新登录); } UserContext.set(Long.valueOf(userId)); return true; } }很多同学图省事直接在拦截器里解析 openid 当 userId 用这在答辩时会被问到「openid 和自增 id 的区别」。openid 是微信维度的身份标识长度 28 位左右不适合当主键自增 id 是系统内部的用户标识关联外键更高效。正确做法是 openid 只用来注册和登录登录后所有业务表都用自增 id 关联。3.2 试卷加载与答题状态接口不返回答案前端草稿要落本地考试系统的试卷接口有一个硬性要求下发给小程序的题目列表绝对不能包含答案和解析字段。我见过有同学图省事后端直接把题库表的实体类序列化返回结果抓包就能看到正确答案考试形同虚设。正确做法是单独建一个返回 VO只序列化题目 ID、题型、题干、选项这些展示字段。后端组装试卷返回对象的核心逻辑public PaperVO buildPaperVO(Paper paper, ListQuestion questions) { PaperVO vo new PaperVO(); vo.setPaperId(paper.getId()); vo.setTitle(paper.getTitle()); // duration 单位是分钟前端拿它做倒计时 vo.setDuration(paper.getDuration()); vo.setQuestions(questions.stream().map(q - { QuestionVO qv new QuestionVO(); qv.setQuestionId(q.getId()); qv.setType(q.getType()); // 1 单选 2 多选 3 判断 qv.setContent(q.getContent()); qv.setOptions(new String[]{q.getOptionA(), q.getOptionB(), q.getOptionC(), q.getOptionD()}); // 注意这里故意不 set 答案和解析字段 return qv; }).collect(Collectors.toList())); return vo; }注意这段代码里我把答案字段主动省略了而不是置空。省略意味着 VO 里根本没有这个字段抓包或调试时看不到安全性更好。要是置空实体类里还残留 getter有些序列化框架仍会把字段输出成 null 值。小程序端拿到试卷后答题状态管理是最容易写乱的地方。我的做法是用一个对象存答案键是题目 ID值是用户选项比如 { 1001: A, 1002: B,D }。切换题目时从对象里读选了题就写入对象同时在本地存储里存一份草稿防止考试中间小程序被杀掉// pages/exam/exam.js Page({ data: { paperId: , questions: [], answers: {}, // 答题状态键为 questionId currentIndex: 0 }, onLoad(options) { this.setData({ paperId: options.paperId }) this.loadPaper() }, loadPaper() { wx.request({ url: ${getApp().globalData.baseUrl}/api/exam/paper/${this.data.paperId}, header: { Authorization: Bearer ${wx.getStorageSync(token)} }, success: (res) { this.setData({ questions: res.data.data.questions }) // 从本地缓存恢复未交卷的草稿 const saved wx.getStorageSync(draft_${this.data.paperId}) if (saved) { this.setData({ answers: saved }) } this.startCountdown() } }) }, onOptionTap(e) { const { qid, val } e.currentTarget.dataset // 动态更新对应题目的答案并持久化草稿 this.setData({ [answers.${qid}]: val }) wx.setStorageSync(draft_${this.data.paperId}, this.data.answers) } })这里有两个关键设计一是 answers 用动态路径方式更新避免把整个对象 setData 一遍造成渲染卡顿二是考试中途切后台或小程序被销毁本地草稿都还在重新进入页面可以恢复。这个草稿机制在真实演示时非常加分你可以直接演示「答题一半杀掉小程序再重进数据还在」这个场景。3.3 交卷与自动判分事务、幂等与成绩回写交卷是整场考试的核心动作也是事故高发区。接口要做三件事校验考试记录状态、逐题判分、持久化答题明细和成绩。这三件事必须在一个事务里完成任何一步失败都不能留下半截数据。后端交卷接口的骨架PostMapping(/api/exam/submit) Transactional(rollbackFor Exception.class) public ResultSubmitVO submit(RequestBody SubmitRequest req) { // 1. 校验是否已交卷防止重复提交 ExamRecord record examRecordMapper.selectOne( new LambdaQueryWrapperExamRecord() .eq(ExamRecord::getUserId, UserContext.get()) .eq(ExamRecord::getPaperId, req.getPaperId()) .last(LIMIT 1)); if (record null) { throw new BizException(考试记录不存在); } if (record.getStatus() 1) { // 已交卷直接返回历史成绩而不是重新判分 return Result.ok(new SubmitVO(record.getScore(), record.getSubmitTime())); } // 2. 逐题判分 int score 0; ListAnswerDetail details new ArrayList(); for (AnswerItem item : req.getAnswers()) { Question q questionMapper.selectById(item.getQuestionId()); boolean correct judgeQuestion(q, item.getUserAnswer()); if (correct) { score q.getScore(); } details.add(buildAnswerDetail(item, q.getScore(), correct)); } // 3. 写入成绩和答题明细 record.setScore(score); record.setStatus(1); record.setSubmitTime(LocalDateTime.now()); examRecordMapper.updateById(record); answerDetailMapper.insertBatch(details); return Result.ok(new SubmitVO(score)); }Transactional 注解保证判分和写记录同生共死不会出现成绩没写但状态改成已交卷的情况。判分函数按题型分三种处理这里最容易错的是多选题private boolean judgeQuestion(Question q, String userAnswer) { String correctAnswer q.getAnswer(); if (q.getType() 2) { // 多选题答案不要求顺序A,B 和 B,A 是同一答案 return sortAnswers(correctAnswer).equals(sortAnswers(userAnswer)); } // 单选题和判断题 return correctAnswer.equalsIgnoreCase(userAnswer.trim()); } private String sortAnswers(String answer) { String[] arr answer.split(,); Arrays.sort(arr); return String.join(,, arr); }多选题的坑在于用户在界面上勾选的顺序可能跟标准答案顺序不一致如果不排序直接比较A,B 会被判定成不等于 B,A。排序后再比较就规避了这个问题。前端交卷请求也要配合做防重复处理submitExam() { if (Object.keys(this.data.answers).length 0) { wx.showToast({ title: 请先作答, icon: none }) return } wx.showLoading({ title: 交卷中 }) wx.request({ url: ${getApp().globalData.baseUrl}/api/exam/submit, method: POST, header: { Authorization: Bearer ${wx.getStorageSync(token)} }, data: { paperId: this.data.paperId, answers: Object.keys(this.data.answers).map(qid ({ questionId: Number(qid), userAnswer: this.data.answers[qid] })) }, success: (res) { // 交卷成功立即清理本地草稿 wx.removeStorageSync(draft_${this.data.paperId}) const score res.data.data.score wx.redirectTo({ url: /pages/result/result?score${score} }) }, complete: () wx.hideLoading() }) }答题明细的组装格式是数组套对象每个对象包含 questionId 和 userAnswer服务端按 questionId 去查题目再判分。注意前端在交卷成功后必须清理本地草稿否则下次考试进入时会把上一场的答案恢复出来造成「这次考试自带上次答案」的诡异现场。4. 数据库设计六张表撑起题库、组卷、考试记录与成绩统计考试系统的数据库设计是答辩时老师必看的部分表拆得合理后面写查询就舒服。我见过把题目和试卷揉在一张表里的做法结果试卷不能复用题目每出一张新卷子要复制一遍题数据和写代码都很难受。正确的做法是把职责拆开用关联表连接。本章按建表顺序讲清楚每张表的职责、字段含义和索引设计SQL 可以直接复制到 MySQL 里执行字段注释我写全了答辩讲表结构时照着读即可。4.1 表结构职责划分用户、题库、试卷、记录怎么拆六张表的关系可以这样理解用户表存谁在用系统题目表存一张公共题库所有试卷从这里选题试卷表存考试本身的属性比如时长和总分试卷题目关联表解决「一张试卷包含哪些题、按什么顺序」的多对多关系考试记录表存每个学生每次考试的答题状态和最终成绩答题明细表存每一道题的对错用来做正确率分析。表名职责关键字段tb_user学生和管理员账号openid、nickname、roletb_question题库所有试卷共用type、content、answer、scoretb_paper试卷基本信息title、duration、total_score、statustb_paper_question试卷与题目关联paper_id、question_id、sorttb_exam_record每次考试的记录user_id、paper_id、score、status、start_time、submit_timetb_answer_detail每题答题明细record_id、question_id、user_answer、is_correct为什么题目和试卷要分开因为题库是可以复用的资产。这学期的一套期中卷和期末卷可能共用 30 道题如果把题目嵌在试卷表里改一道题要改两张表而且无法统计一道题在多少张试卷里出现过。拆开之后题目只有一份试卷只存题目 ID 的引用改题只改一处。考试记录表是整个系统的核心它同时记录开始时间和交卷时间这两个时间戳直接支撑超时判断。答题明细表看起来每行只有「题目 ID 答案 是否对」但它是正确率统计的基础数据源管理端报表全靠它聚合。4.2 建表 SQL字段类型、唯一索引与注释一次到位建表 SQL 最忌讳的是字段含义不清晰。我习惯把注释写到字段级别答辩时直接念注释就能讲清楚设计意图。先建用户表和题目表CREATE TABLE tb_user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 用户ID, openid VARCHAR(64) NOT NULL COMMENT 微信openid用户唯一标识, nickname VARCHAR(50) DEFAULT COMMENT 昵称, avatar_url VARCHAR(255) DEFAULT COMMENT 头像地址, role TINYINT NOT NULL DEFAULT 0 COMMENT 角色0学生 1管理员, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间, PRIMARY KEY (id), UNIQUE KEY uk_openid (openid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;openid 加唯一索引是必须的同一个微信号在同一个小程序下 openid 唯一重复插入会在数据库层直接报错而不是等业务代码去判断。role 字段用 TINYINT 而不是 VARCHAR是因为角色只有两个枚举值数字比字符串查询更快也不容易写错。题目表字段较多核心是 answer 字段的设计CREATE TABLE tb_question ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 题目ID, type TINYINT NOT NULL COMMENT 题型1单选 2多选 3判断, content TEXT NOT NULL COMMENT 题干, option_a VARCHAR(255) DEFAULT COMMENT 选项A, option_b VARCHAR(255) DEFAULT COMMENT 选项B, option_c VARCHAR(255) DEFAULT COMMENT 选项C, option_d VARCHAR(255) DEFAULT COMMENT 选项D, answer VARCHAR(10) NOT NULL COMMENT 正确答案多选用逗号分隔如A,B, score INT NOT NULL DEFAULT 5 COMMENT 每题分值, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), KEY idx_type (type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT题目表;answer 用 VARCHAR(10) 而不是 JSON 或 TEXT是因为它是确定性短文本单选就一个字母多选用逗号分隔最长也就 A,B,C,D 七个字符。判分时排序后直接字符串比较比解析 JSON 快且稳定。score 用 INT 而不是 DECIMAL是因为考试总分一般是 100 的整数倍按整题给分避免浮点累加误差。试卷表和关联表CREATE TABLE tb_paper ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 试卷ID, title VARCHAR(100) NOT NULL COMMENT 试卷名称, duration INT NOT NULL DEFAULT 60 COMMENT 考试时长分钟, total_score INT NOT NULL DEFAULT 100 COMMENT 满分, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0草稿 1已发布, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT试卷表; CREATE TABLE tb_paper_question ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 关联ID, paper_id BIGINT NOT NULL COMMENT 试卷ID, question_id BIGINT NOT NULL COMMENT 题目ID, sort INT NOT NULL DEFAULT 0 COMMENT 题目顺序越小越靠前, PRIMARY KEY (id), UNIQUE KEY uk_paper_question (paper_id, question_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT试卷题目关联表;关联表上加 (paper_id, question_id) 联合唯一索引防止同一道题在同一张试卷里出现两次。sort 字段用于控制题目顺序稍后做随机组卷时也会用到它。考试记录表和答题明细表CREATE TABLE tb_exam_record ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 记录ID, user_id BIGINT NOT NULL COMMENT 用户ID, paper_id BIGINT NOT NULL COMMENT 试卷ID, score INT NOT NULL DEFAULT 0 COMMENT 最终得分, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0考试中 1已交卷, start_time DATETIME NOT NULL COMMENT 考试开始时间, submit_time DATETIME DEFAULT NULL COMMENT 交卷时间, PRIMARY KEY (id), UNIQUE KEY uk_user_paper (user_id, paper_id), KEY idx_submit_time (submit_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT考试记录表; CREATE TABLE tb_answer_detail ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 明细ID, record_id BIGINT NOT NULL COMMENT 考试记录ID, question_id BIGINT NOT NULL COMMENT 题目ID, user_answer VARCHAR(10) DEFAULT COMMENT 用户作答, is_correct TINYINT NOT NULL DEFAULT 0 COMMENT 是否答对0错 1对, PRIMARY KEY (id), KEY idx_record (record_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT答题明细表;考试记录表里的 uk_user_paper 唯一索引是幂等交卷的数据库层兜底上一章说的重复交卷问题光靠代码判断不够这个索引保证同一个用户对同一张试卷最多只有一条记录。submit_time 加索引是因为成绩统计基本都按时间维度按天聚合没有索引的 GROUP BY 在数据量上来时会明显变慢。4.3 成绩统计与联表查询管理端报表常用的两段 SQL数据库设计完成后管理端报表的查询是评审最爱看的部分。两个最常问的统计需求是「每日考试人数和平均分」「每张试卷的通过率」。每日考试人数与平均分SELECT DATE(submit_time) AS day, COUNT(*) AS exam_count, AVG(score) AS avg_score FROM tb_exam_record WHERE status 1 GROUP BY DATE(submit_time) ORDER BY day DESC;这段 SQL 的逻辑是只统计已交卷的记录按提交时间的天维度分组COUNT 出人数AVG 出平均分。它支撑管理端首页的考试趋势折线图一天一行数据。试卷通过率统计SELECT p.id, p.title, COUNT(r.id) AS total_count, SUM(CASE WHEN r.score 60 THEN 1 ELSE 0 END) AS pass_count, ROUND(SUM(CASE WHEN r.score 60 THEN 1 ELSE 0 END) / COUNT(r.id) * 100, 2) AS pass_rate FROM tb_paper p LEFT JOIN tb_exam_record r ON r.paper_id p.id AND r.status 1 GROUP BY p.id, p.title;这里用 LEFT JOIN因为有的试卷发布后还没人考过JOIN 不出来也要显示在报表里。通过率的判断标准是 60 分及格在 CASE WHEN 里写死如果要支持每张卷子不同及格线可以把及格线字段加到试卷表里。题目正确率分析是进阶统计用来回答「哪道题错得最多」SELECT q.id, q.content, SUM(d.is_correct) AS correct_count, COUNT(d.id) AS total_count FROM tb_answer_detail d JOIN tb_question q ON q.id d.question_id JOIN tb_exam_record r ON r.id d.record_id WHERE r.paper_id 1 GROUP BY q.id, q.content ORDER BY correct_count / total_count ASC;这道查询把答题明细表、题目表、考试记录表三张表连在一起先限定试卷再按题目聚合最后按正确率升序排正确率最低的题排最前面。它展示了多表联查能力答辩时主动讲这张报表的实现能显著拉高技术分。5. 避坑指南小程序考试系统最多人踩的 5 个翻车现场考试系统跟普通 CRUD 最大的区别在于它有状态、有并发、有严格的时间约束。下面这五个问题是我实际开发同类项目时踩过的也是答辩演示时最容易当场翻车的现场。每个问题按「现象 → 原因 → 解决」讲透你在开发时提前避开能省掉大量排错时间。5.1 登录态过期答到一半的题全丢了现象学生考试答了 40 分钟交卷时接口返回 401页面被踢回登录页重新登录后进入考试页发现答题记录全空只能从头再考。原因自定义 token 的有效期设得太短比如 30 分钟而考试时长是 60 分钟答题过程中又没有续期逻辑。前端收到 401 后直接跳登录页草稿虽然存在本地但用户不知道重新登录后可以恢复。解决把 token 有效期设成「考试最长时长 30 分钟」的余量同时在每次答题提交接口里刷新 Redis 的过期时间。前端要改造成拦截 401 后静默重新登录并重放请求而不是直接跳页。http 请求封装里加一层重试逻辑const request (url, options {}) { return new Promise((resolve, reject) { wx.request({ url, ...options, header: { Authorization: Bearer ${wx.getStorageSync(token)} }, success: async (res) { if (res.statusCode 401) { // token 过期静默重新登录后重放请求 await wxLogin() resolve(request(url, options)) return } resolve(res.data) }, fail: reject }) }) }注意这里要防止死循环如果重新登录后依然 401要设置重试次数上限否则会无限请求。一般重放一次就够连续第二次 401 说明账号本身出了问题直接跳登录页更合理。5.2 网络抖动导致重复交卷成绩记录翻倍现象学生交卷时网络超时前端自动重试了三次后台出现了三条一模一样的成绩记录成绩统计里这个学生占了三个名额。原因前端只做了按钮防重复点击没做请求幂等后端交卷接口没先查考试记录状态也没有唯一索引兜底。第一次请求其实已经写库成功只是响应没回到前端前端误以为失败又重发了。解决后端交卷接口先查 tb_exam_record 中该用户该试卷的记录status 已经是已交卷就返回已有成绩不再重新判分写入数据库层再用 uk_user_paper 唯一索引做最后防线重复插入直接抛异常并被事务回滚。前端也要改成「交卷前先查一次状态」把重试逻辑从「无脑重放」改成「查询确认后再提交」。5.3 题目顺序固定一份答案全班通用现象同一场考试里所有学生拿到的题目顺序完全一致考试开始五分钟就有学生把答案截图发群后面进考场的人照着截图填就通过了。原因后端按 sort 字段顺序返回题目前端完全按照返回顺序渲染没有任何随机化处理。只要一个人拍到完整的题目顺序答案就能批量复制。解决后端在返回试卷前对题目列表做一次随机洗牌。洗牌要基于用户维度每个用户看到不同顺序// 用 userId 和 paperId 做种子保证同一人重复进入看到相同顺序 // 不同人看到不同顺序且不影响判分逻辑 Collections.shuffle(questionList, new Random(userId.hashCode() ^ paperId));这里的种子设计很关键直接用 Collections.shuffle 的默认随机数会导致同一人刷新页面后题目顺序又变学生投诉「刚才这题顺序不是这样的」。用 userId 和 paperId 的异或做种子在试卷不变的前提下每次进入都保持同一顺序不同用户之间又不一样。5.4 前端倒计时失灵超时试卷照样交了上来现象学生把小程序的考试页面切到后台过了二十分钟再切回来倒计时已经停在某个时间点继续走实际考试时间早就超了超时的卷子后端还是正常判分。原因小程序页面切到后台后setInterval 会被系统挂起倒计时完全依赖前端定时器而且只在前端跑后端没有按开始时间做超时校验。这是客户端定时器的天然缺陷服务端完全不知道学生超时了。解决后端在交卷接口里必须校验开始时间和考试时长超过时限的要标记超时并做兜底判分LocalDateTime deadline record.getStartTime().plusMinutes(paper.getDuration()); boolean overtime LocalDateTime.now().isAfter(deadline); if (overtime) { // 标记超时按当前已答内容判分并截断提交时间 record.setSubmitTime(deadline); }前端倒计时也要在 onShow 生命周期里重新计算剩余时间而不是依赖一个一直自增的计数器。正确做法是存储「考试截止时间戳」每次切回页面用当前时间减去截止时间算出剩余秒数这样即使定时器被挂起重新回来时计算也是准的。5.5 图片题与公式题在小程序端显示异常现象题库里带图片的题干在小程序端显示空白数学公式题显示成一串 LaTeX 源码。学生直接截图发群报「题目显示不全」。原因图片用的是外链域名而这个域名没有配置到小程序的 downloadFile 合法域名列表微信直接拦截了图片请求公式题把 LaTeX 文本直接放在 content 字段小程序原生 text 组件不支持公式渲染。解决图片统一先上传到对象存储再在小程序后台配置合法域名公式题用图片替代 LaTeX 文本或者先把 LaTeX 解析成 HTML再用 rich-text 组件渲染。rich-text 组件支持大部分 HTML 标签但 script、iframe 会被过滤别在题面里做复杂交互。上传图片的管理端接口需要单独处理文件流小程序端用 wx.uploadFile 配合表单上传而不是 wx.request。6. 答辩加分项随机组卷、超时兜底与成绩可视化前面五章解决的是「能不能用」这一章讲的是「好不好用」。答辩时老师对功能完整度的关注点往往集中在三个地方是否支持随机组卷、超时处理是否严谨、管理端有没有数据可视化。这三件事改动量都不大但对评价的提升非常明显。6.1 随机组卷与超时兜底后端两个小改动随机组卷有两种实现方式。第一种是在出卷阶段就用程序从题库随机选题生成一张确定的试卷第二种是组卷后固定试题但不同考生看到相同试题的不同顺序。前者适用于题库量大、每次考试内容要重新抽取的场景后者适用于固定卷面、防作弊的场景。我的建议是两种都要做管理端组卷时支持「从题库随机抽 N 道题」的操作按钮考试下发时再做一次题目顺序洗牌两处改动加起来不到二十行代码。超时兜底在第 5 章已经给出核心代码这里补一个细节成绩展示页需要区分「正常交卷」和「超时交卷」。我的做法是在考试记录表增加一个 overtime 字段提交时置为 1前端成绩页收到这个字段后显示「超时交卷」标签。这个小细节直接回应「系统如何保证考试公平性」的提问比单纯说「我们有超时校验」更有说服力。6.2 成绩可视化用 echarts-for-weixin 展示分数分布管理端的成绩统计页面建议做成图而不是纯表格。分数分布直方图是最容易实现的展示全班/全系统学生的成绩落在哪些分段一眼看出试题难度是否合理// pages/admin/stats/stats.js import * as echarts from ../../ec-canvas/echarts Page({ onLoad() { this.loadScoreDistribution() }, loadScoreDistribution() { wx.request({ url: ${getApp().globalData.baseUrl}/api/admin/score/distribution, header: { Authorization: Bearer ${wx.getStorageSync(token)} }, success: (res) { const data res.data.data // [{range: 0-59, count: 3}, ...] this.initChart(data) } }) }, initChart(data) { this.selectComponent(#bar-chart).init((canvas, width, height, dpr) { const chart echarts.init(canvas, null, { width, height, devicePixelRatio: dpr }) chart.setOption({ xAxis: { type: category, data: data.map(d d.range) }, yAxis: { type: value }, series: [{ type: bar, data: data.map(d d.count) }] }) return chart }) } })后端对应的聚合接口很简单用 CASE WHEN 把分数分桶后 COUNT 就行。如果你用的是 Web 管理端直接引入 ECharts 的全量版本即可不必像小程序里这样引入 ec-canvas 组件。可视化不要求多炫清晰展示「分数分布」「通过率趋势」「题目正确率」三个图答辩的展示效果就已经超过大多数同类作品。做完这三个加分项回到一个我反复强调的检验标准整个系统是否经得起一次完整的演示。从学生登录、进入考试、中途杀进程重进、交卷出分到管理端看到成绩统计全程一个人完成、不需要切换账号。我自己做这类项目的习惯是每次改动后都完整走一遍这条主链路把「能跑」变成「经得起演示」。这比多写很多花哨页面管用得多也希望这五章的细节和踩坑记录能帮你在毕设季少走几十次弯路希望帮到你。本文还有配套的精品资源点击获取