当前位置: 首页 > news >正文

SSM框架实战:在线考试系统从零到部署全解析

简介:这是一套面向计算机专业本科生的SSM架构毕业设计级学生在线考试系统,适用于期末大作业、课程设计及毕业设计场景,帮助学习者掌握企业级Web应用开发全流程。资源包共7008个文件,涵盖3798张系统界面与分析图(含用例图、ER图、数据库表设计图)、916个前端页面(HTML/JSP)、816个样式文件(CSS)、530个交互脚本(JS)、69个核心Java业务类(如ExamServiceImpl、ExamController等)以及配套的XML配置、SQL建库脚本、Redis缓存配置和Shiro权限控制代码,整体压缩包达763.9MB。已有1068人学习下载,资源附带详细运行指导视频(MP4)、全套开发工具安装包(含MySQL5.7、Redis5.0.5、Tomcat8.5等),所有Java类均含完整注释,模块划分清晰,权限控制与考试流程逻辑完备,是理解SSM整合、Shiro安全框架与Redis缓存实践的高质量参考实现。

1. 项目概述与整体设计思路

1.1 为什么还在做SSM项目,而不是直接上Spring Boot

先开门见山说结论:SSM(Spring + Spring MVC + MyBatis)这套组合虽然看起来“老”,但它依然是理解Java Web后端技术原理的最佳切入路径。

这几年我带过不少新人,很多人一上来就学Spring Boot,跟着视频敲了几个demo,能跑起来就觉得自己会了。结果面试一问DispatcherServlet怎么工作的、MyBatis的Mapper代理是怎么生成的、Spring的事务拦截器在哪一层生效,基本都答不上来。而SSM项目的好处恰恰在于,它逼你去面对那些被Spring Boot“藏起来”的细节。比如你要手动配置web.xml、要自己写SqlMapConfig.xml的Mapper扫描路径、要理解Spring容器和Spring MVC容器之间的父子关系。这些脏活累活,在Spring Boot里全被自动配置替代了,但如果你没亲手配过一遍,出了问题排查起来就会很被动。

那“学生在线考试系统”这个选题为什么经典?因为它麻雀虽小、五脏俱全,业务上天然覆盖了Web开发最常见的几大类问题:

  • 用户体系:学生、教师、管理员三类角色,权限不能乱串;
  • 数据关联:题目、试卷、考试记录、成绩,涉及多表关联查询;
  • 核心业务逻辑:自动组卷、定时交卷、客观题自动判分、主观题人工复核;
  • 状态与并发:同一场考试多个学生同时交卷,数据库写入不能乱;
  • 前后端交互:考试倒计时、答题缓存、异常断线恢复,这些交互细节特别考验接口设计。

换句话说,做完这个项目,你不是只会“CRUD搬运工”,而是把Spring的IOC/AOP、MyBatis的动态SQL、Spring MVC的请求流转、以及数据库设计的基本功全部串起来了。这也是我至今仍推荐拿它当练手项目的原因。

1.2 系统角色与核心功能模块拆解

在动代码之前,先花时间想清楚权限边界和功能边界,这一步能帮你省掉后期大量的返工。我们这个系统的角色分三类,账号体系建议做在一张表里,用role字段区分,而不是傻乎乎建三张用户表。

  • 学生端:登录、查看考试列表(只显示已发布且在有效期内的考试)、进入考试(答题倒计时、题面渲染)、交卷(自动判分后立即看到客观题得分)、查看历史成绩和试卷详情(含正确答案对照)。
  • 教师端:题库管理(单选、多选、判断题的CRUD)、手动组卷(按题型和知识点筛选题目,选完生成试卷)、自动组卷(按难度比例和题型数量随机抽题)、阅卷(主观题打分)、考试成绩导出。
  • 管理员端:用户管理(教师和学生账号的创建、禁用、重置密码)、考试发布管理(安排考试时间、时长、监考策略)、数据统计(参考人数、平均分、及格率、分数段分布)。

模块拆到这里,数据库的表结构其实已经能推出来了。我把表结构设计放在下一节细讲,但这里想先强调一个容易被忽略的点:“试卷”和“考试”要拆成两个概念。试卷是一组题目的集合,可以被复用;考试是一次具体的活动安排,包含起止时间、时长、参加学生范围、关联哪份试卷。很多第一次做这个系统的人会混在一起,结果后面想“同一套卷子安排两场考试”时就傻眼了。

1.3 技术选型的理由与备选方案对比

