夜雨聆风学习资料网

ARTICLE · 1164530

微信公众号技术文档-内嵌图片版

微信公众号技术文档-内嵌图片版

在线考试怎么防作弊?我用 Vue + face-api.js 做了切屏 / 人脸 / 摄像头三重监控

浏览器端就能跑的人脸检测,不依赖任何后端算法服务。这篇文章把切屏检测、摄像头抓拍、face-api.js 人脸离开检测组合成一套在线考试防作弊方案,附完整代码、设计权衡和踩坑记录。

一、为什么要做这件事

做过在线考试系统的都知道,最难的不是"答题",而是"怎么让答题不作弊"。

线下有监考老师盯着,线上呢?考生完全可以切出去搜答案、找人代考、中途离开。接第三方监考服务又贵、还要把视频传到云端,对毕设和个人项目根本不现实。

我的思路是:把监控能力尽量放到浏览器端——用浏览器原生事件做切屏检测、用 WebRTC 拿摄像头画面、用 face-api.js 做人脸检测。全程不需要后端跑任何算法,前端就能实现一套"轻量监考"。

项目本身是一套前后端分离的在线考试系统(SpringBoot + Vue + Element-UI),覆盖管理员 / 教师 / 学生三类角色。在深入防作弊实现之前,先用一张架构图和一份功能清单交代清楚系统全貌,后面再看防作弊就不会一头雾水。

二、系统架构

