☰
基于ThinkPHP与Laravel的人脸识别考勤系统设计与实现
2026/9/26 13:13:57 网站建设 项目流程

Response## 1. 项目定位:考勤系统为什么必须做人脸识别,以及我对技术栈的解读

先聊一个最容易被忽略的问题:考勤系统一旦上人脸识别,整个产品形态完全不一样了。传统打卡机主要靠指纹、IC卡、密码,这几种方式有共同的毛病——指纹磨损了识别率断崖式下降,卡容易丢,密码可以被代打。人脸识别算是目前"体验和安全性平衡得最好"的考勤方案,不需要接触设备,员工走到摄像头前看一秒就能完成打卡,而且很难被代打。

我这次做的"基于ThinkPHP-Laravel的人脸识别考勤管理系统(Vue前端)",本质是一次集成型项目:一台人脸识别门禁终端负责抓拍和特征比对,服务端负责考勤规则计算和业务数据管理,Vue负责呈现给管理员和员工。这里先解释一个看起来诡异的点——标题里为什么同时出现ThinkPHP和Laravel两个框架。

在实际项目里,纯从零开始选型很少会把两个框架硬塞进一个仓库,但集成类项目很常见的情况是:老管理后台跑在ThinkPHP上,里面沉淀了部门、员工、班次等基础数据,不太可能一夜之间重写;新的开放API和移动端接口又想用更现代的Laravel来实现。权衡之后我把系统拆成了三个模块:

  • 设备接入层:用ThinkPHP搭了一个轻量网关,负责接收门禁终端上传的打卡事件、心跳检测和设备状态上报。
  • 业务API层:用Laravel提供REST API,处理员工管理、考勤规则、报表统计,给Vue前端调用。
  • 前端展示层:Vue 3 + Element Plus做成SPA,管理人员在浏览器里实时查看考勤情况。

这样分的好处很明显,ThinkPHP网关和硬件打交道很稳定,Laravel API侧则可以利用中间件、队列、Eloquent这些成熟机制快速开发业务。两边通过内部HTTP接口通信,不共享会话状态,以后拆成独立服务也容易得多。

什么人适合参考这套方案?如果你在公司里接到类似"把旧考勤系统升级成人脸识别"、"给现有门禁设备加上考勤管理平台"这种需求,又恰好团队技术栈是PHP为主,这篇文章应该能帮你少走不少弯路。我会把数据库表设计、打卡链路、迟到早退计算、Vue前端实现、以及我在部署过程中踩过的框架版本坑全部展开来讲。

2. 后端怎么拆:ThinkPHP与Laravel的分工、数据表与人脸比对流程

2.1 框架分工:谁管设备接入,谁管业务API

可能有人会问,设备终端直接调Laravel不就行了,为什么还要在中间放一层ThinkPHP网关?

我实际测试后的结论是:人脸识别门禁终端的上报协议很不"标准"。有的终端走HTTP JSON,有的走私有TCP协议,有的文档里写的字段跟实际推送的根本对不上。如果把这种不稳定的对接逻辑直接塞进Laravel业务代码里,一旦终端厂商升级协议,就得重新发布整个API服务。

用ThinkPHP做一层独立的设备网关,终端只需要认识这一个地址,网关负责做协议转换和字段清洗,转发给Laravel的有效载荷永远是统一格式:

{ "device_id": "A1001", "event": "PUNCH", "employee_no": "E201", "punch_time": "2025-01-01 09:00:01", "face_score": 96.8 }

这样做还有个隐藏收益:网关可以缓冲掉设备网络抖动带来的重复上报。终端的TCP重传机制经常导致同一条打卡记录到达两次,网关里做一层幂等去重,后面业务侧就轻松很多。

模块框架职责典型接口
设备网关ThinkPHP终端接入、协议转换、心跳检测、幂等去重/device/punch/device/heartbeat
业务APILaravel员工、班次、考勤记录、报表、登录鉴权/api/staff/api/attendance
前端Vue 3管理后台、实时看板、打卡记录查询浏览器SPA

2.2 数据表设计:把考勤表设计成"可追溯"而不是"可展示"

考勤系统最容易踩的坑,是数据库表设计得只满足页面展示,后期想回溯问题就抓瞎。比如员工某天为什么迟到,管理员想看到底是设备上报延迟还是人为修改过,就得靠日志表。

我的核心表设计如下,重点都在"可追溯"上:

员工表(staff)

