damer-0210 #21
@@ -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 {
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
BIN
server/celerybeat-schedule-shm
Normal file
BIN
server/celerybeat-schedule-shm
Normal file
Binary file not shown.
BIN
server/celerybeat-schedule-wal
Normal file
BIN
server/celerybeat-schedule-wal
Normal file
Binary file not shown.
Reference in New Issue
Block a user