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