feat(user): sync entitlement snapshot

This commit is contained in:
2026-06-29 10:37:37 +08:00
parent 455204e1e4
commit 58cd2a7545
22 changed files with 479 additions and 54 deletions
+48
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { UserEntitlements } from "@/data/dto/user";
import { User } from "@/data/dto/user/user"; import { User } from "@/data/dto/user/user";
describe("User", () => { describe("User", () => {
@@ -42,4 +43,51 @@ describe("User", () => {
lastMessageAt: "2026-06-24T11:00:23.156561+00:00", 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);
});
}); });
+1
View File
@@ -9,5 +9,6 @@ export * from "./personality_traits";
export * from "./recent_memory"; export * from "./recent_memory";
export * from "./update_profile_request"; export * from "./update_profile_request";
export * from "./user"; export * from "./user";
export * from "./user_entitlements";
export * from "./user_stats_response"; export * from "./user_stats_response";
export * from "./user_view"; export * from "./user_view";
+39
View File
@@ -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);
}
}
+2
View File
@@ -12,6 +12,8 @@ export const UserViewSchema = z.object({
avatarUrl: z.string(), avatarUrl: z.string(),
intimacy: z.number(), intimacy: z.number(),
dolBalance: z.number(), dolBalance: z.number(),
creditBalance: z.number(),
vipExpiresAt: z.string().nullable(),
relationshipStage: z.string(), relationshipStage: z.string(),
currentMood: z.string(), currentMood: z.string(),
isGuest: z.boolean(), isGuest: z.boolean(),
@@ -15,6 +15,7 @@ import type {
CreditsHistoryData, CreditsHistoryData,
UpdateProfileRequest, UpdateProfileRequest,
User, User,
UserEntitlements,
UserStatsResponse, UserStatsResponse,
} from "@/data/dto/user"; } from "@/data/dto/user";
@@ -36,4 +37,7 @@ export interface IUserRepository {
limit?: number, limit?: number,
offset?: number, offset?: number,
): Promise<Result<CreditsHistoryData>>; ): Promise<Result<CreditsHistoryData>>;
/** 获取当前用户权益快照。 */
getEntitlements(): Promise<Result<UserEntitlements>>;
} }
+6
View File
@@ -14,6 +14,7 @@ import {
CreditsHistoryData, CreditsHistoryData,
UpdateProfileRequest, UpdateProfileRequest,
User, User,
UserEntitlements,
UserStatsResponse, UserStatsResponse,
} from "@/data/dto/user"; } from "@/data/dto/user";
import { Result } from "@/utils"; import { Result } from "@/utils";
@@ -49,6 +50,11 @@ export class UserRepository implements IUserRepository {
): Promise<Result<CreditsHistoryData>> { ): Promise<Result<CreditsHistoryData>> {
return Result.wrap(() => this.api.getCreditsHistory(limit, offset)); return Result.wrap(() => this.api.getCreditsHistory(limit, offset));
} }
/** 获取当前用户权益快照。 */
async getEntitlements(): Promise<Result<UserEntitlements>> {
return Result.wrap(() => this.api.getEntitlements());
}
} }
/** 全局单例。 */ /** 全局单例。 */
+2
View File
@@ -9,4 +9,6 @@ export * from "./personality_traits";
export * from "./recent_memory"; export * from "./recent_memory";
export * from "./update_profile_request"; export * from "./update_profile_request";
export * from "./user"; export * from "./user";
export * from "./user_entitlement_snapshot";
export * from "./user_entitlements";
export * from "./user_stats_response"; export * from "./user_stats_response";
@@ -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
>;
+105
View File
@@ -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<typeof UserEntitlementsSchema>;
export type UserEntitlementsData = z.output<typeof UserEntitlementsSchema>;
+3
View File
@@ -69,6 +69,9 @@ export class ApiPath {
/** 查询积分操作历史 */ /** 查询积分操作历史 */
static readonly userCreditsHistory = `${ApiPath._user}/credits/history`; static readonly userCreditsHistory = `${ApiPath._user}/credits/history`;
/** 获取当前用户权益快照 */
static readonly userEntitlements = `${ApiPath._user}/entitlements`;
// ============ 支付相关 ============ // ============ 支付相关 ============
/** 创建充值订单 */ /** 创建充值订单 */
static readonly paymentCreateOrder = `${ApiPath._payment}/create-order`; static readonly paymentCreateOrder = `${ApiPath._payment}/create-order`;
+9
View File
@@ -12,6 +12,7 @@ import {
CreditsHistoryData, CreditsHistoryData,
UpdateProfileRequest, UpdateProfileRequest,
User, User,
UserEntitlements,
UserStatsResponse, UserStatsResponse,
} from "@/data/dto/user"; } from "@/data/dto/user";
import type { UserData } from "@/data/schemas/user/user"; import type { UserData } from "@/data/schemas/user/user";
@@ -69,6 +70,14 @@ export class UserApi {
unwrap(env) as Record<string, unknown> unwrap(env) as Record<string, unknown>
); );
} }
/**
* 获取当前用户权益快照
*/
async getEntitlements(): Promise<UserEntitlements> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.userEntitlements);
return UserEntitlements.fromJson(unwrap(env) as Record<string, unknown>);
}
} }
/** /**
+1
View File
@@ -18,6 +18,7 @@ export const StorageKeys = {
user: "user", user: "user",
userId: "user_id", userId: "user_id",
userAvatar: "user_avatar", userAvatar: "user_avatar",
userEntitlementSnapshot: "user_entitlement_snapshot",
// chat // chat
chatHistory: "chat_history", chatHistory: "chat_history",
+7
View File
@@ -12,6 +12,7 @@
*/ */
import type { Result } from "@/utils"; 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 { UserData } from "@/data/schemas/user/user";
export interface IUserStorage { export interface IUserStorage {
@@ -27,5 +28,11 @@ export interface IUserStorage {
setAvatarUrl(url: string): Promise<Result<void>>; setAvatarUrl(url: string): Promise<Result<void>>;
clearAvatarUrl(): Promise<Result<void>>; clearAvatarUrl(): Promise<Result<void>>;
getEntitlementSnapshot(): Promise<Result<UserEntitlementSnapshotData | null>>;
setEntitlementSnapshot(
data: UserEntitlementSnapshotData,
): Promise<Result<void>>;
clearEntitlementSnapshot(): Promise<Result<void>>;
clearUserData(): Promise<Result<void>>; clearUserData(): Promise<Result<void>>;
} }
+32 -1
View File
@@ -15,6 +15,10 @@
* - 单例挂在 class 静态字段,HMR 复用安全 * - 单例挂在 class 静态字段,HMR 复用安全
*/ */
import {
UserEntitlementSnapshotSchema,
type UserEntitlementSnapshotData,
} from "@/data/schemas/user/user_entitlement_snapshot";
import { UserSchema, type UserData } from "@/data/schemas/user/user"; import { UserSchema, type UserData } from "@/data/schemas/user/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";
@@ -75,6 +79,31 @@ export class UserStorage implements IUserStorage {
return SpAsyncUtil.remove(StorageKeys.userAvatar); return SpAsyncUtil.remove(StorageKeys.userAvatar);
} }
// ---- entitlement snapshot ----
getEntitlementSnapshot(): Promise<
ResultT<UserEntitlementSnapshotData | null>
> {
return SpAsyncUtil.getJson(
StorageKeys.userEntitlementSnapshot,
UserEntitlementSnapshotSchema,
);
}
setEntitlementSnapshot(
data: UserEntitlementSnapshotData,
): Promise<ResultT<void>> {
return SpAsyncUtil.setJson(
StorageKeys.userEntitlementSnapshot,
data,
UserEntitlementSnapshotSchema,
);
}
clearEntitlementSnapshot(): Promise<ResultT<void>> {
return SpAsyncUtil.remove(StorageKeys.userEntitlementSnapshot);
}
// ---- bulk clear ---- // ---- bulk clear ----
async clearUserData(): Promise<ResultT<void>> { async clearUserData(): Promise<ResultT<void>> {
@@ -82,6 +111,8 @@ export class UserStorage implements IUserStorage {
if (!r1.success) return r1; if (!r1.success) return r1;
const r2 = await this.clearUserId(); const r2 = await this.clearUserId();
if (!r2.success) return r2; if (!r2.success) return r2;
return this.clearAvatarUrl(); const r3 = await this.clearAvatarUrl();
if (!r3.success) return r3;
return this.clearEntitlementSnapshot();
} }
} }
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest"; 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", () => { describe("toView", () => {
it("fills optional user fields with stable defaults", () => { it("fills optional user fields with stable defaults", () => {
@@ -11,6 +14,8 @@ describe("toView", () => {
avatarUrl: "", avatarUrl: "",
intimacy: 0, intimacy: 0,
dolBalance: 0, dolBalance: 0,
creditBalance: 0,
vipExpiresAt: null,
relationshipStage: "", relationshipStage: "",
currentMood: "", currentMood: "",
isGuest: false, isGuest: false,
@@ -28,6 +33,8 @@ describe("toView", () => {
avatarUrl: "https://example.com/avatar.png", avatarUrl: "https://example.com/avatar.png",
intimacy: 42, intimacy: 42,
dolBalance: 12, dolBalance: 12,
creditBalance: 120,
vipExpiresAt: "2026-07-26T00:00:00+00:00",
relationshipStage: "close_friend", relationshipStage: "close_friend",
currentMood: "playful", currentMood: "playful",
isGuest: true, isGuest: true,
@@ -41,6 +48,8 @@ describe("toView", () => {
avatarUrl: "https://example.com/avatar.png", avatarUrl: "https://example.com/avatar.png",
intimacy: 42, intimacy: 42,
dolBalance: 12, dolBalance: 12,
creditBalance: 120,
vipExpiresAt: "2026-07-26T00:00:00+00:00",
relationshipStage: "close_friend", relationshipStage: "close_friend",
currentMood: "playful", currentMood: "playful",
isGuest: true, isGuest: true,
@@ -48,4 +57,19 @@ describe("toView", () => {
voiceMinutesRemaining: 30, 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,
});
});
}); });
+48 -13
View File
@@ -3,7 +3,10 @@ import { createActor, fromPromise, waitFor } from "xstate";
import type { UserView } from "@/data/dto/user"; import type { UserView } from "@/data/dto/user";
import { userMachine } from "@/stores/user/user-machine"; 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> = {}): UserView { function makeUser(overrides: Partial<UserView> = {}): UserView {
return { return {
@@ -13,6 +16,8 @@ function makeUser(overrides: Partial<UserView> = {}): UserView {
avatarUrl: "https://example.com/avatar.png", avatarUrl: "https://example.com/avatar.png",
intimacy: 42, intimacy: 42,
dolBalance: 7, dolBalance: 7,
creditBalance: 7,
vipExpiresAt: null,
relationshipStage: "close_friend", relationshipStage: "close_friend",
currentMood: "happy", currentMood: "happy",
isGuest: false, isGuest: false,
@@ -22,10 +27,21 @@ function makeUser(overrides: Partial<UserView> = {}): UserView {
}; };
} }
function makeEntitlementSnapshot(
overrides: Partial<InitData["snapshot"]> = {},
): NonNullable<InitData["snapshot"]> {
return {
isVip: false,
creditBalance: 7,
...overrides,
};
}
function createTestUserMachine( function createTestUserMachine(
overrides: Partial<{ overrides: Partial<{
initData: InitData; initData: InitData;
fetchedUser: UserView | null; fetchedUser: UserView | null;
fetchedData: UserFetchData;
logoutSpy: () => void; logoutSpy: () => void;
}> = {}, }> = {},
) { ) {
@@ -34,16 +50,26 @@ function createTestUserMachine(
return userMachine.provide({ return userMachine.provide({
actors: { actors: {
userInit: fromPromise<InitData>(async () => ({ userInit: fromPromise<InitData>(async () => ({
user: makeUser(), user: overrides.initData?.user ?? makeUser(),
avatarUrl: "https://example.com/avatar.png", avatarUrl:
...overrides.initData, overrides.initData?.avatarUrl ?? "https://example.com/avatar.png",
snapshot: overrides.initData?.snapshot ?? null,
})), })),
userFetch: fromPromise<UserView | null>( userFetch: fromPromise<UserFetchData>(async () => {
async () => if ("fetchedData" in overrides && overrides.fetchedData) {
"fetchedUser" in overrides return overrides.fetchedData;
? overrides.fetchedUser ?? null }
: makeUser({ username: "Fetched" }), if ("fetchedUser" in overrides) {
), return {
user: overrides.fetchedUser ?? null,
snapshot: null,
};
}
return {
user: makeUser({ username: "Fetched" }),
snapshot: null,
};
}),
userLogout: fromPromise<void>(async () => { userLogout: fromPromise<void>(async () => {
logoutSpy(); logoutSpy();
}), }),
@@ -58,7 +84,9 @@ describe("userMachine", () => {
initData: { initData: {
user: makeUser({ username: "Local User" }), user: makeUser({ username: "Local User" }),
avatarUrl: "https://example.com/local-avatar.png", avatarUrl: "https://example.com/local-avatar.png",
snapshot: null,
}, },
fetchedUser: null,
}), }),
).start(); ).start();
@@ -78,10 +106,8 @@ describe("userMachine", () => {
actor.send({ type: "UserUpdate", user: makeUser({ username: "Before" }) }); actor.send({ type: "UserUpdate", user: makeUser({ username: "Before" }) });
actor.send({ type: "UserUpdateUsername", username: "After" }); actor.send({ type: "UserUpdateUsername", username: "After" });
actor.send({ type: "UserUpdatePronouns", pronouns: "They" });
expect(actor.getSnapshot().context.currentUser?.username).toBe("After"); expect(actor.getSnapshot().context.currentUser?.username).toBe("After");
expect(actor.getSnapshot().context.pronouns).toBe("They");
actor.stop(); actor.stop();
}); });
@@ -89,7 +115,13 @@ describe("userMachine", () => {
it("fetches the latest user and clears loading state when done", async () => { it("fetches the latest user and clears loading state when done", async () => {
const actor = createActor( const actor = createActor(
createTestUserMachine({ createTestUserMachine({
fetchedUser: makeUser({ username: "Fresh User", isVip: true }), fetchedData: {
user: makeUser({ username: "Fresh User" }),
snapshot: makeEntitlementSnapshot({
isVip: true,
creditBalance: 120,
}),
},
}), }),
).start(); ).start();
@@ -101,6 +133,9 @@ describe("userMachine", () => {
const context = actor.getSnapshot().context; const context = actor.getSnapshot().context;
expect(context.currentUser?.username).toBe("Fresh User"); expect(context.currentUser?.username).toBe("Fresh User");
expect(context.currentUser?.isVip).toBe(true); 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); expect(context.isLoading).toBe(false);
actor.stop(); actor.stop();
+4 -4
View File
@@ -25,10 +25,10 @@ import type { UserState as MachineContext, UserEvent } from "./user-machine";
*/ */
interface UserState { interface UserState {
currentUser: MachineContext["currentUser"]; currentUser: MachineContext["currentUser"];
pronouns: string;
coinBalance: number;
isLoading: boolean; isLoading: boolean;
avatarUrl: string | null; avatarUrl: string | null;
isVip: boolean;
creditBalance: number;
} }
const UserStateCtx = createContext<UserState | null>(null); const UserStateCtx = createContext<UserState | null>(null);
@@ -45,14 +45,14 @@ export function UserProvider({ children }: UserProviderProps) {
const userState = useMemo<UserState>( const userState = useMemo<UserState>(
() => ({ () => ({
currentUser: state.context.currentUser, currentUser: state.context.currentUser,
pronouns: state.context.pronouns,
coinBalance: state.context.coinBalance,
isLoading: isLoading:
state.matches("initializing") || state.matches("initializing") ||
state.matches("fetching") || state.matches("fetching") ||
state.matches("loggingOut") || state.matches("loggingOut") ||
state.context.isLoading, state.context.isLoading,
avatarUrl: state.context.avatarUrl, avatarUrl: state.context.avatarUrl,
isVip: state.context.isVip,
creditBalance: state.context.creditBalance,
}), }),
[state], [state],
); );
-1
View File
@@ -8,7 +8,6 @@ export type UserEvent =
| { type: "UserFetch" } | { type: "UserFetch" }
| { type: "UserUpdate"; user: UserView } | { type: "UserUpdate"; user: UserView }
| { type: "UserUpdateUsername"; username: string } | { type: "UserUpdateUsername"; username: string }
| { type: "UserUpdatePronouns"; pronouns: string }
| { type: "UserClearLocal" } | { type: "UserClearLocal" }
| { type: "UserLogout" } | { type: "UserLogout" }
| { type: "UserDeleteChatHistory" } | { type: "UserDeleteChatHistory" }
+39 -11
View File
@@ -14,7 +14,6 @@
import { fromPromise } from "xstate"; import { fromPromise } from "xstate";
import type { UserView } from "@/data/dto/user";
import { userRepository } from "@/data/repositories/user_repository"; import { userRepository } from "@/data/repositories/user_repository";
import { authRepository } from "@/data/repositories/auth_repository"; import { authRepository } from "@/data/repositories/auth_repository";
import type { import type {
@@ -23,7 +22,15 @@ import type {
} from "@/data/repositories/interfaces"; } from "@/data/repositories/interfaces";
import { Result } from "@/utils"; 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<InitData>(async () => readInitData());
// Fetch actor:调 API 拉当前用户 + 持久化 // Fetch actor:调 API 拉当前用户 + 持久化
// ============================================================ // ============================================================
/** /**
* 调 `userRepo.getCurrentUser` 拉当前 user * 调 `userRepo.getCurrentUser + getEntitlements` 拉当前 user 与权益
* - 成功 → 转 UserView + 写本地(userStorage.setUser→ 返回 View * - user 成功 → 转 UserView + 写本地(userStorage.setUser
* - 失败 → 返回 nullmachine 走 onDonecontext 保持不变;onError 兜底) * - entitlements 成功 → 提取 isVip/creditBalance 写本地轻量快照
* - 任一失败都不阻断另一方,machine 层负责保留已有 context
*/ */
export const userFetchActor = fromPromise<UserView | null>(async () => { export const userFetchActor = fromPromise<UserFetchData>(async () => {
const result = await userRepo.getCurrentUser(); const [userResult, entitlementsResult] = await Promise.all([
if (Result.isErr(result)) return null; userRepo.getCurrentUser(),
const view = toView(result.data.toJson()); 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()); await userStorage.setUser(userResult.data.toJson());
return view; return { user: view, snapshot };
}); });
// ============================================================ // ============================================================
+46 -4
View File
@@ -14,6 +14,7 @@
*/ */
import type { UserView } from "@/data/dto/user"; import type { UserView } from "@/data/dto/user";
import type { UserEntitlementSnapshotData } from "@/data/schemas/user";
import { UserStorage } from "@/data/storage/user/user_storage"; import { UserStorage } from "@/data/storage/user/user_storage";
import { Result } from "@/utils"; import { Result } from "@/utils";
@@ -36,6 +37,12 @@ export const userStorage = UserStorage.getInstance();
export interface InitData { export interface InitData {
user: UserView | null; user: UserView | null;
avatarUrl: string | 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; avatarUrl?: string;
intimacy?: number; intimacy?: number;
dolBalance?: number; dolBalance?: number;
creditBalance?: number;
vipExpiresAt?: string | null;
relationshipStage?: string; relationshipStage?: string;
currentMood?: string; currentMood?: string;
isGuest?: boolean; isGuest?: boolean;
isVip?: boolean; isVip?: boolean;
voiceMinutesRemaining?: number; voiceMinutesRemaining?: number;
}): UserView { }): UserView {
const creditBalance = u.creditBalance ?? u.dolBalance ?? 0;
return { return {
id: u.id, id: u.id,
username: u.username, username: u.username,
email: u.email ?? "", email: u.email ?? "",
avatarUrl: u.avatarUrl ?? "", avatarUrl: u.avatarUrl ?? "",
intimacy: u.intimacy ?? 0, intimacy: u.intimacy ?? 0,
dolBalance: u.dolBalance ?? 0, dolBalance: u.dolBalance ?? creditBalance,
creditBalance,
vipExpiresAt: u.vipExpiresAt ?? null,
relationshipStage: u.relationshipStage ?? "", relationshipStage: u.relationshipStage ?? "",
currentMood: u.currentMood ?? "", currentMood: u.currentMood ?? "",
isGuest: u.isGuest ?? false, 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 用) // Init 数据加载(userInitActor 用)
// ============================================================ // ============================================================
@@ -84,14 +120,20 @@ export function toView(u: {
* - Result.isOk(_) && data 双重判空:Result 包装层 + 业务 null * - Result.isOk(_) && data 双重判空:Result 包装层 + 业务 null
*/ */
export async function readInitData(): Promise<InitData> { export async function readInitData(): Promise<InitData> {
const [userResult, avatarResult] = await Promise.all([ const [userResult, avatarResult, snapshotResult] = await Promise.all([
userStorage.getUser(), userStorage.getUser(),
userStorage.getAvatarUrl(), userStorage.getAvatarUrl(),
userStorage.getEntitlementSnapshot(),
]); ]);
let snapshot: UserEntitlementSnapshotData | null = null;
if (Result.isOk(snapshotResult) && snapshotResult.data) {
snapshot = snapshotResult.data;
}
let user: UserView | null = null; let user: UserView | null = null;
if (Result.isOk(userResult) && userResult.data) { if (Result.isOk(userResult) && userResult.data) {
user = toView(userResult.data); user = applyEntitlementSnapshotToView(toView(userResult.data), snapshot);
} }
let avatarUrl: string | null = null; let avatarUrl: string | null = null;
@@ -99,5 +141,5 @@ export async function readInitData(): Promise<InitData> {
avatarUrl = avatarResult.data; avatarUrl = avatarResult.data;
} }
return { user, avatarUrl }; return { user, avatarUrl, snapshot };
} }
+33 -15
View File
@@ -10,6 +10,7 @@ import {
userFetchActor, userFetchActor,
userLogoutActor, userLogoutActor,
} from "./user-machine.actors"; } from "./user-machine.actors";
import { applyEntitlementSnapshotToView } from "./user-machine.helpers";
// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变 // 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变
export type { UserState } from "./user-state"; export type { UserState } from "./user-state";
@@ -32,7 +33,11 @@ export const userMachine = setup({
actions: { actions: {
updateUser: assign(({ event }) => { updateUser: assign(({ event }) => {
if (event.type !== "UserUpdate") return {}; 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 }) => { 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(() => ({ clearUser: assign(() => ({
currentUser: null, currentUser: null,
avatarUrl: null, avatarUrl: null,
isVip: false,
creditBalance: 0,
})), })),
}, },
}).createMachine({ }).createMachine({
@@ -67,9 +69,6 @@ export const userMachine = setup({
UserUpdateUsername: { UserUpdateUsername: {
actions: "updateUsername", actions: "updateUsername",
}, },
UserUpdatePronouns: {
actions: "updatePronouns",
},
UserClearLocal: { UserClearLocal: {
actions: "clearUser", actions: "clearUser",
}, },
@@ -83,14 +82,18 @@ export const userMachine = setup({
invoke: { invoke: {
src: "userInit", src: "userInit",
onDone: { onDone: {
target: "idle", target: "fetching",
actions: assign(({ event }) => ({ actions: assign(({ event }) => ({
currentUser: event.output.user, currentUser: event.output.user,
avatarUrl: event.output.avatarUrl, avatarUrl: event.output.avatarUrl,
isVip: event.output.snapshot?.isVip ?? initialState.isVip,
creditBalance:
event.output.snapshot?.creditBalance ??
initialState.creditBalance,
})), })),
}, },
onError: { onError: {
target: "idle", target: "fetching",
}, },
}, },
}, },
@@ -100,10 +103,25 @@ export const userMachine = setup({
src: "userFetch", src: "userFetch",
onDone: { onDone: {
target: "idle", target: "idle",
actions: assign(({ context, event }) => ({ actions: assign(({ context, event }) => {
currentUser: event.output ?? context.currentUser, const snapshot =
isLoading: false, 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: { onError: {
target: "idle", target: "idle",
+4 -4
View File
@@ -5,16 +5,16 @@ import type { UserView } from "@/data/dto/user";
export interface UserState { export interface UserState {
currentUser: UserView | null; currentUser: UserView | null;
pronouns: string;
coinBalance: number;
isLoading: boolean; isLoading: boolean;
avatarUrl: string | null; avatarUrl: string | null;
isVip: boolean;
creditBalance: number;
} }
export const initialState: UserState = { export const initialState: UserState = {
currentUser: null, currentUser: null,
pronouns: "He",
coinBalance: 45,
isLoading: false, isLoading: false,
avatarUrl: null, avatarUrl: null,
isVip: false,
creditBalance: 0,
}; };