Files
mindfulness/server/app/api/v1/push.py
2026-02-03 17:43:58 +08:00

263 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}