refactor(user): persist compact user cache
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
export * from "./avatar_data";
|
||||
export * from "./credits_data";
|
||||
export * from "./credits_history_data";
|
||||
export * from "./persisted_user";
|
||||
export * from "./personality_traits";
|
||||
export * from "./recent_memory";
|
||||
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
|
||||
* - `clearUserData()` 批量清空
|
||||
*
|
||||
* 注:完整 User 模型见 `src/data/dto/user/user.ts`,本接口允许存 JSON 字符串,
|
||||
* 具体序列化由实现层通过 Zod schema 校验后转换为 `User` 类实例。
|
||||
* 注:完整 User 模型见 `src/data/dto/user/user.ts`。本地缓存只持久化 UI
|
||||
* 当前需要的最小字段,避免把完整后端 profile 长期写入 localStorage。
|
||||
*/
|
||||
|
||||
import type { Result } from "@/utils";
|
||||
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 {
|
||||
getUser(): Promise<Result<UserData | null>>;
|
||||
setUser(user: UserData): Promise<Result<void>>;
|
||||
getUser(): Promise<Result<PersistedUserData | null>>;
|
||||
setUser(user: PersistedUserInput): Promise<Result<void>>;
|
||||
clearUser(): Promise<Result<void>>;
|
||||
|
||||
getUserId(): Promise<Result<string | null>>;
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
* 设计:内部使用 SpAsyncUtil 静态类 + Zod 校验;保持对外 API 完全兼容。
|
||||
*
|
||||
* 边界:
|
||||
* - `getUser` 走 `UserSchema` 解析:缺字段时 Zod `.default()` 自动兜底
|
||||
* - `setUser` 走 `UserSchema` 校验写入:保证存储的总是完整 UserData
|
||||
* - `getUser` 走 `PersistedUserSchema` 解析:兼容旧完整缓存并裁剪多余字段
|
||||
* - `setUser` 走 `PersistedUserSchema` 校验写入:只落地 UI 用到的最小字段
|
||||
* - 单例挂在 class 静态字段,HMR 复用安全
|
||||
*/
|
||||
|
||||
@@ -19,7 +19,11 @@ import {
|
||||
UserEntitlementSnapshotSchema,
|
||||
type UserEntitlementSnapshotData,
|
||||
} 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 { StorageKeys } from "../storage_keys";
|
||||
import type { IUserStorage } from "./iuser_storage";
|
||||
@@ -39,12 +43,17 @@ export class UserStorage implements IUserStorage {
|
||||
|
||||
// ---- user object ----
|
||||
|
||||
getUser(): Promise<ResultT<UserData | null>> {
|
||||
return SpAsyncUtil.getJson(StorageKeys.user, UserSchema);
|
||||
getUser(): Promise<ResultT<PersistedUserData | null>> {
|
||||
return SpAsyncUtil.getJson(StorageKeys.user, PersistedUserSchema);
|
||||
}
|
||||
|
||||
setUser(user: UserData): Promise<ResultT<void>> {
|
||||
return SpAsyncUtil.setJson(StorageKeys.user, user, UserSchema);
|
||||
setUser(user: PersistedUserInput): Promise<ResultT<void>> {
|
||||
const persistedUser = PersistedUserSchema.parse(user);
|
||||
return SpAsyncUtil.setJson(
|
||||
StorageKeys.user,
|
||||
persistedUser,
|
||||
PersistedUserSchema,
|
||||
);
|
||||
}
|
||||
|
||||
clearUser(): Promise<ResultT<void>> {
|
||||
|
||||
Reference in New Issue
Block a user