☰
SpringBoot人脸识别考勤系统后端:设备回调、规则引擎与幂等设计
2026/10/27 16:10:42 网站建设 项目流程

简介:基于SpringBoot的人脸识别考勤系统后端设计与实现资源包,面向Java开发者和Spring Boot初学者,主要解决考勤场景中身份验证、安全访问控制与数据持久化等核心问题。内容不仅涵盖Spring Boot核心配置、RESTful API接口设计、Spring Security安全框架接入、事务管理机制,还介绍了OpenCV人脸检测与识别在项目中的集成方式,并涉及Maven依赖管理、H2/MySQL数据库选型以及Actuator性能监控等实用知识点,能够帮助读者从工程结构、接口定义到部署打包形成完整的后端开发思路。压缩包内共209个文件,文件类型以Java源码、JavaScript脚本、HTML页面、CSS样式为主,同时包含XML、properties/yml配置文件、数据库备份、项目文档、构建脚本及少量图片资源,整体大小约1.16MB,目录划分清晰,便于按模块查阅和学习。目前已有37人学习下载,适合用作课程设计、毕业设计参考,或在此基础上快速搭建人脸识别考勤后端原型,并可直接复用其中的安全配置与REST接口设计进行二次开发。

1. 人脸识别考勤系统后端:真正的难点是那笔账怎么记

门禁机上的人脸识别闸机一开,员工脸一凑,通道就放行了——这个画面大家都很熟,但人脸识别考勤系统后端要做的,不是把脸认出来,而是把设备吐出来的那一条识别记录接住,经过校验、去重、规则判定,最终变成“张三今天 9:03 到岗,迟到 3 分钟”这张表。我拆过的这个 SpringBoot 后端项目,解决的就是这件事:接收设备上报、过滤脏数据、算考勤规则、出汇总结果、走补卡审批。它不负责训练人脸模型,也不负责驱动摄像头,而是给考勤业务一个可靠的 Java 后端底座。适合正在做前后端分离项目、或者要对接硬件设备回调接口的后端开发。

2. 先定链路再写代码:设备、接口、数据库三层怎么拆

2.1 设备端与人脸识别服务的边界怎么划

人脸识别考勤系统里的“识别”这步,绝大多数情况下应该放在设备端完成。门禁机会在本地完成检测、特征提取、1:N 比对,然后把识别结果——员工工号、识别时间、抓拍图地址——通过 HTTP 回调推给后端。后端不需要也不应该直接去调用人脸识别算法,否则摄像头一多、并发一高,应用服务器的 CPU 会被图像计算打满,考勤接口跟着一起卡死。

有一种变体是后端集成人脸识别 SDK,由后端自己接收前端上传的人脸照片做比对。这种方案适合没有闸机、只有普通摄像头的轻量场景,但代价是后端要承担图片解码、特征比对这类计算密集型任务,部署时要单独拆服务、单独扩容。我拆的这个项目走的是前一种方案,设备端识别、后端只处理业务数据,这也是市面上考勤门禁一体机最常见的对接方式。

数据链路大致是:设备识别到员工 → 设备端把记录推送(HTTP POST)到后端接口 → 后端验签、幂等判断 → 写入识别原始记录表 → 异步或定时任务计算考勤结果 → 管理端查询报表。这条链路里,后端的第一职责是“可靠地收数据”,第二职责才是“算考勤”。

2.2 后端模块与包结构:识别、考勤、报表各管各的

SpringBoot 项目的包结构如果按“controller/service/mapper”三层一刀切,设备对接和考勤规则混在一起,后期必然翻车。我习惯按业务域拆包,每个域内再分 controller、service、mapper,这样设备上报的改造不会牵连考勤计算。下面这个包结构直接可抄:

com.example.attendance ├── controller │ ├── DeviceCallbackController.java // 设备回调入口,只做验签和转发 │ ├── EmployeeController.java // 员工管理 │ └── AttendanceController.java // 考勤结果查询、补卡审批 ├── service │ ├── device │ │ ├── DeviceCallbackService.java // 验签、幂等、落库 │ │ └── DeviceRegisterService.java // 设备注册与密钥管理 │ ├── attendance │ │ ├── RuleEngine.java // 班次匹配、迟到早退判定 │ │ └── AttendanceCalculateService.java // 汇总计算 │ └── report │ └── AttendanceReportService.java ├── entity │ ├── RecognitionRecord.java // 设备上报原始记录 │ ├── AttendanceResult.java // 每日考勤结果 │ └── Employee.java ├── mapper └── config └── ThreadPoolConfig.java // 设备回调专用线程池

