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

@@ -21,6 +21,20 @@ class FeishuEventSource(StrEnum):
LONG_CONNECTION = "long_connection"
class FeishuInboundStatus(StrEnum):
PENDING = "pending"
PROCESSING = "processing"
SUCCEEDED = "succeeded"
RETRY = "retry"
FAILED = "failed"
class FeishuEventTransport(StrEnum):
DISABLED = "disabled"
WEBHOOK = "webhook"
LONG_CONNECTION = "long_connection"
class FeishuPayloadKey(StrEnum):
APP_ACCESS_TOKEN = "app_access_token"
APP_ID = "app_id"
@@ -180,6 +194,10 @@ FEISHU_RECEIVE_ID_MISSING = "receive_id or FEISHU_DEFAULT_CHAT_ID is required"
FEISHU_SUCCESS_CODE = 0
FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS = 7200
FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS = 300
FEISHU_INBOUND_BATCH_SIZE = 100
FEISHU_INBOUND_LEASE_SECONDS = 300
FEISHU_INBOUND_MAX_ATTEMPTS = 4
FEISHU_INBOUND_RETRY_DELAYS_SECONDS = (60, 300, 900)
FEISHU_AI_REPLY_TITLE = "AI 回复"
FEISHU_EMPTY_CARD_TEXT = "暂无数据"
FEISHU_MENTION_PATTERN = r"@\S+"

View File

@@ -1,12 +1,24 @@
import json
import logging
from socket import gethostname
from threading import Event, Thread
from time import monotonic
from typing import Any
from urllib.parse import urlsplit
from app.core.background.task_queue import enqueue_feishu_inbound_event
from app.core.config import get_settings
from app.core.constants import ActorValue
from app.core.database import SessionLocal
from app.modules.feishu.constants import FEISHU_DEFAULT_OPEN_API_DOMAIN, FeishuEventSource
from app.application.feishu import FeishuEventService
from app.modules.feishu.constants import (
FEISHU_DEFAULT_OPEN_API_DOMAIN,
FeishuEventSource,
FeishuEventTransport,
FeishuResponseKey,
)
from app.modules.observability.constants import HeartbeatComponent, HeartbeatStatus
from app.modules.observability.service import ObservabilityService
logger = logging.getLogger(__name__)
@@ -18,6 +30,118 @@ def _sdk_domain(base_url: str) -> str:
return f"{parsed.scheme}://{parsed.netloc}"
def _sdk_connection_is_open(client: Any) -> bool:
"""Return whether the SDK exposes a currently open WebSocket connection."""
try:
connection = getattr(client, "_conn", None)
except Exception:
return False
if connection is None:
return False
try:
state = getattr(connection, "state", None)
except Exception:
return False
if state is not None:
state_name = getattr(state, "name", None)
state_text = str(state_name or state).strip().lower()
if state_text == "open" or state_text.endswith(".open"):
return True
if any(
marker in state_text
for marker in ("connecting", "closing", "closed")
):
return False
try:
closed = getattr(connection, "closed", None)
except Exception:
return False
if isinstance(closed, bool):
return not closed
try:
opened = getattr(connection, "open", None)
except Exception:
return False
if isinstance(opened, bool):
return opened
try:
if getattr(connection, "close_code", None) is not None:
return False
transport = getattr(connection, "transport", None)
is_closing = getattr(transport, "is_closing", None)
if callable(is_closing) and is_closing():
return False
except Exception:
return False
# A non-null SDK-private connection object is not enough evidence that the
# WebSocket handshake completed or that the transport remains usable.
return False
def _connection_heartbeat_status(client: Any) -> str:
if _sdk_connection_is_open(client):
return HeartbeatStatus.OK
return HeartbeatStatus.DEGRADED
def _record_runtime_heartbeat(instance_id: str, status_value: str) -> None:
db = SessionLocal()
try:
ObservabilityService(db).record_heartbeat(
component=HeartbeatComponent.FEISHU_EVENTS,
instance_id=instance_id,
status_value=status_value,
actor=ActorValue.FEISHU,
)
except Exception:
db.rollback()
logger.exception("Failed to record Feishu event process heartbeat")
finally:
db.close()
def _heartbeat_loop(
stop_event: Event,
instance_id: str,
interval_seconds: int,
client: Any,
) -> None:
last_status: str | None = None
next_heartbeat_at = 0.0
while not stop_event.is_set():
status_value = _connection_heartbeat_status(client)
current_time = monotonic()
if status_value != last_status or current_time >= next_heartbeat_at:
_record_runtime_heartbeat(instance_id, status_value)
last_status = status_value
next_heartbeat_at = current_time + max(1, interval_seconds)
stop_event.wait(1)
def _start_heartbeat_loop(client: Any) -> tuple[Event, Thread]:
settings = get_settings()
stop_event = Event()
thread = Thread(
target=_heartbeat_loop,
args=(
stop_event,
gethostname(),
settings.heartbeat_interval_seconds,
client,
),
name="feishu-events-heartbeat",
daemon=True,
)
thread.start()
return stop_event, thread
def _sdk_event_to_payload(event: Any) -> dict[str, Any]:
try:
from lark_oapi.core.json import JSON
@@ -42,20 +166,38 @@ def _handle_verified_sdk_event(event: Any) -> None:
payload = _sdk_event_to_payload(event)
db = SessionLocal()
try:
result = FeishuEventService(db)._handle_verified_event(
acceptance = FeishuEventService(db).accept_verified_event(
payload,
source=FeishuEventSource.LONG_CONNECTION,
auto_reply=True,
)
logger.info("Handled Feishu long connection event: %s", result)
finally:
db.close()
if (
acceptance.event_key is not None
and acceptance.should_dispatch
and get_settings().task_queue_enabled
):
enqueue_feishu_inbound_event(
acceptance.event_key,
actor=ActorValue.FEISHU,
)
logger.info(
"Accepted Feishu long connection event key=%s status=%s duplicate=%s",
acceptance.event_key,
acceptance.response.get(FeishuResponseKey.STATUS),
bool(acceptance.response.get(FeishuResponseKey.DUPLICATE)),
)
def run_long_connection() -> None:
"""Start the Feishu long connection client and block forever."""
settings = get_settings()
if settings.feishu_event_transport != FeishuEventTransport.LONG_CONNECTION:
raise RuntimeError(
"FEISHU_EVENT_TRANSPORT must be long_connection for this process"
)
if not settings.feishu_app_id or not settings.feishu_app_secret:
raise RuntimeError("FEISHU_APP_ID and FEISHU_APP_SECRET are required")
@@ -81,7 +223,12 @@ def run_long_connection() -> None:
domain=_sdk_domain(settings.feishu_base_url),
)
logger.info("Starting Feishu long connection client")
client.start()
stop_event, heartbeat_thread = _start_heartbeat_loop(client)
try:
client.start()
finally:
stop_event.set()
heartbeat_thread.join(timeout=1)
if __name__ == "__main__":

