```
feat(feishu): 添加飞书入站事件inbox和混合数据库协调功能 - 实现飞书入站事件持久化inbox机制,支持状态管理、租约锁定和重试退避 - 添加混合数据库基线协调工具,确保平台PostgreSQL结构安全对齐 - 增加运行组件心跳检测和readiness就绪检查机制 - 实现app_ticket事件的安全轮换和验证处理 - 添加生产环境运行编排和fail-closed安全机制 - 支持webhook快速确认和长连接独立进程处理 - 完善个人数据擦除时的待处理事件清理功能 ```
This commit is contained in:
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user