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
+33 -16
View File
@@ -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<String, bool> _verdicts = <String, bool>{};
@@ -232,15 +240,21 @@ class PickerDuplicateGate {
}
// 层 2:服务器单项预检(filename 仅元数据——服务端谓词只看 bytes;
// title null 时传空串占位,判定不受影响)。
// title null 时传空串占位,判定不受影响)。时间帽内未回 = 无命中
// ——弱网下点选不静默挂到 dio 连接超时(10s)。
try {
final hits = await client.checkUploadDuplicates(
albumId,
<UploadDuplicateCheckItem>[(filename: title ?? '', bytes: bytes)],
);
final hits = await client
.checkUploadDuplicates(
albumId,
<UploadDuplicateCheckItem>[(filename: title ?? '', bytes: bytes)],
)
.timeout(
serverCheckTimeout,
onTimeout: () => const <UploadDuplicateHit>[],
);
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),
),
);
}
}