Files
photo-live/apps/mobile/lib/features/uploads/enqueue.dart
T
浅念 0673f394d5 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>
2026-09-29 10:35:04 +08:00

500 lines
20 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(选图点选拦截):[multiImagePickerProvider] 经
/// AssetPickerConfig.selectPredicate 挂 [PickerDuplicateGate]——点选
/// 「已上传到目标相册」的照片当场拦下(SnackBar 提示),再次点击同一张
/// 强制选择;判定口径同 [checkDuplicatesUnified](本机 ∪ 服务器,当前
/// 账号+相册,bytes 为主),查询失败一律放行(enqueue 弹窗 + 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 ApiClient, UploadDuplicateCheckItem, 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(点选拦截):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,
);
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):挂 wechat_assets_picker 的
/// `AssetPickerConfig.selectPredicate` 钩子(10.1.3 源码
/// asset_picker_builder_delegate.dart:982——每次点选/取消时 await,返回
/// false 静默不选)。
///
/// 判定口径与 [checkDuplicatesUnified] 一致(本机 TaskStore 历史 ∪ 服务器
/// 单项预检;同账号+相册,filename+bytes / bytes);**任一半场查询异常按
/// 该半场无命中处理,fileSize 失败等整体异常直接放行**——预检是体验优化
/// 而非闸门,绝不因自身故障挡住选图(enqueue 弹窗 + init 409 兜底不变)。
/// 服务器半场有 [serverCheckTimeout](默认 2s)时间帽——弱网下不让点选
/// 静默挂到 dio 连接超时(10s)。本机命中短路服务器预检(点选瞬间延迟
/// 考量;与批量 union 不短路不同)。
///
/// 重复 → 首次点击 false + SnackBar 提示(l10n `pickerDuplicateBlocked`,
/// 经谓词 ctx 向上解析到根 ScaffoldMessenger——picker 页自带的 Scaffold
/// 之上无 messenger;呈现前 clearSnackBars——快速拦多张不排队 N 条),
/// 该 asset.id 记入放行集 → 二次点击 true(强制选择活口)。取消选择
/// (selected=true)恒放行且不预热放行集。per-asset 判定缓存避免同一张
/// 重复查询。
///
/// 公开(计划原定私有类)仅为让 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>{};
/// 核心判定(测试直接驱动——绕开 AssetEntity 的平台通道 fileSize)。
///
/// [selected]=true 表示当前已选中、本次点击为**取消选择** → 恒放行。
Future<bool> decideFact(
BuildContext ctx,
PickerAssetFact asset,
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;
if (!isDuplicate) return true;
_forced.add(asset.id);
// 判定是异步的: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();
// 层 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)。
try {
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 {
// 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,
);
}