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'; const KEY_FAVORITES_ITEMS = 'favorites.items'; const KEY_CONSENT_ACCEPTED = 'consent.accepted'; const KEY_USER_PROFILE = 'user.profile'; const KEY_USER_PROFILE_SCORING = 'user.profileScoring'; const KEY_RECO_FEED_CACHE = 'reco.feedCache'; const KEY_RECO_FEED_HISTORY = 'reco.feedHistory'; const KEY_UI_THEME_MODE = 'ui.theme.mode'; const KEY_UI_THEME_SUIXIN_STATE = 'ui.theme.suixin.state'; const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings'; export type PushPromptState = 'enabled' | 'skipped' | 'unknown'; export type Reaction = 'like' | 'dislike'; export type ReactionsMap = Record; export type ThemeMode = 'scenery' | 'color' | 'suixin'; export type UserProfile = { name?: string; intents?: string[]; }; export type SuixinBaseThemeId = | 'neutral' | 'emotional_support' | 'parenting_pressure' | 'self_worth' | 'anxiety_relief' | 'rest_balance'; export type SuixinThemeStateV1 = { schema_version: 1; saved_at: string; // ISO8601 /** * 冷启动会话标记(进程级)。 * 用于确保:仅在冷启动时重置 base theme/seed。 */ boot_id: string; base_theme_id: SuixinBaseThemeId; seed: string; step_index: number; last_color: string; // "#RRGGBB" }; /** * 用户画像(问卷打分输出) * 说明:用于推荐/Push/Widget 统一复用;结构以 `src/features/userProfileScoring` 输出为准。 */ export type UserProfileScoring = UserProfileV1_2_Extended; export type DailyReminderSettings = { /** * 每天推送次数: * - 0:关闭 * - 1~5:每天推送 1~5 次 */ timesPerDay: number; /** * 仅用于 UI 展示与交互(开关)。 * 后端偏好建议使用:enabled = pushEnabled && timesPerDay > 0 */ pushEnabled: boolean; }; export type RecoFeedCacheItem = { content_id: number; text: string; }; export type RecoFeedCache = { saved_at: string; // ISO8601 /** * 缓存文案的语言(后端目前只区分 en / tc) * - en: English * - tc: 繁体中文 */ lang?: 'en' | 'tc'; items: RecoFeedCacheItem[]; meta?: Record; }; /** * Feed 链路可观测输入(用于下一次请求携带给后端) * * - already_recommended_ids:本设备已下发过的内容(避免重复下发) * - touched_or_viewed_ids:本设备用户已看过/划过的内容(用于频控/去重/降重复) * * 说明:后端不需要“实时知道”,只要在下一次拉取时带上即可。 */ export type RecoFeedHistory = { updated_at: string; // ISO8601 already_recommended_ids: number[]; touched_or_viewed_ids: number[]; }; async function getJson(key: string, fallback: T): Promise { const raw = await AsyncStorage.getItem(key); if (!raw) return fallback; try { return JSON.parse(raw) as T; } catch { return fallback; } } async function setJson(key: string, value: T): Promise { 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 { // 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 { 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 { const raw = await AsyncStorage.getItem(KEY_ONBOARDING_COMPLETED); return raw === 'true'; } export async function setOnboardingCompleted(completed: boolean): Promise { await AsyncStorage.setItem(KEY_ONBOARDING_COMPLETED, completed ? 'true' : 'false'); } export async function getPushPromptState(): Promise { const raw = await AsyncStorage.getItem(KEY_PUSH_PROMPT_STATE); if (raw === 'enabled' || raw === 'skipped' || raw === 'unknown') return raw; return 'unknown'; } export async function setPushPromptState(state: PushPromptState): Promise { await AsyncStorage.setItem(KEY_PUSH_PROMPT_STATE, state); } export async function getReactions(): Promise { return await getJson(KEY_CONTENT_REACTIONS, {}); } export async function setReaction(contentId: string, reaction: Reaction): Promise { const next = await getReactions(); next[contentId] = reaction; await setJson(KEY_CONTENT_REACTIONS, next); } export type FavoriteItem = { favId: string; // 唯一标识,支持重复点赞同一文案 id: string; /** * 收藏时的文案快照(强烈建议写入,避免后续 cache 覆盖导致无法还原文案) */ text?: string; date: string; themeMode: ThemeMode; background: string; // 颜色值或图片路径 }; export async function getFavorites(): Promise { return await getJson(KEY_FAVORITES_ITEMS, []); } export async function addFavorite(item: FavoriteItem): Promise { const list = await getFavorites(); // 允许重复点赞,不再根据 id 去重 const newList = [item, ...list]; console.log('Adding to favorites:', JSON.stringify(item)); await setJson(KEY_FAVORITES_ITEMS, newList); } export async function removeFavorite(favId: string): Promise { const list = await getFavorites(); const next = list.filter(item => item.favId !== favId); await setJson(KEY_FAVORITES_ITEMS, next); } export async function getConsentAccepted(): Promise { const raw = await AsyncStorage.getItem(KEY_CONSENT_ACCEPTED); return raw === 'true'; } export async function setConsentAccepted(accepted: boolean): Promise { await AsyncStorage.setItem(KEY_CONSENT_ACCEPTED, accepted ? 'true' : 'false'); } export async function getThemeMode(): Promise { const raw = await AsyncStorage.getItem(KEY_UI_THEME_MODE); if (raw === 'scenery' || raw === 'color' || raw === 'suixin') return raw; return 'scenery'; } export async function setThemeMode(mode: ThemeMode): Promise { await AsyncStorage.setItem(KEY_UI_THEME_MODE, mode); } export async function getSuixinThemeState(): Promise { const raw = await AsyncStorage.getItem(KEY_UI_THEME_SUIXIN_STATE); if (!raw) return null; try { const parsed = JSON.parse(raw) as Partial; if (parsed.schema_version !== 1) return null; if (typeof parsed.boot_id !== 'string') return null; if (typeof parsed.base_theme_id !== 'string') return null; if (typeof parsed.seed !== 'string') return null; if (typeof parsed.step_index !== 'number') return null; if (typeof parsed.last_color !== 'string') return null; if (typeof parsed.saved_at !== 'string') return null; return parsed as SuixinThemeStateV1; } catch { return null; } } export async function setSuixinThemeState(state: SuixinThemeStateV1): Promise { await setJson(KEY_UI_THEME_SUIXIN_STATE, state); } export async function getUserProfile(): Promise { return await getJson(KEY_USER_PROFILE, {}); } export async function setUserProfile(profile: UserProfile): Promise { const current = await getUserProfile(); await setJson(KEY_USER_PROFILE, { ...current, ...profile }); } export async function getUserProfileScoring(): Promise { const raw = await AsyncStorage.getItem(KEY_USER_PROFILE_SCORING); if (!raw) return null; try { return JSON.parse(raw) as UserProfileScoring; } catch { return null; } } export async function setUserProfileScoring(profile: UserProfileScoring): Promise { await setJson(KEY_USER_PROFILE_SCORING, profile); } export async function getRecoFeedCache(): Promise { const raw = await AsyncStorage.getItem(KEY_RECO_FEED_CACHE); if (!raw) return null; try { return JSON.parse(raw) as RecoFeedCache; } catch { return null; } } export async function setRecoFeedCache(cache: RecoFeedCache): Promise { await setJson(KEY_RECO_FEED_CACHE, cache); } export async function getRecoFeedHistory(): Promise { const raw = await AsyncStorage.getItem(KEY_RECO_FEED_HISTORY); if (!raw) { return { updated_at: new Date().toISOString(), already_recommended_ids: [], touched_or_viewed_ids: [], }; } try { const parsed = JSON.parse(raw) as Partial; return { updated_at: typeof parsed.updated_at === 'string' ? parsed.updated_at : new Date().toISOString(), already_recommended_ids: Array.isArray(parsed.already_recommended_ids) ? parsed.already_recommended_ids.filter((x) => Number.isFinite(x)).map((x) => Number(x)) : [], touched_or_viewed_ids: Array.isArray(parsed.touched_or_viewed_ids) ? parsed.touched_or_viewed_ids.filter((x) => Number.isFinite(x)).map((x) => Number(x)) : [], }; } catch { return { updated_at: new Date().toISOString(), already_recommended_ids: [], touched_or_viewed_ids: [], }; } } export async function setRecoFeedHistory(history: RecoFeedHistory): Promise { await setJson(KEY_RECO_FEED_HISTORY, history); } function uniqKeepLatest(list: number[], max: number): number[] { const seen = new Set(); const out: number[] = []; for (let i = list.length - 1; i >= 0; i -= 1) { const v = list[i]; if (!Number.isFinite(v)) continue; if (seen.has(v)) continue; seen.add(v); out.push(v); if (out.length >= max) break; } return out.reverse(); } export async function recordRecoFeedServed(contentIds: number[]): Promise { if (!contentIds?.length) return; const h = await getRecoFeedHistory(); const next = { ...h, updated_at: new Date().toISOString(), already_recommended_ids: uniqKeepLatest([...h.already_recommended_ids, ...contentIds], 500), }; await setRecoFeedHistory(next); } export async function recordRecoFeedTouched(contentId: number): Promise { if (!Number.isFinite(contentId)) return; const h = await getRecoFeedHistory(); const next = { ...h, updated_at: new Date().toISOString(), touched_or_viewed_ids: uniqKeepLatest([...h.touched_or_viewed_ids, contentId], 500), }; await setRecoFeedHistory(next); } export async function getDailyReminderSettings(): Promise { const s = await getJson(KEY_DAILY_REMINDER_SETTINGS, { timesPerDay: 3, pushEnabled: false, }); const timesPerDay = Number.isFinite(s.timesPerDay) ? s.timesPerDay : 3; return { // 需求:0~5(0 表示关闭) timesPerDay: Math.min(5, Math.max(0, Math.round(timesPerDay))), pushEnabled: Boolean(s.pushEnabled), }; } export async function setDailyReminderSettings(settings: DailyReminderSettings): Promise { // 需求: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), }); }