From dec3ac82e1b7bbdd610e82da5f367f817b233265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=96=B0=E9=9B=A8?= Date: Tue, 10 Feb 2026 16:47:18 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E5=90=8E=E7=AB=AF=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/app/(onboarding)/onboarding.tsx | 11 +++++------ client/app/_layout.tsx | 11 +++++++++++ client/components/home/ProfileModal.tsx | 7 +++---- client/src/services/pushApi.ts | 7 ++++++- server/app/api/v1/push.py | 7 ++++--- 5 files changed, 29 insertions(+), 14 deletions(-) diff --git a/client/app/(onboarding)/onboarding.tsx b/client/app/(onboarding)/onboarding.tsx index f4b2e98..c3f7e8d 100644 --- a/client/app/(onboarding)/onboarding.tsx +++ b/client/app/(onboarding)/onboarding.tsx @@ -12,7 +12,7 @@ import { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnai import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromScoring } from '@/src/modules/dailyWidgetReco'; import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale'; import { fetchRecoFeed } from '@/src/services/recoApi'; -import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi'; +import { ensurePushTokenRegisteredIfPermitted, setPushPreferences } from '@/src/services/pushApi'; import { recordRecoFeedServed, setOnboardingCompleted, @@ -128,7 +128,8 @@ export default function OnboardingScreen() { await setPushPromptState('unknown'); try { const { status } = await Notifications.requestPermissionsAsync(); - if (status !== 'granted') { + // iOS 可能出现 provisional(临时授权),也应视为“已授权” + if (status !== 'granted' && status !== ('provisional' as any)) { await setPushPromptState('skipped'); return; } @@ -140,10 +141,8 @@ export default function OnboardingScreen() { return; } - // 1) 获取 Expo Push Token(失败才认为“推送开启失败”) - const expoPushToken = await getExpoPushTokenOrThrow(); - // 2) 上报 token 到后端(幂等;失败才认为“推送开启失败”) - await registerPushToken({ pushToken: expoPushToken }); + // 1) 上报 token 到后端(幂等;失败才认为“推送开启失败”) + await ensurePushTokenRegisteredIfPermitted(); // 3) 上报推送偏好(幂等) // 注意:这一步失败时,后端仍可能已成功接收 token。 diff --git a/client/app/_layout.tsx b/client/app/_layout.tsx index c9f09dc..5da97d7 100644 --- a/client/app/_layout.tsx +++ b/client/app/_layout.tsx @@ -87,6 +87,17 @@ export default function RootLayout() { }); }, []); + useEffect(() => { + // 兜底:当用户在系统弹窗/系统设置里变更权限后,App 回到前台时再同步一次 token + const sub = AppState.addEventListener('change', (state) => { + if (state !== 'active') return; + ensurePushTokenRegisteredIfPermitted().catch(() => { + // ignore:不阻塞 + }); + }); + return () => sub.remove(); + }, []); + useEffect(() => { // 字体与 i18n 都准备好后,允许渲染 App(原生 splash 的隐藏交给 onLayout,避免“硬切/闪白”) if (loaded && i18nReady) setAppReady(true); diff --git a/client/components/home/ProfileModal.tsx b/client/components/home/ProfileModal.tsx index d484486..200d0bf 100644 --- a/client/components/home/ProfileModal.tsx +++ b/client/components/home/ProfileModal.tsx @@ -41,7 +41,7 @@ import QuestionIcon from '@/assets/images/home/Profile/widget/question_icon.svg' import * as Notifications from 'expo-notifications'; import { changeLanguage } from '@/src/i18n'; import { fetchLegalLinks } from '@/src/services/legalApi'; -import { ensurePushTokenRegisteredIfPermitted, getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi'; +import { ensurePushTokenRegisteredIfPermitted, setPushPreferences } from '@/src/services/pushApi'; const { width } = Dimensions.get('window'); @@ -430,14 +430,13 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () = // 调试:打印状态 console.log('Push Permission Status:', status); - if (status === 'granted') { + if (status === 'granted' || (status as any) === 'provisional') { setPushEnabled(true); setHasSystemPermission(true); // 获取 token 并上报后端(幂等) try { - const expoPushToken = await getExpoPushTokenOrThrow(); - await registerPushToken({ pushToken: expoPushToken }); + await ensurePushTokenRegisteredIfPermitted(); // 偏好同步失败不应被用户感知为“开启失败” // (常见现象:后端已接收 token,但偏好接口短暂失败/超时) try { diff --git a/client/src/services/pushApi.ts b/client/src/services/pushApi.ts index 38b06b7..84b1c3c 100644 --- a/client/src/services/pushApi.ts +++ b/client/src/services/pushApi.ts @@ -160,10 +160,15 @@ export async function registerPushToken(args: { pushToken: string }): Promise { // 只要系统权限已授权,就应尽早把 token 写入后端(不依赖用户在“每日提醒”里点确认) const settings = await Notifications.getPermissionsAsync(); - if (settings.status !== 'granted') return { ok: false, reason: 'permission_not_granted' }; + if (!isSystemNotificationPermissionGranted(settings.status)) return { ok: false, reason: 'permission_not_granted' }; const token = await getExpoPushTokenOrThrow(); diff --git a/server/app/api/v1/push.py b/server/app/api/v1/push.py index ec43968..ae9b8c9 100644 --- a/server/app/api/v1/push.py +++ b/server/app/api/v1/push.py @@ -194,9 +194,10 @@ async def put_preferences(req: PushPreferencesRequest, db: AsyncSession = Depend await db.commit() - # 返回更新时间(从 ORM 读取到的可能不包含 server_onupdate,这里用 now 兜底) - updated_at = getattr(pref, "updated_at", None) - updated_at_iso = updated_at.isoformat() if isinstance(updated_at, datetime) else None + # 返回更新时间: + # - 某些运行环境/驱动组合下,commit 后访问 ORM 字段可能触发隐式 IO,导致 async 下报 MissingGreenlet。 + # - 这里直接用当前时间兜底(字段本身为可选,仅用于前端展示)。 + updated_at_iso = datetime.now(timezone.utc).isoformat() return PushPreferencesResponse( client_user_id=req.client_user_id,