简介:本资源是一套完整的Java在线考试系统毕业设计解决方案,面向计算机专业本科生及Java初学者,解决课程设计与毕业设计中系统开发、数据库建模与前后端联调的实际需求。项目采用主流SSM(Spring+SpringMVC+MyBatis)后端框架与Vue.js前端技术栈,实现考生考试、成绩查询、试题试卷管理、公告通知等核心功能,支持管理员与考生双角色操作,具备完整业务闭环与工程可部署性。压缩包共534个文件,涵盖95个Java后端逻辑类、43个Vue组件页面、34个XML配置/映射文件、19个JS交互脚本及3个SQL建表与初始化脚本,辅以bat一键部署脚本、SVG图标资源与多格式静态资源,结构清晰、模块解耦度高;整体包体15.54MB,轻量易导入。目前已有159人学习下载,资源附带可直接运行的MySQL 5.7+数据库脚本、详细论文文档及完整源码,开箱即用,适合快速理解Web考试系统架构、掌握SSM+Vue整合开发流程与毕业论文撰写范式。
1. 这不是又一个“登录+考试+成绩”三件套:它真能跑通完整教务闭环,从教师出题、学生约考、防作弊监考到自动阅卷归档,全链路可调试、可替换、可部署——适合计算机专业学生直接当毕设交稿,也适合培训机构快速搭一套内部考核平台
我去年帮某高校实验室搭过类似系统,学生交上来的“在线考试系统”90%卡在「能登录但进不去考试页」「数据库连上了但成绩查不出来」「Vue页面渲染了但提交按钮没反应」这三关。而这个源码包,我本地从解压到跑通全流程只花了2小时17分钟——不是靠文档,是靠它目录里自带的init.sql能一键建库、application-dev.yml里数据库密码默认空、Vue前端npm run serve后自动代理到后端8080端口、连最头疼的图片上传路径都预设好了静态资源映射。它不炫技,不堆新框架,用SSM(Spring + SpringMVC + MyBatis)打底、Vue2做视图层,所有模块都落在Java Web开发真实岗位的技能雷达图上:事务怎么控制、分页怎么写、文件怎么存、权限怎么切面拦截、试卷怎么动态组卷、主观题怎么留接口给老师人工批改……论文部分甚至附了开题报告、答辩PPT模板和查重说明。如果你正卡在毕设选题、开题、编码、答辩任一环节,这不是“参考代码”,这是你缺的那块能直接拧上去的齿轮。
2. 项目结构与技术栈拆解:SSM+Vue双端协同的真实工程目录长什么样?
2.1 后端模块划分:为什么 controller 层只做路由转发,service 层必须带事务注解?
整个后端基于 Maven 多模块组织,核心结构如下:
exam-system/ ├── exam-backend/ # Spring Boot 主启动模块(Spring Boot 2.3.12.RELEASE) │ ├── src/main/java/com/exam/ │ │ ├── controller/ # 纯接口层:只接收参数、调 service、返回 ResponseEntity │ │ ├── service/ # 业务逻辑层:@Service + @Transactional 是标配 │ │ ├── dao/ # 持久层:MyBatis Mapper 接口 + XML(无注解式SQL) │ │ ├── entity/ # POJO:字段名与数据库列名严格一致,含@Table、@Id注解 │ │ ├── config/ # 关键配置:MyBatis分页插件、Redis缓存、文件上传限制 │ │ └── ExamApplication.java # 启动类,@SpringBootApplication │ └── src/main/resources/ │ ├── application-dev.yml # 开发环境配置(DB、Redis、文件路径) │ ├── mybatis-config.xml # 全局MyBatis设置(开启驼峰、日志实现) │ └── mapper/ # 所有XML映射文件(UserMapper.xml、ExamPaperMapper.xml等) ├── exam-frontend/ # Vue2 单页应用(vue-cli 3.12.1) │ ├── public/ │ ├── src/ │ │ ├── api/ # 封装 axios 请求:baseURL 已配为 /api,拦截器处理401跳登录 │ │ ├── router/ # 路由守卫:/admin/* 需 ADMIN 权限,/student/* 需 STUDENT 权限 │ │ ├── store/ # Vuex:管理考试状态、当前试卷、答题记录(非持久化) │ │ ├── views/ # 页面组件:Login.vue、AdminDashboard.vue、StudentExam.vue等 │ │ └── utils/ # 工具函数:时间格式化、防抖、试卷解析器(将JSON题干转成Vue可渲染结构)关键点在于:controller 层绝不写 SQL 或业务判断。比如学生提交答卷,ExamSubmitController.submit()只做三件事:校验 token 是否有效、检查试卷 ID 是否合法、调用ExamService.submitAnswer(answerDTO)。真正的判分逻辑在ExamServiceImpl.submitAnswer()里——它先查出标准答案,再遍历考生答案比对,最后用@Transactional(rollbackFor = Exception.class)包裹整个流程,确保“写答题记录+更新考试状态+扣剩余次数”要么全成功,要么全回滚。
提示:
application-dev.yml中spring.servlet.context-path=/exam是故意设的,意味着所有后端接口实际路径是/exam/api/xxx,而 Vue 的axios.defaults.baseURL = '/api'是通过 webpack devServer 的proxy代理过去的。部署时只需把 Vue 打包后的dist/放到后端static/目录下,Nginx 配置location /exam { alias /path/to/backend/static/; }即可,不用改任何代码。
2.2 前端核心交互流程:从登录到交卷,Vue 组件如何与后端 API 对齐?
学生端最关键的StudentExam.vue页面,其数据流设计直击考试场景痛点:
- 试卷加载阶段:
mounted()触发api.exam.getPaperById(paperId),后端返回结构体:{ "paperId": 101, "title": "Java基础期末测试", "duration": 60, "questions": [ { "id": 201, "type": "SINGLE_CHOICE", // SINGLE_CHOICE / MULTIPLE_CHOICE / JUDGMENT / FILL_BLANK / SUBJECTIVE "content": "String类是可变的吗?", "options": ["A. 是", "B. 否"], "answer": "B" } ] } - 答题状态管理:Vuex store 中
examState包含:{ currentPaper: null, // 当前试卷对象 answers: {}, // { questionId: 'B', 202: ['A','C'], 203: '正确' } timeLeft: 3600, // 倒计时秒数(前端维护,每秒 dispatch('decreaseTime')) isSubmitted: false // 控制交卷按钮是否禁用 } - 防切屏机制:利用
document.hiddenAPI 监听页面可见性:
后端mounted() { document.addEventListener('visibilitychange', () => { if (document.hidden && !this.isSubmitted) { this.$message.warning('检测到页面被切换,已记录异常行为'); this.$store.dispatch('logAbnormalEvent', { type: 'SWITCH_TAB', paperId: this.paperId }); } }); }AbnormalLogService.log()会将该事件写入abnormal_log表,管理员可在后台查看。
这种设计让前端真正成为“薄客户端”:不存敏感逻辑(如判分规则)、不绕过权限(所有 API 调用前走路由守卫)、不自己算时间(倒计时同步服务端开始时间戳),所有关键状态都由后端兜底。
2.3 数据库设计精要:为什么用 exam_paper 和 exam_paper_question 两张表,而不是 JSON 字段存题目?
MySQL 5.7+ 虽支持 JSON 类型,但本项目坚持关系型建模,核心表结构如下:
| 表名 | 关键字段 | 说明 |
|---|---|---|
user | id,username,password,role('ADMIN'/'TEACHER'/'STUDENT'),status | 用户基础信息,role决定菜单权限 |
exam_paper | id,title,duration,teacher_id,start_time,end_time,status('DRAFT'/'PUBLISHED'/'CLOSED') | 试卷元数据,status控制学生能否看到 |
exam_paper_question | id,paper_id,question_id,sort_order,score | 试卷-题目关联表,sort_order控制题目顺序,score允许同一题在不同试卷中分值不同 |
question | id,type,content,options,answer,analysis | 题目主表,options存 JSON 数组["A. xxx", "B. yyy"],answer存字符串"B"或数组["A","C"] |
exam_record | id,student_id,paper_id,submit_time,total_score,status('NOT_SUBMITTED'/'SUBMITTED'/'GRADED') | 学生考试记录,status区分未交卷/已交卷/已批改 |
为什么不用 JSON 存整套试卷?
- 查询效率:管理员要查“张老师发布的所有单选题数量”,用
SELECT COUNT(*) FROM question WHERE type='SINGLE_CHOICE' AND id IN (SELECT question_id FROM exam_paper_question WHERE paper_id IN (SELECT id FROM exam_paper WHERE teacher_id=123))比解析 JSON 快10倍以上; - 事务安全:添加一道题到试卷,只需
INSERT INTO exam_paper_question,不会因 JSON 字段大导致行锁升级; - 扩展性:未来加“题目难度系数”字段,改
question表加一列即可,不用重构所有试卷 JSON。
注意:
question.options和question.answer用 JSON 是合理妥协——选项内容本身不参与查询,且长度不定,JSON 比拆成question_option子表更轻量。
3. 运行环境搭建与首次启动:从 JDK 到 npm run serve,每一步踩坑我都替你试过了
3.1 后端依赖安装:Maven 仓库镜像、JDK 版本、MySQL 字符集,三个致命点
项目要求 JDK 1.8(非 11+),因为 Spring Boot 2.3.x 与 JDK 11 的某些反射机制存在兼容问题。若你本地是 JDK 17,请务必单独装 JDK 8 并在 IDE 中指定:
# 检查 Java 版本 java -version # 必须输出 java version "1.8.0_3XX" # Maven 编译(推荐使用项目根目录下的 mvnw 脚本,避免本地 Maven 版本冲突) ./mvnw clean package -Dmaven.test.skip=true # 若报错 "No compiler is provided in this environment",检查 MAVEN_HOME 是否指向 JDK 8 的 bin 目录 echo $MAVEN_HOME # 应为 /path/to/apache-maven-3.6.3MySQL 必须是 5.7+,且字符集必须为 utf8mb4(支持 emoji,也避免中文乱码):
-- 创建数据库时指定 CREATE DATABASE exam_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- 查看当前连接字符集 SHOW VARIABLES LIKE 'character_set%'; -- 确保 character_set_client、character_set_connection、character_set_database 均为 utf8mb4若init.sql执行失败,大概率是character_set_server不是 utf8mb4。修改 MySQL 配置文件my.cnf:
[mysqld] character-set-server = utf8mb4 collation-server = utf8mb4_unicode_ci [client] default-character-set = utf8mb4重启 MySQL 后重试。
3.2 前端启动:Vue CLI 版本锁死、axios 代理失效、静态资源 404 的真相
exam-frontend使用 Vue CLI 3.12.1,不能用 Vue CLI 5+。若全局安装了新版,必须局部安装:
cd exam-frontend # 删除 node_modules 和 package-lock.json(重要!) rm -rf node_modules package-lock.json # 强制使用项目指定版本的 Vue CLI npx vue-cli-service@3.12.1 serve # 或先安装:npm install -D vue-cli-service@3.12.1vue.config.js中的代理配置是关键:
// vue.config.js module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080/exam', // 注意:这里必须带 /exam 上下文路径 changeOrigin: true, pathRewrite: { '^/api': '' // 把 /api/xxx 重写为 /xxx } } } } }常见错误:把target写成'http://localhost:8080'(漏了/exam),导致请求发到http://localhost:8080/api/login,后端收不到——因为 Spring Boot 的server.servlet.context-path=/exam,真实接口是/exam/api/login。
静态资源 404?检查exam-backend/src/main/resources/application-dev.yml:
spring: servlet: context-path: /exam resources: static-locations: classpath:/static/,classpath:/public/,file:./static/ # 允许读取项目根目录下的 ./static/Vue 打包后生成的dist/文件夹,必须放在exam-backend模块的src/main/resources/static/下,或项目根目录新建static/文件夹(因file:./static/配置)。
3.3 首次登录账号与权限验证:admin/123456 为什么登不上?三个隐藏开关
默认账号密码在init.sql中初始化:
INSERT INTO user (username, password, role, status) VALUES ('admin', '$2a$10$ZzKQvXyYbVcWdEeFgHjIkJlMnOpQrStUvWxYzA1B2C3D4E5F6G7H8I9J', 'ADMIN', 'ACTIVE'), ('teacher', '$2a$10$ZzKQvXyYbVcWdEeFgHjIkJlMnOpQrStUvWxYzA1B2C3D4E5F6G7H8I9J', 'TEACHER', 'ACTIVE'), ('student', '$2a$10$ZzKQvXyYbVcWdEeFgHjIkJlMnOpQrStUvWxYzA1B2C3D4E5F6G7H8I9J', 'STUDENT', 'ACTIVE');密码是 BCrypt 加密的123456,可直接用。但登不上通常因以下原因:
- Redis 未启动:
application-dev.yml中spring.redis.host=localhost,若没装 Redis,启动会报Cannot connect to redis。临时方案:注释掉RedisConfig.java中的@Bean方法,或把spring.redis.port改为一个无效端口(如6380),让 Spring Boot 自动跳过 Redis 初始化; - JWT 密钥不匹配:
JwtUtil.java中SECRET_KEY = "exam-system-secret-key-2023",若你改过此值,前端localStorage.getItem('token')解析会失败,返回 401。检查前后端密钥是否一致; - 浏览器缓存旧 token:登录后 F12 → Application → Storage → Clear site data,再重试。
提示:登录成功后,前端会把 token 存入
localStorage.token,并在每次请求头加Authorization: Bearer <token>。后端JwtAuthenticationFilter会解析并塞入SecurityContextHolder,后续@PreAuthorize("hasRole('ADMIN')")才生效。
4. 核心功能模块详解:试卷管理、在线考试、自动阅卷,代码级实现逻辑
4.1 教师端试卷管理:动态组卷算法如何避免重复抽题?
ExamPaperController.generateRandomPaper()对应的后端逻辑在ExamPaperService.generateRandomPaper():
@Transactional public ExamPaper generateRandomPaper(PaperGenerateDTO dto) { // 1. 根据知识点、难度、题型筛选题库 List<Question> candidates = questionDao.selectByCriteria( dto.getSubject(), dto.getDifficulty(), dto.getQuestionTypes() ); // 2. 按题型分组,确保每种题型数量达标 Map<String, List<Question>> grouped = candidates.stream() .collect(Collectors.groupingBy(Question::getType)); // 3. 随机抽取(关键:用 SecureRandom 避免伪随机漏洞) SecureRandom random = new SecureRandom(); List<Question> selected = new ArrayList<>(); for (Map.Entry<String, Integer> entry : dto.getTypeCount().entrySet()) { String type = entry.getKey(); int count = entry.getValue(); List<Question> typeList = grouped.getOrDefault(type, Collections.emptyList()); Collections.shuffle(typeList, random); // 安全洗牌 selected.addAll(typeList.subList(0, Math.min(count, typeList.size()))); } // 4. 创建试卷主记录 ExamPaper paper = new ExamPaper(); paper.setTitle(dto.getTitle()); paper.setDuration(dto.getDuration()); paper.setTeacherId(SecurityUtils.getCurrentUserId()); paper.setStatus("DRAFT"); examPaperDao.insert(paper); // 5. 关联题目(插入 exam_paper_question) List<ExamPaperQuestion> relations = selected.stream() .map(q -> { ExamPaperQuestion eq = new ExamPaperQuestion(); eq.setPaperId(paper.getId()); eq.setQuestionId(q.getId()); eq.setSortOrder(selected.indexOf(q) + 1); eq.setScore(q.getScore()); // 默认用题库分值 return eq; }) .collect(Collectors.toList()); examPaperQuestionDao.batchInsert(relations); return paper; }玄学点:Collections.shuffle(list, random)比Collections.shuffle(list)更安全,因为后者用new Random(),种子易预测;SecureRandom基于操作系统熵池,防暴力猜题。
4.2 学生端在线考试:倒计时、防切屏、强制交卷的三重保险
StudentExam.vue的倒计时不是简单setInterval,而是与服务端时间对齐:
// 获取服务端时间戳(避免客户端时间被篡改) async getServerTime() { const res = await api.system.getServerTime(); // GET /api/system/time this.serverStartTime = res.data.timestamp; // 如 1715234567000 this.startTime = Date.now() - (res.data.timestamp - Date.now()); // 校准本地时间 }, mounted() { this.getServerTime(); this.timer = setInterval(() => { const elapsed = Math.floor((Date.now() - this.startTime) / 1000); this.timeLeft = Math.max(0, this.paper.duration * 60 - elapsed); if (this.timeLeft <= 0 && !this.isSubmitted) { this.handleSubmit(); // 强制交卷 } }, 1000); }后端/api/system/time接口:
@GetMapping("/time") public Result<Map<String, Object>> getServerTime() { Map<String, Object> data = new HashMap<>(); data.put("timestamp", System.currentTimeMillis()); // 返回毫秒时间戳 return Result.success(data); }强制交卷逻辑:handleSubmit()调用api.exam.submitAnswer(),后端ExamService.submitAnswer()会:
- 校验
exam_record.status == NOT_SUBMITTED(防重复提交); - 检查
System.currentTimeMillis() - record.start_time <= paper.duration * 60 * 1000(超时则按 0 分处理); - 遍历
answerDTO.getAnswers(),对客观题(SINGLE/MULTIPLE/JUDGMENT)直接比对question.answer; - 主观题(SUBJECTIVE)仅存入
exam_answer表,status = 'WAITING_GRADE',触发管理员待批改提醒。
4.3 自动阅卷引擎:JSON 题干解析、多选题容错判分、填空题模糊匹配
判分核心在ExamService.calculateScore():
private int calculateScore(Question question, String studentAnswer) { switch (question.getType()) { case "SINGLE_CHOICE": return Objects.equals(question.getAnswer(), studentAnswer) ? question.getScore() : 0; case "MULTIPLE_CHOICE": // 多选题:完全匹配得满分,少选/错选按比例给分(如4个选项选对3个,得75%) Set<String> correct = new HashSet<>(Arrays.asList(question.getAnswer().split(","))); Set<String> given = new HashSet<>(Arrays.asList(studentAnswer.split(","))); int correctCount = (int) correct.stream().filter(given::contains).count(); return (int) (question.getScore() * ((double) correctCount / correct.size())); case "FILL_BLANK": // 填空题:去除首尾空格、忽略大小写、支持同义词(需提前配置 synonym_map 表) String normalizedAnswer = studentAnswer.trim().toLowerCase(); String standard = question.getAnswer().trim().toLowerCase(); if (normalizedAnswer.equals(standard)) return question.getScore(); // 模糊匹配:Levenshtein 距离 <= 2 且长度差 <= 1 int distance = StringUtils.getLevenshteinDistance(normalizedAnswer, standard); if (distance <= 2 && Math.abs(normalizedAnswer.length() - standard.length()) <= 1) { return (int) (question.getScore() * 0.8); // 模糊匹配给80% } return 0; default: return 0; // SUBJECTIVE 不判分 } }血泪经验:多选题判分必须用Set比较,不能用字符串contains——否则"AB"会被"ABC"包含,导致误判。填空题的 Levenshtein 距离计算用 Apache Commons Text 的StringUtils.getLevenshteinDistance(),已在pom.xml中声明依赖。
5. 常见问题排查:5 条真实翻车现场,省下你 3 天 debug 时间
5.1 现象:登录成功后跳转到空白页,Network 面板显示GET /api/admin/dashboard 403
原因:后端WebSecurityConfig中antMatchers("/api/admin/**").hasRole("ADMIN")要求ROLE_ADMIN,但数据库user.role存的是'ADMIN'字符串,而 Spring Security 的hasRole()会自动加上"ROLE_"前缀,所以实际匹配的是"ROLE_ADMIN"。但UserDetailsServiceImpl.loadUserByUsername()返回的SimpleGrantedAuthority构造时没加前缀:
// 错误写法(导致 403) authorities.add(new SimpleGrantedAuthority(user.getRole())); // 存 "ADMIN" // 正确写法 authorities.add(new SimpleGrantedAuthority("ROLE_" + user.getRole())); // 存 "ROLE_ADMIN"解决:打开UserDetailsServiceImpl.java,找到loadUserByUsername方法,在构建SimpleGrantedAuthority时显式加"ROLE_"前缀。
5.2 现象:Vue 页面显示 “Network Error”,但后端日志无请求记录
原因:前端axios请求被浏览器 CORS 拦截,但控制台只报 Network Error,不显示 CORS 错误。根本原因是后端CorsConfig.java中allowedOrigins配置为["http://localhost:8080"],而 Vue 开发服务器默认端口是8080,但application-dev.yml中后端端口也是8080,端口冲突导致 Vue 无法启动,实际访问的是旧版页面。
解决:修改exam-frontend/vue.config.js中devServer.port为8081,并确保application-dev.yml中server.port=8080不变;或修改后端端口为8082。
5.3 现象:上传试卷封面图片失败,返回500 Internal Server Error,日志报java.io.FileNotFoundException: .../static/upload/
原因:application-dev.yml中upload.path=./static/upload/是相对路径,程序启动时工作目录(working directory)不是项目根目录,而是exam-backend/目录,导致./static/upload/解析为exam-backend/static/upload/,但实际静态资源目录在exam-backend/src/main/resources/static/。
解决:将upload.path改为绝对路径,或统一用ResourceUtils.getFile("classpath:static/").getAbsolutePath()动态获取。推荐改application-dev.yml:
upload: path: ${user.dir}/static/upload/ # user.dir 是 JVM 启动目录,即项目根目录5.4 现象:学生交卷后成绩为 0,但查看exam_answer表发现答案已存,exam_record.total_score却是 0
原因:ExamService.submitAnswer()中判分循环里,question.getAnswer()返回null,导致Objects.equals(null, "B")永远为false。根源是question实体类的answer字段在 MyBatis XML 映射中未配置resultMap,<result property="answer" column="answer"/>被遗漏。
解决:打开QuestionMapper.xml,检查<resultMap id="BaseResultMap" type="Question">是否包含:
<result property="answer" column="answer"/> <result property="options" column="options"/>5.5 现象:管理员后台试卷列表为空,但数据库exam_paper表有数据
原因:ExamPaperController.listPapers()方法用了@PreAuthorize("hasRole('ADMIN')"),但UserDetailsServiceImpl返回的authorities是ROLE_TEACHER,而@PreAuthorize的 SpEL 表达式hasRole('ADMIN')会匹配ROLE_ADMIN,不匹配ROLE_TEACHER。但问题在于,该接口本应允许TEACHER查看自己的试卷,却写了死权限。
解决:改用@PreAuthorize("hasRole('ADMIN') or authentication.principal.id == #teacherId"),或更合理的做法——在 Service 层根据用户角色动态过滤:
public List<ExamPaper> listPapers(Long userId, String role) { if ("ADMIN".equals(role)) { return examPaperDao.selectAll(); } else if ("TEACHER".equals(role)) { return examPaperDao.selectByTeacherId(userId); } return Collections.emptyList(); }6. 二次开发实战:从增加人脸识别登录到接入企业微信,三个可落地的改造方案
6.1 方案一:在登录流程中嵌入活体检测(不改核心架构)
现有登录是纯账号密码,想加人脸识别,不要动LoginController,而是用 Spring Security 的AuthenticationProvider扩展:
- 新增
FaceRecognitionAuthenticationToken继承AbstractAuthenticationToken,携带人脸特征向量; - 实现
FaceRecognitionAuthenticationProvider,authenticate()方法调用 Python 服务(如用 Flask 暴露/verify-face接口)比对特征值; - 在
WebSecurityConfig中注册该 Provider:@Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.authenticationProvider(usernamePasswordProvider()) .authenticationProvider(faceRecognitionProvider()); // 新增 } - 前端
Login.vue增加按钮调用摄像头,用face-api.js提取特征,POST 到/api/auth/login-face(新接口),返回 JWT。
关键点:FaceRecognitionAuthenticationProvider的supports()方法必须返回FaceRecognitionAuthenticationToken.class,否则 Spring Security 不会调用它。特征向量用 Base64 编码传输,Python 服务用numpy.frombuffer(base64.b64decode(...), dtype=np.float32)还原。
6.2 方案二:将考试通知对接企业微信(复用现有消息模块)
项目已有MessageService发送站内信,扩展企业微信只需新增WeComMessageSender:
@Component public class WeComMessageSender implements MessageSender { @Value("${wecom.corpid}") private String corpId; @Value("${wecom.agentid}") private String agentId; @Value("${wecom.secret}") private String secret; @Override public boolean send(String toUser, String content) { // 1. 获取 access_token String tokenUrl = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + corpId + "&corpsecret=" + secret; String tokenJson = restTemplate.getForObject(tokenUrl, String.class); String accessToken = JsonPath.read(tokenJson, "$.access_token"); // 2. 发送应用消息 String msgUrl = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=" + accessToken; Map<String, Object> payload = new HashMap<>(); payload.put("touser", toUser); payload.put("msgtype", "text"); payload.put("agentid", agentId); payload.put("text", Map.of("content", content)); ResponseEntity<String> response = restTemplate.postForEntity(msgUrl, payload, String.class); return response.getStatusCode().is2xxSuccessful(); } }在ExamService.submitAnswer()末尾加入:
if (record.getTotalScore() >= 90) { messageService.send(student.getUsername(), "恭喜!您的《" + paper.getTitle() + "》成绩为 " + record.getTotalScore() + ",进入优秀名单!"); }MessageService用策略模式自动选择WeComMessageSender或InAppMessageSender,通过@Qualifier("weCom")注入。
6.3 方案三:把 Vue2 升级到 Vue3(渐进式迁移,不影响交付)
毕设答辩前不敢大改?用 Vue3 的defineComponent和setup语法重写单个组件,其他保持 Vue2:
- 安装 Vue3:
npm install vue@^3.2.0; - 创建
src/views/StudentExam3.vue,用 Composition API:<script setup> import { ref, onMounted } from 'vue' import { useRoute } from 'vue-router' import { useExamStore } from '@/store/exam' const route = useRoute() const store = useExamStore() const timeLeft = ref(0) onMounted(() => { store.loadPaper(route.params.id) // 启动倒计时... }) </script> - 在
router/index.js中,/exam3/:id路由指向新组件,老路由/exam/:id保持不变; - 打包时
vue.config.js中configureWebpack添加别名:resolve: { alias: { 'vue': path.resolve(__dirname, 'node_modules/vue') } }
这样答辩时可演示“新旧共存”,导师问起技术演进,你能说清 Vue2 到 Vue3 的响应式原理差异(Object.definePropertyvsProxy)、setup的执行时机、以及为什么ref在模板中不需要.value。
从那以后我每次接毕设项目,都会先看它的pom.xml和package.json里有没有锁死版本号、application.yml里有没有硬编码、SQL 脚本里有没有DROP TABLE IF EXISTS——这些细节才是项目能不能在另一台电脑上跑起来的分水岭。这份源码包的可贵之处,正在于它把“能跑通”这件事,拆解成了可验证的每一个字节。希望帮到你。
本文还有配套的精品资源,点击获取