Files
company-ai-platform/app/modules/events/service.py
JiuContinent 19e59e83cc ```
feat: 添加数据库迁移脚本并更新Dockerfile配置

- 在Dockerfile中添加alembic配置文件和目录的复制指令
- 更新alembic/env.py注册新的模块模型:events、workflows、writebacks
- 生成完整的初始数据库schema迁移脚本,包含以下表:
  - approval_requests, attendance_records, audit_logs, domain_events
  - expenses, feishu_event_receipts, fund_accounts, legacy_sync_runs
  - official_writeback_runs, performance_metrics, policies, procurements
  - projects, report_push_runs, risk_event_actions, risk_events
  - standards, suppliers, work_reports, work_tasks, workflow_actions
  - workflow_instances等21个数据表结构定义
- 在API路由器中添加新模块的路由:events、workflows、writebacks、observability
```
2026-07-08 14:08:03 +08:00

185 lines
6.8 KiB
Python

from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.constants import ActorValue
from app.core.pagination import bounded_limit
from app.core.time import utc_now
from app.modules.events.constants import (
EVENT_CODE_PREFIX,
EventAggregateType,
EventErrorDetail,
EventPayloadKey,
EventStatus,
EventType,
)
from app.modules.events.models import DomainEvent
def _serialize_event(record: DomainEvent) -> dict[str, Any]:
return {
column.name: getattr(record, column.name)
for column in record.__table__.columns
}
class EventService:
"""Persist outbox events and dispatch the V3 internal handlers."""
def __init__(self, db: Session):
self.db = db
def emit(
self,
event_type: str,
source: str,
aggregate_type: str,
aggregate_id: str | int | None,
actor: str = ActorValue.SYSTEM,
payload: dict[str, Any] | None = None,
idempotency_key: str | None = None,
dispatch: bool = False,
) -> DomainEvent:
if idempotency_key:
existing = self.db.execute(
select(DomainEvent).where(DomainEvent.idempotency_key == idempotency_key)
).scalar_one_or_none()
if existing is not None:
if dispatch and existing.status == EventStatus.PENDING:
return self.dispatch_event(existing.event_id)
return existing
record = DomainEvent(
event_id=f"{EVENT_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}",
event_type=event_type,
source=source,
aggregate_type=aggregate_type,
aggregate_id=str(aggregate_id) if aggregate_id is not None else None,
actor=actor,
payload=payload or {},
idempotency_key=idempotency_key,
)
self.db.add(record)
self.db.commit()
self.db.refresh(record)
if dispatch:
return self.dispatch_event(record.event_id)
return record
def list_events(
self,
status_filter: str | None = None,
event_type: str | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
stmt = select(DomainEvent).order_by(DomainEvent.id.desc()).limit(bounded_limit(limit))
if status_filter:
stmt = stmt.where(DomainEvent.status == status_filter)
if event_type:
stmt = stmt.where(DomainEvent.event_type == event_type)
return [_serialize_event(item) for item in self.db.execute(stmt).scalars()]
def count_by_status(self) -> dict[str, int]:
rows = self.db.execute(
select(DomainEvent.status, func.count()).group_by(DomainEvent.status)
).all()
return {str(status_value): int(count) for status_value, count in rows}
def get_event(self, event_id: str) -> DomainEvent:
record = self.db.execute(
select(DomainEvent).where(DomainEvent.event_id == event_id)
).scalar_one_or_none()
if record is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=EventErrorDetail.EVENT_NOT_FOUND,
)
return record
def dispatch_event(self, event_id: str) -> DomainEvent:
record = self.get_event(event_id)
if record.status == EventStatus.PROCESSED:
return record
record.attempts += 1
try:
self._handle_event(record)
except Exception as exc:
record.status = EventStatus.FAILED
record.last_error = str(exc)
self.db.commit()
self.db.refresh(record)
return record
record.status = EventStatus.PROCESSED
record.last_error = None
record.processed_at = utc_now()
self.db.commit()
self.db.refresh(record)
return record
def dispatch_pending(self, limit: int = 100) -> list[dict[str, Any]]:
stmt = (
select(DomainEvent)
.where(DomainEvent.status == EventStatus.PENDING)
.order_by(DomainEvent.id.asc())
.limit(bounded_limit(limit))
)
records = list(self.db.execute(stmt).scalars())
return [_serialize_event(self.dispatch_event(record.event_id)) for record in records]
def _handle_event(self, record: DomainEvent) -> None:
if record.event_type == EventType.RISK_ACTION_RECORDED:
self._handle_risk_action(record)
return
if record.event_type in {EventType.WRITEBACK_REQUESTED, EventType.WRITEBACK_SUBMITTED}:
self._handle_writeback(record)
def _handle_risk_action(self, record: DomainEvent) -> None:
from app.modules.risk.constants import RiskEventActionValue
from app.modules.workflows.service import WorkflowService
from app.modules.workflows.constants import WorkflowStatus, WorkflowType
payload = record.payload or {}
action = str(payload.get(EventPayloadKey.ACTION) or "")
if action == RiskEventActionValue.CLOSE:
workflow_status = WorkflowStatus.COMPLETED
elif action == RiskEventActionValue.RESOLVE:
workflow_status = WorkflowStatus.WAITING_REVIEW
else:
workflow_status = WorkflowStatus.RUNNING
WorkflowService(self.db).start_or_update(
workflow_type=WorkflowType.RISK_EVENT_REVIEW,
aggregate_type=EventAggregateType.RISK_EVENT,
aggregate_id=record.aggregate_id,
status_value=workflow_status,
action=action or record.event_type,
actor=record.actor,
payload=payload,
)
def _handle_writeback(self, record: DomainEvent) -> None:
from app.modules.workflows.service import WorkflowService
from app.modules.workflows.constants import WorkflowStatus, WorkflowType
from app.modules.writebacks.constants import WritebackStatus
payload = record.payload or {}
run_status = str(payload.get(EventPayloadKey.STATUS) or "")
if run_status == WritebackStatus.SENT:
workflow_status = WorkflowStatus.COMPLETED
elif run_status == WritebackStatus.FAILED:
workflow_status = WorkflowStatus.FAILED
elif run_status == WritebackStatus.DISABLED:
workflow_status = WorkflowStatus.BLOCKED
else:
workflow_status = WorkflowStatus.WAITING_APPROVAL
WorkflowService(self.db).start_or_update(
workflow_type=WorkflowType.OFFICIAL_WRITEBACK,
aggregate_type=EventAggregateType.WRITEBACK_RUN,
aggregate_id=record.aggregate_id,
status_value=workflow_status,
action=record.event_type,
actor=record.actor,
payload=payload,
)