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
+55 -3
View File
@@ -165,7 +165,11 @@ void main() {
categories: categories,
);
Future<void> pumpCapture(WidgetTester tester, {CaptureSource? cameraSource}) async {
Future<void> pumpCapture(
WidgetTester tester, {
CaptureSource? cameraSource,
FakeAdapter? apiAdapter,
}) async {
final effectiveSource = cameraSource ?? source;
await tester.pumpWidget(
ProviderScope(
@@ -176,8 +180,12 @@ void main() {
client: ApiClient(
sessionStore: session,
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
..httpClientAdapter = FakeAdapter(
(_) => throw StateError('连拍页不应发起 API 请求')),
..httpClientAdapter = apiAdapter ??
FakeAdapter(
// 多选入队的查重预检会发 API 请求:默认打桩为失败,
// 驱动「服务器预检失败 → 静默降级本机-only」路径。
(_) => throw StateError('连拍页测试 API 请求一律失败'),
),
),
),
captureSourceFactoryProvider.overrideWith(
@@ -462,6 +470,50 @@ void main() {
);
});
testWidgets('服务器历史查重命中(本机无记录)→ 统一查重弹窗,取消不上传(Task 2)',
(tester) async {
// 新设备场景:本机 TaskStore 空,服务器预检命中 pick1(bytes 一致);
// pick2 字节数不同不命中——弹窗应精确提示 1 张重复。
final adapter = FakeAdapter((o) {
if (o.path == '/v1/uploads/duplicates/check' && o.method == 'POST') {
return (
200,
{
'duplicates': [
{
'index': 0,
'bytes': kJpgBytes.length,
'uploadedAt': '2026-09-25T08:00:00Z',
},
],
},
);
}
throw StateError('unexpected ${o.method} ${o.path}');
});
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
final pick2 = File('${tmp.path}/pick2.jpg')
..writeAsBytesSync([...kJpgBytes, 0x00]);
galleryPaths.addAll([pick1.path, pick2.path]);
await pumpCapture(tester, apiAdapter: adapter);
await pressRealIo(tester, Icons.photo_library_outlined);
await tester.pump(const Duration(milliseconds: 300));
expect(
find.text('可能已上传过'),
findsOneWidget,
reason: '服务器历史命中(新设备场景)也弹查重确认',
);
expect(find.textContaining('1 张照片可能已上传到该相册'), findsOneWidget);
expect(find.text('跳过重复,上传 1 张'), findsOneWidget);
// 取消:整批不上传(服务器命中同样尊重用户取消)。
await tester.tap(find.text('取消'));
await tester.pumpAndSettle();
expect(await store.listByAccount('alice'), isEmpty);
});
testWidgets('退出确认:有待传任务 → 弹确认;保留继续拍 / 确认才退出',
(tester) async {
await pumpCapture(tester);