feat(user): sync entitlement snapshot
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<Result<CreditsHistoryData>>;
|
||||
|
||||
/** 获取当前用户权益快照。 */
|
||||
getEntitlements(): Promise<Result<UserEntitlements>>;
|
||||
}
|
||||
|
||||
@@ -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<Result<CreditsHistoryData>> {
|
||||
return Result.wrap(() => this.api.getCreditsHistory(limit, offset));
|
||||
}
|
||||
|
||||
/** 获取当前用户权益快照。 */
|
||||
async getEntitlements(): Promise<Result<UserEntitlements>> {
|
||||
return Result.wrap(() => this.api.getEntitlements());
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局单例。 */
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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>;
|
||||
@@ -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`;
|
||||
|
||||
@@ -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<string, unknown>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户权益快照
|
||||
*/
|
||||
async getEntitlements(): Promise<UserEntitlements> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.userEntitlements);
|
||||
return UserEntitlements.fromJson(unwrap(env) as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,6 +18,7 @@ export const StorageKeys = {
|
||||
user: "user",
|
||||
userId: "user_id",
|
||||
userAvatar: "user_avatar",
|
||||
userEntitlementSnapshot: "user_entitlement_snapshot",
|
||||
|
||||
// chat
|
||||
chatHistory: "chat_history",
|
||||
|
||||
@@ -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<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>>;
|
||||
}
|
||||
|
||||
@@ -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<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 ----
|
||||
|
||||
async clearUserData(): Promise<ResultT<void>> {
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> = {}): UserView {
|
||||
return {
|
||||
@@ -13,6 +16,8 @@ function makeUser(overrides: Partial<UserView> = {}): 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> = {}): UserView {
|
||||
};
|
||||
}
|
||||
|
||||
function makeEntitlementSnapshot(
|
||||
overrides: Partial<InitData["snapshot"]> = {},
|
||||
): NonNullable<InitData["snapshot"]> {
|
||||
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<InitData>(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<UserView | null>(
|
||||
async () =>
|
||||
"fetchedUser" in overrides
|
||||
? overrides.fetchedUser ?? null
|
||||
: makeUser({ username: "Fetched" }),
|
||||
),
|
||||
userFetch: fromPromise<UserFetchData>(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<void>(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();
|
||||
|
||||
@@ -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<UserState | null>(null);
|
||||
@@ -45,14 +45,14 @@ export function UserProvider({ children }: UserProviderProps) {
|
||||
const userState = useMemo<UserState>(
|
||||
() => ({
|
||||
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],
|
||||
);
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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<InitData>(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<UserView | null>(async () => {
|
||||
const result = await userRepo.getCurrentUser();
|
||||
if (Result.isErr(result)) return null;
|
||||
const view = toView(result.data.toJson());
|
||||
export const userFetchActor = fromPromise<UserFetchData>(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 };
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -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<InitData> {
|
||||
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<InitData> {
|
||||
avatarUrl = avatarResult.data;
|
||||
}
|
||||
|
||||
return { user, avatarUrl };
|
||||
return { user, avatarUrl, snapshot };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user