Files
photo-live/apps/mobile/lib/features/uploads/enqueue.dart
T
浅念 0fa23e015e feat(app): server-side duplicate pre-check at enqueue moment
Task 2 of docs/plans/2026-09-28-上传查重前置到入队前-实施计划.md:
surface server-side duplicate history at the "click upload" moment,
merged with the local check into the EXISTING dialog.

- ApiClient.checkUploadDuplicates: POST /v1/uploads/duplicates/check
  (commit 805f77b endpoint); 500/chunk sequential requests; chunk-local
  index translated to global; empty list short-circuits (no request);
  errors normalized to AppException via _send.
- enqueue.checkDuplicatesUnified: local TaskStore history ∪ server
  history; server hits map back to filename via global index into the
  stat'd picks; merged, de-duplicated (filename+bytes) list; server
  failure (network/permission/any) silently degrades to local-only —
  init 409 remains the backstop.
- capture_page + album_detail_page _pickFromGallery now call the
  unified entry; dialog/skip/force logic untouched.

Tests: api_client chunking (1200 → 500/500/200) + index translation +
merge + 404 normalization + empty no-request; page-level three states
(hit/no-hit/network failure) incl. fresh-install server-only-hit dialog
on both pages; pre-existing Task 8 dialog tests keep passing via the
degrade path.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 19:17:35 +08:00

303 lines
11 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 匹配 → 返回 [DuplicateCheck],由
/// 调用方决定弹窗确认或跳过。批量场景汇总后单次弹窗(非逐张)。
///
/// Task 2(查重前置到入队前·统一入口):入队前查重升级为
/// [checkDuplicatesUnified]——本机 TaskStore 历史 ∪ 服务器历史
/// (duplicates/check 只读预检,bytes 命中);服务器失败静默降级
/// 本机-only,不阻塞入队。
///
/// Task 8(服务端查重 App 侧集成):弹窗「全部上传」= 用户确认强传——
/// 重复项以 force=true 入队([enqueueForUpload] 的 forcePaths),任务行
/// 落库后由引擎在 initUpload 携带 force 跳过服务端查重(仅 owner/admin
/// 生效,服务端裁决——协作者强传仍会被 409 拒绝并可见失败文案)。
library;
import 'dart:io';
import 'package:flutter/widgets.dart' show BuildContext, Localizations;
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 'providers.dart';
import 'task_state.dart';
/// 单图选择(准备失败重试的重新选图路径);返回源文件路径或 null(取消)。
typedef SingleImagePicker = Future<String?> Function();
/// 多图选择(相册多选入队);返回源文件路径列表(空=取消)。
typedef MultiImagePicker = Future<List<String>> Function(BuildContext context);
/// 单图选择 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 仅发生在用户取消。
final multiImagePickerProvider = Provider<MultiImagePicker>(
(ref) => (context) async {
final locale = Localizations.maybeLocaleOf(context);
final assets = await AssetPicker.pickAssets(
context,
pickerConfig: AssetPickerConfig(
requestType: RequestType.image,
maxAssets: kPickerSoftMaxAssets,
textDelegate: assetPickerTextDelegateFromLocale(locale),
),
);
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;
/// 查重弹窗选择结果(Task 8):null=取消(不上传)。
///
/// 「跳过重复」→ skipPaths=重复项(仅非重复入队);「全部上传」→
/// forcePaths=重复项(强传,见库级注释)。两个集合互斥且可为空。
typedef DuplicateDialogChoice
= ({Set<String> skipPaths, Set<String> forcePaths});
/// 查重结果(单张):匹配到的已上传任务摘要。
class DuplicateMatch {
const DuplicateMatch({
required this.filename,
required this.bytes,
required this.uploadedAt,
});
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;
}
/// 入队前查重:查 TaskStore 同账号 + 同相册 + state=uploaded,
/// filename + bytes 匹配的记录。
///
/// 返回 null = 无重复(可直接入队);非 null = 有疑似重复(调用方弹窗)。
Future<List<DuplicateMatch>?> checkDuplicates(
WidgetRef ref, {
required String accountId,
required String albumId,
required List<String> sourcePaths,
}) async {
if (sourcePaths.isEmpty) return null;
// 收集本次选择的 filename + bytes。
final picks = <({String filename, int bytes})>[];
for (final path in sourcePaths) {
try {
final file = File(path);
picks.add((filename: p.basename(path), bytes: await file.length()));
} on Object {
// 读不到的文件不参与查重(入队时准备器会报错)。
}
}
if (picks.isEmpty) return null;
// 查同相册已完成的任务。
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(
filename: pick.filename,
bytes: pick.bytes,
uploadedAt: task.createdAt,
),
);
break; // 一对一即可(同一历史任务不重复匹配多张同内容照片)。
}
}
}
return matches.isEmpty ? null : matches;
}
/// 统一查重(P0-1 层 1+层 2 前置,Task 2):本机 TaskStore 历史 ∪
/// 服务器历史(POST /uploads/duplicates/check;服务端谓词仅 bytes——
/// filename 刻意不参与,与 initUpload 查重一致)。
///
/// 服务器预检失败(网络/权限/任意异常)→ 静默降级本机-only(不阻塞
/// 入队;服务端 init 409 仍是兜底)。返回 null = 无重复。
///
/// 服务器命中按全局下标映射回本批文件(stat 本批取 filename,与
/// [checkDuplicates] 同模式),弹窗/强传标记的 filename 派生由此生效。
Future<List<DuplicateMatch>?> checkDuplicatesUnified(
WidgetRef ref, {
required String accountId,
required String albumId,
required List<String> sourcePaths,
}) async {
if (sourcePaths.isEmpty) return null;
// 层 1:本机 TaskStore 历史(filename+bytes 匹配)。
final local =
await checkDuplicates(
ref,
accountId: accountId,
albumId: albumId,
sourcePaths: sourcePaths,
) ??
const <DuplicateMatch>[];
// 层 2:服务器历史(bytes 命中,逐请求条目一条)。
final picks = <({String filename, int bytes})>[];
for (final path in sourcePaths) {
try {
final file = File(path);
picks.add((filename: p.basename(path), bytes: await file.length()));
} on Object {
// 读不到的文件不参与查重(入队时准备器会报错)。
}
}
final serverHits = <UploadDuplicateHit>[];
if (picks.isNotEmpty) {
try {
serverHits.addAll(
await ref
.read(apiClientProvider)
.checkUploadDuplicates(albumId, picks),
);
} on Object {
// 静默降级本机-only:预检是体验优化而非闸门,失败不阻塞入队
//(服务端 init 409 仍是兜底)。
}
}
// 合并去重(filename+bytes;本机命中在前——同一重复保留先见者)。
final seen = <String>{};
final merged = <DuplicateMatch>[];
for (final match in [
...local,
for (final hit in serverHits)
if (hit.index >= 0 && hit.index < picks.length)
DuplicateMatch(
filename: picks[hit.index].filename,
bytes: hit.bytes,
uploadedAt: hit.uploadedAt,
),
]) {
if (seen.add('${match.filename}\u0000${match.bytes}')) {
merged.add(match);
}
}
return merged.isEmpty ? null : merged;
}
/// 批量入队:通知权限单次请求 + 逐张准备入队。
///
/// [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,
);
}