feat(ops-center): support lucky gift dynamic v3

This commit is contained in:
zhx 2026-07-15 14:49:39 +08:00
parent 6ff2feb052
commit 4d872de302
14 changed files with 2620 additions and 1495 deletions

View File

@ -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",

View File

@ -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 (
<OpsShell activeView={activeView} onViewChange={setActiveView} views={views}>
<div className="ops-page">
<PageHead meta="跨应用幸运礼物配置与抽奖数据面板" title="运营配置中心">
<span className={state.loading ? "ops-status is-loading" : "ops-status"}>{state.loading ? "同步中" : "已就绪"}</span>
<Button disabled={state.loading} startIcon={<RefreshOutlined fontSize="small" />} onClick={loadDashboard}>
刷新
</Button>
</PageHead>
{/* 首屏失败走 DataState 的错误重试页;已有数据时刷新失败只弹提示条,保留旧数据可用。 */}
{state.error && state.loaded ? (
<div className="ops-alert" role="alert">
{state.error}
</div>
) : null}
<DataState error={state.loaded ? "" : state.error} loading={state.loading && !state.loaded} onRetry={loadDashboard}>
{activeView === "overview" ? <OverviewView data={data} /> : null}
{activeView === "configs" ? (
<ConfigsView
apps={data.apps}
luckyGifts={data.luckyGifts}
saving={savingConfig}
onCreate={openCreateConfig}
onEdit={openEditConfig}
/>
) : null}
{activeView === "draws" ? <DrawsView apps={data.apps} defaultAppCode={defaultAppCode} poolIDsByApp={poolIDsByApp} /> : null}
{activeView === "apps" ? <AppsView apps={data.apps} /> : null}
</DataState>
{dialog ? (
<LuckyGiftConfigDialog
appOptions={appOptions}
config={dialog.config}
mode={dialog.mode}
saving={savingConfig}
onClose={() => setDialog(null)}
onSubmit={handleSaveConfig}
/>
) : null}
</div>
</OpsShell>
);
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 (
<OpsShell activeView={activeView} onViewChange={setActiveView} views={views}>
<div className="ops-page">
<PageHead meta="跨应用幸运礼物配置与抽奖数据面板" title="运营配置中心">
<span className={state.loading ? "ops-status is-loading" : "ops-status"}>
{state.loading ? "同步中" : "已就绪"}
</span>
<Button
disabled={state.loading}
startIcon={<RefreshOutlined fontSize="small" />}
onClick={loadDashboard}
>
刷新
</Button>
</PageHead>
{/* 首屏失败走 DataState 的错误重试页;已有数据时刷新失败只弹提示条,保留旧数据可用。 */}
{state.error && state.loaded ? (
<div className="ops-alert" role="alert">
{state.error}
</div>
) : null}
<DataState
error={state.loaded ? "" : state.error}
loading={state.loading && !state.loaded}
onRetry={loadDashboard}
>
{activeView === "overview" ? <OverviewView data={data} /> : null}
{activeView === "configs" ? (
<ConfigsView
apps={data.apps}
luckyGifts={data.luckyGifts}
saving={savingConfig}
onCreate={openCreateConfig}
onEdit={openEditConfig}
/>
) : null}
{activeView === "draws" ? (
<DrawsView apps={data.apps} defaultAppCode={defaultAppCode} poolIDsByApp={poolIDsByApp} />
) : null}
{activeView === "apps" ? <AppsView apps={data.apps} /> : null}
</DataState>
{dialog ? (
<LuckyGiftConfigDialog
appOptions={appOptions}
config={dialog.config}
mode={dialog.mode}
saving={savingConfig}
onClose={() => setDialog(null)}
onSubmit={handleSaveConfig}
/>
) : null}
</div>
</OpsShell>
);
}

View File

@ -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(
<ToastProvider>
<OpsCenterApp />
</ToastProvider>
);
return render(
<ToastProvider>
<OpsCenterApp />
</ToastProvider>,
);
}
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();
});

View File

@ -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_versionlucky-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 || "",
}))
: [],
}));
}

View File

@ -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,
});
});

View File

