refactor(chat): split sessions by vip status
This commit is contained in:
+207
-58
@@ -3,27 +3,30 @@
|
||||
*
|
||||
* 鉴权解耦(事件驱动):
|
||||
* chat 机器不感知鉴权 / 不管 WebSocket —— 由 <ChatAuthSync /> 派生 loginStatus
|
||||
* ChatAuthSync 只派 3 个事件:
|
||||
* ChatAuthSync 派发登录态生命周期事件:
|
||||
* - `ChatGuestLogin` → 游客会话(断 WS = 不连)
|
||||
* - `ChatNonGuestLogin { token }` → 非游客会话(连 WS)
|
||||
* - `ChatNonVipLogin { token }` → 非 VIP 用户会话(不连 WS)
|
||||
* - `ChatVipLogin { token }` → VIP 用户会话(连 WS)
|
||||
* - `ChatLogout` → 登出(机器自动 cleanup WS actor)
|
||||
*
|
||||
* 状态结构(parent state 模式):
|
||||
* - `idle`:屏没挂 / 登出
|
||||
* - `guestSession`(parent):游客会话 —— 不 invoke WS
|
||||
* - `userSession`(parent):非游客会话 —— invoke WS(持久)
|
||||
* - `nonVipUserSession`(parent):非 VIP 用户会话 —— 不 invoke WS
|
||||
* - `vipUserSession`(parent):VIP 用户会话 —— invoke WS(持久)
|
||||
*
|
||||
* init 双任务(核心重构):
|
||||
* - guestSession.initializing:loadQuota + loadHistory 并行(`invoke: [...]` 数组)
|
||||
* - userSession.initializing:loadHistory + parent-level chatWebSocket 并行
|
||||
* - nonVipUserSession.initializing:loadHistory
|
||||
* - vipUserSession.initializing:loadHistory + parent-level chatWebSocket 并行
|
||||
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier)
|
||||
*
|
||||
* 消息发送路径(业务事实):
|
||||
* - guestSession:走 HTTP,并先检查本地游客配额
|
||||
* - userSession + VIP + WS 已连:走 WS,不受每日免费次数限制
|
||||
* - userSession + 非 VIP:走 HTTP,让后端返回 daily_limit blocked
|
||||
* - nonVipUserSession:走 HTTP,让后端返回 daily_limit blocked
|
||||
* - vipUserSession + WS 已连:走 WS,不受每日免费次数限制
|
||||
* - `ChatWebSocketConnected` 事件 → `wsConnected = true`
|
||||
* - `userSession.exit` action → `wsConnected = false`(WS actor cleanup 时也会跑 exit)
|
||||
* - `vipUserSession.exit` action → `wsConnected = false`(WS actor cleanup 时也会跑 exit)
|
||||
*
|
||||
* 配额:
|
||||
* - 游客:保留本地配额 guard,命中后不发送消息
|
||||
@@ -75,31 +78,22 @@ export const chatMachine = setup({
|
||||
actions: {
|
||||
startGuestSession: assign(() => ({
|
||||
...initialState,
|
||||
isVip: false,
|
||||
})),
|
||||
|
||||
startUserSession: assign(({ event }) => {
|
||||
if (event.type !== "ChatNonGuestLogin") return {};
|
||||
return {
|
||||
...initialState,
|
||||
isVip: event.isVip,
|
||||
};
|
||||
}),
|
||||
startUserSession: assign(() => ({
|
||||
...initialState,
|
||||
})),
|
||||
|
||||
clearChatSession: assign(() => ({
|
||||
...initialState,
|
||||
})),
|
||||
|
||||
appendUserMessage: assign(({ context, event }) => {
|
||||
appendGuestUserMessage: assign(({ context, event }) => {
|
||||
if (event.type !== "ChatSendMessage") return {};
|
||||
|
||||
// 两个额度同步更新:WS 已连不递减(实时会话不消耗),HTTP 必须减
|
||||
const newRemaining = context.wsConnected
|
||||
? context.guestRemainingQuota
|
||||
: Math.max(0, context.guestRemainingQuota - 1);
|
||||
const newTotal = context.wsConnected
|
||||
? context.guestTotalQuota
|
||||
: Math.max(0, context.guestTotalQuota - 1);
|
||||
// 游客消息发送成功前先乐观扣减本地日配额 + 总配额。
|
||||
const newRemaining = Math.max(0, context.guestRemainingQuota - 1);
|
||||
const newTotal = Math.max(0, context.guestTotalQuota - 1);
|
||||
|
||||
// 持久化两个额度( fire-and-forget,不 await —— assign 是 sync)
|
||||
// daily quota 需要 today 字符串(让 ChatStorage 跨天判断 reset)
|
||||
@@ -108,10 +102,10 @@ export const chatMachine = setup({
|
||||
|
||||
ChatStorage.getInstance().setGuestTotalQuota(newTotal);
|
||||
|
||||
log.debug("[chat-machine] appendUserMessage", {
|
||||
log.debug("[chat-machine] appendGuestUserMessage", {
|
||||
contentLength: event.content.length,
|
||||
contentPreview: event.content.slice(0, 50),
|
||||
quotaMode: context.wsConnected ? "ws (no decrement)" : "http (decrement)",
|
||||
quotaMode: "guest http (decrement)",
|
||||
oldRemaining: context.guestRemainingQuota,
|
||||
newRemaining,
|
||||
oldTotal: context.guestTotalQuota,
|
||||
@@ -137,25 +131,47 @@ export const chatMachine = setup({
|
||||
};
|
||||
}),
|
||||
|
||||
appendUserImage: assign(({ context, event }) => {
|
||||
appendUserMessage: assign(({ context, event }) => {
|
||||
if (event.type !== "ChatSendMessage") return {};
|
||||
|
||||
const today = todayString();
|
||||
log.debug("[chat-machine] appendUserMessage", {
|
||||
contentLength: event.content.length,
|
||||
contentPreview: event.content.slice(0, 50),
|
||||
wsConnected: context.wsConnected,
|
||||
isReplyingAI: true,
|
||||
});
|
||||
|
||||
return {
|
||||
messages: [
|
||||
...context.messages,
|
||||
{
|
||||
content: event.content,
|
||||
isFromAI: false,
|
||||
date: today,
|
||||
},
|
||||
],
|
||||
isReplyingAI: true,
|
||||
paywallTriggered: false,
|
||||
paywallReason: null,
|
||||
paywallDetail: null,
|
||||
};
|
||||
}),
|
||||
|
||||
appendGuestUserImage: assign(({ context, event }) => {
|
||||
if (event.type !== "ChatSendImage") return {};
|
||||
// 同 appendUserMessage:两个额度同步减
|
||||
const newRemaining = context.wsConnected
|
||||
? context.guestRemainingQuota
|
||||
: Math.max(0, context.guestRemainingQuota - 1);
|
||||
const newTotal = context.wsConnected
|
||||
? context.guestTotalQuota
|
||||
: Math.max(0, context.guestTotalQuota - 1);
|
||||
// 同 appendGuestUserMessage:游客两个额度同步减
|
||||
const newRemaining = Math.max(0, context.guestRemainingQuota - 1);
|
||||
const newTotal = Math.max(0, context.guestTotalQuota - 1);
|
||||
|
||||
const today = todayString();
|
||||
void ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today);
|
||||
|
||||
void ChatStorage.getInstance().setGuestTotalQuota(newTotal);
|
||||
|
||||
log.debug("[chat-machine] appendUserImage", {
|
||||
log.debug("[chat-machine] appendGuestUserImage", {
|
||||
oldMessagesCount: context.messages.length,
|
||||
wsConnected: context.wsConnected,
|
||||
quotaMode: context.wsConnected ? "ws (no decrement)" : "http (decrement)",
|
||||
quotaMode: "guest http (decrement)",
|
||||
oldRemaining: context.guestRemainingQuota,
|
||||
newRemaining,
|
||||
oldTotal: context.guestTotalQuota,
|
||||
@@ -180,6 +196,32 @@ export const chatMachine = setup({
|
||||
};
|
||||
}),
|
||||
|
||||
appendUserImage: assign(({ context, event }) => {
|
||||
if (event.type !== "ChatSendImage") return {};
|
||||
|
||||
const today = todayString();
|
||||
log.debug("[chat-machine] appendUserImage", {
|
||||
oldMessagesCount: context.messages.length,
|
||||
wsConnected: context.wsConnected,
|
||||
});
|
||||
|
||||
return {
|
||||
messages: [
|
||||
...context.messages,
|
||||
{
|
||||
content: "[Image]",
|
||||
isFromAI: false,
|
||||
date: today,
|
||||
imageUrl: event.imageBase64,
|
||||
},
|
||||
],
|
||||
isReplyingAI: true,
|
||||
paywallTriggered: false,
|
||||
paywallReason: null,
|
||||
paywallDetail: null,
|
||||
};
|
||||
}),
|
||||
|
||||
appendOrUpdateAISentence: assign(({ context, event }) => {
|
||||
if (event.type !== "ChatAISentenceReceived") return {};
|
||||
const messages = [...context.messages];
|
||||
@@ -237,8 +279,12 @@ export const chatMachine = setup({
|
||||
target: "#chat.guestSession",
|
||||
actions: "startGuestSession",
|
||||
},
|
||||
ChatNonGuestLogin: {
|
||||
target: "#chat.userSession",
|
||||
ChatNonVipLogin: {
|
||||
target: "#chat.nonVipUserSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
ChatVipLogin: {
|
||||
target: "#chat.vipUserSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
},
|
||||
@@ -250,8 +296,12 @@ export const chatMachine = setup({
|
||||
target: "#chat.idle",
|
||||
actions: "clearChatSession",
|
||||
},
|
||||
ChatNonGuestLogin: {
|
||||
target: "#chat.userSession",
|
||||
ChatNonVipLogin: {
|
||||
target: "#chat.nonVipUserSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
ChatVipLogin: {
|
||||
target: "#chat.vipUserSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
},
|
||||
@@ -324,7 +374,7 @@ export const chatMachine = setup({
|
||||
},
|
||||
// 3. 两个配额都 OK + 内容非空 → 发送(只用 appendUserMessage 自己的日志)
|
||||
{
|
||||
actions: "appendUserMessage",
|
||||
actions: "appendGuestUserMessage",
|
||||
guard: ({ event }) => event.content.trim().length > 0,
|
||||
target: "sending",
|
||||
},
|
||||
@@ -340,14 +390,11 @@ export const chatMachine = setup({
|
||||
actions: "incrementQuotaExceeded",
|
||||
},
|
||||
{
|
||||
actions: "appendUserImage",
|
||||
actions: "appendGuestUserImage",
|
||||
target: "sending",
|
||||
},
|
||||
],
|
||||
// 删除 ChatLoadMoreHistory handler —— 游客无服务端 history,不支持翻页
|
||||
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
|
||||
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
|
||||
ChatWebSocketConnected: { actions: "setWsConnected" },
|
||||
// 删除 ChatLoadMoreHistory / WS handlers —— 游客无服务端 history,也不连接 WS
|
||||
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
|
||||
},
|
||||
},
|
||||
@@ -372,11 +419,110 @@ export const chatMachine = setup({
|
||||
},
|
||||
},
|
||||
|
||||
userSession: {
|
||||
// 父级 on:把 WS / AI 流句 事件从 ready.on 上提到 userSession.on
|
||||
nonVipUserSession: {
|
||||
on: {
|
||||
ChatLogout: {
|
||||
target: "#chat.idle",
|
||||
actions: "clearChatSession",
|
||||
},
|
||||
ChatGuestLogin: {
|
||||
target: "#chat.guestSession",
|
||||
actions: "startGuestSession",
|
||||
},
|
||||
ChatNonVipLogin: {
|
||||
target: "#chat.nonVipUserSession",
|
||||
reenter: true,
|
||||
actions: "startUserSession",
|
||||
},
|
||||
ChatVipLogin: {
|
||||
target: "#chat.vipUserSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
},
|
||||
initial: "initializing",
|
||||
states: {
|
||||
initializing: {
|
||||
invoke: {
|
||||
src: "loadHistory",
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ event }) => ({
|
||||
messages: event.output.messages,
|
||||
isLoadingMore: false,
|
||||
hasMore: event.output.hasMore,
|
||||
historyOffset: event.output.newOffset,
|
||||
historyLoaded: true,
|
||||
})),
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: assign({
|
||||
isLoadingMore: false,
|
||||
historyLoaded: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
ready: {
|
||||
on: {
|
||||
ChatSendMessage: {
|
||||
guard: ({ event }) => event.content.trim().length > 0,
|
||||
actions: "appendUserMessage",
|
||||
target: "sendingViaHttp",
|
||||
},
|
||||
ChatSendImage: {
|
||||
actions: "appendUserImage",
|
||||
},
|
||||
ChatLoadMoreHistory: {
|
||||
target: "loadingMore",
|
||||
},
|
||||
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
|
||||
},
|
||||
},
|
||||
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 }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
vipUserSession: {
|
||||
// 父级 on:把 WS / AI 流句 事件从 ready.on 上提到 vipUserSession.on
|
||||
// —— 任何 child state(initializing / ready / sendingViaWs / sendingViaHttp / loadingMore)
|
||||
// 都能收到 `ChatAISentenceReceived` 并把 AI 句 push 到 messages,
|
||||
// 这点是 sendingViaWs 流式回 reply 的关键(machine 当时在 sendingViaWs)
|
||||
// 都能收到 `ChatAISentenceReceived` 并把 AI 句 push 到 messages。
|
||||
//
|
||||
// 子级 override 规则:
|
||||
// - sendingViaWs 重新声明 `ChatWebSocketError` 加 target: "ready"(带 action)
|
||||
@@ -390,8 +536,12 @@ export const chatMachine = setup({
|
||||
target: "#chat.guestSession",
|
||||
actions: "startGuestSession",
|
||||
},
|
||||
ChatNonGuestLogin: {
|
||||
target: "#chat.userSession",
|
||||
ChatNonVipLogin: {
|
||||
target: "#chat.nonVipUserSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
ChatVipLogin: {
|
||||
target: "#chat.vipUserSession",
|
||||
reenter: true,
|
||||
actions: "startUserSession",
|
||||
},
|
||||
@@ -405,7 +555,7 @@ export const chatMachine = setup({
|
||||
// 父级:WS 长连接(一进来就连,跨 initializing/ready/sending*/loadingMore)
|
||||
src: "chatWebSocket",
|
||||
input: ({ event }) => ({
|
||||
token: event.type === "ChatNonGuestLogin" ? event.token : "",
|
||||
token: event.type === "ChatVipLogin" ? event.token : "",
|
||||
}),
|
||||
},
|
||||
states: {
|
||||
@@ -445,9 +595,8 @@ export const chatMachine = setup({
|
||||
// 内容非空;wsConnected=false 同理)
|
||||
ChatSendMessage: [
|
||||
{
|
||||
// VIP 用户保留 WS;非 VIP 走 HTTP,让后端 daily_limit 能返回 blocked。
|
||||
// VIP 用户优先走 WS;WS 尚未可用时由下一个 branch 走 HTTP fallback。
|
||||
guard: ({ context, event }) =>
|
||||
context.isVip &&
|
||||
context.wsConnected &&
|
||||
event.content.trim().length > 0,
|
||||
actions: "appendUserMessage",
|
||||
@@ -468,16 +617,16 @@ export const chatMachine = setup({
|
||||
},
|
||||
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
|
||||
// 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected
|
||||
// 已上提到 userSession.on,子级不再声明
|
||||
// 已上提到 vipUserSession.on,子级不再声明
|
||||
},
|
||||
},
|
||||
// WS 发送:invoke sendMessageWsActor 触发一次 send,
|
||||
// 之后 AI 流式回 reply 走 userSession.on 的 ChatAISentenceReceived → appendOrUpdateAISentence
|
||||
// 之后 AI 流式回 reply 走 vipUserSession.on 的 ChatAISentenceReceived → appendOrUpdateAISentence
|
||||
// 当句尾 done: true → isReplyingAI = false → always 跳回 ready
|
||||
sendingViaWs: {
|
||||
on: {
|
||||
// 流式回包中 WS 出错(mid-stream) → 加错误泡 + 跳回 ready(不卡在 sendingViaWs)
|
||||
// 这个 child handler 替换 userSession.on 的 ChatWebSocketError(XState v5 不合并)
|
||||
// 这个 child handler 替换 vipUserSession.on 的 ChatWebSocketError(XState v5 不合并)
|
||||
// 所以需要重复声明 action: "appendSocketErrorMessage"
|
||||
ChatWebSocketError: {
|
||||
target: "ready",
|
||||
|
||||
Reference in New Issue
Block a user