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,267 @@
import re
from collections import Counter
from typing import Any
from fastapi import HTTPException
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.application.feishu.delivery import send_text_if_configured
from app.application.feishu.personal_data import FeishuPersonalDataService
from app.application.feishu.results import command_result
from app.modules.ai_memory.constants import AIMemoryKind
from app.modules.ai_memory.models import AIMemoryEntry
from app.modules.audit.constants import AuditRiskLevel, AuditSource
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.business.models import MarketWatchlist
from app.modules.feishu.constants import FeishuCommandName, FeishuReplyType
from app.modules.feishu.service import FeishuService
from app.modules.feishu_users.principal import FeishuPrincipal
from app.modules.personalization.constants import PreferenceCategory
from app.modules.personalization.models import AIConversation
from app.modules.personalization.services import PreferenceService
from app.modules.subscriptions.models import PushSubscription
PERSONAL_DATA_TITLE = "我的数据"
_CONFIRM_PATTERN = re.compile(r"^确认忘记我\s+([A-Fa-f0-9]{8})$")
_COMMAND_PREFIXES = ("我的数据", "忘记我", "确认忘记我")
def handle_personal_data_command(
db: Session,
feishu: FeishuService,
command_text: str,
principal: FeishuPrincipal,
auto_reply: bool,
) -> dict[str, Any] | None:
"""Handle private data summaries and two-step account erasure."""
text = command_text.strip()
if not text.startswith(_COMMAND_PREFIXES):
return None
principal.require_active()
if principal.chat_type in {"group", "group_chat"}:
return _result(
feishu,
FeishuCommandName.PERSONAL_DATA_SUMMARY,
"为保护个人信息,请私聊机器人使用“我的数据”或“忘记我”。",
principal,
auto_reply,
)
if text == "我的数据":
content = _summary(db, principal)
_audit_summary(db, principal)
return _result(
feishu,
FeishuCommandName.PERSONAL_DATA_SUMMARY,
content,
principal,
auto_reply,
)
if text == "忘记我":
confirmation = FeishuPersonalDataService(db).request_confirmation(principal)
_audit_erasure_request(db, principal)
content = (
"该操作会永久删除你的个人规则、记忆、偏好、兴趣、"
"会话、订阅和飞书身份映射。\n"
f"确认码:{confirmation.confirmation_code}\n"
f"有效期至:{confirmation.expires_at.strftime('%Y-%m-%d %H:%M:%S')} UTC\n"
f"确认命令:确认忘记我 {confirmation.confirmation_code}"
)
return _result(
feishu,
FeishuCommandName.PERSONAL_DATA_ERASURE_REQUEST,
content,
principal,
auto_reply,
)
match = _CONFIRM_PATTERN.fullmatch(text)
if match is None:
return _result(
feishu,
FeishuCommandName.PERSONAL_DATA_ERASURE_CONFIRM,
"格式不正确。请先发送“忘记我”获取一次性确认码。",
principal,
auto_reply,
)
try:
erased = FeishuPersonalDataService(db).confirm(principal, match.group(1))
except HTTPException as exc:
db.rollback()
content = (
"不能删除最后一个有效管理员,请先设置另一名管理员。"
if exc.status_code == 409
else "确认码无效或已过期,请重新发送“忘记我”。"
)
return _result(
feishu,
FeishuCommandName.PERSONAL_DATA_ERASURE_CONFIRM,
content,
principal,
auto_reply,
)
return _result(
feishu,
FeishuCommandName.PERSONAL_DATA_ERASURE_CONFIRM,
"你的个人数据和飞书身份映射已删除。再次联系时会创建新的普通用户身份。",
principal,
auto_reply,
audit_actor=erased.anonymous_id,
)
def _summary(db: Session, principal: FeishuPrincipal) -> str:
owner_id = principal.owner_id
preferences = PreferenceService(db).list_preferences(owner_id)
interest_categories = {
PreferenceCategory.TOPIC,
PreferenceCategory.INTEREST,
}
profile_preferences = [
item for item in preferences if item["category"] not in interest_categories
]
interest_preferences = [
item for item in preferences if item["category"] in interest_categories
]
watchlist = list(
db.execute(
select(MarketWatchlist.symbol)
.where(
MarketWatchlist.owner_id == owner_id,
MarketWatchlist.enabled.is_(True),
)
.order_by(MarketWatchlist.symbol.asc())
).scalars()
)
subscription_statuses = Counter(
db.execute(
select(PushSubscription.status).where(
PushSubscription.owner_id == owner_id
)
).scalars()
)
personal_rule_count = _memory_count(
db,
owner_id,
AIMemoryKind.PERSONAL_RULE,
)
memory_count = _memory_count(db, owner_id, AIMemoryKind.MEMORY)
conversation_count = int(
db.scalar(
select(func.count())
.select_from(AIConversation)
.where(AIConversation.owner_id == owner_id)
)
or 0
)
preference_text = "".join(
f"{item['category']}={item['value']}" for item in profile_preferences[:10]
)
interest_values = [
*(str(item["value"]) for item in interest_preferences),
*watchlist,
]
interest_text = "".join(interest_values[:10])
subscriptions_text = "".join(
f"{status} {count}" for status, count in sorted(subscription_statuses.items())
)
return "\n".join(
[
"我的个人数据摘要:",
f"个人规则:{personal_rule_count}",
f"偏好:{preference_text or '暂无'}",
f"兴趣与自选:{interest_text or '暂无'}",
f"个人记忆:{memory_count}",
f"会话:{conversation_count}",
f"订阅:{subscriptions_text or '暂无'}",
"可发送“我的偏好”“查看规则”“我的订阅”查看明细。",
]
)
def _memory_count(
db: Session,
owner_id: int,
kind: str,
) -> int:
return int(
db.scalar(
select(func.count())
.select_from(AIMemoryEntry)
.where(
AIMemoryEntry.owner_id == owner_id,
AIMemoryEntry.kind == kind,
)
)
or 0
)
def _audit_summary(db: Session, principal: FeishuPrincipal) -> None:
_audit(
db,
principal,
action="personalization.data.summary",
risk_level=AuditRiskLevel.LOW,
)
def _audit_erasure_request(db: Session, principal: FeishuPrincipal) -> None:
_audit(
db,
principal,
action="personalization.erasure.request",
risk_level=AuditRiskLevel.HIGH,
)
def _audit(
db: Session,
principal: FeishuPrincipal,
*,
action: str,
risk_level: str,
) -> None:
AuditService(db).record(
AuditLogCreate(
actor=principal.user_code,
source=AuditSource.FEISHU,
action=action,
target_type="feishu-user",
target_id=principal.user_code,
risk_level=risk_level,
response_payload={"result": "success"},
)
)
db.commit()
def _result(
feishu: FeishuService,
command: FeishuCommandName,
content: str,
principal: FeishuPrincipal,
auto_reply: bool,
*,
audit_actor: str | None = None,
) -> dict[str, Any]:
response = (
send_text_if_configured(
feishu,
principal.chat_id,
content,
audit_actor or principal.user_code,
record_audit=audit_actor is None,
)
if auto_reply
else None
)
return command_result(
command,
FeishuReplyType.TEXT,
PERSONAL_DATA_TITLE,
content,
response,
)