考勤打卡系统这活儿,看着简单,做起来全是细节。我刚接手的时候,公司用的还是钉钉,但管理层后来提了一堆需求——加班要按工时分段算、不同部门要套不同的考勤规则、节假日倒班得单独配置……在钉钉上绕了一圈发现定制成本太高,最后决定自己用Python和Flask搭一套内部系统。这篇文章就是我当时从零搭建的完整复盘,包括数据表怎么设计、打卡接口怎么避坑、加班工时为什么总是算不准、以及部署到公司内网以后踩过的那些雷。不管你是想给小型团队做个轻量方案,还是纯粹想练手Flask,这篇都能给你省不少事。
1. 为什么选Flask而不是Django,也不是直接买现成的
先说结论:20人以下的团队,用钉钉或企业微信飞书的免费考勤就够了。但一旦超过50人,或者公司有排班制、综合工时制、跨天加班这类"非主流"规则,免费工具就开始各种不合身。
我们公司当时的痛点就在这,几个核心业务部门的上班时间压根不一样——研发是弹性打卡,客服是三班倒,仓库那边是两班制还要算节假日加班。钉钉的高级版要按人头付费,一年算下来也不少钱。于是IT部门被问了一句"能不能做个内部的小系统",这种需求落到程序员头上,本质上就是那句话:能用就行,别太贵。
1.1 Flask在考勤系统场景下的说服力
选Flask不是因为它比Django强,而是在这个场景下它恰好切中要害:
- 考勤系统的核心是大量小而分散的接口,比如打卡、查记录、提加班申请、审批,每个接口逻辑不长,Flask的路由组织方式非常直白。
- Django自带的Admin后台和ORM确实香,但它的项目结构和模型注册机制对一个小型内部工具来说,反而是多余的脚手架。
- Flask + SQLAlchemy的组合足够应对复杂的多表关联查询,报表统计需要用到的"分组、日期函数、条件聚合",SQLAlchemy都能搞定。
- 部署省心,一个Gunicorn进程拖起来就能跑,不挑服务器。
我当时给管理层的说法是:"用Django等于开一台重型卡车去运几箱货,Flask是辆皮卡,灵活够用还好维修。"当然这个类比不是很严谨,但领导听懂了。
注意:选型这件事上,不要为了技术好看而选重型框架。内部系统生命周期通常三年起步,每多一层抽象,后续维护的人就多一份负担。Flask的直白让后来接手的同事也能快速看懂。
1.2 需求边界要先划清楚
动手写代码之前,我跟HR、行政、财务分别聊了一轮,最后整理出来的核心需求其实只有五条:
- 员工打卡:支持上下班打卡,记录打卡时间、IP地址、定位信息(可配置是否需要)。
- 考勤规则:支持多套规则并存,比如固定班次、弹性班次、排班制。
- 加班管理:员工提交加班申请,审批通过后自动关联打卡时长,按规则折算加班工时。
- 请假对接:先把请假表做好,考勤统计时把请假时段排除掉。
- 报表导出:主管能看到团队日报/月报,HR能导出全员Excel。
这里我要特别强调一条经验:需求不要一次聊完,考勤系统这种东西,上线后一定会有规则调整。所以架构上后面要有意识留出"规则配置"的扩展口子。
2. 数据库表结构:五张核心表,撑起全套考勤业务
考勤系统的地基就是数据库表设计。我当时参考了几套开源方案,又结合我们的实际场景,最终设计出下面这五张核心表(简化后的结构)。
2.1 员工表与部门表
员工表不用多说,除了基本字段以外,有两个字段很关键:work_schedule_type(班次类型)和hire_date(入职日期)。
class Employee(db.Model): __tablename__ = 'employees' id = db.Column(db.Integer, primary_key=True) emp_no = db.Column(db.String(20), unique=True, nullable=False) name = db.Column(db.String(50), nullable=False) department_id = db.Column(db.Integer, db.ForeignKey('departments.id')) work_schedule_type = db.Column(db.String(20), default='fixed') hire_date = db.Column(db.Date) is_active = db.Column(db.Boolean, default=True)work_schedule_type我用了字符串而不是外键关联一张班次表,是因为班次规则是走另一张表的,这里只需要做一个"类型标记"。实际操作中,类型最好和规则表解耦,不然行政调一个班次时间,你就要回头改员工数据。
2.2 考勤规则表:不同部门不同班次的Timer
这是整个系统里最容易被低估的表。很多新手做考勤只想到"上班9点,下班6点",但现实里一个公司会有好几套时间规则:
- 研发部:弹性上班,10:00前打卡不算迟到,但每天必须满8小时。
- 客服部:三班倒,早班8:00-16:00,中班16:00-24:00,夜班0:00-8:00。
- 仓库:两班制,早班7:00-19:00,晚班19:00-7:00,中间有排休。
我设计的规则表长这样:
class AttendanceRule(db.Model): __tablename__ = 'attendance_rules' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(50), nullable=False) # 规则名:如"研发弹性班" start_time = db.Column(db.Time, nullable=False) # 标准上班时间 end_time = db.Column(db.Time, nullable=False) # 标准下班时间 late_threshold = db.Column(db.Integer, default=0) # 迟到容错分钟 early_leeway = db.Column(db.Integer, default=0) # 下班提前容错分钟 is_flexible = db.Column(db.Boolean, default=False) # 是否弹性班次 work_hours = db.Column(db.Float, default=8.0) # 每日标准工时 applicable_dept_ids = db.Column(db.String(255)) # 适用部门ID,逗号分隔这里注意两个细节:
late_threshold和"弹性班次"是两回事。固定班次也可以有5分钟迟到容错;弹性班次意味着"只要晚到,下班时间必须相应顺延",算法上要特殊处理。applicable_dept_ids我用逗号分隔字符串存储,说白了就是为了省一张关联表。有人说这样不规范,但对于内部系统完全够用,查的时候一行FIND_IN_SET(MySQL)或者先取出再拆就行。
2.3 打卡记录表:别漏掉"日期偏移"这种魔鬼细节
打卡记录表是最容易出问题的表,甚至可以说是整个系统的"事故高发地"。
class PunchRecord(db.Model): __tablename__ = 'punch_records' id = db.Column(db.Integer, primary_key=True) employee_id = db.Column(db.Integer, db.ForeignKey('employees.id')) punch_time = db.Column(db.DateTime, nullable=False) punch_date = db.Column(db.Date, nullable=False) # 业务日期,注意不是自然日 punch_type = db.Column(db.String(10)) # 'check_in' / 'check_out' ip_address = db.Column(db.String(64)) location = db.Column(db.String(255)) # 定位信息,可空 source = db.Column(db.String(20), default='web') # 打卡渠道关键的坑在punch_date这个字段。夜班比如仓库晚班19:00到次日7:00,在打卡记录里,凌晨2点打的卡,业务日期应该归属到"上班那天",而不是自然日期。所以我处理夜班班次的时候,会在形成记录时把punch_date往前推一天,所有加班和工时统计都以这个"业务日期"为准。
我在打卡接口里专门写了这么一段:
def get_business_date(rule, punch_datetime): """根据班次规则判断本次打卡归属于哪个业务日期""" # 如果下班时间早于上班时间,说明是跨天班次(晚班/夜班) if rule.end_time < rule.start_time: # 凌晨0点到上班时间之前的打卡,归属到前一天 if punch_datetime.time() < rule.start_time: return punch_datetime.date() - timedelta(days=1) return punch_datetime.date()这段逻辑当时调试了很久,因为测试人员老觉得"我凌晨打卡,怎么日期错了"。其实不是错,是业务归属不同。
2.4 加班申请单与请假单:考勤不是打卡记录单打独斗
加班管理一定要有"申请—审批"这个前置动作,不能只依赖打卡记录。原因很简单:有人干活到晚上8点打卡下班,不代表他申请了加班;反过来,有人申请了加班但临时有事提前走了。所以加班工时应该以"审批通过的申请单"为基础,再拿打卡记录去校正。
加班申请单的核心字段:
class OvertimeApplication(db.Model): __tablename__ = 'overtime_applications' id = db.Column(db.Integer, primary_key=True) employee_id = db.Column(db.Integer, db.ForeignKey('employees.id')) overtime_date = db.Column(db.Date, nullable=False) start_time = db.Column(db.DateTime, nullable=False) end_time = db.Column(db.DateTime, nullable=False) hours = db.Column(db.Float) # 系统按规则自动算 status = db.Column(db.String(20), default='pending') # pending/approved/rejected approved_by = db.Column(db.Integer)请假表类似,核心就一个时间段字段,考勤统计的时候把请假时段从应出勤里踢掉。
2.5 月汇总表:提前算好,别等报表时现算
最后一个核心表是月度汇总表,这张表是给报表和薪资核算用的。设计思路很简单:所有明细数据实时计算,月底定时任务跑一次汇总,写进汇总表。报表页读汇总表,速度飞快;点了"重新计算"可以随时刷新。
class MonthlySummary(db.Model): __tablename__ = 'monthly_summaries' id = db.Column(db.Integer, primary_key=True) employee_id = db.Column(db.Integer, db.ForeignKey('employees.id')) summary_month = db.Column(db.String(7)) # 'YYYY-MM' work_days = db.Column(db.Integer) # 应出勤天数 actual_days = db.Column(db.Float) # 实际出勤天数 late_count = db.Column(db.Integer) early_count = db.Column(db.Integer) overtime_hours = db.Column(db.Float) # 已核准加班工时 leave_hours = db.Column(db.Float) # 请假时长(小时) absences = db.Column(db.Integer) # 旷工天数汇总表的好处你上线后就知道了,财务月底催数据的时候,你不想因为报表页一个慢查询被钉在座位上加班。
3. 打卡接口的业务逻辑:一个简单的路由,藏着五个"鸡蛋里挑骨头"的细节
打卡接口从路由上看很简单,一个POST请求就完了。但真正写业务逻辑的时候,我发现自己低估了"打卡"这种看起来无比简单的动作里到底有多少边界条件。
3.1 基础打卡接口实现
@app.route('/api/punch', methods=['POST']) @login_required def punch(): data = request.get_json() emp = Employee.query.get(current_user.id) rule = get_rule_for_employee(emp) now = datetime.now() # 1. 判断打卡类型:上午第一次打卡为上班,下午/晚上的打卡为下班 punch_type = determine_punch_type(emp.id, now, rule) # 2. 业务日期归属 biz_date = get_business_date(rule, now) # 3. 查重:同一员工同一业务日期同一类型不允许重复打卡 exists = PunchRecord.query.filter_by( employee_id=emp.id, punch_date=biz_date, punch_type=punch_type ).first() if exists: return jsonify({'code': 400, 'msg': '今日已打过该类型卡'}), 400 # 4. 是否允许补卡?如果员工说"我忘了打卡",走补卡流程,不直接入库 # 5. 记录IP和定位(可选) record = PunchRecord( employee_id=emp.id, punch_time=now, punch_date=biz_date, punch_type=punch_type, ip_address=request.remote_addr, location=data.get('location', '') ) db.session.add(record) db.session.commit() # 返回考勤状态给前端展示 return jsonify({'code': 200, 'data': { 'punch_time': now.strftime('%Y-%m-%d %H:%M:%S'), 'punch_type': punch_type, 'status_text': get_status_text(now, rule, punch_type) }})3.2 判断上班卡还是下班卡:比你想的复杂
determine_punch_type这个函数是打卡接口里最容易写错的。直观做法是"上午12点前算上班,12点后算下班",但三班倒一下就崩了——中班下午4点上班,凌晨下班,照这个判断逻辑,下午打开的打卡面板会识别成下班。
我的做法是:
- 固定班次(朝九晚六):用时间窗口中点划分,比如上午12:00前打卡算上班,12:00后算下班。
- 跨天晚班(如19:00到次日7:00):19:00前后各2小时内打卡为上班,凌晨3点后打卡为下班。
- 夜班(0:00-8:00):由于班次本身就跨天了,上班卡判断就得用"当日是否有上班打卡记录"来做兜底,如果没有就允许打"上班卡"。
最稳妥的方案其实不是算法多聪明,而是组合两个规则:时间窗口+当天已有记录类型。如果今天还没有任何记录,那么任何时间点的第一次打卡都算上班卡;如果已经有上班卡记录,后面的都是下班卡。这个逻辑配合特殊班次的时间窗口配置,基本能覆盖所有情况。
def determine_punch_type(employee_id, now, rule): # 先查今天已有记录 today_records = PunchRecord.query.filter_by( employee_id=employee_id, punch_date=get_business_date(rule, now) ).all() # 今天没有任何打卡,本次是上班卡 if not today_records: return 'check_in' # 已有上班卡,本次是下班卡 has_check_in = any(r.punch_type == 'check_in' for r in today_records) if has_check_in: return 'check_out' # 特殊情况:只有下班记录(比如补下班卡) return 'check_in'3.3 防重复打卡、防代打卡、补卡流程
防重复打卡,上面代码里已经有了,但还要考虑极短时间内连续点击的并发问题。Flask开发模式下单线程没事,生产环境用Gunicorn多worker跑起来,两个请求同时进来,就可能两个都查不到记录,然后插入两条。解决办法是在数据库层面加唯一约束:
__table_args__ = ( db.UniqueConstraint('employee_id', 'punch_date', 'punch_type', name='uq_emp_date_type'), )再配合IntegrityError捕获,双保险才稳。
防代打卡经纬度定位和绑定手机MAC这些方案,对我们内部系统来说太重了。我用的是"IP白名单+同一IP多账号告警"。不同部门在不同网段打卡,IP段合法就通过,异常打卡直接标记为"待人工审核"状态。
补卡流程最容易被忽略。员工忘打卡是常态,行政每天都会收到补卡申请。我在系统里做的是:打卡记录表加一个is_makeup字段,补卡走单独接口,必须上传理由,由主管审批后自动写入记录,同时在记录里留痕"补卡"标记,月底报表可以区分正常打卡和补卡。
3.4 弹性班次的迟到/早退计算
弹性班次(研发部那种)是老考勤系统的重灾区。规则是"你10:30到,你下班也得顺延到18:30"。实现思路:
- 每天对弹性班次的员工计算实际工作时长。
- 实际工作时长达到标准工时(比如8小时)就算正常出勤。
- 是否迟到要看有没有超过"最晚到岗时间"这个配置,而不是看与标准上班时间的时间差。
- 早退的判断是:下班打卡时间早于"标准下班时间+顺延补时前的正常时间"。
用SQL算每天的工时:
SELECT employee_id, punch_date, TIMESTAMPDIFF(MINUTE, MAX(CASE WHEN punch_type='check_in' THEN punch_time END), MAX(CASE WHEN punch_type='check_out' THEN punch_time END) ) AS work_minutes FROM punch_records GROUP BY employee_id, punch_date这里只取第一次上班卡和最后一次下班卡,中间的多次打卡忽略。别小看这个细节,我们上线后发现有员工中午出去吃饭打一次卡,下午进来又打一次,如果取平均值就废了。
经验之谈:考勤统计里永远只取"最早的上班卡"和"最晚的下班卡",中间的记录只作为异常检测参考。这个原则帮我避免了很多扯皮。
4. 加班工时计算:为什么你算出来的加班时长总被吐槽
加班是考勤系统里隐藏最深的一堆算术题。你以为的加班很简单:下班打卡时间减下班时间,超过时间就是加班。但现实是——
- 加班最小单位是多少?公司规定0.5小时起算,不足0.5小时不计。
- 工作日加班、休息日加班、法定节假日加班,计算倍率完全不同(1.5倍/2倍/3倍)。
- 有些部门是"审批前置",没申请就不算加班,打卡再晚也没用。
- 倒班的员工,休息日可能是周三,跟你周六日没关系。
4.1 加班规则的配置化设计
我设计了一个简单的加班规则配置。其实本质上是"倍率表+起算阈值+取整规则"的组合:
| 加班类型 | 计算倍率 | 起算阈值 | 取整规则 |
|---|---|---|---|
| 工作日加班 | 1.5 | 超过18:30 | 不足0.5小时不计 |
| 休息日加班 | 2.0 | 只要出勤就按实际 | 不足4小时按4小时计? |
| 法定节假日 | 3.0 | 按实际打卡 | 不足1小时不计 |
每个公司规则都不一样,所以我索性把规则表单独拉出来,让行政页面可视化配置:
class OvertimeRule(db.Model): __tablename__ = 'overtime_rules' id = db.Column(db.Integer, primary_key=True) rule_name = db.Column(db.String(50)) day_type = db.Column(db.String(20)) # workday / weekend / holiday multiplier = db.Column(db.Float) # 1.5 / 2.0 / 3.0 min_threshold = db.Column(db.Integer) # 加班起算分钟 rounding = db.Column(db.String(20)) # none / up_to_hour / up_to_half_hour4.2 工作日加班的计算逻辑
工作日加班很简单:下班打卡时间 - 标准下班时间,减去中间休息时间(如果中间有打卡),再按阈值和取整规则处理。
def calc_workday_overtime(check_out_time, rule, emp): standard_end = datetime.combine(check_out_time.date(), rule.end_time) diff = check_out_time - standard_end overtime_minutes = int(diff.total_seconds() // 60) # 扣除中午休息时间(如果下班卡前有长时间离岗记录) # 应用阈值 min_threshold = emp.dept_overtime_rule.min_threshold if overtime_minutes < min_threshold: return 0 # 取整逻辑 if emp.dept_overtime_rule.rounding == 'up_to_half_hour': overtime_minutes = math.ceil(overtime_minutes / 30) * 30 return round(overtime_minutes / 60, 1)这里有个容易踩坑的点:中午休息时段。有的人中午出去吃饭时间很长,回来接着上班。系统如果只取第一次上班卡和最后一次下班卡,工时会虚高。所以我加了"离岗过滤"——如果上班卡和下班卡之间只有一个很长的午休区间,要能识别出来。
4.3 休息日与法定节假日的判定
判定休息日不能只靠date.weekday(),因为调休(周末上班、工作日放假)在中国职场太常见了。我维护了一张节假日配置表:
class HolidayConfig(db.Model): __tablename__ = 'holiday_configs' id = db.Column(db.Integer, primary_key=True) holiday_date = db.Column(db.Date, unique=True) day_type = db.Column(db.String(20)) # 'holiday' 法定假日 / 'offday' 调休 / 'workday' 调班上班计算加班倍率前先查这张表,而不是直接看星期几:
def get_day_type(date_obj): config = HolidayConfig.query.filter_by(holiday_date=date_obj).first() if config: return config.day_type # 没有配置,默认周一到周五是工作日,周六日是休息日 if date_obj.weekday() < 5: return 'workday' return 'weekend'调休上班这天,员工实际上班了,但这不是加班(公司规定调休上班不算加班,只算正常出勤)。这个判断题必须事先跟HR确认清楚,不然月底财务会拿着报表来找你。
4.4 加班审批和打卡记录的联动
这可能是整个系统价值最直观的模块。流程这样走:
- 员工提交加班申请(预填写日期、预计起止时间、事由)。
- 主管审批,状态变为approved。
- 员工在加班时段内打卡(下班后打卡)。
- 日终跑批任务把"approved申请单"和"实际打卡记录"做匹配,得出最终核准加班时长。
- 核准时长写入月汇总表。
匹配逻辑有一个原则:实际加班以打卡记录为准,但不超过申请时长。比如申请了2小时加班,结果实际待到3小时,系统只算2小时;反过来申请了3小时,实际只待了1小时,系统只算1小时。
我当时做这个匹配的时候还被财务要求加了一条:"提前到达的情况不算加班。"员工18:00下班,18:30才算加班起算点,那18:00到18:30这半小时不算。这个中间地带靠起算阈值处理。
配合这个场景,加工时的最终代码如下:
def match_overtime(application, punch_out): # 核准时长 = min(申请结束时间, 实际打卡时间) - max(申请开始时间, 标准下班时间) effective_start = max(application.start_time, standard_end(application.overtime_date)) effective_end = min(application.end_time, punch_out.punch_time) overtime_minutes = (effective_end - effective_start).total_seconds() / 60 # 取整 return round_to_rule(overtime_minutes, application.rule)核心提醒:加班工时算得准不准,80%取决于规则定义是否清晰,剩下20%才是代码。你写代码之前,一定要拉着财务把"提前到算不算""不足半小时计不计""上限有没有"这几个问题问死,不然后面改配置加字段,你会想掐死"当时的自己"。
5. 报表统计和Excel导出:行政财务最关心的一环
考勤系统不用做得很花哨,但报表一定要好用。行政和财务阿姨们每天跟Excel打交道,你给她们一个好看的后台列表页,不如给一个"一键导出Excel"的按钮。
5.1 日报/月报的统计口径
我做的报表分三个层级:
- 员工自查询:自己当天的打卡时间、迟到早退状态、本周加班累计。
- 部门主管视图:团队日考勤列表,今天的迟到名单一眼可见。
- HR/财务视图:全公司月度汇总表,支持按月/按部门筛选,导出Excel。
统计口的计算就是我在前面设计的月汇总表,月底最后一天晚上跑定时任务,一次性算出所有员工的出勤、迟到、早退、加班、请假数据。
用SQLAlchemy写月度汇总的核心查询:
from sqlalchemy import func, extract summary = db.session.query( PunchRecord.employee_id, func.count(func.distinct(PunchRecord.punch_date)).label('actual_days'), func.sum(db.case((PunchRecord.is_late == True, 1), else_=0)).label('late_count') ).filter( extract('year', PunchRecord.punch_date) == year, extract('month', PunchRecord.punch_date) == month ).group_by(PunchRecord.employee_id).all()实际上"迟到"不应该是打卡记录表上的布尔字段,而是每天跑批时算出来的状态存到日汇总表里,避免报表页每次都现场算。日记表可以这样:
class DailyAttendance(db.Model): __tablename__ = 'daily_attendance' id = db.Column(db.Integer, primary_key=True) employee_id = db.Column(db.Integer, db.ForeignKey('employees.id')) att_date = db.Column(db.Date, nullable=False) first_punch_in = db.Column(db.DateTime) last_punch_out = db.Column(db.DateTime) status = db.Column(db.String(20)) # normal / late / early_leave / absent work_hours = db.Column(db.Float) overtime_hours = db.Column(db.Float) leave_hours = db.Column(db.Float)每天凌晨跑一次前一天的汇总,把所有判断结果固化。这样做的好处是:月底报表查询再复杂,也只是一张表的SELECT,不会出现多表JOIN把数据库拖垮的情况。
5.2 导出Excel:用openpyxl还是pandas
导出Excel这一步我纠结了一会儿。pandas的to_excel确实是最快的方案,几行代码搞定。但对于"考勤导出"这种表格样式有明确要求(合并单元格、特殊表头、列宽固定)的场景,pandas的输出就比较粗糙了。我最后选了openpyxl直接操作,因为要合并表头、设置边框、冻结首行这些细节。
一个实用的导出片段:
from openpyxl import Workbook from openpyxl.styles import Font, Alignment, Border, Side from openpyxl.utils import get_column_letter def export_monthly_excel(year, month, dept_id=None): wb = Workbook() ws = wb.active ws.title = f"{year}-{month}月度考勤" headers = ['工号', '姓名', '部门', '应出勤', '实际出勤', '迟到', '早退', '加班工时', '请假工时', '旷工'] thin_border = Border( left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin') ) # 写入表头并设置样式 for col, header in enumerate(headers, 1): cell = ws.cell(row=1, column=col, value=header) cell.font = Font(bold=True) cell.border = thin_border cell.alignment = Alignment(horizontal='center') # 写入数据 row = 2 for emp in employees: ws.cell(row=row, column=1, value=emp.emp_no) ws.cell(row=row, column=2, value=emp.name) # ... 依次写入 # 给迟到>0的行标红 if late_count > 0: ws.cell(row=row, column=6).font = Font(color='FF0000') row += 1 # 固定列宽和冻结首行 for i in range(1, len(headers)+1): ws.column_dimensions[get_column_letter(i)].width = 14 ws.freeze_panes = 'A2' # 返回给前端下载 bio = BytesIO() wb.save(bio) return send_file(bio, as_attachment=True, download_name=f'考勤汇总_{year}_{month}.xlsx')5.3 报表页性能优化:不要在列表页做聚合
本来想偷懒在列表页每次请求时现场聚合,结果公司150号人、3个月的数据,列表页直接卡了2秒多。后来把逻辑改成上面说的"每日跑批+月底汇总",页面秒开。
另外一个优化是加Redis缓存。Excel导出的数据,如果参数相同,结果缓存30分钟。财务反复改筛选条件的时候,就能明显感觉到区别。
6. 部署上线复盘:内网系统的那些坑
内网部署这套系统,看着简单,但等你真的下手就知道,问题全在你想不到的地方。
6.1 Gunicorn + Nginx:并发和静态资源
Flask自带的开发服务器(werkzeug)绝对不能用于生产,这个是老生常谈了。我用的是gunicorn + nginx的组合,进程数设成服务器的CPU核数×2+1。
gunicorn -w 4 -b 127.0.0.1:5000 app:appnginx反向代理,同时接管静态文件和前端页面:
server { listen 8080; server_name attendance.company.local; location / { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /static/ { alias /data/attendance/static/; expires 7d; } }注意:内网系统也必须加Token过期机制和操作日志。我上线第一周就被吐槽"为什么没有登录日志",后来补了操作日志表,每个关键操作(打卡异常处理、补卡审批、规则修改)都记一条。这东西平时没用,一旦出现考勤纠纷,就是你跟员工摆事实讲道理的底气。
6.2 同一台服务器上MySQL和SQLite怎么选
我一开始图省事用了SQLite,因为零配置、本地文件即数据库。结果不到一个月就遇到锁冲突——几个部门同事同时打卡,SQLite的写入锁处理开始报database is locked。
后来还是切到MySQL了。原因很简单:并发打卡写入 + 月底聚合查询,需要一个真正的数据库引擎。如果你团队规模不超过30人,SQLite勉强可以撑住,但一旦超过,别犹豫,直接上MySQL。
6.3 时区和服务器时间同步
这个坑特别隐蔽。考勤系统对时间极度敏感,服务器时间不准,所有打卡记录全废。我遇到过两起:
- 服务器是UTC时间,没设置Asia/Shanghai,前端显示的时间整整快了8小时。
- 服务器有硬件时钟漂移,一个多月下来慢了3分钟,导致有员工明明准点打卡却被判迟到。
解决办法其实很简单但很容易被忽略:
- 服务器安装NTP服务,每天自动同步时间。
- 所有时间存储统一用
datetime.now()还是datetime.utcnow()要想清楚。我用的是datetime.now()直接存本地时间,因为系统只服务国内一个时区,没必要引入复杂的时间转换层。如果哪天要跨时区部署,再改UTC存储、展示时本地化。
前端展示建议:
function formatTime(dt) { // 后端返回的已经是Asia/Shanghai时间,直接用 return dt.replace('T', ' ').substring(0, 19); }6.4 打卡高峰期并发:一个"看起来不是问题"的问题
每天早上8:50到9:10是打卡高峰,同时在线人数可能从30人瞬间飙到120人。Gunicorn同步worker模式在这时候每个worker一次只能处理一个请求,如果打卡接口刚好在做数据库查询,请求就排队了。用户端表现为"点击打卡按钮转圈圈"。
解决办法:
- Gunicorn加
--threads 4,启用多线程模式。 - 打卡接口的所有数据库操作用事务,但尽量缩短事务时间。
- 静态页面、HTML模板交给nginx直接返回,不要经过Flask。
实测调整后,打卡接口的P95响应时间从1.2秒降到了200毫秒以内,完全够用。
7. 数据看板:管理者要的"一眼看清"
做了报表和导出以后,管理层的下一个需求自然就是"能不能有个首页一眼看到全公司的考勤情况"。
7.1 迟到排行榜和无人在岗统计
管理者最关心两个问题:今天有谁迟到了?现在有多少人在上班?
实现也不复杂,一个首页接口,返回当天实时状态:
@app.route('/api/dashboard/today') @admin_required def dashboard_today(): today = date.today() # 今日迟到名单 late_list = DailyAttendance.query.filter_by( att_date=today, status='late' ).all() # 当前在岗人数 = 已打过上班卡且还没打过下班卡 now = datetime.now() on_duty = db.session.query(PunchRecord.employee_id).filter( PunchRecord.punch_date == today, PunchRecord.punch_type == 'check_in', ~PunchRecord.employee_id.in_( db.session.query(PunchRecord.employee_id).filter( PunchRecord.punch_date == today, PunchRecord.punch_type == 'check_out' ) ) ).count() return jsonify({ 'late_count': len(late_list), 'late_names': [e.name for e in late_list], 'on_duty_count': on_duty, 'total_employees': Employee.query.filter_by(is_active=True).count() })7.2 用ECharts画个简版趋势图
考勤数据适合展示的图有两类:每日出勤率折线图和部门加班时长对比柱状图。前端用ECharts,后端只给聚合数据,所有图表渲染都交给浏览器。
@app.route('/api/dashboard/daily_trend') def daily_trend(): """过去30天的出勤率""" start_date = date.today() - timedelta(days=30) rows = db.session.query( DailyAttendance.att_date, func.count(DailyAttendance.id).label('total'), func.sum(db.case((DailyAttendance.status == 'normal', 1), else_=0)).label('normal') ).filter( DailyAttendance.att_date >= start_date ).group_by(DailyAttendance.att_date).all() return jsonify({ 'dates': [str(r.att_date) for r in rows], 'rates': [round(r.normal / r.total * 100, 1) if r.total else 0 for r in rows] })这里的"率"我用的是正常出勤人数除以当天应出勤总人数,设定口径的时候跟HR确认过,避免后续解释分歧。
8. 防呆设计和权限设计:管理者想看到的成熟度
到这里功能已经齐全了,但能不能给管理者留下"这系统靠谱"的印象,关键还差最后两块拼图。
8.1 角色权限:员工、主管、HR、超管
我用Flask-Login管理登录态,自己写了一个简单的装饰器做角色控制:
from functools import wraps from flask import session, jsonify def role_required(*roles): def decorator(f): @wraps(f) def wrapper(*args, **kwargs): if not session.get('user_id'): return jsonify({'code': 401, 'msg': '未登录'}), 401 user_role = session.get('user_role') if user_role not in roles: return jsonify({'code': 403, 'msg': '没有权限'}), 403 return f(*args, **kwargs) return wrapper return decorator权限矩阵是:
- 员工:打卡,查询自己的考勤记录,提交加班/补卡申请。
- 部门主管:查看本部门考勤列表,审批加班/补卡。
- HR:管理员工信息、规则配置、查看全公司报表及导出。
- 超管:全部权限,包括日志管理、部门配置。
有个细节值得提一下:主管查部门数据时,SQL里一定要限制department_id == current_user.department_id,不然主管直接拼URL把ID改了就能查别的部门,这种漏洞很Low但很容易犯。
8.2 操作日志:考勤系统的"后悔药"
我加了一个简单的操作日志表,记录谁在什么时间干了什么:
class OperationLog(db.Model): __tablename__ = 'operation_logs' id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('employees.id')) action = db.Column(db.String(20)) # create / update / delete / approve target = db.Column(db.String(50)) # 操作对象类型 target_id = db.Column(db.Integer) # 对象ID detail = db.Column(db.Text) # 变更详情 created_at = db.Column(db.DateTime, default=datetime.now)别小看这个表,考勤纠纷处理的时候,谁能改数据一目了然。我之前在另一个项目里因为没做日志,被财务怀疑偷偷改了别人的考勤记录,虽然最后查清楚了,但过程非常尴尬。给所有修改操作做留痕,是最简单的信任保险。
9. 上线后最常见的几个反馈和对应的优化
系统上线两个月,我收了一箩筐反馈,挑几个有代表性的说说。
9.1 "我明明打了卡,为什么显示未打卡?"
这类问题90%出在打卡记录归属的业务日期上。客服部上夜班的小姑娘,凌晨0点30分打卡,系统归到前一天。她打开系统看到"今天"没有打卡记录,立刻慌了。
解决办法是把前端页面默认显示逻辑改成"显示最近7天记录,按业务日期展示",并且在日期旁标注班次名称,让员工看得明白"系统记到了昨天"。
9.2 "弹性班次为什么我早走了10分钟就算早退"
弹性班次的核心是工作时长,而不是上下班时间。但员工的心态是"我今天来得早,走得早,凭什么算早退"。这种问题本质是规则沟通不到位。
我在前端考勤日历上给每一天加了一个小图标:绿色代表正常,黄色代表迟到/早退但工时达标,红色代表确认为异常。再点进去,能看到系统判定依据:"今日工作时长7小时50分,不足标准8小时"。把判定依据摊开,争议少了一大半。
9.3 "加班时长能不能自动算,不要每次提交申请"
这句建议是我最不能接受的。考勤系统里"自动"和"可靠"经常是矛盾的。如果完全按打卡记录自动算加班,员工只是下班后留在工位上玩手机到9点,打卡记录就"自动加班"了,财务那边会有巨大争议。所以我宁可让员工提交申请、主管审批,虽然多了一步操作,但流程上站得住脚。
当然,对于研发这种加班属于常态的部门,我加了一个"批量补提加班申请"功能:月底让研发主管勾选加班日期,系统自动拉起申请单并批量审批。这样既保证了流程完整,又减少了重复劳动。
10. 最后的几句实在话
这套系统从头到尾大概花了两周多,其中纯写代码时间其实只有一半,另一半全花在和HR、财务对规则上了。现在回看,最大的心得就是:考勤系统的技术难度真的不大,真正的复杂度全在业务规则的梳理和沟通上。
如果你也打算自己动手做一套,我的建议是:
- 先写规则文档,再写代码。把"迟到怎么算""加班怎么算""调休怎么算"先跟业务方一条条确认清楚,拿他们签字确认的文档再开工。
- 预留配置项。无论你现在觉得规则多清晰,上线后一定会改。把班次、节假日、加班起算阈值、迟到容错全做成可配置的,别写死在代码里。
- 打卡数据只增不改。物理删除要绝对禁止,所有修改走补卡流程和操作日志,这是考勤系统的铁律。
- 给行政和HR足够的自助能力。别让她们每次遇到规则调整都来找你改数据库。你多做一天后台配置页面,以后少接十个需求电话。
说到底,考勤打卡系统是那种"没用的时候觉得无所谓,用起来处处是细节"的项目。真正让它稳定运行的,从来不是某个炫酷的算法,而是一堆不起眼的边界情况都被妥善处理了。如果你也在做类似的东西,希望这篇文章能帮你少踩几个坑。