diff --git a/apps/mobile/lib/core/api_client.dart b/apps/mobile/lib/core/api_client.dart index 6148d92..d6b6e9a 100644 --- a/apps/mobile/lib/core/api_client.dart +++ b/apps/mobile/lib/core/api_client.dart @@ -25,6 +25,17 @@ import 'error_codes.dart'; import 'models/cursor_page.dart'; import 'session_store.dart'; +/// 查重预检请求条目(契约 UploadDuplicateCheckItem;filename 仅元数据—— +/// 服务端查重谓词只看 bytes,与 initUpload ③.5 逐字一致)。 +typedef UploadDuplicateCheckItem = ({String filename, int bytes}); + +/// 查重预检命中(契约 UploadDuplicateCheckDuplicate;index 已从分块局部 +/// 下标换算为对入参列表的全局下标)。 +typedef UploadDuplicateHit = ({int index, int bytes, DateTime uploadedAt}); + +/// 单批查重条目上限(契约 items maxItems=500;客户端按此分块顺序调用)。 +const int kUploadDuplicateCheckChunkSize = 500; + class ApiClient { /// [dio] 可注入测试替身(自定义 HttpClientAdapter);[baseUrl] 缺省 /// 取 [ApiConfig.baseUrl](--dart-define API_BASE 覆盖)。 @@ -568,6 +579,53 @@ class ApiClient { limit: limit, ); + /// operationId `checkUploadDuplicates` + /// (POST /uploads/duplicates/check,批量只读预检)。 + /// + /// 按 [kUploadDuplicateCheckChunkSize] 顺序分块调用(契约单批上限 500; + /// 块间串行防瞬时并发放大);各块命中的分块局部下标换算为对 [items] 的 + /// 全局下标后合并返回。空列表直接返回空(契约 items minItems=1,不发 + /// 请求)。错误经 [_send] 归一为 [AppException](404/403/400/401 见契约)。 + Future> checkUploadDuplicates( + String albumId, + List items, + ) async { + if (items.isEmpty) return const []; + final hits = []; + for ( + var start = 0; + start < items.length; + start += kUploadDuplicateCheckChunkSize + ) { + final chunk = items + .skip(start) + .take(kUploadDuplicateCheckChunkSize) + .toList(); + final resp = await _send( + () => _dio.post>( + '/v1/uploads/duplicates/check', + data: { + 'albumId': albumId, + 'items': [ + for (final item in chunk) + {'filename': item.filename, 'bytes': item.bytes}, + ], + }, + ), + ); + final duplicates = (resp.data!['duplicates'] as List) + .cast>(); + for (final d in duplicates) { + hits.add(( + index: start + (d['index'] as int), + bytes: d['bytes'] as int, + uploadedAt: DateTime.parse(d['uploadedAt'] as String), + )); + } + } + return hits; + } + // ── photos(契约 tag: photos)── /// operationId `listAlbumPhotos` diff --git a/apps/mobile/lib/features/uploads/enqueue.dart b/apps/mobile/lib/features/uploads/enqueue.dart index d0e09aa..3a1b8ba 100644 --- a/apps/mobile/lib/features/uploads/enqueue.dart +++ b/apps/mobile/lib/features/uploads/enqueue.dart @@ -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?> 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?> checkDuplicatesUnified( + WidgetRef ref, { + required String accountId, + required String albumId, + required List sourcePaths, +}) async { + if (sourcePaths.isEmpty) return null; + + // 层 1:本机 TaskStore 历史(filename+bytes 匹配)。 + final local = + await checkDuplicates( + ref, + accountId: accountId, + albumId: albumId, + sourcePaths: sourcePaths, + ) ?? + const []; + + // 层 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 = []; + if (picks.isNotEmpty) { + try { + serverHits.addAll( + await ref + .read(apiClientProvider) + .checkUploadDuplicates(albumId, picks), + ); + } on Object { + // 静默降级本机-only:预检是体验优化而非闸门,失败不阻塞入队 + //(服务端 init 409 仍是兜底)。 + } + } + + // 合并去重(filename+bytes;本机命中在前——同一重复保留先见者)。 + final seen = {}; + final merged = []; + 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]:查重弹窗「全部上传」 diff --git a/apps/mobile/lib/pages/album_detail_page.dart b/apps/mobile/lib/pages/album_detail_page.dart index 19dcf4a..75df5c3 100644 --- a/apps/mobile/lib/pages/album_detail_page.dart +++ b/apps/mobile/lib/pages/album_detail_page.dart @@ -342,8 +342,9 @@ class _AlbumDetailPageState extends ConsumerState { if (paths.isEmpty || !mounted) return; final messenger = ScaffoldMessenger.of(context); - // P0-1 层 1:入队前查重。 - final duplicates = await checkDuplicates( + // P0-1 层 1+2(Task 2 统一入口):入队前查重——本机 TaskStore 历史 + // ∪ 服务器历史(bytes 命中;服务器失败静默降级本机-only)。 + final duplicates = await checkDuplicatesUnified( ref, accountId: account, albumId: album.id, diff --git a/apps/mobile/lib/pages/capture_page.dart b/apps/mobile/lib/pages/capture_page.dart index 68cdfad..06c0f06 100644 --- a/apps/mobile/lib/pages/capture_page.dart +++ b/apps/mobile/lib/pages/capture_page.dart @@ -453,8 +453,9 @@ class _CapturePageState extends ConsumerState final l10n = AppLocalizations.of(context); final messenger = ScaffoldMessenger.of(context); - // P0-1 层 1:入队前查重(同相册已完成的 filename+bytes 匹配)。 - final duplicates = await checkDuplicates( + // P0-1 层 1+2(Task 2 统一入口):入队前查重——本机 TaskStore 历史 + // ∪ 服务器历史(bytes 命中;服务器失败静默降级本机-only)。 + final duplicates = await checkDuplicatesUnified( ref, accountId: widget.target.accountId, albumId: widget.target.albumId, diff --git a/apps/mobile/test/core/api_client_test.dart b/apps/mobile/test/core/api_client_test.dart index 2c08020..25789c7 100644 --- a/apps/mobile/test/core/api_client_test.dart +++ b/apps/mobile/test/core/api_client_test.dart @@ -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)['items'] as List; + 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)['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() + .having((e) => e.code, 'code', ErrorCodes.albumNotFound), + ); + }); + }); } diff --git a/apps/mobile/test/pages/album_detail_page_test.dart b/apps/mobile/test/pages/album_detail_page_test.dart index e0731e3..283f0c7 100644 --- a/apps/mobile/test/pages/album_detail_page_test.dart +++ b/apps/mobile/test/pages/album_detail_page_test.dart @@ -47,11 +47,13 @@ void main() { }); /// 路由:GET 详情(反映可变分类表)/ GET 照片预览(scope 自适应;默认 - /// 管理者空列表)/ 分类 CRUD;其余路径直接抛错。 + /// 管理者空列表)/ 分类 CRUD / 查重预检([duplicateCheck],Task 2); + /// 其余路径直接抛错(含未配置的查重预检——驱动「服务器失败静默降级」)。 FakeAdapter detailAdapter({ List>? photos, bool collaborator = false, (int, Object?)? Function(String method, String path)? onCategoryMutation, + (int, Object?)? duplicateCheck, }) => FakeAdapter((o) { if (o.path == '/v1/albums/alb-1' && o.method == 'GET') { @@ -75,6 +77,10 @@ void main() { if (o.path.startsWith('/v1/categories/') && o.method == 'DELETE') { return onCategoryMutation?.call('DELETE', o.path) ?? (204, null); } + if (o.path == '/v1/uploads/duplicates/check' && o.method == 'POST') { + if (duplicateCheck != null) return duplicateCheck; + throw StateError('unexpected ${o.method} ${o.path}'); + } throw StateError('unexpected ${o.method} ${o.path}'); }); @@ -325,16 +331,19 @@ void main() { }); /// 详情页 + 上传链路替身(内存任务库/临时目录准备器/多选替身/ - /// 非平台相机源)。 - Future pumpDetailForUpload(WidgetTester tester) async { - final adapter = detailAdapter(); + /// 非平台相机源;[adapter] 可注入查重预检路由,Task 2)。 + Future pumpDetailForUpload( + WidgetTester tester, { + FakeAdapter? adapter, + }) async { + final effective = adapter ?? detailAdapter(); await tester.pumpWidget( ProviderScope( overrides: [ ...pageTestOverrides( session: store, taskStore: taskStore, - client: clientOf(adapter), + client: clientOf(effective), ), multiImagePickerProvider .overrideWith((ref) => (context) async => galleryPaths), @@ -464,6 +473,132 @@ void main() { reason: '非重复项正常查重语义', ); }); + + testWidgets('服务器历史命中(本机无记录)→ 统一查重弹窗 + force 入队(Task 2)', + (tester) async { + // THE 用户场景:新设备/重装——本机 TaskStore 空,服务器有同 bytes + // 历史(预检命中也应在点击上传时刻弹确认)。 + final adapter = detailAdapter( + duplicateCheck: ( + 200, + { + 'duplicates': [ + { + 'index': 0, + 'bytes': kJpgBytes.length, + 'uploadedAt': '2026-09-25T08:00:00Z', + }, + ], + }, + ), + ); + // pick1 与服务器历史同 bytes(命中);pick2 字节数不同(不命中)。 + 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 pumpDetailForUpload(tester, adapter: adapter); + + final button = find.widgetWithText(OutlinedButton, 'Pick from gallery'); + await tester.runAsync(() async { + (button.evaluate().single.widget as OutlinedButton).onPressed!(); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(const Duration(milliseconds: 300)); + expect( + find.text('可能已上传过'), + findsOneWidget, + reason: '服务器历史命中(本机空)也弹查重确认', + ); + expect(find.textContaining('1 张照片可能已上传到该相册'), findsOneWidget); + expect( + find.text('跳过重复,上传 1 张'), + findsOneWidget, + reason: '另 1 张不重复 → 提供跳过重复入口', + ); + // 请求形状:albumId + 逐项 filename/bytes(index 指向该 items 数组)。 + final req = adapter.requests.firstWhere( + (r) => r.path == '/v1/uploads/duplicates/check', + ); + expect(req.method, 'POST'); + expect(req.body, { + 'albumId': 'alb-1', + 'items': [ + {'filename': 'pick1.jpg', 'bytes': kJpgBytes.length}, + {'filename': 'pick2.jpg', 'bytes': kJpgBytes.length + 1}, + ], + }); + + // 「全部上传」= 确认强传:仅服务器命中的 pick1 force=true。 + await tester.tap(find.text('全部上传 2 张')); + await tester.pump(const Duration(milliseconds: 100)); + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pumpAndSettle(); + + expect( + find.text('Added 2 photo(s) to the upload queue'), + findsOneWidget, + ); + final tasks = await taskStore.listByAccount('alice'); + expect(tasks, hasLength(2)); + expect( + tasks.firstWhere((t) => t.filename == 'pick1.jpg').force, + isTrue, + reason: '服务器命中的重复项经「全部上传」确认强传', + ); + expect( + tasks.firstWhere((t) => t.filename == 'pick2.jpg').force, + isFalse, + reason: '非重复项正常查重语义', + ); + }); + + testWidgets('服务器预检失败 → 静默降级本机-only:无重复不弹窗直接入队(Task 2)', + (tester) async { + // 本机 TaskStore 空 + 服务器 500:降级后无重复 → 不阻塞入队。 + final adapter = detailAdapter( + duplicateCheck: (500, errorBody(ErrorCodes.internalError)), + ); + final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes); + galleryPaths.add(pick1.path); + await pumpDetailForUpload(tester, adapter: adapter); + + final button = find.widgetWithText(OutlinedButton, 'Pick from gallery'); + await tester.runAsync(() async { + (button.evaluate().single.widget as OutlinedButton).onPressed!(); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pumpAndSettle(); + + expect(find.text('可能已上传过'), findsNothing, reason: '静默降级不弹窗'); + expect( + find.text('Added 1 photo(s) to the upload queue'), + findsOneWidget, + reason: '降级不阻塞入队(服务端 init 409 仍是兜底)', + ); + }); + + testWidgets('双方均无重复(服务器空命中)→ 不弹窗直接入队(Task 2)', + (tester) async { + final adapter = detailAdapter( + duplicateCheck: (200, {'duplicates': >[]}), + ); + final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes); + galleryPaths.add(pick1.path); + await pumpDetailForUpload(tester, adapter: adapter); + + final button = find.widgetWithText(OutlinedButton, 'Pick from gallery'); + await tester.runAsync(() async { + (button.evaluate().single.widget as OutlinedButton).onPressed!(); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pumpAndSettle(); + + expect(find.text('可能已上传过'), findsNothing); + expect(find.text('Added 1 photo(s) to the upload queue'), findsOneWidget); + }); }); } diff --git a/apps/mobile/test/pages/capture_page_test.dart b/apps/mobile/test/pages/capture_page_test.dart index 9764b86..a78f259 100644 --- a/apps/mobile/test/pages/capture_page_test.dart +++ b/apps/mobile/test/pages/capture_page_test.dart @@ -165,7 +165,11 @@ void main() { categories: categories, ); - Future pumpCapture(WidgetTester tester, {CaptureSource? cameraSource}) async { + Future 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);