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,321 @@
import re
from datetime import UTC, datetime
from typing import Any
from zoneinfo import ZoneInfo
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.application.feishu.delivery import send_text_if_configured
from app.application.feishu.results import command_result
from app.modules.feishu.constants import FeishuCommandName, FeishuReplyType
from app.modules.feishu.service import FeishuService
from app.modules.feishu_users.principal import FeishuPrincipal
from app.modules.subscriptions.constants import (
DAILY_DELIVERY_LIMIT_REACHED,
PushDeliveryStatus,
PushSubscriptionStatus,
SubscriptionScheduleType,
SubscriptionTargetType,
)
from app.modules.subscriptions.models import PushDelivery, PushSubscription
from app.modules.subscriptions.services import (
ScheduleParseError,
SubscriptionManagementService,
parse_schedule,
)
SUBSCRIPTION_TITLE = "订阅管理"
SUBSCRIPTION_HELP = (
"订阅指令格式:\n"
"订阅 每天 09:00提示词\n"
"订阅 工作日 18:00提示词\n"
"订阅 每周一 09:00提示词\n"
"订阅 每月1号 09:00提示词\n"
"订阅 每隔30分钟提示词\n"
"我的订阅\n"
"暂停订阅 <订阅编号>\n"
"恢复订阅 <订阅编号>\n"
"退订 <订阅编号>\n"
"设置时区 Asia/Shanghai\n"
"设置安静时段 22:00-07:00\n"
"关闭安静时段"
)
_LIST_COMMANDS = {"我的订阅", "查看订阅"}
_PAUSE_PATTERN = re.compile(
r"^(?:暂停订阅|停用订阅)\s+(SUB-[A-Za-z0-9-]+)$",
re.IGNORECASE,
)
_RESUME_PATTERN = re.compile(
r"^(?:恢复订阅|启用订阅)\s+(SUB-[A-Za-z0-9-]+)$",
re.IGNORECASE,
)
_CANCEL_PATTERN = re.compile(
r"^(?:退订|取消订阅)\s+(SUB-[A-Za-z0-9-]+)$",
re.IGNORECASE,
)
_TIMEZONE_PATTERN = re.compile(r"^设置时区\s+(\S+)$")
_QUIET_PATTERN = re.compile(
r"^设置安静时段\s+(\d{1,2}(?:[:]\d{1,2}))"
r"\s*(?:-|~|至|到)\s*(\d{1,2}(?:[:]\d{1,2}))$"
)
_CLOSE_QUIET_COMMAND = "关闭安静时段"
_COMMAND_PREFIXES = (
"订阅",
"我的订阅",
"查看订阅",
"暂停订阅",
"停用订阅",
"恢复订阅",
"启用订阅",
"退订",
"取消订阅",
"设置时区",
"设置安静时段",
"关闭安静时段",
)
def handle_subscription_command(
db: Session,
feishu: FeishuService,
command_text: str,
principal: FeishuPrincipal,
auto_reply: bool,
) -> dict[str, Any] | None:
"""Handle self-service subscriptions for a verified Feishu principal."""
text = command_text.strip()
if not text.startswith(_COMMAND_PREFIXES):
return None
service = SubscriptionManagementService(db)
command = _command_name(text)
try:
content = _execute(service, text, principal)
except HTTPException as exc:
db.rollback()
content = _error_content(exc)
return _result(feishu, command, content, principal, auto_reply)
def _execute(
service: SubscriptionManagementService,
command_text: str,
principal: FeishuPrincipal,
) -> str:
if command_text.startswith("订阅"):
parts = _create_parts(command_text, principal.timezone)
if parts is None:
return f"无法识别订阅时间或提示词。\n\n{SUBSCRIPTION_HELP}"
schedule_expression, prompt = parts
if principal.chat_type in {"group", "group_chat"}:
subscription, schedule = service.create_group(
principal,
schedule_expression,
prompt,
)
else:
subscription, schedule = service.create_private(
principal,
schedule_expression,
prompt,
)
return (
"订阅已启用。\n"
f"编号:{subscription.code}\n"
f"计划:{schedule.display}\n"
f"时区:{schedule.timezone}\n"
f"下次执行:{_format_next(schedule.next_run_at, schedule.timezone)}\n"
f"暂停命令:暂停订阅 {subscription.code}"
)
if command_text in _LIST_COMMANDS:
return _list_content(
service.list_for_owner(principal),
service.latest_deliveries_for_owner(principal),
)
pause_match = _PAUSE_PATTERN.fullmatch(command_text)
if pause_match:
record = service.pause(principal, pause_match.group(1))
return f"订阅已暂停。\n编号:{record.code}\n恢复命令:恢复订阅 {record.code}"
resume_match = _RESUME_PATTERN.fullmatch(command_text)
if resume_match:
record = service.resume(principal, resume_match.group(1))
return (
"订阅已恢复。\n"
f"编号:{record.code}\n"
f"下次执行:{_format_next(record.next_run_at, record.timezone)}\n"
f"暂停命令:暂停订阅 {record.code}"
)
cancel_match = _CANCEL_PATTERN.fullmatch(command_text)
if cancel_match:
record = service.cancel(principal, cancel_match.group(1))
return f"已退订。\n编号:{record.code}"
timezone_match = _TIMEZONE_PATTERN.fullmatch(command_text)
if timezone_match:
owner = service.set_timezone(principal, timezone_match.group(1))
return f"时区已设置为 {owner.timezone}"
quiet_match = _QUIET_PATTERN.fullmatch(command_text)
if quiet_match:
owner = service.set_quiet_hours(
principal,
quiet_match.group(1),
quiet_match.group(2),
)
return (
"安静时段已设置。\n"
f"{owner.quiet_hours_start.strftime('%H:%M')}"
f"-{owner.quiet_hours_end.strftime('%H:%M')}"
)
if command_text == _CLOSE_QUIET_COMMAND:
service.clear_quiet_hours(principal)
return "安静时段已关闭。"
return SUBSCRIPTION_HELP
def _create_parts(command_text: str, timezone_name: str) -> tuple[str, str] | None:
payload = command_text.removeprefix("订阅").strip()
separator_indexes = [
index for index, character in enumerate(payload) if character in {":", ""}
]
for index in reversed(separator_indexes):
schedule_expression = payload[:index].strip()
prompt = payload[index + 1 :].strip()
if not schedule_expression or not prompt:
continue
try:
parse_schedule(schedule_expression, timezone_name)
except ScheduleParseError:
continue
return schedule_expression, prompt
return None
def _list_content(
records: list[PushSubscription],
latest_deliveries: dict[int, PushDelivery],
) -> str:
if not records:
return "当前没有订阅。\n\n" + SUBSCRIPTION_HELP.splitlines()[0]
lines = ["我的订阅:"]
for record in records:
prompt = record.prompt if len(record.prompt) <= 40 else f"{record.prompt[:40]}"
target = (
"私聊"
if record.target_type == SubscriptionTargetType.USER
else "当前群"
)
lines.append(
f"{record.code}{_status_name(record.status)}{target}\n"
f"{_schedule_name(record)}|下次 {_format_next(record.next_run_at, record.timezone)}\n"
f"{prompt}{_delivery_note(latest_deliveries.get(record.id))}"
)
return "\n\n".join(lines)
def _schedule_name(record: PushSubscription) -> str:
config = record.schedule_config
if record.schedule_type == SubscriptionScheduleType.ONCE:
return "单次"
if record.schedule_type == SubscriptionScheduleType.INTERVAL:
return f"每隔 {config['minutes']} 分钟"
clock = f"{int(config['hour']):02d}:{int(config['minute']):02d}"
if record.schedule_type == SubscriptionScheduleType.DAILY:
return f"每天 {clock}"
if record.schedule_type == SubscriptionScheduleType.WEEKDAY:
return f"工作日 {clock}"
if record.schedule_type == SubscriptionScheduleType.WEEKLY:
names = "一二三四五六日"
return f"每周{names[int(config['weekday'])]} {clock}"
return f"每月 {config['day']}{clock}"
def _status_name(status_value: str) -> str:
return {
PushSubscriptionStatus.ACTIVE: "已启用",
PushSubscriptionStatus.PAUSED: "已暂停",
PushSubscriptionStatus.CANCELLED: "已退订",
PushSubscriptionStatus.COMPLETED: "已完成",
}.get(status_value, status_value)
def _delivery_note(delivery: PushDelivery | None) -> str:
if delivery is None:
return ""
if (
delivery.status == PushDeliveryStatus.SKIPPED
and delivery.last_error == DAILY_DELIVERY_LIMIT_REACHED
):
return "\n最近投递:因每日最多 96 条限制已跳过"
if delivery.status == PushDeliveryStatus.RETRY:
return "\n最近投递:发送失败,正在按 1/5/15 分钟重试"
if delivery.status == PushDeliveryStatus.FAILED:
return "\n最近投递:重试后仍失败,请联系管理员"
if delivery.status == PushDeliveryStatus.SKIPPED:
return "\n最近投递:因账号、订阅状态或安静时段限制已跳过"
return ""
def _format_next(value: datetime | None, timezone_name: str) -> str:
if value is None:
return ""
aware = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
return aware.astimezone(ZoneInfo(timezone_name)).strftime("%Y-%m-%d %H:%M")
def _command_name(command_text: str) -> FeishuCommandName:
if command_text in _LIST_COMMANDS:
return FeishuCommandName.SUBSCRIPTION_LIST
if _PAUSE_PATTERN.fullmatch(command_text):
return FeishuCommandName.SUBSCRIPTION_PAUSE
if _RESUME_PATTERN.fullmatch(command_text):
return FeishuCommandName.SUBSCRIPTION_RESUME
if _CANCEL_PATTERN.fullmatch(command_text):
return FeishuCommandName.SUBSCRIPTION_CANCEL
if _TIMEZONE_PATTERN.fullmatch(command_text):
return FeishuCommandName.SUBSCRIPTION_TIMEZONE
if _QUIET_PATTERN.fullmatch(command_text) or command_text == _CLOSE_QUIET_COMMAND:
return FeishuCommandName.SUBSCRIPTION_QUIET_HOURS
return FeishuCommandName.SUBSCRIPTION_CREATE
def _error_content(exc: HTTPException) -> str:
detail = str(exc.detail)
if exc.status_code == 404:
return "没有找到该订阅,请先发送“我的订阅”确认编号。"
if exc.status_code == 409 and "50" in detail:
return "已达到最多 50 个启用订阅,请先暂停或退订现有订阅。"
if exc.status_code == 403:
return "当前飞书账号无权执行该订阅操作。"
return f"订阅指令未执行:{detail}\n\n{SUBSCRIPTION_HELP}"
def _result(
feishu: FeishuService,
command: FeishuCommandName,
content: str,
principal: FeishuPrincipal,
auto_reply: bool,
) -> dict[str, Any]:
response = (
send_text_if_configured(
feishu,
principal.chat_id,
content,
principal.user_code,
)
if auto_reply
else None
)
return command_result(
command,
FeishuReplyType.TEXT,
SUBSCRIPTION_TITLE,
content,
response,
)