fix:小组件- PUSH
This commit is contained in:
39
client/src/services/__tests__/legalApi.test.ts
Normal file
39
client/src/services/__tests__/legalApi.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import i18n from 'i18next';
|
||||
|
||||
import { buildAcceptLanguage } from '../legalApi';
|
||||
|
||||
describe('legalApi.buildAcceptLanguage', () => {
|
||||
function setLang(lang: string) {
|
||||
Object.defineProperty(i18n, 'language', {
|
||||
value: lang,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
it('非中文语言回退为 en', () => {
|
||||
setLang('en');
|
||||
expect(buildAcceptLanguage()).toBe('en');
|
||||
|
||||
setLang('es');
|
||||
expect(buildAcceptLanguage()).toBe('en');
|
||||
});
|
||||
|
||||
it('任意 zh* 归一为 tc', () => {
|
||||
setLang('zh-CN');
|
||||
expect(buildAcceptLanguage()).toBe('tc');
|
||||
|
||||
setLang('zh-TW');
|
||||
expect(buildAcceptLanguage()).toBe('tc');
|
||||
});
|
||||
|
||||
it('显式 tc / hant 等归一为 tc', () => {
|
||||
setLang('tc');
|
||||
expect(buildAcceptLanguage()).toBe('tc');
|
||||
|
||||
setLang('zh-Hant');
|
||||
expect(buildAcceptLanguage()).toBe('tc');
|
||||
});
|
||||
});
|
||||
|
||||
53
client/src/services/appGroupStorage.ts
Normal file
53
client/src/services/appGroupStorage.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NativeModules, Platform } from 'react-native';
|
||||
|
||||
type AppGroupStorageNativeModule = {
|
||||
/**
|
||||
* 写入 App Group 的 UserDefaults(值为字符串,通常是 JSON)
|
||||
*/
|
||||
setString(key: string, value: string): Promise<void>;
|
||||
/**
|
||||
* 读取 App Group 的 UserDefaults(值为字符串,通常是 JSON)
|
||||
*/
|
||||
getString(key: string): Promise<string | null>;
|
||||
/**
|
||||
* 触发 Widget 刷新(iOS 系统仍可能延迟)
|
||||
*/
|
||||
reloadAllTimelines(): Promise<void>;
|
||||
};
|
||||
|
||||
function getNativeModule(): AppGroupStorageNativeModule | null {
|
||||
if (Platform.OS !== 'ios') return null;
|
||||
const raw = (NativeModules as Record<string, unknown>)?.AppGroupStorage as unknown;
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
|
||||
// 注意:若 iOS 原生模块没有正确导出方法(例如缺少 RCT_EXTERN_METHOD 桥接)
|
||||
// JS 侧可能能拿到模块对象,但方法会是 undefined,这里需要做运行时校验避免崩溃。
|
||||
const m = raw as Partial<AppGroupStorageNativeModule>;
|
||||
if (typeof m.setString !== 'function') return null;
|
||||
if (typeof m.getString !== 'function') return null;
|
||||
if (typeof m.reloadAllTimelines !== 'function') return null;
|
||||
return m as AppGroupStorageNativeModule;
|
||||
}
|
||||
|
||||
export function isAppGroupStorageAvailable(): boolean {
|
||||
return Boolean(getNativeModule());
|
||||
}
|
||||
|
||||
export async function appGroupSetString(key: string, value: string): Promise<void> {
|
||||
const m = getNativeModule();
|
||||
if (!m) return;
|
||||
await m.setString(key, value);
|
||||
}
|
||||
|
||||
export async function appGroupGetString(key: string): Promise<string | null> {
|
||||
const m = getNativeModule();
|
||||
if (!m) return null;
|
||||
return await m.getString(key);
|
||||
}
|
||||
|
||||
export async function appGroupReloadAllTimelines(): Promise<void> {
|
||||
const m = getNativeModule();
|
||||
if (!m) return;
|
||||
await m.reloadAllTimelines();
|
||||
}
|
||||
|
||||
37
client/src/services/legalApi.ts
Normal file
37
client/src/services/legalApi.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import i18n from 'i18next';
|
||||
|
||||
import { httpJson } from '../utils/http';
|
||||
|
||||
export type LegalLinks = {
|
||||
privacyPolicyUrl: string;
|
||||
termsOfUseUrl: string;
|
||||
resolvedLang: 'en' | 'tc';
|
||||
};
|
||||
|
||||
export function buildAcceptLanguage(): 'en' | 'tc' {
|
||||
const lang = (i18n.language || '').trim();
|
||||
const lower = lang.toLowerCase();
|
||||
|
||||
// 当前多语言仅支持 EN / TC(与 reco 链路一致);其他语言统一回退到 en
|
||||
if (lower.startsWith('zh')) {
|
||||
return 'tc';
|
||||
}
|
||||
if (lower.includes('tc') || lower.includes('hant') || lower.includes('hk') || lower.includes('mo') || lower.includes('tw')) {
|
||||
return 'tc';
|
||||
}
|
||||
return 'en';
|
||||
}
|
||||
|
||||
export async function fetchLegalLinks(): Promise<LegalLinks> {
|
||||
const headers: Record<string, string> = {
|
||||
'Accept-Language': buildAcceptLanguage(),
|
||||
};
|
||||
return await httpJson<LegalLinks>({
|
||||
path: '/v1/legal/links',
|
||||
method: 'GET',
|
||||
headers,
|
||||
timeoutMs: 8_000,
|
||||
debugLabel: 'LegalLinks',
|
||||
});
|
||||
}
|
||||
|
||||
210
client/src/services/pushApi.ts
Normal file
210
client/src/services/pushApi.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
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';
|
||||
|
||||
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' {
|
||||
const lang = (i18n.language || '').trim();
|
||||
const lower = lang.toLowerCase();
|
||||
if (lower.startsWith('zh')) return 'tc';
|
||||
if (lower.includes('tc') || lower.includes('hant') || lower.includes('hk') || lower.includes('mo') || lower.includes('tw')) return 'tc';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
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 ||
|
||||
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,建议在 app.json 的 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 });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import i18n from 'i18next';
|
||||
|
||||
import { API_BASE_URL } from '@/src/constants/env';
|
||||
import type { UserProfileV1_2 } from '@/src/features/userProfileScoring';
|
||||
import type { UserProfileV1_2 } from '../features/userProfileScoring';
|
||||
import { httpJson } from '../utils/http';
|
||||
|
||||
export type RecommendedItem = {
|
||||
content_id: number;
|
||||
@@ -26,19 +26,10 @@ export type RecoRequest = {
|
||||
now?: string; // ISO8601(可选)
|
||||
};
|
||||
|
||||
function withTimeout(ms: number): AbortController {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms);
|
||||
return controller;
|
||||
}
|
||||
|
||||
export async function fetchRecoFeed(req: RecoRequest): Promise<RecoEngineResult> {
|
||||
const controller = withTimeout(12_000);
|
||||
const url = `${API_BASE_URL}/v1/reco/feed`;
|
||||
const acceptLanguage = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
// 让后端做 locale 选择(目前后端只区分 en/tc)
|
||||
'Accept-Language': acceptLanguage,
|
||||
};
|
||||
@@ -50,25 +41,39 @@ export async function fetchRecoFeed(req: RecoRequest): Promise<RecoEngineResult>
|
||||
now: req.now,
|
||||
};
|
||||
|
||||
// 仅在开发环境打印,避免生产环境日志泄露敏感信息
|
||||
if (__DEV__) {
|
||||
console.log('[Feed API] 请求地址:', url);
|
||||
console.log('[Feed API] Feed的API请求头:', headers);
|
||||
console.log('[Feed API] Feed的API请求体:', bodyObj);
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
return await httpJson<RecoEngineResult>({
|
||||
path: '/v1/reco/feed',
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(bodyObj),
|
||||
signal: controller.signal,
|
||||
body: bodyObj,
|
||||
timeoutMs: 12_000,
|
||||
debugLabel: 'Feed API',
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchRecoWidget(req: RecoRequest): Promise<RecoEngineResult> {
|
||||
const acceptLanguage = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
// 让后端做 locale 选择(目前后端只区分 en/tc)
|
||||
'Accept-Language': acceptLanguage,
|
||||
};
|
||||
|
||||
const bodyObj = {
|
||||
k: req.k ?? 1,
|
||||
user_profile: req.user_profile,
|
||||
already_recommended_ids: req.already_recommended_ids ?? [],
|
||||
touched_or_viewed_ids: req.touched_or_viewed_ids ?? [],
|
||||
now: req.now,
|
||||
};
|
||||
|
||||
return await httpJson<RecoEngineResult>({
|
||||
path: '/v1/reco/widget',
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: bodyObj,
|
||||
timeoutMs: 12_000,
|
||||
debugLabel: 'Widget API',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`推荐接口请求失败:${res.status} ${res.statusText} ${text}`.trim());
|
||||
}
|
||||
|
||||
return (await res.json()) as RecoEngineResult;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user