```
feat(feishu): 添加飞书入站事件inbox和混合数据库协调功能 - 实现飞书入站事件持久化inbox机制,支持状态管理、租约锁定和重试退避 - 添加混合数据库基线协调工具,确保平台PostgreSQL结构安全对齐 - 增加运行组件心跳检测和readiness就绪检查机制 - 实现app_ticket事件的安全轮换和验证处理 - 添加生产环境运行编排和fail-closed安全机制 - 支持webhook快速确认和长连接独立进程处理 - 完善个人数据擦除时的待处理事件清理功能 ```
This commit is contained in:
@@ -22,6 +22,7 @@ class AIMemoryEntry(Base):
|
||||
"owner_id",
|
||||
"fingerprint",
|
||||
name="uq_ai_memory_owner_fingerprint",
|
||||
postgresql_nulls_not_distinct=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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+"
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
17
app/modules/feishu/services/__init__.py
Normal file
17
app/modules/feishu/services/__init__.py
Normal 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",
|
||||
]
|
||||
23
app/modules/feishu/services/context.py
Normal file
23
app/modules/feishu/services/context.py
Normal 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()
|
||||
1305
app/modules/feishu/services/inbox.py
Normal file
1305
app/modules/feishu/services/inbox.py
Normal file
File diff suppressed because it is too large
Load Diff
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
|
||||
@@ -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)
|
||||
|
||||
20
app/modules/feishu_users/identifiers.py
Normal file
20
app/modules/feishu_users/identifiers.py
Normal 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}"
|
||||
@@ -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"
|
||||
|
||||
73
app/modules/observability/runtime.py
Normal file
73
app/modules/observability/runtime.py
Normal 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()
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user