feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
206 lines
6.9 KiB
Python
206 lines
6.9 KiB
Python
from collections.abc import Callable, Mapping, Sequence
|
|
from datetime import timedelta
|
|
from hashlib import sha256
|
|
from hmac import compare_digest
|
|
from secrets import token_hex
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from fastapi import HTTPException, status
|
|
from sqlalchemy import delete, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.utils.time import utc_now
|
|
from app.modules.ai_memory.models import AIMemoryEntry
|
|
from app.modules.business.models import MarketWatchlist
|
|
from app.modules.personalization.constants import ERASURE_CONFIRMATION_TTL_MINUTES
|
|
from app.modules.personalization.models import (
|
|
AIConversation,
|
|
AIConversationMessage,
|
|
PersonalDataErasureRequest,
|
|
UserPreference,
|
|
)
|
|
from app.modules.personalization.schemas import ErasureConfirmation, ErasureResult
|
|
|
|
ErasureHook = Callable[
|
|
[Session, int, str],
|
|
int | Mapping[str, int] | None,
|
|
]
|
|
|
|
|
|
class PersonalDataErasureService:
|
|
"""Issue one-time confirmations and erase owner data in one transaction."""
|
|
|
|
def __init__(self, db: Session):
|
|
self.db = db
|
|
|
|
def request_confirmation(
|
|
self,
|
|
owner_id: int,
|
|
*,
|
|
ttl_minutes: int = ERASURE_CONFIRMATION_TTL_MINUTES,
|
|
) -> ErasureConfirmation:
|
|
_validate_owner(owner_id)
|
|
if ttl_minutes <= 0 or ttl_minutes > 60:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="Invalid erasure confirmation lifetime",
|
|
)
|
|
confirmation_code = token_hex(4).upper()
|
|
now = utc_now()
|
|
expires_at = now + timedelta(minutes=ttl_minutes)
|
|
record = self.db.execute(
|
|
select(PersonalDataErasureRequest).where(
|
|
PersonalDataErasureRequest.owner_id == owner_id
|
|
)
|
|
).scalar_one_or_none()
|
|
if record is None:
|
|
record = PersonalDataErasureRequest(
|
|
owner_id=owner_id,
|
|
token_hash=_token_hash(owner_id, confirmation_code),
|
|
expires_at=expires_at,
|
|
)
|
|
self.db.add(record)
|
|
else:
|
|
record.token_hash = _token_hash(owner_id, confirmation_code)
|
|
record.expires_at = expires_at
|
|
record.updated_at = now
|
|
self.db.commit()
|
|
return ErasureConfirmation(
|
|
confirmation_code=confirmation_code,
|
|
expires_at=expires_at,
|
|
)
|
|
|
|
def confirm_and_erase(
|
|
self,
|
|
owner_id: int,
|
|
confirmation_code: str,
|
|
*,
|
|
before_hooks: Sequence[ErasureHook] = (),
|
|
extra_hooks: Sequence[ErasureHook] = (),
|
|
) -> ErasureResult:
|
|
"""Erase core personal tables and run integration hooks before one commit."""
|
|
|
|
_validate_owner(owner_id)
|
|
request = self.db.execute(
|
|
select(PersonalDataErasureRequest)
|
|
.where(PersonalDataErasureRequest.owner_id == owner_id)
|
|
.with_for_update()
|
|
).scalar_one_or_none()
|
|
if request is None or request.expires_at <= utc_now():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="Erasure confirmation is invalid or expired",
|
|
)
|
|
supplied_hash = _token_hash(owner_id, confirmation_code.strip().upper())
|
|
if not compare_digest(supplied_hash, request.token_hash):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="Erasure confirmation is invalid or expired",
|
|
)
|
|
|
|
anonymous_id = f"anonymous-{uuid4().hex}"
|
|
deleted: dict[str, int] = {}
|
|
extra: dict[str, Any] = {}
|
|
try:
|
|
for index, hook in enumerate(before_hooks):
|
|
_merge_hook_result(
|
|
hook(self.db, owner_id, anonymous_id),
|
|
index=index,
|
|
prefix="before",
|
|
deleted=deleted,
|
|
extra=extra,
|
|
)
|
|
conversation_ids = select(AIConversation.id).where(
|
|
AIConversation.owner_id == owner_id
|
|
)
|
|
deleted["conversation_messages"] = _row_count(
|
|
self.db.execute(
|
|
delete(AIConversationMessage).where(
|
|
AIConversationMessage.conversation_id.in_(conversation_ids)
|
|
)
|
|
).rowcount
|
|
)
|
|
deleted["conversations"] = _row_count(
|
|
self.db.execute(
|
|
delete(AIConversation).where(AIConversation.owner_id == owner_id)
|
|
).rowcount
|
|
)
|
|
deleted["preferences"] = _row_count(
|
|
self.db.execute(
|
|
delete(UserPreference).where(UserPreference.owner_id == owner_id)
|
|
).rowcount
|
|
)
|
|
deleted["ai_memory"] = _row_count(
|
|
self.db.execute(
|
|
delete(AIMemoryEntry).where(AIMemoryEntry.owner_id == owner_id)
|
|
).rowcount
|
|
)
|
|
deleted["watchlist"] = _row_count(
|
|
self.db.execute(
|
|
delete(MarketWatchlist).where(MarketWatchlist.owner_id == owner_id)
|
|
).rowcount
|
|
)
|
|
self.db.delete(request)
|
|
|
|
for index, hook in enumerate(extra_hooks):
|
|
_merge_hook_result(
|
|
hook(self.db, owner_id, anonymous_id),
|
|
index=index,
|
|
prefix="extra",
|
|
deleted=deleted,
|
|
extra=extra,
|
|
)
|
|
self.db.commit()
|
|
except Exception:
|
|
self.db.rollback()
|
|
raise
|
|
return ErasureResult(
|
|
anonymous_id=anonymous_id,
|
|
deleted=deleted,
|
|
extra=extra,
|
|
)
|
|
|
|
def purge_expired_confirmations(self) -> int:
|
|
result = self.db.execute(
|
|
delete(PersonalDataErasureRequest).where(
|
|
PersonalDataErasureRequest.expires_at <= utc_now()
|
|
)
|
|
)
|
|
count = _row_count(result.rowcount)
|
|
self.db.commit()
|
|
return count
|
|
|
|
|
|
def _token_hash(owner_id: int, confirmation_code: str) -> str:
|
|
return sha256(f"{owner_id}\0{confirmation_code}".encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _validate_owner(owner_id: int) -> None:
|
|
if owner_id <= 0:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="Valid erasure owner is required",
|
|
)
|
|
|
|
|
|
def _row_count(value: int | None) -> int:
|
|
return max(0, int(value or 0))
|
|
|
|
|
|
def _merge_hook_result(
|
|
hook_result: int | Mapping[str, int] | None,
|
|
*,
|
|
index: int,
|
|
prefix: str,
|
|
deleted: dict[str, int],
|
|
extra: dict[str, Any],
|
|
) -> None:
|
|
if isinstance(hook_result, Mapping):
|
|
for key, value in hook_result.items():
|
|
deleted[str(key)] = int(value)
|
|
elif isinstance(hook_result, int):
|
|
deleted[f"{prefix}_{index}"] = hook_result
|
|
elif hook_result is not None:
|
|
extra[f"{prefix}_{index}"] = hook_result
|