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

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

View File

@@ -0,0 +1,23 @@
from app.modules.personalization.models import (
AIConversation,
AIConversationMessage,
PersonalDataErasureRequest,
UserPreference,
)
from app.modules.personalization.services import (
ConversationService,
PersonalDataErasureService,
PersonalizationContextService,
PreferenceService,
)
__all__ = [
"AIConversation",
"AIConversationMessage",
"ConversationService",
"PersonalDataErasureRequest",
"PersonalDataErasureService",
"PersonalizationContextService",
"PreferenceService",
"UserPreference",
]

View File

@@ -0,0 +1,101 @@
from enum import StrEnum
class PreferenceCategory(StrEnum):
LANGUAGE = "language"
TONE = "tone"
DETAIL = "detail"
TOPIC = "topic"
INTEREST = "interest"
class PreferenceSource(StrEnum):
EXPLICIT = "explicit"
AUTO = "auto"
class ConversationRole(StrEnum):
USER = "user"
ASSISTANT = "assistant"
class ConversationChatType(StrEnum):
PRIVATE = "private"
GROUP = "group"
class PersonalizationContextKey(StrEnum):
SYSTEM_CONSTRAINTS = "system_constraints"
COMPANY_RULES = "company_rules"
PERSONAL_RULES = "personal_rules"
CURRENT_REQUEST = "current_request"
PREFERENCES = "preferences"
INTERESTS = "interests"
PERSONAL_MEMORY = "personal_memory"
CONVERSATION_HISTORY = "conversation_history"
PREFERENCE_CODE_PREFIX = "PREF"
CONVERSATION_CODE_PREFIX = "CONV"
PREFERENCE_MAX_VALUE_LENGTH = 1000
CONVERSATION_MAX_CONTENT_LENGTH = 20_000
CONVERSATION_RETENTION_DAYS = 30
CONVERSATION_MAX_TURNS = 20
CONVERSATION_MAX_MESSAGES = CONVERSATION_MAX_TURNS * 2
ERASURE_CONFIRMATION_TTL_MINUTES = 10
UNAVAILABLE_AI_PROVIDERS = frozenset({"", "noop"})
PREFERENCE_SIGNAL_TERMS = (
"以后",
"记住",
"偏好",
"喜欢",
"希望",
"请用",
"请保持",
"关注",
"感兴趣",
"prefer",
"preference",
"i like",
"interested in",
)
# These terms identify categories that must never become an inferred personal profile.
# General topics such as public market news remain allowed; the financial terms below are
# intentionally limited to private account, compensation, and confidential-company facts.
SENSITIVE_PREFERENCE_TERMS = (
"api key",
"api_key",
"access token",
"access_token",
"password",
"secret",
"token",
"密码",
"密钥",
"令牌",
"健康",
"病史",
"疾病",
"诊断",
"医疗记录",
"宗教",
"信仰",
"政治立场",
"党派",
"选举倾向",
"性取向",
"同性恋",
"异性恋",
"绩效",
"考核结果",
"银行账号",
"银行卡",
"工资",
"薪资",
"个人收入",
"财务秘密",
"未公开财务",
"保密预算",
)

View File

