ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

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

工厂考勤系统避坑指南:3个致命Bug让你少加班 工厂考勤系统避坑指南:3个致命Bug让你少加班 刚接手工厂考勤模块,控制台全是红字,StackTrace 长得像天书,连哪一行代码报的错都找不到。别慌,这种“报错一堆看不懂 StackTrace”的情况,在制造业信息化改造中太常见了。今天这份避坑指南,就是把你从泥潭里拽出来的绳索。 我们不做那种“大而全”的理论推导,直接上代码。这个项目基于 Java Spring Boot + MyBatis Plus,数据库用 MySQL,前端用 Vue3。目标很明确:实现员工打卡、异常标记、月度统计,且能扛住早高峰 500 人同时打卡的并发压力。 项目目标与核心难点拆解 很多新手一上来就写代码,结果写到一半发现逻辑不通。我们先定目标。工厂考勤和互联网公司不同,核心痛点不在“功能多”,而在数据一致性和边缘场景处理。并发安全:早 8 点整,车间大门口的闸机同时接收 500 个请求,如果数据库行锁没处理好,要么死锁,要么数据丢失。 异常判定逻辑复杂:迟到、早退、旷工、请假、加班,这些状态不是简单的“时间比较”,还涉及跨天、夜班、轮班制。 数据追溯:一旦算错工资,HR 需要能查出具体的打卡记录、审批流程和计算公式,这就对日志和审计表提出了要求。我们的架构选型坚持“够用就好”。不用微服务,单体应用足够支撑千人规模工厂。引入 Redis 做缓存,不是为了解决高并发读取,而是为了防重提交和状态预判断。 目录结构:扁平化优于深层嵌套 好的目录结构是代码可维护性的第一道防线。我强烈建议采用分层架构,但层级不要超过 3 层。以下是核心模块的目录树,注意看 service 包下的 impl 和 strategy 的区别,这是解决复杂业务逻辑的关键。 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-L30:setIfAbsent 是原子操作,这是分布式锁的基础。 L42-L45:calcStatus 是纯函数,不依赖外部状态,方便单元测试。 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 之后的 LocalDateTime 和 Duration 是线程安全的,且语义更清晰。另外,不要假设所有夜班都跨天。有些工厂夜班是 20:00-04:00,有些是 22:00-06:00,必须根据配置表动态判断,或者统一规定“只要 endTime 的 hour 小于 startTime 的 hour 即视为跨天”。 运行与测试:如何验证你的避坑是否有效 代码写完只是完成了一半,测试才是灵魂。对于考勤系统,边界条件测试比功能测试更重要。 1. 单元测试:使用 JUnit 5 + Mockito 针对 TimeCalculator 和 AttendanceServiceImpl 的核心逻辑编写测试。 @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 接口。 观察指标:数据库死锁日志:检查 MySQL error log,是否有 Deadlock found。 Redis 内存:锁的 Key 是否及时清除。如果 finally 块中的 delete 失败,锁会保留 5 秒,这 5 秒内该员工无法打卡,这是可接受的降级。 响应时间:P99 延迟应控制在 200ms 以内。真实案例:我在某项目中发现,当 500 并发时,punchMapper.insert 成为瓶颈。原因是 MyBatis Plus 默认的批量插入策略未启用。解决方案:开启 rewriteBatchedStatements=true JDBC 参数。 或者在 Service 层做内存缓冲,每 10 条 flush 一次。但对于考勤这种强一致性要求,单条插入+唯一索引更可靠,牺牲一点吞吐量换取数据绝对准确。优化扩展:从“能用”到“好用” 系统上线后,HR 最常抱怨的是“统计报表慢”和“补卡流程繁琐”。 1. 报表性能优化 月度统计涉及 GROUP BY 和大量聚合计算。如果直接查主表,百万级数据量下耗时可能超过 10 秒。 方案:预计算表:每日凌晨 0 点,通过定时任务(Quartz/Xxl-Job)计算前一天的考勤结果,存入 daily_attendance_summary 表。 查询优化:前端查月度报表时,只查 daily_attendance_summary,再对日期进行聚合。 索引设计:employee_id, stat_date 建立联合索引。2. 补卡与异常申诉 工厂员工经常忘记打卡,或闸机故障。 流程设计:员工发起补卡申请,填写原因。 班组长审批。 审批通过后,不要直接修改原始打卡记录!而是新增一条 type=2 (补卡) 的记录,并标记原始记录为 void (作废)。 计算工时和工资时,优先读取有效记录。为什么不能修改原记录?审计合规:劳动法要求保留原始证据。 数据追溯:如果算错工资,能查到是谁、什么时候、因为什么原因修改了数据。小结与互动 工厂考勤系统看似简单,实则充满了并发陷阱、时间边界和业务流程的复杂耦合。 回顾一下今天的避坑指南核心:并发防重:Redis 锁 + DB 唯一索引双保险,捕获 DuplicateKeyException 而非抛错。 时间计算:使用 LocalDateTime,专门处理跨天逻辑,策略模式解耦班次规则。 数据一致性:补卡不覆盖,用“新增+作废”模式保留审计痕迹。 性能优化:预计算汇总表,避免实时复杂聚合。代码不是越多越好,而是越“稳”越好。在制造业场景,系统的稳定性远比功能炫酷重要。 互动时间: 在你过往的项目中,你是倾向于直接修改数据库记录来简化补卡逻辑,还是像我这样保留原始记录并新增补卡记录?虽然后者开发成本高,但数据更安全。你更常用哪种写法?评论区交流,看看大家是怎么平衡开发效率与数据安全的。
返回列表