Merge test into main

This commit is contained in:
zhx 2026-07-16 11:07:18 +08:00
commit 231437f0fb
27 changed files with 3874 additions and 756 deletions

View File

@ -390,6 +390,86 @@
"x-permissions": ["lucky-gift:view"] "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": { "/admin/activity/wheel/config": {
"get": { "get": {
"operationId": "getWheelConfig", "operationId": "getWheelConfig",
@ -9071,6 +9151,16 @@
} }
} }
}, },
"LuckyGiftPoolAdjustmentResponse": {
"description": "奖池水位调整成功或同幂等请求的首次结果",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseLuckyGiftPoolAdjustmentResult"
}
}
}
},
"RechargeBillPageResponse": { "RechargeBillPageResponse": {
"description": "OK", "description": "OK",
"content": { "content": {
@ -9483,6 +9573,115 @@
} }
}, },
"schemas": { "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": { "ApiResponseAdminAppList": {
"allOf": [ "allOf": [
{ {

View File

@ -16,6 +16,7 @@ import {
DATE_PRESETS, DATE_PRESETS,
GRANULARITIES, GRANULARITIES,
createDefaultFilters, createDefaultFilters,
createDefaultTableState,
rangeLabel, rangeLabel,
readStateFromURL, readStateFromURL,
toggleMultiValue, toggleMultiValue,
@ -63,6 +64,7 @@ export function SocialAppIdentity({ app, appCode, appName, className = "", size
export function SocialBiApp() { export function SocialBiApp() {
const initial = useMemo(() => readStateFromURL(), []); const initial = useMemo(() => readStateFromURL(), []);
const [filters, setFilters] = useState(initial.filters); 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 [viewKey, setViewKey] = useState(() => (VIEWS.some((view) => view.key === initial.view) ? initial.view : "overview"));
const data = useSocialBiData(filters, { const data = useSocialBiData(filters, {
includeFunnel: viewKey === "funnel", includeFunnel: viewKey === "funnel",
@ -70,8 +72,8 @@ export function SocialBiApp() {
}); });
useEffect(() => { useEffect(() => {
writeStateToURL(filters, viewKey); writeStateToURL(filters, viewKey, tableState);
}, [filters, viewKey]); }, [filters, tableState, viewKey]);
const updateFilter = useCallback((key, value) => { const updateFilter = useCallback((key, value) => {
setFilters((current) => ({ ...current, [key]: value })); setFilters((current) => ({ ...current, [key]: value }));
@ -81,9 +83,19 @@ export function SocialBiApp() {
setFilters(createDefaultFilters()); setFilters(createDefaultFilters());
}, []); }, []);
const updateTableState = useCallback((patch) => {
setTableState((current) => ({ ...current, ...patch }));
}, []);
const openRequirementSection = useCallback((requirementSection) => {
// DataTableView /
setTableState({ mode: "requirements", requirementSection });
setViewKey("table");
}, []);
const contextValue = useMemo( const contextValue = useMemo(
() => ({ ...data, filters, resetFilters, setViewKey, updateFilter, viewKey }), () => ({ ...data, filters, openRequirementSection, resetFilters, setViewKey, tableState, updateFilter, updateTableState, viewKey }),
[data, filters, resetFilters, updateFilter, viewKey] [data, filters, openRequirementSection, resetFilters, tableState, updateFilter, updateTableState, viewKey]
); );
const ActiveView = (VIEWS.find((view) => view.key === viewKey) || VIEWS[0]).component; const ActiveView = (VIEWS.find((view) => view.key === viewKey) || VIEWS[0]).component;

View File

@ -1,10 +1,20 @@
// 社交 BI v2 的全局筛选状态:日期预设/自定义区间、App 多选、区域多选、运营人员、趋势粒度 // 社交 BI v2 的全局筛选与可下钻视图状态:日期/App/区域/运营人员/趋势粒度,以及数据明细模式
// 并同步到 URL query保证筛选状态可以直接作为链接分享。 // 统一同步到 URL query保证筛选结果和“数据需求”目标分组都可以直接作为链接分享。
import { lastDaysRange, thisMonthRange, todayRange } from "../utils/time.js"; import { lastDaysRange, thisMonthRange, todayRange } from "../utils/time.js";
export const ALL = "all"; 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 = [ export const DATE_PRESETS = [
{ key: "today", label: "今日" }, { key: "today", label: "今日" },
{ key: "yesterday", label: "昨日" }, { key: "yesterday", label: "昨日" },
@ -117,11 +127,12 @@ export function bucketDay(statDay, granularity) {
return statDay; 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) { export function readStateFromURL(location = window.location) {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const filters = createDefaultFilters(); const filters = createDefaultFilters();
const table = createDefaultTableState();
let view = ""; let view = "";
if (params.get("preset") && DATE_PRESETS.some((item) => item.key === params.get("preset"))) { if (params.get("preset") && DATE_PRESETS.some((item) => item.key === params.get("preset"))) {
filters.preset = params.get("preset"); filters.preset = params.get("preset");
@ -143,16 +154,23 @@ export function readStateFromURL(location = window.location) {
if (params.get("view")) { if (params.get("view")) {
view = 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) { if (!filters.apps.length) {
filters.apps = [ALL]; filters.apps = [ALL];
} }
if (!filters.regions.length) { if (!filters.regions.length) {
filters.regions = [ALL]; 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); const params = new URLSearchParams(location.search);
URL_KEYS.forEach((key) => params.delete(key)); URL_KEYS.forEach((key) => params.delete(key));
if (filters.preset !== "yesterday") { if (filters.preset !== "yesterday") {
@ -181,6 +199,13 @@ export function writeStateToURL(filters, view, history = window.history, locatio
if (view && view !== "overview") { if (view && view !== "overview") {
params.set("view", view); 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(); const query = params.toString();
history.replaceState(null, "", `${location.pathname}${query ? `?${query}` : ""}`); history.replaceState(null, "", `${location.pathname}${query ? `?${query}` : ""}`);
} }

View File

@ -477,16 +477,18 @@ export function DataTableView() {
range, range,
refreshToken, refreshToken,
requirements, requirements,
requirementsError requirementsError,
tableState,
updateTableState
} = useSocialBi(); } = useSocialBi();
const [mode, setMode] = useState("wide");
const [dimKey, setDimKey] = useState("appDaily"); const [dimKey, setDimKey] = useState("appDaily");
const [closedGroups, setClosedGroups] = useState(readClosedGroups); const [closedGroups, setClosedGroups] = useState(readClosedGroups);
const [sort, setSort] = useState(null); const [sort, setSort] = useState(null);
const [requirementSection, setRequirementSection] = useState(REQUIREMENT_SECTIONS[0].key);
const [requirementRole, setRequirementRole] = useState("all"); const [requirementRole, setRequirementRole] = useState("all");
const [requirementPayer, setRequirementPayer] = useState("all"); const [requirementPayer, setRequirementPayer] = useState("all");
const [requirementNewUser, setRequirementNewUser] = 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 dim = DIMENSIONS.find((item) => item.key === dimKey) || DIMENSIONS[0];
const dimColumns = dim.dims.map((name) => DIM_COLUMNS[name]); const dimColumns = dim.dims.map((name) => DIM_COLUMNS[name]);
@ -575,7 +577,7 @@ export function DataTableView() {
const handleRequirementSectionChange = (key) => { const handleRequirementSectionChange = (key) => {
const nextSection = requirementSectionConfig(key); const nextSection = requirementSectionConfig(key);
setRequirementSection(nextSection.key); updateTableState({ requirementSection: nextSection.key });
if (!nextSection.roleFilter) { if (!nextSection.roleFilter) {
setRequirementRole("all"); setRequirementRole("all");
} }
@ -889,7 +891,7 @@ export function DataTableView() {
aria-checked={mode === item.key} aria-checked={mode === item.key}
className={mode === item.key ? "is-active" : ""} className={mode === item.key ? "is-active" : ""}
key={item.key} key={item.key}
onClick={() => setMode(item.key)} onClick={() => updateTableState({ mode: item.key })}
role="radio" role="radio"
type="button" type="button"
> >

View File

@ -174,7 +174,7 @@ function OverviewSkeleton() {
} }
export function OverviewView() { 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 [trendMetric, setTrendMetric] = useState("recharge_usd_minor");
const [compareMetric, setCompareMetric] = useState("recharge_usd_minor"); const [compareMetric, setCompareMetric] = useState("recharge_usd_minor");
@ -332,20 +332,26 @@ export function OverviewView() {
<strong>{trendLabel}趋势</strong> <strong>{trendLabel}趋势</strong>
<small> App 堆叠</small> <small> App 堆叠</small>
<div className="sbi-card-toolbar"> <div className="sbi-card-toolbar">
<div className="sbi-seg" role="radiogroup" aria-label="趋势指标"> <div className="sbi-seg" role="group" aria-label="趋势指标与明细入口">
{TREND_METRICS.map((item) => ( {TREND_METRICS.map((item) => (
<button <button
aria-checked={trendMetric === item.key} aria-pressed={trendMetric === item.key}
className={trendMetric === item.key ? "is-active" : ""} className={trendMetric === item.key ? "is-active" : ""}
key={item.key} key={item.key}
onClick={() => setTrendMetric(item.key)} onClick={() => setTrendMetric(item.key)}
role="radio"
title={metricTooltip(item.key)} title={metricTooltip(item.key)}
type="button" type="button"
> >
{item.label} {item.label}
</button> </button>
))} ))}
<button
onClick={() => openRequirementSection("game")}
title="查看数据需求中的游戏数据"
type="button"
>
游戏流水
</button>
</div> </div>
</div> </div>
</header> </header>

View File

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

View File

@ -1,13 +1,17 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, expect, test, vi } from "vitest"; import { beforeEach, expect, test, vi } from "vitest";
import { PERMISSIONS } from "@/app/permissions";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; 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"; import { initialOpsCenterView, OpsCenterApp } from "./OpsCenterApp.jsx";
vi.mock("./api.js", () => ({ vi.mock("./api.js", () => ({
DEFAULT_POOL_ID: "default", DEFAULT_POOL_ID: "default",
adjustLuckyGiftPool: vi.fn(),
fetchLuckyGiftDraws: vi.fn(), fetchLuckyGiftDraws: vi.fn(),
fetchOpsDashboard: 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(), saveLuckyGiftConfig: vi.fn(),
})); }));
@ -15,6 +19,10 @@ beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
window.history.replaceState({}, "", "/"); window.history.replaceState({}, "", "/");
saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" }); 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({ fetchLuckyGiftDraws.mockResolvedValue({
items: [ items: [
{ {
@ -62,6 +70,14 @@ beforeEach(() => {
pool_id: "lucky", pool_id: "lucky",
rule_version: 3, rule_version: 3,
}, },
{
...mockFixedConfig(95),
app_code: "lalu",
enabled: false,
is_default: false,
pool_id: "lucky",
rule_version: 2,
},
{ {
...mockFixedConfig(92), ...mockFixedConfig(92),
app_code: "lalu", app_code: "lalu",
@ -84,9 +100,24 @@ beforeEach(() => {
app_code: "lalu", app_code: "lalu",
available_balance: 37950017, available_balance: 37950017,
balance: 38559017, balance: 38559017,
manual_credit_total: 1_000_000,
manual_debit_total: 200_000,
materialized: true, materialized: true,
pool_id: "lucky", pool_id: "lucky",
reserve_floor: 609000, 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, updated_at_ms: 1767000000000,
}, },
{ {
@ -96,6 +127,7 @@ beforeEach(() => {
materialized: false, materialized: false,
pool_id: "default", pool_id: "default",
reserve_floor: 21000, reserve_floor: 21000,
strategy_version: "dynamic_v3",
}, },
], ],
summary: { summary: {
@ -115,10 +147,12 @@ beforeEach(() => {
}); });
}); });
function renderApp() { const allPermissions = [PERMISSIONS.luckyGiftPoolCredit, PERMISSIONS.luckyGiftPoolDebit, PERMISSIONS.luckyGiftUpdate];
function renderApp(permissions = allPermissions) {
return render( return render(
<ToastProvider> <ToastProvider>
<OpsCenterApp /> <OpsCenterApp can={(code) => permissions.includes(code)} />
</ToastProvider>, </ToastProvider>,
); );
} }
@ -137,7 +171,7 @@ test("renders overview with aggregated kpis and pool balances", async () => {
expect(screen.getAllByText("抽奖次数").length).toBeGreaterThan(0); expect(screen.getAllByText("抽奖次数").length).toBeGreaterThan(0);
expect(screen.getAllByText("38,941,517").length).toBeGreaterThan(0); expect(screen.getAllByText("38,941,517").length).toBeGreaterThan(0);
expect(screen.getByText("奖池实时水位")).toBeTruthy(); expect(screen.getByText("奖池实时水位")).toBeTruthy();
expect(screen.getByText("已入账")).toBeTruthy(); expect(screen.getAllByText("已入账").length).toBeGreaterThan(1);
expect(screen.getByText("默认水位")).toBeTruthy(); expect(screen.getByText("默认水位")).toBeTruthy();
expect(fetchOpsDashboard).toHaveBeenCalledTimes(1); expect(fetchOpsDashboard).toHaveBeenCalledTimes(1);
}); });
@ -150,7 +184,7 @@ test("opens the requested config view from the legacy admin document redirect",
renderApp(); renderApp();
expect(await screen.findByRole("button", { name: "添加配置" })).toBeTruthy(); 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 () => { 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: /幸运礼物配置/ })); fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
// lalu lucky / super_lucky // 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("super_lucky")).toBeTruthy();
expect(screen.getByText("v3")).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.getByText("已停用")).toBeTruthy(); expect(screen.getAllByText("已停用").length).toBeGreaterThan(1);
expect(screen.getByText("默认草稿")).toBeTruthy(); 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 () => { test("creates a disabled dynamic draft without copying another app monetary limits", async () => {
renderApp(); renderApp();
await screen.findByText("运营应用"); 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"); const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("添加幸运礼物配置")).toBeTruthy(); expect(within(dialog).getByText("添加幸运礼物配置")).toBeTruthy();
expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3"); expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3");
expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("1000000"); expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("0");
// lalu 100 App // lalu 100 App
expect(within(dialog).getByLabelText(/单次返奖上限/).value).toBe("0"); expect(within(dialog).getByLabelText(/单次返奖上限/).value).toBe("0");
expect(within(dialog).getByText("发布后保持停用")).toBeTruthy(); 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("运营应用"); await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); 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"); const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("新增幸运礼物版本")).toBeTruthy(); expect(within(dialog).getByText("新增幸运礼物版本")).toBeTruthy();
expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3"); expect(within(dialog).getByLabelText("策略版本").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).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.change(within(dialog).getByLabelText(/目标 RTP \(%\)/), { target: { value: "88" } });
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" })); 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({ expect.objectContaining({
app_code: "lalu", app_code: "lalu",
anchor_rate_percent: 1, anchor_rate_percent: 1,
initial_pool_coins: 0,
jackpot_multipliers: [200, 500, 1000], jackpot_multipliers: [200, 500, 1000],
pool_id: "lucky", pool_id: "lucky",
profit_rate_percent: 1, profit_rate_percent: 1,
@ -222,18 +335,18 @@ test("blocks an invalid enabled dynamic rule before calling the save API", async
renderApp(); renderApp();
await screen.findByText("运营应用"); await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); 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"); 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: "保存配置" })); 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(); 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(); expect(within(dialog).queryByRole("alert")).toBeNull();
}); });
@ -247,7 +360,7 @@ function mockDynamicConfig() {
gift_price_reference: 100, gift_price_reference: 100,
high_water_nonzero_factor_percent: 130, high_water_nonzero_factor_percent: 130,
high_watermark_coins: 20_000_000, high_watermark_coins: 20_000_000,
initial_pool_coins: 1_000_000, initial_pool_coins: 0,
jackpot_global_rtp_max_percent: 98, jackpot_global_rtp_max_percent: 98,
jackpot_multipliers: [200, 500, 1000], jackpot_multipliers: [200, 500, 1000],
jackpot_spend_threshold_coins: 50_000, jackpot_spend_threshold_coins: 50_000,

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import { getAccessToken } from "@/shared/api/request"; import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import { apiRequest } from "@/shared/api/request";
export const OPS_API_PREFIX = "/api/v1/admin/ops-center"; export const OPS_API_PREFIX = "/api/v1/admin/ops-center";
export const DEFAULT_POOL_ID = "default"; 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 } = {}) { export async function adjustLuckyGiftPool({
const response = await fetch(buildOpsUrl(path, query), { adjustmentId,
body: body === undefined ? undefined : JSON.stringify(body), amountCoins,
credentials: "include", appCode,
headers: buildHeaders(body), direction,
method, poolId,
}); reason,
const payload = await readPayload(response); strategyVersion,
} = {}) {
if (!response.ok || payload.code !== 0) { if (direction !== "in" && direction !== "out") {
throw new Error(payload.message || response.statusText || "运营接口请求失败"); 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 = {}) { export function buildOpsUrl(path, query = {}) {
@ -118,16 +149,40 @@ export function buildOpsUrl(path, query = {}) {
export function normalizeDashboard({ apps = [], perApp = [] } = {}) { export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []); const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []);
const poolBalances = perApp const normalizedPoolBalances = perApp
.flatMap((entry) => entry.poolBalances || []) .flatMap((entry) => (entry.poolBalances || []).map((item) => normalizePoolBalance(item, entry.appCode)))
.filter((item) => item?.app_code || item?.appCode); .filter((item) => item.app_code);
const appSummaries = perApp const appSummaries = perApp
.map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode)) .map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode))
.filter((summary) => summary.app_code); .filter((summary) => summary.app_code);
const publishedConfigs = luckyGifts.filter((config) => !config.is_default); 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 = { const summary = {
activeApps: apps.length, activeApps: apps.length,
configuredPools: publishedConfigs.length, configuredPools: publishedConfigs.length,
@ -135,9 +190,9 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
failedDraws: sumBy(appSummaries, "failed_draws"), failedDraws: sumBy(appSummaries, "failed_draws"),
grantedDraws: sumBy(appSummaries, "granted_draws"), grantedDraws: sumBy(appSummaries, "granted_draws"),
pendingDraws: sumBy(appSummaries, "pending_draws"), pendingDraws: sumBy(appSummaries, "pending_draws"),
poolAvailableTotal: sumBy(poolBalances, "available_balance", "availableBalance"), poolAvailableTotal: sumBy(currentPoolBalances, "available_balance", "availableBalance"),
poolBalanceTotal: sumBy(poolBalances, "balance", "Balance"), poolBalanceTotal: sumBy(currentPoolBalances, "balance", "Balance"),
poolReserveTotal: sumBy(poolBalances, "reserve_floor", "reserveFloor"), poolReserveTotal: sumBy(currentPoolBalances, "reserve_floor", "reserveFloor"),
totalDraws: sumBy(appSummaries, "total_draws"), totalDraws: sumBy(appSummaries, "total_draws"),
totalRewardCoins: sumBy(appSummaries, "total_reward_coins"), totalRewardCoins: sumBy(appSummaries, "total_reward_coins"),
totalSpentCoins: sumBy(appSummaries, "total_spent_coins"), totalSpentCoins: sumBy(appSummaries, "total_spent_coins"),
@ -146,6 +201,45 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
return { apps, appSummaries, luckyGifts, poolBalances, summary }; 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 = "") { function normalizeDrawSummary(summary = {}, appCode = "") {
return { return {
actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm), actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm),
@ -192,12 +286,39 @@ function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") {
pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID, pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID,
rule_version: ruleVersion, rule_version: ruleVersion,
// 升级前规则没有 strategy_versionlucky-gift owner 明确按 fixed_v2 解释;列表和编辑表单必须沿用该兼容语义。 // 升级前规则没有 strategy_versionlucky-gift owner 明确按 fixed_v2 解释;列表和编辑表单必须沿用该兼容语义。
strategy_version: String(config.strategy_version || config.strategyVersion || "fixed_v2") strategy_version: normalizeStrategyVersion(config.strategy_version ?? config.strategyVersion),
.trim()
.toLowerCase(),
}; };
} }
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 = {}) { function luckyGiftConfigPayload(config = {}) {
const appCode = String(config.app_code || config.appCode || "") const appCode = String(config.app_code || config.appCode || "")
.trim() .trim()
@ -222,7 +343,9 @@ function luckyGiftConfigPayload(config = {}) {
config.high_water_nonzero_factor_percent ?? config.highWaterNonzeroFactorPercent, config.high_water_nonzero_factor_percent ?? config.highWaterNonzeroFactorPercent,
), ),
high_watermark_coins: numberValue(config.high_watermark_coins ?? config.highWatermarkCoins), 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( jackpot_global_rtp_max_percent: numberValue(
config.jackpot_global_rtp_max_percent ?? config.jackpotGlobalRTPMaxPercent, config.jackpot_global_rtp_max_percent ?? config.jackpotGlobalRTPMaxPercent,
), ),
@ -230,11 +353,21 @@ function luckyGiftConfigPayload(config = {}) {
jackpot_spend_threshold_coins: numberValue( jackpot_spend_threshold_coins: numberValue(
config.jackpot_spend_threshold_coins ?? config.jackpotSpendThresholdCoins, config.jackpot_spend_threshold_coins ?? config.jackpotSpendThresholdCoins,
), ),
jackpot_user_72h_rtp_max_percent: numberValue( jackpot_user_48h_rtp_max_percent: numberValue(
config.jackpot_user_72h_rtp_max_percent ?? config.jackpotUser72hRTPMaxPercent, 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( jackpot_user_round_rtp_max_percent: numberValue(
config.jackpot_user_day_rtp_max_percent ?? config.jackpotUserDayRTPMaxPercent, 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), loss_streak_guarantee: numberValue(config.loss_streak_guarantee ?? config.lossStreakGuarantee),
low_water_nonzero_factor_percent: numberValue( 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) { function normalizeItems(value) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value; return value;

View File

@ -1,8 +1,22 @@
import { afterEach, expect, test, vi } from "vitest"; 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(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
setAccessToken("");
setRefreshHandler(null);
setUnauthorizedHandler(null);
}); });
function jsonResponse(data) { 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"); 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 () => { test("loads every pool config per app through the plural configs endpoint", async () => {
const calls = []; const calls = [];
vi.spyOn(window, "fetch").mockImplementation(async (url) => { 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, gift_price_reference: 100,
high_water_nonzero_factor_percent: 130, high_water_nonzero_factor_percent: 130,
high_watermark_coins: 20_000_000, high_watermark_coins: 20_000_000,
initial_pool_coins: 1_000_000, initial_pool_coins: 0,
jackpot_global_rtp_max_percent: 98, jackpot_global_rtp_max_percent: 98,
jackpot_multipliers: [200, 500, 1000], jackpot_multipliers: [200, 500, 1000],
jackpot_spend_threshold_coins: 50_000, jackpot_spend_threshold_coins: 50_000,
jackpot_user_72h_rtp_max_percent: 96, jackpot_user_48h_rtp_max_percent: 96,
jackpot_user_day_rtp_max_percent: 96, jackpot_user_round_rtp_max_percent: 96,
loss_streak_guarantee: 5, loss_streak_guarantee: 5,
low_water_nonzero_factor_percent: 70, low_water_nonzero_factor_percent: 70,
low_watermark_coins: 10_000_000, 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, user_hourly_payout_cap: 34_200,
}); });
}); });
test("keeps fixed_v2 and dynamic_v3 balances independent for the same app and pool", () => {
const data = normalizeDashboard({
apps: [{ app_code: "lalu" }],
perApp: [
{
appCode: "lalu",
poolBalances: [
{
availableBalance: 700,
balance: 900,
manualCreditTotal: 100,
manualDebitTotal: 20,
poolId: "lucky",
reserveFloor: 200,
strategyVersion: "fixed_v2",
},
{
available_balance: 3_000,
balance: 4_000,
manual_credit_total: 2_000,
manual_debit_total: 500,
pool_id: "lucky",
reserve_floor: 1_000,
strategy_version: "dynamic_v3",
},
],
},
],
});
expect(data.poolBalances).toHaveLength(2);
expect(new Set(data.poolBalances.map(luckyGiftPoolKey))).toEqual(
new Set(["lalu:lucky:fixed_v2", "lalu:lucky:dynamic_v3"]),
);
expect(data.poolBalances[0]).toMatchObject({ manual_credit_total: 100, manual_debit_total: 20 });
expect(data.poolBalances[1]).toMatchObject({ manual_credit_total: 2_000, manual_debit_total: 500 });
expect(data.summary.poolBalanceTotal).toBe(4_900);
});
test.each([
["in", "credit"],
["out", "debit"],
])("posts %s pool adjustments with the strategy identity and idempotency key", async (direction, route) => {
const calls = [];
vi.spyOn(window, "fetch").mockImplementation(async (url, init) => {
calls.push({ body: JSON.parse(init.body), method: init.method, url: String(url) });
return jsonResponse({
adjustment: {
adjustment_id: "adjust-1",
amount_coins: 1_000,
balance_after: direction === "out" ? 9_000 : 11_000,
balance_before: 10_000,
strategy_version: "dynamic_v3",
},
idempotent_replay: false,
pool: {
app_code: "lalu",
available_balance: 8_000,
balance: direction === "out" ? 9_000 : 11_000,
manual_credit_total: direction === "in" ? 1_000 : 0,
manual_debit_total: direction === "out" ? 1_000 : 0,
materialized: true,
pool_id: "lucky",
reserve_floor: 1_000,
strategy_version: "dynamic_v3",
},
});
});
const result = await adjustLuckyGiftPool({
adjustmentId: "adjust-1",
amountCoins: 1_000,
appCode: "LALU",
direction,
poolId: "lucky",
reason: " 运营调节 ",
strategyVersion: "dynamic_v3",
});
expect(calls).toEqual([
{
body: { adjustment_id: "adjust-1", amount_coins: 1_000, reason: "运营调节" },
method: "POST",
url: `http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools/${route}?app_code=lalu&pool_id=lucky&strategy_version=dynamic_v3`,
},
]);
expect(result).toMatchObject({
adjustment: { balance_before: 10_000, strategy_version: "dynamic_v3" },
pool: { app_code: "lalu", pool_id: "lucky", strategy_version: "dynamic_v3" },
});
});

View File

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

View File

@ -1,16 +1,9 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import Checkbox from "@mui/material/Checkbox";
import Dialog from "@mui/material/Dialog"; import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions"; import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent"; import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle"; import DialogTitle from "@mui/material/DialogTitle";
import FormControlLabel from "@mui/material/FormControlLabel"; import { useEffect, useMemo, useRef, useState } from "react";
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 { Button } from "@/shared/ui/Button.jsx"; import { Button } from "@/shared/ui/Button.jsx";
import { formatDecimal } from "@/shared/utils/rtpProbability.js";
import { import {
applyDynamicStrategyDefaults, applyDynamicStrategyDefaults,
configToForm, configToForm,
@ -21,12 +14,14 @@ import {
validateLuckyGiftForm, validateLuckyGiftForm,
} from "../configForm.js"; } from "../configForm.js";
import { formatNumber, formatPercent } from "../format.js"; import { formatNumber, formatPercent } from "../format.js";
import { OpsStatusBadge } from "./OpsStatusBadge.jsx"; import { LuckyGiftConfigSections } from "./LuckyGiftConfigSections.jsx";
import { LuckyGiftStageDesigner } from "./LuckyGiftStageDesigner.jsx";
export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) { export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) {
const [form, setForm] = useState(() => configToForm(config)); const [form, setForm] = useState(() => configToForm(config));
const [activeStage, setActiveStage] = useState("novice"); const [activeStage, setActiveStage] = useState("novice");
const [validationErrors, setValidationErrors] = useState([]); const [validationErrors, setValidationErrors] = useState([]);
const validationRef = useRef(null);
const stageSummaries = useMemo(() => form.stages.map((stage) => stageSummary(stage, form)), [form]); const stageSummaries = useMemo(() => form.stages.map((stage) => stageSummary(stage, form)), [form]);
const activeStageKey = form.stages.some((stage) => stage.stage === activeStage) const activeStageKey = form.stages.some((stage) => stage.stage === activeStage)
@ -34,6 +29,13 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
: form.stages[0]?.stage; : form.stages[0]?.stage;
const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey); const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey);
const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey); const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey);
const normalStageConfig = form.stages.find((stage) => stage.stage === "normal");
useEffect(() => {
if (!validationErrors.length) return;
//
validationRef.current?.focus();
}, [validationErrors]);
const updateField = (field, value) => { const updateField = (field, value) => {
setValidationErrors([]); setValidationErrors([]);
@ -87,7 +89,8 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
...stage.tiers, ...stage.tiers,
{ {
enabled: true, enabled: true,
// 10 穿线 // 10
//
highWaterOnly: nextStageMultiplier(stage.tiers) >= 10, highWaterOnly: nextStageMultiplier(stage.tiers) >= 10,
multiplier: String(nextStageMultiplier(stage.tiers)), multiplier: String(nextStageMultiplier(stage.tiers)),
probabilityPercent: "0", probabilityPercent: "0",
@ -129,31 +132,51 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本"; const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本";
return ( return (
<Dialog fullWidth maxWidth="lg" open onClose={saving ? undefined : onClose}> <Dialog
aria-labelledby="ops-config-dialog-title"
fullWidth
maxWidth={false}
open
scroll="paper"
slotProps={{ paper: { className: "ops-config-dialog-paper" } }}
onClose={saving ? undefined : onClose}
>
<form className="ops-config-form" onSubmit={handleSubmit}> <form className="ops-config-form" onSubmit={handleSubmit}>
<DialogTitle className="ops-config-title"> <DialogTitle className="ops-config-title" id="ops-config-dialog-title">
<span>{title}</span> <span>{title}</span>
<small> <small>
{form.app_code || "-"} / {form.pool_id || "default"} /{" "} {form.app_code || "-"} / {form.pool_id || "default"} /{" "}
{config.is_default || isCreate ? "默认草稿" : `当前版本 v${config.rule_version ?? "-"}`} {config.is_default || isCreate ? "默认草稿" : `当前版本 v${config.rule_version ?? "-"}`}
</small> </small>
</DialogTitle> </DialogTitle>
<DialogContent dividers> <dl className="ops-config-summary" aria-label="配置摘要">
<div className="ops-config-metrics"> <ConfigMetric label="应用" value={form.app_code || "-"} />
<ConfigMetric label="应用" value={form.app_code || "-"} /> <ConfigMetric label="奖池" value={form.pool_id || "default"} />
<ConfigMetric label="奖池" value={form.pool_id || "default"} /> <ConfigMetric label="开奖策略" value={form.strategy_version} />
<ConfigMetric label="策略" value={form.strategy_version} /> <ConfigMetric label="基础返还目标" value={formatPercent(Number(form.target_rtp_percent))} />
<ConfigMetric label="目标 RTP" value={formatPercent(Number(form.target_rtp_percent))} /> <ConfigMetric
<ConfigMetric label={form.strategy_version === "dynamic_v3" ? "资金拆分" : "奖池比例"}
label="资金拆分" value={
value={`${form.pool_rate_percent}% / ${form.profit_rate_percent}% / ${form.anchor_rate_percent}%`} form.strategy_version === "dynamic_v3"
/> ? `${form.pool_rate_percent}% / ${form.profit_rate_percent}% / ${form.anchor_rate_percent}%`
<ConfigMetric label="结算窗口" value={formatNumber(Number(form.settlement_window_wager))} /> : `${form.pool_rate_percent}%`
<ConfigMetric label="状态" value={form.enabled ? "启用" : "停用"} /> }
</div> />
<ConfigMetric
label={form.strategy_version === "dynamic_v3" ? "每轮观察流水" : "V2 窗口配置"}
value={formatNumber(Number(form.settlement_window_wager))}
/>
<ConfigMetric label="发布状态" value={form.enabled ? "启用" : "停用"} />
</dl>
<DialogContent className="ops-config-dialog-content" dividers>
{validationErrors.length ? ( {validationErrors.length ? (
<div className="ops-config-validation" role="alert"> <div
aria-live="assertive"
className="ops-config-validation"
ref={validationRef}
role="alert"
tabIndex={-1}
>
<strong>配置不能发布</strong> <strong>配置不能发布</strong>
<ul> <ul>
{validationErrors.map((message) => ( {validationErrors.map((message) => (
@ -164,268 +187,28 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
) : null} ) : null}
<div className="ops-config-layout"> <div className="ops-config-layout">
<aside className="ops-config-params" aria-label="基础参数"> <LuckyGiftConfigSections
<section className="ops-config-section"> appOptions={appOptions}
<h3>规则身份</h3> form={form}
{isCreate ? ( isCreate={isCreate}
<TextField onChange={updateField}
fullWidth />
required <LuckyGiftStageDesigner
label="应用" activeStageConfig={activeStageConfig}
select activeStageKey={activeStageKey}
size="small" activeSummary={activeSummary}
value={form.app_code} normalStageConfig={normalStageConfig}
onChange={(event) => updateField("app_code", event.target.value)} stageSummaries={stageSummaries}
> strategyVersion={form.strategy_version}
{appOptions.map(([value, label]) => ( onAddTier={addStageTier}
<MenuItem key={value} value={value}> onRemoveTier={removeStageTier}
{label} onStageChange={setActiveStage}
</MenuItem> onStageFieldChange={updateStageField}
))} onTierChange={updateStageTier}
</TextField> />
) : (
<TextField disabled fullWidth label="应用" size="small" value={form.app_code} />
)}
<TextField
fullWidth
required
helperText="修改奖池 ID 会发布到新的奖池,不影响原奖池"
label="奖池"
size="small"
value={form.pool_id}
onChange={(event) => updateField("pool_id", event.target.value)}
/>
<TextField
fullWidth
label="策略版本"
select
size="small"
value={form.strategy_version}
onChange={(event) => updateField("strategy_version", event.target.value)}
>
<MenuItem value="dynamic_v3">dynamic_v3</MenuItem>
<MenuItem value="fixed_v2">fixed_v2历史兼容</MenuItem>
</TextField>
<FormControlLabel
control={
<AdminSwitch
checked={form.enabled}
onChange={(event) => updateField("enabled", event.target.checked)}
/>
}
label={form.enabled ? "发布后立即启用" : "发布后保持停用"}
sx={{ gap: 1, marginLeft: 0 }}
/>
</section>
<section className="ops-config-section">
<h3>RTP 与奖池</h3>
<div className="ops-form-grid">
<NumberField
form={form}
label="目标 RTP (%)"
name="target_rtp_percent"
step="0.01"
onChange={updateField}
/>
<NumberField
form={form}
label="公共奖池 (%)"
name="pool_rate_percent"
step="0.01"
onChange={updateField}
/>
<NumberField
form={form}
label="平台盈利 (%)"
name="profit_rate_percent"
step="0.01"
onChange={updateField}
/>
<NumberField
form={form}
helperText="必须与该奖池实际送礼类型lucky / super_lucky在各适用地区的返币比例一致否则开奖账务校验会失败"
label="主播收益 (%)"
name="anchor_rate_percent"
step="0.01"
onChange={updateField}
/>
<NumberField
form={form}
label="控制带宽 (%)"
name="control_band_percent"
step="0.01"
onChange={updateField}
/>
<NumberField
form={form}
label="结算窗口流水"
name="settlement_window_wager"
onChange={updateField}
/>
<NumberField
form={form}
label="礼物参考金额"
name="gift_price_reference"
onChange={updateField}
/>
<NumberField
form={form}
label="新手等价抽数"
name="novice_max_equivalent_draws"
onChange={updateField}
/>
<NumberField
form={form}
label="普通等价抽数"
name="normal_max_equivalent_draws"
onChange={updateField}
/>
</div>
</section>
{form.strategy_version === "dynamic_v3" ? (
<DynamicStrategySections form={form} onChange={updateField} />
) : null}
</aside>
<section className="ops-stage-designer" aria-label="阶段奖档设计">
<div className="ops-stage-tabs" role="tablist" aria-label="阶段奖档">
{stageSummaries.map((summary) => (
<button
aria-selected={summary.stageKey === activeStageKey}
className={`ops-stage-tab ${summary.stageKey === activeStageKey ? "is-active" : ""} ${summary.ok ? "is-ok" : "is-warn"}`}
key={summary.stageKey}
role="tab"
type="button"
onClick={() => setActiveStage(summary.stageKey)}
>
<span>{summary.tabLabel}</span>
</button>
))}
</div>
{activeStageConfig ? (
<div className="ops-stage-stack">
<div className="ops-stage-block__head">
<div>
<h3>{activeSummary?.stageLabel}</h3>
<OpsStatusBadge
label={`${activeSummary?.status} · 概率 ${formatDecimal(activeSummary?.probability)}% · 期望 RTP ${formatDecimal(activeSummary?.expected)}%`}
tone={activeSummary?.ok ? "succeeded" : "warning"}
/>
</div>
<Button
startIcon={<AddOutlined fontSize="small" />}
onClick={() => addStageTier(activeStageConfig.stage)}
>
添加奖档
</Button>
</div>
{form.strategy_version === "dynamic_v3" ? (
<div className="ops-stage-thresholds">
<TextField
disabled={activeStageConfig.stage === "novice"}
label="7 日充值门槛"
size="small"
slotProps={{ htmlInput: { min: 0, step: 1 } }}
type="number"
value={activeStageConfig.min_recharge_7d_coins}
onChange={(event) =>
updateStageField(
activeStageConfig.stage,
"min_recharge_7d_coins",
event.target.value,
)
}
/>
<TextField
disabled={activeStageConfig.stage === "novice"}
label="30 日充值门槛"
size="small"
slotProps={{ htmlInput: { min: 0, step: 1 } }}
type="number"
value={activeStageConfig.min_recharge_30d_coins}
onChange={(event) =>
updateStageField(
activeStageConfig.stage,
"min_recharge_30d_coins",
event.target.value,
)
}
/>
</div>
) : null}
<div className="ops-tier-table">
<div className="ops-tier-head" aria-hidden="true">
<span>倍率</span>
<span>概率 %</span>
<span>高水位</span>
<span>启用</span>
<span>操作</span>
</div>
{activeStageConfig.tiers.map((tier, index) => (
<div className="ops-tier-row" key={`${activeStageConfig.stage}-${index}`}>
<TextField
required
size="small"
slotProps={{
htmlInput: { "aria-label": "倍率", min: 0, step: "0.01" },
}}
type="number"
value={tier.multiplier}
onChange={(event) =>
updateStageTier(activeStageConfig.stage, index, {
// 10
highWaterOnly:
Number(event.target.value) >= 10 || tier.highWaterOnly,
multiplier: event.target.value,
})
}
/>
{/* 概率由目标 RTP 和倍率统一校正,保持只读可以避免运营手填后绕过服务端概率闭合校验。 */}
<TextField
size="small"
slotProps={{
htmlInput: { "aria-label": "概率 %", readOnly: true },
}}
value={formatDecimal(tier.probabilityPercent)}
/>
<Checkbox
checked={Boolean(tier.highWaterOnly)}
disabled={Number(tier.multiplier) >= 10}
size="small"
slotProps={{ input: { "aria-label": "高水位" } }}
onChange={(event) =>
updateStageTier(activeStageConfig.stage, index, {
highWaterOnly: event.target.checked,
})
}
/>
<Checkbox
checked={tier.enabled !== false}
size="small"
slotProps={{ input: { "aria-label": "启用" } }}
onChange={(event) =>
updateStageTier(activeStageConfig.stage, index, {
enabled: event.target.checked,
})
}
/>
<Button
disabled={activeStageConfig.tiers.length <= 1}
variant="danger"
onClick={() => removeStageTier(activeStageConfig.stage, index)}
>
删除
</Button>
</div>
))}
</div>
</div>
) : null}
</section>
</div> </div>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions className="ops-config-dialog-actions">
<Button disabled={saving} onClick={onClose}> <Button disabled={saving} onClick={onClose}>
取消 取消
</Button> </Button>
@ -438,129 +221,11 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
); );
} }
function DynamicStrategySections({ form, onChange }) {
return (
<>
<section className="ops-config-section">
<h3>动态水位</h3>
<div className="ops-form-grid">
<NumberField form={form} label="初始奖池金币" name="initial_pool_coins" onChange={onChange} />
<NumberField form={form} label="连续未中奖保护" name="loss_streak_guarantee" onChange={onChange} />
<NumberField form={form} label="低水位金币" name="low_watermark_coins" onChange={onChange} />
<NumberField
form={form}
label="低水位非零因子 (%)"
name="low_water_nonzero_factor_percent"
step="0.01"
onChange={onChange}
/>
<NumberField form={form} label="高水位金币" name="high_watermark_coins" onChange={onChange} />
<NumberField
form={form}
label="高水位非零因子 (%)"
name="high_water_nonzero_factor_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="充值加权窗口 (ms)"
name="recharge_boost_window_ms"
onChange={onChange}
/>
<NumberField
form={form}
label="充值加权因子 (%)"
name="recharge_boost_factor_percent"
step="0.01"
onChange={onChange}
/>
</div>
</section>
<section className="ops-config-section">
<h3>大奖控制</h3>
<div className="ops-form-grid">
<TextField
className="ops-form-grid__wide"
helperText="按从小到大填写,使用逗号分隔"
label="大奖倍率"
required
size="small"
value={form.jackpot_multipliers}
onChange={(event) => onChange("jackpot_multipliers", event.target.value)}
/>
<NumberField
form={form}
label="全局大奖 RTP 上限 (%)"
name="jackpot_global_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="用户每日 RTP 上限 (%)"
name="jackpot_user_day_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="用户 72 小时 RTP 上限 (%)"
name="jackpot_user_72h_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="用户每日大奖次数"
name="max_jackpot_hits_per_user_day"
onChange={onChange}
/>
<NumberField
form={form}
label="50 美元等值消费门槛"
name="jackpot_spend_threshold_coins"
onChange={onChange}
/>
</div>
</section>
<section className="ops-config-section">
<h3>六维返奖上限</h3>
<div className="ops-form-grid">
<NumberField form={form} label="单次返奖上限" name="max_single_payout" onChange={onChange} />
<NumberField form={form} label="用户小时上限" name="user_hourly_payout_cap" onChange={onChange} />
<NumberField form={form} label="用户每日上限" name="user_daily_payout_cap" onChange={onChange} />
<NumberField form={form} label="设备每日上限" name="device_daily_payout_cap" onChange={onChange} />
<NumberField form={form} label="房间小时上限" name="room_hourly_payout_cap" onChange={onChange} />
<NumberField form={form} label="主播每日上限" name="anchor_daily_payout_cap" onChange={onChange} />
</div>
</section>
</>
);
}
function ConfigMetric({ label, value }) { function ConfigMetric({ label, value }) {
return ( return (
<article className="ops-config-metric"> <div className="ops-config-metric">
<span>{label}</span> <dt>{label}</dt>
<strong>{value}</strong> <dd>{value}</dd>
</article> </div>
);
}
function NumberField({ form, helperText, label, name, onChange, step = "1" }) {
return (
<TextField
helperText={helperText}
label={label}
required
size="small"
slotProps={{ htmlInput: { min: 0, step } }}
type="number"
value={form[name]}
onChange={(event) => onChange(name, event.target.value)}
/>
); );
} }

View File

@ -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(
<LuckyGiftConfigDialog
appOptions={[
["lalu", "Lalu"],
["yumi", "Yumi"],
]}
config={config}
mode="edit"
saving={false}
onClose={vi.fn()}
onSubmit={vi.fn()}
{...overrides}
/>,
);
}
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`,
},
],
}));
}

View File

@ -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 (
<aside className="ops-config-params" aria-label="全局参数">
<nav className="ops-config-section-nav" aria-label="全局参数分区">
<strong>全局参数</strong>
{sections.map(([sectionKey, label]) => (
<button
aria-controls={`ops-config-section-${sectionKey}`}
aria-current={activeSection === sectionKey ? "true" : undefined}
className={activeSection === sectionKey ? "is-active" : ""}
key={sectionKey}
type="button"
onClick={() => jumpToSection(sectionKey)}
>
{label}
</button>
))}
</nav>
<div className="ops-config-section-list" ref={scrollRef}>
<section
aria-labelledby="ops-config-heading-identity"
className="ops-config-section"
data-config-section="identity"
id="ops-config-section-identity"
>
<SectionHeading id="ops-config-heading-identity" title="规则身份" />
<div className="ops-form-grid">
{isCreate ? (
<TextField
fullWidth
required
label={<LuckyGiftFieldLabel field="app_code" label="应用" />}
select
size="small"
value={form.app_code}
onChange={(event) => onChange("app_code", event.target.value)}
>
{appOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
) : (
<TextField
disabled
fullWidth
label={<LuckyGiftFieldLabel field="app_code" label="应用" />}
size="small"
value={form.app_code}
/>
)}
<TextField
fullWidth
required
label={<LuckyGiftFieldLabel field="pool_id" label="奖池" />}
size="small"
value={form.pool_id}
onChange={(event) => onChange("pool_id", event.target.value)}
/>
<TextField
fullWidth
label={<LuckyGiftFieldLabel field="strategy_version" label="策略版本" />}
select
size="small"
value={form.strategy_version}
onChange={(event) => onChange("strategy_version", event.target.value)}
>
<MenuItem value="dynamic_v3">dynamic_v3</MenuItem>
<MenuItem value="fixed_v2">fixed_v2历史兼容</MenuItem>
</TextField>
<FormControlLabel
className="ops-config-enabled-field"
control={
<AdminSwitch
checked={form.enabled}
label="发布状态"
onChange={(event) => onChange("enabled", event.target.checked)}
/>
}
label={
<LuckyGiftFieldLabel
field="enabled"
label={form.enabled ? "发布后立即启用" : "发布后保持停用"}
/>
}
/>
</div>
<p className="ops-field-note">修改奖池 ID 会发布到新奖池不影响原奖池</p>
</section>
<section
aria-labelledby="ops-config-heading-funds"
className="ops-config-section"
data-config-section="funds"
id="ops-config-section-funds"
>
<SectionHeading
detail={
form.strategy_version === "dynamic_v3"
? `资金合计 ${formatCompactNumber(fundTotal)}%`
: undefined
}
id="ops-config-heading-funds"
ok={Math.abs(fundTotal - 100) < 0.0001}
title="返奖与资金分配"
/>
<div className="ops-form-grid">
<NumberField
form={form}
label="目标 RTP (%)"
name="target_rtp_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="奖档允许偏差 (%)"
name="control_band_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="进入奖池比例 (%)"
name="pool_rate_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="平台收入比例 (%)"
name="profit_rate_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="收礼对象收益比例 (%)"
name="anchor_rate_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="每轮观察流水"
name="settlement_window_wager"
onChange={onChange}
/>
<NumberField
form={form}
label="V2 折算参考单价"
name="gift_price_reference"
onChange={onChange}
/>
<NumberField
disabled={form.strategy_version === "dynamic_v3"}
form={form}
label="新手阶段上限(折算次数)"
name="novice_max_equivalent_draws"
onChange={onChange}
/>
<NumberField
disabled={form.strategy_version === "dynamic_v3"}
form={form}
label="普通阶段上限(折算次数)"
name="normal_max_equivalent_draws"
onChange={onChange}
/>
</div>
<p className="ops-field-note">
发布前请核对收礼对象收益与实际送礼返币比例不一致时V3 真实开奖会拒绝账务结算
</p>
</section>
{form.strategy_version === "dynamic_v3" ? (
<>
<section
aria-labelledby="ops-config-heading-water"
className="ops-config-section"
data-config-section="water"
id="ops-config-section-water"
>
<SectionHeading id="ops-config-heading-water" title="动态水位" />
<div className="ops-form-grid">
<NumberField
disabled
form={form}
helperText="请通过奖池水位操作注资"
label="初始奖池金币"
name="initial_pool_coins"
onChange={onChange}
/>
<NumberField
form={form}
label="连续未中奖保护次数"
name="loss_streak_guarantee"
onChange={onChange}
/>
<NumberField
form={form}
label="低水位金币"
name="low_watermark_coins"
onChange={onChange}
/>
<NumberField
form={form}
label="低余额中奖权重 (%)"
name="low_water_nonzero_factor_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="高水位金币"
name="high_watermark_coins"
onChange={onChange}
/>
<NumberField
form={form}
label="高余额中奖权重 (%)"
name="high_water_nonzero_factor_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="充值加权窗口 (ms)"
name="recharge_boost_window_ms"
onChange={onChange}
/>
<NumberField
form={form}
label="充值后中奖权重 (%)"
name="recharge_boost_factor_percent"
step="0.01"
onChange={onChange}
/>
</div>
</section>
<section
aria-labelledby="ops-config-heading-jackpot"
className="ops-config-section"
data-config-section="jackpot"
id="ops-config-section-jackpot"
>
<SectionHeading id="ops-config-heading-jackpot" title="大奖触发条件" />
<JackpotFlowSummary />
<div className="ops-form-grid">
<TextField
className="ops-form-grid__wide"
helperText="按从小到大填写,使用逗号分隔"
label={
<LuckyGiftFieldLabel field="jackpot_multipliers" label="大奖倍率(可选倍数)" />
}
required
size="small"
value={form.jackpot_multipliers}
onChange={(event) => onChange("jackpot_multipliers", event.target.value)}
/>
<NumberField
form={form}
label="全局大奖 RTP 上限 (%)"
name="jackpot_global_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="用户本轮返还触发线 (%)"
name="jackpot_user_round_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="用户近 48 小时返还触发线 (%)"
name="jackpot_user_48h_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="每位用户每日大奖次数"
name="max_jackpot_hits_per_user_day"
onChange={onChange}
/>
<NumberField
disabled
form={form}
helperText="仅兼容历史配置,当前规则不参与大奖资格"
label="用户日累计消费触发门槛(金币)— 已停用"
name="jackpot_spend_threshold_coins"
onChange={onChange}
required={false}
/>
</div>
</section>
<section
aria-labelledby="ops-config-heading-limits"
className="ops-config-section"
data-config-section="limits"
id="ops-config-section-limits"
>
<SectionHeading id="ops-config-heading-limits" title="各场景返奖上限" />
<div className="ops-form-grid">
<NumberField
form={form}
label="单次返奖上限"
name="max_single_payout"
onChange={onChange}
/>
<NumberField
form={form}
label="每位用户每小时最高返奖"
name="user_hourly_payout_cap"
onChange={onChange}
/>
<NumberField
form={form}
label="每位用户每日最高返奖"
name="user_daily_payout_cap"
onChange={onChange}
/>
<NumberField
form={form}
label="每台设备每日最高返奖"
name="device_daily_payout_cap"
onChange={onChange}
/>
<NumberField
form={form}
label="每个房间每小时最高返奖"
name="room_hourly_payout_cap"
onChange={onChange}
/>
<NumberField
form={form}
label="主播每日上限"
name="anchor_daily_payout_cap"
onChange={onChange}
/>
</div>
</section>
</>
) : null}
</div>
</aside>
);
}
function SectionHeading({ detail, id, ok = false, title }) {
return (
<div className="ops-config-section__head">
<h3 id={id} tabIndex={-1}>
{title}
</h3>
{detail ? <span className={ok ? "is-ok" : "is-warn"}>{detail}</span> : null}
</div>
);
}
function JackpotFlowSummary() {
return (
<div className="ops-jackpot-flow" aria-label="大奖产生方式">
<div>
<strong>1. 大盘先通过</strong>
<span>本轮大盘 RTP 不高于全局触发线才继续检查用户等于触发线也可通过</span>
</div>
<div>
<strong>2. 用户两项都通过</strong>
<span>用户本轮 RTP 和近 48 小时 RTP 都必须严格低于各自触发线少一项都不能出大奖</span>
</div>
<div>
<strong>3. 下次送礼尝试大奖</strong>
<span>
用户下次送礼时先剔除奖池或限额付不起的倍率再随机选择没有可支付倍率就不出大奖本轮资格也不会顺延或重复使用
</span>
</div>
</div>
);
}
function NumberField({ disabled = false, form, helperText, label, name, onChange, required = true, step = "1" }) {
return (
<TextField
disabled={disabled}
helperText={helperText}
label={<LuckyGiftFieldLabel field={name} label={label} />}
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 row1024px column
// computed style innerWidth
const flexDirection = nav.ownerDocument.defaultView?.getComputedStyle(nav).flexDirection;
return flexDirection === "row" ? nav.getBoundingClientRect().height : 0;
}

View File

@ -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,500RTP 为 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: "不能小于 010 倍及以上必须勾选“高倍率安全标记”,实际支付仍不能超过奖池和返奖上限。",
},
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 (
<Tooltip
arrow
describeChild
enterDelay={120}
leaveDelay={80}
placement="top-start"
slotProps={{ tooltip: { className: "ops-field-help-tooltip" } }}
title={<FieldHelpContent help={help} label={label} />}
>
<span className="ops-field-label" tabIndex={0}>
<span>{label}</span>
<span aria-hidden="true" className="ops-field-help-trigger">
<HelpOutlineOutlined aria-hidden="true" fontSize="inherit" />
</span>
</span>
</Tooltip>
);
}
function FieldHelpContent({ help, label }) {
return (
<div className="ops-field-help-content" role="note">
<strong>{label}</strong>
<dl>
<div>
<dt>作用</dt>
<dd>{help.summary}</dd>
</div>
<div>
<dt>调整影响</dt>
<dd>{help.adjust}</dd>
</div>
<div>
<dt>注意</dt>
<dd>{help.constraint}</dd>
</div>
</dl>
</div>
);
}

View File

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

View File

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

View File

@ -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 (
<section className="ops-stage-designer" aria-label="阶段奖档设计">
<div className="ops-stage-tabs" role="tablist" aria-label="阶段奖档">
{stageSummaries.map((summary, index) => {
const isActive = summary.stageKey === activeStageKey;
return (
<button
aria-controls={`ops-stage-panel-${summary.stageKey}`}
aria-selected={isActive}
className={`ops-stage-tab ${isActive ? "is-active" : ""} ${summary.ok ? "is-ok" : "is-warn"}`}
id={`ops-stage-tab-${summary.stageKey}`}
key={summary.stageKey}
role="tab"
tabIndex={isActive ? 0 : -1}
type="button"
onClick={() => onStageChange(summary.stageKey)}
onKeyDown={(event) => selectStageFromKeyboard(event, index)}
>
<span className="ops-stage-tab__name">
<strong>{summary.tabLabel}</strong>
<small>{plainStageStatus(summary.status)}</small>
</span>
<span className="ops-stage-tab__metrics">
概率 {formatDecimal(summary.probability)}% · 平均返还 {formatDecimal(summary.expected)}%
</span>
</button>
);
})}
</div>
{activeStageConfig ? (
<div
aria-labelledby={`ops-stage-tab-${activeStageKey}`}
className="ops-stage-stack"
id={`ops-stage-panel-${activeStageKey}`}
role="tabpanel"
>
<div className="ops-stage-block__head">
<div className="ops-stage-block__summary">
<h3>
<LuckyGiftFieldLabel
field={`stage_${activeStageKey}`}
label={activeSummary?.stageLabel || activeStageKey}
/>
</h3>
<OpsStatusBadge
label={`${plainStageStatus(activeSummary?.status)} · 概率 ${formatDecimal(activeSummary?.probability)}% · 预计平均返还 ${formatDecimal(activeSummary?.expected)}%`}
tone={activeSummary?.ok ? "succeeded" : "warning"}
/>
</div>
<Button
aria-label={`${activeSummary?.stageLabel || "当前阶段"}添加奖档`}
startIcon={<AddOutlined fontSize="small" />}
onClick={() => onAddTier(activeStageConfig.stage)}
>
添加奖档
</Button>
</div>
{strategyVersion === "dynamic_v3" && activeStageConfig.stage === "novice" ? (
<NoviceRechargeRange normalStageConfig={normalStageConfig} />
) : strategyVersion === "dynamic_v3" ? (
<div className="ops-stage-thresholds" aria-label={`${activeSummary?.stageLabel}充值门槛`}>
<span>进入本阶段所需充值</span>
<TextField
label={
<LuckyGiftFieldLabel field="min_recharge_7d_coins" label="7 日最低充值(金币)" />
}
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,
)
}
/>
<TextField
label={
<LuckyGiftFieldLabel field="min_recharge_30d_coins" label="30 日最低充值(金币)" />
}
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,
)
}
/>
</div>
) : null}
<div className="ops-tier-table" role="table" aria-label={`${activeSummary?.stageLabel}奖档`}>
<div className="ops-tier-head" role="row">
<span role="columnheader">
<LuckyGiftFieldLabel field="tier_multiplier" label="倍率" />
</span>
<span role="columnheader">
<LuckyGiftFieldLabel field="tier_probability" label="基础中奖机会 %" />
</span>
<span role="columnheader">
<LuckyGiftFieldLabel field="tier_high_water_only" label="高倍率安全标记" />
</span>
<span role="columnheader">
<LuckyGiftFieldLabel field="tier_enabled" label="启用" />
</span>
<span role="columnheader">操作</span>
</div>
{activeStageConfig.tiers.map((tier, index) => {
const rowLabel = `${activeSummary?.stageLabel || activeStageConfig.stage}${index + 1}`;
return (
<div className="ops-tier-row" key={`${activeStageConfig.stage}-${index}`} role="row">
<div className="ops-tier-cell" role="cell">
<TextField
required
size="small"
slotProps={{
htmlInput: {
"aria-label": `${rowLabel}倍率`,
inputMode: "decimal",
min: 0,
step: "0.01",
},
}}
type="number"
value={tier.multiplier}
onChange={(event) =>
onTierChange(activeStageConfig.stage, index, {
// 10
//
highWaterOnly:
Number(event.target.value) >= 10 || tier.highWaterOnly,
multiplier: event.target.value,
})
}
/>
</div>
<div className="ops-tier-cell" role="cell">
{/* 概率由目标 RTP 和倍率统一校正;保持只读可避免运营绕过服务端概率闭合校验。 */}
<TextField
size="small"
slotProps={{
htmlInput: {
"aria-label": `${rowLabel}概率 %`,
readOnly: true,
},
}}
value={formatDecimal(tier.probabilityPercent)}
/>
</div>
<div className="ops-tier-cell ops-tier-cell--center" role="cell">
<Checkbox
checked={Boolean(tier.highWaterOnly)}
disabled={Number(tier.multiplier) >= 10}
size="small"
slotProps={{ input: { "aria-label": `${rowLabel}高倍率安全标记` } }}
onChange={(event) =>
onTierChange(activeStageConfig.stage, index, {
highWaterOnly: event.target.checked,
})
}
/>
</div>
<div className="ops-tier-cell ops-tier-cell--center" role="cell">
<Checkbox
checked={tier.enabled !== false}
size="small"
slotProps={{ input: { "aria-label": `${rowLabel}启用` } }}
onChange={(event) =>
onTierChange(activeStageConfig.stage, index, {
enabled: event.target.checked,
})
}
/>
</div>
<div className="ops-tier-cell ops-tier-cell--center" role="cell">
<IconButton
aria-label={`删除${rowLabel}`}
color="error"
disabled={activeStageConfig.tiers.length <= 1}
size="small"
title={`删除${rowLabel}`}
onClick={() => onRemoveTier(activeStageConfig.stage, index)}
>
<DeleteOutlined fontSize="small" />
</IconButton>
</div>
</div>
);
})}
</div>
</div>
) : null}
</section>
);
}
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 (
<div className="ops-stage-thresholds ops-stage-thresholds--derived" aria-label="新手阶段派生充值范围">
<span>派生范围</span>
<div className="ops-stage-threshold-rule">
<strong>
7 日充值 &lt; {formatNumber(normal7D)} 30 日充值 &lt; {formatNumber(normal30D)}
</strong>
{/* 普通阶段需要两个下限同时成立;因此任一维未达到都应留在新手,等于下限则进入普通。 */}
<small>
{configured ? "" : "普通阶段门槛待配置;"}任一未达到即为新手7 日与 30
日均达到门槛含等于后进入普通
</small>
</div>
</div>
);
}
function plainStageStatus(status) {
if (status === "RTP 偏离") return "平均返还偏离";
if (status === "概率未闭合") return "概率合计不为 100%";
return status || "待检查";
}

View File

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

View File

@ -11,8 +11,21 @@ import { DEFAULT_POOL_ID } from "./api.js";
const DYNAMIC_STRATEGY = "dynamic_v3"; const DYNAMIC_STRATEGY = "dynamic_v3";
const PPM_SCALE = 1_000_000; 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。 // 因而默认保持 0 且规则停用,避免把某个 App 的预算误发到其他 App。
const dynamicDefaults = { const dynamicDefaults = {
anchor_daily_payout_cap: 0, anchor_daily_payout_cap: 0,
@ -21,12 +34,13 @@ const dynamicDefaults = {
device_daily_payout_cap: 0, device_daily_payout_cap: 0,
high_water_nonzero_factor_percent: 130, high_water_nonzero_factor_percent: 130,
high_watermark_coins: 20_000_000, 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_global_rtp_max_percent: 98,
jackpot_multipliers: [200, 500, 1000], jackpot_multipliers: [200, 500, 1000],
jackpot_spend_threshold_coins: 0, jackpot_spend_threshold_coins: 0,
jackpot_user_72h_rtp_max_percent: 96, jackpot_user_48h_rtp_max_percent: 96,
jackpot_user_day_rtp_max_percent: 96, jackpot_user_round_rtp_max_percent: 96,
loss_streak_guarantee: 5, loss_streak_guarantee: 5,
low_water_nonzero_factor_percent: 70, low_water_nonzero_factor_percent: 70,
low_watermark_coins: 10_000_000, low_watermark_coins: 10_000_000,
@ -54,8 +68,8 @@ const numericFields = [
"initial_pool_coins", "initial_pool_coins",
"jackpot_global_rtp_max_percent", "jackpot_global_rtp_max_percent",
"jackpot_spend_threshold_coins", "jackpot_spend_threshold_coins",
"jackpot_user_72h_rtp_max_percent", "jackpot_user_48h_rtp_max_percent",
"jackpot_user_day_rtp_max_percent", "jackpot_user_round_rtp_max_percent",
"loss_streak_guarantee", "loss_streak_guarantee",
"low_water_nonzero_factor_percent", "low_water_nonzero_factor_percent",
"low_watermark_coins", "low_watermark_coins",
@ -108,12 +122,30 @@ export function configToForm(config = {}) {
stages: normalizeFormStages(config.stages, targetRTPPercent, strategyVersion), stages: normalizeFormStages(config.stages, targetRTPPercent, strategyVersion),
strategy_version: 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) { for (const field of numericFields) {
if (field === "target_rtp_percent") { if (field === "target_rtp_percent") {
form[field] = targetRTPPercent; form[field] = targetRTPPercent;
continue; 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; return form;
} }
@ -125,22 +157,41 @@ export function formToConfig(config, form) {
enabled: Boolean(form.enabled), enabled: Boolean(form.enabled),
jackpot_multipliers: multiplierListFromForm(form.jackpot_multipliers), jackpot_multipliers: multiplierListFromForm(form.jackpot_multipliers),
pool_id: form.pool_id, pool_id: form.pool_id,
stages: (form.stages || []).map((stage) => ({ stages: (form.stages || []).map((stage) => {
min_recharge_30d_coins: numberFromForm(stage.min_recharge_30d_coins), // dynamic_v3 的新手范围由普通阶段下限反向派生0/0 只是旧 DTO 要求的兜底哨兵值,
min_recharge_7d_coins: numberFromForm(stage.min_recharge_7d_coins), // 不是可运营的“充值为 0 才是新手”规则。序列化时强制归零,避免历史异常值改变分层。
stage: stage.stage, const isDynamicNovice = form.strategy_version === DYNAMIC_STRATEGY && stage.stage === "novice";
tiers: (stage.tiers || []).map((tier) => ({ return {
enabled: tier.enabled !== false, min_recharge_30d_coins: isDynamicNovice ? 0 : numberFromForm(stage.min_recharge_30d_coins),
high_water_only: Boolean(tier.highWaterOnly), min_recharge_7d_coins: isDynamicNovice ? 0 : numberFromForm(stage.min_recharge_7d_coins),
multiplier: numberFromForm(tier.multiplier), stage: stage.stage,
probability_percent: numberFromForm(tier.probabilityPercent), tiers: (stage.tiers || []).map((tier) => ({
tier_id: tier.tierId || "", 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, strategy_version: form.strategy_version,
}; };
for (const field of numericFields) { 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; return output;
} }
@ -233,6 +284,9 @@ export function validateLuckyGiftForm(form) {
) )
errors.push("等价抽数阶段门槛不正确"); errors.push("等价抽数阶段门槛不正确");
const jackpotMultipliers =
form.strategy_version === DYNAMIC_STRATEGY ? new Set(multiplierListFromForm(form.jackpot_multipliers)) : null;
// 阶段概率与 RTP 是两代策略共用的 owner 校验fixed_v2 也不能跳过。 // 阶段概率与 RTP 是两代策略共用的 owner 校验fixed_v2 也不能跳过。
for (const stageKey of ["novice", "normal", "advanced"]) { for (const stageKey of ["novice", "normal", "advanced"]) {
const stage = form.stages?.find((item) => item.stage === stageKey); const stage = form.stages?.find((item) => item.stage === stageKey);
@ -251,6 +305,19 @@ export function validateLuckyGiftForm(form) {
errors.push(`${stageLabel(stageKey)}启用奖档概率必须大于 0`); errors.push(`${stageLabel(stageKey)}启用奖档概率必须大于 0`);
if (enabled.some((tier) => Number(tier.multiplier) >= 10 && !tier.highWaterOnly)) if (enabled.some((tier) => Number(tier.multiplier) >= 10 && !tier.highWaterOnly))
errors.push(`${stageLabel(stageKey)}10x 及以上奖档必须限制为高水位`); 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 }); const expected = expectedRTPPercent(enabled, { multiplier: (item) => item.multiplier });
if ( if (
expected < number("target_rtp_percent") - number("control_band_percent") || expected < number("target_rtp_percent") - number("control_band_percent") ||
@ -270,7 +337,7 @@ export function validateLuckyGiftForm(form) {
PPM_SCALE PPM_SCALE
) )
errors.push("奖池、平台盈利和主播收益比例合计必须等于 100%"); 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("loss_streak_guarantee") <= 0) errors.push("连续未中奖保护次数必须大于 0");
if (number("low_watermark_coins") <= 0 || number("high_watermark_coins") <= number("low_watermark_coins")) if (number("low_watermark_coins") <= 0 || number("high_watermark_coins") <= number("low_watermark_coins"))
errors.push("高水位必须大于正数低水位"); errors.push("高水位必须大于正数低水位");
@ -282,34 +349,35 @@ export function validateLuckyGiftForm(form) {
const jackpotTokens = String(form.jackpot_multipliers || "") const jackpotTokens = String(form.jackpot_multipliers || "")
.split(/[,\s]+/) .split(/[,\s]+/)
.filter(Boolean); .filter(Boolean);
const jackpotMultipliers = multiplierListFromForm(form.jackpot_multipliers); const jackpotMultiplierValues = multiplierListFromForm(form.jackpot_multipliers);
if ( if (
jackpotTokens.length !== jackpotMultipliers.length || jackpotTokens.length !== jackpotMultiplierValues.length ||
!jackpotMultipliers.length || !jackpotMultiplierValues.length ||
jackpotMultipliers.some((value, index) => value <= 1 || (index > 0 && value <= jackpotMultipliers[index - 1])) jackpotMultiplierValues.some((value, index) => value <= 1 || (index > 0 && value <= jackpotMultiplierValues[index - 1]))
) { ) {
errors.push("大奖倍率必须大于 1x 并严格递增"); errors.push("大奖倍率必须大于 1x 并严格递增");
} }
if (number("jackpot_global_rtp_max_percent") <= 0 || number("jackpot_global_rtp_max_percent") > 100) if (number("jackpot_global_rtp_max_percent") <= 0 || number("jackpot_global_rtp_max_percent") > 100)
errors.push("全局大奖 RTP 上限必须在 0%-100% 之间"); errors.push("全局大奖 RTP 上限必须在 0%-100% 之间");
if ( if (
number("jackpot_user_day_rtp_max_percent") <= 0 || number("jackpot_user_round_rtp_max_percent") <= 0 ||
number("jackpot_user_day_rtp_max_percent") > number("jackpot_global_rtp_max_percent") number("jackpot_user_round_rtp_max_percent") > number("jackpot_global_rtp_max_percent")
) )
errors.push("用户每日 RTP 上限必须大于 0 且不超过全局上限"); errors.push("用户本轮 RTP 触发线必须大于 0 且不超过全局上限");
if ( if (
number("jackpot_user_72h_rtp_max_percent") <= 0 || number("jackpot_user_48h_rtp_max_percent") <= 0 ||
number("jackpot_user_72h_rtp_max_percent") > number("jackpot_global_rtp_max_percent") 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("max_jackpot_hits_per_user_day") <= 0) errors.push("用户每日大奖次数必须大于 0");
if (number("jackpot_spend_threshold_coins") < 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) { for (const [field, label] of payoutCapFields) {
if (number(field) < 0) errors.push(`${label}不能小于 0`); if (number(field) < 0) errors.push(`${label}不能小于 0`);
} }
validateRechargeStages(form.stages || [], errors); validateRechargeStages(form.stages || [], errors, form.enabled);
if (form.enabled) { if (form.enabled) {
if (number("jackpot_spend_threshold_coins") <= 0) errors.push("启用前必须填写 50 美元等值的大奖消费门槛");
for (const [field, label] of payoutCapFields) { for (const [field, label] of payoutCapFields) {
if (number(field) <= 0) errors.push(`启用前必须填写${label}`); if (number(field) <= 0) errors.push(`启用前必须填写${label}`);
} }
@ -336,11 +404,15 @@ function normalizeFormStages(stages, targetRTPPercent, strategyVersion) {
: correctedDefaultTiers(stageKey, targetRTPPercent); : correctedDefaultTiers(stageKey, targetRTPPercent);
const [default7d, default30d] = const [default7d, default30d] =
strategyVersion === DYNAMIC_STRATEGY ? defaultRechargeThreshold(stageKey) : [0, 0]; strategyVersion === DYNAMIC_STRATEGY ? defaultRechargeThreshold(stageKey) : [0, 0];
const isDynamicNovice = strategyVersion === DYNAMIC_STRATEGY && stageKey === "novice";
return { return {
min_recharge_7d_coins: formNumber(source?.min_recharge_7d_coins ?? source?.minRecharge7DCoins ?? default7d), // 只在表单内保留兼容哨兵值;界面上的新手上界始终从普通门槛派生,不读取历史 novice 门槛。
min_recharge_30d_coins: formNumber( min_recharge_7d_coins: isDynamicNovice
source?.min_recharge_30d_coins ?? source?.minRecharge30DCoins ?? default30d, ? "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, stage: stageKey,
tiers: sourceTiers.map((tier) => ({ tiers: sourceTiers.map((tier) => ({
enabled: tier.enabled !== false, enabled: tier.enabled !== false,
@ -373,13 +445,10 @@ function defaultTier(tierID, multiplier, probabilityPercent) {
return { enabled: true, highWaterOnly: false, multiplier, probabilityPercent, tierId: tierID }; return { enabled: true, highWaterOnly: false, multiplier, probabilityPercent, tierId: tierID };
} }
function validateRechargeStages(stages, errors) { function validateRechargeStages(stages, errors, requireComplete) {
const novice = stages.find((stage) => stage.stage === "novice");
const normal = stages.find((stage) => stage.stage === "normal"); const normal = stages.find((stage) => stage.stage === "normal");
const advanced = stages.find((stage) => stage.stage === "advanced"); const advanced = stages.find((stage) => stage.stage === "advanced");
const threshold = (stage, field) => numberFromForm(stage?.[field]); 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 normal7d = threshold(normal, "min_recharge_7d_coins");
const normal30d = threshold(normal, "min_recharge_30d_coins"); const normal30d = threshold(normal, "min_recharge_30d_coins");
const advanced7d = threshold(advanced, "min_recharge_7d_coins"); const advanced7d = threshold(advanced, "min_recharge_7d_coins");
@ -388,15 +457,21 @@ function validateRechargeStages(stages, errors) {
errors.push("充值阶段门槛不能小于 0"); errors.push("充值阶段门槛不能小于 0");
return; return;
} }
if (normal7d === 0 && normal30d === 0) errors.push("正常阶段至少一个充值门槛必须大于 0"); const isUnconfigured = [normal7d, normal30d, advanced7d, advanced30d].every((value) => value === 0);
// 新建的停用草稿可以全 0避免猜测 App 金币价值;一旦开始填写或准备启用,
// 就必须遵守 owner 的二维下限契约normal 至少一维大于 0advanced 逐维不低且至少一维更高。
if (!requireComplete && isUnconfigured) return;
if (normal7d === 0 && normal30d === 0)
errors.push(
requireComplete ? "启用前普通阶段至少一个最低充值门槛必须大于 0" : "普通阶段至少一个充值门槛必须大于 0",
);
if (advanced7d < normal7d || advanced30d < normal30d || (advanced7d === normal7d && advanced30d === normal30d)) if (advanced7d < normal7d || advanced30d < normal30d || (advanced7d === normal7d && advanced30d === normal30d))
errors.push("高阶充值门槛必须逐维不低于正常阶段且至少一项更高"); errors.push("高阶充值门槛必须逐维不低于正常阶段且至少一项更高");
} }
function defaultRechargeThreshold(stage) { function defaultRechargeThreshold() {
if (stage === "novice") return [0, 0]; // 充值门槛受 App 金币比例影响,新草稿不猜测 1 金币等于可用配置;运营填完后才能启用。
if (stage === "normal") return [0, 1]; return [0, 0];
return [1, 1];
} }
function defaultNumber(field, strategyVersion) { function defaultNumber(field, strategyVersion) {
@ -431,6 +506,17 @@ function formConfigNumber(config, snake, camel, fallback) {
return formNumber(config[snake] ?? config[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) { function snakeToCamel(value) {
return value.replace(/_([a-z0-9])/g, (_, letter) => letter.toUpperCase()); return value.replace(/_([a-z0-9])/g, (_, letter) => letter.toUpperCase());
} }

View File

@ -8,7 +8,7 @@ describe("ops center lucky gift dynamic_v3 form", () => {
expect(form).toMatchObject({ expect(form).toMatchObject({
anchor_rate_percent: "1", anchor_rate_percent: "1",
enabled: false, enabled: false,
initial_pool_coins: "1000000", initial_pool_coins: "0",
jackpot_multipliers: "200, 500, 1000", jackpot_multipliers: "200, 500, 1000",
max_single_payout: "0", max_single_payout: "0",
pool_rate_percent: "98", 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([ expect(form.stages.map((stage) => [stage.min_recharge_7d_coins, stage.min_recharge_30d_coins])).toEqual([
["0", "0"], ["0", "0"],
["0", "1"], ["0", "0"],
["1", "1"], ["0", "0"],
]); ]);
expect(form.stages.map((stage) => stage.tiers.map((tier) => Number(tier.probabilityPercent)))).toEqual([ expect(form.stages.map((stage) => stage.tiers.map((tier) => Number(tier.probabilityPercent)))).toEqual([
[5, 4, 86, 5], [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", () => { 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" }); const draft = configToForm({ app_code: "lalu", pool_id: "default", strategy_version: "dynamic_v3" });
expect(validateLuckyGiftForm(draft)).toEqual([]); expect(validateLuckyGiftForm(draft)).toEqual([]);
@ -56,7 +70,8 @@ describe("ops center lucky gift dynamic_v3 form", () => {
const enabled = { ...draft, enabled: true }; const enabled = { ...draft, enabled: true };
const errors = validateLuckyGiftForm(enabled); const errors = validateLuckyGiftForm(enabled);
expect(errors).toContain("启用前必须填写 50 美元等值的大奖消费门槛"); expect(errors).toContain("启用前普通阶段至少一个最低充值门槛必须大于 0");
expect(errors).toContain("高阶充值门槛必须逐维不低于正常阶段且至少一项更高");
expect(errors).toContain("启用前必须填写单次返奖上限"); 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); 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([ expect(dynamic.stages.map((stage) => stage.tiers.map((tier) => Number(tier.multiplier)))).toEqual([
[0, 0.5, 1, 2], [0, 0.5, 1, 2],
[0, 0.5, 1, 2], [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");
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() { function completeDynamicConfig() {
@ -144,7 +190,7 @@ function completeDynamicConfig() {
gift_price_reference: 100, gift_price_reference: 100,
high_water_nonzero_factor_percent: 130, high_water_nonzero_factor_percent: 130,
high_watermark_coins: 20_000_000, high_watermark_coins: 20_000_000,
initial_pool_coins: 1_000_000, initial_pool_coins: 0,
jackpot_global_rtp_max_percent: 98, jackpot_global_rtp_max_percent: 98,
jackpot_multipliers: [200, 500, 1000], jackpot_multipliers: [200, 500, 1000],
jackpot_spend_threshold_coins: 50_000, jackpot_spend_threshold_coins: 50_000,

View File

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

View File

@ -147,15 +147,66 @@ body {
padding: var(--space-3) var(--space-4); 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 { .ops-config-form {
display: contents; display: flex;
min-height: 0;
height: 100%;
flex-direction: column;
} }
.ops-config-title { .ops-config-title {
display: grid; display: grid;
flex: 0 0 auto;
gap: var(--space-1); gap: var(--space-1);
padding: var(--space-4) var(--space-6) var(--space-3);
} }
.ops-config-title small { .ops-config-title small {
@ -164,17 +215,31 @@ body {
font-weight: 700; font-weight: 700;
} }
.ops-config-metrics { .ops-config-summary {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); flex: 0 0 auto;
gap: var(--space-2); grid-template-columns: repeat(7, minmax(0, 1fr));
margin-bottom: var(--space-4); 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 { .ops-config-validation {
display: grid; display: grid;
max-height: 112px;
flex: 0 0 auto;
gap: var(--space-2); 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: 1px solid var(--danger-border, #fecaca);
border-radius: var(--radius-control); border-radius: var(--radius-control);
background: var(--danger-surface, #fef2f2); background: var(--danger-surface, #fef2f2);
@ -182,6 +247,11 @@ body {
padding: var(--space-3) var(--space-4); 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 { .ops-config-validation ul {
display: grid; display: grid;
gap: 2px; gap: 2px;
@ -192,26 +262,31 @@ body {
.ops-config-metric { .ops-config-metric {
display: grid; display: grid;
min-width: 0; min-width: 0;
gap: var(--space-1); gap: 2px;
border: 1px solid var(--border); border-left: 1px solid var(--border);
border-radius: var(--radius-control); padding: var(--space-1) var(--space-3);
background: var(--bg-card-strong, #f8fafc);
padding: 10px var(--space-3);
} }
.ops-config-metric span { .ops-config-metric:first-child {
border-left: 0;
padding-left: 0;
}
.ops-config-metric dt {
overflow: hidden; overflow: hidden;
color: var(--text-tertiary); color: var(--text-tertiary);
font-size: 12px; font-size: 11px;
font-weight: 800; font-weight: 800;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.ops-config-metric strong { .ops-config-metric dd {
overflow: hidden; overflow: hidden;
margin: 0;
color: var(--text-primary); color: var(--text-primary);
font-size: 15px; font-size: 13px;
font-weight: 800;
line-height: 1.25; line-height: 1.25;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@ -219,24 +294,94 @@ body {
.ops-config-layout { .ops-config-layout {
display: grid; display: grid;
grid-template-columns: minmax(360px, 440px) minmax(0, 1fr); min-height: 0;
align-items: start; height: 100%;
gap: var(--space-4); flex: 1 1 auto;
grid-template-columns: clamp(500px, 40vw, 620px) minmax(0, 1fr);
overflow: hidden;
} }
.ops-config-params { .ops-config-params {
display: grid; display: grid;
min-width: 0; 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 { .ops-config-section {
display: grid; display: grid;
gap: var(--space-3); gap: var(--space-3);
border: 1px solid var(--border); border: 0;
border-radius: var(--radius-card); border-bottom: 1px solid var(--border);
border-radius: 0;
background: var(--bg-card); 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 { .ops-config-section h3 {
@ -246,6 +391,28 @@ body {
line-height: 1.3; 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 { .ops-form-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@ -256,44 +423,220 @@ body {
grid-column: 1 / -1; 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 { .ops-stage-designer {
display: grid; display: grid;
min-width: 0; min-width: 0;
gap: 0; min-height: 0;
border: 1px solid var(--border); align-content: start;
border-radius: var(--radius-card); overflow-y: auto;
overscroll-behavior: contain;
border: 0;
border-radius: 0;
background: var(--bg-card); background: var(--bg-card);
} }
.ops-stage-tabs { .ops-stage-tabs {
display: flex; position: sticky;
z-index: 3;
top: 0;
display: grid;
min-width: 0; min-width: 0;
gap: var(--space-1); min-height: 64px;
padding: 0 var(--space-3); grid-template-columns: repeat(3, minmax(0, 1fr));
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--bg-card);
padding: 0 var(--space-4);
} }
.ops-stage-tab { .ops-stage-tab {
display: inline-flex; display: grid;
min-width: 0; min-width: 0;
height: 38px; min-height: 64px;
align-items: center; align-content: center;
gap: 3px;
border: 0; border: 0;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
border-radius: 0; border-radius: 0;
background: transparent; background: transparent;
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
font-size: 13px; padding: var(--space-2) var(--space-3);
font-weight: 800; text-align: left;
padding: 0 var(--space-4);
text-align: center;
transition: transition:
background var(--motion-fast) var(--ease-standard), background var(--motion-fast) var(--ease-standard),
border-color var(--motion-fast) var(--ease-standard), border-color var(--motion-fast) var(--ease-standard),
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 { .ops-stage-tab:hover {
background: var(--primary-hover); background: var(--primary-hover);
} }
@ -316,17 +659,24 @@ body {
.ops-stage-stack { .ops-stage-stack {
display: grid; display: grid;
gap: var(--space-3); gap: var(--space-3);
padding: var(--space-4); padding: 0 var(--space-4) var(--space-6);
} }
.ops-stage-block__head { .ops-stage-block__head {
position: sticky;
z-index: 2;
top: 64px;
display: flex; display: flex;
min-height: 58px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--space-3); 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; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
@ -340,12 +690,38 @@ body {
.ops-stage-thresholds { .ops-stage-thresholds {
display: grid; 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); gap: var(--space-3);
padding: var(--space-3); justify-content: end;
border: 1px solid var(--border); padding: var(--space-1) 0;
border-radius: var(--radius-control); }
.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); 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 { .ops-tier-table {
@ -361,13 +737,13 @@ body {
.ops-tier-row { .ops-tier-row {
display: grid; display: grid;
box-sizing: border-box; box-sizing: border-box;
min-width: 520px; min-width: 590px;
grid-template-columns: grid-template-columns:
minmax(110px, 1fr) minmax(100px, 1fr)
minmax(110px, 1fr) minmax(124px, 1fr)
minmax(64px, auto) 96px
minmax(64px, auto) 64px
minmax(72px, auto); 56px;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
} }
@ -382,17 +758,94 @@ body {
padding: 0 var(--space-3); 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 { .ops-tier-row {
border-bottom: 1px solid var(--row-border); border-bottom: 1px solid var(--row-border);
padding: var(--space-2) var(--space-3); padding: 6px var(--space-3);
} }
.ops-tier-row:last-child { .ops-tier-row:last-child {
border-bottom: 0; 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) { @media (max-width: 960px) {
.ops-shell { .ops-shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -403,14 +856,129 @@ body {
} }
.ops-nav, .ops-nav,
.ops-kpi-grid, .ops-kpi-grid {
.ops-config-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr)); 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); 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) { @media (max-width: 640px) {
@ -420,21 +988,19 @@ body {
.ops-nav, .ops-nav,
.ops-kpi-grid, .ops-kpi-grid,
.ops-config-metrics, .ops-pool-adjustment-metrics {
.ops-form-grid,
.ops-stage-thresholds,
.ops-tier-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.ops-tier-head {
display: none;
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.ops-nav button, .ops-nav button,
.ops-config-section-nav button,
.ops-stage-tab { .ops-stage-tab {
transition: none; transition: none;
} }
.ops-config-section-list {
scroll-behavior: auto;
}
} }

View File

@ -55,7 +55,7 @@ export const PERMISSIONS = {
financeOrderCoinSellerRechargeCreate: "finance-order:coin-seller-recharge:create", financeOrderCoinSellerRechargeCreate: "finance-order:coin-seller-recharge:create",
financeOrderCoinSellerRechargeVerify: "finance-order:coin-seller-recharge:verify", financeOrderCoinSellerRechargeVerify: "finance-order:coin-seller-recharge:verify",
financeOrderCoinSellerRechargeGrant: "finance-order:coin-seller-recharge:grant", financeOrderCoinSellerRechargeGrant: "finance-order:coin-seller-recharge:grant",
financeOrderUSDTAddressUpdate: "finance-order:usdt-address:update", financeOrderUsdtAddressUpdate: "finance-order:usdt-address:update",
coinLedgerView: "coin-ledger:view", coinLedgerView: "coin-ledger:view",
coinSellerLedgerView: "coin-seller-ledger:view", coinSellerLedgerView: "coin-seller-ledger:view",
giftRecordView: "gift-record:view", giftRecordView: "gift-record:view",
@ -192,6 +192,8 @@ export const PERMISSIONS = {
sevenDayCheckInUpdate: "seven-day-checkin:update", sevenDayCheckInUpdate: "seven-day-checkin:update",
luckyGiftView: "lucky-gift:view", luckyGiftView: "lucky-gift:view",
luckyGiftUpdate: "lucky-gift:update", luckyGiftUpdate: "lucky-gift:update",
luckyGiftPoolCredit: "lucky-gift:pool-credit",
luckyGiftPoolDebit: "lucky-gift:pool-debit",
wheelView: "wheel:view", wheelView: "wheel:view",
wheelUpdate: "wheel:update", wheelUpdate: "wheel:update",
roomRocketView: "room-rocket:view", roomRocketView: "room-rocket:view",

View File

@ -81,9 +81,11 @@ export const API_OPERATIONS = {
createUser: "createUser", createUser: "createUser",
createWeeklyStarCycle: "createWeeklyStarCycle", createWeeklyStarCycle: "createWeeklyStarCycle",
creditCoinSellerStock: "creditCoinSellerStock", creditCoinSellerStock: "creditCoinSellerStock",
creditLuckyGiftPool: "creditLuckyGiftPool",
dashboardOverview: "dashboardOverview", dashboardOverview: "dashboardOverview",
dashboardUserProfileOverview: "dashboardUserProfileOverview", dashboardUserProfileOverview: "dashboardUserProfileOverview",
debitCoinSellerStock: "debitCoinSellerStock", debitCoinSellerStock: "debitCoinSellerStock",
debitLuckyGiftPool: "debitLuckyGiftPool",
deleteAchievementDefinition: "deleteAchievementDefinition", deleteAchievementDefinition: "deleteAchievementDefinition",
deleteActivityTemplate: "deleteActivityTemplate", deleteActivityTemplate: "deleteActivityTemplate",
deleteAgency: "deleteAgency", deleteAgency: "deleteAgency",
@ -887,6 +889,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "coin-seller:stock-credit", permission: "coin-seller:stock-credit",
permissions: ["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: { dashboardOverview: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.dashboardOverview, operationId: API_OPERATIONS.dashboardOverview,
@ -908,6 +917,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "coin-seller:stock-credit", permission: "coin-seller:stock-credit",
permissions: ["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: { deleteAchievementDefinition: {
method: "DELETE", method: "DELETE",
operationId: API_OPERATIONS.deleteAchievementDefinition, operationId: API_OPERATIONS.deleteAchievementDefinition,

View File

@ -324,6 +324,38 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/admin/activity/wheel/config": {
parameters: { parameters: {
query?: never; query?: never;
@ -4792,6 +4824,64 @@ export interface paths {
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { 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"] & { ApiResponseAdminAppList: components["schemas"]["Envelope"] & {
data?: components["schemas"]["AdminAppList"]; data?: components["schemas"]["AdminAppList"];
}; };
@ -7333,6 +7423,15 @@ export interface components {
"application/json": components["schemas"]["ApiResponseEmpty"]; "application/json": components["schemas"]["ApiResponseEmpty"];
}; };
}; };
/** @description 奖池水位调整成功或同幂等请求的首次结果 */
LuckyGiftPoolAdjustmentResponse: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiResponseLuckyGiftPoolAdjustmentResult"];
};
};
/** @description OK */ /** @description OK */
RechargeBillPageResponse: { RechargeBillPageResponse: {
headers: { headers: {
@ -8254,6 +8353,74 @@ export interface operations {
200: components["responses"]["EmptyResponse"]; 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: { getWheelConfig: {
parameters: { parameters: {
query?: never; query?: never;