fix:小组件- PUSH
This commit is contained in:
@@ -45,8 +45,10 @@ export const API_BASE_URL = getApiBaseUrl(APP_ENV);
|
||||
* 调试:打印环境变量注入结果(仅开发环境)
|
||||
*
|
||||
* 用途:排查「为什么 API_BASE_URL 不是预期值」的问题(例如 .env.local/命令行注入/缓存导致)。
|
||||
*
|
||||
* 注意:在某些测试环境(如 vitest)里 `__DEV__` 可能不存在,需做兼容判断。
|
||||
*/
|
||||
if (__DEV__) {
|
||||
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
||||
const injected = {
|
||||
EXPO_PUBLIC_ENV: process.env.EXPO_PUBLIC_ENV,
|
||||
EXPO_PUBLIC_API_BASE_URL: process.env.EXPO_PUBLIC_API_BASE_URL,
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"later": "稍后",
|
||||
"loading": "处理中…",
|
||||
"errorTitle": "提示",
|
||||
"errorDesc": "开启失败也没关系,你仍然可以继续使用应用。"
|
||||
"errorDesc": "开启失败,请稍后重试(模拟器可能无法获取推送 Token,建议用真机测试)。"
|
||||
},
|
||||
"home": {
|
||||
"title": "正念",
|
||||
|
||||
173
client/src/modules/dailyWidgetReco/index.ts
Normal file
173
client/src/modules/dailyWidgetReco/index.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { API_BASE_URL } from '@/src/constants/env';
|
||||
import type { UserProfileV1_2, UserProfileV1_2_Extended } from '@/src/features/userProfileScoring/types';
|
||||
import { fetchRecoWidget } from '@/src/services/recoApi';
|
||||
import i18n from 'i18next';
|
||||
import { getUserProfileScoring } from '@/src/storage/appStorage';
|
||||
import { getLocalDayKey } from '@/src/utils/date';
|
||||
|
||||
import {
|
||||
appGroupGetString,
|
||||
appGroupReloadAllTimelines,
|
||||
appGroupSetString,
|
||||
isAppGroupStorageAvailable,
|
||||
} from '@/src/services/appGroupStorage';
|
||||
|
||||
/**
|
||||
* App Group 共享 key(App ↔ Widget 共通)
|
||||
*/
|
||||
export const WIDGET_CONFIG_KEY = 'widget.config.v1';
|
||||
export const WIDGET_USER_PROFILE_KEY = 'widget.userProfile.v1_2';
|
||||
export const WIDGET_DAILY_RECO_KEY = 'widget.dailyReco.v1';
|
||||
|
||||
export type WidgetConfigV1 = {
|
||||
schema_version: 1;
|
||||
saved_at: string; // ISO8601
|
||||
apiBaseUrl: string;
|
||||
};
|
||||
|
||||
export type WidgetUserProfileV1_2 = {
|
||||
schema_version: 1;
|
||||
saved_at: string; // ISO8601
|
||||
user_profile: UserProfileV1_2;
|
||||
};
|
||||
|
||||
export type WidgetDailyRecoV1 = {
|
||||
schema_version: 1;
|
||||
saved_at: string; // ISO8601
|
||||
day_key: string; // YYYY-MM-DD(用户时区)
|
||||
lang: 'en' | 'tc';
|
||||
item: null | {
|
||||
content_id: number;
|
||||
text: string;
|
||||
final_score?: number;
|
||||
fallback_level_final?: number;
|
||||
};
|
||||
meta?: Record<string, unknown>;
|
||||
source?: 'app' | 'widget';
|
||||
};
|
||||
|
||||
function safeJsonParse<T>(raw: string | null): T | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pickUserProfileV1_2(scoringProfile: UserProfileV1_2_Extended): UserProfileV1_2 {
|
||||
return {
|
||||
profile_version: scoringProfile.profile_version,
|
||||
profile_source: scoringProfile.profile_source,
|
||||
profile_generated_at: scoringProfile.profile_generated_at,
|
||||
profile_confidence: scoringProfile.profile_confidence,
|
||||
profile_answered: scoringProfile.profile_answered,
|
||||
stage: scoringProfile.stage,
|
||||
emotion_score: scoringProfile.emotion_score,
|
||||
context: scoringProfile.context,
|
||||
need: scoringProfile.need,
|
||||
};
|
||||
}
|
||||
|
||||
export async function syncWidgetConfig(): Promise<void> {
|
||||
if (!isAppGroupStorageAvailable()) return;
|
||||
const payload: WidgetConfigV1 = {
|
||||
schema_version: 1,
|
||||
saved_at: new Date().toISOString(),
|
||||
apiBaseUrl: API_BASE_URL,
|
||||
};
|
||||
await appGroupSetString(WIDGET_CONFIG_KEY, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export async function syncWidgetUserProfileFromScoring(scoringProfile: UserProfileV1_2_Extended): Promise<void> {
|
||||
if (!isAppGroupStorageAvailable()) return;
|
||||
const payload: WidgetUserProfileV1_2 = {
|
||||
schema_version: 1,
|
||||
saved_at: new Date().toISOString(),
|
||||
user_profile: pickUserProfileV1_2(scoringProfile),
|
||||
};
|
||||
await appGroupSetString(WIDGET_USER_PROFILE_KEY, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export async function syncWidgetUserProfileFromStorage(): Promise<void> {
|
||||
const scoringProfile = await getUserProfileScoring();
|
||||
if (!scoringProfile) return;
|
||||
await syncWidgetUserProfileFromScoring(scoringProfile);
|
||||
}
|
||||
|
||||
export async function getWidgetDailyRecoCache(): Promise<WidgetDailyRecoV1 | null> {
|
||||
if (!isAppGroupStorageAvailable()) return null;
|
||||
const raw = await appGroupGetString(WIDGET_DAILY_RECO_KEY);
|
||||
return safeJsonParse<WidgetDailyRecoV1>(raw);
|
||||
}
|
||||
|
||||
export async function setWidgetDailyRecoCache(cache: WidgetDailyRecoV1): Promise<void> {
|
||||
if (!isAppGroupStorageAvailable()) return;
|
||||
await appGroupSetString(WIDGET_DAILY_RECO_KEY, JSON.stringify(cache));
|
||||
}
|
||||
|
||||
/**
|
||||
* App 前台辅助刷新(不保证准点,但能提升更新及时性与一致性)
|
||||
*
|
||||
* 规则:
|
||||
* - 若共享缓存 `day_key` 已是今天 → 不请求
|
||||
* - 若缺少用户画像 → 不请求(交给 Widget 走兜底/下次重试)
|
||||
* - 成功后写入共享缓存并触发 Widget reload(系统仍可能延迟)
|
||||
*/
|
||||
export async function ensureDailyWidgetRecoUpToDate(args?: {
|
||||
reason?: string;
|
||||
scoringProfile?: UserProfileV1_2_Extended | null;
|
||||
}): Promise<void> {
|
||||
if (!isAppGroupStorageAvailable()) return;
|
||||
|
||||
// 先确保 Widget 能拿到 baseURL(dev/pro 切换时很关键)
|
||||
await syncWidgetConfig();
|
||||
|
||||
const today = getLocalDayKey(new Date());
|
||||
const cached = await getWidgetDailyRecoCache();
|
||||
if (cached?.schema_version === 1 && cached.day_key === today && cached.item?.text) return;
|
||||
|
||||
const scoringProfile = args?.scoringProfile ?? (await getUserProfileScoring());
|
||||
if (!scoringProfile) return;
|
||||
|
||||
// 同步画像给 Widget(保证 Widget 独立拉取也有输入)
|
||||
await syncWidgetUserProfileFromScoring(scoringProfile);
|
||||
|
||||
try {
|
||||
const { items, meta } = await fetchRecoWidget({
|
||||
k: 1,
|
||||
user_profile: pickUserProfileV1_2(scoringProfile),
|
||||
already_recommended_ids: [],
|
||||
touched_or_viewed_ids: [],
|
||||
});
|
||||
|
||||
const top = items?.[0];
|
||||
if (!top?.text) return;
|
||||
|
||||
const lang = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
||||
await setWidgetDailyRecoCache({
|
||||
schema_version: 1,
|
||||
saved_at: new Date().toISOString(),
|
||||
day_key: today,
|
||||
// 语言策略:与后端 Accept-Language 保持一致(目前只区分 en/tc)
|
||||
lang,
|
||||
item: {
|
||||
content_id: top.content_id,
|
||||
text: top.text,
|
||||
final_score: top.final_score,
|
||||
fallback_level_final: top.fallback_level_final,
|
||||
},
|
||||
meta: meta as Record<string, unknown>,
|
||||
source: 'app',
|
||||
});
|
||||
|
||||
// 触发 Widget 刷新(系统仍可能延迟)
|
||||
await appGroupReloadAllTimelines();
|
||||
} catch (e) {
|
||||
// 失败不阻塞主流程;Widget 将使用缓存或兜底文案
|
||||
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
||||
console.log('[DailyWidgetReco] App 前台刷新失败:', args?.reason ?? 'unknown', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import * as Crypto from 'expo-crypto';
|
||||
import type { UserProfileV1_2_Extended } from '@/src/features/userProfileScoring';
|
||||
|
||||
/**
|
||||
* 本地存储 key 统一管理,避免 UI 里散落硬编码
|
||||
*/
|
||||
const KEY_CLIENT_USER_ID = 'client.userId';
|
||||
const KEY_ONBOARDING_COMPLETED = 'onboarding.completed';
|
||||
const KEY_PUSH_PROMPT_STATE = 'push.promptState';
|
||||
const KEY_CONTENT_REACTIONS = 'content.reactions';
|
||||
@@ -31,7 +33,16 @@ export type UserProfile = {
|
||||
*/
|
||||
export type UserProfileScoring = UserProfileV1_2_Extended;
|
||||
export type DailyReminderSettings = {
|
||||
/**
|
||||
* 每天推送次数:
|
||||
* - 0:关闭
|
||||
* - 1~5:每天推送 1~5 次
|
||||
*/
|
||||
timesPerDay: number;
|
||||
/**
|
||||
* 仅用于 UI 展示与交互(开关)。
|
||||
* 后端偏好建议使用:enabled = pushEnabled && timesPerDay > 0
|
||||
*/
|
||||
pushEnabled: boolean;
|
||||
};
|
||||
|
||||
@@ -81,6 +92,49 @@ async function setJson<T>(key: string, value: T): Promise<void> {
|
||||
await AsyncStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
function looksLikeUuid(v: string): boolean {
|
||||
// 宽松校验:8-4-4-4-12(不强依赖大小写)
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
|
||||
}
|
||||
|
||||
function uuidV4FromBytes(bytes: Uint8Array): string {
|
||||
// RFC 4122 v4:设置 version 与 variant
|
||||
const b = new Uint8Array(bytes);
|
||||
b[6] = (b[6] & 0x0f) | 0x40;
|
||||
b[8] = (b[8] & 0x3f) | 0x80;
|
||||
|
||||
const hex = Array.from(b)
|
||||
.map((x) => x.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
async function generateUuidV4(): Promise<string> {
|
||||
// 1) 优先使用运行时提供的 randomUUID(如可用)
|
||||
const maybeCrypto = (globalThis as unknown as { crypto?: { randomUUID?: () => string } }).crypto;
|
||||
if (maybeCrypto?.randomUUID) return maybeCrypto.randomUUID();
|
||||
|
||||
// 2) 使用 expo-crypto 生成安全随机数(推荐)
|
||||
const bytes = await Crypto.getRandomBytesAsync(16);
|
||||
return uuidV4FromBytes(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或生成客户端用户标识(UUID)。
|
||||
*
|
||||
* 说明:
|
||||
* - `client_user_id` 用于与后端关联 Push Token 与用户推送偏好
|
||||
* - 它是“安装实例标识”,不等同真实用户账号
|
||||
*/
|
||||
export async function getOrCreateClientUserId(): Promise<string> {
|
||||
const raw = await AsyncStorage.getItem(KEY_CLIENT_USER_ID);
|
||||
if (raw && looksLikeUuid(raw)) return raw;
|
||||
|
||||
const next = await generateUuidV4();
|
||||
await AsyncStorage.setItem(KEY_CLIENT_USER_ID, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function getOnboardingCompleted(): Promise<boolean> {
|
||||
const raw = await AsyncStorage.getItem(KEY_ONBOARDING_COMPLETED);
|
||||
return raw === 'true';
|
||||
@@ -272,13 +326,15 @@ export async function getDailyReminderSettings(): Promise<DailyReminderSettings>
|
||||
});
|
||||
const timesPerDay = Number.isFinite(s.timesPerDay) ? s.timesPerDay : 3;
|
||||
return {
|
||||
timesPerDay: Math.min(10, Math.max(1, Math.round(timesPerDay))),
|
||||
// 需求:0~5(0 表示关闭)
|
||||
timesPerDay: Math.min(5, Math.max(0, Math.round(timesPerDay))),
|
||||
pushEnabled: Boolean(s.pushEnabled),
|
||||
};
|
||||
}
|
||||
|
||||
export async function setDailyReminderSettings(settings: DailyReminderSettings): Promise<void> {
|
||||
const timesPerDay = Math.min(10, Math.max(1, Math.round(settings.timesPerDay)));
|
||||
// 需求:0~5(0 表示关闭)
|
||||
const timesPerDay = Math.min(5, Math.max(0, Math.round(settings.timesPerDay)));
|
||||
await setJson(KEY_DAILY_REMINDER_SETTINGS, {
|
||||
timesPerDay,
|
||||
pushEnabled: Boolean(settings.pushEnabled),
|
||||
|
||||
14
client/src/utils/date.ts
Normal file
14
client/src/utils/date.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 生成本地日维度 key(用户时区):YYYY-MM-DD
|
||||
*
|
||||
* 说明:
|
||||
* - 使用 JS Date 的本地时间字段(getFullYear/getMonth/getDate)
|
||||
* - 不依赖额外库,避免时区坑扩大化
|
||||
*/
|
||||
export function getLocalDayKey(date: Date = new Date()): string {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
146
client/src/utils/http.ts
Normal file
146
client/src/utils/http.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { API_BASE_URL } from '../constants/env';
|
||||
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
|
||||
export class HttpError extends Error {
|
||||
readonly name = 'HttpError';
|
||||
readonly url: string;
|
||||
readonly status: number;
|
||||
readonly statusText: string;
|
||||
readonly responseText?: string;
|
||||
|
||||
constructor(args: { message: string; url: string; status: number; statusText: string; responseText?: string }) {
|
||||
super(args.message);
|
||||
this.url = args.url;
|
||||
this.status = args.status;
|
||||
this.statusText = args.statusText;
|
||||
this.responseText = args.responseText;
|
||||
}
|
||||
}
|
||||
|
||||
export type HttpJsonOptions = {
|
||||
/**
|
||||
* 支持传入完整 URL 或以 / 开头的 path(会自动拼到 API_BASE_URL)
|
||||
*/
|
||||
path: string;
|
||||
method?: HttpMethod;
|
||||
headers?: Record<string, string>;
|
||||
/**
|
||||
* 将对象自动 JSON.stringify;GET 请求请不要传 body
|
||||
*/
|
||||
body?: unknown;
|
||||
/**
|
||||
* 超时(毫秒),默认 10 秒
|
||||
*/
|
||||
timeoutMs?: number;
|
||||
/**
|
||||
* 外部 signal(例如上层取消请求)
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* 仅开发环境日志:便于联调排查
|
||||
*/
|
||||
debugLabel?: string;
|
||||
};
|
||||
|
||||
function isAbsoluteUrl(path: string): boolean {
|
||||
return /^https?:\/\//i.test(path);
|
||||
}
|
||||
|
||||
function joinUrl(baseUrl: string, path: string): string {
|
||||
const base = (baseUrl || '').replace(/\/+$/, '');
|
||||
const p = (path || '').trim();
|
||||
if (!p) return base;
|
||||
if (p.startsWith('/')) return `${base}${p}`;
|
||||
return `${base}/${p}`;
|
||||
}
|
||||
|
||||
function createTimeoutAbortSignal(timeoutMs: number, external?: AbortSignal): AbortController {
|
||||
const controller = new AbortController();
|
||||
|
||||
const t = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
// 避免 Node/Vitest 下悬挂定时器影响退出
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(t as any)?.unref?.();
|
||||
|
||||
if (external) {
|
||||
if (external.aborted) {
|
||||
controller.abort();
|
||||
} else {
|
||||
external.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
controller.abort();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
function isDev(): boolean {
|
||||
return typeof __DEV__ !== 'undefined' && __DEV__;
|
||||
}
|
||||
|
||||
export async function httpJson<T>(opts: HttpJsonOptions): Promise<T> {
|
||||
const method = opts.method ?? 'GET';
|
||||
const timeoutMs = opts.timeoutMs ?? 10_000;
|
||||
const url = isAbsoluteUrl(opts.path) ? opts.path : joinUrl(API_BASE_URL, opts.path);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...(opts.headers ?? {}),
|
||||
};
|
||||
|
||||
const hasBody = typeof opts.body !== 'undefined' && opts.body !== null;
|
||||
const body = hasBody ? JSON.stringify(opts.body) : undefined;
|
||||
|
||||
// 仅在有 body 时默认补齐 Content-Type,避免 GET 请求无意义携带
|
||||
if (hasBody && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
if (isDev() && opts.debugLabel) {
|
||||
console.log(`[${opts.debugLabel}] 请求地址:`, url);
|
||||
console.log(`[${opts.debugLabel}] 请求方法:`, method);
|
||||
console.log(`[${opts.debugLabel}] 请求头:`, headers);
|
||||
if (hasBody) console.log(`[${opts.debugLabel}] 请求体:`, opts.body);
|
||||
}
|
||||
|
||||
const controller = createTimeoutAbortSignal(timeoutMs, opts.signal);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (e) {
|
||||
// RN 下 AbortError 文案不完全一致,这里统一对外语义
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new Error(`网络请求失败:${msg}`);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new HttpError({
|
||||
message: `HTTP 请求失败:${res.status} ${res.statusText} ${text}`.trim(),
|
||||
url,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
responseText: text,
|
||||
});
|
||||
}
|
||||
|
||||
// 204/205 无内容时不要强行 parse
|
||||
if (res.status === 204 || res.status === 205) {
|
||||
return undefined as unknown as T;
|
||||
}
|
||||
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user