Compare commits
6 Commits
8e71503169
...
v1.0.15
| Author | SHA1 | Date | |
|---|---|---|---|
| 4578d503e7 | |||
|
|
f03d36b5e9 | ||
| 66241e5231 | |||
|
|
e980bd4e4d | ||
|
|
0b8bbebf6a | ||
|
|
1e1e49ea57 |
@@ -56,6 +56,9 @@ jobs:
|
|||||||
# 健康检查路径(未配置则默认 /health;如果你没有 health 接口,可改为 /docs 或 /)
|
# 健康检查路径(未配置则默认 /health;如果你没有 health 接口,可改为 /docs 或 /)
|
||||||
HEALTHCHECK_PATH: ${{ vars.HEALTHCHECK_PATH }}
|
HEALTHCHECK_PATH: ${{ vars.HEALTHCHECK_PATH }}
|
||||||
|
|
||||||
|
# 定时服务健康检查路径(检查 Redis/Worker/Beat;未配置则默认 /v1/push/scheduler/health)
|
||||||
|
SCHEDULER_HEALTHCHECK_PATH: ${{ vars.SCHEDULER_HEALTHCHECK_PATH }}
|
||||||
|
|
||||||
# 可选:远端 env 文件路径(例如 /opt/mindfulness-server/.env.prod),存在则 docker run --env-file
|
# 可选:远端 env 文件路径(例如 /opt/mindfulness-server/.env.prod),存在则 docker run --env-file
|
||||||
REMOTE_ENV_FILE: ${{ vars.REMOTE_ENV_FILE }}
|
REMOTE_ENV_FILE: ${{ vars.REMOTE_ENV_FILE }}
|
||||||
|
|
||||||
@@ -258,11 +261,12 @@ jobs:
|
|||||||
GREEN_PORT="${GREEN_PORT:-8002}"
|
GREEN_PORT="${GREEN_PORT:-8002}"
|
||||||
CONTAINER_PORT="${CONTAINER_PORT:-8000}"
|
CONTAINER_PORT="${CONTAINER_PORT:-8000}"
|
||||||
HEALTHCHECK_PATH="${HEALTHCHECK_PATH:-/health}"
|
HEALTHCHECK_PATH="${HEALTHCHECK_PATH:-/health}"
|
||||||
|
SCHEDULER_HEALTHCHECK_PATH="${SCHEDULER_HEALTHCHECK_PATH:-/v1/push/scheduler/health}"
|
||||||
|
|
||||||
echo "准备部署:${DOCKER_IMAGE}:${DEPLOY_TAG} -> ${DEPLOY_ENV} (${SSH_USER}@${SSH_HOST}:${SSH_PORT})"
|
echo "准备部署:${DOCKER_IMAGE}:${DEPLOY_TAG} -> ${DEPLOY_ENV} (${SSH_USER}@${SSH_HOST}:${SSH_PORT})"
|
||||||
|
|
||||||
ssh -p "${SSH_PORT}" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes "${SSH_USER}@${SSH_HOST}" bash -s -- \
|
ssh -p "${SSH_PORT}" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes "${SSH_USER}@${SSH_HOST}" bash -s -- \
|
||||||
"${DOCKER_IMAGE}" "${DEPLOY_TAG}" "${NGINX_UPSTREAM_FILE}" "${NGINX_UPSTREAM_NAME}" "${BLUE_PORT}" "${GREEN_PORT}" "${CONTAINER_PORT}" "${HEALTHCHECK_PATH}" "${REMOTE_ENV_FILE:-}" <<'REMOTE'
|
"${DOCKER_IMAGE}" "${DEPLOY_TAG}" "${NGINX_UPSTREAM_FILE}" "${NGINX_UPSTREAM_NAME}" "${BLUE_PORT}" "${GREEN_PORT}" "${CONTAINER_PORT}" "${HEALTHCHECK_PATH}" "${SCHEDULER_HEALTHCHECK_PATH}" "${REMOTE_ENV_FILE:-}" <<'REMOTE'
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
IMAGE="$1"
|
IMAGE="$1"
|
||||||
@@ -273,7 +277,8 @@ jobs:
|
|||||||
GREEN_PORT="$6"
|
GREEN_PORT="$6"
|
||||||
CONTAINER_PORT="$7"
|
CONTAINER_PORT="$7"
|
||||||
HEALTHCHECK_PATH="$8"
|
HEALTHCHECK_PATH="$8"
|
||||||
REMOTE_ENV_FILE="$9"
|
SCHEDULER_HEALTHCHECK_PATH="$9"
|
||||||
|
REMOTE_ENV_FILE="${10}"
|
||||||
|
|
||||||
APP_DIR="/opt/mindfulness-server"
|
APP_DIR="/opt/mindfulness-server"
|
||||||
ACTIVE_FILE="${APP_DIR}/active_color"
|
ACTIVE_FILE="${APP_DIR}/active_color"
|
||||||
@@ -307,9 +312,18 @@ jobs:
|
|||||||
# 拉取镜像
|
# 拉取镜像
|
||||||
${SUDO} docker pull "${IMAGE}:${TAG}"
|
${SUDO} docker pull "${IMAGE}:${TAG}"
|
||||||
|
|
||||||
# 启动新颜色容器
|
# 启动新颜色容器(API + Worker + Beat)
|
||||||
|
API_NAME="mindfulness-server-api-${NEW_COLOR}"
|
||||||
|
WORKER_NAME="mindfulness-server-worker-${NEW_COLOR}"
|
||||||
|
BEAT_NAME="mindfulness-server-beat-${NEW_COLOR}"
|
||||||
|
|
||||||
|
# 兼容旧命名:之前可能只有一个 mindfulness-server-blue/green
|
||||||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" >/dev/null 2>&1 || true
|
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
${SUDO} docker rm -f "${API_NAME}" >/dev/null 2>&1 || true
|
||||||
|
${SUDO} docker rm -f "${WORKER_NAME}" >/dev/null 2>&1 || true
|
||||||
|
${SUDO} docker rm -f "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
ENV_FILE_ARGS=()
|
ENV_FILE_ARGS=()
|
||||||
if [[ -n "${REMOTE_ENV_FILE}" && -f "${REMOTE_ENV_FILE}" ]]; then
|
if [[ -n "${REMOTE_ENV_FILE}" && -f "${REMOTE_ENV_FILE}" ]]; then
|
||||||
ENV_FILE_ARGS=(--env-file "${REMOTE_ENV_FILE}")
|
ENV_FILE_ARGS=(--env-file "${REMOTE_ENV_FILE}")
|
||||||
@@ -318,11 +332,29 @@ jobs:
|
|||||||
echo "提示:REMOTE_ENV_FILE 已配置但文件不存在:${REMOTE_ENV_FILE}(将忽略 env-file)"
|
echo "提示:REMOTE_ENV_FILE 已配置但文件不存在:${REMOTE_ENV_FILE}(将忽略 env-file)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# API(对外暴露端口,仅该容器参与蓝绿切流)
|
||||||
${SUDO} docker run -d \
|
${SUDO} docker run -d \
|
||||||
--name "mindfulness-server-${NEW_COLOR}" \
|
--name "${API_NAME}" \
|
||||||
--restart=always \
|
--restart=always \
|
||||||
-p "${NEW_PORT}:${CONTAINER_PORT}" \
|
-p "${NEW_PORT}:${CONTAINER_PORT}" \
|
||||||
"${ENV_FILE_ARGS[@]}" \
|
"${ENV_FILE_ARGS[@]}" \
|
||||||
|
-e START_API=1 -e START_WORKER=0 -e START_BEAT=0 \
|
||||||
|
"${IMAGE}:${TAG}"
|
||||||
|
|
||||||
|
# Worker(处理异步/ETA 任务,不暴露端口)
|
||||||
|
${SUDO} docker run -d \
|
||||||
|
--name "${WORKER_NAME}" \
|
||||||
|
--restart=always \
|
||||||
|
"${ENV_FILE_ARGS[@]}" \
|
||||||
|
-e START_API=0 -e START_WORKER=1 -e START_BEAT=0 \
|
||||||
|
"${IMAGE}:${TAG}"
|
||||||
|
|
||||||
|
# Beat(定时调度,不暴露端口)
|
||||||
|
${SUDO} docker run -d \
|
||||||
|
--name "${BEAT_NAME}" \
|
||||||
|
--restart=always \
|
||||||
|
"${ENV_FILE_ARGS[@]}" \
|
||||||
|
-e START_API=0 -e START_WORKER=0 -e START_BEAT=1 \
|
||||||
"${IMAGE}:${TAG}"
|
"${IMAGE}:${TAG}"
|
||||||
|
|
||||||
# 健康检查
|
# 健康检查
|
||||||
@@ -350,8 +382,37 @@ jobs:
|
|||||||
|
|
||||||
if [[ "$i" -eq 30 ]]; then
|
if [[ "$i" -eq 30 ]]; then
|
||||||
echo "健康检查失败:新版本未就绪,回滚并退出"
|
echo "健康检查失败:新版本未就绪,回滚并退出"
|
||||||
${SUDO} docker logs --tail 200 "mindfulness-server-${NEW_COLOR}" || true
|
${SUDO} docker logs --tail 200 "${API_NAME}" || true
|
||||||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# 定时服务健康检查(确保 Redis/Worker/Beat 都 OK,避免“接口正常但定时任务没跑”)
|
||||||
|
if [[ "${SCHEDULER_HEALTHCHECK_PATH}" != /* ]]; then
|
||||||
|
SCHEDULER_HEALTHCHECK_PATH="/${SCHEDULER_HEALTHCHECK_PATH}"
|
||||||
|
fi
|
||||||
|
SCHED_URL="http://127.0.0.1:${NEW_PORT}${SCHEDULER_HEALTHCHECK_PATH}"
|
||||||
|
echo "定时服务健康检查:${SCHED_URL}"
|
||||||
|
|
||||||
|
# Beat 心跳是按分钟刷新,这里最多等 90 秒(45*2s)
|
||||||
|
for i in $(seq 1 45); do
|
||||||
|
RES="$(curl -fsS "${SCHED_URL}" 2>/dev/null || true)"
|
||||||
|
if [[ -n "${RES}" ]] \
|
||||||
|
&& echo "${RES}" | grep -q '"redis":{"ok":true' \
|
||||||
|
&& echo "${RES}" | grep -q '"worker":{"ok":true' \
|
||||||
|
&& echo "${RES}" | grep -q '"beat":{"ok":true' ; then
|
||||||
|
echo "定时服务健康检查通过:${RES}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$i" -eq 45 ]]; then
|
||||||
|
echo "定时服务健康检查失败:${RES}"
|
||||||
|
echo "Worker/Beat 日志(各 120 行):"
|
||||||
|
${SUDO} docker logs --tail 120 "${WORKER_NAME}" || true
|
||||||
|
${SUDO} docker logs --tail 120 "${BEAT_NAME}" || true
|
||||||
|
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
@@ -360,24 +421,24 @@ jobs:
|
|||||||
# 切换 Nginx upstream(在同一个 conf 文件中通过 backup 做主备切换)
|
# 切换 Nginx upstream(在同一个 conf 文件中通过 backup 做主备切换)
|
||||||
if [[ ! -f "${UPSTREAM_FILE}" ]]; then
|
if [[ ! -f "${UPSTREAM_FILE}" ]]; then
|
||||||
echo "未找到 Nginx upstream 配置文件:${UPSTREAM_FILE}"
|
echo "未找到 Nginx upstream 配置文件:${UPSTREAM_FILE}"
|
||||||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! ${SUDO} grep -qE "upstream[[:space:]]+${UPSTREAM_NAME}[[:space:]]*\\{" "${UPSTREAM_FILE}"; then
|
if ! ${SUDO} grep -qE "upstream[[:space:]]+${UPSTREAM_NAME}[[:space:]]*\\{" "${UPSTREAM_FILE}"; then
|
||||||
echo "在 ${UPSTREAM_FILE} 中未找到 upstream:${UPSTREAM_NAME}"
|
echo "在 ${UPSTREAM_FILE} 中未找到 upstream:${UPSTREAM_NAME}"
|
||||||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! ${SUDO} grep -qE "server[[:space:]]+127\\.0\\.0\\.1:${BLUE_PORT}" "${UPSTREAM_FILE}"; then
|
if ! ${SUDO} grep -qE "server[[:space:]]+127\\.0\\.0\\.1:${BLUE_PORT}" "${UPSTREAM_FILE}"; then
|
||||||
echo "在 ${UPSTREAM_FILE} 中未找到 server 127.0.0.1:${BLUE_PORT}(请先按参考配置写入 upstream)"
|
echo "在 ${UPSTREAM_FILE} 中未找到 server 127.0.0.1:${BLUE_PORT}(请先按参考配置写入 upstream)"
|
||||||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if ! ${SUDO} grep -qE "server[[:space:]]+127\\.0\\.0\\.1:${GREEN_PORT}" "${UPSTREAM_FILE}"; then
|
if ! ${SUDO} grep -qE "server[[:space:]]+127\\.0\\.0\\.1:${GREEN_PORT}" "${UPSTREAM_FILE}"; then
|
||||||
echo "在 ${UPSTREAM_FILE} 中未找到 server 127.0.0.1:${GREEN_PORT}(请先按参考配置写入 upstream)"
|
echo "在 ${UPSTREAM_FILE} 中未找到 server 127.0.0.1:${GREEN_PORT}(请先按参考配置写入 upstream)"
|
||||||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -416,7 +477,7 @@ jobs:
|
|||||||
echo "Nginx 配置校验失败,回滚 upstream 配置并退出"
|
echo "Nginx 配置校验失败,回滚 upstream 配置并退出"
|
||||||
${SUDO} cp -f "${BACKUP_FILE}" "${UPSTREAM_FILE}" || true
|
${SUDO} cp -f "${BACKUP_FILE}" "${UPSTREAM_FILE}" || true
|
||||||
${SUDO} nginx -t && ${SUDO} nginx -s reload || true
|
${SUDO} nginx -t && ${SUDO} nginx -s reload || true
|
||||||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -424,6 +485,11 @@ jobs:
|
|||||||
echo "${NEW_COLOR}" | ${SUDO} tee "${ACTIVE_FILE}" >/dev/null
|
echo "${NEW_COLOR}" | ${SUDO} tee "${ACTIVE_FILE}" >/dev/null
|
||||||
|
|
||||||
# 下线旧容器(切流后再停旧的)
|
# 下线旧容器(切流后再停旧的)
|
||||||
|
OLD_API_NAME="mindfulness-server-api-${OLD_COLOR}"
|
||||||
|
OLD_WORKER_NAME="mindfulness-server-worker-${OLD_COLOR}"
|
||||||
|
OLD_BEAT_NAME="mindfulness-server-beat-${OLD_COLOR}"
|
||||||
|
${SUDO} docker rm -f "${OLD_API_NAME}" "${OLD_WORKER_NAME}" "${OLD_BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
|
# 兼容旧命名
|
||||||
${SUDO} docker rm -f "mindfulness-server-${OLD_COLOR}" >/dev/null 2>&1 || true
|
${SUDO} docker rm -f "mindfulness-server-${OLD_COLOR}" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
echo "部署完成:${NEW_COLOR} 已上线"
|
echo "部署完成:${NEW_COLOR} 已上线"
|
||||||
|
|||||||
@@ -136,12 +136,20 @@ export default function OnboardingScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) 获取 Expo Push Token
|
// 1) 获取 Expo Push Token(失败才认为“推送开启失败”)
|
||||||
const expoPushToken = await getExpoPushTokenOrThrow();
|
const expoPushToken = await getExpoPushTokenOrThrow();
|
||||||
// 2) 上报 token 到后端(幂等)
|
// 2) 上报 token 到后端(幂等;失败才认为“推送开启失败”)
|
||||||
await registerPushToken({ pushToken: expoPushToken });
|
await registerPushToken({ pushToken: expoPushToken });
|
||||||
|
|
||||||
// 3) 上报推送偏好(幂等)
|
// 3) 上报推送偏好(幂等)
|
||||||
|
// 注意:这一步失败时,后端仍可能已成功接收 token。
|
||||||
|
// 为避免出现“后端已接收 token 但前端弹窗提示失败”的错觉,这里改为:偏好同步失败不弹“开启失败”,仅记录并继续。
|
||||||
|
try {
|
||||||
await setPushPreferences({ enabled: wantsPush, timesPerDay: reminderTimes });
|
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');
|
await setPushPromptState('enabled');
|
||||||
} catch (e) {
|
} 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 { View, Text, StyleSheet, TouchableOpacity, Dimensions, Platform, Alert, Image } from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import * as WebBrowser from 'expo-web-browser';
|
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 { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { setConsentAccepted, getConsentAccepted } from '../../src/storage/appStorage';
|
import { setConsentAccepted, getConsentAccepted } from '../../src/storage/appStorage';
|
||||||
import { fetchLegalLinks } from '@/src/services/legalApi';
|
import { fetchLegalLinks } from '@/src/services/legalApi';
|
||||||
import { getOnboardingCompleted } from '@/src/storage/appStorage';
|
import { getOnboardingCompleted } from '@/src/storage/appStorage';
|
||||||
|
import { API_BASE_URL } from '@/src/constants/env';
|
||||||
|
|
||||||
// 导入 SVG 组件
|
// 导入 SVG 组件
|
||||||
import FlowersBg from '../../assets/images/index/flowers_endbg.svg';
|
import FlowersBg from '../../assets/images/index/flowers_endbg.svg';
|
||||||
@@ -19,11 +20,19 @@ export default function SplashScreen() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showConsent, setShowConsent] = useState(false);
|
const [showConsent, setShowConsent] = useState(false);
|
||||||
const [links, setLinks] = useState<{ privacy?: string; terms?: string }>({});
|
const [links, setLinks] = useState<{ privacy?: string; terms?: string }>({});
|
||||||
|
const [linksLoading, setLinksLoading] = useState(false);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkConsent();
|
checkConsent();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const checkConsent = async () => {
|
const checkConsent = async () => {
|
||||||
const accepted = await getConsentAccepted();
|
const accepted = await getConsentAccepted();
|
||||||
setShowConsent(!accepted);
|
setShowConsent(!accepted);
|
||||||
@@ -53,23 +62,48 @@ export default function SplashScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 拉取协议链接(由后端按语言下发;默认 EN)
|
async function refreshLegalLinks(): Promise<{ privacy?: string; terms?: string }> {
|
||||||
useEffect(() => {
|
if (mountedRef.current) setLinksLoading(true);
|
||||||
let cancelled = false;
|
|
||||||
(async () => {
|
|
||||||
try {
|
try {
|
||||||
const res = await fetchLegalLinks();
|
const res = await fetchLegalLinks();
|
||||||
if (cancelled) return;
|
const next = { privacy: res.privacyPolicyUrl, terms: res.termsOfUseUrl };
|
||||||
setLinks({ privacy: res.privacyPolicyUrl, terms: res.termsOfUseUrl });
|
if (mountedRef.current) setLinks(next);
|
||||||
|
return next;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 不阻塞主流程:失败时不崩溃,链接入口可不展示
|
// 不阻塞主流程:失败时不崩溃,链接入口仍可点(会提示)
|
||||||
if (__DEV__) console.log('[LegalLinks] 拉取失败(splash):', 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;
|
const bgDecorationTop = 363;
|
||||||
@@ -114,23 +148,33 @@ export default function SplashScreen() {
|
|||||||
<WelcomeBtn width={87} height={57} />
|
<WelcomeBtn width={87} height={57} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View style={styles.linksContainer}>
|
<Text style={styles.noticeText}>
|
||||||
<TouchableOpacity
|
<Trans
|
||||||
disabled={!links.privacy}
|
i18nKey="consent.noticeRich"
|
||||||
onPress={() => (links.privacy ? openLink(links.privacy) : undefined)}
|
values={{
|
||||||
>
|
privacyLabel: t('consent.privacy'),
|
||||||
<Text style={styles.linkText}>{t('consent.privacy')}</Text>
|
termsLabel: t('consent.terms'),
|
||||||
</TouchableOpacity>
|
privacySuffix: !links.privacy && linksLoading ? t('consent.linkLoadingSuffix') : '',
|
||||||
<View style={styles.divider} />
|
termsSuffix: !links.terms && linksLoading ? t('consent.linkLoadingSuffix') : '',
|
||||||
<TouchableOpacity
|
}}
|
||||||
disabled={!links.terms}
|
components={{
|
||||||
onPress={() => (links.terms ? openLink(links.terms) : undefined)}
|
privacy: (
|
||||||
>
|
<Text
|
||||||
<Text style={styles.linkText}>{t('consent.terms')}</Text>
|
style={[styles.noticeLinkText, !links.privacy && styles.noticeLinkTextDisabled]}
|
||||||
</TouchableOpacity>
|
onPress={() => void handleOpenLegal('privacy')}
|
||||||
</View>
|
suppressHighlighting
|
||||||
|
/>
|
||||||
<Text style={styles.noticeText}>{t('consent.notice')}</Text>
|
),
|
||||||
|
terms: (
|
||||||
|
<Text
|
||||||
|
style={[styles.noticeLinkText, !links.terms && styles.noticeLinkTextDisabled]}
|
||||||
|
onPress={() => void handleOpenLegal('terms')}
|
||||||
|
suppressHighlighting
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
@@ -179,10 +223,6 @@ const styles = StyleSheet.create({
|
|||||||
buttonWrapper: {
|
buttonWrapper: {
|
||||||
marginBottom: 40,
|
marginBottom: 40,
|
||||||
},
|
},
|
||||||
linksContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
noticeText: {
|
noticeText: {
|
||||||
marginTop: 10,
|
marginTop: 10,
|
||||||
paddingHorizontal: 28,
|
paddingHorizontal: 28,
|
||||||
@@ -191,15 +231,14 @@ const styles = StyleSheet.create({
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: 'rgba(119, 47, 0, 0.45)',
|
color: 'rgba(119, 47, 0, 0.45)',
|
||||||
},
|
},
|
||||||
linkText: {
|
noticeLinkText: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: 'rgba(119, 47, 0, 0.5)', // 使用半透明的文字颜色
|
// 颜色区分:协议链接更醒目
|
||||||
|
color: 'rgba(119, 47, 0, 0.75)',
|
||||||
textDecorationLine: 'underline',
|
textDecorationLine: 'underline',
|
||||||
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
divider: {
|
noticeLinkTextDisabled: {
|
||||||
width: 1,
|
opacity: 0.55,
|
||||||
height: 12,
|
|
||||||
backgroundColor: 'rgba(119, 47, 0, 0.2)',
|
|
||||||
marginHorizontal: 15,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -139,7 +139,10 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
|||||||
|
|
||||||
const openLink = useCallback(
|
const openLink = useCallback(
|
||||||
async (url?: string) => {
|
async (url?: string) => {
|
||||||
if (!url) return;
|
if (!url) {
|
||||||
|
Alert.alert(t('common.notice'), t('consent.linkUnavailable'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await WebBrowser.openBrowserAsync(url);
|
await WebBrowser.openBrowserAsync(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -435,7 +438,14 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
|||||||
try {
|
try {
|
||||||
const expoPushToken = await getExpoPushTokenOrThrow();
|
const expoPushToken = await getExpoPushTokenOrThrow();
|
||||||
await registerPushToken({ pushToken: expoPushToken });
|
await registerPushToken({ pushToken: expoPushToken });
|
||||||
|
// 偏好同步失败不应被用户感知为“开启失败”
|
||||||
|
// (常见现象:后端已接收 token,但偏好接口短暂失败/超时)
|
||||||
|
try {
|
||||||
await setPushPreferences({ enabled: true, timesPerDay });
|
await setPushPreferences({ enabled: true, timesPerDay });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.warn('[PushPreferences] 同步失败(ProfileModal,不阻塞)', msg);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
Alert.alert(t('common.notice'), msg);
|
Alert.alert(t('common.notice'), msg);
|
||||||
|
|||||||
21
client/eas.json
Normal file
21
client/eas.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 16.32.0",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
|
"notice": "Notice",
|
||||||
"openLinkError": "Cannot open link",
|
"openLinkError": "Cannot open link",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"close": "Close"
|
"close": "Close"
|
||||||
@@ -146,7 +147,11 @@
|
|||||||
"agree": "Agree & Continue",
|
"agree": "Agree & Continue",
|
||||||
"privacy": "Privacy Policy",
|
"privacy": "Privacy Policy",
|
||||||
"terms": "Terms of Use",
|
"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": {
|
"permissions": {
|
||||||
"notificationsDenied": "Notifications are denied. Please enable them in Settings."
|
"notificationsDenied": "Notifications are denied. Please enable them in Settings."
|
||||||
@@ -168,6 +173,9 @@
|
|||||||
"ok": "確定",
|
"ok": "確定",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"back": "返回",
|
"back": "返回",
|
||||||
|
"error": "錯誤",
|
||||||
|
"notice": "提示",
|
||||||
|
"openLinkError": "無法打開鏈接",
|
||||||
"close": "關閉"
|
"close": "關閉"
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
@@ -308,7 +316,11 @@
|
|||||||
"agree": "同意並繼續",
|
"agree": "同意並繼續",
|
||||||
"privacy": "隱私協議",
|
"privacy": "隱私協議",
|
||||||
"terms": "用戶使用協議",
|
"terms": "用戶使用協議",
|
||||||
"notice": "繼續使用即代表你同意《隱私協議》與《用戶使用協議》。"
|
"notice": "繼續使用即代表你同意《隱私協議》與《用戶使用協議》。",
|
||||||
|
"noticeRich": "繼續使用即代表你同意<privacy>《{{privacyLabel}}》{{privacySuffix}}</privacy>與<terms>《{{termsLabel}}》{{termsSuffix}}</terms>。",
|
||||||
|
"linkUnavailable": "協議鏈接載入失敗,請檢查網路後重試。",
|
||||||
|
"linkUnavailableDev": "協議鏈接載入失敗,請檢查網路或 API_BASE_URL 設定:{{baseUrl}}",
|
||||||
|
"linkLoadingSuffix": "(載入中…)"
|
||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。"
|
"notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。"
|
||||||
|
|||||||
@@ -142,6 +142,22 @@ export async function httpJson<T>(opts: HttpJsonOptions): Promise<T> {
|
|||||||
return undefined as unknown as 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 app /app/app
|
||||||
COPY alembic /app/alembic
|
COPY alembic /app/alembic
|
||||||
COPY alembic.ini /app/alembic.ini
|
COPY alembic.ini /app/alembic.ini
|
||||||
|
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
|
||||||
|
|
||||||
|
RUN chmod +x /app/docker-entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
@@ -29,4 +32,7 @@ EXPOSE 8000
|
|||||||
# - 参考文档:server/README.md
|
# - 参考文档:server/README.md
|
||||||
|
|
||||||
# 生产镜像默认不开启 reload
|
# 生产镜像默认不开启 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"]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from datetime import datetime, timezone
|
|||||||
from typing import Any, Literal, Optional
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import redis
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -13,8 +14,10 @@ from app.api.limits import rate_limit_push_by_ip
|
|||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.db.models.push_preference import PushPreference
|
from app.db.models.push_preference import PushPreference
|
||||||
from app.db.models.push_token import PushToken
|
from app.db.models.push_token import PushToken
|
||||||
|
from app.db.models.push_send_log import PushSendLog
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.features.user_profile_scoring.types import UserProfileV1_2
|
from app.features.user_profile_scoring.types import UserProfileV1_2
|
||||||
|
from app.worker import celery_app
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
@@ -260,3 +263,84 @@ async def test_push(
|
|||||||
_ = accept_language
|
_ = accept_language
|
||||||
return {"status": "ok", "expo": expo_res}
|
return {"status": "ok", "expo": expo_res}
|
||||||
|
|
||||||
|
|
||||||
|
def _env_prefix(app_env: str) -> str:
|
||||||
|
"""
|
||||||
|
根据环境生成前缀:
|
||||||
|
- dev -> dev
|
||||||
|
- prod -> pro
|
||||||
|
"""
|
||||||
|
|
||||||
|
return "dev" if str(app_env) == "dev" else "pro"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scheduler/health")
|
||||||
|
async def scheduler_health(db: AsyncSession = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
推送“定时服务”健康检查(用于容器内验证)。
|
||||||
|
|
||||||
|
返回内容(尽量不暴露敏感信息):
|
||||||
|
- Redis:是否可连通
|
||||||
|
- Worker:是否至少有一个 worker 在线(inspect ping)
|
||||||
|
- Beat:是否在跑(beat 心跳 key 是否在持续刷新)
|
||||||
|
- DB:是否可查询到 push_send_log 的最新时间(辅助定位排程是否生成)
|
||||||
|
"""
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
prefix = _env_prefix(settings.app_env)
|
||||||
|
beat_key = f"{prefix}:beat:heartbeat"
|
||||||
|
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"env": settings.app_env,
|
||||||
|
"redis": {"ok": False},
|
||||||
|
"worker": {"ok": False, "worker_count": 0},
|
||||||
|
"beat": {"ok": False, "last_heartbeat_at": None, "age_seconds": None},
|
||||||
|
"db": {"ok": False, "push_send_log_latest_created_at": None},
|
||||||
|
"now_utc": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1) Redis 连通性 + 读取 beat 心跳
|
||||||
|
try:
|
||||||
|
r = redis.Redis.from_url(settings.celery_broker_url, decode_responses=True)
|
||||||
|
r.ping()
|
||||||
|
out["redis"]["ok"] = True
|
||||||
|
|
||||||
|
hb = r.get(beat_key)
|
||||||
|
if hb:
|
||||||
|
out["beat"]["last_heartbeat_at"] = hb
|
||||||
|
try:
|
||||||
|
# Python 3.11+ 支持解析 ISO8601(含 +00:00)
|
||||||
|
hb_dt = datetime.fromisoformat(hb.replace("Z", "+00:00"))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
age = int((now - hb_dt.astimezone(timezone.utc)).total_seconds())
|
||||||
|
out["beat"]["age_seconds"] = age
|
||||||
|
# 2 分钟内认为健康(beat 每分钟刷新一次)
|
||||||
|
out["beat"]["ok"] = age <= 120
|
||||||
|
except Exception:
|
||||||
|
# 解析失败:至少说明 key 存在,但时间格式异常
|
||||||
|
out["beat"]["ok"] = False
|
||||||
|
except Exception as e:
|
||||||
|
out["redis"]["error"] = f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
# 2) Worker 在线性(inspect ping)
|
||||||
|
try:
|
||||||
|
insp = celery_app.control.inspect(timeout=1.0)
|
||||||
|
pings = insp.ping() or {}
|
||||||
|
if isinstance(pings, dict):
|
||||||
|
out["worker"]["worker_count"] = len(pings)
|
||||||
|
out["worker"]["ok"] = len(pings) > 0
|
||||||
|
except Exception as e:
|
||||||
|
out["worker"]["error"] = f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
# 3) DB:查询 push_send_log 最新创建时间(用于判断排程是否有生成)
|
||||||
|
try:
|
||||||
|
q = select(PushSendLog.created_at).order_by(PushSendLog.created_at.desc()).limit(1)
|
||||||
|
row = await db.execute(q)
|
||||||
|
latest = row.scalar_one_or_none()
|
||||||
|
out["db"]["ok"] = True
|
||||||
|
out["db"]["push_send_log_latest_created_at"] = latest.isoformat() if latest else None
|
||||||
|
except Exception as e:
|
||||||
|
out["db"]["error"] = f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ Celery 任务集合。
|
|||||||
from app.tasks import ping as _ping # noqa: F401
|
from app.tasks import ping as _ping # noqa: F401
|
||||||
from app.tasks import reco as _reco # noqa: F401
|
from app.tasks import reco as _reco # noqa: F401
|
||||||
from app.tasks import push as _push # noqa: F401
|
from app.tasks import push as _push # noqa: F401
|
||||||
|
from app.tasks import ops as _ops # noqa: F401
|
||||||
|
|
||||||
|
|||||||
41
server/app/tasks/ops.py
Normal file
41
server/app/tasks/ops.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import redis
|
||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
def _env_prefix(app_env: str) -> str:
|
||||||
|
"""
|
||||||
|
根据环境生成前缀:
|
||||||
|
- dev -> dev
|
||||||
|
- prod -> pro
|
||||||
|
"""
|
||||||
|
|
||||||
|
return "dev" if str(app_env) == "dev" else "pro"
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="tasks.ops.beat_heartbeat")
|
||||||
|
def beat_heartbeat() -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Beat 心跳任务(用于健康检查)。
|
||||||
|
|
||||||
|
作用:
|
||||||
|
- 由 Celery Beat 每分钟触发一次
|
||||||
|
- 写入 Redis 心跳 key,并设置 TTL
|
||||||
|
- API 侧读取该 key,可判断 beat 是否在运行
|
||||||
|
"""
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
prefix = _env_prefix(settings.app_env)
|
||||||
|
key = f"{prefix}:beat:heartbeat"
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
r = redis.Redis.from_url(settings.celery_broker_url, decode_responses=True)
|
||||||
|
# TTL 设短一些:一旦 beat 挂了,很快就能从“过期/缺失”判断出来
|
||||||
|
r.set(key, now, ex=180)
|
||||||
|
return {"status": "ok", "key": key, "at": now}
|
||||||
|
|
||||||
@@ -45,6 +45,12 @@ celery_app.conf.update(
|
|||||||
# - 这里按 UTC 00:10 触发一次;具体时间可按运维习惯调整
|
# - 这里按 UTC 00:10 触发一次;具体时间可按运维习惯调整
|
||||||
celery_app.conf.timezone = "UTC"
|
celery_app.conf.timezone = "UTC"
|
||||||
celery_app.conf.beat_schedule = {
|
celery_app.conf.beat_schedule = {
|
||||||
|
# Beat 心跳:用于 API 健康检查判断 beat 是否在跑
|
||||||
|
"ops-beat-heartbeat": {
|
||||||
|
"task": "tasks.ops.beat_heartbeat",
|
||||||
|
"schedule": crontab(minute="*/1"),
|
||||||
|
"options": {"queue": f"{prefix}:celery"},
|
||||||
|
},
|
||||||
"push-generate-daily-schedule": {
|
"push-generate-daily-schedule": {
|
||||||
"task": "tasks.push.generate_daily_schedule",
|
"task": "tasks.push.generate_daily_schedule",
|
||||||
"schedule": crontab(minute=10, hour=0),
|
"schedule": crontab(minute=10, hour=0),
|
||||||
|
|||||||
BIN
server/celerybeat-schedule
Normal file
BIN
server/celerybeat-schedule
Normal file
Binary file not shown.
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)
|
# - 自动启动 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 --env prod # 使用 .env.prod(若存在且可被 source)
|
||||||
# ./run.sh --port 9000 # 改端口
|
# ./run.sh --port 9000 # 改端口
|
||||||
# ./run.sh --no-reload # 关闭热更新
|
# ./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 # 只安装依赖,不启动
|
# ./run.sh --install-only # 只安装依赖,不启动
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
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(如果存在)。
|
--env dev|prod 优先尝试加载 .env.dev 或 .env.prod(如果存在)。
|
||||||
--host <host> uvicorn host(默认 0.0.0.0)
|
--host <host> uvicorn host(默认 0.0.0.0)
|
||||||
--port <port> uvicorn port(默认 8000)
|
--port <port> uvicorn port(默认 8000)
|
||||||
--no-reload 关闭 uvicorn --reload
|
--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)
|
--skip-install 跳过依赖安装(默认会安装/更新 requirements.txt)
|
||||||
--install-only 只安装依赖,不启动服务
|
--install-only 只安装依赖,不启动服务
|
||||||
-h, --help 显示帮助
|
-h, --help 显示帮助
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
- 若你的 .env.* 不是 shell 可 source 的格式(例如包含空格/特殊字符未加引号),建议改成 KEY=value 形式。
|
- 若你的 .env.* 不是 shell 可 source 的格式(例如包含空格/特殊字符未加引号),建议改成 KEY=value 形式。
|
||||||
|
- 仅启动 API 并不会生成 `push_send_log`;要测试“定时推送”,需要 Beat 调度 `tasks.push.generate_daily_schedule`,并由 Worker 执行后续 ETA 任务。
|
||||||
|
- 也可以用环境变量一键启动:START_ALL=1 ./run.sh
|
||||||
- 启动后访问:
|
- 启动后访问:
|
||||||
/healthz 健康检查
|
/healthz 健康检查
|
||||||
/docs OpenAPI 文档
|
/docs OpenAPI 文档
|
||||||
@@ -45,6 +55,10 @@ PORT="8000"
|
|||||||
RELOAD="1"
|
RELOAD="1"
|
||||||
SKIP_INSTALL="0"
|
SKIP_INSTALL="0"
|
||||||
INSTALL_ONLY="0"
|
INSTALL_ONLY="0"
|
||||||
|
API_ONLY="0"
|
||||||
|
# 默认:一键启动(满足“bash run.sh 就全部启动”)
|
||||||
|
WITH_WORKER="1"
|
||||||
|
WITH_BEAT="1"
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -64,6 +78,25 @@ while [[ $# -gt 0 ]]; do
|
|||||||
RELOAD="0"
|
RELOAD="0"
|
||||||
shift 1
|
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)
|
||||||
SKIP_INSTALL="1"
|
SKIP_INSTALL="1"
|
||||||
shift 1
|
shift 1
|
||||||
@@ -99,6 +132,28 @@ if [[ -f "$ENV_FILE" ]]; then
|
|||||||
set +a
|
set +a
|
||||||
fi
|
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)
|
# 选择 python 命令(优先 python3)
|
||||||
PY_BIN=""
|
PY_BIN=""
|
||||||
if command -v python3 >/dev/null 2>&1; then
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
@@ -140,6 +195,45 @@ if [[ "$RELOAD" == "1" ]]; then
|
|||||||
UVICORN_ARGS+=(--reload)
|
UVICORN_ARGS+=(--reload)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "启动服务:uvicorn ${UVICORN_ARGS[*]}"
|
if [[ "$WITH_WORKER" == "0" && "$WITH_BEAT" == "0" ]]; then
|
||||||
exec uvicorn "${UVICORN_ARGS[@]}"
|
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