6 Commits

Author SHA1 Message Date
4578d503e7 Merge pull request '增加定时服务的健康检测' (#18) from damer into main
Reviewed-on: #18
2026-02-09 07:40:02 +00:00
吕新雨
f03d36b5e9 增加定时服务的健康检测 2026-02-09 15:39:46 +08:00
66241e5231 Merge pull request 'damer' (#17) from damer into main
Reviewed-on: #17
2026-02-09 06:49:41 +00:00
吕新雨
e980bd4e4d fix:更新容器启动 2026-02-09 14:47:17 +08:00
吕新雨
0b8bbebf6a fix:增加定时推动 2026-02-09 11:52:11 +08:00
吕新雨
1e1e49ea57 fix:同意隐私 2026-02-05 16:33:58 +08:00
15 changed files with 561 additions and 69 deletions

View File

@@ -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} 已上线"

View File

@@ -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) 上报推送偏好(幂等)
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'); await setPushPromptState('enabled');
} catch (e) { } catch (e) {

View File

@@ -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() {
} }
}; };
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 // 拉取协议链接(由后端按语言下发;默认 EN
useEffect(() => { useEffect(() => {
let cancelled = false; void refreshLegalLinks();
(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;
};
}, []); }, []);
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,
}, },
}); });

View File

@@ -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 });
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) { } 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
View 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": {}
}
}

View File

@@ -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": "系統權限已被拒絕,請前往手機設定開啟通知。"

View File

@@ -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,
});
}
} }

View File

@@ -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"]

View File

@@ -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

View File

@@ -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
View 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}

View File

@@ -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

Binary file not shown.

View 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 / PORTAPI 监听地址,默认 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。
# 说明:
# - 单容器模式:若启动了 APISTART_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 Workercelery -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 Beatcelery -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 "启动 APIuvicorn 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

View File

@@ -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 Workercelery -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 Beatcelery -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[@]}"