refactor(stores): standardize state machine structure

This commit is contained in:
2026-07-15 11:19:08 +08:00
parent ed3e800245
commit 20d07023e4
68 changed files with 3492 additions and 3193 deletions
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import {
applyEntitlementSnapshotToView,
toView,
} from "@/stores/user/user-machine.helpers";
} from "@/stores/user/helper";
describe("toView", () => {
it("fills optional user fields with stable defaults", () => {
@@ -0,0 +1,77 @@
import { vi } from "vitest";
import { fromPromise } from "xstate";
import type { UserView } from "@/data/dto/user";
import { userMachine } from "@/stores/user/user-machine";
import type { UserFetchData } from "@/stores/user/machine/actors/profile";
import type { InitData } from "@/stores/user/machine/actors/session";
export function makeUser(overrides: Partial<UserView> = {}): UserView {
return {
id: "user-1",
username: "Elio Fan",
countryCode: "HK",
creditBalance: 7,
dailyFreeChatLimit: 30,
dailyFreeChatRemaining: 30,
dailyFreePrivateLimit: 2,
dailyFreePrivateRemaining: 2,
isVip: false,
...overrides,
};
}
export function makeEntitlementSnapshot(
overrides: Partial<InitData["snapshot"]> = {},
): NonNullable<InitData["snapshot"]> {
return {
isVip: false,
creditBalance: 7,
...overrides,
};
}
export function createTestUserMachine(
overrides: Partial<{
initData: InitData;
initError: Error;
fetchedUser: UserView | null;
fetchedData: UserFetchData;
fetchError: Error;
logoutSpy: () => void;
logoutError: Error;
}> = {},
) {
const logoutSpy = overrides.logoutSpy ?? vi.fn<() => void>();
return userMachine.provide({
actors: {
userInit: fromPromise<InitData>(async () => {
if (overrides.initError) throw overrides.initError;
return {
user: overrides.initData?.user ?? makeUser(),
avatarUrl:
overrides.initData?.avatarUrl ?? "https://example.com/avatar.png",
snapshot: overrides.initData?.snapshot ?? null,
};
}),
userFetch: fromPromise<UserFetchData>(async () => {
if (overrides.fetchError) throw overrides.fetchError;
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();
if (overrides.logoutError) throw overrides.logoutError;
}),
},
});
}
@@ -1,172 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { createActor, fromPromise, waitFor } from "xstate";
import type { UserView } from "@/data/dto/user";
import { userMachine } from "@/stores/user/user-machine";
import type {
InitData,
UserFetchData,
} from "@/stores/user/user-machine.helpers";
function makeUser(overrides: Partial<UserView> = {}): UserView {
return {
id: "user-1",
username: "Elio Fan",
countryCode: "HK",
creditBalance: 7,
dailyFreeChatLimit: 30,
dailyFreeChatRemaining: 30,
dailyFreePrivateLimit: 2,
dailyFreePrivateRemaining: 2,
isVip: false,
...overrides,
};
}
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;
}> = {},
) {
const logoutSpy = overrides.logoutSpy ?? vi.fn<() => void>();
return userMachine.provide({
actors: {
userInit: fromPromise<InitData>(async () => ({
user: overrides.initData?.user ?? makeUser(),
avatarUrl:
overrides.initData?.avatarUrl ?? "https://example.com/avatar.png",
snapshot: overrides.initData?.snapshot ?? null,
})),
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();
}),
},
});
}
describe("userMachine", () => {
it("initializes user and avatar from local storage actor output", async () => {
const actor = createActor(
createTestUserMachine({
initData: {
user: makeUser({ username: "Local User" }),
avatarUrl: "https://example.com/local-avatar.png",
snapshot: null,
},
fetchedUser: null,
}),
).start();
actor.send({ type: "UserInit" });
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
expect(actor.getSnapshot().context.currentUser?.username).toBe("Local User");
expect(actor.getSnapshot().context.avatarUrl).toBe(
"https://example.com/local-avatar.png",
);
actor.stop();
});
it("updates user profile synchronously", () => {
const actor = createActor(createTestUserMachine()).start();
actor.send({ type: "UserUpdate", user: makeUser({ username: "After" }) });
expect(actor.getSnapshot().context.currentUser?.username).toBe("After");
actor.stop();
});
it("fetches the latest user and clears loading state when done", async () => {
const actor = createActor(
createTestUserMachine({
fetchedData: {
user: makeUser({ username: "Fresh User" }),
snapshot: makeEntitlementSnapshot({
isVip: true,
creditBalance: 120,
}),
},
}),
).start();
actor.send({ type: "UserFetch" });
expect(actor.getSnapshot().context.isLoading).toBe(true);
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
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();
});
it("keeps the current user when fetch returns null", async () => {
const actor = createActor(
createTestUserMachine({
fetchedUser: null,
}),
).start();
actor.send({ type: "UserUpdate", user: makeUser({ username: "Cached" }) });
actor.send({ type: "UserFetch" });
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
const context = actor.getSnapshot().context;
expect(context.currentUser?.username).toBe("Cached");
expect(context.isLoading).toBe(false);
actor.stop();
});
it("clears user data after logout", async () => {
const logoutSpy = vi.fn<() => void>();
const actor = createActor(createTestUserMachine({ logoutSpy })).start();
actor.send({ type: "UserUpdate", user: makeUser() });
actor.send({ type: "UserLogout" });
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
const context = actor.getSnapshot().context;
expect(logoutSpy).toHaveBeenCalledOnce();
expect(context.currentUser).toBeNull();
expect(context.avatarUrl).toBeNull();
actor.stop();
});
});
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { createActor, waitFor } from "xstate";
import {
createTestUserMachine,
makeEntitlementSnapshot,
makeUser,
} from "./user-machine.test-utils";
describe("user profile flow", () => {
it("updates user profile synchronously", () => {
const actor = createActor(createTestUserMachine()).start();
actor.send({ type: "UserUpdate", user: makeUser({ username: "After" }) });
expect(actor.getSnapshot().context.currentUser?.username).toBe("After");
actor.stop();
});
it("fetches the latest user and entitlements", async () => {
const actor = createActor(
createTestUserMachine({
fetchedData: {
user: makeUser({ username: "Fresh User" }),
snapshot: makeEntitlementSnapshot({
isVip: true,
creditBalance: 120,
}),
},
}),
).start();
actor.send({ type: "UserFetch" });
expect(actor.getSnapshot().context.isLoading).toBe(true);
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
expect(actor.getSnapshot().context).toMatchObject({
isVip: true,
creditBalance: 120,
isLoading: false,
});
expect(actor.getSnapshot().context.currentUser).toMatchObject({
username: "Fresh User",
isVip: true,
creditBalance: 120,
});
actor.stop();
});
it("keeps current user when fetch returns null", async () => {
const actor = createActor(
createTestUserMachine({ fetchedUser: null }),
).start();
actor.send({ type: "UserUpdate", user: makeUser({ username: "Cached" }) });
actor.send({ type: "UserFetch" });
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
expect(actor.getSnapshot().context.currentUser?.username).toBe("Cached");
actor.stop();
});
it("clears loading state when fetch actor fails", async () => {
const actor = createActor(
createTestUserMachine({ fetchError: new Error("fetch failed") }),
).start();
actor.send({ type: "UserFetch" });
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
expect(actor.getSnapshot().context.isLoading).toBe(false);
actor.stop();
});
});
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from "vitest";
import { createActor, waitFor } from "xstate";
import {
createTestUserMachine,
makeUser,
} from "./user-machine.test-utils";
describe("user session flow", () => {
it("initializes user and avatar from actor output", async () => {
const actor = createActor(
createTestUserMachine({
initData: {
user: makeUser({ username: "Local User" }),
avatarUrl: "https://example.com/local-avatar.png",
snapshot: null,
},
fetchedUser: null,
}),
).start();
actor.send({ type: "UserInit" });
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
expect(actor.getSnapshot().context.currentUser?.username).toBe(
"Local User",
);
expect(actor.getSnapshot().context.avatarUrl).toBe(
"https://example.com/local-avatar.png",
);
actor.stop();
});
it("continues to fetch when initialization fails", async () => {
const actor = createActor(
createTestUserMachine({ initError: new Error("storage unavailable") }),
).start();
actor.send({ type: "UserInit" });
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
expect(actor.getSnapshot().context.currentUser?.username).toBe("Fetched");
actor.stop();
});
it("clears user data after logout", async () => {
const logoutSpy = vi.fn<() => void>();
const actor = createActor(
createTestUserMachine({ logoutSpy }),
).start();
actor.send({ type: "UserUpdate", user: makeUser() });
actor.send({ type: "UserLogout" });
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
expect(logoutSpy).toHaveBeenCalledOnce();
expect(actor.getSnapshot().context.currentUser).toBeNull();
expect(actor.getSnapshot().context.avatarUrl).toBeNull();
actor.stop();
});
it("clears user data even when logout actor fails", async () => {
const actor = createActor(
createTestUserMachine({ logoutError: new Error("logout failed") }),
).start();
actor.send({ type: "UserUpdate", user: makeUser() });
actor.send({ type: "UserLogout" });
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
expect(actor.getSnapshot().context.currentUser).toBeNull();
actor.stop();
});
});
+1
View File
@@ -0,0 +1 @@
export * from "./view";
+51
View File
@@ -0,0 +1,51 @@
import type { UserView } from "@/data/dto/user";
import type { UserEntitlementSnapshotData } from "@/data/schemas/user";
export function toView(u: {
id: string;
username: string;
countryCode?: string;
dolBalance?: number;
creditBalance?: number;
dailyFreeChatLimit?: number;
dailyFreeChatRemaining?: number;
dailyFreePrivateLimit?: number;
dailyFreePrivateRemaining?: number;
isVip?: boolean;
}): UserView {
const creditBalance = u.creditBalance ?? u.dolBalance ?? 0;
return {
id: u.id,
username: u.username,
countryCode: u.countryCode ?? "",
creditBalance,
dailyFreeChatLimit: u.dailyFreeChatLimit ?? 0,
dailyFreeChatRemaining: u.dailyFreeChatRemaining ?? 0,
dailyFreePrivateLimit: u.dailyFreePrivateLimit ?? 0,
dailyFreePrivateRemaining: u.dailyFreePrivateRemaining ?? 0,
isVip: u.isVip ?? false,
};
}
export function applyEntitlementSnapshotToView(
user: UserView | null,
snapshot: UserEntitlementSnapshotData | null,
): UserView | null {
if (!user || !snapshot) return user;
return {
...user,
isVip: snapshot.isVip,
creditBalance: snapshot.creditBalance,
};
}
export function toEntitlementSnapshot(input: {
isVip: boolean;
creditBalance: number;
}): UserEntitlementSnapshotData {
return {
isVip: input.isVip,
creditBalance: input.creditBalance,
};
}
+2 -5
View File
@@ -1,10 +1,7 @@
/**
* @file Automatically generated by barrelsby.
*/
/** @file User store barrel. */
export * from "./user-context";
export * from "./user-events";
export * from "./user-machine.actors";
export * from "./user-machine.helpers";
export * from "./helper";
export * from "./user-machine";
export * from "./user-state";
+44
View File
@@ -0,0 +1,44 @@
import { fromPromise } from "xstate";
import type { UserView } from "@/data/dto/user";
import { getUserRepository } from "@/data/repositories/user_repository";
import type { UserEntitlementSnapshotData } from "@/data/schemas/user";
import { UserStorage } from "@/data/storage/user/user_storage";
import { Result } from "@/utils/result";
import {
applyEntitlementSnapshotToView,
toEntitlementSnapshot,
toView,
} from "../../helper/view";
export interface UserFetchData {
user: UserView | null;
snapshot: UserEntitlementSnapshotData | null;
}
export const userFetchActor = fromPromise<UserFetchData>(async () => {
const userRepo = getUserRepository();
const userStorage = UserStorage.getInstance();
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(userResult.data.toJson());
return { user: view, snapshot };
});
+46
View File
@@ -0,0 +1,46 @@
import { fromPromise } from "xstate";
import type { UserView } from "@/data/dto/user";
import { getAuthRepository } from "@/data/repositories/auth_repository";
import type { UserEntitlementSnapshotData } from "@/data/schemas/user";
import { UserStorage } from "@/data/storage/user/user_storage";
import { Result } from "@/utils/result";
import { applyEntitlementSnapshotToView, toView } from "../../helper/view";
export interface InitData {
user: UserView | null;
avatarUrl: string | null;
snapshot: UserEntitlementSnapshotData | null;
}
export const userInitActor = fromPromise<InitData>(async () => {
const userStorage = UserStorage.getInstance();
const [userResult, avatarResult, snapshotResult] = await Promise.all([
userStorage.getUser(),
userStorage.getAvatarUrl(),
userStorage.getEntitlementSnapshot(),
]);
const snapshot =
Result.isOk(snapshotResult) && snapshotResult.data
? snapshotResult.data
: null;
const user =
Result.isOk(userResult) && userResult.data
? applyEntitlementSnapshotToView(toView(userResult.data), snapshot)
: null;
const avatarUrl =
Result.isOk(avatarResult) &&
avatarResult.data &&
avatarResult.data.length > 0
? avatarResult.data
: null;
return { user, avatarUrl, snapshot };
});
export const userLogoutActor = fromPromise<void>(async () => {
const result = await getAuthRepository().logout();
if (Result.isErr(result)) throw result.error;
});
+74
View File
@@ -0,0 +1,74 @@
import type { DoneActorEvent, ErrorActorEvent } from "xstate";
import { applyEntitlementSnapshotToView } from "../helper/view";
import type { UserFetchData } from "./actors/profile";
import {
baseUserMachineSetup,
createUserActorActionSetup,
} from "./setup";
const updateUserAction = baseUserMachineSetup.assign(({ event }) =>
event.type === "UserUpdate"
? {
currentUser: event.user,
isVip: event.user.isVip,
creditBalance: event.user.creditBalance,
}
: {},
);
const markUserLoadingAction = baseUserMachineSetup.assign({
isLoading: true,
});
export const profileMachineSetup = baseUserMachineSetup.extend({
actions: {
updateUser: updateUserAction,
markUserLoading: markUserLoadingAction,
},
});
const userFetchDoneSetup =
createUserActorActionSetup<DoneActorEvent<UserFetchData>>();
const userFetchErrorSetup = createUserActorActionSetup<ErrorActorEvent>();
const applyFetchedUserAction = userFetchDoneSetup.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,
};
},
);
const markUserFetchFailedAction = userFetchErrorSetup.assign({
isLoading: false,
});
export const fetchingState = profileMachineSetup.createStateConfig({
entry: "markUserLoading",
invoke: {
src: "userFetch",
onDone: {
target: "idle",
actions: applyFetchedUserAction,
},
onError: {
target: "idle",
actions: markUserFetchFailedAction,
},
},
});
+75
View File
@@ -0,0 +1,75 @@
import type { DoneActorEvent } from "xstate";
import { initialState } from "../user-state";
import type { InitData } from "./actors/session";
import { fetchingState, profileMachineSetup } from "./profile-flow";
import { createUserActorActionSetup } from "./setup";
const clearUserAction = profileMachineSetup.assign(() => ({
currentUser: null,
avatarUrl: null,
isVip: false,
creditBalance: 0,
}));
export const userMachineSetup = profileMachineSetup.extend({
actions: {
clearUser: clearUserAction,
},
});
const userInitDoneSetup =
createUserActorActionSetup<DoneActorEvent<InitData>>();
const applyInitializedUserAction = userInitDoneSetup.assign(({ event }) => ({
currentUser: event.output.user,
avatarUrl: event.output.avatarUrl,
isVip: event.output.snapshot?.isVip ?? initialState.isVip,
creditBalance:
event.output.snapshot?.creditBalance ?? initialState.creditBalance,
}));
const idleState = userMachineSetup.createStateConfig({
on: {
UserInit: "initializing",
UserFetch: "fetching",
UserUpdate: { actions: "updateUser" },
UserClearLocal: { actions: "clearUser" },
UserLogout: "loggingOut",
},
});
const initializingState = userMachineSetup.createStateConfig({
invoke: {
src: "userInit",
onDone: {
target: "fetching",
actions: applyInitializedUserAction,
},
onError: { target: "fetching" },
},
});
const loggingOutState = userMachineSetup.createStateConfig({
invoke: {
src: "userLogout",
onDone: {
target: "idle",
actions: "clearUser",
},
onError: {
target: "idle",
actions: "clearUser",
},
},
});
export const userRootStateConfig = userMachineSetup.createStateConfig({
initial: "idle",
states: {
idle: idleState,
initializing: initializingState,
fetching: fetchingState,
loggingOut: loggingOutState,
},
});
+46
View File
@@ -0,0 +1,46 @@
import { setup, type ActionFunction, type EventObject } from "xstate";
import type { UserEvent } from "../user-events";
import type { UserState } from "../user-state";
import { userFetchActor } from "./actors/profile";
import { userInitActor, userLogoutActor } from "./actors/session";
export const baseUserMachineSetup = setup({
types: {
context: {} as UserState,
events: {} as UserEvent,
},
actors: {
userInit: userInitActor,
userFetch: userFetchActor,
userLogout: userLogoutActor,
},
});
type UserActorAction<TEvent extends EventObject> = ActionFunction<
UserState,
TEvent,
UserEvent,
undefined,
never,
never,
never,
never,
never
>;
export function createUserActorActionSetup<TEvent extends EventObject>() {
const actorActionSetup = setup({
types: {
context: {} as UserState,
events: {} as TEvent,
},
});
return {
assign(
assignment: Parameters<typeof actorActionSetup.assign>[0],
): UserActorAction<TEvent> {
return actorActionSetup.assign(assignment) as unknown as UserActorAction<TEvent>;
},
};
}
-92
View File
@@ -1,92 +0,0 @@
/**
* User 状态机:Actors(异步服务)
*
* 从 `user-machine.ts` 抽出的"Actors"段:
* - `userInitActor`fromPromise:读 UserStorage 并行 user + avatarUrl
* - `userFetchActor`fromPromise:调 API + 持久化到本地)
* - `userLogoutActor`fromPromiseauth logout + 清本地数据)
*
* 设计:
* - 依赖 `user-machine.helpers.ts`toView, readInitData, userStorage
* - 仓库使用懒单例 getter,actor 执行时才创建实例
* - 命名约定:`[user-machine]` 前缀日志(如未来加)—— 与 chat 模块对齐
*/
import { fromPromise } from "xstate";
import { getUserRepository } from "@/data/repositories/user_repository";
import { getAuthRepository } from "@/data/repositories/auth_repository";
import { Result } from "@/utils/result";
import {
applyEntitlementSnapshotToView,
type InitData,
readInitData,
toEntitlementSnapshot,
toView,
type UserFetchData,
userStorage,
} from "./user-machine.helpers";
// ============================================================
// Init actor:从 UserStorage 读 user + avatarUrl
// ============================================================
/**
* 读本地 user + avatarUrl(并行)。
* - 失败/无数据 → 返回 null,不抛错(machine 走 onDone 回到 idlecontext 是 null
* - onDone 赋 currentUser + avatarUrl 到 context
*/
export const userInitActor = fromPromise<InitData>(async () => readInitData());
// ============================================================
// Fetch actor:调 API 拉当前用户 + 持久化
// ============================================================
/**
* 调 `userRepo.getCurrentUser + getEntitlements` 拉当前 user 与权益:
* - user 成功 → 转 UserView + 写本地(userStorage.setUser
* - entitlements 成功 → 提取 isVip/creditBalance 写本地轻量快照
* - 任一失败都不阻断另一方,machine 层负责保留已有 context
*/
export const userFetchActor = fromPromise<UserFetchData>(async () => {
const userRepo = getUserRepository();
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(userResult.data.toJson());
return { user: view, snapshot };
});
// ============================================================
// Logout actor:后端登出 + 清本地用户数据
// ============================================================
/**
* 登出:
* - 调 `authRepo.logout` 让后端吊销 token
* - `userStorage.clearUserData` 清本地 user / avatar 等
* - onDone / onError 都跑 `clearUser` actionmachine 层)—— 失败也清本地
*/
export const userLogoutActor = fromPromise<void>(async () => {
const result = await getAuthRepository().logout();
if (Result.isErr(result)) throw result.error;
});
-137
View File
@@ -1,137 +0,0 @@
/**
* User 状态机:纯函数 + 数据加载
*
* 从 `user-machine.ts` 抽出的"Helpers"段:
* - InitData 形状
* - `toView`DTO → UserView 映射(纯函数)
* - `readInitData`:从 UserStorage 读 user + avatarUrl(并行)
* - `userStorage` 单例:被 helpers 和 actors 共用
*
* 设计目标:
* - helpers 无 XState 依赖(可独立 import / 测试)
* - actors 依赖 helpersimport
* - machine 依赖 actorsimport)—— 不直接依赖 helpers
*/
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/result";
// ============================================================
// Storage singleton
// ============================================================
/**
* UserStorage 单例 —— 被 helpersreadInitData)和 actorsuserFetchActor / userLogoutActor
* 共同使用。从 helpers 导出,避免在 actors 里重复 `UserStorage.getInstance()`。
*/
export const userStorage = UserStorage.getInstance();
// ============================================================
// InitData 形状
// ============================================================
/**
* `userInitActor` 的输出形状(user-machine.initializing.onDone 用)——
* 拆分 user 和 avatarUrl 是因为它们来自 UserStorage 两个独立接口。
*/
export interface InitData {
user: UserView | null;
avatarUrl: string | null;
snapshot: UserEntitlementSnapshotData | null;
}
export interface UserFetchData {
user: UserView | null;
snapshot: UserEntitlementSnapshotData | null;
}
// ============================================================
// DTO → UserView 映射
// ============================================================
/**
* UserView DTO → UserView 视图模型(纯函数,可单测)
* - 与 chat 的 `localMessagesToUi` 同模式
* - 默认值收敛在此:undefined → "" / 0 / false,避免下游到处判空
*/
export function toView(u: {
id: string;
username: string;
countryCode?: string;
dolBalance?: number;
creditBalance?: number;
dailyFreeChatLimit?: number;
dailyFreeChatRemaining?: number;
dailyFreePrivateLimit?: number;
dailyFreePrivateRemaining?: number;
isVip?: boolean;
}): UserView {
const creditBalance = u.creditBalance ?? u.dolBalance ?? 0;
return {
id: u.id,
username: u.username,
countryCode: u.countryCode ?? "",
creditBalance,
dailyFreeChatLimit: u.dailyFreeChatLimit ?? 0,
dailyFreeChatRemaining: u.dailyFreeChatRemaining ?? 0,
dailyFreePrivateLimit: u.dailyFreePrivateLimit ?? 0,
dailyFreePrivateRemaining: u.dailyFreePrivateRemaining ?? 0,
isVip: u.isVip ?? false,
};
}
export function applyEntitlementSnapshotToView(
user: UserView | null,
snapshot: UserEntitlementSnapshotData | null,
): UserView | null {
if (!user || !snapshot) return user;
return {
...user,
isVip: snapshot.isVip,
creditBalance: snapshot.creditBalance,
};
}
export function toEntitlementSnapshot(input: {
isVip: boolean;
creditBalance: number;
}): UserEntitlementSnapshotData {
return {
isVip: input.isVip,
creditBalance: input.creditBalance,
};
}
// ============================================================
// Init 数据加载(userInitActor 用)
// ============================================================
/**
* 并行读 user + avatarUrlUserStorage 两个独立接口)。
* - 任意一个失败 / 无数据 → 对应字段返回 null,不抛错(与 chat 的
* `readAndSyncHistory` "失败不卡 init" 一致的设计)
* - Result.isOk(_) && data 双重判空:Result 包装层 + 业务 null
*/
export async function readInitData(): Promise<InitData> {
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 = applyEntitlementSnapshotToView(toView(userResult.data), snapshot);
}
let avatarUrl: string | null = null;
if (Result.isOk(avatarResult) && avatarResult.data && avatarResult.data.length > 0) {
avatarUrl = avatarResult.data;
}
return { user, avatarUrl, snapshot };
}
+6 -127
View File
@@ -1,138 +1,17 @@
/**
* User 状态机(XState v5
*/
import { setup, assign } from "xstate";
import { UserState, initialState } from "./user-state";
import type { UserEvent } from "./user-events";
import {
userInitActor,
userFetchActor,
userLogoutActor,
} from "./user-machine.actors";
import { applyEntitlementSnapshotToView } from "./user-machine.helpers";
userMachineSetup,
userRootStateConfig,
} from "./machine/session-flow";
import { initialState } from "./user-state";
// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变
export type { UserState } from "./user-state";
export { initialState } from "./user-state";
export type { UserEvent } from "./user-events";
// ============================================================
// Machine
// ============================================================
export const userMachine = setup({
types: {
context: {} as UserState,
events: {} as UserEvent,
},
actors: {
userInit: userInitActor,
userFetch: userFetchActor,
userLogout: userLogoutActor,
},
actions: {
updateUser: assign(({ event }) => {
if (event.type !== "UserUpdate") return {};
return {
currentUser: event.user,
isVip: event.user.isVip,
creditBalance: event.user.creditBalance,
};
}),
clearUser: assign(() => ({
currentUser: null,
avatarUrl: null,
isVip: false,
creditBalance: 0,
})),
},
}).createMachine({
export const userMachine = userMachineSetup.createMachine({
id: "user",
initial: "idle",
context: initialState,
states: {
idle: {
on: {
UserInit: "initializing",
UserFetch: "fetching",
UserUpdate: {
actions: "updateUser",
},
UserClearLocal: {
actions: "clearUser",
},
UserLogout: "loggingOut",
},
},
initializing: {
invoke: {
src: "userInit",
onDone: {
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: "fetching",
},
},
},
fetching: {
invoke: {
src: "userFetch",
onDone: {
target: "idle",
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",
actions: assign({ isLoading: false }),
},
},
entry: assign({ isLoading: true }),
},
loggingOut: {
invoke: {
src: "userLogout",
onDone: {
target: "idle",
actions: "clearUser",
},
onError: {
target: "idle",
actions: "clearUser",
},
},
},
},
...userRootStateConfig,
});
export type UserMachine = typeof userMachine;