Files
JiuContinent d7db84571d ```
feat: 添加飞书用户模块和订阅功能支持

- 新增feishu_users模块用于处理飞书用户身份验证和权限管理
- 新增subscriptions模块用于处理订阅相关功能
- 新增personalization模块用于个性化服务
- 在alembic迁移配置中注册新的模型模块
- 在API路由器中添加feishu_users和subscriptions路由
- 实现事件调度服务的改进,包括错误处理和状态更新优化
- 添加飞书命令处理的权限检查机制
- 实现飞书应用票据事件处理
- 改进审计日志记录功能
```
2026-07-27 08:02:17 +08:00

188 lines
6.4 KiB
Python

from typing import Any
from uuid import uuid4
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import ActorValue
from app.core.http.pagination import bounded_limit
from app.core.utils.time import utc_now
from app.modules.business.service import serialize_model
from app.modules.workflows.constants import (
WORKFLOW_ACTION_CODE_PREFIX,
WORKFLOW_CODE_PREFIX,
WorkflowErrorDetail,
WorkflowStatus,
)
from app.modules.workflows.models import WorkflowAction, WorkflowInstance
class WorkflowService:
"""Track V3 workflow instances and append-only workflow actions."""
def __init__(self, db: Session):
self.db = db
def start_or_update(
self,
workflow_type: str,
aggregate_type: str,
aggregate_id: str | int | None,
status_value: str,
action: str,
actor: str = ActorValue.SYSTEM,
payload: dict[str, Any] | None = None,
commit: bool = True,
source_event_id: str | None = None,
) -> WorkflowInstance:
existing_workflow = self._workflow_for_source_event(source_event_id)
if existing_workflow is not None:
return existing_workflow
aggregate_id_text = str(aggregate_id) if aggregate_id is not None else None
workflow_query = (
select(WorkflowInstance).where(
WorkflowInstance.workflow_type == workflow_type,
WorkflowInstance.aggregate_type == aggregate_type,
WorkflowInstance.aggregate_id == aggregate_id_text,
)
.with_for_update()
)
record = self.db.execute(workflow_query).scalar_one_or_none()
previous_status = None
now = utc_now()
if record is None:
candidate = WorkflowInstance(
code=(
f"{WORKFLOW_CODE_PREFIX}-{now:%Y%m%d%H%M%S%f}-"
f"{uuid4().hex[:8]}"
),
workflow_type=workflow_type,
aggregate_type=aggregate_type,
aggregate_id=aggregate_id_text,
status=status_value,
actor=actor,
current_step=action,
payload=payload or {},
)
try:
with self.db.begin_nested():
self.db.add(candidate)
self.db.flush()
record = candidate
except IntegrityError:
record = self.db.execute(workflow_query).scalar_one()
previous_status = record.status
else:
previous_status = record.status
existing_workflow = self._workflow_for_source_event(source_event_id)
if existing_workflow is not None:
if existing_workflow.code != record.code:
raise ValueError(
"Source event is already attached to a different workflow"
)
return existing_workflow
if previous_status is not None:
record.status = status_value
record.actor = actor
record.current_step = action
record.payload = payload or {}
record.updated_at = now
if status_value in {
WorkflowStatus.BLOCKED,
WorkflowStatus.COMPLETED,
WorkflowStatus.FAILED,
}:
record.completed_at = now
elif previous_status in {
WorkflowStatus.BLOCKED,
WorkflowStatus.COMPLETED,
WorkflowStatus.FAILED,
}:
record.completed_at = None
self.db.add(
WorkflowAction(
code=(
f"{WORKFLOW_ACTION_CODE_PREFIX}-{now:%Y%m%d%H%M%S%f}-"
f"{uuid4().hex[:8]}"
),
workflow_code=record.code,
source_event_id=source_event_id,
action=action,
actor=actor,
from_status=previous_status,
to_status=status_value,
payload=payload or {},
)
)
if commit:
self.db.commit()
self.db.refresh(record)
else:
self.db.flush()
return record
def _workflow_for_source_event(
self,
source_event_id: str | None,
) -> WorkflowInstance | None:
if not source_event_id:
return None
action = self.db.execute(
select(WorkflowAction).where(
WorkflowAction.source_event_id == source_event_id
)
).scalar_one_or_none()
if action is None:
return None
return self.db.execute(
select(WorkflowInstance).where(WorkflowInstance.code == action.workflow_code)
).scalar_one()
def list_workflows(
self,
status_filter: str | None = None,
workflow_type: str | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
stmt = (
select(WorkflowInstance)
.order_by(WorkflowInstance.id.desc())
.limit(bounded_limit(limit))
)
if status_filter:
stmt = stmt.where(WorkflowInstance.status == status_filter)
if workflow_type:
stmt = stmt.where(WorkflowInstance.workflow_type == workflow_type)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def get_workflow(self, code: str) -> dict[str, Any]:
record = self.db.execute(
select(WorkflowInstance).where(WorkflowInstance.code == code)
).scalar_one_or_none()
if record is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=WorkflowErrorDetail.WORKFLOW_NOT_FOUND,
)
actions = self.db.execute(
select(WorkflowAction)
.where(WorkflowAction.workflow_code == code)
.order_by(WorkflowAction.id.asc())
).scalars()
data = serialize_model(record)
data["actions"] = [serialize_model(item) for item in actions]
return data
def count_by_status(self) -> dict[str, int]:
rows = self.db.execute(
select(WorkflowInstance.status, func.count()).group_by(WorkflowInstance.status)
).all()
return {str(status_value): int(count) for status_value, count in rows}