人脸识别打卡考勤系统:OpenCV、dlib与MySQL完整串联
2026/9/16 19:39:43 网站建设 项目流程

简介:这是一份基于Python、OpenCV与MySQL实现的人脸识别打卡考勤系统,属于高分毕业设计源码,评审分为99分,代码完整可运行,适合正在做毕设或想进行项目实战的计算机专业学生,也可直接用于课程设计或期末大作业。资源包共18个文件,以13个Python源码文件为主,涵盖系统主界面、员工信息录入、考勤查询与报表等模块;同时附带2个dlib人脸识别模型文件及1个SQL建库脚本,可帮助快速搭建数据库并复现核心算法流程。整体压缩包约88.99MB,文件结构清晰,便于对照学习。核心流程包括摄像头实时人脸采集、特征提取、数据库比对识别与打卡记录存储,配合可视化界面完成员工信息管理、考勤打卡与结果查询的完整闭环。目前已有182人学习下载,项目从底层数据表到前端交互闭环完整,能直观展示人脸特征采集、比对识别和打卡记录管理等关键流程,适合系统掌握基于Python的人脸识别考勤系统开发思路。

1. 人脸识别打卡考勤系统:OpenCV、dlib 与 MySQL 的一次完整串联

指纹打卡机最怕手汗和磨破皮,刷卡机防不住代刷,人脸识别把“你是谁”变成了图像特征匹配问题,真正做到非接触、免介质。这套系统用 Python 把 OpenCV 摄像头采集、dlib 人脸特征提取、MySQL 数据存储串成一条完整链路,最后用桌面 GUI 做成可操作的人脸识别打卡考勤系统。代码覆盖数据表设计、人脸采集、特征计算、识别比对、考勤记录和增删改查界面,适合正在做毕业设计、课程设计的学生,也适合第一次接触“摄像头 + 数据库”项目、想理解人脸识别流程的开发者。整个工程按采集、特征计算、识别、界面四个环节展开,每个环节都能单独拆出来改造。

2. MySQL 数据层设计:CreateDB.sql 建表与 SqlController 的读写封装

2.1 考勤系统需要两张表:员工表与打卡记录表

这个考勤系统建模的核心是两个实体:员工和打卡行为。employee 表保存员工身份信息与人脸特征,attendance_record 表保存每一次打卡。两者通过 emp_id 关联,构成最典型的 1:N 结构。把员工信息拆出来而不是写进每条打卡记录,是为了避免数据冗余,也方便日后修改部门、姓名时只改一处。

CREATE DATABASE IF NOT EXISTS attendance_db DEFAULT CHARACTER SET utf8mb4; USE attendance_db; CREATE TABLE employee ( emp_id INT AUTO_INCREMENT PRIMARY KEY COMMENT '员工ID', emp_no VARCHAR(20) NOT NULL UNIQUE COMMENT '工号', name VARCHAR(50) NOT NULL COMMENT '姓名', department VARCHAR(50) DEFAULT '未分配' COMMENT '部门', face_feature TEXT COMMENT '128维人脸特征,逗号分隔', photo_path VARCHAR(255) COMMENT '采集照片保存路径', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '录入时间' ) ENGINE=InnoDB; CREATE TABLE attendance_record ( record_id INT AUTO_INCREMENT PRIMARY KEY, emp_id INT NOT NULL, check_time DATETIME DEFAULT CURRENT_TIMESTAMP, status VARCHAR(10) DEFAULT '正常' COMMENT '正常/迟到/早退', CONSTRAINT fk_emp FOREIGN KEY (emp_id) REFERENCES employee(emp_id) ) ENGINE=InnoDB;

建表脚本里有两个容易被忽略的细节。face_feature 用 TEXT 存逗号分隔的浮点数,这是 128 维特征在 MySQL 里最直接的落地方式,Python 端读出后 split 再转 numpy 数组即可。外键 fk_emp 保证不会插入不存在的 emp_id,避免打卡记录变成孤儿数据;InnoDB 引擎支持外键和事务,数据一致性比 MyISAM 更有保障。

字段类型的选择也直接影响后面代码的写法。emp_no 用 VARCHAR(20) 而不是 INT,是考虑到工号可能需要保留前导零或包含字母;name 用 VARCHAR(50) 在 MySQL 中按字符计算长度,中文姓名不会因为字节数被截断。DATETIME 默认值是 CURRENT_TIMESTAMP,Python 端插入打卡记录时不传时间也能自动写入当前时刻。

