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,
|
required this.contentType,
|
||||||
this.categoryId,
|
this.categoryId,
|
||||||
this.sourceDeviceId,
|
this.sourceDeviceId,
|
||||||
|
this.force = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 目标相册 ID。
|
/// 目标相册 ID。
|
||||||
@@ -116,6 +117,9 @@ class UploadInitRequest {
|
|||||||
/// 发起设备标识(可选,minLength 8 / maxLength 64;A19)。
|
/// 发起设备标识(可选,minLength 8 / maxLength 64;A19)。
|
||||||
final String? sourceDeviceId;
|
final String? sourceDeviceId;
|
||||||
|
|
||||||
|
/// P0-1 层 2:跳过服务端查重(仅 owner/admin 生效)。缺省 false。
|
||||||
|
final bool force;
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'albumId': albumId,
|
'albumId': albumId,
|
||||||
if (categoryId != null) 'categoryId': categoryId,
|
if (categoryId != null) 'categoryId': categoryId,
|
||||||
@@ -124,6 +128,7 @@ class UploadInitRequest {
|
|||||||
'bytes': bytes,
|
'bytes': bytes,
|
||||||
'contentType': contentType,
|
'contentType': contentType,
|
||||||
if (sourceDeviceId != null) 'sourceDeviceId': sourceDeviceId,
|
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()` |
|
| 一次最多 100 张 | Android 系统 Photo Picker 平台上限 `MediaStore.getPickImagesMaxLimit()`(多数设备=100),app/服务端无此限制 | image_picker_android `ImagePickerUtils.getMaxItems()` |
|
||||||
| 只能逐张点选 | 系统 Photo Picker UI 不支持区间拖选/全选,平台限制无解 | 同上 |
|
| 只能逐张点选 | 系统 Photo Picker UI 不支持区间拖选/全选,平台限制无解 | 同上 |
|
||||||
|
|
||||||
@@ -188,27 +188,28 @@ git commit -m "feat(app): bounded-parallel prepare (default 3) replaces serial q
|
|||||||
### Task 3: enqueueForUpload 并行收集
|
### Task 3: enqueueForUpload 并行收集
|
||||||
|
|
||||||
**Files:**
|
**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
|
```dart
|
||||||
final preparer = ref.read(uploadPreparerProvider);
|
|
||||||
final results = await Future.wait(
|
final results = await Future.wait(
|
||||||
sourcePaths.map((path) => preparer.prepare(
|
effective.map((path) => preparer.prepare(
|
||||||
accountId: accountId,
|
accountId: accountId,
|
||||||
albumId: albumId,
|
albumId: albumId,
|
||||||
categoryId: categoryId,
|
categoryId: categoryId,
|
||||||
sourcePath: path,
|
sourcePath: path,
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
return results
|
final enqueued = results
|
||||||
.where((task) => task.state == UploadTaskState.queued)
|
.where((task) => task.state == UploadTaskState.queued)
|
||||||
.length;
|
.length;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
(返回 `EnqueueResult(enqueued: enqueued, duplicates: [], skipped: sourcePaths.length - effective.length)` 原样保留。)
|
||||||
|
|
||||||
- [ ] **Step 2: 跑页级测试**
|
- [ ] **Step 2: 跑页级测试**
|
||||||
|
|
||||||
Run: `cd apps/mobile && flutter test test/pages/album_detail_page_test.dart test/pages/capture_page_test.dart`
|
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/pubspec.yaml`
|
||||||
- Modify: `apps/mobile/android/app/src/main/AndroidManifest.xml`
|
- Modify: `apps/mobile/android/app/src/main/AndroidManifest.xml`
|
||||||
- Modify: `apps/mobile/lib/features/uploads/enqueue.dart`
|
- 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`
|
- 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: 加依赖**
|
- [ ] **Step 1: 加依赖**
|
||||||
|
|
||||||
@@ -351,7 +352,7 @@ typedef 增加BuildContext(picker 需页面上下文):
|
|||||||
typedef MultiImagePicker = Future<List<String>> Function(BuildContext context);
|
typedef MultiImagePicker = Future<List<String>> Function(BuildContext context);
|
||||||
```
|
```
|
||||||
|
|
||||||
provider 实现:
|
provider 实现(文案代理用包内置的 locale 解析助手,勿手写 `<语言>TextDelegate` 类名——10.x 导出名不含 `ChineseTextDelegate`):
|
||||||
|
|
||||||
```dart
|
```dart
|
||||||
/// 多图选择 Provider(生产=wechat_assets_picker;测试 override)。
|
/// 多图选择 Provider(生产=wechat_assets_picker;测试 override)。
|
||||||
@@ -364,15 +365,12 @@ provider 实现:
|
|||||||
final multiImagePickerProvider = Provider<MultiImagePicker>(
|
final multiImagePickerProvider = Provider<MultiImagePicker>(
|
||||||
(ref) => (context) async {
|
(ref) => (context) async {
|
||||||
final locale = Localizations.maybeLocaleOf(context);
|
final locale = Localizations.maybeLocaleOf(context);
|
||||||
final textDelegate = locale?.languageCode == 'zh'
|
|
||||||
? const ChineseTextDelegate()
|
|
||||||
: const EnglishTextDelegate();
|
|
||||||
final assets = await AssetPicker.pickAssets(
|
final assets = await AssetPicker.pickAssets(
|
||||||
context,
|
context,
|
||||||
pickerConfig: AssetPickerConfig(
|
pickerConfig: AssetPickerConfig(
|
||||||
requestType: RequestType.image,
|
requestType: RequestType.image,
|
||||||
maxAssets: kPickerSoftMaxAssets,
|
maxAssets: kPickerSoftMaxAssets,
|
||||||
textDelegate: textDelegate,
|
textDelegate: assetPickerTextDelegateFromLocale(locale),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (assets == null) return const <String>[];
|
if (assets == null) return const <String>[];
|
||||||
@@ -398,11 +396,13 @@ import 'package:wechat_assets_picker/wechat_assets_picker.dart';
|
|||||||
|
|
||||||
- [ ] **Step 4: 更新两处调用点**
|
- [ ] **Step 4: 更新两处调用点**
|
||||||
|
|
||||||
`album_detail_page.dart` `_pickFromGallery`:
|
`album_detail_page.dart:341` `_pickFromGallery`(picker 需要 context,且此处在 `await _currentAccountId()` 之后——补 mounted 守卫防 `use_build_context_synchronously` lint):
|
||||||
```dart
|
```dart
|
||||||
|
final account = await _currentAccountId();
|
||||||
|
if (account == null || !mounted) return;
|
||||||
final paths = await ref.read(multiImagePickerProvider)(context);
|
final paths = await ref.read(multiImagePickerProvider)(context);
|
||||||
```
|
```
|
||||||
`capture_page.dart` `_pickFromGallery` 同样改为 `(context)`。
|
`capture_page.dart:307` `_pickFromGallery` 同样改为 `(context)`(该处在任何 await 之前取 context,无 mounted 问题)。
|
||||||
|
|
||||||
- [ ] **Step 5: 更新测试替身签名**
|
- [ ] **Step 5: 更新测试替身签名**
|
||||||
|
|
||||||
@@ -413,7 +413,14 @@ import 'package:wechat_assets_picker/wechat_assets_picker.dart';
|
|||||||
.overrideWith((ref) => (context) async => galleryPaths),
|
.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: 全量测试 + 静态检查**
|
- [ ] **Step 6: 全量测试 + 静态检查**
|
||||||
|
|
||||||
|
|||||||
@@ -3390,6 +3390,13 @@ components:
|
|||||||
description: |
|
description: |
|
||||||
客户端首次生成并持久化的设备标识;服务端历史返回用于区分上传来源
|
客户端首次生成并持久化的设备标识;服务端历史返回用于区分上传来源
|
||||||
(A19)。可选;同一 clientTaskId 幂等重放时保留首次落库的值。
|
(A19)。可选;同一 clientTaskId 幂等重放时保留首次落库的值。
|
||||||
|
force:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
description: |
|
||||||
|
跳过服务端查重(P0-1 层 2)。仅相册 owner/admin 可用;协作者
|
||||||
|
传 true 无效。false(缺省)时:同 albumId+uploaderId+bytes
|
||||||
|
已有非 deleted 照片 → 409 PHOTO_DUPLICATE。
|
||||||
|
|
||||||
UploadCredential:
|
UploadCredential:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -1648,6 +1648,13 @@ export interface components {
|
|||||||
* (A19)。可选;同一 clientTaskId 幂等重放时保留首次落库的值。
|
* (A19)。可选;同一 clientTaskId 幂等重放时保留首次落库的值。
|
||||||
*/
|
*/
|
||||||
sourceDeviceId?: string;
|
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。 */
|
/** @description staging 对象的临时直传凭证;仅可写入指定 objectKey,不可访问 originals/variants。 */
|
||||||
UploadCredential: {
|
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 属主校验);
|
// 惰性读取,故在服务层校验(同 filename 扩展名白名单与 categoryId 属主校验);
|
||||||
// · clientTaskId/filename/albumId/categoryId 列宽对齐 db-cn schema
|
// · clientTaskId/filename/albumId/categoryId 列宽对齐 db-cn schema
|
||||||
// (VarChar 128/255/64/64);sourceDeviceId 8-64(A19 设备来源语义)。
|
// (VarChar 128/255/64/64);sourceDeviceId 8-64(A19 设备来源语义)。
|
||||||
import {
|
import { IsBoolean,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
@@ -55,4 +55,9 @@ export class UploadInitRequestDto {
|
|||||||
@MinLength(8)
|
@MinLength(8)
|
||||||
@MaxLength(64)
|
@MaxLength(64)
|
||||||
sourceDeviceId?: string;
|
sourceDeviceId?: string;
|
||||||
|
|
||||||
|
/** P0-1 层 2:跳过服务端查重(仅 owner/admin 生效;协作者无效)。 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
force?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,27 @@ export class UploadsService {
|
|||||||
if (!album.uploadEnabled) {
|
if (!album.uploadEnabled) {
|
||||||
throw albumUploadDisabled();
|
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 扩展名白名单(对象键后缀派生)。
|
// ④ 大小限额(惰性读 env)与 filename 扩展名白名单(对象键后缀派生)。
|
||||||
const maxBytes = uploadMaxBytes();
|
const maxBytes = uploadMaxBytes();
|
||||||
if (dto.bytes > maxBytes) {
|
if (dto.bytes > maxBytes) {
|
||||||
|
|||||||
Reference in New Issue
Block a user