ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Node.js+Vue3+人脸识别考勤系统实战:从架构到部署

Node.js+Vue3+人脸识别考勤系统实战:从架构到部署 前一阵子帮朋友公司搭了一套内部考勤系统用的就是标题里这套组合Node.js Vue3 人脸识别。他公司大概两百多人之前一直用钉钉打卡加Excel月末人工核对迟到早退全凭行政一张嘴月底统计表一出总有人来撕。后来我干脆给他们做了一套独立的人脸识别考勤系统摄像头对准前台员工走过去刷脸打卡数据实时进库月底报表自动生成。整个过程从环境初始化到上线跑了大概三周中间踩了不少坑。这篇文章我会把整套系统的设计思路、关键技术选型、核心代码实现和实际测试中遇到的问题都写出来。适合正在做类似项目的开发者参考也适合企业里想自建考勤系统的技术负责人看。我会尽量讲清楚每一步“为什么这么做”而不是只丢代码。1. 这套技术组合到底在解决什么问题1.1 传统考勤方式的痛点我在做这个项目之前先花了两天时间蹲在他们公司前台观察打卡情况。指纹机的问题是手干手湿都影响识别率很多人按三次都过不去排队堵在前台门禁卡的问题就更明显了——代刷、丢卡、忘带卡每周都能在行政那边听到好几次手机定位打卡最离谱有人直接装虚拟定位插件人在被窝里打卡地点已经在公司了。人脸识别考勤最大的价值是“人证合一”。摄像头拍到你的脸系统确认你就是你这个行为无法轻易交给别人代做。虽然严格意义上也能拿照片来骗但比起指纹膜、代刷卡作案成本高了不少。而且人脸识别的硬件成本低一个USB摄像头加一台普通电脑就能跑起来对中小企业非常友好。1.2 为什么不是Java、Python而是Node.js Vue3很多人一听到企业系统下意识就选Java Spring Boot。实际上对考勤这种轻中量级的内部管理系统Node.js完全够用而且开发效率高得多。考勤系统的核心接口无非是打卡记录、员工管理、报表查询这些都是典型的IO密集型操作Node.js的异步模型处理起来很顺。再加上Node.js生态里操作MySQL、Redis、调用HTTP服务的库非常成熟一个人两天就能把后端骨架拉起来。前端选Vue3则是因为项目有大量实时交互场景摄像头画面预览、打卡结果即时反馈、考勤报表可视化。Vue3的组合式API在处理这类“有状态”页面时非常舒服代码复用也方便。如果用传统JQuery那套硬写页面稍微复杂点就乱成一锅粥了。我这里做一个简单选型对比给还在纠结的朋友参考方案开发效率实时交互部署成本适合场景Node.js Vue3高强低中小型内部系统Java Spring Boot Vue3中强中高大型企业、复杂权限系统Python Flask Jinja2中弱低快速原型、内部小工具1.3 系统整体架构这套系统我拆成了四个部分前端页面Vue3 Vite员工打卡页、管理后台、考勤统计报表。后端服务Node.js Express业务逻辑、员工管理、考勤记录、报表查询。识别服务Python FastAPI insightface人脸特征提取、相似度比对独立部署通过HTTP接口对外提供服务。数据库MySQL员工信息、考勤记录、规则配置。人脸识别模型用Python跑Node.js只做业务调度这个分工是我权衡之后的选择。原因后面单独讲这里先记住不要试图在Node.js里直接跑深度学习模型生态和部署都不成熟拆成微服务反而省心。2. 环境准备从Node.js安装到前后端工程跑起来2.1 Windows下Node.js版本管理与npm执行策略很多人在Windows上开发Node.js项目第一个坑就是环境。Node.js官方安装包虽然能直接装但版本管理不灵活项目多了以后不同版本切换很痛苦。我建议用nvm-windows来管理Node版本实际操作非常简单。# 安装nvm-windows后在PowerShell或CMD里执行 nvm install 20.11.0 nvm use 20.11.0 # 验证版本 node -v npm -v这里我给个小建议Node版本选LTS版本也就是20.x或18.x别追最新大版本。考勤系统要的是稳定不是新特性。接着你会遇到一个非常经典的报错npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本这个问题的根因是PowerShell的执行策略默认是Restricted不允许执行任何脚本文件而npm本质上就是通过npm.ps1这个脚本启动的。解决办法是放开当前用户的执行策略Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser这条命令的意思是允许运行本地脚本和已签名的远程脚本作用范围是当前用户不会影响系统其他用户。执行后重新打开PowerShellnpm就能正常用了。2.2 创建Vue3前端工程我习惯用Vite来创建Vue3项目比Vue CLI快很多而且Vite的依赖预构建让开发体验很流畅。# 创建项目模板选择vue-ts npm create vitelatest attendance-web -- --template vue-ts cd attendance-web npm install npm run dev项目跑起来后我会先做两件事第一安装axios和element-plus一个用来发请求一个用来做管理后台的UI组件库第二搭好目录结构把views、components、api、router这些文件夹建好。Vue3的单文件组件结构清晰页面复杂度上来以后组件拆分基本决定了你后期维护的心情。2.3 初始化Express后端与数据库后端我直接用Express不引入NestJS这种重框架。考勤系统的接口没有复杂到需要依赖注入和装饰器那套东西Express的路由中间件模型已经完全够用。mkdir attendance-server cd attendance-server npm init -y npm install express cors multer mysql2 axios npm install -D nodemon数据库我用MySQL 8开发环境用Docker跑一个实例最省事docker run --name attendance-mysql \ -e MYSQL_ROOT_PASSWORDyourpassword \ -p 3306:3306 \ -d mysql:8ORM我选了Prisma而不是Sequelize。原因是Prisma的Schema声明式定义非常直观迁移工具也很好用TypeScript类型自动生成能避免一堆运行时字段拼写错误。npm install prisma -D npx prisma init --datasource-provider mysql3. 考勤数据模型与业务规则设计3.1 员工、打卡记录、规则三张核心表很多人做考勤系统上来就建一张打卡记录表员工号加时间完事。这种设计只能应付最简单的场景一旦涉及迟到早退判断、加班统计、跨天班次后患无穷。我设计的核心模型是这样的model Employee { id Int id default(autoincrement()) employeeNo String unique name String department String faceImages Json // 注册时采集的多张照片路径 faceFeature Json? // 人脸特征向量 createdAt DateTime default(now()) records AttendanceRecord[] } model AttendanceRecord { id Int id default(autoincrement()) employeeId Int employee Employee relation(fields: [employeeId], references: [id]) workDate DateTime // 归属工作日跨天班次时这个字段是关键 checkTime DateTime // 实际打卡时间 type String // CHECK_IN 或 CHECK_OUT similarity Float // 人脸比对相似度 photoUrl String? // 打卡照片用于事后核实 createdAt DateTime default(now()) index([employeeId, workDate]) } model AttendanceRule { id Int id default(autoincrement()) name String // 班次名称如标准白班 checkInStart DateTime // 可打上班卡的最早时间 checkInLateLimit DateTime // 最晚不迟到时间 checkInEnd DateTime // 最晚上班卡时间超过算旷工 checkOutStart DateTime // 可打下班卡的最早时间 checkOutEnd DateTime // 最晚下班卡时间超过算加班 workMinutes Int // 每日应工作分钟数 isNightShift Boolean // 是否跨天班次 }faceImages和faceFeature存JSON字段是我想特别说明的设计。faceImages存的是注册时拍的几张照片路径方便出了问题人工核对faceFeature是Python服务提取出的128维特征向量打卡比对的依据。这里有个小细节照片路径要存相对路径不要存绝对路径否则换服务器部署时全乱了。3.2 迟到、早退、旷工与跨天班次的判定逻辑考勤系统真正的灵魂是业务规则。我见过太多开发者在“打上卡”上面花了很多精力结果月底统计出来的数据被行政吐槽“没法用”。核心判定逻辑大概是这样的正常上班卡在checkInLateLimit之前下班卡在checkOutStart之后。迟到上班卡在checkInLateLimit之后、checkInEnd之前。早退下班卡在checkOutStart之前。旷工没有打卡记录或者打卡时间超出checkInEnd或checkOutEnd窗口。加班下班卡时间减去标准下班时间再减掉晚餐休息时间。看着简单但实际编写时有一堆边界情况。比如有人上午迟到两小时下午正常下班这一天怎么算我的处理方式是输出一个考勤状态字段由规则引擎统一计算而不是在页面层东拼西凑。跨天班次是另一个大坑。我们公司有客服夜班晚上21点到次日凌晨5点。如果按自然日分组凌晨3点的打卡会被记到新的一天导致前一天显示缺卡、后一天多出一条无效记录。解决方法是引入workDate字段在打卡写入时根据班次计算归属工作日function calculateWorkDate(checkTime: Date, isNightShift: boolean): Date { const date new Date(checkTime); if (isNightShift date.getHours() 6) { // 夜班凌晨的打卡归属到前一天 date.setDate(date.getDate() - 1); } return date; }3.3 上班卡/下班卡的提取规则员工一天可能打很多次卡比如中午出去吃饭回来又打了一次。统计上班卡和下班卡时如果直接取第一条和最后一条很容易出错。我的策略是上班卡取窗口内最早的一条下班卡取窗口内最晚的一条。窗口由班次的checkInStart、checkOutEnd限定。这样就算中午出门刷了脸也不会影响早晚打卡的正常判断。这个逻辑在月底对账时特别有用行政要的是一眼能看懂的数据而不是一堆需要人工筛选的原始记录。4. 人脸识别模块的选型与封装4.1 自建识别服务与商用在线API怎么选人脸识别是整个系统的技术亮点也是最容易走弯路的地方。我当时的候选方案有三个方案精度离线能力授权费用落地难度商用在线API百度/旷视/虹软在线高无按量付费或年费低虹软ArcFace离线SDK高有免费需申请低自建Python推理服务insightface高有免费中高在线API的问题有两个一是数据隐私公司员工的人脸照片要传到第三方服务器很多公司HR一听就摇头二是依赖网络一旦公司出口带宽抖动打卡就会卡住。离线SDK是不错的选择虹软的ArcFace免费授权商用精度也不错。我最后选了insightface自建服务原因是我不想被某个SDK的授权限制锁死而且Python生态里的模型可以随时换哪天想升级成带活体检测的模型改一行代码的事。4.2 Python识别微服务的接口约定我把人脸识别做成了独立微服务用FastAPI编写部署在127.0.0.1的9010端口只对内网开放。Node.js后端通过HTTP调用它。接口设计很简单就两个# app.py from fastapi import FastAPI from pydantic import BaseModel import insightface import numpy as np import base64 import cv2 app FastAPI() model insightface.app.FaceAnalysis(namebuffalo_l) model.prepare(ctx_id0) # 使用GPU如果没有GPU改-1用CPU class FaceRequest(BaseModel): image_base64: str # 员工注册提取人脸特征 app.post(/extract) async def extract(req: FaceRequest): img_data base64.b64decode(req.image_base64) img_array np.frombuffer(img_data, dtypenp.uint8) img cv2.imdecode(img_array, cv2.IMREAD_COLOR) faces model.get(img) if len(faces) 0: return {code: 1, msg: no face detected} feature faces[0].normed_embedding.tolist() return {code: 0, feature: feature} # 打卡识别提取特征 和库内特征比对 app.post(/recognize) async def recognize(req: FaceRequest): # 提取当前人脸特征 # 遍历内存中的员工特征库计算余弦相似度 # 返回相似度最高的员工号和相似度分数 ...这里有个非常重要的设计决定员工特征库直接缓存在Python进程内存里而不是每次比对都去MySQL查。1000个员工每个特征128维浮点数总共也就几百KB内存每次全库余弦相似度计算只要几十毫秒。如果走HTTP去Node.js拿数据再算反而慢得多还增加了系统耦合。4.3 前端人脸采集策略前端页面只负责两件事展示摄像头画面、截取一帧图片传给后端。我不建议在前端直接跑人脸检测模型比如face-api.js虽然能在浏览器里画出人脸框但模型加载慢、推理速度受设备影响大。我做了一个很轻量的交互员工站在摄像头前看到视频画面里的自己点击“打卡”按钮系统截取当前帧传给后端识别。截帧的核心代码非常简单// capture.ts const video document.getElementById(camera) as HTMLVideoElement; const canvas document.createElement(canvas); canvas.width 480; canvas.height 480; const ctx canvas.getContext(2d)!; ctx.drawImage(video, 0, 0, 480, 480); const base64Image canvas.toDataURL(image/jpeg, 0.9).split(,)[1];注意我截的是方形图片480x480。人脸检测模型通常对正方形输入处理最稳定后续在Python端裁剪时也方便。至于光线问题我加了一个提示摄像头画面上方显示绿色取景框员工把脸放在框内再点打卡识别成功率会高很多。5. 核心链路代码实现从摄像头到考勤报表5.1 Vue3摄像头调用与权限处理Vue3的Composition API很适合封装一个独立的useCamera组合式函数// useCamera.ts import { ref, onUnmounted } from vue; export function useCamera() { const videoRef refHTMLVideoElement | null(null); let stream: MediaStream | null null; async function startCamera() { stream await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 }, audio: false, }); if (videoRef.value) { videoRef.value.srcObject stream; } } function capture(): string | null { // 截帧并返回base64字符串逻辑见上一节 } function stopCamera() { stream?.getTracks().forEach(track track.stop()); } onUnmounted(stopCamera); return { videoRef, startCamera, capture, stopCamera }; }在打卡页面里这样用template div classcheckin-page video refvideoRef autoplay playsinline muted/video el-button typeprimary clickhandleCheck刷脸打卡/el-button /div /template点击打卡后调用后端接口传入图片和打卡类型。这里有一个实际场景的细节画面里的员工可能没意识到摄像头已经开始工作了所以我做了两段式交互首次加载页面时不自动开启摄像头等员工点“开始打卡”才弹出授权并开启避免员工一进页面就看到自己在屏幕上发呆。5.2 Node.js端打卡接口与识别调度后端先封装一个调用Python服务的客户端// recognizer.ts import axios from axios; const FACE_SERVICE_URL http://127.0.0.1:9010; export async function recognizeFace(imageBase64: string) { const res await axios.post(${FACE_SERVICE_URL}/recognize, { image_base64: imageBase64, }); return res.data; }打卡接口// routes/attendance.ts app.post(/api/attendance/check, async (req, res) { const { imageBase64, type } req.body; // 1. 调用人脸识别服务 const result await recognizeFace(imageBase64); if (result.code ! 0) { return res.json({ code: 1, message: 未检测到人脸请重试 }); } // 2. 查询员工校验是否注册 const employee await prisma.employee.findUnique({ where: { employeeNo: result.employeeNo }, }); if (!employee) { return res.json({ code: 1, message: 员工不存在 }); } // 3. 计算workDate处理跨天班次 const now new Date(); const rule await getEmployeeRule(employee.id); const workDate calculateWorkDate(now, rule.isNightShift); // 4. 写入考勤记录 const record await prisma.attendanceRecord.create({ data: { employeeId: employee.id, workDate, checkTime: now, type, similarity: result.similarity, photoUrl: /uploads/${Date.now()}.jpg, }, }); // 5. 返回打卡结果 const status evaluateCheckStatus(rule, now, type); res.json({ code: 0, data: { employee, status, checkTime: now } }); });这里我特别想提醒一个细节识别成功后要先确认员工信息再写考勤记录顺序不能反。我见过有人先写记录再查员工结果员工已经离职但没删注册信息系统给离职员工记了一笔考勤后面排查了半天才发现是数据一致性问题。5.3 考勤统计报表的查询与展示月底考勤报表是行政最关心的部分。报表逻辑很简单按月查询员工的打卡记录按workDate分组再套用考勤规则逐日判定状态。const records await prisma.attendanceRecord.findMany({ where: { employeeId, workDate: { gte: monthStart, lte: monthEnd, }, }, orderBy: { checkTime: asc }, });拿到记录后在内存里分组、取上班卡和下班卡、逐日判定状态。这个逻辑用SQL直接写会很痛苦但在Node.js里用Map分组后写起来很直观1000人一个月的数据量内存计算完全扛得住。报表前端我用ECharts画两种图一个是部门出勤率柱状图一个是个人每日打卡状态的热力图。ECharts的配置项比较多我最常用的一个示例是这样的const option { xAxis: { type: category, data: days }, yAxis: { type: value }, series: [{ type: bar, data: attendanceRate, itemStyle: { color: #409EFF }, }], };报表页面不用做太多花哨的交互行政人员需要的是“一眼看出谁迟到几次、谁缺卡几次”所以表格为主、图表为辅别本末倒置。6. 开发实测中踩过的坑和排查过程6.1 摄像头一直黑屏安全上下文的问题第一次在真机测试的时候就翻车了。同事的手机连上内网地址打开打卡页面摄像头区域一片黑点了授权也没反应。我打开浏览器控制台发现navigator.mediaDevices是undefined心里大概有数了。查了一下MDN文档原来是浏览器的安全策略在作怪getUserMedia要求页面必须在安全上下文中运行。所谓安全上下文包括HTTPS协议、localhost本地调试但不包括通过IP访问的HTTP页面。也就是说你用localhost访问没问题换到局域网IP的手机上摄像头就直接不可用。解决办法是给部署环境配上HTTPS。我用Caddy自动申请证书配置非常简单attendance.example.com { reverse_proxy /api/* 127.0.0.1:3000 root * /var/www/attendance-web file_server }如果只是开发环境想临时测一下手机可以用Chrome的flag强制关闭安全认证但只建议本地调试用生产环境一定要走HTTPS。6.2 夜班考勤全部错位跨天归属的处理这个问题是系统上线第三天被行政发现的。Excel导出的报表里夜班客服的打卡记录全部对不上号前一天显示缺卡后一天莫名其妙多了一条凌晨的卡。我一开始怀疑是打卡接口写入时把时间搞错了查了数据库原始记录发现打卡时间完全正确就是3点46分、4点12分这种。再一看workDate果然全部算到了打卡当天但夜班人的“当天”应该是前一天。这个排查过程大概花了一个小时。确认基础记录没毛病后问题定位到workDate的计算逻辑。我当时的代码没有考虑班次类型简单粗暴地用了new Date()当自然日。修复方案就是前面写的calculateWorkDate函数。这种问题光靠测试很难发现因为测试时都用标准白班时间谁会想到凌晨打卡的人呢所以设计数据模型时一定要先问清楚企业有没有跨天班次。6.3 照片识别超时接口被高频请求打爆系统刚上线那天下午打卡接口突然开始超时后台看Node.js进程CPU占用飙到300%。我第一反应是识别服务出问题了但看Python那边CPU并不高反而是Node.js这边请求堆积。查了Nginx访问日志发现打卡接口的请求量异常大每分钟六七百次而实际上前台每分钟最多也就三十个人打卡。看请求内容才发现是前端页面在做定时截帧上传——我把“自动检测到人脸再打卡”做成了一帧一帧上传后端判断这导致大量无效请求打到了识别服务上。修复分两层。前端加了一个忙碌锁和最小间隔let isChecking false; async function handleCheck() { if (isChecking) return; isChecking true; try { await checkIn(); } finally { setTimeout(() { isChecking false; }, 3000); } }后端对同一个员工IP加了一次请求限流一分钟内同一员工只能打卡10次。这样即使前端出了bug也不会把服务打死。这个坑给我的教训是摄像头相关的接口一定要限流因为前端循环采帧和用户重复点击是常态不是异常。7. 部署经验与后续优化方向7.1 一个可落地的部署架构整套系统部署在一台4核8G的服务器上足够支撑几百人的考勤。我用Docker Compose编排了所有组件version: 3.8 services: mysql: image: mysql:8 environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} volumes: - mysql-data:/var/lib/mysql ports: - 3306:3306 face-service: build: ./face-service ports: - 9010:9010 volumes: - face-models:/app/models deploy: resources: reservations: devices: - capabilities: [gpu] # 有GPU就打开没有就删掉这段 server: build: ./attendance-server ports: - 3000:3000 environment: DATABASE_URL: mysql://root:${MYSQL_ROOT_PASSWORD}mysql:3306/attendance FACE_SERVICE_URL: http://face-service:9010 nginx: image: nginx:alpine ports: - 80:80 - 443:443 volumes: - ./nginx.conf:/etc/nginx/nginx.conf - ./attendance-web/dist:/usr/share/nginx/html部署完成后访问Nginx的80端口就能看到前端页面静态资源和API都走同一个域名避免了跨域问题。7.2 性能与体验的优化清单系统稳定运行后我又做了几轮优化按收益从高到低排识别结果缓存到Redis同一员工五分钟内的打卡记录不做重复写入防止前端重复提交。照片自动清理策略打卡照片保留90天超过自动删除既省磁盘又符合数据最小化原则。人脸注册照片补录给管理后台加了一个批量导入功能支持Excel上传员工信息后逐个人脸打卡注册。WebSocket实时推送考勤大屏页面用WebSocket接收打卡事件员工刷脸成功后大屏上立刻显示姓名和部门这个小功能很受老板喜欢。另外人脸数据属于敏感个人信息在实际部署前一定要获得员工的明确授权。我们在员工入职环节多了一步人脸录入确认HR会告知用途和保存期限这点不要忽视。做这套系统的过程里我最大的体会是人脸识别本身只占项目的一小部分考勤系统的复杂度和价值都在业务规则里。如果你也想做类似的项目建议把精力优先放在数据模型设计和规则判断上把打卡、迟到、早退、旷工、加班这些维度逐一想清楚而不是一上来就去训练模型。识别准确率差一点最多多刷两次脸业务规则乱了月底报表就是一场灾难。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进