feat: 添加飞书用户模块和订阅功能支持

- 新增feishu_users模块用于处理飞书用户身份验证和权限管理
- 新增subscriptions模块用于处理订阅相关功能
- 新增personalization模块用于个性化服务
- 在alembic迁移配置中注册新的模型模块
- 在API路由器中添加feishu_users和subscriptions路由
- 实现事件调度服务的改进,包括错误处理和状态更新优化
- 添加飞书命令处理的权限检查机制
- 实现飞书应用票据事件处理
- 改进审计日志记录功能
```
This commit is contained in:
2026-07-27 08:02:17 +08:00
parent db751f03b4
commit d7db84571d
148 changed files with 17110 additions and 765 deletions

View File

@@ -0,0 +1,269 @@
import json
import sys
from collections.abc import Iterator
from types import SimpleNamespace
from typing import Any
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.application.feishu.events import FeishuEventService
from app.core.config import get_settings
from app.core.database import Base
from app.modules.audit.models import AuditLog
from app.modules.feishu import long_connection
from app.modules.feishu.app_tickets import FeishuAppTicketService
from app.modules.feishu.constants import FeishuEventSource
from app.modules.feishu.models import FeishuAppTicket, FeishuEventReceipt
@pytest.fixture
def session_factory(
monkeypatch: pytest.MonkeyPatch,
) -> Iterator[sessionmaker[Session]]:
monkeypatch.setenv("FEISHU_APP_ID", "cli-ticket-app")
monkeypatch.setenv("FEISHU_APP_SECRET", "ticket-app-secret")
monkeypatch.setenv("FEISHU_VERIFICATION_TOKEN", "ticket-token")
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "false")
get_settings.cache_clear()
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(
engine,
tables=[
AuditLog.__table__,
FeishuEventReceipt.__table__,
FeishuAppTicket.__table__,
],
)
factory = sessionmaker(bind=engine, expire_on_commit=False)
try:
yield factory
finally:
Base.metadata.drop_all(
engine,
tables=[
FeishuAppTicket.__table__,
FeishuEventReceipt.__table__,
AuditLog.__table__,
],
)
engine.dispose()
get_settings.cache_clear()
def _v2_ticket_event(
event_id: str,
ticket: str,
*,
app_id: str = "cli-ticket-app",
) -> dict[str, Any]:
return {
"schema": "2.0",
"header": {
"event_id": event_id,
"event_type": "app_ticket",
"token": "ticket-token",
"app_id": app_id,
},
"event": {"app_ticket": ticket},
}
def _v1_ticket_event(
event_id: str,
ticket: str,
*,
app_id: str = "cli-ticket-app",
) -> dict[str, Any]:
return {
"ts": "1785081600.000",
"uuid": event_id,
"token": "ticket-token",
"type": "app_ticket",
"event": {
"app_id": app_id,
"app_ticket": ticket,
},
}
def test_verified_ticket_is_deduplicated_rotated_and_never_leaked(
session_factory: sessionmaker[Session],
) -> None:
first_ticket = "ticket-secret-first"
rotated_ticket = "ticket-secret-rotated"
with session_factory() as db:
service = FeishuEventService(db)
first = service._handle_verified_event(
_v2_ticket_event("ticket-event-1", first_ticket),
source=FeishuEventSource.WEBHOOK,
)
stored = db.scalar(select(FeishuAppTicket))
assert stored is not None
stored_id = stored.id
first_received_at = stored.received_at
assert FeishuAppTicketService(db).get_ticket("cli-ticket-app") == first_ticket
assert first_ticket not in json.dumps(first, ensure_ascii=False)
duplicate_payload = _v2_ticket_event(
"ticket-event-1",
"ticket-secret-duplicate-must-not-win",
)
duplicate = service._handle_verified_event(
duplicate_payload,
source=FeishuEventSource.LONG_CONNECTION,
)
assert duplicate["duplicate"] is True
assert FeishuAppTicketService(db).get_ticket("cli-ticket-app") == first_ticket
rotated = service._handle_verified_event(
_v2_ticket_event("ticket-event-2", rotated_ticket),
source=FeishuEventSource.LONG_CONNECTION,
)
db.expire_all()
current = db.scalar(select(FeishuAppTicket))
assert current is not None
assert current.id == stored_id
assert current.app_ticket == rotated_ticket
assert current.received_at >= first_received_at
assert db.scalar(select(func.count()).select_from(FeishuAppTicket)) == 1
assert db.scalar(select(func.count()).select_from(FeishuEventReceipt)) == 2
assert rotated_ticket not in json.dumps(rotated, ensure_ascii=False)
audits = list(db.execute(select(AuditLog)).scalars())
assert len(audits) == 2
serialized_audits = json.dumps(
[
{
"request": item.request_payload,
"response": item.response_payload,
"target": item.target_id,
}
for item in audits
],
ensure_ascii=False,
)
assert first_ticket not in serialized_audits
assert rotated_ticket not in serialized_audits
assert "ticket-secret-duplicate-must-not-win" not in serialized_audits
def test_only_verified_matching_app_ticket_events_can_write(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
service = FeishuEventService(db)
unverified = _v2_ticket_event("unverified-ticket", "unverified-secret")
unverified["header"]["token"] = "invalid-token"
with pytest.raises(HTTPException) as unverified_error:
service.handle_event(unverified, source=FeishuEventSource.WEBHOOK)
assert unverified_error.value.status_code == 401
with pytest.raises(HTTPException) as mismatch_error:
service._handle_verified_event(
_v2_ticket_event(
"wrong-app-ticket",
"wrong-app-secret",
app_id="cli-other-app",
),
source=FeishuEventSource.WEBHOOK,
)
assert mismatch_error.value.status_code == 401
missing_ticket = _v2_ticket_event("missing-ticket", "")
with pytest.raises(HTTPException) as missing_error:
service._handle_verified_event(
missing_ticket,
source=FeishuEventSource.WEBHOOK,
)
assert missing_error.value.status_code == 400
not_ticket_event = _v2_ticket_event("ordinary-event", "must-not-store")
not_ticket_event["header"]["event_type"] = "im.message.receive_v1"
result = service._handle_verified_event(
not_ticket_event,
source=FeishuEventSource.WEBHOOK,
auto_reply=False,
)
assert result["handled"] is False
assert db.scalar(select(FeishuAppTicket)) is None
def test_v1_app_ticket_payload_uses_uuid_receipt(
session_factory: sessionmaker[Session],
) -> None:
ticket = "v1-ticket-secret"
with session_factory() as db:
result = FeishuEventService(db).handle_event(
_v1_ticket_event("v1-ticket-uuid", ticket),
source=FeishuEventSource.WEBHOOK,
)
receipt = db.scalar(select(FeishuEventReceipt))
assert result == {"ok": True, "handled": True}
assert receipt is not None
assert receipt.event_id == "v1-ticket-uuid"
assert receipt.event_key == "cli-ticket-app:app_ticket:v1-ticket-uuid"
assert FeishuAppTicketService(db).get_ticket("cli-ticket-app") == ticket
assert ticket not in json.dumps(result, ensure_ascii=False)
def test_long_connection_registers_custom_app_ticket_handler(
monkeypatch: pytest.MonkeyPatch,
) -> None:
registrations: dict[str, Any] = {}
class FakeBuilder:
def register_p2_im_message_receive_v1(self, handler: Any) -> "FakeBuilder":
registrations["message"] = handler
return self
def register_p1_customized_event(
self,
event_type: str,
handler: Any,
) -> "FakeBuilder":
registrations[event_type] = handler
return self
def build(self) -> "FakeBuilder":
return self
class FakeDispatcherHandler:
@staticmethod
def builder(encrypt_key: str, verification_token: str) -> FakeBuilder:
registrations["builder_args"] = (encrypt_key, verification_token)
return FakeBuilder()
class FakeClient:
def __init__(self, **kwargs: Any):
registrations["client_kwargs"] = kwargs
def start(self) -> None:
registrations["started"] = True
fake_lark = SimpleNamespace(
EventDispatcherHandler=FakeDispatcherHandler,
LogLevel=SimpleNamespace(WARNING="warning"),
ws=SimpleNamespace(Client=FakeClient),
)
monkeypatch.setitem(sys.modules, "lark_oapi", fake_lark)
monkeypatch.setenv("FEISHU_APP_ID", "cli-ticket-app")
monkeypatch.setenv("FEISHU_APP_SECRET", "ticket-app-secret")
monkeypatch.setenv("FEISHU_VERIFICATION_TOKEN", "ticket-token")
get_settings.cache_clear()
try:
long_connection.run_long_connection()
finally:
get_settings.cache_clear()
assert registrations["app_ticket"] is long_connection._handle_app_ticket_event
assert registrations["message"] is long_connection._handle_message_event
assert registrations["started"] is True