feat(ops): add experiment arm pool controls

This commit is contained in:
zhx 2026-07-17 14:40:22 +08:00
parent 0be72eaff1
commit dc8b9e488d
3 changed files with 116 additions and 5 deletions

View File

@ -318,9 +318,7 @@ export function OpsCenterApp({ can = denyPermission }) {
runExperimentAction(
(current) => updateLuckyGiftExperiment(current.app_code, current.experiment_id, { status }),
() =>
status === "paused"
? "实验已暂停,全部流量(含白名单)回到对照组"
: "实验已恢复,按原分组继续分流",
status === "paused" ? "实验已暂停,全部流量(含白名单)回到对照组" : "实验已恢复,按原分组继续分流",
),
[runExperimentAction],
);
@ -479,11 +477,17 @@ export function OpsCenterApp({ can = denyPermission }) {
{experimentManage ? (
<LuckyGiftExperimentManageDialog
busy={experimentBusy}
canCreditPool={canCreditPool}
canDebitPool={canDebitPool}
canManage={canManageExperiment}
experiment={experimentManage.experiment}
poolBalances={data.poolBalances}
poolBusy={adjustingPool || state.loading}
statsLoading={experimentManage.statsLoading}
onAdjustTraffic={adjustExperimentTraffic}
onClose={() => setExperimentManage(null)}
onCreditPool={(pool) => openPoolAdjustment(pool, "in")}
onDebitPool={(pool) => openPoolAdjustment(pool, "out")}
onEnd={endExperiment}
onRemoveOverride={removeExperimentOverride}
onSetStatus={setExperimentStatus}

View File

@ -153,6 +153,24 @@ beforeEach(() => {
strategy_version: "fixed_v2",
updated_at_ms: 1767000000000,
},
{
app_code: "lalu",
available_balance: 9_000_000,
balance: 10_000_000,
materialized: true,
pool_id: "super_lucky",
reserve_floor: 1_000_000,
strategy_version: "dynamic_v3",
},
{
app_code: "lalu",
available_balance: 1_500_000,
balance: 2_000_000,
materialized: true,
pool_id: "super_lucky",
reserve_floor: 500_000,
strategy_version: "fixed_v2",
},
{
app_code: "yumi",
available_balance: 361500,
@ -199,7 +217,7 @@ function mockRunningExperiment() {
actual_rtp_percent: 95.2,
arm: "control",
rule_version: 2,
strategy_version: "fixed_v2",
strategy_version: "dynamic_v3",
total_draws: 100,
total_reward_coins: 952,
total_spent_coins: 1000,
@ -612,6 +630,26 @@ test("manages a running experiment: stats, traffic, overrides and ending with a
expect(within(dialog).getAllByText("对照组").length).toBeGreaterThan(0);
expect(within(dialog).getAllByText("实验组").length).toBeGreaterThan(0);
// dynamic_v3 V2
expect(within(dialog).getByText("10,000,000")).toBeTruthy();
expect(within(dialog).getByText("2,000,000")).toBeTruthy();
fireEvent.click(within(dialog).getByRole("button", { name: "添加水位 lalu / super_lucky / dynamic_v3" }));
const poolDialog = await screen.findByRole("dialog", { name: "添加奖池水位" });
fireEvent.change(within(poolDialog).getByLabelText(/调整金币/), { target: { value: "1000000" } });
fireEvent.change(within(poolDialog).getByLabelText(/调整原因/), { target: { value: "实验对照组补水" } });
fireEvent.click(within(poolDialog).getByRole("button", { name: "确认添加" }));
await waitFor(() =>
expect(adjustLuckyGiftPool).toHaveBeenCalledWith(
expect.objectContaining({
appCode: "lalu",
direction: "in",
poolId: "super_lucky",
strategyVersion: "dynamic_v3",
}),
),
);
await waitFor(() => expect(screen.queryByRole("dialog", { name: "添加奖池水位" })).toBeNull());
fireEvent.change(within(dialog).getByLabelText(/实验组流量/), { target: { value: "20" } });
fireEvent.click(within(dialog).getByRole("button", { name: "应用" }));
await waitFor(() =>

View File

@ -7,6 +7,7 @@ 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 { luckyGiftPoolKey } from "../api.js";
import {
EXPERIMENT_ARM_LABELS,
EXPERIMENT_ARMS,
@ -17,20 +18,27 @@ import {
trafficPercentError,
} from "../experimentForm.js";
import { formatNumber, formatPercent } from "../format.js";
import { LuckyGiftPoolActions } from "./LuckyGiftPoolActions.jsx";
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
// AB /
// OpsCenterApp toast
export function LuckyGiftExperimentManageDialog({
busy,
canCreditPool,
canDebitPool,
canManage,
experiment,
onAdjustTraffic,
onClose,
onCreditPool,
onDebitPool,
onEnd,
onRemoveOverride,
onSetStatus,
onUpsertOverride,
poolBalances = [],
poolBusy = false,
statsLoading,
}) {
const [trafficInput, setTrafficInput] = useState(() => String(experiment.treatment_traffic_percent ?? ""));
@ -46,6 +54,12 @@ export function LuckyGiftExperimentManageDialog({
const editable = canManage && isActiveExperiment(experiment);
const badge = experimentStatusBadge(experiment);
const overrides = Array.isArray(experiment.overrides) ? experiment.overrides : [];
const armStrategies = EXPERIMENT_ARMS.map((arm) => experimentArmStat(experiment, arm)?.strategy_version).filter(
Boolean,
);
// app+pool+strategy arm
//
const sharedStrategyPool = armStrategies.length === EXPERIMENT_ARMS.length && new Set(armStrategies).size === 1;
const submitTraffic = (event) => {
event.preventDefault();
@ -115,14 +129,25 @@ export function LuckyGiftExperimentManageDialog({
<ExperimentArmCard
arm={arm}
busy={busy}
canCreditPool={canCreditPool}
canDebitPool={canDebitPool}
canEnd={editable}
experiment={experiment}
key={arm}
poolBalances={poolBalances}
poolBusy={poolBusy}
statsLoading={statsLoading}
onCreditPool={onCreditPool}
onDebitPool={onDebitPool}
onPickWinner={setEndConfirmArm}
/>
))}
</div>
{sharedStrategyPool && (canCreditPool || canDebitPool) ? (
<div className="ops-pool-adjustment-note" role="status">
两组使用同一策略共用这口奖池水位从任一组入口调整都会同步生效
</div>
) : null}
{/* editable 才渲染确认层:结束成功后 status 变为 completed确认层随之收起。 */}
{editable && endConfirmArm ? (
@ -262,12 +287,38 @@ export function LuckyGiftExperimentManageDialog({
);
}
function ExperimentArmCard({ arm, busy, canEnd, experiment, onPickWinner, statsLoading }) {
function ExperimentArmCard({
arm,
busy,
canCreditPool,
canDebitPool,
canEnd,
experiment,
onCreditPool,
onDebitPool,
onPickWinner,
poolBalances,
poolBusy,
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;
const strategyVersion = stat?.strategy_version || "";
const poolIdentity = {
app_code: experiment.app_code,
pool_id: experiment.pool_id,
strategy_version: strategyVersion,
};
const pool = strategyVersion
? (poolBalances || []).find((item) => luckyGiftPoolKey(item) === luckyGiftPoolKey(poolIdentity))
: null;
// aria/tooltip
// LuckyGiftPoolActions 0
const poolActionTarget = pool || poolIdentity;
// 00
const statValue = (value, format) => (stat ? format(value) : statsLoading ? "加载中" : "-");
const poolValue = (value) => (pool ? formatNumber(value) : statsLoading ? "加载中" : "-");
return (
<section
@ -304,7 +355,25 @@ function ExperimentArmCard({ arm, busy, canEnd, experiment, onPickWinner, statsL
<dt>实际 RTP</dt>
<dd>{statValue(stat?.actual_rtp_percent, formatPercent)}</dd>
</div>
<div>
<dt>奖池余额</dt>
<dd>{poolValue(pool?.balance)}</dd>
</div>
<div>
<dt>可用水位</dt>
<dd>{poolValue(pool?.available_balance)}</dd>
</div>
</dl>
{strategyVersion ? (
<LuckyGiftPoolActions
canCredit={canCreditPool}
canDebit={canDebitPool}
disabled={poolBusy}
pool={poolActionTarget}
onCredit={onCreditPool}
onDebit={onDebitPool}
/>
) : null}
{canEnd ? (
<Button
aria-label={`选择${EXPERIMENT_ARM_LABELS[arm]}为最终版本`}