From 958a0f8ffd8d2908a20d5d2b83a6a937b24455f0 Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 30 Jun 2026 14:17:14 +0800 Subject: [PATCH] feat(chat): handle credit-gated send responses --- docs/backend/send.md | 53 ++++++++++ src/app/chat/chat-screen.helpers.ts | 22 ++++- src/app/chat/chat-screen.tsx | 29 +++++- src/data/dto/chat/chat_send_response.ts | 6 ++ src/data/schemas/chat/chat_send_response.ts | 9 +- src/data/schemas/nullable-defaults.ts | 5 +- .../__tests__/chat-machine.helpers.test.ts | 96 +++++++++++++++++++ .../chat-machine.transitions.test.ts | 8 ++ src/stores/chat/chat-machine.actors.ts | 20 +++- src/stores/chat/chat-machine.helpers.ts | 46 +++++++-- src/stores/chat/chat-machine.ts | 10 +- src/stores/chat/chat-state.ts | 16 +++- 12 files changed, 303 insertions(+), 17 deletions(-) create mode 100644 docs/backend/send.md diff --git a/docs/backend/send.md b/docs/backend/send.md new file mode 100644 index 00000000..54439e48 --- /dev/null +++ b/docs/backend/send.md @@ -0,0 +1,53 @@ +这次新增/补齐的主要是这些字段,都是给前端判断“能不能继续发、当前内容锁不锁、扣了多少积分”用的。 +{ + "canSendMessage": false, + "cannotSendReason": "insufficient_credits", + "creditBalance": 0, + "creditsCharged": 0, + "requiredCredits": 2, + "shortfallCredits": 2 +} +字段含义 +canSendMessage +用户是否还能继续发普通聊天消息。 +如果是 false,前端应该禁用输入框/发送按钮,提示充值。 +cannotSendReason +不能继续发送消息的原因。 +目前主要是: +insufficient_credits +表示免费聊天次数用完,并且积分也不够继续发消息。 +creditBalance +本次接口处理后的用户积分余额。 +比如原来 100,发了一条会员普通消息扣 2,这里返回 98。 +creditsCharged +本次实际扣掉的积分。 +例如: +普通聊天扣 2 +照片扣 40 +语音消息扣 20 +私密消息扣 10 +没有扣费就是 0 +requiredCredits +本次动作理论上需要多少积分。 +比如用户想看照片,需要 40,就返回 40。 +如果免费额度内,通常是 0。 +shortfallCredits +用户还差多少积分。 +比如照片需要 40,用户只有 15,则: +{ + "requiredCredits": 40, + "creditBalance": 15, + "shortfallCredits": 25 +} +和旧字段的关系 +blocked +当前这条回复/内容是否被锁住。比如照片、语音、私密消息积分不够时是 true。 +blockReason +当前内容被锁的原因,比如: +insufficient_credits +image_paywall +voice_message +private_message +lockDetail +给前端展示锁定卡片/充值提示的详细信息。 +里面也会带 requiredCredits/currentCredits/shortfallCredits,方便前端直接渲染弹窗。 \ No newline at end of file diff --git a/src/app/chat/chat-screen.helpers.ts b/src/app/chat/chat-screen.helpers.ts index 4c65f06c..5bcc942b 100644 --- a/src/app/chat/chat-screen.helpers.ts +++ b/src/app/chat/chat-screen.helpers.ts @@ -84,8 +84,26 @@ export function getChatPaywallSubscriptionUrl(): string { }); } -export function getChatPaywallNavigationUrl(loginStatus: LoginStatus): string { - const subscriptionUrl = getChatPaywallSubscriptionUrl(); +export function getChatCreditsTopUpSubscriptionUrl(): string { + return ROUTE_BUILDERS.subscription("topup", { + returnTo: "chat", + }); +} + +export function getInsufficientCreditsSubscriptionType( + isVip: boolean, +): "vip" | "topup" { + return isVip ? "topup" : "vip"; +} + +export function getChatPaywallNavigationUrl( + loginStatus: LoginStatus, + type: "vip" | "topup" = "vip", +): string { + const subscriptionUrl = + type === "topup" + ? getChatCreditsTopUpSubscriptionUrl() + : getChatPaywallSubscriptionUrl(); if (deriveIsGuest(loginStatus)) { return ROUTE_BUILDERS.authWithRedirect(subscriptionUrl); } diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index c68eda23..107fe12e 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -6,6 +6,7 @@ import { useRouter } from "next/navigation"; import { useAuthState } from "@/stores/auth/auth-context"; import { useChatDispatch, useChatState } from "@/stores/chat/chat-context"; +import { useUserState } from "@/stores/user/user-context"; import { MobileShell } from "@/app/_components/core"; @@ -22,6 +23,7 @@ import { import { deriveIsGuest, getChatPaywallNavigationUrl, + getInsufficientCreditsSubscriptionType, isChatDevelopmentEnvironment, openChatInExternalBrowser, recordExternalBrowserPromptShown, @@ -34,6 +36,7 @@ export function ChatScreen() { const state = useChatState(); const chatDispatch = useChatDispatch(); const authState = useAuthState(); + const userState = useUserState(); const router = useRouter(); const [showExternalBrowserDialog, setShowExternalBrowserDialog] = useState(false); @@ -43,8 +46,17 @@ export function ChatScreen() { // 消息数量限制由后端统一返回 lockDetail,前端不再处理本地额度。 const showMessageLimitBanner = - state.upgradePromptVisible && state.upgradeReason === "weekly_limit"; - const messageLimitTitle = "The limit for free chat times\nhas been reached"; + state.upgradePromptVisible && + (state.upgradeReason === "weekly_limit" || + state.upgradeReason === "insufficient_credits"); + const messageLimitTitle = + state.upgradeReason === "insufficient_credits" + ? "Insufficient credits\nTop up to continue chatting" + : "The limit for free chat times\nhas been reached"; + const messageLimitCtaLabel = + state.upgradeReason === "insufficient_credits" + ? "Top up credits to continue" + : "Unlock your membership to continue"; const externalBrowserPromptShownRef = useRef(false); @@ -102,7 +114,17 @@ export function ChatScreen() { } function handleMessageLimitUnlock(): void { - openChatPaywallSubscription(); + const subscriptionType = + state.upgradeReason === "insufficient_credits" + ? getInsufficientCreditsSubscriptionType(userState.isVip) + : "vip"; + + router.push( + getChatPaywallNavigationUrl( + authState.loginStatus, + subscriptionType, + ), + ); } function handleUnlockVoiceMessage(): void { @@ -137,6 +159,7 @@ export function ChatScreen() { {showMessageLimitBanner ? ( ) : ( diff --git a/src/data/dto/chat/chat_send_response.ts b/src/data/dto/chat/chat_send_response.ts index 69269cf5..eac4b79b 100644 --- a/src/data/dto/chat/chat_send_response.ts +++ b/src/data/dto/chat/chat_send_response.ts @@ -17,6 +17,12 @@ export class ChatSendResponse { declare readonly timestamp: number; declare readonly image: ChatImageData; declare readonly lockDetail: ChatLockDetailData; + declare readonly canSendMessage: boolean; + declare readonly cannotSendReason: string | null; + declare readonly creditBalance: number; + declare readonly creditsCharged: number; + declare readonly requiredCredits: number; + declare readonly shortfallCredits: number; private constructor(input: ChatSendResponseInput) { const data = ChatSendResponseSchema.parse(input); diff --git a/src/data/schemas/chat/chat_send_response.ts b/src/data/schemas/chat/chat_send_response.ts index 438b0d95..b0c35a29 100644 --- a/src/data/schemas/chat/chat_send_response.ts +++ b/src/data/schemas/chat/chat_send_response.ts @@ -3,17 +3,24 @@ * */ import { z } from "zod"; +import { booleanOrFalse, stringOrNull } from "../nullable-defaults"; import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads"; export const ChatSendResponseSchema = z.object({ reply: z.string(), audioUrl: z.string().default(""), messageId: z.string(), - isGuest: z.boolean().default(false), + isGuest: booleanOrFalse, // 函数式 default —— 每次 parse 时重新调 Date.now()(后端不返回 timestamp 时用当下时间) timestamp: z.number().default(() => Date.now()), image: ChatImageSchema, lockDetail: ChatLockDetailSchema, + canSendMessage: z.boolean().default(true), + cannotSendReason: stringOrNull, + creditBalance: z.number().default(0), + creditsCharged: z.number().default(0), + requiredCredits: z.number().default(0), + shortfallCredits: z.number().default(0), }); export type ChatSendResponseInput = z.input; diff --git a/src/data/schemas/nullable-defaults.ts b/src/data/schemas/nullable-defaults.ts index 4d9df94b..aa205769 100644 --- a/src/data/schemas/nullable-defaults.ts +++ b/src/data/schemas/nullable-defaults.ts @@ -18,6 +18,9 @@ export const stringOrEmpty = z .transform((v) => v ?? "") .default(""); +/** `string | null | undefined` → `string | null`(默认 null) */ +export const stringOrNull = z.string().nullable().default(null); + /** `number | null | undefined` → `number`(默认 0) */ export const numberOrZero = z .number() @@ -30,4 +33,4 @@ export const booleanOrFalse = z .boolean() .nullable() .transform((v) => v ?? false) - .default(false); \ No newline at end of file + .default(false); diff --git a/src/stores/chat/__tests__/chat-machine.helpers.test.ts b/src/stores/chat/__tests__/chat-machine.helpers.test.ts index 80de88f6..38ea1619 100644 --- a/src/stores/chat/__tests__/chat-machine.helpers.test.ts +++ b/src/stores/chat/__tests__/chat-machine.helpers.test.ts @@ -38,6 +38,12 @@ function makeChatState(overrides: Partial = {}): ChatState { pendingReplyCount: 1, upgradePromptVisible: false, upgradeReason: null, + canSendMessage: true, + cannotSendReason: null, + creditBalance: 0, + creditsCharged: 0, + requiredCredits: 0, + shortfallCredits: 0, isLoadingMore: false, hasMore: true, historyOffset: 0, @@ -168,6 +174,96 @@ describe("applyHttpSendOutput", () => { expect(nextState.upgradePromptVisible).toBe(true); expect(nextState.upgradeReason).toBe("weekly_limit"); }); + + it("stores insufficient credit state and removes failed optimistic messages", () => { + const context = makeChatState({ + messages: [ + { + content: "hello", + isFromAI: false, + date: "2026-06-25", + }, + ], + }); + + const nextState = applyHttpSendOutput(context, { + response: makeResponse({ + reply: "", + messageId: "", + canSendMessage: false, + cannotSendReason: "insufficient_credits", + creditBalance: 0, + creditsCharged: 0, + requiredCredits: 2, + shortfallCredits: 2, + lockDetail: { + locked: true, + showContent: false, + showUpgrade: true, + reason: "insufficient_credits", + hint: "Insufficient credits.", + detail: { + requiredCredits: 2, + currentCredits: 0, + shortfallCredits: 2, + }, + }, + }), + reply: null, + }); + + expect(nextState.messages).toEqual([]); + expect(nextState.isReplyingAI).toBe(false); + expect(nextState.upgradePromptVisible).toBe(true); + expect(nextState.upgradeReason).toBe("insufficient_credits"); + expect(nextState.canSendMessage).toBe(false); + expect(nextState.cannotSendReason).toBe("insufficient_credits"); + expect(nextState.requiredCredits).toBe(2); + expect(nextState.shortfallCredits).toBe(2); + }); + + it("keeps a valid reply while marking future sends as unavailable", () => { + const context = makeChatState({ + messages: [ + { + content: "hello", + isFromAI: false, + date: "2026-06-25", + }, + ], + }); + const reply = sendResponseToUiMessage( + makeResponse({ + reply: "This one went through, but you need credits next.", + canSendMessage: false, + cannotSendReason: "insufficient_credits", + creditBalance: 0, + requiredCredits: 2, + shortfallCredits: 2, + }), + ); + + const nextState = applyHttpSendOutput(context, { + response: makeResponse({ + reply: "This one went through, but you need credits next.", + canSendMessage: false, + cannotSendReason: "insufficient_credits", + creditBalance: 0, + requiredCredits: 2, + shortfallCredits: 2, + }), + reply, + }); + + expect(nextState.messages).toHaveLength(2); + expect(nextState.messages?.[1]).toMatchObject({ + content: "This one went through, but you need credits next.", + isFromAI: true, + }); + expect(nextState.upgradePromptVisible).toBe(true); + expect(nextState.upgradeReason).toBe("insufficient_credits"); + expect(nextState.canSendMessage).toBe(false); + }); }); describe("localMessagesToUi", () => { diff --git a/src/stores/chat/__tests__/chat-machine.transitions.test.ts b/src/stores/chat/__tests__/chat-machine.transitions.test.ts index 3b3a690d..8fb4474a 100644 --- a/src/stores/chat/__tests__/chat-machine.transitions.test.ts +++ b/src/stores/chat/__tests__/chat-machine.transitions.test.ts @@ -467,6 +467,11 @@ describe("chatMachine transitions", () => { limit: 3, }, }, + canSendMessage: false, + cannotSendReason: "insufficient_credits", + creditBalance: 0, + requiredCredits: 2, + shortfallCredits: 2, }), reply: null, }, @@ -474,11 +479,14 @@ describe("chatMachine transitions", () => { expect(actor.getSnapshot().context.upgradePromptVisible).toBe(true); expect(actor.getSnapshot().context.upgradeReason).toBe("weekly_limit"); + expect(actor.getSnapshot().context.canSendMessage).toBe(false); actor.send({ type: "ChatPaymentSucceeded" }); expect(actor.getSnapshot().context.upgradePromptVisible).toBe(false); expect(actor.getSnapshot().context.upgradeReason).toBeNull(); + expect(actor.getSnapshot().context.canSendMessage).toBe(true); + expect(actor.getSnapshot().context.cannotSendReason).toBeNull(); actor.stop(); }); diff --git a/src/stores/chat/chat-machine.actors.ts b/src/stores/chat/chat-machine.actors.ts index 751e599f..31ba1648 100644 --- a/src/stores/chat/chat-machine.actors.ts +++ b/src/stores/chat/chat-machine.actors.ts @@ -160,8 +160,26 @@ async function sendMessageViaHttp(content: string): Promise<{ result.data.lockDetail.locked && result.data.lockDetail.showUpgrade && result.data.lockDetail.reason === "weekly_limit"; + const isInsufficientCredits = + result.data.canSendMessage === false && + result.data.cannotSendReason === "insufficient_credits" && + !hasRenderableSendResponse(result.data); return { response: result.data, - reply: isMessageLimit ? null : sendResponseToUiMessage(result.data), + reply: + isMessageLimit || isInsufficientCredits + ? null + : sendResponseToUiMessage(result.data), }; } + +function hasRenderableSendResponse(response: ChatSendResponse): boolean { + return ( + response.reply.trim().length > 0 || + response.audioUrl.trim().length > 0 || + Boolean(response.image.url) || + response.lockDetail.reason === "private_message" || + response.lockDetail.reason === "voice_message" || + response.lockDetail.reason === "image_paywall" + ); +} diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts index d5269110..5bc73a15 100644 --- a/src/stores/chat/chat-machine.helpers.ts +++ b/src/stores/chat/chat-machine.helpers.ts @@ -3,7 +3,7 @@ import { getChatRepository } from "@/data/repositories/chat_repository"; import { ChatSendResponse } from "@/data/dto/chat/chat_send_response"; import { todayString, Result, Logger } from "@/utils"; -import type { ChatState } from "./chat-state"; +import type { ChatState, ChatUpgradeReason } from "./chat-state"; const log = new Logger("StoresChatChatMachineHelpers"); @@ -170,29 +170,40 @@ export function applyHttpSendOutput( const { response, reply } = output; const lockDetail = response.lockDetail; - const isMessageLimitBlocked = + const isWeeklyLimitBlocked = lockDetail.locked && lockDetail.showUpgrade && lockDetail.reason === "weekly_limit"; + const isInsufficientCredits = + response.canSendMessage === false && + response.cannotSendReason === "insufficient_credits"; + const upgradeReason: ChatUpgradeReason | null = isWeeklyLimitBlocked + ? "weekly_limit" + : isInsufficientCredits + ? "insufficient_credits" + : null; + const sendCapabilityState = getSendCapabilityState(response); - if (isMessageLimitBlocked) { + if (upgradeReason) { const lastMessage = context.messages[context.messages.length - 1]; const messages = - lastMessage && !lastMessage.isFromAI + !reply && lastMessage && !lastMessage.isFromAI ? context.messages.slice(0, -1) : context.messages; return { - messages, + messages: reply ? [...messages, reply] : messages, ...finishPendingReply(context), upgradePromptVisible: true, - upgradeReason: "weekly_limit", + upgradeReason, + ...sendCapabilityState, }; } if (!reply) { return { ...finishPendingReply(context), + ...sendCapabilityState, }; } @@ -202,6 +213,7 @@ export function applyHttpSendOutput( ...finishPendingReply(context), upgradePromptVisible: false, upgradeReason: null, + ...sendCapabilityState, }; } @@ -210,6 +222,28 @@ export function applyHttpSendOutput( ...finishPendingReply(context), upgradePromptVisible: false, upgradeReason: null, + ...sendCapabilityState, + }; +} + +function getSendCapabilityState( + response: ChatSendResponse, +): Pick< + ChatState, + | "canSendMessage" + | "cannotSendReason" + | "creditBalance" + | "creditsCharged" + | "requiredCredits" + | "shortfallCredits" +> { + return { + canSendMessage: response.canSendMessage, + cannotSendReason: response.cannotSendReason, + creditBalance: response.creditBalance, + creditsCharged: response.creditsCharged, + requiredCredits: response.requiredCredits, + shortfallCredits: response.shortfallCredits, }; } diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index 9bc10dd0..1022729f 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -206,6 +206,10 @@ export const chatMachine = setup({ clearUpgradePrompt: assign(() => ({ upgradePromptVisible: false, upgradeReason: null, + canSendMessage: true, + cannotSendReason: null, + requiredCredits: 0, + shortfallCredits: 0, })), markPaymentUnlockPending: assign(() => ({ @@ -306,7 +310,8 @@ export const chatMachine = setup({ on: { ChatSendMessage: { actions: ["appendGuestUserMessage", "enqueueMessage"], - guard: ({ event }) => event.content.trim().length > 0, + guard: ({ context, event }) => + context.canSendMessage && event.content.trim().length > 0, }, ChatSendImage: { actions: "appendGuestUserImage", @@ -441,7 +446,8 @@ export const chatMachine = setup({ ready: { on: { ChatSendMessage: { - guard: ({ event }) => event.content.trim().length > 0, + guard: ({ context, event }) => + context.canSendMessage && event.content.trim().length > 0, actions: ["appendUserMessage", "enqueueMessage"], }, ChatSendImage: { diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index 3c2ed108..588baa21 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -1,11 +1,19 @@ import type { UiMessage } from "@/data/dto/chat"; +export type ChatUpgradeReason = "weekly_limit" | "insufficient_credits"; + export interface ChatState { messages: UiMessage[]; isReplyingAI: boolean; pendingReplyCount: number; upgradePromptVisible: boolean; - upgradeReason: "weekly_limit" | null; + upgradeReason: ChatUpgradeReason | null; + canSendMessage: boolean; + cannotSendReason: string | null; + creditBalance: number; + creditsCharged: number; + requiredCredits: number; + shortfallCredits: number; isLoadingMore: boolean; hasMore: boolean; historyOffset: number; @@ -27,6 +35,12 @@ export const initialState: ChatState = { pendingReplyCount: 0, upgradePromptVisible: false, upgradeReason: null, + canSendMessage: true, + cannotSendReason: null, + creditBalance: 0, + creditsCharged: 0, + requiredCredits: 0, + shortfallCredits: 0, isLoadingMore: false, hasMore: true, historyOffset: 0,