由于最近上下班老是忘打卡,想要找一个能够自动提醒上下班打卡的工具。由于 iPhone 和公司现有的工具都只能选择固定时间提醒,而不能够排除掉节假日,所以我自己开发了一个小工具,实现自动在企业微信推送上下班打卡消息。
首先描述一下需求,我只需要在工作日发送上下班的通知,并且如果第二天是放假,应该早一点发送通知,并且需要支持不同用户配置不同的推送时间。
有了需求之后简单描述一下实现方案,配置使用配置中心下发配置即可,首先需要获取到节假日的数据信息,可以从holiday-cn获取到节假日和哪一天需要补班的信息,我们这里会定时拉取节假日信息缓存到本地,对外暴露一个接口给定时任务中心调用,会在调用的时候检查当前时间是否需要进行通知,如果需要通知,会生成一个 task,通过时间轮延时执行。整体架构如下
时间信息通过DateUtil来解析,时间轮通过 netty 提供的工具类来完成。netty 提供的工具类非常好用,简易代码如下
HashedWheelTimer timer = new HashedWheelTimer();
// 延时5s执行
timer.newTimeout(timeout -> {
log.info("delay execute");
}, 5, TimeUnit.SECONDS);
Runtime.getRuntime().addShutdownHook(new Thread(timer::stop));
task
会返回一个 timeout 对象,可以检测任务是否已经过期,也可以手动取消任务。
基于以上的前提,我们简要过一下代码,首先在程序启动时,需要拉取节假日信息到本地缓存 (当然,也需要定时更新缓存)
private void getHolidays() {
for (String holidayUrl : HOLIDAY_URL_LIST) {
String url = String.format(holidayUrl, getCurrentYear());
try (HttpResponse response = HttpUtil.createGet(url).timeout(5000).execute()) {
if (response.getStatus() != 200) {
log.error("can't find latest holiday, pls retry..");
continue;
}
String resp = response.body();
Object days = JSONPath.read(resp, "$.days");
List<Map<String, Object>> dayList = JSON.parseObject(JSON.toJSONString(days),
new TypeReference<List<Map<String, Object>>>() {
});
holidays.clear();
offDays.clear();
dayList.forEach(day -> {
if ((boolean) day.get("isOffDay")) {
holidays.add(String.valueOf(day.get("date")));
} else {
offDays.add(String.valueOf(day.get("date")));
}
});
return;
} catch (Throwable t) {
log.error("request {} err, e: ", url, t);
}
}
}
这里我们需要区分节假日和补班日 2 个缓存时间,节假日不需要发送通知,而补班日需要发送通知
String today = DateUtil.today();
// 非节假日和补班日才需要发送通知
if ((isWeekDay() && !holidays.contains(today)) ||
(!isWeekDay() && offDays.contains(today))) {
Map<String, PunchConfigVO.PunchTime> persons = ConfigCenterUtil.PUNCH.getPersons();
Set<String> customPersons = new HashSet<>();
persons.forEach((empNo, punchTime) -> {
customPersons.add(empNo);
calculateTask(empNo, punchTime);
});
PunchConfigVO.DefaultConfig defaultConfig = ConfigCenterUtil.PUNCH.getDefaultConfig();
defaultConfig.getPersons().removeAll(customPersons);
for (String person : defaultConfig.getPersons()) {
calculateTask(person, new PunchConfigVO.PunchTime(defaultConfig.getClockIn(),
defaultConfig.getClockOut(), defaultConfig.getBeforeWeekendClockOut(),
defaultConfig.getAlertTamp()));
}
}
计算是否需要发送通知,以及什么是否发送通知的核心逻辑如下
private void calculateTask(String empNo, PunchConfigVO.PunchTime punchTime) {
Set<String> targetAlertStamp = todayTasks.get(empNo);
// 如果当天没有上班通知,才需要加上班通知的task
if (targetAlertStamp == null || !targetAlertStamp.contains(punchTime.getClockIn())) {
// 计算还剩多少时间还需要发送通知
long clockInStamp = getSecUntilTarget(punchTime.getClockIn());
if (clockInStamp > 0) {
// 通知时间大于当前时间才需要通知
todayTasks.computeIfAbsent(empNo, k -> new ConcurrentSkipListSet<>())
.add(punchTime.getClockOut());
timeWheelManager.addTimer(new PunchTask(empNo, PunchType.CLOCK_IN,
punchTime.getClockIn()), clockInStamp);
}
// 上班前提前通知
long inTargetStamp = clockInStamp - punchTime.getAlertTamp();
if (inTargetStamp > 0) {
todayTasks.computeIfAbsent(empNo, k -> new ConcurrentSkipListSet<>())
.add(punchTime.getClockIn());
timeWheelManager.addTimer(
new PunchTask(empNo, PunchType.BEFORE_CLOCK_IN, getBeforeTargetStamp(
punchTime.getClockIn(), punchTime.getAlertTamp())),
inTargetStamp);
}
}
String nextDay = DateUtil.offsetDay(new Date(), 1).toDateStr();
if (holidays.contains(nextDay) || nextIsWeekend()) {
// 第二天放假时,提前通知
long beforeWeekendStamp = getSecUntilTarget(punchTime.getBeforeWeekendClockOut());
long outTargetStamp = beforeWeekendStamp - punchTime.getAlertTamp();
calculateClockOutTask(empNo, punchTime.getBeforeWeekendClockOut(), punchTime.getAlertTamp(), outTargetStamp);
} else {
if (targetAlertStamp == null || !targetAlertStamp.contains(punchTime.getClockOut())) {
long clockOutStamp = getSecUntilTarget(punchTime.getClockOut());
long outTargetStamp = clockOutStamp - punchTime.getAlertTamp();
calculateClockOutTask(empNo, punchTime.getClockOut(), punchTime.getAlertTamp(), outTargetStamp);
}
}
}
private void calculateClockOutTask(String empNo, String clockOut, int alertStamp, long clockOutStamp) {
if (clockOutStamp > 0) {
todayTasks.computeIfAbsent(empNo, k -> new ConcurrentSkipListSet<>())
.add(clockOut);
timeWheelManager.addTimer(
new PunchTask(empNo, PunchType.CLOCK_OUT, clockOut), clockOutStamp);
}
long outTargetStamp = clockOutStamp - alertStamp;
if (outTargetStamp > 0) {
todayTasks.computeIfAbsent(empNo, k -> new ConcurrentSkipListSet<>())
.add(clockOut);
timeWheelManager.addTimer(
new PunchTask(empNo, PunchType.BEFORE_CLOCK_OUT, getBeforeTargetStamp(
clockOut, alertStamp)),
outTargetStamp);
}
}
时间轮管理类负责新增和清理任务,如果当前用户没有对应的通知任务,直接添加 task;但是如果存在,并且任务没有过期,就需要将缓存中的 task 取消,然后将新的 task 重新添加进去,避免修改了任务执行之间之后重复发送通知
public synchronized void addTimer(HolidayManager.PunchTask task, long after) {
TimerKey key = new TimerKey(task.getEmpNo(), task.getType());
TimeoutTask oldTask = timerTasks.get(key);
// 任务如果存在,需要清理掉已有的任务,再写入新的任务
if (oldTask != null && !oldTask.getTimeout().isExpired() &&
!oldTask.getTargetStamp().equals(task.getTargetStamp())) {
oldTask.getTimeout().cancel();
Timeout timeout = timer.newTimeout(task, after, TimeUnit.SECONDS);
timerTasks.put(key, new TimeoutTask(timeout, task.getTargetStamp()));
log.info("task will be execute after {}s, empNo: {}", after, task.getEmpNo());
} else if (oldTask == null) {
// 任务不存在则直接新增
Timeout timeout = timer.newTimeout(task, after, TimeUnit.SECONDS);
timerTasks.put(key, new TimeoutTask(timeout, task.getTargetStamp()));
log.info("task will be execute after {}s, empNo: {}", after, task.getEmpNo());
}
}
定时通知效果如下
以上就是上下班通知提醒的核心逻辑,整体核心代码不到 200 行,算是一个有趣的练手程序,当然还有很多优化场景没有实现,比如保证多节点数据不重复执行,通知的存储等,只有等后续如果有需求再慢慢优化啦。