字段类型说明
idint主键
employee_novarchar工号,终端识别的Key
namevarchar姓名
dept_idint部门
shift_idint班次
face_featuretext人脸特征向量(JSON或Base64)
statustinyint1正常 0停用

考勤记录表(attendance)

字段类型说明
idint主键
employee_novarchar工号
punch_timedatetime打卡时间
biz_datedate业务日期,按这个字段做月报
statevarcharnormal/late/early/absent
device_idvarchar来源设备
face_scoredecimal人脸相似度
sourcetinyint0终端上报 1手动补登 2API
created_atdatetime记录创建时间

这里的biz_date特别重要,很多新人直接用punch_time分组,结果夜班员工在凌晨打卡的数据被算到前一天,月报怎么都对不上。业务日期必须由排班逻辑决定,而不是打卡时间。

班次表(shift)

id, name, start_time, end_time, late_grace_minutes, early_leave_grace_minutes

late_grace_minutes是"宽限分钟数",比如班次9点上班,允许9:05前打卡不算迟到。宽限逻辑绝不能写死在代码里,不同公司要求不同,做成字段最灵活。

2.3 人脸比对主流程:本地1:N识别还是云端API

人脸识别考勤里,最核心的问题是:摄像头抓拍到一张脸之后,怎么知道这个人是谁?

现在主流有两种实现路线。一种是设备端本地识别,98%的脸部比对是在门禁终端里完成的,终端里面内置人脸底库,识别成功后才把员工工号推送给服务端;另外一种是把摄像头抓拍的图片传回云端人脸识别API,由云端做1:N搜索,返回最相似的人。

我强烈建议优先选设备端本地识别。原因很现实:考勤打卡是高频场景,如果每次打卡都要传图片到服务器,再等API返回,延迟轻松超过1秒,而且服务器带宽压力很大。门禁终端自带的识别能力通常在0.3秒内就完成,终端直接推送工号,服务端只需要做业务处理。

服务端的人脸识别能力也要保留一份,主要是管理员的"人脸注册"场景。新员工入职时拍一张照片,通过Laravel调用人脸识别API提取特征向量,写入face_feature字段,再推送到每个门禁终端。这个过程不需要实时性,走异步队列就可以了。

3. 核心打卡链路:终端上报、幂等去重和迟到早退规则

3.1 打卡数据流是怎么走通的

完整链路我梳理成下面几条:

  1. 员工经过门禁终端,终端本地识别成功。
  2. 终端封装设备ID、员工工号、打卡时间、相似度分数,通过HTTP发到ThinkPHP网关。
  3. ThinkPHP网关校验终端签名,查询Redis里是否已有相同device_id + employee_no + punch_time的键,如果有就丢弃,没有就写入Redis并转发到Laravel API。
  4. Laravel收到数据后,写入考勤记录表,触发班次规则计算,更新当日考勤状态。
  5. Vue前端通过WebSocket或定时轮询拉取最新记录,刷新实时看板。

这里最容易被忽略的是第3步。门禁终端在弱网环境下会重发数据,网关不做幂等的话,一条打卡会生成好几条考勤记录,月底汇总就出大问题。我用的简单做法是:

