Merge lucky gift dynamic v3 admin into test
This commit is contained in:
commit
f8cb838e82
@ -320,7 +320,7 @@
|
||||
"x-permissions": ["red-packet:update"]
|
||||
}
|
||||
},
|
||||
"/admin/activity/lucky-gifts/v2/config": {
|
||||
"/admin/ops-center/lucky-gifts/config": {
|
||||
"get": {
|
||||
"operationId": "getLuckyGiftConfig",
|
||||
"responses": {
|
||||
@ -342,7 +342,7 @@
|
||||
"x-permissions": ["lucky-gift:update"]
|
||||
}
|
||||
},
|
||||
"/admin/activity/lucky-gifts/v2/configs": {
|
||||
"/admin/ops-center/lucky-gifts/configs": {
|
||||
"get": {
|
||||
"operationId": "listLuckyGiftConfigs",
|
||||
"responses": {
|
||||
@ -354,7 +354,7 @@
|
||||
"x-permissions": ["lucky-gift:view"]
|
||||
}
|
||||
},
|
||||
"/admin/activity/lucky-gifts/v2/draws": {
|
||||
"/admin/ops-center/lucky-gifts/draws": {
|
||||
"get": {
|
||||
"operationId": "listLuckyGiftDraws",
|
||||
"responses": {
|
||||
@ -366,7 +366,7 @@
|
||||
"x-permissions": ["lucky-gift:view"]
|
||||
}
|
||||
},
|
||||
"/admin/activity/lucky-gifts/v2/draw-summary": {
|
||||
"/admin/ops-center/lucky-gifts/summary": {
|
||||
"get": {
|
||||
"operationId": "getLuckyGiftDrawSummary",
|
||||
"responses": {
|
||||
@ -378,6 +378,18 @@
|
||||
"x-permissions": ["lucky-gift:view"]
|
||||
}
|
||||
},
|
||||
"/admin/ops-center/lucky-gifts/pools": {
|
||||
"get": {
|
||||
"operationId": "listLuckyGiftPoolBalances",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "lucky-gift:view",
|
||||
"x-permissions": ["lucky-gift:view"]
|
||||
}
|
||||
},
|
||||
"/admin/activity/wheel/config": {
|
||||
"get": {
|
||||
"operationId": "getWheelConfig",
|
||||
|
||||
@ -20,19 +20,26 @@ const views = [
|
||||
{ icon: InsightsOutlined, key: "overview", label: "数据总览" },
|
||||
{ icon: CardGiftcardOutlined, key: "configs", 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 = {
|
||||
apps: [],
|
||||
appSummaries: [],
|
||||
luckyGifts: [],
|
||||
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() {
|
||||
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 [dialog, setDialog] = useState(null);
|
||||
const [savingConfig, setSavingConfig] = useState(false);
|
||||
@ -49,7 +56,7 @@ export function OpsCenterApp() {
|
||||
data: current.data || initialDashboard,
|
||||
error: error.message || "运营接口暂不可用",
|
||||
loaded: current.loaded,
|
||||
loading: false
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
@ -61,8 +68,12 @@ export function OpsCenterApp() {
|
||||
const { data } = state;
|
||||
|
||||
const appOptions = useMemo(
|
||||
() => data.apps.map((item) => [item.app_code ?? item.appCode, `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`]),
|
||||
[data.apps]
|
||||
() =>
|
||||
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]);
|
||||
|
||||
const openCreateConfig = useCallback(() => {
|
||||
// 新建配置以第一个应用的现有配置做参数模板(每个应用至少有一行草稿或已发布配置),
|
||||
// 应用和奖池在弹窗里由运营自行选择,避免旧版"添加配置"悄悄绑定到第一个应用的问题。
|
||||
const template = data.luckyGifts[0];
|
||||
if (!template) {
|
||||
const firstAppCode = data.apps
|
||||
.map((item) =>
|
||||
String(item.app_code ?? item.appCode ?? "")
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
)
|
||||
.find(Boolean);
|
||||
if (!firstAppCode) {
|
||||
showToast("暂无可添加配置的应用", "warning");
|
||||
return;
|
||||
}
|
||||
// 金额门槛和六维上限按 App 金币口径配置,绝不能复制第一条已发布规则给另一个 App。
|
||||
// 仅传规则身份,让 configToForm 生成 disabled dynamic_v3 安全草稿;金额字段保持 0,运营补齐后才能启用。
|
||||
setDialog({
|
||||
config: { ...structuredClone(template), is_default: true, pool_id: DEFAULT_POOL_ID, rule_version: 0 },
|
||||
mode: "create"
|
||||
config: {
|
||||
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) => {
|
||||
setDialog({ config: structuredClone(config), mode: "edit" });
|
||||
@ -115,7 +139,7 @@ export function OpsCenterApp() {
|
||||
setSavingConfig(false);
|
||||
}
|
||||
},
|
||||
[loadDashboard, showToast]
|
||||
[loadDashboard, showToast],
|
||||
);
|
||||
|
||||
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}>
|
||||
<div className="ops-page">
|
||||
<PageHead meta="跨应用幸运礼物配置与抽奖数据面板" title="运营配置中心">
|
||||
<span className={state.loading ? "ops-status is-loading" : "ops-status"}>{state.loading ? "同步中" : "已就绪"}</span>
|
||||
<Button disabled={state.loading} startIcon={<RefreshOutlined fontSize="small" />} onClick={loadDashboard}>
|
||||
<span className={state.loading ? "ops-status is-loading" : "ops-status"}>
|
||||
{state.loading ? "同步中" : "已就绪"}
|
||||
</span>
|
||||
<Button
|
||||
disabled={state.loading}
|
||||
startIcon={<RefreshOutlined fontSize="small" />}
|
||||
onClick={loadDashboard}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</PageHead>
|
||||
@ -135,7 +165,11 @@ export function OpsCenterApp() {
|
||||
{state.error}
|
||||
</div>
|
||||
) : 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 === "configs" ? (
|
||||
<ConfigsView
|
||||
@ -146,7 +180,9 @@ export function OpsCenterApp() {
|
||||
onEdit={openEditConfig}
|
||||
/>
|
||||
) : 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}
|
||||
</DataState>
|
||||
{dialog ? (
|
||||
|
||||
@ -2,17 +2,18 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { fetchLuckyGiftDraws, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
|
||||
import { OpsCenterApp } from "./OpsCenterApp.jsx";
|
||||
import { initialOpsCenterView, OpsCenterApp } from "./OpsCenterApp.jsx";
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
DEFAULT_POOL_ID: "default",
|
||||
fetchLuckyGiftDraws: vi.fn(),
|
||||
fetchOpsDashboard: vi.fn(),
|
||||
saveLuckyGiftConfig: vi.fn()
|
||||
saveLuckyGiftConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.history.replaceState({}, "", "/");
|
||||
saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" });
|
||||
fetchLuckyGiftDraws.mockResolvedValue({
|
||||
items: [
|
||||
@ -26,17 +27,17 @@ beforeEach(() => {
|
||||
multiplier_ppm: 2_000_000,
|
||||
pool_id: "super_lucky",
|
||||
reward_status: "granted",
|
||||
rule_version: 2
|
||||
}
|
||||
rule_version: 2,
|
||||
},
|
||||
],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1
|
||||
total: 1,
|
||||
});
|
||||
fetchOpsDashboard.mockResolvedValue({
|
||||
apps: [
|
||||
{ app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 },
|
||||
{ app_code: "yumi", app_name: "Yumi", source: "fixed", status: "active" }
|
||||
{ app_code: "yumi", app_name: "Yumi", source: "fixed", status: "active" },
|
||||
],
|
||||
appSummaries: [
|
||||
{
|
||||
@ -49,17 +50,53 @@ beforeEach(() => {
|
||||
total_reward_coins: 900,
|
||||
total_spent_coins: 1000,
|
||||
unique_rooms: 2,
|
||||
unique_users: 2
|
||||
}
|
||||
unique_users: 2,
|
||||
},
|
||||
],
|
||||
luckyGifts: [
|
||||
{ app_code: "lalu", enabled: true, is_default: false, pool_id: "lucky", rule_version: 3, target_rtp_percent: 95 },
|
||||
{ app_code: "lalu", enabled: false, is_default: false, pool_id: "super_lucky", rule_version: 2, target_rtp_percent: 92 },
|
||||
{ app_code: "yumi", enabled: false, is_default: true, pool_id: "default", rule_version: 0, target_rtp_percent: 95 }
|
||||
{
|
||||
...mockDynamicConfig(),
|
||||
app_code: "lalu",
|
||||
enabled: true,
|
||||
is_default: false,
|
||||
pool_id: "lucky",
|
||||
rule_version: 3,
|
||||
},
|
||||
{
|
||||
...mockFixedConfig(92),
|
||||
app_code: "lalu",
|
||||
enabled: false,
|
||||
is_default: false,
|
||||
pool_id: "super_lucky",
|
||||
rule_version: 2,
|
||||
},
|
||||
{
|
||||
...mockDynamicConfig(),
|
||||
app_code: "yumi",
|
||||
enabled: false,
|
||||
is_default: true,
|
||||
pool_id: "default",
|
||||
rule_version: 0,
|
||||
},
|
||||
],
|
||||
poolBalances: [
|
||||
{ app_code: "lalu", available_balance: 37950017, balance: 38559017, materialized: true, pool_id: "lucky", reserve_floor: 609000, updated_at_ms: 1767000000000 },
|
||||
{ app_code: "yumi", available_balance: 361500, balance: 382500, materialized: false, pool_id: "default", reserve_floor: 21000 }
|
||||
{
|
||||
app_code: "lalu",
|
||||
available_balance: 37950017,
|
||||
balance: 38559017,
|
||||
materialized: true,
|
||||
pool_id: "lucky",
|
||||
reserve_floor: 609000,
|
||||
updated_at_ms: 1767000000000,
|
||||
},
|
||||
{
|
||||
app_code: "yumi",
|
||||
available_balance: 361500,
|
||||
balance: 382500,
|
||||
materialized: false,
|
||||
pool_id: "default",
|
||||
reserve_floor: 21000,
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
activeApps: 2,
|
||||
@ -73,8 +110,8 @@ beforeEach(() => {
|
||||
poolReserveTotal: 630000,
|
||||
totalDraws: 3,
|
||||
totalRewardCoins: 900,
|
||||
totalSpentCoins: 1000
|
||||
}
|
||||
totalSpentCoins: 1000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@ -82,7 +119,7 @@ function renderApp() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<OpsCenterApp />
|
||||
</ToastProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
@ -105,6 +142,17 @@ test("renders overview with aggregated kpis and pool balances", async () => {
|
||||
expect(fetchOpsDashboard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("opens the requested config view from the legacy admin document redirect", async () => {
|
||||
expect(initialOpsCenterView("?view=configs")).toBe("configs");
|
||||
expect(initialOpsCenterView("?view=unknown")).toBe("overview");
|
||||
window.history.replaceState({}, "", "/ops-center/?view=configs");
|
||||
|
||||
renderApp();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "添加配置" })).toBeTruthy();
|
||||
expect(screen.getByText("lucky")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("lists every pool config including published non-default pools", async () => {
|
||||
renderApp();
|
||||
await screen.findByText("运营应用");
|
||||
@ -121,6 +169,21 @@ test("lists every pool config including published non-default pools", async () =
|
||||
expect(screen.getByText("默认草稿")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("creates a disabled dynamic draft without copying another app monetary limits", async () => {
|
||||
renderApp();
|
||||
await screen.findByText("运营应用");
|
||||
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "添加配置" }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(within(dialog).getByText("添加幸运礼物配置")).toBeTruthy();
|
||||
expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3");
|
||||
expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("1000000");
|
||||
// 第一条 lalu 已发布配置的单次上限为 100 万;新建跨 App 配置必须归零并保持停用。
|
||||
expect(within(dialog).getByLabelText(/单次返奖上限/).value).toBe("0");
|
||||
expect(within(dialog).getByText("发布后保持停用")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("opens config dialog from a published pool row and saves a new version", async () => {
|
||||
renderApp();
|
||||
await screen.findByText("运营应用");
|
||||
@ -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]);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(within(dialog).getByText("新增幸运礼物版本")).toBeTruthy();
|
||||
expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3");
|
||||
expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("1000000");
|
||||
expect(within(dialog).getByLabelText(/大奖倍率/).value).toBe("200, 500, 1000");
|
||||
expect(within(dialog).getAllByLabelText("概率 %")[0].readOnly).toBe(true);
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText(/目标 RTP \(%\)/), { target: { value: "88" } });
|
||||
@ -140,14 +206,112 @@ test("opens config dialog from a published pool row and saves a new version", as
|
||||
expect(saveLuckyGiftConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
app_code: "lalu",
|
||||
anchor_rate_percent: 1,
|
||||
jackpot_multipliers: [200, 500, 1000],
|
||||
pool_id: "lucky",
|
||||
profit_rate_percent: 1,
|
||||
stages: expect.arrayContaining([expect.objectContaining({ stage: "novice" })]),
|
||||
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 () => {
|
||||
renderApp();
|
||||
await screen.findByText("运营应用");
|
||||
@ -156,7 +320,7 @@ test("loads draw records for the first app with server pagination", async () =>
|
||||
|
||||
await waitFor(() => {
|
||||
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();
|
||||
|
||||
@ -15,10 +15,10 @@ export async function fetchOpsDashboard(query = {}) {
|
||||
const [luckyGifts, poolBalances, drawSummary] = await Promise.all([
|
||||
fetchAppLuckyGiftConfigs(appCode),
|
||||
opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems),
|
||||
opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } })
|
||||
opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } }),
|
||||
]);
|
||||
return { appCode, drawSummary, luckyGifts, poolBalances };
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
return normalizeDashboard({ apps, perApp });
|
||||
@ -35,17 +35,27 @@ async function fetchAppLuckyGiftConfigs(appCode) {
|
||||
}
|
||||
// 一个已发布配置都没有的应用仍要展示一行可编辑草稿,让运营从这里发布第一版;
|
||||
// 草稿的默认参数(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] : [];
|
||||
}
|
||||
|
||||
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 = {
|
||||
app_code: appCode,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
pool_id: poolId,
|
||||
status
|
||||
status,
|
||||
};
|
||||
// 同一个输入框同时支持内部数字 UID 和外部接入方的字符串 ID:纯数字走 user_id 精确匹配,
|
||||
// 其余走 external_user_id,避免运营要先分辨用户来源再选字段。
|
||||
@ -61,7 +71,7 @@ export async function fetchLuckyGiftDraws({ appCode, page = 1, pageSize = 20, po
|
||||
items: normalizeItems(payload),
|
||||
page: numberValue(payload.page) || page,
|
||||
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",
|
||||
query: {
|
||||
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),
|
||||
credentials: "include",
|
||||
headers: buildHeaders(body),
|
||||
method
|
||||
method,
|
||||
});
|
||||
const payload = await readPayload(response);
|
||||
|
||||
@ -130,7 +140,7 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
|
||||
poolReserveTotal: sumBy(poolBalances, "reserve_floor", "reserveFloor"),
|
||||
totalDraws: sumBy(appSummaries, "total_draws"),
|
||||
totalRewardCoins: sumBy(appSummaries, "total_reward_coins"),
|
||||
totalSpentCoins: sumBy(appSummaries, "total_spent_coins")
|
||||
totalSpentCoins: sumBy(appSummaries, "total_spent_coins"),
|
||||
};
|
||||
|
||||
return { apps, appSummaries, luckyGifts, poolBalances, summary };
|
||||
@ -139,7 +149,9 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
|
||||
function normalizeDrawSummary(summary = {}, appCode = "") {
|
||||
return {
|
||||
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),
|
||||
granted_draws: numberValue(summary.granted_draws ?? summary.grantedDraws),
|
||||
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_spent_coins: numberValue(summary.total_spent_coins ?? summary.totalSpentCoins),
|
||||
unique_rooms: numberValue(summary.unique_rooms ?? summary.uniqueRooms),
|
||||
unique_users: numberValue(summary.unique_users ?? summary.uniqueUsers)
|
||||
unique_users: numberValue(summary.unique_users ?? summary.uniqueUsers),
|
||||
};
|
||||
}
|
||||
|
||||
@ -155,7 +167,9 @@ function uniqueAppCodes(apps) {
|
||||
const seen = new Set();
|
||||
const codes = [];
|
||||
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)) {
|
||||
return;
|
||||
}
|
||||
@ -166,7 +180,9 @@ function uniqueAppCodes(apps) {
|
||||
}
|
||||
|
||||
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);
|
||||
return {
|
||||
...config,
|
||||
@ -174,26 +190,79 @@ function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") {
|
||||
// rule_version <= 0 表示服务端返回的未发布草稿:列表要标成"默认草稿"并禁止当作已生效规则解读。
|
||||
is_default: ruleVersion <= 0,
|
||||
pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID,
|
||||
rule_version: ruleVersion
|
||||
rule_version: ruleVersion,
|
||||
// 升级前规则没有 strategy_version,lucky-gift owner 明确按 fixed_v2 解释;列表和编辑表单必须沿用该兼容语义。
|
||||
strategy_version: String(config.strategy_version || config.strategyVersion || "fixed_v2")
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
function luckyGiftConfigPayload(config = {}) {
|
||||
const appCode = String(config.app_code || config.appCode || "").trim().toLowerCase();
|
||||
const appCode = String(config.app_code || config.appCode || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const poolID = String(config.pool_id || config.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID;
|
||||
const strategyVersion = String(config.strategy_version || config.strategyVersion || "fixed_v2")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
// 规则版本是不可变快照,PUT 必须完整回传 owner GET 返回的策略字段。只提交旧版 RTP/奖档字段会让
|
||||
// 空 strategy_version 被服务端按 fixed_v2 发布,或让 dynamic_v3 因资金、水位、大奖字段归零而拒绝发布。
|
||||
return {
|
||||
anchor_daily_payout_cap: numberValue(config.anchor_daily_payout_cap ?? config.anchorDailyPayoutCap),
|
||||
anchor_rate_percent: numberValue(config.anchor_rate_percent ?? config.anchorRatePercent),
|
||||
app_code: appCode,
|
||||
control_band_percent: numberValue(config.control_band_percent ?? config.controlBandPercent),
|
||||
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),
|
||||
gift_price_reference: numberValue(config.gift_price_reference ?? config.giftPriceReference),
|
||||
high_water_nonzero_factor_percent: numberValue(
|
||||
config.high_water_nonzero_factor_percent ?? config.highWaterNonzeroFactorPercent,
|
||||
),
|
||||
high_watermark_coins: numberValue(config.high_watermark_coins ?? config.highWatermarkCoins),
|
||||
initial_pool_coins: numberValue(config.initial_pool_coins ?? config.initialPoolCoins),
|
||||
jackpot_global_rtp_max_percent: numberValue(
|
||||
config.jackpot_global_rtp_max_percent ?? config.jackpotGlobalRTPMaxPercent,
|
||||
),
|
||||
jackpot_multipliers: numberArray(config.jackpot_multipliers ?? config.jackpotMultipliers),
|
||||
jackpot_spend_threshold_coins: numberValue(
|
||||
config.jackpot_spend_threshold_coins ?? config.jackpotSpendThresholdCoins,
|
||||
),
|
||||
jackpot_user_72h_rtp_max_percent: numberValue(
|
||||
config.jackpot_user_72h_rtp_max_percent ?? config.jackpotUser72hRTPMaxPercent,
|
||||
),
|
||||
jackpot_user_day_rtp_max_percent: numberValue(
|
||||
config.jackpot_user_day_rtp_max_percent ?? config.jackpotUserDayRTPMaxPercent,
|
||||
),
|
||||
loss_streak_guarantee: numberValue(config.loss_streak_guarantee ?? config.lossStreakGuarantee),
|
||||
low_water_nonzero_factor_percent: numberValue(
|
||||
config.low_water_nonzero_factor_percent ?? config.lowWaterNonzeroFactorPercent,
|
||||
),
|
||||
low_watermark_coins: numberValue(config.low_watermark_coins ?? config.lowWatermarkCoins),
|
||||
max_jackpot_hits_per_user_day: numberValue(
|
||||
config.max_jackpot_hits_per_user_day ?? config.maxJackpotHitsPerUserDay,
|
||||
),
|
||||
max_single_payout: numberValue(config.max_single_payout ?? config.maxSinglePayout),
|
||||
normal_max_equivalent_draws: numberValue(config.normal_max_equivalent_draws ?? config.normalMaxEquivalentDraws),
|
||||
novice_max_equivalent_draws: numberValue(config.novice_max_equivalent_draws ?? config.noviceMaxEquivalentDraws),
|
||||
pool_id: poolID,
|
||||
pool_rate_percent: numberValue(config.pool_rate_percent ?? config.poolRatePercent),
|
||||
profit_rate_percent: numberValue(config.profit_rate_percent ?? config.profitRatePercent),
|
||||
recharge_boost_factor_percent: numberValue(
|
||||
config.recharge_boost_factor_percent ?? config.rechargeBoostFactorPercent,
|
||||
),
|
||||
recharge_boost_window_ms: numberValue(
|
||||
config.recharge_boost_window_ms ?? config.rechargeBoostWindowMs ?? config.rechargeBoostWindowMS,
|
||||
),
|
||||
room_hourly_payout_cap: numberValue(config.room_hourly_payout_cap ?? config.roomHourlyPayoutCap),
|
||||
settlement_window_wager: numberValue(config.settlement_window_wager ?? config.settlementWindowWager),
|
||||
stages: normalizeStages(config.stages),
|
||||
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) {
|
||||
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) {
|
||||
@ -245,11 +317,22 @@ function numberValue(value) {
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function numberArray(values) {
|
||||
if (!Array.isArray(values)) {
|
||||
return [];
|
||||
}
|
||||
// 不在传输层静默删除 0/负倍率;表单负责拦截,若有遗漏也应让 owner 返回明确校验错误,
|
||||
// 否则数组位置变化会掩盖运营实际提交的错误值。
|
||||
return values.map(numberValue);
|
||||
}
|
||||
|
||||
function normalizeStages(stages) {
|
||||
if (!Array.isArray(stages)) {
|
||||
return [];
|
||||
}
|
||||
return stages.map((stage) => ({
|
||||
min_recharge_30d_coins: numberValue(stage.min_recharge_30d_coins ?? stage.minRecharge30DCoins),
|
||||
min_recharge_7d_coins: numberValue(stage.min_recharge_7d_coins ?? stage.minRecharge7DCoins),
|
||||
stage: stage.stage || stage.Stage || "",
|
||||
tiers: Array.isArray(stage.tiers)
|
||||
? stage.tiers.map((tier) => ({
|
||||
@ -257,8 +340,8 @@ function normalizeStages(stages) {
|
||||
high_water_only: Boolean(tier.high_water_only ?? tier.highWaterOnly),
|
||||
multiplier: numberValue(tier.multiplier),
|
||||
probability_percent: numberValue(tier.probability_percent ?? tier.probabilityPercent),
|
||||
tier_id: tier.tier_id || tier.tierId || ""
|
||||
tier_id: tier.tier_id || tier.tierId || "",
|
||||
}))
|
||||
: []
|
||||
: [],
|
||||
}));
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ afterEach(() => {
|
||||
function jsonResponse(data) {
|
||||
return new Response(JSON.stringify({ code: 0, data }), {
|
||||
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 两个已发布奖池,复数接口必须全部带回。
|
||||
return jsonResponse([
|
||||
{ app_code: "lalu", enabled: true, pool_id: "lucky", rule_version: 3, target_rtp_percent: 95 },
|
||||
{ app_code: "lalu", enabled: false, pool_id: "super_lucky", rule_version: 2, target_rtp_percent: 92 }
|
||||
{ app_code: "lalu", enabled: false, pool_id: "super_lucky", rule_version: 2, target_rtp_percent: 92 },
|
||||
]);
|
||||
}
|
||||
if (text.includes("/lucky-gifts/pools?")) {
|
||||
return jsonResponse({
|
||||
items: [
|
||||
{ app_code: "lalu", available_balance: 37950017, balance: 38559017, materialized: true, pool_id: "lucky", reserve_floor: 609000 },
|
||||
{ app_code: "lalu", available_balance: 361500, balance: 382500, materialized: false, pool_id: "default", reserve_floor: 21000 }
|
||||
]
|
||||
{
|
||||
app_code: "lalu",
|
||||
available_balance: 37950017,
|
||||
balance: 38559017,
|
||||
materialized: true,
|
||||
pool_id: "lucky",
|
||||
reserve_floor: 609000,
|
||||
},
|
||||
{
|
||||
app_code: "lalu",
|
||||
available_balance: 361500,
|
||||
balance: 382500,
|
||||
materialized: false,
|
||||
pool_id: "default",
|
||||
reserve_floor: 21000,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (text.includes("/lucky-gifts/summary?")) {
|
||||
return jsonResponse({ granted_draws: 9, pending_draws: 1, total_draws: 10, total_reward_coins: 900, total_spent_coins: 1000 });
|
||||
return jsonResponse({
|
||||
granted_draws: 9,
|
||||
pending_draws: 1,
|
||||
total_draws: 10,
|
||||
total_reward_coins: 900,
|
||||
total_spent_coins: 1000,
|
||||
});
|
||||
}
|
||||
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/lucky-gifts/configs?app_code=lalu",
|
||||
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools?app_code=lalu",
|
||||
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu"
|
||||
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu",
|
||||
]);
|
||||
expect(data.luckyGifts).toEqual([
|
||||
expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "lucky", rule_version: 3 }),
|
||||
expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "super_lucky", rule_version: 2 })
|
||||
expect.objectContaining({ app_code: "lalu", is_default: false, pool_id: "super_lucky", rule_version: 2 }),
|
||||
]);
|
||||
expect(data.summary).toMatchObject({
|
||||
activeApps: 1,
|
||||
configuredPools: 2,
|
||||
enabledPools: 1,
|
||||
poolBalanceTotal: 38941517,
|
||||
totalDraws: 10
|
||||
totalDraws: 10,
|
||||
});
|
||||
});
|
||||
|
||||
@ -81,7 +101,13 @@ test("falls back to the server default draft when an app has no published config
|
||||
return jsonResponse([]);
|
||||
}
|
||||
if (text.includes("/lucky-gifts/config?")) {
|
||||
return jsonResponse({ app_code: "yumi", enabled: false, pool_id: "default", rule_version: 0, target_rtp_percent: 95 });
|
||||
return jsonResponse({
|
||||
app_code: "yumi",
|
||||
enabled: false,
|
||||
pool_id: "default",
|
||||
rule_version: 0,
|
||||
target_rtp_percent: 95,
|
||||
});
|
||||
}
|
||||
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(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({
|
||||
anchor_daily_payout_cap: 12_312_000,
|
||||
anchor_rate_percent: 1,
|
||||
app_code: "aslan",
|
||||
pool_id: "default",
|
||||
control_band_percent: 3,
|
||||
device_daily_payout_cap: 1_026_000,
|
||||
enabled: true,
|
||||
target_rtp_percent: 95,
|
||||
pool_rate_percent: 96,
|
||||
settlement_window_wager: 1_000_000,
|
||||
control_band_percent: 1,
|
||||
gift_price_reference: 100,
|
||||
stages: [{ stage: "novice", tiers: [{ multiplier: 1, probabilityPercent: 100, enabled: true }] }]
|
||||
high_water_nonzero_factor_percent: 130,
|
||||
high_watermark_coins: 20_000_000,
|
||||
initial_pool_coins: 1_000_000,
|
||||
jackpot_global_rtp_max_percent: 98,
|
||||
jackpot_multipliers: [200, 500, 1000],
|
||||
jackpot_spend_threshold_coins: 50_000,
|
||||
jackpot_user_72h_rtp_max_percent: 96,
|
||||
jackpot_user_day_rtp_max_percent: 96,
|
||||
loss_streak_guarantee: 5,
|
||||
low_water_nonzero_factor_percent: 70,
|
||||
low_watermark_coins: 10_000_000,
|
||||
max_jackpot_hits_per_user_day: 5,
|
||||
max_single_payout: 50_000,
|
||||
normal_max_equivalent_draws: 20_000,
|
||||
novice_max_equivalent_draws: 2_000,
|
||||
pool_id: "default",
|
||||
pool_rate_percent: 98,
|
||||
profit_rate_percent: 1,
|
||||
recharge_boost_factor_percent: 110,
|
||||
recharge_boost_window_ms: 300_000,
|
||||
room_hourly_payout_cap: 684_000,
|
||||
settlement_window_wager: 1_000_000,
|
||||
stages: [
|
||||
{
|
||||
min_recharge_30d_coins: 0,
|
||||
min_recharge_7d_coins: 0,
|
||||
stage: "novice",
|
||||
tiers: [{ multiplier: 1, probabilityPercent: 100, enabled: true }],
|
||||
},
|
||||
],
|
||||
strategy_version: "dynamic_v3",
|
||||
target_rtp_percent: 98,
|
||||
user_daily_payout_cap: 615_600,
|
||||
user_hourly_payout_cap: 34_200,
|
||||
});
|
||||
|
||||
expect(calls[0].method).toBe("PUT");
|
||||
expect(calls[0].url).toBe("http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=aslan&pool_id=default");
|
||||
expect(JSON.parse(calls[0].body)).toMatchObject({
|
||||
expect(calls[0].url).toBe(
|
||||
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/config?app_code=aslan&pool_id=default",
|
||||
);
|
||||
// 精确比较完整 owner payload;子集断言会让新增/遗漏的策略字段在回归中静默通过。
|
||||
expect(JSON.parse(calls[0].body)).toEqual({
|
||||
anchor_daily_payout_cap: 12_312_000,
|
||||
anchor_rate_percent: 1,
|
||||
app_code: "aslan",
|
||||
control_band_percent: 3,
|
||||
device_daily_payout_cap: 1_026_000,
|
||||
effective_from_ms: 0,
|
||||
enabled: true,
|
||||
gift_price_reference: 100,
|
||||
high_water_nonzero_factor_percent: 130,
|
||||
high_watermark_coins: 20_000_000,
|
||||
initial_pool_coins: 1_000_000,
|
||||
jackpot_global_rtp_max_percent: 98,
|
||||
jackpot_multipliers: [200, 500, 1000],
|
||||
jackpot_spend_threshold_coins: 50_000,
|
||||
jackpot_user_72h_rtp_max_percent: 96,
|
||||
jackpot_user_day_rtp_max_percent: 96,
|
||||
loss_streak_guarantee: 5,
|
||||
low_water_nonzero_factor_percent: 70,
|
||||
low_watermark_coins: 10_000_000,
|
||||
max_jackpot_hits_per_user_day: 5,
|
||||
max_single_payout: 50_000,
|
||||
normal_max_equivalent_draws: 20_000,
|
||||
novice_max_equivalent_draws: 2_000,
|
||||
pool_id: "default",
|
||||
stages: [{ stage: "novice", tiers: [{ probability_percent: 100 }] }],
|
||||
target_rtp_percent: 95
|
||||
pool_rate_percent: 98,
|
||||
profit_rate_percent: 1,
|
||||
recharge_boost_factor_percent: 110,
|
||||
recharge_boost_window_ms: 300_000,
|
||||
room_hourly_payout_cap: 684_000,
|
||||
settlement_window_wager: 1_000_000,
|
||||
stages: [
|
||||
{
|
||||
min_recharge_30d_coins: 0,
|
||||
min_recharge_7d_coins: 0,
|
||||
stage: "novice",
|
||||
tiers: [
|
||||
{
|
||||
enabled: true,
|
||||
high_water_only: false,
|
||||
multiplier: 1,
|
||||
probability_percent: 100,
|
||||
tier_id: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
strategy_version: "dynamic_v3",
|
||||
target_rtp_percent: 98,
|
||||
user_daily_payout_cap: 615_600,
|
||||
user_hourly_payout_cap: 34_200,
|
||||
});
|
||||
});
|
||||
|
||||
@ -11,13 +11,17 @@ export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
|
||||
const [appFilter, setAppFilter] = useState("");
|
||||
|
||||
const appOptions = useMemo(
|
||||
() => apps.map((item) => [item.app_code ?? item.appCode, `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`]),
|
||||
[apps]
|
||||
() =>
|
||||
apps.map((item) => [
|
||||
item.app_code ?? item.appCode,
|
||||
`${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`,
|
||||
]),
|
||||
[apps],
|
||||
);
|
||||
// 配置列表已在 dashboard 阶段跨应用拉全,应用筛选只做前端过滤,避免重复扇出请求。
|
||||
const items = useMemo(
|
||||
() => (appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts),
|
||||
[appFilter, luckyGifts]
|
||||
[appFilter, luckyGifts],
|
||||
);
|
||||
|
||||
const columns = useMemo(() => buildColumns({ onEdit, saving }), [onEdit, saving]);
|
||||
@ -26,17 +30,29 @@ export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
|
||||
<div className="ops-view">
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
<Button disabled={saving} startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={onCreate}>
|
||||
<Button
|
||||
disabled={saving}
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
variant="primary"
|
||||
onClick={onCreate}
|
||||
>
|
||||
添加配置
|
||||
</Button>
|
||||
}
|
||||
filters={<AdminFilterSelect label="应用" options={[["", "全部应用"], ...appOptions]} value={appFilter} onChange={setAppFilter} />}
|
||||
filters={
|
||||
<AdminFilterSelect
|
||||
label="应用"
|
||||
options={[["", "全部应用"], ...appOptions]}
|
||||
value={appFilter}
|
||||
onChange={setAppFilter}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
emptyLabel="暂无幸运礼物配置"
|
||||
items={items}
|
||||
minWidth="1080px"
|
||||
minWidth="1260px"
|
||||
rowKey={(item) => `${item.app_code}:${item.pool_id}:${item.rule_version}`}
|
||||
title="幸运礼物配置"
|
||||
total={items.length}
|
||||
@ -53,7 +69,15 @@ function buildColumns({ onEdit, saving }) {
|
||||
key: "rule_version",
|
||||
label: "版本",
|
||||
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",
|
||||
@ -63,19 +87,33 @@ function buildColumns({ onEdit, saving }) {
|
||||
if (item.is_default) {
|
||||
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: "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: "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",
|
||||
label: "发布时间",
|
||||
render: (item) => (item.is_default ? "-" : <TimeText value={item.created_at_ms} />),
|
||||
width: "minmax(176px, 1.1fr)"
|
||||
width: "minmax(176px, 1.1fr)",
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
@ -84,7 +122,7 @@ function buildColumns({ onEdit, saving }) {
|
||||
<Button disabled={saving} size="small" onClick={() => onEdit(item)}>
|
||||
{item.is_default ? "发布配置" : "新增版本"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@ -12,11 +12,13 @@ import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { formatDecimal } from "@/shared/utils/rtpProbability.js";
|
||||
import {
|
||||
applyDynamicStrategyDefaults,
|
||||
configToForm,
|
||||
correctAllStageProbabilities,
|
||||
formToConfig,
|
||||
nextStageMultiplier,
|
||||
stageSummary
|
||||
stageSummary,
|
||||
validateLuckyGiftForm,
|
||||
} from "../configForm.js";
|
||||
import { formatNumber, formatPercent } from "../format.js";
|
||||
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
||||
@ -24,21 +26,37 @@ import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
||||
export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) {
|
||||
const [form, setForm] = useState(() => configToForm(config));
|
||||
const [activeStage, setActiveStage] = useState("novice");
|
||||
const [validationErrors, setValidationErrors] = useState([]);
|
||||
|
||||
const stageSummaries = useMemo(() => form.stages.map((stage) => stageSummary(stage, form)), [form]);
|
||||
const activeStageKey = form.stages.some((stage) => stage.stage === activeStage) ? activeStage : form.stages[0]?.stage;
|
||||
const activeStageKey = form.stages.some((stage) => stage.stage === activeStage)
|
||||
? activeStage
|
||||
: form.stages[0]?.stage;
|
||||
const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey);
|
||||
const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey);
|
||||
|
||||
const updateField = (field, value) => {
|
||||
setValidationErrors([]);
|
||||
setForm((current) => {
|
||||
const next = { ...current, [field]: value };
|
||||
if (field === "strategy_version" && value === "dynamic_v3" && current.strategy_version !== "dynamic_v3") {
|
||||
return applyDynamicStrategyDefaults(next);
|
||||
}
|
||||
// 目标 RTP 决定各奖档概率的闭合解,改动后全部阶段重算,避免展示的概率和将要发布的值不一致。
|
||||
return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next;
|
||||
});
|
||||
};
|
||||
|
||||
const 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) => {
|
||||
setValidationErrors([]);
|
||||
setForm((current) =>
|
||||
correctAllStageProbabilities({
|
||||
...current,
|
||||
@ -46,15 +64,18 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
||||
stage.stage === stageKey
|
||||
? {
|
||||
...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) => {
|
||||
setValidationErrors([]);
|
||||
setForm((current) =>
|
||||
correctAllStageProbabilities({
|
||||
...current,
|
||||
@ -70,29 +91,37 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
||||
highWaterOnly: nextStageMultiplier(stage.tiers) >= 10,
|
||||
multiplier: String(nextStageMultiplier(stage.tiers)),
|
||||
probabilityPercent: "0",
|
||||
tierId: ""
|
||||
tierId: "",
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
: stage
|
||||
)
|
||||
})
|
||||
: stage,
|
||||
),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const removeStageTier = (stageKey, tierIndex) => {
|
||||
setValidationErrors([]);
|
||||
setForm((current) =>
|
||||
correctAllStageProbabilities({
|
||||
...current,
|
||||
stages: current.stages.map((stage) =>
|
||||
stage.stage === stageKey ? { ...stage, tiers: stage.tiers.filter((_, index) => index !== tierIndex) } : stage
|
||||
)
|
||||
})
|
||||
stage.stage === stageKey
|
||||
? { ...stage, tiers: stage.tiers.filter((_, index) => index !== tierIndex) }
|
||||
: stage,
|
||||
),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const errors = validateLuckyGiftForm(form);
|
||||
if (errors.length) {
|
||||
setValidationErrors(errors);
|
||||
return;
|
||||
}
|
||||
onSubmit(formToConfig(config, form));
|
||||
};
|
||||
|
||||
@ -113,12 +142,27 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
||||
<div className="ops-config-metrics">
|
||||
<ConfigMetric label="应用" value={form.app_code || "-"} />
|
||||
<ConfigMetric label="奖池" value={form.pool_id || "default"} />
|
||||
<ConfigMetric label="策略" value={form.strategy_version} />
|
||||
<ConfigMetric label="目标 RTP" value={formatPercent(Number(form.target_rtp_percent))} />
|
||||
<ConfigMetric label="奖池回收" value={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={form.enabled ? "启用" : "停用"} />
|
||||
</div>
|
||||
|
||||
{validationErrors.length ? (
|
||||
<div className="ops-config-validation" role="alert">
|
||||
<strong>配置不能发布</strong>
|
||||
<ul>
|
||||
{validationErrors.map((message) => (
|
||||
<li key={message}>{message}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="ops-config-layout">
|
||||
<aside className="ops-config-params" aria-label="基础参数">
|
||||
<section className="ops-config-section">
|
||||
@ -151,8 +195,24 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
||||
value={form.pool_id}
|
||||
onChange={(event) => updateField("pool_id", event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="策略版本"
|
||||
select
|
||||
size="small"
|
||||
value={form.strategy_version}
|
||||
onChange={(event) => updateField("strategy_version", event.target.value)}
|
||||
>
|
||||
<MenuItem value="dynamic_v3">dynamic_v3</MenuItem>
|
||||
<MenuItem value="fixed_v2">fixed_v2(历史兼容)</MenuItem>
|
||||
</TextField>
|
||||
<FormControlLabel
|
||||
control={<AdminSwitch checked={form.enabled} onChange={(event) => updateField("enabled", event.target.checked)} />}
|
||||
control={
|
||||
<AdminSwitch
|
||||
checked={form.enabled}
|
||||
onChange={(event) => updateField("enabled", event.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={form.enabled ? "发布后立即启用" : "发布后保持停用"}
|
||||
sx={{ gap: 1, marginLeft: 0 }}
|
||||
/>
|
||||
@ -161,13 +221,71 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
||||
<section className="ops-config-section">
|
||||
<h3>RTP 与奖池</h3>
|
||||
<div className="ops-form-grid">
|
||||
<NumberField form={form} label="目标 RTP (%)" name="target_rtp_percent" step="0.01" onChange={updateField} />
|
||||
<NumberField form={form} label="奖池回收率 (%)" name="pool_rate_percent" step="0.01" onChange={updateField} />
|
||||
<NumberField form={form} label="控制带宽 (%)" name="control_band_percent" step="0.01" onChange={updateField} />
|
||||
<NumberField form={form} label="结算窗口流水" name="settlement_window_wager" onChange={updateField} />
|
||||
<NumberField form={form} label="礼物参考金额" name="gift_price_reference" onChange={updateField} />
|
||||
<NumberField
|
||||
form={form}
|
||||
label="目标 RTP (%)"
|
||||
name="target_rtp_percent"
|
||||
step="0.01"
|
||||
onChange={updateField}
|
||||
/>
|
||||
<NumberField
|
||||
form={form}
|
||||
label="公共奖池 (%)"
|
||||
name="pool_rate_percent"
|
||||
step="0.01"
|
||||
onChange={updateField}
|
||||
/>
|
||||
<NumberField
|
||||
form={form}
|
||||
label="平台盈利 (%)"
|
||||
name="profit_rate_percent"
|
||||
step="0.01"
|
||||
onChange={updateField}
|
||||
/>
|
||||
<NumberField
|
||||
form={form}
|
||||
helperText="必须与该奖池实际送礼类型(lucky / super_lucky)在各适用地区的返币比例一致,否则开奖账务校验会失败"
|
||||
label="主播收益 (%)"
|
||||
name="anchor_rate_percent"
|
||||
step="0.01"
|
||||
onChange={updateField}
|
||||
/>
|
||||
<NumberField
|
||||
form={form}
|
||||
label="控制带宽 (%)"
|
||||
name="control_band_percent"
|
||||
step="0.01"
|
||||
onChange={updateField}
|
||||
/>
|
||||
<NumberField
|
||||
form={form}
|
||||
label="结算窗口流水"
|
||||
name="settlement_window_wager"
|
||||
onChange={updateField}
|
||||
/>
|
||||
<NumberField
|
||||
form={form}
|
||||
label="礼物参考金额"
|
||||
name="gift_price_reference"
|
||||
onChange={updateField}
|
||||
/>
|
||||
<NumberField
|
||||
form={form}
|
||||
label="新手等价抽数"
|
||||
name="novice_max_equivalent_draws"
|
||||
onChange={updateField}
|
||||
/>
|
||||
<NumberField
|
||||
form={form}
|
||||
label="普通等价抽数"
|
||||
name="normal_max_equivalent_draws"
|
||||
onChange={updateField}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{form.strategy_version === "dynamic_v3" ? (
|
||||
<DynamicStrategySections form={form} onChange={updateField} />
|
||||
) : null}
|
||||
</aside>
|
||||
|
||||
<section className="ops-stage-designer" aria-label="阶段奖档设计">
|
||||
@ -195,10 +313,47 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
||||
tone={activeSummary?.ok ? "succeeded" : "warning"}
|
||||
/>
|
||||
</div>
|
||||
<Button startIcon={<AddOutlined fontSize="small" />} onClick={() => addStageTier(activeStageConfig.stage)}>
|
||||
<Button
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
onClick={() => addStageTier(activeStageConfig.stage)}
|
||||
>
|
||||
添加奖档
|
||||
</Button>
|
||||
</div>
|
||||
{form.strategy_version === "dynamic_v3" ? (
|
||||
<div className="ops-stage-thresholds">
|
||||
<TextField
|
||||
disabled={activeStageConfig.stage === "novice"}
|
||||
label="7 日充值门槛"
|
||||
size="small"
|
||||
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||||
type="number"
|
||||
value={activeStageConfig.min_recharge_7d_coins}
|
||||
onChange={(event) =>
|
||||
updateStageField(
|
||||
activeStageConfig.stage,
|
||||
"min_recharge_7d_coins",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
disabled={activeStageConfig.stage === "novice"}
|
||||
label="30 日充值门槛"
|
||||
size="small"
|
||||
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||||
type="number"
|
||||
value={activeStageConfig.min_recharge_30d_coins}
|
||||
onChange={(event) =>
|
||||
updateStageField(
|
||||
activeStageConfig.stage,
|
||||
"min_recharge_30d_coins",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="ops-tier-table">
|
||||
<div className="ops-tier-head" aria-hidden="true">
|
||||
<span>倍率</span>
|
||||
@ -212,21 +367,26 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
||||
<TextField
|
||||
required
|
||||
size="small"
|
||||
slotProps={{ htmlInput: { "aria-label": "倍率", min: 0, step: "0.01" } }}
|
||||
slotProps={{
|
||||
htmlInput: { "aria-label": "倍率", min: 0, step: "0.01" },
|
||||
}}
|
||||
type="number"
|
||||
value={tier.multiplier}
|
||||
onChange={(event) =>
|
||||
updateStageTier(activeStageConfig.stage, index, {
|
||||
// 10 倍及以上强制只走高水位,输入时同步勾选并锁死,防止高倍档在低水位放开。
|
||||
highWaterOnly: Number(event.target.value) >= 10 || tier.highWaterOnly,
|
||||
multiplier: event.target.value
|
||||
highWaterOnly:
|
||||
Number(event.target.value) >= 10 || tier.highWaterOnly,
|
||||
multiplier: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */}
|
||||
<TextField
|
||||
size="small"
|
||||
slotProps={{ htmlInput: { "aria-label": "概率 %", readOnly: true } }}
|
||||
slotProps={{
|
||||
htmlInput: { "aria-label": "概率 %", readOnly: true },
|
||||
}}
|
||||
value={formatDecimal(tier.probabilityPercent)}
|
||||
/>
|
||||
<Checkbox
|
||||
@ -234,13 +394,21 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
||||
disabled={Number(tier.multiplier) >= 10}
|
||||
size="small"
|
||||
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
|
||||
checked={tier.enabled !== false}
|
||||
size="small"
|
||||
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
|
||||
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 }) {
|
||||
return (
|
||||
<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 (
|
||||
<TextField
|
||||
helperText={helperText}
|
||||
label={label}
|
||||
required
|
||||
size="small"
|
||||
|
||||
@ -5,41 +5,172 @@ import {
|
||||
decimal,
|
||||
expectedRTPPercent,
|
||||
formatDecimal,
|
||||
probabilityTotal
|
||||
probabilityTotal,
|
||||
} from "@/shared/utils/rtpProbability.js";
|
||||
import { DEFAULT_POOL_ID } from "./api.js";
|
||||
|
||||
// 幸运礼物配置表单的纯领域逻辑:配置 <-> 表单换算、概率闭合校正、阶段摘要。
|
||||
// 与展示层分离,弹窗组件只负责渲染和事件绑定。
|
||||
const DYNAMIC_STRATEGY = "dynamic_v3";
|
||||
const PPM_SCALE = 1_000_000;
|
||||
|
||||
// 只有跨 App 通用的算法参数才有默认值;50 美元等值门槛和六维金额上限必须由运营按 App 金币口径填写,
|
||||
// 因而默认保持 0 且规则停用,避免把某个 App 的预算误发到其他 App。
|
||||
const dynamicDefaults = {
|
||||
anchor_daily_payout_cap: 0,
|
||||
anchor_rate_percent: 1,
|
||||
control_band_percent: 3,
|
||||
device_daily_payout_cap: 0,
|
||||
high_water_nonzero_factor_percent: 130,
|
||||
high_watermark_coins: 20_000_000,
|
||||
initial_pool_coins: 1_000_000,
|
||||
jackpot_global_rtp_max_percent: 98,
|
||||
jackpot_multipliers: [200, 500, 1000],
|
||||
jackpot_spend_threshold_coins: 0,
|
||||
jackpot_user_72h_rtp_max_percent: 96,
|
||||
jackpot_user_day_rtp_max_percent: 96,
|
||||
loss_streak_guarantee: 5,
|
||||
low_water_nonzero_factor_percent: 70,
|
||||
low_watermark_coins: 10_000_000,
|
||||
max_jackpot_hits_per_user_day: 5,
|
||||
max_single_payout: 0,
|
||||
pool_rate_percent: 98,
|
||||
profit_rate_percent: 1,
|
||||
recharge_boost_factor_percent: 110,
|
||||
recharge_boost_window_ms: 300_000,
|
||||
room_hourly_payout_cap: 0,
|
||||
target_rtp_percent: 98,
|
||||
user_daily_payout_cap: 0,
|
||||
user_hourly_payout_cap: 0,
|
||||
};
|
||||
|
||||
const numericFields = [
|
||||
"anchor_daily_payout_cap",
|
||||
"anchor_rate_percent",
|
||||
"control_band_percent",
|
||||
"device_daily_payout_cap",
|
||||
"effective_from_ms",
|
||||
"gift_price_reference",
|
||||
"high_water_nonzero_factor_percent",
|
||||
"high_watermark_coins",
|
||||
"initial_pool_coins",
|
||||
"jackpot_global_rtp_max_percent",
|
||||
"jackpot_spend_threshold_coins",
|
||||
"jackpot_user_72h_rtp_max_percent",
|
||||
"jackpot_user_day_rtp_max_percent",
|
||||
"loss_streak_guarantee",
|
||||
"low_water_nonzero_factor_percent",
|
||||
"low_watermark_coins",
|
||||
"max_jackpot_hits_per_user_day",
|
||||
"max_single_payout",
|
||||
"normal_max_equivalent_draws",
|
||||
"novice_max_equivalent_draws",
|
||||
"pool_rate_percent",
|
||||
"profit_rate_percent",
|
||||
"recharge_boost_factor_percent",
|
||||
"recharge_boost_window_ms",
|
||||
"room_hourly_payout_cap",
|
||||
"settlement_window_wager",
|
||||
"target_rtp_percent",
|
||||
"user_daily_payout_cap",
|
||||
"user_hourly_payout_cap",
|
||||
];
|
||||
|
||||
const payoutCapFields = [
|
||||
["max_single_payout", "单次返奖上限"],
|
||||
["user_hourly_payout_cap", "用户小时上限"],
|
||||
["user_daily_payout_cap", "用户每日上限"],
|
||||
["device_daily_payout_cap", "设备每日上限"],
|
||||
["room_hourly_payout_cap", "房间小时上限"],
|
||||
["anchor_daily_payout_cap", "主播每日上限"],
|
||||
];
|
||||
|
||||
export function configToForm(config = {}) {
|
||||
const targetRTPPercent = formNumber(config.target_rtp_percent ?? config.targetRtpPercent);
|
||||
return {
|
||||
const hasPersistedShape = Object.keys(config).length > 0;
|
||||
const strategyVersion = stringConfigValue(
|
||||
config,
|
||||
"strategy_version",
|
||||
"strategyVersion",
|
||||
hasPersistedShape ? "fixed_v2" : DYNAMIC_STRATEGY,
|
||||
);
|
||||
const targetRTPPercent = formConfigNumber(
|
||||
config,
|
||||
"target_rtp_percent",
|
||||
"targetRtpPercent",
|
||||
strategyVersion === DYNAMIC_STRATEGY ? dynamicDefaults.target_rtp_percent : 95,
|
||||
);
|
||||
const form = {
|
||||
app_code: config.app_code || config.appCode || "",
|
||||
control_band_percent: formNumber(config.control_band_percent ?? config.controlBandPercent),
|
||||
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_rate_percent: formNumber(config.pool_rate_percent ?? config.poolRatePercent),
|
||||
settlement_window_wager: formNumber(config.settlement_window_wager ?? config.settlementWindowWager),
|
||||
stages: normalizeFormStages(config.stages, targetRTPPercent),
|
||||
target_rtp_percent: targetRTPPercent
|
||||
stages: normalizeFormStages(config.stages, targetRTPPercent, strategyVersion),
|
||||
strategy_version: strategyVersion,
|
||||
};
|
||||
for (const field of numericFields) {
|
||||
if (field === "target_rtp_percent") {
|
||||
form[field] = targetRTPPercent;
|
||||
continue;
|
||||
}
|
||||
form[field] = formConfigNumber(config, field, snakeToCamel(field), defaultNumber(field, strategyVersion));
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
export function formToConfig(config, form) {
|
||||
return {
|
||||
const output = {
|
||||
...config,
|
||||
app_code: form.app_code,
|
||||
control_band_percent: numberFromForm(form.control_band_percent),
|
||||
enabled: Boolean(form.enabled),
|
||||
gift_price_reference: numberFromForm(form.gift_price_reference),
|
||||
jackpot_multipliers: multiplierListFromForm(form.jackpot_multipliers),
|
||||
pool_id: form.pool_id,
|
||||
pool_rate_percent: numberFromForm(form.pool_rate_percent),
|
||||
settlement_window_wager: numberFromForm(form.settlement_window_wager),
|
||||
stages: form.stages,
|
||||
target_rtp_percent: numberFromForm(form.target_rtp_percent)
|
||||
stages: (form.stages || []).map((stage) => ({
|
||||
min_recharge_30d_coins: numberFromForm(stage.min_recharge_30d_coins),
|
||||
min_recharge_7d_coins: numberFromForm(stage.min_recharge_7d_coins),
|
||||
stage: stage.stage,
|
||||
tiers: (stage.tiers || []).map((tier) => ({
|
||||
enabled: tier.enabled !== false,
|
||||
high_water_only: Boolean(tier.highWaterOnly),
|
||||
multiplier: numberFromForm(tier.multiplier),
|
||||
probability_percent: numberFromForm(tier.probabilityPercent),
|
||||
tier_id: tier.tierId || "",
|
||||
})),
|
||||
})),
|
||||
strategy_version: form.strategy_version,
|
||||
};
|
||||
for (const field of numericFields) {
|
||||
output[field] = numberFromForm(form[field]);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
// 从历史 fixed_v2 显式切换到 dynamic_v3 时,先停用并载入安全草稿;金额型上限仍保持 0,
|
||||
// 运营补齐后才能通过启用校验,避免一次策略切换直接发布无法结算的规则。
|
||||
export function applyDynamicStrategyDefaults(form) {
|
||||
const next = {
|
||||
...form,
|
||||
enabled: false,
|
||||
jackpot_multipliers: dynamicDefaults.jackpot_multipliers.join(", "),
|
||||
strategy_version: DYNAMIC_STRATEGY,
|
||||
};
|
||||
for (const [field, value] of Object.entries(dynamicDefaults)) {
|
||||
if (field !== "jackpot_multipliers") {
|
||||
next[field] = String(value);
|
||||
}
|
||||
}
|
||||
next.stages = stageOptions.map(([stage]) => {
|
||||
const [min7d, min30d] = defaultRechargeThreshold(stage);
|
||||
return {
|
||||
min_recharge_7d_coins: String(min7d),
|
||||
min_recharge_30d_coins: String(min30d),
|
||||
stage,
|
||||
// fixed_v2 可能含历史高倍基础档;切换策略时必须回到 owner 的 98% 安全基线,
|
||||
// 不能只改策略名后把旧概率意外带入 dynamic_v3 的第一版。
|
||||
tiers: correctedDefaultTiers(stage, dynamicDefaults.target_rtp_percent),
|
||||
};
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
// 概率列必须整体闭合到 100% 且期望 RTP 逼近目标值,所以任何倍率/目标 RTP 改动后都重算全阶段概率,
|
||||
@ -49,27 +180,26 @@ export function correctAllStageProbabilities(form) {
|
||||
...form,
|
||||
stages: (form.stages || []).map((stage) => {
|
||||
const probabilityByIndex = correctRTPProbabilities(stage.tiers, form.target_rtp_percent, {
|
||||
multiplier: (item) => item.multiplier
|
||||
multiplier: (item) => item.multiplier,
|
||||
});
|
||||
return {
|
||||
...stage,
|
||||
tiers: probabilityByIndex ? applyProbabilityMap(stage.tiers, probabilityByIndex, formatDecimal) : stage.tiers
|
||||
tiers: probabilityByIndex
|
||||
? applyProbabilityMap(stage.tiers, probabilityByIndex, formatDecimal)
|
||||
: stage.tiers,
|
||||
};
|
||||
})
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function stageSummary(stage, form) {
|
||||
const probability = probabilityTotal(stage.tiers);
|
||||
const expected = expectedRTPPercent(stage.tiers, {
|
||||
multiplier: (item) => item.multiplier
|
||||
});
|
||||
const expected = expectedRTPPercent(stage.tiers, { multiplier: (item) => item.multiplier });
|
||||
const target = Number(form.target_rtp_percent || 0);
|
||||
const band = Number(form.control_band_percent || 0);
|
||||
const probabilityClosed = Math.abs(probability - 100) < 0.0001;
|
||||
const rtpInBand = expected >= target - band && expected <= target + band;
|
||||
const fullLabel = stageOptions.find(([key]) => key === stage.stage)?.[1] || stage.stage;
|
||||
|
||||
return {
|
||||
expected,
|
||||
ok: probabilityClosed && rtpInBand,
|
||||
@ -78,73 +208,243 @@ export function stageSummary(stage, form) {
|
||||
rtpInBand,
|
||||
stageKey: stage.stage,
|
||||
stageLabel: fullLabel,
|
||||
// 发布按钮不做硬拦截(服务端仍会校验),这里的状态只用于提示运营当前阶段是否可安全发布。
|
||||
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 = []) {
|
||||
const positive = tiers.map((tier) => decimal(tier.multiplier)).filter((value) => value > 0);
|
||||
if (!positive.length) {
|
||||
return 1;
|
||||
}
|
||||
if (!positive.length) return 1;
|
||||
const max = Math.max(...positive);
|
||||
if (max >= 5) {
|
||||
return max * 2;
|
||||
}
|
||||
if (max >= 2) {
|
||||
return 5;
|
||||
}
|
||||
if (max >= 5) return max * 2;
|
||||
if (max >= 2) return 5;
|
||||
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]));
|
||||
return correctAllStageProbabilities({
|
||||
stages: stageOptions.map(([stageKey]) => {
|
||||
return stageOptions.map(([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 {
|
||||
min_recharge_7d_coins: formNumber(source?.min_recharge_7d_coins ?? source?.minRecharge7DCoins ?? default7d),
|
||||
min_recharge_30d_coins: formNumber(
|
||||
source?.min_recharge_30d_coins ?? source?.minRecharge30DCoins ?? default30d,
|
||||
),
|
||||
stage: stageKey,
|
||||
tiers: sourceTiers.map((tier) => ({
|
||||
enabled: tier.enabled !== false,
|
||||
highWaterOnly: Boolean(tier.high_water_only ?? tier.highWaterOnly),
|
||||
multiplier: formNumber(tier.multiplier),
|
||||
probabilityPercent: formNumber(tier.probability_percent ?? tier.probabilityPercent),
|
||||
tierId: tier.tier_id || tier.tierId || ""
|
||||
}))
|
||||
tierId: tier.tier_id || tier.tierId || "",
|
||||
})),
|
||||
};
|
||||
}),
|
||||
target_rtp_percent: targetRTPPercent
|
||||
}).stages;
|
||||
});
|
||||
}
|
||||
|
||||
function defaultStageTiers(stage) {
|
||||
if (stage === "advanced") {
|
||||
return [
|
||||
defaultTier("advanced_none", 0, 27),
|
||||
defaultTier("advanced_1x", 1, 60),
|
||||
defaultTier("advanced_2x", 2, 10),
|
||||
defaultTier("advanced_5x", 5, 3, { high_water_only: true })
|
||||
function correctedDefaultTiers(stage, targetRTPPercent) {
|
||||
const tiers = [
|
||||
defaultTier(`${stage}_none`, 0, 5),
|
||||
defaultTier(`${stage}_0_5x`, 0.5, 4),
|
||||
defaultTier(`${stage}_1x`, 1, 86),
|
||||
defaultTier(`${stage}_2x`, 2, 5),
|
||||
];
|
||||
// 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 [
|
||||
defaultTier(`${stage}_none`, 0, 10),
|
||||
defaultTier(`${stage}_0_5x`, 0.5, 20),
|
||||
defaultTier(`${stage}_1x`, 1, 55),
|
||||
defaultTier(`${stage}_2x`, 2, 15)
|
||||
];
|
||||
const probabilities = correctRTPProbabilities(tiers, targetRTPPercent, { multiplier: (item) => item.multiplier });
|
||||
return probabilities ? applyProbabilityMap(tiers, probabilities, formatDecimal) : tiers;
|
||||
}
|
||||
|
||||
function defaultTier(tierID, multiplier, probabilityPercent, options = {}) {
|
||||
return {
|
||||
enabled: true,
|
||||
highWaterOnly: Boolean(options.high_water_only),
|
||||
multiplier,
|
||||
probabilityPercent,
|
||||
tierId: tierID
|
||||
};
|
||||
function defaultTier(tierID, multiplier, probabilityPercent) {
|
||||
return { enabled: true, highWaterOnly: false, multiplier, probabilityPercent, tierId: tierID };
|
||||
}
|
||||
|
||||
function validateRechargeStages(stages, errors) {
|
||||
const novice = stages.find((stage) => stage.stage === "novice");
|
||||
const normal = stages.find((stage) => stage.stage === "normal");
|
||||
const advanced = stages.find((stage) => stage.stage === "advanced");
|
||||
const threshold = (stage, field) => numberFromForm(stage?.[field]);
|
||||
if (threshold(novice, "min_recharge_7d_coins") !== 0 || threshold(novice, "min_recharge_30d_coins") !== 0)
|
||||
errors.push("新手阶段充值门槛必须为 0 / 0");
|
||||
const normal7d = threshold(normal, "min_recharge_7d_coins");
|
||||
const normal30d = threshold(normal, "min_recharge_30d_coins");
|
||||
const advanced7d = threshold(advanced, "min_recharge_7d_coins");
|
||||
const advanced30d = threshold(advanced, "min_recharge_30d_coins");
|
||||
if ([normal7d, normal30d, advanced7d, advanced30d].some((value) => value < 0)) {
|
||||
errors.push("充值阶段门槛不能小于 0");
|
||||
return;
|
||||
}
|
||||
if (normal7d === 0 && normal30d === 0) errors.push("正常阶段至少一个充值门槛必须大于 0");
|
||||
if (advanced7d < normal7d || advanced30d < normal30d || (advanced7d === normal7d && advanced30d === normal30d))
|
||||
errors.push("高阶充值门槛必须逐维不低于正常阶段且至少一项更高");
|
||||
}
|
||||
|
||||
function defaultRechargeThreshold(stage) {
|
||||
if (stage === "novice") return [0, 0];
|
||||
if (stage === "normal") return [0, 1];
|
||||
return [1, 1];
|
||||
}
|
||||
|
||||
function defaultNumber(field, strategyVersion) {
|
||||
if (field === "gift_price_reference") return 100;
|
||||
if (field === "settlement_window_wager") return 1_000_000;
|
||||
if (field === "novice_max_equivalent_draws") return 2_000;
|
||||
if (field === "normal_max_equivalent_draws") return 20_000;
|
||||
if (field === "effective_from_ms") return 0;
|
||||
return strategyVersion === DYNAMIC_STRATEGY ? (dynamicDefaults[field] ?? 0) : 0;
|
||||
}
|
||||
|
||||
function multiplierListToForm(value, strategyVersion) {
|
||||
if (Array.isArray(value) && value.length) return value.map(formatDecimal).join(", ");
|
||||
if (typeof value === "string" && value.trim()) return value;
|
||||
return strategyVersion === DYNAMIC_STRATEGY ? dynamicDefaults.jackpot_multipliers.join(", ") : "";
|
||||
}
|
||||
|
||||
function multiplierListFromForm(value) {
|
||||
return String(value || "")
|
||||
.split(/[,,\s]+/)
|
||||
.filter(Boolean)
|
||||
.map(Number)
|
||||
.filter(Number.isFinite);
|
||||
}
|
||||
|
||||
function stringConfigValue(config, snake, camel, fallback) {
|
||||
const value = config[snake] ?? config[camel];
|
||||
return String(value || fallback);
|
||||
}
|
||||
|
||||
function formConfigNumber(config, snake, camel, fallback) {
|
||||
return formNumber(config[snake] ?? config[camel] ?? fallback);
|
||||
}
|
||||
|
||||
function snakeToCamel(value) {
|
||||
return value.replace(/_([a-z0-9])/g, (_, letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function stageLabel(stage) {
|
||||
return stageOptions.find(([key]) => key === stage)?.[1] || stage;
|
||||
}
|
||||
|
||||
function percentToPPM(value) {
|
||||
return Math.round(Number(value || 0) * 10_000);
|
||||
}
|
||||
|
||||
function unique(values) {
|
||||
return Array.from(new Set(values));
|
||||
}
|
||||
|
||||
export function formNumber(value) {
|
||||
|
||||
217
ops-center/src/configForm.test.js
Normal file
217
ops-center/src/configForm.test.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -166,11 +166,29 @@ body {
|
||||
|
||||
.ops-config-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.ops-config-validation {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
border: 1px solid var(--danger-border, #fecaca);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--danger-surface, #fef2f2);
|
||||
color: var(--danger, #dc2626);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
.ops-config-validation ul {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.ops-config-metric {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
@ -201,7 +219,7 @@ body {
|
||||
|
||||
.ops-config-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(360px, 440px) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
@ -234,6 +252,10 @@ body {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.ops-form-grid__wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.ops-stage-designer {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
@ -316,6 +338,16 @@ body {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.ops-stage-thresholds {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-card-strong, #f8fafc);
|
||||
}
|
||||
|
||||
.ops-tier-table {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
@ -390,6 +422,7 @@ body {
|
||||
.ops-kpi-grid,
|
||||
.ops-config-metrics,
|
||||
.ops-form-grid,
|
||||
.ops-stage-thresholds,
|
||||
.ops-tier-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@ -1,204 +1,18 @@
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { LuckyGiftConfigDrawer } from "@/features/lucky-gift/components/LuckyGiftConfigDrawer.jsx";
|
||||
import { LuckyGiftConfigSummary } from "@/features/lucky-gift/components/LuckyGiftConfigSummary.jsx";
|
||||
import { LuckyGiftSimulationPanel } from "@/features/lucky-gift/components/LuckyGiftSimulationPanel.jsx";
|
||||
import { drawStatusOptions } from "@/features/lucky-gift/constants.js";
|
||||
import { useLuckyGiftPage } from "@/features/lucky-gift/hooks/useLuckyGiftPage.js";
|
||||
import styles from "@/features/lucky-gift/lucky-gift.module.css";
|
||||
import { AdminFilterResetButton, AdminFilterSelect, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const LUCKY_GIFT_OPS_CENTER_PATH = "/ops-center/?view=configs";
|
||||
|
||||
export function redirectToLuckyGiftOpsCenter(targetLocation = window.location) {
|
||||
// Ops Center 是独立 HTML 入口,React Router 的 SPA Navigate 会落回主后台兜底路由;
|
||||
// 必须触发文档级导航,才能加载新版跨 App 配置页并避免旧页面调用已下线的 activity/v2 接口。
|
||||
targetLocation.replace(LUCKY_GIFT_OPS_CENTER_PATH);
|
||||
}
|
||||
|
||||
export function LuckyGiftConfigPage() {
|
||||
const page = useLuckyGiftPage();
|
||||
useEffect(() => {
|
||||
redirectToLuckyGiftOpsCenter();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<LuckyGiftConfigSummary
|
||||
canUpdate={page.abilities.canUpdate}
|
||||
config={page.config}
|
||||
configLoading={page.configLoading}
|
||||
poolId={page.poolId}
|
||||
poolOptions={page.poolOptions}
|
||||
onAddPool={page.openAddPool}
|
||||
onEdit={page.openConfigDrawer}
|
||||
onPoolChange={page.changePoolId}
|
||||
onRefresh={page.reloadConfig}
|
||||
/>
|
||||
<LuckyGiftSimulationPanel config={page.config} />
|
||||
<LuckyGiftDrawSummaryPanel page={page} />
|
||||
<LuckyGiftConfigDrawer
|
||||
abilities={page.abilities}
|
||||
configLoading={page.configLoading}
|
||||
configSaving={page.configSaving}
|
||||
form={page.form}
|
||||
open={page.configDrawerOpen}
|
||||
setForm={page.setForm}
|
||||
onClose={page.closeConfigDrawer}
|
||||
onSubmit={page.submitConfig}
|
||||
/>
|
||||
<AddPoolDialog
|
||||
disabled={page.configSaving}
|
||||
open={page.addPoolOpen}
|
||||
value={page.addPoolId}
|
||||
onChange={page.setAddPoolId}
|
||||
onClose={page.closeAddPool}
|
||||
onSubmit={page.submitAddPool}
|
||||
/>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function LuckyGiftDrawSummaryPanel({ page }) {
|
||||
const summary = page.drawSummary || {};
|
||||
const netSpent = Number(summary.totalSpentCoins || 0) - Number(summary.totalRewardCoins || 0);
|
||||
const stats = [
|
||||
{
|
||||
hint: `房间 ${formatNumber(summary.uniqueRooms)}`,
|
||||
label: "参与用户",
|
||||
value: formatNumber(summary.uniqueUsers),
|
||||
},
|
||||
{
|
||||
hint: `成功 ${formatNumber(summary.grantedDraws)} / 待发放 ${formatNumber(summary.pendingDraws)} / 失败 ${formatNumber(summary.failedDraws)}`,
|
||||
label: "抽奖次数",
|
||||
value: formatNumber(summary.totalDraws),
|
||||
},
|
||||
{
|
||||
hint: `目标 ${formatPercent(page.config?.targetRTPPercent)}`,
|
||||
label: "实际 RTP",
|
||||
value: formatPPM(summary.actualRTPPPM),
|
||||
},
|
||||
{
|
||||
hint: `净消耗 ${formatNumber(netSpent)}`,
|
||||
label: "消耗金币",
|
||||
value: formatNumber(summary.totalSpentCoins),
|
||||
},
|
||||
{
|
||||
hint: `基础 ${formatNumber(summary.baseRewardCoins)}`,
|
||||
label: "返还金币",
|
||||
value: formatNumber(summary.totalRewardCoins),
|
||||
},
|
||||
{
|
||||
hint: `房间 ${formatNumber(summary.roomAtmosphereRewardCoins)} / 活动 ${formatNumber(summary.activitySubsidyCoins)}`,
|
||||
label: "额外预算",
|
||||
value: formatNumber(
|
||||
Number(summary.roomAtmosphereRewardCoins || 0) + Number(summary.activitySubsidyCoins || 0),
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className={styles.drawSummaryPanel}>
|
||||
<header className={styles.drawSummaryHeader}>
|
||||
<div className={styles.stack}>
|
||||
<h2 className={styles.sectionTitle}>抽奖数据概览</h2>
|
||||
<span className={styles.meta}>奖池 {summary.poolId || page.poolId}</span>
|
||||
</div>
|
||||
<Button
|
||||
disabled={page.drawSummaryLoading}
|
||||
startIcon={<RefreshOutlined fontSize="small" />}
|
||||
onClick={page.reloadDrawSummary}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</header>
|
||||
<div className={styles.drawSummaryFilters}>
|
||||
<TextField className={styles.filterInput} disabled label="当前奖池" value={page.poolId} />
|
||||
<TextField
|
||||
className={styles.filterInput}
|
||||
label="礼物 ID"
|
||||
value={page.giftId}
|
||||
onChange={(event) => page.changeGiftId(event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
className={styles.filterInput}
|
||||
label="用户 ID"
|
||||
value={page.userId}
|
||||
onChange={(event) => page.changeUserId(event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
className={styles.filterInput}
|
||||
label="房间 ID"
|
||||
value={page.roomId}
|
||||
onChange={(event) => page.changeRoomId(event.target.value)}
|
||||
/>
|
||||
<AdminFilterSelect
|
||||
label="发放状态"
|
||||
options={[["", "全部状态"], ...drawStatusOptions]}
|
||||
value={page.status}
|
||||
onChange={page.changeStatus}
|
||||
/>
|
||||
<AdminFilterResetButton
|
||||
disabled={!page.giftId && !page.userId && !page.roomId && !page.status}
|
||||
onClick={page.resetDrawFilters}
|
||||
/>
|
||||
</div>
|
||||
{page.drawSummaryError ? (
|
||||
<div className={styles.summaryError}>
|
||||
<span>{page.drawSummaryError}</span>
|
||||
<Button startIcon={<RefreshOutlined fontSize="small" />} onClick={page.reloadDrawSummary}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.drawSummaryGrid} aria-busy={page.drawSummaryLoading}>
|
||||
{stats.map((item) => (
|
||||
<div className={styles.metricItem} key={item.label}>
|
||||
<span>{item.label}</span>
|
||||
<strong>{page.drawSummaryLoading ? "-" : item.value}</strong>
|
||||
<small>{page.drawSummaryLoading ? "" : item.hint}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AddPoolDialog({ disabled, onChange, onClose, onSubmit, open, value }) {
|
||||
return (
|
||||
<Dialog fullWidth maxWidth="xs" open={open} onClose={disabled ? undefined : onClose}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<DialogTitle>添加奖池</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
required
|
||||
disabled={disabled}
|
||||
label="奖池 ID"
|
||||
margin="dense"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button disabled={disabled} type="button" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={disabled} type="submit" variant="primary">
|
||||
添加并编辑
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPPM(value) {
|
||||
return `${(Number(value || 0) / 10_000).toFixed(2).replace(/\.?0+$/, "")}%`;
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
return `${Number(value || 0)
|
||||
.toFixed(2)
|
||||
.replace(/\.?0+$/, "")}%`;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||
// 文档跳转通常立即完成;保留可读状态,供慢网络和辅助技术明确当前动作。
|
||||
return <p role="status">正在打开新版幸运礼物配置…</p>;
|
||||
}
|
||||
|
||||
14
src/features/lucky-gift/pages/LuckyGiftConfigPage.test.jsx
Normal file
14
src/features/lucky-gift/pages/LuckyGiftConfigPage.test.jsx
Normal 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");
|
||||
});
|
||||
@ -229,6 +229,7 @@ export const API_OPERATIONS = {
|
||||
listLoginLogs: "listLoginLogs",
|
||||
listLuckyGiftConfigs: "listLuckyGiftConfigs",
|
||||
listLuckyGiftDraws: "listLuckyGiftDraws",
|
||||
listLuckyGiftPoolBalances: "listLuckyGiftPoolBalances",
|
||||
listManagers: "listManagers",
|
||||
listOperationLogs: "listOperationLogs",
|
||||
listPermissions: "listPermissions",
|
||||
@ -1307,14 +1308,14 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
getLuckyGiftConfig: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getLuckyGiftConfig,
|
||||
path: "/v1/admin/activity/lucky-gifts/v2/config",
|
||||
path: "/v1/admin/ops-center/lucky-gifts/config",
|
||||
permission: "lucky-gift:view",
|
||||
permissions: ["lucky-gift:view"]
|
||||
},
|
||||
getLuckyGiftDrawSummary: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getLuckyGiftDrawSummary,
|
||||
path: "/v1/admin/activity/lucky-gifts/v2/draw-summary",
|
||||
path: "/v1/admin/ops-center/lucky-gifts/summary",
|
||||
permission: "lucky-gift:view",
|
||||
permissions: ["lucky-gift:view"]
|
||||
},
|
||||
@ -1900,14 +1901,21 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
listLuckyGiftConfigs: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listLuckyGiftConfigs,
|
||||
path: "/v1/admin/activity/lucky-gifts/v2/configs",
|
||||
path: "/v1/admin/ops-center/lucky-gifts/configs",
|
||||
permission: "lucky-gift:view",
|
||||
permissions: ["lucky-gift:view"]
|
||||
},
|
||||
listLuckyGiftDraws: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listLuckyGiftDraws,
|
||||
path: "/v1/admin/activity/lucky-gifts/v2/draws",
|
||||
path: "/v1/admin/ops-center/lucky-gifts/draws",
|
||||
permission: "lucky-gift:view",
|
||||
permissions: ["lucky-gift:view"]
|
||||
},
|
||||
listLuckyGiftPoolBalances: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listLuckyGiftPoolBalances,
|
||||
path: "/v1/admin/ops-center/lucky-gifts/pools",
|
||||
permission: "lucky-gift:view",
|
||||
permissions: ["lucky-gift:view"]
|
||||
},
|
||||
@ -3034,7 +3042,7 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
upsertLuckyGiftConfig: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.upsertLuckyGiftConfig,
|
||||
path: "/v1/admin/activity/lucky-gifts/v2/config",
|
||||
path: "/v1/admin/ops-center/lucky-gifts/config",
|
||||
permission: "lucky-gift:update",
|
||||
permissions: ["lucky-gift:update"]
|
||||
},
|
||||
|
||||
36
src/shared/api/generated/schema.d.ts
vendored
36
src/shared/api/generated/schema.d.ts
vendored
@ -244,7 +244,7 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/activity/lucky-gifts/v2/config": {
|
||||
"/admin/ops-center/lucky-gifts/config": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
@ -260,7 +260,7 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/activity/lucky-gifts/v2/configs": {
|
||||
"/admin/ops-center/lucky-gifts/configs": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
@ -276,7 +276,7 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/activity/lucky-gifts/v2/draws": {
|
||||
"/admin/ops-center/lucky-gifts/draws": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
@ -292,7 +292,7 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/activity/lucky-gifts/v2/draw-summary": {
|
||||
"/admin/ops-center/lucky-gifts/summary": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
@ -308,6 +308,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/ops-center/lucky-gifts/pools": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listLuckyGiftPoolBalances"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/activity/wheel/config": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -7979,6 +7995,18 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listLuckyGiftPoolBalances: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
getWheelConfig: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user