feat(app): server-side duplicate pre-check at enqueue moment
Task 2 of docs/plans/2026-09-28-上传查重前置到入队前-实施计划.md:
surface server-side duplicate history at the "click upload" moment,
merged with the local check into the EXISTING dialog.
- ApiClient.checkUploadDuplicates: POST /v1/uploads/duplicates/check
(commit 805f77b endpoint); 500/chunk sequential requests; chunk-local
index translated to global; empty list short-circuits (no request);
errors normalized to AppException via _send.
- enqueue.checkDuplicatesUnified: local TaskStore history ∪ server
history; server hits map back to filename via global index into the
stat'd picks; merged, de-duplicated (filename+bytes) list; server
failure (network/permission/any) silently degrades to local-only —
init 409 remains the backstop.
- capture_page + album_detail_page _pickFromGallery now call the
unified entry; dialog/skip/force logic untouched.
Tests: api_client chunking (1200 → 500/500/200) + index translation +
merge + 404 normalization + empty no-request; page-level three states
(hit/no-hit/network failure) incl. fresh-install server-only-hit dialog
on both pages; pre-existing Task 8 dialog tests keep passing via the
degrade path.
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,11 @@
|
||||
/// state=uploaded + filename+bytes 匹配 → 返回 [DuplicateCheck],由
|
||||
/// 调用方决定弹窗确认或跳过。批量场景汇总后单次弹窗(非逐张)。
|
||||
///
|
||||
/// Task 2(查重前置到入队前·统一入口):入队前查重升级为
|
||||
/// [checkDuplicatesUnified]——本机 TaskStore 历史 ∪ 服务器历史
|
||||
/// (duplicates/check 只读预检,bytes 命中);服务器失败静默降级
|
||||
/// 本机-only,不阻塞入队。
|
||||
///
|
||||
/// Task 8(服务端查重 App 侧集成):弹窗「全部上传」= 用户确认强传——
|
||||
/// 重复项以 force=true 入队([enqueueForUpload] 的 forcePaths),任务行
|
||||
/// 落库后由引擎在 initUpload 携带 force 跳过服务端查重(仅 owner/admin
|
||||
@@ -25,6 +30,8 @@ import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
|
||||
|
||||
import '../../core/api_client.dart'
|
||||
show UploadDuplicateHit, apiClientProvider;
|
||||
import '../../core/notification_permission.dart';
|
||||
import 'providers.dart';
|
||||
import 'task_state.dart';
|
||||
@@ -172,6 +179,77 @@ Future<List<DuplicateMatch>?> checkDuplicates(
|
||||
return matches.isEmpty ? null : matches;
|
||||
}
|
||||
|
||||
/// 统一查重(P0-1 层 1+层 2 前置,Task 2):本机 TaskStore 历史 ∪
|
||||
/// 服务器历史(POST /uploads/duplicates/check;服务端谓词仅 bytes——
|
||||
/// filename 刻意不参与,与 initUpload 查重一致)。
|
||||
///
|
||||
/// 服务器预检失败(网络/权限/任意异常)→ 静默降级本机-only(不阻塞
|
||||
/// 入队;服务端 init 409 仍是兜底)。返回 null = 无重复。
|
||||
///
|
||||
/// 服务器命中按全局下标映射回本批文件(stat 本批取 filename,与
|
||||
/// [checkDuplicates] 同模式),弹窗/强传标记的 filename 派生由此生效。
|
||||
Future<List<DuplicateMatch>?> checkDuplicatesUnified(
|
||||
WidgetRef ref, {
|
||||
required String accountId,
|
||||
required String albumId,
|
||||
required List<String> sourcePaths,
|
||||
}) async {
|
||||
if (sourcePaths.isEmpty) return null;
|
||||
|
||||
// 层 1:本机 TaskStore 历史(filename+bytes 匹配)。
|
||||
final local =
|
||||
await checkDuplicates(
|
||||
ref,
|
||||
accountId: accountId,
|
||||
albumId: albumId,
|
||||
sourcePaths: sourcePaths,
|
||||
) ??
|
||||
const <DuplicateMatch>[];
|
||||
|
||||
// 层 2:服务器历史(bytes 命中,逐请求条目一条)。
|
||||
final picks = <({String filename, int bytes})>[];
|
||||
for (final path in sourcePaths) {
|
||||
try {
|
||||
final file = File(path);
|
||||
picks.add((filename: p.basename(path), bytes: await file.length()));
|
||||
} on Object {
|
||||
// 读不到的文件不参与查重(入队时准备器会报错)。
|
||||
}
|
||||
}
|
||||
final serverHits = <UploadDuplicateHit>[];
|
||||
if (picks.isNotEmpty) {
|
||||
try {
|
||||
serverHits.addAll(
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.checkUploadDuplicates(albumId, picks),
|
||||
);
|
||||
} on Object {
|
||||
// 静默降级本机-only:预检是体验优化而非闸门,失败不阻塞入队
|
||||
//(服务端 init 409 仍是兜底)。
|
||||
}
|
||||
}
|
||||
|
||||
// 合并去重(filename+bytes;本机命中在前——同一重复保留先见者)。
|
||||
final seen = <String>{};
|
||||
final merged = <DuplicateMatch>[];
|
||||
for (final match in [
|
||||
...local,
|
||||
for (final hit in serverHits)
|
||||
if (hit.index >= 0 && hit.index < picks.length)
|
||||
DuplicateMatch(
|
||||
filename: picks[hit.index].filename,
|
||||
bytes: hit.bytes,
|
||||
uploadedAt: hit.uploadedAt,
|
||||
),
|
||||
]) {
|
||||
if (seen.add('${match.filename}\u0000${match.bytes}')) {
|
||||
merged.add(match);
|
||||
}
|
||||
}
|
||||
return merged.isEmpty ? null : merged;
|
||||
}
|
||||
|
||||
/// 批量入队:通知权限单次请求 + 逐张准备入队。
|
||||
///
|
||||
/// [skipPaths]:用户选择跳过的重复项;[forcePaths]:查重弹窗「全部上传」
|
||||
|
||||
Reference in New Issue
Block a user