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
+7 -4
View File
@@ -1,7 +1,5 @@
"use client";
/**
* ChatScreen 编排层
*/
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
@@ -50,8 +48,13 @@ export function ChatScreen() {
// - quotaExceededTrigger 而非 guestTotalQuota / guestRemainingQuota ——
// trigger 由 chat 机器的 guard 在 ChatSendMessage / ChatSendImage 被拦截时 +1
// 是 "用户被配额拒绝过" 的权威信号(避免依赖 quota 数值本身的边界判断)
const showExhaustedBanner =
const showGuestQuotaBanner =
isGuest && state.quotaLoaded && state.quotaExceededTrigger > 0;
const showDailyLimitBanner =
!isGuest &&
state.paywallTriggered &&
state.paywallReason === "daily_limit";
const showExhaustedBanner = showGuestQuotaBanner || showDailyLimitBanner;
const externalBrowserPromptShownRef = useRef(false);
@@ -45,7 +45,7 @@ export function ChatQuotaExhaustedBanner({
onUnlock();
return;
}
router.push(ROUTES.auth);
router.push(`${ROUTES.subscription}?type=vip`);
};
return (
+1 -1
View File
@@ -10,7 +10,7 @@ import { AppEnvUtil } from "@/utils";
export class GuestChatQuota {
// 静态常量
static readonly maxQuotaPerDay = 40;
static readonly maxQuotaPerDay = 30;
static readonly maxQuotaPerDayTest = 4;
static readonly defaultTotalQuota = 50;
+16 -6
View File
@@ -10,9 +10,9 @@
import { useEffect, useRef } from "react";
import { usePathname, useRouter } from "next/navigation";
import type { LoginStatus } from "@/data/dto/auth";
import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { useAuthState } from "@/stores/auth/auth-context";
import { useUserState } from "@/stores/user/user-context";
import { PROTECTED_ROUTES, ROUTES, type StaticRoute } from "@/router/routes";
import { useChatDispatch } from "./chat-context";
@@ -23,17 +23,22 @@ function isProtectedPath(pathname: string): boolean {
export function ChatAuthSync() {
const authState = useAuthState();
const userState = useUserState();
const chatDispatch = useChatDispatch();
const pathname = usePathname();
const router = useRouter();
const prevLoginStatusRef = useRef<LoginStatus | null>(null);
const prevSessionKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!authState.hasInitialized || authState.isLoading) return;
const prev = prevLoginStatusRef.current;
if (prev === authState.loginStatus) return;
prevLoginStatusRef.current = authState.loginStatus;
const isVip = userState.currentUser?.isVip === true;
const sessionKey =
authState.loginStatus === "notLoggedIn" || authState.loginStatus === "guest"
? authState.loginStatus
: `${authState.loginStatus}:${isVip ? "vip" : "non-vip"}`;
if (prevSessionKeyRef.current === sessionKey) return;
prevSessionKeyRef.current = sessionKey;
if (authState.loginStatus === "notLoggedIn") {
chatDispatch({ type: "ChatLogout" });
@@ -52,7 +57,11 @@ export function ChatAuthSync() {
void (async () => {
const tokenR = await AuthStorage.getInstance().getLoginToken();
if (!cancelled && tokenR.success && tokenR.data) {
chatDispatch({ type: "ChatNonGuestLogin", token: tokenR.data });
chatDispatch({
type: "ChatNonGuestLogin",
token: tokenR.data,
isVip,
});
}
})();
@@ -66,6 +75,7 @@ export function ChatAuthSync() {
chatDispatch,
pathname,
router,
userState.currentUser?.isVip,
]);
return null;
+6
View File
@@ -26,6 +26,9 @@ interface ChatState {
/** 游客总配额 */
guestTotalQuota: number;
quotaExceededTrigger: number;
paywallTriggered: boolean;
paywallReason: MachineContext["paywallReason"];
paywallDetail: MachineContext["paywallDetail"];
isLoadingMore: boolean;
hasMore: boolean;
historyOffset: number;
@@ -53,6 +56,9 @@ export function ChatProvider({ children }: ChatProviderProps) {
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,
+1 -1
View File
@@ -18,7 +18,7 @@
export type ChatEvent =
// 鉴权生命周期(登录态变化后由 ChatAuthSync 派)
| { type: "ChatGuestLogin" }
| { type: "ChatNonGuestLogin"; token: string }
| { type: "ChatNonGuestLogin"; token: string; isVip: boolean }
| { type: "ChatLogout" }
// 业务事件
| { type: "ChatSendMessage"; content: string }
+8 -4
View File
@@ -5,6 +5,7 @@
import { fromPromise, fromCallback } from "xstate";
import { createChatWebSocket, ChatWebSocket } from "@/core/net/chat-websocket";
import type { ChatSendResponse } from "@/data/dto/chat";
import { Result, Logger } from "@/utils";
import {
@@ -83,10 +84,10 @@ export const loadHistoryActor = fromPromise<{
* HTTP 发送消息(一次发送 = 一次返回)
* - 后端响应就是 AI 回复(`ChatSendResponse.reply: string`
* - 不再调 `getLocalMessages()`(多此一举)
* - 返回 `{ reply: UiMessage }` —— `sending.onDone` 把它追加到 `context.messages`
* - 返回完整 response + 可追加的 reply,方便处理后端 paywall blocked 响应
*/
export const sendMessageHttpActor = fromPromise<
{ reply: UiMessage },
{ response: ChatSendResponse; reply: UiMessage | null },
{ content: string }
>(async ({ input }) => {
const result = await chatRepo.sendMessage(input.content);
@@ -94,7 +95,10 @@ export const sendMessageHttpActor = fromPromise<
log.error("[chat-machine] sendMessageHttpActor failed", { error: result.error });
throw result.error;
}
return { reply: sendResponseToUiMessage(result.data) };
return {
response: result.data,
reply: result.data.blocked ? null : sendResponseToUiMessage(result.data),
};
});
/** 翻历史(pagination)—— 不走 local-first,纯 server fetch */
@@ -198,4 +202,4 @@ export const sendMessageWsActor = fromPromise<void, { content: string }>(
);
// Re-export `UiMessage` type for the chat-machine.ts public API
type UiMessage = import("@/data/dto/chat").UiMessage;
type UiMessage = import("@/data/dto/chat").UiMessage;
+48 -1
View File
@@ -13,7 +13,7 @@
* 设计目标:
* - helpers 无 XState 依赖(可独立 import / 测试)
* - actors 依赖 helpersimport
* - machine 依赖 actors(import)—— 不直接依赖 helpers
* - machine 依赖 actors,并复用 helpers 里的纯状态转换函数
*
* 历史:
* - `readInitData` + `InitResult` 已删(被 2 个独立 helper 替换)
@@ -27,6 +27,8 @@ import { ChatStorage } from "@/data/storage/chat/chat_storage";
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
import { formatDate, Result, Logger } from "@/utils";
import type { ChatState } from "./chat-state";
const log = new Logger("StoresChatChatMachineHelpers");
// ============================================================
@@ -76,6 +78,51 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
};
}
export type HttpSendOutput = {
response: ChatSendResponse;
reply: UiMessage | null;
};
export function applyHttpSendOutput(
context: ChatState,
output: HttpSendOutput,
): Partial<ChatState> {
const { response, reply } = output;
if (response.blocked === true && response.blockReason === "daily_limit") {
const detail = response.blockDetail;
const lastMessage = context.messages[context.messages.length - 1];
const messages =
lastMessage && !lastMessage.isFromAI
? context.messages.slice(0, -1)
: context.messages;
return {
messages,
isReplyingAI: false,
paywallTriggered: true,
paywallReason: "daily_limit",
paywallDetail: detail
? { usedToday: detail.usedToday, limit: detail.limit }
: null,
};
}
if (!reply) {
return {
isReplyingAI: false,
};
}
return {
messages: [...context.messages, reply],
isReplyingAI: false,
paywallTriggered: false,
paywallReason: null,
paywallDetail: null,
};
}
// ============================================================
// Result → number 映射
// ============================================================
+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",
+9
View File
@@ -21,12 +21,17 @@ export interface ChatState {
* - false:走 HTTP,消耗次数
*/
wsConnected: boolean;
/** 当前非游客用户是否 VIP;游客会话固定 false。 */
isVip: boolean;
/** 游客剩余配额(次/天)—— 仅游客业务展示用
*/
guestRemainingQuota: number;
/** 游客总配额(仅游客展示用) */
guestTotalQuota: number;
quotaExceededTrigger: number;
paywallTriggered: boolean;
paywallReason: "daily_limit" | null;
paywallDetail: { usedToday: number; limit: number } | null;
isLoadingMore: boolean;
hasMore: boolean;
historyOffset: number;
@@ -46,9 +51,13 @@ export const initialState: ChatState = {
messages: [],
isReplyingAI: false,
wsConnected: false,
isVip: false,
guestRemainingQuota: 0,
guestTotalQuota: 0,
quotaExceededTrigger: 0,
paywallTriggered: false,
paywallReason: null,
paywallDetail: null,
isLoadingMore: false,
hasMore: true,
historyOffset: 0,