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