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
+12
View File
@@ -137,6 +137,18 @@ class ApiClient {
final Object? message = data['message']; final Object? message = data['message'];
final Object? requestId = data['requestId']; final Object? requestId = data['requestId'];
if (code is String && message is String) { 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( return AppException(
code: code, code: code,
message: message, message: message,
+6
View File
@@ -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/握手/断网等连接层错误)。 /// 网络连接失败(DNS/握手/断网等连接层错误)。
static const String networkError = 'NETWORK_ERROR'; static const String networkError = 'NETWORK_ERROR';
+2
View File
@@ -26,6 +26,8 @@ extension AppLocalizationsErrorX on AppLocalizations {
return errorAuthTokenExpired; return errorAuthTokenExpired;
case ErrorCodes.validationFailed: case ErrorCodes.validationFailed:
return errorValidation; return errorValidation;
case ErrorCodes.photoDuplicate: // P0-1 服务端查重拒绝(409 归一码)。
return errorPhotoDuplicate;
case ErrorCodes.networkError: case ErrorCodes.networkError:
return errorNetwork; return errorNetwork;
case ErrorCodes.networkTimeout: case ErrorCodes.networkTimeout:
@@ -10,6 +10,11 @@
/// P0-1 层 1(UI06 分析 v2):入队前**本地查重**——同账号 + 同相册 + /// P0-1 层 1(UI06 分析 v2):入队前**本地查重**——同账号 + 同相册 +
/// state=uploaded + filename+bytes 匹配 → 返回 [DuplicateCheck],由 /// state=uploaded + filename+bytes 匹配 → 返回 [DuplicateCheck],由
/// 调用方决定弹窗确认或跳过。批量场景汇总后单次弹窗(非逐张)。 /// 调用方决定弹窗确认或跳过。批量场景汇总后单次弹窗(非逐张)。
///
/// Task 8(服务端查重 App 侧集成):弹窗「全部上传」= 用户确认强传——
/// 重复项以 force=true 入队([enqueueForUpload] 的 forcePaths),任务行
/// 落库后由引擎在 initUpload 携带 force 跳过服务端查重(仅 owner/admin
/// 生效,服务端裁决——协作者强传仍会被 409 拒绝并可见失败文案)。
library; library;
import 'dart:io'; import 'dart:io';
@@ -77,6 +82,13 @@ final multiImagePickerProvider = Provider<MultiImagePicker>(
/// 选图软上限(用户要求不设硬限制;9999 为防误触极端值)。 /// 选图软上限(用户要求不设硬限制;9999 为防误触极端值)。
const int kPickerSoftMaxAssets = 9999; const int kPickerSoftMaxAssets = 9999;
/// 查重弹窗选择结果(Task 8):null=取消(不上传)。
///
/// 「跳过重复」→ skipPaths=重复项(仅非重复入队);「全部上传」→
/// forcePaths=重复项(强传,见库级注释)。两个集合互斥且可为空。
typedef DuplicateDialogChoice
= ({Set<String> skipPaths, Set<String> forcePaths});
/// 查重结果(单张):匹配到的已上传任务摘要。 /// 查重结果(单张):匹配到的已上传任务摘要。
class DuplicateMatch { class DuplicateMatch {
const DuplicateMatch({ const DuplicateMatch({
@@ -162,6 +174,10 @@ Future<List<DuplicateMatch>?> checkDuplicates(
/// 批量入队:通知权限单次请求 + 逐张准备入队。 /// 批量入队:通知权限单次请求 + 逐张准备入队。
/// ///
/// [skipPaths]:用户选择跳过的重复项;[forcePaths]:查重弹窗「全部上传」
/// 确认强传的重复项(Task 8——落到任务行 force 标记,引擎 initUpload
/// 携带;仅 owner/admin 生效,服务端裁决)。两者互斥。
///
/// 返回 [EnqueueResult](含 enqueued 计数与 duplicates 列表)。 /// 返回 [EnqueueResult](含 enqueued 计数与 duplicates 列表)。
/// 准备失败的任务已落库为 preparationFailed(连拍页失败徽章/队列页可见, /// 准备失败的任务已落库为 preparationFailed(连拍页失败徽章/队列页可见,
/// 不在此抛出)。 /// 不在此抛出)。
@@ -172,6 +188,7 @@ Future<EnqueueResult> enqueueForUpload(
String? categoryId, String? categoryId,
required List<String> sourcePaths, required List<String> sourcePaths,
List<String>? skipPaths, List<String>? skipPaths,
List<String>? forcePaths,
}) async { }) async {
// 首次入队动作触发(幂等;拒绝/非 Android 静默降级,见实现注释)。 // 首次入队动作触发(幂等;拒绝/非 Android 静默降级,见实现注释)。
await ref.read(notificationPermissionProvider).ensureRequested(); await ref.read(notificationPermissionProvider).ensureRequested();
@@ -183,12 +200,16 @@ Future<EnqueueResult> enqueueForUpload(
.where((path) => !skipSet.contains(path)) .where((path) => !skipSet.contains(path))
.toList(); .toList();
// Task 8:「全部上传」确认的强传项(重复项 force=true 落任务行)。
final forceSet = forcePaths?.toSet() ?? <String>{};
final results = await Future.wait( final results = await Future.wait(
effective.map((path) => preparer.prepare( effective.map((path) => preparer.prepare(
accountId: accountId, accountId: accountId,
albumId: albumId, albumId: albumId,
categoryId: categoryId, categoryId: categoryId,
sourcePath: path, sourcePath: path,
force: forceSet.contains(path),
)), )),
); );
final enqueued = results final enqueued = results
@@ -87,7 +87,9 @@ class UploadPreparer {
/// 准备一张图片并入队。 /// 准备一张图片并入队。
/// ///
/// [sourcePath] 为 image_picker/camera 返回的源文件路径(App 沙箱内 /// [sourcePath] 为 image_picker/camera 返回的源文件路径(App 沙箱内
/// 可直接 File 读取);[filename] 缺省取源文件 basename。 /// 可直接 File 读取);[filename] 缺省取源文件 basename;[force] 为
/// 查重弹窗「全部上传」的用户确认(Task 8)——落到任务行,由引擎在
/// initUpload 携带(仅 owner/admin 生效,服务端裁决)。
/// ///
/// 返回入队后的任务(state=queued);失败返回 state=preparationFailed /// 返回入队后的任务(state=queued);失败返回 state=preparationFailed
/// 的任务(不抛异常——准备失败是业务状态而非调用错误)。 /// 的任务(不抛异常——准备失败是业务状态而非调用错误)。
@@ -97,6 +99,7 @@ class UploadPreparer {
String? categoryId, String? categoryId,
required String sourcePath, required String sourcePath,
String? filename, String? filename,
bool force = false,
}) { }) {
return _runBounded( return _runBounded(
() => _prepare( () => _prepare(
@@ -105,6 +108,7 @@ class UploadPreparer {
categoryId: categoryId, categoryId: categoryId,
sourcePath: sourcePath, sourcePath: sourcePath,
filename: filename, filename: filename,
force: force,
), ),
); );
} }
@@ -164,6 +168,7 @@ class UploadPreparer {
required String? categoryId, required String? categoryId,
required String sourcePath, required String sourcePath,
required String? filename, required String? filename,
required bool force,
}) async { }) async {
final id = _newId(); final id = _newId();
final clientTaskId = _newId(); final clientTaskId = _newId();
@@ -188,6 +193,7 @@ class UploadPreparer {
// 预检失败时占位(该行永不进入传输;成功路径 bytes 来自源 stat)。 // 预检失败时占位(该行永不进入传输;成功路径 bytes 来自源 stat)。
bytes: bytes > 0 ? bytes : 1, bytes: bytes > 0 ? bytes : 1,
contentType: contentType ?? 'application/octet-stream', contentType: contentType ?? 'application/octet-stream',
force: force,
), ),
); );
@@ -89,6 +89,11 @@ class UploadTasks extends Table {
TextColumn get state => TextColumn get state =>
text().map(const _UploadTaskStateConverter())(); 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))(); IntColumn get attempt => integer().withDefault(const Constant(0))();
@@ -128,8 +133,18 @@ class UploadTaskDb extends _$UploadTaskDb {
/// 测试用内存库(drift NativeDatabase.memory,随 close 丢弃)。 /// 测试用内存库(drift NativeDatabase.memory,随 close 丢弃)。
UploadTaskDb.memory() : super(NativeDatabase.memory()); UploadTaskDb.memory() : super(NativeDatabase.memory());
/// v1:初始表;v2(Task 8):+force 列(查重弹窗「全部上传」强传标记)。
@override @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 执行 /// 打开生产库:app documents 目录下 upload_tasks.sqlite,后台 isolate 执行
@@ -154,6 +169,7 @@ class UploadTaskDraft {
required this.filename, required this.filename,
required this.bytes, required this.bytes,
required this.contentType, required this.contentType,
this.force = false,
}); });
/// 任务 ID(uuid,调用方生成)。 /// 任务 ID(uuid,调用方生成)。
@@ -182,6 +198,9 @@ class UploadTaskDraft {
/// MIME 类型。 /// MIME 类型。
final String contentType; final String contentType;
/// 用户已确认强传(查重弹窗「全部上传」);initUpload 携带 force。
final bool force;
} }
/// 状态迁移时一并写入的旁路字段(未提供的列保持不变;`null` 与"清除"通过 /// 状态迁移时一并写入的旁路字段(未提供的列保持不变;`null` 与"清除"通过
@@ -279,6 +298,7 @@ class TaskStore {
filename: draft.filename, filename: draft.filename,
bytes: draft.bytes, bytes: draft.bytes,
contentType: draft.contentType, contentType: draft.contentType,
force: Value(draft.force),
state: UploadTaskState.preparing, state: UploadTaskState.preparing,
createdAt: timestamp, createdAt: timestamp,
updatedAt: timestamp, updatedAt: timestamp,
@@ -113,6 +113,19 @@ class $UploadTasksTable extends UploadTasks
type: DriftSqlType.string, type: DriftSqlType.string,
requiredDuringInsert: true, requiredDuringInsert: true,
).withConverter<UploadTaskState>($UploadTasksTable.$converterstate); ).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( static const VerificationMeta _attemptMeta = const VerificationMeta(
'attempt', 'attempt',
); );
@@ -214,6 +227,7 @@ class $UploadTasksTable extends UploadTasks
bytes, bytes,
contentType, contentType,
state, state,
force,
attempt, attempt,
nextRetryAt, nextRetryAt,
serverUploadId, serverUploadId,
@@ -311,6 +325,12 @@ class $UploadTasksTable extends UploadTasks
} else if (isInserting) { } else if (isInserting) {
context.missing(_contentTypeMeta); context.missing(_contentTypeMeta);
} }
if (data.containsKey('force')) {
context.handle(
_forceMeta,
force.isAcceptableOrUnknown(data['force']!, _forceMeta),
);
}
if (data.containsKey('attempt')) { if (data.containsKey('attempt')) {
context.handle( context.handle(
_attemptMeta, _attemptMeta,
@@ -423,6 +443,10 @@ class $UploadTasksTable extends UploadTasks
data['${effectivePrefix}state'], data['${effectivePrefix}state'],
)!, )!,
), ),
force: attachedDatabase.typeMapping.read(
DriftSqlType.bool,
data['${effectivePrefix}force'],
)!,
attempt: attachedDatabase.typeMapping.read( attempt: attachedDatabase.typeMapping.read(
DriftSqlType.int, DriftSqlType.int,
data['${effectivePrefix}attempt'], data['${effectivePrefix}attempt'],
@@ -498,6 +522,11 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
/// 任务状态(状态机见 task_state.dart)。 /// 任务状态(状态机见 task_state.dart)。
final UploadTaskState state; final UploadTaskState state;
/// 用户已确认强传(P0-1 Task 8):查重弹窗「全部上传」入队的任务置
/// true,initUpload 携带 force 跳过服务端查重(仅 owner/admin 生效,
/// 服务端裁决)。缺省 false(正常查重)。
final bool force;
/// 已尝试传输次数(退避与上限判断依据)。 /// 已尝试传输次数(退避与上限判断依据)。
final int attempt; final int attempt;
@@ -535,6 +564,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
required this.bytes, required this.bytes,
required this.contentType, required this.contentType,
required this.state, required this.state,
required this.force,
required this.attempt, required this.attempt,
this.nextRetryAt, this.nextRetryAt,
this.serverUploadId, this.serverUploadId,
@@ -563,6 +593,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
$UploadTasksTable.$converterstate.toSql(state), $UploadTasksTable.$converterstate.toSql(state),
); );
} }
map['force'] = Variable<bool>(force);
map['attempt'] = Variable<int>(attempt); map['attempt'] = Variable<int>(attempt);
if (!nullToAbsent || nextRetryAt != null) { if (!nullToAbsent || nextRetryAt != null) {
map['next_retry_at'] = Variable<DateTime>(nextRetryAt); map['next_retry_at'] = Variable<DateTime>(nextRetryAt);
@@ -598,6 +629,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
bytes: Value(bytes), bytes: Value(bytes),
contentType: Value(contentType), contentType: Value(contentType),
state: Value(state), state: Value(state),
force: Value(force),
attempt: Value(attempt), attempt: Value(attempt),
nextRetryAt: nextRetryAt == null && nullToAbsent nextRetryAt: nextRetryAt == null && nullToAbsent
? const Value.absent() ? const Value.absent()
@@ -635,6 +667,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
bytes: serializer.fromJson<int>(json['bytes']), bytes: serializer.fromJson<int>(json['bytes']),
contentType: serializer.fromJson<String>(json['contentType']), contentType: serializer.fromJson<String>(json['contentType']),
state: serializer.fromJson<UploadTaskState>(json['state']), state: serializer.fromJson<UploadTaskState>(json['state']),
force: serializer.fromJson<bool>(json['force']),
attempt: serializer.fromJson<int>(json['attempt']), attempt: serializer.fromJson<int>(json['attempt']),
nextRetryAt: serializer.fromJson<DateTime?>(json['nextRetryAt']), nextRetryAt: serializer.fromJson<DateTime?>(json['nextRetryAt']),
serverUploadId: serializer.fromJson<String?>(json['serverUploadId']), serverUploadId: serializer.fromJson<String?>(json['serverUploadId']),
@@ -659,6 +692,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
'bytes': serializer.toJson<int>(bytes), 'bytes': serializer.toJson<int>(bytes),
'contentType': serializer.toJson<String>(contentType), 'contentType': serializer.toJson<String>(contentType),
'state': serializer.toJson<UploadTaskState>(state), 'state': serializer.toJson<UploadTaskState>(state),
'force': serializer.toJson<bool>(force),
'attempt': serializer.toJson<int>(attempt), 'attempt': serializer.toJson<int>(attempt),
'nextRetryAt': serializer.toJson<DateTime?>(nextRetryAt), 'nextRetryAt': serializer.toJson<DateTime?>(nextRetryAt),
'serverUploadId': serializer.toJson<String?>(serverUploadId), 'serverUploadId': serializer.toJson<String?>(serverUploadId),
@@ -681,6 +715,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
int? bytes, int? bytes,
String? contentType, String? contentType,
UploadTaskState? state, UploadTaskState? state,
bool? force,
int? attempt, int? attempt,
Value<DateTime?> nextRetryAt = const Value.absent(), Value<DateTime?> nextRetryAt = const Value.absent(),
Value<String?> serverUploadId = const Value.absent(), Value<String?> serverUploadId = const Value.absent(),
@@ -700,6 +735,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
bytes: bytes ?? this.bytes, bytes: bytes ?? this.bytes,
contentType: contentType ?? this.contentType, contentType: contentType ?? this.contentType,
state: state ?? this.state, state: state ?? this.state,
force: force ?? this.force,
attempt: attempt ?? this.attempt, attempt: attempt ?? this.attempt,
nextRetryAt: nextRetryAt.present ? nextRetryAt.value : this.nextRetryAt, nextRetryAt: nextRetryAt.present ? nextRetryAt.value : this.nextRetryAt,
serverUploadId: serverUploadId.present serverUploadId: serverUploadId.present
@@ -733,6 +769,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
? data.contentType.value ? data.contentType.value
: this.contentType, : this.contentType,
state: data.state.present ? data.state.value : this.state, 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, attempt: data.attempt.present ? data.attempt.value : this.attempt,
nextRetryAt: data.nextRetryAt.present nextRetryAt: data.nextRetryAt.present
? data.nextRetryAt.value ? data.nextRetryAt.value
@@ -763,6 +800,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
..write('bytes: $bytes, ') ..write('bytes: $bytes, ')
..write('contentType: $contentType, ') ..write('contentType: $contentType, ')
..write('state: $state, ') ..write('state: $state, ')
..write('force: $force, ')
..write('attempt: $attempt, ') ..write('attempt: $attempt, ')
..write('nextRetryAt: $nextRetryAt, ') ..write('nextRetryAt: $nextRetryAt, ')
..write('serverUploadId: $serverUploadId, ') ..write('serverUploadId: $serverUploadId, ')
@@ -787,6 +825,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
bytes, bytes,
contentType, contentType,
state, state,
force,
attempt, attempt,
nextRetryAt, nextRetryAt,
serverUploadId, serverUploadId,
@@ -810,6 +849,7 @@ class UploadTask extends DataClass implements Insertable<UploadTask> {
other.bytes == this.bytes && other.bytes == this.bytes &&
other.contentType == this.contentType && other.contentType == this.contentType &&
other.state == this.state && other.state == this.state &&
other.force == this.force &&
other.attempt == this.attempt && other.attempt == this.attempt &&
other.nextRetryAt == this.nextRetryAt && other.nextRetryAt == this.nextRetryAt &&
other.serverUploadId == this.serverUploadId && other.serverUploadId == this.serverUploadId &&
@@ -831,6 +871,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
final Value<int> bytes; final Value<int> bytes;
final Value<String> contentType; final Value<String> contentType;
final Value<UploadTaskState> state; final Value<UploadTaskState> state;
final Value<bool> force;
final Value<int> attempt; final Value<int> attempt;
final Value<DateTime?> nextRetryAt; final Value<DateTime?> nextRetryAt;
final Value<String?> serverUploadId; final Value<String?> serverUploadId;
@@ -851,6 +892,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
this.bytes = const Value.absent(), this.bytes = const Value.absent(),
this.contentType = const Value.absent(), this.contentType = const Value.absent(),
this.state = const Value.absent(), this.state = const Value.absent(),
this.force = const Value.absent(),
this.attempt = const Value.absent(), this.attempt = const Value.absent(),
this.nextRetryAt = const Value.absent(), this.nextRetryAt = const Value.absent(),
this.serverUploadId = const Value.absent(), this.serverUploadId = const Value.absent(),
@@ -872,6 +914,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
required int bytes, required int bytes,
required String contentType, required String contentType,
required UploadTaskState state, required UploadTaskState state,
this.force = const Value.absent(),
this.attempt = const Value.absent(), this.attempt = const Value.absent(),
this.nextRetryAt = const Value.absent(), this.nextRetryAt = const Value.absent(),
this.serverUploadId = const Value.absent(), this.serverUploadId = const Value.absent(),
@@ -903,6 +946,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
Expression<int>? bytes, Expression<int>? bytes,
Expression<String>? contentType, Expression<String>? contentType,
Expression<String>? state, Expression<String>? state,
Expression<bool>? force,
Expression<int>? attempt, Expression<int>? attempt,
Expression<DateTime>? nextRetryAt, Expression<DateTime>? nextRetryAt,
Expression<String>? serverUploadId, Expression<String>? serverUploadId,
@@ -924,6 +968,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
if (bytes != null) 'bytes': bytes, if (bytes != null) 'bytes': bytes,
if (contentType != null) 'content_type': contentType, if (contentType != null) 'content_type': contentType,
if (state != null) 'state': state, if (state != null) 'state': state,
if (force != null) 'force': force,
if (attempt != null) 'attempt': attempt, if (attempt != null) 'attempt': attempt,
if (nextRetryAt != null) 'next_retry_at': nextRetryAt, if (nextRetryAt != null) 'next_retry_at': nextRetryAt,
if (serverUploadId != null) 'server_upload_id': serverUploadId, if (serverUploadId != null) 'server_upload_id': serverUploadId,
@@ -947,6 +992,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
Value<int>? bytes, Value<int>? bytes,
Value<String>? contentType, Value<String>? contentType,
Value<UploadTaskState>? state, Value<UploadTaskState>? state,
Value<bool>? force,
Value<int>? attempt, Value<int>? attempt,
Value<DateTime?>? nextRetryAt, Value<DateTime?>? nextRetryAt,
Value<String?>? serverUploadId, Value<String?>? serverUploadId,
@@ -968,6 +1014,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
bytes: bytes ?? this.bytes, bytes: bytes ?? this.bytes,
contentType: contentType ?? this.contentType, contentType: contentType ?? this.contentType,
state: state ?? this.state, state: state ?? this.state,
force: force ?? this.force,
attempt: attempt ?? this.attempt, attempt: attempt ?? this.attempt,
nextRetryAt: nextRetryAt ?? this.nextRetryAt, nextRetryAt: nextRetryAt ?? this.nextRetryAt,
serverUploadId: serverUploadId ?? this.serverUploadId, serverUploadId: serverUploadId ?? this.serverUploadId,
@@ -1015,6 +1062,9 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
$UploadTasksTable.$converterstate.toSql(state.value), $UploadTasksTable.$converterstate.toSql(state.value),
); );
} }
if (force.present) {
map['force'] = Variable<bool>(force.value);
}
if (attempt.present) { if (attempt.present) {
map['attempt'] = Variable<int>(attempt.value); map['attempt'] = Variable<int>(attempt.value);
} }
@@ -1058,6 +1108,7 @@ class UploadTasksCompanion extends UpdateCompanion<UploadTask> {
..write('bytes: $bytes, ') ..write('bytes: $bytes, ')
..write('contentType: $contentType, ') ..write('contentType: $contentType, ')
..write('state: $state, ') ..write('state: $state, ')
..write('force: $force, ')
..write('attempt: $attempt, ') ..write('attempt: $attempt, ')
..write('nextRetryAt: $nextRetryAt, ') ..write('nextRetryAt: $nextRetryAt, ')
..write('serverUploadId: $serverUploadId, ') ..write('serverUploadId: $serverUploadId, ')
@@ -1115,6 +1166,7 @@ typedef $$UploadTasksTableCreateCompanionBuilder =
required int bytes, required int bytes,
required String contentType, required String contentType,
required UploadTaskState state, required UploadTaskState state,
Value<bool> force,
Value<int> attempt, Value<int> attempt,
Value<DateTime?> nextRetryAt, Value<DateTime?> nextRetryAt,
Value<String?> serverUploadId, Value<String?> serverUploadId,
@@ -1137,6 +1189,7 @@ typedef $$UploadTasksTableUpdateCompanionBuilder =
Value<int> bytes, Value<int> bytes,
Value<String> contentType, Value<String> contentType,
Value<UploadTaskState> state, Value<UploadTaskState> state,
Value<bool> force,
Value<int> attempt, Value<int> attempt,
Value<DateTime?> nextRetryAt, Value<DateTime?> nextRetryAt,
Value<String?> serverUploadId, Value<String?> serverUploadId,
@@ -1208,6 +1261,11 @@ class $$UploadTasksTableFilterComposer
builder: (column) => ColumnWithTypeConverterFilters(column), builder: (column) => ColumnWithTypeConverterFilters(column),
); );
ColumnFilters<bool> get force => $composableBuilder(
column: $table.force,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get attempt => $composableBuilder( ColumnFilters<int> get attempt => $composableBuilder(
column: $table.attempt, column: $table.attempt,
builder: (column) => ColumnFilters(column), builder: (column) => ColumnFilters(column),
@@ -1308,6 +1366,11 @@ class $$UploadTasksTableOrderingComposer
builder: (column) => ColumnOrderings(column), builder: (column) => ColumnOrderings(column),
); );
ColumnOrderings<bool> get force => $composableBuilder(
column: $table.force,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get attempt => $composableBuilder( ColumnOrderings<int> get attempt => $composableBuilder(
column: $table.attempt, column: $table.attempt,
builder: (column) => ColumnOrderings(column), builder: (column) => ColumnOrderings(column),
@@ -1396,6 +1459,9 @@ class $$UploadTasksTableAnnotationComposer
GeneratedColumnWithTypeConverter<UploadTaskState, String> get state => GeneratedColumnWithTypeConverter<UploadTaskState, String> get state =>
$composableBuilder(column: $table.state, builder: (column) => column); $composableBuilder(column: $table.state, builder: (column) => column);
GeneratedColumn<bool> get force =>
$composableBuilder(column: $table.force, builder: (column) => column);
GeneratedColumn<int> get attempt => GeneratedColumn<int> get attempt =>
$composableBuilder(column: $table.attempt, builder: (column) => column); $composableBuilder(column: $table.attempt, builder: (column) => column);
@@ -1468,6 +1534,7 @@ class $$UploadTasksTableTableManager
Value<int> bytes = const Value.absent(), Value<int> bytes = const Value.absent(),
Value<String> contentType = const Value.absent(), Value<String> contentType = const Value.absent(),
Value<UploadTaskState> state = const Value.absent(), Value<UploadTaskState> state = const Value.absent(),
Value<bool> force = const Value.absent(),
Value<int> attempt = const Value.absent(), Value<int> attempt = const Value.absent(),
Value<DateTime?> nextRetryAt = const Value.absent(), Value<DateTime?> nextRetryAt = const Value.absent(),
Value<String?> serverUploadId = const Value.absent(), Value<String?> serverUploadId = const Value.absent(),
@@ -1488,6 +1555,7 @@ class $$UploadTasksTableTableManager
bytes: bytes, bytes: bytes,
contentType: contentType, contentType: contentType,
state: state, state: state,
force: force,
attempt: attempt, attempt: attempt,
nextRetryAt: nextRetryAt, nextRetryAt: nextRetryAt,
serverUploadId: serverUploadId, serverUploadId: serverUploadId,
@@ -1510,6 +1578,7 @@ class $$UploadTasksTableTableManager
required int bytes, required int bytes,
required String contentType, required String contentType,
required UploadTaskState state, required UploadTaskState state,
Value<bool> force = const Value.absent(),
Value<int> attempt = const Value.absent(), Value<int> attempt = const Value.absent(),
Value<DateTime?> nextRetryAt = const Value.absent(), Value<DateTime?> nextRetryAt = const Value.absent(),
Value<String?> serverUploadId = const Value.absent(), Value<String?> serverUploadId = const Value.absent(),
@@ -1530,6 +1599,7 @@ class $$UploadTasksTableTableManager
bytes: bytes, bytes: bytes,
contentType: contentType, contentType: contentType,
state: state, state: state,
force: force,
attempt: attempt, attempt: attempt,
nextRetryAt: nextRetryAt, nextRetryAt: nextRetryAt,
serverUploadId: serverUploadId, serverUploadId: serverUploadId,
@@ -19,7 +19,7 @@
/// | 网络不可达/超时/429 限速 | waitingNetwork | 退避 5s·2^n 封顶 10min,无次数上限 | /// | 网络不可达/超时/429 限速 | waitingNetwork | 退避 5s·2^n 封顶 10min,无次数上限 |
/// | 401 会话过期 | waitingNetwork | 30s 短退避;全局驱逐由 ApiClient 处理,不烧次数 | /// | 401 会话过期 | waitingNetwork | 30s 短退避;全局驱逐由 ApiClient 处理,不烧次数 |
/// | 400 UPLOAD_VERIFY_FAILED / 5xx / 未知 | retryableFailed | 退避重试,3 次后停止自动重试 | /// | 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 次 | /// | presign 403 凭证过期 | (renew 后原状重传) | renewUpload 至多 2 次 |
/// - **取消**([cancel]):未开始 → cancelled 后删行删文件(确定语义); /// - **取消**([cancel]):未开始 → cancelled 后删行删文件(确定语义);
/// 进行中 → cancelToken 尽力取消 + 置 cancelled(文件保留,由清理 /// 进行中 → cancelToken 尽力取消 + 置 cancelled(文件保留,由清理
@@ -335,6 +335,9 @@ class UploadEngine {
Future<void> _transferSteps(UploadTask task, _TransferHandle handle) async { Future<void> _transferSteps(UploadTask task, _TransferHandle handle) async {
// a. initUpload(幂等:重试复用 clientTaskId,服务端返回原会话)。 // a. initUpload(幂等:重试复用 clientTaskId,服务端返回原会话)。
// force(Task 8):查重弹窗「全部上传」入队时落库的用户确认——重试
// 与 retryNow 均沿用任务行标记(用户已确认过的强传不因重试退化为
// 再被 409 拒绝)。
final init = await _apiClient.initUpload( final init = await _apiClient.initUpload(
UploadInitRequest( UploadInitRequest(
albumId: task.albumId, albumId: task.albumId,
@@ -344,6 +347,7 @@ class UploadEngine {
bytes: task.bytes, bytes: task.bytes,
contentType: task.contentType, contentType: task.contentType,
sourceDeviceId: await _deviceId.value(), sourceDeviceId: await _deviceId.value(),
force: task.force,
), ),
); );
// 回填会话/照片 ID(状态仍 uploading,旁路写入,见 TaskStore.updateMeta)。 // 回填会话/照片 ID(状态仍 uploading,旁路写入,见 TaskStore.updateMeta)。
@@ -486,10 +490,14 @@ class UploadEngine {
return; return;
} }
// 3) 客户端重试无意义(服务端开关/幂等冲突/源文件缺失):不自动 // 3) 客户端重试无意义(服务端开关/幂等冲突/查重拒绝/源文件缺失):
// 重试,用户可见 lastError,修正后 retryNow 手动重试。 // 不自动重试,用户可见 lastError,修正后 retryNow 手动重试。
// PHOTO_DUPLICATE(P0-1 Task 8):重发同体 init 必再 409——用户想
// 强传需重走选图弹窗「全部上传」(force 仅 retryNow 外的入队路径
// 携带;retryNow 保持任务原 force 标记)。
if (code == ErrorCodes.albumUploadDisabled || if (code == ErrorCodes.albumUploadDisabled ||
code == ErrorCodes.uploadSessionConflict || code == ErrorCodes.uploadSessionConflict ||
code == ErrorCodes.photoDuplicate ||
code == UploadEngine.sourceMissingCode) { code == UploadEngine.sourceMissingCode) {
await _fail( await _fail(
task, task,
+1
View File
@@ -293,6 +293,7 @@
"errorAlbumUploadDisabled": "Album uploads are disabled", "errorAlbumUploadDisabled": "Album uploads are disabled",
"errorUploadSessionConflict": "Upload session conflict. Please retry", "errorUploadSessionConflict": "Upload session conflict. Please retry",
"errorUploadVerifyFailed": "Upload verification failed. Will retry", "errorUploadVerifyFailed": "Upload verification failed. Will retry",
"errorPhotoDuplicate": "Possible duplicate: an identical photo already exists in this album",
"captureTitle": "Continuous capture", "captureTitle": "Continuous capture",
"captureCategoryDefault": "Default category", "captureCategoryDefault": "Default category",
"captureSwitchHint": "Switching only affects photos captured afterwards", "captureSwitchHint": "Switching only affects photos captured afterwards",
@@ -1562,6 +1562,12 @@ abstract class AppLocalizations {
/// **'上传核验失败,将自动重试'** /// **'上传核验失败,将自动重试'**
String get errorUploadVerifyFailed; String get errorUploadVerifyFailed;
/// No description provided for @errorPhotoDuplicate.
///
/// In zh, this message translates to:
/// **'照片可能重复:同相册已存在相同内容的照片'**
String get errorPhotoDuplicate;
/// No description provided for @captureTitle. /// No description provided for @captureTitle.
/// ///
/// In zh, this message translates to: /// In zh, this message translates to:
@@ -780,6 +780,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get errorUploadVerifyFailed => String get errorUploadVerifyFailed =>
'Upload verification failed. Will retry'; 'Upload verification failed. Will retry';
@override
String get errorPhotoDuplicate =>
'Possible duplicate: an identical photo already exists in this album';
@override @override
String get captureTitle => 'Continuous capture'; String get captureTitle => 'Continuous capture';
@@ -755,6 +755,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get errorUploadVerifyFailed => '上传核验失败,将自动重试'; String get errorUploadVerifyFailed => '上传核验失败,将自动重试';
@override
String get errorPhotoDuplicate => '照片可能重复:同相册已存在相同内容的照片';
@override @override
String get captureTitle => '连拍上传'; String get captureTitle => '连拍上传';
+1
View File
@@ -293,6 +293,7 @@
"errorAlbumUploadDisabled": "相册已关闭上传", "errorAlbumUploadDisabled": "相册已关闭上传",
"errorUploadSessionConflict": "上传会话冲突,请重试", "errorUploadSessionConflict": "上传会话冲突,请重试",
"errorUploadVerifyFailed": "上传核验失败,将自动重试", "errorUploadVerifyFailed": "上传核验失败,将自动重试",
"errorPhotoDuplicate": "照片可能重复:同相册已存在相同内容的照片",
"captureTitle": "连拍上传", "captureTitle": "连拍上传",
"captureCategoryDefault": "默认分类", "captureCategoryDefault": "默认分类",
"captureSwitchHint": "切换分类仅影响之后拍摄的照片", "captureSwitchHint": "切换分类仅影响之后拍摄的照片",
+15 -7
View File
@@ -351,10 +351,12 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
); );
Set<String> skipPaths = <String>{}; Set<String> skipPaths = <String>{};
Set<String> forcePaths = <String>{};
if (duplicates != null && duplicates.isNotEmpty && mounted) { if (duplicates != null && duplicates.isNotEmpty && mounted) {
final skip = await _showDuplicateDialog(l10n, duplicates, paths); final choice = await _showDuplicateDialog(l10n, duplicates, paths);
if (skip == null) return; // 取消。 if (choice == null) return; // 取消。
skipPaths = skip; skipPaths = choice.skipPaths;
forcePaths = choice.forcePaths;
} }
final result = await enqueueForUpload( final result = await enqueueForUpload(
@@ -363,6 +365,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
albumId: album.id, albumId: album.id,
sourcePaths: paths, sourcePaths: paths,
skipPaths: skipPaths.toList(), skipPaths: skipPaths.toList(),
forcePaths: forcePaths.toList(),
); );
if (!mounted) return; if (!mounted) return;
messenger.showSnackBar( messenger.showSnackBar(
@@ -376,8 +379,10 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
); );
} }
/// 批量重复确认弹窗(P0-1):返回 null=取消 / 空 Set=全部上传 / 非空=跳过项。 /// 批量重复确认弹窗(Task 8):null=取消;「跳过重复」→ skipPaths=
Future<Set<String>?> _showDuplicateDialog( /// 重复项;「全部上传」→ forcePaths=重复项(用户确认强传,服务端
/// force 查重跳过仅 owner/admin 生效——协作者仍会被 409 拒绝)。
Future<DuplicateDialogChoice?> _showDuplicateDialog(
AppLocalizations l10n, AppLocalizations l10n,
List<DuplicateMatch> duplicates, List<DuplicateMatch> duplicates,
List<String> allPaths, List<String> allPaths,
@@ -417,8 +422,11 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
); );
if (choice == 'cancel' || choice == null) return null; if (choice == 'cancel' || choice == null) return null;
if (choice == 'skip') return dupPaths; if (choice == 'skip') {
return <String>{}; return (skipPaths: dupPaths, forcePaths: <String>{});
}
// 'all' = 用户确认强传:重复项 force 入队(不跳过任何)。
return (skipPaths: <String>{}, forcePaths: dupPaths);
} }
@override @override
+16 -7
View File
@@ -462,13 +462,16 @@ class _CapturePageState extends ConsumerState<CapturePage>
); );
Set<String> skipPaths; Set<String> skipPaths;
Set<String> forcePaths;
if (duplicates != null && duplicates.isNotEmpty && mounted) { if (duplicates != null && duplicates.isNotEmpty && mounted) {
// 批量确认弹窗(单次,非逐张)。 // 批量确认弹窗(单次,非逐张)。
final skip = await _showDuplicateDialog(l10n, duplicates, paths); final choice = await _showDuplicateDialog(l10n, duplicates, paths);
if (skip == null) return; // 取消——不上传。 if (choice == null) return; // 取消——不上传。
skipPaths = skip; skipPaths = choice.skipPaths;
forcePaths = choice.forcePaths;
} else { } else {
skipPaths = <String>{}; skipPaths = <String>{};
forcePaths = <String>{};
} }
final result = await enqueueForUpload( final result = await enqueueForUpload(
@@ -478,6 +481,7 @@ class _CapturePageState extends ConsumerState<CapturePage>
categoryId: _categoryId, categoryId: _categoryId,
sourcePaths: paths, sourcePaths: paths,
skipPaths: skipPaths.toList(), skipPaths: skipPaths.toList(),
forcePaths: forcePaths.toList(),
); );
if (!mounted) return; if (!mounted) return;
final uploaded = result.enqueued; final uploaded = result.enqueued;
@@ -493,8 +497,10 @@ class _CapturePageState extends ConsumerState<CapturePage>
); );
} }
/// 批量重复确认弹窗:返回 null=取消 / 空 Set=全部上传 / 非空=跳过项路径集。 /// 批量重复确认弹窗(Task 8):null=取消;「跳过重复」→ skipPaths=
Future<Set<String>?> _showDuplicateDialog( /// 重复项;「全部上传」→ forcePaths=重复项(用户确认强传,服务端
/// force 查重跳过仅 owner/admin 生效——协作者仍会被 409 拒绝)。
Future<DuplicateDialogChoice?> _showDuplicateDialog(
AppLocalizations l10n, AppLocalizations l10n,
List<DuplicateMatch> duplicates, List<DuplicateMatch> duplicates,
List<String> allPaths, List<String> allPaths,
@@ -535,8 +541,11 @@ class _CapturePageState extends ConsumerState<CapturePage>
); );
if (choice == 'cancel' || choice == null) return null; if (choice == 'cancel' || choice == null) return null;
if (choice == 'skip') return dupPaths; if (choice == 'skip') {
return <String>{}; // 'all' = 不跳过任何 return (skipPaths: dupPaths, forcePaths: <String>{});
}
// 'all' = 用户确认强传:重复项 force 入队(不跳过任何)。
return (skipPaths: <String>{}, forcePaths: dupPaths);
} }
void _switchCategory(String? categoryId) { void _switchCategory(String? categoryId) {
@@ -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:网络层(客户端伪码)', () { group('mapDioException:网络层(客户端伪码)', () {
test('连接失败 → NETWORK_ERROR', () { test('连接失败 → NETWORK_ERROR', () {
final ex = ApiClient.mapDioException( final ex = ApiClient.mapDioException(
@@ -124,6 +124,26 @@ void main() {
await p.prepare(accountId: 'alice', albumId: 'alb-1', sourcePath: src.path); await p.prepare(accountId: 'alice', albumId: 'alb-1', sourcePath: src.path);
expect(enqueued, 1); 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)', () { group('prepare(预检失败 → preparationFailed)', () {
@@ -11,6 +11,8 @@
/// - datetime 文本落库的亚秒精度。 /// - datetime 文本落库的亚秒精度。
library; library;
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:drift/native.dart' show NativeDatabase;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:photo_live/features/uploads/task_state.dart'; import 'package:photo_live/features/uploads/task_state.dart';
import 'package:photo_live/features/uploads/task_store.dart'; import 'package:photo_live/features/uploads/task_store.dart';
@@ -26,6 +28,7 @@ UploadTaskDraft draft({
String filename = 'IMG_0001.jpg', String filename = 'IMG_0001.jpg',
int bytes = 1024, int bytes = 1024,
String contentType = 'image/jpeg', String contentType = 'image/jpeg',
bool force = false,
}) { }) {
return UploadTaskDraft( return UploadTaskDraft(
id: id, id: id,
@@ -37,6 +40,7 @@ UploadTaskDraft draft({
filename: filename, filename: filename,
bytes: bytes, bytes: bytes,
contentType: contentType, contentType: contentType,
force: force,
); );
} }
@@ -76,6 +80,10 @@ Future<UploadTask> advanceTo(
} }
void main() { void main() {
// 迁移组在同一测试内另建独立执行器的库(与 setUp 的内存库并存)——
// drift 多实例告警在此是误报(见 helpers/fakes.dart 同款处理)。
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
late TaskStore store; late TaskStore store;
setUp(() { setUp(() {
@@ -103,6 +111,13 @@ void main() {
expect(task.bytes, 1024); expect(task.bytes, 1024);
expect(task.createdAt, createdAt); expect(task.createdAt, createdAt);
expect(task.updatedAt, 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 { test('datetime 文本落库保住亚秒/微秒精度,且本地输入统一 UTC 落库', () async {
@@ -541,4 +556,66 @@ void main() {
expect(await store.delete('task-0001'), 0, reason: '重复删除返回 0'); 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);
});
});
} }
@@ -71,6 +71,10 @@ class Harness {
int initNetworkFailures = 0; int initNetworkFailures = 0;
int initUnauthorized = 0; int initUnauthorized = 0;
int initAlbumDisabled = 0; int initAlbumDisabled = 0;
/// initUpload 查重 409 次数(Task 8:wire 体复用 VALIDATION_FAILED code、
/// message=PHOTO_DUPLICATE)。
int initDuplicateRejects = 0;
int completeVerifyFailures = 0; int completeVerifyFailures = 0;
int completeNetworkFailures = 0; int completeNetworkFailures = 0;
int putNetworkFailures = 0; int putNetworkFailures = 0;
@@ -124,6 +128,19 @@ class Harness {
initAlbumDisabled--; initAlbumDisabled--;
return (409, errorBody(ErrorCodes.albumUploadDisabled)); return (409, errorBody(ErrorCodes.albumUploadDisabled));
} }
if (initDuplicateRejects > 0) {
initDuplicateRejects--;
// P0-1 契约现状:查重拒绝 409 的 code 复用 VALIDATION_FAILED,
// 靠 message=PHOTO_DUPLICATE 识别(Task 8 客户端归一依据)。
return (
409,
<String, dynamic>{
'code': ErrorCodes.validationFailed,
'message': 'PHOTO_DUPLICATE',
'requestId': 'req-dup',
},
);
}
if (initUnauthorized > 0) { if (initUnauthorized > 0) {
initUnauthorized--; initUnauthorized--;
return (401, errorBody(ErrorCodes.authTokenExpired)); return (401, errorBody(ErrorCodes.authTokenExpired));
@@ -218,6 +235,7 @@ class Harness {
String? categoryId, String? categoryId,
String clientTaskId = 'ct-default', String clientTaskId = 'ct-default',
DateTime? createdAt, DateTime? createdAt,
bool force = false,
}) async { }) async {
final draft = UploadTaskDraft( final draft = UploadTaskDraft(
id: 'task-$clientTaskId', id: 'task-$clientTaskId',
@@ -229,6 +247,7 @@ class Harness {
filename: file.uri.pathSegments.last, filename: file.uri.pathSegments.last,
bytes: await file.length(), bytes: await file.length(),
contentType: 'image/jpeg', contentType: 'image/jpeg',
force: force,
); );
final task = await store.create(draft, now: createdAt); final task = await store.create(draft, now: createdAt);
return store.updateState( return store.updateState(
@@ -399,6 +418,8 @@ void main() {
expect(body['bytes'], 1024); expect(body['bytes'], 1024);
expect(body['contentType'], 'image/jpeg'); expect(body['contentType'], 'image/jpeg');
expect(body['sourceDeviceId'], _kDeviceId); expect(body['sourceDeviceId'], _kDeviceId);
expect(body.containsKey('force'), isFalse,
reason: '非强传任务不下发 force(缺省语义)');
// PUT:预签名 URL + 凭证头 + Content-Length;无 Authorization // PUT:预签名 URL + 凭证头 + Content-Length;无 Authorization
// (裸 Dio——Bearer 会破坏 MinIO 预签名)。 // (裸 Dio——Bearer 会破坏 MinIO 预签名)。
@@ -417,6 +438,24 @@ void main() {
expect(progress[seeded.id]?.last, 1024); 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(受管副本上传)', test('与准备器集成:preparing → queued → completed(受管副本上传)',
() async { () async {
final src = photoFile('src.jpg'); final src = photoFile('src.jpg');
@@ -621,6 +660,31 @@ void main() {
expect(h.initBodies.length, initCount, reason: '无自动重试'); 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 短退避;重登录后(重试)完成,' test('401 会话过期 → waitingNetwork 短退避;重登录后(重试)完成,'
'attempt 不增长', () async { 'attempt 不增长', () async {
final file = photoFile('g.jpg'); final file = photoFile('g.jpg');
@@ -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/enqueue.dart';
import 'package:photo_live/features/uploads/preparer.dart'; import 'package:photo_live/features/uploads/preparer.dart';
import 'package:photo_live/features/uploads/providers.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/features/uploads/task_store.dart';
import 'package:photo_live/l10n/app_localizations.dart'; import 'package:photo_live/l10n/app_localizations.dart';
import 'package:photo_live/pages/album_categories_page.dart'; import 'package:photo_live/pages/album_categories_page.dart';
@@ -400,6 +401,69 @@ void main() {
expect(tasks, hasLength(2)); expect(tasks, hasLength(2));
expect(tasks.every((t) => t.albumId == 'alb-1'), isTrue); 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<void>.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<void>.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: '非重复项正常查重语义',
);
});
}); });
} }
@@ -405,6 +405,63 @@ void main() {
expect(await store.listByAccount('alice'), hasLength(2)); 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<void>.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('退出确认:有待传任务 → 弹确认;保留继续拍 / 确认才退出', testWidgets('退出确认:有待传任务 → 弹确认;保留继续拍 / 确认才退出',
(tester) async { (tester) async {
await pumpCapture(tester); await pumpCapture(tester);