十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从CRUD到高并发:JavaWeb在线答题平台生产级架构设计与实践

从CRUD到高并发:JavaWeb在线答题平台生产级架构设计与实践 简介这是一套基于JavaWeb技术栈开发的在线答题平台完整源码面向高校计算机专业师生及Java初学者用于课程设计、毕业设计或教学实训场景解决在线测验、成绩统计与班级分班管理等实际教学需求。资源包共388个文件涵盖50个核心Java后端逻辑类、41个JSP页面模板、38个JavaScript交互脚本、22个CSS样式文件及大量静态资源如PNG/GIF图标、字体文件等整体压缩后仅6.1MB结构清晰、前后端分离明确便于快速部署与二次开发。目前已有61人学习下载。读者可直接运行项目获得包含学生单次答题防重复提交、教师多维度成绩可视化统计总体/地区/班级、基础数据全量CRUD管理学院/专业/班级/试题/学生以及响应式前端界面在内的完整功能闭环预览中可见Layui、Bootstrap、TinyMCE等主流框架集成具备良好的工程实践参考价值。1. 项目缘起为什么一个“在线答题平台”值得深挖最近在整理硬盘里的老项目翻到了一个尘封已久的压缩包文件名是“基于javaweb的在线答题平台.zip”。相信很多Java开发者尤其是学生朋友都接触过类似的项目。它看起来平平无奇不就是个增删改查CRUD的考试系统吗网上源码一抓一大把似乎没什么技术含量。但恰恰是这种“看起来简单”的项目最能暴露一个开发者从“会用框架”到“理解工程”的鸿沟。我当年接手过一个类似系统的重构任务原版代码就是典型的“学生作业”风格JSP里嵌满了Java代码和SQL业务逻辑和页面渲染搅在一起功能跑通就算完事。结果呢用户量稍微上来一点页面加载奇慢并发提交试卷时数据库锁死更别提添加一个新题型比如编程题需要把整个项目翻个底朝天。所以今天我不只是把这个压缩包解压、运行一下了事。我想以这个“在线答题平台”为蓝本结合我这些年踩过的坑和积累的经验从头到尾拆解一个真正具备生产环境思考的JavaWeb项目该如何构建。我们会超越简单的“用户-试题-试卷”模型去探讨如何设计一个灵活、高性能、易扩展的答题系统。无论你是正在做毕业设计的学生还是需要快速搭建内部培训系统的开发者这篇文章都能给你提供一套可直接落地的架构思路和实操细节。2. 超越CRUD在线答题平台的核心架构设计拿到一个需求切忌立刻打开IDE开干。我们先花点时间想清楚一个在线答题平台它的核心实体和业务流程到底是什么这决定了整个系统的骨架。2.1 领域模型深度解析大多数入门项目会设计三张主表User用户、Question试题、Exam试卷/考试记录。这没错但太粗糙了。在实际应用中我们需要更精细的建模。首先看试题Question。它绝对不止一个content题干和answer答案字段。我们需要支持多种题型单选题、多选题、判断题、填空题、简答题未来可能还要支持编程题。用一个大文本字段存所有信息后续解析和渲染会是噩梦。我的设计是采用“元数据内容”的分离模型。核心实体表设计思路题目类型表 (question_type)定义系统支持的题型如SINGLE_CHOICE(单选),MULTI_CHOICE(多选),TRUE_FALSE(判断),FILL_BLANK(填空),SHORT_ANSWER(简答)。每个类型可以关联一个特定的渲染和处理组件。题目表 (question)存储题目的通用元信息。id主键。type_id关联题型。content题干文本可能包含图片、公式的HTML或Markdown。difficulty难度系数1-5。knowledge_points知识点标签JSON数组或关联表如[“Java基础”, “多线程”]。creator_id创建者。status状态审核中、已发布、已禁用。注意这里不存储选项和标准答案因为它们因题型而异。题目扩展表根据题型设计不同的扩展表来存储特异化数据。选择题选项表 (question_choice)question_id,option_label(如 ‘A’, ‘B’),option_content,is_correct(布尔值标识是否为正确选项)。对于多选题会有多个is_correcttrue的记录。填空题答案表 (question_fill_blank)question_id,blank_index(第几个空),correct_answer(标准答案)可能还有answer_type(精确匹配、关键字匹配、正则匹配)。简答题/编程题评分要点表 (question_rubric)question_id,point(评分要点描述),score(该要点分值)。这样设计的好处是扩展性极强。要新增一种题型比如连线题只需在question_type加一条记录并新建一张question_matching扩展表业务逻辑层对应新增一个处理器即可原有数据结构和代码影响最小。其次是考试/练习 (Exam_Paper)与**考试记录 (Exam_Record)**的分离。这是很多初期设计容易混淆的地方。试卷模板 (exam_paper)定义了一套固定的题目组合相当于考试的“蓝图”。它包含试卷标题、描述、总时长、总分、题目列表通常是一个包含题目ID和分值的JSON配置或者通过中间表paper_question关联。一旦发布模板本身不应再修改。考试记录 (exam_record)代表一个用户的一次具体作答过程。它关联user_id和paper_id记录开始时间、提交时间、状态进行中、已提交、超时、最终得分等。它的核心是答案快照。2.2 答案快照与抗抵赖设计exam_record中如何存储用户的答案直接存用户提交的原始答案字符串吗这会有问题。假如管理员在考试后修改了题目的选项内容或正确答案那么回溯时用户看到的题目和当初作答的题目就不一致了容易产生纠纷。因此必须引入快照Snapshot机制。在用户开始考试时系统应根据当时的试卷模板生成一份题目快照存入record_question_snapshot表。这份快照包含了当时题目的完整信息题干、选项内容和正确性、分值等。同时用户提交的答案存储在record_answer表中关联快照ID。这样无论后台题目如何变化用户查看到的答题历史永远是当时考试时的原样保证了过程的公正可追溯。这是生产级系统必须考虑的设计。2.3 高并发提交与性能考量在线答题尤其是考试结束前的最后一分钟大量用户同时提交试卷会对服务器和数据库造成巨大压力。如果采用“一提交就立即计算所有题目分数并写库”的同步方式数据库连接池很可能被耗尽导致部分用户提交失败。解决方案是异步化与批处理提交即成功用户点击提交后系统只做两件事a) 将exam_record状态标记为“已提交”b) 将用户的所有答案关联题目快照ID作为一个消息发送到消息队列如RabbitMQ, Kafka。后台评分Worker部署独立的评分服务消费消息队列中的答案消息进行评分计算。计算时只读快照数据不受主库事务影响。批量更新评分Worker可以积累一定数量的评分结果后批量更新exam_record的分数字段极大减少数据库的写操作频率。对于实时性要求不高的练习模式这个方案非常有效。对于需要实时显示分数的考试可以在提交后通过WebSocket或长轮询等待评分Worker处理完成后主动将结果推送给前端。3. 技术栈选型与工程化实践说完了核心设计我们来看看如何用JavaWeb技术栈实现它。我不会只列出一堆技术名词而是解释为什么选它以及如何组织代码。3.1 后端技术栈Spring Boot MyBatis-Plus为什么是Spring Boot因为它提供了“约定大于配置”的快速启动能力内嵌Tomcat依赖管理清晰。对于这个项目我们选择以下核心依赖Web:spring-boot-starter-web(基础)模板引擎: 我强烈建议前后端分离使用spring-boot-starter-json提供RESTful API前端用Vue/React。如果非要服务端渲染Thymeleaf比JSP更现代。数据层:spring-boot-starter-data-jdbc或mybatis-spring-boot-starter。这里我推荐MyBatis-Plus。它是在MyBatis基础上的增强提供了强大的CRUD封装、条件构造器、分页插件等能极大减少样板代码。例如对于Question表的增删改查几乎不用写XML。数据库: MySQL 8.0。务必使用InnoDB引擎字符集utf8mb4以支持完整Emoji。连接池: HikariCPSpring Boot 2.x默认性能最好。缓存: 引入spring-boot-starter-data-redis用Redis缓存热点数据如试卷模板、题目详情注意缓存击穿和雪崩问题。消息队列: 对于异步评分引入spring-boot-starter-amqp集成RabbitMQ。3.2 分层架构与包结构一个清晰的包结构是项目可维护性的基础。避免所有类都堆在同一个包下。src/main/java/com/yourcompany/onlineexam/ ├── OnlineExamApplication.java // 启动类 ├── config/ // 配置类 │ ├── MybatisPlusConfig.java // MP分页等配置 │ ├── RedisConfig.java │ └── WebMvcConfig.java // 拦截器、跨域配置 ├── controller/ // 控制层接收请求返回JSON │ ├── api/ │ │ ├── QuestionController.java │ │ ├── ExamPaperController.java │ │ └── ExamRecordController.java │ └── web/ // 如果做服务端渲染放这里 ├── service/ // 业务逻辑层 │ ├── IQuestionService.java // 接口 │ ├── impl/QuestionServiceImpl.java // 实现类 │ ├── IExamPaperService.java │ └── impl/... ├── manager/ // 通用业务处理层聚合多个service的复杂逻辑 │ └── ExamServiceManager.java // 例如组卷、开始考试、提交考试 ├── dao/ // 数据访问层即Mapper接口 │ ├── QuestionMapper.java │ ├── QuestionChoiceMapper.java │ └── ... ├── entity/ // 实体类与数据库表对应 │ ├── Question.java │ ├── QuestionChoice.java │ └── ... ├── dto/ // 数据传输对象用于前后端交互 │ ├── QuestionDTO.java // 可能组合了题目和选项 │ ├── ExamPaperDTO.java │ └── ... ├── vo/ // 视图对象用于接口返回 │ ├── QuestionVO.java // 给前端渲染用的题目对象 │ └── ExamRecordVO.java ├── enums/ // 枚举类 │ ├── QuestionTypeEnum.java │ └── ExamStatusEnum.java └── utils/ // 工具类 ├── JsonUtils.java ├── SnapshotUtils.java // 题目快照生成工具 └── ScoreCalculator.java // 评分工具类关键点说明entity是纯数据对象只有字段和getter/setter用于和数据库交互。dto用于接收前端传入的参数或service层之间传递数据可能包含多个entity的字段。vo是专门为前端接口定制的返回对象只包含前端需要的数据格式友好。引入manager层是为了解决复杂业务场景下一个业务动作需要调用多个service的问题避免service之间循环依赖让service只关注自己领域的原子操作。3.3 基于MyBatis-Plus的高效数据操作以题目Question为例展示MyBatis-Plus的威力。首先定义实体类Data TableName(t_question) // 指定表名 public class Question { TableId(type IdType.AUTO) // 主键自增 private Long id; private Integer typeId; private String content; private Integer difficulty; private String knowledgePoints; // JSON字符串存储 private Long creatorId; private Integer status; TableField(fill FieldFill.INSERT) // 自动填充 private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }然后创建Mapper接口无需写XMLpublic interface QuestionMapper extends BaseMapperQuestion { // 简单的CRUD方法已由BaseMapper提供 // 如果需要复杂查询可以在这里定义方法并配合Select注解或XML Select(SELECT * FROM t_question WHERE knowledge_points LIKE CONCAT(%, #{point}, %)) ListQuestion selectByKnowledgePoint(Param(point) String point); }在Service中你可以这样用Service public class QuestionServiceImpl extends ServiceImplQuestionMapper, Question implements IQuestionService { public PageQuestionVO getQuestionPage(PageParam param, QuestionQueryDTO query) { // 使用QueryWrapper构建查询条件 QueryWrapperQuestion wrapper new QueryWrapper(); if (StringUtils.isNotBlank(query.getKeyword())) { wrapper.like(content, query.getKeyword()); } if (query.getTypeId() ! null) { wrapper.eq(type_id, query.getTypeId()); } wrapper.orderByDesc(create_time); // 分页查询Page是MP的分页对象 PageQuestion page new Page(param.getPageNum(), param.getPageSize()); PageQuestion questionPage baseMapper.selectPage(page, wrapper); // 将PageQuestion 转换为 PageQuestionVO这里省略转换过程 return convertToVoPage(questionPage); } public boolean createQuestion(QuestionCreateDTO dto) { // 1. 保存题目基本信息 Question question new Question(); BeanUtils.copyProperties(dto, question); this.save(question); // 2. 根据题型保存扩展信息如选项 if (QuestionTypeEnum.SINGLE_CHOICE.getCode().equals(dto.getTypeId()) || QuestionTypeEnum.MULTI_CHOICE.getCode().equals(dto.getTypeId())) { ListQuestionChoice choices dto.getChoices().stream().map(choiceDto - { QuestionChoice choice new QuestionChoice(); choice.setQuestionId(question.getId()); choice.setOptionLabel(choiceDto.getLabel()); choice.setOptionContent(choiceDto.getContent()); choice.setIsCorrect(choiceDto.getCorrect()); return choice; }).collect(Collectors.toList()); // 批量插入选项 questionChoiceService.saveBatch(choices); } // ... 处理其他题型 return true; } }可以看到MyBatis-Plus让基础的CRUD操作变得极其简洁我们可以把精力集中在复杂的业务逻辑组装上。4. 核心业务流程实现与踩坑点有了扎实的架构和清晰的分层我们来实现几个最核心的业务流程组卷、考试计时与提交、异步评分。4.1 智能组卷策略的实现组卷不是随机挑几道题。它需要根据规则知识点分布、难度、题型数量来筛选题目。这是一个典型的条件查询随机选取问题。第一步定义组卷规则ExamPaperRule可以设计一个规则对象前端以JSON格式传递{ totalScore: 100, duration: 120, sections: [ { name: Java基础, questionType: SINGLE_CHOICE, scorePerQuestion: 2, count: 20, knowledgePoints: [语法, 面向对象], difficultyRange: [1, 3] }, { name: 多线程, questionType: MULTI_CHOICE, scorePerQuestion: 5, count: 5, knowledgePoints: [线程安全, 锁机制], difficultyRange: [3, 5] } ] }第二步按规则查询题目池这里最容易踩的坑是如何高效地从海量题库中随机抽取不重复的题目直接用ORDER BY RAND()在数据量大时性能极差。优化方案预先计算如果题库更新不频繁可以为每个常用的知识点-难度组合预先计算一个“题目ID列表”缓存到Redis中随机时直接从缓存列表里取。应用层随机先通过条件查询利用索引缩小范围得到一个较小的候选ID列表然后在Java应用层用Collections.shuffle()随机打乱取前N个。public ListLong selectQuestionIdsByRule(SectionRule rule) { QueryWrapperQuestion wrapper new QueryWrapper(); wrapper.select(id); // 只查ID减少数据传输 wrapper.eq(type_id, rule.getQuestionType()); wrapper.eq(status, QuestionStatus.PUBLISHED); wrapper.between(difficulty, rule.getDifficultyRange().getMin(), rule.getDifficultyRange().getMax()); if (CollectionUtils.isNotEmpty(rule.getKnowledgePoints())) { // 假设知识点用JSON数组存储使用JSON_CONTAINS函数MySQL 5.7 // 注意这种查询可能无法使用索引大数据量需另建关系表 wrapper.apply(JSON_CONTAINS(knowledge_points, JSON_ARRAY({0})), StringUtils.join(rule.getKnowledgePoints(), \,\)); } // 先按条件查出所有符合条件的ID ListLong candidateIds baseMapper.selectObjs(wrapper) .stream() .map(o - (Long) o) .collect(Collectors.toList()); // 应用层随机 Collections.shuffle(candidateIds); return candidateIds.stream().limit(rule.getCount()).collect(Collectors.toList()); }数据库端优化对于MySQL 8.0可以尝试用WITH语句和ROW_NUMBER()来优化随机查询但复杂度较高。第三步组装试卷模板获取到所有题目的ID后连同分值信息组装成一个结构化的配置存入exam_paper表的question_config字段JSON类型。这样试卷模板就与具体题目解耦了题目信息的更新不会影响已发布的试卷。4.2 考试计时、防作弊与提交考试开始后有两个关键点计时和防刷新/防切屏。计时方案服务端计时为主在exam_record中记录start_time考试开始时间。提交时服务器计算current_time - start_time判断是否超时。这是最可靠的。客户端计时为辅前端根据考试时长进行倒计时显示提升用户体验。但最终以服务端时间为准。前端定时如每30秒向后端发送一次心跳同步时间并更新考试状态。防作弊的常见措施前端限制防君子不防小人防切屏/失去焦点监听document.visibilitychange和window.blur事件记录次数超过阈值则警告或强制交卷。let blurCount 0; document.addEventListener(visibilitychange, function() { if (document.hidden) { blurCount; // 警告或提交逻辑 } });禁止右键和复制oncontextmenu事件返回false禁用选择复制。题目乱序和选项乱序后端返回题目和选项时顺序是随机的每个用户看到的顺序不同。提交时的数据安全用户答案提交是一个高频写操作。前端不要一道题一提交而是在本地缓存最后统一提交。提交的数据结构要清晰{ recordId: 12345, answers: [ { questionSnapshotId: 1001, // 关联题目快照 answer: [A] // 答案单选是数组单元素多选是多元素填空是字符串数组 }, // ... 其他题目答案 ] }关键点提交API必须做幂等性处理防止用户因网络问题重复点击提交按钮导致重复评分。可以在请求头中带一个唯一令牌Token服务端用Redis记录已处理的令牌。4.3 异步评分服务的设计评分是CPU密集型操作尤其是涉及文本相似度计算简答题或代码运行编程题。必须与主Web服务解耦。架构流程提交事件发布在ExamRecordService的提交方法中在状态更新为“已提交”后发布一个评分事件消息到RabbitMQ。Transactional public void submitExam(Long recordId, ListAnswerDTO answers) { // 1. 更新考试记录状态 ExamRecord record getById(recordId); record.setStatus(ExamStatus.SUBMITTED); record.setSubmitTime(LocalDateTime.now()); updateById(record); // 2. 保存用户答案关联快照ID saveAnswers(recordId, answers); // 3. 发送评分消息事务提交后发送确保数据一致性 // 使用TransactionSynchronizationManager在事务成功后发送 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { ScoreMessage message new ScoreMessage(recordId); rabbitTemplate.convertAndSend(exam.exchange, score.routing.key, message); log.info(评分消息已发送: {}, recordId); } }); }消息结构消息体只需包含recordId即可评分服务可以根据这个ID查询出完整的试卷快照和用户答案。评分Worker一个独立的Spring Boot应用监听消息队列。Component RabbitListener(queues score.queue) public class ScoreConsumer { Autowired private ScoreCalculator scoreCalculator; RabbitHandler public void process(ScoreMessage message) { Long recordId message.getRecordId(); try { // 1. 根据recordId查询考试记录、试卷快照、用户答案 ExamScoreContext context loadScoreContext(recordId); // 2. 调用评分器计算总分 Integer totalScore scoreCalculator.calculateTotalScore(context); // 3. 更新考试记录分数可批量更新优化 examRecordService.updateScore(recordId, totalScore); // 4. 可选发送评分完成通知如WebSocket推送 notifyScoreComplete(recordId, totalScore); } catch (Exception e) { log.error(评分失败 recordId: {}, recordId, e); // 记录失败可放入死信队列进行人工干预或重试 } } }评分器策略模式评分逻辑因题型而异使用策略模式非常合适。Component public class ScoreCalculator { // 注入所有题型评分处理器 Autowired private MapString, ScoreStrategy strategyMap; public Integer calculateScore(QuestionSnapshot snapshot, UserAnswer answer) { String questionType snapshot.getQuestionType(); ScoreStrategy strategy strategyMap.get(questionType ScoreStrategy); if (strategy null) { throw new UnsupportedOperationException(不支持的题型: questionType); } return strategy.calculate(snapshot, answer); } public Integer calculateTotalScore(ExamScoreContext context) { return context.getAnswerList().stream() .mapToInt(answer - { QuestionSnapshot snapshot findSnapshot(context, answer.getSnapshotId()); return calculateScore(snapshot, answer); }).sum(); } } // 策略接口 public interface ScoreStrategy { Integer calculate(QuestionSnapshot snapshot, UserAnswer answer); } // 单选题评分策略 Component(SINGLE_CHOICEScoreStrategy) public class SingleChoiceScoreStrategy implements ScoreStrategy { Override public Integer calculate(QuestionSnapshot snapshot, UserAnswer answer) { // 从快照中获取正确答案 String correctAnswer snapshot.getCorrectAnswer(); // 如 A // 用户答案 ListString userAnswers answer.getAnswerList(); // 比较逻辑 if (userAnswers.size() 1 correctAnswer.equals(userAnswers.get(0))) { return snapshot.getScore(); // 返回该题分值 } return 0; } } // 填空题评分策略关键字匹配示例 Component(FILL_BLANKScoreStrategy) public class FillBlankScoreStrategy implements ScoreStrategy { Override public Integer calculate(QuestionSnapshot snapshot, UserAnswer answer) { // 快照中存储了每个空的正确答案和匹配规则 ListBlankAnswer correctBlanks snapshot.getBlankAnswers(); ListString userBlanks answer.getAnswerList(); int score 0; for (int i 0; i correctBlanks.size(); i) { if (i userBlanks.size()) { BlankAnswer correct correctBlanks.get(i); String userAnswer userBlanks.get(i); if (isAnswerCorrect(correct, userAnswer)) { score correct.getPoint(); // 每个空可能有独立分值 } } } return score; } private boolean isAnswerCorrect(BlankAnswer correct, String userAnswer) { // 实现各种匹配逻辑精确匹配、去除空格后匹配、包含关键字等 // ... } }这样设计评分服务职责单一易于扩展。新增一种题型只需实现对应的ScoreStrategy并注册到Spring容器即可。5. 前端交互从JSP到现代前后端分离早期的JavaWeb项目多用JSP但维护起来非常痛苦。现代开发几乎都是前后端分离。这里以Vue 3 Element Plus为例简述关键页面的实现思路。5.1 考试页面组件化考试页面是核心需要良好的状态管理和用户体验。组件结构ExamPage.vue ├── ExamTimer.vue (顶部计时器) ├── ExamQuestionList.vue (左侧题目导航栏) ├── ExamQuestionArea.vue (右侧答题区) └── ExamSubmitDialog.vue (提交确认弹窗)状态管理Pinia创建一个examStore来集中管理考试状态。// stores/exam.js import { defineStore } from pinia; export const useExamStore defineStore(exam, { state: () ({ recordId: null, paper: null, // 试卷信息 questions: [], // 题目列表含快照 answers: {}, // 答案映射 { snapshotId: answer } timeLeft: 0, // 剩余秒数 isSubmitting: false, }), actions: { async loadExam(paperId) { // 调用开始考试API获取recordId和题目快照 const { data } await axios.post(/api/exam/start, { paperId }); this.recordId data.recordId; this.questions data.questions; this.timeLeft data.duration * 60; this.startTimer(); }, startTimer() { this.timer setInterval(() { if (this.timeLeft 0) { clearInterval(this.timer); this.autoSubmit(); } else { this.timeLeft--; } }, 1000); }, saveAnswer(snapshotId, answer) { // 本地保存答案可以节流后自动同步到服务器 this.answers[snapshotId] answer; this.debouncedSyncToServer(); }, async submitExam() { this.isSubmitting true; try { const answerList Object.keys(this.answers).map(snapshotId ({ questionSnapshotId: parseInt(snapshotId), answer: this.answers[snapshotId] })); await axios.post(/api/exam/${this.recordId}/submit, { answers: answerList }); // 提交成功跳转到结果页 router.push(/exam/result/${this.recordId}); } catch (error) { // 处理错误 } finally { this.isSubmitting false; } } } });答题区动态渲染根据题目类型动态加载不同的答题组件。!-- ExamQuestionArea.vue -- template div v-ifcurrentQuestion !-- 题干渲染支持HTML -- div classquestion-content v-htmlcurrentQuestion.content/div !-- 动态组件渲染答题区域 -- component :isquestionComponentMap[currentQuestion.type] :questioncurrentQuestion :valueanswers[currentQuestion.id] changehandleAnswerChange / /div /template script setup import { computed } from vue; import { useExamStore } from /stores/exam; import SingleChoice from ./components/SingleChoice.vue; import MultiChoice from ./components/MultiChoice.vue; import FillBlank from ./components/FillBlank.vue; const examStore useExamStore(); const currentQuestion computed(() examStore.currentQuestion); const answers computed(() examStore.answers); const questionComponentMap { SINGLE_CHOICE: SingleChoice, MULTI_CHOICE: MultiChoice, FILL_BLANK: FillBlank, // ... 其他题型 }; const handleAnswerChange (answer) { examStore.saveAnswer(currentQuestion.value.id, answer); }; /script5.2 管理后台题目与试卷管理管理后台通常需要复杂的表单和表格。Element Plus的el-form和el-table是不错的选择。题目创建/编辑表单难点在于不同题型对应不同的表单项。可以使用一个type字段来切换动态表单。template el-form :modelform label-width100px el-form-item label题型 el-select v-modelform.typeId changehandleTypeChange el-option v-fortype in questionTypes :keytype.id :labeltype.name :valuetype.id / /el-select /el-form-item el-form-item label题干 RichTextEditor v-modelform.content / /el-form-item !-- 动态部分 -- div v-ifform.typeId SINGLE_CHOICE el-form-item label选项 div v-for(choice, index) in form.choices :keyindex el-radio v-modelform.correctChoice :labelchoice.label{{ choice.label }}/el-radio el-input v-modelchoice.content placeholder选项内容 / el-button clickremoveChoice(index)删除/el-button /div el-button clickaddChoice添加选项/el-button /el-form-item /div div v-else-ifform.typeId FILL_BLANK el-form-item label填空数量 el-input-number v-modelform.blankCount :min1 changegenerateBlankAnswers / /el-form-item div v-fori in form.blankCount :keyi el-form-item :label第${i}空答案 el-input v-modelform.blankAnswers[i-1] / /el-form-item /div /div !-- ... 其他题型 -- /el-form /template试卷列表与组卷试卷列表页提供搜索、分页。点击“组卷”可以跳转到使用上述JSON规则或可视化拖拽界面进行组卷的页面。可视化组卷可以参考一些开源库但核心仍然是生成我们之前定义的规则JSON。6. 部署、监控与优化建议项目开发完了怎么让它稳定跑起来6.1 基础部署与配置数据库配置生产环境一定要调优MySQL参数如innodb_buffer_pool_size设置为机器内存的70-80%max_connections根据应用需要设置。为question表的type_id,difficulty,status,creator_id等字段建立索引。对于知识点的JSON查询如果数据量大考虑拆分成单独的关系表question_knowledge_point。应用部署使用java -jar启动Spring Boot应用配合Nginx做反向代理和静态资源服务。使用systemd或Docker管理进程。环境分离务必区分application-dev.yml开发、application-test.yml测试、application-prod.yml生产配置文件管理不同的数据库、Redis、MQ连接信息。6.2 关键监控点应用健康Spring Boot Actuator暴露/health,/metrics,/prometheus端点接入Prometheus Grafana监控。接口性能使用AOP或Filter记录关键接口如提交试卷、加载试卷的耗时设置慢查询告警如2秒。JVM监控监控堆内存使用、GC频率和耗时避免Full GC导致服务暂停。数据库监控监控慢SQLlong_query_time设置为1秒使用EXPLAIN分析执行计划。Redis监控监控内存使用、命中率、连接数。6.3 性能优化实战缓存策略试卷模板缓存ExamPaper在发布后基本不变非常适合用Redis缓存Key设计为paper:{id}设置较长的过期时间如1天。题目详情缓存热点题目如高频错题可以缓存。注意缓存穿透查询不存在的ID用空值缓存短时间。注意缓存击穿热点Key过期用互斥锁或永不过期后台更新。用户答题进度缓存对于长时间考试用户未提交的答案可以实时同步到Redis防止浏览器崩溃导致答案丢失。Key设计为user_answer:{recordId}。数据库优化读写分离将评分Worker等读操作多的服务指向从库减轻主库压力。分库分表如果考试记录表exam_record数据量巨大亿级考虑按年份或用户ID哈希进行分表。归档历史数据将超过一年的考试记录迁移到历史表或冷存储保证主表查询效率。前端优化图片等静态资源上传OSS题目中的图片不要存数据库Base64上传到对象存储如阿里云OSS返回URL。懒加载题目对于题目很多的试卷不要一次性加载所有题目和图片。可以只加载当前题和前后几题滚动时再加载。答案自动保存使用防抖debounce技术用户停止输入后几秒自动将答案同步到服务器减少最后提交时的数据量和失败风险。6.4 安全加固SQL注入使用MyBatis-Plus的条件构造器或#{}参数绑定基本已免疫。XSS攻击前端富文本编辑器输入的题干在后端存储和前端渲染时都要做过滤或转义。可以使用Jsoup进行HTML清洗。越权访问每个接口都必须校验当前登录用户是否有权操作目标资源。例如查询考试记录/api/exam/record/{id}必须在Service层校验record.userId currentUserId。敏感信息日志中不能打印用户答案、密码等敏感信息。回顾这个“在线答题平台”项目从最初简陋的CRUD到如今涵盖领域建模、异步解耦、策略模式、前后端分离的微服务化架构思考其复杂度已不可同日而语。这正是一个典型业务系统随着需求增长而不断演进的缩影。核心收获不在于用了多少炫技的框架而在于如何根据业务特点做出合理的技术决策。比如用快照解决数据一致性问题用消息队列削峰填谷用策略模式应对变化。这些设计模式和解耦思想在任何后端系统中都是相通的。下次当你再看到一个“简单”的项目压缩包时不妨也试着用这种思路去重构和深化它这比单纯运行起来收获要大得多。本文还有配套的精品资源点击获取
返回列表