docs: M2 第二波收口——报告 13~21 与契约草案入档挂导航
CI / docs-build (push) Successful in 1m19s

- 13~18 后端纵切六单报告(T2-03~08,测试 95→182)
- 14 + openapi-pets-draft.yaml 契约起草档案
- 19 契约冻结报告(v1.2.0,22 项草案修正对照)
- 20 契约一致性测试(快照机制 + 1 漂移修复)
- 21 第二波收口总表(定型语义汇总,第三波接入依据)
- mkdocs build --strict 通过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 10:40:27 +08:00
parent 511617be55
commit 222990e587
11 changed files with 2654 additions and 0 deletions
@@ -0,0 +1,71 @@
# 埋点分段持久化队列实施报告(M2 第二波)
> 作者:Frontend DeveloperFlutter
> 日期:2026-09-07
> 依据:`iterations/iteration-1/13-tracking-implementation-spec.md` §3.3/§3.4(分段队列原始设计)、`iteration-2/06-experiment-tracking-plan.md` §基础设施评估(约两周离线积压容量)、`iteration-2/10-flutter-analytics-repair.md`(第一波修复语义基线)
> 仓库:patbond-flutter dev 分支,提交 `33b993c`(基线 `1afec6a`
---
## 1. 背景
第一波按计划只做了内存队列的一行级加固(失败重回队列、上限 500 丢最旧),分段持久化推迟到本波。本波将队列升级为 13 号规范 §3.3 的 shared_preferences 分段持久化方案:应用被杀/冷启动不再丢失未上传事件,离线积压容量约两周(500 条上限,06 号报告估算)。
## 2. 设计要点
### 2.1 存储布局(新文件 `lib/analytics/analytics_event_store.dart`
按 13 号规范 §3.3 的 key 布局实现:
| Key | 内容 |
| --- | --- |
| `pb.analytics.segIndex` | JSON 数组:段 ID 有序列表(旧 → 新) |
| `pb.analytics.seg.<segId>` | JSON 数组:该段最多 20 条序列化事件 |
| `pb.analytics.droppedCount` | 本地累计丢弃计数(溢出淘汰 + 4xx 丢批 + 损坏段),诊断用 |
- **写入**`trackEvent` 追加到当前开放段并只重写该段(≤ 20 条、几 KB),避免整队列单 key 的 O(n) 重写放大;段满 20 条封段、开新段。
- **上限与淘汰**:总量 500 条(25 段),超限丢最旧整段并累加 `droppedCount`
- **at-least-once**:上传拿到终态才删段——202 受理删段,4xx 永久拒绝删段并计入丢弃数;网络错误/5xx 段原样保留在本地。应用在响应前被杀,事件仍在,冷启动重发,服务端靠 eventId(UUIDv7)幂等去重。
- **内存为唯一事实来源**shared_preferences 是尽力而为的镜像,持久化不可用(如插件未初始化)时降级纯内存队列,任何存取失败只打日志绝不抛出(埋点旁路原则)。
### 2.2 并发与损坏容错
- **冲刷中新事件不丢**`takeBatch` 取最旧整段拼批时即封段(sealed),上传在途期间新事件只会写入新的开放段;批内容与对应段不再变化,202 后整段删除安全。
- **损坏段**:JSON 解析失败的段直接删 key 丢弃、计入 `droppedCount`,恢复流程不崩溃;段索引本身损坏时按 key 前缀清扫孤儿段后从空队列重建。
- **恢复顺序**:restore 前已入队的内存事件排在恢复事件之后(恢复的更旧,优先上传/淘汰),并在恢复时补落盘。
### 2.3 服务接入(`lib/analytics/analytics_service.dart` + `lib/app/app.dart`
- 冲刷触发点保持不变:满 20 条 + 离开前台 `flushNow()`;新增冷启动 `restore()`app.dart initState 后台调用,不阻塞渲染)恢复积压并冲刷一次——即 13 号 §3.4 四个触发点落地三个(30 秒定时器仍未做,见 §4)。
- 冲刷改为按段拼批 ≤ 50 条循环上传,对齐契约单批上限(13 号 §1.1;旧实现失败重回后可能单批远超 50 被服务端整批 400 拒绝,本波顺带修复)。
- 第一波语义无回退:`flushNow()`、4xx 毒丸丢弃、eventId UUIDv7、sessionId 注入、`_platformName()` 均保留。
## 3. 测试变化
- 基线 51 → **64 全绿**+13);`flutter analyze` 0 问题、`dart format` 无 diff。
- 新增 `test/analytics/analytics_event_store_test.dart`(8 个):持久化恢复与分段数、501 条触发丢最旧整段、损坏段容错与索引清理、索引损坏清扫重建、封段隔离在途批次、按段拼批 ≤50、删段后 prefs 无残留、无持久化降级纯内存。
- 新增 `test/analytics/analytics_persistent_queue_test.dart`5 个,本地 HttpServer 模拟 202/400):满 20 冲刷且 202 后清段、flushNow 冲刷不满额队列、上传失败持久化 + 冷启动恢复自动重传、4xx 删段丢弃计数、60 条积压按 40+20 分批上传。
- 既有 8 个 AnalyticsService 测试未改动全部通过(`pendingEvents` 语义兼容)。
## 4. 与 13 号规范符合度对照
| 规范条目(§3.3/§3.4) | 状态 | 说明 |
| --- | --- | --- |
| 分段存储 key 布局(segIndex / seg.\<id\> / droppedCount | 符合 | key 名与规范一致 |
| 每段 ≤ 20 条、写入只重写当前段 | 符合 | |
| 总上限 500 条、超限丢最旧整段 | 符合 | |
| 202 后才删段(at-least-once) | 符合 | 取整段组批,无部分消费段重写的需要 |
| 单批 ≤ 50 条 | 符合 | 每批最多 2 整段(40 条),循环冲刷 |
| 冷启动恢复 + 冲刷触发 | 符合 | `restore()` 于 app 启动挂接 |
| 满 20 条 / 退后台冲刷触发 | 符合 | 第一波语义保留 |
| `pb.analytics.anonymousId` / `lastActiveAt` 持久化 | 未做 | anonymousId 仍每冷启动重新生成,属会话/身份持久化范畴,非本工单队列范围,建议下波补 |
| 30 秒定时冲刷 | 未做 | 本波任务明确保持触发点不变;低活跃场景已由退后台 + 冷启动冲刷兜底 |
| 指数退避(5s ×2 上限 5min)、429 按 Retry-After | 未做 | 沿用第一波语义:4xx(含 429)一律永久丢弃;有限流上量前风险低,遗留下波 |
| 401 去 Authorization 重试一次 | 未做 | 第一波遗留项,本波未扩展 |
## 5. 交付物
- 代码:patbond-flutter `dev` 提交 `33b993c`(已推送),改动 5 文件 +577/−28。
- 新增:`lib/analytics/analytics_event_store.dart``test/analytics/analytics_event_store_test.dart``test/analytics/analytics_persistent_queue_test.dart`
- 修改:`lib/analytics/analytics_service.dart`(接入持久化队列、分批冲刷)、`lib/app/app.dart`(冷启动 restore 挂接)
- 依赖:无新增(`shared_preferences ^2.5.4` 已在 pubspec