Files
company-ai-platform/app/modules/subscriptions/services/schedule.py
JiuContinent d7db84571d ```
feat: 添加飞书用户模块和订阅功能支持

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

463 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)