feat(chat): handle daily message paywall

This commit is contained in:
2026-06-22 10:46:17 +08:00
parent df7f673855
commit 61fd4ca916
10 changed files with 162 additions and 44 deletions
+65 -26
View File
@@ -19,13 +19,15 @@
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier
*
* 消息发送路径(业务事实):
* - `wsConnected: true`(仅 userSession 期间)→ 走 WS,不消耗次数(仅配额规则)
* - `wsConnected: false` → 走 HTTP,消耗次数(仅游客有意义 —— 非游客 0-1=max(0,-1)=0 天然 no-op
* - guestSession:走 HTTP,并先检查本地游客配额
* - userSession + VIP + WS 已连:走 WS,不受每日免费次数限制
* - userSession + 非 VIP:走 HTTP,让后端返回 daily_limit blocked
* - `ChatWebSocketConnected` 事件 → `wsConnected = true`
* - `userSession.exit` action → `wsConnected = false`WS actor cleanup 时也会跑 exit
*
* 配额(仅游客)
* - 游客"无消息限制"(业务事实)—— 永不被赋值
* 配额:
* - 游客:保留本地配额 guard,命中后不发送消息
* - 注册非 VIP:以后端 `blocked=true && blockReason="daily_limit"` 为准
* - `appendUserMessage` 减 `context.wsConnected ? q : Math.max(0, q-1)` —— WS 已连时不递减
*
*/
@@ -35,9 +37,9 @@ import { setup, assign } from "xstate";
import { ChatStorage } from "@/data/storage/chat/chat_storage";
import { formatDate, todayString, Logger } from "@/utils";
import { ChatState, initialState } from "./chat-state";
import type { ChatEvent } from "./chat-events";
import { applyHttpSendOutput } from "./chat-machine.helpers";
import {
loadQuotaActor,
loadHistoryActor,
@@ -49,12 +51,6 @@ import {
const log = new Logger("StoresChatChatMachine");
/** 游客配额阈值(与 Dart `GuestChatQuota` 保持一致) */
export const GuestChatQuota = {
warningThreshold: 5,
quotaExhausted: 0,
} as const;
// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变
export type { ChatState } from "./chat-state";
export { initialState } from "./chat-state";
@@ -77,6 +73,23 @@ export const chatMachine = setup({
chatWebSocket: chatWebSocketActor,
},
actions: {
startGuestSession: assign(() => ({
...initialState,
isVip: false,
})),
startUserSession: assign(({ event }) => {
if (event.type !== "ChatNonGuestLogin") return {};
return {
...initialState,
isVip: event.isVip,
};
}),
clearChatSession: assign(() => ({
...initialState,
})),
appendUserMessage: assign(({ context, event }) => {
if (event.type !== "ChatSendMessage") return {};
@@ -118,6 +131,9 @@ export const chatMachine = setup({
isReplyingAI: true,
guestRemainingQuota: newRemaining,
guestTotalQuota: newTotal,
paywallTriggered: false,
paywallReason: null,
paywallDetail: null,
};
}),
@@ -158,6 +174,9 @@ export const chatMachine = setup({
isReplyingAI: true,
guestRemainingQuota: newRemaining,
guestTotalQuota: newTotal,
paywallTriggered: false,
paywallReason: null,
paywallDetail: null,
};
}),
@@ -214,15 +233,27 @@ export const chatMachine = setup({
states: {
idle: {
on: {
ChatGuestLogin: "#chat.guestSession",
ChatNonGuestLogin: "#chat.userSession",
ChatGuestLogin: {
target: "#chat.guestSession",
actions: "startGuestSession",
},
ChatNonGuestLogin: {
target: "#chat.userSession",
actions: "startUserSession",
},
},
},
guestSession: {
on: {
ChatLogout: "#chat.idle",
ChatNonGuestLogin: "#chat.userSession",
ChatLogout: {
target: "#chat.idle",
actions: "clearChatSession",
},
ChatNonGuestLogin: {
target: "#chat.userSession",
actions: "startUserSession",
},
},
initial: "initializing",
states: {
@@ -328,10 +359,9 @@ export const chatMachine = setup({
}),
onDone: {
target: "ready",
actions: assign(({ context, event }) => ({
messages: [...context.messages, event.output.reply],
isReplyingAI: false,
})),
actions: assign(({ context, event }) =>
applyHttpSendOutput(context, event.output),
),
},
onError: {
target: "ready",
@@ -352,10 +382,18 @@ export const chatMachine = setup({
// - sendingViaWs 重新声明 `ChatWebSocketError` 加 target: "ready"(带 action
// —— XState v5: child handler 替换 parent;复制 action
on: {
ChatLogout: "#chat.idle",
ChatLogout: {
target: "#chat.idle",
actions: "clearChatSession",
},
ChatGuestLogin: {
target: "#chat.guestSession",
actions: "startGuestSession",
},
ChatNonGuestLogin: {
target: "#chat.userSession",
reenter: true,
actions: "startUserSession",
},
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
ChatWebSocketError: { actions: "appendSocketErrorMessage" },
@@ -407,9 +445,11 @@ export const chatMachine = setup({
// 内容非空;wsConnected=false 同理)
ChatSendMessage: [
{
// WS 已连 + 内容非空 → 走 WSsendingViaWs 等 AI 流式回 reply
// VIP 用户保留 WS;非 VIP 走 HTTP,让后端 daily_limit 能返回 blocked。
guard: ({ context, event }) =>
context.wsConnected && event.content.trim().length > 0,
context.isVip &&
context.wsConnected &&
event.content.trim().length > 0,
actions: "appendUserMessage",
target: "sendingViaWs",
},
@@ -472,10 +512,9 @@ export const chatMachine = setup({
}),
onDone: {
target: "ready",
actions: assign(({ context, event }) => ({
messages: [...context.messages, event.output.reply],
isReplyingAI: false,
})),
actions: assign(({ context, event }) =>
applyHttpSendOutput(context, event.output),
),
},
onError: {
target: "ready",