refactor(chat): remove vip websocket session flow
This commit is contained in:
+13
-327
@@ -5,34 +5,28 @@
|
||||
* chat 机器不感知鉴权 / 不管 WebSocket —— 由 <ChatAuthSync /> 派生 loginStatus
|
||||
* ChatAuthSync 派发登录态生命周期事件:
|
||||
* - `ChatGuestLogin` → 游客会话(断 WS = 不连)
|
||||
* - `ChatNonVipLogin { token }` → 非 VIP 用户会话(不连 WS)
|
||||
* - `ChatVipLogin { token }` → VIP 用户会话(连 WS)
|
||||
* - `ChatLogout` → 正式登录用户登出(机器自动 cleanup WS actor)
|
||||
* - `ChatUserLogin { token }` → 其他登录用户会话(不连 WS)
|
||||
* - `ChatLogout` → 正式登录用户登出
|
||||
*
|
||||
* 登录态流转约束:
|
||||
* - 未登录:可以进入游客登录 / 其他登录。
|
||||
* - 游客登录:只可以升级为其他登录,不响应退出。
|
||||
* - 其他登录:可以退出,也可以在非 VIP / VIP 间切换,不响应游客登录。
|
||||
* - 其他登录:可以退出,不响应游客登录。
|
||||
*
|
||||
* 状态结构(parent state 模式):
|
||||
* - `idle`:屏没挂 / 登出
|
||||
* - `guestSession`(parent):游客会话 —— 不 invoke WS
|
||||
* - `nonVipUserSession`(parent):非 VIP 用户会话 —— 不 invoke WS
|
||||
* - `vipUserSession`(parent):VIP 用户会话 —— invoke WS(持久)
|
||||
* - `userSession`(parent):其他登录用户会话 —— 不 invoke WS
|
||||
*
|
||||
* init 任务:
|
||||
* - guestSession.initializing:loadHistory
|
||||
* - nonVipUserSession.initializing:loadHistory
|
||||
* - vipUserSession.initializing:loadHistory + parent-level chatWebSocket 并行
|
||||
* - userSession.initializing:loadHistory
|
||||
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier)
|
||||
*
|
||||
* 消息发送路径(业务事实):
|
||||
* - `ChatSendMessage` 只负责即时追加用户消息 + 入队,不进入 sending 子状态
|
||||
* - guestSession / nonVipUserSession:队列串行走 HTTP,限制以后端 `lockDetail` 为准
|
||||
* - vipUserSession:队列优先走 WS,WS 不可用时 fallback HTTP
|
||||
* - guestSession / userSession:队列串行走 HTTP,限制以后端 `lockDetail` 为准
|
||||
* - `pendingReplyCount` 跟踪待回复数量,`isReplyingAI` 由计数派生
|
||||
* - `ChatWebSocketConnected` 事件 → `wsConnected = true`
|
||||
* - `vipUserSession.exit` action → `wsConnected = false`(WS actor cleanup 时也会跑 exit)
|
||||
*
|
||||
* 配额:
|
||||
* - 前端不再处理本地消息额度;游客 / 注册用户均以后端响应为准。
|
||||
@@ -50,17 +44,13 @@ import {
|
||||
applyHttpSendOutput,
|
||||
beginPendingReply,
|
||||
finishPendingReply,
|
||||
normalizeLockDetail,
|
||||
} from "./chat-machine.helpers";
|
||||
import {
|
||||
loadHistoryActor,
|
||||
sendMessageHttpActor,
|
||||
sendMessageWsActor,
|
||||
loadMoreHistoryActor,
|
||||
chatWebSocketActor,
|
||||
httpMessageQueueActor,
|
||||
unlockPrivateMessageActor,
|
||||
wsPreferredMessageQueueActor,
|
||||
} from "./chat-machine.actors";
|
||||
|
||||
const log = new Logger("StoresChatChatMachine");
|
||||
@@ -81,11 +71,8 @@ export const chatMachine = setup({
|
||||
actors: {
|
||||
loadHistory: loadHistoryActor,
|
||||
sendMessageHttp: sendMessageHttpActor,
|
||||
sendMessageWs: sendMessageWsActor,
|
||||
loadMoreHistory: loadMoreHistoryActor,
|
||||
chatWebSocket: chatWebSocketActor,
|
||||
httpMessageQueue: httpMessageQueueActor,
|
||||
wsPreferredMessageQueue: wsPreferredMessageQueueActor,
|
||||
unlockPrivateMessage: unlockPrivateMessageActor,
|
||||
},
|
||||
actions: {
|
||||
@@ -137,7 +124,6 @@ export const chatMachine = setup({
|
||||
log.debug("[chat-machine] appendUserMessage", {
|
||||
contentLength: event.content.length,
|
||||
contentPreview: event.content.slice(0, 50),
|
||||
wsConnected: context.wsConnected,
|
||||
isReplyingAI: context.isReplyingAI,
|
||||
});
|
||||
|
||||
@@ -189,7 +175,6 @@ export const chatMachine = setup({
|
||||
const today = todayString();
|
||||
log.debug("[chat-machine] appendUserImage", {
|
||||
oldMessagesCount: context.messages.length,
|
||||
wsConnected: context.wsConnected,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -210,43 +195,6 @@ export const chatMachine = setup({
|
||||
};
|
||||
}),
|
||||
|
||||
appendOrUpdateAISentence: assign(({ context, event }) => {
|
||||
if (event.type !== "ChatAISentenceReceived") return {};
|
||||
const messages = [...context.messages];
|
||||
if (event.index === 0) {
|
||||
messages.push({
|
||||
content: event.text,
|
||||
isFromAI: true,
|
||||
date: todayString(),
|
||||
});
|
||||
} else {
|
||||
const last = messages[messages.length - 1];
|
||||
if (last && last.isFromAI) {
|
||||
messages[messages.length - 1] = {
|
||||
...last,
|
||||
content: `${last.content} ${event.text}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
log.debug("[chat-machine] appendOrUpdateAISentence", {
|
||||
index: event.index,
|
||||
total: event.total,
|
||||
done: event.done,
|
||||
messagesCount: messages.length,
|
||||
});
|
||||
if (!event.done) {
|
||||
return {
|
||||
messages,
|
||||
pendingReplyCount: Math.max(1, context.pendingReplyCount),
|
||||
isReplyingAI: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
messages,
|
||||
...finishPendingReply(context),
|
||||
};
|
||||
}),
|
||||
|
||||
appendSocketErrorMessage: assign(({ context }) => {
|
||||
const messages = [
|
||||
...context.messages,
|
||||
@@ -266,61 +214,6 @@ export const chatMachine = setup({
|
||||
return applyHttpSendOutput(context, event.output);
|
||||
}),
|
||||
|
||||
appendAIImage: assign(({ context, event }) => {
|
||||
if (event.type !== "ChatImageReceived") return {};
|
||||
const messages = [...context.messages];
|
||||
const last = messages[messages.length - 1];
|
||||
|
||||
if (last?.isFromAI && !last.imageUrl) {
|
||||
messages[messages.length - 1] = {
|
||||
...last,
|
||||
content: "",
|
||||
imageUrl: event.url,
|
||||
};
|
||||
} else {
|
||||
messages.push({
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: todayString(),
|
||||
imageUrl: event.url,
|
||||
});
|
||||
}
|
||||
|
||||
log.debug("[chat-machine] appendAIImage", {
|
||||
messagesCount: messages.length,
|
||||
hasMergedIntoLastMessage: Boolean(last?.isFromAI && !last.imageUrl),
|
||||
});
|
||||
|
||||
return {
|
||||
messages,
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
upgradeHint: null,
|
||||
upgradeDetail: null,
|
||||
};
|
||||
}),
|
||||
|
||||
applyPaywallStatus: assign(({ event }) => {
|
||||
if (event.type !== "ChatPaywallStatusReceived") return {};
|
||||
if (!event.lockDetail.showUpgrade) {
|
||||
return {
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
upgradeHint: null,
|
||||
upgradeDetail: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
upgradePromptVisible: true,
|
||||
upgradeReason:
|
||||
event.lockDetail.reason === "private_message"
|
||||
? "private_message"
|
||||
: "image",
|
||||
upgradeHint: event.lockDetail.hint,
|
||||
upgradeDetail: normalizeLockDetail(event.lockDetail.detail),
|
||||
};
|
||||
}),
|
||||
|
||||
setUnlockingPrivateMessage: assign(({ event }) => {
|
||||
if (event.type !== "ChatUnlockPrivateMessage") return {};
|
||||
return {
|
||||
@@ -383,9 +276,6 @@ export const chatMachine = setup({
|
||||
clearUnlockingPrivateMessage: assign({
|
||||
unlockingPrivateMessageId: null,
|
||||
}),
|
||||
|
||||
setWsConnected: assign({ wsConnected: true }),
|
||||
clearWsConnected: assign({ wsConnected: false }),
|
||||
},
|
||||
}).createMachine({
|
||||
id: "chat",
|
||||
@@ -398,12 +288,8 @@ export const chatMachine = setup({
|
||||
target: "#chat.guestSession",
|
||||
actions: "startGuestSession",
|
||||
},
|
||||
ChatNonVipLogin: {
|
||||
target: "#chat.nonVipUserSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
ChatVipLogin: {
|
||||
target: "#chat.vipUserSession",
|
||||
ChatUserLogin: {
|
||||
target: "#chat.userSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
},
|
||||
@@ -415,12 +301,8 @@ export const chatMachine = setup({
|
||||
src: "httpMessageQueue",
|
||||
},
|
||||
on: {
|
||||
ChatNonVipLogin: {
|
||||
target: "#chat.nonVipUserSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
ChatVipLogin: {
|
||||
target: "#chat.vipUserSession",
|
||||
ChatUserLogin: {
|
||||
target: "#chat.userSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
ChatQueuedSendStarted: {
|
||||
@@ -517,7 +399,7 @@ export const chatMachine = setup({
|
||||
},
|
||||
},
|
||||
|
||||
nonVipUserSession: {
|
||||
userSession: {
|
||||
invoke: {
|
||||
id: "messageQueue",
|
||||
src: "httpMessageQueue",
|
||||
@@ -527,15 +409,11 @@ export const chatMachine = setup({
|
||||
target: "#chat.idle",
|
||||
actions: "clearChatSession",
|
||||
},
|
||||
ChatNonVipLogin: {
|
||||
target: "#chat.nonVipUserSession",
|
||||
ChatUserLogin: {
|
||||
target: "#chat.userSession",
|
||||
reenter: true,
|
||||
actions: "startUserSession",
|
||||
},
|
||||
ChatVipLogin: {
|
||||
target: "#chat.vipUserSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
ChatQueuedSendStarted: {
|
||||
actions: "markQueuedSendStarted",
|
||||
},
|
||||
@@ -644,198 +522,6 @@ export const chatMachine = setup({
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
vipUserSession: {
|
||||
// 父级 on:把 WS / AI 流句 事件从 ready.on 上提到 vipUserSession.on
|
||||
// —— 任何 child state(initializing / ready / sendingViaWs / sendingViaHttp / loadingMore)
|
||||
// 都能收到 `ChatAISentenceReceived` 并把 AI 句 push 到 messages。
|
||||
//
|
||||
// 子级 override 规则:
|
||||
// - sendingViaWs 重新声明 `ChatWebSocketError` 加 target: "ready"(带 action)
|
||||
// —— XState v5: child handler 替换 parent;复制 action
|
||||
on: {
|
||||
ChatLogout: {
|
||||
target: "#chat.idle",
|
||||
actions: "clearChatSession",
|
||||
},
|
||||
ChatNonVipLogin: {
|
||||
target: "#chat.nonVipUserSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
ChatVipLogin: {
|
||||
target: "#chat.vipUserSession",
|
||||
reenter: true,
|
||||
actions: "startUserSession",
|
||||
},
|
||||
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
|
||||
ChatImageReceived: { actions: "appendAIImage" },
|
||||
ChatPaywallStatusReceived: { actions: "applyPaywallStatus" },
|
||||
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
|
||||
ChatWebSocketConnected: { actions: "setWsConnected" },
|
||||
ChatQueuedSendStarted: {
|
||||
actions: "markQueuedSendStarted",
|
||||
},
|
||||
ChatQueuedHttpDone: {
|
||||
actions: "applyQueuedHttpOutput",
|
||||
},
|
||||
ChatQueuedSendError: {
|
||||
actions: "appendSocketErrorMessage",
|
||||
},
|
||||
},
|
||||
initial: "initializing",
|
||||
exit: "clearWsConnected",
|
||||
invoke: [
|
||||
{
|
||||
// 父级:WS 长连接(一进来就连,跨 initializing/ready/loadingMore)
|
||||
src: "chatWebSocket",
|
||||
input: ({ event }) => ({
|
||||
token: event.type === "ChatVipLogin" ? event.token : "",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "messageQueue",
|
||||
src: "wsPreferredMessageQueue",
|
||||
},
|
||||
],
|
||||
states: {
|
||||
initializing: {
|
||||
// barrier:WS 连上 + history 加载完成才进 ready
|
||||
always: [
|
||||
{
|
||||
target: "ready",
|
||||
guard: ({ context }) => context.wsConnected && context.historyLoaded,
|
||||
},
|
||||
],
|
||||
invoke: {
|
||||
// 任务 2:拉 history(只调用 loadHistoryActor,不再用 loadMoreHistoryActor)
|
||||
src: "loadHistory",
|
||||
onDone: {
|
||||
actions: assign(({ event }) => ({
|
||||
messages: event.output.messages,
|
||||
isLoadingMore: false,
|
||||
hasMore: event.output.hasMore,
|
||||
historyOffset: event.output.newOffset,
|
||||
historyLoaded: true,
|
||||
})),
|
||||
},
|
||||
onError: {
|
||||
// 失败也标 loaded,不卡 init
|
||||
actions: assign({
|
||||
isLoadingMore: false,
|
||||
historyLoaded: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
ready: {
|
||||
on: {
|
||||
ChatSendMessage: {
|
||||
guard: ({ event }) => event.content.trim().length > 0,
|
||||
actions: ["appendUserMessage", "enqueueMessage"],
|
||||
},
|
||||
ChatSendImage: {
|
||||
actions: "appendUserImage",
|
||||
},
|
||||
ChatLoadMoreHistory: {
|
||||
target: "loadingMore",
|
||||
},
|
||||
ChatUnlockPrivateMessage: {
|
||||
actions: "setUnlockingPrivateMessage",
|
||||
target: "unlockingPrivate",
|
||||
},
|
||||
// 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected
|
||||
// 已上提到 vipUserSession.on,子级不再声明
|
||||
},
|
||||
},
|
||||
// WS 发送:invoke sendMessageWsActor 触发一次 send,
|
||||
// 之后 AI 流式回 reply 走 vipUserSession.on 的 ChatAISentenceReceived → appendOrUpdateAISentence
|
||||
// 当句尾 done: true → isReplyingAI = false → always 跳回 ready
|
||||
sendingViaWs: {
|
||||
on: {
|
||||
// 流式回包中 WS 出错(mid-stream) → 加错误泡 + 跳回 ready(不卡在 sendingViaWs)
|
||||
// 这个 child handler 替换 vipUserSession.on 的 ChatWebSocketError(XState v5 不合并)
|
||||
// 所以需要重复声明 action: "appendSocketErrorMessage"
|
||||
ChatWebSocketError: {
|
||||
target: "ready",
|
||||
actions: "appendSocketErrorMessage",
|
||||
},
|
||||
},
|
||||
invoke: {
|
||||
src: "sendMessageWs",
|
||||
input: ({ event }) => ({
|
||||
content: event.type === "ChatSendMessage" ? event.content : "",
|
||||
}),
|
||||
onError: {
|
||||
// sendMessageWsActor 自身抛错(无 active ws / ws not OPEN) → 回 ready + 清 typing
|
||||
target: "ready",
|
||||
actions: assign({ isReplyingAI: false }),
|
||||
},
|
||||
},
|
||||
// 监听 isReplyingAI —— appendOrUpdateAISentence 在 done: true 时设 false
|
||||
always: [
|
||||
{
|
||||
target: "ready",
|
||||
guard: ({ context }) => !context.isReplyingAI,
|
||||
},
|
||||
],
|
||||
},
|
||||
// HTTP 发送:一次发送 = 一次返回(保留原 sending 状态语义,guest 与 ws-down fallback 走此)
|
||||
sendingViaHttp: {
|
||||
invoke: {
|
||||
src: "sendMessageHttp",
|
||||
input: ({ event }) => ({
|
||||
content: event.type === "ChatSendMessage" ? event.content : "",
|
||||
}),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ context, event }) =>
|
||||
applyHttpSendOutput(context, event.output),
|
||||
),
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: assign({ isReplyingAI: false }),
|
||||
},
|
||||
},
|
||||
},
|
||||
loadingMore: {
|
||||
invoke: {
|
||||
src: "loadMoreHistory",
|
||||
input: ({ context }) => ({ offset: context.historyOffset }),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ context, event }) => ({
|
||||
messages: [...event.output.messages, ...context.messages],
|
||||
isLoadingMore: false,
|
||||
hasMore: event.output.hasMore,
|
||||
historyOffset: event.output.newOffset,
|
||||
})),
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: assign({ isLoadingMore: false }),
|
||||
},
|
||||
},
|
||||
},
|
||||
unlockingPrivate: {
|
||||
invoke: {
|
||||
src: "unlockPrivateMessage",
|
||||
input: ({ event }) => ({
|
||||
messageId:
|
||||
event.type === "ChatUnlockPrivateMessage" ? event.messageId : "",
|
||||
}),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: "applyUnlockPrivateOutput",
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: "clearUnlockingPrivateMessage",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user