```
feat: 添加飞书集成和审计API密钥认证 - 在数据库配置中添加飞书模型导入 - 添加审计API密钥配置项和认证中间件 - 实现飞书事件重复处理防止机制 - 为审批路由添加API密钥认证 - 优化AI适配器错误处理并添加JSON解析异常捕获 - 更新测试用例以包含新的认证和事件处理逻辑 ```
This commit is contained in:
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
18
app/modules/feishu/models.py
Normal file
18
app/modules/feishu/models.py
Normal 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)
|
||||
Reference in New Issue
Block a user