From 678225e8b53d8cd6f1e9b271524fedf2b491d77e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Mon, 28 Sep 2026 18:25:24 +0800 Subject: [PATCH] =?UTF-8?q?docs(plan):=20upload=20duplicate=20pre-check=20?= =?UTF-8?q?at=20enqueue=20moment=20=E2=80=94=20incremental=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- ...026-09-28-上传查重前置到入队前-实施计划.md | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/plans/2026-09-28-上传查重前置到入队前-实施计划.md diff --git a/docs/plans/2026-09-28-上传查重前置到入队前-实施计划.md b/docs/plans/2026-09-28-上传查重前置到入队前-实施计划.md new file mode 100644 index 0000000..e050d3a --- /dev/null +++ b/docs/plans/2026-09-28-上传查重前置到入队前-实施计划.md @@ -0,0 +1,54 @@ +# 上传查重前置到「点上传」时刻 实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 用户点上传(选完照片确认入队)的那一刻,弹窗提示与服务端历史一致的重复项(跨设备/重装/改名也能查到),而不是等任务在上传中心以 PHOTO_DUPLICATE 失败才暴露。 + +**Architecture:** 服务端加一个只读批量查重预检端点(判定谓词与 initUpload 查重**逐字一致**:同 albumId+uploaderId+bytes+非 deleted,保证弹窗即真相);App 选图入队前把「本机 TaskStore 历史 + 服务器历史」合并进**既有**去重弹窗(弹窗/force 透传/409 兜底全部不动)。连拍快门路径不弹窗(不打断拍摄,靠 409 兜底 + 上传中心可见)。 + +**背景(为什么):** 现有层 1(本机 checkDuplicates)只查本设备历史——卸载重装/换机/他传的照片查不到 → 不弹窗 → 入队上传 → 服务端 409 落在上传中心(用户 2026-09-28 实测反馈:debug→release 换签名重装后本地历史清空,全落上传中心)。 + +**设计要点:** +- 端点:`POST /v1/uploads/duplicates/check`,operationId `checkUploadDuplicates`;请求 `{albumId, items:[{filename, bytes}]}`(1..500 项,客户端按 500 分块);响应 `{duplicates:[{index, bytes, uploadedAt}]}`(index 指向请求数组,命中子集)。 +- 服务端权限链与 initUpload 相同(canViewAlbum 404 兜底 + canUploadToAlbum);查询 `photos.findMany(bytes in [去重字节集], visibility != deleted)` 内存映射回 index。 +- App:`ApiClient.checkUploadDuplicates` + enqueue.dart 统一入口 `checkDuplicatesUnified`(本机命中 ∪ 服务器命中;服务器调用失败**静默降级**本机-only,不阻塞入队);两页 `_pickFromGallery` 换用统一入口;弹窗/skip/force 逻辑零改动。 +- 服务器预检按 **bytes** 精确映射回 path(修正现有按 filename 映射的误伤面——同名不同图不再被误标,顺带收紧)。 +- 契约红线:新端点纯增量,不改既有字段(新契约字段全部 optional 原则)。 + +--- + +### Task 1: 契约 + 服务端查重预检端点(TDD) + +**Files:** +- Modify: `packages/contracts/openapi.yaml`(+paths checkUploadDuplicates + schemas) +- Regen: `packages/contracts/src/generated/openapi.d.ts`(`pnpm --filter contracts gen`) +- Modify: `services/api/src/uploads/dto.ts`(+DuplicateCheckRequestDto) +- Modify: `services/api/src/uploads/uploads.controller.ts`(+POST duplicates/check) +- Modify: `services/api/src/uploads/uploads.service.ts`(+checkDuplicates) +- Test: `services/api/test/uploads.e2e-spec.ts`(追加 describe) + +- [ ] **Step 1: 失败测试**(uploads.e2e-spec 追加):成员 A 已有 bytes=B 的可见照片 → check(B) 命中(index/bytes/uploadedAt);不同 bytes 不命中;他账号上传的同 bytes 不命中(uploaderId 隔离);非成员 404;>500 项 400;空 items 400。 +- [ ] **Step 2: 跑测试确认失败**(端点不存在 404) +- [ ] **Step 3: 契约 + 实现**(谓词与 initUpload ③.5 查重逐字一致;findMany bytes in + visibility not deleted;uploadedAt 取命中行) +- [ ] **Step 4: 契约 lint + api typecheck + e2e 全绿** +- [ ] **Step 5: Commit** `feat(api): upload duplicate pre-check endpoint (checkUploadDuplicates)` + +### Task 2: App 统一查重入口 + 双页接线(TDD) + +**Files:** +- Modify: `apps/mobile/lib/core/api_client.dart`(+checkUploadDuplicates,500/块顺序分块) +- Modify: `apps/mobile/lib/features/uploads/enqueue.dart`(+checkDuplicatesUnified:本机 ∪ 服务器;bytes 映射回 path;服务器失败静默降级) +- Modify: `apps/mobile/lib/pages/capture_page.dart`、`album_detail_page.dart`(`_pickFromGallery` 换统一入口) +- Test: 上述对应测试(页级 fake apiClient override 命中/不命中/网络失败三态) + +- [ ] **Step 1: 失败测试**(统一入口:仅服务器命中 → 弹窗出现且 forcePaths 正确;服务器异常 → 静默本机-only;本机+服务器并集去重) +- [ ] **Step 2: 确认失败 → 实现 → 全绿**(flutter analyze + 全量 test) +- [ ] **Step 3: Commit** `feat(app): server-side duplicate pre-check at enqueue moment` + +### Task 3: 部署测试服务器 + 测试包 + 验证清单 + +- [ ] 服务端镜像构建部署(git archive → docker build → scp → load → compose up → **restart 两个 web 容器**,见 memory 部署流程) +- [ ] 测试服 APK 重打(API_BASE=32005) +- [ ] 用户验证:重装 App(清本地历史)→ 重选已传照片 → **点上传即弹重复**(跨设备场景)→「跳过重复」只传非重复 →「全部上传」force 生效 → 上传中心不再出现重复失败 + +**风险与回退:** 预检接口失败静默降级(最坏回到现状=409 兜底);revert Task 1/2 各自独立成 commit;服务端查询走 bytes 索引(idx_photos_dedup 已有,90dcc58)。