From 9b7df9b8ac6bd98784d8aac0d51f7e6a6b602ce8 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 14:49:39 +0800 Subject: [PATCH] feat(ops-center): support lucky gift dynamic v3 --- contracts/admin-openapi.json | 20 +- ops-center/src/OpsCenterApp.jsx | 316 ++++--- ops-center/src/OpsCenterApp.test.jsx | 436 +++++++--- ops-center/src/api.js | 479 ++++++----- ops-center/src/api.test.js | 334 +++++--- ops-center/src/components/ConfigsView.jsx | 184 ++-- .../src/components/LuckyGiftConfigDialog.jsx | 794 ++++++++++++------ ops-center/src/configForm.js | 544 +++++++++--- ops-center/src/configForm.test.js | 217 +++++ ops-center/src/styles/index.css | 509 +++++------ .../lucky-gift/pages/LuckyGiftConfigPage.jsx | 214 +---- .../pages/LuckyGiftConfigPage.test.jsx | 14 + src/shared/api/generated/endpoints.ts | 18 +- src/shared/api/generated/schema.d.ts | 36 +- 14 files changed, 2620 insertions(+), 1495 deletions(-) create mode 100644 ops-center/src/configForm.test.js create mode 100644 src/features/lucky-gift/pages/LuckyGiftConfigPage.test.jsx diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 29bd213..ffe1de5 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -320,7 +320,7 @@ "x-permissions": ["red-packet:update"] } }, - "/admin/activity/lucky-gifts/v2/config": { + "/admin/ops-center/lucky-gifts/config": { "get": { "operationId": "getLuckyGiftConfig", "responses": { @@ -342,7 +342,7 @@ "x-permissions": ["lucky-gift:update"] } }, - "/admin/activity/lucky-gifts/v2/configs": { + "/admin/ops-center/lucky-gifts/configs": { "get": { "operationId": "listLuckyGiftConfigs", "responses": { @@ -354,7 +354,7 @@ "x-permissions": ["lucky-gift:view"] } }, - "/admin/activity/lucky-gifts/v2/draws": { + "/admin/ops-center/lucky-gifts/draws": { "get": { "operationId": "listLuckyGiftDraws", "responses": { @@ -366,7 +366,7 @@ "x-permissions": ["lucky-gift:view"] } }, - "/admin/activity/lucky-gifts/v2/draw-summary": { + "/admin/ops-center/lucky-gifts/summary": { "get": { "operationId": "getLuckyGiftDrawSummary", "responses": { @@ -378,6 +378,18 @@ "x-permissions": ["lucky-gift:view"] } }, + "/admin/ops-center/lucky-gifts/pools": { + "get": { + "operationId": "listLuckyGiftPoolBalances", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "x-permission": "lucky-gift:view", + "x-permissions": ["lucky-gift:view"] + } + }, "/admin/activity/wheel/config": { "get": { "operationId": "getWheelConfig", diff --git a/ops-center/src/OpsCenterApp.jsx b/ops-center/src/OpsCenterApp.jsx index 3c55ee0..7d0e75b 100644 --- a/ops-center/src/OpsCenterApp.jsx +++ b/ops-center/src/OpsCenterApp.jsx @@ -17,149 +17,185 @@ import { OpsShell } from "./components/OpsShell.jsx"; import { OverviewView } from "./components/OverviewView.jsx"; const views = [ - { icon: InsightsOutlined, key: "overview", label: "数据总览" }, - { icon: CardGiftcardOutlined, key: "configs", label: "幸运礼物配置" }, - { icon: CasinoOutlined, key: "draws", label: "抽奖记录" }, - { icon: AppsOutlined, key: "apps", label: "应用列表" } + { icon: InsightsOutlined, key: "overview", label: "数据总览" }, + { icon: CardGiftcardOutlined, key: "configs", label: "幸运礼物配置" }, + { icon: CasinoOutlined, key: "draws", label: "抽奖记录" }, + { icon: AppsOutlined, key: "apps", label: "应用列表" }, ]; +const viewKeys = new Set(views.map((view) => view.key)); const initialDashboard = { - apps: [], - appSummaries: [], - luckyGifts: [], - poolBalances: [], - summary: {} + apps: [], + appSummaries: [], + luckyGifts: [], + poolBalances: [], + summary: {}, }; -export function OpsCenterApp() { - const [activeView, setActiveView] = useState("overview"); - const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true }); - const [dialog, setDialog] = useState(null); - const [savingConfig, setSavingConfig] = useState(false); - const { showToast } = useToast(); - - const loadDashboard = useCallback(async () => { - setState((current) => ({ ...current, error: "", loading: true })); - try { - const data = await fetchOpsDashboard(); - setState({ data, error: "", loaded: true, loading: false }); - } catch (error) { - // 接口灰度期间保留已加载的数据和页面结构,单次失败只提示、不整页白屏。 - setState((current) => ({ - data: current.data || initialDashboard, - error: error.message || "运营接口暂不可用", - loaded: current.loaded, - loading: false - })); - } - }, []); - - useEffect(() => { - loadDashboard(); - }, [loadDashboard]); - - const { data } = state; - - const appOptions = useMemo( - () => data.apps.map((item) => [item.app_code ?? item.appCode, `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`]), - [data.apps] - ); - - // 抽奖记录页的奖池下拉:配置和真实水位都可能先于对方存在(先发布未入账 / 老池子停用后仍有流水),取并集。 - const poolIDsByApp = useMemo(() => { - const byApp = new Map(); - const add = (appCode, poolID) => { - if (!appCode || !poolID) { - return; - } - if (!byApp.has(appCode)) { - byApp.set(appCode, new Set()); - } - byApp.get(appCode).add(poolID); - }; - data.luckyGifts.forEach((config) => add(config.app_code, config.pool_id)); - data.poolBalances.forEach((pool) => add(pool.app_code ?? pool.appCode, pool.pool_id ?? pool.poolId)); - return new Map(Array.from(byApp.entries(), ([appCode, poolIDs]) => [appCode, Array.from(poolIDs).sort()])); - }, [data.luckyGifts, data.poolBalances]); - - const openCreateConfig = useCallback(() => { - // 新建配置以第一个应用的现有配置做参数模板(每个应用至少有一行草稿或已发布配置), - // 应用和奖池在弹窗里由运营自行选择,避免旧版"添加配置"悄悄绑定到第一个应用的问题。 - const template = data.luckyGifts[0]; - if (!template) { - showToast("暂无可添加配置的应用", "warning"); - return; - } - setDialog({ - config: { ...structuredClone(template), is_default: true, pool_id: DEFAULT_POOL_ID, rule_version: 0 }, - mode: "create" - }); - }, [data.luckyGifts, showToast]); - - const openEditConfig = useCallback((config) => { - setDialog({ config: structuredClone(config), mode: "edit" }); - }, []); - - const handleSaveConfig = useCallback( - async (config) => { - setSavingConfig(true); - try { - await saveLuckyGiftConfig(config); - showToast(`已发布 ${config.app_code} / ${config.pool_id} 的新规则版本`, "success"); - setDialog(null); - await loadDashboard(); - } catch (error) { - // 保存失败保持弹窗打开,运营修完参数可直接重试,不丢已填内容。 - showToast(error.message || "保存幸运礼物配置失败", "error"); - } finally { - setSavingConfig(false); - } - }, - [loadDashboard, showToast] - ); - - const defaultAppCode = data.apps[0]?.app_code ?? data.apps[0]?.appCode ?? ""; - - return ( - -
- - {state.loading ? "同步中" : "已就绪"} - - - {/* 首屏失败走 DataState 的错误重试页;已有数据时刷新失败只弹提示条,保留旧数据可用。 */} - {state.error && state.loaded ? ( -
- {state.error} -
- ) : null} - - {activeView === "overview" ? : null} - {activeView === "configs" ? ( - - ) : null} - {activeView === "draws" ? : null} - {activeView === "apps" ? : null} - - {dialog ? ( - setDialog(null)} - onSubmit={handleSaveConfig} - /> - ) : null} -
-
- ); +export function initialOpsCenterView(search = globalThis.location?.search || "") { + const requested = new URLSearchParams(search).get("view") || ""; + return viewKeys.has(requested) ? requested : "overview"; +} + +export function OpsCenterApp() { + // 主后台旧入口通过 ?view=configs 进入独立 HTML;只接受白名单视图,异常参数仍回到总览。 + const [activeView, setActiveView] = useState(() => initialOpsCenterView()); + const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true }); + const [dialog, setDialog] = useState(null); + const [savingConfig, setSavingConfig] = useState(false); + const { showToast } = useToast(); + + const loadDashboard = useCallback(async () => { + setState((current) => ({ ...current, error: "", loading: true })); + try { + const data = await fetchOpsDashboard(); + setState({ data, error: "", loaded: true, loading: false }); + } catch (error) { + // 接口灰度期间保留已加载的数据和页面结构,单次失败只提示、不整页白屏。 + setState((current) => ({ + data: current.data || initialDashboard, + error: error.message || "运营接口暂不可用", + loaded: current.loaded, + loading: false, + })); + } + }, []); + + useEffect(() => { + loadDashboard(); + }, [loadDashboard]); + + const { data } = state; + + const appOptions = useMemo( + () => + data.apps.map((item) => [ + item.app_code ?? item.appCode, + `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`, + ]), + [data.apps], + ); + + // 抽奖记录页的奖池下拉:配置和真实水位都可能先于对方存在(先发布未入账 / 老池子停用后仍有流水),取并集。 + const poolIDsByApp = useMemo(() => { + const byApp = new Map(); + const add = (appCode, poolID) => { + if (!appCode || !poolID) { + return; + } + if (!byApp.has(appCode)) { + byApp.set(appCode, new Set()); + } + byApp.get(appCode).add(poolID); + }; + data.luckyGifts.forEach((config) => add(config.app_code, config.pool_id)); + data.poolBalances.forEach((pool) => add(pool.app_code ?? pool.appCode, pool.pool_id ?? pool.poolId)); + return new Map(Array.from(byApp.entries(), ([appCode, poolIDs]) => [appCode, Array.from(poolIDs).sort()])); + }, [data.luckyGifts, data.poolBalances]); + + const openCreateConfig = useCallback(() => { + const firstAppCode = data.apps + .map((item) => + String(item.app_code ?? item.appCode ?? "") + .trim() + .toLowerCase(), + ) + .find(Boolean); + if (!firstAppCode) { + showToast("暂无可添加配置的应用", "warning"); + return; + } + // 金额门槛和六维上限按 App 金币口径配置,绝不能复制第一条已发布规则给另一个 App。 + // 仅传规则身份,让 configToForm 生成 disabled dynamic_v3 安全草稿;金额字段保持 0,运营补齐后才能启用。 + setDialog({ + config: { + app_code: firstAppCode, + enabled: false, + is_default: true, + pool_id: DEFAULT_POOL_ID, + rule_version: 0, + strategy_version: "dynamic_v3", + }, + mode: "create", + }); + }, [data.apps, showToast]); + + const openEditConfig = useCallback((config) => { + setDialog({ config: structuredClone(config), mode: "edit" }); + }, []); + + const handleSaveConfig = useCallback( + async (config) => { + setSavingConfig(true); + try { + await saveLuckyGiftConfig(config); + showToast(`已发布 ${config.app_code} / ${config.pool_id} 的新规则版本`, "success"); + setDialog(null); + await loadDashboard(); + } catch (error) { + // 保存失败保持弹窗打开,运营修完参数可直接重试,不丢已填内容。 + showToast(error.message || "保存幸运礼物配置失败", "error"); + } finally { + setSavingConfig(false); + } + }, + [loadDashboard, showToast], + ); + + const defaultAppCode = data.apps[0]?.app_code ?? data.apps[0]?.appCode ?? ""; + + return ( + +
+ + + {state.loading ? "同步中" : "已就绪"} + + + + {/* 首屏失败走 DataState 的错误重试页;已有数据时刷新失败只弹提示条,保留旧数据可用。 */} + {state.error && state.loaded ? ( +
+ {state.error} +
+ ) : null} + + {activeView === "overview" ? : null} + {activeView === "configs" ? ( + + ) : null} + {activeView === "draws" ? ( + + ) : null} + {activeView === "apps" ? : null} + + {dialog ? ( + setDialog(null)} + onSubmit={handleSaveConfig} + /> + ) : null} +
+
+ ); } diff --git a/ops-center/src/OpsCenterApp.test.jsx b/ops-center/src/OpsCenterApp.test.jsx index 6a6b61b..c5d4e21 100644 --- a/ops-center/src/OpsCenterApp.test.jsx +++ b/ops-center/src/OpsCenterApp.test.jsx @@ -2,175 +2,339 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea import { beforeEach, expect, test, vi } from "vitest"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; import { fetchLuckyGiftDraws, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js"; -import { OpsCenterApp } from "./OpsCenterApp.jsx"; +import { initialOpsCenterView, OpsCenterApp } from "./OpsCenterApp.jsx"; vi.mock("./api.js", () => ({ - DEFAULT_POOL_ID: "default", - fetchLuckyGiftDraws: vi.fn(), - fetchOpsDashboard: vi.fn(), - saveLuckyGiftConfig: vi.fn() + DEFAULT_POOL_ID: "default", + fetchLuckyGiftDraws: vi.fn(), + fetchOpsDashboard: vi.fn(), + saveLuckyGiftConfig: vi.fn(), })); beforeEach(() => { - vi.clearAllMocks(); - saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" }); - fetchLuckyGiftDraws.mockResolvedValue({ - items: [ - { - app_code: "lalu", - created_at_ms: 1767000000000, - draw_id: "draw-1", - effective_reward_coins: 100, - external_user_id: "ext-1", - gift_id: "gift-9", - multiplier_ppm: 2_000_000, - pool_id: "super_lucky", - reward_status: "granted", - rule_version: 2 - } - ], - page: 1, - pageSize: 20, - total: 1 - }); - fetchOpsDashboard.mockResolvedValue({ - 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" } - ], - appSummaries: [ - { - actual_rtp_ppm: 900000, - app_code: "lalu", - failed_draws: 0, - granted_draws: 3, - pending_draws: 0, - total_draws: 3, - total_reward_coins: 900, - total_spent_coins: 1000, - unique_rooms: 2, - unique_users: 2 - } - ], - luckyGifts: [ - { app_code: "lalu", enabled: true, is_default: false, pool_id: "lucky", rule_version: 3, target_rtp_percent: 95 }, - { app_code: "lalu", enabled: false, is_default: false, pool_id: "super_lucky", rule_version: 2, target_rtp_percent: 92 }, - { app_code: "yumi", enabled: false, is_default: true, pool_id: "default", rule_version: 0, target_rtp_percent: 95 } - ], - poolBalances: [ - { app_code: "lalu", available_balance: 37950017, balance: 38559017, materialized: true, pool_id: "lucky", reserve_floor: 609000, updated_at_ms: 1767000000000 }, - { app_code: "yumi", available_balance: 361500, balance: 382500, materialized: false, pool_id: "default", reserve_floor: 21000 } - ], - summary: { - activeApps: 2, - configuredPools: 2, - enabledPools: 1, - failedDraws: 0, - grantedDraws: 3, - pendingDraws: 0, - poolAvailableTotal: 38311517, - poolBalanceTotal: 38941517, - poolReserveTotal: 630000, - totalDraws: 3, - totalRewardCoins: 900, - totalSpentCoins: 1000 - } - }); + vi.clearAllMocks(); + window.history.replaceState({}, "", "/"); + saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" }); + fetchLuckyGiftDraws.mockResolvedValue({ + items: [ + { + app_code: "lalu", + created_at_ms: 1767000000000, + draw_id: "draw-1", + effective_reward_coins: 100, + external_user_id: "ext-1", + gift_id: "gift-9", + multiplier_ppm: 2_000_000, + pool_id: "super_lucky", + reward_status: "granted", + rule_version: 2, + }, + ], + page: 1, + pageSize: 20, + total: 1, + }); + fetchOpsDashboard.mockResolvedValue({ + 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" }, + ], + appSummaries: [ + { + actual_rtp_ppm: 900000, + app_code: "lalu", + failed_draws: 0, + granted_draws: 3, + pending_draws: 0, + total_draws: 3, + total_reward_coins: 900, + total_spent_coins: 1000, + unique_rooms: 2, + unique_users: 2, + }, + ], + luckyGifts: [ + { + ...mockDynamicConfig(), + app_code: "lalu", + enabled: true, + is_default: false, + pool_id: "lucky", + rule_version: 3, + }, + { + ...mockFixedConfig(92), + app_code: "lalu", + enabled: false, + is_default: false, + pool_id: "super_lucky", + rule_version: 2, + }, + { + ...mockDynamicConfig(), + app_code: "yumi", + enabled: false, + is_default: true, + pool_id: "default", + rule_version: 0, + }, + ], + poolBalances: [ + { + app_code: "lalu", + available_balance: 37950017, + balance: 38559017, + materialized: true, + pool_id: "lucky", + reserve_floor: 609000, + updated_at_ms: 1767000000000, + }, + { + app_code: "yumi", + available_balance: 361500, + balance: 382500, + materialized: false, + pool_id: "default", + reserve_floor: 21000, + }, + ], + summary: { + activeApps: 2, + configuredPools: 2, + enabledPools: 1, + failedDraws: 0, + grantedDraws: 3, + pendingDraws: 0, + poolAvailableTotal: 38311517, + poolBalanceTotal: 38941517, + poolReserveTotal: 630000, + totalDraws: 3, + totalRewardCoins: 900, + totalSpentCoins: 1000, + }, + }); }); function renderApp() { - return render( - - - - ); + return render( + + + , + ); } test("renders overview with aggregated kpis and pool balances", async () => { - renderApp(); + renderApp(); - expect(await screen.findByRole("heading", { name: "运营配置中心" })).toBeTruthy(); - expect(screen.getByRole("navigation", { name: "运营中心导航" })).toBeTruthy(); - ["数据总览", "幸运礼物配置", "抽奖记录", "应用列表"].forEach((label) => { - expect(screen.getByRole("button", { name: new RegExp(label) })).toBeTruthy(); - }); + expect(await screen.findByRole("heading", { name: "运营配置中心" })).toBeTruthy(); + expect(screen.getByRole("navigation", { name: "运营中心导航" })).toBeTruthy(); + ["数据总览", "幸运礼物配置", "抽奖记录", "应用列表"].forEach((label) => { + expect(screen.getByRole("button", { name: new RegExp(label) })).toBeTruthy(); + }); - expect(await screen.findByText("运营应用")).toBeTruthy(); - // 抽奖次数 KPI 必须是跨应用聚合值,而不是第一个应用的汇总(KPI 卡和汇总表列头各出现一次)。 - expect(screen.getAllByText("抽奖次数").length).toBeGreaterThan(0); - expect(screen.getAllByText("38,941,517").length).toBeGreaterThan(0); - expect(screen.getByText("奖池实时水位")).toBeTruthy(); - expect(screen.getByText("已入账")).toBeTruthy(); - expect(screen.getByText("默认水位")).toBeTruthy(); - expect(fetchOpsDashboard).toHaveBeenCalledTimes(1); + expect(await screen.findByText("运营应用")).toBeTruthy(); + // 抽奖次数 KPI 必须是跨应用聚合值,而不是第一个应用的汇总(KPI 卡和汇总表列头各出现一次)。 + expect(screen.getAllByText("抽奖次数").length).toBeGreaterThan(0); + expect(screen.getAllByText("38,941,517").length).toBeGreaterThan(0); + expect(screen.getByText("奖池实时水位")).toBeTruthy(); + expect(screen.getByText("已入账")).toBeTruthy(); + expect(screen.getByText("默认水位")).toBeTruthy(); + expect(fetchOpsDashboard).toHaveBeenCalledTimes(1); +}); + +test("opens the requested config view from the legacy admin document redirect", async () => { + expect(initialOpsCenterView("?view=configs")).toBe("configs"); + expect(initialOpsCenterView("?view=unknown")).toBe("overview"); + window.history.replaceState({}, "", "/ops-center/?view=configs"); + + renderApp(); + + expect(await screen.findByRole("button", { name: "添加配置" })).toBeTruthy(); + expect(screen.getByText("lucky")).toBeTruthy(); }); test("lists every pool config including published non-default pools", async () => { - renderApp(); - await screen.findByText("运营应用"); + renderApp(); + await screen.findByText("运营应用"); - fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); + fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); - // 核心回归:lalu 的 lucky / super_lucky 已发布奖池必须出现在配置列表里。 - expect(await screen.findByText("lucky")).toBeTruthy(); - expect(screen.getByText("super_lucky")).toBeTruthy(); - expect(screen.getByText("v3")).toBeTruthy(); - expect(screen.getByText("v2")).toBeTruthy(); - expect(screen.getByText("已启用")).toBeTruthy(); - expect(screen.getByText("已停用")).toBeTruthy(); - expect(screen.getByText("默认草稿")).toBeTruthy(); + // 核心回归:lalu 的 lucky / super_lucky 已发布奖池必须出现在配置列表里。 + expect(await screen.findByText("lucky")).toBeTruthy(); + expect(screen.getByText("super_lucky")).toBeTruthy(); + expect(screen.getByText("v3")).toBeTruthy(); + expect(screen.getByText("v2")).toBeTruthy(); + expect(screen.getByText("已启用")).toBeTruthy(); + expect(screen.getByText("已停用")).toBeTruthy(); + expect(screen.getByText("默认草稿")).toBeTruthy(); +}); + +test("creates a disabled dynamic draft without copying another app monetary limits", async () => { + renderApp(); + await screen.findByText("运营应用"); + fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); + fireEvent.click(await screen.findByRole("button", { name: "添加配置" })); + + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByText("添加幸运礼物配置")).toBeTruthy(); + expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3"); + expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("1000000"); + // 第一条 lalu 已发布配置的单次上限为 100 万;新建跨 App 配置必须归零并保持停用。 + expect(within(dialog).getByLabelText(/单次返奖上限/).value).toBe("0"); + expect(within(dialog).getByText("发布后保持停用")).toBeTruthy(); }); test("opens config dialog from a published pool row and saves a new version", async () => { - renderApp(); - await screen.findByText("运营应用"); + renderApp(); + await screen.findByText("运营应用"); - fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); - await screen.findByText("lucky"); + fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); + await screen.findByText("lucky"); - fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]); - const dialog = await screen.findByRole("dialog"); - expect(within(dialog).getByText("新增幸运礼物版本")).toBeTruthy(); - expect(within(dialog).getAllByLabelText("概率 %")[0].readOnly).toBe(true); + fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]); + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByText("新增幸运礼物版本")).toBeTruthy(); + expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3"); + expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("1000000"); + expect(within(dialog).getByLabelText(/大奖倍率/).value).toBe("200, 500, 1000"); + expect(within(dialog).getAllByLabelText("概率 %")[0].readOnly).toBe(true); - fireEvent.change(within(dialog).getByLabelText(/目标 RTP \(%\)/), { target: { value: "88" } }); - fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" })); + fireEvent.change(within(dialog).getByLabelText(/目标 RTP \(%\)/), { target: { value: "88" } }); + fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" })); - await waitFor(() => { - expect(saveLuckyGiftConfig).toHaveBeenCalledWith( - expect.objectContaining({ - app_code: "lalu", - pool_id: "lucky", - stages: expect.arrayContaining([expect.objectContaining({ stage: "novice" })]), - target_rtp_percent: 88 - }) - ); - }); + await waitFor(() => { + expect(saveLuckyGiftConfig).toHaveBeenCalledWith( + expect.objectContaining({ + app_code: "lalu", + anchor_rate_percent: 1, + jackpot_multipliers: [200, 500, 1000], + pool_id: "lucky", + profit_rate_percent: 1, + stages: expect.arrayContaining([expect.objectContaining({ stage: "novice" })]), + strategy_version: "dynamic_v3", + target_rtp_percent: 88, + }), + ); + }); }); +test("blocks an invalid enabled dynamic rule before calling the save API", async () => { + renderApp(); + await screen.findByText("运营应用"); + fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); + await screen.findByText("lucky"); + fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]); + + const dialog = await screen.findByRole("dialog"); + fireEvent.change(within(dialog).getByLabelText(/初始奖池金币/), { target: { value: "0" } }); + fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" })); + + expect(await within(dialog).findByRole("alert")).toHaveTextContent("初始奖池必须大于 0"); + expect(saveLuckyGiftConfig).not.toHaveBeenCalled(); + + // 奖档编辑同样必须清掉上一次提交错误,避免用户修正后仍看到已经过期的阻断原因。 + fireEvent.change(within(dialog).getAllByLabelText("倍率")[0], { target: { value: "0.1" } }); + expect(within(dialog).queryByRole("alert")).toBeNull(); +}); + +function mockDynamicConfig() { + return { + anchor_daily_payout_cap: 6_000_000, + anchor_rate_percent: 1, + control_band_percent: 3, + device_daily_payout_cap: 4_000_000, + effective_from_ms: 0, + gift_price_reference: 100, + high_water_nonzero_factor_percent: 130, + high_watermark_coins: 20_000_000, + initial_pool_coins: 1_000_000, + jackpot_global_rtp_max_percent: 98, + jackpot_multipliers: [200, 500, 1000], + jackpot_spend_threshold_coins: 50_000, + jackpot_user_72h_rtp_max_percent: 96, + jackpot_user_day_rtp_max_percent: 96, + loss_streak_guarantee: 5, + low_water_nonzero_factor_percent: 70, + low_watermark_coins: 10_000_000, + max_jackpot_hits_per_user_day: 5, + max_single_payout: 1_000_000, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_rate_percent: 98, + profit_rate_percent: 1, + recharge_boost_factor_percent: 110, + recharge_boost_window_ms: 300_000, + room_hourly_payout_cap: 5_000_000, + settlement_window_wager: 1_000_000, + stages: mockStages(98), + strategy_version: "dynamic_v3", + target_rtp_percent: 98, + user_daily_payout_cap: 3_000_000, + user_hourly_payout_cap: 2_000_000, + }; +} + +function mockFixedConfig(targetRTP) { + return { + control_band_percent: 1, + gift_price_reference: 100, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_rate_percent: targetRTP + 1, + settlement_window_wager: 1_000_000, + stages: mockStages(targetRTP), + strategy_version: "fixed_v2", + target_rtp_percent: targetRTP, + }; +} + +function mockStages(targetRTP) { + return ["novice", "normal", "advanced"].map((stage, index) => ({ + min_recharge_30d_coins: index === 0 ? 0 : index, + min_recharge_7d_coins: index === 2 ? 1 : 0, + stage, + tiers: [ + { + enabled: true, + high_water_only: false, + multiplier: 0, + probability_percent: 100 - targetRTP, + tier_id: `${stage}_none`, + }, + { + enabled: true, + high_water_only: false, + multiplier: 1, + probability_percent: targetRTP, + tier_id: `${stage}_1x`, + }, + ], + })); +} + test("loads draw records for the first app with server pagination", async () => { - renderApp(); - await screen.findByText("运营应用"); + renderApp(); + await screen.findByText("运营应用"); - fireEvent.click(screen.getByRole("button", { name: /抽奖记录/ })); + fireEvent.click(screen.getByRole("button", { name: /抽奖记录/ })); - await waitFor(() => { - expect(fetchLuckyGiftDraws).toHaveBeenCalledWith( - expect.objectContaining({ appCode: "lalu", page: 1, poolId: "", status: "" }) - ); - }); - expect(await screen.findByText("ext-1")).toBeTruthy(); - expect(screen.getByText("已发放")).toBeTruthy(); - expect(screen.getByText("2x")).toBeTruthy(); + await waitFor(() => { + expect(fetchLuckyGiftDraws).toHaveBeenCalledWith( + expect.objectContaining({ appCode: "lalu", page: 1, poolId: "", status: "" }), + ); + }); + expect(await screen.findByText("ext-1")).toBeTruthy(); + expect(screen.getByText("已发放")).toBeTruthy(); + expect(screen.getByText("2x")).toBeTruthy(); }); test("shows app registry and fixed integrations in apps view", async () => { - renderApp(); - await screen.findByText("运营应用"); + renderApp(); + await screen.findByText("运营应用"); - fireEvent.click(screen.getByRole("button", { name: /应用列表/ })); + fireEvent.click(screen.getByRole("button", { name: /应用列表/ })); - expect(await screen.findByText("Lalu")).toBeTruthy(); - expect(screen.getByText("主数据")).toBeTruthy(); - expect(screen.getByText("外部接入")).toBeTruthy(); + expect(await screen.findByText("Lalu")).toBeTruthy(); + expect(screen.getByText("主数据")).toBeTruthy(); + expect(screen.getByText("外部接入")).toBeTruthy(); }); diff --git a/ops-center/src/api.js b/ops-center/src/api.js index 1983fb4..9095cce 100644 --- a/ops-center/src/api.js +++ b/ops-center/src/api.js @@ -4,261 +4,344 @@ export const OPS_API_PREFIX = "/api/v1/admin/ops-center"; export const DEFAULT_POOL_ID = "default"; export async function fetchOpsDashboard(query = {}) { - // Ops Center 是跨 App 的运营面板:先拉应用主数据,再按 app_code 扇出拉取奖池配置、余额和抽奖汇总。 - // lucky-gift-service 的所有后台接口都以 app_code 作为租户维度过滤,没有"全部应用"一次拉取的入口。 - const appsPayload = await opsRequest("/apps", { query }); - const apps = normalizeItems(appsPayload); - const appCodes = uniqueAppCodes(apps); + // Ops Center 是跨 App 的运营面板:先拉应用主数据,再按 app_code 扇出拉取奖池配置、余额和抽奖汇总。 + // lucky-gift-service 的所有后台接口都以 app_code 作为租户维度过滤,没有"全部应用"一次拉取的入口。 + const appsPayload = await opsRequest("/apps", { query }); + const apps = normalizeItems(appsPayload); + const appCodes = uniqueAppCodes(apps); - const perApp = await Promise.all( - appCodes.map(async (appCode) => { - const [luckyGifts, poolBalances, drawSummary] = await Promise.all([ - fetchAppLuckyGiftConfigs(appCode), - opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems), - opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } }) - ]); - return { appCode, drawSummary, luckyGifts, poolBalances }; - }) - ); + const perApp = await Promise.all( + appCodes.map(async (appCode) => { + const [luckyGifts, poolBalances, drawSummary] = await Promise.all([ + fetchAppLuckyGiftConfigs(appCode), + opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems), + opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } }), + ]); + return { appCode, drawSummary, luckyGifts, poolBalances }; + }), + ); - return normalizeDashboard({ apps, perApp }); + return normalizeDashboard({ apps, perApp }); } async function fetchAppLuckyGiftConfigs(appCode) { - // 必须用复数 /configs:它返回该应用每个奖池的最新已发布规则版本。 - // 单数 /config 只回默认奖池(无配置时是服务端草稿),会把 lalu 的 lucky/super_lucky 等已发布奖池整体漏掉。 - const published = normalizeItems(await opsRequest("/lucky-gifts/configs", { query: { app_code: appCode } })) - .map((config) => normalizeLuckyGiftConfig(config, appCode)) - .filter((config) => config.app_code); - if (published.length) { - return published; - } - // 一个已发布配置都没有的应用仍要展示一行可编辑草稿,让运营从这里发布第一版; - // 草稿的默认参数(RTP、回收率、结算窗口)以服务端 DefaultRuleConfig 为准,不在前端伪造。 - const draft = normalizeLuckyGiftConfig(await opsRequest("/lucky-gifts/config", { query: { app_code: appCode } }), appCode); - return draft.app_code ? [draft] : []; + // 必须用复数 /configs:它返回该应用每个奖池的最新已发布规则版本。 + // 单数 /config 只回默认奖池(无配置时是服务端草稿),会把 lalu 的 lucky/super_lucky 等已发布奖池整体漏掉。 + const published = normalizeItems(await opsRequest("/lucky-gifts/configs", { query: { app_code: appCode } })) + .map((config) => normalizeLuckyGiftConfig(config, appCode)) + .filter((config) => config.app_code); + if (published.length) { + return published; + } + // 一个已发布配置都没有的应用仍要展示一行可编辑草稿,让运营从这里发布第一版; + // 草稿的默认参数(RTP、回收率、结算窗口)以服务端 DefaultRuleConfig 为准,不在前端伪造。 + const draft = normalizeLuckyGiftConfig( + await opsRequest("/lucky-gifts/config", { query: { app_code: appCode } }), + appCode, + ); + return draft.app_code ? [draft] : []; } -export async function fetchLuckyGiftDraws({ appCode, page = 1, pageSize = 20, poolId = "", status = "", userId = "" } = {}) { - const query = { - app_code: appCode, - page, - page_size: pageSize, - pool_id: poolId, - status - }; - // 同一个输入框同时支持内部数字 UID 和外部接入方的字符串 ID:纯数字走 user_id 精确匹配, - // 其余走 external_user_id,避免运营要先分辨用户来源再选字段。 - const trimmedUserID = String(userId || "").trim(); - if (/^\d+$/.test(trimmedUserID)) { - query.user_id = trimmedUserID; - } else if (trimmedUserID) { - query.external_user_id = trimmedUserID; - } +export async function fetchLuckyGiftDraws({ + appCode, + page = 1, + pageSize = 20, + poolId = "", + status = "", + userId = "", +} = {}) { + const query = { + app_code: appCode, + page, + page_size: pageSize, + pool_id: poolId, + status, + }; + // 同一个输入框同时支持内部数字 UID 和外部接入方的字符串 ID:纯数字走 user_id 精确匹配, + // 其余走 external_user_id,避免运营要先分辨用户来源再选字段。 + const trimmedUserID = String(userId || "").trim(); + if (/^\d+$/.test(trimmedUserID)) { + query.user_id = trimmedUserID; + } else if (trimmedUserID) { + query.external_user_id = trimmedUserID; + } - const payload = await opsRequest("/lucky-gifts/draws", { query }); - return { - items: normalizeItems(payload), - page: numberValue(payload.page) || page, - pageSize: numberValue(payload.pageSize ?? payload.page_size) || pageSize, - total: numberValue(payload.total) - }; + const payload = await opsRequest("/lucky-gifts/draws", { query }); + return { + items: normalizeItems(payload), + page: numberValue(payload.page) || page, + pageSize: numberValue(payload.pageSize ?? payload.page_size) || pageSize, + total: numberValue(payload.total), + }; } export async function saveLuckyGiftConfig(config = {}) { - const payload = luckyGiftConfigPayload(config); - return opsRequest("/lucky-gifts/config", { - body: payload, - method: "PUT", - query: { - app_code: payload.app_code, - pool_id: payload.pool_id - } - }); + const payload = luckyGiftConfigPayload(config); + return opsRequest("/lucky-gifts/config", { + body: payload, + method: "PUT", + query: { + app_code: payload.app_code, + pool_id: payload.pool_id, + }, + }); } export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) { - const response = await fetch(buildOpsUrl(path, query), { - body: body === undefined ? undefined : JSON.stringify(body), - credentials: "include", - headers: buildHeaders(body), - method - }); - const payload = await readPayload(response); + const response = await fetch(buildOpsUrl(path, query), { + body: body === undefined ? undefined : JSON.stringify(body), + credentials: "include", + headers: buildHeaders(body), + method, + }); + const payload = await readPayload(response); - if (!response.ok || payload.code !== 0) { - throw new Error(payload.message || response.statusText || "运营接口请求失败"); - } + if (!response.ok || payload.code !== 0) { + throw new Error(payload.message || response.statusText || "运营接口请求失败"); + } - return payload.data ?? {}; + return payload.data ?? {}; } export function buildOpsUrl(path, query = {}) { - const normalizedPath = path.startsWith("/") ? path : `/${path}`; - const url = new URL(`${OPS_API_PREFIX}${normalizedPath}`, window.location.origin); + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const url = new URL(`${OPS_API_PREFIX}${normalizedPath}`, window.location.origin); - Object.entries(query || {}).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== "") { - url.searchParams.set(key, String(value)); - } - }); + Object.entries(query || {}).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + }); - return url.toString(); + return url.toString(); } export function normalizeDashboard({ apps = [], perApp = [] } = {}) { - const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []); - const poolBalances = perApp - .flatMap((entry) => entry.poolBalances || []) - .filter((item) => item?.app_code || item?.appCode); - const appSummaries = perApp - .map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode)) - .filter((summary) => summary.app_code); + const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []); + const poolBalances = perApp + .flatMap((entry) => entry.poolBalances || []) + .filter((item) => item?.app_code || item?.appCode); + const appSummaries = perApp + .map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode)) + .filter((summary) => summary.app_code); - const publishedConfigs = luckyGifts.filter((config) => !config.is_default); + const publishedConfigs = luckyGifts.filter((config) => !config.is_default); - // KPI 一律跨应用聚合。旧版只取第一个应用的汇总,导致 lalu 有大量抽奖时首屏"抽奖次数"仍显示 0。 - const summary = { - activeApps: apps.length, - configuredPools: publishedConfigs.length, - enabledPools: publishedConfigs.filter((config) => config.enabled).length, - failedDraws: sumBy(appSummaries, "failed_draws"), - grantedDraws: sumBy(appSummaries, "granted_draws"), - pendingDraws: sumBy(appSummaries, "pending_draws"), - poolAvailableTotal: sumBy(poolBalances, "available_balance", "availableBalance"), - poolBalanceTotal: sumBy(poolBalances, "balance", "Balance"), - poolReserveTotal: sumBy(poolBalances, "reserve_floor", "reserveFloor"), - totalDraws: sumBy(appSummaries, "total_draws"), - totalRewardCoins: sumBy(appSummaries, "total_reward_coins"), - totalSpentCoins: sumBy(appSummaries, "total_spent_coins") - }; + // KPI 一律跨应用聚合。旧版只取第一个应用的汇总,导致 lalu 有大量抽奖时首屏"抽奖次数"仍显示 0。 + const summary = { + activeApps: apps.length, + configuredPools: publishedConfigs.length, + enabledPools: publishedConfigs.filter((config) => config.enabled).length, + failedDraws: sumBy(appSummaries, "failed_draws"), + grantedDraws: sumBy(appSummaries, "granted_draws"), + pendingDraws: sumBy(appSummaries, "pending_draws"), + poolAvailableTotal: sumBy(poolBalances, "available_balance", "availableBalance"), + poolBalanceTotal: sumBy(poolBalances, "balance", "Balance"), + poolReserveTotal: sumBy(poolBalances, "reserve_floor", "reserveFloor"), + totalDraws: sumBy(appSummaries, "total_draws"), + totalRewardCoins: sumBy(appSummaries, "total_reward_coins"), + totalSpentCoins: sumBy(appSummaries, "total_spent_coins"), + }; - return { apps, appSummaries, luckyGifts, poolBalances, summary }; + return { apps, appSummaries, luckyGifts, poolBalances, summary }; } function normalizeDrawSummary(summary = {}, appCode = "") { - return { - actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm), - app_code: String(appCode || summary.app_code || "").trim().toLowerCase(), - failed_draws: numberValue(summary.failed_draws ?? summary.failedDraws), - granted_draws: numberValue(summary.granted_draws ?? summary.grantedDraws), - pending_draws: numberValue(summary.pending_draws ?? summary.pendingDraws), - total_draws: numberValue(summary.total_draws ?? summary.totalDraws), - total_reward_coins: numberValue(summary.total_reward_coins ?? summary.totalRewardCoins), - total_spent_coins: numberValue(summary.total_spent_coins ?? summary.totalSpentCoins), - unique_rooms: numberValue(summary.unique_rooms ?? summary.uniqueRooms), - unique_users: numberValue(summary.unique_users ?? summary.uniqueUsers) - }; + return { + actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm), + app_code: String(appCode || summary.app_code || "") + .trim() + .toLowerCase(), + failed_draws: numberValue(summary.failed_draws ?? summary.failedDraws), + granted_draws: numberValue(summary.granted_draws ?? summary.grantedDraws), + pending_draws: numberValue(summary.pending_draws ?? summary.pendingDraws), + total_draws: numberValue(summary.total_draws ?? summary.totalDraws), + total_reward_coins: numberValue(summary.total_reward_coins ?? summary.totalRewardCoins), + total_spent_coins: numberValue(summary.total_spent_coins ?? summary.totalSpentCoins), + unique_rooms: numberValue(summary.unique_rooms ?? summary.uniqueRooms), + unique_users: numberValue(summary.unique_users ?? summary.uniqueUsers), + }; } function uniqueAppCodes(apps) { - const seen = new Set(); - const codes = []; - apps.forEach((item) => { - const code = String(item?.app_code || item?.appCode || "").trim().toLowerCase(); - if (!code || seen.has(code)) { - return; - } - seen.add(code); - codes.push(code); - }); - return codes; + const seen = new Set(); + const codes = []; + apps.forEach((item) => { + const code = String(item?.app_code || item?.appCode || "") + .trim() + .toLowerCase(); + if (!code || seen.has(code)) { + return; + } + seen.add(code); + codes.push(code); + }); + return codes; } function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") { - const appCode = String(config.app_code || config.appCode || fallbackAppCode).trim().toLowerCase(); - const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion); - return { - ...config, - app_code: appCode, - // rule_version <= 0 表示服务端返回的未发布草稿:列表要标成"默认草稿"并禁止当作已生效规则解读。 - is_default: ruleVersion <= 0, - pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID, - rule_version: ruleVersion - }; + const appCode = String(config.app_code || config.appCode || fallbackAppCode) + .trim() + .toLowerCase(); + const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion); + return { + ...config, + app_code: appCode, + // rule_version <= 0 表示服务端返回的未发布草稿:列表要标成"默认草稿"并禁止当作已生效规则解读。 + is_default: ruleVersion <= 0, + pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID, + rule_version: ruleVersion, + // 升级前规则没有 strategy_version,lucky-gift owner 明确按 fixed_v2 解释;列表和编辑表单必须沿用该兼容语义。 + strategy_version: String(config.strategy_version || config.strategyVersion || "fixed_v2") + .trim() + .toLowerCase(), + }; } function luckyGiftConfigPayload(config = {}) { - const appCode = String(config.app_code || config.appCode || "").trim().toLowerCase(); - const poolID = String(config.pool_id || config.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID; - return { - app_code: appCode, - control_band_percent: numberValue(config.control_band_percent ?? config.controlBandPercent), - effective_from_ms: numberValue(config.effective_from_ms ?? config.effectiveFromMS), - enabled: Boolean(config.enabled), - gift_price_reference: numberValue(config.gift_price_reference ?? config.giftPriceReference), - normal_max_equivalent_draws: numberValue(config.normal_max_equivalent_draws ?? config.normalMaxEquivalentDraws), - novice_max_equivalent_draws: numberValue(config.novice_max_equivalent_draws ?? config.noviceMaxEquivalentDraws), - pool_id: poolID, - pool_rate_percent: numberValue(config.pool_rate_percent ?? config.poolRatePercent), - settlement_window_wager: numberValue(config.settlement_window_wager ?? config.settlementWindowWager), - stages: normalizeStages(config.stages), - target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent) - }; + const appCode = String(config.app_code || config.appCode || "") + .trim() + .toLowerCase(); + const poolID = String(config.pool_id || config.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID; + const strategyVersion = String(config.strategy_version || config.strategyVersion || "fixed_v2") + .trim() + .toLowerCase(); + + // 规则版本是不可变快照,PUT 必须完整回传 owner GET 返回的策略字段。只提交旧版 RTP/奖档字段会让 + // 空 strategy_version 被服务端按 fixed_v2 发布,或让 dynamic_v3 因资金、水位、大奖字段归零而拒绝发布。 + return { + anchor_daily_payout_cap: numberValue(config.anchor_daily_payout_cap ?? config.anchorDailyPayoutCap), + anchor_rate_percent: numberValue(config.anchor_rate_percent ?? config.anchorRatePercent), + app_code: appCode, + control_band_percent: numberValue(config.control_band_percent ?? config.controlBandPercent), + device_daily_payout_cap: numberValue(config.device_daily_payout_cap ?? config.deviceDailyPayoutCap), + effective_from_ms: numberValue(config.effective_from_ms ?? config.effectiveFromMs ?? config.effectiveFromMS), + enabled: Boolean(config.enabled), + gift_price_reference: numberValue(config.gift_price_reference ?? config.giftPriceReference), + high_water_nonzero_factor_percent: numberValue( + config.high_water_nonzero_factor_percent ?? config.highWaterNonzeroFactorPercent, + ), + high_watermark_coins: numberValue(config.high_watermark_coins ?? config.highWatermarkCoins), + initial_pool_coins: numberValue(config.initial_pool_coins ?? config.initialPoolCoins), + jackpot_global_rtp_max_percent: numberValue( + config.jackpot_global_rtp_max_percent ?? config.jackpotGlobalRTPMaxPercent, + ), + jackpot_multipliers: numberArray(config.jackpot_multipliers ?? config.jackpotMultipliers), + jackpot_spend_threshold_coins: numberValue( + config.jackpot_spend_threshold_coins ?? config.jackpotSpendThresholdCoins, + ), + jackpot_user_72h_rtp_max_percent: numberValue( + config.jackpot_user_72h_rtp_max_percent ?? config.jackpotUser72hRTPMaxPercent, + ), + jackpot_user_day_rtp_max_percent: numberValue( + config.jackpot_user_day_rtp_max_percent ?? config.jackpotUserDayRTPMaxPercent, + ), + loss_streak_guarantee: numberValue(config.loss_streak_guarantee ?? config.lossStreakGuarantee), + low_water_nonzero_factor_percent: numberValue( + config.low_water_nonzero_factor_percent ?? config.lowWaterNonzeroFactorPercent, + ), + low_watermark_coins: numberValue(config.low_watermark_coins ?? config.lowWatermarkCoins), + max_jackpot_hits_per_user_day: numberValue( + config.max_jackpot_hits_per_user_day ?? config.maxJackpotHitsPerUserDay, + ), + max_single_payout: numberValue(config.max_single_payout ?? config.maxSinglePayout), + normal_max_equivalent_draws: numberValue(config.normal_max_equivalent_draws ?? config.normalMaxEquivalentDraws), + novice_max_equivalent_draws: numberValue(config.novice_max_equivalent_draws ?? config.noviceMaxEquivalentDraws), + pool_id: poolID, + pool_rate_percent: numberValue(config.pool_rate_percent ?? config.poolRatePercent), + profit_rate_percent: numberValue(config.profit_rate_percent ?? config.profitRatePercent), + recharge_boost_factor_percent: numberValue( + config.recharge_boost_factor_percent ?? config.rechargeBoostFactorPercent, + ), + recharge_boost_window_ms: numberValue( + config.recharge_boost_window_ms ?? config.rechargeBoostWindowMs ?? config.rechargeBoostWindowMS, + ), + room_hourly_payout_cap: numberValue(config.room_hourly_payout_cap ?? config.roomHourlyPayoutCap), + settlement_window_wager: numberValue(config.settlement_window_wager ?? config.settlementWindowWager), + stages: normalizeStages(config.stages), + strategy_version: strategyVersion, + user_daily_payout_cap: numberValue(config.user_daily_payout_cap ?? config.userDailyPayoutCap), + user_hourly_payout_cap: numberValue(config.user_hourly_payout_cap ?? config.userHourlyPayoutCap), + target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent), + }; } function buildHeaders(body) { - const headers = {}; - const token = getAccessToken(); + const headers = {}; + const token = getAccessToken(); - if (body !== undefined) { - headers["Content-Type"] = "application/json"; - } - if (token) { - headers.Authorization = `Bearer ${token}`; - } + if (body !== undefined) { + headers["Content-Type"] = "application/json"; + } + if (token) { + headers.Authorization = `Bearer ${token}`; + } - return headers; + return headers; } async function readPayload(response) { - const text = await response.text(); - if (!text) { - return { code: response.ok ? 0 : response.status, data: {} }; - } - try { - return JSON.parse(text); - } catch { - return { code: response.ok ? 0 : response.status, data: text, message: text }; - } + const text = await response.text(); + if (!text) { + return { code: response.ok ? 0 : response.status, data: {} }; + } + try { + return JSON.parse(text); + } catch { + return { code: response.ok ? 0 : response.status, data: text, message: text }; + } } function normalizeItems(value) { - if (Array.isArray(value)) { - return value; - } - if (Array.isArray(value?.items)) { - return value.items; - } - if (Array.isArray(value?.list)) { - return value.list; - } - return []; + if (Array.isArray(value)) { + return value; + } + if (Array.isArray(value?.items)) { + return value.items; + } + if (Array.isArray(value?.list)) { + return value.list; + } + return []; } function sumBy(items, key, fallbackKey) { - return items.reduce((total, item) => total + numberValue(item?.[key] ?? (fallbackKey ? item?.[fallbackKey] : 0)), 0); + return items.reduce( + (total, item) => total + numberValue(item?.[key] ?? (fallbackKey ? item?.[fallbackKey] : 0)), + 0, + ); } function numberValue(value) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : 0; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function numberArray(values) { + if (!Array.isArray(values)) { + return []; + } + // 不在传输层静默删除 0/负倍率;表单负责拦截,若有遗漏也应让 owner 返回明确校验错误, + // 否则数组位置变化会掩盖运营实际提交的错误值。 + return values.map(numberValue); } function normalizeStages(stages) { - if (!Array.isArray(stages)) { - return []; - } - return stages.map((stage) => ({ - stage: stage.stage || stage.Stage || "", - tiers: Array.isArray(stage.tiers) - ? stage.tiers.map((tier) => ({ - enabled: tier.enabled !== false, - high_water_only: Boolean(tier.high_water_only ?? tier.highWaterOnly), - multiplier: numberValue(tier.multiplier), - probability_percent: numberValue(tier.probability_percent ?? tier.probabilityPercent), - tier_id: tier.tier_id || tier.tierId || "" - })) - : [] - })); + if (!Array.isArray(stages)) { + return []; + } + return stages.map((stage) => ({ + min_recharge_30d_coins: numberValue(stage.min_recharge_30d_coins ?? stage.minRecharge30DCoins), + min_recharge_7d_coins: numberValue(stage.min_recharge_7d_coins ?? stage.minRecharge7DCoins), + stage: stage.stage || stage.Stage || "", + tiers: Array.isArray(stage.tiers) + ? stage.tiers.map((tier) => ({ + enabled: tier.enabled !== false, + high_water_only: Boolean(tier.high_water_only ?? tier.highWaterOnly), + multiplier: numberValue(tier.multiplier), + probability_percent: numberValue(tier.probability_percent ?? tier.probabilityPercent), + tier_id: tier.tier_id || tier.tierId || "", + })) + : [], + })); } diff --git a/ops-center/src/api.test.js b/ops-center/src/api.test.js index aa52235..37b5a12 100644 --- a/ops-center/src/api.test.js +++ b/ops-center/src/api.test.js @@ -2,145 +2,251 @@ import { afterEach, expect, test, vi } from "vitest"; import { buildOpsUrl, fetchLuckyGiftDraws, fetchOpsDashboard, OPS_API_PREFIX, saveLuckyGiftConfig } from "./api.js"; afterEach(() => { - vi.restoreAllMocks(); + vi.restoreAllMocks(); }); function jsonResponse(data) { - return new Response(JSON.stringify({ code: 0, data }), { - headers: { "Content-Type": "application/json" }, - status: 200 - }); + return new Response(JSON.stringify({ code: 0, data }), { + headers: { "Content-Type": "application/json" }, + status: 200, + }); } test("builds ops api urls under the dedicated prefix", () => { - const url = buildOpsUrl("/apps", { app_code: "lalu", empty: "", status: "active" }); + const url = buildOpsUrl("/apps", { app_code: "lalu", empty: "", status: "active" }); - expect(OPS_API_PREFIX).toBe("/api/v1/admin/ops-center"); - expect(url).toBe("http://localhost:3000/api/v1/admin/ops-center/apps?app_code=lalu&status=active"); + expect(OPS_API_PREFIX).toBe("/api/v1/admin/ops-center"); + expect(url).toBe("http://localhost:3000/api/v1/admin/ops-center/apps?app_code=lalu&status=active"); }); test("loads every pool config per app through the plural configs endpoint", async () => { - const calls = []; - vi.spyOn(window, "fetch").mockImplementation(async (url) => { - const text = String(url); - calls.push(text); - if (text.endsWith("/apps")) { - return jsonResponse({ items: [{ app_code: "lalu", status: "active" }] }); - } - if (text.includes("/lucky-gifts/configs?")) { - // lalu 在 default 之外还有 lucky/super_lucky 两个已发布奖池,复数接口必须全部带回。 - return jsonResponse([ - { app_code: "lalu", enabled: true, pool_id: "lucky", rule_version: 3, target_rtp_percent: 95 }, - { app_code: "lalu", enabled: false, pool_id: "super_lucky", rule_version: 2, target_rtp_percent: 92 } - ]); - } - if (text.includes("/lucky-gifts/pools?")) { - return jsonResponse({ - items: [ - { app_code: "lalu", available_balance: 37950017, balance: 38559017, materialized: true, pool_id: "lucky", reserve_floor: 609000 }, - { app_code: "lalu", available_balance: 361500, balance: 382500, materialized: false, pool_id: "default", reserve_floor: 21000 } - ] - }); - } - if (text.includes("/lucky-gifts/summary?")) { - return jsonResponse({ granted_draws: 9, pending_draws: 1, total_draws: 10, total_reward_coins: 900, total_spent_coins: 1000 }); - } - return jsonResponse({ items: [] }); - }); + const calls = []; + vi.spyOn(window, "fetch").mockImplementation(async (url) => { + const text = String(url); + calls.push(text); + if (text.endsWith("/apps")) { + return jsonResponse({ items: [{ app_code: "lalu", status: "active" }] }); + } + if (text.includes("/lucky-gifts/configs?")) { + // lalu 在 default 之外还有 lucky/super_lucky 两个已发布奖池,复数接口必须全部带回。 + return jsonResponse([ + { app_code: "lalu", enabled: true, pool_id: "lucky", rule_version: 3, target_rtp_percent: 95 }, + { app_code: "lalu", enabled: false, pool_id: "super_lucky", rule_version: 2, target_rtp_percent: 92 }, + ]); + } + if (text.includes("/lucky-gifts/pools?")) { + return jsonResponse({ + items: [ + { + app_code: "lalu", + available_balance: 37950017, + balance: 38559017, + materialized: true, + pool_id: "lucky", + reserve_floor: 609000, + }, + { + app_code: "lalu", + available_balance: 361500, + balance: 382500, + materialized: false, + pool_id: "default", + reserve_floor: 21000, + }, + ], + }); + } + if (text.includes("/lucky-gifts/summary?")) { + return jsonResponse({ + granted_draws: 9, + pending_draws: 1, + total_draws: 10, + total_reward_coins: 900, + total_spent_coins: 1000, + }); + } + return jsonResponse({ items: [] }); + }); - const data = await fetchOpsDashboard(); + const data = await fetchOpsDashboard(); - expect(calls).toEqual([ - "http://localhost:3000/api/v1/admin/ops-center/apps", - "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" - ]); - expect(data.luckyGifts).toEqual([ - expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "lucky", rule_version: 3 }), - expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "super_lucky", rule_version: 2 }) - ]); - expect(data.summary).toMatchObject({ - activeApps: 1, - configuredPools: 2, - enabledPools: 1, - poolBalanceTotal: 38941517, - totalDraws: 10 - }); + expect(calls).toEqual([ + "http://localhost:3000/api/v1/admin/ops-center/apps", + "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", + ]); + expect(data.luckyGifts).toEqual([ + expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "lucky", rule_version: 3 }), + expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "super_lucky", rule_version: 2 }), + ]); + expect(data.summary).toMatchObject({ + activeApps: 1, + configuredPools: 2, + enabledPools: 1, + poolBalanceTotal: 38941517, + totalDraws: 10, + }); }); test("falls back to the server default draft when an app has no published config", async () => { - const calls = []; - vi.spyOn(window, "fetch").mockImplementation(async (url) => { - const text = String(url); - calls.push(text); - if (text.endsWith("/apps")) { - return jsonResponse({ items: [{ app_code: "yumi", status: "active" }] }); - } - if (text.includes("/lucky-gifts/configs?")) { - return jsonResponse([]); - } - if (text.includes("/lucky-gifts/config?")) { - return jsonResponse({ app_code: "yumi", enabled: false, pool_id: "default", rule_version: 0, target_rtp_percent: 95 }); - } - return jsonResponse({ items: [] }); - }); + const calls = []; + vi.spyOn(window, "fetch").mockImplementation(async (url) => { + const text = String(url); + calls.push(text); + if (text.endsWith("/apps")) { + return jsonResponse({ items: [{ app_code: "yumi", status: "active" }] }); + } + if (text.includes("/lucky-gifts/configs?")) { + return jsonResponse([]); + } + if (text.includes("/lucky-gifts/config?")) { + return jsonResponse({ + app_code: "yumi", + enabled: false, + pool_id: "default", + rule_version: 0, + target_rtp_percent: 95, + }); + } + return jsonResponse({ items: [] }); + }); - const data = await fetchOpsDashboard(); + const data = await fetchOpsDashboard(); - expect(calls).toContain("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=yumi"); - expect(data.luckyGifts).toEqual([ - expect.objectContaining({ app_code: "yumi", is_default: true, pool_id: "default", rule_version: 0 }) - ]); + expect(calls).toContain("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=yumi"); + expect(data.luckyGifts).toEqual([ + expect.objectContaining({ app_code: "yumi", is_default: true, pool_id: "default", rule_version: 0 }), + ]); }); test("fetches draws with numeric ids as user_id and other ids as external_user_id", async () => { - const calls = []; - vi.spyOn(window, "fetch").mockImplementation(async (url) => { - calls.push(String(url)); - return jsonResponse({ items: [{ draw_id: "draw-1" }], page: 1, pageSize: 20, total: 1 }); - }); + const calls = []; + vi.spyOn(window, "fetch").mockImplementation(async (url) => { + calls.push(String(url)); + return jsonResponse({ items: [{ draw_id: "draw-1" }], page: 1, pageSize: 20, total: 1 }); + }); - const numericResult = await fetchLuckyGiftDraws({ appCode: "lalu", poolId: "lucky", userId: "10001" }); - await fetchLuckyGiftDraws({ appCode: "lalu", userId: "ext-abc" }); + const numericResult = await fetchLuckyGiftDraws({ appCode: "lalu", poolId: "lucky", userId: "10001" }); + await fetchLuckyGiftDraws({ appCode: "lalu", userId: "ext-abc" }); - const numericParams = new URL(calls[0]).searchParams; - expect(numericParams.get("app_code")).toBe("lalu"); - expect(numericParams.get("pool_id")).toBe("lucky"); - expect(numericParams.get("user_id")).toBe("10001"); - expect(numericParams.get("external_user_id")).toBeNull(); - const externalParams = new URL(calls[1]).searchParams; - expect(externalParams.get("external_user_id")).toBe("ext-abc"); - expect(externalParams.get("user_id")).toBeNull(); - expect(numericResult).toMatchObject({ items: [{ draw_id: "draw-1" }], page: 1, total: 1 }); + const numericParams = new URL(calls[0]).searchParams; + expect(numericParams.get("app_code")).toBe("lalu"); + expect(numericParams.get("pool_id")).toBe("lucky"); + expect(numericParams.get("user_id")).toBe("10001"); + expect(numericParams.get("external_user_id")).toBeNull(); + const externalParams = new URL(calls[1]).searchParams; + expect(externalParams.get("external_user_id")).toBe("ext-abc"); + expect(externalParams.get("user_id")).toBeNull(); + expect(numericResult).toMatchObject({ items: [{ draw_id: "draw-1" }], page: 1, total: 1 }); }); test("saves lucky gift config through admin ops route", async () => { - const calls = []; - vi.spyOn(window, "fetch").mockImplementation(async (url, init) => { - calls.push({ body: init.body, method: init.method, url: String(url) }); - return jsonResponse({ app_code: "aslan", pool_id: "default" }); - }); + const calls = []; + vi.spyOn(window, "fetch").mockImplementation(async (url, init) => { + calls.push({ body: init.body, method: init.method, url: String(url) }); + return jsonResponse({ app_code: "aslan", pool_id: "default" }); + }); - await saveLuckyGiftConfig({ - app_code: "aslan", - pool_id: "default", - enabled: true, - target_rtp_percent: 95, - pool_rate_percent: 96, - settlement_window_wager: 1_000_000, - control_band_percent: 1, - gift_price_reference: 100, - stages: [{ stage: "novice", tiers: [{ multiplier: 1, probabilityPercent: 100, enabled: true }] }] - }); + await saveLuckyGiftConfig({ + anchor_daily_payout_cap: 12_312_000, + anchor_rate_percent: 1, + app_code: "aslan", + control_band_percent: 3, + device_daily_payout_cap: 1_026_000, + enabled: true, + gift_price_reference: 100, + high_water_nonzero_factor_percent: 130, + high_watermark_coins: 20_000_000, + initial_pool_coins: 1_000_000, + jackpot_global_rtp_max_percent: 98, + jackpot_multipliers: [200, 500, 1000], + jackpot_spend_threshold_coins: 50_000, + jackpot_user_72h_rtp_max_percent: 96, + jackpot_user_day_rtp_max_percent: 96, + loss_streak_guarantee: 5, + low_water_nonzero_factor_percent: 70, + low_watermark_coins: 10_000_000, + max_jackpot_hits_per_user_day: 5, + max_single_payout: 50_000, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_id: "default", + pool_rate_percent: 98, + profit_rate_percent: 1, + recharge_boost_factor_percent: 110, + recharge_boost_window_ms: 300_000, + room_hourly_payout_cap: 684_000, + settlement_window_wager: 1_000_000, + stages: [ + { + min_recharge_30d_coins: 0, + min_recharge_7d_coins: 0, + stage: "novice", + tiers: [{ multiplier: 1, probabilityPercent: 100, enabled: true }], + }, + ], + strategy_version: "dynamic_v3", + target_rtp_percent: 98, + user_daily_payout_cap: 615_600, + user_hourly_payout_cap: 34_200, + }); - expect(calls[0].method).toBe("PUT"); - expect(calls[0].url).toBe("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=aslan&pool_id=default"); - expect(JSON.parse(calls[0].body)).toMatchObject({ - app_code: "aslan", - enabled: true, - pool_id: "default", - stages: [{ stage: "novice", tiers: [{ probability_percent: 100 }] }], - target_rtp_percent: 95 - }); + expect(calls[0].method).toBe("PUT"); + expect(calls[0].url).toBe( + "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=aslan&pool_id=default", + ); + // 精确比较完整 owner payload;子集断言会让新增/遗漏的策略字段在回归中静默通过。 + expect(JSON.parse(calls[0].body)).toEqual({ + anchor_daily_payout_cap: 12_312_000, + anchor_rate_percent: 1, + app_code: "aslan", + control_band_percent: 3, + device_daily_payout_cap: 1_026_000, + effective_from_ms: 0, + enabled: true, + gift_price_reference: 100, + high_water_nonzero_factor_percent: 130, + high_watermark_coins: 20_000_000, + initial_pool_coins: 1_000_000, + jackpot_global_rtp_max_percent: 98, + jackpot_multipliers: [200, 500, 1000], + jackpot_spend_threshold_coins: 50_000, + jackpot_user_72h_rtp_max_percent: 96, + jackpot_user_day_rtp_max_percent: 96, + loss_streak_guarantee: 5, + low_water_nonzero_factor_percent: 70, + low_watermark_coins: 10_000_000, + max_jackpot_hits_per_user_day: 5, + max_single_payout: 50_000, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_id: "default", + pool_rate_percent: 98, + profit_rate_percent: 1, + recharge_boost_factor_percent: 110, + recharge_boost_window_ms: 300_000, + room_hourly_payout_cap: 684_000, + settlement_window_wager: 1_000_000, + stages: [ + { + min_recharge_30d_coins: 0, + min_recharge_7d_coins: 0, + stage: "novice", + tiers: [ + { + enabled: true, + high_water_only: false, + multiplier: 1, + probability_percent: 100, + tier_id: "", + }, + ], + }, + ], + strategy_version: "dynamic_v3", + target_rtp_percent: 98, + user_daily_payout_cap: 615_600, + user_hourly_payout_cap: 34_200, + }); }); diff --git a/ops-center/src/components/ConfigsView.jsx b/ops-center/src/components/ConfigsView.jsx index 62f8a2e..dff4af3 100644 --- a/ops-center/src/components/ConfigsView.jsx +++ b/ops-center/src/components/ConfigsView.jsx @@ -8,83 +8,121 @@ import { formatNumber, formatPercent } from "../format.js"; import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) { - const [appFilter, setAppFilter] = useState(""); + const [appFilter, setAppFilter] = useState(""); - const appOptions = useMemo( - () => apps.map((item) => [item.app_code ?? item.appCode, `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`]), - [apps] - ); - // 配置列表已在 dashboard 阶段跨应用拉全,应用筛选只做前端过滤,避免重复扇出请求。 - const items = useMemo( - () => (appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts), - [appFilter, luckyGifts] - ); + const appOptions = useMemo( + () => + apps.map((item) => [ + item.app_code ?? item.appCode, + `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`, + ]), + [apps], + ); + // 配置列表已在 dashboard 阶段跨应用拉全,应用筛选只做前端过滤,避免重复扇出请求。 + const items = useMemo( + () => (appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts), + [appFilter, luckyGifts], + ); - const columns = useMemo(() => buildColumns({ onEdit, saving }), [onEdit, saving]); + const columns = useMemo(() => buildColumns({ onEdit, saving }), [onEdit, saving]); - return ( -
- } variant="primary" onClick={onCreate}> - 添加配置 - - } - filters={} - /> - `${item.app_code}:${item.pool_id}:${item.rule_version}`} - title="幸运礼物配置" - total={items.length} - /> -
- ); + return ( +
+ } + variant="primary" + onClick={onCreate} + > + 添加配置 + + } + filters={ + + } + /> + `${item.app_code}:${item.pool_id}:${item.rule_version}`} + title="幸运礼物配置" + total={items.length} + /> +
+ ); } function buildColumns({ onEdit, saving }) { - return [ - { key: "app_code", label: "应用", width: "minmax(88px, 0.7fr)" }, - { key: "pool_id", label: "奖池", width: "minmax(110px, 0.9fr)" }, - { - key: "rule_version", - label: "版本", - render: (item) => (item.is_default ? "-" : `v${item.rule_version}`), - width: "minmax(72px, 0.5fr)" - }, - { - key: "enabled", - label: "状态", - render: (item) => { - // 默认草稿是服务端为未配置奖池生成的占位,不参与线上抽奖,必须与"停用"区分开。 - if (item.is_default) { - return ; - } - return item.enabled ? : ; - }, - width: "minmax(128px, 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) }, - { key: "control_band_percent", label: "控制带宽", render: (item) => formatPercent(item.control_band_percent) }, - { key: "settlement_window_wager", label: "结算窗口流水", render: (item) => formatNumber(item.settlement_window_wager) }, - { - key: "created_at_ms", - label: "发布时间", - render: (item) => (item.is_default ? "-" : ), - width: "minmax(176px, 1.1fr)" - }, - { - key: "actions", - label: "操作", - render: (item) => ( - - ) - } - ]; + return [ + { key: "app_code", label: "应用", width: "minmax(88px, 0.7fr)" }, + { key: "pool_id", label: "奖池", width: "minmax(110px, 0.9fr)" }, + { + key: "rule_version", + label: "版本", + render: (item) => (item.is_default ? "-" : `v${item.rule_version}`), + width: "minmax(72px, 0.5fr)", + }, + { + key: "strategy_version", + label: "策略", + // 升级前的不可变版本可能没有该字段,owner 会按 fixed_v2 解释;列表保持同一兼容口径, + // 不能把空值展示成 dynamic_v3,避免运营误判已完成策略切换。 + render: (item) => item.strategy_version || "fixed_v2", + width: "minmax(112px, 0.8fr)", + }, + { + key: "enabled", + label: "状态", + render: (item) => { + // 默认草稿是服务端为未配置奖池生成的占位,不参与线上抽奖,必须与"停用"区分开。 + if (item.is_default) { + return ; + } + return item.enabled ? ( + + ) : ( + + ); + }, + width: "minmax(128px, 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) }, + { + key: "profit_anchor_rate", + label: "平台 / 主播", + render: (item) => `${formatPercent(item.profit_rate_percent)} / ${formatPercent(item.anchor_rate_percent)}`, + width: "minmax(144px, 1fr)", + }, + { key: "control_band_percent", label: "控制带宽", render: (item) => formatPercent(item.control_band_percent) }, + { + key: "settlement_window_wager", + label: "结算窗口流水", + render: (item) => formatNumber(item.settlement_window_wager), + }, + { + key: "created_at_ms", + label: "发布时间", + render: (item) => (item.is_default ? "-" : ), + width: "minmax(176px, 1.1fr)", + }, + { + key: "actions", + label: "操作", + render: (item) => ( + + ), + }, + ]; } diff --git a/ops-center/src/components/LuckyGiftConfigDialog.jsx b/ops-center/src/components/LuckyGiftConfigDialog.jsx index f93438d..54bc3b1 100644 --- a/ops-center/src/components/LuckyGiftConfigDialog.jsx +++ b/ops-center/src/components/LuckyGiftConfigDialog.jsx @@ -12,283 +12,555 @@ import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import { Button } from "@/shared/ui/Button.jsx"; import { formatDecimal } from "@/shared/utils/rtpProbability.js"; import { - configToForm, - correctAllStageProbabilities, - formToConfig, - nextStageMultiplier, - stageSummary + applyDynamicStrategyDefaults, + configToForm, + correctAllStageProbabilities, + formToConfig, + nextStageMultiplier, + stageSummary, + validateLuckyGiftForm, } from "../configForm.js"; import { formatNumber, formatPercent } from "../format.js"; import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) { - const [form, setForm] = useState(() => configToForm(config)); - const [activeStage, setActiveStage] = useState("novice"); + const [form, setForm] = useState(() => configToForm(config)); + const [activeStage, setActiveStage] = useState("novice"); + const [validationErrors, setValidationErrors] = useState([]); - const stageSummaries = useMemo(() => form.stages.map((stage) => stageSummary(stage, form)), [form]); - const activeStageKey = form.stages.some((stage) => stage.stage === activeStage) ? activeStage : form.stages[0]?.stage; - const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey); - const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey); + const stageSummaries = useMemo(() => form.stages.map((stage) => stageSummary(stage, form)), [form]); + const activeStageKey = form.stages.some((stage) => stage.stage === activeStage) + ? activeStage + : form.stages[0]?.stage; + const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey); + const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey); - const updateField = (field, value) => { - setForm((current) => { - const next = { ...current, [field]: value }; - // 目标 RTP 决定各奖档概率的闭合解,改动后全部阶段重算,避免展示的概率和将要发布的值不一致。 - return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next; - }); - }; + const updateField = (field, value) => { + setValidationErrors([]); + setForm((current) => { + const next = { ...current, [field]: value }; + if (field === "strategy_version" && value === "dynamic_v3" && current.strategy_version !== "dynamic_v3") { + return applyDynamicStrategyDefaults(next); + } + // 目标 RTP 决定各奖档概率的闭合解,改动后全部阶段重算,避免展示的概率和将要发布的值不一致。 + return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next; + }); + }; - const updateStageTier = (stageKey, tierIndex, patch) => { - setForm((current) => - correctAllStageProbabilities({ - ...current, - stages: current.stages.map((stage) => - stage.stage === stageKey - ? { - ...stage, - tiers: stage.tiers.map((tier, index) => (index === tierIndex ? { ...tier, ...patch } : tier)) - } - : stage - ) - }) - ); - }; + const updateStageField = (stageKey, field, value) => { + setValidationErrors([]); + setForm((current) => ({ + ...current, + stages: current.stages.map((stage) => (stage.stage === stageKey ? { ...stage, [field]: value } : stage)), + })); + }; - const addStageTier = (stageKey) => { - setForm((current) => - correctAllStageProbabilities({ - ...current, - stages: current.stages.map((stage) => - stage.stage === stageKey - ? { - ...stage, - tiers: [ - ...stage.tiers, - { - enabled: true, - // 10 倍以上属于高倍档,默认只在奖池高水位时开放,防止新档位直接击穿保底线。 - highWaterOnly: nextStageMultiplier(stage.tiers) >= 10, - multiplier: String(nextStageMultiplier(stage.tiers)), - probabilityPercent: "0", - tierId: "" - } - ] - } - : stage - ) - }) - ); - }; - - const removeStageTier = (stageKey, tierIndex) => { - setForm((current) => - correctAllStageProbabilities({ - ...current, - stages: current.stages.map((stage) => - stage.stage === stageKey ? { ...stage, tiers: stage.tiers.filter((_, index) => index !== tierIndex) } : stage - ) - }) - ); - }; - - const handleSubmit = (event) => { - event.preventDefault(); - onSubmit(formToConfig(config, form)); - }; - - const isCreate = mode === "create"; - const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本"; - - return ( - -
- - {title} - - {form.app_code || "-"} / {form.pool_id || "default"} /{" "} - {config.is_default || isCreate ? "默认草稿" : `当前版本 v${config.rule_version ?? "-"}`} - - - -
- - - - - - -
- -
- - -
-
- {stageSummaries.map((summary) => ( - - ))} -
- {activeStageConfig ? ( -
-
-
-

{activeSummary?.stageLabel}

- -
- -
-
- - {activeStageConfig.tiers.map((tier, index) => ( -
- - updateStageTier(activeStageConfig.stage, index, { - // 10 倍及以上强制只走高水位,输入时同步勾选并锁死,防止高倍档在低水位放开。 - highWaterOnly: Number(event.target.value) >= 10 || tier.highWaterOnly, - multiplier: event.target.value - }) + const updateStageTier = (stageKey, tierIndex, patch) => { + setValidationErrors([]); + setForm((current) => + correctAllStageProbabilities({ + ...current, + stages: current.stages.map((stage) => + stage.stage === stageKey + ? { + ...stage, + tiers: stage.tiers.map((tier, index) => + index === tierIndex ? { ...tier, ...patch } : tier, + ), } + : stage, + ), + }), + ); + }; + + const addStageTier = (stageKey) => { + setValidationErrors([]); + setForm((current) => + correctAllStageProbabilities({ + ...current, + stages: current.stages.map((stage) => + stage.stage === stageKey + ? { + ...stage, + tiers: [ + ...stage.tiers, + { + enabled: true, + // 10 倍以上属于高倍档,默认只在奖池高水位时开放,防止新档位直接击穿保底线。 + highWaterOnly: nextStageMultiplier(stage.tiers) >= 10, + multiplier: String(nextStageMultiplier(stage.tiers)), + probabilityPercent: "0", + tierId: "", + }, + ], + } + : stage, + ), + }), + ); + }; + + const removeStageTier = (stageKey, tierIndex) => { + setValidationErrors([]); + setForm((current) => + correctAllStageProbabilities({ + ...current, + stages: current.stages.map((stage) => + stage.stage === stageKey + ? { ...stage, tiers: stage.tiers.filter((_, index) => index !== tierIndex) } + : stage, + ), + }), + ); + }; + + const handleSubmit = (event) => { + event.preventDefault(); + const errors = validateLuckyGiftForm(form); + if (errors.length) { + setValidationErrors(errors); + return; + } + onSubmit(formToConfig(config, form)); + }; + + const isCreate = mode === "create"; + const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本"; + + return ( + + + + {title} + + {form.app_code || "-"} / {form.pool_id || "default"} /{" "} + {config.is_default || isCreate ? "默认草稿" : `当前版本 v${config.rule_version ?? "-"}`} + + + +
+ + + + + - {/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */} - - = 10} - size="small" - slotProps={{ input: { "aria-label": "高水位" } }} - onChange={(event) => updateStageTier(activeStageConfig.stage, index, { highWaterOnly: event.target.checked })} - /> - updateStageTier(activeStageConfig.stage, index, { enabled: event.target.checked })} - /> - -
- ))} -
+ + +
+ + {validationErrors.length ? ( +
+ 配置不能发布 +
    + {validationErrors.map((message) => ( +
  • {message}
  • + ))} +
+
+ ) : null} + +
+ + +
+
+ {stageSummaries.map((summary) => ( + + ))} +
+ {activeStageConfig ? ( +
+
+
+

{activeSummary?.stageLabel}

+ +
+ +
+ {form.strategy_version === "dynamic_v3" ? ( +
+ + updateStageField( + activeStageConfig.stage, + "min_recharge_7d_coins", + event.target.value, + ) + } + /> + + updateStageField( + activeStageConfig.stage, + "min_recharge_30d_coins", + event.target.value, + ) + } + /> +
+ ) : null} +
+ + {activeStageConfig.tiers.map((tier, index) => ( +
+ + updateStageTier(activeStageConfig.stage, index, { + // 10 倍及以上强制只走高水位,输入时同步勾选并锁死,防止高倍档在低水位放开。 + highWaterOnly: + Number(event.target.value) >= 10 || tier.highWaterOnly, + multiplier: event.target.value, + }) + } + /> + {/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */} + + = 10} + size="small" + slotProps={{ input: { "aria-label": "高水位" } }} + onChange={(event) => + updateStageTier(activeStageConfig.stage, index, { + highWaterOnly: event.target.checked, + }) + } + /> + + updateStageTier(activeStageConfig.stage, index, { + enabled: event.target.checked, + }) + } + /> + +
+ ))} +
+
+ ) : null} +
+
+ + + + + + +
+ ); +} + +function DynamicStrategySections({ form, onChange }) { + return ( + <> +
+

动态水位

+
+ + + + + + + +
- ) : null}
- - - - - - - - - ); + +
+

大奖控制

+
+ onChange("jackpot_multipliers", event.target.value)} + /> + + + + + +
+
+ +
+

六维返奖上限

+
+ + + + + + +
+
+ + ); } function ConfigMetric({ label, value }) { - return ( -
- {label} - {value} -
- ); + return ( +
+ {label} + {value} +
+ ); } -function NumberField({ form, label, name, onChange, step = "1" }) { - return ( - onChange(name, event.target.value)} - /> - ); +function NumberField({ form, helperText, label, name, onChange, step = "1" }) { + return ( + onChange(name, event.target.value)} + /> + ); } diff --git a/ops-center/src/configForm.js b/ops-center/src/configForm.js index 8d907df..f814777 100644 --- a/ops-center/src/configForm.js +++ b/ops-center/src/configForm.js @@ -1,158 +1,458 @@ import { stageOptions } from "@/features/lucky-gift/constants.js"; import { - applyProbabilityMap, - correctRTPProbabilities, - decimal, - expectedRTPPercent, - formatDecimal, - probabilityTotal + applyProbabilityMap, + correctRTPProbabilities, + decimal, + expectedRTPPercent, + formatDecimal, + probabilityTotal, } from "@/shared/utils/rtpProbability.js"; import { DEFAULT_POOL_ID } from "./api.js"; -// 幸运礼物配置表单的纯领域逻辑:配置 <-> 表单换算、概率闭合校正、阶段摘要。 -// 与展示层分离,弹窗组件只负责渲染和事件绑定。 +const DYNAMIC_STRATEGY = "dynamic_v3"; +const PPM_SCALE = 1_000_000; + +// 只有跨 App 通用的算法参数才有默认值;50 美元等值门槛和六维金额上限必须由运营按 App 金币口径填写, +// 因而默认保持 0 且规则停用,避免把某个 App 的预算误发到其他 App。 +const dynamicDefaults = { + anchor_daily_payout_cap: 0, + anchor_rate_percent: 1, + control_band_percent: 3, + device_daily_payout_cap: 0, + high_water_nonzero_factor_percent: 130, + high_watermark_coins: 20_000_000, + initial_pool_coins: 1_000_000, + jackpot_global_rtp_max_percent: 98, + jackpot_multipliers: [200, 500, 1000], + jackpot_spend_threshold_coins: 0, + jackpot_user_72h_rtp_max_percent: 96, + jackpot_user_day_rtp_max_percent: 96, + loss_streak_guarantee: 5, + low_water_nonzero_factor_percent: 70, + low_watermark_coins: 10_000_000, + max_jackpot_hits_per_user_day: 5, + max_single_payout: 0, + pool_rate_percent: 98, + profit_rate_percent: 1, + recharge_boost_factor_percent: 110, + recharge_boost_window_ms: 300_000, + room_hourly_payout_cap: 0, + target_rtp_percent: 98, + user_daily_payout_cap: 0, + user_hourly_payout_cap: 0, +}; + +const numericFields = [ + "anchor_daily_payout_cap", + "anchor_rate_percent", + "control_band_percent", + "device_daily_payout_cap", + "effective_from_ms", + "gift_price_reference", + "high_water_nonzero_factor_percent", + "high_watermark_coins", + "initial_pool_coins", + "jackpot_global_rtp_max_percent", + "jackpot_spend_threshold_coins", + "jackpot_user_72h_rtp_max_percent", + "jackpot_user_day_rtp_max_percent", + "loss_streak_guarantee", + "low_water_nonzero_factor_percent", + "low_watermark_coins", + "max_jackpot_hits_per_user_day", + "max_single_payout", + "normal_max_equivalent_draws", + "novice_max_equivalent_draws", + "pool_rate_percent", + "profit_rate_percent", + "recharge_boost_factor_percent", + "recharge_boost_window_ms", + "room_hourly_payout_cap", + "settlement_window_wager", + "target_rtp_percent", + "user_daily_payout_cap", + "user_hourly_payout_cap", +]; + +const payoutCapFields = [ + ["max_single_payout", "单次返奖上限"], + ["user_hourly_payout_cap", "用户小时上限"], + ["user_daily_payout_cap", "用户每日上限"], + ["device_daily_payout_cap", "设备每日上限"], + ["room_hourly_payout_cap", "房间小时上限"], + ["anchor_daily_payout_cap", "主播每日上限"], +]; export function configToForm(config = {}) { - const targetRTPPercent = formNumber(config.target_rtp_percent ?? config.targetRtpPercent); - return { - app_code: config.app_code || config.appCode || "", - control_band_percent: formNumber(config.control_band_percent ?? config.controlBandPercent), - enabled: Boolean(config.enabled), - gift_price_reference: formNumber(config.gift_price_reference ?? config.giftPriceReference), - pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID, - pool_rate_percent: formNumber(config.pool_rate_percent ?? config.poolRatePercent), - settlement_window_wager: formNumber(config.settlement_window_wager ?? config.settlementWindowWager), - stages: normalizeFormStages(config.stages, targetRTPPercent), - target_rtp_percent: targetRTPPercent - }; + const hasPersistedShape = Object.keys(config).length > 0; + const strategyVersion = stringConfigValue( + config, + "strategy_version", + "strategyVersion", + hasPersistedShape ? "fixed_v2" : DYNAMIC_STRATEGY, + ); + const targetRTPPercent = formConfigNumber( + config, + "target_rtp_percent", + "targetRtpPercent", + strategyVersion === DYNAMIC_STRATEGY ? dynamicDefaults.target_rtp_percent : 95, + ); + const form = { + app_code: config.app_code || config.appCode || "", + enabled: Boolean(config.enabled), + jackpot_multipliers: multiplierListToForm( + config.jackpot_multipliers ?? config.jackpotMultipliers, + strategyVersion, + ), + pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID, + stages: normalizeFormStages(config.stages, targetRTPPercent, strategyVersion), + strategy_version: strategyVersion, + }; + for (const field of numericFields) { + if (field === "target_rtp_percent") { + form[field] = targetRTPPercent; + continue; + } + form[field] = formConfigNumber(config, field, snakeToCamel(field), defaultNumber(field, strategyVersion)); + } + return form; } export function formToConfig(config, form) { - return { - ...config, - app_code: form.app_code, - control_band_percent: numberFromForm(form.control_band_percent), - enabled: Boolean(form.enabled), - gift_price_reference: numberFromForm(form.gift_price_reference), - pool_id: form.pool_id, - pool_rate_percent: numberFromForm(form.pool_rate_percent), - settlement_window_wager: numberFromForm(form.settlement_window_wager), - stages: form.stages, - target_rtp_percent: numberFromForm(form.target_rtp_percent) - }; + const output = { + ...config, + app_code: form.app_code, + enabled: Boolean(form.enabled), + jackpot_multipliers: multiplierListFromForm(form.jackpot_multipliers), + pool_id: form.pool_id, + stages: (form.stages || []).map((stage) => ({ + min_recharge_30d_coins: numberFromForm(stage.min_recharge_30d_coins), + min_recharge_7d_coins: numberFromForm(stage.min_recharge_7d_coins), + stage: stage.stage, + tiers: (stage.tiers || []).map((tier) => ({ + enabled: tier.enabled !== false, + high_water_only: Boolean(tier.highWaterOnly), + multiplier: numberFromForm(tier.multiplier), + probability_percent: numberFromForm(tier.probabilityPercent), + tier_id: tier.tierId || "", + })), + })), + strategy_version: form.strategy_version, + }; + for (const field of numericFields) { + output[field] = numberFromForm(form[field]); + } + return output; +} + +// 从历史 fixed_v2 显式切换到 dynamic_v3 时,先停用并载入安全草稿;金额型上限仍保持 0, +// 运营补齐后才能通过启用校验,避免一次策略切换直接发布无法结算的规则。 +export function applyDynamicStrategyDefaults(form) { + const next = { + ...form, + enabled: false, + jackpot_multipliers: dynamicDefaults.jackpot_multipliers.join(", "), + strategy_version: DYNAMIC_STRATEGY, + }; + for (const [field, value] of Object.entries(dynamicDefaults)) { + if (field !== "jackpot_multipliers") { + next[field] = String(value); + } + } + next.stages = stageOptions.map(([stage]) => { + const [min7d, min30d] = defaultRechargeThreshold(stage); + return { + min_recharge_7d_coins: String(min7d), + min_recharge_30d_coins: String(min30d), + stage, + // fixed_v2 可能含历史高倍基础档;切换策略时必须回到 owner 的 98% 安全基线, + // 不能只改策略名后把旧概率意外带入 dynamic_v3 的第一版。 + tiers: correctedDefaultTiers(stage, dynamicDefaults.target_rtp_percent), + }; + }); + return next; } // 概率列必须整体闭合到 100% 且期望 RTP 逼近目标值,所以任何倍率/目标 RTP 改动后都重算全阶段概率, // 不允许运营手工填概率绕过服务端的闭合校验。 export function correctAllStageProbabilities(form) { - return { - ...form, - stages: (form.stages || []).map((stage) => { - const probabilityByIndex = correctRTPProbabilities(stage.tiers, form.target_rtp_percent, { - multiplier: (item) => item.multiplier - }); - return { - ...stage, - tiers: probabilityByIndex ? applyProbabilityMap(stage.tiers, probabilityByIndex, formatDecimal) : stage.tiers - }; - }) - }; + return { + ...form, + stages: (form.stages || []).map((stage) => { + const probabilityByIndex = correctRTPProbabilities(stage.tiers, form.target_rtp_percent, { + multiplier: (item) => item.multiplier, + }); + return { + ...stage, + tiers: probabilityByIndex + ? applyProbabilityMap(stage.tiers, probabilityByIndex, formatDecimal) + : stage.tiers, + }; + }), + }; } export function stageSummary(stage, form) { - const probability = probabilityTotal(stage.tiers); - const expected = expectedRTPPercent(stage.tiers, { - multiplier: (item) => item.multiplier - }); - const target = Number(form.target_rtp_percent || 0); - const band = Number(form.control_band_percent || 0); - const probabilityClosed = Math.abs(probability - 100) < 0.0001; - const rtpInBand = expected >= target - band && expected <= target + band; - const fullLabel = stageOptions.find(([key]) => key === stage.stage)?.[1] || stage.stage; + const probability = probabilityTotal(stage.tiers); + const expected = expectedRTPPercent(stage.tiers, { multiplier: (item) => item.multiplier }); + const target = Number(form.target_rtp_percent || 0); + const band = Number(form.control_band_percent || 0); + const probabilityClosed = Math.abs(probability - 100) < 0.0001; + const rtpInBand = expected >= target - band && expected <= target + band; + const fullLabel = stageOptions.find(([key]) => key === stage.stage)?.[1] || stage.stage; + return { + expected, + ok: probabilityClosed && rtpInBand, + probability, + probabilityClosed, + rtpInBand, + stageKey: stage.stage, + stageLabel: fullLabel, + status: probabilityClosed && rtpInBand ? "可发布" : probabilityClosed ? "RTP 偏离" : "概率未闭合", + tabLabel: stage.stage === "normal" ? "普通" : fullLabel.replace(/阶段$/, ""), + }; +} - return { - expected, - ok: probabilityClosed && rtpInBand, - probability, - probabilityClosed, - rtpInBand, - stageKey: stage.stage, - stageLabel: fullLabel, - // 发布按钮不做硬拦截(服务端仍会校验),这里的状态只用于提示运营当前阶段是否可安全发布。 - status: probabilityClosed && rtpInBand ? "可发布" : probabilityClosed ? "RTP 偏离" : "概率未闭合", - tabLabel: stage.stage === "normal" ? "普通" : fullLabel.replace(/阶段$/, "") - }; +export function validateLuckyGiftForm(form) { + const errors = []; + const number = (field) => numberFromForm(form[field]); + if (!String(form.app_code || "").trim()) errors.push("请选择应用"); + if (!String(form.pool_id || "").trim()) errors.push("奖池不能为空"); + if (!["fixed_v2", DYNAMIC_STRATEGY].includes(form.strategy_version)) errors.push("策略版本不受支持"); + if (number("target_rtp_percent") < 10 || number("target_rtp_percent") > 99) + errors.push("目标 RTP 必须在 10%-99% 之间"); + if (number("pool_rate_percent") < number("target_rtp_percent") || number("pool_rate_percent") > 100) + errors.push("奖池比例必须在目标 RTP 与 100% 之间"); + if (number("control_band_percent") <= 0 || number("control_band_percent") > 5) + errors.push("控制带宽必须大于 0 且不超过 5%"); + if (number("settlement_window_wager") <= 0) errors.push("结算窗口流水必须大于 0"); + if (number("gift_price_reference") <= 0) errors.push("礼物参考金额必须大于 0"); + if ( + number("novice_max_equivalent_draws") < 0 || + number("normal_max_equivalent_draws") < number("novice_max_equivalent_draws") + ) + errors.push("等价抽数阶段门槛不正确"); + + // 阶段概率与 RTP 是两代策略共用的 owner 校验,fixed_v2 也不能跳过。 + for (const stageKey of ["novice", "normal", "advanced"]) { + const stage = form.stages?.find((item) => item.stage === stageKey); + if (!stage) { + errors.push("必须配置新手、正常和高阶三个阶段"); + continue; + } + const enabled = stage.tiers.filter((tier) => tier.enabled !== false); + if (enabled.some((tier) => numberFromForm(tier.multiplier) < 0)) + errors.push(`${stageLabel(stageKey)}倍率不能小于 0`); + const probabilityPPM = enabled.reduce((sum, tier) => sum + percentToPPM(tier.probabilityPercent), 0); + if (probabilityPPM !== PPM_SCALE) errors.push(`${stageLabel(stageKey)}启用奖档概率必须合计 100%`); + if (!enabled.some((tier) => Number(tier.multiplier) === 0)) + errors.push(`${stageLabel(stageKey)}必须包含启用的 0x 档`); + if (enabled.some((tier) => Number(tier.probabilityPercent) <= 0)) + errors.push(`${stageLabel(stageKey)}启用奖档概率必须大于 0`); + if (enabled.some((tier) => Number(tier.multiplier) >= 10 && !tier.highWaterOnly)) + errors.push(`${stageLabel(stageKey)}10x 及以上奖档必须限制为高水位`); + const expected = expectedRTPPercent(enabled, { multiplier: (item) => item.multiplier }); + if ( + expected < number("target_rtp_percent") - number("control_band_percent") || + expected > number("target_rtp_percent") + number("control_band_percent") + ) + errors.push(`${stageLabel(stageKey)}期望 RTP 超出控制带宽`); + } + + if (form.strategy_version !== DYNAMIC_STRATEGY) return unique(errors); + + if (number("profit_rate_percent") < 0 || number("anchor_rate_percent") < 0) + errors.push("平台盈利和主播收益比例不能小于 0"); + if ( + percentToPPM(number("pool_rate_percent")) + + percentToPPM(number("profit_rate_percent")) + + percentToPPM(number("anchor_rate_percent")) !== + PPM_SCALE + ) + errors.push("奖池、平台盈利和主播收益比例合计必须等于 100%"); + if (number("initial_pool_coins") <= 0) errors.push("初始奖池必须大于 0"); + if (number("loss_streak_guarantee") <= 0) errors.push("连续未中奖保护次数必须大于 0"); + if (number("low_watermark_coins") <= 0 || number("high_watermark_coins") <= number("low_watermark_coins")) + errors.push("高水位必须大于正数低水位"); + if (number("low_water_nonzero_factor_percent") <= 0 || number("low_water_nonzero_factor_percent") >= 100) + errors.push("低水位非零因子必须大于 0% 且小于 100%"); + if (number("high_water_nonzero_factor_percent") <= 100) errors.push("高水位非零因子必须大于 100%"); + if (number("recharge_boost_window_ms") <= 0 || number("recharge_boost_factor_percent") <= 100) + errors.push("充值加权窗口必须大于 0 且因子必须大于 100%"); + const jackpotTokens = String(form.jackpot_multipliers || "") + .split(/[,,\s]+/) + .filter(Boolean); + const jackpotMultipliers = multiplierListFromForm(form.jackpot_multipliers); + if ( + jackpotTokens.length !== jackpotMultipliers.length || + !jackpotMultipliers.length || + jackpotMultipliers.some((value, index) => value <= 1 || (index > 0 && value <= jackpotMultipliers[index - 1])) + ) { + errors.push("大奖倍率必须大于 1x 并严格递增"); + } + if (number("jackpot_global_rtp_max_percent") <= 0 || number("jackpot_global_rtp_max_percent") > 100) + errors.push("全局大奖 RTP 上限必须在 0%-100% 之间"); + if ( + number("jackpot_user_day_rtp_max_percent") <= 0 || + number("jackpot_user_day_rtp_max_percent") > number("jackpot_global_rtp_max_percent") + ) + errors.push("用户每日 RTP 上限必须大于 0 且不超过全局上限"); + if ( + number("jackpot_user_72h_rtp_max_percent") <= 0 || + number("jackpot_user_72h_rtp_max_percent") > number("jackpot_global_rtp_max_percent") + ) + errors.push("用户 72 小时 RTP 上限必须大于 0 且不超过全局上限"); + if (number("max_jackpot_hits_per_user_day") <= 0) errors.push("用户每日大奖次数必须大于 0"); + if (number("jackpot_spend_threshold_coins") < 0) errors.push("大奖消费门槛不能小于 0"); + for (const [field, label] of payoutCapFields) { + if (number(field) < 0) errors.push(`${label}不能小于 0`); + } + validateRechargeStages(form.stages || [], errors); + if (form.enabled) { + if (number("jackpot_spend_threshold_coins") <= 0) errors.push("启用前必须填写 50 美元等值的大奖消费门槛"); + for (const [field, label] of payoutCapFields) { + if (number(field) <= 0) errors.push(`启用前必须填写${label}`); + } + } + return unique(errors); } export function nextStageMultiplier(tiers = []) { - const positive = tiers.map((tier) => decimal(tier.multiplier)).filter((value) => value > 0); - if (!positive.length) { - return 1; - } - const max = Math.max(...positive); - if (max >= 5) { - return max * 2; - } - if (max >= 2) { - return 5; - } - return max + 1; + const positive = tiers.map((tier) => decimal(tier.multiplier)).filter((value) => value > 0); + if (!positive.length) return 1; + const max = Math.max(...positive); + if (max >= 5) return max * 2; + if (max >= 2) return 5; + return max + 1; } -function normalizeFormStages(stages, targetRTPPercent = 95) { - const byStage = new Map((Array.isArray(stages) ? stages : []).map((stage) => [stage.stage, stage])); - return correctAllStageProbabilities({ - stages: stageOptions.map(([stageKey]) => { - const source = byStage.get(stageKey); - const sourceTiers = Array.isArray(source?.tiers) && source.tiers.length ? source.tiers : defaultStageTiers(stageKey); - return { - stage: stageKey, - tiers: sourceTiers.map((tier) => ({ - enabled: tier.enabled !== false, - highWaterOnly: Boolean(tier.high_water_only ?? tier.highWaterOnly), - multiplier: formNumber(tier.multiplier), - probabilityPercent: formNumber(tier.probability_percent ?? tier.probabilityPercent), - tierId: tier.tier_id || tier.tierId || "" - })) - }; - }), - target_rtp_percent: targetRTPPercent - }).stages; +function normalizeFormStages(stages, targetRTPPercent, strategyVersion) { + const byStage = new Map((Array.isArray(stages) ? stages : []).map((stage) => [stage.stage, stage])); + return stageOptions.map(([stageKey]) => { + const source = byStage.get(stageKey); + const sourceTiers = + Array.isArray(source?.tiers) && source.tiers.length + ? source.tiers + : correctedDefaultTiers(stageKey, targetRTPPercent); + const [default7d, default30d] = + strategyVersion === DYNAMIC_STRATEGY ? defaultRechargeThreshold(stageKey) : [0, 0]; + return { + min_recharge_7d_coins: formNumber(source?.min_recharge_7d_coins ?? source?.minRecharge7DCoins ?? default7d), + min_recharge_30d_coins: formNumber( + source?.min_recharge_30d_coins ?? source?.minRecharge30DCoins ?? default30d, + ), + stage: stageKey, + tiers: sourceTiers.map((tier) => ({ + enabled: tier.enabled !== false, + highWaterOnly: Boolean(tier.high_water_only ?? tier.highWaterOnly), + multiplier: formNumber(tier.multiplier), + probabilityPercent: formNumber(tier.probability_percent ?? tier.probabilityPercent), + tierId: tier.tier_id || tier.tierId || "", + })), + }; + }); } -function defaultStageTiers(stage) { - if (stage === "advanced") { - return [ - defaultTier("advanced_none", 0, 27), - defaultTier("advanced_1x", 1, 60), - defaultTier("advanced_2x", 2, 10), - defaultTier("advanced_5x", 5, 3, { high_water_only: true }) +function correctedDefaultTiers(stage, targetRTPPercent) { + const tiers = [ + defaultTier(`${stage}_none`, 0, 5), + defaultTier(`${stage}_0_5x`, 0.5, 4), + defaultTier(`${stage}_1x`, 1, 86), + defaultTier(`${stage}_2x`, 2, 5), ]; - } - return [ - defaultTier(`${stage}_none`, 0, 10), - defaultTier(`${stage}_0_5x`, 0.5, 20), - defaultTier(`${stage}_1x`, 1, 55), - defaultTier(`${stage}_2x`, 2, 15) - ]; + // dynamic_v3 owner 的 98% 安全基线不只是数学 RTP,还规定了 0x/0.5x/1x/2x 的体验节奏。 + // 这组 5/4/86/5 本身已精确达到 98%,不能再交给无 baseline 的通用求解器重排成另一组同 RTP 概率。 + if (Math.abs(numberFromForm(targetRTPPercent) - dynamicDefaults.target_rtp_percent) < 0.0001) { + return tiers; + } + const probabilities = correctRTPProbabilities(tiers, targetRTPPercent, { multiplier: (item) => item.multiplier }); + return probabilities ? applyProbabilityMap(tiers, probabilities, formatDecimal) : tiers; } -function defaultTier(tierID, multiplier, probabilityPercent, options = {}) { - return { - enabled: true, - highWaterOnly: Boolean(options.high_water_only), - multiplier, - probabilityPercent, - tierId: tierID - }; +function defaultTier(tierID, multiplier, probabilityPercent) { + return { enabled: true, highWaterOnly: false, multiplier, probabilityPercent, tierId: tierID }; +} + +function validateRechargeStages(stages, errors) { + const novice = stages.find((stage) => stage.stage === "novice"); + const normal = stages.find((stage) => stage.stage === "normal"); + const advanced = stages.find((stage) => stage.stage === "advanced"); + const threshold = (stage, field) => numberFromForm(stage?.[field]); + if (threshold(novice, "min_recharge_7d_coins") !== 0 || threshold(novice, "min_recharge_30d_coins") !== 0) + errors.push("新手阶段充值门槛必须为 0 / 0"); + const normal7d = threshold(normal, "min_recharge_7d_coins"); + const normal30d = threshold(normal, "min_recharge_30d_coins"); + const advanced7d = threshold(advanced, "min_recharge_7d_coins"); + const advanced30d = threshold(advanced, "min_recharge_30d_coins"); + if ([normal7d, normal30d, advanced7d, advanced30d].some((value) => value < 0)) { + errors.push("充值阶段门槛不能小于 0"); + return; + } + if (normal7d === 0 && normal30d === 0) errors.push("正常阶段至少一个充值门槛必须大于 0"); + if (advanced7d < normal7d || advanced30d < normal30d || (advanced7d === normal7d && advanced30d === normal30d)) + errors.push("高阶充值门槛必须逐维不低于正常阶段且至少一项更高"); +} + +function defaultRechargeThreshold(stage) { + if (stage === "novice") return [0, 0]; + if (stage === "normal") return [0, 1]; + return [1, 1]; +} + +function defaultNumber(field, strategyVersion) { + if (field === "gift_price_reference") return 100; + if (field === "settlement_window_wager") return 1_000_000; + if (field === "novice_max_equivalent_draws") return 2_000; + if (field === "normal_max_equivalent_draws") return 20_000; + if (field === "effective_from_ms") return 0; + return strategyVersion === DYNAMIC_STRATEGY ? (dynamicDefaults[field] ?? 0) : 0; +} + +function multiplierListToForm(value, strategyVersion) { + if (Array.isArray(value) && value.length) return value.map(formatDecimal).join(", "); + if (typeof value === "string" && value.trim()) return value; + return strategyVersion === DYNAMIC_STRATEGY ? dynamicDefaults.jackpot_multipliers.join(", ") : ""; +} + +function multiplierListFromForm(value) { + return String(value || "") + .split(/[,,\s]+/) + .filter(Boolean) + .map(Number) + .filter(Number.isFinite); +} + +function stringConfigValue(config, snake, camel, fallback) { + const value = config[snake] ?? config[camel]; + return String(value || fallback); +} + +function formConfigNumber(config, snake, camel, fallback) { + return formNumber(config[snake] ?? config[camel] ?? fallback); +} + +function snakeToCamel(value) { + return value.replace(/_([a-z0-9])/g, (_, letter) => letter.toUpperCase()); +} + +function stageLabel(stage) { + return stageOptions.find(([key]) => key === stage)?.[1] || stage; +} + +function percentToPPM(value) { + return Math.round(Number(value || 0) * 10_000); +} + +function unique(values) { + return Array.from(new Set(values)); } export function formNumber(value) { - const parsed = Number(value); - return Number.isFinite(parsed) ? String(parsed) : "0"; + const parsed = Number(value); + return Number.isFinite(parsed) ? String(parsed) : "0"; } export function numberFromForm(value) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : 0; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; } diff --git a/ops-center/src/configForm.test.js b/ops-center/src/configForm.test.js new file mode 100644 index 0000000..df9118d --- /dev/null +++ b/ops-center/src/configForm.test.js @@ -0,0 +1,217 @@ +import { describe, expect, test } from "vitest"; +import { applyDynamicStrategyDefaults, configToForm, formToConfig, validateLuckyGiftForm } from "./configForm.js"; + +describe("ops center lucky gift dynamic_v3 form", () => { + test("loads safe dynamic defaults without inventing app-specific monetary caps", () => { + const form = configToForm({ app_code: "lalu", pool_id: "default", strategy_version: "dynamic_v3" }); + + expect(form).toMatchObject({ + anchor_rate_percent: "1", + enabled: false, + initial_pool_coins: "1000000", + jackpot_multipliers: "200, 500, 1000", + max_single_payout: "0", + pool_rate_percent: "98", + profit_rate_percent: "1", + strategy_version: "dynamic_v3", + target_rtp_percent: "98", + }); + expect(form.stages.map((stage) => [stage.min_recharge_7d_coins, stage.min_recharge_30d_coins])).toEqual([ + ["0", "0"], + ["0", "1"], + ["1", "1"], + ]); + expect(form.stages.map((stage) => stage.tiers.map((tier) => Number(tier.probabilityPercent)))).toEqual([ + [5, 4, 86, 5], + [5, 4, 86, 5], + [5, 4, 86, 5], + ]); + }); + + test("round-trips every dynamic strategy field and canonical stage keys", () => { + const config = completeDynamicConfig(); + // 使用空原对象可证明每个字段都真正经过 formToConfig 写回;若沿用 config 作为 spread 基底, + // 漏掉的动态字段也会被旧值掩盖,测试无法发现发布 payload 的字段丢失。 + const output = formToConfig({}, configToForm(config)); + + expect(output).toEqual(config); + expect(output.stages[1]).toMatchObject({ + min_recharge_30d_coins: 20_000, + min_recharge_7d_coins: 10_000, + stage: "normal", + }); + expect(output.stages[1].tiers[0]).toEqual({ + enabled: true, + high_water_only: false, + multiplier: 0, + probability_percent: 5, + tier_id: "normal_none", + }); + }); + + test("allows disabled drafts but blocks enabled dynamic rules until app monetary limits are filled", () => { + const draft = configToForm({ app_code: "lalu", pool_id: "default", strategy_version: "dynamic_v3" }); + expect(validateLuckyGiftForm(draft)).toEqual([]); + + const enabled = { ...draft, enabled: true }; + const errors = validateLuckyGiftForm(enabled); + + expect(errors).toContain("启用前必须填写 50 美元等值的大奖消费门槛"); + expect(errors).toContain("启用前必须填写单次返奖上限"); + expect(errors).toContain("启用前必须填写主播每日上限"); + }); + + test("keeps fixed_v2 readable and does not apply dynamic-only validation", () => { + const form = configToForm({ + app_code: "yumi", + control_band_percent: 1, + enabled: true, + gift_price_reference: 100, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_id: "legacy", + pool_rate_percent: 96, + settlement_window_wager: 1_000_000, + stages: fixedStages(), + strategy_version: "fixed_v2", + target_rtp_percent: 95, + }); + + expect(form.strategy_version).toBe("fixed_v2"); + expect(validateLuckyGiftForm(form)).toEqual([]); + }); + + test("switching a legacy form to dynamic creates a disabled safe draft", () => { + const legacy = configToForm({ + app_code: "yumi", + control_band_percent: 1, + enabled: true, + gift_price_reference: 100, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_id: "legacy", + pool_rate_percent: 96, + settlement_window_wager: 1_000_000, + stages: fixedStages(), + strategy_version: "fixed_v2", + target_rtp_percent: 95, + }); + const dynamic = applyDynamicStrategyDefaults(legacy); + + expect(dynamic).toMatchObject({ enabled: false, pool_rate_percent: "98", strategy_version: "dynamic_v3" }); + expect(dynamic.stages.map((stage) => stage.tiers.map((tier) => Number(tier.multiplier)))).toEqual([ + [0, 0.5, 1, 2], + [0, 0.5, 1, 2], + [0, 0.5, 1, 2], + ]); + expect(dynamic.stages.map((stage) => stage.tiers.map((tier) => Number(tier.probabilityPercent)))).toEqual([ + [5, 4, 86, 5], + [5, 4, 86, 5], + [5, 4, 86, 5], + ]); + expect(validateLuckyGiftForm(dynamic)).toEqual([]); + }); + + test("rejects invalid disabled draft values that the owner never accepts", () => { + const draft = configToForm({ app_code: "lalu", pool_id: "default", strategy_version: "dynamic_v3" }); + const errors = validateLuckyGiftForm({ + ...draft, + jackpot_spend_threshold_coins: "-1", + max_single_payout: "-1", + profit_rate_percent: "-1", + anchor_rate_percent: "2", + stages: draft.stages.map((stage) => + stage.stage === "normal" ? { ...stage, min_recharge_7d_coins: "-1" } : stage, + ), + }); + + expect(errors).toContain("平台盈利和主播收益比例不能小于 0"); + expect(errors).toContain("大奖消费门槛不能小于 0"); + expect(errors).toContain("单次返奖上限不能小于 0"); + expect(errors).toContain("充值阶段门槛不能小于 0"); + }); +}); + +function completeDynamicConfig() { + return { + anchor_daily_payout_cap: 6_000_000, + anchor_rate_percent: 1, + app_code: "lalu", + control_band_percent: 3, + device_daily_payout_cap: 4_000_000, + effective_from_ms: 0, + enabled: true, + gift_price_reference: 100, + high_water_nonzero_factor_percent: 130, + high_watermark_coins: 20_000_000, + initial_pool_coins: 1_000_000, + jackpot_global_rtp_max_percent: 98, + jackpot_multipliers: [200, 500, 1000], + jackpot_spend_threshold_coins: 50_000, + jackpot_user_72h_rtp_max_percent: 96, + jackpot_user_day_rtp_max_percent: 96, + loss_streak_guarantee: 5, + low_water_nonzero_factor_percent: 70, + low_watermark_coins: 10_000_000, + max_jackpot_hits_per_user_day: 5, + max_single_payout: 1_000_000, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_id: "default", + pool_rate_percent: 98, + profit_rate_percent: 1, + recharge_boost_factor_percent: 110, + recharge_boost_window_ms: 300_000, + room_hourly_payout_cap: 5_000_000, + settlement_window_wager: 1_000_000, + stages: dynamicStages(), + strategy_version: "dynamic_v3", + target_rtp_percent: 98, + user_daily_payout_cap: 3_000_000, + user_hourly_payout_cap: 2_000_000, + }; +} + +function dynamicStages() { + return [ + dynamicStage("novice", 0, 0), + dynamicStage("normal", 10_000, 20_000), + dynamicStage("advanced", 20_000, 30_000), + ]; +} + +function dynamicStage(stage, min7d, min30d) { + return { + min_recharge_30d_coins: min30d, + min_recharge_7d_coins: min7d, + stage, + tiers: [ + tier(`${stage}_none`, 0, 5), + tier(`${stage}_0_5x`, 0.5, 4), + tier(`${stage}_1x`, 1, 86), + tier(`${stage}_2x`, 2, 5), + ], + }; +} + +function fixedStages() { + return ["novice", "normal", "advanced"].map((stage) => ({ + stage, + tiers: [ + tier(`${stage}_none`, 0, 10), + tier(`${stage}_0_5x`, 0.5, 20), + tier(`${stage}_1x`, 1, 55), + tier(`${stage}_2x`, 2, 15), + ], + })); +} + +function tier(tierId, multiplier, probabilityPercent) { + return { + enabled: true, + high_water_only: false, + multiplier, + probability_percent: probabilityPercent, + tier_id: tierId, + }; +} diff --git a/ops-center/src/styles/index.css b/ops-center/src/styles/index.css index ee7e595..14d2863 100644 --- a/ops-center/src/styles/index.css +++ b/ops-center/src/styles/index.css @@ -1,407 +1,440 @@ :root { - color: var(--text-primary); - background: var(--bg-page); - font-family: var(--font-system); + color: var(--text-primary); + background: var(--bg-page); + font-family: var(--font-system); } body { - margin: 0; - min-width: 320px; - background: var(--bg-page); + margin: 0; + min-width: 320px; + background: var(--bg-page); } #ops-center-root { - min-height: 100vh; + min-height: 100vh; } /* ---- 应用外壳:左侧固定导航 + 内容区,宽度对齐主后台侧边栏(248px) ---- */ .ops-shell { - display: grid; - min-height: 100vh; - grid-template-columns: 248px minmax(0, 1fr); + display: grid; + min-height: 100vh; + grid-template-columns: 248px minmax(0, 1fr); } .ops-sidebar { - display: flex; - flex-direction: column; - gap: var(--space-5); - border-right: 1px solid var(--border); - background: var(--bg-card); - padding: var(--space-5) var(--space-3); + display: flex; + flex-direction: column; + gap: var(--space-5); + border-right: 1px solid var(--border); + background: var(--bg-card); + padding: var(--space-5) var(--space-3); } .ops-brand { - display: flex; - align-items: center; - gap: var(--space-3); - padding: 0 var(--space-2); + display: flex; + align-items: center; + gap: var(--space-3); + padding: 0 var(--space-2); } .ops-brand > span { - display: inline-grid; - width: 36px; - height: 36px; - place-items: center; - border-radius: var(--radius-control); - background: var(--primary); - color: var(--active-contrast, #fff); - font-size: 13px; - font-weight: 800; + display: inline-grid; + width: 36px; + height: 36px; + place-items: center; + border-radius: var(--radius-control); + background: var(--primary); + color: var(--active-contrast, #fff); + font-size: 13px; + font-weight: 800; } .ops-brand strong { - display: block; - font-size: 16px; - line-height: 1.25; + display: block; + font-size: 16px; + line-height: 1.25; } .ops-brand small { - color: var(--text-tertiary); - font-size: 11px; + color: var(--text-tertiary); + font-size: 11px; } .ops-nav { - display: grid; - gap: var(--space-2); + display: grid; + gap: var(--space-2); } .ops-nav button { - display: flex; - width: 100%; - height: 38px; - align-items: center; - gap: var(--space-3); - border: 0; - border-radius: var(--radius-control); - background: transparent; - color: var(--text-secondary); - cursor: pointer; - padding: 0 var(--space-3); - text-align: left; - transition: - background var(--motion-fast) var(--ease-standard), - color var(--motion-fast) var(--ease-standard); + display: flex; + width: 100%; + height: 38px; + align-items: center; + gap: var(--space-3); + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + padding: 0 var(--space-3); + text-align: left; + transition: + background var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard); } .ops-nav button:hover { - background: var(--primary-hover); - color: var(--text-primary); + background: var(--primary-hover); + color: var(--text-primary); } /* active 优先级必须高于 hover:hover 到别的项时当前页仍要保持高亮底色 */ .ops-nav button.is-active, .ops-nav button.is-active:hover { - background: var(--primary-surface); - color: var(--primary); - font-weight: 700; + background: var(--primary-surface); + color: var(--primary); + font-weight: 700; } .ops-main { - min-width: 0; - background: var(--bg-page); + min-width: 0; + background: var(--bg-page); } /* ---- 页面骨架 ---- */ .ops-page { - display: grid; - gap: var(--space-5); - padding: var(--space-6); + display: grid; + gap: var(--space-5); + padding: var(--space-6); } .ops-view { - display: grid; - gap: var(--space-5); + display: grid; + gap: var(--space-5); } .ops-kpi-grid { - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); } .ops-status { - display: inline-flex; - height: 28px; - align-items: center; - border: 1px solid var(--success-border); - border-radius: 999px; - background: var(--success-surface); - color: var(--success); - font-size: 12px; - font-weight: 700; - padding: 0 var(--space-3); + display: inline-flex; + height: 28px; + align-items: center; + border: 1px solid var(--success-border); + border-radius: 999px; + background: var(--success-surface); + color: var(--success); + font-size: 12px; + font-weight: 700; + padding: 0 var(--space-3); } .ops-status.is-loading { - border-color: var(--info-border); - background: var(--info-surface); - color: var(--info); + border-color: var(--info-border); + background: var(--info-surface); + color: var(--info); } .ops-alert { - border: 1px solid var(--warning-border); - border-radius: var(--radius-card); - background: var(--warning-surface); - color: var(--warning); - font-weight: 700; - padding: var(--space-3) var(--space-4); + border: 1px solid var(--warning-border); + border-radius: var(--radius-card); + background: var(--warning-surface); + color: var(--warning); + font-weight: 700; + padding: var(--space-3) var(--space-4); } /* ---- 幸运礼物配置弹窗 ---- */ .ops-config-form { - display: contents; + display: contents; } .ops-config-title { - display: grid; - gap: var(--space-1); + display: grid; + gap: var(--space-1); } .ops-config-title small { - color: var(--text-tertiary); - font-size: 12px; - font-weight: 700; + color: var(--text-tertiary); + font-size: 12px; + font-weight: 700; } .ops-config-metrics { - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: var(--space-2); - margin-bottom: var(--space-4); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: var(--space-2); + margin-bottom: var(--space-4); +} + +.ops-config-validation { + display: grid; + gap: var(--space-2); + margin-bottom: var(--space-4); + border: 1px solid var(--danger-border, #fecaca); + border-radius: var(--radius-control); + background: var(--danger-surface, #fef2f2); + color: var(--danger, #dc2626); + padding: var(--space-3) var(--space-4); +} + +.ops-config-validation ul { + display: grid; + gap: 2px; + margin: 0; + padding-left: 20px; } .ops-config-metric { - display: grid; - min-width: 0; - gap: var(--space-1); - border: 1px solid var(--border); - border-radius: var(--radius-control); - background: var(--bg-card-strong, #f8fafc); - padding: 10px var(--space-3); + display: grid; + min-width: 0; + gap: var(--space-1); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card-strong, #f8fafc); + padding: 10px var(--space-3); } .ops-config-metric span { - overflow: hidden; - color: var(--text-tertiary); - font-size: 12px; - font-weight: 800; - text-overflow: ellipsis; - white-space: nowrap; + overflow: hidden; + color: var(--text-tertiary); + font-size: 12px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; } .ops-config-metric strong { - overflow: hidden; - color: var(--text-primary); - font-size: 15px; - line-height: 1.25; - text-overflow: ellipsis; - white-space: nowrap; + overflow: hidden; + color: var(--text-primary); + font-size: 15px; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; } .ops-config-layout { - display: grid; - grid-template-columns: minmax(280px, 340px) minmax(0, 1fr); - align-items: start; - gap: var(--space-4); + display: grid; + grid-template-columns: minmax(360px, 440px) minmax(0, 1fr); + align-items: start; + gap: var(--space-4); } .ops-config-params { - display: grid; - min-width: 0; - gap: var(--space-4); + display: grid; + min-width: 0; + gap: var(--space-4); } .ops-config-section { - display: grid; - gap: var(--space-3); - border: 1px solid var(--border); - border-radius: var(--radius-card); - background: var(--bg-card); - padding: var(--space-4); + display: grid; + gap: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); + padding: var(--space-4); } .ops-config-section h3 { - margin: 0; - color: var(--text-primary); - font-size: 15px; - line-height: 1.3; + margin: 0; + color: var(--text-primary); + font-size: 15px; + line-height: 1.3; } .ops-form-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-3); + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} + +.ops-form-grid__wide { + grid-column: 1 / -1; } .ops-stage-designer { - display: grid; - min-width: 0; - gap: 0; - border: 1px solid var(--border); - border-radius: var(--radius-card); - background: var(--bg-card); + display: grid; + min-width: 0; + gap: 0; + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); } .ops-stage-tabs { - display: flex; - min-width: 0; - gap: var(--space-1); - padding: 0 var(--space-3); - border-bottom: 1px solid var(--border); + display: flex; + min-width: 0; + gap: var(--space-1); + padding: 0 var(--space-3); + border-bottom: 1px solid var(--border); } .ops-stage-tab { - display: inline-flex; - min-width: 0; - height: 38px; - align-items: center; - border: 0; - border-bottom: 2px solid transparent; - border-radius: 0; - background: transparent; - color: var(--text-secondary); - cursor: pointer; - font-size: 13px; - font-weight: 800; - padding: 0 var(--space-4); - text-align: center; - transition: - background var(--motion-fast) var(--ease-standard), - border-color var(--motion-fast) var(--ease-standard), - color var(--motion-fast) var(--ease-standard); + display: inline-flex; + min-width: 0; + height: 38px; + align-items: center; + border: 0; + border-bottom: 2px solid transparent; + border-radius: 0; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 13px; + font-weight: 800; + padding: 0 var(--space-4); + text-align: center; + transition: + background var(--motion-fast) var(--ease-standard), + border-color var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard); } .ops-stage-tab:hover { - background: var(--primary-hover); + background: var(--primary-hover); } .ops-stage-tab.is-active { - border-bottom-color: var(--primary); - color: var(--text-primary); + border-bottom-color: var(--primary); + color: var(--text-primary); } /* 阶段有问题(概率未闭合 / RTP 偏离)时 tab 下划线转告警色,切换前就能看到哪个阶段需要处理 */ .ops-stage-tab.is-warn { - color: var(--warning); + color: var(--warning); } .ops-stage-tab.is-active.is-warn { - border-bottom-color: var(--warning); - color: var(--warning); + border-bottom-color: var(--warning); + color: var(--warning); } .ops-stage-stack { - display: grid; - gap: var(--space-3); - padding: var(--space-4); + display: grid; + gap: var(--space-3); + padding: var(--space-4); } .ops-stage-block__head { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-3); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); } .ops-stage-block__head > div { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-3); + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-3); } .ops-stage-block__head h3 { - margin: 0; - font-size: 15px; + margin: 0; + font-size: 15px; +} + +.ops-stage-thresholds { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg-card-strong, #f8fafc); } .ops-tier-table { - display: grid; - min-width: 0; - overflow-x: auto; - border: 1px solid var(--border); - border-radius: var(--radius-card); - background: var(--bg-card); + display: grid; + min-width: 0; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius-card); + background: var(--bg-card); } .ops-tier-head, .ops-tier-row { - display: grid; - box-sizing: border-box; - min-width: 520px; - grid-template-columns: - minmax(110px, 1fr) - minmax(110px, 1fr) - minmax(64px, auto) - minmax(64px, auto) - minmax(72px, auto); - align-items: center; - gap: var(--space-2); + display: grid; + box-sizing: border-box; + min-width: 520px; + grid-template-columns: + minmax(110px, 1fr) + minmax(110px, 1fr) + minmax(64px, auto) + minmax(64px, auto) + minmax(72px, auto); + align-items: center; + gap: var(--space-2); } .ops-tier-head { - min-height: 34px; - border-bottom: 1px solid var(--row-border); - background: var(--bg-card-strong, #f8fafc); - color: var(--text-tertiary); - font-size: 12px; - font-weight: 900; - padding: 0 var(--space-3); + min-height: 34px; + border-bottom: 1px solid var(--row-border); + background: var(--bg-card-strong, #f8fafc); + color: var(--text-tertiary); + font-size: 12px; + font-weight: 900; + padding: 0 var(--space-3); } .ops-tier-row { - border-bottom: 1px solid var(--row-border); - padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--row-border); + padding: var(--space-2) var(--space-3); } .ops-tier-row:last-child { - border-bottom: 0; + border-bottom: 0; } /* ---- 响应式 ---- */ @media (max-width: 960px) { - .ops-shell { - grid-template-columns: 1fr; - } + .ops-shell { + grid-template-columns: 1fr; + } - .ops-sidebar { - min-height: auto; - } + .ops-sidebar { + min-height: auto; + } - .ops-nav, - .ops-kpi-grid, - .ops-config-metrics { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } + .ops-nav, + .ops-kpi-grid, + .ops-config-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } - .ops-config-layout { - grid-template-columns: minmax(0, 1fr); - } + .ops-config-layout { + grid-template-columns: minmax(0, 1fr); + } } @media (max-width: 640px) { - .ops-page { - padding: var(--space-4); - } + .ops-page { + padding: var(--space-4); + } - .ops-nav, - .ops-kpi-grid, - .ops-config-metrics, - .ops-form-grid, - .ops-tier-row { - grid-template-columns: 1fr; - } + .ops-nav, + .ops-kpi-grid, + .ops-config-metrics, + .ops-form-grid, + .ops-stage-thresholds, + .ops-tier-row { + grid-template-columns: 1fr; + } - .ops-tier-head { - display: none; - } + .ops-tier-head { + display: none; + } } @media (prefers-reduced-motion: reduce) { - .ops-nav button, - .ops-stage-tab { - transition: none; - } + .ops-nav button, + .ops-stage-tab { + transition: none; + } } diff --git a/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx b/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx index 996a738..80e0b9f 100644 --- a/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx +++ b/src/features/lucky-gift/pages/LuckyGiftConfigPage.jsx @@ -1,204 +1,18 @@ -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 RefreshOutlined from "@mui/icons-material/RefreshOutlined"; -import TextField from "@mui/material/TextField"; -import { LuckyGiftConfigDrawer } from "@/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx"; -import { LuckyGiftConfigSummary } from "@/features/lucky-gift/components/LuckyGiftConfigSummary.jsx"; -import { LuckyGiftSimulationPanel } from "@/features/lucky-gift/components/LuckyGiftSimulationPanel.jsx"; -import { drawStatusOptions } from "@/features/lucky-gift/constants.js"; -import { useLuckyGiftPage } from "@/features/lucky-gift/hooks/useLuckyGiftPage.js"; -import styles from "@/features/lucky-gift/lucky-gift.module.css"; -import { AdminFilterResetButton, AdminFilterSelect, AdminListPage } from "@/shared/ui/AdminListLayout.jsx"; -import { Button } from "@/shared/ui/Button.jsx"; +import { useEffect } from "react"; + +export const LUCKY_GIFT_OPS_CENTER_PATH = "/ops-center/?view=configs"; + +export function redirectToLuckyGiftOpsCenter(targetLocation = window.location) { + // Ops Center 是独立 HTML 入口,React Router 的 SPA Navigate 会落回主后台兜底路由; + // 必须触发文档级导航,才能加载新版跨 App 配置页并避免旧页面调用已下线的 activity/v2 接口。 + targetLocation.replace(LUCKY_GIFT_OPS_CENTER_PATH); +} export function LuckyGiftConfigPage() { - const page = useLuckyGiftPage(); + useEffect(() => { + redirectToLuckyGiftOpsCenter(); + }, []); - return ( - - - - - - - - ); -} - -function LuckyGiftDrawSummaryPanel({ page }) { - const summary = page.drawSummary || {}; - const netSpent = Number(summary.totalSpentCoins || 0) - Number(summary.totalRewardCoins || 0); - const stats = [ - { - hint: `房间 ${formatNumber(summary.uniqueRooms)}`, - label: "参与用户", - value: formatNumber(summary.uniqueUsers), - }, - { - hint: `成功 ${formatNumber(summary.grantedDraws)} / 待发放 ${formatNumber(summary.pendingDraws)} / 失败 ${formatNumber(summary.failedDraws)}`, - label: "抽奖次数", - value: formatNumber(summary.totalDraws), - }, - { - hint: `目标 ${formatPercent(page.config?.targetRTPPercent)}`, - label: "实际 RTP", - value: formatPPM(summary.actualRTPPPM), - }, - { - hint: `净消耗 ${formatNumber(netSpent)}`, - label: "消耗金币", - value: formatNumber(summary.totalSpentCoins), - }, - { - hint: `基础 ${formatNumber(summary.baseRewardCoins)}`, - label: "返还金币", - value: formatNumber(summary.totalRewardCoins), - }, - { - hint: `房间 ${formatNumber(summary.roomAtmosphereRewardCoins)} / 活动 ${formatNumber(summary.activitySubsidyCoins)}`, - label: "额外预算", - value: formatNumber( - Number(summary.roomAtmosphereRewardCoins || 0) + Number(summary.activitySubsidyCoins || 0), - ), - }, - ]; - - return ( -
-
-
-

