# 付费墙接口说明（PAYWALL_API）

> 适用服务：`ai-boyfriend-unified`
> 聊天接口路径：`POST /api/chat/send`
> 历史接口路径：`GET /api/chat/history`
> 当前无单条私密消息解锁接口；VIP 自动查看全部私密消息
> WebSocket 路径：`/ws`
> 字段命名：**camelCase**（与现有接口完全一致）
> 文档语言：中文

---

## 总览：三个付费墙功能

本文档覆盖三个会员限制功能，它们共用同一套「Elio 人设提示 + 是否展示开会员引导」的交互模式：

| 功能 | 触发场景 | 限制对象 | 核心接口 | 核心字段 |
|------|---------|---------|---------|---------|
| ① 每日免费消息次数 | 用户发消息 | 注册非VIP（游客豁免） | `POST /api/chat/send` | `blocked` / `blockReason` / `blockDetail` |
| ② AI 男友照片查看 | 用户请求发图 | 非VIP（含游客） | `POST /api/chat/send` | `paywallTriggered` / `showUpgrade` / `imageType` / `imageUrl` |
| ③ 私密消息锁定 | 查看被锁定的私密消息 | 非VIP（含游客） | `GET /api/chat/history` | `isPrivate` / `privateLocked` / `privateHint` |

> **当前状态**：付费墙、VIP 判定、订单接口、RabbitMQ 支付结果消费、VIP/DOL 发放逻辑已经写入后端。充值能否真正闭环取决于 `PAYMENT_SERVICE_URL`、`RABBITMQ_URL`、`orders` 表迁移以及支付服务 B 是否部署完成。若 B 未配置或不可用，聊天付费墙仍会返回 `showUpgrade=true`，但前端创建订单会失败，用户无法完成购买。

---

## 目录

