diff --git a/src/data/dto/user/__tests__/user.test.ts b/src/data/dto/user/__tests__/user.test.ts index 3e20624d..28079915 100644 --- a/src/data/dto/user/__tests__/user.test.ts +++ b/src/data/dto/user/__tests__/user.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { UserEntitlements } from "@/data/dto/user"; import { User } from "@/data/dto/user/user"; describe("User", () => { @@ -42,4 +43,51 @@ describe("User", () => { lastMessageAt: "2026-06-24T11:00:23.156561+00:00", }); }); + + it("parses the latest backend entitlements payload", () => { + const entitlements = UserEntitlements.fromJson({ + userId: "user-1", + isGuest: false, + isVip: true, + vipExpiresAt: "2026-07-26T00:00:00+00:00", + creditBalance: 120, + dolBalance: 120, + policy: { + membershipState: "vip", + nonVipEntitlementsShared: true, + guestSameAsLoggedInNonVip: true, + refreshAfterPayment: "GET /api/user/entitlements", + }, + costs: { + normal_message: 2, + private_message: 10, + voice_message: 20, + photo: 40, + voice_call_minute: 50, + private_album_10: 300, + private_album_20: 600, + }, + quotas: { + normalChatFreeDaily: 30, + privateUnlockFreeDaily: 1, + voiceMessageFreeDaily: 0, + photoFreeDaily: 0, + }, + historyUnlock: { + enabled: true, + order: "oldest_first", + chargeMode: "highest_cost_per_locked_message", + insufficientBalanceBehavior: "no_deduction", + costs: { + private_message: 10, + voice_message: 20, + photo: 40, + }, + }, + }); + + expect(entitlements.isVip).toBe(true); + expect(entitlements.creditBalance).toBe(120); + expect(entitlements.historyUnlock.costs.photo).toBe(40); + }); }); diff --git a/src/data/dto/user/index.ts b/src/data/dto/user/index.ts index 3e0d014a..c48ea1ab 100644 --- a/src/data/dto/user/index.ts +++ b/src/data/dto/user/index.ts @@ -9,5 +9,6 @@ export * from "./personality_traits"; export * from "./recent_memory"; export * from "./update_profile_request"; export * from "./user"; +export * from "./user_entitlements"; export * from "./user_stats_response"; export * from "./user_view"; diff --git a/src/data/dto/user/user_entitlements.ts b/src/data/dto/user/user_entitlements.ts new file mode 100644 index 00000000..e17e3878 --- /dev/null +++ b/src/data/dto/user/user_entitlements.ts @@ -0,0 +1,39 @@ +/** + * 用户权益快照 DTO + */ +import { + UserEntitlementsSchema, + type UserEntitlementsData, + type UserEntitlementsInput, +} from "@/data/schemas/user/user_entitlements"; + +export class UserEntitlements { + declare readonly userId: string; + declare readonly isGuest: boolean; + declare readonly isVip: boolean; + declare readonly vipExpiresAt: string | null; + declare readonly creditBalance: number; + declare readonly dolBalance: number; + declare readonly policy: UserEntitlementsData["policy"]; + declare readonly costs: UserEntitlementsData["costs"]; + declare readonly quotas: UserEntitlementsData["quotas"]; + declare readonly historyUnlock: UserEntitlementsData["historyUnlock"]; + + private constructor(input: UserEntitlementsInput) { + const data = UserEntitlementsSchema.parse(input); + Object.assign(this, data); + Object.freeze(this); + } + + static from(input: UserEntitlementsInput): UserEntitlements { + return new UserEntitlements(input); + } + + static fromJson(json: unknown): UserEntitlements { + return UserEntitlements.from(json as UserEntitlementsInput); + } + + toJson(): UserEntitlementsData { + return UserEntitlementsSchema.parse(this); + } +} diff --git a/src/data/dto/user/user_view.ts b/src/data/dto/user/user_view.ts index c59d6554..8ae83448 100644 --- a/src/data/dto/user/user_view.ts +++ b/src/data/dto/user/user_view.ts @@ -12,6 +12,8 @@ export const UserViewSchema = z.object({ avatarUrl: z.string(), intimacy: z.number(), dolBalance: z.number(), + creditBalance: z.number(), + vipExpiresAt: z.string().nullable(), relationshipStage: z.string(), currentMood: z.string(), isGuest: z.boolean(), diff --git a/src/data/repositories/interfaces/iuser_repository.ts b/src/data/repositories/interfaces/iuser_repository.ts index ba2d96b6..c093d97a 100644 --- a/src/data/repositories/interfaces/iuser_repository.ts +++ b/src/data/repositories/interfaces/iuser_repository.ts @@ -15,6 +15,7 @@ import type { CreditsHistoryData, UpdateProfileRequest, User, + UserEntitlements, UserStatsResponse, } from "@/data/dto/user"; @@ -36,4 +37,7 @@ export interface IUserRepository { limit?: number, offset?: number, ): Promise>; + + /** 获取当前用户权益快照。 */ + getEntitlements(): Promise>; } diff --git a/src/data/repositories/user_repository.ts b/src/data/repositories/user_repository.ts index ed039e6f..43ff20c3 100644 --- a/src/data/repositories/user_repository.ts +++ b/src/data/repositories/user_repository.ts @@ -14,6 +14,7 @@ import { CreditsHistoryData, UpdateProfileRequest, User, + UserEntitlements, UserStatsResponse, } from "@/data/dto/user"; import { Result } from "@/utils"; @@ -49,6 +50,11 @@ export class UserRepository implements IUserRepository { ): Promise> { return Result.wrap(() => this.api.getCreditsHistory(limit, offset)); } + + /** 获取当前用户权益快照。 */ + async getEntitlements(): Promise> { + return Result.wrap(() => this.api.getEntitlements()); + } } /** 全局单例。 */ diff --git a/src/data/schemas/user/index.ts b/src/data/schemas/user/index.ts index 287d1254..923e6f62 100644 --- a/src/data/schemas/user/index.ts +++ b/src/data/schemas/user/index.ts @@ -9,4 +9,6 @@ export * from "./personality_traits"; export * from "./recent_memory"; export * from "./update_profile_request"; export * from "./user"; +export * from "./user_entitlement_snapshot"; +export * from "./user_entitlements"; export * from "./user_stats_response"; diff --git a/src/data/schemas/user/user_entitlement_snapshot.ts b/src/data/schemas/user/user_entitlement_snapshot.ts new file mode 100644 index 00000000..4512b4f5 --- /dev/null +++ b/src/data/schemas/user/user_entitlement_snapshot.ts @@ -0,0 +1,21 @@ +/** + * 本地用户权益快照 + * + * 只持久化 UI 初始化必须的两个字段,完整权益数据仍由 + * `GET /api/user/entitlements` 实时获取。 + */ +import { z } from "zod"; + +import { booleanOrFalse, numberOrZero } from "../nullable-defaults"; + +export const UserEntitlementSnapshotSchema = z.object({ + isVip: booleanOrFalse, + creditBalance: numberOrZero, +}); + +export type UserEntitlementSnapshotInput = z.input< + typeof UserEntitlementSnapshotSchema +>; +export type UserEntitlementSnapshotData = z.output< + typeof UserEntitlementSnapshotSchema +>; diff --git a/src/data/schemas/user/user_entitlements.ts b/src/data/schemas/user/user_entitlements.ts new file mode 100644 index 00000000..3524221d --- /dev/null +++ b/src/data/schemas/user/user_entitlements.ts @@ -0,0 +1,105 @@ +/** + * 用户权益快照 + */ +import { z } from "zod"; + +import { + booleanOrFalse, + numberOrZero, + stringOrEmpty, +} from "../nullable-defaults"; + +export const UserEntitlementsPolicySchema = z + .object({ + membershipState: stringOrEmpty, + nonVipEntitlementsShared: booleanOrFalse, + guestSameAsLoggedInNonVip: booleanOrFalse, + refreshAfterPayment: stringOrEmpty, + }) + .passthrough(); + +export const UserEntitlementsCostsSchema = z + .object({ + normal_message: numberOrZero, + private_message: numberOrZero, + voice_message: numberOrZero, + photo: numberOrZero, + voice_call_minute: numberOrZero, + private_album_10: numberOrZero, + private_album_20: numberOrZero, + }) + .passthrough(); + +export const UserEntitlementsQuotasSchema = z + .object({ + normalChatFreeDaily: numberOrZero, + privateUnlockFreeDaily: numberOrZero, + voiceMessageFreeDaily: numberOrZero, + photoFreeDaily: numberOrZero, + }) + .passthrough(); + +export const UserEntitlementsHistoryUnlockSchema = z + .object({ + enabled: booleanOrFalse, + order: stringOrEmpty, + chargeMode: stringOrEmpty, + insufficientBalanceBehavior: stringOrEmpty, + costs: z + .object({ + private_message: numberOrZero, + voice_message: numberOrZero, + photo: numberOrZero, + }) + .passthrough() + .default(() => ({ + private_message: 0, + voice_message: 0, + photo: 0, + })), + }) + .passthrough(); + +export const UserEntitlementsSchema = z.object({ + userId: stringOrEmpty, + isGuest: booleanOrFalse, + isVip: booleanOrFalse, + vipExpiresAt: z.string().nullable().default(null), + creditBalance: numberOrZero, + dolBalance: numberOrZero, + policy: UserEntitlementsPolicySchema.default(() => ({ + membershipState: "", + nonVipEntitlementsShared: false, + guestSameAsLoggedInNonVip: false, + refreshAfterPayment: "", + })), + costs: UserEntitlementsCostsSchema.default(() => ({ + normal_message: 0, + private_message: 0, + voice_message: 0, + photo: 0, + voice_call_minute: 0, + private_album_10: 0, + private_album_20: 0, + })), + quotas: UserEntitlementsQuotasSchema.default(() => ({ + normalChatFreeDaily: 0, + privateUnlockFreeDaily: 0, + voiceMessageFreeDaily: 0, + photoFreeDaily: 0, + })), + historyUnlock: UserEntitlementsHistoryUnlockSchema.default(() => ({ + enabled: false, + order: "", + chargeMode: "", + insufficientBalanceBehavior: "", + costs: { + private_message: 0, + voice_message: 0, + photo: 0, + }, + })), +}); + +export type UserEntitlementsInput = z.input; +export type UserEntitlementsData = z.output; diff --git a/src/data/services/api/api_path.ts b/src/data/services/api/api_path.ts index 132e5e38..6f2c91c8 100644 --- a/src/data/services/api/api_path.ts +++ b/src/data/services/api/api_path.ts @@ -69,6 +69,9 @@ export class ApiPath { /** 查询积分操作历史 */ static readonly userCreditsHistory = `${ApiPath._user}/credits/history`; + /** 获取当前用户权益快照 */ + static readonly userEntitlements = `${ApiPath._user}/entitlements`; + // ============ 支付相关 ============ /** 创建充值订单 */ static readonly paymentCreateOrder = `${ApiPath._payment}/create-order`; diff --git a/src/data/services/api/user_api.ts b/src/data/services/api/user_api.ts index 7f730a77..8f82ad75 100644 --- a/src/data/services/api/user_api.ts +++ b/src/data/services/api/user_api.ts @@ -12,6 +12,7 @@ import { CreditsHistoryData, UpdateProfileRequest, User, + UserEntitlements, UserStatsResponse, } from "@/data/dto/user"; import type { UserData } from "@/data/schemas/user/user"; @@ -69,6 +70,14 @@ export class UserApi { unwrap(env) as Record ); } + + /** + * 获取当前用户权益快照 + */ + async getEntitlements(): Promise { + const env = await httpClient>(ApiPath.userEntitlements); + return UserEntitlements.fromJson(unwrap(env) as Record); + } } /** diff --git a/src/data/storage/storage_keys.ts b/src/data/storage/storage_keys.ts index 372b23de..da9c5402 100644 --- a/src/data/storage/storage_keys.ts +++ b/src/data/storage/storage_keys.ts @@ -18,6 +18,7 @@ export const StorageKeys = { user: "user", userId: "user_id", userAvatar: "user_avatar", + userEntitlementSnapshot: "user_entitlement_snapshot", // chat chatHistory: "chat_history", diff --git a/src/data/storage/user/iuser_storage.ts b/src/data/storage/user/iuser_storage.ts index 8b72f508..e91f57d9 100644 --- a/src/data/storage/user/iuser_storage.ts +++ b/src/data/storage/user/iuser_storage.ts @@ -12,6 +12,7 @@ */ import type { Result } from "@/utils"; +import type { UserEntitlementSnapshotData } from "@/data/schemas/user/user_entitlement_snapshot"; import type { UserData } from "@/data/schemas/user/user"; export interface IUserStorage { @@ -27,5 +28,11 @@ export interface IUserStorage { setAvatarUrl(url: string): Promise>; clearAvatarUrl(): Promise>; + getEntitlementSnapshot(): Promise>; + setEntitlementSnapshot( + data: UserEntitlementSnapshotData, + ): Promise>; + clearEntitlementSnapshot(): Promise>; + clearUserData(): Promise>; } diff --git a/src/data/storage/user/user_storage.ts b/src/data/storage/user/user_storage.ts index fb079eda..40469477 100644 --- a/src/data/storage/user/user_storage.ts +++ b/src/data/storage/user/user_storage.ts @@ -15,6 +15,10 @@ * - 单例挂在 class 静态字段,HMR 复用安全 */ +import { + UserEntitlementSnapshotSchema, + type UserEntitlementSnapshotData, +} from "@/data/schemas/user/user_entitlement_snapshot"; import { UserSchema, type UserData } from "@/data/schemas/user/user"; import { type Result as ResultT, SpAsyncUtil } from "@/utils"; import { StorageKeys } from "../storage_keys"; @@ -75,6 +79,31 @@ export class UserStorage implements IUserStorage { return SpAsyncUtil.remove(StorageKeys.userAvatar); } + // ---- entitlement snapshot ---- + + getEntitlementSnapshot(): Promise< + ResultT + > { + return SpAsyncUtil.getJson( + StorageKeys.userEntitlementSnapshot, + UserEntitlementSnapshotSchema, + ); + } + + setEntitlementSnapshot( + data: UserEntitlementSnapshotData, + ): Promise> { + return SpAsyncUtil.setJson( + StorageKeys.userEntitlementSnapshot, + data, + UserEntitlementSnapshotSchema, + ); + } + + clearEntitlementSnapshot(): Promise> { + return SpAsyncUtil.remove(StorageKeys.userEntitlementSnapshot); + } + // ---- bulk clear ---- async clearUserData(): Promise> { @@ -82,6 +111,8 @@ export class UserStorage implements IUserStorage { if (!r1.success) return r1; const r2 = await this.clearUserId(); if (!r2.success) return r2; - return this.clearAvatarUrl(); + const r3 = await this.clearAvatarUrl(); + if (!r3.success) return r3; + return this.clearEntitlementSnapshot(); } } diff --git a/src/stores/user/__tests__/user-machine.helpers.test.ts b/src/stores/user/__tests__/user-machine.helpers.test.ts index c63b9dcf..8efc94ca 100644 --- a/src/stores/user/__tests__/user-machine.helpers.test.ts +++ b/src/stores/user/__tests__/user-machine.helpers.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { toView } from "@/stores/user/user-machine.helpers"; +import { + applyEntitlementSnapshotToView, + toView, +} from "@/stores/user/user-machine.helpers"; describe("toView", () => { it("fills optional user fields with stable defaults", () => { @@ -11,6 +14,8 @@ describe("toView", () => { avatarUrl: "", intimacy: 0, dolBalance: 0, + creditBalance: 0, + vipExpiresAt: null, relationshipStage: "", currentMood: "", isGuest: false, @@ -28,6 +33,8 @@ describe("toView", () => { avatarUrl: "https://example.com/avatar.png", intimacy: 42, dolBalance: 12, + creditBalance: 120, + vipExpiresAt: "2026-07-26T00:00:00+00:00", relationshipStage: "close_friend", currentMood: "playful", isGuest: true, @@ -41,6 +48,8 @@ describe("toView", () => { avatarUrl: "https://example.com/avatar.png", intimacy: 42, dolBalance: 12, + creditBalance: 120, + vipExpiresAt: "2026-07-26T00:00:00+00:00", relationshipStage: "close_friend", currentMood: "playful", isGuest: true, @@ -48,4 +57,19 @@ describe("toView", () => { voiceMinutesRemaining: 30, }); }); + + it("merges entitlement snapshot into user view fields", () => { + const user = toView({ id: "user-3", username: "Nora", isVip: false }); + + expect( + applyEntitlementSnapshotToView(user, { + isVip: true, + creditBalance: 120, + }), + ).toMatchObject({ + isVip: true, + creditBalance: 120, + dolBalance: 120, + }); + }); }); diff --git a/src/stores/user/__tests__/user-machine.test.ts b/src/stores/user/__tests__/user-machine.test.ts index f2f6ed65..79af0332 100644 --- a/src/stores/user/__tests__/user-machine.test.ts +++ b/src/stores/user/__tests__/user-machine.test.ts @@ -3,7 +3,10 @@ import { createActor, fromPromise, waitFor } from "xstate"; import type { UserView } from "@/data/dto/user"; import { userMachine } from "@/stores/user/user-machine"; -import type { InitData } from "@/stores/user/user-machine.helpers"; +import type { + InitData, + UserFetchData, +} from "@/stores/user/user-machine.helpers"; function makeUser(overrides: Partial = {}): UserView { return { @@ -13,6 +16,8 @@ function makeUser(overrides: Partial = {}): UserView { avatarUrl: "https://example.com/avatar.png", intimacy: 42, dolBalance: 7, + creditBalance: 7, + vipExpiresAt: null, relationshipStage: "close_friend", currentMood: "happy", isGuest: false, @@ -22,10 +27,21 @@ function makeUser(overrides: Partial = {}): UserView { }; } +function makeEntitlementSnapshot( + overrides: Partial = {}, +): NonNullable { + return { + isVip: false, + creditBalance: 7, + ...overrides, + }; +} + function createTestUserMachine( overrides: Partial<{ initData: InitData; fetchedUser: UserView | null; + fetchedData: UserFetchData; logoutSpy: () => void; }> = {}, ) { @@ -34,16 +50,26 @@ function createTestUserMachine( return userMachine.provide({ actors: { userInit: fromPromise(async () => ({ - user: makeUser(), - avatarUrl: "https://example.com/avatar.png", - ...overrides.initData, + user: overrides.initData?.user ?? makeUser(), + avatarUrl: + overrides.initData?.avatarUrl ?? "https://example.com/avatar.png", + snapshot: overrides.initData?.snapshot ?? null, })), - userFetch: fromPromise( - async () => - "fetchedUser" in overrides - ? overrides.fetchedUser ?? null - : makeUser({ username: "Fetched" }), - ), + userFetch: fromPromise(async () => { + if ("fetchedData" in overrides && overrides.fetchedData) { + return overrides.fetchedData; + } + if ("fetchedUser" in overrides) { + return { + user: overrides.fetchedUser ?? null, + snapshot: null, + }; + } + return { + user: makeUser({ username: "Fetched" }), + snapshot: null, + }; + }), userLogout: fromPromise(async () => { logoutSpy(); }), @@ -58,7 +84,9 @@ describe("userMachine", () => { initData: { user: makeUser({ username: "Local User" }), avatarUrl: "https://example.com/local-avatar.png", + snapshot: null, }, + fetchedUser: null, }), ).start(); @@ -78,10 +106,8 @@ describe("userMachine", () => { actor.send({ type: "UserUpdate", user: makeUser({ username: "Before" }) }); actor.send({ type: "UserUpdateUsername", username: "After" }); - actor.send({ type: "UserUpdatePronouns", pronouns: "They" }); expect(actor.getSnapshot().context.currentUser?.username).toBe("After"); - expect(actor.getSnapshot().context.pronouns).toBe("They"); actor.stop(); }); @@ -89,7 +115,13 @@ describe("userMachine", () => { it("fetches the latest user and clears loading state when done", async () => { const actor = createActor( createTestUserMachine({ - fetchedUser: makeUser({ username: "Fresh User", isVip: true }), + fetchedData: { + user: makeUser({ username: "Fresh User" }), + snapshot: makeEntitlementSnapshot({ + isVip: true, + creditBalance: 120, + }), + }, }), ).start(); @@ -101,6 +133,9 @@ describe("userMachine", () => { const context = actor.getSnapshot().context; expect(context.currentUser?.username).toBe("Fresh User"); expect(context.currentUser?.isVip).toBe(true); + expect(context.currentUser?.creditBalance).toBe(120); + expect(context.isVip).toBe(true); + expect(context.creditBalance).toBe(120); expect(context.isLoading).toBe(false); actor.stop(); diff --git a/src/stores/user/user-context.tsx b/src/stores/user/user-context.tsx index 399b1d71..c0aaf565 100644 --- a/src/stores/user/user-context.tsx +++ b/src/stores/user/user-context.tsx @@ -25,10 +25,10 @@ import type { UserState as MachineContext, UserEvent } from "./user-machine"; */ interface UserState { currentUser: MachineContext["currentUser"]; - pronouns: string; - coinBalance: number; isLoading: boolean; avatarUrl: string | null; + isVip: boolean; + creditBalance: number; } const UserStateCtx = createContext(null); @@ -45,14 +45,14 @@ export function UserProvider({ children }: UserProviderProps) { const userState = useMemo( () => ({ currentUser: state.context.currentUser, - pronouns: state.context.pronouns, - coinBalance: state.context.coinBalance, isLoading: state.matches("initializing") || state.matches("fetching") || state.matches("loggingOut") || state.context.isLoading, avatarUrl: state.context.avatarUrl, + isVip: state.context.isVip, + creditBalance: state.context.creditBalance, }), [state], ); diff --git a/src/stores/user/user-events.ts b/src/stores/user/user-events.ts index fd73086e..86f420eb 100644 --- a/src/stores/user/user-events.ts +++ b/src/stores/user/user-events.ts @@ -8,7 +8,6 @@ export type UserEvent = | { type: "UserFetch" } | { type: "UserUpdate"; user: UserView } | { type: "UserUpdateUsername"; username: string } - | { type: "UserUpdatePronouns"; pronouns: string } | { type: "UserClearLocal" } | { type: "UserLogout" } | { type: "UserDeleteChatHistory" } diff --git a/src/stores/user/user-machine.actors.ts b/src/stores/user/user-machine.actors.ts index 15c9d714..5da06677 100644 --- a/src/stores/user/user-machine.actors.ts +++ b/src/stores/user/user-machine.actors.ts @@ -14,7 +14,6 @@ import { fromPromise } from "xstate"; -import type { UserView } from "@/data/dto/user"; import { userRepository } from "@/data/repositories/user_repository"; import { authRepository } from "@/data/repositories/auth_repository"; import type { @@ -23,7 +22,15 @@ import type { } from "@/data/repositories/interfaces"; import { Result } from "@/utils"; -import { type InitData, readInitData, toView, userStorage } from "./user-machine.helpers"; +import { + applyEntitlementSnapshotToView, + type InitData, + readInitData, + toEntitlementSnapshot, + toView, + type UserFetchData, + userStorage, +} from "./user-machine.helpers"; // ============================================================ // 仓库注入 @@ -46,17 +53,38 @@ export const userInitActor = fromPromise(async () => readInitData()); // Fetch actor:调 API 拉当前用户 + 持久化 // ============================================================ /** - * 调 `userRepo.getCurrentUser` 拉当前 user: - * - 成功 → 转 UserView + 写本地(userStorage.setUser)→ 返回 View - * - 失败 → 返回 null(machine 走 onDone,context 保持不变;onError 兜底) + * 调 `userRepo.getCurrentUser + getEntitlements` 拉当前 user 与权益: + * - user 成功 → 转 UserView + 写本地(userStorage.setUser) + * - entitlements 成功 → 提取 isVip/creditBalance 写本地轻量快照 + * - 任一失败都不阻断另一方,machine 层负责保留已有 context */ -export const userFetchActor = fromPromise(async () => { - const result = await userRepo.getCurrentUser(); - if (Result.isErr(result)) return null; - const view = toView(result.data.toJson()); +export const userFetchActor = fromPromise(async () => { + const [userResult, entitlementsResult] = await Promise.all([ + userRepo.getCurrentUser(), + userRepo.getEntitlements(), + ]); + + const snapshot = Result.isOk(entitlementsResult) + ? toEntitlementSnapshot({ + isVip: entitlementsResult.data.isVip, + creditBalance: entitlementsResult.data.creditBalance, + }) + : null; + if (snapshot) { + await userStorage.setEntitlementSnapshot(snapshot); + } + + if (Result.isErr(userResult)) { + return { user: null, snapshot }; + } + + const view = applyEntitlementSnapshotToView( + toView(userResult.data.toJson()), + snapshot, + ); // 持久化到本地 - await userStorage.setUser(result.data.toJson()); - return view; + await userStorage.setUser(userResult.data.toJson()); + return { user: view, snapshot }; }); // ============================================================ diff --git a/src/stores/user/user-machine.helpers.ts b/src/stores/user/user-machine.helpers.ts index aaeed581..7e9b7fb6 100644 --- a/src/stores/user/user-machine.helpers.ts +++ b/src/stores/user/user-machine.helpers.ts @@ -14,6 +14,7 @@ */ import type { UserView } from "@/data/dto/user"; +import type { UserEntitlementSnapshotData } from "@/data/schemas/user"; import { UserStorage } from "@/data/storage/user/user_storage"; import { Result } from "@/utils"; @@ -36,6 +37,12 @@ export const userStorage = UserStorage.getInstance(); export interface InitData { user: UserView | null; avatarUrl: string | null; + snapshot: UserEntitlementSnapshotData | null; +} + +export interface UserFetchData { + user: UserView | null; + snapshot: UserEntitlementSnapshotData | null; } // ============================================================ @@ -53,19 +60,25 @@ export function toView(u: { avatarUrl?: string; intimacy?: number; dolBalance?: number; + creditBalance?: number; + vipExpiresAt?: string | null; relationshipStage?: string; currentMood?: string; isGuest?: boolean; isVip?: boolean; voiceMinutesRemaining?: number; }): UserView { + const creditBalance = u.creditBalance ?? u.dolBalance ?? 0; + return { id: u.id, username: u.username, email: u.email ?? "", avatarUrl: u.avatarUrl ?? "", intimacy: u.intimacy ?? 0, - dolBalance: u.dolBalance ?? 0, + dolBalance: u.dolBalance ?? creditBalance, + creditBalance, + vipExpiresAt: u.vipExpiresAt ?? null, relationshipStage: u.relationshipStage ?? "", currentMood: u.currentMood ?? "", isGuest: u.isGuest ?? false, @@ -74,6 +87,29 @@ export function toView(u: { }; } +export function applyEntitlementSnapshotToView( + user: UserView | null, + snapshot: UserEntitlementSnapshotData | null, +): UserView | null { + if (!user || !snapshot) return user; + return { + ...user, + isVip: snapshot.isVip, + dolBalance: snapshot.creditBalance, + creditBalance: snapshot.creditBalance, + }; +} + +export function toEntitlementSnapshot(input: { + isVip: boolean; + creditBalance: number; +}): UserEntitlementSnapshotData { + return { + isVip: input.isVip, + creditBalance: input.creditBalance, + }; +} + // ============================================================ // Init 数据加载(userInitActor 用) // ============================================================ @@ -84,14 +120,20 @@ export function toView(u: { * - Result.isOk(_) && data 双重判空:Result 包装层 + 业务 null */ export async function readInitData(): Promise { - const [userResult, avatarResult] = await Promise.all([ + const [userResult, avatarResult, snapshotResult] = await Promise.all([ userStorage.getUser(), userStorage.getAvatarUrl(), + userStorage.getEntitlementSnapshot(), ]); + let snapshot: UserEntitlementSnapshotData | null = null; + if (Result.isOk(snapshotResult) && snapshotResult.data) { + snapshot = snapshotResult.data; + } + let user: UserView | null = null; if (Result.isOk(userResult) && userResult.data) { - user = toView(userResult.data); + user = applyEntitlementSnapshotToView(toView(userResult.data), snapshot); } let avatarUrl: string | null = null; @@ -99,5 +141,5 @@ export async function readInitData(): Promise { avatarUrl = avatarResult.data; } - return { user, avatarUrl }; + return { user, avatarUrl, snapshot }; } diff --git a/src/stores/user/user-machine.ts b/src/stores/user/user-machine.ts index 1c0d47ff..588f096c 100644 --- a/src/stores/user/user-machine.ts +++ b/src/stores/user/user-machine.ts @@ -10,6 +10,7 @@ import { userFetchActor, userLogoutActor, } from "./user-machine.actors"; +import { applyEntitlementSnapshotToView } from "./user-machine.helpers"; // 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变 export type { UserState } from "./user-state"; @@ -32,7 +33,11 @@ export const userMachine = setup({ actions: { updateUser: assign(({ event }) => { if (event.type !== "UserUpdate") return {}; - return { currentUser: event.user }; + return { + currentUser: event.user, + isVip: event.user.isVip, + creditBalance: event.user.creditBalance, + }; }), updateUsername: assign(({ context, event }) => { @@ -42,14 +47,11 @@ export const userMachine = setup({ }; }), - updatePronouns: assign(({ event }) => { - if (event.type !== "UserUpdatePronouns") return {}; - return { pronouns: event.pronouns }; - }), - clearUser: assign(() => ({ currentUser: null, avatarUrl: null, + isVip: false, + creditBalance: 0, })), }, }).createMachine({ @@ -67,9 +69,6 @@ export const userMachine = setup({ UserUpdateUsername: { actions: "updateUsername", }, - UserUpdatePronouns: { - actions: "updatePronouns", - }, UserClearLocal: { actions: "clearUser", }, @@ -83,14 +82,18 @@ export const userMachine = setup({ invoke: { src: "userInit", onDone: { - target: "idle", + target: "fetching", actions: assign(({ event }) => ({ currentUser: event.output.user, avatarUrl: event.output.avatarUrl, + isVip: event.output.snapshot?.isVip ?? initialState.isVip, + creditBalance: + event.output.snapshot?.creditBalance ?? + initialState.creditBalance, })), }, onError: { - target: "idle", + target: "fetching", }, }, }, @@ -100,10 +103,25 @@ export const userMachine = setup({ src: "userFetch", onDone: { target: "idle", - actions: assign(({ context, event }) => ({ - currentUser: event.output ?? context.currentUser, - isLoading: false, - })), + actions: assign(({ context, event }) => { + const snapshot = + event.output.snapshot ?? + (context.currentUser + ? { + isVip: context.isVip, + creditBalance: context.creditBalance, + } + : null); + const baseUser = event.output.user ?? context.currentUser; + return { + currentUser: + applyEntitlementSnapshotToView(baseUser, snapshot) ?? + context.currentUser, + isVip: snapshot?.isVip ?? context.isVip, + creditBalance: snapshot?.creditBalance ?? context.creditBalance, + isLoading: false, + }; + }), }, onError: { target: "idle", diff --git a/src/stores/user/user-state.ts b/src/stores/user/user-state.ts index 5f192938..f43cbca5 100644 --- a/src/stores/user/user-state.ts +++ b/src/stores/user/user-state.ts @@ -5,16 +5,16 @@ import type { UserView } from "@/data/dto/user"; export interface UserState { currentUser: UserView | null; - pronouns: string; - coinBalance: number; isLoading: boolean; avatarUrl: string | null; + isVip: boolean; + creditBalance: number; } export const initialState: UserState = { currentUser: null, - pronouns: "He", - coinBalance: 45, isLoading: false, avatarUrl: null, + isVip: false, + creditBalance: 0, };