6 Commits

Author SHA1 Message Date
吕新雨
43e33eb991 fix:更新协议隐私问题 2026-02-25 17:42:34 +08:00
吕新雨
7fcc64f6e3 fix:更新技术支持网址 2026-02-25 17:13:29 +08:00
吕新雨
9d6829eceb fix:修复错误 2026-02-24 10:51:32 +08:00
吕新雨
4838bcef4b fix:更新图片 2026-02-24 10:44:36 +08:00
吕新雨
0fcf85a081 更新任务生成 2026-02-13 22:46:01 +08:00
吕新雨
62fcc4bfce fix:每日推荐修复 2026-02-12 13:54:34 +08:00
19 changed files with 260 additions and 26 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 143 KiB

View File

@@ -46,7 +46,7 @@ function getApiBaseUrl(env: AppRuntimeEnv): string {
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_PROD', getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'https://api.damer.fun'));
}
export const API_BASE_URL = getApiBaseUrl(APPpai qa
export const API_BASE_URL = getApiBaseUrl(APP_ENV);
/**
* 调试:打印环境变量注入结果(仅开发环境)

View File

@@ -0,0 +1,31 @@
"""add push_send_log payload snapshot
Revision ID: 0003_add_push_send_log_payload
Revises: 0002_init_push_tables
Create Date: 2026-02-12
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "0003_add_push_send_log_payload"
down_revision = "0002_init_push_tables"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("push_send_log", sa.Column("content_id", sa.Integer(), nullable=True, comment="推送文案内容 ID可选"))
op.add_column("push_send_log", sa.Column("title", sa.String(length=128), nullable=True, comment="推送标题(可选)"))
op.add_column("push_send_log", sa.Column("body", sa.Text(), nullable=True, comment="推送正文(可选)"))
def downgrade() -> None:
op.drop_column("push_send_log", "body")
op.drop_column("push_send_log", "title")
op.drop_column("push_send_log", "content_id")

View File

@@ -7,16 +7,70 @@ 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
from app.legal_docs import (
PRIVACY_POLICY_MD,
TERMS_OF_USE_MD,
choose_content_by_lang,
render_as_simple_html,
split_bilingual_markdown,
)
router = APIRouter(prefix="/v1/legal", tags=["legal"])
INTERNAL_SENTINEL = "__internal__"
# 技术支持页文案EN / TC
SUPPORT_MD = """Dear Mama | Technical Support
Last updated: February 2026
If you need technical support for Dear Mama, please contact us:
- Support email: leitinglan@project-c.org
Support scope (including but not limited to):
- App installation or update issues
- App crashes, freezes, or abnormal behavior
- Notification or widget related issues
- Difficulty accessing privacy policy or terms pages
To help us process your request faster, please include:
- Device model and OS version
- App version
- A brief issue description and occurrence time
- Screenshots or screen recording (if available)
Service commitment:
- We generally respond within 3-5 business days
- Emergency availability may vary by holidays and local time
---
Dear Mama技術支援
最後更新日期2026 年 2 月
如需 Dear Mama 的技術支援,請透過以下方式聯絡我們:
- 支援信箱leitinglan@project-c.org
支援範圍(包含但不限於):
- App 安裝或更新問題
- App 閃退、卡頓或異常行為
- 通知或小組件相關問題
- 隱私政策或使用條款頁面無法開啟
為了加快處理,建議提供:
- 裝置型號與系統版本
- App 版本號
- 問題描述與發生時間
- 截圖或螢幕錄影(如有)
服務承諾:
- 一般會在 3-5 個工作日內回覆
- 節假日或時區差異時,回覆時間可能延長
"""
# 当前多语言仅支持 EN / TC繁体
ResolvedLang = Literal["en", "tc"]
BILINGUAL_CONTENT_LANGUAGE = "en, zh-Hant"
class LegalLinksResponse(BaseModel):
@@ -85,6 +139,18 @@ def _normalize_internal_url(request: Request, url: str, internal_path: str) -> s
return _join_base_url(str(request.base_url), internal_path)
def _build_bilingual_content(text: str) -> str:
"""
固定输出双语协议内容EN 在前TC 在后。
若缺少 TC则仅返回 EN。
"""
en, tc = split_bilingual_markdown(text)
if not tc:
return en
return f"{en}\n\n---\n{tc}"
@router.get("/links", response_model=LegalLinksResponse)
async def get_legal_links(request: Request) -> LegalLinksResponse:
"""
@@ -109,11 +175,18 @@ 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 = "Dear Mama | Privacy Policy" if resolved == "en" else "Dear Mama隱私權政策"
page = render_as_simple_html(title=title, content=content)
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"})
if accept_language:
lang = _resolve_lang(accept_language)
content, resolved = choose_content_by_lang(PRIVACY_POLICY_MD, lang)
title = "Dear Mama | Privacy Policy" if resolved == "en" else "Dear Mama隱私權政策"
html_lang = "en" if resolved == "en" else "zh-Hant"
page = render_as_simple_html(title=title, content=content, html_lang=html_lang)
return HTMLResponse(content=page, headers={"Content-Language": html_lang})
content = _build_bilingual_content(PRIVACY_POLICY_MD)
title = "Dear Mama | Privacy Policy / 隱私權政策"
page = render_as_simple_html(title=title, content=content, html_lang="en")
return HTMLResponse(content=page, headers={"Content-Language": BILINGUAL_CONTENT_LANGUAGE})
@router.get("/terms", response_class=HTMLResponse)
@@ -123,9 +196,37 @@ 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 = "Dear Mama Terms of Use" if resolved == "en" else "Dear Mama 使用條款"
page = render_as_simple_html(title=title, content=content)
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"})
if accept_language:
lang = _resolve_lang(accept_language)
content, resolved = choose_content_by_lang(TERMS_OF_USE_MD, lang)
title = "Dear Mama Terms of Use" if resolved == "en" else "Dear Mama 使用條款"
html_lang = "en" if resolved == "en" else "zh-Hant"
page = render_as_simple_html(title=title, content=content, html_lang=html_lang)
return HTMLResponse(content=page, headers={"Content-Language": html_lang})
content = _build_bilingual_content(TERMS_OF_USE_MD)
title = "Dear Mama Terms of Use / 使用條款"
page = render_as_simple_html(title=title, content=content, html_lang="en")
return HTMLResponse(content=page, headers={"Content-Language": BILINGUAL_CONTENT_LANGUAGE})
@router.get("/support", response_class=HTMLResponse)
async def get_support_page(request: Request) -> HTMLResponse:
"""
技术支持页面(用于 App 审核的可访问 URL
"""
accept_language = request.headers.get("accept-language")
if accept_language:
lang = _resolve_lang(accept_language)
content, resolved = choose_content_by_lang(SUPPORT_MD, lang)
title = "Dear Mama | Technical Support" if resolved == "en" else "Dear Mama技術支援"
html_lang = "en" if resolved == "en" else "zh-Hant"
page = render_as_simple_html(title=title, content=content, html_lang=html_lang)
return HTMLResponse(content=page, headers={"Content-Language": html_lang})
content = _build_bilingual_content(SUPPORT_MD)
title = "Dear Mama | Technical Support / 技術支援"
page = render_as_simple_html(title=title, content=content, html_lang="en")
return HTMLResponse(content=page, headers={"Content-Language": BILINGUAL_CONTENT_LANGUAGE})

View File

@@ -153,6 +153,32 @@ async def register(req: PushRegisterRequest, db: AsyncSession = Depends(get_db))
token.is_active = True
token.last_seen_at = _ensure_utc(now)
# 额外:尽早写入/补齐时区与语言(用于按用户时区生成排程)
# 说明:
# - 用户首次授权后会立即调用 /register但不一定马上进入“每日提醒”确认页
# - 若 push_preferences 里 timezone 为空,会导致排程回退到 UTC体验不符合预期
if req.device_meta:
tz = (req.device_meta.timezone or "").strip() or None
loc = (req.device_meta.locale or "").strip() or None
if tz or loc:
qpref = select(PushPreference).where(PushPreference.client_user_id == req.client_user_id)
rpref = await db.execute(qpref)
pref = rpref.scalar_one_or_none()
if pref is None:
pref = PushPreference(
client_user_id=req.client_user_id,
enabled=False,
times_per_day=0,
timezone=tz,
locale=loc,
)
db.add(pref)
else:
if tz and not (pref.timezone or "").strip():
pref.timezone = tz
if loc and not (pref.locale or "").strip():
pref.locale = loc
await db.commit()
return {"status": "ok"}
@@ -187,8 +213,11 @@ async def put_preferences(req: PushPreferencesRequest, db: AsyncSession = Depend
else:
pref.enabled = enabled
pref.times_per_day = times
pref.timezone = req.timezone
pref.locale = req.locale
# 注意:只在客户端显式传入时覆盖,避免把已保存的 timezone/locale 清空导致排程回退到 UTC
if req.timezone is not None:
pref.timezone = req.timezone
if req.locale is not None:
pref.locale = req.locale
if req.user_profile is not None:
pref.user_profile_json = req.user_profile.model_dump(mode="json")

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import Date, DateTime, Index, SmallInteger, String, Text, UniqueConstraint, func
from sqlalchemy import Date, DateTime, Index, Integer, SmallInteger, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -33,5 +33,10 @@ class PushSendLog(Base):
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="失败原因(可选)")
# 发送内容快照(用于观测 + 去重)
content_id: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="推送文案内容 ID可选")
title: Mapped[str | None] = mapped_column(String(length=128), nullable=True, comment="推送标题(可选)")
body: 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

@@ -265,7 +265,7 @@ def choose_content_by_lang(text: str, lang: ResolvedLang) -> tuple[str, Resolved
return en, "en"
def render_as_simple_html(title: str, content: str) -> str:
def render_as_simple_html(title: str, content: str, html_lang: str = "en") -> str:
"""
将文本以简单 HTML 的方式展示(使用 pre 保留换行并自动换行)。
不做 Markdown 渲染,避免引入额外依赖,确保“最小可用、必有内容”。
@@ -273,8 +273,9 @@ def render_as_simple_html(title: str, content: str) -> str:
safe_title = html.escape(title)
safe_content = html.escape(content)
safe_lang = html.escape(html_lang or "en")
return f"""<!doctype html>
<html lang="en">
<html lang="{safe_lang}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

View File

@@ -44,7 +44,8 @@ def _pick_reco_locale(pref_locale: Optional[str]) -> str:
def _pick_title(locale: str) -> str:
return "每日提醒" if str(locale) == "tc" else "Daily Reminder"
# 需求tc 语言使用繁体标题
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]:
@@ -67,7 +68,11 @@ async def _send_expo_push(*, to: str, title: str, body: str, data: Optional[dict
def _uniform_jitter_times(*, start: datetime, end: datetime, n: int) -> list[datetime]:
"""
将窗口均匀切分为 n 个区间,并在每段内随机取一个时间点(抖动)
将窗口均匀切分为 n 个区间,并在每段内取“中点 + 受限抖动”的时间点
目的:
- 尽量均匀分布(避免相邻两条推送随机到非常接近的时间)
- 仍保留一定随机性,避免过于机械
"""
if n <= 0:
@@ -84,8 +89,15 @@ def _uniform_jitter_times(*, start: datetime, end: datetime, n: int) -> list[dat
if seg <= 0:
out.append(seg_start)
continue
jitter = random.random() * seg
out.append(seg_start + timedelta(seconds=jitter))
# 受限抖动:在每段的 [25%, 75%] 区间内取点
# 这样相邻两段的最小间隔为 50% 段长,能显著减少“随机挤在一起”。
mid = seg_start + timedelta(seconds=seg * 0.5)
jitter = (random.random() - 0.5) * (seg * 0.5) # [-0.25*seg, +0.25*seg]
out.append(mid + timedelta(seconds=jitter))
# 保序(理论上天然有序,这里再保险)
out.sort()
return out
@@ -288,17 +300,65 @@ async def _send_once_async(*, client_user_id: str, local_date: date, slot_index:
# 关键:这里不能调用 tasks.reco.generate内部会 asyncio.run否则会嵌套事件循环崩溃。
from app.tasks.reco import run_reco_payload_async
body = ""
# 去重:用户推送过的内容尽量不再推送
# 说明:
# - 依赖 push_send_log.content_id需先完成对应 DB 迁移)
# - 为避免历史过长导致 already_recommended_ids 过大,这里取“最近若干条已推送内容”近似全量去重
used_ids: list[int] = []
try:
reco_payload = await run_reco_payload_async(scene="push", user_profile=user_profile, k=1, locale=reco_locale)
qused = (
select(PushSendLog.content_id)
.where(
PushSendLog.client_user_id == client_user_id,
PushSendLog.content_id.is_not(None),
PushSendLog.id != log.id,
)
# 优先排除最近发送过的内容
.order_by(PushSendLog.local_date.desc(), PushSendLog.slot_index.desc())
.limit(5000)
)
rused = await session.execute(qused)
used_ids = [int(x) for x in rused.scalars().all() if x is not None]
except Exception:
used_ids = []
body = ""
picked_content_id: int | None = None
try:
reco_payload = await run_reco_payload_async(
scene="push",
user_profile=user_profile,
k=3,
locale=reco_locale,
already_recommended_ids=used_ids,
)
items = (reco_payload or {}).get("items") or []
if items and isinstance(items, list):
body = str(items[0].get("text") or "").strip()
for it in items:
if not isinstance(it, dict):
continue
cid = it.get("content_id")
txt = str(it.get("text") or "").strip()
if not txt:
continue
if cid is not None:
try:
cid_i = int(cid)
except Exception:
cid_i = None
else:
cid_i = None
if cid_i is not None and cid_i in used_ids:
continue
picked_content_id = cid_i
body = txt
break
except Exception:
body = ""
if not body:
body = "给自己一句温柔的话。"
# tc 语言兜底文案使用繁体
body = "給自己一句溫柔的話。" if reco_locale == "tc" else "给自己一句温柔的话。"
# 5) 发送
expo_res = await _send_expo_push(
@@ -329,6 +389,9 @@ async def _send_once_async(*, client_user_id: str, local_date: date, slot_index:
log.status = "sent"
log.sent_at = datetime.now(timezone.utc).replace(tzinfo=None)
log.error = None
log.title = title
log.body = body
log.content_id = picked_content_id
await session.commit()
return {"status": "sent", "expo": expo_res}
except Exception as e:

View File

@@ -53,7 +53,8 @@ celery_app.conf.beat_schedule = {
},
"push-generate-daily-schedule": {
"task": "tasks.push.generate_daily_schedule",
"schedule": crontab(minute=10, hour=0),
# 由“每天一次”调整为“每 2 小时一次”UTC
"schedule": crontab(minute=10, hour="*/2"),
"kwargs": {"max_users": 5000},
"options": {"queue": f"{prefix}:celery"},
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -202,6 +202,9 @@
- 后端新增内置协议内容页:
- `GET /v1/legal/privacy`
- `GET /v1/legal/terms`
- `GET /v1/legal/support`(技术支持页面,提供审核可用的公开支持信息)
- 协议展示策略调整:`/v1/legal/privacy``/v1/legal/terms``/v1/legal/support` 在携带 `Accept-Language` 时按语言单语展示EN/TC缺省时展示 EN + TCEN 在前、TC 在后)
- 协议页面语义修复HTML 根节点 `lang` 属性不再写死,改为随页面实际语言输出(单语 en/zh-Hant双语默认 en
- 客户端新增协议接口封装 `client/src/services/legalApi.ts`
- 客户端工程化:新增统一 HTTP 封装 `client/src/utils/http.ts`baseURL/超时/JSON/统一错误),并将 `legalApi.ts` / `recoApi.ts` 接入
- 客户端接入两处入口:`app/(splash)/splash.tsx``components/home/ProfileModal.tsx`