/// 上传准备阶段(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 Function()? baseDirProvider, Random? random, this.maxBytes = kMaxUploadBytes, this.onEnqueued, }) : _baseDirProvider = baseDirProvider ?? getApplicationSupportDirectory, _random = random ?? Random.secure(); final TaskStore _store; final Future 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 _tail = Future.value(); /// 准备一张图片并入队。 /// /// [sourcePath] 为 image_picker/camera 返回的源文件路径(App 沙箱内 /// 可直接 File 读取);[filename] 缺省取源文件 basename。 /// /// 返回入队后的任务(state=queued);失败返回 state=preparationFailed /// 的任务(不抛异常——准备失败是业务状态而非调用错误)。 Future 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 retry({ required String accountId, required String taskId, required String sourcePath, }) { return _serialize(() => _retry( accountId: accountId, taskId: taskId, sourcePath: sourcePath, )); } /// 串行化:所有准备操作首尾相接(错误吞掉以不断链,错误经返回值上抛)。 Future _serialize(Future Function() op) { final result = _tail.then((_) => op()); _tail = result.then((_) {}, onError: (Object _) {}); return result; } Future _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 _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 _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'); } 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 _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 _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 _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.generate(16, (_) => _random.nextInt(256)); return DeviceId.hexEncode(bytes); } }