diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index e010fac4..ce88b7ab 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -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); diff --git a/src/app/chat/components/chat-quota-exhausted-banner.tsx b/src/app/chat/components/chat-quota-exhausted-banner.tsx index b19addf1..14e380a2 100644 --- a/src/app/chat/components/chat-quota-exhausted-banner.tsx +++ b/src/app/chat/components/chat-quota-exhausted-banner.tsx @@ -45,7 +45,7 @@ export function ChatQuotaExhaustedBanner({ onUnlock(); return; } - router.push(ROUTES.auth); + router.push(`${ROUTES.subscription}?type=vip`); }; return ( diff --git a/src/data/dto/chat/guest_chat_quota.ts b/src/data/dto/chat/guest_chat_quota.ts index 5d10639b..820b6caf 100644 --- a/src/data/dto/chat/guest_chat_quota.ts +++ b/src/data/dto/chat/guest_chat_quota.ts @@ -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; diff --git a/src/stores/chat/chat-auth-sync.tsx b/src/stores/chat/chat-auth-sync.tsx index f9f22c08..4e5edfa2 100644 --- a/src/stores/chat/chat-auth-sync.tsx +++ b/src/stores/chat/chat-auth-sync.tsx @@ -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(null); + const prevSessionKeyRef = useRef(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; diff --git a/src/stores/chat/chat-context.tsx b/src/stores/chat/chat-context.tsx index d286952c..3a52bfb8 100644 --- a/src/stores/chat/chat-context.tsx +++ b/src/stores/chat/chat-context.tsx @@ -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, diff --git a/src/stores/chat/chat-events.ts b/src/stores/chat/chat-events.ts index 1b039c08..9bbaf03a 100644 --- a/src/stores/chat/chat-events.ts +++ b/src/stores/chat/chat-events.ts @@ -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 } diff --git a/src/stores/chat/chat-machine.actors.ts b/src/stores/chat/chat-machine.actors.ts index 5774920e..d4b6197e 100644 --- a/src/stores/chat/chat-machine.actors.ts +++ b/src/stores/chat/chat-machine.actors.ts @@ -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( ); // Re-export `UiMessage` type for the chat-machine.ts public API -type UiMessage = import("@/data/dto/chat").UiMessage; \ No newline at end of file +type UiMessage = import("@/data/dto/chat").UiMessage; diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts index a3c0dbdd..19de1ca0 100644 --- a/src/stores/chat/chat-machine.helpers.ts +++ b/src/stores/chat/chat-machine.helpers.ts @@ -13,7 +13,7 @@ * 设计目标: * - helpers 无 XState 依赖(可独立 import / 测试) * - actors 依赖 helpers(import) - * - 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 { + 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 映射 // ============================================================ diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index 8b16f351..2892fd5e 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -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 已连 + 内容非空 → 走 WS(sendingViaWs 等 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", diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index 7b1d7d99..af76e714 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -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,