Files
patbond-doc/docs/development/iterations/iteration-4/06-experiment-tracking-plan.md
T
lixi 79b33dba31 docs: M4「AI 创作」开工分析八份报告 + 汇总拍板页入档挂导航
八角色并行开工分析,合计 7448 行;另出 00 汇总页(跨角色收敛结论、
13 项待拍板、6 项待仲裁分歧、未取证项汇总),挂第四迭代导航最前。
mkdocs build --strict 通过。

基线实测修正(文档与实况不符):
- api 测试 381(releases.md 记 379,成因待仲裁)
- 埋点白名单 41(四份文档记 42,experiment_exposed 重复计数)
- 真机验证挂起 10 项(转述链 4→6→8→10 每跳丢项)
- E2E 断言机械可数 226(声称 234 无可复核来源)
- v0.4.0 实际发布 09-14 11:17;CI 非红,三仓五上下文全绿

多方独立收敛(无需拍板):
- 队列用 Postgres SKIP LOCKED + 租约列,不引入 Redis/MQ
- 服务端零对象写能力(ObjectStorage 无 put/get),M4 立足点缺地基
- 「四模块字节级快照锁 CI」不存在,实际门禁仅结构断言
- 定稿模型 input_asset_id NOT NULL,即图生图不支持文生图
- 跨 schema 外键补回是 V5 自身指令,裁剪理由已不成立

阻塞项与安全缺口:
- AI provider BLOCKED:零 SDK/endpoint/额度,正典种子即 fixture
- 分支保护必需上下文选错触发器:(push) 限定 branches:[dev],
  致「推 dev 即满足门禁」且「非 dev 分支 PR 永久无法合并」
- check-secrets.sh 对 sk-/sk-ant- 零覆盖,须先于任何 AI key 落地
- 北极星 09-21 窗口已于 09-13 关闭,补救无从下手,建议改事件驱动

本批核心教训:13 处文档/注释与代码相反且多已被下游采信,其中
5 处造成实际规模误判(widthPx M→S、数据模型早已定稿 L→M、
社区侧 purpose 校验实际不存在等)。汇总页 §0 立转述纪律。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 15:48:56 +08:00

