```
feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
This commit is contained in:
@@ -1,12 +1,20 @@
|
||||
from dataclasses import replace
|
||||
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
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import ActorValue
|
||||
from app.modules.audit.constants import AuditAction, AuditSource
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.feishu.app_tickets import (
|
||||
APP_TICKET_EVENT_TYPE,
|
||||
APP_TICKET_PAYLOAD_KEY,
|
||||
FeishuAppTicketService,
|
||||
)
|
||||
from app.modules.feishu.constants import (
|
||||
FeishuCommandKey,
|
||||
FeishuEventReceiptKey,
|
||||
@@ -16,6 +24,8 @@ from app.modules.feishu.constants import (
|
||||
)
|
||||
from app.modules.feishu.models import FeishuEventReceipt
|
||||
from app.modules.feishu.service import FeishuService
|
||||
from app.modules.feishu_users.principal import FeishuMention, FeishuPrincipal
|
||||
from app.modules.feishu_users.services import FeishuIdentityService
|
||||
|
||||
FEISHU_EVENT_ACTIONS = {
|
||||
FeishuEventSource.WEBHOOK: AuditAction.FEISHU_WEBHOOK_EVENT,
|
||||
@@ -38,10 +48,33 @@ class FeishuEventService:
|
||||
auto_reply: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
self.feishu.verify_event(payload)
|
||||
return self._handle_verified_event(payload, source, auto_reply)
|
||||
|
||||
def _handle_verified_event(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
source: str | FeishuEventSource,
|
||||
auto_reply: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle an event after an HTTP verifier or the Feishu SDK accepted it."""
|
||||
|
||||
challenge = payload.get(FeishuPayloadKey.CHALLENGE)
|
||||
if challenge:
|
||||
return {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)
|
||||
user_features_enabled = get_settings().feishu_user_features_enabled
|
||||
command = (
|
||||
self.commands.extract_event_command(payload)
|
||||
if user_features_enabled
|
||||
else None
|
||||
)
|
||||
principal = (
|
||||
self._resolve_principal(payload, command)
|
||||
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 {
|
||||
@@ -51,7 +84,7 @@ class FeishuEventService:
|
||||
}
|
||||
self.feishu.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=ActorValue.FEISHU,
|
||||
actor=principal.user_code if principal else ActorValue.FEISHU,
|
||||
source=AuditSource.FEISHU,
|
||||
action=FEISHU_EVENT_ACTIONS[source_value],
|
||||
target_type=source_value,
|
||||
@@ -60,18 +93,32 @@ class FeishuEventService:
|
||||
if event_identity
|
||||
else None
|
||||
),
|
||||
request_payload=_audit_event_metadata(payload),
|
||||
request_payload=_audit_event_metadata(
|
||||
payload,
|
||||
include_open_id=not user_features_enabled,
|
||||
include_identity_context=user_features_enabled,
|
||||
),
|
||||
response_payload={FeishuResponseKey.ACCEPTED: True},
|
||||
)
|
||||
)
|
||||
command = self.commands.extract_event_command(payload)
|
||||
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=command[FeishuCommandKey.ACTOR],
|
||||
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,
|
||||
@@ -79,6 +126,97 @@ class FeishuEventService:
|
||||
FeishuResponseKey.RESULT: result,
|
||||
}
|
||||
|
||||
def _handle_app_ticket_event(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
source: FeishuEventSource,
|
||||
) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
configured_app_id = str(settings.feishu_app_id or "").strip()
|
||||
if not configured_app_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="FEISHU_APP_ID is required for app ticket events",
|
||||
)
|
||||
|
||||
app_id, ticket = _app_ticket_fields(payload)
|
||||
if not app_id or not ticket:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid Feishu app ticket event",
|
||||
)
|
||||
if app_id != configured_app_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Feishu app ticket app_id does not match configured application",
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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 _resolve_principal(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
command: dict[str, Any],
|
||||
) -> FeishuPrincipal | 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
|
||||
principal = FeishuIdentityService(self.db).resolve_or_register(
|
||||
tenant_key=tenant_key,
|
||||
open_id=open_id,
|
||||
union_id=sender_id.get(FeishuPayloadKey.UNION_ID),
|
||||
user_id=sender_id.get(FeishuPayloadKey.USER_ID),
|
||||
)
|
||||
mentions_value = command.get(FeishuCommandKey.MENTIONS)
|
||||
mentions = (
|
||||
tuple(
|
||||
mention
|
||||
for mention in mentions_value
|
||||
if isinstance(mention, FeishuMention)
|
||||
)
|
||||
if isinstance(mentions_value, (list, tuple))
|
||||
else ()
|
||||
)
|
||||
return replace(
|
||||
principal,
|
||||
chat_id=command.get(FeishuCommandKey.CHAT_ID),
|
||||
chat_type=command.get(FeishuCommandKey.CHAT_TYPE),
|
||||
mentions=mentions,
|
||||
)
|
||||
|
||||
def _register_event(self, event_identity: dict[str, str | None]) -> bool:
|
||||
receipt = FeishuEventReceipt(
|
||||
event_key=str(event_identity[FeishuEventReceiptKey.EVENT_KEY]),
|
||||
@@ -86,16 +224,21 @@ class FeishuEventService:
|
||||
event_id=event_identity.get(FeishuEventReceiptKey.EVENT_ID),
|
||||
message_id=event_identity.get(FeishuEventReceiptKey.MESSAGE_ID),
|
||||
)
|
||||
self.db.add(receipt)
|
||||
try:
|
||||
self.db.flush()
|
||||
with self.db.begin_nested():
|
||||
self.db.add(receipt)
|
||||
self.db.flush()
|
||||
except IntegrityError:
|
||||
self.db.rollback()
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _audit_event_metadata(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
def _audit_event_metadata(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
include_open_id: bool = True,
|
||||
include_identity_context: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Keep webhook audit evidence without storing message content or tokens."""
|
||||
|
||||
header = payload.get(FeishuPayloadKey.HEADER) or {}
|
||||
@@ -103,15 +246,23 @@ def _audit_event_metadata(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
message = event.get(FeishuPayloadKey.MESSAGE) or {}
|
||||
sender = event.get(FeishuPayloadKey.SENDER) or {}
|
||||
sender_id = sender.get(FeishuPayloadKey.SENDER_ID) or {}
|
||||
return {
|
||||
metadata = {
|
||||
"schema": payload.get("schema"),
|
||||
FeishuPayloadKey.EVENT_ID: header.get(FeishuPayloadKey.EVENT_ID),
|
||||
FeishuPayloadKey.EVENT_TYPE: header.get(FeishuPayloadKey.EVENT_TYPE),
|
||||
FeishuPayloadKey.EVENT_TYPE: _event_type(payload),
|
||||
FeishuPayloadKey.MESSAGE_ID: message.get(FeishuPayloadKey.MESSAGE_ID),
|
||||
FeishuCommandKey.CHAT_ID: message.get(FeishuCommandKey.CHAT_ID),
|
||||
FeishuPayloadKey.MESSAGE_TYPE: message.get(FeishuPayloadKey.MESSAGE_TYPE),
|
||||
FeishuPayloadKey.OPEN_ID: sender_id.get(FeishuPayloadKey.OPEN_ID),
|
||||
}
|
||||
app_id, _ = _app_ticket_fields(payload)
|
||||
if app_id:
|
||||
metadata[FeishuPayloadKey.APP_ID] = app_id
|
||||
if include_identity_context:
|
||||
metadata[FeishuPayloadKey.TENANT_KEY] = header.get(FeishuPayloadKey.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)
|
||||
return metadata
|
||||
|
||||
|
||||
def _normalize_source(source: str | FeishuEventSource) -> FeishuEventSource:
|
||||
@@ -126,15 +277,21 @@ def _event_identity(
|
||||
header = payload.get(FeishuPayloadKey.HEADER) or {}
|
||||
event = payload.get(FeishuPayloadKey.EVENT) or {}
|
||||
message = event.get(FeishuPayloadKey.MESSAGE) or {}
|
||||
event_id = header.get(FeishuPayloadKey.EVENT_ID)
|
||||
event_id = (
|
||||
header.get(FeishuPayloadKey.EVENT_ID)
|
||||
or payload.get(FeishuPayloadKey.EVENT_ID)
|
||||
or payload.get(FeishuPayloadKey.UUID)
|
||||
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
|
||||
event_type = header.get(FeishuPayloadKey.EVENT_TYPE)
|
||||
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(
|
||||
str(part)
|
||||
for part in (source_value, event_type or FeishuPayloadKey.EVENT, stable_id)
|
||||
str(part) for part in (tenant_key, event_type or FeishuPayloadKey.EVENT, stable_id)
|
||||
)
|
||||
return {
|
||||
FeishuEventReceiptKey.EVENT_KEY: event_key,
|
||||
@@ -142,3 +299,41 @@ def _event_identity(
|
||||
FeishuEventReceiptKey.EVENT_ID: str(event_id) if event_id else None,
|
||||
FeishuEventReceiptKey.MESSAGE_ID: str(message_id) if message_id else None,
|
||||
}
|
||||
|
||||
|
||||
def _event_type(payload: dict[str, Any]) -> str:
|
||||
header = payload.get(FeishuPayloadKey.HEADER) or {}
|
||||
return str(
|
||||
header.get(FeishuPayloadKey.EVENT_TYPE)
|
||||
or payload.get(FeishuPayloadKey.EVENT_TYPE)
|
||||
or payload.get("type")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
|
||||
def _app_ticket_fields(payload: dict[str, Any]) -> tuple[str, str]:
|
||||
header = payload.get(FeishuPayloadKey.HEADER)
|
||||
event = payload.get(FeishuPayloadKey.EVENT)
|
||||
data = payload.get(FeishuPayloadKey.DATA)
|
||||
candidates = [
|
||||
value
|
||||
for value in (event, data, header, payload)
|
||||
if isinstance(value, dict)
|
||||
]
|
||||
app_id = next(
|
||||
(
|
||||
str(candidate.get(FeishuPayloadKey.APP_ID) or "").strip()
|
||||
for candidate in candidates
|
||||
if candidate.get(FeishuPayloadKey.APP_ID)
|
||||
),
|
||||
"",
|
||||
)
|
||||
ticket = next(
|
||||
(
|
||||
str(candidate.get(APP_TICKET_PAYLOAD_KEY) or "").strip()
|
||||
for candidate in candidates
|
||||
if candidate.get(APP_TICKET_PAYLOAD_KEY)
|
||||
),
|
||||
"",
|
||||
)
|
||||
return app_id, ticket
|
||||
|
||||
Reference in New Issue
Block a user