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

This commit is contained in:
zhx 2026-07-15 14:49:39 +08:00
parent ee900d0127
commit 9b7df9b8ac
14 changed files with 2620 additions and 1495 deletions

View File

@ -320,7 +320,7 @@
"x-permissions": ["red-packet:update"] "x-permissions": ["red-packet:update"]
} }
}, },
"/admin/activity/lucky-gifts/v2/config": { "/admin/ops-center/lucky-gifts/config": {
"get": { "get": {
"operationId": "getLuckyGiftConfig", "operationId": "getLuckyGiftConfig",
"responses": { "responses": {
@ -342,7 +342,7 @@
"x-permissions": ["lucky-gift:update"] "x-permissions": ["lucky-gift:update"]
} }
}, },
"/admin/activity/lucky-gifts/v2/configs": { "/admin/ops-center/lucky-gifts/configs": {
"get": { "get": {
"operationId": "listLuckyGiftConfigs", "operationId": "listLuckyGiftConfigs",
"responses": { "responses": {
@ -354,7 +354,7 @@
"x-permissions": ["lucky-gift:view"] "x-permissions": ["lucky-gift:view"]
} }
}, },
"/admin/activity/lucky-gifts/v2/draws": { "/admin/ops-center/lucky-gifts/draws": {
"get": { "get": {
"operationId": "listLuckyGiftDraws", "operationId": "listLuckyGiftDraws",
"responses": { "responses": {
@ -366,7 +366,7 @@
"x-permissions": ["lucky-gift:view"] "x-permissions": ["lucky-gift:view"]
} }
}, },
"/admin/activity/lucky-gifts/v2/draw-summary": { "/admin/ops-center/lucky-gifts/summary": {
"get": { "get": {
"operationId": "getLuckyGiftDrawSummary", "operationId": "getLuckyGiftDrawSummary",
"responses": { "responses": {
@ -378,6 +378,18 @@
"x-permissions": ["lucky-gift:view"] "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": { "/admin/activity/wheel/config": {
"get": { "get": {
"operationId": "getWheelConfig", "operationId": "getWheelConfig",

View File

@ -20,19 +20,26 @@ const views = [
{ icon: InsightsOutlined, key: "overview", label: "数据总览" }, { icon: InsightsOutlined, key: "overview", label: "数据总览" },
{ icon: CardGiftcardOutlined, key: "configs", label: "幸运礼物配置" }, { icon: CardGiftcardOutlined, key: "configs", label: "幸运礼物配置" },
{ icon: CasinoOutlined, key: "draws", label: "抽奖记录" }, { icon: CasinoOutlined, key: "draws", label: "抽奖记录" },
{ icon: AppsOutlined, key: "apps", label: "应用列表" } { icon: AppsOutlined, key: "apps", label: "应用列表" },
]; ];
const viewKeys = new Set(views.map((view) => view.key));
const initialDashboard = { const initialDashboard = {
apps: [], apps: [],
appSummaries: [], appSummaries: [],
luckyGifts: [], luckyGifts: [],
poolBalances: [], poolBalances: [],
summary: {} summary: {},
}; };
export function initialOpsCenterView(search = globalThis.location?.search || "") {
const requested = new URLSearchParams(search).get("view") || "";
return viewKeys.has(requested) ? requested : "overview";
}
export function OpsCenterApp() { export function OpsCenterApp() {
const [activeView, setActiveView] = useState("overview"); // ?view=configs HTML
const [activeView, setActiveView] = useState(() => initialOpsCenterView());
const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true }); const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true });
const [dialog, setDialog] = useState(null); const [dialog, setDialog] = useState(null);
const [savingConfig, setSavingConfig] = useState(false); const [savingConfig, setSavingConfig] = useState(false);
@ -49,7 +56,7 @@ export function OpsCenterApp() {
data: current.data || initialDashboard, data: current.data || initialDashboard,
error: error.message || "运营接口暂不可用", error: error.message || "运营接口暂不可用",
loaded: current.loaded, loaded: current.loaded,
loading: false loading: false,
})); }));
} }
}, []); }, []);
@ -61,8 +68,12 @@ export function OpsCenterApp() {
const { data } = state; const { data } = state;
const appOptions = useMemo( const appOptions = useMemo(
() => data.apps.map((item) => [item.app_code ?? item.appCode, `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`]), () =>
[data.apps] data.apps.map((item) => [
item.app_code ?? item.appCode,
`${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`,
]),
[data.apps],
); );
// / // /
@ -83,18 +94,31 @@ export function OpsCenterApp() {
}, [data.luckyGifts, data.poolBalances]); }, [data.luckyGifts, data.poolBalances]);
const openCreateConfig = useCallback(() => { const openCreateConfig = useCallback(() => {
// 稿 const firstAppCode = data.apps
// "" .map((item) =>
const template = data.luckyGifts[0]; String(item.app_code ?? item.appCode ?? "")
if (!template) { .trim()
.toLowerCase(),
)
.find(Boolean);
if (!firstAppCode) {
showToast("暂无可添加配置的应用", "warning"); showToast("暂无可添加配置的应用", "warning");
return; return;
} }
// App App
// configToForm disabled dynamic_v3 稿 0
setDialog({ setDialog({
config: { ...structuredClone(template), is_default: true, pool_id: DEFAULT_POOL_ID, rule_version: 0 }, config: {
mode: "create" app_code: firstAppCode,
enabled: false,
is_default: true,
pool_id: DEFAULT_POOL_ID,
rule_version: 0,
strategy_version: "dynamic_v3",
},
mode: "create",
}); });
}, [data.luckyGifts, showToast]); }, [data.apps, showToast]);
const openEditConfig = useCallback((config) => { const openEditConfig = useCallback((config) => {
setDialog({ config: structuredClone(config), mode: "edit" }); setDialog({ config: structuredClone(config), mode: "edit" });
@ -115,7 +139,7 @@ export function OpsCenterApp() {
setSavingConfig(false); setSavingConfig(false);
} }
}, },
[loadDashboard, showToast] [loadDashboard, showToast],
); );
const defaultAppCode = data.apps[0]?.app_code ?? data.apps[0]?.appCode ?? ""; const defaultAppCode = data.apps[0]?.app_code ?? data.apps[0]?.appCode ?? "";
@ -124,8 +148,14 @@ export function OpsCenterApp() {
<OpsShell activeView={activeView} onViewChange={setActiveView} views={views}> <OpsShell activeView={activeView} onViewChange={setActiveView} views={views}>
<div className="ops-page"> <div className="ops-page">
<PageHead meta="跨应用幸运礼物配置与抽奖数据面板" title="运营配置中心"> <PageHead meta="跨应用幸运礼物配置与抽奖数据面板" title="运营配置中心">
<span className={state.loading ? "ops-status is-loading" : "ops-status"}>{state.loading ? "同步中" : "已就绪"}</span> <span className={state.loading ? "ops-status is-loading" : "ops-status"}>
<Button disabled={state.loading} startIcon={<RefreshOutlined fontSize="small" />} onClick={loadDashboard}> {state.loading ? "同步中" : "已就绪"}
</span>
<Button
disabled={state.loading}
startIcon={<RefreshOutlined fontSize="small" />}
onClick={loadDashboard}
>
刷新 刷新
</Button> </Button>
</PageHead> </PageHead>
@ -135,7 +165,11 @@ export function OpsCenterApp() {
{state.error} {state.error}
</div> </div>
) : null} ) : null}
<DataState error={state.loaded ? "" : state.error} loading={state.loading && !state.loaded} onRetry={loadDashboard}> <DataState
error={state.loaded ? "" : state.error}
loading={state.loading && !state.loaded}
onRetry={loadDashboard}
>
{activeView === "overview" ? <OverviewView data={data} /> : null} {activeView === "overview" ? <OverviewView data={data} /> : null}
{activeView === "configs" ? ( {activeView === "configs" ? (
<ConfigsView <ConfigsView
@ -146,7 +180,9 @@ export function OpsCenterApp() {
onEdit={openEditConfig} onEdit={openEditConfig}
/> />
) : null} ) : null}
{activeView === "draws" ? <DrawsView apps={data.apps} defaultAppCode={defaultAppCode} poolIDsByApp={poolIDsByApp} /> : null} {activeView === "draws" ? (
<DrawsView apps={data.apps} defaultAppCode={defaultAppCode} poolIDsByApp={poolIDsByApp} />
) : null}
{activeView === "apps" ? <AppsView apps={data.apps} /> : null} {activeView === "apps" ? <AppsView apps={data.apps} /> : null}
</DataState> </DataState>
{dialog ? ( {dialog ? (

View File

@ -2,17 +2,18 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
import { beforeEach, expect, test, vi } from "vitest"; import { beforeEach, expect, test, vi } from "vitest";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
import { fetchLuckyGiftDraws, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js"; import { fetchLuckyGiftDraws, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
import { OpsCenterApp } from "./OpsCenterApp.jsx"; import { initialOpsCenterView, OpsCenterApp } from "./OpsCenterApp.jsx";
vi.mock("./api.js", () => ({ vi.mock("./api.js", () => ({
DEFAULT_POOL_ID: "default", DEFAULT_POOL_ID: "default",
fetchLuckyGiftDraws: vi.fn(), fetchLuckyGiftDraws: vi.fn(),
fetchOpsDashboard: vi.fn(), fetchOpsDashboard: vi.fn(),
saveLuckyGiftConfig: vi.fn() saveLuckyGiftConfig: vi.fn(),
})); }));
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
window.history.replaceState({}, "", "/");
saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" }); saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" });
fetchLuckyGiftDraws.mockResolvedValue({ fetchLuckyGiftDraws.mockResolvedValue({
items: [ items: [
@ -26,17 +27,17 @@ beforeEach(() => {
multiplier_ppm: 2_000_000, multiplier_ppm: 2_000_000,
pool_id: "super_lucky", pool_id: "super_lucky",
reward_status: "granted", reward_status: "granted",
rule_version: 2 rule_version: 2,
} },
], ],
page: 1, page: 1,
pageSize: 20, pageSize: 20,
total: 1 total: 1,
}); });
fetchOpsDashboard.mockResolvedValue({ fetchOpsDashboard.mockResolvedValue({
apps: [ apps: [
{ app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 }, { app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 },
{ app_code: "yumi", app_name: "Yumi", source: "fixed", status: "active" } { app_code: "yumi", app_name: "Yumi", source: "fixed", status: "active" },
], ],
appSummaries: [ appSummaries: [
{ {
@ -49,17 +50,53 @@ beforeEach(() => {
total_reward_coins: 900, total_reward_coins: 900,
total_spent_coins: 1000, total_spent_coins: 1000,
unique_rooms: 2, unique_rooms: 2,
unique_users: 2 unique_users: 2,
} },
], ],
luckyGifts: [ 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 }, ...mockDynamicConfig(),
{ app_code: "yumi", enabled: false, is_default: true, pool_id: "default", rule_version: 0, target_rtp_percent: 95 } 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: [ 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 } 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: { summary: {
activeApps: 2, activeApps: 2,
@ -73,8 +110,8 @@ beforeEach(() => {
poolReserveTotal: 630000, poolReserveTotal: 630000,
totalDraws: 3, totalDraws: 3,
totalRewardCoins: 900, totalRewardCoins: 900,
totalSpentCoins: 1000 totalSpentCoins: 1000,
} },
}); });
}); });
@ -82,7 +119,7 @@ function renderApp() {
return render( return render(
<ToastProvider> <ToastProvider>
<OpsCenterApp /> <OpsCenterApp />
</ToastProvider> </ToastProvider>,
); );
} }
@ -105,6 +142,17 @@ test("renders overview with aggregated kpis and pool balances", async () => {
expect(fetchOpsDashboard).toHaveBeenCalledTimes(1); 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 () => { test("lists every pool config including published non-default pools", async () => {
renderApp(); renderApp();
await screen.findByText("运营应用"); await screen.findByText("运营应用");
@ -121,6 +169,21 @@ test("lists every pool config including published non-default pools", async () =
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 () => { test("opens config dialog from a published pool row and saves a new version", async () => {
renderApp(); renderApp();
await screen.findByText("运营应用"); await screen.findByText("运营应用");
@ -131,6 +194,9 @@ test("opens config dialog from a published pool row and saves a new version", as
fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]); fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]);
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("新增幸运礼物版本")).toBeTruthy(); 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); expect(within(dialog).getAllByLabelText("概率 %")[0].readOnly).toBe(true);
fireEvent.change(within(dialog).getByLabelText(/目标 RTP \(%\)/), { target: { value: "88" } }); fireEvent.change(within(dialog).getByLabelText(/目标 RTP \(%\)/), { target: { value: "88" } });
@ -140,14 +206,112 @@ test("opens config dialog from a published pool row and saves a new version", as
expect(saveLuckyGiftConfig).toHaveBeenCalledWith( expect(saveLuckyGiftConfig).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
app_code: "lalu", app_code: "lalu",
anchor_rate_percent: 1,
jackpot_multipliers: [200, 500, 1000],
pool_id: "lucky", pool_id: "lucky",
profit_rate_percent: 1,
stages: expect.arrayContaining([expect.objectContaining({ stage: "novice" })]), stages: expect.arrayContaining([expect.objectContaining({ stage: "novice" })]),
target_rtp_percent: 88 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 () => { test("loads draw records for the first app with server pagination", async () => {
renderApp(); renderApp();
await screen.findByText("运营应用"); await screen.findByText("运营应用");
@ -156,7 +320,7 @@ test("loads draw records for the first app with server pagination", async () =>
await waitFor(() => { await waitFor(() => {
expect(fetchLuckyGiftDraws).toHaveBeenCalledWith( expect(fetchLuckyGiftDraws).toHaveBeenCalledWith(
expect.objectContaining({ appCode: "lalu", page: 1, poolId: "", status: "" }) expect.objectContaining({ appCode: "lalu", page: 1, poolId: "", status: "" }),
); );
}); });
expect(await screen.findByText("ext-1")).toBeTruthy(); expect(await screen.findByText("ext-1")).toBeTruthy();

View File

@ -15,10 +15,10 @@ export async function fetchOpsDashboard(query = {}) {
const [luckyGifts, poolBalances, drawSummary] = await Promise.all([ const [luckyGifts, poolBalances, drawSummary] = await Promise.all([
fetchAppLuckyGiftConfigs(appCode), fetchAppLuckyGiftConfigs(appCode),
opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems), opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems),
opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } }) opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } }),
]); ]);
return { appCode, drawSummary, luckyGifts, poolBalances }; return { appCode, drawSummary, luckyGifts, poolBalances };
}) }),
); );
return normalizeDashboard({ apps, perApp }); return normalizeDashboard({ apps, perApp });
@ -35,17 +35,27 @@ async function fetchAppLuckyGiftConfigs(appCode) {
} }
// 一个已发布配置都没有的应用仍要展示一行可编辑草稿,让运营从这里发布第一版; // 一个已发布配置都没有的应用仍要展示一行可编辑草稿,让运营从这里发布第一版;
// 草稿的默认参数RTP、回收率、结算窗口以服务端 DefaultRuleConfig 为准,不在前端伪造。 // 草稿的默认参数RTP、回收率、结算窗口以服务端 DefaultRuleConfig 为准,不在前端伪造。
const draft = normalizeLuckyGiftConfig(await opsRequest("/lucky-gifts/config", { query: { app_code: appCode } }), appCode); const draft = normalizeLuckyGiftConfig(
await opsRequest("/lucky-gifts/config", { query: { app_code: appCode } }),
appCode,
);
return draft.app_code ? [draft] : []; return draft.app_code ? [draft] : [];
} }
export async function fetchLuckyGiftDraws({ appCode, page = 1, pageSize = 20, poolId = "", status = "", userId = "" } = {}) { export async function fetchLuckyGiftDraws({
appCode,
page = 1,
pageSize = 20,
poolId = "",
status = "",
userId = "",
} = {}) {
const query = { const query = {
app_code: appCode, app_code: appCode,
page, page,
page_size: pageSize, page_size: pageSize,
pool_id: poolId, pool_id: poolId,
status status,
}; };
// 同一个输入框同时支持内部数字 UID 和外部接入方的字符串 ID纯数字走 user_id 精确匹配, // 同一个输入框同时支持内部数字 UID 和外部接入方的字符串 ID纯数字走 user_id 精确匹配,
// 其余走 external_user_id避免运营要先分辨用户来源再选字段。 // 其余走 external_user_id避免运营要先分辨用户来源再选字段。
@ -61,7 +71,7 @@ export async function fetchLuckyGiftDraws({ appCode, page = 1, pageSize = 20, po
items: normalizeItems(payload), items: normalizeItems(payload),
page: numberValue(payload.page) || page, page: numberValue(payload.page) || page,
pageSize: numberValue(payload.pageSize ?? payload.page_size) || pageSize, pageSize: numberValue(payload.pageSize ?? payload.page_size) || pageSize,
total: numberValue(payload.total) total: numberValue(payload.total),
}; };
} }
@ -72,8 +82,8 @@ export async function saveLuckyGiftConfig(config = {}) {
method: "PUT", method: "PUT",
query: { query: {
app_code: payload.app_code, app_code: payload.app_code,
pool_id: payload.pool_id pool_id: payload.pool_id,
} },
}); });
} }
@ -82,7 +92,7 @@ export async function opsRequest(path, { body, method = body ? "POST" : "GET", q
body: body === undefined ? undefined : JSON.stringify(body), body: body === undefined ? undefined : JSON.stringify(body),
credentials: "include", credentials: "include",
headers: buildHeaders(body), headers: buildHeaders(body),
method method,
}); });
const payload = await readPayload(response); const payload = await readPayload(response);
@ -130,7 +140,7 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
poolReserveTotal: sumBy(poolBalances, "reserve_floor", "reserveFloor"), poolReserveTotal: sumBy(poolBalances, "reserve_floor", "reserveFloor"),
totalDraws: sumBy(appSummaries, "total_draws"), totalDraws: sumBy(appSummaries, "total_draws"),
totalRewardCoins: sumBy(appSummaries, "total_reward_coins"), totalRewardCoins: sumBy(appSummaries, "total_reward_coins"),
totalSpentCoins: sumBy(appSummaries, "total_spent_coins") totalSpentCoins: sumBy(appSummaries, "total_spent_coins"),
}; };
return { apps, appSummaries, luckyGifts, poolBalances, summary }; return { apps, appSummaries, luckyGifts, poolBalances, summary };
@ -139,7 +149,9 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
function normalizeDrawSummary(summary = {}, appCode = "") { function normalizeDrawSummary(summary = {}, appCode = "") {
return { return {
actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm), actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm),
app_code: String(appCode || summary.app_code || "").trim().toLowerCase(), app_code: String(appCode || summary.app_code || "")
.trim()
.toLowerCase(),
failed_draws: numberValue(summary.failed_draws ?? summary.failedDraws), failed_draws: numberValue(summary.failed_draws ?? summary.failedDraws),
granted_draws: numberValue(summary.granted_draws ?? summary.grantedDraws), granted_draws: numberValue(summary.granted_draws ?? summary.grantedDraws),
pending_draws: numberValue(summary.pending_draws ?? summary.pendingDraws), pending_draws: numberValue(summary.pending_draws ?? summary.pendingDraws),
@ -147,7 +159,7 @@ function normalizeDrawSummary(summary = {}, appCode = "") {
total_reward_coins: numberValue(summary.total_reward_coins ?? summary.totalRewardCoins), total_reward_coins: numberValue(summary.total_reward_coins ?? summary.totalRewardCoins),
total_spent_coins: numberValue(summary.total_spent_coins ?? summary.totalSpentCoins), total_spent_coins: numberValue(summary.total_spent_coins ?? summary.totalSpentCoins),
unique_rooms: numberValue(summary.unique_rooms ?? summary.uniqueRooms), unique_rooms: numberValue(summary.unique_rooms ?? summary.uniqueRooms),
unique_users: numberValue(summary.unique_users ?? summary.uniqueUsers) unique_users: numberValue(summary.unique_users ?? summary.uniqueUsers),
}; };
} }
@ -155,7 +167,9 @@ function uniqueAppCodes(apps) {
const seen = new Set(); const seen = new Set();
const codes = []; const codes = [];
apps.forEach((item) => { apps.forEach((item) => {
const code = String(item?.app_code || item?.appCode || "").trim().toLowerCase(); const code = String(item?.app_code || item?.appCode || "")
.trim()
.toLowerCase();
if (!code || seen.has(code)) { if (!code || seen.has(code)) {
return; return;
} }
@ -166,7 +180,9 @@ function uniqueAppCodes(apps) {
} }
function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") { function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") {
const appCode = String(config.app_code || config.appCode || fallbackAppCode).trim().toLowerCase(); const appCode = String(config.app_code || config.appCode || fallbackAppCode)
.trim()
.toLowerCase();
const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion); const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion);
return { return {
...config, ...config,
@ -174,26 +190,79 @@ function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") {
// rule_version <= 0 表示服务端返回的未发布草稿:列表要标成"默认草稿"并禁止当作已生效规则解读。 // rule_version <= 0 表示服务端返回的未发布草稿:列表要标成"默认草稿"并禁止当作已生效规则解读。
is_default: ruleVersion <= 0, is_default: ruleVersion <= 0,
pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID, pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID,
rule_version: ruleVersion rule_version: ruleVersion,
// 升级前规则没有 strategy_versionlucky-gift owner 明确按 fixed_v2 解释;列表和编辑表单必须沿用该兼容语义。
strategy_version: String(config.strategy_version || config.strategyVersion || "fixed_v2")
.trim()
.toLowerCase(),
}; };
} }
function luckyGiftConfigPayload(config = {}) { function luckyGiftConfigPayload(config = {}) {
const appCode = String(config.app_code || config.appCode || "").trim().toLowerCase(); 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 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 { 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, app_code: appCode,
control_band_percent: numberValue(config.control_band_percent ?? config.controlBandPercent), control_band_percent: numberValue(config.control_band_percent ?? config.controlBandPercent),
effective_from_ms: numberValue(config.effective_from_ms ?? config.effectiveFromMS), 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), enabled: Boolean(config.enabled),
gift_price_reference: numberValue(config.gift_price_reference ?? config.giftPriceReference), 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), normal_max_equivalent_draws: numberValue(config.normal_max_equivalent_draws ?? config.normalMaxEquivalentDraws),
novice_max_equivalent_draws: numberValue(config.novice_max_equivalent_draws ?? config.noviceMaxEquivalentDraws), novice_max_equivalent_draws: numberValue(config.novice_max_equivalent_draws ?? config.noviceMaxEquivalentDraws),
pool_id: poolID, pool_id: poolID,
pool_rate_percent: numberValue(config.pool_rate_percent ?? config.poolRatePercent), 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), settlement_window_wager: numberValue(config.settlement_window_wager ?? config.settlementWindowWager),
stages: normalizeStages(config.stages), stages: normalizeStages(config.stages),
target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent) 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),
}; };
} }
@ -237,7 +306,10 @@ function normalizeItems(value) {
} }
function sumBy(items, key, fallbackKey) { 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) { function numberValue(value) {
@ -245,11 +317,22 @@ function numberValue(value) {
return Number.isFinite(parsed) ? parsed : 0; return Number.isFinite(parsed) ? parsed : 0;
} }
function numberArray(values) {
if (!Array.isArray(values)) {
return [];
}
// 不在传输层静默删除 0/负倍率;表单负责拦截,若有遗漏也应让 owner 返回明确校验错误,
// 否则数组位置变化会掩盖运营实际提交的错误值。
return values.map(numberValue);
}
function normalizeStages(stages) { function normalizeStages(stages) {
if (!Array.isArray(stages)) { if (!Array.isArray(stages)) {
return []; return [];
} }
return stages.map((stage) => ({ 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 || "", stage: stage.stage || stage.Stage || "",
tiers: Array.isArray(stage.tiers) tiers: Array.isArray(stage.tiers)
? stage.tiers.map((tier) => ({ ? stage.tiers.map((tier) => ({
@ -257,8 +340,8 @@ function normalizeStages(stages) {
high_water_only: Boolean(tier.high_water_only ?? tier.highWaterOnly), high_water_only: Boolean(tier.high_water_only ?? tier.highWaterOnly),
multiplier: numberValue(tier.multiplier), multiplier: numberValue(tier.multiplier),
probability_percent: numberValue(tier.probability_percent ?? tier.probabilityPercent), probability_percent: numberValue(tier.probability_percent ?? tier.probabilityPercent),
tier_id: tier.tier_id || tier.tierId || "" tier_id: tier.tier_id || tier.tierId || "",
})) }))
: [] : [],
})); }));
} }

