# CozSweet Private Room 权威协议

## 1. 状态与范围

本文是前端仓库中角色私密空间、相册列表、相册解锁、Gallery 和积分不足导航的唯一人工维护协议。

协议描述当前前端实际执行的行为。字段和状态由以下机器可验证入口约束：

| 边界 | 实现位置 |
| --- | --- |
| API 路径与方法 | `src/data/services/api/api_contract.json` |
| 请求与响应字段 | `src/data/schemas/private-room` |
| API 与 Repository | `src/data/services/api/private_room_api.ts`、`src/data/repositories/private_room_repository.ts` |
| Private Room 状态机 | `src/stores/private-room` |
| 页面、Gallery 与导航 | `src/app/private-room` |
| 角色 Provider | `src/providers/private-room-route-provider.tsx` |

修改上述实现时必须在同一变更中更新本文，不能再新增按相册列表、解锁或 Gallery 拆分的并行协议。

## 2. 角色与路由边界

标准路由：

```text
/characters/{characterSlug}/private-room
```

旧地址保留为默认角色重定向，并保留查询参数：

```text
/private-room -> /characters/elio/private-room
```

URL 使用角色 `slug`，API 和 Actor 使用角色业务 `id`：

| `id` | `slug` |
| --- | --- |
| `elio` | `elio` |
| `maya-tan` | `maya` |
| `nayeli-cervantes` | `nayeli` |

角色必须同时存在于本地目录且 `capabilities.privateRoom=true`。该能力由本地配置和角色目录响应的 `privateContent` 共同决定；能力关闭或 slug 未知时路由返回 Not Found。

`PrivateRoomProvider` 以 `characterId` 为输入和 React key。切换角色会销毁旧 Actor 并创建空状态，不能复用上一角色的相册、余额或解锁请求。

## 3. 相册列表

```http
GET <API_BASE_URL>/api/private-room/albums?characterId=maya-tan&limit=20
Authorization: Bearer <TOKEN>
```

| Query | 必填 | 前端规则 |
| --- | --- | --- |
| `characterId` | 是 | 当前角色业务 ID |
| `limit` | 否 | 当前固定为 20 |

前端当前只加载第一页，不实现 Private Room 分页，也不把相册列表写入本地缓存。初始化、手动刷新或登录身份变化时重新请求网络。

标准响应数据：

```json
{
  "items": [
    {
      "albumId": "album-1",
      "title": "A quiet afternoon",
      "content": "I saved these for you.",
      "previewText": "Unlock to view",
      "imageCount": 3,
      "images": [
        {
          "url": "https://example.com/private/cover.jpg",
          "locked": true,
          "index": 0
        }
      ],
      "locked": true,
      "unlocked": false,
      "unlockCost": 40,
      "publishedAt": "2026-07-20T09:00:00Z",
      "lockDetail": {
        "locked": true
      }
    }
  ],
  "creditBalance": 20
}
```

前端将缺失或非法的可空字段按 Schema 默认值归一化。页面不直接读取原始响应 envelope。

相册只要满足任意条件即视为锁定：

```ts
album.locked || !album.unlocked || album.lockDetail.locked
```

因此不能只根据图片 URL 是否存在判断已解锁。锁定相册允许存在封面 URL，但只能显示锁定预览，不能打开 Gallery。

## 4. 解锁相册

### 4.1 请求

用户点击锁定相册后，前端先展示确认 Dialog。只有待确认 `albumId` 仍存在于当前 Actor 的 `items` 中，才会发起请求。

```http
POST <API_BASE_URL>/api/private-room/albums/{albumId}/unlock
Content-Type: application/json
Authorization: Bearer <TOKEN>
```

```json
{
  "expectedCost": 40
}
```

`albumId` 来自当前角色列表；请求 body 只发送用户确认时看到的 `expectedCost`，不重复发送 `characterId`。后端通过 `albumId` 确定相册与角色归属。

### 4.2 响应

```json
{
  "albumId": "album-1",
  "locked": false,
  "unlocked": true,
  "reason": "ok",
  "unlockCost": 40,
  "requiredCredits": 40,
  "creditBalance": 60,
  "shortfallCredits": 0,
  "images": [
    {
      "url": "https://example.com/private/photo-1.jpg",
      "locked": false,
      "index": 0
    }
  ]
}
```

前端识别的标准 reason：

```text
ok
already_unlocked
insufficient_credits
cost_changed
unlock_in_progress
deduct_failed
persist_failed_refunded
not_found
```

Schema 同时允许未知字符串，未知失败原因使用通用错误文案。

### 4.3 状态机处理

