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:
@@ -557,4 +557,136 @@ void main() {
|
||||
expect(store.session?.token, 'tok-live-1', reason: '旧密码错误不清会话');
|
||||
expect(unauthorizedCalls, 0, reason: 'password-change 401 不驱逐');
|
||||
});
|
||||
|
||||
group('checkUploadDuplicates:上传查重预检(Task 2,分块)', () {
|
||||
test('≤500 项单请求:albumId + items 透传,命中含共享 bytes 逐条返回',
|
||||
() async {
|
||||
seedSession();
|
||||
final adapter = FakeAdapter(
|
||||
(o) => (
|
||||
200,
|
||||
{
|
||||
'duplicates': [
|
||||
{'index': 1, 'bytes': 2048, 'uploadedAt': '2026-09-25T08:00:00Z'},
|
||||
{'index': 0, 'bytes': 1024, 'uploadedAt': '2026-09-24T08:00:00Z'},
|
||||
// 两个请求条目共享同一命中 bytes:契约保证各返回一条。
|
||||
{'index': 2, 'bytes': 1024, 'uploadedAt': '2026-09-24T08:00:00Z'},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final hits = await wired(adapter).checkUploadDuplicates('alb-1', const [
|
||||
(filename: 'a.jpg', bytes: 1024),
|
||||
(filename: 'b.jpg', bytes: 2048),
|
||||
(filename: 'c.jpg', bytes: 1024),
|
||||
]);
|
||||
|
||||
expect(adapter.requests, hasLength(1), reason: '≤500 项不分块');
|
||||
final req = adapter.requests.single;
|
||||
expect(req.method, 'POST');
|
||||
expect(req.path, '/v1/uploads/duplicates/check');
|
||||
expect(req.headers['Authorization'], 'Bearer tok-live-1');
|
||||
expect(req.body, {
|
||||
'albumId': 'alb-1',
|
||||
'items': [
|
||||
{'filename': 'a.jpg', 'bytes': 1024},
|
||||
{'filename': 'b.jpg', 'bytes': 2048},
|
||||
{'filename': 'c.jpg', 'bytes': 1024},
|
||||
],
|
||||
}, reason: '请求体对齐契约 UploadDuplicateCheckRequest');
|
||||
expect(hits, [
|
||||
(
|
||||
index: 1,
|
||||
bytes: 2048,
|
||||
uploadedAt: DateTime.utc(2026, 9, 25, 8),
|
||||
),
|
||||
(
|
||||
index: 0,
|
||||
bytes: 1024,
|
||||
uploadedAt: DateTime.utc(2026, 9, 24, 8),
|
||||
),
|
||||
(
|
||||
index: 2,
|
||||
bytes: 1024,
|
||||
uploadedAt: DateTime.utc(2026, 9, 24, 8),
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
test('>500 项分块:1200 项 → 3 个请求(500/500/200)+ 局部下标换算全局',
|
||||
() async {
|
||||
seedSession();
|
||||
final adapter = FakeAdapter((o) {
|
||||
// 每块只命中块内第 0 项:全局下标 = 分块起点 + 0。
|
||||
final items = (o.data as Map<String, dynamic>)['items'] as List<dynamic>;
|
||||
return (
|
||||
200,
|
||||
{
|
||||
'duplicates': [
|
||||
{
|
||||
'index': 0,
|
||||
'bytes': items[0]['bytes'],
|
||||
'uploadedAt': '2026-09-25T08:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
final items = List.generate(
|
||||
1200,
|
||||
(i) => (filename: 'f$i.jpg', bytes: 1000 + i),
|
||||
);
|
||||
final hits = await wired(adapter).checkUploadDuplicates('alb-1', items);
|
||||
|
||||
expect(adapter.requests, hasLength(3), reason: '契约单批上限 500');
|
||||
expect(
|
||||
adapter.requests.map(
|
||||
(r) => ((r.body as Map<String, dynamic>)['items'] as List).length,
|
||||
),
|
||||
[500, 500, 200],
|
||||
reason: '顺序分块,末块为余量',
|
||||
);
|
||||
expect(hits.map((h) => h.index), [0, 500, 1000],
|
||||
reason: '分块局部下标换算为对原始列表的全局下标');
|
||||
expect(hits.map((h) => h.bytes), [1000, 1500, 2000]);
|
||||
});
|
||||
|
||||
test('空列表不发起请求(契约 items minItems=1)', () async {
|
||||
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
|
||||
|
||||
final hits = await wired(adapter).checkUploadDuplicates('alb-1', const []);
|
||||
|
||||
expect(hits, isEmpty);
|
||||
expect(adapter.requests, isEmpty);
|
||||
});
|
||||
|
||||
test('错误响应经 _send 归一为 AppException(404 ALBUM_NOT_FOUND)',
|
||||
() async {
|
||||
seedSession();
|
||||
final adapter = FakeAdapter(
|
||||
(o) => (
|
||||
404,
|
||||
{
|
||||
'code': 'ALBUM_NOT_FOUND',
|
||||
'message': 'no such album',
|
||||
'requestId': 'r1',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final err = await captureError(
|
||||
() => wired(adapter).checkUploadDuplicates('alb-x', const [
|
||||
(filename: 'a.jpg', bytes: 1024),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(
|
||||
err,
|
||||
isA<AppException>()
|
||||
.having((e) => e.code, 'code', ErrorCodes.albumNotFound),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user