更新注册事件以及abtest后端
This commit is contained in:
parent
1363e62101
commit
6351520e35
|
|
@ -516,6 +516,11 @@ export default class GameManager extends cc.Component {
|
|||
// this.newReadData();
|
||||
}
|
||||
|
||||
if (data.data.isFirst === true) {
|
||||
console.log("服务器中严格的新用户,发送注册事件和公共属性");
|
||||
this.setFirstInfo();
|
||||
}
|
||||
|
||||
if (data.data.forcedUpdate === true
|
||||
|| cc.fx.GameConfig.GM_INFO.wucai_type === 1) {
|
||||
const healthAmount = Number(data.data.healthAmount);
|
||||
|
|
@ -830,16 +835,9 @@ export default class GameManager extends cc.Component {
|
|||
getUserDataToServer(data) {
|
||||
const timestamp = Date.now();
|
||||
if (data) {
|
||||
if (data.isFirst == true) {
|
||||
if (data.isFirst === true) {
|
||||
cc.fx.GameConfig.GM_INFO.first = true;
|
||||
console.log("服务器中严格的新用户", data.isFirst, "发送注册事件和公共属性");
|
||||
// 新用户,走一遍 track注册事件
|
||||
this.setFirstInfo();
|
||||
}
|
||||
|
||||
|
||||
//无论是不是新用户,以防新用户第一次退出了 没有isFirst但是没发送setonce注册,统一走一遍
|
||||
|
||||
this.load3 = true;
|
||||
this.load4 = true;
|
||||
if (data.levelAmount == null || data.levelAmount == undefined) {
|
||||
|
|
|
|||
|
|
@ -348,7 +348,7 @@ export class GameConfig {
|
|||
vibrateOpen: true, //震动
|
||||
coinnum: 0, //每局的金币数
|
||||
paid_user: false, //是否是付费用户
|
||||
version: "1.0.34", //版本号
|
||||
version: "1.0.35", //版本号
|
||||
shushu_DistinctId: "", //数数访客ID
|
||||
shushu_AccountId: "", //数数账号ID
|
||||
uid: "", //用户和后端唯一id
|
||||
|
|
|
|||
|
|
@ -71,9 +71,10 @@ async function saveConfig(config: any) {
|
|||
// 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 oldByLayer = pickLatestConfigByLayer(oldList);
|
||||
const list = [];
|
||||
for (let i = 0; i < config.length; i++) {
|
||||
const ret = await saveOneConfig(config[i]);
|
||||
const ret = await saveOneConfig(config[i], oldByLayer[String(config[i].layer)] || null);
|
||||
if (ret.code !== 1) {
|
||||
return ret;
|
||||
}
|
||||
|
|
@ -95,19 +96,25 @@ async function saveConfig(config: any) {
|
|||
return await saveOneConfig(config);
|
||||
}
|
||||
|
||||
async function saveOneConfig(config: any) {
|
||||
async function saveOneConfig(config: any, previousConfig?: any) {
|
||||
const error = validateConfig(config);
|
||||
if (error) {
|
||||
return { code: 0, data: null, msg: error };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const layer = String(config.layer || "");
|
||||
if (previousConfig === undefined) {
|
||||
const previous = await db.collection(COLLECTION).where({ layer: layer }).getOne();
|
||||
previousConfig = previous.data || null;
|
||||
}
|
||||
const data = {
|
||||
experimentId: config.experimentId,
|
||||
layer: String(config.layer || ""),
|
||||
layer: layer,
|
||||
name: config.name || config.experimentId,
|
||||
enabled: config.enabled !== false,
|
||||
resetAssignments: config.resetAssignments === true,
|
||||
resetVersion: getResetVersion(config, previousConfig, now),
|
||||
startTime: Number(config.startTime || 0),
|
||||
endTime: Number(config.endTime || 0),
|
||||
variants: normalizeVariants(config.variants),
|
||||
|
|
@ -128,10 +135,14 @@ 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({
|
||||
const data: any = {
|
||||
enabled: enabled,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
};
|
||||
if (!enabled) {
|
||||
data.resetVersion = 0;
|
||||
}
|
||||
const ret = await db.collection(COLLECTION).where({ experimentId: experimentId }).update(data);
|
||||
return { code: 1, data: ret, msg: "ok" };
|
||||
}
|
||||
|
||||
|
|
@ -161,13 +172,17 @@ function validateConfig(config: any) {
|
|||
const groups = {};
|
||||
for (let i = 0; i < config.variants.length; i++) {
|
||||
const variant = config.variants[i];
|
||||
if (!variant.group) {
|
||||
const group = String(variant.group || "").trim();
|
||||
if (!group) {
|
||||
return "variant.group required";
|
||||
}
|
||||
if (groups[variant.group]) {
|
||||
if (group.toLowerCase() === "default") {
|
||||
return "variant.group Default is reserved";
|
||||
}
|
||||
if (groups[group]) {
|
||||
return "variant.group duplicated";
|
||||
}
|
||||
groups[variant.group] = true;
|
||||
groups[group] = true;
|
||||
const ratio = Number(variant.ratio);
|
||||
if (!(ratio > 0)) {
|
||||
return "variant.ratio must be positive";
|
||||
|
|
@ -185,7 +200,36 @@ function validateConfig(config: any) {
|
|||
|
||||
function normalizeVariants(variants: any[]) {
|
||||
return variants.map(variant => ({
|
||||
group: variant.group,
|
||||
group: String(variant.group).trim(),
|
||||
ratio: Number(variant.ratio),
|
||||
}));
|
||||
}
|
||||
|
||||
function pickLatestConfigByLayer(configs: any[]) {
|
||||
const byLayer = {};
|
||||
for (const config of configs) {
|
||||
const layer = String(config?.layer || "");
|
||||
const old = byLayer[layer];
|
||||
if (layer && (!old || Number(config.updatedAt || config.createdAt || 0) >= Number(old.updatedAt || old.createdAt || 0))) {
|
||||
byLayer[layer] = config;
|
||||
}
|
||||
}
|
||||
return byLayer;
|
||||
}
|
||||
|
||||
function getResetVersion(config: any, previousConfig: any, now: number) {
|
||||
const previousVersion = Number(previousConfig?.resetVersion || 0);
|
||||
if (config.enabled === false) {
|
||||
return 0;
|
||||
}
|
||||
if (config.resetAssignments !== true) {
|
||||
return previousVersion;
|
||||
}
|
||||
|
||||
const hasPreviousVersion = previousConfig
|
||||
&& Object.prototype.hasOwnProperty.call(previousConfig, "resetVersion");
|
||||
if (!previousConfig || previousConfig.resetAssignments !== true || !hasPreviousVersion) {
|
||||
return Math.max(now, previousVersion + 1);
|
||||
}
|
||||
return previousVersion;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ const query = new Ip2region();
|
|||
const canIos = false;
|
||||
const version = "9.9.99";
|
||||
const AB_CONFIG_COLLECTION = "ab_config";
|
||||
const MANAGED_AB_LAYERS = ["1", "2", "3"];
|
||||
const DEFAULT_AB_GROUP = "Default";
|
||||
export default async function (ctx: FunctionContext) {
|
||||
const body = ctx.body || {};
|
||||
let code = body.code;
|
||||
|
|
@ -56,10 +58,13 @@ export default async function (ctx: FunctionContext) {
|
|||
return { code: 0, data: "账号封禁", msg: "账号封禁" };
|
||||
}
|
||||
let token = await stringToHash(Date.now() + openid);
|
||||
let abTests = {};
|
||||
let abTests: any = null;
|
||||
let shouldPersistABTests = false;
|
||||
let wucaiPreview = null;
|
||||
if (!userData) {
|
||||
abTests = await getActiveABAssignments(openid, null);
|
||||
if (dbname === "users") {
|
||||
abTests = await getActiveABAssignments(openid, null, true);
|
||||
}
|
||||
let migratedUser = null;
|
||||
if (from === "wucai") {
|
||||
const source = await fetchWucaiUser(unionid);
|
||||
|
|
@ -113,7 +118,7 @@ export default async function (ctx: FunctionContext) {
|
|||
headArr: migratedUser?.headArr ?? [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17],
|
||||
catArr: migratedUser?.catArr ?? [1, 2, 3],
|
||||
token: token,
|
||||
abTests: abTests
|
||||
...(dbname === "users" ? { abTests: abTests } : {})
|
||||
});
|
||||
} else {
|
||||
const migrationUpdate: any = {};
|
||||
|
|
@ -162,7 +167,10 @@ export default async function (ctx: FunctionContext) {
|
|||
}
|
||||
}
|
||||
|
||||
abTests = normalizeABTests(userData.abTests);
|
||||
if (dbname === "users") {
|
||||
abTests = await getActiveABAssignments(openid, userData.abTests, false);
|
||||
shouldPersistABTests = JSON.stringify(userData.abTests) !== JSON.stringify(abTests);
|
||||
}
|
||||
let taskTime = userData.taskTime || 0;
|
||||
let task = userData.task;
|
||||
if (task) {
|
||||
|
|
@ -219,6 +227,16 @@ export default async function (ctx: FunctionContext) {
|
|||
await db.collection(dbname)
|
||||
.where({ _id: userData._id })
|
||||
.update(loginUpdate);
|
||||
|
||||
if (shouldPersistABTests) {
|
||||
try {
|
||||
await db.collection(dbname)
|
||||
.where({ _id: userData._id })
|
||||
.update({ abTests: abTests });
|
||||
} catch (e) {
|
||||
console.log("AB测试分组写回失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
await delayExecution(0.1);
|
||||
userData = await getUserData(dbname, openid);
|
||||
|
|
@ -251,7 +269,9 @@ export default async function (ctx: FunctionContext) {
|
|||
userData.shareLv = [];
|
||||
}
|
||||
userData.careerAmount = userData.careerAmount || 0;
|
||||
userData.abTests = normalizeABTests(abTests);
|
||||
if (dbname === "users") {
|
||||
userData.abTests = abTests;
|
||||
}
|
||||
let rebornGiftTime = userData?.rebornGiftTime || 0;
|
||||
let times = getTodayStartTimestamp();
|
||||
if (rebornGiftTime < times) {
|
||||
|
|
@ -532,56 +552,108 @@ async function sign(outTradeNo, nonceStr, timestamp) {
|
|||
return authorization;
|
||||
}
|
||||
|
||||
async function getActiveABAssignments(openid: string, oldAbTests: any) {
|
||||
let abTests = normalizeABTests(oldAbTests);
|
||||
async function getActiveABAssignments(openid: string, oldAbTests: any, isNewUser: boolean) {
|
||||
const now = Date.now();
|
||||
const abTests = normalizeABTests(oldAbTests, now);
|
||||
let configs = [];
|
||||
try {
|
||||
let ret = await db.collection(AB_CONFIG_COLLECTION).get();
|
||||
// console.log("AB config count:", ret.data?.length || 0, ret.data);
|
||||
const ret = await db.collection(AB_CONFIG_COLLECTION).get();
|
||||
configs = pickConfigByLayer(ret.data || []);
|
||||
} catch (e) {
|
||||
// console.log("AB配置读取失败", e);
|
||||
return abTests;
|
||||
return ensureManagedABLayers(abTests, now);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const nextAbTests: any = {};
|
||||
for (let i = 0; i < configs.length; i++) {
|
||||
const experiment = configs[i];
|
||||
if (!isValidExperiment(experiment)) {
|
||||
const configByLayer: any = {};
|
||||
for (const config of configs) {
|
||||
configByLayer[getABLayerKey(config.layer)] = config;
|
||||
}
|
||||
|
||||
const nextAbTests: any = { ...abTests };
|
||||
for (const layer of MANAGED_AB_LAYERS) {
|
||||
const layerKey = getABLayerKey(layer);
|
||||
const experiment = configByLayer[layerKey];
|
||||
const oldAssignment = abTests[layerKey];
|
||||
|
||||
if (!experiment) {
|
||||
nextAbTests[layerKey] = buildDefaultABAssignment(layer, null, oldAssignment, now, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
const resetVersion = getABResetVersion(experiment.resetVersion);
|
||||
if (experiment.enabled === false) {
|
||||
nextAbTests[layerKey] = buildDefaultABAssignment(
|
||||
layer,
|
||||
experiment,
|
||||
oldAssignment,
|
||||
now,
|
||||
resetVersion
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const layerKey = getABLayerKey(experiment.layer);
|
||||
if (abTests[layerKey] && experiment.resetAssignments !== true) {
|
||||
|
||||
const shouldReset = experiment.resetAssignments === true
|
||||
&& resetVersion > 0
|
||||
&& getABResetVersion(oldAssignment?.appliedResetVersion) !== resetVersion;
|
||||
if (isNewUser || shouldReset) {
|
||||
const variant = pickABVariant(openid, experiment);
|
||||
nextAbTests[layerKey] = variant
|
||||
? buildABAssignment(layer, experiment, variant.group, now, resetVersion)
|
||||
: buildDefaultABAssignment(layer, experiment, oldAssignment, now, resetVersion);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (oldAssignment) {
|
||||
nextAbTests[layerKey] = {
|
||||
...abTests[layerKey],
|
||||
experimentId: experiment.experimentId,
|
||||
layer: String(experiment.layer),
|
||||
name: experiment.name || abTests[layerKey].name || "",
|
||||
enabled: experiment.enabled !== false,
|
||||
...oldAssignment,
|
||||
experimentId: getABText(experiment.experimentId),
|
||||
layer: layer,
|
||||
name: getABText(experiment.name),
|
||||
enabled: true,
|
||||
};
|
||||
continue;
|
||||
} else {
|
||||
nextAbTests[layerKey] = buildDefaultABAssignment(layer, experiment, null, now, 0);
|
||||
}
|
||||
const variant = pickABVariant(openid, experiment);
|
||||
if (!variant) {
|
||||
continue;
|
||||
}
|
||||
nextAbTests[layerKey] = {
|
||||
experimentId: experiment.experimentId,
|
||||
layer: String(experiment.layer),
|
||||
name: experiment.name || "",
|
||||
enabled: experiment.enabled !== false,
|
||||
group: variant.group,
|
||||
assignedAt: now,
|
||||
};
|
||||
}
|
||||
return nextAbTests;
|
||||
}
|
||||
|
||||
function normalizeABTests(abTests: any) {
|
||||
function ensureManagedABLayers(abTests: any, now: number) {
|
||||
const completed: any = { ...abTests };
|
||||
for (const layer of MANAGED_AB_LAYERS) {
|
||||
const layerKey = getABLayerKey(layer);
|
||||
if (!completed[layerKey]) {
|
||||
completed[layerKey] = buildDefaultABAssignment(layer, null, null, now, 0);
|
||||
}
|
||||
}
|
||||
return completed;
|
||||
}
|
||||
|
||||
function buildDefaultABAssignment(layer: string, experiment: any, oldAssignment: any, now: number, resetVersion: number) {
|
||||
const wasDefault = normalizeABGroup(oldAssignment?.group) === DEFAULT_AB_GROUP;
|
||||
return {
|
||||
experimentId: experiment ? getABText(experiment.experimentId) : null,
|
||||
layer: layer,
|
||||
name: experiment ? getABText(experiment.name) : null,
|
||||
enabled: !!experiment && experiment.enabled !== false,
|
||||
group: DEFAULT_AB_GROUP,
|
||||
assignedAt: wasDefault ? oldAssignment.assignedAt : now,
|
||||
appliedResetVersion: resetVersion,
|
||||
};
|
||||
}
|
||||
|
||||
function buildABAssignment(layer: string, experiment: any, group: any, assignedAt: number, resetVersion: number) {
|
||||
return {
|
||||
experimentId: getABText(experiment.experimentId),
|
||||
layer: layer,
|
||||
name: getABText(experiment.name),
|
||||
enabled: true,
|
||||
group: normalizeABGroup(group),
|
||||
assignedAt: assignedAt,
|
||||
appliedResetVersion: resetVersion,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeABTests(abTests: any, now = Date.now()) {
|
||||
if (!abTests) {
|
||||
return {};
|
||||
}
|
||||
|
|
@ -592,24 +664,49 @@ function normalizeABTests(abTests: any) {
|
|||
return {};
|
||||
}
|
||||
}
|
||||
if (typeof abTests !== "object" || Array.isArray(abTests)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const normalized: any = {};
|
||||
for (const key in abTests) {
|
||||
const item = abTests[key];
|
||||
if (!item || !item.layer || key !== getABLayerKey(item.layer)) {
|
||||
const layer = String(item?.layer || "").trim();
|
||||
const group = normalizeABGroup(item?.group);
|
||||
if (!layer || !group || key !== getABLayerKey(layer)) {
|
||||
continue;
|
||||
}
|
||||
const assignedAt = Number(item.assignedAt);
|
||||
normalized[key] = {
|
||||
experimentId: item.experimentId || "",
|
||||
layer: String(item.layer),
|
||||
name: item.name || "",
|
||||
experimentId: getABText(item.experimentId),
|
||||
layer: layer,
|
||||
name: getABText(item.name),
|
||||
enabled: item.enabled !== false,
|
||||
group: item.group,
|
||||
assignedAt: item.assignedAt || Date.now(),
|
||||
group: group,
|
||||
assignedAt: Number.isFinite(assignedAt) && assignedAt > 0 ? assignedAt : now,
|
||||
appliedResetVersion: getABResetVersion(item.appliedResetVersion),
|
||||
};
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeABGroup(group: any) {
|
||||
const normalized = String(group || "").trim();
|
||||
return normalized.toLowerCase() === "default" ? DEFAULT_AB_GROUP : normalized;
|
||||
}
|
||||
|
||||
function getABText(value: any) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function getABResetVersion(value: any) {
|
||||
const version = Number(value);
|
||||
return Number.isFinite(version) && version > 0 ? version : 0;
|
||||
}
|
||||
|
||||
function getABLayerKey(layer: any) {
|
||||
return `layer_${String(layer || "").trim()}`;
|
||||
}
|
||||
|
|
|
|||
173
server/laf-cloud/tests/ab-config-admin.test.mjs
Normal file
173
server/laf-cloud/tests/ab-config-admin.test.mjs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { registerHooks } from "node:module";
|
||||
|
||||
const functionUrl = new URL("../functions/abConfigAdmin.ts", import.meta.url);
|
||||
const state = {
|
||||
configs: [],
|
||||
nextId: 1,
|
||||
};
|
||||
|
||||
function clone(value) {
|
||||
return value == null ? value : JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function matches(document, query) {
|
||||
return Object.keys(query).every(key => document[key] === query[key]);
|
||||
}
|
||||
|
||||
function collection(name) {
|
||||
assert.equal(name, "ab_config");
|
||||
return {
|
||||
async get() {
|
||||
return { data: clone(state.configs) };
|
||||
},
|
||||
where(query) {
|
||||
return {
|
||||
async getOne() {
|
||||
return { data: clone(state.configs.find(item => matches(item, query)) || null) };
|
||||
},
|
||||
async update(payload) {
|
||||
const matched = state.configs.filter(item => matches(item, query));
|
||||
matched.forEach(item => Object.assign(item, clone(payload)));
|
||||
return { updated: matched.length };
|
||||
},
|
||||
async remove() {
|
||||
const oldLength = state.configs.length;
|
||||
state.configs = state.configs.filter(item => !matches(item, query));
|
||||
return { deleted: oldLength - state.configs.length };
|
||||
},
|
||||
};
|
||||
},
|
||||
async add(payload) {
|
||||
const document = { _id: `config-${state.nextId++}`, ...clone(payload) };
|
||||
state.configs.push(document);
|
||||
return { id: document._id };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
globalThis.__abConfigAdminCloudMock = {
|
||||
database() {
|
||||
return { collection };
|
||||
},
|
||||
};
|
||||
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier === "@lafjs/cloud") {
|
||||
return {
|
||||
url: "data:text/javascript,export default globalThis.__abConfigAdminCloudMock",
|
||||
shortCircuit: true,
|
||||
};
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
},
|
||||
});
|
||||
|
||||
const { default: abConfigAdmin } = await import(functionUrl);
|
||||
|
||||
function reset() {
|
||||
state.configs = [];
|
||||
state.nextId = 1;
|
||||
}
|
||||
|
||||
function config(extra = {}) {
|
||||
return {
|
||||
experimentId: "exp_layer_1_001",
|
||||
layer: "1",
|
||||
name: "Layer 1",
|
||||
enabled: true,
|
||||
resetAssignments: false,
|
||||
variants: [{ group: "A", ratio: 50 }, { group: "B", ratio: 50 }],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function save(items) {
|
||||
return await abConfigAdmin({
|
||||
body: {
|
||||
action: "save",
|
||||
adminToken: "abtest-admin-token",
|
||||
config: items,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test("resetVersion changes only on an enabled false-to-true transition", async () => {
|
||||
reset();
|
||||
|
||||
await save([config()]);
|
||||
assert.equal(state.configs[0].resetVersion, 0);
|
||||
|
||||
await save([config({ resetAssignments: true })]);
|
||||
const firstVersion = state.configs[0].resetVersion;
|
||||
assert.ok(firstVersion > 0);
|
||||
|
||||
await save([config({
|
||||
experimentId: "exp_layer_1_002",
|
||||
resetAssignments: true,
|
||||
variants: [{ group: "A", ratio: 80 }, { group: "B", ratio: 20 }],
|
||||
})]);
|
||||
assert.equal(state.configs.length, 1);
|
||||
assert.equal(state.configs[0].experimentId, "exp_layer_1_002");
|
||||
assert.equal(state.configs[0].resetVersion, firstVersion);
|
||||
|
||||
await save([config({ experimentId: "exp_layer_1_002", resetAssignments: false })]);
|
||||
assert.equal(state.configs[0].resetVersion, firstVersion);
|
||||
|
||||
await save([config({ experimentId: "exp_layer_1_002", resetAssignments: true })]);
|
||||
assert.ok(state.configs[0].resetVersion > firstVersion);
|
||||
});
|
||||
|
||||
test("a disabled layer does not create a reset version", async () => {
|
||||
reset();
|
||||
|
||||
await save([config({ enabled: false })]);
|
||||
await save([config({ enabled: false, resetAssignments: true })]);
|
||||
assert.equal(state.configs[0].resetVersion, 0);
|
||||
|
||||
await save([config({ enabled: true, resetAssignments: true })]);
|
||||
assert.equal(state.configs[0].resetVersion, 0);
|
||||
|
||||
await save([config({ enabled: true, resetAssignments: false })]);
|
||||
await save([config({ enabled: true, resetAssignments: true })]);
|
||||
assert.ok(state.configs[0].resetVersion > 0);
|
||||
|
||||
await save([config({ enabled: false, resetAssignments: true })]);
|
||||
assert.equal(state.configs[0].resetVersion, 0);
|
||||
|
||||
await save([config({ enabled: true, resetAssignments: true })]);
|
||||
assert.equal(state.configs[0].resetVersion, 0);
|
||||
});
|
||||
|
||||
test("an existing enabled reset without a version is initialized on publish", async () => {
|
||||
reset();
|
||||
state.configs.push({ _id: "legacy", ...config({ resetAssignments: true }) });
|
||||
|
||||
const result = await save([config({ resetAssignments: true })]);
|
||||
|
||||
assert.equal(result.code, 1);
|
||||
assert.ok(state.configs[0].resetVersion > 0);
|
||||
});
|
||||
|
||||
test("Default is a case-insensitive reserved group name", async () => {
|
||||
reset();
|
||||
|
||||
const result = await save([config({
|
||||
variants: [{ group: "DEFAULT", ratio: 50 }, { group: "B", ratio: 50 }],
|
||||
})]);
|
||||
|
||||
assert.equal(result.code, 0);
|
||||
assert.match(result.msg, /Default.*reserved/i);
|
||||
assert.equal(state.configs.length, 0);
|
||||
});
|
||||
|
||||
test("the admin page explains edge-triggered reset behavior", async () => {
|
||||
const ui = await readFile(new URL("../../xxwzUI.html", import.meta.url), "utf8");
|
||||
|
||||
assert.match(ui, /关闭→开启并发布/);
|
||||
assert.match(ui, /关闭后未登录用户不补做/);
|
||||
assert.match(ui, /Default.*系统保留/);
|
||||
});
|
||||
|
|
@ -16,6 +16,7 @@ const state = {
|
|||
source: null,
|
||||
sourceFails: false,
|
||||
sourceCalls: 0,
|
||||
failABUpdate: false,
|
||||
};
|
||||
|
||||
function clone(value) {
|
||||
|
|
@ -49,6 +50,9 @@ function collection(name) {
|
|||
return { data: clone(documents(name).find(item => matches(item, query)) || null) };
|
||||
},
|
||||
async update(payload) {
|
||||
if (state.failABUpdate && Object.keys(payload).length === 1 && payload.abTests) {
|
||||
throw new Error("abTests update failed");
|
||||
}
|
||||
const matched = documents(name).filter(item => matches(item, query));
|
||||
matched.forEach(item => Object.assign(item, clone(payload)));
|
||||
return { updated: matched.length };
|
||||
|
|
@ -134,6 +138,20 @@ function reset(users = [], source = null) {
|
|||
state.source = source;
|
||||
state.sourceFails = false;
|
||||
state.sourceCalls = 0;
|
||||
state.failABUpdate = false;
|
||||
}
|
||||
|
||||
function abConfig(layer, extra = {}) {
|
||||
return {
|
||||
experimentId: `exp_layer_${layer}_001`,
|
||||
layer: String(layer),
|
||||
name: `Layer ${layer}`,
|
||||
enabled: true,
|
||||
resetAssignments: false,
|
||||
resetVersion: 0,
|
||||
variants: [{ group: "A", ratio: 50 }, { group: "B", ratio: 50 }],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function existingUser(extra = {}) {
|
||||
|
|
@ -182,30 +200,213 @@ test("ordinary legacy login initializes wucai_type -1 without reading Wucai", as
|
|||
assert.equal("isWuCai" in state.users[0], false);
|
||||
});
|
||||
|
||||
test("new users skip a disabled layer without affecting enabled layers", async () => {
|
||||
test("new users receive all managed layers without affecting enabled assignments", async () => {
|
||||
reset();
|
||||
state.abConfigs = [
|
||||
{
|
||||
experimentId: "exp_layer_1_001",
|
||||
layer: "1",
|
||||
enabled: false,
|
||||
variants: [{ group: "A", ratio: 50 }, { group: "B", ratio: 50 }],
|
||||
},
|
||||
{
|
||||
experimentId: "exp_layer_2_001",
|
||||
layer: "2",
|
||||
enabled: true,
|
||||
variants: [{ group: "A", ratio: 50 }, { group: "B", ratio: 50 }],
|
||||
},
|
||||
abConfig(1, { enabled: false }),
|
||||
abConfig(2),
|
||||
];
|
||||
|
||||
const result = await login({ body: { code: "code-1" }, headers: {}, socket: {} });
|
||||
|
||||
assert.equal(result.code, 1);
|
||||
assert.equal(result.data.abTests.layer_1, undefined);
|
||||
assert.equal(state.users[0].abTests.layer_1, undefined);
|
||||
assert.deepEqual(Object.keys(result.data.abTests).sort(), ["layer_1", "layer_2", "layer_3"]);
|
||||
assert.equal(result.data.abTests.layer_1.experimentId, "exp_layer_1_001");
|
||||
assert.equal(result.data.abTests.layer_1.enabled, false);
|
||||
assert.equal(result.data.abTests.layer_1.group, "Default");
|
||||
assert.equal(result.data.abTests.layer_2.experimentId, "exp_layer_2_001");
|
||||
assert.match(result.data.abTests.layer_2.group, /^[AB]$/);
|
||||
assert.equal(result.data.abTests.layer_3.experimentId, null);
|
||||
assert.equal(result.data.abTests.layer_3.name, null);
|
||||
assert.equal(result.data.abTests.layer_3.enabled, false);
|
||||
assert.equal(result.data.abTests.layer_3.group, "Default");
|
||||
assert.equal(result.data.abTests.layer_3.appliedResetVersion, 0);
|
||||
assert.deepEqual(state.users[0].abTests, result.data.abTests);
|
||||
});
|
||||
|
||||
test("existing users keep groups per layer while disabled and missing layers become Default", async () => {
|
||||
reset([existingUser({
|
||||
wucai_type: -1,
|
||||
abTests: {
|
||||
layer_1: {
|
||||
experimentId: "exp_layer_1_001",
|
||||
layer: "1",
|
||||
name: "Old Layer 1",
|
||||
enabled: true,
|
||||
group: "B",
|
||||
assignedAt: 100,
|
||||
appliedResetVersion: 7,
|
||||
},
|
||||
layer_2: {
|
||||
experimentId: "exp_layer_2_001",
|
||||
layer: "2",
|
||||
name: "Old Layer 2",
|
||||
enabled: true,
|
||||
group: "A",
|
||||
assignedAt: 200,
|
||||
appliedResetVersion: 0,
|
||||
},
|
||||
layer_3: {
|
||||
experimentId: "exp_layer_3_001",
|
||||
layer: "3",
|
||||
name: "Removed Layer",
|
||||
enabled: true,
|
||||
group: "C",
|
||||
assignedAt: 250,
|
||||
appliedResetVersion: 0,
|
||||
},
|
||||
layer_4: {
|
||||
experimentId: "exp_layer_4_001",
|
||||
layer: "4",
|
||||
name: "Future Layer",
|
||||
enabled: true,
|
||||
group: "H",
|
||||
assignedAt: 300,
|
||||
appliedResetVersion: 0,
|
||||
},
|
||||
},
|
||||
})]);
|
||||
state.abConfigs = [
|
||||
abConfig(1, { experimentId: "exp_layer_1_002", name: "New Layer 1" }),
|
||||
abConfig(2, { enabled: false, resetAssignments: true, resetVersion: 101 }),
|
||||
];
|
||||
|
||||
const result = await login({ body: { code: "code-1" }, headers: {}, socket: {} });
|
||||
|
||||
assert.equal(result.code, 1);
|
||||
assert.equal(result.data.abTests.layer_1.group, "B");
|
||||
assert.equal(result.data.abTests.layer_1.experimentId, "exp_layer_1_002");
|
||||
assert.equal(result.data.abTests.layer_1.name, "New Layer 1");
|
||||
assert.equal(result.data.abTests.layer_1.assignedAt, 100);
|
||||
assert.equal(result.data.abTests.layer_1.appliedResetVersion, 7);
|
||||
assert.equal(result.data.abTests.layer_2.group, "Default");
|
||||
assert.equal(result.data.abTests.layer_2.enabled, false);
|
||||
assert.notEqual(result.data.abTests.layer_2.assignedAt, 200);
|
||||
assert.equal(result.data.abTests.layer_3.group, "Default");
|
||||
assert.equal(result.data.abTests.layer_3.experimentId, null);
|
||||
assert.notEqual(result.data.abTests.layer_3.assignedAt, 250);
|
||||
assert.equal(result.data.abTests.layer_4.group, "H");
|
||||
assert.equal(result.data.abTests.layer_4.assignedAt, 300);
|
||||
assert.deepEqual(state.users[0].abTests, result.data.abTests);
|
||||
});
|
||||
|
||||
test("an active reset version reassigns each existing user only once", async () => {
|
||||
reset([existingUser({
|
||||
wucai_type: -1,
|
||||
abTests: {
|
||||
layer_1: {
|
||||
experimentId: "exp_layer_1_001",
|
||||
layer: "1",
|
||||
name: "Layer 1",
|
||||
enabled: true,
|
||||
group: "B",
|
||||
assignedAt: 100,
|
||||
appliedResetVersion: 0,
|
||||
},
|
||||
layer_2: {
|
||||
experimentId: "exp_layer_2_001",
|
||||
layer: "2",
|
||||
name: "Layer 2",
|
||||
enabled: true,
|
||||
group: "B",
|
||||
assignedAt: 200,
|
||||
appliedResetVersion: 0,
|
||||
},
|
||||
},
|
||||
})]);
|
||||
state.abConfigs = [
|
||||
abConfig(1, {
|
||||
resetAssignments: true,
|
||||
resetVersion: 101,
|
||||
variants: [{ group: "A", ratio: 95 }, { group: "B", ratio: 5 }],
|
||||
}),
|
||||
abConfig(2),
|
||||
];
|
||||
|
||||
const first = await login({ body: { code: "code-1" }, headers: {}, socket: {} });
|
||||
const assignedAt = first.data.abTests.layer_1.assignedAt;
|
||||
const second = await login({ body: { code: "code-1" }, headers: {}, socket: {} });
|
||||
|
||||
assert.equal(first.data.abTests.layer_1.group, "A");
|
||||
assert.equal(first.data.abTests.layer_1.appliedResetVersion, 101);
|
||||
assert.notEqual(assignedAt, 100);
|
||||
assert.equal(second.data.abTests.layer_1.group, "A");
|
||||
assert.equal(second.data.abTests.layer_1.assignedAt, assignedAt);
|
||||
assert.equal(second.data.abTests.layer_1.appliedResetVersion, 101);
|
||||
assert.equal(second.data.abTests.layer_2.group, "B");
|
||||
assert.equal(second.data.abTests.layer_2.assignedAt, 200);
|
||||
});
|
||||
|
||||
test("users who miss a reset window keep their old group after the switch closes", async () => {
|
||||
reset([existingUser({
|
||||
wucai_type: -1,
|
||||
abTests: {
|
||||
layer_1: {
|
||||
experimentId: "exp_layer_1_001",
|
||||
layer: "1",
|
||||
name: "Layer 1",
|
||||
enabled: true,
|
||||
group: "B",
|
||||
assignedAt: 100,
|
||||
appliedResetVersion: 0,
|
||||
},
|
||||
},
|
||||
})]);
|
||||
state.abConfigs = [abConfig(1, {
|
||||
resetAssignments: false,
|
||||
resetVersion: 101,
|
||||
variants: [{ group: "A", ratio: 95 }, { group: "B", ratio: 5 }],
|
||||
})];
|
||||
|
||||
const result = await login({ body: { code: "code-1" }, headers: {}, socket: {} });
|
||||
|
||||
assert.equal(result.data.abTests.layer_1.group, "B");
|
||||
assert.equal(result.data.abTests.layer_1.assignedAt, 100);
|
||||
assert.equal(result.data.abTests.layer_1.appliedResetVersion, 0);
|
||||
});
|
||||
|
||||
test("Default remains sticky until an active reset version is available", async () => {
|
||||
reset([existingUser({
|
||||
wucai_type: -1,
|
||||
abTests: {
|
||||
layer_1: {
|
||||
experimentId: "exp_layer_1_001",
|
||||
layer: "1",
|
||||
name: "Layer 1",
|
||||
enabled: true,
|
||||
group: "DEFAULT",
|
||||
assignedAt: 100,
|
||||
appliedResetVersion: 0,
|
||||
},
|
||||
},
|
||||
})]);
|
||||
state.abConfigs = [abConfig(1, {
|
||||
resetAssignments: false,
|
||||
resetVersion: 101,
|
||||
variants: [{ group: "A", ratio: 95 }, { group: "B", ratio: 5 }],
|
||||
})];
|
||||
|
||||
const beforeReset = await login({ body: { code: "code-1" }, headers: {}, socket: {} });
|
||||
state.abConfigs[0].resetAssignments = true;
|
||||
const afterReset = await login({ body: { code: "code-1" }, headers: {}, socket: {} });
|
||||
|
||||
assert.equal(beforeReset.data.abTests.layer_1.group, "Default");
|
||||
assert.equal(beforeReset.data.abTests.layer_1.assignedAt, 100);
|
||||
assert.equal(afterReset.data.abTests.layer_1.group, "A");
|
||||
assert.equal(afterReset.data.abTests.layer_1.appliedResetVersion, 101);
|
||||
assert.notEqual(afterReset.data.abTests.layer_1.assignedAt, 100);
|
||||
});
|
||||
|
||||
test("broken AB data is repaired without blocking login when persistence fails", async () => {
|
||||
reset([existingUser({ wucai_type: -1, abTests: "not-json" })]);
|
||||
state.failABUpdate = true;
|
||||
|
||||
const result = await login({ body: { code: "code-1" }, headers: {}, socket: {} });
|
||||
|
||||
assert.equal(result.code, 1);
|
||||
assert.deepEqual(Object.keys(result.data.abTests).sort(), ["layer_1", "layer_2", "layer_3"]);
|
||||
assert.equal(result.data.abTests.layer_1.group, "Default");
|
||||
assert.equal(state.users[0].abTests, "not-json");
|
||||
});
|
||||
|
||||
test("ordinary usersAd login does not create migration fields", async () => {
|
||||
|
|
@ -219,6 +420,8 @@ test("ordinary usersAd login does not create migration fields", async () => {
|
|||
assert.equal(result.code, 1);
|
||||
assert.equal(state.usersAd.length, 1);
|
||||
assert.equal("wucai_type" in state.usersAd[0], false);
|
||||
assert.equal("abTests" in state.usersAd[0], false);
|
||||
assert.equal("abTests" in result.data, false);
|
||||
assert.equal(state.sourceCalls, 0);
|
||||
});
|
||||
|
||||
|
|
@ -238,6 +441,7 @@ test("new Wucai account migrates source data and returns type 0", async () => {
|
|||
});
|
||||
|
||||
assert.equal(result.code, 1);
|
||||
assert.equal(result.data.isFirst, true);
|
||||
assert.equal(state.sourceCalls, 1);
|
||||
assert.equal(state.users[0].wucai_type, 0);
|
||||
assert.equal(state.users[0].coinAmount, 100);
|
||||
|
|
|
|||
|
|
@ -55,6 +55,28 @@ test("Wucai transfer analytics report the final choice and both archive levels",
|
|||
assert.match(utils, /shushu_userSetTransferredFromWucai\(true\)/);
|
||||
});
|
||||
|
||||
test("new-user registration is independent of local cache and does not wait for analytics login", async () => {
|
||||
const gameManager = await readFile(
|
||||
new URL("../../../assets/Script/GameManager.ts", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
assert.doesNotMatch(gameManager, /pendingRegisterEvent/);
|
||||
assert.match(gameManager, /if \(data\.data\.isFirst === true\)\s*\{[\s\S]*?this\.setFirstInfo\(\);\s*\}/);
|
||||
assert.equal((gameManager.match(/this\.setFirstInfo\(\);/g) || []).length, 1);
|
||||
|
||||
const analyticsInitIndex = gameManager.indexOf("MiniGameSdk.API.shushu_Init();");
|
||||
const loginRequestIndex = gameManager.indexOf("Utils.getUserInfo(", analyticsInitIndex);
|
||||
const cacheBranchIndex = gameManager.indexOf("this.getUserDataToLocal(data.data);", loginRequestIndex);
|
||||
const registerIndex = gameManager.indexOf("this.setFirstInfo();", cacheBranchIndex);
|
||||
const loginMethodIndex = gameManager.indexOf("private loginShushuOnce()");
|
||||
assert.ok(analyticsInitIndex >= 0);
|
||||
assert.ok(loginRequestIndex > analyticsInitIndex);
|
||||
assert.ok(cacheBranchIndex > loginRequestIndex);
|
||||
assert.ok(registerIndex > cacheBranchIndex);
|
||||
assert.ok(loginMethodIndex > registerIndex);
|
||||
});
|
||||
|
||||
test("a completed progress choice shows the Home reward popup once after reboot", async () => {
|
||||
const [transfer, home, gameManager] = await Promise.all([
|
||||
readFile(new URL("../../../assets/Transfer/script/Transfer.ts", import.meta.url), "utf8"),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AB Test Config</title>
|
||||
<title>AB Test Config v1.0</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f7fb;
|
||||
|
|
@ -276,6 +276,12 @@
|
|||
padding-top: 18px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.reset-help {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.group-count {
|
||||
width: 84px;
|
||||
}
|
||||
|
|
@ -394,7 +400,7 @@
|
|||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>AB Test Config</h1>
|
||||
<h1>AB Test Config v1.0</h1>
|
||||
<div class="action-panel">
|
||||
<div class="top-actions">
|
||||
<button id="readBtn" class="secondary">读取配置</button>
|
||||
|
|
@ -409,7 +415,7 @@
|
|||
<main>
|
||||
<div class="side-panel">
|
||||
<section class="settings">
|
||||
<label for="apiUrl">abConfigAdmin 云函数: URL</label>
|
||||
<label for="apiUrl">abConfigAdmin 云函数v1.0: URL</label>
|
||||
<input id="apiUrl" placeholder="https://q6rvwvtnga.sealoshzh.site/abConfigAdmin/">
|
||||
|
||||
<label for="adminToken">Admin Token</label>
|
||||
|
|
@ -636,6 +642,7 @@
|
|||
let total = 0;
|
||||
for (const variant of item.variants) {
|
||||
if (!variant.group) return `Layer ${item.layer} 存在空组名`;
|
||||
if (variant.group.trim().toLowerCase() === "default") return `Layer ${item.layer} 的 Default 是系统保留组名`;
|
||||
if (groupSet[variant.group]) return `Layer ${item.layer} 组名 ${variant.group} 重复`;
|
||||
groupSet[variant.group] = true;
|
||||
if (!(variant.ratio > 0)) return `Layer ${item.layer} 的比例必须大于 0`;
|
||||
|
|
@ -715,12 +722,15 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="group-head">
|
||||
<div class="group-tools">
|
||||
<label class="switch" title="开启后,这个 Layer 的老用户也会按当前配置重新计算分组">
|
||||
彻底重新分组
|
||||
<input data-field="resetAssignments" data-index="${index}" type="checkbox" ${layer.resetAssignments ? "checked" : ""}>
|
||||
<span></span>
|
||||
</label>
|
||||
<div>
|
||||
<div class="group-tools">
|
||||
<label class="switch" title="关闭→开启并发布才创建新一轮;禁用 Layer 时不生效">
|
||||
彻底重新分组
|
||||
<input data-field="resetAssignments" data-index="${index}" type="checkbox" ${layer.resetAssignments ? "checked" : ""}>
|
||||
<span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="reset-help">关闭→开启并发布才会创建新一轮;每名老用户在开启期间最多处理一次。关闭后未登录用户不补做,状态不变再次发布也不会创建新一轮;禁用 Layer 时不生效。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="group-head">
|
||||
Loading…
Reference in New Issue
Block a user