P3 收口(Task 12):回归门全绿实测数字、MuMu 设备集成证据(阶段 1 十二腿 + 阶段 2 双场景,引用 it-logs 存档原文)、29+3 轮排障 13 根因 展开表、契约增补(password-change + sourceDeviceId)登记、G2 真机 清单(计划全量 + 集成期新增 5 项)、G3 引用 README-ios、偏差表(计划 内 13 + 计划外 5);总纲 P3 行进度注记(G2 待用户真机执行、D03 待确 认);P3 计划 Task 11/12 勾选。 Co-Authored-By: Claude Code <noreply@anthropic.com>
22 KiB
P3 验收记录
- 阶段:P3 Flutter App(计划
docs/plans/2026-09-18-P3-Flutter-App.md,Task 1–12) - 日期:2026-09-20(实施 2026-09-18/19,设备集成收敛 2026-09-20)
- 基线 commits:
79eb975..HEAD(tagp2→d41be36,共 13 个提交 = 1 计划评审 + 12 实施,不含本记录提交) - 执行模式:子代理驱动 + 双审(每任务先写失败测试→实现→通过→提交;Task 11 集成排障由主代理在真机(MuMu)上 29 轮阶段 1 + 3 轮阶段 2 迭代收敛)
- 回归门:web 侧
pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm e2e全绿(lint 9/9、typecheck 13/13、build 9/9 任务;数字见文末统计表);mobile 侧flutter analyze0 issue +flutter test280/280 +:app:testDebugUnitTest29/29;设备集成套件不重跑(2026-09-20 已全绿,证据=存档日志,见下) - 基础设施:
apps/mobileFlutter 3.44.7 单工程(Android 优先落地;iOS 目录完整、编译留 G3);编排脚本scripts/run-mobile-it.sh(MuMu adb127.0.0.1:16384/ Android 15 /adb reverse tcp:9000/10.0.2.2回环 / compose--profile ov/ 四个 dev 服务 / 时间戳专属账号数据隔离);debug APK 产物路径apps/mobile/build/app/outputs/flutter-apk/app-debug.apk(不入库)
任务清单与提交
| Task | 内容 | Commit |
|---|---|---|
| 1 | 工程脚手架 + 环境脚本(flutter-env.sh、minSdk 24/targetSdk 36、l10n、基线路由) | 8bc50e0 |
| 2 | API 客户端与会话(dio 封装、401 驱逐、错误码全量映射、契约对齐模型) | f975321 |
| 3 | 登录与修改密码(含后端契约增补批次 password-change + sourceDeviceId) | 31ba03d(后端)+ 8be21b8(App 页面) |
| 4 | 相册列表/创建/编辑 + 分类/成员管理 + 分享二维码 | bbea1dd |
| 5 | drift 上传任务库(状态机 9 态、accountId 隔离、保留策略) | d96e56f |
| 6 | 上传引擎(准备阶段、批量传输、失败分类、退避重试、源文件清理守护) | 3ddb0ea |
| 7 | Android 原生后台桥接(WorkManager expedited + dataSync 前台服务) | 4e35bdc |
| 8 | iOS 桥接代码(URLSession background,编译/签名留 G3) | 42e1c2e |
| 9 | 队列 UI(相册/状态/时间三维筛选)+ 连拍拍摄页 | 955f9b1 |
| 10 | App 内语言切换(zh/en/跟随系统,provider 立即生效,arb 完整性门) | 8f3966d |
| 11 | 集成测试(MuMu + 真实后端全栈,阶段 1 主流程 + 阶段 2 后台/杀进程) | fb3c278 + d41be36(29+3 轮设备排障收敛) |
| 12 | 回归门 + 本记录 | 本提交 |
集成测试证据(关键章节)
日志存档于 apps/mobile/build/it-logs/(gitignore,不入库):
- 阶段 1 通过日志:
phase1-20260920-135106.log - 阶段 2 通过日志:
phase2-console.log(运行戳 20260920-145310;分日志bg-home-prepare/verify-*、kill-restart-prepare/verify-*)
工具链
| 项 | 值 |
|---|---|
| 设备 | MuMu 模拟器,adb 127.0.0.1:16384,Android 15(ro.build.version.release=15 实测) |
| App→宿主网络 | 10.0.2.2 回环(API_BASE=http://10.0.2.2:8080、OV_BASE=:8081、VIEWER_BASE=:5175,dart-define 注入) |
| MinIO 上传网络 | adb reverse tcp:9000 tcp:9000——presign URL 内嵌服务端视角 localhost:9000,模拟器的 localhost 是自己,直连必挂;反向隧道后 URL 与 SigV4 host 签名零改动 |
| 后端 | infra/docker-compose.yml --profile ov(mysql/redis/minio 双地域)+ dev:api(8080)/dev:ov(8081)/dev:worker/dev:sync 全真实栈(真实 presign PUT、真实发布、真实跨境同步) |
| 数据隔离 | 集成测试不做 DB reset(dev 库共享):每次运行 seed:it 幂等预置 itmain_/itstaff_/itbg1_/itbg2_ + 时间戳专属账号,相册标题带同一时间戳后缀 |
| 素材 | 测试图 adb push → run-as 拷入 App files 目录(首选)/ /data/local/tmp(回退,卸装存活);大图 2 张(~14-15MB)驱动阶段 2 |
阶段 1:主流程十二腿(p3_flow_test.dart,1 spec 串行全流程)
日志原文逐腿引用(phase1-20260920-135106.log):
| 腿 | 验证内容 | 日志原文 |
|---|---|---|
| 1 | 专属账号登录 | [IT] leg1 登录 OK:itmain_20260920-135106 |
| 2 | 建相册(zh+en 双语标题 + Material 日期/时间选择器) | [IT] leg2 建相册 OK:集成测试相册 20260920-135106([IT] albumId=cmu9eh8oc00fwwxy3mlhf7bmk publicToken=tw3knygd9pkmoanp4froftid) |
| 3 | 相册详情 + 建分类 | [IT] leg3 分类 OK |
| 4 | 邀请成员(staff 账号脚本预建) | [IT] leg4 邀请 OK:itstaff_20260920-135106 |
| 5 | 上传 3 图(选图替身 → 真实准备/队列/引擎/原生 WorkManager 桥接/真实 presign PUT/complete) | [IT] leg5 队列 3 张全部 completed |
| 6 | 服务端上传历史(跨设备记录 + 本设备标记,A19) | [IT] leg6 服务端历史 OK:3 条本设备记录 |
| 7 | ov 公开相册可见 3 张(真实 worker 发布 + 跨境同步管线) | [IT] leg7 ov 公开相册 OK:3 张 |
| 8 | 隐藏一张 → App 管理列表「已隐藏/已发布」状态同步 | [IT] leg8 隐藏同步 OK |
| 9 | 断网(DIO 故障注入)→ waiting_network(UI+store 双断言)→ 恢复重试 |
[IT] leg9 故障闸门关闭(模拟断网) / [IT] leg9 waitingNetwork 断言 OK(UI+store) / [IT] leg9 网络恢复重试 OK:4 张全部 completed |
| 10 | 修改密码(服务端保留当前会话 → refresh 仍成功) | [IT] leg10 修改密码 OK(会话保留,refresh 成功) |
| 11 | 退出登录 → 新密码重登录 | [IT] leg11 新密码重登录 OK |
| 12 | App 内语言切换 zh→en→zh(provider→MaterialApp 全局重构链路) | [IT] leg12 语言切换 en OK / [IT] leg12 语言切回 zh OK —— 全流程完成 |
结果行:00:36 +1: All tests passed!
阶段 2:后台延续 + 杀进程恢复(p3_background_test.dart,prepare/verify 两段式 + 脚本 adb 设备动作)
App 无法自己 HOME/杀自己——prepare 阶段打 MARKER:UPLOADING 后由脚本执行设备动作(计划明示的 adb 编排场景)。
场景 1 bg-home(A04 后台延续,模拟器级):
[run-mobile-it 20260920-145310] bg-home:HOME 键切后台(上传仍进行中)[run-mobile-it 20260920-145310] bg-home:服务端旁证 OK——后台(WorkManager 路径)2 张 confirmed(脚本以 curl 登录真实账号轮询GET /v1/uploads服务端权威确认,非 UI 自证;2 张 ~14-15MB 大图)[IT] verify:服务端上传历史 2 条 confirmed OK+00:02 +1: All tests passed!(verify = 全新进程登录→队列收口→服务端历史)
场景 2 kill-restart(A05 杀进程恢复,模拟器级):
[run-mobile-it 20260920-145310] kill-restart:force-stop 杀进程(上传中)[IT] verify:服务端上传历史 2 条 confirmed OK+00:02 +1: All tests passed!(新进程冷启动后 in-flight 任务经 drift 持久化归位续传完成)- 收口行:
[run-mobile-it 20260920-145310] 阶段 2 全部通过 ✔(A04 后台延续 + A05 杀进程恢复,模拟器级)
A04 / A05(模拟器级)
- A04 后台延续:HOME 切后台后传输由原生 WorkManager 承接(expedited + dataSync 前台服务),服务端旁证 2 张 confirmed;锁屏、厂商后台管理留 G2 真机(计划明示边界)。
- A05 杀进程恢复:上传中
am force-stop→ 新进程登录 → 任务库恢复 in-flight 状态(uploading/uploadedConfirming归位重试,单测另有「卡在 uploading 的任务 drain 前归位并完成」固化)→ 服务端 2 条 confirmed(clientTaskId 幂等下无重复记录)。
A15 / A19(代码级 + 集成佐证)
- A15 连拍归属不变:
- 引擎不可变测试:
test/features/uploads/upload_engine_test.dart「A15:连拍中途切换相册/分类不影响既有任务(引擎只读行内目标)」——目标相册/分类在入队时落任务行,引擎只读行内值,全局切换不影响在途任务。 - 拍摄页语义:
test/pages/capture_page_test.dart「分类切换:hint 提示 + 仅影响之后拍摄(A15 归属不变)」+「连拍两张串行入队(不逐张确认)」+「常驻信息层:相册名 + 分类候选」。
- 引擎不可变测试:
- A19 跨设备服务端历史:
- 页面测试:
test/pages/server_history_page_test.dart「A19 横幅 + 行内容(短码/KB/状态标签/设备短码/本设备标记)」(横幅明示「本机队列不在此列」语义)+「游标加载更多」。 - 设备标识:
test/core/device_id_test.dart8 例(首生成 32 位 hex、跨实例稳定、并发单次落盘、异安装互异、非法值再生)。 - 集成佐证:leg6
3 条本设备记录(sourceDeviceId 随 init 上送、GET /uploads 回显比对)。
- 页面测试:
A01 / A02 联动(App 端)
- A01 认证与会话:App 端会话链全程真实——登录(leg1)、secure storage 持久、改密后当前会话保留(leg10
refresh 成功)、新密码重登录(leg11)、改密撤销其他会话(服务端 e2e,见契约增补节)。单测:api_client_test.dart(Bearer 注入/业务 401 清会话+通知一次/并发 401 去重/登录端点 401 不驱逐/refresh 令牌保留/changePassword 旧密码错不驱逐)、session_store_test.dart(全字段往返/令牌缺失=null/过期时间不可解析容错)。 - A02 权限逐次校验:App 无本地权限缓存——无会话裸发由服务端 401 裁决(
api_client_test.dart「无会话时不注入 Authorization」),每个请求携带 Bearer 由服务端逐次校验;集成全程走真实权限链(邀请 staff、owner 上传、隐藏经真实契约端点),P1a 的 permissions/full-chain e2e 仍是权威证据。
排障实录(29 + 3 轮设备调试——本阶段最有价值资产)
阶段 1 共 29 轮、阶段 2 共 3 轮迭代定位,13 个根因全部修复后全绿(对应 d41be36 提交信息,展开成表):
| # | 现象 | 根因 | 修复 |
|---|---|---|---|
| 1 | adb shell mkdir 报 'C:'/'Files' 只读、adb push 无限悬挂 |
Git Bash/MSYS 把传给 adb.exe 的安卓设备绝对路径(/data/...)改写为 C:\Program Files\Git\data\... 再被 Android shell 拆词 |
按命令 MSYS_NO_PATHCONV=1 仅对含设备路径的 adb 调用禁用转化;宿主侧路径经 winpath()(cygpath)显式转 Windows 形式(run-mobile-it.sh 头部) |
| 2 | 上传卡 uploading 永久挂起 |
presign URL 内嵌服务端视角的 MinIO 地址 localhost:9000——模拟器的 localhost 是自己,永远连不上 |
adb reverse tcp:9000 tcp:9000 反向隧道;URL 与 SigV4 host 签名零改动 |
| 3 | ov-api 一片 500、sync 推送连败 | compose 启动未带 --profile ov,Docker Desktop 重启后 ov 侧容器不会自动回来 |
脚本统一 docker compose --profile ov up -d(曾排查整轮才定位) |
| 4 | 对话框「保存」点击落空、对话框滞留、后续腿全断 | 软键盘遮挡对话框按钮 | dismissKeyboard + ensureVisible 后再点击(ui_steps.dart) |
| 5 | goBack 点中被压在下面的页面返回键,腿 9 莫名退栈 | 栈叠路由多页面同名 BackButton,.first 命中底层页面 |
goBack 取栈顶 hit-testable 的返回键 |
| 6 | 底部弹层选项两次竞态失败 | 弹层入场动画期间选项 hitTestable=false |
等待选项 hit-testable 再操作 |
| 7 | 语言切换弹层点击不稳定 | 弹层选项与顶栏切换器同文案,finder 命中不稳 | 改驱动真实 LocaleSettings provider(弹层交互已由 Task 10 widget 测试覆盖;本腿验证 provider→MaterialApp 全局链路) |
| 8 | 改密提交点击偶发失效 / ensureVisible 抛 No element |
「修改密码」按钮与 AppBar 标题同文案 + 提交中 spinner 瞬时替换文本致 finder 为空 | descendant(of: FilledButton) 限定 + 重试点击直至成功提示出现 |
| 9 | 改密成功后再 goBack 多退一页 | 改密页成功后自行 pop(Task 3 语义),额外返回会退出设置页 | 去掉多余 goBack |
| 10 | bg verify 断言设备侧队列状态失败 | verify 是全新进程,测试装配的任务库为内存实例(跨进程本就不共享) | verify 改服务端权威断言(GET /uploads confirmed 计数);设备侧 drift 持久化由 prepare 阶段真实落库验证 |
| 11 | 重跑后测试图全部「源文件缺失」 | flutter test 每次重装 APK 清掉 App 数据(files 目录素材全没) |
图片目录逐候选容错后回退 /data/local/tmp(adb push 直读、全员可读、卸装存活,app_boot.dart) |
| 12 | run-as cp 失败无感知,后续腿才炸 | 失败被静默吞掉 | cp 失败即 die——测试图为必需件,不再吞错 |
| 13 | 阶段 2 启动即配置失败 | ItConfig.assertValid 要求全量 dart-define,bg 剧本不用 staff 但缺 IT_STAFF_ACCOUNT |
脚本补 IT_STAFF_ACCOUNT=bg-unused-* 占位(run-mobile-it.sh bg_scenario) |
契约增补登记(P3 唯一增补批次,31ba03d)
| 增补 | 契约 | 语义 | e2e |
|---|---|---|---|
| 修改密码 | POST /v1/auth/password-change(operationId changePassword) |
Bearer + {oldPassword 1-128, newPassword 8-128} → 204;旧密码错 401 AUTH_INVALID_CREDENTIALS;成功后事务内更新 argon2 哈希、撤销该用户除当前会话外全部其他会话(本人不下线)、审计 auth.password_change(需求 4.1 的 P1 契约缺口补齐) |
auth +4 例(未认证 401 / 新密码过短 400 / 旧密码错且会话无副作用 / 成功后他端撤销当前保留 + 新旧密码裁决 + 审计) |
| 设备来源 | sourceDeviceId(A19) |
UploadInitRequest 可选 8-64 位设备标识(App 首次生成持久);upload_sessions 落列(VARCHAR(64),可空,存量行 NULL 兼容);GET /uploads 条目回显;幂等重放不比对(first-writer,首次落库值不随重放漂移) |
uploads +1 例(携带落库回显、换设备重放保留首值、缺省 null、长度越界 400) |
api e2e 179 → 184;openapi.yaml 单一事实来源 + gen + redocly 门全绿;admin 无 UI(P4a)。
G2 真机验证清单(强制关口,待用户执行)
计划 Task 12 全量(对齐技术方案 §6.4)+ 集成期新增发现:
计划内(技术方案 6.4 全量)
- 锁屏 ≥15min(上传中息屏,恢复后续传/完成)
- 厂商后台管理(厂商「省电/后台限制」开启下的续传存活)
- 断网 / 凭证过期恢复(真实网络切换 + presign 403 → renew 换 URL)
- 强制退出(用户强杀后重启恢复,UI 不承诺后台完成)
- 源文件缺失(
SOURCE_MISSING不自动重试) - 存储不足(准备阶段空间检查失败路径)
- 批量 100 张真实样本(重复记录数应为 0——clientTaskId 幂等)
- HEIC 真机样本解码(§5.4)
集成期新增发现
- expedited 配额行为:
setExpedited在真机系统配额耗尽时的降级路径(当前 expedited + dataSync 前台服务实现,§6.3 UIDT 主路径待 G2 评估后定) - 厂商杀后台:WorkManager 在激进 ROM(MIUI/EMUI/ColorOS 等)上的实际存活与重排
- dataSync 6h 上限:Android 15 对
foregroundServiceType=dataSync的 6 小时系统上限行为(长队列场景) - POST_NOTIFICATIONS 授权 UX:集成脚本预授权绕过了弹窗——真机首次未授权时上传通知可见性与引导
- 真机 adb 直连(无 reverse/10.0.2.2 便利)下的 API/MinIO 地址配置复核
最小验证集(总纲注记引用):真机装 APK → 登录 → 批量 100 张真实样本上传中锁屏 ≥15min + 切后台 + 断网恢复 → 厂商后台清理/强杀重启恢复 → HEIC 样本 → expedited 配额与通知授权 UX。
G3 iOS 编译清单(资源关口)
代码已完整提交、协议与 Android 逐字一致(Dart 侧同一 NativeTransport 驱动双平台);清单不复制,见 apps/mobile/ios/README-ios.md:代码清单表(UploadBridge/UploadEventBus/UploadTaskStore/UploadPlugin/AppDelegate)、Background Modes 偏差(有意不声明 UIBackgroundModes)、Xcode 首次构建五步(预期零改动通过)、G3 验证清单 10 项(实际文件上传/持久化原生回调/重启重联校准/强制退出/低电量调度/凭证过期/原生重试上限/进度节流/批量样本/HEIC + 可选 XCTest)。
偏差与发现登记
计划内已登记(计划/测试源码明示)
| # | 事项 | 说明 |
|---|---|---|
| 1 | Dart 手工模型对齐契约 | contracts 是 TS 不共享运行时;App 内手工镜像 Dart 模型(注释标注契约 operationId),以 openapi.yaml 为准人工对齐,openapi 生成物作对照文档 |
| 2 | 模拟器网络回环 10.0.2.2 | API/OV/VIEWER_BASE 经 dart-define 指向 10.0.2.2 宿主回环(计划 Task 2 明示) |
| 3 | 断网用 DIO 故障注入闸门 | 模拟器断真实网不便,fault_gate.dart 注入 SocketException 驱动 waiting_network(计划 Task 11 明示) |
| 4 | 语言切换腿改 provider 驱动 | 弹层交互已有 widget 测试覆盖,IT 腿驱动 LocaleSettings provider 验证全局链路(排障 #7) |
| 5 | bg verify 改服务端权威断言 | 全新进程内存库不共享,以 GET /uploads confirmed 为准(排障 #10) |
| 6 | 分类/相册表单腿的键盘收起 | 软键盘遮挡对话框按钮,dismissKeyboard + ensureVisible(排障 #4) |
| 7 | 选图/连拍走多选替身 | enqueueViaGallery 多选替身入队(真实准备/队列/引擎/原生桥接/后端全真);拍摄链路由拍摄页 widget 测试覆盖 |
| 8 | 拍摄腿未在 IT 驱动 | camera 实拍无法在编排内驱动,连拍语义 = 引擎不可变测试 + 拍摄页 widget 测试 + IT 多选替身组合覆盖 |
| 9 | viewer 腿用 ov 公开 API 替代网页断言 | ov 公开相册 API 见 3 张(真实同步管线),网页侧断言由 web e2e 承担 |
| 10 | 隐藏一张经 ApiClient 调真实端点 | App 照片页为 Task 4 占位无操作按钮;同一 ApiClient 调真实契约端点后回 UI 断言状态同步 |
| 11 | Android 桥接 expedited + dataSync | 计划原文 UIDT 主路径(§6.3)落地为 WorkManager expedited + dataSync 前台服务,UIDT 待 G2 真机评估后定(计划 Task 12 明示) |
| 12 | 源文件保留策略默认 14 天 | KEEP_COMPLETED_DAYS=14,仅清理 completed/cancelled,未完成源文件永不清理(守护单测) |
| 13 | iOS UIBackgroundModes 有意不声明 | background URLSession 无需声明,多声明反引入审核 scrutiny(README-ios.md,对计划原文的修正) |
计划外新发现(集成期,本记录补登)
| # | 事项 | 影响/处理 |
|---|---|---|
| 14 | adb reverse tcp:9000 | presign URL 内嵌服务端 localhost MinIO host(见排障 #2)——真机/CI 编排同样需要解决「客户端可达的 MinIO 地址」,G2 真机清单已登记复核项 |
| 15 | compose 必须 --profile ov |
Docker 重启后 ov 侧容器不自动回来(排障 #3);脚本已固化,运维同样适用 |
| 16 | flutter test 重装清数据 | 每次运行重装 APK 清 App 数据,测试素材回退 /data/local/tmp(排障 #11);也意味着集成运行间 App 本地数据不保留(dev 库共享数据隔离设计与此兼容) |
| 17 | phase2 staff define 占位 | ItConfig.assertValid 全量校验要求非 staff 剧本也传 IT_STAFF_ACCOUNT(排障 #13) |
| 18 | MSYS 路径转化按命令禁用 | Windows Git Bash 编排 adb 设备路径的通用坑(排障 #1),方案沉淀在 run-mobile-it.sh 头部注释 |
测试统计汇总(2026-09-20 回归门实测)
Web 侧(P3 未降级;仅 api e2e 因契约增补 +5)
| 包 | 单测 suites(files)/tests | e2e suites(tests) |
|---|---|---|
| @photo/contracts | 3 / 22 | – |
| @photo/storage | 1 / 6 | – |
| @photo/api-cn | 7 / 51 | 16 / 184(P2 179 → +5:password-change 4 + sourceDeviceId 1) |
| @photo/worker | 1 / 8 | 1 / 7 |
| @photo/sync | –(passWithNoTests) | 4 / 27 |
| @photo/viewer | vitest 16 / 105 | playwright 6 spec / 21 |
| @photo/admin | vitest 4 / 15 | playwright 2 spec / 7 |
| web 合计 | 32 / 207 | 29 / 246(jest 21 suites / 218 + playwright 8 spec / 28) |
- 附加门:lint 9/9、typecheck 13/13、build 9/9 任务全绿。
移动端
| 套件 | 结果 |
|---|---|
flutter analyze |
No issues found!(0 issue) |
flutter test(单测/widget,35 文件) |
280 / 280 全过(00:19 +280: All tests passed!) |
Android :app:testDebugUnitTest(gradle) |
29 / 29(UploadEventBus 8 + UploadProtocol 11 + UploadPutExecutor 10,0 失败 0 错误) |
注:不带模块限定的 gradlew test 会连带执行第三方插件 image_picker_android 自带测试套件(本机 10 败)——非本仓代码,App 模块测试以 :app:testDebugUnitTest 为准。
设备集成(2026-09-20 通过,日志存档不重跑)
| 套件 | 规模 | 结果 |
|---|---|---|
| 阶段 1 主流程(p3_flow_test.dart) | 1 spec / 12 腿 | 00:36 +1: All tests passed!(29 轮迭代后收敛) |
| 阶段 2 后台/杀进程(p3_background_test.dart) | 2 场景 ×(prepare+verify) | 两场景 00:02 +1: All tests passed!(3 轮迭代后收敛) |
总量:自动化 207 + 246(web)+ 280 + 29(mobile)= 762(P2 出口 448 → +314:web +5、mobile +309),另设备集成 12 腿 + 2 场景全绿。