提示:如果你在本机用 MySQL 8.0+,默认字符集已经是 utf8mb4,但 CREATE DATABASE 语句里显式指定仍然是最好的习惯,避免建库时继承全局配置导致乱码。

2.2 SqlController.py:连接生命周期与增删改查封装

SqlController 是系统里唯一直接操作 pymysql 的类,界面层只调用它暴露的方法。这样设计的直接好处是排查问题时有明确边界:界面显示异常,先在 SqlController 里验证 SQL;数据库连接失败,也只需要改一处配置。

import pymysql from contextlib import contextmanager # Config.py 里维护连接参数,SqlController 直接引用 db_config = { 'host': '127.0.0.1', 'port': 3306, 'user': 'root', 'password': '123456', 'database': 'attendance_db', 'charset': 'utf8mb4' } class SqlController: def __init__(self, config=None): self.config = config if config else db_config @contextmanager def get_connection(self): conn = pymysql.connect(**self.config) try: yield conn conn.commit() except Exception: conn.rollback() raise finally: conn.close() def query_all(self, sql, params=None): with self.get_connection() as conn: with conn.cursor(pymysql.cursors.DictCursor) as cursor: cursor.execute(sql, params) return cursor.fetchall() def execute_update(self, sql, params=None): with self.get_connection() as conn: with conn.cursor() as cursor: return cursor.execute(sql, params)

get_connection 用 contextmanager 把连接的生命周期约束在 with 块内,正常路径下 yield 结束后提交事务,异常路径下回滚并重新抛出,finally 保证连接被关闭。即使某个界面按钮触发了异常,连接也不会泄漏到进程里。DictCursor 让查询结果变成列表套字典的结构,界面代码写 row['name'] 而不是 row[2],可读性会好很多。

参数说明:db_config 里 host 用 127.0.0.1 而不是 localhost,在部分 Windows 环境下可以避免 socket 解析差异;port 保持 3306,如果安装 MySQL 时改过端口要同步修改;charset 必须和建库字符集一致,否则中文写入时容易出现乱码。query_all 适合 SELECT 返回多条结果,例如员工列表和打卡记录查询;execute_update 返回受影响行数,插入员工或写入打卡记录时可以用来判断是否成功。当前项目规模下,用完即关是最简单可靠的方案。

2.3 参数化查询:防注入与中文编码一并解决

所有 SQL 都用 %s 占位符,把用户输入作为 execute 的 params 传入,而不是拼进字符串。这样一方面避免 SQL 注入,另一方面 pymysql 会自动处理转义,工号里出现单引号或者中文逗号时不会破坏 SQL 结构。

# 推荐:参数化查询 sql = ("INSERT INTO employee (emp_no, name, department, face_feature, photo_path) " "VALUES (%s, %s, %s, %s, %s)") ctrl.execute_update(sql, (emp_no, name, dept, feature_text, photo_path)) # 不推荐:字符串拼接,emp_no 为 "x' OR '1'='1" 时会注入额外条件 # sql = "INSERT INTO employee ... VALUES ('" + emp_no + "')"

运行阶段最常见的异常集中在两类。pymysql.err.OperationalError 代表连接层面失败,排查顺序是 MySQL 服务是否启动、端口是否还是 3306、账号密码是否正确、配置里字符集命名是否无误;pymysql.err.IntegrityError 代表唯一键或外键冲突,通常是工号重复,应该被捕获并提示“该工号已存在”,同时让事务回滚。下面的表格覆盖了开发期最容易遇到的数据库异常:

异常典型错误码原因处理方式
OperationalError2003MySQL 服务未启动或端口不对启动服务、核对端口
OperationalError1045用户名或密码错误修改 Config.py
ProgrammingError1064表名、字段名拼接出错打印 SQL 检查
IntegrityError1062唯一索引冲突捕获异常并提示重复

我在开发这个系统时遇到过 1064 报错,原因是 Python 的三引号字符串里保留了多余的换行和缩进。把 SQL 先放到 MySQL Workbench 里执行一遍,确认无误再粘回代码,能省下不少排查时间。

3. 人脸采集与特征计算:OpenCV 调用相机与 dlib 68 点定位

