feat(tip): support dynamic gift products
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
# 虚拟礼物商品与付款后文案 API
|
||||
|
||||
## 一、用途
|
||||
|
||||
前端按当前角色一次获取全部启用的礼物品类和商品,在本地完成品类切换与商品筛选。页面不得写死咖啡、鲜花、服装等品类,也不得根据 `planId` 推算名称、图片或价格。付款成功后,前端使用订单号获取稳定的角色感谢文案。
|
||||
|
||||
## 三、推荐接入流程
|
||||
|
||||
1. 当前角色确定后,只请求一次 `GET /api/payment/gift-products?characterId=<CHARACTER_ID>`。
|
||||
2. 使用 `data.categories` 渲染品类栏,使用 `data.plans` 保存完整商品目录。
|
||||
3. 用户切换品类时只在前端按 `product.category` 本地筛选,不再请求后端。
|
||||
4. 使用商品自身的 `planName`、`description`、`imageUrl`、`amountCents` 和 `currency` 渲染通用商品卡片。
|
||||
5. 用户选择商品后,使用该商品原始 `planId` 创建订单。
|
||||
6. 用户切换商品时清除旧 `orderId`、`clientSecret` 和支付组件状态,再创建新订单。
|
||||
7. Stripe 使用本次订单返回的 `payParams.clientSecret` 挂载 Payment Element。
|
||||
8. 每 3 至 5 秒轮询订单状态,直到状态变成 `paid`、`failed` 或 `expired`。
|
||||
9. 状态为 `paid` 后调用 `POST /api/payment/tip-message`,展示 `data.message`。
|
||||
|
||||
> `amountCents`、`currency` 和 `planId` 必须来自同一条商品数据。后端下单时会再次按 `planId` 校验真实价格,前端展示值不得作为收费依据。
|
||||
|
||||
---
|
||||
|
||||
## 四、一次获取完整礼物目录
|
||||
|
||||
### 4.1 请求
|
||||
|
||||
```http
|
||||
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 请求示例
|
||||
|
||||
```bash
|
||||
curl 'https://proapi.banlv-ai.com/api/payment/gift-products?characterId=elio'
|
||||
```
|
||||
|
||||
### 4.4 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"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 建议类型
|
||||
|
||||
```ts
|
||||
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 状态和筛选
|
||||
|
||||
```ts
|
||||
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`。
|
||||
|
||||
```ts
|
||||
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 请求
|
||||
|
||||
```http
|
||||
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 请求示例
|
||||
|
||||
```bash
|
||||
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 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"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 请求
|
||||
|
||||
```http
|
||||
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 请求示例
|
||||
|
||||
```bash
|
||||
curl 'https://proapi.banlv-ai.com/api/payment/order-status?order_id=<ORDER_ID>' \
|
||||
-H 'Authorization: Bearer <USER_TOKEN>'
|
||||
```
|
||||
|
||||
### 7.3 关键响应字段
|
||||
|
||||
```json
|
||||
{
|
||||
"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 请求
|
||||
|
||||
```http
|
||||
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 请求示例
|
||||
|
||||
```bash
|
||||
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 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"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。
|
||||
|
||||
---
|
||||
|
||||
## 十、前端验收步骤
|
||||
|
||||
1. 在预发请求 Elio 的品类,确认页面根据接口返回结果渲染,没有写死咖啡品类。
|
||||
2. 请求选中品类的商品,确认商品名称、`planId`、图片、`amountCents` 和币种全部来自接口。
|
||||
3. 分别选择 `$4.99`、`$9.99` 商品,确认创建订单请求发送的是对应商品自己的 `planId`。
|
||||
4. 创建订单后校验 `payParams.firstChargeAmountCents === product.amountCents`;不一致时阻止支付并重新创建订单。
|
||||
5. 切换商品后确认旧 Stripe Payment Element 已销毁,新的组件使用新 `clientSecret`。
|
||||
6. 使用预发测试账号完成一笔允许的支付,轮询状态直到 `paid`。
|
||||
7. 连续两次调用付款后文案接口,确认同一订单返回内容完全一致。
|
||||
8. 再次购买同一商品,确认 `tipCount` 增加 1。
|
||||
9. 确认礼物付款后没有增加积分、VIP 或自动续费。
|
||||
|
||||
## 十一、注意事项
|
||||
|
||||
- 前端不要缓存并跨商品复用 `orderId`、`clientSecret` 或 `payParams`。
|
||||
- `planId` 是商品身份,`amountCents` 是商品价格;二者必须来自同一次商品接口响应。
|
||||
- 品类或商品为空时展示空状态,不要回退到写死的旧咖啡商品。
|
||||
- `imageUrl=null` 是合法结果,应显示本地占位图。
|
||||
- 本接口没有新增数据库迁移,也没有改变现有公开字段命名。
|
||||
Reference in New Issue
Block a user