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
@@ -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)', () {
@@ -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<UploadTask> 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);
});
});
}
@@ -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,
<String, dynamic>{
'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');