@ -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 (
<div className="ops-view">
<AdminListToolbar
actions={
<Button disabled={saving} startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={onCreate}>
添加配置
</Button>
}
filters={<AdminFilterSelect label="应用" options={[["", "全部应用"], ...appOptions]} value={appFilter} onChange={setAppFilter} />}
/>
<DataTable
columns={columns}
emptyLabel="暂无幸运礼物配置"
items={items}
minWidth="1080px"
rowKey={(item) => `${item.app_code}:${item.pool_id}:${item.rule_version}`}
title="幸运礼物配置"
total={items.length}
/>
</div>
);
return (
<div className="ops-view">
<AdminListToolbar
actions={
<Button
disabled={saving}
startIcon={<AddOutlined fontSize="small" />}
variant="primary"
onClick={onCreate}
>
添加配置
</Button>
}
filters={
<AdminFilterSelect
label="应用"
options={[["", "全部应用"], ...appOptions]}
value={appFilter}
onChange={setAppFilter}
/>
}
/>
<DataTable
columns={columns}
emptyLabel="暂无幸运礼物配置"
items={items}
minWidth="1260px"
rowKey={(item) => `${item.app_code}:${item.pool_id}:${item.rule_version}`}
title="幸运礼物配置"
total={items.length}
/>
</div>
);
}
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 <OpsStatusBadge label="默认草稿" tone="warning" />;
}
return item.enabled ? <OpsStatusBadge label="已启用" tone="succeeded" /> : <OpsStatusBadge label="已停用" />;
},
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 ? "-" : <TimeText value={item.created_at_ms} />),
width: "minmax(176px, 1.1fr)"
},
{
key: "actions",
label: "操作",
render: (item) => (
<Button disabled={saving} size="small" onClick={() => onEdit(item)}>
{item.is_default ? "发布配置" : "新增版本"}
</Button>
)
}
];
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 <OpsStatusBadge label="默认草稿" tone="warning" />;
}
return item.enabled ? (
<OpsStatusBadge label="已启用" tone="succeeded" />
) : (
<OpsStatusBadge label="已停用" />
);
},
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 ? "-" : <TimeText value={item.created_at_ms} />),
width: "minmax(176px, 1.1fr)",
},
{
key: "actions",
label: "操作",
render: (item) => (
<Button disabled={saving} size="small" onClick={() => onEdit(item)}>
{item.is_default ? "发布配置" : "新增版本"}
</Button>
),
},
];
}

View File

