diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json
index 703b45c..96afce1 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 ? "同步中" : "已就绪"}
- } onClick={loadDashboard}>
- 刷新
-
-
- {/* 首屏失败走 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 ? "同步中" : "已就绪"}
+
+ }
+ onClick={loadDashboard}
+ >
+ 刷新
+
+
+ {/* 首屏失败走 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 (
-