763 lines
83 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
# 第四迭代埋点与实验规划(AI 创作)
> 角色:Experiment Tracker
> 日期:2026-09-14
> 前序:`iteration-2/06`(字典 v2、北极星定义式、H1~H4、A/B 八项前置)、`iteration-2/24`(白名单 v2)、`iteration-3/06`(字典 v3、聚合曝光裁定、A/B 前置推进)、`iteration-3/22`(白名单 v3)、`iteration-3/10`(队列硬化)、`iteration-3/28` §7 观察项 2eventVersion 歧义登记)
> 依据:`patbond-api` dev@3cd8005 `EventDictionary.java` / `TrackEventsRequest.java` / `AnalyticsService.java` / `V2__create_platform_product_events.sql` 现行实现;`patbond-flutter` dev@fbcd734 `lib/analytics/` 与 `lib/features/*/*_analytics.dart` 现行挂接;契约 v1.4.0`development-plan.md` §M4`device-verification.md`
> 范围:M4 AI 创作纵切(模型/风格目录、生成任务创建/查询/取消、Worker 队列执行、产物入媒体表、一键建社区草稿)的埋点与实验;本地服务(M5)不在本轮定义
> 性质:纯规划文档,供 M4 开发工单直接引用;本报告未改动任何生产代码或配置
**速览(七个核心结论)**
1. **北极星 09-21 首次出数不可行,且不是「来不及」而是「窗口已关闭」**。09-21 是 W37 队列(首记 09-07~09-13)+8 天的成熟日,入队窗口已于 **09-13 结束**——今日起做任何事都无法为 W37 补进一个用户。给出三档降级方案,推荐「触发条件替代日期承诺」+「基建就绪读数」,见 §0。
2. **decisive finding:阻塞 M2 两项真机验证的「待设备到位」已经不成立**。本机 `~/Android/Sdk` 已装 android-36 x86_64 系统镜像与 `platform-tools/adb``flutter emulators` 列出一个可直接启动的 **Pixel_7** AVD;而 `device-verification.md:9` 明文接受「Android 真机(推荐)或 Android 模拟器」。两项合计约 55 分钟,**今天即可执行**。这把最早可得的北极星读数从「无限期」拉到 **2026-09-28W38 队列)**,见 §0.3。
3. **白名单实际 41 条,不是 42**,根因是 `experiment_exposed` 被重复计数(community 域实为 18 条,加 platform 域 1 条共 19 条新增;文档记作「19 + experiment_exposed」= 20)。**无任何测试断言字典条数**,故漂移四份文档一路不可见。M4 前置项:补一个条数断言,见 §1.1、§7。
4. **eventVersion 定型为「每个事件自身的 props schema 版本」**(否决「字典世代」读法):客户端现行硬编码 1 在此读法下**本就正确**,零客户端改动、零历史回填;反之则全部历史行皆错且不可修复。配套给出 bump 规则与三层校验对齐方案,见 §2。
5. **`platform=linux` 整批 400 是契约明文行为,枚举排除 linux 是三层锁死的设计意图;缺陷在客户端**——`analytics_service.dart:99-100` 注释称「逐条 rejected(不影响客户端)」与实现相反:实际整批 400 且被 `uploadBatch` 判为永久拒绝**整批丢弃**,桌面端 100% 静默丢事件。处置为客户端本地短路 + 调试覆盖开关,不动枚举,见 §3。
6. **服务端生成侧不建第二条埋点管道**`platform.product_events``anonymous_id`/`session_id` NOT NULL 与 `platform IN ('android','ios')` CHECK 使 Worker 事件无法入表;裁定以 `creation.generation_tasks` 事实表为服务端指标权威,经 `creationTaskId` 与客户端事件拼接,见 §4.2。
7. **M4 首个「实验」应是 A/A 基建验证空跑,真实 A/B 冻结设计待流量**。A/B 前置为 **5 绿 3 半**——分流哈希、Flutter 曝光封装、feature flag **三者代码零实现**(本角色逐项 grep 取证),必须作为前置工单而非既有资产,见 §6。
**事件增量**:新增 **8** 条(全部客户端侧,`creation` 域),既有事件补属性 **2** 处,废弃 **0** 条;字典 **41 → 49**
---
## 0. 北极星 09-21 时限:风险评估与行动建议
> 本节按任务要求置于全文最前(惯例的 §0「基线现状」顺延为 §1)。
### 0.1 北极星是否已定型:**是,无待办**
定型载体 **ADR-012**`architecture/decisions.md:128`,原文):
> 北极星 = **7 日回访记录率**(分母:当 ISO 周产生生命周期首条 `health_record_create_succeeded` 的去重用户;分子:其中在首记日之后第 1–7 个 UTC 自然日内再次创建成功者;不含首记当日;首记日 +8 天出数)。
ADR-020`decisions.md:172`)确认 M3 保持不变,复评点 = M3 收官 + H7 读数。完整定义式、七项口径与出数 SQL 在 `iteration-2/06` §2.1。**指标定义侧零缺口**:
- 唯一数据源 `health_record_create_succeeded` 在白名单内(`EventDictionary.java:67`),且客户端**确实在发**`lib/features/pets/health_record_analytics.dart` 挂接,本角色 grep 取证于 §1.2 的 33 条实发清单)。
- 口径刻意不依赖 `sessionId`(只用 `userId` + `server_ts`),故队列硬化与会话缺陷都不污染读数。
- 统计纪律已定:Wilson 95% CI 发布、<50 人周合并或 4 周滚动、未成熟队列不发布。
**但出数缺一个可执行载体**`iteration-2/06` §2.1 的 SQL 只存在于报告正文,仓库内无脚本、无视图、无定时任务,没有任何一处「跑一下就出数」的入口。这与 PM 报告的判断一致,列为 §8 拍板项与 §附 工单。
### 0.2 09-21 为何不可行:窗口已关闭(算术判定,非进度判断)
09-21 这个日期的来源是 `iteration-3/06:312`(原文):
> | 北极星首个成熟周队列 | W37 队列(09-07~09-13 首记)+8 天成熟 | **2026-09-21(周一)首次出数**,此后每周一滚动 | 数据侧 | 本角色发布(Wilson 95% CI<50 人周合并) |
对齐 ISO 周实测(`date -d`):09-07 = 周一 / ISO 周 37 第 1 天,09-13 = 周日 / W37 第 7 天,09-14 = 周一 / **W38 第 1 天**。因此:
| 事实 | 结论 |
| --- | --- |
| W37 的**入队窗口** = 首记日落在 09-07~09-13 | 该窗口已于 **2026-09-13(昨天)结束** |
| 分母 = 首记落在 W37 的去重 userId | 今日(09-14)起产生的首记一律归入 **W38**,永远进不了 W37 |
| 09-21 = W37 最后一名成员(09-13 首记)的 +8 天成熟日 | 09-21 只能出 W37 这一个队列的读数 |
**判定:不可行,且无任何补救能改变。** 这不是「一周内做不完」的问题——即使今天就把 10 项真机验证全部做完、立刻拉来一批真实用户,他们全部落在 W38,W37 的分母不会增加一个人。
**W37 分母的现实取值:极可能为 0**,证据链:
1. `device-verification.md` 三处执行记录(L106 / L168 / L221**全部空白**——10 项真机验证一项未执行。
2. 无 android/ios 包分发:`releases.md` v0.4.009-14 发布)只登记三仓 tag 与 CI 门禁,无商店/内测分发;`server-exposure.md` 对公网只开 22/80/443Gitea + 文档站),**无 API 端点对外**,因此不存在真实用户可达的后端。
3. 桌面端 100% 丢弃(§3 取证):唯一实际跑过的客户端形态(Linux 桌面)产生的事件全被整批 400 后永久丢弃。
4. 库中可能存在的 `product_events` 行只有 curl 人工注入的合成 payload`iteration-3/25` §5c、`iteration-3/26` §5c,均以 `platform=android` 伪值造),且位于可被 `docker compose down -v` 清掉的本地卷 `patbond_pgdata`
未取证项:本角色**未查询数据库**(compose 未运行,且 8 个 agent 并发期间不宜起容器占端口)。W37 分母的实测值需执行:
```bash
cd <你的工作区>/patbond-api && docker compose up -d postgres
docker exec patbond-postgres-1 psql -U patbond -d patbond -c \
"SELECT platform, count(*) FILTER (WHERE event_name='health_record_create_succeeded') AS first_rec_src,
count(*) AS all_events, min(server_ts), max(server_ts)
FROM platform.product_events GROUP BY platform;"
```
### 0.3 一周内真正可行的事(decisive finding
**「待设备到位」这个阻塞理由已经不成立。** 本角色实测本机环境:
| 取证 | 结果 |
| --- | --- |
| `ls ~/Android/Sdk/system-images/*/*/*` | `android-36/google_apis_ps16k/x86_64``android-36/google_apis/x86_64` |
| `ls ~/.android/avd/` | `Pixel_7.avd``Pixel_7.ini` |
| `flutter emulators` | `1 available emulator: Pixel_7 • Pixel 7 • Google • android` |
| `ls ~/Android/Sdk/platform-tools/adb` | 存在 |
| `flutter devices` | 当前仅 linux + chrome(模拟器未启动,**不是不存在**) |
`device-verification.md:9` 明文把模拟器列为一等路径:
> **设备**Android 真机(推荐)或 Android 模拟器。桌面/Web 不可用——没有真实的移动端后台生命周期(`paused` 不触发),且 platform 值不在契约枚举内会被服务端整批拒绝。
M2 两项的步骤完整、通过标准明确、合计约 **55 分钟**(验证一 ~10 分钟:注册→切 Tab/建宠物/记体重→退后台 5 秒→psql 查 `platform='android'`;验证二 ~45 分钟:同一次登录不杀进程,5 分钟不换会话 / 35 分钟换会话,期望恰好 2 个 `session_id`)。唯一需要补的是 `ANDROID_HOME`/PATH 与 `flutter emulators --launch Pixel_7`,以及模拟器场景下宿主机地址用 `10.0.2.2`(文档 L20-31 已给命令)。
**这把最早可得的北极星读数从「无限期」拉到 2026-09-28**W38 = 09-14~09-20,最晚首记 09-20 → +8 天 = **09-28(周一)**。前提是本周内(09-14~09-20)确实产生首记,且**同一 userId 在首记之后的第 1~7 个 UTC 自然日中的另一天**再创建一条成功记录(分子不含首记当日,故单次模拟器会话无法产生分子,需跨日两次操作)。
诚实标注:这样得到的 W38 读数**是测试账号自播种的,不是产品读数**。它的价值是**管道自证**(证明 SQL、口径、落库、去重全链路可跑通并能出一个非零数),必须与真实产品读数分账登记,绝不可用于任何产品结论或 A/B 基线。
### 0.4 降级方案(三档,推荐 A+B)
| 档 | 方案 | 代价 | 本角色意见 |
| --- | --- | --- | --- |
| **A** | **把首次出数从「日期承诺」改为「事件驱动触发条件」**:定义 T0 = 首个满足「≥1 名真实移动端用户产生生命周期首条 `health_record_create_succeeded`」的 ISO 周;首次出数 = T0 + 8 天后的首个周一,此后每周一滚动。同时把 `device-verification.md:40``iteration-3/29:52` 的「09-21 时限提醒」改写为「已失效 + 指向本节」 | 常设文档两处改写 | **推荐**。真机与真实流量都不在项目控制范围内,继续挂日期只会持续生产假红线——本次就是第一例 |
| **B** | **09-21 仍按期出一次「基建就绪读数」**:不报北极星数值,只报「北极星 SQL 已可执行 + W37 分母 = N(实测)+ 链路证据」,作为管道验收而非指标读数 | 一条 SQL + 一段登记 | **推荐并行**。保住「每周一滚动」的节奏纪律不断线,且顺带把 §0.1 的「无 SQL 载体」缺口一并补上 |
| **C** | 用桌面 override(§3)或 curl 造数凑出「首批读数」 | —— | **明确否决**。数据造假,且会污染 W37 之后所有队列基线与 A/B 的历史对照 |
### 0.5 本周行动建议(按优先级,含责任侧)
| # | 行动 | 责任侧 | 工时 | 产出 |
| --- | --- | --- | --- | --- |
| 1 | 启动 `Pixel_7` 模拟器,执行 M2 两项真机验证,填 `device-verification.md` L106 执行记录 | 真机执行人(PM 已排为第一波首日工单) | 1 小时 | `platform='android'` 事件实证;A/B 前置 #1 的拦路项解除 |
| 2 | 把 `iteration-2/06` §2.1 的北极星 SQL 落成仓库内可执行载体(脚本或数据库视图),跑出 W37 实测分母 | 数据侧 | 0.5 天 | §0.4-B 的基建就绪读数;消除「无 SQL 载体」缺口 |
| 3 | 拍板 §0.4 的 A + B,改写两处常设文档的时限表述 | 用户 / PM | 0.5 小时 | 假红线清除 |
| 4 | 本周内跨两天用模拟器自播种 W38 队列(首记 + 隔日回访记录各一条),09-28 出管道自证读数 | 真机执行人 | 2 × 15 分钟 | 首个非零读数(标「自播种,非产品数据」) |
| 5 | 按 §3 修客户端桌面短路 + 调试覆盖开关,让桌面 E2E 也能验端上完整链路 | Flutter | 0.5 天 | 「埋点落库」类验证从「只能真机」降级为「桌面可验 + 真机只验移动生命周期」 |
**关于其余 8 项真机挂起**(真机挂起实为 **10 项** = M2 两项 + M3 四项 + M3.5 四项;`feature-checklist.md` 只跟踪了 M3.5 的两项,漏登记 caregiver 改宠物头像与获赞数对账两项):与北极星出数**无关**,不进本周关键路径。其中 **M3.5-3caregiver 改宠物头像)当前不可执行**——`device-verification.md:202` 承诺的造数 SQL「收口时补」至今未补,且关系授予入口未开放,需先补 SQL 才能排期。
## 1. 基线现状取证(含三处文档口径纠正)
本节所有判断均以打开原始源码/配置为准,逐条注明文件与行号。**不采信任何文档转述**(M3.5 教训:一份报告写「无 nickname 字段」实指「契约未暴露」,被误读后规模预估高估一整档)。
### 1.1 纠正一:白名单实际 **41 条**,不是 42
| 项 | 实测 | 证据 |
| --- | --- | --- |
| 白名单定义位置 | **唯一一处** `WHITELIST` map | `patbond-api/patbond-user/src/main/java/com/patbond/patbond/user/analytics/EventDictionary.java:42-104` |
| `Map.entry(` 条数 | **41** | `grep -c 'Map.entry(' EventDictionary.java` → 41;逐条枚举见下 |
| 是否存在第二处白名单 | **否** | grep `experiment_exposed` / `user_unfollowed` 全仓:命中仅该文件、其测试、文档与客户端发送点,无第二份配置 |
| 是否有测试断言条数 | **否** | `grep -n "hasSize\|size()\|42\|41\|40\|count" EventDictionaryTest.java`**零命中** |
分域实测计数:auth **11**`:43-57`+ `page_viewed` **1**`:59`+ pet **3**`:61-64`+ health_record **7**`:66-74`+ post **8**`:76-86`+ feed **2**`:88-91`+ 互动 **8**`:93-101`+ `experiment_exposed` **1**`:103`= **41**
**根因(已定位)**`experiment_exposed` 被重复计数。community 域实为 **18** 条(post 8 + feed 2 + 互动 8),`experiment_exposed` 属 platform 域,v3 新增合计 **19** 条,22 + 19 = **41**。但 `iteration-3/22:13` 写作「新增事件 **20**06 号 §1.5 的 19 个 + experiment_exposed 已含其中,编号 22~40)」——括号内「已含其中」自我否证了「20」这个数,而下游文档取了 20:`iteration-3/29-m3-summary.md:20`「22 事件 → **42 事件**(+19 community 域 + experiment_exposed)」、`feature-checklist.md:218`「EventDictionary 22→**42**」。该类自身 Javadoc`:11-16` 的 8+2+8 + experiment_exposed)反而是对的,等于 41。
**为何漂移四份文档一路不可见**`EventDictionaryTest.java` 逐事件断言 props 集合,但**从不断言字典总条数**——加一条、少一条、数错一条,测试全绿。这正是「无门禁的口径」必然漂移的样本。
**M4 前置项(本报告列为硬要求)**:在 `EventDictionaryTest` 增一个条数断言 + 一份全事件名快照断言,把「字典条数」变成受测契约。见 §7、§附 工单 1。
### 1.2 字典 41 条 vs 客户端实发 33 条:**8 条字典空转**
客户端实发事件名(`grep -rhoP "(trackEvent|_track)\(\s*'\K[a-z_]+" patbond-flutter/lib/ | sort -u`**33** 条)与字典逐条比对,以下 **8** 条在 `patbond-flutter/lib/` **零引用**(逐条 raw grep 复核,非按差集推断):
| # | 事件名 | 字典位置 | 空转原因(取证) | M4 处置建议 |
| --- | --- | --- | --- | --- |
| 1 | `auth_register_started` | `:43` | 未挂接。注意字典**也没有** `auth_login_started` | **建议 M4 顺带补挂**:注册漏斗起点缺失 ⇒ 注册转化率无分母 |
| 2 | `auth_token_refresh_succeeded` | `:50` | 未挂接 | 保留,M6 可观测性迭代补 |
| 3 | `auth_token_refresh_failed` | `:51` | 未挂接 | 同上 |
| 4 | `auth_session_restore_started` | `:54` | 未挂接 | 同上 |
| 5 | `auth_session_restore_succeeded` | `:55` | 未挂接 | 同上 |
| 6 | `auth_session_restore_failed` | `:56` | 未挂接 | 同上 |
| 7 | `health_record_deleted` | `:74` | `health_record_analytics.dart:8` 注释:「因 M2 契约无删除端点暂无挂接点,留待删除交互落地」 | 保留(等删除 UI) |
| 8 | `experiment_exposed` | `:103` | **`lib/` 零引用**——A/B 前置 #5 只交付了字典半边,Flutter 强类型封装未落地 | **M4 必做**,见 §6.2 |
**废弃建议:0 条。** 上述 8 条均有明确的将来挂接点,按 ADR-013 的判例(「废弃是零成本的」以客户端零引用为前提)反向适用:这些是「字典先行」而非「设计废弃」,删掉只会在挂接时再加回来。改为**登记为「字典空转清单」纳入离线巡检**,防止其无声长期存在。
### 1.3 纠正二:真机验证挂起实为 **10 项**,不是 8 项
`device-verification.md` 全文实登记 **10** 项 = M2 **2** + M3 **4** + M3.5 **4**。汇总文档口径分叉:`feature-checklist.md:221``releases.md:120``iteration-3/29:52` 只统计「M2 两项 + M3 四项」= 6;`feature-checklist.md:244` 只登记 M3.5 的两项(头像上传弱网、头像缓存),**漏登记 M3.5-3(caregiver 改宠物头像)与 M3.5-4(获赞数与帖子点赞数对账)**。
与埋点相关的只有两项:**M2-验证一**(Android 事件真实落库)与 **M3-4**(社区事件落库)。三处执行记录(L106/L168/L221)全空。
### 1.4 纠正三:A/B 前置为 **5 绿 3 半**,不是「6 绿 1 半」
`iteration-3/06:351` 原文结论:「**结论:M3 末 6 项全绿 + #7 部分绿,M4 初补齐监控即可启动首实验。**」本角色对代码侧逐项 grep 取证,与 PM 报告结论一致:**分流哈希、Flutter 曝光封装、feature flag 三者代码零实现**。
| # | 前置条件 | M3 宣称 | 代码/文档取证 | 对齐后 |
| --- | --- | --- | --- | --- |
| 1 | 数据质量验收 | 绿(注「拦路项是真机」) | 真机 0/10 执行;巡检无数据可跑 | **绿(条件性)**——§0.5 行动 1 执行后成立 |
| 2 | 指标基线 | 绿(「09-21 起自然达成」) | 前瞻式绿;无数据,且 §0 已判定 09-21 不成立 | **绿(条件性)**——依赖 §0.4 重定 T0 |
| 3 | 样本量规则成文 | 绿(M3 中交付) | `docs/` 无独立文档;方法学示例存在于 `iteration-2/06:317`(基线率/MDE/α/功效/每组样本量/8 周止损线) | **半**:方法已示范,规则未成文、未按实测基线重算(无基线可代入) |
| 4 | 稳定分流组件 | 绿 | `anonymousId` 持久化**已落**`analytics_service.dart:177-190`key `pb.analytics.anonymousId`);`hash(userId, experimentSalt) % buckets` **零实现**——grep `experimentSalt\|assignVariant\|分流` 全 api/flutter 主源码,`bucket` 命中全是 MinIO 对象存储桶 | **半** |
| 5 | 曝光事件 | 绿(「Flutter 强类型封装同批出」) | 字典半边已落(`EventDictionary.java:103`);Flutter 封装**零实现**(§1.2 第 8 条) | **半** |
| 6 | 实验设计模板与评审流程 | 绿(与 #3 同文档) | 同 #3,无独立文档 | 并入 #3 计 |
| 7 | 护栏监控与回滚 | **部分绿**(「回滚随社区发布开关顺带落地」) | feature flag **零实现**grep `featureflag/toggle/ConditionalOnProperty` 于 api 主源码与 `application*.yml``docker-compose*.yml``.env*`、flutter `lib/` **全部零命中**。即「回滚绿」这半也不成立 | **半(偏红)** |
| 8 | 隐私合规复核 | 常态 | 每实验一次 | **常态**M4 首实验时执行 |
**结论:三项零代码前置(#4 分流哈希、#5 Flutter 曝光封装、#7 feature flag)必须作为 M4 前置工单排期,不得假定已就绪。** 这直接决定 §6 的实验形态选择。
### 1.5 接收链路现状(M4 设计的硬约束)
| 项 | 现状 | 证据 |
| --- | --- | --- |
| 端点 | `POST /api/v1/events`,匿名可报(唯一允许匿名的写端点),单批 1–50 | `AnalyticsController.java:31-37`;契约 `openapi-v1.4.0.yaml:440-495` |
| 逐条拒绝原因 | `unknown_event_name` / `identity_mismatch` / `forbidden_field` / `schema_invalid` | `AnalyticsService.java:46-88` |
| 整批 400 的触发面 | JSON 不可解析、条数越界、**任一条 DTO 字段校验失败** | 契约 `:481``TrackEventsRequest``@NotNull`/`@Pattern`/`@Size` 全是请求级校验 |
| props 处置 | 白名单外的键**剥离后入库**(事件保留,计 warning) | `AnalyticsService.java:90-108` |
| 幂等 | `event_id` PRIMARY KEY + `ON CONFLICT DO NOTHING` | `AnalyticsRepository.java:44``V2:9` |
| 统计权威时间 | `server_ts timestamptz NOT NULL DEFAULT now()`,客户端不发 | `V2:16` |
| 事实表 | `platform.product_events`props `jsonb``user_id` **刻意不设外键** | `V2:8-31` |
| 高频事件索引 | `(event_name, server_ts)``(user_id, server_ts) WHERE NOT NULL``(anonymous_id, server_ts)` | `V2:37-45` |
| 未实现项 | 64KB 请求体上限、429 限流 | 契约据实未写(`iteration-3/06` §0 |
**对 M4 最关键的三条硬约束**(决定 §4.2 的裁定):
1. `anonymous_id uuid NOT NULL``session_id uuid NOT NULL``V2:12,14`)——服务端 Worker 二者皆无。
2. `CONSTRAINT ck_product_events_platform CHECK (platform IN ('android','ios'))``V2:23`)——**平台枚举在 DB 层也锁死**,加值需 Flyway 迁移。
3. `CONSTRAINT ck_product_events_client_ts CHECK (client_ts >= server_ts - interval '30 days' AND client_ts <= server_ts + interval '1 day')``V2:27-30`)——离线积压超 30 天的事件**落库即失败**,被吞成 `schema_invalid`
### 1.6 命名与公共属性口径(M4 沿用,不改)
- 事件名:`<域>_<动作>_<结果>` snake_case,结果后缀仅 `_succeeded`/`_failed`**单义事件不设结果后缀**`health_record_viewed`/`post_deleted` 先例)。DTO 正则 `^[a-z][a-z0-9_]{1,63}$``TrackEventsRequest.java:35`),DB CHECK 同式(`V2:21`)。
- 属性名 camelCase;公共属性十项全带(`iteration-1/13` §4.0),`userId` 是唯一可空项,`serverTs` 服务端补写、客户端不发。
- **禁止多路复用事件**(ADR-013 判例):不设 `creation_action(actionType)` 式事件,任何一个动作的枚举扩充不得污染其他指标口径的分母。
- 去重:dedupe key = `eventId`(客户端 UUIDv7,实测 `analytics_service.dart:137` 已为 `Uuid().v7()`,v2 登记的 v4 偏差已修);指标层主体去重一律 `userId`;漏斗归因窗 24 小时。
- 质量阈值:丢失率 <5%、对账偏差 <5%、去重命中 <10%、`serverTs` 覆盖 100%、无红线泄漏。
## 2. 历史包袱一:eventVersion 口径定型
### 2.1 歧义取证(三层各说各话)
登记于 `iteration-3/28` §7 观察项 2,本角色逐层复核确认:
| 层 | 现状 | 证据 |
| --- | --- | --- |
| 契约 | 描述「事件 schema 版本(**字典 v1 全部为 1**)」——可读作「每个事件自身的 schema 版本」,也可读作「事件字典版本」;`type: integer`**无 `minimum`** | `openapi-v1.4.0.yaml:2341-2344` |
| 服务端 DTO | `@NotNull Integer eventVersion`**无范围/枚举校验** | `TrackEventsRequest.java:38-39` |
| 服务端逻辑 | 原样传给落库,**全程不校验、不参与任何判断** | `AnalyticsService.java:77``AnalyticsRepository.java:26,48` |
| 数据库 | `event_version smallint NOT NULL DEFAULT 1` + `CHECK (event_version > 0)` | `V2:11,22` |
| 客户端 | 对**所有**事件硬编码 `'eventVersion': 1` | `analytics_service.dart:139` |
| E2E 脚本 | M2 版发 **2**、M3 版发 **3**(按「字典世代」读法) | `iteration-3/28` §7 |
**两处附带缺陷(本报告新增取证)**
1. **三层校验不一致导致错误被降级**`eventVersion=0` 或负数可通过 DTO 校验(只有 `@NotNull`),到落库时被 DB CHECK 拒绝抛异常,在 `AnalyticsService.java:81-84` 被 catch 后返回 `schema_invalid`。即客户端的版本号 bug 不会得到清晰的 400,而是伪装成「落库失败」逐条静默拒绝。契约「据实不写 minimum」(`iteration-2/09` §出入 4)的选择反过来固化了这个不一致。
2. **契约描述本身已过期**:「字典 v1 全部为 1」写于字典 v1 时期,字典已走到 v3 仍未更新——这句话的存在本身就是该字段长期无人管理的证据。
### 2.2 定型裁定:**每个事件自身的 props schema 版本**(否决「字典世代」读法)
四条理由,按决定性排序:
1. **历史数据的可修复性是不对称的**。按「每事件 schema 版本」读,客户端现行硬编码 1 **本就是正确的**——41 条事件中无一条曾变更过 props 语义,全部理应为 1。零客户端改动、零回填、零历史重解释。按「字典世代」读,则**所有历史行全错**:既有行该是 1/2/3 三种值却全是 1,而 `server_ts` 无法反推事件当初属于哪一代字典(同一事件名跨代持续上报),**不可修复**。
2. **「字典世代」读法承载零信息量**。字典世代可由 `event_name` 唯一确定(每个事件名只在一代中入册,`iteration-3/22` §1 的编号表即映射)。一个可被现有列完全推导的版本列是死重量,还会诱导下游写出 `WHERE event_version = 3` 这种在客户端口径下永远为空的查询——`iteration-3/28` §7 记录的「后果」正是此。
3. **「每事件 schema 版本」才是该字段的设计意图**。`iteration-1/13` §4 原文「schema 变更须递增事件版本并在本字典追加条目,禁止原地改语义」——「**追加条目**」只有在「同名事件的不同版本并列为两条字典条目」时才讲得通,这是每事件版本的语义。规划文档的历次用法也一律如此:`iteration-2/06` §1.4「若编辑放弃率成为问题再以 eventVersion=2 增补」、`iteration-3/06` §1.4「届时以 eventVersion=2 增补 `_failed`」。
4. **错的是脚本,不是客户端**。两版 E2E 脚本发 2/3 是沿 M2 先例的误读,`iteration-3/28` §3.13 落库的 `event_version=3` 已明确「**不是客户端真实取值**」。修脚本是两行改动。
### 2.3 bump 规则(定型的核心,此前完全缺失)
歧义的真正代价不是取值错,而是**没人知道什么时候该 +1**。定型如下:
| 变更类型 | 是否 bump | 理由 |
| --- | --- | --- |
| 新增**可选**属性,且不改变既有属性语义 | **不 bump** | 下游按键取值,多一个键不破坏任何既有解析;这是最高频的变更,若 bump 则版本号会因无害增补而通胀 |
| 删除属性 | **必须 bump** | 下游解析会 KeyError / 静默变 NULL |
| 改属性语义、单位或口径(如 `durationMs` 的起点变了) | **必须 bump** | 最危险的一类:不 bump 则新旧数据混在同一列,指标断层不可见 |
| 改既有枚举值的含义(新增枚举值**不**算) | **必须 bump** | 同上 |
| 改触发时机(如 `_succeeded` 从「收到响应」改为「UI 渲染完成」) | **必须 bump** | 时间序列会出现无解释的漂移 |
| 改事件名 | 不 bump,**按新事件处理** | 新名即新条目,旧名进「故意不进字典」锁死清单 |
配套纪律:
- bump 后**新旧版本在字典中并列为两条条目**(同名、不同 version、各自的 props 白名单),旧版本标注冻结日期;下游一律按 `(event_name, event_version)` 二元组取口径,**禁止只按 event_name 聚合跨版本数据**。
- 字典条目一旦发布,其 `(name, version)` 的 props 白名单**只可增不可减**(减即 bump)。
- 客户端不得硬编码字面量 `1`:改为按事件查表取版本(见 §2.4)。
### 2.4 落地方案(M4 内完成,两档)
**推荐档(严校验,把口径变成受测契约)**
1. `EventDictionary` 的 WHITELIST key 从 `String eventName` 改为 `(eventName, version)` 二元组(Java 可用 `record EventKey(String name, int version)`),`isKnownEvent`/`allowedProps` 同步改签名;41 条现有条目全部登记为 version **1**
2. `AnalyticsService.processEvent` 在「未知事件名」之后增一道校验:`(name, version)` 不在字典 ⇒ 逐条 `rejected`,新增 reason **`unknown_event_version`**。
- 关键:**放在逐条拒绝路径,不放 DTO 校验**。若做成 DTO 的 `@Min/@Max`,一条脏事件会整批 400 连坐——这正是 §3 里 `platform` 犯过的错,不重犯。
3. 契约同步:`eventVersion` 描述改为无歧义表述 + 补 `minimum: 1`(与 DB CHECK 对齐,消除 §2.1 缺陷 1);`EventResult.reason` 枚举追加 `unknown_event_version`。**属纯增量**(新增枚举值 + 新增校验说明),v1.4.0 客户端无需改动。
4. 客户端 `analytics_service.dart:139` 的字面量 `1` 改为从事件定义查表取值(与强类型封装同层,编译期锁死)。
5. 两份 E2E 脚本的 `eventVersion` 由 2/3 改 **1**
契约描述建议措辞(可直接抄):
> `eventVersion`**该事件自身 props schema 的版本**,起始 1。新增可选属性不递增;删除属性、改属性语义/单位、改既有枚举值含义、改触发时机必须递增,并在事件字典中与旧版本并列成两条条目。**不是**事件字典的世代号——字典世代由 `eventName` 唯一确定。当前全部 41 条事件均为版本 1。
**最小档(若不愿动校验逻辑)**:只做上述第 3 步的 `minimum: 1` + DTO `@Min(1)` + 第 4、5 步。代价:口径靠文档纪律维持,无门禁——鉴于 §1.1 刚证明「无门禁的口径必然漂移」,本角色**不推荐**最小档。
**为何必须在 M4 新增事件之前定型**:M4 一次进 8 条新事件,若口径未定,这 8 条会各自带上一个含义不明的版本号,债务从 41 条规模翻到 49 条规模,且新条目还会成为「按字典世代读」的新证据(有人会想给它们标 4)。定型的边际成本此刻最低。
## 3. 历史包袱二:platform=linux 桌面端埋点整批 400 的处置
### 3.1 是设计意图还是缺陷:**两者都有,但缺陷不在服务端**
**枚举排除 linux = 设计意图,且是三层锁死的**(改动成本远高于文档暗示):
| 层 | 约束 | 位置 |
| --- | --- | --- |
| 契约 | `platform: {type: string, enum: [android, ios]}` | `openapi-v1.4.0.yaml:2373-2376`(× 5 份副本) |
| 服务端 DTO | `@Pattern(regexp = "^(android|ios)$", message = "platform 必须为 android 或 ios")` | `TrackEventsRequest.java:56-58` |
| 数据库 | `CONSTRAINT ck_product_events_platform CHECK (platform IN ('android','ios'))` | `V2:23` |
**整批 400 = 契约明文行为,不是缺陷**。契约 `:481` 原文:「**整批拒绝**——JSON 不可解析、events 为空或超过 50 条、**单条事件字段校验失败**(code 40000)」。`platform` 是 DTO 字段级 `@Pattern`,属请求级校验,故一条 linux 事件否掉整批,与设计一致。
**缺陷在客户端**,两处:
1. **注释与实现相反**`analytics_service.dart:99-100` 原文:
> `/// 平台标识。契约枚举为 android/ios;Web/桌面为开发调试形态,`
> `/// 上报值不在枚举内会被服务端逐条 rejected(不影响客户端),属预期。`
实际不是「逐条 rejected」而是**整批 400**。逐条 rejected 只发生在 `AnalyticsService.processEvent` 的四种原因里(`unknown_event_name`/`identity_mismatch`/`forbidden_field`/`schema_invalid`),`platform` 根本到不了那一层。
2. **「不影响客户端」也是错的——影响是 100% 静默丢弃**。`_platformName()``:101-112`)在桌面走 `return Platform.operatingSystem``"linux"`;随后 `uploadBatch``:273-281`)把 4xx 判为**永久拒绝**
> `// 4xx 为永久性拒绝(校验失败/批量超限等),重试不可能成功;`
> `// 丢弃并打日志,避免毒丸批次无限重回队列阻塞后续事件。`
`_flush()``:229`)据此 `removeSegments(..., countAsDropped: true)`。**结果:桌面端每一批事件都被丢弃,端上采集/队列/冲刷全部正常运转但落库恒为零**,只在 debug 日志留一行 `Analytics batch permanently rejected`
这个错误注释已被转述进至少 5 处文档(`releases.md:120``device-verification.md:144``feature-checklist.md:221``iteration-3/27:34``iteration-3/28:56`),措辞多为「属契约内行为」「既有预期」——**「契约内」是对的,「预期」掩盖了「桌面端埋点能力为零」这个事实**。这与 §1.1 的 41/42 是同一类问题:一句不准确的表述被当作结论反复引用。
### 3.2 处置建议:客户端本地短路 + 显式调试覆盖开关(不动枚举)
**否决「把 linux/server 加进枚举」**:需 Flyway V6 改 CHECKM4 本就要建 `creation` schema,但混进埋点平台枚举会让这次迁移横跨两个无关关注点)+ 5 份契约副本 + DTO 正则 + 4 个模块的契约一致性矩阵重跑;且会让 `platform` 维度混入非产品流量,污染所有按平台切分的指标。收益仅为「桌面调试方便」,不成比例。
**建议做三件事(均在 Flutter 侧,零契约、零迁移)**
1. **本地短路**`_platformName()` 返回值不在 `{android, ios}` 内时,`trackEvent` **直接不入队**并 `debugPrint` 一条明确的「桌面端埋点已本地禁用(platform=$p 不在契约枚举内)」。
- 收益不只是省流量:当前桌面上**所有**事件被同一个 400 连坐丢弃,若将来同一队列里混入合法事件(例如覆盖开关只对部分事件生效),毒丸批次会把它们一起带走。短路把这个风险从「隐性」变为「不存在」。
2. **显式调试覆盖开关**:新增 `--dart-define=PATBOND_ANALYTICS_PLATFORM_OVERRIDE=android`**默认关**。开启后桌面上报 `platform=android`,使桌面 E2E 能真验端上完整链路(采集 → 分段持久化队列 → 冲刷 → 202 → 逐条 `accepted``rejected=0`)。
- **这是解 §0 困局的技术杠杆**:可把「埋点落库」这一类验证从「只能真机」降级为「桌面可验链路 + 真机只验移动生命周期」。真机仍不可替代的部分是 `paused` 生命周期与 30 分钟后台换会话(`device-verification.md:9` 明确「桌面/Web 不可用——没有真实的移动端后台生命周期(`paused` 不触发)」),即 M2-验证二的核心。
- **数据完整性护栏(必须一并写入纪律)**:覆盖开关只允许对**本地 compose 后端**使用,产生的行 `platform` 维度是伪值,禁止用于任何产品读数、北极星队列或 A/B 基线;建议同时把 `appVersion` 打上可识别后缀(如 `0.4.0+desktop-e2e`)使这类行在 SQL 层可被一条 `WHERE app_version NOT LIKE '%desktop-e2e%'` 干净剔除。**这是本建议能否被采纳的前提条件**——没有这条护栏,覆盖开关就是 §0.4 档 C(造数据)的后门。
3. **修注释与文档**:把 `analytics_service.dart:99-100` 改为陈述实际行为(整批 400 + 永久丢弃 + 桌面采集能力为零),并在 §7 列出的 5 处文档转述点同步纠正。
### 3.3 顺带发现(不属本节范围,登记备查)
`ck_product_events_client_ts``V2:27-30`)要求 `client_ts >= server_ts - interval '30 days'`。客户端持久化队列容量 500 条、宣称「离线积压约两周容量」(`analytics_service.dart:159-161`),但若设备离线超 30 天后回连,积压事件**落库即触发 CHECK 失败**,被 `AnalyticsService.java:81-84` 吞成 `schema_invalid` 逐条拒绝,客户端收 202 后删段——数据静默丢失且原因不可辨(与真正的 schema 问题同一个 reason)。建议 M4 顺手给这类拒绝一个独立 reason(如 `client_ts_out_of_range`),或在客户端冲刷前按 30 天窗口本地淘汰。**未取证**:无线上数据可证明该路径是否真实发生过。
## 4. 事件字典 v4 增量(AI 创作)
### 4.1 沿用原则与域划分
命名 `<域>_<动作>_<结果>` snake_case、结果编码进事件名(`_succeeded`/`_failed`)、单义事件不设结果后缀、`eventVersion` 起始 1(口径按 §2 定型)、公共属性十项全带、属性 camelCase。
**新增域前缀:`creation`**(与 `development-plan.md` §3 的 schema 名 `creation` 对齐,域按实体归属划——实体是「生成任务」)。服务端 Worker 侧的执行事实**不进事件字典**,理由见 §4.2。
**设计纪律(沿 ADR-013 判例)**:不设 `creation_action(actionType)` 式多路复用事件。提交/结果/失败/取消/建草稿是五个语义独立的动作,各自独立成名;任何一个的枚举扩充不污染其他指标口径的分母。
### 4.2 核心裁定:服务端生成侧**不建第二条埋点管道**
M4 的生成执行发生在 Worker(队列消费、租约、重试、超时),这些事实客户端观测不到。三个候选方案:
| 方案 | 做法 | 判定 |
| --- | --- | --- |
| A | Worker 走 `POST /api/v1/events``platform`/`anonymousId`/`sessionId` 填伪值 | **否决**`platform` 只能填 android/ios`V2:23`),会把服务端流量混进平台维度,**污染所有既有按平台切分的指标**;`session_id NOT NULL` 也只能造假 |
| B | 扩 `product_events`platform 加 `server` 值、`anonymous_id`/`session_id` 改可空 | **否决**。需 Flyway 迁移改 CHECK + 放宽两个 NOT NULL + 5 份契约;放宽 NOT NULL 是**收紧不可逆**的反向操作,且此后每个下游查询都要处理 NULL 会话 |
| C | 新建 `platform.server_events` 表,Worker 进程内直写(不走 HTTP) | 可行但**多余**,见下 |
| **D** | **以 `creation.generation_tasks` 事实表为服务端指标权威**,经 `creationTaskId` 与客户端事件拼接 | **采纳** |
**采纳 D 的理由**
1. **所需事实 100% 已在任务表里**。M4 的验收标准(`development-plan.md:252-256`)本就要求「任务状态流转合法、Worker 重启不丢任务、相同幂等请求不重复生成、失败原因可追踪」——这逼出的表结构天然包含时间戳、状态、尝试次数、失败原因。§5 的服务端侧指标(成功率、P50/P95 生成耗时、排队时长、重试率、超时率)**全部可由该表直接聚合**,事件层加不了任何信息。
2. **避免双写不一致**。若同一事实既有任务表行又有事件行,两者必然在某些边界(Worker 崩溃、事务回滚)分叉,届时「哪个是真的」无解。任务表是状态机的 system of record,事件只能是它的影子。
3. **零迁移增量**(相对方案 B/C):M4 本就要为 `creation` schema 建 V6 迁移,指标所需列并入即可,不额外建表、不改埋点管道。
4. 客户端事件继续只承载**用户可见行为**(看到了什么、点了什么、等了多久),这是它的比较优势且不可被服务端替代(用户是否真的看到结果,服务端不知道)。
**因此这是对后端工单的硬要求**(本报告的指标口径依赖它,请在契约冻结时一并确认):`creation.generation_tasks` 须持久化以下列,且**终态行保留 ≥90 天不得物理删除**(软终态):
`id`= `creationTaskId`)、`user_id``model_key``style_key``status``created_at``queued_at``first_attempt_at``last_attempt_at``finished_at``attempt_count``failure_reason``input_media_count``output_asset_count``idempotency_key`/`request_hash`
若上述任一列缺失,对应指标不可计算——`first_attempt_at` 缺失 ⇒ 排队时长与纯执行耗时无法分离(只能得到端到端耗时);`attempt_count` 缺失 ⇒ 重试率不可得。
### 4.3 隐私红线增量与一处修订申请
沿用现行红线:props 键命中 `password|token|secret|phone|mobile|email|credential|idfa|gaid` ⇒ 整条 rejected`EventDictionary.java:38-39`;客户端同款本地拦截 `analytics_service.dart:288-295`)。社区红线(`EventDictionary.java:22-25`):无自由文本、无内容或对方主体 ID、无话题名、无文件名/路径/URL,只有行为计数与分桶。
**AI 创作域的红线增量(新增三条)**
1. **生成 prompt 文本绝不入 props**,任何长度、任何截断、任何哈希都不行——哈希可被字典攻击还原短 prompt。只允许 `promptLengthBucket`(分桶)。
2. **模型/风格标识只许上报目录的稳定 key**`modelKey`/`styleKey`),不上报展示名、不上报版本串、不上报供应商名。展示名可能被产品运营改成含营销文案的自由文本。
3. **产物不入 props**:无 assetId、无 objectKey、无签名 URL、无缩略图,只有 `resultCount`/`outputAssetCount` 计数(沿 M3 媒体域先例)。
**修订申请(§8 拍板项 6**:现行红线写作「无内容或对方主体 IDpostId/commentId/topicId/target userId)」,字面上会连带禁止 `creationTaskId`。但 §5 的漏斗**必须**有一个客户端与服务端共享的拼接键,否则「提交 → 排队 → 完成 → 建草稿 → 发帖」跨越两个数据源无法连成一条。
建议把红线措辞**收敛为其原本意图**:
> 禁止**他人主体与内容主体** IDpostId / commentId / topicId / 对方 userId);**允许上报调用者自有资源的 ID 作为漏斗拼接键**,当前仅 `creationTaskId` 一例,新增需本报告同级评审。
理由:原红线的隐私意图是「防止从事件流重建「谁对谁的内容做了什么」的社交图谱」(`iteration-3/06` §1.3)。`creationTaskId` 指向的是**上报者自己**的任务,不涉及第二主体;它不泄露 prompt 或产物(那些在 `creation` 表里,与 props 同一数据库同一信任域,不构成跨边界泄漏);且它是 UUIDv7,除时间戳外无内在信息。
**否决的替代方案**:把拼接键做成第 11 个**公共属性**(顶层字段而非 props)。虽然架构上更干净(props 红线完全不动),但要改 5 份契约副本的 `TrackedEvent`、改 DTO、加 DB 列(Flyway 迁移)、且要为一个只有 `creation` 域用得上的字段污染全部 41 条既有事件的公共属性集——**十项公共属性是跨迭代冻结面,为单域需求撬动它不成比例**。走 props 白名单则是零迁移(props 是 jsonb)。
### 4.4 新事件清单(`creation` 域,8 条,全部客户端侧)
| 事件名 | 触发时机 | 专有属性 |
| --- | --- | --- |
| `creation_started` | 进入 AI 创作流程并产生**首次有效交互**(选定模型或风格、或首次输入 prompt),每次进入记一次;仅浏览不交互不记 | `entryPoint``create_tab` / `home` / `pet_detail` / `post_form`,待 UI 定稿收敛) |
| `creation_model_selected` | 模型或风格**选定动作完成**时(每次变更各记一次,不去重——变更次数本身是选择摩擦的度量) | `modelKey``styleKey``selectSeq`(本次创作内第几次选择,从 1 起) |
| `creation_submit_succeeded` | 生成任务创建接口成功响应后(拿到 taskId,**非生成完成**)(**漏斗事件**,M4 全部转化率指标的分母源) | `creationTaskId``durationMs`started→submit)、`modelKey``styleKey``inputMediaCount``promptLengthBucket``regenerateFrom` |
| `creation_submit_failed` | 生成任务创建接口失败(含配额拦截、内容审核前置拒绝) | `modelKey``failureReason``errorCode``httpStatus``attemptSeq` |
| `creation_result_viewed` | 成功生成的结果**首次渲染可见**(不是任务完成,是用户真的看到了) | `creationTaskId``waitedMs`(submit→首次可见,用户感知等待)、`resultCount` |
| `creation_failure_viewed` | 失败态**首次渲染**给用户(与 `creation_submit_failed` 区分:后者是提交就没成,此者是排队/执行后才失败) | `creationTaskId``failureReason``waitedMs` |
| `creation_cancelled` | 用户主动取消且取消接口成功响应后 | `creationTaskId``waitedMs``stage``queued` / `running` |
| `creation_draft_created` | 「一键创建社区草稿」成功响应后 | `creationTaskId``selectedCount`(选入草稿的产物数) |
**枚举值定义**(客户端编译期锁死,离线巡检;ingest 只校验键不校验值——`EventDictionary.java:26-33` 的既有设计):
| 属性 | 枚举 / 口径 |
| --- | --- |
| `regenerateFrom` | `none`(首次生成)/ `failure`(对失败任务重试)/ `dissatisfaction`(对已成功结果不满意再生成)——三值必须分开,否则「重新生成率」会把「系统不可靠」与「产品不满意」两个完全不同的问题混成一个数 |
| `failureReason` | 沿用既有族 + 新增 `quota_exceeded``model_unavailable``content_rejected`(内容安全拒绝)、`generation_timeout``cancelled` |
| `promptLengthBucket` | 沿 `textLengthBucket` 先例分桶(如 `0` / `1-20` / `21-60` / `61-200` / `200+`),**绝不上报原文与长度精确值** |
| `stage` | `queued`(尚未被 Worker 取走)/ `running`(已租约执行中) |
| `modelKey` / `styleKey` | 服务端目录接口的稳定 key,与目录契约同批冻结;**非展示名** |
| `inputMediaCount` / `resultCount` / `selectedCount` | int 计数,无输入为 0 |
**去重口径**
- `creation_started`:按「进入一次创作流程」记一次,同一 `sessionId` 内反复切页不重复记(客户端持有本次流程状态,离开流程即重置)。
- `creation_result_viewed` / `creation_failure_viewed`**每个 `creationTaskId` 至多一条**(「首次可见」语义)。客户端需按 taskId 记忆已上报集合,避免用户来回切页刷出多条——否则「结果触达率」分子会 >1。
- `creation_model_selected`**刻意不去重**(见上表 `selectSeq`)。
- 其余事件:一次成功业务动作一条。
- 服务端幂等仍以 `eventId` 为唯一保证点(客户端 at-least-once 投递)。
### 4.5 v4 增量总览(编号沿跨迭代连续编号)
字典现有 41 条(§1.1 取证),编号沿 `iteration-3/22` 的 22~40 续接。**注意:既有编号最大为 40 而实际条数为 41**(`page_viewed` 在 v2 转正时记作 `—` 未占号),本报告不回改历史编号,新增从 **41** 起编,并在 §7 要求把「编号」与「条数」两个口径在测试里各自锁死。
| # | 事件名 | 版本 | 性质 |
| --- | --- | --- | --- |
| 41 | `creation_started` | 1 | 新增 |
| 42 | `creation_model_selected` | 1 | 新增 |
| 43 | `creation_submit_succeeded` | 1 | 新增(漏斗事件) |
| 44 | `creation_submit_failed` | 1 | 新增 |
| 45 | `creation_result_viewed` | 1 | 新增(漏斗事件,结果触达) |
| 46 | `creation_failure_viewed` | 1 | 新增 |
| 47 | `creation_cancelled` | 1 | 新增 |
| 48 | `creation_draft_created` | 1 | 新增(漏斗事件,转化前置) |
| — | `post_publish_succeeded` | 1 | **属性增量**+`creationTaskId`,不 bump——§2.3 规则:新增可选属性不递增) |
| — | `post_draft_saved` | 1 | **属性增量**+`creationTaskId`,不 bump |
| — | `experiment_exposed` | 1 | **启用**(字典 M3 先行,M4 首次实际上报;无属性变更) |
**字典条数:41 → 49。废弃 0 条。**
**后端 `EventDictionary` 白名单增量(工单可直接抄)**
```java
// v4 增量 creation 域(iteration-4 报告 06 §4.4
Map.entry("creation_started", Set.of("entryPoint")),
Map.entry("creation_model_selected", Set.of("modelKey", "styleKey", "selectSeq")),
Map.entry("creation_submit_succeeded",
Set.of("creationTaskId", "durationMs", "modelKey", "styleKey",
"inputMediaCount", "promptLengthBucket", "regenerateFrom")),
Map.entry("creation_submit_failed",
Set.of("modelKey", "failureReason", "errorCode", "httpStatus", "attemptSeq")),
Map.entry("creation_result_viewed", Set.of("creationTaskId", "waitedMs", "resultCount")),
Map.entry("creation_failure_viewed", Set.of("creationTaskId", "failureReason", "waitedMs")),
Map.entry("creation_cancelled", Set.of("creationTaskId", "waitedMs", "stage")),
Map.entry("creation_draft_created", Set.of("creationTaskId", "selectedCount")),
```
**既有条目改写两处**(在原位加属性,不新增条目):
```java
// 原:Set.of("durationMs", "mediaCount", "topicCount", "textLengthBucket", "fromDraft")
Map.entry("post_publish_succeeded",
Set.of("durationMs", "mediaCount", "topicCount", "textLengthBucket",
"fromDraft", "creationTaskId")),
// 原:Set.of("trigger", "mediaCount")
Map.entry("post_draft_saved", Set.of("trigger", "mediaCount", "creationTaskId")),
```
**类注释红线措辞同步修订**`EventDictionary.java:22-25`):按 §4.3 把「no content or counterpart IDs」改为「no **counterpart-subject or content** IDs; the caller's own `creationTaskId` is permitted as the funnel join key」。
### 4.6 漏斗定义、闭环与缺口复核
**AI 创作全漏斗(六段,跨两个数据源,单键 `creationTaskId` 拼接)**
| 段 | 事件 / 事实 | 数据源 | 主体去重 |
| --- | --- | --- | --- |
| 1 进入创作 | `creation_started` | 客户端 | userId |
| 2 选定模型 | `creation_model_selected`(末次) | 客户端 | userId |
| 3 提交生成 | `creation_submit_succeeded` / `creation_submit_failed` | 客户端 | userId(任务量用 creationTaskId |
| 4 排队与执行 | `generation_tasks``queued_at``first_attempt_at``finished_at``status``attempt_count` | **服务端事实表** | creationTaskId |
| 5 结果触达 | `creation_result_viewed` / `creation_failure_viewed` / `creation_cancelled` | 客户端 | creationTaskId |
| 6 建草稿 → 发帖 | `creation_draft_created``post_publish_succeeded``creationTaskId` 非空) | 客户端 | userId |
**join key 定型**`creationTaskId`(UUIDv7,由服务端在创建任务时生成并在响应中返回,客户端原样上报)。
- 客户端事件侧位置:`props->>'creationTaskId'`
- 服务端侧:`creation.generation_tasks.id`
- 拼接 SQL 惯例(漏斗第 3~5 段):
```sql
SELECT t.id, t.status, t.attempt_count,
t.first_attempt_at - t.queued_at AS queue_wait,
t.finished_at - t.first_attempt_at AS exec_time,
v.props->>'waitedMs' AS perceived_wait_ms
FROM creation.generation_tasks t
LEFT JOIN platform.product_events v
ON v.event_name = 'creation_result_viewed'
AND (v.props->>'creationTaskId')::uuid = t.id
WHERE t.created_at >= :from;
```
- **为何不用 (userId, 时间窗) 拼接**:用户可并发提交多个任务(重试 + 新建),时间窗拼接在并发场景下会把 A 任务的耗时配给 B 任务的结果,且无法察觉——P95 耗时这类尾部指标对错配极其敏感。此外未登录不可创作(生成消耗配额),故 userId 恒非空,但仍不足以消歧。
**闭环成立**:六段无断点,第 4 段的服务端事实与前后客户端事件均可经 `creationTaskId` 连上。
**缺口 1(接受不埋):客户端 `creation_queued`。** 排队进入/结束的时点以服务端为准(`queued_at`/`first_attempt_at`),客户端只能靠轮询观测,粒度取决于轮询周期,会系统性高估排队时长。客户端侧的「等待」由 `waitedMs` 一个用户感知量表达即可,不再补一个精度更差的重复事件。
**缺口 2(接受不埋):`creation_retried` 独立事件。** 重试即一次新提交,已由 `creation_submit_succeeded.regenerateFrom ∈ {failure, dissatisfaction}` 表达。独立事件会与提交事件双计,且让「提交总数」这个分母出现两种口径。
**缺口 3(接受不埋):`creation_quota_blocked` 独立事件。** 已由 `creation_submit_failed(failureReason='quota_exceeded')` 覆盖;独立成名违反「结果编码进事件名」惯例,且分裂提交失败的分母。
**缺口 4(登记待拍板,缺口 4 = §8 拍板 9):结果内单产物的选择/滑动行为**(多产物时用户看了第几张、选了第几张)。首版不埋——UI 未定稿,且 `resultIndex` 类属性容易被误当作「用户偏好某模型输出」的产品结论。若成为问题,届时按 §2.3 以新增可选属性(不 bump)补 `selectedIndexBucket`。
**修订 1(实质缺口,本版已修)**:`post_publish_succeeded` 与 `post_draft_saved` 的现行白名单**无任何标识帖子来源的属性**(分别为 `durationMs`/`mediaCount`/`topicCount`/`textLengthBucket`/`fromDraft` 与 `trigger`/`mediaCount`,见 `EventDictionary.java:78-79,77`),因此「AI 生成 → 发帖」这条 M4 最核心的转化**在现行字典下根本不可计算**。这是本报告发现的最实质字典缺口,靠 §4.5 的两处属性增量修复。
**维度够用性**`modelKey`/`styleKey` 支持按模型切分全部指标;`regenerateFrom` 区分系统不可靠与产品不满意;`promptLengthBucket`/`inputMediaCount` 支持输入复杂度 × 耗时/成功率相关分析;`entryPoint` 支持入口效率对比;平台与版本维度由公共属性提供。**一处不够用但无需加属性**:「是否首次使用 AI 创作」可由用户级 `min(server_ts)` of `creation_submit_succeeded` 推导。
### 4.7 pageName 枚举增量
`page_viewed.pageName` 由客户端编译期枚举锁死,**ingest 只校验键不校验值**`EventDictionary.java:30-33` 明确「pageName growth needs no code change here」),故后端零改动。
现行客户端枚举实测 **14** 个(`analytics_page_name.dart:9-25`):`login`/`register`/`home`/`profile`/`pet_list`/`pet_detail`/`pet_form`/`record_form`/`record_detail`/`create`/`pet_archive`/`services`/`post_detail`/`post_form`。
**M4 增量:新增 1 个** —— `creation_result`(生成结果页)。AI 创作 Tab 复用既有 `create`;模型/风格选择若为同页 sheet 则不单独计页。
**顺带登记的偏差(无功能影响)**:`EventDictionary.java:28-31` 的注释声称 v3 pageName 家族含 `topic_list`/`topic_detail`/`user_profile`/`follower_list`/`following_list`/`favorite_list`/`draft_list`,但客户端枚举中**这 7 个都不存在**(对应页面属 ADR-018 剪出或尚未落地)。因 ingest 不校验值,无功能影响;但这是「文档先于实现」的又一例,建议随本次修订据实收敛。
## 5. M4 指标口径定义
统计口径沿用 v1/v2`server_ts` 划 UTC 日界;主体去重一律 `userId`;漏斗归因窗 24 小时。**任务级指标按 `creationTaskId` 去重,用户级指标按 `userId` 去重**——两者不可混用(一个用户可提交 N 个任务,混用会让重度用户支配读数)。
| 指标 | 分子 | 分母 | 数据源 | 口径要点 |
| --- | --- | --- | --- | --- |
| **生成成功率** | `status='succeeded'` 的任务数 | `status IN ('succeeded','failed')` 的任务数 | 服务端事实表 | **排除 `cancelled`**(用户主动取消不是系统失败,计入会让「用户不耐烦」伪装成「系统不可靠」);**排除未终态**(`queued`/`running`);按 task 去重非按 user |
| **P50 / P95 生成耗时** | —— | —— | 服务端事实表 | `finished_at - first_attempt_at`(**纯执行耗时,不含排队**);只取 `status='succeeded'`(失败任务的耗时是超时值,混入会污染尾部);P95 用 `percentile_cont(0.95)`;按模型(`model_key`)分组发布,**不发布跨模型合并的 P95**(不同模型耗时分布差一个量级,合并值无意义) |
| **排队时长** | —— | —— | 服务端事实表 | `first_attempt_at - queued_at`;P50/P95 同上;**重试任务只取首次尝试**,否则重试等待会被计成排队 |
| **端到端感知等待**(辅助) | —— | —— | 客户端 | `creation_result_viewed.waitedMs` 的 P50/P95。与「排队 + 执行」之差即客户端轮询/渲染开销——**这个差值是客户端性能债的直接读数** |
| **结果触达率** | `creation_result_viewed` 的去重 `creationTaskId` | `status='succeeded'` 的任务数 | 双源 | 衡量「生成完了但用户没看到」。**这是 AI 创作特有的浪费指标**:算力已花但价值未交付。触达率低 ⇒ 需要完成通知 |
| **生成 → 发帖转化率**(M4 核心) | 24h 内 `post_publish_succeeded` 且 `props->>'creationTaskId'` 非空 的去重 `userId` | `creation_result_viewed` 的去重 `userId` | 客户端 | 分母用**结果触达**而非提交或成功——没看到结果的人不可能发帖,用提交做分母会把系统故障算进产品转化的账 |
| **草稿 → 发帖转化率**(辅助) | 同上分子 | `creation_draft_created` 去重 `userId` | 客户端 | 拆解上一指标:区分「不想发」与「建了草稿卡在发布环节」 |
| **重新生成率** | `creation_submit_succeeded` 且 `regenerateFrom <> 'none'` | 全部 `creation_submit_succeeded` | 客户端 | **必须按 `regenerateFrom` 值分开发布两个数**`failure` 档升高 = 系统不可靠(应归因到生成成功率);`dissatisfaction` 档升高 = 产品质量问题。合并成一个数会让两种截然不同的病症互相掩盖 |
| **配额触顶率** | `creation_submit_failed` 且 `failureReason='quota_exceeded'` 的去重 `userId` | `creation_started` 的去重 `userId` | 客户端 | 分母用「进入创作」而非「提交」——被配额拦住的用户其提交是失败的,用提交做分母会自我循环。**同时须按用户分层看**(触顶用户占比 vs 触顶次数分布),均值会掩盖「少数重度用户天天触顶」 |
| **创作漏斗整体转化** | `creation_submit_succeeded` 去重 `userId` | `creation_started` 去重 `userId` | 客户端 | 提交前流失(选模型放弃、prompt 写不出来) |
**护栏指标(M4 全程周报,不只 A/B 期间)**:
| 护栏 | 口径 | 告警线 |
| --- | --- | --- |
| 生成成功率 | 见上 | 周环比相对下降 >5% ⇒ P1 |
| P95 生成耗时 | 见上,按模型 | 周环比 +50% ⇒ P1 |
| 内容审核拒绝率 | `failureReason='content_rejected'` / 提交总数 | 绝对值 >5% ⇒ 复核审核阈值是否过严 |
| 埋点质量四项 | 丢失率 <5%、对账偏差 <5%、去重命中 <10%、`serverTs` 覆盖 100% | 任一破线 ⇒ 当周读数标「数据未验收」 |
| **北极星不回退** | 7 日回访记录率(ADR-012) | AI 创作是新场景,**必须确认它没有把用户从「记录」这个核心场景吸走**——这是 M4 最重要的护栏 |
| 帖子删除率 | `post_deleted` / `post_publish_succeeded`,按 `creationTaskId` 有无分两群 | AI 帖删除率显著高于自发帖 ⇒ 一键发布在诱导用户发不想发的内容 |
**对账 SQL 增量(入周一巡检,沿 `iteration-2/06` §6 惯例)**
```sql
-- 对账 1:客户端提交事件数 vs 服务端任务行数(期望偏差 <5%)
SELECT
(SELECT count(DISTINCT props->>'creationTaskId') FROM platform.product_events
WHERE event_name = 'creation_submit_succeeded' AND server_ts >= :from) AS client_submits,
(SELECT count(*) FROM creation.generation_tasks WHERE created_at >= :from) AS server_tasks;
-- 对账 2:孤儿 taskId——客户端上报了但服务端无此任务(期望恒为空集,命中即 P1)
SELECT DISTINCT e.props->>'creationTaskId' AS orphan_task
FROM platform.product_events e
LEFT JOIN creation.generation_tasks t ON (e.props->>'creationTaskId')::uuid = t.id
WHERE e.event_name LIKE 'creation_%' AND e.props ? 'creationTaskId' AND t.id IS NULL;
-- 对账 3AI 来源帖的 creationTaskId 必须指向本人任务(期望恒为空集,命中即越权或口径错)
SELECT e.event_id
FROM platform.product_events e
JOIN creation.generation_tasks t ON (e.props->>'creationTaskId')::uuid = t.id
WHERE e.event_name = 'post_publish_succeeded' AND e.user_id IS NOT NULL
AND t.user_id <> e.user_id;
-- 对账 4:红线巡检——creation 域事件不得出现 prompt/URL 形态的值(期望恒为空集)
SELECT event_id, event_name, props
FROM platform.product_events
WHERE event_name LIKE 'creation_%'
AND (props::text ~* '(https?://|/[a-z0-9_-]+\.(jpg|jpeg|png|webp|heic))'
OR length(props::text) > 512); -- 512 字节以上说明混入了自由文本
-- 对账 5:字典空转巡检(§1.2)——列出白名单内但本周零上报的事件
-- (期望只剩已知的 8 条空转项;出现新成员即挂接回归)
SELECT e.name FROM (VALUES ('creation_started'),('creation_submit_succeeded'),
('creation_result_viewed'),('creation_draft_created'),('experiment_exposed')) AS e(name)
WHERE NOT EXISTS (SELECT 1 FROM platform.product_events p
WHERE p.event_name = e.name AND p.server_ts >= :from);
```
**未取证**:以上 SQL 未在真实数据上执行过(无数据,§0.2),且 `creation.generation_tasks` 的列名以 §4.2 的要求为准、尚未由后端契约确认;后端定稿后需回改列名并试跑一次。
## 6. A/B 实验设计
### 6.1 形态裁定:M4 首个「实验」应是 A/A 基建验证,真实 A/B 冻结设计待流量
`iteration-3/29:68` 的路线是「M4 可启动首个实验」。本角色按 §1.4 取证后**修订这个判断**,两条理由:
1. **前置三项代码零实现**(分流哈希、Flutter 曝光封装、feature flag)。这不是「补监控即可」的距离,是从零写三个组件。
2. **样本量不可达**。§6.3 的真实 A/B 需约 **1,560** 名到达生成结果的用户;当前真实用户数为 **0**(§0.2 取证)。按 M2 §4.3 的止损纪律「若按届时 DAU 换算实验需运行超过 8 周,判定该实验不可行」,该实验现在就判不可行。
**因此 M4 的实验交付物拆成两件**:
- **6.2 A/A 基建验证空跑**——M4 内可执行、零产品风险、极小样本即有价值。
- **6.3 首个真实 A/B 设计书**——设计冻结、判定线预登记(防 HARKing),**放行条件挂在流量上**,不挂日期。
### 6.2 A/A 基建验证实验(M4 交付,非产品实验)
**目的**:在没有产品风险的前提下验证实验基建本身。A/A 的全部价值在于「**当两组体验完全相同时,指标差异应当不显著**」——若显著,说明基建有缺陷(分流不均、曝光漏报、指标管道错配),而这类缺陷在真实 A/B 中会被误读成产品结论。
| 项 | 设计 |
| --- | --- |
| 假设 | 无产品假设。原假设即「两组无差异」,期望**不拒绝** |
| 变体 | Control 与 Variant **完全相同的现有体验**(代码路径一致,只走一遍分流与曝光上报) |
| 触点 | AI 创作结果页(`creation_result` 页曝光时上报 `experiment_exposed` |
| 分流单位 | `userId`(AI 创作需登录消耗配额,故无需 anonymousId 分流——**这顺带让前置 #4 的「登录前分流归并规则」不成为本实验的前置**) |
| 分流实现 | `hash(userId + experimentSalt) % 100``experimentKey='creation_result_cta_aa'`50/50 |
| 检查项 | ① **SRM(样本比例失配)**:两组曝光用户数的卡方检验,α=0.001,超出即分流有偏;② 曝光事件与分配的一致性:`experiment_exposed` 去重用户数 ≈ 实际到达结果页用户数(漏报率 <2%);③ 每个 userId 的 `variant` **跨会话、跨设备恒定**(同一 userId 出现两个 variant ⇒ P1);④ §5 全部指标在两组间的差异均不显著(p>0.05);⑤ 停止规则与回滚开关各演练一次 |
| 样本量 | 检测粗差不需要功效计算:**≥200 曝光用户合计**即可暴露 >60/40 的分流失衡与曝光漏报;恒定性检查(③)在几十个用户上就能发现 bug。**测试账号可参与**(无产品结论,不存在污染产品读数的问题,但须与产品读数分账) |
| 运行周期 | ≥3 天(跨一次冷启动与一次 token 刷新,验证 variant 在会话重建后不漂移) |
| 停止规则 | 检查项 ①③ 任一失败 ⇒ 立即停、修基建、重跑;不设「提前成功」——A/A 没有成功可提前 |
| 产品风险 | **零**(两组体验相同) |
**这一步的产出是「基建可信」这个前提本身**。跳过它直接跑真实 A/B,等于把三个从未运行过的新组件与一个产品结论绑在一起,出问题时无法归因。
### 6.3 首个真实 A/B 设计书(设计冻结,放行条件挂流量)
**候选选择说明**`iteration-3/06:351` 的候选池按 H3/H4/H5/H7 判定结果动态排序,但 H1~H8 **全部无读数**(无数据),数据驱动的排序此刻不可用。故按「M4 新建面 + 效应量可期 + 与 M4 核心指标直接挂钩」选定下述实验,并明确它**不占用**候选池中那四个记录域实验的席位。
```markdown
# 实验:生成结果页「发布到社区」引导强度
## 假设
**问题陈述**:AI 生成的产物默认停留在创作页,用户需自行找到入口才能发到社区;
「生成 → 发帖」这条 M4 最核心的价值链路很可能在最后一步大量流失。
**假设**:把生成结果页的「发布到社区」从次级入口提升为主按钮,并预填一段可编辑的
默认文案,将提升「生成 → 发帖转化率」。
**主指标**:生成 → 发帖转化率(§5 定义:24h 内 post_publish_succeeded 且
creationTaskId 非空的去重 userId / creation_result_viewed 去重 userId
**成功阈值**:绝对提升 ≥ +6pp 且 95% CI 下界 > 0
**次要指标**:草稿 → 发帖转化率、创作漏斗整体转化、重新生成率(dissatisfaction 档)
**护栏指标**:生成成功率、P95 生成耗时(按模型)、**帖子删除率(AI 帖 vs 自发帖)**、
次日回访、**北极星 7 日回访记录率不得下降**
## 实验设计
**类型**:双臂 A/Bfeature flag 控制,服务端下发变体)
**总体**:全部到达生成结果页的登录用户;无地域/机型限制;新老用户均入组(并预登记
按「是否首次使用 AI 创作」的分层分析,**该分层是预登记的、非事后挖掘**)
**分流单位**userId`hash(userId + experimentSalt) % 100`),50/50
**样本量**:每组 ~780、合计 ~1,560 名**曝光**(= 到达结果页)用户
推导:p₁=0.20(基线假设,**无实测基线,见下方风险**)、p₂=0.26MDE +6pp)、
双侧 α=0.05、功效 80%
n = (1.96·√(2·0.23·0.77) + 0.8416·√(0.20·0.80 + 0.26·0.74))² / 0.06² ≈ 772 → 取 780
**最短运行周期**:**≥14 天**且不早于样本量达标。14 天的三个来源:① 覆盖两个完整周内
节律(周末创作行为与工作日不同);② 主指标含 24h 归因窗;③ 北极星护栏需 +8 天成熟期
**变体**
- Control:现状——结果页主操作为「保存」,发布社区为次级入口
- Variant A:结果页主按钮改为「发布到社区」,预填默认文案(可编辑),「保存」降为次级
## 风险评估
**潜在风险**
1. 诱导发布低质内容 → 社区 Feed 质量下降、用户后悔删帖
2. 预填文案若千篇一律 → Feed 同质化
3. 「保存」降级可能损害只想留存产物的用户
**缓解**:帖子删除率为一级护栏(AI 帖 vs 自发帖分群对比);预填文案不得含固定营销语;
「保存」保持一次点击可达;feature flag 支持 5 分钟内全量回滚
**成功/失败判定**
- 主指标达阈值 且 全部护栏未破 → **Go**,全量
- 主指标达阈值 但 帖子删除率显著升高 → **不全量**,改做「发布前预览确认」再测
- 主指标未达阈值(CI 含 0)→ **No-Go**,保持现状,转而排查结果触达率
## 统计纪律(预登记,不得事后调整)
- **固定视界,不许偷看**:样本量达标且满 14 天前不做任何显著性判定。中途看板只
允许查看护栏与数据质量,**不得查看主指标的 p 值**
- **单一主指标**,故无需多重比较校正;次要指标一律标注为探索性,**不可用于翻盘
No-Go 结论**
- 护栏采用单侧监控(α=0.005),其触发只用于**提前停止**,不用于宣告成功
- **提前停止仅在护栏破线时**:生成成功率相对下降 >5%,或 P95 生成耗时 +50%
或帖子删除率相对升高 >30%
- 判定线先于数据存在(防 HARKing);分层分析只有上面预登记的一项
- 隐私合规复核(A/B 前置 #8)在 T0 前执行一次
## 放行条件(全部满足才能启动,**不挂日期**)
1. §6.2 的 A/A 验证通过(含 SRM 与 variant 恒定性)
2. A/B 前置 #4 分流哈希、#5 Flutter 曝光封装、#7 feature flag **代码落地并有测试**
3. #3/#6 样本量规则与实验设计模板成文
4. 埋点质量四项达标,且 §5 对账 1~4 连续两周无破线
5. **实测基线**`creation_result_viewed → 发帖` 转化率连续 ≥2 周稳定产出(当前用的
p₁=0.20 是**假设值**,达标后必须用实测值重算样本量——若实测基线是 5% 而非 20%,
所需样本量会变成数千每组,实验可行性结论随之翻转)
6. 按实测 DAU 换算,预计运行 **≤8 周**(>8 周即判不可行,退回观察性分析,止损线预登记)
```
**当前放行条件状态:0/6 满足。** 条件 5 与 6 依赖真实流量,不在工程可控范围内——这是本设计书**必须挂条件而非挂日期**的原因,也是 §0.4 给北极星提的同一条纪律(用触发条件替代日期承诺)。
### 6.4 `experiment_exposed` 的上报纪律(M4 首次启用)
字典条目 M3 已先行(`EventDictionary.java:103`props = `experimentKey`、`variant`),M4 首次实际上报。三条纪律:
1. **在用户实际到达实验触点、变体 UI 已渲染时上报**,不在分配时上报。分配时上报会把「被分到但从未看到」的用户算进分母,系统性稀释效应量(`iteration-3/06:145` 已定此口径)。
2. **每个 (userId, experimentKey) 每会话至多一条**,跨会话可重复(用于验证 variant 恒定性)。分析时按 userId 去重取首次曝光。
3. **`experimentKey` 取自实验注册表枚举**,不接受自由字符串;`variant` 取 `control` / `variant_a` 等固定枚举。注册表是 §附 工单的交付物之一。
## 7. 白名单变更清单与同步点
### 7.1 变更汇总
| 类别 | 数量 | 明细 |
| --- | --- | --- |
| **新增事件** | **8** | `creation_started`、`creation_model_selected`、`creation_submit_succeeded`、`creation_submit_failed`、`creation_result_viewed`、`creation_failure_viewed`、`creation_cancelled`、`creation_draft_created` |
| **既有事件加属性** | **2** | `post_publish_succeeded` +`creationTaskId``post_draft_saved` +`creationTaskId` |
| **启用既有事件** | **1** | `experiment_exposed`(字典已在,客户端封装 M4 补) |
| **废弃事件** | **0** | 见 §1.2:8 条空转事件均有将来挂接点,改为纳入巡检而非废弃 |
| **eventVersion bump** | **0** | 新增可选属性按 §2.3 规则不递增 |
| **pageName 新增** | **1** | `creation_result`(后端零改动) |
| **字典条数** | **41 → 49** | 基数 41 已取证(§1.1),非文档记载的 42 |
| **Flyway 迁移** | 埋点侧 **0** | `creation` schema 的 V6 迁移属后端范围;`product_events` 不动(props 是 jsonb,加属性零迁移) |
### 7.2 同步点(契约/配置/测试,逐项可勾)
| # | 同步点 | 文件 | 改动 |
| --- | --- | --- | --- |
| 1 | 后端白名单 | `patbond-api/patbond-user/src/main/java/com/patbond/patbond/user/analytics/EventDictionary.java` | +8 条目(§4.5 代码块可直抄);改写 2 条既有条目;类注释红线措辞按 §4.3 修订;若采纳 §2.4 推荐档则 WHITELIST key 改 `(name, version)` 二元组 |
| 2 | 后端服务 | `.../analytics/AnalyticsService.java` | 仅 §2.4 推荐档需改:增 `unknown_event_version` 逐条拒绝分支(**放逐条路径,不放 DTO**) |
| 3 | 后端 DTO | `.../analytics/TrackEventsRequest.java` | `eventVersion` 补 `@Min(1)`(与 DB CHECK 对齐,消除 §2.1 缺陷 1)。**`platform` 正则不动** |
| 4 | **字典条数门禁** | `.../analytics/EventDictionaryTest.java` | **本次必做**:① 断言白名单条数 = 49;② 断言全事件名快照集合;③ 新增 8 条各自的 props 断言;④ 2 条改写事件的 props 断言更新。**理由见 §1.1——没有这个断言,41/42 这类漂移永久不可见** |
| 5 | 契约(5 份副本,md5 现为同一值) | `patbond-api/patbond-{auth,user,pet,community}/src/test/resources/contract/openapi-v1.4.0.yaml` + `patbond-doc/docs/api/openapi.yaml` | `eventVersion` 描述改无歧义表述 + 补 `minimum: 1``EventResult.reason` 枚举 +`unknown_event_version`(推荐档)。**均为纯增量**,v1.4.0 客户端无需改动。(`patbond-doc/site/` 下同名文件是 mkdocs 构建产物,不手改) |
| 6 | 客户端埋点核心 | `patbond-flutter/lib/analytics/analytics_service.dart` | ① `:139` 的 `'eventVersion': 1` 字面量改查表取值;② `:99-100` 注释按 §3.1 据实改写;③ `_platformName()` 非枚举值本地短路(§3.2);④ 新增 `PATBOND_ANALYTICS_PLATFORM_OVERRIDE` 调试覆盖 + `appVersion` 后缀护栏 |
| 7 | 客户端强类型封装(新建) | `patbond-flutter/lib/features/creation/creation_analytics.dart` | 8 条事件的编译期封装 + 枚举(沿 `pet_analytics.dart`/`post_analytics.dart`/`community_interaction_analytics.dart` 先例:枚举锁死,业务代码禁止手拼事件名与属性) |
| 8 | 客户端曝光封装(新建) | `patbond-flutter/lib/analytics/experiment_exposure.dart`(建议路径) | `experiment_exposed` 强类型封装 + 实验注册表枚举(A/B 前置 #5 的缺失半边,§1.4) |
| 9 | pageName 枚举 | `patbond-flutter/lib/analytics/analytics_page_name.dart` | +`creationResult('creation_result')`;顺带据实收敛 §4.7 登记的 7 个不存在项的注释 |
| 10 | 既有帖子埋点 | `patbond-flutter/lib/features/community/post_analytics.dart` | 发布/存草稿两处透传 `creationTaskId`(仅 AI 来源时携带) |
| 11 | E2E 脚本 | `patbond-flutter/test_e2e_m2_manual.dart`、`test_e2e_m3_manual.dart` | `eventVersion` 由 2/3 改 **1**(§2.2 理由 4 |
| 12 | 常设文档条数纠正 | `patbond-doc/docs/development/feature-checklist.md:218` | 「EventDictionary 22→42」改为据实的 41,并记 M4 后 49 |
| 13 | 常设文档 platform 表述 | `releases.md:120`、`device-verification.md:144`、`feature-checklist.md:221` | 把「逐条 rejected / 不影响客户端」改为「整批 400 + 永久丢弃,桌面采集能力为零」(§3.1) |
| 14 | 常设文档北极星时限 | `device-verification.md:40`、`iteration-3/29:52` | 按 §0.4 档 A 改为触发条件表述(迭代报告存档按「迭代报告豁免」惯例不回改,只改常设页与被引用的时限提醒) |
| 15 | ADR | `patbond-doc/docs/architecture/decisions.md` | 新增「ADR-023 M4 埋点与实验决策」:eventVersion 定型 + bump 规则、服务端不建第二管道、红线为 `creationTaskId` 开例外、北极星时限改触发条件、A/B 前置状态按取证重置为 5 绿 3 半、M4 首个实验为 A/A |
### 7.3 巡检增量
- §5 的对账 SQL 1~5 入周一巡检。
- 「字典空转清单」(§1.2 的 8 条 + M4 后新增项)纳入月度巡检,防止字典先行条目无声长期空转。
- SRM 检查(§6.2 检查项 ①)在任何实验运行期间每日跑一次。
## 8. 待拍板清单(汇总)
| # | 事项 | 选项 | 本角色裁定 / 建议 |
| --- | --- | --- | --- |
| **1** | **北极星 09-21 首次出数时限的处置** | ① 维持日期承诺 ② 改为事件驱动触发条件(档 A)+ 09-21 出基建就绪读数(档 B) ③ 造数凑读数(档 C) | **建议 ② = A+B**。09-21 已被算术判定为不可能(W37 入队窗口 09-13 已关闭,§0.2),维持日期只会持续生产假红线;档 C **明确否决**(数据造假且污染后续所有队列基线)。同时把 `iteration-2/06` §2.1 的北极星 SQL 落成仓库内可执行载体——它目前只存在于报告正文 |
| **2** | **eventVersion 口径定型** | ① 每个事件自身的 props schema 版本 ② 事件字典世代号 | **建议 ①**(§2.2 四条理由)。决定性理由是可修复性不对称:读法 ① 下客户端现行硬编码 1 **本就正确**,零改动零回填;读法 ② 下全部历史行皆错且**不可修复**(`server_ts` 无法反推事件当初属于哪代字典)。必须在 M4 新增 8 条事件**之前**定型,否则债务从 41 条规模翻到 49 条 |
| **3** | **eventVersion 是否上服务端校验** | ① 推荐档:字典 key 改 `(name, version)`,未知版本逐条 `unknown_event_version` ② 最小档:仅补契约 `minimum: 1` + DTO `@Min(1)` | **建议 ①**。§1.1 刚证明「无门禁的口径必然漂移四份文档而不可见」,纯文档纪律不足。注意实现须放**逐条拒绝路径**,不可放 DTO 校验——否则重犯 `platform` 的整批连坐错误 |
| **4** | **platform=linux 处置** | ① 把 linux/server 加进枚举 ② 客户端本地短路 + 默认关闭的调试覆盖开关 | **建议 ②**。枚举是三层锁死(契约 × 5 份 + DTO 正则 + DB CHECK `V2:23`),加值需 Flyway 迁移且会让非产品流量污染 platform 维度;收益仅「桌面调试方便」,不成比例。**采纳 ② 的前提条件**:覆盖开关必须配 `appVersion` 可识别后缀护栏(§3.2),否则它就是档 C 造数据的后门 |
| **5** | **服务端生成侧指标来源** | ① 新建 `server_events` 埋点管道 ② 扩 `product_events` 容纳服务端事件 ③ 以 `creation.generation_tasks` 事实表为权威 | **建议 ③**(§4.2)。所需事实 100% 已在任务表(M4 验收标准本就逼出这些列),事件层加不了信息还会引入双写不一致;② 需放宽两个 NOT NULL(不可逆的反向操作)+ 改 DB CHECK。**代价**:后端须承诺 §4.2 列出的列齐备且终态行保留 ≥90 天,否则对应指标不可算 |
| **6** | **隐私红线是否为 `creationTaskId` 开例外** | ① 维持「无内容或对方主体 ID」的字面禁止,漏斗改用 (userId, 时间窗) 拼接 ② 收敛措辞为「禁他人/内容主体 ID」,允许上报者自有资源 ID 作拼接键 | **建议 ②**。原红线意图是防止重建「谁对谁的内容做了什么」的社交图谱,`creationTaskId` 不涉第二主体、不泄露 prompt 与产物;① 的时间窗拼接在并发提交下会把 A 任务耗时配给 B 任务结果且不可察觉,对 P95 这类尾部指标是致命的。**否决**「做成第 11 个公共属性」的替代方案——为单域需求撬动跨迭代冻结的十项公共属性面不成比例 |
| **7** | **M4 首个「实验」的形态** | ① 直接跑真实 A/B ② A/A 基建验证先行,真实 A/B 冻结设计待流量 | **建议 ②**(§6.1)。真实 A/B 需 ~1,560 名到达结果页用户,当前真实用户 0,按 M2 §4.3 止损纪律现在就判不可行;且三项前置为零代码。A/A 零产品风险、极小样本即可暴露分流失衡与曝光漏报——**跳过它等于把三个从未运行过的组件与一个产品结论绑在一起** |
| **8** | **A/B 前置状态是否按取证结果重置** | ① 维持 `iteration-3/29` 的「6 绿 1 半」 ② 重置为 **5 绿 3 半**并写入 ADR | **建议 ②**。#4 分流哈希、#5 Flutter 曝光封装、#7 feature flag 三者代码零实现(逐项 grep 取证,§1.4),且 #1/#2 的绿是**前瞻式**的(挂在「09-21 起自然达成」上,该前提已随 §0 失效)。这直接决定 M4 排期——**若按「6 绿 1 半」排,会在实验启动日才发现要先写三个组件** |
| **9** | 结果内单产物选择/滑动行为是否首版就埋(§4.6 缺口 4) | ① 首版补 `selectedIndexBucket` ② 不埋,成为问题时按 §2.3 新增可选属性 | **建议 ②**。UI 未定稿;且 `resultIndex` 类数据容易被误读成「用户偏好某模型输出」的产品结论。复活条件已写明 |
| **10** | 白名单条数口径纠正(42 → 41,M4 后 49)的回改范围 | ① 全量回改含历史迭代报告 ② 只改常设文档(`feature-checklist.md`),迭代报告存档不动 | **建议 ②**,沿既有「迭代报告豁免」惯例:迭代报告是时点存档,回改会破坏其与当时代码状态的对应关系。但**必须**同时落地 §7.2 同步点 4 的条数断言,否则纠正一次仍会漂移第二次 |
**最关键三项**:**#1**(北极星时限——唯一有外部日期承诺、且已失效)、**#8**(A/B 前置重置——直接改变 M4 排期与工单量)、**#5**(服务端指标来源——决定 M4 后端表结构,定晚了要返工迁移)。
## 附:M4 埋点工单拆分建议(按依赖排序)
1. **真机执行人(首日,1 小时,无依赖,最高优先)**:`export ANDROID_HOME=~/Android/Sdk` + `flutter emulators --launch Pixel_7`,执行 M2 两项真机验证(步骤 `device-verification.md:42-92`,模拟器场景宿主机地址用 `10.0.2.2`),填 L106 执行记录。产出 `platform='android'` 事件实证,解除 A/B 前置 #1 的拦路项。**这是 §0.3 的 decisive finding 的直接落地,且不依赖任何其他工单**。
2. **用户 / PM(首日)**:拍板 §8 的 #1、#2、#5、#8(前两项决定后续工单形态,#5 决定后端表结构,#8 决定排期)。
3. **数据侧(0.5 天,依赖 1)**:把 `iteration-2/06` §2.1 北极星 SQL 落成仓库内可执行载体,跑出 W37 实测分母,按 §0.4 档 B 出「基建就绪读数」。
4. **后端(依赖 2 的 #5**`creation` schema 的 V6 迁移中确保 §4.2 要求的列齐备(`queued_at`/`first_attempt_at`/`attempt_count`/`failure_reason` 等)+ 终态行保留 ≥90 天纪律;与生成任务契约同批冻结 `modelKey`/`styleKey` 枚举。
5. **后端(依赖 2 的 #2#3**`EventDictionary` v4 增量 8 条 + 2 条改写(§4.5 代码块可直抄);eventVersion 校验按推荐档;**同批补字典条数与全名快照断言**(§7.2 同步点 4)。可与 4 并行。
6. **后端(依赖 5)**:契约 5 份副本同步 `eventVersion` 描述 + `minimum: 1` + `reason` 枚举增量;跑契约一致性矩阵。
7. **Flutter(依赖 2 的 #4,可与 4/5 并行)**`analytics_service.dart` 四项改动(eventVersion 查表、注释据实、桌面短路、调试覆盖 + appVersion 护栏);两份 E2E 脚本 `eventVersion` 改 1。
8. **Flutter(依赖 5 的字典)**:新建 `creation_analytics.dart` 强类型封装 8 事件 + 枚举;`analytics_page_name.dart` +`creationResult``post_analytics.dart` 透传 `creationTaskId`。
9. **后端 + FlutterA/B 前置补齐,依赖 2 的 #8)**:① 分流组件 `hash(userId + experimentSalt) % 100` + 实验注册表;② `experiment_exposure.dart` 曝光强类型封装;③ feature flag 开关机制(含 5 分钟内回滚能力)。**三者均为零代码起步,不得按「已就绪」排期**。
10. **本角色(依赖 9**:样本量规则 + 实验设计模板成文(A/B 前置 #3/#6,此前两迭代标绿但无文档);A/A 基建验证实验的检查项清单与 SRM 巡检脚本。
11. **本角色(M4 全程)**:每周一北极星/护栏读数复核;§5 对账 SQL 1~5 入巡检;字典空转清单月度巡检。
12. **文档(收官前)**ADR-023 落地;§7.2 同步点 12~14 的三处常设文档纠正。