Files
photo-live/docs/plans/2026-09-28-App上传提速与无限制选图-实施计划.md
T
浅念 cc47278f50 docs: upload speedup plan v2 — 9 tasks after full re-review
- Task 7 camera WYSIWYG aspect + pinch zoom (two-pointer test, throttled setZoom)
- Task 8 server-dedup app integration (force passthrough, PHOTO_DUPLICATE
  classification, l10n) — closes the 409 dead-end from 90dcc58
- Task 6 crop: crop_your_image 2.0.0 callback API + Isolate.run pixel pipeline
- Stale claims fixed (album_detail dedup already wired in 638dd9b), pre-flight
  parallel-session check added

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 13:34:03 +08:00

835 lines
43 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.
# App 上传提速与无限制选图 实施计划
> **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 张上限、支持长按拖动连选与全选;新增封面/宣传图上传前的裁剪调整页;相机预览改所见即所得并支持捏合变焦;完成服务端查重(409 PHOTO_DUPLICATE / force)的 App 侧集成闭环。
**Architecture:** 三处提速均为参数/局部改动,不动上传状态机与传输协议;选图替换仅改 `multiImagePickerProvider` 注入点实现 + Android 权限声明,队列/准备器/引擎零改动(Provider 注入架构已就位);裁剪为独立纯 Flutter 页面(crop_your_image),仅接相册表单两个选图入口,上传链路复用既有 `initAlbumAsset → presign → complete`;相机改动限于 `CaptureSource`/`CameraPluginSource` 与预览手势层;服务端查重集成走「force 任务级透传 + 409 归类不自动重试 + 专用文案」三件套,不动服务端。
**实施前置检查(必做,已两次发生会话期间合入新提交):** 同步最新 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 条低优先级通知,可接受,观察后再定);
- 队列页失败项的「仍要上传(force)」快捷动作——force 透传只做到入队弹窗层(Task 8);队列页 force 重试留后续;
- 相册照片列表 UI 的 `_localFilterAutoLoadCap = 100`(album_photos_page.dart:534)与本计划无关——那是照片网格懒加载上限,不是上传限制,验收时勿混淆。
(注:相册详情页 `_pickFromGallery` 已在 638dd9b 接入 checkDuplicates 弹窗——两处入队入口查重均已就位,无需处理。)
---
## 背景(问题 → 根因速查)
| 问题 | 根因 | 证据 |
|---|---|---|
| 上传慢 | 并行传输上限=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 9 验证时先采旧包数据):同批 20 张照片入队+传完的总耗时、入队阶段(全部变 queued)耗时。
---
### Task 1: 传输并发默认值 2 → 4
**Files:**
- Modify: `apps/mobile/lib/features/uploads/upload_engine.dart:53-56`
说明:dart-define `MAX_PARALLEL_UPLOADS` 覆盖机制保持不变(A/B 实测用);引擎测试均显式注入 `maxParallel`,不受默认值影响。
- [ ] **Step 1: 修改常量与注释**
```dart
/// 并行传输上限(计划:批量并发为可配置小值;dart-define
/// MAX_PARALLEL_UPLOADS 覆盖)。2026-09-28 上传提速:2 → 4
/// (4G/Wi-Fi 上行可支撑;Android 侧每任务独立 WorkManager 通知,
/// 并发=通知数,观察后再定是否合并)。
const int kMaxParallelUploads =
int.fromEnvironment('MAX_PARALLEL_UPLOADS', defaultValue: 4);
```
- [ ] **Step 2: 跑上传相关测试确认不回归**
Run: `cd apps/mobile && flutter test test/features/uploads/`
Expected: 全部 PASS
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/lib/features/uploads/upload_engine.dart
git commit -m "feat(app): raise default upload parallelism 2 -> 4"
```
---
### Task 2: UploadPreparer 有界并行(默认 3)
**Files:**
- Modify: `apps/mobile/lib/features/uploads/preparer.dart`
- Test: `apps/mobile/test/features/uploads/preparer_test.dart`
设计:把 `_tail` 全局串行链改为**有界并发执行器**(信号量 + FIFO 等待队列)。并发默认 3(dart-define `PREPARE_CONCURRENCY` 覆盖),1 = 等价旧行为。防连拍 IO 风暴的初衷保留(上限钳位),只是不再退化为全串行。
- [ ] **Step 1: 写失败测试**(`preparer_test.dart` 追加)
```dart
test('有界并发:并发 prepare 数不超过 maxConcurrentPrepares 且全部完成', () async {
var active = 0;
var peak = 0;
final store = ... // 按现有测试构造方式(内存 db + tmp 目录)
final preparer = UploadPreparer(
store,
baseDirProvider: () async => tmp,
maxConcurrentPrepares: 3,
// 用可注入的复制观察:直接跑真实 prepare,统计峰值没有钩子——
// 改为构造 9 个任务并发触发,断言全部终态 + 耗时显著小于串行版。
);
final tasks = await Future.wait(
List.generate(9, (i) => preparer.prepare(
accountId: 'acc', albumId: 'alb',
sourcePath: fixtureJpg.path, // 复用现有测试的 jpg 夹具
)),
);
expect(tasks.every((t) => t.state == UploadTaskState.queued), isTrue);
});
```
注:若现有夹具不便统计峰值,验收改为「9 个并发 prepare 全部 queued + 串行对照耗时 > 并行耗时」;峰值断言可通过对 `maxConcurrentPrepares: 1` 的对照行为(同参数 1 时任务仍全部成功)兜底。
- [ ] **Step 2: 跑测试确认失败**
Run: `cd apps/mobile && flutter test test/features/uploads/preparer_test.dart`
Expected: FAIL(`maxConcurrentPrepares` 参数不存在)
- [ ] **Step 3: 实现(preparer.dart)**
构造函数与常量:
```dart
/// 准备阶段有界并发上限(dart-define PREPARE_CONCURRENCY 覆盖;1=串行)。
const int kMaxConcurrentPrepares =
int.fromEnvironment('PREPARE_CONCURRENCY', defaultValue: 3);
```
```dart
UploadPreparer(
this._store, {
Future<Directory> Function()? baseDirProvider,
Random? random,
this.maxBytes = kMaxUploadBytes,
this.maxConcurrentPrepares = kMaxConcurrentPrepares,
this.onEnqueued,
}) : ... // 其余不变
/// 准备并发上限(运行时 <1 钳位为 1)。
final int maxConcurrentPrepares;
int get _effectivePrepareConcurrency =>
maxConcurrentPrepares < 1 ? 1 : maxConcurrentPrepares;
```
替换 `_tail` 串行链(删除 `Future<void> _tail` 与旧 `_serialize`):
```dart
/// 在途准备计数 + FIFO 等待队列(有界并发信号量)。
int _activePrepares = 0;
final Queue<Completer<void>> _prepareWaiters = Queue<Completer<void>>();
/// 有界并发执行:超额排队(FIFO);错误由调用方经返回值处理,不设全局断链。
Future<T> _runBounded<T>(Future<T> Function() op) {
final ticket = _acquirePrepareSlot();
final result = ticket.then((_) => op());
result.whenComplete(_releasePrepareSlot).ignore();
return result;
}
Future<void> _acquirePrepareSlot() {
if (_activePrepares < _effectivePrepareConcurrency) {
_activePrepares++;
return Future<void>.value();
}
final waiter = Completer<void>();
_prepareWaiters.addLast(waiter);
return waiter.future;
}
void _releasePrepareSlot() {
final waiter = _prepareWaiters.isEmpty
? null
: _prepareWaiters.removeFirst();
if (waiter == null) {
_activePrepares--;
return;
}
// 名额直接移交队首等待者(计数不变)。
waiter.complete();
}
```
`prepare`/`retry` 内 `_serialize(...)` 调用点改名为 `_runBounded(...)`(签名同形,两处替换)。`import 'dart:async';` 已有(Completer 需要);补 `Queue` 需 `import 'dart:collection';`。
同步更新类级 doc 注释:「串行执行」段改为「有界并发执行(默认 3):文件复制仍是 IO 最重环节,上限钳位防连拍风暴;传输并发由 UploadEngine 另行控制」。
- [ ] **Step 4: 跑测试确认通过(预期首跑挂一个既有用例)**
Run: `cd apps/mobile && flutter test test/features/uploads/preparer_test.dart`
Expected: 首跑 FAIL——既有「串行执行」用例(preparer_test.dart:219-243,断言 createdAt 按提交顺序非降)在默认并发 3 下交错。处置:该用例构造处补 `maxConcurrentPrepares: 1`(保留串行语义回归保护),同步更新其分组名/注释为新语义;随后全部 PASS。
- [ ] **Step 5: Commit**
```bash
git add apps/mobile/lib/features/uploads/preparer.dart apps/mobile/test/features/uploads/preparer_test.dart
git commit -m "feat(app): bounded-parallel prepare (default 3) replaces serial queue"
```
---
### Task 3: enqueueForUpload 并行收集
**Files:**
- Modify: `apps/mobile/lib/features/uploads/enqueue.dart:152-167`
说明:Task 2 后 preparer 内部已限流,外层 for-await 循环改为 `Future.wait` 即可。**注意:`enqueueForUpload` 已是 P0-1 结构(返回 `EnqueueResult`,`skipPaths` 过滤在函数内)**——并行化只改循环段,保留 `effective` 过滤与返回结构。
- [ ] **Step 1: 替换循环段(152-161 行)**
```dart
final results = await Future.wait(
effective.map((path) => preparer.prepare(
accountId: accountId,
albumId: albumId,
categoryId: categoryId,
sourcePath: path,
)),
);
final enqueued = results
.where((task) => task.state == UploadTaskState.queued)
.length;
```
(返回 `EnqueueResult(enqueued: enqueued, duplicates: [], skipped: sourcePaths.length - effective.length)` 原样保留。)
- [ ] **Step 2: 跑页级测试**
Run: `cd apps/mobile && flutter test test/pages/album_detail_page_test.dart test/pages/capture_page_test.dart`
Expected: 全部 PASS
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/lib/features/uploads/enqueue.dart
git commit -m "feat(app): enqueue gallery batch concurrently via Future.wait"
```
---
### Task 4: 进度回调节流(64KB → 256KB / 完成必报)
**Files:**
- Modify: `apps/mobile/android/app/src/main/kotlin/com/photolive/photo_live/UploadPutExecutor.kt`
- Modify: `apps/mobile/lib/features/uploads/native_transport.dart`(DartHttpTransport)
- Test: `apps/mobile/android/app/src/test/java/com/photolive/photo_live/UploadPutExecutorTest.kt`
语义:仅减少回调频率(256KB 步长 + 末次必报 sent==total),单调性与最终 total 不变——现有「monotonic bytes up to total」断言兼容。
- [ ] **Step 1: Kotlin 侧**
`UploadPutExecutor` object 内加常量:
```kotlin
/** 进度回调最小步长(字节);末块必报。 */
private const val PROGRESS_STEP_BYTES = 256L * 1024
```
`writeTo` 改为:
```kotlin
override fun writeTo(sink: BufferedSink) {
file.inputStream().use { input ->
val buffer = ByteArray(64 * 1024)
var sent = 0L
var lastReported = 0L
while (true) {
val read = input.read(buffer)
if (read < 0) {
break
}
sink.write(buffer, 0, read)
sent += read
if (sent - lastReported >= PROGRESS_STEP_BYTES || sent == total) {
lastReported = sent
onProgress?.invoke(sent, total)
}
}
sink.flush()
}
}
```
- [ ] **Step 2: Dart dio 路径同节流**(`native_transport.dart` `DartHttpTransport.send`)
```dart
final file = File(filePath);
final length = await file.length();
var sent = 0;
var lastReported = 0;
const progressStep = 256 * 1024;
final progressStream = file.openRead().map((List<int> chunk) {
sent += chunk.length;
if (sent - lastReported >= progressStep || sent == length) {
lastReported = sent;
onProgress?.call(sent, length);
}
return chunk;
});
```
- [ ] **Step 3: 双侧测试**
Run: `cd apps/mobile/android && ./gradlew :app:testDebugUnitTest --tests "com.photolive.photo_live.UploadPutExecutorTest"`
Expected: PASS(若有断言逐 64KB 回调次数的用例,改为断言步长 ≥256KB 且末次 sent==total)
Run: `cd apps/mobile && flutter test test/features/uploads/`
Expected: PASS
- [ ] **Step 4: Commit**
```bash
git add apps/mobile/android/app/src/main/kotlin/com/photolive/photo_live/UploadPutExecutor.kt apps/mobile/android/app/src/test/java/com/photolive/photo_live/UploadPutExecutorTest.kt apps/mobile/lib/features/uploads/native_transport.dart
git commit -m "perf(app): throttle upload progress callbacks to 256KB steps"
```
---
### Task 5: wechat_assets_picker 集成(解除 100 张上限 + 连选/全选)
**Files:**
- Modify: `apps/mobile/pubspec.yaml`
- Modify: `apps/mobile/android/app/src/main/AndroidManifest.xml`
- Modify: `apps/mobile/lib/features/uploads/enqueue.dart`
- Modify: `apps/mobile/lib/pages/album_detail_page.dart:341`
- Modify: `apps/mobile/lib/pages/capture_page.dart:307`
- Test: `apps/mobile/test/pages/album_detail_page_test.dart:338`、`test/pages/capture_page_test.dart:179`、`integration_test/helpers/app_boot.dart:96`(及 grep 命中的其余 override)
- [ ] **Step 1: 加依赖**
`pubspec.yaml` 选图/连拍分组追加:
```yaml
# 选图 / 连拍
image_picker: ^1.2.3
camera: ^0.12.1
# 应用内相册选择器(连选/全选/无系统 100 张上限;基于 photo_manager)
wechat_assets_picker: ^10.1.3
```
Run: `cd apps/mobile && flutter pub get`
Expected: 解析成功(photo_manager ^3.5.0 随依赖引入)
- [ ] **Step 2: Android 权限声明**
`AndroidManifest.xml` 权限区追加(minSdk 24 / targetSdk 36 已满足包要求):
```xml
<!-- 应用内相册选择器(wechat_assets_picker/photo_manager):图片读取 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission
android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"
android:maxSdkVersion="34"/>
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32"/>
```
(iOS 留 G3:届时 Info.plist 需 `NSPhotoLibraryUsageDescription`。)
- [ ] **Step 3: 替换 multiImagePickerProvider 实现**(`enqueue.dart`)
typedef 增加BuildContext(picker 需页面上下文):
```dart
/// 多图选择(相册多选入队);返回源文件路径列表(空=取消)。
typedef MultiImagePicker = Future<List<String>> Function(BuildContext context);
```
provider 实现(文案代理用包内置的 locale 解析助手,勿手写 `<语言>TextDelegate` 类名——10.x 导出名不含 `ChineseTextDelegate`):
```dart
/// 多图选择 Provider(生产=wechat_assets_picker;测试 override)。
///
/// - 应用内选择器:长按进入多选 + 拖动连选 + 全选;无系统 Photo Picker
/// 的 100 张硬上限(软上限 9999 防极端内存,photo_manager 网格分页
/// 加载,实际不受限);
/// - originFile 取原始文件路径(Android 图片无转码副本,省一次 cache
/// 中转复制);**路径解析有界并行**(批量数百张时避免串行平台通道
/// 往返阻塞入队启动);
/// - 权限由 picker 内部请求;永久拒绝时 picker 自带 denied/limited 视图
/// (非「返回空」);返回 null 仅发生在用户取消。
final multiImagePickerProvider = Provider<MultiImagePicker>(
(ref) => (context) async {
final locale = Localizations.maybeLocaleOf(context);
final assets = await AssetPicker.pickAssets(
context,
pickerConfig: AssetPickerConfig(
requestType: RequestType.image,
maxAssets: kPickerSoftMaxAssets,
textDelegate: assetPickerTextDelegateFromLocale(locale),
),
);
if (assets == null) return const <String>[];
final files = await Future.wait(assets.map((a) => a.originFile));
return [
for (final file in files)
if (file != null) file.path,
];
},
);
/// 选图软上限(用户要求不设硬限制;9999 为防误触极端值)。
const int kPickerSoftMaxAssets = 9999;
```
文件头补 import:
```dart
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):
```dart
final account = await _currentAccountId();
if (account == null || !mounted) return;
final paths = await ref.read(multiImagePickerProvider)(context);
```
`capture_page.dart:307` `_pickFromGallery` 同样改为 `(context)`(该处在任何 await 之前取 context,无 mounted 问题)。
- [ ] **Step 5: 更新测试替身签名**
所有 `multiImagePickerProvider.overrideWith((ref) => () async => galleryPaths)` 改为:
```dart
multiImagePickerProvider
.overrideWith((ref) => (context) async => galleryPaths),
```
**集成测试替身**(`integration_test/helpers/app_boot.dart:96`)同步改签名:
```dart
multiImagePickerProvider
.overrideWithValue((context) async => picker.consume()),
```
Run: `cd apps/mobile && grep -rn "multiImagePickerProvider" lib/ test/ integration_test/` 确认无遗漏签名。
- [ ] **Step 6: 全量测试 + 静态检查**
Run: `cd apps/mobile && flutter analyze && flutter test`
Expected: analyze 无新增告警;测试全 PASS
- [ ] **Step 7: Commit**
```bash
git add apps/mobile/pubspec.yaml apps/mobile/pubspec.lock apps/mobile/android/app/src/main/AndroidManifest.xml apps/mobile/lib/features/uploads/enqueue.dart apps/mobile/lib/pages/album_detail_page.dart apps/mobile/lib/pages/capture_page.dart apps/mobile/test/ apps/mobile/integration_test/
git commit -m "feat(app): in-app asset picker (wechat_assets_picker) - unlimited multi-select with range selection"
```
---
### 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<ImageCropPage> 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<void> _pickCoverImage() async {
final path = await ref.read(singleImagePickerProvider)();
if (path == null || !mounted) return;
final cropped = await Navigator.of(context).push<String>(
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<void> 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<void> 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<String>? 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/`(按项目验收记录惯例)。
- [ ] **Step 1: 构建指向测试服务器的包**
Run: `cd apps/mobile && flutter build apk --release --dart-define=API_BASE=<测试服务器 cn-api 入口,见 docs/plans/2026-09-21-测试服务器部署方案.md 与 memory(端口 32005)>`
Expected: 产物 `build/app/outputs/flutter-apk/app-release.apk`
- [ ] **Step 2: 基线 A/B(并发效果量化)**
- 同一批 20 张照片(同网络环境):新包默认(并发 4)vs `--dart-define=MAX_PARALLEL_UPLOADS=2` 各测一次;
- 入队阶段(全部 queued)耗时:`PREPARE_CONCURRENCY` 1 vs 3 各测一次;
- 记录:入队耗时、传完总耗时、并发上传中通知条数;上传延迟异常时用 `queryTask`(engine 已有)核对 WorkManager expedited 是否降级排队;
- 服务端观察:大批量(150+ 张)连续上传后水印处理是否积压(管理后台/照片可见延迟)——并发提升会放大既有水印队列水位,仅记录不处理。
- [ ] **Step 3: 功能核对清单**
- [ ] 首次进选择器:系统存储权限弹窗,允许后网格正常;永久拒绝路径返回空(等同取消,无崩溃)
- [ ] 长按进入多选 + 拖动连选;「全选」可用;zh/en 文案随 App 语言
- [ ] 一次选 **>100 张**(如 150 张)入队成功,队列页计数正确
- [ ] 连拍页(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 + crop + camera verification on test server"
```
---
## 风险与回退
| 风险 | 缓解 |
|---|---|
| 并发 4 在弱网引发更多 waitingNetwork 抖动 | dart-define 回调 2/3 实测定值;失败分类/退避机制不变 |
| 4 条并发上传通知观感 | 低优先级 channel 已设;观察后决定是否做汇总通知(Out of scope) |
| 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;裁剪回退 = revert Task 6 单个 commit(接线点只在两个 `_pickXxxImage`,或仅把 ImageCropPage 内部实现换成方案 B);相机回退 = revert Task 7 单个 commit(预览/变焦互不耦合,也可只回退手势层);查重集成回退 = revert Task 8 代码(drift 迁移**不可回滚删除**——schema 版本保留前滚,revert 时仅回退逻辑代码、迁移空壳留置,force 列默认 0 无害)。