Files
photo-live/docs/plans/2026-09-28-App潜在问题完整分析与解决方案.md
T
浅念 98e073d8c8 docs: App potential issues v2 — full analysis with server-side dedup (confirmed, ready to execute)
v2 补齐 v1 自查发现的 8 个漏洞:三重指纹(filename+bytes+CRC32)、批量弹窗
(单次确认非逐张)、photos 表 dedup 索引、force 权限约束(owner/admin)、
上传并发限制(3)、相册开关预检、上传进度百分比、磁盘空间预检。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 10:24:46 +08:00

251 lines
8.5 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.
# 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 安全、并发、预检、进度、磁盘)。确认后按排期实施。*