Files
mindfulness/client/src/services/pushApi.ts
2026-02-05 16:06:49 +08:00

210 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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; // 05
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 官方推荐读取 projectIdEAS/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 });
}