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,486 @@
from collections.abc import Iterator
from typing import Any
import pytest
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.application.feishu.commands import FeishuCommandService
from app.application.feishu.handlers import personal_data as personal_data_handler
from app.application.feishu.personal_data import PERSONAL_DATA_ERASURE_ACTION
from app.core.config import get_settings
from app.core.database import Base
from app.modules.ai_agent.constants import AIResponseKey
from app.modules.ai_agent.service import AIService
from app.modules.ai_memory.models import AIMemoryEntry
from app.modules.audit.constants import AuditAction
from app.modules.audit.models import AuditLog
from app.modules.feishu.constants import FeishuCommandName
from app.modules.feishu_users.constants import FeishuUserRole
from app.modules.feishu_users.models import FeishuUser
from app.modules.feishu_users.principal import FeishuPrincipal
from app.modules.feishu_users.services import FeishuIdentityService
from app.modules.personalization.models import UserPreference
from app.modules.personalization.services import ConversationService
from app.modules.subscriptions.models import PushSubscription
@pytest.fixture
def session_factory(
monkeypatch: pytest.MonkeyPatch,
) -> Iterator[sessionmaker[Session]]:
monkeypatch.setenv("FEISHU_USER_FEATURES_ENABLED", "true")
monkeypatch.setenv("FEISHU_APP_ID", "")
monkeypatch.setenv("FEISHU_APP_SECRET", "")
get_settings.cache_clear()
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
try:
yield factory
finally:
engine.dispose()
get_settings.cache_clear()
def _principal(
db: Session,
suffix: str,
*,
role: str = FeishuUserRole.USER,
chat_id: str | None = None,
chat_type: str = "p2p",
) -> FeishuPrincipal:
user = FeishuUser(
code=f"FSU-COMMAND-{suffix}",
tenant_key=f"tenant-{suffix}",
open_id=f"open-{suffix}",
role=role,
)
db.add(user)
db.commit()
db.refresh(user)
return FeishuPrincipal.from_user(
user,
chat_id=chat_id or f"chat-{suffix}",
chat_type=chat_type,
)
def test_personal_rules_are_owner_scoped_and_company_rules_require_admin(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
owner = _principal(db, "owner")
other = _principal(db, "other")
admin = _principal(db, "admin", role=FeishuUserRole.ADMIN)
created = FeishuCommandService(db).handle_text(
"学习规则 80回答尽量简洁",
principal=owner,
auto_reply=False,
)
denied = FeishuCommandService(db).handle_text(
"学习公司规则:所有人使用中文",
principal=other,
auto_reply=False,
)
company = FeishuCommandService(db).handle_text(
"学习公司规则:所有人使用中文",
principal=admin,
auto_reply=False,
)
records = list(
db.execute(
select(AIMemoryEntry).order_by(AIMemoryEntry.id.asc())
).scalars()
)
assert created["command"] == FeishuCommandName.RULE_CREATE
assert denied["command"] == FeishuCommandName.PERMISSION_DENIED
assert company["command"] == FeishuCommandName.RULE_CREATE
assert len(records) == 2
assert records[0].owner_id == owner.owner_id
assert records[1].owner_id is None
owner_list = FeishuCommandService(db).handle_text(
"查看规则",
principal=owner,
auto_reply=False,
)
other_list = FeishuCommandService(db).handle_text(
"查看规则",
principal=other,
auto_reply=False,
)
assert "回答尽量简洁" in owner_list["content"]
assert "回答尽量简洁" not in other_list["content"]
assert "所有人使用中文" not in owner_list["content"]
def test_preferences_and_topics_are_isolated_and_sensitive_content_is_rejected(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
owner = _principal(db, "preference-owner")
other = _principal(db, "preference-other")
service = FeishuCommandService(db)
preference = service.handle_text(
"记住偏好 语气:简洁",
principal=owner,
auto_reply=False,
)
topic = service.handle_text(
"关注主题:人工智能",
principal=owner,
auto_reply=False,
)
code = str(preference["content"]).split("编号:", 1)[1].splitlines()[0]
cross_owner_delete = service.handle_text(
f"删除偏好 {code}",
principal=other,
auto_reply=False,
)
sensitive = service.handle_text(
"记住偏好 兴趣:我的银行卡是 123456",
principal=owner,
auto_reply=False,
)
assert preference["command"] == FeishuCommandName.PREFERENCE_SET
assert topic["command"] == FeishuCommandName.PREFERENCE_SET
assert "没有找到" in cross_owner_delete["content"]
assert "敏感" in sensitive["content"]
assert db.scalar(
select(func.count())
.select_from(UserPreference)
.where(UserPreference.owner_id == owner.owner_id)
) == 2
assert db.scalar(
select(func.count())
.select_from(UserPreference)
.where(UserPreference.owner_id == other.owner_id)
) == 0
def test_conversation_reset_only_clears_current_user_and_chat(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
owner = _principal(
db,
"conversation-owner",
chat_id="group-current",
chat_type="group",
)
other = _principal(
db,
"conversation-other",
chat_id="group-current",
chat_type="group",
)
conversations = ConversationService(db)
conversations.record_turn(
owner.owner_id,
"group",
"group-current",
user_content="owner question",
assistant_content="owner answer",
provider_name="direct_llm",
)
conversations.record_turn(
owner.owner_id,
"group",
"group-other",
user_content="other chat question",
assistant_content="other chat answer",
provider_name="direct_llm",
)
conversations.record_turn(
other.owner_id,
"group",
"group-current",
user_content="other user question",
assistant_content="other user answer",
provider_name="direct_llm",
)
result = FeishuCommandService(db).handle_text(
"重置对话",
principal=owner,
auto_reply=False,
)
assert result["command"] == FeishuCommandName.CONVERSATION_RESET
assert conversations.history(owner.owner_id, "group", "group-current") == []
assert conversations.history(owner.owner_id, "group", "group-other")
assert conversations.history(other.owner_id, "group", "group-current")
def test_ai_and_subscription_commands_are_wired_to_verified_principal(
session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, Any] = {}
def fake_personalized(
self: AIService,
owner_id: int,
chat_type: str,
chat_key: str,
prompt: str,
**_: Any,
) -> dict[str, Any]:
captured.update(
{
"owner_id": owner_id,
"chat_type": chat_type,
"chat_key": chat_key,
"prompt": prompt,
}
)
return {
AIResponseKey.OK: True,
AIResponseKey.ANSWER: "账号隔离回答",
AIResponseKey.PROVIDER: "test",
AIResponseKey.RAW: {},
}
monkeypatch.setattr(AIService, "ask_personalized", fake_personalized)
with session_factory() as db:
principal = _principal(
db,
"wiring",
chat_id="verified-private-chat",
)
service = FeishuCommandService(db)
answer = service.handle_text(
"问 你好",
principal=principal,
auto_reply=False,
)
subscription = service.handle_text(
"订阅 每天 09:00给我一个问候",
principal=principal,
auto_reply=False,
)
assert answer["content"] == "账号隔离回答"
assert captured == {
"owner_id": principal.owner_id,
"chat_type": "p2p",
"chat_key": "verified-private-chat",
"prompt": "你好",
}
assert subscription["command"] == FeishuCommandName.SUBSCRIPTION_CREATE
stored = db.scalar(select(PushSubscription))
assert stored is not None
assert stored.owner_id == principal.owner_id
assert stored.target_id == principal.open_id
def test_help_lists_only_role_allowed_company_commands(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
user = _principal(db, "help-user")
admin = _principal(db, "help-admin", role=FeishuUserRole.ADMIN)
user_help = FeishuCommandService(db).handle_text(
"帮助",
principal=user,
auto_reply=False,
)
admin_help = FeishuCommandService(db).handle_text(
"帮助",
principal=admin,
auto_reply=False,
)
assert "管理员还可以使用" not in user_help["content"]
assert "管理员还可以使用" in admin_help["content"]
def test_my_data_summary_and_two_step_erasure_use_current_identity_only(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
principal = _principal(db, "personal-data")
other = _principal(db, "personal-data-other")
service = FeishuCommandService(db)
service.handle_text(
"学习规则:只属于我的规则",
principal=principal,
auto_reply=False,
)
service.handle_text(
"记住偏好 语言:中文",
principal=principal,
auto_reply=False,
)
service.handle_text(
"关注主题:低空经济",
principal=principal,
auto_reply=False,
)
service.handle_text(
"记住偏好 语气:不应出现在另一用户摘要",
principal=other,
auto_reply=False,
)
service.handle_text(
"订阅 每天 09:00个人提醒",
principal=principal,
auto_reply=False,
)
summary = service.handle_text(
"我的数据",
principal=principal,
auto_reply=False,
)
request = service.handle_text(
"忘记我",
principal=principal,
auto_reply=False,
)
confirmation_code = (
str(request["content"]).split("确认码:", 1)[1].splitlines()[0]
)
erased = service.handle_text(
f"确认忘记我 {confirmation_code}",
principal=principal,
auto_reply=False,
)
assert summary["command"] == FeishuCommandName.PERSONAL_DATA_SUMMARY
assert "个人规则1 条" in summary["content"]
assert "language=中文" in summary["content"]
assert "低空经济" in summary["content"]
assert "不应出现在另一用户摘要" not in summary["content"]
assert request["command"] == (
FeishuCommandName.PERSONAL_DATA_ERASURE_REQUEST
)
assert erased["command"] == (
FeishuCommandName.PERSONAL_DATA_ERASURE_CONFIRM
)
assert db.get(FeishuUser, principal.owner_id) is None
assert db.get(FeishuUser, other.owner_id) is not None
recreated = FeishuIdentityService(db).resolve_or_register(
tenant_key=principal.tenant_key,
open_id=principal.open_id,
)
assert recreated.owner_id != principal.owner_id
assert recreated.role == FeishuUserRole.USER
def test_erasure_confirmation_reply_does_not_create_identifying_send_audit(
session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: list[dict[str, Any]] = []
def fake_send_text(
_feishu: Any,
chat_id: str | None,
text: str,
actor: str,
*,
record_audit: bool = True,
) -> dict[str, Any]:
captured.append(
{
"chat_id": chat_id,
"text": text,
"actor": actor,
"record_audit": record_audit,
}
)
return {"code": 0}
monkeypatch.setattr(
personal_data_handler,
"send_text_if_configured",
fake_send_text,
)
with session_factory() as db:
principal = _principal(db, "erasure-reply")
service = FeishuCommandService(db)
request = service.handle_text(
"忘记我",
principal=principal,
auto_reply=False,
)
confirmation_code = (
str(request["content"]).split("确认码:", 1)[1].splitlines()[0]
)
service.handle_text(
f"确认忘记我 {confirmation_code}",
principal=principal,
auto_reply=True,
)
assert len(captured) == 1
assert captured[0]["actor"].startswith("anonymous-")
assert captured[0]["record_audit"] is False
anonymous_logs = list(
db.execute(
select(AuditLog).where(
AuditLog.actor == captured[0]["actor"]
)
).scalars()
)
assert anonymous_logs
assert any(
item.action == PERSONAL_DATA_ERASURE_ACTION
for item in anonymous_logs
)
assert all(
item.action != AuditAction.FEISHU_SEND_TEXT
for item in anonymous_logs
)
assert all(
item.target_type is None
and item.target_id is None
and item.request_payload is None
and item.response_payload is None
and item.request_id is None
for item in anonymous_logs
)
def test_group_chat_does_not_disclose_summary_or_erasure_code(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
principal = _principal(
db,
"group-personal-data",
chat_id="group-personal-data",
chat_type="group",
)
summary = FeishuCommandService(db).handle_text(
"我的数据",
principal=principal,
auto_reply=False,
)
erasure = FeishuCommandService(db).handle_text(
"忘记我",
principal=principal,
auto_reply=False,
)
assert "请私聊" in summary["content"]
assert "请私聊" in erasure["content"]
assert "确认码" not in erasure["content"]
assert db.get(FeishuUser, principal.owner_id) is not None