| 条件 | 行为 |
| --- | --- |
| `unlocked=true` 且 `locked=false` | 更新相册、余额和图片；增加成功 nonce；刷新用户权益 |
| `reason=insufficient_credits` | 更新相册与余额，生成 Paywall 请求 |
| `reason=cost_changed` | 清除确认状态、展示价格变化错误并重新加载列表 |
| `reason=not_found` | 从当前列表移除相册并展示错误 |
| 其他业务失败 | 使用响应补丁更新相册，并展示对应或通用错误 |
| 请求异常 | 保留列表，清除进行中状态并展示异常信息 |

响应补丁只替换匹配 `albumId` 的相册：

- `locked`、`unlocked` 使用响应值；
- 非零 `unlockCost` 更新当前价格，否则保留列表价格；
- 非空 `images` 更新图片，否则保留列表图片；
- `lockDetail.locked` 与响应 `locked` 对齐。

解锁请求进行期间确认按钮保持禁用，避免同一个 Actor 重复提交。

## 5. 身份与支付导航

Private Room 初始化会复用 Guest 登录引导。Auth 尚未初始化或正在加载时不请求列表；`notLoggedIn` 完成 Guest bootstrap 后再进入列表加载。Guest 和正式用户都可以读取后端允许的相册列表。

积分不足生成 Paywall 请求后：

| 当前身份 | 导航 |
| --- | --- |
| Guest 或 Not Logged In | 打开 Auth，redirect 为当前角色 Private Room |
| 已认证用户 | 打开 Top-up，`returnTo=private-room`，保留当前角色来源 |

Paywall 导航发起后立即消费当前请求，避免 React 重渲染重复导航。支付回跳和订单恢复遵循 [Payment 权威协议](./FRONTEND_PAYMENT_API.md)。

解锁成功后 `unlockSuccessNonce` 递增，页面桥接到 `UserFetch`，刷新当前积分和权益。Private Room 不自行修改 User Store 余额。

## 6. Gallery URL 协议

解锁相册使用查询参数打开页内 Gallery：

```text
/characters/{slug}/private-room?album={albumId}&image={zeroBasedIndex}
```

解析规则：

- `album` 必须是非空字符串；
- `image` 必须是大于等于 0 的整数，缺失时使用 0；
- 相册必须仍在当前角色列表中；
- 相册必须已解锁；
- 对应图片必须存在非空 URL。

任一条件不满足时，页面通过 replace 删除 `album` 和 `image`，并保留其他查询参数。

页面内点击九宫格缩略图时，Gallery 从该图片的后端数组原始索引打开。关闭操作优先使用浏览器 back；直接刷新或外部分享 Gallery URL 时，关闭操作使用 replace 返回当前角色 Private Room。

Gallery 只浏览 `locked=false` 且 URL 非空的图片，但 URL 中的 `image` 仍使用原始数组索引。横向拖动时图片跟随指针，达到视口宽度 18%（最低 56px），或达到 0.45px/ms 且至少移动 24px 时切换；首尾越界拖动使用 0.28 阻尼且不循环。松手后使用 240ms 横向吸附动画，键盘方向键和左右按钮复用相同切换逻辑。Escape 关闭；`prefers-reduced-motion` 下取消吸附过渡。

## 7. UI 与数据边界

- React key 使用稳定 `albumId`；
- 卡片图片数量优先使用 `imageCount`，为 0 时回退到 `images.length`；
- 锁定相册继续只显示模糊封面和解锁入口；
- 解锁相册过滤锁定或空 URL 图片，并保留剩余图片的原始数组索引；
- 1 张图片显示 4:5 大图，2/4 张使用两列，其余使用三列正方形九宫格；
- 超过 9 张时卡片显示前九张，末格显示 `+N`，Gallery 仍可浏览全部有效图片；
- Gallery 只挂载当前图片和相邻图片，避免多图相册同时解码全部原图；
- `creditBalance` 是当前列表/解锁响应快照，不替代 User Store 权益；
- Private Room 不使用 Chat 的 `conversationKey`、消息缓存或媒体缓存；
- Private Room 不持有 Payment Actor，Top-up 通过路由级导航进入独立 Payment Provider。

## 8. 变更验收

Private Room 协议相关变更至少验证：

1. `src/data/schemas/private-room/__tests__`；
2. `src/data/services/api/__tests__/multi_character_api.test.ts` 中的 Private Room 请求；
3. `src/stores/private-room/__tests__`；
4. `src/app/private-room/__tests__` 与组件测试；
5. Elio、Maya、Nayeli 列表互不串联；
6. 登录身份变化会刷新当前角色列表；
7. 成功、余额不足、价格变化、重复解锁、退款失败和 not found 分支；
8. Auth、Top-up 和支付成功后返回原角色；
9. 锁定相册不能通过 Gallery URL 绕过；
10. 切换角色后旧 Actor 的相册与解锁状态不再可见。
