388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
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<string, Reaction>;
|
||
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<string, unknown>;
|
||
};
|
||
|
||
/**
|
||
* 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<T>(key: string, fallback: T): Promise<T> {
|
||
const raw = await AsyncStorage.getItem(key);
|
||
if (!raw) return fallback;
|
||
try {
|
||
return JSON.parse(raw) as T;
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
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';
|
||
}
|
||
|
||
export async function setOnboardingCompleted(completed: boolean): Promise<void> {
|
||
await AsyncStorage.setItem(KEY_ONBOARDING_COMPLETED, completed ? 'true' : 'false');
|
||
}
|
||
|
||
export async function getPushPromptState(): Promise<PushPromptState> {
|
||
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<void> {
|
||
await AsyncStorage.setItem(KEY_PUSH_PROMPT_STATE, state);
|
||
}
|
||
|
||
export async function getReactions(): Promise<ReactionsMap> {
|
||
return await getJson<ReactionsMap>(KEY_CONTENT_REACTIONS, {});
|
||
}
|
||
|
||
export async function setReaction(contentId: string, reaction: Reaction): Promise<void> {
|
||
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<FavoriteItem[]> {
|
||
return await getJson<FavoriteItem[]>(KEY_FAVORITES_ITEMS, []);
|
||
}
|
||
|
||
export async function addFavorite(item: FavoriteItem): Promise<void> {
|
||
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<void> {
|
||
const list = await getFavorites();
|
||
const next = list.filter(item => item.favId !== favId);
|
||
await setJson(KEY_FAVORITES_ITEMS, next);
|
||
}
|
||
|
||
export async function getConsentAccepted(): Promise<boolean> {
|
||
const raw = await AsyncStorage.getItem(KEY_CONSENT_ACCEPTED);
|
||
return raw === 'true';
|
||
}
|
||
|
||
export async function setConsentAccepted(accepted: boolean): Promise<void> {
|
||
await AsyncStorage.setItem(KEY_CONSENT_ACCEPTED, accepted ? 'true' : 'false');
|
||
}
|
||
|
||
export async function getThemeMode(): Promise<ThemeMode> {
|
||
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<void> {
|
||
await AsyncStorage.setItem(KEY_UI_THEME_MODE, mode);
|
||
}
|
||
|
||
export async function getSuixinThemeState(): Promise<SuixinThemeStateV1 | null> {
|
||
const raw = await AsyncStorage.getItem(KEY_UI_THEME_SUIXIN_STATE);
|
||
if (!raw) return null;
|
||
try {
|
||
const parsed = JSON.parse(raw) as Partial<SuixinThemeStateV1>;
|
||
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<void> {
|
||
await setJson(KEY_UI_THEME_SUIXIN_STATE, state);
|
||
}
|
||
|
||
export async function getUserProfile(): Promise<UserProfile> {
|
||
return await getJson<UserProfile>(KEY_USER_PROFILE, {});
|
||
}
|
||
|
||
export async function setUserProfile(profile: UserProfile): Promise<void> {
|
||
const current = await getUserProfile();
|
||
await setJson(KEY_USER_PROFILE, { ...current, ...profile });
|
||
}
|
||
|
||
export async function getUserProfileScoring(): Promise<UserProfileScoring | null> {
|
||
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<void> {
|
||
await setJson(KEY_USER_PROFILE_SCORING, profile);
|
||
}
|
||
|
||
export async function getRecoFeedCache(): Promise<RecoFeedCache | null> {
|
||
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<void> {
|
||
await setJson(KEY_RECO_FEED_CACHE, cache);
|
||
}
|
||
|
||
export async function getRecoFeedHistory(): Promise<RecoFeedHistory> {
|
||
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<RecoFeedHistory>;
|
||
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<void> {
|
||
await setJson(KEY_RECO_FEED_HISTORY, history);
|
||
}
|
||
|
||
function uniqKeepLatest(list: number[], max: number): number[] {
|
||
const seen = new Set<number>();
|
||
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<void> {
|
||
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<void> {
|
||
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<DailyReminderSettings> {
|
||
const s = await getJson<DailyReminderSettings>(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<void> {
|
||
// 需求: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),
|
||
});
|
||
}
|