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

@@ -169,7 +169,7 @@ class HermesAdapter(AIAdapter):
response = client.post(url, json=payload, headers=headers)
if response.status_code >= 400:
raise HTTPException(status_code=502, detail={AIErrorKey.HERMES: response.text})
data = response.json()
data = _chat_completion_payload(response, AIErrorKey.HERMES)
try:
answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][
AIHttpPayloadKey.CONTENT
@@ -349,7 +349,7 @@ class DirectLLMAdapter(AIAdapter):
response = client.post(url, json=payload, headers=headers)
if response.status_code >= 400:
raise HTTPException(status_code=502, detail={AIErrorKey.DIRECT_LLM: response.text})
data = response.json()
data = _chat_completion_payload(response, AIErrorKey.DIRECT_LLM)
try:
answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][
AIHttpPayloadKey.CONTENT
@@ -399,6 +399,19 @@ def _response_payload(response: httpx.Response) -> dict[str, Any]:
return {AIResponseKey.STATUS_CODE: response.status_code, AIResponseKey.DATA: data}
def _chat_completion_payload(response: httpx.Response, error_key: AIErrorKey) -> dict[str, Any]:
try:
return response.json()
except ValueError as exc:
raise HTTPException(
status_code=502,
detail={
error_key: UNEXPECTED_HERMES_RESPONSE,
AIResponseKey.RAW: {AIResponseKey.TEXT: response.text},
},
) from exc
def _chat_messages(prompt: str, context: dict[str, Any] | None = None) -> list[dict[str, str]]:
return [
{

View File

@@ -23,12 +23,19 @@ def list_approvals(
status: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_approval_api_key),
):
_ = principal
return ApprovalService(db).list(status_filter=status, limit=limit)
@router.get("/{ticket_id}", response_model=ApprovalRead)
def get_approval(ticket_id: str, db: Session = Depends(get_db)):
def get_approval(
ticket_id: str,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_approval_api_key),
):
_ = principal
return ApprovalService(db).get_by_ticket(ticket_id)

View File

@@ -5,7 +5,7 @@ from decimal import Decimal
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.orm import Session
from app.core.pagination import bounded_limit
@@ -135,11 +135,29 @@ class ApprovalService:
status_code=status.HTTP_403_FORBIDDEN,
detail=ApprovalErrorDetail.PAYLOAD_MISMATCH,
)
ticket.status = ApprovalStatus.USED
ticket.used_by = actor
ticket.used_at = utc_now()
used_at = utc_now()
values: dict[str, Any] = {
"status": ApprovalStatus.USED,
"used_by": actor,
"used_at": used_at,
}
if record_id is not None and not ticket.record_id:
ticket.record_id = str(record_id)
values["record_id"] = str(record_id)
result = self.db.execute(
update(ApprovalRequest)
.where(
ApprovalRequest.ticket_id == ticket_id,
ApprovalRequest.status == ApprovalStatus.APPROVED,
)
.values(**values)
)
if result.rowcount != 1:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ApprovalErrorDetail.NOT_APPROVED,
)
for key, value in values.items():
setattr(ticket, key, value)
return ticket
def is_approved_for(

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import require_api_key
from app.core.security import ApiPrincipal, require_api_key, require_audit_api_key
from app.modules.audit.schemas import AuditLogRead
from app.modules.audit.service import AuditService
@@ -13,5 +13,7 @@ router = APIRouter(dependencies=[Depends(require_api_key)])
def list_audit_logs(
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_audit_api_key),
) -> list:
_ = principal
return AuditService(db).list_logs(limit=limit)

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)