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:
@@ -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: 全量测试 + 静态检查**
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user