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