前一阵子帮朋友公司搭了一套内部考勤系统,用的就是标题里这套组合: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这条命令的意思是:允许运行本地脚本和已签名的远程脚本,作用范围是当前用户,不会影响系统其他用户。执行后重新打开PowerShell,npm就能正常用了。
2.2 创建Vue3前端工程
我习惯用Vite来创建Vue3项目,比Vue CLI快很多,而且Vite的依赖预构建让开发体验很流畅。
# 创建项目,模板选择vue-ts npm create vite@latest 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_PASSWORD=yourpassword \ -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(name="buffalo_l") model.prepare(ctx_id=0) # 使用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, dtype=np.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 = ref<HTMLVideoElement | 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 class="checkin-page"> <video ref="videoRef" autoplay playsinline muted></video> <el-button type="primary" @click="handleCheck">刷脸打卡</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会告知用途和保存期限,这点不要忽视。
做这套系统的过程里,我最大的体会是:人脸识别本身只占项目的一小部分,考勤系统的复杂度和价值都在业务规则里。如果你也想做类似的项目,建议把精力优先放在数据模型设计和规则判断上,把打卡、迟到、早退、旷工、加班这些维度逐一想清楚,而不是一上来就去训练模型。识别准确率差一点,最多多刷两次脸;业务规则乱了,月底报表就是一场灾难。