feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
463 lines
15 KiB
Python
463 lines
15 KiB
Python
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)
|