Files
FruitServer/docs/plans/PROGRESS.md
T

874 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PROGRESS — 工作留痕(FruitServer)
> 红线第 2 条:谁干活谁写这里(命令 + 原始输出 + 解读,逐动作追加)。
> 指令通道:`docs/plans/INBOX.md`(specs 仓库只拉取、由项目负责人更新)。
---
## 2026-10-16 I1:后台管理端 —— 商品(金币包)配置 + 系统公告管理
计划文件:`docs/plans/2026-10-16-I1.md`(先写计划再动手)。改了 `docs/api-phase1.md` §2.11/§2.12(契约先行)。
### 动作 1:开工前核对仓库状态
```
$ cd FruitServer && git status --short && git log --oneline -1
M docs/api-phase1.md
M fruitshorts-admin-api/.../FruitShortsApplication.java
M fruitshorts-app-api/.../AppNoticeService.java
M sql/data-phase1.sql
?? docs/plans/2026-10-16-I1.md
?? fruitshorts-admin-api/.../ProductAdminController.java(+ NoticeAdminController)
?? fruitshorts-admin-api/src/test/.../ProductAdminControllerContractTest.java(+ Notice / AdminPermissionAnnotation)
?? fruitshorts-module-commerce/.../ProductService.java(+ 3 个 VO)
?? fruitshorts-module-system/.../OpsNoticeService.java(+ 3 个 VO)
7513ac0 docs(plans): 指令通道迁入本仓库(specs 改为只拉取,由项目负责人更新)
```
解读:分支 `develop`,起点就是指令通道那笔 `7513ac0`;工作区只有本轮 I1 的改动。
### 动作 2:契约先行(先改文档再写代码)
`docs/api-phase1.md` 新增 **§2.11 商品(金币包)配置(已实现)**、**§2.12 系统公告管理(已实现)**:
接口表 + 字段表 + 校验错误码表 + 权限点 + 与 C 端可见性口径对齐说明。VIP 口径在契约里定死:
**允许建档、不允许启用**。
### 动作 3:后端门禁 —— `mvn test`
首轮(雪花 id 修复前):
```
$ mvn test
[INFO] Tests run: 33, Failures: 0, Errors: 0, Skipped: 0 (fruitshorts-module-commerce)
[INFO] Tests run: 14, Failures: 0, Errors: 0, Skipped: 0 (fruitshorts-admin-api)
[INFO] Tests run: 49, Failures: 0, Errors: 0, Skipped: 0 (fruitshorts-app-api)
[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0 (fruitshorts-transcode-worker)
[INFO] BUILD SUCCESS
```
解读:存量 91 条(app-api 41 + commerce 27 + admin-api 3 + worker 12 + …)无一跑红,新增 17 条全过,共 108。
### 动作 4:SQL 幂等(连跑三遍)
```
$ psql -f sql/data-phase1.sql # 第一次
INSERT 0 5 / UPDATE 5 / INSERT 0 4 / INSERT 0 4 / INSERT 0 3 / INSERT 0 3
$ psql -f sql/data-phase1.sql # 第二次
INSERT 0 5 / UPDATE 5 / INSERT 0 0 / INSERT 0 0 / INSERT 0 3 / INSERT 0 0
$ psql -t -c "select 'role_menu='||count(*) from sys_role_menu union all ..." # 跑前
role_menu=54 / permission=52 / menu=33 / role_perm=79
$ psql -f sql/data-phase1.sql # 第三次
RUN3 EXIT=0
role_menu=54 / permission=52 / menu=33 / role_perm=79 # 跑后:完全一致
```
解读:脚本可重复执行,**行数不增长、不重复插入**。新增权限点 `1160-1163`、菜单 `31/32/33`(商品挂 22「订单与权益」下、公告新建 32「运营管理」分组 + 33 子菜单),并给超管授了角色-菜单。
### 动作 5:打包 + 起回服务 + curl 自测(商品)
```
$ mvn -pl fruitshorts-app-api,fruitshorts-admin-api -am package -DskipTests → BUILD SUCCESS
(kill 8080/8081 → 重新起 jar → 健康检查)
admin=200 / app=200
```
商品自测(完整脚本 `/tmp/i1-curl.sh`,原始输出 `/tmp/i1-curl.log`):
```
3) 新建商品 i1_test_coin_100(800+80,IDR 9900,platform=GOOGLE)
{"code":0,"message":"ok","data":2111010131922784257}
4) 新建后状态 → "status": 0, "statusText": "已下架" # 新建默认下架
5) code 重复 → {"code":200001,"message":"商品编码已存在:i1_test_coin_100"}
6) 同平台 store id 重复 → {"code":200001,"message":"该平台下商店商品 id 已被占用:I1_TEST_COIN_100"}
7) 金币包 coin_amount=0 → {"code":400,"message":"金币包的金币数量必须大于 0"}
8) VIP vip_days=0 → {"code":400,"message":"会员商品的会员天数必须大于 0"}
9) VIP vip_days=30 → {"code":0} # 允许建档
10) VIP 启用 → {"code":400,"message":"会员商品在订阅功能上线前不能启用(可先建档,上线后再上下架)"}
11) 上架 → {"code":0},详情 status=1/已上架
12) C 端等价查询(store_product_id + platform in (GOOGLE,ALL) + status=1)
2111010131922784257 | i1_test_coin_100 | GOOGLE | I1_TEST_COIN_100 | 800 | 80 | 1 (1 row)
```
公告自测(同一脚本,13–18 步):
```
13) 公告分页(新建前)→ total 0
14) 新建 target=ALL → {"code":0,"data":2111010133357236226}
15) 新建 target=CREATOR → {"code":0,"data":2111010133537591298}
16) 新建 expire_at=2020-01-01(已过期) → {"code":0}
17) C 端 GET /app/notices(匿名)→ total 1,只返回第 14 步那条
18) 后台下架第 14 步那条 → C 端 total 0
```
越权自测(`content.editor01` / role=content_operator,无 `product:*` / `notice:*`):
```
GET /admin/products/page → {"code":403,"message":"无权限"}
GET /admin/notices/page → {"code":403,"message":"无权限"}
POST /admin/products → {"code":403,"message":"无权限"}
PUT /admin/notices/1/status → {"code":403,"message":"无权限"}
(无 token)GET /admin/products/page → {"code":401,"message":"未登录或登录已过期"}
```
解读:商品三类校验、VIP 只建不启、公告"C 端可见/不可见"三个分支、越权 401/403 全部实测通过。
`logs` 里 `200002 该商品未在后台配置` 的触发点在商店校验**之后**(`/app/orders/verify` 当前默认
`DisabledStoreVerifier` → 先返回 `200003 商店校验尚未配置`),所以 C 端链路验证到"进入商店校验环节"为止,
真正的发币链路等 Google Play 凭据到位(见回执「待确认」)。
### 动作 6:清理第一轮自测数据
```
DELETE 3 # 3 个自测商品
DELETE 4 # 4 条自测公告
products | soft_deleted notices
3 | 0 0
```
### 动作 7:前端页面(ReelShortWeb,develop 分支)
新增 4 个文件(首行署名 `// Author: fruit.com.cn` / `<!-- Author: fruit.com.cn -->`):
```
src/api/admin/product.ts src/api/admin/notice.ts
src/views/commerce/ProductManagementView.vue src/views/operation/NoticeManagementView.vue
```
**挂载机制实测**(与 INBOX 描述有出入,以实测为准):菜单树来自 DB `sys_menu`,
`src/utils/routerHelper.ts` 用 `import.meta.glob('../views/**/*.{vue,tsx}')` 按 `component` 字符串**动态解析**;
`src/router/index.ts` 的 `asyncRouterMap` 只静态声明 dashboard/content/user/system 这几个早期分组,
commerce/governance/board/creator-center 都不在里面 —— 所以新增页面**不需要**改静态路由表,
只要 ① 建 `.vue` ② 菜单 `component` 与视图路径一致。本轮据此实现,未动 `src/router/index.ts`。
前端门禁:
```
$ pnpm ts:check → 0 错误(无输出)
$ pnpm exec eslint --ext .js,.ts,.vue ./src
✖ 1 problem (1 error, 0 warnings) # 第一遍:NoticeManagementView.vue prettier 折行
(改完复跑)→ 无输出,0 error
$ pnpm build:pro → Build successful. Please see dist-pro directory
```
### 动作 8:`:3005` 真浏览器实测(Codex in-app browser)
```
登录 admin → 侧边栏出现「订单与权益 → 商品配置」「运营管理 → 系统公告」(其余菜单不变)
#/commerce/products → 标题「商品配置」,11 列与后端字段一一对应,状态列用 statusText(已上架/已下架)
#/operation/notices → 标题「系统公告」,空态「还没有发布公告」
新建商品(UI)→ toast「商品已创建(默认下架,请确认后再上架)」,行内 totalCoin 550、状态「已下架」
新建公告(UI)→ toast「公告已发布」,行内 发布时间 16:32:15、失效时间「-」、状态「已发布」
C 端 GET /app/notices(匿名)→ 立刻能看到这条(total 1)
下架 → toast「公告已下架」→ C 端 total 0
删除 → toast「商品已删除」「公告已删除」→ 列表回到种子数据
溢出检查(1440/1280/1024,两页各一次)
1440: docScroll=1440 clientWidth=1440 overflowX=false
1280: docScroll=1280 clientWidth=1280 overflowX=false
1024: docScroll=1024 clientWidth=1024 overflowX=false
截图:/tmp/i1-product-list.png、/tmp/i1-notice-list.png
```
### 动作 9:❌ 实测抓到的真缺陷 —— 雪花 id 在浏览器里被四舍五入
UI 上点「下架」时后端回:
```
{"code":200002,"message":"公告不存在"} # 明明列表里就有这条
```
定位:
```
$ node -e 'console.log(2111011206851596290, Number.isSafeInteger(2111011206851596290))'
2111011206851596300 false
$ psql -c "select id, title from ops_notice;"
2111011206851596290 | I1 自测公告(UI 创建) # DB 里是 …596290
```
解读:S0 换雪花主键后 id 已超过 JS 安全整数范围(2^53-1 = 9007199254740991)。
浏览器 `JSON.parse` 把 `2111011206851596290` 变成 `2111011206851596300`,
页面拿这个被改过的 id 回查 → `200002`。**这是 S0 之后所有"新建即操作"的通用风险**,
只是本轮新建的页面第一个撞上。
修复(**限定在本轮两个模块的契约内**,不动全局序列化、不动 app-api,避免影响 Flutter 客户端):
```
fruitshorts-module-commerce/.../ProductRespVO.java id 加 @JsonSerialize(using = ToStringSerializer.class)
fruitshorts-module-system/.../NoticeRespVO.java 同上
ProductAdminController#create / NoticeAdminController#create 返回 Result<String>
ReelShortWeb/src/api/admin/{product,notice}.ts id: string
docs/api-phase1.md §2.11/§2.12 写明"id 以字符串下发"
```
新增两条防回归测试(把真实雪花 id 喂进去断言是字符串):
`ProductAdminControllerContractTest#should_send_string_id_when_product_id_exceeds_js_safe_integer`、
`NoticeAdminControllerContractTest#should_send_string_id_when_notice_id_exceeds_js_safe_integer`。
### 动作 10:修复后复跑门禁(最终数字)
```
$ mvn test
[INFO] Tests run: 33, Failures: 0, Errors: 0, Skipped: 0 (module-commerce)
[INFO] Tests run: 16, Failures: 0, Errors: 0, Skipped: 0 (admin-api)
[INFO] Tests run: 49, Failures: 0, Errors: 0, Skipped: 0 (app-api)
[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0 (transcode-worker)
[INFO] BUILD SUCCESS # 共 110 条(存量 91 + 新增 19)
$ mvn -pl fruitshorts-app-api,fruitshorts-admin-api -am package -DskipTests → BUILD SUCCESS
```
修复后复测(同一浏览器,重新起服务后):
```
点「已发布」→ toast「公告已下架」,行内状态变「已下架」 # 修复前这里是「公告不存在」
C 端 GET /app/notices → {"total":0} # 下架后客户端立即不可见
接口返回类型核对:id= 2111012331625517057 type= str
```
### 动作 11:自测数据清理(收尾)
```
DELETE 1 # 商品 i1_ui_coin_880(第一轮 UI 建的)
DELETE 4 # 公告 4 条(含 UI 建的)
DELETE 1 # 商品 i1_ui2_coin_500(修复后 UI 建的)
DELETE 1 # 公告 I1 复测公告:印尼开服
products | soft_deleted notices
3 | 0 0
```
解读:`product` 表回到 3 行种子(`coin_100 / coin_550 / coin_1200`),`ops_notice` 回 0 行。
**没有留下任何自测数据。**
### 本轮遗留(已写进 INBOX 回执的「待确认」)
1. 雪花 id 的全局方案:admin-api 是否统一 `Long → String`(涉及所有既有页面的 `id` 类型),
app-api 是否要动(影响 Flutter 客户端解析)——属于平台级改动,等裁定。
2. `/app/orders/verify` 的真发币链路需要 Google Play 凭据(B1-1 已列清单)。
---
## 2026-10-16 J1:全局 ID 字符串化(Long → String)+ 契约统一
计划文件:`docs/plans/2026-10-16-J1.md`(先写计划再动手)。用户裁定「方案 (a) 全局修改,统一契约」。
### 动作 1:后端全局序列化(fruitshorts-framework)
新增 `JacksonLongToStringConfig`:`SimpleModule` 把 `Long.class` / `Long.TYPE` → `ToStringSerializer`,
经 `Jackson2ObjectMapperBuilderCustomizer` 挂进 Spring Boot 的 `ObjectMapper`。
两个入口都是 `scanBasePackages = "com.fruitshorts"`,一处生效两端一致,**没改 yml**。
一个刻意的例外:`PageResult.total` 是条数不是 id,用 mixin 显式保持数字(前端分页组件要数字)。
为此给 mixin 加了个 `PlainLongSerializer`(`NumberSerializer` 没有无参构造,`using=` 装不上)。
I1 里给商品/公告 VO 逐个加的 `@JsonSerialize` 与 `Result<String>` 新建返回**已回退成自然写法** ——
全局生效后它们只是冗余特例(现在是"所有模块一个口径")。
### 动作 2:契约 §0 总口径
`docs/api-phase1.md` §0 新增「**ID 字段一律以字符串下发**」+ 示例 + 边界(`total` 与 `Integer` 保持数字、
id 回传按字符串传),§2.11/§2.12 的逐模块表述改成引用 §0。
### 动作 3:契约测试的 standalone mapper 也挂上模块(否则假绿)
`ControllerContractSupport`(app-api / admin-api 两处)自带手搓 `JsonMapper`,不含全局模块 ——
不改它的话,测试断言数字 id 也会过,而生产早就变成字符串了。挂上模块后:
```
$ mvn test
[INFO] Tests run: 33, Failures: 0, Errors: 0, Skipped: 0 (fruitshorts-module-commerce)
[INFO] Tests run: 21, Failures: 0, Errors: 0, Skipped: 0 (fruitshorts-admin-api)
[INFO] Tests run: 49, Failures: 0, Errors: 0, Skipped: 0 (fruitshorts-app-api)
[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0 (fruitshorts-transcode-worker)
[INFO] BUILD SUCCESS # 共 115 条(存量 110 + 新增 5)
```
新增 `LongToStringSerializationTest` 5 条:普通字段 / 嵌套对象 / List / Map 值 / 分页(total 数字 + list id 字符串)
/ 请求体 `"id":"…"` 反序列化成 Long / 19 位路径变量绑定成 Long。
另外把 C 端 4 条 id 断言从数字改成 `value("…") + isString()`(原来"数字也能过"的宽松比较不算证据)。
### 动作 4:Web 与客户端适配
见回执的逐文件清单。要点:Web 审计出 4 处把 id 当数字用的地方(代看 id 过 `Number`、
两处 `Number(route.…)` 与 id 比较、菜单顶级哨兵 0 与 "0" 不一致);Flutter 侧 id 本来就是
`(json['id'] ?? '').toString()`,会崩的是被全局规则扫到的 **Long 计数**,只改了 `expiresIn` 与 `viewCount`。
```
$ pnpm ts:check → 0 错误
$ pnpm exec eslint … ./src → 0 error
$ pnpm build:pro → Build successful
$ dart analyze lib test → No issues found!
$ flutter test → All tests passed! (+167)
```
### 动作 5:端到端复现"修复前后"(真实服务)
```
① 复现 PM 那个场景:C 端注册 → admin 列表/详情
$ curl -X POST :8081/app/auth/register -d '{"targetType":"EMAIL","target":"j1.selftest@example.com",…}'
→ "expiresIn": "7200" # long 字段,已是字符串
$ curl -H "Authorization: …" :8080/admin/app-users/page?pageSize=50
total = 2 int # 分页 total 仍是数字(例外生效)
命中用户: uid=U79DG5IOM id='2111015748376858626' id类型=str
$ curl -H "Authorization: …" :8080/admin/app-users/2111015748376858626
code= 0 message= ok # 修复前这里就是 200002
data.id= '2111015748376858626' uid= U79DG5IOM
```
```
② JS 侧逐位一致(真实响应喂 node)
$ node -e '…'
响应原文里的 id(JSON 字符串): "2111015748376858626"
typeof 解析后 : string
String(parsed.id) : 2111015748376858626
库里 app_user.id : 2111015748376858626
逐位相等? : true
---- 反面对照:同一串数字当裸数字解析 ----
裸数字 -> String(parsed.id) : 2111015748376858600
逐位相等? : false
```
```
③ 全局范围:换两个模块(商品 / 创作者)走"新建 → 用响应 id 查详情 → 改状态"
新建商品响应 : {"code":0,"data":"2111015841427492865"}
商品详情 : code=0 id='2111015841427492865'
商品上架 : {"code":0}
新建创作者响应 : {"code":0,"data":"2111015841771425794"}
创作者详情 : code=0 id='2111015841771425794' name=J1 探针工作室
```
```
④ C 端字段类型(匿名可读的链路)
/app/home/feed : drama card id='5'(str) viewCount='3100000'(str)
/app/dramas/5 : id='5'(str) viewCount='3100000'(str) totalEpisodes=72(int) episode id='7'(str)
/app/episodes/7/play : episodeId='7'(str) dramaId='5'(str) durationSec=228(int) unlockCoin=0(int)
```
解读:**Long → 字符串**(id、viewCount、expiresIn、coinAmount),**Integer/int 与分页 total 仍是数字**,
与 §0 的规则和例外完全对上。
### 动作 6:自测数据清理(含一次口径纠正)
```
DELETE 1 # 自测用户 app_user(含 credential 2 / session 2 / device 2 / consent 3 / wallet 1)
DELETE 1 # 商品 j1_probe_coin
DELETE 1 # 创作者 J1PROBE
全库扫描 uid / 邮箱 / 雪花 id → 无任何残留(NOTICE: 扫描完成)
现存:creator 只剩 PM 那条 id=25(CR20261015001)、app_user 只剩 id=28(UHR-DLTH8)、
product 3 行种子、ops_notice 0 行 —— 都不是本轮数据。
```
⚠️ 口径纠正:我第一版清理脚本用 `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`,随后单独删掉并复核。)
---
## 2026-10-16 K1-verify:用正确地址重做真机验证(设备侧取证)—— 结果:**未验证(已按用户要求交回用户执行)**
红线第 10 条新增后,本轮目标是"用正确地址 + 设备侧证据"重做真机验证。以下是逐动作留痕。
### 动作 1:取真实地址(不硬编码)
```
$ ipconfig getifaddr en0
192.168.31.187
$ for i in en0 en1 en2 utun0..3; do ipconfig getifaddr $i; done
en0 = 192.168.31.187 # 只有 en0 有地址;旧的 10.157.242.1 已不存在
$ lsof -nP -iTCP:8081 -sTCP:LISTEN
java 80383 ... TCP *:8081 (LISTEN)
```
### 动作 2:加设备侧集成测试(可重复、可入库的证据)
- `pubspec.yaml` dev_dependencies 加 `integration_test`(sdk)
- 新增 `integration_test/app_backend_test.dart`:在**手机进程里**请求 `/app/home/feed` 与 `/app/dramas/{id}`,
断言列表非空、详情 id 一致;再用真 App 跑一遍,断言首页底部标签栏出现(= 剧目列表加载成功)。
两个用例都把 `API_BASE_URL` 打进设备日志,便于区分"地址不对"还是"设备连不上"。
- `dart analyze lib test integration_test` → No issues found!
### 动作 3:第一次真机执行(**设备侧输出,17:56**)
```
$ IP=$(ipconfig getifaddr en0) && flutter test integration_test -d 00008101-000875813E42001E \
--dart-define=API_BASE_URL=http://$IP:8081
Running Xcode build... Xcode build done. 31.3s
Installing and launching... 20.0s
00:00 +0: should_fetch_home_feed_and_drama_detail_when_device_reaches_backend
[device] API_BASE_URL=http://192.168.31.187:8081
[device] 首页分区=4 剧目=3
[device] 详情 id=5 剧集=3
00:01 +1: should_render_home_drama_list_when_app_boots_on_device
[device] 首页剧目列表已渲染:API_BASE_URL=http://192.168.31.187:8081
00:05 +2: (tearDownAll)
00:06 +2: All tests passed!
```
解读:`[device]` 行是**手机进程里**打印的(不是开发机 curl),断言也在手机进程里跑;这一次是全过的。
### 动作 4:第二次执行(只跑第一个用例,17:59)—— 失败
```
Running Xcode build... Xcode build done. 17.8s
Installing and launching...
The Dart VM Service was not discovered after 60 seconds. This is taking much longer than expected...
```
我同时起了 lsof 轮询想抓"手机 IP → 8081"的已建立连接,**一个连接都没抓到**(原始文件只有 lsof 表头)——
因为 app 那次根本没 attach 上,也就没有发请求。这一步没有产出任何设备侧证据。
### 动作 5:第三次(`flutter run` 真机跑,18:03–18:04)—— 被叫停
```
Launching lib/main.dart on iPhone in debug mode...
Running Xcode build... Xcode build done. 30.4s
Installing and launching...
You may be prompted to give access to control Xcode...
```
PM 报告:该轮后续撞上 iOS 构建报错
`build/ios/iphoneos/video_player_avfoundation_video_player_avfoundation.bundle: No such file or directory`,
并把进程停掉、执行了 `flutter clean`(我自己的日志到 "Installing and launching" 为止,上面这条报错文本来自 PM 报告,不是我抓到的)。
**推测成因(如实写,未定论)**:我这轮 flutter run 与另一次执行**并发写同一个 build/ 目录**,
产物被互相破坏(PM 也把"并发写 build 目录"列为成因之一)。
### 动作 6:按用户指令停止执行,转为交接
用户 20:45 指令:「codex 不用跑测试 我来执行」。我随即:
```
$ kill -TERM 76461 76458 76675 77472 76250 # 我的 flutter run / xcodebuild / frontend_server / lsof 轮询
$ pgrep -fl "flutter_tools|xcodebuild|k1v_poller"
4173 ... flutter_tools.snapshot daemon # 只剩 IDE 常驻 daemon(非构建)
```
`build/` 目录里留着我被 kill 的那次构建的半成品,用户按 `flutter clean && flutter pub get` 起跑即可清掉。
### 结论口径
- 按**红线第 10 条**与用户要求:本批真机验证**标注「未验证」**,由用户执行后以用户的结果为准。
- 动作 3 那次是**真实的设备侧输出且全过**,但随后两次执行失败(一次 VM service 未 attach、一次构建产物缺失),
所以在用户重跑之前**不把它当作最终结论**;它作为历史记录保留在这里。
- 我**没有**用任何"开发机 curl"作为设备可达证据。
---
## 2026-10-17 K2:分类 + 观看记录 + 收藏列表 + 下载授权管理
### 动作 1:按 INBOX 指定顺序读取规范与契约
叙述:开工前先完整核对 INBOX K2 条目、项目约束、Git 流程、接口 §0/§1.3/§1.5/§1.6,
再读 A3/A5、CONTENT/RECORD 用例与测试计划。当前没有启动客户端构建,也没有改业务代码。
```
$ sed -n '1,260p' docs/plans/INBOX.md
# INBOX — 给 Codex 的指令队列(FruitServer 仓库内)
## [待处理] 2026-10-17 | K2 客户端第二批:分类 + 观看记录 + 收藏列表 + 下载授权(A3/A5 剩余)+ UI
本批只做 K2,做完停下写回执,不要连做 K3/K4。
接口只认 FruitServer/docs/api-phase1.md §1.3 / §1.5 / §1.6
$ sed -n '1,260p' /Users/jianghaifeng/Desktop/ReelShort/AGENTS.md
# 水果短剧(FruitShorts)— Codex 项目上下文
## 强制约束(不可违反)
## 自主执行原则(Autonomy — 最高优先级)
$ sed -n '1,340p' /Users/jianghaifeng/Desktop/ReelShort/CODING_STANDARDS.md
## 3. Dart / Flutter(ReelShort)
TDD 强制:先写测试(红灯)→ 实现(绿灯)→ 重构
门禁:dart analyze lib test 零告警 + flutter test 全过
$ sed -n '1,300p' /Users/jianghaifeng/Desktop/ReelShort/GIT_WORKFLOW.md
## 一、分支模型(Git Flow)
feature/*:具体功能开发,来源 develop
合并一律 --no-ff(禁用 fast-forward)
$ sed -n '1,64p;150,158p;186,222p' docs/api-phase1.md
## 0. 通用约定
所有 ID 字段(主键与外键)在 JSON 里都是字符串
### 1.3 内容浏览(已实现)
GET /app/categories | 分类列表
### 1.5 观看记录与收藏(已实现)
GET /app/watch/recent?limit=20 | 最近观看
DELETE /app/watch/progress/{dramaId} | 删除某剧记录
DELETE /app/watch/progress | 清空记录
### 1.6 离线下载(已实现)
GET /app/downloads/policy
PUT /app/downloads/settings
POST /app/downloads
GET /app/downloads
GET /app/downloads/{episodeId}/url
POST /app/downloads/{episodeId}/progress
DELETE /app/downloads/{episodeId}
DELETE /app/downloads
$ sed -n '1,90p' /Users/jianghaifeng/Desktop/ReelShort/docs/plans/codex-研发任务清单.md
- A3 内容(03):首页 feed、发现 feed、分类、详情(一次请求渲染详情页)、搜索。
- A5 记录/收藏(05):观看记录、最近观看、收藏切换与列表、下载授权。
$ sed -n '1,135p' /Users/jianghaifeng/Desktop/ReelShort/docs/16-测试用例库.md
TC-CONTENT-09 | 分类题材 | 有分类 | 查看分类 | 分类列表正确 | P2
TC-RECORD-01 | 观看历史 | 有播放 | 查看历史 | 按时间倒序,可删除 | P0
TC-RECORD-03 | 取消收藏 | 已收藏 | 取消 | 状态反转 | P0
TC-RECORD-04 | 收藏列表 | 有收藏 | 查看 | 分页正确 | P1
TC-RECORD-09 | 历史清空 | 有历史 | 清空 | 全部清空 | P2
$ sed -n '1,100p' /Users/jianghaifeng/Desktop/ReelShort/docs/09-测试计划.md
单元测试 | Dart 核心类 | flutter test | 核心模块 ≥80%
UI 测试 | 关键页面 | Flutter widget test | 核心页面
```
解读:K2 范围、TDD 顺序、接口白名单、三语言文案、三态、登录闸、门禁与设备验证边界均已确认。
搜索没有契约接口,本批不做;收藏分页和观看记录卡片字段按 INBOX 的降级方案处理。
### 动作 2:只读核对两个仓库基线与客户端现状
叙述:确认客户端从干净 `develop` 开工,并核对现有网络层、记录/收藏实现和页面入口,
用于计划中的真实文件清单。
```
$ git status --short --branch && git log -5 --oneline --decorate
## develop...origin/develop
00280ca (HEAD -> develop, origin/develop) merge: 同步 main 的作者标识提交到 develop
84a1f58 test(client): 新增设备侧集成测试(真机跑通后端可达性)
d7eff4c feat(client): 对接详情/播放/进度续看/收藏(K1 看剧闭环)+ 三态 UI
$ git status --short --branch && git log -5 --oneline --decorate # FruitServer
## develop...origin/develop
4118efd (HEAD -> develop, origin/develop) docs(inbox): 按 specs 补全 K2 执行规范与门禁
096e90c docs(inbox): 记录用户裁定——K2 先按现状契约执行,契约缺口后期再补
582453f docs(inbox): 下发 K2 客户端第二批(分类/记录/收藏/下载授权 + UI)
$ find lib -maxdepth 4 -type f | sort
lib/core/data/remote/catalog_api.dart
lib/core/data/remote/library_api.dart
lib/core/data/remote/watch_api.dart
lib/pages/discover/discover_view.dart
lib/pages/mine/mine_view.dart
...
```
解读:两仓均无未提交改动。分类 API、下载 API/page/controller 不存在;
观看记录删除只有单条,没有清空;收藏数据层已有但无独立页面;
`ApiClient` 只有 GET/POST/DELETE,下载设置所需的 PUT 需最小补齐。
### 动作 3:写 K2 计划
叙述:先落计划再动业务代码,明确四块范围、实施顺序、影响文件、三个契约缺口、
风险和待确认项。
```
文件:docs/plans/2026-10-17-K2.md
章节:范围与完成口径 / 实施顺序与 TDD / 预计影响文件 /
契约缺口 / 风险与控制 / 待确认 / 完成定义
```
解读:后续实现按 K2-A → K2-B → K2-C → K2-D 推进;每块先红后绿并单独跑门禁。
真机与 Android 构建结果按实际证据记录,缺失时明确标“未验证”。
### 动作 4:创建功能分支并跑存量基线门禁
叙述:客户端从 `develop` 创建任务指定分支;沙箱不允许写 `.git/index.lock`,
Git 与 Flutter 工具链缓存写操作按权限流程在受控环境执行。先确认存量门禁本身是绿的。
```
$ git checkout -b feature/flutter-k2-library develop
Switched to a new branch 'feature/flutter-k2-library'
## feature/flutter-k2-library
$ dart analyze lib test
Analyzing lib, test...
No issues found!
$ flutter test
00:10 +188: All tests passed!
```
解读:客户端基线 188 个测试全过、静态检查零告警,可以开始 K2-A。
### 动作 5:K2-A 测试红灯
叙述:先新增分类模型/API/控制器的契约测试,覆盖雪花 ID 字符串、分类到
`category-{id}` 分区映射、缺失分区空态、hot/new 入口、全量去重和失败重试。
```
$ flutter test test/category/category_controller_test.dart
test/category/category_controller_test.dart:6:8: Error: Error when reading
'lib/core/data/models/content_category.dart': No such file or directory
test/category/category_controller_test.dart:10:8: Error: Error when reading
'lib/core/data/remote/category_api.dart': No such file or directory
test/category/category_controller_test.dart:11:8: Error: Error when reading
'lib/core/data/repositories/category_repository.dart': No such file or directory
test/category/category_controller_test.dart:12:8: Error: Error when reading
'lib/core/data/services/category_controller.dart': No such file or directory
00:00 +0 -1: Some tests failed.
```
解读:红灯原因符合预期,目标实现尚不存在。下一步实现分类数据层与控制器。
### 动作 6:K2-A 实现绿灯与门禁
叙述:实现 `ContentCategory`、`CategoryApi`、`CategoryRepository`、
`CategoryController`,发现页改为读取 `/app/categories` 与 `/app/discover/feed`;
保留 For You / Hot / New 样式,服务端分类只匹配 `category-{id}`。
```
$ flutter test test/category/category_controller_test.dart
00:00 +5: All tests passed!
$ dart analyze lib test
Analyzing lib, test...
No issues found!
$ flutter test
00:08 +191 ~1: All tests passed!
```
解读:分类块绿。`~1` 是既有真实后端联调用例因本机 8081 未启动而按设计跳过,
不是失败。分类 UI 仍保留 hot/new 入口;后端发现 feed 未下发对应分区时显示空态。
### 动作 7:K2-B 测试红灯
叙述:先固定观看记录页核心逻辑:`updatedAt` 解析、recent 按 dramaId 去重、
详情并发上限 4、单条详情失败降级与单项重试、删除/清空乐观回滚、recent 失败态。
```
$ flutter test test/watch/watch_records_controller_test.dart
test/watch/watch_records_controller_test.dart:11:8: Error: Error when reading
'lib/core/data/services/watch_records_controller.dart': No such file or directory
test/watch/watch_records_controller_test.dart:23:3: Error: No named parameter
with the name 'updatedAt'.
test/watch/watch_records_controller_test.dart:49:21: Error: The getter
'updatedAt' isn't defined for the type 'WatchProgress'.
00:00 +0 -1: Some tests failed.
```
解读:红灯符合预期,观看记录控制器与更新时间字段尚不存在。
### 动作 8:K2-B 实现与门禁
叙述:增加 `updatedAt`、观看记录清空 API/repository,新增独立
`WatchRecordsController` 与 `WatchRecordsPage`。“我的 → 观看记录”已改为打开独立页。
详情请求按 dramaId 去重、最多 4 个并发;单条详情失败显示占位与单项重试;
单删和清空先更新 UI,失败回滚并提示;清空有二次确认。
```
$ flutter test test/watch/watch_records_controller_test.dart test/watch/watch_records_page_test.dart
00:00 +10: All tests passed!
$ dart analyze lib test
Analyzing lib, test...
No issues found!
$ flutter test
00:08 +201 ~1: All tests passed!
```
解读:K2-B 绿。`~1` 仍是既有真实后端联调用例跳过;本轮新增 10 个记录相关测试。
### 动作 9:K2-C 测试红灯
叙述:先补收藏取消失败的原位回滚测试,再新增独立收藏页的卡片、取消失败、
空态、失败重试与未登录闸测试。
```
$ flutter test test/library/favorite_controller_test.dart test/library/favorites_page_test.dart
test/library/favorite_controller_test.dart:80:8: Error: The setter 'failRemove'
isn't defined for the type 'InMemoryFavoriteRepository'.
test/library/favorites_page_test.dart:13:8: Error: Error when reading
'lib/pages/favorites/favorites_page.dart': No such file or directory
test/library/favorites_page_test.dart:108:23: Error: The getter 'listCalls'
isn't defined for the type 'InMemoryFavoriteRepository'.
00:00 +0 -2: Some tests failed.
```
解读:红灯符合预期:独立收藏页不存在,测试仓库也还不能模拟删除失败。
### 动作 10:K2-C 实现与门禁
叙述:新增独立 `FavoritesPage` 和复用列表卡片,服务端列表三态齐全;
取消收藏采用乐观更新,失败时按原索引回滚;我的页增加“我的收藏”入口。
```
$ flutter test test/library/favorite_controller_test.dart test/library/favorites_page_test.dart
00:00 +13: All tests passed!
$ dart analyze lib test
Analyzing lib, test...
No issues found!
$ flutter test
00:10 +207 ~1: All tests passed!
```
解读:K2-C 绿。收藏页按契约全量读取,不发送 `page/size`;真实后端联调用例仍按设计跳过。
### 动作 11:K2-D 测试红灯
叙述:先用 API 契约测试钉住 §1.6 全部 8 个下载接口与字符串 ID;
再用控制器测试固定策略/授权加载、后端 message 透传、设置回滚、删除和清空回滚。
```
$ flutter test test/download/download_api_test.dart test/download/download_controller_test.dart
test/download/download_api_test.dart:7:8: Error: Error when reading
'lib/core/data/remote/download_api.dart': No such file or directory
test/download/download_controller_test.dart:4:8: Error: Error when reading
'lib/core/data/models/download_license.dart': No such file or directory
test/download/download_controller_test.dart:9:31: Error: Type
'DownloadController' not found.
00:00 +0 -2: Some tests failed.
```
解读:红灯符合预期,下载模型、API、仓库与控制器尚未实现。
### 动作 12:K2-D 数据层与页面实现
叙述:实现下载策略/授权模型、8 个接口的 `DownloadApi`、仓库与控制器;
`ApiClient` 仅补契约需要的 `PUT`。新增“我的下载”页,展示策略、配额、
授权/任务原始状态、进度、有效期,并提供仅 Wi-Fi、清晰度、单删和清空;
详情页与播放页接入真实授权申请。授权失败直接展示后端 `message`。
```
$ flutter test test/download/download_api_test.dart test/download/download_controller_test.dart test/download/downloads_page_test.dart
00:00 +10: All tests passed!
$ dart analyze lib test
Analyzing lib, test...
No issues found!
$ flutter test
00:10 +219 ~1: All tests passed!
```
解读:K2-D 首轮绿。文件分片落盘、断点续传、离线播放、`licenseKey` 本地校验未做,
`url` 与 `progress` 只交付 API 方法;这些边界不会写成“已完成”。
### 动作 13:提交前边界审查(清晰度下拉修复)
叙述:审查发现当后端策略 `maxQuality` 已是 `720P/1080P` 时,
下拉选项会重复同一值并触发 Flutter 断言。先补测试得到红灯,再去重选项;
同时处理退出后重新登录页面不刷新、登录闸文案覆盖观看记录/下载的问题。
```
$ flutter test test/download/downloads_page_test.dart
Failed assertion: There should be exactly one item with
[DropdownButton]'s value: 720P.
Either zero or 2 or more [DropdownMenuItem]s were detected with the same value
00:01 +6 -1: Some tests failed.
$ flutter test test/download/downloads_page_test.dart
00:00 +7: All tests passed!
```
解读:边界缺陷已由测试固定并修复,避免特定后端清晰度值导致下载页崩溃。
### 动作 14:最终门禁与 Android 构建
叙述:整理掉格式化造成的无关测试 diff 后,在 develop 合并前后都复跑门禁。
首次 Android 构建因本机 Gradle 依赖经代理下载持续约 12 分钟,但最终成功;
修复后二次构建命中缓存(2.377 秒)。
```
$ dart analyze lib test
Analyzing lib, test...
No issues found!
$ flutter test
00:11 +220 ~1: All tests passed!
$ flutter build apk --debug
Running Gradle task 'assembleDebug'... 705.7s
✓ Built build/app/outputs/flutter-apk/app-debug.apk
$ flutter build apk --debug # 最终修复后
Running Gradle task 'assembleDebug'... 2,377ms
✓ Built build/app/outputs/flutter-apk/app-debug.apk
```
解读:静态检查零告警、220 个测试通过;`~1` 是既有真实后端联调用例因本机未启动
8081 服务按设计跳过。Android debug APK 构建通过。
### 动作 15:Git 提交、合并与推送
叙述:客户端从干净 develop 建功能分支,功能提交与 `--no-ff` 合并提交均使用
项目规定作者。合并前确认 `origin/develop` 无新提交;合并后在 develop 复跑门禁,
再 push,最后删除已完成 feature 分支。未改 main。
```
commit=cc4d7c5a9dc6a4f6f0565a32503cce5973af703c
author=Zhou Weidong <502333560@qq.com>
subject=feat(flutter): 接入分类记录收藏与下载授权
merge=45f5f25640a274edb51bd90b3b5e52c43ba9d9cd
author=Zhou Weidong <502333560@qq.com>
subject=merge: 合并 K2 客户端分类记录收藏与下载授权
$ git push origin develop
To http://git.reelshort2.com/jianghaifeng/ReelShort.git
00280ca..45f5f25 develop -> develop
$ git branch -d feature/flutter-k2-library
Deleted branch feature/flutter-k2-library (was cc4d7c5).
```
解读:客户端 develop 已与 origin/develop 对齐,工作区干净。
### 动作 16:真实数据与未验证边界
叙述:生产环境注入的发现分类、观看记录、收藏、下载授权均调用契约 HTTP 接口,
不再用本地演示目录补齐。由于设备侧验证归用户执行,本轮没有把任何开发机请求
当作“设备可达”证据。
```
已接生产 HTTP 链路:
- GET /app/categories + GET /app/discover/feed
- GET /app/watch/recent + DELETE /app/watch/progress/{dramaId} + DELETE /app/watch/progress
- GET /app/favorites + DELETE /app/favorites/{dramaId} + POST /app/favorites/toggle
- §1.6 全部 8 个 /app/downloads* 契约接口
仍未做:
- 搜索(契约无接口)
- 下载文件分片落盘 / 断点续传 / 离线播放 / licenseKey 本地校验
- 真机 / 模拟器侧验证(未验证,由用户执行)
```
解读:功能代码和构建门禁已完成;设备网络、实际授权状态与 UI 交互需用户按回执步骤验证。