@ -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 (
<Dialog fullWidth maxWidth="lg" open onClose={saving ? undefined : onClose}>
<form className="ops-config-form" onSubmit={handleSubmit}>
<DialogTitle className="ops-config-title">
<span>{title}</span>
<small>
{form.app_code || "-"} / {form.pool_id || "default"} /{" "}
{config.is_default || isCreate ? "默认草稿" : `当前版本 v${config.rule_version ?? "-"}`}
</small>
</DialogTitle>
<DialogContent dividers>
<div className="ops-config-metrics">
<ConfigMetric label="应用" value={form.app_code || "-"} />
<ConfigMetric label="奖池" value={form.pool_id || "default"} />
<ConfigMetric label="目标 RTP" value={formatPercent(Number(form.target_rtp_percent))} />
<ConfigMetric label="奖池回收" value={formatPercent(Number(form.pool_rate_percent))} />
<ConfigMetric label="结算窗口" value={formatNumber(Number(form.settlement_window_wager))} />
<ConfigMetric label="状态" value={form.enabled ? "启用" : "停用"} />
</div>
<div className="ops-config-layout">
<aside className="ops-config-params" aria-label="基础参数">
<section className="ops-config-section">
<h3>规则身份</h3>
{isCreate ? (
<TextField
fullWidth
required
label="应用"
select
size="small"
value={form.app_code}
onChange={(event) => updateField("app_code", event.target.value)}
>
{appOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
) : (
<TextField disabled fullWidth label="应用" size="small" value={form.app_code} />
)}
<TextField
fullWidth
required
helperText="修改奖池 ID 会发布到新的奖池,不影响原奖池"
label="奖池"
size="small"
value={form.pool_id}
onChange={(event) => updateField("pool_id", event.target.value)}
/>
<FormControlLabel
control={<AdminSwitch checked={form.enabled} onChange={(event) => updateField("enabled", event.target.checked)} />}
label={form.enabled ? "发布后立即启用" : "发布后保持停用"}
sx={{ gap: 1, marginLeft: 0 }}
/>
</section>
<section className="ops-config-section">
<h3>RTP 与奖池</h3>
<div className="ops-form-grid">
<NumberField form={form} label="目标 RTP (%)" name="target_rtp_percent" step="0.01" onChange={updateField} />
<NumberField form={form} label="奖池回收率 (%)" name="pool_rate_percent" step="0.01" onChange={updateField} />
<NumberField form={form} label="控制带宽 (%)" name="control_band_percent" step="0.01" onChange={updateField} />
<NumberField form={form} label="结算窗口流水" name="settlement_window_wager" onChange={updateField} />
<NumberField form={form} label="礼物参考金额" name="gift_price_reference" onChange={updateField} />
</div>
</section>
</aside>
<section className="ops-stage-designer" aria-label="阶段奖档设计">
<div className="ops-stage-tabs" role="tablist" aria-label="阶段奖档">
{stageSummaries.map((summary) => (
<button
aria-selected={summary.stageKey === activeStageKey}
className={`ops-stage-tab ${summary.stageKey === activeStageKey ? "is-active" : ""} ${summary.ok ? "is-ok" : "is-warn"}`}
key={summary.stageKey}
role="tab"
type="button"
onClick={() => setActiveStage(summary.stageKey)}
>
<span>{summary.tabLabel}</span>
</button>
))}
</div>
{activeStageConfig ? (
<div className="ops-stage-stack">
<div className="ops-stage-block__head">
<div>
<h3>{activeSummary?.stageLabel}</h3>
<OpsStatusBadge
label={`${activeSummary?.status} · 概率 ${formatDecimal(activeSummary?.probability)}% · 期望 RTP ${formatDecimal(activeSummary?.expected)}%`}
tone={activeSummary?.ok ? "succeeded" : "warning"}
/>
</div>
<Button startIcon={<AddOutlined fontSize="small" />} onClick={() => addStageTier(activeStageConfig.stage)}>
添加奖档
</Button>
</div>
<div className="ops-tier-table">
<div className="ops-tier-head" aria-hidden="true">
<span>倍率</span>
<span>概率 %</span>
<span>高水位</span>
<span>启用</span>
<span>操作</span>
</div>
{activeStageConfig.tiers.map((tier, index) => (
<div className="ops-tier-row" key={`${activeStageConfig.stage}-${index}`}>
<TextField
required
size="small"
slotProps={{ htmlInput: { "aria-label": "倍率", min: 0, step: "0.01" } }}
type="number"
value={tier.multiplier}
onChange={(event) =>
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 (
<Dialog fullWidth maxWidth="lg" open onClose={saving ? undefined : onClose}>
<form className="ops-config-form" onSubmit={handleSubmit}>
<DialogTitle className="ops-config-title">
<span>{title}</span>
<small>
{form.app_code || "-"} / {form.pool_id || "default"} /{" "}
{config.is_default || isCreate ? "默认草稿" : `当前版本 v${config.rule_version ?? "-"}`}
</small>
</DialogTitle>
<DialogContent dividers>
<div className="ops-config-metrics">
<ConfigMetric label="应用" value={form.app_code || "-"} />
<ConfigMetric label="奖池" value={form.pool_id || "default"} />
<ConfigMetric label="策略" value={form.strategy_version} />
<ConfigMetric label="目标 RTP" value={formatPercent(Number(form.target_rtp_percent))} />
<ConfigMetric
label="资金拆分"
value={`${form.pool_rate_percent}% / ${form.profit_rate_percent}% / ${form.anchor_rate_percent}%`}
/>
{/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */}
<TextField
size="small"
slotProps={{ htmlInput: { "aria-label": "概率 %", readOnly: true } }}
value={formatDecimal(tier.probabilityPercent)}
/>
<Checkbox
checked={Boolean(tier.highWaterOnly)}
disabled={Number(tier.multiplier) >= 10}
size="small"
slotProps={{ input: { "aria-label": "高水位" } }}
onChange={(event) => updateStageTier(activeStageConfig.stage, index, { highWaterOnly: event.target.checked })}
/>
<Checkbox
checked={tier.enabled !== false}
size="small"
slotProps={{ input: { "aria-label": "启用" } }}
onChange={(event) => updateStageTier(activeStageConfig.stage, index, { enabled: event.target.checked })}
/>
<Button
disabled={activeStageConfig.tiers.length <= 1}
variant="danger"
onClick={() => removeStageTier(activeStageConfig.stage, index)}
>
删除
</Button>
</div>
))}
</div>
<ConfigMetric label="结算窗口" value={formatNumber(Number(form.settlement_window_wager))} />
<ConfigMetric label="状态" value={form.enabled ? "启用" : "停用"} />
</div>
{validationErrors.length ? (
<div className="ops-config-validation" role="alert">
<strong>配置不能发布</strong>
<ul>
{validationErrors.map((message) => (
<li key={message}>{message}</li>
))}
</ul>
</div>
) : null}
<div className="ops-config-layout">
<aside className="ops-config-params" aria-label="基础参数">
<section className="ops-config-section">
<h3>规则身份</h3>
{isCreate ? (
<TextField
fullWidth
required
label="应用"
select
size="small"
value={form.app_code}
onChange={(event) => updateField("app_code", event.target.value)}
>
{appOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
) : (
<TextField disabled fullWidth label="应用" size="small" value={form.app_code} />
)}
<TextField
fullWidth
required
helperText="修改奖池 ID 会发布到新的奖池,不影响原奖池"
label="奖池"
size="small"
value={form.pool_id}
onChange={(event) => updateField("pool_id", event.target.value)}
/>
<TextField
fullWidth
label="策略版本"
select
size="small"
value={form.strategy_version}
onChange={(event) => updateField("strategy_version", event.target.value)}
>
<MenuItem value="dynamic_v3">dynamic_v3</MenuItem>
<MenuItem value="fixed_v2">fixed_v2历史兼容</MenuItem>
</TextField>
<FormControlLabel
control={
<AdminSwitch
checked={form.enabled}
onChange={(event) => updateField("enabled", event.target.checked)}
/>
}
label={form.enabled ? "发布后立即启用" : "发布后保持停用"}
sx={{ gap: 1, marginLeft: 0 }}
/>
</section>
<section className="ops-config-section">
<h3>RTP 与奖池</h3>
<div className="ops-form-grid">
<NumberField
form={form}
label="目标 RTP (%)"
name="target_rtp_percent"
step="0.01"
onChange={updateField}
/>
<NumberField
form={form}
label="公共奖池 (%)"
name="pool_rate_percent"
step="0.01"
onChange={updateField}
/>
<NumberField
form={form}
label="平台盈利 (%)"
name="profit_rate_percent"
step="0.01"
onChange={updateField}
/>
<NumberField
form={form}
helperText="必须与该奖池实际送礼类型lucky / super_lucky在各适用地区的返币比例一致否则开奖账务校验会失败"
label="主播收益 (%)"
name="anchor_rate_percent"
step="0.01"
onChange={updateField}
/>
<NumberField
form={form}
label="控制带宽 (%)"
name="control_band_percent"
step="0.01"
onChange={updateField}
/>
<NumberField
form={form}
label="结算窗口流水"
name="settlement_window_wager"
onChange={updateField}
/>
<NumberField
form={form}
label="礼物参考金额"
name="gift_price_reference"
onChange={updateField}
/>
<NumberField
form={form}
label="新手等价抽数"
name="novice_max_equivalent_draws"
onChange={updateField}
/>
<NumberField
form={form}
label="普通等价抽数"
name="normal_max_equivalent_draws"
onChange={updateField}
/>
</div>
</section>
{form.strategy_version === "dynamic_v3" ? (
<DynamicStrategySections form={form} onChange={updateField} />
) : null}
</aside>
<section className="ops-stage-designer" aria-label="阶段奖档设计">
<div className="ops-stage-tabs" role="tablist" aria-label="阶段奖档">
{stageSummaries.map((summary) => (
<button
aria-selected={summary.stageKey === activeStageKey}
className={`ops-stage-tab ${summary.stageKey === activeStageKey ? "is-active" : ""} ${summary.ok ? "is-ok" : "is-warn"}`}
key={summary.stageKey}
role="tab"
type="button"
onClick={() => setActiveStage(summary.stageKey)}
>
<span>{summary.tabLabel}</span>
</button>
))}
</div>
{activeStageConfig ? (
<div className="ops-stage-stack">
<div className="ops-stage-block__head">
<div>
<h3>{activeSummary?.stageLabel}</h3>
<OpsStatusBadge
label={`${activeSummary?.status} · 概率 ${formatDecimal(activeSummary?.probability)}% · 期望 RTP ${formatDecimal(activeSummary?.expected)}%`}
tone={activeSummary?.ok ? "succeeded" : "warning"}
/>
</div>
<Button
startIcon={<AddOutlined fontSize="small" />}
onClick={() => addStageTier(activeStageConfig.stage)}
>
添加奖档
</Button>
</div>
{form.strategy_version === "dynamic_v3" ? (
<div className="ops-stage-thresholds">
<TextField
disabled={activeStageConfig.stage === "novice"}
label="7 日充值门槛"
size="small"
slotProps={{ htmlInput: { min: 0, step: 1 } }}
type="number"
value={activeStageConfig.min_recharge_7d_coins}
onChange={(event) =>
updateStageField(
activeStageConfig.stage,
"min_recharge_7d_coins",
event.target.value,
)
}
/>
<TextField
disabled={activeStageConfig.stage === "novice"}
label="30 日充值门槛"
size="small"
slotProps={{ htmlInput: { min: 0, step: 1 } }}
type="number"
value={activeStageConfig.min_recharge_30d_coins}
onChange={(event) =>
updateStageField(
activeStageConfig.stage,
"min_recharge_30d_coins",
event.target.value,
)
}
/>
</div>
) : null}
<div className="ops-tier-table">
<div className="ops-tier-head" aria-hidden="true">
<span>倍率</span>
<span>概率 %</span>
<span>高水位</span>
<span>启用</span>
<span>操作</span>
</div>
{activeStageConfig.tiers.map((tier, index) => (
<div className="ops-tier-row" key={`${activeStageConfig.stage}-${index}`}>
<TextField
required
size="small"
slotProps={{
htmlInput: { "aria-label": "倍率", min: 0, step: "0.01" },
}}
type="number"
value={tier.multiplier}
onChange={(event) =>
updateStageTier(activeStageConfig.stage, index, {
// 10
highWaterOnly:
Number(event.target.value) >= 10 || tier.highWaterOnly,
multiplier: event.target.value,
})
}
/>
{/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */}
<TextField
size="small"
slotProps={{
htmlInput: { "aria-label": "概率 %", readOnly: true },
}}
value={formatDecimal(tier.probabilityPercent)}
/>
<Checkbox
checked={Boolean(tier.highWaterOnly)}
disabled={Number(tier.multiplier) >= 10}
size="small"
slotProps={{ input: { "aria-label": "高水位" } }}
onChange={(event) =>
updateStageTier(activeStageConfig.stage, index, {
highWaterOnly: event.target.checked,
})
}
/>
<Checkbox
checked={tier.enabled !== false}
size="small"
slotProps={{ input: { "aria-label": "启用" } }}
onChange={(event) =>
updateStageTier(activeStageConfig.stage, index, {
enabled: event.target.checked,
})
}
/>
<Button
disabled={activeStageConfig.tiers.length <= 1}
variant="danger"
onClick={() => removeStageTier(activeStageConfig.stage, index)}
>
删除
</Button>
</div>
))}
</div>
</div>
) : null}
</section>
</div>
</DialogContent>
<DialogActions>
<Button disabled={saving} onClick={onClose}>
取消
</Button>
<Button disabled={saving} type="submit" variant="primary">
{saving ? "保存中" : "保存配置"}
</Button>
</DialogActions>
</form>
</Dialog>
);
}
function DynamicStrategySections({ form, onChange }) {
return (
<>
<section className="ops-config-section">
<h3>动态水位</h3>
<div className="ops-form-grid">
<NumberField form={form} label="初始奖池金币" name="initial_pool_coins" onChange={onChange} />
<NumberField form={form} label="连续未中奖保护" name="loss_streak_guarantee" onChange={onChange} />
<NumberField form={form} label="低水位金币" name="low_watermark_coins" onChange={onChange} />
<NumberField
form={form}
label="低水位非零因子 (%)"
name="low_water_nonzero_factor_percent"
step="0.01"
onChange={onChange}
/>
<NumberField form={form} label="高水位金币" name="high_watermark_coins" onChange={onChange} />
<NumberField
form={form}
label="高水位非零因子 (%)"
name="high_water_nonzero_factor_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="充值加权窗口 (ms)"
name="recharge_boost_window_ms"
onChange={onChange}
/>
<NumberField
form={form}
label="充值加权因子 (%)"
name="recharge_boost_factor_percent"
step="0.01"
onChange={onChange}
/>
</div>
) : null}
</section>
</div>
</DialogContent>
<DialogActions>
<Button disabled={saving} onClick={onClose}>
取消
</Button>
<Button disabled={saving} type="submit" variant="primary">
{saving ? "保存中" : "保存配置"}
</Button>
</DialogActions>
</form>
</Dialog>
);
<section className="ops-config-section">
<h3>大奖控制</h3>
<div className="ops-form-grid">
<TextField
className="ops-form-grid__wide"
helperText="按从小到大填写,使用逗号分隔"
label="大奖倍率"
required
size="small"
value={form.jackpot_multipliers}
onChange={(event) => onChange("jackpot_multipliers", event.target.value)}
/>
<NumberField
form={form}
label="全局大奖 RTP 上限 (%)"
name="jackpot_global_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="用户每日 RTP 上限 (%)"
name="jackpot_user_day_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="用户 72 小时 RTP 上限 (%)"
name="jackpot_user_72h_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="用户每日大奖次数"
name="max_jackpot_hits_per_user_day"
onChange={onChange}
/>
<NumberField
form={form}
label="50 美元等值消费门槛"
name="jackpot_spend_threshold_coins"
onChange={onChange}
/>
</div>
</section>
<section className="ops-config-section">
<h3>六维返奖上限</h3>
<div className="ops-form-grid">
<NumberField form={form} label="单次返奖上限" name="max_single_payout" onChange={onChange} />
<NumberField form={form} label="用户小时上限" name="user_hourly_payout_cap" onChange={onChange} />
<NumberField form={form} label="用户每日上限" name="user_daily_payout_cap" onChange={onChange} />
<NumberField form={form} label="设备每日上限" name="device_daily_payout_cap" onChange={onChange} />
<NumberField form={form} label="房间小时上限" name="room_hourly_payout_cap" onChange={onChange} />
<NumberField form={form} label="主播每日上限" name="anchor_daily_payout_cap" onChange={onChange} />
</div>
</section>
</>
);
}
function ConfigMetric({ label, value }) {
return (
<article className="ops-config-metric">
<span>{label}</span>
<strong>{value}</strong>
</article>
);
return (
<article className="ops-config-metric">
<span>{label}</span>
<strong>{value}</strong>
</article>
);
}
function NumberField({ form, label, name, onChange, step = "1" }) {
return (
<TextField
label={label}
required
size="small"
slotProps={{ htmlInput: { min: 0, step } }}
type="number"
value={form[name]}
onChange={(event) => onChange(name, event.target.value)}
/>
);
function NumberField({ form, helperText, label, name, onChange, step = "1" }) {
return (
<TextField
helperText={helperText}
label={label}
required
size="small"
slotProps={{ htmlInput: { min: 0, step } }}
type="number"
value={form[name]}
onChange={(event) => onChange(name, event.target.value)}
/>
);
}

View File

@ -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;
}

View File

@ -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,
};
}

