Files
FruitServer/docs/plans/PROGRESS.md
T

21 KiB
Raw Blame History

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,随后单独删掉并复核。)