新功能:个性化推荐算法
This commit is contained in:
BIN
server/app/api/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
server/app/api/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
server/app/api/__pycache__/limits.cpython-313.pyc
Normal file
BIN
server/app/api/__pycache__/limits.cpython-313.pyc
Normal file
Binary file not shown.
62
server/app/api/limits.py
Normal file
62
server/app/api/limits.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Tuple
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
|
||||
@dataclass
|
||||
class FixedWindowRateLimiter:
|
||||
"""
|
||||
固定窗口限流(内存版)。
|
||||
|
||||
约束:
|
||||
- 适用于单进程/单实例;多进程/多实例下不共享计数(V1 可接受)
|
||||
- 窗口粒度:按分钟 bucket(window_seconds 建议为 60)
|
||||
"""
|
||||
|
||||
limit: int
|
||||
window_seconds: int
|
||||
_counters: Dict[Tuple[str, int], int] = field(default_factory=dict)
|
||||
_last_gc_bucket: int = 0
|
||||
|
||||
def _bucket(self, now_ts: float) -> int:
|
||||
return int(now_ts // float(self.window_seconds))
|
||||
|
||||
def _gc(self, current_bucket: int) -> None:
|
||||
# 每隔一段时间清理一次,避免 dict 无限增长(保留最近 3 个 bucket)
|
||||
if self._last_gc_bucket == current_bucket:
|
||||
return
|
||||
self._last_gc_bucket = current_bucket
|
||||
keep_from = current_bucket - 2
|
||||
to_delete = [k for k in self._counters.keys() if k[1] < keep_from]
|
||||
for k in to_delete:
|
||||
self._counters.pop(k, None)
|
||||
|
||||
def allow(self, *, key: str, now_ts: float) -> None:
|
||||
bucket = self._bucket(now_ts)
|
||||
self._gc(bucket)
|
||||
|
||||
k = (str(key), int(bucket))
|
||||
n = int(self._counters.get(k, 0)) + 1
|
||||
self._counters[k] = n
|
||||
if n > int(self.limit):
|
||||
raise HTTPException(status_code=429, detail="rate_limited")
|
||||
|
||||
|
||||
_reco_rate_limiter = FixedWindowRateLimiter(limit=10, window_seconds=60)
|
||||
|
||||
|
||||
async def rate_limit_reco_by_ip(request: Request) -> None:
|
||||
"""
|
||||
推荐接口限流:按 IP,1 分钟 10 次。
|
||||
"""
|
||||
|
||||
ip = "unknown"
|
||||
if request.client and request.client.host:
|
||||
ip = str(request.client.host)
|
||||
|
||||
_reco_rate_limiter.allow(key=ip, now_ts=time.time())
|
||||
|
||||
BIN
server/app/api/v1/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
server/app/api/v1/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
server/app/api/v1/__pycache__/reco.cpython-313.pyc
Normal file
BIN
server/app/api/v1/__pycache__/reco.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
156
server/app/api/v1/reco.py
Normal file
156
server/app/api/v1/reco.py
Normal file
@@ -0,0 +1,156 @@
|
||||
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(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user