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}
|
||||
|
||||
@@ -46,6 +46,24 @@ class Settings(BaseSettings):
|
||||
celery_broker_url: str
|
||||
celery_result_backend: Optional[str] = None
|
||||
|
||||
# 法务协议链接(由后端统一托管并下发给客户端)
|
||||
# 说明:
|
||||
# - 当前仅支持 EN / TC 两种语言(与客户端现状一致)
|
||||
# - 若未显式配置 LEGAL_*,后端会使用“内置协议页面”(/v1/legal/privacy、/v1/legal/terms)
|
||||
# - 线上建议配置为你们的官网/托管页/静态站点 HTTPS 链接,避免与 API 域名强绑定
|
||||
#
|
||||
# 这里用一个内部哨兵值表示“走内置页面”,避免默认值误导为可用的公网链接。
|
||||
legal_privacy_url_en: str = "__internal__"
|
||||
legal_terms_url_en: str = "__internal__"
|
||||
legal_privacy_url_tc: Optional[str] = None
|
||||
legal_terms_url_tc: Optional[str] = None
|
||||
|
||||
# Expo Push(可选)
|
||||
# 说明:
|
||||
# - 不填也能调用 Expo Push API,但会受更严格的速率限制
|
||||
# - 建议生产环境配置 EXPO_ACCESS_TOKEN,便于稳定性与配额
|
||||
expo_access_token: Optional[str] = None
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="",
|
||||
case_sensitive=False,
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
from app.db.models.content import Content
|
||||
from app.db.models.content_profile import ContentProfile
|
||||
from app.db.models.content_risk_flag import ContentRiskFlag
|
||||
from app.db.models.push_preference import PushPreference
|
||||
from app.db.models.push_send_log import PushSendLog
|
||||
from app.db.models.push_token import PushToken
|
||||
|
||||
__all__ = ["Content", "ContentProfile", "ContentRiskFlag"]
|
||||
__all__ = ["Content", "ContentProfile", "ContentRiskFlag", "PushPreference", "PushSendLog", "PushToken"]
|
||||
|
||||
|
||||
63
server/app/db/models/push_preference.py
Normal file
63
server/app/db/models/push_preference.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, JSON, SmallInteger, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class PushPreference(Base):
|
||||
"""
|
||||
用户推送偏好(以 client_user_id 为主键)。
|
||||
"""
|
||||
|
||||
__tablename__ = "push_preferences"
|
||||
|
||||
client_user_id: Mapped[str] = mapped_column(
|
||||
String(length=64),
|
||||
primary_key=True,
|
||||
comment="客户端用户标识(UUID)",
|
||||
)
|
||||
|
||||
enabled: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="是否开启每日提醒(enabled=false 或 times_per_day=0 均视为关闭)",
|
||||
)
|
||||
|
||||
times_per_day: Mapped[int] = mapped_column(
|
||||
SmallInteger,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="每天推送次数(0~5)",
|
||||
)
|
||||
|
||||
timezone: Mapped[str | None] = mapped_column(
|
||||
String(length=64),
|
||||
nullable=True,
|
||||
comment="IANA 时区(例如 Asia/Shanghai),来自客户端上报",
|
||||
)
|
||||
|
||||
locale: Mapped[str | None] = mapped_column(
|
||||
String(length=32),
|
||||
nullable=True,
|
||||
comment="客户端语言(例如 zh-CN/en/zh-TW),用于文案语言选择",
|
||||
)
|
||||
|
||||
user_profile_json: Mapped[dict | None] = mapped_column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
comment="用户画像(V1.2;可选)。用于 Push 场景推荐文案生成。",
|
||||
)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
server_onupdate=func.now(),
|
||||
comment="更新时间",
|
||||
)
|
||||
|
||||
37
server/app/db/models/push_send_log.py
Normal file
37
server/app/db/models/push_send_log.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, Index, SmallInteger, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class PushSendLog(Base):
|
||||
"""
|
||||
推送发送日志(用于幂等防重复 + 可观测)。
|
||||
"""
|
||||
|
||||
__tablename__ = "push_send_log"
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("client_user_id", "local_date", "slot_index", name="uniq_user_date_slot"),
|
||||
Index("idx_push_log_user_date", "client_user_id", "local_date"),
|
||||
Index("idx_push_log_status", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, comment="主键")
|
||||
|
||||
client_user_id: Mapped[str] = mapped_column(String(length=64), nullable=False, comment="客户端用户标识(UUID)")
|
||||
local_date: Mapped[date] = mapped_column(Date, nullable=False, comment="用户时区的本地日期(用于幂等)")
|
||||
slot_index: Mapped[int] = mapped_column(SmallInteger, nullable=False, comment="当天第几条(1..times_per_day)")
|
||||
|
||||
scheduled_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, comment="计划发送时间(UTC)")
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="实际发送时间(UTC)")
|
||||
|
||||
status: Mapped[str] = mapped_column(String(length=16), nullable=False, server_default="scheduled", comment="scheduled/sent/failed")
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True, comment="失败原因(可选)")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now(), comment="创建时间")
|
||||
|
||||
63
server/app/db/models/push_token.py
Normal file
63
server/app/db/models/push_token.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Enum, Index, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
PushEnv = Literal["dev", "prod"]
|
||||
PushPlatform = Literal["ios", "android"]
|
||||
|
||||
|
||||
class PushToken(Base):
|
||||
"""
|
||||
Push Token 绑定表(Expo Push Token)。
|
||||
|
||||
约束:
|
||||
- token 在同一 env + app_id 下必须唯一(避免重复推送)
|
||||
- 同一 client_user_id 可能会更新 token(重装/轮换/重新授权)
|
||||
"""
|
||||
|
||||
__tablename__ = "push_tokens"
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("env", "app_id", "push_token", name="uniq_env_app_token"),
|
||||
Index("idx_push_tokens_client_user_id", "client_user_id"),
|
||||
Index("idx_push_tokens_is_active", "is_active"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, comment="主键")
|
||||
|
||||
client_user_id: Mapped[str] = mapped_column(String(length=64), nullable=False, comment="客户端用户标识(UUID)")
|
||||
platform: Mapped[PushPlatform] = mapped_column(
|
||||
Enum("ios", "android", name="push_platform"),
|
||||
nullable=False,
|
||||
comment="平台",
|
||||
)
|
||||
|
||||
push_token: Mapped[str] = mapped_column(String(length=255), nullable=False, comment="Expo Push Token")
|
||||
app_id: Mapped[str] = mapped_column(String(length=255), nullable=False, comment="bundle id / package name(用于隔离)")
|
||||
env: Mapped[PushEnv] = mapped_column(
|
||||
Enum("dev", "prod", name="push_env"),
|
||||
nullable=False,
|
||||
comment="环境隔离",
|
||||
)
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
comment="是否有效(发送失败且不可恢复时置为 false)",
|
||||
)
|
||||
|
||||
last_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
server_onupdate=func.now(),
|
||||
comment="最后一次上报时间",
|
||||
)
|
||||
|
||||
305
server/app/legal_docs.py
Normal file
305
server/app/legal_docs.py
Normal file
@@ -0,0 +1,305 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
|
||||
ResolvedLang = Literal["en", "tc"]
|
||||
|
||||
|
||||
# 协议原文(直接来自仓库中的 Markdown 文档)。
|
||||
# 说明:为了保证“点击一定有内容”,这里在后端内置了一份可展示的协议文本。
|
||||
# 线上若你们有官网/静态站点托管页面,可通过环境变量 LEGAL_* 覆盖为外部链接。
|
||||
PRIVACY_POLICY_MD = """Hey Mama | Privacy Policy
|
||||
Last updated: February 2026
|
||||
|
||||
1. Introduction
|
||||
Welcome to Hey Mama (“the App,” “we,” “us”).
|
||||
We respect your privacy and are committed to protecting your personal information. This Privacy Policy explains how we collect, use, store, and protect information when you use the App.
|
||||
By downloading, accessing, or using the App, you acknowledge that you have read, understood, and agreed to this Privacy Policy.
|
||||
|
||||
2. Data Controller and Scope
|
||||
The App is operated and maintained by the Hey Mama team.
|
||||
This Privacy Policy applies to information processing activities related to your use of the App.
|
||||
|
||||
3. Information We Collect
|
||||
3.1 Information You Provide
|
||||
Hey Mama does not require account registration and does not require you to provide personally identifiable information.
|
||||
During your use of the App, you may optionally provide or generate the following information:
|
||||
- Reminder settings (e.g., reminder frequency)
|
||||
- Text content you view, save as favorites, or create within the App (if available)
|
||||
This information is used only to operate core App features and provide a personalized experience.
|
||||
|
||||
3.2 Information Collected Automatically
|
||||
When you use the App, we may automatically collect certain non-identifiable technical information, including but not limited to:
|
||||
- Device type and operating system version
|
||||
- App version
|
||||
- Device language settings
|
||||
- Basic usage status (e.g., whether the App is opened, whether reminders are enabled)
|
||||
This information does not directly identify you and is primarily used to maintain App stability, troubleshoot issues, and improve user experience.
|
||||
The App does not collect precise location information for the purposes described in this policy, does not engage in cross-app tracking, and does not use your data for third-party advertising.
|
||||
|
||||
4. Push Notifications
|
||||
With your permission, the App may send you reminder notifications, such as daily affirmations.
|
||||
- Notifications contain general text information only
|
||||
- Notifications do not include sensitive personal data
|
||||
- You can disable notifications at any time in your device settings
|
||||
|
||||
5. Home Screen Widgets
|
||||
If you choose to use home screen widgets, the displayed content comes from the App’s text-based affirmations. Widgets do not collect or transmit additional personal data.
|
||||
|
||||
6. How We Use Information
|
||||
We use collected information only for the following purposes:
|
||||
- To provide and maintain core App functionality
|
||||
- To improve content presentation and user experience
|
||||
- To fix bugs and enhance system stability
|
||||
We do not:
|
||||
- Sell, rent, or trade your personal data
|
||||
- Use your data for third-party advertising purposes
|
||||
|
||||
7. Third-Party Services
|
||||
Hey Mama currently does not integrate third-party advertising or marketing services.
|
||||
The App may rely on necessary operating system and app store services to provide functionality (for example, push notification delivery mechanisms).
|
||||
If we later integrate third-party analytics or technical services, we will update this Privacy Policy accordingly.
|
||||
|
||||
8. Data Retention and Security
|
||||
We retain information only for as long as necessary to achieve the purposes described above. We implement reasonable technical and organizational measures to protect information against unauthorized access, disclosure, alteration, or loss.
|
||||
|
||||
9. Minors
|
||||
Hey Mama is not designed for children, and we do not knowingly collect personal information from users under the age of 13.
|
||||
If you are a minor, please use the App with the consent and supervision of a parent or guardian.
|
||||
|
||||
10. Changes to This Privacy Policy
|
||||
We may update this Privacy Policy from time to time. The updated version will be made available within the App or through related pages. If you continue to use the App after updates take effect, you are deemed to have accepted the updated policy.
|
||||
|
||||
|
||||
---
|
||||
Hey Mama|隱私權政策
|
||||
最後更新日期:2026 年 2 月
|
||||
|
||||
一、前言
|
||||
歡迎使用 Hey Mama(以下簡稱「本 App」、「我們」)。
|
||||
我們重視您的隱私,並致力於保護您的個人資料安全。本隱私權政策說明您在使用 Hey Mama 時,我們如何收集、使用、保存與保護相關資訊。
|
||||
當您下載、存取或使用本 App,即表示您已閱讀、理解並同意本隱私權政策之內容。
|
||||
|
||||
二、我們收集的資訊
|
||||
1. 使用者主動提供的資訊
|
||||
Hey Mama 不要求建立帳號,亦不強制使用者提供可識別個人身分的資料。
|
||||
在使用過程中,您可能會選擇性提供或產生以下資訊:
|
||||
- 提醒設定(例如提醒頻率)
|
||||
- 使用者在 App 內閱讀、收藏或建立的文字內容(如有)
|
||||
上述資訊僅用於 App 功能運作與個人化體驗。
|
||||
2. 自動收集的資訊
|
||||
當您使用本 App 時,我們可能會自動收集部分非識別性技術資訊,包括但不限於:
|
||||
- 裝置類型與作業系統版本
|
||||
- App 版本
|
||||
- 裝置語言設定
|
||||
- 基本使用行為(例如是否開啟 App、是否啟用提醒)
|
||||
這些資訊無法直接識別您的身分,僅用於維持 App 穩定性與改善使用體驗。
|
||||
|
||||
三、推送通知
|
||||
在取得您同意後,Hey Mama 可能會向您發送提醒推送,例如每日肯定語提示。
|
||||
- 推送內容僅包含一般文字資訊
|
||||
- 不包含任何敏感個人資料
|
||||
- 您可隨時於裝置系統設定中關閉通知功能
|
||||
|
||||
四、桌面小組件
|
||||
若您選擇使用桌面小組件,其顯示內容僅來自 App 內的文字肯定語,不會額外收集或傳送新的個人資料。
|
||||
|
||||
五、資訊使用方式
|
||||
我們僅於下列目的範圍內使用所收集的資訊:
|
||||
- 提供與維護 App 的基本功能
|
||||
- 改善內容呈現與使用體驗
|
||||
- 修復錯誤與提升系統穩定性
|
||||
我們不會:
|
||||
- 出售、出租或交換您的個人資料
|
||||
- 將資料用於第三方廣告投放
|
||||
|
||||
六、第三方服務
|
||||
目前 Hey Mama 未整合第三方廣告或行銷服務。
|
||||
如未來整合第三方分析或技術服務,我們將於本政策中另行說明並更新。
|
||||
|
||||
七、資料保存與安全
|
||||
我們僅在達成上述目的所需期間內保存相關資訊,並採取合理的技術與管理措施,以防止資料遭未經授權存取、洩漏、竄改或遺失。
|
||||
|
||||
八、未成年人說明
|
||||
Hey Mama 並非專為兒童設計,亦不刻意收集未滿 13 歲使用者的個人資料。
|
||||
若您為未成年人,請在監護人同意與陪同下使用本 App。
|
||||
|
||||
九、隱私權政策的變更
|
||||
我們可能會不定期更新本隱私權政策。
|
||||
更新後的版本將公布於 App 內或相關頁面,您於政策更新後繼續使用本 App,即視為同意更新內容。
|
||||
"""
|
||||
|
||||
TERMS_OF_USE_MD = """Hey Mama – Terms of Use
|
||||
Last updated: February 2026
|
||||
Welcome to Hey Mama (“the App,” “we,” or “us”).
|
||||
Please read these Terms of Use carefully before downloading, accessing, or using the App. By using the App, you agree to be bound by these Terms.
|
||||
|
||||
1. Intended Audience
|
||||
Hey Mama is intended for adults only.
|
||||
The App is not designed for children, and users must ensure they have the legal capacity to use the App under applicable laws.
|
||||
|
||||
2. Services Provided
|
||||
Hey Mama provides text-based content and features, including but not limited to:
|
||||
- Daily affirmations and mindfulness text
|
||||
- User-configured reminders and push notifications
|
||||
- Home screen widgets displaying affirmation text
|
||||
- Personalized reading or saving experiences (where applicable)
|
||||
All content is provided for general emotional support and self-reflection purposes only and does not constitute medical, psychological, or professional advice.
|
||||
|
||||
3. Acceptable Use
|
||||
You agree to:
|
||||
- Use the App for personal, non-commercial purposes only
|
||||
- Not copy, reproduce, distribute, sell, modify, reverse engineer, or attempt to extract the source code of the App
|
||||
- Not engage in any activity that may interfere with the App’s functionality, stability, or user experience
|
||||
We reserve the right to restrict or terminate access if these Terms are violated.
|
||||
|
||||
4. Push Notifications
|
||||
The App may send push notifications based on your settings (e.g. daily affirmation reminders).
|
||||
- Notifications contain general text only
|
||||
- No sensitive personal data is included
|
||||
- You may disable notifications at any time through your device settings
|
||||
|
||||
5. Intellectual Property
|
||||
All content, design elements, interfaces, and materials within the App are owned by us or our licensors and are protected by applicable intellectual property laws.
|
||||
Unauthorized use, reproduction, or distribution is strictly prohibited.
|
||||
|
||||
6. Disclaimer
|
||||
The App and its content are provided for informational and self-support purposes only.
|
||||
- We do not guarantee specific emotional or psychological outcomes
|
||||
- The App does not replace professional medical, mental health, or legal advice
|
||||
- You are solely responsible for how you use the content
|
||||
|
||||
7. Service Availability
|
||||
We may modify, suspend, or discontinue any part of the App at any time due to maintenance, updates, or circumstances beyond our control.
|
||||
We are not liable for any loss or damage resulting from such interruptions or changes.
|
||||
|
||||
8. Changes to These Terms
|
||||
We may update these Terms of Use from time to time.
|
||||
Updated versions will be made available within the App or related pages. Continued use of the App after changes indicates acceptance of the revised Terms.
|
||||
|
||||
9. Governing Law
|
||||
These Terms shall be governed by and construed in accordance with the applicable laws of our operating jurisdiction.
|
||||
|
||||
---
|
||||
Hey Mama 使用條款
|
||||
最後更新日期:2026 年 2 月
|
||||
歡迎使用 Hey Mama(以下簡稱「本 App」、「我們」)。
|
||||
在下載、存取或使用本 App 前,請您仔細閱讀本使用條款。當您開始使用本 App,即表示您已閱讀、理解並同意遵守本條款。
|
||||
|
||||
1. 服務對象與使用資格
|
||||
Hey Mama 僅供成年人使用(intended for adults)。
|
||||
本 App 並非為兒童設計,使用者應確認自己具備依所在地法律使用本服務的完全行為能力。
|
||||
|
||||
2. 服務內容
|
||||
Hey Mama 提供以文字形式為主的內容與功能,包括但不限於:
|
||||
- 每日肯定語與正念文字內容
|
||||
- 使用者設定的提醒與推送通知
|
||||
- 桌面小組件顯示肯定語文字
|
||||
- 內容閱讀與收藏等個人化體驗(如適用)
|
||||
本 App 所提供之內容僅作為日常情緒支持與自我提醒用途,不構成任何形式的醫療、心理諮商或專業建議。
|
||||
|
||||
3. 使用方式與限制
|
||||
使用者同意:
|
||||
- 僅將本 App 用於個人、非商業用途
|
||||
- 不以任何方式複製、重製、散布、出售、反編譯或試圖取得本 App 之原始碼
|
||||
- 不進行任何可能影響 App 正常運作、穩定性或其他使用者體驗之行為
|
||||
如有違反,我們有權在不另行通知的情況下限制或終止使用權限。
|
||||
|
||||
4. 推送通知
|
||||
本 App 可能依使用者設定發送推送通知(例如每日肯定語提醒)。
|
||||
- 推送內容僅為一般文字資訊
|
||||
- 不包含個人化敏感資料
|
||||
- 您可隨時透過裝置系統設定關閉通知功能
|
||||
|
||||
5. 智慧財產權
|
||||
本 App 及其所有內容(包含但不限於文字、設計、介面、版面配置與視覺元素)之智慧財產權,均屬於我們或合法授權方所有。
|
||||
未經事前書面同意,任何形式之使用、修改、重製或散布皆屬禁止。
|
||||
|
||||
6. 免責聲明
|
||||
本 App 所提供之內容僅供一般參考與自我提醒之用。
|
||||
- 我們不保證內容能產生特定心理、情緒或行為結果
|
||||
- 本 App 不取代任何專業醫療、心理或法律建議
|
||||
- 使用者應自行判斷內容是否適合自身狀況
|
||||
|
||||
7. 服務中斷與變更
|
||||
我們可能因系統維護、功能調整、更新或其他不可抗力因素,暫時中斷或變更本 App 之全部或部分功能。
|
||||
對於因此可能造成的任何直接或間接損失,我們不負任何責任。
|
||||
|
||||
8. 條款修改
|
||||
我們可能不定期更新本使用條款。
|
||||
更新後的版本將公布於 App 內或相關頁面,您於條款更新後繼續使用本 App,即視為同意更新內容。
|
||||
|
||||
9. 準據法與管轄
|
||||
本使用條款之解釋與適用,悉依我們所在地之相關法律規定處理。
|
||||
"""
|
||||
|
||||
|
||||
def split_bilingual_markdown(text: str) -> tuple[str, str | None]:
|
||||
"""
|
||||
按文档中的 `---` 分隔符将内容拆成两段。
|
||||
|
||||
返回:
|
||||
- 第一段:默认视为英文
|
||||
- 第二段:默认视为繁体(可为空)
|
||||
"""
|
||||
|
||||
parts = re.split(r"^\s*---\s*$", text, maxsplit=1, flags=re.MULTILINE)
|
||||
if not parts:
|
||||
return "", None
|
||||
if len(parts) == 1:
|
||||
return parts[0].strip(), None
|
||||
return parts[0].strip(), parts[1].strip()
|
||||
|
||||
|
||||
def choose_content_by_lang(text: str, lang: ResolvedLang) -> tuple[str, ResolvedLang]:
|
||||
"""
|
||||
根据 lang 选择展示内容。若 tc 内容缺失则回退 en。
|
||||
"""
|
||||
|
||||
en, tc = split_bilingual_markdown(text)
|
||||
if lang == "tc" and tc:
|
||||
return tc, "tc"
|
||||
return en, "en"
|
||||
|
||||
|
||||
def render_as_simple_html(title: str, content: str) -> str:
|
||||
"""
|
||||
将文本以简单 HTML 的方式展示(使用 pre 保留换行并自动换行)。
|
||||
不做 Markdown 渲染,避免引入额外依赖,确保“最小可用、必有内容”。
|
||||
"""
|
||||
|
||||
safe_title = html.escape(title)
|
||||
safe_content = html.escape(content)
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{safe_title}</title>
|
||||
<style>
|
||||
:root {{
|
||||
color-scheme: light dark;
|
||||
}}
|
||||
body {{
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, "PingFang TC", "PingFang SC", "Noto Sans CJK TC", "Noto Sans CJK SC", sans-serif;
|
||||
line-height: 1.6;
|
||||
}}
|
||||
pre {{
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<pre>{safe_content}</pre>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@@ -3,6 +3,8 @@ from fastapi import FastAPI
|
||||
from app.core.config import get_settings
|
||||
from app.api.v1.reco import router as reco_router
|
||||
from app.api.v1.user_profile_scoring import router as user_profile_router
|
||||
from app.api.v1.legal import router as legal_router
|
||||
from app.api.v1.push import router as push_router
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@@ -19,6 +21,8 @@ def create_app() -> FastAPI:
|
||||
# 业务路由
|
||||
app.include_router(user_profile_router)
|
||||
app.include_router(reco_router)
|
||||
app.include_router(legal_router)
|
||||
app.include_router(push_router)
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
|
||||
@@ -2,3 +2,12 @@
|
||||
Celery 任务集合。
|
||||
"""
|
||||
|
||||
# 重要:
|
||||
# - 本项目的任务按文件拆分在 app/tasks/*.py 中
|
||||
# - Celery autodiscover 通常只会导入 app.tasks(即本包),不会自动递归导入子模块
|
||||
# - 因此这里需要显式导入各任务模块,确保 shared_task 被注册
|
||||
|
||||
from app.tasks import ping as _ping # noqa: F401
|
||||
from app.tasks import reco as _reco # noqa: F401
|
||||
from app.tasks import push as _push # noqa: F401
|
||||
|
||||
|
||||
312
server/app/tasks/push.py
Normal file
312
server/app/tasks/push.py
Normal file
@@ -0,0 +1,312 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
from celery import current_app, shared_task
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.db.models.push_preference import PushPreference
|
||||
from app.db.models.push_send_log import PushSendLog
|
||||
from app.db.models.push_token import PushToken
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.features.personalized_reco.content_repository.types import normalize_locale
|
||||
from app.features.user_profile_scoring.scoring import build_user_profile_from_questionnaire
|
||||
from app.features.user_profile_scoring.types import QuestionnaireAnswersV1_2, UserProfileV1_2
|
||||
|
||||
|
||||
def _ensure_tz(tz_name: Optional[str]) -> ZoneInfo:
|
||||
raw = (tz_name or "").strip()
|
||||
if not raw:
|
||||
return ZoneInfo("UTC")
|
||||
try:
|
||||
return ZoneInfo(raw)
|
||||
except Exception:
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
def _pick_reco_locale(pref_locale: Optional[str]) -> str:
|
||||
"""
|
||||
将客户端 locale(如 zh-CN/en/zh-TW)映射到推荐系统 locale(en/tc)。
|
||||
"""
|
||||
|
||||
raw = (pref_locale or "").strip().lower()
|
||||
if raw.startswith("zh"):
|
||||
return "tc"
|
||||
# 其他语言在当前版本统一回退到 en(与现有 reco/ legal 链路一致)
|
||||
return "en"
|
||||
|
||||
|
||||
def _pick_title(locale: str) -> str:
|
||||
return "每日提醒" if str(locale) == "tc" else "Daily Reminder"
|
||||
|
||||
|
||||
async def _send_expo_push(*, to: str, title: str, body: str, data: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
||||
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)
|
||||
res.raise_for_status()
|
||||
return res.json()
|
||||
|
||||
|
||||
def _uniform_jitter_times(*, start: datetime, end: datetime, n: int) -> list[datetime]:
|
||||
"""
|
||||
将窗口均匀切分为 n 个区间,并在每段内随机取一个时间点(抖动)。
|
||||
"""
|
||||
|
||||
if n <= 0:
|
||||
return []
|
||||
total = (end - start).total_seconds()
|
||||
if total <= 0:
|
||||
return []
|
||||
|
||||
out: list[datetime] = []
|
||||
for i in range(n):
|
||||
seg_start = start + timedelta(seconds=total * i / n)
|
||||
seg_end = start + timedelta(seconds=total * (i + 1) / n)
|
||||
seg = (seg_end - seg_start).total_seconds()
|
||||
if seg <= 0:
|
||||
out.append(seg_start)
|
||||
continue
|
||||
jitter = random.random() * seg
|
||||
out.append(seg_start + timedelta(seconds=jitter))
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ScheduleTarget:
|
||||
local_date: date
|
||||
start_local: datetime
|
||||
end_local: datetime
|
||||
|
||||
|
||||
def _pick_schedule_target(*, now_utc: datetime, tz: ZoneInfo) -> _ScheduleTarget:
|
||||
"""
|
||||
为某个用户选择“要生成计划的本地日期”:
|
||||
- 若当前本地时间 < 09:00:生成“今天”
|
||||
- 否则:生成“明天”
|
||||
|
||||
目的:确保生成的时间点尽量都在未来,避免任务 ETA 立刻触发导致体验异常。
|
||||
"""
|
||||
|
||||
now_local = now_utc.astimezone(tz)
|
||||
today = now_local.date()
|
||||
day = today if now_local.time() < time(9, 0) else (today + timedelta(days=1))
|
||||
start_local = datetime.combine(day, time(9, 0), tzinfo=tz)
|
||||
end_local = datetime.combine(day + timedelta(days=1), time(0, 0), tzinfo=tz) # 24:00
|
||||
return _ScheduleTarget(local_date=day, start_local=start_local, end_local=end_local)
|
||||
|
||||
|
||||
async def _generate_schedule_once(*, now_utc: datetime, max_users: int = 5000) -> dict[str, int]:
|
||||
"""
|
||||
为所有开启每日提醒的用户生成当天/明天的推送计划,并投递 ETA 发送任务。
|
||||
|
||||
幂等:
|
||||
- `push_send_log` 唯一键(client_user_id + local_date + slot_index)保证同一天不会重复排程
|
||||
- 即使重复运行,最多只会补齐缺失 slot
|
||||
"""
|
||||
|
||||
created = 0
|
||||
scheduled = 0
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
q = (
|
||||
select(PushPreference)
|
||||
.where(PushPreference.enabled == True, PushPreference.times_per_day > 0) # noqa: E712
|
||||
.limit(int(max_users))
|
||||
)
|
||||
rows = await session.execute(q)
|
||||
prefs = list(rows.scalars().all())
|
||||
|
||||
for pref in prefs:
|
||||
tz = _ensure_tz(pref.timezone)
|
||||
target = _pick_schedule_target(now_utc=now_utc, tz=tz)
|
||||
|
||||
n = int(pref.times_per_day or 0)
|
||||
n = max(0, min(5, n))
|
||||
if n <= 0:
|
||||
continue
|
||||
|
||||
times_local = _uniform_jitter_times(start=target.start_local, end=target.end_local, n=n)
|
||||
|
||||
for idx, dt_local in enumerate(times_local, start=1):
|
||||
dt_utc = dt_local.astimezone(timezone.utc)
|
||||
|
||||
# 先尝试插入日志(幂等)
|
||||
log = PushSendLog(
|
||||
client_user_id=pref.client_user_id,
|
||||
local_date=target.local_date,
|
||||
slot_index=int(idx),
|
||||
scheduled_at=dt_utc.replace(tzinfo=None), # DB 存 naive(约定为 UTC)
|
||||
status="scheduled",
|
||||
)
|
||||
session.add(log)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
# 可能已存在(唯一键冲突),跳过
|
||||
continue
|
||||
|
||||
created += 1
|
||||
|
||||
# 投递 ETA 发送任务
|
||||
current_app.send_task(
|
||||
"tasks.push.send_scheduled",
|
||||
kwargs={
|
||||
"client_user_id": pref.client_user_id,
|
||||
"local_date": target.local_date.isoformat(),
|
||||
"slot_index": int(idx),
|
||||
},
|
||||
eta=dt_utc,
|
||||
)
|
||||
scheduled += 1
|
||||
|
||||
return {"created": created, "scheduled": scheduled}
|
||||
|
||||
|
||||
@shared_task(name="tasks.push.generate_daily_schedule")
|
||||
def generate_daily_schedule(*, max_users: int = 5000) -> dict[str, int]:
|
||||
"""
|
||||
生成每日推送计划(入口任务)。
|
||||
|
||||
说明:
|
||||
- 建议由 celery beat 定时触发(见 app/worker.py)
|
||||
- 入参尽量小,避免 Redis 队列膨胀
|
||||
"""
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
return asyncio.run(_generate_schedule_once(now_utc=now_utc, max_users=int(max_users)))
|
||||
|
||||
|
||||
async def _send_once_async(*, client_user_id: str, local_date: date, slot_index: int) -> dict[str, Any]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 1) 查 log,避免重复发送
|
||||
qlog = select(PushSendLog).where(
|
||||
PushSendLog.client_user_id == client_user_id,
|
||||
PushSendLog.local_date == local_date,
|
||||
PushSendLog.slot_index == int(slot_index),
|
||||
)
|
||||
rlog = await session.execute(qlog)
|
||||
log = rlog.scalar_one_or_none()
|
||||
if log is None:
|
||||
return {"status": "noop", "reason": "no_log"}
|
||||
if str(log.status) == "sent":
|
||||
return {"status": "noop", "reason": "already_sent"}
|
||||
|
||||
# 2) 当前偏好检查(用户可能中途关闭/改次数)
|
||||
qpref = select(PushPreference).where(PushPreference.client_user_id == client_user_id)
|
||||
rpref = await session.execute(qpref)
|
||||
pref = rpref.scalar_one_or_none()
|
||||
if pref is None or (not bool(pref.enabled)) or int(pref.times_per_day or 0) < int(slot_index):
|
||||
log.status = "skipped"
|
||||
log.error = "disabled_or_reduced"
|
||||
await session.commit()
|
||||
return {"status": "skipped"}
|
||||
|
||||
# 3) 找 token
|
||||
qtok = (
|
||||
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)
|
||||
)
|
||||
rtok = await session.execute(qtok)
|
||||
token = rtok.scalar_one_or_none()
|
||||
if token is None:
|
||||
log.status = "failed"
|
||||
log.error = "no_active_token"
|
||||
await session.commit()
|
||||
return {"status": "failed", "reason": "no_active_token"}
|
||||
|
||||
# 4) 生成文案(复用推荐模块 push 场景)
|
||||
reco_locale = str(normalize_locale(_pick_reco_locale(pref.locale)))
|
||||
title = _pick_title(reco_locale)
|
||||
|
||||
if pref.user_profile_json:
|
||||
user_profile = UserProfileV1_2.model_validate(pref.user_profile_json)
|
||||
else:
|
||||
# 无画像:用“全跳过”的默认画像(降个性化/降风险)
|
||||
user_profile = UserProfileV1_2.model_validate(
|
||||
build_user_profile_from_questionnaire(QuestionnaireAnswersV1_2()).model_dump()
|
||||
)
|
||||
|
||||
# 直接复用 reco 的 Celery 任务实现(同步函数)
|
||||
from app.tasks.reco import generate as reco_generate
|
||||
|
||||
reco_payload = reco_generate(scene="push", user_profile=user_profile.model_dump(), k=1, locale=reco_locale)
|
||||
body = ""
|
||||
try:
|
||||
items = (reco_payload or {}).get("items") or []
|
||||
if items and isinstance(items, list):
|
||||
body = str(items[0].get("text") or "").strip()
|
||||
except Exception:
|
||||
body = ""
|
||||
|
||||
if not body:
|
||||
body = "给自己一句温柔的话。"
|
||||
|
||||
# 5) 发送
|
||||
try:
|
||||
expo_res = await _send_expo_push(
|
||||
to=str(token.push_token),
|
||||
title=title,
|
||||
body=body,
|
||||
data={"client_user_id": client_user_id, "scene": "push"},
|
||||
)
|
||||
except Exception as e:
|
||||
log.status = "failed"
|
||||
log.error = f"send_failed:{type(e).__name__}"
|
||||
await session.commit()
|
||||
return {"status": "failed", "error": str(e)}
|
||||
|
||||
# 6) 解析 Expo 回执,必要时停用 token
|
||||
try:
|
||||
data_list = (expo_res or {}).get("data") or []
|
||||
if data_list and isinstance(data_list, list):
|
||||
first = data_list[0] or {}
|
||||
if first.get("status") == "error":
|
||||
details = first.get("details") or {}
|
||||
err = str(details.get("error") or first.get("message") or "expo_error")
|
||||
log.status = "failed"
|
||||
log.error = err
|
||||
if "DeviceNotRegistered" in err:
|
||||
token.is_active = False
|
||||
await session.commit()
|
||||
return {"status": "failed", "expo": expo_res}
|
||||
except Exception:
|
||||
# 忽略解析异常,继续按成功处理
|
||||
pass
|
||||
|
||||
log.status = "sent"
|
||||
log.sent_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
log.error = None
|
||||
await session.commit()
|
||||
return {"status": "sent", "expo": expo_res}
|
||||
|
||||
|
||||
@shared_task(name="tasks.push.send_scheduled")
|
||||
def send_scheduled(*, client_user_id: str, local_date: str, slot_index: int) -> dict[str, Any]:
|
||||
"""
|
||||
ETA 发送任务:发送某用户某天第 slot 条推送。
|
||||
"""
|
||||
|
||||
d = date.fromisoformat(str(local_date))
|
||||
return asyncio.run(_send_once_async(client_user_id=str(client_user_id), local_date=d, slot_index=int(slot_index)))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
@@ -38,6 +39,20 @@ celery_app.conf.update(
|
||||
task_default_routing_key=f"{prefix}:celery",
|
||||
)
|
||||
|
||||
# 自动发现任务(app/tasks 下的 shared_task)
|
||||
celery_app.autodiscover_tasks(["app.tasks"])
|
||||
# 定时任务(Celery Beat)
|
||||
# 说明:
|
||||
# - 每天运行一次“生成推送计划”,为所有开启每日提醒的用户生成当天/明天的随机抖动时间点,并投递 ETA 发送任务
|
||||
# - 这里按 UTC 00:10 触发一次;具体时间可按运维习惯调整
|
||||
celery_app.conf.timezone = "UTC"
|
||||
celery_app.conf.beat_schedule = {
|
||||
"push-generate-daily-schedule": {
|
||||
"task": "tasks.push.generate_daily_schedule",
|
||||
"schedule": crontab(minute=10, hour=0),
|
||||
"kwargs": {"max_users": 5000},
|
||||
"options": {"queue": f"{prefix}:celery"},
|
||||
}
|
||||
}
|
||||
|
||||
# 自动发现任务(约定:导入 app.tasks 触发其内部对子模块的显式导入)
|
||||
celery_app.autodiscover_tasks(["app"])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user