3.1 FaceCollect.py:用 OpenCV 把人脸区域截出来

采集阶段的目标是为每个员工收集足够多的人脸样本。FaceCollect.py 打开摄像头后,循环读取每一帧,用人脸检测器找出画面里的人脸位置,再把这块区域保存成图片。OpenCV 调用相机的基本原理是 VideoCapture 后端逐帧读取驱动输出的图像,所以初始化后必须用 isOpened() 确认摄像头可用,否则 read() 会一直返回空帧。

import cv2 import dlib import os detector = dlib.get_frontal_face_detector() predictor = dlib.shape_predictor("Models/shape_predictor_68_face_landmarks.dat") emp_no = "EMP001" save_dir = f"capture/{emp_no}" os.makedirs(save_dir, exist_ok=True) cap = cv2.VideoCapture(0) if not cap.isOpened(): raise IOError("摄像头打开失败,请检查设备连接或索引") count = 0 while count < 30: ret, frame = cap.read() if not ret: break # 灰度化后检测,比彩色图计算量更小 gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces = detector(gray, 1) for face in faces: # 取人脸边界并做越界保护 x1, y1 = max(0, face.left()), max(0, face.top()) x2, y2 = face.right(), face.bottom() face_img = frame[y1:y2, x1:x2] if face_img.size == 0: continue cv2.imwrite(os.path.join(save_dir, f"{count:03d}.jpg"), face_img) count += 1 if count >= 30: break cap.release() cv2.destroyAllWindows()

detector(gray, 1) 的第二个参数是上采样次数,控制检测器对图像金字塔的处理层级,值越大越能发现小尺寸人脸,但计算耗时也会明显上升,实时采集场景用 1 就够。裁剪坐标直接来自 dlib rectangle 的 left/right/top/bottom,但必须用 max(0, ...) 约束边界,否则人脸贴近画面边缘时切片会越界。保存文件名用三位数字编号,方便后续按顺序读取。

3.2 FeatureCompute.py:从 68 个关键点到 128 维特征向量

shape_predictor_68_face_landmarks.dat 输出的是人脸 68 个关键点坐标,这些点描述的是五官轮廓,本身不能直接用于身份区分。真正的识别特征由 dlib_face_recognition_resnet_model_v1.dat 这个在大规模人脸数据集上训练过的 ResNet 模型计算得出,输入是经过关键点对齐的人脸区域,输出是 128 维浮点向量。

import dlib import numpy as np detector = dlib.get_frontal_face_detector() predictor = dlib.shape_predictor("Models/shape_predictor_68_face_landmarks.dat") rec_model = dlib.face_recognition_model_v1("Models/dlib_face_recognition_resnet_model_v1.dat") def compute_feature(image_path): img = dlib.load_rgb_image(image_path) dets = detector(img, 1) if len(dets) == 0: return None shape = predictor(img, dets[0]) descriptor = rec_model.compute_face_descriptor(img, shape) feature = np.array(descriptor) # 归一化到单位向量,减小光照和尺度影响 return feature / np.linalg.norm(feature)

compute_face_descriptor 返回的 descriptor 是 dlib 的 vector 对象,必须先转成 numpy 数组才能和数据库里读出的特征做向量运算。最后一步归一化不能省:把特征向量缩放到单位长度,后续算欧氏距离时,结果不会受图像尺寸、人脸在画面中占的比例影响。FeatureCompute.py 在工程中的角色就是这个函数,录入员工时调用它,识别时每帧也调用它,所以这个函数的执行速度直接决定识别帧率。

3.3 采样数量与多特征存储:一个人要存几张脸

128 维特征并不是一张照片就能稳定代表的。同一个员工在侧脸、仰头、戴眼镜、光线变化时,特征向量都会偏移。采集阶段建议让员工正对摄像头,缓慢左右转头,保存 10 到 20 张不同角度的样本;数据库里可以存多条特征,识别时逐条比对,取最小距离。

特征条数单次比对耗时误识率趋势适用阶段
1高,换角度就丢功能验证
5较快中等小范围演示
10-20正式考勤

多特征在数据库里的存储格式是一段段特征用分号拼接:“0.12,0.34,...;0.45,0.21,...”,读取后先按分号切段,再对每一段做 parse 和距离计算。我在实际项目里通常限制每张照片只取最大的人脸框,避免合照中背景人脸干扰主特征。识别时只要其中一条特征与当前人脸距离小于阈值,就判定为同一人。

