210 lines
6.3 KiB
TypeScript
210 lines
6.3 KiB
TypeScript
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<string> {
|
||
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<void> {
|
||
const clientUserId = await getOrCreateClientUserId();
|
||
const headers: Record<string, string> = {
|
||
'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<void>({
|
||
path: '/v1/push/register',
|
||
method: 'POST',
|
||
headers,
|
||
body,
|
||
timeoutMs: 10_000,
|
||
debugLabel: 'PushRegister',
|
||
});
|
||
}
|
||
|
||
export async function setPushPreferences(args: { enabled: boolean; timesPerDay: number }): Promise<PushPreferencesResponse> {
|
||
const clientUserId = await getOrCreateClientUserId();
|
||
const tz = pickTimezone();
|
||
const locale = pickLocale();
|
||
const scoringProfile = await getUserProfileScoring().catch(() => null);
|
||
const headers: Record<string, string> = {
|
||
'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<PushPreferencesResponse>({
|
||
path: '/v1/push/preferences',
|
||
method: 'PUT',
|
||
headers,
|
||
body,
|
||
timeoutMs: 10_000,
|
||
debugLabel: 'PushPreferences',
|
||
});
|
||
}
|
||
|
||
export async function getPushPreferences(): Promise<PushPreferencesResponse> {
|
||
const clientUserId = await getOrCreateClientUserId();
|
||
const headers: Record<string, string> = {
|
||
'Accept-Language': buildAcceptLanguage(),
|
||
};
|
||
const qs = `client_user_id=${encodeURIComponent(clientUserId)}`;
|
||
return await httpJson<PushPreferencesResponse>({
|
||
path: `/v1/push/preferences?${qs}`,
|
||
method: 'GET',
|
||
headers,
|
||
timeoutMs: 8_000,
|
||
debugLabel: 'PushPreferencesGet',
|
||
});
|
||
}
|
||
|
||
// 预留:未来推送文案个性化需要上报用户画像时使用(本期先不强制依赖)
|
||
export async function setPushProfileHint(_profile: UserProfileScoring | null): Promise<void> {
|
||
// 本期不实现:后端通过现有推荐模块自行拉取/计算 push 文案
|
||
}
|
||
|
||
export async function syncPushPreferencesFromLocal(): Promise<void> {
|
||
const s = await getDailyReminderSettings();
|
||
await setPushPreferences({ enabled: s.pushEnabled, timesPerDay: s.timesPerDay });
|
||
}
|
||
|