refactor(chat): simplify paid message lock state

This commit is contained in:
2026-07-13 15:20:07 +08:00
parent fd631168c8
commit 4682b4bf3f
17 changed files with 86 additions and 190 deletions
@@ -3,7 +3,7 @@
* ChatInsufficientCreditsBanner 积分不足横幅
*
* 何时显示:
* - 后端返回 cannotSendReason="insufficient_credits"
* - 后端返回 canSendMessage=false
*
* 视觉规格(与设计稿对齐):
* - 粉→品红渐变背景(与 splash 渐变一致)
-8
View File
@@ -24,11 +24,7 @@ export interface SentencePayload {
export interface PaywallStatusPayload {
locked: boolean;
showContent: boolean;
showUpgrade: boolean;
reason: string | null;
hint: string | null;
detail: Record<string, unknown> | null;
}
export class ChatWebSocket {
@@ -178,11 +174,7 @@ export class ChatWebSocket {
const lockDetail = payload.data?.lockDetail;
this.onPaywallStatus?.({
locked: lockDetail?.locked ?? false,
showContent: lockDetail?.showContent ?? true,
showUpgrade: lockDetail?.showUpgrade ?? false,
reason: lockDetail?.reason ?? null,
hint: lockDetail?.hint ?? null,
detail: lockDetail?.detail ?? null,
});
break;
}
@@ -15,11 +15,7 @@ describe("UnlockPrivateResponse", () => {
shortfallCredits: 0,
lockDetail: {
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
hint: null,
detail: null,
},
});
@@ -49,8 +45,7 @@ describe("UnlockPrivateResponse", () => {
},
lockDetail: {
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
},
});
@@ -73,13 +68,7 @@ describe("UnlockPrivateResponse", () => {
shortfallCredits: 5,
lockDetail: {
locked: true,
showContent: false,
showUpgrade: true,
reason: "insufficient_balance",
hint: "Insufficient credits.",
detail: {
messageId: "msg_001",
},
reason: "voice_message",
},
});
@@ -89,7 +78,10 @@ describe("UnlockPrivateResponse", () => {
expect(response.creditsCharged).toBe(0);
expect(response.requiredCredits).toBe(10);
expect(response.shortfallCredits).toBe(5);
expect(response.lockDetail.showUpgrade).toBe(true);
expect(response.lockDetail).toEqual({
locked: true,
reason: "voice_message",
});
});
it("defaults nullable content to an empty string", () => {
@@ -100,11 +92,7 @@ describe("UnlockPrivateResponse", () => {
reason: "insufficient_balance",
lockDetail: {
locked: true,
showContent: false,
showUpgrade: true,
reason: "insufficient_balance",
hint: null,
detail: null,
reason: "private_message",
},
});
@@ -123,11 +111,7 @@ describe("UnlockPrivateResponse", () => {
shortfallCredits: null,
lockDetail: {
locked: true,
showContent: false,
showUpgrade: true,
reason: "insufficient_balance",
hint: null,
detail: null,
reason: "image_paywall",
},
});
@@ -147,11 +131,26 @@ describe("UnlockPrivateResponse", () => {
expect(response.lockDetail).toEqual({
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
hint: null,
detail: null,
});
});
it("strips legacy lock detail fields while preserving lock state and type", () => {
const response = UnlockPrivateResponse.fromJson({
unlocked: false,
lockDetail: {
locked: true,
reason: "private_message",
showContent: false,
showUpgrade: true,
hint: "Legacy hint",
detail: { messageId: "legacy-message" },
},
});
expect(response.lockDetail).toEqual({
locked: true,
reason: "private_message",
});
});
});
@@ -17,7 +17,6 @@ export class ChatSendResponse {
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;
@@ -23,11 +23,7 @@ function makeMessage(
image: { type: null, url: null },
lockDetail: {
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
hint: null,
detail: null,
},
...overrides,
});
@@ -45,11 +41,7 @@ function makeResponse(
image: { type: null, url: null },
lockDetail: {
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
hint: null,
detail: null,
},
...overrides,
});
@@ -28,13 +28,8 @@ export class ChatLocalMessageStore {
audioUrl: normalizeUnlockedAudioUrl(patch.audioUrl, message.audioUrl),
image: patch.image ?? message.image,
lockDetail: patch.lockDetail ?? {
...message.lockDetail,
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
hint: null,
detail: null,
},
});
});
-10
View File
@@ -5,21 +5,15 @@ import { z } from "zod";
import {
booleanOrFalse,
booleanOrTrue,
schemaOr,
stringOrNull,
unknownRecordOrNull,
} from "../nullable-defaults";
const CHAT_IMAGE_DEFAULTS = { type: null, url: null } as const;
const CHAT_LOCK_DETAIL_DEFAULTS = {
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
hint: null,
detail: null,
} as const;
export const ChatImageSchema = schemaOr(
@@ -33,11 +27,7 @@ export const ChatImageSchema = schemaOr(
export const ChatLockDetailSchema = schemaOr(
z.object({
locked: booleanOrFalse,
showContent: booleanOrTrue,
showUpgrade: booleanOrFalse,
reason: stringOrNull,
hint: stringOrNull,
detail: unknownRecordOrNull,
}),
CHAT_LOCK_DETAIL_DEFAULTS,
);
@@ -9,7 +9,6 @@ import {
numberOrLazy,
numberOrZero,
stringOrEmpty,
stringOrNull,
} from "../nullable-defaults";
import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads";
@@ -23,7 +22,6 @@ export const ChatSendResponseSchema = z.object({
image: ChatImageSchema,
lockDetail: ChatLockDetailSchema,
canSendMessage: booleanOrTrue,
cannotSendReason: stringOrNull,
creditBalance: numberOrZero,
creditsCharged: numberOrZero,
requiredCredits: numberOrZero,
@@ -21,11 +21,7 @@ function makeResponse(
image: { type: null, url: null },
lockDetail: {
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
hint: null,
detail: null,
},
...overrides,
});
@@ -40,7 +36,6 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
upgradePromptVisible: false,
upgradeReason: null,
canSendMessage: true,
cannotSendReason: null,
creditBalance: 0,
creditsCharged: 0,
requiredCredits: 0,
@@ -73,11 +68,7 @@ describe("sendResponseToUiMessage", () => {
audioUrl: "",
lockDetail: {
locked: true,
showContent: false,
showUpgrade: true,
reason: "voice_message",
hint: "这语气好撩呀,让人有点心跳加速呢…",
detail: { messageId: "msg-1" },
},
}),
);
@@ -87,9 +78,7 @@ describe("sendResponseToUiMessage", () => {
expect(message.locked).toBe(true);
expect(message.lockReason).toBe("voice_message");
expect(message.lockedPrivate).toBeUndefined();
expect(message.privateMessageHint).toBe(
"这语气好撩呀,让人有点心跳加速呢…",
);
expect(message.privateMessageHint).toBeUndefined();
});
it("maps locked private text messages as private messages", () => {
@@ -97,11 +86,7 @@ describe("sendResponseToUiMessage", () => {
makeResponse({
lockDetail: {
locked: true,
showContent: false,
showUpgrade: true,
reason: "private_message",
hint: "Elio has a private message for you.",
detail: { messageId: "msg-1" },
},
}),
);
@@ -111,9 +96,7 @@ describe("sendResponseToUiMessage", () => {
expect(message.lockReason).toBe("private_message");
expect(message.isPrivate).toBe(true);
expect(message.lockedPrivate).toBe(true);
expect(message.privateMessageHint).toBe(
"Elio has a private message for you.",
);
expect(message.privateMessageHint).toBeUndefined();
});
it("keeps paywalled image messages visible but locked", () => {
@@ -125,11 +108,7 @@ describe("sendResponseToUiMessage", () => {
},
lockDetail: {
locked: true,
showContent: false,
showUpgrade: true,
reason: "image",
hint: "Activate VIP to view the full photo.",
detail: { imageId: "image-1" },
},
}),
);
@@ -141,6 +120,25 @@ describe("sendResponseToUiMessage", () => {
expect(message.lockReason).toBe("image");
expect(message.lockedPrivate).toBeUndefined();
});
it("does not mark a message as paywalled when its lock reason is unlocked", () => {
const message = sendResponseToUiMessage(
makeResponse({
image: {
type: "jpg",
url: "https://example.com/unlocked-photo.jpg",
},
lockDetail: {
locked: false,
reason: "image_paywall",
},
}),
);
expect(message.locked).toBe(false);
expect(message.lockReason).toBe("image_paywall");
expect(message.imagePaywalled).toBeUndefined();
});
});
describe("applyHttpSendOutput", () => {
@@ -160,22 +158,13 @@ describe("applyHttpSendOutput", () => {
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,
},
locked: false,
reason: null,
},
}),
reply: null,
@@ -186,7 +175,6 @@ describe("applyHttpSendOutput", () => {
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);
});
@@ -205,7 +193,6 @@ describe("applyHttpSendOutput", () => {
makeResponse({
reply: "This one went through, but you need credits next.",
canSendMessage: false,
cannotSendReason: "insufficient_credits",
creditBalance: 0,
requiredCredits: 2,
shortfallCredits: 2,
@@ -216,7 +203,6 @@ describe("applyHttpSendOutput", () => {
response: makeResponse({
reply: "This one went through, but you need credits next.",
canSendMessage: false,
cannotSendReason: "insufficient_credits",
creditBalance: 0,
requiredCredits: 2,
shortfallCredits: 2,
@@ -248,11 +234,7 @@ describe("localMessagesToUi", () => {
image: { type: null, url: null },
lockDetail: {
locked: true,
showContent: false,
showUpgrade: true,
reason: "voice_message",
hint: "A locked voice message is waiting.",
detail: { messageId: "voice-1" },
},
},
]);
@@ -262,7 +244,7 @@ describe("localMessagesToUi", () => {
expect(message.locked).toBe(true);
expect(message.lockReason).toBe("voice_message");
expect(message.lockedPrivate).toBeUndefined();
expect(message.privateMessageHint).toBe("A locked voice message is waiting.");
expect(message.privateMessageHint).toBeUndefined();
});
it("hides text content for image history messages", () => {
@@ -280,11 +262,7 @@ describe("localMessagesToUi", () => {
},
lockDetail: {
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
hint: null,
detail: null,
},
},
]);
@@ -48,11 +48,7 @@ function makeChatSendResponse(): ChatSendResponse {
image: { type: null, url: null },
lockDetail: {
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
hint: null,
detail: null,
},
});
}
@@ -71,11 +67,7 @@ function makeUnlockPrivateResponse(
shortfallCredits: 0,
lockDetail: {
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
hint: null,
detail: null,
},
...overrides,
});
@@ -870,11 +862,7 @@ describe("chatMachine transitions", () => {
shortfallCredits: 7,
lockDetail: {
locked: true,
showContent: false,
showUpgrade: true,
reason: "insufficient_balance",
hint: "Insufficient credits.",
detail: { messageId: "msg-voice-locked" },
reason: "voice_message",
},
}),
},
@@ -938,19 +926,10 @@ describe("chatMachine transitions", () => {
timestamp: Date.now(),
image: { type: null, url: null },
lockDetail: {
locked: true,
showContent: false,
showUpgrade: true,
reason: "insufficient_credits",
hint: "Insufficient credits.",
detail: {
requiredCredits: 2,
currentCredits: 0,
shortfallCredits: 2,
},
locked: false,
reason: null,
},
canSendMessage: false,
cannotSendReason: "insufficient_credits",
creditBalance: 0,
requiredCredits: 2,
shortfallCredits: 2,
@@ -970,7 +949,6 @@ describe("chatMachine transitions", () => {
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();
});
@@ -59,8 +59,7 @@ describe("chat promotion", () => {
},
lockDetail: {
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
},
}),
});
+1 -1
View File
@@ -23,7 +23,7 @@
*
* 发送能力:
* - 前端不再处理本地消息额度;游客 / 注册用户均以后端响应为准。
* - 后端返回 `cannotSendReason="insufficient_credits"` 时展示充值引导。
* - 后端返回 `canSendMessage=false` 时展示积分不足充值引导。
*
*/
+9 -26
View File
@@ -1,4 +1,5 @@
import { type ChatSendResponse, type UiMessage } from "@/data/dto/chat";
import type { ChatLockDetailData } from "@/data/schemas/chat";
import { todayString } from "@/utils";
/**
@@ -14,14 +15,7 @@ export function localMessagesToUi(
createdAt: string;
audioUrl?: string | null;
image?: { type: string | null; url: string | null };
lockDetail?: {
locked: boolean;
showContent: boolean;
showUpgrade: boolean;
reason: string | null;
hint: string | null;
detail: Record<string, unknown> | null;
};
lockDetail?: ChatLockDetailData;
}>,
): UiMessage[] {
return records.map((m) => ({
@@ -72,30 +66,19 @@ function getAiMessageDisplayContent(input: {
}
function deriveUiLockFields(
lockDetail:
| {
locked: boolean;
showContent: boolean;
showUpgrade: boolean;
reason: string | null;
hint: string | null;
}
| undefined,
lockDetail: ChatLockDetailData | undefined,
imageUrl?: string | null,
): Partial<UiMessage> {
const lockReason = lockDetail?.reason ?? null;
const isPrivateMessage = lockReason === "private_message";
const isVoiceMessage = lockReason === "voice_message";
const isLocked = lockDetail?.locked === true;
const isPrivateMessage = isLocked && lockReason === "private_message";
const isLockedImage =
isLocked && (lockReason === "image_paywall" || lockReason === "image");
return {
...(imageUrl ? { imageUrl } : {}),
...(imageUrl && lockDetail?.showUpgrade ? { imagePaywalled: true } : {}),
...(imageUrl && isLockedImage ? { imagePaywalled: true } : {}),
...(lockDetail ? { locked: lockDetail.locked, lockReason } : {}),
...(isPrivateMessage ? { isPrivate: true } : {}),
...(isPrivateMessage && lockDetail?.locked
? { lockedPrivate: true }
: {}),
...((isPrivateMessage || isVoiceMessage) && lockDetail?.hint
? { privateMessageHint: lockDetail.hint }
: {}),
...(isPrivateMessage ? { lockedPrivate: true } : {}),
};
}
+8 -5
View File
@@ -119,7 +119,6 @@ export const clearUpgradePromptAction = chatAssign(() => ({
upgradePromptVisible: false,
upgradeReason: null,
canSendMessage: true,
cannotSendReason: null,
requiredCredits: 0,
shortfallCredits: 0,
}));
@@ -173,7 +172,6 @@ async function sendMessageViaHttp(content: string): Promise<{
void chatRepo.prefetchMediaForSendResponse(result.data);
const isInsufficientCredits =
result.data.canSendMessage === false &&
result.data.cannotSendReason === "insufficient_credits" &&
!hasRenderableSendResponse(result.data);
return {
response: result.data,
@@ -183,12 +181,17 @@ async function sendMessageViaHttp(content: string): Promise<{
}
function hasRenderableSendResponse(response: ChatSendResponse): boolean {
const isLockedPaidMessage =
response.lockDetail.locked &&
(response.lockDetail.reason === "private_message" ||
response.lockDetail.reason === "voice_message" ||
response.lockDetail.reason === "image_paywall" ||
response.lockDetail.reason === "image");
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"
isLockedPaidMessage
);
}
+1 -18
View File
@@ -34,9 +34,7 @@ export function applyHttpSendOutput(
): Partial<ChatState> {
const { response, reply } = output;
const isInsufficientCredits =
response.canSendMessage === false &&
response.cannotSendReason === "insufficient_credits";
const isInsufficientCredits = response.canSendMessage === false;
const upgradeReason: ChatUpgradeReason | null = isInsufficientCredits
? "insufficient_credits"
: null;
@@ -65,19 +63,6 @@ export function applyHttpSendOutput(
};
}
if (
response.lockDetail.showUpgrade &&
response.lockDetail.reason === "private_message"
) {
return {
messages: [...context.messages, reply],
...finishPendingReply(context),
upgradePromptVisible: false,
upgradeReason: null,
...sendCapabilityState,
};
}
return {
messages: [...context.messages, reply],
...finishPendingReply(context),
@@ -92,7 +77,6 @@ function getSendCapabilityState(
): Pick<
ChatState,
| "canSendMessage"
| "cannotSendReason"
| "creditBalance"
| "creditsCharged"
| "requiredCredits"
@@ -100,7 +84,6 @@ function getSendCapabilityState(
> {
return {
canSendMessage: response.canSendMessage,
cannotSendReason: response.cannotSendReason,
creditBalance: response.creditBalance,
creditsCharged: response.creditsCharged,
requiredCredits: response.requiredCredits,
-2
View File
@@ -27,7 +27,6 @@ export interface ChatState {
upgradePromptVisible: boolean;
upgradeReason: ChatUpgradeReason | null;
canSendMessage: boolean;
cannotSendReason: string | null;
creditBalance: number;
creditsCharged: number;
requiredCredits: number;
@@ -63,7 +62,6 @@ export const initialState: ChatState = {
upgradePromptVisible: false,
upgradeReason: null,
canSendMessage: true,
cannotSendReason: null,
creditBalance: 0,
creditsCharged: 0,
requiredCredits: 0,
+11 -2
View File
@@ -51,8 +51,7 @@ export function applySingleUnlockOutput(
locked: output.response.lockDetail.locked,
lockReason: output.response.lockDetail.reason,
imagePaywalled: resolvedImageUrl
? output.response.lockDetail.locked &&
output.response.lockDetail.showUpgrade
? isLockedImage(output.response.lockDetail)
: undefined,
lockedPrivate: false,
privateMessageHint: null,
@@ -60,6 +59,16 @@ export function applySingleUnlockOutput(
});
}
function isLockedImage(lockDetail: {
locked: boolean;
reason: string | null;
}): boolean {
return (
lockDetail.locked &&
(lockDetail.reason === "image_paywall" || lockDetail.reason === "image")
);
}
function getUnlockedAudioUrl(
message: UiMessage,
response: UnlockPrivateResponse,