```
feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
This commit is contained in:
267
app/application/feishu/handlers/personal_data.py
Normal file
267
app/application/feishu/handlers/personal_data.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user