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

@@ -3,9 +3,12 @@ from typing import Any
from uuid import uuid4
from fastapi import HTTPException, status
from sqlalchemy import or_, select
from sqlalchemy import or_, select, update
from app.application.events.handlers import EventHandlerMixin
from app.application.events.handlers import (
EventHandlerMixin,
UnsupportedEventTypeError,
)
from app.core.config import get_settings
from app.core.constants import ActorValue
from app.core.http.pagination import bounded_limit
@@ -44,12 +47,11 @@ class EventDispatchService(EventHandlerMixin):
worker_id: str | None = None,
preclaimed: bool = False,
) -> DomainEvent:
lock_owner = worker_id or f"api:{uuid4().hex}"
record = (
self.get_event(event_id)
if preclaimed
else self._claim_event(event_id, lock_owner)
)
if preclaimed:
lock_owner = worker_id or ""
else:
lock_owner = f"{worker_id or 'api'}:{uuid4().hex}"
record = self.get_event(event_id) if preclaimed else self._claim_event(event_id, lock_owner)
if record.status == EventStatus.PROCESSED:
return record
if preclaimed and record.locked_by != lock_owner:
@@ -63,30 +65,31 @@ class EventDispatchService(EventHandlerMixin):
try:
self._handle_event(record)
except Exception as exc:
retryable = record.attempts < self._max_attempts(record)
record.status = EventStatus.PENDING if retryable else EventStatus.FAILED
record.last_error = str(exc)
record.next_attempt_at = (
utc_now() + timedelta(seconds=settings.event_dispatch_retry_delay_seconds)
if retryable
else None
self.db.rollback()
record = self.get_event(event_id)
retryable = not isinstance(
exc, UnsupportedEventTypeError
) and record.attempts < self._max_attempts(record)
return self._finalize_event(
event_id,
lock_owner,
status_value=(EventStatus.PENDING if retryable else EventStatus.FAILED),
last_error=f"{type(exc).__name__}: {exc}"[:2000],
next_attempt_at=(
utc_now() + timedelta(seconds=settings.event_dispatch_retry_delay_seconds)
if retryable
else None
),
processed_at=None,
)
record.locked_by = None
record.locked_until = None
self.db.commit()
self.db.refresh(record)
self._audit_dispatch(record)
return record
record.status = EventStatus.PROCESSED
record.last_error = None
record.processed_at = utc_now()
record.next_attempt_at = None
record.locked_by = None
record.locked_until = None
self.db.commit()
self.db.refresh(record)
self._audit_dispatch(record)
return record
return self._finalize_event(
event_id,
lock_owner,
status_value=EventStatus.PROCESSED,
last_error=None,
next_attempt_at=None,
processed_at=utc_now(),
)
def dispatch_pending(
self,
@@ -95,7 +98,7 @@ class EventDispatchService(EventHandlerMixin):
) -> list[dict[str, Any]]:
now = utc_now()
stmt = (
select(DomainEvent)
select(DomainEvent.event_id)
.where(
DomainEvent.status == EventStatus.PENDING,
or_(
@@ -113,38 +116,51 @@ class EventDispatchService(EventHandlerMixin):
)
.order_by(DomainEvent.id.asc())
.limit(bounded_limit(limit))
.with_for_update(skip_locked=True)
)
records = list(self.db.execute(stmt).scalars())
lock_owner = worker_id or f"worker:{uuid4().hex}"
locked_until = now + timedelta(
seconds=get_settings().event_dispatch_lock_seconds
)
for record in records:
record.locked_by = lock_owner
record.locked_until = locked_until
record.attempts += 1
self.db.commit()
return [
_serialize_event(
self.dispatch_event(
record.event_id,
event_ids = list(self.db.execute(stmt).scalars())
lock_owner = f"{worker_id or 'worker'}:{uuid4().hex}"
dispatched: list[dict[str, Any]] = []
for event_id in event_ids:
try:
record = self.dispatch_event(
event_id,
worker_id=lock_owner,
preclaimed=True,
)
)
for record in records
]
except HTTPException as exc:
if exc.status_code == status.HTTP_409_CONFLICT:
continue
raise
dispatched.append(_serialize_event(record))
return dispatched
def retry_event(self, event_id: str, actor: str = ActorValue.API) -> DomainEvent:
record = self.get_event(event_id)
record = self.db.execute(
select(DomainEvent).where(DomainEvent.event_id == event_id).with_for_update()
).scalar_one_or_none()
if record is None:
self.db.rollback()
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=EventErrorDetail.EVENT_NOT_FOUND,
)
now = utc_now()
if (
record.locked_by is not None
and record.locked_until is not None
and record.locked_until > now
):
self.db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=EventErrorDetail.EVENT_LOCKED,
)
if record.status == EventStatus.PROCESSED:
self.db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=EventErrorDetail.EVENT_NOT_RETRYABLE,
)
record.status = EventStatus.PENDING
record.actor = actor
record.attempts = 0
record.max_attempts = record.max_attempts or get_settings().event_dispatch_max_attempts
record.last_error = None
@@ -155,8 +171,48 @@ class EventDispatchService(EventHandlerMixin):
self.db.refresh(record)
return record
def _audit_dispatch(self, record: DomainEvent) -> None:
AuditService(self.db).log(
def _finalize_event(
self,
event_id: str,
lock_owner: str,
*,
status_value: str,
last_error: str | None,
next_attempt_at: Any,
processed_at: Any,
) -> DomainEvent:
result = self.db.execute(
update(DomainEvent)
.where(
DomainEvent.event_id == event_id,
DomainEvent.locked_by == lock_owner,
DomainEvent.status == EventStatus.PENDING,
)
.values(
status=status_value,
last_error=last_error,
next_attempt_at=next_attempt_at,
processed_at=processed_at,
locked_by=None,
locked_until=None,
)
.execution_options(synchronize_session=False)
)
if result.rowcount != 1:
self.db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=EventErrorDetail.EVENT_LOCKED,
)
self.db.expire_all()
record = self.get_event(event_id)
self._stage_dispatch_audit(record)
self.db.commit()
self.db.refresh(record)
return record
def _stage_dispatch_audit(self, record: DomainEvent) -> None:
AuditService(self.db).record(
AuditLogCreate(
actor=record.actor,
source=AuditSource.EVENTS,
@@ -198,9 +254,7 @@ class EventDispatchService(EventHandlerMixin):
detail=EventErrorDetail.EVENT_LOCKED,
)
record.locked_by = lock_owner
record.locked_until = now + timedelta(
seconds=get_settings().event_dispatch_lock_seconds
)
record.locked_until = now + timedelta(seconds=get_settings().event_dispatch_lock_seconds)
record.status = EventStatus.PENDING
record.attempts += 1
self.db.commit()

View File

@@ -6,6 +6,10 @@ from app.modules.events.constants import (
from app.modules.events.models import DomainEvent
class UnsupportedEventTypeError(ValueError):
"""Raised when no application handler is registered for an event type."""
class EventHandlerMixin:
def _handle_event(self, record: DomainEvent) -> None:
if record.event_type == EventType.RISK_ACTION_RECORDED:
@@ -30,6 +34,9 @@ class EventHandlerMixin:
if record.event_type == EventType.ENTERPRISE_ANALYTICS_GENERATED:
self._handle_enterprise_analytics_event(record)
return
raise UnsupportedEventTypeError(
f"Unsupported domain event type: {record.event_type}"
)
def _handle_risk_action(self, record: DomainEvent) -> None:
from app.modules.risk.constants import RiskEventActionValue
@@ -53,6 +60,7 @@ class EventHandlerMixin:
actor=record.actor,
payload=payload,
commit=False,
source_event_id=record.event_id,
)
def _handle_report_event(self, record: DomainEvent) -> None:
@@ -125,4 +133,5 @@ class EventHandlerMixin:
actor=record.actor,
payload=record.payload or {},
commit=False,
source_event_id=record.event_id,
)