技术组件本项目选择选型理由备选方案
后端框架Spring + Spring MVC学习价值高,手动配置能理解Web应用底层机制Spring Boot(配置少,但原理容易被忽略)
ORM框架MyBatis动态SQL灵活,适合题库这种多条件组合查询场景MyBatis-Plus(省事,但不利于理解XML绑定)
前端(方案A)JSP + JSTL + Bootstrap传统方案,适合快速出效果、学习成本低Thymeleaf、FreeMarker
前端(方案B)Vue 3 + Element Plus前后端分离,体验好,贴近企业实际开发React、Angular
数据库MySQL 5.7+免费、通用性强,面试和企业都在用PostgreSQL
数据库连接池Druid自带监控页面,能实时看SQL执行情况HikariCP
构建工具Maven依赖管理直观,SSM项目最成熟的构建方式Gradle

这里需要单独说一下前端方案。如果你是想快速熟悉SSM全流程、把主要精力放在后端逻辑上,那就选JSP;但如果你希望这个项目能放进简历作为展示项目,我强烈建议做前后端分离,用Vue 3来写前端。后面第6节我会专门讲Vue 3如何和SSM后端连接,包括跨域、axios封装、动态路由权限这些实操细节。

2. 数据库设计与表关系核心要点

2.1 表结构设计:八张核心表

数据库设计是这类系统最重要的地基,我直接把设计好的表结构贴出来,并逐个说明设计意图。

-- 1. 用户表:教师、学生、管理员共用一个账号体系 CREATE TABLE `sys_user` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', `password` VARCHAR(100) NOT NULL COMMENT '加密存储,推荐BCrypt', `real_name` VARCHAR(50) DEFAULT NULL COMMENT '真实姓名', `role` TINYINT NOT NULL COMMENT '1-管理员 2-教师 3-学生', `phone` VARCHAR(20) DEFAULT NULL, `email` VARCHAR(50) DEFAULT NULL, `status` TINYINT NOT NULL DEFAULT '1' COMMENT '1-启用 0-禁用', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表'; -- 2. 题目表:题干、选项、答案、解析、知识点、难度 CREATE TABLE `question` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `question_type` TINYINT NOT NULL COMMENT '1-单选 2-多选 3-判断', `content` TEXT NOT NULL COMMENT '题干,支持富文本/图片路径', `options` TEXT DEFAULT NULL COMMENT 'JSON数组,如["A.选项1","B.选项2"]', `answer` VARCHAR(50) NOT NULL COMMENT '单选填A,多选填ACD,判断填T/F', `analysis` TEXT COMMENT '答案解析', `subject` VARCHAR(50) DEFAULT NULL COMMENT '所属科目', `knowledge_point` VARCHAR(100) DEFAULT NULL COMMENT '知识点,用于组卷筛选', `difficulty` TINYINT DEFAULT '1' COMMENT '1-易 2-中 3-难', `creator_id` BIGINT DEFAULT NULL COMMENT '录入人', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_type_diff` (`question_type`, `difficulty`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='题库表';

这里有一个关键技术点想单独提出来说:选项字段用TEXT类型存JSON,而不是单独建一张选项表,这是有意为之。因为考试系统的题目选项天然是并列的、没有独立业务属性的,如果为每个选项建一行记录,查询时要拼行转列,反而把简单问题复杂化了。用JSON存储,在Java端用一个泛型List接收即可,配合MyBatis的TypeHandler或Jackson反序列化,非常灵活。

-- 3. 试卷表:存储一份试卷的基本信息 CREATE TABLE `exam_paper` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `paper_name` VARCHAR(100) NOT NULL COMMENT '试卷名称', `total_score` INT DEFAULT '100', `duration_minutes` INT DEFAULT '60' COMMENT '建议时长,发布时可覆盖', `creator_id` BIGINT COMMENT '创建人', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `status` TINYINT DEFAULT '0' COMMENT '0-草稿 1-已发布', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='试卷表'; -- 4. 试卷题目关联表:试卷和题目是多对多关系 CREATE TABLE `exam_paper_question` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `paper_id` BIGINT NOT NULL COMMENT '试卷ID', `question_id` BIGINT NOT NULL COMMENT '题目ID', `score` INT NOT NULL COMMENT '该题分值', `sort_order` INT DEFAULT '0' COMMENT '题号排序', PRIMARY KEY (`id`), KEY `idx_paper` (`paper_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='试卷题目关联表';

题目表、试卷表、关联表这三张表做完,整份卷子的结构就已经清晰了。为什么要单独建关联表?因为同一道题可以出现在多份试卷里,而且在不同试卷里分值可能不同,比如同样的选择题,A卷里3分,B卷里5分。这种“对象本身属性”和“关联关系属性”并存的情况,正是中间表存在的意义。

再补上考试记录表和答题明细表:

