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