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,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(),
}