学之思开源考试系统:Java后端+Vue前端+微信小程序+Docker一键部署

该文章已生成可运行项目,

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:学之思考试系统是一套开箱即用的在线考试解决方案,后端用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文件动辄几百行,DeploymentServiceIngressConfigMap层层嵌套,学生还没搞懂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.sqlserver服务的build路径直指./source/server,学生打开这个目录,一眼就看到pom.xmlsrc/main/java/com/xzs,Spring Boot结构扑面而来;web服务用Nginx镜像,build路径是./vue,进去就是Dockerfiledist/目录。这种“配置即路径,路径即代码”的设计,让部署过程变成一次代码溯源之旅——学生执行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.idexam_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接口时,会逐行讲解:

  1. 网关过滤JwtAuthenticationFilter.doFilterInternal()拦截请求,从Header里提取Authorization: Bearer xxx,用Jwts.parser().setSigningKey("xzs-secret")验签,失败则返回401;
  2. 参数校验@RequestBody @Valid ExamStartDTO dto触发Hibernate Validator,检查dto.getPaperId()不能为空、dto.getUserId()必须是数字;
  3. 业务前置检查ExamService.startExam()里先查paper = paperMapper.selectById(dto.getPaperId()),确认试卷status==1(已发布)且publish_time <= now()
  4. 并发控制:用RedisTemplate.opsForValue().setIfAbsent("exam_lock:"+dto.getPaperId(), "1", 30, TimeUnit.SECONDS)防止同一试卷被重复开启;
  5. 记录创建ExamRecord record = new ExamRecord(); record.setUserId(dto.getUserId()); record.setPaperId(dto.getPaperId()); record.setStartTime(LocalDateTime.now()); record.setStatus(1); recordMapper.insert(record);
  6. 自动组卷:如果试卷设置为“随机抽题”,则调用QuestionService.randomSelectQuestions(paperId, 20),从题库按难度权重捞题;
  7. 响应组装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的路由做了三重防护:

  1. 登录态校验if(to.meta.requiresAuth && !store.state.user.token) next('/login')
  2. 考试状态校验if(to.meta.inExam) { const record = store.state.exam.currentRecord; if(!record || record.status !== 1) next('/dashboard') }
  3. 页面卸载拦截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 → 小程序全局存储

具体步骤如下:

  1. 小程序端调用wx.login()获取临时code;
  2. 调用wx.getPhoneNumber()获取加密的手机号密文(需用户点击授权按钮);
  3. 将code和密文一起POST到后端/wx/login接口;
  4. 后端用code向微信服务器换取session_key,再用session_key解密手机号;
  5. 查询数据库:若手机号存在,返回已有用户Token;若不存在,自动创建用户并返回Token;
  6. 小程序将Token存入wx.setStorageSync('token', token),后续所有请求带上Authorization Header。

这个流程的关键在于第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");

学生最容易犯的错是:把encryptedDataiv当成普通字符串传参,而微信返回的是Base64编码,必须先Base64.getDecoder().decode()。我把这个坑写进实验指导书里,并附上调试技巧:在开发者工具Console里输入console.log(encryptedData, iv),复制输出内容到在线Base64解码网站,确认解码后是乱码还是正常JSON——如果是乱码,说明前端没解码就传了。

注意:生产环境务必把appIdappSecret从代码里移出,放到application-prod.ymlwx.app-id配置项中,避免Git泄露。我在实训课上故意把appSecret硬编码在代码里,让学生用git log --grep="appSecret"找到历史提交,再演示如何用.gitignorespring.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-slimCOPY xzs-server.jar /app.jarENTRYPOINT ["java","-jar","/app.jar"],极简到不能再简;
  • docker/web/Dockerfile:基于nginx:alpineCOPY 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:00git clone https://github.com/mindskip/xzs.git,耗时28秒(网络良好);
  • T+00:28cd xzs && ls -l,确认目录结构,重点检查docker-compose.ymlsql/xzs.sqlsource/server/pom.xml是否存在;
  • T+01:15cp sql/xzs.sql xzs-mysql-master/init.sql,把初始化SQL复制到MySQL挂载目录;
  • T+01:30docker-compose up -d,启动三容器,终端显示Creating xzs-mysql ... done等信息;
  • T+02:10docker-compose logs -f mysql | grep "MySQL init process done.",等待MySQL初始化完成(约40秒);
  • T+02:50docker-compose logs -f server | grep "Started XzsServerApplication",等待后端启动成功(约35秒);
  • T+03:25curl -I http://localhost:8080/actuator/health,返回HTTP/1.1 200,确认后端健康;
  • T+03:30curl -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 部署后必做的五项验证:确保系统真正可用

