diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json
index 104b28c..5ed8487 100644
--- a/contracts/admin-openapi.json
+++ b/contracts/admin-openapi.json
@@ -390,6 +390,86 @@
"x-permissions": ["lucky-gift:view"]
}
},
+ "/admin/ops-center/lucky-gifts/pools/credit": {
+ "post": {
+ "operationId": "creditLuckyGiftPool",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "app_code",
+ "required": true,
+ "schema": { "type": "string", "minLength": 1, "maxLength": 32 }
+ },
+ {
+ "in": "query",
+ "name": "pool_id",
+ "required": true,
+ "schema": { "type": "string", "minLength": 1, "maxLength": 96 }
+ },
+ {
+ "in": "query",
+ "name": "strategy_version",
+ "required": true,
+ "schema": { "type": "string", "enum": ["fixed_v2", "dynamic_v3"] }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/LuckyGiftPoolAdjustmentInput" }
+ }
+ }
+ },
+ "responses": {
+ "200": { "$ref": "#/components/responses/LuckyGiftPoolAdjustmentResponse" },
+ "400": { "description": "调整参数不正确" },
+ "409": { "description": "幂等键冲突或可用水位不足" }
+ },
+ "x-permission": "lucky-gift:pool-credit",
+ "x-permissions": ["lucky-gift:pool-credit"]
+ }
+ },
+ "/admin/ops-center/lucky-gifts/pools/debit": {
+ "post": {
+ "operationId": "debitLuckyGiftPool",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "app_code",
+ "required": true,
+ "schema": { "type": "string", "minLength": 1, "maxLength": 32 }
+ },
+ {
+ "in": "query",
+ "name": "pool_id",
+ "required": true,
+ "schema": { "type": "string", "minLength": 1, "maxLength": 96 }
+ },
+ {
+ "in": "query",
+ "name": "strategy_version",
+ "required": true,
+ "schema": { "type": "string", "enum": ["fixed_v2", "dynamic_v3"] }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/LuckyGiftPoolAdjustmentInput" }
+ }
+ }
+ },
+ "responses": {
+ "200": { "$ref": "#/components/responses/LuckyGiftPoolAdjustmentResponse" },
+ "400": { "description": "调整参数不正确" },
+ "409": { "description": "幂等键冲突或可用水位不足" }
+ },
+ "x-permission": "lucky-gift:pool-debit",
+ "x-permissions": ["lucky-gift:pool-debit"]
+ }
+ },
"/admin/activity/wheel/config": {
"get": {
"operationId": "getWheelConfig",
@@ -9071,6 +9151,16 @@
}
}
},
+ "LuckyGiftPoolAdjustmentResponse": {
+ "description": "奖池水位调整成功或同幂等请求的首次结果",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponseLuckyGiftPoolAdjustmentResult"
+ }
+ }
+ }
+ },
"RechargeBillPageResponse": {
"description": "OK",
"content": {
@@ -9483,6 +9573,115 @@
}
},
"schemas": {
+ "LuckyGiftPoolAdjustmentInput": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["adjustment_id", "amount_coins", "reason"],
+ "properties": {
+ "adjustment_id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128
+ },
+ "amount_coins": {
+ "type": "integer",
+ "format": "int64",
+ "minimum": 1
+ },
+ "reason": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255
+ }
+ }
+ },
+ "LuckyGiftPoolBalance": {
+ "type": "object",
+ "required": [
+ "app_code",
+ "pool_id",
+ "strategy_version",
+ "balance",
+ "reserve_floor",
+ "available_balance",
+ "total_in",
+ "total_out",
+ "manual_credit_total",
+ "manual_debit_total",
+ "materialized",
+ "updated_at_ms"
+ ],
+ "properties": {
+ "app_code": { "type": "string" },
+ "pool_id": { "type": "string" },
+ "strategy_version": {
+ "type": "string",
+ "enum": ["fixed_v2", "dynamic_v3"]
+ },
+ "balance": { "type": "integer", "format": "int64" },
+ "reserve_floor": { "type": "integer", "format": "int64" },
+ "available_balance": { "type": "integer", "format": "int64" },
+ "total_in": { "type": "integer", "format": "int64" },
+ "total_out": { "type": "integer", "format": "int64" },
+ "manual_credit_total": { "type": "integer", "format": "int64" },
+ "manual_debit_total": { "type": "integer", "format": "int64" },
+ "materialized": { "type": "boolean" },
+ "updated_at_ms": { "type": "integer", "format": "int64" }
+ }
+ },
+ "LuckyGiftPoolAdjustment": {
+ "type": "object",
+ "required": [
+ "adjustment_id",
+ "app_code",
+ "pool_id",
+ "strategy_version",
+ "direction",
+ "amount_coins",
+ "reason",
+ "operator_admin_id",
+ "balance_before",
+ "balance_after",
+ "created_at_ms"
+ ],
+ "properties": {
+ "adjustment_id": { "type": "string" },
+ "app_code": { "type": "string" },
+ "pool_id": { "type": "string" },
+ "strategy_version": {
+ "type": "string",
+ "enum": ["fixed_v2", "dynamic_v3"]
+ },
+ "direction": { "type": "string", "enum": ["in", "out"] },
+ "amount_coins": { "type": "integer", "format": "int64" },
+ "reason": { "type": "string" },
+ "operator_admin_id": { "type": "integer", "format": "int64" },
+ "balance_before": { "type": "integer", "format": "int64" },
+ "balance_after": { "type": "integer", "format": "int64" },
+ "created_at_ms": { "type": "integer", "format": "int64" }
+ }
+ },
+ "LuckyGiftPoolAdjustmentResult": {
+ "type": "object",
+ "required": ["adjustment", "pool", "idempotent_replay"],
+ "properties": {
+ "adjustment": { "$ref": "#/components/schemas/LuckyGiftPoolAdjustment" },
+ "pool": { "$ref": "#/components/schemas/LuckyGiftPoolBalance" },
+ "idempotent_replay": { "type": "boolean" }
+ }
+ },
+ "ApiResponseLuckyGiftPoolAdjustmentResult": {
+ "allOf": [
+ { "$ref": "#/components/schemas/Envelope" },
+ {
+ "type": "object",
+ "properties": {
+ "requestId": { "type": "string" },
+ "data": { "$ref": "#/components/schemas/LuckyGiftPoolAdjustmentResult" }
+ }
+ }
+ ]
+ },
"ApiResponseAdminAppList": {
"allOf": [
{
diff --git a/databi/src/social/SocialBiApp.jsx b/databi/src/social/SocialBiApp.jsx
index 1f79363..cd8af39 100644
--- a/databi/src/social/SocialBiApp.jsx
+++ b/databi/src/social/SocialBiApp.jsx
@@ -16,6 +16,7 @@ import {
DATE_PRESETS,
GRANULARITIES,
createDefaultFilters,
+ createDefaultTableState,
rangeLabel,
readStateFromURL,
toggleMultiValue,
@@ -63,6 +64,7 @@ export function SocialAppIdentity({ app, appCode, appName, className = "", size
export function SocialBiApp() {
const initial = useMemo(() => readStateFromURL(), []);
const [filters, setFilters] = useState(initial.filters);
+ const [tableState, setTableState] = useState(() => initial.table || createDefaultTableState());
const [viewKey, setViewKey] = useState(() => (VIEWS.some((view) => view.key === initial.view) ? initial.view : "overview"));
const data = useSocialBiData(filters, {
includeFunnel: viewKey === "funnel",
@@ -70,8 +72,8 @@ export function SocialBiApp() {
});
useEffect(() => {
- writeStateToURL(filters, viewKey);
- }, [filters, viewKey]);
+ writeStateToURL(filters, viewKey, tableState);
+ }, [filters, tableState, viewKey]);
const updateFilter = useCallback((key, value) => {
setFilters((current) => ({ ...current, [key]: value }));
@@ -81,9 +83,19 @@ export function SocialBiApp() {
setFilters(createDefaultFilters());
}, []);
+ const updateTableState = useCallback((patch) => {
+ setTableState((current) => ({ ...current, ...patch }));
+ }, []);
+
+ const openRequirementSection = useCallback((requirementSection) => {
+ // 下钻必须同时设置明细模式和业务分组,否则 DataTableView 会按默认值落回“运营宽表 / 新用户”。
+ setTableState({ mode: "requirements", requirementSection });
+ setViewKey("table");
+ }, []);
+
const contextValue = useMemo(
- () => ({ ...data, filters, resetFilters, setViewKey, updateFilter, viewKey }),
- [data, filters, resetFilters, updateFilter, viewKey]
+ () => ({ ...data, filters, openRequirementSection, resetFilters, setViewKey, tableState, updateFilter, updateTableState, viewKey }),
+ [data, filters, openRequirementSection, resetFilters, tableState, updateFilter, updateTableState, viewKey]
);
const ActiveView = (VIEWS.find((view) => view.key === viewKey) || VIEWS[0]).component;
diff --git a/databi/src/social/state.js b/databi/src/social/state.js
index efdc835..b0c8b9b 100644
--- a/databi/src/social/state.js
+++ b/databi/src/social/state.js
@@ -1,10 +1,20 @@
-// 社交 BI v2 的全局筛选状态:日期预设/自定义区间、App 多选、区域多选、运营人员、趋势粒度,
-// 并同步到 URL query,保证筛选状态可以直接作为链接分享。
+// 社交 BI v2 的全局筛选与可下钻视图状态:日期/App/区域/运营人员/趋势粒度,以及数据明细模式,
+// 统一同步到 URL query,保证筛选结果和“数据需求”目标分组都可以直接作为链接分享。
import { lastDaysRange, thisMonthRange, todayRange } from "../utils/time.js";
export const ALL = "all";
+const TABLE_MODES = new Set(["wide", "requirements"]);
+const REQUIREMENT_SECTIONS = new Set(["new_users", "revenue", "retention", "active", "gift", "game"]);
+
+export function createDefaultTableState() {
+ return {
+ mode: "wide",
+ requirementSection: "new_users"
+ };
+}
+
export const DATE_PRESETS = [
{ key: "today", label: "今日" },
{ key: "yesterday", label: "昨日" },
@@ -117,11 +127,12 @@ export function bucketDay(statDay, granularity) {
return statDay;
}
-const URL_KEYS = ["preset", "start", "end", "apps", "regions", "operator", "view", "granularity"];
+const URL_KEYS = ["preset", "start", "end", "apps", "regions", "operator", "view", "granularity", "mode", "section"];
export function readStateFromURL(location = window.location) {
const params = new URLSearchParams(location.search);
const filters = createDefaultFilters();
+ const table = createDefaultTableState();
let view = "";
if (params.get("preset") && DATE_PRESETS.some((item) => item.key === params.get("preset"))) {
filters.preset = params.get("preset");
@@ -143,16 +154,23 @@ export function readStateFromURL(location = window.location) {
if (params.get("view")) {
view = params.get("view");
}
+ const requestedTableMode = params.get("mode");
+ if (view === "table" && TABLE_MODES.has(requestedTableMode)) {
+ table.mode = requestedTableMode;
+ if (requestedTableMode === "requirements" && REQUIREMENT_SECTIONS.has(params.get("section"))) {
+ table.requirementSection = params.get("section");
+ }
+ }
if (!filters.apps.length) {
filters.apps = [ALL];
}
if (!filters.regions.length) {
filters.regions = [ALL];
}
- return { filters, view };
+ return { filters, table, view };
}
-export function writeStateToURL(filters, view, history = window.history, location = window.location) {
+export function writeStateToURL(filters, view, table, history = window.history, location = window.location) {
const params = new URLSearchParams(location.search);
URL_KEYS.forEach((key) => params.delete(key));
if (filters.preset !== "yesterday") {
@@ -181,6 +199,13 @@ export function writeStateToURL(filters, view, history = window.history, locatio
if (view && view !== "overview") {
params.set("view", view);
}
+ if (view === "table" && table?.mode === "requirements") {
+ // 目标分组与数据需求模式一起入 URL,保证概览下钻和复制后的链接都能落到同一个游戏数据视图。
+ params.set("mode", "requirements");
+ if (REQUIREMENT_SECTIONS.has(table.requirementSection) && table.requirementSection !== "new_users") {
+ params.set("section", table.requirementSection);
+ }
+ }
const query = params.toString();
history.replaceState(null, "", `${location.pathname}${query ? `?${query}` : ""}`);
}
diff --git a/databi/src/social/views/DataTableView.jsx b/databi/src/social/views/DataTableView.jsx
index f574b16..cf9004d 100644
--- a/databi/src/social/views/DataTableView.jsx
+++ b/databi/src/social/views/DataTableView.jsx
@@ -477,16 +477,18 @@ export function DataTableView() {
range,
refreshToken,
requirements,
- requirementsError
+ requirementsError,
+ tableState,
+ updateTableState
} = useSocialBi();
- const [mode, setMode] = useState("wide");
const [dimKey, setDimKey] = useState("appDaily");
const [closedGroups, setClosedGroups] = useState(readClosedGroups);
const [sort, setSort] = useState(null);
- const [requirementSection, setRequirementSection] = useState(REQUIREMENT_SECTIONS[0].key);
const [requirementRole, setRequirementRole] = useState("all");
const [requirementPayer, setRequirementPayer] = useState("all");
const [requirementNewUser, setRequirementNewUser] = useState("all");
+ const mode = tableState?.mode || TABLE_MODES[0].key;
+ const requirementSection = tableState?.requirementSection || REQUIREMENT_SECTIONS[0].key;
const dim = DIMENSIONS.find((item) => item.key === dimKey) || DIMENSIONS[0];
const dimColumns = dim.dims.map((name) => DIM_COLUMNS[name]);
@@ -575,7 +577,7 @@ export function DataTableView() {
const handleRequirementSectionChange = (key) => {
const nextSection = requirementSectionConfig(key);
- setRequirementSection(nextSection.key);
+ updateTableState({ requirementSection: nextSection.key });
if (!nextSection.roleFilter) {
setRequirementRole("all");
}
@@ -889,7 +891,7 @@ export function DataTableView() {
aria-checked={mode === item.key}
className={mode === item.key ? "is-active" : ""}
key={item.key}
- onClick={() => setMode(item.key)}
+ onClick={() => updateTableState({ mode: item.key })}
role="radio"
type="button"
>
diff --git a/databi/src/social/views/OverviewView.jsx b/databi/src/social/views/OverviewView.jsx
index 68d76db..7909faa 100644
--- a/databi/src/social/views/OverviewView.jsx
+++ b/databi/src/social/views/OverviewView.jsx
@@ -174,7 +174,7 @@ function OverviewSkeleton() {
}
export function OverviewView() {
- const { appCodes, derived, filters, isLoading, setViewKey, updateFilter } = useSocialBi();
+ const { appCodes, derived, filters, isLoading, openRequirementSection, setViewKey, updateFilter } = useSocialBi();
const [trendMetric, setTrendMetric] = useState("recharge_usd_minor");
const [compareMetric, setCompareMetric] = useState("recharge_usd_minor");
@@ -332,20 +332,26 @@ export function OverviewView() {
{trendLabel}趋势
按 App 堆叠
-
+
{TREND_METRICS.map((item) => (
))}
+
diff --git a/ops-center/src/OpsCenterApp.jsx b/ops-center/src/OpsCenterApp.jsx
index 7d0e75b..54e461e 100644
--- a/ops-center/src/OpsCenterApp.jsx
+++ b/ops-center/src/OpsCenterApp.jsx
@@ -4,17 +4,20 @@ import CasinoOutlined from "@mui/icons-material/CasinoOutlined";
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import { useCallback, useEffect, useMemo, useState } from "react";
+import { PERMISSIONS } from "@/app/permissions";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { PageHead } from "@/shared/ui/PageHead.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
-import { DEFAULT_POOL_ID, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
+import { adjustLuckyGiftPool, DEFAULT_POOL_ID, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
import { AppsView } from "./components/AppsView.jsx";
import { ConfigsView } from "./components/ConfigsView.jsx";
import { DrawsView } from "./components/DrawsView.jsx";
import { LuckyGiftConfigDialog } from "./components/LuckyGiftConfigDialog.jsx";
+import { LuckyGiftPoolAdjustmentDialog } from "./components/LuckyGiftPoolAdjustmentDialog.jsx";
import { OpsShell } from "./components/OpsShell.jsx";
import { OverviewView } from "./components/OverviewView.jsx";
+import { formatNumber } from "./format.js";
const views = [
{ icon: InsightsOutlined, key: "overview", label: "数据总览" },
@@ -37,13 +40,20 @@ export function initialOpsCenterView(search = globalThis.location?.search || "")
return viewKeys.has(requested) ? requested : "overview";
}
-export function OpsCenterApp() {
+const denyPermission = () => false;
+
+export function OpsCenterApp({ can = denyPermission }) {
// 主后台旧入口通过 ?view=configs 进入独立 HTML;只接受白名单视图,异常参数仍回到总览。
const [activeView, setActiveView] = useState(() => initialOpsCenterView());
const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true });
const [dialog, setDialog] = useState(null);
+ const [poolDialog, setPoolDialog] = useState(null);
+ const [adjustingPool, setAdjustingPool] = useState(false);
const [savingConfig, setSavingConfig] = useState(false);
const { showToast } = useToast();
+ const canCreditPool = can(PERMISSIONS.luckyGiftPoolCredit);
+ const canDebitPool = can(PERMISSIONS.luckyGiftPoolDebit);
+ const canUpdateConfig = can(PERMISSIONS.luckyGiftUpdate);
const loadDashboard = useCallback(async () => {
setState((current) => ({ ...current, error: "", loading: true }));
@@ -124,6 +134,18 @@ export function OpsCenterApp() {
setDialog({ config: structuredClone(config), mode: "edit" });
}, []);
+ const openPoolAdjustment = useCallback((pool, direction) => {
+ if (!pool) {
+ return;
+ }
+ // 幂等键绑定一次弹窗会话,而不是一次 fetch;网络超时或 409 后弹窗保留,后续重试不会重复入账。
+ setPoolDialog({
+ adjustmentId: createPoolAdjustmentID(),
+ direction,
+ pool: structuredClone(pool),
+ });
+ }, []);
+
const handleSaveConfig = useCallback(
async (config) => {
setSavingConfig(true);
@@ -142,6 +164,31 @@ export function OpsCenterApp() {
[loadDashboard, showToast],
);
+ const handleAdjustPool = useCallback(
+ async (request) => {
+ setAdjustingPool(true);
+ try {
+ const result = await adjustLuckyGiftPool(request);
+ const adjustment = result.adjustment || {};
+ const action = request.direction === "out" ? "扣减" : "添加";
+ showToast(
+ `已${action}水位 ${request.appCode} / ${request.poolId} / ${request.strategyVersion}:${formatNumber(
+ adjustment.balance_before,
+ )} → ${formatNumber(adjustment.balance_after)}`,
+ "success",
+ );
+ setPoolDialog(null);
+ await loadDashboard();
+ } catch (error) {
+ // owner 可能已经成功但响应丢失;保留表单与 adjustment_id,用户重试时由 owner 返回首次结果而不重复调整。
+ showToast(error.message || "调整奖池水位失败", "error");
+ } finally {
+ setAdjustingPool(false);
+ }
+ },
+ [loadDashboard, showToast],
+ );
+
const defaultAppCode = data.apps[0]?.app_code ?? data.apps[0]?.appCode ?? "";
return (
@@ -170,13 +217,29 @@ export function OpsCenterApp() {
loading={state.loading && !state.loaded}
onRetry={loadDashboard}
>
- {activeView === "overview" ?
: null}
+ {activeView === "overview" ? (
+
openPoolAdjustment(pool, "in")}
+ onDebit={(pool) => openPoolAdjustment(pool, "out")}
+ />
+ ) : null}
{activeView === "configs" ? (
openPoolAdjustment(pool, "in")}
+ onDebit={(pool) => openPoolAdjustment(pool, "out")}
onEdit={openEditConfig}
/>
) : null}
@@ -195,7 +258,27 @@ export function OpsCenterApp() {
onSubmit={handleSaveConfig}
/>
) : null}
+ {poolDialog ? (
+ setPoolDialog(null)}
+ onSubmit={handleAdjustPool}
+ />
+ ) : null}
);
}
+
+let fallbackAdjustmentSequence = 0;
+
+function createPoolAdjustmentID() {
+ if (typeof globalThis.crypto?.randomUUID === "function") {
+ return globalThis.crypto.randomUUID();
+ }
+ fallbackAdjustmentSequence += 1;
+ return `pool-adjust-${Date.now().toString(36)}-${fallbackAdjustmentSequence.toString(36)}`;
+}
diff --git a/ops-center/src/OpsCenterApp.test.jsx b/ops-center/src/OpsCenterApp.test.jsx
index c5d4e21..e91954c 100644
--- a/ops-center/src/OpsCenterApp.test.jsx
+++ b/ops-center/src/OpsCenterApp.test.jsx
@@ -1,13 +1,17 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, expect, test, vi } from "vitest";
+import { PERMISSIONS } from "@/app/permissions";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
-import { fetchLuckyGiftDraws, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
+import { adjustLuckyGiftPool, fetchLuckyGiftDraws, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js";
import { initialOpsCenterView, OpsCenterApp } from "./OpsCenterApp.jsx";
vi.mock("./api.js", () => ({
DEFAULT_POOL_ID: "default",
+ adjustLuckyGiftPool: vi.fn(),
fetchLuckyGiftDraws: vi.fn(),
fetchOpsDashboard: vi.fn(),
+ luckyGiftPoolKey: (item = {}) =>
+ `${item.app_code || item.appCode}:${item.pool_id || item.poolId}:${item.strategy_version || item.strategyVersion || "fixed_v2"}`,
saveLuckyGiftConfig: vi.fn(),
}));
@@ -15,6 +19,10 @@ beforeEach(() => {
vi.clearAllMocks();
window.history.replaceState({}, "", "/");
saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" });
+ adjustLuckyGiftPool.mockResolvedValue({
+ adjustment: { balance_after: 39_559_017, balance_before: 38_559_017 },
+ idempotent_replay: false,
+ });
fetchLuckyGiftDraws.mockResolvedValue({
items: [
{
@@ -62,6 +70,14 @@ beforeEach(() => {
pool_id: "lucky",
rule_version: 3,
},
+ {
+ ...mockFixedConfig(95),
+ app_code: "lalu",
+ enabled: false,
+ is_default: false,
+ pool_id: "lucky",
+ rule_version: 2,
+ },
{
...mockFixedConfig(92),
app_code: "lalu",
@@ -84,9 +100,24 @@ beforeEach(() => {
app_code: "lalu",
available_balance: 37950017,
balance: 38559017,
+ manual_credit_total: 1_000_000,
+ manual_debit_total: 200_000,
materialized: true,
pool_id: "lucky",
reserve_floor: 609000,
+ strategy_version: "dynamic_v3",
+ updated_at_ms: 1767000000000,
+ },
+ {
+ app_code: "lalu",
+ available_balance: 0,
+ balance: 900,
+ manual_credit_total: 100,
+ manual_debit_total: 20,
+ materialized: true,
+ pool_id: "lucky",
+ reserve_floor: 900,
+ strategy_version: "fixed_v2",
updated_at_ms: 1767000000000,
},
{
@@ -96,6 +127,7 @@ beforeEach(() => {
materialized: false,
pool_id: "default",
reserve_floor: 21000,
+ strategy_version: "dynamic_v3",
},
],
summary: {
@@ -115,10 +147,12 @@ beforeEach(() => {
});
});
-function renderApp() {
+const allPermissions = [PERMISSIONS.luckyGiftPoolCredit, PERMISSIONS.luckyGiftPoolDebit, PERMISSIONS.luckyGiftUpdate];
+
+function renderApp(permissions = allPermissions) {
return render(
-
+ permissions.includes(code)} />
,
);
}
@@ -137,7 +171,7 @@ test("renders overview with aggregated kpis and pool balances", async () => {
expect(screen.getAllByText("抽奖次数").length).toBeGreaterThan(0);
expect(screen.getAllByText("38,941,517").length).toBeGreaterThan(0);
expect(screen.getByText("奖池实时水位")).toBeTruthy();
- expect(screen.getByText("已入账")).toBeTruthy();
+ expect(screen.getAllByText("已入账").length).toBeGreaterThan(1);
expect(screen.getByText("默认水位")).toBeTruthy();
expect(fetchOpsDashboard).toHaveBeenCalledTimes(1);
});
@@ -150,7 +184,7 @@ test("opens the requested config view from the legacy admin document redirect",
renderApp();
expect(await screen.findByRole("button", { name: "添加配置" })).toBeTruthy();
- expect(screen.getByText("lucky")).toBeTruthy();
+ expect(screen.getAllByText("lucky").length).toBeGreaterThan(0);
});
test("lists every pool config including published non-default pools", async () => {
@@ -160,15 +194,92 @@ test("lists every pool config including published non-default pools", async () =
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
// 核心回归:lalu 的 lucky / super_lucky 已发布奖池必须出现在配置列表里。
- expect(await screen.findByText("lucky")).toBeTruthy();
+ expect((await screen.findAllByText("lucky")).length).toBeGreaterThan(1);
expect(screen.getByText("super_lucky")).toBeTruthy();
expect(screen.getByText("v3")).toBeTruthy();
- expect(screen.getByText("v2")).toBeTruthy();
+ expect(screen.getAllByText("v2").length).toBeGreaterThan(0);
expect(screen.getByText("已启用")).toBeTruthy();
- expect(screen.getByText("已停用")).toBeTruthy();
+ expect(screen.getAllByText("已停用").length).toBeGreaterThan(1);
expect(screen.getByText("默认草稿")).toBeTruthy();
});
+test("shows strategy-specific balances and adjustment actions directly in the config list", async () => {
+ renderApp();
+ await screen.findByText("运营应用");
+ fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
+
+ expect(await screen.findByRole("button", { name: "添加水位 lalu / lucky / dynamic_v3" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: "扣减水位 lalu / lucky / dynamic_v3" })).toBeEnabled();
+ expect(screen.getByRole("button", { name: "添加水位 lalu / lucky / fixed_v2" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: "扣减水位 lalu / lucky / fixed_v2" })).toBeDisabled();
+ expect(screen.getByText("38,559,017")).toBeTruthy();
+ expect(screen.getByText("900")).toBeTruthy();
+});
+
+test("shows credit and debit buttons only when the standalone session grants each permission", async () => {
+ renderApp([PERMISSIONS.luckyGiftPoolCredit]);
+ await screen.findByText("运营应用");
+
+ expect(screen.getByRole("button", { name: "添加水位 lalu / lucky / dynamic_v3" })).toBeTruthy();
+ expect(screen.queryByRole("button", { name: "扣减水位 lalu / lucky / dynamic_v3" })).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
+ expect(screen.queryByRole("button", { name: "添加配置" })).toBeNull();
+ expect(screen.queryByRole("button", { name: /新增版本 lalu/ })).toBeNull();
+});
+
+test("submits a pool credit, reports before and after balances, closes, and refreshes", async () => {
+ renderApp();
+ await screen.findByText("运营应用");
+ fireEvent.click(screen.getByRole("button", { name: "添加水位 lalu / lucky / dynamic_v3" }));
+
+ const dialog = await screen.findByRole("dialog", { name: "添加奖池水位" });
+ expect(within(dialog).getByText("dynamic_v3")).toBeTruthy();
+ fireEvent.change(within(dialog).getByLabelText(/调整金币/), { target: { value: "1000000" } });
+ fireEvent.change(within(dialog).getByLabelText(/调整原因/), { target: { value: "运营补水" } });
+ fireEvent.click(within(dialog).getByRole("button", { name: "确认添加" }));
+
+ await waitFor(() => {
+ expect(adjustLuckyGiftPool).toHaveBeenCalledWith(
+ expect.objectContaining({
+ adjustmentId: expect.any(String),
+ amountCoins: 1_000_000,
+ appCode: "lalu",
+ direction: "in",
+ poolId: "lucky",
+ reason: "运营补水",
+ strategyVersion: "dynamic_v3",
+ }),
+ );
+ });
+ expect(await screen.findByText(/38,559,017 → 39,559,017/)).toBeTruthy();
+ await waitFor(() => expect(screen.queryByRole("dialog", { name: "添加奖池水位" })).toBeNull());
+ expect(fetchOpsDashboard).toHaveBeenCalledTimes(2);
+});
+
+test("keeps the form and adjustment id stable when a debit request is retried", async () => {
+ adjustLuckyGiftPool.mockRejectedValueOnce(new Error("可用水位已变化")).mockResolvedValueOnce({
+ adjustment: { balance_after: 38_558_917, balance_before: 38_559_017 },
+ idempotent_replay: false,
+ });
+ renderApp();
+ await screen.findByText("运营应用");
+ fireEvent.click(screen.getByRole("button", { name: "扣减水位 lalu / lucky / dynamic_v3" }));
+
+ const dialog = await screen.findByRole("dialog", { name: "扣减奖池水位" });
+ fireEvent.change(within(dialog).getByLabelText(/调整金币/), { target: { value: "100" } });
+ fireEvent.change(within(dialog).getByLabelText(/调整原因/), { target: { value: "冲正" } });
+ fireEvent.click(within(dialog).getByRole("button", { name: "确认扣减" }));
+
+ expect(await screen.findByText("可用水位已变化")).toBeTruthy();
+ expect(within(dialog).getByLabelText(/调整金币/)).toHaveValue("100");
+ expect(within(dialog).getByLabelText(/调整原因/)).toHaveValue("冲正");
+ const firstAdjustmentID = adjustLuckyGiftPool.mock.calls[0][0].adjustmentId;
+
+ fireEvent.click(within(dialog).getByRole("button", { name: "确认扣减" }));
+ await waitFor(() => expect(adjustLuckyGiftPool).toHaveBeenCalledTimes(2));
+ expect(adjustLuckyGiftPool.mock.calls[1][0].adjustmentId).toBe(firstAdjustmentID);
+});
+
test("creates a disabled dynamic draft without copying another app monetary limits", async () => {
renderApp();
await screen.findByText("运营应用");
@@ -178,7 +289,7 @@ test("creates a disabled dynamic draft without copying another app monetary limi
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("添加幸运礼物配置")).toBeTruthy();
expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3");
- expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("1000000");
+ expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("0");
// 第一条 lalu 已发布配置的单次上限为 100 万;新建跨 App 配置必须归零并保持停用。
expect(within(dialog).getByLabelText(/单次返奖上限/).value).toBe("0");
expect(within(dialog).getByText("发布后保持停用")).toBeTruthy();
@@ -189,15 +300,16 @@ test("opens config dialog from a published pool row and saves a new version", as
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
- await screen.findByText("lucky");
+ await screen.findAllByText("lucky");
- fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]);
+ fireEvent.click(screen.getByRole("button", { name: "新增版本 lalu / lucky / dynamic_v3" }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("新增幸运礼物版本")).toBeTruthy();
expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3");
- expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("1000000");
+ expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("0");
+ expect(within(dialog).getByLabelText(/初始奖池金币/)).toBeDisabled();
expect(within(dialog).getByLabelText(/大奖倍率/).value).toBe("200, 500, 1000");
- expect(within(dialog).getAllByLabelText("概率 %")[0].readOnly).toBe(true);
+ expect(within(dialog).getByLabelText("新手阶段第 1 档概率 %").readOnly).toBe(true);
fireEvent.change(within(dialog).getByLabelText(/目标 RTP \(%\)/), { target: { value: "88" } });
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" }));
@@ -207,6 +319,7 @@ test("opens config dialog from a published pool row and saves a new version", as
expect.objectContaining({
app_code: "lalu",
anchor_rate_percent: 1,
+ initial_pool_coins: 0,
jackpot_multipliers: [200, 500, 1000],
pool_id: "lucky",
profit_rate_percent: 1,
@@ -222,18 +335,18 @@ test("blocks an invalid enabled dynamic rule before calling the save API", async
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
- await screen.findByText("lucky");
- fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]);
+ await screen.findAllByText("lucky");
+ fireEvent.click(screen.getByRole("button", { name: "新增版本 lalu / lucky / dynamic_v3" }));
const dialog = await screen.findByRole("dialog");
- fireEvent.change(within(dialog).getByLabelText(/初始奖池金币/), { target: { value: "0" } });
+ fireEvent.change(within(dialog).getByLabelText(/低水位金币/), { target: { value: "0" } });
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" }));
- expect(await within(dialog).findByRole("alert")).toHaveTextContent("初始奖池必须大于 0");
+ expect(await within(dialog).findByRole("alert")).toHaveTextContent("高水位必须大于正数低水位");
expect(saveLuckyGiftConfig).not.toHaveBeenCalled();
// 奖档编辑同样必须清掉上一次提交错误,避免用户修正后仍看到已经过期的阻断原因。
- fireEvent.change(within(dialog).getAllByLabelText("倍率")[0], { target: { value: "0.1" } });
+ fireEvent.change(within(dialog).getByLabelText("新手阶段第 1 档倍率"), { target: { value: "0.1" } });
expect(within(dialog).queryByRole("alert")).toBeNull();
});
@@ -247,7 +360,7 @@ function mockDynamicConfig() {
gift_price_reference: 100,
high_water_nonzero_factor_percent: 130,
high_watermark_coins: 20_000_000,
- initial_pool_coins: 1_000_000,
+ initial_pool_coins: 0,
jackpot_global_rtp_max_percent: 98,
jackpot_multipliers: [200, 500, 1000],
jackpot_spend_threshold_coins: 50_000,
diff --git a/ops-center/src/OpsCenterEntry.jsx b/ops-center/src/OpsCenterEntry.jsx
new file mode 100644
index 0000000..43a32ed
--- /dev/null
+++ b/ops-center/src/OpsCenterEntry.jsx
@@ -0,0 +1,26 @@
+import { useEffect } from "react";
+import { useAuth } from "@/app/auth/AuthProvider.jsx";
+import { buildLoginRedirectPath } from "@/features/auth/loginRedirect.js";
+import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
+import { OpsCenterApp } from "./OpsCenterApp.jsx";
+
+export function AuthenticatedOpsCenter({ targetWindow = window }) {
+ const { can, loading, user } = useAuth();
+ const loginPath = buildLoginRedirectPath(targetWindow.location);
+
+ useEffect(() => {
+ if (!loading && !user) {
+ // ops-center 是独立 HTML 入口,不能使用 SPA Navigate。登录页沿用主站既有 `redirect` 协议,
+ // 成功后 LoginPage 会识别 /ops-center/ 为 document entry,再通过 location.replace 回到完整路径。
+ targetWindow.location.replace(loginPath);
+ }
+ }, [loading, loginPath, targetWindow, user]);
+
+ if (loading || !user) {
+ // AuthProvider 可能正在用 refresh cookie 更新过期 token,完成前不能挂载 OpsCenterApp;
+ // 否则 dashboard 的并发请求会先拿旧 token 返回 401,且认证完成后没有新的挂载时机自动恢复。
+ return ;
+ }
+
+ return ;
+}
diff --git a/ops-center/src/OpsCenterEntry.test.jsx b/ops-center/src/OpsCenterEntry.test.jsx
new file mode 100644
index 0000000..fabce8e
--- /dev/null
+++ b/ops-center/src/OpsCenterEntry.test.jsx
@@ -0,0 +1,100 @@
+import { act, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, expect, test, vi } from "vitest";
+import { AuthProvider } from "@/app/auth/AuthProvider.jsx";
+import * as authApi from "@/features/auth/api";
+import { setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "@/shared/api/request";
+import { AuthenticatedOpsCenter } from "./OpsCenterEntry.jsx";
+
+vi.mock("@/features/auth/api", () => ({
+ getMe: vi.fn(),
+ login: vi.fn(),
+ logout: vi.fn(),
+ refreshSession: vi.fn(),
+}));
+
+vi.mock("./OpsCenterApp.jsx", () => ({
+ OpsCenterApp: ({ can }) => (
+
+ ),
+}));
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ setAccessToken("");
+ setRefreshHandler(null);
+ setUnauthorizedHandler(null);
+});
+
+test("does not mount the dashboard without a session and redirects to login with the full ops path", async () => {
+ authApi.refreshSession.mockRejectedValue(new Error("no session"));
+ const targetWindow = fakeWindow("/ops-center/", "?view=configs", "#dynamic_v3");
+
+ render(
+
+
+ ,
+ );
+
+ // refresh 尚未结束以及确定无用户后的跳转窗口都只展示骨架,Dashboard 不会发出任何业务请求。
+ expect(screen.queryByTestId("ops-center-app")).toBeNull();
+ await waitFor(() => {
+ expect(targetWindow.location.replace).toHaveBeenCalledWith(
+ "/login?redirect=%2Fops-center%2F%3Fview%3Dconfigs%23dynamic_v3",
+ );
+ });
+ expect(screen.queryByTestId("ops-center-app")).toBeNull();
+});
+
+test("waits for stale-token refresh fallback before mounting the dashboard", async () => {
+ const refresh = deferred();
+ const me = deferred();
+ setAccessToken("stale-token");
+ authApi.refreshSession.mockReturnValue(refresh.promise);
+ authApi.getMe.mockReturnValue(me.promise);
+ const targetWindow = fakeWindow("/ops-center/", "?view=overview", "");
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.queryByTestId("ops-center-app")).toBeNull();
+ await act(async () => {
+ refresh.reject(new Error("refresh cookie expired"));
+ });
+ await waitFor(() => expect(authApi.getMe).toHaveBeenCalledTimes(1));
+ expect(screen.queryByTestId("ops-center-app")).toBeNull();
+
+ await act(async () => {
+ me.resolve({
+ accessToken: "fresh-token",
+ permissions: ["lucky-gift:pool-credit"],
+ user: { id: 1, username: "ops" },
+ });
+ });
+
+ expect(await screen.findByTestId("ops-center-app")).toHaveAttribute("data-can-credit", "true");
+ expect(targetWindow.location.replace).not.toHaveBeenCalled();
+});
+
+function fakeWindow(pathname, search, hash) {
+ return {
+ location: {
+ hash,
+ pathname,
+ replace: vi.fn(),
+ search,
+ },
+ };
+}
+
+function deferred() {
+ let resolve;
+ let reject;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, reject, resolve };
+}
diff --git a/ops-center/src/api.js b/ops-center/src/api.js
index 9095cce..e206959 100644
--- a/ops-center/src/api.js
+++ b/ops-center/src/api.js
@@ -1,4 +1,5 @@
-import { getAccessToken } from "@/shared/api/request";
+import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
+import { apiRequest } from "@/shared/api/request";
export const OPS_API_PREFIX = "/api/v1/admin/ops-center";
export const DEFAULT_POOL_ID = "default";
@@ -87,20 +88,50 @@ export async function saveLuckyGiftConfig(config = {}) {
});
}
-export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) {
- const response = await fetch(buildOpsUrl(path, query), {
- body: body === undefined ? undefined : JSON.stringify(body),
- credentials: "include",
- headers: buildHeaders(body),
- method,
- });
- const payload = await readPayload(response);
-
- if (!response.ok || payload.code !== 0) {
- throw new Error(payload.message || response.statusText || "运营接口请求失败");
+export async function adjustLuckyGiftPool({
+ adjustmentId,
+ amountCoins,
+ appCode,
+ direction,
+ poolId,
+ reason,
+ strategyVersion,
+} = {}) {
+ if (direction !== "in" && direction !== "out") {
+ throw new Error("奖池水位调整方向不正确");
}
+ const operation = direction === "out" ? API_OPERATIONS.debitLuckyGiftPool : API_OPERATIONS.creditLuckyGiftPool;
+ const operationPath = apiEndpointPath(operation);
- return payload.data ?? {};
+ // adjustment_id 是 owner 侧资金变更的幂等键,同一个弹窗失败重试必须原样复用;request_id 只做链路追踪,
+ // 不能替代业务幂等。方向映射到两条独立路由,使前后端权限也能按补水、扣水分别收敛。
+ const payload = await opsRequest(opsRelativePath(operationPath), {
+ body: {
+ adjustment_id: String(adjustmentId || "").trim(),
+ amount_coins: numberValue(amountCoins),
+ reason: String(reason || "").trim(),
+ },
+ method: "POST",
+ query: {
+ app_code: String(appCode || "")
+ .trim()
+ .toLowerCase(),
+ pool_id: String(poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID,
+ strategy_version: normalizeStrategyVersion(strategyVersion),
+ },
+ });
+ return normalizePoolAdjustmentResult(payload);
+}
+
+export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) {
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
+ // 复用主后台请求层才能共享 refreshOnce:运行期间 token 过期时先刷新再重放一次请求。
+ // 水位写接口携带稳定 adjustment_id,因此认证重放仍由 owner 幂等收敛;409 文案继续原样抛给弹窗保留表单。
+ return apiRequest(`/v1/admin/ops-center${normalizedPath}`, {
+ body,
+ method,
+ query,
+ });
}
export function buildOpsUrl(path, query = {}) {
@@ -118,16 +149,40 @@ export function buildOpsUrl(path, query = {}) {
export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []);
- const poolBalances = perApp
- .flatMap((entry) => entry.poolBalances || [])
- .filter((item) => item?.app_code || item?.appCode);
+ const normalizedPoolBalances = perApp
+ .flatMap((entry) => (entry.poolBalances || []).map((item) => normalizePoolBalance(item, entry.appCode)))
+ .filter((item) => item.app_code);
const appSummaries = perApp
.map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode))
.filter((summary) => summary.app_code);
const publishedConfigs = luckyGifts.filter((config) => !config.is_default);
+ const currentConfigByPool = new Map();
+ luckyGifts.forEach((config) => {
+ const familyKey = luckyGiftPoolFamilyKey(config);
+ const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion);
+ const current = currentConfigByPool.get(familyKey);
+ // 正常接口只返回每个奖池的最新版本;这里仍按最大 rule_version 收敛,避免兼容接口返回多版本时
+ // 由数组顺序把旧策略误判为当前。版本相同则保留首条,确保结果不随返回顺序抖动。
+ if (!current || ruleVersion > current.ruleVersion) {
+ currentConfigByPool.set(familyKey, {
+ ruleVersion,
+ strategyVersion: normalizeStrategyVersion(config.strategy_version ?? config.strategyVersion),
+ });
+ }
+ });
+ // 账本接口会同时返回同一 app+pool 的 V2/V3 独立水位;最新规则配置才决定哪口是当前奖池。
+ // 没有配置可关联的孤立账本继续按当前展示,避免前端误把仍需运营处理的资金静默藏进历史筛选。
+ const poolBalances = normalizedPoolBalances.map((pool) => {
+ const currentStrategy = currentConfigByPool.get(luckyGiftPoolFamilyKey(pool))?.strategyVersion;
+ return {
+ ...pool,
+ is_historical: Boolean(currentStrategy && currentStrategy !== pool.strategy_version),
+ };
+ });
+ const currentPoolBalances = poolBalances.filter((pool) => !pool.is_historical);
- // KPI 一律跨应用聚合。旧版只取第一个应用的汇总,导致 lalu 有大量抽奖时首屏"抽奖次数"仍显示 0。
+ // KPI 一律跨应用聚合;奖池金额只汇总当前策略,避免历史 V2 与当前 V3 的独立账本重复计入运营水位。
const summary = {
activeApps: apps.length,
configuredPools: publishedConfigs.length,
@@ -135,9 +190,9 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
failedDraws: sumBy(appSummaries, "failed_draws"),
grantedDraws: sumBy(appSummaries, "granted_draws"),
pendingDraws: sumBy(appSummaries, "pending_draws"),
- poolAvailableTotal: sumBy(poolBalances, "available_balance", "availableBalance"),
- poolBalanceTotal: sumBy(poolBalances, "balance", "Balance"),
- poolReserveTotal: sumBy(poolBalances, "reserve_floor", "reserveFloor"),
+ poolAvailableTotal: sumBy(currentPoolBalances, "available_balance", "availableBalance"),
+ poolBalanceTotal: sumBy(currentPoolBalances, "balance", "Balance"),
+ poolReserveTotal: sumBy(currentPoolBalances, "reserve_floor", "reserveFloor"),
totalDraws: sumBy(appSummaries, "total_draws"),
totalRewardCoins: sumBy(appSummaries, "total_reward_coins"),
totalSpentCoins: sumBy(appSummaries, "total_spent_coins"),
@@ -146,6 +201,45 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
return { apps, appSummaries, luckyGifts, poolBalances, summary };
}
+export function normalizePoolBalance(pool = {}, fallbackAppCode = "") {
+ return {
+ ...pool,
+ app_code: String(pool.app_code || pool.appCode || fallbackAppCode)
+ .trim()
+ .toLowerCase(),
+ available_balance: numberValue(pool.available_balance ?? pool.availableBalance),
+ balance: numberValue(pool.balance ?? pool.Balance),
+ manual_credit_total: numberValue(pool.manual_credit_total ?? pool.manualCreditTotal),
+ manual_debit_total: numberValue(pool.manual_debit_total ?? pool.manualDebitTotal),
+ materialized: Boolean(pool.materialized),
+ pool_id: String(pool.pool_id || pool.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID,
+ reserve_floor: numberValue(pool.reserve_floor ?? pool.reserveFloor),
+ strategy_version: normalizeStrategyVersion(pool.strategy_version ?? pool.strategyVersion),
+ total_in: numberValue(pool.total_in ?? pool.totalIn),
+ total_out: numberValue(pool.total_out ?? pool.totalOut),
+ updated_at_ms: numberValue(pool.updated_at_ms ?? pool.updatedAtMs),
+ };
+}
+
+export function luckyGiftPoolKey(item = {}) {
+ const appCode = String(item.app_code || item.appCode || "")
+ .trim()
+ .toLowerCase();
+ const poolID = String(item.pool_id || item.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID;
+ const strategyVersion = normalizeStrategyVersion(item.strategy_version ?? item.strategyVersion);
+ // V2/V3 资金由 owner 按策略版本隔离;任何列表关联或 React rowKey 都必须包含 strategy_version,
+ // 否则同一 app+pool 的两口奖池会在前端被覆盖成一行。
+ return `${appCode}:${poolID}:${strategyVersion}`;
+}
+
+function luckyGiftPoolFamilyKey(item = {}) {
+ const appCode = String(item.app_code || item.appCode || "")
+ .trim()
+ .toLowerCase();
+ const poolID = String(item.pool_id || item.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID;
+ return `${appCode}:${poolID}`;
+}
+
function normalizeDrawSummary(summary = {}, appCode = "") {
return {
actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm),
@@ -192,12 +286,39 @@ function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") {
pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID,
rule_version: ruleVersion,
// 升级前规则没有 strategy_version,lucky-gift owner 明确按 fixed_v2 解释;列表和编辑表单必须沿用该兼容语义。
- strategy_version: String(config.strategy_version || config.strategyVersion || "fixed_v2")
- .trim()
- .toLowerCase(),
+ strategy_version: normalizeStrategyVersion(config.strategy_version ?? config.strategyVersion),
};
}
+function normalizePoolAdjustmentResult(payload = {}) {
+ const adjustment = payload.adjustment || {};
+ return {
+ ...payload,
+ adjustment: {
+ ...adjustment,
+ amount_coins: numberValue(adjustment.amount_coins ?? adjustment.amountCoins),
+ balance_after: numberValue(adjustment.balance_after ?? adjustment.balanceAfter),
+ balance_before: numberValue(adjustment.balance_before ?? adjustment.balanceBefore),
+ strategy_version: normalizeStrategyVersion(adjustment.strategy_version ?? adjustment.strategyVersion),
+ },
+ pool: normalizePoolBalance(payload.pool || {}),
+ };
+}
+
+function opsRelativePath(operationPath) {
+ const prefix = "/v1/admin/ops-center";
+ if (!operationPath.startsWith(prefix)) {
+ throw new Error(`运营接口不属于 ops-center: ${operationPath}`);
+ }
+ return operationPath.slice(prefix.length);
+}
+
+function normalizeStrategyVersion(value) {
+ return String(value || "fixed_v2")
+ .trim()
+ .toLowerCase();
+}
+
function luckyGiftConfigPayload(config = {}) {
const appCode = String(config.app_code || config.appCode || "")
.trim()
@@ -222,7 +343,9 @@ function luckyGiftConfigPayload(config = {}) {
config.high_water_nonzero_factor_percent ?? config.highWaterNonzeroFactorPercent,
),
high_watermark_coins: numberValue(config.high_watermark_coins ?? config.highWatermarkCoins),
- initial_pool_coins: numberValue(config.initial_pool_coins ?? config.initialPoolCoins),
+ // dynamic_v3 已改为通过带审计的 pool credit 接口注资;即使旧表单快照仍带 1m,也不能在发布新版本时继续隐式 seed。
+ initial_pool_coins:
+ strategyVersion === "dynamic_v3" ? 0 : numberValue(config.initial_pool_coins ?? config.initialPoolCoins),
jackpot_global_rtp_max_percent: numberValue(
config.jackpot_global_rtp_max_percent ?? config.jackpotGlobalRTPMaxPercent,
),
@@ -230,11 +353,21 @@ function luckyGiftConfigPayload(config = {}) {
jackpot_spend_threshold_coins: numberValue(
config.jackpot_spend_threshold_coins ?? config.jackpotSpendThresholdCoins,
),
- jackpot_user_72h_rtp_max_percent: numberValue(
- config.jackpot_user_72h_rtp_max_percent ?? config.jackpotUser72hRTPMaxPercent,
+ jackpot_user_48h_rtp_max_percent: numberValue(
+ config.jackpot_user_48h_rtp_max_percent ??
+ config.jackpotUser48hRTPMaxPercent ??
+ config.jackpotUser48hRtpMaxPercent ??
+ config.jackpot_user_72h_rtp_max_percent ??
+ config.jackpotUser72hRTPMaxPercent ??
+ config.jackpotUser72hRtpMaxPercent,
),
- jackpot_user_day_rtp_max_percent: numberValue(
- config.jackpot_user_day_rtp_max_percent ?? config.jackpotUserDayRTPMaxPercent,
+ jackpot_user_round_rtp_max_percent: numberValue(
+ config.jackpot_user_round_rtp_max_percent ??
+ config.jackpotUserRoundRTPMaxPercent ??
+ config.jackpotUserRoundRtpMaxPercent ??
+ config.jackpot_user_day_rtp_max_percent ??
+ config.jackpotUserDayRTPMaxPercent ??
+ config.jackpotUserDayRtpMaxPercent,
),
loss_streak_guarantee: numberValue(config.loss_streak_guarantee ?? config.lossStreakGuarantee),
low_water_nonzero_factor_percent: numberValue(
@@ -266,32 +399,6 @@ function luckyGiftConfigPayload(config = {}) {
};
}
-function buildHeaders(body) {
- const headers = {};
- const token = getAccessToken();
-
- if (body !== undefined) {
- headers["Content-Type"] = "application/json";
- }
- if (token) {
- headers.Authorization = `Bearer ${token}`;
- }
-
- return headers;
-}
-
-async function readPayload(response) {
- const text = await response.text();
- if (!text) {
- return { code: response.ok ? 0 : response.status, data: {} };
- }
- try {
- return JSON.parse(text);
- } catch {
- return { code: response.ok ? 0 : response.status, data: text, message: text };
- }
-}
-
function normalizeItems(value) {
if (Array.isArray(value)) {
return value;
diff --git a/ops-center/src/api.test.js b/ops-center/src/api.test.js
index 37b5a12..310c404 100644
--- a/ops-center/src/api.test.js
+++ b/ops-center/src/api.test.js
@@ -1,8 +1,22 @@
import { afterEach, expect, test, vi } from "vitest";
-import { buildOpsUrl, fetchLuckyGiftDraws, fetchOpsDashboard, OPS_API_PREFIX, saveLuckyGiftConfig } from "./api.js";
+import { setAccessToken, setRefreshHandler, setUnauthorizedHandler } from "@/shared/api/request";
+import {
+ adjustLuckyGiftPool,
+ buildOpsUrl,
+ fetchLuckyGiftDraws,
+ fetchOpsDashboard,
+ luckyGiftPoolKey,
+ normalizeDashboard,
+ opsRequest,
+ OPS_API_PREFIX,
+ saveLuckyGiftConfig,
+} from "./api.js";
afterEach(() => {
vi.restoreAllMocks();
+ setAccessToken("");
+ setRefreshHandler(null);
+ setUnauthorizedHandler(null);
});
function jsonResponse(data) {
@@ -19,6 +33,27 @@ test("builds ops api urls under the dedicated prefix", () => {
expect(url).toBe("http://localhost:3000/api/v1/admin/ops-center/apps?app_code=lalu&status=active");
});
+test("refreshes a stale access token before retrying an ops-center request", async () => {
+ setAccessToken("stale-token");
+ setRefreshHandler(async () => {
+ setAccessToken("fresh-token");
+ return true;
+ });
+ vi.spyOn(window, "fetch")
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ code: 40100, message: "访问凭证已失效" }), {
+ headers: { "Content-Type": "application/json" },
+ status: 401,
+ }),
+ )
+ .mockResolvedValueOnce(jsonResponse({ items: [{ app_code: "lalu" }] }));
+
+ await expect(opsRequest("/apps")).resolves.toEqual({ items: [{ app_code: "lalu" }] });
+ expect(window.fetch).toHaveBeenCalledTimes(2);
+ expect(window.fetch.mock.calls[0][1].headers.Authorization).toBe("Bearer stale-token");
+ expect(window.fetch.mock.calls[1][1].headers.Authorization).toBe("Bearer fresh-token");
+});
+
test("loads every pool config per app through the plural configs endpoint", async () => {
const calls = [];
vi.spyOn(window, "fetch").mockImplementation(async (url) => {
@@ -208,12 +243,12 @@ test("saves lucky gift config through admin ops route", async () => {
gift_price_reference: 100,
high_water_nonzero_factor_percent: 130,
high_watermark_coins: 20_000_000,
- initial_pool_coins: 1_000_000,
+ initial_pool_coins: 0,
jackpot_global_rtp_max_percent: 98,
jackpot_multipliers: [200, 500, 1000],
jackpot_spend_threshold_coins: 50_000,
- jackpot_user_72h_rtp_max_percent: 96,
- jackpot_user_day_rtp_max_percent: 96,
+ jackpot_user_48h_rtp_max_percent: 96,
+ jackpot_user_round_rtp_max_percent: 96,
loss_streak_guarantee: 5,
low_water_nonzero_factor_percent: 70,
low_watermark_coins: 10_000_000,
@@ -250,3 +285,95 @@ test("saves lucky gift config through admin ops route", async () => {
user_hourly_payout_cap: 34_200,
});
});
+
+test("keeps fixed_v2 and dynamic_v3 balances independent for the same app and pool", () => {
+ const data = normalizeDashboard({
+ apps: [{ app_code: "lalu" }],
+ perApp: [
+ {
+ appCode: "lalu",
+ poolBalances: [
+ {
+ availableBalance: 700,
+ balance: 900,
+ manualCreditTotal: 100,
+ manualDebitTotal: 20,
+ poolId: "lucky",
+ reserveFloor: 200,
+ strategyVersion: "fixed_v2",
+ },
+ {
+ available_balance: 3_000,
+ balance: 4_000,
+ manual_credit_total: 2_000,
+ manual_debit_total: 500,
+ pool_id: "lucky",
+ reserve_floor: 1_000,
+ strategy_version: "dynamic_v3",
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(data.poolBalances).toHaveLength(2);
+ expect(new Set(data.poolBalances.map(luckyGiftPoolKey))).toEqual(
+ new Set(["lalu:lucky:fixed_v2", "lalu:lucky:dynamic_v3"]),
+ );
+ expect(data.poolBalances[0]).toMatchObject({ manual_credit_total: 100, manual_debit_total: 20 });
+ expect(data.poolBalances[1]).toMatchObject({ manual_credit_total: 2_000, manual_debit_total: 500 });
+ expect(data.summary.poolBalanceTotal).toBe(4_900);
+});
+
+test.each([
+ ["in", "credit"],
+ ["out", "debit"],
+])("posts %s pool adjustments with the strategy identity and idempotency key", async (direction, route) => {
+ const calls = [];
+ vi.spyOn(window, "fetch").mockImplementation(async (url, init) => {
+ calls.push({ body: JSON.parse(init.body), method: init.method, url: String(url) });
+ return jsonResponse({
+ adjustment: {
+ adjustment_id: "adjust-1",
+ amount_coins: 1_000,
+ balance_after: direction === "out" ? 9_000 : 11_000,
+ balance_before: 10_000,
+ strategy_version: "dynamic_v3",
+ },
+ idempotent_replay: false,
+ pool: {
+ app_code: "lalu",
+ available_balance: 8_000,
+ balance: direction === "out" ? 9_000 : 11_000,
+ manual_credit_total: direction === "in" ? 1_000 : 0,
+ manual_debit_total: direction === "out" ? 1_000 : 0,
+ materialized: true,
+ pool_id: "lucky",
+ reserve_floor: 1_000,
+ strategy_version: "dynamic_v3",
+ },
+ });
+ });
+
+ const result = await adjustLuckyGiftPool({
+ adjustmentId: "adjust-1",
+ amountCoins: 1_000,
+ appCode: "LALU",
+ direction,
+ poolId: "lucky",
+ reason: " 运营调节 ",
+ strategyVersion: "dynamic_v3",
+ });
+
+ expect(calls).toEqual([
+ {
+ body: { adjustment_id: "adjust-1", amount_coins: 1_000, reason: "运营调节" },
+ method: "POST",
+ url: `http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/pools/${route}?app_code=lalu&pool_id=lucky&strategy_version=dynamic_v3`,
+ },
+ ]);
+ expect(result).toMatchObject({
+ adjustment: { balance_before: 10_000, strategy_version: "dynamic_v3" },
+ pool: { app_code: "lalu", pool_id: "lucky", strategy_version: "dynamic_v3" },
+ });
+});
diff --git a/ops-center/src/components/ConfigsView.jsx b/ops-center/src/components/ConfigsView.jsx
index dff4af3..278c7ff 100644
--- a/ops-center/src/components/ConfigsView.jsx
+++ b/ops-center/src/components/ConfigsView.jsx
@@ -4,10 +4,25 @@ import { AdminFilterSelect, AdminListToolbar } from "@/shared/ui/AdminListLayout
import { Button } from "@/shared/ui/Button.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { TimeText } from "@/shared/ui/TimeText.jsx";
+import { luckyGiftPoolKey } from "../api.js";
import { formatNumber, formatPercent } from "../format.js";
+import { LuckyGiftPoolActions } from "./LuckyGiftPoolActions.jsx";
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
-export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
+export function ConfigsView({
+ apps,
+ canCredit,
+ canDebit,
+ canUpdate,
+ disabled,
+ luckyGifts,
+ onCreate,
+ onCredit,
+ onDebit,
+ onEdit,
+ poolBalances,
+ saving,
+}) {
const [appFilter, setAppFilter] = useState("");
const appOptions = useMemo(
@@ -19,25 +34,46 @@ export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
[apps],
);
// 配置列表已在 dashboard 阶段跨应用拉全,应用筛选只做前端过滤,避免重复扇出请求。
- const items = useMemo(
- () => (appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts),
- [appFilter, luckyGifts],
- );
+ const items = useMemo(() => {
+ const poolByKey = new Map(poolBalances.map((pool) => [luckyGiftPoolKey(pool), pool]));
+ const visibleConfigs = appFilter ? luckyGifts.filter((config) => config.app_code === appFilter) : luckyGifts;
+ // 余额必须按 app+pool+strategy 精确关联;同一个 app/pool 同时存在 fixed_v2 和 dynamic_v3 时,
+ // 任何缺少 strategy_version 的关联都会把两口独立资金池串行或覆盖。
+ return visibleConfigs.map((config) => ({
+ ...config,
+ pool_balance: poolByKey.get(luckyGiftPoolKey(config)),
+ }));
+ }, [appFilter, luckyGifts, poolBalances]);
- const columns = useMemo(() => buildColumns({ onEdit, saving }), [onEdit, saving]);
+ const columns = useMemo(
+ () =>
+ buildColumns({
+ canCredit,
+ canDebit,
+ canUpdate,
+ disabled,
+ onCredit,
+ onDebit,
+ onEdit,
+ saving,
+ }),
+ [canCredit, canDebit, canUpdate, disabled, onCredit, onDebit, onEdit, saving],
+ );
return (
}
- variant="primary"
- onClick={onCreate}
- >
- 添加配置
-
+ canUpdate ? (
+
}
+ variant="primary"
+ onClick={onCreate}
+ >
+ 添加配置
+
+ ) : null
}
filters={
`${item.app_code}:${item.pool_id}:${item.rule_version}`}
+ minWidth="1540px"
+ rowKey={(item) => `${luckyGiftPoolKey(item)}:${item.rule_version}`}
title="幸运礼物配置"
total={items.length}
/>
@@ -61,8 +97,8 @@ export function ConfigsView({ apps, luckyGifts, onCreate, onEdit, saving }) {
);
}
-function buildColumns({ onEdit, saving }) {
- return [
+function buildColumns({ canCredit, canDebit, canUpdate, disabled, onCredit, onDebit, onEdit, saving }) {
+ const columns = [
{ key: "app_code", label: "应用", width: "minmax(88px, 0.7fr)" },
{ key: "pool_id", label: "奖池", width: "minmax(110px, 0.9fr)" },
{
@@ -79,6 +115,12 @@ function buildColumns({ onEdit, saving }) {
render: (item) => item.strategy_version || "fixed_v2",
width: "minmax(112px, 0.8fr)",
},
+ {
+ key: "pool_balance",
+ label: "奖池余额",
+ render: (item) => (item.pool_balance ? formatNumber(item.pool_balance.balance) : "-"),
+ width: "minmax(120px, 0.9fr)",
+ },
{
key: "enabled",
label: "状态",
@@ -115,14 +157,46 @@ function buildColumns({ onEdit, saving }) {
render: (item) => (item.is_default ? "-" : ),
width: "minmax(176px, 1.1fr)",
},
- {
+ ];
+
+ if (canUpdate || canCredit || canDebit) {
+ columns.push({
+ fixed: "right",
key: "actions",
label: "操作",
- render: (item) => (
-
- ),
- },
- ];
+ render: (item) => {
+ const identity = `${item.app_code} / ${item.pool_id} / ${item.strategy_version}`;
+ const actionPool = item.pool_balance || {
+ app_code: item.app_code,
+ pool_id: item.pool_id,
+ strategy_version: item.strategy_version,
+ };
+ return (
+
+
+ {canUpdate ? (
+
+ ) : null}
+
+ );
+ },
+ width: `${(canCredit ? 110 : 0) + (canDebit ? 110 : 0) + (canUpdate ? 110 : 0) + 20}px`,
+ });
+ }
+
+ return columns;
}
diff --git a/ops-center/src/components/LuckyGiftConfigDialog.jsx b/ops-center/src/components/LuckyGiftConfigDialog.jsx
index 54bc3b1..590c4f3 100644
--- a/ops-center/src/components/LuckyGiftConfigDialog.jsx
+++ b/ops-center/src/components/LuckyGiftConfigDialog.jsx
@@ -1,16 +1,9 @@
-import AddOutlined from "@mui/icons-material/AddOutlined";
-import Checkbox from "@mui/material/Checkbox";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
-import FormControlLabel from "@mui/material/FormControlLabel";
-import MenuItem from "@mui/material/MenuItem";
-import TextField from "@mui/material/TextField";
-import { useMemo, useState } from "react";
-import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
+import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/shared/ui/Button.jsx";
-import { formatDecimal } from "@/shared/utils/rtpProbability.js";
import {
applyDynamicStrategyDefaults,
configToForm,
@@ -21,12 +14,14 @@ import {
validateLuckyGiftForm,
} from "../configForm.js";
import { formatNumber, formatPercent } from "../format.js";
-import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
+import { LuckyGiftConfigSections } from "./LuckyGiftConfigSections.jsx";
+import { LuckyGiftStageDesigner } from "./LuckyGiftStageDesigner.jsx";
export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) {
const [form, setForm] = useState(() => configToForm(config));
const [activeStage, setActiveStage] = useState("novice");
const [validationErrors, setValidationErrors] = useState([]);
+ const validationRef = useRef(null);
const stageSummaries = useMemo(() => form.stages.map((stage) => stageSummary(stage, form)), [form]);
const activeStageKey = form.stages.some((stage) => stage.stage === activeStage)
@@ -34,6 +29,13 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
: form.stages[0]?.stage;
const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey);
const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey);
+ const normalStageConfig = form.stages.find((stage) => stage.stage === "normal");
+
+ useEffect(() => {
+ if (!validationErrors.length) return;
+ // 保存按钮位于固定底栏;失败后把焦点送回错误摘要,避免键盘或读屏用户停留在无变化的按钮上。
+ validationRef.current?.focus();
+ }, [validationErrors]);
const updateField = (field, value) => {
setValidationErrors([]);
@@ -87,7 +89,8 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
...stage.tiers,
{
enabled: true,
- // 10 倍以上属于高倍档,默认只在奖池高水位时开放,防止新档位直接击穿保底线。
+ // 10 倍以上先写入服务端要求的高倍率安全标记;实际开奖仍以实时余额和返奖上限
+ // 判断是否付得起,前端默认值不能被解释成“高余额时一定会发”。
highWaterOnly: nextStageMultiplier(stage.tiers) >= 10,
multiplier: String(nextStageMultiplier(stage.tiers)),
probabilityPercent: "0",
@@ -129,31 +132,51 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
const title = isCreate ? "添加幸运礼物配置" : config.is_default ? "发布幸运礼物配置" : "新增幸运礼物版本";
return (
-