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(
|
runExperimentAction(
|
||||||
(current) => updateLuckyGiftExperiment(current.app_code, current.experiment_id, { status }),
|
(current) => updateLuckyGiftExperiment(current.app_code, current.experiment_id, { status }),
|
||||||
() =>
|
() =>
|
||||||
status === "paused"
|
status === "paused" ? "实验已暂停,全部流量(含白名单)回到对照组" : "实验已恢复,按原分组继续分流",
|
||||||
? "实验已暂停,全部流量(含白名单)回到对照组"
|
|
||||||
: "实验已恢复,按原分组继续分流",
|
|
||||||
),
|
),
|
||||||
[runExperimentAction],
|
[runExperimentAction],
|
||||||
);
|
);
|
||||||
@ -479,11 +477,17 @@ export function OpsCenterApp({ can = denyPermission }) {
|
|||||||
{experimentManage ? (
|
{experimentManage ? (
|
||||||
<LuckyGiftExperimentManageDialog
|
<LuckyGiftExperimentManageDialog
|
||||||
busy={experimentBusy}
|
busy={experimentBusy}
|
||||||
|
canCreditPool={canCreditPool}
|
||||||
|
canDebitPool={canDebitPool}
|
||||||
canManage={canManageExperiment}
|
canManage={canManageExperiment}
|
||||||
experiment={experimentManage.experiment}
|
experiment={experimentManage.experiment}
|
||||||
|
poolBalances={data.poolBalances}
|
||||||
|
poolBusy={adjustingPool || state.loading}
|
||||||
statsLoading={experimentManage.statsLoading}
|
statsLoading={experimentManage.statsLoading}
|
||||||
onAdjustTraffic={adjustExperimentTraffic}
|
onAdjustTraffic={adjustExperimentTraffic}
|
||||||
onClose={() => setExperimentManage(null)}
|
onClose={() => setExperimentManage(null)}
|
||||||
|
onCreditPool={(pool) => openPoolAdjustment(pool, "in")}
|
||||||
|
onDebitPool={(pool) => openPoolAdjustment(pool, "out")}
|
||||||
onEnd={endExperiment}
|
onEnd={endExperiment}
|
||||||
onRemoveOverride={removeExperimentOverride}
|
onRemoveOverride={removeExperimentOverride}
|
||||||
onSetStatus={setExperimentStatus}
|
onSetStatus={setExperimentStatus}
|
||||||
|
|||||||
@ -153,6 +153,24 @@ beforeEach(() => {
|
|||||||
strategy_version: "fixed_v2",
|
strategy_version: "fixed_v2",
|
||||||
updated_at_ms: 1767000000000,
|
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",
|
app_code: "yumi",
|
||||||
available_balance: 361500,
|
available_balance: 361500,
|
||||||
@ -199,7 +217,7 @@ function mockRunningExperiment() {
|
|||||||
actual_rtp_percent: 95.2,
|
actual_rtp_percent: 95.2,
|
||||||
arm: "control",
|
arm: "control",
|
||||||
rule_version: 2,
|
rule_version: 2,
|
||||||
strategy_version: "fixed_v2",
|
strategy_version: "dynamic_v3",
|
||||||
total_draws: 100,
|
total_draws: 100,
|
||||||
total_reward_coins: 952,
|
total_reward_coins: 952,
|
||||||
total_spent_coins: 1000,
|
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);
|
||||||
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.change(within(dialog).getByLabelText(/实验组流量/), { target: { value: "20" } });
|
||||||
fireEvent.click(within(dialog).getByRole("button", { name: "应用" }));
|
fireEvent.click(within(dialog).getByRole("button", { name: "应用" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import TextField from "@mui/material/TextField";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||||
|
import { luckyGiftPoolKey } from "../api.js";
|
||||||
import {
|
import {
|
||||||
EXPERIMENT_ARM_LABELS,
|
EXPERIMENT_ARM_LABELS,
|
||||||
EXPERIMENT_ARMS,
|
EXPERIMENT_ARMS,
|
||||||
@ -17,20 +18,27 @@ import {
|
|||||||
trafficPercentError,
|
trafficPercentError,
|
||||||
} from "../experimentForm.js";
|
} from "../experimentForm.js";
|
||||||
import { formatNumber, formatPercent } from "../format.js";
|
import { formatNumber, formatPercent } from "../format.js";
|
||||||
|
import { LuckyGiftPoolActions } from "./LuckyGiftPoolActions.jsx";
|
||||||
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
||||||
|
|
||||||
// AB 实验管理弹窗:展示两组统计并承载放量、暂停/恢复、白名单、结束实验四类操作。
|
// AB 实验管理弹窗:展示两组统计并承载放量、暂停/恢复、白名单、结束实验四类操作。
|
||||||
// 所有请求都由 OpsCenterApp 的回调执行(失败 toast 保留弹窗),这里只负责输入校验与二次确认。
|
// 所有请求都由 OpsCenterApp 的回调执行(失败 toast 保留弹窗),这里只负责输入校验与二次确认。
|
||||||
export function LuckyGiftExperimentManageDialog({
|
export function LuckyGiftExperimentManageDialog({
|
||||||
busy,
|
busy,
|
||||||
|
canCreditPool,
|
||||||
|
canDebitPool,
|
||||||
canManage,
|
canManage,
|
||||||
experiment,
|
experiment,
|
||||||
onAdjustTraffic,
|
onAdjustTraffic,
|
||||||
onClose,
|
onClose,
|
||||||
|
onCreditPool,
|
||||||
|
onDebitPool,
|
||||||
onEnd,
|
onEnd,
|
||||||
onRemoveOverride,
|
onRemoveOverride,
|
||||||
onSetStatus,
|
onSetStatus,
|
||||||
onUpsertOverride,
|
onUpsertOverride,
|
||||||
|
poolBalances = [],
|
||||||
|
poolBusy = false,
|
||||||
statsLoading,
|
statsLoading,
|
||||||
}) {
|
}) {
|
||||||
const [trafficInput, setTrafficInput] = useState(() => String(experiment.treatment_traffic_percent ?? ""));
|
const [trafficInput, setTrafficInput] = useState(() => String(experiment.treatment_traffic_percent ?? ""));
|
||||||
@ -46,6 +54,12 @@ export function LuckyGiftExperimentManageDialog({
|
|||||||
const editable = canManage && isActiveExperiment(experiment);
|
const editable = canManage && isActiveExperiment(experiment);
|
||||||
const badge = experimentStatusBadge(experiment);
|
const badge = experimentStatusBadge(experiment);
|
||||||
const overrides = Array.isArray(experiment.overrides) ? experiment.overrides : [];
|
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) => {
|
const submitTraffic = (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -115,14 +129,25 @@ export function LuckyGiftExperimentManageDialog({
|
|||||||
<ExperimentArmCard
|
<ExperimentArmCard
|
||||||
arm={arm}
|
arm={arm}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
canCreditPool={canCreditPool}
|
||||||
|
canDebitPool={canDebitPool}
|
||||||
canEnd={editable}
|
canEnd={editable}
|
||||||
experiment={experiment}
|
experiment={experiment}
|
||||||
key={arm}
|
key={arm}
|
||||||
|
poolBalances={poolBalances}
|
||||||
|
poolBusy={poolBusy}
|
||||||
statsLoading={statsLoading}
|
statsLoading={statsLoading}
|
||||||
|
onCreditPool={onCreditPool}
|
||||||
|
onDebitPool={onDebitPool}
|
||||||
onPickWinner={setEndConfirmArm}
|
onPickWinner={setEndConfirmArm}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{sharedStrategyPool && (canCreditPool || canDebitPool) ? (
|
||||||
|
<div className="ops-pool-adjustment-note" role="status">
|
||||||
|
两组使用同一策略,共用这口奖池水位;从任一组入口调整都会同步生效。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* editable 才渲染确认层:结束成功后 status 变为 completed,确认层随之收起。 */}
|
{/* editable 才渲染确认层:结束成功后 status 变为 completed,确认层随之收起。 */}
|
||||||
{editable && endConfirmArm ? (
|
{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 stat = experimentArmStat(experiment, arm);
|
||||||
const ruleVersion = arm === "control" ? experiment.control_rule_version : experiment.treatment_rule_version;
|
const ruleVersion = arm === "control" ? experiment.control_rule_version : experiment.treatment_rule_version;
|
||||||
const isWinner = experiment.status === "completed" && experiment.winner_arm === arm;
|
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 消费也是有效实验结论)。
|
// 统计尚未返回时明确展示“加载中”,避免运营把未加载误读成 0(0 消费也是有效实验结论)。
|
||||||
const statValue = (value, format) => (stat ? format(value) : statsLoading ? "加载中" : "-");
|
const statValue = (value, format) => (stat ? format(value) : statsLoading ? "加载中" : "-");
|
||||||
|
const poolValue = (value) => (pool ? formatNumber(value) : statsLoading ? "加载中" : "-");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@ -304,7 +355,25 @@ function ExperimentArmCard({ arm, busy, canEnd, experiment, onPickWinner, statsL
|
|||||||
<dt>实际 RTP</dt>
|
<dt>实际 RTP</dt>
|
||||||
<dd>{statValue(stat?.actual_rtp_percent, formatPercent)}</dd>
|
<dd>{statValue(stat?.actual_rtp_percent, formatPercent)}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>奖池余额</dt>
|
||||||
|
<dd>{poolValue(pool?.balance)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>可用水位</dt>
|
||||||
|
<dd>{poolValue(pool?.available_balance)}</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
{strategyVersion ? (
|
||||||
|
<LuckyGiftPoolActions
|
||||||
|
canCredit={canCreditPool}
|
||||||
|
canDebit={canDebitPool}
|
||||||
|
disabled={poolBusy}
|
||||||
|
pool={poolActionTarget}
|
||||||
|
onCredit={onCreditPool}
|
||||||
|
onDebit={onDebitPool}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{canEnd ? (
|
{canEnd ? (
|
||||||
<Button
|
<Button
|
||||||
aria-label={`选择${EXPERIMENT_ARM_LABELS[arm]}为最终版本`}
|
aria-label={`选择${EXPERIMENT_ARM_LABELS[arm]}为最终版本`}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user