Merge pull request 'damer' (#17) from damer into main
Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
@@ -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) 上报推送偏好(幂等)
|
||||
// 注意:这一步失败时,后端仍可能已成功接收 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) {
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
};
|
||||
|
||||
// 拉取协议链接(由后端按语言下发;默认 EN)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
async function refreshLegalLinks(): Promise<{ privacy?: string; terms?: string }> {
|
||||
if (mountedRef.current) setLinksLoading(true);
|
||||
try {
|
||||
const res = await fetchLegalLinks();
|
||||
if (cancelled) return;
|
||||
setLinks({ privacy: res.privacyPolicyUrl, terms: res.termsOfUseUrl });
|
||||
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 (!cancelled) setLinks({});
|
||||
if (mountedRef.current) setLinks({});
|
||||
return {};
|
||||
} finally {
|
||||
if (mountedRef.current) setLinksLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
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(() => {
|
||||
void refreshLegalLinks();
|
||||
}, []);
|
||||
|
||||
const bgDecorationTop = 363;
|
||||
@@ -114,23 +148,33 @@ export default function SplashScreen() {
|
||||
<WelcomeBtn width={87} height={57} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.linksContainer}>
|
||||
<TouchableOpacity
|
||||
disabled={!links.privacy}
|
||||
onPress={() => (links.privacy ? openLink(links.privacy) : undefined)}
|
||||
>
|
||||
<Text style={styles.linkText}>{t('consent.privacy')}</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
<TouchableOpacity
|
||||
disabled={!links.terms}
|
||||
onPress={() => (links.terms ? openLink(links.terms) : undefined)}
|
||||
>
|
||||
<Text style={styles.linkText}>{t('consent.terms')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={styles.noticeText}>{t('consent.notice')}</Text>
|
||||
<Text style={styles.noticeText}>
|
||||
<Trans
|
||||
i18nKey="consent.noticeRich"
|
||||
values={{
|
||||
privacyLabel: t('consent.privacy'),
|
||||
termsLabel: t('consent.terms'),
|
||||
privacySuffix: !links.privacy && linksLoading ? t('consent.linkLoadingSuffix') : '',
|
||||
termsSuffix: !links.terms && linksLoading ? t('consent.linkLoadingSuffix') : '',
|
||||
}}
|
||||
components={{
|
||||
privacy: (
|
||||
<Text
|
||||
style={[styles.noticeLinkText, !links.privacy && styles.noticeLinkTextDisabled]}
|
||||
onPress={() => void handleOpenLegal('privacy')}
|
||||
suppressHighlighting
|
||||
/>
|
||||
),
|
||||
terms: (
|
||||
<Text
|
||||
style={[styles.noticeLinkText, !links.terms && styles.noticeLinkTextDisabled]}
|
||||
onPress={() => void handleOpenLegal('terms')}
|
||||
suppressHighlighting
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
// 偏好同步失败不应被用户感知为“开启失败”
|
||||
// (常见现象:后端已接收 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);
|
||||
|
||||
@@ -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 <privacy>{{privacyLabel}}{{privacySuffix}}</privacy> and <terms>{{termsLabel}}{{termsSuffix}}</terms>.",
|
||||
"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": "繼續使用即代表你同意<privacy>《{{privacyLabel}}》{{privacySuffix}}</privacy>與<terms>《{{termsLabel}}》{{termsSuffix}}</terms>。",
|
||||
"linkUnavailable": "協議鏈接載入失敗,請檢查網路後重試。",
|
||||
"linkUnavailableDev": "協議鏈接載入失敗,請檢查網路或 API_BASE_URL 設定:{{baseUrl}}",
|
||||
"linkLoadingSuffix": "(載入中…)"
|
||||
},
|
||||
"permissions": {
|
||||
"notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。"
|
||||
|
||||
@@ -142,6 +142,22 @@ export async function httpJson<T>(opts: HttpJsonOptions): Promise<T> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
88
server/docker-entrypoint.sh
Normal file
88
server/docker-entrypoint.sh
Normal file
@@ -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
|
||||
|
||||
102
server/run.sh
102
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 <host> uvicorn host(默认 0.0.0.0)
|
||||
--port <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[@]}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user