# 18 A/B 实验（for AI Agents）

> 关联代码：internal/service/abtest/（engine.go=Assign/RecordEvent/Analyze z 检验、types.go、registry.go=变量注册表）、internal/model/sprint6.go（ABExperiment/ABAssignment/ABEvent）
> 管理端页面：web/src/views/sprint6/ABExperiment.vue（/sprint6/ab）；小程序消费：utils/ab-api.ts + pages/drawbox/index
> 2026-09-13 变量注册表改造已合并 master（91f96b8）；2026-09-14 扩展至 4 个变量（8a727d5）。

====================================================================
1. 功能概述
====================================================================

服务端分流的 A/B 实验系统：管理端配置实验（变量/分桶/定向/变体），小程序经统一 assign 接口拿分组与变体参数并真实消费；转化事件回流后管理端看显著性分析。注册表当前有 4 个变量：drawbox（命运盒子文案）、homepage_popup（首页弹窗文案）、product_button（商品详情页按钮）、signin_reward（签到页奖励展示）。

====================================================================
2. 底层逻辑
====================================================================

2.1 变量注册表（registry.go，单一事实来源）
- 实验变量只能来自注册表，服务端 ValidateExperimentGroups 强校验 variable 与各变体 payload schema（类型/枚举/MaxLength/未知 key 拒绝）——管理端配置错不了格式。
- 已注册变量（key → 消费端 / 转化事件 / 参数）：
  - drawbox → pages/drawbox/index / draw：button_text、subtitle、share_title、show_footer_rule(boolean)
  - homepage_popup → pages/index/index / popup_action：title、description、button_text、show_popup(boolean)
  - product_button → pages/mall/detail/index / purchase：buy_button_text、exchange_button_text、urgency_text（空串不展示）
  - signin_reward → pages/signin-lottery/detail/index / checkin：reward_title、streak_description、show_streak_bonus(boolean)
- 参数均带 MaxLength 与默认值；变体 payload 只存与默认值不同的项。

2.2 配置存储
- ab_experiments.groups 存 Config JSON（variants + bucket_key + targeting）；历史 hash_key 字段与裸数组格式仍可解析并自动映射 bucket_key（存量兼容）。
- 状态：0 草稿 / 1 运行 / 2 暂停 / 3 停止 / 4 归档。

2.3 分桶（bucket_key 四维）
- user_id（默认）：哈希输入 "{十进制uid}:{十进制expID}"，与 v1 逐字节一致——存量未落库用户分组不漂移（有测试锁定，勿改哈希格式）。
- openid / ip / ip_region（geo 实验：同省同组，样本不独立，分析时注意口径）。

2.4 定向（targeting，分桶前过滤）
- user_type：all / new / returning（新老口径 = mp_users.is_first_draw_done）。
- regions：省份白名单（ip2region；命名与 internal/service/ad/regions.go 清单一致）。geoip.xdb_path 未配置时 IP 维度自然不命中=不入实验。
- sticky 优先于定向：已落库用户不因定向收紧而变组。
- 宁缺勿滥：IsNewUser/Region 未知（nil/空）时启用对应定向则不匹配（不入实验）。

2.5 分配与事件
- Assign：mp JWT 调 GET /mp/ab/assign?variable=（唯一正常分流入口）；sticky 落库恒按 (experiment_id, user_id) 唯一。
- RecordEvent 兜底路径只传 UserID：openid/ip 维度或带定向的实验不经事件链路分配。
- ABEvent 转化事件：分析时按 user 去重。

2.6 分析
- GET /admin/ab/:id/analyze：各变体样本数/转化数/转化率 + 对照组 vs 最优变体的差值、z 检验 p 值、95% CI、显著性结论与推荐语。

====================================================================
3. 后台操作（驾驶舱 → A/B 实验 /sprint6/ab）
====================================================================

