```
feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
This commit is contained in:
498
app/modules/subscriptions/services/management.py
Normal file
498
app/modules/subscriptions/services/management.py
Normal 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)
|
||||
Reference in New Issue
Block a user