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:
2026-07-08 14:08:03 +08:00
parent 92f490b97e
commit 19e59e83cc
59 changed files with 3271 additions and 247 deletions

View File

@@ -11,6 +11,7 @@ class ApprovalStatus(StrEnum):
class ApprovalActionValue(StrEnum):
CREATE = "create"
UPDATE = "update"
WRITEBACK = "writeback"
class ApprovalErrorDetail(StrEnum):

View File

@@ -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(

View File

@@ -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):

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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}'"

View File

@@ -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)]

View File

@@ -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.",
)

View File

@@ -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,

View File

@@ -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"]),
},

View File

@@ -0,0 +1 @@

View 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"

View 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)

View 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)}

View 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]]

View 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,
)

View File

@@ -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

View File

@@ -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,

View File

@@ -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,
},
},
],
}

View File

@@ -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

View File

@@ -0,0 +1 @@

View 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"

View 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()

View 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,
}

View File

@@ -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"

View File

@@ -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}")

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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."""

View File

@@ -0,0 +1 @@

View 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"

View 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)

View 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)}

View 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]]

View 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}

View File

@@ -0,0 +1 @@

View 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()

View 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"

View 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)

View 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,
)
}

View 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

View 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,
}