from __future__ import annotations from datetime import datetime, timezone from typing import Any, Optional from fastapi import APIRouter, Depends, Header from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from app.api.limits import rate_limit_reco_by_ip from app.db.session import get_db from app.features.personalized_reco.content_repository.interface import ContentRepository from app.features.personalized_reco.content_repository.sqlalchemy_repo import SqlAlchemyContentRepository from app.features.personalized_reco.reco_engine import recommend from app.features.personalized_reco.reco_engine.types import RecoConstraints, RecoEngineResult from app.features.user_profile_scoring.types import UserProfileV1_2 router = APIRouter( prefix="/v1/reco", tags=["reco"], dependencies=[Depends(rate_limit_reco_by_ip)], ) class RecoRequest(BaseModel): k: Optional[int] = None user_profile: UserProfileV1_2 already_recommended_ids: list[Any] = Field(default_factory=list) touched_or_viewed_ids: list[Any] = Field(default_factory=list) now: Optional[datetime] = None def _parse_now_from_header(x_now: Optional[str]) -> Optional[datetime]: if not x_now: return None raw = str(x_now).strip() if not raw: return None # 支持 Z if raw.endswith("Z"): raw = raw[:-1] + "+00:00" try: dt = datetime.fromisoformat(raw) except Exception: return None if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt def _pick_now(*, header_now: Optional[str], body_now: Optional[datetime]) -> datetime: dt = _parse_now_from_header(header_now) if dt is not None: return dt if body_now is not None: if body_now.tzinfo is None: return body_now.replace(tzinfo=timezone.utc) return body_now return datetime.now(timezone.utc) def _pick_locale_from_accept_language(accept_language: Optional[str]) -> str: """ 从 Accept-Language 映射 locale: - 缺失/空 -> en - 含 zh-TW/zh-HK/tc -> tc - 其他 -> en """ raw = (accept_language or "").strip().lower() if not raw: return "en" if "zh-tw" in raw or "zh-hk" in raw or "tc" in raw: return "tc" return "en" async def get_reco_repo(db: AsyncSession = Depends(get_db)) -> ContentRepository: """ 构造推荐 repo(可在测试中 override,避免依赖真实 DB)。 """ return SqlAlchemyContentRepository(db) @router.post("/feed", response_model=RecoEngineResult) async def reco_feed( req: RecoRequest, repo: ContentRepository = Depends(get_reco_repo), x_now: Optional[str] = Header(default=None, alias="X-Now"), accept_language: Optional[str] = Header(default=None, alias="Accept-Language"), ) -> RecoEngineResult: k_i = 30 if req.k is None else int(req.k) now = _pick_now(header_now=x_now, body_now=req.now) locale = _pick_locale_from_accept_language(accept_language) return await recommend( repo=repo, scene="feed", user_profile=req.user_profile, already_recommended_ids=list(req.already_recommended_ids or []), touched_or_viewed_ids=list(req.touched_or_viewed_ids or []), k=k_i, now=now, locale=locale, constraints=RecoConstraints(), ) @router.post("/push", response_model=RecoEngineResult) async def reco_push( req: RecoRequest, repo: ContentRepository = Depends(get_reco_repo), x_now: Optional[str] = Header(default=None, alias="X-Now"), accept_language: Optional[str] = Header(default=None, alias="Accept-Language"), ) -> RecoEngineResult: k_i = 1 if req.k is None else int(req.k) now = _pick_now(header_now=x_now, body_now=req.now) locale = _pick_locale_from_accept_language(accept_language) return await recommend( repo=repo, scene="push", user_profile=req.user_profile, already_recommended_ids=list(req.already_recommended_ids or []), touched_or_viewed_ids=list(req.touched_or_viewed_ids or []), k=k_i, now=now, locale=locale, constraints=RecoConstraints(), ) @router.post("/widget", response_model=RecoEngineResult) async def reco_widget( req: RecoRequest, repo: ContentRepository = Depends(get_reco_repo), x_now: Optional[str] = Header(default=None, alias="X-Now"), accept_language: Optional[str] = Header(default=None, alias="Accept-Language"), ) -> RecoEngineResult: k_i = 1 if req.k is None else int(req.k) now = _pick_now(header_now=x_now, body_now=req.now) locale = _pick_locale_from_accept_language(accept_language) return await recommend( repo=repo, scene="widget", user_profile=req.user_profile, already_recommended_ids=list(req.already_recommended_ids or []), touched_or_viewed_ids=list(req.touched_or_viewed_ids or []), k=k_i, now=now, locale=locale, constraints=RecoConstraints(), )