news 2026/9/22 9:56:32

工厂考勤系统避坑指南:3个致命Bug让你少加班

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
工厂考勤系统避坑指南:3个致命Bug让你少加班

工厂考勤系统避坑指南:3个致命Bug让你少加班

刚接手工厂考勤模块,控制台全是红字,StackTrace 长得像天书,连哪一行代码报的错都找不到。别慌,这种“报错一堆看不懂 StackTrace”的情况,在制造业信息化改造中太常见了。今天这份避坑指南,就是把你从泥潭里拽出来的绳索。

我们不做那种“大而全”的理论推导,直接上代码。这个项目基于 Java Spring Boot + MyBatis Plus,数据库用 MySQL,前端用 Vue3。目标很明确:实现员工打卡、异常标记、月度统计,且能扛住早高峰 500 人同时打卡的并发压力。

项目目标与核心难点拆解

很多新手一上来就写代码,结果写到一半发现逻辑不通。我们先定目标。工厂考勤和互联网公司不同,核心痛点不在“功能多”,而在数据一致性边缘场景处理

  1. 并发安全:早 8 点整,车间大门口的闸机同时接收 500 个请求,如果数据库行锁没处理好,要么死锁,要么数据丢失。
  2. 异常判定逻辑复杂:迟到、早退、旷工、请假、加班,这些状态不是简单的“时间比较”,还涉及跨天、夜班、轮班制。
  3. 数据追溯:一旦算错工资,HR 需要能查出具体的打卡记录、审批流程和计算公式,这就对日志和审计表提出了要求。

我们的架构选型坚持“够用就好”。不用微服务,单体应用足够支撑千人规模工厂。引入 Redis 做缓存,不是为了解决高并发读取,而是为了防重提交状态预判断

目录结构:扁平化优于深层嵌套

好的目录结构是代码可维护性的第一道防线。我强烈建议采用分层架构,但层级不要超过 3 层。以下是核心模块的目录树,注意看 service 包下的 implstrategy 的区别,这是解决复杂业务逻辑的关键。

src/main/java/com/factory/attendance/
├── controller
│   ├── PunchController.java       # 打卡接口
│   └── ReportController.java      # 报表接口
├── service
│   ├── AttendanceService.java     # 核心服务接口
│   ├── impl
│   │   └── AttendanceServiceImpl.java # 核心逻辑实现
│   └── strategy                   # 策略模式处理不同班次逻辑
│       ├── DayShiftStrategy.java
│       └── NightShiftStrategy.java
├── mapper
│   └── EmployeePunchMapper.java
├── model
│   ├── entity
│   │   └── EmployeePunch.java     # 打卡记录实体
│   ├── dto
│   │   └── PunchResultDTO.java    # 返回给前端的对象
│   └── vo
│       └── MonthlyReportVO.java   # 月度统计视图对象
└── common├── exception│   └── AttendanceException.java # 自定义异常└── util└── TimeCalculator.java    # 时间计算工具类

避坑点:不要把所有时间计算逻辑都写在 Service 里。工厂的班次规则经常变(比如某个月搞“两班倒”),如果逻辑硬编码在 Service 中,改一次需求就要发一次版。使用策略模式,将“日班”、“夜班”、“大小周”的计算逻辑抽象成独立类,新增班次只需新增一个实现类,符合开闭原则。

核心代码实现:并发与时间计算

这是最硬核的部分。我选取了两个最容易出 Bug 的场景:并发打卡防重、跨天夜班时间计算。

1. 并发打卡:Redis 分布式锁 + 数据库唯一索引

很多初学者喜欢用 SELECT FOR UPDATE 或者 synchronized,在集群环境下都是灾难。正确的做法是“双保险”:Redis 做前置过滤,数据库唯一索引做最终兜底。

@Service
public class AttendanceServiceImpl implements AttendanceService {@Autowiredprivate StringRedisTemplate redisTemplate;@Autowiredprivate EmployeePunchMapper punchMapper;@Overridepublic PunchResultDTO punchCard(String employeeId, Long timestamp) {// 1. 生成唯一键:员工ID + 日期 + 打卡类型(0:上班, 1:下班)// 假设前端传入 timestamp,我们先判断是上班还是下班boolean isMorning = isMorningPunch(timestamp);String lockKey = "att:lock:" + employeeId + ":" + LocalDate.now() + ":" + (isMorning ? 0 : 1);// 2. Redis 设置过期时间锁,防止死锁Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS);if (!locked) {throw new AttendanceException("请勿重复提交,正在处理中");}try {// 3. 业务逻辑:查询该员工今日是否已有记录EmployeePunch existPunch = punchMapper.selectByEmployeeAndDate(employeeId, LocalDate.now());if (existPunch != null) {// 4. 如果已有记录,判断是否允许补卡或覆盖(根据业务规则)// 这里简化处理:直接返回已有状态,避免重复写入return buildResult(existPunch, "Duplicate");}// 5. 构建新记录EmployeePunch newPunch = new EmployeePunch();newPunch.setEmployeeId(employeeId);newPunch.setPunchTime(new Date(timestamp));newPunch.setType(isMorning ? 0 : 1);newPunch.setStatus(calcStatus(newPunch)); // 计算迟到/正常// 6. 插入数据库// 注意:数据库表中 employee_id, punch_date, type 必须建立联合唯一索引punchMapper.insert(newPunch);return buildResult(newPunch, "Success");} catch (DuplicateKeyException e) {// 7. 捕获数据库唯一索引冲突,说明 Redis 锁失效或并发极高// 此时不能抛错给用户,应查询数据库返回真实状态EmployeePunch dbPunch = punchMapper.selectByEmployeeAndDate(employeeId, LocalDate.now());return buildResult(dbPunch, "Conflict");} finally {// 8. 无论成功失败,必须释放锁redisTemplate.delete(lockKey);}}
}

逐行解析

