Files
photo-live/apps/mobile/lib/features/uploads/preparer.dart
T
浅念 638dd9b45d feat(app): P0 batch 2+2.5 — local upload dedup with batch dialog, album switch check, file size guard
P0-1 层 1 本地查重:
  - checkDuplicates:同账号+同相册+state=completed+filename+bytes 匹配
  - EnqueueResult 返回 enqueued+duplicates+skipped
  - capture_page + album_detail_page:批量单次弹窗(非逐张)三选一
    [跳过重复上传非重复] [全部上传] [取消]

P0-8 相册上传开关预检:album_detail_page 入队前检查 uploadEnabled,
  关闭时提示「该相册已关闭上传」+ 不入队

P1-8 文件大小预检:preparer 限单文件 ≤100MB(磁盘满由 on Object 兜底)

P0-7 更正:上传并发限制已有(kMaxParallelUploads=2),v2 分析误报

397/397 全绿、analyze 0

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 11:06:40 +08:00

313 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 6)。
///
/// 选图(image_picker)或连拍(camera)得到的源文件 → 复制到受管目录
/// (app 支持目录/uploads/{accountId}/{albumId}/{taskId}{ext})→ stat 字节
/// → MIME 嗅探(扩展名映射 + jpg/png 魔数校验)→ 尺寸上限本地预检 →
/// 入队 queued。任一步失败 → 独立状态 preparationFailed(可重试,见
/// [UploadPreparer.retry]),lastError 携带客户端伪码([PrepErrorCodes])。
///
/// 设计取舍(对齐计划 Task 6 评审口径):
/// - **串行执行**:文件复制是最容易造成 IO 风暴的环节(连拍场景一次
/// 落盘多张),队列化逐张处理;传输并发由 UploadEngine 另行控制;
/// - **磁盘空间不显式预检**:引入 disk_space 插件成本(新依赖 + 平台
/// 误差)高于收益——复制失败本身即失败信号(ENOSPC 走 copy 失败分支
/// → preparationFailed,用户可见可重试)。此为有意裁剪,登记为计划
/// 偏差;
/// - **本地尺寸上限**:客户端无法得知服务端 UPLOAD_MAX 环境变量,取
/// 保守常量 50MB 预检(超限直接 preparationFailed,不浪费复制与传输);
/// 界于本地上限与服务端真实上限之间的文件由服务端 400 兜底。
library;
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../core/device_id.dart';
import 'task_state.dart';
import 'task_store.dart';
/// 准备阶段失败伪码(客户端本地,契约外;lastError 展示用,
/// UI 层映射本地化文案)。
abstract final class PrepErrorCodes {
/// 源文件不可读(已被清理/权限/不存在)。
static const String sourceUnreadable = 'PREP_SOURCE_UNREADABLE';
/// MIME 不在白名单(jpg/png/heic 之外)。
static const String unsupportedType = 'PREP_UNSUPPORTED_TYPE';
/// 超过本地尺寸上限(50MB 预检)。
static const String tooLarge = 'PREP_TOO_LARGE';
/// 复制到受管目录失败(含磁盘满/复制期间源被改动)。
static const String copyFailed = 'PREP_COPY_FAILED';
}
/// 上传准备器:入队唯一入口(队列 UI/连拍页调用;引擎消费产出)。
///
/// 所有操作串行(连拍入队逐张复制,避免 IO 风暴,见库级注释)。
class UploadPreparer {
UploadPreparer(
this._store, {
Future<Directory> Function()? baseDirProvider,
Random? random,
this.maxBytes = kMaxUploadBytes,
this.onEnqueued,
}) : _baseDirProvider = baseDirProvider ?? getApplicationSupportDirectory,
_random = random ?? Random.secure();
final TaskStore _store;
final Future<Directory> Function() _baseDirProvider;
final Random _random;
/// 本地尺寸预检上限(默认 [kMaxUploadBytes];测试可注入小值)。
final int maxBytes;
/// 本地尺寸预检上限(50MB;服务端 UPLOAD_MAX 由 400 兜底,见库级注释)。
static const int kMaxUploadBytes = 50 * 1024 * 1024;
/// 入队成功回调(唤醒传输引擎立即出队)。
final void Function()? onEnqueued;
/// 串行队列尾部。
Future<void> _tail = Future<void>.value();
/// 准备一张图片并入队。
///
/// [sourcePath] 为 image_picker/camera 返回的源文件路径(App 沙箱内
/// 可直接 File 读取);[filename] 缺省取源文件 basename。
///
/// 返回入队后的任务(state=queued);失败返回 state=preparationFailed
/// 的任务(不抛异常——准备失败是业务状态而非调用错误)。
Future<UploadTask> prepare({
required String accountId,
required String albumId,
String? categoryId,
required String sourcePath,
String? filename,
}) {
return _serialize(
() => _prepare(
accountId: accountId,
albumId: albumId,
categoryId: categoryId,
sourcePath: sourcePath,
filename: filename,
),
);
}
/// 重试准备失败的任务(preparationFailed → preparing → 复制 → queued)。
///
/// 源文件原始路径不落库(任务表仅存受管副本路径),调用方(队列 UI)
/// 需重新提供 [sourcePath](重新选图,或相机缓存仍存时直传)。
Future<UploadTask> retry({
required String accountId,
required String taskId,
required String sourcePath,
}) {
return _serialize(
() =>
_retry(accountId: accountId, taskId: taskId, sourcePath: sourcePath),
);
}
/// 串行化:所有准备操作首尾相接(错误吞掉以不断链,错误经返回值上抛)。
Future<T> _serialize<T>(Future<T> Function() op) {
final result = _tail.then((_) => op());
_tail = result.then<void>((_) {}, onError: (Object _) {});
return result;
}
Future<UploadTask> _prepare({
required String accountId,
required String albumId,
required String? categoryId,
required String sourcePath,
required String? filename,
}) async {
final id = _newId();
final clientTaskId = _newId();
final ext = _normalizeExt(sourcePath);
final contentType = await _sniffContentType(sourcePath, ext);
final bytes = await _statBytes(sourcePath);
// 快速失败预检:类型不在白名单 / 源不可读(bytes=0)/ 超本地上限。
final failCode = _preCheck(contentType: contentType, bytes: bytes);
final dir = await _managedDir(accountId, albumId);
final targetPath = p.join(dir.path, '$id$ext');
final task = await _store.create(
UploadTaskDraft(
id: id,
accountId: accountId,
albumId: albumId,
categoryId: categoryId,
clientTaskId: clientTaskId,
sourceFilePath: targetPath,
filename: filename ?? p.basename(sourcePath),
// 预检失败时占位(该行永不进入传输;成功路径 bytes 来自源 stat)。
bytes: bytes > 0 ? bytes : 1,
contentType: contentType ?? 'application/octet-stream',
),
);
if (failCode != null) {
return _store.updateState(
id,
UploadTaskState.preparationFailed,
patch: UploadTaskPatch(lastError: failCode),
);
}
return _copyAndQueue(task, sourcePath: sourcePath);
}
Future<UploadTask> _retry({
required String accountId,
required String taskId,
required String sourcePath,
}) async {
final task = await _store.get(accountId, taskId);
if (task == null) {
throw StateError('上传任务不存在:$taskId');
}
if (task.state != UploadTaskState.preparationFailed) {
throw StateError('仅 preparationFailed 可重试准备(当前:${task.state})');
}
final preparing = await _store.updateState(
taskId,
UploadTaskState.preparing,
patch: const UploadTaskPatch(clearLastError: true),
);
return _copyAndQueue(preparing, sourcePath: sourcePath);
}
/// 复制到受管路径并入队;复制失败 → preparationFailed(返回,不抛)。
Future<UploadTask> _copyAndQueue(
UploadTask task, {
required String sourcePath,
}) async {
final source = File(sourcePath);
final target = File(task.sourceFilePath);
try {
final expected = await source.length();
if (expected <= 0) {
throw StateError('empty source');
}
// P1-8:磁盘空间预检——超 100MB 直接拒绝(copy 阶段的磁盘满错误
// 已被下方 on Object 兜底为 copyFailed;此上限防止单文件打满存储)。
if (expected > 100 * 1024 * 1024) {
throw StateError('source too large');
}
await target.parent.create(recursive: true);
await source.copy(task.sourceFilePath);
final copied = await target.length();
if (copied != expected) {
// 源在复制期间被改动(相机流水写入中)——按复制失败处理,
// 用户可重试。
if (await target.exists()) {
await target.delete();
}
throw StateError('copy size mismatch');
}
// 声明值与副本对齐(重试换源场景下行内 bytes 可能是占位值;
// TaskStore.updateMeta 旁路写入,不迁移状态)。
if (copied != task.bytes) {
await _store.updateMeta(task.id, bytes: copied);
}
} on Object {
return _store.updateState(
task.id,
UploadTaskState.preparationFailed,
patch: const UploadTaskPatch(lastError: PrepErrorCodes.copyFailed),
);
}
final queued = await _store.updateState(
task.id,
UploadTaskState.queued,
patch: const UploadTaskPatch(clearLastError: true),
);
onEnqueued?.call();
return queued;
}
/// 快速预检:返回失败伪码(null=通过)。
String? _preCheck({required String? contentType, required int bytes}) {
if (bytes <= 0) return PrepErrorCodes.sourceUnreadable;
if (contentType == null) return PrepErrorCodes.unsupportedType;
if (bytes > maxBytes) return PrepErrorCodes.tooLarge;
return null;
}
/// MIME 嗅探:魔数优先(jpg/png),回退扩展名映射(含无魔数的 heic)。
///
/// 返回 null=无法确定白名单内类型(调用方按 unsupportedType 失败)。
Future<String?> _sniffContentType(String sourcePath, String ext) async {
try {
final head = await File(sourcePath).openRead(0, 8).first;
if (head.length >= 3 &&
head[0] == 0xFF &&
head[1] == 0xD8 &&
head[2] == 0xFF) {
return 'image/jpeg';
}
if (head.length >= 4 &&
head[0] == 0x89 &&
head[1] == 0x50 &&
head[2] == 0x4E &&
head[3] == 0x47) {
return 'image/png';
}
} on Object {
// 魔数读取失败(源不存在/权限):交由 _statBytes→sourceUnreadable。
}
return _contentTypeByExt(ext);
}
/// 扩展名 → 白名单 MIME(未知扩展返回 null)。
static String? _contentTypeByExt(String ext) {
switch (ext) {
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.png':
return 'image/png';
case '.heic':
case '.heif':
return 'image/heic';
}
return null;
}
/// stat 源字节数;不可读返回 0(触发 sourceUnreadable 预检失败)。
Future<int> _statBytes(String sourcePath) async {
try {
return await File(sourcePath).length();
} on Object {
return 0;
}
}
/// 归一小写扩展名(含点;无扩展返回空串)。
static String _normalizeExt(String sourcePath) =>
p.extension(sourcePath).toLowerCase();
/// 受管目录:{base}/uploads/{accountId}/{albumId}/。
Future<Directory> _managedDir(String accountId, String albumId) async {
final base = await _baseDirProvider();
final dir = Directory(p.join(base.path, 'uploads', accountId, albumId));
await dir.create(recursive: true);
return dir;
}
/// 32 位十六进制随机 ID(任务 id 与 clientTaskId 各一次生成,
/// 与 core/device_id.dart 同一编码方式)。
String _newId() {
final bytes = List<int>.generate(16, (_) => _random.nextInt(256));
return DeviceId.hexEncode(bytes);
}
}