做医考答题系统这个需求,乍一看好像只要把题库导进去再加个答题页面就能交差,真正动手之后才发现要考虑的东西远比想象中多。我这个项目是基于 Spring Boot 做的“医考答题练习系统”,面向的是医学类考试的刷题场景,包含单选题、多选题、判断题等常见题型,支持练习模式、模拟考试模式、错题记录、答题历史统计这些核心功能。整套系统分成前台答题端和后台管理端,用 Spring Boot 提供接口服务,MySQL 存数据,前端采用模板引擎和服务端渲染的方式,既能独立演示也能直接部署到服务器上跑。本文不打算把代码一行行贴出来,而是从真正的实现角度聊聊这套系统怎么做出来的,尤其是那些写论文和看源码时容易忽略的坑。
先说清楚这套系统适合谁。如果你正准备做 Spring Boot 课程设计、毕业设计,或者想拿一个“题库类Web系统”来练手,那这篇文章的思路可以直接作为项目的骨架;如果你是刚接触 Spring Boot 的初学者,也可以按照文中的步骤把开发环境搭起来,再把项目从零跑通,理解整个交互闭环。很多东西我在实际操作中试过,也踩了不少坑,以下内容会尽量把原因和解决过程交代清楚。
1. 项目整体设计和技术选型
1.1 医考答题系统的真实需求是什么
先别急着建表写接口,第一步是把需求理清楚。所谓“医考答题练习系统”,本质上就是一个题库训练平台,使用者一般是医学院校学生、准备执业医师考试的考生或者医院内部培训人员。对这类用户来说,核心诉求不是做一套花哨的在线考试平台,而是能随时随地刷题、判断对错、知道错在哪里、记住错题并反复练习。
系统角色上我把它划分为两类:普通用户和管理员。普通用户注册登录后,可以选择不同科目或分类进行练习,答完一题立刻看到解析,也可以按一套固定题量进入模拟考试模式,提交后查看成绩和正确率;管理员负责题目维护,包括题目录入、解析编辑、科目分类管理和系统运行数据查看。
这个系统的难点不在“增删改查”,而在两个业务点上:一是答题过程中要不要保存用户的每次作答记录,二是“错题本”到底该以什么粒度记录。如果只记录最终得分,用户想回去看自己错在哪道题上就做不到了;如果每选一次选项就落一条记录,数据量增长又快得离谱,而且用户改答案还要反复更新。我最终的方案是:一次完整的答题会话(一次练习或一场模拟考试)生成一个记录主表,明细表保存该会话下每一道题的作答快照,包括用户选项、正确答案、是否答对等,这样既能统计整场得分,也能随时回看错题。这层设计决定了后面所有的数据表和接口结构,所以放在第一步说很重要。
1.2 技术栈为什么选 Spring Boot 全家桶
这套系统的技术选型非常标准,但这种“标准”本身就是一种优势。后端使用 Spring Boot 2.7.x + MyBatis-Plus,数据库用 MySQL 8.0,前端直接使用 Thymeleaf 模板引擎加 Bootstrap 和 jQuery,权限管理用 Sa-Token 或 Spring Security,考虑到课程设计和论文写作的常见要求,我这里用 Sa-Token 做认证会明显简化代码量。整个项目用 Maven 管理,Java 环境为 JDK 1.8。
有些同学可能会纠结:既然要做前后端分离,要不要上 Vue?我的建议是别在自己的课程设计里找这个麻烦。单页应用虽然接口清晰,但会引入跨域、鉴权token存储、前端打包构建等一堆额外问题,而医考答题系统本身页面逻辑并不复杂,用模板引擎做服务端渲染,写起来快,调试方便,部署时就是一个打好的 jar 包,对评审老师展示也足够完整。
Spring Boot 的优势是自动配置和成熟生态。你会发现写代码时最花时间的反而是业务逻辑本身,而不是怎么去配置框架。MyBatis-Plus 则把单表 CRUD 操作压缩到极简,针对简单查询不需要写 XML 文件,内置的分页插件也能直接满足题目列表的分页需求。有人总觉得 MyBatis-Plus 只能做简单查询,但实际上配合条件构造器 QueryWrapper,像“按分类查询启用的题目”“查询某个用户未删除的错题记录”这类需求都能简洁地实现。
1.3 单体架构下模块怎么划分
系统虽然体量不大,但目录结构仍然要保持清晰,否则写到后面对着一堆 Controller 会头大。我这里把工程分为三层再加两个额外子模块:
- controller 层:接收前端请求,校验参数,返回页面或统一响应体;
- service 层:承载业务逻辑,比如生成练习计划、计算得分、保存答题快照;
- mapper 层:对接数据库表;
- common 包:统一返回结果 Result、异常处理、工具类;
- config 包:MyBatis-Plus 分页配置、拦截器配置。
整体架构就是典型的单体分层架构。实际开发时大家说“贫血模型”也好,说“事务脚本”也好,这类业务系统最怕过度设计,不需要硬套 DDD 那一套。模块间的依赖关系保持单向即可,比如 service 不直接操作 HttpServletRequest,而是由 controller 把当前登录用户 id 传入 service,这样既方便单元测试,也避免把前端那套 Http 对象一路传到数据库操作层。
模块划分上,我拆成了用户模块、题库模块、答题模块、错题模块和统计分析模块。答题模块是其中最核心的,它既要负责取出题目和选项,又要负责保存用户作答明细和计算得分。这里我建议把“答题会话”和“题目作答”两个概念拆开,分别对应一张主表和一张明细表,理由会在后文的表结构里详细说明。前置设计做完之后,基本上写任何功能时脑子里都有一张清晰的调用路径图了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计:这套系统的地基
2.1 核心表结构与设计思路
数据库设计是整个项目里最不能偷懒的部分。医考答题练习系统围绕“用户”、“题目”、“作答会话”三条主线展开,我最终设计了七张核心表:
- sys_user:用户表,包含账号、密码、昵称、角色等;
- exam_category:科目分类表,比如“内科护理学”、“生理学”、“临床执业医师综合”;
- exam_question:题目表,存放题干、选项、正确答案、题目类型、解析、难度等;
- exam_session:答题会话表,记录一次练习或一次模拟考试的基本信息;
- exam_session_detail:会话明细表,记录每一道题的作答情况和结果;
- exam_wrong_book:错题本表,记录用户答错的题目及错误次数;
- sys_notice:公告表,用来在首页做简单的消息通知。
举个例子说明主表和明细表的必要性。用户开始“生理学模拟考试(50题)”,系统先生成一条 exam_session 记录,包含用户id、分类id、考试类型、总题数、总分数;用户每次切换下一题或者最终交卷时,把这道题的用户选项、正确答案、是否正确写入 exam_session_detail。这样一场考试的数据是“一个主记录 + 最多50条明细”,既不是一条大字段塞50个题目,也不是每次点击选项都往数据库里打点。查成绩时只读主表;查“这道题为什么错了”时,根据 session_id 去查明细即可。
错题本不要设计成每次答错就插一条新记录。如果用户在同个知识点反复练习,一天能产生几百条重复错题数据,正确做法是 exam_wrong_book 按 user_id + question_id 做唯一约束,错一次增加 wrong_count,答对了且连续正确就减少或移除。我实际项目里是只记录“当前处于未掌握状态”的错题,也就是最近一次作答仍然错误才保留,如果随后答对了则把该错题标记为已消除或者删除,这样错题本里的数据才是真正有复习价值的。
2.2 题目的字段设计隐藏要点
题目表看起来简单,里面有几处设计特别容易踩坑。第一个是选项的存储方式。有些入门教程会把选项设置成固定字段,比如 option_a、option_b、option_c、option_d,但这套设计遇到判断题就不好办,一旦以后加一个 E 选项更是灾难。我这边把整道题的选项统一存成一个 JSON 字符串,比如 [{"code":"A","content":"肺部感染"},{"code":"B","content":"肺结核"}],读取后在 Java 中解析成对象列表,展示、排序和动态添加选项都更灵活,数据库也省得建一堆冗余列。
接着是正确答案的存储。这里要注意单选题存一个字符串如“A”,多选题存成“ABD”,判断题存储成“T”或“F”,通过题型的 type 字段区分。业务层判断用户作答是否正确时,先把多选题的答案字符串拆成数组或集合,再看用户选的集合和答案集合是否相等,顺序不同不算错。
再有一个隐藏需求是“乱序练习”。很多刷题系统喜欢把选项顺序打乱,免得用户背位置。这个功能如果放在数据库解析时做,会给 SQL 和查询带来麻烦。我选择的做法是查询出来后在前端或 service 层对选项列表做一次 shuffle,不过要注意解析题如果带“A. 选项一”这种文本,打乱时必须同步处理题干中的引用关系,所以我在设计题型时就把解析统一存成了结构化文本,避免选项顺序变动导致解析错位。这段经验在论文的业务难点描述里也很加分。
2.3 索引和初始化数据不要忽略
构建表的时候不要只盯着字段,索引设计直接决定系统好不好用。实际查询中频率最高的是“按分类查启用题目”“按会话id查明细”“按用户查错题”,所以至少要给以下字段添加索引:
- exam_question:category_id、status,这两个常用于过滤;
- exam_session:user_id、create_time,用于查历史记录;
- exam_session_detail:session_id,用于回看某次考试的明细;
- exam_wrong_book:user_id、question_id 建立联合唯一索引。
数据初始化部分,在建库后要预置一个管理员账号、几个科目分类,以及一批可以直接用于演示的题目。很多初学者在这里容易偷懒,直接拷几道题就运行,结果到了展示环节,发现前端的题目列表空荡荡。课程设计型项目最好准备至少100道以上的基础题库,能够支撑分页、分类筛选等功能演示。题目内容可以手工录入一部分,也可以写一段 Java 初始化数据的 CommandLineRunner,项目启动时自动检查数据量并填充示例数据,这个方法对后期的调试和演示都非常友好。
3. 后端核心功能是如何实现的
3.1 登录认证和角色权限控制
这套系统的权限控制并不复杂,我用 Sa-Token 主要因为集成成本低。用户提交账号密码后,后端校验密码使用 BCrypt 加密比对,成功后调用 StpUtil.login(userId) 完成会话创建,前端每次请求带过来的是 Sa-Token 内置的 Token,不需要像 JWT 那样手动封装一套解析逻辑。用户角色我用一个简单的字段区分,管理员访问后台管理接口时写一个 Sa-Token 的拦截器或注解校验,不满足就跳转到登录页并提示“无权访问”。
用 Sa-Token 有几点很省心:内置了会话过期管理、踢人下线、权限注解校验,这些功能如果自己用 JWT 实现,工作量并不小,而且容易出现 Token 校验上的安全漏洞。当然,很多学校教材里默认用 Spring Security + JWT,如果论文有硬性规定就至少掌握 Spring Security 基本过滤器链路。但在实际交付的这个项目里,我更看重效率和可读性,Sa-Token 配合拦截器的方式很直观,复试被问起也能把原理讲清楚。
注册流程上加了一点限制:普通的用户账号需要通过邀请码注册,而管理员账号仅允许在数据库中先预置。这样可以避免演示环境被陌生人随意注册成管理员。密码参数上,我设置了 BCrypt 的强度为默认10,注册接口中做过一次明文长度的校验,防止异常的超长密码给加密带来性能问题。
3.2 答题核心流程:不仅仅是出题和判分
答题流程是这个系统业务逻辑最集中的地方。我把它分成“开始答题”“作答保存”“题目切换”“交卷结算”四段来看。
开始练习时,用户从前端页面选择一个科目分类,点击“开始练习”,后端创建一个 exam_session,并根据科目分类查出全部启用的题目。练习模式每次取一道题,前端展示题目内容、选项和“上一题/下一题”按钮;模拟考试模式则在交卷前不允许回看答案,这和练习模式最大的不同在于是否即时显示解析。这个逻辑必须在后端限流与查询时区分,不要把练习模式下的“显示答案”接口暴露给模拟考试,否则用户直接把所有选项都试一遍就能拿高分。
作答保存这里,我采用的是提交时批量保存而不是每点击一次选项向后端发一次请求。前端的交互是:用户点击某个选项时,先把当前题目id和选中答案放在本地变量里,点击“下一题”或切换题目时,把上一题的作答记录批量发送到后端,后端一次性写入 exam_session_detail。如果用户答完最后一题没有进行任何操作,点击“交卷”时前端把本地未提交的全部记录补发一次,之后后端进入结算流程。
判分逻辑比较容易写错的是多选题。实现时要先判断用户作答的答案是否为空,如果用户漏选任何一项,都不得分;如果用户选择了正确答案之外的干扰项,该题判为错误。我将“是否答对”的状态也独立记录为 correct_flag 字段,这样后端的得分计算就不需要再次解析每道题的答案了,统计时直接对明细表的 correct_flag 做聚合就行,效率高且不容易出偏差。
3.3 错题本和成绩统计的联动实现
错题本功能表面上只是“查一下这个用户答错了哪些题”,实际上需要和答题流程打通。我在保存 exam_session_detail 时,每条明细写完后会同步维护 exam_wrong_book。为了方便这一步操作,我没有在业务代码里写复杂的同步逻辑,而是设计了一个统一封装的服务方法:保存一份作答明细后,根据正确性执行错题记录的 upsert 或消除逻辑。
成绩统计部分,我做了两个维度的数据展示。用户端首页显示“总的练习人数、练习次数、平均正确率、当前分类进度”;个人中心显示自己练习的曲线图,比如最近七天的答题数量和正确率走势。因为是单体项目,做图表我采用了后端返回统计数据、前端用 ECharts 绘制折线图,比后端直接生成图片要简单得多,接口返回的数据结构也是普通的 List
统计分析有一个新手经常掉进去的“性能陷阱”:每次查询成绩统计时都把某个用户的所有明细记录全部查出来,再在应用层循环统计。数据少的时候看不出问题,等刷题记录达到上千条,接口响应就会明显变慢。我的处理方式是在统计类接口里使用 SQL 聚合查询,例如用 GROUP BY question_type, correct_flag 直接拿到各题型正确数,结合 exam_session 表的 create_date 用 GROUP BY DATE(create_time) 计算每日刷题数,完全不需要把明细加载到内存来计算。
4. 前端页面和答题体验细节
4.1 页面结构与关键路由设计
页面设计走的是最实用的“服务端渲染 + 少量 JS 交互”方案。公共的页面结构是这样:顶部是导航栏,登录后显示用户名、练习入口、错题本、个人中心;中部是内容区域;底部放版权信息。模板引擎使用 Thymeleaf 后,页面写在 resources/templates 目录下,CSS 和 JS 放在 resources/static 中。
从用户角度出发,主要页面包括:
- 首页:系统介绍、最新公告、开始练习按钮;
- 分类选题页:展示科目列表和每个分类下的题目数量;
- 答题页:核心页面,展示题目、选项、答题进度;
- 答题结果页:展示本次练习得分、正确数、错误数和解析入口;
- 错题本页:按错题列表展示题目、错误次数、最近错误时间;
- 个人中心:修改密码、查看历史练习记录;
- 后台管理页:管理员对题目、分类、用户、公告进行管理。
在写答题页时有个关键决定:使用隐藏表单保存未确定的答案数据,还是直接在 JS 中定义全局对象保存。我用后者,维护一个 answers 对象,形式是 { questionId: selectedOption },用户切换题目时更新这个对象,并顺便把上一题的数据提交到后端。这么做的原因是为了给用户更好的响应体验,如果每点一个选项就同步发 ajax 请求,网络稍差时页面会卡顿,体验很糟糕;但是这里必须考虑到浏览器刷新会丢失未保存数据的问题,所以我在离开答题页或交卷前加入了 beforeunload 事件提示用户“有未保存的作答记录,确定离开吗”。
4.2 答题交互的细节优化
答题页是所有前端工作的重点。题目选项使用了卡片式按钮,鼠标悬停有高亮效果,选中后填充为蓝色边框。这种视觉效果实现成本很低,但明显提升使用观感。对于多选题,前端默认展示“多选”标识,选项点击后保持已选状态,再次点击取消选择,这个交互必须做对,不然很多用户会误提交未答完整的题。
模拟考试模式需要一个倒计时功能,我在前端用了 setTimeout 封装的计时器,每秒更新时间显示,考试时间到时自动触发提交表单。这里的难点不在倒计时本身,而是“如何防止用户通过开发者工具绕过前端时间限制”。真实考试系统必须在后端记录考试开始时间,并在交卷接口校验收卷时间;我这里的模拟考试虽然没有那么严格,但仍做了后端时间校验,避免把考试时长做成纯前端逻辑。判断逻辑很简单:exam_session 建立时记录 start_time,交卷时如果当前时间早于开始时间加考试时长则允许直接交卷,晚于则按整场超时处理并强制收卷。
解析的展示逻辑也需要细化。练习模式下用户点击“查看解析”时,所有字体为绿色即正确答案,用户选错的选项红色显示,同时底部展示完整解析文本和知识点标签。模拟考试模式下,在交卷之前不显示当前题目的解析和正确与否,只有交卷后的“查看解析”页才允许完整展示。这样区分是因为练习模式的核心目标是即时反馈,而考试模式需要模拟真实考场环境,答案回看放在交卷后更合理。
4.3 后台管理端的页面实现要点
后台管理系统首要解决的是“如何高效维护大量题目”。页面部分我设计了三页:分类管理页、题目列表页、题目编辑页。题目录入采用表单页而不是弹窗,因为题目本身包含题干、多个选项、答案、解析等较多字段,弹窗很容易遮住内容导致输入混乱;整页编辑反而宽敞且好做页面内校验。
题目列表页要支持按分类筛选和按题干搜索,分页使用 MyBatis-Plus 的分页插件完成。这里前端表格我用一个比较轻的思路:默认每页10条,底部放分页控件,点击页码时通过 URL 参数拼接后重新渲染页面。整个系统没有引入打包工具,Thymeleaf 模板继承机制能避免大量重复代码,比如后台公共页面可以抽成一个 layout html,再让具体页面 fragment 填充内容区块。
后台接口一律加前缀 /admin/,并通过 Sa-Token 的权限校验保证用户角色必须是 admin。开发过程里我踩过一个坑:直接使用注解做权限拦截时,如果用户未登录就访问后台接口,Sa-Token 抛出的异常类型是 NotLoginException,需要在全局异常处理器里捕获并跳转到登录页,而不是直接返回 500;同样地,权限不足要返回 403 页面而不是堆栈信息。全局异常处理这一节代码不多,但能大幅提升用户体验,也为论文增加了“系统健壮性设计”的内容。
5. 环境搭建与调试部署
5.1 本地开发环境准备清单
拿全新电脑把项目跑起来需要准备以下工具,按顺序安装基本不会出问题:
- JDK 1.8(配置 JAVA_HOME);
- Maven 3.6+(配置本地仓库;
- MySQL 8.0(本地服务启动);
- IDEA 开发工具(装 Lombok 插件);
- Navicat 或 MySQL Workbench(数据库管理)。
为什么会单独强调 JDK 版本?网上不少项目用的是 Spring Boot 2.7,要求 JDK 8 就可以,但如果你直接装了最新版 JDK 17 或 JDK 21,Maven 编译时可能出现 Lombok 版本不兼容或者 maven-compiler-plugin 默认版本太旧的问题。这个项目就是 JDK 1.8 环境下的,提前确认一下自己的 IDEA 项目 SDK 是不是 1.8,编译级别是否为 1.8,能避免很多“我代码没问题但项目跑不起来”的情况。
开发环境配置还有一个容易忽略的细节是 Maven 的镜像源。国内直接访问中央仓库经常超时,在 settings.xml 里配置阿里云镜像即可。IDEA 中 Maven 的 Runner 设置里把 JRE 也改成 1.8,否则即使全局 JDK 是 1.8,Maven 运行时可能还是用 IDEA 默认的 JRE。
5.2 创建数据库和修改配置
项目运行时首先需要创建数据库。我的初始化 SQL 脚本一般放在 sql 目录下,直接执行脚本即可完成建表和初始化数据。数据库名称建议用 exam_system,使用 utf8mb4 作为字符集。在 Spring Boot 的 application.yml 中重点检查这几个配置项:
yaml复制spring:
datasource:
url: jdbc:mysql://localhost:3306/exam_system?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 你的数据库密码
driver-class-name: com.mysql.cj.jdbc.Driver
URL 里带 serverTimezone 和 characterEncoding 是 MySQL 8.0 的硬性要求。本地库密码比较弱比如 123456 时,高版本 MySQL 可能还会要求 allowPublicKeyRetrieval=true,否则偶尔会报“Public Key Retrieval is not allowed”异常。这是从 MySQL 8.0 开始的认证插件变化导致的问题,不是代码 bug。
引入 MyBatis-Plus 之后还要在配置文件中设置 mapper.xml 路径和实体类别名。如果实体类没有加 @TableName 注解,默认表名是由类名转下划线生成,稍有命名不一致就要在注解里手动指定,不然启动后一执行查询就报“Table doesn't exist”。
5.3 项目打包和部署运行
本地开发时直接在 IDEA 中运行主类即可,但在正式环境或者给别人演示时,通常打成一个 jar 包执行,这样不需要 IDE。操作流程是:先执行 mvn clean package -DskipTests,然后 target 目录下会生成 exam-system-0.0.1-SNAPSHOT.jar,最后运行:
bash复制java -jar exam-system-0.0.1-SNAPSHOT.jar
如果服务器上的 MySQL 不在本机,记得把数据源地址改成正确的 IP。若需要后台运行,用 nohup java -jar exam-system-0.0.1-SNAPSHOT.jar > app.log 2>&1 &,这样关闭终端也不会停掉。查看日志时直接 tail -f app.log,可以看到“Started ExamApplication in x seconds”的启动成功标志。
部署过程中常见端口冲突问题,默认项目跑在 8080 端口,如果被其他进程占用,启动会报 Web server failed to start。解决方法是换一个端口,在启动参数里覆盖环境属性:
bash复制java -jar exam-system-0.0.1-SNAPSHOT.jar --server.port=8081
不过前端模板里如果有写死的接口地址,就尽量保持一致,否则访问页面时接口请求会指向旧端口导致数据加载失败。
5.4 调试过程中的高效排查方法
开发这套系统的过程中,我用的调试方式主要是三种:浏览器开发者工具、IDEA 断点调试、SQL 日志输出。前端页面打不开或数据不对时,先看 Network 面板里接口返回的状态码及响应内容,是 500 还是 404 还是 401,定位到具体接口后再去看后端日志。
MyBatis-Plus 默认打印 SQL 日志的配置可以在 application.yml 中开启:
yaml复制logging:
level:
com.example.exam.mapper: debug
这样控制台会打印每条 SQL 语句和参数,常见问题比如“查询结果明明为空但代码里逻辑判断没问题”一眼就能看到 SQL 条件是否正确。如果觉得日志太乱,可以先按条件查询表数据,把结果数量和期望值对比一下,八成问题是出在查询条件多了一个 status=1 或者分类 id 不匹配。
使用断点调试时,重点不是看每一行代码,而是观察关键方法入口参数和返回值,比如答题提交接口的入参对象是否包含了所有明细,Service 层是否在事务中。有时把事务注解去掉后,错误能“暂时消失”,但那是因为部分写入失败没有报错,并不是正确的修复方式。遇到这种问题就把每个写入到库的操作单独执行一遍,检查是否有字段长度超限或数据库约束冲突。
6. 常见问题与排错经验
6.1 启动阶段最容易踩的坑
很多同学把项目跑不起来归咎于自己电脑环境有问题,实际上多数是配置或依赖冲突。下面列几个高频问题及解决思路。
项目启动时报 java.sql.SQLNonTransientConnectionException: Public Key Retrieval is not allowed,这通常是 MySQL 8.0 连接串没加 allowPublicKeyRetrieval=true。把完整数据源 URL 粘贴到配置文件后重新启动即可。
启动时报 Failed to configure a DataSource: 'url' attribute is not specified,这是没从 resources 目录加载到 application.yml。检查编译后的 target/classes 目录下是否存在配置文件,如果不存在就是编译时排除掉了 resources 文件,需要检查 pom.xml 里的 resources 配置。
启动时报 Error creating bean with name 'xxxMapper',多半是 MyBatis-Plus 的 Mapper 接口没加 @Mapper 注解,或者启动类上的 @MapperScan 没有扫描到对应包。确认 Mapper 接口所在包路径和 @MapperScan 的值一致即可。
6.2 中文乱码和时区问题
中文乱码出现最多的情况是页面中文正常但数据库中存的是问号,或者反过来。页面中文正常说明模板编码没问题,数据库中存成问号则要关注 MySQL 客户端连接和表字段的字符集。建库时使用 utf8mb4,连接串中指定 characterEncoding=utf8,一般就不会乱码。我这里所有表设计都未省略 charset,创建表的语句直接带上 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci。
时区问题主要影响日期字段。如果数据库连接串没加 serverTimezone=Asia/Shanghai,有时会报 The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized,这个错误本质上是 MySQL 返回的时区字符串和 Java 时区解析不兼容。解决方案就是显式指定 serverTimezone,或者登录 MySQL 执行 set global time_zone = '+08:00'。统一时区后,前端页面展示时间时要留意后端返回的 LocalDateTime 默认格式带有“T”,比如“2024-01-01T10:20:30”,建议在 JSON 序列化配置中指定 pattern:yyyy-MM-dd HH:mm:ss,这样前端拿到的就是直观的格式。
6.3 答题保存和成绩显示不一致的排查思路
系统上线前,我专门测过大量异常操作,发现一个很有意思的问题:答题过程中用户快速连续点击下一题,偶然会出现成绩统计的分正确数比实际答对数量少的情况。排查半天后发现原因是前端点击事件没有加锁,导致同一道题的作答明细重复提交了两次。第一次提交把答案存为 A,第二次又把相同的记录再插入一遍,虽然主键不冲突,但明细表产生了多条数据,统计 SQL 又用 SUM(correct_flag) 就会把重复记录算进去。
解决方案有两层:一是前端在提交按钮点击后立即禁用按钮,并在异步请求返回前阻止继续点击;二是明细表增加一个业务唯一键,即 session_id + question_id,插入使用 ON DUPLICATE KEY UPDATE 或先查后插的方式,保证同一场考试内一道题只保存最终一次作答结果。业务唯一键的设计很值得推行,它能从数据库层面兜住前端异常操作,而不是完全依赖代码处理。
6.4 关于性能优化和并发作答的思考
如果这个系统将来要部署给一个班级的学生同时使用,正常情况下性能不会有问题,但也要提前做一些缓存优化。我主要增加了三个层面的处理:一是首页的公告和科目数量统计加了简单的本地缓存,Spring Cache 配合 @Cacheable 注解即可,几分钟过期一次;二是在练习模式下发题时,使用 Page 查询代替一次性查询全部题目,避免题目数量很大时一次加载太多数据;三是把一些只读配置如系统参数放到了 application.yml 或本地缓存,避免每次请求都去查数据库。
讲到并发这块,一个典型场景是多人同时交卷。后端在做“当前用户交卷结算”时,如果同时对同一用户的会话进行多次提交,容易造成重复明细或统计误差。在实际设计里我使用了乐观锁控制,在 exam_session 表中增加 version 字段,交卷时通过 UPDATE exam_session SET status = 2, version = version + 1 WHERE id = ? AND version = ? 来判断是否重复提交,影响行数为 0 说明已经交过卷,后端直接返回“请勿重复交卷”。这种乐观锁方案比 synchronized 锁更简单,也更适合单体应用的正常使用场景。
最后的实操心得扩展
写到这里,这套医考答题练习系统的核心部分基本聊完了。从我的实际交付经验来看,Spring Boot 本身并不卡人,真正花时间的反而是需求边界、表结构设计、答题状态流转这几点。很多朋友拿到类似题目第一反应是赶紧把界面搭出来,但如果没有把“会话和明细拆分”“错题状态同步”“后端时间校验”这些想清楚,后续补 Bug 的时间会远超写代码的时间。
如果你要基于这篇思路自己做项目,我的建议是先从“用户开始一次答题并得到成绩”这条最核心的链路走通,别一上来就铺开做后台管理、公告、个人中心这些周边功能。等主流程闭环跑通,你会对整个系统的数据流转有非常明确的感知,再做错题本、统计图表和后台维护时,几乎不太会返工。
另外有两个细节我觉得特别值得保留。一个是把题目选项设计成 JSON 存储,虽然第一眼看起来没有传统的 A/B/C/D 四个字段直观,但实际开发时带来的灵活性非常大;另一个是无论如何都要给明细表加业务唯一键,它能帮你挡住大量非常规操作造成的脏数据。实践之后你再回头看书本上讲的“数据库设计范式”和“事务边界”,会更有体会。最后提醒一句:如果你用这套思路去写课程设计或论文,画系统架构图时把会话主表和明细表的关系明确标注出来,答辩时被问到“为什么这么设计”也会更容易讲透。
