Files
photo-live/docs/plans/2026-09-28-App上传提速与无限制选图-实施计划.md
T
浅念 90dcc58c33 feat(uploads): P0-1 batch 4 — server-side duplicate photo detection (409 + force + index)
契约 UploadInitRequest 加 force(bool,仅 owner/admin 跳过查重)。
服务端 init 事务前置查重:同 albumId + uploaderId + bytes 的非 deleted
照片已存在 → 409 PHOTO_DUPLICATE。photos 表加 idx_photos_dedup 索引
(迁移 20260928120000)。
App UploadInitRequest 加 force 字段 + toJson 序列化。
本地查重(层 1)+ 服务端查重(层 2)双层防线到位。

API typecheck ✅ · app analyze ✅ · 397/397 ✅ · contract lint ✅

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

483 lines
19 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 张上限、支持长按拖动连选与全选。
**Architecture:** 三处提速均为参数/局部改动,不动上传状态机与传输协议;选图替换仅改 `multiImagePickerProvider` 注入点实现 + Android 权限声明,队列/准备器/引擎零改动(Provider 注入架构已就位)。
**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 满足)。
**已确认决策(2026-09-28):**
- ① 接受应用内选择器带来的存储权限弹窗(READ_MEDIA_IMAGES);
- ② 不做客户端压缩(原图直传保持不变)。
**明确不做(Out of scope):**
- 前台快路径传输(前台直连 dio、后台 WorkManager 接管)——待本次并发提升数据出来后再评估;
- 批量 initUpload 契约扩展;
- 并发 worker 通知合并(4 并发 = 4 条低优先级通知,可接受,观察后再定)。
---
## 背景(问题 → 根因速查)
| 问题 | 根因 | 证据 |
|---|---|---|
| 上传慢 | 并行传输上限=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 不支持区间拖选/全选,平台限制无解 | 同上 |
测试基线(Task 6 验证时先采旧包数据):同批 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: 全部 PASS(含既有串行语义测试——显式传 `maxConcurrentPrepares: 1` 的用例如依赖顺序应仍通过;若有用例依赖默认串行顺序,将其构造处补 `maxConcurrentPrepares: 1`)
- [ ] **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 内部处理,拒绝时返回空(等同取消)。
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 paths = <String>[];
for (final asset in assets) {
final file = await asset.originFile;
if (file != null) paths.add(file.path);
}
return paths;
},
);
/// 选图软上限(用户要求不设硬限制;9999 为防误触极端值)。
const int kPickerSoftMaxAssets = 9999;
```
文件头补 import:
```dart
import 'package:flutter/widgets.dart' show BuildContext, Localizations;
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
```
- [ ] **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/
git commit -m "feat(app): in-app asset picker (wechat_assets_picker) - unlimited multi-select with range selection"
```
---
### Task 6: 测试服务器验证(测试服务器优先工作流)
**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)耗时、传完总耗时、并发上传中通知条数。
- [ ] **Step 3: 功能核对清单**
- [ ] 首次进选择器:系统存储权限弹窗,允许后网格正常;永久拒绝路径返回空(等同取消,无崩溃)
- [ ] 长按进入多选 + 拖动连选;「全选」可用;zh/en 文案随 App 语言
- [ ] 一次选 **>100 张**(如 150 张)入队成功,队列页计数正确
- [ ] 连拍页(capture)相册入口同样生效;去重弹窗(filename+bytes)不受影响
- [ ] 上传中切后台:WorkManager 续传、通知进度正常
- [ ] 中途断网→恢复:waitingNetwork 退避与立即重试语义不变
- [ ] 完成后源文件按保留期清理(队列页无异常残留)
- [ ] **Step 4: 记录验收结果 + Commit**
```bash
git add docs/plans/progress/
git commit -m "docs(app): upload speedup + asset picker 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 逐行写;队列页既有分页渲染 |
回退开关:并发(MAX_PARALLEL_UPLOADS)、准备并发(PREPARE_CONCURRENCY)均可用 dart-define 无代码回退;选图器回退 = revert Task 5 单个 commit。