$key = 'punch:dedup:' . $deviceId . ':' . $employeeNo . ':' . $punchTime; $isDuplicate = Redis::setnx($key, 1, ['EX' => 1800]); if (!$isDuplicate) { // 重复上报,直接丢弃 return response()->json(['code' => 0, 'msg' => 'duplicate']); }

用setnx加半小时过期,不浪费存储,足够覆盖终端重试窗口。

3.2 迟到、早退和缺卡的计算逻辑

考勤状态计算不能只看"打了卡没"。一个班次通常要判断两次打卡:上班卡和下班卡。我采用的状态模型如下:

状态判定条件
normal按时打卡
late打卡时间晚于班次开始时间+宽限
early非外勤原因,下班打卡早于班次结束时间-宽限
absent全天无任何打卡记录

Laravel侧判定逻辑示例:

// 假设上班时间为 09:00,宽限5分钟 $shiftStart = strtotime('09:00'); $grace = 5 * 60; if ($punchTimestamp > $shiftStart + $grace) { $state = 'late'; }

这里有个实战细节:跨天班次的start_time处理。比如夜班22:00到次日06:00,如果直接比较时间字符串就会算错。我统一做法是手动把班次时间转成Carbon实例后加一天处理,再和punch_time比较。

缺卡状态不是实时判定的,而是在下班时间过后由定时任务统一扫描。每天凌晨一点跑一次脚本,把"当天应当有打卡记录但实际没有"的员工生成缺卡记录,并推送给管理员。Laravel的Task Scheduler一行计划任务就够了。

3.3 补卡和异常处理:别把小问题搞成大流程

实际使用中,员工总会遇到设备没识别、手机忘带、摄像头被人挡住等情况,这时候需要补卡。补卡流程我做了权限控制,普通员工可以提交补卡申请,部门主管审批后才能生效。补卡本质上是一条source=1(手动补登)的考勤记录,但状态由管理员手动指定为normal,同时保留原始申请信息,方便日后审计。

这里要说明一个我自己栽过跟头的点:补卡记录如果直接改原记录的状态,审计时就完全不知道这条记录是被补来的还是设备正常上报的。所以我才在表里加了source和created_at两个字段,排查纠纷时直接看source=1的记录,一清二楚。

4. Vue前端实现要点:从登录鉴权到实时打卡看板

4.1 工程搭建与代码结构

Vue前端我选的是Vue 3 + Vite + Element Plus + Pinia + Axios,这套组合现在生态最成熟,社区里的踩坑案例也多,遇到问题基本都能搜到答案。目录结构:

src/ ├── api/ # 接口封装 ├── assets/ # 静态资源 ├── components/ # 公共组件 ├── layout/ # 后台布局 ├── router/ # 路由配置 ├── stores/ # Pinia状态管理 ├── views/ │ ├── dashboard/ # 实时看板 │ ├── staff/ # 员工管理 │ ├── attendance/ # 考勤记录与报表 │ └── login/ # 登录页 └── main.ts

启动项目照例是:

npm create vite@latest attendance-web -- --template vue cd attendance-web npm install npm run dev

一个提醒:Vite默认端口5173,Laravel API跑在8000,前后端联调时一定要配代理,不然跨域问题能卡掉半天。

// vite.config.ts export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true } } } })

4.2 登录鉴权:Token存哪、路由守卫怎么写

考勤系统涉及员工隐私数据,登录鉴权不能只做前端假跳转。我用的是简化的JWT方案:用户登录后Laravel签发一个Token,前端存到localStorage,每次请求通过Axios拦截器带上。

// axios拦截器 instance.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; });

路由守卫决定用户能访问哪些页面。管理员有员工管理、考勤报表权限,普通员工只能看到自己的打卡状态和补卡申请入口。我用了Vue Router的beforeEach。

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); const role = localStorage.getItem('role'); if (!token && to.path !== '/login') { next('/login'); } else if (to.meta.roles && !to.meta.roles.includes(role)) { next('/403'); } else { next(); } });

不要把角色鉴权只放在前端。前端守卫只是体验优化,真正的权限校验必须由Laravel中间件做,接口层面不加权限控制,等于把后门敞开。

4.3 实时预览和"实时打卡"展示

实时看板是这套系统的门面。门禁终端通常支持RTSP视频流,但浏览器不能直接播放RTSP,需要转封装成HLS(m3u8)格式后,由前端用hls.js播放。相关热搜词里"vue播放m3u8"讨论很多,实际实现并不复杂:

