96 lines
7.1 KiB
Markdown
96 lines
7.1 KiB
Markdown
# K1 客户端对接:详情 + 播放 + 进度续看 + 收藏(看剧闭环)+ UI 调整 —— 计划(2026-10-16)
|
||
|
||
> 依据:`docs/plans/INBOX.md` 19:40 条目。用户指令:「现在你要做的事对接客户端接口及 UI 调整,
|
||
> 仍然严格按照 specs 执行」→ 本轮做客户端 `ReelShort`(Flutter),目标是一条完整看剧闭环:
|
||
> **首页点进剧 → 详情正确 → 能起播 → 进度能续看 → 能收藏**,全链路走真接口。
|
||
> 契约以 `FruitServer/docs/api-phase1.md` §1.4 / §1.5 为准。
|
||
|
||
## 一、勘察更正(先说清,免得后面对不上)
|
||
|
||
条目里写「客户端只调了 6 个接口、没有详情和票据」—— 这条**不准确**,实测:
|
||
|
||
| 接口 | 现状 |
|
||
| --- | --- |
|
||
| `GET /app/dramas/{id}` | **已接**:`CatalogApi.dramaDetail` + `CatalogController.requireDetail`,详情页在用 |
|
||
| `GET /app/episodes/{id}/play` | **半接**:`CatalogApi.playTicket` + `CatalogController.resolvePlay` 存在,但**没有任何页面调用**;详情下发的 `playUrl`(免费集已带签名)被直接使用 |
|
||
| `/app/watch/*` | **没接**:`WatchHistoryRepository` 是 `SharedPreferences` 本地实现,`demoProfileId` 假身份 |
|
||
| `/app/favorites/*` | **没接**:收藏走本地 `EngagementController`(SharedPreferences),点赞也是本地(点赞属 K3,不动) |
|
||
|
||
所以本轮真正要做的是:**播放票据接上并带过期重取**、**进度从本地换远程**、**收藏换服务端**、
|
||
**三态 UI**,以及把详情页已有的加载/失败态补齐(空态、续看进度条)。
|
||
|
||
## 二、方案(分层:api → repository → logic → view)
|
||
|
||
### 1. 新增 api 类(`lib/core/data/remote/`)
|
||
|
||
| 文件 | 方法 | 对应接口 |
|
||
| --- | --- | --- |
|
||
| `playback_api.dart`(新增) | `ticket(episodeId)` | `GET /app/episodes/{id}/play` |
|
||
| `watch_api.dart`(新增) | `saveProgress` / `progressOfDrama` / `recent` | `POST /app/watch/progress`、`GET /app/watch/progress?dramaId=`、`GET /app/watch/recent?limit=` |
|
||
| `library_api.dart`(新增) | `toggleFavorite` / `favorites` / `removeFavorite` | `POST /app/favorites/toggle`、`GET /app/favorites`、`DELETE /app/favorites/{dramaId}` |
|
||
|
||
票据调用从 `CatalogApi` **搬**到 `PlaybackApi`(条目要求新增 playback_api),
|
||
`CatalogRepository.resolvePlay` 随之移到新的 `PlaybackRepository`——这是本轮唯一"动到既有目录层"的地方,最小接线。
|
||
|
||
### 2. repository(保留抽象,测试注入假实现)
|
||
|
||
- `playback_repository.dart`(新增):`ticketFor(episodeId, {forceRefresh})`。
|
||
`HttpPlaybackRepository` 内含**短时缓存**:默认 TTL 10 分钟(签名有效期契约是 15 分钟,留余量),
|
||
**时钟与 TTL 可注入** → 过期自动重取,测试用假时钟钉死。
|
||
- `watch_history_repository.dart`(**改签名**):服务端按 `(user, drama)` 存一条续看记录,
|
||
所以接口从 `(profileId, dramaId, episodeId)` 变成
|
||
`progressFor(dramaId)` / `save({episodeId, position, duration, finished})` / `recent(limit)`;
|
||
`HttpWatchHistoryRepository` + `InMemoryWatchHistoryRepository`(测试用);
|
||
`demoProfileId` 删除;`clampResumePosition` 保留(续看钳位规则不变)。
|
||
- `favorite_repository.dart`(新增):`toggle` / `list` / `remove`;HTTP + 内存实现。
|
||
|
||
### 3. controller / logic
|
||
|
||
- `favorite_controller.dart`(新增):`savedIds` + `cards` + `loading/error`,
|
||
`toggle(dramaId)` **乐观更新 → 失败回滚**(`DELETE`/`toggle` 任一失败都回到原状态)。
|
||
- `DramaDetailLogic` / `MineLogic` / `PlayerLogic`:改用新的 `WatchHistoryRepository`,
|
||
续看点从 `WatchProgress(episodeId, positionMs)` 来(不再是本地 episodeId 字符串)。
|
||
- `PlayerLogic`:
|
||
- 起播前 `ticketFor(episode)`,把签名地址写回 session 的剧集对象;**地址原样交给播放器**
|
||
(`AppConfig.mediaUrl` 只补 host,不动 path/query —— 新增测试钉住 `?e=&s=` 原样保留);
|
||
- 票据拿不到 / `accessType=COIN_REQUIRED|NO_MEDIA` → 由**后端返回值**驱动 UI(锁定/无片源提示 + 重试);
|
||
- 进度上报**节流 10 秒**(原实现是 5 秒),切集/退出/切后台各补一次,`finished` 在播完时上报。
|
||
|
||
### 4. UI
|
||
|
||
- 详情页:加载中 / 失败(可重试)/ 空剧集三态;续看位置在"继续观看"按钮与对应剧集行上给进度条。
|
||
- 播放页:票据加载中 / 失败(可重试)/ 锁定(`accessType` 驱动)三态;收藏按钮真实状态 + 回滚 +
|
||
失败提示(未登录先引导登录,沿用现有 `ensureSignedIn`)。
|
||
- 我的页:最近观看卡片三态(服务端续看位置驱动)、收藏列表三态,去掉 `demoProfileId` 假身份。
|
||
- 文案:新增键写进 `app_en.arb` / `app_zh.arb` / `app_id.arb`,`flutter gen-l10n` 重新生成。
|
||
|
||
## 三、影响面清单
|
||
|
||
新增:`playback_api.dart`、`watch_api.dart`、`library_api.dart`、`watch_progress.dart`(模型)、
|
||
`playback_repository.dart`、`favorite_repository.dart`、`favorite_controller.dart`。
|
||
|
||
改:`watch_history_repository.dart`(签名)、`catalog_api.dart`(去掉 playTicket)、
|
||
`catalog_repository.dart`(去掉 resolvePlay)、`catalog_controller.dart`(同步)、
|
||
`play_ticket.dart`(补 dramaId/number/previewSeconds)、`app.dart`(注入三个仓库 + FavoriteController)、
|
||
`drama_detail_{logic,view,binding}.dart`、`mine_{logic,view,binding}.dart`、
|
||
`player_{logic,view,binding}.dart`、`discover_{logic,binding}.dart`(续看点来源)、
|
||
`app_{en,zh,id}.arb` + 生成的 4 个 l10n 文件。
|
||
|
||
测试:改 `test/support/fake_watch_history.dart`、`test/playback/watch_history_test.dart`、
|
||
`test/catalog/catalog_controller_test.dart`、`test/support/fake_catalog.dart`、
|
||
`test/{discover,mine,drama_detail,player}/…`(续看点改签名);新增
|
||
`playback_ticket_expiry_test.dart`、`progress_throttle_test.dart`、`watch_history_remote_test.dart`、
|
||
`favorite_controller_test.dart`、`player_ticket_gate_test.dart`、`integration/live_watch_flow_test.dart`。
|
||
|
||
## 四、风险 / 待确认
|
||
|
||
1. **改动面比"只加接口"大**:`WatchHistoryRepository` 的签名必须改(服务端是"每剧一条续看记录",
|
||
没有 profile 概念),这会连带改 4 个 logic + 6 个测试文件。这是接口模型决定的,不是顺手重构。
|
||
2. **未登录的边界**:`/app/watch/*` 与 `/app/favorites/*` 需要登录态。客户端策略:
|
||
未登录时**不发**这些请求(浏览/试看仍可匿名),收藏按钮沿用"先引导登录"。
|
||
3. **票据过期无法从响应判断**(契约里没有 expiresAt 字段),所以用**客户端 TTL(10 分钟)**保守失效重取;
|
||
若后端后续补 `expiresAt`,直接换成按字段判断即可(口径已写进计划)。
|
||
4. **`/app/watch/recent` 只给 dramaId/episodeId/位置**,没有剧名封面 —— K1 里"我的"页用
|
||
已加载的首页/发现卡片按 id 映射显示;映射不到就不显示(K2 的记录页再做成独立请求)。
|
||
5. 联调测试需要登录态:新增的 live 测试会**注册一个一次性邮箱账号**,跑完在库里清掉(回执写明)。
|