fix:小组件- PUSH

This commit is contained in:
吕新雨
2026-02-03 17:43:58 +08:00
parent d742b398ef
commit c1c2c6197d
66 changed files with 4888 additions and 479 deletions

View File

@@ -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:
"""
推送相关接口限流:按 IP1 分钟 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
View 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
View 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}

View File

@@ -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,

View File

@@ -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"]

View 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="每天推送次数05",
)
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="更新时间",
)

View 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="创建时间")

View 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
View 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 Apps 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 Apps 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>
"""

View File

@@ -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:

View File

@@ -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
View 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映射到推荐系统 localeen/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)))

View File

@@ -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"])