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

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

285 lines
10 KiB
Python

import json
from typing import Any
from fastapi import HTTPException, status
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,
FeishuPayloadKey,
FeishuResponseKey,
)
from app.modules.feishu.models import FeishuEventReceipt
from app.modules.feishu.service import FeishuService
FEISHU_EVENT_ACTIONS = {
FeishuEventSource.WEBHOOK: AuditAction.FEISHU_WEBHOOK_EVENT,
FeishuEventSource.LONG_CONNECTION: AuditAction.FEISHU_LONG_CONNECTION_EVENT,
}
class FeishuEventService:
"""Handle Feishu message events from webhook or long connection."""
def __init__(self, db: Session):
self.db = db
self.feishu = FeishuService(db)
self.commands = FeishuCommandService(db)
def handle_event(
self,
payload: dict[str, Any],
source: str | FeishuEventSource,
auto_reply: bool = True,
) -> dict[str, Any]:
self.feishu.verify_event(payload)
challenge = payload.get(FeishuPayloadKey.CHALLENGE)
if challenge:
return {FeishuResponseKey.CHALLENGE: challenge}
source_value = _normalize_source(source)
event_identity = _event_identity(payload, source)
if event_identity and not self._register_event(event_identity):
return {
FeishuResponseKey.OK: True,
FeishuResponseKey.HANDLED: False,
FeishuResponseKey.DUPLICATE: True,
}
self.feishu.audit.log(
AuditLogCreate(
actor=ActorValue.FEISHU,
source=AuditSource.FEISHU,
action=FEISHU_EVENT_ACTIONS[source_value],
target_type=source_value,
target_id=(
event_identity.get(FeishuEventReceiptKey.EVENT_KEY)
if event_identity
else None
),
request_payload=payload,
response_payload={FeishuResponseKey.ACCEPTED: True},
)
)
command = self.commands.extract_event_command(payload)
if not command:
return {FeishuResponseKey.OK: True, FeishuResponseKey.HANDLED: False}
result = self.commands.handle_text(
command[FeishuCommandKey.TEXT],
chat_id=command[FeishuCommandKey.CHAT_ID],
actor=command[FeishuCommandKey.ACTOR],
auto_reply=auto_reply,
)
return {
FeishuResponseKey.OK: True,
FeishuResponseKey.HANDLED: True,
FeishuResponseKey.RESULT: result,
}
def handle_approval_card_action(self, payload: dict[str, Any]) -> dict[str, Any]:
"""Handle Feishu interactive-card approval button callbacks."""
self.feishu.verify_event(payload)
value = _approval_action_value(payload)
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=FEISHU_APPROVAL_ACTION_INVALID,
)
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)
return {
FeishuResponseKey.OK: True,
FeishuResponseKey.HANDLED: True,
FeishuResponseKey.DUPLICATE: True,
FeishuResponseKey.RESULT: {
FeishuApprovalValueKey.TICKET_ID: ticket.ticket_id,
FeishuResponseKey.STATUS: ticket.status,
FeishuResponseKey.APPROVER: ticket.approver,
},
}
ticket = ApprovalService(self.db).decide(
ticket_id,
actor,
approved=decision == FeishuApprovalAction.APPROVE,
comment=str(comment) if comment is not None else None,
)
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.STATUS: ticket.status,
FeishuApprovalValueKey.DECISION: decision,
},
)
)
return {
FeishuResponseKey.OK: True,
FeishuResponseKey.HANDLED: True,
FeishuResponseKey.RESULT: {
FeishuApprovalValueKey.TICKET_ID: ticket.ticket_id,
FeishuResponseKey.STATUS: ticket.status,
FeishuResponseKey.APPROVER: ticket.approver,
},
}
def _register_event(self, event_identity: dict[str, str | None]) -> bool:
receipt = FeishuEventReceipt(
event_key=str(event_identity[FeishuEventReceiptKey.EVENT_KEY]),
source=str(event_identity[FeishuEventReceiptKey.SOURCE]),
event_id=event_identity.get(FeishuEventReceiptKey.EVENT_ID),
message_id=event_identity.get(FeishuEventReceiptKey.MESSAGE_ID),
)
self.db.add(receipt)
try:
self.db.flush()
except IntegrityError:
self.db.rollback()
return False
return True
def _normalize_source(source: str | FeishuEventSource) -> FeishuEventSource:
return FeishuEventSource(source)
def _event_identity(
payload: dict[str, Any],
source: str | FeishuEventSource,
) -> dict[str, str | None] | None:
source_value = _normalize_source(source)
header = payload.get(FeishuPayloadKey.HEADER) or {}
event = payload.get(FeishuPayloadKey.EVENT) or {}
message = event.get(FeishuPayloadKey.MESSAGE) or {}
event_id = header.get(FeishuPayloadKey.EVENT_ID)
message_id = message.get(FeishuPayloadKey.MESSAGE_ID)
stable_id = event_id or message_id
if not stable_id:
return None
event_type = header.get(FeishuPayloadKey.EVENT_TYPE)
event_key = ":".join(
str(part)
for part in (source_value, event_type or FeishuPayloadKey.EVENT, stable_id)
)
return {
FeishuEventReceiptKey.EVENT_KEY: event_key,
FeishuEventReceiptKey.SOURCE: source_value,
FeishuEventReceiptKey.EVENT_ID: str(event_id) if event_id else None,
FeishuEventReceiptKey.MESSAGE_ID: str(message_id) if message_id else None,
}
def _approval_action_value(payload: dict[str, Any]) -> dict[str, Any]:
action = payload.get(FeishuApprovalValueKey.ACTION) or {}
event = payload.get(FeishuPayloadKey.EVENT) 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)
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
return value if isinstance(value, dict) else {}
def _approval_operator(payload: dict[str, Any]) -> str:
operator = payload.get("operator") or (payload.get(FeishuPayloadKey.EVENT) or {}).get(
"operator"
) or {}
operator_id = operator.get("operator_id") or {}
return (
operator_id.get(FeishuPayloadKey.OPEN_ID)
or operator_id.get(FeishuPayloadKey.USER_ID)
or operator.get(FeishuPayloadKey.OPEN_ID)
or operator.get(FeishuPayloadKey.USER_ID)
or ActorValue.FEISHU
)
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,
decision: str,
actor: str,
) -> dict[str, str | None]:
header = payload.get(FeishuPayloadKey.HEADER) or {}
event_id = header.get(FeishuPayloadKey.EVENT_ID)
stable_id = event_id or f"{ticket_id}:{decision}:{actor}"
return {
FeishuEventReceiptKey.EVENT_KEY: f"{FeishuEventSource.WEBHOOK}:approval:{stable_id}",
FeishuEventReceiptKey.SOURCE: FeishuEventSource.WEBHOOK,
FeishuEventReceiptKey.EVENT_ID: str(event_id) if event_id else None,
FeishuEventReceiptKey.MESSAGE_ID: None,
}