# 第四迭代埋点与实验规划(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 观察项 2(eventVersion 歧义登记) > 依据:`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-28(W38 队列)**,见 §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.0(09-14 发布)只登记三仓 tag 与 CI 门禁,无商店/内测分发;`server-exposure.md` 对公网只开 22/80/443(Gitea + 文档站),**无 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-3(caregiver 改宠物头像)当前不可执行**——`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 改 CHECK(M4 本就要建 `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)**:现行红线写作「无内容或对方主体 ID(postId/commentId/topicId/target userId)」,字面上会连带禁止 `creationTaskId`。但 §5 的漏斗**必须**有一个客户端与服务端共享的拼接键,否则「提交 → 排队 → 完成 → 建草稿 → 发帖」跨越两个数据源无法连成一条。 建议把红线措辞**收敛为其原本意图**: > 禁止**他人主体与内容主体** ID(postId / 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; -- 对账 3:AI 来源帖的 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/B(feature flag 控制,服务端下发变体) **总体**:全部到达生成结果页的登录用户;无地域/机型限制;新老用户均入组(并预登记 按「是否首次使用 AI 创作」的分层分析,**该分层是预登记的、非事后挖掘**) **分流单位**:userId(`hash(userId + experimentSalt) % 100`),50/50 **样本量**:每组 ~780、合计 ~1,560 名**曝光**(= 到达结果页)用户 推导:p₁=0.20(基线假设,**无实测基线,见下方风险**)、p₂=0.26(MDE +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. **后端 + Flutter(A/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 的三处常设文档纠正。