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>
This commit is contained in:
浅念
2026-09-28 10:24:46 +08:00
parent 984fe8981c
commit 98e073d8c8
@@ -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 安全、并发、预检、进度、磁盘)。确认后按排期实施。*