feat(app): server-side duplicate pre-check at enqueue moment
Task 2 of docs/plans/2026-09-28-上传查重前置到入队前-实施计划.md:
surface server-side duplicate history at the "click upload" moment,
merged with the local check into the EXISTING dialog.
- ApiClient.checkUploadDuplicates: POST /v1/uploads/duplicates/check
(commit 805f77b endpoint); 500/chunk sequential requests; chunk-local
index translated to global; empty list short-circuits (no request);
errors normalized to AppException via _send.
- enqueue.checkDuplicatesUnified: local TaskStore history ∪ server
history; server hits map back to filename via global index into the
stat'd picks; merged, de-duplicated (filename+bytes) list; server
failure (network/permission/any) silently degrades to local-only —
init 409 remains the backstop.
- capture_page + album_detail_page _pickFromGallery now call the
unified entry; dialog/skip/force logic untouched.
Tests: api_client chunking (1200 → 500/500/200) + index translation +
merge + 404 normalization + empty no-request; page-level three states
(hit/no-hit/network failure) incl. fresh-install server-only-hit dialog
on both pages; pre-existing Task 8 dialog tests keep passing via the
degrade path.
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,17 @@ import 'error_codes.dart';
|
||||
import 'models/cursor_page.dart';
|
||||
import 'session_store.dart';
|
||||
|
||||
/// 查重预检请求条目(契约 UploadDuplicateCheckItem;filename 仅元数据——
|
||||
/// 服务端查重谓词只看 bytes,与 initUpload ③.5 逐字一致)。
|
||||
typedef UploadDuplicateCheckItem = ({String filename, int bytes});
|
||||
|
||||
/// 查重预检命中(契约 UploadDuplicateCheckDuplicate;index 已从分块局部
|
||||
/// 下标换算为对入参列表的全局下标)。
|
||||
typedef UploadDuplicateHit = ({int index, int bytes, DateTime uploadedAt});
|
||||
|
||||
/// 单批查重条目上限(契约 items maxItems=500;客户端按此分块顺序调用)。
|
||||
const int kUploadDuplicateCheckChunkSize = 500;
|
||||
|
||||
class ApiClient {
|
||||
/// [dio] 可注入测试替身(自定义 HttpClientAdapter);[baseUrl] 缺省
|
||||
/// 取 [ApiConfig.baseUrl](--dart-define API_BASE 覆盖)。
|
||||
@@ -568,6 +579,53 @@ class ApiClient {
|
||||
limit: limit,
|
||||
);
|
||||
|
||||
/// operationId `checkUploadDuplicates`
|
||||
/// (POST /uploads/duplicates/check,批量只读预检)。
|
||||
///
|
||||
/// 按 [kUploadDuplicateCheckChunkSize] 顺序分块调用(契约单批上限 500;
|
||||
/// 块间串行防瞬时并发放大);各块命中的分块局部下标换算为对 [items] 的
|
||||
/// 全局下标后合并返回。空列表直接返回空(契约 items minItems=1,不发
|
||||
/// 请求)。错误经 [_send] 归一为 [AppException](404/403/400/401 见契约)。
|
||||
Future<List<UploadDuplicateHit>> checkUploadDuplicates(
|
||||
String albumId,
|
||||
List<UploadDuplicateCheckItem> items,
|
||||
) async {
|
||||
if (items.isEmpty) return const <UploadDuplicateHit>[];
|
||||
final hits = <UploadDuplicateHit>[];
|
||||
for (
|
||||
var start = 0;
|
||||
start < items.length;
|
||||
start += kUploadDuplicateCheckChunkSize
|
||||
) {
|
||||
final chunk = items
|
||||
.skip(start)
|
||||
.take(kUploadDuplicateCheckChunkSize)
|
||||
.toList();
|
||||
final resp = await _send(
|
||||
() => _dio.post<Map<String, dynamic>>(
|
||||
'/v1/uploads/duplicates/check',
|
||||
data: {
|
||||
'albumId': albumId,
|
||||
'items': [
|
||||
for (final item in chunk)
|
||||
{'filename': item.filename, 'bytes': item.bytes},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
final duplicates = (resp.data!['duplicates'] as List<dynamic>)
|
||||
.cast<Map<String, dynamic>>();
|
||||
for (final d in duplicates) {
|
||||
hits.add((
|
||||
index: start + (d['index'] as int),
|
||||
bytes: d['bytes'] as int,
|
||||
uploadedAt: DateTime.parse(d['uploadedAt'] as String),
|
||||
));
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
// ── photos(契约 tag: photos)──
|
||||
|
||||
/// operationId `listAlbumPhotos`
|
||||
|
||||
Reference in New Issue
Block a user