- 配置页实验徽章/实验详情入口,实验期禁用常规发布 - 创建实验弹窗(复用配置表单+流量/白名单),实验管理弹窗(分组指标/放量/暂停/结束) - 契约新增 5 个实验端点与 lucky-gift:experiment 权限,gen:api 再生成 - vite.config.js 移除已删除 money/index.html 的构建入口,修复 build 阻断 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
129 lines
5.2 KiB
JavaScript
129 lines
5.2 KiB
JavaScript
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;
|
||
}
|