diff --git a/docs/plans/2026-09-28-App上传提速与无限制选图-实施计划.md b/docs/plans/2026-09-28-App上传提速与无限制选图-实施计划.md index e06b4ce..0a9f82a 100644 --- a/docs/plans/2026-09-28-App上传提速与无限制选图-实施计划.md +++ b/docs/plans/2026-09-28-App上传提速与无限制选图-实施计划.md @@ -2,20 +2,28 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** 提升 App 批量上传吞吐(并发 2→4、准备阶段有界并行、进度回调节流),并用应用内相册选择器(wechat_assets_picker)替换系统照片选择器,解除 100 张上限、支持长按拖动连选与全选。 +**Goal:** 提升 App 批量上传吞吐(并发 2→4、准备阶段有界并行、进度回调节流),并用应用内相册选择器(wechat_assets_picker)替换系统照片选择器,解除 100 张上限、支持长按拖动连选与全选;新增封面/宣传图上传前的裁剪调整页;相机预览改所见即所得并支持捏合变焦;完成服务端查重(409 PHOTO_DUPLICATE / force)的 App 侧集成闭环。 -**Architecture:** 三处提速均为参数/局部改动,不动上传状态机与传输协议;选图替换仅改 `multiImagePickerProvider` 注入点实现 + Android 权限声明,队列/准备器/引擎零改动(Provider 注入架构已就位)。 +**Architecture:** 三处提速均为参数/局部改动,不动上传状态机与传输协议;选图替换仅改 `multiImagePickerProvider` 注入点实现 + Android 权限声明,队列/准备器/引擎零改动(Provider 注入架构已就位);裁剪为独立纯 Flutter 页面(crop_your_image),仅接相册表单两个选图入口,上传链路复用既有 `initAlbumAsset → presign → complete`;相机改动限于 `CaptureSource`/`CameraPluginSource` 与预览手势层;服务端查重集成走「force 任务级透传 + 409 归类不自动重试 + 专用文案」三件套,不动服务端。 -**Tech Stack:** Flutter/Dart(dio、drift、Riverpod)、Kotlin(WorkManager/OkHttp)、`wechat_assets_picker ^10.1.3`(依赖 `photo_manager ^3.5.0`,要求 Flutter ≥3.27,本项目 Dart ^3.12.2 满足)。 +**实施前置检查(必做,已两次发生会话期间合入新提交):** 同步最新 main;对本计划 Files 清单里的文件重跑 grep 核对行号与结构(尤其 enqueue/capture/album_detail/engine);确认没有并行会话在做 P0-1 批次 5(App 侧 force 接线是 P0-1 文档 §81 行既定剩余项,本计划 Task 8 承接——如另有会话在做,删除 Task 8 并保留其余)。 + +**Tech Stack:** Flutter/Dart(dio、drift、Riverpod)、Kotlin(WorkManager/OkHttp)、`wechat_assets_picker ^10.1.3`(依赖 `photo_manager ^3.5.0`,要求 Flutter ≥3.27,本项目 Dart ^3.12.2 满足)、`crop_your_image ^2.0.0` + `image ^4.3.0`(封面/宣传图裁剪,纯 Dart)。 **已确认决策(2026-09-28):** - ① 接受应用内选择器带来的存储权限弹窗(READ_MEDIA_IMAGES); -- ② 不做客户端压缩(原图直传保持不变)。 +- ② 不做客户端压缩(原图直传保持不变); +- ③ 封面/宣传图增加上传前裁剪:选型见 Task 6(方案 A crop_your_image 为主,方案 B image_cropper 为回退);裁剪仅作用于封面/宣传图两张资产,照片上传不裁剪不压缩。 +- ④ 相机预览改「所见即所得」:按传感器比例(纵向 4:3 类)AspectRatio 居中显示,上下露出页面深色背景;不裁切、不拍后裁剪(拍后裁剪丢像素,与②保原片冲突,已否决);同时新增双指捏合变焦 + 双击复位。 **明确不做(Out of scope):** - 前台快路径传输(前台直连 dio、后台 WorkManager 接管)——待本次并发提升数据出来后再评估; - 批量 initUpload 契约扩展; -- 并发 worker 通知合并(4 并发 = 4 条低优先级通知,可接受,观察后再定)。 +- 并发 worker 通知合并(4 并发 = 4 条低优先级通知,可接受,观察后再定); +- 队列页失败项的「仍要上传(force)」快捷动作——force 透传只做到入队弹窗层(Task 8);队列页 force 重试留后续; +- 相册照片列表 UI 的 `_localFilterAutoLoadCap = 100`(album_photos_page.dart:534)与本计划无关——那是照片网格懒加载上限,不是上传限制,验收时勿混淆。 + +(注:相册详情页 `_pickFromGallery` 已在 638dd9b 接入 checkDuplicates 弹窗——两处入队入口查重均已就位,无需处理。) --- @@ -26,8 +34,12 @@ | 上传慢 | 并行传输上限=2;准备阶段两层串行;进度回调每 64KB 一次 | `upload_engine.dart:55`、`enqueue.dart:153`(for-await 串行)、`preparer.dart:117`(`_serialize`)、`UploadPutExecutor.kt:74` | | 一次最多 100 张 | Android 系统 Photo Picker 平台上限 `MediaStore.getPickImagesMaxLimit()`(多数设备=100),app/服务端无此限制 | image_picker_android `ImagePickerUtils.getMaxItems()` | | 只能逐张点选 | 系统 Photo Picker UI 不支持区间拖选/全选,平台限制无解 | 同上 | +| 封面/宣传图无裁剪 | 相册表单选图后原样上传,16:9/3:1 展示区靠 BoxFit.cover 硬裁切显示,用户无法控制取景 | `album_form_page.dart:499-510`(`_pickCoverImage`/`_pickBannerImage` 直接存路径) | +| 相机预览「被放大」 | 预览用 `FittedBox(cover)` 全屏铺满:传感器纵向 4:3 vs 屏幕 ~20:9,横向裁掉 ~38% 画面,观感放大;且 `takePicture` 出的是全幅照片 → 预览所见 ≠ 照片所得 | `capture_page.dart:88-103`(`CameraPluginSource.buildPreview`) | +| 相机无变焦 | 未接 `setZoomLevel`,无捏合/倍数控制 | `capture_page.dart` 全文无 zoom 相关代码 | +| 重复上传 409 死胡同 | 服务端查重(90dcc58)已上线但 App 侧未接线:initUpload 409 的 wire code=VALIDATION_FAILED(message=PHOTO_DUPLICATE),引擎归桶 4 自动重试 2 次后永久失败、队列页显示「参数校验失败」误导文案;`force` 字段存在但无人赋值——弹窗「全部上传」也会被 409 | `uploads.service.ts:124-143`、`upload_engine.dart:500-511`(桶 4)、`models.dart:121`(force 无调用方)、`error_l10n.dart:27-28` | -测试基线(Task 6 验证时先采旧包数据):同批 20 张照片入队+传完的总耗时、入队阶段(全部变 queued)耗时。 +测试基线(Task 9 验证时先采旧包数据):同批 20 张照片入队+传完的总耗时、入队阶段(全部变 queued)耗时。 --- @@ -171,10 +183,10 @@ const int kMaxConcurrentPrepares = 同步更新类级 doc 注释:「串行执行」段改为「有界并发执行(默认 3):文件复制仍是 IO 最重环节,上限钳位防连拍风暴;传输并发由 UploadEngine 另行控制」。 -- [ ] **Step 4: 跑测试确认通过** +- [ ] **Step 4: 跑测试确认通过(预期首跑挂一个既有用例)** Run: `cd apps/mobile && flutter test test/features/uploads/preparer_test.dart` -Expected: 全部 PASS(含既有串行语义测试——显式传 `maxConcurrentPrepares: 1` 的用例如依赖顺序应仍通过;若有用例依赖默认串行顺序,将其构造处补 `maxConcurrentPrepares: 1`) +Expected: 首跑 FAIL——既有「串行执行」用例(preparer_test.dart:219-243,断言 createdAt 按提交顺序非降)在默认并发 3 下交错。处置:该用例构造处补 `maxConcurrentPrepares: 1`(保留串行语义回归保护),同步更新其分组名/注释为新语义;随后全部 PASS。 - [ ] **Step 5: Commit** @@ -361,7 +373,10 @@ provider 实现(文案代理用包内置的 locale 解析助手,勿手写 `< /// 的 100 张硬上限(软上限 9999 防极端内存,photo_manager 网格分页 /// 加载,实际不受限); /// - originFile 取原始文件路径(Android 图片无转码副本,省一次 cache -/// 中转复制);权限请求由 picker 内部处理,拒绝时返回空(等同取消)。 +/// 中转复制);**路径解析有界并行**(批量数百张时避免串行平台通道 +/// 往返阻塞入队启动); +/// - 权限由 picker 内部请求;永久拒绝时 picker 自带 denied/limited 视图 +/// (非「返回空」);返回 null 仅发生在用户取消。 final multiImagePickerProvider = Provider( (ref) => (context) async { final locale = Localizations.maybeLocaleOf(context); @@ -374,12 +389,11 @@ final multiImagePickerProvider = Provider( ), ); if (assets == null) return const []; - final paths = []; - for (final asset in assets) { - final file = await asset.originFile; - if (file != null) paths.add(file.path); - } - return paths; + final files = await Future.wait(assets.map((a) => a.originFile)); + return [ + for (final file in files) + if (file != null) file.path, + ]; }, ); @@ -394,6 +408,8 @@ import 'package:flutter/widgets.dart' show BuildContext, Localizations; import 'package:wechat_assets_picker/wechat_assets_picker.dart'; ``` +同步更新文件头 doc 注释(1-7 行):「image_picker 的平台调用」描述改为 wechat_assets_picker、「准备器内部串行复制」改为有界并行(与 Task 2 落地后的实际行为一致)。 + - [ ] **Step 4: 更新两处调用点** `album_detail_page.dart:341` `_pickFromGallery`(picker 需要 context,且此处在 `await _currentAccountId()` 之后——补 mounted 守卫防 `use_build_context_synchronously` lint): @@ -436,7 +452,329 @@ git commit -m "feat(app): in-app asset picker (wechat_assets_picker) - unlimited --- -### Task 6: 测试服务器验证(测试服务器优先工作流) +### Task 6: 相册封面/宣传图上传前裁剪(crop_your_image) + +**Files:** +- Modify: `apps/mobile/pubspec.yaml` +- Create: `apps/mobile/lib/pages/image_crop_page.dart` +- Modify: `apps/mobile/lib/pages/album_form_page.dart:499-510` +- Modify: `apps/mobile/lib/l10n/app_zh.arb`、`apps/mobile/lib/l10n/app_en.arb` +- Test: `apps/mobile/test/pages/image_crop_page_test.dart` + +**方案选型(决策 ③):** + +| 方案 | 优点 | 缺点 | +|---|---|---| +| **A(选定)`crop_your_image ^2.0.0`** | 纯 Flutter `Crop` 组件(固定纵横比、交互式裁剪框);UI/中英文文案完全走 App 主题与 l10n(与 UI03R 高保真一致);唯一依赖 `image` 包;iOS G3 无需 pods | 大图像素级裁剪/编码在 Dart 层(12MP 约 1~3s,需「处理中」遮罩);`image` 包不解码 HEIC | +| B(回退备选)`image_cropper ^12.2.1` | 原生 uCrop/TOCropViewController,开箱即用、原生性能 | 原生 UI 与 App 主题割裂;需 manifest 注册 UCropActivity + AppCompat 主题;iOS 侧留 G3 | + +裁剪规格(对齐表单展示区,输出上限只作用于这两张资产,与「照片不压缩」决策无关): +- 封面:**16:9**(表单预览区比例),输出宽 ≤1920(1920×1080); +- 宣传图:**3:1**(观众页顶部横幅),输出宽 ≤2400(2400×800); +- 输出 JPEG quality 90、`.jpg` 扩展名(保持 `_guessContentType` → image/jpeg 白名单)。 + +- [ ] **Step 1: 加依赖** + +`pubspec.yaml` 追加(`image` 需显式声明——页面直接 import 它编码输出,不能只靠传递依赖): + +```yaml + # 封面/宣传图裁剪(纯 Flutter) + crop_your_image: ^2.0.0 + image: ^4.3.0 +``` + +Run: `cd apps/mobile && flutter pub get` + +- [ ] **Step 2: l10n 文案**(两个 arb 同步加,`flutter gen-l10n` 随构建自动生成) + +```json + "cropTitle": "裁剪图片", + "cropConfirm": "完成", + "cropCancel": "取消", + "cropProcessing": "处理中…", + "cropUnsupportedFormat": "暂不支持该图片格式,请选择 JPG/PNG" +``` + +- [ ] **Step 3: 写失败测试**(`test/pages/image_crop_page_test.dart`) + +```dart +// 夹具:8×6 JPG 小图(测试资产目录已有 jpg 夹具可复用/新增)。 +testWidgets('取消 → pop(null)', (tester) async { + final popped = await pumpCropPage(tester, fixtureJpg, aspectRatio: 16 / 9); + await tester.tap(find.text('取消')); + await tester.pumpAndSettle(); + expect(popped.value, isNull); +}); + +testWidgets('确认 → pop 出 .jpg 路径', (tester) async { + final popped = await pumpCropPage(tester, fixtureJpg, aspectRatio: 16 / 9); + await tester.tap(find.text('完成')); + await tester.pumpAndSettle(const Duration(seconds: 5)); + expect(popped.value, endsWith('.jpg')); + expect(await File(popped.value!).exists(), isTrue); +}); +``` + +Run: `cd apps/mobile && flutter test test/pages/image_crop_page_test.dart` +Expected: FAIL(页面不存在) + +- [ ] **Step 4: 实现 `ImageCropPage`** + +```dart +/// 封面/宣传图裁剪页:固定纵横比交互裁剪 → 等比缩放上限 → JPEG 落盘。 +/// +/// 输入 image_picker 选中的本机路径;输出裁剪后的临时文件路径 +/// ({tmp}/album_assets/crop_{ms}.jpg);取消/失败返回 null。 +class ImageCropPage extends StatefulWidget { + const ImageCropPage({ + super.key, + required this.sourcePath, + required this.aspectRatio, + this.maxOutputWidth, + }); + + /// 源图本机路径(image_picker 单选返回)。 + final String sourcePath; + + /// 目标宽高比(宽/高):封面 16/9、宣传图 3/1。 + final double aspectRatio; + + /// 输出宽度上限(等比缩放;null=保持裁剪原分辨率)。 + final int? maxOutputWidth; + + @override + State createState() => _ImageCropPageState(); +} +``` + +要点(**crop_your_image 2.0.0 是回调式 API,不是同步返回**): +- `FutureBuilder` 读源文件 bytes → `Crop` 组件(`aspectRatio`、`interactive: true`、`initialRectBuilder` 初始全图、`fixCropRect: true` 锁外框)+ `controller: CropController()` + **`onCropped: (CropResult result)` 回调**; +- AppBar 标题 `l10n.cropTitle`,底部「取消」(pop null)/「完成」(`_controller.crop()`——void,结果走 onCropped); +- `onCropped` 收到 `CropSuccess(image: bytes)` 后:**`Isolate.run`** 内做 `image` 包 decode → 超 `maxOutputWidth` 则 `copyResize(width:)` → `encodeJpg(quality: 90)` → 写 `{tmp}/album_assets/crop_{ms}.jpg` → pop(path)。**像素处理必须进 isolate**:12MP 源全量 decode+encode 在主 isolate 会卡死 UI 数秒,50MP 源约 200MB 内存峰值有 OOM 风险(Crop 组件自身已解码一次,这里避免二次解码叠在 UI 线程); +- `onCropped` 收到 `CropFailure` / decode 失败(HEIC 等不支持的格式)→ 遮罩解除 + `l10n.cropUnsupportedFormat` SnackBar,不 pop; +- 编码期间 `_processing` 遮罩 + `l10n.cropProcessing`(防重复点击); +- 主题用项目 tokens(与 UI03R 一致),不引入新风格。 + +- [ ] **Step 5: 接线相册表单**(`album_form_page.dart:499-510` 替换) + +```dart + Future _pickCoverImage() async { + final path = await ref.read(singleImagePickerProvider)(); + if (path == null || !mounted) return; + final cropped = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ImageCropPage( + sourcePath: path, + aspectRatio: 16 / 9, + maxOutputWidth: 1920, + ), + ), + ); + if (cropped != null && mounted) setState(() => _coverFilePath = cropped); + } +``` + +`_pickBannerImage` 同构(`aspectRatio: 3 / 1`、`maxOutputWidth: 2400`)。语义:选图后**必经裁剪**(无「跳过」——原样上传入口由裁剪框拉到全图自然达成)。队列页 preparationFailed 重选源(queue_tab:742)不接裁剪——那是任务级重选,不是资产裁剪场景。 + +- [ ] **Step 6: 测试 + 静态检查** + +Run: `cd apps/mobile && flutter analyze && flutter test test/pages/image_crop_page_test.dart test/pages/album_form_page_test.dart` +Expected: analyze 无新增告警;测试 PASS(album_form_page_test 现无用例直接驱动封面/宣传图选择——如实施中发现有,需补「pick → 裁剪页出现 → tap 完成」驱动链) + +- [ ] **Step 7: Commit** + +```bash +git add apps/mobile/pubspec.yaml apps/mobile/pubspec.lock apps/mobile/lib/pages/image_crop_page.dart apps/mobile/lib/pages/album_form_page.dart apps/mobile/lib/l10n/ apps/mobile/test/pages/image_crop_page_test.dart +git commit -m "feat(app): crop cover & banner before upload (crop_your_image, 16:9 / 3:1)" +``` + +--- + +### Task 7: 相机预览所见即所得 + 捏合变焦 + +**Files:** +- Modify: `apps/mobile/lib/pages/capture_page.dart:39-120`(CaptureSource 接口 + CameraPluginSource) +- Test: `apps/mobile/test/pages/capture_page_test.dart`(追加用例,现有 fake source 零改动——接口给默认实现) + +**根因(写给人看的为什么):** `buildPreview()` 用 `FittedBox(cover)` 把纵向 4:3 的传感器画面铺满 ~20:9 的屏幕,`scale = max(屏宽/图宽, 屏高/图高)` 由高度撑满决定,宽度溢出 → 左右各裁约 19%(合计 ~38%)画面,所以「像被放大了」。且照片是全幅 4:3,预览所见 ≠ 照片所得。修法:预览按传感器比例 `AspectRatio` 居中(上下露出 `kColorPhotoBackdrop` 深色背景,与深色摄影界面浑然一体),不裁切不变形,所见即所得。 + +**接口扩展策略:** zoom 能力以**默认实现**挂在 `CaptureSource` 抽象类上(`minZoom=1/maxZoom=1/setZoom` no-op),只有 `CameraPluginSource` 覆写真实现——现有测试替身(fake source)不改不破。 + +- [ ] **Step 1: 写失败测试**(capture_page_test.dart 追加) + +```dart +testWidgets('捏合变焦:双指张开驱动 setZoom 并显示倍数徽标', (tester) async { + // fake source 覆写 zoom 能力(min 1 / max 8,记录 setZoom 调用)。 + ... + await tester.pumpWidget(bootCapturePage(zoomFake)); + // ⚠️ 必须两根指针:ScaleGestureRecognizer 的 scale = 当前指距/初始指距, + // 单指针 span=0 → scale 恒为 1.0,永远测不到变焦。 + final p1 = await tester.startGesture(center.translate(-40, 0)); + final p2 = await tester.startGesture(center.translate(40, 0)); + await p1.moveBy(const Offset(-40, 0)); + await p2.moveBy(const Offset(40, 0)); + await tester.pump(); + await p1.up(); + await p2.up(); + expect(zoomFake.calls.last, greaterThan(1.0)); + expect(find.textContaining('×'), findsOneWidget); +}); + +testWidgets('双击复位 1.0×', (tester) async { ... expect(zoomFake.calls.last, 1.0); }); +``` + +Run: `cd apps/mobile && flutter test test/pages/capture_page_test.dart` +Expected: FAIL(无 zoom 能力/手势) + +- [ ] **Step 2: 接口默认实现**(CaptureSource,capture_page.dart:33-52 区段追加) + +```dart + /// 变焦能力下限(默认 1=不支持变焦;子类按硬件覆写)。 + double get minZoom => 1; + + /// 变焦能力上限(默认 1=不支持变焦)。 + double get maxZoom => 1; + + /// 设置变焦倍数(默认 no-op——能力为 1 时 UI 不启用手势)。 + Future setZoom(double level) async {} +``` + +- [ ] **Step 3: CameraPluginSource 覆写** + +```dart + @override + Widget buildPreview() { + // 所见即所得(§4 页面 13 修订,决策 ④):按传感器纵向比例居中显示, + // 上下露出深色背景——不再 FittedBox cover 全屏(那会横向裁 ~38% 画面, + // 且预览所见 ≠ takePicture 全幅照片)。 + final size = _controller.value.previewSize; + Widget preview = CameraPreview(_controller); + if (size != null && size.width > 0 && size.height > 0) { + preview = Center( + child: AspectRatio( + // 交换:竖屏用 height 作宽、width 作高(传感器横向 → 纵向)。 + aspectRatio: size.height / size.width, + child: preview, + ), + ); + } + return preview; + } + + double _zoomLevel = 1.0; + + @override + double get minZoom => _minZoom; + double _minZoom = 1; + double _maxZoom = 1; + + @override + double get maxZoom => _maxZoom; + + @override + Future setZoom(double level) async { + final clamped = level.clamp(minZoom, maxZoom).toDouble(); + if (clamped == _zoomLevel) return; + await _controller.setZoomLevel(clamped); + _zoomLevel = clamped; + } + + double get currentZoom => _zoomLevel; +``` + +`create()` 里 `initialize()` 后读能力(失败按不支持处理): + +```dart + await controller.initialize(); + final source = CameraPluginSource._(controller); + try { + source._minZoom = await controller.getMinZoomLevel(); + source._maxZoom = await controller.getMaxZoomLevel(); + } on Object { + // 能力探测失败(部分设备/模拟器):保持 1/1,捏合不启用。 + } + return source; +``` + +- [ ] **Step 4: 页面手势 + 倍数徽标** + +- `_buildCameraArea` ready 分支:预览外包 `GestureDetector`(`onScaleStart` 记 `_pinchBaseZoom`,`onScaleUpdate` → `setZoom(base * scale)`,`onDoubleTap` → `setZoom(1)`),仅 `maxZoom > 1` 时挂; +- **setZoom 节流与串行化**:`onScaleUpdate` 每帧触发(60-120/s),不能每次都打平台通道——目标值与上次已发值差 ≥0.05 才调用;`setZoom` 在途时缓存最新目标值、完成后补发(防乱序导致徽标与硬件漂移); +- 变焦徽标:捏合期间显示当前倍数(`1.0×` 格式,四舍五入 1 位小数),停止 1s 后淡出(复用 `_feedbackTimer` 模式); +- `_flipCamera`/`_initCamera` 重开相机后 zoom 回 1(新 source 实例天然复位,徽标状态同步清理)。 + +- [ ] **Step 5: 测试 + 静态检查** + +Run: `cd apps/mobile && flutter analyze && flutter test test/pages/capture_page_test.dart` +Expected: analyze 无新增告警;全部 PASS(含既有用例——fake source 未覆写 zoom 走默认实现) + +- [ ] **Step 6: Commit** + +```bash +git add apps/mobile/lib/pages/capture_page.dart apps/mobile/test/pages/capture_page_test.dart +git commit -m "feat(app): camera preview WYSIWYG aspect + pinch zoom with double-tap reset" +``` + +--- + +### Task 8: 服务端查重 App 侧集成(force 接线 + 409 分类与文案) + +**Files:** +- Modify: `apps/mobile/lib/core/error_codes.dart`(+photoDuplicate 常量) +- Modify: `apps/mobile/lib/core/api_client.dart`(initUpload 409 归一) +- Modify: `apps/mobile/lib/core/error_l10n.dart`(+文案) +- Modify: `apps/mobile/lib/features/uploads/task_store.dart`(schema +force 列 + 迁移;`task_store.g.dart` build_runner 重生成) +- Modify: `apps/mobile/lib/features/uploads/{preparer,enqueue,upload_engine}.dart`(force 透传 + 分类) +- Modify: `apps/mobile/lib/pages/{capture_page,album_detail_page}.dart`(弹窗「全部上传」传 force) +- Test: 上述各层对应测试 + +**背景(为什么必须有这个任务):** 90dcc58 服务端查重已上线:initUpload 同 albumId+uploaderId+bytes 已存在 → 409(**wire code=`VALIDATION_FAILED`、message=`PHOTO_DUPLICATE`**——契约未设独立错误码;`force=true` 仅 owner/admin 跳过)。App 侧只有 models 加了 force 字段,未接线。**现状死胡同**:引擎把该 409 归桶 4 → 自动重试 2 次(每次重新 init 又 409)→ 永久 retryableFailed;队列页 lastError 映射到「参数校验失败」类文案——完全误导;去重弹窗「全部上传」也无法把 force 传到服务端(guaranteed 409)。本计划解除选图上限后,重装/跨设备/改名文件的重复上传是高频路径,必须闭环。**本任务承接 P0-1 文档既定的 App 侧剩余项(其 §文件清单「api_client.dart initUpload 加参数;409 PHOTO_DUPLICATE 处理」)——实施前按头部「实施前置检查」确认无并行会话撞车。** + +- [ ] **Step 1: 写失败测试(引擎分类)** + +engine 测试追加:ApiClient 替身对 initUpload 返回 409、body `{code: 'VALIDATION_FAILED', message: 'PHOTO_DUPLICATE', requestId: 'r'}` → 断言任务终态 `retryableFailed` 且 `nextRetryAt == null`(**不自动重试**)、`lastError == 'PHOTO_DUPLICATE'`。 + +Run: `cd apps/mobile && flutter test test/features/uploads/` +Expected: FAIL(现状归桶 4:nextRetryAt 非 null 且 lastError=VALIDATION_FAILED) + +- [ ] **Step 2: 错误归一 + 文案** + +- `ErrorCodes` 加 `static const String photoDuplicate = 'PHOTO_DUPLICATE';` +- ApiClient 统一错误归一处:HTTP 409 且 message 含 `PHOTO_DUPLICATE` → `AppException(code: photoDuplicate, statusCode: 409)`(按 message 识别——wire code 复用 VALIDATION_FAILED 是 P0-1 契约现状;若契约日后设独立 code,此识别自动兼容); +- `error_l10n.dart` 加映射:zh「照片可能重复:同相册已存在相同内容的照片」/ en「Possible duplicate: an identical photo already exists in this album」(队列页 lastError 展示链路复用)。 + +- [ ] **Step 3: 引擎分类(不自动重试桶)** + +`_classifyFailure` 桶 3(不自动重试,人工 retryNow 语义保留)条件追加 `code == ErrorCodes.photoDuplicate`。注意:retryNow 重试仍不带 force——用户想强传需重走选图弹窗选「全部上传」;队列页不做 force 快捷动作(Out of scope)。 + +- [ ] **Step 4: force 任务级透传(TDD:store 层先写失败测试)** + +- `UploadTasks` 表加 `force BOOLEAN NOT NULL DEFAULT 0`;drift schemaVersion +1 + 迁移(照 task_store.dart 既有迁移样例);`flutter pub run build_runner build --delete-conflicting-outputs` 重生成 `.g.dart`;补旧库升级迁移测试(既有行保留、force=0); +- `UploadTaskDraft`/`UploadTask` +`force`;`preparer.prepare` +`force` 参数透传 create; +- `enqueueForUpload` +`List? forcePaths`(默认空集):命中路径建任务 force=true; +- 两处弹窗 `'all'` 分支(capture_page / album_detail_page 的 `_showDuplicateDialog` 调用侧):`enqueueForUpload(..., forcePaths: dupPaths)`——「全部上传」= 用户已确认强传;`'skip'` 分支不变(跳过重复项)。协作者选「全部上传」仍会被服务端 409 → 走 Step 2/3 的可见失败 + 明确文案(权限语义:force 仅 owner/admin 生效,服务端裁决); +- engine `_transferSteps`:`UploadInitRequest(..., force: task.force)`。 + +- [ ] **Step 5: 测试更新** + +- 页面测试:弹窗「全部上传」路径断言 force 透传(fake preparer 捕获 draft.force=true); +- engine 测试:force=true 的任务 init 请求体携带 `force: true`。 + +- [ ] **Step 6: 全量测试 + 静态检查** + +Run: `cd apps/mobile && flutter analyze && flutter test` +Expected: analyze 无新增告警;全部 PASS + +- [ ] **Step 7: Commit** + +```bash +git add apps/mobile/lib apps/mobile/test +git commit -m "feat(app): wire server dedup - force passthrough + PHOTO_DUPLICATE classification & l10n" +``` + +--- + +### Task 9: 测试服务器验证(测试服务器优先工作流) **Files:** 无代码改动;产出验证记录 `docs/plans/progress/`(按项目验收记录惯例)。 @@ -448,23 +786,31 @@ Expected: 产物 `build/app/outputs/flutter-apk/app-release.apk` - [ ] **Step 2: 基线 A/B(并发效果量化)** - 同一批 20 张照片(同网络环境):新包默认(并发 4)vs `--dart-define=MAX_PARALLEL_UPLOADS=2` 各测一次; -- 记录:入队阶段(全部 queued)耗时、传完总耗时、并发上传中通知条数。 +- 入队阶段(全部 queued)耗时:`PREPARE_CONCURRENCY` 1 vs 3 各测一次; +- 记录:入队耗时、传完总耗时、并发上传中通知条数;上传延迟异常时用 `queryTask`(engine 已有)核对 WorkManager expedited 是否降级排队; +- 服务端观察:大批量(150+ 张)连续上传后水印处理是否积压(管理后台/照片可见延迟)——并发提升会放大既有水印队列水位,仅记录不处理。 - [ ] **Step 3: 功能核对清单** - [ ] 首次进选择器:系统存储权限弹窗,允许后网格正常;永久拒绝路径返回空(等同取消,无崩溃) - [ ] 长按进入多选 + 拖动连选;「全选」可用;zh/en 文案随 App 语言 - [ ] 一次选 **>100 张**(如 150 张)入队成功,队列页计数正确 -- [ ] 连拍页(capture)相册入口同样生效;去重弹窗(filename+bytes)不受影响 +- [ ] 连拍页(capture)相册入口同样生效 +- [ ] **换选择器后查重语义**:重复选择同一批已上传照片(filename+bytes 命中)→ 去重弹窗仍出现(originFile 文件名与 image_picker cache 名可能不同,重点核对匹配) - [ ] 上传中切后台:WorkManager 续传、通知进度正常 - [ ] 中途断网→恢复:waitingNetwork 退避与立即重试语义不变 - [ ] 完成后源文件按保留期清理(队列页无异常残留) +- [ ] **封面/宣传图裁剪**:选图 → 拖动/缩放裁剪框 → 完成后表单预览即裁剪结果 → 保存后上传成功;观众页横幅 3:1 显示无变形;编辑相册重复流程正常 +- [ ] **HEIC 源图**:确认时提示「暂不支持该图片格式」(不崩溃、不 pop);选 JPG/PNG 正常 +- [ ] **服务端查重闭环(Task 8)**:owner 重复选同一批照片 → 弹窗「全部上传」→ 上传成功(force 生效);弹窗「跳过重复」→ 只传非重复项;**改名后的重复文件**(本地 filename 查重不命中)→ 服务端 409 → 队列页失败项显示「照片可能重复」文案(非「参数校验失败」)、不自动重试烧次数;协作者账号「全部上传」重复项 → 409 失败可见(force 权限拦截) +- [ ] **相机预览所见即所得**:取景画面与拍出照片内容一致(无「放大/视野变窄」感);上下留边为深色背景不突兀;连拍、闪光、前后摄切换、分类切换全部正常 +- [ ] **变焦**:双指捏合流畅放大缩小、徽标显示当前倍数、双击复位 1.0×;切前摄/重开相机后复位;变焦后拍照照片与预览取景一致;不支持变焦的设备(模拟器)捏合无响应且无崩溃 - [ ] **Step 4: 记录验收结果 + Commit** ```bash git add docs/plans/progress/ -git commit -m "docs(app): upload speedup + asset picker verification on test server" +git commit -m "docs(app): upload speedup + asset picker + crop + camera verification on test server" ``` --- @@ -478,5 +824,11 @@ git commit -m "docs(app): upload speedup + asset picker verification on test ser | Android 15+ dataSync FGS 6 小时上限影响超大尾批 | 既有幂等重传保障;本次不动 | | photo_manager 权限被永久拒绝 | 返回空列表=取消语义,不阻断主流程;后续可加「去设置」引导(不在本次) | | 大批量(数百张)DB/内存压力 | photo_manager 分页加载;drift 逐行写;队列页既有分页渲染 | +| `image` 包纯 Dart 编码性能 | 像素处理全部进 `Isolate.run`(Crop 组件已解码一次,确认链路二次解码+缩放+编码不再占用 UI isolate);输出宽度上限 1920/2400 界定耗时;「处理中」遮罩防重复触发;不可接受则回退方案 B(image_cropper 原生) | +| 与并行会话 P0-1 批次 5 撞车(Task 8 承接其 App 侧剩余项) | 头部「实施前置检查」确认;撞车则删除 Task 8 保留其余(任务间无依赖) | +| `image` 包不支持 HEIC 解码 | 裁剪确认时报 `cropUnsupportedFormat`,引导改选 JPG/PNG;后续如需 HEIC 支持再评估归一化插件(不在本次) | +| crop_your_image 2.0.0 发布已 21 个月 | 纯 Dart 组件 API 稳定、无原生面;若遇阻整体切方案 B,接线点不变(只换 ImageCropPage 内部实现) | +| `setZoomLevel` 设备差异(部分设备 min>1 或不支持) | initialize 后能力探测,探测失败/能力为 1 时禁用手势与徽标(默认实现兜底) | +| AspectRatio 留边在异形屏的观感 | 背景即 `kColorPhotoBackdrop`(深色摄影界面既有色),上下留边与 UI 层同域;真机核对列入 Task 8 清单 | -回退开关:并发(MAX_PARALLEL_UPLOADS)、准备并发(PREPARE_CONCURRENCY)均可用 dart-define 无代码回退;选图器回退 = revert Task 5 单个 commit。 +回退开关:并发(MAX_PARALLEL_UPLOADS)、准备并发(PREPARE_CONCURRENCY)均可用 dart-define 无代码回退;选图器回退 = revert Task 5 单个 commit;裁剪回退 = revert Task 6 单个 commit(接线点只在两个 `_pickXxxImage`,或仅把 ImageCropPage 内部实现换成方案 B);相机回退 = revert Task 7 单个 commit(预览/变焦互不耦合,也可只回退手势层);查重集成回退 = revert Task 8 代码(drift 迁移**不可回滚删除**——schema 版本保留前滚,revert 时仅回退逻辑代码、迁移空壳留置,force 列默认 0 无害)。