實現上下班自動通知打卡

唐潇唐發表於2024-12-23

背景

由於最近上下班老是忘打卡,想要找一個能夠自動提醒上下班打卡的工具。由於 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 行,算是一個有趣的練手程式,當然還有很多最佳化場景沒有實現,比如保證多節點資料不重複執行,通知的儲存等,只有等後續如果有需求再慢慢最佳化啦。

相關文章