diff --git a/apps/mobile/integration_test/helpers/app_boot.dart b/apps/mobile/integration_test/helpers/app_boot.dart index 2c60cf7..9891eda 100644 --- a/apps/mobile/integration_test/helpers/app_boot.dart +++ b/apps/mobile/integration_test/helpers/app_boot.dart @@ -93,7 +93,10 @@ class ItAppHarness { ref.onDispose(() => client.close()); return client; }), - multiImagePickerProvider.overrideWithValue((context) async => picker.consume()), + multiImagePickerProvider.overrideWithValue( + (context, {required accountId, required albumId}) async => + picker.consume(), + ), singleImagePickerProvider.overrideWithValue(() async { final paths = picker.consume(); return paths.isEmpty ? null : paths.first; diff --git a/apps/mobile/lib/features/uploads/enqueue.dart b/apps/mobile/lib/features/uploads/enqueue.dart index b557d2d..cac52b6 100644 --- a/apps/mobile/lib/features/uploads/enqueue.dart +++ b/apps/mobile/lib/features/uploads/enqueue.dart @@ -22,27 +22,46 @@ /// 重复项以 force=true 入队([enqueueForUpload] 的 forcePaths),任务行 /// 落库后由引擎在 initUpload 携带 force 跳过服务端查重(仅 owner/admin /// 生效,服务端裁决——协作者强传仍会被 409 拒绝并可见失败文案)。 +/// +/// 方案 A(选图点选拦截):[multiImagePickerProvider] 经 +/// AssetPickerConfig.selectPredicate 挂 [PickerDuplicateGate]——点选 +/// 「已上传到目标相册」的照片当场拦下(SnackBar 提示),再次点击同一张 +/// 强制选择;判定口径同 [checkDuplicatesUnified](本机 ∪ 服务器,当前 +/// 账号+相册,bytes 为主),查询失败一律放行(enqueue 弹窗 + init 409 +/// 兜底不变)。 library; import 'dart:io'; -import 'package:flutter/widgets.dart' show BuildContext, Localizations; +import 'package:flutter/material.dart' show ScaffoldMessenger, SnackBar; +import 'package:flutter/widgets.dart' + show BuildContext, Localizations, Text; import 'package:flutter_riverpod/flutter_riverpod.dart'; 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; + show ApiClient, UploadDuplicateCheckItem, UploadDuplicateHit, + apiClientProvider; import '../../core/notification_permission.dart'; +import '../../l10n/app_localizations.dart'; import 'providers.dart'; import 'task_state.dart'; +import 'task_store.dart'; /// 单图选择(准备失败重试的重新选图路径);返回源文件路径或 null(取消)。 typedef SingleImagePicker = Future Function(); /// 多图选择(相册多选入队);返回源文件路径列表(空=取消)。 -typedef MultiImagePicker = Future> Function(BuildContext context); +/// +/// [accountId]/[albumId] 为点选拦截的判定范围(方案 A:当前目标相册; +/// 与入队目标同源)——两处调用点(相册详情/连拍页)均持有,必传。 +typedef MultiImagePicker = Future> Function( + BuildContext context, { + required String accountId, + required String albumId, +}); /// 单图选择 Provider(生产=image_picker;测试 override)。 final singleImagePickerProvider = Provider( @@ -65,9 +84,18 @@ final singleImagePickerProvider = Provider( /// 该张(null 过滤),不丢弃整批;入队数由调用方 SnackBar 反映实际 /// 张数; /// - 权限由 picker 内部请求;永久拒绝时 picker 自带 denied/limited 视图 -/// (非「返回空」);返回 null 仅发生在用户取消。 +/// (非「返回空」);返回 null 仅发生在用户取消; +/// - 方案 A(点选拦截):selectPredicate 挂 [PickerDuplicateGate]——已 +/// 上传到目标相册的照片点选当场拦下(再点一次强制选择)。 final multiImagePickerProvider = Provider( - (ref) => (context) async { + (ref) => (context, {required accountId, required albumId}) async { + // 拦截器按次构造:每次选图会话独立的判定缓存/放行集(不跨会话残留)。 + final gate = PickerDuplicateGate( + store: ref.read(taskStoreProvider), + client: ref.read(apiClientProvider), + accountId: accountId, + albumId: albumId, + ); final locale = Localizations.maybeLocaleOf(context); final assets = await AssetPicker.pickAssets( context, @@ -75,6 +103,14 @@ final multiImagePickerProvider = Provider( requestType: RequestType.image, maxAssets: kPickerSoftMaxAssets, textDelegate: assetPickerTextDelegateFromLocale(locale), + // 点选拦截(selectPredicate 签名 FutureOr Function(ctx, + // asset, selected)——false 静默不选);AssetEntity → + // [PickerAssetFact] 适配(fileSize 惰性求值)在此完成。 + selectPredicate: (ctx, asset, selected) => gate.decideFact( + ctx, + (id: asset.id, title: asset.title, fileSize: () => asset.fileSize), + selected, + ), ), ); if (assets == null) return const []; @@ -91,6 +127,138 @@ final multiImagePickerProvider = Provider( /// 选图软上限(用户要求不设硬限制;9999 为防误触极端值)。 const int kPickerSoftMaxAssets = 9999; +/// 拦截器所需的最小资产事实([PickerDuplicateGate] 的测试接缝)。 +/// +/// AssetEntity → 此记录的适配在 [multiImagePickerProvider] 内完成: +/// - `fileSize`:photo_manager 3.12 为 `Future get fileSize` +/// (entity.dart:778——平台通道异步,无同步 length 字段);以惰性闭包 +/// 传入,缓存命中/取消选择/二次点击路径均不发起查询; +/// - `title`:`String?`——iOS 需 FilterOption.needTitle 才非空 +/// (wechat_assets_picker 默认 filterOptions 仅对 audio 开),Android +/// 侧无条件携带(ConvertUtils.kt 无条件 `title to entity.displayName`); +/// null → 本机半场跳过(服务器按 bytes 仍可判)。 +typedef PickerAssetFact = ({ + String id, + String? title, + Future Function() fileSize, +}); + +/// 选图点选拦截器(方案 A):挂 wechat_assets_picker 的 +/// `AssetPickerConfig.selectPredicate` 钩子(10.1.3 源码 +/// asset_picker_builder_delegate.dart:982——每次点选/取消时 await,返回 +/// false 静默不选)。 +/// +/// 判定口径与 [checkDuplicatesUnified] 一致(本机 TaskStore 历史 ∪ 服务器 +/// 单项预检;同账号+相册,filename+bytes / bytes);**任一半场查询异常按 +/// 该半场无命中处理,fileSize 失败等整体异常直接放行**——预检是体验优化 +/// 而非闸门,绝不因自身故障挡住选图(enqueue 弹窗 + init 409 兜底不变)。 +/// 本机命中短路服务器预检(点选瞬间延迟考量;与批量 union 不短路不同)。 +/// +/// 重复 → 首次点击 false + SnackBar 提示(l10n `pickerDuplicateBlocked`, +/// 经谓词 ctx 向上解析到根 ScaffoldMessenger——picker 页自带的 Scaffold +/// 之上无 messenger),该 asset.id 记入放行集 → 二次点击 true(强制选择 +/// 活口)。取消选择(selected=true)恒放行且不预热放行集。per-asset +/// 判定缓存避免同一张重复查询。 +/// +/// 公开(计划原定私有类)仅为让 enqueue_test 直接构造注入替身。 +class PickerDuplicateGate { + PickerDuplicateGate({ + required this.store, + required this.client, + required this.accountId, + required this.albumId, + }); + + /// 本机历史半场数据源(生产=taskStoreProvider)。 + final TaskStore store; + + /// 服务器单项预检半场客户端(生产=apiClientProvider)。 + final ApiClient client; + + /// 判定范围:账号(TaskStore 账号隔离硬守护的入参)。 + final String accountId; + + /// 判定范围:目标相册(与入队目标一致)。 + final String albumId; + + /// per-asset 判定缓存(assetId → 是否重复):二次点击瞬回。 + final Map _verdicts = {}; + + /// 二次点击放行集(首次拦截时记入——escape hatch)。 + final Set _forced = {}; + + /// 核心判定(测试直接驱动——绕开 AssetEntity 的平台通道 fileSize)。 + /// + /// [selected]=true 表示当前已选中、本次点击为**取消选择** → 恒放行。 + Future decideFact( + BuildContext ctx, + PickerAssetFact asset, + bool selected, + ) async { + if (selected) return true; + if (_forced.contains(asset.id)) return true; // 二次点击:强制选择。 + final isDuplicate = _verdicts[asset.id] ?? await _judge(asset); + _verdicts[asset.id] = isDuplicate; + if (!isDuplicate) return true; + _forced.add(asset.id); + // 判定是异步的:picker 页可能在等待期间已被关闭(mounted=false, + // 也就不会再有第二次点击)——跳过提示,不跨 async 间隙用 ctx。 + if (ctx.mounted) _notifyBlocked(ctx); + return false; + } + + /// 判定一张是否重复(true=重复):本机 ∪ 服务器,异常降级放行。 + Future _judge(PickerAssetFact asset) async { + try { + // 文件大小:两半场共用,取一次。 + final bytes = await asset.fileSize(); + + // 层 1:本机历史(同账号+相册+completed,filename+bytes)。 + final title = asset.title; + if (title != null) { + try { + final completed = await store.listByAccount( + accountId, + albumId: albumId, + states: const {UploadTaskState.completed}, + ); + final hit = completed.any( + (t) => t.filename == title && t.bytes == bytes, + ); + if (hit) return true; // 本机命中短路服务器(点选延迟考量)。 + } on Object { + // 本机查询失败:该半场按无命中,服务器半场仍判。 + } + } + + // 层 2:服务器单项预检(filename 仅元数据——服务端谓词只看 bytes; + // title null 时传空串占位,判定不受影响)。 + try { + final hits = await client.checkUploadDuplicates( + albumId, + [(filename: title ?? '', bytes: bytes)], + ); + return hits.isNotEmpty; + } on Object { + // 服务器预检失败:按无命中(放行——预检故障不挡选图)。 + return false; + } + } on Object { + // fileSize 失败等任意异常:放行。 + return false; + } + } + + /// 拦截提示(谓词 ctx → 根 ScaffoldMessenger)。 + void _notifyBlocked(BuildContext ctx) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(ctx).pickerDuplicateBlocked), + ), + ); + } +} + /// 查重弹窗选择结果(Task 8):null=取消(不上传)。 /// /// 「跳过重复」→ skipPaths=重复项(仅非重复入队);「全部上传」→ diff --git a/apps/mobile/lib/l10n/app_en.arb b/apps/mobile/lib/l10n/app_en.arb index 98e4672..0502023 100644 --- a/apps/mobile/lib/l10n/app_en.arb +++ b/apps/mobile/lib/l10n/app_en.arb @@ -349,6 +349,7 @@ } }, "uploadPickNone": "No photos selected", + "pickerDuplicateBlocked": "Already uploaded to this album — tap again to select anyway", "albumManagePhotos": "Manage photos", "albumTakeFirstPhoto": "Take the first photo", "albumUploadDisabledHint": "Uploads are off for this album. New photos cannot be added right now", diff --git a/apps/mobile/lib/l10n/app_localizations.dart b/apps/mobile/lib/l10n/app_localizations.dart index 74ac07f..55255a4 100644 --- a/apps/mobile/lib/l10n/app_localizations.dart +++ b/apps/mobile/lib/l10n/app_localizations.dart @@ -1688,6 +1688,12 @@ abstract class AppLocalizations { /// **'未选择照片'** String get uploadPickNone; + /// No description provided for @pickerDuplicateBlocked. + /// + /// In zh, this message translates to: + /// **'这张已上传到该相册——再点一次可强制选择'** + String get pickerDuplicateBlocked; + /// No description provided for @albumManagePhotos. /// /// In zh, this message translates to: diff --git a/apps/mobile/lib/l10n/app_localizations_en.dart b/apps/mobile/lib/l10n/app_localizations_en.dart index 2b0289e..da7f430 100644 --- a/apps/mobile/lib/l10n/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/app_localizations_en.dart @@ -856,6 +856,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get uploadPickNone => 'No photos selected'; + @override + String get pickerDuplicateBlocked => + 'Already uploaded to this album — tap again to select anyway'; + @override String get albumManagePhotos => 'Manage photos'; diff --git a/apps/mobile/lib/l10n/app_localizations_zh.dart b/apps/mobile/lib/l10n/app_localizations_zh.dart index 84f0c66..e030a94 100644 --- a/apps/mobile/lib/l10n/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/app_localizations_zh.dart @@ -828,6 +828,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get uploadPickNone => '未选择照片'; + @override + String get pickerDuplicateBlocked => '这张已上传到该相册——再点一次可强制选择'; + @override String get albumManagePhotos => '管理照片'; diff --git a/apps/mobile/lib/l10n/app_zh.arb b/apps/mobile/lib/l10n/app_zh.arb index a4c9617..26fb199 100644 --- a/apps/mobile/lib/l10n/app_zh.arb +++ b/apps/mobile/lib/l10n/app_zh.arb @@ -349,6 +349,7 @@ } }, "uploadPickNone": "未选择照片", + "pickerDuplicateBlocked": "这张已上传到该相册——再点一次可强制选择", "albumManagePhotos": "管理照片", "albumTakeFirstPhoto": "拍摄第一张照片", "albumUploadDisabledHint": "相册已关闭上传,暂不能新增照片", diff --git a/apps/mobile/lib/pages/album_detail_page.dart b/apps/mobile/lib/pages/album_detail_page.dart index 255a763..27d2a72 100644 --- a/apps/mobile/lib/pages/album_detail_page.dart +++ b/apps/mobile/lib/pages/album_detail_page.dart @@ -338,7 +338,12 @@ class _AlbumDetailPageState extends ConsumerState { final l10n = AppLocalizations.of(context); final account = await _currentAccountId(); if (account == null || !mounted) return; - final paths = await ref.read(multiImagePickerProvider)(context); + // 方案 A:accountId/albumId 传入选图器(点选拦截的判定范围)。 + final paths = await ref.read(multiImagePickerProvider)( + context, + accountId: account, + albumId: album.id, + ); if (paths.isEmpty || !mounted) return; final messenger = ScaffoldMessenger.of(context); diff --git a/apps/mobile/lib/pages/capture_page.dart b/apps/mobile/lib/pages/capture_page.dart index 660fc15..8dcdc23 100644 --- a/apps/mobile/lib/pages/capture_page.dart +++ b/apps/mobile/lib/pages/capture_page.dart @@ -448,7 +448,12 @@ class _CapturePageState extends ConsumerState /// 相册多选入队(目标 = 当前相册与分类;入口=底部缩略图位)。 Future _pickFromGallery() async { - final paths = await ref.read(multiImagePickerProvider)(context); + // 方案 A:目标账号/相册传入选图器(点选拦截的判定范围)。 + final paths = await ref.read(multiImagePickerProvider)( + context, + accountId: widget.target.accountId, + albumId: widget.target.albumId, + ); if (paths.isEmpty || !mounted) return; final l10n = AppLocalizations.of(context); final messenger = ScaffoldMessenger.of(context); diff --git a/apps/mobile/test/features/uploads/enqueue_test.dart b/apps/mobile/test/features/uploads/enqueue_test.dart index b7284dc..afc1d3e 100644 --- a/apps/mobile/test/features/uploads/enqueue_test.dart +++ b/apps/mobile/test/features/uploads/enqueue_test.dart @@ -1,15 +1,23 @@ -/// checkDuplicatesUnified 单元测试(Task 2 评审 #1/#3)。 +/// checkDuplicatesUnified 单元测试(Task 2 评审 #1/#3)+ 选图点选拦截器 +/// [PickerDuplicateGate] 五态用例(方案 A)。 /// /// 页级测试覆盖弹窗三态;本文件钉住合并语义:同一文件被本机 TaskStore /// 与服务器历史同时命中 → 单条结果(不重复呈现)且本地 uploadedAt 优先。 /// 经 Consumer 捕获 WidgetRef 驱动真实函数——drift/文件 stat 均真实异步, /// 整体跑在 runAsync(真实事件循环)窗口内(同页级测试的 real-io 模式; /// fake-async 区内不做真实异步 IO)。 +/// +/// 拦截器用例直接构造 [PickerDuplicateGate](构造注入真实内存 TaskStore + +/// dio 打桩 ApiClient,同 checkDuplicatesUnified 的替身面);资产事实用 +/// [PickerAssetFact] 记录绕开 AssetEntity.fileSize 的平台通道。 library; import 'dart:io'; import 'package:dio/dio.dart'; +import 'package:flutter/material.dart' + show BuildContext, Builder, MaterialApp, Scaffold, ScaffoldMessenger, + SnackBar; import 'package:flutter/widgets.dart' show SizedBox; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -18,6 +26,7 @@ import 'package:photo_live/core/session_store.dart'; import 'package:photo_live/features/uploads/enqueue.dart'; import 'package:photo_live/features/uploads/task_state.dart'; import 'package:photo_live/features/uploads/task_store.dart'; +import 'package:photo_live/l10n/app_localizations.dart'; import '../../helpers/fakes.dart'; @@ -152,4 +161,327 @@ void main() { reason: '两张均送预检(含本地已命中的那张——union 语义不做短路)', ); }); + + group('选图点选拦截器 PickerDuplicateGate(方案 A)', () { + /// 宿主树:MaterialApp(含根 ScaffoldMessenger + l10n)——SnackBar 提示 + /// 与文案断言的载体;测试环境默认 en locale(同页级测试)。 + Future pumpGateHost( + WidgetTester tester, + void Function(BuildContext) capture, + ) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) { + capture(context); + return const SizedBox(); + }, + ), + ), + ), + ); + } + + /// 测试资产事实(fileSize 惰性求值——gate 缓存未命中才调用)。 + PickerAssetFact gateAsset({ + String id = 'asset-1', + String? title = 'pick1.jpg', + required int bytes, + }) => + ( + id: id, + title: title, + fileSize: () async => bytes, + ); + + /// 种子:同账号+相册的已完成任务(本机半场命中源;runAsync 区内调用)。 + Future seedCompletedTask( + TaskStore store, + String filename, + int bytes, + ) async { + final seed = await store.create( + UploadTaskDraft( + id: 'task-seed', + accountId: 'alice', + albumId: 'alb-1', + clientTaskId: 'ct-seed', + sourceFilePath: '/tmp/seed.jpg', + filename: filename, + bytes: bytes, + contentType: 'image/jpeg', + ), + ); + for (final state in [ + UploadTaskState.queued, + UploadTaskState.uploading, + UploadTaskState.uploadedConfirming, + UploadTaskState.completed, + ]) { + await store.updateState(seed.id, state); + } + } + + /// 拦截提示收尾:等 SnackBar 挂载 → 断言由调用方完成 → 隐藏并冲刷, + /// 避免 4s 自动关闭 timer 残留到 testWidgets 收尾。 + Future drainBlockedSnackBar(WidgetTester tester, BuildContext ctx) async { + await tester.pump(); + ScaffoldMessenger.of(ctx).hideCurrentSnackBar(); + await tester.pumpAndSettle(); + } + + testWidgets('① 本机命中 → 首次拒选+提示;二次点击强制放行;本机命中短路服务器预检', + (tester) async { + final session = FakeSessionStore() + ..session = Session( + token: 'tok-1', + expiresAt: DateTime.utc(2099, 1, 1), + account: 'alice', + ); + final store = TaskStore.memory(); + addTearDown(() => store.close()); + // 服务器半场打桩为空命中(若被调用则请求被记录——本机命中应短路)。 + final adapter = FakeAdapter( + (o) => (200, {'duplicates': >[]}), + ); + final client = ApiClient( + sessionStore: session, + dio: Dio(BaseOptions(baseUrl: 'http://test.local')) + ..httpClientAdapter = adapter, + ); + + late BuildContext ctx; + await pumpGateHost(tester, (c) => ctx = c); + final gate = PickerDuplicateGate( + store: store, + client: client, + accountId: 'alice', + albumId: 'alb-1', + ); + final asset = gateAsset(bytes: kJpgBytes.length); + + late bool first; + late bool second; + await tester.runAsync(() async { + await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length); + first = await gate.decideFact(ctx, asset, false); + second = await gate.decideFact(ctx, asset, false); + }); + + expect(first, isFalse, reason: '本机历史命中 → 首次点选拦下'); + expect(second, isTrue, reason: '二次点击同一张 → 强制选择(escape hatch)'); + expect(adapter.requests, isEmpty, + reason: '本机命中短路服务器单项预检(点选延迟考量)'); + + // 拦截提示:谓词 ctx 经根 messenger 呈现 SnackBar(en 文案)。 + await tester.pump(); + expect(find.byType(SnackBar), findsOneWidget); + expect( + find.text( + 'Already uploaded to this album — tap again to select anyway', + ), + findsOneWidget, + ); + await drainBlockedSnackBar(tester, ctx); + }); + + testWidgets('② 服务器单项命中 → 拒选(单项请求携带 filename+bytes)', + (tester) async { + final session = FakeSessionStore() + ..session = Session( + token: 'tok-1', + expiresAt: DateTime.utc(2099, 1, 1), + account: 'alice', + ); + final store = TaskStore.memory(); + addTearDown(() => store.close()); + // 本机无历史;服务器预检回 index=0 命中(本批仅此一项)。 + final adapter = FakeAdapter( + (o) => ( + 200, + { + 'duplicates': [ + { + 'index': 0, + 'bytes': kJpgBytes.length, + 'uploadedAt': '2026-01-01T00:00:00Z', + }, + ], + }, + ), + ); + final client = ApiClient( + sessionStore: session, + dio: Dio(BaseOptions(baseUrl: 'http://test.local')) + ..httpClientAdapter = adapter, + ); + + late BuildContext ctx; + await pumpGateHost(tester, (c) => ctx = c); + final gate = PickerDuplicateGate( + store: store, + client: client, + accountId: 'alice', + albumId: 'alb-1', + ); + + late bool first; + await tester.runAsync(() async { + first = await gate.decideFact( + ctx, + gateAsset(bytes: kJpgBytes.length), + false, + ); + }); + + expect(first, isFalse, reason: '服务器历史命中 → 拒选'); + expect(adapter.requests, hasLength(1), reason: '单项预检恰好一次'); + final body = adapter.requests.single.body as Map; + expect(body['items'], hasLength(1)); + expect( + (body['items'] as List).single, + {'filename': 'pick1.jpg', 'bytes': kJpgBytes.length}, + reason: '请求条目 = asset.title + 文件字节数', + ); + await drainBlockedSnackBar(tester, ctx); + }); + + testWidgets('③ 两路皆空 → 放行(判定缓存:二次调用不再发预检)', + (tester) async { + final session = FakeSessionStore() + ..session = Session( + token: 'tok-1', + expiresAt: DateTime.utc(2099, 1, 1), + account: 'alice', + ); + final store = TaskStore.memory(); + addTearDown(() => store.close()); + final adapter = FakeAdapter( + (o) => (200, {'duplicates': >[]}), + ); + final client = ApiClient( + sessionStore: session, + dio: Dio(BaseOptions(baseUrl: 'http://test.local')) + ..httpClientAdapter = adapter, + ); + + late BuildContext ctx; + await pumpGateHost(tester, (c) => ctx = c); + final gate = PickerDuplicateGate( + store: store, + client: client, + accountId: 'alice', + albumId: 'alb-1', + ); + + late bool first; + late bool second; + await tester.runAsync(() async { + first = await gate.decideFact( + ctx, + gateAsset(bytes: kJpgBytes.length), + false, + ); + second = await gate.decideFact( + ctx, + gateAsset(bytes: kJpgBytes.length), + false, + ); + }); + + expect(first, isTrue, reason: '本机/服务器均无历史 → 放行'); + expect(second, isTrue, reason: '缓存判定直接放行'); + expect(adapter.requests, hasLength(1), + reason: '同一资产二次点选用缓存,不重复预检'); + }); + + testWidgets('④ 服务器预检异常 → 放行(预检故障不挡选图)', (tester) async { + final session = FakeSessionStore() + ..session = Session( + token: 'tok-1', + expiresAt: DateTime.utc(2099, 1, 1), + account: 'alice', + ); + final store = TaskStore.memory(); + addTearDown(() => store.close()); + // 本机无历史 + 服务器半场抛错(网络/权限等任意异常)。 + final adapter = FakeAdapter( + (_) => throw StateError('预检失败'), + ); + final client = ApiClient( + sessionStore: session, + dio: Dio(BaseOptions(baseUrl: 'http://test.local')) + ..httpClientAdapter = adapter, + ); + + late BuildContext ctx; + await pumpGateHost(tester, (c) => ctx = c); + final gate = PickerDuplicateGate( + store: store, + client: client, + accountId: 'alice', + albumId: 'alb-1', + ); + + late bool verdict; + await tester.runAsync(() async { + verdict = await gate.decideFact( + ctx, + gateAsset(bytes: kJpgBytes.length), + false, + ); + }); + + expect(verdict, isTrue, + reason: '预检异常放行——enqueue 弹窗 + init 409 仍是兜底'); + expect(adapter.requests, hasLength(1)); + }); + + testWidgets('⑤ 取消选择恒放行(不预热放行集,不发起查询)', (tester) async { + final session = FakeSessionStore() + ..session = Session( + token: 'tok-1', + expiresAt: DateTime.utc(2099, 1, 1), + account: 'alice', + ); + final store = TaskStore.memory(); + addTearDown(() => store.close()); + final adapter = FakeAdapter( + (o) => (200, {'duplicates': >[]}), + ); + final client = ApiClient( + sessionStore: session, + dio: Dio(BaseOptions(baseUrl: 'http://test.local')) + ..httpClientAdapter = adapter, + ); + + late BuildContext ctx; + await pumpGateHost(tester, (c) => ctx = c); + final gate = PickerDuplicateGate( + store: store, + client: client, + accountId: 'alice', + albumId: 'alb-1', + ); + final asset = gateAsset(bytes: kJpgBytes.length); + + late bool deselect; + late bool reselect; + await tester.runAsync(() async { + await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length); + deselect = await gate.decideFact(ctx, asset, true); + reselect = await gate.decideFact(ctx, asset, false); + }); + + expect(deselect, isTrue, reason: '取消选择(selected=true)恒放行'); + expect(reselect, isFalse, + reason: '取消选择不预热放行集——再点选仍按重复拦截'); + expect(adapter.requests, isEmpty, + reason: '取消选择路径不发起任何查询(本机命中也短路服务器)'); + await drainBlockedSnackBar(tester, ctx); + }); + }); } diff --git a/apps/mobile/test/pages/album_detail_page_test.dart b/apps/mobile/test/pages/album_detail_page_test.dart index c8347fb..a39fd66 100644 --- a/apps/mobile/test/pages/album_detail_page_test.dart +++ b/apps/mobile/test/pages/album_detail_page_test.dart @@ -312,6 +312,7 @@ void main() { group('底部固定上传栏(原 Task 9 FAB 流迁移)', () { late Directory tmp; late List galleryPaths; + late List<(String, String)> pickerScopes; late TaskStore taskStore; setUp(() async { @@ -322,6 +323,8 @@ void main() { ); tmp = await Directory.systemTemp.createTemp('detail-upload-'); galleryPaths = []; + // 方案 A 接线断言:选图器收到的 (accountId, albumId)。 + pickerScopes = <(String, String)>[]; taskStore = TaskStore.memory(); }); @@ -345,8 +348,12 @@ void main() { taskStore: taskStore, client: clientOf(effective), ), - multiImagePickerProvider - .overrideWith((ref) => (context) async => galleryPaths), + multiImagePickerProvider.overrideWith( + (ref) => (context, {required accountId, required albumId}) async { + pickerScopes.add((accountId, albumId)); + return galleryPaths; + }, + ), uploadPreparerProvider.overrideWith( (ref) => UploadPreparer( taskStore, @@ -406,6 +413,8 @@ void main() { find.text('Added 2 photo(s) to the upload queue'), findsOneWidget, ); + // 方案 A 接线:拦截判定范围随调用点传入(当前账号+当前相册)。 + expect(pickerScopes, [('alice', 'alb-1')]); final tasks = await taskStore.listByAccount('alice'); expect(tasks, hasLength(2)); expect(tasks.every((t) => t.albumId == 'alb-1'), isTrue); diff --git a/apps/mobile/test/pages/capture_page_test.dart b/apps/mobile/test/pages/capture_page_test.dart index a78f259..09760cd 100644 --- a/apps/mobile/test/pages/capture_page_test.dart +++ b/apps/mobile/test/pages/capture_page_test.dart @@ -105,6 +105,8 @@ void main() { late Directory tmp; late FakeCaptureSource source; late List galleryPaths; + // 方案 A 接线断言:选图器收到的 (accountId, albumId)。 + late List<(String, String)> pickerScopes; /// 工厂调用记录(前后摄切换断言)。 late List factoryPreferFront; @@ -144,6 +146,7 @@ void main() { final shot3 = File('${tmp.path}/IMG_0003.jpg')..writeAsBytesSync(kJpgBytes); source = FakeCaptureSource([shot1, shot2, shot3]); galleryPaths = []; + pickerScopes = <(String, String)>[]; factoryPreferFront = []; }); @@ -203,8 +206,12 @@ void main() { notificationPermissionProvider.overrideWith( (ref) => NotificationPermission(isAndroid: false), ), - multiImagePickerProvider - .overrideWith((ref) => (context) async => galleryPaths), + multiImagePickerProvider.overrideWith( + (ref) => (context, {required accountId, required albumId}) async { + pickerScopes.add((accountId, albumId)); + return galleryPaths; + }, + ), ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -410,6 +417,8 @@ void main() { await tester.pump(const Duration(milliseconds: 100)); expect(find.text('Added 2 photo(s) to the upload queue'), findsOneWidget); + // 方案 A 接线:拦截判定范围随调用点传入(连拍目标账号+相册)。 + expect(pickerScopes, [('alice', 'alb-1')]); expect(await store.listByAccount('alice'), hasLength(2)); }); diff --git a/docs/plans/2026-09-29-选图时已传照片点选拦截-实施计划.md b/docs/plans/2026-09-29-选图时已传照片点选拦截-实施计划.md index 4b13dc0..1c97cf5 100644 --- a/docs/plans/2026-09-29-选图时已传照片点选拦截-实施计划.md +++ b/docs/plans/2026-09-29-选图时已传照片点选拦截-实施计划.md @@ -28,9 +28,9 @@ - Modify: `apps/mobile/lib/l10n/app_zh.arb`、`app_en.arb`(+pickerDuplicateBlocked) - Test: `apps/mobile/test/features/uploads/enqueue_test.dart`(gate 用例)、`test/pages/{album_detail_page,capture_page}_test.dart`、`integration_test/helpers/app_boot.dart`(替身签名) -- [ ] **Step 1: 失败测试**(gate 五态:本机命中首次拒/二次放行;服务器单项命中拒;两路空放行;服务器异常放行;取消选择恒放行——用 ProviderScope override 注入 fake taskStore/apiClient) -- [ ] **Step 2: 确认失败 → 实现**(先查 photo_manager AssetEntity 文件大小 API 与 title 语义) -- [ ] **Step 3: 全绿**(flutter analyze + 全量 test) -- [ ] **Step 4: Commit** `feat(app): block selecting already-uploaded photos at picker tap (double-tap to force)` +- [x] **Step 1: 失败测试**(gate 五态:本机命中首次拒/二次放行;服务器单项命中拒;两路空放行;服务器异常放行;取消选择恒放行——用 ProviderScope override 注入 fake taskStore/apiClient) +- [x] **Step 2: 确认失败 → 实现**(先查 photo_manager AssetEntity 文件大小 API 与 title 语义) +- [x] **Step 3: 全绿**(flutter analyze + 全量 test) +- [x] **Step 4: Commit** `feat(app): block selecting already-uploaded photos at picker tap (double-tap to force)` **风险与回退:** 谓词异步查询在点选瞬间有 ~100-300ms 延迟(有缓存后二次即瞬回);查询失败放行=最坏回到现状(弹窗+409 兜底);revert 单 commit 即回退。置灰视觉(方案 B)不在本次。