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:
浅念
2026-09-28 11:29:29 +08:00
parent 33f347cdea
commit 90dcc58c33
7 changed files with 74 additions and 18 deletions
@@ -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<String, dynamic> 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,
};
}
@@ -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: 全量测试 + 静态检查**
+7
View File
@@ -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
+7
View File
@@ -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: {
@@ -0,0 +1,4 @@
-- P0-1 层 2:服务端查重索引(同相册+上传者+字节数匹配)
-- 命中即 409 PHOTO_DUPLICATE(force=true 且 owner/admin 可跳过)。
CREATE INDEX idx_photos_dedup
ON photos (albumId, uploaderId, bytes);
+6 -1
View File
@@ -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;
}
@@ -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) {