refactor(chat): rely on server message limits

This commit is contained in:
2026-06-23 11:20:48 +08:00
parent d81abd6efd
commit 6bcc070ba1
19 changed files with 46 additions and 489 deletions
-11
View File
@@ -21,19 +21,12 @@ import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
interface ChatState {
messages: MachineContext["messages"];
isReplyingAI: boolean;
/** 游客剩余配额(次/天)—— 业务展示用,鉴权判断由 chat-screen 派生 loginStatus */
guestRemainingQuota: number;
/** 游客总配额 */
guestTotalQuota: number;
quotaExceededTrigger: number;
paywallTriggered: boolean;
paywallReason: MachineContext["paywallReason"];
paywallDetail: MachineContext["paywallDetail"];
isLoadingMore: boolean;
hasMore: boolean;
historyOffset: number;
/** 游客配额加载完成标志(仅 guestSession 期间) —— UI 可用此显示 loading */
quotaLoaded: boolean;
/** history 初始加载完成标志(local → network → save 跑完) —— UI 可用此显示 loading */
historyLoaded: boolean;
}
@@ -53,16 +46,12 @@ export function ChatProvider({ children }: ChatProviderProps) {
() => ({
messages: state.context.messages,
isReplyingAI: state.context.isReplyingAI,
guestRemainingQuota: state.context.guestRemainingQuota,
guestTotalQuota: state.context.guestTotalQuota,
quotaExceededTrigger: state.context.quotaExceededTrigger,
paywallTriggered: state.context.paywallTriggered,
paywallReason: state.context.paywallReason,
paywallDetail: state.context.paywallDetail,
isLoadingMore: state.context.isLoadingMore,
hasMore: state.context.hasMore,
historyOffset: state.context.historyOffset,
quotaLoaded: state.context.quotaLoaded,
historyLoaded: state.context.historyLoaded,
}),
[state],
+1 -2
View File
@@ -43,5 +43,4 @@ export type ChatEvent =
done: boolean;
}
| { type: "ChatWebSocketError"; errorMessage: string }
| { type: "ChatWebSocketConnected"; userId: string }
| { type: "ChatQuotaExceeded" };
| { type: "ChatWebSocketConnected"; userId: string };
+1 -15
View File
@@ -13,7 +13,6 @@ import {
chatRepo,
localMessagesToUi,
readAndSyncHistory,
readGuestQuota,
sendResponseToUiMessage,
} from "./chat-machine.helpers";
import type { ChatEvent } from "./chat-events";
@@ -42,20 +41,7 @@ export function getActiveChatWebSocket(): ChatWebSocket | null {
return activeChatWebSocket;
}
// ============================================================
// Init 任务 1:拉游客配额(fromPromise,纯 local
// ============================================================
/**
* 拉游客日配 + 总配(仅 guestSession 期间 invoke
* - 失败返 `{ remaining: 0, total: 0 }`(不抛,让 UI 还是能进 ready)
* - onDone 在 state machine 里赋 guestRemainingQuota + guestTotalQuota + 设 quotaLoaded: true
*/
export const loadQuotaActor = fromPromise<{
remaining: number;
total: number;
}>(async () => {
return readGuestQuota();
});
// 本地游客消息额度 actor 已停用:消息数量限制统一交由后端 blocked/daily_limit 响应处理。
// ============================================================
// Init 任务 2:拉 history 3 步流(fromPromise,返回最终结果)
+3 -43
View File
@@ -5,10 +5,7 @@
* - 翻页大小常量
* - 仓库注入(接口类型 → 单例)
* - DTO ↔ UiMessage 映射(纯函数 —— 可单测)
* - Result → number 映射(纯函数 —— 可单测)
* - 2 个独立 init 任务的数据加载:
* - `readGuestQuota()` —— 游客日配 + 总配(仅游客有意义,纯 ChatStorage
* - `readAndSyncHistory()` —— local → network → save network to local 3 步
* - `readAndSyncHistory()` —— local → network → save network to local 3 步
*
* 设计目标:
* - helpers 无 XState 依赖(可独立 import / 测试)
@@ -23,7 +20,6 @@
import type { UiMessage } from "@/data/dto/chat";
import { chatRepository } from "@/data/repositories/chat_repository";
import type { IChatRepository } from "@/data/repositories/interfaces";
import { ChatStorage } from "@/data/storage/chat/chat_storage";
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
import { todayString, Result, Logger } from "@/utils";
@@ -142,44 +138,8 @@ export function applyHttpSendOutput(
};
}
// ============================================================
// Result → number 映射
// ============================================================
/** 日配额 Result → number(纯函数) */
export function mapQuotaResult(
r: Result<{ remaining: number } | null>,
fallback: number,
): number {
if (r.success && r.data != null) return r.data.remaining;
return fallback;
}
/** 总配额 Result → number(纯函数) */
export function mapTotalQuotaResult(
r: Result<number | null>,
fallback: number,
): number {
if (r.success && r.data != null) return r.data;
return fallback;
}
export async function readGuestQuota(): Promise<{
remaining: number;
total: number;
}> {
const chatStorage = ChatStorage.getInstance();
const [dailyResult, totalResult] = await Promise.all([
chatStorage.getGuestDailyChatQuota(),
chatStorage.getGuestTotalQuota(),
]);
return {
remaining: mapQuotaResult(
dailyResult as Result<{ remaining: number } | null>,
0,
),
total: mapTotalQuotaResult(totalResult as Result<number | null>, 0),
};
}
// 本地游客消息额度读取 / 映射逻辑已停用:
// 消息数量限制统一由后端接口返回 blocked/daily_limit,前端不再读取 ChatStorage quota。
// ============================================================
// Init 任务 2history 3 步流(local → network → save)—— `loadHistoryActor` 调
+35 -124
View File
@@ -15,36 +15,33 @@
* - `nonVipUserSession`parent):非 VIP 用户会话 —— 不 invoke WS
* - `vipUserSession`parent):VIP 用户会话 —— invoke WS(持久)
*
* init 任务(核心重构)
* - guestSession.initializingloadQuota + loadHistory 并行(`invoke: [...]` 数组)
* init 任务:
* - guestSession.initializingloadHistory
* - nonVipUserSession.initializingloadHistory
* - vipUserSession.initializingloadHistory + parent-level chatWebSocket 并行
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier
*
* 消息发送路径(业务事实):
* - guestSession:走 HTTP并先检查本地游客配额
* - guestSession:走 HTTP消息数量限制以后端 `blocked` 响应为准
* - nonVipUserSession:走 HTTP,让后端返回 daily_limit blocked
* - vipUserSession + WS 已连:走 WS,不受每日免费次数限制
* - `ChatWebSocketConnected` 事件 → `wsConnected = true`
* - `vipUserSession.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 已连时不递减
* - 前端不再处理本地消息额度;游客 / 注册用户均以后端响应为准。
* - 后端返回 `blocked=true && blockReason="daily_limit"` 时展示会员引导。
*
*/
import { setup, assign } from "xstate";
import { ChatStorage } from "@/data/storage/chat/chat_storage";
import { 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,
sendMessageHttpActor,
sendMessageWsActor,
@@ -68,7 +65,6 @@ export const chatMachine = setup({
events: {} as ChatEvent,
},
actors: {
loadQuota: loadQuotaActor,
loadHistory: loadHistoryActor,
sendMessageHttp: sendMessageHttpActor,
sendMessageWs: sendMessageWsActor,
@@ -91,25 +87,11 @@ export const chatMachine = setup({
appendGuestUserMessage: assign(({ context, event }) => {
if (event.type !== "ChatSendMessage") return {};
// 游客消息发送成功前先乐观扣减本地日配额 + 总配额。
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
const today = todayString();
ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today);
ChatStorage.getInstance().setGuestTotalQuota(newTotal);
log.debug("[chat-machine] appendGuestUserMessage", {
contentLength: event.content.length,
contentPreview: event.content.slice(0, 50),
quotaMode: "guest http (decrement)",
oldRemaining: context.guestRemainingQuota,
newRemaining,
oldTotal: context.guestTotalQuota,
newTotal,
quotaMode: "server controlled",
isReplyingAI: true,
});
@@ -123,8 +105,6 @@ export const chatMachine = setup({
},
],
isReplyingAI: true,
guestRemainingQuota: newRemaining,
guestTotalQuota: newTotal,
paywallTriggered: false,
paywallReason: null,
paywallDetail: null,
@@ -160,22 +140,11 @@ export const chatMachine = setup({
appendGuestUserImage: assign(({ context, event }) => {
if (event.type !== "ChatSendImage") return {};
// 同 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);
const today = todayString();
log.debug("[chat-machine] appendGuestUserImage", {
oldMessagesCount: context.messages.length,
quotaMode: "guest http (decrement)",
oldRemaining: context.guestRemainingQuota,
newRemaining,
oldTotal: context.guestTotalQuota,
newTotal,
quotaMode: "server controlled",
});
return {
messages: [
@@ -188,8 +157,6 @@ export const chatMachine = setup({
},
],
isReplyingAI: true,
guestRemainingQuota: newRemaining,
guestTotalQuota: newTotal,
paywallTriggered: false,
paywallReason: null,
paywallDetail: null,
@@ -309,10 +276,6 @@ export const chatMachine = setup({
};
}),
incrementQuotaExceeded: assign(({ context }) => ({
quotaExceededTrigger: context.quotaExceededTrigger + 1,
})),
setWsConnected: assign({ wsConnected: true }),
clearWsConnected: assign({ wsConnected: false }),
},
@@ -356,94 +319,44 @@ export const chatMachine = setup({
initial: "initializing",
states: {
initializing: {
// "always" barrier:两个任务都完成才进 ready
// 本地游客额度逻辑已停用:只等待历史加载完成。
always: [
{
target: "ready",
guard: ({ context }) => context.quotaLoaded && context.historyLoaded,
guard: ({ context }) => context.historyLoaded,
},
],
invoke: [
// 任务 1:加载配额(fromPromise,返结果 + onDone 设 flag
{
id: "loadQuota",
src: "loadQuota",
onDone: {
actions: assign(({ event }) => ({
guestRemainingQuota: event.output.remaining,
guestTotalQuota: event.output.total,
quotaLoaded: true,
})),
},
onError: {
// 失败也标 loaded,不卡 init(游客可能 quota 服务不可用,让 UI 进 ready)
actions: assign({
quotaLoaded: true,
}),
},
invoke: {
id: "loadHistory",
src: "loadHistory",
onDone: {
actions: assign(({ event }) => ({
messages: event.output.messages,
hasMore: event.output.hasMore,
historyOffset: event.output.newOffset,
historyLoaded: true,
})),
},
// 任务 2:拉 historyfromPromise,返回最终 network messages
{
id: "loadHistory",
src: "loadHistory",
onDone: {
actions: assign(({ event }) => ({
messages: event.output.messages,
hasMore: event.output.hasMore,
historyOffset: event.output.newOffset,
historyLoaded: true,
})),
},
onError: {
// 失败也标 loaded,不卡 init(让 UI 还是能进 ready,屏幕可能空)
actions: assign({
historyLoaded: true,
}),
},
onError: {
// 失败也标 loaded,不卡 init(让 UI 还是能进 ready,屏幕可能空)
actions: assign({
historyLoaded: true,
}),
},
],
},
},
ready: {
on: {
// 配额检查 + 发送消息(3 条 guard,按顺序匹配)
// 1. 总配额(guestTotalQuota)不足 → 触发 quota exceeded dialog,不发送
// 2. 每日配额(guestRemainingQuota)不足 → 同上
// 3. 两个配额都 OK + 内容非空 → 发送 + 减配额
ChatSendMessage: [
// 1. 总配额 不足(guestTotalQuota <= 0 → 触发 quota exceeded dialog
{
guard: ({ context }) => context.guestTotalQuota <= 0,
actions: "incrementQuotaExceeded",
},
// 2. 每日配额 不足(guestRemainingQuota <= 0 → 触发 quota exceeded dialog
{
guard: ({ context }) => context.guestRemainingQuota <= 0,
actions: "incrementQuotaExceeded",
},
// 3. 两个配额都 OK + 内容非空 → 发送(只用 appendUserMessage 自己的日志)
{
actions: "appendGuestUserMessage",
guard: ({ event }) => event.content.trim().length > 0,
target: "sending",
},
],
// 配额检查 + 发送图片(同样 3 条 guard,不检查 "内容空"
ChatSendImage: [
{
guard: ({ context }) => context.guestTotalQuota <= 0,
actions: "incrementQuotaExceeded",
},
{
guard: ({ context }) => context.guestRemainingQuota <= 0,
actions: "incrementQuotaExceeded",
},
{
actions: "appendGuestUserImage",
target: "sending",
},
],
ChatSendMessage: {
actions: "appendGuestUserMessage",
guard: ({ event }) => event.content.trim().length > 0,
target: "sending",
},
ChatSendImage: {
actions: "appendGuestUserImage",
target: "sending",
},
// 删除 ChatLoadMoreHistory / WS handlers —— 游客无服务端 history,也不连接 WS
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
},
},
sending: {
@@ -524,7 +437,6 @@ export const chatMachine = setup({
ChatLoadMoreHistory: {
target: "loadingMore",
},
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
},
},
sendingViaHttp: {
@@ -665,7 +577,6 @@ export const chatMachine = setup({
ChatLoadMoreHistory: {
target: "loadingMore",
},
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
// 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected
// 已上提到 vipUserSession.on,子级不再声明
},
-15
View File
@@ -18,23 +18,12 @@ export interface ChatState {
* - 非 VIP / 游客:固定 false,走 HTTP
*/
wsConnected: boolean;
/** 游客剩余配额(次/天)—— 仅游客业务展示用
*/
guestRemainingQuota: number;
/** 游客总配额(仅游客展示用) */
guestTotalQuota: number;
quotaExceededTrigger: number;
paywallTriggered: boolean;
paywallReason: "daily_limit" | "photo_paywall" | null;
paywallDetail: { usedToday: number; limit: number } | null;
isLoadingMore: boolean;
hasMore: boolean;
historyOffset: number;
/** 游客配额加载完成标志(仅 guestSession 期间) —— default false
* - guestSession.initializing 走 always barrier`quotaLoaded && historyLoaded` 才进 ready
* - user sessions 永不为 true(不调 loadQuota)—— OK,不影响 barrier
*/
quotaLoaded: boolean;
/** history 加载完成标志(initial load —— local → network → save 跑完)
* - guestSession.initializing / user sessions initializing 走 always barrier
* - 不被 `loadMoreHistoryActor` 设置(翻页是另一码事)
@@ -46,15 +35,11 @@ export const initialState: ChatState = {
messages: [],
isReplyingAI: false,
wsConnected: false,
guestRemainingQuota: 0,
guestTotalQuota: 0,
quotaExceededTrigger: 0,
paywallTriggered: false,
paywallReason: null,
paywallDetail: null,
isLoadingMore: false,
hasMore: true,
historyOffset: 0,
quotaLoaded: false,
historyLoaded: false,
};