背景

由于最近上下班老是忘打卡,想要找一个能够自动提醒上下班打卡的工具。由于 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 行,算是一个有趣的练手程序,当然还有很多优化场景没有实现,比如保证多节点数据不重复执行,通知的存储等,只有等后续如果有需求再慢慢优化啦。


↙↙↙阅读原文可查看相关链接,并与作者交流