From 1e1e49ea57576459765d56d5a79a5f77c336c691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=96=B0=E9=9B=A8?= Date: Thu, 5 Feb 2026 16:33:58 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=E5=90=8C=E6=84=8F=E9=9A=90=E7=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/app/(onboarding)/onboarding.tsx | 14 ++- client/app/(splash)/splash.tsx | 129 +++++++++++++++--------- client/components/home/ProfileModal.tsx | 14 ++- client/src/i18n/locales/all.json | 16 ++- client/src/utils/http.ts | 18 +++- 5 files changed, 138 insertions(+), 53 deletions(-) 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, + }); + } } From 0b8bbebf6a6a03d6b83db520cdb8f182fb1588c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=96=B0=E9=9B=A8?= Date: Mon, 9 Feb 2026 11:52:11 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix=EF=BC=9A=E5=A2=9E=E5=8A=A0=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E6=8E=A8=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/Dockerfile | 8 +++- server/docker-entrypoint.sh | 85 +++++++++++++++++++++++++++++++++++++ server/run.sh | 68 +++++++++++++++++++++++++++-- 3 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 server/docker-entrypoint.sh 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..4471832 --- /dev/null +++ b/server/docker-entrypoint.sh @@ -0,0 +1,85 @@ +#!/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;避免误配。 +if [ "$START_BEAT" = "1" ] && [ "$START_WORKER" != "1" ]; then + log "提示:已启用 START_BEAT=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..e859f6b 100755 --- a/server/run.sh +++ b/server/run.sh @@ -11,24 +11,28 @@ set -euo pipefail # ./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 --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] [--with-worker] [--with-beat] [--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 + --with-worker 同时启动 Celery Worker(处理异步/ETA 任务) + --with-beat 同时启动 Celery 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 任务。 - 启动后访问: /healthz 健康检查 /docs OpenAPI 文档 @@ -45,6 +49,8 @@ PORT="8000" RELOAD="1" SKIP_INSTALL="0" INSTALL_ONLY="0" +WITH_WORKER="0" +WITH_BEAT="0" while [[ $# -gt 0 ]]; do case "$1" in @@ -64,6 +70,14 @@ while [[ $# -gt 0 ]]; do RELOAD="0" shift 1 ;; + --with-worker) + WITH_WORKER="1" + shift 1 + ;; + --with-beat) + WITH_BEAT="1" + shift 1 + ;; --skip-install) SKIP_INSTALL="1" shift 1 @@ -99,6 +113,15 @@ if [[ -f "$ENV_FILE" ]]; then set +a 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 +163,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[@]}" From e980bd4e4d82b3d8e232307836044ee759ce188d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=96=B0=E9=9B=A8?= Date: Mon, 9 Feb 2026 14:47:17 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=E6=9B=B4=E6=96=B0=E5=AE=B9=E5=99=A8?= =?UTF-8?q?=E5=90=AF=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/docker-entrypoint.sh | 9 ++++++--- server/run.sh | 40 +++++++++++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/server/docker-entrypoint.sh b/server/docker-entrypoint.sh index 4471832..61eb6b2 100644 --- a/server/docker-entrypoint.sh +++ b/server/docker-entrypoint.sh @@ -25,9 +25,12 @@ if [ "${START_ALL:-0}" = "1" ]; then START_BEAT="1" fi -# Beat 只负责“投递任务到队列”,真正执行仍需要 Worker;避免误配。 -if [ "$START_BEAT" = "1" ] && [ "$START_WORKER" != "1" ]; then - log "提示:已启用 START_BEAT=1,自动同时启用 START_WORKER=1(否则队列无人执行)。" +# 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 diff --git a/server/run.sh b/server/run.sh index e859f6b..d55c32a 100755 --- a/server/run.sh +++ b/server/run.sh @@ -7,25 +7,30 @@ 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] [--with-worker] [--with-beat] [--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 显示帮助 @@ -33,6 +38,7 @@ usage() { 说明: - 若你的 .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 文档 @@ -49,8 +55,10 @@ PORT="8000" RELOAD="1" SKIP_INSTALL="0" INSTALL_ONLY="0" -WITH_WORKER="0" -WITH_BEAT="0" +API_ONLY="0" +# 默认:一键启动(满足“bash run.sh 就全部启动”) +WITH_WORKER="1" +WITH_BEAT="1" while [[ $# -gt 0 ]]; do case "$1" in @@ -70,6 +78,12 @@ 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 @@ -78,6 +92,11 @@ while [[ $# -gt 0 ]]; do WITH_BEAT="1" shift 1 ;; + --all) + WITH_WORKER="1" + WITH_BEAT="1" + shift 1 + ;; --skip-install) SKIP_INSTALL="1" shift 1 @@ -113,6 +132,19 @@ 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}"