refactor(user): persist compact user cache

This commit is contained in:
2026-07-07 15:36:17 +08:00
parent 5fc381f8eb
commit e10e65d2f3
5 changed files with 144 additions and 12 deletions
+1
View File
@@ -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";
+22
View File
@@ -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);
});
});
+8 -5
View File
@@ -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>>;
+16 -7
View File
@@ -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>> {