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