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:
浅念
2026-09-28 19:17:35 +08:00
parent 64e1be09d3
commit 0fa23e015e
7 changed files with 469 additions and 12 deletions
+132
View File
@@ -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),
);
});
});
}