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。

技术栈
后端
前端
/api 代理到后端 | ||
三、核心功能模块
系统按角色分为三个工作台,登录时自动识别身份并路由到对应端:
1. 管理员端
用户管理:增删改教师 / 学生账号,分配角色权限( perMissionManage)。考试管理:审核、发布或撤回教师提交的考试。 数据统计:查看参考人数、平均分、成绩分布等整体运行情况。
2. 教师端
题库管理:单选 / 多选 / 判断 / 填空 / 简答五大题型增删改查,支持 Excel 模板批量导入导出。 试卷生成:按题型 / 分值自定义组卷, paper_manage通过paperId + questionType + questionId关联题目。考试发布:创建考试并关联试卷,设置时长、总分、切屏阈值等参数。 成绩评定与分析:客观题自动判分,主观题人工批改;ECharts 展示成绩统计与分数段分布。
3. 学生端
在线考试:规定时间内作答,倒计时结束自动交卷,全程防作弊监控(本文重点)。 练习模式: isPractice下不启用监控,供平时刷题。成绩查询:查看个人成绩与答题详情。 消息通知:接收考试相关通知。
主要 REST 接口
/api/login | ||
/api/exam/{examCode} | ||
/api/paper/{paperId} | ||
/api/score | ||
/api/scores/{examCode} | ||
/api/import | ||
/api/export |
四、三重监控的整体设计
系统实现了三个维度的监控,从"最容易做"到"最有技术含量"依次是:
blurvisibilitychange 原生事件 | |||
getUserMedia + Canvas | |||
face-api.js |
三者共用一套思路:异常行为累计计数 → 弹警告 → 超过阈值自动交卷。阈值不是写死的,而是由教师在创建考试时配置,随考试数据下发到前端。
下面逐维度拆解真实代码。
五、① 切屏检测:原生事件抓"小动作"(已上线)
这是接入正式答题流程的能力。核心是阈值可配置:切屏上限 maxSwitchCount 来自后端考试配置的 toggleNum 字段,练习模式或未配置时不启用监控:
// 从考试数据中读取切屏次数限制getSwitchLimitFromExamData() {if (this.examData.toggleNum !== undefined && this.examData.toggleNum !== null) {this.maxSwitchCount = this.examData.toggleNumif (this.maxSwitchCount > 0) {this.initSwitchMonitor() // 阈值 > 0 才启用}} else {this.maxSwitchCount = 0 // 未配置则关闭监控}}
启用后同时监听三个事件,尽量堵住各种"切出去"的姿势:
initSwitchMonitor() {if (this.isPractice || this.maxSwitchCount <= 0) return // 练习模式不监控this.isMonitoring = truedocument.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) returnthis.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 = streamthis.$refs.monitorVideo.srcObject = streamthis.isCameraActive = truethis.$refs.monitorVideo.onloadedmetadata = async () => {await this.loadFaceDetectionModels() // 画面就绪后再加载模型setInterval(() => this.captureFrame(), 10000) // 每 10 秒抓拍一帧this.startFaceDetection() // 启动人脸检测}}
抓拍走 ,把当前视频帧画下来,需要留证时 toDataURL() 上传即可:
captureFrame() {const videoEl = this.$refs.monitorVideoconst canvas = this.$refs.captureCanvasconst ctx = canvas.getContext('2d')canvas.width = videoEl.videoWidthcanvas.height = videoEl.videoHeightctx.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) returnconst videoEl = this.$refs.monitorVideoif (!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 = trueif (this.cheatCount >= this.examInfo.maxCheatCount) { // 默认阈值 3this.$message.error('多次离开摄像头范围,系统自动交卷!')setTimeout(() => this.submitExam(), 2000)}}
页面上实时显示当前人脸状态(normal / abnormal),让考生知道自己正被"看着",本身就有威慑作用。

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




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





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




防作弊监控界面见第八节配图。
十一、部署与运行
后端
创建数据库 OnlineExam并导入static/sql/OnlineExam.sql。修改 springboot/src/main/resources/application.properties中的数据库连接信息。运行 ExamsystemApplication(默认端口8080)。
前端
npm install --legacy-peer-deps# 忽略依赖冲突npm run dev# 启动开发服务器(用 localhost 访问,摄像头才可用)
别忘了把
tiny_face_detector模型文件放到public/models目录下,否则人脸检测无法启动。
十二、后续规划
把切屏、人脸、抓拍三路监控统一到同一违规计数与处罚链路,并接入正式答题流程。 抓拍帧 + 违规记录上传后端,形成完整的考试行为留档。 人脸检测升级为人脸比对(首次采集基准照,后续做 1:1 比对),彻底防代考。 引入 JWT + Spring Security 增强接口鉴权。
结语
这套防作弊方案最大的价值在于:纯浏览器端实现,不依赖任何后端算法或第三方监考服务。切屏检测用原生事件、阈值可配置,已跑在正式答题流程里;摄像头抓拍与 face-api.js 人脸离开检测则进一步补齐了"防离座、防代考"的能力。
如果你也在做在线考试 / 在线面试 / 远程监考类项目,希望这篇文章能帮你少走点弯路。
📮获取源码
觉得有用欢迎关注,后续持续分享全栈实战内容。本文仅开放思路学习;完整源码(前端 + 后端 + 数据库脚本)需要获取使用授权,公众号后台私信即可咨询。源码仅限个人学习,禁止二次分发与商用交付。