```
feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
This commit is contained in:
462
app/modules/subscriptions/services/schedule.py
Normal file
462
app/modules/subscriptions/services/schedule.py
Normal file
@@ -0,0 +1,462 @@
|
||||
import re
|
||||
from calendar import monthrange
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from app.modules.subscriptions.constants import (
|
||||
INVALID_SCHEDULE,
|
||||
INVALID_TIMEZONE,
|
||||
MIN_INTERVAL_MINUTES,
|
||||
SubscriptionScheduleType,
|
||||
)
|
||||
|
||||
_WEEKDAYS = {
|
||||
"一": 0,
|
||||
"二": 1,
|
||||
"三": 2,
|
||||
"四": 3,
|
||||
"五": 4,
|
||||
"六": 5,
|
||||
"日": 6,
|
||||
"天": 6,
|
||||
}
|
||||
_WEEKDAY_NAMES = ("一", "二", "三", "四", "五", "六", "日")
|
||||
_INTERVAL_PATTERN = re.compile(r"每隔\s*(?P<value>\d+)\s*(?P<unit>分钟|小时)")
|
||||
_DAILY_PATTERN = re.compile(r"每天\s*(?P<clock>.+)")
|
||||
_WEEKDAY_PATTERN = re.compile(r"(?:每个)?工作日\s*(?P<clock>.+)")
|
||||
_WEEKLY_PATTERN = re.compile(r"每周(?P<weekday>[一二三四五六日天])\s*(?P<clock>.+)")
|
||||
_MONTHLY_PATTERN = re.compile(
|
||||
r"每月\s*(?P<day>\d{1,2})\s*(?:号|日)\s*(?P<clock>.+)"
|
||||
)
|
||||
_RELATIVE_PATTERN = re.compile(r"(?P<day>今天|明天)\s*(?P<clock>.+)")
|
||||
_ISO_DATE_PATTERN = re.compile(
|
||||
r"(?P<year>\d{4})[-/](?P<month>\d{1,2})[-/](?P<day>\d{1,2})"
|
||||
r"\s+(?P<clock>.+)"
|
||||
)
|
||||
_CHINESE_DATE_PATTERN = re.compile(
|
||||
r"(?P<year>\d{4})年(?P<month>\d{1,2})月(?P<day>\d{1,2})[日号]"
|
||||
r"\s*(?P<clock>.+)"
|
||||
)
|
||||
_COLON_CLOCK_PATTERN = re.compile(r"(?P<hour>\d{1,2}):(?P<minute>\d{1,2})")
|
||||
_CHINESE_CLOCK_PATTERN = re.compile(
|
||||
r"(?P<hour>\d{1,2})点(?:(?P<half>半)|(?P<minute>\d{1,2})分?)?"
|
||||
)
|
||||
|
||||
|
||||
class ScheduleParseError(ValueError):
|
||||
"""Raised when a controlled schedule expression cannot be normalized."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NormalizedSchedule:
|
||||
schedule_type: str
|
||||
schedule_config: dict[str, Any]
|
||||
timezone: str
|
||||
next_run_at: datetime
|
||||
display: str
|
||||
|
||||
|
||||
def parse_schedule(
|
||||
expression: str,
|
||||
timezone_name: str,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> NormalizedSchedule:
|
||||
"""Parse the supported Chinese schedule grammar into a UTC plan."""
|
||||
|
||||
text = _normalize_expression(expression)
|
||||
zone = validate_timezone(timezone_name)
|
||||
now_utc = _as_utc(now)
|
||||
local_now = now_utc.astimezone(zone)
|
||||
|
||||
match = _INTERVAL_PATTERN.fullmatch(text)
|
||||
if match:
|
||||
value = int(match.group("value"))
|
||||
minutes = value * (60 if match.group("unit") == "小时" else 1)
|
||||
if minutes < MIN_INTERVAL_MINUTES:
|
||||
raise ScheduleParseError(f"订阅间隔不得短于 {MIN_INTERVAL_MINUTES} 分钟")
|
||||
try:
|
||||
next_run = now_utc + timedelta(minutes=minutes)
|
||||
except OverflowError as exc:
|
||||
raise ScheduleParseError("订阅间隔过大") from exc
|
||||
config = {
|
||||
"minutes": minutes,
|
||||
"anchor_at": _to_naive_utc(next_run).isoformat(),
|
||||
}
|
||||
display_value = (
|
||||
f"每隔 {value} 小时" if match.group("unit") == "小时" else f"每隔 {value} 分钟"
|
||||
)
|
||||
return NormalizedSchedule(
|
||||
schedule_type=SubscriptionScheduleType.INTERVAL,
|
||||
schedule_config=config,
|
||||
timezone=timezone_name,
|
||||
next_run_at=_to_naive_utc(next_run),
|
||||
display=display_value,
|
||||
)
|
||||
|
||||
match = _DAILY_PATTERN.fullmatch(text)
|
||||
if match:
|
||||
hour, minute = _parse_clock(match.group("clock"))
|
||||
config = {"hour": hour, "minute": minute}
|
||||
return _recurring_schedule(
|
||||
SubscriptionScheduleType.DAILY,
|
||||
config,
|
||||
timezone_name,
|
||||
now_utc,
|
||||
f"每天 {hour:02d}:{minute:02d}",
|
||||
)
|
||||
|
||||
match = _WEEKDAY_PATTERN.fullmatch(text)
|
||||
if match:
|
||||
hour, minute = _parse_clock(match.group("clock"))
|
||||
config = {"hour": hour, "minute": minute}
|
||||
return _recurring_schedule(
|
||||
SubscriptionScheduleType.WEEKDAY,
|
||||
config,
|
||||
timezone_name,
|
||||
now_utc,
|
||||
f"工作日 {hour:02d}:{minute:02d}",
|
||||
)
|
||||
|
||||
match = _WEEKLY_PATTERN.fullmatch(text)
|
||||
if match:
|
||||
hour, minute = _parse_clock(match.group("clock"))
|
||||
weekday = _WEEKDAYS[match.group("weekday")]
|
||||
config = {"weekday": weekday, "hour": hour, "minute": minute}
|
||||
return _recurring_schedule(
|
||||
SubscriptionScheduleType.WEEKLY,
|
||||
config,
|
||||
timezone_name,
|
||||
now_utc,
|
||||
f"每周{_WEEKDAY_NAMES[weekday]} {hour:02d}:{minute:02d}",
|
||||
)
|
||||
|
||||
match = _MONTHLY_PATTERN.fullmatch(text)
|
||||
if match:
|
||||
day = int(match.group("day"))
|
||||
if not 1 <= day <= 31:
|
||||
raise ScheduleParseError("每月日期必须在 1 到 31 之间")
|
||||
hour, minute = _parse_clock(match.group("clock"))
|
||||
config = {"day": day, "hour": hour, "minute": minute}
|
||||
return _recurring_schedule(
|
||||
SubscriptionScheduleType.MONTHLY,
|
||||
config,
|
||||
timezone_name,
|
||||
now_utc,
|
||||
f"每月 {day} 号 {hour:02d}:{minute:02d}",
|
||||
)
|
||||
|
||||
match = _RELATIVE_PATTERN.fullmatch(text)
|
||||
if match:
|
||||
hour, minute = _parse_clock(match.group("clock"))
|
||||
offset = 1 if match.group("day") == "明天" else 0
|
||||
target_date = local_now.date() + timedelta(days=offset)
|
||||
return _once_schedule(
|
||||
target_date,
|
||||
hour,
|
||||
minute,
|
||||
timezone_name,
|
||||
now_utc,
|
||||
f"{match.group('day')} {hour:02d}:{minute:02d}",
|
||||
)
|
||||
|
||||
match = _ISO_DATE_PATTERN.fullmatch(text) or _CHINESE_DATE_PATTERN.fullmatch(text)
|
||||
if match:
|
||||
try:
|
||||
target_date = date(
|
||||
int(match.group("year")),
|
||||
int(match.group("month")),
|
||||
int(match.group("day")),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ScheduleParseError("日期不存在") from exc
|
||||
hour, minute = _parse_clock(match.group("clock"))
|
||||
return _once_schedule(
|
||||
target_date,
|
||||
hour,
|
||||
minute,
|
||||
timezone_name,
|
||||
now_utc,
|
||||
f"{target_date.isoformat()} {hour:02d}:{minute:02d}",
|
||||
)
|
||||
|
||||
raise ScheduleParseError(
|
||||
f"{INVALID_SCHEDULE}。示例:每天 09:00、每周一 18:00、每隔 30 分钟"
|
||||
)
|
||||
|
||||
|
||||
def next_occurrence(
|
||||
schedule_type: str,
|
||||
schedule_config: dict[str, Any],
|
||||
timezone_name: str,
|
||||
*,
|
||||
after: datetime,
|
||||
) -> datetime | None:
|
||||
"""Return the first UTC occurrence strictly after ``after``."""
|
||||
|
||||
zone = validate_timezone(timezone_name)
|
||||
after_utc = _as_utc(after)
|
||||
plan_type = SubscriptionScheduleType(schedule_type)
|
||||
|
||||
if plan_type == SubscriptionScheduleType.ONCE:
|
||||
run_at = _parse_stored_utc(schedule_config["run_at"])
|
||||
return _to_naive_utc(run_at) if run_at > after_utc else None
|
||||
|
||||
if plan_type == SubscriptionScheduleType.INTERVAL:
|
||||
interval = timedelta(minutes=int(schedule_config["minutes"]))
|
||||
anchor = _parse_stored_utc(schedule_config["anchor_at"])
|
||||
if anchor > after_utc:
|
||||
return _to_naive_utc(anchor)
|
||||
elapsed = after_utc - anchor
|
||||
steps = elapsed // interval + 1
|
||||
return _to_naive_utc(anchor + interval * steps)
|
||||
|
||||
hour = int(schedule_config["hour"])
|
||||
minute = int(schedule_config["minute"])
|
||||
local_after = after_utc.astimezone(zone)
|
||||
|
||||
if plan_type == SubscriptionScheduleType.DAILY:
|
||||
return _next_daily(local_after, hour, minute, zone)
|
||||
if plan_type == SubscriptionScheduleType.WEEKDAY:
|
||||
return _next_weekday(local_after, hour, minute, zone)
|
||||
if plan_type == SubscriptionScheduleType.WEEKLY:
|
||||
weekday = int(schedule_config["weekday"])
|
||||
return _next_weekly(local_after, weekday, hour, minute, zone)
|
||||
if plan_type == SubscriptionScheduleType.MONTHLY:
|
||||
day = int(schedule_config["day"])
|
||||
return _next_monthly(local_after, day, hour, minute, zone)
|
||||
raise ScheduleParseError(INVALID_SCHEDULE)
|
||||
|
||||
|
||||
def is_in_quiet_hours(
|
||||
current: datetime,
|
||||
timezone_name: str,
|
||||
quiet_start: time | str | None,
|
||||
quiet_end: time | str | None,
|
||||
) -> bool:
|
||||
if quiet_start is None or quiet_end is None:
|
||||
return False
|
||||
start = _coerce_time(quiet_start)
|
||||
end = _coerce_time(quiet_end)
|
||||
if start == end:
|
||||
return False
|
||||
local_time = _as_utc(current).astimezone(validate_timezone(timezone_name)).time()
|
||||
local_time = local_time.replace(tzinfo=None)
|
||||
if start < end:
|
||||
return start <= local_time < end
|
||||
return local_time >= start or local_time < end
|
||||
|
||||
|
||||
def next_quiet_end(
|
||||
current: datetime,
|
||||
timezone_name: str,
|
||||
quiet_start: time | str,
|
||||
quiet_end: time | str,
|
||||
) -> datetime:
|
||||
"""Return quiet-window end as a naive UTC timestamp."""
|
||||
|
||||
zone = validate_timezone(timezone_name)
|
||||
now_local = _as_utc(current).astimezone(zone)
|
||||
start = _coerce_time(quiet_start)
|
||||
end = _coerce_time(quiet_end)
|
||||
end_date = now_local.date()
|
||||
if start > end and now_local.time().replace(tzinfo=None) >= start:
|
||||
end_date += timedelta(days=1)
|
||||
candidate = _local_candidate(end_date, end.hour, end.minute, zone)
|
||||
if candidate is None:
|
||||
candidate = _first_valid_local_after(end_date, end.hour, end.minute, zone)
|
||||
return _to_naive_utc(candidate)
|
||||
|
||||
|
||||
def validate_timezone(timezone_name: str) -> ZoneInfo:
|
||||
try:
|
||||
return ZoneInfo(timezone_name)
|
||||
except (ZoneInfoNotFoundError, ValueError, TypeError) as exc:
|
||||
raise ScheduleParseError(INVALID_TIMEZONE) from exc
|
||||
|
||||
|
||||
def parse_quiet_clock(value: str) -> time:
|
||||
hour, minute = _parse_clock(_normalize_expression(value))
|
||||
return time(hour=hour, minute=minute)
|
||||
|
||||
|
||||
def _recurring_schedule(
|
||||
schedule_type: str,
|
||||
config: dict[str, Any],
|
||||
timezone_name: str,
|
||||
now_utc: datetime,
|
||||
display: str,
|
||||
) -> NormalizedSchedule:
|
||||
next_run = next_occurrence(
|
||||
schedule_type,
|
||||
config,
|
||||
timezone_name,
|
||||
after=now_utc,
|
||||
)
|
||||
if next_run is None:
|
||||
raise ScheduleParseError(INVALID_SCHEDULE)
|
||||
return NormalizedSchedule(
|
||||
schedule_type=schedule_type,
|
||||
schedule_config=config,
|
||||
timezone=timezone_name,
|
||||
next_run_at=next_run,
|
||||
display=display,
|
||||
)
|
||||
|
||||
|
||||
def _once_schedule(
|
||||
target_date: date,
|
||||
hour: int,
|
||||
minute: int,
|
||||
timezone_name: str,
|
||||
now_utc: datetime,
|
||||
display: str,
|
||||
) -> NormalizedSchedule:
|
||||
zone = validate_timezone(timezone_name)
|
||||
target = _local_candidate(target_date, hour, minute, zone)
|
||||
if target is None:
|
||||
raise ScheduleParseError("该本地时间不存在")
|
||||
if target <= now_utc:
|
||||
raise ScheduleParseError("执行时间必须晚于当前时间")
|
||||
run_at = _to_naive_utc(target)
|
||||
return NormalizedSchedule(
|
||||
schedule_type=SubscriptionScheduleType.ONCE,
|
||||
schedule_config={"run_at": run_at.isoformat()},
|
||||
timezone=timezone_name,
|
||||
next_run_at=run_at,
|
||||
display=display,
|
||||
)
|
||||
|
||||
|
||||
def _next_daily(local_after: datetime, hour: int, minute: int, zone: ZoneInfo) -> datetime:
|
||||
for offset in range(0, 370):
|
||||
candidate = _local_candidate(local_after.date() + timedelta(days=offset), hour, minute, zone)
|
||||
if candidate is not None and candidate > local_after.astimezone(UTC):
|
||||
return _to_naive_utc(candidate)
|
||||
raise ScheduleParseError(INVALID_SCHEDULE)
|
||||
|
||||
|
||||
def _next_weekday(local_after: datetime, hour: int, minute: int, zone: ZoneInfo) -> datetime:
|
||||
for offset in range(0, 14):
|
||||
target_date = local_after.date() + timedelta(days=offset)
|
||||
if target_date.weekday() >= 5:
|
||||
continue
|
||||
candidate = _local_candidate(target_date, hour, minute, zone)
|
||||
if candidate is not None and candidate > local_after.astimezone(UTC):
|
||||
return _to_naive_utc(candidate)
|
||||
raise ScheduleParseError(INVALID_SCHEDULE)
|
||||
|
||||
|
||||
def _next_weekly(
|
||||
local_after: datetime,
|
||||
weekday: int,
|
||||
hour: int,
|
||||
minute: int,
|
||||
zone: ZoneInfo,
|
||||
) -> datetime:
|
||||
offset = (weekday - local_after.weekday()) % 7
|
||||
for weeks in range(0, 3):
|
||||
target_date = local_after.date() + timedelta(days=offset + weeks * 7)
|
||||
candidate = _local_candidate(target_date, hour, minute, zone)
|
||||
if candidate is not None and candidate > local_after.astimezone(UTC):
|
||||
return _to_naive_utc(candidate)
|
||||
raise ScheduleParseError(INVALID_SCHEDULE)
|
||||
|
||||
|
||||
def _next_monthly(
|
||||
local_after: datetime,
|
||||
day: int,
|
||||
hour: int,
|
||||
minute: int,
|
||||
zone: ZoneInfo,
|
||||
) -> datetime:
|
||||
year = local_after.year
|
||||
month = local_after.month
|
||||
for _ in range(0, 240):
|
||||
if day <= monthrange(year, month)[1]:
|
||||
candidate = _local_candidate(date(year, month, day), hour, minute, zone)
|
||||
if candidate is not None and candidate > local_after.astimezone(UTC):
|
||||
return _to_naive_utc(candidate)
|
||||
month += 1
|
||||
if month == 13:
|
||||
year += 1
|
||||
month = 1
|
||||
raise ScheduleParseError(INVALID_SCHEDULE)
|
||||
|
||||
|
||||
def _normalize_expression(expression: str) -> str:
|
||||
text = re.sub(r"\s+", " ", str(expression or "").strip()).replace(":", ":")
|
||||
if not text:
|
||||
raise ScheduleParseError(INVALID_SCHEDULE)
|
||||
return text
|
||||
|
||||
|
||||
def _parse_clock(value: str) -> tuple[int, int]:
|
||||
text = value.strip().replace(":", ":")
|
||||
match = _COLON_CLOCK_PATTERN.fullmatch(text)
|
||||
if match:
|
||||
hour = int(match.group("hour"))
|
||||
minute = int(match.group("minute"))
|
||||
else:
|
||||
match = _CHINESE_CLOCK_PATTERN.fullmatch(text)
|
||||
if not match:
|
||||
raise ScheduleParseError("时间必须使用 HH:MM 或 H点M分")
|
||||
hour = int(match.group("hour"))
|
||||
minute = 30 if match.group("half") else int(match.group("minute") or 0)
|
||||
if not 0 <= hour <= 23 or not 0 <= minute <= 59:
|
||||
raise ScheduleParseError("时间超出有效范围")
|
||||
return hour, minute
|
||||
|
||||
|
||||
def _local_candidate(
|
||||
target_date: date,
|
||||
hour: int,
|
||||
minute: int,
|
||||
zone: ZoneInfo,
|
||||
) -> datetime | None:
|
||||
naive = datetime.combine(target_date, time(hour=hour, minute=minute))
|
||||
aware = naive.replace(tzinfo=zone)
|
||||
roundtrip = aware.astimezone(UTC).astimezone(zone).replace(tzinfo=None)
|
||||
if roundtrip != naive:
|
||||
return None
|
||||
return aware.astimezone(UTC)
|
||||
|
||||
|
||||
def _first_valid_local_after(
|
||||
target_date: date,
|
||||
hour: int,
|
||||
minute: int,
|
||||
zone: ZoneInfo,
|
||||
) -> datetime:
|
||||
base = datetime.combine(target_date, time(hour=hour, minute=minute))
|
||||
for offset in range(0, 181):
|
||||
candidate = base + timedelta(minutes=offset)
|
||||
aware = _local_candidate(candidate.date(), candidate.hour, candidate.minute, zone)
|
||||
if aware is not None:
|
||||
return aware
|
||||
raise ScheduleParseError("安静时段结束时间无效")
|
||||
|
||||
|
||||
def _coerce_time(value: time | str) -> time:
|
||||
if isinstance(value, time):
|
||||
return value.replace(tzinfo=None, second=0, microsecond=0)
|
||||
return parse_quiet_clock(value)
|
||||
|
||||
|
||||
def _as_utc(value: datetime | None) -> datetime:
|
||||
if value is None:
|
||||
return datetime.now(UTC)
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
def _to_naive_utc(value: datetime) -> datetime:
|
||||
return _as_utc(value).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _parse_stored_utc(value: str | datetime) -> datetime:
|
||||
parsed = value if isinstance(value, datetime) else datetime.fromisoformat(str(value))
|
||||
return _as_utc(parsed)
|
||||
Reference in New Issue
Block a user