2 Commits

Author SHA1 Message Date
吕新雨
154f347ddb 注册token排查 2026-02-10 16:57:36 +08:00
吕新雨
dec3ac82e1 fix:后端错误 2026-02-10 16:47:18 +08:00
5 changed files with 62 additions and 15 deletions

View File

@@ -12,7 +12,7 @@ import { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnai
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromScoring } from '@/src/modules/dailyWidgetReco'; import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromScoring } from '@/src/modules/dailyWidgetReco';
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale'; import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
import { fetchRecoFeed } from '@/src/services/recoApi'; import { fetchRecoFeed } from '@/src/services/recoApi';
import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi'; import { ensurePushTokenRegisteredIfPermitted, setPushPreferences } from '@/src/services/pushApi';
import { import {
recordRecoFeedServed, recordRecoFeedServed,
setOnboardingCompleted, setOnboardingCompleted,
@@ -128,7 +128,8 @@ export default function OnboardingScreen() {
await setPushPromptState('unknown'); await setPushPromptState('unknown');
try { try {
const { status } = await Notifications.requestPermissionsAsync(); const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') { // iOS 可能出现 provisional临时授权也应视为“已授权”
if (status !== 'granted' && status !== ('provisional' as any)) {
await setPushPromptState('skipped'); await setPushPromptState('skipped');
return; return;
} }
@@ -140,10 +141,8 @@ export default function OnboardingScreen() {
return; return;
} }
// 1) 获取 Expo Push Token失败才认为“推送开启失败”) // 1) 上报 token 到后端(幂等;失败才认为“推送开启失败”)
const expoPushToken = await getExpoPushTokenOrThrow(); await ensurePushTokenRegisteredIfPermitted();
// 2) 上报 token 到后端(幂等;失败才认为“推送开启失败”)
await registerPushToken({ pushToken: expoPushToken });
// 3) 上报推送偏好(幂等) // 3) 上报推送偏好(幂等)
// 注意:这一步失败时,后端仍可能已成功接收 token。 // 注意:这一步失败时,后端仍可能已成功接收 token。

View File

@@ -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(() => { useEffect(() => {
// 字体与 i18n 都准备好后,允许渲染 App原生 splash 的隐藏交给 onLayout避免“硬切/闪白”) // 字体与 i18n 都准备好后,允许渲染 App原生 splash 的隐藏交给 onLayout避免“硬切/闪白”)
if (loaded && i18nReady) setAppReady(true); if (loaded && i18nReady) setAppReady(true);

View File

@@ -41,7 +41,7 @@ import QuestionIcon from '@/assets/images/home/Profile/widget/question_icon.svg'
import * as Notifications from 'expo-notifications'; import * as Notifications from 'expo-notifications';
import { changeLanguage } from '@/src/i18n'; import { changeLanguage } from '@/src/i18n';
import { fetchLegalLinks } from '@/src/services/legalApi'; 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'); const { width } = Dimensions.get('window');
@@ -430,14 +430,13 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
// 调试:打印状态 // 调试:打印状态
console.log('Push Permission Status:', status); console.log('Push Permission Status:', status);
if (status === 'granted') { if (status === 'granted' || (status as any) === 'provisional') {
setPushEnabled(true); setPushEnabled(true);
setHasSystemPermission(true); setHasSystemPermission(true);
// 获取 token 并上报后端(幂等) // 获取 token 并上报后端(幂等)
try { try {
const expoPushToken = await getExpoPushTokenOrThrow(); await ensurePushTokenRegisteredIfPermitted();
await registerPushToken({ pushToken: expoPushToken });
// 偏好同步失败不应被用户感知为“开启失败” // 偏好同步失败不应被用户感知为“开启失败”
// (常见现象:后端已接收 token但偏好接口短暂失败/超时) // (常见现象:后端已接收 token但偏好接口短暂失败/超时)
try { try {

View File

@@ -160,10 +160,15 @@ export async function registerPushToken(args: { pushToken: string }): Promise<vo
}); });
} }
function isSystemNotificationPermissionGranted(status: unknown): boolean {
// iOS 可能出现 provisional临时授权在 Push 场景也应视为“可获取 token 并上报”
return status === 'granted' || status === 'provisional';
}
export async function ensurePushTokenRegisteredIfPermitted(): Promise<{ ok: boolean; reason: string }> { export async function ensurePushTokenRegisteredIfPermitted(): Promise<{ ok: boolean; reason: string }> {
// 只要系统权限已授权,就应尽早把 token 写入后端(不依赖用户在“每日提醒”里点确认) // 只要系统权限已授权,就应尽早把 token 写入后端(不依赖用户在“每日提醒”里点确认)
const settings = await Notifications.getPermissionsAsync(); 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(); const token = await getExpoPushTokenOrThrow();

View File

@@ -7,7 +7,7 @@ import httpx
import redis 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 func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.limits import rate_limit_push_by_ip from app.api.limits import rate_limit_push_by_ip
@@ -194,9 +194,10 @@ async def put_preferences(req: PushPreferencesRequest, db: AsyncSession = Depend
await db.commit() await db.commit()
# 返回更新时间(从 ORM 读取到的可能不包含 server_onupdate这里用 now 兜底) # 返回更新时间
updated_at = getattr(pref, "updated_at", None) # - 某些运行环境/驱动组合下commit 后访问 ORM 字段可能触发隐式 IO导致 async 下报 MissingGreenlet。
updated_at_iso = updated_at.isoformat() if isinstance(updated_at, datetime) else None # - 这里直接用当前时间兜底(字段本身为可选,仅用于前端展示)。
updated_at_iso = datetime.now(timezone.utc).isoformat()
return PushPreferencesResponse( return PushPreferencesResponse(
client_user_id=req.client_user_id, client_user_id=req.client_user_id,
@@ -296,6 +297,7 @@ async def scheduler_health(db: AsyncSession = Depends(get_db)) -> dict[str, Any]
"worker": {"ok": False, "worker_count": 0}, "worker": {"ok": False, "worker_count": 0},
"beat": {"ok": False, "last_heartbeat_at": None, "age_seconds": None}, "beat": {"ok": False, "last_heartbeat_at": None, "age_seconds": None},
"db": {"ok": False, "push_send_log_latest_created_at": 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(), "now_utc": datetime.now(timezone.utc).isoformat(),
} }
@@ -342,5 +344,36 @@ async def scheduler_health(db: AsyncSession = Depends(get_db)) -> dict[str, Any]
except Exception as e: except Exception as e:
out["db"]["error"] = f"{type(e).__name__}: {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 return out