View File

@@ -1,10 +1,14 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, Text, true
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.utils.time import utc_now
from app.modules.feishu.constants import (
FEISHU_INBOUND_MAX_ATTEMPTS,
FeishuInboundStatus,
)
class FeishuEventReceipt(Base):
@@ -15,7 +19,79 @@ class FeishuEventReceipt(Base):
source: Mapped[str] = mapped_column(String(64), index=True)
event_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
message_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
event_type: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
auto_reply: Mapped[bool] = mapped_column(
Boolean,
default=True,
server_default=true(),
)
status: Mapped[str] = mapped_column(
String(32),
default=FeishuInboundStatus.PENDING,
server_default=FeishuInboundStatus.PENDING,
index=True,
)
attempt_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
max_attempts: Mapped[int] = mapped_column(
Integer,
default=FEISHU_INBOUND_MAX_ATTEMPTS,
server_default=str(FEISHU_INBOUND_MAX_ATTEMPTS),
)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
next_attempt_at: Mapped[datetime | None] = mapped_column(
DateTime,
nullable=True,
index=True,
)
locked_until: Mapped[datetime | None] = mapped_column(
DateTime,
nullable=True,
index=True,
)
locked_by: Mapped[str | None] = mapped_column(
String(128),
nullable=True,
index=True,
)
received_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
processed_at: Mapped[datetime | None] = mapped_column(
DateTime,
nullable=True,
index=True,
)
reply_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
reply_status: Mapped[str | None] = mapped_column(
String(32),
nullable=True,
index=True,
)
reply_attempt_count: Mapped[int] = mapped_column(
Integer,
default=0,
server_default="0",
)
reply_last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
reply_next_attempt_at: Mapped[datetime | None] = mapped_column(
DateTime,
nullable=True,
index=True,
)
reply_locked_until: Mapped[datetime | None] = mapped_column(
DateTime,
nullable=True,
index=True,
)
reply_locked_by: Mapped[str | None] = mapped_column(
String(128),
nullable=True,
index=True,
)
reply_sent_at: Mapped[datetime | None] = mapped_column(
DateTime,
nullable=True,
index=True,
)
class FeishuAppTicket(Base):