-- 5. 考试表(一场具体的考试安排) CREATE TABLE `exam` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `exam_name` VARCHAR(100) NOT NULL, `paper_id` BIGINT NOT NULL COMMENT '关联试卷', `start_time` DATETIME NOT NULL, `end_time` DATETIME NOT NULL, `duration_minutes` INT NOT NULL DEFAULT '60', `total_score` INT NOT NULL DEFAULT '100', `pass_score` INT NOT NULL DEFAULT '60', `publish_status` TINYINT DEFAULT '0' COMMENT '0-未发布 1-已发布', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试表'; -- 6. 学生考试记录表:记录谁参加了哪场考试 CREATE TABLE `exam_record` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `exam_id` BIGINT NOT NULL, `student_id` BIGINT NOT NULL, `paper_id` BIGINT NOT NULL, `start_time` DATETIME NOT NULL COMMENT '实际开始时间', `submit_time` DATETIME DEFAULT NULL COMMENT '交卷时间', `objective_score` DECIMAL(5,1) DEFAULT '0.0' COMMENT '客观题得分', `subjective_score` DECIMAL(5,1) DEFAULT '0.0' COMMENT '主观题得分', `total_score` DECIMAL(5,1) DEFAULT NULL, `status` TINYINT DEFAULT '0' COMMENT '0-考试中 1-已交卷 2-已阅卷', `ip_address` VARCHAR(50) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_exam_student` (`exam_id`, `student_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试记录表'; -- 7. 答题明细表:记录学生每一道题的作答 CREATE TABLE `exam_answer_detail` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `record_id` BIGINT NOT NULL COMMENT '考试记录ID', `question_id` BIGINT NOT NULL, `student_answer` TEXT COMMENT '学生答案,多选题存ACD', `is_correct` TINYINT DEFAULT NULL COMMENT '客观题是否正确 1-正确 0-错误', `score` DECIMAL(5,1) DEFAULT '0.0' COMMENT '此题得分', PRIMARY KEY (`id`), KEY `idx_record` (`record_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='答题明细表';

2.2 核心设计决策:为什么答案用字符串而不是布尔值

你可能会注意到,题目答案字段answer设计成了VARCHAR(50),而不是更简洁的布尔值或单字符。这是我在实际项目中踩过坑之后特意改的。

第一,多选题的存在让答案天然变成了一个集合,例如正确答案是A、C、D,如果字段设计太窄,存不下这种多选情况。用VARCHAR存字符串“ACD”,在判分时做字符串分割和比对,简单高效。第二,判断题答案用“T/F”而不是BIT类型,在校验时更直观,也避免前端拿布尔值做各种类型转换的麻烦。第三,如果你后续想扩展填空题、简答题,答案字段的兼容性也更强。

这里额外提醒一句:所有用于判断的答案字段,在入库时必须统一大小写并trim空字符,否则就会出现“学生填了A,答案是a”这种低级但让人血压飙升的误判。

2.3 数据库优化心得:索引怎么建才不翻车

SSM项目一般不会遇到大数据量瓶颈,但合理的索引设计仍然是一个专业开发者应有的基本素养,而且面试时一定会被问到。我这套表的索引设计原则可以总结为三条:

第一条,外键关联字段建立普通索引,比如exam_record表中的exam_idstudent_idexam_answer_detail表中的record_id、关联表中的paper_id。因为在线考试系统最频繁的操作是“查询某个学生的某场考试记录”和“查询某份试卷的全部题目”,这些字段没有索引会全表扫描。

第二条,状态字段和常用条件字段建立联合索引,比如题目表经常要按照question_typedifficulty组合筛选题目来组卷,这两个字段建立联合索引后,自动组卷的查询效率会明显提升。

第三条,唯一索引保护核心业务约束exam_record表里的uk_exam_student十分关键,它保证了同一场考试同一个学生只有一条记录,防止前端重复交卷时产生脏数据。

数据库层级的约束,比你在Java代码里写一百行if判断都要可靠。这一点我在第7节讲“重复交卷”问题时还会再次强调。

3. 后端核心模块实现与分析

3.1 三层架构与代码包结构规范

项目采用经典的三层架构,我建议的包结构如下。这个结构是我从多个生产项目中归纳出来的,既不过度设计,又有清晰的边界。

com.example.exam ├── Controller // 控制层,负责接收请求、参数校验、返回结果 ├── Service // 业务层,接口 + impl,核心业务逻辑都在这里 ├── Mapper // 数据访问层,接口 + XML ├── Model // 实体类,与数据库表一一对应 │ ├── entity │ ├── dto // 接收前端参数的对象 │ └── vo // 返回前端的数据对象 ├── Common │ ├── Result // 统一返回结果包装 │ ├── PageResult // 分页结果包装 │ └── GlobalExceptionHandler // 全局异常处理 └── Config // 配置类

这里我想重点聊聊dtovo的分层。很多初学者图省事,直接拿实体类entity去接收前端参数、再直接返回给前端。短期看代码量少了,但后续维护非常痛苦。原因是数据库表结构和前端页面字段天然不是一一对应的。比如新增题目时,前端要传options数组(JSON),同时还要传answer字符串;而查询题目列表时,前端需要的是options解析后的列表,以及一个“试题难度的中文说明”。这些字段组装逻辑放实体类里太脏,放Controller层又太厚,正确做法就是分别在dtovo里规范定义。

3.2 核心功能一:基于JWT的用户登录与权限拦截

因为要支持Vue 3前端,传统Session方案需要处理跨域携带Cookie的问题,比较麻烦。所以这里我使用JWT方案来做登录态管理。

@RestController @RequestMapping("/api/auth") public class AuthController { @Autowired private UserService userService; @PostMapping("/login") public Result login(@RequestBody LoginDTO loginDTO) { // 1. 查用户 User user = userService.findByUsername(loginDTO.getUsername()); if (user == null) { return Result.error("用户不存在"); } // 2. 校验密码 if (!BCrypt.checkpw(loginDTO.getPassword(), user.getPassword())) { return Result.error("密码错误"); } // 3. 校验状态 if (user.getStatus() == 0) { return Result.error("账号已被禁用,请联系管理员"); } // 4. 生成 token String token = JwtUtil.createToken(user.getId(), user.getRole()); Map<String, Object> data = new HashMap<>(); data.put("token", token); data.put("realName", user.getRealName()); data.put("role", user.getRole()); return Result.success(data); } }

登录接口的逻辑很清晰:查用户 → 校验密码 → 检查状态 → 发token。密码存储使用BCrypt加密,而不是MD5。原因也很简单,MD5是摘要算法,加盐不当很容易被彩虹表反查;而BCrypt是适应性哈希算法,内部自动随机盐,并且可以通过调整strength参数来控制计算成本,是目前业界最主流的密码存储方案。

权限拦截这块用Spring MVC的HandlerInterceptor实现:

public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口 String uri = request.getRequestURI(); if (uri.contains("/api/auth/login")) { return true; } // 获取 token String token = request.getHeader("Authorization"); // 校验 token try { Claims claims = JwtUtil.parseToken(token.replace("Bearer ", "")); // 将用户信息放入 request,后续Controller直接获取 request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"token无效或已过期\"}"); return false; } } }

这个实现的亮点在于,将用户信息放入request属性后,后续所有Controller都能通过参数解析拿到当前登录人,省去重复解析token的操作。我通常在项目的WebMvcConfig里再定制一个@CurrentUser注解实现参数解析,这样业务代码里直接写User currentUser作为参数就能拿人,体验非常好。

3.3 核心功能二:自动组卷算法与动态SQL实现

手动组卷的逻辑相对简单,教师从题库里勾选题目,循环插入关联表就行。真正有技术含量的是自动组卷。

假设需求是“组一份总分100分的试卷,单选题15道,每题2分,多选题5道,每题4分,判断题10道,每题3分,总分刚好是 15×2 + 5×4 + 10×3 = 100”。同时要求题目难度覆盖比例为:容易40%、中等40%、较难20%。这种需求用代码实现思路如下:

@Transactional public Long autoGeneratePaper(AutoPaperDTO dto) { // 1. 创建试卷 ExamPaper paper = new ExamPaper(); paper.setPaperName(dto.getPaperName()); paper.setTotalScore(dto.getTotalScore()); examPaperMapper.insert(paper); int sortOrder = 0; // 2. 遍历每个题型的抽取要求 for (QuestionTypeConfig config : dto.getConfigs()) { // 3. 查询符合条件的题目列表,这里使用MyBatis动态SQL List<Question> pool = questionMapper.selectForAutoGenerate( config.getQuestionType(), config.getDifficulty(), config.getSubject()); // 4. 随机抽取指定数量 Collections.shuffle(pool); int needCount = config.getCount(); if (pool.size() < needCount) { throw new BusinessException("题库数量不足:" + config.getQuestionTypeDesc() + " 需要 " + needCount + " 道,实际只有 " + pool.size() + " 道"); } // 5. 插入关联表 for (int i = 0; i < needCount; i++) { ExamPaperQuestion paperQuestion = new ExamPaperQuestion(); paperQuestion.setPaperId(paper.getId()); paperQuestion.setQuestionId(pool.get(i).getId()); paperQuestion.setScore(config.getScore()); paperQuestion.setSortOrder(sortOrder++); examPaperQuestionMapper.insert(paperQuestion); } } return paper.getId(); }

这里有几个细节非常关键。

第一,@Transactional事务不能少,否则组卷中途出错会出现“试卷有了但题目没插全”的半成品数据。第二,随机抽题不要直接在SQL里写ORDER BY RAND(),当题库数据量到十万级的时候,这个操作会让数据库CPU瞬间飙高。更合理的做法是先把候选题目ID查出来放内存里,再用Collections.shuffle进行随机。当然,如果题库数据量确实大(几十万条级别),也可以考虑“先按条件查出一个ID列表,再用WHERE id IN (...) ORDER BY RAND() LIMIT n”,至少在MySQL 8.0版本中这是比较折中且稳定的方案。

组卷对应的动态SQL也一并贴出来:

<select id="selectForAutoGenerate" resultType="com.example.exam.model.entity.Question"> SELECT * FROM question <where> <if test="questionType != null"> AND question_type = #{questionType} </if> <if test="difficulty != null"> AND difficulty = #{difficulty} </if> <if test="subject != null and subject != ''"> AND subject = #{subject} </if> </where> </select>

MyBatis动态SQL的核心价值在于,查询条件是可组合的,教师勾选“只看单选题、难度中等”和“只看判断题、所有难度”是两个完全不同的查询,用if标签拼装SQL,比在一行SQL里写死多个AND条件要优雅得多。

3.4 核心功能三:考试中自动交卷与客观题判分

自动判分逻辑是考试系统最核心的价值所在。学生交卷后,后端需要遍历答题明细,比对正确答案,计算得分,并汇总出客观题成绩。这一段代码我给出完整实现:

@Transactional public void submitExam(SubmitExamDTO dto) { // 1. 获取考试记录 ExamRecord record = examRecordMapper.selectById(dto.getRecordId()); if (record == null) { throw new BusinessException("考试记录不存在"); } if (record.getStatus() != 0) { throw new BusinessException("考试已交卷,请勿重复操作"); } // 2. 校验是否超过考试时间(防止前端倒计时被篡改) Exam exam = examMapper.selectById(record.getExamId()); Date now = new Date(); if (now.after(exam.getEndTime())) { // 超时,强制交卷 record.setStatus(1); record.setSubmitTime(exam.getEndTime()); } else { record.setStatus(1); record.setSubmitTime(now); } // 3. 获取试卷所有题目 List<ExamPaperQuestion> paperQuestions = examPaperQuestionMapper.selectByPaperId(record.getPaperId()); Map<Long, Integer> scoreMap = new HashMap<>(); for (ExamPaperQuestion pq : paperQuestions) { scoreMap.put(pq.getQuestionId(), pq.getScore()); } // 4. 遍历答题明细,客观题自动判分 double objectiveScore = 0; for (ExamAnswerDetail detail : dto.getAnswers()) { Question question = questionMapper.selectById(detail.getQuestionId()); if (question.getQuestionType() == 3) { // 主观题:暂不判分,分值记为0,待教师人工阅卷 detail.setScore(0.0); detail.setIsCorrect(null); } else { // 客观题:判断题答案比对 boolean correct = normalizeAnswer(detail.getStudentAnswer()) .equals(normalizeAnswer(question.getAnswer())); detail.setIsCorrect(correct ? 1 : 0); double s = correct ? scoreMap.getOrDefault(question.getId(), 0) : 0.0; detail.setScore(s); if (correct) { objectiveScore += s; } } examAnswerDetailMapper.insert(detail); } // 5. 更新考试记录总成绩 record.setObjectiveScore(objectiveScore); record.setTotalScore(objectiveScore); // 人工阅卷后再更新 examRecordMapper.updateById(record); }

这段代码有几个对错逻辑值得展开说明。

多选题判分策略是一个容易纠结的点。业界一般有两种策略:一种叫“全对才得分”,即必须和标准答案完全一致,选多、选少、选错都不得分;另一种叫“部分给分”,即少选的情况下每个正确选项给部分分。我在代码里采用了“全对才得分”策略,原因是用户可接受度更好,实现也简单。如果你要做“部分给分”,就必须指定“漏选给一半分”的规则,那对应的代码逻辑要改成集合交集计算,这也并不难,看需求取舍。

防作弊的细节:前端倒计时归零后自动调用交卷接口,这个逻辑只能作为用户体验辅助,不能作为后端信任的凭据。因为前端的代码可以被绕过,一个懂技术的学生完全可以通过浏览器开发者工具修改倒计时变量,让自己“永远不超时”。所以后端一定要在交卷接口再做一次时间校验,就是我代码里第2步做的事。记住一个核心原则:所有安全相关的判断必须发生在服务端,前端只是UI辅助。

4. 前端设计:Vue 3连接SSM后端的完整实践

4.1 Vite开发服务器代理解决跨域问题

如果你选了前后端分离方案,第一个要面对的问题就是跨域。Vue 3前端开发环境默认跑在http://localhost:5173,后端Spring MVC跑在http://localhost:8080,两个端口不同,浏览器会拦截前端发起的Ajax请求。

解决方案有两个方向。方向一是后端开启CORS,在Spring配置里加一个CorsFilter或使用@CrossOrigin注解;方向二是在前端Vite配置代理,把/api开头的请求代理到后端地址。实际开发中我推荐两件事同时做,但理由是反直觉的:开发环境用代理,生产环境用CORS

开发环境用代理的好处是,你在代码里写的请求路径是完整的项目路径(比如/api/exam/list),不需要拼接http://localhost:8080这样的绝对地址,代码更干净健壮;生产环境把前后端项目部署在同一域名的不同路径下(比如后端/api,前端根路径),代理同样可行。CORS则作为拓展兼容方案,尤其是你联调时要用Postman或者别的前端项目临时调用时,打开CORS能省很多事。

Vite代理配置如下:

// vite.config.js export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, // 如果后端Controller映射没有/api前缀,这里做路径重写 // rewrite: (path) => path.replace(/^\/api/, '') } } } })

4.2 axios封装与请求拦截器

axios是Vue项目中最主流的HTTP库,针对这个考试系统,我封装了一个统一的请求模块。这里把核心代码贴出来,你可以直接抄到自己的项目里:

// src/utils/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 30000, }) // 请求拦截器 request.interceptors.request.use( (config) => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }, (error) => { return Promise.reject(error) } ) // 响应拦截器 request.interceptors.response.use( (response) => { const res = response.data // 后端返回结构统一为 { code, msg, data } if (res.code !== 200) { ElMessage.error(res.msg || '请求失败') if (res.code === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(new Error(res.msg)) } return res.data }, (error) => { ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } ) export default request

这个封装的精妙之处在响应拦截器里:业务代码只需要关心data部分,codemsg的处理被统一消化掉了。比如调用const examList = await getExamList(),拿到的直接就是数组或对象,不需要每处都写res.data.data这种冗长代码。

4.3 考试页面的核心交互:倒计时、答案暂存、异常恢复

考试页面是前端最复杂的部分。我给读者一个完整的交互体验设计方案:

倒计时与到时自动交卷:进入考试后,后端返回考试截止时间(从后端数据库读取),前端依据此时间倒计时,而不是在前端定义一个60分钟定时器从0开始减。这个设计有讲究。因为如果学生中途刷新页面,前端定义在内存里的倒计时会归零重置,学生就有了“重新获得满满时间”的漏洞;而依据截止时间倒计时,刷新后倒计时依然准确,这正是后端时间戳校验思想的延伸。

答案暂存防丢失:即使是本机不刷新,也建议在每次切换题目时,将当前作答答案存入sessionStorage。这样万一用户不小心关闭浏览器再开,重新进入考试页面时,可以提示“检测到未提交的答案,是否恢复”,体验会好很多。

异常断线恢复:考试过程中如果网络断开,前端应当在axios错误捕获时提示“网络异常,答题进度已自动保存,请勿关闭页面”,并停止自动交卷倒计时,等网络恢复后提示重新连接。防止学生在断网期间无法操作。

// 倒计时核心逻辑 const startCountdown = (deadline) => { const timer = setInterval(() => { const remain = new Date(deadline).getTime() - Date.now() if (remain <= 0) { clearInterval(timer) submitExam() // 调用交卷接口 return } const minutes = Math.floor(remain / 60000) const seconds = Math.floor((remain % 60000) / 1000) countdown.value = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` }, 1000) }

