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"]
|
||||
}
|
||||
},
|
||||
"/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": {
|
||||
"get": {
|
||||
"operationId": "getWheelConfig",
|
||||
|
||||
@ -9,14 +9,26 @@ import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { PageHead } from "@/shared/ui/PageHead.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 { ConfigsView } from "./components/ConfigsView.jsx";
|
||||
import { DrawsView } from "./components/DrawsView.jsx";
|
||||
import { LuckyGiftConfigDialog } from "./components/LuckyGiftConfigDialog.jsx";
|
||||
import { LuckyGiftExperimentManageDialog } from "./components/LuckyGiftExperimentManageDialog.jsx";
|
||||
import { LuckyGiftPoolAdjustmentDialog } from "./components/LuckyGiftPoolAdjustmentDialog.jsx";
|
||||
import { OpsShell } from "./components/OpsShell.jsx";
|
||||
import { OverviewView } from "./components/OverviewView.jsx";
|
||||
import { EXPERIMENT_ARM_LABELS, formatTrafficPercent } from "./experimentForm.js";
|
||||
import { formatNumber } from "./format.js";
|
||||
|
||||
const views = [
|
||||
@ -30,11 +42,16 @@ const viewKeys = new Set(views.map((view) => view.key));
|
||||
const initialDashboard = {
|
||||
apps: [],
|
||||
appSummaries: [],
|
||||
experiments: [],
|
||||
luckyGifts: [],
|
||||
poolBalances: [],
|
||||
summary: {},
|
||||
};
|
||||
|
||||
// 实验管理权限码与 permissions.ts 常量并行开发:常量尚未生成时回退到后端约定的字面量,
|
||||
// 避免部署顺序影响入口显隐;两者最终值一致("lucky-gift:experiment")。
|
||||
const EXPERIMENT_PERMISSION = PERMISSIONS.luckyGiftExperiment || "lucky-gift:experiment";
|
||||
|
||||
export function initialOpsCenterView(search = globalThis.location?.search || "") {
|
||||
const requested = new URLSearchParams(search).get("view") || "";
|
||||
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 [dialog, setDialog] = useState(null);
|
||||
const [poolDialog, setPoolDialog] = useState(null);
|
||||
const [experimentManage, setExperimentManage] = useState(null);
|
||||
const [adjustingPool, setAdjustingPool] = useState(false);
|
||||
const [savingConfig, setSavingConfig] = useState(false);
|
||||
const [experimentBusy, setExperimentBusy] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
const canCreditPool = can(PERMISSIONS.luckyGiftPoolCredit);
|
||||
const canDebitPool = can(PERMISSIONS.luckyGiftPoolDebit);
|
||||
const canUpdateConfig = can(PERMISSIONS.luckyGiftUpdate);
|
||||
const canManageExperiment = can(EXPERIMENT_PERMISSION);
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
setState((current) => ({ ...current, error: "", loading: true }));
|
||||
@ -134,6 +154,11 @@ export function OpsCenterApp({ can = denyPermission }) {
|
||||
setDialog({ config: structuredClone(config), mode: "edit" });
|
||||
}, []);
|
||||
|
||||
const openCreateExperiment = useCallback((config) => {
|
||||
// 复用配置弹窗的实验模式:以当前行配置为实验组初始值,当前最新启用版本自动成为对照组。
|
||||
setDialog({ config: structuredClone(config), mode: "experiment" });
|
||||
}, []);
|
||||
|
||||
const openPoolAdjustment = useCallback((pool, direction) => {
|
||||
if (!pool) {
|
||||
return;
|
||||
@ -164,6 +189,167 @@ export function OpsCenterApp({ can = denyPermission }) {
|
||||
[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(
|
||||
async (request) => {
|
||||
setAdjustingPool(true);
|
||||
@ -232,15 +418,19 @@ export function OpsCenterApp({ can = denyPermission }) {
|
||||
apps={data.apps}
|
||||
canCredit={canCreditPool}
|
||||
canDebit={canDebitPool}
|
||||
canExperiment={canManageExperiment}
|
||||
canUpdate={canUpdateConfig}
|
||||
disabled={adjustingPool || state.loading}
|
||||
experiments={data.experiments}
|
||||
luckyGifts={data.luckyGifts}
|
||||
poolBalances={data.poolBalances}
|
||||
saving={savingConfig}
|
||||
onCreate={openCreateConfig}
|
||||
onCreateExperiment={openCreateExperiment}
|
||||
onCredit={(pool) => openPoolAdjustment(pool, "in")}
|
||||
onDebit={(pool) => openPoolAdjustment(pool, "out")}
|
||||
onEdit={openEditConfig}
|
||||
onManageExperiment={openExperimentManage}
|
||||
/>
|
||||
) : null}
|
||||
{activeView === "draws" ? (
|
||||
@ -255,7 +445,23 @@ export function OpsCenterApp({ can = denyPermission }) {
|
||||
mode={dialog.mode}
|
||||
saving={savingConfig}
|
||||
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}
|
||||
{poolDialog ? (
|
||||
|
||||
@ -2,23 +2,55 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
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";
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
DEFAULT_POOL_ID: "default",
|
||||
adjustLuckyGiftPool: vi.fn(),
|
||||
createLuckyGiftExperiment: vi.fn(),
|
||||
endLuckyGiftExperiment: vi.fn(),
|
||||
fetchLuckyGiftDraws: vi.fn(),
|
||||
fetchLuckyGiftExperiments: vi.fn(),
|
||||
fetchOpsDashboard: vi.fn(),
|
||||
luckyGiftPoolKey: (item = {}) =>
|
||||
`${item.app_code || item.appCode}:${item.pool_id || item.poolId}:${item.strategy_version || item.strategyVersion || "fixed_v2"}`,
|
||||
saveLuckyGiftConfig: vi.fn(),
|
||||
setLuckyGiftExperimentOverrides: vi.fn(),
|
||||
updateLuckyGiftExperiment: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.history.replaceState({}, "", "/");
|
||||
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({
|
||||
adjustment: { balance_after: 39_559_017, balance_before: 38_559_017 },
|
||||
idempotent_replay: false,
|
||||
@ -43,6 +75,7 @@ beforeEach(() => {
|
||||
total: 1,
|
||||
});
|
||||
fetchOpsDashboard.mockResolvedValue({
|
||||
experiments: [mockRunningExperiment()],
|
||||
apps: [
|
||||
{ app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 },
|
||||
{ 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) {
|
||||
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();
|
||||
});
|
||||
|
||||
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(
|
||||
appCodes.map(async (appCode) => {
|
||||
const [luckyGifts, poolBalances, drawSummary] = await Promise.all([
|
||||
const [luckyGifts, poolBalances, drawSummary, experiments] = await Promise.all([
|
||||
fetchAppLuckyGiftConfigs(appCode),
|
||||
opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems),
|
||||
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);
|
||||
}
|
||||
|
||||
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 } = {}) {
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
// 复用主后台请求层才能共享 refreshOnce:运行期间 token 过期时先刷新再重放一次请求。
|
||||
@ -149,6 +293,8 @@ export function buildOpsUrl(path, query = {}) {
|
||||
|
||||
export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
|
||||
const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []);
|
||||
// 实验按 app+pool 独占;这里保持扁平列表,活跃实验与配置行的关联由展示层的 family key 完成。
|
||||
const experiments = perApp.flatMap((entry) => entry.experiments || []);
|
||||
const normalizedPoolBalances = perApp
|
||||
.flatMap((entry) => (entry.poolBalances || []).map((item) => normalizePoolBalance(item, entry.appCode)))
|
||||
.filter((item) => item.app_code);
|
||||
@ -198,7 +344,7 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
|
||||
totalSpentCoins: sumBy(appSummaries, "total_spent_coins"),
|
||||
};
|
||||
|
||||
return { apps, appSummaries, luckyGifts, poolBalances, summary };
|
||||
return { apps, appSummaries, experiments, luckyGifts, poolBalances, summary };
|
||||
}
|
||||
|
||||
export function normalizePoolBalance(pool = {}, fallbackAppCode = "") {
|
||||
@ -319,6 +465,12 @@ function normalizeStrategyVersion(value) {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeAppCodeValue(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function luckyGiftConfigPayload(config = {}) {
|
||||
const appCode = String(config.app_code || config.appCode || "")
|
||||
.trim()
|
||||
|
||||
@ -3,13 +3,18 @@ import { setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "@/sha
|
||||
import {
|
||||
adjustLuckyGiftPool,
|
||||
buildOpsUrl,
|
||||
createLuckyGiftExperiment,
|
||||
endLuckyGiftExperiment,
|
||||
fetchLuckyGiftDraws,
|
||||
fetchLuckyGiftExperiments,
|
||||
fetchOpsDashboard,
|
||||
luckyGiftPoolKey,
|
||||
normalizeDashboard,
|
||||
opsRequest,
|
||||
OPS_API_PREFIX,
|
||||
saveLuckyGiftConfig,
|
||||
setLuckyGiftExperimentOverrides,
|
||||
updateLuckyGiftExperiment,
|
||||
} from "./api.js";
|
||||
|
||||
afterEach(() => {
|
||||
@ -100,6 +105,19 @@ test("loads every pool config per app through the plural configs endpoint", asyn
|
||||
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: [] });
|
||||
});
|
||||
|
||||
@ -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/pools?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.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" },
|
||||
});
|
||||
});
|
||||
|
||||
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 { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||
import { luckyGiftPoolKey } from "../api.js";
|
||||
import { activeExperimentsByPool, experimentPoolFamilyKey, experimentStatusBadge } from "../experimentForm.js";
|
||||
import { formatNumber, formatPercent } from "../format.js";
|
||||
import { LuckyGiftPoolActions } from "./LuckyGiftPoolActions.jsx";
|
||||
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
||||
@ -13,13 +14,17 @@ export function ConfigsView({
|
||||
apps,
|
||||
canCredit,
|
||||
canDebit,
|
||||
canExperiment,
|
||||
canUpdate,
|
||||
disabled,
|
||||
experiments,
|
||||
luckyGifts,
|
||||
onCreate,
|
||||
onCreateExperiment,
|
||||
onCredit,
|
||||
onDebit,
|
||||
onEdit,
|
||||
onManageExperiment,
|
||||
poolBalances,
|
||||
saving,
|
||||
}) {
|
||||
@ -36,28 +41,53 @@ export function ConfigsView({
|
||||
// 配置列表已在 dashboard 阶段跨应用拉全,应用筛选只做前端过滤,避免重复扇出请求。
|
||||
const items = useMemo(() => {
|
||||
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;
|
||||
// 余额必须按 app+pool+strategy 精确关联;同一个 app/pool 同时存在 fixed_v2 和 dynamic_v3 时,
|
||||
// 任何缺少 strategy_version 的关联都会把两口独立资金池串行或覆盖。
|
||||
return visibleConfigs.map((config) => ({
|
||||
...config,
|
||||
active_experiment: experimentByPool.get(experimentPoolFamilyKey(config)) || null,
|
||||
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(
|
||||
() =>
|
||||
buildColumns({
|
||||
canCredit,
|
||||
canDebit,
|
||||
canExperiment,
|
||||
canUpdate,
|
||||
disabled,
|
||||
hasExperimentEntry,
|
||||
onCreateExperiment,
|
||||
onCredit,
|
||||
onDebit,
|
||||
onEdit,
|
||||
onManageExperiment,
|
||||
saving,
|
||||
}),
|
||||
[canCredit, canDebit, canUpdate, disabled, onCredit, onDebit, onEdit, saving],
|
||||
[
|
||||
canCredit,
|
||||
canDebit,
|
||||
canExperiment,
|
||||
canUpdate,
|
||||
disabled,
|
||||
hasExperimentEntry,
|
||||
onCreateExperiment,
|
||||
onCredit,
|
||||
onDebit,
|
||||
onEdit,
|
||||
onManageExperiment,
|
||||
saving,
|
||||
],
|
||||
);
|
||||
|
||||
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 = [
|
||||
{ key: "app_code", label: "应用", width: "minmax(88px, 0.7fr)" },
|
||||
{ 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)",
|
||||
},
|
||||
{
|
||||
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: "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({
|
||||
fixed: "right",
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item) => {
|
||||
const identity = `${item.app_code} / ${item.pool_id} / ${item.strategy_version}`;
|
||||
const hasActiveExperiment = Boolean(item.active_experiment);
|
||||
const actionPool = item.pool_balance || {
|
||||
app_code: item.app_code,
|
||||
pool_id: item.pool_id,
|
||||
@ -184,17 +238,47 @@ function buildColumns({ canCredit, canDebit, canUpdate, disabled, onCredit, onDe
|
||||
{canUpdate ? (
|
||||
<Button
|
||||
aria-label={`${item.is_default ? "发布配置" : "新增版本"} ${identity}`}
|
||||
disabled={saving}
|
||||
// 活跃实验独占该奖池的版本发布(owner 对常规 PUT 返回 409);
|
||||
// 前端直接禁用入口并说明原因,而不是让运营提交后才看到冲突报错。
|
||||
disabled={saving || hasActiveExperiment}
|
||||
size="small"
|
||||
title={hasActiveExperiment ? "实验进行中,结束实验后才能发布" : undefined}
|
||||
onClick={() => onEdit(item)}
|
||||
>
|
||||
{item.is_default ? "发布配置" : "新增版本"}
|
||||
</Button>
|
||||
) : 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>
|
||||
);
|
||||
},
|
||||
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,
|
||||
validateLuckyGiftForm,
|
||||
} from "../configForm.js";
|
||||
import {
|
||||
buildExperimentCreateRequest,
|
||||
experimentDraft,
|
||||
formatTrafficPercent,
|
||||
validateExperimentDraft,
|
||||
} from "../experimentForm.js";
|
||||
import { formatNumber, formatPercent } from "../format.js";
|
||||
import { LuckyGiftConfigSections } from "./LuckyGiftConfigSections.jsx";
|
||||
import { LuckyGiftExperimentFields } from "./LuckyGiftExperimentFields.jsx";
|
||||
import { LuckyGiftStageDesigner } from "./LuckyGiftStageDesigner.jsx";
|
||||
|
||||
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 [validationErrors, setValidationErrors] = useState([]);
|
||||
const validationRef = useRef(null);
|
||||
@ -42,7 +53,9 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
||||
setForm((current) => {
|
||||
const next = { ...current, [field]: value };
|
||||
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 决定各奖档概率的闭合解,改动后全部阶段重算,避免展示的概率和将要发布的值不一致。
|
||||
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) => {
|
||||
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) {
|
||||
setValidationErrors(errors);
|
||||
return;
|
||||
}
|
||||
if (isExperiment) {
|
||||
onSubmit(buildExperimentCreateRequest(experiment, formToConfig(config, submittedForm)));
|
||||
return;
|
||||
}
|
||||
onSubmit(formToConfig(config, form));
|
||||
};
|
||||
|
||||
const isCreate = mode === "create";
|
||||
const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本";
|
||||
const title = isExperiment
|
||||
? "创建 AB 实验"
|
||||
: isCreate
|
||||
? "添加幸运礼物配置"
|
||||
: config.is_default
|
||||
? "发布幸运礼物配置"
|
||||
: "新增幸运礼物版本";
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@ -146,7 +179,11 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
||||
<span>{title}</span>
|
||||
<small>
|
||||
{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>
|
||||
</DialogTitle>
|
||||
<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 窗口配置"}
|
||||
value={formatNumber(Number(form.settlement_window_wager))}
|
||||
/>
|
||||
<ConfigMetric label="发布状态" value={form.enabled ? "启用" : "停用"} />
|
||||
<ConfigMetric
|
||||
label={isExperiment ? "实验组流量" : "发布状态"}
|
||||
value={
|
||||
isExperiment
|
||||
? formatTrafficPercent(Number(experiment.trafficPercent))
|
||||
: form.enabled
|
||||
? "启用"
|
||||
: "停用"
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
<DialogContent className="ops-config-dialog-content" dividers>
|
||||
{validationErrors.length ? (
|
||||
@ -177,7 +223,7 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
||||
role="alert"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<strong>配置不能发布</strong>
|
||||
<strong>{isExperiment ? "实验不能创建" : "配置不能发布"}</strong>
|
||||
<ul>
|
||||
{validationErrors.map((message) => (
|
||||
<li key={message}>{message}</li>
|
||||
@ -186,9 +232,18 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isExperiment ? (
|
||||
<LuckyGiftExperimentFields
|
||||
disabled={saving}
|
||||
draft={experiment}
|
||||
onChange={updateExperimentField}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="ops-config-layout">
|
||||
<LuckyGiftConfigSections
|
||||
appOptions={appOptions}
|
||||
experimentMode={isExperiment}
|
||||
form={form}
|
||||
isCreate={isCreate}
|
||||
onChange={updateField}
|
||||
@ -213,7 +268,7 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={saving} type="submit" variant="primary">
|
||||
{saving ? "保存中" : "保存配置"}
|
||||
{saving ? (isExperiment ? "创建中" : "保存中") : isExperiment ? "创建实验" : "保存配置"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
|
||||
@ -16,7 +16,7 @@ const DYNAMIC_SECTIONS = [
|
||||
["limits", "限额"],
|
||||
];
|
||||
|
||||
export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }) {
|
||||
export function LuckyGiftConfigSections({ appOptions, experimentMode = false, form, isCreate, onChange }) {
|
||||
const scrollRef = useRef(null);
|
||||
const sections = useMemo(
|
||||
() => (form.strategy_version === "dynamic_v3" ? [...BASE_SECTIONS, ...DYNAMIC_SECTIONS] : BASE_SECTIONS),
|
||||
@ -160,6 +160,9 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
|
||||
/>
|
||||
)}
|
||||
<TextField
|
||||
// 实验绑定当前行的 app+pool(对照组由该奖池最新启用版本担任);
|
||||
// 实验模式下改奖池 ID 会把实验开到另一个奖池家族,必须锁定。
|
||||
disabled={experimentMode}
|
||||
fullWidth
|
||||
required
|
||||
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)}
|
||||
/>
|
||||
<TextField
|
||||
// 实验两组必须同策略族(owner 强制校验):分组会随放量/白名单漂移,
|
||||
// 混合策略会击穿 room saga 对 dynamic_v3 的扣费前拦截。策略迁移走常规发布。
|
||||
disabled={experimentMode}
|
||||
fullWidth
|
||||
label={<LuckyGiftFieldLabel field="strategy_version" label="策略版本" />}
|
||||
select
|
||||
@ -183,6 +189,9 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
|
||||
control={
|
||||
<AdminSwitch
|
||||
checked={form.enabled}
|
||||
// 实验组配置发布后立即参与分流开奖,owner 拒绝停用的实验组;
|
||||
// 创建实验时锁定启用开关,避免运营误以为可以先停用再放量。
|
||||
disabled={experimentMode}
|
||||
label="发布状态"
|
||||
onChange={(event) => onChange("enabled", event.target.checked)}
|
||||
/>
|
||||
@ -190,7 +199,13 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
|
||||
label={
|
||||
<LuckyGiftFieldLabel
|
||||
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:
|
||||
"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: {
|
||||
summary: "决定这一档是否参加当前阶段的普通基础抽奖。关闭后不会从普通奖档中选中。",
|
||||
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);
|
||||
}
|
||||
|
||||
/* ---- 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) {
|
||||
@ -925,6 +1109,15 @@ body {
|
||||
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 {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@ -194,6 +194,7 @@ export const PERMISSIONS = {
|
||||
sevenDayCheckInUpdate: "seven-day-checkin:update",
|
||||
luckyGiftView: "lucky-gift:view",
|
||||
luckyGiftUpdate: "lucky-gift:update",
|
||||
luckyGiftExperiment: "lucky-gift:experiment",
|
||||
luckyGiftPoolCredit: "lucky-gift:pool-credit",
|
||||
luckyGiftPoolDebit: "lucky-gift:pool-debit",
|
||||
wheelView: "wheel:view",
|
||||
|
||||
@ -59,6 +59,7 @@ export const API_OPERATIONS = {
|
||||
createGift: "createGift",
|
||||
createH5Link: "createH5Link",
|
||||
createHostAgencyPolicy: "createHostAgencyPolicy",
|
||||
createLuckyGiftExperiment: "createLuckyGiftExperiment",
|
||||
createManager: "createManager",
|
||||
createMenu: "createMenu",
|
||||
createPermission: "createPermission",
|
||||
@ -123,6 +124,7 @@ export const API_OPERATIONS = {
|
||||
enableResource: "enableResource",
|
||||
enableResourceGroup: "enableResourceGroup",
|
||||
enableResourceShopItem: "enableResourceShopItem",
|
||||
endLuckyGiftExperiment: "endLuckyGiftExperiment",
|
||||
expireRoomRpsChallenge: "expireRoomRpsChallenge",
|
||||
exportActivityTemplateData: "exportActivityTemplateData",
|
||||
exportCoinSellerLedger: "exportCoinSellerLedger",
|
||||
@ -236,6 +238,7 @@ export const API_OPERATIONS = {
|
||||
listLoginLogs: "listLoginLogs",
|
||||
listLuckyGiftConfigs: "listLuckyGiftConfigs",
|
||||
listLuckyGiftDraws: "listLuckyGiftDraws",
|
||||
listLuckyGiftExperiments: "listLuckyGiftExperiments",
|
||||
listLuckyGiftPoolBalances: "listLuckyGiftPoolBalances",
|
||||
listManagers: "listManagers",
|
||||
listOperationLogs: "listOperationLogs",
|
||||
@ -333,6 +336,7 @@ export const API_OPERATIONS = {
|
||||
setDiceRobotStatus: "setDiceRobotStatus",
|
||||
setGameStatus: "setGameStatus",
|
||||
setGameWhitelistEnabled: "setGameWhitelistEnabled",
|
||||
setLuckyGiftExperimentOverrides: "setLuckyGiftExperimentOverrides",
|
||||
setPrettyIdStatus: "setPrettyIdStatus",
|
||||
setTaskDefinitionStatus: "setTaskDefinitionStatus",
|
||||
setThirdPartyPaymentMethodStatus: "setThirdPartyPaymentMethodStatus",
|
||||
@ -366,6 +370,7 @@ export const API_OPERATIONS = {
|
||||
updateHostAgencyPolicy: "updateHostAgencyPolicy",
|
||||
updateHumanRoomRobotConfig: "updateHumanRoomRobotConfig",
|
||||
updateInviteActivityRewardConfig: "updateInviteActivityRewardConfig",
|
||||
updateLuckyGiftExperiment: "updateLuckyGiftExperiment",
|
||||
updateManager: "updateManager",
|
||||
updateMenu: "updateMenu",
|
||||
updateMenuVisible: "updateMenuVisible",
|
||||
@ -738,6 +743,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createManager,
|
||||
@ -1186,6 +1198,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.expireRoomRpsChallenge,
|
||||
@ -1975,6 +1994,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listLuckyGiftPoolBalances,
|
||||
@ -2640,6 +2666,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.setPrettyIdStatus,
|
||||
@ -2871,6 +2904,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "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: {
|
||||
method: "PUT",
|
||||
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;
|
||||
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": {
|
||||
parameters: {
|
||||
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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@ -79,7 +79,6 @@ export default defineConfig({
|
||||
databiSocial: new URL("./databi/social/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,
|
||||
moneyRedirect: new URL("./money/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