feat(characters): support character-scoped conversations
This commit is contained in:
@@ -0,0 +1,544 @@
|
||||
# 单角色应用迁移为多角色聊天架构
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文档定义 CozSweet 从单角色 Elio 迁移到多角色架构时,后端需要完成的接口、数据模型和兼容策略调整。
|
||||
|
||||
迁移目标:
|
||||
|
||||
1. 后端提供统一角色目录。
|
||||
2. 同一用户可以分别与多个角色聊天。
|
||||
3. 不同角色的聊天历史、媒体、锁内容、私密相册和打赏归属必须隔离。
|
||||
4. 钱包、VIP、支付套餐和每日免费额度继续按用户全局共享。
|
||||
5. 现有客户端和历史数据继续默认归属于 Elio。
|
||||
|
||||
本文档中的标准角色标识:
|
||||
|
||||
| 类型 | Elio 示例 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `id` | `character_elio` | 数据库关联、API 请求和缓存隔离 |
|
||||
| `slug` | `elio` | 前端 URL 和外部入口 |
|
||||
|
||||
后端业务接口必须使用 `id`,不能使用可能变化的展示名称。
|
||||
|
||||
环境地址:
|
||||
|
||||
| 环境 | API Base URL |
|
||||
| --- | --- |
|
||||
| test 测试环境 | `https://testapi.banlv-ai.com` |
|
||||
| pro 预发环境 | `https://proapi.banlv-ai.com` |
|
||||
| production 生产环境 | `https://api.banlv-ai.com` |
|
||||
|
||||
## 2. 角色目录接口
|
||||
|
||||
### 2.1 请求
|
||||
|
||||
```http
|
||||
GET <API_BASE_URL>/api/characters
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
接口支持正式用户 Login Token 和游客 Guest Token。
|
||||
|
||||
### 2.2 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": "character_elio",
|
||||
"slug": "elio",
|
||||
"displayName": "Elio Silvestri",
|
||||
"avatarUrl": "https://cdn.example.com/characters/elio/avatar.jpg"
|
||||
},
|
||||
{
|
||||
"id": "character_aria",
|
||||
"slug": "aria",
|
||||
"displayName": "Aria",
|
||||
"avatarUrl": "https://cdn.example.com/characters/aria/avatar.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
公开字段只有:
|
||||
|
||||
| 字段 | 类型 | 是否必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `id` | string | 是 | 稳定、唯一、不可变的角色业务 ID。 |
|
||||
| `slug` | string | 是 | 稳定、唯一、URL 安全的角色标识。 |
|
||||
| `displayName` | string | 是 | 前端展示名称。 |
|
||||
| `avatarUrl` | string | 是 | HTTPS 头像地址。 |
|
||||
|
||||
后端只返回启用角色,并按照后台运营顺序排列 `items`。前端直接使用数组顺序,不执行二次排序。
|
||||
|
||||
响应中不要增加以下字段:
|
||||
|
||||
- `enabled`
|
||||
- `sortOrder`
|
||||
- `coverUrl`
|
||||
- `chatBackgroundUrl`
|
||||
- `tagline`
|
||||
- `emptyChatGreeting`
|
||||
- `capabilities`
|
||||
- 创建人、更新时间等后台管理字段
|
||||
|
||||
当前约定所有返回的角色都支持聊天、私密空间和打赏。
|
||||
|
||||
### 2.3 角色字段规则
|
||||
|
||||
- `id` 建议使用不可枚举 UUID 或稳定业务 ID,最大长度 64。
|
||||
- `slug` 只允许小写字母、数字和连字符,建议校验 `^[a-z0-9]+(?:-[a-z0-9]+)*$`。
|
||||
- `slug` 发布后不能修改;必须修改时应保留旧 slug 重定向映射。
|
||||
- `displayName` 去除首尾空白后不能为空,建议最大长度 80。
|
||||
- `avatarUrl` 必须为有效 HTTPS URL。
|
||||
- `id` 和 `slug` 都需要数据库唯一约束。
|
||||
|
||||
角色目录不需要分页,也不返回 `total`。
|
||||
|
||||
## 3. 聊天接口调整
|
||||
|
||||
### 3.1 通用规则
|
||||
|
||||
以下接口新增标准字段 `characterId`:
|
||||
|
||||
```text
|
||||
POST /api/chat/send
|
||||
GET /api/chat/history
|
||||
POST /api/chat/unlock-private
|
||||
POST /api/chat/unlock-history
|
||||
```
|
||||
|
||||
`characterId` 使用角色目录中的 `id`,例如 `character_elio`,不能传 slug 或显示名称。
|
||||
|
||||
后端必须使用以下维度定位聊天数据:
|
||||
|
||||
```text
|
||||
正式用户:userId + characterId
|
||||
游客:guest identity/device identity + characterId
|
||||
```
|
||||
|
||||
任何历史查询、消息发送、解锁和配额判断都不能只按用户查找。
|
||||
|
||||
### 3.2 发送消息
|
||||
|
||||
```http
|
||||
POST /api/chat/send
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
现有请求体增加 `characterId`,其他字段保持原协议:
|
||||
|
||||
```json
|
||||
{
|
||||
"characterId": "character_elio",
|
||||
"message": "Hello Elio",
|
||||
"image": "",
|
||||
"imageId": "",
|
||||
"imageThumbUrl": "",
|
||||
"imageMediumUrl": "",
|
||||
"imageOriginalUrl": "",
|
||||
"imageWidth": 0,
|
||||
"imageHeight": 0,
|
||||
"useWebSocket": false
|
||||
}
|
||||
```
|
||||
|
||||
后端处理要求:
|
||||
|
||||
1. 校验角色存在且已启用。
|
||||
2. 使用 `characterId` 选择对应角色 Prompt、模型配置和聊天上下文。
|
||||
3. 只加载该用户与该角色的历史。
|
||||
4. 新消息和生成结果必须写入相同 `characterId`。
|
||||
5. 响应继续沿用现有 `ChatSendResponse`,不额外返回前端不使用的角色字段。
|
||||
|
||||
如果后端启用 WebSocket,连接握手或每条发送帧也必须携带 `characterId`,不能依赖服务端进程内的“当前角色”状态。
|
||||
|
||||
### 3.3 获取聊天历史
|
||||
|
||||
```http
|
||||
GET /api/chat/history?characterId=character_elio&limit=50&offset=0
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 是否必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `characterId` | string | 是 | 角色业务 ID。 |
|
||||
| `limit` | integer | 否 | 保持现有分页规则。 |
|
||||
| `offset` | integer | 否 | 保持现有分页规则。 |
|
||||
|
||||
响应结构和分页字段保持不变。`messages`、`total` 和 `offset` 必须只统计当前角色。
|
||||
|
||||
禁止在当前角色历史中返回其他角色的消息,即使消息属于同一用户。
|
||||
|
||||
### 3.4 解锁单条消息
|
||||
|
||||
```http
|
||||
POST /api/chat/unlock-private
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
请求体增加 `characterId`:
|
||||
|
||||
```json
|
||||
{
|
||||
"characterId": "character_elio",
|
||||
"messageId": "message-id",
|
||||
"lockType": "voice_message",
|
||||
"clientLockId": "client-generated-id"
|
||||
}
|
||||
```
|
||||
|
||||
现有 `messageId`、`lockType` 和 `clientLockId` 规则保持不变。
|
||||
|
||||
后端必须验证:
|
||||
|
||||
- 已存在消息的 `character_id` 与请求 `characterId` 一致。
|
||||
- 临时锁消息不存在 `messageId` 时,新建消息必须写入请求角色。
|
||||
- `clientLockId` 的幂等范围至少包含身份和角色,推荐唯一键:
|
||||
|
||||
```text
|
||||
identity + character_id + client_lock_id
|
||||
```
|
||||
|
||||
跨角色解锁返回 `CHARACTER_MISMATCH`,不能扣积分,也不能修改消息。
|
||||
|
||||
### 3.5 一键解锁历史
|
||||
|
||||
```http
|
||||
POST /api/chat/unlock-history
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
请求体由空请求调整为:
|
||||
|
||||
```json
|
||||
{
|
||||
"characterId": "character_elio"
|
||||
}
|
||||
```
|
||||
|
||||
后端只能统计和解锁当前角色的锁消息。`requiredCredits`、解锁数量和返回 messages 都必须按当前角色计算。
|
||||
|
||||
## 4. 角色最新消息接口
|
||||
|
||||
角色列表需要一次性获取各角色的最新消息,避免对每个角色分别请求 history。
|
||||
|
||||
### 4.1 请求
|
||||
|
||||
```http
|
||||
GET /api/chat/previews
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
### 4.2 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"characterId": "character_elio",
|
||||
"message": {
|
||||
"id": "message-id",
|
||||
"role": "assistant",
|
||||
"type": "text",
|
||||
"content": "How was your day?",
|
||||
"createdAt": "2026-07-17T10:00:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"characterId": "character_aria",
|
||||
"message": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `items` 只包含当前启用角色,并保持角色目录顺序。
|
||||
- `message` 使用现有 ChatMessage wire 结构;没有历史时返回 `null`。
|
||||
- 锁消息继续遵循现有内容隐藏规则,不能通过预览接口泄露 URL 或锁定内容。
|
||||
- 查询应批量完成,避免后端内部出现按角色逐条查询的 N+1 问题。
|
||||
|
||||
## 5. 私密空间接口调整
|
||||
|
||||
### 5.1 获取相册
|
||||
|
||||
标准请求改为:
|
||||
|
||||
```http
|
||||
GET /api/private-room/albums?characterId=character_elio&limit=20
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
兼容期继续接受旧参数:
|
||||
|
||||
```http
|
||||
GET /api/private-room/albums?character=elio&limit=20
|
||||
```
|
||||
|
||||
参数优先级:
|
||||
|
||||
1. 存在 `characterId` 时使用标准 ID。
|
||||
2. 兼容期仅存在 `character` 时,将旧 slug 解析为角色 ID。
|
||||
3. 两者同时存在但指向不同角色时返回 `CHARACTER_MISMATCH`。
|
||||
4. 两者都缺失时,兼容期默认 Elio。
|
||||
|
||||
响应结构保持不变,不增加前端未使用的角色字段。
|
||||
|
||||
### 5.2 解锁相册
|
||||
|
||||
接口路径保持不变:
|
||||
|
||||
```http
|
||||
POST /api/private-room/albums/{albumId}/unlock
|
||||
```
|
||||
|
||||
`albumId` 已能唯一确定角色,因此请求体不增加 `characterId`。后端必须根据 album 关联的角色进行权限、积分和解锁记录校验。
|
||||
|
||||
## 6. Tip 支付接口调整
|
||||
|
||||
### 6.1 创建订单
|
||||
|
||||
现有接口:
|
||||
|
||||
```http
|
||||
POST /api/payment/create-order
|
||||
```
|
||||
|
||||
请求体增加可选字段 `recipientCharacterId`:
|
||||
|
||||
```json
|
||||
{
|
||||
"planId": "tip_coffee_medium",
|
||||
"payChannel": "stripe",
|
||||
"autoRenew": false,
|
||||
"recipientCharacterId": "character_elio"
|
||||
}
|
||||
```
|
||||
|
||||
后端必须先根据 `planId` 判断订单类型:
|
||||
|
||||
| 订单类型 | `recipientCharacterId` 规则 |
|
||||
| --- | --- |
|
||||
| Tip | 必填,并校验角色存在且启用 |
|
||||
| VIP | 忽略,不保存 |
|
||||
| Top-up | 忽略,不保存 |
|
||||
|
||||
Tip 订单和最终支付记录需要保存 `recipient_character_id`,用于归属、统计和后续展示。
|
||||
|
||||
支付套餐、创建订单响应和订单状态响应不增加角色字段。
|
||||
|
||||
## 7. 数据模型调整
|
||||
|
||||
### 7.1 Characters 表
|
||||
|
||||
后台建议至少保存:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `id` | 主键,稳定角色 ID。 |
|
||||
| `slug` | 唯一 URL 标识。 |
|
||||
| `display_name` | 展示名称。 |
|
||||
| `avatar_url` | 头像地址。 |
|
||||
| `enabled` | 是否对用户启用,仅后台使用。 |
|
||||
| `sort_order` | 运营排序,仅后台使用。 |
|
||||
| `created_at` | 创建时间。 |
|
||||
| `updated_at` | 更新时间。 |
|
||||
|
||||
`enabled` 和 `sort_order` 是后端管理字段,不返回前端。
|
||||
|
||||
### 7.2 业务表
|
||||
|
||||
以下数据增加 `character_id` 外键:
|
||||
|
||||
- conversations 或等价会话表;
|
||||
- chat messages;
|
||||
- 锁消息、解锁记录和 `clientLockId` 幂等记录;
|
||||
- private albums 及相册解锁记录;
|
||||
- Tip 订单或 Tip 业务记录。
|
||||
|
||||
推荐索引:
|
||||
|
||||
```text
|
||||
(user_id, character_id, created_at)
|
||||
(guest_id, character_id, created_at)
|
||||
(character_id, enabled)
|
||||
(identity, character_id, client_lock_id) UNIQUE
|
||||
```
|
||||
|
||||
如果当前系统没有 conversations 表,可以继续使用 messages,但所有历史查询必须显式包含 `character_id`。
|
||||
|
||||
## 8. 历史数据迁移
|
||||
|
||||
上线新接口前执行:
|
||||
|
||||
1. 创建 Elio 角色,固定 `id=character_elio`、`slug=elio`。
|
||||
2. 为相关业务表增加可空 `character_id`。
|
||||
3. 将现有消息、锁记录、相册和 Tip 记录全部回填为 `character_elio`。
|
||||
4. 检查不存在空角色或无法关联的数据。
|
||||
5. 创建联合索引和外键。
|
||||
6. 将必要业务表的 `character_id` 调整为非空。
|
||||
|
||||
回填前后需要核对每张表的记录数量,迁移不能删除历史聊天或解锁状态。
|
||||
|
||||
## 9. 兼容发布策略
|
||||
|
||||
### 第一阶段:后端兼容
|
||||
|
||||
- 上线角色目录和新字段。
|
||||
- Chat 请求缺少 `characterId` 时暂时默认 `character_elio`。
|
||||
- Private Room 缺少角色时暂时默认 Elio。
|
||||
- Tip 旧客户端缺少 recipient 时,旧 Tip 订单暂时默认 Elio。
|
||||
- 记录缺少角色参数的调用次数和客户端版本。
|
||||
|
||||
### 第二阶段:前端迁移
|
||||
|
||||
- 新前端所有请求显式发送角色 ID。
|
||||
- 动态路由和支付回跳携带角色上下文。
|
||||
- 观察错误率、跨角色校验和旧请求占比。
|
||||
|
||||
### 第三阶段:收紧协议
|
||||
|
||||
- Chat 接口缺少 `characterId` 时返回 `CHARACTER_REQUIRED`。
|
||||
- Tip 订单缺少 recipient 时返回 `CHARACTER_REQUIRED`。
|
||||
- 删除 Private Room 旧 `character` 参数。
|
||||
- 删除默认 Elio 的接口兼容逻辑。
|
||||
|
||||
由于 PWA 和浏览器可能长期缓存旧前端,第三阶段只能在旧请求流量降至可接受水平后执行。
|
||||
|
||||
只有“完全缺少角色字段”的旧请求可以在兼容期默认 Elio。显式传入不存在、禁用或不匹配角色时不得回退 Elio。
|
||||
|
||||
## 10. 错误响应
|
||||
|
||||
统一失败 envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "Character is not available",
|
||||
"error": "CHARACTER_DISABLED"
|
||||
}
|
||||
```
|
||||
|
||||
新增错误:
|
||||
|
||||
| HTTP 状态 | error | 场景 |
|
||||
| ---: | --- | --- |
|
||||
| `400` | `CHARACTER_REQUIRED` | 收紧协议后缺少角色字段。 |
|
||||
| `404` | `CHARACTER_NOT_FOUND` | `id` 或兼容 slug 不存在。 |
|
||||
| `403` | `CHARACTER_DISABLED` | 角色存在但未启用。 |
|
||||
| `409` | `CHARACTER_MISMATCH` | 消息、相册或重复参数属于不同角色。 |
|
||||
|
||||
角色错误不能扣积分、创建消息、创建订单或修改任何解锁状态。
|
||||
|
||||
## 11. 安全和一致性要求
|
||||
|
||||
- 角色 ID 必须由后端查询验证,不能信任客户端展示信息。
|
||||
- 用户只能读取自己的某个角色会话,不能通过替换 characterId 读取其他用户数据。
|
||||
- messageId、albumId 和 clientLockId 必须同时校验身份与角色归属。
|
||||
- 角色禁用后不能继续发送、解锁、创建相册订单或 Tip 订单。
|
||||
- 角色禁用不删除历史数据,恢复启用后历史仍可读取。
|
||||
- 日志可以记录 `characterId`,但不能记录聊天正文、Token 或私密媒体 URL。
|
||||
- Analytics 和业务统计统一使用角色 ID,不使用 displayName。
|
||||
|
||||
## 12. 联调验收
|
||||
|
||||
后端完成后至少验证:
|
||||
|
||||
1. `GET /api/characters` 只返回 4 个公开字段。
|
||||
2. 角色目录只包含启用角色,并严格按照后台排序。
|
||||
3. Login Token 和 Guest Token 均可获取角色目录并聊天。
|
||||
4. 同一用户在 Elio 和其他角色中的历史完全隔离。
|
||||
5. 发送消息、历史分页、单条解锁和历史解锁都限定当前角色。
|
||||
6. 使用 Elio 的 messageId 配合其他角色 ID 解锁时返回 `CHARACTER_MISMATCH`,且不扣积分。
|
||||
7. Private Room 的标准 characterId 与旧 character slug 在兼容期结果一致。
|
||||
8. Tip 订单正确保存 `recipient_character_id`,VIP 和 Top-up 不保存。
|
||||
9. 角色预览接口一次返回各启用角色的最新消息,没有历史时返回 `null`。
|
||||
10. 禁用角色从目录消失,直接访问业务接口返回 `CHARACTER_DISABLED`。
|
||||
11. 缺少角色字段的旧请求在兼容期仍进入 Elio。
|
||||
12. 历史数据回填前后记录数量、积分和解锁状态一致。
|
||||
|
||||
## 13. OpenAPI 参考
|
||||
|
||||
```yaml
|
||||
paths:
|
||||
/api/characters:
|
||||
get:
|
||||
summary: List enabled characters in display order
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Enabled characters
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [success, data]
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
const: true
|
||||
data:
|
||||
type: object
|
||||
required: [items]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [id, slug, displayName, avatarUrl]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
slug:
|
||||
type: string
|
||||
pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
||||
displayName:
|
||||
type: string
|
||||
avatarUrl:
|
||||
type: string
|
||||
format: uri
|
||||
/api/chat/history:
|
||||
get:
|
||||
parameters:
|
||||
- in: query
|
||||
name: characterId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
/api/chat/unlock-history:
|
||||
post:
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [characterId]
|
||||
properties:
|
||||
characterId:
|
||||
type: string
|
||||
```
|
||||
Reference in New Issue
Block a user