4.4 权限控制:动态菜单与按钮级权限

前端权限控制,核心思想就是根据登录人的角色动态渲染菜单和按钮。我建议把角色信息放在store里持久化,页面路由控制则通过Vue Router的beforeEach守卫实现。

// 路由守卫 router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const role = localStorage.getItem('role') if (to.path === '/login') { next() return } if (!token) { next('/login') return } // 根据角色判断是否有权限访问该页面 if (to.meta.roles && to.meta.roles.indexOf(Number(role)) === -1) { ElMessage.error('无权访问该页面') next('/dashboard') return } next() })

5. 项目落地与部署

5.1 项目初始化与Maven依赖配置

SSM项目的初始化核心在于Maven的pom.xml依赖配置。我直接给出一份可用的依赖清单,对照使用即可:

<dependencies> <!-- Spring核心 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.3.20</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.20</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>5.3.20</version> </dependency> <!-- MyBatis --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.10</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.7</version> </dependency> <!-- MySQL驱动 + Druid连接池 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.28</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.8</version> </dependency> <!-- JWT + 密码加密 --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-crypto</artifactId> <version>5.6.2</version> </dependency> <!-- Servlet API --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <!-- Jackson --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.13.0</version> </dependency> <!-- 其他工具类 --> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.12.0</version> </dependency> </dependencies>