  • L12-L15:锁的 Key 设计至关重要。必须包含日期和类型,否则员工上午打了卡,下午下班打卡会被锁住。
  • L28-L30setIfAbsent 是原子操作,这是分布式锁的基础。
  • L42-L45calcStatus 是纯函数,不依赖外部状态,方便单元测试。
  • L47-L49这是最关键的避坑点。即使有 Redis 锁,由于网络抖动、Redis 故障或锁过期,仍可能出现并发写入。数据库的 DuplicateKeyException 是最后的防线。很多开发者在这里直接 throw e,导致用户看到 500 错误,体验极差。正确做法是捕获异常,查询数据库真实状态返回给前端。

2. 跨天夜班时间计算

工厂夜班经常从晚上 22:00 干到第二天 06:00。如果简单用 endTime - startTime,当 endTime 的日期小于 startTime 时,结果为负数。

public class TimeCalculator {/*** 计算工作时长,支持跨天* @param startTime 开始时间* @param endTime 结束时间* @return 毫秒数*/public static long calcDuration(LocalDateTime startTime, LocalDateTime endTime) {if (startTime == null || endTime == null) {throw new IllegalArgumentException("时间不能为空");}// 如果结束时间小于开始时间,说明跨天了if (endTime.isBefore(startTime)) {// 将开始时间减去 1 天,使时间差为正// 注意:这里不能直接 endTime.plusDays(1),因为如果是跨月、跨年,逻辑可能出错// 最稳妥的方式是:判断是否跨天,如果跨天,将 startTime 的日期部分减 1startTime = startTime.minusDays(1);}return Duration.between(startTime, endTime).toMillis();}
}

避坑点:不要用 new Date().getTime() 做差值计算,Java 8 之后的 LocalDateTimeDuration 是线程安全的,且语义更清晰。另外,不要假设所有夜班都跨天。有些工厂夜班是 20:00-04:00,有些是 22:00-06:00,必须根据配置表动态判断,或者统一规定“只要 endTime 的 hour 小于 startTime 的 hour 即视为跨天”。

运行与测试:如何验证你的避坑是否有效

代码写完只是完成了一半,测试才是灵魂。对于考勤系统,边界条件测试比功能测试更重要。

1. 单元测试:使用 JUnit 5 + Mockito

针对 TimeCalculatorAttendanceServiceImpl 的核心逻辑编写测试。

@Test
public void testCrossDayCalculation() {LocalDateTime start = LocalDateTime.of(2023, 10, 1, 22, 0, 0);LocalDateTime end = LocalDateTime.of(2023, 10, 2, 6, 0, 0);long duration = TimeCalculator.calcDuration(start, end);long expectedHours = 8 * 60 * 60 * 1000; // 8小时assertEquals(expectedHours, duration);
}@Test
public void testDuplicatePunch() {// Mock Mapper 和 Rediswhen(punchMapper.selectByEmployeeAndDate(any(), any())).thenReturn(null);when(punchMapper.insert(any())).thenThrow(new DuplicateKeyException("Dup"));when(punchMapper.selectByEmployeeAndDate(any(), any())).thenReturn(mockPunch);// 执行PunchResultDTO result = service.punchCard("emp001", System.currentTimeMillis());// 验证:不应该抛异常,而是返回数据库中的记录assertNotNull(result);assertEquals("Conflict", result.getMessage());
}

2. 压力测试:模拟早高峰

使用 JMeter 或 Locust 模拟 500 个线程,在同一秒内调用 punchCard 接口。

观察指标

  1. 数据库死锁日志:检查 MySQL error log,是否有 Deadlock found
  2. Redis 内存:锁的 Key 是否及时清除。如果 finally 块中的 delete 失败,锁会保留 5 秒,这 5 秒内该员工无法打卡,这是可接受的降级。
  3. 响应时间:P99 延迟应控制在 200ms 以内。

真实案例:我在某项目中发现,当 500 并发时,punchMapper.insert 成为瓶颈。原因是 MyBatis Plus 默认的批量插入策略未启用。解决方案:

  1. 开启 rewriteBatchedStatements=true JDBC 参数。
  2. 或者在 Service 层做内存缓冲,每 10 条 flush 一次。但对于考勤这种强一致性要求,单条插入+唯一索引更可靠,牺牲一点吞吐量换取数据绝对准确。

优化扩展:从“能用”到“好用”

系统上线后,HR 最常抱怨的是“统计报表慢”和“补卡流程繁琐”。

1. 报表性能优化

月度统计涉及 GROUP BY 和大量聚合计算。如果直接查主表,百万级数据量下耗时可能超过 10 秒。

方案

