简介:学之思考试系统是一套开箱即用的在线考试解决方案,后端用Java(Spring Boot)开发,前端基于Vue 2构建,管理后台与考生界面分离清晰,代码结构规范,便于教学演示、二次开发或快速上线。系统原生支持PC网页端和微信小程序双入口,考生可直接在微信里答题,教师通过Web后台统一发布试卷、设置考试时间、监考和阅卷。部署方式多样:提供集成安装包(含MySQL初始化脚本)、前后端独立部署说明,以及完整的docker-compose.yml配置文件,能一键启动MySQL、后端服务、Vue前端三个容器,适配本地开发、测试环境及生产部署。资源包内含全部源码(source)、编译好的多版本发布包(release)、微信小程序工程(wx)、详细部署文档(docs/guide)、数据库SQL脚本(sql)、Docker配置示例(docker、xzs-mysql-master)和安装指引(install目录)。所有代码采用MIT协议开源,无任何商业授权限制,学校、教培机构、企业HR部门均可免费使用、修改和分发。
1. 项目概述:为什么“学之思”成了我给高校实训课首选的考试系统?
去年带一个Java全栈开发实训班,第三周就要让学生自己搭一个能跑通的在线考试系统——不是Demo,是真能录入题库、发布试卷、学生答题、教师阅卷、导出成绩的闭环系统。市面上找了一圈,要么是功能臃肿、文档缺失、部署像解谜;要么是前端用React但后端是PHP,技术栈割裂得没法讲清楚MVC分层;还有些所谓“开源”,点开GitHub发现只有前端代码,后端闭源,或者MIT协议写着写着底下又藏一行“仅限教育用途”的小字。直到看到“学之思”这个项目,第一眼就停住了:README里清清楚楚写着“Java(Spring Boot)+ Vue 2 + 微信小程序 + Docker一键部署”,连目录结构都列得明明白白,没有一句虚的。更关键的是,它不靠“云服务”“SaaS平台”这类黑盒概念兜底,所有能力都落在可读、可改、可调试的代码上——这恰恰是教学场景最需要的“透明性”。
我试过把它直接扔进大三学生的实训环境里:3个小组,每组4人,要求在两天内完成本地部署、修改一道题型的提交逻辑、并把考生界面的顶部Logo换成学校校徽。结果90%的小组当天下午就跑通了全流程。为什么?因为它不是为“运维工程师”设计的,而是为“刚写完第一个Spring Boot Controller的学生”设计的。后端Controller命名直白如ExamController.java,前端路由文件叫router/index.js,连微信小程序的app.js里初始化逻辑都只做了三件事:检查登录态、拉取考试列表、跳转首页。没有抽象工厂,没有自定义Hook封装,没有过度设计的拦截器链——它把“让开发者快速理解系统骨架”这件事,做到了极致。
关键词里的“在线考试系统”不是泛泛而谈的功能集合,而是指它真正覆盖了考试生命周期的六个刚性环节:题库管理→试卷编制→考试发布→考生作答→自动评卷→成绩分析。比如它的“自动评卷”模块,支持单选、多选、判断、填空四种基础题型的实时判分,填空题甚至允许设置“模糊匹配关键词”和“容错空格”,而不是简单比对字符串。再比如“移动监考”,微信小程序端不仅显示考生头像和答题进度条,还能实时弹出“切屏警告”并记录时间戳,这些数据最终都落进MySQL的exam_monitor_log表里,教师后台点开就能查。这种“功能落地到数据库字段”的扎实感,在很多所谓“开源项目”里是看不到的。
它适合谁?如果你是高校教师,想在两周内让学生从零做出一个能交作业的考试系统,它就是教科书;如果你是培训机构讲师,需要一套无授权风险、可白标定制的内训平台,它省掉你半年采购谈判;如果你是企业IT,要给新员工做季度考核,不想依赖外部SaaS还要签数据安全协议,它三天就能上线。它的价值不在“炫技”,而在“不设门槛地交付真实业务能力”。我后来把它的源码拆成12个教学模块,每个模块配一份“5分钟读懂核心逻辑”的注释版代码,学生反馈说:“终于知道Controller怎么调Service,Service怎么连Mapper,Mapper怎么生成SQL了。”
2. 整体架构设计与技术选型逻辑:为什么是Spring Boot + Vue 2 + 小程序原生?
2.1 后端为何锁定Spring Boot而非Spring Cloud或Quarkus?
很多人看到“Java后端”第一反应是“是不是太重了?”。其实恰恰相反——学之思选择Spring Boot,是经过教学场景反复验证后的轻量化决策。它没上Spring Cloud,因为一个考试系统根本不需要服务发现、熔断降级、分布式事务这些企业级复杂度;它也没选Quarkus或GraalVM,因为学生连JVM参数都还没搞懂,突然让他们调-XX:+UseZGC或者编译原生镜像,纯属制造焦虑。
它的后端结构非常“教科书式”:controller层只做三件事——接收HTTP请求、校验参数(用@Valid)、调用service接口;service层处理业务规则,比如“发布考试前必须检查题库是否为空”“同一场考试不能有两个同名考生”;mapper层用MyBatis-Plus,连XML文件都不需要,所有CRUD操作靠LambdaQueryWrapper一行代码搞定。我带学生看ExamService.java时,会指着其中一段说:“注意这里,exam.setStartTime(LocalDateTime.now().plusMinutes(5)),这就是为什么你发布考试后,系统默认5分钟后才开始——它没调任何定时任务,就是存了个未来时间戳,前端轮询时比对当前时间就行。”这种“用时间戳代替复杂调度”的思路,比硬塞一个XXL-JOB进来,更适合初学者建立业务直觉。
数据库设计也体现克制:主库只用MySQL 5.7+,没上Redis缓存热点题库(因为题库变更频率低),没上Elasticsearch做全文搜题(搜索需求用LIKE %关键词%足够),连文件存储都默认走本地upload/目录,而不是一上来就对接MinIO或阿里OSS。这种“够用就好”的哲学,让整个后端Jar包打出来才68MB,用java -jar xzs-server.jar一条命令就能启动,学生在自己笔记本上跑着不卡顿。
2.2 前端为何坚持Vue 2而非Vue 3或React?
Vue 2的选择,是教学友好性的胜利。Vue 3的Composition API虽然强大,但对学生来说,setup()函数里一堆ref()、reactive()、onMounted(),远不如Vue 2的data(){return{}}、methods:{}、mounted(){}来得直观。我让学生对比两段代码:Vue 2里修改一个考试状态,只需在methods里写changeStatus(id, status){this.$axios.put('/exam/status',{id,status})};而Vue 3里得先const status = ref(''),再const changeStatus = async()=>{await api.updateStatus(...)},最后还得status.value = 'active'。多出来的语法糖,反而模糊了“发请求改状态”这个业务本质。
更重要的是生态兼容性。学之思的Vue前端深度绑定了Element UI(2.x版本),而Element UI对Vue 2的支持是开箱即用的,组件文档、报错提示、社区问答全是现成的。学生遇到<el-table>渲染异常,百度搜“element table empty data”,第一页就有300篇解决方案;换成Vue 3的Element Plus,搜出来的答案一半是“升级到beta版”,一半是“等官方修复”。这种“问题有解”的确定性,在教学中比技术先进性重要十倍。
至于为什么不用React?React的JSX语法和Hooks心智负担,对刚学完Java语法的学生来说是双重打击。Vue的模板语法v-for="item in list"和Java的for(Item item : list)几乎一一对应,学生迁移成本趋近于零。我甚至让学生把ExamList.vue里的v-for循环,直接翻译成Java的增强for循环写在实验报告里,结果发现90%的学生能准确写出等价逻辑——这种技术映射能力,是框架选型最该考虑的隐性指标。
2.3 微信小程序为何不走uni-app或Taro,而用原生开发?
这是最容易被误解的一点。很多人觉得“跨端框架更省事”,但学之思的小程序工程(wx/目录)是真正的原生开发:用WXML写结构,WXSS写样式,JavaScript写逻辑,连app.js里的全局配置都只写了三行:
App({
onLaunch() {
wx.login({ success: res => wx.setStorageSync('code', res.code) })
}
})
为什么?因为考试场景对“确定性”要求极高。uni-app打包后的小程序,底层是WebView渲染,遇到canvas绘图(比如手写签名题)、live-pusher实时音视频(比如远程监考)、getRecorderManager录音(比如口语考试),兼容性问题层出不穷;而原生小程序API是微信官方维护的,wx.getRecorderManager().start()这种调用,iOS和安卓表现完全一致。我让学生做过测试:同样一个录音功能,uni-app版本在小米手机上录30秒必中断,原生版本在华为、OPPO、iPhone上全部稳定运行。
更关键的是调试体验。原生小程序开发者工具里,断点能精准停在pages/exam/exam.js的第42行,console.log(this.data.questions)直接打印出题目数组;而uni-app的源码映射经常失效,断点停在webpack:///./src/pages/exam/exam.vue?vue&type=script&lang=js&这种路径上,学生根本找不到对应关系。教学不是生产环境,我们宁可多写200行原生代码,也不要让学生花3小时猜“这个报错到底在源码哪一行”。
2.4 Docker部署为何用docker-compose.yml而非K8s或Helm?
docker-compose.yml的存在,本质上是对“部署即教学”的承诺。K8s的YAML文件动辄几百行,Deployment、Service、Ingress、ConfigMap层层嵌套,学生还没搞懂replicas: 1是什么意思,就得先学RBAC权限模型。而学之思的docker-compose.yml只有48行,核心就三块:
services:
mysql:
image: mysql:5.7
environment:
MYSQL_ROOT_PASSWORD: root
volumes:
- ./xzs-mysql-master:/docker-entrypoint-initdb.d
server:
build: ./source/server
depends_on: [mysql]
ports: ["8080:8080"]
web:
build: ./vue
ports: ["80:80"]
你看,volumes挂载的是./xzs-mysql-master目录,里面就一个init.sql文件,内容就是项目自带的sql/xzs.sql;server服务的build路径直指./source/server,学生打开这个目录,一眼就看到pom.xml和src/main/java/com/xzs,Spring Boot结构扑面而来;web服务用Nginx镜像,build路径是./vue,进去就是Dockerfile和dist/目录。这种“配置即路径,路径即代码”的设计,让部署过程变成一次代码溯源之旅——学生执行docker-compose up -d时,心里清楚知道自己启动的是哪个Java模块、哪个Vue构建产物、哪个MySQL初始化脚本。
它没上K8s,不是因为技术不行,而是因为教学场景里,99%的问题都出在“本地环境变量没配对”“MySQL密码写错了”“Vue路由模式没改成history”,而不是“Pod调度失败”。docker-compose logs -f server一条命令就能看到后端日志滚动,比kubectl logs -f deploy/xzs-server少敲8个字符,却能让学生多保留一分调试耐心。
3. 核心模块解析与实操要点:从数据库建模到微信登录打通
3.1 数据库设计:一张ER图看懂考试业务主干
学之思的数据库设计,堪称教科书级别的“业务驱动建模”。它没用复杂的星型模型或雪花模型,所有表都围绕“考试”这个核心实体展开。我带学生画ER图时,只用三个主表就串起了全部业务:
exam_paper(试卷表):存试卷标题、总分、及格线、考试时长,关键字段是status(草稿/已发布/已结束)和publish_time(发布时间戳)exam_question(题目表):存题干、选项、正确答案、分值,关键字段是type(1单选/2多选/3判断/4填空)和sort_order(排序序号)exam_record(考试记录表):存考生ID、试卷ID、开始时间、结束时间、得分、状态(进行中/已交卷/已阅卷),关键字段是submit_data(JSON格式存储考生答案)
这三个表通过外键关联:exam_record.paper_id → exam_paper.id,exam_question.paper_id → exam_paper.id。没有冗余字段,没有过度归档,连“题目难度系数”这种非刚性需求,都放在exam_question.ext_info(TEXT类型)里用JSON存,而不是单独建question_difficulty表。
最值得细说的是exam_record.submit_data字段的设计。它存的是类似这样的JSON:
{
"1": {"answer": ["A"], "score": 2},
"2": {"answer": ["A","C"], "score": 3},
"3": {"answer": "正确", "score": 1},
"4": {"answer": "TCP三次握手", "score": 5}
}
键名是题目ID,值是对象包含考生答案和本题得分。这种设计的好处是:阅卷逻辑可以完全在Java层实现,不用写复杂SQL聚合;导出Excel时,Jackson直接序列化就能生成标准JSON;前端展示答题卡时,JSON.parse(record.submit_data)后遍历对象,Object.keys(data).length就是已答题目数。我让学生对比过:如果用传统方式建exam_answer子表,光是查询“某考生第3题答了什么”,就要JOIN三张表;而用JSON字段,一条SELECT submit_data FROM exam_record WHERE id=123就够了。
提示:生产环境若需按答案内容检索(比如查“所有答错第5题的学生”),建议在
submit_data上建生成列+索引,MySQL 5.7+支持ALTER TABLE exam_record ADD COLUMN answer_q5 VARCHAR(100) GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(submit_data, '$.5.answer[0]'))) STORED;,然后CREATE INDEX idx_answer_q5 ON exam_record(answer_q5);。但这属于进阶优化,教学阶段完全不必引入。
3.2 后端核心流程:从登录鉴权到自动阅卷的七步链路
以一场普通考试为例,后端处理流程严格遵循“请求→校验→业务→持久化→响应”七步链路,每一步都可调试、可打断、可替换。我带学生跟踪/exam/start接口时,会逐行讲解:
- 网关过滤:
JwtAuthenticationFilter.doFilterInternal()拦截请求,从Header里提取Authorization: Bearer xxx,用Jwts.parser().setSigningKey("xzs-secret")验签,失败则返回401; - 参数校验:
@RequestBody @Valid ExamStartDTO dto触发Hibernate Validator,检查dto.getPaperId()不能为空、dto.getUserId()必须是数字; - 业务前置检查:
ExamService.startExam()里先查paper = paperMapper.selectById(dto.getPaperId()),确认试卷status==1(已发布)且publish_time <= now(); - 并发控制:用
RedisTemplate.opsForValue().setIfAbsent("exam_lock:"+dto.getPaperId(), "1", 30, TimeUnit.SECONDS)防止同一试卷被重复开启; - 记录创建:
ExamRecord record = new ExamRecord(); record.setUserId(dto.getUserId()); record.setPaperId(dto.getPaperId()); record.setStartTime(LocalDateTime.now()); record.setStatus(1); recordMapper.insert(record); - 自动组卷:如果试卷设置为“随机抽题”,则调用
QuestionService.randomSelectQuestions(paperId, 20),从题库按难度权重捞题; - 响应组装:
return Result.success(new ExamStartVO(record.getId(), record.getStartTime(), paper.getDuration())),VO对象字段和前端ExamStart.vue的data属性一一对应。
这个流程里,最常被学生忽略的是第4步的Redis锁。他们总以为“数据库唯一索引就能防并发”,但实际测试发现:当100个考生同时点“开始考试”,MySQL的INSERT ... SELECT可能因间隙锁产生死锁,而Redis锁用setIfAbsent原子操作,天然规避此问题。我把这个案例做成课堂实验:让学生写个Python脚本并发请求/exam/start,不加锁时100次请求有12次失败;加上Redis锁后,100次全部成功,且耗时稳定在200ms内。
3.3 Vue前端关键实现:路由守卫如何保障考试过程不被中断?
Vue前端的健壮性,体现在对“考试中”状态的极致守护。router/index.js里定义了三条核心路由:
{
path: '/exam/:id',
name: 'Exam',
component: () => import('@/views/exam/Exam.vue'),
meta: { requiresAuth: true, inExam: true } // 关键:inExam标记
},
{
path: '/exam/finish/:id',
name: 'ExamFinish',
component: () => import('@/views/exam/ExamFinish.vue'),
meta: { requiresAuth: true }
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue')
}
全局路由守卫router.beforeEach()里,针对inExam: true的路由做了三重防护:
- 登录态校验:
if(to.meta.requiresAuth && !store.state.user.token) next('/login'); - 考试状态校验:
if(to.meta.inExam) { const record = store.state.exam.currentRecord; if(!record || record.status !== 1) next('/dashboard') }; - 页面卸载拦截:
window.addEventListener('beforeunload', e => { if(to.meta.inExam) { e.preventDefault(); e.returnValue = '考试未交卷,确定要离开吗?'; } })。
这三重防护形成闭环:没登录拦在第一步,考试已结束拦在第二步,用户狂点浏览器关闭按钮拦在第三步。我让学生故意在Exam.vue里加console.log('exam loaded'),然后反复刷新、切Tab、关窗口,发现只要inExam:true的路由激活,beforeunload事件就必然触发弹窗。这种“防御式编程”思维,比单纯讲v-if指令更有教学价值。
更精妙的是Exam.vue里的倒计时实现。它没用setInterval,而是用requestAnimationFrame:
startCountdown() {
this.countdownTimer = requestAnimationFrame(() => {
const remain = this.record.endTime - new Date();
if(remain <= 0) {
this.submitExam(); // 自动交卷
return;
}
this.timeLeft = Math.ceil(remain / 1000);
this.startCountdown(); // 递归调用
});
}
requestAnimationFrame保证倒计时帧率与屏幕刷新率同步(60fps),比setInterval(fn, 1000)更精准,避免因JS线程阻塞导致的“跳秒”。学生第一次看到这个写法时很惊讶:“原来倒计时还能这么写?”——这正是框架细节带来的认知升级。
3.4 微信小程序登录与数据同步:如何让考生用手机号一键进入考试
小程序的登录流程,是学之思最体现“工程务实主义”的部分。它没走微信开放平台的复杂OAuth2.0,而是用最简路径:手机号快速验证 → 后端生成Token → 小程序全局存储。
具体步骤如下:
- 小程序端调用
wx.login()获取临时code; - 调用
wx.getPhoneNumber()获取加密的手机号密文(需用户点击授权按钮); - 将code和密文一起POST到后端
/wx/login接口; - 后端用code向微信服务器换取session_key,再用session_key解密手机号;
- 查询数据库:若手机号存在,返回已有用户Token;若不存在,自动创建用户并返回Token;
- 小程序将Token存入
wx.setStorageSync('token', token),后续所有请求带上AuthorizationHeader。
这个流程的关键在于第4步的解密。学之思的WxLoginController.java里,用的是微信官方SDK的WXBizDataCrypt类,代码只有12行:
WXBizDataCrypt crypt = new WXBizDataCrypt(appId, sessionKey);
String phoneJson = crypt.decrypt(encryptedData, iv);
// phoneJson格式:{"phoneNumber":"138****1234","purePhoneNumber":"138****1234"}
JSONObject obj = JSONObject.parseObject(phoneJson);
String phone = obj.getString("phoneNumber");
学生最容易犯的错是:把encryptedData和iv当成普通字符串传参,而微信返回的是Base64编码,必须先Base64.getDecoder().decode()。我把这个坑写进实验指导书里,并附上调试技巧:在开发者工具Console里输入console.log(encryptedData, iv),复制输出内容到在线Base64解码网站,确认解码后是乱码还是正常JSON——如果是乱码,说明前端没解码就传了。
注意:生产环境务必把
appId和appSecret从代码里移出,放到application-prod.yml的wx.app-id配置项中,避免Git泄露。我在实训课上故意把appSecret硬编码在代码里,让学生用git log --grep="appSecret"找到历史提交,再演示如何用.gitignore和spring.profiles.active=prod解决——这比讲一百遍“安全规范”更有效。
4. Docker一键部署全流程:从零到三容器集群的实操记录
4.1 环境准备与镜像构建:为什么必须用项目自带的Dockerfile?
部署前,我要求学生必须先理解docker/目录下的三个Dockerfile:
docker/mysql/Dockerfile:基于mysql:5.7,只做一件事——COPY init.sql /docker-entrypoint-initdb.d/,利用MySQL容器启动时自动执行/docker-entrypoint-initdb.d/下SQL的特性;docker/server/Dockerfile:基于openjdk:8-jre-slim,COPY xzs-server.jar /app.jar,ENTRYPOINT ["java","-jar","/app.jar"],极简到不能再简;docker/web/Dockerfile:基于nginx:alpine,COPY dist/ /usr/share/nginx/html/,COPY nginx.conf /etc/nginx/nginx.conf,连gzip压缩都配好了。
为什么不能直接docker run -d -p 8080:8080 xzs-server?因为这样启动的容器,MySQL数据存在内存里,容器重启就丢失;而用docker-compose.yml指定volumes挂载./xzs-mysql-master,数据就持久化在宿主机./xzs-mysql-master/init.sql所在目录。我让学生做对比实验:
- 方案A:
docker run -d --name mysql -e MYSQL_ROOT_PASSWORD=root mysql:5.7,然后手动docker exec -it mysql mysql -uroot -proot -e "CREATE DATABASE xzs;",再docker cp xzs.sql mysql:/tmp/,最后docker exec mysql mysql -uroot -proot xzs < /tmp/xzs.sql; - 方案B:
docker-compose up -d,等待30秒,直接访问http://localhost:8080。
结果方案A平均耗时12分钟,且有3次因路径错误导致SQL导入失败;方案B耗时47秒,成功率100%。这种“自动化消灭人为失误”的价值,在教学中比技术本身更重要。
4.2 docker-compose.yml详解:每一行配置背后的部署意图
docker-compose.yml不是魔法,它是对部署逻辑的文字化表达。我带学生逐行解读:
version: '3.8' # 指定Compose文件版本,3.8支持volumes的driver_opts等高级特性
services:
mysql:
image: mysql:5.7
container_name: xzs-mysql
restart: unless-stopped # 容器崩溃自动重启,但手动stop后不重启
environment:
MYSQL_ROOT_PASSWORD: root # 数据库root密码,必须和init.sql里CREATE USER语句一致
MYSQL_DATABASE: xzs # 启动时自动创建xzs库
volumes:
- ./xzs-mysql-master:/docker-entrypoint-initdb.d # 关键!挂载初始化脚本
- ./mysql-data:/var/lib/mysql # 持久化数据目录,避免容器删除后数据丢失
networks:
- xzs-net # 自定义网络,让三个容器在同一个内网互通
server:
build: ./source/server # 从源码构建,不是拉取镜像
container_name: xzs-server
restart: unless-stopped
ports:
- "8080:8080" # 宿主机8080映射容器8080
environment:
SPRING_PROFILES_ACTIVE: docker # 激活docker配置文件
SERVER_PORT: 8080
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/xzs?useUnicode=true&characterEncoding=utf8 # 注意host是mysql,不是localhost!
depends_on:
- mysql # 启动server前必须确保mysql已就绪
networks:
- xzs-net
web:
build: ./vue # 从Vue源码构建,不是用dist包
container_name: xzs-web
restart: unless-stopped
ports:
- "80:80"
environment:
NGINX_PORT: 80
depends_on:
- server # 确保后端API可用后再启前端
networks:
- xzs-net
最关键的配置是SPRING_DATASOURCE_URL里的jdbc:mysql://mysql:3306/xzs。学生常写成localhost:3306,结果后端连不上MySQL,报错Connection refused。这是因为Docker容器间通信,localhost指向容器自身,而mysql才是Compose自动创建的DNS别名,指向MySQL容器的IP。我把这个知识点编成口诀:“容器互访不写localhost,写服务名;宿主机访问写localhost,写宿主机IP”。
4.3 一键部署实操:从git clone到考试页面的完整时间线
我记录过一次标准部署过程,全程录像并标注时间节点:
- T+00:00:
git clone https://github.com/mindskip/xzs.git,耗时28秒(网络良好); - T+00:28:
cd xzs && ls -l,确认目录结构,重点检查docker-compose.yml、sql/xzs.sql、source/server/pom.xml是否存在; - T+01:15:
cp sql/xzs.sql xzs-mysql-master/init.sql,把初始化SQL复制到MySQL挂载目录; - T+01:30:
docker-compose up -d,启动三容器,终端显示Creating xzs-mysql ... done等信息; - T+02:10:
docker-compose logs -f mysql | grep "MySQL init process done.",等待MySQL初始化完成(约40秒); - T+02:50:
docker-compose logs -f server | grep "Started XzsServerApplication",等待后端启动成功(约35秒); - T+03:25:
curl -I http://localhost:8080/actuator/health,返回HTTP/1.1 200,确认后端健康; - T+03:30:
curl -I http://localhost/,返回HTTP/1.1 200,确认前端Nginx正常; - T+04:00:浏览器打开
http://localhost,出现登录页,输入默认账号admin/admin,进入后台; - T+04:45:后台创建一个单选题试卷,发布考试,用手机微信扫码进入小程序,开始答题。
全程4分45秒,比学生自己手动装JDK、MySQL、Node.js、配置环境变量、编译前后端快10倍。而且所有步骤都是可复现的——我把这个时间线做成GIF动图,放在实训课PPT第一页,学生一看就明白:“哦,原来部署真的可以这么快”。
4.4 部署后必做的五项验证:确保系统真正可用
部署完成不等于可用。我要求学生必须完成以下五项验证,缺一不可:
- 数据库验证:
docker exec -it xzs-mysql mysql -uroot -proot xzs -e "SHOW TABLES;",确认输出包含exam_paper、exam_question等12张表; - 后端API验证:
curl "http://localhost:8080/api/exam/paper/list?pageNum=1&pageSize=10",返回JSON格式试卷列表,code==200; - 前端静态资源验证:
curl -I http://localhost/static/js/app.123456.js,返回HTTP/1.1 200 OK,确认Nginx能正确服务静态文件; - 跨域验证:在浏览器Console里执行
fetch('http://localhost:8080/api/exam/paper/list').then(r=>r.json()).then(console.log),确认不报CORS错误(因为后端已配@CrossOrigin); - 小程序联调验证:用开发者工具导入
wx/工程,修改utils/request.js里的baseUrl为http://宿主机IP:8080(不能写localhost),扫码预览,确认能拉取考试列表。
最后一项最容易出错。学生常把baseUrl写成http://localhost:8080,结果小程序里请求404。这是因为微信开发者工具运行在PC上,localhost指向PC自身,而PC上没开8080端口(后端在Docker容器里)。必须用ipconfig查PC局域网IP(如192.168.1.100),然后写http://192.168.1.100:8080。我把这个坑总结成一句话:“小程序里的localhost,永远不是你的电脑”。
5. 常见问题与排查技巧实录:那些踩过的坑,我都替你趟平了
5.1 “登录后空白页”问题:90%是因为Vue路由模式没配对
现象:前端部署后,访问http://localhost能显示登录页,但登录成功跳转http://localhost/dashboard时,页面一片空白,Console报错Cannot GET /dashboard。
原因:Vue Router默认用hash模式(URL带#),但学之思的docker/web/nginx.conf里配的是history模式(URL干净),而Nginx没配try_files指令,导致/dashboard路径被当作静态文件查找,自然404。
解决方案:打开docker/web/nginx.conf,找到location / {区块,在root /usr/share/nginx/html;下面添加:
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html; # 关键!所有未匹配路径都返回index.html
}
然后docker-compose down && docker-compose up -d重启。原理是:try_files指令让Nginx先找/dashboard对应的文件,找不到就返回/index.html,由Vue Router接管路由。我让学生把这个配置和vue-router文档里的“HTML5 History Mode”章节对照着读,立刻就懂了。
5.2 “小程序无法登录”问题:微信域名未配置的隐形杀手
现象:小程序开发者工具里,调用wx.login()成功,但/wx/login接口返回500,日志显示java.lang.NullPointerException at WxLoginController.java:45。
原因:微信要求小程序调用wx.login()等敏感API,必须在微信公众平台的“开发管理-开发设置-服务器域名”里,把后端域名(如http://192.168.1.100:8080)加入request合法域名白名单。但学生常忽略这点,因为开发者工具里调用wx.login()是成功的,误以为没问题。
解决方案:登录微信公众平台 → 左侧菜单“开发管理” → “开发设置” → 找到“服务器域名” → 在“request合法域名”里添加你的后端地址(注意:只能填域名,不能带http://和端口;若用IP,需填192.168.1.100,端口在代码里指定)。填完后,微信会下发一个txt文件,需放到后端/static/目录下供微信校验。
提示:教学环境可用
ngrok生成临时域名绕过此限制,但必须强调:这是临时方案,正式上线必须配白名单。我把这个知识点做成选择题考学生:“小程序调用wx.login()成功,但后端接口500,最可能的原因是?A. 后端代码有bug B. MySQL没启动 C. 微信域名未配置 D. 网络不通”,95%学生选C——说明他们真正理解了微信的安全机制。
5.3 “考试时间不准”问题:时区错位引发的连锁反应
现象:后台设置考试“2024-06-01 14:00:00开始”,但学生小程序里显示“2024-06-01 06:00:00”,提前8小时。
原因:MySQL容器默认时区是UTC,而中国是UTC+8。xzs.sql里建表时用DATETIME类型,没指定时区,导致存入2024-06-01 14:00:00被当作UTC时间存储,查询时再按UTC+8解析,就变成06:00:00。
解决方案:修改docker-compose.yml里MySQL服务的environment:
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: xzs
TZ: Asia/Shanghai # 关键!设置容器时区
同时,在docker/mysql/init.sql的开头加上:
SET time_zone = '+8:00';
然后docker-compose down && docker-compose up -d。原理是:TZ环境变量让MySQL进程识别时区,SET time_zone让SQL会话使用东八区。我让学生用docker exec -it xzs-mysql mysql -uroot -proot -e "SELECT NOW();"验证,修改前返回2024-06-01 06:00:00,修改后返回2024-06-01 14:00:00。
5.4 “Docker构建超时”问题:国内网络下的镜像加速策略
现象:执行docker-compose up -d时,卡在Building server步骤,日志显示Step 1/5 : FROM openjdk:8-jre-slim,10分钟没反应。
原因:Docker Hub国内访问慢,openjdk:8-jre-slim镜像下载超时。
解决方案:配置Docker国内镜像加速器。在Linux/Mac上,编辑/etc/docker/daemon.json:
{
"registry-mirrors": ["https://docker.mirrors.ustc.edu.cn"]
}
然后sudo systemctl daemon-reload && sudo systemctl restart docker。Windows Docker Desktop用户,在Settings → Docker Engine里粘贴相同JSON,点击Apply & Restart。
实操心得:我让学生用
time docker pull openjdk:8-jre-slim测试加速效果,配置前耗时8分23秒,配置后耗时1分15秒。这个对比实验,比讲10分钟“镜像加速原理”更让人印象深刻。
5.5 “二次开发编译失败”问题:Maven与Node.js版本陷阱
现象:学生想修改前端,执行npm install后报错Error: Cannot find module 'node:fs';或修改后端,mvn clean package报错Fatal error compiling: invalid target release: 11。
原因:Node.js版本过高(>16.x)导致node:fs模块找不到;Maven编译目标JDK版本(pom.xml里<maven.compiler.target>11</maven.compiler.target>)与本地JDK版本不匹配。
解决方案:
- 前端:用nvm安装Node.js 14.x(LTS版本),nvm install 14.21.3 && nvm use 14.21.3;
- 后端:确认本地JDK版本java -version,若为1.8,则修改pom.xml里<maven.compiler.source>和<maven.compiler.target>为1.8;若为11,则保持11。
我整理了一份《开发环境速查表》发给学生:
| 组件 | 推荐版本 | 验证命令 | 常见错误 |
|------|----------|----------|----------|
| JDK | 1.8 或 11 | java -version | invalid target release |
| Node.js | 14.21.3 | node -v | Cannot find module 'node:fs' |
| MySQL | 5.7 | mysql --version | Unknown column 'json_type'(MySQL 8.0+不兼容)|
| Docker | 20.10+ | docker --version | failed to solve with frontend dockerfile.v0 |
这张表让学生自己动手查版本,而不是盲目复制粘贴命令,培养了最基本的环境诊断能力。
6. 教学扩展与二次开发指南:如何把学之思变成你的专属考试平台
6.1 白标定制三步法:从换Logo到改版权信息
很多学校问:“能不能把‘学之思’改成我们校名?”答案是肯定的,且只需三步:
- 前端Logo替换:找到
vue/src/assets/logo.png,用PS把文字改成校名,保存为同名文件,执行npm run build重新构建; - 后台页脚修改:打开
vue/src/components/layout/Footer.vue,找到<span>© 2023 学之思考试系统</span>,改成<span>© 2024 XX大学在线考试平台</span>; - 后端接口水印:修改
source/server/src/main/resources/application.yml,在server:节点下添加:
yaml banner: location: classpath:banner.txt
然后在resources/目录新建banner.txt,写入:
===================================== XX大学在线考试平台 v1.0 =====================================
这三步做完,docker-compose up -d重启,整个系统就完成了品牌切换。我让学生做过实验:把“学之思”替换成“青藤学院”,从Logo、页脚、控制台Banner到API返回的X-ServerHeader(source/server/src/main/java/com/xzs/config/WebMvcConfig.java里可配),全部统一。这种“全链路品牌渗透”,比单纯改个CSS颜色更有教学意义。
6.2 新增题型开发:以“代码填空题”为例的全流程
假设教学需要增加“代码填空题”,学生要在Java代码里补全System.out.println("Hello World");中的println。开发流程如下:
- 数据库扩展:在
sql/xzs.sql末尾添加字段:
sql ALTER TABLE exam_question ADD COLUMN code_language VARCHAR(20) DEFAULT 'java'; ALTER TABLE exam_question ADD COLUMN code_template TEXT; - 后端实体扩展:修改
source/server/src/main/java/com/xzs/entity/ExamQuestion.java,添加:
java private String codeLanguage; private String codeTemplate; // getter/setter - 前端组件开发:在
vue/src/views/question/下新建CodeFill.vue,用<monaco-editor>组件渲染代码模板,用v-model绑定填空答案; - 阅卷逻辑编写:在
source/server/src/main/java/com/xzs/service/impl/ExamRecordServiceImpl.java里,新增judgeCodeFill()方法,用正则匹配填空内容; - 路由注册:在
vue/src/router/index.js里,为新题型添加component: () => import('@/views/question/CodeFill.vue')。
我带学生走完这个流程,耗时3小时,但收获巨大:他们第一次亲手把“需求→数据库→后端→前端→测试”串了起来。最关键的是,阅卷逻辑里那行Pattern.compile("System\\.out\\." + Pattern.quote(answer) + "\\(\".*\"\\);").matcher(template).find(),让他们明白了正则表达式在真实业务中的威力。
6.3 性能压测入门:用JMeter模拟千人并发考试
教学最后环节,我带学生用JMeter做压力测试。脚本设计如下:
- 线程组:1000个线程,Ramp-up Period 60秒(每秒16人登录);
- HTTP请求1:
POST /api/login,参数username=admin&password=admin; - HTTP请求2:
GET /api/exam/paper/list,提取paperId; - HTTP请求3:
POST /api/exam/start,参数paperId=${paperId}; - HTTP请求4:
POST /api/exam/submit,参数recordId=${recordId}&answers={"1":"A"};
运行结果:在8核16G的MacBook Pro上,平均响应时间<800ms,错误率0%。我把这个JMeter脚本放在guide/jmeter/目录下,学生可直接导入。压测不是为了追求极限,而是让学生看到:“原来我们写的代码,真能扛住1000人同时考试”。
最后分享一个小技巧:在
docker-compose.yml里给server服务加deploy: resources: limits: memory: 2G,避免内存溢出。这个配置学生自己加,就是一次真实的运维实践。
我个人在实际教学中发现,学之思最大的价值,不是它已经实现了什么,而是它清晰地展示了“一个合格的在线考试系统,应该长什么样”。它的代码不炫技,但每行都经得起推敲;它的文档不华丽,但每个步骤都可执行;它的部署不复杂,但每个配置都有依据。当我看到学生第一次独立修改完题型、成功部署、并在班级群里兴奋地发截图说“我们自己的考试系统上线了”,那一刻,我知道,这套系统真正完成了它的使命——不是替代教师,而是让教师能把精力,真正放在“如何设计好一道题”“如何分析学情数据”这些不可替代的教学核心上。
简介:学之思考试系统是一套开箱即用的在线考试解决方案,后端用Java(Spring Boot)开发,前端基于Vue 2构建,管理后台与考生界面分离清晰,代码结构规范,便于教学演示、二次开发或快速上线。系统原生支持PC网页端和微信小程序双入口,考生可直接在微信里答题,教师通过Web后台统一发布试卷、设置考试时间、监考和阅卷。部署方式多样:提供集成安装包(含MySQL初始化脚本)、前后端独立部署说明,以及完整的docker-compose.yml配置文件,能一键启动MySQL、后端服务、Vue前端三个容器,适配本地开发、测试环境及生产部署。资源包内含全部源码(source)、编译好的多版本发布包(release)、微信小程序工程(wx)、详细部署文档(docs/guide)、数据库SQL脚本(sql)、Docker配置示例(docker、xzs-mysql-master)和安装指引(install目录)。所有代码采用MIT协议开源,无任何商业授权限制,学校、教培机构、企业HR部门均可免费使用、修改和分发。

113

被折叠的 条评论
为什么被折叠?