注意几个依赖版本选择背后的考量:Spring用了5.3.x,是因为这个版本对Servlet API 4.0和Java 8+都有良好的兼容性,而且当前主流Spring Boot 2.x底层也是用的这个版本的Spring核心,学完以后迁移到Spring Boot几乎没有障碍。MyBatis和mybatis-spring的版本必须搭配,它们各自的3.5.x和2.0.x是官方推荐配套版本,用错了会出现MapperScannerConfigurer初始化异常这种莫名其妙的错误。

5.2 部署方式与上线避坑

项目开发完成后部署到服务器,推荐用外置Tomcat以war包方式发布。用Maven打包时,记得先执行mvn clean package跳过测试,避免测试类干扰打包:

mvn clean package -DskipTests

打出的war包复制到Tomcat的webapps目录即可。这里有几个部署期的难点值得提一下:

第一,数据库连接配置要外置。生产环境的数据库地址、账号密码通常和本地不同,我习惯做法是在jdbc.properties里配置占位符,部署时直接改配置文件,而不是重新打包。第二,服务器时区问题。MySQL连接串一定要加serverTimezone=Asia/Shanghai,否则存的日期会差8个小时。这个坑非常经典,我第一次部署时前端显示考试时间总比预期晚8小时,查了半天才发现是连接串缺了时区参数。第三,静态资源与后端接口的路径规划。如果是前后端分离部署,需要把Vue构建出来的静态文件交给Nginx托管,Nginx配置将/api路径代理到Tomcat端口。如果你打算用spring-mvc.xml里的default-servlet-handler同时处理静态资源,要小心路径冲突。