1. [设计原则与兼容性](#1-设计原则与兼容性)
2. [功能①：免费消息次数限制](#2-功能免费消息次数限制)
3. [功能②：AI 男友照片查看](#3-功能ai-男友照片查看)
4. [功能③：私密消息查看](#4-功能私密消息查看)
5. [POST /api/chat/send 完整响应结构](#5-post-apichatsend-完整响应结构)
6. [WebSocket 事件](#6-websocket-事件)
7. [支付服务与前端充值对接](#7-支付服务与前端充值对接)
8. [前端接入清单](#8-前端接入清单)
9. [当前未生效或依赖外部配置的内容](#9-当前未生效或依赖外部配置的内容)
10. [Elio 回复语音字段](#10-elio-回复语音字段)
11. [Manager 图片上传错误码](#11-manager-图片上传错误码)
12. [附录：图片意图关键词](#12-附录图片意图关键词)

---

## 1. 设计原则与兼容性

1. **不新增聊天接口**：功能①②都复用现有 `POST /api/chat/send`，功能③只复用 `GET /api/chat/history`。
2. **响应结构固定**：付费墙相关字段在对应接口的**每一条**响应中都会出现（默认值 `false`/`null`），前端可稳定读取，不会"有时有有时没有"。
3. **完全向后兼容**：所有现有字段原样保留，仅**新增**字段，不删改任何旧字段。
4. **升级引导判断**：图片付费墙用 `showUpgrade === true` 展示"开通会员"引导；消息次数限制沿用 `blocked === true` + `blockReason` 判断；私密消息只看 `privateLocked`。
5. **Fail-safe**：所有付费墙逻辑失败时降级放行，不影响正常聊天。

---

## 2. 功能①：免费消息次数限制

### 规则

- 生产环境：游客每天最多免费发送 **30** 条消息，游客累计最多 **50** 条消息；注册的非 VIP 用户每天最多免费发送 **30** 条消息。
- 预发环境：上述数量为生产的 1/10，即游客每天 **3** 条、累计 **5** 条；注册非 VIP 每天 **3** 条。
- **VIP 不受限**。
- 超限后，`POST /api/chat/send` 返回 `blocked=true`，`reply` 为空，前端据此弹开通会员提示。

### 触发时的响应（data 字段）

```json
{
  "mode": "http",
  "reply": "",
  "voiceUrl": "",
  "audioUrl": "",
  "intimacyChange": 0,
  "newIntimacy": 0,
  "relationshipStage": "密友",
  "currentMood": "happy",
  "messageId": "",
  "isGuest": false,
  "timestamp": 1780975180614,
  "blocked": true,
  "blockReason": "daily_limit",
  "blockDetail": {
    "type": "daily_msg_limit",
    "usedToday": 30,
    "limit": 30
  },
  "paywallTriggered": false,
  "showUpgrade": false,
  "imageType": null,
  "imageUrl": null
}
```

### 前端处理

```
if (data.blocked === true && data.blockReason === "daily_limit") {
    展示"今日免费消息已用完，开通会员畅聊"引导 UI；
    data.blockDetail 提供 usedToday / limit 供文案展示。
}

if (data.blocked === true && data.blockReason === "total_limit") {
    展示"游客免费消息已用完，注册或开通会员继续聊"引导 UI；
    data.blockDetail 提供 usedTotal / limit 供文案展示。
}
```

> 注：消息次数限制当前用 `blocked/blockReason/blockDetail` 表达（沿用既有字段）。这与 `showUpgrade` 不冲突——限流场景 `showUpgrade=false`，前端通过 `blocked` 判断。

---

## 3. 功能②：AI 男友照片查看

### 规则

照片有两种触发方式：

**(a) 用户主动索要**（如"发张照片"、"让我看看你"、"send me a photo"）：
- **VIP** → 返回当天最接近当前时间的 Elio 日程图（`imageUrl` 有值，`imageType="elio_schedule"`）。
- **非VIP**（含游客）→ 返回 Elio 升级提示，`showUpgrade=true`、`imageUrl=null`。

**(b) Elio 主动发图**（无需用户索要）：
- 普通聊天中，主生成模型判断"此刻适合发照片"（如夜晚、亲密氛围、用户在想念时），且用户为 **VIP**、当天主动照片配额未用完、当天确有日程图时，后端会在该条普通回复里**附带** `imageUrl`（HTTP）/ 推送 `image` 事件（WS）。
- 非 VIP **静默不发、不打扰**（不弹升级提示）。
- VIP 每天主动收图上限 **3 张**（`PHOTO_DAILY_LIMIT=3`，UTC 日切）。
- 主动发图时 `paywallTriggered=false`、`showUpgrade=false`，前端只需照常渲染 `imageUrl` 即可。

> 对前端而言协议不变：无论 (a) 还是 (b)，都通过同一个 `imageUrl` 字段（HTTP）或 `image` 事件（WS）拿到图片，直接渲染即可。

### 响应字段

| 字段 | 类型 | 说明 |
|------|------|------|
| `paywallTriggered` | bool | 本条是否触发图片付费墙 |
| `showUpgrade` | bool | **前端是否展示开会员引导（核心判断字段）** |
| `imageType` | string \| null | `null`=无图 / `"elio_schedule"`=Elio 日程图 |
| `imageUrl` | string \| null | 图片公开 URL（VIP 有图时有值） |

### 三种场景（data 字段示例）

**A. 非VIP 请求图片（当前所有用户）：**
```json
{
  "reply": "宝贝，我的照片只有会员才能看到哦……开通会员，我就把最近的照片发给你。😉",
  "paywallTriggered": true,
  "showUpgrade": true,
  "imageType": null,
  "imageUrl": null
}
```

**B. VIP 请求图片 + 当天有图：**
```json
{
  "reply": "嗯，你想看我……那就给你看一张。这是我今天下午的照片，你喜欢吗？",
  "paywallTriggered": false,
  "showUpgrade": false,
  "imageType": "elio_schedule",
  "imageUrl": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/42/a1b2c3d4.jpg"
}
```

**C. VIP 请求图片 + 当天无图：**
```json
{
  "reply": "今天还没拍什么好看的，等我有了好照片再发给你好不好？",
  "paywallTriggered": false,
  "showUpgrade": false,
  "imageType": null,
  "imageUrl": null
}
```

> 上面仅列出付费墙字段，完整 data 结构见第 5 节。

### 前端处理

```
1. 始终渲染 data.reply 为 Elio 文字气泡。
2. if (data.showUpgrade) 展示开会员引导。
3. if (data.imageUrl) 渲染图片（URL 可直接用于 <img src>，无需鉴权）。
```

---

## 4. 功能③：私密消息查看

### 规则

- AI 回复中若包含私密/亲密/大尺度内容，后端**异步**判定并给该消息打标 `is_private=true`，生成模糊预告 `private_hint`。
- 用户拉取历史时：
  - **VIP** → 所有历史私密消息直接返回完整内容；之前锁住的内容也会一起解开，后续也不再锁。
  - **非VIP** → 私密消息内容被屏蔽，返回 `privateLocked=true` + `privateHint`，前端展示**锁定卡片**。
- 当前产品**没有单条私密消息解锁流程**。是否展示完整内容，只取决于当前用户是不是 VIP。

### 4.1 GET /api/chat/history —— 历史含锁定标记

每条消息（`data.messages[]`）字段：

| 字段 | 类型 | 说明 |
|------|------|------|
| `role` | string | `user` / `assistant` |
| `type` | string | `text` / `voice`；有 Elio 语音地址时为 `voice` |
| `content` | string | 消息内容；**非VIP查看私密消息时为空字符串** |
| `id` | string | 消息 ID |
| `created_at` | string | ISO8601 时间 |
| `audioUrl` | string \| null | Elio 语音播放 URL；用户文本消息为 `null` |
| `voiceUrl` | string \| null | 同 `audioUrl`，兼容旧字段 |
| `isPrivate` | bool \| null | 是否私密消息 |
| `privateLocked` | bool \| null | **true = 当前用户无权查看，内容已锁定** |
| `privateHint` | string \| null | 非会员看到的模糊预告文案 |

`data` 顶层建议前端只关心：

| 字段 | 类型 | 说明 |
|------|------|------|
| `isVip` | bool | 当前用户是否 VIP |

> 兼容旧版本客户端，后端可能仍返回 `privateFreeLimit`、`privateUsedToday`、`privateCanViewFree` 等旧字段；当前前端可以忽略。

**历史响应示例（非VIP，含一条锁定的私密消息）：**
```json
{
  "messages": [
    {
      "role": "user",
      "type": "text",
      "content": "今天好想你",
      "id": "msg-1",
      "created_at": "2026-06-09T03:00:00Z",
      "audioUrl": null,
      "voiceUrl": null,
      "isPrivate": false,
      "privateLocked": false,
      "privateHint": null
    },
    {
      "role": "assistant",
      "type": "voice",
      "content": "",
      "id": "msg-2",
      "created_at": "2026-06-09T03:00:05Z",
      "audioUrl": "https://.../audio/msg-2.mp3",
      "voiceUrl": "https://.../audio/msg-2.mp3",
      "isPrivate": true,
      "privateLocked": true,
      "privateHint": "他好像想说什么悄悄话，解锁会员才能看到…"
    }
  ],
  "total": 2,
  "limit": 50,
  "offset": 0,
  "isVip": false
}
```

### 4.2 前端处理

```
拉取 history：
  对每条 privateLocked===true 的消息，渲染为锁定卡片（展示 privateHint）。

用户开通 VIP 后：
  重新拉取 history；
  之前被锁住的私密消息会直接返回完整 content，不需要再调额外解锁接口。
```

---

## 5. POST /api/chat/send 完整响应结构

`data` 字段的**全部字段**（现有 + 新增），所有 `/api/chat/send` 响应都包含：

> 注意：`privateLocked` / `privateHint` / `isPrivate` **不属于** `/api/chat/send` 的响应字段；它们只出现在 `GET /api/chat/history` 的 `data.messages[]` 单条历史消息里。私密消息由后端异步打标，因此前端要在刷新/拉取历史时根据 `privateLocked=true` 渲染模糊锁定卡片。

| 字段 | 类型 | 现有/新增 | 说明 |
|------|------|----------|------|
| `mode` | string | 现有 | `"http"` / `"websocket"` |
| `reply` | string | 现有 | Elio 回复文本 |
| `voiceUrl` | string | 现有 | 语音 URL（后台异步生成） |
| `audioUrl` | string | 现有 | 兼容旧客户端 |
| `intimacyChange` | int | 现有 | 亲密度变化（恒 0） |
| `newIntimacy` | int | 现有 | 当前亲密度 |
| `relationshipStage` | string | 现有 | 关系阶段 |
| `currentMood` | string | 现有 | Elio 情绪 |
| `messageId` | string | 现有 | 消息 ID |
| `isGuest` | bool \| null | 现有 | 是否游客 |
| `timestamp` | int | 现有 | Unix 毫秒时间戳 |
| `blocked` | bool \| null | 现有 | 是否消息次数限流拦截（功能①） |
| `blockReason` | string \| null | 现有 | `"daily_limit"` / `"total_limit"` |
| `blockDetail` | object \| null | 现有 | 每日限制：`{ type, usedToday, limit }`；游客总量限制：`{ type, usedTotal, limit }` |
| `paywallTriggered` | bool | 新增 | 是否触发图片付费墙（功能②） |
| `showUpgrade` | bool | 新增 | **前端是否展示开会员引导** |
| `imageType` | string \| null | 新增 | `null` / `"elio_schedule"` |
| `imageUrl` | string \| null | 新增 | 图片公开 URL |

---

## 6. WebSocket 事件

通过 `/ws` 连接时：

### image 事件（VIP 有图时额外推送）
```json
{ "type": "image", "data": { "imageUrl": "https://.../schedules/42/xxx.jpg" } }
```

### paywall_status 事件（每次图片请求都推送）
```json
{
  "type": "paywall_status",
  "data": {
    "paywallTriggered": true,
    "showUpgrade": true,
    "imageType": null,
    "imageUrl": null
  }
}
```

文字回复（含升级提示）沿用现有整句推送机制，前端无需改动。

---

## 7. 支付服务与前端充值对接

支付链路分三段：

```text
前端 -> 后端 A(ai-boyfriend-unified) -> 支付服务 B -> RabbitMQ -> 后端 A 发放权益 -> 前端刷新状态
```

### 7.1 后端 A 提供给前端的接口

#### GET /api/payment/plans

获取套餐列表，无需登录。前端在开通会员/充值弹窗中展示。

响应 `data.plans[]` 字段：

| 字段 | 类型 | 说明 |
|---|---|---|
| `plan_id` | string | 前端创建订单时传入的套餐 ID |
| `plan_name` | string | 展示名称 |
| `order_type` | string | 传给支付服务 B 的订单类型 |
| `amount_cents` | int | 金额，单位为分 |
| `original_amount_cents` | int \| null | 原价/划线价，单位为分；仅 VIP 套餐返回 |
| `daily_price_cents` | int \| null | 日均展示价，单位为分；仅 VIP 套餐返回 |
| `currency` | string | VIP 套餐当前为 `USD`；DOL 充值项当前为 `CNY` |
| `vip_days` | int \| null | VIP 增加天数，`null` 表示永久或非 VIP 商品 |
| `dol_amount` | int \| null | DOL 积分数量，VIP 商品为 `null` |

当前套餐：

| plan_id | plan_name | order_type | amount_cents | original_amount_cents | daily_price_cents | currency | 发放内容 |
|---|---|---:|---:|---:|---:|---|---|
| `vip_monthly` | Monthly | `vip_monthly` | 1990 | 2490 | 66 | USD | VIP +30 天 |
| `vip_quarterly` | Quarterly | `vip_quarterly` | 4990 | 5990 | 55 | USD | VIP +90 天 |
| `vip_annual` | Annual | `vip_annual` | 16990 | 19990 | 47 | USD | VIP +365 天 |
| `dol_100` | 100 DOL | `dol` | 600 | null | null | CNY | DOL +100 |
| `dol_500` | 500 DOL | `dol` | 2800 | null | null | CNY | DOL +500 |

#### POST /api/payment/create-order

创建订单，需登录。

```http
POST /api/payment/create-order
Authorization: Bearer <user_token>
Content-Type: application/json
```

请求体：

```json
{
  "planId": "vip_monthly",
  "payChannel": "stripe",
  "autoRenew": true
}
```

字段说明：

| 字段 | 必填 | 说明 |
|---|---|---|
| `planId` | 是 | 来自 `GET /api/payment/plans` 的 `plan_id` |
| `payChannel` | 是 | 支付通道取值约定：`stripe` / `ezpay` / `paycools`。当前生产代码若仍只放开 `stripe` / `ezpay`，需要同步把 A/B 两侧白名单加上 `paycools` |
| `autoRenew` | 是 | 是否自动续费；DOL 和永久会员建议传 `false` |

成功响应：

```json
{
  "success": true,
  "data": {
    "orderId": "pay_abc123",
    "payParams": {
      "checkout_url": "https://pay.example.com/checkout/pay_abc123"
    }
  }
}
```

`payParams` 是支付服务 B 返回给前端的拉起支付信息。对于 Stripe Checkout / ezPay / PayCools 这类浏览器跳转支付，B 必须在 `payParams` 内提供可直接打开的支付链接。PayCools 返回的支付链接应放在 `checkout_url`、`payment_url` 或 `url` 中。

当前 Stripe 返回为 Checkout 链接模式。前端创建订单成功后应优先打开：

1. `data.paymentUrl`
2. `data.checkoutUrl`
3. `data.url`
4. `data.payParams.paymentUrl`
5. `data.payParams.checkoutUrl`
6. `data.payParams.url`

打开支付链接后继续轮询 `GET /api/payment/order-status?order_id=...`；支付服务 B 收到 Stripe webhook 后会发布 MQ，后端 A 消费后把订单从 `pending` 更新为 `paid`。

前端读取支付链接的优先级：

1. `payParams.checkout_url`
2. `payParams.payment_url`
3. `payParams.approval_url`
4. `payParams.url`

如果 B 使用 SDK 参数而不是跳转链接，也可以在 `payParams` 中返回 SDK 所需字段，但必须提前和前端约定字段名。后端 A 不解析 `payParams`，只做透传。

错误：

| 状态码 | 场景 |
|---|---|
| `400` | `planId` 无效，或 `payChannel` 不是后端当前放开的支付通道 |
| `401` | 未登录或 token 无效 |
| `502` | `PAYMENT_SERVICE_URL` 未配置，或支付服务 B 创建订单失败 |
| `500` | 后端 A 创建订单过程中发生未知错误 |

#### GET /api/payment/order-status

轮询订单状态，需登录。只能查询当前登录用户自己的订单。

```http
GET /api/payment/order-status?order_id=pay_abc123
Authorization: Bearer <user_token>
```

响应：

```json
{
  "success": true,
  "data": {
    "orderId": "pay_abc123",
    "status": "pending",
    "orderType": "vip_monthly",
    "planId": "vip_monthly"
  }
}
```

`status` 取值：

| status | 前端动作 |
|---|---|
| `pending` | 继续等待；建议每 3~5 秒轮询一次 |
| `paid` | 停止轮询，刷新 VIP 状态或 DOL 余额 |
| `failed` | 停止轮询，提示支付失败/取消 |

#### GET /api/payment/vip-status

查询当前用户 VIP 状态，需登录。

```http
GET /api/payment/vip-status
Authorization: Bearer <user_token>
```

响应：

```json
{
  "success": true,
  "data": {
    "isVip": true,
    "vipExpiresAt": "2026-07-17T10:00:00+00:00"
  }
}
```

`vipExpiresAt=null` 表示永久会员。

### 7.2 后端 A 调支付服务 B：创建订单

后端 A 在收到前端 `create-order` 后，会调用支付服务 B：

```http
POST {PAYMENT_SERVICE_URL}/orders
Authorization: Bearer {PAYMENT_SERVICE_API_KEY}
Content-Type: application/json
```

请求体：

```json
{
  "user_id": "用户 UUID",
  "order_type": "vip_monthly",
  "pay_channel": "paycools",
  "automatic_renewal": true,
  "amount_cents": 1990,
  "currency": "USD"
}
```

B 必须返回：

```json
{
  "order_id": "pay_abc123",
  "pay_params": {
    "checkout_url": "https://pay.example.com/checkout/pay_abc123"
  }
}
```

约定：

- `pay_params` 结构由支付服务 B 决定，后端 A 不解析，前端原样使用。
- 浏览器跳转支付必须返回可打开的 URL，推荐统一使用 `pay_params.checkout_url`。
- PayCools 返回支付链接时，建议放在 `pay_params.checkout_url`；前端也兼容 `payment_url` / `approval_url` / `url`。
- 当前支付对接文档约定的 `pay_channel` 是 `stripe` / `ezpay` / `paycools`；生产代码也需要同步支持这些取值。如果要接其他通道，需要先由支付服务 B 的 `/orders` 支持该通道，再把后端 A 的渠道校验和前端选项一起放开。
- 后端 A 会把 `order_id`、`user_id`、`order_type`、`plan_id`、`status=pending` 写入本地 `orders` 表。
- 发放什么权益由 A 侧 `plan_id` 决定，B 不需要知道 VIP 天数或 DOL 数量。

### 7.3 支付服务 B 回传支付结果：RabbitMQ

B 在支付成功或失败后，向 RabbitMQ 队列发布消息。

| 项目 | 值 |
|---|---|
| 队列名 | 默认 `payment.events`，可通过 `RABBITMQ_QUEUE` 修改 |
| 消息格式 | JSON UTF-8 |
| 推荐属性 | durable queue + persistent message |

消息体：

```json
{
  "user_id": "用户 UUID",
  "order_id": "pay_abc123",
  "status": "success",
  "info": ""
}
```

字段：

| 字段 | 必填 | 说明 |
|---|---|---|
| `user_id` | 是 | 当前订单所属用户 |
| `order_id` | 是 | B 创建订单时返回给 A 的订单号 |
| `status` | 是 | `success` / `fail` |
| `info` | 否 | 失败原因或备注 |
| `sign` | 视配置 | 如果 A 配置了 `PAYMENT_WEBHOOK_SECRET`，B 需要按双方约定签名 |

A 收到消息后的行为：

| status | A 侧处理 |
|---|---|
| `success` | 查本地订单 -> 按 `plan_id` 发放 VIP 或 DOL -> 订单置为 `paid` -> 推送 `payment_success` |
| `fail` | 订单置为 `failed` -> 推送 `payment_failed` |

幂等规则：同一订单已经是 `paid` 时，再收到 `success` 不会重复发放权益。

### 7.4 支付 WebSocket 事件

权益发放后，A 会通过 `/ws` 推送给在线用户。

VIP 支付成功：

```json
{
  "type": "payment_success",
  "orderId": "pay_abc123",
  "payType": "vip",
  "planName": "月度会员",
  "vipExpiresAt": "2026-07-17T10:00:00+00:00"
}
```

DOL 支付成功：

```json
{
  "type": "payment_success",
  "orderId": "pay_def456",
  "payType": "dol",
  "planName": "100 DOL",
  "dolAmount": 100,
  "dolBalance": 350
}
```

支付失败：

```json
{
  "type": "payment_failed",
  "orderId": "pay_abc123",
  "info": "用户取消支付"
}
```

### 7.5 环境变量

```env
PAYMENT_SERVICE_URL=https://pay.example.com
PAYMENT_SERVICE_API_KEY=<A 调 B 的鉴权 key，可空>
PAYMENT_SERVICE_TIMEOUT=10.0

RABBITMQ_URL=amqp://user:pass@host:5672/vhost
RABBITMQ_QUEUE=payment.events
PAYMENT_WEBHOOK_SECRET=<MQ 消息签名密钥，可空>
```

---

## 8. 前端接入清单

### 8.1 触发开通会员入口

前端需要在这些场景展示开通会员入口：

- `POST /api/chat/send` 返回 `blocked=true` 且 `blockReason="daily_limit"`：每日免费消息用完。
- `POST /api/chat/send` 返回 `showUpgrade=true`：照片付费墙或其他升级提示。
- `GET /api/chat/history` 返回 `privateLocked=true`：这条私密消息当前只有 VIP 可见。
- 用户主动点击会员中心/充值按钮。

### 8.2 购买流程

推荐流程：

```text
1. 打开升级弹窗
2. GET /api/payment/plans 加载套餐
3. 用户选择 planId、payChannel、autoRenew
4. POST /api/payment/create-order
5. 从 data.payParams 读取 checkout_url/payment_url/approval_url/url 并打开支付页，PayCools 链接也走这个逻辑，或按约定调用支付 SDK
6. 每 3~5 秒 GET /api/payment/order-status?order_id=...
7. 收到 paid 或 WebSocket payment_success 后停止轮询
8. payType=vip 时调用 /api/payment/vip-status 并刷新本地会员状态
9. payType=dol 时刷新用户资料或积分余额，使用最新 dolBalance
```

前端不应该自己推断权益是否发放成功，应以 `order-status=paid`、`payment_success` 或 `vip-status` 的后端结果为准。
前端也不应该调用任何“确认支付成功”接口来改订单状态；订单状态只能由支付服务 B 接到支付通道回调后，通过 RabbitMQ 通知后端 A 更新。

### 8.3 支付中状态

创建订单后到支付结果返回前，按钮/弹窗应显示支付处理中，避免重复创建订单。用户关闭支付窗口时不要立即判定失败，继续轮询一小段时间或允许用户手动刷新状态。

---

## 9. 当前未生效或依赖外部配置的内容

这些内容已经有后端代码或接口定义，但上线生效需要对应外部条件：

| 功能 | 代码状态 | 生效条件 | 未满足时表现 |
|---|---|---|---|
| 付费墙展示 | 已接入聊天、图片、私密消息 | 无额外条件 | 非 VIP 返回 `blocked` 或 `showUpgrade` |
| 创建订单 | 已有 `/api/payment/create-order` | 配置 `PAYMENT_SERVICE_URL`，支付服务 B 可访问，且 B 返回 `order_id` + 可拉起支付的 `pay_params` | 返回 502，或前端拿不到支付链接 |
| 支付结果发放权益 | 已有 RabbitMQ 消费者 | 配置 `RABBITMQ_URL`，B 发布 `payment.events` 消息 | 订单停留 `pending`，VIP/DOL 不会到账 |
| 订单轮询 | 已有 `/api/payment/order-status` | 执行 `database/payment-migration.sql` 创建 `orders` 表 | 查询不到订单或写入失败 |
| VIP 解锁付费功能 | 已有 VIP 判定 | `users.is_vip=true` 且未过期，或永久会员 | 非 VIP 仍按限制处理 |
| DOL 充值到账 | 已有 DOL 发放 | `users.dol_balance` 字段存在，B 回传成功 MQ | 前端不能只靠创建订单展示到账 |
| Elio 回复语音 | `POST /api/chat/send` 已返回 `voiceUrl` / `audioUrl` 字段；`GET /api/chat/history` 的消息项也返回 `type` / `audioUrl`；WS 模式会推 `voice_ready` | TTS 服务可用，且前端按字段播放 | HTTP 首包里通常为空，后台生成后写入 DB；稍后拉 history 可拿到 `audioUrl` |

VIP 当前会解除/放宽的功能：

- 每日免费消息次数限制。
- 私密消息查看限制。
- AI 男友照片查看和主动发图。
- 语音/TTS 相关门控中需要 VIP 的普通语音回复。

---

## 10. Elio 回复语音字段

当前产品口径是 **Elio 可以发语音给用户，用户不需要发语音给 Elio**。因此聊天发送接口只需要前端传文字 `message`，不需要用户音频上传字段。

### 10.1 POST /api/chat/send 中的语音字段

`POST /api/chat/send` 的 `data` 中包含：

| 字段 | 说明 |
|---|---|
| `voiceUrl` | Elio 回复语音 URL，新字段 |
| `audioUrl` | 兼容旧客户端，语义同 `voiceUrl` |

当前 HTTP 模式下，AI 回复文本会立即返回，TTS 语音在后台异步生成，所以首包里通常是：

```json
{
  "reply": "AI 的回复内容",
  "voiceUrl": "",
  "audioUrl": ""
}
```

后台生成完成后会把语音地址写入 `chat_messages.voice_url`。`GET /api/chat/history` 会把这条消息返回为 `type="voice"`，并带 `audioUrl` / `voiceUrl`。因此前端若走 HTTP 首包，不能假设立刻拿到语音 URL；可以稍后刷新 history 获取。

语音消息对应的文字版不需要单独转写接口：

| 场景 | 前端直接读取的字段 |
|---|---|
| `POST /api/chat/send` 首包 | `reply` |
| `GET /api/chat/history` 单条消息 | `content` |

### 10.2 WebSocket 模式的语音事件

如果前端使用 WebSocket 模式，后端会在分句 TTS 生成完成后推送：

```json
{
  "type": "voice_ready",
  "index": 0,
  "voiceUrl": "https://.../audio/xxx.mp3",
  "audioUrl": "https://.../audio/xxx.mp3"
}
```

前端应优先使用 `voiceUrl` 播放，`audioUrl` 仅作为旧字段兼容。

> 注意：当前 `/api/chat/send` 请求体没有用户音频字段；用户侧语音输入不是当前需求，不需要额外转写字段或用户 `audioUrl`。

---

## 11. Manager 图片上传错误码

用于 ai-boyfriend-Manager 后台 `POST/PUT /api/schedule` 的图片上传（运营管理员侧）。

### 11.1 HTTP 400 — 格式不支持
```json
{ "detail": "不支持的文件格式 image/gif，仅允许 JPEG、PNG、WEBP" }
```

### 11.2 HTTP 400 — 文件过大（>10MB）
```json
{ "detail": "文件大小超过 10 MB 限制（当前 12582912 字节）" }
```

### 11.3 image_warning — 图片处理失败（非阻断，HTTP 200）

图片通过校验但压缩/上传失败时不返回 500，而是 200 + `image_warning`，日程文字正常保存：
```json
{ "id": 42, "title": "苏州行", "image_url": null, "image_warning": "图片压缩失败：PIL 解码错误" }
```

| image_warning 示例 | 含义 |
|---|---|
| `"图片压缩失败：PIL 解码错误"` | 图片损坏或格式异常 |
| `"图片上传失败：Storage 连接超时"` | Storage 网络异常 |
| `"图片上传失败：权限不足"` | Bucket 权限配置错误 |
| `"数据库写入 image_url 失败"` | 图片已传但 URL 未关联日程 |

---

## 12. 附录：图片意图关键词

正则匹配，仅检测消息**前 600 字符**，大小写不敏感：

| 语言 | 模式 |
|------|------|
| 中文 | `发.*照片`、`发.*图`、`让我看看你`、`给我看看你`、`你今天的照片`、`你最近的照片`、`你的照片`、`你的图片` |
| 英文 | `send.*photo`、`send.*picture`、`show.*picture`、`let me see you`、`what do you look like` |

---

## 附：数据库迁移

付费墙、支付、语音、图片功能依赖的库表迁移脚本：

- `database/paywall-migration.sql` — users 会员字段（`is_vip`/`vip_expires_at`）、chat_messages 私密字段（`is_private`/`private_hint`）、`daily_private_unlocks` 表
- `database/payment-migration.sql` — `orders` 表，用于本地订单状态、轮询和 MQ 回调发放权益
- `database/migrate_elio_schedules_image_url.sql` — elio_schedules 表 `image_url` 列（功能②）
- `database/voice-quota-migration.sql` — users 语音配额字段（`daily_voice_count`/`daily_voice_date`）
- `database/photo-quota-migration.sql` — users 主动照片配额字段（`daily_photo_count`/`daily_photo_date`，Elio 主动发图日限）

> 上线前需在 Supabase SQL Editor 执行以上脚本，并创建 `elio-schedules` 公开 Storage Bucket。
> 若支付功能要闭环，必须确认 `orders` 表、`users.is_vip`、`users.vip_expires_at`、`users.dol_balance` 均已存在。
