refactor(user): persist compact user cache
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
export * from "./avatar_data";
|
export * from "./avatar_data";
|
||||||
export * from "./credits_data";
|
export * from "./credits_data";
|
||||||
export * from "./credits_history_data";
|
export * from "./credits_history_data";
|
||||||
|
export * from "./persisted_user";
|
||||||
export * from "./personality_traits";
|
export * from "./personality_traits";
|
||||||
export * from "./recent_memory";
|
export * from "./recent_memory";
|
||||||
export * from "./update_profile_request";
|
export * from "./update_profile_request";
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* 本地持久化用户模型。
|
||||||
|
*
|
||||||
|
* 后端 UserSchema 保持完整,用于接口解析;本 schema 只描述本地缓存真正
|
||||||
|
* 会用到的字段,避免把完整 profile 长期写入 localStorage。
|
||||||
|
*/
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { numberOrZero, stringOrEmpty } from "../nullable-defaults";
|
||||||
|
|
||||||
|
export const PersistedUserSchema = z.object({
|
||||||
|
id: stringOrEmpty,
|
||||||
|
username: stringOrEmpty,
|
||||||
|
countryCode: stringOrEmpty,
|
||||||
|
dailyFreeChatLimit: numberOrZero,
|
||||||
|
dailyFreeChatRemaining: numberOrZero,
|
||||||
|
dailyFreePrivateLimit: numberOrZero,
|
||||||
|
dailyFreePrivateRemaining: numberOrZero,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PersistedUserInput = z.input<typeof PersistedUserSchema>;
|
||||||
|
export type PersistedUserData = z.output<typeof PersistedUserSchema>;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createStorage } from "unstorage";
|
||||||
|
import memoryDriver from "unstorage/drivers/memory";
|
||||||
|
|
||||||
|
import { StorageKeys } from "@/data/storage/storage_keys";
|
||||||
|
import { UserStorage } from "@/data/storage/user/user_storage";
|
||||||
|
import { SpAsyncUtil } from "@/utils";
|
||||||
|
|
||||||
|
describe("UserStorage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
SpAsyncUtil.setStorage(createStorage({ driver: memoryDriver() }));
|
||||||
|
UserStorage._resetForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedPersistedUser = {
|
||||||
|
id: "user-1",
|
||||||
|
username: "Elio",
|
||||||
|
countryCode: "HK",
|
||||||
|
dailyFreeChatLimit: 30,
|
||||||
|
dailyFreeChatRemaining: 26,
|
||||||
|
dailyFreePrivateLimit: 2,
|
||||||
|
dailyFreePrivateRemaining: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("persists only the user fields used by the app", async () => {
|
||||||
|
const fullUser = {
|
||||||
|
id: "user-1",
|
||||||
|
username: "Elio",
|
||||||
|
email: "elio@example.com",
|
||||||
|
platform: "web",
|
||||||
|
country: "Hong Kong",
|
||||||
|
countryCode: "HK",
|
||||||
|
intimacy: 42,
|
||||||
|
dolBalance: 12,
|
||||||
|
creditBalance: 120,
|
||||||
|
dailyFreeChatLimit: 30,
|
||||||
|
dailyFreeChatUsed: 4,
|
||||||
|
dailyFreeChatRemaining: 26,
|
||||||
|
dailyFreePrivateLimit: 2,
|
||||||
|
dailyFreePrivateUsed: 1,
|
||||||
|
dailyFreePrivateRemaining: 1,
|
||||||
|
personalityTraits: {
|
||||||
|
caring: 0.5,
|
||||||
|
playful: 0.5,
|
||||||
|
serious: 0.5,
|
||||||
|
cheerful: 0.5,
|
||||||
|
romantic: 0.5,
|
||||||
|
},
|
||||||
|
preferredLanguage: "zh",
|
||||||
|
createdAt: "2026-06-12T08:08:27.378706+00:00",
|
||||||
|
lastMessageAt: "2026-06-24T11:00:23.156561+00:00",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await UserStorage.getInstance().setUser(fullUser);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
const raw = await SpAsyncUtil.getRawStorage().getItem<unknown>(
|
||||||
|
StorageKeys.user,
|
||||||
|
);
|
||||||
|
const stored = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||||
|
expect(stored).toEqual(expectedPersistedUser);
|
||||||
|
expect(stored).not.toHaveProperty("email");
|
||||||
|
expect(stored).not.toHaveProperty("platform");
|
||||||
|
expect(stored).not.toHaveProperty("country");
|
||||||
|
expect(stored).not.toHaveProperty("personalityTraits");
|
||||||
|
expect(stored).not.toHaveProperty("createdAt");
|
||||||
|
expect(stored).not.toHaveProperty("lastMessageAt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads old full user caches as compact persisted users", async () => {
|
||||||
|
await SpAsyncUtil.getRawStorage().setItem(
|
||||||
|
StorageKeys.user,
|
||||||
|
JSON.stringify({
|
||||||
|
...expectedPersistedUser,
|
||||||
|
email: "elio@example.com",
|
||||||
|
platform: "web",
|
||||||
|
country: "Hong Kong",
|
||||||
|
personalityTraits: {
|
||||||
|
caring: 0.5,
|
||||||
|
playful: 0.5,
|
||||||
|
serious: 0.5,
|
||||||
|
cheerful: 0.5,
|
||||||
|
romantic: 0.5,
|
||||||
|
},
|
||||||
|
preferredLanguage: "zh",
|
||||||
|
createdAt: "2026-06-12T08:08:27.378706+00:00",
|
||||||
|
lastMessageAt: "2026-06-24T11:00:23.156561+00:00",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await UserStorage.getInstance().getUser();
|
||||||
|
if (!result.success) {
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
|
expect(result.data).toEqual(expectedPersistedUser);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,17 +7,20 @@
|
|||||||
* - User 对象 / userId / avatarUrl 三个独立字段的 CRUD
|
* - User 对象 / userId / avatarUrl 三个独立字段的 CRUD
|
||||||
* - `clearUserData()` 批量清空
|
* - `clearUserData()` 批量清空
|
||||||
*
|
*
|
||||||
* 注:完整 User 模型见 `src/data/dto/user/user.ts`,本接口允许存 JSON 字符串,
|
* 注:完整 User 模型见 `src/data/dto/user/user.ts`。本地缓存只持久化 UI
|
||||||
* 具体序列化由实现层通过 Zod schema 校验后转换为 `User` 类实例。
|
* 当前需要的最小字段,避免把完整后端 profile 长期写入 localStorage。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Result } from "@/utils";
|
import type { Result } from "@/utils";
|
||||||
import type { UserEntitlementSnapshotData } from "@/data/schemas/user/user_entitlement_snapshot";
|
import type { UserEntitlementSnapshotData } from "@/data/schemas/user/user_entitlement_snapshot";
|
||||||
import type { UserData } from "@/data/schemas/user/user";
|
import type {
|
||||||
|
PersistedUserData,
|
||||||
|
PersistedUserInput,
|
||||||
|
} from "@/data/schemas/user/persisted_user";
|
||||||
|
|
||||||
export interface IUserStorage {
|
export interface IUserStorage {
|
||||||
getUser(): Promise<Result<UserData | null>>;
|
getUser(): Promise<Result<PersistedUserData | null>>;
|
||||||
setUser(user: UserData): Promise<Result<void>>;
|
setUser(user: PersistedUserInput): Promise<Result<void>>;
|
||||||
clearUser(): Promise<Result<void>>;
|
clearUser(): Promise<Result<void>>;
|
||||||
|
|
||||||
getUserId(): Promise<Result<string | null>>;
|
getUserId(): Promise<Result<string | null>>;
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
* 设计:内部使用 SpAsyncUtil 静态类 + Zod 校验;保持对外 API 完全兼容。
|
* 设计:内部使用 SpAsyncUtil 静态类 + Zod 校验;保持对外 API 完全兼容。
|
||||||
*
|
*
|
||||||
* 边界:
|
* 边界:
|
||||||
* - `getUser` 走 `UserSchema` 解析:缺字段时 Zod `.default()` 自动兜底
|
* - `getUser` 走 `PersistedUserSchema` 解析:兼容旧完整缓存并裁剪多余字段
|
||||||
* - `setUser` 走 `UserSchema` 校验写入:保证存储的总是完整 UserData
|
* - `setUser` 走 `PersistedUserSchema` 校验写入:只落地 UI 用到的最小字段
|
||||||
* - 单例挂在 class 静态字段,HMR 复用安全
|
* - 单例挂在 class 静态字段,HMR 复用安全
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -19,7 +19,11 @@ import {
|
|||||||
UserEntitlementSnapshotSchema,
|
UserEntitlementSnapshotSchema,
|
||||||
type UserEntitlementSnapshotData,
|
type UserEntitlementSnapshotData,
|
||||||
} from "@/data/schemas/user/user_entitlement_snapshot";
|
} from "@/data/schemas/user/user_entitlement_snapshot";
|
||||||
import { UserSchema, type UserData } from "@/data/schemas/user/user";
|
import {
|
||||||
|
PersistedUserSchema,
|
||||||
|
type PersistedUserData,
|
||||||
|
type PersistedUserInput,
|
||||||
|
} from "@/data/schemas/user/persisted_user";
|
||||||
import { type Result as ResultT, SpAsyncUtil } from "@/utils";
|
import { type Result as ResultT, SpAsyncUtil } from "@/utils";
|
||||||
import { StorageKeys } from "../storage_keys";
|
import { StorageKeys } from "../storage_keys";
|
||||||
import type { IUserStorage } from "./iuser_storage";
|
import type { IUserStorage } from "./iuser_storage";
|
||||||
@@ -39,12 +43,17 @@ export class UserStorage implements IUserStorage {
|
|||||||
|
|
||||||
// ---- user object ----
|
// ---- user object ----
|
||||||
|
|
||||||
getUser(): Promise<ResultT<UserData | null>> {
|
getUser(): Promise<ResultT<PersistedUserData | null>> {
|
||||||
return SpAsyncUtil.getJson(StorageKeys.user, UserSchema);
|
return SpAsyncUtil.getJson(StorageKeys.user, PersistedUserSchema);
|
||||||
}
|
}
|
||||||
|
|
||||||
setUser(user: UserData): Promise<ResultT<void>> {
|
setUser(user: PersistedUserInput): Promise<ResultT<void>> {
|
||||||
return SpAsyncUtil.setJson(StorageKeys.user, user, UserSchema);
|
const persistedUser = PersistedUserSchema.parse(user);
|
||||||
|
return SpAsyncUtil.setJson(
|
||||||
|
StorageKeys.user,
|
||||||
|
persistedUser,
|
||||||
|
PersistedUserSchema,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearUser(): Promise<ResultT<void>> {
|
clearUser(): Promise<ResultT<void>> {
|
||||||
|
|||||||
Reference in New Issue
Block a user