feat(feishu): 添加飞书入站事件inbox和混合数据库协调功能

- 实现飞书入站事件持久化inbox机制,支持状态管理、租约锁定和重试退避
- 添加混合数据库基线协调工具,确保平台PostgreSQL结构安全对齐
- 增加运行组件心跳检测和readiness就绪检查机制
- 实现app_ticket事件的安全轮换和验证处理
- 添加生产环境运行编排和fail-closed安全机制
- 支持webhook快速确认和长连接独立进程处理
- 完善个人数据擦除时的待处理事件清理功能
```
This commit is contained in:
2026-07-27 17:14:37 +08:00
parent d7db84571d
commit eb8267ed18
61 changed files with 8703 additions and 183 deletions

View File

@@ -1,8 +1,10 @@
import json
from dataclasses import replace
from dataclasses import dataclass
from hashlib import sha256
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.application.feishu.commands import FeishuCommandService
@@ -19,12 +21,19 @@ from app.modules.feishu.constants import (
FeishuCommandKey,
FeishuEventReceiptKey,
FeishuEventSource,
FeishuInboundStatus,
FeishuPayloadKey,
FeishuResponseKey,
)
from app.modules.feishu.models import FeishuEventReceipt
from app.modules.feishu.service import FeishuService
from app.modules.feishu.services import (
FeishuInboundAcceptance,
FeishuInboundProcessResult,
FeishuInboundService,
bind_inbound_event,
)
from app.modules.feishu_users.principal import FeishuMention, FeishuPrincipal
from app.modules.feishu_users.identifiers import feishu_audit_identity_hash
from app.modules.feishu_users.services import FeishuIdentityService
FEISHU_EVENT_ACTIONS = {
@@ -33,6 +42,16 @@ FEISHU_EVENT_ACTIONS = {
}
@dataclass(frozen=True, slots=True)
class FeishuEventAcceptance:
"""Transport-safe acknowledgement for one verified event."""
response: dict[str, Any]
event_key: str | None = None
created: bool = False
should_dispatch: bool = False
class FeishuEventService:
"""Handle Feishu message events from webhook or long connection."""
@@ -40,6 +59,7 @@ class FeishuEventService:
self.db = db
self.feishu = FeishuService(db)
self.commands = FeishuCommandService(db)
self.inbox = FeishuInboundService(db)
def handle_event(
self,
@@ -56,14 +76,134 @@ class FeishuEventService:
source: str | FeishuEventSource,
auto_reply: bool = True,
) -> dict[str, Any]:
"""Handle an event after an HTTP verifier or the Feishu SDK accepted it."""
"""Synchronously accept and process a verified event for local callers."""
acceptance = self.accept_verified_event(payload, source, auto_reply=auto_reply)
if acceptance.event_key is None:
return acceptance.response
if not acceptance.should_dispatch:
return acceptance.response
outcome = self.process_inbound_event(
acceptance.event_key,
worker_id=f"{source}:inline",
)
if outcome.handler_result is not None:
return outcome.handler_result
return _inbound_response(outcome.record, duplicate=not acceptance.created)
def accept_verified_event(
self,
payload: dict[str, Any],
source: str | FeishuEventSource,
*,
auto_reply: bool = True,
) -> FeishuEventAcceptance:
"""Durably accept a verified event without executing its command."""
challenge = payload.get(FeishuPayloadKey.CHALLENGE)
if challenge:
return {FeishuResponseKey.CHALLENGE: challenge}
return FeishuEventAcceptance(
response={FeishuResponseKey.CHALLENGE: challenge}
)
source_value = _normalize_source(source)
if _event_type(payload) == APP_TICKET_EVENT_TYPE:
return self._handle_app_ticket_event(payload, source_value)
event_type = _event_type(payload)
is_app_ticket = event_type == APP_TICKET_EVENT_TYPE
if is_app_ticket:
self._validate_app_ticket_event(payload)
event_identity = _event_identity(payload, source_value)
acceptance = self.inbox.accept(
payload,
event_identity,
event_type=event_type,
auto_reply=auto_reply,
persist_payload=not is_app_ticket,
)
if acceptance.created:
self._audit_accepted_event(payload, source_value, acceptance)
if is_app_ticket:
if acceptance.record.status == FeishuInboundStatus.SUCCEEDED:
return FeishuEventAcceptance(
response=_inbound_response(acceptance.record, duplicate=True),
event_key=acceptance.record.event_key,
created=False,
should_dispatch=False,
)
outcome = self.inbox.process(
acceptance.record.event_key,
handler_factory=lambda db: (
lambda event_payload, _source, _auto_reply, _event_key: (
FeishuEventService(db)._execute_app_ticket_event(
event_payload
)
)
),
payload_override=payload,
worker_id=f"{source_value}:app-ticket",
)
if outcome.record.status != FeishuInboundStatus.SUCCEEDED:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=(
"Failed to persist verified Feishu app ticket: "
f"{outcome.record.last_error or 'unknown_error'}"
),
)
return FeishuEventAcceptance(
response=outcome.handler_result
or _inbound_response(outcome.record, duplicate=not acceptance.created),
event_key=outcome.record.event_key,
created=acceptance.created,
should_dispatch=False,
)
return FeishuEventAcceptance(
response=_inbound_response(
acceptance.record,
duplicate=not acceptance.created,
),
event_key=acceptance.record.event_key,
created=acceptance.created,
should_dispatch=acceptance.should_dispatch,
)
def process_inbound_event(
self,
event_key: str,
*,
worker_id: str = "feishu-inbound",
) -> FeishuInboundProcessResult:
"""Claim and process one accepted event."""
return self.inbox.process(
event_key,
handler_factory=lambda db: FeishuEventService(
db
)._execute_inbound_event,
worker_id=worker_id,
)
def process_due_inbound_events(
self,
*,
limit: int,
worker_id: str = "feishu-inbound",
) -> list[FeishuInboundProcessResult]:
"""Recover due retries and expired leases from persistent state."""
return self.inbox.process_due(
handler_factory=lambda db: FeishuEventService(
db
)._execute_inbound_event,
limit=limit,
worker_id=worker_id,
)
def _execute_inbound_event(
self,
payload: dict[str, Any],
_source: str,
auto_reply: bool,
event_key: str,
) -> dict[str, Any]:
user_features_enabled = get_settings().feishu_user_features_enabled
command = (
self.commands.extract_event_command(payload)
@@ -75,62 +215,37 @@ class FeishuEventService:
if user_features_enabled and command
else None
)
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=principal.user_code if principal else 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=_audit_event_metadata(
payload,
include_open_id=not user_features_enabled,
include_identity_context=user_features_enabled,
),
response_payload={FeishuResponseKey.ACCEPTED: True},
)
)
if command is None:
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=(
principal.user_code
if principal
else (
ActorValue.FEISHU
if user_features_enabled
else command[FeishuCommandKey.ACTOR]
)
),
auto_reply=auto_reply,
principal=principal,
)
self.commands.feishu.set_message_uuid(_reply_uuid(event_key))
with bind_inbound_event(event_key):
result = self.commands.handle_text(
command[FeishuCommandKey.TEXT],
chat_id=command[FeishuCommandKey.CHAT_ID],
actor=(
principal.user_code
if principal
else (
ActorValue.FEISHU
if user_features_enabled
else command[FeishuCommandKey.ACTOR]
)
),
auto_reply=auto_reply,
principal=principal,
)
return {
FeishuResponseKey.OK: True,
FeishuResponseKey.HANDLED: True,
FeishuResponseKey.RESULT: result,
}
def _handle_app_ticket_event(
def _validate_app_ticket_event(
self,
payload: dict[str, Any],
source: FeishuEventSource,
) -> dict[str, Any]:
) -> tuple[str, str]:
settings = get_settings()
configured_app_id = str(settings.feishu_app_id or "").strip()
if not configured_app_id:
@@ -150,37 +265,47 @@ class FeishuEventService:
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Feishu app ticket app_id does not match configured application",
)
return app_id, ticket
event_identity = _event_identity(payload, source)
if event_identity is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Feishu app ticket event identity is required",
)
if not self._register_event(event_identity):
return {
FeishuResponseKey.OK: True,
FeishuResponseKey.HANDLED: False,
FeishuResponseKey.DUPLICATE: True,
}
def _execute_app_ticket_event(
self,
payload: dict[str, Any],
) -> dict[str, Any]:
app_id, ticket = self._validate_app_ticket_event(payload)
FeishuAppTicketService(self.db).store_verified(app_id, ticket)
self.feishu.audit.log(
AuditLogCreate(
actor=ActorValue.FEISHU,
source=AuditSource.FEISHU,
action=FEISHU_EVENT_ACTIONS[source],
target_type=source,
target_id=str(event_identity[FeishuEventReceiptKey.EVENT_KEY]),
request_payload=_audit_event_metadata(payload),
response_payload={FeishuResponseKey.ACCEPTED: True},
)
)
return {
FeishuResponseKey.OK: True,
FeishuResponseKey.HANDLED: True,
}
def _audit_accepted_event(
self,
payload: dict[str, Any],
source: FeishuEventSource,
acceptance: FeishuInboundAcceptance,
) -> None:
user_features_enabled = get_settings().feishu_user_features_enabled
audit_actor = (
_audit_identity_actor(payload)
if user_features_enabled
else None
)
self.feishu.audit.log(
AuditLogCreate(
actor=audit_actor or ActorValue.FEISHU,
source=AuditSource.FEISHU,
action=FEISHU_EVENT_ACTIONS[source],
target_type=source,
target_id=acceptance.record.event_key,
request_payload=_audit_event_metadata(
payload,
include_open_id=not user_features_enabled,
include_identity_context=user_features_enabled,
),
response_payload={FeishuResponseKey.ACCEPTED: True},
)
)
def _resolve_principal(
self,
payload: dict[str, Any],
@@ -217,22 +342,6 @@ class FeishuEventService:
mentions=mentions,
)
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),
)
try:
with self.db.begin_nested():
self.db.add(receipt)
self.db.flush()
except IntegrityError:
return False
return True
def _audit_event_metadata(
payload: dict[str, Any],
*,
@@ -248,20 +357,35 @@ def _audit_event_metadata(
sender_id = sender.get(FeishuPayloadKey.SENDER_ID) or {}
metadata = {
"schema": payload.get("schema"),
FeishuPayloadKey.EVENT_ID: header.get(FeishuPayloadKey.EVENT_ID),
FeishuPayloadKey.EVENT_ID: _identifier_digest(
header.get(FeishuPayloadKey.EVENT_ID),
"event-id",
),
FeishuPayloadKey.EVENT_TYPE: _event_type(payload),
FeishuPayloadKey.MESSAGE_ID: message.get(FeishuPayloadKey.MESSAGE_ID),
FeishuCommandKey.CHAT_ID: message.get(FeishuCommandKey.CHAT_ID),
FeishuPayloadKey.MESSAGE_ID: _identifier_digest(
message.get(FeishuPayloadKey.MESSAGE_ID),
"message-id",
),
FeishuCommandKey.CHAT_ID: _identifier_digest(
message.get(FeishuCommandKey.CHAT_ID),
"chat-id",
),
FeishuPayloadKey.MESSAGE_TYPE: message.get(FeishuPayloadKey.MESSAGE_TYPE),
}
app_id, _ = _app_ticket_fields(payload)
if app_id:
metadata[FeishuPayloadKey.APP_ID] = app_id
metadata[FeishuPayloadKey.APP_ID] = _identifier_digest(app_id, "app-id")
if include_identity_context:
metadata[FeishuPayloadKey.TENANT_KEY] = header.get(FeishuPayloadKey.TENANT_KEY)
metadata[FeishuPayloadKey.TENANT_KEY] = _identifier_digest(
header.get(FeishuPayloadKey.TENANT_KEY),
"tenant-key",
)
metadata[FeishuCommandKey.CHAT_TYPE] = message.get(FeishuPayloadKey.CHAT_TYPE)
if include_open_id:
metadata[FeishuPayloadKey.OPEN_ID] = sender_id.get(FeishuPayloadKey.OPEN_ID)
metadata[FeishuPayloadKey.OPEN_ID] = _identifier_digest(
sender_id.get(FeishuPayloadKey.OPEN_ID),
"open-id",
)
return metadata
@@ -269,10 +393,22 @@ def _normalize_source(source: str | FeishuEventSource) -> FeishuEventSource:
return FeishuEventSource(source)
def _audit_identity_actor(payload: dict[str, Any]) -> str | None:
header = payload.get(FeishuPayloadKey.HEADER) or {}
event = payload.get(FeishuPayloadKey.EVENT) or {}
sender = event.get(FeishuPayloadKey.SENDER) or {}
sender_id = sender.get(FeishuPayloadKey.SENDER_ID) or {}
tenant_key = str(header.get(FeishuPayloadKey.TENANT_KEY) or "").strip()
open_id = str(sender_id.get(FeishuPayloadKey.OPEN_ID) or "").strip()
if not tenant_key or not open_id:
return None
return feishu_audit_identity_hash(tenant_key, open_id)
def _event_identity(
payload: dict[str, Any],
source: str | FeishuEventSource,
) -> dict[str, str | None] | None:
) -> dict[str, str | None]:
source_value = _normalize_source(source)
header = payload.get(FeishuPayloadKey.HEADER) or {}
event = payload.get(FeishuPayloadKey.EVENT) or {}
@@ -284,23 +420,92 @@ def _event_identity(
or event.get(FeishuPayloadKey.UUID)
)
message_id = message.get(FeishuPayloadKey.MESSAGE_ID)
stable_id = event_id or message_id
if not stable_id:
return None
stable_id = event_id or message_id or _payload_fingerprint(payload)
event_type = _event_type(payload)
app_id, _ = _app_ticket_fields(payload)
tenant_key = header.get(FeishuPayloadKey.TENANT_KEY) or app_id or "unknown-tenant"
event_key = ":".join(
raw_event_key = ":".join(
str(part) for part in (tenant_key, event_type or FeishuPayloadKey.EVENT, stable_id)
)
event_key = _identifier_digest(raw_event_key, "event-key")
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,
FeishuEventReceiptKey.EVENT_ID: _identifier_digest(event_id, "event-id"),
FeishuEventReceiptKey.MESSAGE_ID: _identifier_digest(
message_id,
"message-id",
),
}
def _payload_fingerprint(payload: dict[str, Any]) -> str:
scrubbed = _scrub_transport_secrets(payload)
serialized = json.dumps(
scrubbed,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
return f"sha256-{sha256(serialized.encode('utf-8')).hexdigest()}"
def _scrub_transport_secrets(value: Any) -> Any:
if isinstance(value, dict):
return {
str(key): _scrub_transport_secrets(item)
for key, item in value.items()
if str(key).casefold()
not in {
"access_token",
"app_access_token",
"app_secret",
"app_ticket",
"authorization",
"encrypt",
"refresh_token",
"secret",
"tenant_access_token",
"token",
}
}
if isinstance(value, list):
return [_scrub_transport_secrets(item) for item in value]
return value
def _reply_uuid(event_key: str) -> str:
digest = sha256(event_key.encode("utf-8")).hexdigest()[:32]
return f"inbound-{digest}"
def _identifier_digest(value: Any, domain: str) -> str | None:
text = str(value or "").strip()
if not text:
return None
digest = sha256(
f"company-ai-platform:feishu:{domain}:v1\0{text}".encode("utf-8")
).hexdigest()
return f"sha256-{digest}"
def _inbound_response(
record: Any,
*,
duplicate: bool,
) -> dict[str, Any]:
response = {
FeishuResponseKey.OK: True,
FeishuResponseKey.ACCEPTED: True,
FeishuResponseKey.HANDLED: record.status == FeishuInboundStatus.SUCCEEDED,
FeishuResponseKey.STATUS: record.status,
}
if duplicate:
response[FeishuResponseKey.DUPLICATE] = True
return response
def _event_type(payload: dict[str, Any]) -> str:
header = payload.get(FeishuPayloadKey.HEADER) or {}
return str(