refactor: auto-init guest chat quota on first read, clean up logs

- Remove unused `quotaWarningThreshold` and `warningThreshold` getter from `GuestChatQuota` DTO
- Apply get-or-init pattern in `ChatStorage.getGuestDailyChatQuota` and `getGuestTotalQuota`: when no existing value, initialize with DTO defaults (`threshold` = max per day, `totalQuotaDefault`) and return the seeded data
- Fall back to original `null` result if initialization write fails
- Consolidate logging strategy: drop per-actor trace logs in `chat-machine.ts`, keep business-decision logs in 3 assign actions, move all actor ENTRY/API/DONE chains to `chat-machine.actors.ts` and helper load logs to `chat-machine.helpers.ts`
This commit is contained in:
2026-06-15 17:39:50 +08:00
parent fa694af723
commit 31f2433c4b
4 changed files with 88 additions and 170 deletions
-11
View File
@@ -11,9 +11,7 @@ export class GuestChatQuota {
// 静态常量 // 静态常量
static readonly maxQuotaPerDay = 40; static readonly maxQuotaPerDay = 40;
static readonly maxQuotaPerDayTest = 4; static readonly maxQuotaPerDayTest = 4;
static readonly quotaWarningThreshold = 20;
static readonly quotaWarningThresholdTest = 2;
static readonly defaultTotalQuota = 50; static readonly defaultTotalQuota = 50;
static readonly defaultTotalQuotaDev = 5; static readonly defaultTotalQuotaDev = 5;
static readonly quotaExhausted = 0; static readonly quotaExhausted = 0;
@@ -49,15 +47,6 @@ export class GuestChatQuota {
return process.env.NODE_ENV !== "production"; return process.env.NODE_ENV !== "production";
} }
/**
* 获取当前环境的配额警告阈值
*/
static get warningThreshold(): number {
return GuestChatQuota.isDevelopment
? GuestChatQuota.quotaWarningThresholdTest
: GuestChatQuota.quotaWarningThreshold;
}
/** /**
* 获取当前环境的每日最大配额 * 获取当前环境的每日最大配额
*/ */
+40 -3
View File
@@ -18,9 +18,11 @@ import {
GuestChatQuotaSchema, GuestChatQuotaSchema,
type GuestChatQuotaData, type GuestChatQuotaData,
} from "@/data/schemas/chat/guest_chat_quota"; } from "@/data/schemas/chat/guest_chat_quota";
import { GuestChatQuota as GuestChatQuotaDto } from "@/data/dto/chat/guest_chat_quota";
import { z } from "zod"; import { z } from "zod";
import { type Result as ResultT } from "@/utils/result"; import { type Result as ResultT, Result } from "@/utils/result";
import { formatDate } from "@/utils/date";
import { SpAsyncUtil } from "@/utils/storage"; import { SpAsyncUtil } from "@/utils/storage";
import { StorageKeys } from "../storage_keys"; import { StorageKeys } from "../storage_keys";
import type { IChatStorage } from "./ichat_storage"; import type { IChatStorage } from "./ichat_storage";
@@ -41,7 +43,27 @@ export class ChatStorage implements IChatStorage {
// ---- guest daily chat quota ---- // ---- guest daily chat quota ----
getGuestDailyChatQuota(): Promise<ResultT<GuestChatQuotaData | null>> { getGuestDailyChatQuota(): Promise<ResultT<GuestChatQuotaData | null>> {
return SpAsyncUtil.getJson(StorageKeys.guestChatQuota, GuestChatQuotaSchema); // get-or-init**有**返**有****无**则**用** DTO 默认**值**`threshold` = max per day**初**始**化**并**返**回**
return SpAsyncUtil.getJson(
StorageKeys.guestChatQuota,
GuestChatQuotaSchema,
).then(async (existing) => {
if (existing.success && existing.data != null) {
return existing;
}
const today = formatDate(new Date());
const initResult = await this.setGuestDailyChatQuota(
GuestChatQuotaDto.threshold,
today,
);
if (Result.isErr(initResult)) {
return existing; // **初**始**化**失**败** → **返**原**始** null
}
return {
success: true,
data: { remaining: GuestChatQuotaDto.threshold, date: today },
};
});
} }
setGuestDailyChatQuota(remaining: number, date: string): Promise<ResultT<void>> { setGuestDailyChatQuota(remaining: number, date: string): Promise<ResultT<void>> {
@@ -56,7 +78,22 @@ export class ChatStorage implements IChatStorage {
// ---- guest total quota ---- // ---- guest total quota ----
getGuestTotalQuota(): Promise<ResultT<number | null>> { getGuestTotalQuota(): Promise<ResultT<number | null>> {
return SpAsyncUtil.getJson(StorageKeys.guestTotalQuotaRemaining, z.number()); // get-or-init**有**返**有****无**则**用** DTO 默认**值**`totalQuotaDefault`**初**始**化**并**返**回**
return SpAsyncUtil.getJson(
StorageKeys.guestTotalQuotaRemaining,
z.number(),
).then(async (existing) => {
if (existing.success && existing.data != null) {
return existing;
}
const initResult = await this.setGuestTotalQuota(
GuestChatQuotaDto.totalQuotaDefault,
);
if (Result.isErr(initResult)) {
return existing;
}
return { success: true, data: GuestChatQuotaDto.totalQuotaDefault };
});
} }
setGuestTotalQuota(remaining: number): Promise<ResultT<void>> { setGuestTotalQuota(remaining: number): Promise<ResultT<void>> {
+48 -154
View File
@@ -47,9 +47,11 @@
* - `chat-machine.helpers.ts` —— 纯函数 + 数据加载(readGuestQuota + readAndSyncHistory * - `chat-machine.helpers.ts` —— 纯函数 + 数据加载(readGuestQuota + readAndSyncHistory
* - `chat-machine.actors.ts` —— 5 个 XState actorloadQuota + loadHistory + sendMessage + loadMoreHistory + chatWebSocket * - `chat-machine.actors.ts` —— 5 个 XState actorloadQuota + loadHistory + sendMessage + loadMoreHistory + chatWebSocket
* *
* **调试日志**(保留 —— 用户要求"不要将其删除"): * **日**志**策略****清**理**后**):
* - 命名约定:`[<file>]` 前缀标识来源 * - **不**在**状**态**机**文**件**中**打**追踪**日**志**actor / helper / action **本**身**有** ENTRY / DONE **日**志****就**够**排查**了)
* - 4 actor **全链日志**ENTRY / API / DONE / 关键决策)—— 排查 actor ****被调时**一眼**看到 * - `appendUserMessage` / `appendUserImage` / `appendOrUpdateAISentence` 这 3 **个** action `assign` **内**部**保**留** `console.log`**记**录** action **本**身**的**业**务**决**策****比**如** quota 减**** / isFromAI 标**记**
* - **所**有** actor ENTRY / API / DONE **全**链**日**志**放**在** `chat-machine.actors.ts`
* - **所**有** helper 数据**加**载**日**志**`readGuestQuota` / `readAndSyncHistory` 3 **步**)放**在** `chat-machine.helpers.ts`
*/ */
import { setup, assign } from "xstate"; import { setup, assign } from "xstate";
@@ -95,9 +97,11 @@ export const chatMachine = setup({
actions: { actions: {
appendUserMessage: assign(({ context, event }) => { appendUserMessage: assign(({ context, event }) => {
if (event.type !== "ChatSendMessage") return {}; if (event.type !== "ChatSendMessage") return {};
const newQuota = context.wsConnected const newQuota = context.wsConnected
? context.guestRemainingQuota ? context.guestRemainingQuota
: Math.max(0, context.guestRemainingQuota - 1); : Math.max(0, context.guestRemainingQuota - 1);
console.log("[chat-machine] appendUserMessage", { console.log("[chat-machine] appendUserMessage", {
contentLength: event.content.length, contentLength: event.content.length,
contentPreview: event.content.slice(0, 50), contentPreview: event.content.slice(0, 50),
@@ -109,6 +113,7 @@ export const chatMachine = setup({
newQuota, newQuota,
isReplyingAI: true, isReplyingAI: true,
}); });
return { return {
messages: [ messages: [
...context.messages, ...context.messages,
@@ -195,26 +200,6 @@ export const chatMachine = setup({
setWsConnected: assign({ wsConnected: true }), setWsConnected: assign({ wsConnected: true }),
clearWsConnected: assign({ wsConnected: false }), clearWsConnected: assign({ wsConnected: false }),
logSendMessageReceived: ({ event, context }) =>
console.log("[chat-machine] SendMessage received", {
contentLength: event.type === "ChatSendMessage" ? event.content.length : 0,
isReplyingAI: context.isReplyingAI,
wsConnected: context.wsConnected,
currentMessagesCount: context.messages.length,
}),
logSendImageReceived: ({ event, context }) =>
console.log("[chat-machine] SendImage received", {
hasImageBase64: event.type === "ChatSendImage" ? !!event.imageBase64 : false,
wsConnected: context.wsConnected,
}),
logLoadMoreHistoryReceived: ({ context }) =>
console.log("[chat-machine] LoadMoreHistory received", {
currentOffset: context.historyOffset,
hasMore: context.hasMore,
}),
}, },
}).createMachine({ }).createMachine({
id: "chat", id: "chat",
@@ -248,17 +233,11 @@ export const chatMachine = setup({
id: "loadQuota", id: "loadQuota",
src: "loadQuota", src: "loadQuota",
onDone: { onDone: {
actions: assign(({ event }) => { actions: assign(({ event }) => ({
console.log("[chat-machine] guestSession.initializing.loadQuota.onDone", { guestRemainingQuota: event.output.remaining,
remaining: event.output.remaining, guestTotalQuota: event.output.total,
total: event.output.total, quotaLoaded: true,
}); })),
return {
guestRemainingQuota: event.output.remaining,
guestTotalQuota: event.output.total,
quotaLoaded: true,
};
}),
}, },
onError: { onError: {
// 失败**也**标 loaded**不**卡 init**游**客**可**能 quota 服务**不**可**用****让** UI **进** ready // 失败**也**标 loaded**不**卡 init**游**客**可**能 quota 服务**不**可**用****让** UI **进** ready
@@ -272,23 +251,12 @@ export const chatMachine = setup({
id: "loadHistory", id: "loadHistory",
src: "loadHistory", src: "loadHistory",
onDone: { onDone: {
actions: assign(({ event }) => { actions: assign(({ event }) => ({
console.log( messages: event.output.messages,
"[chat-machine] guestSession.initializing.loadHistory.onDone", hasMore: event.output.hasMore,
{ historyOffset: event.output.newOffset,
finalCount: event.output.messages.length, historyLoaded: true,
hasMore: event.output.hasMore, })),
newOffset: event.output.newOffset,
localOverwritten: event.output.localOverwritten,
},
);
return {
messages: event.output.messages,
hasMore: event.output.hasMore,
historyOffset: event.output.newOffset,
historyLoaded: true,
};
}),
}, },
onError: { onError: {
// 失败**也**标 loaded**不**卡 init**让** UI **还**是**能**进** ready**屏**幕**可**能**空** // 失败**也**标 loaded**不**卡 init**让** UI **还**是**能**进** ready**屏**幕**可**能**空**
@@ -316,9 +284,9 @@ export const chatMachine = setup({
guard: ({ context }) => context.guestRemainingQuota <= 0, guard: ({ context }) => context.guestRemainingQuota <= 0,
actions: "incrementQuotaExceeded", actions: "incrementQuotaExceeded",
}, },
// 3. 两**个**配**额**都** OK + **内**容**非**空** → **发**送** // 3. 两**个**配**额**都** OK + **内**容**非**空** → **发**送****只**用** appendUserMessage **自**己**的**日**志**
{ {
actions: ["logSendMessageReceived", "appendUserMessage"], actions: "appendUserMessage",
guard: ({ event }) => event.content.trim().length > 0, guard: ({ event }) => event.content.trim().length > 0,
target: "sending", target: "sending",
}, },
@@ -335,7 +303,7 @@ export const chatMachine = setup({
actions: "incrementQuotaExceeded", actions: "incrementQuotaExceeded",
}, },
{ {
actions: ["logSendImageReceived", "appendUserImage"], actions: "appendUserImage",
target: "sending", target: "sending",
}, },
], ],
@@ -349,11 +317,6 @@ export const chatMachine = setup({
}, },
}, },
sending: { sending: {
entry: ({ event }) =>
console.log("[chat-machine] → guestSession.sending", {
contentLength: event.type === "ChatSendMessage" ? event.content.length : 0,
contentPreview: event.type === "ChatSendMessage" ? event.content.slice(0, 50) : "",
}),
invoke: { invoke: {
src: "sendMessageHttp", src: "sendMessageHttp",
input: ({ event }) => ({ input: ({ event }) => ({
@@ -361,28 +324,14 @@ export const chatMachine = setup({
}), }),
onDone: { onDone: {
target: "ready", target: "ready",
actions: [ actions: assign(({ context, event }) => ({
assign(({ context, event }) => ({ messages: [...context.messages, event.output.reply],
messages: [...context.messages, event.output.reply], isReplyingAI: false,
isReplyingAI: false, })),
})),
({ context, event }) =>
console.log("[chat-machine] guestSession.sending.onDone", {
newReplyContentLength: event.output.reply.content.length,
totalMessagesCount: context.messages.length + 1,
isReplyingAI: false,
}),
],
}, },
onError: { onError: {
target: "ready", target: "ready",
actions: [ actions: assign({ isReplyingAI: false }),
assign({ isReplyingAI: false }),
({ event }) =>
console.error("[chat-machine] guestSession.sending.onError", {
error: event.error instanceof Error ? event.error.message : String(event.error),
}),
],
}, },
}, },
}, },
@@ -416,24 +365,13 @@ export const chatMachine = setup({
// 任务 2:拉 history**只**调**用** loadHistoryActor**不**再**用** loadMoreHistoryActor // 任务 2:拉 history**只**调**用** loadHistoryActor**不**再**用** loadMoreHistoryActor
src: "loadHistory", src: "loadHistory",
onDone: { onDone: {
actions: assign(({ event }) => { actions: assign(({ event }) => ({
console.log( messages: event.output.messages,
"[chat-machine] userSession.initializing.loadHistory.onDone", isLoadingMore: false,
{ hasMore: event.output.hasMore,
finalCount: event.output.messages.length, historyOffset: event.output.newOffset,
hasMore: event.output.hasMore, historyLoaded: true,
newOffset: event.output.newOffset, })),
localOverwritten: event.output.localOverwritten,
},
);
return {
messages: event.output.messages,
isLoadingMore: false,
hasMore: event.output.hasMore,
historyOffset: event.output.newOffset,
historyLoaded: true,
};
}),
}, },
onError: { onError: {
// 失败**也**标 loaded**不**卡 init // 失败**也**标 loaded**不**卡 init
@@ -447,22 +385,14 @@ export const chatMachine = setup({
ready: { ready: {
on: { on: {
ChatSendMessage: { ChatSendMessage: {
actions: ["logSendMessageReceived", "appendUserMessage"], actions: "appendUserMessage",
guard: ({ event }) => event.content.trim().length > 0, guard: ({ event }) => event.content.trim().length > 0,
target: "sending", target: "sending",
}, },
ChatSendImage: { ChatSendImage: {
actions: ["logSendImageReceived", "appendUserImage"], actions: "appendUserImage",
}, },
ChatLoadMoreHistory: { ChatLoadMoreHistory: {
actions: [
"logLoadMoreHistoryReceived",
({ context }) =>
console.log("[chat-machine] userSession.ready.ChatLoadMoreHistory received", {
currentOffset: context.historyOffset,
hasMore: context.hasMore,
}),
],
target: "loadingMore", target: "loadingMore",
}, },
ChatLogout: "#chat.idle", ChatLogout: "#chat.idle",
@@ -474,12 +404,6 @@ export const chatMachine = setup({
}, },
}, },
sending: { sending: {
entry: ({ event }) =>
console.log("[chat-machine] → userSession.sending", {
contentLength: event.type === "ChatSendMessage" ? event.content.length : 0,
contentPreview: event.type === "ChatSendMessage" ? event.content.slice(0, 50) : "",
wsConnected: true,
}),
invoke: { invoke: {
src: "sendMessageHttp", src: "sendMessageHttp",
input: ({ event }) => ({ input: ({ event }) => ({
@@ -487,63 +411,33 @@ export const chatMachine = setup({
}), }),
onDone: { onDone: {
target: "ready", target: "ready",
actions: [ actions: assign(({ context, event }) => ({
assign(({ context, event }) => ({ messages: [...context.messages, event.output.reply],
messages: [...context.messages, event.output.reply], isReplyingAI: false,
isReplyingAI: false, })),
})),
({ context, event }) =>
console.log("[chat-machine] userSession.sending.onDone", {
newReplyContentLength: event.output.reply.content.length,
totalMessagesCount: context.messages.length + 1,
isReplyingAI: false,
}),
],
}, },
onError: { onError: {
target: "ready", target: "ready",
actions: [ actions: assign({ isReplyingAI: false }),
assign({ isReplyingAI: false }),
({ event }) =>
console.error("[chat-machine] userSession.sending.onError", {
error: event.error instanceof Error ? event.error.message : String(event.error),
}),
],
}, },
}, },
}, },
loadingMore: { loadingMore: {
entry: ({ context }) =>
console.log("[chat-machine] → userSession.loadingMore", { offset: context.historyOffset }),
invoke: { invoke: {
src: "loadMoreHistory", src: "loadMoreHistory",
input: ({ context }) => ({ offset: context.historyOffset }), input: ({ context }) => ({ offset: context.historyOffset }),
onDone: { onDone: {
target: "ready", target: "ready",
actions: [ actions: assign(({ context, event }) => ({
assign(({ context, event }) => ({ messages: [...event.output.messages, ...context.messages],
messages: [...event.output.messages, ...context.messages], isLoadingMore: false,
isLoadingMore: false, hasMore: event.output.hasMore,
hasMore: event.output.hasMore, historyOffset: event.output.newOffset,
historyOffset: event.output.newOffset, })),
})),
({ event }) =>
console.log("[chat-machine] userSession.loadingMore.onDone", {
newMessagesCount: event.output.messages.length,
newOffset: event.output.newOffset,
hasMore: event.output.hasMore,
}),
],
}, },
onError: { onError: {
target: "ready", target: "ready",
actions: [ actions: assign({ isLoadingMore: false }),
assign({ isLoadingMore: false }),
({ event }) =>
console.error("[chat-machine] userSession.loadingMore.onError", {
error: event.error instanceof Error ? event.error.message : String(event.error),
}),
],
}, },
}, },
}, },
-2
View File
@@ -29,8 +29,6 @@ export interface ChatState {
*/ */
wsConnected: boolean; wsConnected: boolean;
/** 游客剩余配额(次/天)—— **仅**游客业务展示用 /** 游客剩余配额(次/天)—— **仅**游客业务展示用
* - default 0 = "未初始化" / "已耗尽"(同码,靠 `isGuest` 区分)
* - 非游客:永不被赋值(业务事实"无消息限制")
*/ */
guestRemainingQuota: number; guestRemainingQuota: number;
/** 游客总配额(仅游客展示用) */ /** 游客总配额(仅游客展示用) */