```
feat(feishu): 添加飞书入站事件inbox和混合数据库协调功能 - 实现飞书入站事件持久化inbox机制,支持状态管理、租约锁定和重试退避 - 添加混合数据库基线协调工具,确保平台PostgreSQL结构安全对齐 - 增加运行组件心跳检测和readiness就绪检查机制 - 实现app_ticket事件的安全轮换和验证处理 - 添加生产环境运行编排和fail-closed安全机制 - 支持webhook快速确认和长连接独立进程处理 - 完善个人数据擦除时的待处理事件清理功能 ```
This commit is contained in:
@@ -65,19 +65,44 @@ FINANCE_COMMAND_PREFIXES = ("资金需求", "未来30天资金需求", "项目
|
||||
def _parse_content_text(content: Any) -> str:
|
||||
"""Extract plain command text from a Feishu message content payload."""
|
||||
|
||||
if isinstance(content, dict):
|
||||
return str(
|
||||
content.get(FeishuPayloadKey.TEXT) or content.get(FeishuPayloadKey.CONTENT) or ""
|
||||
)
|
||||
if not isinstance(content, str):
|
||||
return ""
|
||||
return _structured_content_text(content)
|
||||
try:
|
||||
data = json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
return content
|
||||
if isinstance(data, dict):
|
||||
return str(data.get(FeishuPayloadKey.TEXT) or data.get(FeishuPayloadKey.CONTENT) or "")
|
||||
return content
|
||||
return _structured_content_text(data)
|
||||
|
||||
|
||||
def _structured_content_text(value: Any) -> str:
|
||||
"""Flatten Feishu text and rich-post content without stringifying metadata."""
|
||||
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
separator = "\n" if any(isinstance(item, list) for item in value) else ""
|
||||
return separator.join(
|
||||
text
|
||||
for item in value
|
||||
if (text := _structured_content_text(item))
|
||||
)
|
||||
if not isinstance(value, dict):
|
||||
return ""
|
||||
|
||||
tag = str(value.get("tag") or "").strip().lower()
|
||||
if tag == "br":
|
||||
return "\n"
|
||||
if tag == "at":
|
||||
# Authorization uses the event's structured mentions, not display text.
|
||||
return " "
|
||||
|
||||
text_value = value.get(FeishuPayloadKey.TEXT)
|
||||
if isinstance(text_value, str):
|
||||
return text_value
|
||||
if FeishuPayloadKey.CONTENT in value:
|
||||
return _structured_content_text(value.get(FeishuPayloadKey.CONTENT))
|
||||
title = value.get("title")
|
||||
return title if isinstance(title, str) else ""
|
||||
|
||||
|
||||
def _clean_command_text(text: str) -> str:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.feishu.commands import FeishuCommandService
|
||||
@@ -19,12 +21,19 @@ from app.modules.feishu.constants import (
|
||||
FeishuCommandKey,
|
||||
FeishuEventReceiptKey,
|
||||
FeishuEventSource,
|
||||
FeishuInboundStatus,
|
||||
FeishuPayloadKey,
|
||||
FeishuResponseKey,
|
||||
)
|
||||
from app.modules.feishu.models import FeishuEventReceipt
|
||||
from app.modules.feishu.service import FeishuService
|
||||
from app.modules.feishu.services import (
|
||||
FeishuInboundAcceptance,
|
||||
FeishuInboundProcessResult,
|
||||
FeishuInboundService,
|
||||
bind_inbound_event,
|
||||
)
|
||||
from app.modules.feishu_users.principal import FeishuMention, FeishuPrincipal
|
||||
from app.modules.feishu_users.identifiers import feishu_audit_identity_hash
|
||||
from app.modules.feishu_users.services import FeishuIdentityService
|
||||
|
||||
FEISHU_EVENT_ACTIONS = {
|
||||
@@ -33,6 +42,16 @@ FEISHU_EVENT_ACTIONS = {
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FeishuEventAcceptance:
|
||||
"""Transport-safe acknowledgement for one verified event."""
|
||||
|
||||
response: dict[str, Any]
|
||||
event_key: str | None = None
|
||||
created: bool = False
|
||||
should_dispatch: bool = False
|
||||
|
||||
|
||||
class FeishuEventService:
|
||||
"""Handle Feishu message events from webhook or long connection."""
|
||||
|
||||
@@ -40,6 +59,7 @@ class FeishuEventService:
|
||||
self.db = db
|
||||
self.feishu = FeishuService(db)
|
||||
self.commands = FeishuCommandService(db)
|
||||
self.inbox = FeishuInboundService(db)
|
||||
|
||||
def handle_event(
|
||||
self,
|
||||
@@ -56,14 +76,134 @@ class FeishuEventService:
|
||||
source: str | FeishuEventSource,
|
||||
auto_reply: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle an event after an HTTP verifier or the Feishu SDK accepted it."""
|
||||
"""Synchronously accept and process a verified event for local callers."""
|
||||
|
||||
acceptance = self.accept_verified_event(payload, source, auto_reply=auto_reply)
|
||||
if acceptance.event_key is None:
|
||||
return acceptance.response
|
||||
if not acceptance.should_dispatch:
|
||||
return acceptance.response
|
||||
outcome = self.process_inbound_event(
|
||||
acceptance.event_key,
|
||||
worker_id=f"{source}:inline",
|
||||
)
|
||||
if outcome.handler_result is not None:
|
||||
return outcome.handler_result
|
||||
return _inbound_response(outcome.record, duplicate=not acceptance.created)
|
||||
|
||||
def accept_verified_event(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
source: str | FeishuEventSource,
|
||||
*,
|
||||
auto_reply: bool = True,
|
||||
) -> FeishuEventAcceptance:
|
||||
"""Durably accept a verified event without executing its command."""
|
||||
|
||||
challenge = payload.get(FeishuPayloadKey.CHALLENGE)
|
||||
if challenge:
|
||||
return {FeishuResponseKey.CHALLENGE: challenge}
|
||||
return FeishuEventAcceptance(
|
||||
response={FeishuResponseKey.CHALLENGE: challenge}
|
||||
)
|
||||
source_value = _normalize_source(source)
|
||||
if _event_type(payload) == APP_TICKET_EVENT_TYPE:
|
||||
return self._handle_app_ticket_event(payload, source_value)
|
||||
event_type = _event_type(payload)
|
||||
is_app_ticket = event_type == APP_TICKET_EVENT_TYPE
|
||||
if is_app_ticket:
|
||||
self._validate_app_ticket_event(payload)
|
||||
event_identity = _event_identity(payload, source_value)
|
||||
acceptance = self.inbox.accept(
|
||||
payload,
|
||||
event_identity,
|
||||
event_type=event_type,
|
||||
auto_reply=auto_reply,
|
||||
persist_payload=not is_app_ticket,
|
||||
)
|
||||
if acceptance.created:
|
||||
self._audit_accepted_event(payload, source_value, acceptance)
|
||||
if is_app_ticket:
|
||||
if acceptance.record.status == FeishuInboundStatus.SUCCEEDED:
|
||||
return FeishuEventAcceptance(
|
||||
response=_inbound_response(acceptance.record, duplicate=True),
|
||||
event_key=acceptance.record.event_key,
|
||||
created=False,
|
||||
should_dispatch=False,
|
||||
)
|
||||
outcome = self.inbox.process(
|
||||
acceptance.record.event_key,
|
||||
handler_factory=lambda db: (
|
||||
lambda event_payload, _source, _auto_reply, _event_key: (
|
||||
FeishuEventService(db)._execute_app_ticket_event(
|
||||
event_payload
|
||||
)
|
||||
)
|
||||
),
|
||||
payload_override=payload,
|
||||
worker_id=f"{source_value}:app-ticket",
|
||||
)
|
||||
if outcome.record.status != FeishuInboundStatus.SUCCEEDED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=(
|
||||
"Failed to persist verified Feishu app ticket: "
|
||||
f"{outcome.record.last_error or 'unknown_error'}"
|
||||
),
|
||||
)
|
||||
return FeishuEventAcceptance(
|
||||
response=outcome.handler_result
|
||||
or _inbound_response(outcome.record, duplicate=not acceptance.created),
|
||||
event_key=outcome.record.event_key,
|
||||
created=acceptance.created,
|
||||
should_dispatch=False,
|
||||
)
|
||||
return FeishuEventAcceptance(
|
||||
response=_inbound_response(
|
||||
acceptance.record,
|
||||
duplicate=not acceptance.created,
|
||||
),
|
||||
event_key=acceptance.record.event_key,
|
||||
created=acceptance.created,
|
||||
should_dispatch=acceptance.should_dispatch,
|
||||
)
|
||||
|
||||
def process_inbound_event(
|
||||
self,
|
||||
event_key: str,
|
||||
*,
|
||||
worker_id: str = "feishu-inbound",
|
||||
) -> FeishuInboundProcessResult:
|
||||
"""Claim and process one accepted event."""
|
||||
|
||||
return self.inbox.process(
|
||||
event_key,
|
||||
handler_factory=lambda db: FeishuEventService(
|
||||
db
|
||||
)._execute_inbound_event,
|
||||
worker_id=worker_id,
|
||||
)
|
||||
|
||||
def process_due_inbound_events(
|
||||
self,
|
||||
*,
|
||||
limit: int,
|
||||
worker_id: str = "feishu-inbound",
|
||||
) -> list[FeishuInboundProcessResult]:
|
||||
"""Recover due retries and expired leases from persistent state."""
|
||||
|
||||
return self.inbox.process_due(
|
||||
handler_factory=lambda db: FeishuEventService(
|
||||
db
|
||||
)._execute_inbound_event,
|
||||
limit=limit,
|
||||
worker_id=worker_id,
|
||||
)
|
||||
|
||||
def _execute_inbound_event(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
_source: str,
|
||||
auto_reply: bool,
|
||||
event_key: str,
|
||||
) -> dict[str, Any]:
|
||||
user_features_enabled = get_settings().feishu_user_features_enabled
|
||||
command = (
|
||||
self.commands.extract_event_command(payload)
|
||||
@@ -75,62 +215,37 @@ class FeishuEventService:
|
||||
if user_features_enabled and command
|
||||
else None
|
||||
)
|
||||
event_identity = _event_identity(payload, source)
|
||||
if event_identity and not self._register_event(event_identity):
|
||||
return {
|
||||
FeishuResponseKey.OK: True,
|
||||
FeishuResponseKey.HANDLED: False,
|
||||
FeishuResponseKey.DUPLICATE: True,
|
||||
}
|
||||
self.feishu.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=principal.user_code if principal else ActorValue.FEISHU,
|
||||
source=AuditSource.FEISHU,
|
||||
action=FEISHU_EVENT_ACTIONS[source_value],
|
||||
target_type=source_value,
|
||||
target_id=(
|
||||
event_identity.get(FeishuEventReceiptKey.EVENT_KEY)
|
||||
if event_identity
|
||||
else None
|
||||
),
|
||||
request_payload=_audit_event_metadata(
|
||||
payload,
|
||||
include_open_id=not user_features_enabled,
|
||||
include_identity_context=user_features_enabled,
|
||||
),
|
||||
response_payload={FeishuResponseKey.ACCEPTED: True},
|
||||
)
|
||||
)
|
||||
if command is None:
|
||||
command = self.commands.extract_event_command(payload)
|
||||
if not command:
|
||||
return {FeishuResponseKey.OK: True, FeishuResponseKey.HANDLED: False}
|
||||
result = self.commands.handle_text(
|
||||
command[FeishuCommandKey.TEXT],
|
||||
chat_id=command[FeishuCommandKey.CHAT_ID],
|
||||
actor=(
|
||||
principal.user_code
|
||||
if principal
|
||||
else (
|
||||
ActorValue.FEISHU
|
||||
if user_features_enabled
|
||||
else command[FeishuCommandKey.ACTOR]
|
||||
)
|
||||
),
|
||||
auto_reply=auto_reply,
|
||||
principal=principal,
|
||||
)
|
||||
self.commands.feishu.set_message_uuid(_reply_uuid(event_key))
|
||||
with bind_inbound_event(event_key):
|
||||
result = self.commands.handle_text(
|
||||
command[FeishuCommandKey.TEXT],
|
||||
chat_id=command[FeishuCommandKey.CHAT_ID],
|
||||
actor=(
|
||||
principal.user_code
|
||||
if principal
|
||||
else (
|
||||
ActorValue.FEISHU
|
||||
if user_features_enabled
|
||||
else command[FeishuCommandKey.ACTOR]
|
||||
)
|
||||
),
|
||||
auto_reply=auto_reply,
|
||||
principal=principal,
|
||||
)
|
||||
return {
|
||||
FeishuResponseKey.OK: True,
|
||||
FeishuResponseKey.HANDLED: True,
|
||||
FeishuResponseKey.RESULT: result,
|
||||
}
|
||||
|
||||
def _handle_app_ticket_event(
|
||||
def _validate_app_ticket_event(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
source: FeishuEventSource,
|
||||
) -> dict[str, Any]:
|
||||
) -> tuple[str, str]:
|
||||
settings = get_settings()
|
||||
configured_app_id = str(settings.feishu_app_id or "").strip()
|
||||
if not configured_app_id:
|
||||
@@ -150,37 +265,47 @@ class FeishuEventService:
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Feishu app ticket app_id does not match configured application",
|
||||
)
|
||||
return app_id, ticket
|
||||
|
||||
event_identity = _event_identity(payload, source)
|
||||
if event_identity is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Feishu app ticket event identity is required",
|
||||
)
|
||||
if not self._register_event(event_identity):
|
||||
return {
|
||||
FeishuResponseKey.OK: True,
|
||||
FeishuResponseKey.HANDLED: False,
|
||||
FeishuResponseKey.DUPLICATE: True,
|
||||
}
|
||||
|
||||
def _execute_app_ticket_event(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
app_id, ticket = self._validate_app_ticket_event(payload)
|
||||
FeishuAppTicketService(self.db).store_verified(app_id, ticket)
|
||||
self.feishu.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=ActorValue.FEISHU,
|
||||
source=AuditSource.FEISHU,
|
||||
action=FEISHU_EVENT_ACTIONS[source],
|
||||
target_type=source,
|
||||
target_id=str(event_identity[FeishuEventReceiptKey.EVENT_KEY]),
|
||||
request_payload=_audit_event_metadata(payload),
|
||||
response_payload={FeishuResponseKey.ACCEPTED: True},
|
||||
)
|
||||
)
|
||||
return {
|
||||
FeishuResponseKey.OK: True,
|
||||
FeishuResponseKey.HANDLED: True,
|
||||
}
|
||||
|
||||
def _audit_accepted_event(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
source: FeishuEventSource,
|
||||
acceptance: FeishuInboundAcceptance,
|
||||
) -> None:
|
||||
user_features_enabled = get_settings().feishu_user_features_enabled
|
||||
audit_actor = (
|
||||
_audit_identity_actor(payload)
|
||||
if user_features_enabled
|
||||
else None
|
||||
)
|
||||
self.feishu.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=audit_actor or ActorValue.FEISHU,
|
||||
source=AuditSource.FEISHU,
|
||||
action=FEISHU_EVENT_ACTIONS[source],
|
||||
target_type=source,
|
||||
target_id=acceptance.record.event_key,
|
||||
request_payload=_audit_event_metadata(
|
||||
payload,
|
||||
include_open_id=not user_features_enabled,
|
||||
include_identity_context=user_features_enabled,
|
||||
),
|
||||
response_payload={FeishuResponseKey.ACCEPTED: True},
|
||||
)
|
||||
)
|
||||
|
||||
def _resolve_principal(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
@@ -217,22 +342,6 @@ class FeishuEventService:
|
||||
mentions=mentions,
|
||||
)
|
||||
|
||||
def _register_event(self, event_identity: dict[str, str | None]) -> bool:
|
||||
receipt = FeishuEventReceipt(
|
||||
event_key=str(event_identity[FeishuEventReceiptKey.EVENT_KEY]),
|
||||
source=str(event_identity[FeishuEventReceiptKey.SOURCE]),
|
||||
event_id=event_identity.get(FeishuEventReceiptKey.EVENT_ID),
|
||||
message_id=event_identity.get(FeishuEventReceiptKey.MESSAGE_ID),
|
||||
)
|
||||
try:
|
||||
with self.db.begin_nested():
|
||||
self.db.add(receipt)
|
||||
self.db.flush()
|
||||
except IntegrityError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _audit_event_metadata(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
@@ -248,20 +357,35 @@ def _audit_event_metadata(
|
||||
sender_id = sender.get(FeishuPayloadKey.SENDER_ID) or {}
|
||||
metadata = {
|
||||
"schema": payload.get("schema"),
|
||||
FeishuPayloadKey.EVENT_ID: header.get(FeishuPayloadKey.EVENT_ID),
|
||||
FeishuPayloadKey.EVENT_ID: _identifier_digest(
|
||||
header.get(FeishuPayloadKey.EVENT_ID),
|
||||
"event-id",
|
||||
),
|
||||
FeishuPayloadKey.EVENT_TYPE: _event_type(payload),
|
||||
FeishuPayloadKey.MESSAGE_ID: message.get(FeishuPayloadKey.MESSAGE_ID),
|
||||
FeishuCommandKey.CHAT_ID: message.get(FeishuCommandKey.CHAT_ID),
|
||||
FeishuPayloadKey.MESSAGE_ID: _identifier_digest(
|
||||
message.get(FeishuPayloadKey.MESSAGE_ID),
|
||||
"message-id",
|
||||
),
|
||||
FeishuCommandKey.CHAT_ID: _identifier_digest(
|
||||
message.get(FeishuCommandKey.CHAT_ID),
|
||||
"chat-id",
|
||||
),
|
||||
FeishuPayloadKey.MESSAGE_TYPE: message.get(FeishuPayloadKey.MESSAGE_TYPE),
|
||||
}
|
||||
app_id, _ = _app_ticket_fields(payload)
|
||||
if app_id:
|
||||
metadata[FeishuPayloadKey.APP_ID] = app_id
|
||||
metadata[FeishuPayloadKey.APP_ID] = _identifier_digest(app_id, "app-id")
|
||||
if include_identity_context:
|
||||
metadata[FeishuPayloadKey.TENANT_KEY] = header.get(FeishuPayloadKey.TENANT_KEY)
|
||||
metadata[FeishuPayloadKey.TENANT_KEY] = _identifier_digest(
|
||||
header.get(FeishuPayloadKey.TENANT_KEY),
|
||||
"tenant-key",
|
||||
)
|
||||
metadata[FeishuCommandKey.CHAT_TYPE] = message.get(FeishuPayloadKey.CHAT_TYPE)
|
||||
if include_open_id:
|
||||
metadata[FeishuPayloadKey.OPEN_ID] = sender_id.get(FeishuPayloadKey.OPEN_ID)
|
||||
metadata[FeishuPayloadKey.OPEN_ID] = _identifier_digest(
|
||||
sender_id.get(FeishuPayloadKey.OPEN_ID),
|
||||
"open-id",
|
||||
)
|
||||
return metadata
|
||||
|
||||
|
||||
@@ -269,10 +393,22 @@ def _normalize_source(source: str | FeishuEventSource) -> FeishuEventSource:
|
||||
return FeishuEventSource(source)
|
||||
|
||||
|
||||
def _audit_identity_actor(payload: dict[str, Any]) -> str | None:
|
||||
header = payload.get(FeishuPayloadKey.HEADER) or {}
|
||||
event = payload.get(FeishuPayloadKey.EVENT) or {}
|
||||
sender = event.get(FeishuPayloadKey.SENDER) or {}
|
||||
sender_id = sender.get(FeishuPayloadKey.SENDER_ID) or {}
|
||||
tenant_key = str(header.get(FeishuPayloadKey.TENANT_KEY) or "").strip()
|
||||
open_id = str(sender_id.get(FeishuPayloadKey.OPEN_ID) or "").strip()
|
||||
if not tenant_key or not open_id:
|
||||
return None
|
||||
return feishu_audit_identity_hash(tenant_key, open_id)
|
||||
|
||||
|
||||
def _event_identity(
|
||||
payload: dict[str, Any],
|
||||
source: str | FeishuEventSource,
|
||||
) -> dict[str, str | None] | None:
|
||||
) -> dict[str, str | None]:
|
||||
source_value = _normalize_source(source)
|
||||
header = payload.get(FeishuPayloadKey.HEADER) or {}
|
||||
event = payload.get(FeishuPayloadKey.EVENT) or {}
|
||||
@@ -284,23 +420,92 @@ def _event_identity(
|
||||
or event.get(FeishuPayloadKey.UUID)
|
||||
)
|
||||
message_id = message.get(FeishuPayloadKey.MESSAGE_ID)
|
||||
stable_id = event_id or message_id
|
||||
if not stable_id:
|
||||
return None
|
||||
stable_id = event_id or message_id or _payload_fingerprint(payload)
|
||||
event_type = _event_type(payload)
|
||||
app_id, _ = _app_ticket_fields(payload)
|
||||
tenant_key = header.get(FeishuPayloadKey.TENANT_KEY) or app_id or "unknown-tenant"
|
||||
event_key = ":".join(
|
||||
raw_event_key = ":".join(
|
||||
str(part) for part in (tenant_key, event_type or FeishuPayloadKey.EVENT, stable_id)
|
||||
)
|
||||
event_key = _identifier_digest(raw_event_key, "event-key")
|
||||
return {
|
||||
FeishuEventReceiptKey.EVENT_KEY: event_key,
|
||||
FeishuEventReceiptKey.SOURCE: source_value,
|
||||
FeishuEventReceiptKey.EVENT_ID: str(event_id) if event_id else None,
|
||||
FeishuEventReceiptKey.MESSAGE_ID: str(message_id) if message_id else None,
|
||||
FeishuEventReceiptKey.EVENT_ID: _identifier_digest(event_id, "event-id"),
|
||||
FeishuEventReceiptKey.MESSAGE_ID: _identifier_digest(
|
||||
message_id,
|
||||
"message-id",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _payload_fingerprint(payload: dict[str, Any]) -> str:
|
||||
scrubbed = _scrub_transport_secrets(payload)
|
||||
serialized = json.dumps(
|
||||
scrubbed,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
return f"sha256-{sha256(serialized.encode('utf-8')).hexdigest()}"
|
||||
|
||||
|
||||
def _scrub_transport_secrets(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
str(key): _scrub_transport_secrets(item)
|
||||
for key, item in value.items()
|
||||
if str(key).casefold()
|
||||
not in {
|
||||
"access_token",
|
||||
"app_access_token",
|
||||
"app_secret",
|
||||
"app_ticket",
|
||||
"authorization",
|
||||
"encrypt",
|
||||
"refresh_token",
|
||||
"secret",
|
||||
"tenant_access_token",
|
||||
"token",
|
||||
}
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_scrub_transport_secrets(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _reply_uuid(event_key: str) -> str:
|
||||
digest = sha256(event_key.encode("utf-8")).hexdigest()[:32]
|
||||
return f"inbound-{digest}"
|
||||
|
||||
|
||||
def _identifier_digest(value: Any, domain: str) -> str | None:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
digest = sha256(
|
||||
f"company-ai-platform:feishu:{domain}:v1\0{text}".encode("utf-8")
|
||||
).hexdigest()
|
||||
return f"sha256-{digest}"
|
||||
|
||||
|
||||
def _inbound_response(
|
||||
record: Any,
|
||||
*,
|
||||
duplicate: bool,
|
||||
) -> dict[str, Any]:
|
||||
response = {
|
||||
FeishuResponseKey.OK: True,
|
||||
FeishuResponseKey.ACCEPTED: True,
|
||||
FeishuResponseKey.HANDLED: record.status == FeishuInboundStatus.SUCCEEDED,
|
||||
FeishuResponseKey.STATUS: record.status,
|
||||
}
|
||||
if duplicate:
|
||||
response[FeishuResponseKey.DUPLICATE] = True
|
||||
return response
|
||||
|
||||
|
||||
def _event_type(payload: dict[str, Any]) -> str:
|
||||
header = payload.get(FeishuPayloadKey.HEADER) or {}
|
||||
return str(
|
||||
|
||||
52
app/application/feishu/inbound.py
Normal file
52
app/application/feishu/inbound.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from socket import gethostname
|
||||
from typing import Any
|
||||
|
||||
from app.application.feishu.events import FeishuEventService
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.database import SessionLocal
|
||||
from app.modules.feishu.constants import FEISHU_INBOUND_BATCH_SIZE
|
||||
|
||||
|
||||
def process_feishu_inbound_event(
|
||||
event_key: str,
|
||||
*,
|
||||
actor: str = ActorValue.WORKER,
|
||||
) -> dict[str, Any]:
|
||||
"""Process one durable Feishu inbox row in an isolated session."""
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
outcome = FeishuEventService(db).process_inbound_event(
|
||||
event_key,
|
||||
worker_id=f"{actor}:{gethostname()}",
|
||||
)
|
||||
return _serialize_outcome(outcome)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def process_due_feishu_inbound_events(
|
||||
*,
|
||||
limit: int = FEISHU_INBOUND_BATCH_SIZE,
|
||||
actor: str = ActorValue.WORKER,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Recover due retries and expired Feishu inbox leases."""
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
outcomes = FeishuEventService(db).process_due_inbound_events(
|
||||
limit=limit,
|
||||
worker_id=f"{actor}:{gethostname()}",
|
||||
)
|
||||
return [_serialize_outcome(outcome) for outcome in outcomes]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _serialize_outcome(outcome: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"event_key": outcome.record.event_key,
|
||||
"status": outcome.record.status,
|
||||
"attempt_count": outcome.record.attempt_count,
|
||||
"handled": outcome.handler_result is not None,
|
||||
}
|
||||
@@ -16,7 +16,12 @@ from app.modules.feishu_users.constants import (
|
||||
FeishuUserStatus,
|
||||
parse_admin_identities,
|
||||
)
|
||||
from app.modules.feishu.services import (
|
||||
FeishuInboundService,
|
||||
current_inbound_event_key,
|
||||
)
|
||||
from app.modules.feishu_users.bootstrap import admin_bootstrap_identity_hash
|
||||
from app.modules.feishu_users.identifiers import feishu_audit_identity_hash
|
||||
from app.modules.feishu_users.models import (
|
||||
FeishuAdminBootstrapTombstone,
|
||||
FeishuUser,
|
||||
@@ -187,6 +192,10 @@ class FeishuPersonalDataService:
|
||||
user.open_id,
|
||||
user.union_id,
|
||||
user.user_id,
|
||||
feishu_audit_identity_hash(
|
||||
user.tenant_key,
|
||||
user.open_id,
|
||||
),
|
||||
)
|
||||
if value
|
||||
},
|
||||
@@ -230,6 +239,21 @@ class FeishuPersonalDataService:
|
||||
"subscriptions": subscriptions,
|
||||
}
|
||||
|
||||
def clear_pending_inbound_events(
|
||||
db: Session,
|
||||
_owner_id: int,
|
||||
_anonymous_id: str,
|
||||
) -> dict[str, int]:
|
||||
# The caller already holds the FeishuUser row. Inbound handlers
|
||||
# acquire that same identity fence before their own receipt row, so
|
||||
# erasure can safely fence related receipts in identity -> inbox order.
|
||||
cleared = FeishuInboundService(db).erase_identity_payloads(
|
||||
tenant_key=user.tenant_key,
|
||||
open_id=user.open_id,
|
||||
exclude_event_key=current_inbound_event_key(),
|
||||
)
|
||||
return {"inbound_events": cleared}
|
||||
|
||||
def finalize_identity(
|
||||
db: Session,
|
||||
_owner_id: int,
|
||||
@@ -281,7 +305,7 @@ class FeishuPersonalDataService:
|
||||
return self.erasure.confirm_and_erase(
|
||||
user.id,
|
||||
confirmation_code,
|
||||
before_hooks=(delete_subscriptions,),
|
||||
before_hooks=(delete_subscriptions, clear_pending_inbound_events),
|
||||
extra_hooks=(*self.extra_hooks, finalize_identity),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import date
|
||||
from datetime import UTC, date, datetime
|
||||
from socket import gethostname
|
||||
from typing import Any
|
||||
|
||||
@@ -40,6 +40,7 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
|
||||
enqueue_attendance_summary_push,
|
||||
enqueue_daily_brief_push,
|
||||
enqueue_event_dispatch,
|
||||
enqueue_feishu_inbound_cycle,
|
||||
enqueue_legacy_project_sync,
|
||||
enqueue_legacy_task_sync,
|
||||
enqueue_lifecycle_report,
|
||||
@@ -47,6 +48,7 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
|
||||
enqueue_project_weekly_push,
|
||||
enqueue_risk_progress_push,
|
||||
enqueue_subscription_cycle,
|
||||
enqueue_worker_heartbeat,
|
||||
enqueue_work_daily_push,
|
||||
enqueue_work_weekly_push,
|
||||
)
|
||||
@@ -186,6 +188,10 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
|
||||
)
|
||||
_set_state(app, "last_event_dispatch", dispatch)
|
||||
|
||||
def run_feishu_inbound_cycle() -> None:
|
||||
dispatch = enqueue_feishu_inbound_cycle(actor=ActorValue.SCHEDULER)
|
||||
_set_state(app, "last_feishu_inbound_cycle", dispatch)
|
||||
|
||||
def record_scheduler_heartbeat() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -202,6 +208,10 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
|
||||
dispatch = enqueue_subscription_cycle(actor=ActorValue.SCHEDULER)
|
||||
_set_state(app, "last_subscription_cycle", dispatch)
|
||||
|
||||
def run_worker_heartbeat() -> None:
|
||||
dispatch = enqueue_worker_heartbeat(actor=ActorValue.SCHEDULER)
|
||||
_set_state(app, "last_worker_heartbeat", dispatch)
|
||||
|
||||
def run_personalization_retention_cleanup() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -285,6 +295,7 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
|
||||
trigger="cron",
|
||||
minute=settings.event_dispatch_cron_minute,
|
||||
id="event_dispatch",
|
||||
next_run_time=datetime.now(UTC),
|
||||
replace_existing=True,
|
||||
)
|
||||
if settings.market_analysis_enabled:
|
||||
@@ -320,6 +331,23 @@ def create_scheduler(app: FastAPI | None = None) -> Any:
|
||||
trigger="interval",
|
||||
seconds=settings.heartbeat_interval_seconds,
|
||||
id="scheduler_heartbeat",
|
||||
next_run_time=datetime.now(UTC),
|
||||
replace_existing=True,
|
||||
)
|
||||
if settings.task_queue_enabled:
|
||||
scheduler.add_job(
|
||||
run_worker_heartbeat,
|
||||
trigger="interval",
|
||||
seconds=settings.heartbeat_interval_seconds,
|
||||
id="worker_heartbeat",
|
||||
next_run_time=datetime.now(UTC),
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
run_feishu_inbound_cycle,
|
||||
trigger="interval",
|
||||
minutes=1,
|
||||
id="feishu_inbound_event_cycle",
|
||||
replace_existing=True,
|
||||
)
|
||||
if settings.feishu_user_features_enabled:
|
||||
|
||||
Reference in New Issue
Block a user