diff --git a/docs/plans/2026-10-16-K1.md b/docs/plans/2026-10-16-K1.md new file mode 100644 index 0000000..51cf06b --- /dev/null +++ b/docs/plans/2026-10-16-K1.md @@ -0,0 +1,95 @@ +# 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 测试会**注册一个一次性邮箱账号**,跑完在库里清掉(回执写明)。 diff --git a/docs/plans/INBOX.md b/docs/plans/INBOX.md index 67d5f91..0211d51 100644 --- a/docs/plans/INBOX.md +++ b/docs/plans/INBOX.md @@ -19,7 +19,7 @@ --- -## [待处理] 2026-10-16 19:40 | K1 客户端对接:详情 + 播放 + 进度续看 + 收藏(看剧闭环)+ UI 调整 +## [已处理·待用户验收] 2026-10-16 19:40 | K1 客户端对接:详情 + 播放 + 进度续看 + 收藏(看剧闭环)+ UI 调整 **用户指令(2026-10-16 19:30)**:「现在你要做的事对接客户端接口及 UI 调整,仍然严格按照 specs 执行」→ 转到 **客户端 `ReelShort`(Flutter)**,按 specs 阶段 A 推进。 @@ -364,3 +364,94 @@ ReelShort(Flutter)逐文件清单(**只有这些,没夹带别的**) **禅道**:J1 在禅道里同样没有对应任务(项目 2 下仍是 S0/S1/S0-16、A1–A6、B1/B2、C1–C3),未动任何既有任务状态。 **服务现状**:8080/8081 是我刚重建起回的新 jar(自测时在跑);:3005 的 dev server 也在。若被环境回收,按上面「准备」起回来即可。 + +--- + +### 回执(Codex,2026-10-16):K1 完成,停在干净交接点 + +**提交**:`ReelShort d7eff4c`(develop,已推;`main` 未动)。留痕:`docs/plans/PROGRESS.md`;计划:`docs/plans/2026-10-16-K1.md`。 + +**门禁**:`dart analyze lib test` → No issues found!;`flutter test` → **All tests passed!(188 条,存量 27 个测试文件未跑红)**;真实后端联调(8081)见下方原始输出。 + +**先纠正条目里的一处勘察**:客户端**详情接口早已接上**(`CatalogApi.dramaDetail` + `CatalogController.requireDetail`,详情页在用), +票据调用 `CatalogApi.playTicket` 也存在但**没有任何页面调用**。所以本轮真正做的是:把票据接进播放链路、进度从本地换服务端、 +收藏换服务端、补三态 UI。 + +**改了什么(ReelShort,只列这一批)** +新增:`remote/playback_api.dart`、`remote/watch_api.dart`、`remote/library_api.dart`、`models/watch_progress.dart`、 +`repositories/playback_repository.dart`、`repositories/favorite_repository.dart`、`services/favorite_controller.dart`、 +`formatting/error_text.dart`。 +改:`app.dart`(注入三个仓库 + 收藏控制器 + 登录/登出联动)、`remote/api_client.dart`(补 `delete`)、 +`remote/catalog_api.dart` 与 `repositories/catalog_repository.dart`、`services/catalog_controller.dart`(票据职责搬走)、 +`models/play_ticket.dart`(补 dramaId/number/title/previewSeconds)、`repositories/watch_history_repository.dart`(换服务端模型)、 +`pages/{discover,drama_detail,mine,player}/*`、`l10n/app_{en,zh,id}.arb` + 4 个生成文件。 +测试:新增 5 个(含 live 联调),改 8 个(含 fake 与 widget_test 的离线仓库注入)。 + +**怎么改的(要点)** +1. **起播前取票**:`PlaybackRepository.ticketFor()` 带 TTL 缓存(默认 10 分钟 < 后端 15 分钟签名),过期或 `forceRefresh` 时重取; + 回前台发现票据过期会重新取。**签名地址原样交给播放器**(`AppConfig.mediaUrl` 只补 host,path 与 `?e=&s=` 不动,有测试钉住)。 +2. **accessType 驱动 UI**:`COIN_REQUIRED/VIP_REQUIRED` → 锁定提示 + "金币解锁即将开放";`NO_MEDIA` → 暂无片源; + 前端不推断能不能播(契约 §1.4 的硬要求)。付费解锁仍属 B2。 +3. **进度**:服务端每剧一条记录,`POST /app/watch/progress` 节流从 5 秒改成**10 秒**,切集/退出/切后台各补一次,播完带 `finished`; + 续看位置只在"记录里的那一集"生效(其它集从头播);`demoProfileId` 与本地 SharedPreferences 实现**已删除**。 +4. **收藏**:`FavoriteController` 以服务端返回为准,乐观更新 + 失败回滚 + 失败提示;退出登录清空(不跨账号串联)。 +5. **三态**:详情页(加载 / 失败可重试 / 空剧集);播放页(票据加载中 / 失败可重试 / 后端判定不可播); + 我的页(续看读取失败可重试 / 收藏列表加载中·失败·空 / 最近观看加载中·失败·空)。断网与超时文案走 l10n(en/zh/id 各 8 个新键)。 + +**原始联调输出(8081)** +``` +① /app/home/feed → dramaId=5 +② /app/dramas/5 → episodes=3;第一集 id='7' + playUrl=/files/episode/202610/cec39d8e….mp4?e=1792144372&s=8791826a… +③ /app/episodes/7/play → episodeId='7' dramaId='5' accessType=FREE accessible=True +④ POST /app/watch/progress (42s) → positionMs="42000" durationMs="228000" playCount=1 +⑤ GET /app/watch/progress?dramaId=5 → episodeId='7' positionMs='42000' ← 续看位置读得回来 +⑥ GET /app/watch/recent?limit=20 → 条数=1(dramaId='5' episodeId='7') +⑦ POST /app/favorites/toggle → true;GET /app/favorites → 1 条;DELETE → 0 条 +$ flutter test test/integration/live_watch_flow_test.dart → All tests passed! +``` +自测数据已清干净:`app_user` 只剩 PM 那条(id=28)、`watch_progress` 0、`user_favorite` 0。 + +**哪些页面现在走真数据、哪些还是假的** +| 页面/能力 | 数据来源 | 状态 | +| --- | --- | --- | +| 首页 / 发现页 | `/app/home/feed`、`/app/discover/feed` | ✅ 真数据(本批未动) | +| 登录 / 注册 / 刷新 / 登出 | `/app/auth/*` | ✅ 真数据(本批未动) | +| **详情页(剧集列表、可播地址)** | `/app/dramas/{id}` | ✅ 真数据 | +| **详情页"继续观看"与剧集进度条** | `/app/watch/progress?dramaId=` | ✅ 本批接上(原来读本机) | +| **播放页(票据/签名地址/起播判定)** | `/app/episodes/{id}/play` + accessType | ✅ 本批接上 | +| **播放页进度上报(10s 节流)** | `POST /app/watch/progress` | ✅ 本批接上(原来存本机) | +| **我的页:收藏列表 + 收藏按钮** | `/app/favorites*` | ✅ 本批接上(原来存本机) | +| **我的页:最近观看** | `/app/watch/recent` | ✅ 接上;**只渲染能在已加载目录里按 id 匹配到的剧**,匹配不到先不显示(见待确认 2) | +| 我的页:点赞 | 本地 `EngagementController` | ❌ 仍是本地假状态(属 **K3** 互动) | +| 播放页:评论 | 本地 `DramaCommentsSheet` | ❌ 仍是本地(属 **K3**) | +| 播放页:解锁付费集 | — | ❌ 本批不做(属 **B2**,票据已按 `accessType` 提示) | +| 记录页 / 收藏列表页 / 分类搜索 / 下载 | — | ❌ 属 **K2** | + +**用户可照做的验证步骤(每步预期结果)** +准备:后端两个 jar 起着(`admin=200`、`app=200`);`cd ReelShort && flutter run`(模拟器用 +`--dart-define=API_BASE_URL=http://10.0.2.2:8081`;iOS/桌面用默认 127.0.0.1)。 + +1. **匿名可看**:不登录进首页 → 点任一剧 → 详情正常出剧集列表;点某集能起播(免费集)。 + 预期:起播前有一瞬"加载票据",无白屏;付费集显示"第 N 集已锁定 + 金币解锁即将开放"。 +2. **登录后进度**:用「我的 → 登录」注册/登录一个邮箱账号 → 进一部剧 → 看 20~30 秒 → 返回详情。 + 预期:详情页的"继续观看"按钮下方出现进度条与"续看至 mm:ss / mm:ss",剧集行上也有一条进度条。 +3. **跨进程续看(真数据验证)**:杀进程重开 → 进同一部剧 → 直接点"继续观看"。 + 预期:从上次的位置继续播(不是从头)。也可以直接查库: + `psql -c "select drama_id, episode_id, position_ms from watch_progress order by updated_at desc limit 3;"` 预期有该剧记录。 +4. **收藏**:播放页右侧点收藏(或详情页右上角)→ 打开「我的 → Watchlist」。 + 预期:该剧出现在收藏列表;再点一次取消 → 从列表消失。断网时点收藏 → 提示"收藏操作失败,请重试"且图标回到原状态。 +5. **异常路径**:把后端停掉(`lsof -tiTCP:8081 -sTCP:LSLISTEN | xargs kill`)后进详情/播放。 + 预期:详情页给"网络异常,请检查网络后重试"+ 重试按钮;播放页给"此视频暂时无法播放"+ 重试; + 我的页收藏区给失败文案 + 重试;**都不白屏、也不假装成功**。恢复后端后点重试即可继续。 +6. **一套命令验证(不用模拟器)**: + `cd ReelShort && flutter test test/integration/live_watch_flow_test.dart` → 预期 All tests passed(服务没起会 skip)。 + +**待确认 / 如实说明** +1. **票据过期用的是客户端 TTL(10 分钟)**,因为契约里 `play` 响应没有 `expiresAt` 字段。后端若补上过期时间,改成按字段判断即可(一行)。 +2. **`/app/watch/recent` 只返回 dramaId/episodeId/位置,没有剧名封面**,所以"我的页最近观看"目前按 id 去已加载的目录卡片里匹配; + 匹配不到的条目先不显示。**K2 的记录页**建议要么后端补卡片字段,要么做成独立请求(已在计划里记为风险 4)。 +3. **`WatchHistoryRepository` 的接口签名被改了**(服务端是"每剧一条续看记录",没有 profile 概念),连带改了 4 个 logic 与 6 个测试文件—— + 这是接口模型决定的,不是顺手重构;`clampResumePosition` 等本地工具函数保留。 +4. **未登录时**进度与收藏接口一律不发(匿名浏览/试看不受影响),收藏按钮沿用"先引导登录"。 +5. 禅道里仍**没有 K1 任务**(项目 2 下只有 S0/S1、A1–A6、B1/B2、C1–C3),未动任何既有任务状态。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 29ad3db..16552c0 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -358,3 +358,57 @@ DELETE 1 # 创作者 J1PROBE ⚠️ 口径纠正:我第一版清理脚本用 `psql -t -A -c "delete … returning 1" | wc -l` 数"行数", **数到的其实是 psql 的命令标签**(实测删 0 行时输出 `DELETE 0`,也是 1 行),所以那轮打印的 "删除 1 行"是假数字。真正的证据是后面的**全库残留扫描**(零命中)与现存数据核对,已按此结论写回执。 + +--- + +## 2026-10-16 K1:客户端对接(详情 + 播放 + 进度续看 + 收藏)+ 三态 UI + +计划文件:`docs/plans/2026-10-16-K1.md`(先写计划再动手)。仓库:`ReelShort`(Flutter,develop)。 + +### 动作 1:勘察更正 +条目说"客户端只调了 6 个接口、没有详情和票据"——实测**详情已接**(`CatalogApi.dramaDetail`), +**票据半接**(`CatalogApi.playTicket` 存在但没页面调用)。这一点写进计划与回执。 + +### 动作 2:分层实现(api → repository → logic → view) +- 新增 `playback_api.dart` / `watch_api.dart` / `library_api.dart` + `watch_progress.dart` 模型 +- 新增 `PlaybackRepository`(票据 TTL 缓存,默认 10 分钟;时钟/TTL 可注入)、`FavoriteRepository` +- `WatchHistoryRepository` 换成服务端模型(每剧一条续看记录),删掉 `demoProfileId` 与 SharedPreferences 实现 +- `FavoriteController` 乐观更新 + 失败回滚;`app.dart` 注入三个仓库与收藏控制器 + +### 动作 3:门禁(最终数字) + +``` +$ dart analyze lib test +No issues found! +$ flutter test +00:09 +188: All tests passed! +``` + +新增测试:`playback_ticket_expiry_test`(票据过期重取 + 签名 query 原样保留)、 +`progress_throttle_test`(10 秒节流 + 切集补报 + 播完 finished + 续看定位)、 +`watch_history_test`(重写为服务端模型)、`library/favorite_controller_test`(回滚/错误态/退出清空)、 +`integration/live_watch_flow_test`(真实后端端到端)。 + +### 动作 4:真实后端联调(8081,原始输出) + +``` +① 首页 → dramaId=5 +② 详情 → episodes=3,第一集 id='7' playUrl=/files/episode/202610/cec39d8e….mp4?e=1792144372&s=8791826a… +③ 票据 → episodeId='7' dramaId='5' accessType=FREE accessible=True + playUrl 与详情里的签名地址一致(客户端只补 host,不动 path/query) +④ 上报进度(42s)→ {"dramaId":"5","episodeId":"7","positionMs":"42000","durationMs":"228000","playCount":1} +⑤ 读回续看位置 → dramaId='5' episodeId='7' positionMs='42000' durationMs='228000' +⑥ 最近观看 → 条数=1,dramaId='5' episodeId='7' positionMs='42000' +⑦ 收藏 → toggle 返回 true → 收藏列表条数=1(首个 '5')→ DELETE → 取消后条数=0 +``` + +`flutter test test/integration/live_watch_flow_test.dart` → All tests passed(同一链路在客户端代码里跑通)。 +解读:`positionMs/durationMs/dramaId` 都是**字符串**(J1 的新口径),客户端用 `jsonToInt` 解析。 + +### 动作 5:自测数据清理 + +``` +已清理 2 个自测账号(k1.selftest.* / k1.receipt.*)+ 依赖行 +残留核对:app_user 1 行(PM 的 id=28)、app_user_credential 1 行、watch_progress 0、user_favorite 0 +``` +(第一次清理漏了 `app_user` 本体——它的主键列叫 `id` 不是 `user_id`,随后单独删掉并复核。)