```
feat: 添加飞书集成和审计API密钥认证 - 在数据库配置中添加飞书模型导入 - 添加审计API密钥配置项和认证中间件 - 实现飞书事件重复处理防止机制 - 为审批路由添加API密钥认证 - 优化AI适配器错误处理并添加JSON解析异常捕获 - 更新测试用例以包含新的认证和事件处理逻辑 ```
This commit is contained in:
@@ -8,6 +8,7 @@ from app.core.db_base import Base
|
|||||||
from app.modules.approvals import models as approval_models
|
from app.modules.approvals import models as approval_models
|
||||||
from app.modules.audit import models as audit_models
|
from app.modules.audit import models as audit_models
|
||||||
from app.modules.business import models as business_models
|
from app.modules.business import models as business_models
|
||||||
|
from app.modules.feishu import models as feishu_models
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ target_metadata = Base.metadata
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
# Keep imports referenced so SQLAlchemy model classes register with Base.metadata.
|
# 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:
|
def run_migrations_offline() -> None:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.core.db_base import Base
|
|||||||
from app.modules.approvals import models as approval_models
|
from app.modules.approvals import models as approval_models
|
||||||
from app.modules.audit import models as audit_models
|
from app.modules.audit import models as audit_models
|
||||||
from app.modules.business import models as business_models
|
from app.modules.business import models as business_models
|
||||||
|
from app.modules.feishu import models as feishu_models
|
||||||
|
|
||||||
revision = "202607060001"
|
revision = "202607060001"
|
||||||
down_revision = None
|
down_revision = None
|
||||||
@@ -18,7 +19,7 @@ branch_labels = None
|
|||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
# Keep imports referenced so SQLAlchemy model classes register with Base.metadata.
|
# 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:
|
def upgrade() -> None:
|
||||||
|
|||||||
91
alembic/versions/202607060003_feishu_event_receipts.py
Normal file
91
alembic/versions/202607060003_feishu_event_receipts.py
Normal 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)
|
||||||
@@ -21,6 +21,8 @@ class Settings(BaseSettings):
|
|||||||
api_prefix: str = "/api/v1"
|
api_prefix: str = "/api/v1"
|
||||||
api_key: str | None = None
|
api_key: str | None = None
|
||||||
api_actor: str = ActorValue.API
|
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_key: str | None = None
|
||||||
approval_api_actor: str = ActorValue.APPROVER
|
approval_api_actor: str = ActorValue.APPROVER
|
||||||
cors_origins: list[str] = Field(default_factory=lambda: ["*"])
|
cors_origins: list[str] = Field(default_factory=lambda: ["*"])
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from enum import StrEnum
|
|||||||
|
|
||||||
class ActorValue(StrEnum):
|
class ActorValue(StrEnum):
|
||||||
API = "api"
|
API = "api"
|
||||||
|
AUDITOR = "auditor"
|
||||||
APPROVER = "approver"
|
APPROVER = "approver"
|
||||||
SYSTEM = "system"
|
SYSTEM = "system"
|
||||||
SCHEDULER = "scheduler"
|
SCHEDULER = "scheduler"
|
||||||
@@ -12,6 +13,7 @@ class ActorValue(StrEnum):
|
|||||||
class HttpHeader(StrEnum):
|
class HttpHeader(StrEnum):
|
||||||
AUTHORIZATION = "Authorization"
|
AUTHORIZATION = "Authorization"
|
||||||
X_API_KEY = "X-API-Key"
|
X_API_KEY = "X-API-Key"
|
||||||
|
X_AUDIT_API_KEY = "X-Audit-API-Key"
|
||||||
X_APPROVAL_API_KEY = "X-Approval-API-Key"
|
X_APPROVAL_API_KEY = "X-Approval-API-Key"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -53,3 +53,25 @@ def require_approval_api_key(
|
|||||||
detail="Invalid approval API key",
|
detail="Invalid approval API key",
|
||||||
)
|
)
|
||||||
return ApiPrincipal(actor=settings.approval_api_actor)
|
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)
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ class HermesAdapter(AIAdapter):
|
|||||||
response = client.post(url, json=payload, headers=headers)
|
response = client.post(url, json=payload, headers=headers)
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
raise HTTPException(status_code=502, detail={AIErrorKey.HERMES: response.text})
|
raise HTTPException(status_code=502, detail={AIErrorKey.HERMES: response.text})
|
||||||
data = response.json()
|
data = _chat_completion_payload(response, AIErrorKey.HERMES)
|
||||||
try:
|
try:
|
||||||
answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][
|
answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][
|
||||||
AIHttpPayloadKey.CONTENT
|
AIHttpPayloadKey.CONTENT
|
||||||
@@ -349,7 +349,7 @@ class DirectLLMAdapter(AIAdapter):
|
|||||||
response = client.post(url, json=payload, headers=headers)
|
response = client.post(url, json=payload, headers=headers)
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
raise HTTPException(status_code=502, detail={AIErrorKey.DIRECT_LLM: response.text})
|
raise HTTPException(status_code=502, detail={AIErrorKey.DIRECT_LLM: response.text})
|
||||||
data = response.json()
|
data = _chat_completion_payload(response, AIErrorKey.DIRECT_LLM)
|
||||||
try:
|
try:
|
||||||
answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][
|
answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][
|
||||||
AIHttpPayloadKey.CONTENT
|
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}
|
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]]:
|
def _chat_messages(prompt: str, context: dict[str, Any] | None = None) -> list[dict[str, str]]:
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,12 +23,19 @@ def list_approvals(
|
|||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
limit: int = Query(default=100, ge=1, le=500),
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
principal: ApiPrincipal = Depends(require_approval_api_key),
|
||||||
):
|
):
|
||||||
|
_ = principal
|
||||||
return ApprovalService(db).list(status_filter=status, limit=limit)
|
return ApprovalService(db).list(status_filter=status, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{ticket_id}", response_model=ApprovalRead)
|
@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)
|
return ApprovalService(db).get_by_ticket(ticket_id)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from decimal import Decimal
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.pagination import bounded_limit
|
from app.core.pagination import bounded_limit
|
||||||
@@ -135,11 +135,29 @@ class ApprovalService:
|
|||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=ApprovalErrorDetail.PAYLOAD_MISMATCH,
|
detail=ApprovalErrorDetail.PAYLOAD_MISMATCH,
|
||||||
)
|
)
|
||||||
ticket.status = ApprovalStatus.USED
|
used_at = utc_now()
|
||||||
ticket.used_by = actor
|
values: dict[str, Any] = {
|
||||||
ticket.used_at = utc_now()
|
"status": ApprovalStatus.USED,
|
||||||
|
"used_by": actor,
|
||||||
|
"used_at": used_at,
|
||||||
|
}
|
||||||
if record_id is not None and not ticket.record_id:
|
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
|
return ticket
|
||||||
|
|
||||||
def is_approved_for(
|
def is_approved_for(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
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.schemas import AuditLogRead
|
||||||
from app.modules.audit.service import AuditService
|
from app.modules.audit.service import AuditService
|
||||||
|
|
||||||
@@ -13,5 +13,7 @@ router = APIRouter(dependencies=[Depends(require_api_key)])
|
|||||||
def list_audit_logs(
|
def list_audit_logs(
|
||||||
limit: int = Query(default=100, ge=1, le=500),
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
principal: ApiPrincipal = Depends(require_audit_api_key),
|
||||||
) -> list:
|
) -> list:
|
||||||
|
_ = principal
|
||||||
return AuditService(db).list_logs(limit=limit)
|
return AuditService(db).list_logs(limit=limit)
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ class FeishuMessageType(StrEnum):
|
|||||||
|
|
||||||
|
|
||||||
class FeishuPayloadKey(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 = "receive_id"
|
||||||
RECEIVE_ID_TYPE = "receive_id_type"
|
RECEIVE_ID_TYPE = "receive_id_type"
|
||||||
MESSAGE_TYPE = "msg_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_SUCCESS_CODE = 0
|
||||||
FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS = 7200
|
FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS = 7200
|
||||||
FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS = 300
|
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 typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.constants import ActorValue
|
from app.core.constants import ActorValue
|
||||||
from app.modules.audit.constants import AuditSource
|
from app.modules.audit.constants import AuditSource
|
||||||
from app.modules.audit.schemas import AuditLogCreate
|
from app.modules.audit.schemas import AuditLogCreate
|
||||||
from app.modules.feishu.commands import FeishuCommandService
|
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
|
from app.modules.feishu.service import FeishuService
|
||||||
|
|
||||||
|
FEISHU_EVENT_ACTIONS = {
|
||||||
|
"webhook": FEISHU_WEBHOOK_EVENT_ACTION,
|
||||||
|
"long_connection": FEISHU_LONG_CONNECTION_EVENT_ACTION,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class FeishuEventService:
|
class FeishuEventService:
|
||||||
"""Handle Feishu message events from webhook or long connection."""
|
"""Handle Feishu message events from webhook or long connection."""
|
||||||
@@ -25,11 +37,16 @@ class FeishuEventService:
|
|||||||
auto_reply: bool = True,
|
auto_reply: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
self.feishu.verify_event(payload)
|
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(
|
self.feishu.audit.log(
|
||||||
AuditLogCreate(
|
AuditLogCreate(
|
||||||
actor=ActorValue.FEISHU,
|
actor=ActorValue.FEISHU,
|
||||||
source=AuditSource.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,
|
request_payload=payload,
|
||||||
response_payload={"accepted": True},
|
response_payload={"accepted": True},
|
||||||
)
|
)
|
||||||
@@ -44,3 +61,40 @@ class FeishuEventService:
|
|||||||
auto_reply=auto_reply,
|
auto_reply=auto_reply,
|
||||||
)
|
)
|
||||||
return {"ok": True, "handled": True, "result": result}
|
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)
|
||||||
@@ -16,10 +16,12 @@ from app.modules.business.models import (
|
|||||||
WorkReport,
|
WorkReport,
|
||||||
WorkTask,
|
WorkTask,
|
||||||
)
|
)
|
||||||
|
from app.modules.feishu.models import FeishuEventReceipt
|
||||||
|
|
||||||
_MODELS = [
|
_MODELS = [
|
||||||
ApprovalRequest,
|
ApprovalRequest,
|
||||||
AuditLog,
|
AuditLog,
|
||||||
|
FeishuEventReceipt,
|
||||||
Project,
|
Project,
|
||||||
WorkTask,
|
WorkTask,
|
||||||
Procurement,
|
Procurement,
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ class DummyResponse:
|
|||||||
return self._data
|
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:
|
def chat_response(content: str) -> DummyResponse:
|
||||||
return 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.status_code == 502
|
||||||
assert exc_info.value.detail[AIErrorKey.DIRECT_LLM] == "Unexpected chat completion response"
|
assert exc_info.value.detail[AIErrorKey.DIRECT_LLM] == "Unexpected chat completion response"
|
||||||
assert exc_info.value.detail[AIResponseKey.RAW] == {AIHttpPayloadKey.CHOICES: []}
|
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"
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ _db.close()
|
|||||||
|
|
||||||
os.environ["DATABASE_URL"] = "sqlite:///" + _db.name.replace("\\", "/")
|
os.environ["DATABASE_URL"] = "sqlite:///" + _db.name.replace("\\", "/")
|
||||||
os.environ["API_KEY"] = "test-key"
|
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_KEY"] = "approval-key"
|
||||||
os.environ["APPROVAL_API_ACTOR"] = "approval-manager"
|
os.environ["APPROVAL_API_ACTOR"] = "approval-manager"
|
||||||
os.environ["FEISHU_APP_ID"] = ""
|
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.config import Settings, get_settings
|
||||||
from app.core.database import Base, engine
|
from app.core.database import Base, engine
|
||||||
from app.core.pagination import bounded_limit, bounded_offset
|
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.main import _allow_cors_credentials, app
|
||||||
from app.modules.audit.constants import AUDIT_REDACTED_VALUE
|
from app.modules.audit.constants import AUDIT_REDACTED_VALUE
|
||||||
from app.modules.legacy_mysql.service import LegacyMySQLService
|
from app.modules.legacy_mysql.service import LegacyMySQLService
|
||||||
@@ -48,6 +50,7 @@ from app.modules.reports.constants import (
|
|||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
headers = {"X-API-Key": "test-key"}
|
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"}
|
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:
|
def test_feishu_webhook_routes_message_event() -> None:
|
||||||
payload = {
|
payload = {
|
||||||
"schema": "2.0",
|
"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": {
|
"event": {
|
||||||
"sender": {"sender_id": {"open_id": "ou_test"}},
|
"sender": {"sender_id": {"open_id": "ou_test"}},
|
||||||
"message": {
|
"message": {
|
||||||
"chat_id": "oc_test",
|
"chat_id": "oc_test",
|
||||||
|
"message_id": "om_smoke_risk_001",
|
||||||
"message_type": "text",
|
"message_type": "text",
|
||||||
"content": json.dumps({"text": "risk"}),
|
"content": json.dumps({"text": "risk"}),
|
||||||
},
|
},
|
||||||
@@ -109,7 +117,14 @@ def test_feishu_webhook_routes_message_event() -> None:
|
|||||||
assert data["handled"] is True
|
assert data["handled"] is True
|
||||||
assert data["result"]["command"] == "risk_summary"
|
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
|
assert logs_response.status_code == 200
|
||||||
audit_payload = json.dumps(logs_response.json(), ensure_ascii=False)
|
audit_payload = json.dumps(logs_response.json(), ensure_ascii=False)
|
||||||
assert "test-feishu-token" not in audit_payload
|
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:
|
with pytest.raises(HTTPException) as approval_exc_info:
|
||||||
require_approval_api_key("approval-key")
|
require_approval_api_key("approval-key")
|
||||||
assert approval_exc_info.value.status_code == 503
|
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:
|
finally:
|
||||||
monkeypatch.setenv("API_KEY", "test-key")
|
monkeypatch.setenv("API_KEY", "test-key")
|
||||||
|
monkeypatch.setenv("AUDIT_API_KEY", "audit-key")
|
||||||
monkeypatch.setenv("APPROVAL_API_KEY", "approval-key")
|
monkeypatch.setenv("APPROVAL_API_KEY", "approval-key")
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
|
|
||||||
@@ -199,6 +221,21 @@ def test_approval_gate_for_high_risk_update() -> None:
|
|||||||
assert create_approval_response.json()["applicant"] == "api"
|
assert create_approval_response.json()["applicant"] == "api"
|
||||||
create_ticket_id = create_approval_response.json()["ticket_id"]
|
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(
|
approve_create_response = client.post(
|
||||||
f"/api/v1/approvals/{create_ticket_id}/approve",
|
f"/api/v1/approvals/{create_ticket_id}/approve",
|
||||||
headers=approval_headers,
|
headers=approval_headers,
|
||||||
|
|||||||
Reference in New Issue
Block a user