Files
photo-live/apps/mobile/lib/features/uploads/preparer.dart
T
浅念 29f3bde9c2 feat(app): wire server dedup - force passthrough + PHOTO_DUPLICATE classification & l10n
Task 8: close the gap between server-side duplicate detection (90dcc58)
and the app.

- ApiClient.mapDioException: normalize 409 wire body reusing
  VALIDATION_FAILED with message=PHOTO_DUPLICATE to
  ErrorCodes.photoDuplicate (message-based; auto-compat if the contract
  later adds a dedicated code)
- l10n zh/en errorPhotoDuplicate; error_l10n maps the code (queue page
  inherits via fallback)
- UploadEngine._classifyFailure: PHOTO_DUPLICATE joins the no-auto-retry
  bucket (retrying the same init body would 409 again); force to skip
  dedup travels only through the enqueue path
- UploadTasks drift table +force column (schema v2, addColumn migration;
  legacy rows default false); UploadTaskDraft/UploadTask/prepare carry it
- enqueueForUpload +forcePaths; duplicate dialog "全部上传" (capture page
  + album detail page) now returns DuplicateDialogChoice so confirmed
  duplicates enqueue with force=true (server adjudicates owner/admin)
- engine init body carries force only when true (absent otherwise)

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

356 lines
13 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 评审口径):
/// - **有界并发执行(默认 3)**:文件复制仍是 IO 最重环节,上限钳位
/// 防连拍(一次落盘多张)IO 风暴;传输并发由 UploadEngine 另行控制;
/// - **磁盘空间不显式预检**:引入 disk_space 插件成本(新依赖 + 平台
/// 误差)高于收益——复制失败本身即失败信号(ENOSPC 走 copy 失败分支
/// → preparationFailed,用户可见可重试)。此为有意裁剪,登记为计划
/// 偏差;
/// - **本地尺寸上限**:客户端无法得知服务端 UPLOAD_MAX 环境变量,取
/// 保守常量 50MB 预检(超限直接 preparationFailed,不浪费复制与传输);
/// 界于本地上限与服务端真实上限之间的文件由服务端 400 兜底。
library;
import 'dart:async';
import 'dart:collection';
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';
}
/// 准备阶段有界并发上限(dart-define PREPARE_CONCURRENCY 覆盖;1=串行)。
const int kMaxConcurrentPrepares =
int.fromEnvironment('PREPARE_CONCURRENCY', defaultValue: 3);
/// 上传准备器:入队唯一入口(队列 UI/连拍页调用;引擎消费产出)。
///
/// 所有操作有界并发执行(默认 3):文件复制仍是 IO 最重环节,上限钳位
/// 防连拍风暴;传输并发由 UploadEngine 另行控制(见库级注释)。
class UploadPreparer {
UploadPreparer(
this._store, {
Future<Directory> Function()? baseDirProvider,
Random? random,
this.maxBytes = kMaxUploadBytes,
this.maxConcurrentPrepares = kMaxConcurrentPrepares,
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;
/// 准备并发上限(运行时 <1 钳位为 1)。
final int maxConcurrentPrepares;
int get _effectivePrepareConcurrency =>
maxConcurrentPrepares < 1 ? 1 : maxConcurrentPrepares;
/// 准备一张图片并入队。
///
/// [sourcePath] 为 image_picker/camera 返回的源文件路径(App 沙箱内
/// 可直接 File 读取);[filename] 缺省取源文件 basename;[force] 为
/// 查重弹窗「全部上传」的用户确认(Task 8)——落到任务行,由引擎在
/// initUpload 携带(仅 owner/admin 生效,服务端裁决)。
///
/// 返回入队后的任务(state=queued);失败返回 state=preparationFailed
/// 的任务(不抛异常——准备失败是业务状态而非调用错误)。
Future<UploadTask> prepare({
required String accountId,
required String albumId,
String? categoryId,
required String sourcePath,
String? filename,
bool force = false,
}) {
return _runBounded(
() => _prepare(
accountId: accountId,
albumId: albumId,
categoryId: categoryId,
sourcePath: sourcePath,
filename: filename,
force: force,
),
);
}
/// 重试准备失败的任务(preparationFailed → preparing → 复制 → queued)。
///
/// 源文件原始路径不落库(任务表仅存受管副本路径),调用方(队列 UI)
/// 需重新提供 [sourcePath](重新选图,或相机缓存仍存时直传)。
Future<UploadTask> retry({
required String accountId,
required String taskId,
required String sourcePath,
}) {
return _runBounded(
() =>
_retry(accountId: accountId, taskId: taskId, sourcePath: sourcePath),
);
}
/// 在途准备计数 + FIFO 等待队列(有界并发信号量)。
int _activePrepares = 0;
final Queue<Completer<void>> _prepareWaiters = Queue<Completer<void>>();
/// 有界并发执行:超额排队(FIFO);错误由调用方经返回值处理,不设全局断链。
Future<T> _runBounded<T>(Future<T> Function() op) {
final ticket = _acquirePrepareSlot();
final result = ticket.then((_) => op());
result.whenComplete(_releasePrepareSlot).ignore();
return result;
}
Future<void> _acquirePrepareSlot() {
if (_activePrepares < _effectivePrepareConcurrency) {
_activePrepares++;
return Future<void>.value();
}
final waiter = Completer<void>();
_prepareWaiters.addLast(waiter);
return waiter.future;
}
void _releasePrepareSlot() {
final waiter = _prepareWaiters.isEmpty
? null
: _prepareWaiters.removeFirst();
if (waiter == null) {
_activePrepares--;
return;
}
// 名额直接移交队首等待者(计数不变)。
waiter.complete();
}
Future<UploadTask> _prepare({
required String accountId,
required String albumId,
required String? categoryId,
required String sourcePath,
required String? filename,
required bool force,
}) 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',
force: force,
),
);
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);
}
}