Python+OpenCV人脸识别签到系统:客户端服务端架构与工程实践
简介一套基于Python与OpenCV的人脸识别签到管理系统完整源码面向毕业设计、期末大作业及课设实践适合需要快速搭建人脸考勤项目并学习客户端与服务端双重架构的开发者。系统功能覆盖人脸注册、实时检测、身份识别、签到记录管理界面简单直观可灵活调整以适配不同场景。资源包共97个文件、约14.67MB主要包含Python源码26个py及39个pyc、2个演示视频、课程设计报告docxpptx、16个zbak备份、数据库与配置文件db/ini/xlsx/pkl以及Haar人脸检测分类器xml目录清晰便于二次开发。目前已有40人学习下载。通过这套源码可掌握OpenCV人脸检测与识别应用、签到系统的客户端与服务端交互流程、界面搭建及数据持久化方案配套设计报告和演示视频辅助理解整体架构可直接作为课程报告或答辩资料参考是一份完整实用的毕业设计项目。1. 人脸识别签到系统真正的门槛不在识别而在工程“Python OpenCV 人脸识别签到系统”这类项目在 CSDN、GitHub 上并不少见但你翻开源码会发现一个规律大多数都把精力耗在了算法层——怎么检测人脸、怎么提取特征、怎么算相似度。真正走到生产环境就会意识到识别准确率只是入场券签到系统能不能落地取决于两件事一是客户端和服务端怎么拆分二是签到记录怎么保证不丢、不重、可追溯。这也正是本文要讲清楚的。标题里的“客户端和服务端”不是摆设——摄像头采集、人脸检测、特征比对需要在客户端本地完成而签到记录、人员库、考勤统计则需要一个服务端来统一管理。单机版的人脸签到 Demo 很好写摄像头一开、人脸一对、写入 Excel 就算完事。但要把它做成一个“管理系统”客户端负责识别、服务端负责数据才是一个从业者会交付的架构。本文沿着“客户端识别 服务端管理”这条主线把整个链路拆开讲人脸注册和签到怎么设计、客户端与服务端之间怎么通信、识别参数怎么调、签到记录怎么防重最后落在一个日结统计的技巧上。无论你是拿这个项目做毕业设计还是想在现有考勤系统里接入人脸签到都能直接照着做。2. 客户端与服务端分离人脸签到系统的骨架设计2.1 为什么客户端必须本地跑 OpenCV而不是把图片传到服务端识别很多人第一次设计人脸签到系统时直觉是客户端负责拍照把人脸图片传给服务端由服务端用 OpenCV 做识别。这在局域网内看似可行但存在三个问题摄像头帧率瓶颈OpenCV 的VideoCapture.read()读取一帧约 10-30ms加上人脸检测的耗时本地处理一帧大约 50-100ms。如果走网络传输光是一张 JPEG 编码 上传就要 30-50ms还不算服务端排队时间整个识别链路会退化到 2-3 秒一帧。服务端状态爆炸人脸签到本质是持续的视频帧流如果每一帧都上传服务端要同时维护所有客户端的连接状态还要防止并发过高导致的人脸库比对排队。离线可用性客户端如果断网连签到都做不了这在考勤场景里是致命的。所以客户端负责识别服务端负责数据这个边界必须清晰。识别链路中人脸检测、特征提取、特征比对全部在客户端本地完成只有签到成功后的“签到记录”才通过网络传到服务端。这样一来网络只在签到那一瞬间产生一次请求压力极小即使摄像头一直开着也不会对服务端造成负担。2.2 双端通信协议用 JSON over HTTP 还是自定义 TCP客户端和服务端之间传什么数据是架构设计的第一步。最常见的做法是 HTTP JSON因为实现简单、调试方便而且跟 Web 管理端天然兼容。客户端需要向服务端发送两类请求人员库同步客户端启动或定时向服务端拉取最新的人脸特征库。注意这里拉取的“特征”不是原始图片而是 OpenCV 人脸识别器如 LBPH训练出的特征模型文件或者每张人脸的特征向量。签到上报客户端识别成功后把员工 ID、签到时间、现场照片路径上报给服务端。为什么不用自定义 TCP 协议因为人脸签到系统的通信频率极低——每人每天只有几次签到请求用 HTTP 足够而且 Python 标准库里的http.client或requests就能搞定不需要引入额外的网络框架。只有在需要视频流实时传输的场景比如远程门禁监控才需要考虑 RTSP 或自定义 TCP 流协议。2.3 服务端核心数据表人员表、签到表、设备表的设计服务端的第一件事是把数据结构定下来。用 SQLite 还是 MySQL这取决于规模。如果只是毕设或小团队内部使用SQLite 足够部署简单如果考勤人数超过 500 人建议直接上 MySQL避免后续迁移。我建议至少建三张表人员表person主键 ID、工号、姓名、部门、人脸特征base64 编码的 feature 向量、状态在职/离职、创建时间。签到表attendance主键 ID、人员 ID、签到时间、签到类型上班/下班、现场照片路径、设备编号、是否异常迟到/早退/正常。设备表device主键 ID、设备编号、设备名称、最后在线时间、所在位置。签到表必须包含“设备编号”字段用来区分不同客户端的签到来源。如果将来有多个门禁点可以通过这个字段判断员工在哪个位置打卡。CREATE TABLE person ( id INTEGER PRIMARY KEY AUTOINCREMENT, emp_no VARCHAR(20) UNIQUE NOT NULL, name VARCHAR(50) NOT NULL, department VARCHAR(50), face_feature TEXT, -- base64 编码的特征向量或模型路径 status TINYINT DEFAULT 1, -- 1 在职0 离职 created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE attendance ( id INTEGER PRIMARY KEY AUTOINCREMENT, person_id INTEGER NOT NULL, check_time DATETIME DEFAULT CURRENT_TIMESTAMP, check_type TINYINT DEFAULT 1, -- 1 上班2 下班 photo_path VARCHAR(255), device_no VARCHAR(50), status TINYINT DEFAULT 1, -- 1 正常2 迟到3 早退 FOREIGN KEY (person_id) REFERENCES person(id) ); CREATE TABLE device ( id INTEGER PRIMARY KEY AUTOINCREMENT, device_no VARCHAR(50) UNIQUE NOT NULL, device_name VARCHAR(100), last_seen DATETIME, location VARCHAR(100) );这里有个关键点person.face_feature存的是特征向量而不是图片路径。LBPH 人脸识别器训练后生成的是模型文件而 OpenCV 的face.recognize()返回的是置信度。更多时候我们会用face_recognition库提取 128 维特征向量这个向量可以直接 base64 存入数据库客户端拉取后在内存里还原成 numpy 数组做比对。2.4 客户端启动流程拉取模型、打开摄像头、进入识别循环客户端初始化顺序如果不对会出现摄像头打不开、模型加载失败等灵异问题。我一般按这个顺序走import cv2 import numpy as np import requests import json import base64 # 1. 从服务端拉取人员特征库 def fetch_face_db(server_url): resp requests.get(f{server_url}/api/face_db, timeout10) data resp.json() face_encodings [] person_ids [] for item in data[data]: enc np.frombuffer(base64.b64decode(item[feature]), dtypenp.float64) face_encodings.append(enc) person_ids.append(item[id]) return face_encodings, person_ids # 2. 初始化摄像头 def init_camera(camera_id0, width640, height480): cap cv2.VideoCapture(camera_id) cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) # 宽度设小一点提升帧率 cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) if not cap.isOpened(): raise RuntimeError(fCamera {camera_id} open failed) return cap # 3. 主识别循环脱敏实际使用时请接入真实模型 def recognition_loop(cap, face_encodings, person_ids): while True: ret, frame cap.read() if not ret: continue # 这里调用人脸检测 特征提取 比对 # 比对命中后将 person_id 写入签到队列 passfetch_face_db用的是requests.get超时设置为 10 秒。如果服务端不可用客户端不能崩溃应该捕获异常并提示“服务端连接失败请检查网络”。摄像头的分辨率不一定要用默认的 1280x720。帧率优先的场景640x480 通常是识别精度和性能的平衡点。分辨率调低后LBPH 或face_recognition的检测速度会有明显提升而签到场景下人脸通常离摄像头很近清晰度损失可以接受。主循环里有一个容易被忽视的点不要把比对结果直接写在循环里。识别命中后要进入一个“冷却期”比如 2 秒内不重复签到否则摄像头连续抽帧每次命中都会上报一条记录签到表会被塞满。这个防重逻辑可以放在客户端也可以放在服务端后面在第 4 章展开讲。3. 人脸注册与签到识别OpenCV 识别的完整实现3.1 人脸注册的两种做法单张图片提特征 vs. 多帧采样训练模型人脸注册是整个系统的起点。员工第一次使用系统时需要在客户端录入人脸。常见的做法有两种做法一单张图片提取特征基于 face_recognitiondef register_face(image_path, person_id): import face_recognition image face_recognition.load_image_file(image_path) encodings face_recognition.face_encodings(image) if not encodings: raise ValueError(No face detected in image) feature encodings[0] # 128 维向量 feature_b64 base64.b64encode(feature.tobytes()).decode(ascii) # 上传到服务端关联 person_id这种做法的优点是快一张照片即可完成注册而且face_recognition库底层用的是 dlib 的人脸关键点检测对姿态变化有一定容忍度。缺点是单张照片的鲁棒性差——如果这张照片恰好模糊、侧脸、光线奇怪后续签到时误识率会偏高。做法二多帧采样 LBPH 训练基于 OpenCV 自带的识别器第二种方法是用 OpenCV 的LBPHFaceRecognizer拍摄 10-20 帧人脸图片每帧裁剪出人脸区域标注 ID然后训练生成一个模型文件。这种方法的优势在于LBPH 模型天然把“同一个人的不同姿态”做进了模型里识别时对光线、角度变化的容忍度更高。# 人脸对齐和裁剪简化版 def detect_and_crop_face(frame): face_cascade cv2.CascadeClassifier( cv2.data.haarcascades haarcascade_frontalface_default.xml ) gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces face_cascade.detectMultiScale( gray, scaleFactor1.1, minNeighbors5, minSize(80, 80) ) if len(faces) 0: return None x, y, w, h faces[0] return gray[y:yh, x:xw] # 返回灰度图LBPH 输入要求灰度在实际项目中“多帧采样 模型训练”更贴近商用方案。但这里有个效率问题如果公司有几百号人每人都几十帧图片训练时间会随模型尺寸线性增长。面对这种规模更推荐的做法是把每个人当成一个独立的特征向量存入数据库——这就是第 2 章里face_feature字段的由来。3.2 识别循环里的三个参数detectMultiScale 的 scaleFactor、minNeighbors、minSizedetectMultiScale是人脸检测的入口它的三个参数直接决定了检测的召回率和误检率。这个函数用滑动窗口 级联分类器的方式扫描整张图像参数含义如下scaleFactor控制每次缩放图像的尺度默认值是 1.1表示每轮扫描图像缩小 10%。这个值越小扫描次数越多检测越慢召回率越高值越大速度快但容易漏检。minNeighbors每个候选矩形需要保留的邻近矩形数。值越大误检越少但可能漏掉真实人脸。当签到摄像头距离人脸较近、背景较干净时可以调到 5-6如果场景复杂比如背光、多人走动可以降到 3换取更高的召回率。minSize检测到的人脸最小尺寸单位是像素。minSize(80, 80)意味着小于 80x80 的人脸直接忽略。这个参数对性能影响很大因为窗口扫描时最小的搜索窗口就是 minSize尺寸设得越小需要扫描的位置越多帧率下降越明显。这里有几个经验值场景scaleFactorminNeighborsminSize备注门禁考勤人距离摄像头 0.5-1.5 米1.15(80, 80)默认配置均衡型闸机人快速通过画面中有移动1.23(100, 100)提速度降误检弱光环境有轻微噪声1.056(80, 80)慢但稳定避免漏检人脸检测完成后送进特征提取和比对环节的应当是裁剪后的灰度人脸图。如果直接送原始彩色图像第一是特征提取计算量大第二是部分识别器如 LBPH本身只接受灰度输入。注意灰度图预处理时最好做一次直方图均衡化cv2.equalizeHist用来减轻光线不均匀的影响。3.3 特征比对与置信度阈值欧氏距离阈值怎么定face_recognition库中两个人脸的相似度通过 128 维特征向量的欧氏距离来衡量。距离越小越有可能是同一个人。import numpy as np def match_face(unknown_encoding, known_encodings, threshold0.45): distances np.linalg.norm(known_encodings - unknown_encoding, axis1) min_idx int(np.argmin(distances)) if distances[min_idx] threshold: return min_idx, distances[min_idx] return None, distances[min_idx]threshold 的默认值在face_recognition库中通常取 0.45-0.5但实际项目中不能照搬。这个值取决于注册照片的拍摄条件同一摄像头、同一光线、同一角度下注册欧氏距离通常在 0.25-0.35阈值可以设得严格一些比如 0.4降低误识率。注册照片和签到场景差异大比如注册用的是证件照签到用摄像头实拍阈值要放宽到 0.5-0.55否则会造成大量拒识。一个稳妥的做法是上线前拿 20 个人做交叉验证每个人签到 3 次统计“异类距离”和“同类距离”取两者的中间值作为阈值。不要用官方默认值人脸识别系统的定制化空间很大程度就落在这个参数上。3.4 现场照片与签到记录如何关联签到不仅需要“谁在几点打了卡”还需要留下证据防止后续纠纷。打开摄像头识别成功的那一刻客户端截取当前帧保存为 JPG 图片然后把图片路径或图片本身随签到请求一起上报。图片的命名规则建议使用“员工工号 时间戳”避免重名覆盖。def save_snapshot(frame, emp_no): timestamp datetime.datetime.now().strftime(%Y%m%d_%H%M%S) filename f{emp_no}_{timestamp}.jpg # 覆盖目录注意只保留最近 N 天防止磁盘被占满 path os.path.join(snapshots, filename) cv2.imwrite(path, frame) return path照片文件放到独立的snapshots目录路径写入数据库的photo_path字段。如果将来需要做 Web 端查看直接把这个目录用 Nginx 或 FastAPI 映射成静态资源即可。注意保留图片的年份/月份子目录否则一年下来几万张照片放在同一目录文件系统会变得极慢。4. 签到防重与状态判断服务端如何保证记录可靠4.1 服务端防重的三层逻辑时间窗口、人员状态、设备冷却前面提到客户端要做好“识别冷却”但服务端也必须做防重因为客户端很可能临时被绕过或者多个客户端同时对同一员工进行识别。服务端防重通常分三层逻辑第一层时间窗口防重。同一个员工在 60 秒内只能签到一次。在签到表中查最近一条记录如果时间差小于 60 秒直接拒绝重复上报。第二层状态防重。员工已经是“在职”状态且今日已完成上班签到再来的签到请求可能属于下班签到。这里不要用主键约束去硬防而是用业务逻辑去判断。第三层设备冷却。同一台设备在 3 秒内的所有上报都只接受第一条防止摄像头识别人群时连续多次触发。设备编号 时间窗口做成一个唯一键可以用 Redis 的SETNX实现。# 服务端签到接口示例FastAPI 风格 import redis r redis.Redis(hostlocalhost, port6379, db0) def checkin(person_id, device_no): now int(time.time()) # 设备冷却同一设备 3 秒只接受一次请求 lock_key fdevice_lock:{device_no} if not r.set(lock_key, now, nxTrue, ex3): return {code: 4001, msg: device is busy} # 人员防重同一人 60 秒只记录一次 person_key fperson_lock:{person_id} if not r.set(person_key, now, nxTrue, ex60): return {code: 4002, msg: duplicate checkin} # 业务规则按时间判断上班 / 下班 # 写入数据库 attendance 表 return {code: 0, msg: checkin success}防重后的响应会进入一个待同步队列。如果服务端暂时不可用客户端可以把签到记录缓存到本地 SQLite等网络恢复后再批量上传。这比简单地报错“网络异常”体验好得多而且数据不丢失。4.2 迟到、早退、缺卡的判断规则与 SQL 实现签到记录入库之后考勤状态需要在查询时实时计算而不是在写入时用 Python 硬编码——因为上班时间是可能调整的写死之后改起来很痛苦。典型的规则是默认上班时间为 09:00迟到判断按分钟粒度实现。一条用于统计每日考勤的查询按人员分组取当日最早的签到记录作为上班时间最晚的作为下班时间SELECT person_id, MIN(check_time) AS first_in, MAX(check_time) AS last_out, CASE WHEN TIME(MIN(check_time)) 09:00:00 THEN late WHEN MAX(check_time) IS NULL THEN missing ELSE normal END AS day_status FROM attendance WHERE DATE(check_time) 2024-11-20 GROUP BY person_id;实际项目中凌晨打卡、跨日出勤等情况会更复杂但这里展示了两个关键点分组聚合和条件分支。具体来说考勤系统按日期取当天最早/最晚记录用 SQL 的MIN/MAX配合GROUP BY完成可以避免把整表数据拉回 Python 里手算。4.3 客户端断网续传本地 SQLite 缓存表的设计客户端识别成功后如果网络请求失败不能直接把结果丢掉。本地 SQLite 缓存签到记录是通用做法设计上要区分“未同步”和“已同步”两种状态。import sqlite3 def init_local_db(): conn sqlite3.connect(local_cache.db) conn.execute( CREATE TABLE IF NOT EXISTS pending_checkin ( id INTEGER PRIMARY KEY AUTOINCREMENT, person_id INTEGER NOT NULL, check_time DATETIME NOT NULL, photo_path TEXT, device_no TEXT, sync_status INTEGER DEFAULT 0 -- 0 未同步1 已同步 ) ) return conn def cache_checkin(conn, person_id, check_time, photo_path, device_no): conn.execute( INSERT INTO pending_checkin (person_id, check_time, photo_path, device_no) VALUES (?, ?, ?, ?), (person_id, check_time, photo_path, device_no), ) conn.commit()后台线程每隔 10 秒尝试同步一次def sync_pending(server_url, conn): rows conn.execute( SELECT * FROM pending_checkin WHERE sync_status 0 ).fetchall() for row in rows: try: resp requests.post(f{server_url}/api/checkin, jsonrow, timeout5) if resp.status_code 200: conn.execute( UPDATE pending_checkin SET sync_status 1 WHERE id ?, (row[0],), ) conn.commit() except requests.exceptions.RequestException: break # 网络不通保留下次继续sync_status字段是断网续传的核心它把“本地已记录”和“服务端已确认”两个状态区分开。需要注意一旦记录同步到服务端但客户端崩溃理论上存在重复上报的可能——这就是为什么第 4.1 节的时间窗口防重非常重要它天然地兜住了这一层重复。5. 识别性能与参数调优让 OpenCV 在人脸签到场景跑得更稳5.1 帧率与 CPU 占用的取舍缩小检测区域始终是第一优化手段很多人一上来就改detectMultiScale的参数其实最大的性能瓶颈根本不在这里而是全图扫描。摄像头采集的画面里人脸通常只占画面中央的一小块区域。把检测区域裁剪到中央 60% 区域帧率可以提升近一倍CPU 占用直线下降。def center_crop_for_detection(frame, crop_ratio0.6): h, w frame.shape[:2] x int(w * (1 - crop_ratio) / 2) y int(h * (1 - crop_ratio) / 2) return frame[y:y int(h * crop_ratio), x:x int(w * crop_ratio)]识别时先裁剪中央区域检测到人脸并完成比对后再回到完整帧截取现场照片。这样既保证签到照片画面完整又不让全图扫描拖慢识别速度。5.2 LBPH 与 face_recognition 的选型对比什么时候用哪个两种识别方案的性能差异明显维度LBPHOpenCV 自带face_recognitiondlib 封装模型体积几百 KB约 100 MB含模型文件单人识别耗时约 5-15ms约 50-150msCPU训练复杂度需要每人数张图单张图即可提取特征精度光线变化敏感更鲁棒支持姿态变化人工标注需求每次新增人员需重新训练直接入库无需整体训练结论是如果识别人数少于 100 人、摄像头位置固定、光线稳定LBPH 是性价比最高的方案如果团队规模大、或者公司有移动考勤需求手机端拍照签到face_recognition 更合适。在独立客户端嵌入式设备上LBPH 的模型体积优势会直接体现在部署时间上。5.3 识别日志与现场照片保留策略磁盘空间怎么控制签到系统运行一段时间后最大的存储消耗不是数据库而是现场照片。一张 JPG 约 50-200KB100 人每人每天 2 次签到一天就是 20-40MB一年就是 7-15GB。长期运行磁盘迟早被照片塞满。常见策略是照片按月份归档超过 6 个月的自动压缩为低分辨率版本或直接清理。清理用服务端的定时任务来完成# 定时任务每天凌晨执行 import os, datetime, shutil archive_dir snapshots/archive today datetime.date.today() for d in os.listdir(snapshots/2024): month_dir os.path.join(snapshots/2024, d) if datetime.date(2024, int(d), 1) today - datetime.timedelta(days180): shutil.move(month_dir, archive_dir)数据库里只保留photo_path字符串路径清理照片不影响数据结构。也可以更进一步在上传照片到服务端的时候就把原图降采样到 640x480因为签到记录并不需要保留原始分辨率照片。5.4 一个容易被忽略的坑OpenCV 窗口不能正常关闭摄像头被占用开发调试阶段OpenCV 的imshow窗口经常出现“点关闭没反应”的情况关掉终端后发现摄像头无法再次打开提示设备被占用。这是因为cap.release()没有在窗口关闭回调中执行。正确处理方式是在主循环检测到用户按 ESC 或 Q 键时先释放窗口和摄像头再退出进程while True: ret, frame cap.read() # ... 识别逻辑 ... if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()如果你是在嵌入式设备上跑还有一个隐藏问题VideoCapture(0)打开失败时OpenCV 不会抛异常而是返回一个未就绪的捕获对象此时继续调用read()会返回(False, None)。初始化时必须检查isOpened()否则会出现“摄像头打不开但程序不报错”的诡异现象。6. 日结与异常补登把签到数据变成工作日终的可用报表6.1 日结统计的落库在服务端用一条 SQL 生成考勤汇总人脸识别签到的最终产出不是“识别成功”那一刻的弹出框而是下班后管理员能看到一份“今天谁正常、谁迟到、谁没来”的表格。我通常的做法是每天 23:50 跑一个定时任务把当天各人员的考勤状态写入一张daily_summary表这样早晨查看报表时不需要实时扫描大表速度会快很多。INSERT INTO daily_summary (person_id, work_date, first_in, last_out, status) SELECT p.id, CURDATE(), a.first_in, a.last_out, CASE WHEN a.last_out IS NULL THEN missing WHEN TIME(a.first_in) 09:30:00 THEN late ELSE normal END FROM ( SELECT person_id, MIN(check_time) AS first_in, MAX(check_time) AS last_out FROM attendance WHERE DATE(check_time) CURDATE() GROUP BY person_id ) a RIGHT JOIN person p ON p.id a.person_id;这里用RIGHT JOIN把人脸库里所有在职员工都列出来包括没有签到记录的人标记为missing。这一步很关键——只有签到记录的人做汇总等于把“忘打卡”的人静默忽略掉了这在考勤制度上是站不住脚的。6.2 异常补登接口管理员手工修正迟到记录人脸识别的误拒比如员工今天化妆或有遮挡无法完全避免因此日报表里会预留“异常补登”的能力。管理员在 Web 端选择人员和日期设置正确的签到时间系统将补登记录写入一张独立的attendance_override表并将该员工的日状态更新为已修正。# FastAPI 补登接口示例 app.post(/api/attendance_override) def override_attendance(person_id: int, work_date: str, check_time: str): # 插入补登表 # 同时更新 daily_summary 中该员工当天的状态为 override pass补登记录与原始识别记录区分存放而不是直接修改attendance表好处是保留了审计线索——你可以知道哪些记录是机器识别出来的、哪些是人工修正过的。这在答辩演示和实际人事管理中都是加分项。6.3 用一个 Shell 命令做每日数据备份签到数据是企业人事数据备份不能省。最简单可靠的方案是每天凌晨用 cron 执行一次 SQLite 或 MySQL 的导出保留最近 30 天备份文件#!/bin/bash BACKUP_DIR/data/backup/attendance DATE$(date %Y%m%d) mysqldump -u root -p*** attendance ${BACKUP_DIR}/attendance_${DATE}.sql find ${BACKUP_DIR} -name attendance_*.sql -mtime 30 -exec rm {} \;不管底层用的是 SQLite 还是 MySQL备份一定要保留多天的版本不要只留一份“昨日备份”然后用新数据覆盖它。员工数据的价值远超代码本身磁盘富余的多留几天没有坏处。6.4 报表输出的最后一步从数据库到 Excel日终统计完成后管理人员需要一份能直接打开的报表。用 Python 的openpyxl库把daily_summary表的内容导出成带格式的 Excel 文件from openpyxl import Workbook wb Workbook() ws wb.active ws.append([工号, 姓名, 日期, 上班时间, 下班时间, 状态]) rows cursor.execute(SELECT ... FROM daily_summary WHERE work_date ?, (today,)).fetchall() for row in rows: ws.append(row) wb.save(freports/attendance_{today}.xlsx)Sheet 名默认叫 “Sheet”可以直接改成日期多份日报放在同一个工作簿里分 Sheet 归档比每天生成一个独立文件更整洁。输出报表放在 cron 任务里自动执行每天早晨管理员打开邮箱或共享目录就能拿到前一天的考勤结果。本文还有配套的精品资源点击获取