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:
浅念
2026-09-28 17:06:19 +08:00
parent 4f89c25bad
commit 29f3bde9c2
21 changed files with 522 additions and 19 deletions
@@ -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,