八角色并行开工分析,合计 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>
83 KiB
第四迭代埋点与实验规划(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-apidev@3cd8005EventDictionary.java/TrackEventsRequest.java/AnalyticsService.java/V2__create_platform_product_events.sql现行实现;patbond-flutterdev@fbcd734lib/analytics/与lib/features/*/*_analytics.dart现行挂接;契约 v1.4.0;development-plan.md§M4;device-verification.md范围:M4 AI 创作纵切(模型/风格目录、生成任务创建/查询/取消、Worker 队列执行、产物入媒体表、一键建社区草稿)的埋点与实验;本地服务(M5)不在本轮定义 性质:纯规划文档,供 M4 开发工单直接引用;本报告未改动任何生产代码或配置
速览(七个核心结论):
- 北极星 09-21 首次出数不可行,且不是「来不及」而是「窗口已关闭」。09-21 是 W37 队列(首记 09-07~09-13)+8 天的成熟日,入队窗口已于 09-13 结束——今日起做任何事都无法为 W37 补进一个用户。给出三档降级方案,推荐「触发条件替代日期承诺」+「基建就绪读数」,见 §0。
- 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。 - 白名单实际 41 条,不是 42,根因是
experiment_exposed被重复计数(community 域实为 18 条,加 platform 域 1 条共 19 条新增;文档记作「19 + experiment_exposed」= 20)。无任何测试断言字典条数,故漂移四份文档一路不可见。M4 前置项:补一个条数断言,见 §1.1、§7。 - eventVersion 定型为「每个事件自身的 props schema 版本」(否决「字典世代」读法):客户端现行硬编码 1 在此读法下本就正确,零客户端改动、零历史回填;反之则全部历史行皆错且不可修复。配套给出 bump 规则与三层校验对齐方案,见 §2。
platform=linux整批 400 是契约明文行为,枚举排除 linux 是三层锁死的设计意图;缺陷在客户端——analytics_service.dart:99-100注释称「逐条 rejected(不影响客户端)」与实现相反:实际整批 400 且被uploadBatch判为永久拒绝整批丢弃,桌面端 100% 静默丢事件。处置为客户端本地短路 + 调试覆盖开关,不动枚举,见 §3。- 服务端生成侧不建第二条埋点管道:
platform.product_events的anonymous_id/session_idNOT NULL 与platform IN ('android','ios')CHECK 使 Worker 事件无法入表;裁定以creation.generation_tasks事实表为服务端指标权威,经creationTaskId与客户端事件拼接,见 §4.2。 - 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,证据链:
device-verification.md三处执行记录(L106 / L168 / L221)全部空白——10 项真机验证一项未执行。- 无 android/ios 包分发:
releases.mdv0.4.0(09-14 发布)只登记三仓 tag 与 CI 门禁,无商店/内测分发;server-exposure.md对公网只开 22/80/443(Gitea + 文档站),无 API 端点对外,因此不存在真实用户可达的后端。 - 桌面端 100% 丢弃(§3 取证):唯一实际跑过的客户端形态(Linux 桌面)产生的事件全被整批 400 后永久丢弃。
- 库中可能存在的
product_events行只有 curl 人工注入的合成 payload(iteration-3/25§5c、iteration-3/26§5c,均以platform=android伪值造),且位于可被docker compose down -v清掉的本地卷patbond_pgdata。
未取证项:本角色未查询数据库(compose 未运行,且 8 个 agent 并发期间不宜起容器占端口)。W37 分母的实测值需执行:
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-1409-20,最晚首记 09-20 → +8 天 = 09-28(周一)。前提是本周内(09-1409-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 的裁定):
anonymous_id uuid NOT NULL、session_id uuid NOT NULL(V2:12,14)——服务端 Worker 二者皆无。CONSTRAINT ck_product_events_platform CHECK (platform IN ('android','ios'))(V2:23)——平台枚举在 DB 层也锁死,加值需 Flyway 迁移。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 |
两处附带缺陷(本报告新增取证):
- 三层校验不一致导致错误被降级:
eventVersion=0或负数可通过 DTO 校验(只有@NotNull),到落库时被 DB CHECK 拒绝抛异常,在AnalyticsService.java:81-84被 catch 后返回schema_invalid。即客户端的版本号 bug 不会得到清晰的 400,而是伪装成「落库失败」逐条静默拒绝。契约「据实不写 minimum」(iteration-2/09§出入 4)的选择反过来固化了这个不一致。 - 契约描述本身已过期:「字典 v1 全部为 1」写于字典 v1 时期,字典已走到 v3 仍未更新——这句话的存在本身就是该字段长期无人管理的证据。
2.2 定型裁定:每个事件自身的 props schema 版本(否决「字典世代」读法)
四条理由,按决定性排序:
- 历史数据的可修复性是不对称的。按「每事件 schema 版本」读,客户端现行硬编码 1 本就是正确的——41 条事件中无一条曾变更过 props 语义,全部理应为 1。零客户端改动、零回填、零历史重解释。按「字典世代」读,则所有历史行全错:既有行该是 1/2/3 三种值却全是 1,而
server_ts无法反推事件当初属于哪一代字典(同一事件名跨代持续上报),不可修复。 - 「字典世代」读法承载零信息量。字典世代可由
event_name唯一确定(每个事件名只在一代中入册,iteration-3/22§1 的编号表即映射)。一个可被现有列完全推导的版本列是死重量,还会诱导下游写出WHERE event_version = 3这种在客户端口径下永远为空的查询——iteration-3/28§7 记录的「后果」正是此。 - 「每事件 schema 版本」才是该字段的设计意图。
iteration-1/13§4 原文「schema 变更须递增事件版本并在本字典追加条目,禁止原地改语义」——「追加条目」只有在「同名事件的不同版本并列为两条字典条目」时才讲得通,这是每事件版本的语义。规划文档的历次用法也一律如此:iteration-2/06§1.4「若编辑放弃率成为问题再以 eventVersion=2 增补」、iteration-3/06§1.4「届时以 eventVersion=2 增补_failed」。 - 错的是脚本,不是客户端。两版 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 内完成,两档)
推荐档(严校验,把口径变成受测契约):
EventDictionary的 WHITELIST key 从String eventName改为(eventName, version)二元组(Java 可用record EventKey(String name, int version)),isKnownEvent/allowedProps同步改签名;41 条现有条目全部登记为 version 1。AnalyticsService.processEvent在「未知事件名」之后增一道校验:(name, version)不在字典 ⇒ 逐条rejected,新增 reasonunknown_event_version。- 关键:放在逐条拒绝路径,不放 DTO 校验。若做成 DTO 的
@Min/@Max,一条脏事件会整批 400 连坐——这正是 §3 里platform犯过的错,不重犯。
- 关键:放在逐条拒绝路径,不放 DTO 校验。若做成 DTO 的
- 契约同步:
eventVersion描述改为无歧义表述 + 补minimum: 1(与 DB CHECK 对齐,消除 §2.1 缺陷 1);EventResult.reason枚举追加unknown_event_version。属纯增量(新增枚举值 + 新增校验说明),v1.4.0 客户端无需改动。 - 客户端
analytics_service.dart:139的字面量1改为从事件定义查表取值(与强类型封装同层,编译期锁死)。 - 两份 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")` |
| 数据库 | CONSTRAINT ck_product_events_platform CHECK (platform IN ('android','ios')) |
V2:23 |
整批 400 = 契约明文行为,不是缺陷。契约 :481 原文:「整批拒绝——JSON 不可解析、events 为空或超过 50 条、单条事件字段校验失败(code 40000)」。platform 是 DTO 字段级 @Pattern,属请求级校验,故一条 linux 事件否掉整批,与设计一致。
缺陷在客户端,两处:
-
注释与实现相反。
analytics_service.dart:99-100原文:/// 平台标识。契约枚举为 android/ios;Web/桌面为开发调试形态,/// 上报值不在枚举内会被服务端逐条 rejected(不影响客户端),属预期。实际不是「逐条 rejected」而是整批 400。逐条 rejected 只发生在
AnalyticsService.processEvent的四种原因里(unknown_event_name/identity_mismatch/forbidden_field/schema_invalid),platform根本到不了那一层。 -
「不影响客户端」也是错的——影响是 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 侧,零契约、零迁移):
- 本地短路:
_platformName()返回值不在{android, ios}内时,trackEvent直接不入队并debugPrint一条明确的「桌面端埋点已本地禁用(platform=$p 不在契约枚举内)」。- 收益不只是省流量:当前桌面上所有事件被同一个 400 连坐丢弃,若将来同一队列里混入合法事件(例如覆盖开关只对部分事件生效),毒丸批次会把它们一起带走。短路把这个风险从「隐性」变为「不存在」。
- 显式调试覆盖开关:新增
--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(造数据)的后门。
- 这是解 §0 困局的技术杠杆:可把「埋点落库」这一类验证从「只能真机」降级为「桌面可验链路 + 真机只验移动生命周期」。真机仍不可替代的部分是
- 修注释与文档:把
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 的理由:
- 所需事实 100% 已在任务表里。M4 的验收标准(
development-plan.md:252-256)本就要求「任务状态流转合法、Worker 重启不丢任务、相同幂等请求不重复生成、失败原因可追踪」——这逼出的表结构天然包含时间戳、状态、尝试次数、失败原因。§5 的服务端侧指标(成功率、P50/P95 生成耗时、排队时长、重试率、超时率)全部可由该表直接聚合,事件层加不了任何信息。 - 避免双写不一致。若同一事实既有任务表行又有事件行,两者必然在某些边界(Worker 崩溃、事务回滚)分叉,届时「哪个是真的」无解。任务表是状态机的 system of record,事件只能是它的影子。
- 零迁移增量(相对方案 B/C):M4 本就要为
creationschema 建 V6 迁移,指标所需列并入即可,不额外建表、不改埋点管道。 - 客户端事件继续只承载用户可见行为(看到了什么、点了什么、等了多久),这是它的比较优势且不可被服务端替代(用户是否真的看到结果,服务端不知道)。
因此这是对后端工单的硬要求(本报告的指标口径依赖它,请在契约冻结时一并确认):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 创作域的红线增量(新增三条):
- 生成 prompt 文本绝不入 props,任何长度、任何截断、任何哈希都不行——哈希可被字典攻击还原短 prompt。只允许
promptLengthBucket(分桶)。 - 模型/风格标识只许上报目录的稳定 key(
modelKey/styleKey),不上报展示名、不上报版本串、不上报供应商名。展示名可能被产品运营改成含营销文案的自由文本。 - 产物不入 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 白名单增量(工单可直接抄):
// 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")),
既有条目改写两处(在原位加属性,不新增条目):
// 原: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 段):
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 惯例):
-- 对账 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 取证后修订这个判断,两条理由:
- 前置三项代码零实现(分流哈希、Flutter 曝光封装、feature flag)。这不是「补监控即可」的距离,是从零写三个组件。
- 样本量不可达。§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 核心指标直接挂钩」选定下述实验,并明确它不占用候选池中那四个记录域实验的席位。
# 实验:生成结果页「发布到社区」引导强度
## 假设
**问题陈述**: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 首次实际上报。三条纪律:
- 在用户实际到达实验触点、变体 UI 已渲染时上报,不在分配时上报。分配时上报会把「被分到但从未看到」的用户算进分母,系统性稀释效应量(
iteration-3/06:145已定此口径)。 - 每个 (userId, experimentKey) 每会话至多一条,跨会话可重复(用于验证 variant 恒定性)。分析时按 userId 去重取首次曝光。
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 小时,无依赖,最高优先):
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 的直接落地,且不依赖任何其他工单。 - 用户 / PM(首日):拍板 §8 的 #1、#2、#5、#8(前两项决定后续工单形态,#5 决定后端表结构,#8 决定排期)。
- 数据侧(0.5 天,依赖 1):把
iteration-2/06§2.1 北极星 SQL 落成仓库内可执行载体,跑出 W37 实测分母,按 §0.4 档 B 出「基建就绪读数」。 - 后端(依赖 2 的 #5):
creationschema 的 V6 迁移中确保 §4.2 要求的列齐备(queued_at/first_attempt_at/attempt_count/failure_reason等)+ 终态行保留 ≥90 天纪律;与生成任务契约同批冻结modelKey/styleKey枚举。 - 后端(依赖 2 的 #2#3):
EventDictionaryv4 增量 8 条 + 2 条改写(§4.5 代码块可直抄);eventVersion 校验按推荐档;同批补字典条数与全名快照断言(§7.2 同步点 4)。可与 4 并行。 - 后端(依赖 5):契约 5 份副本同步
eventVersion描述 +minimum: 1+reason枚举增量;跑契约一致性矩阵。 - Flutter(依赖 2 的 #4,可与 4/5 并行):
analytics_service.dart四项改动(eventVersion 查表、注释据实、桌面短路、调试覆盖 + appVersion 护栏);两份 E2E 脚本eventVersion改 1。 - Flutter(依赖 5 的字典):新建
creation_analytics.dart强类型封装 8 事件 + 枚举;analytics_page_name.dart+creationResult;post_analytics.dart透传creationTaskId。 - 后端 + Flutter(A/B 前置补齐,依赖 2 的 #8):① 分流组件
hash(userId + experimentSalt) % 100+ 实验注册表;②experiment_exposure.dart曝光强类型封装;③ feature flag 开关机制(含 5 分钟内回滚能力)。三者均为零代码起步,不得按「已就绪」排期。 - 本角色(依赖 9):样本量规则 + 实验设计模板成文(A/B 前置 #3/#6,此前两迭代标绿但无文档);A/A 基建验证实验的检查项清单与 SRM 巡检脚本。
- 本角色(M4 全程):每周一北极星/护栏读数复核;§5 对账 SQL 1~5 入巡检;字典空转清单月度巡检。
- 文档(收官前):ADR-023 落地;§7.2 同步点 12~14 的三处常设文档纠正。