From ed3b34ce1ccccbd3a5d0a8f65e526568f8535a96 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 18:21:47 +0800 Subject: [PATCH] refactor(private-zoom): rename full feature surface --- barrelsby.json | 6 +- docs/backend/FRONTEND_MULTI_ROLE_CHAT_API.md | 10 +- docs/backend/FRONTEND_PAYMENT_API.md | 4 +- ...OM_API.md => FRONTEND_PRIVATE_ZOOM_API.md} | 52 +++--- docs/external-entry/README.md | 6 +- docs/external-entry/debug-links.md | 6 +- docs/external-entry/links.md | 12 +- .../2026-07-22-private-zoom-rename.md | 176 ++++++++++++++++++ .../mock/chat/chat-avatar-navigation.spec.ts | 6 +- .../banner/elio.png | Bin .../banner/maya.png | Bin .../banner/nayeli.png | Bin .../__tests__/openapi-contract.test.mjs | 4 +- .../__tests__/tailwind-components.test.tsx | 8 +- .../__tests__/tailwind-components.test.tsx | 22 +-- src/app/_components/core/app-bottom-nav.tsx | 22 +-- .../[characterSlug]/private-room/page.tsx | 5 - .../{private-room => private-zoom}/layout.tsx | 10 +- .../[characterSlug]/private-zoom/page.tsx | 5 + src/app/chat/chat-screen.tsx | 6 +- .../__tests__/tailwind-components.test.tsx | 4 +- src/app/chat/components/message-avatar.tsx | 4 +- .../private-album-gallery-motion.test.ts | 0 .../private-album-gallery-url.test.ts | 8 +- .../__tests__/private-album-images.test.ts | 2 +- ...use-private-zoom-flow-navigation.test.tsx} | 30 +-- .../__tests__/use-private-zoom-flow.test.ts} | 8 +- .../private-album-card.interaction.test.tsx | 10 +- .../__tests__/private-album-card.test.tsx | 6 +- .../__tests__/private-album-gallery.test.tsx | 10 +- .../components/index.ts | 0 .../components/private-album-card.tsx | 6 +- .../components/private-album-gallery.tsx | 4 +- .../components/status-card.tsx | 2 +- .../components/unlock-confirm-dialog.tsx | 4 +- .../{private-room => private-zoom}/page.tsx | 4 +- .../private-album-gallery-motion.ts | 0 .../private-album-gallery-url.ts | 6 +- .../private-album-images.ts | 2 +- .../private-zoom-screen.module.css} | 0 .../private-zoom-screen.tsx} | 82 ++++---- .../use-private-zoom-flow.ts} | 40 ++-- src/app/splash/splash-screen.tsx | 8 +- .../constants/__tests__/character.test.ts | 4 +- src/data/constants/character.ts | 36 ++-- src/data/repositories/index.ts | 4 +- src/data/repositories/interfaces/index.ts | 2 +- ...ository.ts => iprivate_zoom_repository.ts} | 4 +- ...pository.ts => private_zoom_repository.ts} | 20 +- .../__tests__/immutable_schema.test.ts | 4 +- .../__tests__/private_album.test.ts | 6 +- .../{private-room => private-zoom}/index.ts | 0 .../private_album.ts | 0 .../request/index.ts | 0 .../request/unlock_private_album_request.ts | 0 .../response/index.ts | 0 .../response/private_album_unlock_response.ts | 0 .../response/private_albums_response.ts | 0 .../services/api/__tests__/api_path.test.ts | 6 +- .../api/__tests__/multi_character_api.test.ts | 8 +- src/data/services/api/api_contract.json | 4 +- src/data/services/api/api_path.ts | 6 +- src/data/services/api/index.ts | 2 +- ...rivate_room_api.ts => private_zoom_api.ts} | 10 +- .../payment/pending_payment_order_storage.ts | 2 +- .../analytics/payment_analytics_context.ts | 4 +- .../__tests__/external_entry.test.ts | 10 +- .../__tests__/subscription_exit.test.ts | 16 +- src/lib/navigation/external_entry.ts | 6 +- src/lib/navigation/subscription_exit.ts | 10 +- .../__tests__/payment_search_params.test.ts | 6 +- .../__tests__/pending_payment_order.test.ts | 16 +- src/lib/payment/payment_search_params.ts | 2 +- .../private_album.ts | 2 +- .../character-actor-scope-providers.test.tsx | 34 ++-- src/providers/private-room-route-provider.tsx | 21 --- src/providers/private-zoom-route-provider.tsx | 21 +++ src/providers/root-providers.tsx | 2 +- .../__tests__/global-route-context.test.ts | 2 +- .../__tests__/navigation-resolver.test.ts | 6 +- src/router/__tests__/route-meta.test.ts | 10 +- src/router/navigation-types.ts | 2 +- src/router/route-meta.ts | 4 +- src/router/routes.ts | 8 +- src/stores/private-room/index.ts | 7 - .../private-room/private-room-events.ts | 8 - .../private-room/private-room-machine.ts | 18 -- .../private-zoom-album-flow.test.ts} | 22 +-- .../private-zoom-machine.test-utils.ts} | 26 +-- .../private-zoom-unlock-flow.test.ts} | 18 +- .../helper/albums.ts | 10 +- .../helper/index.ts | 0 .../helper/unlock.ts | 10 +- src/stores/private-zoom/index.ts | 7 + .../machine/actors/albums.ts | 12 +- .../machine/album-flow.ts | 24 +-- .../machine/setup.ts | 28 +-- .../machine/unlock-flow.ts | 36 ++-- .../private-zoom-context.tsx} | 48 ++--- .../private-zoom/private-zoom-events.ts | 8 + .../private-zoom/private-zoom-machine.ts | 18 ++ .../private-zoom-state.ts} | 14 +- 102 files changed, 695 insertions(+), 519 deletions(-) rename docs/backend/{FRONTEND_PRIVATE_ROOM_API.md => FRONTEND_PRIVATE_ZOOM_API.md} (83%) create mode 100644 docs/frontend-integration/2026-07-22-private-zoom-rename.md rename public/images/{private-room => private-zoom}/banner/elio.png (100%) rename public/images/{private-room => private-zoom}/banner/maya.png (100%) rename public/images/{private-room => private-zoom}/banner/nayeli.png (100%) delete mode 100644 src/app/characters/[characterSlug]/private-room/page.tsx rename src/app/characters/[characterSlug]/{private-room => private-zoom}/layout.tsx (60%) create mode 100644 src/app/characters/[characterSlug]/private-zoom/page.tsx rename src/app/{private-room => private-zoom}/__tests__/private-album-gallery-motion.test.ts (100%) rename src/app/{private-room => private-zoom}/__tests__/private-album-gallery-url.test.ts (87%) rename src/app/{private-room => private-zoom}/__tests__/private-album-images.test.ts (95%) rename src/app/{private-room/__tests__/use-private-room-flow-navigation.test.tsx => private-zoom/__tests__/use-private-zoom-flow-navigation.test.tsx} (74%) rename src/app/{private-room/__tests__/use-private-room-flow.test.ts => private-zoom/__tests__/use-private-zoom-flow.test.ts} (56%) rename src/app/{private-room => private-zoom}/components/__tests__/private-album-card.interaction.test.tsx (91%) rename src/app/{private-room => private-zoom}/components/__tests__/private-album-card.test.tsx (96%) rename src/app/{private-room => private-zoom}/components/__tests__/private-album-gallery.test.tsx (92%) rename src/app/{private-room => private-zoom}/components/index.ts (100%) rename src/app/{private-room => private-zoom}/components/private-album-card.tsx (97%) rename src/app/{private-room => private-zoom}/components/private-album-gallery.tsx (98%) rename src/app/{private-room => private-zoom}/components/status-card.tsx (88%) rename src/app/{private-room => private-zoom}/components/unlock-confirm-dialog.tsx (92%) rename src/app/{private-room => private-zoom}/page.tsx (77%) rename src/app/{private-room => private-zoom}/private-album-gallery-motion.ts (100%) rename src/app/{private-room => private-zoom}/private-album-gallery-url.ts (89%) rename src/app/{private-room => private-zoom}/private-album-images.ts (94%) rename src/app/{private-room/private-room-screen.module.css => private-zoom/private-zoom-screen.module.css} (100%) rename src/app/{private-room/private-room-screen.tsx => private-zoom/private-zoom-screen.tsx} (80%) rename src/app/{private-room/use-private-room-flow.ts => private-zoom/use-private-zoom-flow.ts} (70%) rename src/data/repositories/interfaces/{iprivate_room_repository.ts => iprivate_zoom_repository.ts} (83%) rename src/data/repositories/{private_room_repository.ts => private_zoom_repository.ts} (64%) rename src/data/schemas/{private-room => private-zoom}/__tests__/private_album.test.ts (95%) rename src/data/schemas/{private-room => private-zoom}/index.ts (100%) rename src/data/schemas/{private-room => private-zoom}/private_album.ts (100%) rename src/data/schemas/{private-room => private-zoom}/request/index.ts (100%) rename src/data/schemas/{private-room => private-zoom}/request/unlock_private_album_request.ts (100%) rename src/data/schemas/{private-room => private-zoom}/response/index.ts (100%) rename src/data/schemas/{private-room => private-zoom}/response/private_album_unlock_response.ts (100%) rename src/data/schemas/{private-room => private-zoom}/response/private_albums_response.ts (100%) rename src/data/services/api/{private_room_api.ts => private_zoom_api.ts} (83%) rename src/lib/{private-room => private-zoom}/private_album.ts (68%) delete mode 100644 src/providers/private-room-route-provider.tsx create mode 100644 src/providers/private-zoom-route-provider.tsx delete mode 100644 src/stores/private-room/index.ts delete mode 100644 src/stores/private-room/private-room-events.ts delete mode 100644 src/stores/private-room/private-room-machine.ts rename src/stores/{private-room/__tests__/private-room-album-flow.test.ts => private-zoom/__tests__/private-zoom-album-flow.test.ts} (78%) rename src/stores/{private-room/__tests__/private-room-machine.test-utils.ts => private-zoom/__tests__/private-zoom-machine.test-utils.ts} (81%) rename src/stores/{private-room/__tests__/private-room-unlock-flow.test.ts => private-zoom/__tests__/private-zoom-unlock-flow.test.ts} (88%) rename src/stores/{private-room => private-zoom}/helper/albums.ts (87%) rename src/stores/{private-room => private-zoom}/helper/index.ts (100%) rename src/stores/{private-room => private-zoom}/helper/unlock.ts (82%) create mode 100644 src/stores/private-zoom/index.ts rename src/stores/{private-room => private-zoom}/machine/actors/albums.ts (65%) rename src/stores/{private-room => private-zoom}/machine/album-flow.ts (69%) rename src/stores/{private-room => private-zoom}/machine/setup.ts (56%) rename src/stores/{private-room => private-zoom}/machine/unlock-flow.ts (83%) rename src/stores/{private-room/private-room-context.tsx => private-zoom/private-zoom-context.tsx} (55%) create mode 100644 src/stores/private-zoom/private-zoom-events.ts create mode 100644 src/stores/private-zoom/private-zoom-machine.ts rename src/stores/{private-room/private-room-state.ts => private-zoom/private-zoom-state.ts} (67%) diff --git a/barrelsby.json b/barrelsby.json index b59e69be..7063f371 100644 --- a/barrelsby.json +++ b/barrelsby.json @@ -25,9 +25,9 @@ "./src/data/schemas/payment", "./src/data/schemas/payment/request", "./src/data/schemas/payment/response", - "./src/data/schemas/private-room", - "./src/data/schemas/private-room/request", - "./src/data/schemas/private-room/response", + "./src/data/schemas/private-zoom", + "./src/data/schemas/private-zoom/request", + "./src/data/schemas/private-zoom/response", "./src/data/schemas/user", "./src/data/storage/app", "./src/data/storage/auth", diff --git a/docs/backend/FRONTEND_MULTI_ROLE_CHAT_API.md b/docs/backend/FRONTEND_MULTI_ROLE_CHAT_API.md index 1aa4144f..2a853293 100644 --- a/docs/backend/FRONTEND_MULTI_ROLE_CHAT_API.md +++ b/docs/backend/FRONTEND_MULTI_ROLE_CHAT_API.md @@ -63,7 +63,7 @@ Authorization: Bearer 2. `isActive=true`; 3. `capabilities.chat=true`。 -服务端目录决定角色是否可聊天及排序;本地目录继续提供 slug、图片、文案和 Tip 能力。`privateRoom` 只有在本地能力和服务端 `privateContent` 同时开启时可用。 +服务端目录决定角色是否可聊天及排序;本地目录继续提供 slug、图片、文案和 Tip 能力。`privateZoom` 只有在本地能力和服务端 `privateContent` 同时开启时可用。 远端目录返回前,生产环境只使用 Elio 作为临时目录;非生产环境可使用完整本地目录。远端目录加载成功后,以合并结果为准。 @@ -72,7 +72,7 @@ Authorization: Bearer ```text /characters/{slug}/splash /characters/{slug}/chat -/characters/{slug}/private-room +/characters/{slug}/private-zoom /characters/{slug}/tip ``` @@ -81,11 +81,11 @@ Authorization: Bearer ```text /splash -> /characters/elio/splash /chat -> /characters/elio/chat -/private-room -> /characters/elio/private-room +/private-zoom -> /characters/elio/private-zoom /tip -> /characters/elio/tip ``` -Chat Provider 以 `characterId` 为边界,路由角色变化时创建新的 Actor。Private Room 的角色边界由 [Private Room 权威协议](./FRONTEND_PRIVATE_ROOM_API.md) 定义。 +Chat Provider 以 `characterId` 为边界,路由角色变化时创建新的 Actor。Private Zoom 的角色边界由 [Private Zoom 权威协议](./FRONTEND_PRIVATE_ZOOM_API.md) 定义。 ## 3. Chat HTTP 协议 @@ -308,7 +308,7 @@ conversationKey = {ownerKey}::character:{encodeURIComponent(characterId)} ## 7. 关联业务边界 -- Private Room 的相册、解锁、Gallery 和支付回跳由 [Private Room 权威协议](./FRONTEND_PRIVATE_ROOM_API.md) 定义。 +- Private Zoom 的相册、解锁、Gallery 和支付回跳由 [Private Zoom 权威协议](./FRONTEND_PRIVATE_ZOOM_API.md) 定义。 - Tip 的角色归属、订单轮询和支付回跳由 [Payment 权威协议](./FRONTEND_PAYMENT_API.md) 定义;Chat 只保存解锁所需的原角色回跳地址。 - 登录、支付和解锁回跳保存原角色动态 URL,不能降级为通用 `/chat`。 - Analytics 可以使用 `characterId`,聊天正文不属于路由或身份协议的一部分。 diff --git a/docs/backend/FRONTEND_PAYMENT_API.md b/docs/backend/FRONTEND_PAYMENT_API.md index aef9f094..0b7805a7 100644 --- a/docs/backend/FRONTEND_PAYMENT_API.md +++ b/docs/backend/FRONTEND_PAYMENT_API.md @@ -224,7 +224,7 @@ payChannel = ezpay subscriptionType = vip | topup | tip giftCategory(仅 Tip,可空) giftPlanId(仅 Tip,可空) -returnTo = chat | private-room | profile(可选) +returnTo = chat | private-zoom | profile(可选) characterSlug(可选) createdAt ``` @@ -238,7 +238,7 @@ Tip -> /characters/{slug}/tip?category=...&planId=...&payChannel=ezpay& 恢复页面只接受与当前 `paymentType` 相同的待处理订单。带有 `paymentReturn=1` 时派发 `PaymentReturned` 并继续轮询;普通进入支付页时会清理同类型的旧待处理订单。订单进入 paid、failed 或 expired 后清理持久化记录。缺少 Gift 字段的旧记录由最新目录默认选择第一件商品,不再读取 `coffee_type`。 -无效或未知 `characterSlug` 回退到默认角色 slug。有效角色回跳必须保留原角色,不能统一返回 Elio。`returnTo=private-room` 的最终页面行为由 [Private Room 权威协议](./FRONTEND_PRIVATE_ROOM_API.md) 定义。 +无效或未知 `characterSlug` 回退到默认角色 slug。有效角色回跳必须保留原角色,不能统一返回 Elio。`returnTo=private-zoom` 的最终页面行为由 [Private Zoom 权威协议](./FRONTEND_PRIVATE_ZOOM_API.md) 定义。 ## 8. 成功后的跨域同步 diff --git a/docs/backend/FRONTEND_PRIVATE_ROOM_API.md b/docs/backend/FRONTEND_PRIVATE_ZOOM_API.md similarity index 83% rename from docs/backend/FRONTEND_PRIVATE_ROOM_API.md rename to docs/backend/FRONTEND_PRIVATE_ZOOM_API.md index 4f27cc7c..b6148d3c 100644 --- a/docs/backend/FRONTEND_PRIVATE_ROOM_API.md +++ b/docs/backend/FRONTEND_PRIVATE_ZOOM_API.md @@ -1,4 +1,4 @@ -# CozSweet Private Room 权威协议 +# CozSweet Private Zoom 权威协议 ## 1. 状态与范围 @@ -9,11 +9,11 @@ | 边界 | 实现位置 | | --- | --- | | 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` | +| 请求与响应字段 | `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 拆分的并行协议。 @@ -22,13 +22,13 @@ 标准路由: ```text -/characters/{characterSlug}/private-room +/characters/{characterSlug}/private-zoom ``` 旧地址保留为默认角色重定向,并保留查询参数: ```text -/private-room -> /characters/elio/private-room +/private-zoom -> /characters/elio/private-zoom ``` URL 使用角色 `slug`,API 和 Actor 使用角色业务 `id`: @@ -39,14 +39,14 @@ URL 使用角色 `slug`,API 和 Actor 使用角色业务 `id`: | `maya-tan` | `maya` | | `nayeli-cervantes` | `nayeli` | -角色必须同时存在于本地目录且 `capabilities.privateRoom=true`。该能力由本地配置和角色目录响应的 `privateContent` 共同决定;能力关闭或 slug 未知时路由返回 Not Found。 +角色必须同时存在于本地目录且 `capabilities.privateZoom=true`。该能力由本地配置和角色目录响应的 `privateContent` 共同决定;能力关闭或 slug 未知时路由返回 Not Found。 -`PrivateRoomProvider` 以 `characterId` 为输入和 React key。切换角色会销毁旧 Actor 并创建空状态,不能复用上一角色的相册、余额或解锁请求。 +`PrivateZoomProvider` 以 `characterId` 为输入和 React key。切换角色会销毁旧 Actor 并创建空状态,不能复用上一角色的相册、余额或解锁请求。 ## 3. 相册列表 ```http -GET /api/private-room/albums?characterId=maya-tan&limit=20 +GET /api/private-zoom/albums?characterId=maya-tan&limit=20 Authorization: Bearer ``` @@ -55,7 +55,7 @@ Authorization: Bearer | `characterId` | 是 | 当前角色业务 ID | | `limit` | 否 | 当前固定为 20 | -前端当前只加载第一页,不实现 Private Room 分页,也不把相册列表写入本地缓存。初始化、手动刷新或登录身份变化时重新请求网络。 +前端当前只加载第一页,不实现 Private Zoom 分页,也不把相册列表写入本地缓存。初始化、手动刷新或登录身份变化时重新请求网络。 标准响应数据: @@ -105,7 +105,7 @@ album.locked || !album.unlocked || album.lockDetail.locked 用户点击锁定相册后,前端先展示确认 Dialog。只有待确认 `albumId` 仍存在于当前 Actor 的 `items` 中,才会发起请求。 ```http -POST /api/private-room/albums/{albumId}/unlock +POST /api/private-zoom/albums/{albumId}/unlock Content-Type: application/json Authorization: Bearer ``` @@ -177,25 +177,25 @@ Schema 同时允许未知字符串,未知失败原因使用通用错误文案 ## 5. 身份与支付导航 -Private Room 初始化会复用 Guest 登录引导。Auth 尚未初始化或正在加载时不请求列表;`notLoggedIn` 完成 Guest bootstrap 后再进入列表加载。Guest 和正式用户都可以读取后端允许的相册列表。 +Private Zoom 初始化会复用 Guest 登录引导。Auth 尚未初始化或正在加载时不请求列表;`notLoggedIn` 完成 Guest bootstrap 后再进入列表加载。Guest 和正式用户都可以读取后端允许的相册列表。 积分不足生成 Paywall 请求后: | 当前身份 | 导航 | | --- | --- | -| Guest 或 Not Logged In | 打开 Auth,redirect 为当前角色 Private Room | -| 已认证用户 | 打开 Top-up,`returnTo=private-room`,保留当前角色来源 | +| Guest 或 Not Logged In | 打开 Auth,redirect 为当前角色 Private Zoom | +| 已认证用户 | 打开 Top-up,`returnTo=private-zoom`,保留当前角色来源 | Paywall 导航发起后立即消费当前请求,避免 React 重渲染重复导航。支付回跳和订单恢复遵循 [Payment 权威协议](./FRONTEND_PAYMENT_API.md)。 -解锁成功后 `unlockSuccessNonce` 递增,页面桥接到 `UserFetch`,刷新当前积分和权益。Private Room 不自行修改 User Store 余额。 +解锁成功后 `unlockSuccessNonce` 递增,页面桥接到 `UserFetch`,刷新当前积分和权益。Private Zoom 不自行修改 User Store 余额。 ## 6. Gallery URL 协议 解锁相册使用查询参数打开页内 Gallery: ```text -/characters/{slug}/private-room?album={albumId}&image={zeroBasedIndex} +/characters/{slug}/private-zoom?album={albumId}&image={zeroBasedIndex} ``` 解析规则: @@ -208,7 +208,7 @@ Paywall 导航发起后立即消费当前请求,避免 React 重渲染重复 任一条件不满足时,页面通过 replace 删除 `album` 和 `image`,并保留其他查询参数。 -页面内点击九宫格缩略图时,Gallery 从该图片的后端数组原始索引打开。关闭操作优先使用浏览器 back;直接刷新或外部分享 Gallery URL 时,关闭操作使用 replace 返回当前角色 Private Room。 +页面内点击九宫格缩略图时,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` 下取消吸附过渡。 @@ -222,17 +222,17 @@ Gallery 只浏览 `locked=false` 且 URL 非空的图片,但 URL 中的 `image - 超过 9 张时卡片显示前九张,末格显示 `+N`,Gallery 仍可浏览全部有效图片; - Gallery 只挂载当前图片和相邻图片,避免多图相册同时解码全部原图; - `creditBalance` 是当前列表/解锁响应快照,不替代 User Store 权益; -- Private Room 不使用 Chat 的 `conversationKey`、消息缓存或媒体缓存; -- Private Room 不持有 Payment Actor,Top-up 通过路由级导航进入独立 Payment Provider。 +- Private Zoom 不使用 Chat 的 `conversationKey`、消息缓存或媒体缓存; +- Private Zoom 不持有 Payment Actor,Top-up 通过路由级导航进入独立 Payment Provider。 ## 8. 变更验收 -Private Room 协议相关变更至少验证: +Private Zoom 协议相关变更至少验证: -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__` 与组件测试; +1. `src/data/schemas/private-zoom/__tests__`; +2. `src/data/services/api/__tests__/multi_character_api.test.ts` 中的 Private Zoom 请求; +3. `src/stores/private-zoom/__tests__`; +4. `src/app/private-zoom/__tests__` 与组件测试; 5. Elio、Maya、Nayeli 列表互不串联; 6. 登录身份变化会刷新当前角色列表; 7. 成功、余额不足、价格变化、重复解锁、退款失败和 not found 分支; diff --git a/docs/external-entry/README.md b/docs/external-entry/README.md index 39c9efad..290cbbdc 100644 --- a/docs/external-entry/README.md +++ b/docs/external-entry/README.md @@ -19,7 +19,7 @@ https:///external-entry?<参数> | 参数 | 可选值或格式 | 用途 | | --- | --- | --- | -| `target` | `chat`、`tip`、`private-room` | 指定进入的页面;不传或值无效时进入聊天页。 | +| `target` | `chat`、`tip`、`private-zoom` | 指定进入的页面;不传或值无效时进入聊天页。 | | `character` | `elio`、`maya`、`nayeli` | 指定角色;不传或值无效时使用 Elio。 | | `psid` | string | 传入 Facebook Page-scoped User ID。 | | `mode` | `promotion` | 开启聊天促销模式。 | @@ -78,13 +78,13 @@ https:///external-entry?target=tip 私密空间: ```text -https:///external-entry?target=private-room +https:///external-entry?target=private-zoom ``` 进入 Nayeli 私密空间: ```text -https:///external-entry?target=private-room&character=nayeli +https:///external-entry?target=private-zoom&character=nayeli ``` `psid` 可以与任意 `target` 或聊天促销参数组合使用。参数值需要进行 URL 编码,不要在入口中传递登录 Token、Page Access Token 或 App Secret。 diff --git a/docs/external-entry/debug-links.md b/docs/external-entry/debug-links.md index aabc90d4..0fcd6779 100644 --- a/docs/external-entry/debug-links.md +++ b/docs/external-entry/debug-links.md @@ -40,8 +40,8 @@ | 角色 | 链接 | | --- | --- | -| Elio | [打开 Elio 私密空间](http://localhost:3000/external-entry?target=private-room&character=elio) | -| Maya | [打开 Maya 私密空间](http://localhost:3000/external-entry?target=private-room&character=maya) | -| Nayeli | [打开 Nayeli 私密空间](http://localhost:3000/external-entry?target=private-room&character=nayeli) | +| Elio | [打开 Elio 私密空间](http://localhost:3000/external-entry?target=private-zoom&character=elio) | +| Maya | [打开 Maya 私密空间](http://localhost:3000/external-entry?target=private-zoom&character=maya) | +| Nayeli | [打开 Nayeli 私密空间](http://localhost:3000/external-entry?target=private-zoom&character=nayeli) | 如需为其他入口携带 PSID,在链接末尾追加 `&psid=27511427698460020`。对于不带查询参数的默认入口,请改为追加 `?psid=27511427698460020`。 diff --git a/docs/external-entry/links.md b/docs/external-entry/links.md index 501795af..7164e81f 100644 --- a/docs/external-entry/links.md +++ b/docs/external-entry/links.md @@ -18,9 +18,9 @@ | 咖啡打赏 | [打开咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip) | | Maya 咖啡打赏 | [打开 Maya 咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip&character=maya) | | Nayeli 咖啡打赏 | [打开 Nayeli 咖啡打赏](https://frontend-test.banlv-ai.com/external-entry?target=tip&character=nayeli) | -| 私密空间 | [打开私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-room) | -| Maya 私密空间 | [打开 Maya 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-room&character=maya) | -| Nayeli 私密空间 | [打开 Nayeli 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-room&character=nayeli) | +| 私密空间 | [打开私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-zoom) | +| Maya 私密空间 | [打开 Maya 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-zoom&character=maya) | +| Nayeli 私密空间 | [打开 Nayeli 私密空间](https://frontend-test.banlv-ai.com/external-entry?target=private-zoom&character=nayeli) | ## 正式环境 @@ -36,8 +36,8 @@ | 咖啡打赏 | [打开咖啡打赏](https://cozsweet.com/external-entry?target=tip) | | Maya 咖啡打赏 | [打开 Maya 咖啡打赏](https://cozsweet.com/external-entry?target=tip&character=maya) | | Nayeli 咖啡打赏 | [打开 Nayeli 咖啡打赏](https://cozsweet.com/external-entry?target=tip&character=nayeli) | -| 私密空间 | [打开私密空间](https://cozsweet.com/external-entry?target=private-room) | -| Maya 私密空间 | [打开 Maya 私密空间](https://cozsweet.com/external-entry?target=private-room&character=maya) | -| Nayeli 私密空间 | [打开 Nayeli 私密空间](https://cozsweet.com/external-entry?target=private-room&character=nayeli) | +| 私密空间 | [打开私密空间](https://cozsweet.com/external-entry?target=private-zoom) | +| Maya 私密空间 | [打开 Maya 私密空间](https://cozsweet.com/external-entry?target=private-zoom&character=maya) | +| Nayeli 私密空间 | [打开 Nayeli 私密空间](https://cozsweet.com/external-entry?target=private-zoom&character=nayeli) | 如需在其他入口携带 PSID,在链接末尾追加 `&psid=27511427698460020`。 diff --git a/docs/frontend-integration/2026-07-22-private-zoom-rename.md b/docs/frontend-integration/2026-07-22-private-zoom-rename.md new file mode 100644 index 00000000..51f3d884 --- /dev/null +++ b/docs/frontend-integration/2026-07-22-private-zoom-rename.md @@ -0,0 +1,176 @@ +# Private Zoom 全链路改名联调说明 + +- 日期:2026-07-22 +- 目标环境:本地、`pre`、生产 +- 当前状态:已实现待联调 + +## 1. 目标 + +原功能名称存在拼写错误。本次将前端、unified 后端、Manager、数据库、埋点和文档中的正式命名统一为 `Private Zoom`,并同步切换页面路由、API 路径和公开枚举值。 + +## 2. 环境地址 + +| 环境 | 前端 | API Base URL | +| --- | --- | --- | +| `pre` | `https://frontend-test.banlv-ai.com` | `https://proapi.banlv-ai.com` | +| 生产 | `https://cozsweet.com` | `https://api.banlv-ai.com` | + +## 3. 前端影响 + +前端已完成以下修改: + +- 页面路由统一为 `/private-zoom` 和 `/characters/{characterSlug}/private-zoom`。 +- 外部入口参数统一为 `target=private-zoom`。 +- 支付回跳参数统一为 `returnTo=private-zoom`。 +- 类型、Repository、API client、XState actor、Provider 和资源目录统一使用 `privateZoom`、`PrivateZoom`、`private-zoom` 或 `private_zoom`。 +- 埋点键统一为 `navigation.private_zoom`、`chat.open_private_zoom_from_avatar` 等新名称。 +- 角色能力字段统一为 `capabilities.privateZoom`。 + +旧页面和旧查询参数不再作为正式入口保留。发布时必须让前后端属于同一个发布批次。 + +## 4. API 变更清单 + +所有接口都使用 `Authorization: Bearer `,响应继续使用现有统一 envelope。请求与响应中未列出的业务字段保持原类型和含义。 + +| 方法 | 新路径 | 用途 | +| --- | --- | --- | +| `GET` | `/api/private-zoom/albums` | 查询付费图片包 | +| `POST` | `/api/private-zoom/albums/{albumId}/unlock` | 解锁图片包 | +| `GET` | `/api/private-zoom/moments` | 查询朋友圈式内容 | +| `POST` | `/api/private-zoom/moments/{momentId}/unlock` | 解锁单条内容 | +| `GET` | `/api/private-zoom/config` | 查询当前角色和用户解锁配置 | +| `GET` | `/api/private-zoom/diaries` | 查询关系日记 | +| `POST` | `/api/private-zoom/diaries/{diaryId}/seen` | 标记关系日记已读 | +| `POST` | `/api/private-zoom/diaries/{diaryId}/unlock` | 解锁关系日记 | + +### 4.1 图片包列表 + +```http +GET /api/private-zoom/albums?characterId=elio&limit=20 +Authorization: Bearer +``` + +| 字段 | 位置 | 类型 | 必填 | 可为 `null` | 说明 | +| --- | --- | --- | --- | --- | --- | +| `characterId` | query | string | 是 | 否 | 角色 ID | +| `limit` | query | integer | 否 | 否 | `1-50`,默认 `20` | +| `cursor` | query | string | 否 | 是 | 分页游标 | + +调用示例: + +```bash +curl 'https://proapi.banlv-ai.com/api/private-zoom/albums?characterId=elio&limit=20' \ + -H 'Authorization: Bearer ' +``` + +### 4.2 解锁图片包 + +```http +POST /api/private-zoom/albums/{albumId}/unlock +Authorization: Bearer +Content-Type: application/json +``` + +| 字段 | 位置 | 类型 | 必填 | 可为 `null` | 说明 | +| --- | --- | --- | --- | --- | --- | +| `albumId` | path | string | 是 | 否 | 图片包 ID | +| `expectedCost` | body | integer | 否 | 是 | 前端确认价格;与后端价格不一致时拒绝解锁 | + +```bash +curl -X POST 'https://proapi.banlv-ai.com/api/private-zoom/albums//unlock' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"expectedCost":320}' +``` + +### 4.3 Moments 内容与解锁 + +```http +GET /api/private-zoom/moments?characterId=elio&limit=20 +POST /api/private-zoom/moments/{momentId}/unlock +``` + +解锁请求体与图片包一致,`expectedCost` 为可选整数。响应中的正式分类值同步改为: + +```json +{ + "lockDetail": { + "type": "private_zoom_moment", + "reason": "private_zoom_moment" + } +} +``` + +## 5. 其他公开值 + +| 使用位置 | 新值 | 类型 | +| --- | --- | --- | +| `/api/chat/unlock-private` 的 `lockType` 别名 | `private_zoom` | string enum | +| 日程导入 `contentType` 别名 | `private_zoom` | string enum,归一化为 `paid_content` | +| 外部入口 `target` | `private-zoom` | string enum | +| 支付回跳 `returnTo` | `private-zoom` | string enum | +| 锁定详情分类 | `private_zoom_moment` | string enum | + +## 6. 数据库迁移 + +发布前执行 unified 仓库中的: + +```text +database/private-zoom-migration.sql +``` + +迁移会在同一事务中完成: + +- 将历史解锁表无损重命名;新旧表同时存在时先合并再删除旧表。 +- 重命名关联约束、索引、策略和触发器。 +- 合并 `users.preferences` 中历史解锁键,防止已解锁内容重新锁定。 +- 把 `credit_ledger` 历史分类迁移到 `private_zoom` 和 `private_zoom_moment`。 +- 定向迁移历史埋点键、页面 URL 和系统媒体路径,不改写用户聊天正文。 +- 支持重复执行。 + +## 7. 兼容性与发布顺序 + +这是破坏性命名修正,不保留旧 API、旧页面路由或旧公开枚举作为正式兼容层。必须按以下顺序发布: + +1. 备份数据库并记录当前 unified、前端和 Manager 版本。 +2. 执行 `database/private-zoom-migration.sql`。 +3. 部署 unified `pre`,验证所有 `/api/private-zoom/*` 接口。 +4. 部署前端 `pre`,验证页面、外部入口、解锁和支付回跳。 +5. Manager 更新后验证积分用途显示为新分类。 +6. `pre` 验证通过后,同一提交集晋级生产。 + +## 8. 错误与界面状态 + +- `401`:Token 缺失或无效,前端进入现有登录恢复流程。 +- `403`:游客访问仅注册用户可用的关系日记操作,展示现有注册引导。 +- `404`:角色、图片包、内容或日记不存在,展示现有空状态或失效提示。 +- `409`:`expectedCost` 与后端价格不同,刷新列表后重新确认。 +- `402` 或业务余额不足错误:进入现有充值流程,并使用 `returnTo=private-zoom` 返回原角色页面。 +- 网络失败:不修改本地解锁状态,保留重试入口。 + +## 9. 验收用例 + +1. `/characters/elio/private-zoom`、Maya 和 Nayeli 对应页面均可打开。 +2. `GET /api/private-zoom/albums` 返回当前角色内容,角色之间不混用。 +3. 已解锁历史记录在迁移后保持解锁状态,不重复扣积分。 +4. 新解锁成功后刷新页面仍为已解锁状态。 +5. 积分不足进入充值页后,回跳到原角色 `/private-zoom` 页面。 +6. `target=private-zoom&character=nayeli` 进入 Nayeli 对应页面。 +7. Manager 的积分用途不再出现旧分类。 +8. 三个仓库执行旧命名残留扫描,结果为 `0`。 + +## 10. 当前测试证据 + +- unified:`316 passed`。 +- 前端:TypeScript 类型检查通过;Vitest `639 passed`;ESLint 通过;契约测试 `4 passed`;生产构建通过并生成三个角色的 `/private-zoom` 页面。 +- Manager:`75 passed`。 +- PostgreSQL 16 临时库:升级、重复升级、回滚和再次升级全部通过;历史解锁、偏好键、积分账本、埋点、媒体路径和数据库对象名均通过断言,临时容器已删除。 + +## 11. 回滚影响 + +仅回滚应用镜像而不回滚数据库,会让旧版本无法读取新表名,因此生产回滚必须同时执行 unified 仓库中的 `database/private-zoom-rollback.sql`。回滚前应停止写入,先保存切换后的新增解锁记录,再执行逆向迁移并恢复旧应用版本。不得只恢复前端或只恢复 unified。 + +## 12. 待确认事项 + +- `pre` 浏览器真实联调和生产发布尚未执行。 +- 生产镜像 ID 将在发布前固化到发布记录。 diff --git a/e2e/specs/mock/chat/chat-avatar-navigation.spec.ts b/e2e/specs/mock/chat/chat-avatar-navigation.spec.ts index b29e5adb..44a1bb71 100644 --- a/e2e/specs/mock/chat/chat-avatar-navigation.spec.ts +++ b/e2e/specs/mock/chat/chat-avatar-navigation.spec.ts @@ -42,7 +42,7 @@ test("guest user avatar returns to Profile after sign-in", async ({ }); for (const character of characters) { - test(`${character.displayName} avatar opens the matching Private Room`, async ({ + test(`${character.displayName} avatar opens the matching Private Zoom`, async ({ page, }) => { await enterCharacterChat(page, character.slug); @@ -50,13 +50,13 @@ for (const character of characters) { await page .getByRole("button", { - name: `Open ${character.displayName}'s private room`, + name: `Open ${character.displayName}'s private zoom`, }) .last() .click(); await expect(page).toHaveURL( - new RegExp(`/characters/${character.slug}/private-room(?:\\?.*)?$`), + new RegExp(`/characters/${character.slug}/private-zoom(?:\\?.*)?$`), ); }); } diff --git a/public/images/private-room/banner/elio.png b/public/images/private-zoom/banner/elio.png similarity index 100% rename from public/images/private-room/banner/elio.png rename to public/images/private-zoom/banner/elio.png diff --git a/public/images/private-room/banner/maya.png b/public/images/private-zoom/banner/maya.png similarity index 100% rename from public/images/private-room/banner/maya.png rename to public/images/private-zoom/banner/maya.png diff --git a/public/images/private-room/banner/nayeli.png b/public/images/private-zoom/banner/nayeli.png similarity index 100% rename from public/images/private-room/banner/nayeli.png rename to public/images/private-zoom/banner/nayeli.png diff --git a/scripts/contracts/__tests__/openapi-contract.test.mjs b/scripts/contracts/__tests__/openapi-contract.test.mjs index 9473e5de..9216acb1 100644 --- a/scripts/contracts/__tests__/openapi-contract.test.mjs +++ b/scripts/contracts/__tests__/openapi-contract.test.mjs @@ -13,13 +13,13 @@ describe("OpenAPI contract comparison", () => { chatHistory: { method: "get", path: "/api/chat/history" }, unlockAlbum: { method: "post", - path: "/api/private-room/albums/{albumId}/unlock", + path: "/api/private-zoom/albums/{albumId}/unlock", }, }, { paths: { "/api/chat/history": { get: {} }, - "/api/private-room/albums/{album_id}/unlock/": { post: {} }, + "/api/private-zoom/albums/{album_id}/unlock/": { post: {} }, }, }, ); diff --git a/src/app/_components/__tests__/tailwind-components.test.tsx b/src/app/_components/__tests__/tailwind-components.test.tsx index ee7524d3..43487509 100644 --- a/src/app/_components/__tests__/tailwind-components.test.tsx +++ b/src/app/_components/__tests__/tailwind-components.test.tsx @@ -85,8 +85,8 @@ describe("shared Tailwind components", () => { undefined} />, ); @@ -100,10 +100,10 @@ describe("shared Tailwind components", () => { expect(characterHtml).toContain(' ); diff --git a/src/app/characters/[characterSlug]/private-room/page.tsx b/src/app/characters/[characterSlug]/private-room/page.tsx deleted file mode 100644 index 1eafb602..00000000 --- a/src/app/characters/[characterSlug]/private-room/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { PrivateRoomScreen } from "@/app/private-room/private-room-screen"; - -export default function CharacterPrivateRoomPage() { - return ; -} diff --git a/src/app/characters/[characterSlug]/private-room/layout.tsx b/src/app/characters/[characterSlug]/private-zoom/layout.tsx similarity index 60% rename from src/app/characters/[characterSlug]/private-room/layout.tsx rename to src/app/characters/[characterSlug]/private-zoom/layout.tsx index fcd0b02f..b0e5b309 100644 --- a/src/app/characters/[characterSlug]/private-room/layout.tsx +++ b/src/app/characters/[characterSlug]/private-zoom/layout.tsx @@ -2,9 +2,9 @@ import type { ReactNode } from "react"; import { notFound } from "next/navigation"; import { getCharacterBySlug } from "@/data/constants/character"; -import { PrivateRoomRouteProvider } from "@/providers/private-room-route-provider"; +import { PrivateZoomRouteProvider } from "@/providers/private-zoom-route-provider"; -export default async function CharacterPrivateRoomLayout({ +export default async function CharacterPrivateZoomLayout({ children, params, }: { @@ -13,11 +13,11 @@ export default async function CharacterPrivateRoomLayout({ }) { const { characterSlug } = await params; const character = getCharacterBySlug(characterSlug); - if (!character?.capabilities.privateRoom) notFound(); + if (!character?.capabilities.privateZoom) notFound(); return ( - + {children} - + ); } diff --git a/src/app/characters/[characterSlug]/private-zoom/page.tsx b/src/app/characters/[characterSlug]/private-zoom/page.tsx new file mode 100644 index 00000000..96eb67dc --- /dev/null +++ b/src/app/characters/[characterSlug]/private-zoom/page.tsx @@ -0,0 +1,5 @@ +import { PrivateZoomScreen } from "@/app/private-zoom/private-zoom-screen"; + +export default function CharacterPrivateZoomPage() { + return ; +} diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 50b3d3b9..6ef95cfe 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -253,8 +253,8 @@ export function ChatScreen() { ); } - function handleOpenCharacterPrivateRoom(): void { - router.push(characterRoutes.privateRoom); + function handleOpenCharacterPrivateZoom(): void { + router.push(characterRoutes.privateZoom); } return ( @@ -304,7 +304,7 @@ export function ChatScreen() { onUnlockImageMessage={handleUnlockImageMessage} onOpenImage={handleOpenImage} onUserAvatarClick={handleOpenUserProfile} - onCharacterAvatarClick={handleOpenCharacterPrivateRoom} + onCharacterAvatarClick={handleOpenCharacterPrivateZoom} onLoadMoreHistory={() => { chatDispatch({ type: "ChatLoadMoreHistoryRequested" }); }} diff --git a/src/app/chat/components/__tests__/tailwind-components.test.tsx b/src/app/chat/components/__tests__/tailwind-components.test.tsx index 201d77e9..835a5f85 100644 --- a/src/app/chat/components/__tests__/tailwind-components.test.tsx +++ b/src/app/chat/components/__tests__/tailwind-components.test.tsx @@ -62,10 +62,10 @@ describe("chat Tailwind components", () => { expect(aiHtml).toContain('