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

@@ -8,6 +8,7 @@ from app.core.db_base import Base
from app.modules.approvals import models as approval_models
from app.modules.audit import models as audit_models
from app.modules.business import models as business_models
from app.modules.feishu import models as feishu_models
config = context.config
@@ -18,7 +19,7 @@ target_metadata = Base.metadata
settings = get_settings()
# Keep imports referenced so SQLAlchemy model classes register with Base.metadata.
_REGISTERED_MODEL_MODULES = (approval_models, audit_models, business_models)
_REGISTERED_MODEL_MODULES = (approval_models, audit_models, business_models, feishu_models)
def run_migrations_offline() -> None:

View File

@@ -11,6 +11,7 @@ from app.core.db_base import Base
from app.modules.approvals import models as approval_models
from app.modules.audit import models as audit_models
from app.modules.business import models as business_models
from app.modules.feishu import models as feishu_models
revision = "202607060001"
down_revision = None
@@ -18,7 +19,7 @@ branch_labels = None
depends_on = None
# Keep imports referenced so SQLAlchemy model classes register with Base.metadata.
_REGISTERED_MODEL_MODULES = (approval_models, audit_models, business_models)
_REGISTERED_MODEL_MODULES = (approval_models, audit_models, business_models, feishu_models)
def upgrade() -> None:

View File

