fix(app): picker dup gate — 2s server-check timeout, single snackbar, fileSize-throw allow

评审跟进三项(拦截面加固,判定口径不变):

- 服务器半场时间帽:checkUploadDuplicates 竞速 serverCheckTimeout
  (构造参数,默认 2s)——弱网下点选不再静默挂到 dio 连接超时
  (10s);超时=无命中,同各半场失败降级放行(底层请求不取消,
  dio 自身超时照常兜底)。测试 ⑥:永不回包适配器 + 注入 50ms 帽
  → 放行且请求确已发出;
- SnackBar 不堆积:呈现前 clearSnackBars——快速点多个重复资产
  只留当前一条(原会排队 N 条 4s 提示)。测试 ① 扩展:第二条
  隐藏后无排队等待的提示条;
- fileSize 失败路径钉住(测试 ⑦):资产事实 fileSize 抛错 → 放行
  且两半场零查询。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-29 10:35:04 +08:00
parent 7f8cda1cd3
commit 0673f394d5
2 changed files with 151 additions and 17 deletions
+26 -9
View File
@@ -152,13 +152,16 @@ typedef PickerAssetFact = ({
/// 单项预检;同账号+相册,filename+bytes / bytes);**任一半场查询异常按 /// 单项预检;同账号+相册,filename+bytes / bytes);**任一半场查询异常按
/// 该半场无命中处理,fileSize 失败等整体异常直接放行**——预检是体验优化 /// 该半场无命中处理,fileSize 失败等整体异常直接放行**——预检是体验优化
/// 而非闸门,绝不因自身故障挡住选图(enqueue 弹窗 + init 409 兜底不变)。 /// 而非闸门,绝不因自身故障挡住选图(enqueue 弹窗 + init 409 兜底不变)。
/// 本机命中短路服务器预检(点选瞬间延迟考量;与批量 union 不短路不同)。 /// 服务器半场有 [serverCheckTimeout](默认 2s)时间帽——弱网下不让点选
/// 静默挂到 dio 连接超时(10s)。本机命中短路服务器预检(点选瞬间延迟
/// 考量;与批量 union 不短路不同)。
/// ///
/// 重复 → 首次点击 false + SnackBar 提示(l10n `pickerDuplicateBlocked`, /// 重复 → 首次点击 false + SnackBar 提示(l10n `pickerDuplicateBlocked`,
/// 经谓词 ctx 向上解析到根 ScaffoldMessenger——picker 页自带的 Scaffold /// 经谓词 ctx 向上解析到根 ScaffoldMessenger——picker 页自带的 Scaffold
/// 之上无 messenger),该 asset.id 记入放行集 → 二次点击 true(强制选择 /// 之上无 messenger;呈现前 clearSnackBars——快速拦多张不排队 N 条),
/// 活口)。取消选择(selected=true)恒放行且不预热放行集。per-asset /// 该 asset.id 记入放行集 → 二次点击 true(强制选择活口)。取消选择
/// 判定缓存避免同一张重复查询。 /// (selected=true)恒放行且不预热放行集。per-asset 判定缓存避免同一张
/// 重复查询。
/// ///
/// 公开(计划原定私有类)仅为让 enqueue_test 直接构造注入替身。 /// 公开(计划原定私有类)仅为让 enqueue_test 直接构造注入替身。
class PickerDuplicateGate { class PickerDuplicateGate {
@@ -167,6 +170,7 @@ class PickerDuplicateGate {
required this.client, required this.client,
required this.accountId, required this.accountId,
required this.albumId, required this.albumId,
this.serverCheckTimeout = const Duration(seconds: 2),
}); });
/// 本机历史半场数据源(生产=taskStoreProvider)。 /// 本机历史半场数据源(生产=taskStoreProvider)。
@@ -181,6 +185,10 @@ class PickerDuplicateGate {
/// 判定范围:目标相册(与入队目标一致)。 /// 判定范围:目标相册(与入队目标一致)。
final String albumId; final String albumId;
/// 服务器半场预检时间帽(默认 2s;可注入短值做测试)。超时=无命中,
/// 同各半场失败降级放行;底层 dio 请求不取消(其自身超时照常兜底)。
final Duration serverCheckTimeout;
/// per-asset 判定缓存(assetId → 是否重复):二次点击瞬回。 /// per-asset 判定缓存(assetId → 是否重复):二次点击瞬回。
final Map<String, bool> _verdicts = <String, bool>{}; final Map<String, bool> _verdicts = <String, bool>{};
@@ -232,15 +240,21 @@ class PickerDuplicateGate {
} }
// 层 2:服务器单项预检(filename 仅元数据——服务端谓词只看 bytes; // 层 2:服务器单项预检(filename 仅元数据——服务端谓词只看 bytes;
// title null 时传空串占位,判定不受影响)。 // title null 时传空串占位,判定不受影响)。时间帽内未回 = 无命中
// ——弱网下点选不静默挂到 dio 连接超时(10s)。
try { try {
final hits = await client.checkUploadDuplicates( final hits = await client
.checkUploadDuplicates(
albumId, albumId,
<UploadDuplicateCheckItem>[(filename: title ?? '', bytes: bytes)], <UploadDuplicateCheckItem>[(filename: title ?? '', bytes: bytes)],
)
.timeout(
serverCheckTimeout,
onTimeout: () => const <UploadDuplicateHit>[],
); );
return hits.isNotEmpty; return hits.isNotEmpty;
} on Object { } on Object {
// 服务器预检失败:按无命中(放行——预检故障不挡选图)。 // 服务器预检失败/超时:按无命中(放行——预检故障不挡选图)。
return false; return false;
} }
} on Object { } on Object {
@@ -249,9 +263,12 @@ class PickerDuplicateGate {
} }
} }
/// 拦截提示(谓词 ctx → 根 ScaffoldMessenger)。 /// 拦截提示(谓词 ctx → 根 ScaffoldMessenger)。呈现前清队列:快速
/// 点多个重复资产时只留当前一条,不排队 N 条 4s 提示。
void _notifyBlocked(BuildContext ctx) { void _notifyBlocked(BuildContext ctx) {
ScaffoldMessenger.of(ctx).showSnackBar( ScaffoldMessenger.of(ctx)
..clearSnackBars()
..showSnackBar(
SnackBar( SnackBar(
content: Text(AppLocalizations.of(ctx).pickerDuplicateBlocked), content: Text(AppLocalizations.of(ctx).pickerDuplicateBlocked),
), ),
@@ -12,6 +12,7 @@
/// [PickerAssetFact] 记录绕开 AssetEntity.fileSize 的平台通道。 /// [PickerAssetFact] 记录绕开 AssetEntity.fileSize 的平台通道。
library; library;
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
@@ -285,7 +286,34 @@ void main() {
), ),
findsOneWidget, 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)', testWidgets('② 服务器单项命中 → 拒选(单项请求携带 filename+bytes)',
@@ -483,5 +511,94 @@ void main() {
reason: '取消选择路径不发起任何查询(本机命中也短路服务器)'); reason: '取消选择路径不发起任何查询(本机命中也短路服务器)');
await drainBlockedSnackBar(tester, ctx); 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': <Map<String, dynamic>>[]}),
);
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 失败短路两半场——不发起任何查询');
});
}); });
} }