feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
316 lines
10 KiB
Python
316 lines
10 KiB
Python
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(),
|
|
}
|