View File

@@ -1,10 +1,18 @@
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.core.background.task_queue import enqueue_feishu_inbound_event
from app.core.config import get_settings
from app.core.constants import ActorValue
from app.core.database import get_db
from app.core.security import ApiPrincipal, require_api_key
from app.application.feishu import FeishuCommandService, FeishuEventService
from app.modules.feishu.constants import FeishuEventSource, FeishuPayloadKey, FeishuResponseKey
from app.modules.feishu.constants import (
FeishuEventSource,
FeishuEventTransport,
FeishuPayloadKey,
FeishuResponseKey,
)
from app.modules.feishu.event_verification import FeishuWebhookVerifier
from app.modules.feishu.schemas import (
FeishuCardMessage,
@@ -19,15 +27,31 @@ router = APIRouter()
@router.post("/webhook")
async def feishu_webhook(request: Request, db: Session = Depends(get_db)) -> dict:
async def feishu_webhook(
request: Request,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
) -> dict:
"""Handle Feishu webhook challenge and text command events."""
if get_settings().feishu_event_transport != FeishuEventTransport.WEBHOOK:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Feishu webhook transport is disabled",
)
payload = FeishuWebhookVerifier().verify(await request.body(), request.headers)
return FeishuEventService(db)._handle_verified_event(
acceptance = FeishuEventService(db).accept_verified_event(
payload,
source=FeishuEventSource.WEBHOOK,
auto_reply=True,
)
if acceptance.event_key is not None and acceptance.should_dispatch:
background_tasks.add_task(
enqueue_feishu_inbound_event,
acceptance.event_key,
actor=ActorValue.FEISHU,
)
return acceptance.response
@router.post("/send-text", response_model=FeishuSendResult)

View File

@@ -18,6 +18,7 @@ from app.modules.feishu.constants import (
FeishuPayloadKey,
FeishuReceiveIdType,
)
from app.modules.feishu.services.reply_outbox import current_reply_outbox
class FeishuService:
@@ -30,12 +31,18 @@ class FeishuService:
self.tenant_key = _optional_text(tenant_key) or _optional_text(
get_settings().feishu_default_tenant_key
)
self.message_uuid: str | None = None
def set_tenant_key(self, tenant_key: str | None) -> None:
"""Set the default tenant used by subsequent outbound operations."""
self.tenant_key = _optional_text(tenant_key)
def set_message_uuid(self, message_uuid: str | None) -> None:
"""Set the idempotency UUID used by replies in the current command."""
self.message_uuid = _optional_text(message_uuid)
def verify_event(self, payload: dict[str, Any]) -> None:
settings = get_settings()
expected = settings.feishu_verification_token
@@ -62,12 +69,26 @@ class FeishuService:
tenant_key: str | None = None,
record_audit: bool = True,
) -> dict[str, Any]:
resolved_uuid = uuid or self.message_uuid
resolved_tenant_key = self._resolve_tenant_key(tenant_key)
reply_outbox = current_reply_outbox()
if reply_outbox is not None:
return reply_outbox.capture_text(
text=text,
receive_id=receive_id,
default_receive_id=get_settings().feishu_default_chat_id,
receive_id_type=receive_id_type,
actor=actor,
message_uuid=resolved_uuid,
tenant_key=resolved_tenant_key,
record_audit=record_audit,
)
result = self.client.send_text(
text,
receive_id,
receive_id_type,
uuid,
tenant_key=self._resolve_tenant_key(tenant_key),
resolved_uuid,
tenant_key=resolved_tenant_key,
)
if record_audit:
self.audit.log(
@@ -79,7 +100,7 @@ class FeishuService:
"receive_target_hash": _target_fingerprint(receive_id),
FeishuPayloadKey.RECEIVE_ID_TYPE: receive_id_type,
"content_length": len(text),
FeishuPayloadKey.UUID: uuid,
FeishuPayloadKey.UUID: resolved_uuid,
},
response_payload=result,
)
@@ -95,12 +116,25 @@ class FeishuService:
uuid: str | None = None,
tenant_key: str | None = None,
) -> dict[str, Any]:
resolved_uuid = uuid or self.message_uuid
resolved_tenant_key = self._resolve_tenant_key(tenant_key)
reply_outbox = current_reply_outbox()
if reply_outbox is not None:
return reply_outbox.capture_card(
card=card,
receive_id=receive_id,
default_receive_id=get_settings().feishu_default_chat_id,
receive_id_type=receive_id_type,
actor=actor,
message_uuid=resolved_uuid,
tenant_key=resolved_tenant_key,
)
result = self.client.send_card(
card,
receive_id,
receive_id_type,
uuid,
tenant_key=self._resolve_tenant_key(tenant_key),
resolved_uuid,
tenant_key=resolved_tenant_key,
)
self.audit.log(
AuditLogCreate(
@@ -111,7 +145,7 @@ class FeishuService:
"receive_target_hash": _target_fingerprint(receive_id),
FeishuPayloadKey.RECEIVE_ID_TYPE: receive_id_type,
"card_element_count": len(card.get(FeishuPayloadKey.ELEMENTS) or []),
FeishuPayloadKey.UUID: uuid,
FeishuPayloadKey.UUID: resolved_uuid,
},
response_payload=result,
)
@@ -124,9 +158,17 @@ class FeishuService:
actor: str = ActorValue.SYSTEM,
tenant_key: str | None = None,
) -> dict[str, Any]:
resolved_tenant_key = self._resolve_tenant_key(tenant_key)
reply_outbox = current_reply_outbox()
if reply_outbox is not None:
return reply_outbox.capture_image(
image=image,
actor=actor,
tenant_key=resolved_tenant_key,
)
result = self.client.upload_image(
image,
tenant_key=self._resolve_tenant_key(tenant_key),
tenant_key=resolved_tenant_key,
)
self.audit.log(
AuditLogCreate(

View File

@@ -0,0 +1,17 @@
from app.modules.feishu.services.inbox import (
FeishuInboundAcceptance,
FeishuInboundProcessResult,
FeishuInboundService,
)
from app.modules.feishu.services.context import (
bind_inbound_event,
current_inbound_event_key,
)
__all__ = [
"FeishuInboundAcceptance",
"FeishuInboundProcessResult",
"FeishuInboundService",
"bind_inbound_event",
"current_inbound_event_key",
]

View File

@@ -0,0 +1,23 @@
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar
_CURRENT_INBOUND_EVENT_KEY: ContextVar[str | None] = ContextVar(
"current_feishu_inbound_event_key",
default=None,
)
@contextmanager
def bind_inbound_event(event_key: str) -> Iterator[None]:
"""Expose the current inbox key to privacy-cleanup hooks."""
token = _CURRENT_INBOUND_EVENT_KEY.set(event_key)
try:
yield
finally:
_CURRENT_INBOUND_EVENT_KEY.reset(token)
def current_inbound_event_key() -> str | None:
return _CURRENT_INBOUND_EVENT_KEY.get()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,247 @@
import base64
from contextlib import contextmanager
from contextvars import ContextVar
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any, Iterator
from fastapi import HTTPException, status
from app.modules.feishu.constants import FEISHU_RECEIVE_ID_MISSING
_IMAGE_PLACEHOLDER_PREFIX = "__feishu_reply_image__:"
_reply_collector: ContextVar["FeishuReplyCollector | None"] = ContextVar(
"feishu_reply_collector",
default=None,
)
@dataclass(slots=True)
class FeishuReplyCollector:
"""Capture outbound Feishu operations before an inbound transaction commits."""
operations: list[dict[str, Any]] = field(default_factory=list)
def capture_text(
self,
*,
text: str,
receive_id: str | None,
default_receive_id: str | None,
receive_id_type: str,
actor: str,
message_uuid: str | None,
tenant_key: str | None,
record_audit: bool,
) -> dict[str, Any]:
target = _required_receive_id(receive_id, default_receive_id)
self.operations.append(
{
"kind": "text",
"text": text,
"receive_id": target,
"receive_id_type": str(receive_id_type),
"actor": str(actor),
"message_uuid": message_uuid,
"tenant_key": tenant_key,
"record_audit": bool(record_audit),
}
)
return {"code": 0, "queued": True}
def capture_card(
self,
*,
card: dict[str, Any],
receive_id: str | None,
default_receive_id: str | None,
receive_id_type: str,
actor: str,
message_uuid: str | None,
tenant_key: str | None,
) -> dict[str, Any]:
target = _required_receive_id(receive_id, default_receive_id)
self.operations.append(
{
"kind": "card",
"card": deepcopy(card),
"receive_id": target,
"receive_id_type": str(receive_id_type),
"actor": str(actor),
"message_uuid": message_uuid,
"tenant_key": tenant_key,
"record_audit": True,
}
)
return {"code": 0, "queued": True}
def capture_image(
self,
*,
image: bytes,
actor: str,
tenant_key: str | None,
) -> dict[str, Any]:
placeholder = f"{_IMAGE_PLACEHOLDER_PREFIX}{len(self.operations)}"
self.operations.append(
{
"kind": "image",
"placeholder": placeholder,
"image_base64": base64.b64encode(image).decode("ascii"),
"actor": str(actor),
"tenant_key": tenant_key,
}
)
return {
"code": 0,
"queued": True,
"data": {"image_key": placeholder},
}
def as_payload(
self,
*,
identity: tuple[str, str] | None,
identity_fence_required: bool,
) -> dict[str, Any] | None:
if not self.operations:
return None
return {
"version": 1,
"identity": (
{"tenant_key": identity[0], "open_id": identity[1]}
if identity is not None
else None
),
"identity_fence_required": identity_fence_required,
"operations": deepcopy(self.operations),
}
@contextmanager
def bind_reply_outbox() -> Iterator[FeishuReplyCollector]:
"""Capture Feishu side effects for the current inbound command."""
collector = FeishuReplyCollector()
token = _reply_collector.set(collector)
try:
yield collector
finally:
_reply_collector.reset(token)
def current_reply_outbox() -> FeishuReplyCollector | None:
return _reply_collector.get()
def decode_image(operation: dict[str, Any]) -> bytes:
encoded = operation.get("image_base64")
if not isinstance(encoded, str) or not encoded:
raise ValueError("Feishu reply image payload is unavailable")
try:
return base64.b64decode(encoded, validate=True)
except (ValueError, TypeError) as exc:
raise ValueError("Feishu reply image payload is invalid") from exc
def resolved_message_operation(payload: dict[str, Any]) -> dict[str, Any]:
operations = _operations(payload)
messages = [
operation
for operation in operations
if operation.get("kind") in {"text", "card"}
]
if len(messages) != 1:
raise ValueError("Feishu reply outbox requires exactly one message")
image_keys = {
str(operation.get("placeholder")): str(operation.get("image_key"))
for operation in operations
if operation.get("kind") == "image"
and operation.get("placeholder")
and operation.get("image_key")
}
message = deepcopy(messages[0])
if message.get("kind") == "card":
message["card"] = _replace_image_placeholders(
message.get("card"),
image_keys,
)
return message
def pending_image_indexes(payload: dict[str, Any]) -> list[int]:
return [
index
for index, operation in enumerate(_operations(payload))
if operation.get("kind") == "image" and not operation.get("image_key")
]
def operations_copy(payload: dict[str, Any]) -> list[dict[str, Any]]:
return deepcopy(_operations(payload))
def payload_identity(payload: Any) -> tuple[str, str] | None:
if not isinstance(payload, dict):
return None
identity = payload.get("identity")
if not isinstance(identity, dict):
return None
tenant_key = str(identity.get("tenant_key") or "").strip()
open_id = str(identity.get("open_id") or "").strip()
if not tenant_key or not open_id:
return None
return tenant_key, open_id
def identity_fence_required(payload: Any) -> bool:
return isinstance(payload, dict) and bool(payload.get("identity_fence_required"))
def _operations(payload: dict[str, Any]) -> list[dict[str, Any]]:
if payload.get("version") != 1:
raise ValueError("Unsupported Feishu reply outbox payload version")
operations = payload.get("operations")
if not isinstance(operations, list) or not all(
isinstance(operation, dict) for operation in operations
):
raise ValueError("Feishu reply outbox operations are invalid")
return operations
def _replace_image_placeholders(
value: Any,
image_keys: dict[str, str],
) -> Any:
if isinstance(value, dict):
return {
key: _replace_image_placeholders(item, image_keys)
for key, item in value.items()
}
if isinstance(value, list):
return [
_replace_image_placeholders(item, image_keys)
for item in value
]
if (
isinstance(value, str)
and value.startswith(_IMAGE_PLACEHOLDER_PREFIX)
):
image_key = image_keys.get(value)
if not image_key:
raise ValueError("Feishu reply image was not prepared")
return image_key
return value
def _required_receive_id(
receive_id: str | None,
default_receive_id: str | None,
) -> str:
target = str(receive_id or default_receive_id or "").strip()
if not target:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=FEISHU_RECEIVE_ID_MISSING,
)
return target