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,167 @@
from collections.abc import Iterator
from datetime import timedelta
import pytest
from fastapi import FastAPI
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.application.scheduling import create_scheduler
from app.core.config import get_settings
from app.core.database import Base
from app.core.utils.time import utc_now
from app.modules.feishu_users.models import FeishuUser
from app.modules.personalization.models import AIConversation, AIConversationMessage
from app.modules.personalization.services import ConversationService
@pytest.fixture(autouse=True)
def _reset_settings() -> Iterator[None]:
get_settings.cache_clear()
yield
get_settings.cache_clear()
@pytest.fixture
def session_factory() -> Iterator[sessionmaker[Session]]:
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()
def test_global_cleanup_removes_inactive_user_history(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
expired = _conversation(db, "expired")
mixed = _conversation(db, "mixed")
fresh = _conversation(db, "fresh")
old_time = utc_now() - timedelta(days=31)
fresh_time = utc_now() - timedelta(days=29)
db.add_all(
[
AIConversationMessage(
conversation_id=expired.id,
role="user",
content="expired question",
created_at=old_time,
),
AIConversationMessage(
conversation_id=expired.id,
role="assistant",
content="expired answer",
created_at=old_time,
),
AIConversationMessage(
conversation_id=mixed.id,
role="user",
content="old mixed question",
created_at=old_time,
),
AIConversationMessage(
conversation_id=mixed.id,
role="assistant",
content="fresh mixed answer",
created_at=fresh_time,
),
AIConversationMessage(
conversation_id=fresh.id,
role="user",
content="fresh question",
created_at=fresh_time,
),
]
)
db.commit()
deleted = ConversationService(db).cleanup_expired_globally()
assert deleted == {
"conversation_messages": 3,
"conversations": 1,
}
assert db.get(AIConversation, expired.id) is None
assert db.get(AIConversation, mixed.id) is not None
assert db.get(AIConversation, fresh.id) is not None
assert db.scalar(
select(func.count()).select_from(AIConversationMessage)
) == 2
with session_factory() as verification_db:
assert verification_db.get(AIConversation, expired.id) is None
@pytest.mark.parametrize(
("features_enabled", "job_expected"),
[(False, False), (True, True)],
)
def test_scheduler_wires_one_global_retention_job(
session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
features_enabled: bool,
job_expected: bool,
) -> None:
monkeypatch.setenv(
"FEISHU_USER_FEATURES_ENABLED",
str(features_enabled).lower(),
)
monkeypatch.setattr("app.core.database.SessionLocal", session_factory)
get_settings.cache_clear()
app = FastAPI()
scheduler = create_scheduler(app)
job = scheduler.get_job("personalization_retention_cleanup")
assert (job is not None) is job_expected
if job is None:
return
assert job.trigger.interval == timedelta(minutes=1)
with session_factory() as db:
expired = _conversation(db, "scheduled")
db.add(
AIConversationMessage(
conversation_id=expired.id,
role="user",
content="expired scheduled message",
created_at=utc_now() - timedelta(days=31),
)
)
db.commit()
expired_id = expired.id
job.func()
assert app.state.last_personalization_retention_cleanup == {
"conversation_messages": 1,
"conversations": 1,
}
with session_factory() as db:
assert db.get(AIConversation, expired_id) is None
def _conversation(db: Session, suffix: str) -> AIConversation:
owner = FeishuUser(
code=f"FSU-{suffix}",
tenant_key=f"tenant-{suffix}",
open_id=f"open-{suffix}",
)
db.add(owner)
db.flush()
conversation = AIConversation(
owner_id=owner.id,
chat_type="private",
chat_key=f"chat-{suffix}",
)
db.add(conversation)
db.flush()
return conversation