diff --git a/ops-center/src/OpsCenterApp.jsx b/ops-center/src/OpsCenterApp.jsx index 1300697..5eab7c7 100644 --- a/ops-center/src/OpsCenterApp.jsx +++ b/ops-center/src/OpsCenterApp.jsx @@ -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 ? ( setExperimentManage(null)} + onCreditPool={(pool) => openPoolAdjustment(pool, "in")} + onDebitPool={(pool) => openPoolAdjustment(pool, "out")} onEnd={endExperiment} onRemoveOverride={removeExperimentOverride} onSetStatus={setExperimentStatus} diff --git a/ops-center/src/OpsCenterApp.test.jsx b/ops-center/src/OpsCenterApp.test.jsx index 5e8e7b6..cef96ca 100644 --- a/ops-center/src/OpsCenterApp.test.jsx +++ b/ops-center/src/OpsCenterApp.test.jsx @@ -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(() => diff --git a/ops-center/src/components/LuckyGiftExperimentManageDialog.jsx b/ops-center/src/components/LuckyGiftExperimentManageDialog.jsx index 775645d..56ec9ef 100644 --- a/ops-center/src/components/LuckyGiftExperimentManageDialog.jsx +++ b/ops-center/src/components/LuckyGiftExperimentManageDialog.jsx @@ -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({ ))} + {sharedStrategyPool && (canCreditPool || canDebitPool) ? ( +
+ 两组使用同一策略,共用这口奖池水位;从任一组入口调整都会同步生效。 +
+ ) : 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 (
实际 RTP
{statValue(stat?.actual_rtp_percent, formatPercent)}
+
+
奖池余额
+
{poolValue(pool?.balance)}
+
+
+
可用水位
+
{poolValue(pool?.available_balance)}
+
+ {strategyVersion ? ( + + ) : null} {canEnd ? (