整体是标准的前后端分离 + 后端三层结构:前端 Vue SPA 通过 Axios 调 /api/*,开发环境由 devServer 代理到后端并去掉 /api 前缀,后端 SpringBoot 按 Controller → Service → Mapper 分层,最终落到 MySQL。

技术栈

后端

技术
版本
说明
SpringBoot
2.1.2
核心框架,内置 Tomcat,端口 8080
MyBatis-Plus
3.1.0
持久层框架,简化 CRUD
MySQL
8.x
关系型数据库
Druid
1.1.8
数据库连接池
Apache POI
3.14
Excel 题库导入导出
Lombok
1.18.6
简化实体样板代码

前端

技术
版本
说明
Vue.js
2.5
渐进式框架,单页应用
Element-UI
2.4
UI 组件库
Vue Router
3.0
前端路由,按角色分发
Vuex
3.0
状态管理(persistedstate 持久化)
Axios
0.18
HTTP 请求,/api 代理到后端
ECharts
4.2
成绩分布 / 分数段可视化
face-api.js
0.22
浏览器端人脸检测(防作弊)

三、核心功能模块

系统按角色分为三个工作台,登录时自动识别身份并路由到对应端:

1. 管理员端

  • 用户管理:增删改教师 / 学生账号,分配角色权限(perMissionManage)。
  • 考试管理:审核、发布或撤回教师提交的考试。
  • 数据统计:查看参考人数、平均分、成绩分布等整体运行情况。

2. 教师端

  • 题库管理:单选 / 多选 / 判断 / 填空 / 简答五大题型增删改查,支持 Excel 模板批量导入导出。
  • 试卷生成:按题型 / 分值自定义组卷,paper_manage 通过 paperId + questionType + questionId 关联题目。
  • 考试发布:创建考试并关联试卷,设置时长、总分、切屏阈值等参数。
  • 成绩评定与分析:客观题自动判分,主观题人工批改;ECharts 展示成绩统计与分数段分布。

3. 学生端

  • 在线考试:规定时间内作答,倒计时结束自动交卷,全程防作弊监控(本文重点)。
  • 练习模式:isPractice 下不启用监控,供平时刷题。
  • 成绩查询:查看个人成绩与答题详情。
  • 消息通知:接收考试相关通知。

主要 REST 接口

方法
前端调用路径
说明
POST
/api/login
统一登录,区分管理员 / 教师 / 学生
GET
/api/exam/{examCode}
获取考试详情(含时长、切屏阈值 toggleNum)
GET
/api/paper/{paperId}
按题型获取试卷题目
POST
/api/score
提交成绩
GET
/api/scores/{examCode}
查询某场考试成绩
POST
/api/import
Excel 题库批量导入
GET
/api/export
题库导出

四、三重监控的整体设计

系统实现了三个维度的监控,从"最容易做"到"最有技术含量"依次是:

监控维度
技术手段
判定逻辑
状态
① 切屏检测
blur
 / visibilitychange 原生事件
页面失焦或隐藏即计数,超阈值强制交卷
已接入正式答题流程
② 摄像头抓拍
WebRTC getUserMedia + Canvas
定时抓拍留证
考试页实现
③ 人脸离开检测
face-api.js
 TinyFaceDetector
连续多次检测不到人脸判异常
考试页实现

三者共用一套思路:异常行为累计计数 → 弹警告 → 超过阈值自动交卷。阈值不是写死的,而是由教师在创建考试时配置,随考试数据下发到前端。

下面逐维度拆解真实代码。

五、① 切屏检测:原生事件抓"小动作"(已上线)

这是接入正式答题流程的能力。核心是阈值可配置:切屏上限 maxSwitchCount 来自后端考试配置的 toggleNum 字段,练习模式或未配置时不启用监控:

// 从考试数据中读取切屏次数限制getSwitchLimitFromExamData() {  if (this.examData.toggleNum !== undefined && this.examData.toggleNum !== null) {    this.maxSwitchCount = this.examData.toggleNum    if (this.maxSwitchCount > 0) {      this.initSwitchMonitor()   // 阈值 > 0 才启用    }  } else {    this.maxSwitchCount = 0      // 未配置则关闭监控  }}

启用后同时监听三个事件,尽量堵住各种"切出去"的姿势:

initSwitchMonitor() {  if (this.isPractice || this.maxSwitchCount <= 0) return  // 练习模式不监控  this.isMonitoring = true  document.addEventListener('visibilitychange', this.handleVisibilityChange)  window.addEventListener('blur', this.handleWindowBlur)  window.addEventListener('focus', this.handleWindowFocus)}handleVisibilityChange() {  if (document.hidden) this.handleSwitch()   // 切标签页 / 最小化触发}handleWindowBlur() {  this.handleSwitch()                        // 点别的窗口触发}

blur 和 visibilitychange 会重叠触发,所以计数函数加了去重守卫,避免考生切一次被扣两次;达到阈值就强制交卷,否则弹警告:

handleSwitch() {  // 未在监控 / 正在弹警告 / 正在强制交卷时,直接忽略  if (!this.isMonitoring || this.switchWarning || this.isForceSubmitting) return  this.switchCount++  if (this.switchCount >= this.maxSwitchCount) {    this.forceSubmit()          // 超限 → 强制交卷  } else {    this.showSwitchWarning()    // 未超限 → 警告并显示 x/max  }}

强制交卷时会先移除所有监听器,再计算分数并上报成绩接口,同时把 forceSubmit: true 带到成绩页,方便标记"因违规被强制交卷":

submitExam() {  this.cleanupSwitchMonitor()   // 先清理监听,避免重复触发  // ...计算 finalScore...  this.$axios({    url: '/api/score',    method: 'post',    data: {      examCode: this.examData.examCode,      studentId: this.userInfo.id,      subject: this.examData.source,      etScore: finalScore,      answerDate: answerDate,    }  }).then(res => {    if (res.data.code == 200) {      clearInterval(this.timer)      this.$router.push({ path: '/studentScore', query: { score: finalScore, forceSubmit: true } })    }  })}

组件销毁时也要清理,否则路由切走后监听器还挂着:

beforeDestroy() {  this.cleanupSwitchMonitor()  if (this.timer) clearInterval(this.timer)}

六、倒计时与自动交卷:监控闭环的最后一环

考试用 setInterval 每秒递减剩余时间,并在剩余 10 分钟 / 5 分钟 / 1 分钟时分级提醒,归零则自动交卷——和"违规超限"殊途同归:

showTime() {  this.timer = setInterval(() => {    if (this.totalSeconds <= 0) {      clearInterval(this.timer)      this.timeExpired()        // 时间到,自动交卷      return    }    this.totalSeconds--    this.updateTimeDisplay()    if (this.totalSeconds === 600) this.$message.warning('考试时间还剩 10 分钟!')    if (this.totalSeconds === 300) this.$message.warning('考试时间还剩 5 分钟!')    if (this.totalSeconds === 60)  this.$message.error('考试时间仅剩 1 分钟!')  }, 1000)}

正常交卷(commit)时若时间未到,还会弹二次确认,防止误点:

if (this.totalSeconds > 0) {  this.$confirm('考试结束时间未到,是否提前交卷', '友情提示', {    confirmButtonText: '立即交卷',    cancelButtonText: '再检查一下',    type: 'warning'  }).then(() => { /* 提交成绩 */ })}

