Merge branch 'dev' into test
This commit is contained in:
@@ -52,7 +52,12 @@ export function SidebarScreen() {
|
||||
const isNowLoggedOut = user.currentUser == null;
|
||||
prevUserRef.current = user.currentUser;
|
||||
if (wasLoggedIn && isNowLoggedOut) {
|
||||
// 1) AuthReset:清场(清 email/password/username 等敏感字段)
|
||||
authDispatch({ type: "AuthReset" });
|
||||
// 2) AuthStatusCheckSubmitted:通过 storage 重新验证实际 auth 状态
|
||||
// (userLogoutActor 已清空 userStorage,checkAuthStatusActor 会返 "notLoggedIn")
|
||||
// 这一步确保 redirect 到 /chat 后,chat-screen 看到的是真实 storage 状态而非 AuthReset 直接置的 initialState
|
||||
authDispatch({ type: "AuthStatusCheckSubmitted" });
|
||||
router.replace(ROUTES.chat);
|
||||
}
|
||||
}, [user.currentUser, authDispatch, router]);
|
||||
|
||||
@@ -32,9 +32,6 @@ export type { AuthEvent } from "./auth-events";
|
||||
// splash / auth screen 看到 `pendingRedirect && loginStatus !== "notLoggedIn"` 跳 /chat
|
||||
// 跳完派 `AuthClearPendingRedirect` 置 false
|
||||
// checkingAuthStatus 不设 flag —— 被动查 token 不算"用户意图"。
|
||||
//
|
||||
// 注:onDone 用了内联assign(不是 helper function)—— XState v5 的 TypeScript
|
||||
// 推断对 helper return type 不友好;内联能保留 `state.matches("loadingXxx")` 的类型。
|
||||
// ============================================================
|
||||
export const authMachine = setup({
|
||||
types: {
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
* → 派发 `AuthGoogleSyncSubmitted` / `AuthFacebookSyncSubmitted` 事件
|
||||
* → auth machine 调 `authRepository.googleLogin/facebookLogin` 换业务 token
|
||||
* → 业务 token 写入本地 storage
|
||||
* → **清掉 NextAuth session**(`signOut({ redirect: false })`):
|
||||
* idToken / accessToken 已交给后端换业务 token,浏览器侧不再需要保留 OAuth 凭证
|
||||
* 避免凭证在 cookie / sessionStorage 里持续暴露
|
||||
*
|
||||
* 此组件无可见 UI(返回 null),仅作为 NextAuth session 与 auth state
|
||||
* machine 之间的桥接器。挂在 RootProviders 内、AuthProvider 之后:
|
||||
@@ -16,7 +19,7 @@
|
||||
* </AuthProvider>
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
|
||||
import { useAuthDispatch, useAuthState } from "./auth-context";
|
||||
|
||||
@@ -35,20 +38,24 @@ export function OAuthSessionSync() {
|
||||
// 3) 已经有该 provider 的业务 token(防止重复 sync 刷后端 user 表)
|
||||
if (loginStatus === session.provider) return;
|
||||
|
||||
// 4) Google:派发 idToken sync 事件
|
||||
// 4) Google:派发 idToken sync 事件 → 清 NextAuth session
|
||||
if (session.provider === "google") {
|
||||
if (!session.idToken) return;
|
||||
dispatch({ type: "AuthGoogleSyncSubmitted", idToken: session.idToken });
|
||||
// OAuth token 已交给后端 —— 清掉 NextAuth session(fire-and-forget)
|
||||
// 清完后 useSession 返 null / unauthenticated → 下次 effect 早 return,不会重入
|
||||
void signOut({ redirect: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// 5) Facebook:派发 accessToken sync 事件
|
||||
// 5) Facebook:派发 accessToken sync 事件 → 清 NextAuth session
|
||||
if (session.provider === "facebook") {
|
||||
if (!session.accessToken) return;
|
||||
dispatch({
|
||||
type: "AuthFacebookSyncSubmitted",
|
||||
accessToken: session.accessToken,
|
||||
});
|
||||
void signOut({ redirect: false });
|
||||
}
|
||||
}, [status, session, loginStatus, dispatch]);
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* User 状态机:Actors(异步服务)
|
||||
*
|
||||
* 从 `user-machine.ts` 抽出的"Actors"段:
|
||||
* - `userInitActor`(fromPromise:读 UserStorage 并行 user + avatarUrl)
|
||||
* - `userFetchActor`(fromPromise:调 API + 持久化到本地)
|
||||
* - `userLogoutActor`(fromPromise:auth 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 回到 idle,context 是 null)
|
||||
* - onDone 赋 currentUser + avatarUrl 到 context
|
||||
*/
|
||||
export const userInitActor = fromPromise<InitData>(async () => readInitData());
|
||||
|
||||
// ============================================================
|
||||
// Fetch actor:调 API 拉当前用户 + 持久化
|
||||
// ============================================================
|
||||
/**
|
||||
* 调 `userRepo.getCurrentUser` 拉当前 user:
|
||||
* - 成功 → 转 UserView + 写本地(userStorage.setUser)→ 返回 View
|
||||
* - 失败 → 返回 null(machine 走 onDone,context 保持不变;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` action(machine 层)—— 失败也清本地
|
||||
*/
|
||||
export const userLogoutActor = fromPromise<void>(async () => {
|
||||
await authRepo.logout();
|
||||
await userStorage.clearUserData();
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* User 状态机:纯函数 + 数据加载
|
||||
*
|
||||
* 从 `user-machine.ts` 抽出的"Helpers"段:
|
||||
* - InitData 形状
|
||||
* - `toView`:DTO → UserView 映射(纯函数)
|
||||
* - `readInitData`:从 UserStorage 读 user + avatarUrl(并行)
|
||||
* - `userStorage` 单例:被 helpers 和 actors 共用
|
||||
*
|
||||
* 设计目标:
|
||||
* - helpers 无 XState 依赖(可独立 import / 测试)
|
||||
* - actors 依赖 helpers(import)
|
||||
* - machine 依赖 actors(import)—— 不直接依赖 helpers
|
||||
*/
|
||||
|
||||
import type { UserView } from "@/models/user/user";
|
||||
import { UserStorage } from "@/data/storage/user/user_storage";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
// ============================================================
|
||||
// Storage singleton
|
||||
// ============================================================
|
||||
/**
|
||||
* UserStorage 单例 —— 被 helpers(readInitData)和 actors(userFetchActor / 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 + avatarUrl(UserStorage 两个独立接口)。
|
||||
* - 任意一个失败 / 无数据 → 对应字段返回 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 };
|
||||
}
|
||||
@@ -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` actions(inline 在 setup() 中)
|
||||
* - 保持事件类型名与原 Dart 一致
|
||||
*
|
||||
* 分层:
|
||||
* - helpers: 无 XState 依赖(DTO 映射 + 数据加载)
|
||||
* - actors: 依赖 helpers(异步 actor 实现)
|
||||
* - machine: 依赖 actors(XState 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
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user