资源相关

This commit is contained in:
zhx 2026-07-17 16:55:15 +08:00
parent dc8b9e488d
commit 169ada93b5
23 changed files with 1078 additions and 103 deletions

View File

@ -354,6 +354,30 @@
"x-permissions": ["lucky-gift:view"] "x-permissions": ["lucky-gift:view"]
} }
}, },
"/admin/ops-center/lucky-gifts/config/versions": {
"get": {
"operationId": "listLuckyGiftConfigVersions",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "lucky-gift:view",
"x-permissions": ["lucky-gift:view"]
}
},
"/admin/ops-center/lucky-gifts/config/rollback": {
"post": {
"operationId": "rollbackLuckyGiftConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "lucky-gift:update",
"x-permissions": ["lucky-gift:update"]
}
},
"/admin/ops-center/lucky-gifts/draws": { "/admin/ops-center/lucky-gifts/draws": {
"get": { "get": {
"operationId": "listLuckyGiftDraws", "operationId": "listLuckyGiftDraws",

View File

@ -15,8 +15,10 @@ import {
createLuckyGiftExperiment, createLuckyGiftExperiment,
DEFAULT_POOL_ID, DEFAULT_POOL_ID,
endLuckyGiftExperiment, endLuckyGiftExperiment,
fetchLuckyGiftConfigVersions,
fetchLuckyGiftExperiments, fetchLuckyGiftExperiments,
fetchOpsDashboard, fetchOpsDashboard,
rollbackLuckyGiftConfig,
saveLuckyGiftConfig, saveLuckyGiftConfig,
setLuckyGiftExperimentOverrides, setLuckyGiftExperimentOverrides,
updateLuckyGiftExperiment, updateLuckyGiftExperiment,
@ -27,6 +29,7 @@ import { DrawsView } from "./components/DrawsView.jsx";
import { LuckyGiftConfigDialog } from "./components/LuckyGiftConfigDialog.jsx"; import { LuckyGiftConfigDialog } from "./components/LuckyGiftConfigDialog.jsx";
import { LuckyGiftExperimentManageDialog } from "./components/LuckyGiftExperimentManageDialog.jsx"; import { LuckyGiftExperimentManageDialog } from "./components/LuckyGiftExperimentManageDialog.jsx";
import { LuckyGiftPoolAdjustmentDialog } from "./components/LuckyGiftPoolAdjustmentDialog.jsx"; import { LuckyGiftPoolAdjustmentDialog } from "./components/LuckyGiftPoolAdjustmentDialog.jsx";
import { LuckyGiftRollbackDialog } from "./components/LuckyGiftRollbackDialog.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 { EXPERIMENT_ARM_LABELS, formatTrafficPercent } from "./experimentForm.js"; import { EXPERIMENT_ARM_LABELS, formatTrafficPercent } from "./experimentForm.js";
@ -50,6 +53,7 @@ const initialDashboard = {
apps: [], apps: [],
appSummaries: [], appSummaries: [],
experiments: [], experiments: [],
giftHostDiamondRatiosByApp: {},
luckyGifts: [], luckyGifts: [],
poolBalances: [], poolBalances: [],
summary: {}, summary: {},
@ -72,10 +76,12 @@ export function OpsCenterApp({ can = denyPermission }) {
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 [poolDialog, setPoolDialog] = useState(null);
const [rollbackDialog, setRollbackDialog] = useState(null);
const [experimentManage, setExperimentManage] = useState(null); const [experimentManage, setExperimentManage] = useState(null);
const [profileRefreshKey, setProfileRefreshKey] = useState(0); const [profileRefreshKey, setProfileRefreshKey] = useState(0);
const [adjustingPool, setAdjustingPool] = useState(false); const [adjustingPool, setAdjustingPool] = useState(false);
const [savingConfig, setSavingConfig] = useState(false); const [savingConfig, setSavingConfig] = useState(false);
const [rollbackBusy, setRollbackBusy] = useState(false);
const [experimentBusy, setExperimentBusy] = useState(false); const [experimentBusy, setExperimentBusy] = useState(false);
const { showToast } = useToast(); const { showToast } = useToast();
const canCreditPool = can(PERMISSIONS.luckyGiftPoolCredit); const canCreditPool = can(PERMISSIONS.luckyGiftPoolCredit);
@ -175,6 +181,64 @@ export function OpsCenterApp({ can = denyPermission }) {
setDialog({ config: structuredClone(config), mode: "experiment" }); setDialog({ config: structuredClone(config), mode: "experiment" });
}, []); }, []);
const loadRollbackVersions = useCallback(async (config) => {
const identity = `${config.app_code}/${config.pool_id}`;
setRollbackDialog((current) =>
current && current.identity === identity
? { ...current, error: "", loading: true }
: { config: structuredClone(config), error: "", identity, loading: true, versions: [] },
);
try {
const versions = await fetchLuckyGiftConfigVersions(config.app_code, config.pool_id);
setRollbackDialog((current) =>
current && current.identity === identity ? { ...current, loading: false, versions } : current,
);
} catch (error) {
setRollbackDialog((current) =>
current && current.identity === identity
? { ...current, error: error.message || "获取历史版本失败", loading: false }
: current,
);
}
}, []);
const openRollback = useCallback(
(config) => {
// app+pool dashboard
loadRollbackVersions(config);
},
[loadRollbackVersions],
);
const handleRollback = useCallback(
async (targetRuleVersion) => {
const current = rollbackDialog?.config;
if (!current) {
return;
}
setRollbackBusy(true);
try {
const result = await rollbackLuckyGiftConfig({
appCode: current.app_code,
poolId: current.pool_id,
targetRuleVersion,
});
showToast(
`已将 v${result.sourceRuleVersion || targetRuleVersion} 的配置发布为 v${result.config.rule_version}`,
"success",
);
setRollbackDialog(null);
await loadDashboard();
} catch (error) {
// owner
showToast(error.message || "回滚幸运礼物版本失败", "error");
} finally {
setRollbackBusy(false);
}
},
[loadDashboard, rollbackDialog, showToast],
);
const openPoolAdjustment = useCallback((pool, direction) => { const openPoolAdjustment = useCallback((pool, direction) => {
if (!pool) { if (!pool) {
return; return;
@ -438,13 +502,14 @@ export function OpsCenterApp({ can = denyPermission }) {
experiments={data.experiments} experiments={data.experiments}
luckyGifts={data.luckyGifts} luckyGifts={data.luckyGifts}
poolBalances={data.poolBalances} poolBalances={data.poolBalances}
saving={savingConfig} saving={savingConfig || rollbackBusy}
onCreate={openCreateConfig} onCreate={openCreateConfig}
onCreateExperiment={openCreateExperiment} onCreateExperiment={openCreateExperiment}
onCredit={(pool) => openPoolAdjustment(pool, "in")} onCredit={(pool) => openPoolAdjustment(pool, "in")}
onDebit={(pool) => openPoolAdjustment(pool, "out")} onDebit={(pool) => openPoolAdjustment(pool, "out")}
onEdit={openEditConfig} onEdit={openEditConfig}
onManageExperiment={openExperimentManage} onManageExperiment={openExperimentManage}
onRollback={openRollback}
/> />
) : null} ) : null}
{activeView === "draws" ? ( {activeView === "draws" ? (
@ -466,6 +531,7 @@ export function OpsCenterApp({ can = denyPermission }) {
<LuckyGiftConfigDialog <LuckyGiftConfigDialog
appOptions={appOptions} appOptions={appOptions}
config={dialog.config} config={dialog.config}
giftHostDiamondRatiosByApp={data.giftHostDiamondRatiosByApp}
mode={dialog.mode} mode={dialog.mode}
saving={savingConfig} saving={savingConfig}
onClose={() => setDialog(null)} onClose={() => setDialog(null)}
@ -504,6 +570,18 @@ export function OpsCenterApp({ can = denyPermission }) {
onSubmit={handleAdjustPool} onSubmit={handleAdjustPool}
/> />
) : null} ) : null}
{rollbackDialog ? (
<LuckyGiftRollbackDialog
config={rollbackDialog.config}
error={rollbackDialog.error}
loading={rollbackDialog.loading}
submitting={rollbackBusy}
versions={rollbackDialog.versions}
onClose={() => setRollbackDialog(null)}
onRetry={() => loadRollbackVersions(rollbackDialog.config)}
onSubmit={handleRollback}
/>
) : null}
</div> </div>
</OpsShell> </OpsShell>
); );

View File

@ -7,8 +7,10 @@ import {
createLuckyGiftExperiment, createLuckyGiftExperiment,
endLuckyGiftExperiment, endLuckyGiftExperiment,
fetchLuckyGiftDraws, fetchLuckyGiftDraws,
fetchLuckyGiftConfigVersions,
fetchLuckyGiftExperiments, fetchLuckyGiftExperiments,
fetchOpsDashboard, fetchOpsDashboard,
rollbackLuckyGiftConfig,
saveLuckyGiftConfig, saveLuckyGiftConfig,
setLuckyGiftExperimentOverrides, setLuckyGiftExperimentOverrides,
updateLuckyGiftExperiment, updateLuckyGiftExperiment,
@ -21,10 +23,12 @@ vi.mock("./api.js", () => ({
createLuckyGiftExperiment: vi.fn(), createLuckyGiftExperiment: vi.fn(),
endLuckyGiftExperiment: vi.fn(), endLuckyGiftExperiment: vi.fn(),
fetchLuckyGiftDraws: vi.fn(), fetchLuckyGiftDraws: vi.fn(),
fetchLuckyGiftConfigVersions: vi.fn(),
fetchLuckyGiftExperiments: vi.fn(), fetchLuckyGiftExperiments: vi.fn(),
fetchOpsDashboard: vi.fn(), fetchOpsDashboard: vi.fn(),
luckyGiftPoolKey: (item = {}) => luckyGiftPoolKey: (item = {}) =>
`${item.app_code || item.appCode}:${item.pool_id || item.poolId}:${item.strategy_version || item.strategyVersion || "fixed_v2"}`, `${item.app_code || item.appCode}:${item.pool_id || item.poolId}:${item.strategy_version || item.strategyVersion || "fixed_v2"}`,
rollbackLuckyGiftConfig: vi.fn(),
saveLuckyGiftConfig: vi.fn(), saveLuckyGiftConfig: vi.fn(),
setLuckyGiftExperimentOverrides: vi.fn(), setLuckyGiftExperimentOverrides: vi.fn(),
updateLuckyGiftExperiment: vi.fn(), updateLuckyGiftExperiment: vi.fn(),
@ -55,6 +59,11 @@ beforeEach(() => {
adjustment: { balance_after: 39_559_017, balance_before: 38_559_017 }, adjustment: { balance_after: 39_559_017, balance_before: 38_559_017 },
idempotent_replay: false, idempotent_replay: false,
}); });
fetchLuckyGiftConfigVersions.mockResolvedValue([]);
rollbackLuckyGiftConfig.mockResolvedValue({
config: { app_code: "lalu", pool_id: "super_lucky", rule_version: 22 },
sourceRuleVersion: 20,
});
fetchLuckyGiftDraws.mockResolvedValue({ fetchLuckyGiftDraws.mockResolvedValue({
items: [ items: [
{ {
@ -76,6 +85,10 @@ beforeEach(() => {
}); });
fetchOpsDashboard.mockResolvedValue({ fetchOpsDashboard.mockResolvedValue({
experiments: [mockRunningExperiment()], experiments: [mockRunningExperiment()],
giftHostDiamondRatiosByApp: {
lalu: { lucky: 10, super_lucky: 1 },
yumi: { lucky: 10, super_lucky: 1 },
},
apps: [ apps: [
{ app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 }, { app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 },
{ app_code: "yumi", app_name: "Yumi", source: "fixed", status: "active" }, { app_code: "yumi", app_name: "Yumi", source: "fixed", status: "active" },
@ -415,7 +428,7 @@ test("opens config dialog from a published pool row and saves a new version", as
expect(saveLuckyGiftConfig).toHaveBeenCalledWith( expect(saveLuckyGiftConfig).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
app_code: "lalu", app_code: "lalu",
anchor_rate_percent: 1, anchor_rate_percent: 10,
initial_pool_coins: 0, initial_pool_coins: 0,
jackpot_multipliers: [200, 500, 1000], jackpot_multipliers: [200, 500, 1000],
pool_id: "lucky", pool_id: "lucky",
@ -432,7 +445,8 @@ 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.findAllByText("lucky"); // dashboard
expect(screen.getAllByText("lucky").length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole("button", { name: "新增版本 lalu / lucky / dynamic_v3" })); fireEvent.click(screen.getByRole("button", { name: "新增版本 lalu / lucky / dynamic_v3" }));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
@ -450,7 +464,7 @@ test("blocks an invalid enabled dynamic rule before calling the save API", async
function mockDynamicConfig() { function mockDynamicConfig() {
return { return {
anchor_daily_payout_cap: 6_000_000, anchor_daily_payout_cap: 6_000_000,
anchor_rate_percent: 1, anchor_rate_percent: 10,
control_band_percent: 3, control_band_percent: 3,
device_daily_payout_cap: 4_000_000, device_daily_payout_cap: 4_000_000,
effective_from_ms: 0, effective_from_ms: 0,
@ -470,15 +484,15 @@ function mockDynamicConfig() {
max_single_payout: 1_000_000, max_single_payout: 1_000_000,
normal_max_equivalent_draws: 20_000, normal_max_equivalent_draws: 20_000,
novice_max_equivalent_draws: 2_000, novice_max_equivalent_draws: 2_000,
pool_rate_percent: 98, pool_rate_percent: 89,
profit_rate_percent: 1, profit_rate_percent: 1,
recharge_boost_factor_percent: 110, recharge_boost_factor_percent: 110,
recharge_boost_window_ms: 300_000, recharge_boost_window_ms: 300_000,
room_hourly_payout_cap: 5_000_000, room_hourly_payout_cap: 5_000_000,
settlement_window_wager: 1_000_000, settlement_window_wager: 1_000_000,
stages: mockStages(98), stages: mockStages(89),
strategy_version: "dynamic_v3", strategy_version: "dynamic_v3",
target_rtp_percent: 98, target_rtp_percent: 89,
user_daily_payout_cap: 3_000_000, user_daily_payout_cap: 3_000_000,
user_hourly_payout_cap: 2_000_000, user_hourly_payout_cap: 2_000_000,
}; };

View File

@ -4,6 +4,7 @@ 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";
const USER_24H_RTP_DISABLED_FACTOR_PERCENT = 100; const USER_24H_RTP_DISABLED_FACTOR_PERCENT = 100;
const GIFT_DIAMOND_RATIOS_PATH = "/v1/admin/operations/gift-diamond-ratios";
export async function fetchOpsDashboard(query = {}) { export async function fetchOpsDashboard(query = {}) {
// Ops Center 是跨 App 的运营面板:先拉应用主数据,再按 app_code 扇出拉取奖池配置、余额和抽奖汇总。 // Ops Center 是跨 App 的运营面板:先拉应用主数据,再按 app_code 扇出拉取奖池配置、余额和抽奖汇总。
@ -14,21 +15,45 @@ export async function fetchOpsDashboard(query = {}) {
const perApp = await Promise.all( const perApp = await Promise.all(
appCodes.map(async (appCode) => { appCodes.map(async (appCode) => {
const [luckyGifts, poolBalances, drawSummary, experiments] = await Promise.all([ const [luckyGifts, poolBalances, drawSummary, experiments, giftHostDiamondRatios] = await Promise.all([
fetchAppLuckyGiftConfigs(appCode), fetchAppLuckyGiftConfigs(appCode),
opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems), opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems),
opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } }), opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } }),
// 实验列表驱动配置行的实验徽章和发布禁用;该接口比面板后上线,请求失败时降级为空列表 // 实验列表驱动配置行的实验徽章和发布禁用;该接口比面板后上线,请求失败时降级为空列表
// 而不是整页白屏——活跃实验期间的常规发布仍由 owner 以 409 兜底拒绝,降级是安全的。 // 而不是整页白屏——活跃实验期间的常规发布仍由 owner 以 409 兜底拒绝,降级是安全的。
fetchLuckyGiftExperiments(appCode).catch(() => []), fetchLuckyGiftExperiments(appCode).catch(() => []),
// 主播钻石是 wallet 的应用级礼物配置,不属于幸运礼物规则。读取失败时保留配置列表可查看,
// 但把 null 传到编辑弹窗并禁止发布,绝不能把返还金币比例或前端默认值当成主播钻石。
fetchGiftHostDiamondRatios(appCode).catch(() => null),
]); ]);
return { appCode, drawSummary, experiments, luckyGifts, poolBalances }; return { appCode, drawSummary, experiments, giftHostDiamondRatios, luckyGifts, poolBalances };
}), }),
); );
return normalizeDashboard({ apps, perApp }); return normalizeDashboard({ apps, perApp });
} }
export async function fetchGiftHostDiamondRatios(appCode) {
const normalizedAppCode = normalizeAppCodeValue(appCode);
const payload = await apiRequest(GIFT_DIAMOND_RATIOS_PATH, {
// Ops Center 跨应用扇出读取,不能使用主后台当前选中的 X-App-Code每个请求必须显式绑定
// 当前行应用,否则 lalu 的主播钻石比例会被错误复用到其他应用。
headers: { "X-App-Code": normalizedAppCode },
method: "GET",
query: { region_id: 0 },
});
return Object.fromEntries(
normalizeItems(payload)
.map((item) => [
String(item.giftTypeCode ?? item.gift_type_code ?? "")
.trim()
.toLowerCase(),
finitePercentOrNull(item.ratioPercent ?? item.ratio_percent),
])
.filter(([giftType, ratio]) => giftType && ratio !== null),
);
}
async function fetchAppLuckyGiftConfigs(appCode) { async function fetchAppLuckyGiftConfigs(appCode) {
// 必须用复数 /configs它返回该应用每个奖池的最新已发布规则版本。 // 必须用复数 /configs它返回该应用每个奖池的最新已发布规则版本。
// 单数 /config 只回默认奖池(无配置时是服务端草稿),会把 lalu 的 lucky/super_lucky 等已发布奖池整体漏掉。 // 单数 /config 只回默认奖池(无配置时是服务端草稿),会把 lalu 的 lucky/super_lucky 等已发布奖池整体漏掉。
@ -185,6 +210,35 @@ export async function saveLuckyGiftConfig(config = {}) {
}); });
} }
export async function fetchLuckyGiftConfigVersions(appCode, poolId) {
const normalizedAppCode = normalizeAppCodeValue(appCode);
const payload = await opsRequest("/lucky-gifts/config/versions", {
query: {
app_code: normalizedAppCode,
pool_id: String(poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID,
},
});
// 历史版本必须展示 owner 保存的不可变快照,不能套用当前礼物分成或表单默认值,
// 否则运营看到的目标版本与实际回滚内容会不一致。
return normalizeItems(payload).map((config) => normalizeLuckyGiftConfig(config, normalizedAppCode));
}
export async function rollbackLuckyGiftConfig({ appCode, poolId, targetRuleVersion } = {}) {
const normalizedAppCode = normalizeAppCodeValue(appCode);
const payload = await opsRequest("/lucky-gifts/config/rollback", {
body: { target_rule_version: numberValue(targetRuleVersion) },
method: "POST",
query: {
app_code: normalizedAppCode,
pool_id: String(poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID,
},
});
return {
sourceRuleVersion: numberValue(payload?.source_rule_version ?? payload?.sourceRuleVersion),
config: normalizeLuckyGiftConfig(payload?.config || {}, normalizedAppCode),
};
}
export async function adjustLuckyGiftPool({ export async function adjustLuckyGiftPool({
adjustmentId, adjustmentId,
amountCoins, amountCoins,
@ -395,6 +449,9 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
const appSummaries = perApp const appSummaries = perApp
.map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode)) .map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode))
.filter((summary) => summary.app_code); .filter((summary) => summary.app_code);
const giftHostDiamondRatiosByApp = Object.fromEntries(
perApp.map((entry) => [normalizeAppCodeValue(entry.appCode), entry.giftHostDiamondRatios ?? null]),
);
const publishedConfigs = luckyGifts.filter((config) => !config.is_default); const publishedConfigs = luckyGifts.filter((config) => !config.is_default);
const currentConfigByPool = new Map(); const currentConfigByPool = new Map();
@ -438,7 +495,7 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
totalSpentCoins: sumBy(appSummaries, "total_spent_coins"), totalSpentCoins: sumBy(appSummaries, "total_spent_coins"),
}; };
return { apps, appSummaries, experiments, luckyGifts, poolBalances, summary }; return { apps, appSummaries, experiments, giftHostDiamondRatiosByApp, luckyGifts, poolBalances, summary };
} }
export function normalizePoolBalance(pool = {}, fallbackAppCode = "") { export function normalizePoolBalance(pool = {}, fallbackAppCode = "") {
@ -1061,6 +1118,11 @@ function numberValue(value) {
return Number.isFinite(parsed) ? parsed : 0; return Number.isFinite(parsed) ? parsed : 0;
} }
function finitePercentOrNull(value) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 100 ? parsed : null;
}
function numberArray(values) { function numberArray(values) {
if (!Array.isArray(values)) { if (!Array.isArray(values)) {
return []; return [];

View File

@ -61,9 +61,11 @@ test("refreshes a stale access token before retrying an ops-center request", asy
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) => { const requests = [];
vi.spyOn(window, "fetch").mockImplementation(async (url, init) => {
const text = String(url); const text = String(url);
calls.push(text); calls.push(text);
requests.push({ init, url: text });
if (text.endsWith("/apps")) { if (text.endsWith("/apps")) {
return jsonResponse({ items: [{ app_code: "lalu", status: "active" }] }); return jsonResponse({ items: [{ app_code: "lalu", status: "active" }] });
} }
@ -118,6 +120,15 @@ test("loads every pool config per app through the plural configs endpoint", asyn
], ],
}); });
} }
if (text.includes("/admin/operations/gift-diamond-ratios?")) {
return jsonResponse({
items: [
{ giftTypeCode: "lucky", ratioPercent: "10.00", returnCoinRatioPercent: "0.00" },
{ giftTypeCode: "super_lucky", ratioPercent: "1.00", returnCoinRatioPercent: "0.00" },
],
regionId: 0,
});
}
return jsonResponse({ items: [] }); return jsonResponse({ items: [] });
}); });
@ -130,7 +141,12 @@ test("loads every pool config per app through the plural configs endpoint", asyn
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu", "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu",
// dashboard 扇出不带 with_stats/status实验列表只驱动实验徽章和发布封锁。 // dashboard 扇出不带 with_stats/status实验列表只驱动实验徽章和发布封锁。
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/experiments?app_code=lalu", "http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/experiments?app_code=lalu",
"http://localhost:3000/api/v1/admin/operations/gift-diamond-ratios?region_id=0",
]); ]);
const ratioRequest = requests.find((request) => request.url.includes("gift-diamond-ratios"));
expect(ratioRequest.init.headers["X-App-Code"]).toBe("lalu");
// 幸运礼物资金预留只读取主播钻石 ratioPercent线上返还金币为 0不进入该表单。
expect(data.giftHostDiamondRatiosByApp).toEqual({ lalu: { lucky: 10, super_lucky: 1 } });
expect(data.experiments).toEqual([ expect(data.experiments).toEqual([
expect.objectContaining({ app_code: "lalu", experiment_id: "exp-1", pool_id: "lucky", status: "running" }), expect.objectContaining({ app_code: "lalu", experiment_id: "exp-1", pool_id: "lucky", status: "running" }),
]); ]);

View File

@ -25,6 +25,7 @@ export function ConfigsView({
onDebit, onDebit,
onEdit, onEdit,
onManageExperiment, onManageExperiment,
onRollback,
poolBalances, poolBalances,
saving, saving,
}) { }) {
@ -72,6 +73,7 @@ export function ConfigsView({
onDebit, onDebit,
onEdit, onEdit,
onManageExperiment, onManageExperiment,
onRollback,
saving, saving,
}), }),
[ [
@ -86,6 +88,7 @@ export function ConfigsView({
onDebit, onDebit,
onEdit, onEdit,
onManageExperiment, onManageExperiment,
onRollback,
saving, saving,
], ],
); );
@ -139,6 +142,7 @@ function buildColumns({
onDebit, onDebit,
onEdit, onEdit,
onManageExperiment, onManageExperiment,
onRollback,
saving, saving,
}) { }) {
const columns = [ const columns = [
@ -248,6 +252,18 @@ function buildColumns({
{item.is_default ? "发布配置" : "新增版本"} {item.is_default ? "发布配置" : "新增版本"}
</Button> </Button>
) : null} ) : null}
{canUpdate && !item.is_default ? (
<Button
aria-label={`回滚版本 ${identity}`}
//
disabled={saving || hasActiveExperiment}
size="small"
title={hasActiveExperiment ? "实验进行中,结束实验后才能回滚" : undefined}
onClick={() => onRollback(item)}
>
回滚版本
</Button>
) : null}
{hasActiveExperiment ? ( {hasActiveExperiment ? (
<Button <Button
aria-label={`实验详情 ${identity}`} aria-label={`实验详情 ${identity}`}
@ -275,7 +291,7 @@ function buildColumns({
width: `${ width: `${
(canCredit ? 110 : 0) + (canCredit ? 110 : 0) +
(canDebit ? 110 : 0) + (canDebit ? 110 : 0) +
(canUpdate ? 110 : 0) + (canUpdate ? 220 : 0) +
(hasExperimentEntry ? 128 : 0) + (hasExperimentEntry ? 128 : 0) +
20 20
}px`, }px`,

View File

@ -5,10 +5,12 @@ import DialogTitle from "@mui/material/DialogTitle";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/shared/ui/Button.jsx"; import { Button } from "@/shared/ui/Button.jsx";
import { import {
applyGiftHostDiamondRatio,
applyDynamicStrategyDefaults, applyDynamicStrategyDefaults,
configToForm, configToForm,
correctAllStageProbabilities, correctAllStageProbabilities,
formToConfig, formToConfig,
giftHostDiamondRatioForPool,
nextStageMultiplier, nextStageMultiplier,
stageSummary, stageSummary,
validateLuckyGiftForm, validateLuckyGiftForm,
@ -24,11 +26,31 @@ import { LuckyGiftConfigSections } from "./LuckyGiftConfigSections.jsx";
import { LuckyGiftExperimentFields } from "./LuckyGiftExperimentFields.jsx"; import { LuckyGiftExperimentFields } from "./LuckyGiftExperimentFields.jsx";
import { LuckyGiftStageDesigner } from "./LuckyGiftStageDesigner.jsx"; import { LuckyGiftStageDesigner } from "./LuckyGiftStageDesigner.jsx";
export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) { export function LuckyGiftConfigDialog({
appOptions = [],
config,
giftHostDiamondRatiosByApp = {},
mode = "edit",
onClose,
onSubmit,
saving,
}) {
const isExperiment = mode === "experiment"; const isExperiment = mode === "experiment";
const isCreate = mode === "create";
// owner enabled true // owner enabled true
// //
const [form, setForm] = useState(() => configToForm(isExperiment ? { ...config, enabled: true } : config)); const [form, setForm] = useState(() => {
const initial = configToForm(isExperiment ? { ...config, enabled: true } : config);
const ratio = resolveHostDiamondRatio(giftHostDiamondRatiosByApp, initial);
if (initial.strategy_version !== "dynamic_v3" || ratio === null) return initial;
// 稿线
// RTP/
const rebalance =
isCreate ||
(config.is_default ?? config.isDefault) === true ||
Number(config.rule_version ?? config.ruleVersion ?? 0) <= 0;
return applyGiftHostDiamondRatio(initial, ratio, { rebalance });
});
const [experiment, setExperiment] = useState(experimentDraft); const [experiment, setExperiment] = useState(experimentDraft);
const [activeStage, setActiveStage] = useState("novice"); const [activeStage, setActiveStage] = useState("novice");
const [validationErrors, setValidationErrors] = useState([]); const [validationErrors, setValidationErrors] = useState([]);
@ -52,11 +74,15 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
setValidationErrors([]); setValidationErrors([]);
setForm((current) => { setForm((current) => {
const next = { ...current, [field]: value }; const next = { ...current, [field]: value };
const hostDiamondRatio = resolveHostDiamondRatio(giftHostDiamondRatiosByApp, next);
if (field === "strategy_version" && value === "dynamic_v3" && current.strategy_version !== "dynamic_v3") { if (field === "strategy_version" && value === "dynamic_v3" && current.strategy_version !== "dynamic_v3") {
const dynamicDraft = applyDynamicStrategyDefaults(next); const dynamicDraft = applyDynamicStrategyDefaults(next, { anchorRatePercent: hostDiamondRatio });
// 稿稿 // 稿稿
return isExperiment ? { ...dynamicDraft, enabled: true } : dynamicDraft; return isExperiment ? { ...dynamicDraft, enabled: true } : dynamicDraft;
} }
if (["app_code", "pool_id"].includes(field) && next.strategy_version === "dynamic_v3") {
return applyGiftHostDiamondRatio(next, hostDiamondRatio, { rebalance: true });
}
// RTP // RTP
return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next; return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next;
}); });
@ -141,9 +167,14 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
// 使 // 使
// payload // payload
const submittedForm = isExperiment ? { ...form, enabled: true } : form; const submittedForm = isExperiment ? { ...form, enabled: true } : form;
const errors = isExperiment const formErrors = isExperiment
? [...validateExperimentDraft(experiment), ...validateLuckyGiftForm(submittedForm)] ? [...validateExperimentDraft(experiment), ...validateLuckyGiftForm(submittedForm)]
: validateLuckyGiftForm(submittedForm); : validateLuckyGiftForm(submittedForm);
const errors =
submittedForm.strategy_version === "dynamic_v3" &&
resolveHostDiamondRatio(giftHostDiamondRatiosByApp, submittedForm) === null
? ["未读取到当前应用的主播钻石比例,请刷新页面后再发布", ...formErrors]
: formErrors;
if (errors.length) { if (errors.length) {
setValidationErrors(errors); setValidationErrors(errors);
return; return;
@ -155,7 +186,6 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
onSubmit(formToConfig(config, form)); onSubmit(formToConfig(config, form));
}; };
const isCreate = mode === "create";
const title = isExperiment const title = isExperiment
? "创建 AB 实验" ? "创建 AB 实验"
: isCreate : isCreate
@ -195,7 +225,7 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
label={form.strategy_version === "dynamic_v3" ? "资金拆分" : "奖池比例"} label={form.strategy_version === "dynamic_v3" ? "资金拆分" : "奖池比例"}
value={ value={
form.strategy_version === "dynamic_v3" form.strategy_version === "dynamic_v3"
? `奖池 ${form.pool_rate_percent}% / 平台 ${form.profit_rate_percent}% / 收礼返币 ${form.anchor_rate_percent}%(只读` ? `奖池 ${form.pool_rate_percent}% / 平台 ${form.profit_rate_percent}% / 主播钻石 ${form.anchor_rate_percent}%(接口读取`
: `${form.pool_rate_percent}%` : `${form.pool_rate_percent}%`
} }
/> />
@ -245,6 +275,9 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
appOptions={appOptions} appOptions={appOptions}
experimentMode={isExperiment} experimentMode={isExperiment}
form={form} form={form}
hostDiamondRatioAvailable={
resolveHostDiamondRatio(giftHostDiamondRatiosByApp, form) !== null
}
isCreate={isCreate} isCreate={isCreate}
onChange={updateField} onChange={updateField}
/> />
@ -276,6 +309,13 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
); );
} }
function resolveHostDiamondRatio(ratiosByApp, form) {
const appCode = String(form?.app_code || "")
.trim()
.toLowerCase();
return giftHostDiamondRatioForPool(ratiosByApp?.[appCode], form?.pool_id);
}
function ConfigMetric({ label, value }) { function ConfigMetric({ label, value }) {
return ( return (
<div className="ops-config-metric"> <div className="ops-config-metric">

View File

@ -238,12 +238,12 @@ test("fixed_v2 hides only dynamic controls and preserves immutable snapshot fiel
test("saves an ordinary tier and the independent jackpot list when they share a multiplier", () => { test("saves an ordinary tier and the independent jackpot list when they share a multiplier", () => {
const onSubmit = vi.fn(); const onSubmit = vi.fn();
const stages = stagesFor(98).map((stage) => const stages = stagesFor(89).map((stage) =>
stage.stage === "normal" stage.stage === "normal"
? { ? {
...stage, ...stage,
tiers: [ tiers: [
{ ...stage.tiers[0], probability_percent: 1.99 }, { ...stage.tiers[0], probability_percent: 10.99 },
stage.tiers[1], stage.tiers[1],
{ {
enabled: true, enabled: true,
@ -303,6 +303,10 @@ function renderDialog(config, overrides = {}) {
["yumi", "Yumi"], ["yumi", "Yumi"],
]} ]}
config={config} config={config}
giftHostDiamondRatiosByApp={{
lalu: { lucky: 10, super_lucky: 1 },
yumi: { lucky: 10, super_lucky: 1 },
}}
mode="edit" mode="edit"
saving={false} saving={false}
onClose={vi.fn()} onClose={vi.fn()}
@ -315,7 +319,7 @@ function renderDialog(config, overrides = {}) {
function dynamicConfig(overrides = {}) { function dynamicConfig(overrides = {}) {
return { return {
anchor_daily_payout_cap: 6_000_000, anchor_daily_payout_cap: 6_000_000,
anchor_rate_percent: 1, anchor_rate_percent: 10,
app_code: "lalu", app_code: "lalu",
control_band_percent: 3, control_band_percent: 3,
device_daily_payout_cap: 4_000_000, device_daily_payout_cap: 4_000_000,
@ -338,16 +342,16 @@ function dynamicConfig(overrides = {}) {
normal_max_equivalent_draws: 20_000, normal_max_equivalent_draws: 20_000,
novice_max_equivalent_draws: 2_000, novice_max_equivalent_draws: 2_000,
pool_id: "lucky", pool_id: "lucky",
pool_rate_percent: 98, pool_rate_percent: 89,
profit_rate_percent: 1, profit_rate_percent: 1,
recharge_boost_factor_percent: 110, recharge_boost_factor_percent: 110,
recharge_boost_window_ms: 300_000, recharge_boost_window_ms: 300_000,
room_hourly_payout_cap: 5_000_000, room_hourly_payout_cap: 5_000_000,
rule_version: 3, rule_version: 3,
settlement_window_wager: 1_000_000, settlement_window_wager: 1_000_000,
stages: stagesFor(98), stages: stagesFor(89),
strategy_version: "dynamic_v3", strategy_version: "dynamic_v3",
target_rtp_percent: 98, target_rtp_percent: 89,
user_daily_payout_cap: 3_000_000, user_daily_payout_cap: 3_000_000,
user_hourly_payout_cap: 2_000_000, user_hourly_payout_cap: 2_000_000,
...overrides, ...overrides,

View File

@ -16,7 +16,14 @@ const DYNAMIC_SECTIONS = [
["limits", "限额"], ["limits", "限额"],
]; ];
export function LuckyGiftConfigSections({ appOptions, experimentMode = false, form, isCreate, onChange }) { export function LuckyGiftConfigSections({
appOptions,
experimentMode = false,
form,
hostDiamondRatioAvailable = true,
isCreate,
onChange,
}) {
const scrollRef = useRef(null); const scrollRef = useRef(null);
const sections = useMemo( const sections = useMemo(
() => (form.strategy_version === "dynamic_v3" ? [...BASE_SECTIONS, ...DYNAMIC_SECTIONS] : BASE_SECTIONS), () => (form.strategy_version === "dynamic_v3" ? [...BASE_SECTIONS, ...DYNAMIC_SECTIONS] : BASE_SECTIONS),
@ -290,9 +297,9 @@ export function LuckyGiftConfigSections({ appOptions, experimentMode = false, fo
</div> </div>
{form.strategy_version === "dynamic_v3" ? ( {form.strategy_version === "dynamic_v3" ? (
<p className="ops-field-note"> <p className="ops-field-note">
收礼返币比例由礼物返币配置管理本页不再提供修改当前规则使用值为 {hostDiamondRatioAvailable
{` ${formatCompactNumber(Number(form.anchor_rate_percent || 0))}%`} ? `主播钻石比例由礼物配置读取,当前为 ${formatCompactNumber(Number(form.anchor_rate_percent || 0))}%;本页不提供修改。`
发布前需与实际返币比例一致否则 V3 真实开奖会拒绝账务结算 : "未读取到当前应用的主播钻石比例,刷新成功前不能发布 V3 配置。"}
</p> </p>
) : ( ) : (
<> <>

View File

@ -0,0 +1,123 @@
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 Radio from "@mui/material/Radio";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { TimeText } from "@/shared/ui/TimeText.jsx";
import { formatPercent } from "../format.js";
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
export function LuckyGiftRollbackDialog({
config,
error,
loading,
onClose,
onRetry,
onSubmit,
submitting,
versions,
}) {
const candidates = useMemo(
() =>
(Array.isArray(versions) ? versions : []).filter(
(item) => Number(item.rule_version) > 0 && Number(item.rule_version) < Number(config.rule_version),
),
[config.rule_version, versions],
);
const [selectedVersion, setSelectedVersion] = useState(0);
useEffect(() => {
//
setSelectedVersion((current) =>
candidates.some((item) => Number(item.rule_version) === current)
? current
: Number(candidates[0]?.rule_version || 0),
);
}, [candidates]);
return (
<Dialog
fullWidth
maxWidth="lg"
open
aria-labelledby="lucky-gift-rollback-title"
onClose={submitting ? undefined : onClose}
>
<DialogTitle id="lucky-gift-rollback-title">
回滚幸运礼物版本
<small>
{config.app_code} / {config.pool_id} / 当前 v{config.rule_version}
</small>
</DialogTitle>
<DialogContent dividers className="ops-rollback-content">
<div className="ops-rollback-warning" role="note">
回滚会复制所选历史快照并发布为一个新版本历史版本不会被修改V2 V3 的奖池水位保持独立切换策略不会搬移清空或重新注资
</div>
<DataState error={error} loading={loading} onRetry={onRetry} reportPageLoading={false}>
{candidates.length ? (
<div className="ops-rollback-table" role="radiogroup" aria-label="可回滚版本">
<div className="ops-rollback-row ops-rollback-head" aria-hidden="true">
<span />
<span>版本</span>
<span>策略</span>
<span>状态</span>
<span>目标 RTP</span>
<span>进入奖池</span>
<span>平台 / 主播</span>
<span>发布时间</span>
</div>
{candidates.map((item) => {
const version = Number(item.rule_version);
const selected = selectedVersion === version;
return (
<label className={selected ? "ops-rollback-row is-selected" : "ops-rollback-row"} key={version}>
<Radio
checked={selected}
disabled={submitting}
inputProps={{ "aria-label": `选择版本 v${version}` }}
size="small"
value={version}
onChange={() => setSelectedVersion(version)}
/>
<strong>v{version}</strong>
<span>{item.strategy_version || "fixed_v2"}</span>
<span>
{item.enabled ? (
<OpsStatusBadge label="已启用" tone="succeeded" />
) : (
<OpsStatusBadge label="已停用" />
)}
</span>
<span>{formatPercent(item.target_rtp_percent)}</span>
<span>{formatPercent(item.pool_rate_percent)}</span>
<span>
{formatPercent(item.profit_rate_percent)} / {formatPercent(item.anchor_rate_percent)}
</span>
<TimeText value={item.created_at_ms} />
</label>
);
})}
</div>
) : (
<div className="ops-rollback-empty">当前奖池还没有可回滚的历史版本</div>
)}
</DataState>
</DialogContent>
<DialogActions>
<Button disabled={submitting} onClick={onClose}>
取消
</Button>
<Button
disabled={submitting || loading || Boolean(error) || selectedVersion <= 0}
variant="danger"
onClick={() => onSubmit(selectedVersion)}
>
{selectedVersion > 0 ? `确认回滚到 v${selectedVersion}` : "确认回滚"}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@ -247,9 +247,40 @@ export function formToConfig(config, form) {
return output; return output;
} }
export function giftTypeForLuckyGiftPool(poolID) {
// room/wallet 把任意非空幸运奖池归入 lucky只有 super_lucky 家族使用超级幸运礼物比例;
// 测试池和外部自定义池因此沿用普通幸运礼物口径,不要求运营额外维护一份映射。
return String(poolID || "")
.trim()
.toLowerCase()
.includes("super_lucky")
? "super_lucky"
: "lucky";
}
export function giftHostDiamondRatioForPool(ratios, poolID) {
const value = Number(ratios?.[giftTypeForLuckyGiftPool(poolID)]);
return Number.isFinite(value) && value >= 0 && value <= 100 ? value : null;
}
export function applyGiftHostDiamondRatio(form, ratio, { rebalance = false } = {}) {
if (!Number.isFinite(ratio) || ratio < 0 || ratio > 100) return form;
const next = { ...form, anchor_rate_percent: formNumber(ratio) };
if (!rebalance || next.strategy_version !== DYNAMIC_STRATEGY) return next;
// anchor_rate_percent 是历史协议名V3 实际使用 wallet 的主播钻石事实,不读取收礼返币。
// 新奖池只把可配置的平台比例和接口返回的主播钻石预留扣除,剩余资金进入公共奖池。
const availablePoolRate = roundedPercent(100 - numberFromForm(next.profit_rate_percent) - ratio);
next.pool_rate_percent = formNumber(Math.max(availablePoolRate, 0));
next.target_rtp_percent = formNumber(
Math.min(numberFromForm(next.target_rtp_percent), Math.max(availablePoolRate, 0)),
);
return correctAllStageProbabilities(next);
}
// 从历史 fixed_v2 显式切换到 dynamic_v3 时,先停用并载入安全草稿;金额型上限仍保持 0 // 从历史 fixed_v2 显式切换到 dynamic_v3 时,先停用并载入安全草稿;金额型上限仍保持 0
// 运营补齐后才能通过启用校验,避免一次策略切换直接发布无法结算的规则。 // 运营补齐后才能通过启用校验,避免一次策略切换直接发布无法结算的规则。
export function applyDynamicStrategyDefaults(form) { export function applyDynamicStrategyDefaults(form, { anchorRatePercent = null } = {}) {
const next = { const next = {
...form, ...form,
enabled: false, enabled: false,
@ -261,15 +292,24 @@ export function applyDynamicStrategyDefaults(form) {
next[field] = String(value); next[field] = String(value);
} }
} }
if (Number.isFinite(anchorRatePercent) && anchorRatePercent >= 0 && anchorRatePercent <= 100) {
next.anchor_rate_percent = formNumber(anchorRatePercent);
const availablePoolRate = Math.max(
roundedPercent(100 - numberFromForm(next.profit_rate_percent) - anchorRatePercent),
0,
);
next.pool_rate_percent = formNumber(availablePoolRate);
next.target_rtp_percent = formNumber(Math.min(dynamicDefaults.target_rtp_percent, availablePoolRate));
}
next.stages = stageOptions.map(([stage]) => { next.stages = stageOptions.map(([stage]) => {
const [min7d, min30d] = defaultRechargeThreshold(stage); const [min7d, min30d] = defaultRechargeThreshold(stage);
return { return {
min_recharge_7d_coins: String(min7d), min_recharge_7d_coins: String(min7d),
min_recharge_30d_coins: String(min30d), min_recharge_30d_coins: String(min30d),
stage, stage,
// fixed_v2 可能含历史高倍基础档;切换策略时必须回到 owner 的 98% 安全基线, // fixed_v2 可能含历史高倍基础档;切换策略时必须按当前资金可用 RTP 重建安全基线,
// 不能只改策略名后把旧概率意外带入 dynamic_v3 的第一版。 // 不能只改策略名后把旧概率意外带入 dynamic_v3 的第一版。
tiers: correctedDefaultTiers(stage, dynamicDefaults.target_rtp_percent), tiers: correctedDefaultTiers(stage, numberFromForm(next.target_rtp_percent)),
}; };
}); });
return next; return next;
@ -371,8 +411,7 @@ export function validateLuckyGiftForm(form) {
errors.push("关闭 V2 高水位加权时,加权系数与恢复水位必须为 0"); errors.push("关闭 V2 高水位加权时,加权系数与恢复水位必须为 0");
} else { } else {
if (boostThreshold < 0) errors.push("V2 高水位加权阈值不能小于 0"); if (boostThreshold < 0) errors.push("V2 高水位加权阈值不能小于 0");
if (boostFactor <= 100 || boostFactor > 1000) if (boostFactor <= 100 || boostFactor > 1000) errors.push("V2 高水位加权系数必须大于 100% 且不超过 1000%");
errors.push("V2 高水位加权系数必须大于 100% 且不超过 1000%");
if (boostRecover <= 0 || boostRecover > boostThreshold) if (boostRecover <= 0 || boostRecover > boostThreshold)
errors.push("V2 加权恢复水位必须大于 0 且不高于加权阈值"); errors.push("V2 加权恢复水位必须大于 0 且不高于加权阈值");
} }
@ -380,14 +419,14 @@ export function validateLuckyGiftForm(form) {
} }
if (number("profit_rate_percent") < 0 || number("anchor_rate_percent") < 0) if (number("profit_rate_percent") < 0 || number("anchor_rate_percent") < 0)
errors.push("平台盈利和主播收益比例不能小于 0"); errors.push("平台比例和主播钻石比例不能小于 0");
if ( if (
percentToPPM(number("pool_rate_percent")) + percentToPPM(number("pool_rate_percent")) +
percentToPPM(number("profit_rate_percent")) + percentToPPM(number("profit_rate_percent")) +
percentToPPM(number("anchor_rate_percent")) !== percentToPPM(number("anchor_rate_percent")) !==
PPM_SCALE PPM_SCALE
) )
errors.push("奖池、平台盈利和主播收益比例合计必须等于 100%"); errors.push("奖池、平台和主播钻石比例合计必须等于 100%");
if (number("initial_pool_coins") !== 0) errors.push("dynamic_v3 初始奖池必须为 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"))
@ -626,6 +665,10 @@ function percentToPPM(value) {
return Math.round(Number(value || 0) * 10_000); return Math.round(Number(value || 0) * 10_000);
} }
function roundedPercent(value) {
return Math.round(Number(value || 0) * 10_000) / 10_000;
}
function unique(values) { function unique(values) {
return Array.from(new Set(values)); return Array.from(new Set(values));
} }

View File

@ -1,5 +1,12 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { applyDynamicStrategyDefaults, configToForm, formToConfig, validateLuckyGiftForm } from "./configForm.js"; import {
applyDynamicStrategyDefaults,
applyGiftHostDiamondRatio,
configToForm,
formToConfig,
giftHostDiamondRatioForPool,
validateLuckyGiftForm,
} from "./configForm.js";
describe("ops center lucky gift dynamic_v3 form", () => { describe("ops center lucky gift dynamic_v3 form", () => {
test("loads safe dynamic defaults without inventing app-specific monetary caps", () => { test("loads safe dynamic defaults without inventing app-specific monetary caps", () => {
@ -112,27 +119,47 @@ describe("ops center lucky gift dynamic_v3 form", () => {
strategy_version: "fixed_v2", strategy_version: "fixed_v2",
target_rtp_percent: 95, target_rtp_percent: 95,
}); });
const dynamic = applyDynamicStrategyDefaults(legacy); const dynamic = applyDynamicStrategyDefaults(legacy, { anchorRatePercent: 10 });
expect(dynamic).toMatchObject({ expect(dynamic).toMatchObject({
anchor_rate_percent: "10",
enabled: false, enabled: false,
initial_pool_coins: "0", initial_pool_coins: "0",
pool_rate_percent: "98", pool_rate_percent: "89",
strategy_version: "dynamic_v3", strategy_version: "dynamic_v3",
target_rtp_percent: "89",
}); });
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],
[0, 0.5, 1, 2], [0, 0.5, 1, 2],
]); ]);
expect(dynamic.stages.map((stage) => stage.tiers.map((tier) => Number(tier.probabilityPercent)))).toEqual([ expect(
[5, 4, 86, 5], dynamic.stages.every(
[5, 4, 86, 5], (stage) => stage.tiers.reduce((sum, tier) => sum + Number(tier.probabilityPercent), 0) === 100,
[5, 4, 86, 5], ),
]); ).toBe(true);
expect(validateLuckyGiftForm(dynamic)).toEqual([]); expect(validateLuckyGiftForm(dynamic)).toEqual([]);
}); });
test("uses the wallet host diamond ratio and ignores the return-coin ratio", () => {
const ratios = { lucky: 10, super_lucky: 1 };
expect(giftHostDiamondRatioForPool(ratios, "lucky")).toBe(10);
expect(giftHostDiamondRatioForPool(ratios, "campaign_super_lucky_v2")).toBe(1);
const existing = configToForm({
...completeDynamicConfig(),
anchor_rate_percent: 1,
pool_id: "lucky",
pool_rate_percent: 89,
target_rtp_percent: 89,
stages: targetStages(89),
});
const synced = applyGiftHostDiamondRatio(existing, ratios.lucky);
expect(synced).toMatchObject({ anchor_rate_percent: "10", pool_rate_percent: "89" });
expect(validateLuckyGiftForm(synced)).toEqual([]);
});
test("rejects invalid disabled draft values that the owner never accepts", () => { test("rejects invalid disabled draft values that the owner never accepts", () => {
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" });
const errors = validateLuckyGiftForm({ const errors = validateLuckyGiftForm({
@ -146,7 +173,7 @@ describe("ops center lucky gift dynamic_v3 form", () => {
), ),
}); });
expect(errors).toContain("平台盈利和主播收益比例不能小于 0"); expect(errors).toContain("平台比例和主播钻石比例不能小于 0");
expect(errors).toContain("大奖消费门槛不能小于 0"); expect(errors).toContain("大奖消费门槛不能小于 0");
expect(errors).toContain("单次返奖上限不能小于 0"); expect(errors).toContain("单次返奖上限不能小于 0");
expect(errors).toContain("充值阶段门槛不能小于 0"); expect(errors).toContain("充值阶段门槛不能小于 0");
@ -272,6 +299,23 @@ function dynamicStage(stage, min7d, min30d) {
}; };
} }
function targetStages(targetRTP) {
return [
targetStage("novice", 0, 0, targetRTP),
targetStage("normal", 10_000, 20_000, targetRTP),
targetStage("advanced", 20_000, 30_000, targetRTP),
];
}
function targetStage(stage, min7d, min30d, targetRTP) {
return {
min_recharge_30d_coins: min30d,
min_recharge_7d_coins: min7d,
stage,
tiers: [tier(`${stage}_none`, 0, 100 - targetRTP), tier(`${stage}_1x`, 1, targetRTP)],
};
}
function fixedStages() { function fixedStages() {
return ["novice", "normal", "advanced"].map((stage) => ({ return ["novice", "normal", "advanced"].map((stage) => ({
stage, stage,

View File

@ -184,6 +184,72 @@ body {
padding: var(--space-3); padding: var(--space-3);
} }
.ops-rollback-content {
display: grid;
gap: var(--space-4);
min-height: 320px;
}
#lucky-gift-rollback-title small {
display: block;
margin-top: var(--space-1);
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
}
.ops-rollback-warning {
border: 1px solid var(--warning-border);
border-radius: var(--radius-control);
background: var(--warning-surface);
color: var(--warning);
font-size: 13px;
font-weight: 700;
line-height: 1.6;
padding: var(--space-3) var(--space-4);
}
.ops-rollback-table {
border: 1px solid var(--border);
border-radius: var(--radius-control);
overflow: auto;
}
.ops-rollback-row {
display: grid;
grid-template-columns: 48px 70px 120px 112px 100px 100px 144px minmax(176px, 1fr);
align-items: center;
min-width: 940px;
min-height: 58px;
border-bottom: 1px solid var(--row-border);
cursor: pointer;
padding: 0 var(--space-2);
}
.ops-rollback-row:last-child {
border-bottom: 0;
}
.ops-rollback-row.is-selected {
background: var(--primary-surface);
}
.ops-rollback-head {
min-height: 44px;
background: var(--bg-card-strong);
color: var(--text-secondary);
cursor: default;
font-size: 13px;
font-weight: 700;
}
.ops-rollback-empty {
display: grid;
min-height: 180px;
place-items: center;
color: var(--text-secondary);
}
/* ---- 幸运礼物配置弹窗 ---- */ /* ---- 幸运礼物配置弹窗 ---- */
.MuiDialog-paper.ops-config-dialog-paper { .MuiDialog-paper.ops-config-dialog-paper {

View File

@ -107,6 +107,8 @@ export const resourceTypeFilters = [
["gift", "礼物"], ["gift", "礼物"],
["mic_seat_icon", "麦位图标"], ["mic_seat_icon", "麦位图标"],
["mic_seat_animation", "麦位动效"], ["mic_seat_animation", "麦位动效"],
["room_border", "房间边框"],
["room_name_style", "房间名称样式"],
["emoji_pack", "表情包"], ["emoji_pack", "表情包"],
["vip_trial_card", "VIP体验卡"], ["vip_trial_card", "VIP体验卡"],
]; ];
@ -134,9 +136,7 @@ export const resourceShopTypeFilters = [
...resourceTypeFilters.filter(([value]) => resourceShopSellableTypes.includes(value)), ...resourceTypeFilters.filter(([value]) => resourceShopSellableTypes.includes(value)),
]; ];
export const resourceGroupAssetOptions = [ export const resourceGroupAssetOptions = [["COIN", "金币"]];
["COIN", "金币"],
];
export const resourceGroupAssetLabels = Object.fromEntries(resourceGroupAssetOptions); export const resourceGroupAssetLabels = Object.fromEntries(resourceGroupAssetOptions);

View File

@ -53,7 +53,14 @@ import {
resourceShopSellableTypes, resourceShopSellableTypes,
} from "@/features/resources/constants.js"; } from "@/features/resources/constants.js";
import { useResourceAbilities } from "@/features/resources/permissions.js"; import { useResourceAbilities } from "@/features/resources/permissions.js";
import { resourceMetadataJSON } from "@/features/resources/resourceMetadata.js"; import {
resourceMetadataJSON,
resourceRequiresExplicitVisualFormat,
roomBorderFormatFromMetadata,
roomBorderFormatMetadataJSON,
roomNameStyleFormFromMetadata,
roomNameStyleMetadataJSON,
} from "@/features/resources/resourceMetadata.js";
import { import {
emojiPackFormSchema, emojiPackFormSchema,
giftFormSchema, giftFormSchema,
@ -105,6 +112,8 @@ const emptyResourceForm = (resource = {}) => ({
priceType: resource.resourceId ? resource.priceType || (resource.coinPrice > 0 ? "coin" : "free") : "coin", priceType: resource.resourceId ? resource.priceType || (resource.coinPrice > 0 ? "coin" : "free") : "coin",
resourceCode: resource.resourceCode || "", resourceCode: resource.resourceCode || "",
resourceType: resource.resourceType || "avatar_frame", resourceType: resource.resourceType || "avatar_frame",
roomBorderFormat: roomBorderFormatFromMetadata(resource.metadataJson),
...roomNameStyleFormFromMetadata(resource.metadataJson),
walletAssetAmount: resource.walletAssetAmount ? String(resource.walletAssetAmount) : "", walletAssetAmount: resource.walletAssetAmount ? String(resource.walletAssetAmount) : "",
}); });
const emptyEmojiPackForm = () => ({ const emptyEmojiPackForm = () => ({
@ -189,9 +198,7 @@ export function applyGiftResourceSelection(form, resource, fillIdentity = true)
export function buildGiftResourceDrafts(form, resourceIds = [], resources = []) { export function buildGiftResourceDrafts(form, resourceIds = [], resources = []) {
const resourcesById = new Map(resources.map((resource) => [String(resource.resourceId), resource])); const resourcesById = new Map(resources.map((resource) => [String(resource.resourceId), resource]));
const existingDraftsByResourceId = new Map( const existingDraftsByResourceId = new Map(
(form.items || []) (form.items || []).filter((item) => item?.resourceId).map((item) => [String(item.resourceId), item]),
.filter((item) => item?.resourceId)
.map((item) => [String(item.resourceId), item]),
); );
return resourceIds return resourceIds
@ -681,7 +688,10 @@ export function useResourceListPage() {
deletedResourceIds.push(targetResources[0].resourceId); deletedResourceIds.push(targetResources[0].resourceId);
} }
hideResourceRows(deletedResourceIds); hideResourceRows(deletedResourceIds);
showToast(targetResources.length > 1 ? `已删除 ${deletedResourceIds.length} 个资源` : "资源已删除", "success"); showToast(
targetResources.length > 1 ? `已删除 ${deletedResourceIds.length} 个资源` : "资源已删除",
"success",
);
return deletedResourceIds; return deletedResourceIds;
} catch (err) { } catch (err) {
showToast(err.message || "删除资源失败", "error"); showToast(err.message || "删除资源失败", "error");
@ -1667,7 +1677,10 @@ export function useGiftListPage() {
}; };
}); });
shouldReload = true; shouldReload = true;
showToast(`已创建 ${createdDrafts.length} 个礼物,剩余礼物创建失败:${err.message || "操作失败"}`, "error"); showToast(
`已创建 ${createdDrafts.length} 个礼物,剩余礼物创建失败:${err.message || "操作失败"}`,
"error",
);
} else { } else {
showToast(err.message || "操作失败", "error"); showToast(err.message || "操作失败", "error");
} }
@ -2019,10 +2032,10 @@ export function useResourceGrantListPage() {
} }
if (abilities.canGrantVip) { if (abilities.canGrantVip) {
optionRequests.push( optionRequests.push(
Promise.all([ Promise.all([getVipProgram(), getVipConfig()]).then(([program, config]) => ({
getVipProgram(), data: { config, program },
getVipConfig(), type: "vip",
]).then(([program, config]) => ({ data: { config, program }, type: "vip" })), })),
); );
} else { } else {
setVipProgram({ programType: "legacy_timed", trialCardEnabled: false }); setVipProgram({ programType: "legacy_timed", trialCardEnabled: false });
@ -2138,12 +2151,7 @@ export function useResourceGrantListPage() {
}; };
const revokeGrant = async (grant) => { const revokeGrant = async (grant) => {
if ( if (!abilities.canRevokeGrant || !grant?.grantId || grant.status !== "succeeded" || !isRevocableGrant(grant)) {
!abilities.canRevokeGrant ||
!grant?.grantId ||
grant.status !== "succeeded" ||
!isRevocableGrant(grant)
) {
return; return;
} }
const accepted = await confirm({ const accepted = await confirm({
@ -2231,6 +2239,13 @@ function buildResourcePayload(form) {
const isLevelAvatarFrame = resourceType === "avatar_frame" && form.avatarFrameKind === "level"; const isLevelAvatarFrame = resourceType === "avatar_frame" && form.avatarFrameKind === "level";
const isLevelBadge = resourceType === "badge" && form.badgeKind === "level"; const isLevelBadge = resourceType === "badge" && form.badgeKind === "level";
const animationUrl = form.animationUrl.trim(); const animationUrl = form.animationUrl.trim();
let metadataJson = form.metadataJson;
if (resourceRequiresExplicitVisualFormat(resourceType)) {
metadataJson = roomBorderFormatMetadataJSON(metadataJson, form.roomBorderFormat);
}
if (resourceType === "room_name_style") {
metadataJson = roomNameStyleMetadataJSON(metadataJson, form);
}
return { return {
amount: isCoin ? Number(form.walletAssetAmount) : 0, amount: isCoin ? Number(form.walletAssetAmount) : 0,
animationUrl, animationUrl,
@ -2241,7 +2256,7 @@ function buildResourcePayload(form) {
coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0, coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0,
levelTrack: isLevelBadge || isLevelAvatarFrame ? form.levelTrack : undefined, levelTrack: isLevelBadge || isLevelAvatarFrame ? form.levelTrack : undefined,
managerGrantEnabled: Boolean(form.managerGrantEnabled), managerGrantEnabled: Boolean(form.managerGrantEnabled),
metadataJson: resourceMetadataJSON(resourceType, form.metadataJson), metadataJson: resourceMetadataJSON(resourceType, metadataJson),
name: form.name.trim(), name: form.name.trim(),
previewUrl: form.previewUrl.trim(), previewUrl: form.previewUrl.trim(),
priceType: form.priceType, priceType: form.priceType,
@ -2604,12 +2619,18 @@ function isVipGrantRecord(grant) {
} }
const source = String(grant?.grantSource || "").trim(); const source = String(grant?.grantSource || "").trim();
const commandId = String(grant?.commandId || "").trim(); const commandId = String(grant?.commandId || "").trim();
return source === "admin_grant" || source === "activity_grant" || source === "vip_purchase" || commandId.startsWith("vip_reward:"); return (
source === "admin_grant" ||
source === "activity_grant" ||
source === "vip_purchase" ||
commandId.startsWith("vip_reward:")
);
} }
function isVipTrialCardRecord(grant) { function isVipTrialCardRecord(grant) {
return ( return (
grant?.grantSubjectType === "vip_trial_card" || String(grant?.grantSource || "").trim() === "vip_trial_card_grant" grant?.grantSubjectType === "vip_trial_card" ||
String(grant?.grantSource || "").trim() === "vip_trial_card_grant"
); );
} }

View File

@ -82,6 +82,12 @@ import {
import { import {
metadataWithProfileCardLayout, metadataWithProfileCardLayout,
removeProfileCardLayoutMetadata, removeProfileCardLayoutMetadata,
resourceRequiresExplicitVisualFormat,
roomBorderFormatFromMetadata,
roomBorderFormatMetadataJSON,
roomBorderFormatOptions,
roomNameStyleFormFromMetadata,
roomNameStyleInputError,
} from "@/features/resources/resourceMetadata.js"; } from "@/features/resources/resourceMetadata.js";
import { uploadFilesBatch } from "@/shared/api/upload"; import { uploadFilesBatch } from "@/shared/api/upload";
import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx";
@ -89,6 +95,24 @@ import styles from "@/features/resources/resources.module.css";
const resourceTypeOptions = resourceTypeFilters.filter(([value]) => value && value !== "emoji_pack"); const resourceTypeOptions = resourceTypeFilters.filter(([value]) => value && value !== "emoji_pack");
const emptyBatchProgress = { label: "", running: false, value: 0 }; const emptyBatchProgress = { label: "", running: false, value: 0 };
const roomBorderFormatLabels = {
gif: "GIF",
jpeg: "JPEG",
mp4: "MP4",
pag: "PAG",
png: "PNG",
svga: "SVGA",
webp: "WebP",
};
function visualResourceFormatFromFile(file) {
const filename = String(file?.name || "").toLowerCase();
let extension = filename.includes(".") ? filename.split(".").pop() : "";
if (extension === "jpg") {
extension = "jpeg";
}
return roomBorderFormatOptions.includes(extension) ? extension : "";
}
const baseColumns = [ const baseColumns = [
{ {
@ -216,7 +240,9 @@ export function ResourceListPage() {
checked={selectedIdSet.has(resource.resourceId)} checked={selectedIdSet.has(resource.resourceId)}
disabled={!canSelectResources || batchMp4Running || deletingResources} disabled={!canSelectResources || batchMp4Running || deletingResources}
slotProps={{ slotProps={{
input: { "aria-label": `选择 ${resource.name || resource.resourceCode || resource.resourceId}` }, input: {
"aria-label": `选择 ${resource.name || resource.resourceCode || resource.resourceId}`,
},
}} }}
size="small" size="small"
onChange={(event) => toggleResourceSelection(resource, event.target.checked)} onChange={(event) => toggleResourceSelection(resource, event.target.checked)}
@ -392,7 +418,9 @@ export function ResourceListPage() {
} }
updates.push({ layout: confirmedLayout, metadataJson, resource, resourceId: resource.resourceId }); updates.push({ layout: confirmedLayout, metadataJson, resource, resourceId: resource.resourceId });
} catch (err) { } catch (err) {
failures.push(`${resource.name || resource.resourceCode || resource.resourceId}: ${err.message || "解析失败"}`); failures.push(
`${resource.name || resource.resourceCode || resource.resourceId}: ${err.message || "解析失败"}`,
);
} }
} }
if (failures.length) { if (failures.length) {
@ -898,7 +926,9 @@ function Mp4BatchResultDialog({ loading, onClose, onSubmit, open, result }) {
</span> </span>
<span className={styles.mp4ResultCode}>{resource?.resourceCode || "-"}</span> <span className={styles.mp4ResultCode}>{resource?.resourceCode || "-"}</span>
</td> </td>
<td>{resourceTypeLabels[resource?.resourceType] || resource?.resourceType || "-"}</td> <td>
{resourceTypeLabels[resource?.resourceType] || resource?.resourceType || "-"}
</td>
<td>{mp4LayoutLabel(layout)}</td> <td>{mp4LayoutLabel(layout)}</td>
<td> <td>
{layout?.video_w || "-"}x{layout?.video_h || "-"} {layout?.video_w || "-"}x{layout?.video_h || "-"}
@ -1013,7 +1043,11 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
detectedProfileCardLayout = await detectProfileCardLayout(file); detectedProfileCardLayout = await detectProfileCardLayout(file);
} }
// //
return { mp4Layout: detectedMp4Layout, profileCardLayout: detectedProfileCardLayout }; return {
mp4Layout: detectedMp4Layout,
profileCardLayout: detectedProfileCardLayout,
roomBorderFormat: visualResourceFormatFromFile(file),
};
}; };
const handleAnimationChange = (animationUrl, selectionResult) => { const handleAnimationChange = (animationUrl, selectionResult) => {
setForm((previous) => { setForm((previous) => {
@ -1021,6 +1055,9 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
if (!animationUrl) { if (!animationUrl) {
// //
metadataJson = removeProfileCardLayoutMetadata(removeMp4AlphaLayoutMetadata(metadataJson)); metadataJson = removeProfileCardLayoutMetadata(removeMp4AlphaLayoutMetadata(metadataJson));
if (resourceRequiresExplicitVisualFormat(previous.resourceType)) {
metadataJson = roomBorderFormatMetadataJSON(metadataJson, "");
}
} else if (selectionResult) { } else if (selectionResult) {
metadataJson = selectionResult.mp4Layout metadataJson = selectionResult.mp4Layout
? mergeMp4AlphaLayoutMetadata(metadataJson, selectionResult.mp4Layout) ? mergeMp4AlphaLayoutMetadata(metadataJson, selectionResult.mp4Layout)
@ -1029,8 +1066,24 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
previous.resourceType === "profile_card" && selectionResult.profileCardLayout previous.resourceType === "profile_card" && selectionResult.profileCardLayout
? metadataWithProfileCardLayout(metadataJson, selectionResult.profileCardLayout) ? metadataWithProfileCardLayout(metadataJson, selectionResult.profileCardLayout)
: removeProfileCardLayoutMetadata(metadataJson); : removeProfileCardLayoutMetadata(metadataJson);
if (resourceRequiresExplicitVisualFormat(previous.resourceType)) {
//
// 沿 PAG SVGA
metadataJson = roomBorderFormatMetadataJSON(metadataJson, selectionResult.roomBorderFormat);
} }
return { ...previous, animationUrl, metadataJson }; }
return {
...previous,
animationUrl,
metadataJson,
roomBorderFormat: resourceRequiresExplicitVisualFormat(previous.resourceType)
? selectionResult
? selectionResult.roomBorderFormat
: animationUrl
? previous.roomBorderFormat
: ""
: previous.roomBorderFormat,
};
}); });
}; };
const handleSubmit = (event) => { const handleSubmit = (event) => {
@ -1074,11 +1127,26 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
value={form.resourceType} value={form.resourceType}
onChange={(event) => { onChange={(event) => {
const resourceType = event.target.value; const resourceType = event.target.value;
setForm((previous) => ({ setForm((previous) => {
const roomNameStyleFields =
resourceType === "room_name_style"
? roomNameStyleFormFromMetadata(previous.metadataJson)
: {
roomNameAngleDegrees: "",
roomNameStops: "",
roomNameTextColors: "",
};
const roomBorderFormat = resourceRequiresExplicitVisualFormat(resourceType)
? roomBorderFormatFromMetadata(previous.metadataJson)
: "";
return {
...previous, ...previous,
avatarFrameKind: avatarFrameKind:
resourceType === "avatar_frame" ? previous.avatarFrameKind || "normal" : "normal", resourceType === "avatar_frame"
badgeForm: resourceType === "badge" ? previous.badgeForm || "tile" : previous.badgeForm, ? previous.avatarFrameKind || "normal"
: "normal",
badgeForm:
resourceType === "badge" ? previous.badgeForm || "tile" : previous.badgeForm,
badgeKind: resourceType === "badge" ? previous.badgeKind || "normal" : "normal", badgeKind: resourceType === "badge" ? previous.badgeKind || "normal" : "normal",
levelTrack: levelTrack:
resourceType === "badge" || resourceType === "badge" ||
@ -1089,9 +1157,12 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
resourceType === "profile_card" resourceType === "profile_card"
? previous.metadataJson ? previous.metadataJson
: removeProfileCardLayoutMetadata(previous.metadataJson), : removeProfileCardLayoutMetadata(previous.metadataJson),
...roomNameStyleFields,
resourceType, resourceType,
roomBorderFormat,
walletAssetAmount: "", walletAssetAmount: "",
})); };
});
}} }}
> >
{resourceTypeOptions.map(([value, label]) => ( {resourceTypeOptions.map(([value, label]) => (
@ -1239,12 +1310,41 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
</AdminFormFieldGrid> </AdminFormFieldGrid>
</AdminFormSection> </AdminFormSection>
<AdminFormSection title="素材"> <AdminFormSection title="素材">
{form.resourceType === "room_name_style" ? (
<RoomNameStyleEditor disabled={disabled} form={form} setForm={setForm} />
) : null}
{resourceRequiresExplicitVisualFormat(form.resourceType) ? (
<AdminFormFieldGrid>
<TextField
disabled={disabled}
helperText="跟随资源快照返回Flutter 不从 URL 后缀猜格式"
label={form.resourceType === "mic_seat_animation" ? "麦位动效素材格式" : "房间边框素材格式"}
required
select
value={form.roomBorderFormat}
onChange={(event) => {
const roomBorderFormat = event.target.value;
setForm((previous) => ({
...previous,
metadataJson: roomBorderFormatMetadataJSON(previous.metadataJson, roomBorderFormat),
roomBorderFormat,
}));
}}
>
{roomBorderFormatOptions.map((format) => (
<MenuItem key={format} value={format}>
{roomBorderFormatLabels[format] || format}
</MenuItem>
))}
</TextField>
</AdminFormFieldGrid>
) : null}
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))"> <AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<UploadField <UploadField
key={open ? "resource-preview-open" : "resource-preview-closed"} key={open ? "resource-preview-open" : "resource-preview-closed"}
disabled={disabled || uploadDisabled} disabled={disabled || uploadDisabled}
kind="file" kind="file"
label="资源封面" label={form.resourceType === "room_name_style" ? "样式预览图(可选)" : "资源封面"}
showSourceActions showSourceActions
value={form.previewUrl} value={form.previewUrl}
onChange={(previewUrl) => setForm((previous) => ({ ...previous, previewUrl }))} onChange={(previewUrl) => setForm((previous) => ({ ...previous, previewUrl }))}
@ -1254,7 +1354,7 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
key={open ? "resource-animation-open" : "resource-animation-closed"} key={open ? "resource-animation-open" : "resource-animation-closed"}
disabled={disabled || uploadDisabled} disabled={disabled || uploadDisabled}
kind="file" kind="file"
label="动效素材" label={form.resourceType === "room_name_style" ? "样式动效(可选)" : "动效素材"}
mp4Layout={mp4Layout} mp4Layout={mp4Layout}
showSourceActions showSourceActions
value={form.animationUrl} value={form.animationUrl}
@ -1301,6 +1401,38 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
); );
} }
function RoomNameStyleEditor({ disabled, form, setForm }) {
const styleError = roomNameStyleInputError(form);
return (
<AdminFormFieldGrid>
<TextField
disabled={disabled}
error={Boolean(styleError)}
helperText={styleError || "支持 1 至 8 个 #RRGGBB / #RRGGBBAA使用逗号分隔"}
label="房名渐变颜色"
required
value={form.roomNameTextColors}
onChange={(event) => setForm({ ...form, roomNameTextColors: event.target.value })}
/>
<TextField
disabled={disabled}
helperText="可选;数量需与颜色一致,按 0 至 1 递增,例如 0, 0.5, 1"
label="渐变位置"
value={form.roomNameStops}
onChange={(event) => setForm({ ...form, roomNameStops: event.target.value })}
/>
<TextField
disabled={disabled}
helperText="可选Flutter 按服务端快照直接渲染"
label="渐变角度"
type="number"
value={form.roomNameAngleDegrees}
onChange={(event) => setForm({ ...form, roomNameAngleDegrees: event.target.value })}
/>
</AdminFormFieldGrid>
);
}
function Mp4AlphaLayoutEditor({ disabled, metadataJson, onChange }) { function Mp4AlphaLayoutEditor({ disabled, metadataJson, onChange }) {
const layout = mp4AlphaLayoutFromMetadata(metadataJson); const layout = mp4AlphaLayoutFromMetadata(metadataJson);
if (!layout) { if (!layout) {

View File

@ -2,6 +2,12 @@ import { profileCardLayoutFromMetadata } from "@/features/resources/profileCardL
import { mp4AlphaLayoutFromMetadata, mp4AlphaLayoutKey } from "@/features/resources/mp4AlphaLayout.js"; import { mp4AlphaLayoutFromMetadata, mp4AlphaLayoutKey } from "@/features/resources/mp4AlphaLayout.js";
export const emptyResourceMetadata = "{}"; export const emptyResourceMetadata = "{}";
const roomNameColorPattern = /^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/;
export const roomBorderFormatOptions = ["png", "jpeg", "webp", "gif", "svga", "pag", "mp4"];
export function resourceRequiresExplicitVisualFormat(resourceType) {
return resourceType === "mic_seat_animation" || resourceType === "room_border";
}
export function resourceMetadataJSON(resourceType, metadataJson) { export function resourceMetadataJSON(resourceType, metadataJson) {
const metadata = {}; const metadata = {};
@ -14,9 +20,130 @@ export function resourceMetadataJSON(resourceType, metadataJson) {
metadata.animation_format = "mp4"; metadata.animation_format = "mp4";
metadata[mp4AlphaLayoutKey] = mp4AlphaLayout; metadata[mp4AlphaLayoutKey] = mp4AlphaLayout;
} }
if (resourceRequiresExplicitVisualFormat(resourceType)) {
const format = roomBorderFormatFromMetadata(metadataJson);
if (format) {
metadata.format = format;
}
}
if (resourceType === "room_name_style") {
const style = roomNameStyleFromMetadata(metadataJson);
if (style) {
metadata.text_colors = style.textColors;
if (style.stops.length) {
metadata.stops = style.stops;
}
if (style.angleDegrees !== null) {
metadata.angle_degrees = style.angleDegrees;
}
}
}
return Object.keys(metadata).length ? JSON.stringify(metadata) : emptyResourceMetadata; return Object.keys(metadata).length ? JSON.stringify(metadata) : emptyResourceMetadata;
} }
// 麦位动效和房间边框的格式跟资源记录一起保存,客户端不需要从 CDN URL 后缀猜渲染器。
export function roomBorderFormatMetadataJSON(metadataJson, value) {
const metadata = parseMetadataObject(metadataJson);
const format = String(value || "")
.trim()
.toLowerCase();
if (roomBorderFormatOptions.includes(format)) {
metadata.format = format;
} else {
delete metadata.format;
}
return stringifyMetadata(metadata);
}
export function roomBorderFormatFromMetadata(metadataJson) {
const metadata = parseMetadataObject(metadataJson);
const format = String(metadata.format || metadata.animation_format || "")
.trim()
.toLowerCase();
return roomBorderFormatOptions.includes(format) ? format : "";
}
// 房名颜色属于资源目录事实。后台把结构化输入归一成 room-service 唯一读取的 snake_case
// Flutter 只消费房间快照,不能再按 VIP 等级维护另一份渐变配置。
export function roomNameStyleMetadataJSON(metadataJson, input) {
const style = parseRoomNameStyleInput(input);
if (!style) {
return emptyResourceMetadata;
}
const metadata = parseMetadataObject(metadataJson);
delete metadata.textColors;
delete metadata.angleDegrees;
metadata.text_colors = style.textColors;
if (style.stops.length) {
metadata.stops = style.stops;
} else {
delete metadata.stops;
}
if (style.angleDegrees !== null) {
metadata.angle_degrees = style.angleDegrees;
} else {
delete metadata.angle_degrees;
}
return stringifyMetadata(metadata);
}
export function roomNameStyleFromMetadata(metadataJson) {
const metadata = parseMetadataObject(metadataJson);
const textColors = Array.isArray(metadata.text_colors)
? metadata.text_colors.map((value) => String(value).trim()).filter(Boolean)
: Array.isArray(metadata.textColors)
? metadata.textColors.map((value) => String(value).trim()).filter(Boolean)
: [];
const stops = Array.isArray(metadata.stops) ? metadata.stops.map(Number) : [];
const rawAngle = metadata.angle_degrees ?? metadata.angleDegrees;
const angleDegrees = rawAngle === undefined || rawAngle === null || rawAngle === "" ? null : Number(rawAngle);
const style = { angleDegrees, stops, textColors };
return validateRoomNameStyle(style) ? null : style;
}
export function roomNameStyleFormFromMetadata(metadataJson) {
const style = roomNameStyleFromMetadata(metadataJson);
return {
roomNameAngleDegrees:
style?.angleDegrees === null || style?.angleDegrees === undefined ? "" : String(style.angleDegrees),
roomNameStops: style?.stops?.join(", ") || "",
roomNameTextColors: style?.textColors?.join(", ") || "",
};
}
export function parseRoomNameStyleInput(input) {
const textColors = splitRoomNameStyleValues(input?.roomNameTextColors);
const stopTokens = splitRoomNameStyleValues(input?.roomNameStops);
const stops = stopTokens.map(Number);
const rawAngle = String(input?.roomNameAngleDegrees ?? "").trim();
const angleDegrees = rawAngle === "" ? null : Number(rawAngle);
const style = { angleDegrees, stops, textColors };
return validateRoomNameStyle(style) ? null : style;
}
export function roomNameStyleInputError(input) {
const textColors = splitRoomNameStyleValues(input?.roomNameTextColors);
if (!textColors.length || textColors.length > 8 || textColors.some((color) => !roomNameColorPattern.test(color))) {
return "请输入 1 至 8 个 #RRGGBB 或 #RRGGBBAA 颜色";
}
const stopTokens = splitRoomNameStyleValues(input?.roomNameStops);
if (stopTokens.length) {
const stops = stopTokens.map(Number);
if (
stops.length !== textColors.length ||
stops.some((stop) => !Number.isFinite(stop) || stop < 0 || stop > 1) ||
stops.some((stop, index) => index > 0 && stop < stops[index - 1])
) {
return "渐变位置必须与颜色数量一致,并按 0 至 1 递增";
}
}
const rawAngle = String(input?.roomNameAngleDegrees ?? "").trim();
if (rawAngle !== "" && !Number.isFinite(Number(rawAngle))) {
return "渐变角度必须是有效数字";
}
return "";
}
export function removeProfileCardLayoutMetadata(metadataJson) { export function removeProfileCardLayoutMetadata(metadataJson) {
const metadata = parseMetadataObject(metadataJson); const metadata = parseMetadataObject(metadataJson);
delete metadata.profile_card_layout; delete metadata.profile_card_layout;
@ -55,3 +182,32 @@ function stringifyMetadata(metadata) {
}); });
return Object.keys(clean).length ? JSON.stringify(clean) : emptyResourceMetadata; return Object.keys(clean).length ? JSON.stringify(clean) : emptyResourceMetadata;
} }
function splitRoomNameStyleValues(value) {
return String(value || "")
.split(/[\s,]+/)
.map((item) => item.trim())
.filter(Boolean);
}
function validateRoomNameStyle(style) {
if (
!style.textColors.length ||
style.textColors.length > 8 ||
style.textColors.some((color) => !roomNameColorPattern.test(color))
) {
return "invalid colors";
}
if (
style.stops.length &&
(style.stops.length !== style.textColors.length ||
style.stops.some((stop) => !Number.isFinite(stop) || stop < 0 || stop > 1) ||
style.stops.some((stop, index) => index > 0 && stop < style.stops[index - 1]))
) {
return "invalid stops";
}
if (style.angleDegrees !== null && !Number.isFinite(style.angleDegrees)) {
return "invalid angle";
}
return "";
}

View File

@ -1,6 +1,11 @@
import { z } from "zod"; import { z } from "zod";
import { cpRelationTypeOptions, resourceIdentityAutoGrantTypes } from "@/features/resources/constants.js"; import { cpRelationTypeOptions, resourceIdentityAutoGrantTypes } from "@/features/resources/constants.js";
import { hasUnconfirmedMp4Layout } from "@/features/resources/mp4AlphaLayout.js"; import { hasUnconfirmedMp4Layout } from "@/features/resources/mp4AlphaLayout.js";
import {
resourceRequiresExplicitVisualFormat,
roomBorderFormatOptions,
roomNameStyleInputError,
} from "@/features/resources/resourceMetadata.js";
const resourceTypes = [ const resourceTypes = [
"avatar_frame", "avatar_frame",
@ -13,6 +18,8 @@ const resourceTypes = [
"gift", "gift",
"mic_seat_icon", "mic_seat_icon",
"mic_seat_animation", "mic_seat_animation",
"room_border",
"room_name_style",
"emoji_pack", "emoji_pack",
"vip_trial_card", "vip_trial_card",
]; ];
@ -36,7 +43,7 @@ const optionalWalletAssetTypeSchema = z
export const resourceCreateFormSchema = z export const resourceCreateFormSchema = z
.object({ .object({
animationUrl: z.string().trim().min(1, "请上传动效素材").max(512, "动效素材不能超过 512 个字符"), animationUrl: z.string().trim().max(512, "动效素材不能超过 512 个字符"),
avatarFrameKind: z.enum(avatarFrameKinds).optional(), avatarFrameKind: z.enum(avatarFrameKinds).optional(),
badgeForm: z.enum(badgeForms).optional(), badgeForm: z.enum(badgeForms).optional(),
badgeKind: z.enum(badgeKinds).optional(), badgeKind: z.enum(badgeKinds).optional(),
@ -46,13 +53,37 @@ export const resourceCreateFormSchema = z
metadataJson: z.string().trim().max(4096, "资源元数据不能超过 4096 个字符").optional(), metadataJson: z.string().trim().max(4096, "资源元数据不能超过 4096 个字符").optional(),
name: z.string().trim().min(1, "请输入资源名称").max(128, "资源名称不能超过 128 个字符"), name: z.string().trim().min(1, "请输入资源名称").max(128, "资源名称不能超过 128 个字符"),
coinPrice: z.union([z.string(), z.number()]).optional(), coinPrice: z.union([z.string(), z.number()]).optional(),
previewUrl: z.string().trim().min(1, "请上传资源封面").max(512, "资源封面不能超过 512 个字符"), previewUrl: z.string().trim().max(512, "资源封面不能超过 512 个字符"),
priceType: z.enum(resourcePriceTypes, "请选择价格类型"), priceType: z.enum(resourcePriceTypes, "请选择价格类型"),
resourceCode: z.string().trim().min(1, "请输入资源编码").max(96, "资源编码不能超过 96 个字符"), resourceCode: z.string().trim().min(1, "请输入资源编码").max(96, "资源编码不能超过 96 个字符"),
resourceType: z.enum(resourceTypes, "请选择资源类型"), resourceType: z.enum(resourceTypes, "请选择资源类型"),
roomBorderFormat: z.string().optional(),
roomNameAngleDegrees: z.union([z.string(), z.number()]).optional(),
roomNameStops: z.string().optional(),
roomNameTextColors: z.string().optional(),
walletAssetAmount: z.union([z.string(), z.number()]).optional(), walletAssetAmount: z.union([z.string(), z.number()]).optional(),
}) })
.superRefine((value, context) => { .superRefine((value, context) => {
// 彩色房名是纯样式资源,不要求伪造图片 URL其他既有资源继续保持封面和素材的原校验边界。
if (value.resourceType !== "room_name_style") {
if (!value.previewUrl) {
context.addIssue({ code: "custom", message: "请上传资源封面", path: ["previewUrl"] });
}
if (!value.animationUrl) {
context.addIssue({ code: "custom", message: "请上传动效素材", path: ["animationUrl"] });
}
} else {
const styleError = roomNameStyleInputError(value);
if (styleError) {
context.addIssue({ code: "custom", message: styleError, path: ["roomNameTextColors"] });
}
}
if (
resourceRequiresExplicitVisualFormat(value.resourceType) &&
!roomBorderFormatOptions.includes(value.roomBorderFormat)
) {
context.addIssue({ code: "custom", message: "请选择视觉资源素材格式", path: ["roomBorderFormat"] });
}
if (value.resourceType !== "coin") { if (value.resourceType !== "coin") {
if (value.resourceType === "badge" && !value.badgeForm) { if (value.resourceType === "badge" && !value.badgeForm) {
context.addIssue({ context.addIssue({
@ -527,7 +558,10 @@ export const resourceGrantFormSchema = z
path: ["groupId"], path: ["groupId"],
}); });
} }
if ((value.subjectType === "vip" || value.subjectType === "vip_trial_card") && (!Number.isInteger(vipLevel) || vipLevel <= 0)) { if (
(value.subjectType === "vip" || value.subjectType === "vip_trial_card") &&
(!Number.isInteger(vipLevel) || vipLevel <= 0)
) {
context.addIssue({ context.addIssue({
code: "custom", code: "custom",
message: "请选择VIP等级", message: "请选择VIP等级",

View File

@ -37,6 +37,14 @@ export const vipGrantModeLabels = {
trial_card: "VIP体验卡", trial_card: "VIP体验卡",
}; };
// 这三项由 room/wallet 按真实资源和 entitlement 执行。后台必须限定目录类型,
// 否则把头像框等任意资源绑定进房间权益后,只会在用户实际应用时才失败。
export const vipBenefitResourceTypes = {
colored_room_name: "room_name_style",
mic_skin: "mic_seat_animation",
room_border: "room_border",
};
// P1 的 34 项权益用于目录展示和兼容服务端契约unlockLevel 只是历史字段,后台不据此补齐、开启或继承权益。 // P1 的 34 项权益用于目录展示和兼容服务端契约unlockLevel 只是历史字段,后台不据此补齐、开启或继承权益。
export const p1BenefitCatalog = [ export const p1BenefitCatalog = [
benefit("vip_badge_identity", "VIP标识", 1, "function", "user", false), benefit("vip_badge_identity", "VIP标识", 1, "function", "user", false),
@ -56,7 +64,7 @@ export const p1BenefitCatalog = [
benefit("mic_skin", "麦克风皮肤", 4, "decoration", "room", true), benefit("mic_skin", "麦克风皮肤", 4, "decoration", "room", true),
benefit("room_border", "房间边框", 4, "decoration", "room", true), benefit("room_border", "房间边框", 4, "decoration", "room", true),
benefit("vehicle", "VIP座驾", 4, "decoration", "room", true), benefit("vehicle", "VIP座驾", 4, "decoration", "room", true),
benefit("colored_room_name", "VIP彩色房间名称", 5, "function", "room", false), benefit("colored_room_name", "VIP彩色房间名称", 5, "decoration", "room", false),
benefit("colored_nickname", "VIP彩色昵称", 5, "function", "user", false), benefit("colored_nickname", "VIP彩色昵称", 5, "function", "user", false),
benefit("colored_id", "VIP彩色ID", 5, "function", "user", false), benefit("colored_id", "VIP彩色ID", 5, "function", "user", false),
benefit("gift_tray_skin", "送礼托盘皮肤", 5, "function", "room", false), benefit("gift_tray_skin", "送礼托盘皮肤", 5, "function", "room", false),

View File

@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx"; import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
import { getVipConfig, getVipProgram, updateVipConfig, updateVipProgram } from "@/features/vip-config/api"; import { getVipConfig, getVipProgram, updateVipConfig, updateVipProgram } from "@/features/vip-config/api";
import { p1BenefitCatalog, vipProgramDefaults } from "@/features/vip-config/constants.js"; import { p1BenefitCatalog, vipBenefitResourceTypes, vipProgramDefaults } from "@/features/vip-config/constants.js";
import { normalizeVipBenefitMetadata } from "@/features/vip-config/metadata.js"; import { normalizeVipBenefitMetadata } from "@/features/vip-config/metadata.js";
import { useVipConfigAbilities } from "@/features/vip-config/permissions.js"; import { useVipConfigAbilities } from "@/features/vip-config/permissions.js";
import { vipConfigFormSchema, vipProgramFormSchema } from "@/features/vip-config/schema.js"; import { vipConfigFormSchema, vipProgramFormSchema } from "@/features/vip-config/schema.js";
@ -456,10 +456,11 @@ function completeP1BenefitCatalog(remoteCatalog) {
function benefitToForm(benefit, level, index, catalogPlaceholder) { function benefitToForm(benefit, level, index, catalogPlaceholder) {
const benefitCode = benefit.benefitCode || ""; const benefitCode = benefit.benefitCode || "";
const expectedResourceType = vipBenefitResourceTypes[benefitCode] || "";
return { return {
benefitCode, benefitCode,
name: benefit.name || benefitCode, name: benefit.name || benefitCode,
benefitType: benefit.benefitType === "decoration" ? "decoration" : "function", benefitType: Boolean(expectedResourceType) || benefit.benefitType === "decoration" ? "decoration" : "function",
unlockLevel: Number(benefit.unlockLevel || level || 1), unlockLevel: Number(benefit.unlockLevel || level || 1),
status: catalogPlaceholder ? "disabled" : benefit.status === "active" ? "active" : "disabled", status: catalogPlaceholder ? "disabled" : benefit.status === "active" ? "active" : "disabled",
trialEnabled: benefitCode === "daily_coin_rebate" ? false : Boolean(benefit.trialEnabled), trialEnabled: benefitCode === "daily_coin_rebate" ? false : Boolean(benefit.trialEnabled),
@ -537,6 +538,7 @@ function benefitPayload(benefit, level) {
const rawMetadataJson = String(benefit.metadataJson || "{}").trim() || "{}"; const rawMetadataJson = String(benefit.metadataJson || "{}").trim() || "{}";
const sortOrder = Number(benefit.sortOrder || 0); const sortOrder = Number(benefit.sortOrder || 0);
const resourceId = Number(benefit.resourceId || 0); const resourceId = Number(benefit.resourceId || 0);
const expectedResourceType = vipBenefitResourceTypes[benefit.benefitCode] || "";
if (!Number.isInteger(sortOrder) || sortOrder < 0) { if (!Number.isInteger(sortOrder) || sortOrder < 0) {
throw new Error(`VIP${level}${benefit.name || benefit.benefitCode} 排序不能小于 0`); throw new Error(`VIP${level}${benefit.name || benefit.benefitCode} 排序不能小于 0`);
} }
@ -552,6 +554,14 @@ function benefitPayload(benefit, level) {
throw new Error(`VIP${level}${benefit.name || benefit.benefitCode} 资源不正确`); throw new Error(`VIP${level}${benefit.name || benefit.benefitCode} 资源不正确`);
} }
const status = benefit.status === "active" ? "active" : "disabled"; const status = benefit.status === "active" ? "active" : "disabled";
if (expectedResourceType && status === "active" && resourceId <= 0) {
throw new Error(`VIP${level}${benefit.name || benefit.benefitCode} 必须绑定真实资源`);
}
if (expectedResourceType && resourceId > 0 && benefit.resourceType !== expectedResourceType) {
throw new Error(
`VIP${level}${benefit.name || benefit.benefitCode} 只能绑定 ${expectedResourceType} 类型资源`,
);
}
const metadataJson = normalizeVipBenefitMetadata({ const metadataJson = normalizeVipBenefitMetadata({
benefitCode: benefit.benefitCode, benefitCode: benefit.benefitCode,
label: `VIP${level}${benefit.name || benefit.benefitCode}`, label: `VIP${level}${benefit.name || benefit.benefitCode}`,
@ -561,7 +571,7 @@ function benefitPayload(benefit, level) {
return { return {
benefitCode: String(benefit.benefitCode || "").trim(), benefitCode: String(benefit.benefitCode || "").trim(),
name: String(benefit.name || "").trim(), name: String(benefit.name || "").trim(),
benefitType: benefit.benefitType === "decoration" ? "decoration" : "function", benefitType: Boolean(expectedResourceType) || benefit.benefitType === "decoration" ? "decoration" : "function",
unlockLevel: Number(benefit.unlockLevel || level), unlockLevel: Number(benefit.unlockLevel || level),
status, status,
trialEnabled: benefit.benefitCode === "daily_coin_rebate" ? false : Boolean(benefit.trialEnabled), trialEnabled: benefit.benefitCode === "daily_coin_rebate" ? false : Boolean(benefit.trialEnabled),

View File

@ -17,6 +17,7 @@ import TextField from "@mui/material/TextField";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { ResourceSelectField } from "@/features/resources/components/ResourceSelectDrawer.jsx"; import { ResourceSelectField } from "@/features/resources/components/ResourceSelectDrawer.jsx";
import { import {
vipBenefitResourceTypes,
vipGrantModeLabels, vipGrantModeLabels,
vipProgramDefaults, vipProgramDefaults,
vipProgramLabels, vipProgramLabels,
@ -603,7 +604,11 @@ function BenefitCard({ benefit, disabled, onChange, onSelect, selected }) {
function BenefitInspector({ benefit, disabled, level, page }) { function BenefitInspector({ benefit, disabled, level, page }) {
const rebateBenefit = benefit.benefitCode === "daily_coin_rebate"; const rebateBenefit = benefit.benefitCode === "daily_coin_rebate";
const onlineNoticeBenefit = benefit.benefitCode === "online_global_notice"; const onlineNoticeBenefit = benefit.benefitCode === "online_global_notice";
const decorationBenefit = benefit.benefitType === "decoration"; const expectedResourceType = vipBenefitResourceTypes[benefit.benefitCode] || "";
const decorationBenefit = Boolean(expectedResourceType) || benefit.benefitType === "decoration";
const selectableResources = expectedResourceType
? page.resources.filter((resource) => resource.resourceType === expectedResourceType)
: page.resources;
const metadataValid = Boolean(readVipBenefitMetadata(benefit.metadataJson)); const metadataValid = Boolean(readVipBenefitMetadata(benefit.metadataJson));
const onChange = (patch) => page.updateBenefit(level, benefit.benefitCode, patch); const onChange = (patch) => page.updateBenefit(level, benefit.benefitCode, patch);
@ -675,9 +680,9 @@ function BenefitInspector({ benefit, disabled, level, page }) {
drawerTitle={`选择 ${benefit.name} 资源`} drawerTitle={`选择 ${benefit.name} 资源`}
label="绑定资源" label="绑定资源"
loading={page.resourcesLoading} loading={page.resourcesLoading}
placeholder="可不绑定资源" placeholder={benefit.status === "active" ? "请选择权益资源" : "停用时不绑定"}
required={false} required={benefit.status === "active"}
resources={page.resources} resources={selectableResources}
value={benefit.resourceId} value={benefit.resourceId}
onChange={(resourceId, resource) => onChange={(resourceId, resource) =>
onChange({ resourceId, resourceType: resource?.resourceType || "" }) onChange({ resourceId, resourceType: resource?.resourceType || "" })
@ -713,7 +718,7 @@ function BenefitInspector({ benefit, disabled, level, page }) {
<summary>高级设置</summary> <summary>高级设置</summary>
<div className={styles.advancedFields}> <div className={styles.advancedFields}>
<TextField <TextField
disabled={disabled} disabled={disabled || Boolean(expectedResourceType)}
label="权益类型" label="权益类型"
select select
size="small" size="small"

View File

@ -238,6 +238,7 @@ export const API_OPERATIONS = {
listLevelConfig: "listLevelConfig", listLevelConfig: "listLevelConfig",
listLoginLogs: "listLoginLogs", listLoginLogs: "listLoginLogs",
listLuckyGiftConfigs: "listLuckyGiftConfigs", listLuckyGiftConfigs: "listLuckyGiftConfigs",
listLuckyGiftConfigVersions: "listLuckyGiftConfigVersions",
listLuckyGiftDraws: "listLuckyGiftDraws", listLuckyGiftDraws: "listLuckyGiftDraws",
listLuckyGiftExperiments: "listLuckyGiftExperiments", listLuckyGiftExperiments: "listLuckyGiftExperiments",
listLuckyGiftPoolBalances: "listLuckyGiftPoolBalances", listLuckyGiftPoolBalances: "listLuckyGiftPoolBalances",
@ -326,6 +327,7 @@ export const API_OPERATIONS = {
retryRoomTurnoverRewardSettlement: "retryRoomTurnoverRewardSettlement", retryRoomTurnoverRewardSettlement: "retryRoomTurnoverRewardSettlement",
revokeAppUserResource: "revokeAppUserResource", revokeAppUserResource: "revokeAppUserResource",
revokeResourceGrant: "revokeResourceGrant", revokeResourceGrant: "revokeResourceGrant",
rollbackLuckyGiftConfig: "rollbackLuckyGiftConfig",
savePolicyTemplate: "savePolicyTemplate", savePolicyTemplate: "savePolicyTemplate",
search: "search", search: "search",
selfGameStatisticsOverview: "selfGameStatisticsOverview", selfGameStatisticsOverview: "selfGameStatisticsOverview",
@ -1996,6 +1998,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "lucky-gift:view", permission: "lucky-gift:view",
permissions: ["lucky-gift:view"] permissions: ["lucky-gift:view"]
}, },
listLuckyGiftConfigVersions: {
method: "GET",
operationId: API_OPERATIONS.listLuckyGiftConfigVersions,
path: "/v1/admin/ops-center/lucky-gifts/config/versions",
permission: "lucky-gift:view",
permissions: ["lucky-gift:view"]
},
listLuckyGiftDraws: { listLuckyGiftDraws: {
method: "GET", method: "GET",
operationId: API_OPERATIONS.listLuckyGiftDraws, operationId: API_OPERATIONS.listLuckyGiftDraws,
@ -2600,6 +2609,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "resource-grant:revoke", permission: "resource-grant:revoke",
permissions: ["resource-grant:revoke"] permissions: ["resource-grant:revoke"]
}, },
rollbackLuckyGiftConfig: {
method: "POST",
operationId: API_OPERATIONS.rollbackLuckyGiftConfig,
path: "/v1/admin/ops-center/lucky-gifts/config/rollback",
permission: "lucky-gift:update",
permissions: ["lucky-gift:update"]
},
savePolicyTemplate: { savePolicyTemplate: {
method: "POST", method: "POST",
operationId: API_OPERATIONS.savePolicyTemplate, operationId: API_OPERATIONS.savePolicyTemplate,

View File

@ -276,6 +276,38 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/admin/ops-center/lucky-gifts/config/versions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listLuckyGiftConfigVersions"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/ops-center/lucky-gifts/config/rollback": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["rollbackLuckyGiftConfig"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/ops-center/lucky-gifts/draws": { "/admin/ops-center/lucky-gifts/draws": {
parameters: { parameters: {
query?: never; query?: never;
@ -8478,6 +8510,30 @@ export interface operations {
200: components["responses"]["EmptyResponse"]; 200: components["responses"]["EmptyResponse"];
}; };
}; };
listLuckyGiftConfigVersions: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
rollbackLuckyGiftConfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listLuckyGiftDraws: { listLuckyGiftDraws: {
parameters: { parameters: {
query?: never; query?: never;