feat: 添加飞书用户模块和订阅功能支持

- 新增feishu_users模块用于处理飞书用户身份验证和权限管理
- 新增subscriptions模块用于处理订阅相关功能
- 新增personalization模块用于个性化服务
- 在alembic迁移配置中注册新的模型模块
- 在API路由器中添加feishu_users和subscriptions路由
- 实现事件调度服务的改进,包括错误处理和状态更新优化
- 添加飞书命令处理的权限检查机制
- 实现飞书应用票据事件处理
- 改进审计日志记录功能
```
This commit is contained in:
2026-07-27 08:02:17 +08:00
parent db751f03b4
commit d7db84571d
148 changed files with 17110 additions and 765 deletions

View 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)