refactor(user): split user-machine into helpers and actors

Mirror the existing chat-module structure (chat-machine.helpers.ts +
chat-machine.actors.ts) for consistency across state-machine modules.

Split:
- user-machine.helpers.ts: pure data layer (no XState dep)
  - userStorage singleton
  - InitData interface
  - toView DTO → UserView mapper
  - readInitData (parallel getUser + getAvatarUrl)
- user-machine.actors.ts: async actor implementations (fromPromise)
  - userRepo / authRepo injection
  - userInitActor / userFetchActor / userLogoutActor
- user-machine.ts: only setup().createMachine() — keeps inline assign
  actions referencing context/event where they belong, imports actors
  (not helpers, per the layered convention).

No behaviour change — only file boundaries moved. Public API (exports of
UserState / UserEvent / initialState / userMachine / UserMachine) stays
identical so external imports are untouched.
This commit is contained in:
2026-06-16 18:12:11 +08:00
parent bde8b99c04
commit a44463b3ee
3 changed files with 193 additions and 95 deletions
+74
View File
@@ -0,0 +1,74 @@
/**
* User 状态机:Actors(异步服务)
*
* 从 `user-machine.ts` 抽出的"Actors"段:
* - `userInitActor`fromPromise:读 UserStorage 并行 user + avatarUrl
* - `userFetchActor`fromPromise:调 API + 持久化到本地)
* - `userLogoutActor`fromPromiseauth logout + 清本地数据)
*
* 设计:
* - 依赖 `user-machine.helpers.ts`toView, readInitData, userStorage
* - 仓库以接口类型注入(调用面只看接口,运行时仍是同一单例)
* - 命名约定:`[user-machine]` 前缀日志(如未来加)—— 与 chat 模块对齐
*/
import { fromPromise } from "xstate";
import type { UserView } from "@/models/user/user";
import { userRepository } from "@/data/repositories/user_repository";
import { authRepository } from "@/data/repositories/auth_repository";
import type {
IAuthRepository,
IUserRepository,
} from "@/data/repositories/interfaces";
import { Result } from "@/utils/result";
import { type InitData, readInitData, toView, userStorage } from "./user-machine.helpers";
// ============================================================
// 仓库注入
// ============================================================
// 仓库以接口类型注入:调用面只看接口,运行时仍是同一单例
const userRepo: IUserRepository = userRepository;
const authRepo: IAuthRepository = authRepository;
// ============================================================
// 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` 拉当前 user
* - 成功 → 转 UserView + 写本地(userStorage.setUser)→ 返回 View
* - 失败 → 返回 nullmachine 走 onDonecontext 保持不变;onError 兜底)
*/
export const userFetchActor = fromPromise<UserView | null>(async () => {
const result = await userRepo.getCurrentUser();
if (Result.isErr(result)) return null;
const view = toView(result.data.toJson());
// 持久化到本地
await userStorage.setUser(result.data.toJson());
return view;
});
// ============================================================
// Logout actor:后端登出 + 清本地用户数据
// ============================================================
/**
* 登出:
* - 调 `authRepo.logout` 让后端吊销 token
* - `userStorage.clearUserData` 清本地 user / avatar 等
* - onDone / onError 都跑 `clearUser` actionmachine 层)—— 失败也清本地
*/
export const userLogoutActor = fromPromise<void>(async () => {
await authRepo.logout();
await userStorage.clearUserData();
});
+105
View File
@@ -0,0 +1,105 @@
/**
* 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 "@/models/user/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;
}
// ============================================================
// DTO → UserView 映射
// ============================================================
/**
* UserView DTO → UserView 视图模型(纯函数,可单测)
* - 与 chat 的 `localMessagesToUi` 同模式
* - 默认值收敛在此:undefined → "" / 0 / false,避免下游到处判空
*/
export function toView(u: {
id: string;
username: string;
email?: string;
avatarUrl?: string;
intimacy?: number;
dolBalance?: number;
relationshipStage?: string;
currentMood?: string;
isGuest?: boolean;
isVip?: boolean;
voiceMinutesRemaining?: number;
stripeCustomerId?: string | null;
}): UserView {
return {
id: u.id,
username: u.username,
email: u.email ?? "",
avatarUrl: u.avatarUrl ?? "",
intimacy: u.intimacy ?? 0,
dolBalance: u.dolBalance ?? 0,
relationshipStage: u.relationshipStage ?? "",
currentMood: u.currentMood ?? "",
isGuest: u.isGuest ?? false,
isVip: u.isVip ?? false,
voiceMinutesRemaining: u.voiceMinutesRemaining ?? 0,
stripeCustomerId: u.stripeCustomerId ?? null,
};
}
// ============================================================
// Init 数据加载(userInitActor 用)
// ============================================================
/**
* 并行读 user + avatarUrlUserStorage 两个独立接口)。
* - 任意一个失败 / 无数据 → 对应字段返回 null,不抛错(与 chat 的
* `readAndSyncHistory` "失败不卡 init" 一致的设计)
* - Result.isOk(_) && data 双重判空:Result 包装层 + 业务 null
*/
export async function readInitData(): Promise<InitData> {
const [userResult, avatarResult] = await Promise.all([
userStorage.getUser(),
userStorage.getAvatarUrl(),
]);
let user: UserView | null = null;
if (Result.isOk(userResult) && userResult.data) {
user = toView(userResult.data);
}
let avatarUrl: string | null = null;
if (Result.isOk(avatarResult) && avatarResult.data && avatarResult.data.length > 0) {
avatarUrl = avatarResult.data;
}
return { user, avatarUrl };
}
+13 -94
View File
@@ -5,112 +5,31 @@
*
* 设计要点:
* - XState v5 `setup({...}).createMachine({...})` 声明式 API
* - HTTP 操作用 `fromPromise` actor(一次性 Promise
* - HTTP 操作用 `fromPromise` actor(一次性 Promise→ 见 `user-machine.actors.ts`
* - 纯函数 / 数据加载 → 见 `user-machine.helpers.ts`
* - 同步状态更新用 `assign` actionsinline 在 setup() 中)
* - 保持事件类型名与原 Dart 一致
*
* 分层:
* - helpers: 无 XState 依赖(DTO 映射 + 数据加载)
* - actors: 依赖 helpers(异步 actor 实现)
* - machine: 依赖 actorsXState setup + 状态图)
*/
import { setup, fromPromise, assign } from "xstate";
import type { UserView } from "@/models/user/user";
import { userRepository } from "@/data/repositories/user_repository";
import { authRepository } from "@/data/repositories/auth_repository";
import type {
IAuthRepository,
IUserRepository,
} from "@/data/repositories/interfaces";
import { UserStorage } from "@/data/storage/user/user_storage";
import { Result } from "@/utils/result";
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";
// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变
export type { UserState } from "./user-state";
export { initialState } from "./user-state";
export type { UserEvent } from "./user-events";
// ============================================================
// Helpers
// ============================================================
const userStorage = UserStorage.getInstance();
interface InitData {
user: UserView | null;
avatarUrl: string | null;
}
function toView(u: {
id: string;
username: string;
email?: string;
avatarUrl?: string;
intimacy?: number;
dolBalance?: number;
relationshipStage?: string;
currentMood?: string;
isGuest?: boolean;
isVip?: boolean;
voiceMinutesRemaining?: number;
stripeCustomerId?: string | null;
}): UserView {
return {
id: u.id,
username: u.username,
email: u.email ?? "",
avatarUrl: u.avatarUrl ?? "",
intimacy: u.intimacy ?? 0,
dolBalance: u.dolBalance ?? 0,
relationshipStage: u.relationshipStage ?? "",
currentMood: u.currentMood ?? "",
isGuest: u.isGuest ?? false,
isVip: u.isVip ?? false,
voiceMinutesRemaining: u.voiceMinutesRemaining ?? 0,
stripeCustomerId: u.stripeCustomerId ?? null,
};
}
async function readInitData(): Promise<InitData> {
const [userResult, avatarResult] = await Promise.all([
userStorage.getUser(),
userStorage.getAvatarUrl(),
]);
let user: UserView | null = null;
if (Result.isOk(userResult) && userResult.data) {
user = toView(userResult.data);
}
let avatarUrl: string | null = null;
if (Result.isOk(avatarResult) && avatarResult.data && avatarResult.data.length > 0) {
avatarUrl = avatarResult.data;
}
return { user, avatarUrl };
}
// ============================================================
// Actors
// ============================================================
// 仓库以接口类型注入:调用面只看接口,运行时仍是同一单例
const userRepo: IUserRepository = userRepository;
const authRepo: IAuthRepository = authRepository;
const userInitActor = fromPromise<InitData>(async () => readInitData());
const userFetchActor = fromPromise<UserView | null>(async () => {
const result = await userRepo.getCurrentUser();
if (Result.isErr(result)) return null;
const view = toView(result.data.toJson());
// 持久化到本地
await userStorage.setUser(result.data.toJson());
return view;
});
const userLogoutActor = fromPromise<void>(async () => {
await authRepo.logout();
await userStorage.clearUserData();
});
// ============================================================
// Machine
// ============================================================