243 lines
8.3 KiB
Python
243 lines
8.3 KiB
Python
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,
|
||
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):
|
||
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"
|
||
|
||
# 按语言优先级顺序逐项解析,避免“只要包含 zh 就全判 tc”。
|
||
# 例如:en-US,en;q=0.9,zh-CN;q=0.8 应命中 en,而不是 tc。
|
||
items = [seg.strip().lower() for seg in accept_language.split(",") if seg.strip()]
|
||
for item in items:
|
||
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"
|
||
|
||
|
||
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)
|
||
|
||
|
||
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:
|
||
"""
|
||
获取协议链接(隐私协议 / 使用协议)。
|
||
|
||
- 语言来源: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")
|
||
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)
|
||
async def get_terms_of_use(request: Request) -> HTMLResponse:
|
||
"""
|
||
内置使用协议页面(用于未配置外部托管链接时的兜底)。
|
||
"""
|
||
|
||
accept_language = request.headers.get("accept-language")
|
||
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})
|
||
|