# 虚拟礼物商品与付款后文案 API ## 一、用途 前端按当前角色一次获取全部启用的礼物品类和商品,在本地完成品类切换与商品筛选。页面不得写死咖啡、鲜花、服装等品类,也不得根据 `planId` 推算名称、图片或价格。付款成功后,前端使用订单号获取稳定的角色感谢文案。 ## 三、推荐接入流程 1. 当前角色确定后,只请求一次 `GET /api/payment/gift-products?characterId=`。 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(null); const [selectedCategory, setSelectedCategory] = useState(null); const [selectedPlanId, setSelectedPlanId] = useState(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 ` - 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 ' \ -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": "", "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= ``` - 完整地址:`https://proapi.banlv-ai.com/api/payment/order-status` - 登录鉴权:需要,必须与创建订单的用户一致 - Header:`Authorization: Bearer ` - 查询字段兼容性说明:当前字段名是 `order_id`,不是 `orderId` ### 7.2 请求示例 ```bash curl 'https://proapi.banlv-ai.com/api/payment/order-status?order_id=' \ -H 'Authorization: Bearer ' ``` ### 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 ` - 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 ' \ -H 'Content-Type: application/json' \ -d '{"orderId":""}' ``` ### 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` 是合法结果,应显示本地占位图。 - 本接口没有新增数据库迁移,也没有改变现有公开字段命名。