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>
This commit is contained in:
@@ -23,7 +23,7 @@
|
||||
|
||||
| 问题 | 根因 | 证据 |
|
||||
|---|---|---|
|
||||
| 上传慢 | 并行传输上限=2;准备阶段两层串行;进度回调每 64KB 一次 | `upload_engine.dart:55`、`enqueue.dart:54`、`preparer.dart:117`(`_serialize`)、`UploadPutExecutor.kt:74` |
|
||||
| 上传慢 | 并行传输上限=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 不支持区间拖选/全选,平台限制无解 | 同上 |
|
||||
|
||||
@@ -188,27 +188,28 @@ git commit -m "feat(app): bounded-parallel prepare (default 3) replaces serial q
|
||||
### Task 3: enqueueForUpload 并行收集
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/mobile/lib/features/uploads/enqueue.dart:52-63`
|
||||
- Modify: `apps/mobile/lib/features/uploads/enqueue.dart:152-167`
|
||||
|
||||
说明:Task 2 后 preparer 内部已限流,外层 for-await 循环改为 `Future.wait` 即可;返回值语义(成功入队张数)不变。页级测试(album_detail/capture_page 断言入队数)已覆盖该语义。
|
||||
说明:Task 2 后 preparer 内部已限流,外层 for-await 循环改为 `Future.wait` 即可。**注意:`enqueueForUpload` 已是 P0-1 结构(返回 `EnqueueResult`,`skipPaths` 过滤在函数内)**——并行化只改循环段,保留 `effective` 过滤与返回结构。
|
||||
|
||||
- [ ] **Step 1: 替换循环**
|
||||
- [ ] **Step 1: 替换循环段(152-161 行)**
|
||||
|
||||
```dart
|
||||
final preparer = ref.read(uploadPreparerProvider);
|
||||
final results = await Future.wait(
|
||||
sourcePaths.map((path) => preparer.prepare(
|
||||
effective.map((path) => preparer.prepare(
|
||||
accountId: accountId,
|
||||
albumId: albumId,
|
||||
categoryId: categoryId,
|
||||
sourcePath: path,
|
||||
)),
|
||||
);
|
||||
return results
|
||||
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`
|
||||
@@ -306,9 +307,9 @@ git commit -m "perf(app): throttle upload progress callbacks to 256KB steps"
|
||||
- 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:331`
|
||||
- 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`(及 grep 命中的其余 override)
|
||||
- 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: 加依赖**
|
||||
|
||||
@@ -351,7 +352,7 @@ typedef 增加BuildContext(picker 需页面上下文):
|
||||
typedef MultiImagePicker = Future<List<String>> Function(BuildContext context);
|
||||
```
|
||||
|
||||
provider 实现:
|
||||
provider 实现(文案代理用包内置的 locale 解析助手,勿手写 `<语言>TextDelegate` 类名——10.x 导出名不含 `ChineseTextDelegate`):
|
||||
|
||||
```dart
|
||||
/// 多图选择 Provider(生产=wechat_assets_picker;测试 override)。
|
||||
@@ -364,15 +365,12 @@ provider 实现:
|
||||
final multiImagePickerProvider = Provider<MultiImagePicker>(
|
||||
(ref) => (context) async {
|
||||
final locale = Localizations.maybeLocaleOf(context);
|
||||
final textDelegate = locale?.languageCode == 'zh'
|
||||
? const ChineseTextDelegate()
|
||||
: const EnglishTextDelegate();
|
||||
final assets = await AssetPicker.pickAssets(
|
||||
context,
|
||||
pickerConfig: AssetPickerConfig(
|
||||
requestType: RequestType.image,
|
||||
maxAssets: kPickerSoftMaxAssets,
|
||||
textDelegate: textDelegate,
|
||||
textDelegate: assetPickerTextDelegateFromLocale(locale),
|
||||
),
|
||||
);
|
||||
if (assets == null) return const <String>[];
|
||||
@@ -398,11 +396,13 @@ import 'package:wechat_assets_picker/wechat_assets_picker.dart';
|
||||
|
||||
- [ ] **Step 4: 更新两处调用点**
|
||||
|
||||
`album_detail_page.dart` `_pickFromGallery`:
|
||||
`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` `_pickFromGallery` 同样改为 `(context)`。
|
||||
`capture_page.dart:307` `_pickFromGallery` 同样改为 `(context)`(该处在任何 await 之前取 context,无 mounted 问题)。
|
||||
|
||||
- [ ] **Step 5: 更新测试替身签名**
|
||||
|
||||
@@ -413,7 +413,14 @@ import 'package:wechat_assets_picker/wechat_assets_picker.dart';
|
||||
.overrideWith((ref) => (context) async => galleryPaths),
|
||||
```
|
||||
|
||||
Run: `cd apps/mobile && grep -rn "multiImagePickerProvider" test/ lib/` 确认无遗漏签名。
|
||||
**集成测试替身**(`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: 全量测试 + 静态检查**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user