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, getOrCreateClientUserId, getUserProfileScoring } 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', }); } 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 }); }