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:
@@ -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 失败短路两半场——不发起任何查询');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user