feat(app): 选图拦截 v2——硬封锁+仅本机判定,治点选卡顿与连点强穿
问题(用户反馈):v1 服务器单项预检(2s 帽)弱网下把每次点选的勾选卡 0.5~2s(selectPredicate await 期间 selectAsset 不执行);配合「二次点击 强制选择」活口,重复照片被连点强制选中,拦截形同虚设。 - 判定仅查本机 TaskStore(毫秒级)——服务器查重退至入队统一弹窗+409 兜底 - 硬封锁:本机命中每次点击均拒选(_forced 放行集移除),文案不再引导再点 - 判定缓存升级 in-flight Future 共享(putIfAbsent):同一照片连点只判一次 (清偿 9-29 评审登记 Minor 遗留) - 构造去 client/serverCheckTimeout;iOS title 空放行(跨设备判定在入队弹窗) - 已接受限制(用户拍板 A):后台已删照片重传同文件被本机历史永拦 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,6 @@
|
||||
/// [PickerAssetFact] 记录绕开 AssetEntity.fileSize 的平台通道。
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
@@ -163,7 +162,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
group('选图点选拦截器 PickerDuplicateGate(方案 A)', () {
|
||||
group('选图点选拦截器 PickerDuplicateGate(v2 硬封锁:仅本机判定)', () {
|
||||
/// 宿主树:MaterialApp(含根 ScaffoldMessenger + l10n)——SnackBar 提示
|
||||
/// 与文案断言的载体;测试环境默认 en locale(同页级测试)。
|
||||
Future<void> pumpGateHost(
|
||||
@@ -198,18 +197,20 @@ void main() {
|
||||
fileSize: () async => bytes,
|
||||
);
|
||||
|
||||
/// 种子:同账号+相册的已完成任务(本机半场命中源;runAsync 区内调用)。
|
||||
/// 种子:同账号+相册的已完成任务(本机命中源;runAsync 区内调用)。
|
||||
Future<void> seedCompletedTask(
|
||||
TaskStore store,
|
||||
String filename,
|
||||
int bytes,
|
||||
) async {
|
||||
int bytes, {
|
||||
String accountId = 'alice',
|
||||
String albumId = 'alb-1',
|
||||
}) async {
|
||||
final seed = await store.create(
|
||||
UploadTaskDraft(
|
||||
id: 'task-seed',
|
||||
accountId: 'alice',
|
||||
albumId: 'alb-1',
|
||||
clientTaskId: 'ct-seed',
|
||||
id: 'task-seed-$accountId-$albumId',
|
||||
accountId: accountId,
|
||||
albumId: albumId,
|
||||
clientTaskId: 'ct-seed-$accountId-$albumId',
|
||||
sourceFilePath: '/tmp/seed.jpg',
|
||||
filename: filename,
|
||||
bytes: bytes,
|
||||
@@ -234,56 +235,41 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('① 本机命中 → 首次拒选+提示;二次点击强制放行;本机命中短路服务器预检',
|
||||
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': <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',
|
||||
);
|
||||
final asset = gateAsset(bytes: kJpgBytes.length);
|
||||
|
||||
late bool first;
|
||||
late bool second;
|
||||
late List<bool> verdicts;
|
||||
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);
|
||||
verdicts = [
|
||||
await gate.decideFact(ctx, asset, false),
|
||||
await gate.decideFact(ctx, asset, false),
|
||||
await gate.decideFact(ctx, asset, false),
|
||||
];
|
||||
});
|
||||
|
||||
expect(first, isFalse, reason: '本机历史命中 → 首次点选拦下');
|
||||
expect(second, isTrue, reason: '二次点击同一张 → 强制选择(escape hatch)');
|
||||
expect(adapter.requests, isEmpty,
|
||||
reason: '本机命中短路服务器单项预检(点选延迟考量)');
|
||||
expect(
|
||||
verdicts,
|
||||
everyElement(isFalse),
|
||||
reason: 'v2 硬封锁:连点三次均拒选(二次点击强制已移除)',
|
||||
);
|
||||
|
||||
// 拦截提示:谓词 ctx 经根 messenger 呈现 SnackBar(en 文案)。
|
||||
// 拦截提示:谓词 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',
|
||||
),
|
||||
find.text('This photo has already been uploaded to this album'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
@@ -299,286 +285,157 @@ void main() {
|
||||
expect(otherBlocked, isFalse, reason: '另一重复资产同样拦下');
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text(
|
||||
'Already uploaded to this album — tap again to select anyway',
|
||||
),
|
||||
find.text('This photo has already been uploaded to this album'),
|
||||
findsOneWidget,
|
||||
reason: '呈现前清旧条——当前仅一条提示',
|
||||
);
|
||||
ScaffoldMessenger.of(ctx).hideCurrentSnackBar();
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text(
|
||||
'Already uploaded to this album — tap again to select anyway',
|
||||
),
|
||||
find.text('This photo has already been uploaded to this album'),
|
||||
findsNothing,
|
||||
reason: '隐藏后无第二条在队列等待(不堆积)',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('② 服务器单项命中 → 拒选(单项请求携带 filename+bytes)',
|
||||
(tester) async {
|
||||
final session = FakeSessionStore()
|
||||
..session = Session(
|
||||
token: 'tok-1',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'alice',
|
||||
);
|
||||
testWidgets('② 非重复(bytes/文件名/相册/账号 任一不同)→ 放行', (
|
||||
tester,
|
||||
) async {
|
||||
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(
|
||||
await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length);
|
||||
});
|
||||
|
||||
late bool diffBytes;
|
||||
late bool diffName;
|
||||
late bool diffAlbum;
|
||||
late bool diffAccount;
|
||||
await tester.runAsync(() async {
|
||||
diffBytes = await gate.decideFact(
|
||||
ctx,
|
||||
gateAsset(bytes: kJpgBytes.length),
|
||||
gateAsset(id: 'a-bytes', bytes: 12345),
|
||||
false,
|
||||
);
|
||||
diffName = await gate.decideFact(
|
||||
ctx,
|
||||
gateAsset(id: 'a-name', title: 'other.jpg', bytes: kJpgBytes.length),
|
||||
false,
|
||||
);
|
||||
// 判定范围(相册/账号)不同 → 各自构造 gate 验证隔离。
|
||||
diffAlbum = await PickerDuplicateGate(
|
||||
store: store,
|
||||
accountId: 'alice',
|
||||
albumId: 'alb-2',
|
||||
).decideFact(
|
||||
ctx,
|
||||
gateAsset(id: 'a-album', bytes: kJpgBytes.length),
|
||||
false,
|
||||
);
|
||||
diffAccount = await PickerDuplicateGate(
|
||||
store: store,
|
||||
accountId: 'bob',
|
||||
albumId: 'alb-1',
|
||||
).decideFact(
|
||||
ctx,
|
||||
gateAsset(id: 'a-account', bytes: kJpgBytes.length),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
expect(first, isFalse, reason: '服务器历史命中 → 拒选');
|
||||
expect(adapter.requests, hasLength(1), reason: '单项预检恰好一次');
|
||||
final body = adapter.requests.single.body as Map<String, dynamic>;
|
||||
expect(body['items'], hasLength(1));
|
||||
expect(diffBytes, isTrue, reason: '字节数不同 → 非重复');
|
||||
expect(diffName, isTrue, reason: '文件名不同 → 非重复');
|
||||
expect(diffAlbum, isTrue, reason: '其它相册的历史不算本相册重复');
|
||||
expect(diffAccount, isTrue, reason: '其它账号的历史不算本人重复');
|
||||
});
|
||||
|
||||
testWidgets('③ in-flight 共享:同一资产并发连点只判定一次', (tester) async {
|
||||
final store = TaskStore.memory();
|
||||
addTearDown(() => store.close());
|
||||
|
||||
late BuildContext ctx;
|
||||
await pumpGateHost(tester, (c) => ctx = c);
|
||||
final gate = PickerDuplicateGate(
|
||||
store: store,
|
||||
accountId: 'alice',
|
||||
albumId: 'alb-1',
|
||||
);
|
||||
var sizeCalls = 0;
|
||||
final asset = (
|
||||
id: 'asset-1',
|
||||
title: 'pick1.jpg' as String?,
|
||||
fileSize: () async {
|
||||
sizeCalls++;
|
||||
return kJpgBytes.length;
|
||||
},
|
||||
);
|
||||
|
||||
late List<bool> verdicts;
|
||||
await tester.runAsync(() async {
|
||||
await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length);
|
||||
verdicts = await Future.wait([
|
||||
gate.decideFact(ctx, asset, false),
|
||||
gate.decideFact(ctx, asset, false),
|
||||
]);
|
||||
});
|
||||
|
||||
expect(verdicts, everyElement(isFalse), reason: '并发两次均按重复拦下');
|
||||
expect(
|
||||
(body['items'] as List<Object?>).single,
|
||||
{'filename': 'pick1.jpg', 'bytes': kJpgBytes.length},
|
||||
reason: '请求条目 = asset.title + 文件字节数',
|
||||
sizeCalls,
|
||||
1,
|
||||
reason: '判定缓存共享 in-flight Future——连点只查一次(清偿登记遗留)',
|
||||
);
|
||||
await drainBlockedSnackBar(tester, ctx);
|
||||
});
|
||||
|
||||
testWidgets('③ 两路皆空 → 放行(判定缓存:二次调用不再发预检)',
|
||||
(tester) async {
|
||||
final session = FakeSessionStore()
|
||||
..session = Session(
|
||||
token: 'tok-1',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'alice',
|
||||
);
|
||||
testWidgets('④ title 为空(iOS 场景)→ 放行(本机无从匹配)', (
|
||||
tester,
|
||||
) async {
|
||||
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 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,
|
||||
);
|
||||
await seedCompletedTask(store, 'pick1.jpg', kJpgBytes.length);
|
||||
});
|
||||
|
||||
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),
|
||||
gateAsset(id: 'a-null', title: null, bytes: kJpgBytes.length),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
expect(verdict, isTrue,
|
||||
reason: '预检异常放行——enqueue 弹窗 + init 409 仍是兜底');
|
||||
expect(adapter.requests, hasLength(1));
|
||||
reason: 'title 空 → 本机半场无从匹配 → 放行(服务器判定在入队弹窗)');
|
||||
});
|
||||
|
||||
testWidgets('⑤ 取消选择恒放行(不预热放行集,不发起查询)', (tester) async {
|
||||
final session = FakeSessionStore()
|
||||
..session = Session(
|
||||
token: 'tok-1',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'alice',
|
||||
);
|
||||
testWidgets('⑤ fileSize 获取失败 → 放行(判定故障不挡选图)', (
|
||||
tester,
|
||||
) async {
|
||||
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',
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
@@ -597,8 +454,41 @@ void main() {
|
||||
});
|
||||
|
||||
expect(verdict, isTrue, reason: 'fileSize 失败 → 放行');
|
||||
expect(adapter.requests, isEmpty,
|
||||
reason: 'fileSize 失败短路两半场——不发起任何查询');
|
||||
});
|
||||
|
||||
testWidgets('⑥ 取消选择恒放行且零查询;再点选仍按重复拦下', (tester) async {
|
||||
final store = TaskStore.memory();
|
||||
addTearDown(() => store.close());
|
||||
|
||||
late BuildContext ctx;
|
||||
await pumpGateHost(tester, (c) => ctx = c);
|
||||
final gate = PickerDuplicateGate(
|
||||
store: store,
|
||||
accountId: 'alice',
|
||||
albumId: 'alb-1',
|
||||
);
|
||||
var sizeCalls = 0;
|
||||
final asset = (
|
||||
id: 'asset-1',
|
||||
title: 'pick1.jpg' as String?,
|
||||
fileSize: () async {
|
||||
sizeCalls++;
|
||||
return 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(sizeCalls, 1, reason: '仅再点选触发一次判定;取消路径零查询');
|
||||
expect(reselect, isFalse, reason: '再点选仍按重复拦下(硬封锁)');
|
||||
await drainBlockedSnackBar(tester, ctx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user