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