15 KiB
15 KiB
虚拟礼物商品与付款后文案 API
一、用途
前端按当前角色一次获取全部启用的礼物品类和商品,在本地完成品类切换与商品筛选。页面不得写死咖啡、鲜花、服装等品类,也不得根据 planId 推算名称、图片或价格。付款成功后,前端使用订单号获取稳定的角色感谢文案。
三、推荐接入流程
- 当前角色确定后,只请求一次
GET /api/payment/gift-products?characterId=<CHARACTER_ID>。 - 使用
data.categories渲染品类栏,使用data.plans保存完整商品目录。 - 用户切换品类时只在前端按
product.category本地筛选,不再请求后端。 - 使用商品自身的
planName、description、imageUrl、amountCents和currency渲染通用商品卡片。 - 用户选择商品后,使用该商品原始
planId创建订单。 - 用户切换商品时清除旧
orderId、clientSecret和支付组件状态,再创建新订单。 - Stripe 使用本次订单返回的
payParams.clientSecret挂载 Payment Element。 - 每 3 至 5 秒轮询订单状态,直到状态变成
paid、failed或expired。 - 状态为
paid后调用POST /api/payment/tip-message,展示data.message。
amountCents、currency和planId必须来自同一条商品数据。后端下单时会再次按planId校验真实价格,前端展示值不得作为收费依据。
四、一次获取完整礼物目录
4.1 请求
GET /api/payment/gift-products?characterId=elio
- 完整地址:
https://proapi.banlv-ai.com/api/payment/gift-products - 兼容别名:
GET /api/payment/tip-plans - 登录鉴权:不需要
- 请求格式:Query String
4.2 查询参数
| 字段 | 类型 | 必填 | 可空 | 示例 | 说明 |
|---|---|---|---|---|---|
characterId |
string | 前端必传 | 否 | elio |
当前收礼角色 ID。后端仍兼容省略,但多角色前端不得省略。 |
category |
string | 否 | 否 | coffee |
兼容旧的按品类懒加载方式。新版前端首屏不传此字段。 |
4.3 请求示例
curl 'https://proapi.banlv-ai.com/api/payment/gift-products?characterId=elio'
4.4 成功响应
{
"code": 200,
"message": "success",
"success": true,
"data": {
"characterId": "elio",
"categories": [
{
"category": "coffee",
"name": "Coffee",
"productCount": 3,
"imageUrl": null
},
{
"category": "flowers",
"name": "Flowers",
"productCount": 2,
"imageUrl": "https://example.com/flowers.jpg"
}
],
"plans": [
{
"planId": "tip_coffee_usd_4_99",
"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
}
]
}
}
4.5 响应字段
| 字段 | 类型 | 可空 | 说明 |
|---|---|---|---|
data.characterId |
string | 是 | 本次查询的角色;省略请求参数时为 null。 |
data.categories |
array | 否 | 当前结果中的品类,顺序与 Manager 商品排序一致。 |
categories[].category |
string | 否 | 稳定品类值,只用于关联和筛选。 |
categories[].name |
string | 否 | 品类展示名称。 |
categories[].productCount |
number(整数) | 否 | 当前品类中的启用商品数量。 |
categories[].imageUrl |
string | 是 | 该品类第一张可用商品图;没有图片时为 null。 |
data.plans |
array | 否 | 当前角色全部启用商品;没有商品时为空数组。 |
plans[].planId |
string | 否 | 商品唯一标识,创建订单时必须原样传回。 |
plans[].planName |
string | 否 | 商品展示名称。 |
plans[].orderType |
string enum | 否 | 礼物固定为 tip。 |
plans[].tipType |
string | 否 | 支付成功事件使用的礼物细分类型。 |
plans[].category |
string | 否 | 所属品类,与 categories[].category 对应。 |
plans[].characterId |
string | 否 | 收礼角色 ID。 |
plans[].description |
string | 否 | 商品说明,可能为空字符串。 |
plans[].imageUrl |
string | 是 | 完整公开图片 URL;没有图片时为 null。 |
plans[].amountCents |
number(整数) | 否 | 展示金额的百分之一;USD 下 499 表示 $4.99。 |
plans[].currency |
string | 否 | 三位大写币种代码,例如 USD。 |
plans[].autoRenew |
boolean | 否 | 礼物固定为 false。 |
plans[].isFirstRechargeOffer |
boolean | 否 | 礼物固定为 false。 |
plans[].firstRechargeDiscountPercent |
number(整数) | 否 | 礼物固定为 0。 |
plans[].promotionType |
string | 是 | 礼物没有促销时为 null。 |
五、前端通用品类与商品显示逻辑
5.1 建议类型
interface GiftCategory {
category: string;
name: string;
productCount: number;
imageUrl: string | null;
}
interface GiftProduct {
planId: string;
planName: string;
orderType: "tip";
tipType: string;
category: string;
characterId: string;
description: string;
imageUrl: string | null;
amountCents: number;
currency: string;
autoRenew: false;
}
interface GiftCatalog {
characterId: string | null;
categories: GiftCategory[];
plans: GiftProduct[];
}
5.2 状态和筛选
const [catalog, setCatalog] = useState<GiftCatalog | null>(null);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
const visibleProducts = (catalog?.plans ?? []).filter(
product => product.category === selectedCategory,
);
- 角色变化时重新请求,并以
characterId作为缓存键,不能复用另一个角色的目录。 - 请求完成后默认选择
categories[0].category;深链指定的品类存在时优先选择深链值。 - 切换品类只更新
selectedCategory,不请求第二个接口。 - 只有一个品类时可以隐藏品类切换栏。
- 新增未知品类时仍使用同一套通用商品卡片,禁止编写
if (category === "coffee")一类业务分支。 - 商品图片优先级:
product.imageUrl→ 当前category.imageUrl→ 通用礼物占位图。 - 商品名称和说明使用
planName、description;不要使用前端本地咖啡常量覆盖。 - 价格使用
Intl.NumberFormat,金额为amountCents / 100。
const displayPrice = new Intl.NumberFormat("en-US", {
style: "currency",
currency: product.currency,
}).format(product.amountCents / 100);
5.3 页面状态
| 状态 | 前端行为 |
|---|---|
| 加载中 | 显示稳定尺寸的商品骨架,不显示旧角色缓存。 |
categories=[]、plans=[] |
显示“该角色暂时没有可用礼物”,不要回退到写死咖啡。 |
| 某品类没有商品 | 自动切换到第一个有商品的品类;全部为空时显示空状态。 |
imageUrl=null |
使用品类图或通用占位图。 |
| 请求失败 | 显示重试操作,不使用可能过期的价格创建订单。 |
| 角色切换 | 取消旧请求,清空品类、商品、选中商品、订单和 Stripe 状态。 |
5.4 兼容接口
GET /api/payment/gift-categories保留给只需要品类的旧客户端,新版前端主流程不调用。GET /api/payment/gift-products?characterId=elio&category=coffee仍支持按品类过滤,适用于未来商品数很大时懒加载。GET /api/payment/tip-plans与/gift-products返回同一协议,旧前端继续读取data.plans不受影响。- 新增的
data.characterId和data.categories是向后兼容字段。
六、创建礼物支付订单
6.1 请求
POST /api/payment/create-order
- 完整地址:
https://proapi.banlv-ai.com/api/payment/create-order - 登录鉴权:需要
- Header:
Authorization: Bearer <USER_TOKEN> - Content-Type:
application/json
6.2 请求字段
| 字段 | 类型 | 必填 | 可空 | 示例 | 说明 |
|---|---|---|---|---|---|
planId |
string | 是 | 否 | tip_coffee_usd_4_99 |
必须使用本次商品接口返回的原始值。 |
payChannel |
enum string | 是 | 否 | stripe |
可选值:stripe、ezpay。 |
autoRenew |
boolean | 是 | 否 | false |
礼物是一次性付款,固定发送 false。 |
recipientCharacterId |
string | 否 | 否 | elio |
可省略;省略时使用商品所属角色。传入其他角色会被拒绝。 |
6.3 请求示例
curl -X POST 'https://proapi.banlv-ai.com/api/payment/create-order' \
-H 'Authorization: Bearer <USER_TOKEN>' \
-H 'Content-Type: application/json' \
-d '{
"planId": "tip_coffee_usd_4_99",
"payChannel": "stripe",
"autoRenew": false,
"recipientCharacterId": "elio"
}'
6.4 Stripe 成功响应
{
"code": 200,
"message": "success",
"success": true,
"data": {
"orderId": "pay_xxx",
"payParams": {
"clientSecret": "<STRIPE_CLIENT_SECRET>",
"provider": "stripe",
"automaticRenewal": false,
"firstChargeAmountCents": 499,
"renewalAmountCents": null
},
"expiresAt": "2026-07-20T10:30:00+00:00",
"expiresInSeconds": 1800
}
}
6.5 Stripe 前端处理要求
- 使用
data.payParams.clientSecret挂载 Stripe Payment Element。 firstChargeAmountCents应与所选商品的amountCents一致,可用于提交支付前的防错校验。- 用户切换商品或重新创建订单后,必须销毁旧 Payment Element,并使用新的
orderId + clientSecret重新挂载。 - 不要复用上一件商品的
clientSecret,否则页面商品名称、显示价格和实际 PaymentIntent 可能不属于同一订单。 - 礼物不会增加积分或 VIP,不参加首充折扣,也不会自动续费。
EzPay 响应可能在 payParams 中返回 cashierUrl 或 payData,前端按对应支付渠道处理。
七、轮询订单状态
7.1 请求
GET /api/payment/order-status?order_id=<ORDER_ID>
- 完整地址:
https://proapi.banlv-ai.com/api/payment/order-status - 登录鉴权:需要,必须与创建订单的用户一致
- Header:
Authorization: Bearer <USER_TOKEN> - 查询字段兼容性说明:当前字段名是
order_id,不是orderId
7.2 请求示例
curl 'https://proapi.banlv-ai.com/api/payment/order-status?order_id=<ORDER_ID>' \
-H 'Authorization: Bearer <USER_TOKEN>'
7.3 关键响应字段
{
"code": 200,
"message": "success",
"success": true,
"data": {
"orderId": "pay_xxx",
"status": "paid",
"orderType": "tip",
"planId": "tip_coffee_usd_4_99",
"creditsAdded": 0
}
}
| 字段 | 类型 | 可空 | 说明 |
|---|---|---|---|
orderId |
string | 否 | 当前订单号。 |
status |
enum string | 否 | pending、paid、failed 或 expired。 |
orderType |
string | 否 | 礼物订单为 tip。 |
planId |
string | 是 | 当前订单对应的商品 planId。 |
creditsAdded |
number(整数) | 否 | 礼物订单固定为 0。 |
建议每 3 至 5 秒轮询一次;出现 paid、failed 或 expired 后立即停止。
八、获取付款后感谢文案
8.1 请求
POST /api/payment/tip-message
仅在订单状态已经变为 paid 后调用。
- 完整地址:
https://proapi.banlv-ai.com/api/payment/tip-message - 登录鉴权:需要,必须是订单所属用户
- Header:
Authorization: Bearer <USER_TOKEN> - Content-Type:
application/json
8.2 请求字段
| 字段 | 类型 | 必填 | 可空 | 示例 | 说明 |
|---|---|---|---|---|---|
orderId |
string | 是 | 否 | pay_xxx |
当前用户已经支付成功的礼物订单号。 |
8.3 请求示例
curl -X POST 'https://proapi.banlv-ai.com/api/payment/tip-message' \
-H 'Authorization: Bearer <USER_TOKEN>' \
-H 'Content-Type: application/json' \
-d '{"orderId":"<PAID_ORDER_ID>"}'
8.4 成功响应
{
"code": 200,
"message": "success",
"success": true,
"data": {
"orderId": "pay_xxx",
"characterId": "elio",
"planId": "tip_coffee_usd_4_99",
"productName": "Velvet Espresso",
"tipCount": 1,
"poolIndex": 37,
"message": "This is the 1st time you've sent me \"Velvet Espresso\". You have a knack for making me smile."
}
}
8.5 响应字段
| 字段 | 类型 | 可空 | 说明 |
|---|---|---|---|
orderId |
string | 否 | 已支付订单号。 |
characterId |
string | 否 | 收礼角色 ID。 |
planId |
string | 否 | 本次实际支付商品的 planId。 |
productName |
string | 否 | 本次实际支付商品名称。 |
tipCount |
number(整数) | 否 | 当前用户对同一 planId 的成功支付次数。 |
poolIndex |
number(整数) | 否 | 本次选中的预生成文案下标,范围为 0 至 99。 |
message |
string | 否 | 可直接展示的完整英文文案。 |
每个角色在 Manager 中预先保存正好 100 条英文感谢语。后端使用 characterId + orderId 稳定选择一条,因此同一个 orderId 重复请求时,poolIndex 和 message 保持一致。该接口不会在请求时调用 AI。
十、前端验收步骤
- 在预发请求 Elio 的品类,确认页面根据接口返回结果渲染,没有写死咖啡品类。
- 请求选中品类的商品,确认商品名称、
planId、图片、amountCents和币种全部来自接口。 - 分别选择
$4.99、$9.99商品,确认创建订单请求发送的是对应商品自己的planId。 - 创建订单后校验
payParams.firstChargeAmountCents === product.amountCents;不一致时阻止支付并重新创建订单。 - 切换商品后确认旧 Stripe Payment Element 已销毁,新的组件使用新
clientSecret。 - 使用预发测试账号完成一笔允许的支付,轮询状态直到
paid。 - 连续两次调用付款后文案接口,确认同一订单返回内容完全一致。
- 再次购买同一商品,确认
tipCount增加 1。 - 确认礼物付款后没有增加积分、VIP 或自动续费。
十一、注意事项
- 前端不要缓存并跨商品复用
orderId、clientSecret或payParams。 planId是商品身份,amountCents是商品价格;二者必须来自同一次商品接口响应。- 品类或商品为空时展示空状态,不要回退到写死的旧咖啡商品。
imageUrl=null是合法结果,应显示本地占位图。- 本接口没有新增数据库迁移,也没有改变现有公开字段命名。