@@ -0,0 +1,131 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.core.utils.time import utc_now
from app.modules.feishu_users.models import FeishuUser
from app.modules.personalization.constants import (
CONVERSATION_CODE_PREFIX,
PREFERENCE_CODE_PREFIX,
PreferenceSource,
)
def _public_code(prefix: str) -> str:
return f"{prefix}-{uuid4().hex}"
class UserPreference(Base):
__tablename__ = "user_preferences"
__table_args__ = (
UniqueConstraint(
"owner_id",
"category",
"normalized_value",
name="uq_user_preference_owner_category_value",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(
String(64),
default=lambda: _public_code(PREFERENCE_CODE_PREFIX),
unique=True,
index=True,
)
owner_id: Mapped[int] = mapped_column(
ForeignKey("feishu_users.id", ondelete="CASCADE"),
index=True,
)
owner: Mapped[FeishuUser] = relationship()
category: Mapped[str] = mapped_column(String(32), index=True)
value: Mapped[str] = mapped_column(Text)
normalized_value: Mapped[str] = mapped_column(String(1000))
source: Mapped[str] = mapped_column(
String(32),
default=PreferenceSource.EXPLICIT,
index=True,
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
default=utc_now,
onupdate=utc_now,
)
class AIConversation(Base):
__tablename__ = "ai_conversations"
__table_args__ = (
UniqueConstraint(
"owner_id",
"chat_type",
"chat_key",
name="uq_ai_conversation_owner_chat",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(
String(64),
default=lambda: _public_code(CONVERSATION_CODE_PREFIX),
unique=True,
index=True,
)
owner_id: Mapped[int] = mapped_column(
ForeignKey("feishu_users.id", ondelete="CASCADE"),
index=True,
)
owner: Mapped[FeishuUser] = relationship()
chat_type: Mapped[str] = mapped_column(String(32), index=True)
chat_key: Mapped[str] = mapped_column(String(256), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
default=utc_now,
onupdate=utc_now,
index=True,
)
messages: Mapped[list["AIConversationMessage"]] = relationship(
back_populates="conversation",
cascade="all, delete-orphan",
passive_deletes=True,
order_by="AIConversationMessage.id",
)
class AIConversationMessage(Base):
__tablename__ = "ai_conversation_messages"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
conversation_id: Mapped[int] = mapped_column(
ForeignKey("ai_conversations.id", ondelete="CASCADE"),
index=True,
)
role: Mapped[str] = mapped_column(String(32), index=True)
content: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
conversation: Mapped[AIConversation] = relationship(back_populates="messages")
class PersonalDataErasureRequest(Base):
__tablename__ = "personal_data_erasure_requests"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("feishu_users.id", ondelete="CASCADE"),
unique=True,
index=True,
)
owner: Mapped[FeishuUser] = relationship()
token_hash: Mapped[str] = mapped_column(String(64))
expires_at: Mapped[datetime] = mapped_column(DateTime, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
default=utc_now,
onupdate=utc_now,
)

View File

@@ -0,0 +1,65 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from app.modules.personalization.constants import PreferenceCategory, PreferenceSource
class PreferenceCreate(BaseModel):
category: PreferenceCategory
value: str = Field(..., min_length=1, max_length=1000)
source: PreferenceSource = PreferenceSource.EXPLICIT
class PreferenceUpdate(BaseModel):
category: PreferenceCategory | None = None
value: str | None = Field(default=None, min_length=1, max_length=1000)
class PreferenceRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
code: str
category: str
value: str
source: str
created_at: datetime
updated_at: datetime
class ExtractedPreference(BaseModel):
category: PreferenceCategory
value: str = Field(..., min_length=1, max_length=1000)
class PreferenceExtractionPayload(BaseModel):
preferences: list[ExtractedPreference] = Field(default_factory=list, max_length=20)
class ConversationMessageRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
role: str
content: str
created_at: datetime
class ConversationRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
code: str
chat_type: str
chat_key: str
messages: list[ConversationMessageRead] = Field(default_factory=list)
class ErasureConfirmation(BaseModel):
confirmation_code: str
expires_at: datetime
class ErasureResult(BaseModel):
anonymous_id: str
deleted: dict[str, int] = Field(default_factory=dict)
extra: dict[str, Any] = Field(default_factory=dict)

View File

@@ -0,0 +1,15 @@
from app.modules.personalization.services.context import (
PersonalizationContext,
PersonalizationContextService,
)
from app.modules.personalization.services.conversations import ConversationService
from app.modules.personalization.services.erasure import PersonalDataErasureService
from app.modules.personalization.services.preferences import PreferenceService
__all__ = [
"ConversationService",
"PersonalDataErasureService",
"PersonalizationContext",
"PersonalizationContextService",
"PreferenceService",
]

View File

@@ -0,0 +1,191 @@
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.constants import ActorValue
from app.modules.ai_memory.constants import AIMemoryScope
from app.modules.ai_memory.service import AIMemoryService
from app.modules.business.models import MarketWatchlist
from app.modules.personalization.constants import (
PersonalizationContextKey,
PreferenceCategory,
)
from app.modules.personalization.services.conversations import ConversationService
from app.modules.personalization.services.preferences import PreferenceService
@dataclass(frozen=True)
class PersonalizationContext:
"""Ordered, provider-neutral context sections for one AI request."""
system_constraints: str
company_rules: list[dict[str, Any]]
personal_rules: list[dict[str, Any]]
current_request: str
preferences: list[dict[str, Any]]
interests: list[dict[str, Any]]
personal_memory: list[dict[str, Any]]
conversation_history: list[dict[str, Any]]
provider_session_id: str | None = field(default=None)
def as_ordered_dict(self) -> dict[str, Any]:
return {
PersonalizationContextKey.SYSTEM_CONSTRAINTS: self.system_constraints,
PersonalizationContextKey.COMPANY_RULES: self.company_rules,
PersonalizationContextKey.PERSONAL_RULES: self.personal_rules,
PersonalizationContextKey.CURRENT_REQUEST: self.current_request,
PersonalizationContextKey.PREFERENCES: self.preferences,
PersonalizationContextKey.INTERESTS: self.interests,
PersonalizationContextKey.PERSONAL_MEMORY: self.personal_memory,
PersonalizationContextKey.CONVERSATION_HISTORY: self.conversation_history,
}
class PersonalizationContextService:
"""Load only the explicitly requested company and owner-scoped context layers."""
def __init__(self, db: Session):
self.db = db
self.memory = AIMemoryService(db)
self.preferences = PreferenceService(db)
self.conversations = ConversationService(db)
def build(
self,
*,
owner_id: int | None,
request: str,
system_constraints: str,
chat_type: str | None = None,
chat_key: str | None = None,
scope: str = AIMemoryScope.GLOBAL,
subject: str | None = None,
actor: str = ActorValue.SYSTEM,
include_company_rules: bool = True,
include_personal_context: bool = True,
include_history: bool = True,
) -> PersonalizationContext:
company_rules = (
self.memory.active_rules(
scope=scope,
subject=subject,
owner_id=None,
)
if include_company_rules
else []
)
personal_rules: list[dict[str, Any]] = []
preference_items: list[dict[str, Any]] = []
interests: list[dict[str, Any]] = []
personal_memory: list[dict[str, Any]] = []
history: list[dict[str, Any]] = []
provider_session_id: str | None = None
if owner_id is not None and include_personal_context:
personal_rules = self.memory.active_rules(
scope=scope,
subject=subject,
owner_id=owner_id,
)
all_preferences = self.preferences.list_preferences(owner_id)
interest_categories = {
PreferenceCategory.TOPIC,
PreferenceCategory.INTEREST,
}
for preference in all_preferences:
if preference["category"] in interest_categories:
interests.append(preference)
else:
preference_items.append(preference)
interests.extend(self._watchlist_interests(owner_id))
personal_memory = self.memory.recall(
query=request,
scope=scope,
subject=subject,
actor=actor,
owner_id=owner_id,
)
if include_history and chat_type and chat_key:
history = self.conversations.history(owner_id, chat_type, chat_key)
provider_session_id = self.conversations.provider_session_id(
owner_id,
chat_type,
chat_key,
)
return PersonalizationContext(
system_constraints=system_constraints,
company_rules=company_rules,
personal_rules=personal_rules,
current_request=request,
preferences=preference_items,
interests=interests,
personal_memory=personal_memory,
conversation_history=history,
provider_session_id=provider_session_id,
)
def build_private_scheduled(
self,
*,
owner_id: int,
request: str,
system_constraints: str,
scope: str = AIMemoryScope.GLOBAL,
subject: str | None = None,
actor: str = ActorValue.SYSTEM,
) -> PersonalizationContext:
"""Build private scheduled context without company data or conversation history."""
return self.build(
owner_id=owner_id,
request=request,
system_constraints=system_constraints,
scope=scope,
subject=subject,
actor=actor,
include_company_rules=False,
include_personal_context=True,
include_history=False,
)
def build_group_scheduled(
self,
*,
request: str,
system_constraints: str,
scope: str = AIMemoryScope.GLOBAL,
subject: str | None = None,
) -> PersonalizationContext:
"""Build group scheduled context without any creator profile."""
return self.build(
owner_id=None,
request=request,
system_constraints=system_constraints,
scope=scope,
subject=subject,
include_company_rules=True,
include_personal_context=False,
include_history=False,
)
def _watchlist_interests(self, owner_id: int) -> list[dict[str, Any]]:
symbols = self.db.execute(
select(MarketWatchlist.symbol)
.where(
MarketWatchlist.owner_id == owner_id,
MarketWatchlist.enabled.is_(True),
)
.order_by(MarketWatchlist.symbol.asc())
).scalars()
return [
{
"category": "watchlist",
"value": symbol,
"source": "market_watchlist",
}
for symbol in symbols
]

View File

@@ -0,0 +1,323 @@
from datetime import timedelta
from hashlib import sha256
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import delete, exists, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.utils.time import utc_now
from app.modules.personalization.constants import (
CONVERSATION_MAX_CONTENT_LENGTH,
CONVERSATION_MAX_MESSAGES,
CONVERSATION_RETENTION_DAYS,
UNAVAILABLE_AI_PROVIDERS,
ConversationChatType,
ConversationRole,
)
from app.modules.personalization.models import AIConversation, AIConversationMessage
class ConversationService:
"""Persist isolated Feishu conversations with bounded history."""
def __init__(self, db: Session):
self.db = db
def history(
self,
owner_id: int,
chat_type: str | ConversationChatType,
chat_key: str,
) -> list[dict[str, Any]]:
owner_id, chat_type_value, chat_key_value = _conversation_identity(
owner_id,
chat_type,
chat_key,
)
self.cleanup_expired(owner_id=owner_id)
conversation = self._find(owner_id, chat_type_value, chat_key_value)
if conversation is None:
self.db.commit()
return []
messages = list(
self.db.execute(
select(AIConversationMessage)
.where(AIConversationMessage.conversation_id == conversation.id)
.order_by(
AIConversationMessage.created_at.desc(),
AIConversationMessage.id.desc(),
)
.limit(CONVERSATION_MAX_MESSAGES)
).scalars()
)
self.db.commit()
messages.reverse()
return [_serialize_message(message) for message in messages]
def record_turn(
self,
owner_id: int,
chat_type: str | ConversationChatType,
chat_key: str,
*,
user_content: str,
assistant_content: str,
provider_name: str,
ai_available: bool = True,
) -> bool:
"""Record one complete turn only after a real AI answer succeeds."""
if not ai_available or provider_name.strip().lower() in UNAVAILABLE_AI_PROVIDERS:
return False
owner_id, chat_type_value, chat_key_value = _conversation_identity(
owner_id,
chat_type,
chat_key,
)
user_text = _message_content(user_content)
assistant_text = _message_content(assistant_content)
self.cleanup_expired(owner_id=owner_id)
conversation = self._get_or_create(
owner_id,
chat_type_value,
chat_key_value,
)
now = utc_now()
self.db.add_all(
[
AIConversationMessage(
conversation_id=conversation.id,
role=ConversationRole.USER,
content=user_text,
created_at=now,
),
AIConversationMessage(
conversation_id=conversation.id,
role=ConversationRole.ASSISTANT,
content=assistant_text,
created_at=now,
),
]
)
conversation.updated_at = now
self.db.flush()
self._trim(conversation.id)
self.db.commit()
return True
def reset(
self,
owner_id: int,
chat_type: str | ConversationChatType,
chat_key: str,
) -> bool:
owner_id, chat_type_value, chat_key_value = _conversation_identity(
owner_id,
chat_type,
chat_key,
)
conversation = self._find(owner_id, chat_type_value, chat_key_value)
if conversation is None:
return False
self.db.execute(
delete(AIConversationMessage).where(
AIConversationMessage.conversation_id == conversation.id
)
)
self.db.delete(conversation)
self.db.commit()
return True
def cleanup_expired(self, owner_id: int | None = None) -> int:
"""Stage retention cleanup for one owner or all owners.
The surrounding operation owns the transaction. Interactive history and
write paths use this method before completing their own commit.
"""
return self._cleanup_expired(owner_id)["conversation_messages"]
def cleanup_expired_globally(self) -> dict[str, int]:
"""Delete expired messages for every owner and commit the maintenance run."""
deleted = self._cleanup_expired(owner_id=None)
self.db.commit()
return deleted
def _cleanup_expired(self, owner_id: int | None) -> dict[str, int]:
cutoff = utc_now() - timedelta(days=CONVERSATION_RETENTION_DAYS)
conversation_ids = select(AIConversation.id)
if owner_id is not None:
conversation_ids = conversation_ids.where(AIConversation.owner_id == owner_id)
message_result = self.db.execute(
delete(AIConversationMessage).where(
AIConversationMessage.conversation_id.in_(conversation_ids),
AIConversationMessage.created_at < cutoff,
)
)
empty_conversations = delete(AIConversation).where(
~exists(
select(AIConversationMessage.id).where(
AIConversationMessage.conversation_id == AIConversation.id
)
)
)
if owner_id is not None:
empty_conversations = empty_conversations.where(
AIConversation.owner_id == owner_id
)
conversation_result = self.db.execute(empty_conversations)
return {
"conversation_messages": max(0, int(message_result.rowcount or 0)),
"conversations": max(0, int(conversation_result.rowcount or 0)),
}
def delete_owner_conversations(self, owner_id: int) -> dict[str, int]:
conversation_ids = select(AIConversation.id).where(
AIConversation.owner_id == owner_id
)
message_result = self.db.execute(
delete(AIConversationMessage).where(
AIConversationMessage.conversation_id.in_(conversation_ids)
)
)
conversation_result = self.db.execute(
delete(AIConversation).where(AIConversation.owner_id == owner_id)
)
return {
"conversation_messages": max(0, int(message_result.rowcount or 0)),
"conversations": max(0, int(conversation_result.rowcount or 0)),
}
@staticmethod
def provider_session_id(
owner_id: int,
chat_type: str | ConversationChatType,
chat_key: str,
) -> str:
owner_id, chat_type_value, chat_key_value = _conversation_identity(
owner_id,
chat_type,
chat_key,
)
digest = sha256(
f"{owner_id}\0{chat_type_value}\0{chat_key_value}".encode("utf-8")
).hexdigest()
return f"feishu-{digest}"
def _find(
self,
owner_id: int,
chat_type: str,
chat_key: str,
) -> AIConversation | None:
return self.db.execute(
select(AIConversation).where(
AIConversation.owner_id == owner_id,
AIConversation.chat_type == chat_type,
AIConversation.chat_key == chat_key,
)
).scalar_one_or_none()
def _get_or_create(
self,
owner_id: int,
chat_type: str,
chat_key: str,
) -> AIConversation:
existing = self._find(owner_id, chat_type, chat_key)
if existing is not None:
return existing
conversation = AIConversation(
owner_id=owner_id,
chat_type=chat_type,
chat_key=chat_key,
)
try:
with self.db.begin_nested():
self.db.add(conversation)
self.db.flush()
except IntegrityError:
conversation = self._find(owner_id, chat_type, chat_key)
if conversation is None:
raise
return conversation
def _trim(self, conversation_id: int) -> int:
stale_ids = list(
self.db.execute(
select(AIConversationMessage.id)
.where(AIConversationMessage.conversation_id == conversation_id)
.order_by(
AIConversationMessage.created_at.desc(),
AIConversationMessage.id.desc(),
)
.offset(CONVERSATION_MAX_MESSAGES)
).scalars()
)
if not stale_ids:
return 0
result = self.db.execute(
delete(AIConversationMessage).where(
AIConversationMessage.id.in_(stale_ids)
)
)
return max(0, int(result.rowcount or 0))
def _conversation_identity(
owner_id: int,
chat_type: str | ConversationChatType,
chat_key: str,
) -> tuple[int, str, str]:
if owner_id <= 0:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Valid conversation owner is required",
)
raw_chat_type = str(chat_type).strip().lower()
aliases = {
"p2p": ConversationChatType.PRIVATE,
"private": ConversationChatType.PRIVATE,
"group": ConversationChatType.GROUP,
"group_chat": ConversationChatType.GROUP,
}
try:
chat_type_value = str(aliases[raw_chat_type])
except KeyError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Unsupported conversation chat type",
) from exc
chat_key_value = str(chat_key).strip()
if not chat_key_value or len(chat_key_value) > 256:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Valid conversation chat key is required",
)
return owner_id, chat_type_value, chat_key_value
def _message_content(value: str) -> str:
content = str(value).strip()
if not content:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Conversation message is required",
)
if len(content) > CONVERSATION_MAX_CONTENT_LENGTH:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Conversation message is too long",
)
return content
def _serialize_message(message: AIConversationMessage) -> dict[str, Any]:
return {
"role": message.role,
"content": message.content,
"created_at": message.created_at.isoformat(),
}