View File

@ -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 优先级必须高于 hoverhover 到别的项时当前页仍要保持高亮底色 */
.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;
}
}

View File

@ -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 (
<AdminListPage>
<LuckyGiftConfigSummary
canUpdate={page.abilities.canUpdate}
config={page.config}
configLoading={page.configLoading}
poolId={page.poolId}
poolOptions={page.poolOptions}
onAddPool={page.openAddPool}
onEdit={page.openConfigDrawer}
onPoolChange={page.changePoolId}
onRefresh={page.reloadConfig}
/>
<LuckyGiftSimulationPanel config={page.config} />
<LuckyGiftDrawSummaryPanel page={page} />
<LuckyGiftConfigDrawer
abilities={page.abilities}
configLoading={page.configLoading}
configSaving={page.configSaving}
form={page.form}
open={page.configDrawerOpen}
setForm={page.setForm}
onClose={page.closeConfigDrawer}
onSubmit={page.submitConfig}
/>
<AddPoolDialog
disabled={page.configSaving}
open={page.addPoolOpen}
value={page.addPoolId}
onChange={page.setAddPoolId}
onClose={page.closeAddPool}
onSubmit={page.submitAddPool}
/>
</AdminListPage>
);
}
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 (
<section className={styles.drawSummaryPanel}>
<header className={styles.drawSummaryHeader}>
<div className={styles.stack}>
<h2 className={styles.sectionTitle}>抽奖数据概览</h2>
<span className={styles.meta}>奖池 {summary.poolId || page.poolId}</span>
</div>
<Button
disabled={page.drawSummaryLoading}
startIcon={<RefreshOutlined fontSize="small" />}
onClick={page.reloadDrawSummary}
>
刷新
</Button>
</header>
<div className={styles.drawSummaryFilters}>
<TextField className={styles.filterInput} disabled label="当前奖池" value={page.poolId} />
<TextField
className={styles.filterInput}
label="礼物 ID"
value={page.giftId}
onChange={(event) => page.changeGiftId(event.target.value)}
/>
<TextField
className={styles.filterInput}
label="用户 ID"
value={page.userId}
onChange={(event) => page.changeUserId(event.target.value)}
/>
<TextField
className={styles.filterInput}
label="房间 ID"
value={page.roomId}
onChange={(event) => page.changeRoomId(event.target.value)}
/>
<AdminFilterSelect
label="发放状态"
options={[["", "全部状态"], ...drawStatusOptions]}
value={page.status}
onChange={page.changeStatus}
/>
<AdminFilterResetButton
disabled={!page.giftId && !page.userId && !page.roomId && !page.status}
onClick={page.resetDrawFilters}
/>
</div>
{page.drawSummaryError ? (
<div className={styles.summaryError}>
<span>{page.drawSummaryError}</span>
<Button startIcon={<RefreshOutlined fontSize="small" />} onClick={page.reloadDrawSummary}>
重试
</Button>
</div>
) : (
<div className={styles.drawSummaryGrid} aria-busy={page.drawSummaryLoading}>
{stats.map((item) => (
<div className={styles.metricItem} key={item.label}>
<span>{item.label}</span>
<strong>{page.drawSummaryLoading ? "-" : item.value}</strong>
<small>{page.drawSummaryLoading ? "" : item.hint}</small>
</div>
))}
</div>
)}
</section>
);
}
function AddPoolDialog({ disabled, onChange, onClose, onSubmit, open, value }) {
return (
<Dialog fullWidth maxWidth="xs" open={open} onClose={disabled ? undefined : onClose}>
<form onSubmit={onSubmit}>
<DialogTitle>添加奖池</DialogTitle>
<DialogContent>
<TextField
autoFocus
fullWidth
required
disabled={disabled}
label="奖池 ID"
margin="dense"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
</DialogContent>
<DialogActions>
<Button disabled={disabled} type="button" onClick={onClose}>
取消
</Button>
<Button disabled={disabled} type="submit" variant="primary">
添加并编辑
</Button>
</DialogActions>
</form>
</Dialog>
);
}
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 <p role="status">正在打开新版幸运礼物配置</p>;
}

View File

@ -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");
});

View File

@ -233,6 +233,7 @@ export const API_OPERATIONS = {
listLoginLogs: "listLoginLogs",
listLuckyGiftConfigs: "listLuckyGiftConfigs",
listLuckyGiftDraws: "listLuckyGiftDraws",
listLuckyGiftPoolBalances: "listLuckyGiftPoolBalances",
listManagers: "listManagers",
listOperationLogs: "listOperationLogs",
listPermissions: "listPermissions",
@ -1329,14 +1330,14 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
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"]
},
@ -1936,14 +1937,21 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
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"]
},
@ -3098,7 +3106,7 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
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"]
},

View File

@ -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;
@ -8210,6 +8226,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;