feat(tip): support dynamic gift products
This commit is contained in:
@@ -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>>;
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
// ============ 聊天相关 ============
|
||||
/** 发送消息 */
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user