```
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 ```
This commit is contained in:
@@ -11,6 +11,7 @@ class ApprovalStatus(StrEnum):
|
||||
class ApprovalActionValue(StrEnum):
|
||||
CREATE = "create"
|
||||
UPDATE = "update"
|
||||
WRITEBACK = "writeback"
|
||||
|
||||
|
||||
class ApprovalErrorDetail(StrEnum):
|
||||
|
||||
@@ -22,6 +22,13 @@ from app.modules.approvals.schemas import ApprovalCreate
|
||||
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.events.constants import (
|
||||
EventAggregateType,
|
||||
EventPayloadKey,
|
||||
EventSource,
|
||||
EventType,
|
||||
)
|
||||
from app.modules.events.service import EventService
|
||||
|
||||
|
||||
class ApprovalService:
|
||||
@@ -121,6 +128,23 @@ class ApprovalService:
|
||||
response_payload={ApprovalPayloadKey.STATUS: ticket.status},
|
||||
)
|
||||
)
|
||||
EventService(self.db).emit(
|
||||
event_type=EventType.APPROVAL_DECIDED,
|
||||
source=EventSource.APPROVAL,
|
||||
aggregate_type=EventAggregateType.APPROVAL,
|
||||
aggregate_id=ticket.ticket_id,
|
||||
actor=approver,
|
||||
payload={
|
||||
EventPayloadKey.TICKET_ID: ticket.ticket_id,
|
||||
EventPayloadKey.DOMAIN: ticket.domain,
|
||||
EventPayloadKey.RECORD_ID: ticket.record_id,
|
||||
EventPayloadKey.ACTION: ticket.action,
|
||||
EventPayloadKey.STATUS: ticket.status,
|
||||
EventPayloadKey.APPROVED: approved,
|
||||
EventPayloadKey.COMMENT: comment,
|
||||
},
|
||||
idempotency_key=f"approval:{ticket.ticket_id}:{ticket.status}",
|
||||
)
|
||||
return ticket
|
||||
|
||||
def consume_for(
|
||||
|
||||
@@ -17,6 +17,8 @@ class AuditAction(StrEnum):
|
||||
LEGACY_SYNC_TASKS = "sync_tasks"
|
||||
RISK_EVENT_ACTION = "risk_event_action"
|
||||
REPORT_PUSH = "report_push"
|
||||
WRITEBACK_CREATE = "writeback.create"
|
||||
WRITEBACK_SUBMIT = "writeback.submit"
|
||||
|
||||
|
||||
class AuditRiskLevel(StrEnum):
|
||||
@@ -33,6 +35,7 @@ class AuditSource(StrEnum):
|
||||
APPROVAL = "approval"
|
||||
LEGACY_MYSQL = "legacy_mysql"
|
||||
REPORTS = "reports"
|
||||
WRITEBACK = "writeback"
|
||||
|
||||
|
||||
class AuditTargetType(StrEnum):
|
||||
|
||||
@@ -22,4 +22,5 @@ class AuditLog(Base):
|
||||
request_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
response_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default=AuditStatus.SUCCESS, index=True)
|
||||
request_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
|
||||
@@ -17,6 +17,7 @@ class AuditLogCreate(BaseModel):
|
||||
request_payload: Any | None = None
|
||||
response_payload: Any | None = None
|
||||
status: str = AuditStatus.SUCCESS
|
||||
request_id: str | None = None
|
||||
|
||||
|
||||
class AuditLogRead(BaseModel):
|
||||
@@ -32,4 +33,5 @@ class AuditLogRead(BaseModel):
|
||||
request_payload: str | None
|
||||
response_payload: str | None
|
||||
status: str
|
||||
request_id: str | None
|
||||
created_at: datetime
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.pagination import bounded_limit
|
||||
from app.core.request_context import get_request_id
|
||||
from app.modules.audit.constants import AUDIT_REDACTED_VALUE, AUDIT_SENSITIVE_KEYS
|
||||
from app.modules.audit.models import AuditLog
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
@@ -54,6 +55,7 @@ class AuditService:
|
||||
request_payload=_dump(payload.request_payload),
|
||||
response_payload=_dump(payload.response_payload),
|
||||
status=payload.status,
|
||||
request_id=payload.request_id or get_request_id(),
|
||||
)
|
||||
self.db.add(record)
|
||||
self.db.commit()
|
||||
|
||||
@@ -102,6 +102,7 @@ class BusinessErrorDetail(StrEnum):
|
||||
|
||||
|
||||
UNKNOWN_FIELD_TEMPLATE = "Unknown field '{field}'"
|
||||
READ_ONLY_FIELD_TEMPLATE = "Field '{field}' is read-only"
|
||||
INVALID_FIELD_VALUE_TEMPLATE = "Invalid value for field '{field}'"
|
||||
|
||||
|
||||
|
||||
@@ -20,12 +20,199 @@ DOMAIN_MODELS: dict[BusinessDomain, type[DeclarativeMeta]] = {
|
||||
BusinessDomain.LEGACY_SYNC_RUNS: models.LegacySyncRun,
|
||||
}
|
||||
|
||||
HIGH_RISK_DOMAINS = frozenset(
|
||||
{
|
||||
BusinessDomain.FUND_ACCOUNTS,
|
||||
BusinessDomain.PERFORMANCE_METRICS,
|
||||
}
|
||||
)
|
||||
DOMAIN_WRITABLE_FIELDS: dict[BusinessDomain, frozenset[str]] = {
|
||||
BusinessDomain.PROJECTS: frozenset(
|
||||
{
|
||||
"code",
|
||||
"name",
|
||||
"owner",
|
||||
"status",
|
||||
"priority",
|
||||
"progress_percent",
|
||||
"risk_level",
|
||||
"budget_amount",
|
||||
"actual_amount",
|
||||
"start_date",
|
||||
"due_date",
|
||||
"description",
|
||||
}
|
||||
),
|
||||
BusinessDomain.TASKS: frozenset(
|
||||
{
|
||||
"code",
|
||||
"title",
|
||||
"project_code",
|
||||
"owner",
|
||||
"status",
|
||||
"priority",
|
||||
"due_date",
|
||||
"completed_at",
|
||||
"blocker",
|
||||
"description",
|
||||
}
|
||||
),
|
||||
BusinessDomain.PROCUREMENTS: frozenset(
|
||||
{
|
||||
"code",
|
||||
"name",
|
||||
"applicant",
|
||||
"project_code",
|
||||
"supplier_name",
|
||||
"budget_subject",
|
||||
"expected_amount",
|
||||
"actual_amount",
|
||||
"approval_status",
|
||||
"delivery_status",
|
||||
"payment_status",
|
||||
"comparison_summary",
|
||||
}
|
||||
),
|
||||
BusinessDomain.EXPENSES: frozenset(
|
||||
{
|
||||
"code",
|
||||
"expense_type",
|
||||
"amount",
|
||||
"applicant",
|
||||
"department",
|
||||
"project_code",
|
||||
"budget_subject",
|
||||
"payment_account",
|
||||
"invoice_status",
|
||||
"approval_status",
|
||||
"payment_status",
|
||||
}
|
||||
),
|
||||
BusinessDomain.FUND_ACCOUNTS: frozenset(
|
||||
{
|
||||
"code",
|
||||
"name",
|
||||
"account_type",
|
||||
"current_balance",
|
||||
"expected_receivable",
|
||||
"expected_payable",
|
||||
"safety_line",
|
||||
"risk_level",
|
||||
"note",
|
||||
}
|
||||
),
|
||||
BusinessDomain.POLICIES: frozenset(
|
||||
{
|
||||
"code",
|
||||
"title",
|
||||
"policy_type",
|
||||
"owner_department",
|
||||
"version",
|
||||
"status",
|
||||
"effective_date",
|
||||
"feishu_doc_url",
|
||||
"summary",
|
||||
}
|
||||
),
|
||||
BusinessDomain.STANDARDS: frozenset(
|
||||
{
|
||||
"code",
|
||||
"title",
|
||||
"standard_type",
|
||||
"applies_to",
|
||||
"status",
|
||||
"check_items",
|
||||
"remediation",
|
||||
"policy_code",
|
||||
}
|
||||
),
|
||||
BusinessDomain.PERFORMANCE_METRICS: frozenset(
|
||||
{
|
||||
"code",
|
||||
"name",
|
||||
"applies_to_role",
|
||||
"formula",
|
||||
"weight",
|
||||
"data_source",
|
||||
"auto_score",
|
||||
"confirmed_score",
|
||||
"status",
|
||||
}
|
||||
),
|
||||
BusinessDomain.SUPPLIERS: frozenset(
|
||||
{
|
||||
"code",
|
||||
"name",
|
||||
"category",
|
||||
"contact",
|
||||
"quality_score",
|
||||
"delivery_score",
|
||||
"price_score",
|
||||
"risk_level",
|
||||
"blacklist_status",
|
||||
}
|
||||
),
|
||||
BusinessDomain.ATTENDANCE_RECORDS: frozenset(
|
||||
{
|
||||
"code",
|
||||
"employee_name",
|
||||
"employee_id",
|
||||
"department",
|
||||
"project_code",
|
||||
"work_date",
|
||||
"check_in_at",
|
||||
"check_out_at",
|
||||
"status",
|
||||
"location",
|
||||
"note",
|
||||
}
|
||||
),
|
||||
BusinessDomain.WORK_REPORTS: frozenset(
|
||||
{
|
||||
"code",
|
||||
"report_type",
|
||||
"title",
|
||||
"reporter",
|
||||
"department",
|
||||
"project_code",
|
||||
"period_start",
|
||||
"period_end",
|
||||
"content",
|
||||
"metrics",
|
||||
"risk_summary",
|
||||
"status",
|
||||
}
|
||||
),
|
||||
BusinessDomain.RISK_EVENTS: frozenset(
|
||||
{
|
||||
"code",
|
||||
"title",
|
||||
"risk_type",
|
||||
"risk_level",
|
||||
"status",
|
||||
"source_domain",
|
||||
"source_record_id",
|
||||
"project_code",
|
||||
"owner",
|
||||
"due_date",
|
||||
"assigned_to",
|
||||
"closed_reason",
|
||||
"review_summary",
|
||||
"description",
|
||||
"mitigation",
|
||||
"evidence",
|
||||
}
|
||||
),
|
||||
BusinessDomain.LEGACY_SYNC_RUNS: frozenset(
|
||||
{
|
||||
"code",
|
||||
"domain",
|
||||
"source_table",
|
||||
"status",
|
||||
"created_count",
|
||||
"updated_count",
|
||||
"skipped_count",
|
||||
"error_message",
|
||||
"note",
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
HIGH_RISK_DOMAINS = frozenset(DOMAIN_MODELS)
|
||||
LOW_RISK_DOMAINS = frozenset(set(DOMAIN_MODELS) - HIGH_RISK_DOMAINS)
|
||||
|
||||
|
||||
@@ -47,3 +234,7 @@ def is_high_risk_domain(domain: str | BusinessDomain) -> bool:
|
||||
|
||||
def get_domain_model(domain: str | BusinessDomain) -> type[DeclarativeMeta]:
|
||||
return DOMAIN_MODELS[normalize_domain(domain)]
|
||||
|
||||
|
||||
def get_writable_fields(domain: str | BusinessDomain) -> frozenset[str]:
|
||||
return DOMAIN_WRITABLE_FIELDS[normalize_domain(domain)]
|
||||
|
||||
@@ -10,7 +10,7 @@ class DomainRecordCreate(BaseModel):
|
||||
actor: str = ActorValue.API
|
||||
approval_ticket_id: str | None = Field(
|
||||
default=None,
|
||||
description="Required by policy for high-risk creates such as funds or performance.",
|
||||
description="Required by policy for business record creates.",
|
||||
)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class DomainRecordUpdate(BaseModel):
|
||||
actor: str = ActorValue.API
|
||||
approval_ticket_id: str | None = Field(
|
||||
default=None,
|
||||
description="Required by policy for high-risk updates such as funds or performance.",
|
||||
description="Required by policy for business record updates.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,9 +17,10 @@ from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.approvals.constants import ApprovalActionValue, approval_action
|
||||
from app.modules.approvals.service import ApprovalService
|
||||
from app.modules.business.registry import get_domain_model, is_high_risk_domain
|
||||
from app.modules.business.registry import get_domain_model, get_writable_fields, is_high_risk_domain
|
||||
from app.modules.business.constants import (
|
||||
INVALID_FIELD_VALUE_TEMPLATE,
|
||||
READ_ONLY_FIELD_TEMPLATE,
|
||||
UNKNOWN_FIELD_TEMPLATE,
|
||||
BusinessErrorDetail,
|
||||
BusinessField,
|
||||
@@ -56,7 +57,7 @@ def _coerce_column_value(column: Column, value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
def _model_payload(model: Any, data: dict[str, Any]) -> dict[str, Any]:
|
||||
def _model_payload(domain: str, model: Any, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate keys and coerce values according to model column types."""
|
||||
|
||||
columns = {
|
||||
@@ -64,6 +65,7 @@ def _model_payload(model: Any, data: dict[str, Any]) -> dict[str, Any]:
|
||||
for column in model.__table__.columns
|
||||
if column.name != BusinessField.ID
|
||||
}
|
||||
writable_fields = get_writable_fields(domain)
|
||||
payload: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
column = columns.get(key)
|
||||
@@ -72,6 +74,11 @@ def _model_payload(model: Any, data: dict[str, Any]) -> dict[str, Any]:
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=UNKNOWN_FIELD_TEMPLATE.format(field=key),
|
||||
)
|
||||
if key not in writable_fields:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=READ_ONLY_FIELD_TEMPLATE.format(field=key),
|
||||
)
|
||||
try:
|
||||
payload[key] = _coerce_column_value(column, value)
|
||||
except (ValueError, TypeError, InvalidOperation) as exc:
|
||||
@@ -127,7 +134,7 @@ class BusinessService:
|
||||
) -> dict[str, Any]:
|
||||
model = get_domain_model(domain)
|
||||
high_risk = is_high_risk_domain(domain)
|
||||
payload = _model_payload(model, data)
|
||||
payload = _model_payload(domain, model, data)
|
||||
record = model(**payload)
|
||||
self.db.add(record)
|
||||
if high_risk:
|
||||
@@ -176,7 +183,7 @@ class BusinessService:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=BusinessErrorDetail.RECORD_NOT_FOUND,
|
||||
)
|
||||
payload = _model_payload(model, data)
|
||||
payload = _model_payload(domain, model, data)
|
||||
if high_risk:
|
||||
self._consume_approval(
|
||||
approval_ticket_id,
|
||||
|
||||
@@ -16,8 +16,14 @@ from app.modules.business.models import (
|
||||
WorkTask,
|
||||
)
|
||||
from app.modules.business.service import serialize_model
|
||||
from app.modules.events.constants import EventStatus
|
||||
from app.modules.events.models import DomainEvent
|
||||
from app.modules.reports.constants import ReportPushStatus
|
||||
from app.modules.risk.service import RiskService
|
||||
from app.modules.workflows.constants import WorkflowStatus
|
||||
from app.modules.workflows.models import WorkflowInstance
|
||||
from app.modules.writebacks.constants import WritebackStatus
|
||||
from app.modules.writebacks.models import OfficialWritebackRun
|
||||
|
||||
|
||||
class DashboardService:
|
||||
@@ -41,6 +47,24 @@ class DashboardService:
|
||||
RiskEvent.assigned_to.is_(None),
|
||||
)
|
||||
failed_push_runs = self._count(ReportPushRun, ReportPushRun.status == ReportPushStatus.FAILED)
|
||||
pending_events = self._count(DomainEvent, DomainEvent.status == EventStatus.PENDING)
|
||||
failed_events = self._count(DomainEvent, DomainEvent.status == EventStatus.FAILED)
|
||||
running_workflows = self._count(
|
||||
WorkflowInstance,
|
||||
WorkflowInstance.status == WorkflowStatus.RUNNING,
|
||||
)
|
||||
failed_workflows = self._count(
|
||||
WorkflowInstance,
|
||||
WorkflowInstance.status == WorkflowStatus.FAILED,
|
||||
)
|
||||
disabled_writebacks = self._count(
|
||||
OfficialWritebackRun,
|
||||
OfficialWritebackRun.status == WritebackStatus.DISABLED,
|
||||
)
|
||||
failed_writebacks = self._count(
|
||||
OfficialWritebackRun,
|
||||
OfficialWritebackRun.status == WritebackStatus.FAILED,
|
||||
)
|
||||
latest_reports = self.db.execute(
|
||||
select(WorkReport).order_by(WorkReport.id.desc()).limit(5)
|
||||
).scalars()
|
||||
@@ -62,6 +86,12 @@ class DashboardService:
|
||||
"open_risk_events": open_risk_events,
|
||||
"unassigned_open_risks": unassigned_open_risks,
|
||||
"failed_push_runs": failed_push_runs,
|
||||
"pending_events": pending_events,
|
||||
"failed_events": failed_events,
|
||||
"running_workflows": running_workflows,
|
||||
"failed_workflows": failed_workflows,
|
||||
"disabled_writebacks": disabled_writebacks,
|
||||
"failed_writebacks": failed_writebacks,
|
||||
"risk_level": risk_summary["risk_level"],
|
||||
"risk_score": float(risk_summary["risk_score"]),
|
||||
},
|
||||
|
||||
1
app/modules/events/__init__.py
Normal file
1
app/modules/events/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
62
app/modules/events/constants.py
Normal file
62
app/modules/events/constants.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class EventStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
PROCESSED = "processed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class EventType(StrEnum):
|
||||
APPROVAL_DECIDED = "approval.decided"
|
||||
RISK_ACTION_RECORDED = "risk.action_recorded"
|
||||
REPORT_PUSH_SUCCEEDED = "report.push_succeeded"
|
||||
REPORT_PUSH_FAILED = "report.push_failed"
|
||||
LEGACY_SYNC_COMPLETED = "legacy.sync_completed"
|
||||
WRITEBACK_REQUESTED = "writeback.requested"
|
||||
WRITEBACK_SUBMITTED = "writeback.submitted"
|
||||
|
||||
|
||||
class EventSource(StrEnum):
|
||||
APPROVAL = "approval"
|
||||
RISK = "risk"
|
||||
REPORTS = "reports"
|
||||
LEGACY_MYSQL = "legacy_mysql"
|
||||
WRITEBACK = "writeback"
|
||||
API = "api"
|
||||
|
||||
|
||||
class EventAggregateType(StrEnum):
|
||||
APPROVAL = "approval"
|
||||
RISK_EVENT = "risk-event"
|
||||
REPORT_PUSH_RUN = "report-push-run"
|
||||
LEGACY_SYNC_RUN = "legacy-sync-run"
|
||||
WRITEBACK_RUN = "writeback-run"
|
||||
|
||||
|
||||
class EventResponseKey(StrEnum):
|
||||
ITEMS = "items"
|
||||
EVENT = "event"
|
||||
TOTAL = "total"
|
||||
|
||||
|
||||
class EventPayloadKey(StrEnum):
|
||||
ACTION = "action"
|
||||
STATUS = "status"
|
||||
TICKET_ID = "ticket_id"
|
||||
DOMAIN = "domain"
|
||||
RECORD_ID = "record_id"
|
||||
APPROVED = "approved"
|
||||
COMMENT = "comment"
|
||||
CODE = "code"
|
||||
CREATED = "created"
|
||||
UPDATED = "updated"
|
||||
SKIPPED = "skipped"
|
||||
ERROR_MESSAGE = "error_message"
|
||||
|
||||
|
||||
class EventErrorDetail(StrEnum):
|
||||
EVENT_NOT_FOUND = "Domain event not found"
|
||||
|
||||
|
||||
EVENT_CODE_PREFIX = "EVT"
|
||||
33
app/modules/events/models.py
Normal file
33
app/modules/events/models.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.db_base import Base
|
||||
from app.core.time import utc_now
|
||||
from app.modules.events.constants import EventSource, EventStatus
|
||||
|
||||
|
||||
class DomainEvent(Base):
|
||||
__tablename__ = "domain_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
event_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
event_type: Mapped[str] = mapped_column(String(128), index=True)
|
||||
source: Mapped[str] = mapped_column(String(64), default=EventSource.API, index=True)
|
||||
aggregate_type: Mapped[str] = mapped_column(String(128), index=True)
|
||||
aggregate_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
actor: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default=EventStatus.PENDING, index=True)
|
||||
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
idempotency_key: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
processed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||
41
app/modules/events/routes.py
Normal file
41
app/modules/events/routes.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_api_key
|
||||
from app.modules.events.constants import EventResponseKey
|
||||
from app.modules.events.service import EventService, _serialize_event
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_events(
|
||||
status: str | None = None,
|
||||
event_type: str | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return {
|
||||
EventResponseKey.ITEMS: EventService(db).list_events(
|
||||
status_filter=status,
|
||||
event_type=event_type,
|
||||
limit=limit,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{event_id}/dispatch")
|
||||
def dispatch_event(
|
||||
event_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return {EventResponseKey.EVENT: _serialize_event(EventService(db).dispatch_event(event_id))}
|
||||
|
||||
|
||||
@router.post("/dispatch-pending")
|
||||
def dispatch_pending(
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return {EventResponseKey.ITEMS: EventService(db).dispatch_pending(limit=limit)}
|
||||
23
app/modules/events/schemas.py
Normal file
23
app/modules/events/schemas.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DomainEventRead(BaseModel):
|
||||
event_id: str
|
||||
event_type: str
|
||||
source: str
|
||||
aggregate_type: str
|
||||
aggregate_id: str | None
|
||||
actor: str
|
||||
payload: dict[str, Any] | None
|
||||
status: str
|
||||
attempts: int
|
||||
idempotency_key: str | None
|
||||
last_error: str | None
|
||||
created_at: str
|
||||
processed_at: str | None
|
||||
|
||||
|
||||
class DomainEventListRead(BaseModel):
|
||||
items: list[dict[str, Any]]
|
||||
184
app/modules/events/service.py
Normal file
184
app/modules/events/service.py
Normal file
@@ -0,0 +1,184 @@
|
||||
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,
|
||||
)
|
||||
@@ -64,6 +64,8 @@ class FeishuResponseKey(StrEnum):
|
||||
RESULT = "result"
|
||||
CHALLENGE = "challenge"
|
||||
PROVIDER_RESPONSE = "provider_response"
|
||||
STATUS = "status"
|
||||
APPROVER = "approver"
|
||||
|
||||
|
||||
class FeishuCommandResultKey(StrEnum):
|
||||
@@ -96,6 +98,24 @@ class FeishuEventReceiptKey(StrEnum):
|
||||
MESSAGE_ID = "message_id"
|
||||
|
||||
|
||||
class FeishuApprovalAction(StrEnum):
|
||||
APPROVE = "approve"
|
||||
REJECT = "reject"
|
||||
|
||||
|
||||
class FeishuApprovalValueKey(StrEnum):
|
||||
TICKET_ID = "ticket_id"
|
||||
DECISION = "decision"
|
||||
ACTION = "action"
|
||||
COMMENT = "comment"
|
||||
|
||||
|
||||
class FeishuCardKey(StrEnum):
|
||||
ACTIONS = "actions"
|
||||
BUTTON_TYPE = "type"
|
||||
VALUE = "value"
|
||||
|
||||
|
||||
FEISHU_TENANT_TOKEN_PATH = "/auth/v3/tenant_access_token/internal"
|
||||
FEISHU_MESSAGE_PATH = "/im/v1/messages"
|
||||
FEISHU_DEFAULT_OPEN_API_DOMAIN = "https://open.feishu.cn"
|
||||
@@ -103,6 +123,10 @@ FEISHU_AUTH_MISSING = "Feishu app credentials are not configured"
|
||||
FEISHU_VERIFICATION_TOKEN_REQUIRED = "FEISHU_VERIFICATION_TOKEN is required"
|
||||
FEISHU_INVALID_TOKEN = "Invalid Feishu token"
|
||||
FEISHU_RECEIVE_ID_MISSING = "receive_id or FEISHU_DEFAULT_CHAT_ID is required"
|
||||
FEISHU_APPROVAL_ACTION_INVALID = "Invalid Feishu approval action payload"
|
||||
FEISHU_APPROVAL_APPROVER_IDS_REQUIRED = "FEISHU_APPROVAL_APPROVER_IDS is required"
|
||||
FEISHU_APPROVER_NOT_ALLOWED = "Feishu approver is not allowed"
|
||||
FEISHU_APPROVAL_CARD_ACTION_TARGET = "approval_card_action"
|
||||
FEISHU_SUCCESS_CODE = 0
|
||||
FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS = 7200
|
||||
FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS = 300
|
||||
|
||||
@@ -6,11 +6,19 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.config import get_settings
|
||||
from app.modules.approvals.service import ApprovalService
|
||||
from app.modules.audit.constants import AuditAction, AuditSource
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.feishu.commands import FeishuCommandService
|
||||
from app.modules.feishu.constants import (
|
||||
FEISHU_APPROVAL_ACTION_INVALID,
|
||||
FEISHU_APPROVAL_APPROVER_IDS_REQUIRED,
|
||||
FEISHU_APPROVAL_CARD_ACTION_TARGET,
|
||||
FEISHU_APPROVER_NOT_ALLOWED,
|
||||
FeishuApprovalAction,
|
||||
FeishuApprovalValueKey,
|
||||
FeishuCardKey,
|
||||
FeishuCommandKey,
|
||||
FeishuEventReceiptKey,
|
||||
FeishuEventSource,
|
||||
@@ -87,15 +95,38 @@ class FeishuEventService:
|
||||
|
||||
self.feishu.verify_event(payload)
|
||||
value = _approval_action_value(payload)
|
||||
ticket_id = str(value.get("ticket_id") or "").strip()
|
||||
decision = str(value.get("decision") or value.get("action") or "").lower()
|
||||
if not ticket_id or decision not in {"approve", "reject"}:
|
||||
ticket_id = str(value.get(FeishuApprovalValueKey.TICKET_ID) or "").strip()
|
||||
decision = str(
|
||||
value.get(FeishuApprovalValueKey.DECISION)
|
||||
or value.get(FeishuApprovalValueKey.ACTION)
|
||||
or ""
|
||||
).lower()
|
||||
if not ticket_id or decision not in set(FeishuApprovalAction):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Invalid Feishu approval action payload",
|
||||
detail=FEISHU_APPROVAL_ACTION_INVALID,
|
||||
)
|
||||
comment = value.get("comment")
|
||||
comment = value.get(FeishuApprovalValueKey.COMMENT)
|
||||
actor = _approval_operator(payload)
|
||||
try:
|
||||
_ensure_approval_operator_allowed(actor)
|
||||
except HTTPException as exc:
|
||||
self.feishu.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.FEISHU,
|
||||
action=AuditAction.FEISHU_WEBHOOK_EVENT,
|
||||
target_type=FEISHU_APPROVAL_CARD_ACTION_TARGET,
|
||||
target_id=ticket_id,
|
||||
request_payload=payload,
|
||||
response_payload={
|
||||
FeishuResponseKey.OK: False,
|
||||
"status_code": exc.status_code,
|
||||
"detail": exc.detail,
|
||||
},
|
||||
)
|
||||
)
|
||||
raise
|
||||
event_identity = _approval_event_identity(payload, ticket_id, decision, actor)
|
||||
if not self._register_event(event_identity):
|
||||
ticket = ApprovalService(self.db).get_by_ticket(ticket_id)
|
||||
@@ -104,15 +135,15 @@ class FeishuEventService:
|
||||
FeishuResponseKey.HANDLED: True,
|
||||
FeishuResponseKey.DUPLICATE: True,
|
||||
FeishuResponseKey.RESULT: {
|
||||
"ticket_id": ticket.ticket_id,
|
||||
"status": ticket.status,
|
||||
"approver": ticket.approver,
|
||||
FeishuApprovalValueKey.TICKET_ID: ticket.ticket_id,
|
||||
FeishuResponseKey.STATUS: ticket.status,
|
||||
FeishuResponseKey.APPROVER: ticket.approver,
|
||||
},
|
||||
}
|
||||
ticket = ApprovalService(self.db).decide(
|
||||
ticket_id,
|
||||
actor,
|
||||
approved=decision == "approve",
|
||||
approved=decision == FeishuApprovalAction.APPROVE,
|
||||
comment=str(comment) if comment is not None else None,
|
||||
)
|
||||
self.feishu.audit.log(
|
||||
@@ -120,19 +151,22 @@ class FeishuEventService:
|
||||
actor=actor,
|
||||
source=AuditSource.FEISHU,
|
||||
action=AuditAction.FEISHU_WEBHOOK_EVENT,
|
||||
target_type="approval_card_action",
|
||||
target_type=FEISHU_APPROVAL_CARD_ACTION_TARGET,
|
||||
target_id=ticket_id,
|
||||
request_payload=payload,
|
||||
response_payload={"status": ticket.status, "decision": decision},
|
||||
response_payload={
|
||||
FeishuResponseKey.STATUS: ticket.status,
|
||||
FeishuApprovalValueKey.DECISION: decision,
|
||||
},
|
||||
)
|
||||
)
|
||||
return {
|
||||
FeishuResponseKey.OK: True,
|
||||
FeishuResponseKey.HANDLED: True,
|
||||
FeishuResponseKey.RESULT: {
|
||||
"ticket_id": ticket.ticket_id,
|
||||
"status": ticket.status,
|
||||
"approver": ticket.approver,
|
||||
FeishuApprovalValueKey.TICKET_ID: ticket.ticket_id,
|
||||
FeishuResponseKey.STATUS: ticket.status,
|
||||
FeishuResponseKey.APPROVER: ticket.approver,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -183,10 +217,15 @@ def _event_identity(
|
||||
|
||||
|
||||
def _approval_action_value(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
action = payload.get("action") or {}
|
||||
action = payload.get(FeishuApprovalValueKey.ACTION) or {}
|
||||
event = payload.get(FeishuPayloadKey.EVENT) or {}
|
||||
event_action = event.get("action") or {}
|
||||
value = action.get("value") or event_action.get("value") or payload.get("value") or {}
|
||||
event_action = event.get(FeishuApprovalValueKey.ACTION) or {}
|
||||
value = (
|
||||
action.get(FeishuCardKey.VALUE)
|
||||
or event_action.get(FeishuCardKey.VALUE)
|
||||
or payload.get(FeishuCardKey.VALUE)
|
||||
or {}
|
||||
)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
@@ -210,6 +249,24 @@ def _approval_operator(payload: dict[str, Any]) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _ensure_approval_operator_allowed(actor: str) -> None:
|
||||
allowed_ids = {
|
||||
item.strip()
|
||||
for item in get_settings().feishu_approval_approver_ids
|
||||
if item.strip()
|
||||
}
|
||||
if not allowed_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=FEISHU_APPROVAL_APPROVER_IDS_REQUIRED,
|
||||
)
|
||||
if actor not in allowed_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=FEISHU_APPROVER_NOT_ALLOWED,
|
||||
)
|
||||
|
||||
|
||||
def _approval_event_identity(
|
||||
payload: dict[str, Any],
|
||||
ticket_id: str,
|
||||
|
||||
@@ -14,6 +14,9 @@ from app.modules.feishu.constants import (
|
||||
FEISHU_EMPTY_CARD_TEXT,
|
||||
FEISHU_INVALID_TOKEN,
|
||||
FEISHU_VERIFICATION_TOKEN_REQUIRED,
|
||||
FeishuApprovalAction,
|
||||
FeishuApprovalValueKey,
|
||||
FeishuCardKey,
|
||||
FeishuPayloadKey,
|
||||
FeishuReceiveIdType,
|
||||
)
|
||||
@@ -119,16 +122,19 @@ class FeishuService:
|
||||
card = FeishuService.build_basic_card(title, lines)
|
||||
card[FeishuPayloadKey.ELEMENTS].append(
|
||||
{
|
||||
FeishuPayloadKey.TAG: "action",
|
||||
"actions": [
|
||||
FeishuPayloadKey.TAG: FeishuApprovalValueKey.ACTION,
|
||||
FeishuCardKey.ACTIONS: [
|
||||
{
|
||||
FeishuPayloadKey.TAG: "button",
|
||||
FeishuPayloadKey.TEXT: {
|
||||
FeishuPayloadKey.TAG: FeishuPayloadKey.PLAIN_TEXT,
|
||||
FeishuPayloadKey.CONTENT: "批准",
|
||||
},
|
||||
"type": "primary",
|
||||
"value": {"ticket_id": ticket_id, "decision": "approve"},
|
||||
FeishuCardKey.BUTTON_TYPE: "primary",
|
||||
FeishuCardKey.VALUE: {
|
||||
FeishuApprovalValueKey.TICKET_ID: ticket_id,
|
||||
FeishuApprovalValueKey.DECISION: FeishuApprovalAction.APPROVE,
|
||||
},
|
||||
},
|
||||
{
|
||||
FeishuPayloadKey.TAG: "button",
|
||||
@@ -136,8 +142,11 @@ class FeishuService:
|
||||
FeishuPayloadKey.TAG: FeishuPayloadKey.PLAIN_TEXT,
|
||||
FeishuPayloadKey.CONTENT: "拒绝",
|
||||
},
|
||||
"type": "danger",
|
||||
"value": {"ticket_id": ticket_id, "decision": "reject"},
|
||||
FeishuCardKey.BUTTON_TYPE: "danger",
|
||||
FeishuCardKey.VALUE: {
|
||||
FeishuApprovalValueKey.TICKET_ID: ticket_id,
|
||||
FeishuApprovalValueKey.DECISION: FeishuApprovalAction.REJECT,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -19,6 +19,13 @@ from app.modules.audit.service import AuditService
|
||||
from app.modules.business.constants import BusinessDomain, SourceSystem, StatusValue
|
||||
from app.modules.business.models import LegacySyncRun, Project, WorkTask
|
||||
from app.modules.business.service import serialize_model
|
||||
from app.modules.events.constants import (
|
||||
EventAggregateType,
|
||||
EventPayloadKey,
|
||||
EventSource,
|
||||
EventType,
|
||||
)
|
||||
from app.modules.events.service import EventService
|
||||
from app.modules.legacy_mysql.constants import (
|
||||
LEGACY_PROJECT_QUERY_SOURCE,
|
||||
LEGACY_PROJECT_SYNC_NOTE,
|
||||
@@ -491,6 +498,22 @@ class LegacyMySQLService:
|
||||
},
|
||||
)
|
||||
)
|
||||
EventService(self.db).emit(
|
||||
event_type=EventType.LEGACY_SYNC_COMPLETED,
|
||||
source=EventSource.LEGACY_MYSQL,
|
||||
aggregate_type=EventAggregateType.LEGACY_SYNC_RUN,
|
||||
aggregate_id=sync_run.code,
|
||||
actor=actor,
|
||||
payload={
|
||||
EventPayloadKey.CODE: sync_run.code,
|
||||
EventPayloadKey.DOMAIN: BusinessDomain.PROJECTS,
|
||||
EventPayloadKey.STATUS: sync_run.status,
|
||||
EventPayloadKey.CREATED: created,
|
||||
EventPayloadKey.UPDATED: updated,
|
||||
EventPayloadKey.SKIPPED: skipped,
|
||||
},
|
||||
idempotency_key=f"legacy-sync:{sync_run.code}",
|
||||
)
|
||||
return result
|
||||
|
||||
def sync_tasks(
|
||||
@@ -629,4 +652,20 @@ class LegacyMySQLService:
|
||||
},
|
||||
)
|
||||
)
|
||||
EventService(self.db).emit(
|
||||
event_type=EventType.LEGACY_SYNC_COMPLETED,
|
||||
source=EventSource.LEGACY_MYSQL,
|
||||
aggregate_type=EventAggregateType.LEGACY_SYNC_RUN,
|
||||
aggregate_id=sync_run.code,
|
||||
actor=actor,
|
||||
payload={
|
||||
EventPayloadKey.CODE: sync_run.code,
|
||||
EventPayloadKey.DOMAIN: BusinessDomain.TASKS,
|
||||
EventPayloadKey.STATUS: sync_run.status,
|
||||
EventPayloadKey.CREATED: created,
|
||||
EventPayloadKey.UPDATED: updated,
|
||||
EventPayloadKey.SKIPPED: skipped,
|
||||
},
|
||||
idempotency_key=f"legacy-sync:{sync_run.code}",
|
||||
)
|
||||
return result
|
||||
|
||||
1
app/modules/observability/__init__.py
Normal file
1
app/modules/observability/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
27
app/modules/observability/constants.py
Normal file
27
app/modules/observability/constants.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class ObservabilityKey(StrEnum):
|
||||
STATUS = "status"
|
||||
CHECKS = "checks"
|
||||
METRICS = "metrics"
|
||||
DATABASE = "database"
|
||||
REDIS = "redis"
|
||||
EVENTS = "events"
|
||||
WORKFLOWS = "workflows"
|
||||
WRITEBACKS = "writebacks"
|
||||
|
||||
|
||||
class ObservabilityStatus(StrEnum):
|
||||
OK = "ok"
|
||||
DEGRADED = "degraded"
|
||||
SKIPPED = "skipped"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class ObservabilityMetricKey(StrEnum):
|
||||
ERROR = "error"
|
||||
PENDING = "pending"
|
||||
FAILED = "failed"
|
||||
RUNNING = "running"
|
||||
DISABLED = "disabled"
|
||||
30
app/modules/observability/routes.py
Normal file
30
app/modules/observability/routes.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_api_key
|
||||
from app.modules.observability.service import ObservabilityService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health/live")
|
||||
def live(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
_ = db
|
||||
return ObservabilityService(db).live()
|
||||
|
||||
|
||||
@router.get("/health/ready")
|
||||
def ready(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return ObservabilityService(db).ready()
|
||||
|
||||
|
||||
@router.get("/metrics", dependencies=[Depends(require_api_key)])
|
||||
def metrics(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return ObservabilityService(db).metrics()
|
||||
114
app/modules/observability/service.py
Normal file
114
app/modules/observability/service.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.modules.events.constants import EventStatus
|
||||
from app.modules.events.service import EventService
|
||||
from app.modules.observability.constants import (
|
||||
ObservabilityKey,
|
||||
ObservabilityMetricKey,
|
||||
ObservabilityStatus,
|
||||
)
|
||||
from app.modules.workflows.constants import WorkflowStatus
|
||||
from app.modules.workflows.service import WorkflowService
|
||||
from app.modules.writebacks.constants import WritebackStatus
|
||||
from app.modules.writebacks.service import WritebackService
|
||||
|
||||
|
||||
class ObservabilityService:
|
||||
"""Build health, readiness, and JSON metrics for V3 operations."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def live(self) -> dict[str, str]:
|
||||
return {ObservabilityKey.STATUS: ObservabilityStatus.OK}
|
||||
|
||||
def ready(self) -> dict[str, Any]:
|
||||
checks = {
|
||||
ObservabilityKey.DATABASE: self._database_check(),
|
||||
ObservabilityKey.REDIS: self._redis_check(),
|
||||
ObservabilityKey.EVENTS: self._events_check(),
|
||||
ObservabilityKey.WORKFLOWS: self._workflows_check(),
|
||||
ObservabilityKey.WRITEBACKS: self._writebacks_check(),
|
||||
}
|
||||
degraded = any(
|
||||
item[ObservabilityKey.STATUS]
|
||||
in {ObservabilityStatus.DEGRADED, ObservabilityStatus.ERROR}
|
||||
for item in checks.values()
|
||||
)
|
||||
return {
|
||||
ObservabilityKey.STATUS: (
|
||||
ObservabilityStatus.DEGRADED if degraded else ObservabilityStatus.OK
|
||||
),
|
||||
ObservabilityKey.CHECKS: checks,
|
||||
}
|
||||
|
||||
def metrics(self) -> dict[str, Any]:
|
||||
return {
|
||||
ObservabilityKey.METRICS: {
|
||||
ObservabilityKey.EVENTS: EventService(self.db).count_by_status(),
|
||||
ObservabilityKey.WORKFLOWS: WorkflowService(self.db).count_by_status(),
|
||||
ObservabilityKey.WRITEBACKS: WritebackService(self.db).count_by_status(),
|
||||
}
|
||||
}
|
||||
|
||||
def _database_check(self) -> dict[str, Any]:
|
||||
try:
|
||||
self.db.execute(text("select 1")).scalar()
|
||||
except Exception as exc:
|
||||
return {
|
||||
ObservabilityKey.STATUS: ObservabilityStatus.ERROR,
|
||||
ObservabilityMetricKey.ERROR: str(exc),
|
||||
}
|
||||
return {ObservabilityKey.STATUS: ObservabilityStatus.OK}
|
||||
|
||||
def _redis_check(self) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
if not settings.task_queue_enabled:
|
||||
return {ObservabilityKey.STATUS: ObservabilityStatus.SKIPPED}
|
||||
try:
|
||||
from redis import Redis
|
||||
|
||||
Redis.from_url(settings.redis_url, socket_connect_timeout=1).ping()
|
||||
except Exception as exc:
|
||||
return {
|
||||
ObservabilityKey.STATUS: ObservabilityStatus.DEGRADED,
|
||||
ObservabilityMetricKey.ERROR: str(exc),
|
||||
}
|
||||
return {ObservabilityKey.STATUS: ObservabilityStatus.OK}
|
||||
|
||||
def _events_check(self) -> dict[str, Any]:
|
||||
counts = EventService(self.db).count_by_status()
|
||||
failed = counts.get(EventStatus.FAILED, 0)
|
||||
return {
|
||||
ObservabilityKey.STATUS: (
|
||||
ObservabilityStatus.DEGRADED if failed else ObservabilityStatus.OK
|
||||
),
|
||||
ObservabilityMetricKey.PENDING: counts.get(EventStatus.PENDING, 0),
|
||||
ObservabilityMetricKey.FAILED: failed,
|
||||
}
|
||||
|
||||
def _workflows_check(self) -> dict[str, Any]:
|
||||
counts = WorkflowService(self.db).count_by_status()
|
||||
failed = counts.get(WorkflowStatus.FAILED, 0)
|
||||
return {
|
||||
ObservabilityKey.STATUS: (
|
||||
ObservabilityStatus.DEGRADED if failed else ObservabilityStatus.OK
|
||||
),
|
||||
ObservabilityMetricKey.RUNNING: counts.get(WorkflowStatus.RUNNING, 0),
|
||||
ObservabilityMetricKey.FAILED: failed,
|
||||
}
|
||||
|
||||
def _writebacks_check(self) -> dict[str, Any]:
|
||||
counts = WritebackService(self.db).count_by_status()
|
||||
failed = counts.get(WritebackStatus.FAILED, 0)
|
||||
return {
|
||||
ObservabilityKey.STATUS: (
|
||||
ObservabilityStatus.DEGRADED if failed else ObservabilityStatus.OK
|
||||
),
|
||||
ObservabilityMetricKey.DISABLED: counts.get(WritebackStatus.DISABLED, 0),
|
||||
ObservabilityMetricKey.FAILED: failed,
|
||||
}
|
||||
@@ -34,6 +34,10 @@ class ReportPushKey(StrEnum):
|
||||
STATUS = "status"
|
||||
|
||||
|
||||
class ReportErrorDetail(StrEnum):
|
||||
PUSH_RUN_NOT_FOUND = "Report push run not found"
|
||||
|
||||
|
||||
class LifecycleSection(StrEnum):
|
||||
HEALTH = "health"
|
||||
PROJECTS = "projects"
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.modules.reports.schemas import (
|
||||
ReportResponse,
|
||||
WorkReportGenerateRequest,
|
||||
)
|
||||
from app.modules.reports.constants import ReportPushKey
|
||||
from app.modules.reports.service import ReportService
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
@@ -64,7 +65,7 @@ def list_push_runs(
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return {"items": ReportService(db).list_push_runs(status_filter=status, limit=limit)}
|
||||
return {ReportPushKey.ITEMS: ReportService(db).list_push_runs(status_filter=status, limit=limit)}
|
||||
|
||||
|
||||
@router.get("/push-runs/{code}")
|
||||
|
||||
@@ -34,6 +34,13 @@ from app.modules.business.models import (
|
||||
WorkTask,
|
||||
)
|
||||
from app.modules.business.service import serialize_model
|
||||
from app.modules.events.constants import (
|
||||
EventAggregateType,
|
||||
EventPayloadKey,
|
||||
EventSource,
|
||||
EventType,
|
||||
)
|
||||
from app.modules.events.service import EventService
|
||||
from app.modules.feishu.service import FeishuService
|
||||
from app.modules.reports.constants import (
|
||||
ATTENTION_SCORE_THRESHOLD,
|
||||
@@ -50,6 +57,7 @@ from app.modules.reports.constants import (
|
||||
MetricKey,
|
||||
ReportResponseKey,
|
||||
ReportPushStatus,
|
||||
ReportErrorDetail,
|
||||
ReportStatus,
|
||||
ReportText,
|
||||
ReportTitle,
|
||||
@@ -226,7 +234,7 @@ class ReportService:
|
||||
if record is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Report push run not found",
|
||||
detail=ReportErrorDetail.PUSH_RUN_NOT_FOUND,
|
||||
)
|
||||
return record
|
||||
|
||||
@@ -1113,18 +1121,43 @@ class ReportService:
|
||||
try:
|
||||
result = FeishuService(self.db).send_card(card, receive_id, receive_id_type, actor)
|
||||
except Exception as exc:
|
||||
self.update_push_run(
|
||||
failed_run = self.update_push_run(
|
||||
push_run.code,
|
||||
ReportPushStatus.FAILED,
|
||||
error_message=str(exc),
|
||||
)
|
||||
EventService(self.db).emit(
|
||||
event_type=EventType.REPORT_PUSH_FAILED,
|
||||
source=EventSource.REPORTS,
|
||||
aggregate_type=EventAggregateType.REPORT_PUSH_RUN,
|
||||
aggregate_id=failed_run.code,
|
||||
actor=actor,
|
||||
payload={
|
||||
EventPayloadKey.CODE: failed_run.code,
|
||||
EventPayloadKey.STATUS: failed_run.status,
|
||||
EventPayloadKey.ERROR_MESSAGE: failed_run.error_message,
|
||||
},
|
||||
idempotency_key=f"report-push:{failed_run.code}:{failed_run.status}",
|
||||
)
|
||||
raise
|
||||
self.update_push_run(
|
||||
success_run = self.update_push_run(
|
||||
push_run.code,
|
||||
ReportPushStatus.SUCCESS,
|
||||
provider_response=result,
|
||||
sent=True,
|
||||
)
|
||||
EventService(self.db).emit(
|
||||
event_type=EventType.REPORT_PUSH_SUCCEEDED,
|
||||
source=EventSource.REPORTS,
|
||||
aggregate_type=EventAggregateType.REPORT_PUSH_RUN,
|
||||
aggregate_id=success_run.code,
|
||||
actor=actor,
|
||||
payload={
|
||||
EventPayloadKey.CODE: success_run.code,
|
||||
EventPayloadKey.STATUS: success_run.status,
|
||||
},
|
||||
idempotency_key=f"report-push:{success_run.code}:{success_run.status}",
|
||||
)
|
||||
AuditService(self.db).log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
|
||||
@@ -59,6 +59,16 @@ class RiskEventActionKey(StrEnum):
|
||||
RISK_EVENT = "risk_event"
|
||||
ACTION_RECORD = "action_record"
|
||||
ITEMS = "items"
|
||||
FROM_STATUS = "from_status"
|
||||
TO_STATUS = "to_status"
|
||||
COMMENT = "comment"
|
||||
PAYLOAD = "payload"
|
||||
ASSIGNED_TO = "assigned_to"
|
||||
REVIEW_SUMMARY = "review_summary"
|
||||
|
||||
|
||||
class RiskErrorDetail(StrEnum):
|
||||
RISK_EVENT_NOT_FOUND = "Risk event not found"
|
||||
|
||||
|
||||
RISK_SCORE_WEIGHTS = {
|
||||
|
||||
@@ -144,6 +144,7 @@ def close_risk_event(
|
||||
closed_reason=payload.closed_reason,
|
||||
review_summary=payload.review_summary,
|
||||
actor=principal.actor,
|
||||
approval_ticket_id=payload.approval_ticket_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -158,6 +159,7 @@ def reopen_risk_event(
|
||||
event_id,
|
||||
comment=payload.comment,
|
||||
actor=principal.actor,
|
||||
approval_ticket_id=payload.approval_ticket_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@ class RiskResolveRequest(BaseModel):
|
||||
class RiskCloseRequest(BaseModel):
|
||||
closed_reason: str = Field(..., min_length=1)
|
||||
review_summary: str | None = None
|
||||
approval_ticket_id: str | None = None
|
||||
|
||||
|
||||
class RiskReopenRequest(BaseModel):
|
||||
comment: str | None = None
|
||||
approval_ticket_id: str | None = None
|
||||
|
||||
@@ -2,6 +2,7 @@ from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -16,12 +17,15 @@ from app.modules.audit.constants import (
|
||||
)
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.approvals.constants import ApprovalActionValue, approval_action
|
||||
from app.modules.approvals.service import ApprovalService
|
||||
from app.modules.business.constants import (
|
||||
CLOSED_RISK_STATUSES,
|
||||
DONE_STATUSES,
|
||||
GENERATED_RISK_EVENT_TYPES,
|
||||
PROJECT_CLOSED_STATUSES,
|
||||
SUPPLIER_RISK_LEVELS,
|
||||
BusinessErrorDetail,
|
||||
BusinessDomain,
|
||||
RiskEventType,
|
||||
RiskLevel,
|
||||
@@ -36,10 +40,18 @@ from app.modules.business.models import (
|
||||
WorkTask,
|
||||
)
|
||||
from app.modules.business.service import serialize_model
|
||||
from app.modules.events.constants import (
|
||||
EventAggregateType,
|
||||
EventPayloadKey,
|
||||
EventSource,
|
||||
EventType,
|
||||
)
|
||||
from app.modules.events.service import EventService
|
||||
from app.modules.risk.constants import (
|
||||
RISK_SCORE_WEIGHTS,
|
||||
RiskEventActionKey,
|
||||
RiskEventActionValue,
|
||||
RiskErrorDetail,
|
||||
RiskGenerationAction,
|
||||
RiskGenerationResultKey,
|
||||
RiskEventPayloadKey,
|
||||
@@ -131,7 +143,7 @@ class RiskService:
|
||||
from_status,
|
||||
record.status,
|
||||
comment,
|
||||
{"assigned_to": assigned_to},
|
||||
{RiskEventActionKey.ASSIGNED_TO: assigned_to},
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
@@ -191,8 +203,20 @@ class RiskService:
|
||||
closed_reason: str,
|
||||
review_summary: str | None = None,
|
||||
actor: str = ActorValue.API,
|
||||
approval_ticket_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
record = self._get_event(risk_event_id)
|
||||
approval_payload = {
|
||||
RiskEventPayloadKey.STATUS: StatusValue.CLOSED,
|
||||
"closed_reason": closed_reason,
|
||||
RiskEventActionKey.REVIEW_SUMMARY: review_summary,
|
||||
}
|
||||
self._consume_risk_approval(
|
||||
approval_ticket_id,
|
||||
risk_event_id,
|
||||
approval_payload,
|
||||
actor,
|
||||
)
|
||||
from_status = record.status
|
||||
now = utc_now()
|
||||
record.status = StatusValue.CLOSED
|
||||
@@ -208,7 +232,7 @@ class RiskService:
|
||||
from_status,
|
||||
record.status,
|
||||
closed_reason,
|
||||
{"review_summary": review_summary},
|
||||
{RiskEventActionKey.REVIEW_SUMMARY: review_summary},
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
@@ -220,8 +244,19 @@ class RiskService:
|
||||
risk_event_id: int,
|
||||
comment: str | None = None,
|
||||
actor: str = ActorValue.API,
|
||||
approval_ticket_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
record = self._get_event(risk_event_id)
|
||||
approval_payload = {
|
||||
RiskEventPayloadKey.STATUS: StatusValue.OPEN,
|
||||
RiskEventActionKey.COMMENT: comment,
|
||||
}
|
||||
self._consume_risk_approval(
|
||||
approval_ticket_id,
|
||||
risk_event_id,
|
||||
approval_payload,
|
||||
actor,
|
||||
)
|
||||
from_status = record.status
|
||||
record.status = StatusValue.OPEN
|
||||
record.resolved_at = None
|
||||
@@ -277,7 +312,10 @@ class RiskService:
|
||||
if record is None:
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Risk event not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=RiskErrorDetail.RISK_EVENT_NOT_FOUND,
|
||||
)
|
||||
return record
|
||||
|
||||
def _record_action(
|
||||
@@ -312,13 +350,31 @@ class RiskService:
|
||||
risk_level=AuditRiskLevel.MEDIUM,
|
||||
request_payload={
|
||||
RiskEventActionKey.ACTION: action,
|
||||
"from_status": from_status,
|
||||
"to_status": to_status,
|
||||
"comment": comment,
|
||||
"payload": payload,
|
||||
RiskEventActionKey.FROM_STATUS: from_status,
|
||||
RiskEventActionKey.TO_STATUS: to_status,
|
||||
RiskEventActionKey.COMMENT: comment,
|
||||
RiskEventActionKey.PAYLOAD: payload,
|
||||
},
|
||||
)
|
||||
)
|
||||
EventService(self.db).emit(
|
||||
event_type=EventType.RISK_ACTION_RECORDED,
|
||||
source=EventSource.RISK,
|
||||
aggregate_type=EventAggregateType.RISK_EVENT,
|
||||
aggregate_id=record.id,
|
||||
actor=actor,
|
||||
payload={
|
||||
EventPayloadKey.ACTION: action,
|
||||
EventPayloadKey.STATUS: to_status,
|
||||
EventPayloadKey.RECORD_ID: str(record.id),
|
||||
RiskEventActionKey.FROM_STATUS: from_status,
|
||||
RiskEventActionKey.TO_STATUS: to_status,
|
||||
RiskEventActionKey.COMMENT: comment,
|
||||
RiskEventActionKey.PAYLOAD: payload,
|
||||
},
|
||||
idempotency_key=f"risk:{record.id}:{action_record.code}",
|
||||
dispatch=True,
|
||||
)
|
||||
return action_record
|
||||
|
||||
@staticmethod
|
||||
@@ -328,6 +384,27 @@ class RiskService:
|
||||
RiskEventActionKey.ACTION_RECORD: serialize_model(action),
|
||||
}
|
||||
|
||||
def _consume_risk_approval(
|
||||
self,
|
||||
approval_ticket_id: str | None,
|
||||
risk_event_id: int,
|
||||
payload: dict[str, Any],
|
||||
actor: str,
|
||||
) -> None:
|
||||
if not approval_ticket_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=BusinessErrorDetail.HIGH_RISK_APPROVAL_REQUIRED,
|
||||
)
|
||||
ApprovalService(self.db).consume_for(
|
||||
approval_ticket_id,
|
||||
BusinessDomain.RISK_EVENTS,
|
||||
risk_event_id,
|
||||
approval_action(ApprovalActionValue.UPDATE, BusinessDomain.RISK_EVENTS),
|
||||
payload,
|
||||
actor,
|
||||
)
|
||||
|
||||
def generate_events(self, actor: str = ActorValue.API) -> dict[str, Any]:
|
||||
"""Generate or refresh risk-event ledger entries from current signals."""
|
||||
|
||||
|
||||
1
app/modules/workflows/__init__.py
Normal file
1
app/modules/workflows/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
29
app/modules/workflows/constants.py
Normal file
29
app/modules/workflows/constants.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class WorkflowType(StrEnum):
|
||||
RISK_EVENT_REVIEW = "risk_event_review"
|
||||
OFFICIAL_WRITEBACK = "official_writeback"
|
||||
|
||||
|
||||
class WorkflowStatus(StrEnum):
|
||||
WAITING_APPROVAL = "waiting_approval"
|
||||
WAITING_REVIEW = "waiting_review"
|
||||
RUNNING = "running"
|
||||
BLOCKED = "blocked"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class WorkflowResponseKey(StrEnum):
|
||||
ITEMS = "items"
|
||||
WORKFLOW = "workflow"
|
||||
ACTION = "action"
|
||||
|
||||
|
||||
class WorkflowErrorDetail(StrEnum):
|
||||
WORKFLOW_NOT_FOUND = "Workflow instance not found"
|
||||
|
||||
|
||||
WORKFLOW_CODE_PREFIX = "WF"
|
||||
WORKFLOW_ACTION_CODE_PREFIX = "WF-ACTION"
|
||||
44
app/modules/workflows/models.py
Normal file
44
app/modules/workflows/models.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.db_base import Base
|
||||
from app.core.time import utc_now
|
||||
from app.modules.workflows.constants import WorkflowStatus
|
||||
|
||||
|
||||
class WorkflowInstance(Base):
|
||||
__tablename__ = "workflow_instances"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
workflow_type: Mapped[str] = mapped_column(String(128), index=True)
|
||||
aggregate_type: Mapped[str] = mapped_column(String(128), index=True)
|
||||
aggregate_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default=WorkflowStatus.RUNNING, index=True)
|
||||
actor: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True)
|
||||
current_step: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=utc_now,
|
||||
onupdate=utc_now,
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||
|
||||
|
||||
class WorkflowAction(Base):
|
||||
__tablename__ = "workflow_actions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
workflow_code: Mapped[str] = mapped_column(String(64), index=True)
|
||||
action: Mapped[str] = mapped_column(String(128), index=True)
|
||||
actor: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True)
|
||||
from_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
to_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
33
app/modules/workflows/routes.py
Normal file
33
app/modules/workflows/routes.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_api_key
|
||||
from app.modules.workflows.constants import WorkflowResponseKey
|
||||
from app.modules.workflows.service import WorkflowService
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_workflows(
|
||||
status: str | None = None,
|
||||
workflow_type: str | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return {
|
||||
WorkflowResponseKey.ITEMS: WorkflowService(db).list_workflows(
|
||||
status_filter=status,
|
||||
workflow_type=workflow_type,
|
||||
limit=limit,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{code}")
|
||||
def get_workflow(
|
||||
code: str,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return {WorkflowResponseKey.WORKFLOW: WorkflowService(db).get_workflow(code)}
|
||||
21
app/modules/workflows/schemas.py
Normal file
21
app/modules/workflows/schemas.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class WorkflowRead(BaseModel):
|
||||
code: str
|
||||
workflow_type: str
|
||||
aggregate_type: str
|
||||
aggregate_id: str | None
|
||||
status: str
|
||||
actor: str
|
||||
current_step: str | None
|
||||
payload: dict[str, Any] | None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
completed_at: str | None
|
||||
|
||||
|
||||
class WorkflowListRead(BaseModel):
|
||||
items: list[dict[str, Any]]
|
||||
134
app/modules/workflows/service.py
Normal file
134
app/modules/workflows/service.py
Normal file
@@ -0,0 +1,134 @@
|
||||
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.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,
|
||||
) -> WorkflowInstance:
|
||||
aggregate_id_text = str(aggregate_id) if aggregate_id is not None else None
|
||||
record = self.db.execute(
|
||||
select(WorkflowInstance).where(
|
||||
WorkflowInstance.workflow_type == workflow_type,
|
||||
WorkflowInstance.aggregate_type == aggregate_type,
|
||||
WorkflowInstance.aggregate_id == aggregate_id_text,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
previous_status = None
|
||||
now = utc_now()
|
||||
if record is None:
|
||||
record = WorkflowInstance(
|
||||
code=f"{WORKFLOW_CODE_PREFIX}-{now:%Y%m%d%H%M%S%f}",
|
||||
workflow_type=workflow_type,
|
||||
aggregate_type=aggregate_type,
|
||||
aggregate_id=aggregate_id_text,
|
||||
status=status_value,
|
||||
actor=actor,
|
||||
current_step=action,
|
||||
payload=payload or {},
|
||||
)
|
||||
self.db.add(record)
|
||||
self.db.flush()
|
||||
else:
|
||||
previous_status = record.status
|
||||
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}",
|
||||
workflow_code=record.code,
|
||||
action=action,
|
||||
actor=actor,
|
||||
from_status=previous_status,
|
||||
to_status=status_value,
|
||||
payload=payload or {},
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
return record
|
||||
|
||||
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}
|
||||
1
app/modules/writebacks/__init__.py
Normal file
1
app/modules/writebacks/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
74
app/modules/writebacks/adapters.py
Normal file
74
app/modules/writebacks/adapters.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from typing import Any, Protocol
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.core.constants import BEARER_TOKEN_TEMPLATE, HttpHeader
|
||||
from app.modules.writebacks.constants import (
|
||||
WRITEBACK_DISABLED_MESSAGE,
|
||||
WritebackErrorDetail,
|
||||
WritebackPayloadKey,
|
||||
WritebackStatus,
|
||||
)
|
||||
from app.modules.writebacks.models import OfficialWritebackRun
|
||||
|
||||
|
||||
class WritebackAdapter(Protocol):
|
||||
def submit(self, run: OfficialWritebackRun) -> dict[str, Any]:
|
||||
"""Submit one writeback run to the configured official integration."""
|
||||
|
||||
|
||||
class DisabledWritebackAdapter:
|
||||
def submit(self, run: OfficialWritebackRun) -> dict[str, Any]:
|
||||
return {
|
||||
WritebackPayloadKey.STATUS: WritebackStatus.DISABLED,
|
||||
WritebackPayloadKey.ERROR_MESSAGE: WRITEBACK_DISABLED_MESSAGE,
|
||||
WritebackPayloadKey.CODE: run.code,
|
||||
}
|
||||
|
||||
|
||||
class HttpWritebackAdapter:
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
|
||||
def submit(self, run: OfficialWritebackRun) -> dict[str, Any]:
|
||||
if not self.settings.official_api_base_url or not self.settings.official_api_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=WritebackErrorDetail.OFFICIAL_API_NOT_CONFIGURED,
|
||||
)
|
||||
url = f"{self.settings.official_api_base_url.rstrip('/')}/writebacks/{run.domain}"
|
||||
headers = {
|
||||
HttpHeader.AUTHORIZATION: BEARER_TOKEN_TEMPLATE.format(
|
||||
token=self.settings.official_api_token
|
||||
)
|
||||
}
|
||||
payload = {
|
||||
WritebackPayloadKey.CODE: run.code,
|
||||
WritebackPayloadKey.DOMAIN: run.domain,
|
||||
WritebackPayloadKey.RECORD_ID: run.record_id,
|
||||
WritebackPayloadKey.ACTION: run.action,
|
||||
WritebackPayloadKey.PAYLOAD: run.request_payload or {},
|
||||
}
|
||||
with httpx.Client(timeout=self.settings.official_api_timeout_seconds) as client:
|
||||
response = client.post(url, json=payload, headers=headers)
|
||||
if response.status_code >= status.HTTP_400_BAD_REQUEST:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=response.text,
|
||||
)
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
data = {"text": response.text}
|
||||
return {
|
||||
WritebackPayloadKey.STATUS: WritebackStatus.SENT,
|
||||
WritebackPayloadKey.PROVIDER_RESPONSE: data,
|
||||
}
|
||||
|
||||
|
||||
def get_writeback_adapter(settings: Settings) -> WritebackAdapter:
|
||||
if settings.official_writeback_enabled:
|
||||
return HttpWritebackAdapter(settings)
|
||||
return DisabledWritebackAdapter()
|
||||
42
app/modules/writebacks/constants.py
Normal file
42
app/modules/writebacks/constants.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class WritebackStatus(StrEnum):
|
||||
DRAFT = "draft"
|
||||
PENDING_APPROVAL = "pending_approval"
|
||||
DISABLED = "disabled"
|
||||
QUEUED = "queued"
|
||||
SENT = "sent"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class WritebackActionValue(StrEnum):
|
||||
WRITEBACK = "writeback"
|
||||
|
||||
|
||||
class WritebackPayloadKey(StrEnum):
|
||||
CODE = "code"
|
||||
STATUS = "status"
|
||||
DOMAIN = "domain"
|
||||
RECORD_ID = "record_id"
|
||||
ACTION = "action"
|
||||
PAYLOAD = "payload"
|
||||
APPROVAL_TICKET_ID = "approval_ticket_id"
|
||||
PROVIDER_RESPONSE = "provider_response"
|
||||
ERROR_MESSAGE = "error_message"
|
||||
|
||||
|
||||
class WritebackResponseKey(StrEnum):
|
||||
ITEMS = "items"
|
||||
DATA = "data"
|
||||
|
||||
|
||||
class WritebackErrorDetail(StrEnum):
|
||||
RUN_NOT_FOUND = "Writeback run not found"
|
||||
APPROVAL_TICKET_REQUIRED = "approval_ticket_id is required for official writeback"
|
||||
OFFICIAL_API_NOT_CONFIGURED = "Official API is not configured"
|
||||
|
||||
|
||||
WRITEBACK_CODE_PREFIX = "WB"
|
||||
WRITEBACK_DISABLED_MESSAGE = "Official writeback is disabled"
|
||||
WRITEBACK_DEFAULT_ACTION = "sync"
|
||||
39
app/modules/writebacks/models.py
Normal file
39
app/modules/writebacks/models.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.db_base import Base
|
||||
from app.core.time import utc_now
|
||||
from app.modules.writebacks.constants import WritebackStatus
|
||||
|
||||
|
||||
class OfficialWritebackRun(Base):
|
||||
__tablename__ = "official_writeback_runs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
domain: Mapped[str] = mapped_column(String(128), index=True)
|
||||
record_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
action: Mapped[str] = mapped_column(String(128), index=True)
|
||||
actor: Mapped[str] = mapped_column(String(128), default=ActorValue.API, index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default=WritebackStatus.DRAFT, index=True)
|
||||
approval_ticket_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
idempotency_key: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
request_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
provider_response: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=utc_now,
|
||||
onupdate=utc_now,
|
||||
)
|
||||
submitted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||
66
app/modules/writebacks/routes.py
Normal file
66
app/modules/writebacks/routes.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.writebacks.constants import WritebackResponseKey
|
||||
from app.modules.writebacks.schemas import WritebackCreateRequest, WritebackSubmitRequest
|
||||
from app.modules.writebacks.service import WritebackService
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_writebacks(
|
||||
status: str | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return {
|
||||
WritebackResponseKey.ITEMS: WritebackService(db).list_runs(
|
||||
status_filter=status,
|
||||
limit=limit,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_writeback(
|
||||
payload: WritebackCreateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
return {
|
||||
WritebackResponseKey.DATA: WritebackService(db).create_run(
|
||||
domain=payload.domain,
|
||||
record_id=payload.record_id,
|
||||
action=payload.action,
|
||||
payload=payload.payload,
|
||||
actor=principal.actor,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{code}")
|
||||
def get_writeback(
|
||||
code: str,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
return {WritebackResponseKey.DATA: WritebackService(db).get_run(code)}
|
||||
|
||||
|
||||
@router.post("/{code}/submit")
|
||||
def submit_writeback(
|
||||
code: str,
|
||||
payload: WritebackSubmitRequest,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
) -> dict:
|
||||
return {
|
||||
WritebackResponseKey.DATA: WritebackService(db).submit_run(
|
||||
code,
|
||||
approval_ticket_id=payload.approval_ticket_id,
|
||||
actor=principal.actor,
|
||||
)
|
||||
}
|
||||
35
app/modules/writebacks/schemas.py
Normal file
35
app/modules/writebacks/schemas.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.modules.writebacks.constants import WRITEBACK_DEFAULT_ACTION
|
||||
|
||||
|
||||
class WritebackCreateRequest(BaseModel):
|
||||
domain: str = Field(..., min_length=1)
|
||||
record_id: str | None = None
|
||||
action: str = Field(default=WRITEBACK_DEFAULT_ACTION, min_length=1)
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
idempotency_key: str | None = None
|
||||
|
||||
|
||||
class WritebackSubmitRequest(BaseModel):
|
||||
approval_ticket_id: str | None = None
|
||||
|
||||
|
||||
class WritebackRead(BaseModel):
|
||||
code: str
|
||||
domain: str
|
||||
record_id: str | None
|
||||
action: str
|
||||
actor: str
|
||||
status: str
|
||||
approval_ticket_id: str | None
|
||||
idempotency_key: str | None
|
||||
request_payload: dict[str, Any] | None
|
||||
provider_response: dict[str, Any] | None
|
||||
error_message: str | None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
submitted_at: str | None
|
||||
sent_at: str | None
|
||||
232
app/modules/writebacks/service.py
Normal file
232
app/modules/writebacks/service.py
Normal file
@@ -0,0 +1,232 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.pagination import bounded_limit
|
||||
from app.core.time import utc_now
|
||||
from app.modules.approvals.constants import approval_action
|
||||
from app.modules.approvals.service import ApprovalService
|
||||
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.business.service import serialize_model
|
||||
from app.modules.events.constants import EventAggregateType, EventSource, EventType
|
||||
from app.modules.events.service import EventService
|
||||
from app.modules.writebacks.adapters import get_writeback_adapter
|
||||
from app.modules.writebacks.constants import (
|
||||
WRITEBACK_CODE_PREFIX,
|
||||
WRITEBACK_DISABLED_MESSAGE,
|
||||
WritebackActionValue,
|
||||
WritebackErrorDetail,
|
||||
WritebackPayloadKey,
|
||||
WritebackStatus,
|
||||
)
|
||||
from app.modules.writebacks.models import OfficialWritebackRun
|
||||
|
||||
|
||||
class WritebackService:
|
||||
"""Create and submit approved official-system writeback runs."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.audit = AuditService(db)
|
||||
|
||||
def create_run(
|
||||
self,
|
||||
domain: str,
|
||||
record_id: str | None,
|
||||
action: str,
|
||||
payload: dict[str, Any],
|
||||
actor: str = ActorValue.API,
|
||||
idempotency_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if idempotency_key:
|
||||
existing = self.db.execute(
|
||||
select(OfficialWritebackRun).where(
|
||||
OfficialWritebackRun.idempotency_key == idempotency_key
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return serialize_model(existing)
|
||||
|
||||
record = OfficialWritebackRun(
|
||||
code=f"{WRITEBACK_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}",
|
||||
domain=domain,
|
||||
record_id=record_id,
|
||||
action=action,
|
||||
actor=actor,
|
||||
request_payload=payload,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
self.db.add(record)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
EventService(self.db).emit(
|
||||
event_type=EventType.WRITEBACK_REQUESTED,
|
||||
source=EventSource.WRITEBACK,
|
||||
aggregate_type=EventAggregateType.WRITEBACK_RUN,
|
||||
aggregate_id=record.code,
|
||||
actor=actor,
|
||||
payload=self._event_payload(record),
|
||||
idempotency_key=f"{record.code}:requested",
|
||||
dispatch=True,
|
||||
)
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.API,
|
||||
action=AuditAction.WRITEBACK_CREATE,
|
||||
target_type=domain,
|
||||
target_id=record_id,
|
||||
risk_level=AuditRiskLevel.HIGH,
|
||||
request_payload=payload,
|
||||
response_payload={WritebackPayloadKey.CODE: record.code},
|
||||
)
|
||||
)
|
||||
return serialize_model(record)
|
||||
|
||||
def submit_run(
|
||||
self,
|
||||
code: str,
|
||||
approval_ticket_id: str | None,
|
||||
actor: str = ActorValue.API,
|
||||
) -> dict[str, Any]:
|
||||
if not approval_ticket_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=WritebackErrorDetail.APPROVAL_TICKET_REQUIRED,
|
||||
)
|
||||
record = self._get_run(code)
|
||||
settings = get_settings()
|
||||
approval_action_name = approval_action(WritebackActionValue.WRITEBACK, record.domain)
|
||||
if settings.official_writeback_enabled:
|
||||
ApprovalService(self.db).consume_for(
|
||||
approval_ticket_id,
|
||||
record.domain,
|
||||
record.record_id,
|
||||
approval_action_name,
|
||||
record.request_payload or {},
|
||||
actor,
|
||||
)
|
||||
elif not ApprovalService(self.db).is_approved_for(
|
||||
approval_ticket_id,
|
||||
record.domain,
|
||||
record.record_id,
|
||||
approval_action_name,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=WritebackErrorDetail.APPROVAL_TICKET_REQUIRED,
|
||||
)
|
||||
|
||||
record.approval_ticket_id = approval_ticket_id
|
||||
record.submitted_at = utc_now()
|
||||
record.status = WritebackStatus.PENDING_APPROVAL
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
|
||||
adapter = get_writeback_adapter(settings)
|
||||
try:
|
||||
result = adapter.submit(record)
|
||||
except Exception as exc:
|
||||
record.status = WritebackStatus.FAILED
|
||||
record.error_message = str(exc)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
self._emit_submitted(record, actor)
|
||||
self._audit_submit(record, actor)
|
||||
return serialize_model(record)
|
||||
|
||||
if result.get(WritebackPayloadKey.STATUS) == WritebackStatus.DISABLED:
|
||||
record.status = WritebackStatus.DISABLED
|
||||
record.error_message = WRITEBACK_DISABLED_MESSAGE
|
||||
else:
|
||||
record.status = WritebackStatus.SENT
|
||||
record.provider_response = result.get(WritebackPayloadKey.PROVIDER_RESPONSE) or result
|
||||
record.sent_at = utc_now()
|
||||
record.error_message = None
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
self._emit_submitted(record, actor)
|
||||
self._audit_submit(record, actor)
|
||||
return serialize_model(record)
|
||||
|
||||
def list_runs(
|
||||
self,
|
||||
status_filter: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
stmt = (
|
||||
select(OfficialWritebackRun)
|
||||
.order_by(OfficialWritebackRun.id.desc())
|
||||
.limit(bounded_limit(limit))
|
||||
)
|
||||
if status_filter:
|
||||
stmt = stmt.where(OfficialWritebackRun.status == status_filter)
|
||||
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
||||
|
||||
def get_run(self, code: str) -> dict[str, Any]:
|
||||
return serialize_model(self._get_run(code))
|
||||
|
||||
def count_by_status(self) -> dict[str, int]:
|
||||
rows = self.db.execute(
|
||||
select(OfficialWritebackRun.status, func.count()).group_by(
|
||||
OfficialWritebackRun.status
|
||||
)
|
||||
).all()
|
||||
return {str(status_value): int(count) for status_value, count in rows}
|
||||
|
||||
def _get_run(self, code: str) -> OfficialWritebackRun:
|
||||
record = self.db.execute(
|
||||
select(OfficialWritebackRun).where(OfficialWritebackRun.code == code)
|
||||
).scalar_one_or_none()
|
||||
if record is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=WritebackErrorDetail.RUN_NOT_FOUND,
|
||||
)
|
||||
return record
|
||||
|
||||
def _emit_submitted(self, record: OfficialWritebackRun, actor: str) -> None:
|
||||
EventService(self.db).emit(
|
||||
event_type=EventType.WRITEBACK_SUBMITTED,
|
||||
source=EventSource.WRITEBACK,
|
||||
aggregate_type=EventAggregateType.WRITEBACK_RUN,
|
||||
aggregate_id=record.code,
|
||||
actor=actor,
|
||||
payload=self._event_payload(record),
|
||||
idempotency_key=f"{record.code}:submitted:{record.status}",
|
||||
dispatch=True,
|
||||
)
|
||||
|
||||
def _audit_submit(self, record: OfficialWritebackRun, actor: str) -> None:
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source=AuditSource.API,
|
||||
action=AuditAction.WRITEBACK_SUBMIT,
|
||||
target_type=record.domain,
|
||||
target_id=record.record_id,
|
||||
risk_level=AuditRiskLevel.HIGH,
|
||||
request_payload={
|
||||
WritebackPayloadKey.CODE: record.code,
|
||||
WritebackPayloadKey.APPROVAL_TICKET_ID: record.approval_ticket_id,
|
||||
},
|
||||
response_payload=self._event_payload(record),
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _event_payload(record: OfficialWritebackRun) -> dict[str, Any]:
|
||||
return {
|
||||
WritebackPayloadKey.CODE: record.code,
|
||||
WritebackPayloadKey.STATUS: record.status,
|
||||
WritebackPayloadKey.DOMAIN: record.domain,
|
||||
WritebackPayloadKey.RECORD_ID: record.record_id,
|
||||
WritebackPayloadKey.ACTION: record.action,
|
||||
WritebackPayloadKey.ERROR_MESSAGE: record.error_message,
|
||||
}
|
||||
Reference in New Issue
Block a user