""" 用户偏好模块:兴趣关键词的增删查、基于关键词的个性化事件推荐 """ import time from typing import Any, Dict, List, Tuple from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.api.dependencies import get_current_user, get_db from app.models.models import AppUser, UserTopicPreference from app.schemas.preference_schema import ( MatchedEventResponse, UserPreferenceRecommendationResponse, UserTopicPreferenceCreate, UserTopicPreferenceResponse, ) from app.services.matching_service import recommend_events_for_user router = APIRouter() # --- 轻量级接口缓存配置 --- _RECOMMEND_CACHE: Dict[str, Tuple[float, Any]] = {} CACHE_TTL_SECONDS = 60 def _invalidate_user_cache(user_id: int): """清除某个用户的推荐结果缓存(当用户新增或删除关键词时调用)""" keys_to_delete = [k for k in _RECOMMEND_CACHE.keys() if k.startswith(f"{user_id}:")] for k in keys_to_delete: _RECOMMEND_CACHE.pop(k, None) # --------------------------- def _ensure_self_access(path_user_id: int, current_user: AppUser) -> None: """校验路径 user_id 是否为当前登录用户本人。""" if path_user_id != current_user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You can only operate your own resources", ) @router.get( "/users/{user_id}/preferences", response_model=List[UserTopicPreferenceResponse], ) def list_user_preferences( user_id: int, db: Session = Depends(get_db), current_user: AppUser = Depends(get_current_user), ): """获取用户已设置的兴趣关键词。""" _ensure_self_access(user_id, current_user) preferences = ( db.query(UserTopicPreference) .filter(UserTopicPreference.user_id == user_id) .order_by(UserTopicPreference.created_at.desc()) .all() ) return preferences @router.post( "/users/{user_id}/preferences", response_model=UserTopicPreferenceResponse, status_code=status.HTTP_201_CREATED, ) def create_user_preference( user_id: int, payload: UserTopicPreferenceCreate, db: Session = Depends(get_db), current_user: AppUser = Depends(get_current_user), ): """新增一个用户兴趣关键词。""" _ensure_self_access(user_id, current_user) keyword = payload.interested_keyword.strip() if not keyword: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Keyword cannot be empty") db_obj = UserTopicPreference( user_id=user_id, interested_keyword=keyword, ) db.add(db_obj) try: db.commit() except IntegrityError: db.rollback() raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Preference keyword already exists for this user", ) db.refresh(db_obj) _invalidate_user_cache(user_id) # 失效推荐缓存 return db_obj @router.delete( "/users/{user_id}/preferences/{preference_id}", status_code=status.HTTP_204_NO_CONTENT, ) def delete_user_preference( user_id: int, preference_id: int, db: Session = Depends(get_db), current_user: AppUser = Depends(get_current_user), ): """删除一个用户兴趣关键词。""" _ensure_self_access(user_id, current_user) preference = ( db.query(UserTopicPreference) .filter( UserTopicPreference.id == preference_id, UserTopicPreference.user_id == user_id, ) .first() ) if not preference: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Preference not found") db.delete(preference) db.commit() _invalidate_user_cache(user_id) # 失效推荐缓存 return None @router.get( "/users/{user_id}/recommended-events", response_model=UserPreferenceRecommendationResponse, ) def recommend_events( user_id: int, min_hot: int = Query(3, ge=1, description="最小热度阈值"), hours: int = Query(72, ge=1, le=24 * 30, description="仅匹配最近多少小时的事件"), limit: int = Query(20, ge=1, le=50, description="最多返回多少条推荐"), semantic_threshold: float = Query(0.78, ge=0.0, le=1.0, description="语义匹配相似度阈值"), sort_by: str = Query("match_score", description="排序方式: match_score | created_at"), db: Session = Depends(get_db), current_user: AppUser = Depends(get_current_user), ): """基于用户兴趣词推荐事件(精确匹配 + 语义匹配)。""" _ensure_self_access(user_id, current_user) # 推荐结果缓存,避免频繁调用匹配服务 cache_key = f"{user_id}:{min_hot}:{hours}:{limit}:{semantic_threshold}:{sort_by}" current_time = time.time() if cache_key in _RECOMMEND_CACHE: expire_time, cached_data = _RECOMMEND_CACHE[cache_key] if current_time < expire_time: return cached_data # ----------------------- matched = recommend_events_for_user( db, user_id=user_id, min_hot=min_hot, hours=hours, limit=limit, semantic_threshold=semantic_threshold, ) if sort_by == "created_at": matched.sort(key=lambda x: x.event.created_at, reverse=True) result_data: list[MatchedEventResponse] = [] for item in matched: result_data.append( MatchedEventResponse( event_id=item.event.id, unified_title=item.event.unified_title, summary=item.event.ai_comprehensive_summary, hot_score=item.event.hot_score, created_at=item.event.created_at, tags=item.tags, match_score=item.match_score, exact_hits=item.exact_hits, semantic_hits=item.semantic_hits, ) ) response = UserPreferenceRecommendationResponse( user_id=user_id, total=len(result_data), data=result_data, ) # 写入缓存,超过 2000 条时清空防止内存膨胀 if len(_RECOMMEND_CACHE) > 2000: # 防止内存无限增长 _RECOMMEND_CACHE.clear() _RECOMMEND_CACHE[cache_key] = (current_time + CACHE_TTL_SECONDS, response) # ------------------ return response