feat: 添加飞书集成和审计API密钥认证

- 在数据库配置中添加飞书模型导入
- 添加审计API密钥配置项和认证中间件
- 实现飞书事件重复处理防止机制
- 为审批路由添加API密钥认证
- 优化AI适配器错误处理并添加JSON解析异常捕获
- 更新测试用例以包含新的认证和事件处理逻辑
```
This commit is contained in:
2026-07-06 11:34:15 +08:00
parent 0dbe5c1c2d
commit 514b14d390
16 changed files with 353 additions and 16 deletions

View File

@@ -11,6 +11,12 @@ class FeishuMessageType(StrEnum):
class FeishuPayloadKey(StrEnum):
HEADER = "header"
EVENT = "event"
EVENT_ID = "event_id"
EVENT_TYPE = "event_type"
MESSAGE = "message"
MESSAGE_ID = "message_id"
RECEIVE_ID = "receive_id"
RECEIVE_ID_TYPE = "receive_id_type"
MESSAGE_TYPE = "msg_type"
@@ -38,3 +44,5 @@ 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_WEBHOOK_EVENT_ACTION = "webhook_event"
FEISHU_LONG_CONNECTION_EVENT_ACTION = "long_connection_event"

View File

@@ -1,14 +1,26 @@
from typing import Any
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import ActorValue
from app.modules.audit.constants import AuditSource
from app.modules.audit.schemas import AuditLogCreate
from app.modules.feishu.commands import FeishuCommandService
from app.modules.feishu.constants import FeishuCommandKey
from app.modules.feishu.constants import (
FEISHU_LONG_CONNECTION_EVENT_ACTION,
FEISHU_WEBHOOK_EVENT_ACTION,
FeishuCommandKey,
FeishuPayloadKey,
)
from app.modules.feishu.models import FeishuEventReceipt
from app.modules.feishu.service import FeishuService
FEISHU_EVENT_ACTIONS = {
"webhook": FEISHU_WEBHOOK_EVENT_ACTION,
"long_connection": FEISHU_LONG_CONNECTION_EVENT_ACTION,
}
class FeishuEventService:
"""Handle Feishu message events from webhook or long connection."""
@@ -25,11 +37,16 @@ class FeishuEventService:
auto_reply: bool = True,
) -> dict[str, Any]:
self.feishu.verify_event(payload)
event_identity = _event_identity(payload, source)
if event_identity and not self._register_event(event_identity):
return {"ok": True, "handled": False, "duplicate": True}
self.feishu.audit.log(
AuditLogCreate(
actor=ActorValue.FEISHU,
source=AuditSource.FEISHU,
action=f"{source}_event",
action=FEISHU_EVENT_ACTIONS.get(source, FEISHU_WEBHOOK_EVENT_ACTION),
target_type=source,
target_id=event_identity.get("event_key") if event_identity else None,
request_payload=payload,
response_payload={"accepted": True},
)
@@ -44,3 +61,40 @@ class FeishuEventService:
auto_reply=auto_reply,
)
return {"ok": True, "handled": True, "result": result}
def _register_event(self, event_identity: dict[str, str | None]) -> bool:
receipt = FeishuEventReceipt(
event_key=str(event_identity["event_key"]),
source=str(event_identity["source"]),
event_id=event_identity.get("event_id"),
message_id=event_identity.get("message_id"),
)
self.db.add(receipt)
try:
self.db.flush()
except IntegrityError:
self.db.rollback()
return False
return True
def _event_identity(payload: dict[str, Any], source: str) -> dict[str, str | None] | None:
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)
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_key = ":".join(
str(part)
for part in (source, event_type or FeishuPayloadKey.EVENT, stable_id)
)
return {
"event_key": event_key,
"source": source,
"event_id": str(event_id) if event_id else None,
"message_id": str(message_id) if message_id else None,
}

View File

@@ -0,0 +1,18 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db_base import Base
from app.core.time import utc_now
class FeishuEventReceipt(Base):
__tablename__ = "feishu_event_receipts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
event_key: Mapped[str] = mapped_column(String(256), unique=True, index=True)
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)
received_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)