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 0000000..8247f6c Binary files /dev/null and b/server/celerybeat-schedule-shm differ diff --git a/server/celerybeat-schedule-wal b/server/celerybeat-schedule-wal new file mode 100644 index 0000000..ff6a264 Binary files /dev/null and b/server/celerybeat-schedule-wal differ