ops-center 幸运礼物 AB 实验 UI 与契约;清理 money 残留构建入口
- 配置页实验徽章/实验详情入口,实验期禁用常规发布 - 创建实验弹窗(复用配置表单+流量/白名单),实验管理弹窗(分组指标/放量/暂停/结束) - 契约新增 5 个实验端点与 lucky-gift:experiment 权限,gen:api 再生成 - vite.config.js 移除已删除 money/index.html 的构建入口,修复 build 阻断 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
c6ea362cee
commit
f8c7c50316
@ -470,6 +470,94 @@
|
|||||||
"x-permissions": ["lucky-gift:pool-debit"]
|
"x-permissions": ["lucky-gift:pool-debit"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/ops-center/lucky-gifts/experiments": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listLuckyGiftExperiments",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "lucky-gift:view",
|
||||||
|
"x-permissions": ["lucky-gift:view"]
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"operationId": "createLuckyGiftExperiment",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "lucky-gift:experiment",
|
||||||
|
"x-permissions": ["lucky-gift:experiment"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/ops-center/lucky-gifts/experiments/{experimentId}": {
|
||||||
|
"patch": {
|
||||||
|
"operationId": "updateLuckyGiftExperiment",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "experimentId",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-permission": "lucky-gift:experiment",
|
||||||
|
"x-permissions": ["lucky-gift:experiment"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/ops-center/lucky-gifts/experiments/{experimentId}/overrides": {
|
||||||
|
"put": {
|
||||||
|
"operationId": "setLuckyGiftExperimentOverrides",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "experimentId",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-permission": "lucky-gift:experiment",
|
||||||
|
"x-permissions": ["lucky-gift:experiment"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/ops-center/lucky-gifts/experiments/{experimentId}/end": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "endLuckyGiftExperiment",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "experimentId",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-permission": "lucky-gift:experiment",
|
||||||
|
"x-permissions": ["lucky-gift:experiment"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/activity/wheel/config": {
|
"/admin/activity/wheel/config": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getWheelConfig",
|
"operationId": "getWheelConfig",
|
||||||
|
|||||||
@ -9,14 +9,26 @@ import { Button } from "@/shared/ui/Button.jsx";
|
|||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { PageHead } from "@/shared/ui/PageHead.jsx";
|
import { PageHead } from "@/shared/ui/PageHead.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { adjustLuckyGiftPool, DEFAULT_POOL_ID, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
|
import {
|
||||||
|
adjustLuckyGiftPool,
|
||||||
|
createLuckyGiftExperiment,
|
||||||
|
DEFAULT_POOL_ID,
|
||||||
|
endLuckyGiftExperiment,
|
||||||
|
fetchLuckyGiftExperiments,
|
||||||
|
fetchOpsDashboard,
|
||||||
|
saveLuckyGiftConfig,
|
||||||
|
setLuckyGiftExperimentOverrides,
|
||||||
|
updateLuckyGiftExperiment,
|
||||||
|
} from "./api.js";
|
||||||
import { AppsView } from "./components/AppsView.jsx";
|
import { AppsView } from "./components/AppsView.jsx";
|
||||||
import { ConfigsView } from "./components/ConfigsView.jsx";
|
import { ConfigsView } from "./components/ConfigsView.jsx";
|
||||||
import { DrawsView } from "./components/DrawsView.jsx";
|
import { DrawsView } from "./components/DrawsView.jsx";
|
||||||
import { LuckyGiftConfigDialog } from "./components/LuckyGiftConfigDialog.jsx";
|
import { LuckyGiftConfigDialog } from "./components/LuckyGiftConfigDialog.jsx";
|
||||||
|
import { LuckyGiftExperimentManageDialog } from "./components/LuckyGiftExperimentManageDialog.jsx";
|
||||||
import { LuckyGiftPoolAdjustmentDialog } from "./components/LuckyGiftPoolAdjustmentDialog.jsx";
|
import { LuckyGiftPoolAdjustmentDialog } from "./components/LuckyGiftPoolAdjustmentDialog.jsx";
|
||||||
import { OpsShell } from "./components/OpsShell.jsx";
|
import { OpsShell } from "./components/OpsShell.jsx";
|
||||||
import { OverviewView } from "./components/OverviewView.jsx";
|
import { OverviewView } from "./components/OverviewView.jsx";
|
||||||
|
import { EXPERIMENT_ARM_LABELS, formatTrafficPercent } from "./experimentForm.js";
|
||||||
import { formatNumber } from "./format.js";
|
import { formatNumber } from "./format.js";
|
||||||
|
|
||||||
const views = [
|
const views = [
|
||||||
@ -30,11 +42,16 @@ const viewKeys = new Set(views.map((view) => view.key));
|
|||||||
const initialDashboard = {
|
const initialDashboard = {
|
||||||
apps: [],
|
apps: [],
|
||||||
appSummaries: [],
|
appSummaries: [],
|
||||||
|
experiments: [],
|
||||||
luckyGifts: [],
|
luckyGifts: [],
|
||||||
poolBalances: [],
|
poolBalances: [],
|
||||||
summary: {},
|
summary: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 实验管理权限码与 permissions.ts 常量并行开发:常量尚未生成时回退到后端约定的字面量,
|
||||||
|
// 避免部署顺序影响入口显隐;两者最终值一致("lucky-gift:experiment")。
|
||||||
|
const EXPERIMENT_PERMISSION = PERMISSIONS.luckyGiftExperiment || "lucky-gift:experiment";
|
||||||
|
|
||||||
export function initialOpsCenterView(search = globalThis.location?.search || "") {
|
export function initialOpsCenterView(search = globalThis.location?.search || "") {
|
||||||
const requested = new URLSearchParams(search).get("view") || "";
|
const requested = new URLSearchParams(search).get("view") || "";
|
||||||
return viewKeys.has(requested) ? requested : "overview";
|
return viewKeys.has(requested) ? requested : "overview";
|
||||||
@ -48,12 +65,15 @@ export function OpsCenterApp({ can = denyPermission }) {
|
|||||||
const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true });
|
const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true });
|
||||||
const [dialog, setDialog] = useState(null);
|
const [dialog, setDialog] = useState(null);
|
||||||
const [poolDialog, setPoolDialog] = useState(null);
|
const [poolDialog, setPoolDialog] = useState(null);
|
||||||
|
const [experimentManage, setExperimentManage] = useState(null);
|
||||||
const [adjustingPool, setAdjustingPool] = useState(false);
|
const [adjustingPool, setAdjustingPool] = useState(false);
|
||||||
const [savingConfig, setSavingConfig] = useState(false);
|
const [savingConfig, setSavingConfig] = useState(false);
|
||||||
|
const [experimentBusy, setExperimentBusy] = useState(false);
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const canCreditPool = can(PERMISSIONS.luckyGiftPoolCredit);
|
const canCreditPool = can(PERMISSIONS.luckyGiftPoolCredit);
|
||||||
const canDebitPool = can(PERMISSIONS.luckyGiftPoolDebit);
|
const canDebitPool = can(PERMISSIONS.luckyGiftPoolDebit);
|
||||||
const canUpdateConfig = can(PERMISSIONS.luckyGiftUpdate);
|
const canUpdateConfig = can(PERMISSIONS.luckyGiftUpdate);
|
||||||
|
const canManageExperiment = can(EXPERIMENT_PERMISSION);
|
||||||
|
|
||||||
const loadDashboard = useCallback(async () => {
|
const loadDashboard = useCallback(async () => {
|
||||||
setState((current) => ({ ...current, error: "", loading: true }));
|
setState((current) => ({ ...current, error: "", loading: true }));
|
||||||
@ -134,6 +154,11 @@ export function OpsCenterApp({ can = denyPermission }) {
|
|||||||
setDialog({ config: structuredClone(config), mode: "edit" });
|
setDialog({ config: structuredClone(config), mode: "edit" });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const openCreateExperiment = useCallback((config) => {
|
||||||
|
// 复用配置弹窗的实验模式:以当前行配置为实验组初始值,当前最新启用版本自动成为对照组。
|
||||||
|
setDialog({ config: structuredClone(config), mode: "experiment" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
const openPoolAdjustment = useCallback((pool, direction) => {
|
const openPoolAdjustment = useCallback((pool, direction) => {
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
return;
|
return;
|
||||||
@ -164,6 +189,167 @@ export function OpsCenterApp({ can = denyPermission }) {
|
|||||||
[loadDashboard, showToast],
|
[loadDashboard, showToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCreateExperiment = useCallback(
|
||||||
|
async (request) => {
|
||||||
|
setSavingConfig(true);
|
||||||
|
try {
|
||||||
|
const result = await createLuckyGiftExperiment(request.config.app_code, request);
|
||||||
|
showToast(
|
||||||
|
`已创建 AB 实验「${request.name}」:${request.config.app_code} / ${request.config.pool_id},实验组 v${
|
||||||
|
result.experiment?.treatment_rule_version || "-"
|
||||||
|
} 按 ${formatTrafficPercent(request.treatment_traffic_percent)} 放量`,
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
setDialog(null);
|
||||||
|
await loadDashboard();
|
||||||
|
} catch (error) {
|
||||||
|
// 创建失败(含活跃实验冲突 409)保持弹窗打开,运营修完参数可直接重试,不丢已填内容。
|
||||||
|
showToast(error.message || "创建 AB 实验失败", "error");
|
||||||
|
} finally {
|
||||||
|
setSavingConfig(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadDashboard, showToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshExperimentDetail = useCallback(async (experiment) => {
|
||||||
|
// 统计(with_stats=1)由 owner 按 control/treatment 聚合,代价较高,只在管理弹窗里拉取;
|
||||||
|
// dashboard 扇出保持轻量列表。按 pool 过滤后再按 experiment_id 精确匹配。
|
||||||
|
const items = await fetchLuckyGiftExperiments(experiment.app_code, {
|
||||||
|
poolId: experiment.pool_id,
|
||||||
|
withStats: true,
|
||||||
|
});
|
||||||
|
return items.find((item) => item.experiment_id === experiment.experiment_id) || null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openExperimentManage = useCallback(
|
||||||
|
(experiment) => {
|
||||||
|
setExperimentManage({ experiment, statsLoading: true });
|
||||||
|
refreshExperimentDetail(experiment)
|
||||||
|
.then((detail) => {
|
||||||
|
setExperimentManage((current) =>
|
||||||
|
current && current.experiment.experiment_id === experiment.experiment_id
|
||||||
|
? { experiment: detail || current.experiment, statsLoading: false }
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
// 统计加载失败不关闭弹窗:基础字段来自列表数据仍可管理,统计位展示“-”。
|
||||||
|
showToast(error.message || "加载实验统计失败", "error");
|
||||||
|
setExperimentManage((current) =>
|
||||||
|
current && current.experiment.experiment_id === experiment.experiment_id
|
||||||
|
? { ...current, statsLoading: false }
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[refreshExperimentDetail, showToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const runExperimentAction = useCallback(
|
||||||
|
async (action, successMessage) => {
|
||||||
|
const current = experimentManage?.experiment;
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExperimentBusy(true);
|
||||||
|
try {
|
||||||
|
const updated = await action(current);
|
||||||
|
showToast(successMessage(updated), "success");
|
||||||
|
// PATCH/PUT 响应不含 arm_stats:先用响应回填状态字段让弹窗即时一致,再补拉统计;
|
||||||
|
// 同时刷新 dashboard,让配置列表的实验徽章与发布禁用状态同步收敛。
|
||||||
|
setExperimentManage((state) =>
|
||||||
|
state && state.experiment.experiment_id === current.experiment_id
|
||||||
|
? { experiment: { ...state.experiment, ...updated }, statsLoading: true }
|
||||||
|
: state,
|
||||||
|
);
|
||||||
|
await loadDashboard();
|
||||||
|
try {
|
||||||
|
const detail = await refreshExperimentDetail(current);
|
||||||
|
setExperimentManage((state) =>
|
||||||
|
state && state.experiment.experiment_id === current.experiment_id
|
||||||
|
? { experiment: detail || state.experiment, statsLoading: false }
|
||||||
|
: state,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// 操作本身已成功,统计刷新失败不能再报错误 toast;保留已回填的基础字段。
|
||||||
|
setExperimentManage((state) => (state ? { ...state, statsLoading: false } : state));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 操作失败保留弹窗与输入内容,运营修正后可直接重试。
|
||||||
|
showToast(error.message || "实验操作失败", "error");
|
||||||
|
} finally {
|
||||||
|
setExperimentBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[experimentManage, loadDashboard, refreshExperimentDetail, showToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const adjustExperimentTraffic = useCallback(
|
||||||
|
(percent) =>
|
||||||
|
runExperimentAction(
|
||||||
|
(current) =>
|
||||||
|
updateLuckyGiftExperiment(current.app_code, current.experiment_id, {
|
||||||
|
treatment_traffic_percent: percent,
|
||||||
|
}),
|
||||||
|
() => `已调整实验组流量为 ${formatTrafficPercent(percent)}`,
|
||||||
|
),
|
||||||
|
[runExperimentAction],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setExperimentStatus = useCallback(
|
||||||
|
(status) =>
|
||||||
|
runExperimentAction(
|
||||||
|
(current) => updateLuckyGiftExperiment(current.app_code, current.experiment_id, { status }),
|
||||||
|
() =>
|
||||||
|
status === "paused"
|
||||||
|
? "实验已暂停,全部流量(含白名单)回到对照组"
|
||||||
|
: "实验已恢复,按原分组继续分流",
|
||||||
|
),
|
||||||
|
[runExperimentAction],
|
||||||
|
);
|
||||||
|
|
||||||
|
const upsertExperimentOverride = useCallback(
|
||||||
|
({ arm, userKey }) =>
|
||||||
|
runExperimentAction(
|
||||||
|
(current) =>
|
||||||
|
setLuckyGiftExperimentOverrides(current.app_code, current.experiment_id, {
|
||||||
|
removes: [],
|
||||||
|
upserts: [{ arm, user_key: userKey }],
|
||||||
|
}),
|
||||||
|
() => `已把用户 ${userKey} 固定到${EXPERIMENT_ARM_LABELS[arm] || arm}`,
|
||||||
|
),
|
||||||
|
[runExperimentAction],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeExperimentOverride = useCallback(
|
||||||
|
(userKey) =>
|
||||||
|
runExperimentAction(
|
||||||
|
(current) =>
|
||||||
|
setLuckyGiftExperimentOverrides(current.app_code, current.experiment_id, {
|
||||||
|
removes: [userKey],
|
||||||
|
upserts: [],
|
||||||
|
}),
|
||||||
|
() => `已移除白名单用户 ${userKey},该用户回到哈希分流`,
|
||||||
|
),
|
||||||
|
[runExperimentAction],
|
||||||
|
);
|
||||||
|
|
||||||
|
const endExperiment = useCallback(
|
||||||
|
(winnerArm) =>
|
||||||
|
runExperimentAction(
|
||||||
|
async (current) => {
|
||||||
|
const result = await endLuckyGiftExperiment(current.app_code, current.experiment_id, winnerArm);
|
||||||
|
return result.experiment;
|
||||||
|
},
|
||||||
|
(updated) =>
|
||||||
|
`实验已结束:${EXPERIMENT_ARM_LABELS[winnerArm]}配置已重新发布为 v${
|
||||||
|
updated?.final_rule_version || updated?.winner_rule_version || "-"
|
||||||
|
} 并全量生效`,
|
||||||
|
),
|
||||||
|
[runExperimentAction],
|
||||||
|
);
|
||||||
|
|
||||||
const handleAdjustPool = useCallback(
|
const handleAdjustPool = useCallback(
|
||||||
async (request) => {
|
async (request) => {
|
||||||
setAdjustingPool(true);
|
setAdjustingPool(true);
|
||||||
@ -232,15 +418,19 @@ export function OpsCenterApp({ can = denyPermission }) {
|
|||||||
apps={data.apps}
|
apps={data.apps}
|
||||||
canCredit={canCreditPool}
|
canCredit={canCreditPool}
|
||||||
canDebit={canDebitPool}
|
canDebit={canDebitPool}
|
||||||
|
canExperiment={canManageExperiment}
|
||||||
canUpdate={canUpdateConfig}
|
canUpdate={canUpdateConfig}
|
||||||
disabled={adjustingPool || state.loading}
|
disabled={adjustingPool || state.loading}
|
||||||
|
experiments={data.experiments}
|
||||||
luckyGifts={data.luckyGifts}
|
luckyGifts={data.luckyGifts}
|
||||||
poolBalances={data.poolBalances}
|
poolBalances={data.poolBalances}
|
||||||
saving={savingConfig}
|
saving={savingConfig}
|
||||||
onCreate={openCreateConfig}
|
onCreate={openCreateConfig}
|
||||||
|
onCreateExperiment={openCreateExperiment}
|
||||||
onCredit={(pool) => openPoolAdjustment(pool, "in")}
|
onCredit={(pool) => openPoolAdjustment(pool, "in")}
|
||||||
onDebit={(pool) => openPoolAdjustment(pool, "out")}
|
onDebit={(pool) => openPoolAdjustment(pool, "out")}
|
||||||
onEdit={openEditConfig}
|
onEdit={openEditConfig}
|
||||||
|
onManageExperiment={openExperimentManage}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeView === "draws" ? (
|
{activeView === "draws" ? (
|
||||||
@ -255,7 +445,23 @@ export function OpsCenterApp({ can = denyPermission }) {
|
|||||||
mode={dialog.mode}
|
mode={dialog.mode}
|
||||||
saving={savingConfig}
|
saving={savingConfig}
|
||||||
onClose={() => setDialog(null)}
|
onClose={() => setDialog(null)}
|
||||||
onSubmit={handleSaveConfig}
|
// 实验模式提交的是 {name, treatment_traffic_percent, overrides, config} 创建请求,
|
||||||
|
// 其余模式仍是 PUT /lucky-gifts/config 的完整配置对象。
|
||||||
|
onSubmit={dialog.mode === "experiment" ? handleCreateExperiment : handleSaveConfig}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{experimentManage ? (
|
||||||
|
<LuckyGiftExperimentManageDialog
|
||||||
|
busy={experimentBusy}
|
||||||
|
canManage={canManageExperiment}
|
||||||
|
experiment={experimentManage.experiment}
|
||||||
|
statsLoading={experimentManage.statsLoading}
|
||||||
|
onAdjustTraffic={adjustExperimentTraffic}
|
||||||
|
onClose={() => setExperimentManage(null)}
|
||||||
|
onEnd={endExperiment}
|
||||||
|
onRemoveOverride={removeExperimentOverride}
|
||||||
|
onSetStatus={setExperimentStatus}
|
||||||
|
onUpsertOverride={upsertExperimentOverride}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{poolDialog ? (
|
{poolDialog ? (
|
||||||
|
|||||||
@ -2,23 +2,55 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
|
|||||||
import { beforeEach, expect, test, vi } from "vitest";
|
import { beforeEach, expect, test, vi } from "vitest";
|
||||||
import { PERMISSIONS } from "@/app/permissions";
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { adjustLuckyGiftPool, fetchLuckyGiftDraws, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
|
import {
|
||||||
|
adjustLuckyGiftPool,
|
||||||
|
createLuckyGiftExperiment,
|
||||||
|
endLuckyGiftExperiment,
|
||||||
|
fetchLuckyGiftDraws,
|
||||||
|
fetchLuckyGiftExperiments,
|
||||||
|
fetchOpsDashboard,
|
||||||
|
saveLuckyGiftConfig,
|
||||||
|
setLuckyGiftExperimentOverrides,
|
||||||
|
updateLuckyGiftExperiment,
|
||||||
|
} from "./api.js";
|
||||||
import { initialOpsCenterView, OpsCenterApp } from "./OpsCenterApp.jsx";
|
import { initialOpsCenterView, OpsCenterApp } from "./OpsCenterApp.jsx";
|
||||||
|
|
||||||
vi.mock("./api.js", () => ({
|
vi.mock("./api.js", () => ({
|
||||||
DEFAULT_POOL_ID: "default",
|
DEFAULT_POOL_ID: "default",
|
||||||
adjustLuckyGiftPool: vi.fn(),
|
adjustLuckyGiftPool: vi.fn(),
|
||||||
|
createLuckyGiftExperiment: vi.fn(),
|
||||||
|
endLuckyGiftExperiment: vi.fn(),
|
||||||
fetchLuckyGiftDraws: vi.fn(),
|
fetchLuckyGiftDraws: vi.fn(),
|
||||||
|
fetchLuckyGiftExperiments: vi.fn(),
|
||||||
fetchOpsDashboard: vi.fn(),
|
fetchOpsDashboard: vi.fn(),
|
||||||
luckyGiftPoolKey: (item = {}) =>
|
luckyGiftPoolKey: (item = {}) =>
|
||||||
`${item.app_code || item.appCode}:${item.pool_id || item.poolId}:${item.strategy_version || item.strategyVersion || "fixed_v2"}`,
|
`${item.app_code || item.appCode}:${item.pool_id || item.poolId}:${item.strategy_version || item.strategyVersion || "fixed_v2"}`,
|
||||||
saveLuckyGiftConfig: vi.fn(),
|
saveLuckyGiftConfig: vi.fn(),
|
||||||
|
setLuckyGiftExperimentOverrides: vi.fn(),
|
||||||
|
updateLuckyGiftExperiment: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
window.history.replaceState({}, "", "/");
|
window.history.replaceState({}, "", "/");
|
||||||
saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" });
|
saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" });
|
||||||
|
createLuckyGiftExperiment.mockResolvedValue({
|
||||||
|
experiment: { experiment_id: "exp-new", status: "running", treatment_rule_version: 4 },
|
||||||
|
treatment_config: { app_code: "lalu", pool_id: "lucky", rule_version: 4 },
|
||||||
|
});
|
||||||
|
fetchLuckyGiftExperiments.mockResolvedValue([mockRunningExperiment()]);
|
||||||
|
updateLuckyGiftExperiment.mockResolvedValue({ ...mockRunningExperiment(), treatment_traffic_percent: 20 });
|
||||||
|
setLuckyGiftExperimentOverrides.mockResolvedValue({ ...mockRunningExperiment(), overrides: [] });
|
||||||
|
endLuckyGiftExperiment.mockResolvedValue({
|
||||||
|
experiment: {
|
||||||
|
...mockRunningExperiment(),
|
||||||
|
ended_at_ms: 1767000600000,
|
||||||
|
final_rule_version: 4,
|
||||||
|
status: "completed",
|
||||||
|
winner_arm: "treatment",
|
||||||
|
},
|
||||||
|
final_config: { app_code: "lalu", pool_id: "super_lucky", rule_version: 4 },
|
||||||
|
});
|
||||||
adjustLuckyGiftPool.mockResolvedValue({
|
adjustLuckyGiftPool.mockResolvedValue({
|
||||||
adjustment: { balance_after: 39_559_017, balance_before: 38_559_017 },
|
adjustment: { balance_after: 39_559_017, balance_before: 38_559_017 },
|
||||||
idempotent_replay: false,
|
idempotent_replay: false,
|
||||||
@ -43,6 +75,7 @@ beforeEach(() => {
|
|||||||
total: 1,
|
total: 1,
|
||||||
});
|
});
|
||||||
fetchOpsDashboard.mockResolvedValue({
|
fetchOpsDashboard.mockResolvedValue({
|
||||||
|
experiments: [mockRunningExperiment()],
|
||||||
apps: [
|
apps: [
|
||||||
{ app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 },
|
{ app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 },
|
||||||
{ app_code: "yumi", app_name: "Yumi", source: "fixed", status: "active" },
|
{ app_code: "yumi", app_name: "Yumi", source: "fixed", status: "active" },
|
||||||
@ -147,7 +180,53 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const allPermissions = [PERMISSIONS.luckyGiftPoolCredit, PERMISSIONS.luckyGiftPoolDebit, PERMISSIONS.luckyGiftUpdate];
|
// 实验权限常量与 permissions.ts 并行开发,测试使用后端约定的权限码字面量(两者最终一致)。
|
||||||
|
const EXPERIMENT_PERMISSION = "lucky-gift:experiment";
|
||||||
|
const allPermissions = [
|
||||||
|
PERMISSIONS.luckyGiftPoolCredit,
|
||||||
|
PERMISSIONS.luckyGiftPoolDebit,
|
||||||
|
PERMISSIONS.luckyGiftUpdate,
|
||||||
|
EXPERIMENT_PERMISSION,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 活跃实验挂在 lalu/super_lucky:该奖池行必须显示实验徽章并禁用常规发布,
|
||||||
|
// lalu/lucky(已启用且无实验)则暴露“创建 AB 实验”入口。
|
||||||
|
function mockRunningExperiment() {
|
||||||
|
return {
|
||||||
|
app_code: "lalu",
|
||||||
|
arm_stats: [
|
||||||
|
{
|
||||||
|
actual_rtp_percent: 95.2,
|
||||||
|
arm: "control",
|
||||||
|
rule_version: 2,
|
||||||
|
strategy_version: "fixed_v2",
|
||||||
|
total_draws: 100,
|
||||||
|
total_reward_coins: 952,
|
||||||
|
total_spent_coins: 1000,
|
||||||
|
unique_users: 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actual_rtp_percent: 97.9,
|
||||||
|
arm: "treatment",
|
||||||
|
rule_version: 3,
|
||||||
|
strategy_version: "fixed_v2",
|
||||||
|
total_draws: 20,
|
||||||
|
total_reward_coins: 196,
|
||||||
|
total_spent_coins: 200,
|
||||||
|
unique_users: 6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
control_rule_version: 2,
|
||||||
|
experiment_id: "exp-1",
|
||||||
|
name: "rtp-up",
|
||||||
|
overrides: [{ arm: "treatment", user_key: "1001" }],
|
||||||
|
pool_id: "super_lucky",
|
||||||
|
started_at_ms: 1767000000000,
|
||||||
|
status: "running",
|
||||||
|
treatment_rule_version: 3,
|
||||||
|
treatment_traffic_percent: 5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function renderApp(permissions = allPermissions) {
|
function renderApp(permissions = allPermissions) {
|
||||||
return render(
|
return render(
|
||||||
@ -451,3 +530,130 @@ test("shows app registry and fixed integrations in apps view", async () => {
|
|||||||
expect(screen.getByText("主数据")).toBeTruthy();
|
expect(screen.getByText("主数据")).toBeTruthy();
|
||||||
expect(screen.getByText("外部接入")).toBeTruthy();
|
expect(screen.getByText("外部接入")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("flags pools with a running experiment and blocks their regular publishing", async () => {
|
||||||
|
renderApp();
|
||||||
|
await screen.findByText("运营应用");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||||||
|
|
||||||
|
// running 实验在“实验”列展示放量徽章;该奖池的常规发布被禁用并说明原因。
|
||||||
|
expect(await screen.findByText("实验中 5%")).toBeTruthy();
|
||||||
|
const publishButton = screen.getByRole("button", { name: "新增版本 lalu / super_lucky / fixed_v2" });
|
||||||
|
expect(publishButton).toBeDisabled();
|
||||||
|
expect(publishButton).toHaveAttribute("title", "实验进行中,结束实验后才能发布");
|
||||||
|
expect(screen.getByRole("button", { name: "实验详情 lalu / super_lucky / fixed_v2" })).toBeTruthy();
|
||||||
|
// 无实验的行不受影响;只有已启用的已发布配置才有“创建 AB 实验”入口(停用行没有可对照版本)。
|
||||||
|
expect(screen.getByRole("button", { name: "新增版本 lalu / lucky / dynamic_v3" })).toBeEnabled();
|
||||||
|
expect(screen.getByRole("button", { name: "创建 AB 实验 lalu / lucky / dynamic_v3" })).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("button", { name: "创建 AB 实验 lalu / lucky / fixed_v2" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "创建 AB 实验 lalu / super_lucky / fixed_v2" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hides experiment management entries without the experiment permission", async () => {
|
||||||
|
renderApp([PERMISSIONS.luckyGiftUpdate]);
|
||||||
|
await screen.findByText("运营应用");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||||||
|
|
||||||
|
// 无实验权限仍能看到徽章和实验详情(后端 GET 只要求 lucky-gift:view),但没有创建入口。
|
||||||
|
expect(await screen.findByText("实验中 5%")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "实验详情 lalu / super_lucky / fixed_v2" })).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("button", { name: "创建 AB 实验 lalu / lucky / dynamic_v3" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates an ab experiment from an enabled config row with a locked enabled switch", async () => {
|
||||||
|
renderApp();
|
||||||
|
await screen.findByText("运营应用");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "创建 AB 实验 lalu / lucky / dynamic_v3" }));
|
||||||
|
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
expect(within(dialog).getByText("创建 AB 实验")).toBeTruthy();
|
||||||
|
// 实验组配置必须直接可运行:启用开关锁定为开,奖池 ID 锁定在当前行。
|
||||||
|
expect(within(dialog).getByRole("switch", { name: "发布状态" })).toBeDisabled();
|
||||||
|
expect(within(dialog).getByRole("switch", { name: "发布状态" })).toBeChecked();
|
||||||
|
expect(within(dialog).getByLabelText(/^奖池/)).toBeDisabled();
|
||||||
|
|
||||||
|
// 名称必填:空名称提交被表单校验拦截(原生 required + 纯函数校验双重兜底),不发请求。
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "创建实验" }));
|
||||||
|
expect(createLuckyGiftExperiment).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.change(within(dialog).getByLabelText(/实验名称/), { target: { value: "rtp-98" } });
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "创建实验" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createLuckyGiftExperiment).toHaveBeenCalledWith(
|
||||||
|
"lalu",
|
||||||
|
expect.objectContaining({
|
||||||
|
config: expect.objectContaining({ app_code: "lalu", enabled: true, pool_id: "lucky" }),
|
||||||
|
name: "rtp-98",
|
||||||
|
overrides: [],
|
||||||
|
treatment_traffic_percent: 5,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||||
|
expect(fetchOpsDashboard).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("manages a running experiment: stats, traffic, overrides and ending with a winner", async () => {
|
||||||
|
renderApp();
|
||||||
|
await screen.findByText("运营应用");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "实验详情 lalu / super_lucky / fixed_v2" }));
|
||||||
|
|
||||||
|
const dialog = await screen.findByRole("dialog", { name: /实验管理/ });
|
||||||
|
// 打开弹窗即拉 with_stats=1 的统计并展示两组卡片。
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(fetchLuckyGiftExperiments).toHaveBeenCalledWith("lalu", { poolId: "super_lucky", withStats: true }),
|
||||||
|
);
|
||||||
|
expect(await within(dialog).findByText("97.90%")).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText("95.20%")).toBeTruthy();
|
||||||
|
// “实验组”同时出现在分组卡片、白名单分组标签和新增分组下拉中,用 getAllByText 断言存在。
|
||||||
|
expect(within(dialog).getAllByText("对照组").length).toBeGreaterThan(0);
|
||||||
|
expect(within(dialog).getAllByText("实验组").length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
fireEvent.change(within(dialog).getByLabelText(/实验组流量/), { target: { value: "20" } });
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "应用" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(updateLuckyGiftExperiment).toHaveBeenCalledWith("lalu", "exp-1", { treatment_traffic_percent: 20 }),
|
||||||
|
);
|
||||||
|
expect(await screen.findByText("已调整实验组流量为 20%")).toBeTruthy();
|
||||||
|
|
||||||
|
await waitFor(() => expect(within(dialog).getByRole("button", { name: "移除白名单 1001" })).toBeEnabled());
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "移除白名单 1001" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(setLuckyGiftExperimentOverrides).toHaveBeenCalledWith("lalu", "exp-1", {
|
||||||
|
removes: ["1001"],
|
||||||
|
upserts: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 结束实验必须二次确认:确认层说明获胜配置将重新发布并全量生效。
|
||||||
|
await waitFor(() => expect(within(dialog).getByRole("button", { name: "选择实验组为最终版本" })).toBeEnabled());
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "选择实验组为最终版本" }));
|
||||||
|
expect(endLuckyGiftExperiment).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(await within(dialog).findByRole("button", { name: "确认结束并发布" }));
|
||||||
|
await waitFor(() => expect(endLuckyGiftExperiment).toHaveBeenCalledWith("lalu", "exp-1", "treatment"));
|
||||||
|
expect(await screen.findByText(/实验已结束:实验组配置已重新发布为 v4 并全量生效/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pauses a running experiment and keeps the dialog usable when the action fails", async () => {
|
||||||
|
updateLuckyGiftExperiment.mockRejectedValueOnce(new Error("实验状态已变化"));
|
||||||
|
renderApp();
|
||||||
|
await screen.findByText("运营应用");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "实验详情 lalu / super_lucky / fixed_v2" }));
|
||||||
|
const dialog = await screen.findByRole("dialog", { name: /实验管理/ });
|
||||||
|
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "暂停实验" }));
|
||||||
|
// 失败保留弹窗与输入,运营可以直接重试。
|
||||||
|
expect(await screen.findByText("实验状态已变化")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("dialog", { name: /实验管理/ })).toBeTruthy();
|
||||||
|
|
||||||
|
await waitFor(() => expect(within(dialog).getByRole("button", { name: "暂停实验" })).toBeEnabled());
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "暂停实验" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(updateLuckyGiftExperiment).toHaveBeenLastCalledWith("lalu", "exp-1", { status: "paused" }),
|
||||||
|
);
|
||||||
|
expect(await screen.findByText("实验已暂停,全部流量(含白名单)回到对照组")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|||||||
@ -13,12 +13,15 @@ export async function fetchOpsDashboard(query = {}) {
|
|||||||
|
|
||||||
const perApp = await Promise.all(
|
const perApp = await Promise.all(
|
||||||
appCodes.map(async (appCode) => {
|
appCodes.map(async (appCode) => {
|
||||||
const [luckyGifts, poolBalances, drawSummary] = await Promise.all([
|
const [luckyGifts, poolBalances, drawSummary, experiments] = await Promise.all([
|
||||||
fetchAppLuckyGiftConfigs(appCode),
|
fetchAppLuckyGiftConfigs(appCode),
|
||||||
opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems),
|
opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems),
|
||||||
opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } }),
|
opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } }),
|
||||||
|
// 实验列表驱动配置行的实验徽章和发布禁用;该接口比面板后上线,请求失败时降级为空列表
|
||||||
|
// 而不是整页白屏——活跃实验期间的常规发布仍由 owner 以 409 兜底拒绝,降级是安全的。
|
||||||
|
fetchLuckyGiftExperiments(appCode).catch(() => []),
|
||||||
]);
|
]);
|
||||||
return { appCode, drawSummary, luckyGifts, poolBalances };
|
return { appCode, drawSummary, experiments, luckyGifts, poolBalances };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -123,6 +126,147 @@ export async function adjustLuckyGiftPool({
|
|||||||
return normalizePoolAdjustmentResult(payload);
|
return normalizePoolAdjustmentResult(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchLuckyGiftExperiments(appCode, { poolId = "", status = "", withStats = false } = {}) {
|
||||||
|
const normalizedAppCode = normalizeAppCodeValue(appCode);
|
||||||
|
const payload = await opsRequest("/lucky-gifts/experiments", {
|
||||||
|
query: {
|
||||||
|
app_code: normalizedAppCode,
|
||||||
|
pool_id: String(poolId || "").trim(),
|
||||||
|
status: String(status || "").trim(),
|
||||||
|
// with_stats=1 让 owner 按 control/treatment 聚合抽奖统计,代价较高;
|
||||||
|
// dashboard 扇出保持轻量不带该参数,只有实验管理弹窗需要统计。
|
||||||
|
with_stats: withStats ? 1 : "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return normalizeItems(payload).map((item) => normalizeLuckyGiftExperiment(item, normalizedAppCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLuckyGiftExperiment(appCode, request = {}) {
|
||||||
|
const config = luckyGiftConfigPayload(request.config || {});
|
||||||
|
const normalizedAppCode = normalizeAppCodeValue(appCode || config.app_code);
|
||||||
|
// 实验组配置由 owner 原子发布为新规则版本并立即参与开奖,enabled 必须为 true;
|
||||||
|
// 表单层已强制启用,这里再收敛一次,防止其他调用方把停用草稿提交成实验组。
|
||||||
|
const payload = await opsRequest("/lucky-gifts/experiments", {
|
||||||
|
body: {
|
||||||
|
config: { ...config, enabled: true },
|
||||||
|
name: String(request.name || "").trim(),
|
||||||
|
overrides: normalizeExperimentOverrides(request.overrides),
|
||||||
|
treatment_traffic_percent: numberValue(request.treatment_traffic_percent),
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
query: { app_code: normalizedAppCode },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
experiment: normalizeLuckyGiftExperiment(payload?.experiment || {}, normalizedAppCode),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLuckyGiftExperiment(appCode, experimentId, patch = {}) {
|
||||||
|
const body = {};
|
||||||
|
// PATCH 只回传明确要修改的字段:同时携带 status 和 treatment_traffic_percent 会让
|
||||||
|
// “暂停”误把放量重置,或“放量”误改实验状态。
|
||||||
|
if (patch.treatment_traffic_percent !== undefined && patch.treatment_traffic_percent !== null) {
|
||||||
|
body.treatment_traffic_percent = numberValue(patch.treatment_traffic_percent);
|
||||||
|
}
|
||||||
|
if (patch.status) {
|
||||||
|
body.status = String(patch.status);
|
||||||
|
}
|
||||||
|
const payload = await opsRequest(luckyGiftExperimentPath(experimentId), {
|
||||||
|
body,
|
||||||
|
method: "PATCH",
|
||||||
|
query: { app_code: normalizeAppCodeValue(appCode) },
|
||||||
|
});
|
||||||
|
return normalizeLuckyGiftExperiment(payload?.experiment || {}, appCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLuckyGiftExperimentOverrides(appCode, experimentId, { removes, upserts } = {}) {
|
||||||
|
const payload = await opsRequest(`${luckyGiftExperimentPath(experimentId)}/overrides`, {
|
||||||
|
body: {
|
||||||
|
removes: (Array.isArray(removes) ? removes : [])
|
||||||
|
.map((userKey) => String(userKey ?? "").trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
upserts: normalizeExperimentOverrides(upserts),
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
query: { app_code: normalizeAppCodeValue(appCode) },
|
||||||
|
});
|
||||||
|
return normalizeLuckyGiftExperiment(payload?.experiment || {}, appCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function endLuckyGiftExperiment(appCode, experimentId, winnerArm) {
|
||||||
|
// 结束实验没有默认获胜方:获胜配置会被重新发布为最新版本并全量生效,误传空值的后果不可回滚。
|
||||||
|
if (winnerArm !== "control" && winnerArm !== "treatment") {
|
||||||
|
throw new Error("结束实验必须选择获胜方");
|
||||||
|
}
|
||||||
|
const payload = await opsRequest(`${luckyGiftExperimentPath(experimentId)}/end`, {
|
||||||
|
body: { winner_arm: winnerArm },
|
||||||
|
method: "POST",
|
||||||
|
query: { app_code: normalizeAppCodeValue(appCode) },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
experiment: normalizeLuckyGiftExperiment(payload?.experiment || {}, appCode),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLuckyGiftExperiment(experiment = {}, fallbackAppCode = "") {
|
||||||
|
const armStats = experiment.arm_stats ?? experiment.armStats;
|
||||||
|
return {
|
||||||
|
...experiment,
|
||||||
|
app_code: normalizeAppCodeValue(experiment.app_code || experiment.appCode || fallbackAppCode),
|
||||||
|
arm_stats: (Array.isArray(armStats) ? armStats : []).map((stat) => ({
|
||||||
|
actual_rtp_percent: numberValue(stat.actual_rtp_percent ?? stat.actualRtpPercent),
|
||||||
|
arm: String(stat.arm || ""),
|
||||||
|
rule_version: numberValue(stat.rule_version ?? stat.ruleVersion),
|
||||||
|
strategy_version: normalizeStrategyVersion(stat.strategy_version ?? stat.strategyVersion),
|
||||||
|
total_draws: numberValue(stat.total_draws ?? stat.totalDraws),
|
||||||
|
total_reward_coins: numberValue(stat.total_reward_coins ?? stat.totalRewardCoins),
|
||||||
|
total_spent_coins: numberValue(stat.total_spent_coins ?? stat.totalSpentCoins),
|
||||||
|
unique_users: numberValue(stat.unique_users ?? stat.uniqueUsers),
|
||||||
|
})),
|
||||||
|
control_rule_version: numberValue(experiment.control_rule_version ?? experiment.controlRuleVersion),
|
||||||
|
ended_at_ms: numberValue(experiment.ended_at_ms ?? experiment.endedAtMs),
|
||||||
|
experiment_id: String(experiment.experiment_id ?? experiment.experimentId ?? ""),
|
||||||
|
final_rule_version: numberValue(experiment.final_rule_version ?? experiment.finalRuleVersion),
|
||||||
|
name: String(experiment.name || ""),
|
||||||
|
overrides: (Array.isArray(experiment.overrides) ? experiment.overrides : []).map((item) => ({
|
||||||
|
// arm 只可能是两组之一;未知值按 treatment 处理会放大实验组,按 control 处理保持保守。
|
||||||
|
arm: item?.arm === "treatment" ? "treatment" : "control",
|
||||||
|
user_key: String(item?.user_key ?? item?.userKey ?? ""),
|
||||||
|
})),
|
||||||
|
pool_id: String(experiment.pool_id || experiment.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID,
|
||||||
|
started_at_ms: numberValue(experiment.started_at_ms ?? experiment.startedAtMs),
|
||||||
|
status: String(experiment.status || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
treatment_rule_version: numberValue(experiment.treatment_rule_version ?? experiment.treatmentRuleVersion),
|
||||||
|
treatment_traffic_percent: numberValue(
|
||||||
|
experiment.treatment_traffic_percent ?? experiment.treatmentTrafficPercent,
|
||||||
|
),
|
||||||
|
updated_at_ms: numberValue(experiment.updated_at_ms ?? experiment.updatedAtMs),
|
||||||
|
winner_arm: String(experiment.winner_arm ?? experiment.winnerArm ?? ""),
|
||||||
|
winner_rule_version: numberValue(experiment.winner_rule_version ?? experiment.winnerRuleVersion),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function luckyGiftExperimentPath(experimentId) {
|
||||||
|
const id = String(experimentId || "").trim();
|
||||||
|
if (!id) {
|
||||||
|
throw new Error("缺少实验 ID");
|
||||||
|
}
|
||||||
|
return `/lucky-gifts/experiments/${encodeURIComponent(id)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExperimentOverrides(overrides) {
|
||||||
|
return (Array.isArray(overrides) ? overrides : [])
|
||||||
|
.map((item) => ({
|
||||||
|
arm: item?.arm === "control" ? "control" : "treatment",
|
||||||
|
user_key: String(item?.user_key ?? item?.userKey ?? "").trim(),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.user_key);
|
||||||
|
}
|
||||||
|
|
||||||
export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) {
|
export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) {
|
||||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||||
// 复用主后台请求层才能共享 refreshOnce:运行期间 token 过期时先刷新再重放一次请求。
|
// 复用主后台请求层才能共享 refreshOnce:运行期间 token 过期时先刷新再重放一次请求。
|
||||||
@ -149,6 +293,8 @@ export function buildOpsUrl(path, query = {}) {
|
|||||||
|
|
||||||
export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
|
export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
|
||||||
const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []);
|
const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []);
|
||||||
|
// 实验按 app+pool 独占;这里保持扁平列表,活跃实验与配置行的关联由展示层的 family key 完成。
|
||||||
|
const experiments = perApp.flatMap((entry) => entry.experiments || []);
|
||||||
const normalizedPoolBalances = perApp
|
const normalizedPoolBalances = perApp
|
||||||
.flatMap((entry) => (entry.poolBalances || []).map((item) => normalizePoolBalance(item, entry.appCode)))
|
.flatMap((entry) => (entry.poolBalances || []).map((item) => normalizePoolBalance(item, entry.appCode)))
|
||||||
.filter((item) => item.app_code);
|
.filter((item) => item.app_code);
|
||||||
@ -198,7 +344,7 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
|
|||||||
totalSpentCoins: sumBy(appSummaries, "total_spent_coins"),
|
totalSpentCoins: sumBy(appSummaries, "total_spent_coins"),
|
||||||
};
|
};
|
||||||
|
|
||||||
return { apps, appSummaries, luckyGifts, poolBalances, summary };
|
return { apps, appSummaries, experiments, luckyGifts, poolBalances, summary };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizePoolBalance(pool = {}, fallbackAppCode = "") {
|
export function normalizePoolBalance(pool = {}, fallbackAppCode = "") {
|
||||||
@ -319,6 +465,12 @@ function normalizeStrategyVersion(value) {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAppCodeValue(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
function luckyGiftConfigPayload(config = {}) {
|
function luckyGiftConfigPayload(config = {}) {
|
||||||
const appCode = String(config.app_code || config.appCode || "")
|
const appCode = String(config.app_code || config.appCode || "")
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
@ -3,13 +3,18 @@ import { setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "@/sha
|
|||||||
import {
|
import {
|
||||||
adjustLuckyGiftPool,
|
adjustLuckyGiftPool,
|
||||||
buildOpsUrl,
|
buildOpsUrl,
|
||||||
|
createLuckyGiftExperiment,
|
||||||
|
endLuckyGiftExperiment,
|
||||||
fetchLuckyGiftDraws,
|
fetchLuckyGiftDraws,
|
||||||
|
fetchLuckyGiftExperiments,
|
||||||
fetchOpsDashboard,
|
fetchOpsDashboard,
|
||||||
luckyGiftPoolKey,
|
luckyGiftPoolKey,
|
||||||
normalizeDashboard,
|
normalizeDashboard,
|
||||||
opsRequest,
|
opsRequest,
|
||||||
OPS_API_PREFIX,
|
OPS_API_PREFIX,
|
||||||
saveLuckyGiftConfig,
|
saveLuckyGiftConfig,
|
||||||
|
setLuckyGiftExperimentOverrides,
|
||||||
|
updateLuckyGiftExperiment,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -100,6 +105,19 @@ test("loads every pool config per app through the plural configs endpoint", asyn
|
|||||||
total_spent_coins: 1000,
|
total_spent_coins: 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (text.includes("/lucky-gifts/experiments?")) {
|
||||||
|
return jsonResponse({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
experiment_id: "exp-1",
|
||||||
|
name: "raise-rtp",
|
||||||
|
pool_id: "lucky",
|
||||||
|
status: "running",
|
||||||
|
treatment_traffic_percent: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
return jsonResponse({ items: [] });
|
return jsonResponse({ items: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -110,6 +128,11 @@ test("loads every pool config per app through the plural configs endpoint", asyn
|
|||||||
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/configs?app_code=lalu",
|
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/configs?app_code=lalu",
|
||||||
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools?app_code=lalu",
|
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools?app_code=lalu",
|
||||||
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu",
|
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu",
|
||||||
|
// dashboard 扇出不带 with_stats/status:实验列表只驱动实验徽章和发布封锁。
|
||||||
|
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/experiments?app_code=lalu",
|
||||||
|
]);
|
||||||
|
expect(data.experiments).toEqual([
|
||||||
|
expect.objectContaining({ app_code: "lalu", experiment_id: "exp-1", pool_id: "lucky", status: "running" }),
|
||||||
]);
|
]);
|
||||||
expect(data.luckyGifts).toEqual([
|
expect(data.luckyGifts).toEqual([
|
||||||
expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "lucky", rule_version: 3 }),
|
expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "lucky", rule_version: 3 }),
|
||||||
@ -393,3 +416,184 @@ test.each([
|
|||||||
pool: { app_code: "lalu", pool_id: "lucky", strategy_version: "dynamic_v3" },
|
pool: { app_code: "lalu", pool_id: "lucky", strategy_version: "dynamic_v3" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps the dashboard usable when the experiments endpoint is not deployed yet", async () => {
|
||||||
|
vi.spyOn(window, "fetch").mockImplementation(async (url) => {
|
||||||
|
const text = String(url);
|
||||||
|
if (text.endsWith("/apps")) {
|
||||||
|
return jsonResponse({ items: [{ app_code: "lalu", status: "active" }] });
|
||||||
|
}
|
||||||
|
if (text.includes("/lucky-gifts/experiments?")) {
|
||||||
|
return new Response(JSON.stringify({ code: 40400, message: "接口不存在或后端服务未更新" }), {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (text.includes("/lucky-gifts/configs?")) {
|
||||||
|
return jsonResponse([{ app_code: "lalu", enabled: true, pool_id: "lucky", rule_version: 3 }]);
|
||||||
|
}
|
||||||
|
return jsonResponse({ items: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await fetchOpsDashboard();
|
||||||
|
|
||||||
|
// 实验接口晚于面板上线;请求失败必须降级为空列表而不是整页失败,
|
||||||
|
// 活跃实验期间的常规发布仍由 owner 以 409 兜底。
|
||||||
|
expect(data.experiments).toEqual([]);
|
||||||
|
expect(data.luckyGifts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads experiments with optional stats and normalizes arm data", async () => {
|
||||||
|
const calls = [];
|
||||||
|
vi.spyOn(window, "fetch").mockImplementation(async (url) => {
|
||||||
|
calls.push(String(url));
|
||||||
|
return jsonResponse({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
arm_stats: [
|
||||||
|
{
|
||||||
|
actual_rtp_percent: "95.5",
|
||||||
|
arm: "control",
|
||||||
|
rule_version: 3,
|
||||||
|
strategy_version: "dynamic_v3",
|
||||||
|
total_draws: "10",
|
||||||
|
total_reward_coins: 955,
|
||||||
|
total_spent_coins: 1000,
|
||||||
|
unique_users: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
control_rule_version: 3,
|
||||||
|
experiment_id: "exp-1",
|
||||||
|
name: "raise-rtp",
|
||||||
|
overrides: [{ arm: "treatment", user_key: 1001 }],
|
||||||
|
pool_id: "lucky",
|
||||||
|
status: "RUNNING",
|
||||||
|
treatment_rule_version: 4,
|
||||||
|
treatment_traffic_percent: "12.5",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = await fetchLuckyGiftExperiments("LALU", { poolId: "lucky", withStats: true });
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/experiments?app_code=lalu&pool_id=lucky&with_stats=1",
|
||||||
|
]);
|
||||||
|
expect(items).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
app_code: "lalu",
|
||||||
|
control_rule_version: 3,
|
||||||
|
experiment_id: "exp-1",
|
||||||
|
overrides: [{ arm: "treatment", user_key: "1001" }],
|
||||||
|
status: "running",
|
||||||
|
treatment_rule_version: 4,
|
||||||
|
treatment_traffic_percent: 12.5,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(items[0].arm_stats[0]).toEqual({
|
||||||
|
actual_rtp_percent: 95.5,
|
||||||
|
arm: "control",
|
||||||
|
rule_version: 3,
|
||||||
|
strategy_version: "dynamic_v3",
|
||||||
|
total_draws: 10,
|
||||||
|
total_reward_coins: 955,
|
||||||
|
total_spent_coins: 1000,
|
||||||
|
unique_users: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates an experiment with a forcibly enabled treatment config", async () => {
|
||||||
|
const calls = [];
|
||||||
|
vi.spyOn(window, "fetch").mockImplementation(async (url, init) => {
|
||||||
|
calls.push({ body: JSON.parse(init.body), method: init.method, url: String(url) });
|
||||||
|
return jsonResponse({
|
||||||
|
experiment: { experiment_id: "exp-9", status: "running", treatment_rule_version: 4 },
|
||||||
|
treatment_config: { app_code: "lalu", pool_id: "lucky", rule_version: 4 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await createLuckyGiftExperiment("lalu", {
|
||||||
|
config: {
|
||||||
|
app_code: "lalu",
|
||||||
|
enabled: false,
|
||||||
|
initial_pool_coins: 1_000_000,
|
||||||
|
pool_id: "lucky",
|
||||||
|
strategy_version: "dynamic_v3",
|
||||||
|
},
|
||||||
|
name: " 提高RTP ",
|
||||||
|
overrides: [{ arm: "treatment", user_key: " 1001 " }, { user_key: "" }],
|
||||||
|
treatment_traffic_percent: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(calls[0].method).toBe("POST");
|
||||||
|
expect(calls[0].url).toBe("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/experiments?app_code=lalu");
|
||||||
|
expect(calls[0].body.name).toBe("提高RTP");
|
||||||
|
expect(calls[0].body.treatment_traffic_percent).toBe(5);
|
||||||
|
// 空 user_key 在传输层剔除;实验组配置必须直接可运行,dynamic_v3 的隐式 seed 同样清零。
|
||||||
|
expect(calls[0].body.overrides).toEqual([{ arm: "treatment", user_key: "1001" }]);
|
||||||
|
expect(calls[0].body.config).toMatchObject({
|
||||||
|
app_code: "lalu",
|
||||||
|
enabled: true,
|
||||||
|
initial_pool_coins: 0,
|
||||||
|
pool_id: "lucky",
|
||||||
|
strategy_version: "dynamic_v3",
|
||||||
|
});
|
||||||
|
expect(result.experiment).toMatchObject({ experiment_id: "exp-9", status: "running", treatment_rule_version: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
[{ treatment_traffic_percent: 20 }, { treatment_traffic_percent: 20 }],
|
||||||
|
[{ status: "paused" }, { status: "paused" }],
|
||||||
|
])("patches only the provided experiment fields %j", async (patch, expectedBody) => {
|
||||||
|
const calls = [];
|
||||||
|
vi.spyOn(window, "fetch").mockImplementation(async (url, init) => {
|
||||||
|
calls.push({ body: JSON.parse(init.body), method: init.method, url: String(url) });
|
||||||
|
return jsonResponse({ experiment: { experiment_id: "exp-9", status: "running" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const experiment = await updateLuckyGiftExperiment("lalu", "exp-9", patch);
|
||||||
|
|
||||||
|
// PATCH 只携带明确修改的字段:暂停不应重置放量,放量不应改状态。
|
||||||
|
expect(calls).toEqual([
|
||||||
|
{
|
||||||
|
body: expectedBody,
|
||||||
|
method: "PATCH",
|
||||||
|
url: "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/experiments/exp-9?app_code=lalu",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(experiment).toMatchObject({ experiment_id: "exp-9", status: "running" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("replaces overrides and ends the experiment through dedicated routes", async () => {
|
||||||
|
const calls = [];
|
||||||
|
vi.spyOn(window, "fetch").mockImplementation(async (url, init) => {
|
||||||
|
calls.push({ body: JSON.parse(init.body), method: init.method, url: String(url) });
|
||||||
|
return jsonResponse({
|
||||||
|
experiment: { experiment_id: "exp-9", status: "completed", winner_arm: "treatment" },
|
||||||
|
final_config: { rule_version: 5 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await setLuckyGiftExperimentOverrides("lalu", "exp-9", {
|
||||||
|
removes: [" 1002 ", ""],
|
||||||
|
upserts: [{ arm: "control", user_key: "1001" }],
|
||||||
|
});
|
||||||
|
const ended = await endLuckyGiftExperiment("lalu", "exp-9", "treatment");
|
||||||
|
// 获胜方缺失时不允许发请求:结束实验会全量重发配置,不可回滚。
|
||||||
|
await expect(endLuckyGiftExperiment("lalu", "exp-9", "")).rejects.toThrow("结束实验必须选择获胜方");
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
{
|
||||||
|
body: { removes: ["1002"], upserts: [{ arm: "control", user_key: "1001" }] },
|
||||||
|
method: "PUT",
|
||||||
|
url: "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/experiments/exp-9/overrides?app_code=lalu",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: { winner_arm: "treatment" },
|
||||||
|
method: "POST",
|
||||||
|
url: "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/experiments/exp-9/end?app_code=lalu",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(ended.experiment).toMatchObject({ status: "completed", winner_arm: "treatment" });
|
||||||
|
});
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { Button } from "@/shared/ui/Button.jsx";
|
|||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||||
import { luckyGiftPoolKey } from "../api.js";
|
import { luckyGiftPoolKey } from "../api.js";
|
||||||
|
import { activeExperimentsByPool, experimentPoolFamilyKey, experimentStatusBadge } from "../experimentForm.js";
|
||||||
import { formatNumber, formatPercent } from "../format.js";
|
import { formatNumber, formatPercent } from "../format.js";
|
||||||
import { LuckyGiftPoolActions } from "./LuckyGiftPoolActions.jsx";
|
import { LuckyGiftPoolActions } from "./LuckyGiftPoolActions.jsx";
|
||||||
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
||||||
@ -13,13 +14,17 @@ export function ConfigsView({
|
|||||||
apps,
|
apps,
|
||||||
canCredit,
|
canCredit,
|
||||||
canDebit,
|
canDebit,
|
||||||
|
canExperiment,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
disabled,
|
disabled,
|
||||||
|
experiments,
|
||||||
luckyGifts,
|
luckyGifts,
|
||||||
onCreate,
|
onCreate,
|
||||||
|
onCreateExperiment,
|
||||||
onCredit,
|
onCredit,
|
||||||
onDebit,
|
onDebit,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onManageExperiment,
|
||||||
poolBalances,
|
poolBalances,
|
||||||
saving,
|
saving,
|
||||||
}) {
|
}) {
|
||||||
@ -36,28 +41,53 @@ export function ConfigsView({
|
|||||||
// 配置列表已在 dashboard 阶段跨应用拉全,应用筛选只做前端过滤,避免重复扇出请求。
|
// 配置列表已在 dashboard 阶段跨应用拉全,应用筛选只做前端过滤,避免重复扇出请求。
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
const poolByKey = new Map(poolBalances.map((pool) => [luckyGiftPoolKey(pool), pool]));
|
const poolByKey = new Map(poolBalances.map((pool) => [luckyGiftPoolKey(pool), pool]));
|
||||||
|
// 实验按 app+pool 独占且不区分策略版本:同一奖池的 fixed_v2 / dynamic_v3 两行
|
||||||
|
// 共享同一个活跃实验的徽章与发布封锁,因此这里用 family key 而不是 luckyGiftPoolKey。
|
||||||
|
const experimentByPool = activeExperimentsByPool(experiments);
|
||||||
const visibleConfigs = appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts;
|
const visibleConfigs = appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts;
|
||||||
// 余额必须按 app+pool+strategy 精确关联;同一个 app/pool 同时存在 fixed_v2 和 dynamic_v3 时,
|
// 余额必须按 app+pool+strategy 精确关联;同一个 app/pool 同时存在 fixed_v2 和 dynamic_v3 时,
|
||||||
// 任何缺少 strategy_version 的关联都会把两口独立资金池串行或覆盖。
|
// 任何缺少 strategy_version 的关联都会把两口独立资金池串行或覆盖。
|
||||||
return visibleConfigs.map((config) => ({
|
return visibleConfigs.map((config) => ({
|
||||||
...config,
|
...config,
|
||||||
|
active_experiment: experimentByPool.get(experimentPoolFamilyKey(config)) || null,
|
||||||
pool_balance: poolByKey.get(luckyGiftPoolKey(config)),
|
pool_balance: poolByKey.get(luckyGiftPoolKey(config)),
|
||||||
}));
|
}));
|
||||||
}, [appFilter, luckyGifts, poolBalances]);
|
}, [appFilter, experiments, luckyGifts, poolBalances]);
|
||||||
|
|
||||||
|
// 实验详情入口对仅有查看权限的账号同样开放(后端 GET 只要求 lucky-gift:view),
|
||||||
|
// 操作列宽度必须把这类行算进去,否则视图权限账号的实验行按钮会溢出。
|
||||||
|
const hasExperimentEntry = canExperiment || items.some((item) => item.active_experiment);
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildColumns({
|
buildColumns({
|
||||||
canCredit,
|
canCredit,
|
||||||
canDebit,
|
canDebit,
|
||||||
|
canExperiment,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
disabled,
|
disabled,
|
||||||
|
hasExperimentEntry,
|
||||||
|
onCreateExperiment,
|
||||||
onCredit,
|
onCredit,
|
||||||
onDebit,
|
onDebit,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onManageExperiment,
|
||||||
saving,
|
saving,
|
||||||
}),
|
}),
|
||||||
[canCredit, canDebit, canUpdate, disabled, onCredit, onDebit, onEdit, saving],
|
[
|
||||||
|
canCredit,
|
||||||
|
canDebit,
|
||||||
|
canExperiment,
|
||||||
|
canUpdate,
|
||||||
|
disabled,
|
||||||
|
hasExperimentEntry,
|
||||||
|
onCreateExperiment,
|
||||||
|
onCredit,
|
||||||
|
onDebit,
|
||||||
|
onEdit,
|
||||||
|
onManageExperiment,
|
||||||
|
saving,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -97,7 +127,20 @@ export function ConfigsView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildColumns({ canCredit, canDebit, canUpdate, disabled, onCredit, onDebit, onEdit, saving }) {
|
function buildColumns({
|
||||||
|
canCredit,
|
||||||
|
canDebit,
|
||||||
|
canExperiment,
|
||||||
|
canUpdate,
|
||||||
|
disabled,
|
||||||
|
hasExperimentEntry,
|
||||||
|
onCreateExperiment,
|
||||||
|
onCredit,
|
||||||
|
onDebit,
|
||||||
|
onEdit,
|
||||||
|
onManageExperiment,
|
||||||
|
saving,
|
||||||
|
}) {
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: "app_code", label: "应用", width: "minmax(88px, 0.7fr)" },
|
{ key: "app_code", label: "应用", width: "minmax(88px, 0.7fr)" },
|
||||||
{ key: "pool_id", label: "奖池", width: "minmax(110px, 0.9fr)" },
|
{ key: "pool_id", label: "奖池", width: "minmax(110px, 0.9fr)" },
|
||||||
@ -137,6 +180,16 @@ function buildColumns({ canCredit, canDebit, canUpdate, disabled, onCredit, onDe
|
|||||||
},
|
},
|
||||||
width: "minmax(128px, 0.8fr)",
|
width: "minmax(128px, 0.8fr)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "experiment",
|
||||||
|
label: "实验",
|
||||||
|
render: (item) => {
|
||||||
|
// 只展示活跃实验(running/paused);completed 实验已全量收敛,不再影响该行的发布能力。
|
||||||
|
const badge = experimentStatusBadge(item.active_experiment);
|
||||||
|
return badge ? <OpsStatusBadge label={badge.label} tone={badge.tone} /> : "-";
|
||||||
|
},
|
||||||
|
width: "minmax(120px, 0.8fr)",
|
||||||
|
},
|
||||||
{ key: "target_rtp_percent", label: "目标 RTP", render: (item) => formatPercent(item.target_rtp_percent) },
|
{ key: "target_rtp_percent", label: "目标 RTP", render: (item) => formatPercent(item.target_rtp_percent) },
|
||||||
{ key: "pool_rate_percent", label: "公共奖池", render: (item) => formatPercent(item.pool_rate_percent) },
|
{ key: "pool_rate_percent", label: "公共奖池", render: (item) => formatPercent(item.pool_rate_percent) },
|
||||||
{
|
{
|
||||||
@ -159,13 +212,14 @@ function buildColumns({ canCredit, canDebit, canUpdate, disabled, onCredit, onDe
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (canUpdate || canCredit || canDebit) {
|
if (canUpdate || canCredit || canDebit || hasExperimentEntry) {
|
||||||
columns.push({
|
columns.push({
|
||||||
fixed: "right",
|
fixed: "right",
|
||||||
key: "actions",
|
key: "actions",
|
||||||
label: "操作",
|
label: "操作",
|
||||||
render: (item) => {
|
render: (item) => {
|
||||||
const identity = `${item.app_code} / ${item.pool_id} / ${item.strategy_version}`;
|
const identity = `${item.app_code} / ${item.pool_id} / ${item.strategy_version}`;
|
||||||
|
const hasActiveExperiment = Boolean(item.active_experiment);
|
||||||
const actionPool = item.pool_balance || {
|
const actionPool = item.pool_balance || {
|
||||||
app_code: item.app_code,
|
app_code: item.app_code,
|
||||||
pool_id: item.pool_id,
|
pool_id: item.pool_id,
|
||||||
@ -184,17 +238,47 @@ function buildColumns({ canCredit, canDebit, canUpdate, disabled, onCredit, onDe
|
|||||||
{canUpdate ? (
|
{canUpdate ? (
|
||||||
<Button
|
<Button
|
||||||
aria-label={`${item.is_default ? "发布配置" : "新增版本"} ${identity}`}
|
aria-label={`${item.is_default ? "发布配置" : "新增版本"} ${identity}`}
|
||||||
disabled={saving}
|
// 活跃实验独占该奖池的版本发布(owner 对常规 PUT 返回 409);
|
||||||
|
// 前端直接禁用入口并说明原因,而不是让运营提交后才看到冲突报错。
|
||||||
|
disabled={saving || hasActiveExperiment}
|
||||||
size="small"
|
size="small"
|
||||||
|
title={hasActiveExperiment ? "实验进行中,结束实验后才能发布" : undefined}
|
||||||
onClick={() => onEdit(item)}
|
onClick={() => onEdit(item)}
|
||||||
>
|
>
|
||||||
{item.is_default ? "发布配置" : "新增版本"}
|
{item.is_default ? "发布配置" : "新增版本"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{hasActiveExperiment ? (
|
||||||
|
<Button
|
||||||
|
aria-label={`实验详情 ${identity}`}
|
||||||
|
disabled={saving}
|
||||||
|
size="small"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => onManageExperiment(item.active_experiment)}
|
||||||
|
>
|
||||||
|
实验详情
|
||||||
|
</Button>
|
||||||
|
) : canExperiment && item.enabled && !item.is_default ? (
|
||||||
|
// 只有已启用的已发布配置才能作为对照组开实验;默认草稿/停用行没有可对照的线上版本。
|
||||||
|
<Button
|
||||||
|
aria-label={`创建 AB 实验 ${identity}`}
|
||||||
|
disabled={saving}
|
||||||
|
size="small"
|
||||||
|
onClick={() => onCreateExperiment(item)}
|
||||||
|
>
|
||||||
|
创建 AB 实验
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
width: `${(canCredit ? 110 : 0) + (canDebit ? 110 : 0) + (canUpdate ? 110 : 0) + 20}px`,
|
width: `${
|
||||||
|
(canCredit ? 110 : 0) +
|
||||||
|
(canDebit ? 110 : 0) +
|
||||||
|
(canUpdate ? 110 : 0) +
|
||||||
|
(hasExperimentEntry ? 128 : 0) +
|
||||||
|
20
|
||||||
|
}px`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,12 +13,23 @@ import {
|
|||||||
stageSummary,
|
stageSummary,
|
||||||
validateLuckyGiftForm,
|
validateLuckyGiftForm,
|
||||||
} from "../configForm.js";
|
} from "../configForm.js";
|
||||||
|
import {
|
||||||
|
buildExperimentCreateRequest,
|
||||||
|
experimentDraft,
|
||||||
|
formatTrafficPercent,
|
||||||
|
validateExperimentDraft,
|
||||||
|
} from "../experimentForm.js";
|
||||||
import { formatNumber, formatPercent } from "../format.js";
|
import { formatNumber, formatPercent } from "../format.js";
|
||||||
import { LuckyGiftConfigSections } from "./LuckyGiftConfigSections.jsx";
|
import { LuckyGiftConfigSections } from "./LuckyGiftConfigSections.jsx";
|
||||||
|
import { LuckyGiftExperimentFields } from "./LuckyGiftExperimentFields.jsx";
|
||||||
import { LuckyGiftStageDesigner } from "./LuckyGiftStageDesigner.jsx";
|
import { LuckyGiftStageDesigner } from "./LuckyGiftStageDesigner.jsx";
|
||||||
|
|
||||||
export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) {
|
export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) {
|
||||||
const [form, setForm] = useState(() => configToForm(config));
|
const isExperiment = mode === "experiment";
|
||||||
|
// 实验组配置由 owner 原子发布并立即参与分流开奖,enabled 强制 true 且开关锁定;
|
||||||
|
// 以当前行配置为初始值,运营在其上修改出实验组版本。
|
||||||
|
const [form, setForm] = useState(() => configToForm(isExperiment ? { ...config, enabled: true } : config));
|
||||||
|
const [experiment, setExperiment] = useState(experimentDraft);
|
||||||
const [activeStage, setActiveStage] = useState("novice");
|
const [activeStage, setActiveStage] = useState("novice");
|
||||||
const [validationErrors, setValidationErrors] = useState([]);
|
const [validationErrors, setValidationErrors] = useState([]);
|
||||||
const validationRef = useRef(null);
|
const validationRef = useRef(null);
|
||||||
@ -42,7 +53,9 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
|||||||
setForm((current) => {
|
setForm((current) => {
|
||||||
const next = { ...current, [field]: value };
|
const next = { ...current, [field]: value };
|
||||||
if (field === "strategy_version" && value === "dynamic_v3" && current.strategy_version !== "dynamic_v3") {
|
if (field === "strategy_version" && value === "dynamic_v3" && current.strategy_version !== "dynamic_v3") {
|
||||||
return applyDynamicStrategyDefaults(next);
|
const dynamicDraft = applyDynamicStrategyDefaults(next);
|
||||||
|
// 常规模式安全草稿保持停用;实验模式下实验组必须直接可运行,启用态不允许被安全草稿覆盖回停用。
|
||||||
|
return isExperiment ? { ...dynamicDraft, enabled: true } : dynamicDraft;
|
||||||
}
|
}
|
||||||
// 目标 RTP 决定各奖档概率的闭合解,改动后全部阶段重算,避免展示的概率和将要发布的值不一致。
|
// 目标 RTP 决定各奖档概率的闭合解,改动后全部阶段重算,避免展示的概率和将要发布的值不一致。
|
||||||
return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next;
|
return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next;
|
||||||
@ -118,18 +131,38 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateExperimentField = (field, value) => {
|
||||||
|
setValidationErrors([]);
|
||||||
|
setExperiment((current) => ({ ...current, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = (event) => {
|
const handleSubmit = (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const errors = validateLuckyGiftForm(form);
|
// 实验模式即使开关被绕过也按启用校验:提交的实验组配置必须直接可运行,
|
||||||
|
// 校验口径与最终 payload 完全一致,避免“校验通过、发布被拒”的错位。
|
||||||
|
const submittedForm = isExperiment ? { ...form, enabled: true } : form;
|
||||||
|
const errors = isExperiment
|
||||||
|
? [...validateExperimentDraft(experiment), ...validateLuckyGiftForm(submittedForm)]
|
||||||
|
: validateLuckyGiftForm(submittedForm);
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
setValidationErrors(errors);
|
setValidationErrors(errors);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isExperiment) {
|
||||||
|
onSubmit(buildExperimentCreateRequest(experiment, formToConfig(config, submittedForm)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
onSubmit(formToConfig(config, form));
|
onSubmit(formToConfig(config, form));
|
||||||
};
|
};
|
||||||
|
|
||||||
const isCreate = mode === "create";
|
const isCreate = mode === "create";
|
||||||
const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本";
|
const title = isExperiment
|
||||||
|
? "创建 AB 实验"
|
||||||
|
: isCreate
|
||||||
|
? "添加幸运礼物配置"
|
||||||
|
: config.is_default
|
||||||
|
? "发布幸运礼物配置"
|
||||||
|
: "新增幸运礼物版本";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@ -146,7 +179,11 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
|||||||
<span>{title}</span>
|
<span>{title}</span>
|
||||||
<small>
|
<small>
|
||||||
{form.app_code || "-"} / {form.pool_id || "default"} /{" "}
|
{form.app_code || "-"} / {form.pool_id || "default"} /{" "}
|
||||||
{config.is_default || isCreate ? "默认草稿" : `当前版本 v${config.rule_version ?? "-"}`}
|
{isExperiment
|
||||||
|
? `对照组为当前版本 v${config.rule_version ?? "-"},本表单发布为实验组新版本`
|
||||||
|
: config.is_default || isCreate
|
||||||
|
? "默认草稿"
|
||||||
|
: `当前版本 v${config.rule_version ?? "-"}`}
|
||||||
</small>
|
</small>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<dl className="ops-config-summary" aria-label="配置摘要">
|
<dl className="ops-config-summary" aria-label="配置摘要">
|
||||||
@ -166,7 +203,16 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
|||||||
label={form.strategy_version === "dynamic_v3" ? "每轮观察流水" : "V2 窗口配置"}
|
label={form.strategy_version === "dynamic_v3" ? "每轮观察流水" : "V2 窗口配置"}
|
||||||
value={formatNumber(Number(form.settlement_window_wager))}
|
value={formatNumber(Number(form.settlement_window_wager))}
|
||||||
/>
|
/>
|
||||||
<ConfigMetric label="发布状态" value={form.enabled ? "启用" : "停用"} />
|
<ConfigMetric
|
||||||
|
label={isExperiment ? "实验组流量" : "发布状态"}
|
||||||
|
value={
|
||||||
|
isExperiment
|
||||||
|
? formatTrafficPercent(Number(experiment.trafficPercent))
|
||||||
|
: form.enabled
|
||||||
|
? "启用"
|
||||||
|
: "停用"
|
||||||
|
}
|
||||||
|
/>
|
||||||
</dl>
|
</dl>
|
||||||
<DialogContent className="ops-config-dialog-content" dividers>
|
<DialogContent className="ops-config-dialog-content" dividers>
|
||||||
{validationErrors.length ? (
|
{validationErrors.length ? (
|
||||||
@ -177,7 +223,7 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
|||||||
role="alert"
|
role="alert"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<strong>配置不能发布</strong>
|
<strong>{isExperiment ? "实验不能创建" : "配置不能发布"}</strong>
|
||||||
<ul>
|
<ul>
|
||||||
{validationErrors.map((message) => (
|
{validationErrors.map((message) => (
|
||||||
<li key={message}>{message}</li>
|
<li key={message}>{message}</li>
|
||||||
@ -186,9 +232,18 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{isExperiment ? (
|
||||||
|
<LuckyGiftExperimentFields
|
||||||
|
disabled={saving}
|
||||||
|
draft={experiment}
|
||||||
|
onChange={updateExperimentField}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="ops-config-layout">
|
<div className="ops-config-layout">
|
||||||
<LuckyGiftConfigSections
|
<LuckyGiftConfigSections
|
||||||
appOptions={appOptions}
|
appOptions={appOptions}
|
||||||
|
experimentMode={isExperiment}
|
||||||
form={form}
|
form={form}
|
||||||
isCreate={isCreate}
|
isCreate={isCreate}
|
||||||
onChange={updateField}
|
onChange={updateField}
|
||||||
@ -213,7 +268,7 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
|||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={saving} type="submit" variant="primary">
|
<Button disabled={saving} type="submit" variant="primary">
|
||||||
{saving ? "保存中" : "保存配置"}
|
{saving ? (isExperiment ? "创建中" : "保存中") : isExperiment ? "创建实验" : "保存配置"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -16,7 +16,7 @@ const DYNAMIC_SECTIONS = [
|
|||||||
["limits", "限额"],
|
["limits", "限额"],
|
||||||
];
|
];
|
||||||
|
|
||||||
export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }) {
|
export function LuckyGiftConfigSections({ appOptions, experimentMode = false, form, isCreate, onChange }) {
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
const sections = useMemo(
|
const sections = useMemo(
|
||||||
() => (form.strategy_version === "dynamic_v3" ? [...BASE_SECTIONS, ...DYNAMIC_SECTIONS] : BASE_SECTIONS),
|
() => (form.strategy_version === "dynamic_v3" ? [...BASE_SECTIONS, ...DYNAMIC_SECTIONS] : BASE_SECTIONS),
|
||||||
@ -160,6 +160,9 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<TextField
|
<TextField
|
||||||
|
// 实验绑定当前行的 app+pool(对照组由该奖池最新启用版本担任);
|
||||||
|
// 实验模式下改奖池 ID 会把实验开到另一个奖池家族,必须锁定。
|
||||||
|
disabled={experimentMode}
|
||||||
fullWidth
|
fullWidth
|
||||||
required
|
required
|
||||||
label={<LuckyGiftFieldLabel field="pool_id" label="奖池" />}
|
label={<LuckyGiftFieldLabel field="pool_id" label="奖池" />}
|
||||||
@ -168,6 +171,9 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
|
|||||||
onChange={(event) => onChange("pool_id", event.target.value)}
|
onChange={(event) => onChange("pool_id", event.target.value)}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
|
// 实验两组必须同策略族(owner 强制校验):分组会随放量/白名单漂移,
|
||||||
|
// 混合策略会击穿 room saga 对 dynamic_v3 的扣费前拦截。策略迁移走常规发布。
|
||||||
|
disabled={experimentMode}
|
||||||
fullWidth
|
fullWidth
|
||||||
label={<LuckyGiftFieldLabel field="strategy_version" label="策略版本" />}
|
label={<LuckyGiftFieldLabel field="strategy_version" label="策略版本" />}
|
||||||
select
|
select
|
||||||
@ -183,6 +189,9 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
|
|||||||
control={
|
control={
|
||||||
<AdminSwitch
|
<AdminSwitch
|
||||||
checked={form.enabled}
|
checked={form.enabled}
|
||||||
|
// 实验组配置发布后立即参与分流开奖,owner 拒绝停用的实验组;
|
||||||
|
// 创建实验时锁定启用开关,避免运营误以为可以先停用再放量。
|
||||||
|
disabled={experimentMode}
|
||||||
label="发布状态"
|
label="发布状态"
|
||||||
onChange={(event) => onChange("enabled", event.target.checked)}
|
onChange={(event) => onChange("enabled", event.target.checked)}
|
||||||
/>
|
/>
|
||||||
@ -190,7 +199,13 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
|
|||||||
label={
|
label={
|
||||||
<LuckyGiftFieldLabel
|
<LuckyGiftFieldLabel
|
||||||
field="enabled"
|
field="enabled"
|
||||||
label={form.enabled ? "发布后立即启用" : "发布后保持停用"}
|
label={
|
||||||
|
experimentMode
|
||||||
|
? "实验组配置直接参与开奖"
|
||||||
|
: form.enabled
|
||||||
|
? "发布后立即启用"
|
||||||
|
: "发布后保持停用"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
62
ops-center/src/components/LuckyGiftExperimentFields.jsx
Normal file
62
ops-center/src/components/LuckyGiftExperimentFields.jsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import Autocomplete from "@mui/material/Autocomplete";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { normalizeWhitelist } from "../experimentForm.js";
|
||||||
|
import { LuckyGiftFieldLabel } from "./LuckyGiftFieldHelp.jsx";
|
||||||
|
|
||||||
|
// 创建 AB 实验时叠加在配置表单之上的实验字段区。配置表单本身由 LuckyGiftConfigSections /
|
||||||
|
// LuckyGiftStageDesigner 复用,这里只承载实验身份与分流参数,不参与配置字段的校验。
|
||||||
|
export function LuckyGiftExperimentFields({ disabled = false, draft, onChange }) {
|
||||||
|
return (
|
||||||
|
<section aria-label="实验设置" className="ops-experiment-setup">
|
||||||
|
<div className="ops-experiment-setup__head">
|
||||||
|
<h3>实验设置</h3>
|
||||||
|
<span>
|
||||||
|
创建实验会把下方配置原子发布为实验组新版本,当前最新启用版本自动成为对照组;按用户 ID
|
||||||
|
哈希分流,放量单调(调大不会把已进入实验组的用户踢回)。实验进行中,该奖池的常规发布会被拒绝。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="ops-form-grid ops-experiment-setup__grid">
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
fullWidth
|
||||||
|
label={<LuckyGiftFieldLabel field="experiment_name" label="实验名称" />}
|
||||||
|
required
|
||||||
|
size="small"
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(event) => onChange("name", event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
fullWidth
|
||||||
|
helperText="0 表示只有白名单用户进入实验组(灰度验证)"
|
||||||
|
label={<LuckyGiftFieldLabel field="experiment_traffic_percent" label="实验组流量 (%)" />}
|
||||||
|
required
|
||||||
|
size="small"
|
||||||
|
slotProps={{ htmlInput: { inputMode: "decimal", max: 100, min: 0, step: "0.01" } }}
|
||||||
|
type="number"
|
||||||
|
value={draft.trafficPercent}
|
||||||
|
onChange={(event) => onChange("trafficPercent", event.target.value)}
|
||||||
|
/>
|
||||||
|
{/* 白名单是任意用户 ID,没有候选集,用 freeSolo 多值输入承载;提交时全部作为
|
||||||
|
arm=treatment 的 override,与 MultiValueAutocomplete 的多选交互保持一致。 */}
|
||||||
|
<Autocomplete
|
||||||
|
className="ops-form-grid__wide"
|
||||||
|
disabled={disabled}
|
||||||
|
freeSolo
|
||||||
|
multiple
|
||||||
|
options={[]}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
helperText="输入用户 ID 后回车添加;创建时全部固定进入实验组"
|
||||||
|
label={<LuckyGiftFieldLabel field="experiment_whitelist" label="白名单用户 ID" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
size="small"
|
||||||
|
value={draft.whitelist}
|
||||||
|
onChange={(_, next) => onChange("whitelist", normalizeWhitelist(next))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
329
ops-center/src/components/LuckyGiftExperimentManageDialog.jsx
Normal file
329
ops-center/src/components/LuckyGiftExperimentManageDialog.jsx
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||||
|
import {
|
||||||
|
EXPERIMENT_ARM_LABELS,
|
||||||
|
EXPERIMENT_ARMS,
|
||||||
|
experimentArmStat,
|
||||||
|
experimentStatusBadge,
|
||||||
|
formatTrafficPercent,
|
||||||
|
isActiveExperiment,
|
||||||
|
trafficPercentError,
|
||||||
|
} from "../experimentForm.js";
|
||||||
|
import { formatNumber, formatPercent } from "../format.js";
|
||||||
|
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
||||||
|
|
||||||
|
// AB 实验管理弹窗:展示两组统计并承载放量、暂停/恢复、白名单、结束实验四类操作。
|
||||||
|
// 所有请求都由 OpsCenterApp 的回调执行(失败 toast 保留弹窗),这里只负责输入校验与二次确认。
|
||||||
|
export function LuckyGiftExperimentManageDialog({
|
||||||
|
busy,
|
||||||
|
canManage,
|
||||||
|
experiment,
|
||||||
|
onAdjustTraffic,
|
||||||
|
onClose,
|
||||||
|
onEnd,
|
||||||
|
onRemoveOverride,
|
||||||
|
onSetStatus,
|
||||||
|
onUpsertOverride,
|
||||||
|
statsLoading,
|
||||||
|
}) {
|
||||||
|
const [trafficInput, setTrafficInput] = useState(() => String(experiment.treatment_traffic_percent ?? ""));
|
||||||
|
const [trafficError, setTrafficError] = useState("");
|
||||||
|
const [overrideUserKey, setOverrideUserKey] = useState("");
|
||||||
|
const [overrideArm, setOverrideArm] = useState("treatment");
|
||||||
|
const [overrideError, setOverrideError] = useState("");
|
||||||
|
const [endConfirmArm, setEndConfirmArm] = useState("");
|
||||||
|
|
||||||
|
const paused = experiment.status === "paused";
|
||||||
|
const completed = experiment.status === "completed";
|
||||||
|
// completed 只读:历史入口打开时仅展示分组数据与获胜结果,不再提供任何写操作。
|
||||||
|
const editable = canManage && isActiveExperiment(experiment);
|
||||||
|
const badge = experimentStatusBadge(experiment);
|
||||||
|
const overrides = Array.isArray(experiment.overrides) ? experiment.overrides : [];
|
||||||
|
|
||||||
|
const submitTraffic = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const error = trafficPercentError(trafficInput);
|
||||||
|
setTrafficError(error);
|
||||||
|
if (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onAdjustTraffic(Number(trafficInput));
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitOverride = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const userKey = overrideUserKey.trim();
|
||||||
|
if (!userKey) {
|
||||||
|
setOverrideError("请输入用户 ID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOverrideError("");
|
||||||
|
setOverrideUserKey("");
|
||||||
|
onUpsertOverride({ arm: overrideArm, userKey });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
aria-labelledby="ops-experiment-manage-title"
|
||||||
|
fullWidth
|
||||||
|
maxWidth="md"
|
||||||
|
open
|
||||||
|
scroll="paper"
|
||||||
|
onClose={busy ? undefined : onClose}
|
||||||
|
>
|
||||||
|
<DialogTitle className="ops-experiment-title" id="ops-experiment-manage-title">
|
||||||
|
<span>{completed ? "实验详情" : "实验管理"}</span>
|
||||||
|
<small>{experiment.name || experiment.experiment_id}</small>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent className="ops-experiment-content" dividers>
|
||||||
|
<div className="ops-experiment-metrics">
|
||||||
|
<ExperimentMetric label="应用" value={experiment.app_code} />
|
||||||
|
<ExperimentMetric label="奖池" value={experiment.pool_id} />
|
||||||
|
<ExperimentMetric
|
||||||
|
label="状态"
|
||||||
|
value={badge ? <OpsStatusBadge label={badge.label} tone={badge.tone} /> : "-"}
|
||||||
|
/>
|
||||||
|
<ExperimentMetric label="开始时间" value={<TimeText value={experiment.started_at_ms} />} />
|
||||||
|
<ExperimentMetric
|
||||||
|
label="实验组流量"
|
||||||
|
value={formatTrafficPercent(experiment.treatment_traffic_percent)}
|
||||||
|
/>
|
||||||
|
<ExperimentMetric label="白名单" value={`${overrides.length} 人`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{paused ? (
|
||||||
|
<div className="ops-pool-adjustment-note" role="status">
|
||||||
|
实验已暂停:全部流量(含白名单)临时回到对照组,恢复后回到原分组。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{completed ? (
|
||||||
|
<div className="ops-pool-adjustment-note" role="status">
|
||||||
|
实验已结束:{EXPERIMENT_ARM_LABELS[experiment.winner_arm] || "获胜方"}获胜,配置已重新发布为 v
|
||||||
|
{experiment.final_rule_version || experiment.winner_rule_version || "-"} 并全量生效。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="ops-experiment-arms">
|
||||||
|
{EXPERIMENT_ARMS.map((arm) => (
|
||||||
|
<ExperimentArmCard
|
||||||
|
arm={arm}
|
||||||
|
busy={busy}
|
||||||
|
canEnd={editable}
|
||||||
|
experiment={experiment}
|
||||||
|
key={arm}
|
||||||
|
statsLoading={statsLoading}
|
||||||
|
onPickWinner={setEndConfirmArm}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* editable 才渲染确认层:结束成功后 status 变为 completed,确认层随之收起。 */}
|
||||||
|
{editable && endConfirmArm ? (
|
||||||
|
<div className="ops-experiment-confirm" role="alert">
|
||||||
|
<strong>确认以{EXPERIMENT_ARM_LABELS[endConfirmArm]}结束实验?</strong>
|
||||||
|
<p>
|
||||||
|
{EXPERIMENT_ARM_LABELS[endConfirmArm]}(v
|
||||||
|
{endConfirmArm === "control"
|
||||||
|
? experiment.control_rule_version
|
||||||
|
: experiment.treatment_rule_version}
|
||||||
|
)的配置将重新发布为新的最新版本并对全量用户生效;实验立即结束且不可恢复,之后配置页恢复常规发布。
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<Button disabled={busy} onClick={() => setEndConfirmArm("")}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={busy} variant="danger" onClick={() => onEnd(endConfirmArm)}>
|
||||||
|
确认结束并发布
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{editable ? (
|
||||||
|
<section aria-label="实验控制" className="ops-experiment-section">
|
||||||
|
<h3>放量与状态</h3>
|
||||||
|
<div className="ops-experiment-controls">
|
||||||
|
<form className="ops-experiment-inline-form" onSubmit={submitTraffic}>
|
||||||
|
<TextField
|
||||||
|
disabled={busy}
|
||||||
|
error={Boolean(trafficError)}
|
||||||
|
helperText={
|
||||||
|
trafficError ||
|
||||||
|
"0 表示仅白名单进入实验组;放量单调,调大不会把已在实验组的用户踢回"
|
||||||
|
}
|
||||||
|
label="实验组流量 (%)"
|
||||||
|
size="small"
|
||||||
|
slotProps={{ htmlInput: { inputMode: "decimal", max: 100, min: 0, step: "0.01" } }}
|
||||||
|
type="number"
|
||||||
|
value={trafficInput}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTrafficInput(event.target.value);
|
||||||
|
setTrafficError("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button disabled={busy} type="submit">
|
||||||
|
应用
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<div className="ops-experiment-inline-form">
|
||||||
|
<Button
|
||||||
|
disabled={busy}
|
||||||
|
variant={paused ? "success" : undefined}
|
||||||
|
onClick={() => onSetStatus(paused ? "running" : "paused")}
|
||||||
|
>
|
||||||
|
{paused ? "恢复实验" : "暂停实验"}
|
||||||
|
</Button>
|
||||||
|
<span className="ops-field-note">
|
||||||
|
{paused
|
||||||
|
? "恢复后所有用户回到暂停前的分组。"
|
||||||
|
: "暂停后全部流量(含白名单)回到对照组。"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section aria-label="白名单用户" className="ops-experiment-section ops-experiment-overrides">
|
||||||
|
<h3>白名单用户</h3>
|
||||||
|
<p className="ops-field-note">
|
||||||
|
白名单用户不参与哈希分流,固定进入指定分组;流量为 0 时只有白名单进入实验组。
|
||||||
|
</p>
|
||||||
|
{overrides.length ? (
|
||||||
|
<ul>
|
||||||
|
{overrides.map((override) => (
|
||||||
|
<li key={override.user_key}>
|
||||||
|
<code>{override.user_key}</code>
|
||||||
|
<span>{EXPERIMENT_ARM_LABELS[override.arm] || override.arm}</span>
|
||||||
|
{editable ? (
|
||||||
|
<Button
|
||||||
|
aria-label={`移除白名单 ${override.user_key}`}
|
||||||
|
disabled={busy}
|
||||||
|
size="small"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => onRemoveOverride(override.user_key)}
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="ops-field-note">暂无白名单用户</p>
|
||||||
|
)}
|
||||||
|
{editable ? (
|
||||||
|
<form className="ops-experiment-inline-form" onSubmit={submitOverride}>
|
||||||
|
<TextField
|
||||||
|
disabled={busy}
|
||||||
|
error={Boolean(overrideError)}
|
||||||
|
helperText={overrideError}
|
||||||
|
label="用户 ID"
|
||||||
|
size="small"
|
||||||
|
value={overrideUserKey}
|
||||||
|
onChange={(event) => {
|
||||||
|
setOverrideUserKey(event.target.value);
|
||||||
|
setOverrideError("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={busy}
|
||||||
|
label="分组"
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
value={overrideArm}
|
||||||
|
onChange={(event) => setOverrideArm(event.target.value)}
|
||||||
|
>
|
||||||
|
{EXPERIMENT_ARMS.map((arm) => (
|
||||||
|
<MenuItem key={arm} value={arm}>
|
||||||
|
{EXPERIMENT_ARM_LABELS[arm]}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<Button disabled={busy} type="submit">
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button disabled={busy} onClick={onClose}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExperimentArmCard({ arm, busy, canEnd, experiment, onPickWinner, statsLoading }) {
|
||||||
|
const stat = experimentArmStat(experiment, arm);
|
||||||
|
const ruleVersion = arm === "control" ? experiment.control_rule_version : experiment.treatment_rule_version;
|
||||||
|
const isWinner = experiment.status === "completed" && experiment.winner_arm === arm;
|
||||||
|
// 统计尚未返回时明确展示“加载中”,避免运营把未加载误读成 0(0 消费也是有效实验结论)。
|
||||||
|
const statValue = (value, format) => (stat ? format(value) : statsLoading ? "加载中" : "-");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label={EXPERIMENT_ARM_LABELS[arm]}
|
||||||
|
className={["ops-experiment-arm", isWinner ? "ops-experiment-arm--winner" : ""].filter(Boolean).join(" ")}
|
||||||
|
>
|
||||||
|
<header className="ops-experiment-arm__head">
|
||||||
|
<strong>{EXPERIMENT_ARM_LABELS[arm]}</strong>
|
||||||
|
<span>{ruleVersion ? `v${ruleVersion}` : "-"}</span>
|
||||||
|
{isWinner ? <OpsStatusBadge label="获胜" tone="succeeded" /> : null}
|
||||||
|
</header>
|
||||||
|
<dl className="ops-experiment-stats">
|
||||||
|
<div>
|
||||||
|
<dt>策略</dt>
|
||||||
|
<dd>{stat?.strategy_version || "-"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>抽奖次数</dt>
|
||||||
|
<dd>{statValue(stat?.total_draws, formatNumber)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>参与用户</dt>
|
||||||
|
<dd>{statValue(stat?.unique_users, formatNumber)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>消费金币</dt>
|
||||||
|
<dd>{statValue(stat?.total_spent_coins, formatNumber)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>返奖金币</dt>
|
||||||
|
<dd>{statValue(stat?.total_reward_coins, formatNumber)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>实际 RTP</dt>
|
||||||
|
<dd>{statValue(stat?.actual_rtp_percent, formatPercent)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{canEnd ? (
|
||||||
|
<Button
|
||||||
|
aria-label={`选择${EXPERIMENT_ARM_LABELS[arm]}为最终版本`}
|
||||||
|
disabled={busy}
|
||||||
|
size="small"
|
||||||
|
onClick={() => onPickWinner(arm)}
|
||||||
|
>
|
||||||
|
选择此组为最终版本
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExperimentMetric({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div className="ops-config-metric">
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{value || "-"}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -237,6 +237,21 @@ const FIELD_HELP = {
|
|||||||
constraint:
|
constraint:
|
||||||
"10 倍及以上必须勾选才能发布。不要把它当成独立的中奖开关;V3 的高低余额变化由上方两组中奖权重控制。",
|
"10 倍及以上必须勾选才能发布。不要把它当成独立的中奖开关;V3 的高低余额变化由上方两组中奖权重控制。",
|
||||||
},
|
},
|
||||||
|
experiment_name: {
|
||||||
|
summary: "标识这次 AB 实验,显示在配置列表徽章和实验管理弹窗中,方便区分同一奖池的历史实验。",
|
||||||
|
adjust: "只影响展示,不影响分流;创建后不可修改。",
|
||||||
|
constraint: "必填;建议包含实验目的,例如“提高RTP到98-0715”。",
|
||||||
|
},
|
||||||
|
experiment_traffic_percent: {
|
||||||
|
summary: "控制多大比例的用户进入实验组,按用户 ID 哈希分流,其余用户继续使用对照组(当前最新启用版本)。",
|
||||||
|
adjust: "实验期间可随时调整且放量单调:调大不会把已进入实验组的用户踢回对照组。",
|
||||||
|
constraint: "0-100;填 0 表示只有白名单用户进入实验组(灰度验证)。暂停时全部流量(含白名单)回到对照组。",
|
||||||
|
},
|
||||||
|
experiment_whitelist: {
|
||||||
|
summary: "白名单用户不参与哈希分流,创建时全部固定进入实验组,用于放量前的内部灰度验证。",
|
||||||
|
adjust: "实验进行中可在实验管理里增删白名单,或把某个用户指定到对照组。",
|
||||||
|
constraint: "每项为用户 ID 字符串;暂停期间白名单用户同样回到对照组,恢复后回到原分组。",
|
||||||
|
},
|
||||||
tier_enabled: {
|
tier_enabled: {
|
||||||
summary: "决定这一档是否参加当前阶段的普通基础抽奖。关闭后不会从普通奖档中选中。",
|
summary: "决定这一档是否参加当前阶段的普通基础抽奖。关闭后不会从普通奖档中选中。",
|
||||||
adjust: "关闭高倍率档会降低大额结果种类;重新开启后系统会重新计算整组概率。",
|
adjust: "关闭高倍率档会降低大额结果种类;重新开启后系统会重新计算整组概率。",
|
||||||
|
|||||||
128
ops-center/src/experimentForm.js
Normal file
128
ops-center/src/experimentForm.js
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
94
ops-center/src/experimentForm.test.js
Normal file
94
ops-center/src/experimentForm.test.js
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
activeExperimentsByPool,
|
||||||
|
buildExperimentCreateRequest,
|
||||||
|
experimentDraft,
|
||||||
|
experimentPoolFamilyKey,
|
||||||
|
experimentStatusBadge,
|
||||||
|
formatTrafficPercent,
|
||||||
|
normalizeWhitelist,
|
||||||
|
trafficPercentError,
|
||||||
|
validateExperimentDraft,
|
||||||
|
} from "./experimentForm.js";
|
||||||
|
|
||||||
|
describe("ops center lucky gift ab experiment form", () => {
|
||||||
|
test("requires a name and a 0-100 traffic percent", () => {
|
||||||
|
expect(validateExperimentDraft(experimentDraft())).toEqual(["请填写实验名称"]);
|
||||||
|
expect(validateExperimentDraft({ name: "rtp-98", trafficPercent: "5" })).toEqual([]);
|
||||||
|
expect(validateExperimentDraft({ name: " ", trafficPercent: "101" })).toEqual([
|
||||||
|
"请填写实验名称",
|
||||||
|
"实验组流量必须是 0-100 的百分比(0 表示仅白名单用户进入实验组)",
|
||||||
|
]);
|
||||||
|
// 0 是合法灰度值:只有白名单用户进入实验组,不能按“必须大于 0”拦截。
|
||||||
|
expect(validateExperimentDraft({ name: "rtp-98", trafficPercent: "0" })).toEqual([]);
|
||||||
|
expect(trafficPercentError("")).not.toBe("");
|
||||||
|
expect(trafficPercentError("-1")).not.toBe("");
|
||||||
|
expect(trafficPercentError("abc")).not.toBe("");
|
||||||
|
expect(trafficPercentError("100")).toBe("");
|
||||||
|
expect(trafficPercentError(0)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes whitelist entries by trimming and deduplicating", () => {
|
||||||
|
expect(normalizeWhitelist([" 1001 ", "1001", "", "ext-9", null, " "])).toEqual(["1001", "ext-9"]);
|
||||||
|
expect(normalizeWhitelist(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("builds a create request with treatment overrides and a runnable config", () => {
|
||||||
|
const request = buildExperimentCreateRequest(
|
||||||
|
{ name: " 提高RTP ", trafficPercent: "12.5", whitelist: ["1001", " 1001", "ext-9"] },
|
||||||
|
{ app_code: "lalu", enabled: false, pool_id: "lucky" },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 实验组配置必须直接可运行:enabled 强制 true,白名单全部作为 arm=treatment 的 override。
|
||||||
|
expect(request).toEqual({
|
||||||
|
config: { app_code: "lalu", enabled: true, pool_id: "lucky" },
|
||||||
|
name: "提高RTP",
|
||||||
|
overrides: [
|
||||||
|
{ arm: "treatment", user_key: "1001" },
|
||||||
|
{ arm: "treatment", user_key: "ext-9" },
|
||||||
|
],
|
||||||
|
treatment_traffic_percent: 12.5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps only running and paused experiments as pool level publishing locks", () => {
|
||||||
|
const running = {
|
||||||
|
app_code: "lalu",
|
||||||
|
experiment_id: "e2",
|
||||||
|
pool_id: "lucky",
|
||||||
|
started_at_ms: 200,
|
||||||
|
status: "running",
|
||||||
|
treatment_traffic_percent: 5,
|
||||||
|
};
|
||||||
|
const byPool = activeExperimentsByPool([
|
||||||
|
{ app_code: "lalu", experiment_id: "e1", pool_id: "lucky", started_at_ms: 100, status: "completed" },
|
||||||
|
running,
|
||||||
|
{ app_code: "lalu", experiment_id: "e3", pool_id: "super_lucky", started_at_ms: 50, status: "paused" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(byPool.get("lalu:lucky")).toBe(running);
|
||||||
|
// paused 也算活跃:暂停只是流量临时回到对照组,实验仍独占该奖池的发布。
|
||||||
|
expect(byPool.get("lalu:super_lucky")?.experiment_id).toBe("e3");
|
||||||
|
// family key 不含 strategy_version:同一奖池的 fixed_v2/dynamic_v3 配置行共享同一实验封锁。
|
||||||
|
expect(experimentPoolFamilyKey({ appCode: "LALU", poolId: "lucky", strategy_version: "fixed_v2" })).toBe(
|
||||||
|
"lalu:lucky",
|
||||||
|
);
|
||||||
|
expect(experimentPoolFamilyKey({ app_code: "yumi" })).toBe("yumi:default");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps experiment status to shared status badges with trimmed traffic percent", () => {
|
||||||
|
expect(experimentStatusBadge({ status: "running", treatment_traffic_percent: 5 })).toEqual({
|
||||||
|
label: "实验中 5%",
|
||||||
|
tone: "warning",
|
||||||
|
});
|
||||||
|
expect(experimentStatusBadge({ status: "paused", treatment_traffic_percent: 5 })).toEqual({
|
||||||
|
label: "实验暂停",
|
||||||
|
tone: "warning",
|
||||||
|
});
|
||||||
|
expect(experimentStatusBadge({ status: "completed" })).toEqual({ label: "实验已结束", tone: "stopped" });
|
||||||
|
expect(experimentStatusBadge(null)).toBeNull();
|
||||||
|
expect(formatTrafficPercent(12.5)).toBe("12.5%");
|
||||||
|
expect(formatTrafficPercent("7.00")).toBe("7%");
|
||||||
|
expect(formatTrafficPercent("abc")).toBe("0%");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -801,6 +801,190 @@ body {
|
|||||||
padding: var(--space-2) var(--space-6);
|
padding: var(--space-2) var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- AB 实验 ---- */
|
||||||
|
|
||||||
|
/* 创建实验时叠加在配置表单顶部的实验字段区;固定高度不参与下方双栏布局的滚动。 */
|
||||||
|
.ops-experiment-setup {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-setup__head {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-setup__head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-setup__head span {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-setup__grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-title {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-title small {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-content {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding-top: var(--space-4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
row-gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-arms {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-arm {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-arm--winner {
|
||||||
|
border-color: var(--success-border);
|
||||||
|
background: var(--success-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-arm__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-arm__head strong {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-arm__head span {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-stats dt {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-stats dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-section h3 {
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-controls {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-inline-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-overrides ul {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin: var(--space-2) 0 var(--space-3);
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-overrides li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
border: 1px solid var(--row-border, var(--border));
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-overrides li code {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-overrides li span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 结束实验的二次确认层:不可回滚的全量发布动作,用 danger 面板与普通操作区分。 */
|
||||||
|
.ops-experiment-confirm {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
border: 1px solid var(--danger-border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--danger-surface);
|
||||||
|
color: var(--danger);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-confirm p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-confirm div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- 响应式 ---- */
|
/* ---- 响应式 ---- */
|
||||||
|
|
||||||
@media (max-width: 1279px) {
|
@media (max-width: 1279px) {
|
||||||
@ -925,6 +1109,15 @@ body {
|
|||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ops-experiment-setup__grid,
|
||||||
|
.ops-experiment-arms {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-experiment-metrics {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.ops-jackpot-flow {
|
.ops-jackpot-flow {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -194,6 +194,7 @@ export const PERMISSIONS = {
|
|||||||
sevenDayCheckInUpdate: "seven-day-checkin:update",
|
sevenDayCheckInUpdate: "seven-day-checkin:update",
|
||||||
luckyGiftView: "lucky-gift:view",
|
luckyGiftView: "lucky-gift:view",
|
||||||
luckyGiftUpdate: "lucky-gift:update",
|
luckyGiftUpdate: "lucky-gift:update",
|
||||||
|
luckyGiftExperiment: "lucky-gift:experiment",
|
||||||
luckyGiftPoolCredit: "lucky-gift:pool-credit",
|
luckyGiftPoolCredit: "lucky-gift:pool-credit",
|
||||||
luckyGiftPoolDebit: "lucky-gift:pool-debit",
|
luckyGiftPoolDebit: "lucky-gift:pool-debit",
|
||||||
wheelView: "wheel:view",
|
wheelView: "wheel:view",
|
||||||
|
|||||||
@ -59,6 +59,7 @@ export const API_OPERATIONS = {
|
|||||||
createGift: "createGift",
|
createGift: "createGift",
|
||||||
createH5Link: "createH5Link",
|
createH5Link: "createH5Link",
|
||||||
createHostAgencyPolicy: "createHostAgencyPolicy",
|
createHostAgencyPolicy: "createHostAgencyPolicy",
|
||||||
|
createLuckyGiftExperiment: "createLuckyGiftExperiment",
|
||||||
createManager: "createManager",
|
createManager: "createManager",
|
||||||
createMenu: "createMenu",
|
createMenu: "createMenu",
|
||||||
createPermission: "createPermission",
|
createPermission: "createPermission",
|
||||||
@ -123,6 +124,7 @@ export const API_OPERATIONS = {
|
|||||||
enableResource: "enableResource",
|
enableResource: "enableResource",
|
||||||
enableResourceGroup: "enableResourceGroup",
|
enableResourceGroup: "enableResourceGroup",
|
||||||
enableResourceShopItem: "enableResourceShopItem",
|
enableResourceShopItem: "enableResourceShopItem",
|
||||||
|
endLuckyGiftExperiment: "endLuckyGiftExperiment",
|
||||||
expireRoomRpsChallenge: "expireRoomRpsChallenge",
|
expireRoomRpsChallenge: "expireRoomRpsChallenge",
|
||||||
exportActivityTemplateData: "exportActivityTemplateData",
|
exportActivityTemplateData: "exportActivityTemplateData",
|
||||||
exportCoinSellerLedger: "exportCoinSellerLedger",
|
exportCoinSellerLedger: "exportCoinSellerLedger",
|
||||||
@ -236,6 +238,7 @@ export const API_OPERATIONS = {
|
|||||||
listLoginLogs: "listLoginLogs",
|
listLoginLogs: "listLoginLogs",
|
||||||
listLuckyGiftConfigs: "listLuckyGiftConfigs",
|
listLuckyGiftConfigs: "listLuckyGiftConfigs",
|
||||||
listLuckyGiftDraws: "listLuckyGiftDraws",
|
listLuckyGiftDraws: "listLuckyGiftDraws",
|
||||||
|
listLuckyGiftExperiments: "listLuckyGiftExperiments",
|
||||||
listLuckyGiftPoolBalances: "listLuckyGiftPoolBalances",
|
listLuckyGiftPoolBalances: "listLuckyGiftPoolBalances",
|
||||||
listManagers: "listManagers",
|
listManagers: "listManagers",
|
||||||
listOperationLogs: "listOperationLogs",
|
listOperationLogs: "listOperationLogs",
|
||||||
@ -333,6 +336,7 @@ export const API_OPERATIONS = {
|
|||||||
setDiceRobotStatus: "setDiceRobotStatus",
|
setDiceRobotStatus: "setDiceRobotStatus",
|
||||||
setGameStatus: "setGameStatus",
|
setGameStatus: "setGameStatus",
|
||||||
setGameWhitelistEnabled: "setGameWhitelistEnabled",
|
setGameWhitelistEnabled: "setGameWhitelistEnabled",
|
||||||
|
setLuckyGiftExperimentOverrides: "setLuckyGiftExperimentOverrides",
|
||||||
setPrettyIdStatus: "setPrettyIdStatus",
|
setPrettyIdStatus: "setPrettyIdStatus",
|
||||||
setTaskDefinitionStatus: "setTaskDefinitionStatus",
|
setTaskDefinitionStatus: "setTaskDefinitionStatus",
|
||||||
setThirdPartyPaymentMethodStatus: "setThirdPartyPaymentMethodStatus",
|
setThirdPartyPaymentMethodStatus: "setThirdPartyPaymentMethodStatus",
|
||||||
@ -366,6 +370,7 @@ export const API_OPERATIONS = {
|
|||||||
updateHostAgencyPolicy: "updateHostAgencyPolicy",
|
updateHostAgencyPolicy: "updateHostAgencyPolicy",
|
||||||
updateHumanRoomRobotConfig: "updateHumanRoomRobotConfig",
|
updateHumanRoomRobotConfig: "updateHumanRoomRobotConfig",
|
||||||
updateInviteActivityRewardConfig: "updateInviteActivityRewardConfig",
|
updateInviteActivityRewardConfig: "updateInviteActivityRewardConfig",
|
||||||
|
updateLuckyGiftExperiment: "updateLuckyGiftExperiment",
|
||||||
updateManager: "updateManager",
|
updateManager: "updateManager",
|
||||||
updateMenu: "updateMenu",
|
updateMenu: "updateMenu",
|
||||||
updateMenuVisible: "updateMenuVisible",
|
updateMenuVisible: "updateMenuVisible",
|
||||||
@ -738,6 +743,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "host-agency-policy:create",
|
permission: "host-agency-policy:create",
|
||||||
permissions: ["host-agency-policy:create"]
|
permissions: ["host-agency-policy:create"]
|
||||||
},
|
},
|
||||||
|
createLuckyGiftExperiment: {
|
||||||
|
method: "POST",
|
||||||
|
operationId: API_OPERATIONS.createLuckyGiftExperiment,
|
||||||
|
path: "/v1/admin/ops-center/lucky-gifts/experiments",
|
||||||
|
permission: "lucky-gift:experiment",
|
||||||
|
permissions: ["lucky-gift:experiment"]
|
||||||
|
},
|
||||||
createManager: {
|
createManager: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.createManager,
|
operationId: API_OPERATIONS.createManager,
|
||||||
@ -1186,6 +1198,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "resource-shop:update",
|
permission: "resource-shop:update",
|
||||||
permissions: ["resource-shop:update"]
|
permissions: ["resource-shop:update"]
|
||||||
},
|
},
|
||||||
|
endLuckyGiftExperiment: {
|
||||||
|
method: "POST",
|
||||||
|
operationId: API_OPERATIONS.endLuckyGiftExperiment,
|
||||||
|
path: "/v1/admin/ops-center/lucky-gifts/experiments/{experimentId}/end",
|
||||||
|
permission: "lucky-gift:experiment",
|
||||||
|
permissions: ["lucky-gift:experiment"]
|
||||||
|
},
|
||||||
expireRoomRpsChallenge: {
|
expireRoomRpsChallenge: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.expireRoomRpsChallenge,
|
operationId: API_OPERATIONS.expireRoomRpsChallenge,
|
||||||
@ -1975,6 +1994,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "lucky-gift:view",
|
permission: "lucky-gift:view",
|
||||||
permissions: ["lucky-gift:view"]
|
permissions: ["lucky-gift:view"]
|
||||||
},
|
},
|
||||||
|
listLuckyGiftExperiments: {
|
||||||
|
method: "GET",
|
||||||
|
operationId: API_OPERATIONS.listLuckyGiftExperiments,
|
||||||
|
path: "/v1/admin/ops-center/lucky-gifts/experiments",
|
||||||
|
permission: "lucky-gift:view",
|
||||||
|
permissions: ["lucky-gift:view"]
|
||||||
|
},
|
||||||
listLuckyGiftPoolBalances: {
|
listLuckyGiftPoolBalances: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listLuckyGiftPoolBalances,
|
operationId: API_OPERATIONS.listLuckyGiftPoolBalances,
|
||||||
@ -2640,6 +2666,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "game:update",
|
permission: "game:update",
|
||||||
permissions: ["game:update"]
|
permissions: ["game:update"]
|
||||||
},
|
},
|
||||||
|
setLuckyGiftExperimentOverrides: {
|
||||||
|
method: "PUT",
|
||||||
|
operationId: API_OPERATIONS.setLuckyGiftExperimentOverrides,
|
||||||
|
path: "/v1/admin/ops-center/lucky-gifts/experiments/{experimentId}/overrides",
|
||||||
|
permission: "lucky-gift:experiment",
|
||||||
|
permissions: ["lucky-gift:experiment"]
|
||||||
|
},
|
||||||
setPrettyIdStatus: {
|
setPrettyIdStatus: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.setPrettyIdStatus,
|
operationId: API_OPERATIONS.setPrettyIdStatus,
|
||||||
@ -2871,6 +2904,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "invite-activity-reward:update",
|
permission: "invite-activity-reward:update",
|
||||||
permissions: ["invite-activity-reward:update"]
|
permissions: ["invite-activity-reward:update"]
|
||||||
},
|
},
|
||||||
|
updateLuckyGiftExperiment: {
|
||||||
|
method: "PATCH",
|
||||||
|
operationId: API_OPERATIONS.updateLuckyGiftExperiment,
|
||||||
|
path: "/v1/admin/ops-center/lucky-gifts/experiments/{experimentId}",
|
||||||
|
permission: "lucky-gift:experiment",
|
||||||
|
permissions: ["lucky-gift:experiment"]
|
||||||
|
},
|
||||||
updateManager: {
|
updateManager: {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
operationId: API_OPERATIONS.updateManager,
|
operationId: API_OPERATIONS.updateManager,
|
||||||
|
|||||||
130
src/shared/api/generated/schema.d.ts
vendored
130
src/shared/api/generated/schema.d.ts
vendored
@ -356,6 +356,70 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/ops-center/lucky-gifts/experiments": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listLuckyGiftExperiments"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["createLuckyGiftExperiment"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/ops-center/lucky-gifts/experiments/{experimentId}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch: operations["updateLuckyGiftExperiment"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/ops-center/lucky-gifts/experiments/{experimentId}/overrides": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put: operations["setLuckyGiftExperimentOverrides"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/ops-center/lucky-gifts/experiments/{experimentId}/end": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["endLuckyGiftExperiment"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/activity/wheel/config": {
|
"/admin/activity/wheel/config": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -8486,6 +8550,72 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listLuckyGiftExperiments: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
createLuckyGiftExperiment: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
updateLuckyGiftExperiment: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
experimentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
setLuckyGiftExperimentOverrides: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
experimentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
endLuckyGiftExperiment: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
experimentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
getWheelConfig: {
|
getWheelConfig: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@ -79,7 +79,6 @@ export default defineConfig({
|
|||||||
databiSocial: new URL("./databi/social/index.html", import.meta.url).pathname,
|
databiSocial: new URL("./databi/social/index.html", import.meta.url).pathname,
|
||||||
finance: new URL("./finance/index.html", import.meta.url).pathname,
|
finance: new URL("./finance/index.html", import.meta.url).pathname,
|
||||||
externalAdmin: new URL("./external-admin/index.html", import.meta.url).pathname,
|
externalAdmin: new URL("./external-admin/index.html", import.meta.url).pathname,
|
||||||
moneyRedirect: new URL("./money/index.html", import.meta.url).pathname,
|
|
||||||
opsCenter: new URL("./ops-center/index.html", import.meta.url).pathname
|
opsCenter: new URL("./ops-center/index.html", import.meta.url).pathname
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user