From 169ada93b5ccb5ff531ad81bc81c8e0e4b2e4e5e Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 17 Jul 2026 16:55:15 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B5=84=E6=BA=90=E7=9B=B8=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 24 +++ ops-center/src/OpsCenterApp.jsx | 80 +++++++- ops-center/src/OpsCenterApp.test.jsx | 26 ++- ops-center/src/api.js | 68 ++++++- ops-center/src/api.test.js | 18 +- ops-center/src/components/ConfigsView.jsx | 18 +- .../src/components/LuckyGiftConfigDialog.jsx | 52 ++++- .../components/LuckyGiftConfigDialog.test.jsx | 16 +- .../components/LuckyGiftConfigSections.jsx | 15 +- .../components/LuckyGiftRollbackDialog.jsx | 123 ++++++++++++ ops-center/src/configForm.js | 57 +++++- ops-center/src/configForm.test.js | 62 +++++- ops-center/src/styles/index.css | 66 +++++++ src/features/resources/constants.js | 6 +- .../resources/hooks/useResourcePages.js | 59 ++++-- .../resources/pages/ResourceListPage.jsx | 182 +++++++++++++++--- src/features/resources/resourceMetadata.js | 156 +++++++++++++++ src/features/resources/schema.js | 40 +++- src/features/vip-config/constants.js | 10 +- .../vip-config/hooks/useVipConfigPage.js | 16 +- .../vip-config/pages/VipConfigPage.jsx | 15 +- src/shared/api/generated/endpoints.ts | 16 ++ src/shared/api/generated/schema.d.ts | 56 ++++++ 23 files changed, 1078 insertions(+), 103 deletions(-) create mode 100644 ops-center/src/components/LuckyGiftRollbackDialog.jsx diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 3e5dbc3..615e952 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -354,6 +354,30 @@ "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": { "get": { "operationId": "listLuckyGiftDraws", diff --git a/ops-center/src/OpsCenterApp.jsx b/ops-center/src/OpsCenterApp.jsx index 5eab7c7..3d21fa8 100644 --- a/ops-center/src/OpsCenterApp.jsx +++ b/ops-center/src/OpsCenterApp.jsx @@ -15,8 +15,10 @@ import { createLuckyGiftExperiment, DEFAULT_POOL_ID, endLuckyGiftExperiment, + fetchLuckyGiftConfigVersions, fetchLuckyGiftExperiments, fetchOpsDashboard, + rollbackLuckyGiftConfig, saveLuckyGiftConfig, setLuckyGiftExperimentOverrides, updateLuckyGiftExperiment, @@ -27,6 +29,7 @@ import { DrawsView } from "./components/DrawsView.jsx"; import { LuckyGiftConfigDialog } from "./components/LuckyGiftConfigDialog.jsx"; import { LuckyGiftExperimentManageDialog } from "./components/LuckyGiftExperimentManageDialog.jsx"; import { LuckyGiftPoolAdjustmentDialog } from "./components/LuckyGiftPoolAdjustmentDialog.jsx"; +import { LuckyGiftRollbackDialog } from "./components/LuckyGiftRollbackDialog.jsx"; import { OpsShell } from "./components/OpsShell.jsx"; import { OverviewView } from "./components/OverviewView.jsx"; import { EXPERIMENT_ARM_LABELS, formatTrafficPercent } from "./experimentForm.js"; @@ -50,6 +53,7 @@ const initialDashboard = { apps: [], appSummaries: [], experiments: [], + giftHostDiamondRatiosByApp: {}, luckyGifts: [], poolBalances: [], summary: {}, @@ -72,10 +76,12 @@ export function OpsCenterApp({ can = denyPermission }) { const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true }); const [dialog, setDialog] = useState(null); const [poolDialog, setPoolDialog] = useState(null); + const [rollbackDialog, setRollbackDialog] = useState(null); const [experimentManage, setExperimentManage] = useState(null); const [profileRefreshKey, setProfileRefreshKey] = useState(0); const [adjustingPool, setAdjustingPool] = useState(false); const [savingConfig, setSavingConfig] = useState(false); + const [rollbackBusy, setRollbackBusy] = useState(false); const [experimentBusy, setExperimentBusy] = useState(false); const { showToast } = useToast(); const canCreditPool = can(PERMISSIONS.luckyGiftPoolCredit); @@ -175,6 +181,64 @@ export function OpsCenterApp({ can = denyPermission }) { 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) => { if (!pool) { return; @@ -438,13 +502,14 @@ export function OpsCenterApp({ can = denyPermission }) { experiments={data.experiments} luckyGifts={data.luckyGifts} poolBalances={data.poolBalances} - saving={savingConfig} + saving={savingConfig || rollbackBusy} onCreate={openCreateConfig} onCreateExperiment={openCreateExperiment} onCredit={(pool) => openPoolAdjustment(pool, "in")} onDebit={(pool) => openPoolAdjustment(pool, "out")} onEdit={openEditConfig} onManageExperiment={openExperimentManage} + onRollback={openRollback} /> ) : null} {activeView === "draws" ? ( @@ -466,6 +531,7 @@ export function OpsCenterApp({ can = denyPermission }) { setDialog(null)} @@ -504,6 +570,18 @@ export function OpsCenterApp({ can = denyPermission }) { onSubmit={handleAdjustPool} /> ) : null} + {rollbackDialog ? ( + setRollbackDialog(null)} + onRetry={() => loadRollbackVersions(rollbackDialog.config)} + onSubmit={handleRollback} + /> + ) : null} ); diff --git a/ops-center/src/OpsCenterApp.test.jsx b/ops-center/src/OpsCenterApp.test.jsx index cef96ca..1592755 100644 --- a/ops-center/src/OpsCenterApp.test.jsx +++ b/ops-center/src/OpsCenterApp.test.jsx @@ -7,8 +7,10 @@ import { createLuckyGiftExperiment, endLuckyGiftExperiment, fetchLuckyGiftDraws, + fetchLuckyGiftConfigVersions, fetchLuckyGiftExperiments, fetchOpsDashboard, + rollbackLuckyGiftConfig, saveLuckyGiftConfig, setLuckyGiftExperimentOverrides, updateLuckyGiftExperiment, @@ -21,10 +23,12 @@ vi.mock("./api.js", () => ({ createLuckyGiftExperiment: vi.fn(), endLuckyGiftExperiment: vi.fn(), fetchLuckyGiftDraws: vi.fn(), + fetchLuckyGiftConfigVersions: vi.fn(), fetchLuckyGiftExperiments: vi.fn(), fetchOpsDashboard: vi.fn(), luckyGiftPoolKey: (item = {}) => `${item.app_code || item.appCode}:${item.pool_id || item.poolId}:${item.strategy_version || item.strategyVersion || "fixed_v2"}`, + rollbackLuckyGiftConfig: vi.fn(), saveLuckyGiftConfig: vi.fn(), setLuckyGiftExperimentOverrides: vi.fn(), updateLuckyGiftExperiment: vi.fn(), @@ -55,6 +59,11 @@ beforeEach(() => { adjustment: { balance_after: 39_559_017, balance_before: 38_559_017 }, idempotent_replay: false, }); + fetchLuckyGiftConfigVersions.mockResolvedValue([]); + rollbackLuckyGiftConfig.mockResolvedValue({ + config: { app_code: "lalu", pool_id: "super_lucky", rule_version: 22 }, + sourceRuleVersion: 20, + }); fetchLuckyGiftDraws.mockResolvedValue({ items: [ { @@ -76,6 +85,10 @@ beforeEach(() => { }); fetchOpsDashboard.mockResolvedValue({ experiments: [mockRunningExperiment()], + giftHostDiamondRatiosByApp: { + lalu: { lucky: 10, super_lucky: 1 }, + yumi: { lucky: 10, super_lucky: 1 }, + }, apps: [ { app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 }, { 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.objectContaining({ app_code: "lalu", - anchor_rate_percent: 1, + anchor_rate_percent: 10, initial_pool_coins: 0, jackpot_multipliers: [200, 500, 1000], pool_id: "lucky", @@ -432,7 +445,8 @@ test("blocks an invalid enabled dynamic rule before calling the save API", async renderApp(); await screen.findByText("运营应用"); fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ })); - await screen.findAllByText("lucky"); + // dashboard 已完成加载,切换本地视图和打开弹窗都是同步状态;避免在全量并发测试中额外轮询文本。 + expect(screen.getAllByText("lucky").length).toBeGreaterThan(0); fireEvent.click(screen.getByRole("button", { name: "新增版本 lalu / lucky / dynamic_v3" })); 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() { return { anchor_daily_payout_cap: 6_000_000, - anchor_rate_percent: 1, + anchor_rate_percent: 10, control_band_percent: 3, device_daily_payout_cap: 4_000_000, effective_from_ms: 0, @@ -470,15 +484,15 @@ function mockDynamicConfig() { max_single_payout: 1_000_000, normal_max_equivalent_draws: 20_000, novice_max_equivalent_draws: 2_000, - pool_rate_percent: 98, + pool_rate_percent: 89, profit_rate_percent: 1, recharge_boost_factor_percent: 110, recharge_boost_window_ms: 300_000, room_hourly_payout_cap: 5_000_000, settlement_window_wager: 1_000_000, - stages: mockStages(98), + stages: mockStages(89), strategy_version: "dynamic_v3", - target_rtp_percent: 98, + target_rtp_percent: 89, user_daily_payout_cap: 3_000_000, user_hourly_payout_cap: 2_000_000, }; diff --git a/ops-center/src/api.js b/ops-center/src/api.js index 2532592..ac449d8 100644 --- a/ops-center/src/api.js +++ b/ops-center/src/api.js @@ -4,6 +4,7 @@ import { apiRequest } from "@/shared/api/request"; export const OPS_API_PREFIX = "/api/v1/admin/ops-center"; export const DEFAULT_POOL_ID = "default"; const USER_24H_RTP_DISABLED_FACTOR_PERCENT = 100; +const GIFT_DIAMOND_RATIOS_PATH = "/v1/admin/operations/gift-diamond-ratios"; export async function fetchOpsDashboard(query = {}) { // Ops Center 是跨 App 的运营面板:先拉应用主数据,再按 app_code 扇出拉取奖池配置、余额和抽奖汇总。 @@ -14,21 +15,45 @@ export async function fetchOpsDashboard(query = {}) { const perApp = await Promise.all( appCodes.map(async (appCode) => { - const [luckyGifts, poolBalances, drawSummary, experiments] = await Promise.all([ + const [luckyGifts, poolBalances, drawSummary, experiments, giftHostDiamondRatios] = await Promise.all([ fetchAppLuckyGiftConfigs(appCode), opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems), opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } }), // 实验列表驱动配置行的实验徽章和发布禁用;该接口比面板后上线,请求失败时降级为空列表 // 而不是整页白屏——活跃实验期间的常规发布仍由 owner 以 409 兜底拒绝,降级是安全的。 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 }); } +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) { // 必须用复数 /configs:它返回该应用每个奖池的最新已发布规则版本。 // 单数 /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({ adjustmentId, amountCoins, @@ -395,6 +449,9 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) { const appSummaries = perApp .map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode)) .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 currentConfigByPool = new Map(); @@ -438,7 +495,7 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) { 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 = "") { @@ -1061,6 +1118,11 @@ function numberValue(value) { 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) { if (!Array.isArray(values)) { return []; diff --git a/ops-center/src/api.test.js b/ops-center/src/api.test.js index 6766ebf..3ffd687 100644 --- a/ops-center/src/api.test.js +++ b/ops-center/src/api.test.js @@ -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 () => { const calls = []; - vi.spyOn(window, "fetch").mockImplementation(async (url) => { + const requests = []; + vi.spyOn(window, "fetch").mockImplementation(async (url, init) => { const text = String(url); calls.push(text); + requests.push({ init, url: text }); if (text.endsWith("/apps")) { 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: [] }); }); @@ -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", // dashboard 扇出不带 with_stats/status:实验列表只驱动实验徽章和发布封锁。 "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.objectContaining({ app_code: "lalu", experiment_id: "exp-1", pool_id: "lucky", status: "running" }), ]); diff --git a/ops-center/src/components/ConfigsView.jsx b/ops-center/src/components/ConfigsView.jsx index 8c95d23..a2b43e1 100644 --- a/ops-center/src/components/ConfigsView.jsx +++ b/ops-center/src/components/ConfigsView.jsx @@ -25,6 +25,7 @@ export function ConfigsView({ onDebit, onEdit, onManageExperiment, + onRollback, poolBalances, saving, }) { @@ -72,6 +73,7 @@ export function ConfigsView({ onDebit, onEdit, onManageExperiment, + onRollback, saving, }), [ @@ -86,6 +88,7 @@ export function ConfigsView({ onDebit, onEdit, onManageExperiment, + onRollback, saving, ], ); @@ -139,6 +142,7 @@ function buildColumns({ onDebit, onEdit, onManageExperiment, + onRollback, saving, }) { const columns = [ @@ -248,6 +252,18 @@ function buildColumns({ {item.is_default ? "发布配置" : "新增版本"} ) : null} + {canUpdate && !item.is_default ? ( + + ) : null} {hasActiveExperiment ? ( + + + + ); +} diff --git a/ops-center/src/configForm.js b/ops-center/src/configForm.js index dcbaad8..777b708 100644 --- a/ops-center/src/configForm.js +++ b/ops-center/src/configForm.js @@ -247,9 +247,40 @@ export function formToConfig(config, form) { 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, // 运营补齐后才能通过启用校验,避免一次策略切换直接发布无法结算的规则。 -export function applyDynamicStrategyDefaults(form) { +export function applyDynamicStrategyDefaults(form, { anchorRatePercent = null } = {}) { const next = { ...form, enabled: false, @@ -261,15 +292,24 @@ export function applyDynamicStrategyDefaults(form) { 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]) => { const [min7d, min30d] = defaultRechargeThreshold(stage); return { min_recharge_7d_coins: String(min7d), min_recharge_30d_coins: String(min30d), stage, - // fixed_v2 可能含历史高倍基础档;切换策略时必须回到 owner 的 98% 安全基线, + // fixed_v2 可能含历史高倍基础档;切换策略时必须按当前资金可用 RTP 重建安全基线, // 不能只改策略名后把旧概率意外带入 dynamic_v3 的第一版。 - tiers: correctedDefaultTiers(stage, dynamicDefaults.target_rtp_percent), + tiers: correctedDefaultTiers(stage, numberFromForm(next.target_rtp_percent)), }; }); return next; @@ -371,8 +411,7 @@ export function validateLuckyGiftForm(form) { errors.push("关闭 V2 高水位加权时,加权系数与恢复水位必须为 0"); } else { if (boostThreshold < 0) errors.push("V2 高水位加权阈值不能小于 0"); - if (boostFactor <= 100 || boostFactor > 1000) - errors.push("V2 高水位加权系数必须大于 100% 且不超过 1000%"); + if (boostFactor <= 100 || boostFactor > 1000) errors.push("V2 高水位加权系数必须大于 100% 且不超过 1000%"); if (boostRecover <= 0 || boostRecover > boostThreshold) errors.push("V2 加权恢复水位必须大于 0 且不高于加权阈值"); } @@ -380,14 +419,14 @@ export function validateLuckyGiftForm(form) { } if (number("profit_rate_percent") < 0 || number("anchor_rate_percent") < 0) - errors.push("平台盈利和主播收益比例不能小于 0"); + errors.push("平台比例和主播钻石比例不能小于 0"); if ( percentToPPM(number("pool_rate_percent")) + percentToPPM(number("profit_rate_percent")) + percentToPPM(number("anchor_rate_percent")) !== PPM_SCALE ) - errors.push("奖池、平台盈利和主播收益比例合计必须等于 100%"); + errors.push("奖池、平台和主播钻石比例合计必须等于 100%"); if (number("initial_pool_coins") !== 0) errors.push("dynamic_v3 初始奖池必须为 0,请通过水位操作注资"); if (number("loss_streak_guarantee") <= 0) errors.push("连续未中奖保护次数必须大于 0"); if (number("low_watermark_coins") <= 0 || number("high_watermark_coins") <= number("low_watermark_coins")) @@ -626,6 +665,10 @@ function percentToPPM(value) { return Math.round(Number(value || 0) * 10_000); } +function roundedPercent(value) { + return Math.round(Number(value || 0) * 10_000) / 10_000; +} + function unique(values) { return Array.from(new Set(values)); } diff --git a/ops-center/src/configForm.test.js b/ops-center/src/configForm.test.js index 86478f3..f8e747c 100644 --- a/ops-center/src/configForm.test.js +++ b/ops-center/src/configForm.test.js @@ -1,5 +1,12 @@ 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", () => { 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", target_rtp_percent: 95, }); - const dynamic = applyDynamicStrategyDefaults(legacy); + const dynamic = applyDynamicStrategyDefaults(legacy, { anchorRatePercent: 10 }); expect(dynamic).toMatchObject({ + anchor_rate_percent: "10", enabled: false, initial_pool_coins: "0", - pool_rate_percent: "98", + pool_rate_percent: "89", strategy_version: "dynamic_v3", + target_rtp_percent: "89", }); 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], ]); - expect(dynamic.stages.map((stage) => stage.tiers.map((tier) => Number(tier.probabilityPercent)))).toEqual([ - [5, 4, 86, 5], - [5, 4, 86, 5], - [5, 4, 86, 5], - ]); + expect( + dynamic.stages.every( + (stage) => stage.tiers.reduce((sum, tier) => sum + Number(tier.probabilityPercent), 0) === 100, + ), + ).toBe(true); 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", () => { const draft = configToForm({ app_code: "lalu", pool_id: "default", strategy_version: "dynamic_v3" }); 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"); @@ -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() { return ["novice", "normal", "advanced"].map((stage) => ({ stage, diff --git a/ops-center/src/styles/index.css b/ops-center/src/styles/index.css index 602db70..e3fff98 100644 --- a/ops-center/src/styles/index.css +++ b/ops-center/src/styles/index.css @@ -184,6 +184,72 @@ body { 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 { diff --git a/src/features/resources/constants.js b/src/features/resources/constants.js index 2730af6..e09fcf0 100644 --- a/src/features/resources/constants.js +++ b/src/features/resources/constants.js @@ -107,6 +107,8 @@ export const resourceTypeFilters = [ ["gift", "礼物"], ["mic_seat_icon", "麦位图标"], ["mic_seat_animation", "麦位动效"], + ["room_border", "房间边框"], + ["room_name_style", "房间名称样式"], ["emoji_pack", "表情包"], ["vip_trial_card", "VIP体验卡"], ]; @@ -134,9 +136,7 @@ export const resourceShopTypeFilters = [ ...resourceTypeFilters.filter(([value]) => resourceShopSellableTypes.includes(value)), ]; -export const resourceGroupAssetOptions = [ - ["COIN", "金币"], -]; +export const resourceGroupAssetOptions = [["COIN", "金币"]]; export const resourceGroupAssetLabels = Object.fromEntries(resourceGroupAssetOptions); diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js index 323ba78..09b4510 100644 --- a/src/features/resources/hooks/useResourcePages.js +++ b/src/features/resources/hooks/useResourcePages.js @@ -53,7 +53,14 @@ import { resourceShopSellableTypes, } from "@/features/resources/constants.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 { emojiPackFormSchema, giftFormSchema, @@ -105,6 +112,8 @@ const emptyResourceForm = (resource = {}) => ({ priceType: resource.resourceId ? resource.priceType || (resource.coinPrice > 0 ? "coin" : "free") : "coin", resourceCode: resource.resourceCode || "", resourceType: resource.resourceType || "avatar_frame", + roomBorderFormat: roomBorderFormatFromMetadata(resource.metadataJson), + ...roomNameStyleFormFromMetadata(resource.metadataJson), walletAssetAmount: resource.walletAssetAmount ? String(resource.walletAssetAmount) : "", }); const emptyEmojiPackForm = () => ({ @@ -189,9 +198,7 @@ export function applyGiftResourceSelection(form, resource, fillIdentity = true) export function buildGiftResourceDrafts(form, resourceIds = [], resources = []) { const resourcesById = new Map(resources.map((resource) => [String(resource.resourceId), resource])); const existingDraftsByResourceId = new Map( - (form.items || []) - .filter((item) => item?.resourceId) - .map((item) => [String(item.resourceId), item]), + (form.items || []).filter((item) => item?.resourceId).map((item) => [String(item.resourceId), item]), ); return resourceIds @@ -681,7 +688,10 @@ export function useResourceListPage() { deletedResourceIds.push(targetResources[0].resourceId); } hideResourceRows(deletedResourceIds); - showToast(targetResources.length > 1 ? `已删除 ${deletedResourceIds.length} 个资源` : "资源已删除", "success"); + showToast( + targetResources.length > 1 ? `已删除 ${deletedResourceIds.length} 个资源` : "资源已删除", + "success", + ); return deletedResourceIds; } catch (err) { showToast(err.message || "删除资源失败", "error"); @@ -1667,7 +1677,10 @@ export function useGiftListPage() { }; }); shouldReload = true; - showToast(`已创建 ${createdDrafts.length} 个礼物,剩余礼物创建失败:${err.message || "操作失败"}`, "error"); + showToast( + `已创建 ${createdDrafts.length} 个礼物,剩余礼物创建失败:${err.message || "操作失败"}`, + "error", + ); } else { showToast(err.message || "操作失败", "error"); } @@ -2019,10 +2032,10 @@ export function useResourceGrantListPage() { } if (abilities.canGrantVip) { optionRequests.push( - Promise.all([ - getVipProgram(), - getVipConfig(), - ]).then(([program, config]) => ({ data: { config, program }, type: "vip" })), + Promise.all([getVipProgram(), getVipConfig()]).then(([program, config]) => ({ + data: { config, program }, + type: "vip", + })), ); } else { setVipProgram({ programType: "legacy_timed", trialCardEnabled: false }); @@ -2138,12 +2151,7 @@ export function useResourceGrantListPage() { }; const revokeGrant = async (grant) => { - if ( - !abilities.canRevokeGrant || - !grant?.grantId || - grant.status !== "succeeded" || - !isRevocableGrant(grant) - ) { + if (!abilities.canRevokeGrant || !grant?.grantId || grant.status !== "succeeded" || !isRevocableGrant(grant)) { return; } const accepted = await confirm({ @@ -2231,6 +2239,13 @@ function buildResourcePayload(form) { const isLevelAvatarFrame = resourceType === "avatar_frame" && form.avatarFrameKind === "level"; const isLevelBadge = resourceType === "badge" && form.badgeKind === "level"; 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 { amount: isCoin ? Number(form.walletAssetAmount) : 0, animationUrl, @@ -2241,7 +2256,7 @@ function buildResourcePayload(form) { coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0, levelTrack: isLevelBadge || isLevelAvatarFrame ? form.levelTrack : undefined, managerGrantEnabled: Boolean(form.managerGrantEnabled), - metadataJson: resourceMetadataJSON(resourceType, form.metadataJson), + metadataJson: resourceMetadataJSON(resourceType, metadataJson), name: form.name.trim(), previewUrl: form.previewUrl.trim(), priceType: form.priceType, @@ -2604,12 +2619,18 @@ function isVipGrantRecord(grant) { } const source = String(grant?.grantSource || "").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) { 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" ); } diff --git a/src/features/resources/pages/ResourceListPage.jsx b/src/features/resources/pages/ResourceListPage.jsx index f888691..4b1628e 100644 --- a/src/features/resources/pages/ResourceListPage.jsx +++ b/src/features/resources/pages/ResourceListPage.jsx @@ -82,6 +82,12 @@ import { import { metadataWithProfileCardLayout, removeProfileCardLayoutMetadata, + resourceRequiresExplicitVisualFormat, + roomBorderFormatFromMetadata, + roomBorderFormatMetadataJSON, + roomBorderFormatOptions, + roomNameStyleFormFromMetadata, + roomNameStyleInputError, } from "@/features/resources/resourceMetadata.js"; import { uploadFilesBatch } from "@/shared/api/upload"; 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 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 = [ { @@ -216,7 +240,9 @@ export function ResourceListPage() { checked={selectedIdSet.has(resource.resourceId)} disabled={!canSelectResources || batchMp4Running || deletingResources} slotProps={{ - input: { "aria-label": `选择 ${resource.name || resource.resourceCode || resource.resourceId}` }, + input: { + "aria-label": `选择 ${resource.name || resource.resourceCode || resource.resourceId}`, + }, }} size="small" onChange={(event) => toggleResourceSelection(resource, event.target.checked)} @@ -392,7 +418,9 @@ export function ResourceListPage() { } updates.push({ layout: confirmedLayout, metadataJson, resource, resourceId: resource.resourceId }); } 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) { @@ -898,7 +926,9 @@ function Mp4BatchResultDialog({ loading, onClose, onSubmit, open, result }) { {resource?.resourceCode || "-"} - {resourceTypeLabels[resource?.resourceType] || resource?.resourceType || "-"} + + {resourceTypeLabels[resource?.resourceType] || resource?.resourceType || "-"} + {mp4LayoutLabel(layout)} {layout?.video_w || "-"}x{layout?.video_h || "-"} @@ -1013,7 +1043,11 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, detectedProfileCardLayout = await detectProfileCardLayout(file); } // 只返回当次素材的检测增量,上传成功后再合并到最新表单,避免异步旧快照覆盖用户新修改。 - return { mp4Layout: detectedMp4Layout, profileCardLayout: detectedProfileCardLayout }; + return { + mp4Layout: detectedMp4Layout, + profileCardLayout: detectedProfileCardLayout, + roomBorderFormat: visualResourceFormatFromFile(file), + }; }; const handleAnimationChange = (animationUrl, selectionResult) => { setForm((previous) => { @@ -1021,6 +1055,9 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, if (!animationUrl) { // 清除素材时,两类从该素材派生的布局元数据都必须同步清理。 metadataJson = removeProfileCardLayoutMetadata(removeMp4AlphaLayoutMetadata(metadataJson)); + if (resourceRequiresExplicitVisualFormat(previous.resourceType)) { + metadataJson = roomBorderFormatMetadataJSON(metadataJson, ""); + } } else if (selectionResult) { 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 ? metadataWithProfileCardLayout(metadataJson, selectionResult.profileCardLayout) : 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) => { @@ -1074,24 +1127,42 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, value={form.resourceType} onChange={(event) => { const resourceType = event.target.value; - setForm((previous) => ({ - ...previous, - avatarFrameKind: - resourceType === "avatar_frame" ? previous.avatarFrameKind || "normal" : "normal", - badgeForm: resourceType === "badge" ? previous.badgeForm || "tile" : previous.badgeForm, - badgeKind: resourceType === "badge" ? previous.badgeKind || "normal" : "normal", - levelTrack: - resourceType === "badge" || - (resourceType === "avatar_frame" && previous.avatarFrameKind === "level") - ? previous.levelTrack || "" - : "", - metadataJson: - resourceType === "profile_card" - ? previous.metadataJson - : removeProfileCardLayoutMetadata(previous.metadataJson), - resourceType, - walletAssetAmount: "", - })); + setForm((previous) => { + const roomNameStyleFields = + resourceType === "room_name_style" + ? roomNameStyleFormFromMetadata(previous.metadataJson) + : { + roomNameAngleDegrees: "", + roomNameStops: "", + roomNameTextColors: "", + }; + const roomBorderFormat = resourceRequiresExplicitVisualFormat(resourceType) + ? roomBorderFormatFromMetadata(previous.metadataJson) + : ""; + return { + ...previous, + avatarFrameKind: + resourceType === "avatar_frame" + ? previous.avatarFrameKind || "normal" + : "normal", + badgeForm: + resourceType === "badge" ? previous.badgeForm || "tile" : previous.badgeForm, + badgeKind: resourceType === "badge" ? previous.badgeKind || "normal" : "normal", + levelTrack: + resourceType === "badge" || + (resourceType === "avatar_frame" && previous.avatarFrameKind === "level") + ? previous.levelTrack || "" + : "", + metadataJson: + resourceType === "profile_card" + ? previous.metadataJson + : removeProfileCardLayoutMetadata(previous.metadataJson), + ...roomNameStyleFields, + resourceType, + roomBorderFormat, + walletAssetAmount: "", + }; + }); }} > {resourceTypeOptions.map(([value, label]) => ( @@ -1239,12 +1310,41 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, + {form.resourceType === "room_name_style" ? ( + + ) : null} + {resourceRequiresExplicitVisualFormat(form.resourceType) ? ( + + { + const roomBorderFormat = event.target.value; + setForm((previous) => ({ + ...previous, + metadataJson: roomBorderFormatMetadataJSON(previous.metadataJson, roomBorderFormat), + roomBorderFormat, + })); + }} + > + {roomBorderFormatOptions.map((format) => ( + + {roomBorderFormatLabels[format] || format} + + ))} + + + ) : null} setForm((previous) => ({ ...previous, previewUrl }))} @@ -1254,7 +1354,7 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit, key={open ? "resource-animation-open" : "resource-animation-closed"} disabled={disabled || uploadDisabled} kind="file" - label="动效素材" + label={form.resourceType === "room_name_style" ? "样式动效(可选)" : "动效素材"} mp4Layout={mp4Layout} showSourceActions 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 ( + + setForm({ ...form, roomNameTextColors: event.target.value })} + /> + setForm({ ...form, roomNameStops: event.target.value })} + /> + setForm({ ...form, roomNameAngleDegrees: event.target.value })} + /> + + ); +} + function Mp4AlphaLayoutEditor({ disabled, metadataJson, onChange }) { const layout = mp4AlphaLayoutFromMetadata(metadataJson); if (!layout) { diff --git a/src/features/resources/resourceMetadata.js b/src/features/resources/resourceMetadata.js index ab9fa33..0c71fc4 100644 --- a/src/features/resources/resourceMetadata.js +++ b/src/features/resources/resourceMetadata.js @@ -2,6 +2,12 @@ import { profileCardLayoutFromMetadata } from "@/features/resources/profileCardL import { mp4AlphaLayoutFromMetadata, mp4AlphaLayoutKey } from "@/features/resources/mp4AlphaLayout.js"; 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) { const metadata = {}; @@ -14,9 +20,130 @@ export function resourceMetadataJSON(resourceType, metadataJson) { metadata.animation_format = "mp4"; 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; } +// 麦位动效和房间边框的格式跟资源记录一起保存,客户端不需要从 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) { const metadata = parseMetadataObject(metadataJson); delete metadata.profile_card_layout; @@ -55,3 +182,32 @@ function stringifyMetadata(metadata) { }); 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 ""; +} diff --git a/src/features/resources/schema.js b/src/features/resources/schema.js index b2e537e..8218028 100644 --- a/src/features/resources/schema.js +++ b/src/features/resources/schema.js @@ -1,6 +1,11 @@ import { z } from "zod"; import { cpRelationTypeOptions, resourceIdentityAutoGrantTypes } from "@/features/resources/constants.js"; import { hasUnconfirmedMp4Layout } from "@/features/resources/mp4AlphaLayout.js"; +import { + resourceRequiresExplicitVisualFormat, + roomBorderFormatOptions, + roomNameStyleInputError, +} from "@/features/resources/resourceMetadata.js"; const resourceTypes = [ "avatar_frame", @@ -13,6 +18,8 @@ const resourceTypes = [ "gift", "mic_seat_icon", "mic_seat_animation", + "room_border", + "room_name_style", "emoji_pack", "vip_trial_card", ]; @@ -36,7 +43,7 @@ const optionalWalletAssetTypeSchema = z export const resourceCreateFormSchema = z .object({ - animationUrl: z.string().trim().min(1, "请上传动效素材").max(512, "动效素材不能超过 512 个字符"), + animationUrl: z.string().trim().max(512, "动效素材不能超过 512 个字符"), avatarFrameKind: z.enum(avatarFrameKinds).optional(), badgeForm: z.enum(badgeForms).optional(), badgeKind: z.enum(badgeKinds).optional(), @@ -46,13 +53,37 @@ export const resourceCreateFormSchema = z metadataJson: z.string().trim().max(4096, "资源元数据不能超过 4096 个字符").optional(), name: z.string().trim().min(1, "请输入资源名称").max(128, "资源名称不能超过 128 个字符"), 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, "请选择价格类型"), resourceCode: z.string().trim().min(1, "请输入资源编码").max(96, "资源编码不能超过 96 个字符"), 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(), }) .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 === "badge" && !value.badgeForm) { context.addIssue({ @@ -527,7 +558,10 @@ export const resourceGrantFormSchema = z 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({ code: "custom", message: "请选择VIP等级", diff --git a/src/features/vip-config/constants.js b/src/features/vip-config/constants.js index 37c6312..6d4ccfc 100644 --- a/src/features/vip-config/constants.js +++ b/src/features/vip-config/constants.js @@ -37,6 +37,14 @@ export const vipGrantModeLabels = { 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 只是历史字段,后台不据此补齐、开启或继承权益。 export const p1BenefitCatalog = [ benefit("vip_badge_identity", "VIP标识", 1, "function", "user", false), @@ -56,7 +64,7 @@ export const p1BenefitCatalog = [ benefit("mic_skin", "麦克风皮肤", 4, "decoration", "room", true), benefit("room_border", "房间边框", 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_id", "VIP彩色ID", 5, "function", "user", false), benefit("gift_tray_skin", "送礼托盘皮肤", 5, "function", "room", false), diff --git a/src/features/vip-config/hooks/useVipConfigPage.js b/src/features/vip-config/hooks/useVipConfigPage.js index 883869c..d76ccb8 100644 --- a/src/features/vip-config/hooks/useVipConfigPage.js +++ b/src/features/vip-config/hooks/useVipConfigPage.js @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx"; 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 { useVipConfigAbilities } from "@/features/vip-config/permissions.js"; import { vipConfigFormSchema, vipProgramFormSchema } from "@/features/vip-config/schema.js"; @@ -456,10 +456,11 @@ function completeP1BenefitCatalog(remoteCatalog) { function benefitToForm(benefit, level, index, catalogPlaceholder) { const benefitCode = benefit.benefitCode || ""; + const expectedResourceType = vipBenefitResourceTypes[benefitCode] || ""; return { 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), status: catalogPlaceholder ? "disabled" : benefit.status === "active" ? "active" : "disabled", trialEnabled: benefitCode === "daily_coin_rebate" ? false : Boolean(benefit.trialEnabled), @@ -537,6 +538,7 @@ function benefitPayload(benefit, level) { const rawMetadataJson = String(benefit.metadataJson || "{}").trim() || "{}"; const sortOrder = Number(benefit.sortOrder || 0); const resourceId = Number(benefit.resourceId || 0); + const expectedResourceType = vipBenefitResourceTypes[benefit.benefitCode] || ""; if (!Number.isInteger(sortOrder) || sortOrder < 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} 资源不正确`); } 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({ benefitCode: benefit.benefitCode, label: `VIP${level} 的 ${benefit.name || benefit.benefitCode}`, @@ -561,7 +571,7 @@ function benefitPayload(benefit, level) { return { benefitCode: String(benefit.benefitCode || "").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), status, trialEnabled: benefit.benefitCode === "daily_coin_rebate" ? false : Boolean(benefit.trialEnabled), diff --git a/src/features/vip-config/pages/VipConfigPage.jsx b/src/features/vip-config/pages/VipConfigPage.jsx index 2543054..e6b309f 100644 --- a/src/features/vip-config/pages/VipConfigPage.jsx +++ b/src/features/vip-config/pages/VipConfigPage.jsx @@ -17,6 +17,7 @@ import TextField from "@mui/material/TextField"; import { useMemo, useState } from "react"; import { ResourceSelectField } from "@/features/resources/components/ResourceSelectDrawer.jsx"; import { + vipBenefitResourceTypes, vipGrantModeLabels, vipProgramDefaults, vipProgramLabels, @@ -603,7 +604,11 @@ function BenefitCard({ benefit, disabled, onChange, onSelect, selected }) { function BenefitInspector({ benefit, disabled, level, page }) { const rebateBenefit = benefit.benefitCode === "daily_coin_rebate"; 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 onChange = (patch) => page.updateBenefit(level, benefit.benefitCode, patch); @@ -675,9 +680,9 @@ function BenefitInspector({ benefit, disabled, level, page }) { drawerTitle={`选择 ${benefit.name} 资源`} label="绑定资源" loading={page.resourcesLoading} - placeholder="可不绑定资源" - required={false} - resources={page.resources} + placeholder={benefit.status === "active" ? "请选择权益资源" : "停用时可暂不绑定"} + required={benefit.status === "active"} + resources={selectableResources} value={benefit.resourceId} onChange={(resourceId, resource) => onChange({ resourceId, resourceType: resource?.resourceType || "" }) @@ -713,7 +718,7 @@ function BenefitInspector({ benefit, disabled, level, page }) { 高级设置
= { permission: "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: { method: "GET", operationId: API_OPERATIONS.listLuckyGiftDraws, @@ -2600,6 +2609,13 @@ export const API_ENDPOINTS: Record = { permission: "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: { method: "POST", operationId: API_OPERATIONS.savePolicyTemplate, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 4b5d81e..d72578d 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -276,6 +276,38 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -8478,6 +8510,30 @@ export interface operations { 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: { parameters: { query?: never;