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

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