Files
cozsweet-frontend-nextjs/docs/backend/PAYWALL_API.md
T
2026-06-30 10:49:14 +08:00

787 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 付费墙接口说明(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 | 图片公开 URLVIP 有图时有值) |
### 三种场景(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` 均已存在。