抽奖数据概览

- 奖池 {summary.poolId || page.poolId} -
- -
-
- - page.changeGiftId(event.target.value)} - /> - page.changeUserId(event.target.value)} - /> - page.changeRoomId(event.target.value)} - /> - - -
- {page.drawSummaryError ? ( -
- {page.drawSummaryError} - -
- ) : ( -
- {stats.map((item) => ( -
- {item.label} - {page.drawSummaryLoading ? "-" : item.value} - {page.drawSummaryLoading ? "" : item.hint} -
- ))} -
- )} -
- ); -} - -function AddPoolDialog({ disabled, onChange, onClose, onSubmit, open, value }) { - return ( - -
- 添加奖池 - - onChange(event.target.value)} - /> - - - - - -
-
- ); -} - -function formatPPM(value) { - return `${(Number(value || 0) / 10_000).toFixed(2).replace(/\.?0+$/, "")}%`; -} - -function formatPercent(value) { - return `${Number(value || 0) - .toFixed(2) - .replace(/\.?0+$/, "")}%`; -} - -function formatNumber(value) { - return new Intl.NumberFormat("zh-CN").format(Number(value || 0)); + // 文档跳转通常立即完成;保留可读状态,供慢网络和辅助技术明确当前动作。 + return

正在打开新版幸运礼物配置…

; } diff --git a/src/features/lucky-gift/pages/LuckyGiftConfigPage.test.jsx b/src/features/lucky-gift/pages/LuckyGiftConfigPage.test.jsx new file mode 100644 index 0000000..79bd5c2 --- /dev/null +++ b/src/features/lucky-gift/pages/LuckyGiftConfigPage.test.jsx @@ -0,0 +1,14 @@ +import { expect, test, vi } from "vitest"; +import { + LUCKY_GIFT_OPS_CENTER_PATH, + redirectToLuckyGiftOpsCenter, +} from "@/features/lucky-gift/pages/LuckyGiftConfigPage.jsx"; + +test("moves the legacy lucky gift route to the ops-center document entry", () => { + const targetLocation = { replace: vi.fn() }; + + redirectToLuckyGiftOpsCenter(targetLocation); + + expect(targetLocation.replace).toHaveBeenCalledWith(LUCKY_GIFT_OPS_CENTER_PATH); + expect(LUCKY_GIFT_OPS_CENTER_PATH).toBe("/ops-center/?view=configs"); +}); diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index a75c051..0d384c3 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -229,6 +229,7 @@ export const API_OPERATIONS = { listLoginLogs: "listLoginLogs", listLuckyGiftConfigs: "listLuckyGiftConfigs", listLuckyGiftDraws: "listLuckyGiftDraws", + listLuckyGiftPoolBalances: "listLuckyGiftPoolBalances", listManagers: "listManagers", listOperationLogs: "listOperationLogs", listPermissions: "listPermissions", @@ -1306,14 +1307,14 @@ export const API_ENDPOINTS: Record = { getLuckyGiftConfig: { method: "GET", operationId: API_OPERATIONS.getLuckyGiftConfig, - path: "/v1/admin/activity/lucky-gifts/v2/config", + path: "/v1/admin/ops-center/lucky-gifts/config", permission: "lucky-gift:view", permissions: ["lucky-gift:view"] }, getLuckyGiftDrawSummary: { method: "GET", operationId: API_OPERATIONS.getLuckyGiftDrawSummary, - path: "/v1/admin/activity/lucky-gifts/v2/draw-summary", + path: "/v1/admin/ops-center/lucky-gifts/summary", permission: "lucky-gift:view", permissions: ["lucky-gift:view"] }, @@ -1899,14 +1900,21 @@ export const API_ENDPOINTS: Record = { listLuckyGiftConfigs: { method: "GET", operationId: API_OPERATIONS.listLuckyGiftConfigs, - path: "/v1/admin/activity/lucky-gifts/v2/configs", + path: "/v1/admin/ops-center/lucky-gifts/configs", permission: "lucky-gift:view", permissions: ["lucky-gift:view"] }, listLuckyGiftDraws: { method: "GET", operationId: API_OPERATIONS.listLuckyGiftDraws, - path: "/v1/admin/activity/lucky-gifts/v2/draws", + path: "/v1/admin/ops-center/lucky-gifts/draws", + permission: "lucky-gift:view", + permissions: ["lucky-gift:view"] + }, + listLuckyGiftPoolBalances: { + method: "GET", + operationId: API_OPERATIONS.listLuckyGiftPoolBalances, + path: "/v1/admin/ops-center/lucky-gifts/pools", permission: "lucky-gift:view", permissions: ["lucky-gift:view"] }, @@ -3026,7 +3034,7 @@ export const API_ENDPOINTS: Record = { upsertLuckyGiftConfig: { method: "PUT", operationId: API_OPERATIONS.upsertLuckyGiftConfig, - path: "/v1/admin/activity/lucky-gifts/v2/config", + path: "/v1/admin/ops-center/lucky-gifts/config", permission: "lucky-gift:update", permissions: ["lucky-gift:update"] }, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index cff1343..1768226 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -244,7 +244,7 @@ export interface paths { patch?: never; trace?: never; }; - "/admin/activity/lucky-gifts/v2/config": { + "/admin/ops-center/lucky-gifts/config": { parameters: { query?: never; header?: never; @@ -260,7 +260,7 @@ export interface paths { patch?: never; trace?: never; }; - "/admin/activity/lucky-gifts/v2/configs": { + "/admin/ops-center/lucky-gifts/configs": { parameters: { query?: never; header?: never; @@ -276,7 +276,7 @@ export interface paths { patch?: never; trace?: never; }; - "/admin/activity/lucky-gifts/v2/draws": { + "/admin/ops-center/lucky-gifts/draws": { parameters: { query?: never; header?: never; @@ -292,7 +292,7 @@ export interface paths { patch?: never; trace?: never; }; - "/admin/activity/lucky-gifts/v2/draw-summary": { + "/admin/ops-center/lucky-gifts/summary": { parameters: { query?: never; header?: never; @@ -308,6 +308,22 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/ops-center/lucky-gifts/pools": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listLuckyGiftPoolBalances"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/activity/wheel/config": { parameters: { query?: never; @@ -7935,6 +7951,18 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + listLuckyGiftPoolBalances: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; getWheelConfig: { parameters: { query?: never;