import { DEFAULT_POOL_ID } from "./api.js"; // AB 实验的分组语义固定为两组:control 是创建实验时的最新启用版本,treatment 是实验发布的新版本。 export const EXPERIMENT_ARMS = ["control", "treatment"]; export const EXPERIMENT_ARM_LABELS = { control: "对照组", treatment: "实验组" }; // 默认 5% 小流量起步:放量在后端是单调的(只增不减不会把已进组用户踢回), // 起始值必须保守,创建后可以随时在实验管理里调大。 export const DEFAULT_TREATMENT_TRAFFIC_PERCENT = "5"; export function experimentDraft() { return { name: "", trafficPercent: DEFAULT_TREATMENT_TRAFFIC_PERCENT, whitelist: [], }; } // 白名单在提交前统一 trim + 去重:Autocomplete freeSolo 允许粘贴带空白的 ID, // 重复的 user_key 会让 PUT overrides 的 upsert 语义变得不可预测。 export function normalizeWhitelist(values) { const seen = new Set(); const list = []; (Array.isArray(values) ? values : []).forEach((value) => { const userKey = String(value ?? "").trim(); if (!userKey || seen.has(userKey)) { return; } seen.add(userKey); list.push(userKey); }); return list; } // 0 是合法值:表示只有白名单用户进入实验组(灰度验证),不能按“必填大于 0”校验。 export function trafficPercentError(value) { const raw = String(value ?? "").trim(); const parsed = Number(raw); if (!raw || !Number.isFinite(parsed) || parsed < 0 || parsed > 100) { return "实验组流量必须是 0-100 的百分比(0 表示仅白名单用户进入实验组)"; } return ""; } export function validateExperimentDraft(draft = {}) { const errors = []; if (!String(draft.name || "").trim()) { errors.push("请填写实验名称"); } const trafficError = trafficPercentError(draft.trafficPercent); if (trafficError) { errors.push(trafficError); } return errors; } // 组装 POST /experiments 的请求体。config 必须是 formToConfig 的完整输出: // owner 会把它原子发布为实验组版本,enabled 强制 true——停用的实验组无法开奖,实验没有意义。 export function buildExperimentCreateRequest(draft = {}, config = {}) { return { config: { ...config, enabled: true }, name: String(draft.name || "").trim(), overrides: normalizeWhitelist(draft.whitelist).map((userKey) => ({ arm: "treatment", user_key: userKey })), treatment_traffic_percent: Number(draft.trafficPercent), }; } // 展示层统一去掉尾零:5 -> "5%"、12.5 -> "12.5%",保留最多两位小数与后端浮点精度一致。 export function formatTrafficPercent(value) { const parsed = Number(value); if (!Number.isFinite(parsed)) { return "0%"; } return `${Number(parsed.toFixed(2))}%`; } // paused 也算活跃:暂停只把流量临时切回对照组,实验仍占用该奖池,常规发布仍会被 owner 以 409 拒绝。 export function isActiveExperiment(experiment) { return experiment?.status === "running" || experiment?.status === "paused"; } // 实验按 app+pool 维度独占,不携带 strategy_version:同一奖池的 fixed_v2/dynamic_v3 配置行 // 共享同一个活跃实验的发布封锁,因此关联键必须与 luckyGiftPoolKey 不同(后者含策略版本)。 export function experimentPoolFamilyKey(item = {}) { const appCode = String(item.app_code || item.appCode || "") .trim() .toLowerCase(); const poolID = String(item.pool_id || item.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID; return `${appCode}:${poolID}`; } export function activeExperimentsByPool(experiments = []) { const byPool = new Map(); (Array.isArray(experiments) ? experiments : []).forEach((experiment) => { if (!isActiveExperiment(experiment)) { return; } const key = experimentPoolFamilyKey(experiment); const current = byPool.get(key); // 后端保证同一 app+pool 最多一个活跃实验;若数据异常返回多个,保留最近开始的那个, // 保证入口指向的实验与 owner 分流实际使用的一致。 if (!current || Number(experiment.started_at_ms || 0) > Number(current.started_at_ms || 0)) { byPool.set(key, experiment); } }); return byPool; } // 列表与弹窗共用同一份状态徽章口径:running 需要带放量比例,运营扫一眼就知道实验体量。 export function experimentStatusBadge(experiment) { if (!experiment) { return null; } if (experiment.status === "running") { return { label: `实验中 ${formatTrafficPercent(experiment.treatment_traffic_percent)}`, tone: "warning" }; } if (experiment.status === "paused") { return { label: "实验暂停", tone: "warning" }; } if (experiment.status === "completed") { return { label: "实验已结束", tone: "stopped" }; } return null; } export function experimentArmStat(experiment, arm) { return (Array.isArray(experiment?.arm_stats) ? experiment.arm_stats : []).find((stat) => stat.arm === arm) || null; }