from __future__ import annotations from datetime import datetime, timezone from typing import Any, Literal, Optional import httpx import redis from fastapi import APIRouter, Depends, Header, HTTPException, Query from pydantic import BaseModel, Field from sqlalchemy import func, 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.models.push_send_log import PushSendLog from app.db.session import get_db from app.features.push_payload import build_home_push_data from app.features.user_profile_scoring.types import UserProfileV1_2 from app.worker import celery_app 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) # 额外:尽早写入/补齐时区与语言(用于按用户时区生成排程) # 说明: # - 用户首次授权后会立即调用 /register,但不一定马上进入“每日提醒”确认页 # - 若 push_preferences 里 timezone 为空,会导致排程回退到 UTC,体验不符合预期 if req.device_meta: tz = (req.device_meta.timezone or "").strip() or None loc = (req.device_meta.locale or "").strip() or None if tz or loc: qpref = select(PushPreference).where(PushPreference.client_user_id == req.client_user_id) rpref = await db.execute(qpref) pref = rpref.scalar_one_or_none() if pref is None: pref = PushPreference( client_user_id=req.client_user_id, enabled=False, times_per_day=0, timezone=tz, locale=loc, ) db.add(pref) else: if tz and not (pref.timezone or "").strip(): pref.timezone = tz if loc and not (pref.locale or "").strip(): pref.locale = loc 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 # 注意:只在客户端显式传入时覆盖,避免把已保存的 timezone/locale 清空导致排程回退到 UTC if req.timezone is not None: pref.timezone = req.timezone if req.locale is not None: 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() # 返回更新时间: # - 某些运行环境/驱动组合下,commit 后访问 ORM 字段可能触发隐式 IO,导致 async 下报 MissingGreenlet。 # - 这里直接用当前时间兜底(字段本身为可选,仅用于前端展示)。 updated_at_iso = datetime.now(timezone.utc).isoformat() 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 "Dear Mama" body = req.body or "这是一条测试推送(dev)。" expo_res = await _send_expo_push( to=token.push_token, title=title, body=body, data=build_home_push_data( client_user_id=req.client_user_id, body=body, scene="push", ), ) _ = accept_language return {"status": "ok", "expo": expo_res} def _env_prefix(app_env: str) -> str: """ 根据环境生成前缀: - dev -> dev - prod -> pro """ return "dev" if str(app_env) == "dev" else "pro" @router.get("/scheduler/health") async def scheduler_health(db: AsyncSession = Depends(get_db)) -> dict[str, Any]: """ 推送“定时服务”健康检查(用于容器内验证)。 返回内容(尽量不暴露敏感信息): - Redis:是否可连通 - Worker:是否至少有一个 worker 在线(inspect ping) - Beat:是否在跑(beat 心跳 key 是否在持续刷新) - DB:是否可查询到 push_send_log 的最新时间(辅助定位排程是否生成) """ settings = get_settings() prefix = _env_prefix(settings.app_env) beat_key = f"{prefix}:beat:heartbeat" out: dict[str, Any] = { "env": settings.app_env, "redis": {"ok": False}, "worker": {"ok": False, "worker_count": 0}, "beat": {"ok": False, "last_heartbeat_at": None, "age_seconds": None}, "db": {"ok": False, "push_send_log_latest_created_at": None}, "db_push_tokens": {"ok": False, "count": None, "latest": None}, "now_utc": datetime.now(timezone.utc).isoformat(), } # 1) Redis 连通性 + 读取 beat 心跳 try: r = redis.Redis.from_url(settings.celery_broker_url, decode_responses=True) r.ping() out["redis"]["ok"] = True hb = r.get(beat_key) if hb: out["beat"]["last_heartbeat_at"] = hb try: # Python 3.11+ 支持解析 ISO8601(含 +00:00) hb_dt = datetime.fromisoformat(hb.replace("Z", "+00:00")) now = datetime.now(timezone.utc) age = int((now - hb_dt.astimezone(timezone.utc)).total_seconds()) out["beat"]["age_seconds"] = age # 2 分钟内认为健康(beat 每分钟刷新一次) out["beat"]["ok"] = age <= 120 except Exception: # 解析失败:至少说明 key 存在,但时间格式异常 out["beat"]["ok"] = False except Exception as e: out["redis"]["error"] = f"{type(e).__name__}: {e}" # 2) Worker 在线性(inspect ping) try: insp = celery_app.control.inspect(timeout=1.0) pings = insp.ping() or {} if isinstance(pings, dict): out["worker"]["worker_count"] = len(pings) out["worker"]["ok"] = len(pings) > 0 except Exception as e: out["worker"]["error"] = f"{type(e).__name__}: {e}" # 3) DB:查询 push_send_log 最新创建时间(用于判断排程是否有生成) try: q = select(PushSendLog.created_at).order_by(PushSendLog.created_at.desc()).limit(1) row = await db.execute(q) latest = row.scalar_one_or_none() out["db"]["ok"] = True out["db"]["push_send_log_latest_created_at"] = latest.isoformat() if latest else None except Exception as e: out["db"]["error"] = f"{type(e).__name__}: {e}" # 4) DB:查询 push_tokens 计数与最近一条(用于确认 /v1/push/register 是否真正落库) try: qcount = select(func.count()).select_from(PushToken) rcount = await db.execute(qcount) cnt = int(rcount.scalar_one() or 0) qlatest = select(PushToken).order_by(PushToken.last_seen_at.desc()).limit(1) rlatest = await db.execute(qlatest) t = rlatest.scalar_one_or_none() latest_obj = None if t is not None: tok = str(t.push_token or "") masked = tok[:10] + "***" + tok[-6:] if len(tok) > 20 else (tok[:6] + "***" if tok else "") latest_obj = { "id": int(getattr(t, "id", 0) or 0), "client_user_id": str(t.client_user_id), "env": str(t.env), "app_id": str(t.app_id), "platform": str(t.platform), "is_active": bool(t.is_active), "last_seen_at": t.last_seen_at.isoformat() if t.last_seen_at else None, "push_token_masked": masked, } out["db_push_tokens"]["ok"] = True out["db_push_tokens"]["count"] = cnt out["db_push_tokens"]["latest"] = latest_obj except Exception as e: out["db_push_tokens"]["error"] = f"{type(e).__name__}: {e}" return out