feat(tip): support dynamic gift products

This commit is contained in:
2026-07-21 13:19:45 +08:00
parent 55cb98ed14
commit 37ff69020b
62 changed files with 2325 additions and 1085 deletions
@@ -1,38 +1,73 @@
import { describe, expect, it, vi } from "vitest";
import { PaymentRepository } from "@/data/repositories/payment_repository";
import { TipPaymentPlansResponseSchema } from "@/data/schemas/payment";
import {
GiftProductsResponseSchema,
TipMessageResponseSchema,
} from "@/data/schemas/payment";
import type { PaymentApi } from "@/data/services/api";
import { Result } from "@/utils/result";
describe("PaymentRepository", () => {
it("adapts tip plans without caching them as subscription plans", async () => {
const getTipPlans = vi.fn().mockResolvedValue(
TipPaymentPlansResponseSchema.parse({
plans: [
{
planId: "tip_coffee_usd_9_99",
planName: "Medium Coffee",
amountCents: 999,
currency: "USD",
},
],
}),
);
it("loads a character gift catalog without adapting product metadata", async () => {
const catalog = GiftProductsResponseSchema.parse({
characterId: "elio",
categories: [
{
category: "coffee",
name: "Coffee",
productCount: 1,
imageUrl: null,
},
],
plans: [
{
planId: "tip_coffee_usd_9_99",
planName: "Golden Reserve",
orderType: "tip",
tipType: "coffee_medium",
category: "coffee",
characterId: "elio",
description: "Buy Elio a coffee",
imageUrl: null,
amountCents: 999,
currency: "USD",
autoRenew: false,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: 0,
promotionType: null,
},
],
});
const getGiftProducts = vi.fn().mockResolvedValue(catalog);
const repository = new PaymentRepository({
getTipPlans,
getGiftProducts,
} as unknown as PaymentApi);
const result = await repository.getTipPlans();
const result = await repository.getGiftProducts("elio");
expect(getTipPlans).toHaveBeenCalledOnce();
expect(Result.isOk(result) && result.data.plans[0]).toMatchObject({
expect(getGiftProducts).toHaveBeenCalledWith("elio");
expect(Result.isOk(result) && result.data).toBe(catalog);
});
it("validates and forwards a Tip message order id", async () => {
const tipMessage = TipMessageResponseSchema.parse({
orderId: "pay_xxx",
characterId: "elio",
planId: "tip_coffee_usd_9_99",
planName: "Medium Coffee",
orderType: "tip",
amountCents: 999,
currency: "USD",
isFirstRechargeOffer: false,
productName: "Golden Reserve",
tipCount: 1,
poolIndex: 1,
message: "Thank you.",
});
const getTipMessage = vi.fn().mockResolvedValue(tipMessage);
const repository = new PaymentRepository({
getTipMessage,
} as unknown as PaymentApi);
const result = await repository.getTipMessage("pay_xxx");
expect(getTipMessage).toHaveBeenCalledWith({ orderId: "pay_xxx" });
expect(Result.isOk(result) && result.data.message).toBe("Thank you.");
});
});
@@ -3,9 +3,11 @@
*/
import type {
CreatePaymentOrderResponse,
GiftProductsResponse,
PayChannel,
PaymentOrderStatusResponse,
PaymentPlansResponse,
TipMessageResponse,
} from "@/data/schemas/payment";
import type { Result } from "@/utils/result";
@@ -16,8 +18,8 @@ export interface IPaymentRepository {
/** 获取本地缓存套餐列表。 */
getCachedPlans(): Promise<Result<PaymentPlansResponse | null>>;
/** 获取咖啡打赏套餐列表。 */
getTipPlans(): Promise<Result<PaymentPlansResponse>>;
/** 获取当前角色的完整礼物目录。 */
getGiftProducts(characterId: string): Promise<Result<GiftProductsResponse>>;
/** 清除本地缓存套餐列表。 */
clearCachedPlans(): Promise<Result<void>>;
@@ -32,4 +34,7 @@ export interface IPaymentRepository {
/** 查询支付订单状态。 */
getOrderStatus(orderId: string): Promise<Result<PaymentOrderStatusResponse>>;
/** 获取已支付礼物订单的角色感谢文案。 */
getTipMessage(orderId: string): Promise<Result<TipMessageResponse>>;
}
+14 -19
View File
@@ -7,10 +7,13 @@ import type { IPaymentRepository } from "@/data/repositories/interfaces";
import {
CreatePaymentOrderRequestSchema,
CreatePaymentOrderResponse,
GiftProductsResponse,
PayChannel,
PaymentOrderStatusResponse,
PaymentPlansResponse,
PaymentPlansResponseSchema,
TipMessageRequestSchema,
TipMessageResponse,
} from "@/data/schemas/payment";
import { PaymentApi, paymentApi } from "@/data/services/api";
import { PaymentPlansStorage } from "@/data/storage/payment";
@@ -38,25 +41,11 @@ export class PaymentRepository implements IPaymentRepository {
});
}
/** 获取咖啡打赏套餐列表,不写入通用套餐缓存。 */
async getTipPlans(): Promise<Result<PaymentPlansResponse>> {
return Result.wrap(async () => {
const response = await this.api.getTipPlans();
return PaymentPlansResponseSchema.parse({
plans: response.plans.map((plan) => ({
...plan,
orderType: "tip",
vipDays: null,
dolAmount: null,
creditBalance: 0,
originalAmountCents: null,
dailyPriceCents: null,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: null,
promotionType: null,
})),
});
});
/** 获取当前角色的完整礼物目录,不写入订阅套餐缓存。 */
async getGiftProducts(
characterId: string,
): Promise<Result<GiftProductsResponse>> {
return Result.wrap(() => this.api.getGiftProducts(characterId));
}
/** 清除本地缓存套餐列表。 */
@@ -89,6 +78,12 @@ export class PaymentRepository implements IPaymentRepository {
): Promise<Result<PaymentOrderStatusResponse>> {
return Result.wrap(() => this.api.getOrderStatus(orderId));
}
/** 获取已支付礼物订单的角色感谢文案。 */
async getTipMessage(orderId: string): Promise<Result<TipMessageResponse>> {
const request = TipMessageRequestSchema.parse({ orderId });
return Result.wrap(() => this.api.getTipMessage(request));
}
}
/** 全局懒单例。 */
@@ -2,21 +2,21 @@ import { describe, expect, it } from "vitest";
import { z } from "zod";
import {
GiftProductsResponseSchema,
PaymentOrderStatusResponseSchema,
PaymentPlanSchema,
PaymentPlansResponseSchema,
TipPaymentPlansResponseSchema,
TipMessageResponseSchema,
} from "@/data/schemas/payment";
describe("PaymentOrderStatusResponse", () => {
it("parses paid Tip success metadata", () => {
it("parses the paid gift order shape", () => {
const response = PaymentOrderStatusResponseSchema.parse({
orderId: "tip_order_123",
status: "paid",
orderType: "tip",
planId: "tip_coffee_usd_9_99",
tipCount: 2,
thankYouMessage: " You made my day. ",
creditsAdded: 0,
});
expect(response).toEqual({
@@ -24,37 +24,20 @@ describe("PaymentOrderStatusResponse", () => {
status: "paid",
orderType: "tip",
planId: "tip_coffee_usd_9_99",
tipCount: 2,
thankYouMessage: "You made my day.",
creditsAdded: 0,
});
});
it("degrades missing or invalid optional Tip metadata to null", () => {
it("accepts expired status and a nullable plan id", () => {
expect(
PaymentOrderStatusResponseSchema.parse({
orderId: "pay_order_456",
status: "pending",
orderType: "vip_monthly",
planId: "vip_monthly",
}),
).toMatchObject({
tipCount: null,
thankYouMessage: null,
});
expect(
PaymentOrderStatusResponseSchema.parse({
orderId: "tip_order_invalid",
status: "paid",
status: "expired",
orderType: "tip",
planId: "tip_coffee_usd_4_99",
tipCount: 0,
thankYouMessage: " ",
planId: null,
creditsAdded: 0,
}),
).toMatchObject({
tipCount: null,
thankYouMessage: null,
});
).toMatchObject({ status: "expired", planId: null, creditsAdded: 0 });
});
});
@@ -209,34 +192,58 @@ describe("PaymentPlansResponse", () => {
});
});
describe("TipPaymentPlansResponse", () => {
it("keeps only fields used by the tip payment flow", () => {
const response = TipPaymentPlansResponseSchema.parse({
describe("GiftProductsResponse", () => {
it("parses and freezes categories and complete gift products", () => {
const response = GiftProductsResponseSchema.parse({
characterId: "elio",
categories: [
{
category: "coffee",
name: "Coffee",
productCount: 1,
imageUrl: null,
},
],
plans: [
{
planId: "tip_coffee_usd_4_99",
planName: "Small Coffee",
planName: "Velvet Espresso",
orderType: "tip",
tipType: "coffee_small",
category: "coffee",
characterId: "elio",
description: "Buy Elio a small coffee",
imageUrl: null,
amountCents: 499,
currency: "USD",
autoRenew: false,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: 0,
promotionType: null,
},
],
});
expect(response).toEqual({
plans: [
{
planId: "tip_coffee_usd_4_99",
planName: "Small Coffee",
amountCents: 499,
currency: "USD",
},
],
});
expect(response.categories[0]?.category).toBe("coffee");
expect(response.plans[0]?.description).toBe("Buy Elio a small coffee");
expect(Object.isFrozen(response)).toBe(true);
expect(Object.isFrozen(response.categories)).toBe(true);
expect(Object.isFrozen(response.plans[0])).toBe(true);
});
});
describe("TipMessageResponse", () => {
it("parses the complete stable backend message", () => {
const response = TipMessageResponseSchema.parse({
orderId: "pay_xxx",
characterId: "elio",
planId: "tip_coffee_usd_4_99",
productName: "Velvet Espresso",
tipCount: 2,
poolIndex: 37,
message: "You have a knack for making me smile.",
});
expect(response.message).toBe("You have a knack for making me smile.");
});
});
+14
View File
@@ -0,0 +1,14 @@
import { z } from "zod";
export const GiftCategorySchema = z
.object({
category: z.string().min(1),
name: z.string(),
productCount: z.number().int().nonnegative(),
imageUrl: z.string().nullable(),
})
.readonly();
export type GiftCategoryInput = z.input<typeof GiftCategorySchema>;
export type GiftCategoryData = z.output<typeof GiftCategorySchema>;
export type GiftCategory = GiftCategoryData;
+26
View File
@@ -0,0 +1,26 @@
import { z } from "zod";
import { stringOrNull } from "../nullable-defaults";
export const GiftProductSchema = z
.object({
planId: z.string().min(1),
planName: z.string(),
orderType: z.literal("tip"),
tipType: z.string(),
category: z.string().min(1),
characterId: z.string().min(1),
description: z.string(),
imageUrl: z.string().nullable(),
amountCents: z.number().int().nonnegative(),
currency: z.string(),
autoRenew: z.literal(false),
isFirstRechargeOffer: z.literal(false),
firstRechargeDiscountPercent: z.number().int(),
promotionType: stringOrNull,
})
.readonly();
export type GiftProductInput = z.input<typeof GiftProductSchema>;
export type GiftProductData = z.output<typeof GiftProductSchema>;
export type GiftProduct = GiftProductData;
+5 -2
View File
@@ -3,9 +3,12 @@
*/
export * from "./payment_plan";
export * from "./tip_payment_plan";
export * from "./gift_category";
export * from "./gift_product";
export * from "./request/create_payment_order_request";
export * from "./request/tip_message_request";
export * from "./response/create_payment_order_response";
export * from "./response/gift_products_response";
export * from "./response/payment_order_status_response";
export * from "./response/payment_plans_response";
export * from "./response/tip_payment_plans_response";
export * from "./response/tip_message_response";
@@ -3,3 +3,4 @@
*/
export * from "./create_payment_order_request";
export * from "./tip_message_request";
@@ -0,0 +1,11 @@
import { z } from "zod";
export const TipMessageRequestSchema = z
.object({
orderId: z.string().min(1),
})
.readonly();
export type TipMessageRequestInput = z.input<typeof TipMessageRequestSchema>;
export type TipMessageRequestData = z.output<typeof TipMessageRequestSchema>;
export type TipMessageRequest = TipMessageRequestData;
@@ -0,0 +1,21 @@
import { z } from "zod";
import { arrayOrEmpty, stringOrNull } from "../../nullable-defaults";
import { GiftCategorySchema } from "../gift_category";
import { GiftProductSchema } from "../gift_product";
export const GiftProductsResponseSchema = z
.object({
characterId: stringOrNull,
categories: arrayOrEmpty(GiftCategorySchema),
plans: arrayOrEmpty(GiftProductSchema),
})
.readonly();
export type GiftProductsResponseInput = z.input<
typeof GiftProductsResponseSchema
>;
export type GiftProductsResponseData = z.output<
typeof GiftProductsResponseSchema
>;
export type GiftProductsResponse = GiftProductsResponseData;
+2 -1
View File
@@ -3,6 +3,7 @@
*/
export * from "./create_payment_order_response";
export * from "./gift_products_response";
export * from "./payment_order_status_response";
export * from "./payment_plans_response";
export * from "./tip_payment_plans_response";
export * from "./tip_message_response";
@@ -3,33 +3,20 @@
*/
import { z } from "zod";
export const PaymentOrderStatusSchema = z.enum(["pending", "paid", "failed"]);
const tipCountOrNull = z.preprocess(
(value) =>
typeof value === "number" && Number.isInteger(value) && value > 0
? value
: null,
z.number().int().positive().nullable(),
);
const thankYouMessageOrNull = z.preprocess(
(value) => {
if (typeof value !== "string") return null;
const message = value.trim();
return message.length > 0 ? message : null;
},
z.string().nullable(),
);
export const PaymentOrderStatusSchema = z.enum([
"pending",
"paid",
"failed",
"expired",
]);
export const PaymentOrderStatusResponseSchema = z
.object({
orderId: z.string(),
status: PaymentOrderStatusSchema,
orderType: z.string(),
planId: z.string(),
tipCount: tipCountOrNull,
thankYouMessage: thankYouMessageOrNull,
planId: z.string().nullable(),
creditsAdded: z.number().int(),
})
.readonly();
@@ -0,0 +1,17 @@
import { z } from "zod";
export const TipMessageResponseSchema = z
.object({
orderId: z.string().min(1),
characterId: z.string().min(1),
planId: z.string().min(1),
productName: z.string(),
tipCount: z.number().int().positive(),
poolIndex: z.number().int().min(0).max(99),
message: z.string().min(1),
})
.readonly();
export type TipMessageResponseInput = z.input<typeof TipMessageResponseSchema>;
export type TipMessageResponseData = z.output<typeof TipMessageResponseSchema>;
export type TipMessageResponse = TipMessageResponseData;
@@ -1,19 +0,0 @@
import { z } from "zod";
import { arrayOrEmpty } from "../../nullable-defaults";
import { TipPaymentPlanSchema } from "../tip_payment_plan";
export const TipPaymentPlansResponseSchema = z
.object({
plans: arrayOrEmpty(TipPaymentPlanSchema),
})
.readonly();
export type TipPaymentPlansResponseInput = z.input<
typeof TipPaymentPlansResponseSchema
>;
export type TipPaymentPlansResponseData = z.output<
typeof TipPaymentPlansResponseSchema
>;
export type TipPaymentPlansResponse = TipPaymentPlansResponseData;
@@ -1,15 +0,0 @@
import { z } from "zod";
export const TipPaymentPlanSchema = z
.object({
planId: z.string(),
planName: z.string(),
amountCents: z.number(),
currency: z.string(),
})
.readonly();
export type TipPaymentPlanInput = z.input<typeof TipPaymentPlanSchema>;
export type TipPaymentPlanData = z.output<typeof TipPaymentPlanSchema>;
export type TipPaymentPlan = TipPaymentPlanData;
@@ -13,39 +13,72 @@ describe("PaymentApi", () => {
httpClientMock.mockReset();
});
it("loads public coffee tip plans from the dedicated endpoint", async () => {
it("loads the complete public gift catalog for a character", async () => {
httpClientMock.mockResolvedValue({
success: true,
data: {
characterId: "elio",
categories: [
{
category: "coffee",
name: "Coffee",
productCount: 1,
imageUrl: null,
},
],
plans: [
{
planId: "tip_coffee_usd_19_99",
planName: "Large Coffee",
orderType: "tip",
tipType: "coffee_large",
category: "coffee",
characterId: "elio",
description: "Buy Elio a large coffee",
imageUrl: null,
amountCents: 1999,
currency: "USD",
autoRenew: false,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: 0,
promotionType: null,
},
],
},
});
const response = await new PaymentApi().getTipPlans();
const response = await new PaymentApi().getGiftProducts("elio");
expect(httpClientMock).toHaveBeenCalledWith("/api/payment/tip-plans");
expect(response).toEqual({
plans: [
{
planId: "tip_coffee_usd_19_99",
planName: "Large Coffee",
amountCents: 1999,
currency: "USD",
},
],
expect(httpClientMock).toHaveBeenCalledWith(
"/api/payment/gift-products",
{ query: { characterId: "elio" } },
);
expect(response.categories[0]?.category).toBe("coffee");
expect(response.plans[0]?.planId).toBe("tip_coffee_usd_19_99");
});
it("posts the paid order id when loading the Tip message", async () => {
httpClientMock.mockResolvedValue({
success: true,
data: {
orderId: "pay_xxx",
characterId: "elio",
planId: "tip_coffee_usd_19_99",
productName: "Large Coffee",
tipCount: 1,
poolIndex: 7,
message: "Thank you for the thoughtful gift.",
},
});
const response = await new PaymentApi().getTipMessage({
orderId: "pay_xxx",
});
expect(httpClientMock).toHaveBeenCalledWith("/api/payment/tip-message", {
method: "POST",
body: { orderId: "pay_xxx" },
});
expect(response.message).toBe("Thank you for the thoughtful gift.");
});
});
+2 -1
View File
@@ -15,7 +15,8 @@
"paymentCreateOrder": { "method": "post", "path": "/api/payment/create-order" },
"paymentOrderStatus": { "method": "get", "path": "/api/payment/order-status" },
"paymentPlans": { "method": "get", "path": "/api/payment/plans" },
"paymentTipPlans": { "method": "get", "path": "/api/payment/tip-plans" },
"paymentGiftProducts": { "method": "get", "path": "/api/payment/gift-products" },
"paymentTipMessage": { "method": "post", "path": "/api/payment/tip-message" },
"chatSend": { "method": "post", "path": "/api/chat/send" },
"chatHistory": { "method": "get", "path": "/api/chat/history" },
"chatUnlockPrivate": { "method": "post", "path": "/api/chat/unlock-private" },
+5 -2
View File
@@ -60,8 +60,11 @@ export class ApiPath {
/** 获取商品套餐列表 */
static readonly paymentPlans = apiContract.paymentPlans.path;
/** 获取咖啡打赏套餐列表 */
static readonly paymentTipPlans = apiContract.paymentTipPlans.path;
/** 获取角色的完整礼物目录 */
static readonly paymentGiftProducts = apiContract.paymentGiftProducts.path;
/** 获取已支付礼物订单的角色感谢文案 */
static readonly paymentTipMessage = apiContract.paymentTipMessage.path;
// ============ 聊天相关 ============
/** 发送消息 */
+26 -6
View File
@@ -7,12 +7,15 @@ import {
CreatePaymentOrderRequest,
CreatePaymentOrderResponse,
CreatePaymentOrderResponseSchema,
GiftProductsResponse,
GiftProductsResponseSchema,
PaymentOrderStatusResponse,
PaymentOrderStatusResponseSchema,
PaymentPlansResponse,
PaymentPlansResponseSchema,
TipPaymentPlansResponse,
TipPaymentPlansResponseSchema,
TipMessageRequest,
TipMessageResponse,
TipMessageResponseSchema,
} from "@/data/schemas/payment";
import { ApiPath } from "./api_path";
@@ -28,10 +31,13 @@ export class PaymentApi {
);
}
/** 获取咖啡打赏套餐列表。 */
async getTipPlans(): Promise<TipPaymentPlansResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.paymentTipPlans);
return TipPaymentPlansResponseSchema.parse(
/** 一次获取当前角色的完整礼物目录。 */
async getGiftProducts(characterId: string): Promise<GiftProductsResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.paymentGiftProducts,
{ query: { characterId } },
);
return GiftProductsResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
);
}
@@ -64,6 +70,20 @@ export class PaymentApi {
unwrap(env) as Record<string, unknown>,
);
}
/** 获取已支付礼物订单的稳定感谢文案。 */
async getTipMessage(body: TipMessageRequest): Promise<TipMessageResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.paymentTipMessage,
{
method: "POST",
body,
},
);
return TipMessageResponseSchema.parse(
unwrap(env) as Record<string, unknown>,
);
}
}
/**
@@ -7,15 +7,18 @@ import { SpAsyncUtil } from "@/utils/storage";
import { StorageKeys } from "../storage_keys";
const PendingPaymentOrderSchema = z.object({
orderId: z.string().min(1),
payChannel: z.literal("ezpay"),
subscriptionType: z.enum(["vip", "topup", "tip"]),
tipCoffeeType: z.enum(["small", "medium", "large"]).optional(),
returnTo: z.enum(["chat", "private-room", "sidebar"]).optional(),
characterSlug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
createdAt: z.number(),
});
const PendingPaymentOrderSchema = z
.object({
orderId: z.string().min(1),
payChannel: z.literal("ezpay"),
subscriptionType: z.enum(["vip", "topup", "tip"]),
giftCategory: z.string().min(1).nullable().default(null),
giftPlanId: z.string().min(1).nullable().default(null),
returnTo: z.enum(["chat", "private-room", "sidebar"]).optional(),
characterSlug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
createdAt: z.number(),
})
.readonly();
export type PendingPaymentOrder = z.output<typeof PendingPaymentOrderSchema>;