部署完成不等于可用。我要求学生必须完成以下五项验证,缺一不可:

  1. 数据库验证docker exec -it xzs-mysql mysql -uroot -proot xzs -e "SHOW TABLES;",确认输出包含exam_paperexam_question等12张表;
  2. 后端API验证curl "http://localhost:8080/api/exam/paper/list?pageNum=1&pageSize=10",返回JSON格式试卷列表,code==200
  3. 前端静态资源验证curl -I http://localhost/static/js/app.123456.js,返回HTTP/1.1 200 OK,确认Nginx能正确服务静态文件;
  4. 跨域验证:在浏览器Console里执行fetch('http://localhost:8080/api/exam/paper/list').then(r=>r.json()).then(console.log),确认不报CORS错误(因为后端已配@CrossOrigin);
  5. 小程序联调验证:用开发者工具导入wx/工程,修改utils/request.js里的baseUrlhttp://宿主机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到改版权信息

很多学校问:“能不能把‘学之思’改成我们校名?”答案是肯定的,且只需三步:

  1. 前端Logo替换:找到vue/src/assets/logo.png,用PS把文字改成校名,保存为同名文件,执行npm run build重新构建;
  2. 后台页脚修改:打开vue/src/components/layout/Footer.vue,找到<span>© 2023 学之思考试系统</span>,改成<span>© 2024 XX大学在线考试平台</span>
  3. 后端接口水印:修改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。开发流程如下:

  1. 数据库扩展:在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;
  2. 后端实体扩展:修改source/server/src/main/java/com/xzs/entity/ExamQuestion.java,添加:
    java private String codeLanguage; private String codeTemplate; // getter/setter
  3. 前端组件开发:在vue/src/views/question/下新建CodeFill.vue,用<monaco-editor>组件渲染代码模板,用v-model绑定填空答案;
  4. 阅卷逻辑编写:在source/server/src/main/java/com/xzs/service/impl/ExamRecordServiceImpl.java里,新增judgeCodeFill()方法,用正则匹配填空内容;
  5. 路由注册:在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,避免内存溢出。这个配置学生自己加,就是一次真实的运维实践。

我个人在实际教学中发现,学之思最大的价值,不是它已经实现了什么,而是它清晰地展示了“一个合格的在线考试系统,应该长什么样”。它的代码不炫技,但每行都经得起推敲;它的文档不华丽,但每个步骤都可执行;它的部署不复杂,但每个配置都有依据。当我看到学生第一次独立修改完题型、成功部署、并在班级群里兴奋地发截图说“我们自己的考试系统上线了”,那一刻,我知道,这套系统真正完成了它的使命——不是替代教师,而是让教师能把精力,真正放在“如何设计好一道题”“如何分析学情数据”这些不可替代的教学核心上。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:学之思考试系统是一套开箱即用的在线考试解决方案,后端用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部门均可免费使用、修改和分发。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

本文章已经生成可运行项目
随着人类对生命健康需求的不断增长,新药研发面临着前所未有的挑战。传统的药物研发流程通常耗时长达十年以上,耗资数十亿美元,且最终成功率极低,这在制药界被称为“反摩尔定律”困境。近年来,人工智能技术的飞速发展,特别是深度学习和大数据分析的广泛应用,为新药发现带来了革命性的契机。人工智能能够从海量的化学和生物数据中挖掘潜在规律,显著加速药物靶点发现、先导化合物优化等关键环节。在此背景下,本研究旨在设计并实现一个基于人工智能的新药发现辅助系统,以期为传统药物研发流程提供高效的智能化辅助工具,从而有效缩短研发周期并大幅降低研发成本。本研究以Python作为主要开发语言,深度结合PyTorch和TensorFlow两大主流深度学习框架,并集成RDKit化学信息学工具包,构建了一个功能完善的新药发现辅助系统。系统的核心目标是利用先进的人工智能技术辅助新药分子的设计与活性评估。在研究方法上,本文创新性地提出了一种融合多模态数据的新药发现算法。该算法综合处理分子的多种表示形式,包括一维的SMILES序列、二维的分子图结构以及三维的空间构象数据。通过构建多通道神经网络,系统能够有效提取并融合不同模态的特征,从而全面捕捉分子的理化性质与生物学活性之间的复杂非线性关系。 【课程报告内容】 摘要 第1章 绪论 第2章 相关技术与理论 第3章 系统需求分析 第4章 系统总体设计 第5章 系统详细设计与实现 第6章 系统测试与分析 第7章 总结与展望 参考文献 附件-实现指南
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值