3ddb0ea2d4
Co-Authored-By: Claude Code <noreply@anthropic.com>
307 lines
11 KiB
Dart
307 lines
11 KiB
Dart
/// 上传准备阶段(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');
|
||
}
|
||
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);
|
||
}
|
||
}
|