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:
@@ -23,12 +23,14 @@
|
||||
/// 落库后由引擎在 initUpload 携带 force 跳过服务端查重(仅 owner/admin
|
||||
/// 生效,服务端裁决——协作者强传仍会被 409 拒绝并可见失败文案)。
|
||||
///
|
||||
/// 方案 A(选图点选拦截):[multiImagePickerProvider] 经
|
||||
/// 方案 A v2(选图点选拦截·硬封锁):[multiImagePickerProvider] 经
|
||||
/// AssetPickerConfig.selectPredicate 挂 [PickerDuplicateGate]——点选
|
||||
/// 「已上传到目标相册」的照片当场拦下(SnackBar 提示),再次点击同一张
|
||||
/// 强制选择;判定口径同 [checkDuplicatesUnified](本机 ∪ 服务器,当前
|
||||
/// 账号+相册,bytes 为主),查询失败一律放行(enqueue 弹窗 + init 409
|
||||
/// 兜底不变)。
|
||||
/// 「已上传到目标相册」的照片当场拦下且**再次点击也不放行**(SnackBar
|
||||
/// 提示)。判定**仅查本机 TaskStore 历史**(毫秒级——v1 的服务器单项
|
||||
/// 预检在弱网下把每次点选的勾选卡到最多 2s,是「选图不灵敏」的根因);
|
||||
/// 服务器查重退至入队时刻统一弹窗(checkDuplicatesUnified)+ init 409
|
||||
/// 兜底,跨设备/重装场景的重复仍在点上传时拦。已接受限制(用户拍板):
|
||||
/// 后台已删除的照片重传同一文件会被本机历史永远拦下(清数据/换设备)。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
@@ -42,8 +44,7 @@ import 'package:path/path.dart' as p;
|
||||
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
|
||||
|
||||
import '../../core/api_client.dart'
|
||||
show ApiClient, UploadDuplicateCheckItem, UploadDuplicateHit,
|
||||
apiClientProvider;
|
||||
show UploadDuplicateHit, apiClientProvider;
|
||||
import '../../core/notification_permission.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'providers.dart';
|
||||
@@ -85,14 +86,13 @@ final singleImagePickerProvider = Provider<SingleImagePicker>(
|
||||
/// 张数;
|
||||
/// - 权限由 picker 内部请求;永久拒绝时 picker 自带 denied/limited 视图
|
||||
/// (非「返回空」);返回 null 仅发生在用户取消;
|
||||
/// - 方案 A(点选拦截):selectPredicate 挂 [PickerDuplicateGate]——已
|
||||
/// 上传到目标相册的照片点选当场拦下(再点一次强制选择)。
|
||||
/// - 方案 A v2(点选拦截·硬封锁):selectPredicate 挂 [PickerDuplicateGate]
|
||||
/// ——已上传到目标相册的照片点选当场拦下(再次点击不放行;仅本机判定)。
|
||||
final multiImagePickerProvider = Provider<MultiImagePicker>(
|
||||
(ref) => (context, {required accountId, required albumId}) async {
|
||||
// 拦截器按次构造:每次选图会话独立的判定缓存/放行集(不跨会话残留)。
|
||||
// 拦截器按次构造:每次选图会话独立的判定缓存(不跨会话残留)。
|
||||
final gate = PickerDuplicateGate(
|
||||
store: ref.read(taskStoreProvider),
|
||||
client: ref.read(apiClientProvider),
|
||||
accountId: accountId,
|
||||
albumId: albumId,
|
||||
);
|
||||
@@ -143,57 +143,49 @@ typedef PickerAssetFact = ({
|
||||
Future<int> Function() fileSize,
|
||||
});
|
||||
|
||||
/// 选图点选拦截器(方案 A):挂 wechat_assets_picker 的
|
||||
/// 选图点选拦截器(方案 A v2·硬封锁):挂 wechat_assets_picker 的
|
||||
/// `AssetPickerConfig.selectPredicate` 钩子(10.1.3 源码
|
||||
/// asset_picker_builder_delegate.dart:982——每次点选/取消时 await,返回
|
||||
/// false 静默不选)。
|
||||
/// false 静默不选;谓词返回前勾选不出现,故判定必须快)。
|
||||
///
|
||||
/// 判定口径与 [checkDuplicatesUnified] 一致(本机 TaskStore 历史 ∪ 服务器
|
||||
/// 单项预检;同账号+相册,filename+bytes / bytes);**任一半场查询异常按
|
||||
/// 该半场无命中处理,fileSize 失败等整体异常直接放行**——预检是体验优化
|
||||
/// 而非闸门,绝不因自身故障挡住选图(enqueue 弹窗 + init 409 兜底不变)。
|
||||
/// 服务器半场有 [serverCheckTimeout](默认 2s)时间帽——弱网下不让点选
|
||||
/// 静默挂到 dio 连接超时(10s)。本机命中短路服务器预检(点选瞬间延迟
|
||||
/// 考量;与批量 union 不短路不同)。
|
||||
/// v2 要点(2026-09-29 用户反馈驱动):
|
||||
/// - **仅本机判定**(同账号+相册+completed,filename+bytes)——毫秒级,
|
||||
/// 不走网络。v1 的服务器单项预检(2s 时间帽)在弱网下让每次点选卡
|
||||
/// 0.5~2s,配合「二次点击强制」活口,重复照片被连点强制选中,拦截
|
||||
/// 形同虚设。服务器查重移回入队时刻统一弹窗 + init 409 兜底。
|
||||
/// - **硬封锁**:本机命中的重复每次点击都拒选(无强制活口);提示文案
|
||||
/// 不再引导再点。
|
||||
/// - 判定缓存共享 in-flight Future(putIfAbsent)——同一照片连点只判定
|
||||
/// 一次;[_judge] 全捕获不抛错,缓存 Future 无失败态。
|
||||
/// - 查询异常(fileSize 失败/本机查询失败)一律放行——预检是体验优化
|
||||
/// 而非闸门;title 为空(iOS 常见)本机无从匹配 → 放行(跨设备判定
|
||||
/// 在入队弹窗)。
|
||||
///
|
||||
/// 重复 → 首次点击 false + SnackBar 提示(l10n `pickerDuplicateBlocked`,
|
||||
/// 经谓词 ctx 向上解析到根 ScaffoldMessenger——picker 页自带的 Scaffold
|
||||
/// 之上无 messenger;呈现前 clearSnackBars——快速拦多张不排队 N 条),
|
||||
/// 该 asset.id 记入放行集 → 二次点击 true(强制选择活口)。取消选择
|
||||
/// (selected=true)恒放行且不预热放行集。per-asset 判定缓存避免同一张
|
||||
/// 重复查询。
|
||||
/// 拦下时 SnackBar 提示(l10n `pickerDuplicateBlocked`,经谓词 ctx 向上
|
||||
/// 解析到根 ScaffoldMessenger——picker 页自带的 Scaffold 之上无
|
||||
/// messenger;呈现前 clearSnackBars——快速拦多张不排队 N 条)。取消选择
|
||||
/// (selected=true)恒放行。
|
||||
///
|
||||
/// 公开(计划原定私有类)仅为让 enqueue_test 直接构造注入替身。
|
||||
class PickerDuplicateGate {
|
||||
PickerDuplicateGate({
|
||||
required this.store,
|
||||
required this.client,
|
||||
required this.accountId,
|
||||
required this.albumId,
|
||||
this.serverCheckTimeout = const Duration(seconds: 2),
|
||||
});
|
||||
|
||||
/// 本机历史半场数据源(生产=taskStoreProvider)。
|
||||
final TaskStore store;
|
||||
|
||||
/// 服务器单项预检半场客户端(生产=apiClientProvider)。
|
||||
final ApiClient client;
|
||||
|
||||
/// 判定范围:账号(TaskStore 账号隔离硬守护的入参)。
|
||||
final String accountId;
|
||||
|
||||
/// 判定范围:目标相册(与入队目标一致)。
|
||||
final String albumId;
|
||||
|
||||
/// 服务器半场预检时间帽(默认 2s;可注入短值做测试)。超时=无命中,
|
||||
/// 同各半场失败降级放行;底层 dio 请求不取消(其自身超时照常兜底)。
|
||||
final Duration serverCheckTimeout;
|
||||
|
||||
/// per-asset 判定缓存(assetId → 是否重复):二次点击瞬回。
|
||||
final Map<String, bool> _verdicts = <String, bool>{};
|
||||
|
||||
/// 二次点击放行集(首次拦截时记入——escape hatch)。
|
||||
final Set<String> _forced = <String>{};
|
||||
/// per-asset 判定缓存(assetId → 判定 Future):in-flight 共享——
|
||||
/// 同一照片连点(判定未完成时再点)复用同一次判定;完成后秒回。
|
||||
final Map<String, Future<bool>> _verdicts = <String, Future<bool>>{};
|
||||
|
||||
/// 核心判定(测试直接驱动——绕开 AssetEntity 的平台通道 fileSize)。
|
||||
///
|
||||
@@ -204,57 +196,34 @@ class PickerDuplicateGate {
|
||||
bool selected,
|
||||
) async {
|
||||
if (selected) return true;
|
||||
if (_forced.contains(asset.id)) return true; // 二次点击:强制选择。
|
||||
final isDuplicate = _verdicts[asset.id] ?? await _judge(asset);
|
||||
_verdicts[asset.id] = isDuplicate;
|
||||
final isDuplicate = await _verdicts.putIfAbsent(
|
||||
asset.id,
|
||||
() => _judge(asset),
|
||||
);
|
||||
if (!isDuplicate) return true;
|
||||
_forced.add(asset.id);
|
||||
// 判定是异步的:picker 页可能在等待期间已被关闭(mounted=false,
|
||||
// 也就不会再有第二次点击)——跳过提示,不跨 async 间隙用 ctx。
|
||||
// 判定是异步的:picker 页可能在等待期间已被关闭(mounted=false)
|
||||
// ——跳过提示,不跨 async 间隙用 ctx。
|
||||
if (ctx.mounted) _notifyBlocked(ctx);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 判定一张是否重复(true=重复):本机 ∪ 服务器,异常降级放行。
|
||||
/// 判定一张是否重复(true=重复):仅本机历史,异常降级放行。
|
||||
Future<bool> _judge(PickerAssetFact asset) async {
|
||||
try {
|
||||
// 文件大小:两半场共用,取一次。
|
||||
final bytes = await asset.fileSize();
|
||||
|
||||
// 层 1:本机历史(同账号+相册+completed,filename+bytes)。
|
||||
final title = asset.title;
|
||||
if (title != null) {
|
||||
try {
|
||||
final completed = await store.listByAccount(
|
||||
accountId,
|
||||
albumId: albumId,
|
||||
states: const <UploadTaskState>{UploadTaskState.completed},
|
||||
);
|
||||
final hit = completed.any(
|
||||
(t) => t.filename == title && t.bytes == bytes,
|
||||
);
|
||||
if (hit) return true; // 本机命中短路服务器(点选延迟考量)。
|
||||
} on Object {
|
||||
// 本机查询失败:该半场按无命中,服务器半场仍判。
|
||||
}
|
||||
}
|
||||
|
||||
// 层 2:服务器单项预检(filename 仅元数据——服务端谓词只看 bytes;
|
||||
// title null 时传空串占位,判定不受影响)。时间帽内未回 = 无命中
|
||||
// ——弱网下点选不静默挂到 dio 连接超时(10s)。
|
||||
if (title == null) return false; // iOS 常见:本机无从匹配 → 放行。
|
||||
try {
|
||||
final hits = await client
|
||||
.checkUploadDuplicates(
|
||||
albumId,
|
||||
<UploadDuplicateCheckItem>[(filename: title ?? '', bytes: bytes)],
|
||||
)
|
||||
.timeout(
|
||||
serverCheckTimeout,
|
||||
onTimeout: () => const <UploadDuplicateHit>[],
|
||||
);
|
||||
return hits.isNotEmpty;
|
||||
final completed = await store.listByAccount(
|
||||
accountId,
|
||||
albumId: albumId,
|
||||
states: const <UploadTaskState>{UploadTaskState.completed},
|
||||
);
|
||||
return completed.any(
|
||||
(t) => t.filename == title && t.bytes == bytes,
|
||||
);
|
||||
} on Object {
|
||||
// 服务器预检失败/超时:按无命中(放行——预检故障不挡选图)。
|
||||
// 本机查询失败:放行(入队弹窗 + 409 兜底)。
|
||||
return false;
|
||||
}
|
||||
} on Object {
|
||||
|
||||
Reference in New Issue
Block a user