192 lines
5.8 KiB
TypeScript
192 lines
5.8 KiB
TypeScript
import cloud from '@lafjs/cloud'
|
|
const db = cloud.database();
|
|
|
|
const COLLECTION = "ab_config";
|
|
const DEFAULT_ADMIN_TOKEN = "abtest-admin-token";
|
|
|
|
export default async function (ctx: FunctionContext) {
|
|
const action = ctx.body?.action || ctx.query?.action || "list";
|
|
const adminToken = ctx.body?.adminToken || ctx.query?.adminToken;
|
|
if (!checkAdminToken(adminToken)) {
|
|
return { code: 0, data: null, msg: "unauthorized" };
|
|
}
|
|
|
|
switch (action) {
|
|
case "list":
|
|
return await listConfigs();
|
|
case "save":
|
|
return await saveConfig(ctx.body?.config);
|
|
case "disable":
|
|
return await setEnabled(ctx.body?.experimentId, false);
|
|
case "enable":
|
|
return await setEnabled(ctx.body?.experimentId, true);
|
|
case "delete":
|
|
return await deleteConfig(ctx.body?.experimentId);
|
|
default:
|
|
return { code: 0, data: null, msg: "invalid action" };
|
|
}
|
|
}
|
|
|
|
function checkAdminToken(adminToken: string) {
|
|
const env = (globalThis as any).process?.env || {};
|
|
const token = env.AB_ADMIN_TOKEN || DEFAULT_ADMIN_TOKEN;
|
|
return !!adminToken && adminToken === token;
|
|
}
|
|
|
|
async function listConfigs() {
|
|
const ret = await db.collection(COLLECTION).get();
|
|
return { code: 1, data: ret.data || [], msg: "ok" };
|
|
}
|
|
|
|
async function saveConfig(config: any) {
|
|
if (typeof config == "string") {
|
|
try {
|
|
config = JSON.parse(config);
|
|
} catch {
|
|
return { code: 0, data: null, msg: "config json invalid" };
|
|
}
|
|
}
|
|
if (Array.isArray(config)) {
|
|
const layerSet = new Set<string>();
|
|
const experimentSet = new Set<string>();
|
|
for (let i = 0; i < config.length; i++) {
|
|
const error = validateConfig(config[i]);
|
|
if (error) {
|
|
return { code: 0, data: null, msg: `config[${i}]: ${error}` };
|
|
}
|
|
const layer = String(config[i].layer);
|
|
const experimentId = String(config[i].experimentId);
|
|
if (layerSet.has(layer)) {
|
|
return { code: 0, data: null, msg: `layer duplicated: ${layer}` };
|
|
}
|
|
if (experimentSet.has(experimentId)) {
|
|
return { code: 0, data: null, msg: `experimentId duplicated: ${experimentId}` };
|
|
}
|
|
layerSet.add(layer);
|
|
experimentSet.add(experimentId);
|
|
}
|
|
|
|
// An array is the complete desired configuration, not a partial upsert.
|
|
// Capture the previous list before saving, then remove records omitted
|
|
// from the submitted array. An empty array therefore clears all configs.
|
|
const oldRet = await db.collection(COLLECTION).get();
|
|
const oldList = Array.isArray(oldRet.data) ? oldRet.data : [];
|
|
const list = [];
|
|
for (let i = 0; i < config.length; i++) {
|
|
const ret = await saveOneConfig(config[i]);
|
|
if (ret.code !== 1) {
|
|
return ret;
|
|
}
|
|
list.push(ret.data);
|
|
}
|
|
|
|
for (const oldConfig of oldList) {
|
|
const experimentId = oldConfig?.experimentId;
|
|
if (experimentId && !experimentSet.has(String(experimentId))) {
|
|
const ret = await deleteConfig(String(experimentId));
|
|
if (ret.code !== 1) {
|
|
return ret;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { code: 1, data: list, msg: "saved" };
|
|
}
|
|
return await saveOneConfig(config);
|
|
}
|
|
|
|
async function saveOneConfig(config: any) {
|
|
const error = validateConfig(config);
|
|
if (error) {
|
|
return { code: 0, data: null, msg: error };
|
|
}
|
|
|
|
const now = Date.now();
|
|
const data = {
|
|
experimentId: config.experimentId,
|
|
layer: String(config.layer || ""),
|
|
name: config.name || config.experimentId,
|
|
enabled: config.enabled !== false,
|
|
resetAssignments: config.resetAssignments === true,
|
|
startTime: Number(config.startTime || 0),
|
|
endTime: Number(config.endTime || 0),
|
|
variants: normalizeVariants(config.variants),
|
|
updatedAt: now,
|
|
};
|
|
|
|
const old = await db.collection(COLLECTION).where({ experimentId: data.experimentId }).getOne();
|
|
if (old.data) {
|
|
await db.collection(COLLECTION).where({ _id: old.data._id }).update(data);
|
|
return { code: 1, data: data, msg: "updated" };
|
|
}
|
|
|
|
await db.collection(COLLECTION).add({ ...data, createdAt: now });
|
|
return { code: 1, data: data, msg: "created" };
|
|
}
|
|
|
|
async function setEnabled(experimentId: string, enabled: boolean) {
|
|
if (!experimentId) {
|
|
return { code: 0, data: null, msg: "experimentId required" };
|
|
}
|
|
const ret = await db.collection(COLLECTION).where({ experimentId: experimentId }).update({
|
|
enabled: enabled,
|
|
updatedAt: Date.now(),
|
|
});
|
|
return { code: 1, data: ret, msg: "ok" };
|
|
}
|
|
|
|
async function deleteConfig(experimentId: string) {
|
|
if (!experimentId) {
|
|
return { code: 0, data: null, msg: "experimentId required" };
|
|
}
|
|
const ret = await db.collection(COLLECTION).where({ experimentId: experimentId }).remove({ multi: true });
|
|
return { code: 1, data: ret, msg: "ok" };
|
|
}
|
|
|
|
function validateConfig(config: any) {
|
|
if (!config) {
|
|
return "config required";
|
|
}
|
|
if (!config.experimentId) {
|
|
return "experimentId required";
|
|
}
|
|
if (!config.layer) {
|
|
return "layer required";
|
|
}
|
|
if (!Array.isArray(config.variants) || config.variants.length < 2) {
|
|
return "variants length must be >= 2";
|
|
}
|
|
|
|
let total = 0;
|
|
const groups = {};
|
|
for (let i = 0; i < config.variants.length; i++) {
|
|
const variant = config.variants[i];
|
|
if (!variant.group) {
|
|
return "variant.group required";
|
|
}
|
|
if (groups[variant.group]) {
|
|
return "variant.group duplicated";
|
|
}
|
|
groups[variant.group] = true;
|
|
const ratio = Number(variant.ratio);
|
|
if (!(ratio > 0)) {
|
|
return "variant.ratio must be positive";
|
|
}
|
|
total += ratio;
|
|
}
|
|
if (total <= 0) {
|
|
return "ratio total must be positive";
|
|
}
|
|
if (config.startTime && config.endTime && Number(config.endTime) <= Number(config.startTime)) {
|
|
return "endTime must be greater than startTime";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function normalizeVariants(variants: any[]) {
|
|
return variants.map(variant => ({
|
|
group: variant.group,
|
|
ratio: Number(variant.ratio),
|
|
}));
|
|
}
|