5 Commits

Author SHA1 Message Date
吕新雨
0ad21da246 fix:更新隐私协议语言问题 2026-02-25 17:46:59 +08:00
吕新雨
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
15 changed files with 156 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')); 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

@@ -7,16 +7,70 @@ from fastapi.responses import HTMLResponse
from pydantic import BaseModel, HttpUrl from pydantic import BaseModel, HttpUrl
from app.core.config import get_settings 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"]) router = APIRouter(prefix="/v1/legal", tags=["legal"])
INTERNAL_SENTINEL = "__internal__" 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繁体 # 当前多语言仅支持 EN / TC繁体
ResolvedLang = Literal["en", "tc"] ResolvedLang = Literal["en", "tc"]
BILINGUAL_CONTENT_LANGUAGE = "en, zh-Hant"
class LegalLinksResponse(BaseModel): class LegalLinksResponse(BaseModel):
@@ -36,13 +90,23 @@ def _resolve_lang(accept_language: Optional[str]) -> ResolvedLang:
if not accept_language: if not accept_language:
return "en" return "en"
s = accept_language.lower()
# 目前只支持 EN / TC只要是中文或显式 tc都归到 tc # 按语言优先级顺序逐项解析,避免“只要包含 zh 就全判 tc”。
if "tc" in s: # 例如en-US,en;q=0.9,zh-CN;q=0.8 应命中 en而不是 tc。
return "tc" items = [seg.strip().lower() for seg in accept_language.split(",") if seg.strip()]
if "zh" in s or "hant" in s or "tw" in s or "hk" in s or "mo" in s: for item in items:
return "tc" lang_tag = item.split(";", 1)[0].strip()
if not lang_tag:
continue
if lang_tag == "tc":
return "tc"
if lang_tag.startswith(("zh-hant", "zh-tw", "zh-hk", "zh-mo")):
return "tc"
if lang_tag.startswith("zh"):
return "tc"
if lang_tag.startswith("en"):
return "en"
return "en" return "en"
@@ -85,6 +149,18 @@ def _normalize_internal_url(request: Request, url: str, internal_path: str) -> s
return _join_base_url(str(request.base_url), internal_path) 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) @router.get("/links", response_model=LegalLinksResponse)
async def get_legal_links(request: Request) -> LegalLinksResponse: async def get_legal_links(request: Request) -> LegalLinksResponse:
""" """
@@ -109,11 +185,18 @@ async def get_privacy_policy(request: Request) -> HTMLResponse:
""" """
accept_language = request.headers.get("accept-language") accept_language = request.headers.get("accept-language")
lang = _resolve_lang(accept_language) if accept_language:
content, resolved = choose_content_by_lang(PRIVACY_POLICY_MD, lang) lang = _resolve_lang(accept_language)
title = "Dear Mama | Privacy Policy" if resolved == "en" else "Dear Mama隱私權政策" content, resolved = choose_content_by_lang(PRIVACY_POLICY_MD, lang)
page = render_as_simple_html(title=title, content=content) title = "Dear Mama | Privacy Policy" if resolved == "en" else "Dear Mama隱私權政策"
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"}) 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) @router.get("/terms", response_class=HTMLResponse)
@@ -123,9 +206,37 @@ async def get_terms_of_use(request: Request) -> HTMLResponse:
""" """
accept_language = request.headers.get("accept-language") accept_language = request.headers.get("accept-language")
lang = _resolve_lang(accept_language) if accept_language:
content, resolved = choose_content_by_lang(TERMS_OF_USE_MD, lang) lang = _resolve_lang(accept_language)
title = "Dear Mama Terms of Use" if resolved == "en" else "Dear Mama 使用條款" content, resolved = choose_content_by_lang(TERMS_OF_USE_MD, lang)
page = render_as_simple_html(title=title, content=content) title = "Dear Mama Terms of Use" if resolved == "en" else "Dear Mama 使用條款"
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"}) 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

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

View File

@@ -68,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]: def _uniform_jitter_times(*, start: datetime, end: datetime, n: int) -> list[datetime]:
""" """
将窗口均匀切分为 n 个区间,并在每段内随机取一个时间点(抖动) 将窗口均匀切分为 n 个区间,并在每段内取“中点 + 受限抖动”的时间点
目的:
- 尽量均匀分布(避免相邻两条推送随机到非常接近的时间)
- 仍保留一定随机性,避免过于机械
""" """
if n <= 0: if n <= 0:
@@ -85,8 +89,15 @@ def _uniform_jitter_times(*, start: datetime, end: datetime, n: int) -> list[dat
if seg <= 0: if seg <= 0:
out.append(seg_start) out.append(seg_start)
continue 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 return out
@@ -289,18 +300,22 @@ async def _send_once_async(*, client_user_id: str, local_date: date, slot_index:
# 关键:这里不能调用 tasks.reco.generate内部会 asyncio.run否则会嵌套事件循环崩溃。 # 关键:这里不能调用 tasks.reco.generate内部会 asyncio.run否则会嵌套事件循环崩溃。
from app.tasks.reco import run_reco_payload_async from app.tasks.reco import run_reco_payload_async
# 去重:同一用户同一天尽量不重复推送相同 content # 去重:用户推送过的内容尽量不再推送
# 说明:
# - 依赖 push_send_log.content_id需先完成对应 DB 迁移)
# - 为避免历史过长导致 already_recommended_ids 过大,这里取“最近若干条已推送内容”近似全量去重
used_ids: list[int] = [] used_ids: list[int] = []
try: try:
qused = ( qused = (
select(PushSendLog.content_id) select(PushSendLog.content_id)
.where( .where(
PushSendLog.client_user_id == client_user_id, PushSendLog.client_user_id == client_user_id,
PushSendLog.local_date == local_date,
PushSendLog.content_id.is_not(None), PushSendLog.content_id.is_not(None),
PushSendLog.id != log.id, PushSendLog.id != log.id,
) )
.order_by(PushSendLog.slot_index.asc()) # 优先排除最近发送过的内容
.order_by(PushSendLog.local_date.desc(), PushSendLog.slot_index.desc())
.limit(5000)
) )
rused = await session.execute(qused) rused = await session.execute(qused)
used_ids = [int(x) for x in rused.scalars().all() if x is not None] used_ids = [int(x) for x in rused.scalars().all() if x is not None]

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/privacy`
- `GET /v1/legal/terms` - `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` - 客户端新增协议接口封装 `client/src/services/legalApi.ts`
- 客户端工程化:新增统一 HTTP 封装 `client/src/utils/http.ts`baseURL/超时/JSON/统一错误),并将 `legalApi.ts` / `recoApi.ts` 接入 - 客户端工程化:新增统一 HTTP 封装 `client/src/utils/http.ts`baseURL/超时/JSON/统一错误),并将 `legalApi.ts` / `recoApi.ts` 接入
- 客户端接入两处入口:`app/(splash)/splash.tsx``components/home/ProfileModal.tsx` - 客户端接入两处入口:`app/(splash)/splash.tsx``components/home/ProfileModal.tsx`