ABExperiment.vue（权限 ab:*）：
- 筛选：变量、状态。
- 表格列：ID/变量/分组/转化值/样本量/转化率/状态/操作。
- 行操作：编辑、启用/禁用、删除。
- 「新增/编辑」弹窗：
  - 名称、实验变量（下拉，来自 GET /admin/ab/variables 注册表；选择后变体参数按 schema 动态渲染 string/number/boolean/enum 控件）
  - 状态（0 草稿-1 运行-2 暂停-3 停止-4 归档）、起止时间
  - 分桶键：user_id（默认）/ openid / ip / ip_region
  - 定向：用户类型 all/new/returning + 省份白名单多选
  - 分组卡片：首组=对照组；每组权重 %（合计须 1-100）+ payload 参数表单（只存与默认值不同的项）
- 「分析」弹窗：显著性报告。
- 「删除」实验。

3.1 后台操作逻辑备注（隐藏约束，UI 不直接体现）
- 权重校验：所有分组权重合计须 >0 且 ≤100；若 <100，未覆盖的百分比范围用户不入实验（走默认）。权重为 0 的组不会被分配到用户。
- 分桶哈希：user_id 模式输入 "{十进制uid}:{十进制expID}" 经 FNV-1a 哈希 mod 100 取桶号；格式已锁定不可改（改动导致存量用户分组漂移）。
- 粘性分配：用户首次 assign 时 FirstOrCreate 落库 ABAssignment，后续请求复用历史分组——即使定向规则收紧或实验参数调整，已分配用户不会变组。
- 同变量冲突：同一 variable 下多个运行中实验同时命中时，最小 ID 的实验优先生效，其余返回默认。避免同变量并行实验的归因混乱。
- 删除约束：运行中（status=1）的实验不可删除，需先停止或归档；删除时级联删除 ABAssignment（用户分组记录）和 ABEvent（转化事件），不可恢复。
- 分析约束：z 检验要求每组最少 30 样本；样本不足时报告标注「样本不足」不输出显著性结论。停止/归档的实验仍可分析历史数据。
- 状态行为：草稿/暂停/停止/归档状态下 assign 均返回默认值（不报错）；只有 status=1（运行中）才真实分流。

====================================================================
4. 小程序端操作
====================================================================

- 统一入口：utils/ab-api.ts abAssign(variable) → GET /api/mp/ab/assign?variable=<key>；失败/未命中静默走默认（不影响主流程）。
- 四个消费端：
  - pages/drawbox/index：drawbox——中心按钮文案/副标题/分享文案/底部规则开关
  - pages/index/index：homepage_popup——首页弹窗标题/描述/按钮文案/是否展示
  - pages/mall/detail/index：product_button——购买/兑换按钮文案与紧迫感提示
  - pages/signin-lottery/detail/index：signin_reward——签到奖励标题/连签描述/加成开关
- 转化上报：drawbox 抽奖后经 Redis Stream 异步 RecordEvent（见 08；Redis 不可用同步降级）；其余场景按 EventName 上报。

====================================================================
5. API 速查
====================================================================

GET  /api/mp/ab/assign?variable=            分流（mp JWT）
GET|POST /api/admin/ab；PUT/DELETE /api/admin/ab/:id        实验 CRUD
GET  /api/admin/ab/variables                 注册表变量 + 省份清单
GET  /api/admin/ab/:id/analyze               显著性分析

====================================================================
6. 注意事项
====================================================================

- 哈希格式已锁定：user_id 分桶输入 "{uid}:{expID}" 逐字节兼容 v1，改动会导致存量用户分组漂移——禁止「顺手优化」。
- 变体 payload 只存与默认值不同的项；前端 normalizePayload/defaultPayload 必须显式传当前变量 spec（历史坑：读错 spec 曾导致合法 payload 被全部丢弃）。
- 新实验变量必须先在 registry.go 注册（开发动作），管理端无法凭空造变量。
- 带定向/非 user_id 分桶的实验，事件链路兜底分配不生效，依赖 mp assign 正常调用——排查「样本为 0」先确认小程序端确实调了 assign。
- geo（ip_region）实验同省同组样本不独立，z 检验结论按运营参考而非统计严格。
- 实验停止/归档后 assign 返回默认，不会报错；变体下线无需发版。
