diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index ce8a452..5165a7f 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -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": [ { diff --git a/ops-center/src/OpsCenterApp.jsx b/ops-center/src/OpsCenterApp.jsx index 7d0e75b..54e461e 100644 --- a/ops-center/src/OpsCenterApp.jsx +++ b/ops-center/src/OpsCenterApp.jsx @@ -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" ? : null} + {activeView === "overview" ? ( + openPoolAdjustment(pool, "in")} + onDebit={(pool) => openPoolAdjustment(pool, "out")} + /> + ) : null} {activeView === "configs" ? ( openPoolAdjustment(pool, "in")} + onDebit={(pool) => openPoolAdjustment(pool, "out")} onEdit={openEditConfig} /> ) : null} @@ -195,7 +258,27 @@ export function OpsCenterApp() { onSubmit={handleSaveConfig} /> ) : null} + {poolDialog ? ( + setPoolDialog(null)} + onSubmit={handleAdjustPool} + /> + ) : null} ); } + +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)}`; +} diff --git a/ops-center/src/OpsCenterApp.test.jsx b/ops-center/src/OpsCenterApp.test.jsx index c5d4e21..4f30e7c 100644 --- a/ops-center/src/OpsCenterApp.test.jsx +++ b/ops-center/src/OpsCenterApp.test.jsx @@ -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( - + permissions.includes(code)} /> , ); } @@ -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, diff --git a/ops-center/src/OpsCenterEntry.jsx b/ops-center/src/OpsCenterEntry.jsx new file mode 100644 index 0000000..43a32ed --- /dev/null +++ b/ops-center/src/OpsCenterEntry.jsx @@ -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 ; + } + + return ; +} diff --git a/ops-center/src/OpsCenterEntry.test.jsx b/ops-center/src/OpsCenterEntry.test.jsx new file mode 100644 index 0000000..fabce8e --- /dev/null +++ b/ops-center/src/OpsCenterEntry.test.jsx @@ -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 }) => ( +
+ ), +})); + +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( + + + , + ); + + // 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( + + + , + ); + + 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 }; +} diff --git a/ops-center/src/api.js b/ops-center/src/api.js index 9095cce..7ef47d5 100644 --- a/ops-center/src/api.js +++ b/ops-center/src/api.js @@ -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_version,lucky-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; diff --git a/ops-center/src/api.test.js b/ops-center/src/api.test.js index 37b5a12..7b24247 100644 --- a/ops-center/src/api.test.js +++ b/ops-center/src/api.test.js @@ -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" }, + }); +}); diff --git a/ops-center/src/components/ConfigsView.jsx b/ops-center/src/components/ConfigsView.jsx index dff4af3..278c7ff 100644 --- a/ops-center/src/components/ConfigsView.jsx +++ b/ops-center/src/components/ConfigsView.jsx @@ -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 (
} - variant="primary" - onClick={onCreate} - > - 添加配置 - + canUpdate ? ( + + ) : null } filters={ `${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 ? "-" : ), width: "minmax(176px, 1.1fr)", }, - { + ]; + + if (canUpdate || canCredit || canDebit) { + columns.push({ + fixed: "right", key: "actions", label: "操作", - render: (item) => ( - - ), - }, - ]; + 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 ( +
+ + {canUpdate ? ( + + ) : null} +
+ ); + }, + width: `${(canCredit ? 110 : 0) + (canDebit ? 110 : 0) + (canUpdate ? 110 : 0) + 20}px`, + }); + } + + return columns; } diff --git a/ops-center/src/components/LuckyGiftConfigDialog.jsx b/ops-center/src/components/LuckyGiftConfigDialog.jsx index 54bc3b1..c017168 100644 --- a/ops-center/src/components/LuckyGiftConfigDialog.jsx +++ b/ops-center/src/components/LuckyGiftConfigDialog.jsx @@ -444,7 +444,14 @@ function DynamicStrategySections({ form, onChange }) {

动态水位

- + + {canCredit ? ( + + ) : null} + {canDebit ? ( + + ) : null} +
+ ); +} diff --git a/ops-center/src/components/LuckyGiftPoolAdjustmentDialog.jsx b/ops-center/src/components/LuckyGiftPoolAdjustmentDialog.jsx new file mode 100644 index 0000000..43d4f39 --- /dev/null +++ b/ops-center/src/components/LuckyGiftPoolAdjustmentDialog.jsx @@ -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 ( + +
+ {title} + +
+ + + + + + +
+ {pool.materialized === false ? ( +
+ 当前为默认水位,提交后将物化为独立策略奖池。 +
+ ) : null} + { + setAmount(event.target.value); + setErrors((current) => ({ ...current, amount: "" })); + }} + /> + { + setReason(event.target.value); + setErrors((current) => ({ ...current, reason: "" })); + }} + /> +
+ + + + +
+
+ ); +} + +function PoolMetric({ label, value }) { + return ( +
+ {label} + {value || "-"} +
+ ); +} + +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; +} diff --git a/ops-center/src/components/OverviewView.jsx b/ops-center/src/components/OverviewView.jsx index 8f98126..ed64516 100644 --- a/ops-center/src/components/OverviewView.jsx +++ b/ops-center/src/components/OverviewView.jsx @@ -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 ( -
-
- - - - -
+ return ( +
+
+ + + + +
- `${item.app_code ?? item.appCode}:${item.pool_id ?? item.poolId}`} - title="奖池实时水位" - total={poolBalances.length} - /> + - item.app_code} - title="各应用抽奖汇总" - total={appSummaries.length} - /> -
- ); + item.app_code} + title="各应用抽奖汇总" + total={appSummaries.length} + /> +
+ ); } -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 ? : , - 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 ? "-" : , - 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 ? ( + + ) : ( + + ), + 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 ? "-" : ), + width: "minmax(150px, 1fr)", + }, + ]; + + if (canCredit || canDebit) { + columns.push({ + fixed: "right", + key: "actions", + label: "操作", + render: (item) => ( + + ), + 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 ; - } - return ; + { 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 ; + } + return ( + + ); + }, + 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; } diff --git a/ops-center/src/configForm.js b/ops-center/src/configForm.js index f814777..bc7f3cf 100644 --- a/ops-center/src/configForm.js +++ b/ops-center/src/configForm.js @@ -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("高水位必须大于正数低水位"); diff --git a/ops-center/src/configForm.test.js b/ops-center/src/configForm.test.js index df9118d..a20ec5c 100644 --- a/ops-center/src/configForm.test.js +++ b/ops-center/src/configForm.test.js @@ -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, diff --git a/ops-center/src/main.jsx b/ops-center/src/main.jsx index 44f4414..205ab47 100644 --- a/ops-center/src/main.jsx +++ b/ops-center/src/main.jsx @@ -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( - - - - - - - - + + + + + + + + + + , ); diff --git a/ops-center/src/styles/index.css b/ops-center/src/styles/index.css index 14d2863..277fc65 100644 --- a/ops-center/src/styles/index.css +++ b/ops-center/src/styles/index.css @@ -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 { diff --git a/src/app/permissions.ts b/src/app/permissions.ts index 9fcf5ec..1c90d76 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -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", diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 5f54e3e..005f3c8 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -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 = { 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 = { 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, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index be01785..3b59929 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -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; 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;