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,487 @@
import json
from collections.abc import Iterator
from dataclasses import replace
from typing import Any
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.application.feishu.commands import FeishuCommandService
from app.application.feishu.events import FeishuEventService
from app.core.config import get_settings
from app.core.database import Base, get_db
from app.modules.audit.models import AuditLog
from app.modules.feishu.constants import (
FeishuCommandName,
FeishuEventSource,
)
from app.modules.feishu.models import FeishuEventReceipt
from app.modules.feishu.routes import router as feishu_router
from app.modules.feishu_users.constants import (
FeishuUserAuditAction,
FeishuUserRole,
FeishuUserStatus,
)
from app.modules.feishu_users.models import (
FeishuAdminBootstrapTombstone,
FeishuUser,
)
from app.modules.feishu_users.principal import FeishuMention
from app.modules.feishu_users.services import (
FeishuIdentityService,
FeishuUserManagementService,
)
@pytest.fixture
def session_factory(
monkeypatch: pytest.MonkeyPatch,
) -> Iterator[sessionmaker[Session]]:
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "true")
monkeypatch.setenv("FEISHU_ADMIN_IDENTITIES", "tenant-a:ou-admin")
monkeypatch.setenv("FEISHU_APP_ID", "")
monkeypatch.setenv("FEISHU_APP_SECRET", "")
monkeypatch.setenv("FEISHU_VERIFICATION_TOKEN", "verified-token")
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__,
FeishuAdminBootstrapTombstone.__table__,
FeishuUser.__table__,
],
)
factory = sessionmaker(bind=engine, expire_on_commit=False)
try:
yield factory
finally:
Base.metadata.drop_all(
engine,
tables=[
FeishuAdminBootstrapTombstone.__table__,
FeishuUser.__table__,
FeishuEventReceipt.__table__,
AuditLog.__table__,
],
)
engine.dispose()
get_settings.cache_clear()
def _message_event(
*,
event_id: str,
text: str,
tenant_key: str | None = "tenant-a",
open_id: str | None = "ou-user",
chat_id: str = "oc-chat",
chat_type: str = "p2p",
mentions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
header: dict[str, Any] = {
"event_id": event_id,
"event_type": "im.message.receive_v1",
"token": "verified-token",
}
if tenant_key is not None:
header["tenant_key"] = tenant_key
sender_id: dict[str, str] = {"user_id": "legacy-user-id"}
if open_id is not None:
sender_id["open_id"] = open_id
message: dict[str, Any] = {
"chat_id": chat_id,
"chat_type": chat_type,
"message_id": f"om-{event_id}",
"message_type": "text",
"content": json.dumps({"text": text}, ensure_ascii=False),
}
if mentions is not None:
message["mentions"] = mentions
return {
"schema": "2.0",
"header": header,
"event": {
"sender": {"sender_id": sender_id},
"message": message,
},
}
def _mention(
key: str,
open_id: str,
*,
name: str,
tenant_key: str = "tenant-a",
) -> dict[str, Any]:
return {
"key": key,
"name": name,
"tenant_key": tenant_key,
"id": {
"open_id": open_id,
"union_id": f"on-{open_id}",
"user_id": f"u-{open_id}",
},
}
def test_verified_event_requires_tenant_and_open_id_without_registering(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
missing_tenant = FeishuEventService(db)._handle_verified_event(
_message_event(
event_id="missing-tenant",
text="risk",
tenant_key=None,
),
source=FeishuEventSource.WEBHOOK,
auto_reply=False,
)
missing_open_id = FeishuEventService(db)._handle_verified_event(
_message_event(
event_id="missing-open",
text="risk",
open_id=None,
),
source=FeishuEventSource.WEBHOOK,
auto_reply=False,
)
assert missing_tenant["result"]["command"] == "permission_denied"
assert missing_open_id["result"]["command"] == "permission_denied"
assert db.scalar(select(FeishuUser)) is None
denial_logs = list(
db.execute(
select(AuditLog).where(
AuditLog.action == FeishuUserAuditAction.PERMISSION_DENIED
)
).scalars()
)
assert len(denial_logs) == 2
assert all(item.actor == "feishu" for item in denial_logs)
def test_unverified_event_cannot_create_identity(
session_factory: sessionmaker[Session],
) -> None:
payload = _message_event(
event_id="invalid-token",
text="问 你好",
open_id="ou-unverified",
)
payload["header"]["token"] = "invalid-token"
with session_factory() as db:
with pytest.raises(HTTPException) as exc_info:
FeishuEventService(db).handle_event(
payload,
source=FeishuEventSource.WEBHOOK,
auto_reply=False,
)
assert exc_info.value.status_code == 401
assert db.scalar(select(FeishuUser)) is None
def test_group_event_uses_sender_principal_and_structured_context(
session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, Any] = {}
def fake_handle_text(
self: FeishuCommandService,
text: str,
chat_id: str | None = None,
actor: str = "feishu",
auto_reply: bool = True,
principal: Any = None,
) -> dict[str, Any]:
captured.update(
{
"text": text,
"chat_id": chat_id,
"actor": actor,
"principal": principal,
}
)
return {
"command": "ai_ask",
"reply_type": "text",
"title": "test",
"content": "ok",
"provider_response": None,
}
monkeypatch.setattr(FeishuCommandService, "handle_text", fake_handle_text)
payload = _message_event(
event_id="group-principal",
text="@_bot 问 项目状态",
open_id="ou-sender",
chat_id="oc-group",
chat_type="group",
mentions=[_mention("@_target", "ou-target", name="目标用户")],
)
with session_factory() as db:
result = FeishuEventService(db)._handle_verified_event(
payload,
source=FeishuEventSource.LONG_CONNECTION,
auto_reply=False,
)
principal = captured["principal"]
assert result["handled"] is True
assert principal.open_id == "ou-sender"
assert principal.chat_id == "oc-group"
assert principal.chat_type == "group"
assert principal.mentions[0].open_id == "ou-target"
assert captured["actor"] == principal.user_code
assert captured["chat_id"] == "oc-group"
@pytest.mark.parametrize(
"command",
[
"日报",
"项目资金 P-001",
"风险",
"学习公司规则:所有人共享",
"设为管理员 @_target",
],
)
def test_ordinary_user_cannot_run_company_commands(
session_factory: sessionmaker[Session],
command: str,
) -> None:
with session_factory() as db:
principal = FeishuIdentityService(db).resolve_or_register(
tenant_key="tenant-a",
open_id=f"ou-{abs(hash(command))}",
)
result = FeishuCommandService(db).handle_text(
command,
principal=principal,
auto_reply=False,
)
assert result["command"] == FeishuCommandName.PERMISSION_DENIED
assert "公司级功能" in result["content"]
def test_disabled_user_is_rejected_before_command_execution(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
principal = FeishuIdentityService(db).resolve_or_register(
tenant_key="tenant-a",
open_id="ou-disabled",
)
FeishuUserManagementService(db).update_user(
principal.user_code,
changes={"status": FeishuUserStatus.DISABLED},
actor="service-admin",
)
disabled = FeishuIdentityService(db).resolve_or_register(
tenant_key="tenant-a",
open_id="ou-disabled",
)
result = FeishuCommandService(db).handle_text(
"问 你好",
principal=disabled,
auto_reply=False,
)
assert result["command"] == FeishuCommandName.PERMISSION_DENIED
assert "已停用" in result["content"]
def test_admin_command_uses_target_mention_and_ignores_bot_mention(
session_factory: sessionmaker[Session],
) -> None:
payload = _message_event(
event_id="admin-promote",
text="@_bot 设为管理员 @_target",
open_id="ou-admin",
chat_id="oc-group",
chat_type="group",
mentions=[
_mention("@_bot", "ou-bot", name="机器人"),
_mention("@_target", "ou-target", name="张三"),
],
)
with session_factory() as db:
result = FeishuEventService(db)._handle_verified_event(
payload,
source=FeishuEventSource.WEBHOOK,
auto_reply=False,
)
target = FeishuIdentityService(db).get_by_identity(
tenant_key="tenant-a",
open_id="ou-target",
)
assert result["result"]["command"] == FeishuCommandName.USER_SET_ADMIN
assert "张三 已设为管理员" in result["result"]["content"]
assert target is not None
assert target.role == FeishuUserRole.ADMIN
assert target.union_id == "on-ou-target"
def test_admin_command_rejects_text_identity_cross_tenant_and_last_admin(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
admin = FeishuIdentityService(db).resolve_or_register(
tenant_key="tenant-a",
open_id="ou-admin",
)
admin = replace(admin, chat_id="oc-group", chat_type="group")
service = FeishuCommandService(db)
forged = service.handle_text(
"设为管理员 @ou-forged",
principal=admin,
auto_reply=False,
)
assert forged["command"] == FeishuCommandName.USER_SET_ADMIN
assert "真实的飞书 @用户" in forged["content"]
assert (
FeishuIdentityService(db).get_by_identity(
tenant_key="tenant-a",
open_id="ou-forged",
)
is None
)
cross_tenant = service.handle_text(
"设为管理员 @_target",
principal=replace(
admin,
mentions=(
FeishuMention(
key="@_target",
name="其他租户用户",
tenant_key="tenant-b",
open_id="ou-other",
),
),
),
auto_reply=False,
)
assert "当前租户" in cross_tenant["content"]
assert (
FeishuIdentityService(db).get_by_identity(
tenant_key="tenant-b",
open_id="ou-other",
)
is None
)
self_demote = service.handle_text(
"设为普通用户 @_self",
principal=replace(
admin,
mentions=(
FeishuMention(
key="@_self",
name="管理员",
tenant_key="tenant-a",
open_id="ou-admin",
),
),
),
auto_reply=False,
)
assert "最后一个有效管理员" in self_demote["content"]
stored_admin = FeishuIdentityService(db).get_by_identity(
tenant_key="tenant-a",
open_id="ou-admin",
)
assert stored_admin is not None
assert stored_admin.role == FeishuUserRole.ADMIN
def test_preview_cannot_forge_feishu_principal_when_features_enabled(
session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("API_KEY", "service-key")
monkeypatch.setenv("API_ACTOR", "service-principal")
get_settings.cache_clear()
app = FastAPI()
app.include_router(feishu_router, prefix="/api/v1/integrations/feishu")
def override_db() -> Iterator[Session]:
with session_factory() as db:
yield db
app.dependency_overrides[get_db] = override_db
response = TestClient(app).post(
"/api/v1/integrations/feishu/commands/preview",
headers={"X-API-Key": "service-key"},
json={
"text": "日报",
"actor": "ou-admin",
"auto_reply": False,
},
)
assert response.status_code == 200
assert response.json()["command"] == "permission_denied"
with session_factory() as db:
assert db.scalar(select(FeishuUser)) is None
def test_disabled_feature_flag_preserves_legacy_actor_flow(
session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "false")
get_settings.cache_clear()
captured: dict[str, Any] = {}
def fake_handle_text(
self: FeishuCommandService,
text: str,
chat_id: str | None = None,
actor: str = "feishu",
auto_reply: bool = True,
principal: Any = None,
) -> dict[str, Any]:
captured["actor"] = actor
captured["principal"] = principal
return {
"command": "fallback_ai",
"reply_type": "text",
"title": "test",
"content": "ok",
"provider_response": None,
}
monkeypatch.setattr(FeishuCommandService, "handle_text", fake_handle_text)
with session_factory() as db:
FeishuEventService(db)._handle_verified_event(
_message_event(
event_id="legacy-flow",
text="hello",
tenant_key=None,
open_id=None,
),
source=FeishuEventSource.WEBHOOK,
auto_reply=False,
)
assert captured["actor"] == "legacy-user-id"
assert captured["principal"] is None
assert db.scalar(select(FeishuUser)) is None