import i18n from 'i18next'; import Constants from 'expo-constants'; import * as Notifications from 'expo-notifications'; import { Platform } from 'react-native'; import { httpJson } from '../utils/http'; import { APP_ENV } from '../constants/env'; import { getDailyReminderSettings, getLastRegisteredPushPayload, getOrCreateClientUserId, getUserProfileScoring, setLastRegisteredPushPayload, } from '../storage/appStorage'; import type { UserProfileScoring } from '../storage/appStorage'; import { toBackendLocaleFromLanguageTag } from '../i18n/locale'; export type PushEnv = 'dev' | 'prod'; export type PushPlatform = 'ios' | 'android'; export type PushDeviceMeta = { model?: string; os_version?: string; app_version?: string; locale?: string; timezone?: string; }; export type PushRegisterRequest = { client_user_id: string; platform: PushPlatform; push_token: string; app_id: string; env: PushEnv; device_meta?: PushDeviceMeta; }; export type PushPreferencesRequest = { client_user_id: string; enabled: boolean; times_per_day: number; // 0~5 timezone?: string; locale?: string; // 可选:用户画像(用于后端 Push 场景推荐文案生成) user_profile?: UserProfileScoring; }; export type PushPreferencesResponse = PushPreferencesRequest & { updated_at?: string; }; export function buildAcceptLanguage(): 'en' | 'tc' { return toBackendLocaleFromLanguageTag(i18n.language); } function toPushEnv(appEnv: typeof APP_ENV): PushEnv { // 客户端 APP_ENV: local/dev/prod → 后端推送 env: dev/prod if (appEnv === 'prod') return 'prod'; return 'dev'; } function pickAppId(): string { // 优先按平台取 bundleId/package;拿不到则回退到 slug if (Platform.OS === 'ios') { return ( Constants.expoConfig?.ios?.bundleIdentifier || Constants.easConfig?.projectId || Constants.expoConfig?.slug || 'unknown' ); } if (Platform.OS === 'android') { return ( Constants.expoConfig?.android?.package || Constants.easConfig?.projectId || Constants.expoConfig?.slug || 'unknown' ); } return Constants.expoConfig?.slug || 'unknown'; } function pickTimezone(): string | undefined { try { const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; return tz && String(tz).trim() ? String(tz).trim() : undefined; } catch { return undefined; } } function pickLocale(): string | undefined { const lang = (i18n.language || '').trim(); return lang ? lang : undefined; } function pickPlatform(): PushPlatform { return Platform.OS === 'ios' ? 'ios' : 'android'; } function getExpoProjectId(): string | undefined { // Expo 官方推荐读取 projectId(EAS/Dev Client 下通常需要) return ( Constants.easConfig?.projectId || // 兼容 app.json / app.config.ts 的 extra.eas.projectId // eslint-disable-next-line @typescript-eslint/no-explicit-any (Constants.expoConfig as any)?.extra?.eas?.projectId || // 兜底:某些运行时环境仍可直接读到 EXPO_PUBLIC_ 注入 process.env.EXPO_PUBLIC_EAS_PROJECT_ID || undefined ); } export async function getExpoPushTokenOrThrow(): Promise { const projectId = getExpoProjectId(); try { const res = projectId ? await Notifications.getExpoPushTokenAsync({ projectId }) : await Notifications.getExpoPushTokenAsync(); return res.data; } catch (e) { const msg = e instanceof Error ? e.message : String(e); const hint = projectId ? '' : '(可能缺少 EAS projectId:请在 .env.local 配置 EXPO_PUBLIC_EAS_PROJECT_ID,或在 app.json/app.config.ts 的 extra.eas.projectId 写入后重试)'; throw new Error(`获取 Expo Push Token 失败:${msg}${hint}`); } } export async function registerPushToken(args: { pushToken: string }): Promise { const clientUserId = await getOrCreateClientUserId(); const headers: Record = { 'Accept-Language': buildAcceptLanguage(), }; const deviceMeta: PushDeviceMeta = { os_version: String(Platform.Version ?? ''), app_version: Constants.expoConfig?.version, locale: pickLocale(), timezone: pickTimezone(), }; const body: PushRegisterRequest = { client_user_id: clientUserId, platform: pickPlatform(), push_token: args.pushToken, app_id: pickAppId(), env: toPushEnv(APP_ENV), device_meta: deviceMeta, }; await httpJson({ path: '/v1/push/register', method: 'POST', headers, body, timeoutMs: 10_000, debugLabel: 'PushRegister', }); } 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 (!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 + 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 setLastRegisteredPushPayload({ pushToken: token, clientUserId, env, appId }); return { ok: true, reason: 'registered' }; } export async function setPushPreferences(args: { enabled: boolean; timesPerDay: number }): Promise { const clientUserId = await getOrCreateClientUserId(); const tz = pickTimezone(); const locale = pickLocale(); const scoringProfile = await getUserProfileScoring().catch(() => null); const headers: Record = { 'Accept-Language': buildAcceptLanguage(), }; const body: PushPreferencesRequest = { client_user_id: clientUserId, enabled: Boolean(args.enabled) && args.timesPerDay > 0, times_per_day: Math.min(5, Math.max(0, Math.round(args.timesPerDay))), timezone: tz, locale, user_profile: scoringProfile ?? undefined, }; return await httpJson({ path: '/v1/push/preferences', method: 'PUT', headers, body, timeoutMs: 10_000, debugLabel: 'PushPreferences', }); } export async function getPushPreferences(): Promise { const clientUserId = await getOrCreateClientUserId(); const headers: Record = { 'Accept-Language': buildAcceptLanguage(), }; const qs = `client_user_id=${encodeURIComponent(clientUserId)}`; return await httpJson({ path: `/v1/push/preferences?${qs}`, method: 'GET', headers, timeoutMs: 8_000, debugLabel: 'PushPreferencesGet', }); } // 预留:未来推送文案个性化需要上报用户画像时使用(本期先不强制依赖) export async function setPushProfileHint(_profile: UserProfileScoring | null): Promise { // 本期不实现:后端通过现有推荐模块自行拉取/计算 push 文案 } export async function syncPushPreferencesFromLocal(): Promise { const s = await getDailyReminderSettings(); await setPushPreferences({ enabled: s.pushEnabled, timesPerDay: s.timesPerDay }); }