feat: 添加飞书用户模块和订阅功能支持

- 新增feishu_users模块用于处理飞书用户身份验证和权限管理
- 新增subscriptions模块用于处理订阅相关功能
- 新增personalization模块用于个性化服务
- 在alembic迁移配置中注册新的模型模块
- 在API路由器中添加feishu_users和subscriptions路由
- 实现事件调度服务的改进,包括错误处理和状态更新优化
- 添加飞书命令处理的权限检查机制
- 实现飞书应用票据事件处理
- 改进审计日志记录功能
```
This commit is contained in:
2026-07-27 08:02:17 +08:00
parent db751f03b4
commit d7db84571d
148 changed files with 17110 additions and 765 deletions

View File

@@ -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