From a44463b3ee4df2e9682d4aee2f8219239a3ce653 Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 16 Jun 2026 18:12:11 +0800 Subject: [PATCH 1/3] refactor(user): split user-machine into helpers and actors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/stores/user/user-machine.actors.ts | 74 ++++++++++++++++ src/stores/user/user-machine.helpers.ts | 105 +++++++++++++++++++++++ src/stores/user/user-machine.ts | 109 +++--------------------- 3 files changed, 193 insertions(+), 95 deletions(-) create mode 100644 src/stores/user/user-machine.actors.ts create mode 100644 src/stores/user/user-machine.helpers.ts diff --git a/src/stores/user/user-machine.actors.ts b/src/stores/user/user-machine.actors.ts new file mode 100644 index 00000000..2ae19b6f --- /dev/null +++ b/src/stores/user/user-machine.actors.ts @@ -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(async () => readInitData()); + +// ============================================================ +// Fetch actor:调 API 拉当前用户 + 持久化 +// ============================================================ +/** + * 调 `userRepo.getCurrentUser` 拉当前 user: + * - 成功 → 转 UserView + 写本地(userStorage.setUser)→ 返回 View + * - 失败 → 返回 null(machine 走 onDone,context 保持不变;onError 兜底) + */ +export const userFetchActor = fromPromise(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(async () => { + await authRepo.logout(); + await userStorage.clearUserData(); +}); \ No newline at end of file diff --git a/src/stores/user/user-machine.helpers.ts b/src/stores/user/user-machine.helpers.ts new file mode 100644 index 00000000..647acd70 --- /dev/null +++ b/src/stores/user/user-machine.helpers.ts @@ -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 { + 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 }; +} \ No newline at end of file diff --git a/src/stores/user/user-machine.ts b/src/stores/user/user-machine.ts index 2426baf6..4e33e8a8 100644 --- a/src/stores/user/user-machine.ts +++ b/src/stores/user/user-machine.ts @@ -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 { - 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(async () => readInitData()); - -const userFetchActor = fromPromise(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(async () => { - await authRepo.logout(); - await userStorage.clearUserData(); -}); - // ============================================================ // Machine // ============================================================ @@ -220,4 +139,4 @@ export const userMachine = setup({ }, }); -export type UserMachine = typeof userMachine; +export type UserMachine = typeof userMachine; \ No newline at end of file From 39e7d61c8a6d94542d144e144305df9b8c6efa71 Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 16 Jun 2026 18:24:23 +0800 Subject: [PATCH 2/3] fix(auth): re-check auth status after logout; clear NextAuth session after sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Sidebar logout flow: - After AuthReset, also dispatch AuthStatusCheckSubmitted so the auth machine re-verifies storage (not just resets to initialState). This makes sure the redirect to /chat reflects the actual storage state, not just the forcibly-cleared context. 2) OAuthSessionSync: - After dispatching AuthGoogleSyncSubmitted / AuthFacebookSyncSubmitted, call signOut({ redirect: false }) to drop the NextAuth session. The OAuth idToken / accessToken has already been handed to the backend; clearing the browser-side session prevents lingering OAuth credential exposure. Fire-and-forget — once status flips to "unauthenticated", the useEffect early-returns so no re-entry. Note: auth-machine.ts lost a 3-line stale comment block about XState v5 type inference for inline assign; bundled with this commit. --- src/app/sidebar/components/sidebar-screen.tsx | 5 +++++ src/stores/auth/auth-machine.ts | 3 --- src/stores/auth/oauth-session-sync.tsx | 13 ++++++++++--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/app/sidebar/components/sidebar-screen.tsx b/src/app/sidebar/components/sidebar-screen.tsx index c542d118..5f67b668 100644 --- a/src/app/sidebar/components/sidebar-screen.tsx +++ b/src/app/sidebar/components/sidebar-screen.tsx @@ -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]); diff --git a/src/stores/auth/auth-machine.ts b/src/stores/auth/auth-machine.ts index bc0b1df4..cc0c8e4e 100644 --- a/src/stores/auth/auth-machine.ts +++ b/src/stores/auth/auth-machine.ts @@ -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: { diff --git a/src/stores/auth/oauth-session-sync.tsx b/src/stores/auth/oauth-session-sync.tsx index 8d228ce5..2dec85e0 100644 --- a/src/stores/auth/oauth-session-sync.tsx +++ b/src/stores/auth/oauth-session-sync.tsx @@ -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 @@ * */ 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]); From 8832552321d689d16761a9b8a68932c495ea6878 Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 16 Jun 2026 18:05:03 +0800 Subject: [PATCH 3/3] fix(githooks): write log timestamps in local time (was UTC + Z) --- .githooks/post-receive | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.githooks/post-receive b/.githooks/post-receive index a04aaf6c..83ec4045 100755 --- a/.githooks/post-receive +++ b/.githooks/post-receive @@ -32,7 +32,7 @@ echo "=== REPO_TOPLEVEL=$REPO_TOPLEVEL (GIT_DIR=$GIT_DIR) ===" >&2 # 的 build 用上新代码。 # ============================================================ sync_worktree() { - echo "=== sync_worktree: git reset --hard HEAD @ $(date -u +%Y-%m-%dT%H:%M:%SZ) ===" + echo "=== sync_worktree: git reset --hard HEAD @ $(date +%Y-%m-%dT%H:%M:%S%z) ===" git reset --hard HEAD } @@ -49,7 +49,7 @@ init_log_file() { # ============================================================ write_metadata_header() { { - echo "=== post-receive @ $(date -u +%Y-%m-%dT%H:%M:%SZ) ===" + echo "=== post-receive @ $(date +%Y-%m-%dT%H:%M:%S%z) ===" echo "branch: $(git rev-parse --abbrev-ref HEAD)" echo "commit: $(git rev-parse --short HEAD)" echo "cwd: $(pwd)" @@ -90,10 +90,10 @@ copy_env_by_branch() { # 返回:build 退出码(main 用 if ! run_build 决定是否继续) # ============================================================ run_build() { - echo "=== build START @ $(date -u +%Y-%m-%dT%H:%M:%SZ) ===" >> "$LOG_FILE" + echo "=== build START @ $(date +%Y-%m-%dT%H:%M:%S%z) ===" >> "$LOG_FILE" pnpm run build > /dev/null 2>&1 BUILD_EXIT=$? - echo "=== build EXIT_CODE=$BUILD_EXIT @ $(date -u +%Y-%m-%dT%H:%M:%SZ) ===" >> "$LOG_FILE" + echo "=== build EXIT_CODE=$BUILD_EXIT @ $(date +%Y-%m-%dT%H:%M:%S%z) ===" >> "$LOG_FILE" echo "" >> "$LOG_FILE" return $BUILD_EXIT } @@ -106,14 +106,14 @@ run_build() { # 4) 等 1s # ============================================================ stop_existing_next() { - echo "=== stop existing next @ $(date -u +%Y-%m-%dT%H:%M:%SZ) ===" >> "$LOG_FILE" + echo "=== stop existing next @ $(date +%Y-%m-%dT%H:%M:%S%z) ===" >> "$LOG_FILE" pkill -f "next start" 2>/dev/null pkill -f "next-server" 2>/dev/null sleep 2 pkill -9 -f "next start" 2>/dev/null pkill -9 -f "next-server" 2>/dev/null sleep 1 - echo "=== stop DONE @ $(date -u +%Y-%m-%dT%H:%M:%SZ) ===" >> "$LOG_FILE" + echo "=== stop DONE @ $(date +%Y-%m-%dT%H:%M:%S%z) ===" >> "$LOG_FILE" echo "" >> "$LOG_FILE" } @@ -123,17 +123,17 @@ stop_existing_next() { launch_next() { # next stdout/stderr 接到 logs/next-server.log(覆盖模式,每次 deploy 重新开始) NEXT_LOG_FILE="$REPO_TOPLEVEL/logs/next-server.log" - echo "=== start LAUNCH @ $(date -u +%Y-%m-%dT%H:%M:%SZ) ===" >> "$LOG_FILE" + echo "=== start LAUNCH @ $(date +%Y-%m-%dT%H:%M:%S%z) ===" >> "$LOG_FILE" nohup pnpm run start > "$NEXT_LOG_FILE" 2>&1 & START_PID=$! - echo "=== start PID=$START_PID @ $(date -u +%Y-%m-%dT%H:%M:%SZ) ===" >> "$LOG_FILE" + echo "=== start PID=$START_PID @ $(date +%Y-%m-%dT%H:%M:%S%z) ===" >> "$LOG_FILE" } # ============================================================ # 7. log_abort —— build 失败时写结束标记(不输出 START LAUNCH) # ============================================================ log_abort() { - echo "=== ABORTED @ $(date -u +%Y-%m-%dT%H:%M:%SZ) (build failed, start skipped) ===" >> "$LOG_FILE" + echo "=== ABORTED @ $(date +%Y-%m-%dT%H:%M:%S%z) (build failed, start skipped) ===" >> "$LOG_FILE" } # ============================================================