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 1/3] =?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, From 154f347ddbe9a225e42735a3448fb36178999fa0 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:57:36 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E6=B3=A8=E5=86=8Ctoken=E6=8E=92=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/app/api/v1/push.py | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/server/app/api/v1/push.py b/server/app/api/v1/push.py index ae9b8c9..07814aa 100644 --- a/server/app/api/v1/push.py +++ b/server/app/api/v1/push.py @@ -7,7 +7,7 @@ import httpx import redis from fastapi import APIRouter, Depends, Header, HTTPException, Query from pydantic import BaseModel, Field -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.limits import rate_limit_push_by_ip @@ -297,6 +297,7 @@ async def scheduler_health(db: AsyncSession = Depends(get_db)) -> dict[str, Any] "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}, + "db_push_tokens": {"ok": False, "count": None, "latest": None}, "now_utc": datetime.now(timezone.utc).isoformat(), } @@ -343,5 +344,36 @@ async def scheduler_health(db: AsyncSession = Depends(get_db)) -> dict[str, Any] except Exception as e: out["db"]["error"] = f"{type(e).__name__}: {e}" + # 4) DB:查询 push_tokens 计数与最近一条(用于确认 /v1/push/register 是否真正落库) + try: + qcount = select(func.count()).select_from(PushToken) + rcount = await db.execute(qcount) + cnt = int(rcount.scalar_one() or 0) + + qlatest = select(PushToken).order_by(PushToken.last_seen_at.desc()).limit(1) + rlatest = await db.execute(qlatest) + t = rlatest.scalar_one_or_none() + + latest_obj = None + if t is not None: + tok = str(t.push_token or "") + masked = tok[:10] + "***" + tok[-6:] if len(tok) > 20 else (tok[:6] + "***" if tok else "") + latest_obj = { + "id": int(getattr(t, "id", 0) or 0), + "client_user_id": str(t.client_user_id), + "env": str(t.env), + "app_id": str(t.app_id), + "platform": str(t.platform), + "is_active": bool(t.is_active), + "last_seen_at": t.last_seen_at.isoformat() if t.last_seen_at else None, + "push_token_masked": masked, + } + + out["db_push_tokens"]["ok"] = True + out["db_push_tokens"]["count"] = cnt + out["db_push_tokens"]["latest"] = latest_obj + except Exception as e: + out["db_push_tokens"]["error"] = f"{type(e).__name__}: {e}" + return out From 076bd5636f5e57811d3bd7b642d24183593a1c2f 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 17:23:38 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=E6=9B=B4=E6=96=B0APP-PUSH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/services/pushApi.ts | 25 ++++++++++++++++------ client/src/storage/appStorage.ts | 35 +++++++++++++++++++++++++++++-- server/celerybeat-schedule-shm | Bin 0 -> 32768 bytes server/celerybeat-schedule-wal | Bin 0 -> 107152 bytes 4 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 server/celerybeat-schedule-shm create mode 100644 server/celerybeat-schedule-wal diff --git a/client/src/services/pushApi.ts b/client/src/services/pushApi.ts index 84b1c3c..31bc19a 100644 --- a/client/src/services/pushApi.ts +++ b/client/src/services/pushApi.ts @@ -7,10 +7,10 @@ import { httpJson } from '../utils/http'; import { APP_ENV } from '../constants/env'; import { getDailyReminderSettings, - getLastRegisteredPushToken, + getLastRegisteredPushPayload, getOrCreateClientUserId, getUserProfileScoring, - setLastRegisteredPushToken, + setLastRegisteredPushPayload, } from '../storage/appStorage'; import type { UserProfileScoring } from '../storage/appStorage'; import { toBackendLocaleFromLanguageTag } from '../i18n/locale'; @@ -170,14 +170,27 @@ export async function ensurePushTokenRegisteredIfPermitted(): Promise<{ ok: bool const settings = await Notifications.getPermissionsAsync(); if (!isSystemNotificationPermissionGranted(settings.status)) return { ok: false, reason: 'permission_not_granted' }; + const clientUserId = await getOrCreateClientUserId(); const token = await getExpoPushTokenOrThrow(); + const env = toPushEnv(APP_ENV); + const appId = pickAppId(); - // 简单去重:token 未变化则不重复上报(减少网络与日志噪音) - const last = await getLastRegisteredPushToken().catch(() => null); - if (last && String(last) === String(token)) return { ok: true, reason: 'already_registered' }; + // 去重规则(更严格): + // - 只有当 token + client_user_id + env + app_id 全都一致时才跳过 + // - 避免出现“client_user_id 变化但 token 没变,导致后端绑定不更新”的问题 + const last = await getLastRegisteredPushPayload().catch(() => null); + if ( + last && + last.pushToken === token && + last.clientUserId === clientUserId && + last.env === env && + last.appId === appId + ) { + return { ok: true, reason: 'already_registered' }; + } await registerPushToken({ pushToken: token }); - await setLastRegisteredPushToken(token); + await setLastRegisteredPushPayload({ pushToken: token, clientUserId, env, appId }); return { ok: true, reason: 'registered' }; } diff --git a/client/src/storage/appStorage.ts b/client/src/storage/appStorage.ts index 84f7b2d..ea505da 100644 --- a/client/src/storage/appStorage.ts +++ b/client/src/storage/appStorage.ts @@ -18,8 +18,9 @@ const KEY_RECO_FEED_HISTORY = 'reco.feedHistory'; const KEY_UI_THEME_MODE = 'ui.theme.mode'; const KEY_UI_THEME_SUIXIN_STATE = 'ui.theme.suixin.state'; const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings'; -const KEY_PUSH_LAST_REGISTERED_TOKEN = 'push.lastRegisteredToken'; -const KEY_PUSH_LAST_REGISTERED_AT = 'push.lastRegisteredAt'; +const KEY_PUSH_LAST_REGISTERED_TOKEN = 'push.lastRegisteredToken'; // 旧:仅 token(保留兼容读取) +const KEY_PUSH_LAST_REGISTERED_AT = 'push.lastRegisteredAt'; // 旧:时间(保留兼容) +const KEY_PUSH_LAST_REGISTERED_PAYLOAD = 'push.lastRegisteredPayload'; // 新:token+client_user_id+env+app_id export type PushPromptState = 'enabled' | 'skipped' | 'unknown'; export type Reaction = 'like' | 'dislike'; @@ -81,6 +82,36 @@ export async function setLastRegisteredPushToken(token: string): Promise { await AsyncStorage.setItem(KEY_PUSH_LAST_REGISTERED_AT, new Date().toISOString()); } +export type LastRegisteredPushPayload = { + pushToken: string; + clientUserId: string; + env: 'dev' | 'prod'; + appId: string; + savedAt: string; // ISO8601 +}; + +export async function getLastRegisteredPushPayload(): Promise { + const raw = await AsyncStorage.getItem(KEY_PUSH_LAST_REGISTERED_PAYLOAD); + if (!raw) return null; + try { + const obj = JSON.parse(raw) as Partial; + if (!obj || typeof obj !== 'object') return null; + if (!obj.pushToken || !obj.clientUserId || !obj.env || !obj.appId || !obj.savedAt) return null; + if (obj.env !== 'dev' && obj.env !== 'prod') return null; + return obj as LastRegisteredPushPayload; + } catch { + return null; + } +} + +export async function setLastRegisteredPushPayload(payload: Omit): Promise { + const savedAt = new Date().toISOString(); + const full: LastRegisteredPushPayload = { ...payload, savedAt }; + await AsyncStorage.setItem(KEY_PUSH_LAST_REGISTERED_PAYLOAD, JSON.stringify(full)); + // 同时写入旧 key,便于兼容老逻辑/快速排查 + await setLastRegisteredPushToken(payload.pushToken); +} + export type RecoFeedCacheItem = { content_id: number; text: string; diff --git a/server/celerybeat-schedule-shm b/server/celerybeat-schedule-shm new file mode 100644 index 0000000000000000000000000000000000000000..8247f6cda01b4b87c44e24535c84436d541f99bf GIT binary patch literal 32768 zcmeI)J4!=Q5C-5s@ez%$#AtFAdqEJ~gsqT0qz?hN;Vx{lffRO@*@jCn2zGj|NU*ig z+k6AV42Q#+!?%E`UanKeLCIFqJ1=#kMQDZKYqmb@w&82SI_2LEI1ar9U2Ak*TSe!{^$fq6Y7)#Dq^srA%+AiFA_Xw`|!1- z?`fJi-&9tfobP%2oO4p_U!Q0FGj-1@u?s5{rAcwWE^VL8rC({E+4v4gvJ^*D)uTXr7_#cnE(-W!M8a4MfOEN#FYd?GB*3A8)=3elt*g!7eql0J)9N?x=U?3rdz{q&JCL}7bm6*EpHW>^qRJ5kJ~Fs zQnE_XQ}tD8C`p8u%Zr+K^rgiK)atEmcCh_{2a6-Te3aK5)iUp@X{kYBq}5bbXhvb! zkt5T)_)FQ#qBN7;n;z7<^C!rvbTD1a5$3}(&VkzigPc-o4bS2B?T*` z3PT&niu941o$Z$SBq8zRAI!1o41cU`b4U>$nMJ?wj{;88i1%kl!f-`m~QpFH~S|K$aW zX@SVwb85;A1_1;RKmY**5I_I{1Q3|>0^|kA3(Q_#VB-wt1t#vf@4XFwoY+oYV9wuU z7$5=&Ab+Xp9-J8$IA=2YYVt*3_y(l z0tg_000IagfB*srAaFAR5I_I{1Q0*~0R#|OXac^F zfUmgeiv0*~G%pZsefU#$3X z`H^^4t}-y^@&Y|GxF5mf@IMb9J^sc8@&dPe51>8*2q1s}0tg_000IagfB*usA>a!M z_=;D_3-nxXKZ3m2MeIISE#n(7-`E$^HT-rXFOTW@S`S9Tvl|FGF5Sr!KjXP7iNEfoASGG0cj5}?5 zz>Zp)IcUb+cH&+;YOx))(oREKwr*vHgkx8V<4#Yw{^Fu=sIIQz%_H)efkGy0Wen4? z>-Pyuv#h-A!)`c~GepMNo0G+-WwFmK%HtumU<_wcgIVDmb9Oj(aQChrClPSV?jsSf zo64j2#^E-c%I6GA8*m4o2qzRf(C+9f^_7;aG3pH5KPuZD(hjNxQ+~YdY&+g!$|JS= z3!)&09!`q`-K91i)2(4Q=Z4Lgixbm@mbZ#bdQIE3$L$p*DOn}%o~Zh&G!!L5;pOrL znRoQ1#R=5vt!;L&{ecIIBfNZ+qt0F3kKm@(7r2GIz>`Dk{{Gc9-{>PRP~CSZW`_U* z2q1s}0tg_000IagP%Q$!kbtlFxV!N{!AhyZ&<3(1eUgUFuQ$c*EzsfEfpz6%LM1&R(-QUK z;BY1{WP0MQ*NQ2LLcIA-=LN#AUb^@EOr zSb6WvhZ=c-oBf^Vd;}0c009ILKmY**5I_I{1n!1_FC^eAZoS34z|`t@$4-BK;=AMp z?#8vGPXrJ^009ILKmY**5U3u3v1Ee0z&yzdELW5lr@Xwt@`aukczxn;tM)%