Files
photo-live/apps/mobile/lib/features/uploads/enqueue.dart
T
浅念 f88c1c170a 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>
2026-09-29 14:24:53 +08:00

469 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 选图注入点 + 批量入队动作(P3 计划 Task 9)。
///
/// 选图的平台调用经 Provider 注入([singleImagePickerProvider]=image_picker
/// 单图;[multiImagePickerProvider]=wechat_assets_picker 应用内多选),
/// widget 测试 override 为替身——不经平台通道即可驱动入队流;
/// [enqueueForUpload] 为连拍页与相册详情 FAB 的共用入队路径:首次动作
/// 触发 POST_NOTIFICATIONS 请求(静默降级)+ 批量经准备器入队(准备器
/// 内部有界并行复制,默认并发 3,防连拍 IO 风暴)。
///
/// P0-1 层 1(UI06 分析 v2):入队前**本地查重**——同账号 + 同相册 +
/// state=uploaded + filename+bytes 匹配 → 返回 [DuplicateMatch],由
/// 调用方决定弹窗确认或跳过。批量场景汇总后单次弹窗(非逐张)。
/// 本地匹配经统一入口 [checkDuplicatesUnified] 驱动(原独立入口
/// checkDuplicates 已折叠为私有半场,仓库内无其他调用方)。
///
/// Task 2(查重前置到入队前·统一入口):入队前查重升级为
/// [checkDuplicatesUnified]——本机 TaskStore 历史 ∪ 服务器历史
/// (duplicates/check 只读预检,bytes 命中);服务器失败静默降级
/// 本机-only,不阻塞入队。
///
/// Task 8(服务端查重 App 侧集成):弹窗「全部上传」= 用户确认强传——
/// 重复项以 force=true 入队([enqueueForUpload] 的 forcePaths),任务行
/// 落库后由引擎在 initUpload 携带 force 跳过服务端查重(仅 owner/admin
/// 生效,服务端裁决——协作者强传仍会被 409 拒绝并可见失败文案)。
///
/// 方案 A v2(选图点选拦截·硬封锁):[multiImagePickerProvider] 经
/// AssetPickerConfig.selectPredicate 挂 [PickerDuplicateGate]——点选
/// 「已上传到目标相册」的照片当场拦下且**再次点击也不放行**(SnackBar
/// 提示)。判定**仅查本机 TaskStore 历史**(毫秒级——v1 的服务器单项
/// 预检在弱网下把每次点选的勾选卡到最多 2s,是「选图不灵敏」的根因);
/// 服务器查重退至入队时刻统一弹窗(checkDuplicatesUnified)+ init 409
/// 兜底,跨设备/重装场景的重复仍在点上传时拦。已接受限制(用户拍板):
/// 后台已删除的照片重传同一文件会被本机历史永远拦下(清数据/换设备)。
library;
import 'dart:io';
import 'package:flutter/material.dart' show ScaffoldMessenger, SnackBar;
import 'package:flutter/widgets.dart'
show BuildContext, Localizations, Text;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart' as p;
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
import '../../core/api_client.dart'
show UploadDuplicateHit, apiClientProvider;
import '../../core/notification_permission.dart';
import '../../l10n/app_localizations.dart';
import 'providers.dart';
import 'task_state.dart';
import 'task_store.dart';
/// 单图选择(准备失败重试的重新选图路径);返回源文件路径或 null(取消)。
typedef SingleImagePicker = Future<String?> Function();
/// 多图选择(相册多选入队);返回源文件路径列表(空=取消)。
///
/// [accountId]/[albumId] 为点选拦截的判定范围(方案 A:当前目标相册;
/// 与入队目标同源)——两处调用点(相册详情/连拍页)均持有,必传。
typedef MultiImagePicker = Future<List<String>> Function(
BuildContext context, {
required String accountId,
required String albumId,
});
/// 单图选择 Provider(生产=image_picker;测试 override)。
final singleImagePickerProvider = Provider<SingleImagePicker>(
(ref) => () async {
final file = await ImagePicker().pickImage(source: ImageSource.gallery);
return file?.path;
},
);
/// 多图选择 Provider(生产=wechat_assets_picker;测试 override)。
///
/// - 应用内选择器:长按进入多选 + 拖动连选 + 全选;无系统 Photo Picker
/// 的 100 张硬上限(软上限 9999 防极端内存,photo_manager 网格分页
/// 加载,实际不受限);
/// - originFile 取原始文件路径:API 30+ 直接返回原路径;Android 10 非
/// legacy 经 ScopedCache 复制到应用缓存(较慢、占缓存空间)。路径
/// 解析并行发起(批量数百张时避免串行平台通道往返阻塞入队启动;
/// 平台通道调用在平台线程自然串行化,无 IO 风暴);
/// - 单个资产解析失败(选择后被删/云下架等 PlatformException)仅跳过
/// 该张(null 过滤),不丢弃整批;入队数由调用方 SnackBar 反映实际
/// 张数;
/// - 权限由 picker 内部请求;永久拒绝时 picker 自带 denied/limited 视图
/// (非「返回空」);返回 null 仅发生在用户取消;
/// - 方案 A v2(点选拦截·硬封锁):selectPredicate 挂 [PickerDuplicateGate]
/// ——已上传到目标相册的照片点选当场拦下(再次点击不放行;仅本机判定)。
final multiImagePickerProvider = Provider<MultiImagePicker>(
(ref) => (context, {required accountId, required albumId}) async {
// 拦截器按次构造:每次选图会话独立的判定缓存(不跨会话残留)。
final gate = PickerDuplicateGate(
store: ref.read(taskStoreProvider),
accountId: accountId,
albumId: albumId,
);
final locale = Localizations.maybeLocaleOf(context);
final assets = await AssetPicker.pickAssets(
context,
pickerConfig: AssetPickerConfig(
requestType: RequestType.image,
maxAssets: kPickerSoftMaxAssets,
textDelegate: assetPickerTextDelegateFromLocale(locale),
// 点选拦截(selectPredicate 签名 FutureOr<bool> Function(ctx,
// asset, selected)——false 静默不选);AssetEntity →
// [PickerAssetFact] 适配(fileSize 惰性求值)在此完成。
selectPredicate: (ctx, asset, selected) => gate.decideFact(
ctx,
(id: asset.id, title: asset.title, fileSize: () => asset.fileSize),
selected,
),
),
);
if (assets == null) return const <String>[];
final files = await Future.wait(
assets.map((a) => a.originFile.then((f) => f, onError: (_) => null)),
);
return [
for (final file in files)
if (file != null) file.path,
];
},
);
/// 选图软上限(用户要求不设硬限制;9999 为防误触极端值)。
const int kPickerSoftMaxAssets = 9999;
/// 拦截器所需的最小资产事实([PickerDuplicateGate] 的测试接缝)。
///
/// AssetEntity → 此记录的适配在 [multiImagePickerProvider] 内完成:
/// - `fileSize`:photo_manager 3.12 为 `Future<int> get fileSize`
/// (entity.dart:778——平台通道异步,无同步 length 字段);以惰性闭包
/// 传入,缓存命中/取消选择/二次点击路径均不发起查询;
/// - `title`:`String?`——iOS 需 FilterOption.needTitle 才非空
/// (wechat_assets_picker 默认 filterOptions 仅对 audio 开),Android
/// 侧无条件携带(ConvertUtils.kt 无条件 `title to entity.displayName`);
/// null → 本机半场跳过(服务器按 bytes 仍可判)。
typedef PickerAssetFact = ({
String id,
String? title,
Future<int> Function() fileSize,
});
/// 选图点选拦截器(方案 A v2·硬封锁):挂 wechat_assets_picker 的
/// `AssetPickerConfig.selectPredicate` 钩子(10.1.3 源码
/// asset_picker_builder_delegate.dart:982——每次点选/取消时 await,返回
/// false 静默不选;谓词返回前勾选不出现,故判定必须快)。
///
/// v2 要点(2026-09-29 用户反馈驱动):
/// - **仅本机判定**(同账号+相册+completed,filename+bytes)——毫秒级,
/// 不走网络。v1 的服务器单项预检(2s 时间帽)在弱网下让每次点选卡
/// 0.5~2s,配合「二次点击强制」活口,重复照片被连点强制选中,拦截
/// 形同虚设。服务器查重移回入队时刻统一弹窗 + init 409 兜底。
/// - **硬封锁**:本机命中的重复每次点击都拒选(无强制活口);提示文案
/// 不再引导再点。
/// - 判定缓存共享 in-flight Future(putIfAbsent)——同一照片连点只判定
/// 一次;[_judge] 全捕获不抛错,缓存 Future 无失败态。
/// - 查询异常(fileSize 失败/本机查询失败)一律放行——预检是体验优化
/// 而非闸门;title 为空(iOS 常见)本机无从匹配 → 放行(跨设备判定
/// 在入队弹窗)。
///
/// 拦下时 SnackBar 提示(l10n `pickerDuplicateBlocked`,经谓词 ctx 向上
/// 解析到根 ScaffoldMessenger——picker 页自带的 Scaffold 之上无
/// messenger;呈现前 clearSnackBars——快速拦多张不排队 N 条)。取消选择
/// (selected=true)恒放行。
///
/// 公开(计划原定私有类)仅为让 enqueue_test 直接构造注入替身。
class PickerDuplicateGate {
PickerDuplicateGate({
required this.store,
required this.accountId,
required this.albumId,
});
/// 本机历史半场数据源(生产=taskStoreProvider)。
final TaskStore store;
/// 判定范围:账号(TaskStore 账号隔离硬守护的入参)。
final String accountId;
/// 判定范围:目标相册(与入队目标一致)。
final String albumId;
/// per-asset 判定缓存(assetId → 判定 Future):in-flight 共享——
/// 同一照片连点(判定未完成时再点)复用同一次判定;完成后秒回。
final Map<String, Future<bool>> _verdicts = <String, Future<bool>>{};
/// 核心判定(测试直接驱动——绕开 AssetEntity 的平台通道 fileSize)。
///
/// [selected]=true 表示当前已选中、本次点击为**取消选择** → 恒放行。
Future<bool> decideFact(
BuildContext ctx,
PickerAssetFact asset,
bool selected,
) async {
if (selected) return true;
final isDuplicate = await _verdicts.putIfAbsent(
asset.id,
() => _judge(asset),
);
if (!isDuplicate) return true;
// 判定是异步的:picker 页可能在等待期间已被关闭(mounted=false)
// ——跳过提示,不跨 async 间隙用 ctx。
if (ctx.mounted) _notifyBlocked(ctx);
return false;
}
/// 判定一张是否重复(true=重复):仅本机历史,异常降级放行。
Future<bool> _judge(PickerAssetFact asset) async {
try {
final bytes = await asset.fileSize();
final title = asset.title;
if (title == null) return false; // iOS 常见:本机无从匹配 → 放行。
try {
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 {
// fileSize 失败等任意异常:放行。
return false;
}
}
/// 拦截提示(谓词 ctx → 根 ScaffoldMessenger)。呈现前清队列:快速
/// 点多个重复资产时只留当前一条,不排队 N 条 4s 提示。
void _notifyBlocked(BuildContext ctx) {
ScaffoldMessenger.of(ctx)
..clearSnackBars()
..showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(ctx).pickerDuplicateBlocked),
),
);
}
}
/// 查重弹窗选择结果(Task 8):null=取消(不上传)。
///
/// 「跳过重复」→ skipPaths=重复项(仅非重复入队);「全部上传」→
/// forcePaths=重复项(强传,见库级注释)。两个集合互斥且可为空。
typedef DuplicateDialogChoice
= ({Set<String> skipPaths, Set<String> forcePaths});
/// 查重结果(单张):匹配到的已上传任务摘要。
class DuplicateMatch {
const DuplicateMatch({
required this.sourcePath,
required this.filename,
required this.bytes,
required this.uploadedAt,
});
/// 本批照片的完整源路径:弹窗/入队按路径精确派生重复项——同 basename
/// 不同目录/扩展名的兄弟文件不误伤(Task 2 评审 #2)。
final String sourcePath;
final String filename;
final int bytes;
final DateTime uploadedAt;
}
/// 入队结果(P0-1:含查重信息)。
class EnqueueResult {
const EnqueueResult({
required this.enqueued,
required this.duplicates,
this.skipped = 0,
});
/// 成功入队张数(state=queued)。
final int enqueued;
/// 与历史已上传任务匹配的项(需用户确认)。
final List<DuplicateMatch> duplicates;
/// 用户选择跳过的重复项张数。
final int skipped;
}
/// 本批照片的 stat 结果(路径 + 派生 filename + 字节数;层 1/层 2 查重
/// 共用——文件只 stat 一次,Task 2 评审 #3)。
typedef _PickStat = ({String path, String filename, int bytes});
/// stat 本批文件([checkDuplicatesUnified] 专用;读不到的文件跳过,
/// 入队时准备器会报错)。
Future<List<_PickStat>> _statPicks(List<String> sourcePaths) async {
final picks = <_PickStat>[];
for (final path in sourcePaths) {
try {
final file = File(path);
picks.add((
path: path,
filename: p.basename(path),
bytes: await file.length(),
));
} on Object {
// 读不到的文件不参与查重(入队时准备器会报错)。
}
}
return picks;
}
/// 层 1 本机匹配:TaskStore 同账号 + 同相册 + state=completed 的任务与
/// 本批 stat 结果做 filename + bytes 匹配(原公共 checkDuplicates 的
/// 内核,折叠为统一入口的私有半场——仓库内已无其他调用方)。
Future<List<DuplicateMatch>> _matchLocalHistory(
WidgetRef ref, {
required String accountId,
required String albumId,
required List<_PickStat> picks,
}) async {
// 查同相册已完成的任务。
final store = ref.read(taskStoreProvider);
final completed = await store.listByAccount(
accountId,
albumId: albumId,
states: {UploadTaskState.completed},
);
// 匹配 filename + bytes。
final matches = <DuplicateMatch>[];
for (final task in completed) {
for (final pick in picks) {
if (task.filename == pick.filename && task.bytes == pick.bytes) {
matches.add(
DuplicateMatch(
sourcePath: pick.path,
filename: pick.filename,
bytes: pick.bytes,
uploadedAt: task.createdAt,
),
);
break; // 一对一即可(同一历史任务不重复匹配多张同内容照片)。
}
}
}
return matches;
}
/// 统一查重(P0-1 层 1+层 2 前置,Task 2):本机 TaskStore 历史 ∪
/// 服务器历史(POST /uploads/duplicates/check;服务端谓词仅 bytes——
/// filename 刻意不参与,与 initUpload 查重一致)。
///
/// 服务器预检失败(网络/权限/任意异常)→ 静默降级本机-only(不阻塞
/// 入队;服务端 init 409 仍是兜底)。返回 null = 无重复。
///
/// 本批文件只 stat 一次([_statPicks],两层共用);服务器命中按全局
/// 下标映射回 stat 结果(filename/sourcePath 由此生效);合并按
/// sourcePath 去重——同一文件双命中只呈现一条,本机命中在前(本地
/// uploadedAt 优先,Task 2 评审 #1)。
Future<List<DuplicateMatch>?> checkDuplicatesUnified(
WidgetRef ref, {
required String accountId,
required String albumId,
required List<String> sourcePaths,
}) async {
if (sourcePaths.isEmpty) return null;
// stat 一次,层 1/层 2 共用。
final picks = await _statPicks(sourcePaths);
if (picks.isEmpty) return null;
// 层 1:本机 TaskStore 历史(filename+bytes 匹配)。
final local = await _matchLocalHistory(
ref,
accountId: accountId,
albumId: albumId,
picks: picks,
);
// 层 2:服务器历史(bytes 命中,逐请求条目一条;失败静默降级)。
final serverHits = <UploadDuplicateHit>[];
try {
serverHits.addAll(
await ref
.read(apiClientProvider)
.checkUploadDuplicates(
albumId,
[
for (final pick in picks)
(filename: pick.filename, bytes: pick.bytes),
],
),
);
} on Object {
// 静默降级本机-only:预检是体验优化而非闸门,失败不阻塞入队
//(服务端 init 409 仍是兜底)。
}
// 合并去重(sourcePath 唯一标识一张本批照片;putIfAbsent 保留先见
// 者——本机在前,故同一重复的 uploadedAt 取本地历史)。
final byPath = <String, DuplicateMatch>{};
for (final match in [
...local,
for (final hit in serverHits)
if (hit.index >= 0 && hit.index < picks.length)
DuplicateMatch(
sourcePath: picks[hit.index].path,
filename: picks[hit.index].filename,
bytes: hit.bytes,
uploadedAt: hit.uploadedAt,
),
]) {
byPath.putIfAbsent(match.sourcePath, () => match);
}
return byPath.isEmpty ? null : byPath.values.toList();
}
/// 批量入队:通知权限单次请求 + 逐张准备入队。
///
/// [skipPaths]:用户选择跳过的重复项;[forcePaths]:查重弹窗「全部上传」
/// 确认强传的重复项(Task 8——落到任务行 force 标记,引擎 initUpload
/// 携带;仅 owner/admin 生效,服务端裁决)。两者互斥。
///
/// 返回 [EnqueueResult](含 enqueued 计数与 duplicates 列表)。
/// 准备失败的任务已落库为 preparationFailed(连拍页失败徽章/队列页可见,
/// 不在此抛出)。
Future<EnqueueResult> enqueueForUpload(
WidgetRef ref, {
required String accountId,
required String albumId,
String? categoryId,
required List<String> sourcePaths,
List<String>? skipPaths,
List<String>? forcePaths,
}) async {
// 首次入队动作触发(幂等;拒绝/非 Android 静默降级,见实现注释)。
await ref.read(notificationPermissionProvider).ensureRequested();
final preparer = ref.read(uploadPreparerProvider);
// P0-1:跳过用户选择取消的重复项。
final skipSet = skipPaths?.toSet() ?? <String>{};
final effective = sourcePaths
.where((path) => !skipSet.contains(path))
.toList();
// Task 8:「全部上传」确认的强传项(重复项 force=true 落任务行)。
final forceSet = forcePaths?.toSet() ?? <String>{};
final results = await Future.wait(
effective.map((path) => preparer.prepare(
accountId: accountId,
albumId: albumId,
categoryId: categoryId,
sourcePath: path,
force: forceSet.contains(path),
)),
);
final enqueued = results
.where((task) => task.state == UploadTaskState.queued)
.length;
return EnqueueResult(
enqueued: enqueued,
duplicates: [],
skipped: sourcePaths.length - effective.length,
);
}