fix:小组件- PUSH
This commit is contained in:
@@ -47,6 +47,7 @@ class FixedWindowRateLimiter:
|
||||
|
||||
|
||||
_reco_rate_limiter = FixedWindowRateLimiter(limit=10, window_seconds=60)
|
||||
_push_rate_limiter = FixedWindowRateLimiter(limit=30, window_seconds=60)
|
||||
|
||||
|
||||
async def rate_limit_reco_by_ip(request: Request) -> None:
|
||||
@@ -60,3 +61,19 @@ async def rate_limit_reco_by_ip(request: Request) -> None:
|
||||
|
||||
_reco_rate_limiter.allow(key=ip, now_ts=time.time())
|
||||
|
||||
|
||||
async def rate_limit_push_by_ip(request: Request) -> None:
|
||||
"""
|
||||
推送相关接口限流:按 IP,1 分钟 30 次。
|
||||
|
||||
说明:
|
||||
- register/preferences 等接口可能在客户端反复重试
|
||||
- 本期先用内存固定窗口限流做基础保护
|
||||
"""
|
||||
|
||||
ip = "unknown"
|
||||
if request.client and request.client.host:
|
||||
ip = str(request.client.host)
|
||||
|
||||
_push_rate_limiter.allow(key=ip, now_ts=time.time())
|
||||
|
||||
|
||||
131
server/app/api/v1/legal.py
Normal file
131
server/app/api/v1/legal.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel, HttpUrl
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.legal_docs import PRIVACY_POLICY_MD, TERMS_OF_USE_MD, choose_content_by_lang, render_as_simple_html
|
||||
|
||||
|
||||
router = APIRouter(prefix="/v1/legal", tags=["legal"])
|
||||
|
||||
INTERNAL_SENTINEL = "__internal__"
|
||||
|
||||
|
||||
# 当前多语言仅支持 EN / TC(繁体)
|
||||
ResolvedLang = Literal["en", "tc"]
|
||||
|
||||
|
||||
class LegalLinksResponse(BaseModel):
|
||||
privacyPolicyUrl: HttpUrl
|
||||
termsOfUseUrl: HttpUrl
|
||||
resolvedLang: ResolvedLang
|
||||
|
||||
|
||||
def _resolve_lang(accept_language: Optional[str]) -> ResolvedLang:
|
||||
"""
|
||||
从 Accept-Language 里做一个轻量语言解析。
|
||||
|
||||
说明:
|
||||
- 只关心:en / tc(繁体)
|
||||
- 解析失败或缺失:回退 en
|
||||
"""
|
||||
|
||||
if not accept_language:
|
||||
return "en"
|
||||
s = accept_language.lower()
|
||||
|
||||
# 目前只支持 EN / TC:只要是中文或显式 tc,都归到 tc
|
||||
if "tc" in s:
|
||||
return "tc"
|
||||
if "zh" in s or "hant" in s or "tw" in s or "hk" in s or "mo" in s:
|
||||
return "tc"
|
||||
|
||||
return "en"
|
||||
|
||||
|
||||
def _pick_urls(lang: ResolvedLang) -> tuple[str, str, ResolvedLang]:
|
||||
settings = get_settings()
|
||||
|
||||
if lang == "tc":
|
||||
privacy = settings.legal_privacy_url_tc or settings.legal_privacy_url_en
|
||||
terms = settings.legal_terms_url_tc or settings.legal_terms_url_en
|
||||
# 如果未配置 tc 链接:
|
||||
# - 若走内置页面(__internal__),可直接展示 tc 内容,因此 resolved=tc
|
||||
# - 否则回退到 en 链接,因此 resolved=en
|
||||
if settings.legal_privacy_url_tc or settings.legal_terms_url_tc:
|
||||
resolved: ResolvedLang = "tc"
|
||||
elif privacy == INTERNAL_SENTINEL or terms == INTERNAL_SENTINEL:
|
||||
resolved = "tc"
|
||||
else:
|
||||
resolved = "en"
|
||||
return privacy, terms, resolved
|
||||
|
||||
return settings.legal_privacy_url_en, settings.legal_terms_url_en, "en"
|
||||
|
||||
|
||||
def _join_base_url(base_url: str, path: str) -> str:
|
||||
base = (base_url or "").rstrip("/")
|
||||
p = (path or "").strip()
|
||||
if not p.startswith("/"):
|
||||
p = "/" + p
|
||||
return base + p
|
||||
|
||||
|
||||
def _normalize_internal_url(request: Request, url: str, internal_path: str) -> str:
|
||||
"""
|
||||
将内置哨兵值替换为当前服务的可访问绝对 URL。
|
||||
"""
|
||||
|
||||
if url != INTERNAL_SENTINEL:
|
||||
return url
|
||||
return _join_base_url(str(request.base_url), internal_path)
|
||||
|
||||
|
||||
@router.get("/links", response_model=LegalLinksResponse)
|
||||
async def get_legal_links(request: Request) -> LegalLinksResponse:
|
||||
"""
|
||||
获取协议链接(隐私协议 / 使用协议)。
|
||||
|
||||
- 语言来源:Accept-Language
|
||||
- 默认兜底:en
|
||||
"""
|
||||
|
||||
accept_language = request.headers.get("accept-language")
|
||||
lang = _resolve_lang(accept_language)
|
||||
privacy, terms, resolved = _pick_urls(lang)
|
||||
privacy = _normalize_internal_url(request, privacy, "/v1/legal/privacy")
|
||||
terms = _normalize_internal_url(request, terms, "/v1/legal/terms")
|
||||
return LegalLinksResponse(privacyPolicyUrl=privacy, termsOfUseUrl=terms, resolvedLang=resolved)
|
||||
|
||||
|
||||
@router.get("/privacy", response_class=HTMLResponse)
|
||||
async def get_privacy_policy(request: Request) -> HTMLResponse:
|
||||
"""
|
||||
内置隐私协议页面(用于未配置外部托管链接时的兜底)。
|
||||
"""
|
||||
|
||||
accept_language = request.headers.get("accept-language")
|
||||
lang = _resolve_lang(accept_language)
|
||||
content, resolved = choose_content_by_lang(PRIVACY_POLICY_MD, lang)
|
||||
title = "Hey Mama | Privacy Policy" if resolved == "en" else "Hey Mama|隱私權政策"
|
||||
page = render_as_simple_html(title=title, content=content)
|
||||
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"})
|
||||
|
||||
|
||||
@router.get("/terms", response_class=HTMLResponse)
|
||||
async def get_terms_of_use(request: Request) -> HTMLResponse:
|
||||
"""
|
||||
内置使用协议页面(用于未配置外部托管链接时的兜底)。
|
||||
"""
|
||||
|
||||
accept_language = request.headers.get("accept-language")
|
||||
lang = _resolve_lang(accept_language)
|
||||
content, resolved = choose_content_by_lang(TERMS_OF_USE_MD, lang)
|
||||
title = "Hey Mama – Terms of Use" if resolved == "en" else "Hey Mama 使用條款"
|
||||
page = render_as_simple_html(title=title, content=content)
|
||||
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"})
|
||||
|
||||
262
server/app/api/v1/push.py
Normal file
262
server/app/api/v1/push.py
Normal file
@@ -0,0 +1,262 @@
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user