diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 0ed8a73..bcc5c0b 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -470,6 +470,94 @@ "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": { "get": { "operationId": "getWheelConfig", diff --git a/ops-center/src/OpsCenterApp.jsx b/ops-center/src/OpsCenterApp.jsx index 54e461e..90a1d62 100644 --- a/ops-center/src/OpsCenterApp.jsx +++ b/ops-center/src/OpsCenterApp.jsx @@ -9,14 +9,26 @@ import { Button } from "@/shared/ui/Button.jsx"; import { DataState } from "@/shared/ui/DataState.jsx"; import { PageHead } from "@/shared/ui/PageHead.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 { ConfigsView } from "./components/ConfigsView.jsx"; import { DrawsView } from "./components/DrawsView.jsx"; import { LuckyGiftConfigDialog } from "./components/LuckyGiftConfigDialog.jsx"; +import { LuckyGiftExperimentManageDialog } from "./components/LuckyGiftExperimentManageDialog.jsx"; import { LuckyGiftPoolAdjustmentDialog } from "./components/LuckyGiftPoolAdjustmentDialog.jsx"; import { OpsShell } from "./components/OpsShell.jsx"; import { OverviewView } from "./components/OverviewView.jsx"; +import { EXPERIMENT_ARM_LABELS, formatTrafficPercent } from "./experimentForm.js"; import { formatNumber } from "./format.js"; const views = [ @@ -30,11 +42,16 @@ const viewKeys = new Set(views.map((view) => view.key)); const initialDashboard = { apps: [], appSummaries: [], + experiments: [], luckyGifts: [], poolBalances: [], summary: {}, }; +// 实验管理权限码与 permissions.ts 常量并行开发:常量尚未生成时回退到后端约定的字面量, +// 避免部署顺序影响入口显隐;两者最终值一致("lucky-gift:experiment")。 +const EXPERIMENT_PERMISSION = PERMISSIONS.luckyGiftExperiment || "lucky-gift:experiment"; + export function initialOpsCenterView(search = globalThis.location?.search || "") { const requested = new URLSearchParams(search).get("view") || ""; 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 [dialog, setDialog] = useState(null); const [poolDialog, setPoolDialog] = useState(null); + const [experimentManage, setExperimentManage] = useState(null); const [adjustingPool, setAdjustingPool] = useState(false); const [savingConfig, setSavingConfig] = useState(false); + const [experimentBusy, setExperimentBusy] = useState(false); const { showToast } = useToast(); const canCreditPool = can(PERMISSIONS.luckyGiftPoolCredit); const canDebitPool = can(PERMISSIONS.luckyGiftPoolDebit); const canUpdateConfig = can(PERMISSIONS.luckyGiftUpdate); + const canManageExperiment = can(EXPERIMENT_PERMISSION); const loadDashboard = useCallback(async () => { setState((current) => ({ ...current, error: "", loading: true })); @@ -134,6 +154,11 @@ export function OpsCenterApp({ can = denyPermission }) { setDialog({ config: structuredClone(config), mode: "edit" }); }, []); + const openCreateExperiment = useCallback((config) => { + // 复用配置弹窗的实验模式:以当前行配置为实验组初始值,当前最新启用版本自动成为对照组。 + setDialog({ config: structuredClone(config), mode: "experiment" }); + }, []); + const openPoolAdjustment = useCallback((pool, direction) => { if (!pool) { return; @@ -164,6 +189,167 @@ export function OpsCenterApp({ can = denyPermission }) { [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( async (request) => { setAdjustingPool(true); @@ -232,15 +418,19 @@ export function OpsCenterApp({ can = denyPermission }) { apps={data.apps} canCredit={canCreditPool} canDebit={canDebitPool} + canExperiment={canManageExperiment} canUpdate={canUpdateConfig} disabled={adjustingPool || state.loading} + experiments={data.experiments} luckyGifts={data.luckyGifts} poolBalances={data.poolBalances} saving={savingConfig} onCreate={openCreateConfig} + onCreateExperiment={openCreateExperiment} onCredit={(pool) => openPoolAdjustment(pool, "in")} onDebit={(pool) => openPoolAdjustment(pool, "out")} onEdit={openEditConfig} + onManageExperiment={openExperimentManage} /> ) : null} {activeView === "draws" ? ( @@ -255,7 +445,23 @@ export function OpsCenterApp({ can = denyPermission }) { mode={dialog.mode} saving={savingConfig} onClose={() => setDialog(null)} - onSubmit={handleSaveConfig} + // 实验模式提交的是 {name, treatment_traffic_percent, overrides, config} 创建请求, + // 其余模式仍是 PUT /lucky-gifts/config 的完整配置对象。 + onSubmit={dialog.mode === "experiment" ? handleCreateExperiment : handleSaveConfig} + /> + ) : null} + {experimentManage ? ( + setExperimentManage(null)} + onEnd={endExperiment} + onRemoveOverride={removeExperimentOverride} + onSetStatus={setExperimentStatus} + onUpsertOverride={upsertExperimentOverride} /> ) : null} {poolDialog ? ( diff --git a/ops-center/src/OpsCenterApp.test.jsx b/ops-center/src/OpsCenterApp.test.jsx index e91954c..5e8e7b6 100644 --- a/ops-center/src/OpsCenterApp.test.jsx +++ b/ops-center/src/OpsCenterApp.test.jsx @@ -2,23 +2,55 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea import { beforeEach, expect, test, vi } from "vitest"; import { PERMISSIONS } from "@/app/permissions"; 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"; vi.mock("./api.js", () => ({ DEFAULT_POOL_ID: "default", adjustLuckyGiftPool: vi.fn(), + createLuckyGiftExperiment: vi.fn(), + endLuckyGiftExperiment: vi.fn(), fetchLuckyGiftDraws: vi.fn(), + fetchLuckyGiftExperiments: vi.fn(), fetchOpsDashboard: vi.fn(), luckyGiftPoolKey: (item = {}) => `${item.app_code || item.appCode}:${item.pool_id || item.poolId}:${item.strategy_version || item.strategyVersion || "fixed_v2"}`, saveLuckyGiftConfig: vi.fn(), + setLuckyGiftExperimentOverrides: vi.fn(), + updateLuckyGiftExperiment: vi.fn(), })); beforeEach(() => { vi.clearAllMocks(); window.history.replaceState({}, "", "/"); 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({ adjustment: { balance_after: 39_559_017, balance_before: 38_559_017 }, idempotent_replay: false, @@ -43,6 +75,7 @@ beforeEach(() => { total: 1, }); fetchOpsDashboard.mockResolvedValue({ + experiments: [mockRunningExperiment()], apps: [ { app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 }, { 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) { 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(); }); + +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(); +}); diff --git a/ops-center/src/api.js b/ops-center/src/api.js index e206959..af0e115 100644 --- a/ops-center/src/api.js +++ b/ops-center/src/api.js @@ -13,12 +13,15 @@ export async function fetchOpsDashboard(query = {}) { const perApp = await Promise.all( appCodes.map(async (appCode) => { - const [luckyGifts, poolBalances, drawSummary] = await Promise.all([ + const [luckyGifts, poolBalances, drawSummary, experiments] = await Promise.all([ fetchAppLuckyGiftConfigs(appCode), opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems), 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); } +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 } = {}) { const normalizedPath = path.startsWith("/") ? path : `/${path}`; // 复用主后台请求层才能共享 refreshOnce:运行期间 token 过期时先刷新再重放一次请求。 @@ -149,6 +293,8 @@ export function buildOpsUrl(path, query = {}) { export function normalizeDashboard({ apps = [], perApp = [] } = {}) { const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []); + // 实验按 app+pool 独占;这里保持扁平列表,活跃实验与配置行的关联由展示层的 family key 完成。 + const experiments = perApp.flatMap((entry) => entry.experiments || []); const normalizedPoolBalances = perApp .flatMap((entry) => (entry.poolBalances || []).map((item) => normalizePoolBalance(item, entry.appCode))) .filter((item) => item.app_code); @@ -198,7 +344,7 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) { totalSpentCoins: sumBy(appSummaries, "total_spent_coins"), }; - return { apps, appSummaries, luckyGifts, poolBalances, summary }; + return { apps, appSummaries, experiments, luckyGifts, poolBalances, summary }; } export function normalizePoolBalance(pool = {}, fallbackAppCode = "") { @@ -319,6 +465,12 @@ function normalizeStrategyVersion(value) { .toLowerCase(); } +function normalizeAppCodeValue(value) { + return String(value || "") + .trim() + .toLowerCase(); +} + function luckyGiftConfigPayload(config = {}) { const appCode = String(config.app_code || config.appCode || "") .trim() diff --git a/ops-center/src/api.test.js b/ops-center/src/api.test.js index f8f0111..11c5b9c 100644 --- a/ops-center/src/api.test.js +++ b/ops-center/src/api.test.js @@ -3,13 +3,18 @@ import { setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "@/sha import { adjustLuckyGiftPool, buildOpsUrl, + createLuckyGiftExperiment, + endLuckyGiftExperiment, fetchLuckyGiftDraws, + fetchLuckyGiftExperiments, fetchOpsDashboard, luckyGiftPoolKey, normalizeDashboard, opsRequest, OPS_API_PREFIX, saveLuckyGiftConfig, + setLuckyGiftExperimentOverrides, + updateLuckyGiftExperiment, } from "./api.js"; afterEach(() => { @@ -100,6 +105,19 @@ test("loads every pool config per app through the plural configs endpoint", asyn 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: [] }); }); @@ -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/pools?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.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" }, }); }); + +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" }); +}); diff --git a/ops-center/src/components/ConfigsView.jsx b/ops-center/src/components/ConfigsView.jsx index 278c7ff..8c95d23 100644 --- a/ops-center/src/components/ConfigsView.jsx +++ b/ops-center/src/components/ConfigsView.jsx @@ -5,6 +5,7 @@ import { Button } from "@/shared/ui/Button.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; import { TimeText } from "@/shared/ui/TimeText.jsx"; import { luckyGiftPoolKey } from "../api.js"; +import { activeExperimentsByPool, experimentPoolFamilyKey, experimentStatusBadge } from "../experimentForm.js"; import { formatNumber, formatPercent } from "../format.js"; import { LuckyGiftPoolActions } from "./LuckyGiftPoolActions.jsx"; import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; @@ -13,13 +14,17 @@ export function ConfigsView({ apps, canCredit, canDebit, + canExperiment, canUpdate, disabled, + experiments, luckyGifts, onCreate, + onCreateExperiment, onCredit, onDebit, onEdit, + onManageExperiment, poolBalances, saving, }) { @@ -36,28 +41,53 @@ export function ConfigsView({ // 配置列表已在 dashboard 阶段跨应用拉全,应用筛选只做前端过滤,避免重复扇出请求。 const items = useMemo(() => { 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; // 余额必须按 app+pool+strategy 精确关联;同一个 app/pool 同时存在 fixed_v2 和 dynamic_v3 时, // 任何缺少 strategy_version 的关联都会把两口独立资金池串行或覆盖。 return visibleConfigs.map((config) => ({ ...config, + active_experiment: experimentByPool.get(experimentPoolFamilyKey(config)) || null, 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( () => buildColumns({ canCredit, canDebit, + canExperiment, canUpdate, disabled, + hasExperimentEntry, + onCreateExperiment, onCredit, onDebit, onEdit, + onManageExperiment, saving, }), - [canCredit, canDebit, canUpdate, disabled, onCredit, onDebit, onEdit, saving], + [ + canCredit, + canDebit, + canExperiment, + canUpdate, + disabled, + hasExperimentEntry, + onCreateExperiment, + onCredit, + onDebit, + onEdit, + onManageExperiment, + saving, + ], ); 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 = [ { key: "app_code", label: "应用", width: "minmax(88px, 0.7fr)" }, { 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)", }, + { + key: "experiment", + label: "实验", + render: (item) => { + // 只展示活跃实验(running/paused);completed 实验已全量收敛,不再影响该行的发布能力。 + const badge = experimentStatusBadge(item.active_experiment); + return badge ? : "-"; + }, + width: "minmax(120px, 0.8fr)", + }, { 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) }, { @@ -159,13 +212,14 @@ function buildColumns({ canCredit, canDebit, canUpdate, disabled, onCredit, onDe }, ]; - if (canUpdate || canCredit || canDebit) { + if (canUpdate || canCredit || canDebit || hasExperimentEntry) { columns.push({ fixed: "right", key: "actions", label: "操作", render: (item) => { const identity = `${item.app_code} / ${item.pool_id} / ${item.strategy_version}`; + const hasActiveExperiment = Boolean(item.active_experiment); const actionPool = item.pool_balance || { app_code: item.app_code, pool_id: item.pool_id, @@ -184,17 +238,47 @@ function buildColumns({ canCredit, canDebit, canUpdate, disabled, onCredit, onDe {canUpdate ? ( ) : null} + {hasActiveExperiment ? ( + + ) : canExperiment && item.enabled && !item.is_default ? ( + // 只有已启用的已发布配置才能作为对照组开实验;默认草稿/停用行没有可对照的线上版本。 + + ) : null} ); }, - 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`, }); } diff --git a/ops-center/src/components/LuckyGiftConfigDialog.jsx b/ops-center/src/components/LuckyGiftConfigDialog.jsx index 590c4f3..7e6aa0d 100644 --- a/ops-center/src/components/LuckyGiftConfigDialog.jsx +++ b/ops-center/src/components/LuckyGiftConfigDialog.jsx @@ -13,12 +13,23 @@ import { stageSummary, validateLuckyGiftForm, } from "../configForm.js"; +import { + buildExperimentCreateRequest, + experimentDraft, + formatTrafficPercent, + validateExperimentDraft, +} from "../experimentForm.js"; import { formatNumber, formatPercent } from "../format.js"; import { LuckyGiftConfigSections } from "./LuckyGiftConfigSections.jsx"; +import { LuckyGiftExperimentFields } from "./LuckyGiftExperimentFields.jsx"; import { LuckyGiftStageDesigner } from "./LuckyGiftStageDesigner.jsx"; 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 [validationErrors, setValidationErrors] = useState([]); const validationRef = useRef(null); @@ -42,7 +53,9 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", setForm((current) => { const next = { ...current, [field]: value }; 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 决定各奖档概率的闭合解,改动后全部阶段重算,避免展示的概率和将要发布的值不一致。 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) => { 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) { setValidationErrors(errors); return; } + if (isExperiment) { + onSubmit(buildExperimentCreateRequest(experiment, formToConfig(config, submittedForm))); + return; + } onSubmit(formToConfig(config, form)); }; const isCreate = mode === "create"; - const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本"; + const title = isExperiment + ? "创建 AB 实验" + : isCreate + ? "添加幸运礼物配置" + : config.is_default + ? "发布幸运礼物配置" + : "新增幸运礼物版本"; return ( {title} {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 ?? "-"}`}
@@ -166,7 +203,16 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", label={form.strategy_version === "dynamic_v3" ? "每轮观察流水" : "V2 窗口配置"} value={formatNumber(Number(form.settlement_window_wager))} /> - +
{validationErrors.length ? ( @@ -177,7 +223,7 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", role="alert" tabIndex={-1} > - 配置不能发布 + {isExperiment ? "实验不能创建" : "配置不能发布"}
    {validationErrors.map((message) => (
  • {message}
  • @@ -186,9 +232,18 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", ) : null} + {isExperiment ? ( + + ) : null} +
    diff --git a/ops-center/src/components/LuckyGiftConfigSections.jsx b/ops-center/src/components/LuckyGiftConfigSections.jsx index 92df6c1..ad7c49f 100644 --- a/ops-center/src/components/LuckyGiftConfigSections.jsx +++ b/ops-center/src/components/LuckyGiftConfigSections.jsx @@ -16,7 +16,7 @@ const DYNAMIC_SECTIONS = [ ["limits", "限额"], ]; -export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }) { +export function LuckyGiftConfigSections({ appOptions, experimentMode = false, form, isCreate, onChange }) { const scrollRef = useRef(null); const sections = useMemo( () => (form.strategy_version === "dynamic_v3" ? [...BASE_SECTIONS, ...DYNAMIC_SECTIONS] : BASE_SECTIONS), @@ -160,6 +160,9 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } /> )} } @@ -168,6 +171,9 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } onChange={(event) => onChange("pool_id", event.target.value)} /> } select @@ -183,6 +189,9 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } control={ onChange("enabled", event.target.checked)} /> @@ -190,7 +199,13 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } label={ } /> diff --git a/ops-center/src/components/LuckyGiftExperimentFields.jsx b/ops-center/src/components/LuckyGiftExperimentFields.jsx new file mode 100644 index 0000000..bb9623f --- /dev/null +++ b/ops-center/src/components/LuckyGiftExperimentFields.jsx @@ -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 ( +
    +
    +

    实验设置

    + + 创建实验会把下方配置原子发布为实验组新版本,当前最新启用版本自动成为对照组;按用户 ID + 哈希分流,放量单调(调大不会把已进入实验组的用户踢回)。实验进行中,该奖池的常规发布会被拒绝。 + +
    +
    + } + required + size="small" + value={draft.name} + onChange={(event) => onChange("name", event.target.value)} + /> + } + 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 的多选交互保持一致。 */} + ( + } + /> + )} + size="small" + value={draft.whitelist} + onChange={(_, next) => onChange("whitelist", normalizeWhitelist(next))} + /> +
    +
    + ); +} diff --git a/ops-center/src/components/LuckyGiftExperimentManageDialog.jsx b/ops-center/src/components/LuckyGiftExperimentManageDialog.jsx new file mode 100644 index 0000000..775645d --- /dev/null +++ b/ops-center/src/components/LuckyGiftExperimentManageDialog.jsx @@ -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 ( + + + {completed ? "实验详情" : "实验管理"} + {experiment.name || experiment.experiment_id} + + +
    + + + : "-"} + /> + } /> + + +
    + + {paused ? ( +
    + 实验已暂停:全部流量(含白名单)临时回到对照组,恢复后回到原分组。 +
    + ) : null} + {completed ? ( +
    + 实验已结束:{EXPERIMENT_ARM_LABELS[experiment.winner_arm] || "获胜方"}获胜,配置已重新发布为 v + {experiment.final_rule_version || experiment.winner_rule_version || "-"} 并全量生效。 +
    + ) : null} + +
    + {EXPERIMENT_ARMS.map((arm) => ( + + ))} +
    + + {/* editable 才渲染确认层:结束成功后 status 变为 completed,确认层随之收起。 */} + {editable && endConfirmArm ? ( +
    + 确认以{EXPERIMENT_ARM_LABELS[endConfirmArm]}结束实验? +

    + {EXPERIMENT_ARM_LABELS[endConfirmArm]}(v + {endConfirmArm === "control" + ? experiment.control_rule_version + : experiment.treatment_rule_version} + )的配置将重新发布为新的最新版本并对全量用户生效;实验立即结束且不可恢复,之后配置页恢复常规发布。 +

    +
    + + +
    +
    + ) : null} + + {editable ? ( +
    +

    放量与状态

    +
    +
    + { + setTrafficInput(event.target.value); + setTrafficError(""); + }} + /> + + +
    + + + {paused + ? "恢复后所有用户回到暂停前的分组。" + : "暂停后全部流量(含白名单)回到对照组。"} + +
    +
    +
    + ) : null} + +
    +

    白名单用户

    +

    + 白名单用户不参与哈希分流,固定进入指定分组;流量为 0 时只有白名单进入实验组。 +

    + {overrides.length ? ( +
      + {overrides.map((override) => ( +
    • + {override.user_key} + {EXPERIMENT_ARM_LABELS[override.arm] || override.arm} + {editable ? ( + + ) : null} +
    • + ))} +
    + ) : ( +

    暂无白名单用户

    + )} + {editable ? ( +
    + { + setOverrideUserKey(event.target.value); + setOverrideError(""); + }} + /> + setOverrideArm(event.target.value)} + > + {EXPERIMENT_ARMS.map((arm) => ( + + {EXPERIMENT_ARM_LABELS[arm]} + + ))} + + + + ) : null} +
    +
    + + + +
    + ); +} + +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; + // 统计尚未返回时明确展示“加载中”,避免运营把未加载误读成 0(0 消费也是有效实验结论)。 + const statValue = (value, format) => (stat ? format(value) : statsLoading ? "加载中" : "-"); + + return ( +
    +
    + {EXPERIMENT_ARM_LABELS[arm]} + {ruleVersion ? `v${ruleVersion}` : "-"} + {isWinner ? : null} +
    +
    +
    +
    策略
    +
    {stat?.strategy_version || "-"}
    +
    +
    +
    抽奖次数
    +
    {statValue(stat?.total_draws, formatNumber)}
    +
    +
    +
    参与用户
    +
    {statValue(stat?.unique_users, formatNumber)}
    +
    +
    +
    消费金币
    +
    {statValue(stat?.total_spent_coins, formatNumber)}
    +
    +
    +
    返奖金币
    +
    {statValue(stat?.total_reward_coins, formatNumber)}
    +
    +
    +
    实际 RTP
    +
    {statValue(stat?.actual_rtp_percent, formatPercent)}
    +
    +
    + {canEnd ? ( + + ) : null} +
    + ); +} + +function ExperimentMetric({ label, value }) { + return ( +
    + {label} + {value || "-"} +
    + ); +} diff --git a/ops-center/src/components/LuckyGiftFieldHelp.jsx b/ops-center/src/components/LuckyGiftFieldHelp.jsx index 4db296e..e8e94d9 100644 --- a/ops-center/src/components/LuckyGiftFieldHelp.jsx +++ b/ops-center/src/components/LuckyGiftFieldHelp.jsx @@ -237,6 +237,21 @@ const FIELD_HELP = { constraint: "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: { summary: "决定这一档是否参加当前阶段的普通基础抽奖。关闭后不会从普通奖档中选中。", adjust: "关闭高倍率档会降低大额结果种类;重新开启后系统会重新计算整组概率。", diff --git a/ops-center/src/experimentForm.js b/ops-center/src/experimentForm.js new file mode 100644 index 0000000..13dcdba --- /dev/null +++ b/ops-center/src/experimentForm.js @@ -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; +} diff --git a/ops-center/src/experimentForm.test.js b/ops-center/src/experimentForm.test.js new file mode 100644 index 0000000..baeb522 --- /dev/null +++ b/ops-center/src/experimentForm.test.js @@ -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%"); + }); +}); diff --git a/ops-center/src/styles/index.css b/ops-center/src/styles/index.css index 10312cd..c8f40ac 100644 --- a/ops-center/src/styles/index.css +++ b/ops-center/src/styles/index.css @@ -801,6 +801,190 @@ body { 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) { @@ -925,6 +1109,15 @@ body { 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 { grid-template-columns: minmax(0, 1fr); } diff --git a/src/app/permissions.ts b/src/app/permissions.ts index e7c990a..73c61e9 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -194,6 +194,7 @@ export const PERMISSIONS = { sevenDayCheckInUpdate: "seven-day-checkin:update", luckyGiftView: "lucky-gift:view", luckyGiftUpdate: "lucky-gift:update", + luckyGiftExperiment: "lucky-gift:experiment", luckyGiftPoolCredit: "lucky-gift:pool-credit", luckyGiftPoolDebit: "lucky-gift:pool-debit", wheelView: "wheel:view", diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 6227226..7a6d7dc 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -59,6 +59,7 @@ export const API_OPERATIONS = { createGift: "createGift", createH5Link: "createH5Link", createHostAgencyPolicy: "createHostAgencyPolicy", + createLuckyGiftExperiment: "createLuckyGiftExperiment", createManager: "createManager", createMenu: "createMenu", createPermission: "createPermission", @@ -123,6 +124,7 @@ export const API_OPERATIONS = { enableResource: "enableResource", enableResourceGroup: "enableResourceGroup", enableResourceShopItem: "enableResourceShopItem", + endLuckyGiftExperiment: "endLuckyGiftExperiment", expireRoomRpsChallenge: "expireRoomRpsChallenge", exportActivityTemplateData: "exportActivityTemplateData", exportCoinSellerLedger: "exportCoinSellerLedger", @@ -236,6 +238,7 @@ export const API_OPERATIONS = { listLoginLogs: "listLoginLogs", listLuckyGiftConfigs: "listLuckyGiftConfigs", listLuckyGiftDraws: "listLuckyGiftDraws", + listLuckyGiftExperiments: "listLuckyGiftExperiments", listLuckyGiftPoolBalances: "listLuckyGiftPoolBalances", listManagers: "listManagers", listOperationLogs: "listOperationLogs", @@ -333,6 +336,7 @@ export const API_OPERATIONS = { setDiceRobotStatus: "setDiceRobotStatus", setGameStatus: "setGameStatus", setGameWhitelistEnabled: "setGameWhitelistEnabled", + setLuckyGiftExperimentOverrides: "setLuckyGiftExperimentOverrides", setPrettyIdStatus: "setPrettyIdStatus", setTaskDefinitionStatus: "setTaskDefinitionStatus", setThirdPartyPaymentMethodStatus: "setThirdPartyPaymentMethodStatus", @@ -366,6 +370,7 @@ export const API_OPERATIONS = { updateHostAgencyPolicy: "updateHostAgencyPolicy", updateHumanRoomRobotConfig: "updateHumanRoomRobotConfig", updateInviteActivityRewardConfig: "updateInviteActivityRewardConfig", + updateLuckyGiftExperiment: "updateLuckyGiftExperiment", updateManager: "updateManager", updateMenu: "updateMenu", updateMenuVisible: "updateMenuVisible", @@ -738,6 +743,13 @@ export const API_ENDPOINTS: Record = { permission: "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: { method: "POST", operationId: API_OPERATIONS.createManager, @@ -1186,6 +1198,13 @@ export const API_ENDPOINTS: Record = { permission: "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: { method: "POST", operationId: API_OPERATIONS.expireRoomRpsChallenge, @@ -1975,6 +1994,13 @@ export const API_ENDPOINTS: Record = { permission: "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: { method: "GET", operationId: API_OPERATIONS.listLuckyGiftPoolBalances, @@ -2640,6 +2666,13 @@ export const API_ENDPOINTS: Record = { permission: "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: { method: "POST", operationId: API_OPERATIONS.setPrettyIdStatus, @@ -2871,6 +2904,13 @@ export const API_ENDPOINTS: Record = { permission: "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: { method: "PUT", operationId: API_OPERATIONS.updateManager, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index a3733c0..7df3f11 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -356,6 +356,70 @@ export interface paths { patch?: 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": { parameters: { 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: { parameters: { query?: never; diff --git a/vite.config.js b/vite.config.js index bc6aeb6..9598827 100644 --- a/vite.config.js +++ b/vite.config.js @@ -79,7 +79,6 @@ export default defineConfig({ databiSocial: new URL("./databi/social/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, - moneyRedirect: new URL("./money/index.html", import.meta.url).pathname, opsCenter: new URL("./ops-center/index.html", import.meta.url).pathname } }