feat(app): wire server dedup - force passthrough + PHOTO_DUPLICATE classification & l10n

Task 8: close the gap between server-side duplicate detection (90dcc58)
and the app.

- ApiClient.mapDioException: normalize 409 wire body reusing
  VALIDATION_FAILED with message=PHOTO_DUPLICATE to
  ErrorCodes.photoDuplicate (message-based; auto-compat if the contract
  later adds a dedicated code)
- l10n zh/en errorPhotoDuplicate; error_l10n maps the code (queue page
  inherits via fallback)
- UploadEngine._classifyFailure: PHOTO_DUPLICATE joins the no-auto-retry
  bucket (retrying the same init body would 409 again); force to skip
  dedup travels only through the enqueue path
- UploadTasks drift table +force column (schema v2, addColumn migration;
  legacy rows default false); UploadTaskDraft/UploadTask/prepare carry it
- enqueueForUpload +forcePaths; duplicate dialog "全部上传" (capture page
  + album detail page) now returns DuplicateDialogChoice so confirmed
  duplicates enqueue with force=true (server adjudicates owner/admin)
- engine init body carries force only when true (absent otherwise)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-28 17:06:19 +08:00
parent 4f89c25bad
commit 29f3bde9c2
21 changed files with 522 additions and 19 deletions
+15 -7
View File
@@ -351,10 +351,12 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
);
Set<String> skipPaths = <String>{};
Set<String> forcePaths = <String>{};
if (duplicates != null && duplicates.isNotEmpty && mounted) {
final skip = await _showDuplicateDialog(l10n, duplicates, paths);
if (skip == null) return; // 取消。
skipPaths = skip;
final choice = await _showDuplicateDialog(l10n, duplicates, paths);
if (choice == null) return; // 取消。
skipPaths = choice.skipPaths;
forcePaths = choice.forcePaths;
}
final result = await enqueueForUpload(
@@ -363,6 +365,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
albumId: album.id,
sourcePaths: paths,
skipPaths: skipPaths.toList(),
forcePaths: forcePaths.toList(),
);
if (!mounted) return;
messenger.showSnackBar(
@@ -376,8 +379,10 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
);
}
/// 批量重复确认弹窗(P0-1):返回 null=取消 / 空 Set=全部上传 / 非空=跳过项。
Future<Set<String>?> _showDuplicateDialog(
/// 批量重复确认弹窗(Task 8):null=取消;「跳过重复」→ skipPaths=
/// 重复项;「全部上传」→ forcePaths=重复项(用户确认强传,服务端
/// force 查重跳过仅 owner/admin 生效——协作者仍会被 409 拒绝)。
Future<DuplicateDialogChoice?> _showDuplicateDialog(
AppLocalizations l10n,
List<DuplicateMatch> duplicates,
List<String> allPaths,
@@ -417,8 +422,11 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
);
if (choice == 'cancel' || choice == null) return null;
if (choice == 'skip') return dupPaths;
return <String>{};
if (choice == 'skip') {
return (skipPaths: dupPaths, forcePaths: <String>{});
}
// 'all' = 用户确认强传:重复项 force 入队(不跳过任何)。
return (skipPaths: <String>{}, forcePaths: dupPaths);
}
@override
+16 -7
View File
@@ -462,13 +462,16 @@ class _CapturePageState extends ConsumerState<CapturePage>
);
Set<String> skipPaths;
Set<String> forcePaths;
if (duplicates != null && duplicates.isNotEmpty && mounted) {
// 批量确认弹窗(单次,非逐张)。
final skip = await _showDuplicateDialog(l10n, duplicates, paths);
if (skip == null) return; // 取消——不上传。
skipPaths = skip;
final choice = await _showDuplicateDialog(l10n, duplicates, paths);
if (choice == null) return; // 取消——不上传。
skipPaths = choice.skipPaths;
forcePaths = choice.forcePaths;
} else {
skipPaths = <String>{};
forcePaths = <String>{};
}
final result = await enqueueForUpload(
@@ -478,6 +481,7 @@ class _CapturePageState extends ConsumerState<CapturePage>
categoryId: _categoryId,
sourcePaths: paths,
skipPaths: skipPaths.toList(),
forcePaths: forcePaths.toList(),
);
if (!mounted) return;
final uploaded = result.enqueued;
@@ -493,8 +497,10 @@ class _CapturePageState extends ConsumerState<CapturePage>
);
}
/// 批量重复确认弹窗:返回 null=取消 / 空 Set=全部上传 / 非空=跳过项路径集。
Future<Set<String>?> _showDuplicateDialog(
/// 批量重复确认弹窗(Task 8):null=取消;「跳过重复」→ skipPaths=
/// 重复项;「全部上传」→ forcePaths=重复项(用户确认强传,服务端
/// force 查重跳过仅 owner/admin 生效——协作者仍会被 409 拒绝)。
Future<DuplicateDialogChoice?> _showDuplicateDialog(
AppLocalizations l10n,
List<DuplicateMatch> duplicates,
List<String> allPaths,
@@ -535,8 +541,11 @@ class _CapturePageState extends ConsumerState<CapturePage>
);
if (choice == 'cancel' || choice == null) return null;
if (choice == 'skip') return dupPaths;
return <String>{}; // 'all' = 不跳过任何
if (choice == 'skip') {
return (skipPaths: dupPaths, forcePaths: <String>{});
}
// 'all' = 用户确认强传:重复项 force 入队(不跳过任何)。
return (skipPaths: <String>{}, forcePaths: dupPaths);
}
void _switchCategory(String? categoryId) {