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:
zhx 2026-07-17 00:22:29 +08:00
parent c6ea362cee
commit f8c7c50316
18 changed files with 2025 additions and 24 deletions

View File

@ -470,6 +470,94 @@
"x-permissions": ["lucky-gift:pool-debit"] "x-permissions": ["lucky-gift:pool-debit"]
} }
}, },
"/admin/ops-center/lucky-gifts/experiments": {
"get": {
"operationId": "listLuckyGiftExperiments",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "lucky-gift:view",
"x-permissions": ["lucky-gift:view"]
},
"post": {
"operationId": "createLuckyGiftExperiment",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "lucky-gift:experiment",
"x-permissions": ["lucky-gift:experiment"]
}
},
"/admin/ops-center/lucky-gifts/experiments/{experimentId}": {
"patch": {
"operationId": "updateLuckyGiftExperiment",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "experimentId",
"required": true,
"schema": {
"type": "string"
}
}
],
"x-permission": "lucky-gift:experiment",
"x-permissions": ["lucky-gift:experiment"]
}
},
"/admin/ops-center/lucky-gifts/experiments/{experimentId}/overrides": {
"put": {
"operationId": "setLuckyGiftExperimentOverrides",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "experimentId",
"required": true,
"schema": {
"type": "string"
}
}
],
"x-permission": "lucky-gift:experiment",
"x-permissions": ["lucky-gift:experiment"]
}
},
"/admin/ops-center/lucky-gifts/experiments/{experimentId}/end": {
"post": {
"operationId": "endLuckyGiftExperiment",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "experimentId",
"required": true,
"schema": {
"type": "string"
}
}
],
"x-permission": "lucky-gift:experiment",
"x-permissions": ["lucky-gift:experiment"]
}
},
"/admin/activity/wheel/config": { "/admin/activity/wheel/config": {
"get": { "get": {
"operationId": "getWheelConfig", "operationId": "getWheelConfig",

View File

@ -9,14 +9,26 @@ import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx"; import { DataState } from "@/shared/ui/DataState.jsx";
import { PageHead } from "@/shared/ui/PageHead.jsx"; import { PageHead } from "@/shared/ui/PageHead.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { adjustLuckyGiftPool, DEFAULT_POOL_ID, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js"; import {
adjustLuckyGiftPool,
createLuckyGiftExperiment,
DEFAULT_POOL_ID,
endLuckyGiftExperiment,
fetchLuckyGiftExperiments,
fetchOpsDashboard,
saveLuckyGiftConfig,
setLuckyGiftExperimentOverrides,
updateLuckyGiftExperiment,
} from "./api.js";
import { AppsView } from "./components/AppsView.jsx"; import { AppsView } from "./components/AppsView.jsx";
import { ConfigsView } from "./components/ConfigsView.jsx"; import { ConfigsView } from "./components/ConfigsView.jsx";
import { DrawsView } from "./components/DrawsView.jsx"; import { DrawsView } from "./components/DrawsView.jsx";
import { LuckyGiftConfigDialog } from "./components/LuckyGiftConfigDialog.jsx"; import { LuckyGiftConfigDialog } from "./components/LuckyGiftConfigDialog.jsx";
import { LuckyGiftExperimentManageDialog } from "./components/LuckyGiftExperimentManageDialog.jsx";
import { LuckyGiftPoolAdjustmentDialog } from "./components/LuckyGiftPoolAdjustmentDialog.jsx"; import { LuckyGiftPoolAdjustmentDialog } from "./components/LuckyGiftPoolAdjustmentDialog.jsx";
import { OpsShell } from "./components/OpsShell.jsx"; import { OpsShell } from "./components/OpsShell.jsx";
import { OverviewView } from "./components/OverviewView.jsx"; import { OverviewView } from "./components/OverviewView.jsx";
import { EXPERIMENT_ARM_LABELS, formatTrafficPercent } from "./experimentForm.js";
import { formatNumber } from "./format.js"; import { formatNumber } from "./format.js";
const views = [ const views = [
@ -30,11 +42,16 @@ const viewKeys = new Set(views.map((view) => view.key));
const initialDashboard = { const initialDashboard = {
apps: [], apps: [],
appSummaries: [], appSummaries: [],
experiments: [],
luckyGifts: [], luckyGifts: [],
poolBalances: [], poolBalances: [],
summary: {}, summary: {},
}; };
// permissions.ts 退
// "lucky-gift:experiment"
const EXPERIMENT_PERMISSION = PERMISSIONS.luckyGiftExperiment || "lucky-gift:experiment";
export function initialOpsCenterView(search = globalThis.location?.search || "") { export function initialOpsCenterView(search = globalThis.location?.search || "") {
const requested = new URLSearchParams(search).get("view") || ""; const requested = new URLSearchParams(search).get("view") || "";
return viewKeys.has(requested) ? requested : "overview"; return viewKeys.has(requested) ? requested : "overview";
@ -48,12 +65,15 @@ export function OpsCenterApp({ can = denyPermission }) {
const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true }); const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true });
const [dialog, setDialog] = useState(null); const [dialog, setDialog] = useState(null);
const [poolDialog, setPoolDialog] = useState(null); const [poolDialog, setPoolDialog] = useState(null);
const [experimentManage, setExperimentManage] = useState(null);
const [adjustingPool, setAdjustingPool] = useState(false); const [adjustingPool, setAdjustingPool] = useState(false);
const [savingConfig, setSavingConfig] = useState(false); const [savingConfig, setSavingConfig] = useState(false);
const [experimentBusy, setExperimentBusy] = useState(false);
const { showToast } = useToast(); const { showToast } = useToast();
const canCreditPool = can(PERMISSIONS.luckyGiftPoolCredit); const canCreditPool = can(PERMISSIONS.luckyGiftPoolCredit);
const canDebitPool = can(PERMISSIONS.luckyGiftPoolDebit); const canDebitPool = can(PERMISSIONS.luckyGiftPoolDebit);
const canUpdateConfig = can(PERMISSIONS.luckyGiftUpdate); const canUpdateConfig = can(PERMISSIONS.luckyGiftUpdate);
const canManageExperiment = can(EXPERIMENT_PERMISSION);
const loadDashboard = useCallback(async () => { const loadDashboard = useCallback(async () => {
setState((current) => ({ ...current, error: "", loading: true })); setState((current) => ({ ...current, error: "", loading: true }));
@ -134,6 +154,11 @@ export function OpsCenterApp({ can = denyPermission }) {
setDialog({ config: structuredClone(config), mode: "edit" }); setDialog({ config: structuredClone(config), mode: "edit" });
}, []); }, []);
const openCreateExperiment = useCallback((config) => {
//
setDialog({ config: structuredClone(config), mode: "experiment" });
}, []);
const openPoolAdjustment = useCallback((pool, direction) => { const openPoolAdjustment = useCallback((pool, direction) => {
if (!pool) { if (!pool) {
return; return;
@ -164,6 +189,167 @@ export function OpsCenterApp({ can = denyPermission }) {
[loadDashboard, showToast], [loadDashboard, showToast],
); );
const handleCreateExperiment = useCallback(
async (request) => {
setSavingConfig(true);
try {
const result = await createLuckyGiftExperiment(request.config.app_code, request);
showToast(
`已创建 AB 实验「${request.name}」:${request.config.app_code} / ${request.config.pool_id},实验组 v${
result.experiment?.treatment_rule_version || "-"
} ${formatTrafficPercent(request.treatment_traffic_percent)} 放量`,
"success",
);
setDialog(null);
await loadDashboard();
} catch (error) {
// 409
showToast(error.message || "创建 AB 实验失败", "error");
} finally {
setSavingConfig(false);
}
},
[loadDashboard, showToast],
);
const refreshExperimentDetail = useCallback(async (experiment) => {
// with_stats=1 owner control/treatment
// dashboard pool experiment_id
const items = await fetchLuckyGiftExperiments(experiment.app_code, {
poolId: experiment.pool_id,
withStats: true,
});
return items.find((item) => item.experiment_id === experiment.experiment_id) || null;
}, []);
const openExperimentManage = useCallback(
(experiment) => {
setExperimentManage({ experiment, statsLoading: true });
refreshExperimentDetail(experiment)
.then((detail) => {
setExperimentManage((current) =>
current && current.experiment.experiment_id === experiment.experiment_id
? { experiment: detail || current.experiment, statsLoading: false }
: current,
);
})
.catch((error) => {
// -
showToast(error.message || "加载实验统计失败", "error");
setExperimentManage((current) =>
current && current.experiment.experiment_id === experiment.experiment_id
? { ...current, statsLoading: false }
: current,
);
});
},
[refreshExperimentDetail, showToast],
);
const runExperimentAction = useCallback(
async (action, successMessage) => {
const current = experimentManage?.experiment;
if (!current) {
return;
}
setExperimentBusy(true);
try {
const updated = await action(current);
showToast(successMessage(updated), "success");
// PATCH/PUT arm_stats
// dashboard
setExperimentManage((state) =>
state && state.experiment.experiment_id === current.experiment_id
? { experiment: { ...state.experiment, ...updated }, statsLoading: true }
: state,
);
await loadDashboard();
try {
const detail = await refreshExperimentDetail(current);
setExperimentManage((state) =>
state && state.experiment.experiment_id === current.experiment_id
? { experiment: detail || state.experiment, statsLoading: false }
: state,
);
} catch {
// toast
setExperimentManage((state) => (state ? { ...state, statsLoading: false } : state));
}
} catch (error) {
//
showToast(error.message || "实验操作失败", "error");
} finally {
setExperimentBusy(false);
}
},
[experimentManage, loadDashboard, refreshExperimentDetail, showToast],
);
const adjustExperimentTraffic = useCallback(
(percent) =>
runExperimentAction(
(current) =>
updateLuckyGiftExperiment(current.app_code, current.experiment_id, {
treatment_traffic_percent: percent,
}),
() => `已调整实验组流量为 ${formatTrafficPercent(percent)}`,
),
[runExperimentAction],
);
const setExperimentStatus = useCallback(
(status) =>
runExperimentAction(
(current) => updateLuckyGiftExperiment(current.app_code, current.experiment_id, { status }),
() =>
status === "paused"
? "实验已暂停,全部流量(含白名单)回到对照组"
: "实验已恢复,按原分组继续分流",
),
[runExperimentAction],
);
const upsertExperimentOverride = useCallback(
({ arm, userKey }) =>
runExperimentAction(
(current) =>
setLuckyGiftExperimentOverrides(current.app_code, current.experiment_id, {
removes: [],
upserts: [{ arm, user_key: userKey }],
}),
() => `已把用户 ${userKey} 固定到${EXPERIMENT_ARM_LABELS[arm] || arm}`,
),
[runExperimentAction],
);
const removeExperimentOverride = useCallback(
(userKey) =>
runExperimentAction(
(current) =>
setLuckyGiftExperimentOverrides(current.app_code, current.experiment_id, {
removes: [userKey],
upserts: [],
}),
() => `已移除白名单用户 ${userKey},该用户回到哈希分流`,
),
[runExperimentAction],
);
const endExperiment = useCallback(
(winnerArm) =>
runExperimentAction(
async (current) => {
const result = await endLuckyGiftExperiment(current.app_code, current.experiment_id, winnerArm);
return result.experiment;
},
(updated) =>
`实验已结束:${EXPERIMENT_ARM_LABELS[winnerArm]}配置已重新发布为 v${
updated?.final_rule_version || updated?.winner_rule_version || "-"
} 并全量生效`,
),
[runExperimentAction],
);
const handleAdjustPool = useCallback( const handleAdjustPool = useCallback(
async (request) => { async (request) => {
setAdjustingPool(true); setAdjustingPool(true);
@ -232,15 +418,19 @@ export function OpsCenterApp({ can = denyPermission }) {
apps={data.apps} apps={data.apps}
canCredit={canCreditPool} canCredit={canCreditPool}
canDebit={canDebitPool} canDebit={canDebitPool}
canExperiment={canManageExperiment}
canUpdate={canUpdateConfig} canUpdate={canUpdateConfig}
disabled={adjustingPool || state.loading} disabled={adjustingPool || state.loading}
experiments={data.experiments}
luckyGifts={data.luckyGifts} luckyGifts={data.luckyGifts}
poolBalances={data.poolBalances} poolBalances={data.poolBalances}
saving={savingConfig} saving={savingConfig}
onCreate={openCreateConfig} onCreate={openCreateConfig}
onCreateExperiment={openCreateExperiment}
onCredit={(pool) => openPoolAdjustment(pool, "in")} onCredit={(pool) => openPoolAdjustment(pool, "in")}
onDebit={(pool) => openPoolAdjustment(pool, "out")} onDebit={(pool) => openPoolAdjustment(pool, "out")}
onEdit={openEditConfig} onEdit={openEditConfig}
onManageExperiment={openExperimentManage}
/> />
) : null} ) : null}
{activeView === "draws" ? ( {activeView === "draws" ? (
@ -255,7 +445,23 @@ export function OpsCenterApp({ can = denyPermission }) {
mode={dialog.mode} mode={dialog.mode}
saving={savingConfig} saving={savingConfig}
onClose={() => setDialog(null)} onClose={() => setDialog(null)}
onSubmit={handleSaveConfig} // {name, treatment_traffic_percent, overrides, config}
// PUT /lucky-gifts/config
onSubmit={dialog.mode === "experiment" ? handleCreateExperiment : handleSaveConfig}
/>
) : null}
{experimentManage ? (
<LuckyGiftExperimentManageDialog
busy={experimentBusy}
canManage={canManageExperiment}
experiment={experimentManage.experiment}
statsLoading={experimentManage.statsLoading}
onAdjustTraffic={adjustExperimentTraffic}
onClose={() => setExperimentManage(null)}
onEnd={endExperiment}
onRemoveOverride={removeExperimentOverride}
onSetStatus={setExperimentStatus}
onUpsertOverride={upsertExperimentOverride}
/> />
) : null} ) : null}
{poolDialog ? ( {poolDialog ? (

View File

@ -2,23 +2,55 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
import { beforeEach, expect, test, vi } from "vitest"; import { beforeEach, expect, test, vi } from "vitest";
import { PERMISSIONS } from "@/app/permissions"; import { PERMISSIONS } from "@/app/permissions";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
import { adjustLuckyGiftPool, fetchLuckyGiftDraws, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js"; import {
adjustLuckyGiftPool,
createLuckyGiftExperiment,
endLuckyGiftExperiment,
fetchLuckyGiftDraws,
fetchLuckyGiftExperiments,
fetchOpsDashboard,
saveLuckyGiftConfig,
setLuckyGiftExperimentOverrides,
updateLuckyGiftExperiment,
} from "./api.js";
import { initialOpsCenterView, OpsCenterApp } from "./OpsCenterApp.jsx"; import { initialOpsCenterView, OpsCenterApp } from "./OpsCenterApp.jsx";
vi.mock("./api.js", () => ({ vi.mock("./api.js", () => ({
DEFAULT_POOL_ID: "default", DEFAULT_POOL_ID: "default",
adjustLuckyGiftPool: vi.fn(), adjustLuckyGiftPool: vi.fn(),
createLuckyGiftExperiment: vi.fn(),
endLuckyGiftExperiment: vi.fn(),
fetchLuckyGiftDraws: vi.fn(), fetchLuckyGiftDraws: vi.fn(),
fetchLuckyGiftExperiments: vi.fn(),
fetchOpsDashboard: vi.fn(), fetchOpsDashboard: vi.fn(),
luckyGiftPoolKey: (item = {}) => luckyGiftPoolKey: (item = {}) =>
`${item.app_code || item.appCode}:${item.pool_id || item.poolId}:${item.strategy_version || item.strategyVersion || "fixed_v2"}`, `${item.app_code || item.appCode}:${item.pool_id || item.poolId}:${item.strategy_version || item.strategyVersion || "fixed_v2"}`,
saveLuckyGiftConfig: vi.fn(), saveLuckyGiftConfig: vi.fn(),
setLuckyGiftExperimentOverrides: vi.fn(),
updateLuckyGiftExperiment: vi.fn(),
})); }));
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
window.history.replaceState({}, "", "/"); window.history.replaceState({}, "", "/");
saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" }); saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" });
createLuckyGiftExperiment.mockResolvedValue({
experiment: { experiment_id: "exp-new", status: "running", treatment_rule_version: 4 },
treatment_config: { app_code: "lalu", pool_id: "lucky", rule_version: 4 },
});
fetchLuckyGiftExperiments.mockResolvedValue([mockRunningExperiment()]);
updateLuckyGiftExperiment.mockResolvedValue({ ...mockRunningExperiment(), treatment_traffic_percent: 20 });
setLuckyGiftExperimentOverrides.mockResolvedValue({ ...mockRunningExperiment(), overrides: [] });
endLuckyGiftExperiment.mockResolvedValue({
experiment: {
...mockRunningExperiment(),
ended_at_ms: 1767000600000,
final_rule_version: 4,
status: "completed",
winner_arm: "treatment",
},
final_config: { app_code: "lalu", pool_id: "super_lucky", rule_version: 4 },
});
adjustLuckyGiftPool.mockResolvedValue({ adjustLuckyGiftPool.mockResolvedValue({
adjustment: { balance_after: 39_559_017, balance_before: 38_559_017 }, adjustment: { balance_after: 39_559_017, balance_before: 38_559_017 },
idempotent_replay: false, idempotent_replay: false,
@ -43,6 +75,7 @@ beforeEach(() => {
total: 1, total: 1,
}); });
fetchOpsDashboard.mockResolvedValue({ fetchOpsDashboard.mockResolvedValue({
experiments: [mockRunningExperiment()],
apps: [ apps: [
{ app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 }, { app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 },
{ app_code: "yumi", app_name: "Yumi", source: "fixed", status: "active" }, { app_code: "yumi", app_name: "Yumi", source: "fixed", status: "active" },
@ -147,7 +180,53 @@ beforeEach(() => {
}); });
}); });
const allPermissions = [PERMISSIONS.luckyGiftPoolCredit, PERMISSIONS.luckyGiftPoolDebit, PERMISSIONS.luckyGiftUpdate]; // permissions.ts 使
const EXPERIMENT_PERMISSION = "lucky-gift:experiment";
const allPermissions = [
PERMISSIONS.luckyGiftPoolCredit,
PERMISSIONS.luckyGiftPoolDebit,
PERMISSIONS.luckyGiftUpdate,
EXPERIMENT_PERMISSION,
];
// lalu/super_lucky
// lalu/lucky AB
function mockRunningExperiment() {
return {
app_code: "lalu",
arm_stats: [
{
actual_rtp_percent: 95.2,
arm: "control",
rule_version: 2,
strategy_version: "fixed_v2",
total_draws: 100,
total_reward_coins: 952,
total_spent_coins: 1000,
unique_users: 40,
},
{
actual_rtp_percent: 97.9,
arm: "treatment",
rule_version: 3,
strategy_version: "fixed_v2",
total_draws: 20,
total_reward_coins: 196,
total_spent_coins: 200,
unique_users: 6,
},
],
control_rule_version: 2,
experiment_id: "exp-1",
name: "rtp-up",
overrides: [{ arm: "treatment", user_key: "1001" }],
pool_id: "super_lucky",
started_at_ms: 1767000000000,
status: "running",
treatment_rule_version: 3,
treatment_traffic_percent: 5,
};
}
function renderApp(permissions = allPermissions) { function renderApp(permissions = allPermissions) {
return render( return render(
@ -451,3 +530,130 @@ test("shows app registry and fixed integrations in apps view", async () => {
expect(screen.getByText("主数据")).toBeTruthy(); expect(screen.getByText("主数据")).toBeTruthy();
expect(screen.getByText("外部接入")).toBeTruthy(); expect(screen.getByText("外部接入")).toBeTruthy();
}); });
test("flags pools with a running experiment and blocks their regular publishing", async () => {
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
// running
expect(await screen.findByText("实验中 5%")).toBeTruthy();
const publishButton = screen.getByRole("button", { name: "新增版本 lalu / super_lucky / fixed_v2" });
expect(publishButton).toBeDisabled();
expect(publishButton).toHaveAttribute("title", "实验进行中,结束实验后才能发布");
expect(screen.getByRole("button", { name: "实验详情 lalu / super_lucky / fixed_v2" })).toBeTruthy();
// AB
expect(screen.getByRole("button", { name: "新增版本 lalu / lucky / dynamic_v3" })).toBeEnabled();
expect(screen.getByRole("button", { name: "创建 AB 实验 lalu / lucky / dynamic_v3" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "创建 AB 实验 lalu / lucky / fixed_v2" })).toBeNull();
expect(screen.queryByRole("button", { name: "创建 AB 实验 lalu / super_lucky / fixed_v2" })).toBeNull();
});
test("hides experiment management entries without the experiment permission", async () => {
renderApp([PERMISSIONS.luckyGiftUpdate]);
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
// GET lucky-gift:view
expect(await screen.findByText("实验中 5%")).toBeTruthy();
expect(screen.getByRole("button", { name: "实验详情 lalu / super_lucky / fixed_v2" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "创建 AB 实验 lalu / lucky / dynamic_v3" })).toBeNull();
});
test("creates an ab experiment from an enabled config row with a locked enabled switch", async () => {
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
fireEvent.click(await screen.findByRole("button", { name: "创建 AB 实验 lalu / lucky / dynamic_v3" }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("创建 AB 实验")).toBeTruthy();
// ID
expect(within(dialog).getByRole("switch", { name: "发布状态" })).toBeDisabled();
expect(within(dialog).getByRole("switch", { name: "发布状态" })).toBeChecked();
expect(within(dialog).getByLabelText(/^奖池/)).toBeDisabled();
// required +
fireEvent.click(within(dialog).getByRole("button", { name: "创建实验" }));
expect(createLuckyGiftExperiment).not.toHaveBeenCalled();
fireEvent.change(within(dialog).getByLabelText(/实验名称/), { target: { value: "rtp-98" } });
fireEvent.click(within(dialog).getByRole("button", { name: "创建实验" }));
await waitFor(() => {
expect(createLuckyGiftExperiment).toHaveBeenCalledWith(
"lalu",
expect.objectContaining({
config: expect.objectContaining({ app_code: "lalu", enabled: true, pool_id: "lucky" }),
name: "rtp-98",
overrides: [],
treatment_traffic_percent: 5,
}),
);
});
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(fetchOpsDashboard).toHaveBeenCalledTimes(2);
});
test("manages a running experiment: stats, traffic, overrides and ending with a winner", async () => {
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
fireEvent.click(await screen.findByRole("button", { name: "实验详情 lalu / super_lucky / fixed_v2" }));
const dialog = await screen.findByRole("dialog", { name: /实验管理/ });
// with_stats=1
await waitFor(() =>
expect(fetchLuckyGiftExperiments).toHaveBeenCalledWith("lalu", { poolId: "super_lucky", withStats: true }),
);
expect(await within(dialog).findByText("97.90%")).toBeTruthy();
expect(within(dialog).getByText("95.20%")).toBeTruthy();
// getAllByText
expect(within(dialog).getAllByText("对照组").length).toBeGreaterThan(0);
expect(within(dialog).getAllByText("实验组").length).toBeGreaterThan(0);
fireEvent.change(within(dialog).getByLabelText(/实验组流量/), { target: { value: "20" } });
fireEvent.click(within(dialog).getByRole("button", { name: "应用" }));
await waitFor(() =>
expect(updateLuckyGiftExperiment).toHaveBeenCalledWith("lalu", "exp-1", { treatment_traffic_percent: 20 }),
);
expect(await screen.findByText("已调整实验组流量为 20%")).toBeTruthy();
await waitFor(() => expect(within(dialog).getByRole("button", { name: "移除白名单 1001" })).toBeEnabled());
fireEvent.click(within(dialog).getByRole("button", { name: "移除白名单 1001" }));
await waitFor(() =>
expect(setLuckyGiftExperimentOverrides).toHaveBeenCalledWith("lalu", "exp-1", {
removes: ["1001"],
upserts: [],
}),
);
//
await waitFor(() => expect(within(dialog).getByRole("button", { name: "选择实验组为最终版本" })).toBeEnabled());
fireEvent.click(within(dialog).getByRole("button", { name: "选择实验组为最终版本" }));
expect(endLuckyGiftExperiment).not.toHaveBeenCalled();
fireEvent.click(await within(dialog).findByRole("button", { name: "确认结束并发布" }));
await waitFor(() => expect(endLuckyGiftExperiment).toHaveBeenCalledWith("lalu", "exp-1", "treatment"));
expect(await screen.findByText(/实验已结束:实验组配置已重新发布为 v4 并全量生效/)).toBeTruthy();
});
test("pauses a running experiment and keeps the dialog usable when the action fails", async () => {
updateLuckyGiftExperiment.mockRejectedValueOnce(new Error("实验状态已变化"));
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
fireEvent.click(await screen.findByRole("button", { name: "实验详情 lalu / super_lucky / fixed_v2" }));
const dialog = await screen.findByRole("dialog", { name: /实验管理/ });
fireEvent.click(within(dialog).getByRole("button", { name: "暂停实验" }));
//
expect(await screen.findByText("实验状态已变化")).toBeTruthy();
expect(screen.getByRole("dialog", { name: /实验管理/ })).toBeTruthy();
await waitFor(() => expect(within(dialog).getByRole("button", { name: "暂停实验" })).toBeEnabled());
fireEvent.click(within(dialog).getByRole("button", { name: "暂停实验" }));
await waitFor(() =>
expect(updateLuckyGiftExperiment).toHaveBeenLastCalledWith("lalu", "exp-1", { status: "paused" }),
);
expect(await screen.findByText("实验已暂停,全部流量(含白名单)回到对照组")).toBeTruthy();
});

View File

@ -13,12 +13,15 @@ export async function fetchOpsDashboard(query = {}) {
const perApp = await Promise.all( const perApp = await Promise.all(
appCodes.map(async (appCode) => { appCodes.map(async (appCode) => {
const [luckyGifts, poolBalances, drawSummary] = await Promise.all([ const [luckyGifts, poolBalances, drawSummary, experiments] = await Promise.all([
fetchAppLuckyGiftConfigs(appCode), fetchAppLuckyGiftConfigs(appCode),
opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems), opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems),
opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } }), opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } }),
// 实验列表驱动配置行的实验徽章和发布禁用;该接口比面板后上线,请求失败时降级为空列表
// 而不是整页白屏——活跃实验期间的常规发布仍由 owner 以 409 兜底拒绝,降级是安全的。
fetchLuckyGiftExperiments(appCode).catch(() => []),
]); ]);
return { appCode, drawSummary, luckyGifts, poolBalances }; return { appCode, drawSummary, experiments, luckyGifts, poolBalances };
}), }),
); );
@ -123,6 +126,147 @@ export async function adjustLuckyGiftPool({
return normalizePoolAdjustmentResult(payload); return normalizePoolAdjustmentResult(payload);
} }
export async function fetchLuckyGiftExperiments(appCode, { poolId = "", status = "", withStats = false } = {}) {
const normalizedAppCode = normalizeAppCodeValue(appCode);
const payload = await opsRequest("/lucky-gifts/experiments", {
query: {
app_code: normalizedAppCode,
pool_id: String(poolId || "").trim(),
status: String(status || "").trim(),
// with_stats=1 让 owner 按 control/treatment 聚合抽奖统计,代价较高;
// dashboard 扇出保持轻量不带该参数,只有实验管理弹窗需要统计。
with_stats: withStats ? 1 : "",
},
});
return normalizeItems(payload).map((item) => normalizeLuckyGiftExperiment(item, normalizedAppCode));
}
export async function createLuckyGiftExperiment(appCode, request = {}) {
const config = luckyGiftConfigPayload(request.config || {});
const normalizedAppCode = normalizeAppCodeValue(appCode || config.app_code);
// 实验组配置由 owner 原子发布为新规则版本并立即参与开奖enabled 必须为 true
// 表单层已强制启用,这里再收敛一次,防止其他调用方把停用草稿提交成实验组。
const payload = await opsRequest("/lucky-gifts/experiments", {
body: {
config: { ...config, enabled: true },
name: String(request.name || "").trim(),
overrides: normalizeExperimentOverrides(request.overrides),
treatment_traffic_percent: numberValue(request.treatment_traffic_percent),
},
method: "POST",
query: { app_code: normalizedAppCode },
});
return {
...payload,
experiment: normalizeLuckyGiftExperiment(payload?.experiment || {}, normalizedAppCode),
};
}
export async function updateLuckyGiftExperiment(appCode, experimentId, patch = {}) {
const body = {};
// PATCH 只回传明确要修改的字段:同时携带 status 和 treatment_traffic_percent 会让
// “暂停”误把放量重置,或“放量”误改实验状态。
if (patch.treatment_traffic_percent !== undefined && patch.treatment_traffic_percent !== null) {
body.treatment_traffic_percent = numberValue(patch.treatment_traffic_percent);
}
if (patch.status) {
body.status = String(patch.status);
}
const payload = await opsRequest(luckyGiftExperimentPath(experimentId), {
body,
method: "PATCH",
query: { app_code: normalizeAppCodeValue(appCode) },
});
return normalizeLuckyGiftExperiment(payload?.experiment || {}, appCode);
}
export async function setLuckyGiftExperimentOverrides(appCode, experimentId, { removes, upserts } = {}) {
const payload = await opsRequest(`${luckyGiftExperimentPath(experimentId)}/overrides`, {
body: {
removes: (Array.isArray(removes) ? removes : [])
.map((userKey) => String(userKey ?? "").trim())
.filter(Boolean),
upserts: normalizeExperimentOverrides(upserts),
},
method: "PUT",
query: { app_code: normalizeAppCodeValue(appCode) },
});
return normalizeLuckyGiftExperiment(payload?.experiment || {}, appCode);
}
export async function endLuckyGiftExperiment(appCode, experimentId, winnerArm) {
// 结束实验没有默认获胜方:获胜配置会被重新发布为最新版本并全量生效,误传空值的后果不可回滚。
if (winnerArm !== "control" && winnerArm !== "treatment") {
throw new Error("结束实验必须选择获胜方");
}
const payload = await opsRequest(`${luckyGiftExperimentPath(experimentId)}/end`, {
body: { winner_arm: winnerArm },
method: "POST",
query: { app_code: normalizeAppCodeValue(appCode) },
});
return {
...payload,
experiment: normalizeLuckyGiftExperiment(payload?.experiment || {}, appCode),
};
}
export function normalizeLuckyGiftExperiment(experiment = {}, fallbackAppCode = "") {
const armStats = experiment.arm_stats ?? experiment.armStats;
return {
...experiment,
app_code: normalizeAppCodeValue(experiment.app_code || experiment.appCode || fallbackAppCode),
arm_stats: (Array.isArray(armStats) ? armStats : []).map((stat) => ({
actual_rtp_percent: numberValue(stat.actual_rtp_percent ?? stat.actualRtpPercent),
arm: String(stat.arm || ""),
rule_version: numberValue(stat.rule_version ?? stat.ruleVersion),
strategy_version: normalizeStrategyVersion(stat.strategy_version ?? stat.strategyVersion),
total_draws: numberValue(stat.total_draws ?? stat.totalDraws),
total_reward_coins: numberValue(stat.total_reward_coins ?? stat.totalRewardCoins),
total_spent_coins: numberValue(stat.total_spent_coins ?? stat.totalSpentCoins),
unique_users: numberValue(stat.unique_users ?? stat.uniqueUsers),
})),
control_rule_version: numberValue(experiment.control_rule_version ?? experiment.controlRuleVersion),
ended_at_ms: numberValue(experiment.ended_at_ms ?? experiment.endedAtMs),
experiment_id: String(experiment.experiment_id ?? experiment.experimentId ?? ""),
final_rule_version: numberValue(experiment.final_rule_version ?? experiment.finalRuleVersion),
name: String(experiment.name || ""),
overrides: (Array.isArray(experiment.overrides) ? experiment.overrides : []).map((item) => ({
// arm 只可能是两组之一;未知值按 treatment 处理会放大实验组,按 control 处理保持保守。
arm: item?.arm === "treatment" ? "treatment" : "control",
user_key: String(item?.user_key ?? item?.userKey ?? ""),
})),
pool_id: String(experiment.pool_id || experiment.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID,
started_at_ms: numberValue(experiment.started_at_ms ?? experiment.startedAtMs),
status: String(experiment.status || "")
.trim()
.toLowerCase(),
treatment_rule_version: numberValue(experiment.treatment_rule_version ?? experiment.treatmentRuleVersion),
treatment_traffic_percent: numberValue(
experiment.treatment_traffic_percent ?? experiment.treatmentTrafficPercent,
),
updated_at_ms: numberValue(experiment.updated_at_ms ?? experiment.updatedAtMs),
winner_arm: String(experiment.winner_arm ?? experiment.winnerArm ?? ""),
winner_rule_version: numberValue(experiment.winner_rule_version ?? experiment.winnerRuleVersion),
};
}
function luckyGiftExperimentPath(experimentId) {
const id = String(experimentId || "").trim();
if (!id) {
throw new Error("缺少实验 ID");
}
return `/lucky-gifts/experiments/${encodeURIComponent(id)}`;
}
function normalizeExperimentOverrides(overrides) {
return (Array.isArray(overrides) ? overrides : [])
.map((item) => ({
arm: item?.arm === "control" ? "control" : "treatment",
user_key: String(item?.user_key ?? item?.userKey ?? "").trim(),
}))
.filter((item) => item.user_key);
}
export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) { export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) {
const normalizedPath = path.startsWith("/") ? path : `/${path}`; const normalizedPath = path.startsWith("/") ? path : `/${path}`;
// 复用主后台请求层才能共享 refreshOnce运行期间 token 过期时先刷新再重放一次请求。 // 复用主后台请求层才能共享 refreshOnce运行期间 token 过期时先刷新再重放一次请求。
@ -149,6 +293,8 @@ export function buildOpsUrl(path, query = {}) {
export function normalizeDashboard({ apps = [], perApp = [] } = {}) { export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []); const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []);
// 实验按 app+pool 独占;这里保持扁平列表,活跃实验与配置行的关联由展示层的 family key 完成。
const experiments = perApp.flatMap((entry) => entry.experiments || []);
const normalizedPoolBalances = perApp const normalizedPoolBalances = perApp
.flatMap((entry) => (entry.poolBalances || []).map((item) => normalizePoolBalance(item, entry.appCode))) .flatMap((entry) => (entry.poolBalances || []).map((item) => normalizePoolBalance(item, entry.appCode)))
.filter((item) => item.app_code); .filter((item) => item.app_code);
@ -198,7 +344,7 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
totalSpentCoins: sumBy(appSummaries, "total_spent_coins"), totalSpentCoins: sumBy(appSummaries, "total_spent_coins"),
}; };
return { apps, appSummaries, luckyGifts, poolBalances, summary }; return { apps, appSummaries, experiments, luckyGifts, poolBalances, summary };
} }
export function normalizePoolBalance(pool = {}, fallbackAppCode = "") { export function normalizePoolBalance(pool = {}, fallbackAppCode = "") {
@ -319,6 +465,12 @@ function normalizeStrategyVersion(value) {
.toLowerCase(); .toLowerCase();
} }
function normalizeAppCodeValue(value) {
return String(value || "")
.trim()
.toLowerCase();
}
function luckyGiftConfigPayload(config = {}) { function luckyGiftConfigPayload(config = {}) {
const appCode = String(config.app_code || config.appCode || "") const appCode = String(config.app_code || config.appCode || "")
.trim() .trim()

View File

@ -3,13 +3,18 @@ import { setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "@/sha
import { import {
adjustLuckyGiftPool, adjustLuckyGiftPool,
buildOpsUrl, buildOpsUrl,
createLuckyGiftExperiment,
endLuckyGiftExperiment,
fetchLuckyGiftDraws, fetchLuckyGiftDraws,
fetchLuckyGiftExperiments,
fetchOpsDashboard, fetchOpsDashboard,
luckyGiftPoolKey, luckyGiftPoolKey,
normalizeDashboard, normalizeDashboard,
opsRequest, opsRequest,
OPS_API_PREFIX, OPS_API_PREFIX,
saveLuckyGiftConfig, saveLuckyGiftConfig,
setLuckyGiftExperimentOverrides,
updateLuckyGiftExperiment,
} from "./api.js"; } from "./api.js";
afterEach(() => { afterEach(() => {
@ -100,6 +105,19 @@ test("loads every pool config per app through the plural configs endpoint", asyn
total_spent_coins: 1000, total_spent_coins: 1000,
}); });
} }
if (text.includes("/lucky-gifts/experiments?")) {
return jsonResponse({
items: [
{
experiment_id: "exp-1",
name: "raise-rtp",
pool_id: "lucky",
status: "running",
treatment_traffic_percent: 5,
},
],
});
}
return jsonResponse({ items: [] }); return jsonResponse({ items: [] });
}); });
@ -110,6 +128,11 @@ test("loads every pool config per app through the plural configs endpoint", asyn
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/configs?app_code=lalu", "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/configs?app_code=lalu",
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools?app_code=lalu", "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools?app_code=lalu",
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu", "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu",
// dashboard 扇出不带 with_stats/status实验列表只驱动实验徽章和发布封锁。
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/experiments?app_code=lalu",
]);
expect(data.experiments).toEqual([
expect.objectContaining({ app_code: "lalu", experiment_id: "exp-1", pool_id: "lucky", status: "running" }),
]); ]);
expect(data.luckyGifts).toEqual([ expect(data.luckyGifts).toEqual([
expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "lucky", rule_version: 3 }), expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "lucky", rule_version: 3 }),
@ -393,3 +416,184 @@ test.each([
pool: { app_code: "lalu", pool_id: "lucky", strategy_version: "dynamic_v3" }, pool: { app_code: "lalu", pool_id: "lucky", strategy_version: "dynamic_v3" },
}); });
}); });
test("keeps the dashboard usable when the experiments endpoint is not deployed yet", async () => {
vi.spyOn(window, "fetch").mockImplementation(async (url) => {
const text = String(url);
if (text.endsWith("/apps")) {
return jsonResponse({ items: [{ app_code: "lalu", status: "active" }] });
}
if (text.includes("/lucky-gifts/experiments?")) {
return new Response(JSON.stringify({ code: 40400, message: "接口不存在或后端服务未更新" }), {
headers: { "Content-Type": "application/json" },
status: 404,
});
}
if (text.includes("/lucky-gifts/configs?")) {
return jsonResponse([{ app_code: "lalu", enabled: true, pool_id: "lucky", rule_version: 3 }]);
}
return jsonResponse({ items: [] });
});
const data = await fetchOpsDashboard();
// 实验接口晚于面板上线;请求失败必须降级为空列表而不是整页失败,
// 活跃实验期间的常规发布仍由 owner 以 409 兜底。
expect(data.experiments).toEqual([]);
expect(data.luckyGifts).toHaveLength(1);
});
test("loads experiments with optional stats and normalizes arm data", async () => {
const calls = [];
vi.spyOn(window, "fetch").mockImplementation(async (url) => {
calls.push(String(url));
return jsonResponse({
items: [
{
arm_stats: [
{
actual_rtp_percent: "95.5",
arm: "control",
rule_version: 3,
strategy_version: "dynamic_v3",
total_draws: "10",
total_reward_coins: 955,
total_spent_coins: 1000,
unique_users: 4,
},
],
control_rule_version: 3,
experiment_id: "exp-1",
name: "raise-rtp",
overrides: [{ arm: "treatment", user_key: 1001 }],
pool_id: "lucky",
status: "RUNNING",
treatment_rule_version: 4,
treatment_traffic_percent: "12.5",
},
],
});
});
const items = await fetchLuckyGiftExperiments("LALU", { poolId: "lucky", withStats: true });
expect(calls).toEqual([
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/experiments?app_code=lalu&pool_id=lucky&with_stats=1",
]);
expect(items).toEqual([
expect.objectContaining({
app_code: "lalu",
control_rule_version: 3,
experiment_id: "exp-1",
overrides: [{ arm: "treatment", user_key: "1001" }],
status: "running",
treatment_rule_version: 4,
treatment_traffic_percent: 12.5,
}),
]);
expect(items[0].arm_stats[0]).toEqual({
actual_rtp_percent: 95.5,
arm: "control",
rule_version: 3,
strategy_version: "dynamic_v3",
total_draws: 10,
total_reward_coins: 955,
total_spent_coins: 1000,
unique_users: 4,
});
});
test("creates an experiment with a forcibly enabled treatment config", async () => {
const calls = [];
vi.spyOn(window, "fetch").mockImplementation(async (url, init) => {
calls.push({ body: JSON.parse(init.body), method: init.method, url: String(url) });
return jsonResponse({
experiment: { experiment_id: "exp-9", status: "running", treatment_rule_version: 4 },
treatment_config: { app_code: "lalu", pool_id: "lucky", rule_version: 4 },
});
});
const result = await createLuckyGiftExperiment("lalu", {
config: {
app_code: "lalu",
enabled: false,
initial_pool_coins: 1_000_000,
pool_id: "lucky",
strategy_version: "dynamic_v3",
},
name: " 提高RTP ",
overrides: [{ arm: "treatment", user_key: " 1001 " }, { user_key: "" }],
treatment_traffic_percent: 5,
});
expect(calls[0].method).toBe("POST");
expect(calls[0].url).toBe("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/experiments?app_code=lalu");
expect(calls[0].body.name).toBe("提高RTP");
expect(calls[0].body.treatment_traffic_percent).toBe(5);
// 空 user_key 在传输层剔除实验组配置必须直接可运行dynamic_v3 的隐式 seed 同样清零。
expect(calls[0].body.overrides).toEqual([{ arm: "treatment", user_key: "1001" }]);
expect(calls[0].body.config).toMatchObject({
app_code: "lalu",
enabled: true,
initial_pool_coins: 0,
pool_id: "lucky",
strategy_version: "dynamic_v3",
});
expect(result.experiment).toMatchObject({ experiment_id: "exp-9", status: "running", treatment_rule_version: 4 });
});
test.each([
[{ treatment_traffic_percent: 20 }, { treatment_traffic_percent: 20 }],
[{ status: "paused" }, { status: "paused" }],
])("patches only the provided experiment fields %j", async (patch, expectedBody) => {
const calls = [];
vi.spyOn(window, "fetch").mockImplementation(async (url, init) => {
calls.push({ body: JSON.parse(init.body), method: init.method, url: String(url) });
return jsonResponse({ experiment: { experiment_id: "exp-9", status: "running" } });
});
const experiment = await updateLuckyGiftExperiment("lalu", "exp-9", patch);
// PATCH 只携带明确修改的字段:暂停不应重置放量,放量不应改状态。
expect(calls).toEqual([
{
body: expectedBody,
method: "PATCH",
url: "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/experiments/exp-9?app_code=lalu",
},
]);
expect(experiment).toMatchObject({ experiment_id: "exp-9", status: "running" });
});
test("replaces overrides and ends the experiment through dedicated routes", async () => {
const calls = [];
vi.spyOn(window, "fetch").mockImplementation(async (url, init) => {
calls.push({ body: JSON.parse(init.body), method: init.method, url: String(url) });
return jsonResponse({
experiment: { experiment_id: "exp-9", status: "completed", winner_arm: "treatment" },
final_config: { rule_version: 5 },
});
});
await setLuckyGiftExperimentOverrides("lalu", "exp-9", {
removes: [" 1002 ", ""],
upserts: [{ arm: "control", user_key: "1001" }],
});
const ended = await endLuckyGiftExperiment("lalu", "exp-9", "treatment");
// 获胜方缺失时不允许发请求:结束实验会全量重发配置,不可回滚。
await expect(endLuckyGiftExperiment("lalu", "exp-9", "")).rejects.toThrow("结束实验必须选择获胜方");
expect(calls).toEqual([
{
body: { removes: ["1002"], upserts: [{ arm: "control", user_key: "1001" }] },
method: "PUT",
url: "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/experiments/exp-9/overrides?app_code=lalu",
},
{
body: { winner_arm: "treatment" },
method: "POST",
url: "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/experiments/exp-9/end?app_code=lalu",
},
]);
expect(ended.experiment).toMatchObject({ status: "completed", winner_arm: "treatment" });
});

