feat(chat): render role-bound payment guidance
Docker Image / Build and Push Docker Image (push) Successful in 5m2s

This commit is contained in:
Codex
2026-07-27 18:14:04 +08:00
parent 019caae598
commit 8c0d1ad3ce
49 changed files with 882 additions and 81 deletions
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import {
ChatLockDetailSchema,
ChatSendResponseSchema,
PaymentGuidanceSchema,
UnlockHistoryResponseSchema,
UnlockPrivateResponseSchema,
} from "@/data/schemas/chat";
const guidance = {
guidanceId: "guidance-1",
characterId: "maya-tan",
characterName: "Maya Tan",
scene: "voiceLocked",
mode: "guide",
title: "Keep talking with Maya",
copy: "Hey love, I want to keep talking. This voice message needs 20 credits.",
ctaLabel: "View VIP & credit options",
purchaseOptions: ["vip", "topup"],
target: "subscription",
ruleId: "payment_guidance_voiceLocked",
} as const;
describe("payment guidance wire contract", () => {
it("parses the public decision without changing role identity", () => {
expect(PaymentGuidanceSchema.parse(guidance)).toEqual(guidance);
});
it("keeps all backend lock facts instead of dropping hint and CTA", () => {
expect(
ChatLockDetailSchema.parse({
locked: true,
showContent: false,
showUpgrade: true,
reason: "voice_message",
hint: "This voice message needs 20 credits.",
actionLabel: "Unlock",
detail: { requiredCredits: 20 },
}),
).toEqual({
locked: true,
showContent: false,
showUpgrade: true,
reason: "voice_message",
hint: "This voice message needs 20 credits.",
actionLabel: "Unlock",
detail: { requiredCredits: 20 },
});
});
it("keeps backward compatibility when guidance is absent", () => {
const parsed = ChatSendResponseSchema.parse({ reply: "Hello" });
expect(parsed.paymentGuidance).toBeNull();
});
it("parses guidance on chat and both unlock responses", () => {
expect(
ChatSendResponseSchema.parse({
reply: "",
canSendMessage: false,
paymentGuidance: guidance,
}).paymentGuidance,
).toEqual(guidance);
expect(
UnlockPrivateResponseSchema.parse({
unlocked: false,
reason: "insufficient_credits",
paymentGuidance: guidance,
}).paymentGuidance,
).toEqual(guidance);
expect(
UnlockHistoryResponseSchema.parse({
unlocked: false,
reason: "insufficient_balance",
paymentGuidance: { ...guidance, scene: "historyLocked" },
}).paymentGuidance?.scene,
).toBe("historyLocked");
});
});
+10 -1
View File
@@ -3,7 +3,11 @@
*/
import { z } from "zod";
import { booleanOrFalse, schemaOr, stringOrNull } from "../nullable-defaults";
import {
booleanOrFalse,
schemaOr,
stringOrNull,
} from "../nullable-defaults";
const CHAT_IMAGE_DEFAULTS = { type: null, url: null } as const;
@@ -23,7 +27,12 @@ export const ChatImageSchema = schemaOr(
export const ChatLockDetailSchema = schemaOr(
z.object({
locked: booleanOrFalse,
showContent: z.boolean().nullish(),
showUpgrade: z.boolean().nullish(),
reason: stringOrNull,
hint: z.string().nullish(),
actionLabel: z.string().nullish(),
detail: z.record(z.string(), z.unknown()).nullish(),
}),
CHAT_LOCK_DETAIL_DEFAULTS,
).readonly();
@@ -13,6 +13,41 @@ import {
import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads";
import { ChatActionSchema } from "../chat_action";
export const PaymentGuidanceSceneSchema = z.enum([
"vip",
"insufficientCredits",
"chatInterrupted",
"voiceLocked",
"imageLocked",
"privateMessageLocked",
"historyLocked",
"privateZoneLocked",
"unknownLock",
"whyPay",
"leavingAfterCredits",
"paymentIssue",
"externalRisk",
"purchaseOptions",
]);
export const PaymentGuidanceSchema = z
.object({
guidanceId: z.string().min(1),
characterId: z.string().min(1),
characterName: z.string().min(1),
scene: PaymentGuidanceSceneSchema,
mode: z.enum(["guide", "careOnly", "issueSupport", "riskReminder"]),
title: z.string().nullable().default(null),
copy: z.string().min(1),
ctaLabel: z.string().nullable().default(null),
purchaseOptions: z.array(z.enum(["vip", "topup"])).readonly(),
target: z.enum(["subscription", "giftCatalog"]).nullable().default(null),
ruleId: z.string().min(1),
})
.readonly();
export type PaymentGuidance = z.output<typeof PaymentGuidanceSchema>;
export const CommercialActionSchema = z
.object({
actionId: z.string().min(1),
@@ -42,6 +77,7 @@ export const ChatSendResponseSchema = z
requiredCredits: numberOrZero,
shortfallCredits: numberOrZero,
commercialAction: CommercialActionSchema.nullish().default(null),
paymentGuidance: PaymentGuidanceSchema.nullish().default(null),
chatAction: ChatActionSchema.nullish().default(null),
})
.readonly();
@@ -9,6 +9,7 @@ import {
numberOrZero,
recordOrEmpty,
} from "../../nullable-defaults";
import { PaymentGuidanceSchema } from "./chat_send_response";
export const UnlockHistoryCostsByMessageSchema = recordOrEmpty(z.number());
@@ -33,6 +34,7 @@ export const UnlockHistoryResponseSchema = z
shortfallCredits: numberOrZero,
costsByMessage: UnlockHistoryCostsByMessageSchema,
messageIds: arrayOrEmpty(z.string()),
paymentGuidance: PaymentGuidanceSchema.nullish().default(null),
})
.readonly();
@@ -8,6 +8,7 @@ import {
} from "../../nullable-defaults";
import { ChatLockTypeSchema } from "../chat_lock_type";
import { ChatImageSchema } from "../chat_payloads";
import { PaymentGuidanceSchema } from "./chat_send_response";
/**
* 单条历史付费 / 私密消息解锁响应。
@@ -28,6 +29,7 @@ export const UnlockPrivateResponseSchema = z
creditsCharged: numberOrZero,
requiredCredits: numberOrZero,
shortfallCredits: numberOrZero,
paymentGuidance: PaymentGuidanceSchema.nullish().default(null),
})
.readonly();
@@ -39,6 +39,28 @@ const lockedAlbum = {
};
describe("Private Album schema models", () => {
it("keeps shared payment guidance on an insufficient-credit response", () => {
const response = PrivateAlbumUnlockResponseSchema.parse({
albumId: ALBUM_ID,
reason: "insufficient_credits",
paymentGuidance: {
guidanceId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
characterId: "elio",
characterName: "Elio Silvestri",
scene: "privateZoneLocked",
mode: "guide",
title: "Keep talking with Elio",
copy: "Baby, this album needs credits.",
ctaLabel: "View VIP & credit options",
purchaseOptions: ["vip", "topup"],
target: "subscription",
ruleId: "payment_guidance_privateZoneLocked",
},
});
expect(response.paymentGuidance?.characterId).toBe("elio");
});
it("keeps a locked album cover URL while preserving the lock state", () => {
const response = PrivateAlbumsResponseSchema.parse({
items: [lockedAlbum],
@@ -10,6 +10,7 @@ import {
stringOrEmpty,
stringOrNull,
} from "../nullable-defaults";
import { PaymentGuidanceSchema } from "../chat";
export const PrivateZoneVideoPostSchema = z
.object({
@@ -63,6 +64,7 @@ export const PrivateZonePostUnlockResponseSchema = z
shortfallCredits: numberOrZero,
creditBalance: numberOrZero,
post: schemaOrNull(PrivateZoneVideoPostSchema),
paymentGuidance: PaymentGuidanceSchema.nullish(),
})
.readonly();
@@ -7,6 +7,7 @@ import {
stringOrEmpty,
} from "../../nullable-defaults";
import { PrivateAlbumImageSchema } from "../private_album";
import { PaymentGuidanceSchema } from "../../chat";
export const PrivateAlbumUnlockReasonSchema = z.enum([
"ok",
@@ -30,6 +31,7 @@ export const PrivateAlbumUnlockResponseSchema = z
creditBalance: numberOrZero,
shortfallCredits: numberOrZero,
images: arrayOrEmpty(PrivateAlbumImageSchema),
paymentGuidance: PaymentGuidanceSchema.nullish(),
})
.readonly();