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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user