Files
JiuContinent d7db84571d ```
feat: 添加飞书用户模块和订阅功能支持

- 新增feishu_users模块用于处理飞书用户身份验证和权限管理
- 新增subscriptions模块用于处理订阅相关功能
- 新增personalization模块用于个性化服务
- 在alembic迁移配置中注册新的模型模块
- 在API路由器中添加feishu_users和subscriptions路由
- 实现事件调度服务的改进,包括错误处理和状态更新优化
- 添加飞书命令处理的权限检查机制
- 实现飞书应用票据事件处理
- 改进审计日志记录功能
```
2026-07-27 08:02:17 +08:00

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