diff --git a/src/data/schemas/user/index.ts b/src/data/schemas/user/index.ts index 923e6f62..efeca61f 100644 --- a/src/data/schemas/user/index.ts +++ b/src/data/schemas/user/index.ts @@ -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"; diff --git a/src/data/schemas/user/persisted_user.ts b/src/data/schemas/user/persisted_user.ts new file mode 100644 index 00000000..08922956 --- /dev/null +++ b/src/data/schemas/user/persisted_user.ts @@ -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; +export type PersistedUserData = z.output; diff --git a/src/data/storage/user/__tests__/user_storage.test.ts b/src/data/storage/user/__tests__/user_storage.test.ts new file mode 100644 index 00000000..e6f3549e --- /dev/null +++ b/src/data/storage/user/__tests__/user_storage.test.ts @@ -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( + 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); + }); +}); diff --git a/src/data/storage/user/iuser_storage.ts b/src/data/storage/user/iuser_storage.ts index e91f57d9..08cd1864 100644 --- a/src/data/storage/user/iuser_storage.ts +++ b/src/data/storage/user/iuser_storage.ts @@ -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>; - setUser(user: UserData): Promise>; + getUser(): Promise>; + setUser(user: PersistedUserInput): Promise>; clearUser(): Promise>; getUserId(): Promise>; diff --git a/src/data/storage/user/user_storage.ts b/src/data/storage/user/user_storage.ts index 40469477..adf0b5f9 100644 --- a/src/data/storage/user/user_storage.ts +++ b/src/data/storage/user/user_storage.ts @@ -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> { - return SpAsyncUtil.getJson(StorageKeys.user, UserSchema); + getUser(): Promise> { + return SpAsyncUtil.getJson(StorageKeys.user, PersistedUserSchema); } - setUser(user: UserData): Promise> { - return SpAsyncUtil.setJson(StorageKeys.user, user, UserSchema); + setUser(user: PersistedUserInput): Promise> { + const persistedUser = PersistedUserSchema.parse(user); + return SpAsyncUtil.setJson( + StorageKeys.user, + persistedUser, + PersistedUserSchema, + ); } clearUser(): Promise> {