refactor(chat): decouple chat state from auth and gate quota alerts to guests

- Remove ChatAuthStatusChanged dispatch from auth login flow; chat screen now subscribes directly to auth state machine (loginStatus) to derive isGuest and drive WS lifecycle
- Gate quota-exceeded and warning dialogs to guests only (non-guests have no message limit)
- Dispatch ChatLoadMoreHistory for non-guest initial load vs ChatInit for guests
- Fix AppConstants import path from @/core/constants/app_constants to @/core/app_constants
- Add defensive isGuest checks in quota effects to prevent non-guest quota triggers
This commit is contained in:
2026-06-11 18:31:08 +08:00
parent e3d069e49f
commit ed4ccddae3
11 changed files with 233 additions and 63 deletions
+8 -5
View File
@@ -8,8 +8,8 @@
* 兼容性:保持原 `useChatState()` / `useChatDispatch()` API。
* 内部将 XState 状态机快照映射回原 `ChatState` 形状。
*
* 注:WebSocket 长生命周期 actor 暂未集成到状态机(保留在 chat-side-effects.ts
* 由外部 chat-side-effects 模块管理),待下一轮迁移
* 注:WebSocket 长生命周期 actor **由 chat-screen.tsx 监听 auth 状态机驱动**——
* 鉴权解耦(loginStatus 在 authchat 机器**不**感知鉴权)
*/
import {
type Dispatch,
@@ -24,13 +24,17 @@ import { chatMachine } from "./chat-machine";
import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
/**
* 对外暴露的 State 形状(保持与原 ChatState 兼容)
* 对外暴露的 State 形状
*
* **isGuest 字段已删** —— 由 chat-screen 派生自 `auth.loginStatus === "guest"`。
* 消费方(chat-screen)通过 `useAuthState()` 取 loginStatus,本地派生 isGuest。
*/
interface ChatState {
messages: MachineContext["messages"];
isReplyingAI: boolean;
isGuest: boolean;
/** 游客剩余配额(次/天)—— 业务展示用,鉴权判断由 chat-screen 派生 loginStatus */
guestRemainingQuota: number;
/** 游客总配额 */
guestTotalQuota: number;
quotaExceededTrigger: number;
isLoadingMore: boolean;
@@ -53,7 +57,6 @@ export function ChatProvider({ children }: ChatProviderProps) {
() => ({
messages: state.context.messages,
isReplyingAI: state.context.isReplyingAI,
isGuest: state.context.isGuest,
guestRemainingQuota: state.context.guestRemainingQuota,
guestTotalQuota: state.context.guestTotalQuota,
quotaExceededTrigger: state.context.quotaExceededTrigger,
+5 -2
View File
@@ -2,6 +2,10 @@
* Chat 状态机:事件联合
*
* 事件名与原 Dart ChatEvent 对齐。
*
* 历史:原本有 `ChatAuthStatusChanged`chat 机器刷新 isGuest)—— 已**删**。
* 鉴权状态变化(loginStatus)由 chat-screen 通过 `useAuthState()` 订阅,
* chat 机器**不**感知鉴权。
*/
export type ChatEvent =
| { type: "ChatInit" }
@@ -19,5 +23,4 @@ export type ChatEvent =
}
| { type: "ChatWebSocketError"; errorMessage: string }
| { type: "ChatWebSocketConnected"; userId: string }
| { type: "ChatQuotaExceeded" }
| { type: "ChatAuthStatusChanged" };
| { type: "ChatQuotaExceeded" };
+32 -19
View File
@@ -13,6 +13,16 @@
* - HTTP 操作用 `fromPromise` actor(一次性 Promise
* - 保持事件类型名与原 Dart 一致,便于业务层迁移对照
* - actions 全部 inline 在 setup() 中(确保类型推断正确)
*
* **鉴权解耦**
* chat 机器**不**感知鉴权状态 —— `isGuest` 字段已删(由 chat-screen 派生自 auth.loginStatus)。
* WebSocket 管理也由 chat-screen 监听 auth 状态机后驱动。
*
* **配额(**仅**游客)**
* - `guestRemainingQuota` / `guestTotalQuota` **仅**游客有意义
* - 非游客"无消息限制"(业务事实)—— 永不被赋值
* - chat-screen 派 `ChatInit` 时(**只**在 guest)→ onDone 条件赋
* - `appendUserMessage` 减 `Math.max(0, q - 1)` —— 非游客 0-1=0 天然 no-op
*/
import { setup, fromPromise, assign } from "xstate";
@@ -85,6 +95,8 @@ interface InitResult {
async function readInitData(): Promise<InitResult> {
const chatStorage = ChatStorage.getInstance();
// isGuest 在 chat 业务层仅用于"是否加载本地历史" + "onDone 条件赋配额" —— **不**是鉴权信号
// 鉴权判断已上提到 chat-screen 派生
const isGuest = !AuthStorage.getInstance().hasLoginToken();
const [dailyResult, totalResult, localResult] = await Promise.all([
@@ -95,13 +107,14 @@ async function readInitData(): Promise<InitResult> {
return {
isGuest,
// fallback 0 —— 非游客路径用不到;onDone 会**条件**赋
guestRemainingQuota: mapQuotaResult(
dailyResult as Result<{ remaining: number } | null>,
40,
0,
),
guestTotalQuota: mapTotalQuotaResult(
totalResult as Result<number | null>,
50,
0,
),
localMessages:
localResult && Result.isOk(localResult) && localResult.data
@@ -145,8 +158,7 @@ const loadMoreHistoryActor = fromPromise<
};
});
// ============================================================
// Machine
// ============================================================// Machine
// ============================================================
export const chatMachine = setup({
types: {
@@ -171,6 +183,8 @@ export const chatMachine = setup({
},
],
isReplyingAI: true,
// 游客减配额;非游客 0 - 1 = max(0, -1) = 0 —— **天然 no-op**(业务事实"非游客无限制")
guestRemainingQuota: Math.max(0, context.guestRemainingQuota - 1),
};
}),
@@ -187,6 +201,8 @@ export const chatMachine = setup({
},
],
isReplyingAI: true,
// 游客减配额;非游客 no-op
guestRemainingQuota: Math.max(0, context.guestRemainingQuota - 1),
};
}),
@@ -223,10 +239,6 @@ export const chatMachine = setup({
return { messages, isReplyingAI: false };
}),
refreshAuthStatus: assign(() => ({
isGuest: !AuthStorage.getInstance().hasLoginToken(),
})),
incrementQuotaExceeded: assign(({ context }) => ({
quotaExceededTrigger: context.quotaExceededTrigger + 1,
})),
@@ -248,9 +260,6 @@ export const chatMachine = setup({
ChatSendImage: {
actions: "appendUserImage",
},
ChatAuthStatusChanged: {
actions: "refreshAuthStatus",
},
ChatAISentenceReceived: {
actions: "appendOrUpdateAISentence",
},
@@ -269,11 +278,18 @@ export const chatMachine = setup({
src: "chatInit",
onDone: {
target: "ready",
actions: assign({
messages: ({ event }) => event.output.localMessages,
isGuest: ({ event }) => event.output.isGuest,
guestRemainingQuota: ({ event }) => event.output.guestRemainingQuota,
guestTotalQuota: ({ event }) => event.output.guestTotalQuota,
// **条件赋**:仅 isGuest 时初始化配额(业务事实"非游客无消息限制")
// - 游客:messages + quota 都更新
// - 非游客:仅 messageslocal 空),quota 保持 default 0(不更新其值)
actions: assign(({ event }) => {
const updates: Partial<ChatState> = {
messages: event.output.localMessages,
};
if (event.output.isGuest) {
updates.guestRemainingQuota = event.output.guestRemainingQuota;
updates.guestTotalQuota = event.output.guestTotalQuota;
}
return updates;
}),
},
onError: {
@@ -293,9 +309,6 @@ export const chatMachine = setup({
},
ChatLoadMoreHistory: "loadingMoreHistory",
ChatScreenInvisible: "idle",
ChatAuthStatusChanged: {
actions: "refreshAuthStatus",
},
ChatAISentenceReceived: {
actions: "appendOrUpdateAISentence",
},
+15 -4
View File
@@ -2,14 +2,26 @@
* Chat 状态机:State 形状 + 初始值
*
* 注:GuestChatQuota 仍位于 chat-machine.ts(属于业务常量,与上下文配对紧密)。
*
* 历史:
* - `isGuest: boolean` 字段已**删**(由 chat-screen 派生自 `auth.loginStatus === "guest"`
* - `guestRemainingQuota` / `guestTotalQuota` default **改 0**
* - 游客:chat-screen 派 `ChatInit` → readInitData 调 ChatStorage → onDone 条件赋
* - 非游客:**永不被赋值**(业务事实"非游客无消息限制")—— 0 - 1 = max(0, -1) = 0 天然 no-op
* - 0 同时表示"未初始化"和"已耗尽" —— UI 用 `if (isGuest && ...)` 天然过滤
*/
import type { UiMessage } from "@/models/chat/ui-message";
export interface ChatState {
messages: UiMessage[];
isReplyingAI: boolean;
isGuest: boolean;
/**
* 游客剩余配额(次/天)—— **仅**游客业务展示用
* - default 0 = "未初始化" / "已耗尽"(同码,靠 `isGuest` 区分)
* - 非游客:永不被赋值(业务事实"无消息限制")
*/
guestRemainingQuota: number;
/** 游客总配额(仅游客展示用) */
guestTotalQuota: number;
quotaExceededTrigger: number;
isLoadingMore: boolean;
@@ -20,9 +32,8 @@ export interface ChatState {
export const initialState: ChatState = {
messages: [],
isReplyingAI: false,
isGuest: true,
guestRemainingQuota: 40,
guestTotalQuota: 50,
guestRemainingQuota: 0,
guestTotalQuota: 0,
quotaExceededTrigger: 0,
isLoadingMore: false,
hasMore: true,