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

55 lines
5.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 上传查重前置到「点上传」时刻 实施计划
> **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)。