merge: lucky gift pool balance controls
This commit is contained in:
commit
460464ee18
@ -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",
|
||||||
@ -8772,6 +8852,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"LuckyGiftPoolAdjustmentResponse": {
|
||||||
|
"description": "奖池水位调整成功或同幂等请求的首次结果",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiResponseLuckyGiftPoolAdjustmentResult"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"RechargeBillPageResponse": {
|
"RechargeBillPageResponse": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"content": {
|
"content": {
|
||||||
@ -9184,6 +9274,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": [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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)}`;
|
||||||
|
}
|
||||||
|
|||||||
@ -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,13 +300,14 @@ 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).getAllByLabelText("概率 %")[0].readOnly).toBe(true);
|
||||||
|
|
||||||
@ -207,6 +319,7 @@ test("opens config dialog from a published pool row and saves a new version", as
|
|||||||
expect.objectContaining({
|
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,14 +335,14 @@ 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();
|
||||||
|
|
||||||
// 奖档编辑同样必须清掉上一次提交错误,避免用户修正后仍看到已经过期的阻断原因。
|
// 奖档编辑同样必须清掉上一次提交错误,避免用户修正后仍看到已经过期的阻断原因。
|
||||||
@ -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,
|
||||||
|
|||||||
26
ops-center/src/OpsCenterEntry.jsx
Normal file
26
ops-center/src/OpsCenterEntry.jsx
Normal 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} />;
|
||||||
|
}
|
||||||
100
ops-center/src/OpsCenterEntry.test.jsx
Normal file
100
ops-center/src/OpsCenterEntry.test.jsx
Normal 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 };
|
||||||
|
}
|
||||||
@ -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 = {}) {
|
||||||
@ -119,8 +150,8 @@ 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 poolBalances = 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);
|
||||||
@ -146,6 +177,37 @@ 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 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 +254,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_version,lucky-gift owner 明确按 fixed_v2 解释;列表和编辑表单必须沿用该兼容语义。
|
// 升级前规则没有 strategy_version,lucky-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 +311,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,
|
||||||
),
|
),
|
||||||
@ -266,32 +357,6 @@ function luckyGiftConfigPayload(config = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHeaders(body) {
|
|
||||||
const headers = {};
|
|
||||||
const token = getAccessToken();
|
|
||||||
|
|
||||||
if (body !== undefined) {
|
|
||||||
headers["Content-Type"] = "application/json";
|
|
||||||
}
|
|
||||||
if (token) {
|
|
||||||
headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readPayload(response) {
|
|
||||||
const text = await response.text();
|
|
||||||
if (!text) {
|
|
||||||
return { code: response.ok ? 0 : response.status, data: {} };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
return { code: response.ok ? 0 : response.status, data: text, message: text };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeItems(value) {
|
function normalizeItems(value) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@ -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,7 +243,7 @@ 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,
|
||||||
@ -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" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -444,7 +444,14 @@ function DynamicStrategySections({ form, onChange }) {
|
|||||||
<section className="ops-config-section">
|
<section className="ops-config-section">
|
||||||
<h3>动态水位</h3>
|
<h3>动态水位</h3>
|
||||||
<div className="ops-form-grid">
|
<div className="ops-form-grid">
|
||||||
<NumberField form={form} label="初始奖池金币" name="initial_pool_coins" onChange={onChange} />
|
<NumberField
|
||||||
|
disabled
|
||||||
|
form={form}
|
||||||
|
helperText="dynamic_v3 发布规则不注资,请在奖池水位中添加金币"
|
||||||
|
label="初始奖池金币"
|
||||||
|
name="initial_pool_coins"
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
<NumberField form={form} label="连续未中奖保护" name="loss_streak_guarantee" onChange={onChange} />
|
<NumberField form={form} label="连续未中奖保护" name="loss_streak_guarantee" onChange={onChange} />
|
||||||
<NumberField form={form} label="低水位金币" name="low_watermark_coins" onChange={onChange} />
|
<NumberField form={form} label="低水位金币" name="low_watermark_coins" onChange={onChange} />
|
||||||
<NumberField
|
<NumberField
|
||||||
@ -550,9 +557,10 @@ function ConfigMetric({ label, value }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NumberField({ form, helperText, label, name, onChange, step = "1" }) {
|
function NumberField({ disabled = false, form, helperText, label, name, onChange, step = "1" }) {
|
||||||
return (
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
helperText={helperText}
|
helperText={helperText}
|
||||||
label={label}
|
label={label}
|
||||||
required
|
required
|
||||||
|
|||||||
48
ops-center/src/components/LuckyGiftPoolActions.jsx
Normal file
48
ops-center/src/components/LuckyGiftPoolActions.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
ops-center/src/components/LuckyGiftPoolAdjustmentDialog.jsx
Normal file
132
ops-center/src/components/LuckyGiftPoolAdjustmentDialog.jsx
Normal 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;
|
||||||
|
}
|
||||||
@ -2,123 +2,168 @@ 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 } from "react";
|
||||||
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 poolBalanceColumns = useMemo(
|
||||||
|
() => buildPoolBalanceColumns({ canCredit, canDebit, disabled, onCredit, onDebit }),
|
||||||
|
[canCredit, canDebit, disabled, onCredit, onDebit],
|
||||||
|
);
|
||||||
|
|
||||||
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
|
<DataTable
|
||||||
columns={poolBalanceColumns}
|
columns={poolBalanceColumns}
|
||||||
emptyLabel="暂无奖池余额"
|
emptyLabel="暂无奖池余额"
|
||||||
items={poolBalances}
|
items={poolBalances}
|
||||||
minWidth="920px"
|
minWidth="1480px"
|
||||||
rowKey={(item) => `${item.app_code ?? item.appCode}:${item.pool_id ?? item.poolId}`}
|
rowKey={luckyGiftPoolKey}
|
||||||
title="奖池实时水位"
|
title="奖池实时水位"
|
||||||
total={poolBalances.length}
|
total={poolBalances.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) =>
|
||||||
},
|
item.materialized === false ? (
|
||||||
{ key: "balance", label: "当前余额", render: (item) => formatNumber(item.balance ?? item.Balance) },
|
<OpsStatusBadge label="默认水位" />
|
||||||
{ 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)" },
|
<OpsStatusBadge label="已入账" tone="succeeded" />
|
||||||
{ key: "total_in", label: "累计流入", render: (item) => formatNumber(item.total_in ?? item.totalIn) },
|
),
|
||||||
{ key: "total_out", label: "累计流出", render: (item) => formatNumber(item.total_out ?? item.totalOut) },
|
width: "minmax(132px, 0.9fr)",
|
||||||
{
|
},
|
||||||
key: "updated_at_ms",
|
{ key: "balance", label: "当前余额", render: (item) => formatNumber(item.balance) },
|
||||||
label: "更新时间",
|
{ key: "available_balance", label: "可用余额", render: (item) => formatNumber(item.available_balance) },
|
||||||
render: (item) =>
|
{
|
||||||
item.materialized === false ? "-" : <TimeText value={item.updated_at_ms ?? item.updatedAtMs} />,
|
key: "reserve_floor",
|
||||||
width: "minmax(150px, 1fr)"
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,7 +21,8 @@ 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,
|
||||||
@ -113,6 +114,11 @@ export function configToForm(config = {}) {
|
|||||||
form[field] = targetRTPPercent;
|
form[field] = targetRTPPercent;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (field === "initial_pool_coins" && strategyVersion === DYNAMIC_STRATEGY) {
|
||||||
|
// 旧 dynamic_v3 快照可能仍保存 1,000,000;新增版本必须显式归零,不能把历史隐式注资语义继续复制。
|
||||||
|
form[field] = "0";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
form[field] = formConfigNumber(config, field, snakeToCamel(field), defaultNumber(field, strategyVersion));
|
form[field] = formConfigNumber(config, field, snakeToCamel(field), defaultNumber(field, strategyVersion));
|
||||||
}
|
}
|
||||||
return form;
|
return form;
|
||||||
@ -140,7 +146,10 @@ export function formToConfig(config, form) {
|
|||||||
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]);
|
||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
@ -270,7 +279,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("高水位必须大于正数低水位");
|
||||||
|
|||||||
@ -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",
|
||||||
@ -49,6 +49,15 @@ describe("ops center lucky gift dynamic_v3 form", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("drops legacy dynamic initial seed when loading and publishing a new version", () => {
|
||||||
|
const legacy = completeDynamicConfig();
|
||||||
|
legacy.initial_pool_coins = 1_000_000;
|
||||||
|
|
||||||
|
const form = configToForm(legacy);
|
||||||
|
expect(form.initial_pool_coins).toBe("0");
|
||||||
|
expect(formToConfig(legacy, form).initial_pool_coins).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("allows disabled drafts but blocks enabled dynamic rules until app monetary limits are filled", () => {
|
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([]);
|
||||||
@ -98,7 +107,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],
|
||||||
@ -144,7 +158,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,
|
||||||
|
|||||||
@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -147,6 +147,43 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- 幸运礼物配置弹窗 ---- */
|
/* ---- 幸运礼物配置弹窗 ---- */
|
||||||
|
|
||||||
.ops-config-form {
|
.ops-config-form {
|
||||||
@ -421,6 +458,7 @@ body {
|
|||||||
.ops-nav,
|
.ops-nav,
|
||||||
.ops-kpi-grid,
|
.ops-kpi-grid,
|
||||||
.ops-config-metrics,
|
.ops-config-metrics,
|
||||||
|
.ops-pool-adjustment-metrics,
|
||||||
.ops-form-grid,
|
.ops-form-grid,
|
||||||
.ops-stage-thresholds,
|
.ops-stage-thresholds,
|
||||||
.ops-tier-row {
|
.ops-tier-row {
|
||||||
|
|||||||
@ -187,6 +187,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",
|
||||||
|
|||||||
@ -80,9 +80,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",
|
||||||
@ -871,6 +873,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,
|
||||||
@ -892,6 +901,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,
|
||||||
|
|||||||
167
src/shared/api/generated/schema.d.ts
vendored
167
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||||
@ -4680,6 +4712,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"];
|
||||||
};
|
};
|
||||||
@ -7086,6 +7176,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: {
|
||||||
@ -8007,6 +8106,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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user