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,498 @@
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.http.pagination import bounded_limit, bounded_offset
from app.core.utils.time import utc_now
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.feishu_users.constants import (
FeishuCapability,
FeishuUserRole,
FeishuUserStatus,
)
from app.modules.feishu_users.models import FeishuUser
from app.modules.feishu_users.principal import FeishuPrincipal
from app.modules.subscriptions.constants import (
EMPTY_PROMPT,
INVALID_GROUP_TARGET,
INVALID_QUIET_HOURS,
MAX_ACTIVE_SUBSCRIPTIONS,
PushSubscriptionStatus,
SUBSCRIPTION_LIMIT_REACHED,
SUBSCRIPTION_NOT_FOUND,
SubscriptionAuditAction,
SubscriptionScheduleType,
SubscriptionTargetType,
)
from app.modules.subscriptions.models import PushDelivery, PushSubscription
from app.modules.subscriptions.services.schedule import (
NormalizedSchedule,
ScheduleParseError,
next_occurrence,
parse_quiet_clock,
parse_schedule,
validate_timezone,
)
class SubscriptionManagementService:
"""Manage subscriptions only through authenticated Feishu principals."""
def __init__(self, db: Session):
self.db = db
def create_private(
self,
principal: FeishuPrincipal,
schedule_expression: str,
prompt: str,
*,
now: datetime | None = None,
) -> tuple[PushSubscription, NormalizedSchedule]:
principal.require_capability(FeishuCapability.PRIVATE_SUBSCRIPTION)
owner = self._active_owner(principal, for_update=True)
return self._create(
owner=owner,
target_type=SubscriptionTargetType.USER,
target_id=owner.open_id,
schedule_expression=schedule_expression,
prompt=prompt,
now=now,
)
def create_group(
self,
principal: FeishuPrincipal,
schedule_expression: str,
prompt: str,
*,
now: datetime | None = None,
) -> tuple[PushSubscription, NormalizedSchedule]:
principal.require_capability(FeishuCapability.GROUP_SUBSCRIPTION)
owner = self._active_owner(principal, for_update=True)
if (
owner.role != FeishuUserRole.ADMIN
or not principal.chat_id
or principal.chat_type not in {"group", "group_chat"}
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=INVALID_GROUP_TARGET,
)
return self._create(
owner=owner,
target_type=SubscriptionTargetType.CHAT,
target_id=principal.chat_id,
schedule_expression=schedule_expression,
prompt=prompt,
now=now,
)
def list_for_owner(self, principal: FeishuPrincipal) -> list[PushSubscription]:
principal.require_active()
return list(
self.db.execute(
select(PushSubscription)
.where(PushSubscription.owner_id == principal.owner_id)
.order_by(PushSubscription.id.desc())
).scalars()
)
def latest_deliveries_for_owner(
self,
principal: FeishuPrincipal,
) -> dict[int, PushDelivery]:
"""Return at most one latest delivery per owner-scoped subscription."""
principal.require_active()
latest_ids = (
select(func.max(PushDelivery.id))
.join(PushSubscription)
.where(PushSubscription.owner_id == principal.owner_id)
.group_by(PushDelivery.subscription_id)
)
records = self.db.execute(
select(PushDelivery).where(PushDelivery.id.in_(latest_ids))
).scalars()
return {record.subscription_id: record for record in records}
def pause(self, principal: FeishuPrincipal, code: str) -> PushSubscription:
principal.require_capability(FeishuCapability.PRIVATE_SUBSCRIPTION)
record = self._owned_subscription(principal.owner_id, code, for_update=True)
if record.status == PushSubscriptionStatus.ACTIVE:
record.status = PushSubscriptionStatus.PAUSED
self._audit(principal, SubscriptionAuditAction.PAUSE, record)
self.db.commit()
self.db.refresh(record)
return record
def resume(
self,
principal: FeishuPrincipal,
code: str,
*,
now: datetime | None = None,
) -> PushSubscription:
principal.require_capability(FeishuCapability.PRIVATE_SUBSCRIPTION)
self._active_owner(principal, for_update=True)
record = self._owned_subscription(principal.owner_id, code, for_update=True)
if record.status != PushSubscriptionStatus.PAUSED:
return record
self._ensure_active_capacity(principal.owner_id)
current = _naive_utc(now or utc_now())
if record.next_run_at is None or record.next_run_at <= current:
next_run = next_occurrence(
record.schedule_type,
record.schedule_config,
record.timezone,
after=current,
)
if next_run is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Expired one-time subscriptions cannot be resumed",
)
record.next_run_at = next_run
record.status = PushSubscriptionStatus.ACTIVE
self._audit(principal, SubscriptionAuditAction.RESUME, record)
self.db.commit()
self.db.refresh(record)
return record
def cancel(self, principal: FeishuPrincipal, code: str) -> PushSubscription:
principal.require_capability(FeishuCapability.PRIVATE_SUBSCRIPTION)
record = self._owned_subscription(principal.owner_id, code, for_update=True)
if record.status != PushSubscriptionStatus.CANCELLED:
record.status = PushSubscriptionStatus.CANCELLED
record.next_run_at = None
self._audit(principal, SubscriptionAuditAction.CANCEL, record)
self.db.commit()
self.db.refresh(record)
return record
def set_timezone(
self,
principal: FeishuPrincipal,
timezone_name: str,
*,
now: datetime | None = None,
) -> FeishuUser:
principal.require_active()
try:
validate_timezone(timezone_name)
except ScheduleParseError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
owner = self._active_owner(principal, for_update=True)
owner.timezone = timezone_name
current = _naive_utc(now or utc_now())
subscriptions = list(
self.db.execute(
select(PushSubscription).where(
PushSubscription.owner_id == owner.id,
PushSubscription.status.in_(
[
PushSubscriptionStatus.ACTIVE,
PushSubscriptionStatus.PAUSED,
]
),
)
).scalars()
)
for subscription in subscriptions:
subscription.timezone = timezone_name
if (
subscription.status == PushSubscriptionStatus.ACTIVE
and subscription.schedule_type
not in {
SubscriptionScheduleType.ONCE,
SubscriptionScheduleType.INTERVAL,
}
):
subscription.next_run_at = next_occurrence(
subscription.schedule_type,
subscription.schedule_config,
timezone_name,
after=current,
)
self._audit_user(
principal,
SubscriptionAuditAction.UPDATE_TIMEZONE,
{"timezone": timezone_name},
)
self.db.commit()
self.db.refresh(owner)
return owner
def set_quiet_hours(
self,
principal: FeishuPrincipal,
start: str,
end: str,
) -> FeishuUser:
principal.require_active()
try:
quiet_start = parse_quiet_clock(start)
quiet_end = parse_quiet_clock(end)
except ScheduleParseError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
if quiet_start == quiet_end:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=INVALID_QUIET_HOURS,
)
owner = self._active_owner(principal, for_update=True)
owner.quiet_hours_start = quiet_start
owner.quiet_hours_end = quiet_end
self._audit_user(
principal,
SubscriptionAuditAction.UPDATE_QUIET_HOURS,
{"enabled": True},
)
self.db.commit()
self.db.refresh(owner)
return owner
def clear_quiet_hours(self, principal: FeishuPrincipal) -> FeishuUser:
principal.require_active()
owner = self._active_owner(principal, for_update=True)
owner.quiet_hours_start = None
owner.quiet_hours_end = None
self._audit_user(
principal,
SubscriptionAuditAction.UPDATE_QUIET_HOURS,
{"enabled": False},
)
self.db.commit()
self.db.refresh(owner)
return owner
def list_all(
self,
*,
status_filter: str | None = None,
owner_id: int | None = None,
limit: int = 100,
offset: int = 0,
) -> tuple[int, list[PushSubscription]]:
stmt = select(PushSubscription)
count_stmt = select(func.count()).select_from(PushSubscription)
if status_filter:
stmt = stmt.where(PushSubscription.status == status_filter)
count_stmt = count_stmt.where(PushSubscription.status == status_filter)
if owner_id is not None:
stmt = stmt.where(PushSubscription.owner_id == owner_id)
count_stmt = count_stmt.where(PushSubscription.owner_id == owner_id)
stmt = (
stmt.order_by(PushSubscription.id.desc())
.limit(bounded_limit(limit))
.offset(bounded_offset(offset))
)
total = int(self.db.scalar(count_stmt) or 0)
return total, list(self.db.execute(stmt).scalars())
def list_deliveries(
self,
*,
status_filter: str | None = None,
subscription_code: str | None = None,
limit: int = 100,
offset: int = 0,
) -> tuple[int, list[PushDelivery]]:
stmt = select(PushDelivery).join(PushSubscription)
count_stmt = (
select(func.count())
.select_from(PushDelivery)
.join(PushSubscription)
)
if status_filter:
stmt = stmt.where(PushDelivery.status == status_filter)
count_stmt = count_stmt.where(PushDelivery.status == status_filter)
if subscription_code:
stmt = stmt.where(PushSubscription.code == subscription_code)
count_stmt = count_stmt.where(PushSubscription.code == subscription_code)
stmt = (
stmt.order_by(PushDelivery.id.desc())
.limit(bounded_limit(limit))
.offset(bounded_offset(offset))
)
total = int(self.db.scalar(count_stmt) or 0)
return total, list(self.db.execute(stmt).scalars())
def _create(
self,
*,
owner: FeishuUser,
target_type: str,
target_id: str,
schedule_expression: str,
prompt: str,
now: datetime | None,
) -> tuple[PushSubscription, NormalizedSchedule]:
clean_prompt = str(prompt or "").strip()
if not clean_prompt:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=EMPTY_PROMPT,
)
self._ensure_active_capacity(owner.id)
try:
schedule = parse_schedule(
schedule_expression,
owner.timezone,
now=now,
)
except ScheduleParseError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
record = PushSubscription(
code=f"SUB-{uuid4().hex.upper()}",
owner_id=owner.id,
target_type=target_type,
target_id=target_id,
prompt=clean_prompt,
schedule_type=schedule.schedule_type,
schedule_config=schedule.schedule_config,
timezone=schedule.timezone,
next_run_at=schedule.next_run_at,
status=PushSubscriptionStatus.ACTIVE,
consented_at=_naive_utc(now or utc_now()),
)
self.db.add(record)
self.db.flush()
self._audit_values(
actor=owner.code,
action=SubscriptionAuditAction.CREATE,
target_id=record.code,
response={
"target_type": target_type,
"schedule_type": schedule.schedule_type,
},
)
self.db.commit()
self.db.refresh(record)
return record, schedule
def _ensure_active_capacity(self, owner_id: int) -> None:
count = int(
self.db.scalar(
select(func.count())
.select_from(PushSubscription)
.where(
PushSubscription.owner_id == owner_id,
PushSubscription.status == PushSubscriptionStatus.ACTIVE,
)
)
or 0
)
if count >= MAX_ACTIVE_SUBSCRIPTIONS:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=SUBSCRIPTION_LIMIT_REACHED,
)
def _active_owner(
self,
principal: FeishuPrincipal,
*,
for_update: bool = False,
) -> FeishuUser:
stmt = select(FeishuUser).where(FeishuUser.id == principal.owner_id)
if for_update:
stmt = stmt.with_for_update()
owner = self.db.execute(stmt).scalar_one_or_none()
if owner is None or owner.status != FeishuUserStatus.ACTIVE:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Feishu user is disabled",
)
if owner.tenant_key != principal.tenant_key or owner.open_id != principal.open_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Feishu identity mismatch",
)
return owner
def _owned_subscription(
self,
owner_id: int,
code: str,
*,
for_update: bool = False,
) -> PushSubscription:
stmt = select(PushSubscription).where(
PushSubscription.owner_id == owner_id,
PushSubscription.code == code,
)
if for_update:
stmt = stmt.with_for_update()
record = self.db.execute(stmt).scalar_one_or_none()
if record is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=SUBSCRIPTION_NOT_FOUND,
)
return record
def _audit(
self,
principal: FeishuPrincipal,
action: str,
record: PushSubscription,
) -> None:
self._audit_values(
actor=principal.user_code,
action=action,
target_id=record.code,
response={"status": record.status},
)
def _audit_user(
self,
principal: FeishuPrincipal,
action: str,
response: dict[str, Any],
) -> None:
self._audit_values(
actor=principal.user_code,
action=action,
target_id=principal.user_code,
response=response,
)
def _audit_values(
self,
*,
actor: str,
action: str,
target_id: str,
response: dict[str, Any],
) -> None:
AuditService(self.db).record(
AuditLogCreate(
actor=actor,
source="subscriptions",
action=action,
target_type="subscription",
target_id=target_id,
response_payload=response,
)
)
def _naive_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value
return value.astimezone(UTC).replace(tzinfo=None)