  • 预计算表:每日凌晨 0 点,通过定时任务(Quartz/Xxl-Job)计算前一天的考勤结果,存入 daily_attendance_summary 表。
  • 查询优化:前端查月度报表时,只查 daily_attendance_summary,再对日期进行聚合。
  • 索引设计employee_id, stat_date 建立联合索引。

2. 补卡与异常申诉

工厂员工经常忘记打卡,或闸机故障。

流程设计

  1. 员工发起补卡申请,填写原因。
  2. 班组长审批。
  3. 审批通过后,不要直接修改原始打卡记录!而是新增一条 type=2 (补卡) 的记录,并标记原始记录为 void (作废)
  4. 计算工时和工资时,优先读取有效记录。

为什么不能修改原记录?

  • 审计合规:劳动法要求保留原始证据。
  • 数据追溯:如果算错工资,能查到是谁、什么时候、因为什么原因修改了数据。

小结与互动

工厂考勤系统看似简单,实则充满了并发陷阱、时间边界和业务流程的复杂耦合。

回顾一下今天的避坑指南核心:

  1. 并发防重:Redis 锁 + DB 唯一索引双保险,捕获 DuplicateKeyException 而非抛错。
  2. 时间计算:使用 LocalDateTime,专门处理跨天逻辑,策略模式解耦班次规则。
  3. 数据一致性:补卡不覆盖,用“新增+作废”模式保留审计痕迹。
  4. 性能优化:预计算汇总表,避免实时复杂聚合。

代码不是越多越好,而是越“稳”越好。在制造业场景,系统的稳定性远比功能炫酷重要。

互动时间: 在你过往的项目中,你是倾向于直接修改数据库记录来简化补卡逻辑,还是像我这样保留原始记录并新增补卡记录?虽然后者开发成本高,但数据更安全。你更常用哪种写法?评论区交流,看看大家是怎么平衡开发效率与数据安全的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/22 9:55:52

3个坑带你搞懂黑鸟单车源码解析与架构选型

3个坑带你搞懂黑鸟单车源码解析与架构选型 盯着屏幕上一长串红色的 StackTrace,是不是感觉脑子里像塞了一团浆糊?明明只改了一行配置,结果整个黑鸟单车的后台直接崩了,日志里全是 NullPointerException 和 Connection Timeout…

作者头像 李华
网站建设 2026/9/22 9:55:52

3招搞定广角畸变:从报错到性能优化的实战指南

3招搞定广角畸变:从报错到性能优化的实战指南 刚转行做游戏开发的朋友,是不是经常遇到这种尴尬:Python 语法背得滚瓜烂熟,OpenCV 的 API 查文档也能跑通,但真到了项目里处理摄像机镜头畸变时,代码一跑就卡死,或者画面边缘拉伸得像被扯烂的橡皮泥?这就是典型的“学会语法却不知怎么搭项目”。很…

作者头像 李华
网站建设 2026/9/22 9:55:22

cmd切换目录总报错?3个最佳实践让你告别路径噩梦

cmd切换目录总报错?3个最佳实践让你告别路径噩梦 复制来的代码跑不通,报错信息里全是“找不到路径”或“拒绝访问”,你是不是也盯着屏幕发呆,不知道从哪下手调试?别急,这其实是 cmd 切换目录时最典型的坑,尤其是新手在 Windows 环境下操作时,极易因路径格式、权限或命令混淆导致失败。掌握…

作者头像 李华
网站建设 2026/9/22 9:55:22

别被第二次考试吓退 源码解析助你一次通关

别被第二次考试吓退 源码解析助你一次通关 看了一堆教程还是不会写项目?这是无数开发者的噩梦。很多人对着文档发呆,觉得理论懂了就等于会了,结果一动手就崩。其实,问题往往出在你对底层逻辑的模糊认知上。今天咱们不聊虚的,直接拆解【第二次考试】背后的机制,通过源码解析让你看清它到底在考什么。…

作者头像 李华
网站建设 2026/9/22 9:55:22

小俊面试突击:搞定配置难题,从入门到精通

小俊面试突击:搞定配置难题,从入门到精通 刚接手新项目,配置环境就卡半天?这是很多开发者,包括我们团队里的“小俊”,都遇到过的噩梦。依赖冲突、版本不对、环境变量丢失,光看报错信息就能让人头秃。…

作者头像 李华
网站建设 2026/9/22 9:55:10

有线网卡驱动下载提速指南:从入门到精通的避坑实战

有线网卡驱动下载提速指南:从入门到精通的避坑实战 版本升级后 API 全变了,导致你的有线网卡驱动下载脚本直接报错?别慌,这种因底层接口变动引发的性能瓶颈和稳定性问题,是无数开发者和运维人员从入门到精通必经的“鬼门关”。很多老项目里硬编码的驱动获取方式,在新系统或新内核下不仅失效,还会因为网络重试机…

作者头像 李华