feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
487 lines
16 KiB
Python
487 lines
16 KiB
Python
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
|