feat(chat): add support action cards and live entitlements
Docker Image / Build and Push Docker Image (push) Successful in 2m2s

This commit is contained in:
Codex
2026-07-24 17:59:57 +08:00
parent 17236bd14e
commit 30ab2c2c97
52 changed files with 1151 additions and 40 deletions
@@ -9,6 +9,33 @@ import type { PaymentApi } from "@/data/services/api";
import { Result } from "@/utils/result";
describe("PaymentRepository", () => {
it("forwards chat action attribution only in the owned order request", async () => {
const createOrder = vi.fn().mockResolvedValue({
orderId: "pay_xxx",
payParams: null,
});
const repository = new PaymentRepository({
createOrder,
} as unknown as PaymentApi);
const result = await repository.createOrder(
"vip_monthly",
"stripe",
true,
undefined,
undefined,
"019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
);
expect(createOrder).toHaveBeenCalledWith({
planId: "vip_monthly",
payChannel: "stripe",
autoRenew: true,
chatActionId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
});
expect(Result.isOk(result)).toBe(true);
});
it("loads a character gift catalog without adapting product metadata", async () => {
const catalog = GiftProductsResponseSchema.parse({
characterId: "elio",
@@ -31,6 +31,7 @@ export interface IPaymentRepository {
autoRenew: boolean,
recipientCharacterId?: string,
commercialOfferId?: string,
chatActionId?: string,
): Promise<Result<CreatePaymentOrderResponse>>;
/** 查询支付订单状态。 */
@@ -63,6 +63,7 @@ export class PaymentRepository implements IPaymentRepository {
autoRenew: boolean,
recipientCharacterId?: string,
commercialOfferId?: string,
chatActionId?: string,
): Promise<Result<CreatePaymentOrderResponse>> {
const request = CreatePaymentOrderRequestSchema.parse({
planId,
@@ -70,6 +71,7 @@ export class PaymentRepository implements IPaymentRepository {
autoRenew,
...(recipientCharacterId ? { recipientCharacterId } : {}),
...(commercialOfferId ? { commercialOfferId } : {}),
...(chatActionId ? { chatActionId } : {}),
});
return Result.wrap(() => this.api.createOrder(request));
}
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import {
ChatActionEventRequestSchema,
ChatActionSchema,
} from "@/data/schemas/chat";
describe("ChatActionSchema", () => {
it("accepts a support action without an order", () => {
expect(
ChatActionSchema.parse({
actionId: "action-1",
kind: "support",
type: "openFeedback",
copy: "Send the screenshot and approximate payment time here.",
ctaLabel: "Open Feedback",
ruleId: null,
orderId: null,
}),
).toMatchObject({ type: "openFeedback", orderId: null });
});
it("requires orderId only for resumePayment", () => {
expect(() =>
ChatActionSchema.parse({
actionId: "action-2",
kind: "support",
type: "resumePayment",
copy: "You can continue the pending order here.",
ctaLabel: "Continue",
ruleId: null,
orderId: null,
}),
).toThrow();
expect(() =>
ChatActionSchema.parse({
actionId: "action-3",
kind: "support",
type: "openWallet",
copy: "Open your wallet.",
ctaLabel: "Open Wallet",
ruleId: null,
orderId: "order-not-allowed",
}),
).toThrow();
});
});
describe("ChatActionEventRequestSchema", () => {
it("requires a UUID event id and an allowlisted event type", () => {
expect(
ChatActionEventRequestSchema.parse({
eventId: "4b223884-02ec-4fd2-aacf-e84ee8ca3adb",
actionId: "action-1",
characterId: "elio",
eventType: "arrived",
}),
).toMatchObject({ eventType: "arrived" });
expect(() =>
ChatActionEventRequestSchema.parse({
eventId: "not-a-uuid",
actionId: "action-1",
characterId: "elio",
eventType: "clicked",
}),
).toThrow();
});
});
+75
View File
@@ -0,0 +1,75 @@
import { z } from "zod";
export const ChatActionTypeSchema = z.enum([
"giftOffer",
"privateAlbumOffer",
"openProfile",
"openWallet",
"openCoinsRules",
"activateVip",
"topUp",
"openFeedback",
"resumePayment",
]);
export const ChatActionSchema = z
.object({
actionId: z.string().min(1),
kind: z.enum(["support", "commercial"]),
type: ChatActionTypeSchema,
copy: z.string().min(1),
ctaLabel: z.string().min(1),
ruleId: z.string().min(1).nullable(),
orderId: z.string().min(1).nullable(),
})
.superRefine((action, context) => {
if (action.type === "resumePayment" && action.orderId === null) {
context.addIssue({
code: "custom",
path: ["orderId"],
message: "orderId is required for resumePayment",
});
}
if (action.type !== "resumePayment" && action.orderId !== null) {
context.addIssue({
code: "custom",
path: ["orderId"],
message: "orderId is only allowed for resumePayment",
});
}
})
.readonly();
export const ChatActionEventTypeSchema = z.enum([
"viewed",
"opened",
"dismissed",
"arrived",
]);
export const ChatActionEventRequestSchema = z
.object({
eventId: z.uuid(),
actionId: z.string().min(1),
characterId: z.string().min(1),
eventType: ChatActionEventTypeSchema,
})
.readonly();
export const ChatActionEventResponseSchema = z
.object({
eventId: z.uuid(),
actionId: z.string().min(1),
eventType: ChatActionEventTypeSchema,
duplicate: z.boolean(),
})
.readonly();
export type ChatAction = z.output<typeof ChatActionSchema>;
export type ChatActionEventType = z.output<typeof ChatActionEventTypeSchema>;
export type ChatActionEventRequest = z.input<
typeof ChatActionEventRequestSchema
>;
export type ChatActionEventResponse = z.output<
typeof ChatActionEventResponseSchema
>;
+1
View File
@@ -7,6 +7,7 @@ export * from "./chat_media";
export * from "./chat_message";
export * from "./opening_message";
export * from "./chat_payloads";
export * from "./chat_action";
export * from "./request/send_message_request";
export * from "./request/unlock_history_request";
export * from "./request/unlock_private_request";
@@ -11,6 +11,7 @@ import {
stringOrEmpty,
} from "../../nullable-defaults";
import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads";
import { ChatActionSchema } from "../chat_action";
export const CommercialActionSchema = z
.object({
@@ -41,6 +42,7 @@ export const ChatSendResponseSchema = z
requiredCredits: numberOrZero,
shortfallCredits: numberOrZero,
commercialAction: CommercialActionSchema.nullish().default(null),
chatAction: ChatActionSchema.nullish().default(null),
})
.readonly();
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { CreatePaymentOrderRequestSchema } from "@/data/schemas/payment";
describe("CreatePaymentOrderRequestSchema", () => {
const baseRequest = {
planId: "vip_monthly",
payChannel: "stripe",
autoRenew: true,
} as const;
it("accepts a UUID chatActionId for funnel attribution", () => {
expect(
CreatePaymentOrderRequestSchema.parse({
...baseRequest,
chatActionId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
}),
).toMatchObject({
...baseRequest,
chatActionId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5",
});
});
it("rejects a non-UUID chatActionId", () => {
expect(() =>
CreatePaymentOrderRequestSchema.parse({
...baseRequest,
chatActionId: "action-1",
}),
).toThrow();
});
});
@@ -12,6 +12,7 @@ export const CreatePaymentOrderRequestSchema = z
autoRenew: z.boolean(),
recipientCharacterId: z.string().min(1).optional(),
commercialOfferId: z.string().min(1).optional(),
chatActionId: z.uuid().optional(),
})
.readonly();
@@ -60,6 +60,26 @@ describe("multi-character API contract", () => {
});
});
it("reports an idempotent chat action event", async () => {
const body = {
eventId: "4b223884-02ec-4fd2-aacf-e84ee8ca3adb",
actionId: "action-1",
characterId: CHARACTER_ID,
eventType: "opened" as const,
};
httpClientMock.mockResolvedValue({
success: true,
data: { ...body, duplicate: false },
});
await new ChatApi().recordActionEvent(body);
expect(httpClientMock).toHaveBeenCalledWith("/api/chat/action-events", {
method: "POST",
body,
});
});
it("persists the character opening message with the canonical contract", async () => {
httpClientMock.mockResolvedValue({
success: true,
+1
View File
@@ -19,6 +19,7 @@
"paymentTipMessage": { "method": "post", "path": "/api/payment/tip-message" },
"paymentCommercialOfferAccept": { "method": "post", "path": "/api/payment/commercial-offers/{offerId}/accept" },
"chatSend": { "method": "post", "path": "/api/chat/send" },
"chatActionEvents": { "method": "post", "path": "/api/chat/action-events" },
"chatOpeningMessage": { "method": "post", "path": "/api/chat/opening-message" },
"chatHistory": { "method": "get", "path": "/api/chat/history" },
"chatUnlockPrivate": { "method": "post", "path": "/api/chat/unlock-private" },
+3
View File
@@ -78,6 +78,9 @@ export class ApiPath {
/** 发送消息 */
static readonly chatSend = apiContract.chatSend.path;
/** 记录角色聊天操作卡漏斗事件 */
static readonly chatActionEvents = apiContract.chatActionEvents.path;
/** 幂等保存角色开场白 */
static readonly chatOpeningMessage = apiContract.chatOpeningMessage.path;
+19
View File
@@ -11,6 +11,10 @@ import {
ChatPreviewsResponseSchema,
ChatSendResponse,
ChatSendResponseSchema,
ChatActionEventRequest,
ChatActionEventRequestSchema,
ChatActionEventResponse,
ChatActionEventResponseSchema,
OpeningMessageRequest,
OpeningMessageResponse,
OpeningMessageResponseSchema,
@@ -42,6 +46,21 @@ export class ChatApi {
return ChatSendResponseSchema.parse(unwrap(env) as Record<string, unknown>);
}
/** 上报白名单聊天操作卡事件;eventId 用于安全重试。 */
async recordActionEvent(
body: ChatActionEventRequest,
): Promise<ChatActionEventResponse> {
const request = ChatActionEventRequestSchema.parse(body);
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.chatActionEvents,
{
method: "POST",
body: request,
},
);
return ChatActionEventResponseSchema.parse(unwrap(env));
}
/** 幂等保存当前角色的首次开场白。 */
async saveOpeningMessage(
body: OpeningMessageRequest,