设备回调单独出一个 controller 和 service,是因为它的调用方是硬件设备,不是浏览器,鉴权方式、异常处理、响应格式都和普通管理端接口不一样。设备端通常只认固定格式的 JSON 响应,返回“success”或“failed”,你不能把管理端的统一异常体直接甩给设备。

2.3 数据库表设计:识别记录和考勤结果必须分开存

最核心的设计决策是:设备上报的原始识别记录,和算出来的考勤结果,必须分表。原始记录只增不改,是审计依据;考勤结果每天由定时任务刷新,是可变的业务数据。两者混在一张表里,后期改判定规则时会非常被动,历史数据全要跟着翻一遍。

四张核心表的建表语句可以收敛成下面这样:

CREATE TABLE employee ( id BIGINT PRIMARY KEY AUTO_INCREMENT, employee_no VARCHAR(32) NOT NULL UNIQUE, name VARCHAR(64) NOT NULL, department_id BIGINT, face_enabled TINYINT DEFAULT 1, status TINYINT DEFAULT 1, -- 1 在职 0 离职 create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE device ( id BIGINT PRIMARY KEY AUTO_INCREMENT, device_code VARCHAR(64) NOT NULL UNIQUE, device_name VARCHAR(128), location VARCHAR(255), secret_key VARCHAR(128) NOT NULL, -- 设备签名密钥 status TINYINT DEFAULT 1 ); CREATE TABLE recognition_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, record_id VARCHAR(128) NOT NULL, -- 设备端生成的事件 ID device_code VARCHAR(64) NOT NULL, employee_no VARCHAR(32) NOT NULL, recognize_time DATETIME NOT NULL, image_url VARCHAR(512), status TINYINT DEFAULT 0, -- 0 待处理 1 已参与计算 2 已忽略 UNIQUE KEY uk_device_record (device_code, record_id) -- 幂等兜底 ); CREATE TABLE attendance_result ( id BIGINT PRIMARY KEY AUTO_INCREMENT, employee_no VARCHAR(32) NOT NULL, work_date DATE NOT NULL, shift_id BIGINT, first_punch_time DATETIME, last_punch_time DATETIME, result_status VARCHAR(16) NOT NULL, -- NORMAL / LATE / EARLY / MISS / OVERTIME UNIQUE KEY uk_emp_date (employee_no, work_date) );

要点在 recognition_record 的联合唯一索引 uk_device_record,这一条索引就是设备重复回调的后悔药。设备断网重连后会重推之前没推成功的记录,如果后端不做幂等,同样的打卡事件会被插入两次,考勤结果直接翻倍。有了唯一索引,重复插入直接报 DuplicateKey,代码里捕获这个异常按成功处理就行。

3. 设备回调接入:把“谁刷了脸”变成一条可信记录

3.1 回调接口验签与幂等设计

设备回调接口是暴露在公网上的,如果任何人都能往这个接口推数据,就能伪造打卡记录。所以接口必须做签名校验。常见做法是设备端用 appKey + timestamp + secret 拼成字符串做 MD5/HMAC,后端用同样的参数重算一次比对。下面这段是精简实现:

@PostMapping("/api/device/callback") public Result<?> callback(@RequestBody DeviceEventDTO event) { // 1. 验签 String serverSign = md5(event.getAppKey() + event.getTimestamp() + secretCache.get(event.getDeviceCode())); if (!serverSign.equalsIgnoreCase(event.getSign())) { log.warn("sign mismatch, deviceCode={}", event.getDeviceCode()); return Result.error("sign invalid"); } // 2. 时间窗口校验,防重放 long diff = System.currentTimeMillis() - event.getTimestamp(); if (Math.abs(diff) > 5 * 60 * 1000) { return Result.error("timestamp too old"); } // 3. 幂等插入 try { recognitionRecordService.insertIfAbsent(event); } catch (DuplicateKeyException e) { // 已存在,设备重推,直接返回成功 return Result.success(); } // 4. 丢到队列异步消费,快速响应设备 deviceEventQueue.offer(event); return Result.success(); }

验签用的是设备密钥加当前时间戳,后端从 Redis 或本地缓存里取 secretKey,而不是每次查库。时间窗口校验卡在 5 分钟,超过这个时间范围的一律拒绝,防止抓包重放。幂等靠数据库唯一索引兜底,业务层不用加分布式锁。

设备端对接时有一个细节要注意:设备推送的 timestamp 是设备本地时间,如果设备时钟漂移,后端按服务器时间做窗口校验可能直接误伤。我一般会在验签前先校准一次设备时间,或者在签名里带上设备时间、后端按设备时间与服务器时间差值的绝对值做容错。

3.2 多设备并发上报:线程池与队列削峰

几十台设备同时打卡,早高峰瞬间可能打过来几百条请求。如果回调接口里每一步都同步落库、同步更新考勤,数据库连接池会被打满。设备端不在乎你返回快慢,只关心“你收没收到”,所以回调接口要做得足够薄——验签、幂等、入队,然后立刻返回。真正的落库和计算放到异步队列里慢慢消费。

线程池参数按设备规模来配,我常用的起步配置:

@Configuration public class ThreadPoolConfig { @Bean("deviceCallbackExecutor") public ThreadPoolTaskExecutor deviceCallbackExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); // 常态线程数 executor.setMaxPoolSize(16); // 峰值线程数 executor.setQueueCapacity(10000); // 队列容量,单位是事件数 executor.setThreadNamePrefix("device-callback-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); return executor; } }

核心线程 4,最大 16,队列 1 万,这个容量按 50 台设备、每台每秒上报 2 条的标准来算已经足够。CallerRunsPolicy 是保险丝——队列满了以后,由 Tomcat 线程亲自处理,宁可让接口响应变慢,也不能把事件丢掉。丢掉一条识别记录,员工当天的考勤就缺卡了,这种数据事故比接口变慢严重得多。

这里踩过一个坑:线程池拒绝策略默认是 AbortPolicy,满了直接抛异常,设备会收到失败响应然后短时间内重推,重推又加重队列压力,形成恶性循环。换成 CallerRunsPolicy 之后,压力自然传导给上游设备的重试机制,系统反而更稳。

3.3 识别记录与班次怎么对上:时间归属与迟到判定

设备回调只告诉你“谁在哪一秒刷了脸”,不告诉你“这个人算不算迟到”。考勤系统要把这条记录归属到某个班次里。最简单的规则是:用打卡时间匹配当天启用的班次,如果时间落在班次的上班前 2 小时到下班后 2 小时之间,就归属该班次。

迟到的判定我一般这么处理:先取每个员工每天的第一笔打卡记录,拿它和班次上班时间比。允许一个宽限分钟数,比如上班 09:00,宽限 5 分钟,09:05 前打卡算正常。宽限时间是产品给的,不要自己拍脑袋定。

public AttendanceStatus evaluate(RecognitionRecord record, Shift shift) { LocalTime punchTime = record.getRecognizeTime().toLocalTime(); // 1. 先算最早上班打卡是否迟到 if (punchTime.isAfter(shift.getWorkStartTime().plusMinutes(shift.getLateGraceMinutes()))) { return AttendanceStatus.LATE; } // 2. 下班打卡同理,早退判定用下班时间前推 grace 分钟 if (punchTime.isBefore(shift.getWorkEndTime().minusMinutes(shift.getEarlyGraceMinutes()))) { return AttendanceStatus.EARLY; } // 3. 既有上班又有下班记录才算出勤,否则算缺卡 return AttendanceStatus.NORMAL; }

这段逻辑看着简单,实际坑全在时间比较上。比如跨天班次,夜班 22:00 到次日 06:00,上班时间是 22:00,下班时间已经是第二天,LocalTime 直接比较会认为 06:00 小于 22:00 而判定为早退。处理办法是给 shift 增加一个 endTimeNextDay 布尔字段,比较时给下班时间加一天再算。

4. 考勤计算闭环:从规则配置到定时汇总

4.1 班次与节假日规则:把考勤规则做成可配置

班次不能写死在代码里。不同部门可能上班时间不一样,节假日和调休更是每个月都要调整。所以项目里一定要有一张班次表和一张工作日历表,考勤计算时先查员工所属部门的班次,再用日历判断当天是工作日还是休息日。

下面这张班次表基本覆盖了常见场景:

CREATE TABLE shift ( id BIGINT PRIMARY KEY AUTO_INCREMENT, shift_name VARCHAR(64) NOT NULL, work_start_time TIME NOT NULL, work_end_time TIME NOT NULL, end_time_next_day TINYINT DEFAULT 0, -- 是否跨天,夜班为 1 late_grace_minutes INT DEFAULT 0, -- 上班宽限分钟 early_grace_minutes INT DEFAULT 0, -- 下班宽限分钟 work_hours DECIMAL(4,2), -- 标准工时,用于工时统计 status TINYINT DEFAULT 1 );

work_hours 这个字段很容易被忽略,但它后期很有用——计算加班工时、补卡扣工时都要用标准工时做基准。班次配置界面一般由管理端提供,后端只提供 CRUD 接口和根据日期查询生效班次的接口。

节假日和调休建议单独建一张表存日历,不要动态判断。调休的本质是“周末上班”和“工作日休息”两种例外,动态判断只会让考勤计算变成一个黑匣子,出了问题没人说得清。用日历表把所有例外提前录入,计算时 join 一下,任何人都能查清楚某一天为什么算休息。

4.2 判定优先级:缺卡、早退、外勤先判谁

一个人一天可能有多条识别记录,比如中午出去吃饭刷一次、下午回来又刷一次。考勤判定必须规定优先级顺序:先判断员工当天是否有外勤申请,有外勤则直接覆盖考勤状态;再判断是否有补卡申请,有补卡且审批通过就按补卡后状态算;最后才走迟到早退规则。顺序搞反了,会出现员工外勤审批通过却因为上午没打卡被标记缺卡的情况。

// 简化版判定流程 AttendanceResult result = buildBaseResult(employeeNo, workDate); // 1. 外勤申请优先 if (outsideService.hasApprovedOutside(employeeNo, workDate)) { result.setResultStatus(AttendanceStatus.OUTSIDE); return result; } // 2. 补卡申请次之 if (remedyService.hasApprovedRemedy(employeeNo, workDate)) { result.setResultStatus(AttendanceStatus.NORMAL); result.setRemedyFlag(true); return result; } // 3. 正常排班场景:第一卡判定迟到,最后一卡判定早退 List<RecognitionRecord> records = recordService.listByEmployeeAndDate(employeeNo, workDate); if (records.isEmpty()) { result.setResultStatus(AttendanceStatus.MISS); return result; } result.setFirstPunchTime(records.get(0).getRecognizeTime()); result.setLastPunchTime(records.get(records.size() - 1).getRecognizeTime()); result.setResultStatus(ruleEngine.evaluate(records, shift)); return result;

补卡的场景要特别说一下:补卡通过后,考勤结果置为正常,但原始识别记录不能删除,还要留一个补卡标记,否则月底导出报表时,审计人员会对不上账。这也是为什么我强调原始记录表只增不改。

4.3 定时任务:夜间批量生成考勤结果与分布式锁

考勤结果由定时任务在每天凌晨生成。SpringBoot 的 @Scheduled 注解用起来很简单,但项目如果部署了多个实例,同一时刻每个实例都会执行一次任务,产生重复计算和重复写库。解决方式是给任务加一把分布式锁,用 Redis 的 SETNX 或者数据库行锁都可以。

@Scheduled(cron = "0 30 2 * * ?") // 每天凌晨 2 点 30 分执行 public void dailyCalculate() { String lockKey = "attendance:calc:lock:" + LocalDate.now(); Boolean locked = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofMinutes(30)); if (!Boolean.TRUE.equals(locked)) { log.info("calculate task already executed by other instance"); return; } try { attendanceCalculateService.calculateYesterday(); } finally { stringRedisTemplate.delete(lockKey); } }

setIfAbsent 带上过期时间,防止任务执行到一半进程崩溃,锁变成死锁。过期时间设 30 分钟,正常情况下计算任务几分钟就能跑完,如果历史数据量大,需要根据数据量调大这个值。

任务里还有一个容易漏的点:定时任务只计算前一天,不计算当天。当天数据不完整,员工还在陆续打卡,算出缺卡会误伤。就算要出当天实时报表,也应该单独走统计查询接口,不写定时任务。

5. 避坑指南:设备对接最容易翻车的五个位置

5.1 设备时间不准与跨天班次:考勤错账的两大元凶

现象:员工明明按时到岗,考勤报表却显示迟到;夜班员工连续几天被标记缺卡。

原因:第一,设备本地时钟漂移。门禁机的 RTC 晶振精度有限,运行一个月能差出十几分钟,设备推送的 recognizeTime 用的是设备自己的时间,不是服务器时间。第二,夜班跨天班次没有做日期归属处理,自然日分组把 06:00 的下班打卡归到了前一天。

解决:后端在设备注册时提供时间校准接口,设备每次重启或每满 24 小时自动调一次;服务器收到回调时,用设备时间与服务器时间的偏差做一次校正。跨天班次按 shift.endTimeNextDay 把结束时间加一天后再比较,考勤日期按上班时间归属,比如周五晚上的夜班算周五的考勤。

5.2 员工离职后设备还在识别:人脸特征禁用与关联断裂

现象:员工离职后,设备偶尔还能识别到这张脸,产生一条 employee_no 已失效的识别记录;考勤计算关联不到员工,报表里出现乱码或 null。

原因:离职只做了员工表状态更新,没有把人脸特征从设备端删除,也没在识别记录落库时检查员工状态。老员工的识别记录变成了断链的脏数据。

解决:员工离职时,调用设备管理接口下发人脸删除指令,同时在识别记录落库时校验 employee.status,在离职状态直接标记为忽略记录,不进考勤计算。这里要记住一点:人脸特征下发到设备是异步的,删除也是异步的,中间有一小段时间窗口,后端不能只依赖设备端删除,必须靠状态校验兜底。

5.3 重复回调导致考勤记录翻倍:唯一索引被绕过的风险

现象:同一条打卡记录在库里出现两条,考勤结果被算成“正常”两次或出现异常状态。

原因:有的设备推送逻辑是“先推记录 A,成功后推记录 B”,但网络超时后设备端重推 A,后端验签通过但没查重,直接插入。

解决:联合唯一索引(device_code, record_id)是最后一道防线,但业务层也要先查一次,避免每次都靠捕获 DuplicateKeyException 来兜底——异常捕获在高并发下是有成本的。还有一层要关注:如果设备推送的 recordId 不是全局唯一,而是同一设备内唯一,那唯一索引必须把 device_code 放进去,否则两台设备可能产生相同的事件 ID。

5.4 定时任务重复执行:多实例部署忘了加锁

现象:第二天早上考勤报表数据被翻倍,每个员工的考勤结果出现多条记录。

原因:生产环境部署了两个实例,@Scheduled 任务没有加分布式锁,两个实例同时算前一天,同时 insert,没有唯一索引拦截的部分直接产生了重复数据。

解决:加 Redis 分布式锁,锁过期时间设成任务的预估运行时间乘以 2。如果公司没上 Redis,也可以用数据库表建一个 task_lock 表,靠唯一键抢锁,效果一样。

5.5 回调接口响应太慢:设备端重试风暴把服务打挂

现象:考勤打卡高峰期,回调接口响应时间从 20ms 涨到 2s,设备端不断重试,数据库连接池被打满。

原因:回调接口直接把落库和考勤计算放在同步链路里,数据库一旦慢查询,Tomcat 线程全部阻塞,设备端收不到 200 响应就开始重推。

解决:回调接口只做验签、幂等、入队三步操作,计算逻辑移交给异步线程池。入队用内存队列,或者直接丢到 Redis List 里让后台任务消费。服务整体恢复时间从分钟级降到秒级。

6. 进阶验证:三个手段把考勤账算到一分不差

代码写完之后,怎么证明这套东西算得对?我常用的手段是构造一批边界数据做回归测试,重点卡三个位置:时间边界、幂等逻辑、跨天归属。

第一个手段是写一段小的模拟推送脚本,用 curl 直接向回调接口发送构造好的事件,覆盖 00:00、09:00、09:05、23:59 这些临界时间点:

# 模拟 09:05:30 的迟到打卡 curl -X POST http://localhost:8080/api/device/callback \ -H "Content-Type: application/json" \ -d '{ "deviceCode": "DEVICE-001", "recordId": "TEST-LATE-001", "employeeNo": "EMP001", "recognizeTime": "2025-03-10 09:05:30", "appKey": "test-app", "timestamp": 1741572330000, "sign": "计算好的签名值" }'

把这条记录推到测试环境,然后查 attendance_result 表,确认 result_status 是 LATE。然后改成 09:04:59 再推一次,确认是 NORMAL。这样就把宽限分钟数的边界卡死了。

第二个手段是幂等验证:同一 recordId 连续推两次,确认库里只有一条记录,且接口第二次返回 success。这块应该主动写成自动化测试,而不是每次手工调,因为设备重推是高频现象,回归测试必须覆盖。

第三个手段是对账:写一个统计 SQL,把原始识别记录的数量和考勤结果里的打卡人数做比对。每天跑一次,数量对不上说明有数据链路问题,要下钻查是丢了数据还是重复插入了。

-- 核对:某天识别记录的去重数 vs 考勤结果条数 SELECT r.work_date, COUNT(DISTINCT CONCAT(r.device_code, '-', r.record_id)) AS raw_cnt, COUNT(DISTINCT a.employee_no) AS result_cnt FROM recognition_record r LEFT JOIN attendance_result a ON a.work_date = r.work_date AND a.employee_no = r.employee_no GROUP BY r.work_date HAVING raw_cnt > 0;

从那以后,我每次接手考勤类项目都会强制走一遍这三步:边界时间模拟脚本、幂等重复推送测试、每日对账 SQL。最后这一步尤其重要,它把数据库里“原始记录”和“业务结果”两本账的差异暴露在明面上,比任何人拍胸脯说系统没问题都可靠。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询