6. 常见问题与排查技巧实录

6.1 问题速查表

我把这个项目开发过程中最常遇到的10个问题整理成了表格,每一条都是真实踩过的坑:

问题表现根本原因解决方案
页面报404,但Controller路径看起来没问题没有加@ResponseBody或Controller不在Spring扫描包下检查<context:component-scan>的包名范围
前端传的JSON参数为null缺少Jackson依赖或实体类缺少无参构造加jackson-databind依赖,检查实体类
MyBatis提示Invalid bound statement (not found)Mapper接口和XML的namespace匹配失败检查XML中的namespace和接口全限定名是否一致
分页查询失效,返回了所有数据没有使用PageHelper依赖或拦截器配置遗漏添加PageHelper依赖并在MyBatis配置中注册拦截器
交卷时数据重复插入前端重复点击交卷按钮前端按钮防抖 + 后端唯一索引兜底
考试时间快了8小时MySQL连接串缺少时区参数URL添加serverTimezone=Asia/Shanghai
文件上传中文文件名乱码Tomcat默认编码不是UTF-8修改server.xml配置URIEncoding="UTF-8"
答案保存不成功,但其他接口正常参数对象名和前端字段名不一致前端统一下划线命名,后端dto用驼峰,加@JsonProperty
自动组卷偶尔出现空卷没有校验题库数量,随机抽取时池子为空抽题前先检查候选池数量,不足则抛业务异常
学生能同时打开两个浏览器参加考试没有做同一账号考试互斥后端记录考试开始状态,检查现有进行中的记录

