diff --git a/docs/plans/2026-09-28-App潜在问题完整分析与解决方案.md b/docs/plans/2026-09-28-App潜在问题完整分析与解决方案.md new file mode 100644 index 0000000..2a30b09 --- /dev/null +++ b/docs/plans/2026-09-28-App潜在问题完整分析与解决方案.md @@ -0,0 +1,250 @@ +# App 端潜在问题完整分析与解决方案(v2 修订版,含服务端查重) + +日期:2026-09-28 · 版本:v2(含自查漏洞修复) · 状态:**已确认,待实施** +范围:apps/mobile 全量 + services/api 涉及上传/查重的接口 + +--- + +## 一、总览 + +| 级别 | 数量 | 说明 | +|---|---|---| +| 🔴 P0 高 | 8 | 影响核心使用/数据正确性/上传引擎健康 | +| 🟡 P1 中 | 8 | 特定场景触发/体验缺陷 | +| 🟢 P2 低 | 7 | 改善项/增强功能 | + +--- + +## 二、🔴 P0 高优先级 + +### P0-1 上传照片无重复判断(用户核心痛点) + +**现状**:同一张照片选 10 次就传 10 次,没有任何提醒或确认。 + +#### 层 1:App 端本地查重(三重指纹 + 批量弹窗) + +**指纹**:`filename + bytes + 前后各4KB CRC32`(快速,~1ms,不卡 UI) +- 三重组合大幅降低假阳性(不同照片同名同大小但内容不同 → CRC 不同 → 正确放行) +- 改名后的同内容照片查不出(接受局限——服务端层 2 兜底) + +**批量 UX**: +``` +选 20 张 → 入队前一次性扫描全部 → 8 张匹配 → 单次弹窗: + +⚠️ 8 张照片可能已上传过(共选择 20 张) +IMG_001.jpg · IMG_005.jpg · IMG_012.jpg … + +[跳过重复项,上传 12 张] [全部上传 20 张] [取消] +``` + +**改动文件**: +| 文件 | 改动 | +|---|---| +| `preparer.dart` | 加 `computeFingerprint(path)` 方法(读文件头尾 4KB → CRC32) | +| `task_store.dart` | 加 `findByFingerprint(accountId, albumId, fingerprint)` 查询方法 | +| `enqueue.dart` | `enqueueForUpload` 改为返回 `EnqueueResult{enqueued, duplicates}` | +| `capture_page.dart` | `_pickFromGallery` 收到 duplicates → 批量弹窗 | +| `album_detail_page.dart` | 同上(相册详情的从相册选择入口) | +| `app_zh/en.arb` | 加弹窗文案 key | + +#### 层 2:服务端查重(跨设备 + 精确哈希 + 索引 + force 安全) + +**查重逻辑**(`uploads.service.ts` `init` 事务内): +``` +initUpload 请求 → + WHERE albumId + uploaderId + bytes 匹配 photos 表(有索引) + → 有匹配 → 409 { code: 'PHOTO_DUPLICATE', existingPhotoId, uploadedAt } + → 无匹配 或 force=true 且 isAdmin → 正常建会话 +``` + +**安全约束**:`force: true` 仅 owner/admin 生效——协作者无法绕过。 + +**数据库迁移**: +```sql +CREATE INDEX idx_photos_dedup + ON photos (albumId, uploaderId, bytes) + WHERE visibility != 'deleted'; +``` + +**契约改动**(`openapi.yaml`): +- `UploadInitRequest` 加可选 `contentHash`(string, SHA-256 hex,精确查重备用) +- `UploadInitRequest` 加可选 `force`(boolean, 默认 false) +- 409 响应体加 `code: PHOTO_DUPLICATE` + `existingPhotoId` + +**改动文件**: +| 文件 | 改动 | +|---|---| +| `openapi.yaml` | 上述契约字段 | +| `uploads.service.ts` | init 事务内查重 + force 权限检查 | +| `dto.ts` | `UploadInitRequestDto` 加 `contentHash`/`force` | +| `prisma migration` | `idx_photos_dedup` 索引 | +| `api_client.dart`(App) | initUpload 加参数;409 PHOTO_DUPLICATE 处理 | +| `upload_engine.dart`(App) | 409 不标记失败 → 转交 UI 确认 | + +--- + +### P0-2 照片查看器无缩放 + +在 `PageView` 每页包 `InteractiveViewer`,缩放时禁用 PageView 滑动: + +```dart +InteractiveViewer( + maxScale: 5.0, + onInteractionUpdate: (details) { + _pageController.physics = details.scale != 1.0 + ? const NeverScrollableScrollPhysics() + : const PageScrollPhysics(); + }, + child: PhotoThumbnail(...), +) +``` + +**工时**:~30min + +--- + +### P0-3 相机后台未释放 + +`CapturePage` 加 `WidgetsBindingObserver`: +- `paused` → dispose 相机 +- `resumed` → 重新 initialize + +**工时**:~30min + +--- + +### P0-4 无离线感知 + +- `connectivity_plus` 依赖 +- `NetworkStatusProvider`(Stream) +- 离线 → 上传引擎暂停 + UI 黄条提示 +- 恢复 → 自动重试 + +**工时**:~3h + +--- + +### P0-5 照片 EXIF 方向未处理 + +App 端上传前读取 EXIF orientation → 非 1 时本地旋转修正。 + +**工时**:~2h + +--- + +### P0-6 图片无内存管理 + +PhotoGrid 的 `Image` 加 `cacheWidth`(按屏幕像素 / 列数)。 + +**工时**:~1h + +--- + +### P0-7 上传并发无限制(🆕 v2 新增) + +**现状**:无 `maxConcurrent` 控制——50 张同时 PUT。 + +**方案**:上传引擎加并发信号量(上限 3),排队执行。 + +**改动**:`upload_engine.dart` 加 `_Semaphore` 类 + `_drainQueue` 改为限流。 + +**工时**:~1h + +--- + +### P0-8 入队前不检查相册上传开关(🆕 v2 新增) + +**现状**:`enqueueForUpload` 不查 `album.uploadEnabled`——关闭后仍复制+init→403。 + +**方案**:入队前检查 → `false` 时提示「该相册已关闭上传」+ 不入队。 + +**改动**:`enqueue.dart` + `capture_page` / `album_detail_page`。 + +**工时**:~30min + +--- + +## 三、🟡 P1 中优先级 + +| # | 问题 | 方案 | 工时 | +|---|---|---|---| +| P1-1 | 海报渲染 OOM | pixelRatio 3→2 + try/catch 降级 | 15min | +| P1-2 | 相机分辨率过高 | `ResolutionPreset.max` → `high` | 5min | +| P1-3 | 闪光灯状态丢失 | flip 后恢复 flash state | 15min | +| P1-4 | 无全局崩溃捕获 | `FlutterError.onError` + `runZonedGuarded` + 本地日志 | 1h | +| P1-5 | 上传引擎无生命周期 | `WidgetsBindingObserver` pause/resume | 1h | +| P1-6 | 分享缩略图 PNG 超 32KB | 改 JPEG 编码或限制尺寸更小 | 30min | +| P1-7 | 上传无进度百分比(🆕) | 任务行加 `LinearProgressIndicator`(`onReceiveProgress` 已有) | 30min | +| P1-8 | 磁盘空间无预检(🆕) | 复制前 `path_provider` 检查可用空间 | 30min | + +--- + +## 四、🟢 P2 低优先级 / 增强 + +| # | 问题 | 方案 | +|---|---|---| +| P2-1 | 无深度链接 | `app_links` + router 集成 | +| P2-2 | 无推送通知 | FCM / 极光 | +| P2-3 | 表单草稿不持久 | `shared_preferences` 定时保存 | +| P2-4 | 无图片压缩 | resize 最大 2048px + JPEG 85% | +| P2-5 | 无批量管理 | 照片网格多选批量隐藏/删除 | +| P2-6 | 多语言无 RTL | 支持阿拉伯语等 | +| P2-7 | App 体积 64MB 偏大(🆕) | `--split-per-abi`(arm64-v8a 约 25MB)或 `--obfuscate` | + +--- + +## 五、上传引擎优化(🆕 v2 新增) + +| # | 优化 | 说明 | +|---|---|---| +| 1 | 哈希计算放 Isolate | 如做 SHA-256,必须 `Isolate.run()` 否则卡 UI | +| 2 | 上传重试退避策略 | 网络错→立即重试;5xx→指数退避(1s/2s/4s/8s);403→不重试 | +| 3 | 源文件清理提前 | 上传成功+服务端确认后 24h 即清理(非当前定期批量) | +| 4 | 上传优先级 | 刚拍的排前面(用户在等),补传的排后面 | +| 5 | 取消原子性 | 先标记 cancelled 再异步清理,UI 即时反馈 | + +--- + +## 六、修订后实施排期 + +| 批次 | 内容 | 工时 | 备注 | +|---|---|---|---| +| **第 1 批** | P0-2 缩放 + P0-3 相机释放 + P0-6 内存管理 | ~2h | 核心体验 | +| **第 2 批** | P0-1 层1(本地查重 + 批量弹窗) | ~3h | 用户痛点 | +| **第 2.5 批** | P0-7 并发限制 + P0-8 开关预检 + P1-8 磁盘预检 | ~2h | 上传引擎健康 | +| **第 3 批** | P0-4 离线感知 + P0-5 EXIF 方向 | ~5h | 稳定性 | +| **第 4 批** | P0-1 层2(服务端查重 + 索引 + force 安全) | ~6h | 跨设备查重 | +| **第 5 批** | P1 全部 + 上传进度 + 重试退避 | ~5h | 品质 | +| **第 6 批** | P2 + App 瘦身 | 按需 | 增强 | + +**总工时**:~23h(P0 全部 + P1 全部);第 1-2.5 批可一天内完成。 + +--- + +## 七、测试要点 + +### 查重测试 +- 同一张照片选两次 → 第二次弹窗 +- 不同照片但同名 → 不弹窗(bytes/CRC 不同) +- 批量选 20 张含 8 张重复 → **单次**弹窗(不是 8 次) +- 确认「跳过重复项」→ 只上传 12 张 +- 确认「全部上传」→ 上传 20 张 +- 确认「取消」→ 不入队 +- 跨设备:手机 A 传了 → 手机 B 选同一张 → 服务端 409 → 弹窗 +- `force: true` owner 可绕过 → 协作者不可绕过 + +### 上传引擎测试 +- 并发上限 3:入队 10 张 → 任何时刻最多 3 个 uploading +- 相册关闭上传 → 提示 + 不入队 +- 磁盘不足 → 友好提示 +- 离线 → 任务暂停 → 恢复网络 → 自动重传 +- 5xx 错误 → 指数退避(非立即重试) + +### 查看器测试 +- 双指放大缩小 → 松手回弹/保持 +- 放大状态下滑动 → 照片内平移(不切页) +- 恢复 1x 后 → 左右滑动切页 + +--- + +*v2 修订版——已补齐自查发现的 8 个漏洞(指纹精度、批量弹窗、索引、force 安全、并发、预检、进度、磁盘)。确认后按排期实施。*