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>
This commit is contained in:
@@ -10,6 +10,11 @@
|
||||
/// P0-1 层 1(UI06 分析 v2):入队前**本地查重**——同账号 + 同相册 +
|
||||
/// state=uploaded + filename+bytes 匹配 → 返回 [DuplicateCheck],由
|
||||
/// 调用方决定弹窗确认或跳过。批量场景汇总后单次弹窗(非逐张)。
|
||||
///
|
||||
/// Task 8(服务端查重 App 侧集成):弹窗「全部上传」= 用户确认强传——
|
||||
/// 重复项以 force=true 入队([enqueueForUpload] 的 forcePaths),任务行
|
||||
/// 落库后由引擎在 initUpload 携带 force 跳过服务端查重(仅 owner/admin
|
||||
/// 生效,服务端裁决——协作者强传仍会被 409 拒绝并可见失败文案)。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
@@ -77,6 +82,13 @@ final multiImagePickerProvider = Provider<MultiImagePicker>(
|
||||
/// 选图软上限(用户要求不设硬限制;9999 为防误触极端值)。
|
||||
const int kPickerSoftMaxAssets = 9999;
|
||||
|
||||
/// 查重弹窗选择结果(Task 8):null=取消(不上传)。
|
||||
///
|
||||
/// 「跳过重复」→ skipPaths=重复项(仅非重复入队);「全部上传」→
|
||||
/// forcePaths=重复项(强传,见库级注释)。两个集合互斥且可为空。
|
||||
typedef DuplicateDialogChoice
|
||||
= ({Set<String> skipPaths, Set<String> forcePaths});
|
||||
|
||||
/// 查重结果(单张):匹配到的已上传任务摘要。
|
||||
class DuplicateMatch {
|
||||
const DuplicateMatch({
|
||||
@@ -162,6 +174,10 @@ Future<List<DuplicateMatch>?> checkDuplicates(
|
||||
|
||||
/// 批量入队:通知权限单次请求 + 逐张准备入队。
|
||||
///
|
||||
/// [skipPaths]:用户选择跳过的重复项;[forcePaths]:查重弹窗「全部上传」
|
||||
/// 确认强传的重复项(Task 8——落到任务行 force 标记,引擎 initUpload
|
||||
/// 携带;仅 owner/admin 生效,服务端裁决)。两者互斥。
|
||||
///
|
||||
/// 返回 [EnqueueResult](含 enqueued 计数与 duplicates 列表)。
|
||||
/// 准备失败的任务已落库为 preparationFailed(连拍页失败徽章/队列页可见,
|
||||
/// 不在此抛出)。
|
||||
@@ -172,6 +188,7 @@ Future<EnqueueResult> enqueueForUpload(
|
||||
String? categoryId,
|
||||
required List<String> sourcePaths,
|
||||
List<String>? skipPaths,
|
||||
List<String>? forcePaths,
|
||||
}) async {
|
||||
// 首次入队动作触发(幂等;拒绝/非 Android 静默降级,见实现注释)。
|
||||
await ref.read(notificationPermissionProvider).ensureRequested();
|
||||
@@ -183,12 +200,16 @@ Future<EnqueueResult> enqueueForUpload(
|
||||
.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
|
||||
|
||||
@@ -87,7 +87,9 @@ class UploadPreparer {
|
||||
/// 准备一张图片并入队。
|
||||
///
|
||||
/// [sourcePath] 为 image_picker/camera 返回的源文件路径(App 沙箱内
|
||||
/// 可直接 File 读取);[filename] 缺省取源文件 basename。
|
||||
/// 可直接 File 读取);[filename] 缺省取源文件 basename;[force] 为
|
||||
/// 查重弹窗「全部上传」的用户确认(Task 8)——落到任务行,由引擎在
|
||||
/// initUpload 携带(仅 owner/admin 生效,服务端裁决)。
|
||||
///
|
||||
/// 返回入队后的任务(state=queued);失败返回 state=preparationFailed
|
||||
/// 的任务(不抛异常——准备失败是业务状态而非调用错误)。
|
||||
@@ -97,6 +99,7 @@ class UploadPreparer {
|
||||
String? categoryId,
|
||||
required String sourcePath,
|
||||
String? filename,
|
||||
bool force = false,
|
||||
}) {
|
||||
return _runBounded(
|
||||
() => _prepare(
|
||||
@@ -105,6 +108,7 @@ class UploadPreparer {
|
||||
categoryId: categoryId,
|
||||
sourcePath: sourcePath,
|
||||
filename: filename,
|
||||
force: force,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -164,6 +168,7 @@ class UploadPreparer {
|
||||
required String? categoryId,
|
||||
required String sourcePath,
|
||||
required String? filename,
|
||||
required bool force,
|
||||
}) async {
|
||||
final id = _newId();
|
||||
final clientTaskId = _newId();
|
||||
@@ -188,6 +193,7 @@ class UploadPreparer {
|
||||
// 预检失败时占位(该行永不进入传输;成功路径 bytes 来自源 stat)。
|
||||
bytes: bytes > 0 ? bytes : 1,
|
||||
contentType: contentType ?? 'application/octet-stream',
|
||||
force: force,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -89,6 +89,11 @@ class UploadTasks extends Table {
|
||||
TextColumn get state =>
|
||||
text().map(const _UploadTaskStateConverter())();
|
||||
|
||||
/// 用户已确认强传(P0-1 Task 8):查重弹窗「全部上传」入队的任务置
|
||||
/// true,initUpload 携带 force 跳过服务端查重(仅 owner/admin 生效,
|
||||
/// 服务端裁决)。缺省 false(正常查重)。
|
||||
BoolColumn get force => boolean().withDefault(const Constant(false))();
|
||||
|
||||
/// 已尝试传输次数(退避与上限判断依据)。
|
||||
IntColumn get attempt => integer().withDefault(const Constant(0))();
|
||||
|
||||
@@ -128,8 +133,18 @@ class UploadTaskDb extends _$UploadTaskDb {
|
||||
/// 测试用内存库(drift NativeDatabase.memory,随 close 丢弃)。
|
||||
UploadTaskDb.memory() : super(NativeDatabase.memory());
|
||||
|
||||
/// v1:初始表;v2(Task 8):+force 列(查重弹窗「全部上传」强传标记)。
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
int get schemaVersion => 2;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
onUpgrade: (m, from, to) async {
|
||||
if (from == 1) {
|
||||
await m.addColumn(uploadTasks, uploadTasks.force);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 打开生产库:app documents 目录下 upload_tasks.sqlite,后台 isolate 执行
|
||||
@@ -154,6 +169,7 @@ class UploadTaskDraft {
|
||||
required this.filename,
|
||||
required this.bytes,
|
||||
required this.contentType,
|
||||
this.force = false,
|
||||
});
|
||||
|
||||
/// 任务 ID(uuid,调用方生成)。
|
||||
@@ -182,6 +198,9 @@ class UploadTaskDraft {
|
||||
|
||||
/// MIME 类型。
|
||||
final String contentType;
|
||||
|
||||
/// 用户已确认强传(查重弹窗「全部上传」);initUpload 携带 force。
|
||||
final bool force;
|
||||
}
|
||||
|
||||
/// 状态迁移时一并写入的旁路字段(未提供的列保持不变;`null` 与"清除"通过
|
||||
@@ -279,6 +298,7 @@ class TaskStore {
|
||||
filename: draft.filename,
|
||||
bytes: draft.bytes,
|
||||
contentType: draft.contentType,
|
||||
force: Value(draft.force),
|
||||
state: UploadTaskState.preparing,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
|
||||
@@ -113,6 +113,19 @@ class $UploadTasksTable extends UploadTasks
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
).withConverter<UploadTaskState>($UploadTasksTable.$converterstate);
|
||||
static const VerificationMeta _forceMeta = const VerificationMeta('force');
|
||||
@override
|
||||
late final GeneratedColumn<bool> force = GeneratedColumn<bool>(
|
||||
'force',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.bool,
|
||||
requiredDuringInsert: false,
|
||||
defaultConstraints: GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("force" IN (0, 1))',
|
||||
),
|
||||
defaultValue: const Constant(false),
|
||||
);
|
||||
static const VerificationMeta _attemptMeta = const VerificationMeta(
|
||||
'attempt',
|
||||
);
|
||||
@@ -214,6 +227,7 @@ class $UploadTasksTable extends UploadTasks
|
||||
bytes,
|
||||
contentType,
|
||||
state,
|
||||
force,
|
||||
attempt,
|
||||
nextRetryAt,
|
||||
serverUploadId,
|
||||
@@ -311,6 +325,12 @@ class $UploadTasksTable extends UploadTasks
|
||||
} else if (isInserting) {
|
||||
context.missing(_contentTypeMeta);
|
||||
}
|
||||
if (data.containsKey('force')) {
|
||||
context.handle(
|
||||
_forceMeta,
|
||||
force.isAcceptableOrUnknown(data['force']!, _forceMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('attempt')) {
|
||||
context.handle(
|
||||
_attemptMeta,
|
||||
@@ -423,6 +443,10 @@ class $UploadTasksTable extends UploadTasks
|
||||
data['${effectivePrefix}state'],
|
||||
)!,
|
||||
),
|
||||
force: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}force'],
|
||||
)!,
|
||||
attempt: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}attempt'],
|
||||
@@ -498,6 +522,11 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
|
||||
/// 任务状态(状态机见 task_state.dart)。
|
||||
final UploadTaskState state;
|
||||
|
||||
/// 用户已确认强传(P0-1 Task 8):查重弹窗「全部上传」入队的任务置
|
||||
/// true,initUpload 携带 force 跳过服务端查重(仅 owner/admin 生效,
|
||||
/// 服务端裁决)。缺省 false(正常查重)。
|
||||
final bool force;
|
||||
|
||||
/// 已尝试传输次数(退避与上限判断依据)。
|
||||
final int attempt;
|
||||
|
||||
@@ -535,6 +564,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
|
||||
required this.bytes,
|
||||
required this.contentType,
|
||||
required this.state,
|
||||
required this.force,
|
||||
required this.attempt,
|
||||
this.nextRetryAt,
|
||||
this.serverUploadId,
|
||||
@@ -563,6 +593,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
|
||||
$UploadTasksTable.$converterstate.toSql(state),
|
||||
);
|
||||
}
|
||||
map['force'] = Variable<bool>(force);
|
||||
map['attempt'] = Variable<int>(attempt);
|
||||
if (!nullToAbsent || nextRetryAt != null) {
|
||||
map['next_retry_at'] = Variable<DateTime>(nextRetryAt);
|
||||
@@ -598,6 +629,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
|
||||
bytes: Value(bytes),
|
||||
contentType: Value(contentType),
|
||||
state: Value(state),
|
||||
force: Value(force),
|
||||
attempt: Value(attempt),
|
||||
nextRetryAt: nextRetryAt == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
@@ -635,6 +667,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
|
||||
bytes: serializer.fromJson<int>(json['bytes']),
|
||||
contentType: serializer.fromJson<String>(json['contentType']),
|
||||
state: serializer.fromJson<UploadTaskState>(json['state']),
|
||||
force: serializer.fromJson<bool>(json['force']),
|
||||
attempt: serializer.fromJson<int>(json['attempt']),
|
||||
nextRetryAt: serializer.fromJson<DateTime?>(json['nextRetryAt']),
|
||||
serverUploadId: serializer.fromJson<String?>(json['serverUploadId']),
|
||||
@@ -659,6 +692,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
|
||||
'bytes': serializer.toJson<int>(bytes),
|
||||
'contentType': serializer.toJson<String>(contentType),
|
||||
'state': serializer.toJson<UploadTaskState>(state),
|
||||
'force': serializer.toJson<bool>(force),
|
||||
'attempt': serializer.toJson<int>(attempt),
|
||||
'nextRetryAt': serializer.toJson<DateTime?>(nextRetryAt),
|
||||
'serverUploadId': serializer.toJson<String?>(serverUploadId),
|
||||
@@ -681,6 +715,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
|
||||
int? bytes,
|
||||
String? contentType,
|
||||
UploadTaskState? state,
|
||||
bool? force,
|
||||
int? attempt,
|
||||
Value<DateTime?> nextRetryAt = const Value.absent(),
|
||||
Value<String?> serverUploadId = const Value.absent(),
|
||||
@@ -700,6 +735,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
|
||||
bytes: bytes ?? this.bytes,
|
||||
contentType: contentType ?? this.contentType,
|
||||
state: state ?? this.state,
|
||||
force: force ?? this.force,
|
||||
attempt: attempt ?? this.attempt,
|
||||
nextRetryAt: nextRetryAt.present ? nextRetryAt.value : this.nextRetryAt,
|
||||
serverUploadId: serverUploadId.present
|
||||
@@ -733,6 +769,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
|
||||
? data.contentType.value
|
||||
: this.contentType,
|
||||
state: data.state.present ? data.state.value : this.state,
|
||||
force: data.force.present ? data.force.value : this.force,
|
||||
attempt: data.attempt.present ? data.attempt.value : this.attempt,
|
||||
nextRetryAt: data.nextRetryAt.present
|
||||
? data.nextRetryAt.value
|
||||
@@ -763,6 +800,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
|
||||
..write('bytes: $bytes, ')
|
||||
..write('contentType: $contentType, ')
|
||||
..write('state: $state, ')
|
||||
..write('force: $force, ')
|
||||
..write('attempt: $attempt, ')
|
||||
..write('nextRetryAt: $nextRetryAt, ')
|
||||
..write('serverUploadId: $serverUploadId, ')
|
||||
@@ -787,6 +825,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
|
||||
bytes,
|
||||
contentType,
|
||||
state,
|
||||
force,
|
||||
attempt,
|
||||
nextRetryAt,
|
||||
serverUploadId,
|
||||
@@ -810,6 +849,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
|
||||
other.bytes == this.bytes &&
|
||||
other.contentType == this.contentType &&
|
||||
other.state == this.state &&
|
||||
other.force == this.force &&
|
||||
other.attempt == this.attempt &&
|
||||
other.nextRetryAt == this.nextRetryAt &&
|
||||
other.serverUploadId == this.serverUploadId &&
|
||||
@@ -831,6 +871,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
|
||||
final Value<int> bytes;
|
||||
final Value<String> contentType;
|
||||
final Value<UploadTaskState> state;
|
||||
final Value<bool> force;
|
||||
final Value<int> attempt;
|
||||
final Value<DateTime?> nextRetryAt;
|
||||
final Value<String?> serverUploadId;
|
||||
@@ -851,6 +892,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
|
||||
this.bytes = const Value.absent(),
|
||||
this.contentType = const Value.absent(),
|
||||
this.state = const Value.absent(),
|
||||
this.force = const Value.absent(),
|
||||
this.attempt = const Value.absent(),
|
||||
this.nextRetryAt = const Value.absent(),
|
||||
this.serverUploadId = const Value.absent(),
|
||||
@@ -872,6 +914,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
|
||||
required int bytes,
|
||||
required String contentType,
|
||||
required UploadTaskState state,
|
||||
this.force = const Value.absent(),
|
||||
this.attempt = const Value.absent(),
|
||||
this.nextRetryAt = const Value.absent(),
|
||||
this.serverUploadId = const Value.absent(),
|
||||
@@ -903,6 +946,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
|
||||
Expression<int>? bytes,
|
||||
Expression<String>? contentType,
|
||||
Expression<String>? state,
|
||||
Expression<bool>? force,
|
||||
Expression<int>? attempt,
|
||||
Expression<DateTime>? nextRetryAt,
|
||||
Expression<String>? serverUploadId,
|
||||
@@ -924,6 +968,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
|
||||
if (bytes != null) 'bytes': bytes,
|
||||
if (contentType != null) 'content_type': contentType,
|
||||
if (state != null) 'state': state,
|
||||
if (force != null) 'force': force,
|
||||
if (attempt != null) 'attempt': attempt,
|
||||
if (nextRetryAt != null) 'next_retry_at': nextRetryAt,
|
||||
if (serverUploadId != null) 'server_upload_id': serverUploadId,
|
||||
@@ -947,6 +992,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
|
||||
Value<int>? bytes,
|
||||
Value<String>? contentType,
|
||||
Value<UploadTaskState>? state,
|
||||
Value<bool>? force,
|
||||
Value<int>? attempt,
|
||||
Value<DateTime?>? nextRetryAt,
|
||||
Value<String?>? serverUploadId,
|
||||
@@ -968,6 +1014,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
|
||||
bytes: bytes ?? this.bytes,
|
||||
contentType: contentType ?? this.contentType,
|
||||
state: state ?? this.state,
|
||||
force: force ?? this.force,
|
||||
attempt: attempt ?? this.attempt,
|
||||
nextRetryAt: nextRetryAt ?? this.nextRetryAt,
|
||||
serverUploadId: serverUploadId ?? this.serverUploadId,
|
||||
@@ -1015,6 +1062,9 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
|
||||
$UploadTasksTable.$converterstate.toSql(state.value),
|
||||
);
|
||||
}
|
||||
if (force.present) {
|
||||
map['force'] = Variable<bool>(force.value);
|
||||
}
|
||||
if (attempt.present) {
|
||||
map['attempt'] = Variable<int>(attempt.value);
|
||||
}
|
||||
@@ -1058,6 +1108,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
|
||||
..write('bytes: $bytes, ')
|
||||
..write('contentType: $contentType, ')
|
||||
..write('state: $state, ')
|
||||
..write('force: $force, ')
|
||||
..write('attempt: $attempt, ')
|
||||
..write('nextRetryAt: $nextRetryAt, ')
|
||||
..write('serverUploadId: $serverUploadId, ')
|
||||
@@ -1115,6 +1166,7 @@ typedef $$UploadTasksTableCreateCompanionBuilder =
|
||||
required int bytes,
|
||||
required String contentType,
|
||||
required UploadTaskState state,
|
||||
Value<bool> force,
|
||||
Value<int> attempt,
|
||||
Value<DateTime?> nextRetryAt,
|
||||
Value<String?> serverUploadId,
|
||||
@@ -1137,6 +1189,7 @@ typedef $$UploadTasksTableUpdateCompanionBuilder =
|
||||
Value<int> bytes,
|
||||
Value<String> contentType,
|
||||
Value<UploadTaskState> state,
|
||||
Value<bool> force,
|
||||
Value<int> attempt,
|
||||
Value<DateTime?> nextRetryAt,
|
||||
Value<String?> serverUploadId,
|
||||
@@ -1208,6 +1261,11 @@ class $$UploadTasksTableFilterComposer
|
||||
builder: (column) => ColumnWithTypeConverterFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<bool> get force => $composableBuilder(
|
||||
column: $table.force,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<int> get attempt => $composableBuilder(
|
||||
column: $table.attempt,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
@@ -1308,6 +1366,11 @@ class $$UploadTasksTableOrderingComposer
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<bool> get force => $composableBuilder(
|
||||
column: $table.force,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<int> get attempt => $composableBuilder(
|
||||
column: $table.attempt,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
@@ -1396,6 +1459,9 @@ class $$UploadTasksTableAnnotationComposer
|
||||
GeneratedColumnWithTypeConverter<UploadTaskState, String> get state =>
|
||||
$composableBuilder(column: $table.state, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<bool> get force =>
|
||||
$composableBuilder(column: $table.force, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<int> get attempt =>
|
||||
$composableBuilder(column: $table.attempt, builder: (column) => column);
|
||||
|
||||
@@ -1468,6 +1534,7 @@ class $$UploadTasksTableTableManager
|
||||
Value<int> bytes = const Value.absent(),
|
||||
Value<String> contentType = const Value.absent(),
|
||||
Value<UploadTaskState> state = const Value.absent(),
|
||||
Value<bool> force = const Value.absent(),
|
||||
Value<int> attempt = const Value.absent(),
|
||||
Value<DateTime?> nextRetryAt = const Value.absent(),
|
||||
Value<String?> serverUploadId = const Value.absent(),
|
||||
@@ -1488,6 +1555,7 @@ class $$UploadTasksTableTableManager
|
||||
bytes: bytes,
|
||||
contentType: contentType,
|
||||
state: state,
|
||||
force: force,
|
||||
attempt: attempt,
|
||||
nextRetryAt: nextRetryAt,
|
||||
serverUploadId: serverUploadId,
|
||||
@@ -1510,6 +1578,7 @@ class $$UploadTasksTableTableManager
|
||||
required int bytes,
|
||||
required String contentType,
|
||||
required UploadTaskState state,
|
||||
Value<bool> force = const Value.absent(),
|
||||
Value<int> attempt = const Value.absent(),
|
||||
Value<DateTime?> nextRetryAt = const Value.absent(),
|
||||
Value<String?> serverUploadId = const Value.absent(),
|
||||
@@ -1530,6 +1599,7 @@ class $$UploadTasksTableTableManager
|
||||
bytes: bytes,
|
||||
contentType: contentType,
|
||||
state: state,
|
||||
force: force,
|
||||
attempt: attempt,
|
||||
nextRetryAt: nextRetryAt,
|
||||
serverUploadId: serverUploadId,
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
/// | 网络不可达/超时/429 限速 | waitingNetwork | 退避 5s·2^n 封顶 10min,无次数上限 |
|
||||
/// | 401 会话过期 | waitingNetwork | 30s 短退避;全局驱逐由 ApiClient 处理,不烧次数 |
|
||||
/// | 400 UPLOAD_VERIFY_FAILED / 5xx / 未知 | retryableFailed | 退避重试,3 次后停止自动重试 |
|
||||
/// | 409 ALBUM_UPLOAD_DISABLED / UPLOAD_SESSION_CONFLICT / 源文件缺失 | retryableFailed | 不自动重试(人工修正后 retryNow) |
|
||||
/// | 409 ALBUM_UPLOAD_DISABLED / UPLOAD_SESSION_CONFLICT / PHOTO_DUPLICATE / 源文件缺失 | retryableFailed | 不自动重试(人工修正后 retryNow) |
|
||||
/// | presign 403 凭证过期 | (renew 后原状重传) | renewUpload 至多 2 次 |
|
||||
/// - **取消**([cancel]):未开始 → cancelled 后删行删文件(确定语义);
|
||||
/// 进行中 → cancelToken 尽力取消 + 置 cancelled(文件保留,由清理
|
||||
@@ -335,6 +335,9 @@ class UploadEngine {
|
||||
|
||||
Future<void> _transferSteps(UploadTask task, _TransferHandle handle) async {
|
||||
// a. initUpload(幂等:重试复用 clientTaskId,服务端返回原会话)。
|
||||
// force(Task 8):查重弹窗「全部上传」入队时落库的用户确认——重试
|
||||
// 与 retryNow 均沿用任务行标记(用户已确认过的强传不因重试退化为
|
||||
// 再被 409 拒绝)。
|
||||
final init = await _apiClient.initUpload(
|
||||
UploadInitRequest(
|
||||
albumId: task.albumId,
|
||||
@@ -344,6 +347,7 @@ class UploadEngine {
|
||||
bytes: task.bytes,
|
||||
contentType: task.contentType,
|
||||
sourceDeviceId: await _deviceId.value(),
|
||||
force: task.force,
|
||||
),
|
||||
);
|
||||
// 回填会话/照片 ID(状态仍 uploading,旁路写入,见 TaskStore.updateMeta)。
|
||||
@@ -486,10 +490,14 @@ class UploadEngine {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) 客户端重试无意义(服务端开关/幂等冲突/源文件缺失):不自动
|
||||
// 重试,用户可见 lastError,修正后 retryNow 手动重试。
|
||||
// 3) 客户端重试无意义(服务端开关/幂等冲突/查重拒绝/源文件缺失):
|
||||
// 不自动重试,用户可见 lastError,修正后 retryNow 手动重试。
|
||||
// PHOTO_DUPLICATE(P0-1 Task 8):重发同体 init 必再 409——用户想
|
||||
// 强传需重走选图弹窗「全部上传」(force 仅 retryNow 外的入队路径
|
||||
// 携带;retryNow 保持任务原 force 标记)。
|
||||
if (code == ErrorCodes.albumUploadDisabled ||
|
||||
code == ErrorCodes.uploadSessionConflict ||
|
||||
code == ErrorCodes.photoDuplicate ||
|
||||
code == UploadEngine.sourceMissingCode) {
|
||||
await _fail(
|
||||
task,
|
||||
|
||||
Reference in New Issue
Block a user