feat(chat): add support action cards and live entitlements
Docker Image / Build and Push Docker Image (push) Successful in 2m2s
Docker Image / Build and Push Docker Image (push) Successful in 2m2s
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user