点狮HRM-考勤打卡灵活规则引擎设计与实现
·
一、业务背景与挑战
1.1 企业考勤管理痛点
在企业考勤管理中,传统的固定打卡制度往往难以满足多样化的业务需求。不同的企业、不同的岗位、不同的工作方式,需要灵活的打卡规则。
传统考勤制度的问题:
- 一刀切问题:所有员工使用相同的打卡规则,缺乏灵活性
- 员工体验差:严格的打卡时间导致员工满意度下降
- 管理成本高:需要处理大量的异常打卡申请
- 效率损失:员工为了不迟到而提前到岗,造成隐性工时浪费
- 适应性差:无法适应弹性工作制、远程办公等新型工作方式
相关链接:
- 🌐 官网:http://www.dianshixinxi.com/
- 📱 演示站:http://cloud.dianshixinxi.com:90/
- 🎨 Gitee:https://gitee.com/glorylion/JFinalOA
- 💻 GitCode:https://gitcode.com/Glory_Lion/pointlion-cloud

1.2 灵活打卡的价值
什么是灵活打卡:
灵活打卡是一种人性化的考勤管理制度,在保证工作时长和质量的前提下,允许员工在一定范围内灵活调整上下班时间。
灵活打卡的业务价值:
- 提升员工满意度:减少打卡压力,改善工作体验
- 提高工作效率:员工可以在状态最佳时工作
- 降低管理成本:减少异常打卡处理的审批流程
- 增强组织适应性:适应弹性工作制和远程办公
- 保证工作质量:通过规则设计确保工作时长

1.3 技术实现挑战
灵活打卡的技术挑战:
- 规则复杂多样:灵活对调、异常宽限、弹性时段等多种规则
- 计算逻辑复杂:需要考虑多种边界情况
- 性能要求高:打卡高峰期需要快速响应
- 数据一致性:确保打卡记录的准确性和完整性
- 规则可配置:支持企业自定义打卡规则