View File

@ -8,7 +8,7 @@ afterEach(() => {
function jsonResponse(data) { function jsonResponse(data) {
return new Response(JSON.stringify({ code: 0, data }), { return new Response(JSON.stringify({ code: 0, data }), {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
status: 200 status: 200,
}); });
} }
@ -31,19 +31,39 @@ test("loads every pool config per app through the plural configs endpoint", asyn
// lalu 在 default 之外还有 lucky/super_lucky 两个已发布奖池,复数接口必须全部带回。 // lalu 在 default 之外还有 lucky/super_lucky 两个已发布奖池,复数接口必须全部带回。
return jsonResponse([ return jsonResponse([
{ app_code: "lalu", enabled: true, pool_id: "lucky", rule_version: 3, target_rtp_percent: 95 }, { 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 } { app_code: "lalu", enabled: false, pool_id: "super_lucky", rule_version: 2, target_rtp_percent: 92 },
]); ]);
} }
if (text.includes("/lucky-gifts/pools?")) { if (text.includes("/lucky-gifts/pools?")) {
return jsonResponse({ return jsonResponse({
items: [ 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 } 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?")) { 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({
granted_draws: 9,
pending_draws: 1,
total_draws: 10,
total_reward_coins: 900,
total_spent_coins: 1000,
});
} }
return jsonResponse({ items: [] }); return jsonResponse({ items: [] });
}); });
@ -54,18 +74,18 @@ test("loads every pool config per app through the plural configs endpoint", asyn
"http://localhost:3000/api/v1/admin/ops-center/apps", "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/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/pools?app_code=lalu",
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu" "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu",
]); ]);
expect(data.luckyGifts).toEqual([ 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: "lucky", rule_version: 3 }),
expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "super_lucky", rule_version: 2 }) expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "super_lucky", rule_version: 2 }),
]); ]);
expect(data.summary).toMatchObject({ expect(data.summary).toMatchObject({
activeApps: 1, activeApps: 1,
configuredPools: 2, configuredPools: 2,
enabledPools: 1, enabledPools: 1,
poolBalanceTotal: 38941517, poolBalanceTotal: 38941517,
totalDraws: 10 totalDraws: 10,
}); });
}); });
@ -81,7 +101,13 @@ test("falls back to the server default draft when an app has no published config
return jsonResponse([]); return jsonResponse([]);
} }
if (text.includes("/lucky-gifts/config?")) { 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({
app_code: "yumi",
enabled: false,
pool_id: "default",
rule_version: 0,
target_rtp_percent: 95,
});
} }
return jsonResponse({ items: [] }); return jsonResponse({ items: [] });
}); });
@ -90,7 +116,7 @@ test("falls back to the server default draft when an app has no published config
expect(calls).toContain("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=yumi"); expect(calls).toContain("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=yumi");
expect(data.luckyGifts).toEqual([ expect(data.luckyGifts).toEqual([
expect.objectContaining({ app_code: "yumi", is_default: true, pool_id: "default", rule_version: 0 }) expect.objectContaining({ app_code: "yumi", is_default: true, pool_id: "default", rule_version: 0 }),
]); ]);
}); });
@ -123,24 +149,104 @@ test("saves lucky gift config through admin ops route", async () => {
}); });
await saveLuckyGiftConfig({ await saveLuckyGiftConfig({
anchor_daily_payout_cap: 12_312_000,
anchor_rate_percent: 1,
app_code: "aslan", app_code: "aslan",
pool_id: "default", control_band_percent: 3,
device_daily_payout_cap: 1_026_000,
enabled: true, enabled: true,
target_rtp_percent: 95,
pool_rate_percent: 96,
settlement_window_wager: 1_000_000,
control_band_percent: 1,
gift_price_reference: 100, gift_price_reference: 100,
stages: [{ stage: "novice", tiers: [{ multiplier: 1, probabilityPercent: 100, enabled: true }] }] 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].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(calls[0].url).toBe(
expect(JSON.parse(calls[0].body)).toMatchObject({ "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", app_code: "aslan",
control_band_percent: 3,
device_daily_payout_cap: 1_026_000,
effective_from_ms: 0,
enabled: true, 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_id: "default",
stages: [{ stage: "novice", tiers: [{ probability_percent: 100 }] }], pool_rate_percent: 98,
target_rtp_percent: 95 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

@ -11,13 +11,17 @@ export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
const [appFilter, setAppFilter] = useState(""); const [appFilter, setAppFilter] = useState("");
const appOptions = useMemo( const appOptions = useMemo(
() => apps.map((item) => [item.app_code ?? item.appCode, `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`]), () =>
[apps] apps.map((item) => [
item.app_code ?? item.appCode,
`${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`,
]),
[apps],
); );
// dashboard // dashboard
const items = useMemo( const items = useMemo(
() => (appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts), () => (appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts),
[appFilter, luckyGifts] [appFilter, luckyGifts],
); );
const columns = useMemo(() => buildColumns({ onEdit, saving }), [onEdit, saving]); const columns = useMemo(() => buildColumns({ onEdit, saving }), [onEdit, saving]);
@ -26,17 +30,29 @@ export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
<div className="ops-view"> <div className="ops-view">
<AdminListToolbar <AdminListToolbar
actions={ actions={
<Button disabled={saving} startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={onCreate}> <Button
disabled={saving}
startIcon={<AddOutlined fontSize="small" />}
variant="primary"
onClick={onCreate}
>
添加配置 添加配置
</Button> </Button>
} }
filters={<AdminFilterSelect label="应用" options={[["", "全部应用"], ...appOptions]} value={appFilter} onChange={setAppFilter} />} filters={
<AdminFilterSelect
label="应用"
options={[["", "全部应用"], ...appOptions]}
value={appFilter}
onChange={setAppFilter}
/>
}
/> />
<DataTable <DataTable
columns={columns} columns={columns}
emptyLabel="暂无幸运礼物配置" emptyLabel="暂无幸运礼物配置"
items={items} items={items}
minWidth="1080px" minWidth="1260px"
rowKey={(item) => `${item.app_code}:${item.pool_id}:${item.rule_version}`} rowKey={(item) => `${item.app_code}:${item.pool_id}:${item.rule_version}`}
title="幸运礼物配置" title="幸运礼物配置"
total={items.length} total={items.length}
@ -53,7 +69,15 @@ function buildColumns({ onEdit, saving }) {
key: "rule_version", key: "rule_version",
label: "版本", label: "版本",
render: (item) => (item.is_default ? "-" : `v${item.rule_version}`), render: (item) => (item.is_default ? "-" : `v${item.rule_version}`),
width: "minmax(72px, 0.5fr)" 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", key: "enabled",
@ -63,19 +87,33 @@ function buildColumns({ onEdit, saving }) {
if (item.is_default) { if (item.is_default) {
return <OpsStatusBadge label="默认草稿" tone="warning" />; return <OpsStatusBadge label="默认草稿" tone="warning" />;
} }
return item.enabled ? <OpsStatusBadge label="已启用" tone="succeeded" /> : <OpsStatusBadge label="已停用" />; return item.enabled ? (
<OpsStatusBadge label="已启用" tone="succeeded" />
) : (
<OpsStatusBadge label="已停用" />
);
}, },
width: "minmax(128px, 0.8fr)" width: "minmax(128px, 0.8fr)",
}, },
{ key: "target_rtp_percent", label: "目标 RTP", render: (item) => formatPercent(item.target_rtp_percent) }, { 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: "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: "control_band_percent", label: "控制带宽", render: (item) => formatPercent(item.control_band_percent) },
{ key: "settlement_window_wager", label: "结算窗口流水", render: (item) => formatNumber(item.settlement_window_wager) }, {
key: "settlement_window_wager",
label: "结算窗口流水",
render: (item) => formatNumber(item.settlement_window_wager),
},
{ {
key: "created_at_ms", key: "created_at_ms",
label: "发布时间", label: "发布时间",
render: (item) => (item.is_default ? "-" : <TimeText value={item.created_at_ms} />), render: (item) => (item.is_default ? "-" : <TimeText value={item.created_at_ms} />),
width: "minmax(176px, 1.1fr)" width: "minmax(176px, 1.1fr)",
}, },
{ {
key: "actions", key: "actions",
@ -84,7 +122,7 @@ function buildColumns({ onEdit, saving }) {
<Button disabled={saving} size="small" onClick={() => onEdit(item)}> <Button disabled={saving} size="small" onClick={() => onEdit(item)}>
{item.is_default ? "发布配置" : "新增版本"} {item.is_default ? "发布配置" : "新增版本"}
</Button> </Button>
) ),
} },
]; ];
} }

