From 10928534e72c854e907767722fe36954ba0bc5eb Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 16 Jul 2026 15:38:03 +0800 Subject: [PATCH] feat(ops): configure user 24h RTP downweight --- ops-center/src/api.js | 36 +++++++ .../components/LuckyGiftConfigSections.jsx | 14 +++ .../src/components/LuckyGiftFieldHelp.jsx | 14 +++ ops-center/src/configForm.js | 101 +++++++++++++++++- 4 files changed, 160 insertions(+), 5 deletions(-) diff --git a/ops-center/src/api.js b/ops-center/src/api.js index e206959..262c320 100644 --- a/ops-center/src/api.js +++ b/ops-center/src/api.js @@ -3,6 +3,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; export async function fetchOpsDashboard(query = {}) { // Ops Center 是跨 App 的运营面板:先拉应用主数据,再按 app_code 扇出拉取奖池配置、余额和抽奖汇总。 @@ -278,6 +279,15 @@ function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") { .trim() .toLowerCase(); const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion); + const user24hRTPThreshold = + config.user_24h_rtp_threshold_percent ?? + config.user24HRTPThresholdPercent ?? + config.user24hRTPThresholdPercent ?? + config.user24hRtpThresholdPercent; + const user24hOrdinaryWinFactor = + config.user_24h_ordinary_win_factor_percent ?? + config.user24HOrdinaryWinFactorPercent ?? + config.user24hOrdinaryWinFactorPercent; return { ...config, app_code: appCode, @@ -287,6 +297,13 @@ function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") { rule_version: ruleVersion, // 升级前规则没有 strategy_version,lucky-gift owner 明确按 fixed_v2 解释;列表和编辑表单必须沿用该兼容语义。 strategy_version: normalizeStrategyVersion(config.strategy_version ?? config.strategyVersion), + // 仅在 owner 真正返回新字段时补 canonical key;缺字段代表历史禁用快照,不能在读取阶段伪装成已启用。 + ...(user24hRTPThreshold !== undefined + ? { user_24h_rtp_threshold_percent: numberValue(user24hRTPThreshold) } + : {}), + ...(user24hOrdinaryWinFactor !== undefined + ? { user_24h_ordinary_win_factor_percent: numberValue(user24hOrdinaryWinFactor) } + : {}), }; } @@ -327,6 +344,16 @@ function luckyGiftConfigPayload(config = {}) { const strategyVersion = String(config.strategy_version || config.strategyVersion || "fixed_v2") .trim() .toLowerCase(); + const user24hRTPThreshold = + config.user_24h_rtp_threshold_percent ?? + config.user24HRTPThresholdPercent ?? + config.user24hRTPThresholdPercent ?? + config.user24hRtpThresholdPercent; + const user24hOrdinaryWinFactor = + config.user_24h_ordinary_win_factor_percent ?? + config.user24HOrdinaryWinFactorPercent ?? + config.user24hOrdinaryWinFactorPercent; + const hasUser24hRTPConfig = user24hRTPThreshold !== undefined || user24hOrdinaryWinFactor !== undefined; // 规则版本是不可变快照,PUT 必须完整回传 owner GET 返回的策略字段。只提交旧版 RTP/奖档字段会让 // 空 strategy_version 被服务端按 fixed_v2 发布,或让 dynamic_v3 因资金、水位、大奖字段归零而拒绝发布。 @@ -393,6 +420,15 @@ function luckyGiftConfigPayload(config = {}) { settlement_window_wager: numberValue(config.settlement_window_wager ?? config.settlementWindowWager), stages: normalizeStages(config.stages), strategy_version: strategyVersion, + // 旧调用方不带这组字段时保持旧 payload 形状;任一字段出现就成组发送,避免只发布一半策略。 + ...(hasUser24hRTPConfig + ? { + user_24h_ordinary_win_factor_percent: numberValue( + user24hOrdinaryWinFactor ?? USER_24H_RTP_DISABLED_FACTOR_PERCENT, + ), + user_24h_rtp_threshold_percent: numberValue(user24hRTPThreshold), + } + : {}), user_daily_payout_cap: numberValue(config.user_daily_payout_cap ?? config.userDailyPayoutCap), user_hourly_payout_cap: numberValue(config.user_hourly_payout_cap ?? config.userHourlyPayoutCap), target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent), diff --git a/ops-center/src/components/LuckyGiftConfigSections.jsx b/ops-center/src/components/LuckyGiftConfigSections.jsx index f3fd41c..41eeb82 100644 --- a/ops-center/src/components/LuckyGiftConfigSections.jsx +++ b/ops-center/src/components/LuckyGiftConfigSections.jsx @@ -345,6 +345,20 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } step="0.01" onChange={onChange} /> + + diff --git a/ops-center/src/components/LuckyGiftFieldHelp.jsx b/ops-center/src/components/LuckyGiftFieldHelp.jsx index 07b2aec..c3c642b 100644 --- a/ops-center/src/components/LuckyGiftFieldHelp.jsx +++ b/ops-center/src/components/LuckyGiftFieldHelp.jsx @@ -115,6 +115,20 @@ const FIELD_HELP = { adjust: "调高会增强充值后的中奖体验,也会增加奖池消耗;调低则更温和。", constraint: "必须大于 100%,会与高低余额调整相乘;它只改变普通奖档,不会直接提高大奖触发机会。", }, + user_24h_rtp_threshold_percent: { + summary: + "每次普通开奖前,系统会把同一用户在当前应用和奖池近 24 小时的实际返奖除以实际消费。结果严格高于这里填写的比例时,才降低普通中奖机会。", + adjust: "填 150 表示近 24 小时返奖超过消费的 150% 后开始调整。调低会让更多用户更早进入调整,调高则只处理返还明显偏高的用户。", + constraint: + "结果刚好等于或低于触发线时立即恢复正常;近 24 小时没有消费时不调整。内部用户和外部接入用户都按各自稳定账号独立统计。", + }, + user_24h_ordinary_win_factor_percent: { + summary: + "用户超过近 24 小时触发线后,普通非 0 倍奖项还保留多少中奖机会。填 70 表示保留原来的 70%,也就是降低 30%,不是降低 70%。", + adjust: "调低会把更多普通中奖机会转成未中奖,更快压低该用户的短期返还;填 100 表示不减少中奖机会。", + constraint: + "必须大于 0% 且不超过 100%。减少的部分全部进入 0 倍档;两种独立大奖不受影响,连续未中奖保护仍优先执行,奖池余额和返奖上限也仍会拦截无法支付的结果。", + }, jackpot_multipliers: { summary: "列出 48 小时亏损补偿和累计消费达标两种大奖策略可能采用的倍数,例如 200、500、1000。它不并入普通奖档概率:大奖未成功才进入普通开奖。", diff --git a/ops-center/src/configForm.js b/ops-center/src/configForm.js index b8fedd3..84d24c2 100644 --- a/ops-center/src/configForm.js +++ b/ops-center/src/configForm.js @@ -12,6 +12,9 @@ import { DEFAULT_POOL_ID } from "./api.js"; const DYNAMIC_STRATEGY = "dynamic_v3"; const PPM_SCALE = 1_000_000; const LEGACY_JACKPOT_RTP_MODE = "__legacy_jackpot_rtp_fields"; +const LEGACY_USER_24H_RTP_MODE = "__legacy_user_24h_rtp_fields"; +const USER_24H_RTP_DEFAULT_THRESHOLD_PERCENT = 150; +const USER_24H_RTP_DISABLED_FACTOR_PERCENT = 100; const legacyJackpotRTPAliases = { jackpot_user_48h_rtp_max_percent: [ "jackpot_user_72h_rtp_max_percent", @@ -24,6 +27,10 @@ const legacyJackpotRTPAliases = { "jackpotUserDayRtpMaxPercent", ], }; +const user24hRTPAliases = { + user_24h_ordinary_win_factor_percent: ["user24HOrdinaryWinFactorPercent"], + user_24h_rtp_threshold_percent: ["user24HRTPThresholdPercent", "user24hRTPThresholdPercent"], +}; // 只有跨 App 通用的算法参数才有默认值;六维金额上限必须由运营按 App 奖池的金币口径填写, // 因而默认保持 0 且规则停用,避免把某个 App 的预算误发到其他 App。 @@ -52,6 +59,8 @@ const dynamicDefaults = { recharge_boost_window_ms: 300_000, room_hourly_payout_cap: 0, target_rtp_percent: 98, + user_24h_ordinary_win_factor_percent: 70, + user_24h_rtp_threshold_percent: USER_24H_RTP_DEFAULT_THRESHOLD_PERCENT, user_daily_payout_cap: 0, user_hourly_payout_cap: 0, }; @@ -84,6 +93,8 @@ const numericFields = [ "room_hourly_payout_cap", "settlement_window_wager", "target_rtp_percent", + "user_24h_ordinary_win_factor_percent", + "user_24h_rtp_threshold_percent", "user_daily_payout_cap", "user_hourly_payout_cap", ]; @@ -122,12 +133,16 @@ export function configToForm(config = {}) { stages: normalizeFormStages(config.stages, targetRTPPercent, strategyVersion), strategy_version: strategyVersion, }; + const newDynamicDraft = strategyVersion === DYNAMIC_STRATEGY && isNewDynamicDraft(config); // 只在明确收到旧 day/72h 且缺少对应 canonical 字段时进入兼容模式。页面始终编辑 canonical 值, // 这个标记仅让纯函数保留旧对象形状;HTTP 层仍会归一成 round/48h 后再发送。 form[LEGACY_JACKPOT_RTP_MODE] = Object.entries(legacyJackpotRTPAliases).some( ([canonical, aliases]) => !hasConfigValue(config, [canonical, snakeToCamel(canonical)]) && hasConfigValue(config, aliases), ); + // 已发布的旧规则没有近 24 小时降权字段,或者以 0/100 明确表示关闭。编辑时把禁用态展示为 + // 更容易理解的 150/100,但保留来源形状:运营不改这两个值就不会意外给历史版本启用 70% 降权。 + form[LEGACY_USER_24H_RTP_MODE] = legacyUser24hRTPMode(config, strategyVersion); for (const field of numericFields) { if (field === "target_rtp_percent") { form[field] = targetRTPPercent; @@ -138,10 +153,27 @@ export function configToForm(config = {}) { form[field] = "0"; continue; } - const legacyAliases = legacyJackpotRTPAliases[field] || []; - form[field] = hasConfigValue(config, [field, snakeToCamel(field)]) - ? formConfigNumber(config, field, snakeToCamel(field), defaultNumber(field, strategyVersion)) - : formNumber(firstConfigValue(config, legacyAliases) ?? defaultNumber(field, strategyVersion)); + if (newDynamicDraft && field === "user_24h_rtp_threshold_percent") { + // 兼容 admin-server 先于本字段升级时返回的显式 0/100 草稿;未发布版本始终采用新建默认。 + form[field] = String(USER_24H_RTP_DEFAULT_THRESHOLD_PERCENT); + continue; + } + if (newDynamicDraft && field === "user_24h_ordinary_win_factor_percent") { + form[field] = String(dynamicDefaults.user_24h_ordinary_win_factor_percent); + continue; + } + if (form[LEGACY_USER_24H_RTP_MODE] && field === "user_24h_rtp_threshold_percent") { + form[field] = String(USER_24H_RTP_DEFAULT_THRESHOLD_PERCENT); + continue; + } + if (form[LEGACY_USER_24H_RTP_MODE] && field === "user_24h_ordinary_win_factor_percent") { + form[field] = String(USER_24H_RTP_DISABLED_FACTOR_PERCENT); + continue; + } + const aliases = [...(legacyJackpotRTPAliases[field] || []), ...(user24hRTPAliases[field] || [])]; + form[field] = hasConfigValue(config, [field, snakeToCamel(field), ...aliases]) + ? formNumber(firstConfigValue(config, [field, snakeToCamel(field), ...aliases])) + : formNumber(defaultNumber(field, strategyVersion)); } if (form[LEGACY_JACKPOT_RTP_MODE]) { form.jackpot_user_72h_rtp_max_percent = form.jackpot_user_48h_rtp_max_percent; @@ -182,6 +214,21 @@ export function formToConfig(config, form) { ? 0 : numberFromForm(form[field]); } + if ( + form.strategy_version === DYNAMIC_STRATEGY && + form[LEGACY_USER_24H_RTP_MODE] && + numberFromForm(form.user_24h_rtp_threshold_percent) === USER_24H_RTP_DEFAULT_THRESHOLD_PERCENT && + numberFromForm(form.user_24h_ordinary_win_factor_percent) === USER_24H_RTP_DISABLED_FACTOR_PERCENT + ) { + // 缺字段的旧快照继续保持原始 shape,显式 0/100 的旧快照则继续写回禁用值;两者再次发布都无效果。 + if (form[LEGACY_USER_24H_RTP_MODE] === "missing") { + delete output.user_24h_rtp_threshold_percent; + delete output.user_24h_ordinary_win_factor_percent; + } else { + output.user_24h_rtp_threshold_percent = 0; + output.user_24h_ordinary_win_factor_percent = USER_24H_RTP_DISABLED_FACTOR_PERCENT; + } + } if (form[LEGACY_JACKPOT_RTP_MODE]) { // 旧输入的纯函数 round-trip 保持原 shape,避免调用方只做本地比较时出现伪 diff;真正保存时 // luckyGiftConfigPayload 会把这两个别名归一成 canonical HTTP 字段,绝不会双发两套字段。 @@ -330,6 +377,9 @@ export function validateLuckyGiftForm(form) { if (number("high_water_nonzero_factor_percent") <= 100) errors.push("高水位非零因子必须大于 100%"); if (number("recharge_boost_window_ms") <= 0 || number("recharge_boost_factor_percent") <= 100) errors.push("充值加权窗口必须大于 0 且因子必须大于 100%"); + if (number("user_24h_rtp_threshold_percent") <= 0) errors.push("用户近 24 小时 RTP 触发线必须大于 0%"); + if (number("user_24h_ordinary_win_factor_percent") <= 0 || number("user_24h_ordinary_win_factor_percent") > 100) + errors.push("高 RTP 时普通中奖机会保留比例必须大于 0% 且不超过 100%"); const jackpotTokens = String(form.jackpot_multipliers || "") .split(/[,,\s]+/) .filter(Boolean); @@ -337,7 +387,9 @@ export function validateLuckyGiftForm(form) { if ( jackpotTokens.length !== jackpotMultiplierValues.length || !jackpotMultiplierValues.length || - jackpotMultiplierValues.some((value, index) => value <= 1 || (index > 0 && value <= jackpotMultiplierValues[index - 1])) + jackpotMultiplierValues.some( + (value, index) => value <= 1 || (index > 0 && value <= jackpotMultiplierValues[index - 1]), + ) ) { errors.push("大奖倍率必须大于 1x 并严格递增"); } @@ -469,6 +521,45 @@ function defaultNumber(field, strategyVersion) { return strategyVersion === DYNAMIC_STRATEGY ? (dynamicDefaults[field] ?? 0) : 0; } +function legacyUser24hRTPMode(config, strategyVersion) { + if (strategyVersion !== DYNAMIC_STRATEGY) return ""; + // rule_version=0 / is_default 是尚未发布的草稿,即使旧后端显式返回 0/100 也必须使用新建 + // 默认;只有已发布版本的缺字段或 0/100 才属于历史禁用兼容。 + if (isNewDynamicDraft(config)) return ""; + const threshold = firstConfigValue(config, [ + "user_24h_rtp_threshold_percent", + snakeToCamel("user_24h_rtp_threshold_percent"), + ...user24hRTPAliases.user_24h_rtp_threshold_percent, + ]); + const factor = firstConfigValue(config, [ + "user_24h_ordinary_win_factor_percent", + snakeToCamel("user_24h_ordinary_win_factor_percent"), + ...user24hRTPAliases.user_24h_ordinary_win_factor_percent, + ]); + if (threshold === undefined && factor === undefined) return "missing"; + if (Number(threshold || 0) === 0 && [undefined, 0, 100].includes(factor === undefined ? undefined : Number(factor))) + return "disabled"; + return ""; +} + +function isNewDynamicDraft(config) { + if (config.is_default === true || config.isDefault === true) return true; + if (hasConfigValue(config, ["rule_version", "ruleVersion"])) { + return Number(firstConfigValue(config, ["rule_version", "ruleVersion"])) <= 0; + } + // 新建入口只传规则身份;若已经带有资金、概率或水位字段,就按旧发布快照处理,不能套 70% 新默认值。 + const identityKeys = new Set([ + "app_code", + "appCode", + "enabled", + "pool_id", + "poolId", + "strategy_version", + "strategyVersion", + ]); + return Object.keys(config).every((key) => identityKeys.has(key)); +} + function multiplierListToForm(value, strategyVersion) { if (Array.isArray(value) && value.length) return value.map(formatDecimal).join(", "); if (typeof value === "string" && value.trim()) return value;