diff --git a/client/app/(onboarding)/onboarding.tsx b/client/app/(onboarding)/onboarding.tsx
index ef9ae47..3f3ee98 100644
--- a/client/app/(onboarding)/onboarding.tsx
+++ b/client/app/(onboarding)/onboarding.tsx
@@ -136,12 +136,20 @@ export default function OnboardingScreen() {
return;
}
- // 1) 获取 Expo Push Token
+ // 1) 获取 Expo Push Token(失败才认为“推送开启失败”)
const expoPushToken = await getExpoPushTokenOrThrow();
- // 2) 上报 token 到后端(幂等)
+ // 2) 上报 token 到后端(幂等;失败才认为“推送开启失败”)
await registerPushToken({ pushToken: expoPushToken });
+
// 3) 上报推送偏好(幂等)
- await setPushPreferences({ enabled: wantsPush, timesPerDay: reminderTimes });
+ // 注意:这一步失败时,后端仍可能已成功接收 token。
+ // 为避免出现“后端已接收 token 但前端弹窗提示失败”的错觉,这里改为:偏好同步失败不弹“开启失败”,仅记录并继续。
+ try {
+ await setPushPreferences({ enabled: wantsPush, timesPerDay: reminderTimes });
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ console.warn('[PushPreferences] 同步失败(Onboarding,不阻塞)', msg);
+ }
await setPushPromptState('enabled');
} catch (e) {
diff --git a/client/app/(splash)/splash.tsx b/client/app/(splash)/splash.tsx
index f3065bf..bc5fa3e 100644
--- a/client/app/(splash)/splash.tsx
+++ b/client/app/(splash)/splash.tsx
@@ -1,12 +1,13 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Dimensions, Platform, Alert, Image } from 'react-native';
import { useRouter } from 'expo-router';
import * as WebBrowser from 'expo-web-browser';
-import { useTranslation } from 'react-i18next';
+import { Trans, useTranslation } from 'react-i18next';
import { SafeAreaView } from 'react-native-safe-area-context';
import { setConsentAccepted, getConsentAccepted } from '../../src/storage/appStorage';
import { fetchLegalLinks } from '@/src/services/legalApi';
import { getOnboardingCompleted } from '@/src/storage/appStorage';
+import { API_BASE_URL } from '@/src/constants/env';
// 导入 SVG 组件
import FlowersBg from '../../assets/images/index/flowers_endbg.svg';
@@ -19,11 +20,19 @@ export default function SplashScreen() {
const { t } = useTranslation();
const [showConsent, setShowConsent] = useState(false);
const [links, setLinks] = useState<{ privacy?: string; terms?: string }>({});
+ const [linksLoading, setLinksLoading] = useState(false);
+ const mountedRef = useRef(true);
useEffect(() => {
checkConsent();
}, []);
+ useEffect(() => {
+ return () => {
+ mountedRef.current = false;
+ };
+ }, []);
+
const checkConsent = async () => {
const accepted = await getConsentAccepted();
setShowConsent(!accepted);
@@ -53,23 +62,48 @@ export default function SplashScreen() {
}
};
+ async function refreshLegalLinks(): Promise<{ privacy?: string; terms?: string }> {
+ if (mountedRef.current) setLinksLoading(true);
+ try {
+ const res = await fetchLegalLinks();
+ const next = { privacy: res.privacyPolicyUrl, terms: res.termsOfUseUrl };
+ if (mountedRef.current) setLinks(next);
+ return next;
+ } catch (e) {
+ // 不阻塞主流程:失败时不崩溃,链接入口仍可点(会提示)
+ if (__DEV__) console.log('[LegalLinks] 拉取失败(splash):', e);
+ if (mountedRef.current) setLinks({});
+ return {};
+ } finally {
+ if (mountedRef.current) setLinksLoading(false);
+ }
+ }
+
+ async function handleOpenLegal(type: 'privacy' | 'terms') {
+ const currentUrl = type === 'privacy' ? links.privacy : links.terms;
+ if (currentUrl) {
+ await openLink(currentUrl);
+ return;
+ }
+
+ // 链接还没拿到/拉取失败:点击时主动再拉一次,避免“点了没反应”
+ const next = await refreshLegalLinks();
+ const nextUrl = type === 'privacy' ? next.privacy : next.terms;
+ if (nextUrl) {
+ await openLink(nextUrl);
+ return;
+ }
+
+ const msg =
+ typeof __DEV__ !== 'undefined' && __DEV__
+ ? t('consent.linkUnavailableDev', { baseUrl: API_BASE_URL })
+ : t('consent.linkUnavailable');
+ Alert.alert(t('common.notice'), msg);
+ }
+
// 拉取协议链接(由后端按语言下发;默认 EN)
useEffect(() => {
- let cancelled = false;
- (async () => {
- try {
- const res = await fetchLegalLinks();
- if (cancelled) return;
- setLinks({ privacy: res.privacyPolicyUrl, terms: res.termsOfUseUrl });
- } catch (e) {
- // 不阻塞主流程:失败时不崩溃,链接入口可不展示
- if (__DEV__) console.log('[LegalLinks] 拉取失败(splash):', e);
- if (!cancelled) setLinks({});
- }
- })();
- return () => {
- cancelled = true;
- };
+ void refreshLegalLinks();
}, []);
const bgDecorationTop = 363;
@@ -114,23 +148,33 @@ export default function SplashScreen() {
-
- (links.privacy ? openLink(links.privacy) : undefined)}
- >
- {t('consent.privacy')}
-
-
- (links.terms ? openLink(links.terms) : undefined)}
- >
- {t('consent.terms')}
-
-
-
- {t('consent.notice')}
+
+ void handleOpenLegal('privacy')}
+ suppressHighlighting
+ />
+ ),
+ terms: (
+ void handleOpenLegal('terms')}
+ suppressHighlighting
+ />
+ ),
+ }}
+ />
+
>
)}
@@ -179,10 +223,6 @@ const styles = StyleSheet.create({
buttonWrapper: {
marginBottom: 40,
},
- linksContainer: {
- flexDirection: 'row',
- alignItems: 'center',
- },
noticeText: {
marginTop: 10,
paddingHorizontal: 28,
@@ -191,15 +231,14 @@ const styles = StyleSheet.create({
textAlign: 'center',
color: 'rgba(119, 47, 0, 0.45)',
},
- linkText: {
+ noticeLinkText: {
fontSize: 12,
- color: 'rgba(119, 47, 0, 0.5)', // 使用半透明的文字颜色
+ // 颜色区分:协议链接更醒目
+ color: 'rgba(119, 47, 0, 0.75)',
textDecorationLine: 'underline',
+ fontWeight: '600',
},
- divider: {
- width: 1,
- height: 12,
- backgroundColor: 'rgba(119, 47, 0, 0.2)',
- marginHorizontal: 15,
+ noticeLinkTextDisabled: {
+ opacity: 0.55,
},
});
diff --git a/client/components/home/ProfileModal.tsx b/client/components/home/ProfileModal.tsx
index 19fab72..35c6c60 100644
--- a/client/components/home/ProfileModal.tsx
+++ b/client/components/home/ProfileModal.tsx
@@ -139,7 +139,10 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
const openLink = useCallback(
async (url?: string) => {
- if (!url) return;
+ if (!url) {
+ Alert.alert(t('common.notice'), t('consent.linkUnavailable'));
+ return;
+ }
try {
await WebBrowser.openBrowserAsync(url);
} catch (error) {
@@ -435,7 +438,14 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
try {
const expoPushToken = await getExpoPushTokenOrThrow();
await registerPushToken({ pushToken: expoPushToken });
- await setPushPreferences({ enabled: true, timesPerDay });
+ // 偏好同步失败不应被用户感知为“开启失败”
+ // (常见现象:后端已接收 token,但偏好接口短暂失败/超时)
+ try {
+ await setPushPreferences({ enabled: true, timesPerDay });
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ console.warn('[PushPreferences] 同步失败(ProfileModal,不阻塞)', msg);
+ }
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
Alert.alert(t('common.notice'), msg);
diff --git a/client/src/i18n/locales/all.json b/client/src/i18n/locales/all.json
index a73c368..aa4fddc 100644
--- a/client/src/i18n/locales/all.json
+++ b/client/src/i18n/locales/all.json
@@ -4,6 +4,7 @@
"ok": "OK",
"cancel": "Cancel",
"error": "Error",
+ "notice": "Notice",
"openLinkError": "Cannot open link",
"back": "Back",
"close": "Close"
@@ -146,7 +147,11 @@
"agree": "Agree & Continue",
"privacy": "Privacy Policy",
"terms": "Terms of Use",
- "notice": "By continuing, you agree to the Privacy Policy and Terms of Use."
+ "notice": "By continuing, you agree to the Privacy Policy and Terms of Use.",
+ "noticeRich": "By continuing, you agree to the {{privacyLabel}}{{privacySuffix}} and {{termsLabel}}{{termsSuffix}}.",
+ "linkUnavailable": "Failed to load the policy link. Please check your network and try again.",
+ "linkUnavailableDev": "Failed to load the policy link. Please check your network or API_BASE_URL: {{baseUrl}}",
+ "linkLoadingSuffix": " (loading…)"
},
"permissions": {
"notificationsDenied": "Notifications are denied. Please enable them in Settings."
@@ -168,6 +173,9 @@
"ok": "確定",
"cancel": "取消",
"back": "返回",
+ "error": "錯誤",
+ "notice": "提示",
+ "openLinkError": "無法打開鏈接",
"close": "關閉"
},
"onboarding": {
@@ -308,7 +316,11 @@
"agree": "同意並繼續",
"privacy": "隱私協議",
"terms": "用戶使用協議",
- "notice": "繼續使用即代表你同意《隱私協議》與《用戶使用協議》。"
+ "notice": "繼續使用即代表你同意《隱私協議》與《用戶使用協議》。",
+ "noticeRich": "繼續使用即代表你同意《{{privacyLabel}}》{{privacySuffix}}與《{{termsLabel}}》{{termsSuffix}}。",
+ "linkUnavailable": "協議鏈接載入失敗,請檢查網路後重試。",
+ "linkUnavailableDev": "協議鏈接載入失敗,請檢查網路或 API_BASE_URL 設定:{{baseUrl}}",
+ "linkLoadingSuffix": "(載入中…)"
},
"permissions": {
"notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。"
diff --git a/client/src/utils/http.ts b/client/src/utils/http.ts
index 4a4d30d..f165247 100644
--- a/client/src/utils/http.ts
+++ b/client/src/utils/http.ts
@@ -142,6 +142,22 @@ export async function httpJson(opts: HttpJsonOptions): Promise {
return undefined as unknown as T;
}
- return (await res.json()) as T;
+ // 某些后端/网关会返回 200 但 body 为空;此时 res.json() 会抛错,导致客户端误判“失败”。
+ // 这里改为:先读 text,空则返回 undefined;非空再 parse JSON。
+ const text = await res.text().catch(() => '');
+ if (!text || !String(text).trim()) {
+ return undefined as unknown as T;
+ }
+ try {
+ return JSON.parse(text) as T;
+ } catch {
+ throw new HttpError({
+ message: `HTTP 响应不是合法 JSON:${res.status} ${res.statusText} ${text}`.trim(),
+ url,
+ status: res.status,
+ statusText: res.statusText,
+ responseText: text,
+ });
+ }
}
diff --git a/server/Dockerfile b/server/Dockerfile
index 4da3e8a..e70ecd7 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -20,6 +20,9 @@ RUN python -m pip install -U pip \
COPY app /app/app
COPY alembic /app/alembic
COPY alembic.ini /app/alembic.ini
+COPY docker-entrypoint.sh /app/docker-entrypoint.sh
+
+RUN chmod +x /app/docker-entrypoint.sh
EXPOSE 8000
@@ -29,4 +32,7 @@ EXPOSE 8000
# - 参考文档:server/README.md
# 生产镜像默认不开启 reload
-CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+# 默认行为:只启动 API(与之前一致)
+# 如需同时启动定时推送相关进程(Celery Worker/Beat),可在运行时注入:
+# -e START_ALL=1
+ENTRYPOINT ["/app/docker-entrypoint.sh"]
diff --git a/server/docker-entrypoint.sh b/server/docker-entrypoint.sh
new file mode 100644
index 0000000..61eb6b2
--- /dev/null
+++ b/server/docker-entrypoint.sh
@@ -0,0 +1,88 @@
+#!/bin/sh
+set -eu
+
+# 容器入口:
+# - 默认只启动 API(与原 Dockerfile 行为一致)
+# - 如需测试“定时推送”,可额外启动 Celery Worker + Beat
+#
+# 环境变量:
+# - START_API=1|0(默认 1)
+# - START_WORKER=1|0(默认 0)
+# - START_BEAT=1|0(默认 0)
+# - START_ALL=1(等价于 START_WORKER=1 + START_BEAT=1)
+# - HOST / PORT(API 监听地址,默认 0.0.0.0:8000)
+
+log() {
+ echo "[entrypoint] $*"
+}
+
+START_API="${START_API:-1}"
+START_WORKER="${START_WORKER:-0}"
+START_BEAT="${START_BEAT:-0}"
+
+if [ "${START_ALL:-0}" = "1" ]; then
+ START_WORKER="1"
+ START_BEAT="1"
+fi
+
+# Beat 只负责“投递任务到队列”,真正执行仍需要 Worker。
+# 说明:
+# - 单容器模式:若启动了 API(START_API=1)且同时启用了 Beat,则自动补齐 Worker,避免误配导致“只排程不执行”。
+# - 多容器模式:允许单独启动 beat 容器(START_API=0, START_BEAT=1),不做自动补齐。
+if [ "$START_API" = "1" ] && [ "$START_BEAT" = "1" ] && [ "$START_WORKER" != "1" ]; then
+ log "提示:已启用 START_BEAT=1(且 START_API=1),自动同时启用 START_WORKER=1(否则队列无人执行)。"
+ START_WORKER="1"
+fi
+
+PIDS=""
+
+stop_children() {
+ # 温和退出
+ for pid in $PIDS; do
+ kill -TERM "$pid" >/dev/null 2>&1 || true
+ done
+}
+
+on_term() {
+ log "收到退出信号,正在停止子进程..."
+ stop_children
+ # 等待子进程退出,避免残留
+ wait >/dev/null 2>&1 || true
+ exit 0
+}
+
+trap on_term INT TERM
+
+if [ "$START_WORKER" = "1" ]; then
+ log "启动 Celery Worker:celery -A app.worker:celery_app worker -l info"
+ celery -A app.worker:celery_app worker -l info &
+ PIDS="$PIDS $!"
+fi
+
+if [ "$START_BEAT" = "1" ]; then
+ log "启动 Celery Beat:celery -A app.worker:celery_app beat -l info"
+ celery -A app.worker:celery_app beat -l info &
+ PIDS="$PIDS $!"
+fi
+
+if [ "$START_API" = "1" ]; then
+ HOST="${HOST:-0.0.0.0}"
+ PORT="${PORT:-8000}"
+ log "启动 API:uvicorn app.main:app --host $HOST --port $PORT"
+ uvicorn app.main:app --host "$HOST" --port "$PORT" &
+ API_PID="$!"
+ PIDS="$PIDS $API_PID"
+
+ # 以 API 生命周期为准:API 退出则容器退出,并清理其他进程
+ wait "$API_PID"
+ CODE="$?"
+ log "API 已退出(code=$CODE),正在停止其他进程..."
+ stop_children
+ wait >/dev/null 2>&1 || true
+ exit "$CODE"
+fi
+
+# 未启动 API:就阻塞等待其他进程(一般用于仅跑 worker/beat 的容器)
+log "未启动 API,等待后台进程..."
+wait
+
diff --git a/server/run.sh b/server/run.sh
index 86905fd..d55c32a 100755
--- a/server/run.sh
+++ b/server/run.sh
@@ -7,28 +7,38 @@ set -euo pipefail
# - 自动启动 uvicorn(默认开启 --reload)
#
# 用法示例:
-# ./run.sh # 默认 host=0.0.0.0 port=8000 env=dev reload=on
+# ./run.sh # 默认一键启动:API + Celery Worker + Celery Beat
# ./run.sh --env prod # 使用 .env.prod(若存在且可被 source)
# ./run.sh --port 9000 # 改端口
# ./run.sh --no-reload # 关闭热更新
+# ./run.sh --with-worker --with-beat # 同时启动 Celery Worker + Beat(用于定时推送)
+# ./run.sh --all # 等价于 --with-worker --with-beat
+# START_ALL=1 ./run.sh # 用环境变量一键启动(适合写到脚本/别名里)
+# ./run.sh --api-only # 只启动 API(不启动 Worker/Beat)
# ./run.sh --install-only # 只安装依赖,不启动
usage() {
cat <<'EOF'
用法:
- ./run.sh [--env dev|prod] [--host 0.0.0.0] [--port 8000] [--no-reload] [--skip-install] [--install-only]
+ ./run.sh [--env dev|prod] [--host 0.0.0.0] [--port 8000] [--no-reload] [--api-only] [--with-worker] [--with-beat] [--all] [--skip-install] [--install-only]
参数:
--env dev|prod 优先尝试加载 .env.dev 或 .env.prod(如果存在)。
--host uvicorn host(默认 0.0.0.0)
--port uvicorn port(默认 8000)
--no-reload 关闭 uvicorn --reload
+ --api-only 只启动 API(不启动 Worker/Beat)
+ --with-worker 同时启动 Celery Worker(处理异步/ETA 任务)
+ --with-beat 同时启动 Celery Beat(定时调度,例如每日生成推送排程)
+ --all 同时启动 Worker + Beat(等价于 --with-worker --with-beat)
--skip-install 跳过依赖安装(默认会安装/更新 requirements.txt)
--install-only 只安装依赖,不启动服务
-h, --help 显示帮助
说明:
- 若你的 .env.* 不是 shell 可 source 的格式(例如包含空格/特殊字符未加引号),建议改成 KEY=value 形式。
+ - 仅启动 API 并不会生成 `push_send_log`;要测试“定时推送”,需要 Beat 调度 `tasks.push.generate_daily_schedule`,并由 Worker 执行后续 ETA 任务。
+ - 也可以用环境变量一键启动:START_ALL=1 ./run.sh
- 启动后访问:
/healthz 健康检查
/docs OpenAPI 文档
@@ -45,6 +55,10 @@ PORT="8000"
RELOAD="1"
SKIP_INSTALL="0"
INSTALL_ONLY="0"
+API_ONLY="0"
+# 默认:一键启动(满足“bash run.sh 就全部启动”)
+WITH_WORKER="1"
+WITH_BEAT="1"
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -64,6 +78,25 @@ while [[ $# -gt 0 ]]; do
RELOAD="0"
shift 1
;;
+ --api-only)
+ API_ONLY="1"
+ WITH_WORKER="0"
+ WITH_BEAT="0"
+ shift 1
+ ;;
+ --with-worker)
+ WITH_WORKER="1"
+ shift 1
+ ;;
+ --with-beat)
+ WITH_BEAT="1"
+ shift 1
+ ;;
+ --all)
+ WITH_WORKER="1"
+ WITH_BEAT="1"
+ shift 1
+ ;;
--skip-install)
SKIP_INSTALL="1"
shift 1
@@ -99,6 +132,28 @@ if [[ -f "$ENV_FILE" ]]; then
set +a
fi
+# 允许通过环境变量一键开启(适合写到别名/CI 脚本里)
+if [[ "${START_ALL:-0}" == "1" ]]; then
+ WITH_WORKER="1"
+ WITH_BEAT="1"
+fi
+
+# 允许通过环境变量强制只启动 API
+if [[ "${START_API_ONLY:-0}" == "1" ]]; then
+ API_ONLY="1"
+ WITH_WORKER="0"
+ WITH_BEAT="0"
+fi
+
+# 让 API/Celery 统一使用同一个 APP_ENV(影响 Redis key 前缀、定时任务配置等)
+export APP_ENV="${APP_ENV:-$ENV_NAME}"
+
+# Beat 只负责“投递任务到队列”,真正执行仍需要 Worker;这里自动补齐,避免误用。
+if [[ "$WITH_BEAT" == "1" && "$WITH_WORKER" == "0" ]]; then
+ echo "提示:已启用 --with-beat,自动同时启用 --with-worker(否则队列无人执行)。"
+ WITH_WORKER="1"
+fi
+
# 选择 python 命令(优先 python3)
PY_BIN=""
if command -v python3 >/dev/null 2>&1; then
@@ -140,6 +195,45 @@ if [[ "$RELOAD" == "1" ]]; then
UVICORN_ARGS+=(--reload)
fi
-echo "启动服务:uvicorn ${UVICORN_ARGS[*]}"
-exec uvicorn "${UVICORN_ARGS[@]}"
+if [[ "$WITH_WORKER" == "0" && "$WITH_BEAT" == "0" ]]; then
+ echo "启动服务:uvicorn ${UVICORN_ARGS[*]}"
+ exec uvicorn "${UVICORN_ARGS[@]}"
+fi
+
+PIDS=()
+
+cleanup() {
+ # 避免重复清理导致脚本退出码被覆盖
+ set +e
+ if [[ ${#PIDS[@]} -gt 0 ]]; then
+ echo ""
+ echo "正在停止后台进程..."
+ # 先尝试温和退出
+ for pid in "${PIDS[@]}"; do
+ kill -TERM "$pid" >/dev/null 2>&1 || true
+ done
+ # 等待一点时间,再强制杀掉仍存活的(防止残留)
+ sleep 1
+ for pid in "${PIDS[@]}"; do
+ kill -KILL "$pid" >/dev/null 2>&1 || true
+ done
+ fi
+}
+
+trap cleanup EXIT INT TERM
+
+if [[ "$WITH_WORKER" == "1" ]]; then
+ echo "启动 Celery Worker:celery -A app.worker:celery_app worker -l info"
+ celery -A app.worker:celery_app worker -l info &
+ PIDS+=("$!")
+fi
+
+if [[ "$WITH_BEAT" == "1" ]]; then
+ echo "启动 Celery Beat:celery -A app.worker:celery_app beat -l info"
+ celery -A app.worker:celery_app beat -l info &
+ PIDS+=("$!")
+fi
+
+echo "启动服务:uvicorn ${UVICORN_ARGS[*]}"
+uvicorn "${UVICORN_ARGS[@]}"