View File

@ -5,6 +5,7 @@ import { Button } from "@/shared/ui/Button.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx";
import { TimeText } from "@/shared/ui/TimeText.jsx"; import { TimeText } from "@/shared/ui/TimeText.jsx";
import { luckyGiftPoolKey } from "../api.js"; import { luckyGiftPoolKey } from "../api.js";
import { activeExperimentsByPool, experimentPoolFamilyKey, experimentStatusBadge } from "../experimentForm.js";
import { formatNumber, formatPercent } from "../format.js"; import { formatNumber, formatPercent } from "../format.js";
import { LuckyGiftPoolActions } from "./LuckyGiftPoolActions.jsx"; import { LuckyGiftPoolActions } from "./LuckyGiftPoolActions.jsx";
import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
@ -13,13 +14,17 @@ export function ConfigsView({
apps, apps,
canCredit, canCredit,
canDebit, canDebit,
canExperiment,
canUpdate, canUpdate,
disabled, disabled,
experiments,
luckyGifts, luckyGifts,
onCreate, onCreate,
onCreateExperiment,
onCredit, onCredit,
onDebit, onDebit,
onEdit, onEdit,
onManageExperiment,
poolBalances, poolBalances,
saving, saving,
}) { }) {
@ -36,28 +41,53 @@ export function ConfigsView({
// dashboard // dashboard
const items = useMemo(() => { const items = useMemo(() => {
const poolByKey = new Map(poolBalances.map((pool) => [luckyGiftPoolKey(pool), pool])); const poolByKey = new Map(poolBalances.map((pool) => [luckyGiftPoolKey(pool), pool]));
// app+pool fixed_v2 / dynamic_v3
// family key luckyGiftPoolKey
const experimentByPool = activeExperimentsByPool(experiments);
const visibleConfigs = appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts; const visibleConfigs = appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts;
// app+pool+strategy app/pool fixed_v2 dynamic_v3 // app+pool+strategy app/pool fixed_v2 dynamic_v3
// strategy_version // strategy_version
return visibleConfigs.map((config) => ({ return visibleConfigs.map((config) => ({
...config, ...config,
active_experiment: experimentByPool.get(experimentPoolFamilyKey(config)) || null,
pool_balance: poolByKey.get(luckyGiftPoolKey(config)), pool_balance: poolByKey.get(luckyGiftPoolKey(config)),
})); }));
}, [appFilter, luckyGifts, poolBalances]); }, [appFilter, experiments, luckyGifts, poolBalances]);
// GET lucky-gift:view
//
const hasExperimentEntry = canExperiment || items.some((item) => item.active_experiment);
const columns = useMemo( const columns = useMemo(
() => () =>
buildColumns({ buildColumns({
canCredit, canCredit,
canDebit, canDebit,
canExperiment,
canUpdate, canUpdate,
disabled, disabled,
hasExperimentEntry,
onCreateExperiment,
onCredit, onCredit,
onDebit, onDebit,
onEdit, onEdit,
onManageExperiment,
saving, saving,
}), }),
[canCredit, canDebit, canUpdate, disabled, onCredit, onDebit, onEdit, saving], [
canCredit,
canDebit,
canExperiment,
canUpdate,
disabled,
hasExperimentEntry,
onCreateExperiment,
onCredit,
onDebit,
onEdit,
onManageExperiment,
saving,
],
); );
return ( return (
@ -97,7 +127,20 @@ export function ConfigsView({
); );
} }
function buildColumns({ canCredit, canDebit, canUpdate, disabled, onCredit, onDebit, onEdit, saving }) { function buildColumns({
canCredit,
canDebit,
canExperiment,
canUpdate,
disabled,
hasExperimentEntry,
onCreateExperiment,
onCredit,
onDebit,
onEdit,
onManageExperiment,
saving,
}) {
const columns = [ const columns = [
{ key: "app_code", label: "应用", width: "minmax(88px, 0.7fr)" }, { key: "app_code", label: "应用", width: "minmax(88px, 0.7fr)" },
{ key: "pool_id", label: "奖池", width: "minmax(110px, 0.9fr)" }, { key: "pool_id", label: "奖池", width: "minmax(110px, 0.9fr)" },
@ -137,6 +180,16 @@ function buildColumns({ canCredit, canDebit, canUpdate, disabled, onCredit, onDe
}, },
width: "minmax(128px, 0.8fr)", width: "minmax(128px, 0.8fr)",
}, },
{
key: "experiment",
label: "实验",
render: (item) => {
// running/pausedcompleted
const badge = experimentStatusBadge(item.active_experiment);
return badge ? <OpsStatusBadge label={badge.label} tone={badge.tone} /> : "-";
},
width: "minmax(120px, 0.8fr)",
},
{ key: "target_rtp_percent", label: "目标 RTP", render: (item) => formatPercent(item.target_rtp_percent) }, { key: "target_rtp_percent", label: "目标 RTP", render: (item) => formatPercent(item.target_rtp_percent) },
{ key: "pool_rate_percent", label: "公共奖池", render: (item) => formatPercent(item.pool_rate_percent) }, { key: "pool_rate_percent", label: "公共奖池", render: (item) => formatPercent(item.pool_rate_percent) },
{ {
@ -159,13 +212,14 @@ function buildColumns({ canCredit, canDebit, canUpdate, disabled, onCredit, onDe
}, },
]; ];
if (canUpdate || canCredit || canDebit) { if (canUpdate || canCredit || canDebit || hasExperimentEntry) {
columns.push({ columns.push({
fixed: "right", fixed: "right",
key: "actions", key: "actions",
label: "操作", label: "操作",
render: (item) => { render: (item) => {
const identity = `${item.app_code} / ${item.pool_id} / ${item.strategy_version}`; const identity = `${item.app_code} / ${item.pool_id} / ${item.strategy_version}`;
const hasActiveExperiment = Boolean(item.active_experiment);
const actionPool = item.pool_balance || { const actionPool = item.pool_balance || {
app_code: item.app_code, app_code: item.app_code,
pool_id: item.pool_id, pool_id: item.pool_id,
@ -184,17 +238,47 @@ function buildColumns({ canCredit, canDebit, canUpdate, disabled, onCredit, onDe
{canUpdate ? ( {canUpdate ? (
<Button <Button
aria-label={`${item.is_default ? "发布配置" : "新增版本"} ${identity}`} aria-label={`${item.is_default ? "发布配置" : "新增版本"} ${identity}`}
disabled={saving} // owner PUT 409
//
disabled={saving || hasActiveExperiment}
size="small" size="small"
title={hasActiveExperiment ? "实验进行中,结束实验后才能发布" : undefined}
onClick={() => onEdit(item)} onClick={() => onEdit(item)}
> >
{item.is_default ? "发布配置" : "新增版本"} {item.is_default ? "发布配置" : "新增版本"}
</Button> </Button>
) : null} ) : null}
{hasActiveExperiment ? (
<Button
aria-label={`实验详情 ${identity}`}
disabled={saving}
size="small"
variant="primary"
onClick={() => onManageExperiment(item.active_experiment)}
>
实验详情
</Button>
) : canExperiment && item.enabled && !item.is_default ? (
// 稿/线
<Button
aria-label={`创建 AB 实验 ${identity}`}
disabled={saving}
size="small"
onClick={() => onCreateExperiment(item)}
>
创建 AB 实验
</Button>
) : null}
</div> </div>
); );
}, },
width: `${(canCredit ? 110 : 0) + (canDebit ? 110 : 0) + (canUpdate ? 110 : 0) + 20}px`, width: `${
(canCredit ? 110 : 0) +
(canDebit ? 110 : 0) +
(canUpdate ? 110 : 0) +
(hasExperimentEntry ? 128 : 0) +
20
}px`,
}); });
} }

View File

@ -13,12 +13,23 @@ import {
stageSummary, stageSummary,
validateLuckyGiftForm, validateLuckyGiftForm,
} from "../configForm.js"; } from "../configForm.js";
import {
buildExperimentCreateRequest,
experimentDraft,
formatTrafficPercent,
validateExperimentDraft,
} from "../experimentForm.js";
import { formatNumber, formatPercent } from "../format.js"; import { formatNumber, formatPercent } from "../format.js";
import { LuckyGiftConfigSections } from "./LuckyGiftConfigSections.jsx"; import { LuckyGiftConfigSections } from "./LuckyGiftConfigSections.jsx";
import { LuckyGiftExperimentFields } from "./LuckyGiftExperimentFields.jsx";
import { LuckyGiftStageDesigner } from "./LuckyGiftStageDesigner.jsx"; import { LuckyGiftStageDesigner } from "./LuckyGiftStageDesigner.jsx";
export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) { export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) {
const [form, setForm] = useState(() => configToForm(config)); const isExperiment = mode === "experiment";
// owner enabled true
//
const [form, setForm] = useState(() => configToForm(isExperiment ? { ...config, enabled: true } : config));
const [experiment, setExperiment] = useState(experimentDraft);
const [activeStage, setActiveStage] = useState("novice"); const [activeStage, setActiveStage] = useState("novice");
const [validationErrors, setValidationErrors] = useState([]); const [validationErrors, setValidationErrors] = useState([]);
const validationRef = useRef(null); const validationRef = useRef(null);
@ -42,7 +53,9 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
setForm((current) => { setForm((current) => {
const next = { ...current, [field]: value }; const next = { ...current, [field]: value };
if (field === "strategy_version" && value === "dynamic_v3" && current.strategy_version !== "dynamic_v3") { if (field === "strategy_version" && value === "dynamic_v3" && current.strategy_version !== "dynamic_v3") {
return applyDynamicStrategyDefaults(next); const dynamicDraft = applyDynamicStrategyDefaults(next);
// 稿稿
return isExperiment ? { ...dynamicDraft, enabled: true } : dynamicDraft;
} }
// RTP // RTP
return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next; return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next;
@ -118,18 +131,38 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
); );
}; };
const updateExperimentField = (field, value) => {
setValidationErrors([]);
setExperiment((current) => ({ ...current, [field]: value }));
};
const handleSubmit = (event) => { const handleSubmit = (event) => {
event.preventDefault(); event.preventDefault();
const errors = validateLuckyGiftForm(form); // 使
// payload
const submittedForm = isExperiment ? { ...form, enabled: true } : form;
const errors = isExperiment
? [...validateExperimentDraft(experiment), ...validateLuckyGiftForm(submittedForm)]
: validateLuckyGiftForm(submittedForm);
if (errors.length) { if (errors.length) {
setValidationErrors(errors); setValidationErrors(errors);
return; return;
} }
if (isExperiment) {
onSubmit(buildExperimentCreateRequest(experiment, formToConfig(config, submittedForm)));
return;
}
onSubmit(formToConfig(config, form)); onSubmit(formToConfig(config, form));
}; };
const isCreate = mode === "create"; const isCreate = mode === "create";
const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本"; const title = isExperiment
? "创建 AB 实验"
: isCreate
? "添加幸运礼物配置"
: config.is_default
? "发布幸运礼物配置"
: "新增幸运礼物版本";
return ( return (
<Dialog <Dialog
@ -146,7 +179,11 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
<span>{title}</span> <span>{title}</span>
<small> <small>
{form.app_code || "-"} / {form.pool_id || "default"} /{" "} {form.app_code || "-"} / {form.pool_id || "default"} /{" "}
{config.is_default || isCreate ? "默认草稿" : `当前版本 v${config.rule_version ?? "-"}`} {isExperiment
? `对照组为当前版本 v${config.rule_version ?? "-"},本表单发布为实验组新版本`
: config.is_default || isCreate
? "默认草稿"
: `当前版本 v${config.rule_version ?? "-"}`}
</small> </small>
</DialogTitle> </DialogTitle>
<dl className="ops-config-summary" aria-label="配置摘要"> <dl className="ops-config-summary" aria-label="配置摘要">
@ -166,7 +203,16 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
label={form.strategy_version === "dynamic_v3" ? "每轮观察流水" : "V2 窗口配置"} label={form.strategy_version === "dynamic_v3" ? "每轮观察流水" : "V2 窗口配置"}
value={formatNumber(Number(form.settlement_window_wager))} value={formatNumber(Number(form.settlement_window_wager))}
/> />
<ConfigMetric label="发布状态" value={form.enabled ? "启用" : "停用"} /> <ConfigMetric
label={isExperiment ? "实验组流量" : "发布状态"}
value={
isExperiment
? formatTrafficPercent(Number(experiment.trafficPercent))
: form.enabled
? "启用"
: "停用"
}
/>
</dl> </dl>
<DialogContent className="ops-config-dialog-content" dividers> <DialogContent className="ops-config-dialog-content" dividers>
{validationErrors.length ? ( {validationErrors.length ? (
@ -177,7 +223,7 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
role="alert" role="alert"
tabIndex={-1} tabIndex={-1}
> >
<strong>配置不能发布</strong> <strong>{isExperiment ? "实验不能创建" : "配置不能发布"}</strong>
<ul> <ul>
{validationErrors.map((message) => ( {validationErrors.map((message) => (
<li key={message}>{message}</li> <li key={message}>{message}</li>
@ -186,9 +232,18 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
</div> </div>
) : null} ) : null}
{isExperiment ? (
<LuckyGiftExperimentFields
disabled={saving}
draft={experiment}
onChange={updateExperimentField}
/>
) : null}
<div className="ops-config-layout"> <div className="ops-config-layout">
<LuckyGiftConfigSections <LuckyGiftConfigSections
appOptions={appOptions} appOptions={appOptions}
experimentMode={isExperiment}
form={form} form={form}
isCreate={isCreate} isCreate={isCreate}
onChange={updateField} onChange={updateField}
@ -213,7 +268,7 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
取消 取消
</Button> </Button>
<Button disabled={saving} type="submit" variant="primary"> <Button disabled={saving} type="submit" variant="primary">
{saving ? "保存中" : "保存配置"} {saving ? (isExperiment ? "创建中" : "保存中") : isExperiment ? "创建实验" : "保存配置"}
</Button> </Button>
</DialogActions> </DialogActions>
</form> </form>

View File

@ -16,7 +16,7 @@ const DYNAMIC_SECTIONS = [
["limits", "限额"], ["limits", "限额"],
]; ];
export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }) { export function LuckyGiftConfigSections({ appOptions, experimentMode = false, form, isCreate, onChange }) {
const scrollRef = useRef(null); const scrollRef = useRef(null);
const sections = useMemo( const sections = useMemo(
() => (form.strategy_version === "dynamic_v3" ? [...BASE_SECTIONS, ...DYNAMIC_SECTIONS] : BASE_SECTIONS), () => (form.strategy_version === "dynamic_v3" ? [...BASE_SECTIONS, ...DYNAMIC_SECTIONS] : BASE_SECTIONS),
@ -160,6 +160,9 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
/> />
)} )}
<TextField <TextField
// app+pool
// ID
disabled={experimentMode}
fullWidth fullWidth
required required
label={<LuckyGiftFieldLabel field="pool_id" label="奖池" />} label={<LuckyGiftFieldLabel field="pool_id" label="奖池" />}
@ -168,6 +171,9 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
onChange={(event) => onChange("pool_id", event.target.value)} onChange={(event) => onChange("pool_id", event.target.value)}
/> />
<TextField <TextField
// owner /
// 穿 room saga dynamic_v3
disabled={experimentMode}
fullWidth fullWidth
label={<LuckyGiftFieldLabel field="strategy_version" label="策略版本" />} label={<LuckyGiftFieldLabel field="strategy_version" label="策略版本" />}
select select
@ -183,6 +189,9 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
control={ control={
<AdminSwitch <AdminSwitch
checked={form.enabled} checked={form.enabled}
// owner
//
disabled={experimentMode}
label="发布状态" label="发布状态"
onChange={(event) => onChange("enabled", event.target.checked)} onChange={(event) => onChange("enabled", event.target.checked)}
/> />
@ -190,7 +199,13 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
label={ label={
<LuckyGiftFieldLabel <LuckyGiftFieldLabel
field="enabled" field="enabled"
label={form.enabled ? "发布后立即启用" : "发布后保持停用"} label={
experimentMode
? "实验组配置直接参与开奖"
: form.enabled
? "发布后立即启用"
: "发布后保持停用"
}
/> />
} }
/> />

View 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>
);
}

View 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;
// 00
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>
);
}

View File

@ -237,6 +237,21 @@ const FIELD_HELP = {
constraint: constraint:
"10 倍及以上必须勾选才能发布。不要把它当成独立的中奖开关V3 的高低余额变化由上方两组中奖权重控制。", "10 倍及以上必须勾选才能发布。不要把它当成独立的中奖开关V3 的高低余额变化由上方两组中奖权重控制。",
}, },
experiment_name: {
summary: "标识这次 AB 实验,显示在配置列表徽章和实验管理弹窗中,方便区分同一奖池的历史实验。",
adjust: "只影响展示,不影响分流;创建后不可修改。",
constraint: "必填建议包含实验目的例如“提高RTP到98-0715”。",
},
experiment_traffic_percent: {
summary: "控制多大比例的用户进入实验组,按用户 ID 哈希分流,其余用户继续使用对照组(当前最新启用版本)。",
adjust: "实验期间可随时调整且放量单调:调大不会把已进入实验组的用户踢回对照组。",
constraint: "0-100填 0 表示只有白名单用户进入实验组(灰度验证)。暂停时全部流量(含白名单)回到对照组。",
},
experiment_whitelist: {
summary: "白名单用户不参与哈希分流,创建时全部固定进入实验组,用于放量前的内部灰度验证。",
adjust: "实验进行中可在实验管理里增删白名单,或把某个用户指定到对照组。",
constraint: "每项为用户 ID 字符串;暂停期间白名单用户同样回到对照组,恢复后回到原分组。",
},
tier_enabled: { tier_enabled: {
summary: "决定这一档是否参加当前阶段的普通基础抽奖。关闭后不会从普通奖档中选中。", summary: "决定这一档是否参加当前阶段的普通基础抽奖。关闭后不会从普通奖档中选中。",
adjust: "关闭高倍率档会降低大额结果种类;重新开启后系统会重新计算整组概率。", adjust: "关闭高倍率档会降低大额结果种类;重新开启后系统会重新计算整组概率。",

View 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;
}

View 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%");
});
});

View File

@ -801,6 +801,190 @@ body {
padding: var(--space-2) var(--space-6); padding: var(--space-2) var(--space-6);
} }
/* ---- AB 实验 ---- */
/* 创建实验时叠加在配置表单顶部的实验字段区;固定高度不参与下方双栏布局的滚动。 */
.ops-experiment-setup {
flex: 0 0 auto;
border-bottom: 1px solid var(--border);
background: var(--bg-card);
padding: var(--space-3) var(--space-4);
}
.ops-experiment-setup__head {
display: grid;
gap: var(--space-1);
margin-bottom: var(--space-3);
}
.ops-experiment-setup__head h3 {
margin: 0;
font-size: 14px;
}
.ops-experiment-setup__head span {
color: var(--text-tertiary);
font-size: 12px;
font-weight: 600;
line-height: 1.5;
}
.ops-experiment-setup__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ops-experiment-title {
display: grid;
gap: var(--space-1);
}
.ops-experiment-title small {
color: var(--text-tertiary);
font-size: 12px;
font-weight: 700;
}
.ops-experiment-content {
display: grid;
gap: var(--space-4);
padding-top: var(--space-4) !important;
}
.ops-experiment-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
row-gap: var(--space-3);
}
.ops-experiment-arms {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.ops-experiment-arm {
display: grid;
align-content: start;
gap: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-card);
padding: var(--space-3) var(--space-4);
}
.ops-experiment-arm--winner {
border-color: var(--success-border);
background: var(--success-surface);
}
.ops-experiment-arm__head {
display: flex;
align-items: center;
gap: var(--space-2);
}
.ops-experiment-arm__head strong {
font-size: 14px;
}
.ops-experiment-arm__head span {
color: var(--text-tertiary);
font-size: 12px;
font-weight: 800;
}
.ops-experiment-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
margin: 0;
}
.ops-experiment-stats dt {
color: var(--text-tertiary);
font-size: 11px;
font-weight: 800;
}
.ops-experiment-stats dd {
margin: 0;
color: var(--text-primary);
font-size: 13px;
font-weight: 800;
}
.ops-experiment-section h3 {
margin: 0 0 var(--space-2);
font-size: 13px;
}
.ops-experiment-controls {
display: grid;
gap: var(--space-3);
}
.ops-experiment-inline-form {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: var(--space-2);
}
.ops-experiment-overrides ul {
display: grid;
gap: var(--space-1);
margin: var(--space-2) 0 var(--space-3);
padding: 0;
list-style: none;
}
.ops-experiment-overrides li {
display: flex;
align-items: center;
gap: var(--space-3);
border: 1px solid var(--row-border, var(--border));
border-radius: var(--radius-control);
padding: var(--space-1) var(--space-3);
}
.ops-experiment-overrides li code {
flex: 1 1 auto;
overflow: hidden;
font-size: 13px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.ops-experiment-overrides li span {
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
}
/* 结束实验的二次确认层:不可回滚的全量发布动作,用 danger 面板与普通操作区分。 */
.ops-experiment-confirm {
display: grid;
gap: var(--space-2);
border: 1px solid var(--danger-border);
border-radius: var(--radius-card);
background: var(--danger-surface);
color: var(--danger);
padding: var(--space-3) var(--space-4);
}
.ops-experiment-confirm p {
margin: 0;
font-size: 13px;
font-weight: 600;
line-height: 1.5;
}
.ops-experiment-confirm div {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
/* ---- 响应式 ---- */ /* ---- 响应式 ---- */
@media (max-width: 1279px) { @media (max-width: 1279px) {
@ -925,6 +1109,15 @@ body {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.ops-experiment-setup__grid,
.ops-experiment-arms {
grid-template-columns: minmax(0, 1fr);
}
.ops-experiment-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ops-jackpot-flow { .ops-jackpot-flow {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }

View File

@ -194,6 +194,7 @@ export const PERMISSIONS = {
sevenDayCheckInUpdate: "seven-day-checkin:update", sevenDayCheckInUpdate: "seven-day-checkin:update",
luckyGiftView: "lucky-gift:view", luckyGiftView: "lucky-gift:view",
luckyGiftUpdate: "lucky-gift:update", luckyGiftUpdate: "lucky-gift:update",
luckyGiftExperiment: "lucky-gift:experiment",
luckyGiftPoolCredit: "lucky-gift:pool-credit", luckyGiftPoolCredit: "lucky-gift:pool-credit",
luckyGiftPoolDebit: "lucky-gift:pool-debit", luckyGiftPoolDebit: "lucky-gift:pool-debit",
wheelView: "wheel:view", wheelView: "wheel:view",

View File

@ -59,6 +59,7 @@ export const API_OPERATIONS = {
createGift: "createGift", createGift: "createGift",
createH5Link: "createH5Link", createH5Link: "createH5Link",
createHostAgencyPolicy: "createHostAgencyPolicy", createHostAgencyPolicy: "createHostAgencyPolicy",
createLuckyGiftExperiment: "createLuckyGiftExperiment",
createManager: "createManager", createManager: "createManager",
createMenu: "createMenu", createMenu: "createMenu",
createPermission: "createPermission", createPermission: "createPermission",
@ -123,6 +124,7 @@ export const API_OPERATIONS = {
enableResource: "enableResource", enableResource: "enableResource",
enableResourceGroup: "enableResourceGroup", enableResourceGroup: "enableResourceGroup",
enableResourceShopItem: "enableResourceShopItem", enableResourceShopItem: "enableResourceShopItem",
endLuckyGiftExperiment: "endLuckyGiftExperiment",
expireRoomRpsChallenge: "expireRoomRpsChallenge", expireRoomRpsChallenge: "expireRoomRpsChallenge",
exportActivityTemplateData: "exportActivityTemplateData", exportActivityTemplateData: "exportActivityTemplateData",
exportCoinSellerLedger: "exportCoinSellerLedger", exportCoinSellerLedger: "exportCoinSellerLedger",
@ -236,6 +238,7 @@ export const API_OPERATIONS = {
listLoginLogs: "listLoginLogs", listLoginLogs: "listLoginLogs",
listLuckyGiftConfigs: "listLuckyGiftConfigs", listLuckyGiftConfigs: "listLuckyGiftConfigs",
listLuckyGiftDraws: "listLuckyGiftDraws", listLuckyGiftDraws: "listLuckyGiftDraws",
listLuckyGiftExperiments: "listLuckyGiftExperiments",
listLuckyGiftPoolBalances: "listLuckyGiftPoolBalances", listLuckyGiftPoolBalances: "listLuckyGiftPoolBalances",
listManagers: "listManagers", listManagers: "listManagers",
listOperationLogs: "listOperationLogs", listOperationLogs: "listOperationLogs",
@ -333,6 +336,7 @@ export const API_OPERATIONS = {
setDiceRobotStatus: "setDiceRobotStatus", setDiceRobotStatus: "setDiceRobotStatus",
setGameStatus: "setGameStatus", setGameStatus: "setGameStatus",
setGameWhitelistEnabled: "setGameWhitelistEnabled", setGameWhitelistEnabled: "setGameWhitelistEnabled",
setLuckyGiftExperimentOverrides: "setLuckyGiftExperimentOverrides",
setPrettyIdStatus: "setPrettyIdStatus", setPrettyIdStatus: "setPrettyIdStatus",
setTaskDefinitionStatus: "setTaskDefinitionStatus", setTaskDefinitionStatus: "setTaskDefinitionStatus",
setThirdPartyPaymentMethodStatus: "setThirdPartyPaymentMethodStatus", setThirdPartyPaymentMethodStatus: "setThirdPartyPaymentMethodStatus",
@ -366,6 +370,7 @@ export const API_OPERATIONS = {
updateHostAgencyPolicy: "updateHostAgencyPolicy", updateHostAgencyPolicy: "updateHostAgencyPolicy",
updateHumanRoomRobotConfig: "updateHumanRoomRobotConfig", updateHumanRoomRobotConfig: "updateHumanRoomRobotConfig",
updateInviteActivityRewardConfig: "updateInviteActivityRewardConfig", updateInviteActivityRewardConfig: "updateInviteActivityRewardConfig",
updateLuckyGiftExperiment: "updateLuckyGiftExperiment",
updateManager: "updateManager", updateManager: "updateManager",
updateMenu: "updateMenu", updateMenu: "updateMenu",
updateMenuVisible: "updateMenuVisible", updateMenuVisible: "updateMenuVisible",
@ -738,6 +743,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "host-agency-policy:create", permission: "host-agency-policy:create",
permissions: ["host-agency-policy:create"] permissions: ["host-agency-policy:create"]
}, },
createLuckyGiftExperiment: {
method: "POST",
operationId: API_OPERATIONS.createLuckyGiftExperiment,
path: "/v1/admin/ops-center/lucky-gifts/experiments",
permission: "lucky-gift:experiment",
permissions: ["lucky-gift:experiment"]
},
createManager: { createManager: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.createManager, operationId: API_OPERATIONS.createManager,
@ -1186,6 +1198,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "resource-shop:update", permission: "resource-shop:update",
permissions: ["resource-shop:update"] permissions: ["resource-shop:update"]
}, },
endLuckyGiftExperiment: {
method: "POST",
operationId: API_OPERATIONS.endLuckyGiftExperiment,
path: "/v1/admin/ops-center/lucky-gifts/experiments/{experimentId}/end",
permission: "lucky-gift:experiment",
permissions: ["lucky-gift:experiment"]
},
expireRoomRpsChallenge: { expireRoomRpsChallenge: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.expireRoomRpsChallenge, operationId: API_OPERATIONS.expireRoomRpsChallenge,
@ -1975,6 +1994,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "lucky-gift:view", permission: "lucky-gift:view",
permissions: ["lucky-gift:view"] permissions: ["lucky-gift:view"]
}, },
listLuckyGiftExperiments: {
method: "GET",
operationId: API_OPERATIONS.listLuckyGiftExperiments,
path: "/v1/admin/ops-center/lucky-gifts/experiments",
permission: "lucky-gift:view",
permissions: ["lucky-gift:view"]
},
listLuckyGiftPoolBalances: { listLuckyGiftPoolBalances: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listLuckyGiftPoolBalances, operationId: API_OPERATIONS.listLuckyGiftPoolBalances,
@ -2640,6 +2666,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "game:update", permission: "game:update",
permissions: ["game:update"] permissions: ["game:update"]
}, },
setLuckyGiftExperimentOverrides: {
method: "PUT",
operationId: API_OPERATIONS.setLuckyGiftExperimentOverrides,
path: "/v1/admin/ops-center/lucky-gifts/experiments/{experimentId}/overrides",
permission: "lucky-gift:experiment",
permissions: ["lucky-gift:experiment"]
},
setPrettyIdStatus: { setPrettyIdStatus: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.setPrettyIdStatus, operationId: API_OPERATIONS.setPrettyIdStatus,
@ -2871,6 +2904,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "invite-activity-reward:update", permission: "invite-activity-reward:update",
permissions: ["invite-activity-reward:update"] permissions: ["invite-activity-reward:update"]
}, },
updateLuckyGiftExperiment: {
method: "PATCH",
operationId: API_OPERATIONS.updateLuckyGiftExperiment,
path: "/v1/admin/ops-center/lucky-gifts/experiments/{experimentId}",
permission: "lucky-gift:experiment",
permissions: ["lucky-gift:experiment"]
},
updateManager: { updateManager: {
method: "PUT", method: "PUT",
operationId: API_OPERATIONS.updateManager, operationId: API_OPERATIONS.updateManager,

View File

@ -356,6 +356,70 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/admin/ops-center/lucky-gifts/experiments": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listLuckyGiftExperiments"];
put?: never;
post: operations["createLuckyGiftExperiment"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/ops-center/lucky-gifts/experiments/{experimentId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch: operations["updateLuckyGiftExperiment"];
trace?: never;
};
"/admin/ops-center/lucky-gifts/experiments/{experimentId}/overrides": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["setLuckyGiftExperimentOverrides"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/ops-center/lucky-gifts/experiments/{experimentId}/end": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["endLuckyGiftExperiment"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/activity/wheel/config": { "/admin/activity/wheel/config": {
parameters: { parameters: {
query?: never; query?: never;
@ -8486,6 +8550,72 @@ export interface operations {
}; };
}; };
}; };
listLuckyGiftExperiments: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
createLuckyGiftExperiment: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
updateLuckyGiftExperiment: {
parameters: {
query?: never;
header?: never;
path: {
experimentId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
setLuckyGiftExperimentOverrides: {
parameters: {
query?: never;
header?: never;
path: {
experimentId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
endLuckyGiftExperiment: {
parameters: {
query?: never;
header?: never;
path: {
experimentId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
getWheelConfig: { getWheelConfig: {
parameters: { parameters: {
query?: never; query?: never;

View File

@ -79,7 +79,6 @@ export default defineConfig({
databiSocial: new URL("./databi/social/index.html", import.meta.url).pathname, databiSocial: new URL("./databi/social/index.html", import.meta.url).pathname,
finance: new URL("./finance/index.html", import.meta.url).pathname, finance: new URL("./finance/index.html", import.meta.url).pathname,
externalAdmin: new URL("./external-admin/index.html", import.meta.url).pathname, externalAdmin: new URL("./external-admin/index.html", import.meta.url).pathname,
moneyRedirect: new URL("./money/index.html", import.meta.url).pathname,
opsCenter: new URL("./ops-center/index.html", import.meta.url).pathname opsCenter: new URL("./ops-center/index.html", import.meta.url).pathname
} }
} }