fix(app): path-precise duplicate matching; shared file stat; union test
Review follow-ups on 0fa23e0 (Task 2), applied as one commit:
- Path-precise dupPaths (#2, user-visible): DuplicateMatch gains
sourcePath (local match fills it from the matched pick; unified
server branch from picks[hit.index].path). Both pages' duplicate
dialogs derive dupPaths from duplicates' sourcePath instead of
basename matching, so same-basename siblings (different dir/bytes)
are no longer mis-flagged — "跳过重复" no longer drops the
non-duplicate sibling. Stale basename-derivation comment removed.
- Single shared stat (#3): files are stat'd once in
checkDuplicatesUnified (_statPicks) and the picks feed both the
local half (extracted _matchLocalHistory; public checkDuplicates
folded — it had no remaining callers) and the server request.
Halves stat IO for large batches (was ~2x).
- Union test (#1): new test/features/uploads/enqueue_test.dart pins
that a file hit by BOTH local and server history yields ONE
DuplicateMatch with the local uploadedAt (server's echoed time does
not override). Merge dedup is now keyed by sourcePath
(putIfAbsent, local first).
Tests: +1 enqueue union test (real-async chain inside one runAsync
window; sync file/dir setup — fake-async body must not await real
IO), +1 album_detail same-basename sibling test (skip-duplicates
enqueues exactly the non-hit sibling, distinguished by bytes since
sourceFilePath becomes the preparer's staging copy).
Gates: flutter analyze clean; full flutter test 429/429 green.
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -8,8 +8,10 @@
|
||||
/// 内部有界并行复制,默认并发 3,防连拍 IO 风暴)。
|
||||
///
|
||||
/// P0-1 层 1(UI06 分析 v2):入队前**本地查重**——同账号 + 同相册 +
|
||||
/// state=uploaded + filename+bytes 匹配 → 返回 [DuplicateCheck],由
|
||||
/// state=uploaded + filename+bytes 匹配 → 返回 [DuplicateMatch],由
|
||||
/// 调用方决定弹窗确认或跳过。批量场景汇总后单次弹窗(非逐张)。
|
||||
/// 本地匹配经统一入口 [checkDuplicatesUnified] 驱动(原独立入口
|
||||
/// checkDuplicates 已折叠为私有半场,仓库内无其他调用方)。
|
||||
///
|
||||
/// Task 2(查重前置到入队前·统一入口):入队前查重升级为
|
||||
/// [checkDuplicatesUnified]——本机 TaskStore 历史 ∪ 服务器历史
|
||||
@@ -99,11 +101,15 @@ typedef DuplicateDialogChoice
|
||||
/// 查重结果(单张):匹配到的已上传任务摘要。
|
||||
class DuplicateMatch {
|
||||
const DuplicateMatch({
|
||||
required this.sourcePath,
|
||||
required this.filename,
|
||||
required this.bytes,
|
||||
required this.uploadedAt,
|
||||
});
|
||||
|
||||
/// 本批照片的完整源路径:弹窗/入队按路径精确派生重复项——同 basename
|
||||
/// 不同目录/扩展名的兄弟文件不误伤(Task 2 评审 #2)。
|
||||
final String sourcePath;
|
||||
final String filename;
|
||||
final int bytes;
|
||||
final DateTime uploadedAt;
|
||||
@@ -127,30 +133,38 @@ class EnqueueResult {
|
||||
final int skipped;
|
||||
}
|
||||
|
||||
/// 入队前查重:查 TaskStore 同账号 + 同相册 + state=uploaded,
|
||||
/// filename + bytes 匹配的记录。
|
||||
///
|
||||
/// 返回 null = 无重复(可直接入队);非 null = 有疑似重复(调用方弹窗)。
|
||||
Future<List<DuplicateMatch>?> checkDuplicates(
|
||||
WidgetRef ref, {
|
||||
required String accountId,
|
||||
required String albumId,
|
||||
required List<String> sourcePaths,
|
||||
}) async {
|
||||
if (sourcePaths.isEmpty) return null;
|
||||
/// 本批照片的 stat 结果(路径 + 派生 filename + 字节数;层 1/层 2 查重
|
||||
/// 共用——文件只 stat 一次,Task 2 评审 #3)。
|
||||
typedef _PickStat = ({String path, String filename, int bytes});
|
||||
|
||||
// 收集本次选择的 filename + bytes。
|
||||
final picks = <({String filename, int bytes})>[];
|
||||
/// stat 本批文件([checkDuplicatesUnified] 专用;读不到的文件跳过,
|
||||
/// 入队时准备器会报错)。
|
||||
Future<List<_PickStat>> _statPicks(List<String> sourcePaths) async {
|
||||
final picks = <_PickStat>[];
|
||||
for (final path in sourcePaths) {
|
||||
try {
|
||||
final file = File(path);
|
||||
picks.add((filename: p.basename(path), bytes: await file.length()));
|
||||
picks.add((
|
||||
path: path,
|
||||
filename: p.basename(path),
|
||||
bytes: await file.length(),
|
||||
));
|
||||
} on Object {
|
||||
// 读不到的文件不参与查重(入队时准备器会报错)。
|
||||
}
|
||||
}
|
||||
if (picks.isEmpty) return null;
|
||||
return picks;
|
||||
}
|
||||
|
||||
/// 层 1 本机匹配:TaskStore 同账号 + 同相册 + state=completed 的任务与
|
||||
/// 本批 stat 结果做 filename + bytes 匹配(原公共 checkDuplicates 的
|
||||
/// 内核,折叠为统一入口的私有半场——仓库内已无其他调用方)。
|
||||
Future<List<DuplicateMatch>> _matchLocalHistory(
|
||||
WidgetRef ref, {
|
||||
required String accountId,
|
||||
required String albumId,
|
||||
required List<_PickStat> picks,
|
||||
}) async {
|
||||
// 查同相册已完成的任务。
|
||||
final store = ref.read(taskStoreProvider);
|
||||
final completed = await store.listByAccount(
|
||||
@@ -166,6 +180,7 @@ Future<List<DuplicateMatch>?> checkDuplicates(
|
||||
if (task.filename == pick.filename && task.bytes == pick.bytes) {
|
||||
matches.add(
|
||||
DuplicateMatch(
|
||||
sourcePath: pick.path,
|
||||
filename: pick.filename,
|
||||
bytes: pick.bytes,
|
||||
uploadedAt: task.createdAt,
|
||||
@@ -175,8 +190,7 @@ Future<List<DuplicateMatch>?> checkDuplicates(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches.isEmpty ? null : matches;
|
||||
return matches;
|
||||
}
|
||||
|
||||
/// 统一查重(P0-1 层 1+层 2 前置,Task 2):本机 TaskStore 历史 ∪
|
||||
@@ -186,8 +200,10 @@ Future<List<DuplicateMatch>?> checkDuplicates(
|
||||
/// 服务器预检失败(网络/权限/任意异常)→ 静默降级本机-only(不阻塞
|
||||
/// 入队;服务端 init 409 仍是兜底)。返回 null = 无重复。
|
||||
///
|
||||
/// 服务器命中按全局下标映射回本批文件(stat 本批取 filename,与
|
||||
/// [checkDuplicates] 同模式),弹窗/强传标记的 filename 派生由此生效。
|
||||
/// 本批文件只 stat 一次([_statPicks],两层共用);服务器命中按全局
|
||||
/// 下标映射回 stat 结果(filename/sourcePath 由此生效);合并按
|
||||
/// sourcePath 去重——同一文件双命中只呈现一条,本机命中在前(本地
|
||||
/// uploadedAt 优先,Task 2 评审 #1)。
|
||||
Future<List<DuplicateMatch>?> checkDuplicatesUnified(
|
||||
WidgetRef ref, {
|
||||
required String accountId,
|
||||
@@ -196,58 +212,54 @@ Future<List<DuplicateMatch>?> checkDuplicatesUnified(
|
||||
}) async {
|
||||
if (sourcePaths.isEmpty) return null;
|
||||
|
||||
// stat 一次,层 1/层 2 共用。
|
||||
final picks = await _statPicks(sourcePaths);
|
||||
if (picks.isEmpty) return null;
|
||||
|
||||
// 层 1:本机 TaskStore 历史(filename+bytes 匹配)。
|
||||
final local =
|
||||
await checkDuplicates(
|
||||
ref,
|
||||
accountId: accountId,
|
||||
albumId: albumId,
|
||||
sourcePaths: sourcePaths,
|
||||
) ??
|
||||
const <DuplicateMatch>[];
|
||||
final local = await _matchLocalHistory(
|
||||
ref,
|
||||
accountId: accountId,
|
||||
albumId: albumId,
|
||||
picks: picks,
|
||||
);
|
||||
|
||||
// 层 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 {
|
||||
// 读不到的文件不参与查重(入队时准备器会报错)。
|
||||
}
|
||||
}
|
||||
// 层 2:服务器历史(bytes 命中,逐请求条目一条;失败静默降级)。
|
||||
final serverHits = <UploadDuplicateHit>[];
|
||||
if (picks.isNotEmpty) {
|
||||
try {
|
||||
serverHits.addAll(
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.checkUploadDuplicates(albumId, picks),
|
||||
);
|
||||
} on Object {
|
||||
// 静默降级本机-only:预检是体验优化而非闸门,失败不阻塞入队
|
||||
//(服务端 init 409 仍是兜底)。
|
||||
}
|
||||
try {
|
||||
serverHits.addAll(
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.checkUploadDuplicates(
|
||||
albumId,
|
||||
[
|
||||
for (final pick in picks)
|
||||
(filename: pick.filename, bytes: pick.bytes),
|
||||
],
|
||||
),
|
||||
);
|
||||
} on Object {
|
||||
// 静默降级本机-only:预检是体验优化而非闸门,失败不阻塞入队
|
||||
//(服务端 init 409 仍是兜底)。
|
||||
}
|
||||
|
||||
// 合并去重(filename+bytes;本机命中在前——同一重复保留先见者)。
|
||||
final seen = <String>{};
|
||||
final merged = <DuplicateMatch>[];
|
||||
// 合并去重(sourcePath 唯一标识一张本批照片;putIfAbsent 保留先见
|
||||
// 者——本机在前,故同一重复的 uploadedAt 取本地历史)。
|
||||
final byPath = <String, DuplicateMatch>{};
|
||||
for (final match in [
|
||||
...local,
|
||||
for (final hit in serverHits)
|
||||
if (hit.index >= 0 && hit.index < picks.length)
|
||||
DuplicateMatch(
|
||||
sourcePath: picks[hit.index].path,
|
||||
filename: picks[hit.index].filename,
|
||||
bytes: hit.bytes,
|
||||
uploadedAt: hit.uploadedAt,
|
||||
),
|
||||
]) {
|
||||
if (seen.add('${match.filename}\u0000${match.bytes}')) {
|
||||
merged.add(match);
|
||||
}
|
||||
byPath.putIfAbsent(match.sourcePath, () => match);
|
||||
}
|
||||
return merged.isEmpty ? null : merged;
|
||||
return byPath.isEmpty ? null : byPath.values.toList();
|
||||
}
|
||||
|
||||
/// 批量入队:通知权限单次请求 + 逐张准备入队。
|
||||
|
||||
Reference in New Issue
Block a user