feat(ops): add experiment arm pool controls
This commit is contained in:
parent
0be72eaff1
commit
dc8b9e488d
@ -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}
|
||||
|
||||
@ -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(() =>
|
||||
|
||||
@ -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;
|
||||
// 统计尚未返回时明确展示“加载中”,避免运营把未加载误读成 0(0 消费也是有效实验结论)。
|
||||
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]}为最终版本`}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user