import Hls from 'hls.js'; function playHls(videoEl, url) { if (Hls.isSupported()) { const hls = new Hls({ liveDurationInfinity: true }); hls.loadSource(url); hls.attachMedia(videoEl); } else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) { videoEl.src = url; // iOS Safari 原生支持 } }

打卡列表部分我没用WebSocket,而是做了3秒轮询。因为考勤系统对实时性要求在秒级就够,轮询实现简单,后端好维护。只有大屏展示的设备状态才用了WebSocket,实时反映终端离线还是在线。

4.4 考勤报表:ECharts做曲线和排行

月报页面按员工展示迟到、早退、缺卡、正常天数,并用ECharts画柱状图和折线图。接口设计是:

GET /api/attendance/monthly?month=2025-01&dept_id=3

返回聚合数组后,前端按日期分组渲染。这里踩过一个教训:ECharts在Element Plus的el-tabs里切换Tab时,图表宽度经常变成0。解决办法是切换后调用chart.resize(),或者用v-if延迟渲染图表容器,等DOM完全显示后再初始化。

5. 安全与坑点复盘:框架漏洞、Session跨域与Electron打包

5.1 ThinkPHP老版本安全问题,CVE-2024-29291必须重视

2024年披露的CVE-2024-29291涉及ThinkPHP的多语言功能,低版本存在被利用的风险,影响范围很广。我先把结论给出来:

  • 如果还在用ThinkPHP 6.1.5以下,尽快升级到6.1.5或更高版本;
  • 生产环境务必关闭多语言功能,或者限制lang参数来源;
  • 不要用老框架直接暴露公网设备网关端口,前面至少要套一层Nginx ACL或防火墙规则。

这类漏洞的特点是修复版本已经发布,但很多老项目根本没人跟进升级,成了"长期在线的定时炸弹"。考勤系统连着员工的敏感人脸数据,一旦被打穿后果很严重。

我也同步检查了Laravel侧,Laravel官方安全公告修复得很快,关键是别用composer update偷懒,要手动确认项目拉到的是安全版本,并且把.env里的APP_DEBUG设为false,APP_KEY必须重新生成。

5.2 前后端分离下的Session、Cookie和跨域问题

基于Vue的前后端分离项目,Session处理是个高频坑。最初我用PHP原生的Session存登录态,Laravel API和Vue前端不在同一个域名下,Cookie很难维护。后来切到Token方案,一开始全部用Bearer Token,但很快发现一个问题:管理员在管理后台长时间挂机,Token过期后突然提交,直接变空白页。

我的方案是Laravel做双Token机制:Access Token短时效(2小时),Refresh Token长时效(7天),前端Axios拦截器捕获401时,自动用Refresh Token换新的Access Token并重放原请求。整个流程用户无感,自定义指令也顺便做了:

// 自定义按钮权限指令 v-permission app.directive('permission', { mounted(el, binding) { const perms = store.perms; if (!perms.includes(binding.value)) { el.parentNode?.removeChild(el); } } });

关于跨域,Laravel端我推荐写中间件统一设置CORS头,但生产环境不要滥用*,指定前端域名并设置Access-Control-Allow-Credentials: true更安全。

5.3 Electron打包成桌面考勤助手

这套系统除了浏览器版,后来还被打包成Windows桌面端,方便前台管理员开机自动运行。用的工具是electron-builder,打包流程:

npm install electron electron-builder -D npm run build npx electron-builder --win --x64

Electron打包Vue项目有几个注意点:

  1. Vite的base要设成相对路径./,否则打包后加载不到静态资源;
  2. Electron的主进程和渲染进程之间通信要用IPC框架,不能用浏览器里那套window全局变量;
  3. 人脸识别终端如果走RTSP取流,Electron渲染进程的CSP策略可能阻止播放,需要在主进程里允许对应网络域。

我在这个阶段还遇到了"打包出来的exe体积巨大"的问题,Vue + Element Plus打出来接近90MB。后面通过按需引入Element Plus组件、关闭sourcemap、启用gzip压缩,压到了40MB左右。虽然不算极致,但已经可以接受。

至于外壳加固,我是用vite-plugin-electron把主进程和渲染进程打包流程统一管理的,开发模式下一条命令同时起Vite和Electron,调试体验比分开跑顺畅很多。

5.4 一个最容易被忽略的坑:时钟同步

最后分享一个特别不起眼但影响巨大的问题——人脸识别终端和服务器的时钟不同步。设备录制打卡时间用的是自身系统时间,如果终端时间慢了3分钟,员工明明9:00到公司,设备记成8:57,看起来正常;哪天设备时间快了5分钟,9:02打卡就会被判定迟到,线下纠纷能吵到人事部门。

我最后的解决方案是写了一个每分钟跑一次的巡检脚本,从Laravel侧获取服务器时间,批量校准所有在线终端的时钟。这个脚本排查问题的频率,比框架漏洞还要高,强烈建议你们在上线前就做好终端时间同步机制。

最后的实操体会

这套考勤系统从需求梳理到上线,总共花了大概三周,其中一半时间耗在和终端设备联调上,真正写业务逻辑的时间反而很少。这也印证了我的一个观点:人脸识别考勤系统,难点并不在人脸识别算法本身,而在于设备对接的健壮性、考勤规则的灵活性和数据回溯的完整性。

如果让我重新做一次,我会在一开始的网关层就把设备协议适配做成插件化配置,而不是后补成一个个兼容分支;同时会在第一天就开启Laravel的队列和定时任务,因为班次状态流转这种逻辑,用脚本定时扫比在接口里实时算要可靠得多。

对于准备动手做类似项目的朋友,我最后补充一串可以直接做的清单:先把终端设备和网关的幂等通讯跑通,再谈人脸识别准确率;先把数据库的biz_date和source字段设计好,再写报表;先把框架升级到安全版本,再做任何外网映射。这些看起来都是细枝末节,但恰恰是它们决定了考勤系统能不能在月底报表出来的那一刻经得住考验。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询