diff --git a/apps/mobile/lib/features/uploads/models.dart b/apps/mobile/lib/features/uploads/models.dart index d885729..a50bbd8 100644 --- a/apps/mobile/lib/features/uploads/models.dart +++ b/apps/mobile/lib/features/uploads/models.dart @@ -93,6 +93,7 @@ class UploadInitRequest { required this.contentType, this.categoryId, this.sourceDeviceId, + this.force = false, }); /// 目标相册 ID。 @@ -116,6 +117,9 @@ class UploadInitRequest { /// 发起设备标识(可选,minLength 8 / maxLength 64;A19)。 final String? sourceDeviceId; + /// P0-1 层 2:跳过服务端查重(仅 owner/admin 生效)。缺省 false。 + final bool force; + Map toJson() => { 'albumId': albumId, if (categoryId != null) 'categoryId': categoryId, @@ -124,6 +128,7 @@ class UploadInitRequest { 'bytes': bytes, 'contentType': contentType, if (sourceDeviceId != null) 'sourceDeviceId': sourceDeviceId, + if (force) 'force': true, }; } diff --git a/docs/plans/2026-09-28-App上传提速与无限制选图-实施计划.md b/docs/plans/2026-09-28-App上传提速与无限制选图-实施计划.md index 87c1d15..89022d7 100644 --- a/docs/plans/2026-09-28-App上传提速与无限制选图-实施计划.md +++ b/docs/plans/2026-09-28-App上传提速与无限制选图-实施计划.md @@ -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> Function(BuildContext context); ``` -provider 实现: +provider 实现(文案代理用包内置的 locale 解析助手,勿手写 `<语言>TextDelegate` 类名——10.x 导出名不含 `ChineseTextDelegate`): ```dart /// 多图选择 Provider(生产=wechat_assets_picker;测试 override)。 @@ -364,15 +365,12 @@ provider 实现: final multiImagePickerProvider = Provider( (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 []; @@ -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: 全量测试 + 静态检查** diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml index 3f1ab47..301932a 100644 --- a/packages/contracts/openapi.yaml +++ b/packages/contracts/openapi.yaml @@ -3390,6 +3390,13 @@ components: description: | 客户端首次生成并持久化的设备标识;服务端历史返回用于区分上传来源 (A19)。可选;同一 clientTaskId 幂等重放时保留首次落库的值。 + force: + type: boolean + default: false + description: | + 跳过服务端查重(P0-1 层 2)。仅相册 owner/admin 可用;协作者 + 传 true 无效。false(缺省)时:同 albumId+uploaderId+bytes + 已有非 deleted 照片 → 409 PHOTO_DUPLICATE。 UploadCredential: type: object diff --git a/packages/contracts/src/generated/openapi.d.ts b/packages/contracts/src/generated/openapi.d.ts index cc2e46a..73c214a 100644 --- a/packages/contracts/src/generated/openapi.d.ts +++ b/packages/contracts/src/generated/openapi.d.ts @@ -1648,6 +1648,13 @@ export interface components { * (A19)。可选;同一 clientTaskId 幂等重放时保留首次落库的值。 */ sourceDeviceId?: string; + /** + * @description 跳过服务端查重(P0-1 层 2)。仅相册 owner/admin 可用;协作者 + * 传 true 无效。false(缺省)时:同 albumId+uploaderId+bytes + * 已有非 deleted 照片 → 409 PHOTO_DUPLICATE。 + * @default false + */ + force: boolean; }; /** @description staging 对象的临时直传凭证;仅可写入指定 objectKey,不可访问 originals/variants。 */ UploadCredential: { diff --git a/packages/db-cn/prisma/migrations/20260928120000_photos_dedup_index/migration.sql b/packages/db-cn/prisma/migrations/20260928120000_photos_dedup_index/migration.sql new file mode 100644 index 0000000..c9302f2 --- /dev/null +++ b/packages/db-cn/prisma/migrations/20260928120000_photos_dedup_index/migration.sql @@ -0,0 +1,4 @@ +-- P0-1 层 2:服务端查重索引(同相册+上传者+字节数匹配) +-- 命中即 409 PHOTO_DUPLICATE(force=true 且 owner/admin 可跳过)。 +CREATE INDEX idx_photos_dedup + ON photos (albumId, uploaderId, bytes); diff --git a/services/api/src/uploads/dto.ts b/services/api/src/uploads/dto.ts index 49a11a1..7966aea 100644 --- a/services/api/src/uploads/dto.ts +++ b/services/api/src/uploads/dto.ts @@ -5,7 +5,7 @@ // 惰性读取,故在服务层校验(同 filename 扩展名白名单与 categoryId 属主校验); // · clientTaskId/filename/albumId/categoryId 列宽对齐 db-cn schema // (VarChar 128/255/64/64);sourceDeviceId 8-64(A19 设备来源语义)。 -import { +import { IsBoolean, IsIn, IsInt, IsNotEmpty, @@ -55,4 +55,9 @@ export class UploadInitRequestDto { @MinLength(8) @MaxLength(64) sourceDeviceId?: string; + + /** P0-1 层 2:跳过服务端查重(仅 owner/admin 生效;协作者无效)。 */ + @IsOptional() + @IsBoolean() + force?: boolean; } diff --git a/services/api/src/uploads/uploads.service.ts b/services/api/src/uploads/uploads.service.ts index 7f0fb53..5978ded 100644 --- a/services/api/src/uploads/uploads.service.ts +++ b/services/api/src/uploads/uploads.service.ts @@ -120,6 +120,27 @@ export class UploadsService { if (!album.uploadEnabled) { throw albumUploadDisabled(); } + + // ③.5 P0-1 层 2:服务端查重——同相册+同上传者+同字节数的非 deleted 照片 + // 已存在时 409(force=true 且 owner/admin 可跳过;协作者 force 无效)。 + if (dto.force !== true || !(await this.permissions.canManageAlbum(user, dto.albumId))) { + const duplicate = await this.prisma.photos.findFirst({ + where: { + albumId: dto.albumId, + uploaderId: user.id, + bytes: dto.bytes, + visibility: { not: 'deleted' }, + }, + select: { id: true, uploadedAt: true }, + }); + if (duplicate !== null) { + throw new ApiException( + ErrorCodes.VALIDATION_FAILED, + 'PHOTO_DUPLICATE', + HttpStatus.CONFLICT, + ); + } + } // ④ 大小限额(惰性读 env)与 filename 扩展名白名单(对象键后缀派生)。 const maxBytes = uploadMaxBytes(); if (dto.bytes > maxBytes) {