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
+403
View File
@@ -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` 是合法结果,应显示本地占位图。
- 本接口没有新增数据库迁移,也没有改变现有公开字段命名。