Files
photo-live/docs/plans/2026-09-28-上传查重前置到入队前-实施计划.md
T
2026-09-28 18:25:24 +08:00

5.0 KiB
Raw Blame History

上传查重前置到「点上传」时刻 实施计划

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)。