From 0673f394d565ca4e62d978ccb5ddc2a1823893c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 29 Sep 2026 10:35:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(app):=20picker=20dup=20gate=20=E2=80=94=202?= =?UTF-8?q?s=20server-check=20timeout,=20single=20snackbar,=20fileSize-thr?= =?UTF-8?q?ow=20allow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审跟进三项(拦截面加固,判定口径不变): - 服务器半场时间帽:checkUploadDuplicates 竞速 serverCheckTimeout (构造参数,默认 2s)——弱网下点选不再静默挂到 dio 连接超时 (10s);超时=无命中,同各半场失败降级放行(底层请求不取消, dio 自身超时照常兜底)。测试 ⑥:永不回包适配器 + 注入 50ms 帽 → 放行且请求确已发出; - SnackBar 不堆积:呈现前 clearSnackBars——快速点多个重复资产 只留当前一条(原会排队 N 条 4s 提示)。测试 ① 扩展:第二条 隐藏后无排队等待的提示条; - fileSize 失败路径钉住(测试 ⑦):资产事实 fileSize 抛错 → 放行 且两半场零查询。 Co-Authored-By: Claude Code --- apps/mobile/lib/features/uploads/enqueue.dart | 49 +++++--- .../test/features/uploads/enqueue_test.dart | 119 +++++++++++++++++- 2 files changed, 151 insertions(+), 17 deletions(-) diff --git a/apps/mobile/lib/features/uploads/enqueue.dart b/apps/mobile/lib/features/uploads/enqueue.dart index cac52b6..fb1048d 100644 --- a/apps/mobile/lib/features/uploads/enqueue.dart +++ b/apps/mobile/lib/features/uploads/enqueue.dart @@ -152,13 +152,16 @@ typedef PickerAssetFact = ({ /// 单项预检;同账号+相册,filename+bytes / bytes);**任一半场查询异常按 /// 该半场无命中处理,fileSize 失败等整体异常直接放行**——预检是体验优化 /// 而非闸门,绝不因自身故障挡住选图(enqueue 弹窗 + init 409 兜底不变)。 -/// 本机命中短路服务器预检(点选瞬间延迟考量;与批量 union 不短路不同)。 +/// 服务器半场有 [serverCheckTimeout](默认 2s)时间帽——弱网下不让点选 +/// 静默挂到 dio 连接超时(10s)。本机命中短路服务器预检(点选瞬间延迟 +/// 考量;与批量 union 不短路不同)。 /// /// 重复 → 首次点击 false + SnackBar 提示(l10n `pickerDuplicateBlocked`, /// 经谓词 ctx 向上解析到根 ScaffoldMessenger——picker 页自带的 Scaffold -/// 之上无 messenger),该 asset.id 记入放行集 → 二次点击 true(强制选择 -/// 活口)。取消选择(selected=true)恒放行且不预热放行集。per-asset -/// 判定缓存避免同一张重复查询。 +/// 之上无 messenger;呈现前 clearSnackBars——快速拦多张不排队 N 条), +/// 该 asset.id 记入放行集 → 二次点击 true(强制选择活口)。取消选择 +/// (selected=true)恒放行且不预热放行集。per-asset 判定缓存避免同一张 +/// 重复查询。 /// /// 公开(计划原定私有类)仅为让 enqueue_test 直接构造注入替身。 class PickerDuplicateGate { @@ -167,6 +170,7 @@ class PickerDuplicateGate { required this.client, required this.accountId, required this.albumId, + this.serverCheckTimeout = const Duration(seconds: 2), }); /// 本机历史半场数据源(生产=taskStoreProvider)。 @@ -181,6 +185,10 @@ class PickerDuplicateGate { /// 判定范围:目标相册(与入队目标一致)。 final String albumId; + /// 服务器半场预检时间帽(默认 2s;可注入短值做测试)。超时=无命中, + /// 同各半场失败降级放行;底层 dio 请求不取消(其自身超时照常兜底)。 + final Duration serverCheckTimeout; + /// per-asset 判定缓存(assetId → 是否重复):二次点击瞬回。 final Map _verdicts = {}; @@ -232,15 +240,21 @@ class PickerDuplicateGate { } // 层 2:服务器单项预检(filename 仅元数据——服务端谓词只看 bytes; - // title null 时传空串占位,判定不受影响)。 + // title null 时传空串占位,判定不受影响)。时间帽内未回 = 无命中 + // ——弱网下点选不静默挂到 dio 连接超时(10s)。 try { - final hits = await client.checkUploadDuplicates( - albumId, - [(filename: title ?? '', bytes: bytes)], - ); + final hits = await client + .checkUploadDuplicates( + albumId, + [(filename: title ?? '', bytes: bytes)], + ) + .timeout( + serverCheckTimeout, + onTimeout: () => const [], + ); return hits.isNotEmpty; } on Object { - // 服务器预检失败:按无命中(放行——预检故障不挡选图)。 + // 服务器预检失败/超时:按无命中(放行——预检故障不挡选图)。 return false; } } on Object { @@ -249,13 +263,16 @@ class PickerDuplicateGate { } } - /// 拦截提示(谓词 ctx → 根 ScaffoldMessenger)。 + /// 拦截提示(谓词 ctx → 根 ScaffoldMessenger)。呈现前清队列:快速 + /// 点多个重复资产时只留当前一条,不排队 N 条 4s 提示。 void _notifyBlocked(BuildContext ctx) { - ScaffoldMessenger.of(ctx).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(ctx).pickerDuplicateBlocked), - ), - ); + ScaffoldMessenger.of(ctx) + ..clearSnackBars() + ..showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(ctx).pickerDuplicateBlocked), + ), + ); } } diff --git a/apps/mobile/test/features/uploads/enqueue_test.dart b/apps/mobile/test/features/uploads/enqueue_test.dart index afc1d3e..1c82b81 100644 --- a/apps/mobile/test/features/uploads/enqueue_test.dart +++ b/apps/mobile/test/features/uploads/enqueue_test.dart @@ -12,6 +12,7 @@ /// [PickerAssetFact] 记录绕开 AssetEntity.fileSize 的平台通道。 library; +import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; @@ -285,7 +286,34 @@ void main() { ), findsOneWidget, ); - await drainBlockedSnackBar(tester, ctx); + + // 不堆积:快速点另一个重复资产 → 旧条清除后只留一条(无 N×4s 队列)。 + late bool otherBlocked; + await tester.runAsync(() async { + otherBlocked = await gate.decideFact( + ctx, + gateAsset(id: 'asset-2', bytes: kJpgBytes.length), + false, + ); + }); + expect(otherBlocked, isFalse, reason: '另一重复资产同样拦下'); + await tester.pumpAndSettle(); + expect( + find.text( + 'Already uploaded to this album — tap again to select anyway', + ), + findsOneWidget, + reason: '呈现前清旧条——当前仅一条提示', + ); + ScaffoldMessenger.of(ctx).hideCurrentSnackBar(); + await tester.pumpAndSettle(); + expect( + find.text( + 'Already uploaded to this album — tap again to select anyway', + ), + findsNothing, + reason: '隐藏后无第二条在队列等待(不堆积)', + ); }); testWidgets('② 服务器单项命中 → 拒选(单项请求携带 filename+bytes)', @@ -483,5 +511,94 @@ void main() { reason: '取消选择路径不发起任何查询(本机命中也短路服务器)'); await drainBlockedSnackBar(tester, ctx); }); + + testWidgets('⑥ 服务器预检超时(注入 50ms 帽)→ 放行(时间帽=无命中)', + (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()); + // 永不回包的适配器:请求受理但响应挂起(模拟弱网下预检吊住—— + // 不等 dio 默认超时,靠注入的短时间帽放行)。 + final neverResponds = Completer<(int, Object?)>(); + final adapter = FakeAdapter((o) => neverResponds.future); + 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', + serverCheckTimeout: const Duration(milliseconds: 50), + ); + + late bool verdict; + await tester.runAsync(() async { + verdict = await gate.decideFact( + ctx, + gateAsset(bytes: kJpgBytes.length), + false, + ); + }); + + expect(verdict, isTrue, reason: '超时=无命中 → 放行(绝不吊起点选)'); + expect(adapter.requests, hasLength(1), reason: '预检确已发出(只是没回)'); + }); + + testWidgets('⑦ fileSize 获取失败 → 放行(预检故障不挡选图)', + (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 verdict; + await tester.runAsync(() async { + verdict = await gate.decideFact( + ctx, + ( + id: 'asset-1', + title: 'pick1.jpg', + fileSize: () async => throw StateError('size failed'), + ), + false, + ); + }); + + expect(verdict, isTrue, reason: 'fileSize 失败 → 放行'); + expect(adapter.requests, isEmpty, + reason: 'fileSize 失败短路两半场——不发起任何查询'); + }); }); }