Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
154f347ddb | ||
|
|
dec3ac82e1 |
@@ -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。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }> {
|
||||
// 只要系统权限已授权,就应尽早把 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();
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
@@ -296,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(),
|
||||
}
|
||||
|
||||
@@ -342,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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user