4. 识别与考勤判定:Recognition.py 的距离计算与状态判断

4.1 欧氏距离与阈值:人脸比对的核心计算

识别阶段做的事情和录特征时完全对称:从当前摄像头帧提取 128 维特征,再与数据库中的员工特征全部比对一次。比对距离用欧氏距离,也就是两个向量的二范数。人脸识别算法里欧氏距离和余弦相似度都能用,因为特征已经做过归一化,两者的排序结果在数学上一致,但欧氏距离的数值更直观,dlib 官方示例也默认用这个值。

import numpy as np def parse_feature(text): return np.array(text.split(','), dtype=float) def recognize(current_feature, threshold=0.5): sql = "SELECT emp_id, face_feature FROM employee WHERE face_feature IS NOT NULL" rows = sql_controller.query_all(sql) min_dist = float('inf') match_id = None for row in rows: # 多特征按分号切段后逐条比对 for feature_text in row['face_feature'].split(';'): db_feature = parse_feature(feature_text) dist = np.linalg.norm(current_feature - db_feature) if dist < min_dist: min_dist = dist match_id = row['emp_id'] if min_dist < threshold: return match_id, min_dist return None, min_dist

这里的 sql_controller 是系统启动时创建的全局实例,查询只取 emp_id 和 face_feature,避免把整行员工信息拉进内存。内层循环把多特征场景下的分号分隔字符串拆开,逐段计算距离。min_dist 初始化为无穷大,保证任何有限距离都会被比较一次。threshold 默认 0.5,最终小于这个值就返回匹配到的 emp_id,否则返回 None,界面层据此提示“未识别”或“打卡成功”。0.5 只是起步值,具体怎么调在第 6 章展开。

4.2 打卡状态与重复打卡:迟到判定写在 Python 里

识别命中后,系统要生成一条考勤记录。状态字段的值取决于打卡时间与上班时间的关系。员工规定 9:30 前到岗,那么 9:30 之前打上卡记“正常”,之后记“迟到”。这个规则放在 Recognition.py 的 check_status 函数里,而不是 SQL 里,因为考勤规则会变,改 Python 一处比改数据库和查询逻辑都简单。

from datetime import datetime def check_status(check_time): deadline = datetime(check_time.year, check_time.month, check_time.day, 9, 30) return "正常" if check_time <= deadline else "迟到" def has_checked_today(emp_id, date_obj): sql = """ SELECT COUNT(*) AS cnt FROM attendance_record WHERE emp_id = %s AND DATE(check_time) = %s """ row = sql_controller.query_all(sql, (emp_id, date_obj.strftime('%Y-%m-%d'))) return row[0]['cnt'] > 0

check_status 把时间比较限定在同一天内的 9:30 这个时间点,跨天时不会误判。has_checked_today 用 DATE() 函数把 DATETIME 转成日期再比较,这会让 check_time 上的索引失效,但打卡记录量不大,性能问题不突出;如果表数据到了几十万行,就应该把日期看成两个边界范围查询。在插入打卡记录之前先调用 has_checked_today,命中则提示“今日已打卡”,避免同一个人一天产生多条重复记录。

4.3 打卡失败率高的根源:单帧判定与光线突变

摄像头是按帧输出的,单独某一帧提取特征失败,不代表人不在摄像头前。常见原因有三种:人脸侧转超过检测器角度范围、环境光突变导致图像过暗、人脸占比太小。如果系统在一帧失败后就判定“未识别”,用户稍微偏头就会被漏打卡,体验非常差。

比较稳妥的做法是引入连续帧窗口。用计数器记录连续识别失败的帧数,失败加 1,成功清零;当失败计数达到 5 时,才真正判定为“无人脸并退出打卡流程”。同时,如果数据库写入失败,不要直接把异常抛给界面,用 try-except 包住插入语句,失败时提示“打卡记录保存失败,请联系管理员”。考勤场景下,宁可提示用户补录,也不能让异常被静默吞掉。

现象可能原因调整方向
正面能识别,转头就丢特征角度覆盖不足采集阶段增加左右侧脸样本
偶尔识别成别人阈值过大调低 threshold
库里有人一直识别不出照片模糊或光线过暗重新采集并提高采集帧质量
多人同时出现在画面检测器返回多个人脸只取面积最大的人脸框

