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,365 @@
from datetime import timedelta
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine, select, update
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import get_settings
from app.core.database import Base
from app.core.utils.time import utc_now
from app.modules.ai_memory.constants import AIMemoryScope
from app.modules.ai_memory.models import AIMemoryEntry
from app.modules.ai_memory.service import AIMemoryService
from app.modules.business.models import MarketWatchlist
from app.modules.feishu_users.models import FeishuUser
from app.modules.market.service import MarketService
from app.modules.personalization.constants import (
CONVERSATION_MAX_MESSAGES,
PersonalizationContextKey,
)
from app.modules.personalization.models import (
AIConversation,
AIConversationMessage,
PersonalDataErasureRequest,
UserPreference,
)
from app.modules.personalization.services import (
ConversationService,
PersonalDataErasureService,
PersonalizationContextService,
PreferenceService,
)
def _session_factory():
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
return engine, sessionmaker(bind=engine, expire_on_commit=False)
def _user(db: Session, suffix: str, *, open_id: str | None = None) -> FeishuUser:
record = FeishuUser(
code=f"USR-{suffix}",
tenant_key=f"tenant-{suffix}",
open_id=open_id or f"open-{suffix}",
)
db.add(record)
db.commit()
db.refresh(record)
return record
def test_rules_memory_and_watchlists_are_owner_scoped(monkeypatch) -> None:
monkeypatch.setenv("AI_MEMORY_ENABLED", "true")
monkeypatch.setenv("AI_MEMORY_AUTO_WRITE_ENABLED", "true")
get_settings.cache_clear()
engine, factory = _session_factory()
try:
with factory() as db:
first = _user(db, "first", open_id="same-open-id")
second = _user(db, "second", open_id="same-open-id")
memory = AIMemoryService(db)
company_rule = memory.create_rule(
content="公司规则",
scope=AIMemoryScope.GLOBAL,
subject="company",
priority=90,
tags=[],
actor="service",
)
first_rule = memory.create_rule(
content="相同个人规则",
scope=AIMemoryScope.USER,
subject="profile",
priority=50,
tags=[],
actor="first",
owner_id=first.id,
)
second_rule = memory.create_rule(
content="相同个人规则",
scope=AIMemoryScope.USER,
subject="profile",
priority=50,
tags=[],
actor="second",
owner_id=second.id,
)
assert [item["code"] for item in memory.list_rules()] == [company_rule["code"]]
assert [item["code"] for item in memory.list_rules(owner_id=first.id)] == [
first_rule["code"]
]
assert [item["code"] for item in memory.list_rules(owner_id=second.id)] == [
second_rule["code"]
]
with pytest.raises(HTTPException) as exc_info:
memory.update_rule(
code=second_rule["code"],
content="越权修改",
priority=None,
tags=None,
enabled=None,
actor="first",
owner_id=first.id,
)
assert exc_info.value.status_code == 404
first_memory = memory.auto_write(
prompt="Remember concise project risk summaries",
context={"scope": "user", "subject": "profile"},
answer="Use concise project risk summaries.",
actor="first",
owner_id=first.id,
)
second_memory = memory.auto_write(
prompt="Remember concise project risk summaries",
context={"scope": "user", "subject": "profile"},
answer="Use concise project risk summaries.",
actor="second",
owner_id=second.id,
)
assert first_memory is not None
assert second_memory is not None
assert first_memory.code != second_memory.code
assert first_memory.fingerprint != second_memory.fingerprint
market = MarketService(db)
market.add_watchlist("same-open-id", "600000", owner_id=first.id)
market.add_watchlist("same-open-id", "600000", owner_id=second.id)
assert market.watchlist("ignored", owner_id=first.id) == [{"symbol": "600000.SH"}]
assert market.watchlist("ignored", owner_id=second.id) == [{"symbol": "600000.SH"}]
market.add_watchlist("legacy-open", "000001")
assert market.watchlist("legacy-open") == [{"symbol": "000001.SZ"}]
assert market.claim_legacy_watchlist(first.id, "legacy-open") == 1
assert market.watchlist("legacy-open") == []
assert market.watchlist("ignored", owner_id=first.id) == [
{"symbol": "600000.SH"},
{"symbol": "000001.SZ"},
]
finally:
get_settings.cache_clear()
engine.dispose()
def test_preferences_are_allowlisted_sensitive_safe_and_owner_scoped() -> None:
engine, factory = _session_factory()
try:
with factory() as db:
first = _user(db, "pref-first")
second = _user(db, "pref-second")
service = PreferenceService(db)
first_pref = service.upsert(first.id, "tone", "简洁直接")
second_pref = service.upsert(second.id, "tone", "简洁直接")
assert first_pref["code"] != second_pref["code"]
assert service.list_preferences(first.id) == [first_pref]
with pytest.raises(HTTPException) as exc_info:
service.delete(first.id, second_pref["code"])
assert exc_info.value.status_code == 404
with pytest.raises(HTTPException) as exc_info:
service.upsert(first.id, "topic", "记住我的银行账号 123456")
assert exc_info.value.status_code == 422
assert (
service.save_auto_extraction(
first.id,
provider_name="noop",
user_text="以后请用中文",
structured_payload={
"preferences": [{"category": "language", "value": "中文"}]
},
)
== []
)
assert (
service.save_auto_extraction(
first.id,
provider_name="direct_llm",
user_text="今天怎么样",
structured_payload={
"preferences": [{"category": "language", "value": "中文"}]
},
)
== []
)
saved = service.save_auto_extraction(
first.id,
provider_name="direct_llm",
user_text="以后请用中文,并记住我的健康诊断",
structured_payload={
"preferences": [
{"category": "language", "value": "中文"},
{"category": "topic", "value": "我的健康诊断"},
]
},
)
assert [(item["category"], item["value"]) for item in saved] == [
("language", "中文")
]
finally:
engine.dispose()
def test_conversations_keep_twenty_turns_and_isolate_sessions() -> None:
engine, factory = _session_factory()
try:
with factory() as db:
first = _user(db, "conversation-first")
second = _user(db, "conversation-second")
service = ConversationService(db)
assert (
service.record_turn(
second.id,
"private",
"chat-1",
user_content="placeholder",
assistant_content="placeholder",
provider_name="noop",
)
is False
)
for index in range(21):
assert service.record_turn(
first.id,
"private",
"chat-1",
user_content=f"user-{index}",
assistant_content=f"assistant-{index}",
provider_name="direct_llm",
)
history = service.history(first.id, "private", "chat-1")
assert len(history) == CONVERSATION_MAX_MESSAGES
assert history[0]["content"] == "user-1"
assert history[-1]["content"] == "assistant-20"
assert service.history(second.id, "private", "chat-1") == []
assert service.provider_session_id(first.id, "private", "chat-1") == (
service.provider_session_id(first.id, "p2p", "chat-1")
)
assert service.provider_session_id(first.id, "group", "chat-1") != (
service.provider_session_id(second.id, "group", "chat-1")
)
conversation = db.execute(
select(AIConversation).where(AIConversation.owner_id == first.id)
).scalar_one()
db.execute(
update(AIConversationMessage)
.where(AIConversationMessage.conversation_id == conversation.id)
.values(created_at=utc_now() - timedelta(days=31))
)
db.commit()
assert service.history(first.id, "private", "chat-1") == []
finally:
engine.dispose()
def test_context_order_and_erasure_core_hooks() -> None:
engine, factory = _session_factory()
try:
with factory() as db:
owner = _user(db, "context-owner")
other = _user(db, "context-other")
memory = AIMemoryService(db)
memory.create_rule(
content="公司规则优先",
scope="global",
subject="company",
priority=100,
tags=[],
actor="service",
)
memory.create_rule(
content="个人规则",
scope="global",
subject="profile",
priority=80,
tags=[],
actor="owner",
owner_id=owner.id,
)
PreferenceService(db).upsert(owner.id, "tone", "简洁")
PreferenceService(db).upsert(owner.id, "interest", "人工智能")
MarketService(db).add_watchlist("owner-open", "600000", owner_id=owner.id)
ConversationService(db).record_turn(
owner.id,
"private",
"chat-context",
user_content="上一问",
assistant_content="上一答",
provider_name="direct_llm",
)
context = PersonalizationContextService(db).build(
owner_id=owner.id,
request="当前请求",
system_constraints="只读安全规则",
chat_type="private",
chat_key="chat-context",
subject="profile",
actor="owner",
)
assert [str(key) for key in context.as_ordered_dict()] == [
PersonalizationContextKey.SYSTEM_CONSTRAINTS,
PersonalizationContextKey.COMPANY_RULES,
PersonalizationContextKey.PERSONAL_RULES,
PersonalizationContextKey.CURRENT_REQUEST,
PersonalizationContextKey.PREFERENCES,
PersonalizationContextKey.INTERESTS,
PersonalizationContextKey.PERSONAL_MEMORY,
PersonalizationContextKey.CONVERSATION_HISTORY,
]
assert context.company_rules[0]["rule"] == "公司规则优先"
assert context.personal_rules[0]["rule"] == "个人规则"
assert {item["value"] for item in context.interests} == {
"人工智能",
"600000.SH",
}
assert context.conversation_history[-1]["content"] == "上一答"
confirmation = PersonalDataErasureService(db).request_confirmation(owner.id)
with pytest.raises(HTTPException):
PersonalDataErasureService(db).confirm_and_erase(owner.id, "BAD-CODE")
seen: dict[str, str | int] = {}
def integration_hook(
_db: Session,
owner_id: int,
anonymous_id: str,
) -> dict[str, int]:
seen.update(owner_id=owner_id, anonymous_id=anonymous_id)
return {"subscriptions": 0}
erased = PersonalDataErasureService(db).confirm_and_erase(
owner.id,
confirmation.confirmation_code,
extra_hooks=(integration_hook,),
)
assert erased.deleted["preferences"] == 2
assert erased.deleted["conversations"] == 1
assert erased.deleted["ai_memory"] == 1
assert erased.deleted["watchlist"] == 1
assert seen["owner_id"] == owner.id
assert seen["anonymous_id"] == erased.anonymous_id
assert db.scalar(
select(UserPreference).where(UserPreference.owner_id == owner.id)
) is None
assert db.scalar(
select(AIMemoryEntry).where(AIMemoryEntry.owner_id == owner.id)
) is None
assert db.scalar(
select(MarketWatchlist).where(MarketWatchlist.owner_id == owner.id)
) is None
assert db.scalar(
select(PersonalDataErasureRequest).where(
PersonalDataErasureRequest.owner_id == owner.id
)
) is None
assert db.get(FeishuUser, other.id) is not None
assert memory.active_rules()[0]["rule"] == "公司规则优先"
finally:
engine.dispose()