refactor(chat): rely on server message limits
This commit is contained in:
@@ -42,22 +42,14 @@ export function ChatScreen() {
|
||||
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
|
||||
const isGuest = deriveIsGuest(authState.loginStatus);
|
||||
|
||||
// 游客配额耗尽 → 显示 Unlock CTA banner(替代 ChatInputBar)
|
||||
// 三重条件:游客 + 配额加载完 + 用户被配额 guard 拦截过至少一次
|
||||
// - quotaLoaded 避免一进 /chat 就跳 banner(loadQuota 还在 in-flight)
|
||||
// - quotaExceededTrigger 而非 guestTotalQuota / guestRemainingQuota ——
|
||||
// trigger 由 chat 机器的 guard 在 ChatSendMessage / ChatSendImage 被拦截时 +1,
|
||||
// 是 "用户被配额拒绝过" 的权威信号(避免依赖 quota 数值本身的边界判断)
|
||||
const showGuestQuotaBanner =
|
||||
isGuest && state.quotaLoaded && state.quotaExceededTrigger > 0;
|
||||
// 消息数量限制由后端统一返回 blocked/daily_limit,前端不再处理本地额度。
|
||||
const showDailyLimitBanner =
|
||||
!isGuest &&
|
||||
state.paywallTriggered &&
|
||||
state.paywallReason === "daily_limit";
|
||||
const showPhotoPaywallBanner =
|
||||
state.paywallTriggered && state.paywallReason === "photo_paywall";
|
||||
const showExhaustedBanner =
|
||||
showGuestQuotaBanner || showDailyLimitBanner || showPhotoPaywallBanner;
|
||||
showDailyLimitBanner || showPhotoPaywallBanner;
|
||||
|
||||
const externalBrowserPromptShownRef = useRef(false);
|
||||
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
* ChatQuotaExhaustedBanner 游客配额耗尽横幅
|
||||
*
|
||||
* 何时显示:
|
||||
* - isGuest === true(仅游客业务事实"非游客无配额上限")
|
||||
* - quotaLoaded === true(避免一进 /chat 就跳 banner)
|
||||
* - guestTotalQuota <= 0(用 total 而非 remaining —— 配额刚减为 0 不立即跳)
|
||||
* - 后端返回 blocked=true && blockReason="daily_limit"
|
||||
*
|
||||
* 视觉规格(与设计稿对齐):
|
||||
* - 粉→品红渐变背景(与 splash 渐变一致)
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* 游客每日聊天配额数据 DTO
|
||||
*/
|
||||
import {
|
||||
GuestChatQuotaSchema,
|
||||
type GuestChatQuotaInput,
|
||||
type GuestChatQuotaData,
|
||||
} from "@/data/schemas/chat/guest_chat_quota";
|
||||
import { AppEnvUtil } from "@/utils";
|
||||
|
||||
export class GuestChatQuota {
|
||||
// 静态常量
|
||||
static readonly maxQuotaPerDay = 30;
|
||||
static readonly maxQuotaPerDayTest = 4;
|
||||
|
||||
static readonly defaultTotalQuota = 50;
|
||||
static readonly defaultTotalQuotaTest = 5;
|
||||
static readonly quotaExhausted = 0;
|
||||
|
||||
declare readonly remaining: number;
|
||||
declare readonly date: string;
|
||||
|
||||
private constructor(input: GuestChatQuotaInput) {
|
||||
const data = GuestChatQuotaSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: GuestChatQuotaInput): GuestChatQuota {
|
||||
return new GuestChatQuota(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): GuestChatQuota {
|
||||
return GuestChatQuota.from(json as GuestChatQuotaInput);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配额耗尽状态值
|
||||
*/
|
||||
static get exhausted(): number {
|
||||
return GuestChatQuota.quotaExhausted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前环境的每日最大配额
|
||||
*/
|
||||
static get threshold(): number {
|
||||
return AppEnvUtil.isProduction()
|
||||
? GuestChatQuota.maxQuotaPerDay
|
||||
: GuestChatQuota.maxQuotaPerDayTest;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前环境的总配额默认值
|
||||
*/
|
||||
static get totalQuotaDefault(): number {
|
||||
return AppEnvUtil.isProduction()
|
||||
? GuestChatQuota.defaultTotalQuota
|
||||
: GuestChatQuota.defaultTotalQuotaTest;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建初始配额实例
|
||||
*/
|
||||
static initial(todayString: string): GuestChatQuota {
|
||||
return new GuestChatQuota({
|
||||
remaining: GuestChatQuota.threshold,
|
||||
date: todayString,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否需要重置(跨天)
|
||||
*/
|
||||
needsReset(todayString: string): boolean {
|
||||
return this.date !== todayString;
|
||||
}
|
||||
|
||||
toJson(): GuestChatQuotaData {
|
||||
return GuestChatQuotaSchema.parse(this);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ export * from "./chat_message";
|
||||
export * from "./chat_send_response";
|
||||
export * from "./chat_sync_data";
|
||||
export * from "./chat_sync_request";
|
||||
export * from "./guest_chat_quota";
|
||||
export * from "./image_upload_response";
|
||||
export * from "./send_message_request";
|
||||
export * from "./stt_data";
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* 游客每日聊天配额数据
|
||||
* 原始 Dart: GuestChatQuota (lib/data/models/chat/guest_chat_quota.dart)
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
export const GuestChatQuotaSchema = z.object({
|
||||
remaining: z.number(),
|
||||
date: z.string(),
|
||||
});
|
||||
|
||||
export type GuestChatQuotaInput = z.input<typeof GuestChatQuotaSchema>;
|
||||
export type GuestChatQuotaData = z.output<typeof GuestChatQuotaSchema>;
|
||||
@@ -7,7 +7,6 @@ export * from "./chat_message";
|
||||
export * from "./chat_send_response";
|
||||
export * from "./chat_sync_data";
|
||||
export * from "./chat_sync_request";
|
||||
export * from "./guest_chat_quota";
|
||||
export * from "./image_upload_response";
|
||||
export * from "./send_message_request";
|
||||
export * from "./stt_data";
|
||||
|
||||
@@ -35,7 +35,6 @@ export * from "../dto/chat/chat_message";
|
||||
export * from "../dto/chat/chat_send_response";
|
||||
export * from "../dto/chat/chat_sync_data";
|
||||
export * from "../dto/chat/chat_sync_request";
|
||||
export * from "../dto/chat/guest_chat_quota";
|
||||
export * from "../dto/chat/image_upload_response";
|
||||
export * from "../dto/chat/send_message_request";
|
||||
export * from "../dto/chat/stt_data";
|
||||
@@ -69,7 +68,6 @@ export * from "../schemas/chat/chat_message";
|
||||
export * from "../schemas/chat/chat_send_response";
|
||||
export * from "../schemas/chat/chat_sync_data";
|
||||
export * from "../schemas/chat/chat_sync_request";
|
||||
export * from "../schemas/chat/guest_chat_quota";
|
||||
export * from "../schemas/chat/image_upload_response";
|
||||
export * from "../schemas/chat/send_message_request";
|
||||
export * from "../schemas/chat/stt_data";
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createStorage } from "unstorage";
|
||||
import memoryDriver from "unstorage/drivers/memory";
|
||||
|
||||
import { GuestChatQuota } from "@/data/dto/chat/guest_chat_quota";
|
||||
import { StorageKeys } from "@/data/storage/storage_keys";
|
||||
import { todayString, SpAsyncUtil } from "@/utils";
|
||||
|
||||
import { ChatStorage } from "../chat_storage";
|
||||
|
||||
describe("ChatStorage guest quota", () => {
|
||||
beforeEach(() => {
|
||||
SpAsyncUtil.setStorage(createStorage({ driver: memoryDriver() }));
|
||||
ChatStorage._resetForTests();
|
||||
});
|
||||
|
||||
it("keeps an existing daily quota for the current day", async () => {
|
||||
const storage = ChatStorage.getInstance();
|
||||
await storage.setGuestDailyChatQuota(2, todayString());
|
||||
|
||||
const result = await storage.getGuestDailyChatQuota();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.success ? result.data : null).toEqual({
|
||||
remaining: 2,
|
||||
date: todayString(),
|
||||
});
|
||||
});
|
||||
|
||||
it("resets daily quota only when the stored date is not today", async () => {
|
||||
const storage = ChatStorage.getInstance();
|
||||
await storage.setGuestDailyChatQuota(1, "2000-01-01");
|
||||
|
||||
const result = await storage.getGuestDailyChatQuota();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.success ? result.data : null).toEqual({
|
||||
remaining: GuestChatQuota.threshold,
|
||||
date: todayString(),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not overwrite invalid daily quota data with the default value", async () => {
|
||||
await SpAsyncUtil.setString(StorageKeys.guestChatQuota, "{not-json");
|
||||
|
||||
const result = await ChatStorage.getInstance().getGuestDailyChatQuota();
|
||||
const stored = await SpAsyncUtil.getString(StorageKeys.guestChatQuota);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(stored.success ? stored.data : null).toBe("{not-json");
|
||||
});
|
||||
|
||||
it("does not overwrite invalid total quota data with the default value", async () => {
|
||||
await SpAsyncUtil.setString(StorageKeys.guestTotalQuotaRemaining, "{not-json");
|
||||
|
||||
const result = await ChatStorage.getInstance().getGuestTotalQuota();
|
||||
const stored = await SpAsyncUtil.getString(StorageKeys.guestTotalQuotaRemaining);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(stored.success ? stored.data : null).toBe("{not-json");
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
GuestChatQuotaSchema,
|
||||
type GuestChatQuotaData,
|
||||
} from "@/data/schemas/chat/guest_chat_quota";
|
||||
import { GuestChatQuota as GuestChatQuotaDto } from "@/data/dto/chat/guest_chat_quota";
|
||||
import { z } from "zod";
|
||||
|
||||
import { type Result as ResultT, Result, todayString, SpAsyncUtil } from "@/utils";
|
||||
import { StorageKeys } from "../storage_keys";
|
||||
import type { IChatStorage } from "./ichat_storage";
|
||||
|
||||
export class ChatStorage implements IChatStorage {
|
||||
export class ChatStorage {
|
||||
private static _instance: ChatStorage | null = null;
|
||||
|
||||
static getInstance(): ChatStorage {
|
||||
@@ -24,65 +13,6 @@ export class ChatStorage implements IChatStorage {
|
||||
ChatStorage._instance = null;
|
||||
}
|
||||
|
||||
// ---- guest daily chat quota ----
|
||||
|
||||
async getGuestDailyChatQuota(): Promise<ResultT<GuestChatQuotaData | null>> {
|
||||
const existing = await SpAsyncUtil.getJson(
|
||||
StorageKeys.guestChatQuota,
|
||||
GuestChatQuotaSchema,
|
||||
);
|
||||
if (Result.isErr(existing)) return existing;
|
||||
|
||||
const today = todayString();
|
||||
if (existing.data != null) {
|
||||
const quota = GuestChatQuotaDto.from(existing.data);
|
||||
if (!quota.needsReset(today)) return existing;
|
||||
}
|
||||
|
||||
const initResult = await this.setGuestDailyChatQuota(
|
||||
GuestChatQuotaDto.threshold,
|
||||
today,
|
||||
);
|
||||
if (Result.isErr(initResult)) return existing;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { remaining: GuestChatQuotaDto.threshold, date: today },
|
||||
};
|
||||
}
|
||||
|
||||
setGuestDailyChatQuota(remaining: number, date: string): Promise<ResultT<void>> {
|
||||
const value: GuestChatQuotaData = { remaining, date };
|
||||
return SpAsyncUtil.setJson(StorageKeys.guestChatQuota, value, GuestChatQuotaSchema);
|
||||
}
|
||||
|
||||
clearGuestDailyChatQuota(): Promise<ResultT<void>> {
|
||||
return SpAsyncUtil.remove(StorageKeys.guestChatQuota);
|
||||
}
|
||||
|
||||
// ---- guest total quota ----
|
||||
|
||||
async getGuestTotalQuota(): Promise<ResultT<number | null>> {
|
||||
const existing = await SpAsyncUtil.getJson(
|
||||
StorageKeys.guestTotalQuotaRemaining,
|
||||
z.number(),
|
||||
);
|
||||
if (Result.isErr(existing)) return existing;
|
||||
if (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>> {
|
||||
return SpAsyncUtil.setJson(StorageKeys.guestTotalQuotaRemaining, remaining, z.number());
|
||||
}
|
||||
|
||||
clearGuestTotalQuota(): Promise<ResultT<void>> {
|
||||
return SpAsyncUtil.remove(StorageKeys.guestTotalQuotaRemaining);
|
||||
}
|
||||
// 本地游客消息数量额度逻辑已停用:
|
||||
// 游客 / 非游客的消息数量限制统一由后端接口返回 blocked/daily_limit。
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* IChatStorage 接口
|
||||
*
|
||||
* 对齐 Dart 端 `IChatStorage`(lib/data/services/storage/chat/ichat_storage.dart):
|
||||
* - 游客每日聊天配额 + 游客总配额 两个独立字段的 CRUD
|
||||
*
|
||||
* 注:`GuestChatQuota` 模型见 `src/data/dto/chat/guest_chat_quota.ts`,
|
||||
* 数据形态 `{ remaining: number, date: string }`。
|
||||
* 跨天判断逻辑(`needsReset`)保留在 `GuestChatQuota` 类上。
|
||||
*/
|
||||
|
||||
import type { Result } from "@/utils";
|
||||
import type { GuestChatQuotaData } from "@/data/schemas/chat/guest_chat_quota";
|
||||
|
||||
export interface IChatStorage {
|
||||
getGuestDailyChatQuota(): Promise<Result<GuestChatQuotaData | null>>;
|
||||
setGuestDailyChatQuota(
|
||||
remaining: number,
|
||||
date: string,
|
||||
): Promise<Result<void>>;
|
||||
clearGuestDailyChatQuota(): Promise<Result<void>>;
|
||||
|
||||
getGuestTotalQuota(): Promise<Result<number | null>>;
|
||||
setGuestTotalQuota(remaining: number): Promise<Result<void>>;
|
||||
clearGuestTotalQuota(): Promise<Result<void>>;
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
*/
|
||||
|
||||
export * from "./chat_storage";
|
||||
export * from "./ichat_storage";
|
||||
export * from "./local_chat_db";
|
||||
export * from "./local_chat_storage";
|
||||
export * from "./local_message";
|
||||
|
||||
@@ -14,7 +14,6 @@ export * from "./app/app_storage";
|
||||
export * from "./auth/auth_storage";
|
||||
export * from "./auth/iauth_storage";
|
||||
export * from "./chat/chat_storage";
|
||||
export * from "./chat/ichat_storage";
|
||||
export * from "./chat/local_chat_db";
|
||||
export * from "./chat/local_chat_storage";
|
||||
export * from "./chat/local_message";
|
||||
|
||||
@@ -21,8 +21,6 @@ export const StorageKeys = {
|
||||
|
||||
// chat
|
||||
chatHistory: "chat_history",
|
||||
guestChatQuota: "guest_chat_quota",
|
||||
guestTotalQuotaRemaining: "guest_total_quota_remaining",
|
||||
|
||||
// pwa / app info
|
||||
lastPwaDialogShown: "last_pwa_dialog_shown",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -43,5 +43,4 @@ export type ChatEvent =
|
||||
done: boolean;
|
||||
}
|
||||
| { type: "ChatWebSocketError"; errorMessage: string }
|
||||
| { type: "ChatWebSocketConnected"; userId: string }
|
||||
| { type: "ChatQuotaExceeded" };
|
||||
| { type: "ChatWebSocketConnected"; userId: string };
|
||||
|
||||
@@ -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,返回最终结果)
|
||||
|
||||
@@ -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 任务 2:history 3 步流(local → network → save)—— `loadHistoryActor` 调
|
||||
|
||||
+35
-124
@@ -15,36 +15,33 @@
|
||||
* - `nonVipUserSession`(parent):非 VIP 用户会话 —— 不 invoke WS
|
||||
* - `vipUserSession`(parent):VIP 用户会话 —— invoke WS(持久)
|
||||
*
|
||||
* init 双任务(核心重构):
|
||||
* - guestSession.initializing:loadQuota + loadHistory 并行(`invoke: [...]` 数组)
|
||||
* init 任务:
|
||||
* - guestSession.initializing:loadHistory
|
||||
* - nonVipUserSession.initializing:loadHistory
|
||||
* - vipUserSession.initializing:loadHistory + 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:拉 history(fromPromise,返回最终 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,子级不再声明
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user