from __future__ import annotations from datetime import datetime, timezone from typing import Any, Literal, Optional import httpx from fastapi import APIRouter, Depends, Header, HTTPException, Query from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.limits import rate_limit_push_by_ip from app.core.config import get_settings from app.db.models.push_preference import PushPreference from app.db.models.push_token import PushToken from app.db.session import get_db from app.features.user_profile_scoring.types import UserProfileV1_2 router = APIRouter( prefix="/v1/push", tags=["push"], dependencies=[Depends(rate_limit_push_by_ip)], ) PushEnv = Literal["dev", "prod"] PushPlatform = Literal["ios", "android"] class PushDeviceMeta(BaseModel): model: Optional[str] = None os_version: Optional[str] = None app_version: Optional[str] = None locale: Optional[str] = None timezone: Optional[str] = None class PushRegisterRequest(BaseModel): client_user_id: str = Field(min_length=8, max_length=64) platform: PushPlatform push_token: str = Field(min_length=8, max_length=255) app_id: str = Field(min_length=1, max_length=255) env: PushEnv device_meta: Optional[PushDeviceMeta] = None class PushPreferencesRequest(BaseModel): client_user_id: str = Field(min_length=8, max_length=64) enabled: bool times_per_day: int = Field(ge=0, le=5) timezone: Optional[str] = None locale: Optional[str] = None # 可选:用户画像(用于 Push 场景推荐文案生成) user_profile: Optional[UserProfileV1_2] = None class PushPreferencesResponse(BaseModel): client_user_id: str enabled: bool times_per_day: int timezone: Optional[str] = None locale: Optional[str] = None updated_at: Optional[str] = None class PushTestRequest(BaseModel): client_user_id: str = Field(min_length=8, max_length=64) title: Optional[str] = None body: Optional[str] = None def _ensure_utc(dt: datetime) -> datetime: if dt.tzinfo is None: return dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc) async def _pick_active_token(db: AsyncSession, *, client_user_id: str) -> Optional[PushToken]: # 取最近一次上报的 active token q = ( select(PushToken) .where(PushToken.client_user_id == client_user_id, PushToken.is_active == True) # noqa: E712 .order_by(PushToken.last_seen_at.desc()) .limit(1) ) row = await db.execute(q) return row.scalar_one_or_none() async def _send_expo_push(*, to: str, title: str, body: str, data: Optional[dict[str, Any]] = None) -> dict[str, Any]: """ 发送 Expo Push。 说明: - V1:最小可用实现,满足 test 与后续定时任务调用 - 失败处理与 token 停用在后续任务逻辑中完善 """ settings = get_settings() url = "https://exp.host/--/api/v2/push/send" headers: dict[str, str] = { "Content-Type": "application/json", } if settings.expo_access_token: headers["Authorization"] = f"Bearer {settings.expo_access_token}" payload: dict[str, Any] = {"to": to, "title": title, "body": body} if data: payload["data"] = data async with httpx.AsyncClient(timeout=10.0) as client: res = await client.post(url, headers=headers, json=payload) if res.status_code >= 400: raise HTTPException(status_code=502, detail=f"expo_push_failed:{res.status_code}") return res.json() @router.post("/register") async def register(req: PushRegisterRequest, db: AsyncSession = Depends(get_db)) -> dict[str, str]: """ 注册/更新 Push Token(幂等)。 """ now = datetime.now(timezone.utc) # 以 env+app_id+push_token 唯一:存在则更新归属与 last_seen q = select(PushToken).where( PushToken.env == req.env, PushToken.app_id == req.app_id, PushToken.push_token == req.push_token, ) row = await db.execute(q) token = row.scalar_one_or_none() if token is None: token = PushToken( client_user_id=req.client_user_id, platform=req.platform, push_token=req.push_token, app_id=req.app_id, env=req.env, is_active=True, last_seen_at=_ensure_utc(now), ) db.add(token) else: token.client_user_id = req.client_user_id token.platform = req.platform token.is_active = True token.last_seen_at = _ensure_utc(now) await db.commit() return {"status": "ok"} @router.put("/preferences", response_model=PushPreferencesResponse) async def put_preferences(req: PushPreferencesRequest, db: AsyncSession = Depends(get_db)) -> PushPreferencesResponse: """ 设置每日提醒偏好(幂等)。 """ # 规范化:enabled=false 或 times=0 均视为关闭 times = int(req.times_per_day) enabled = bool(req.enabled) and times > 0 q = select(PushPreference).where(PushPreference.client_user_id == req.client_user_id) row = await db.execute(q) pref = row.scalar_one_or_none() if pref is None: pref = PushPreference( client_user_id=req.client_user_id, enabled=enabled, times_per_day=times, timezone=req.timezone, locale=req.locale, # 注意:Pydantic 会把 ISO8601 字符串解析成 datetime; # SQLAlchemy JSON 列默认使用 json.dumps,无法序列化 datetime。 # 这里用 mode="json" 保证写库内容都是可 JSON 序列化的基础类型(datetime → ISO 字符串)。 user_profile_json=req.user_profile.model_dump(mode="json") if req.user_profile else None, ) db.add(pref) else: pref.enabled = enabled pref.times_per_day = times pref.timezone = req.timezone pref.locale = req.locale if req.user_profile is not None: pref.user_profile_json = req.user_profile.model_dump(mode="json") await db.commit() # 返回更新时间(从 ORM 读取到的可能不包含 server_onupdate,这里用 now 兜底) updated_at = getattr(pref, "updated_at", None) updated_at_iso = updated_at.isoformat() if isinstance(updated_at, datetime) else None return PushPreferencesResponse( client_user_id=req.client_user_id, enabled=enabled, times_per_day=times, timezone=req.timezone, locale=req.locale, updated_at=updated_at_iso, ) @router.get("/preferences", response_model=PushPreferencesResponse) async def get_preferences( client_user_id: str = Query(min_length=8, max_length=64), db: AsyncSession = Depends(get_db), ) -> PushPreferencesResponse: q = select(PushPreference).where(PushPreference.client_user_id == client_user_id) row = await db.execute(q) pref = row.scalar_one_or_none() if pref is None: return PushPreferencesResponse(client_user_id=client_user_id, enabled=False, times_per_day=0) updated_at_iso = pref.updated_at.isoformat() if pref.updated_at else None return PushPreferencesResponse( client_user_id=client_user_id, enabled=bool(pref.enabled) and int(pref.times_per_day) > 0, times_per_day=int(pref.times_per_day), timezone=pref.timezone, locale=pref.locale, updated_at=updated_at_iso, ) @router.post("/test") async def test_push( req: PushTestRequest, db: AsyncSession = Depends(get_db), accept_language: Optional[str] = Header(default=None, alias="Accept-Language"), ) -> dict[str, Any]: """ 立即测试推送(仅用于 dev 联调)。 """ settings = get_settings() if settings.app_env != "dev": raise HTTPException(status_code=403, detail="test_only_in_dev") token = await _pick_active_token(db, client_user_id=req.client_user_id) if token is None: raise HTTPException(status_code=404, detail="no_active_token") # 如果没有用户画像,也不影响 test 推送;文案按请求/默认文案发送。 q = select(PushPreference).where(PushPreference.client_user_id == req.client_user_id) row = await db.execute(q) pref = row.scalar_one_or_none() if pref and pref.user_profile_json: _ = UserProfileV1_2.model_validate(pref.user_profile_json) # V1:先发固定测试文案;后续在定时任务中替换为推荐模块的 push 场景模板 title = req.title or "Hey Mama" body = req.body or "这是一条测试推送(dev)。" expo_res = await _send_expo_push(to=token.push_token, title=title, body=body, data={"client_user_id": req.client_user_id}) _ = accept_language return {"status": "ok", "expo": expo_res}