七、② 摄像头抓拍:WebRTC 拿画面 + Canvas 留证

在考试页里,进入页面(mounted)就无条件申请摄像头。分辨率压到 640×480 够用即可,省带宽也省性能:

async startCamera() {  const stream = await navigator.mediaDevices.getUserMedia({    video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' },    audio: false   // 只要画面,不要声音  })  this.videoStream = stream  this.$refs.monitorVideo.srcObject = stream  this.isCameraActive = true  this.$refs.monitorVideo.onloadedmetadata = async () => {    await this.loadFaceDetectionModels()          // 画面就绪后再加载模型    setInterval(() => this.captureFrame(), 10000) // 每 10 秒抓拍一帧    this.startFaceDetection()                     // 启动人脸检测  }}

抓拍走 ,把当前视频帧画下来,需要留证时 toDataURL() 上传即可:

captureFrame() {  const videoEl = this.$refs.monitorVideo  const canvas = this.$refs.captureCanvas  const ctx = canvas.getContext('2d')  canvas.width = videoEl.videoWidth  canvas.height = videoEl.videoHeight  ctx.drawImage(videoEl, 0, 0)  // canvas.toDataURL() 可上传到服务器留证}

组件销毁时务必关掉摄像头轨道,否则指示灯一直亮着,很吓人也很耗电:

stopCamera() {  if (this.videoStream) {    this.videoStream.getTracks().forEach(track => track.stop())    this.videoStream = null  }}

八、③ 人脸离开检测:face-api.js 的 TinyFaceDetector(最有技术含量)

这是"防代考 / 防离座"的关键。用 face-api.js 的TinyFaceDetector(轻量模型,适合浏览器实时检测)。

第一步:加载模型。模型文件放在 public/models,页面直接从本地 URI 加载,不走网络:

async loadFaceDetectionModels() {  await faceapi.nets.tinyFaceDetector.loadFromUri('/models')  this.faceDetectionModelLoaded = true}

第二步:定时检测。每 3 秒一次,兼顾实时性和性能(检测是异步的,太频繁会卡顿):

startFaceDetection() {  if (this.faceDetectionInterval) clearInterval(this.faceDetectionInterval)  this.faceDetectionInterval = setInterval(() => this.detectFace(), 3000)}async detectFace() {  if (!this.isCameraActive || !this.faceDetectionModelLoaded) return  const videoEl = this.$refs.monitorVideo  if (!videoEl || videoEl.readyState !== 4) return   // 视频未就绪直接跳过  const options = new faceapi.TinyFaceDetectorOptions({    inputSize: 320,       // 输入尺寸,越小越快    scoreThreshold: 0.5   // 置信度阈值  })  const detections = await faceapi.detectAllFaces(videoEl, options)  if (detections.length > 0) {    this.noFaceCount = 0        // 检测到人脸,清零    this.faceStatus = 'normal'  } else {    this.noFaceCount++    this.faceStatus = 'abnormal'    if (this.noFaceCount >= 3)  // 连续 3 次(约 9 秒)没人脸才判异常      this.handleFaceLoss()  }}

关键设计:为什么不是一检测不到就扣分?

因为人脸检测本身会抖动——低头看键盘、光线突变、侧脸,都可能某一帧检测不到人。单次失败就扣分,正常考生也会被误判出局。

所以我加了 noFaceCount 做连续失败计数:只有连续 3 次(约 9 秒)都检测不到,才认定"考生真的离开了"。这个小小的缓冲把误判率降到了可接受范围:

handleFaceLoss() {  this.noFaceCount = 0     // 重置连续计数  this.cheatCount++        // 累计到总违规  this.warningDialog = true  if (this.cheatCount >= this.examInfo.maxCheatCount) {   // 默认阈值 3    this.$message.error('多次离开摄像头范围,系统自动交卷!')    setTimeout(() => this.submitExam(), 2000)  }}

页面上实时显示当前人脸状态(normal / abnormal),让考生知道自己正被"看着",本身就有威慑作用。

九、踩过的坑(这部分最有价值)

  1. getUserMedia
     只在 HTTPS 或 localhost 下可用。 用 IP 直连的 HTTP 页面申请摄像头会被浏览器直接拒绝。本地调试务必用 localhost,线上必须上 HTTPS。
  2. 视频没就绪就检测会报错。 detectFace 里必须先判断 videoEl.readyState === 4(HAVE_ENOUGH_DATA),否则 detectAllFaces 拿到空帧会抛异常;模型加载也要放在 onloadedmetadata 之后。
  3. 人脸检测必须做"连续失败缓冲"。 单次检测失败不能算违规,否则误判率极高。noFaceCount >= 3 是反复调出来的经验值。
  4. TinyFaceDetector 的 inputSize 别开太大。 只能取 128 / 160 / 224 / 256 / 320 / 416 / 512 / 608,越大越准但越慢。实时检测场景 320 是速度和准确率的平衡点。
  5. 切屏的 blur 和 visibilitychange 会重复触发。 必须用 switchWarning / isForceSubmitting 之类标志位去重,否则考生切一次可能被扣两次。
  6. 事件监听、定时器、摄像头轨道都要在 beforeDestroy 清理。 忘关任何一个都会内存泄漏,或出现"考试结束了摄像头灯还亮着"的诡异现象。
  7. 阈值一定要可配置、别写死。 切屏上限走后端 toggleNum、人脸违规上限走 maxCheatCount,教师能按考试严格程度调整;练习模式要主动关闭监控,否则自己练题也被强制交卷。

十、系统运行截图

登录与管理员端

统一登录入口自动识别身份;管理员端负责用户、权限与整体数据。

教师端

题库、组卷、考试发布与成绩分析的一站式工作台。

学生端

我的考试、成绩查询、消息通知与在线答题。

防作弊监控界面见第八节配图。

十一、部署与运行

后端

  1. 创建数据库 OnlineExam 并导入 static/sql/OnlineExam.sql。
  2. 修改 springboot/src/main/resources/application.properties 中的数据库连接信息。
  3. 运行 ExamsystemApplication(默认端口 8080)。

前端

npm install --legacy-peer-deps# 忽略依赖冲突npm run dev# 启动开发服务器(用 localhost 访问,摄像头才可用)

别忘了把 tiny_face_detector 模型文件放到 public/models 目录下,否则人脸检测无法启动。

十二、后续规划

  • 把切屏、人脸、抓拍三路监控统一到同一违规计数与处罚链路,并接入正式答题流程。
  • 抓拍帧 + 违规记录上传后端,形成完整的考试行为留档。
  • 人脸检测升级为人脸比对(首次采集基准照,后续做 1:1 比对),彻底防代考。
  • 引入 JWT + Spring Security 增强接口鉴权。

结语

这套防作弊方案最大的价值在于:纯浏览器端实现,不依赖任何后端算法或第三方监考服务。切屏检测用原生事件、阈值可配置,已跑在正式答题流程里;摄像头抓拍与 face-api.js 人脸离开检测则进一步补齐了"防离座、防代考"的能力。

如果你也在做在线考试 / 在线面试 / 远程监考类项目,希望这篇文章能帮你少走点弯路。

📮获取源码

觉得有用欢迎关注,后续持续分享全栈实战内容。本文仅开放思路学习;完整源码(前端 + 后端 + 数据库脚本)需要获取使用授权,公众号后台私信即可咨询。源码仅限个人学习,禁止二次分发与商用交付。

相关学习资料