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

@@ -22,6 +22,7 @@ class AIMemoryEntry(Base):
"owner_id",
"fingerprint",
name="uq_ai_memory_owner_fingerprint",
postgresql_nulls_not_distinct=True,
),
)

View File

@@ -91,7 +91,12 @@ class MarketAnnouncement(Base, TimestampMixin):
class MarketWatchlist(Base, TimestampMixin):
__tablename__ = "market_watchlists"
__table_args__ = (
UniqueConstraint("owner_id", "symbol", name="uq_market_watchlist_owner_symbol"),
UniqueConstraint(
"owner_id",
"symbol",
name="uq_market_watchlist_owner_symbol",
postgresql_nulls_not_distinct=True,
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int | None] = mapped_column(

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

View File

@@ -80,7 +80,15 @@ def parse_admin_identities(
tenant_key, separator, open_id = text.partition(":")
tenant_key = tenant_key.strip()
open_id = open_id.strip()
if not separator or not tenant_key or not open_id:
if (
not separator
or not tenant_key
or not open_id
or ":" in open_id
or len(tenant_key) > 128
or len(open_id) > 128
or any(character in text for character in "\r\n")
):
raise ValueError(INVALID_ADMIN_IDENTITY)
identities.add((tenant_key, open_id))
return frozenset(identities)

View File

@@ -0,0 +1,20 @@
from hashlib import sha256
_AUDIT_IDENTITY_DOMAIN = b"company-ai-platform:feishu-audit-identity:v1\0"
def feishu_audit_identity_hash(tenant_key: str, open_id: str) -> str:
"""Return the erasable pseudonymous subject used by pre-registration audits."""
tenant_bytes = tenant_key.encode("utf-8")
open_id_bytes = open_id.encode("utf-8")
identity = b"".join(
(
len(tenant_bytes).to_bytes(4, "big"),
tenant_bytes,
len(open_id_bytes).to_bytes(4, "big"),
open_id_bytes,
)
)
digest = sha256(_AUDIT_IDENTITY_DOMAIN + identity).hexdigest()
return f"feishu-identity-sha256-{digest}"

View File

@@ -6,13 +6,16 @@ class ObservabilityKey(StrEnum):
CHECKS = "checks"
METRICS = "metrics"
DATABASE = "database"
SCHEMA = "schema"
REDIS = "redis"
EVENTS = "events"
WORKFLOWS = "workflows"
AI_MEMORY = "ai_memory"
HEARTBEATS = "heartbeats"
API = "api"
SCHEDULER = "scheduler"
WORKER = "worker"
FEISHU_EVENTS = "feishu_events"
class ObservabilityStatus(StrEnum):
@@ -40,7 +43,9 @@ class HeartbeatComponent(StrEnum):
API = "api"
SCHEDULER = "scheduler"
WORKER = "worker"
FEISHU_EVENTS = "feishu-events"
class HeartbeatStatus(StrEnum):
OK = "ok"
DEGRADED = "degraded"

View File

@@ -0,0 +1,73 @@
import logging
from os import getpid
from socket import gethostname
from threading import Event, Thread
from fastapi import FastAPI
from app.core.config import get_settings
from app.core.constants import ActorValue
from app.core.database import SessionLocal
from app.modules.observability.constants import HeartbeatComponent
from app.modules.observability.service import ObservabilityService
logger = logging.getLogger(__name__)
def attach_api_heartbeat(app: FastAPI) -> None:
"""Record API process liveness without coupling it to request traffic."""
interval_seconds = max(1, get_settings().heartbeat_interval_seconds)
instance_id = f"{gethostname()}:{getpid()}"
@app.on_event("startup")
def start_api_heartbeat() -> None:
current_thread = getattr(app.state, "api_heartbeat_thread", None)
if current_thread is not None and current_thread.is_alive():
return
stop_event = Event()
_record_api_heartbeat(instance_id)
thread = Thread(
target=_api_heartbeat_loop,
args=(stop_event, instance_id, interval_seconds),
name="api-heartbeat",
daemon=True,
)
app.state.api_heartbeat_stop_event = stop_event
app.state.api_heartbeat_thread = thread
app.state.api_heartbeat_instance_id = instance_id
thread.start()
@app.on_event("shutdown")
def stop_api_heartbeat() -> None:
stop_event = getattr(app.state, "api_heartbeat_stop_event", None)
thread = getattr(app.state, "api_heartbeat_thread", None)
if stop_event is not None:
stop_event.set()
if thread is not None:
thread.join(timeout=1)
def _api_heartbeat_loop(
stop_event: Event,
instance_id: str,
interval_seconds: int,
) -> None:
while not stop_event.wait(max(1, interval_seconds)):
_record_api_heartbeat(instance_id)
def _record_api_heartbeat(instance_id: str) -> None:
db = SessionLocal()
try:
ObservabilityService(db).record_heartbeat(
component=HeartbeatComponent.API,
instance_id=instance_id,
actor=ActorValue.API,
)
except Exception:
db.rollback()
logger.exception("Failed to record API process heartbeat")
finally:
db.close()

View File

@@ -9,6 +9,10 @@ from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.constants import ActorValue
from app.core.database.migrations import (
current_alembic_revisions,
expected_alembic_heads,
)
from app.core.utils.time import utc_now
from app.modules.ai_memory.service import AIMemoryService
from app.modules.audit.constants import (
@@ -20,12 +24,18 @@ from app.modules.audit.constants import (
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.events.constants import EventStatus
from app.modules.events.models import DomainEvent
from app.modules.events.services import EventService
from app.modules.feishu.app_tickets import FeishuAppTicketService
from app.modules.feishu.constants import FeishuAppType
from app.modules.feishu_users.constants import FeishuUserStatus
from app.modules.feishu.services import FeishuInboundService
from app.modules.feishu_users.constants import (
FeishuUserStatus,
parse_admin_identities,
)
from app.modules.feishu_users.models import FeishuUser
from app.modules.observability.constants import (
HeartbeatComponent,
HeartbeatStatus,
ObservabilityKey,
ObservabilityMetricKey,
@@ -53,10 +63,35 @@ class ObservabilityService:
def ready(self) -> dict[str, Any]:
checks = {
ObservabilityKey.DATABASE: self._safe_call(self._database_check),
ObservabilityKey.SCHEMA: self._safe_call(self._schema_check),
ObservabilityKey.REDIS: self._safe_call(self._redis_check),
ObservabilityKey.EVENTS: self._safe_call(self._events_check),
ObservabilityKey.WORKFLOWS: self._safe_call(self._workflows_check),
ObservabilityKey.HEARTBEATS: self._safe_call(self._heartbeats_check),
ObservabilityKey.API: self._safe_call(
lambda: self._component_heartbeat_check(
HeartbeatComponent.API,
required=self._api_required(),
)
),
ObservabilityKey.SCHEDULER: self._safe_call(
lambda: self._component_heartbeat_check(
HeartbeatComponent.SCHEDULER,
required=self._scheduler_required(),
)
),
ObservabilityKey.WORKER: self._safe_call(
lambda: self._component_heartbeat_check(
HeartbeatComponent.WORKER,
required=get_settings().task_queue_enabled,
)
),
ObservabilityKey.FEISHU_EVENTS: self._safe_call(
lambda: self._component_heartbeat_check(
HeartbeatComponent.FEISHU_EVENTS,
required=self._feishu_events_required(),
)
),
"feishu_subscriptions": self._safe_call(
self._feishu_subscriptions_check
),
@@ -87,6 +122,7 @@ class ObservabilityService:
self.heartbeat_summary
),
"feishu_users": self._safe_call(self._feishu_user_metrics),
"feishu_inbound": self._safe_call(self._feishu_inbound_metrics),
"subscriptions": self._safe_call(self._subscription_metrics),
}
}
@@ -158,6 +194,29 @@ class ObservabilityService:
self.db.execute(text("select 1")).scalar()
return {ObservabilityKey.STATUS: ObservabilityStatus.OK}
def _schema_check(self) -> dict[str, Any]:
settings = get_settings()
required = (
settings.app_env.lower() in {"prod", "production"}
or settings.scheduler_enabled
or settings.feishu_user_features_enabled
or settings.feishu_event_transport != "disabled"
)
if not required:
return {ObservabilityKey.STATUS: ObservabilityStatus.SKIPPED}
expected = expected_alembic_heads()
current = current_alembic_revisions(self.db.connection())
matches = current == expected and len(expected) == 1
return {
ObservabilityKey.STATUS: (
ObservabilityStatus.OK if matches else ObservabilityStatus.DEGRADED
),
"current": [] if current is None else list(current),
"expected": list(expected),
"reason": None if matches else "schema_revision_mismatch",
}
def _redis_check(self) -> dict[str, Any]:
settings = get_settings()
if not settings.task_queue_enabled:
@@ -215,22 +274,58 @@ class ObservabilityService:
def _events_check(self) -> dict[str, Any]:
counts = EventService(self.db).count_by_status()
current = utc_now()
failed = counts.get(EventStatus.FAILED, 0)
processable = int(
self.db.scalar(
select(func.count())
.select_from(DomainEvent)
.where(
DomainEvent.status == EventStatus.PENDING,
DomainEvent.next_attempt_at.is_not(None),
DomainEvent.next_attempt_at <= current,
or_(
DomainEvent.locked_until.is_(None),
DomainEvent.locked_until <= current,
),
)
)
or 0
)
expired_leases = int(
self.db.scalar(
select(func.count())
.select_from(DomainEvent)
.where(
DomainEvent.status == EventStatus.PENDING,
DomainEvent.locked_by.is_not(None),
DomainEvent.locked_until.is_not(None),
DomainEvent.locked_until <= current,
)
)
or 0
)
reasons: list[str] = []
if processable and not get_settings().event_dispatch_enabled:
reasons.append("dispatch_disabled")
if expired_leases:
reasons.append("expired_leases")
return {
ObservabilityKey.STATUS: (
ObservabilityStatus.DEGRADED if failed else ObservabilityStatus.OK
ObservabilityStatus.DEGRADED if reasons else ObservabilityStatus.OK
),
ObservabilityMetricKey.PENDING: counts.get(EventStatus.PENDING, 0),
ObservabilityMetricKey.FAILED: failed,
"processable": processable,
"expired_leases": expired_leases,
"reasons": reasons,
}
def _workflows_check(self) -> dict[str, Any]:
counts = WorkflowService(self.db).count_by_status()
failed = counts.get(WorkflowStatus.FAILED, 0)
return {
ObservabilityKey.STATUS: (
ObservabilityStatus.DEGRADED if failed else ObservabilityStatus.OK
),
ObservabilityKey.STATUS: ObservabilityStatus.OK,
ObservabilityMetricKey.RUNNING: counts.get(WorkflowStatus.RUNNING, 0),
ObservabilityMetricKey.FAILED: failed,
}
@@ -238,20 +333,86 @@ class ObservabilityService:
def _heartbeats_check(self) -> dict[str, Any]:
summary = self.heartbeat_summary()
total = summary[ObservabilityMetricKey.TOTAL]
active = summary[ObservabilityMetricKey.ACTIVE]
stale = summary[ObservabilityMetricKey.STALE]
if total == 0:
return {ObservabilityKey.STATUS: ObservabilityStatus.SKIPPED}
return {
ObservabilityKey.STATUS: (
ObservabilityStatus.DEGRADED if stale else ObservabilityStatus.OK
ObservabilityStatus.OK if active else ObservabilityStatus.DEGRADED
),
ObservabilityMetricKey.TOTAL: total,
ObservabilityMetricKey.ACTIVE: active,
ObservabilityMetricKey.STALE: stale,
ObservabilityMetricKey.LAST_SEEN_AT: summary[
ObservabilityMetricKey.LAST_SEEN_AT
],
}
def _component_heartbeat_check(
self,
component: str,
*,
required: bool,
) -> dict[str, Any]:
if not required:
return {
ObservabilityKey.STATUS: ObservabilityStatus.SKIPPED,
ObservabilityMetricKey.COMPONENT: component,
}
records = list(
self.db.execute(
select(SystemHeartbeat.status, SystemHeartbeat.last_seen_at).where(
SystemHeartbeat.component == component
)
).all()
)
if not records:
return {
ObservabilityKey.STATUS: ObservabilityStatus.DEGRADED,
ObservabilityMetricKey.COMPONENT: component,
"reason": "heartbeat_missing",
}
last_seen_at = max(item.last_seen_at for item in records)
fresh_records = [
item
for item in records
if item.last_seen_at >= self._heartbeat_stale_threshold()
]
if not fresh_records:
return {
ObservabilityKey.STATUS: ObservabilityStatus.DEGRADED,
ObservabilityMetricKey.COMPONENT: component,
ObservabilityMetricKey.LAST_SEEN_AT: last_seen_at.isoformat(),
"reason": "heartbeat_stale",
}
healthy = any(item.status == HeartbeatStatus.OK for item in fresh_records)
return {
ObservabilityKey.STATUS: (
ObservabilityStatus.OK if healthy else ObservabilityStatus.DEGRADED
),
ObservabilityMetricKey.COMPONENT: component,
ObservabilityMetricKey.LAST_SEEN_AT: last_seen_at.isoformat(),
"reason": None if healthy else "heartbeat_degraded",
}
@staticmethod
def _api_required() -> bool:
return get_settings().app_env.lower() in {"prod", "production"}
@staticmethod
def _scheduler_required() -> bool:
settings = get_settings()
return (
settings.feishu_user_features_enabled
or settings.app_env.lower() in {"prod", "production"}
)
@staticmethod
def _feishu_events_required() -> bool:
settings = get_settings()
return settings.feishu_event_transport == "long_connection"
def _feishu_subscriptions_check(self) -> dict[str, Any]:
active = int(
self.db.scalar(
@@ -284,13 +445,17 @@ class ObservabilityService:
)
or 0
)
if not active and not processable_deliveries:
settings = get_settings()
if (
not settings.feishu_user_features_enabled
and not active
and not processable_deliveries
):
return {
ObservabilityKey.STATUS: ObservabilityStatus.SKIPPED,
"active": 0,
"processable_deliveries": 0,
}
settings = get_settings()
app_id = str(settings.feishu_app_id or "").strip()
credentials_configured = bool(app_id and settings.feishu_app_secret)
active_tenant_count = int(
@@ -316,6 +481,31 @@ class ObservabilityService:
str(settings.feishu_default_tenant_key or "").strip()
)
reasons: list[str] = []
if (
not settings.feishu_user_features_enabled
and (active or processable_deliveries)
):
reasons.append("features_disabled")
if (
settings.app_env.lower() in {"prod", "production"}
and settings.feishu_user_features_enabled
):
try:
configured_admins = parse_admin_identities(
settings.feishu_admin_identities
)
except ValueError:
reasons.append("admin_identities_invalid")
else:
if not configured_admins:
reasons.append("admin_identities_missing")
if settings.feishu_event_transport == "disabled":
reasons.append("event_transport_disabled")
elif (
settings.feishu_event_transport == "webhook"
and not settings.feishu_verification_token
):
reasons.append("verification_token_missing")
if not credentials_configured:
reasons.append("credentials_missing")
if settings.feishu_app_type == FeishuAppType.STORE:
@@ -341,6 +531,8 @@ class ObservabilityService:
),
"active": active,
"processable_deliveries": processable_deliveries,
"features_enabled": settings.feishu_user_features_enabled,
"event_transport": settings.feishu_event_transport,
"app_type": settings.feishu_app_type,
"credentials_configured": credentials_configured,
"ticket_configured": ticket_configured,
@@ -360,6 +552,9 @@ class ObservabilityService:
)
return {"active": active}
def _feishu_inbound_metrics(self) -> dict[str, int]:
return FeishuInboundService(self.db).status_counts()
def _subscription_metrics(self) -> dict[str, int]:
active = int(
self.db.scalar(