
简介基于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. 正常排班场景第一卡判定迟到最后一卡判定早退 ListRecognitionRecord 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。最后这一步尤其重要它把数据库里“原始记录”和“业务结果”两本账的差异暴露在明面上比任何人拍胸脯说系统没问题都可靠。希望帮到你。本文还有配套的精品资源点击获取