Merge pull request 'damer-0210' (#21) from damer-0210 into main

Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
2026-02-10 09:24:00 +00:00
8 changed files with 114 additions and 23 deletions

View File

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

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

View File

@@ -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';
@@ -160,19 +160,37 @@ 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 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' };
}

View File

@@ -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<void> {
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<LastRegisteredPushPayload | null> {
const raw = await AsyncStorage.getItem(KEY_PUSH_LAST_REGISTERED_PAYLOAD);
if (!raw) return null;
try {
const obj = JSON.parse(raw) as Partial<LastRegisteredPushPayload>;
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<LastRegisteredPushPayload, 'savedAt'>): Promise<void> {
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;

View File

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

Binary file not shown.

Binary file not shown.