二、整体架构设计
2.1 系统架构全景
┌─────────────────────────────────────────────────────────────────┐
│ 灵活打卡规则引擎系统 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 规则配置管理层 │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │ │
│ │ │ 灵活对调规则 │ │ 异常宽限规则 │ │ 弹性时段规则 │ │ │
│ │ │(FlexibleSwap)│ │(GracePeriod) │ │(FlexibleTime) │ │ │
│ │ └──────────────┘ └──────────────┘ └─────────────────┘ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │ │
│ │ │ 规则优先级 │ │ 规则冲突处理 │ │ 规则版本管理 │ │ │
│ │ │(Priority) │ │(Conflict) │ │(Version) │ │ │
│ │ └──────────────┘ └──────────────┘ └─────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 规则计算引擎层 │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │ │
│ │ │ 规则调度器 │ │ 上下文管理器 │ │ 结果聚合器 │ │ │
│ │ │(Dispatcher) │ │(Context) │ │(Aggregator) │ │ │
│ │ └──────────────┘ └──────────────┘ └─────────────────┘ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │ │
│ │ │ 规则执行器 │ │ 异常处理器 │ │ 日志记录器 │ │ │
│ │ │(Executor) │ │(Handler) │ │(Logger) │ │ │
│ │ └──────────────┘ └──────────────┘ └─────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 打卡验证层 │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │ │
│ │ │ 时间验证 │ │ 位置验证 │ │ 设备验证 │ │ │
│ │ │(TimeCheck) │ │(LocCheck) │ │(DeviceCheck) │ │ │
│ │ └──────────────┘ └──────────────┘ └─────────────────┘ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │ │
│ │ │ 状态计算 │ │ 异常标记 │ │ 规则匹配 │ │ │
│ │ │(Status) │ │(Exception) │ │(Matcher) │ │ │
│ │ └──────────────┘ └──────────────┘ └─────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 数据存储层 │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │ │
│ │ │ 打卡记录表 │ │ 规则配置表 │ │ 计算历史表 │ │ │
│ │ │(ClockRecord) │ │(RuleConfig) │ │(CalcHistory) │ │ │
│ │ └──────────────┘ └──────────────┘ └─────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
2.2 核心领域模型
灵活规则定义(AttendanceShiftFlexibleRuleDO):
/**
* 班次灵活规则定义
* 定义一个班次的灵活打卡规则
*/
@Data
@TableName("hrm_attendance_shift_flexible_rule")
public class AttendanceShiftFlexibleRuleDO {
/**
* 规则ID
*/
private String id;
/**
* 班次ID
*/
private String shiftId;
/**
* 规则类型
* 1-灵活对调,2-异常宽限
*/
private Integer ruleType;
/**
* 规则名称
*/
private String ruleName;
/**
* 是否启用
*/
private Boolean enabled;
/**
* 优先级(数值越小优先级越高)
*/
private Integer priority;
// ========== 灵活对调规则参数 ==========
/**
* 晚到上限(分钟)
* 允许晚到的最大分钟数
*/
private Integer maxLateArriveMinutes;
/**
* 早走上限(分钟)
* 允许早走的最大分钟数
*/
private Integer maxEarlyLeaveMinutes;
// ========== 异常宽限规则参数 ==========
/**
* 迟到宽限时间(分钟)
* 在此时间内迟到不算异常
*/
private Integer lateGraceMinutes;
/**
* 早退宽限时间(分钟)
* 在此时间内早退不算异常
*/
private Integer earlyLeaveGraceMinutes;
/**
* 适用日期范围(JSON格式)
* 示例:{"startDate":"2024-01-01","endDate":"2024-12-31"}
*/
private String applicableDateRange;
/**
* 适用星期(JSON数组)
* 示例:[1,2,3,4,5] 表示周一到周五
*/
private String applicableWeekdays;
/**
* 排除日期(JSON数组)
* 示例:["2024-01-01","2024-05-01"] 排除节假日
*/
private String excludedDates;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
/**
* 规则类型枚举
*/
public enum FlexibleRuleType {
/**
* 灵活对调规则(晚到晚走、早到早走)
*/
FLEXIBLE_SWAP(1, "灵活对调"),
/**
* 异常宽限规则(宽限时间)
*/
GRACE_PERIOD(2, "异常宽限");
private final Integer code;
private final String name;
FlexibleRuleType(Integer code, String name) {
this.code = code;
this.name = name;
}
public Integer getCode() {
return code;
}
public String getName() {
return name;
}
}
打卡记录模型(AttendanceDailyDO):
/**
* 考勤日报记录
*/
@Data
@TableName("hrm_attendance_daily")
public class AttendanceDailyDO {
/**
* 记录ID
*/
private String id;
/**
* 员工ID
*/
private String employeeId;
/**
* 考勤日期
*/
private LocalDate attendanceDate;
/**
* 班次ID
*/
private String shiftId;
/**
* 班次名称
*/
private String shiftName;
// ========== 打卡时间 ==========
/**
* 上班打卡时间
*/
private LocalDateTime signInTime;
/**
* 下班打卡时间
*/
private LocalDateTime signOutTime;
/**
* 打卡位置(JSON格式)
*/
private String location;
/**
* 打卡设备
*/
private String device;
// ========== 标准时间 ==========
/**
* 标准上班时间
*/
private LocalTime standardSignInTime;
/**
* 标准下班时间
*/
private LocalTime standardSignOutTime;
// ========== 计算结果 ==========
/**
* 签到状态:0-正常,1-迟到,2-未签到
*/
private String signInStatus;
/**
* 签退状态:0-正常,1-早退,2-未签退
*/
private String signOutStatus;
/**
* 迟到分钟数
*/
private Integer lateMinutes;
/**
* 早退分钟数
*/
private Integer earlyMinutes;
/**
* 工作时长(小时)
*/
private BigDecimal workHours;
/**
* 加班时长(小时)
*/
private BigDecimal overtimeDuration;
/**
* 应出勤状态:1-正常出勤,2-请假,3-出差,4-缺勤
*/
private Integer attendanceStatus;
/**
* 使用的规则ID
*/
private String appliedRuleId;
/**
* 使用的规则类型
*/
private Integer appliedRuleType;
/**
* 计算详情(JSON格式)
*/
private String calculationDetail;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
2.3 规则引擎架构设计
灵活规则计算引擎接口:
/**
* 灵活打卡规则计算引擎
* 定义灵活打卡规则计算的统一接口
*/
public interface FlexibleRuleCalculationEngine {
/**
* 应用所有灵活规则计算打卡结果
*
* @param actualSignInTime 实际签到时间
* @param actualSignOutTime 实际签退时间
* @param standardSignInTime 标准签到时间
* @param standardSignOutTime 标准签退时间
* @param flexibleRules 灵活规则列表
* @return 打卡计算结果
*/
AttendanceResult calculateWithAllRules(
LocalTime actualSignInTime,
LocalTime actualSignOutTime,
LocalTime standardSignInTime,
LocalTime standardSignOutTime,
List<AttendanceShiftFlexibleRuleDO> flexibleRules
);
/**
* 应用单个规则计算打卡结果
*
* @param actualSignInTime 实际签到时间
* @param actualSignOutTime 实际签退时间
* @param standardSignInTime 标准签到时间
* @param standardSignOutTime 标准签退时间
* @param rule 灵活规则
* @return 打卡计算结果
*/
AttendanceResult calculateWithSingleRule(
LocalTime actualSignInTime,
LocalTime actualSignOutTime,
LocalTime standardSignInTime,
LocalTime standardSignOutTime,
AttendanceShiftFlexibleRuleDO rule
);
/**
* 验证规则是否适用
*
* @param rule 灵活规则
* @param attendanceDate 考勤日期
* @return 是否适用
*/
boolean isRuleApplicable(
AttendanceShiftFlexibleRuleDO rule,
LocalDate attendanceDate
);
}
/**
* 打卡计算结果
*/
@Data
@Builder
public class AttendanceResult {
/**
* 签到状态:0-正常,1-迟到,2-未签到
*/
private String signInStatus;
/**
* 签退状态:0-正常,1-早退,2-未签退
*/
private String signOutStatus;
/**
* 签到是否正常
*/
private Boolean signInNormal;
/**
* 签退是否正常
*/
private Boolean signOutNormal;
/**
* 迟到分钟数
*/
private Integer lateMinutes;
/**
* 早退分钟数
*/
private Integer earlyMinutes;
/**
* 应工作时长(分钟)
*/
private Integer requiredWorkMinutes;
/**
* 实际工作时长(分钟)
*/
private Integer actualWorkMinutes;
/**
* 使用的规则ID
*/
private String appliedRuleId;
/**
* 使用的规则类型
*/
private Integer appliedRuleType;
/**
* 计算详情(JSON格式)
*/
private String calculationDetail;
/**
* 是否为正常出勤
*/
public boolean isNormalAttendance() {
return Boolean.TRUE.equals(signInNormal) &&
Boolean.TRUE.equals(signOutNormal);
}
/**
* 获取状态描述
*/
public String getStatusDescription() {
List<String> statusList = new ArrayList<>();
if ("2".equals(signInStatus)) {
statusList.add("未签到");
} else if (!signInNormal) {
statusList.add("迟到" + lateMinutes + "分钟");
}
if ("2".equals(signOutStatus)) {
statusList.add("未签退");
} else if (!signOutNormal) {
statusList.add("早退" + earlyMinutes + "分钟");
}
if (statusList.isEmpty()) {
return "正常";
}
return String.join("、", statusList);
}
}
三、核心模块实现
3.1 灵活对调规则实现
灵活对调规则计算器:
/**
* 灵活对调规则计算器
*
* 规则说明:
* - 晚到晚走:上班晚到一定时间,下班相应晚走同样时间,不算迟到
* - 早到早走:上班早到一定时间,下班相应早走,不算早退
* - 保证工作时长:核心是保证实际工作时长不低于标准工作时长
*
* 适用场景:
* - 弹性工作制
* - 项目型工作
* - 结果导向型岗位
*/
@Component
@Slf4j
public class FlexibleSwapRuleCalculator {
/**
* 计算灵活对调规则的打卡结果
*
* @param actualSignInTime 实际签到时间
* @param actualSignOutTime 实际签退时间
* @param standardSignInTime 标准签到时间
* @param standardSignOutTime 标准签退时间
* @param rule 灵活对调规则
* @return 打卡计算结果
*/
public AttendanceResult calculate(
LocalTime actualSignInTime,
LocalTime actualSignOutTime,
LocalTime standardSignInTime,
LocalTime standardSignOutTime,
AttendanceShiftFlexibleRuleDO rule
) {
// 1. 计算迟到早退分钟数
int lateMinutes = calculateLateMinutes(actualSignInTime, standardSignInTime);
int earlyLeaveMinutes = calculateEarlyLeaveMinutes(
actualSignOutTime, standardSignOutTime
);
// 2. 计算标准工作时长
int standardWorkMinutes = calculateStandardWorkMinutes(
standardSignInTime, standardSignOutTime
);
// 3. 计算实际工作时长
int actualWorkMinutes = calculateActualWorkMinutes(
actualSignInTime, actualSignOutTime
);
AttendanceResult result = AttendanceResult.builder()
.requiredWorkMinutes(standardWorkMinutes)
.actualWorkMinutes(actualWorkMinutes)
.appliedRuleId(rule.getId())
.appliedRuleType(rule.getRuleType())
.build();
// 4. 判断签到状态
if (lateMinutes > 0) {
// 晚到了
if (lateMinutes > rule.getMaxLateArriveMinutes()) {
// 超过晚到上限
result.setSignInStatus("1");
result.setSignInNormal(false);
result.setLateMinutes(lateMinutes);
} else {
// 晚到在上限内,需要检查是否相应晚走
if (actualWorkMinutes >= standardWorkMinutes) {
// 实际工作时长达标,记为正常
result.setSignInStatus("0");
result.setSignInNormal(true);
result.setLateMinutes(0);
} else {
// 实际工作时长不足,记为迟到(不足部分)
int lackMinutes = standardWorkMinutes - actualWorkMinutes;
result.setSignInStatus("1");
result.setSignInNormal(false);
result.setLateMinutes(lateMinutes -
(earlyLeaveMinutes < 0 ? -earlyLeaveMinutes : 0));
}
}
} else if (lateMinutes < 0) {
// 早到了,记为正常
result.setSignInStatus("0");
result.setSignInNormal(true);
result.setLateMinutes(0);
} else {
// 准时
result.setSignInStatus("0");
result.setSignInNormal(true);
result.setLateMinutes(0);
}
// 5. 判断签退状态
if (earlyLeaveMinutes > 0) {
// 早走了
if (earlyLeaveMinutes > rule.getMaxEarlyLeaveMinutes()) {
// 超过早走上限
result.setSignOutStatus("1");
result.setSignOutNormal(false);
result.setEarlyMinutes(earlyLeaveMinutes);
} else {
// 早走在上限内,需要检查是否相应早到
if (actualWorkMinutes >= standardWorkMinutes) {
// 实际工作时长达标,记为正常
result.setSignOutStatus("0");
result.setSignOutNormal(true);
result.setEarlyMinutes(0);
} else {
// 实际工作时长不足,记为早退(不足部分)
int lackMinutes = standardWorkMinutes - actualWorkMinutes;
result.setSignOutStatus("1");
result.setSignOutNormal(false);
result.setEarlyMinutes(earlyLeaveMinutes -
(lateMinutes < 0 ? -lateMinutes : 0));
}
}
} else if (earlyLeaveMinutes < 0) {
// 晚走了,记为正常
result.setSignOutStatus("0");
result.setSignOutNormal(true);
result.setEarlyMinutes(0);
} else {
// 准时
result.setSignOutStatus("0");
result.setSignOutNormal(true);
result.setEarlyMinutes(0);
}
// 6. 记录计算详情
Map<String, Object> detail = new HashMap<>();
detail.put("lateMinutes", lateMinutes);
detail.put("earlyLeaveMinutes", earlyLeaveMinutes);
detail.put("standardWorkMinutes", standardWorkMinutes);
detail.put("actualWorkMinutes", actualWorkMinutes);
detail.put("maxLateArriveMinutes", rule.getMaxLateArriveMinutes());
detail.put("maxEarlyLeaveMinutes", rule.getMaxEarlyLeaveMinutes());
result.setCalculationDetail(JSON.toJSONString(detail));
return result;
}
/**
* 计算迟到分钟数
*
* @param actualTime 实际时间
* @param standardTime 标准时间
* @return 迟到分钟数(正数表示迟到,负数表示早到)
*/
private int calculateLateMinutes(LocalTime actualTime, LocalTime standardTime) {
return (int) ChronoUnit.MINUTES.between(standardTime, actualTime);
}
/**
* 计算早退分钟数
*
* @param actualTime 实际时间
* @param standardTime 标准时间
* @return 早退分钟数(正数表示早退,负数表示晚走)
*/
private int calculateEarlyLeaveMinutes(LocalTime actualTime, LocalTime standardTime) {
return (int) ChronoUnit.MINUTES.between(actualTime, standardTime);
}
/**
* 计算标准工作时长(分钟)
*/
private int calculateStandardWorkMinutes(LocalTime signInTime, LocalTime signOutTime) {
return (int) ChronoUnit.MINUTES.between(signInTime, signOutTime);
}
/**
* 计算实际工作时长(分钟)
*/
private int calculateActualWorkMinutes(LocalTime signInTime, LocalTime signOutTime) {
if (signInTime == null || signOutTime == null) {
return 0;
}
return (int) ChronoUnit.MINUTES.between(signInTime, signOutTime);
}
}
3.2 异常宽限规则实现
异常宽限规则计算器:
/**
* 异常宽限规则计算器
*
* 规则说明:
* - 宽限时间:在宽限时间内的迟到/早退不计为异常
* - 不保证时长:宽限规则不保证工作时长,只放宽异常判定标准
*
* 适用场景:
* - 交通拥堵容忍
* - 临时性短时延误
* - 人性化管理
*/
@Component
@Slf4j
public class GracePeriodRuleCalculator {
/**
* 计算异常宽限规则的打卡结果
*
* @param actualSignInTime 实际签到时间
* @param actualSignOutTime 实际签退时间
* @param standardSignInTime 标准签到时间
* @param standardSignOutTime 标准签退时间
* @param rule 异常宽限规则
* @return 打卡计算结果
*/
public AttendanceResult calculate(
LocalTime actualSignInTime,
LocalTime actualSignOutTime,
LocalTime standardSignInTime,
LocalTime standardSignOutTime,
AttendanceShiftFlexibleRuleDO rule
) {
// 1. 计算迟到早退分钟数
int lateMinutes = calculateLateMinutes(actualSignInTime, standardSignInTime);
int earlyLeaveMinutes = calculateEarlyLeaveMinutes(
actualSignOutTime, standardSignOutTime
);
AttendanceResult result = AttendanceResult.builder()
.appliedRuleId(rule.getId())
.appliedRuleType(rule.getRuleType())
.build();
// 2. 判断签到状态(应用宽限规则)
if (lateMinutes > 0) {
if (lateMinutes <= rule.getLateGraceMinutes()) {
// 在宽限期内,不算迟到
result.setSignInStatus("0");
result.setSignInNormal(true);
result.setLateMinutes(0);
} else {
// 超过宽限期,记为迟到
result.setSignInStatus("1");
result.setSignInNormal(false);
result.setLateMinutes(lateMinutes);
}
} else if (lateMinutes < 0) {
// 早到了
result.setSignInStatus("0");
result.setSignInNormal(true);
result.setLateMinutes(0);
} else {
// 准时
result.setSignInStatus("0");
result.setSignInNormal(true);
result.setLateMinutes(0);
}
// 3. 判断签退状态(应用宽限规则)
if (earlyLeaveMinutes > 0) {
if (earlyLeaveMinutes <= rule.getEarlyLeaveGraceMinutes()) {
// 在宽限期内,不算早退
result.setSignOutStatus("0");
result.setSignOutNormal(true);
result.setEarlyMinutes(0);
} else {
// 超过宽限期,记为早退
result.setSignOutStatus("1");
result.setSignOutNormal(false);
result.setEarlyMinutes(earlyLeaveMinutes);
}
} else if (earlyLeaveMinutes < 0) {
// 晚走了
result.setSignOutStatus("0");
result.setSignOutNormal(true);
result.setEarlyMinutes(0);
} else {
// 准时
result.setSignOutStatus("0");
result.setSignOutNormal(true);
result.setEarlyMinutes(0);
}
// 4. 记录计算详情
Map<String, Object> detail = new HashMap<>();
detail.put("lateMinutes", lateMinutes);
detail.put("earlyLeaveMinutes", earlyLeaveMinutes);
detail.put("lateGraceMinutes", rule.getLateGraceMinutes());
detail.put("earlyLeaveGraceMinutes", rule.getEarlyLeaveGraceMinutes());
result.setCalculationDetail(JSON.toJSONString(detail));
return result;
}
/**
* 计算迟到分钟数
*/
private int calculateLateMinutes(LocalTime actualTime, LocalTime standardTime) {
return (int) ChronoUnit.MINUTES.between(standardTime, actualTime);
}
/**
* 计算早退分钟数
*/
private int calculateEarlyLeaveMinutes(LocalTime actualTime, LocalTime standardTime) {
return (int) ChronoUnit.MINUTES.between(actualTime, standardTime);
}
}
3.3 规则调度引擎实现
规则调度引擎:
/**
* 灵活打卡规则调度引擎
* 负责协调多个规则的执行顺序和结果聚合
*/
@Service
@Slf4j
public class FlexibleRuleDispatchEngine implements FlexibleRuleCalculationEngine {
@Autowired
private FlexibleSwapRuleCalculator swapRuleCalculator;
@Autowired
private GracePeriodRuleCalculator gracePeriodRuleCalculator;
/**
* 应用所有灵活规则计算打卡结果
*
* 执行顺序:
* 1. 优先应用异常宽限规则(rule_type=2)
* 2. 如果宽限规则判断为正常,直接返回
* 3. 否则应用灵活对调规则(rule_type=1)
* 4. 如果都没有,使用标准计算
*
* @param actualSignInTime 实际签到时间
* @param actualSignOutTime 实际签退时间
* @param standardSignInTime 标准签到时间
* @param standardSignOutTime 标准签退时间
* @param flexibleRules 灵活规则列表
* @return 打卡计算结果
*/
@Override
public AttendanceResult calculateWithAllRules(
LocalTime actualSignInTime,
LocalTime actualSignOutTime,
LocalTime standardSignInTime,
LocalTime standardSignOutTime,
List<AttendanceShiftFlexibleRuleDO> flexibleRules
) {
// 1. 处理没有灵活规则的情况
if (flexibleRules == null || flexibleRules.isEmpty()) {
return calculateStandard(
actualSignInTime, actualSignOutTime,
standardSignInTime, standardSignOutTime
);
}
// 2. 过滤出启用的规则
List<AttendanceShiftFlexibleRuleDO> enabledRules = flexibleRules.stream()
.filter(AttendanceShiftFlexibleRuleDO::getEnabled)
.sorted(Comparator.comparing(
AttendanceShiftFlexibleRuleDO::getPriority))
.collect(Collectors.toList());
if (enabledRules.isEmpty()) {
return calculateStandard(
actualSignInTime, actualSignOutTime,
standardSignInTime, standardSignOutTime
);
}
// 3. 优先应用异常宽限规则
AttendanceShiftFlexibleRuleDO graceRule = findRuleByType(
enabledRules, FlexibleRuleType.GRACE_PERIOD.getCode()
);
if (graceRule != null) {
AttendanceResult result = gracePeriodRuleCalculator.calculate(
actualSignInTime, actualSignOutTime,
standardSignInTime, standardSignOutTime, graceRule
);
// 如果宽限规则判断为正常,直接返回
if (result.isNormalAttendance()) {
log.debug("异常宽限规则判定为正常: ruleId={}", graceRule.getId());
return result;
}
log.debug("异常宽限规则判定为异常: ruleId={}, status={}",
graceRule.getId(), result.getStatusDescription());
}
// 4. 应用灵活对调规则
AttendanceShiftFlexibleRuleDO swapRule = findRuleByType(
enabledRules, FlexibleRuleType.FLEXIBLE_SWAP.getCode()
);
if (swapRule != null) {
AttendanceResult result = swapRuleCalculator.calculate(
actualSignInTime, actualSignOutTime,
standardSignInTime, standardSignOutTime, swapRule
);
log.debug("灵活对调规则计算完成: ruleId={}, status={}",
swapRule.getId(), result.getStatusDescription());
return result;
}
// 5. 没有适用的规则,使用标准计算
return calculateStandard(
actualSignInTime, actualSignOutTime,
standardSignInTime, standardSignOutTime
);
}
/**
* 应用单个规则计算打卡结果
*/
@Override
public AttendanceResult calculateWithSingleRule(
LocalTime actualSignInTime,
LocalTime actualSignOutTime,
LocalTime standardSignInTime,
LocalTime standardSignOutTime,
AttendanceShiftFlexibleRuleDO rule
) {
if (rule == null || !rule.getEnabled()) {
return calculateStandard(
actualSignInTime, actualSignOutTime,
standardSignInTime, standardSignOutTime
);
}
// 根据规则类型选择计算器
if (FlexibleRuleType.FLEXIBLE_SWAP.getCode().equals(rule.getRuleType())) {
return swapRuleCalculator.calculate(
actualSignInTime, actualSignOutTime,
standardSignInTime, standardSignOutTime, rule
);
} else if (FlexibleRuleType.GRACE_PERIOD.getCode().equals(rule.getRuleType())) {
return gracePeriodRuleCalculator.calculate(
actualSignInTime, actualSignOutTime,
standardSignInTime, standardSignOutTime, rule
);
}
// 未知规则类型,使用标准计算
return calculateStandard(
actualSignInTime, actualSignOutTime,
standardSignInTime, standardSignOutTime
);
}
/**
* 验证规则是否适用
*/
@Override
public boolean isRuleApplicable(
AttendanceShiftFlexibleRuleDO rule,
LocalDate attendanceDate
) {
if (rule == null || !rule.getEnabled()) {
return false;
}
// 1. 检查日期范围
if (!isWithinApplicableDateRange(rule, attendanceDate)) {
return false;
}
// 2. 检查星期
if (!isWithinApplicableWeekdays(rule, attendanceDate)) {
return false;
}
// 3. 检查排除日期
if (isExcludedDate(rule, attendanceDate)) {
return false;
}
return true;
}
/**
* 标准计算(无灵活规则)
*/
private AttendanceResult calculateStandard(
LocalTime actualSignInTime,
LocalTime actualSignOutTime,
LocalTime standardSignInTime,
LocalTime standardSignOutTime
) {
AttendanceResult result = AttendanceResult.builder().build();
// 判断签到状态
if (actualSignInTime == null) {
result.setSignInStatus("2"); // 未签到
result.setSignInNormal(false);
} else if (actualSignInTime.isAfter(standardSignInTime)) {
// 迟到
int lateMinutes = (int) ChronoUnit.MINUTES.between(
standardSignInTime, actualSignInTime
);
result.setSignInStatus("1");
result.setSignInNormal(false);
result.setLateMinutes(lateMinutes);
} else {
// 正常或早到
result.setSignInStatus("0");
result.setSignInNormal(true);
result.setLateMinutes(0);
}
// 判断签退状态
if (actualSignOutTime == null) {
result.setSignOutStatus("2"); // 未签退
result.setSignOutNormal(false);
} else if (actualSignOutTime.isBefore(standardSignOutTime)) {
// 早退
int earlyMinutes = (int) ChronoUnit.MINUTES.between(
actualSignOutTime, standardSignOutTime
);
result.setSignOutStatus("1");
result.setSignOutNormal(false);
result.setEarlyMinutes(earlyMinutes);
} else {
// 正常或晚走
result.setSignOutStatus("0");
result.setSignOutNormal(true);
result.setEarlyMinutes(0);
}
return result;
}
/**
* 根据规则类型查找规则
*/
private AttendanceShiftFlexibleRuleDO findRuleByType(
List<AttendanceShiftFlexibleRuleDO> rules, Integer ruleType
) {
return rules.stream()
.filter(rule -> ruleType.equals(rule.getRuleType()))
.findFirst()
.orElse(null);
}
/**
* 检查是否在适用日期范围内
*/
private boolean isWithinApplicableDateRange(
AttendanceShiftFlexibleRuleDO rule,
LocalDate attendanceDate
) {
String dateRangeStr = rule.getApplicableDateRange();
if (StringUtils.isBlank(dateRangeStr)) {
return true; // 没有配置日期范围,默认适用
}
try {
JSONObject dateRange = JSON.parseObject(dateRangeStr);
LocalDate startDate = LocalDate.parse(dateRange.getString("startDate"));
LocalDate endDate = LocalDate.parse(dateRange.getString("endDate"));
return !attendanceDate.isBefore(startDate) &&
!attendanceDate.isAfter(endDate);
} catch (Exception e) {
log.warn("解析适用日期范围失败: ruleId={}, error={}",
rule.getId(), e.getMessage());
return true; // 解析失败,默认适用
}
}
/**
* 检查是否在适用星期内
*/
private boolean isWithinApplicableWeekdays(
AttendanceShiftFlexibleRuleDO rule,
LocalDate attendanceDate
) {
String weekdaysStr = rule.getApplicableWeekdays();
if (StringUtils.isBlank(weekdaysStr)) {
return true; // 没有配置星期,默认适用
}
try {
List<Integer> weekdays = JSON.parseArray(weekdaysStr, Integer.class);
DayOfWeek dayOfWeek = attendanceDate.getDayOfWeek();
return weekdays.contains(dayOfWeek.getValue());
} catch (Exception e) {
log.warn("解析适用星期失败: ruleId={}, error={}",
rule.getId(), e.getMessage());
return true; // 解析失败,默认适用
}
}
/**
* 检查是否为排除日期
*/
private boolean isExcludedDate(
AttendanceShiftFlexibleRuleDO rule,
LocalDate attendanceDate
) {
String excludedDatesStr = rule.getExcludedDates();
if (StringUtils.isBlank(excludedDatesStr)) {
return false; // 没有配置排除日期
}
try {
List<LocalDate> excludedDates = JSON.parseArray(
excludedDatesStr, LocalDate.class
);
return excludedDates.contains(attendanceDate);
} catch (Exception e) {
log.warn("解析排除日期失败: ruleId={}, error={}",
rule.getId(), e.getMessage());
return false; // 解析失败,不排除
}
}
}
3.4 位置验证实现
位置验证服务:
/**
* 位置打卡验证服务
*
* 核心功能:
* 1. 验证打卡位置是否在允许的范围内
* 2. 使用Haversine公式计算两点间距离
* 3. 支持组织级和考勤组级的位置配置
* 4. 支持配置打卡半径
*/
@Service
@Slf4j
public class LocationValidationService {
@Autowired
private AttendanceModeService attendanceModeService;
/**
* 默认打卡半径(米)
*/
private static final double DEFAULT_RADIUS = 400;
/**
* 地球半径(米)
*/
private static final double EARTH_RADIUS = 6371000;
/**
* 验证位置打卡(组织模式)
*
* @param configCoordinateX 配置的经度
* @param configCoordinateY 配置的纬度
* @param configRadius 配置的打卡半径(米)
* @param actualCoordinateX 实际打卡经度
* @param actualCoordinateY 实际打卡纬度
* @throws RuntimeException 验证失败抛出异常
*/
public void validateLocationForOrgMode(
Double configCoordinateX,
Double configCoordinateY,
Double configRadius,
String actualCoordinateX,
String actualCoordinateY
) {
// 1. 检查系统配置是否启用位置打卡
if (!attendanceModeService.isLocationCheckEnabled()) {
return; // 系统未启用位置打卡,不验证
}
// 2. 检查是否配置了打卡位置
if (configCoordinateX == null || configCoordinateY == null) {
return; // 未配置位置,不验证
}
// 3. 检查用户是否传入了位置信息
if (StringUtils.isBlank(actualCoordinateX) ||
StringUtils.isBlank(actualCoordinateY)) {
throw new LocationValidationException("请提供位置信息");
}
// 4. 计算距离并验证
double actualX = Double.parseDouble(actualCoordinateX);
double actualY = Double.parseDouble(actualCoordinateY);
double distance = calculateDistance(
actualX, actualY, configCoordinateX, configCoordinateY
);
double radius = configRadius != null ? configRadius : DEFAULT_RADIUS;
if (distance > radius) {
throw new LocationValidationException(
String.format("当前位置不在打卡范围内,距离%.0f米", distance)
);
}
log.debug("位置验证通过: distance={}", distance);
}
/**
* 验证位置打卡(考勤组模式)
*
* @param groupCoordinateX 考勤组配置的经度
* @param groupCoordinateY 考勤组配置的纬度
* @param groupRadius 考勤组配置的打卡半径(米)
* @param actualCoordinateX 实际打卡经度
* @param actualCoordinateY 实际打卡纬度
* @throws RuntimeException 验证失败抛出异常
*/
public void validateLocationForGroupMode(
Double groupCoordinateX,
Double groupCoordinateY,
Double groupRadius,
String actualCoordinateX,
String actualCoordinateY
) {
// 1. 检查系统配置是否启用位置打卡
if (!attendanceModeService.isLocationCheckEnabled()) {
return; // 系统未启用位置打卡,不验证
}
// 2. 检查考勤组是否配置了位置
if (groupCoordinateX == null || groupCoordinateY == null) {
return; // 考勤组未配置位置,不验证
}
// 3. 检查用户是否传入了位置信息
if (StringUtils.isBlank(actualCoordinateX) ||
StringUtils.isBlank(actualCoordinateY)) {
throw new LocationValidationException("请提供位置信息");
}
// 4. 计算距离并验证
double actualX = Double.parseDouble(actualCoordinateX);
double actualY = Double.parseDouble(actualCoordinateY);
double distance = calculateDistance(
actualX, actualY, groupCoordinateX, groupCoordinateY
);
double radius = groupRadius != null ? groupRadius : DEFAULT_RADIUS;
if (distance > radius) {
throw new LocationValidationException(
String.format("当前位置不在打卡范围内,距离%.0f米", distance)
);
}
log.debug("位置验证通过: distance={}", distance);
}
/**
* 计算两点之间的距离(米)
*
* 使用 Haversine 公式计算地球表面两点之间的距离
*
* Haversine公式:
* a = sin²(Δφ/2) + cos(φ1) ⋅ cos(φ2) ⋅ sin²(Δλ/2)
* c = 2 ⋅ atan2(√a, √(1−a))
* d = R ⋅ c
*
* 其中:
* - φ1, φ2: 两点的纬度
* - λ1, λ2: 两点的经度
* - Δφ = φ2 - φ1
* - Δλ = λ2 - λ1
* - R: 地球半径(6371000米)
* - d: 两点间的距离
*
* @param longitude1 经度1
* @param latitude1 纬度1
* @param longitude2 经度2
* @param latitude2 纬度2
* @return 距离(米)
*/
public double calculateDistance(
double longitude1,
double latitude1,
double longitude2,
double latitude2
) {
// 1. 将角度转换为弧度
double lat1Rad = Math.toRadians(latitude1);
double lat2Rad = Math.toRadians(latitude2);
double deltaLonRad = Math.toRadians(longitude2 - longitude1);
double deltaLatRad = Math.toRadians(latitude2 - latitude1);
// 2. 应用Haversine公式
double a = Math.sin(deltaLatRad / 2) * Math.sin(deltaLatRad / 2) +
Math.cos(lat1Rad) * Math.cos(lat2Rad) *
Math.sin(deltaLonRad / 2) * Math.sin(deltaLonRad / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
// 3. 计算距离
return EARTH_RADIUS * c;
}
/**
* 批量验证位置
*
* @param locations 位置列表
* @param configCoordinates 配置坐标
* @param radius 半径
* @return 验证结果列表
*/
public List<LocationValidationResult> validateBatch(
List<LocationPoint> locations,
LocationPoint configCoordinates,
Double radius
) {
return locations.stream()
.map(location -> {
try {
validateLocationForOrgMode(
configCoordinates.getLongitude(),
configCoordinates.getLatitude(),
radius,
String.valueOf(location.getLongitude()),
String.valueOf(location.getLatitude())
);
return LocationValidationResult.success(location);
} catch (LocationValidationException e) {
return LocationValidationResult.failure(location, e.getMessage());
}
})
.collect(Collectors.toList());
}
/**
* 计算多边形中心点
* 用于设置多边形打卡区域的中心位置
*
* @param points 多边形顶点列表
* @return 中心点坐标
*/
public LocationPoint calculatePolygonCenter(List<LocationPoint> points) {
if (points == null || points.isEmpty()) {
throw new IllegalArgumentException("多边形顶点不能为空");
}
double sumX = 0;
double sumY = 0;
for (LocationPoint point : points) {
sumX += point.getLongitude();
sumY += point.getLatitude();
}
return new LocationPoint(
sumX / points.size(),
sumY / points.size()
);
}
/**
* 判断点是否在多边形内
* 用于多边形打卡区域的判定
*
* @param point 判定点
* @param polygon 多边形顶点列表
* @return 是否在多边形内
*/
public boolean isPointInPolygon(LocationPoint point, List<LocationPoint> polygon) {
if (polygon == null || polygon.size() < 3) {
return false;
}
int crossings = 0;
int n = polygon.size();
for (int i = 0; i < n; i++) {
LocationPoint p1 = polygon.get(i);
LocationPoint p2 = polygon.get((i + 1) % n);
// 检查射线是否与边相交
if ((p1.getLatitude() > point.getLatitude()) !=
(p2.getLatitude() > point.getLatitude())) {
// 计算射线与边的交点的x坐标
double intersectX = (point.getLatitude() - p1.getLatitude()) *
(p2.getLongitude() - p1.getLongitude()) /
(p2.getLatitude() - p1.getLatitude()) + p1.getLongitude();
if (point.getLongitude() < intersectX) {
crossings++;
}
}
}
// 奇数个交点表示在多边形内
return (crossings % 2) == 1;
}
}
/**
* 位置点
*/
@Data
@AllArgsConstructor
public class LocationPoint {
/**
* 经度
*/
private Double longitude;
/**
* 纬度
*/
private Double latitude;
}
/**
* 位置验证结果
*/
@Data
@Builder
public class LocationValidationResult {
/**
* 位置点
*/
private LocationPoint location;
/**
* 是否验证通过
*/
private Boolean valid;
/**
* 错误信息
*/
private String errorMessage;
public static LocationValidationResult success(LocationPoint location) {
return LocationValidationResult.builder()
.location(location)
.valid(true)
.build();
}
public static LocationValidationResult failure(LocationPoint location, String errorMessage) {
return LocationValidationResult.builder()
.location(location)
.valid(false)
.errorMessage(errorMessage)
.build();
}
}
/**
* 位置验证异常
*/
public class LocationValidationException extends RuntimeException {
public LocationValidationException(String message) {
super(message);
}
}
3.5 打卡流程集成
打卡服务集成:
/**
* 考勤打卡服务
* 整合灵活规则引擎和位置验证,提供完整的打卡功能
*/
@Service
@Slf4j
public class AttendanceClockInService {
@Autowired
private FlexibleRuleDispatchEngine ruleEngine;
@Autowired
private LocationValidationService locationValidationService;
@Autowired
private AttendanceDailyService attendanceDailyService;
/**
* 执行签到
*
* @param request 签到请求
* @return 签到结果
*/
@Transactional(rollbackFor = Exception.class)
public ClockInResult clockIn(ClockInRequest request) {
log.info("执行签到: employeeId={}, time={}",
request.getEmployeeId(), request.getClockInTime());
// 1. 获取员工考勤组和班次信息
AttendanceGroupDO group = attendanceGroupService.getByEmployeeId(request.getEmployeeId());
if (group == null) {
throw new BusinessException("员工未分配考勤组");
}
AttendanceShiftDO shift = attendanceShiftService.getById(group.getShiftId());
if (shift == null) {
throw new BusinessException("考勤组未分配班次");
}
// 2. 验证打卡位置
try {
locationValidationService.validateLocationForGroupMode(
group.getLongitude(),
group.getLatitude(),
group.getLocationRadius(),
request.getLocationX(),
request.getLocationY()
);
} catch (LocationValidationException e) {
// 位置验证失败
log.warn("签到位置验证失败: employeeId={}, error={}",
request.getEmployeeId(), e.getMessage());
// 根据配置决定是否允许打卡
if (!Boolean.TRUE.equals(group.getAllowClockInOutsideLocation())) {
throw new BusinessException(e.getMessage());
}
}
// 3. 获取灵活规则
List<AttendanceShiftFlexibleRuleDO> flexibleRules =
attendanceShiftFlexibleRuleService.listByShiftId(shift.getId());
// 4. 应用灵活规则计算签到状态
LocalTime actualSignInTime = request.getClockInTime().toLocalTime();
LocalTime actualSignOutTime = null; // 签到时没有签退时间
AttendanceResult attendanceResult = ruleEngine.calculateWithAllRules(
actualSignInTime,
actualSignOutTime,
shift.getSignInTime(), // 标准上班时间
shift.getSignOutTime(), // 标准下班时间
flexibleRules
);
// 5. 保存签到记录
AttendanceDailyDO dailyRecord = new AttendanceDailyDO();
dailyRecord.setId(IdUtil.fastUUID());
dailyRecord.setEmployeeId(request.getEmployeeId());
dailyRecord.setAttendanceDate(request.getClockInTime().toLocalDate());
dailyRecord.setShiftId(shift.getId());
dailyRecord.setShiftName(shift.getShiftName());
dailyRecord.setSignInTime(request.getClockInTime());
dailyRecord.setLocation(buildLocationString(request.getLocationX(), request.getLocationY()));
dailyRecord.setDevice(request.getDevice());
dailyRecord.setStandardSignInTime(shift.getSignInTime());
dailyRecord.setStandardSignOutTime(shift.getSignOutTime());
dailyRecord.setSignInStatus(attendanceResult.getSignInStatus());
dailyRecord.setLateMinutes(attendanceResult.getLateMinutes());
// 如果已有当天记录,更新;否则插入
attendanceDailyService.saveOrUpdate(dailyRecord);
// 6. 构建返回结果
ClockInResult result = ClockInResult.builder()
.success(true)
.signInStatus(attendanceResult.getSignInStatus())
.signInNormal(attendanceResult.getSignInNormal())
.lateMinutes(attendanceResult.getLateMinutes())
.message(attendanceResult.getSignInNormal() ? "签到成功" :
"签到成功,但" + attendanceResult.getStatusDescription())
.build();
log.info("签到完成: employeeId={}, status={}, lateMinutes={}",
request.getEmployeeId(), result.getSignInStatus(), result.getLateMinutes());
return result;
}
/**
* 执行签退
*
* @param request 签退请求
* @return 签退结果
*/
@Transactional(rollbackFor = Exception.class)
public ClockOutResult clockOut(ClockOutRequest request) {
log.info("执行签退: employeeId={}, time={}",
request.getEmployeeId(), request.getClockOutTime());
// 1. 获取当天的签到记录
LocalDate attendanceDate = request.getClockOutTime().toLocalDate();
AttendanceDailyDO dailyRecord = attendanceDailyService.getByEmployeeAndDate(
request.getEmployeeId(), attendanceDate
);
if (dailyRecord == null) {
throw new BusinessException("未找到今日签到记录");
}
// 2. 获取班次和灵活规则
AttendanceShiftDO shift = attendanceShiftService.getById(dailyRecord.getShiftId());
List<AttendanceShiftFlexibleRuleDO> flexibleRules =
attendanceShiftFlexibleRuleService.listByShiftId(shift.getId());
// 3. 应用灵活规则计算签退状态
LocalTime actualSignInTime = dailyRecord.getSignInTime() != null ?
dailyRecord.getSignInTime().toLocalTime() : null;
LocalTime actualSignOutTime = request.getClockOutTime().toLocalTime();
AttendanceResult attendanceResult = ruleEngine.calculateWithAllRules(
actualSignInTime,
actualSignOutTime,
shift.getSignInTime(),
shift.getSignOutTime(),
flexibleRules
);
// 4. 更新签退记录
dailyRecord.setSignOutTime(request.getClockOutTime());
dailyRecord.setLocation(buildLocationString(request.getLocationX(), request.getLocationY()));
dailyRecord.setDevice(request.getDevice());
dailyRecord.setSignOutStatus(attendanceResult.getSignOutStatus());
dailyRecord.setEarlyMinutes(attendanceResult.getEarlyMinutes());
// 计算工作时长
if (actualSignInTime != null && actualSignOutTime != null) {
int workMinutes = (int) ChronoUnit.MINUTES.between(actualSignInTime, actualSignOutTime);
dailyRecord.setWorkHours(new BigDecimal(workMinutes)
.divide(new BigDecimal("60"), 2, RoundingMode.HALF_UP));
}
attendanceDailyService.updateById(dailyRecord);
// 5. 构建返回结果
ClockOutResult result = ClockOutResult.builder()
.success(true)
.signOutStatus(attendanceResult.getSignOutStatus())
.signOutNormal(attendanceResult.getSignOutNormal())
.earlyMinutes(attendanceResult.getEarlyMinutes())
.workHours(dailyRecord.getWorkHours())
.message(attendanceResult.getSignOutNormal() ? "签退成功" :
"签退成功,但" + attendanceResult.getStatusDescription())
.build();
log.info("签退完成: employeeId={}, status={}, earlyMinutes={}, workHours={}",
request.getEmployeeId(), result.getSignOutStatus(),
result.getEarlyMinutes(), result.getWorkHours());
return result;
}
/**
* 构建位置字符串
*/
private String buildLocation(String longitude, String latitude) {
if (StringUtils.isBlank(longitude) || StringUtils.isBlank(latitude)) {
return null;
}
Map<String, String> location = new HashMap<>();
location.put("longitude", longitude);
location.put("latitude", latitude);
return JSON.toJSONString(location);
}
}
四、高级特性实现
4.1 规则冲突处理
规则冲突处理器:
/**
* 灵活规则冲突处理器
* 处理多个规则同时适用时的冲突问题
*/
@Component
@Slf4j
public class FlexibleRuleConflictHandler {
/**
* 解决规则冲突
*
* 策略:
* 1. 按优先级排序,优先级高的规则优先生效
* 2. 如果优先级相同,按规则类型排序:宽限规则 > 对调规则
* 3. 支持规则组合:多个规则可以同时生效
*
* @param rules 灵活规则列表
* @return 解决冲突后的规则列表
*/
public List<AttendanceShiftFlexibleRuleDO> resolveConflicts(
List<AttendanceShiftFlexibleRuleDO> rules
) {
if (rules == null || rules.size() <= 1) {
return rules;
}
// 1. 过滤出启用的规则
List<AttendanceShiftFlexibleRuleDO> enabledRules = rules.stream()
.filter(AttendanceShiftFlexibleRuleDO::getEnabled)
.collect(Collectors.toList());
if (enabledRules.isEmpty()) {
return Collections.emptyList();
}
// 2. 按优先级排序
enabledRules.sort(Comparator.comparing(
AttendanceShiftFlexibleRuleDO::getPriority
));
// 3. 检查是否有相同优先级的规则
Map<Integer, List<AttendanceShiftFlexibleRuleDO>> groupedByPriority =
enabledRules.stream()
.collect(Collectors.groupingBy(
AttendanceShiftFlexibleRuleDO::getPriority
));
// 4. 处理相同优先级的规则
List<AttendanceShiftFlexibleRuleDO> resolvedRules = new ArrayList<>();
for (Map.Entry<Integer, List<AttendanceShiftFlexibleRuleDO>> entry :
groupedByPriority.entrySet()) {
List<AttendanceShiftFlexibleRuleDO> samePriorityRules = entry.getValue();
if (samePriorityRules.size() == 1) {
// 没有冲突
resolvedRules.add(samePriorityRules.get(0));
} else {
// 有冲突,选择规则类型优先级更高的
AttendanceShiftFlexibleRuleDO selectedRule =
selectRuleByType(samePriorityRules);
resolvedRules.add(selectedRule);
log.debug("规则冲突解决: priority={}, selectedRule={}, excluded={}",
entry.getKey(), selectedRule.getId(),
samePriorityRules.stream()
.filter(r -> !r.getId().equals(selectedRule.getId()))
.map(AttendanceShiftFlexibleRuleDO::getId)
.collect(Collectors.toList())
);
}
}
return resolvedRules;
}
/**
* 根据规则类型选择优先级更高的规则
*
* 规则类型优先级:
* 1. 异常宽限规则(rule_type=2)优先
* 2. 灵活对调规则(rule_type=1)次之
*/
private AttendanceShiftFlexibleRuleDO selectRuleByType(
List<AttendanceShiftFlexibleRuleDO> rules
) {
// 优先选择异常宽限规则
AttendanceShiftFlexibleRuleDO graceRule = rules.stream()
.filter(rule -> FlexibleRuleType.GRACE_PERIOD.getCode().equals(rule.getRuleType()))
.findFirst()
.orElse(null);
if (graceRule != null) {
return graceRule;
}
// 其次选择灵活对调规则
return rules.stream()
.filter(rule -> FlexibleRuleType.FLEXIBLE_SWAP.getCode().equals(rule.getRuleType()))
.findFirst()
.orElse(rules.get(0));
}
/**
* 检测规则冲突
*
* @param rules 规则列表
* @return 冲突检测结果
*/
public ConflictDetectionResult detectConflicts(
List<AttendanceShiftFlexibleRuleDO> rules
) {
ConflictDetectionResult result = ConflictDetectionResult.builder().build();
if (rules == null || rules.size() <= 1) {
return result;
}
// 检测相同优先级的规则
Map<Integer, List<AttendanceShiftFlexibleRuleDO>> groupedByPriority =
rules.stream()
.filter(AttendanceShiftFlexibleRuleDO::getEnabled)
.collect(Collectors.groupingBy(
AttendanceShiftFlexibleRuleDO::getPriority
));
List<ConflictGroup> conflicts = new ArrayList<>();
for (Map.Entry<Integer, List<AttendanceShiftFlexibleRuleDO>> entry :
groupedByPriority.entrySet()) {
List<AttendanceShiftFlexibleRuleDO> samePriorityRules = entry.getValue();
if (samePriorityRules.size() > 1) {
// 检测到冲突
ConflictGroup conflict = ConflictGroup.builder()
.priority(entry.getKey())
.conflictingRules(samePriorityRules)
.conflictCount(samePriorityRules.size())
.build();
conflicts.add(conflict);
}
}
result.setConflicts(conflicts);
result.setHasConflicts(!conflicts.isEmpty());
return result;
}
}
/**
* 冲突检测结果
*/
@Data
@Builder
public class ConflictDetectionResult {
/**
* 是否有冲突
*/
private Boolean hasConflicts;
/**
* 冲突组列表
*/
private List<ConflictGroup> conflicts;
}
/**
* 冲突组
*/
@Data
@Builder
public class ConflictGroup {
/**
* 优先级
*/
private Integer priority;
/**
* 冲突的规则列表
*/
private List<AttendanceShiftFlexibleRuleDO> conflictingRules;
/**
* 冲突数量
*/
private Integer conflictCount;
}
4.2 规则版本管理
规则版本管理服务:
/**
* 灵活规则版本管理服务
* 支持规则的版本控制和历史追溯
*/
@Service
@Slf4j
public class FlexibleRuleVersionService {
@Autowired
private AttendanceShiftFlexibleRuleMapper ruleMapper;
@Autowired
private FlexibleRuleChangeLogMapper changeLogMapper;
/**
* 创建规则快照
*
* @param shiftId 班次ID
* @param version 版本号
* @return 快照ID
*/
@Transactional(rollbackFor = Exception.class)
public String createSnapshot(String shiftId, String version) {
log.info("创建规则快照: shiftId={}, version={}", shiftId, version);
// 1. 获取当前规则
List<AttendanceShiftFlexibleRuleDO> currentRules = ruleMapper.selectList(
new LambdaQueryWrapper<AttendanceShiftFlexibleRuleDO>()
.eq(AttendanceShiftFlexibleRuleDO::getShiftId, shiftId)
.eq(AttendanceShiftFlexibleRuleDO::getEnabled, true)
);
// 2. 创建快照
List<FlexibleRuleSnapshot> snapshots = new ArrayList<>();
for (AttendanceShiftFlexibleRuleDO rule : currentRules) {
FlexibleRuleSnapshot snapshot = new FlexibleRuleSnapshot();
snapshot.setId(IdUtil.fastUUID());
snapshot.setShiftId(shiftId);
snapshot.setRuleId(rule.getId());
snapshot.setRuleType(rule.getRuleType());
snapshot.setRuleName(rule.getRuleName());
snapshot.setRuleConfig(JSON.toJSONString(rule));
snapshot.setVersion(version);
snapshot.setSnapshotTime(LocalDateTime.now());
snapshots.add(snapshot);
}
// 3. 批量保存快照
if (!snapshots.isEmpty()) {
flexibleRuleSnapshotMapper.insertBatch(snapshots);
}
log.info("规则快照创建完成: shiftId={}, version={}, count={}",
shiftId, version, snapshots.size());
return version;
}
/**
* 恢复规则版本
*
* @param shiftId 班次ID
* @param version 目标版本
* @return 恢复的规则数量
*/
@Transactional(rollbackFor = Exception.class)
public int restoreVersion(String shiftId, String version) {
log.info("恢复规则版本: shiftId={}, version={}", shiftId, version);
// 1. 获取目标版本的快照
List<FlexibleRuleSnapshot> snapshots = flexibleRuleSnapshotMapper.selectList(
new LambdaQueryWrapper<FlexibleRuleSnapshot>()
.eq(FlexibleRuleSnapshot::getShiftId, shiftId)
.eq(FlexibleRuleSnapshot::getVersion, version)
);
if (snapshots.isEmpty()) {
throw new BusinessException("未找到指定版本的规则快照");
}
// 2. 删除当前规则
ruleMapper.delete(
new LambdaQueryWrapper<AttendanceShiftFlexibleRuleDO>()
.eq(AttendanceShiftFlexibleRuleDO::getShiftId, shiftId)
);
// 3. 从快照恢复规则
int restoredCount = 0;
for (FlexibleRuleSnapshot snapshot : snapshots) {
try {
AttendanceShiftFlexibleRuleDO rule = JSON.parseObject(
snapshot.getRuleConfig(),
AttendanceShiftFlexibleRuleDO.class
);
rule.setId(IdUtil.fastUUID()); // 生成新ID
rule.setCreateTime(LocalDateTime.now());
rule.setUpdateTime(LocalDateTime.now());
ruleMapper.insert(rule);
restoredCount++;
} catch (Exception e) {
log.error("恢复规则失败: ruleId={}, error={}",
snapshot.getRuleId(), e.getMessage());
}
}
// 4. 记录变更日志
recordChangeLog(shiftId, "RESTORE_VERSION",
String.format("恢复到版本%s,共恢复%d条规则", version, restoredCount));
log.info("规则版本恢复完成: shiftId={}, version={}, count={}",
shiftId, version, restoredCount);
return restoredCount;
}
/**
* 对比两个版本的规则差异
*
* @param shiftId 班次ID
* @param version1 版本1
* @param version2 版本2
* @return 差异对比结果
*/
public RuleDiffResult compareVersions(
String shiftId,
String version1,
String version2
) {
log.info("对比规则版本: shiftId={}, v1={}, v2={}", shiftId, version1, version2);
// 1. 获取两个版本的快照
List<FlexibleRuleSnapshot> snapshots1 = flexibleRuleSnapshotMapper.selectList(
new LambdaQueryWrapper<FlexibleRuleSnapshot>()
.eq(FlexibleRuleSnapshot::getShiftId, shiftId)
.eq(FlexibleRuleSnapshot::getVersion, version1)
);
List<FlexibleRuleSnapshot> snapshots2 = flexibleRuleSnapshotMapper.selectList(
new LambdaQueryWrapper<FlexibleRuleSnapshot>()
.eq(FlexibleRuleSnapshot::getShiftId, shiftId)
.eq(FlexibleRuleSnapshot::getVersion, version2)
);
// 2. 构建差异结果
RuleDiffResult diffResult = RuleDiffResult.builder()
.shiftId(shiftId)
.version1(version1)
.version2(version2)
.build();
// 3. 找出新增的规则(在v2中有,v1中没有)
List<RuleDifference> addedRules = snapshots2.stream()
.filter(s2 -> snapshots1.stream()
.noneMatch(s1 -> s1.getRuleId().equals(s2.getRuleId())))
.map(s2 -> RuleDifference.builder()
.ruleType("ADDED")
.ruleId(s2.getRuleId())
.ruleName(s2.getRuleName())
.version2Value(s2.getRuleConfig())
.build())
.collect(Collectors.toList());
diffResult.setAddedRules(addedRules);
// 4. 找出删除的规则(在v1中有,v2中没有)
List<RuleDifference> removedRules = snapshots1.stream()
.filter(s1 -> snapshots2.stream()
.noneMatch(s2 -> s2.getRuleId().equals(s1.getRuleId())))
.map(s1 -> RuleDifference.builder()
.ruleType("REMOVED")
.ruleId(s1.getRuleId())
.ruleName(s1.getRuleName())
.version1Value(s1.getRuleConfig())
.build())
.collect(Collectors.toList());
diffResult.setRemovedRules(removedRules);
// 5. 找出修改的规则
List<RuleDifference> modifiedRules = new ArrayList<>();
for (FlexibleRuleSnapshot s1 : snapshots1) {
FlexibleRuleSnapshot s2 = snapshots2.stream()
.filter(snapshot -> snapshot.getRuleId().equals(s1.getRuleId()))
.findFirst()
.orElse(null);
if (s2 != null && !s1.getRuleConfig().equals(s2.getRuleConfig())) {
modifiedRules.add(RuleDifference.builder()
.ruleType("MODIFIED")
.ruleId(s1.getRuleId())
.ruleName(s1.getRuleName())
.version1Value(s1.getRuleConfig())
.version2Value(s2.getRuleConfig())
.build());
}
}
diffResult.setModifiedRules(modifiedRules);
// 6. 设置统计信息
diffResult.setTotalAdded(addedRules.size());
diffResult.setTotalRemoved(removedRules.size());
diffResult.setTotalModified(modifiedRules.size());
log.info("规则版本对比完成: shiftId={}, added={}, removed={}, modified={}",
shiftId, addedRules.size(), removedRules.size(), modifiedRules.size());
return diffResult;
}
/**
* 记录变更日志
*/
private void recordChangeLog(String shiftId, String changeType, String description) {
FlexibleRuleChangeLog changeLog = new FlexibleRuleChangeLog();
changeLog.setId(IdUtil.fastUUID());
changeLog.setShiftId(shiftId);
changeLog.setChangeType(changeType);
changeLog.setDescription(description);
changeLog.setChangeTime(LocalDateTime.now());
changeLog.setOperator(SecurityContextHolder.getUserId());
changeLogMapper.insert(changeLog);
}
}
/**
* 规则差异结果
*/
@Data
@Builder
public class RuleDiffResult {
private String shiftId;
private String version1;
private String version2;
private List<RuleDifference> addedRules;
private List<RuleDifference> removedRules;
private List<RuleDifference> modifiedRules;
private Integer totalAdded;
private Integer totalRemoved;
private Integer totalModified;
}
/**
* 规则差异
*/
@Data
@Builder
public class RuleDifference {
private String ruleType; // ADDED, REMOVED, MODIFIED
private String ruleId;
private String ruleName;
private String version1Value;
private String version2Value;
}
五、性能优化
5.1 打卡高峰优化
打卡性能优化策略:
/**
* 打卡性能优化服务
* 应对打卡高峰期的性能压力
*/
@Service
@Slf4j
public class ClockInPerformanceOptimizer {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private FlexibleRuleDispatchEngine ruleEngine;
/**
* 预加载灵活规则到缓存
*
* @param shiftIds 班次ID列表
*/
public void preloadRulesToCache(List<String> shiftIds) {
log.info("预加载灵活规则到缓存: shiftCount={}", shiftIds.size());
for (String shiftId : shiftIds) {
List<AttendanceShiftFlexibleRuleDO> rules =
attendanceShiftFlexibleRuleMapper.selectList(
new LambdaQueryWrapper<AttendanceShiftFlexibleRuleDO>()
.eq(AttendanceShiftFlexibleRuleDO::getShiftId, shiftId)
.eq(AttendanceShiftFlexibleRuleDO::getEnabled, true)
.orderByAsc(AttendanceShiftFlexibleRuleDO::getPriority)
);
if (!rules.isEmpty()) {
String cacheKey = buildRuleCacheKey(shiftId);
redisTemplate.opsForValue().set(cacheKey, rules, 1, TimeUnit.HOURS);
}
}
log.info("灵活规则预加载完成");
}
/**
* 从缓存获取规则
*
* @param shiftId 班次ID
* @return 灵活规则列表
*/
public List<AttendanceShiftFlexibleRuleDO> getRulesFromCache(String shiftId) {
String cacheKey = buildRuleCacheKey(shiftId);
Object cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return (List<AttendanceShiftFlexibleRuleDO>) cached;
}
// 缓存未命中,从数据库加载
List<AttendanceShiftFlexibleRuleDO> rules =
attendanceShiftFlexibleRuleMapper.selectList(
new LambdaQueryWrapper<AttendanceShiftFlexibleRuleDO>()
.eq(AttendanceShiftFlexibleRuleDO::getShiftId, shiftId)
.eq(AttendanceShiftFlexibleRuleDO::getEnabled, true)
.orderByAsc(AttendanceShiftFlexibleRuleDO::getPriority)
);
// 写入缓存
if (!rules.isEmpty()) {
redisTemplate.opsForValue().set(cacheKey, rules, 1, TimeUnit.HOURS);
}
return rules;
}
/**
* 批量异步计算打卡结果
*
* @param requests 打卡请求列表
* @return 异步任务ID
*/
public String batchCalculateAsync(List<ClockInRequest> requests) {
String taskId = IdUtil.fastUUID();
// 发送到消息队列异步处理
BatchClockInMessage message = new BatchClockInMessage();
message.setTaskId(taskId);
message.setRequests(requests);
rocketMQTemplate.asyncSend("BATCH_CLOCK_IN_TOPIC", message);
return taskId;
}
/**
* 构建规则缓存键
*/
private String buildRuleCacheKey(String shiftId) {
return "flexible:rule:shift:" + shiftId;
}
}
5.2 监控与告警
打卡监控服务:
/**
* 打卡监控服务
* 监控打卡业务的健康状态和异常情况
*/
@Component
@Slf4j
public class ClockInMonitoringService {
@Autowired
private MeterRegistry meterRegistry;
@Autowired
private NotificationService notificationService;
/**
* 记录打卡成功
*/
public void recordClockInSuccess(String employeeId, String shiftId) {
// 记录成功计数
meterRegistry.counter("clock.in.success",
"shift", shiftId
).increment();
// 记录最近打卡时间
String key = "clock.in.last:" + employeeId;
redisTemplate.opsForValue().set(key, System.currentTimeMillis(), 24, TimeUnit.HOURS);
}
/**
* 记录打卡失败
*/
public void recordClockInFailure(String employeeId, String shiftId, String errorMessage) {
// 记录失败计数
meterRegistry.counter("clock.in.failure",
"shift", shiftId,
"error", categorizeError(errorMessage)
).increment();
// 记录失败详情
String key = "clock.in.fail:" + employeeId + ":" + System.currentTimeMillis();
redisTemplate.opsForValue().set(key, errorMessage, 7, TimeUnit.DAYS);
}
/**
* 检测打卡异常
*
* @param employeeId 员工ID
* @return 异常检测结果
*/
public AnomalyDetectionResult detectClockInAnomaly(String employeeId) {
AnomalyDetectionResult result = AnomalyDetectionResult.builder()
.employeeId(employeeId)
.build();
// 1. 检查连续未打卡天数
int consecutiveAbsentDays = getConsecutiveAbsentDays(employeeId);
if (consecutiveAbsentDays >= 3) {
result.setHasAnomaly(true);
result.addAnomaly("连续" + consecutiveAbsentDays + "天未打卡");
}
// 2. 检查频繁迟到
int frequentLateCount = getFrequentLateCount(employeeId, 30);
if (frequentLateCount >= 10) {
result.setHasAnomaly(true);
result.addAnomaly("30天内迟到" + frequentLateCount + "次");
}
// 3. 检查打卡位置异常
List<String> abnormalLocations = getAbnormalLocations(employeeId);
if (!abnormalLocations.isEmpty()) {
result.setHasAnomaly(true);
result.addAnomaly("打卡位置异常: " + String.join(", ", abnormalLocations));
}
// 4. 发送告警
if (result.getHasAnomaly()) {
sendAnomalyAlert(employeeId, result.getAnomalies());
}
return result;
}
/**
* 获取连续未打卡天数
*/
private int getConsecutiveAbsentDays(String employeeId) {
LocalDate today = LocalDate.now();
int consecutiveDays = 0;
for (int i = 1; i <= 30; i++) {
LocalDate date = today.minusDays(i);
AttendanceDailyDO record = attendanceDailyService.getByEmployeeAndDate(
employeeId, date
);
if (record == null || "2".equals(record.getSignInStatus())) {
consecutiveDays++;
} else {
break;
}
}
return consecutiveDays;
}
/**
* 获取频繁迟到次数
*/
private int getFrequentLateCount(String employeeId, int days) {
LocalDate startDate = LocalDate.now().minusDays(days);
Long count = attendanceDailyMapper.selectCount(
new LambdaQueryWrapper<AttendanceDailyDO>()
.eq(AttendanceDailyDO::getEmployeeId, employeeId)
.ge(AttendanceDailyDO::getAttendanceDate, startDate)
.eq(AttendanceDailyDO::getSignInStatus, "1")
);
return count.intValue();
}
/**
* 获取异常位置
*/
private List<String> getAbnormalLocations(String employeeId) {
// 分析打卡位置,找出异常位置
// 这里简化处理
return Collections.emptyList();
}
/**
* 发送异常告警
*/
private void sendAnomalyAlert(String employeeId, List<String> anomalies) {
try {
EmployeeDO employee = employeeMapper.selectById(employeeId);
String message = String.format(
"员工[%s]打卡异常:%s",
employee.getName(),
String.join(";", anomalies)
);
notificationService.sendAlertToManager(employee.getSuperiorId(), message);
} catch (Exception e) {
log.error("发送异常告警失败: employeeId={}, error={}",
employeeId, e.getMessage());
}
}
/**
* 错误分类
*/
private String categorizeError(String errorMessage) {
if (errorMessage.contains("位置")) {
return "location";
} else if (errorMessage.contains("时间")) {
return "time";
} else {
return "other";
}
}
}
六、最佳实践
6.1 规则设计最佳实践
1. 规则优先级设置
// ✅ 推荐:合理的优先级设置
public class RulePriorityConfig {
/**
* 优先级规则:
* - 异常宽限规则:优先级 100-199(最宽容,优先级最高)
* - 灵活对调规则:优先级 200-299(保证工作时长)
* - 标准规则:优先级 999(最严格,兜底规则)
*/
public static final int GRACE_PERIOD_PRIORITY_MIN = 100;
public static final int GRACE_PERIOD_PRIORITY_MAX = 199;
public static final int FLEXIBLE_SWAP_PRIORITY_MIN = 200;
public static final int FLEXIBLE_SWAP_PRIORITY_MAX = 299;
}
// ❌ 不推荐:混乱的优先级设置
// 优先级设置混乱,导致规则执行顺序不符合预期
2. 规则参数配置
// ✅ 推荐:合理的规则参数
FlexibleRuleDTO rule = FlexibleRuleDTO.builder()
.ruleType(FlexibleRuleType.GRACE_PERIOD)
.lateGraceMinutes(15) // 15分钟迟到宽限
.earlyLeaveGraceMinutes(15) // 15分钟早退宽限
.priority(100) // 优先级100
.build();
// ❌ 不推荐:过大的宽限时间
FlexibleRuleDTO rule = FlexibleRuleDTO.builder()
.ruleType(FlexibleRuleType.GRACE_PERIOD)
.lateGraceMinutes(120) // 2小时宽限太大
.priority(100)
.build();
6.2 性能优化最佳实践
1. 缓存策略
// ✅ 推荐:多级缓存策略
public class CacheStrategy {
// L1缓存:本地缓存(Caffeine)
// 缓存灵活规则,1小时过期
private final Cache<String, List<AttendanceShiftFlexibleRuleDO>> localCache;
// L2缓存:分布式缓存(Redis)
// 缓存打卡结果,24小时过期
// 缓存预热
public void warmUpCache(List<String> shiftIds) {
// 在打卡高峰前预热缓存
}
}
// ❌ 不推荐:没有缓存
// 每次打卡都查询数据库,高峰期性能差
2. 批量处理
// ✅ 推荐:批量异步处理
public class BatchProcessing {
// 使用消息队列批量处理打卡计算
public void processBatch(List<ClockInRequest> requests) {
rocketMQTemplate.asyncSend("BATCH_CLOCK_IN_TOPIC", requests);
}
}
// ❌ 不推荐:同步串行处理
// 一个打卡请求阻塞后续请求
6.3 安全性最佳实践
1. 位置验证安全
// ✅ 推荐:严格的位置验证
public class LocationSecurity {
// 1. 位置信息加密传输
// 2. 验证位置来源(GPS vs WiFi)
// 3. 检测位置模拟
// 4. 记录位置历史用于分析
}
// ❌ 不推荐:宽松的位置验证
// 容易被绕过,存在打卡作弊风险
七、总结
本文详细介绍了考勤打卡灵活规则引擎的设计与实现,主要内容包括:
核心技术点
- 策略模式应用:灵活对调规则和异常宽限规则的策略实现
- 规则调度引擎:协调多个规则的执行顺序和结果聚合
- 位置验证:基于Haversine公式的距离计算和范围验证
- 规则冲突处理:解决多个规则同时适用的冲突问题
- 规则版本管理:支持规则的历史版本追溯和恢复
- 性能优化:缓存策略、批量处理、监控告警
业务价值
- 提升员工满意度:人性化的打卡制度改善工作体验
- 降低管理成本:减少异常打卡处理的审批流程
- 增强组织适应性:适应弹性工作制和远程办公
- 保证工作质量:通过规则设计确保工作时长
- 数据化决策:完整的打卡数据支持管理决策
扩展方向
- AI规则推荐:根据历史数据推荐最优规则配置
- 智能异常检测:使用机器学习检测打卡作弊
- 预测性分析:预测高峰时段,提前优化资源配置
- 移动端优化:优化移动端打卡体验
- 多场景支持:支持远程打卡、外勤打卡等场景
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)