6.2 排查技巧:看日志与慢SQL定位瓶颈

实际排查问题,我自己的流程很固定:先看日志、再看SQL、最后看代码。SSM项目里启动日志和运行日志会打印很多关键信息,比如Spring初始化时扫描了哪些Bean、MyBatis加载了哪些Mapper XML。如果某个接口报500错误,先打开日志找到异常堆栈,对照堆栈的行号定位到具体的Mapper方法或Service方法,再打开MyBatis的SQL日志确认实际执行的SQL语句。

慢SQL问题推荐使用Druid连接池自带的监控页面,配置好StatFilterStatViewServlet后,在http://localhost:8080/druid/index.html能看到每条SQL的执行次数、耗时、最慢SQL的详细语句。我实际优化过的一个案例是:考试列表页每次刷新需要1.5秒,监控发现它在循环查询学生的每场考试成绩,产生了典型的N+1查询,后面改成JOIN查询加一次批量查询,耗时降到了50毫秒以内。

6.3 安全加固:SQL注入与XSS防御实操

在线考试系统涉及大量查询和展示,安全上最容易出问题的就是SQL注入和XSS攻击。

SQL注入方面,MyBatis的#{}预编译机制已经能挡住绝大多数注入攻击,但如果你在SQL里用了${}拼接字段,比如动态排序的ORDER BY ${orderByColumn},就会留下注入漏洞。这个地方的防御方案是白名单校验,在Java端判断传入的列名是否在允许的排序字段集合里,不在就拒绝。XSS攻击方面,题目或用户名这类用户输入内容,保存时必须做HTML标签转义,展示时利用模板引擎的默认转义机制。我建议给全文配一个基于Jackson的XssFilter过滤器,在请求进入Controller前统一清洗参数,防止<script>标签入库。

