diff --git a/apps/mobile/lib/core/api_client.dart b/apps/mobile/lib/core/api_client.dart index 7dfe934..6148d92 100644 --- a/apps/mobile/lib/core/api_client.dart +++ b/apps/mobile/lib/core/api_client.dart @@ -137,6 +137,18 @@ class ApiClient { final Object? message = data['message']; final Object? requestId = data['requestId']; if (code is String && message is String) { + // P0-1 服务端查重拒绝(Task 8):409 的 wire code 复用 + // VALIDATION_FAILED,按 message=PHOTO_DUPLICATE 识别归一——所有 + // 调用方(上传引擎分类/l10n 文案)据此走查重分支;契约日后若设 + // 独立 code,此处识别与透传均兼容。 + if (status == 409 && message.contains(ErrorCodes.photoDuplicate)) { + return AppException( + code: ErrorCodes.photoDuplicate, + message: message, + requestId: requestId is String ? requestId : null, + statusCode: status, + ); + } return AppException( code: code, message: message, diff --git a/apps/mobile/lib/core/error_codes.dart b/apps/mobile/lib/core/error_codes.dart index 0760222..484f022 100644 --- a/apps/mobile/lib/core/error_codes.dart +++ b/apps/mobile/lib/core/error_codes.dart @@ -68,6 +68,12 @@ abstract final class ErrorCodes { // ── 以下为客户端本地伪码(契约外,见类注释) ── + /// 照片疑似重复(409,服务端查重拒绝)。P0-1 契约现状:wire 体 code + /// 复用 VALIDATION_FAILED、靠 message=PHOTO_DUPLICATE 识别——ApiClient + /// 归一为本码(若契约日后设独立 code,归一自动兼容)。skip 服务端查重 + /// 的 force 请求仅 owner/admin 生效(服务端裁决)。 + static const String photoDuplicate = 'PHOTO_DUPLICATE'; + /// 网络连接失败(DNS/握手/断网等连接层错误)。 static const String networkError = 'NETWORK_ERROR'; diff --git a/apps/mobile/lib/core/error_l10n.dart b/apps/mobile/lib/core/error_l10n.dart index 174a3d5..13af723 100644 --- a/apps/mobile/lib/core/error_l10n.dart +++ b/apps/mobile/lib/core/error_l10n.dart @@ -26,6 +26,8 @@ extension AppLocalizationsErrorX on AppLocalizations { return errorAuthTokenExpired; case ErrorCodes.validationFailed: return errorValidation; + case ErrorCodes.photoDuplicate: // P0-1 服务端查重拒绝(409 归一码)。 + return errorPhotoDuplicate; case ErrorCodes.networkError: return errorNetwork; case ErrorCodes.networkTimeout: diff --git a/apps/mobile/lib/features/uploads/enqueue.dart b/apps/mobile/lib/features/uploads/enqueue.dart index e62df7c..d0e09aa 100644 --- a/apps/mobile/lib/features/uploads/enqueue.dart +++ b/apps/mobile/lib/features/uploads/enqueue.dart @@ -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( /// 选图软上限(用户要求不设硬限制;9999 为防误触极端值)。 const int kPickerSoftMaxAssets = 9999; +/// 查重弹窗选择结果(Task 8):null=取消(不上传)。 +/// +/// 「跳过重复」→ skipPaths=重复项(仅非重复入队);「全部上传」→ +/// forcePaths=重复项(强传,见库级注释)。两个集合互斥且可为空。 +typedef DuplicateDialogChoice + = ({Set skipPaths, Set forcePaths}); + /// 查重结果(单张):匹配到的已上传任务摘要。 class DuplicateMatch { const DuplicateMatch({ @@ -162,6 +174,10 @@ Future?> checkDuplicates( /// 批量入队:通知权限单次请求 + 逐张准备入队。 /// +/// [skipPaths]:用户选择跳过的重复项;[forcePaths]:查重弹窗「全部上传」 +/// 确认强传的重复项(Task 8——落到任务行 force 标记,引擎 initUpload +/// 携带;仅 owner/admin 生效,服务端裁决)。两者互斥。 +/// /// 返回 [EnqueueResult](含 enqueued 计数与 duplicates 列表)。 /// 准备失败的任务已落库为 preparationFailed(连拍页失败徽章/队列页可见, /// 不在此抛出)。 @@ -172,6 +188,7 @@ Future enqueueForUpload( String? categoryId, required List sourcePaths, List? skipPaths, + List? forcePaths, }) async { // 首次入队动作触发(幂等;拒绝/非 Android 静默降级,见实现注释)。 await ref.read(notificationPermissionProvider).ensureRequested(); @@ -183,12 +200,16 @@ Future enqueueForUpload( .where((path) => !skipSet.contains(path)) .toList(); + // Task 8:「全部上传」确认的强传项(重复项 force=true 落任务行)。 + final forceSet = forcePaths?.toSet() ?? {}; + 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 diff --git a/apps/mobile/lib/features/uploads/preparer.dart b/apps/mobile/lib/features/uploads/preparer.dart index dda6076..c05b10b 100644 --- a/apps/mobile/lib/features/uploads/preparer.dart +++ b/apps/mobile/lib/features/uploads/preparer.dart @@ -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, ), ); diff --git a/apps/mobile/lib/features/uploads/task_store.dart b/apps/mobile/lib/features/uploads/task_store.dart index 250bf65..fc7e47f 100644 --- a/apps/mobile/lib/features/uploads/task_store.dart +++ b/apps/mobile/lib/features/uploads/task_store.dart @@ -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, diff --git a/apps/mobile/lib/features/uploads/task_store.g.dart b/apps/mobile/lib/features/uploads/task_store.g.dart index c0b0509..d211065 100644 --- a/apps/mobile/lib/features/uploads/task_store.g.dart +++ b/apps/mobile/lib/features/uploads/task_store.g.dart @@ -113,6 +113,19 @@ class $UploadTasksTable extends UploadTasks type: DriftSqlType.string, requiredDuringInsert: true, ).withConverter($UploadTasksTable.$converterstate); + static const VerificationMeta _forceMeta = const VerificationMeta('force'); + @override + late final GeneratedColumn force = GeneratedColumn( + '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 { /// 任务状态(状态机见 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 { 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 { $UploadTasksTable.$converterstate.toSql(state), ); } + map['force'] = Variable(force); map['attempt'] = Variable(attempt); if (!nullToAbsent || nextRetryAt != null) { map['next_retry_at'] = Variable(nextRetryAt); @@ -598,6 +629,7 @@ class UploadTask extends DataClass implements Insertable { 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 { bytes: serializer.fromJson(json['bytes']), contentType: serializer.fromJson(json['contentType']), state: serializer.fromJson(json['state']), + force: serializer.fromJson(json['force']), attempt: serializer.fromJson(json['attempt']), nextRetryAt: serializer.fromJson(json['nextRetryAt']), serverUploadId: serializer.fromJson(json['serverUploadId']), @@ -659,6 +692,7 @@ class UploadTask extends DataClass implements Insertable { 'bytes': serializer.toJson(bytes), 'contentType': serializer.toJson(contentType), 'state': serializer.toJson(state), + 'force': serializer.toJson(force), 'attempt': serializer.toJson(attempt), 'nextRetryAt': serializer.toJson(nextRetryAt), 'serverUploadId': serializer.toJson(serverUploadId), @@ -681,6 +715,7 @@ class UploadTask extends DataClass implements Insertable { int? bytes, String? contentType, UploadTaskState? state, + bool? force, int? attempt, Value nextRetryAt = const Value.absent(), Value serverUploadId = const Value.absent(), @@ -700,6 +735,7 @@ class UploadTask extends DataClass implements Insertable { 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 { ? 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 { ..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 { bytes, contentType, state, + force, attempt, nextRetryAt, serverUploadId, @@ -810,6 +849,7 @@ class UploadTask extends DataClass implements Insertable { 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 { final Value bytes; final Value contentType; final Value state; + final Value force; final Value attempt; final Value nextRetryAt; final Value serverUploadId; @@ -851,6 +892,7 @@ class UploadTasksCompanion extends UpdateCompanion { 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 { 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 { Expression? bytes, Expression? contentType, Expression? state, + Expression? force, Expression? attempt, Expression? nextRetryAt, Expression? serverUploadId, @@ -924,6 +968,7 @@ class UploadTasksCompanion extends UpdateCompanion { 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 { Value? bytes, Value? contentType, Value? state, + Value? force, Value? attempt, Value? nextRetryAt, Value? serverUploadId, @@ -968,6 +1014,7 @@ class UploadTasksCompanion extends UpdateCompanion { 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 { $UploadTasksTable.$converterstate.toSql(state.value), ); } + if (force.present) { + map['force'] = Variable(force.value); + } if (attempt.present) { map['attempt'] = Variable(attempt.value); } @@ -1058,6 +1108,7 @@ class UploadTasksCompanion extends UpdateCompanion { ..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 force, Value attempt, Value nextRetryAt, Value serverUploadId, @@ -1137,6 +1189,7 @@ typedef $$UploadTasksTableUpdateCompanionBuilder = Value bytes, Value contentType, Value state, + Value force, Value attempt, Value nextRetryAt, Value serverUploadId, @@ -1208,6 +1261,11 @@ class $$UploadTasksTableFilterComposer builder: (column) => ColumnWithTypeConverterFilters(column), ); + ColumnFilters get force => $composableBuilder( + column: $table.force, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get attempt => $composableBuilder( column: $table.attempt, builder: (column) => ColumnFilters(column), @@ -1308,6 +1366,11 @@ class $$UploadTasksTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get force => $composableBuilder( + column: $table.force, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get attempt => $composableBuilder( column: $table.attempt, builder: (column) => ColumnOrderings(column), @@ -1396,6 +1459,9 @@ class $$UploadTasksTableAnnotationComposer GeneratedColumnWithTypeConverter get state => $composableBuilder(column: $table.state, builder: (column) => column); + GeneratedColumn get force => + $composableBuilder(column: $table.force, builder: (column) => column); + GeneratedColumn get attempt => $composableBuilder(column: $table.attempt, builder: (column) => column); @@ -1468,6 +1534,7 @@ class $$UploadTasksTableTableManager Value bytes = const Value.absent(), Value contentType = const Value.absent(), Value state = const Value.absent(), + Value force = const Value.absent(), Value attempt = const Value.absent(), Value nextRetryAt = const Value.absent(), Value 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 force = const Value.absent(), Value attempt = const Value.absent(), Value nextRetryAt = const Value.absent(), Value serverUploadId = const Value.absent(), @@ -1530,6 +1599,7 @@ class $$UploadTasksTableTableManager bytes: bytes, contentType: contentType, state: state, + force: force, attempt: attempt, nextRetryAt: nextRetryAt, serverUploadId: serverUploadId, diff --git a/apps/mobile/lib/features/uploads/upload_engine.dart b/apps/mobile/lib/features/uploads/upload_engine.dart index 9500513..828a054 100644 --- a/apps/mobile/lib/features/uploads/upload_engine.dart +++ b/apps/mobile/lib/features/uploads/upload_engine.dart @@ -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 _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, diff --git a/apps/mobile/lib/l10n/app_en.arb b/apps/mobile/lib/l10n/app_en.arb index f3e4425..98e4672 100644 --- a/apps/mobile/lib/l10n/app_en.arb +++ b/apps/mobile/lib/l10n/app_en.arb @@ -293,6 +293,7 @@ "errorAlbumUploadDisabled": "Album uploads are disabled", "errorUploadSessionConflict": "Upload session conflict. Please retry", "errorUploadVerifyFailed": "Upload verification failed. Will retry", + "errorPhotoDuplicate": "Possible duplicate: an identical photo already exists in this album", "captureTitle": "Continuous capture", "captureCategoryDefault": "Default category", "captureSwitchHint": "Switching only affects photos captured afterwards", diff --git a/apps/mobile/lib/l10n/app_localizations.dart b/apps/mobile/lib/l10n/app_localizations.dart index c13f689..74ac07f 100644 --- a/apps/mobile/lib/l10n/app_localizations.dart +++ b/apps/mobile/lib/l10n/app_localizations.dart @@ -1562,6 +1562,12 @@ abstract class AppLocalizations { /// **'上传核验失败,将自动重试'** String get errorUploadVerifyFailed; + /// No description provided for @errorPhotoDuplicate. + /// + /// In zh, this message translates to: + /// **'照片可能重复:同相册已存在相同内容的照片'** + String get errorPhotoDuplicate; + /// No description provided for @captureTitle. /// /// In zh, this message translates to: diff --git a/apps/mobile/lib/l10n/app_localizations_en.dart b/apps/mobile/lib/l10n/app_localizations_en.dart index fba570f..2b0289e 100644 --- a/apps/mobile/lib/l10n/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/app_localizations_en.dart @@ -780,6 +780,10 @@ class AppLocalizationsEn extends AppLocalizations { String get errorUploadVerifyFailed => 'Upload verification failed. Will retry'; + @override + String get errorPhotoDuplicate => + 'Possible duplicate: an identical photo already exists in this album'; + @override String get captureTitle => 'Continuous capture'; diff --git a/apps/mobile/lib/l10n/app_localizations_zh.dart b/apps/mobile/lib/l10n/app_localizations_zh.dart index e73ec4a..84f0c66 100644 --- a/apps/mobile/lib/l10n/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/app_localizations_zh.dart @@ -755,6 +755,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get errorUploadVerifyFailed => '上传核验失败,将自动重试'; + @override + String get errorPhotoDuplicate => '照片可能重复:同相册已存在相同内容的照片'; + @override String get captureTitle => '连拍上传'; diff --git a/apps/mobile/lib/l10n/app_zh.arb b/apps/mobile/lib/l10n/app_zh.arb index 7ebb132..a4c9617 100644 --- a/apps/mobile/lib/l10n/app_zh.arb +++ b/apps/mobile/lib/l10n/app_zh.arb @@ -293,6 +293,7 @@ "errorAlbumUploadDisabled": "相册已关闭上传", "errorUploadSessionConflict": "上传会话冲突,请重试", "errorUploadVerifyFailed": "上传核验失败,将自动重试", + "errorPhotoDuplicate": "照片可能重复:同相册已存在相同内容的照片", "captureTitle": "连拍上传", "captureCategoryDefault": "默认分类", "captureSwitchHint": "切换分类仅影响之后拍摄的照片", diff --git a/apps/mobile/lib/pages/album_detail_page.dart b/apps/mobile/lib/pages/album_detail_page.dart index 8fe0eaf..19dcf4a 100644 --- a/apps/mobile/lib/pages/album_detail_page.dart +++ b/apps/mobile/lib/pages/album_detail_page.dart @@ -351,10 +351,12 @@ class _AlbumDetailPageState extends ConsumerState { ); Set skipPaths = {}; + Set forcePaths = {}; if (duplicates != null && duplicates.isNotEmpty && mounted) { - final skip = await _showDuplicateDialog(l10n, duplicates, paths); - if (skip == null) return; // 取消。 - skipPaths = skip; + final choice = await _showDuplicateDialog(l10n, duplicates, paths); + if (choice == null) return; // 取消。 + skipPaths = choice.skipPaths; + forcePaths = choice.forcePaths; } final result = await enqueueForUpload( @@ -363,6 +365,7 @@ class _AlbumDetailPageState extends ConsumerState { albumId: album.id, sourcePaths: paths, skipPaths: skipPaths.toList(), + forcePaths: forcePaths.toList(), ); if (!mounted) return; messenger.showSnackBar( @@ -376,8 +379,10 @@ class _AlbumDetailPageState extends ConsumerState { ); } - /// 批量重复确认弹窗(P0-1):返回 null=取消 / 空 Set=全部上传 / 非空=跳过项。 - Future?> _showDuplicateDialog( + /// 批量重复确认弹窗(Task 8):null=取消;「跳过重复」→ skipPaths= + /// 重复项;「全部上传」→ forcePaths=重复项(用户确认强传,服务端 + /// force 查重跳过仅 owner/admin 生效——协作者仍会被 409 拒绝)。 + Future _showDuplicateDialog( AppLocalizations l10n, List duplicates, List allPaths, @@ -417,8 +422,11 @@ class _AlbumDetailPageState extends ConsumerState { ); if (choice == 'cancel' || choice == null) return null; - if (choice == 'skip') return dupPaths; - return {}; + if (choice == 'skip') { + return (skipPaths: dupPaths, forcePaths: {}); + } + // 'all' = 用户确认强传:重复项 force 入队(不跳过任何)。 + return (skipPaths: {}, forcePaths: dupPaths); } @override diff --git a/apps/mobile/lib/pages/capture_page.dart b/apps/mobile/lib/pages/capture_page.dart index d636cd8..68cdfad 100644 --- a/apps/mobile/lib/pages/capture_page.dart +++ b/apps/mobile/lib/pages/capture_page.dart @@ -462,13 +462,16 @@ class _CapturePageState extends ConsumerState ); Set skipPaths; + Set forcePaths; if (duplicates != null && duplicates.isNotEmpty && mounted) { // 批量确认弹窗(单次,非逐张)。 - final skip = await _showDuplicateDialog(l10n, duplicates, paths); - if (skip == null) return; // 取消——不上传。 - skipPaths = skip; + final choice = await _showDuplicateDialog(l10n, duplicates, paths); + if (choice == null) return; // 取消——不上传。 + skipPaths = choice.skipPaths; + forcePaths = choice.forcePaths; } else { skipPaths = {}; + forcePaths = {}; } final result = await enqueueForUpload( @@ -478,6 +481,7 @@ class _CapturePageState extends ConsumerState categoryId: _categoryId, sourcePaths: paths, skipPaths: skipPaths.toList(), + forcePaths: forcePaths.toList(), ); if (!mounted) return; final uploaded = result.enqueued; @@ -493,8 +497,10 @@ class _CapturePageState extends ConsumerState ); } - /// 批量重复确认弹窗:返回 null=取消 / 空 Set=全部上传 / 非空=跳过项路径集。 - Future?> _showDuplicateDialog( + /// 批量重复确认弹窗(Task 8):null=取消;「跳过重复」→ skipPaths= + /// 重复项;「全部上传」→ forcePaths=重复项(用户确认强传,服务端 + /// force 查重跳过仅 owner/admin 生效——协作者仍会被 409 拒绝)。 + Future _showDuplicateDialog( AppLocalizations l10n, List duplicates, List allPaths, @@ -535,8 +541,11 @@ class _CapturePageState extends ConsumerState ); if (choice == 'cancel' || choice == null) return null; - if (choice == 'skip') return dupPaths; - return {}; // 'all' = 不跳过任何 + if (choice == 'skip') { + return (skipPaths: dupPaths, forcePaths: {}); + } + // 'all' = 用户确认强传:重复项 force 入队(不跳过任何)。 + return (skipPaths: {}, forcePaths: dupPaths); } void _switchCategory(String? categoryId) { diff --git a/apps/mobile/test/core/error_codes_test.dart b/apps/mobile/test/core/error_codes_test.dart index 9277fc5..054de38 100644 --- a/apps/mobile/test/core/error_codes_test.dart +++ b/apps/mobile/test/core/error_codes_test.dart @@ -106,6 +106,50 @@ void main() { }); }); + group('mapDioException:P0-1 服务端查重 409 归一(Task 8)', () { + test('409 VALIDATION_FAILED + message PHOTO_DUPLICATE → 归一为 PHOTO_DUPLICATE', () { + final ex = ApiClient.mapDioException(_httpError(409, { + 'code': ErrorCodes.validationFailed, + 'message': 'PHOTO_DUPLICATE', + 'requestId': 'req-dup', + })); + expect(ex.code, ErrorCodes.photoDuplicate); + expect(ex.statusCode, 409); + expect(ex.requestId, 'req-dup', reason: '诊断字段保留'); + }); + + test('契约日后设独立 code=PHOTO_DUPLICATE → 原样透传(归一兼容演进)', () { + final ex = ApiClient.mapDioException(_httpError(409, { + 'code': ErrorCodes.photoDuplicate, + 'message': 'duplicate photo', + 'requestId': 'r', + })); + expect(ex.code, ErrorCodes.photoDuplicate); + expect(ex.statusCode, 409); + }); + + test('非查重语义不受归一影响(同 code 不同 message / 非 409)', () { + // 409 VALIDATION_FAILED 但 message 不是查重(如幂等参数校验)。 + expect( + ApiClient.mapDioException(_httpError(409, { + 'code': ErrorCodes.validationFailed, + 'message': 'bytes out of range', + 'requestId': 'r', + })).code, + ErrorCodes.validationFailed, + ); + // 400 VALIDATION_FAILED 是普通校验失败(非查重拒绝路径)。 + expect( + ApiClient.mapDioException(_httpError(400, { + 'code': ErrorCodes.validationFailed, + 'message': 'PHOTO_DUPLICATE', + 'requestId': 'r', + })).code, + ErrorCodes.validationFailed, + ); + }); + }); + group('mapDioException:网络层(客户端伪码)', () { test('连接失败 → NETWORK_ERROR', () { final ex = ApiClient.mapDioException( diff --git a/apps/mobile/test/features/uploads/preparer_test.dart b/apps/mobile/test/features/uploads/preparer_test.dart index 7a417a5..242f472 100644 --- a/apps/mobile/test/features/uploads/preparer_test.dart +++ b/apps/mobile/test/features/uploads/preparer_test.dart @@ -124,6 +124,26 @@ void main() { await p.prepare(accountId: 'alice', albumId: 'alb-1', sourcePath: src.path); expect(enqueued, 1); }); + + test('force 透传(Task 8):缺省 false,force=true 落到任务行', () async { + final src = sourceFile('force.jpg', _jpgBytes); + final p = preparer(); + + final normal = await p.prepare( + accountId: 'alice', + albumId: 'alb-1', + sourcePath: src.path, + ); + expect(normal.force, isFalse, reason: '缺省不强传'); + + final forced = await p.prepare( + accountId: 'alice', + albumId: 'alb-1', + sourcePath: src.path, + force: true, + ); + expect(forced.force, isTrue, reason: '弹窗「全部上传」的强传标记落库'); + }); }); group('prepare(预检失败 → preparationFailed)', () { diff --git a/apps/mobile/test/features/uploads/task_store_test.dart b/apps/mobile/test/features/uploads/task_store_test.dart index b7b0258..567a99b 100644 --- a/apps/mobile/test/features/uploads/task_store_test.dart +++ b/apps/mobile/test/features/uploads/task_store_test.dart @@ -11,6 +11,8 @@ /// - datetime 文本落库的亚秒精度。 library; +import 'package:drift/drift.dart' show driftRuntimeOptions; +import 'package:drift/native.dart' show NativeDatabase; import 'package:flutter_test/flutter_test.dart'; import 'package:photo_live/features/uploads/task_state.dart'; import 'package:photo_live/features/uploads/task_store.dart'; @@ -26,6 +28,7 @@ UploadTaskDraft draft({ String filename = 'IMG_0001.jpg', int bytes = 1024, String contentType = 'image/jpeg', + bool force = false, }) { return UploadTaskDraft( id: id, @@ -37,6 +40,7 @@ UploadTaskDraft draft({ filename: filename, bytes: bytes, contentType: contentType, + force: force, ); } @@ -76,6 +80,10 @@ Future advanceTo( } void main() { + // 迁移组在同一测试内另建独立执行器的库(与 setUp 的内存库并存)—— + // drift 多实例告警在此是误报(见 helpers/fakes.dart 同款处理)。 + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + late TaskStore store; setUp(() { @@ -103,6 +111,13 @@ void main() { expect(task.bytes, 1024); expect(task.createdAt, createdAt); expect(task.updatedAt, createdAt); + expect(task.force, isFalse, reason: '缺省不强传(服务端查重正常裁决)'); + }); + + test('force=true 落库(弹窗「全部上传」确认强传的任务级标记)', () async { + final task = await store.create(draft(force: true)); + expect(task.force, isTrue); + expect((await store.get('alice', 'task-0001'))!.force, isTrue); }); test('datetime 文本落库保住亚秒/微秒精度,且本地输入统一 UTC 落库', () async { @@ -541,4 +556,66 @@ void main() { expect(await store.delete('task-0001'), 0, reason: '重复删除返回 0'); }); }); + + group('schema 迁移(v1 → v2:force 列,Task 8)', () { + /// v1 底库(无 force 列)+ 一条既有行,user_version=1——升级后打开。 + UploadTaskDb legacyV1Db() => UploadTaskDb( + NativeDatabase.memory(setup: (raw) { + raw.execute(''' + CREATE TABLE IF NOT EXISTS "upload_tasks" ( + "id" TEXT NOT NULL PRIMARY KEY, + "account_id" TEXT NOT NULL, + "album_id" TEXT NOT NULL, + "category_id" TEXT NULL, + "client_task_id" TEXT NOT NULL, + "source_file_path" TEXT NOT NULL, + "filename" TEXT NOT NULL, + "bytes" INTEGER NOT NULL, + "content_type" TEXT NOT NULL, + "state" TEXT NOT NULL, + "attempt" INTEGER NOT NULL DEFAULT 0, + "next_retry_at" TEXT NULL, + "server_upload_id" TEXT NULL, + "server_photo_id" TEXT NULL, + "last_error" TEXT NULL, + "cleaned_at" TEXT NULL, + "created_at" TEXT NOT NULL, + "updated_at" TEXT NOT NULL + ); + '''); + raw.execute(''' + INSERT INTO upload_tasks ( + "id", "account_id", "album_id", "client_task_id", + "source_file_path", "filename", "bytes", "content_type", + "state", "created_at", "updated_at" + ) VALUES ( + 'task-legacy', 'alice', 'alb-1', 'ct-legacy', + '/managed/uploads/alb-1/legacy.jpg', 'IMG_LEGACY.jpg', 2048, + 'image/jpeg', 'completed', + '2026-09-01T00:00:00.000Z', '2026-09-01T00:01:00.000Z' + ); + '''); + raw.execute('PRAGMA user_version = 1;'); + }), + ); + + test('旧库升级:既有行保留、force=false 默认、新列可写', () async { + final db = legacyV1Db(); + final migrated = TaskStore(db); + addTearDown(migrated.close); + + final legacy = await migrated.get('alice', 'task-legacy'); + expect(legacy, isNotNull, reason: '升级不丢既有行'); + expect(legacy!.state, UploadTaskState.completed); + expect(legacy.filename, 'IMG_LEGACY.jpg'); + expect(legacy.bytes, 2048); + expect(legacy.force, isFalse, reason: 'v1 行无 force 列:迁移默认 false'); + + // 升级后新任务可写 force=true(列已由迁移补齐)。 + final created = await migrated.create( + draft(id: 'task-new', clientTaskId: 'ct-new', force: true), + ); + expect(created.force, isTrue); + }); + }); } diff --git a/apps/mobile/test/features/uploads/upload_engine_test.dart b/apps/mobile/test/features/uploads/upload_engine_test.dart index ac03f51..3e6b42c 100644 --- a/apps/mobile/test/features/uploads/upload_engine_test.dart +++ b/apps/mobile/test/features/uploads/upload_engine_test.dart @@ -71,6 +71,10 @@ class Harness { int initNetworkFailures = 0; int initUnauthorized = 0; int initAlbumDisabled = 0; + + /// initUpload 查重 409 次数(Task 8:wire 体复用 VALIDATION_FAILED code、 + /// message=PHOTO_DUPLICATE)。 + int initDuplicateRejects = 0; int completeVerifyFailures = 0; int completeNetworkFailures = 0; int putNetworkFailures = 0; @@ -124,6 +128,19 @@ class Harness { initAlbumDisabled--; return (409, errorBody(ErrorCodes.albumUploadDisabled)); } + if (initDuplicateRejects > 0) { + initDuplicateRejects--; + // P0-1 契约现状:查重拒绝 409 的 code 复用 VALIDATION_FAILED, + // 靠 message=PHOTO_DUPLICATE 识别(Task 8 客户端归一依据)。 + return ( + 409, + { + 'code': ErrorCodes.validationFailed, + 'message': 'PHOTO_DUPLICATE', + 'requestId': 'req-dup', + }, + ); + } if (initUnauthorized > 0) { initUnauthorized--; return (401, errorBody(ErrorCodes.authTokenExpired)); @@ -218,6 +235,7 @@ class Harness { String? categoryId, String clientTaskId = 'ct-default', DateTime? createdAt, + bool force = false, }) async { final draft = UploadTaskDraft( id: 'task-$clientTaskId', @@ -229,6 +247,7 @@ class Harness { filename: file.uri.pathSegments.last, bytes: await file.length(), contentType: 'image/jpeg', + force: force, ); final task = await store.create(draft, now: createdAt); return store.updateState( @@ -399,6 +418,8 @@ void main() { expect(body['bytes'], 1024); expect(body['contentType'], 'image/jpeg'); expect(body['sourceDeviceId'], _kDeviceId); + expect(body.containsKey('force'), isFalse, + reason: '非强传任务不下发 force(缺省语义)'); // PUT:预签名 URL + 凭证头 + Content-Length;无 Authorization // (裸 Dio——Bearer 会破坏 MinIO 预签名)。 @@ -417,6 +438,24 @@ void main() { expect(progress[seeded.id]?.last, 1024); }); + test('force 任务:init 请求体携带 force:true(Task 8 查重强传透传)', + () async { + final file = photoFile('force-1.jpg'); + engine = h.buildEngine(); + engine.start('alice', period: const Duration(hours: 1)); + + final seeded = await h.seedTask( + file, + clientTaskId: 'ct-force-1', + force: true, + ); + await cycle(); + + expect((await task(seeded.id)).state, UploadTaskState.completed); + expect(h.initBodies.single['force'], isTrue, + reason: '弹窗「全部上传」确认的强传必须到达服务端'); + }); + test('与准备器集成:preparing → queued → completed(受管副本上传)', () async { final src = photoFile('src.jpg'); @@ -621,6 +660,31 @@ void main() { expect(h.initBodies.length, initCount, reason: '无自动重试'); }); + test('409 PHOTO_DUPLICATE(服务端查重拒绝)→ retryableFailed 不自动重试', + () async { + final file = photoFile('dup.jpg'); + engine = h.buildEngine(); + engine.start('alice', period: const Duration(hours: 1)); + h.initDuplicateRejects = 99; // 永远 409(重试同样被拒)。 + + final seeded = await h.seedTask(file, clientTaskId: 'ct-dup'); + await cycle(); + + final t = await task(seeded.id); + expect(t.state, UploadTaskState.retryableFailed); + // 归一后 lastError=PHOTO_DUPLICATE(此前缺陷:VALIDATION_FAILED 落 + // 桶 4 自动重试,文案误导)。 + expect(t.lastError, ErrorCodes.photoDuplicate); + expect(t.nextRetryAt, isNull, reason: '重试必然再 409:不自动重试'); + expect(t.attempt, 0, reason: '客户端重试无意义,不烧次数'); + + final initCount = h.initBodies.length; + h.clock = h.clock.add(const Duration(hours: 1)); + await cycle(); + expect(h.initBodies.length, initCount, + reason: '无自动重试(单次 init 即终判)'); + }); + test('401 会话过期 → waitingNetwork 短退避;重登录后(重试)完成,' 'attempt 不增长', () async { final file = photoFile('g.jpg'); diff --git a/apps/mobile/test/pages/album_detail_page_test.dart b/apps/mobile/test/pages/album_detail_page_test.dart index 4c8ad09..e0731e3 100644 --- a/apps/mobile/test/pages/album_detail_page_test.dart +++ b/apps/mobile/test/pages/album_detail_page_test.dart @@ -21,6 +21,7 @@ import 'package:photo_live/core/session_store.dart'; import 'package:photo_live/features/uploads/enqueue.dart'; import 'package:photo_live/features/uploads/preparer.dart'; import 'package:photo_live/features/uploads/providers.dart'; +import 'package:photo_live/features/uploads/task_state.dart'; import 'package:photo_live/features/uploads/task_store.dart'; import 'package:photo_live/l10n/app_localizations.dart'; import 'package:photo_live/pages/album_categories_page.dart'; @@ -400,6 +401,69 @@ void main() { expect(tasks, hasLength(2)); expect(tasks.every((t) => t.albumId == 'alb-1'), isTrue); }); + + testWidgets('查重弹窗「全部上传」→ 重复项 force 入队(Task 8)', + (tester) async { + // 既有已完成任务:与 pick1 同名同字节(本地查重命中一张)。 + final seed = await taskStore.create(UploadTaskDraft( + id: 'task-dup-seed', + accountId: 'alice', + albumId: 'alb-1', + clientTaskId: 'ct-dup-seed', + sourceFilePath: '${tmp.path}/seed-dup.jpg', + filename: 'pick1.jpg', + bytes: kJpgBytes.length, + contentType: 'image/jpeg', + )); + for (final state in [ + UploadTaskState.queued, + UploadTaskState.uploading, + UploadTaskState.uploadedConfirming, + UploadTaskState.completed, + ]) { + await taskStore.updateState(seed.id, state); + } + + final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes); + final pick2 = File('${tmp.path}/pick2.jpg')..writeAsBytesSync(kJpgBytes); + galleryPaths.addAll([pick1.path, pick2.path]); + await pumpDetailForUpload(tester); + + // 触发选图:真实 IO 区完成查重,随后弹窗挂起等待选择。 + final button = find.widgetWithText(OutlinedButton, 'Pick from gallery'); + await tester.runAsync(() async { + (button.evaluate().single.widget as OutlinedButton).onPressed!(); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('可能已上传过'), findsOneWidget, reason: '查重弹窗出现'); + + // 「全部上传」= 用户确认强传:重复项 force=true、非重复项不受影响。 + await tester.tap(find.text('全部上传 2 张')); + await tester.pump(const Duration(milliseconds: 100)); + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pumpAndSettle(); + + expect( + find.text('Added 2 photo(s) to the upload queue'), + findsOneWidget, + ); + final tasks = await taskStore.listByAccount('alice'); + final fresh = tasks.where((t) => t.id != seed.id).toList(); + expect(fresh, hasLength(2)); + expect( + fresh.firstWhere((t) => t.filename == 'pick1.jpg').force, + isTrue, + reason: '重复项经「全部上传」确认强传', + ); + expect( + fresh.firstWhere((t) => t.filename == 'pick2.jpg').force, + isFalse, + reason: '非重复项正常查重语义', + ); + }); }); } diff --git a/apps/mobile/test/pages/capture_page_test.dart b/apps/mobile/test/pages/capture_page_test.dart index a1b988d..9764b86 100644 --- a/apps/mobile/test/pages/capture_page_test.dart +++ b/apps/mobile/test/pages/capture_page_test.dart @@ -405,6 +405,63 @@ void main() { expect(await store.listByAccount('alice'), hasLength(2)); }); + testWidgets('查重弹窗「全部上传」→ 重复项 force 入队、非重复项正常(Task 8)', + (tester) async { + // 既有已完成任务:与 pick1 同名同字节(本地查重命中一张)。 + final seed = await store.create(UploadTaskDraft( + id: 'task-dup-seed', + accountId: 'alice', + albumId: 'alb-1', + clientTaskId: 'ct-dup-seed', + sourceFilePath: '${tmp.path}/seed-dup.jpg', + filename: 'pick1.jpg', + bytes: kJpgBytes.length, + contentType: 'image/jpeg', + )); + for (final state in [ + UploadTaskState.queued, + UploadTaskState.uploading, + UploadTaskState.uploadedConfirming, + UploadTaskState.completed, + ]) { + await store.updateState(seed.id, state); + } + + final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes); + final pick2 = File('${tmp.path}/pick2.jpg')..writeAsBytesSync(kJpgBytes); + galleryPaths.addAll([pick1.path, pick2.path]); + await pumpCapture(tester); + + // 相册入口:真实 IO 区完成查重,随后弹窗挂起等待选择。 + await pressRealIo(tester, Icons.photo_library_outlined); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('可能已上传过'), findsOneWidget, reason: '查重弹窗出现'); + + // 「全部上传」= 用户确认强传:重复项 force=true、非重复项不受影响。 + await tester.tap(find.text('全部上传 2 张')); + await tester.pump(const Duration(milliseconds: 100)); + // 入队复制走真实文件 IO:真实事件循环窗口内完成。 + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pumpAndSettle(); + + expect(find.text('Added 2 photo(s) to the upload queue'), findsOneWidget); + final tasks = await store.listByAccount('alice'); + final fresh = tasks.where((t) => t.id != seed.id).toList(); + expect(fresh, hasLength(2)); + expect( + fresh.firstWhere((t) => t.filename == 'pick1.jpg').force, + isTrue, + reason: '重复项经「全部上传」确认强传', + ); + expect( + fresh.firstWhere((t) => t.filename == 'pick2.jpg').force, + isFalse, + reason: '非重复项正常查重语义', + ); + }); + testWidgets('退出确认:有待传任务 → 弹确认;保留继续拍 / 确认才退出', (tester) async { await pumpCapture(tester);