# CozSweet 多角色聊天权威协议 ## 1. 状态与范围 本文是前端仓库中多角色目录、聊天、解锁、缓存隔离及关联业务的唯一人工维护协议。旧的单角色迁移方案和独立锁媒体协议已删除,不再作为实现或联调依据。 协议描述当前前端已经执行的行为,不描述尚未落地的后端迁移步骤。字段和路径由下列机器可验证入口约束: | 边界 | 实现位置 | | --- | --- | | API 路径与方法 | `src/data/services/api/api_contract.json` | | 请求与响应字段 | `src/data/schemas/character`、`src/data/schemas/chat` | | 角色 ID、slug 与本地资源 | `src/data/constants/character.ts` | | Chat UI 消息身份 | `src/stores/chat/ui-message.ts` | | 本地缓存身份 | `src/data/repositories/chat_cache_identity.ts`、`src/lib/chat/chat_cache_keys.ts` | 如本文与上述实现发生差异,修改代码时必须在同一变更中更新本文,不能新增第二份并行协议。 ## 2. 角色目录与路由 ### 2.1 角色身份 `id` 是 API、状态机、缓存和数据归属使用的稳定业务身份;`slug` 只用于 URL。 | `id` | `slug` | 展示名称 | | --- | --- | --- | | `elio` | `elio` | Elio Silvestri | | `maya-tan` | `maya` | Maya Tan | | `nayeli-cervantes` | `nayeli` | Nayeli Cervantes | 不得向业务 API 发送 slug、展示名称、`@handle` 或旧 ID。旧 ID `character_elio`、`character_maya`、`character_nayeli` 仅用于本地持久化数据迁移。 ### 2.2 角色目录接口 ```http GET /api/characters?capability=chat Authorization: Bearer ``` 标准响应数据: ```json { "items": [ { "id": "maya-tan", "displayName": "Maya Tan", "isActive": true, "capabilities": { "chat": true, "privateContent": true }, "sortOrder": 20 } ], "defaultCharacterId": "elio" } ``` 前端只接纳同时满足以下条件的角色: 1. `id` 存在于本地资源目录; 2. `isActive=true`; 3. `capabilities.chat=true`。 服务端目录决定角色是否可聊天及排序;本地目录继续提供 slug、图片、文案和 Tip 能力。`privateRoom` 只有在本地能力和服务端 `privateContent` 同时开启时可用。 远端目录返回前,生产环境只使用 Elio 作为临时目录;非生产环境可使用完整本地目录。远端目录加载成功后,以合并结果为准。 ### 2.3 页面路由 ```text /characters/{slug}/splash /characters/{slug}/chat /characters/{slug}/private-room /characters/{slug}/tip ``` 当前 Splash 是角色详情入口,不是全角色列表页。以下旧地址保留为默认角色重定向,并原样保留查询参数: ```text /splash -> /characters/elio/splash /chat -> /characters/elio/chat /private-room -> /characters/elio/private-room /tip -> /characters/elio/tip ``` Chat 和 Private Room Provider 都以 `characterId` 为边界。路由角色变化时必须创建新的 Actor,不能在旧 Actor 内切换角色。 ## 3. Chat HTTP 协议 ### 3.1 发送消息 ```http POST /api/chat/send Content-Type: application/json Authorization: Bearer ``` ```json { "characterId": "maya-tan", "message": "Hello Maya", "useWebSocket": false } ``` 请求规则: - `characterId` 必填; - `message` 最大 4000 字符; - `message` 与 `imageId`、`imageThumbUrl`、`imageMediumUrl`、`imageOriginalUrl` 至少一项有效; - 图片请求可附带 `imageWidth`、`imageHeight`; - `useWebSocket` 缺失时按 `false` 处理。 前端使用的响应字段为: ```text reply, audioUrl, messageId, isGuest, timestamp, image, lockDetail, canSendMessage, creditBalance, creditsCharged, requiredCredits, shortfallCredits ``` `messageId` 是后端消息身份,不是 React key。锁状态只读取 `lockDetail.locked` 和 `lockDetail.reason`。 ### 3.2 获取历史 ```http GET /api/chat/history?characterId=maya-tan&limit=50&offset=0 Authorization: Bearer ``` | Query | 必填 | 规则 | | --- | --- | --- | | `characterId` | 是 | 角色业务 ID | | `limit` | 否 | 前端默认 50 | | `offset` | 否 | 前端默认 0 | 响应数据包含 `messages`、`total`、`limit`、`offset`,并可包含当前私密额度快照。消息字段: ```text role, type, content, id, createdAt/created_at, audioUrl, image, lockDetail ``` 前端兼容 `createdAt` 和 `created_at` 输入,解析后统一为 `createdAt`。缺失的可空载荷会由 Schema 归一化,业务组件不直接读取原始 envelope。 本地快照可先进入可渲染状态;网络历史随后覆盖缓存并更新 UI。网络同步期间新增的乐观消息按 `displayId` 保留。向上拉取更早页面时也按 `displayId` 去重。 ### 3.3 最新消息预览 ```http GET /api/chat/previews Authorization: Bearer ``` ```json { "items": [ { "characterId": "elio", "message": { "id": "", "role": "assistant", "type": "text", "content": "How was your day?" } }, { "characterId": "maya-tan", "message": null } ] } ``` 接口可用时,Splash 以批量结果更新各角色预览;接口失败时,当前角色可回退到 `history?limit=1&offset=0`。预览缓存仍使用角色会话身份隔离。 ## 4. 解锁协议 ### 4.1 解锁单条消息 ```http POST /api/chat/unlock-private Content-Type: application/json Authorization: Bearer ``` 已有后端消息: ```json { "characterId": "maya-tan", "messageId": "" } ``` 前端 Promotion 锁卡片: ```json { "characterId": "maya-tan", "lockType": "voice_message", "clientLockId": "" } ``` 请求必须包含 `characterId`,并至少包含 `messageId` 或 `lockType`。标准 `lockType` 为: ```text voice_message image_paywall private_message ``` 前端解析的响应字段: ```text unlocked, content, messageId, clientLockId, lockType, audioUrl, image, reason, creditBalance, creditsCharged, requiredCredits, shortfallCredits ``` 处理规则: 1. `unlocked=true` 时更新内容、音频、图片和锁状态; 2. `unlocked=false` 时保持锁定,并根据 `reason` 决定是否打开支付流程; 3. `reason=not_found` 不打开支付流程; 4. 响应 `messageId` 只写入 `remoteId`,不得替换 `displayId`; 5. Promotion 重试继续使用同一个 `clientLockId`; 6. 跨支付、登录和图片 Overlay 恢复时同时保存显示身份与后端身份。 ### 4.2 解锁当前角色历史 ```http POST /api/chat/unlock-history Content-Type: application/json Authorization: Bearer {"characterId":"maya-tan"} ``` 请求和返回消息都限定当前角色。前端完成解锁后重新读取该角色历史,不复用其他角色 Actor 的消息。 ### 4.3 角色错误 前端识别以下后端错误码: | 错误码 | 前端行为 | | --- | --- | | `CHARACTER_DISABLED` | 禁止继续发送并刷新角色目录 | | `CHARACTER_NOT_FOUND` | 清理待恢复导航、刷新目录并返回默认角色 | | `CHARACTER_MISMATCH` | 不进入支付,刷新当前角色历史 | | `CHARACTER_STATE_UNAVAILABLE` | 保留会话并提示稍后重试 | 任何角色错误都不得在前端回退为另一个角色的历史。 ## 5. UI 消息身份协议 网络字段继续使用 `messageId`;前端 UI 严格区分三种身份: | 字段 | 用途 | 是否稳定 | | --- | --- | --- | | `displayId` | React key、DOM 定位、Overlay URL、解锁目标 | 消息生命周期内必须稳定 | | `remoteId` | 后端 `messageId`、媒体缓存、解锁 API | 后端返回后可补充 | | `clientId` | 乐观发送消息或无后端 ID 的回复 | 创建时生成 | 标准 `displayId`: ```text 历史消息 server:{encodedRemoteId}:{assistant|user} 同角色重复 ID server:{encodedRemoteId}:{role}:{occurrence} 无后端 ID 历史 legacy:{stableHash} 乐观文本 client:message:{uuid} 乐观图片 client:image:{uuid} 无 ID 回复 client:reply:{uuid} 错误提示 client:error:{uuid} 空会话问候语 greeting:{characterId} Promotion promotion:{clientLockId} ``` 同一个后端 ID 可能同时出现在 user 和 assistant 记录中,因此不得直接使用 `remoteId` 作为 React key。解锁前后 `displayId` 不变;图片 Overlay 始终写入 `displayId`,但读取时兼容旧的 `remoteId` URL。 ## 6. 本地缓存与会话隔离 缓存 owner 规则: ```text 正式用户或已有用户 ID 的会话 user:{userId} 没有用户 ID 的 Guest 会话 device:{deviceId} ``` 正式账号缺少持久化用户 ID 时跳过 Chat 缓存,不得回退到设备级或匿名缓存。 消息和媒体共同使用会话键: ```text conversationKey = {ownerKey}::character:{encodeURIComponent(characterId)} ``` 媒体键在该会话下继续包含后端消息 ID 和媒体类型: ```text {conversationKey}:{remoteId}:{image|audio} ``` 旧的无角色 owner key 迁移到 Elio;包含旧角色 ID 的 key 迁移到当前后端 ID。迁移是幂等的,不能覆盖已经存在的当前命名空间数据。 切换用户、Guest/正式账号或角色时,不得复用前一个 `conversationKey`。切换角色会销毁旧 Chat Actor;请求取消信号会阻止旧响应写入新角色状态。 ## 7. 关联业务边界 - Private Room 列表请求使用 `GET /api/private-room/albums?characterId={id}&limit=20`;相册解锁由 `albumId` 定位,body 只发送 `expectedCost`。 - Tip 创建订单时发送 `recipientCharacterId`;VIP 和 Top-up 不依赖角色归属。 - 登录、支付和解锁回跳保存原角色动态 URL,不能降级为通用 `/chat`。 - Analytics 可以使用 `characterId`,聊天正文不属于路由或身份协议的一部分。 ## 8. 变更验收 协议相关变更至少验证: 1. Character、Chat、Storage、Navigation 和 Payment 的 TypeScript 类型检查; 2. `src/data/services/api/__tests__/multi_character_api.test.ts`; 3. `src/stores/chat/__tests__`; 4. `src/data/storage/chat/__tests__` 与 `src/data/storage/navigation/__tests__`; 5. `src/app/chat/__tests__` 及 Chat 组件测试; 6. 同一用户的不同角色、Guest 与正式账号、账号切换后的历史和媒体均不串联; 7. 快速切换角色时,旧请求不能覆盖新 Actor; 8. 动态 URL、旧地址重定向、登录和支付回跳恢复正确角色; 9. 解锁后 `displayId` 不变,`remoteId` 可从响应补齐。