7. 个人经验总结与进阶建议

这个项目从零到完整上线,我前前后后做过两版。第一版用JSP,花了大概两周;第二版改成Vue 3前后端分离,又花了一周调试联调。回头看的体会是:SSM真正的价值不在框架本身,而在于它逼着你把Web开发的底层链路完整走了一遍。你会理解请求从浏览器到Controller再到Service和Mapper的完整流转,能说清楚Spring容器和Spring MVC容器的父子关系,知道事务代理是怎么拦截的,也知道MyBatis的Mapper接口是怎么通过JDK动态代理生成实现类的。这些知识,在面试时、在排查Spring Boot项目的诡异问题时,都会变成你的底气和直觉。

最后再分享一个小技巧:做完这个项目,建议顺手把它改造成Spring Boot版本,做一次“同一套业务逻辑,两套技术栈实现”的对照。改造过程中你会很容易发现Spring Boot自动配置到底帮你做了什么,同时也能理解为什么Spring Boot能成为当下生产环境的主流选择。而这种“先学原理、再上高速”的学习路径,对我带过的新人来说,是见效最快的一条路。

如果你在实操中遇到什么奇怪的问题,欢迎按上面的排查思路去定位。尤其是数据库连接串时区、N+1查询、事务不回滚这三大经典坑,碰上一次就会记忆深刻。

本文还有配套的精品资源,点击获取

http://www.cnnetsun.cn/news/4351181.html

相关文章:

  • 从代码生成到任务交付:构建AI Coding的Do Work Skill工作流
  • 本地部署 Stable Diffusion:6GB 显存 5 分钟跑通 768 出图
  • Agentic AI验证框架:从规则校验到事实一致性的工程实践
  • 1250A双电源快速切换柜:20ms级快速切换替代传统ATS
  • 基于QT的串口调试工具开发:从原理到工程实践
  • 零基础学书法逆锋起笔:避开六个常见错误,练出有骨力的笔画
  • 大模型多轮训练全解析:原理、代码与调参实践
  • 加拿大ATIO认证翻译怎么办理?线上、线下详细办理攻略
  • OpenVoice 语音克隆实战:从一段10秒参考音到六语配音的完整路径
  • npm依赖安全:如何评估一个包的Blast Radius爆炸半径影响范围
  • 猫抓CatCatch浏览器插件:网页媒体嗅探与资源抓取完全指南
  • 打造高级交互作品集:从产品思维到技术实现的全流程指南
  • 清源AI开发实战:无尽冬日采集设置全流程解析
  • 基于SpringBoot的在线智慧社区服务平台系统(毕业设计项目源码+文档)
  • LangGraph实战:从零构建可控的Agent状态机编排
  • 智能车竞赛制胜关键:工程化开发流程与模块化架构实战
  • PDFMathTranslate 自由页码选择功能完整指南:大论文只翻需要的几页
  • JAX 还是 TensorFlow?一份让你 10 分钟拍板的完整选型指南
  • 大数据专业毕业设计选题
  • Kronos 使用指南:3 步跑通开源金融 K 线基础模型
  • 6GB显存单图生成3D模型:ComfyUI到UE5全流程实战
  • FCPX插件如何高效制作科技感SaaS产品演示动画
  • 语音控制Minecraft换地形:RCON实现与服务器崩溃排查
  • 用ED度量神经网络简单性:多项式表示与复杂度分析
  • 基于SpringBoot的知遇心理服务系统设计与实现毕业设计项目源码文档
  • AI应用开发学习路径:Agent、微调与私有化部署全攻略
  • Claude Code Router:多 Agent 多模型统一路由入口,三步接入指南
  • 如何训练奖励模型:train-llm-from-scratch的Bradley-Terry损失详解
  • 我的世界Overlay测试指南:拼好种与终末之诗通关验证
  • 暴跌行情下,用市场温度判断短线与长线交易逻辑