8.8 KiB
CozSweet Private Zoom 权威协议
1. 状态与范围
本文是前端仓库中角色私密空间、相册列表、相册解锁、Gallery 和积分不足导航的唯一人工维护协议。
协议描述当前前端实际执行的行为。字段和状态由以下机器可验证入口约束:
| 边界 | 实现位置 |
|---|---|
| API 路径与方法 | src/data/services/api/api_contract.json |
| 请求与响应字段 | src/data/schemas/private-zoom |
| API 与 Repository | src/data/services/api/private_zoom_api.ts、src/data/repositories/private_zoom_repository.ts |
| Private Zoom 状态机 | src/stores/private-zoom |
| 页面、Gallery 与导航 | src/app/private-zoom |
| 角色 Provider | src/providers/private-zoom-route-provider.tsx |
修改上述实现时必须在同一变更中更新本文,不能再新增按相册列表、解锁或 Gallery 拆分的并行协议。
2. 角色与路由边界
标准路由:
/characters/{characterSlug}/private-zoom
旧地址保留为默认角色重定向,并保留查询参数:
/private-zoom -> /characters/elio/private-zoom
URL 使用角色 slug,API 和 Actor 使用角色业务 id:
id |
slug |
|---|---|
elio |
elio |
maya-tan |
maya |
nayeli-cervantes |
nayeli |
角色必须同时存在于本地目录且 capabilities.privateZoom=true。该能力由本地配置和角色目录响应的 privateContent 共同决定;能力关闭或 slug 未知时路由返回 Not Found。
PrivateZoomProvider 以 characterId 为输入和 React key。切换角色会销毁旧 Actor 并创建空状态,不能复用上一角色的相册、余额或解锁请求。
3. 相册列表
GET <API_BASE_URL>/api/private-zoom/albums?characterId=maya-tan&limit=20
Authorization: Bearer <TOKEN>
| Query | 必填 | 前端规则 |
|---|---|---|
characterId |
是 | 当前角色业务 ID |
limit |
否 | 当前固定为 20 |
前端当前只加载第一页,不实现 Private Zoom 分页,也不把相册列表写入本地缓存。初始化、手动刷新或登录身份变化时重新请求网络。
标准响应数据:
{
"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。
相册只要满足任意条件即视为锁定:
album.locked || !album.unlocked || album.lockDetail.locked
因此不能只根据图片 URL 是否存在判断已解锁。锁定相册允许存在封面 URL,但只能显示锁定预览,不能打开 Gallery。
4. 解锁相册
4.1 请求
用户点击锁定相册后,前端先展示确认 Dialog。只有待确认 albumId 仍存在于当前 Actor 的 items 中,才会发起请求。
POST <API_BASE_URL>/api/private-zoom/albums/{albumId}/unlock
Content-Type: application/json
Authorization: Bearer <TOKEN>
{
"expectedCost": 40
}
albumId 来自当前角色列表;请求 body 只发送用户确认时看到的 expectedCost,不重复发送 characterId。后端通过 albumId 确定相册与角色归属。
4.2 响应
{
"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:
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 Zoom 初始化会复用 Guest 登录引导。Auth 尚未初始化或正在加载时不请求列表;notLoggedIn 完成 Guest bootstrap 后再进入列表加载。Guest 和正式用户都可以读取后端允许的相册列表。
积分不足生成 Paywall 请求后:
| 当前身份 | 导航 |
|---|---|
| Guest 或 Not Logged In | 打开 Auth,redirect 为当前角色 Private Zoom |
| 已认证用户 | 打开 Top-up,returnTo=private-zoom,保留当前角色来源 |
Paywall 导航发起后立即消费当前请求,避免 React 重渲染重复导航。支付回跳和订单恢复遵循 Payment 权威协议。
解锁成功后 unlockSuccessNonce 递增,页面桥接到 UserFetch,刷新当前积分和权益。Private Zoom 不自行修改 User Store 余额。
6. Gallery URL 协议
解锁相册使用查询参数打开页内 Gallery:
/characters/{slug}/private-zoom?album={albumId}&image={zeroBasedIndex}
解析规则:
album必须是非空字符串;image必须是大于等于 0 的整数,缺失时使用 0;- 相册必须仍在当前角色列表中;
- 相册必须已解锁;
- 对应图片必须存在非空 URL。
任一条件不满足时,页面通过 replace 删除 album 和 image,并保留其他查询参数。
页面内点击九宫格缩略图时,Gallery 从该图片的后端数组原始索引打开。关闭操作优先使用浏览器 back;直接刷新或外部分享 Gallery URL 时,关闭操作使用 replace 返回当前角色 Private Zoom。
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; - 锁定相册显示模糊封面、角色头像、锁标识、图片数量和
View collection入口;卡片不展示视频数量或积分价格,解锁价格只在确认 Dialog 中展示; - 解锁相册过滤锁定或空 URL 图片,并保留剩余图片的原始数组索引;
- 1 张图片显示 4:5 大图,2/4 张使用两列,其余使用三列正方形九宫格;
- 超过 9 张时卡片显示前九张,末格显示
+N,Gallery 仍可浏览全部有效图片; - Gallery 只挂载当前图片和相邻图片,避免多图相册同时解码全部原图;
creditBalance是当前列表/解锁响应快照,不替代 User Store 权益;- Private Zoom 不使用 Chat 的
conversationKey、消息缓存或媒体缓存; - Private Zoom 不持有 Payment Actor,Top-up 通过路由级导航进入独立 Payment Provider。
8. 变更验收
Private Zoom 协议相关变更至少验证:
src/data/schemas/private-zoom/__tests__;src/data/services/api/__tests__/multi_character_api.test.ts中的 Private Zoom 请求;src/stores/private-zoom/__tests__;src/app/private-zoom/__tests__与组件测试;- Elio、Maya、Nayeli 列表互不串联;
- 登录身份变化会刷新当前角色列表;
- 成功、余额不足、价格变化、重复解锁、退款失败和 not found 分支;
- Auth、Top-up 和支付成功后返回原角色;
- 锁定相册不能通过 Gallery URL 绕过;
- 切换角色后旧 Actor 的相册与解锁状态不再可见。