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
@@ -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,