@@ -0,0 +1,91 @@
"""Add Feishu event receipts.
Revision ID: 202607060003
Revises: 202607060002
Create Date: 2026-07-06
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
revision = "202607060003"
down_revision = "202607060002"
branch_labels = None
depends_on = None
FEISHU_EVENT_RECEIPTS_TABLE = "feishu_event_receipts"
def _table_exists() -> bool:
inspector = inspect(op.get_bind())
return FEISHU_EVENT_RECEIPTS_TABLE in inspector.get_table_names()
def upgrade() -> None:
if _table_exists():
return
op.create_table(
FEISHU_EVENT_RECEIPTS_TABLE,
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("event_key", sa.String(length=256), nullable=False),
sa.Column("source", sa.String(length=64), nullable=False),
sa.Column("event_id", sa.String(length=128), nullable=True),
sa.Column("message_id", sa.String(length=128), nullable=True),
sa.Column("received_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_feishu_event_receipts_event_id"),
FEISHU_EVENT_RECEIPTS_TABLE,
["event_id"],
unique=False,
)
op.create_index(
op.f("ix_feishu_event_receipts_event_key"),
FEISHU_EVENT_RECEIPTS_TABLE,
["event_key"],
unique=True,
)
op.create_index(
op.f("ix_feishu_event_receipts_message_id"),
FEISHU_EVENT_RECEIPTS_TABLE,
["message_id"],
unique=False,
)
op.create_index(
op.f("ix_feishu_event_receipts_received_at"),
FEISHU_EVENT_RECEIPTS_TABLE,
["received_at"],
unique=False,
)
op.create_index(
op.f("ix_feishu_event_receipts_source"),
FEISHU_EVENT_RECEIPTS_TABLE,
["source"],
unique=False,
)
def downgrade() -> None:
if not _table_exists():
return
op.drop_index(op.f("ix_feishu_event_receipts_source"), table_name=FEISHU_EVENT_RECEIPTS_TABLE)
op.drop_index(
op.f("ix_feishu_event_receipts_received_at"),
table_name=FEISHU_EVENT_RECEIPTS_TABLE,
)
op.drop_index(
op.f("ix_feishu_event_receipts_message_id"),
table_name=FEISHU_EVENT_RECEIPTS_TABLE,
)
op.drop_index(
op.f("ix_feishu_event_receipts_event_key"),
table_name=FEISHU_EVENT_RECEIPTS_TABLE,
)
op.drop_index(
op.f("ix_feishu_event_receipts_event_id"),
table_name=FEISHU_EVENT_RECEIPTS_TABLE,
)
op.drop_table(FEISHU_EVENT_RECEIPTS_TABLE)

View File

@@ -21,6 +21,8 @@ class Settings(BaseSettings):
api_prefix: str = "/api/v1"
api_key: str | None = None
api_actor: str = ActorValue.API
audit_api_key: str | None = None
audit_api_actor: str = ActorValue.AUDITOR
approval_api_key: str | None = None
approval_api_actor: str = ActorValue.APPROVER
cors_origins: list[str] = Field(default_factory=lambda: ["*"])

View File

@@ -3,6 +3,7 @@ from enum import StrEnum
class ActorValue(StrEnum):
API = "api"
AUDITOR = "auditor"
APPROVER = "approver"
SYSTEM = "system"
SCHEDULER = "scheduler"
@@ -12,6 +13,7 @@ class ActorValue(StrEnum):
class HttpHeader(StrEnum):
AUTHORIZATION = "Authorization"
X_API_KEY = "X-API-Key"
X_AUDIT_API_KEY = "X-Audit-API-Key"
X_APPROVAL_API_KEY = "X-Approval-API-Key"

View File

@@ -53,3 +53,25 @@ def require_approval_api_key(
detail="Invalid approval API key",
)
return ApiPrincipal(actor=settings.approval_api_actor)
def require_audit_api_key(
x_audit_api_key: str | None = Header(
default=None,
alias=HttpHeader.X_AUDIT_API_KEY,
),
) -> ApiPrincipal:
"""Validate the audit API key and return the audit principal."""
settings = get_settings()
if not settings.audit_api_key:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="AUDIT_API_KEY is required",
)
if not x_audit_api_key or not compare_digest(x_audit_api_key, settings.audit_api_key):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid audit API key",
)
return ApiPrincipal(actor=settings.audit_api_actor)

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)

View File

@@ -16,10 +16,12 @@ from app.modules.business.models import (
WorkReport,
WorkTask,
)
from app.modules.feishu.models import FeishuEventReceipt
_MODELS = [
ApprovalRequest,
AuditLog,
FeishuEventReceipt,
Project,
WorkTask,
Procurement,

View File

@@ -28,6 +28,15 @@ class DummyResponse:
return self._data
class NonJsonResponse(DummyResponse):
def __init__(self, text: str, status_code: int = 200):
super().__init__({}, status_code=status_code)
self.text = text
def json(self) -> dict[str, Any]:
raise ValueError("invalid json")
def chat_response(content: str) -> DummyResponse:
return DummyResponse(
{
@@ -265,3 +274,53 @@ def test_direct_llm_adapter_wraps_unexpected_chat_response(monkeypatch) -> None:
assert exc_info.value.status_code == 502
assert exc_info.value.detail[AIErrorKey.DIRECT_LLM] == "Unexpected chat completion response"
assert exc_info.value.detail[AIResponseKey.RAW] == {AIHttpPayloadKey.CHOICES: []}
def test_hermes_adapter_wraps_non_json_chat_response(monkeypatch) -> None:
class BadJsonClient(DummyClient):
def post(
self,
url: str,
json: dict[str, Any],
headers: dict[str, str],
) -> NonJsonResponse:
self.calls.append({"method": "POST", "url": url, "json": json, "headers": headers})
return NonJsonResponse("upstream html")
DummyClient.calls = []
monkeypatch.setattr(adapters.httpx, "Client", BadJsonClient)
settings = Settings(hermes_base_url="http://hermes.local/v1", hermes_api_key="hermes-key")
with pytest.raises(HTTPException) as exc_info:
adapters.HermesAdapter(settings).ask("summarize")
assert exc_info.value.status_code == 502
assert exc_info.value.detail[AIErrorKey.HERMES] == "Unexpected chat completion response"
assert exc_info.value.detail[AIResponseKey.RAW][AIResponseKey.TEXT] == "upstream html"
def test_direct_llm_adapter_wraps_non_json_chat_response(monkeypatch) -> None:
class BadJsonClient(DummyClient):
def post(
self,
url: str,
json: dict[str, Any],
headers: dict[str, str],
) -> NonJsonResponse:
self.calls.append({"method": "POST", "url": url, "json": json, "headers": headers})
return NonJsonResponse("upstream html")
DummyClient.calls = []
monkeypatch.setattr(adapters.httpx, "Client", BadJsonClient)
settings = Settings(
direct_llm_base_url="http://llm.local/v1",
direct_llm_api_key="direct-key",
direct_llm_model="company-model",
)
with pytest.raises(HTTPException) as exc_info:
adapters.DirectLLMAdapter(settings).ask("summarize")
assert exc_info.value.status_code == 502
assert exc_info.value.detail[AIErrorKey.DIRECT_LLM] == "Unexpected chat completion response"
assert exc_info.value.detail[AIResponseKey.RAW][AIResponseKey.TEXT] == "upstream html"

View File

@@ -15,6 +15,8 @@ _db.close()
os.environ["DATABASE_URL"] = "sqlite:///" + _db.name.replace("\\", "/")
os.environ["API_KEY"] = "test-key"
os.environ["AUDIT_API_KEY"] = "audit-key"
os.environ["AUDIT_API_ACTOR"] = "audit-manager"
os.environ["APPROVAL_API_KEY"] = "approval-key"
os.environ["APPROVAL_API_ACTOR"] = "approval-manager"
os.environ["FEISHU_APP_ID"] = ""
@@ -31,7 +33,7 @@ from fastapi.testclient import TestClient
from app.core.config import Settings, get_settings
from app.core.database import Base, engine
from app.core.pagination import bounded_limit, bounded_offset
from app.core.security import require_api_key, require_approval_api_key
from app.core.security import require_api_key, require_approval_api_key, require_audit_api_key
from app.main import _allow_cors_credentials, app
from app.modules.audit.constants import AUDIT_REDACTED_VALUE
from app.modules.legacy_mysql.service import LegacyMySQLService
@@ -48,6 +50,7 @@ from app.modules.reports.constants import (
Base.metadata.create_all(bind=engine)
client = TestClient(app)
headers = {"X-API-Key": "test-key"}
audit_headers = {"X-API-Key": "test-key", "X-Audit-API-Key": "audit-key"}
approval_headers = {"X-API-Key": "test-key", "X-Approval-API-Key": "approval-key"}
@@ -93,11 +96,16 @@ def test_project_report_and_feishu_command_preview() -> None:
def test_feishu_webhook_routes_message_event() -> None:
payload = {
"schema": "2.0",
"header": {"event_type": "im.message.receive_v1", "token": "test-feishu-token"},
"header": {
"event_id": "evt-smoke-risk-001",
"event_type": "im.message.receive_v1",
"token": "test-feishu-token",
},
"event": {
"sender": {"sender_id": {"open_id": "ou_test"}},
"message": {
"chat_id": "oc_test",
"message_id": "om_smoke_risk_001",
"message_type": "text",
"content": json.dumps({"text": "risk"}),
},
@@ -109,7 +117,14 @@ def test_feishu_webhook_routes_message_event() -> None:
assert data["handled"] is True
assert data["result"]["command"] == "risk_summary"
logs_response = client.get("/api/v1/audit/logs", headers=headers)
duplicate_response = client.post("/api/v1/integrations/feishu/webhook", json=payload)
assert duplicate_response.status_code == 200
assert duplicate_response.json()["duplicate"] is True
blocked_logs_response = client.get("/api/v1/audit/logs", headers=headers)
assert blocked_logs_response.status_code == 401
logs_response = client.get("/api/v1/audit/logs", headers=audit_headers)
assert logs_response.status_code == 200
audit_payload = json.dumps(logs_response.json(), ensure_ascii=False)
assert "test-feishu-token" not in audit_payload
@@ -138,8 +153,15 @@ def test_api_key_and_feishu_webhook_fail_closed(monkeypatch) -> None:
with pytest.raises(HTTPException) as approval_exc_info:
require_approval_api_key("approval-key")
assert approval_exc_info.value.status_code == 503
monkeypatch.setenv("AUDIT_API_KEY", "")
get_settings.cache_clear()
with pytest.raises(HTTPException) as audit_exc_info:
require_audit_api_key("audit-key")
assert audit_exc_info.value.status_code == 503
finally:
monkeypatch.setenv("API_KEY", "test-key")
monkeypatch.setenv("AUDIT_API_KEY", "audit-key")
monkeypatch.setenv("APPROVAL_API_KEY", "approval-key")
get_settings.cache_clear()
@@ -199,6 +221,21 @@ def test_approval_gate_for_high_risk_update() -> None:
assert create_approval_response.json()["applicant"] == "api"
create_ticket_id = create_approval_response.json()["ticket_id"]
approval_list_without_approval_key_response = client.get("/api/v1/approvals", headers=headers)
assert approval_list_without_approval_key_response.status_code == 401
approval_detail_without_approval_key_response = client.get(
f"/api/v1/approvals/{create_ticket_id}",
headers=headers,
)
assert approval_detail_without_approval_key_response.status_code == 401
approval_detail_response = client.get(
f"/api/v1/approvals/{create_ticket_id}",
headers=approval_headers,
)
assert approval_detail_response.status_code == 200
approve_create_response = client.post(
f"/api/v1/approvals/{create_ticket_id}/approve",
headers=approval_headers,