diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 104b28c..5ed8487 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", @@ -9071,6 +9151,16 @@ } } }, + "LuckyGiftPoolAdjustmentResponse": { + "description": "奖池水位调整成功或同幂等请求的首次结果", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseLuckyGiftPoolAdjustmentResult" + } + } + } + }, "RechargeBillPageResponse": { "description": "OK", "content": { @@ -9483,6 +9573,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/databi/src/social/SocialBiApp.jsx b/databi/src/social/SocialBiApp.jsx index 1f79363..cd8af39 100644 --- a/databi/src/social/SocialBiApp.jsx +++ b/databi/src/social/SocialBiApp.jsx @@ -16,6 +16,7 @@ import { DATE_PRESETS, GRANULARITIES, createDefaultFilters, + createDefaultTableState, rangeLabel, readStateFromURL, toggleMultiValue, @@ -63,6 +64,7 @@ export function SocialAppIdentity({ app, appCode, appName, className = "", size export function SocialBiApp() { const initial = useMemo(() => readStateFromURL(), []); const [filters, setFilters] = useState(initial.filters); + const [tableState, setTableState] = useState(() => initial.table || createDefaultTableState()); const [viewKey, setViewKey] = useState(() => (VIEWS.some((view) => view.key === initial.view) ? initial.view : "overview")); const data = useSocialBiData(filters, { includeFunnel: viewKey === "funnel", @@ -70,8 +72,8 @@ export function SocialBiApp() { }); useEffect(() => { - writeStateToURL(filters, viewKey); - }, [filters, viewKey]); + writeStateToURL(filters, viewKey, tableState); + }, [filters, tableState, viewKey]); const updateFilter = useCallback((key, value) => { setFilters((current) => ({ ...current, [key]: value })); @@ -81,9 +83,19 @@ export function SocialBiApp() { setFilters(createDefaultFilters()); }, []); + const updateTableState = useCallback((patch) => { + setTableState((current) => ({ ...current, ...patch })); + }, []); + + const openRequirementSection = useCallback((requirementSection) => { + // 下钻必须同时设置明细模式和业务分组,否则 DataTableView 会按默认值落回“运营宽表 / 新用户”。 + setTableState({ mode: "requirements", requirementSection }); + setViewKey("table"); + }, []); + const contextValue = useMemo( - () => ({ ...data, filters, resetFilters, setViewKey, updateFilter, viewKey }), - [data, filters, resetFilters, updateFilter, viewKey] + () => ({ ...data, filters, openRequirementSection, resetFilters, setViewKey, tableState, updateFilter, updateTableState, viewKey }), + [data, filters, openRequirementSection, resetFilters, tableState, updateFilter, updateTableState, viewKey] ); const ActiveView = (VIEWS.find((view) => view.key === viewKey) || VIEWS[0]).component; diff --git a/databi/src/social/state.js b/databi/src/social/state.js index efdc835..b0c8b9b 100644 --- a/databi/src/social/state.js +++ b/databi/src/social/state.js @@ -1,10 +1,20 @@ -// 社交 BI v2 的全局筛选状态:日期预设/自定义区间、App 多选、区域多选、运营人员、趋势粒度, -// 并同步到 URL query,保证筛选状态可以直接作为链接分享。 +// 社交 BI v2 的全局筛选与可下钻视图状态:日期/App/区域/运营人员/趋势粒度,以及数据明细模式, +// 统一同步到 URL query,保证筛选结果和“数据需求”目标分组都可以直接作为链接分享。 import { lastDaysRange, thisMonthRange, todayRange } from "../utils/time.js"; export const ALL = "all"; +const TABLE_MODES = new Set(["wide", "requirements"]); +const REQUIREMENT_SECTIONS = new Set(["new_users", "revenue", "retention", "active", "gift", "game"]); + +export function createDefaultTableState() { + return { + mode: "wide", + requirementSection: "new_users" + }; +} + export const DATE_PRESETS = [ { key: "today", label: "今日" }, { key: "yesterday", label: "昨日" }, @@ -117,11 +127,12 @@ export function bucketDay(statDay, granularity) { return statDay; } -const URL_KEYS = ["preset", "start", "end", "apps", "regions", "operator", "view", "granularity"]; +const URL_KEYS = ["preset", "start", "end", "apps", "regions", "operator", "view", "granularity", "mode", "section"]; export function readStateFromURL(location = window.location) { const params = new URLSearchParams(location.search); const filters = createDefaultFilters(); + const table = createDefaultTableState(); let view = ""; if (params.get("preset") && DATE_PRESETS.some((item) => item.key === params.get("preset"))) { filters.preset = params.get("preset"); @@ -143,16 +154,23 @@ export function readStateFromURL(location = window.location) { if (params.get("view")) { view = params.get("view"); } + const requestedTableMode = params.get("mode"); + if (view === "table" && TABLE_MODES.has(requestedTableMode)) { + table.mode = requestedTableMode; + if (requestedTableMode === "requirements" && REQUIREMENT_SECTIONS.has(params.get("section"))) { + table.requirementSection = params.get("section"); + } + } if (!filters.apps.length) { filters.apps = [ALL]; } if (!filters.regions.length) { filters.regions = [ALL]; } - return { filters, view }; + return { filters, table, view }; } -export function writeStateToURL(filters, view, history = window.history, location = window.location) { +export function writeStateToURL(filters, view, table, history = window.history, location = window.location) { const params = new URLSearchParams(location.search); URL_KEYS.forEach((key) => params.delete(key)); if (filters.preset !== "yesterday") { @@ -181,6 +199,13 @@ export function writeStateToURL(filters, view, history = window.history, locatio if (view && view !== "overview") { params.set("view", view); } + if (view === "table" && table?.mode === "requirements") { + // 目标分组与数据需求模式一起入 URL,保证概览下钻和复制后的链接都能落到同一个游戏数据视图。 + params.set("mode", "requirements"); + if (REQUIREMENT_SECTIONS.has(table.requirementSection) && table.requirementSection !== "new_users") { + params.set("section", table.requirementSection); + } + } const query = params.toString(); history.replaceState(null, "", `${location.pathname}${query ? `?${query}` : ""}`); } diff --git a/databi/src/social/views/DataTableView.jsx b/databi/src/social/views/DataTableView.jsx index f574b16..cf9004d 100644 --- a/databi/src/social/views/DataTableView.jsx +++ b/databi/src/social/views/DataTableView.jsx @@ -477,16 +477,18 @@ export function DataTableView() { range, refreshToken, requirements, - requirementsError + requirementsError, + tableState, + updateTableState } = useSocialBi(); - const [mode, setMode] = useState("wide"); const [dimKey, setDimKey] = useState("appDaily"); const [closedGroups, setClosedGroups] = useState(readClosedGroups); const [sort, setSort] = useState(null); - const [requirementSection, setRequirementSection] = useState(REQUIREMENT_SECTIONS[0].key); const [requirementRole, setRequirementRole] = useState("all"); const [requirementPayer, setRequirementPayer] = useState("all"); const [requirementNewUser, setRequirementNewUser] = useState("all"); + const mode = tableState?.mode || TABLE_MODES[0].key; + const requirementSection = tableState?.requirementSection || REQUIREMENT_SECTIONS[0].key; const dim = DIMENSIONS.find((item) => item.key === dimKey) || DIMENSIONS[0]; const dimColumns = dim.dims.map((name) => DIM_COLUMNS[name]); @@ -575,7 +577,7 @@ export function DataTableView() { const handleRequirementSectionChange = (key) => { const nextSection = requirementSectionConfig(key); - setRequirementSection(nextSection.key); + updateTableState({ requirementSection: nextSection.key }); if (!nextSection.roleFilter) { setRequirementRole("all"); } @@ -889,7 +891,7 @@ export function DataTableView() { aria-checked={mode === item.key} className={mode === item.key ? "is-active" : ""} key={item.key} - onClick={() => setMode(item.key)} + onClick={() => updateTableState({ mode: item.key })} role="radio" type="button" > diff --git a/databi/src/social/views/OverviewView.jsx b/databi/src/social/views/OverviewView.jsx index 68d76db..7909faa 100644 --- a/databi/src/social/views/OverviewView.jsx +++ b/databi/src/social/views/OverviewView.jsx @@ -174,7 +174,7 @@ function OverviewSkeleton() { } export function OverviewView() { - const { appCodes, derived, filters, isLoading, setViewKey, updateFilter } = useSocialBi(); + const { appCodes, derived, filters, isLoading, openRequirementSection, setViewKey, updateFilter } = useSocialBi(); const [trendMetric, setTrendMetric] = useState("recharge_usd_minor"); const [compareMetric, setCompareMetric] = useState("recharge_usd_minor"); @@ -332,20 +332,26 @@ export function OverviewView() { {trendLabel}趋势 按 App 堆叠
-
+
{TREND_METRICS.map((item) => ( ))} +
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..e91954c 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,15 +300,16 @@ 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); + expect(within(dialog).getByLabelText("新手阶段第 1 档概率 %").readOnly).toBe(true); fireEvent.change(within(dialog).getByLabelText(/目标 RTP \(%\)/), { target: { value: "88" } }); fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" })); @@ -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,18 +335,18 @@ 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(); // 奖档编辑同样必须清掉上一次提交错误,避免用户修正后仍看到已经过期的阻断原因。 - fireEvent.change(within(dialog).getAllByLabelText("倍率")[0], { target: { value: "0.1" } }); + fireEvent.change(within(dialog).getByLabelText("新手阶段第 1 档倍率"), { target: { value: "0.1" } }); expect(within(dialog).queryByRole("alert")).toBeNull(); }); @@ -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..e206959 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 = {}) { @@ -118,16 +149,40 @@ 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); + const normalizedPoolBalances = perApp + .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); const publishedConfigs = luckyGifts.filter((config) => !config.is_default); + const currentConfigByPool = new Map(); + luckyGifts.forEach((config) => { + const familyKey = luckyGiftPoolFamilyKey(config); + const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion); + const current = currentConfigByPool.get(familyKey); + // 正常接口只返回每个奖池的最新版本;这里仍按最大 rule_version 收敛,避免兼容接口返回多版本时 + // 由数组顺序把旧策略误判为当前。版本相同则保留首条,确保结果不随返回顺序抖动。 + if (!current || ruleVersion > current.ruleVersion) { + currentConfigByPool.set(familyKey, { + ruleVersion, + strategyVersion: normalizeStrategyVersion(config.strategy_version ?? config.strategyVersion), + }); + } + }); + // 账本接口会同时返回同一 app+pool 的 V2/V3 独立水位;最新规则配置才决定哪口是当前奖池。 + // 没有配置可关联的孤立账本继续按当前展示,避免前端误把仍需运营处理的资金静默藏进历史筛选。 + const poolBalances = normalizedPoolBalances.map((pool) => { + const currentStrategy = currentConfigByPool.get(luckyGiftPoolFamilyKey(pool))?.strategyVersion; + return { + ...pool, + is_historical: Boolean(currentStrategy && currentStrategy !== pool.strategy_version), + }; + }); + const currentPoolBalances = poolBalances.filter((pool) => !pool.is_historical); - // KPI 一律跨应用聚合。旧版只取第一个应用的汇总,导致 lalu 有大量抽奖时首屏"抽奖次数"仍显示 0。 + // KPI 一律跨应用聚合;奖池金额只汇总当前策略,避免历史 V2 与当前 V3 的独立账本重复计入运营水位。 const summary = { activeApps: apps.length, configuredPools: publishedConfigs.length, @@ -135,9 +190,9 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) { failedDraws: sumBy(appSummaries, "failed_draws"), grantedDraws: sumBy(appSummaries, "granted_draws"), pendingDraws: sumBy(appSummaries, "pending_draws"), - poolAvailableTotal: sumBy(poolBalances, "available_balance", "availableBalance"), - poolBalanceTotal: sumBy(poolBalances, "balance", "Balance"), - poolReserveTotal: sumBy(poolBalances, "reserve_floor", "reserveFloor"), + poolAvailableTotal: sumBy(currentPoolBalances, "available_balance", "availableBalance"), + poolBalanceTotal: sumBy(currentPoolBalances, "balance", "Balance"), + poolReserveTotal: sumBy(currentPoolBalances, "reserve_floor", "reserveFloor"), totalDraws: sumBy(appSummaries, "total_draws"), totalRewardCoins: sumBy(appSummaries, "total_reward_coins"), totalSpentCoins: sumBy(appSummaries, "total_spent_coins"), @@ -146,6 +201,45 @@ 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 luckyGiftPoolFamilyKey(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; + return `${appCode}:${poolID}`; +} + function normalizeDrawSummary(summary = {}, appCode = "") { return { actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm), @@ -192,12 +286,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 +343,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, ), @@ -230,11 +353,21 @@ function luckyGiftConfigPayload(config = {}) { jackpot_spend_threshold_coins: numberValue( config.jackpot_spend_threshold_coins ?? config.jackpotSpendThresholdCoins, ), - jackpot_user_72h_rtp_max_percent: numberValue( - config.jackpot_user_72h_rtp_max_percent ?? config.jackpotUser72hRTPMaxPercent, + jackpot_user_48h_rtp_max_percent: numberValue( + config.jackpot_user_48h_rtp_max_percent ?? + config.jackpotUser48hRTPMaxPercent ?? + config.jackpotUser48hRtpMaxPercent ?? + config.jackpot_user_72h_rtp_max_percent ?? + config.jackpotUser72hRTPMaxPercent ?? + config.jackpotUser72hRtpMaxPercent, ), - jackpot_user_day_rtp_max_percent: numberValue( - config.jackpot_user_day_rtp_max_percent ?? config.jackpotUserDayRTPMaxPercent, + jackpot_user_round_rtp_max_percent: numberValue( + config.jackpot_user_round_rtp_max_percent ?? + config.jackpotUserRoundRTPMaxPercent ?? + config.jackpotUserRoundRtpMaxPercent ?? + config.jackpot_user_day_rtp_max_percent ?? + config.jackpotUserDayRTPMaxPercent ?? + config.jackpotUserDayRtpMaxPercent, ), loss_streak_guarantee: numberValue(config.loss_streak_guarantee ?? config.lossStreakGuarantee), low_water_nonzero_factor_percent: numberValue( @@ -266,32 +399,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..310c404 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,12 +243,12 @@ 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, - jackpot_user_72h_rtp_max_percent: 96, - jackpot_user_day_rtp_max_percent: 96, + jackpot_user_48h_rtp_max_percent: 96, + jackpot_user_round_rtp_max_percent: 96, loss_streak_guarantee: 5, low_water_nonzero_factor_percent: 70, low_watermark_coins: 10_000_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..590c4f3 100644 --- a/ops-center/src/components/LuckyGiftConfigDialog.jsx +++ b/ops-center/src/components/LuckyGiftConfigDialog.jsx @@ -1,16 +1,9 @@ -import AddOutlined from "@mui/icons-material/AddOutlined"; -import Checkbox from "@mui/material/Checkbox"; 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 FormControlLabel from "@mui/material/FormControlLabel"; -import MenuItem from "@mui/material/MenuItem"; -import TextField from "@mui/material/TextField"; -import { useMemo, useState } from "react"; -import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/shared/ui/Button.jsx"; -import { formatDecimal } from "@/shared/utils/rtpProbability.js"; import { applyDynamicStrategyDefaults, configToForm, @@ -21,12 +14,14 @@ import { validateLuckyGiftForm, } from "../configForm.js"; import { formatNumber, formatPercent } from "../format.js"; -import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; +import { LuckyGiftConfigSections } from "./LuckyGiftConfigSections.jsx"; +import { LuckyGiftStageDesigner } from "./LuckyGiftStageDesigner.jsx"; export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) { const [form, setForm] = useState(() => configToForm(config)); const [activeStage, setActiveStage] = useState("novice"); const [validationErrors, setValidationErrors] = useState([]); + const validationRef = useRef(null); const stageSummaries = useMemo(() => form.stages.map((stage) => stageSummary(stage, form)), [form]); const activeStageKey = form.stages.some((stage) => stage.stage === activeStage) @@ -34,6 +29,13 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", : form.stages[0]?.stage; const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey); const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey); + const normalStageConfig = form.stages.find((stage) => stage.stage === "normal"); + + useEffect(() => { + if (!validationErrors.length) return; + // 保存按钮位于固定底栏;失败后把焦点送回错误摘要,避免键盘或读屏用户停留在无变化的按钮上。 + validationRef.current?.focus(); + }, [validationErrors]); const updateField = (field, value) => { setValidationErrors([]); @@ -87,7 +89,8 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", ...stage.tiers, { enabled: true, - // 10 倍以上属于高倍档,默认只在奖池高水位时开放,防止新档位直接击穿保底线。 + // 10 倍以上先写入服务端要求的高倍率安全标记;实际开奖仍以实时余额和返奖上限 + // 判断是否付得起,前端默认值不能被解释成“高余额时一定会发”。 highWaterOnly: nextStageMultiplier(stage.tiers) >= 10, multiplier: String(nextStageMultiplier(stage.tiers)), probabilityPercent: "0", @@ -129,31 +132,51 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本"; return ( - +
- + {title} {form.app_code || "-"} / {form.pool_id || "default"} /{" "} {config.is_default || isCreate ? "默认草稿" : `当前版本 v${config.rule_version ?? "-"}`} - -
- - - - - - - -
- +
+ + + + + + + +
+ {validationErrors.length ? ( -
+
配置不能发布
    {validationErrors.map((message) => ( @@ -164,268 +187,28 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", ) : null}
    - - -
    -
    - {stageSummaries.map((summary) => ( - - ))} -
    - {activeStageConfig ? ( -
    -
    -
    -

    {activeSummary?.stageLabel}

    - -
    - -
    - {form.strategy_version === "dynamic_v3" ? ( -
    - - updateStageField( - activeStageConfig.stage, - "min_recharge_7d_coins", - event.target.value, - ) - } - /> - - updateStageField( - activeStageConfig.stage, - "min_recharge_30d_coins", - event.target.value, - ) - } - /> -
    - ) : null} -
    - - {activeStageConfig.tiers.map((tier, index) => ( -
    - - updateStageTier(activeStageConfig.stage, index, { - // 10 倍及以上强制只走高水位,输入时同步勾选并锁死,防止高倍档在低水位放开。 - highWaterOnly: - Number(event.target.value) >= 10 || tier.highWaterOnly, - multiplier: event.target.value, - }) - } - /> - {/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */} - - = 10} - size="small" - slotProps={{ input: { "aria-label": "高水位" } }} - onChange={(event) => - updateStageTier(activeStageConfig.stage, index, { - highWaterOnly: event.target.checked, - }) - } - /> - - updateStageTier(activeStageConfig.stage, index, { - enabled: event.target.checked, - }) - } - /> - -
    - ))} -
    -
    - ) : null} -
    + +
    - + @@ -438,129 +221,11 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", ); } -function DynamicStrategySections({ form, onChange }) { - return ( - <> -
    -

    动态水位

    -
    - - - - - - - - -
    -
    - -
    -

    大奖控制

    -
    - onChange("jackpot_multipliers", event.target.value)} - /> - - - - - -
    -
    - -
    -

    六维返奖上限

    -
    - - - - - - -
    -
    - - ); -} - function ConfigMetric({ label, value }) { return ( -
    - {label} - {value} -
    - ); -} - -function NumberField({ form, helperText, label, name, onChange, step = "1" }) { - return ( - onChange(name, event.target.value)} - /> +
    +
    {label}
    +
    {value}
    +
    ); } diff --git a/ops-center/src/components/LuckyGiftConfigDialog.test.jsx b/ops-center/src/components/LuckyGiftConfigDialog.test.jsx new file mode 100644 index 0000000..2b54a32 --- /dev/null +++ b/ops-center/src/components/LuckyGiftConfigDialog.test.jsx @@ -0,0 +1,341 @@ +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import { LuckyGiftConfigDialog } from "./LuckyGiftConfigDialog.jsx"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +test("keeps every dynamic_v3 control reachable while locking rule-time pool injection", () => { + renderDialog(dynamicConfig()); + + const dialog = screen.getByRole("dialog"); + expect(within(dialog).getByRole("navigation", { name: "全局参数分区" })).toBeInTheDocument(); + ["规则", "资金", "水位", "大奖", "限额"].forEach((label) => { + expect(within(dialog).getByRole("button", { name: label })).toBeInTheDocument(); + }); + + expect(within(dialog).getByRole("switch", { name: "发布状态" })).toBeChecked(); + expect(within(dialog).getByLabelText(/初始奖池金币/)).toHaveValue(0); + expect(within(dialog).getByLabelText(/初始奖池金币/)).toBeDisabled(); + [ + "低水位金币", + "高水位金币", + "充值加权窗口 (ms)", + "大奖倍率", + "全局大奖 RTP 上限 (%)", + "用户日累计消费触发门槛(金币)", + "单次返奖上限", + "主播每日上限", + ].forEach((label) => + expect(within(dialog).getByLabelText((accessibleName) => accessibleName.startsWith(label))).toBeInTheDocument(), + ); + + expect(within(dialog).getAllByRole("tab")).toHaveLength(3); + expect(within(dialog).getByRole("columnheader", { name: "倍率" })).toBeInTheDocument(); + expect(within(dialog).getByLabelText("新手阶段第 1 档倍率")).toBeInTheDocument(); + expect(within(dialog).getByLabelText("新手阶段第 2 档概率 %")).toHaveAttribute("readonly"); + expect(within(dialog).getByRole("button", { name: "删除新手阶段第 1 档" })).toBeInTheDocument(); + expect(within(dialog).getByLabelText("新手阶段派生充值范围")).toHaveTextContent("7 日充值 < 0 或 30 日充值 < 1"); + expect( + within(dialog).getByText("任一未达到即为新手;7 日与 30 日均达到门槛(含等于)后进入普通。"), + ).toBeInTheDocument(); + + fireEvent.click(within(dialog).getByRole("tab", { name: /普通/ })); + expect(within(dialog).getByLabelText("7 日最低充值(金币)")).toHaveValue(0); + expect(within(dialog).getByLabelText("30 日最低充值(金币)")).toHaveValue(1); +}); + +test("section navigation handles reduced motion and falls back after switching from dynamic_v3 to fixed_v2", async () => { + vi.stubGlobal( + "matchMedia", + vi.fn().mockReturnValue({ + addEventListener: vi.fn(), + matches: true, + media: "(prefers-reduced-motion: reduce)", + removeEventListener: vi.fn(), + }), + ); + renderDialog(dynamicConfig()); + + const dialog = screen.getByRole("dialog"); + const sectionList = dialog.querySelector(".ops-config-section-list"); + const identitySection = dialog.querySelector('[data-config-section="identity"]'); + const waterSection = dialog.querySelector('[data-config-section="water"]'); + const scrollTo = vi.fn(); + Object.defineProperty(sectionList, "clientHeight", { configurable: true, value: 480 }); + Object.defineProperty(sectionList, "scrollHeight", { configurable: true, value: 1_400 }); + Object.defineProperty(sectionList, "scrollTo", { configurable: true, value: scrollTo }); + Object.defineProperty(sectionList, "getBoundingClientRect", { + configurable: true, + value: () => ({ top: 200 }), + }); + Object.defineProperty(waterSection, "getBoundingClientRect", { + configurable: true, + value: () => ({ top: 520 }), + }); + // 尺寸变化后监听器也必须从响应式外层重新绑定到桌面内层 scroller。 + fireEvent(window, new Event("resize")); + + fireEvent.click(within(dialog).getByRole("button", { name: "水位" })); + expect(scrollTo).toHaveBeenLastCalledWith({ behavior: "auto", top: 312 }); + expect(within(dialog).getByRole("button", { name: "水位" })).toHaveAttribute("aria-current", "true"); + expect(within(dialog).getByRole("heading", { name: "动态水位" })).toHaveFocus(); + + sectionList.scrollTop = 137; + Object.defineProperty(identitySection, "getBoundingClientRect", { + configurable: true, + value: () => ({ top: 71 }), + }); + fireEvent.click(within(dialog).getByRole("button", { name: "规则" })); + expect(scrollTo).toHaveBeenLastCalledWith({ behavior: "auto", top: 0 }); + expect(within(dialog).getByRole("heading", { name: "规则身份" })).toHaveFocus(); + + fireEvent.click(within(dialog).getByRole("button", { name: "水位" })); + + fireEvent.mouseDown(within(dialog).getByLabelText("策略版本")); + fireEvent.click(screen.getByRole("option", { name: "fixed_v2(历史兼容)" })); + + await waitFor(() => { + expect(within(dialog).queryByRole("button", { name: "水位" })).not.toBeInTheDocument(); + expect(within(dialog).getByRole("button", { name: "规则" })).toHaveAttribute("aria-current", "true"); + }); + expect(within(dialog).queryByLabelText(/初始奖池金币/)).not.toBeInTheDocument(); + expect(within(dialog).getByRole("switch", { name: "发布状态" })).toBeInTheDocument(); +}); + +test.each([390, 1024])( + "uses the dialog content scroller for %ipx responsive navigation and active-section sync", + (viewportWidth) => { + renderDialog(dynamicConfig()); + + const dialog = screen.getByRole("dialog"); + const sectionNav = dialog.querySelector(".ops-config-section-nav"); + const sectionList = dialog.querySelector(".ops-config-section-list"); + const outerScroller = sectionList.closest(".ops-config-dialog-content"); + const innerScrollTo = vi.fn(); + const outerScrollTo = vi.fn(); + // 不向组件暴露 viewport 宽度;测试通过导航的真实 flex 布局区分手机横栏和平板左栏。 + sectionNav.style.flexDirection = viewportWidth === 390 ? "row" : "column"; + Object.defineProperty(sectionNav, "getBoundingClientRect", { + configurable: true, + value: () => ({ height: 50 }), + }); + Object.defineProperty(sectionList, "clientHeight", { configurable: true, value: 1_200 }); + Object.defineProperty(sectionList, "scrollHeight", { configurable: true, value: 1_200 }); + Object.defineProperty(sectionList, "scrollTo", { configurable: true, value: innerScrollTo }); + Object.defineProperty(outerScroller, "scrollTop", { configurable: true, value: 1_837, writable: true }); + Object.defineProperty(outerScroller, "scrollTo", { configurable: true, value: outerScrollTo }); + Object.defineProperty(outerScroller, "getBoundingClientRect", { + configurable: true, + value: () => ({ top: 120 }), + }); + + const sectionTops = { + funds: -800, + identity: -1_000, + jackpot: 180, + limits: 800, + water: -579, + }; + for (const [sectionKey, top] of Object.entries(sectionTops)) { + Object.defineProperty( + dialog.querySelector(`[data-config-section="${sectionKey}"]`), + "getBoundingClientRect", + { + configurable: true, + value: () => ({ top }), + }, + ); + } + + fireEvent.scroll(outerScroller); + const expectedActiveSection = viewportWidth === 390 ? "大奖" : "水位"; + expect(within(dialog).getByRole("button", { name: expectedActiveSection })).toHaveAttribute( + "aria-current", + "true", + ); + + fireEvent.click(within(dialog).getByRole("button", { name: "水位" })); + expect(outerScrollTo).toHaveBeenCalledWith({ + behavior: "smooth", + // 横向 sticky nav 占 50px,目标标题落在其底边下方 8px;左栏布局不需要额外偏移。 + top: viewportWidth === 390 ? 1_080 : 1_130, + }); + expect(innerScrollTo).not.toHaveBeenCalled(); + }, +); + +test("stage tabs expose complete aria relationships and support roving keyboard navigation", () => { + renderDialog(dynamicConfig()); + + const dialog = screen.getByRole("dialog"); + const noviceTab = within(dialog).getByRole("tab", { name: /新手/ }); + const normalTab = within(dialog).getByRole("tab", { name: /普通/ }); + const advancedTab = within(dialog).getByRole("tab", { name: /高阶/ }); + + expect(noviceTab).toHaveAttribute("aria-controls", "ops-stage-panel-novice"); + expect(noviceTab).toHaveAttribute("tabindex", "0"); + expect(normalTab).toHaveAttribute("tabindex", "-1"); + expect(within(dialog).getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "ops-stage-tab-novice"); + + fireEvent.keyDown(noviceTab, { key: "ArrowRight" }); + expect(normalTab).toHaveFocus(); + expect(normalTab).toHaveAttribute("aria-selected", "true"); + expect(within(dialog).getByRole("tabpanel")).toHaveAttribute("id", "ops-stage-panel-normal"); + expect(within(dialog).getByLabelText("正常阶段第 1 档倍率")).toBeInTheDocument(); + + fireEvent.keyDown(normalTab, { key: "End" }); + expect(advancedTab).toHaveFocus(); + expect(advancedTab).toHaveAttribute("aria-selected", "true"); + fireEvent.keyDown(advancedTab, { key: "Home" }); + expect(noviceTab).toHaveFocus(); + expect(noviceTab).toHaveAttribute("aria-selected", "true"); +}); + +test("fixed_v2 hides only dynamic controls and preserves immutable snapshot fields on submit", () => { + const onSubmit = vi.fn(); + renderDialog(fixedConfig(), { onSubmit }); + + const dialog = screen.getByRole("dialog"); + expect(within(dialog).getByRole("switch", { name: "发布状态" })).toBeChecked(); + expect(within(dialog).queryByRole("button", { name: "水位" })).not.toBeInTheDocument(); + expect(within(dialog).queryByText(/资金合计/)).not.toBeInTheDocument(); + expect(within(dialog).queryByLabelText(/初始奖池金币/)).not.toBeInTheDocument(); + expect(within(dialog).getAllByRole("tab")).toHaveLength(3); + + fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" })); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + effective_from_ms: 987_654_321, + enabled: true, + strategy_version: "fixed_v2", + stages: expect.arrayContaining([ + expect.objectContaining({ + stage: "novice", + tiers: expect.arrayContaining([ + expect.objectContaining({ + enabled: true, + high_water_only: false, + tier_id: "novice_none", + }), + ]), + }), + ]), + }), + ); +}); + +test("focuses the validation summary when the fixed action bar submission is rejected", async () => { + const onSubmit = vi.fn(); + renderDialog(dynamicConfig({ low_watermark_coins: 0 }), { onSubmit }); + + const dialog = screen.getByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" })); + + const alert = await within(dialog).findByRole("alert"); + expect(alert).toHaveTextContent("高水位必须大于正数低水位"); + expect(alert).toHaveFocus(); + expect(onSubmit).not.toHaveBeenCalled(); +}); + +function renderDialog(config, overrides = {}) { + return render( + , + ); +} + +function dynamicConfig(overrides = {}) { + return { + anchor_daily_payout_cap: 6_000_000, + anchor_rate_percent: 1, + app_code: "lalu", + control_band_percent: 3, + device_daily_payout_cap: 4_000_000, + effective_from_ms: 123_456_789, + enabled: true, + gift_price_reference: 100, + high_water_nonzero_factor_percent: 130, + high_watermark_coins: 20_000_000, + initial_pool_coins: 0, + jackpot_global_rtp_max_percent: 98, + jackpot_multipliers: [200, 500, 1000], + jackpot_spend_threshold_coins: 50_000, + jackpot_user_72h_rtp_max_percent: 96, + jackpot_user_day_rtp_max_percent: 96, + loss_streak_guarantee: 5, + low_water_nonzero_factor_percent: 70, + low_watermark_coins: 10_000_000, + max_jackpot_hits_per_user_day: 5, + max_single_payout: 1_000_000, + normal_max_equivalent_draws: 20_000, + novice_max_equivalent_draws: 2_000, + pool_id: "lucky", + pool_rate_percent: 98, + profit_rate_percent: 1, + recharge_boost_factor_percent: 110, + recharge_boost_window_ms: 300_000, + room_hourly_payout_cap: 5_000_000, + rule_version: 3, + settlement_window_wager: 1_000_000, + stages: stagesFor(98), + strategy_version: "dynamic_v3", + target_rtp_percent: 98, + user_daily_payout_cap: 3_000_000, + user_hourly_payout_cap: 2_000_000, + ...overrides, + }; +} + +function fixedConfig() { + return { + app_code: "lalu", + control_band_percent: 1, + effective_from_ms: 987_654_321, + enabled: true, + gift_price_reference: 100, + pool_id: "legacy", + pool_rate_percent: 95, + rule_version: 8, + settlement_window_wager: 1_000_000, + stages: stagesFor(95), + strategy_version: "fixed_v2", + target_rtp_percent: 95, + }; +} + +function stagesFor(targetRTP) { + return ["novice", "normal", "advanced"].map((stage, index) => ({ + min_recharge_30d_coins: index === 0 ? 0 : 1, + min_recharge_7d_coins: index === 2 ? 1 : 0, + stage, + tiers: [ + { + enabled: true, + high_water_only: false, + multiplier: 0, + probability_percent: 100 - targetRTP, + tier_id: `${stage}_none`, + }, + { + enabled: true, + high_water_only: false, + multiplier: 1, + probability_percent: targetRTP, + tier_id: `${stage}_1x`, + }, + ], + })); +} diff --git a/ops-center/src/components/LuckyGiftConfigSections.jsx b/ops-center/src/components/LuckyGiftConfigSections.jsx new file mode 100644 index 0000000..2d8e00b --- /dev/null +++ b/ops-center/src/components/LuckyGiftConfigSections.jsx @@ -0,0 +1,530 @@ +import FormControlLabel from "@mui/material/FormControlLabel"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; +import { LuckyGiftFieldLabel } from "./LuckyGiftFieldHelp.jsx"; + +const BASE_SECTIONS = [ + ["identity", "规则"], + ["funds", "资金"], +]; + +const DYNAMIC_SECTIONS = [ + ["water", "水位"], + ["jackpot", "大奖"], + ["limits", "限额"], +]; + +export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }) { + const scrollRef = useRef(null); + const sections = useMemo( + () => (form.strategy_version === "dynamic_v3" ? [...BASE_SECTIONS, ...DYNAMIC_SECTIONS] : BASE_SECTIONS), + [form.strategy_version], + ); + const [activeSection, setActiveSection] = useState(sections[0][0]); + + useEffect(() => { + if (sections.some(([sectionKey]) => sectionKey === activeSection)) return; + // dynamic_v3 的专属分区会在切回 fixed_v2 后卸载;同步回首个有效分区,避免导航继续指向不存在的面板。 + setActiveSection(sections[0][0]); + if (scrollRef.current) resolveConfigScroller(scrollRef.current).scrollTop = 0; + }, [activeSection, sections]); + + useEffect(() => { + const sectionList = scrollRef.current; + if (!sectionList) return undefined; + const view = sectionList.ownerDocument.defaultView; + let boundScroller; + + const syncActiveSection = () => { + if (!boundScroller) return; + const readingLine = + boundScroller.getBoundingClientRect().top + configNavInset(sectionList, boundScroller) + 32; + let nextSection = sections[0][0]; + for (const [sectionKey] of sections) { + const section = sectionList.querySelector(`[data-config-section="${sectionKey}"]`); + if (section && section.getBoundingClientRect().top <= readingLine) nextSection = sectionKey; + } + setActiveSection((current) => (current === nextSection ? current : nextSection)); + }; + + const bindActualScroller = () => { + const nextScroller = resolveConfigScroller(sectionList); + if (nextScroller === boundScroller) return; + boundScroller?.removeEventListener("scroll", syncActiveSection); + boundScroller = nextScroller; + // 桌面由参数面板独立滚动,平板/手机由 DialogContent 单滚动;只监听真实 scroller, + // 避免小屏内层 overflow:visible 时导航状态更新、内容位置却完全没有变化。 + boundScroller.addEventListener("scroll", syncActiveSection, { passive: true }); + }; + + bindActualScroller(); + view?.addEventListener("resize", bindActualScroller); + const ResizeObserverClass = view?.ResizeObserver; + const resizeObserver = ResizeObserverClass ? new ResizeObserverClass(bindActualScroller) : null; + resizeObserver?.observe(sectionList); + + return () => { + boundScroller?.removeEventListener("scroll", syncActiveSection); + view?.removeEventListener("resize", bindActualScroller); + resizeObserver?.disconnect(); + }; + }, [sections]); + + const jumpToSection = (sectionKey) => { + const sectionList = scrollRef.current; + const section = sectionList?.querySelector(`[data-config-section="${sectionKey}"]`); + if (!sectionList || !section) return; + const scroller = resolveConfigScroller(sectionList); + setActiveSection(sectionKey); + // offsetParent 不是可靠的滚动参考系;无论当前是桌面内层还是响应式外层滚动,都用视口坐标 + // 换算同一个 scroller 的目标位点;窄屏横向导航是 sticky 覆盖层,目标还必须落到导航下方。 + const navInset = configNavInset(sectionList, scroller); + const top = Math.max( + scroller.scrollTop + + section.getBoundingClientRect().top - + scroller.getBoundingClientRect().top - + navInset - + 8, + 0, + ); + const reduceMotion = scroller.ownerDocument.defaultView?.matchMedia?.( + "(prefers-reduced-motion: reduce)", + ).matches; + if (typeof scroller.scrollTo === "function") { + scroller.scrollTo({ behavior: reduceMotion ? "auto" : "smooth", top }); + } else { + // jsdom 和部分旧 WebView 没有 Element.scrollTo,直接写 scrollTop 仍能保持导航可用。 + scroller.scrollTop = top; + } + // 导航按钮只负责选择分区;焦点落到标题后,读屏会立即获得当前编辑上下文。 + section.querySelector("h3")?.focus({ preventScroll: true }); + }; + + const fundTotal = + Number(form.pool_rate_percent || 0) + + Number(form.profit_rate_percent || 0) + + Number(form.anchor_rate_percent || 0); + + return ( + + ); +} + +function SectionHeading({ detail, id, ok = false, title }) { + return ( +
    +

    + {title} +

    + {detail ? {detail} : null} +
    + ); +} + +function JackpotFlowSummary() { + return ( +
    +
    + 1. 大盘先通过 + 本轮大盘 RTP 不高于全局触发线,才继续检查用户;等于触发线也可通过。 +
    +
    + 2. 用户两项都通过 + 用户本轮 RTP 和近 48 小时 RTP 都必须严格低于各自触发线,少一项都不能出大奖。 +
    +
    + 3. 下次送礼尝试大奖 + + 用户下次送礼时,先剔除奖池或限额付不起的倍率,再随机选择;没有可支付倍率就不出大奖,本轮资格也不会顺延或重复使用。 + +
    +
    + ); +} + +function NumberField({ disabled = false, form, helperText, label, name, onChange, required = true, step = "1" }) { + return ( + } + required={required} + size="small" + slotProps={{ htmlInput: { inputMode: "decimal", min: 0, step } }} + type="number" + value={form[name]} + onChange={(event) => onChange(name, event.target.value)} + /> + ); +} + +function formatCompactNumber(value) { + return Number.isFinite(value) ? Number(value.toFixed(4)).toString() : "-"; +} + +function resolveConfigScroller(sectionList) { + // 桌面参数列限定高度后由自己滚动;响应式规则把它改为 overflow:visible,此时 scrollHeight 与 + // clientHeight 相等,实际滚动责任上移到 DialogContent。判定能力而非 viewport 宽度可兼容缩放和嵌入场景。 + if (sectionList.scrollHeight > sectionList.clientHeight) return sectionList; + return sectionList.closest(".ops-config-dialog-content") || sectionList; +} + +function configNavInset(sectionList, scroller) { + if (scroller === sectionList) return 0; + const nav = sectionList.previousElementSibling; + if (!nav?.classList.contains("ops-config-section-nav")) return 0; + // 响应式是否形成覆盖层由真实布局决定:390px 时 nav 为横向 row,1024px 时仍是左侧 column。 + // 读取 computed style 而不是猜 innerWidth,才能兼容浏览器缩放、嵌入容器和未来断点调整。 + const flexDirection = nav.ownerDocument.defaultView?.getComputedStyle(nav).flexDirection; + return flexDirection === "row" ? nav.getBoundingClientRect().height : 0; +} diff --git a/ops-center/src/components/LuckyGiftFieldHelp.jsx b/ops-center/src/components/LuckyGiftFieldHelp.jsx new file mode 100644 index 0000000..84bbddd --- /dev/null +++ b/ops-center/src/components/LuckyGiftFieldHelp.jsx @@ -0,0 +1,286 @@ +import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined"; +import Tooltip from "@mui/material/Tooltip"; + +// 字段说明集中维护,避免同一参数在不同分区出现互相矛盾的运营口径。 +// 每条都回答“控制什么、调大会怎样、发布时受什么限制”,Tooltip 只负责展示,不参与表单值或校验。 +const FIELD_HELP = { + app_code: { + summary: "决定这套幸运礼物规则属于哪个 App。不同 App 的配置、抽奖记录和奖池余额彼此隔离。", + adjust: "选择错 App 会把新版本发布到另一套业务数据中;编辑已有配置时不能在这里迁移 App。", + constraint: "发布前确认应用与实际送礼入口一致。", + }, + pool_id: { + summary: "决定礼物使用哪一组规则和哪一本奖池账。同一 App 内,送礼接口会按这个 ID 找到配置。", + adjust: "改成新 ID 会创建一组新的规则,不会搬走旧奖池里的金币,也不会改写旧抽奖记录。", + constraint: "fixed_v2 与 dynamic_v3 即使使用相同 ID,也会分别记账;ID 必须与礼物接入配置一致。", + }, + strategy_version: { + summary: + "选择开奖方式。fixed_v2 保留旧版固定规则;dynamic_v3 会根据奖池余额、充值阶段、大奖条件和返奖上限动态调整。", + adjust: "切到 dynamic_v3 会载入安全草稿并保持停用,确认全部金额口径后再发布。", + constraint: "dynamic_v3 使用独立奖池且从 0 开始,发布规则不会自动注入金币。", + }, + enabled: { + summary: "决定新版本发布后是否立即用于真实送礼。关闭时规则仍会保存,但不会触发幸运开奖。", + adjust: "首次配置或大幅调整时可先保持停用,核对资金比例和上限后再启用。", + constraint: "启用 dynamic_v3 前必须填完三项大奖 RTP 触发线和所有返奖上限。", + }, + target_rtp_percent: { + summary: "表示基础奖档的配置目标。例如填 98,代表各阶段的“倍率 × 基础概率”预计返还约为 98%。", + adjust: "修改后,页面会重新分配各档基础概率;调高通常增加基础返奖,调低则相反。", + constraint: + "只能填 10%–99%,进入奖池的比例不能低于它。水位、充值加成、大奖和上限都会改变实际结果,它不是线上返还承诺。", + }, + control_band_percent: { + summary: "允许每个用户阶段的奖档组合与平均返还目标存在多大偏差。", + adjust: "调大更容易通过发布,但实际返还可能离目标更远;调小更严格,也更难配置出可发布的倍率组合。", + constraint: "必须大于 0 且不超过 5 个百分点,不会强行补偿单次或单个用户的结果。", + }, + pool_rate_percent: { + summary: "每笔幸运礼物消费中,有多少金币进入公共奖池,后续中奖从这里支付。", + adjust: "调高会让奖池补充更快;调低会留出更多平台或收礼份额,但奖池更容易不足。", + constraint: "不能低于平均返还目标;V3 中它与平台、收礼份额合计必须等于 100%,按整数金币向下取整。", + }, + profit_rate_percent: { + summary: "V3 每笔消费中直接计为平台收入的比例,不进入奖池,也不发给收礼对象。", + adjust: "调高会增加平台份额,但必须相应减少奖池或收礼份额。", + constraint: + "不能为负数;V3 三项资金比例必须精确合计 100%,整数拆分后不能整除的余数也计入平台;V2 不使用该比例开奖。", + }, + anchor_rate_percent: { + summary: "V3 每笔消费中返给本次收礼对象的比例,通常是主播但不一定等于房主;这部分不是用户中奖奖励。", + adjust: "调高会增加收礼对象所得,并相应减少奖池或平台份额。", + constraint: + "按整数金币向下取整,且必须与钱包实际发给收礼对象的金币完全一致,否则 V3 会拒绝开奖;V2 不使用该比例开奖。", + }, + settlement_window_wager: { + summary: + "V3 按实际消费金币累计一轮大盘 RTP;某一抽跨过门槛时仍归旧轮,下一抽才进入新轮。大盘结算并重置时,也会按完全相同的起止时间结算每个用户的本轮 RTP。", + adjust: "调大后大盘和用户本轮 RTP 都会更久才结算;调小后两者都会更频繁结算。V2 会先按礼物参考金额换算成窗口抽数。", + constraint: "必须大于 0。新一轮还没有消费样本时,不会触发返还偏低大奖。", + }, + gift_price_reference: { + summary: "V2 用它把累计消费换算成“折算次数”,同时计算用户阶段和每轮窗口的抽数;它不是礼物真实售价。", + adjust: "调高后,同样消费折算成更少次数,用户升级更慢且 V2 窗口抽数更少;调低则相反。", + constraint: "必须大于 0。V3 的窗口直接看真实金币流水,用户阶段看 7 日和 30 日充值,不用它做这两项判断。", + }, + novice_max_equivalent_draws: { + summary: "fixed_v2 中,用户累计消费折算次数不超过这个值时使用新手奖档。", + adjust: "调高会让用户在新手阶段停留更久;调低会更早进入普通阶段。", + constraint: "不能小于 0,也不能高于普通阶段上限;dynamic_v3 不使用这个值分层。", + }, + normal_max_equivalent_draws: { + summary: "fixed_v2 中,用户超过新手上限后,不超过这个值时使用普通奖档;再超过才进入高阶奖档。", + adjust: "调高会推迟进入高阶阶段;调低会让用户更早进入高阶阶段。", + constraint: "不能小于新手阶段上限;dynamic_v3 不使用这个值分层。", + }, + initial_pool_coins: { + summary: "表示发布规则时是否顺便给奖池放入启动金币。dynamic_v3 已改为不允许规则自动注资。", + adjust: "该值固定为 0 且不可编辑,避免每次发新版本重复增加奖池余额。", + constraint: "需要启动资金时,请在配置列表使用“添加水位”,该操作会保留独立审计记录。", + }, + loss_streak_guarantee: { + summary: + "同一用户在当前礼物奖池连续多少次返还为 0 后,下一次普通开奖会先去掉 0 倍档。填 5 表示连续 5 次为 0 后,第 6 次普通开奖尝试正奖。", + adjust: "调小会更快触发体验保护;调大会降低触发频率并减少奖池压力。", + constraint: "必须大于 0;奖池不足或任一返奖上限不足时仍可返回 0,不会透支。", + }, + low_watermark_coins: { + summary: "本抽应进入奖池的金币先入账;入账后余额低于这个值时,系统会降低普通开奖中非 0 倍档的基础机会。", + adjust: "调高会更早开始保护奖池;调低会让正常中奖节奏保持更久。", + constraint: "必须大于 0,并且小于高余额分界线。", + }, + low_water_nonzero_factor_percent: { + summary: "奖池处于低余额时,普通开奖中非 0 倍档的基础机会保留多少。填 70 表示按原来的 70% 参与计算。", + adjust: "调低更保护奖池但中奖更少;调高更接近正常节奏但消耗更快。", + constraint: "必须大于 0% 且小于 100%,0 倍档接收剩余概率;它不会直接提高或降低两种大奖的触发机会。", + }, + high_watermark_coins: { + summary: "本抽应进入奖池的金币先入账;入账后余额高于这个值时,系统会提高普通开奖中非 0 倍档的基础机会。", + adjust: "调低会更早提高中奖机会;调高会要求奖池积累更多后才加速返奖。", + constraint: "必须严格大于低余额分界线;刚好等于该值时仍按正常状态处理。", + }, + high_water_nonzero_factor_percent: { + summary: "奖池处于高余额时,普通开奖中非 0 倍档的基础机会放大到多少。填 130 表示变为原来的 130%。", + adjust: "调高会更快释放奖池、增加正奖机会;调低则更接近正常节奏。", + constraint: "必须大于 100%;放大后会重新归一化,并继续受奖池和返奖上限限制;它不会直接提高大奖触发机会。", + }, + recharge_boost_window_ms: { + summary: "用户完成一次可验证充值后,普通开奖中非 0 倍档的机会加成持续多久。300000 毫秒等于 5 分钟。", + adjust: "调大会延长充值后的加成时间;调小会让加成更快结束。", + constraint: "必须大于 0,生效区间包含充值时刻但不包含结束时刻;外部接入没有可信充值事实,不会触发加成。", + }, + recharge_boost_factor_percent: { + summary: "充值加成期间,普通开奖中非 0 倍档的基础机会放大到多少。填 110 表示变为原来的 110%。", + adjust: "调高会增强充值后的中奖体验,也会增加奖池消耗;调低则更温和。", + constraint: "必须大于 100%,会与高低余额调整相乘;它只改变普通奖档,不会直接提高大奖触发机会。", + }, + jackpot_multipliers: { + summary: "列出大奖可能采用的倍数,例如 200、500、1000。实际奖励按本抽消费金额乘以倍数计算。", + adjust: "增加或调高倍数会放大奖励金额和资金压力;删除倍数会减少可选大奖。", + constraint: + "用逗号分隔,必须都大于 1 且从小到大;系统会在所有付得起的大奖倍数中等机会选择一档。这里的倍率不能再作为普通奖档设置正概率,否则配置无法发布。", + }, + jackpot_global_rtp_max_percent: { + summary: + "系统每次达到结算流水后,会结束并重置一轮大盘 RTP。刚结算的本轮大盘 RTP 不高于这条线时,才继续检查用户的两项返还条件。", + adjust: "调高会让补偿大奖更容易进入候选;调低会要求全局返还更低才触发。", + constraint: + "必须大于 0% 且不超过 100%。大盘 RTP 等于本项配置值时可以通过;还必须同时满足用户本轮 RTP 严格低于本轮触发线、用户近 48 小时 RTP 严格低于长期触发线,且奖池和各项限额足够。闭窗资格只在用户下一次送礼时尝试一次,奖池或限额不足也不会顺延。", + }, + jackpot_user_round_rtp_max_percent: { + summary: + "系统每次重置大盘 RTP 时,会按完全相同的起止时间结算这个用户的本轮 RTP。例如 9 点重置一次、10 点再次重置,10 点结算时就检查用户在 9 点到 10 点这一轮的总返奖 ÷ 总消费。", + adjust: "调高会让更多本轮返还偏低的用户通过;调低会更严格。用户本轮 RTP 必须严格小于配置值,刚好等于也不通过。", + constraint: + "只统计当前 App、当前奖池和同一结算轮次内的实际消费与返奖;本轮没有消费样本时不通过。它必须与近 48 小时条件同时通过,不能只满足其中一项。", + }, + jackpot_user_48h_rtp_max_percent: { + summary: + "判断这个用户近 48 小时是否整体返还偏低。本轮结算时,用户注册已满 48 小时,系统才会把结算时点向前 48 小时内的返奖金币加总,再除以同期实际消费金币;同一 App、奖池下跨房间、主播和设备合并统计,结果严格低于这条线才通过。", + adjust: "调高会更容易通过,调低会更严格;结果等于配置值也不通过。例如近 48 小时消费 10,000、返奖 9,500,RTP 为 95%,配置 96% 时通过;返奖 9,600、RTP 为 96% 时不通过。", + constraint: + "先看注册时间:注册未满 48 小时直接不通过;注册已满 48 小时后,不要求连续玩满 48 小时,只统计窗口内真实发生的消费和返奖。即使前 47 小时都没玩、最近只玩 5 分钟,也用这 5 分钟的数据计算;整段时间没有消费时不通过。它还必须与大盘条件、用户本轮条件同时通过,并受每日大奖次数、奖池余额和所有返奖上限限制。", + }, + max_jackpot_hits_per_user_day: { + summary: "限制同一用户在当前奖池的一个 UTC 自然日内最多真正拿到多少次大奖。", + adjust: "调高允许同一用户一天获得更多大奖;调低会更早停止该用户当天的大奖,不保证转给其他用户。", + constraint: + "必须大于 0;达到次数后,即使大盘、本轮和近 48 小时 RTP 都通过也不再出大奖,但仍可参与普通奖档;下一个 UTC 自然日重新累计。", + }, + jackpot_spend_threshold_coins: { + summary: + "这是旧规则按用户日累计消费保存独立大奖资格时使用的历史字段。当前规则已停用这条路径,保留数值只为读取和回传旧版本配置。", + adjust: "当前不可编辑;无论原值是多少,都不会增加用户出大奖的机会。", + constraint: + "当前大奖必须经过大盘条件,并同时满足用户本轮 RTP 与近 48 小时 RTP 两项条件;这个历史字段不能替代或绕过任何一项。", + }, + max_single_payout: { + summary: "限制一抽最多能返多少金币;超过这个金额的奖档当次不会发出。", + adjust: "调高允许更大的单次奖励,也增加资金风险;调低会直接挡住更多高倍率结果。", + constraint: + "启用 V3 时必须大于 0;批量送礼按每个子抽分别判断。奖档超过任一剩余额度会整档挡住,不会裁剪成较小金额。", + }, + user_hourly_payout_cap: { + summary: "限制同一用户在当前 UTC 小时内累计最多返多少金币。", + adjust: "调高允许用户短时间获得更多奖励;调低会更早限制高频送礼用户。", + constraint: "启用 V3 时必须大于 0;按当前奖池和实际支付时间所在的 UTC 小时计算,到下一小时重新累计。", + }, + user_daily_payout_cap: { + summary: "限制同一用户在当前 UTC 自然日内累计最多返多少金币。", + adjust: "调高允许单个用户一天获得更多奖励;调低会更均匀地分散预算。", + constraint: "启用 V3 时必须大于 0;按当前奖池和实际支付时间所在的 UTC 日期累计。", + }, + device_daily_payout_cap: { + summary: "限制同一设备上的所有账号在一个 UTC 自然日内合计最多返多少金币,用来防止换账号绕过用户上限。", + adjust: "调高会放宽同设备多账号的总额度;调低会更严格地控制集中领取。", + constraint: "启用 V3 时必须大于 0,并按当前奖池隔离;必须拿到稳定设备 ID,外部接入也不能用用户 ID 代替。", + }, + room_hourly_payout_cap: { + summary: "限制同一房间内所有用户在当前 UTC 小时合计最多返多少金币。", + adjust: "调高允许热门房间更集中地产生奖励;调低会更早限制单房间的消耗速度。", + constraint: + "启用 V3 时必须大于 0,并按当前奖池隔离;外部接口没有真实房间,同一 App 的外部请求会共用一个虚拟房间额度。", + }, + anchor_daily_payout_cap: { + summary: "限制同一房主关联的所有房间和用户在一个 UTC 自然日内合计最多返多少金币。", + adjust: "调高允许奖励更集中在单个主播;调低会把预算分散到更多主播。", + constraint: + "启用 V3 时必须大于 0,并按当前奖池隔离;外部接口没有房主,同一 App 的外部请求会共用一个虚拟主播额度。", + }, + stage_novice: { + summary: + "新手阶段使用的一组基础奖档。V2 看累计消费折算次数;V3 中未同时达到普通阶段两项充值门槛的用户都会落在这里。", + adjust: "可通过倍率组合调整未达到普通门槛用户的体验,但预计平均返还仍要落在允许范围内。", + constraint: + "V3 的新手充值门槛固定为 0 / 0;外部接入当前也始终按新手阶段处理。每个阶段都必须保留启用的 0 倍档。", + }, + stage_normal: { + summary: "普通阶段使用的一组基础奖档。V2 看累计消费折算次数;V3 必须同时达到这里的 7 日和 30 日最低充值。", + adjust: "V3 中提高充值门槛会减少进入普通阶段的人数,降低则会扩大范围;V2 由上方等价抽数上限控制阶段。", + constraint: "V3 中至少一个充值门槛必须大于 0,并且不能高于高阶阶段对应门槛。", + }, + stage_advanced: { + summary: + "高阶阶段使用的一组基础奖档。V2 超过普通折算次数上限后进入;V3 会选择同时满足 7 日和 30 日门槛的最高阶段。", + adjust: "V3 中提高充值门槛会缩小高阶范围,降低则会扩大;V2 由上方等价抽数上限控制阶段。", + constraint: "V3 中两项门槛都不能低于普通阶段,且至少一项必须更高。", + }, + min_recharge_7d_coins: { + summary: "用户最近 7 个 UTC 自然日(含当天)的充值金币至少达到多少,才满足当前阶段的一半条件。", + adjust: "调高会减少进入该阶段的人数;调低会扩大范围。", + constraint: "用户还必须同时达到 30 日门槛;新手阶段固定为 0。外部接入没有可信充值快照时按新手处理。", + }, + min_recharge_30d_coins: { + summary: "用户最近 30 个 UTC 自然日(含当天)的充值金币至少达到多少,才满足当前阶段的另一半条件。", + adjust: "调高会更看重长期充值;调低会让近期活跃用户更容易进入该阶段。", + constraint: "用户还必须同时达到 7 日门槛;新手阶段固定为 0。", + }, + tier_multiplier: { + summary: "这一档返还本抽消费金额的多少倍。0 倍表示没有返还,1 倍表示返还与本抽消费相同。", + adjust: "调高会增加这一档的奖励金额;系统会自动重算各档基础概率来尽量保持平均返还目标。", + constraint: "不能小于 0;10 倍及以上必须勾选“高倍率安全标记”,实际支付仍不能超过奖池和返奖上限。", + }, + tier_probability: { + summary: "表示在当前用户阶段中抽到这一档的基础机会,所有启用奖档必须合计 100%。", + adjust: "该值由平均返还目标和各档倍数自动计算,只读展示;修改倍数或目标后会重新计算。", + constraint: "V3 会按水位和可信充值调整普通非 0 档,并受六项上限限制;V2 保持基础概率,只过滤奖池付不起的档位。", + }, + tier_high_water_only: { + summary: "这是发布高倍率基础奖档前的安全确认标记;当前运行逻辑不会根据它判断奖池处于哪种水位。", + adjust: "勾选或取消不会直接放行、拦截奖档;实际是否能发仍看实时奖池余额和返奖上限。", + constraint: + "10 倍及以上必须勾选才能发布。不要把它当成独立的中奖开关;V3 的高低余额变化由上方两组中奖权重控制。", + }, + tier_enabled: { + summary: "决定这一档是否参加当前阶段的普通基础抽奖。关闭后不会从普通奖档中选中。", + adjust: "关闭高倍率档会降低大额结果种类;重新开启后系统会重新计算整组概率。", + constraint: + "每个阶段启用档位的概率必须合计 100%,并至少保留一个 0 倍档;若同倍率另列在大奖倍率中,大奖机制仍可能选中它。", + }, +}; + +export function LuckyGiftFieldLabel({ field, label }) { + const help = FIELD_HELP[field]; + if (!help) return label; + return ( + } + > + + {label} + + + + ); +} + +function FieldHelpContent({ help, label }) { + return ( +
    + {label} +
    +
    +
    作用
    +
    {help.summary}
    +
    +
    +
    调整影响
    +
    {help.adjust}
    +
    +
    +
    注意
    +
    {help.constraint}
    +
    +
    +
    + ); +} diff --git a/ops-center/src/components/LuckyGiftPoolActions.jsx b/ops-center/src/components/LuckyGiftPoolActions.jsx new file mode 100644 index 0000000..9b329c4 --- /dev/null +++ b/ops-center/src/components/LuckyGiftPoolActions.jsx @@ -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 ( +
    + {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/LuckyGiftStageDesigner.jsx b/ops-center/src/components/LuckyGiftStageDesigner.jsx new file mode 100644 index 0000000..2878b14 --- /dev/null +++ b/ops-center/src/components/LuckyGiftStageDesigner.jsx @@ -0,0 +1,273 @@ +import AddOutlined from "@mui/icons-material/AddOutlined"; +import DeleteOutlined from "@mui/icons-material/DeleteOutlined"; +import Checkbox from "@mui/material/Checkbox"; +import IconButton from "@mui/material/IconButton"; +import TextField from "@mui/material/TextField"; +import { Button } from "@/shared/ui/Button.jsx"; +import { formatDecimal } from "@/shared/utils/rtpProbability.js"; +import { formatNumber } from "../format.js"; +import { LuckyGiftFieldLabel } from "./LuckyGiftFieldHelp.jsx"; +import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; + +export function LuckyGiftStageDesigner({ + activeStageConfig, + activeStageKey, + activeSummary, + normalStageConfig, + onAddTier, + onRemoveTier, + onStageChange, + onStageFieldChange, + onTierChange, + stageSummaries, + strategyVersion, +}) { + const selectStageFromKeyboard = (event, currentIndex) => { + const lastIndex = stageSummaries.length - 1; + let nextIndex; + if (["ArrowRight", "ArrowDown"].includes(event.key)) nextIndex = (currentIndex + 1) % stageSummaries.length; + if (["ArrowLeft", "ArrowUp"].includes(event.key)) + nextIndex = (currentIndex - 1 + stageSummaries.length) % stageSummaries.length; + if (event.key === "Home") nextIndex = 0; + if (event.key === "End") nextIndex = lastIndex; + if (nextIndex === undefined) return; + event.preventDefault(); + const nextStage = stageSummaries[nextIndex]?.stageKey; + if (!nextStage) return; + // Tabs 遵循 roving tabindex:方向键既切换阶段,也把焦点移到新 tab,键盘操作不会落回隐藏面板。 + onStageChange(nextStage); + event.currentTarget.ownerDocument.getElementById(`ops-stage-tab-${nextStage}`)?.focus(); + }; + + return ( +
    +
    + {stageSummaries.map((summary, index) => { + const isActive = summary.stageKey === activeStageKey; + return ( + + ); + })} +
    + + {activeStageConfig ? ( +
    +
    +
    +

    + +

    + +
    + +
    + + {strategyVersion === "dynamic_v3" && activeStageConfig.stage === "novice" ? ( + + ) : strategyVersion === "dynamic_v3" ? ( +
    + 进入本阶段所需充值 + + } + size="small" + slotProps={{ htmlInput: { min: 0, step: 1 } }} + type="number" + value={activeStageConfig.min_recharge_7d_coins} + onChange={(event) => + onStageFieldChange( + activeStageConfig.stage, + "min_recharge_7d_coins", + event.target.value, + ) + } + /> + + } + size="small" + slotProps={{ htmlInput: { min: 0, step: 1 } }} + type="number" + value={activeStageConfig.min_recharge_30d_coins} + onChange={(event) => + onStageFieldChange( + activeStageConfig.stage, + "min_recharge_30d_coins", + event.target.value, + ) + } + /> +
    + ) : null} + +
    +
    + + + + + + + + + + + + + 操作 +
    + {activeStageConfig.tiers.map((tier, index) => { + const rowLabel = `${activeSummary?.stageLabel || activeStageConfig.stage}第 ${index + 1} 档`; + return ( +
    +
    + + onTierChange(activeStageConfig.stage, index, { + // 10 倍及以上是服务端发布校验要求的高倍率标记;运行时仍会独立检查真实奖池余额, + // 不能把前端勾选本身当成资金足够或必然可发奖的依据。 + highWaterOnly: + Number(event.target.value) >= 10 || tier.highWaterOnly, + multiplier: event.target.value, + }) + } + /> +
    +
    + {/* 概率由目标 RTP 和倍率统一校正;保持只读可避免运营绕过服务端概率闭合校验。 */} + +
    +
    + = 10} + size="small" + slotProps={{ input: { "aria-label": `${rowLabel}高倍率安全标记` } }} + onChange={(event) => + onTierChange(activeStageConfig.stage, index, { + highWaterOnly: event.target.checked, + }) + } + /> +
    +
    + + onTierChange(activeStageConfig.stage, index, { + enabled: event.target.checked, + }) + } + /> +
    +
    + onRemoveTier(activeStageConfig.stage, index)} + > + + +
    +
    + ); + })} +
    +
    + ) : null} +
    + ); +} + +function NoviceRechargeRange({ normalStageConfig }) { + const normal7D = Number(normalStageConfig?.min_recharge_7d_coins || 0); + const normal30D = Number(normalStageConfig?.min_recharge_30d_coins || 0); + // normal 是二维 AND 下限,合法配置只需至少一维大于 0;为 0 的维度也必须照实展示。 + const configured = Number.isFinite(normal7D) && Number.isFinite(normal30D) && (normal7D > 0 || normal30D > 0); + + return ( +
    + 派生范围 +
    + + 7 日充值 < {formatNumber(normal7D)} 或 30 日充值 < {formatNumber(normal30D)} + + {/* 普通阶段需要两个下限同时成立;因此任一维未达到都应留在新手,等于下限则进入普通。 */} + + {configured ? "" : "普通阶段门槛待配置;"}任一未达到即为新手;7 日与 30 + 日均达到门槛(含等于)后进入普通。 + +
    +
    + ); +} + +function plainStageStatus(status) { + if (status === "RTP 偏离") return "平均返还偏离"; + if (status === "概率未闭合") return "概率合计不为 100%"; + return status || "待检查"; +} diff --git a/ops-center/src/components/OverviewView.jsx b/ops-center/src/components/OverviewView.jsx index 8f98126..5ee4c7d 100644 --- a/ops-center/src/components/OverviewView.jsx +++ b/ops-center/src/components/OverviewView.jsx @@ -2,123 +2,206 @@ 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, useState } from "react"; +import { AdminFilterSelect, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx"; 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 [poolScope, setPoolScope] = useState("current"); + const poolBalanceColumns = useMemo( + () => buildPoolBalanceColumns({ canCredit, canDebit, disabled, onCredit, onDebit }), + [canCredit, canDebit, disabled, onCredit, onDebit], + ); + const poolCounts = useMemo( + () => ({ + all: poolBalances.length, + current: poolBalances.filter((pool) => !pool.is_historical).length, + history: poolBalances.filter((pool) => pool.is_historical).length, + }), + [poolBalances], + ); + const visiblePoolBalances = useMemo(() => { + if (poolScope === "history") { + return poolBalances.filter((pool) => pool.is_historical); + } + if (poolScope === "all") { + return poolBalances; + } + // 当前策略是默认视图;历史账本只从显式筛选进入,避免同名 V2/V3 被误读为重复奖池。 + return poolBalances.filter((pool) => !pool.is_historical); + }, [poolBalances, poolScope]); - 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) => { + if (item.is_historical) { + return ; + } + return 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..316b750 100644 --- a/ops-center/src/configForm.js +++ b/ops-center/src/configForm.js @@ -11,8 +11,21 @@ import { DEFAULT_POOL_ID } from "./api.js"; const DYNAMIC_STRATEGY = "dynamic_v3"; const PPM_SCALE = 1_000_000; +const LEGACY_JACKPOT_RTP_MODE = "__legacy_jackpot_rtp_fields"; +const legacyJackpotRTPAliases = { + jackpot_user_48h_rtp_max_percent: [ + "jackpot_user_72h_rtp_max_percent", + "jackpotUser72hRTPMaxPercent", + "jackpotUser72hRtpMaxPercent", + ], + jackpot_user_round_rtp_max_percent: [ + "jackpot_user_day_rtp_max_percent", + "jackpotUserDayRTPMaxPercent", + "jackpotUserDayRtpMaxPercent", + ], +}; -// 只有跨 App 通用的算法参数才有默认值;50 美元等值门槛和六维金额上限必须由运营按 App 金币口径填写, +// 只有跨 App 通用的算法参数才有默认值;六维金额上限必须由运营按 App 奖池的金币口径填写, // 因而默认保持 0 且规则停用,避免把某个 App 的预算误发到其他 App。 const dynamicDefaults = { anchor_daily_payout_cap: 0, @@ -21,12 +34,13 @@ 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, - jackpot_user_72h_rtp_max_percent: 96, - jackpot_user_day_rtp_max_percent: 96, + jackpot_user_48h_rtp_max_percent: 96, + jackpot_user_round_rtp_max_percent: 96, loss_streak_guarantee: 5, low_water_nonzero_factor_percent: 70, low_watermark_coins: 10_000_000, @@ -54,8 +68,8 @@ const numericFields = [ "initial_pool_coins", "jackpot_global_rtp_max_percent", "jackpot_spend_threshold_coins", - "jackpot_user_72h_rtp_max_percent", - "jackpot_user_day_rtp_max_percent", + "jackpot_user_48h_rtp_max_percent", + "jackpot_user_round_rtp_max_percent", "loss_streak_guarantee", "low_water_nonzero_factor_percent", "low_watermark_coins", @@ -108,12 +122,30 @@ export function configToForm(config = {}) { stages: normalizeFormStages(config.stages, targetRTPPercent, strategyVersion), strategy_version: strategyVersion, }; + // 只在明确收到旧 day/72h 且缺少对应 canonical 字段时进入兼容模式。页面始终编辑 canonical 值, + // 这个标记仅让纯函数保留旧对象形状;HTTP 层仍会归一成 round/48h 后再发送。 + form[LEGACY_JACKPOT_RTP_MODE] = Object.entries(legacyJackpotRTPAliases).some( + ([canonical, aliases]) => + !hasConfigValue(config, [canonical, snakeToCamel(canonical)]) && hasConfigValue(config, aliases), + ); for (const field of numericFields) { if (field === "target_rtp_percent") { form[field] = targetRTPPercent; continue; } - form[field] = formConfigNumber(config, field, snakeToCamel(field), defaultNumber(field, strategyVersion)); + if (field === "initial_pool_coins" && strategyVersion === DYNAMIC_STRATEGY) { + // 旧 dynamic_v3 快照可能仍保存 1,000,000;新增版本必须显式归零,不能把历史隐式注资语义继续复制。 + form[field] = "0"; + continue; + } + const legacyAliases = legacyJackpotRTPAliases[field] || []; + form[field] = hasConfigValue(config, [field, snakeToCamel(field)]) + ? formConfigNumber(config, field, snakeToCamel(field), defaultNumber(field, strategyVersion)) + : formNumber(firstConfigValue(config, legacyAliases) ?? defaultNumber(field, strategyVersion)); + } + if (form[LEGACY_JACKPOT_RTP_MODE]) { + form.jackpot_user_72h_rtp_max_percent = form.jackpot_user_48h_rtp_max_percent; + form.jackpot_user_day_rtp_max_percent = form.jackpot_user_round_rtp_max_percent; } return form; } @@ -125,22 +157,41 @@ export function formToConfig(config, form) { enabled: Boolean(form.enabled), jackpot_multipliers: multiplierListFromForm(form.jackpot_multipliers), pool_id: form.pool_id, - stages: (form.stages || []).map((stage) => ({ - min_recharge_30d_coins: numberFromForm(stage.min_recharge_30d_coins), - min_recharge_7d_coins: numberFromForm(stage.min_recharge_7d_coins), - stage: stage.stage, - tiers: (stage.tiers || []).map((tier) => ({ - enabled: tier.enabled !== false, - high_water_only: Boolean(tier.highWaterOnly), - multiplier: numberFromForm(tier.multiplier), - probability_percent: numberFromForm(tier.probabilityPercent), - tier_id: tier.tierId || "", - })), - })), + stages: (form.stages || []).map((stage) => { + // dynamic_v3 的新手范围由普通阶段下限反向派生;0/0 只是旧 DTO 要求的兜底哨兵值, + // 不是可运营的“充值为 0 才是新手”规则。序列化时强制归零,避免历史异常值改变分层。 + const isDynamicNovice = form.strategy_version === DYNAMIC_STRATEGY && stage.stage === "novice"; + return { + min_recharge_30d_coins: isDynamicNovice ? 0 : numberFromForm(stage.min_recharge_30d_coins), + min_recharge_7d_coins: isDynamicNovice ? 0 : numberFromForm(stage.min_recharge_7d_coins), + stage: stage.stage, + tiers: (stage.tiers || []).map((tier) => ({ + enabled: tier.enabled !== false, + high_water_only: Boolean(tier.highWaterOnly), + multiplier: numberFromForm(tier.multiplier), + probability_percent: numberFromForm(tier.probabilityPercent), + tier_id: tier.tierId || "", + })), + }; + }), strategy_version: form.strategy_version, }; for (const field of numericFields) { - output[field] = numberFromForm(form[field]); + output[field] = + field === "initial_pool_coins" && form.strategy_version === DYNAMIC_STRATEGY + ? 0 + : numberFromForm(form[field]); + } + if (form[LEGACY_JACKPOT_RTP_MODE]) { + // 旧输入的纯函数 round-trip 保持原 shape,避免调用方只做本地比较时出现伪 diff;真正保存时 + // luckyGiftConfigPayload 会把这两个别名归一成 canonical HTTP 字段,绝不会双发两套字段。 + output.jackpot_user_72h_rtp_max_percent = output.jackpot_user_48h_rtp_max_percent; + output.jackpot_user_day_rtp_max_percent = output.jackpot_user_round_rtp_max_percent; + delete output.jackpot_user_48h_rtp_max_percent; + delete output.jackpot_user_round_rtp_max_percent; + } else { + delete output.jackpot_user_72h_rtp_max_percent; + delete output.jackpot_user_day_rtp_max_percent; } return output; } @@ -233,6 +284,9 @@ export function validateLuckyGiftForm(form) { ) errors.push("等价抽数阶段门槛不正确"); + const jackpotMultipliers = + form.strategy_version === DYNAMIC_STRATEGY ? new Set(multiplierListFromForm(form.jackpot_multipliers)) : null; + // 阶段概率与 RTP 是两代策略共用的 owner 校验,fixed_v2 也不能跳过。 for (const stageKey of ["novice", "normal", "advanced"]) { const stage = form.stages?.find((item) => item.stage === stageKey); @@ -251,6 +305,19 @@ export function validateLuckyGiftForm(form) { errors.push(`${stageLabel(stageKey)}启用奖档概率必须大于 0`); if (enabled.some((tier) => Number(tier.multiplier) >= 10 && !tier.highWaterOnly)) errors.push(`${stageLabel(stageKey)}10x 及以上奖档必须限制为高水位`); + const jackpotStageMultipliers = + jackpotMultipliers && + enabled + .filter( + (tier) => + jackpotMultipliers.has(numberFromForm(tier.multiplier)) && + percentToPPM(tier.probabilityPercent) > 0, + ) + .map((tier) => formatDecimal(numberFromForm(tier.multiplier))); + if (jackpotStageMultipliers?.length) { + // dynamic_v3 的大奖倍率只能走大奖补偿路径;普通阶段再给正概率会绕过 owner 的双 RTP 门槛。 + errors.push(`${stageLabel(stageKey)}普通奖档不能包含大奖倍率:${unique(jackpotStageMultipliers).join("x、")}x`); + } const expected = expectedRTPPercent(enabled, { multiplier: (item) => item.multiplier }); if ( expected < number("target_rtp_percent") - number("control_band_percent") || @@ -270,7 +337,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("高水位必须大于正数低水位"); @@ -282,34 +349,35 @@ export function validateLuckyGiftForm(form) { const jackpotTokens = String(form.jackpot_multipliers || "") .split(/[,,\s]+/) .filter(Boolean); - const jackpotMultipliers = multiplierListFromForm(form.jackpot_multipliers); + const jackpotMultiplierValues = multiplierListFromForm(form.jackpot_multipliers); if ( - jackpotTokens.length !== jackpotMultipliers.length || - !jackpotMultipliers.length || - jackpotMultipliers.some((value, index) => value <= 1 || (index > 0 && value <= jackpotMultipliers[index - 1])) + jackpotTokens.length !== jackpotMultiplierValues.length || + !jackpotMultiplierValues.length || + jackpotMultiplierValues.some((value, index) => value <= 1 || (index > 0 && value <= jackpotMultiplierValues[index - 1])) ) { errors.push("大奖倍率必须大于 1x 并严格递增"); } if (number("jackpot_global_rtp_max_percent") <= 0 || number("jackpot_global_rtp_max_percent") > 100) errors.push("全局大奖 RTP 上限必须在 0%-100% 之间"); if ( - number("jackpot_user_day_rtp_max_percent") <= 0 || - number("jackpot_user_day_rtp_max_percent") > number("jackpot_global_rtp_max_percent") + number("jackpot_user_round_rtp_max_percent") <= 0 || + number("jackpot_user_round_rtp_max_percent") > number("jackpot_global_rtp_max_percent") ) - errors.push("用户每日 RTP 上限必须大于 0 且不超过全局上限"); + errors.push("用户本轮 RTP 触发线必须大于 0 且不超过全局上限"); if ( - number("jackpot_user_72h_rtp_max_percent") <= 0 || - number("jackpot_user_72h_rtp_max_percent") > number("jackpot_global_rtp_max_percent") + number("jackpot_user_48h_rtp_max_percent") <= 0 || + number("jackpot_user_48h_rtp_max_percent") > number("jackpot_global_rtp_max_percent") ) - errors.push("用户 72 小时 RTP 上限必须大于 0 且不超过全局上限"); + errors.push("用户 48 小时 RTP 触发线必须大于 0 且不超过全局上限"); if (number("max_jackpot_hits_per_user_day") <= 0) errors.push("用户每日大奖次数必须大于 0"); if (number("jackpot_spend_threshold_coins") < 0) errors.push("大奖消费门槛不能小于 0"); + if (form[LEGACY_JACKPOT_RTP_MODE] && form.enabled && number("jackpot_spend_threshold_coins") <= 0) + errors.push("启用前必须填写用户日累计消费触发门槛(金币)"); for (const [field, label] of payoutCapFields) { if (number(field) < 0) errors.push(`${label}不能小于 0`); } - validateRechargeStages(form.stages || [], errors); + validateRechargeStages(form.stages || [], errors, form.enabled); if (form.enabled) { - if (number("jackpot_spend_threshold_coins") <= 0) errors.push("启用前必须填写 50 美元等值的大奖消费门槛"); for (const [field, label] of payoutCapFields) { if (number(field) <= 0) errors.push(`启用前必须填写${label}`); } @@ -336,11 +404,15 @@ function normalizeFormStages(stages, targetRTPPercent, strategyVersion) { : correctedDefaultTiers(stageKey, targetRTPPercent); const [default7d, default30d] = strategyVersion === DYNAMIC_STRATEGY ? defaultRechargeThreshold(stageKey) : [0, 0]; + const isDynamicNovice = strategyVersion === DYNAMIC_STRATEGY && stageKey === "novice"; return { - min_recharge_7d_coins: formNumber(source?.min_recharge_7d_coins ?? source?.minRecharge7DCoins ?? default7d), - min_recharge_30d_coins: formNumber( - source?.min_recharge_30d_coins ?? source?.minRecharge30DCoins ?? default30d, - ), + // 只在表单内保留兼容哨兵值;界面上的新手上界始终从普通门槛派生,不读取历史 novice 门槛。 + min_recharge_7d_coins: isDynamicNovice + ? "0" + : formNumber(source?.min_recharge_7d_coins ?? source?.minRecharge7DCoins ?? default7d), + min_recharge_30d_coins: isDynamicNovice + ? "0" + : formNumber(source?.min_recharge_30d_coins ?? source?.minRecharge30DCoins ?? default30d), stage: stageKey, tiers: sourceTiers.map((tier) => ({ enabled: tier.enabled !== false, @@ -373,13 +445,10 @@ function defaultTier(tierID, multiplier, probabilityPercent) { return { enabled: true, highWaterOnly: false, multiplier, probabilityPercent, tierId: tierID }; } -function validateRechargeStages(stages, errors) { - const novice = stages.find((stage) => stage.stage === "novice"); +function validateRechargeStages(stages, errors, requireComplete) { const normal = stages.find((stage) => stage.stage === "normal"); const advanced = stages.find((stage) => stage.stage === "advanced"); const threshold = (stage, field) => numberFromForm(stage?.[field]); - if (threshold(novice, "min_recharge_7d_coins") !== 0 || threshold(novice, "min_recharge_30d_coins") !== 0) - errors.push("新手阶段充值门槛必须为 0 / 0"); const normal7d = threshold(normal, "min_recharge_7d_coins"); const normal30d = threshold(normal, "min_recharge_30d_coins"); const advanced7d = threshold(advanced, "min_recharge_7d_coins"); @@ -388,15 +457,21 @@ function validateRechargeStages(stages, errors) { errors.push("充值阶段门槛不能小于 0"); return; } - if (normal7d === 0 && normal30d === 0) errors.push("正常阶段至少一个充值门槛必须大于 0"); + const isUnconfigured = [normal7d, normal30d, advanced7d, advanced30d].every((value) => value === 0); + // 新建的停用草稿可以全 0,避免猜测 App 金币价值;一旦开始填写或准备启用, + // 就必须遵守 owner 的二维下限契约:normal 至少一维大于 0,advanced 逐维不低且至少一维更高。 + if (!requireComplete && isUnconfigured) return; + if (normal7d === 0 && normal30d === 0) + errors.push( + requireComplete ? "启用前普通阶段至少一个最低充值门槛必须大于 0" : "普通阶段至少一个充值门槛必须大于 0", + ); if (advanced7d < normal7d || advanced30d < normal30d || (advanced7d === normal7d && advanced30d === normal30d)) errors.push("高阶充值门槛必须逐维不低于正常阶段且至少一项更高"); } -function defaultRechargeThreshold(stage) { - if (stage === "novice") return [0, 0]; - if (stage === "normal") return [0, 1]; - return [1, 1]; +function defaultRechargeThreshold() { + // 充值门槛受 App 金币比例影响,新草稿不猜测 1 金币等于可用配置;运营填完后才能启用。 + return [0, 0]; } function defaultNumber(field, strategyVersion) { @@ -431,6 +506,17 @@ function formConfigNumber(config, snake, camel, fallback) { return formNumber(config[snake] ?? config[camel] ?? fallback); } +function firstConfigValue(config, keys) { + for (const key of keys) { + if (config[key] !== undefined && config[key] !== null) return config[key]; + } + return undefined; +} + +function hasConfigValue(config, keys) { + return firstConfigValue(config, keys) !== undefined; +} + function snakeToCamel(value) { return value.replace(/_([a-z0-9])/g, (_, letter) => letter.toUpperCase()); } diff --git a/ops-center/src/configForm.test.js b/ops-center/src/configForm.test.js index df9118d..1c302dd 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", @@ -18,8 +18,8 @@ describe("ops center lucky gift dynamic_v3 form", () => { }); expect(form.stages.map((stage) => [stage.min_recharge_7d_coins, stage.min_recharge_30d_coins])).toEqual([ ["0", "0"], - ["0", "1"], - ["1", "1"], + ["0", "0"], + ["0", "0"], ]); expect(form.stages.map((stage) => stage.tiers.map((tier) => Number(tier.probabilityPercent)))).toEqual([ [5, 4, 86, 5], @@ -49,6 +49,20 @@ 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; + legacy.stages[0].min_recharge_7d_coins = 12_345; + legacy.stages[0].min_recharge_30d_coins = 67_890; + + const form = configToForm(legacy); + expect(form.initial_pool_coins).toBe("0"); + expect(form.stages[0]).toMatchObject({ min_recharge_7d_coins: "0", min_recharge_30d_coins: "0" }); + const output = formToConfig(legacy, form); + expect(output.initial_pool_coins).toBe(0); + expect(output.stages[0]).toMatchObject({ min_recharge_7d_coins: 0, min_recharge_30d_coins: 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([]); @@ -56,7 +70,8 @@ describe("ops center lucky gift dynamic_v3 form", () => { const enabled = { ...draft, enabled: true }; const errors = validateLuckyGiftForm(enabled); - expect(errors).toContain("启用前必须填写 50 美元等值的大奖消费门槛"); + expect(errors).toContain("启用前普通阶段至少一个最低充值门槛必须大于 0"); + expect(errors).toContain("高阶充值门槛必须逐维不低于正常阶段且至少一项更高"); expect(errors).toContain("启用前必须填写单次返奖上限"); expect(errors).toContain("启用前必须填写主播每日上限"); }); @@ -98,7 +113,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], @@ -130,6 +150,32 @@ describe("ops center lucky gift dynamic_v3 form", () => { expect(errors).toContain("单次返奖上限不能小于 0"); expect(errors).toContain("充值阶段门槛不能小于 0"); }); + + test("rejects jackpot multipliers that still have ordinary stage probability", () => { + const form = configToForm(completeDynamicConfig()); + const errors = validateLuckyGiftForm({ + ...form, + stages: form.stages.map((stage) => + stage.stage === "normal" + ? { + ...stage, + tiers: [ + ...stage.tiers, + { + enabled: true, + highWaterOnly: true, + multiplier: "200", + probabilityPercent: "0.01", + tierId: "normal_200x", + }, + ], + } + : stage, + ), + }); + + expect(errors).toContain("正常阶段普通奖档不能包含大奖倍率:200x"); + }); }); function completeDynamicConfig() { @@ -144,7 +190,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..636911b 100644 --- a/ops-center/src/styles/index.css +++ b/ops-center/src/styles/index.css @@ -147,15 +147,66 @@ 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); +} + /* ---- 幸运礼物配置弹窗 ---- */ +.MuiDialog-paper.ops-config-dialog-paper { + width: 94vw; + max-width: 1520px; + height: 92vh; + max-height: 92vh; + margin: var(--space-4); + overflow: hidden; +} + .ops-config-form { - display: contents; + display: flex; + min-height: 0; + height: 100%; + flex-direction: column; } .ops-config-title { display: grid; + flex: 0 0 auto; gap: var(--space-1); + padding: var(--space-4) var(--space-6) var(--space-3); } .ops-config-title small { @@ -164,17 +215,31 @@ body { font-weight: 700; } -.ops-config-metrics { +.ops-config-summary { display: grid; - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); - gap: var(--space-2); - margin-bottom: var(--space-4); + flex: 0 0 auto; + grid-template-columns: repeat(7, minmax(0, 1fr)); + margin: 0; + padding: 0 var(--space-6) var(--space-3); +} + +.ops-config-dialog-content { + display: flex; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; + overflow: hidden !important; + background: var(--bg-card); + padding: 0 !important; } .ops-config-validation { display: grid; + max-height: 112px; + flex: 0 0 auto; gap: var(--space-2); - margin-bottom: var(--space-4); + overflow-y: auto; + margin: var(--space-3) var(--space-4) 0; border: 1px solid var(--danger-border, #fecaca); border-radius: var(--radius-control); background: var(--danger-surface, #fef2f2); @@ -182,6 +247,11 @@ body { padding: var(--space-3) var(--space-4); } +.ops-config-validation:focus-visible { + outline: 2px solid var(--danger, #dc2626); + outline-offset: 2px; +} + .ops-config-validation ul { display: grid; gap: 2px; @@ -192,26 +262,31 @@ body { .ops-config-metric { display: grid; min-width: 0; - gap: var(--space-1); - border: 1px solid var(--border); - border-radius: var(--radius-control); - background: var(--bg-card-strong, #f8fafc); - padding: 10px var(--space-3); + gap: 2px; + border-left: 1px solid var(--border); + padding: var(--space-1) var(--space-3); } -.ops-config-metric span { +.ops-config-metric:first-child { + border-left: 0; + padding-left: 0; +} + +.ops-config-metric dt { overflow: hidden; color: var(--text-tertiary); - font-size: 12px; + font-size: 11px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; } -.ops-config-metric strong { +.ops-config-metric dd { overflow: hidden; + margin: 0; color: var(--text-primary); - font-size: 15px; + font-size: 13px; + font-weight: 800; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; @@ -219,24 +294,94 @@ body { .ops-config-layout { display: grid; - grid-template-columns: minmax(360px, 440px) minmax(0, 1fr); - align-items: start; - gap: var(--space-4); + min-height: 0; + height: 100%; + flex: 1 1 auto; + grid-template-columns: clamp(500px, 40vw, 620px) minmax(0, 1fr); + overflow: hidden; } .ops-config-params { display: grid; min-width: 0; - gap: var(--space-4); + min-height: 0; + grid-template-columns: 92px minmax(0, 1fr); + overflow: hidden; + border-right: 1px solid var(--border); +} + +.ops-config-section-nav { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--space-1); + overflow-y: auto; + border-right: 1px solid var(--border); + background: var(--bg-card-strong, #f8fafc); + padding: var(--space-3) var(--space-2); +} + +.ops-config-section-nav strong { + color: var(--text-tertiary); + font-size: 11px; + letter-spacing: 0.04em; + padding: var(--space-1) var(--space-2) var(--space-2); +} + +.ops-config-section-nav button { + min-height: 34px; + border: 0; + border-left: 2px solid transparent; + border-radius: var(--radius-control); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 800; + padding: 0 var(--space-2); + text-align: left; + transition: + background var(--motion-fast) var(--ease-standard), + border-color var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard); +} + +.ops-config-section-nav button:hover { + background: var(--primary-hover); +} + +.ops-config-section-nav button.is-active { + border-left-color: var(--primary); + background: var(--primary-surface); + color: var(--primary); +} + +.ops-config-section-list { + min-width: 0; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding: var(--space-3) var(--space-4) var(--space-6); + scroll-behavior: smooth; } .ops-config-section { display: grid; gap: var(--space-3); - border: 1px solid var(--border); - border-radius: var(--radius-card); + border: 0; + border-bottom: 1px solid var(--border); + border-radius: 0; background: var(--bg-card); - padding: var(--space-4); + padding: var(--space-3) 0 var(--space-5); +} + +.ops-config-section + .ops-config-section { + padding-top: var(--space-5); +} + +.ops-config-section:last-child { + border-bottom: 0; } .ops-config-section h3 { @@ -246,6 +391,28 @@ body { line-height: 1.3; } +.ops-config-section__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.ops-config-section__head span { + border-radius: var(--radius-pill); + background: var(--warning-surface); + color: var(--warning); + font-size: 11px; + font-weight: 800; + padding: 3px var(--space-2); + white-space: nowrap; +} + +.ops-config-section__head span.is-ok { + background: var(--success-surface); + color: var(--success); +} + .ops-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -256,44 +423,220 @@ body { grid-column: 1 / -1; } +.ops-config-enabled-field { + min-height: var(--control-height); + justify-content: space-between; + margin: 0 !important; + border: 1px solid var(--border); + border-radius: var(--radius-control); + padding: 0 var(--space-3); +} + +.ops-config-enabled-field .MuiFormControlLabel-label { + color: var(--text-secondary); + font-size: 13px; + font-weight: 700; +} + +.ops-field-note { + margin: 0; + color: var(--text-tertiary); + font-size: 12px; + line-height: 1.5; +} + +.ops-jackpot-flow { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--border); +} + +.ops-jackpot-flow > div { + display: grid; + align-content: start; + gap: 4px; + background: var(--bg-card-strong, #f8fafc); + padding: var(--space-2) var(--space-3); +} + +.ops-jackpot-flow strong { + color: var(--text-primary); + font-size: 12px; +} + +.ops-jackpot-flow span { + color: var(--text-secondary); + font-size: 11px; + line-height: 1.5; +} + +.ops-field-label { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 4px; + border-radius: 3px; + cursor: help; + white-space: nowrap; +} + +/* OutlinedInput 会在 notch 的 legend 中复制一次 label;保留其宽度计算,但隐藏复制品, + * 避免同一个说明入口在键盘 Tab 顺序中出现两次。 */ +.MuiOutlinedInput-notchedOutline legend .ops-field-label { + visibility: hidden; +} + +.ops-field-help-trigger { + display: inline-flex; + width: 16px; + height: 16px; + flex: 0 0 16px; + align-items: center; + justify-content: center; + border-radius: 50%; + color: var(--text-tertiary); + font-size: 14px; + transition: + background var(--motion-fast) var(--ease-standard), + color var(--motion-fast) var(--ease-standard); +} + +.ops-field-label:hover .ops-field-help-trigger, +.ops-field-label:focus-visible .ops-field-help-trigger { + background: var(--primary-hover); + color: var(--primary); +} + +.ops-field-label:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--primary-border, #93c5fd); +} + +/* Tooltip 通过 portal 渲染,必须用自身 class 承载完整排版;字段内只保留问号,不牺牲表单密度。 */ +.MuiTooltip-tooltip.ops-field-help-tooltip { + max-width: min(420px, calc(100vw - 32px)); + border: 1px solid rgb(148 163 184 / 32%); + border-radius: var(--radius-control); + background: #0f172a; + box-shadow: 0 16px 40px rgb(15 23 42 / 28%); + color: #f8fafc; + padding: var(--space-3) var(--space-4); +} + +.ops-field-help-content { + display: grid; + gap: var(--space-2); +} + +.ops-field-help-content > strong { + color: #ffffff; + font-size: 13px; + line-height: 1.4; +} + +.ops-field-help-content dl { + display: grid; + gap: 7px; + margin: 0; +} + +.ops-field-help-content dl > div { + display: grid; + grid-template-columns: 56px minmax(0, 1fr); + align-items: start; + gap: var(--space-2); +} + +.ops-field-help-content dt { + color: #93c5fd; + font-size: 12px; + font-weight: 800; + line-height: 1.55; +} + +.ops-field-help-content dd { + margin: 0; + color: #e2e8f0; + font-size: 12px; + line-height: 1.55; +} + .ops-stage-designer { display: grid; min-width: 0; - gap: 0; - border: 1px solid var(--border); - border-radius: var(--radius-card); + min-height: 0; + align-content: start; + overflow-y: auto; + overscroll-behavior: contain; + border: 0; + border-radius: 0; background: var(--bg-card); } .ops-stage-tabs { - display: flex; + position: sticky; + z-index: 3; + top: 0; + display: grid; min-width: 0; - gap: var(--space-1); - padding: 0 var(--space-3); + min-height: 64px; + grid-template-columns: repeat(3, minmax(0, 1fr)); border-bottom: 1px solid var(--border); + background: var(--bg-card); + padding: 0 var(--space-4); } .ops-stage-tab { - display: inline-flex; + display: grid; min-width: 0; - height: 38px; - align-items: center; + min-height: 64px; + align-content: center; + gap: 3px; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent; color: var(--text-secondary); cursor: pointer; - font-size: 13px; - font-weight: 800; - padding: 0 var(--space-4); - text-align: center; + padding: var(--space-2) var(--space-3); + text-align: left; transition: background var(--motion-fast) var(--ease-standard), border-color var(--motion-fast) var(--ease-standard), color var(--motion-fast) var(--ease-standard); } +.ops-stage-tab__name { + display: flex; + min-width: 0; + align-items: baseline; + justify-content: space-between; + gap: var(--space-2); +} + +.ops-stage-tab__name strong { + color: inherit; + font-size: 13px; +} + +.ops-stage-tab__name small, +.ops-stage-tab__metrics { + overflow: hidden; + color: var(--text-tertiary); + font-size: 11px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ops-stage-tab.is-warn .ops-stage-tab__name small { + color: var(--warning); +} + .ops-stage-tab:hover { background: var(--primary-hover); } @@ -316,17 +659,24 @@ body { .ops-stage-stack { display: grid; gap: var(--space-3); - padding: var(--space-4); + padding: 0 var(--space-4) var(--space-6); } .ops-stage-block__head { + position: sticky; + z-index: 2; + top: 64px; display: flex; + min-height: 58px; align-items: center; justify-content: space-between; gap: var(--space-3); + border-bottom: 1px solid var(--border); + background: var(--bg-card); + padding: var(--space-2) 0; } -.ops-stage-block__head > div { +.ops-stage-block__summary { display: flex; flex-wrap: wrap; align-items: center; @@ -340,12 +690,38 @@ body { .ops-stage-thresholds { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: auto repeat(2, minmax(140px, 220px)); + align-items: center; gap: var(--space-3); - padding: var(--space-3); - border: 1px solid var(--border); - border-radius: var(--radius-control); + justify-content: end; + padding: var(--space-1) 0; +} + +.ops-stage-thresholds > span { + color: var(--text-tertiary); + font-size: 12px; + font-weight: 800; +} + +.ops-stage-threshold-rule { + display: grid; + grid-column: 2 / -1; + gap: 2px; + min-width: 0; + border-left: 3px solid var(--primary); background: var(--bg-card-strong, #f8fafc); + padding: var(--space-2) var(--space-3); +} + +.ops-stage-threshold-rule strong { + color: var(--text-primary); + font-size: 13px; +} + +.ops-stage-threshold-rule small { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; } .ops-tier-table { @@ -361,13 +737,13 @@ body { .ops-tier-row { display: grid; box-sizing: border-box; - min-width: 520px; + min-width: 590px; grid-template-columns: - minmax(110px, 1fr) - minmax(110px, 1fr) - minmax(64px, auto) - minmax(64px, auto) - minmax(72px, auto); + minmax(100px, 1fr) + minmax(124px, 1fr) + 96px + 64px + 56px; align-items: center; gap: var(--space-2); } @@ -382,17 +758,94 @@ body { padding: 0 var(--space-3); } +.ops-tier-head span:nth-child(n + 3) { + text-align: center; +} + +/* 奖档表头允许自然换行,说明图标不会在窄弹窗中挤到相邻列。 */ +.ops-tier-head .ops-field-label { + justify-content: center; + line-height: 1.2; + text-align: center; + white-space: normal; +} + .ops-tier-row { border-bottom: 1px solid var(--row-border); - padding: var(--space-2) var(--space-3); + padding: 6px var(--space-3); } .ops-tier-row:last-child { border-bottom: 0; } +.ops-tier-cell { + display: flex; + min-width: 0; + align-items: center; +} + +.ops-tier-cell > .MuiFormControl-root { + width: 100%; +} + +.ops-tier-cell--center { + justify-content: center; +} + +.ops-config-dialog-actions { + min-height: 60px; + flex: 0 0 auto; + border-top: 1px solid var(--border); + background: var(--bg-card); + padding: var(--space-2) var(--space-6); +} + /* ---- 响应式 ---- */ +@media (max-width: 1279px) { + .ops-config-summary { + grid-template-columns: repeat(4, minmax(0, 1fr)); + row-gap: var(--space-2); + } + + .ops-config-dialog-content { + overflow-y: auto !important; + } + + .ops-config-layout { + height: auto; + min-height: 100%; + grid-template-columns: minmax(0, 1fr); + overflow: visible; + } + + .ops-config-params { + min-height: auto; + grid-template-columns: 112px minmax(0, 1fr); + overflow: visible; + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .ops-config-section-nav { + position: sticky; + top: 0; + max-height: 100%; + align-self: start; + } + + .ops-config-section-list, + .ops-stage-designer { + min-height: auto; + overflow: visible; + } + + .ops-stage-designer { + border-top: var(--space-2) solid var(--bg-page); + } +} + @media (max-width: 960px) { .ops-shell { grid-template-columns: 1fr; @@ -403,14 +856,129 @@ body { } .ops-nav, - .ops-kpi-grid, - .ops-config-metrics { + .ops-kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} - .ops-config-layout { +@media (max-width: 767px) { + .MuiDialog-paper.ops-config-dialog-paper { + width: 100vw; + max-width: none; + height: 100dvh; + max-height: 100dvh; + margin: 0; + border-radius: 0; + } + + .ops-config-title { + padding: var(--space-3) var(--space-4) var(--space-2); + } + + .ops-config-summary { + display: flex; + gap: 0; + overflow-x: auto; + padding: 0 var(--space-4) var(--space-2); + } + + .ops-config-metric { + min-width: 124px; + } + + .ops-config-params { grid-template-columns: minmax(0, 1fr); } + + .ops-config-section-nav { + z-index: 4; + display: flex; + max-width: 100vw; + flex-direction: row; + overflow-x: auto; + border-right: 0; + border-bottom: 1px solid var(--border); + padding: var(--space-2); + } + + .ops-config-section-nav strong { + display: none; + } + + .ops-config-section-nav button { + min-width: 72px; + flex: 0 0 auto; + border-left: 0; + border-bottom: 2px solid transparent; + text-align: center; + } + + .ops-config-section-nav button.is-active { + border-bottom-color: var(--primary); + } + + .ops-config-section-list { + padding: var(--space-2) var(--space-3) var(--space-5); + } + + .ops-form-grid { + grid-template-columns: minmax(0, 1fr); + } + + .ops-jackpot-flow { + grid-template-columns: minmax(0, 1fr); + } + + .ops-stage-tabs { + display: flex; + max-width: 100vw; + overflow-x: auto; + padding: 0 var(--space-2); + } + + .ops-stage-tab { + min-width: 180px; + flex: 1 0 180px; + } + + .ops-stage-stack { + padding: 0 var(--space-3) var(--space-5); + } + + .ops-stage-block__head { + align-items: flex-start; + flex-wrap: wrap; + } + + .ops-stage-block__summary { + align-items: flex-start; + flex-direction: column; + gap: var(--space-1); + } + + .ops-stage-thresholds { + grid-template-columns: repeat(2, minmax(0, 1fr)); + justify-content: stretch; + } + + .ops-stage-thresholds > span { + grid-column: 1 / -1; + } + + .ops-stage-threshold-rule { + grid-column: 1 / -1; + } + + /* 小屏保留完整列头并让表格横向滚动,控件同时有阶段+行号标签,避免一列卡片失去字段关系。 */ + .ops-tier-head, + .ops-tier-row { + min-width: 600px; + } + + .ops-config-dialog-actions { + min-height: 56px; + padding: var(--space-2) var(--space-4) max(var(--space-2), env(safe-area-inset-bottom)); + } } @media (max-width: 640px) { @@ -420,21 +988,19 @@ body { .ops-nav, .ops-kpi-grid, - .ops-config-metrics, - .ops-form-grid, - .ops-stage-thresholds, - .ops-tier-row { + .ops-pool-adjustment-metrics { grid-template-columns: 1fr; } - - .ops-tier-head { - display: none; - } } @media (prefers-reduced-motion: reduce) { .ops-nav button, + .ops-config-section-nav button, .ops-stage-tab { transition: none; } + + .ops-config-section-list { + scroll-behavior: auto; + } } diff --git a/src/app/permissions.ts b/src/app/permissions.ts index 1035e51..cbe86f8 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -55,7 +55,7 @@ export const PERMISSIONS = { financeOrderCoinSellerRechargeCreate: "finance-order:coin-seller-recharge:create", financeOrderCoinSellerRechargeVerify: "finance-order:coin-seller-recharge:verify", financeOrderCoinSellerRechargeGrant: "finance-order:coin-seller-recharge:grant", - financeOrderUSDTAddressUpdate: "finance-order:usdt-address:update", + financeOrderUsdtAddressUpdate: "finance-order:usdt-address:update", coinLedgerView: "coin-ledger:view", coinSellerLedgerView: "coin-seller-ledger:view", giftRecordView: "gift-record:view", @@ -192,6 +192,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 90fbe83..2fe5977 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -81,9 +81,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", @@ -887,6 +889,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, @@ -908,6 +917,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 1db833b..87c1e47 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; @@ -4792,6 +4824,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"]; }; @@ -7333,6 +7423,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: { @@ -8254,6 +8353,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;