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

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

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

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

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

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

83 KiB
Raw Blame History

第四迭代埋点与实验规划(AI 创作)

角色:Experiment Tracker 日期:2026-09-14 前序:iteration-2/06(字典 v2、北极星定义式、H1~H4、A/B 八项前置)、iteration-2/24(白名单 v2)、iteration-3/06(字典 v3、聚合曝光裁定、A/B 前置推进)、iteration-3/22(白名单 v3)、iteration-3/10(队列硬化)、iteration-3/28 §7 观察项 2eventVersion 歧义登记) 依据:patbond-api dev@3cd8005 EventDictionary.java / TrackEventsRequest.java / AnalyticsService.java / V2__create_platform_product_events.sql 现行实现;patbond-flutter dev@fbcd734 lib/analytics/lib/features/*/*_analytics.dart 现行挂接;契约 v1.4.0development-plan.md §M4device-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/adbflutter emulators 列出一个可直接启动的 Pixel_7 AVD;而 device-verification.md:9 明文接受「Android 真机(推荐)或 Android 模拟器」。两项合计约 55 分钟,今天即可执行。这把最早可得的北极星读数从「无限期」拉到 2026-09-28W38 队列),见 §0.3。
  3. 白名单实际 41 条,不是 42,根因是 experiment_exposed 被重复计数(community 域实为 18 条,加 platform 域 1 条共 19 条新增;文档记作「19 + experiment_exposed」= 20)。无任何测试断言字典条数,故漂移四份文档一路不可见。M4 前置项:补一个条数断言,见 §1.1、§7。
  4. eventVersion 定型为「每个事件自身的 props schema 版本」(否决「字典世代」读法):客户端现行硬编码 1 在此读法下本就正确,零客户端改动、零历史回填;反之则全部历史行皆错且不可修复。配套给出 bump 规则与三层校验对齐方案,见 §2。
  5. platform=linux 整批 400 是契约明文行为,枚举排除 linux 是三层锁死的设计意图;缺陷在客户端——analytics_service.dart:99-100 注释称「逐条 rejected(不影响客户端)」与实现相反:实际整批 400 且被 uploadBatch 判为永久拒绝整批丢弃,桌面端 100% 静默丢事件。处置为客户端本地短路 + 调试覆盖开关,不动枚举,见 §3。
  6. 服务端生成侧不建第二条埋点管道platform.product_eventsanonymous_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-012architecture/decisions.md:128,原文):

北极星 = 7 日回访记录率(分母:当 ISO 周产生生命周期首条 health_record_create_succeeded 的去重用户;分子:其中在首记日之后第 1–7 个 UTC 自然日内再次创建成功者;不含首记当日;首记日 +8 天出数)。

ADR-020decisions.md:172)确认 M3 保持不变,复评点 = M3 收官 + H7 读数。完整定义式、七项口径与出数 SQL 在 iteration-2/06 §2.1。指标定义侧零缺口

  • 唯一数据源 health_record_create_succeeded 在白名单内(EventDictionary.java:67),且客户端确实在发lib/features/pets/health_record_analytics.dart 挂接,本角色 grep 取证于 §1.2 的 33 条实发清单)。
  • 口径刻意不依赖 sessionId(只用 userId + server_ts),故队列硬化与会话缺陷都不污染读数。
  • 统计纪律已定:Wilson 95% CI 发布、<50 人周合并或 4 周滚动、未成熟队列不发布。

但出数缺一个可执行载体iteration-2/06 §2.1 的 SQL 只存在于报告正文,仓库内无脚本、无视图、无定时任务,没有任何一处「跑一下就出数」的入口。这与 PM 报告的判断一致,列为 §8 拍板项与 §附 工单。

0.2 09-21 为何不可行:窗口已关闭(算术判定,非进度判断)

09-21 这个日期的来源是 iteration-3/06:312(原文):

| 北极星首个成熟周队列 | W37 队列(09-07~09-13 首记)+8 天成熟 | 2026-09-21(周一)首次出数,此后每周一滚动 | 数据侧 | 本角色发布(Wilson 95% CI<50 人周合并) |

对齐 ISO 周实测(date -d):09-07 = 周一 / ISO 周 37 第 1 天,09-13 = 周日 / W37 第 7 天,09-14 = 周一 / W38 第 1 天。因此:

事实 结论
W37 的入队窗口 = 首记日落在 09-07~09-13 该窗口已于 2026-09-13(昨天)结束
分母 = 首记落在 W37 的去重 userId 今日(09-14)起产生的首记一律归入 W38,永远进不了 W37
09-21 = W37 最后一名成员(09-13 首记)的 +8 天成熟日 09-21 只能出 W37 这一个队列的读数

判定:不可行,且无任何补救能改变。 这不是「一周内做不完」的问题——即使今天就把 10 项真机验证全部做完、立刻拉来一批真实用户,他们全部落在 W38,W37 的分母不会增加一个人。

W37 分母的现实取值:极可能为 0,证据链:

  1. device-verification.md 三处执行记录(L106 / L168 / L221全部空白——10 项真机验证一项未执行。
  2. 无 android/ios 包分发:releases.md v0.4.009-14 发布)只登记三仓 tag 与 CI 门禁,无商店/内测分发;server-exposure.md 对公网只开 22/80/443Gitea + 文档站),无 API 端点对外,因此不存在真实用户可达的后端。
  3. 桌面端 100% 丢弃(§3 取证):唯一实际跑过的客户端形态(Linux 桌面)产生的事件全被整批 400 后永久丢弃。
  4. 库中可能存在的 product_events 行只有 curl 人工注入的合成 payloaditeration-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_64android-36/google_apis/x86_64
ls ~/.android/avd/ Pixel_7.avdPixel_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-28W38 = 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:40iteration-3/29:52 的「09-21 时限提醒」改写为「已失效 + 指向本节」 常设文档两处改写 推荐。真机与真实流量都不在项目控制范围内,继续挂日期只会持续生产假红线——本次就是第一例
B 09-21 仍按期出一次「基建就绪读数」:不报北极星数值,只报「北极星 SQL 已可执行 + W37 分母 = N(实测)+ 链路证据」,作为管道验收而非指标读数 一条 SQL + 一段登记 推荐并行。保住「每周一滚动」的节奏纪律不断线,且顺带把 §0.1 的「无 SQL 载体」缺口一并补上
C 用桌面 override(§3)或 curl 造数凑出「首批读数」 —— 明确否决。数据造假,且会污染 W37 之后所有队列基线与 A/B 的历史对照

0.5 本周行动建议(按优先级,含责任侧)

# 行动 责任侧 工时 产出
1 启动 Pixel_7 模拟器,执行 M2 两项真机验证,填 device-verification.md L106 执行记录 真机执行人(PM 已排为第一波首日工单) 1 小时 platform='android' 事件实证;A/B 前置 #1 的拦路项解除
2 iteration-2/06 §2.1 的北极星 SQL 落成仓库内可执行载体(脚本或数据库视图),跑出 W37 实测分母 数据侧 0.5 天 §0.4-B 的基建就绪读数;消除「无 SQL 载体」缺口
3 拍板 §0.4 的 A + B,改写两处常设文档的时限表述 用户 / PM 0.5 小时 假红线清除
4 本周内跨两天用模拟器自播种 W38 队列(首记 + 隔日回访记录各一条),09-28 出管道自证读数 真机执行人 2 × 15 分钟 首个非零读数(标「自播种,非产品数据」)
5 按 §3 修客户端桌面短路 + 调试覆盖开关,让桌面 E2E 也能验端上完整链路 Flutter 0.5 天 「埋点落库」类验证从「只能真机」降级为「桌面可验 + 真机只验移动生命周期」

关于其余 8 项真机挂起(真机挂起实为 10 项 = M2 两项 + M3 四项 + M3.5 四项;feature-checklist.md 只跟踪了 M3.5 的两项,漏登记 caregiver 改宠物头像与获赞数对账两项):与北极星出数无关,不进本周关键路径。其中 M3.5-3caregiver 改宠物头像)当前不可执行——device-verification.md:202 承诺的造数 SQL「收口时补」至今未补,且关系授予入口未开放,需先补 SQL 才能排期。

1. 基线现状取证(含三处文档口径纠正)

本节所有判断均以打开原始源码/配置为准,逐条注明文件与行号。不采信任何文档转述(M3.5 教训:一份报告写「无 nickname 字段」实指「契约未暴露」,被误读后规模预估高估一整档)。

1.1 纠正一:白名单实际 41 条,不是 42

实测 证据
白名单定义位置 唯一一处 WHITELIST map patbond-api/patbond-user/src/main/java/com/patbond/patbond/user/analytics/EventDictionary.java:42-104
Map.entry( 条数 41 grep -c 'Map.entry(' EventDictionary.java → 41;逐条枚举见下
是否存在第二处白名单 grep experiment_exposed / user_unfollowed 全仓:命中仅该文件、其测试、文档与客户端发送点,无第二份配置
是否有测试断言条数 grep -n "hasSize|size()|42|41|40|count" EventDictionaryTest.java零命中

分域实测计数:auth 11:43-57+ page_viewed 1:59+ pet 3:61-64+ health_record 7:66-74+ post 8:76-86+ feed 2:88-91+ 互动 8:93-101+ experiment_exposed 1:103= 41

根因(已定位)experiment_exposed 被重复计数。community 域实为 18 条(post 8 + feed 2 + 互动 8),experiment_exposed 属 platform 域,v3 新增合计 19 条,22 + 19 = 41。但 iteration-3/22:13 写作「新增事件 2006 号 §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 -u33 条)与字典逐条比对,以下 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:221releases.md:120iteration-3/29:52 只统计「M2 两项 + M3 四项」= 6;feature-checklist.md:244 只登记 M3.5 的两项(头像上传弱网、头像缓存),漏登记 M3.5-3caregiver 改宠物头像)与 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-190key 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*.ymldocker-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 字段校验失败 契约 :481TrackEventsRequest@NotNull/@Pattern/@Size 全是请求级校验
props 处置 白名单外的键剥离后入库(事件保留,计 warning AnalyticsService.java:90-108
幂等 event_id PRIMARY KEY + ON CONFLICT DO NOTHING AnalyticsRepository.java:44V2:9
统计权威时间 server_ts timestamptz NOT NULL DEFAULT now(),客户端不发 V2:16
事实表 platform.product_eventsprops jsonbuser_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 NULLsession_id uuid NOT NULLV2: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: integerminimum openapi-v1.4.0.yaml:2341-2344
服务端 DTO @NotNull Integer eventVersion无范围/枚举校验 TrackEventsRequest.java:38-39
服务端逻辑 原样传给落库,全程不校验、不参与任何判断 AnalyticsService.java:77AnalyticsRepository.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")`
数据库 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:120device-verification.md:144feature-checklist.md:221iteration-3/27:34iteration-3/28:56),措辞多为「属契约内行为」「既有预期」——「契约内」是对的,「预期」掩盖了「桌面端埋点能力为零」这个事实。这与 §1.1 的 41/42 是同一类问题:一句不准确的表述被当作结论反复引用。

3.2 处置建议:客户端本地短路 + 显式调试覆盖开关(不动枚举)

否决「把 linux/server 加进枚举」:需 Flyway V6 改 CHECKM4 本就要建 creation schema,但混进埋点平台枚举会让这次迁移横跨两个无关关注点)+ 5 份契约副本 + DTO 正则 + 4 个模块的契约一致性矩阵重跑;且会让 platform 维度混入非产品流量,污染所有按平台切分的指标。收益仅为「桌面调试方便」,不成比例。

建议做三件事(均在 Flutter 侧,零契约、零迁移)

  1. 本地短路_platformName() 返回值不在 {android, ios} 内时,trackEvent 直接不入队debugPrint 一条明确的「桌面端埋点已本地禁用(platform=$p 不在契约枚举内)」。
    • 收益不只是省流量:当前桌面上所有事件被同一个 400 连坐丢弃,若将来同一队列里混入合法事件(例如覆盖开关只对部分事件生效),毒丸批次会把它们一起带走。短路把这个风险从「隐性」变为「不存在」。
  2. 显式调试覆盖开关:新增 --dart-define=PATBOND_ANALYTICS_PLATFORM_OVERRIDE=android默认关。开启后桌面上报 platform=android,使桌面 E2E 能真验端上完整链路(采集 → 分段持久化队列 → 冲刷 → 202 → 逐条 acceptedrejected=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_tsV2: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/eventsplatform/anonymousId/sessionId 填伪值 否决platform 只能填 android/iosV2:23),会把服务端流量混进平台维度,污染所有既有按平台切分的指标session_id NOT NULL 也只能造假
B product_eventsplatform 加 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_idmodel_keystyle_keystatuscreated_atqueued_atfirst_attempt_atlast_attempt_atfinished_atattempt_countfailure_reasoninput_media_countoutput_asset_countidempotency_key/request_hash

若上述任一列缺失,对应指标不可计算——first_attempt_at 缺失 ⇒ 排队时长与纯执行耗时无法分离(只能得到端到端耗时);attempt_count 缺失 ⇒ 重试率不可得。

4.3 隐私红线增量与一处修订申请

沿用现行红线:props 键命中 password|token|secret|phone|mobile|email|credential|idfa|gaid ⇒ 整条 rejectedEventDictionary.java:38-39;客户端同款本地拦截 analytics_service.dart:288-295)。社区红线(EventDictionary.java:22-25):无自由文本、无内容或对方主体 ID、无话题名、无文件名/路径/URL,只有行为计数与分桶。

AI 创作域的红线增量(新增三条)

  1. 生成 prompt 文本绝不入 props,任何长度、任何截断、任何哈希都不行——哈希可被字典攻击还原短 prompt。只允许 promptLengthBucket(分桶)。
  2. 模型/风格标识只许上报目录的稳定 keymodelKey/styleKey),不上报展示名、不上报版本串、不上报供应商名。展示名可能被产品运营改成含营销文案的自由文本。
  3. 产物不入 props:无 assetId、无 objectKey、无签名 URL、无缩略图,只有 resultCount/outputAssetCount 计数(沿 M3 媒体域先例)。

修订申请(§8 拍板项 6:现行红线写作「无内容或对方主体 IDpostId/commentId/topicId/target userId)」,字面上会连带禁止 creationTaskId。但 §5 的漏斗必须有一个客户端与服务端共享的拼接键,否则「提交 → 排队 → 完成 → 建草稿 → 发帖」跨越两个数据源无法连成一条。

建议把红线措辞收敛为其原本意图

禁止他人主体与内容主体 IDpostId / commentId / topicId / 对方 userId);允许上报调用者自有资源的 ID 作为漏斗拼接键,当前仅 creationTaskId 一例,新增需本报告同级评审。

理由:原红线的隐私意图是「防止从事件流重建「谁对谁的内容做了什么」的社交图谱」(iteration-3/06 §1.3)。creationTaskId 指向的是上报者自己的任务,不涉及第二主体;它不泄露 prompt 或产物(那些在 creation 表里,与 props 同一数据库同一信任域,不构成跨边界泄漏);且它是 UUIDv7,除时间戳外无内在信息。

否决的替代方案:把拼接键做成第 11 个公共属性(顶层字段而非 props)。虽然架构上更干净(props 红线完全不动),但要改 5 份契约副本的 TrackedEvent、改 DTO、加 DB 列(Flyway 迁移)、且要为一个只有 creation 域用得上的字段污染全部 41 条既有事件的公共属性集——十项公共属性是跨迭代冻结面,为单域需求撬动它不成比例。走 props 白名单则是零迁移(props 是 jsonb)。

4.4 新事件清单(creation 域,8 条,全部客户端侧)

事件名 触发时机 专有属性
creation_started 进入 AI 创作流程并产生首次有效交互(选定模型或风格、或首次输入 prompt),每次进入记一次;仅浏览不交互不记 entryPointcreate_tab / home / pet_detail / post_form,待 UI 定稿收敛)
creation_model_selected 模型或风格选定动作完成时(每次变更各记一次,不去重——变更次数本身是选择摩擦的度量) modelKeystyleKeyselectSeq(本次创作内第几次选择,从 1 起)
creation_submit_succeeded 生成任务创建接口成功响应后(拿到 taskId,非生成完成)(漏斗事件M4 全部转化率指标的分母源) creationTaskIddurationMsstarted→submit)、modelKeystyleKeyinputMediaCountpromptLengthBucketregenerateFrom
creation_submit_failed 生成任务创建接口失败(含配额拦截、内容审核前置拒绝) modelKeyfailureReasonerrorCodehttpStatusattemptSeq
creation_result_viewed 成功生成的结果首次渲染可见(不是任务完成,是用户真的看到了) creationTaskIdwaitedMs(submit→首次可见,用户感知等待)、resultCount
creation_failure_viewed 失败态首次渲染给用户(与 creation_submit_failed 区分:后者是提交就没成,此者是排队/执行后才失败) creationTaskIdfailureReasonwaitedMs
creation_cancelled 用户主动取消且取消接口成功响应后 creationTaskIdwaitedMsstagequeued / running
creation_draft_created 「一键创建社区草稿」成功响应后 creationTaskIdselectedCount(选入草稿的产物数)

枚举值定义(客户端编译期锁死,离线巡检;ingest 只校验键不校验值——EventDictionary.java:26-33 的既有设计):

属性 枚举 / 口径
regenerateFrom none(首次生成)/ failure(对失败任务重试)/ dissatisfaction(对已成功结果不满意再生成)——三值必须分开,否则「重新生成率」会把「系统不可靠」与「产品不满意」两个完全不同的问题混成一个数
failureReason 沿用既有族 + 新增 quota_exceededmodel_unavailablecontent_rejected(内容安全拒绝)、generation_timeoutcancelled
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 而实际条数为 41page_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_tasksqueued_atfirst_attempt_atfinished_atstatusattempt_count 服务端事实表 creationTaskId
5 结果触达 creation_result_viewed / creation_failure_viewed / creation_cancelled 客户端 creationTaskId
6 建草稿 → 发帖 creation_draft_createdpost_publish_succeededcreationTaskId 非空) 客户端 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_succeededpost_draft_saved 的现行白名单无任何标识帖子来源的属性(分别为 durationMs/mediaCount/topicCount/textLengthBucket/fromDrafttrigger/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/v2server_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_atP50/P95 同上;重试任务只取首次尝试,否则重试等待会被计成排队
端到端感知等待(辅助) —— —— 客户端 creation_result_viewed.waitedMs 的 P50/P95。与「排队 + 执行」之差即客户端轮询/渲染开销——这个差值是客户端性能债的直接读数
结果触达率 creation_result_viewed 的去重 creationTaskId status='succeeded' 的任务数 双源 衡量「生成完了但用户没看到」。这是 AI 创作特有的浪费指标:算力已花但价值未交付。触达率低 ⇒ 需要完成通知
生成 → 发帖转化率M4 核心) 24h 内 post_publish_succeededprops->>'creationTaskId' 非空 的去重 userId creation_result_viewed 的去重 userId 客户端 分母用结果触达而非提交或成功——没看到结果的人不可能发帖,用提交做分母会把系统故障算进产品转化的账
草稿 → 发帖转化率(辅助) 同上分子 creation_draft_created 去重 userId 客户端 拆解上一指标:区分「不想发」与「建了草稿卡在发布环节」
重新生成率 creation_submit_succeededregenerateFrom <> 'none' 全部 creation_submit_succeeded 客户端 必须按 regenerateFrom 值分开发布两个数failure 档升高 = 系统不可靠(应归因到生成成功率);dissatisfaction 档升高 = 产品质量问题。合并成一个数会让两种截然不同的病症互相掩盖
配额触顶率 creation_submit_failedfailureReason='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;

-- 对账 3AI 来源帖的 creationTaskId 必须指向本人任务(期望恒为空集,命中即越权或口径错)
SELECT e.event_id
FROM platform.product_events e
JOIN creation.generation_tasks t ON (e.props->>'creationTaskId')::uuid = t.id
WHERE e.event_name = 'post_publish_succeeded' AND e.user_id IS NOT NULL
  AND t.user_id <> e.user_id;

-- 对账 4:红线巡检——creation 域事件不得出现 prompt/URL 形态的值(期望恒为空集)
SELECT event_id, event_name, props
FROM platform.product_events
WHERE event_name LIKE 'creation_%'
  AND (props::text ~* '(https?://|/[a-z0-9_-]+\.(jpg|jpeg|png|webp|heic))'
       OR length(props::text) > 512);   -- 512 字节以上说明混入了自由文本

-- 对账 5:字典空转巡检(§1.2)——列出白名单内但本周零上报的事件
-- (期望只剩已知的 8 条空转项;出现新成员即挂接回归)
SELECT e.name FROM (VALUES ('creation_started'),('creation_submit_succeeded'),
  ('creation_result_viewed'),('creation_draft_created'),('experiment_exposed')) AS e(name)
WHERE NOT EXISTS (SELECT 1 FROM platform.product_events p
                  WHERE p.event_name = e.name AND p.server_ts >= :from);

未取证:以上 SQL 未在真实数据上执行过(无数据,§0.2),且 creation.generation_tasks 的列名以 §4.2 的要求为准、尚未由后端契约确认;后端定稿后需回改列名并试跑一次。

6. A/B 实验设计

6.1 形态裁定:M4 首个「实验」应是 A/A 基建验证,真实 A/B 冻结设计待流量

iteration-3/29:68 的路线是「M4 可启动首个实验」。本角色按 §1.4 取证后修订这个判断,两条理由:

  1. 前置三项代码零实现(分流哈希、Flutter 曝光封装、feature flag)。这不是「补监控即可」的距离,是从零写三个组件。
  2. 样本量不可达。§6.3 的真实 A/B 需约 1,560 名到达生成结果的用户;当前真实用户数为 0(§0.2 取证)。按 M2 §4.3 的止损纪律「若按届时 DAU 换算实验需运行超过 8 周,判定该实验不可行」,该实验现在就判不可行。

因此 M4 的实验交付物拆成两件

  • 6.2 A/A 基建验证空跑——M4 内可执行、零产品风险、极小样本即有价值。
  • 6.3 首个真实 A/B 设计书——设计冻结、判定线预登记(防 HARKing),放行条件挂在流量上,不挂日期。

6.2 A/A 基建验证实验(M4 交付,非产品实验)

目的:在没有产品风险的前提下验证实验基建本身。A/A 的全部价值在于「当两组体验完全相同时,指标差异应当不显著」——若显著,说明基建有缺陷(分流不均、曝光漏报、指标管道错配),而这类缺陷在真实 A/B 中会被误读成产品结论。

设计
假设 无产品假设。原假设即「两组无差异」,期望不拒绝
变体 Control 与 Variant 完全相同的现有体验(代码路径一致,只走一遍分流与曝光上报)
触点 AI 创作结果页(creation_result 页曝光时上报 experiment_exposed
分流单位 userId(AI 创作需登录消耗配额,故无需 anonymousId 分流——这顺带让前置 #4 的「登录前分流归并规则」不成为本实验的前置
分流实现 hash(userId + experimentSalt) % 100experimentKey='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/Bfeature 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:103props = experimentKeyvariant),M4 首次实际上报。三条纪律:

  1. 在用户实际到达实验触点、变体 UI 已渲染时上报,不在分配时上报。分配时上报会把「被分到但从未看到」的用户算进分母,系统性稀释效应量(iteration-3/06:145 已定此口径)。
  2. 每个 (userId, experimentKey) 每会话至多一条,跨会话可重复(用于验证 variant 恒定性)。分析时按 userId 去重取首次曝光。
  3. experimentKey 取自实验注册表枚举,不接受自由字符串;variantcontrol / variant_a 等固定枚举。注册表是 §附 工单的交付物之一。

7. 白名单变更清单与同步点

7.1 变更汇总

类别 数量 明细
新增事件 8 creation_startedcreation_model_selectedcreation_submit_succeededcreation_submit_failedcreation_result_viewedcreation_failure_viewedcreation_cancelledcreation_draft_created
既有事件加属性 2 post_publish_succeeded +creationTaskIdpost_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: 1EventResult.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.darttest_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:120device-verification.md:144feature-checklist.md:221 把「逐条 rejected / 不影响客户端」改为「整批 400 + 永久丢弃,桌面采集能力为零」(§3.1)
14 常设文档北极星时限 device-verification.md:40iteration-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 的 #5creation schema 的 V6 迁移中确保 §4.2 要求的列齐备(queued_at/first_attempt_at/attempt_count/failure_reason 等)+ 终态行保留 ≥90 天纪律;与生成任务契约同批冻结 modelKey/styleKey 枚举。
  5. 后端(依赖 2 的 #2#3EventDictionary 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 +creationResultpost_analytics.dart 透传 creationTaskId
  9. 后端 + FlutterA/B 前置补齐,依赖 2 的 #8):① 分流组件 hash(userId + experimentSalt) % 100 + 实验注册表;② experiment_exposure.dart 曝光强类型封装;③ feature flag 开关机制(含 5 分钟内回滚能力)。三者均为零代码起步,不得按「已就绪」排期
  10. 本角色(依赖 9:样本量规则 + 实验设计模板成文(A/B 前置 #3/#6,此前两迭代标绿但无文档);A/A 基建验证实验的检查项清单与 SRM 巡检脚本。
  11. 本角色(M4 全程):每周一北极星/护栏读数复核;§5 对账 SQL 1~5 入巡检;字典空转清单月度巡检。
  12. 文档(收官前)ADR-023 落地;§7.2 同步点 12~14 的三处常设文档纠正。