5. GUI 交互实现:MainGUI 与增删改查窗口的工程化设计

5.1 MainGUI.py 主窗口:把识别和查询装进一个界面

MainGUI.py 构建的是系统主界面。它负责展示摄像头预览、识别结果和当天打卡记录,同时提供“员工管理”“考勤查询”等入口。桌面界面采用 PyQt5,信号槽机制把按钮点击和业务函数连接起来。

from PyQt5.QtWidgets import QMainWindow, QPushButton, QTableWidget, QLabel class MainGUI(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("人脸识别打卡考勤系统") self.resize(900, 600) self.camera_label = QLabel("摄像头画面区域", self) self.table = QTableWidget(self) self.table.setColumnCount(4) self.table.setHorizontalHeaderLabels(["工号", "姓名", "打卡时间", "状态"]) self.btn_open_camera = QPushButton("开始识别", self) # 点击按钮后启动识别线程 self.btn_open_camera.clicked.connect(self.start_recognition) def start_recognition(self): # 识别逻辑应放在线程中,避免阻塞主界面 pass

start_recognition 里不能直接在主线程跑 while 循环,否则界面会卡死。常见的做法是用 QThread 或 Python 的 threading 开一个识别线程,识别结果通过信号发回主线程更新表格。摄像头预览则是把 OpenCV 的 frame 转成 QImage 后 setPixmap 显示在 QLabel 上。工程里的 GUIHelper.py 就是干这种杂活的:把 cv2 图像转 QImage、弹出消息框、格式化时间字符串。各个组件的职责可以按下面这张表理解:

组件类型职责
camera_labelQLabel显示摄像头实时预览
tableQTableWidget展示识别命中的打卡记录
btn_open_cameraQPushButton启动/停止识别线程
btn_manageQPushButton打开员工管理窗口

5.2 Employee.py 与 InsertWindow.py:新员工录入的完整路径

InsertWindow.py 负责录入新员工。界面上有三个输入项:工号、姓名、部门,一个“采集人脸”按钮,一个“保存”按钮。点击采集后调用 FaceCollect 抓拍样本,再用 FeatureCompute 计算特征并拼接成字符串;点击保存后调用 SqlController 的 execute_update 写入 employee 表。

Employee.py 在系统里是员工数据模型,它把业务字段封装成一个类,界面层传递和处理时不用来回拆字典。

class Employee: def __init__(self, emp_id, emp_no, name, department, face_feature=None, photo_path=None): self.emp_id = emp_id self.emp_no = emp_no self.name = name self.department = department self.face_feature = face_feature self.photo_path = photo_path def to_dict(self): return { 'emp_no': self.emp_no, 'name': self.name, 'department': self.department, 'face_feature': self.face_feature, 'photo_path': self.photo_path }

to_dict 的输出可以直接作为 execute_update 的参数来源,让数据从界面到数据库的传递路径保持统一。ModifyWindow 和 DeleteWindow 复用了这个模型,修改员工信息时先依据 emp_id 查出原记录,回填到输入框,保存时再整体更新。修改员工信息不用重建人脸特征,只有更换照片时才需要重新调用 compute_feature 覆盖原特征。

5.3 QueryWindow.py:按日期、部门过滤考勤记录

查询窗口是考勤系统里使用频率最高的功能。它需要支持按日期看全部打卡记录,按部门过滤,以及显示每个人当天打卡是否正常。QueryWindow.py 的查询函数把过滤条件拼成 SQL,再交回 SqlController 执行。

def query_attendance(date_str, department=None): sql = """ SELECT e.emp_no, e.name, e.department, r.check_time, r.status FROM attendance_record r JOIN employee e ON r.emp_id = e.emp_id WHERE DATE(r.check_time) = %s """ params = [date_str] if department: sql += " AND e.department = %s" params.append(department) sql += " ORDER BY e.department, r.check_time" return sql_controller.query_all(sql, tuple(params))

这里用 JOIN 把员工表和打卡记录表连起来,只查需要展示的字段。部门过滤条件用动态追加 SQL 的方式实现,但参数仍然通过 params 传入,不会引入注入风险。日期参数直接传 'YYYY-MM-DD' 字符串,MySQL 的 DATE() 函数会自动识别。如果查询结果为空,界面层给出“该日期无打卡记录”的提示,而不是渲染一个空表格。部门下拉框的数据来自 employee 表的 DISTINCT department 查询,这样新增部门后不需要改界面代码。

6. 部署与调参:dlib 模型加载失败的根源与识别阈值调优

6.1 模型文件路径与依赖版本校验

dlib 的两个模型文件 shape_predictor_68_face_landmarks.dat 和 dlib_face_recognition_resnet_model_v1.dat 必须放在 Models 目录下,且文件名不能改动。代码里用相对路径加载模型,所以启动程序的当前工作目录必须和项目根目录一致,否则会抛出RuntimeError: Error loading shape_predictor_68_face_landmarks.dat。先验证 Python 环境再用模型:

python -c "import dlib; print(dlib.__version__)" python -c "import cv2; print(cv2.__version__)" python -c "import pymysql; print(pymysql.__version__)"

dlib 在 Windows 上安装经常卡在编译环节,因为需要 CMake 和 Visual Studio 的 C++ 构建工具,优先用预编译 wheel 安装,省去本地编译的麻烦。OpenCV 安装则相对简单,pip 安装后根据版本注意cv2opencv-python的对应关系即可。

6.2 摄像头索引与识别帧率的取舍

VideoCapture(0)的 0 是摄像头索引。笔记本内置摄像头是 0,外接 USB 摄像头可能需要改成 1 或 2。Windows 平台下如果默认后端打不开摄像头,可以强制指定 DirectShow 后端:

# 0 是设备索引,CAP_DSHOW 指定 Windows 下使用 DirectShow 后端 cap = cv2.VideoCapture(0, cv2.CAP_DSHOW)

摄像头被其他程序占用时,isOpened() 可能返回 True 但 read() 永远返回 False,循环里要加超时计数,连续多帧失败就释放资源并提示用户。实时识别不要每帧都跑完整流程,常见做法是隔 2-3 帧取一帧做检测和特征提取,画面预览仍走原帧,既能保帧率又能省 CPU。

6.3 用离线测试集调识别阈值

threshold=0.5 只是起步值。正确做法是准备一组已标注的人脸照片,批量计算特征后逐一与数据库比对,画出距离分布,找到“所有同一人距离都小于、所有不同人都大于”的分界点。

# 离线验证:每张测试图片打印匹配结果与距离 test_set = [ ("test_emp001_a.jpg", "EMP001"), ("test_emp001_b.jpg", "EMP001"), ("test_emp002_a.jpg", "EMP002") ] for image_path, expect_emp in test_set: feature = compute_feature(image_path) if feature is None: print(f"{image_path}: 未检测到人脸") continue match_id, min_dist = recognize(feature, threshold=0.5) ok = "OK" if match_id == expect_emp else "FAIL" print(f"{image_path}: match={match_id}, dist={min_dist:.3f}, {ok}")

通过修改 threshold 跑多轮验证,可以观察到误识率的变化。下面是一组典型参考值,具体数字随采集环境和样本数量变化,必须重新实测:

阈值同人漏识率异人误识率适用场景
0.4偏高极低门禁、考勤等安全优先场景
0.5中等中等默认折中值
0.6偏高演示或体验场景

6.4 考勤结果的 SQL 快速验证

识别和打卡都做完后,用下面这段 SQL 查看某一天所有人的考勤汇总。LEFT JOIN 保证即使某位员工当天没有打卡记录,也会出现在结果里,只是 total 为 0,方便直接看出谁缺勤。

SELECT e.emp_no, e.name, e.department, COUNT(r.record_id) AS total, SUM(CASE WHEN r.status = '迟到' THEN 1 ELSE 0 END) AS late_count FROM employee e LEFT JOIN attendance_record r ON r.emp_id = e.emp_id AND DATE(r.check_time) = '2025-01-10' GROUP BY e.emp_id, e.emp_no, e.name, e.department ORDER BY e.department;

日期条件写在 JOIN 的 ON 子句里而不是 WHERE 子句里,是因为 WHERE 会先把 LEFT JOIN 的结果过滤掉未匹配行,让没有打卡记录的员工从结果里消失。total=0 的员工就是当天没有打卡的人,可以直接纳入缺勤名单;late_count 大于 1 时说明重复打卡的防重逻辑仍有遗漏,需要回去检查 has_checked_today 是否在每次识别命中后都被执行。

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

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

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

立即咨询