View File

@ -12,11 +12,13 @@ import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { Button } from "@/shared/ui/Button.jsx"; import { Button } from "@/shared/ui/Button.jsx";
import { formatDecimal } from "@/shared/utils/rtpProbability.js"; import { formatDecimal } from "@/shared/utils/rtpProbability.js";
import { import {
applyDynamicStrategyDefaults,
configToForm, configToForm,
correctAllStageProbabilities, correctAllStageProbabilities,
formToConfig, formToConfig,
nextStageMultiplier, nextStageMultiplier,
stageSummary stageSummary,
validateLuckyGiftForm,
} from "../configForm.js"; } from "../configForm.js";
import { formatNumber, formatPercent } from "../format.js"; import { formatNumber, formatPercent } from "../format.js";
import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
@ -24,21 +26,37 @@ import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) { export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) {
const [form, setForm] = useState(() => configToForm(config)); const [form, setForm] = useState(() => configToForm(config));
const [activeStage, setActiveStage] = useState("novice"); const [activeStage, setActiveStage] = useState("novice");
const [validationErrors, setValidationErrors] = useState([]);
const stageSummaries = useMemo(() => form.stages.map((stage) => stageSummary(stage, form)), [form]); 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 activeStageKey = form.stages.some((stage) => stage.stage === activeStage)
? activeStage
: form.stages[0]?.stage;
const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey); const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey);
const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey); const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey);
const updateField = (field, value) => { const updateField = (field, value) => {
setValidationErrors([]);
setForm((current) => { setForm((current) => {
const next = { ...current, [field]: value }; const next = { ...current, [field]: value };
if (field === "strategy_version" && value === "dynamic_v3" && current.strategy_version !== "dynamic_v3") {
return applyDynamicStrategyDefaults(next);
}
// RTP // RTP
return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next; return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next;
}); });
}; };
const updateStageField = (stageKey, field, value) => {
setValidationErrors([]);
setForm((current) => ({
...current,
stages: current.stages.map((stage) => (stage.stage === stageKey ? { ...stage, [field]: value } : stage)),
}));
};
const updateStageTier = (stageKey, tierIndex, patch) => { const updateStageTier = (stageKey, tierIndex, patch) => {
setValidationErrors([]);
setForm((current) => setForm((current) =>
correctAllStageProbabilities({ correctAllStageProbabilities({
...current, ...current,
@ -46,15 +64,18 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
stage.stage === stageKey stage.stage === stageKey
? { ? {
...stage, ...stage,
tiers: stage.tiers.map((tier, index) => (index === tierIndex ? { ...tier, ...patch } : tier)) tiers: stage.tiers.map((tier, index) =>
index === tierIndex ? { ...tier, ...patch } : tier,
),
} }
: stage : stage,
) ),
}) }),
); );
}; };
const addStageTier = (stageKey) => { const addStageTier = (stageKey) => {
setValidationErrors([]);
setForm((current) => setForm((current) =>
correctAllStageProbabilities({ correctAllStageProbabilities({
...current, ...current,
@ -70,29 +91,37 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
highWaterOnly: nextStageMultiplier(stage.tiers) >= 10, highWaterOnly: nextStageMultiplier(stage.tiers) >= 10,
multiplier: String(nextStageMultiplier(stage.tiers)), multiplier: String(nextStageMultiplier(stage.tiers)),
probabilityPercent: "0", probabilityPercent: "0",
tierId: "" tierId: "",
},
],
} }
] : stage,
} ),
: stage }),
)
})
); );
}; };
const removeStageTier = (stageKey, tierIndex) => { const removeStageTier = (stageKey, tierIndex) => {
setValidationErrors([]);
setForm((current) => setForm((current) =>
correctAllStageProbabilities({ correctAllStageProbabilities({
...current, ...current,
stages: current.stages.map((stage) => stages: current.stages.map((stage) =>
stage.stage === stageKey ? { ...stage, tiers: stage.tiers.filter((_, index) => index !== tierIndex) } : stage stage.stage === stageKey
) ? { ...stage, tiers: stage.tiers.filter((_, index) => index !== tierIndex) }
}) : stage,
),
}),
); );
}; };
const handleSubmit = (event) => { const handleSubmit = (event) => {
event.preventDefault(); event.preventDefault();
const errors = validateLuckyGiftForm(form);
if (errors.length) {
setValidationErrors(errors);
return;
}
onSubmit(formToConfig(config, form)); onSubmit(formToConfig(config, form));
}; };
@ -113,12 +142,27 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
<div className="ops-config-metrics"> <div className="ops-config-metrics">
<ConfigMetric label="应用" value={form.app_code || "-"} /> <ConfigMetric label="应用" value={form.app_code || "-"} />
<ConfigMetric label="奖池" value={form.pool_id || "default"} /> <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="目标 RTP" value={formatPercent(Number(form.target_rtp_percent))} />
<ConfigMetric label="奖池回收" value={formatPercent(Number(form.pool_rate_percent))} /> <ConfigMetric
label="资金拆分"
value={`${form.pool_rate_percent}% / ${form.profit_rate_percent}% / ${form.anchor_rate_percent}%`}
/>
<ConfigMetric label="结算窗口" value={formatNumber(Number(form.settlement_window_wager))} /> <ConfigMetric label="结算窗口" value={formatNumber(Number(form.settlement_window_wager))} />
<ConfigMetric label="状态" value={form.enabled ? "启用" : "停用"} /> <ConfigMetric label="状态" value={form.enabled ? "启用" : "停用"} />
</div> </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"> <div className="ops-config-layout">
<aside className="ops-config-params" aria-label="基础参数"> <aside className="ops-config-params" aria-label="基础参数">
<section className="ops-config-section"> <section className="ops-config-section">
@ -151,8 +195,24 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
value={form.pool_id} value={form.pool_id}
onChange={(event) => updateField("pool_id", event.target.value)} 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 <FormControlLabel
control={<AdminSwitch checked={form.enabled} onChange={(event) => updateField("enabled", event.target.checked)} />} control={
<AdminSwitch
checked={form.enabled}
onChange={(event) => updateField("enabled", event.target.checked)}
/>
}
label={form.enabled ? "发布后立即启用" : "发布后保持停用"} label={form.enabled ? "发布后立即启用" : "发布后保持停用"}
sx={{ gap: 1, marginLeft: 0 }} sx={{ gap: 1, marginLeft: 0 }}
/> />
@ -161,13 +221,71 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
<section className="ops-config-section"> <section className="ops-config-section">
<h3>RTP 与奖池</h3> <h3>RTP 与奖池</h3>
<div className="ops-form-grid"> <div className="ops-form-grid">
<NumberField form={form} label="目标 RTP (%)" name="target_rtp_percent" step="0.01" onChange={updateField} /> <NumberField
<NumberField form={form} label="奖池回收率 (%)" name="pool_rate_percent" step="0.01" onChange={updateField} /> form={form}
<NumberField form={form} label="控制带宽 (%)" name="control_band_percent" step="0.01" onChange={updateField} /> label="目标 RTP (%)"
<NumberField form={form} label="结算窗口流水" name="settlement_window_wager" onChange={updateField} /> name="target_rtp_percent"
<NumberField form={form} label="礼物参考金额" name="gift_price_reference" onChange={updateField} /> 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> </div>
</section> </section>
{form.strategy_version === "dynamic_v3" ? (
<DynamicStrategySections form={form} onChange={updateField} />
) : null}
</aside> </aside>
<section className="ops-stage-designer" aria-label="阶段奖档设计"> <section className="ops-stage-designer" aria-label="阶段奖档设计">
@ -195,10 +313,47 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
tone={activeSummary?.ok ? "succeeded" : "warning"} tone={activeSummary?.ok ? "succeeded" : "warning"}
/> />
</div> </div>
<Button startIcon={<AddOutlined fontSize="small" />} onClick={() => addStageTier(activeStageConfig.stage)}> <Button
startIcon={<AddOutlined fontSize="small" />}
onClick={() => addStageTier(activeStageConfig.stage)}
>
添加奖档 添加奖档
</Button> </Button>
</div> </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-table">
<div className="ops-tier-head" aria-hidden="true"> <div className="ops-tier-head" aria-hidden="true">
<span>倍率</span> <span>倍率</span>
@ -212,21 +367,26 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
<TextField <TextField
required required
size="small" size="small"
slotProps={{ htmlInput: { "aria-label": "倍率", min: 0, step: "0.01" } }} slotProps={{
htmlInput: { "aria-label": "倍率", min: 0, step: "0.01" },
}}
type="number" type="number"
value={tier.multiplier} value={tier.multiplier}
onChange={(event) => onChange={(event) =>
updateStageTier(activeStageConfig.stage, index, { updateStageTier(activeStageConfig.stage, index, {
// 10 // 10
highWaterOnly: Number(event.target.value) >= 10 || tier.highWaterOnly, highWaterOnly:
multiplier: event.target.value Number(event.target.value) >= 10 || tier.highWaterOnly,
multiplier: event.target.value,
}) })
} }
/> />
{/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */} {/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */}
<TextField <TextField
size="small" size="small"
slotProps={{ htmlInput: { "aria-label": "概率 %", readOnly: true } }} slotProps={{
htmlInput: { "aria-label": "概率 %", readOnly: true },
}}
value={formatDecimal(tier.probabilityPercent)} value={formatDecimal(tier.probabilityPercent)}
/> />
<Checkbox <Checkbox
@ -234,13 +394,21 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
disabled={Number(tier.multiplier) >= 10} disabled={Number(tier.multiplier) >= 10}
size="small" size="small"
slotProps={{ input: { "aria-label": "高水位" } }} slotProps={{ input: { "aria-label": "高水位" } }}
onChange={(event) => updateStageTier(activeStageConfig.stage, index, { highWaterOnly: event.target.checked })} onChange={(event) =>
updateStageTier(activeStageConfig.stage, index, {
highWaterOnly: event.target.checked,
})
}
/> />
<Checkbox <Checkbox
checked={tier.enabled !== false} checked={tier.enabled !== false}
size="small" size="small"
slotProps={{ input: { "aria-label": "启用" } }} slotProps={{ input: { "aria-label": "启用" } }}
onChange={(event) => updateStageTier(activeStageConfig.stage, index, { enabled: event.target.checked })} onChange={(event) =>
updateStageTier(activeStageConfig.stage, index, {
enabled: event.target.checked,
})
}
/> />
<Button <Button
disabled={activeStageConfig.tiers.length <= 1} disabled={activeStageConfig.tiers.length <= 1}
@ -270,6 +438,109 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
); );
} }
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>
</section>
<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 }) { function ConfigMetric({ label, value }) {
return ( return (
<article className="ops-config-metric"> <article className="ops-config-metric">
@ -279,9 +550,10 @@ function ConfigMetric({ label, value }) {
); );
} }
function NumberField({ form, label, name, onChange, step = "1" }) { function NumberField({ form, helperText, label, name, onChange, step = "1" }) {
return ( return (
<TextField <TextField
helperText={helperText}
label={label} label={label}
required required
size="small" size="small"

View File

@ -5,41 +5,172 @@ import {
decimal, decimal,
expectedRTPPercent, expectedRTPPercent,
formatDecimal, formatDecimal,
probabilityTotal probabilityTotal,
} from "@/shared/utils/rtpProbability.js"; } from "@/shared/utils/rtpProbability.js";
import { DEFAULT_POOL_ID } from "./api.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 = {}) { export function configToForm(config = {}) {
const targetRTPPercent = formNumber(config.target_rtp_percent ?? config.targetRtpPercent); const hasPersistedShape = Object.keys(config).length > 0;
return { 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 || "", app_code: config.app_code || config.appCode || "",
control_band_percent: formNumber(config.control_band_percent ?? config.controlBandPercent),
enabled: Boolean(config.enabled), enabled: Boolean(config.enabled),
gift_price_reference: formNumber(config.gift_price_reference ?? config.giftPriceReference), jackpot_multipliers: multiplierListToForm(
config.jackpot_multipliers ?? config.jackpotMultipliers,
strategyVersion,
),
pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID, pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID,
pool_rate_percent: formNumber(config.pool_rate_percent ?? config.poolRatePercent), stages: normalizeFormStages(config.stages, targetRTPPercent, strategyVersion),
settlement_window_wager: formNumber(config.settlement_window_wager ?? config.settlementWindowWager), strategy_version: strategyVersion,
stages: normalizeFormStages(config.stages, targetRTPPercent),
target_rtp_percent: targetRTPPercent
}; };
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) { export function formToConfig(config, form) {
return { const output = {
...config, ...config,
app_code: form.app_code, app_code: form.app_code,
control_band_percent: numberFromForm(form.control_band_percent),
enabled: Boolean(form.enabled), enabled: Boolean(form.enabled),
gift_price_reference: numberFromForm(form.gift_price_reference), jackpot_multipliers: multiplierListFromForm(form.jackpot_multipliers),
pool_id: form.pool_id, pool_id: form.pool_id,
pool_rate_percent: numberFromForm(form.pool_rate_percent), stages: (form.stages || []).map((stage) => ({
settlement_window_wager: numberFromForm(form.settlement_window_wager), min_recharge_30d_coins: numberFromForm(stage.min_recharge_30d_coins),
stages: form.stages, min_recharge_7d_coins: numberFromForm(stage.min_recharge_7d_coins),
target_rtp_percent: numberFromForm(form.target_rtp_percent) 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 改动后都重算全阶段概率, // 概率列必须整体闭合到 100% 且期望 RTP 逼近目标值,所以任何倍率/目标 RTP 改动后都重算全阶段概率,
@ -49,27 +180,26 @@ export function correctAllStageProbabilities(form) {
...form, ...form,
stages: (form.stages || []).map((stage) => { stages: (form.stages || []).map((stage) => {
const probabilityByIndex = correctRTPProbabilities(stage.tiers, form.target_rtp_percent, { const probabilityByIndex = correctRTPProbabilities(stage.tiers, form.target_rtp_percent, {
multiplier: (item) => item.multiplier multiplier: (item) => item.multiplier,
}); });
return { return {
...stage, ...stage,
tiers: probabilityByIndex ? applyProbabilityMap(stage.tiers, probabilityByIndex, formatDecimal) : stage.tiers tiers: probabilityByIndex
? applyProbabilityMap(stage.tiers, probabilityByIndex, formatDecimal)
: stage.tiers,
}; };
}) }),
}; };
} }
export function stageSummary(stage, form) { export function stageSummary(stage, form) {
const probability = probabilityTotal(stage.tiers); const probability = probabilityTotal(stage.tiers);
const expected = expectedRTPPercent(stage.tiers, { const expected = expectedRTPPercent(stage.tiers, { multiplier: (item) => item.multiplier });
multiplier: (item) => item.multiplier
});
const target = Number(form.target_rtp_percent || 0); const target = Number(form.target_rtp_percent || 0);
const band = Number(form.control_band_percent || 0); const band = Number(form.control_band_percent || 0);
const probabilityClosed = Math.abs(probability - 100) < 0.0001; const probabilityClosed = Math.abs(probability - 100) < 0.0001;
const rtpInBand = expected >= target - band && expected <= target + band; const rtpInBand = expected >= target - band && expected <= target + band;
const fullLabel = stageOptions.find(([key]) => key === stage.stage)?.[1] || stage.stage; const fullLabel = stageOptions.find(([key]) => key === stage.stage)?.[1] || stage.stage;
return { return {
expected, expected,
ok: probabilityClosed && rtpInBand, ok: probabilityClosed && rtpInBand,
@ -78,73 +208,243 @@ export function stageSummary(stage, form) {
rtpInBand, rtpInBand,
stageKey: stage.stage, stageKey: stage.stage,
stageLabel: fullLabel, stageLabel: fullLabel,
// 发布按钮不做硬拦截(服务端仍会校验),这里的状态只用于提示运营当前阶段是否可安全发布。
status: probabilityClosed && rtpInBand ? "可发布" : probabilityClosed ? "RTP 偏离" : "概率未闭合", status: probabilityClosed && rtpInBand ? "可发布" : probabilityClosed ? "RTP 偏离" : "概率未闭合",
tabLabel: stage.stage === "normal" ? "普通" : fullLabel.replace(/阶段$/, "") 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 = []) { export function nextStageMultiplier(tiers = []) {
const positive = tiers.map((tier) => decimal(tier.multiplier)).filter((value) => value > 0); const positive = tiers.map((tier) => decimal(tier.multiplier)).filter((value) => value > 0);
if (!positive.length) { if (!positive.length) return 1;
return 1;
}
const max = Math.max(...positive); const max = Math.max(...positive);
if (max >= 5) { if (max >= 5) return max * 2;
return max * 2; if (max >= 2) return 5;
}
if (max >= 2) {
return 5;
}
return max + 1; return max + 1;
} }
function normalizeFormStages(stages, targetRTPPercent = 95) { function normalizeFormStages(stages, targetRTPPercent, strategyVersion) {
const byStage = new Map((Array.isArray(stages) ? stages : []).map((stage) => [stage.stage, stage])); const byStage = new Map((Array.isArray(stages) ? stages : []).map((stage) => [stage.stage, stage]));
return correctAllStageProbabilities({ return stageOptions.map(([stageKey]) => {
stages: stageOptions.map(([stageKey]) => {
const source = byStage.get(stageKey); const source = byStage.get(stageKey);
const sourceTiers = Array.isArray(source?.tiers) && source.tiers.length ? source.tiers : defaultStageTiers(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 { 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, stage: stageKey,
tiers: sourceTiers.map((tier) => ({ tiers: sourceTiers.map((tier) => ({
enabled: tier.enabled !== false, enabled: tier.enabled !== false,
highWaterOnly: Boolean(tier.high_water_only ?? tier.highWaterOnly), highWaterOnly: Boolean(tier.high_water_only ?? tier.highWaterOnly),
multiplier: formNumber(tier.multiplier), multiplier: formNumber(tier.multiplier),
probabilityPercent: formNumber(tier.probability_percent ?? tier.probabilityPercent), probabilityPercent: formNumber(tier.probability_percent ?? tier.probabilityPercent),
tierId: tier.tier_id || tier.tierId || "" tierId: tier.tier_id || tier.tierId || "",
})) })),
}; };
}), });
target_rtp_percent: targetRTPPercent
}).stages;
} }
function defaultStageTiers(stage) { function correctedDefaultTiers(stage, targetRTPPercent) {
if (stage === "advanced") { const tiers = [
return [ defaultTier(`${stage}_none`, 0, 5),
defaultTier("advanced_none", 0, 27), defaultTier(`${stage}_0_5x`, 0.5, 4),
defaultTier("advanced_1x", 1, 60), defaultTier(`${stage}_1x`, 1, 86),
defaultTier("advanced_2x", 2, 10), defaultTier(`${stage}_2x`, 2, 5),
defaultTier("advanced_5x", 5, 3, { high_water_only: true })
]; ];
// 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;
} }
return [ const probabilities = correctRTPProbabilities(tiers, targetRTPPercent, { multiplier: (item) => item.multiplier });
defaultTier(`${stage}_none`, 0, 10), return probabilities ? applyProbabilityMap(tiers, probabilities, formatDecimal) : tiers;
defaultTier(`${stage}_0_5x`, 0.5, 20),
defaultTier(`${stage}_1x`, 1, 55),
defaultTier(`${stage}_2x`, 2, 15)
];
} }
function defaultTier(tierID, multiplier, probabilityPercent, options = {}) { function defaultTier(tierID, multiplier, probabilityPercent) {
return { return { enabled: true, highWaterOnly: false, multiplier, probabilityPercent, tierId: tierID };
enabled: true, }
highWaterOnly: Boolean(options.high_water_only),
multiplier, function validateRechargeStages(stages, errors) {
probabilityPercent, const novice = stages.find((stage) => stage.stage === "novice");
tierId: tierID 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) { export function formNumber(value) {

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

@ -166,11 +166,29 @@ body {
.ops-config-metrics { .ops-config-metrics {
display: grid; display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: var(--space-2); gap: var(--space-2);
margin-bottom: var(--space-4); 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 { .ops-config-metric {
display: grid; display: grid;
min-width: 0; min-width: 0;
@ -201,7 +219,7 @@ body {
.ops-config-layout { .ops-config-layout {
display: grid; display: grid;
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr); grid-template-columns: minmax(360px, 440px) minmax(0, 1fr);
align-items: start; align-items: start;
gap: var(--space-4); gap: var(--space-4);
} }
@ -234,6 +252,10 @@ body {
gap: var(--space-3); gap: var(--space-3);
} }
.ops-form-grid__wide {
grid-column: 1 / -1;
}
.ops-stage-designer { .ops-stage-designer {
display: grid; display: grid;
min-width: 0; min-width: 0;
@ -316,6 +338,16 @@ body {
font-size: 15px; 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 { .ops-tier-table {
display: grid; display: grid;
min-width: 0; min-width: 0;
@ -390,6 +422,7 @@ body {
.ops-kpi-grid, .ops-kpi-grid,
.ops-config-metrics, .ops-config-metrics,
.ops-form-grid, .ops-form-grid,
.ops-stage-thresholds,
.ops-tier-row { .ops-tier-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@ -1,204 +1,18 @@
import Dialog from "@mui/material/Dialog"; import { useEffect } from "react";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent"; export const LUCKY_GIFT_OPS_CENTER_PATH = "/ops-center/?view=configs";
import DialogTitle from "@mui/material/DialogTitle";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; export function redirectToLuckyGiftOpsCenter(targetLocation = window.location) {
import TextField from "@mui/material/TextField"; // Ops Center HTML React Router SPA Navigate
import { LuckyGiftConfigDrawer } from "@/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx"; // App 线 activity/v2
import { LuckyGiftConfigSummary } from "@/features/lucky-gift/components/LuckyGiftConfigSummary.jsx"; targetLocation.replace(LUCKY_GIFT_OPS_CENTER_PATH);
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";
export function LuckyGiftConfigPage() { export function LuckyGiftConfigPage() {
const page = useLuckyGiftPage(); useEffect(() => {
redirectToLuckyGiftOpsCenter();
}, []);
return ( //
<AdminListPage> return <p role="status">正在打开新版幸运礼物配置</p>;
<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));
} }

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

@ -229,6 +229,7 @@ export const API_OPERATIONS = {
listLoginLogs: "listLoginLogs", listLoginLogs: "listLoginLogs",
listLuckyGiftConfigs: "listLuckyGiftConfigs", listLuckyGiftConfigs: "listLuckyGiftConfigs",
listLuckyGiftDraws: "listLuckyGiftDraws", listLuckyGiftDraws: "listLuckyGiftDraws",
listLuckyGiftPoolBalances: "listLuckyGiftPoolBalances",
listManagers: "listManagers", listManagers: "listManagers",
listOperationLogs: "listOperationLogs", listOperationLogs: "listOperationLogs",
listPermissions: "listPermissions", listPermissions: "listPermissions",
@ -1306,14 +1307,14 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
getLuckyGiftConfig: { getLuckyGiftConfig: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.getLuckyGiftConfig, operationId: API_OPERATIONS.getLuckyGiftConfig,
path: "/v1/admin/activity/lucky-gifts/v2/config", path: "/v1/admin/ops-center/lucky-gifts/config",
permission: "lucky-gift:view", permission: "lucky-gift:view",
permissions: ["lucky-gift:view"] permissions: ["lucky-gift:view"]
}, },
getLuckyGiftDrawSummary: { getLuckyGiftDrawSummary: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.getLuckyGiftDrawSummary, 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", permission: "lucky-gift:view",
permissions: ["lucky-gift:view"] permissions: ["lucky-gift:view"]
}, },
@ -1899,14 +1900,21 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
listLuckyGiftConfigs: { listLuckyGiftConfigs: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listLuckyGiftConfigs, operationId: API_OPERATIONS.listLuckyGiftConfigs,
path: "/v1/admin/activity/lucky-gifts/v2/configs", path: "/v1/admin/ops-center/lucky-gifts/configs",
permission: "lucky-gift:view", permission: "lucky-gift:view",
permissions: ["lucky-gift:view"] permissions: ["lucky-gift:view"]
}, },
listLuckyGiftDraws: { listLuckyGiftDraws: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listLuckyGiftDraws, 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", permission: "lucky-gift:view",
permissions: ["lucky-gift:view"] permissions: ["lucky-gift:view"]
}, },
@ -3026,7 +3034,7 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
upsertLuckyGiftConfig: { upsertLuckyGiftConfig: {
method: "PUT", method: "PUT",
operationId: API_OPERATIONS.upsertLuckyGiftConfig, operationId: API_OPERATIONS.upsertLuckyGiftConfig,
path: "/v1/admin/activity/lucky-gifts/v2/config", path: "/v1/admin/ops-center/lucky-gifts/config",
permission: "lucky-gift:update", permission: "lucky-gift:update",
permissions: ["lucky-gift:update"] permissions: ["lucky-gift:update"]
}, },

View File

@ -244,7 +244,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/admin/activity/lucky-gifts/v2/config": { "/admin/ops-center/lucky-gifts/config": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@ -260,7 +260,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/admin/activity/lucky-gifts/v2/configs": { "/admin/ops-center/lucky-gifts/configs": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@ -276,7 +276,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/admin/activity/lucky-gifts/v2/draws": { "/admin/ops-center/lucky-gifts/draws": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@ -292,7 +292,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/admin/activity/lucky-gifts/v2/draw-summary": { "/admin/ops-center/lucky-gifts/summary": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@ -308,6 +308,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/admin/activity/wheel/config": {
parameters: { parameters: {
query?: never; query?: never;
@ -7935,6 +7951,18 @@ export interface operations {
200: components["responses"]["EmptyResponse"]; 200: components["responses"]["EmptyResponse"];
}; };
}; };
listLuckyGiftPoolBalances: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
getWheelConfig: { getWheelConfig: {
parameters: { parameters: {
query?: never; query?: never;