208 lines
6.4 KiB
Rust
208 lines
6.4 KiB
Rust
use chrono::{DateTime, Datelike, Duration, FixedOffset, NaiveDateTime, TimeZone, Weekday};
|
||
|
||
use crate::entity::recurrence_rule;
|
||
use crate::entity::sea_orm_active_enums::RecurrenceType;
|
||
|
||
/// 根据循环规则计算下一次触发时间
|
||
pub fn calculate_next_due(
|
||
rule: &recurrence_rule::Model,
|
||
current_due: DateTime<FixedOffset>,
|
||
) -> Option<DateTime<FixedOffset>> {
|
||
let interval = rule.interval.max(1) as i64;
|
||
let offset = current_due.offset().clone();
|
||
|
||
match rule.r#type {
|
||
RecurrenceType::Hourly => {
|
||
// 每 N 小时
|
||
Some(current_due + Duration::hours(interval))
|
||
}
|
||
RecurrenceType::Daily => {
|
||
// 每 N 天
|
||
Some(current_due + Duration::days(interval))
|
||
}
|
||
RecurrenceType::Weekly => {
|
||
// 每 N 周,可选指定星期几
|
||
if let Some(weekday) = rule.by_weekday {
|
||
// 找到下一个指定的星期几
|
||
let target_weekday = num_to_weekday(weekday);
|
||
let next = current_due + Duration::weeks(interval);
|
||
|
||
// 调整到目标星期几
|
||
let current_weekday = next.weekday();
|
||
let days_ahead = (target_weekday.num_days_from_monday() as i64
|
||
- current_weekday.num_days_from_monday() as i64
|
||
+ 7)
|
||
% 7;
|
||
|
||
if days_ahead == 0 {
|
||
// 同一天,保持当前时间
|
||
Some(next)
|
||
} else {
|
||
Some(next + Duration::days(days_ahead))
|
||
}
|
||
} else {
|
||
Some(current_due + Duration::weeks(interval))
|
||
}
|
||
}
|
||
RecurrenceType::Monthly => {
|
||
// 每 N 月,可选指定几号
|
||
let target_day = rule.by_monthday.unwrap_or(current_due.day() as i32) as u32;
|
||
|
||
let mut year = current_due.year();
|
||
let mut month = current_due.month() as i32 + interval as i32;
|
||
|
||
// 处理年份进位
|
||
while month > 12 {
|
||
month -= 12;
|
||
year += 1;
|
||
}
|
||
|
||
// 处理月份天数不足的情况(如 2 月没有 31 号)
|
||
let day = target_day.min(days_in_month(year, month as u32));
|
||
|
||
let naive = NaiveDateTime::new(
|
||
chrono::NaiveDate::from_ymd_opt(year, month as u32, day)?,
|
||
current_due.time(),
|
||
);
|
||
Some(offset.from_local_datetime(&naive).single()?)
|
||
}
|
||
RecurrenceType::Yearly => {
|
||
// 每 N 年
|
||
let year = current_due.year() + interval as i32;
|
||
let month = current_due.month();
|
||
let day = current_due.day().min(days_in_month(year, month));
|
||
|
||
let naive = NaiveDateTime::new(
|
||
chrono::NaiveDate::from_ymd_opt(year, month, day)?,
|
||
current_due.time(),
|
||
);
|
||
Some(offset.from_local_datetime(&naive).single()?)
|
||
}
|
||
}
|
||
}
|
||
|
||
fn num_to_weekday(num: i32) -> Weekday {
|
||
match num % 7 {
|
||
0 => Weekday::Sun,
|
||
1 => Weekday::Mon,
|
||
2 => Weekday::Tue,
|
||
3 => Weekday::Wed,
|
||
4 => Weekday::Thu,
|
||
5 => Weekday::Fri,
|
||
6 => Weekday::Sat,
|
||
_ => Weekday::Mon,
|
||
}
|
||
}
|
||
|
||
fn days_in_month(year: i32, month: u32) -> u32 {
|
||
match month {
|
||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||
4 | 6 | 9 | 11 => 30,
|
||
2 => {
|
||
if is_leap_year(year) {
|
||
29
|
||
} else {
|
||
28
|
||
}
|
||
}
|
||
_ => 30,
|
||
}
|
||
}
|
||
|
||
fn is_leap_year(year: i32) -> bool {
|
||
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use chrono::{NaiveDate, Timelike};
|
||
use uuid::Uuid;
|
||
|
||
fn make_rule(
|
||
rule_type: RecurrenceType,
|
||
interval: i32,
|
||
by_weekday: Option<i32>,
|
||
by_monthday: Option<i32>,
|
||
) -> recurrence_rule::Model {
|
||
recurrence_rule::Model {
|
||
id: Uuid::new_v4(),
|
||
r#type: rule_type,
|
||
interval,
|
||
by_weekday,
|
||
by_monthday,
|
||
timezone: "Asia/Shanghai".to_string(),
|
||
created_at: chrono::Utc::now().fixed_offset(),
|
||
updated_at: chrono::Utc::now().fixed_offset(),
|
||
}
|
||
}
|
||
|
||
fn make_datetime(year: i32, month: u32, day: u32, hour: u32, min: u32) -> DateTime<FixedOffset> {
|
||
let naive = NaiveDate::from_ymd_opt(year, month, day)
|
||
.unwrap()
|
||
.and_hms_opt(hour, min, 0)
|
||
.unwrap();
|
||
FixedOffset::east_opt(0).unwrap().from_local_datetime(&naive).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn test_hourly() {
|
||
let rule = make_rule(RecurrenceType::Hourly, 2, None, None);
|
||
let current = make_datetime(2024, 1, 15, 10, 30);
|
||
|
||
let next = calculate_next_due(&rule, current).unwrap();
|
||
assert_eq!(next.hour(), 12);
|
||
assert_eq!(next.minute(), 30);
|
||
}
|
||
|
||
#[test]
|
||
fn test_daily() {
|
||
let rule = make_rule(RecurrenceType::Daily, 3, None, None);
|
||
let current = make_datetime(2024, 1, 15, 10, 30);
|
||
|
||
let next = calculate_next_due(&rule, current).unwrap();
|
||
assert_eq!(next.day(), 18);
|
||
}
|
||
|
||
#[test]
|
||
fn test_weekly() {
|
||
let rule = make_rule(RecurrenceType::Weekly, 1, None, None);
|
||
let current = make_datetime(2024, 1, 15, 10, 30);
|
||
|
||
let next = calculate_next_due(&rule, current).unwrap();
|
||
assert_eq!(next.day(), 22);
|
||
}
|
||
|
||
#[test]
|
||
fn test_monthly() {
|
||
let rule = make_rule(RecurrenceType::Monthly, 1, None, Some(15));
|
||
let current = make_datetime(2024, 1, 15, 10, 30);
|
||
|
||
let next = calculate_next_due(&rule, current).unwrap();
|
||
assert_eq!(next.month(), 2);
|
||
assert_eq!(next.day(), 15);
|
||
}
|
||
|
||
#[test]
|
||
fn test_monthly_overflow() {
|
||
// 1月31号 -> 2月没有31号,应该是28号(或29号)
|
||
let rule = make_rule(RecurrenceType::Monthly, 1, None, Some(31));
|
||
let current = make_datetime(2024, 1, 31, 10, 30);
|
||
|
||
let next = calculate_next_due(&rule, current).unwrap();
|
||
assert_eq!(next.month(), 2);
|
||
assert_eq!(next.day(), 29); // 2024 是闰年
|
||
}
|
||
|
||
#[test]
|
||
fn test_yearly() {
|
||
let rule = make_rule(RecurrenceType::Yearly, 1, None, None);
|
||
let current = make_datetime(2024, 6, 15, 10, 30);
|
||
|
||
let next = calculate_next_due(&rule, current).unwrap();
|
||
assert_eq!(next.year(), 2025);
|
||
assert_eq!(next.month(), 6);
|
||
assert_eq!(next.day(), 15);
|
||
}
|
||
}
|