merge: lucky gift pool balance controls

This commit is contained in:
zhx 2026-07-15 16:14:52 +08:00
commit 460464ee18
19 changed files with 1481 additions and 212 deletions

View File

@ -390,6 +390,86 @@
"x-permissions": ["lucky-gift:view"]
}
},
"/admin/ops-center/lucky-gifts/pools/credit": {
"post": {
"operationId": "creditLuckyGiftPool",
"parameters": [
{
"in": "query",
"name": "app_code",
"required": true,
"schema": { "type": "string", "minLength": 1, "maxLength": 32 }
},
{
"in": "query",
"name": "pool_id",
"required": true,
"schema": { "type": "string", "minLength": 1, "maxLength": 96 }
},
{
"in": "query",
"name": "strategy_version",
"required": true,
"schema": { "type": "string", "enum": ["fixed_v2", "dynamic_v3"] }
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/LuckyGiftPoolAdjustmentInput" }
}
}
},
"responses": {
"200": { "$ref": "#/components/responses/LuckyGiftPoolAdjustmentResponse" },
"400": { "description": "调整参数不正确" },
"409": { "description": "幂等键冲突或可用水位不足" }
},
"x-permission": "lucky-gift:pool-credit",
"x-permissions": ["lucky-gift:pool-credit"]
}
},
"/admin/ops-center/lucky-gifts/pools/debit": {
"post": {
"operationId": "debitLuckyGiftPool",
"parameters": [
{
"in": "query",
"name": "app_code",
"required": true,
"schema": { "type": "string", "minLength": 1, "maxLength": 32 }
},
{
"in": "query",
"name": "pool_id",
"required": true,
"schema": { "type": "string", "minLength": 1, "maxLength": 96 }
},
{
"in": "query",
"name": "strategy_version",
"required": true,
"schema": { "type": "string", "enum": ["fixed_v2", "dynamic_v3"] }
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/LuckyGiftPoolAdjustmentInput" }
}
}
},
"responses": {
"200": { "$ref": "#/components/responses/LuckyGiftPoolAdjustmentResponse" },
"400": { "description": "调整参数不正确" },
"409": { "description": "幂等键冲突或可用水位不足" }
},
"x-permission": "lucky-gift:pool-debit",
"x-permissions": ["lucky-gift:pool-debit"]
}
},
"/admin/activity/wheel/config": {
"get": {
"operationId": "getWheelConfig",
@ -8772,6 +8852,16 @@
}
}
},
"LuckyGiftPoolAdjustmentResponse": {
"description": "奖池水位调整成功或同幂等请求的首次结果",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseLuckyGiftPoolAdjustmentResult"
}
}
}
},
"RechargeBillPageResponse": {
"description": "OK",
"content": {
@ -9184,6 +9274,115 @@
}
},
"schemas": {
"LuckyGiftPoolAdjustmentInput": {
"type": "object",
"additionalProperties": false,
"required": ["adjustment_id", "amount_coins", "reason"],
"properties": {
"adjustment_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"amount_coins": {
"type": "integer",
"format": "int64",
"minimum": 1
},
"reason": {
"type": "string",
"minLength": 1,
"maxLength": 255
}
}
},
"LuckyGiftPoolBalance": {
"type": "object",
"required": [
"app_code",
"pool_id",
"strategy_version",
"balance",
"reserve_floor",
"available_balance",
"total_in",
"total_out",
"manual_credit_total",
"manual_debit_total",
"materialized",
"updated_at_ms"
],
"properties": {
"app_code": { "type": "string" },
"pool_id": { "type": "string" },
"strategy_version": {
"type": "string",
"enum": ["fixed_v2", "dynamic_v3"]
},
"balance": { "type": "integer", "format": "int64" },
"reserve_floor": { "type": "integer", "format": "int64" },
"available_balance": { "type": "integer", "format": "int64" },
"total_in": { "type": "integer", "format": "int64" },
"total_out": { "type": "integer", "format": "int64" },
"manual_credit_total": { "type": "integer", "format": "int64" },
"manual_debit_total": { "type": "integer", "format": "int64" },
"materialized": { "type": "boolean" },
"updated_at_ms": { "type": "integer", "format": "int64" }
}
},
"LuckyGiftPoolAdjustment": {
"type": "object",
"required": [
"adjustment_id",
"app_code",
"pool_id",
"strategy_version",
"direction",
"amount_coins",
"reason",
"operator_admin_id",
"balance_before",
"balance_after",
"created_at_ms"
],
"properties": {
"adjustment_id": { "type": "string" },
"app_code": { "type": "string" },
"pool_id": { "type": "string" },
"strategy_version": {
"type": "string",
"enum": ["fixed_v2", "dynamic_v3"]
},
"direction": { "type": "string", "enum": ["in", "out"] },
"amount_coins": { "type": "integer", "format": "int64" },
"reason": { "type": "string" },
"operator_admin_id": { "type": "integer", "format": "int64" },
"balance_before": { "type": "integer", "format": "int64" },
"balance_after": { "type": "integer", "format": "int64" },
"created_at_ms": { "type": "integer", "format": "int64" }
}
},
"LuckyGiftPoolAdjustmentResult": {
"type": "object",
"required": ["adjustment", "pool", "idempotent_replay"],
"properties": {
"adjustment": { "$ref": "#/components/schemas/LuckyGiftPoolAdjustment" },
"pool": { "$ref": "#/components/schemas/LuckyGiftPoolBalance" },
"idempotent_replay": { "type": "boolean" }
}
},
"ApiResponseLuckyGiftPoolAdjustmentResult": {
"allOf": [
{ "$ref": "#/components/schemas/Envelope" },
{
"type": "object",
"properties": {
"requestId": { "type": "string" },
"data": { "$ref": "#/components/schemas/LuckyGiftPoolAdjustmentResult" }
}
}
]
},
"ApiResponseAdminAppList": {
"allOf": [
{

View File

@ -4,17 +4,20 @@ import CasinoOutlined from "@mui/icons-material/CasinoOutlined";
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import { useCallback, useEffect, useMemo, useState } from "react";
import { PERMISSIONS } from "@/app/permissions";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { PageHead } from "@/shared/ui/PageHead.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { DEFAULT_POOL_ID, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
import { adjustLuckyGiftPool, DEFAULT_POOL_ID, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
import { AppsView } from "./components/AppsView.jsx";
import { ConfigsView } from "./components/ConfigsView.jsx";
import { DrawsView } from "./components/DrawsView.jsx";
import { LuckyGiftConfigDialog } from "./components/LuckyGiftConfigDialog.jsx";
import { LuckyGiftPoolAdjustmentDialog } from "./components/LuckyGiftPoolAdjustmentDialog.jsx";
import { OpsShell } from "./components/OpsShell.jsx";
import { OverviewView } from "./components/OverviewView.jsx";
import { formatNumber } from "./format.js";
const views = [
{ icon: InsightsOutlined, key: "overview", label: "数据总览" },
@ -37,13 +40,20 @@ export function initialOpsCenterView(search = globalThis.location?.search || "")
return viewKeys.has(requested) ? requested : "overview";
}
export function OpsCenterApp() {
const denyPermission = () => false;
export function OpsCenterApp({ can = denyPermission }) {
// ?view=configs HTML
const [activeView, setActiveView] = useState(() => initialOpsCenterView());
const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true });
const [dialog, setDialog] = useState(null);
const [poolDialog, setPoolDialog] = useState(null);
const [adjustingPool, setAdjustingPool] = useState(false);
const [savingConfig, setSavingConfig] = useState(false);
const { showToast } = useToast();
const canCreditPool = can(PERMISSIONS.luckyGiftPoolCredit);
const canDebitPool = can(PERMISSIONS.luckyGiftPoolDebit);
const canUpdateConfig = can(PERMISSIONS.luckyGiftUpdate);
const loadDashboard = useCallback(async () => {
setState((current) => ({ ...current, error: "", loading: true }));
@ -124,6 +134,18 @@ export function OpsCenterApp() {
setDialog({ config: structuredClone(config), mode: "edit" });
}, []);
const openPoolAdjustment = useCallback((pool, direction) => {
if (!pool) {
return;
}
// fetch 409
setPoolDialog({
adjustmentId: createPoolAdjustmentID(),
direction,
pool: structuredClone(pool),
});
}, []);
const handleSaveConfig = useCallback(
async (config) => {
setSavingConfig(true);
@ -142,6 +164,31 @@ export function OpsCenterApp() {
[loadDashboard, showToast],
);
const handleAdjustPool = useCallback(
async (request) => {
setAdjustingPool(true);
try {
const result = await adjustLuckyGiftPool(request);
const adjustment = result.adjustment || {};
const action = request.direction === "out" ? "扣减" : "添加";
showToast(
`${action}水位 ${request.appCode} / ${request.poolId} / ${request.strategyVersion}${formatNumber(
adjustment.balance_before,
)} ${formatNumber(adjustment.balance_after)}`,
"success",
);
setPoolDialog(null);
await loadDashboard();
} catch (error) {
// owner adjustment_id owner
showToast(error.message || "调整奖池水位失败", "error");
} finally {
setAdjustingPool(false);
}
},
[loadDashboard, showToast],
);
const defaultAppCode = data.apps[0]?.app_code ?? data.apps[0]?.appCode ?? "";
return (
@ -170,13 +217,29 @@ export function OpsCenterApp() {
loading={state.loading && !state.loaded}
onRetry={loadDashboard}
>
{activeView === "overview" ? <OverviewView data={data} /> : null}
{activeView === "overview" ? (
<OverviewView
canCredit={canCreditPool}
canDebit={canDebitPool}
data={data}
disabled={adjustingPool || state.loading}
onCredit={(pool) => openPoolAdjustment(pool, "in")}
onDebit={(pool) => openPoolAdjustment(pool, "out")}
/>
) : null}
{activeView === "configs" ? (
<ConfigsView
apps={data.apps}
canCredit={canCreditPool}
canDebit={canDebitPool}
canUpdate={canUpdateConfig}
disabled={adjustingPool || state.loading}
luckyGifts={data.luckyGifts}
poolBalances={data.poolBalances}
saving={savingConfig}
onCreate={openCreateConfig}
onCredit={(pool) => openPoolAdjustment(pool, "in")}
onDebit={(pool) => openPoolAdjustment(pool, "out")}
onEdit={openEditConfig}
/>
) : null}
@ -195,7 +258,27 @@ export function OpsCenterApp() {
onSubmit={handleSaveConfig}
/>
) : null}
{poolDialog ? (
<LuckyGiftPoolAdjustmentDialog
adjustmentId={poolDialog.adjustmentId}
direction={poolDialog.direction}
pool={poolDialog.pool}
submitting={adjustingPool}
onClose={() => setPoolDialog(null)}
onSubmit={handleAdjustPool}
/>
) : null}
</div>
</OpsShell>
);
}
let fallbackAdjustmentSequence = 0;
function createPoolAdjustmentID() {
if (typeof globalThis.crypto?.randomUUID === "function") {
return globalThis.crypto.randomUUID();
}
fallbackAdjustmentSequence += 1;
return `pool-adjust-${Date.now().toString(36)}-${fallbackAdjustmentSequence.toString(36)}`;
}

View File

@ -1,13 +1,17 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, expect, test, vi } from "vitest";
import { PERMISSIONS } from "@/app/permissions";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
import { fetchLuckyGiftDraws, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
import { adjustLuckyGiftPool, fetchLuckyGiftDraws, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
import { initialOpsCenterView, OpsCenterApp } from "./OpsCenterApp.jsx";
vi.mock("./api.js", () => ({
DEFAULT_POOL_ID: "default",
adjustLuckyGiftPool: vi.fn(),
fetchLuckyGiftDraws: vi.fn(),
fetchOpsDashboard: vi.fn(),
luckyGiftPoolKey: (item = {}) =>
`${item.app_code || item.appCode}:${item.pool_id || item.poolId}:${item.strategy_version || item.strategyVersion || "fixed_v2"}`,
saveLuckyGiftConfig: vi.fn(),
}));
@ -15,6 +19,10 @@ beforeEach(() => {
vi.clearAllMocks();
window.history.replaceState({}, "", "/");
saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" });
adjustLuckyGiftPool.mockResolvedValue({
adjustment: { balance_after: 39_559_017, balance_before: 38_559_017 },
idempotent_replay: false,
});
fetchLuckyGiftDraws.mockResolvedValue({
items: [
{
@ -62,6 +70,14 @@ beforeEach(() => {
pool_id: "lucky",
rule_version: 3,
},
{
...mockFixedConfig(95),
app_code: "lalu",
enabled: false,
is_default: false,
pool_id: "lucky",
rule_version: 2,
},
{
...mockFixedConfig(92),
app_code: "lalu",
@ -84,9 +100,24 @@ beforeEach(() => {
app_code: "lalu",
available_balance: 37950017,
balance: 38559017,
manual_credit_total: 1_000_000,
manual_debit_total: 200_000,
materialized: true,
pool_id: "lucky",
reserve_floor: 609000,
strategy_version: "dynamic_v3",
updated_at_ms: 1767000000000,
},
{
app_code: "lalu",
available_balance: 0,
balance: 900,
manual_credit_total: 100,
manual_debit_total: 20,
materialized: true,
pool_id: "lucky",
reserve_floor: 900,
strategy_version: "fixed_v2",
updated_at_ms: 1767000000000,
},
{
@ -96,6 +127,7 @@ beforeEach(() => {
materialized: false,
pool_id: "default",
reserve_floor: 21000,
strategy_version: "dynamic_v3",
},
],
summary: {
@ -115,10 +147,12 @@ beforeEach(() => {
});
});
function renderApp() {
const allPermissions = [PERMISSIONS.luckyGiftPoolCredit, PERMISSIONS.luckyGiftPoolDebit, PERMISSIONS.luckyGiftUpdate];
function renderApp(permissions = allPermissions) {
return render(
<ToastProvider>
<OpsCenterApp />
<OpsCenterApp can={(code) => permissions.includes(code)} />
</ToastProvider>,
);
}
@ -137,7 +171,7 @@ test("renders overview with aggregated kpis and pool balances", async () => {
expect(screen.getAllByText("抽奖次数").length).toBeGreaterThan(0);
expect(screen.getAllByText("38,941,517").length).toBeGreaterThan(0);
expect(screen.getByText("奖池实时水位")).toBeTruthy();
expect(screen.getByText("已入账")).toBeTruthy();
expect(screen.getAllByText("已入账").length).toBeGreaterThan(1);
expect(screen.getByText("默认水位")).toBeTruthy();
expect(fetchOpsDashboard).toHaveBeenCalledTimes(1);
});
@ -150,7 +184,7 @@ test("opens the requested config view from the legacy admin document redirect",
renderApp();
expect(await screen.findByRole("button", { name: "添加配置" })).toBeTruthy();
expect(screen.getByText("lucky")).toBeTruthy();
expect(screen.getAllByText("lucky").length).toBeGreaterThan(0);
});
test("lists every pool config including published non-default pools", async () => {
@ -160,15 +194,92 @@ test("lists every pool config including published non-default pools", async () =
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
// lalu lucky / super_lucky
expect(await screen.findByText("lucky")).toBeTruthy();
expect((await screen.findAllByText("lucky")).length).toBeGreaterThan(1);
expect(screen.getByText("super_lucky")).toBeTruthy();
expect(screen.getByText("v3")).toBeTruthy();
expect(screen.getByText("v2")).toBeTruthy();
expect(screen.getAllByText("v2").length).toBeGreaterThan(0);
expect(screen.getByText("已启用")).toBeTruthy();
expect(screen.getByText("已停用")).toBeTruthy();
expect(screen.getAllByText("已停用").length).toBeGreaterThan(1);
expect(screen.getByText("默认草稿")).toBeTruthy();
});
test("shows strategy-specific balances and adjustment actions directly in the config list", async () => {
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
expect(await screen.findByRole("button", { name: "添加水位 lalu / lucky / dynamic_v3" })).toBeTruthy();
expect(screen.getByRole("button", { name: "扣减水位 lalu / lucky / dynamic_v3" })).toBeEnabled();
expect(screen.getByRole("button", { name: "添加水位 lalu / lucky / fixed_v2" })).toBeTruthy();
expect(screen.getByRole("button", { name: "扣减水位 lalu / lucky / fixed_v2" })).toBeDisabled();
expect(screen.getByText("38,559,017")).toBeTruthy();
expect(screen.getByText("900")).toBeTruthy();
});
test("shows credit and debit buttons only when the standalone session grants each permission", async () => {
renderApp([PERMISSIONS.luckyGiftPoolCredit]);
await screen.findByText("运营应用");
expect(screen.getByRole("button", { name: "添加水位 lalu / lucky / dynamic_v3" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "扣减水位 lalu / lucky / dynamic_v3" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
expect(screen.queryByRole("button", { name: "添加配置" })).toBeNull();
expect(screen.queryByRole("button", { name: /新增版本 lalu/ })).toBeNull();
});
test("submits a pool credit, reports before and after balances, closes, and refreshes", async () => {
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: "添加水位 lalu / lucky / dynamic_v3" }));
const dialog = await screen.findByRole("dialog", { name: "添加奖池水位" });
expect(within(dialog).getByText("dynamic_v3")).toBeTruthy();
fireEvent.change(within(dialog).getByLabelText(/调整金币/), { target: { value: "1000000" } });
fireEvent.change(within(dialog).getByLabelText(/调整原因/), { target: { value: "运营补水" } });
fireEvent.click(within(dialog).getByRole("button", { name: "确认添加" }));
await waitFor(() => {
expect(adjustLuckyGiftPool).toHaveBeenCalledWith(
expect.objectContaining({
adjustmentId: expect.any(String),
amountCoins: 1_000_000,
appCode: "lalu",
direction: "in",
poolId: "lucky",
reason: "运营补水",
strategyVersion: "dynamic_v3",
}),
);
});
expect(await screen.findByText(/38,559,017 → 39,559,017/)).toBeTruthy();
await waitFor(() => expect(screen.queryByRole("dialog", { name: "添加奖池水位" })).toBeNull());
expect(fetchOpsDashboard).toHaveBeenCalledTimes(2);
});
test("keeps the form and adjustment id stable when a debit request is retried", async () => {
adjustLuckyGiftPool.mockRejectedValueOnce(new Error("可用水位已变化")).mockResolvedValueOnce({
adjustment: { balance_after: 38_558_917, balance_before: 38_559_017 },
idempotent_replay: false,
});
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: "扣减水位 lalu / lucky / dynamic_v3" }));
const dialog = await screen.findByRole("dialog", { name: "扣减奖池水位" });
fireEvent.change(within(dialog).getByLabelText(/调整金币/), { target: { value: "100" } });
fireEvent.change(within(dialog).getByLabelText(/调整原因/), { target: { value: "冲正" } });
fireEvent.click(within(dialog).getByRole("button", { name: "确认扣减" }));
expect(await screen.findByText("可用水位已变化")).toBeTruthy();
expect(within(dialog).getByLabelText(/调整金币/)).toHaveValue("100");
expect(within(dialog).getByLabelText(/调整原因/)).toHaveValue("冲正");
const firstAdjustmentID = adjustLuckyGiftPool.mock.calls[0][0].adjustmentId;
fireEvent.click(within(dialog).getByRole("button", { name: "确认扣减" }));
await waitFor(() => expect(adjustLuckyGiftPool).toHaveBeenCalledTimes(2));
expect(adjustLuckyGiftPool.mock.calls[1][0].adjustmentId).toBe(firstAdjustmentID);
});
test("creates a disabled dynamic draft without copying another app monetary limits", async () => {
renderApp();
await screen.findByText("运营应用");
@ -178,7 +289,7 @@ test("creates a disabled dynamic draft without copying another app monetary limi
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("添加幸运礼物配置")).toBeTruthy();
expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3");
expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("1000000");
expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("0");
// lalu 100 App
expect(within(dialog).getByLabelText(/单次返奖上限/).value).toBe("0");
expect(within(dialog).getByText("发布后保持停用")).toBeTruthy();
@ -189,13 +300,14 @@ test("opens config dialog from a published pool row and saves a new version", as
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
await screen.findByText("lucky");
await screen.findAllByText("lucky");
fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]);
fireEvent.click(screen.getByRole("button", { name: "新增版本 lalu / lucky / dynamic_v3" }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("新增幸运礼物版本")).toBeTruthy();
expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3");
expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("1000000");
expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("0");
expect(within(dialog).getByLabelText(/初始奖池金币/)).toBeDisabled();
expect(within(dialog).getByLabelText(/大奖倍率/).value).toBe("200, 500, 1000");
expect(within(dialog).getAllByLabelText("概率 %")[0].readOnly).toBe(true);
@ -207,6 +319,7 @@ test("opens config dialog from a published pool row and saves a new version", as
expect.objectContaining({
app_code: "lalu",
anchor_rate_percent: 1,
initial_pool_coins: 0,
jackpot_multipliers: [200, 500, 1000],
pool_id: "lucky",
profit_rate_percent: 1,
@ -222,14 +335,14 @@ 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]);
await screen.findAllByText("lucky");
fireEvent.click(screen.getByRole("button", { name: "新增版本 lalu / lucky / dynamic_v3" }));
const dialog = await screen.findByRole("dialog");
fireEvent.change(within(dialog).getByLabelText(/初始奖池金币/), { target: { value: "0" } });
fireEvent.change(within(dialog).getByLabelText(/低水位金币/), { target: { value: "0" } });
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" }));
expect(await within(dialog).findByRole("alert")).toHaveTextContent("初始奖池必须大于 0");
expect(await within(dialog).findByRole("alert")).toHaveTextContent("高水位必须大于正数低水位");
expect(saveLuckyGiftConfig).not.toHaveBeenCalled();
//
@ -247,7 +360,7 @@ function mockDynamicConfig() {
gift_price_reference: 100,
high_water_nonzero_factor_percent: 130,
high_watermark_coins: 20_000_000,
initial_pool_coins: 1_000_000,
initial_pool_coins: 0,
jackpot_global_rtp_max_percent: 98,
jackpot_multipliers: [200, 500, 1000],
jackpot_spend_threshold_coins: 50_000,

View File

@ -0,0 +1,26 @@
import { useEffect } from "react";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { buildLoginRedirectPath } from "@/features/auth/loginRedirect.js";
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
import { OpsCenterApp } from "./OpsCenterApp.jsx";
export function AuthenticatedOpsCenter({ targetWindow = window }) {
const { can, loading, user } = useAuth();
const loginPath = buildLoginRedirectPath(targetWindow.location);
useEffect(() => {
if (!loading && !user) {
// ops-center HTML 使 SPA Navigate沿 `redirect`
// LoginPage /ops-center/ document entry location.replace
targetWindow.location.replace(loginPath);
}
}, [loading, loginPath, targetWindow, user]);
if (loading || !user) {
// AuthProvider refresh cookie token OpsCenterApp
// dashboard token 401
return <PageSkeleton />;
}
return <OpsCenterApp can={can} />;
}

View File

@ -0,0 +1,100 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, expect, test, vi } from "vitest";
import { AuthProvider } from "@/app/auth/AuthProvider.jsx";
import * as authApi from "@/features/auth/api";
import { setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "@/shared/api/request";
import { AuthenticatedOpsCenter } from "./OpsCenterEntry.jsx";
vi.mock("@/features/auth/api", () => ({
getMe: vi.fn(),
login: vi.fn(),
logout: vi.fn(),
refreshSession: vi.fn(),
}));
vi.mock("./OpsCenterApp.jsx", () => ({
OpsCenterApp: ({ can }) => (
<div data-can-credit={String(can("lucky-gift:pool-credit"))} data-testid="ops-center-app" />
),
}));
beforeEach(() => {
vi.clearAllMocks();
setAccessToken("");
setRefreshHandler(null);
setUnauthorizedHandler(null);
});
test("does not mount the dashboard without a session and redirects to login with the full ops path", async () => {
authApi.refreshSession.mockRejectedValue(new Error("no session"));
const targetWindow = fakeWindow("/ops-center/", "?view=configs", "#dynamic_v3");
render(
<AuthProvider>
<AuthenticatedOpsCenter targetWindow={targetWindow} />
</AuthProvider>,
);
// refresh Dashboard
expect(screen.queryByTestId("ops-center-app")).toBeNull();
await waitFor(() => {
expect(targetWindow.location.replace).toHaveBeenCalledWith(
"/login?redirect=%2Fops-center%2F%3Fview%3Dconfigs%23dynamic_v3",
);
});
expect(screen.queryByTestId("ops-center-app")).toBeNull();
});
test("waits for stale-token refresh fallback before mounting the dashboard", async () => {
const refresh = deferred();
const me = deferred();
setAccessToken("stale-token");
authApi.refreshSession.mockReturnValue(refresh.promise);
authApi.getMe.mockReturnValue(me.promise);
const targetWindow = fakeWindow("/ops-center/", "?view=overview", "");
render(
<AuthProvider>
<AuthenticatedOpsCenter targetWindow={targetWindow} />
</AuthProvider>,
);
expect(screen.queryByTestId("ops-center-app")).toBeNull();
await act(async () => {
refresh.reject(new Error("refresh cookie expired"));
});
await waitFor(() => expect(authApi.getMe).toHaveBeenCalledTimes(1));
expect(screen.queryByTestId("ops-center-app")).toBeNull();
await act(async () => {
me.resolve({
accessToken: "fresh-token",
permissions: ["lucky-gift:pool-credit"],
user: { id: 1, username: "ops" },
});
});
expect(await screen.findByTestId("ops-center-app")).toHaveAttribute("data-can-credit", "true");
expect(targetWindow.location.replace).not.toHaveBeenCalled();
});
function fakeWindow(pathname, search, hash) {
return {
location: {
hash,
pathname,
replace: vi.fn(),
search,
},
};
}
function deferred() {
let resolve;
let reject;
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}

View File

@ -1,4 +1,5 @@
import { getAccessToken } from "@/shared/api/request";
import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import { apiRequest } from "@/shared/api/request";
export const OPS_API_PREFIX = "/api/v1/admin/ops-center";
export const DEFAULT_POOL_ID = "default";
@ -87,20 +88,50 @@ export async function saveLuckyGiftConfig(config = {}) {
});
}
export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) {
const response = await fetch(buildOpsUrl(path, query), {
body: body === undefined ? undefined : JSON.stringify(body),
credentials: "include",
headers: buildHeaders(body),
method,
});
const payload = await readPayload(response);
if (!response.ok || payload.code !== 0) {
throw new Error(payload.message || response.statusText || "运营接口请求失败");
export async function adjustLuckyGiftPool({
adjustmentId,
amountCoins,
appCode,
direction,
poolId,
reason,
strategyVersion,
} = {}) {
if (direction !== "in" && direction !== "out") {
throw new Error("奖池水位调整方向不正确");
}
const operation = direction === "out" ? API_OPERATIONS.debitLuckyGiftPool : API_OPERATIONS.creditLuckyGiftPool;
const operationPath = apiEndpointPath(operation);
return payload.data ?? {};
// adjustment_id 是 owner 侧资金变更的幂等键同一个弹窗失败重试必须原样复用request_id 只做链路追踪,
// 不能替代业务幂等。方向映射到两条独立路由,使前后端权限也能按补水、扣水分别收敛。
const payload = await opsRequest(opsRelativePath(operationPath), {
body: {
adjustment_id: String(adjustmentId || "").trim(),
amount_coins: numberValue(amountCoins),
reason: String(reason || "").trim(),
},
method: "POST",
query: {
app_code: String(appCode || "")
.trim()
.toLowerCase(),
pool_id: String(poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID,
strategy_version: normalizeStrategyVersion(strategyVersion),
},
});
return normalizePoolAdjustmentResult(payload);
}
export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
// 复用主后台请求层才能共享 refreshOnce运行期间 token 过期时先刷新再重放一次请求。
// 水位写接口携带稳定 adjustment_id因此认证重放仍由 owner 幂等收敛409 文案继续原样抛给弹窗保留表单。
return apiRequest(`/v1/admin/ops-center${normalizedPath}`, {
body,
method,
query,
});
}
export function buildOpsUrl(path, query = {}) {
@ -119,8 +150,8 @@ export function buildOpsUrl(path, query = {}) {
export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []);
const poolBalances = perApp
.flatMap((entry) => entry.poolBalances || [])
.filter((item) => item?.app_code || item?.appCode);
.flatMap((entry) => (entry.poolBalances || []).map((item) => normalizePoolBalance(item, entry.appCode)))
.filter((item) => item.app_code);
const appSummaries = perApp
.map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode))
.filter((summary) => summary.app_code);
@ -146,6 +177,37 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
return { apps, appSummaries, luckyGifts, poolBalances, summary };
}
export function normalizePoolBalance(pool = {}, fallbackAppCode = "") {
return {
...pool,
app_code: String(pool.app_code || pool.appCode || fallbackAppCode)
.trim()
.toLowerCase(),
available_balance: numberValue(pool.available_balance ?? pool.availableBalance),
balance: numberValue(pool.balance ?? pool.Balance),
manual_credit_total: numberValue(pool.manual_credit_total ?? pool.manualCreditTotal),
manual_debit_total: numberValue(pool.manual_debit_total ?? pool.manualDebitTotal),
materialized: Boolean(pool.materialized),
pool_id: String(pool.pool_id || pool.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID,
reserve_floor: numberValue(pool.reserve_floor ?? pool.reserveFloor),
strategy_version: normalizeStrategyVersion(pool.strategy_version ?? pool.strategyVersion),
total_in: numberValue(pool.total_in ?? pool.totalIn),
total_out: numberValue(pool.total_out ?? pool.totalOut),
updated_at_ms: numberValue(pool.updated_at_ms ?? pool.updatedAtMs),
};
}
export function luckyGiftPoolKey(item = {}) {
const appCode = String(item.app_code || item.appCode || "")
.trim()
.toLowerCase();
const poolID = String(item.pool_id || item.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID;
const strategyVersion = normalizeStrategyVersion(item.strategy_version ?? item.strategyVersion);
// V2/V3 资金由 owner 按策略版本隔离;任何列表关联或 React rowKey 都必须包含 strategy_version
// 否则同一 app+pool 的两口奖池会在前端被覆盖成一行。
return `${appCode}:${poolID}:${strategyVersion}`;
}
function normalizeDrawSummary(summary = {}, appCode = "") {
return {
actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm),
@ -192,12 +254,39 @@ function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") {
pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID,
rule_version: ruleVersion,
// 升级前规则没有 strategy_versionlucky-gift owner 明确按 fixed_v2 解释;列表和编辑表单必须沿用该兼容语义。
strategy_version: String(config.strategy_version || config.strategyVersion || "fixed_v2")
.trim()
.toLowerCase(),
strategy_version: normalizeStrategyVersion(config.strategy_version ?? config.strategyVersion),
};
}
function normalizePoolAdjustmentResult(payload = {}) {
const adjustment = payload.adjustment || {};
return {
...payload,
adjustment: {
...adjustment,
amount_coins: numberValue(adjustment.amount_coins ?? adjustment.amountCoins),
balance_after: numberValue(adjustment.balance_after ?? adjustment.balanceAfter),
balance_before: numberValue(adjustment.balance_before ?? adjustment.balanceBefore),
strategy_version: normalizeStrategyVersion(adjustment.strategy_version ?? adjustment.strategyVersion),
},
pool: normalizePoolBalance(payload.pool || {}),
};
}
function opsRelativePath(operationPath) {
const prefix = "/v1/admin/ops-center";
if (!operationPath.startsWith(prefix)) {
throw new Error(`运营接口不属于 ops-center: ${operationPath}`);
}
return operationPath.slice(prefix.length);
}
function normalizeStrategyVersion(value) {
return String(value || "fixed_v2")
.trim()
.toLowerCase();
}
function luckyGiftConfigPayload(config = {}) {
const appCode = String(config.app_code || config.appCode || "")
.trim()
@ -222,7 +311,9 @@ function luckyGiftConfigPayload(config = {}) {
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),
// dynamic_v3 已改为通过带审计的 pool credit 接口注资;即使旧表单快照仍带 1m也不能在发布新版本时继续隐式 seed。
initial_pool_coins:
strategyVersion === "dynamic_v3" ? 0 : numberValue(config.initial_pool_coins ?? config.initialPoolCoins),
jackpot_global_rtp_max_percent: numberValue(
config.jackpot_global_rtp_max_percent ?? config.jackpotGlobalRTPMaxPercent,
),
@ -266,32 +357,6 @@ function luckyGiftConfigPayload(config = {}) {
};
}
function buildHeaders(body) {
const headers = {};
const token = getAccessToken();
if (body !== undefined) {
headers["Content-Type"] = "application/json";
}
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
async function readPayload(response) {
const text = await response.text();
if (!text) {
return { code: response.ok ? 0 : response.status, data: {} };
}
try {
return JSON.parse(text);
} catch {
return { code: response.ok ? 0 : response.status, data: text, message: text };
}
}
function normalizeItems(value) {
if (Array.isArray(value)) {
return value;

View File

@ -1,8 +1,22 @@
import { afterEach, expect, test, vi } from "vitest";
import { buildOpsUrl, fetchLuckyGiftDraws, fetchOpsDashboard, OPS_API_PREFIX, saveLuckyGiftConfig } from "./api.js";
import { setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "@/shared/api/request";
import {
adjustLuckyGiftPool,
buildOpsUrl,
fetchLuckyGiftDraws,
fetchOpsDashboard,
luckyGiftPoolKey,
normalizeDashboard,
opsRequest,
OPS_API_PREFIX,
saveLuckyGiftConfig,
} from "./api.js";
afterEach(() => {
vi.restoreAllMocks();
setAccessToken("");
setRefreshHandler(null);
setUnauthorizedHandler(null);
});
function jsonResponse(data) {
@ -19,6 +33,27 @@ test("builds ops api urls under the dedicated prefix", () => {
expect(url).toBe("http://localhost:3000/api/v1/admin/ops-center/apps?app_code=lalu&status=active");
});
test("refreshes a stale access token before retrying an ops-center request", async () => {
setAccessToken("stale-token");
setRefreshHandler(async () => {
setAccessToken("fresh-token");
return true;
});
vi.spyOn(window, "fetch")
.mockResolvedValueOnce(
new Response(JSON.stringify({ code: 40100, message: "访问凭证已失效" }), {
headers: { "Content-Type": "application/json" },
status: 401,
}),
)
.mockResolvedValueOnce(jsonResponse({ items: [{ app_code: "lalu" }] }));
await expect(opsRequest("/apps")).resolves.toEqual({ items: [{ app_code: "lalu" }] });
expect(window.fetch).toHaveBeenCalledTimes(2);
expect(window.fetch.mock.calls[0][1].headers.Authorization).toBe("Bearer stale-token");
expect(window.fetch.mock.calls[1][1].headers.Authorization).toBe("Bearer fresh-token");
});
test("loads every pool config per app through the plural configs endpoint", async () => {
const calls = [];
vi.spyOn(window, "fetch").mockImplementation(async (url) => {
@ -208,7 +243,7 @@ test("saves lucky gift config through admin ops route", async () => {
gift_price_reference: 100,
high_water_nonzero_factor_percent: 130,
high_watermark_coins: 20_000_000,
initial_pool_coins: 1_000_000,
initial_pool_coins: 0,
jackpot_global_rtp_max_percent: 98,
jackpot_multipliers: [200, 500, 1000],
jackpot_spend_threshold_coins: 50_000,
@ -250,3 +285,95 @@ test("saves lucky gift config through admin ops route", async () => {
user_hourly_payout_cap: 34_200,
});
});
test("keeps fixed_v2 and dynamic_v3 balances independent for the same app and pool", () => {
const data = normalizeDashboard({
apps: [{ app_code: "lalu" }],
perApp: [
{
appCode: "lalu",
poolBalances: [
{
availableBalance: 700,
balance: 900,
manualCreditTotal: 100,
manualDebitTotal: 20,
poolId: "lucky",
reserveFloor: 200,
strategyVersion: "fixed_v2",
},
{
available_balance: 3_000,
balance: 4_000,
manual_credit_total: 2_000,
manual_debit_total: 500,
pool_id: "lucky",
reserve_floor: 1_000,
strategy_version: "dynamic_v3",
},
],
},
],
});
expect(data.poolBalances).toHaveLength(2);
expect(new Set(data.poolBalances.map(luckyGiftPoolKey))).toEqual(
new Set(["lalu:lucky:fixed_v2", "lalu:lucky:dynamic_v3"]),
);
expect(data.poolBalances[0]).toMatchObject({ manual_credit_total: 100, manual_debit_total: 20 });
expect(data.poolBalances[1]).toMatchObject({ manual_credit_total: 2_000, manual_debit_total: 500 });
expect(data.summary.poolBalanceTotal).toBe(4_900);
});
test.each([
["in", "credit"],
["out", "debit"],
])("posts %s pool adjustments with the strategy identity and idempotency key", async (direction, route) => {
const calls = [];
vi.spyOn(window, "fetch").mockImplementation(async (url, init) => {
calls.push({ body: JSON.parse(init.body), method: init.method, url: String(url) });
return jsonResponse({
adjustment: {
adjustment_id: "adjust-1",
amount_coins: 1_000,
balance_after: direction === "out" ? 9_000 : 11_000,
balance_before: 10_000,
strategy_version: "dynamic_v3",
},
idempotent_replay: false,
pool: {
app_code: "lalu",
available_balance: 8_000,
balance: direction === "out" ? 9_000 : 11_000,
manual_credit_total: direction === "in" ? 1_000 : 0,
manual_debit_total: direction === "out" ? 1_000 : 0,
materialized: true,
pool_id: "lucky",
reserve_floor: 1_000,
strategy_version: "dynamic_v3",
},
});
});
const result = await adjustLuckyGiftPool({
adjustmentId: "adjust-1",
amountCoins: 1_000,
appCode: "LALU",
direction,
poolId: "lucky",
reason: " 运营调节 ",
strategyVersion: "dynamic_v3",
});
expect(calls).toEqual([
{
body: { adjustment_id: "adjust-1", amount_coins: 1_000, reason: "运营调节" },
method: "POST",
url: `http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools/${route}?app_code=lalu&pool_id=lucky&strategy_version=dynamic_v3`,
},
]);
expect(result).toMatchObject({
adjustment: { balance_before: 10_000, strategy_version: "dynamic_v3" },
pool: { app_code: "lalu", pool_id: "lucky", strategy_version: "dynamic_v3" },
});
});

View File

@ -4,10 +4,25 @@ import { AdminFilterSelect, AdminListToolbar } from "@/shared/ui/AdminListLayout
import { Button } from "@/shared/ui/Button.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { TimeText } from "@/shared/ui/TimeText.jsx";
import { luckyGiftPoolKey } from "../api.js";
import { formatNumber, formatPercent } from "../format.js";
import { LuckyGiftPoolActions } from "./LuckyGiftPoolActions.jsx";
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
export function ConfigsView({
apps,
canCredit,
canDebit,
canUpdate,
disabled,
luckyGifts,
onCreate,
onCredit,
onDebit,
onEdit,
poolBalances,
saving,
}) {
const [appFilter, setAppFilter] = useState("");
const appOptions = useMemo(
@ -19,25 +34,46 @@ export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
[apps],
);
// dashboard
const items = useMemo(
() => (appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts),
[appFilter, luckyGifts],
);
const items = useMemo(() => {
const poolByKey = new Map(poolBalances.map((pool) => [luckyGiftPoolKey(pool), pool]));
const visibleConfigs = appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts;
// app+pool+strategy app/pool fixed_v2 dynamic_v3
// strategy_version
return visibleConfigs.map((config) => ({
...config,
pool_balance: poolByKey.get(luckyGiftPoolKey(config)),
}));
}, [appFilter, luckyGifts, poolBalances]);
const columns = useMemo(() => buildColumns({ onEdit, saving }), [onEdit, saving]);
const columns = useMemo(
() =>
buildColumns({
canCredit,
canDebit,
canUpdate,
disabled,
onCredit,
onDebit,
onEdit,
saving,
}),
[canCredit, canDebit, canUpdate, disabled, onCredit, onDebit, onEdit, saving],
);
return (
<div className="ops-view">
<AdminListToolbar
actions={
<Button
disabled={saving}
startIcon={<AddOutlined fontSize="small" />}
variant="primary"
onClick={onCreate}
>
添加配置
</Button>
canUpdate ? (
<Button
disabled={saving}
startIcon={<AddOutlined fontSize="small" />}
variant="primary"
onClick={onCreate}
>
添加配置
</Button>
) : null
}
filters={
<AdminFilterSelect
@ -52,8 +88,8 @@ export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
columns={columns}
emptyLabel="暂无幸运礼物配置"
items={items}
minWidth="1260px"
rowKey={(item) => `${item.app_code}:${item.pool_id}:${item.rule_version}`}
minWidth="1540px"
rowKey={(item) => `${luckyGiftPoolKey(item)}:${item.rule_version}`}
title="幸运礼物配置"
total={items.length}
/>
@ -61,8 +97,8 @@ export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
);
}
function buildColumns({ onEdit, saving }) {
return [
function buildColumns({ canCredit, canDebit, canUpdate, disabled, onCredit, onDebit, onEdit, saving }) {
const columns = [
{ key: "app_code", label: "应用", width: "minmax(88px, 0.7fr)" },
{ key: "pool_id", label: "奖池", width: "minmax(110px, 0.9fr)" },
{
@ -79,6 +115,12 @@ function buildColumns({ onEdit, saving }) {
render: (item) => item.strategy_version || "fixed_v2",
width: "minmax(112px, 0.8fr)",
},
{
key: "pool_balance",
label: "奖池余额",
render: (item) => (item.pool_balance ? formatNumber(item.pool_balance.balance) : "-"),
width: "minmax(120px, 0.9fr)",
},
{
key: "enabled",
label: "状态",
@ -115,14 +157,46 @@ function buildColumns({ onEdit, saving }) {
render: (item) => (item.is_default ? "-" : <TimeText value={item.created_at_ms} />),
width: "minmax(176px, 1.1fr)",
},
{
];
if (canUpdate || canCredit || canDebit) {
columns.push({
fixed: "right",
key: "actions",
label: "操作",
render: (item) => (
<Button disabled={saving} size="small" onClick={() => onEdit(item)}>
{item.is_default ? "发布配置" : "新增版本"}
</Button>
),
},
];
render: (item) => {
const identity = `${item.app_code} / ${item.pool_id} / ${item.strategy_version}`;
const actionPool = item.pool_balance || {
app_code: item.app_code,
pool_id: item.pool_id,
strategy_version: item.strategy_version,
};
return (
<div className="ops-config-actions">
<LuckyGiftPoolActions
canCredit={canCredit}
canDebit={canDebit}
disabled={disabled}
pool={actionPool}
onCredit={onCredit}
onDebit={onDebit}
/>
{canUpdate ? (
<Button
aria-label={`${item.is_default ? "发布配置" : "新增版本"} ${identity}`}
disabled={saving}
size="small"
onClick={() => onEdit(item)}
>
{item.is_default ? "发布配置" : "新增版本"}
</Button>
) : null}
</div>
);
},
width: `${(canCredit ? 110 : 0) + (canDebit ? 110 : 0) + (canUpdate ? 110 : 0) + 20}px`,
});
}
return columns;
}

View File

@ -444,7 +444,14 @@ function DynamicStrategySections({ form, onChange }) {
<section className="ops-config-section">
<h3>动态水位</h3>
<div className="ops-form-grid">
<NumberField form={form} label="初始奖池金币" name="initial_pool_coins" onChange={onChange} />
<NumberField
disabled
form={form}
helperText="dynamic_v3 发布规则不注资,请在奖池水位中添加金币"
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
@ -550,9 +557,10 @@ function ConfigMetric({ label, value }) {
);
}
function NumberField({ form, helperText, label, name, onChange, step = "1" }) {
function NumberField({ disabled = false, form, helperText, label, name, onChange, step = "1" }) {
return (
<TextField
disabled={disabled}
helperText={helperText}
label={label}
required

View File

@ -0,0 +1,48 @@
import { Button } from "@/shared/ui/Button.jsx";
export function LuckyGiftPoolActions({ canCredit, canDebit, disabled, onCredit, onDebit, pool }) {
if (!canCredit && !canDebit) {
return null;
}
const appCode = pool?.app_code || "-";
const poolID = pool?.pool_id || "-";
const strategyVersion = pool?.strategy_version || "fixed_v2";
const identity = `${appCode} / ${poolID} / ${strategyVersion}`;
const missingPool = !pool || pool.balance === undefined || pool.available_balance === undefined;
const availableBalance = Number(pool?.available_balance || 0);
return (
<div className="ops-pool-actions">
{canCredit ? (
<Button
aria-label={`添加水位 ${identity}`}
disabled={disabled || missingPool}
size="small"
title={missingPool ? "尚未获取该策略的奖池水位" : undefined}
onClick={() => onCredit(pool)}
>
添加水位
</Button>
) : null}
{canDebit ? (
<Button
aria-label={`扣减水位 ${identity}`}
disabled={disabled || missingPool || availableBalance <= 0}
size="small"
title={
missingPool
? "尚未获取该策略的奖池水位"
: availableBalance <= 0
? "当前无可扣减水位"
: undefined
}
variant="danger"
onClick={() => onDebit(pool)}
>
扣减水位
</Button>
) : null}
</div>
);
}

View File

@ -0,0 +1,132 @@
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import TextField from "@mui/material/TextField";
import { useState } from "react";
import { Button } from "@/shared/ui/Button.jsx";
import { formatNumber } from "../format.js";
export function LuckyGiftPoolAdjustmentDialog({ adjustmentId, direction, onClose, onSubmit, pool, submitting }) {
const [amount, setAmount] = useState("");
const [reason, setReason] = useState("");
const [errors, setErrors] = useState({});
const debit = direction === "out";
const appCode = pool.app_code;
const poolID = pool.pool_id;
const strategyVersion = pool.strategy_version;
const availableBalance = Number(pool.available_balance || 0);
const title = debit ? "扣减奖池水位" : "添加奖池水位";
const submit = (event) => {
event.preventDefault();
const nextErrors = validateAdjustment({ amount, availableBalance, debit, reason });
setErrors(nextErrors);
if (Object.keys(nextErrors).length) {
return;
}
// adjustmentId
// 使
onSubmit({
adjustmentId,
amountCoins: Number(amount),
appCode,
direction,
poolId: poolID,
reason: reason.trim(),
strategyVersion,
});
};
return (
<Dialog
fullWidth
maxWidth="sm"
open
aria-labelledby="lucky-pool-adjustment-title"
onClose={submitting ? undefined : onClose}
>
<form className="ops-pool-adjustment-form" onSubmit={submit}>
<DialogTitle id="lucky-pool-adjustment-title">{title}</DialogTitle>
<DialogContent dividers className="ops-pool-adjustment-content">
<div className="ops-config-metrics ops-pool-adjustment-metrics">
<PoolMetric label="应用" value={appCode} />
<PoolMetric label="奖池" value={poolID} />
<PoolMetric label="策略" value={strategyVersion} />
<PoolMetric label="当前余额" value={formatNumber(pool.balance)} />
<PoolMetric label="保底线" value={formatNumber(pool.reserve_floor)} />
<PoolMetric label="可用余额" value={formatNumber(availableBalance)} />
</div>
{pool.materialized === false ? (
<div className="ops-pool-adjustment-note" role="status">
当前为默认水位提交后将物化为独立策略奖池
</div>
) : null}
<TextField
autoFocus
disabled={submitting}
error={Boolean(errors.amount)}
fullWidth
helperText={errors.amount || (debit ? `最多可扣减 ${formatNumber(availableBalance)} 金币` : "")}
label="调整金币"
required
slotProps={{ htmlInput: { inputMode: "numeric", pattern: "[0-9]*" } }}
value={amount}
onChange={(event) => {
setAmount(event.target.value);
setErrors((current) => ({ ...current, amount: "" }));
}}
/>
<TextField
disabled={submitting}
error={Boolean(errors.reason)}
fullWidth
helperText={errors.reason}
label="调整原因"
multiline
required
rows={3}
slotProps={{ htmlInput: { maxLength: 255 } }}
value={reason}
onChange={(event) => {
setReason(event.target.value);
setErrors((current) => ({ ...current, reason: "" }));
}}
/>
</DialogContent>
<DialogActions>
<Button disabled={submitting} onClick={onClose}>
取消
</Button>
<Button disabled={submitting} type="submit" variant={debit ? "danger" : "primary"}>
{debit ? "确认扣减" : "确认添加"}
</Button>
</DialogActions>
</form>
</Dialog>
);
}
function PoolMetric({ label, value }) {
return (
<div className="ops-config-metric">
<span>{label}</span>
<strong>{value || "-"}</strong>
</div>
);
}
function validateAdjustment({ amount, availableBalance, debit, reason }) {
const errors = {};
const rawAmount = String(amount || "").trim();
if (!/^[1-9]\d*$/.test(rawAmount) || !Number.isSafeInteger(Number(rawAmount))) {
errors.amount = "请输入大于 0 的整数金币数量";
} else if (debit && Number(rawAmount) > availableBalance) {
errors.amount = `扣减不能超过可用余额 ${formatNumber(availableBalance)}`;
}
if (!String(reason || "").trim()) {
errors.reason = "请输入调整原因";
}
return errors;
}

View File

@ -2,123 +2,168 @@ import AppsOutlined from "@mui/icons-material/AppsOutlined";
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
import CasinoOutlined from "@mui/icons-material/CasinoOutlined";
import SavingsOutlined from "@mui/icons-material/SavingsOutlined";
import { useMemo } from "react";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { KpiCard } from "@/shared/ui/KpiCard.jsx";
import { TimeText } from "@/shared/ui/TimeText.jsx";
import { luckyGiftPoolKey } from "../api.js";
import { formatNumber, formatPercentFromPPM } from "../format.js";
import { LuckyGiftPoolActions } from "./LuckyGiftPoolActions.jsx";
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
export function OverviewView({ data }) {
const { appSummaries, poolBalances, summary } = data;
export function OverviewView({ canCredit, canDebit, data, disabled, onCredit, onDebit }) {
const { appSummaries, poolBalances, summary } = data;
const poolBalanceColumns = useMemo(
() => buildPoolBalanceColumns({ canCredit, canDebit, disabled, onCredit, onDebit }),
[canCredit, canDebit, disabled, onCredit, onDebit],
);
return (
<div className="ops-view">
<div className="kpi-grid ops-kpi-grid">
<KpiCard
icon={AppsOutlined}
label="运营应用"
sub={`主数据 ${formatNumber(countBySource(data.apps, "registry"))} · 外部接入 ${formatNumber(countBySource(data.apps, "fixed"))}`}
value={formatNumber(summary.activeApps)}
/>
<KpiCard
icon={CardGiftcardOutlined}
label="已发布奖池配置"
sub={`启用 ${formatNumber(summary.enabledPools)} · 停用 ${formatNumber(summary.configuredPools - summary.enabledPools)}`}
tone="info"
value={formatNumber(summary.configuredPools)}
/>
<KpiCard
icon={CasinoOutlined}
label="抽奖次数"
sub={`成功 ${formatNumber(summary.grantedDraws)} · 待发放 ${formatNumber(summary.pendingDraws)} · 失败 ${formatNumber(summary.failedDraws)}`}
tone="warning"
value={formatNumber(summary.totalDraws)}
/>
<KpiCard
icon={SavingsOutlined}
label="奖池金币"
sub={`可用 ${formatNumber(summary.poolAvailableTotal)} · 保底 ${formatNumber(summary.poolReserveTotal)}`}
tone="success"
value={formatNumber(summary.poolBalanceTotal)}
/>
</div>
return (
<div className="ops-view">
<div className="kpi-grid ops-kpi-grid">
<KpiCard
icon={AppsOutlined}
label="运营应用"
sub={`主数据 ${formatNumber(countBySource(data.apps, "registry"))} · 外部接入 ${formatNumber(countBySource(data.apps, "fixed"))}`}
value={formatNumber(summary.activeApps)}
/>
<KpiCard
icon={CardGiftcardOutlined}
label="已发布奖池配置"
sub={`启用 ${formatNumber(summary.enabledPools)} · 停用 ${formatNumber(summary.configuredPools - summary.enabledPools)}`}
tone="info"
value={formatNumber(summary.configuredPools)}
/>
<KpiCard
icon={CasinoOutlined}
label="抽奖次数"
sub={`成功 ${formatNumber(summary.grantedDraws)} · 待发放 ${formatNumber(summary.pendingDraws)} · 失败 ${formatNumber(summary.failedDraws)}`}
tone="warning"
value={formatNumber(summary.totalDraws)}
/>
<KpiCard
icon={SavingsOutlined}
label="奖池金币"
sub={`可用 ${formatNumber(summary.poolAvailableTotal)} · 保底 ${formatNumber(summary.poolReserveTotal)}`}
tone="success"
value={formatNumber(summary.poolBalanceTotal)}
/>
</div>
<DataTable
columns={poolBalanceColumns}
emptyLabel="暂无奖池余额"
items={poolBalances}
minWidth="920px"
rowKey={(item) => `${item.app_code ?? item.appCode}:${item.pool_id ?? item.poolId}`}
title="奖池实时水位"
total={poolBalances.length}
/>
<DataTable
columns={poolBalanceColumns}
emptyLabel="暂无奖池余额"
items={poolBalances}
minWidth="1480px"
rowKey={luckyGiftPoolKey}
title="奖池实时水位"
total={poolBalances.length}
/>
<DataTable
columns={appSummaryColumns}
emptyLabel="暂无抽奖数据"
items={appSummaries}
minWidth="920px"
rowKey={(item) => item.app_code}
title="各应用抽奖汇总"
total={appSummaries.length}
/>
</div>
);
<DataTable
columns={appSummaryColumns}
emptyLabel="暂无抽奖数据"
items={appSummaries}
minWidth="920px"
rowKey={(item) => item.app_code}
title="各应用抽奖汇总"
total={appSummaries.length}
/>
</div>
);
}
const poolBalanceColumns = [
{ key: "app_code", label: "应用", render: (item) => item.app_code ?? item.appCode, width: "minmax(96px, 0.8fr)" },
{ key: "pool_id", label: "奖池", render: (item) => item.pool_id ?? item.poolId, width: "minmax(110px, 1fr)" },
{
key: "materialized",
label: "水位状态",
// materialized=false
render: (item) =>
item.materialized === false ? <OpsStatusBadge label="默认水位" /> : <OpsStatusBadge label="已入账" tone="succeeded" />,
width: "minmax(132px, 0.9fr)"
},
{ key: "balance", label: "当前余额", render: (item) => formatNumber(item.balance ?? item.Balance) },
{ key: "available_balance", label: "可用余额", render: (item) => formatNumber(item.available_balance ?? item.availableBalance) },
{ key: "reserve_floor", label: "保底线", render: (item) => formatNumber(item.reserve_floor ?? item.reserveFloor), width: "minmax(96px, 0.8fr)" },
{ key: "total_in", label: "累计流入", render: (item) => formatNumber(item.total_in ?? item.totalIn) },
{ key: "total_out", label: "累计流出", render: (item) => formatNumber(item.total_out ?? item.totalOut) },
{
key: "updated_at_ms",
label: "更新时间",
render: (item) =>
item.materialized === false ? "-" : <TimeText value={item.updated_at_ms ?? item.updatedAtMs} />,
width: "minmax(150px, 1fr)"
}
];
function buildPoolBalanceColumns({ canCredit, canDebit, disabled, onCredit, onDebit }) {
const columns = [
{ key: "app_code", label: "应用", width: "minmax(96px, 0.8fr)" },
{ key: "pool_id", label: "奖池", width: "minmax(110px, 1fr)" },
{ key: "strategy_version", label: "策略", width: "minmax(112px, 0.9fr)" },
{
key: "materialized",
label: "水位状态",
// materialized=false owner owner
render: (item) =>
item.materialized === false ? (
<OpsStatusBadge label="默认水位" />
) : (
<OpsStatusBadge label="已入账" tone="succeeded" />
),
width: "minmax(132px, 0.9fr)",
},
{ key: "balance", label: "当前余额", render: (item) => formatNumber(item.balance) },
{ key: "available_balance", label: "可用余额", render: (item) => formatNumber(item.available_balance) },
{
key: "reserve_floor",
label: "保底线",
render: (item) => formatNumber(item.reserve_floor),
width: "minmax(96px, 0.8fr)",
},
{ key: "total_in", label: "业务流入", render: (item) => formatNumber(item.total_in) },
{ key: "total_out", label: "返奖流出", render: (item) => formatNumber(item.total_out) },
{ key: "manual_credit_total", label: "人工添加", render: (item) => formatNumber(item.manual_credit_total) },
{ key: "manual_debit_total", label: "人工扣减", render: (item) => formatNumber(item.manual_debit_total) },
{
key: "updated_at_ms",
label: "更新时间",
render: (item) => (item.materialized === false ? "-" : <TimeText value={item.updated_at_ms} />),
width: "minmax(150px, 1fr)",
},
];
if (canCredit || canDebit) {
columns.push({
fixed: "right",
key: "actions",
label: "操作",
render: (item) => (
<LuckyGiftPoolActions
canCredit={canCredit}
canDebit={canDebit}
disabled={disabled}
pool={item}
onCredit={onCredit}
onDebit={onDebit}
/>
),
width: canCredit && canDebit ? "220px" : "120px",
});
}
return columns;
}
const appSummaryColumns = [
{ key: "app_code", label: "应用", width: "minmax(96px, 0.8fr)" },
{ key: "total_draws", label: "抽奖次数", render: (item) => formatNumber(item.total_draws) },
{ key: "unique_users", label: "参与用户", render: (item) => formatNumber(item.unique_users) },
{ key: "unique_rooms", label: "参与房间", render: (item) => formatNumber(item.unique_rooms) },
{ key: "total_spent_coins", label: "消耗金币", render: (item) => formatNumber(item.total_spent_coins) },
{ key: "total_reward_coins", label: "返还金币", render: (item) => formatNumber(item.total_reward_coins) },
{
key: "actual_rtp_ppm",
label: "实际 RTP",
render: (item) => (item.total_draws > 0 ? formatPercentFromPPM(item.actual_rtp_ppm) : "-")
},
{
key: "exceptions",
label: "发放异常",
// 0 0
render: (item) => {
const pending = Number(item.pending_draws || 0);
const failed = Number(item.failed_draws || 0);
if (!pending && !failed) {
return <OpsStatusBadge label="正常" tone="succeeded" />;
}
return <OpsStatusBadge label={`待发放 ${formatNumber(pending)} · 失败 ${formatNumber(failed)}`} tone={failed ? "danger" : "warning"} />;
{ key: "app_code", label: "应用", width: "minmax(96px, 0.8fr)" },
{ key: "total_draws", label: "抽奖次数", render: (item) => formatNumber(item.total_draws) },
{ key: "unique_users", label: "参与用户", render: (item) => formatNumber(item.unique_users) },
{ key: "unique_rooms", label: "参与房间", render: (item) => formatNumber(item.unique_rooms) },
{ key: "total_spent_coins", label: "消耗金币", render: (item) => formatNumber(item.total_spent_coins) },
{ key: "total_reward_coins", label: "返还金币", render: (item) => formatNumber(item.total_reward_coins) },
{
key: "actual_rtp_ppm",
label: "实际 RTP",
render: (item) => (item.total_draws > 0 ? formatPercentFromPPM(item.actual_rtp_ppm) : "-"),
},
{
key: "exceptions",
label: "发放异常",
// 0 0
render: (item) => {
const pending = Number(item.pending_draws || 0);
const failed = Number(item.failed_draws || 0);
if (!pending && !failed) {
return <OpsStatusBadge label="正常" tone="succeeded" />;
}
return (
<OpsStatusBadge
label={`待发放 ${formatNumber(pending)} · 失败 ${formatNumber(failed)}`}
tone={failed ? "danger" : "warning"}
/>
);
},
width: "minmax(170px, 1.2fr)",
},
width: "minmax(170px, 1.2fr)"
}
];
function countBySource(apps = [], source) {
return apps.filter((item) => (item.source || "registry") === source).length;
return apps.filter((item) => (item.source || "registry") === source).length;
}

View File

@ -21,7 +21,8 @@ const dynamicDefaults = {
device_daily_payout_cap: 0,
high_water_nonzero_factor_percent: 130,
high_watermark_coins: 20_000_000,
initial_pool_coins: 1_000_000,
// dynamic_v3 的运行资金只能通过带审计的奖池水位接口注入,发布规则不再隐式 seed。
initial_pool_coins: 0,
jackpot_global_rtp_max_percent: 98,
jackpot_multipliers: [200, 500, 1000],
jackpot_spend_threshold_coins: 0,
@ -113,6 +114,11 @@ export function configToForm(config = {}) {
form[field] = targetRTPPercent;
continue;
}
if (field === "initial_pool_coins" && strategyVersion === DYNAMIC_STRATEGY) {
// 旧 dynamic_v3 快照可能仍保存 1,000,000新增版本必须显式归零不能把历史隐式注资语义继续复制。
form[field] = "0";
continue;
}
form[field] = formConfigNumber(config, field, snakeToCamel(field), defaultNumber(field, strategyVersion));
}
return form;
@ -140,7 +146,10 @@ export function formToConfig(config, form) {
strategy_version: form.strategy_version,
};
for (const field of numericFields) {
output[field] = numberFromForm(form[field]);
output[field] =
field === "initial_pool_coins" && form.strategy_version === DYNAMIC_STRATEGY
? 0
: numberFromForm(form[field]);
}
return output;
}
@ -270,7 +279,7 @@ export function validateLuckyGiftForm(form) {
PPM_SCALE
)
errors.push("奖池、平台盈利和主播收益比例合计必须等于 100%");
if (number("initial_pool_coins") <= 0) errors.push("初始奖池必须大于 0");
if (number("initial_pool_coins") !== 0) errors.push("dynamic_v3 初始奖池必须为 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("高水位必须大于正数低水位");

View File

@ -8,7 +8,7 @@ describe("ops center lucky gift dynamic_v3 form", () => {
expect(form).toMatchObject({
anchor_rate_percent: "1",
enabled: false,
initial_pool_coins: "1000000",
initial_pool_coins: "0",
jackpot_multipliers: "200, 500, 1000",
max_single_payout: "0",
pool_rate_percent: "98",
@ -49,6 +49,15 @@ describe("ops center lucky gift dynamic_v3 form", () => {
});
});
test("drops legacy dynamic initial seed when loading and publishing a new version", () => {
const legacy = completeDynamicConfig();
legacy.initial_pool_coins = 1_000_000;
const form = configToForm(legacy);
expect(form.initial_pool_coins).toBe("0");
expect(formToConfig(legacy, form).initial_pool_coins).toBe(0);
});
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([]);
@ -98,7 +107,12 @@ describe("ops center lucky gift dynamic_v3 form", () => {
});
const dynamic = applyDynamicStrategyDefaults(legacy);
expect(dynamic).toMatchObject({ enabled: false, pool_rate_percent: "98", strategy_version: "dynamic_v3" });
expect(dynamic).toMatchObject({
enabled: false,
initial_pool_coins: "0",
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],
@ -144,7 +158,7 @@ function completeDynamicConfig() {
gift_price_reference: 100,
high_water_nonzero_factor_percent: 130,
high_watermark_coins: 20_000_000,
initial_pool_coins: 1_000_000,
initial_pool_coins: 0,
jackpot_global_rtp_max_percent: 98,
jackpot_multipliers: [200, 500, 1000],
jackpot_spend_threshold_coins: 50_000,

View File

@ -2,21 +2,24 @@ import CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider } from "@mui/material/styles";
import React from "react";
import { createRoot } from "react-dom/client";
import { AuthProvider } from "@/app/auth/AuthProvider.jsx";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
import { theme } from "@/theme.js";
import { OpsCenterApp } from "./OpsCenterApp.jsx";
import { AuthenticatedOpsCenter } from "./OpsCenterEntry.jsx";
import "@/styles/tokens.css";
import "@/styles/shared-ui.css";
import "@/styles/responsive.css";
import "./styles/index.css";
createRoot(document.getElementById("ops-center-root")).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<ToastProvider>
<CssBaseline />
<OpsCenterApp />
</ToastProvider>
</ThemeProvider>
</React.StrictMode>
<React.StrictMode>
<ThemeProvider theme={theme}>
<ToastProvider>
<AuthProvider>
<CssBaseline />
<AuthenticatedOpsCenter />
</AuthProvider>
</ToastProvider>
</ThemeProvider>
</React.StrictMode>,
);

View File

@ -147,6 +147,43 @@ body {
padding: var(--space-3) var(--space-4);
}
.ops-pool-actions,
.ops-config-actions {
display: flex;
align-items: center;
gap: var(--space-2);
white-space: nowrap;
}
.ops-config-actions {
justify-content: flex-end;
}
.ops-pool-adjustment-form {
display: contents;
}
.ops-pool-adjustment-content {
display: grid;
gap: var(--space-4);
padding-top: var(--space-4) !important;
}
.ops-pool-adjustment-metrics {
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 0;
}
.ops-pool-adjustment-note {
border: 1px solid var(--info-border);
border-radius: var(--radius-control);
background: var(--info-surface);
color: var(--info);
font-size: 13px;
font-weight: 700;
padding: var(--space-3);
}
/* ---- 幸运礼物配置弹窗 ---- */
.ops-config-form {
@ -421,6 +458,7 @@ body {
.ops-nav,
.ops-kpi-grid,
.ops-config-metrics,
.ops-pool-adjustment-metrics,
.ops-form-grid,
.ops-stage-thresholds,
.ops-tier-row {

View File

@ -187,6 +187,8 @@ export const PERMISSIONS = {
sevenDayCheckInUpdate: "seven-day-checkin:update",
luckyGiftView: "lucky-gift:view",
luckyGiftUpdate: "lucky-gift:update",
luckyGiftPoolCredit: "lucky-gift:pool-credit",
luckyGiftPoolDebit: "lucky-gift:pool-debit",
wheelView: "wheel:view",
wheelUpdate: "wheel:update",
roomRocketView: "room-rocket:view",

View File

@ -80,9 +80,11 @@ export const API_OPERATIONS = {
createUser: "createUser",
createWeeklyStarCycle: "createWeeklyStarCycle",
creditCoinSellerStock: "creditCoinSellerStock",
creditLuckyGiftPool: "creditLuckyGiftPool",
dashboardOverview: "dashboardOverview",
dashboardUserProfileOverview: "dashboardUserProfileOverview",
debitCoinSellerStock: "debitCoinSellerStock",
debitLuckyGiftPool: "debitLuckyGiftPool",
deleteAchievementDefinition: "deleteAchievementDefinition",
deleteActivityTemplate: "deleteActivityTemplate",
deleteAgency: "deleteAgency",
@ -871,6 +873,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "coin-seller:stock-credit",
permissions: ["coin-seller:stock-credit"]
},
creditLuckyGiftPool: {
method: "POST",
operationId: API_OPERATIONS.creditLuckyGiftPool,
path: "/v1/admin/ops-center/lucky-gifts/pools/credit",
permission: "lucky-gift:pool-credit",
permissions: ["lucky-gift:pool-credit"]
},
dashboardOverview: {
method: "GET",
operationId: API_OPERATIONS.dashboardOverview,
@ -892,6 +901,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "coin-seller:stock-credit",
permissions: ["coin-seller:stock-credit"]
},
debitLuckyGiftPool: {
method: "POST",
operationId: API_OPERATIONS.debitLuckyGiftPool,
path: "/v1/admin/ops-center/lucky-gifts/pools/debit",
permission: "lucky-gift:pool-debit",
permissions: ["lucky-gift:pool-debit"]
},
deleteAchievementDefinition: {
method: "DELETE",
operationId: API_OPERATIONS.deleteAchievementDefinition,

View File

@ -324,6 +324,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/ops-center/lucky-gifts/pools/credit": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["creditLuckyGiftPool"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/ops-center/lucky-gifts/pools/debit": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["debitLuckyGiftPool"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/activity/wheel/config": {
parameters: {
query?: never;
@ -4680,6 +4712,64 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
LuckyGiftPoolAdjustmentInput: {
adjustment_id: string;
/** Format: int64 */
amount_coins: number;
reason: string;
};
LuckyGiftPoolBalance: {
app_code: string;
pool_id: string;
/** @enum {string} */
strategy_version: "fixed_v2" | "dynamic_v3";
/** Format: int64 */
balance: number;
/** Format: int64 */
reserve_floor: number;
/** Format: int64 */
available_balance: number;
/** Format: int64 */
total_in: number;
/** Format: int64 */
total_out: number;
/** Format: int64 */
manual_credit_total: number;
/** Format: int64 */
manual_debit_total: number;
materialized: boolean;
/** Format: int64 */
updated_at_ms: number;
};
LuckyGiftPoolAdjustment: {
adjustment_id: string;
app_code: string;
pool_id: string;
/** @enum {string} */
strategy_version: "fixed_v2" | "dynamic_v3";
/** @enum {string} */
direction: "in" | "out";
/** Format: int64 */
amount_coins: number;
reason: string;
/** Format: int64 */
operator_admin_id: number;
/** Format: int64 */
balance_before: number;
/** Format: int64 */
balance_after: number;
/** Format: int64 */
created_at_ms: number;
};
LuckyGiftPoolAdjustmentResult: {
adjustment: components["schemas"]["LuckyGiftPoolAdjustment"];
pool: components["schemas"]["LuckyGiftPoolBalance"];
idempotent_replay: boolean;
};
ApiResponseLuckyGiftPoolAdjustmentResult: components["schemas"]["Envelope"] & {
requestId?: string;
data?: components["schemas"]["LuckyGiftPoolAdjustmentResult"];
};
ApiResponseAdminAppList: components["schemas"]["Envelope"] & {
data?: components["schemas"]["AdminAppList"];
};
@ -7086,6 +7176,15 @@ export interface components {
"application/json": components["schemas"]["ApiResponseEmpty"];
};
};
/** @description 奖池水位调整成功或同幂等请求的首次结果 */
LuckyGiftPoolAdjustmentResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseLuckyGiftPoolAdjustmentResult"];
};
};
/** @description OK */
RechargeBillPageResponse: {
headers: {
@ -8007,6 +8106,74 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
creditLuckyGiftPool: {
parameters: {
query: {
app_code: string;
pool_id: string;
strategy_version: "fixed_v2" | "dynamic_v3";
};
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["LuckyGiftPoolAdjustmentInput"];
};
};
responses: {
200: components["responses"]["LuckyGiftPoolAdjustmentResponse"];
/** @description 调整参数不正确 */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description 幂等键冲突或可用水位不足 */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
debitLuckyGiftPool: {
parameters: {
query: {
app_code: string;
pool_id: string;
strategy_version: "fixed_v2" | "dynamic_v3";
};
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["LuckyGiftPoolAdjustmentInput"];
};
};
responses: {
200: components["responses"]["LuckyGiftPoolAdjustmentResponse"];
/** @description 调整参数不正确 */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description 幂等键冲突或可用水位不足 */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
getWheelConfig: {
parameters: {
query?: never;