# 虚拟礼物商品与付款后文案 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` 是合法结果，应显示本地占位图。
- 本接口没有新增数据库迁移，也没有改变现有公开字段命名。