View 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

View File

@@ -0,0 +1,315 @@
import json
import re
import unicodedata
from typing import Any
from fastapi import HTTPException, status
from pydantic import ValidationError
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.modules.personalization.constants import (
PREFERENCE_MAX_VALUE_LENGTH,
PREFERENCE_SIGNAL_TERMS,
SENSITIVE_PREFERENCE_TERMS,
UNAVAILABLE_AI_PROVIDERS,
PreferenceCategory,
PreferenceSource,
)
from app.modules.personalization.models import UserPreference
from app.modules.personalization.schemas import PreferenceExtractionPayload
class PreferenceService:
"""Manage explicit and safely extracted preferences inside one owner boundary."""
def __init__(self, db: Session):
self.db = db
def list_preferences(
self,
owner_id: int,
category: str | PreferenceCategory | None = None,
) -> list[dict[str, Any]]:
_validate_owner(owner_id)
stmt = (
select(UserPreference)
.where(UserPreference.owner_id == owner_id)
.order_by(UserPreference.category.asc(), UserPreference.id.asc())
)
if category is not None:
stmt = stmt.where(UserPreference.category == _category_value(category))
return [_serialize(item) for item in self.db.execute(stmt).scalars()]
def upsert(
self,
owner_id: int,
category: str | PreferenceCategory,
value: str,
source: str | PreferenceSource = PreferenceSource.EXPLICIT,
*,
commit: bool = True,
) -> dict[str, Any]:
"""Create one owner-scoped preference or reuse its normalized equivalent."""
_validate_owner(owner_id)
category_value, clean_value, normalized = validate_preference(category, value)
source_value = _source_value(source)
existing = self.db.execute(
select(UserPreference).where(
UserPreference.owner_id == owner_id,
UserPreference.category == category_value,
UserPreference.normalized_value == normalized,
)
).scalar_one_or_none()
if existing is not None:
existing.value = clean_value
if source_value == PreferenceSource.EXPLICIT:
existing.source = source_value
if commit:
self.db.commit()
self.db.refresh(existing)
return _serialize(existing)
record = UserPreference(
owner_id=owner_id,
category=category_value,
value=clean_value,
normalized_value=normalized,
source=source_value,
)
try:
with self.db.begin_nested():
self.db.add(record)
self.db.flush()
except IntegrityError:
record = self.db.execute(
select(UserPreference).where(
UserPreference.owner_id == owner_id,
UserPreference.category == category_value,
UserPreference.normalized_value == normalized,
)
).scalar_one()
if commit:
self.db.commit()
self.db.refresh(record)
return _serialize(record)
def update(
self,
owner_id: int,
code: str,
*,
category: str | PreferenceCategory | None = None,
value: str | None = None,
) -> dict[str, Any]:
record = self._owned_record(owner_id, code)
next_category = category if category is not None else record.category
next_value = value if value is not None else record.value
category_value, clean_value, normalized = validate_preference(
next_category,
next_value,
)
try:
with self.db.begin_nested():
record.category = category_value
record.value = clean_value
record.normalized_value = normalized
self.db.flush()
except IntegrityError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Preference already exists",
) from exc
self.db.commit()
self.db.refresh(record)
return _serialize(record)
def delete(self, owner_id: int, code: str) -> None:
record = self._owned_record(owner_id, code)
self.db.delete(record)
self.db.commit()
def delete_matching(
self,
owner_id: int,
*,
category: str | PreferenceCategory,
value: str,
) -> bool:
category_value, _, normalized = validate_preference(category, value)
record = self.db.execute(
select(UserPreference).where(
UserPreference.owner_id == owner_id,
UserPreference.category == category_value,
UserPreference.normalized_value == normalized,
)
).scalar_one_or_none()
if record is None:
return False
self.db.delete(record)
self.db.commit()
return True
def save_auto_extraction(
self,
owner_id: int,
*,
provider_name: str,
user_text: str,
structured_payload: str | dict[str, Any] | list[Any],
) -> list[dict[str, Any]]:
"""Persist only allowlisted, non-sensitive output from a real AI provider."""
if provider_name.strip().lower() in UNAVAILABLE_AI_PROVIDERS:
return []
if not contains_preference_signal(user_text):
return []
candidates = _parse_extraction_payload(structured_payload)
saved: list[dict[str, Any]] = []
for candidate in candidates:
try:
saved.append(
self.upsert(
owner_id=owner_id,
category=candidate.category,
value=candidate.value,
source=PreferenceSource.AUTO,
commit=False,
)
)
except HTTPException:
# Automatic extraction is intentionally silent. Invalid or sensitive
# candidates are discarded without creating a rejected profile row.
continue
if saved:
self.db.commit()
return saved
def delete_owner_preferences(self, owner_id: int) -> int:
"""Stage deletion of every preference for an owner."""
result = self.db.execute(
delete(UserPreference).where(UserPreference.owner_id == owner_id)
)
return max(0, int(result.rowcount or 0))
def _owned_record(self, owner_id: int, code: str) -> UserPreference:
_validate_owner(owner_id)
record = self.db.execute(
select(UserPreference).where(
UserPreference.owner_id == owner_id,
UserPreference.code == code,
)
).scalar_one_or_none()
if record is None:
# Deliberately indistinguishable from a nonexistent code.
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Preference not found",
)
return record
def contains_preference_signal(value: str) -> bool:
text = unicodedata.normalize("NFKC", value).casefold()
return any(term in text for term in PREFERENCE_SIGNAL_TERMS)
def validate_preference(
category: str | PreferenceCategory,
value: str,
) -> tuple[str, str, str]:
category_value = _category_value(category)
clean_value = _clean_value(value)
if is_sensitive_preference(clean_value):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Sensitive preference content is not allowed",
)
return category_value, clean_value, normalize_preference_value(clean_value)
def normalize_preference_value(value: str) -> str:
normalized = unicodedata.normalize("NFKC", value)
normalized = re.sub(r"\s+", " ", normalized).strip()
return normalized.casefold()
def is_sensitive_preference(value: str) -> bool:
normalized = unicodedata.normalize("NFKC", value).casefold()
return any(term.casefold() in normalized for term in SENSITIVE_PREFERENCE_TERMS)
def _parse_extraction_payload(
payload: str | dict[str, Any] | list[Any],
) -> list[Any]:
parsed: Any = payload
if isinstance(payload, str):
try:
parsed = json.loads(payload)
except json.JSONDecodeError:
return []
if isinstance(parsed, list):
parsed = {"preferences": parsed}
elif isinstance(parsed, dict) and "preferences" not in parsed:
parsed = {"preferences": [parsed]}
try:
return PreferenceExtractionPayload.model_validate(parsed).preferences
except ValidationError:
return []
def _category_value(category: str | PreferenceCategory) -> str:
try:
return str(PreferenceCategory(category))
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Unsupported preference category",
) from exc
def _source_value(source: str | PreferenceSource) -> str:
try:
return str(PreferenceSource(source))
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Unsupported preference source",
) from exc
def _clean_value(value: str) -> str:
clean_value = unicodedata.normalize("NFKC", str(value)).strip()
if not clean_value:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Preference value is required",
)
if len(clean_value) > PREFERENCE_MAX_VALUE_LENGTH:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Preference value is too long",
)
return clean_value
def _validate_owner(owner_id: int) -> None:
if owner_id <= 0:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Valid preference owner is required",
)
def _serialize(record: UserPreference) -> dict[str, Any]:
return {
"code": record.code,
"category": record.category,
"value": record.value,
"source": record.source,
"created_at": record.created_at.isoformat(),
"updated_at": record.updated_at.isoformat(),
}