diff --git a/ops-center/src/api.js b/ops-center/src/api.js index 1c3be33..e206959 100644 --- a/ops-center/src/api.js +++ b/ops-center/src/api.js @@ -353,11 +353,21 @@ function luckyGiftConfigPayload(config = {}) { jackpot_spend_threshold_coins: numberValue( config.jackpot_spend_threshold_coins ?? config.jackpotSpendThresholdCoins, ), - jackpot_user_72h_rtp_max_percent: numberValue( - config.jackpot_user_72h_rtp_max_percent ?? config.jackpotUser72hRTPMaxPercent, + jackpot_user_48h_rtp_max_percent: numberValue( + config.jackpot_user_48h_rtp_max_percent ?? + config.jackpotUser48hRTPMaxPercent ?? + config.jackpotUser48hRtpMaxPercent ?? + config.jackpot_user_72h_rtp_max_percent ?? + config.jackpotUser72hRTPMaxPercent ?? + config.jackpotUser72hRtpMaxPercent, ), - jackpot_user_day_rtp_max_percent: numberValue( - config.jackpot_user_day_rtp_max_percent ?? config.jackpotUserDayRTPMaxPercent, + jackpot_user_round_rtp_max_percent: numberValue( + config.jackpot_user_round_rtp_max_percent ?? + config.jackpotUserRoundRTPMaxPercent ?? + config.jackpotUserRoundRtpMaxPercent ?? + config.jackpot_user_day_rtp_max_percent ?? + config.jackpotUserDayRTPMaxPercent ?? + config.jackpotUserDayRtpMaxPercent, ), loss_streak_guarantee: numberValue(config.loss_streak_guarantee ?? config.lossStreakGuarantee), low_water_nonzero_factor_percent: numberValue( diff --git a/ops-center/src/components/LuckyGiftConfigSections.jsx b/ops-center/src/components/LuckyGiftConfigSections.jsx index 966d003..2d8e00b 100644 --- a/ops-center/src/components/LuckyGiftConfigSections.jsx +++ b/ops-center/src/components/LuckyGiftConfigSections.jsx @@ -377,15 +377,15 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } /> @@ -396,11 +396,13 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } onChange={onChange} /> @@ -473,28 +475,30 @@ function JackpotFlowSummary() { return (
- 1. 消费达标优先 - 当天累计消费每跨过一次门槛,就保存一次大奖资格;资格从下一抽开始使用。 + 1. 大盘先通过 + 本轮大盘 RTP 不高于全局触发线,才继续检查用户;等于触发线也可通过。
- 2. 返还偏低补偿 - 没有待用资格时,整体、用户当天和近 72 小时平均返还都不高于触发线,才尝试大奖。 + 2. 用户两项都通过 + 用户本轮 RTP 和近 48 小时 RTP 都必须严格低于各自触发线,少一项都不能出大奖。
- 3. 只从付得起的档位选 - 先剔除奖池或限额不足的倍率,再在剩余倍率中等机会选择;消费资格付不起时会保留。 + 3. 下次送礼尝试大奖 + + 用户下次送礼时,先剔除奖池或限额付不起的倍率,再随机选择;没有可支付倍率就不出大奖,本轮资格也不会顺延或重复使用。 +
); } -function NumberField({ disabled = false, form, helperText, label, name, onChange, step = "1" }) { +function NumberField({ disabled = false, form, helperText, label, name, onChange, required = true, step = "1" }) { return ( } - required + required={required} size="small" slotProps={{ htmlInput: { inputMode: "decimal", min: 0, step } }} type="number" diff --git a/ops-center/src/components/LuckyGiftFieldHelp.jsx b/ops-center/src/components/LuckyGiftFieldHelp.jsx index 09fc0e1..84bbddd 100644 --- a/ops-center/src/components/LuckyGiftFieldHelp.jsx +++ b/ops-center/src/components/LuckyGiftFieldHelp.jsx @@ -23,7 +23,7 @@ const FIELD_HELP = { enabled: { summary: "决定新版本发布后是否立即用于真实送礼。关闭时规则仍会保存,但不会触发幸运开奖。", adjust: "首次配置或大幅调整时可先保持停用,核对资金比例和上限后再启用。", - constraint: "启用 dynamic_v3 前必须填完大奖消费门槛和所有返奖上限。", + constraint: "启用 dynamic_v3 前必须填完三项大奖 RTP 触发线和所有返奖上限。", }, target_rtp_percent: { summary: "表示基础奖档的配置目标。例如填 98,代表各阶段的“倍率 × 基础概率”预计返还约为 98%。", @@ -54,9 +54,10 @@ const FIELD_HELP = { "按整数金币向下取整,且必须与钱包实际发给收礼对象的金币完全一致,否则 V3 会拒绝开奖;V2 不使用该比例开奖。", }, settlement_window_wager: { - summary: "V3 按实际消费金币累计一轮返还观察窗口;某一抽跨过门槛时仍归旧窗口,下一抽才进入新窗口。", - adjust: "调大后更少重开窗口;调小后会更频繁清空全局窗口样本。V2 会先按礼物参考金额换算成窗口抽数。", - constraint: "必须大于 0。新窗口还没有消费样本时,不会触发返还偏低补偿大奖。", + summary: + "V3 按实际消费金币累计一轮大盘 RTP;某一抽跨过门槛时仍归旧轮,下一抽才进入新轮。大盘结算并重置时,也会按完全相同的起止时间结算每个用户的本轮 RTP。", + adjust: "调大后大盘和用户本轮 RTP 都会更久才结算;调小后两者都会更频繁结算。V2 会先按礼物参考金额换算成窗口抽数。", + constraint: "必须大于 0。新一轮还没有消费样本时,不会触发返还偏低大奖。", }, gift_price_reference: { summary: "V2 用它把累计消费换算成“折算次数”,同时计算用户阶段和每轮窗口的抽数;它不是礼物真实售价。", @@ -117,34 +118,42 @@ const FIELD_HELP = { jackpot_multipliers: { summary: "列出大奖可能采用的倍数,例如 200、500、1000。实际奖励按本抽消费金额乘以倍数计算。", adjust: "增加或调高倍数会放大奖励金额和资金压力;删除倍数会减少可选大奖。", - constraint: "用逗号分隔,必须都大于 1 且从小到大;系统会在所有付得起的大奖倍数中等机会选择一档。", + constraint: + "用逗号分隔,必须都大于 1 且从小到大;系统会在所有付得起的大奖倍数中等机会选择一档。这里的倍率不能再作为普通奖档设置正概率,否则配置无法发布。", }, jackpot_global_rtp_max_percent: { - summary: "当前 App、奖池、规则版本在本轮窗口内的平均返还不高于这条线时,才满足补偿大奖的全局条件。", + summary: + "系统每次达到结算流水后,会结束并重置一轮大盘 RTP。刚结算的本轮大盘 RTP 不高于这条线时,才继续检查用户的两项返还条件。", adjust: "调高会让补偿大奖更容易进入候选;调低会要求全局返还更低才触发。", constraint: - "必须大于 0% 且不超过 100%。全局、用户当日和近 72 小时三项都必须有消费样本并全部通过,才会直接选择可支付大奖。", + "必须大于 0% 且不超过 100%。大盘 RTP 等于本项配置值时可以通过;还必须同时满足用户本轮 RTP 严格低于本轮触发线、用户近 48 小时 RTP 严格低于长期触发线,且奖池和各项限额足够。闭窗资格只在用户下一次送礼时尝试一次,奖池或限额不足也不会顺延。", }, - jackpot_user_day_rtp_max_percent: { - summary: "用户当天的平均返还不高于这条线时,才允许尝试补偿大奖。", - adjust: "调高会让更多用户满足条件;调低会把大奖更集中给当天返还明显偏低的用户。", - constraint: "按当前奖池的 UTC 自然日统计;没有消费样本时不触发。必须大于 0 且不能超过全局触发线。", + jackpot_user_round_rtp_max_percent: { + summary: + "系统每次重置大盘 RTP 时,会按完全相同的起止时间结算这个用户的本轮 RTP。例如 9 点重置一次、10 点再次重置,10 点结算时就检查用户在 9 点到 10 点这一轮的总返奖 ÷ 总消费。", + adjust: "调高会让更多本轮返还偏低的用户通过;调低会更严格。用户本轮 RTP 必须严格小于配置值,刚好等于也不通过。", + constraint: + "只统计当前 App、当前奖池和同一结算轮次内的实际消费与返奖;本轮没有消费样本时不通过。它必须与近 48 小时条件同时通过,不能只满足其中一项。", }, - jackpot_user_72h_rtp_max_percent: { - summary: "用户最近连续 72 小时的平均返还不高于这条线时,才允许尝试补偿大奖。", - adjust: "调高会放宽长期条件;调低会要求用户近三天整体返还更低。", - constraint: "按当前奖池滚动 72 小时统计,不是三个本地自然日;没有消费样本时不触发,且不能超过全局触发线。", + jackpot_user_48h_rtp_max_percent: { + summary: + "判断这个用户近 48 小时是否整体返还偏低。本轮结算时,用户注册已满 48 小时,系统才会把结算时点向前 48 小时内的返奖金币加总,再除以同期实际消费金币;同一 App、奖池下跨房间、主播和设备合并统计,结果严格低于这条线才通过。", + adjust: "调高会更容易通过,调低会更严格;结果等于配置值也不通过。例如近 48 小时消费 10,000、返奖 9,500,RTP 为 95%,配置 96% 时通过;返奖 9,600、RTP 为 96% 时不通过。", + constraint: + "先看注册时间:注册未满 48 小时直接不通过;注册已满 48 小时后,不要求连续玩满 48 小时,只统计窗口内真实发生的消费和返奖。即使前 47 小时都没玩、最近只玩 5 分钟,也用这 5 分钟的数据计算;整段时间没有消费时不通过。它还必须与大盘条件、用户本轮条件同时通过,并受每日大奖次数、奖池余额和所有返奖上限限制。", }, max_jackpot_hits_per_user_day: { summary: "限制同一用户在当前奖池的一个 UTC 自然日内最多真正拿到多少次大奖。", adjust: "调高允许同一用户一天获得更多大奖;调低会更早停止该用户当天的大奖,不保证转给其他用户。", - constraint: "必须大于 0;达到次数后仍可参与普通奖档,尚未使用的消费资格会保留。", + constraint: + "必须大于 0;达到次数后,即使大盘、本轮和近 48 小时 RTP 都通过也不再出大奖,但仍可参与普通奖档;下一个 UTC 自然日重新累计。", }, jackpot_spend_threshold_coins: { - summary: "用户在当前奖池的同一 UTC 日内,每累计消费满一份门槛,就保存一个供后续抽奖使用的大奖资格。", - adjust: "按各 App 奖池确认的金币口径填写;调高会要求消费更多才获得资格,调低会更频繁地产生资格。", + summary: + "这是旧规则按用户日累计消费保存独立大奖资格时使用的历史字段。当前规则已停用这条路径,保留数值只为读取和回传旧版本配置。", + adjust: "当前不可编辑;无论原值是多少,都不会增加用户出大奖的机会。", constraint: - "启用前必须大于 0,系统不会根据币价或充值记录自行猜额度;一笔消费可产生多个资格,资格从下一抽开始用,未能支付时不会丢失。", + "当前大奖必须经过大盘条件,并同时满足用户本轮 RTP 与近 48 小时 RTP 两项条件;这个历史字段不能替代或绕过任何一项。", }, max_single_payout: { summary: "限制一抽最多能返多少金币;超过这个金额的奖档当次不会发出。", diff --git a/ops-center/src/configForm.js b/ops-center/src/configForm.js index 24655d3..90dc23e 100644 --- a/ops-center/src/configForm.js +++ b/ops-center/src/configForm.js @@ -11,8 +11,21 @@ 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 legacyJackpotRTPAliases = { + jackpot_user_48h_rtp_max_percent: [ + "jackpot_user_72h_rtp_max_percent", + "jackpotUser72hRTPMaxPercent", + "jackpotUser72hRtpMaxPercent", + ], + jackpot_user_round_rtp_max_percent: [ + "jackpot_user_day_rtp_max_percent", + "jackpotUserDayRTPMaxPercent", + "jackpotUserDayRtpMaxPercent", + ], +}; -// 只有跨 App 通用的算法参数才有默认值;用户日累计消费触发门槛和六维金额上限必须由运营按 App 奖池的金币口径填写, +// 只有跨 App 通用的算法参数才有默认值;六维金额上限必须由运营按 App 奖池的金币口径填写, // 因而默认保持 0 且规则停用,避免把某个 App 的预算误发到其他 App。 const dynamicDefaults = { anchor_daily_payout_cap: 0, @@ -26,8 +39,8 @@ const dynamicDefaults = { jackpot_global_rtp_max_percent: 98, jackpot_multipliers: [200, 500, 1000], jackpot_spend_threshold_coins: 0, - jackpot_user_72h_rtp_max_percent: 96, - jackpot_user_day_rtp_max_percent: 96, + jackpot_user_48h_rtp_max_percent: 96, + jackpot_user_round_rtp_max_percent: 96, loss_streak_guarantee: 5, low_water_nonzero_factor_percent: 70, low_watermark_coins: 10_000_000, @@ -55,8 +68,8 @@ const numericFields = [ "initial_pool_coins", "jackpot_global_rtp_max_percent", "jackpot_spend_threshold_coins", - "jackpot_user_72h_rtp_max_percent", - "jackpot_user_day_rtp_max_percent", + "jackpot_user_48h_rtp_max_percent", + "jackpot_user_round_rtp_max_percent", "loss_streak_guarantee", "low_water_nonzero_factor_percent", "low_watermark_coins", @@ -109,6 +122,12 @@ export function configToForm(config = {}) { stages: normalizeFormStages(config.stages, targetRTPPercent, strategyVersion), strategy_version: strategyVersion, }; + // 只在明确收到旧 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), + ); for (const field of numericFields) { if (field === "target_rtp_percent") { form[field] = targetRTPPercent; @@ -119,7 +138,14 @@ export function configToForm(config = {}) { form[field] = "0"; continue; } - form[field] = formConfigNumber(config, field, snakeToCamel(field), defaultNumber(field, strategyVersion)); + 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 (form[LEGACY_JACKPOT_RTP_MODE]) { + form.jackpot_user_72h_rtp_max_percent = form.jackpot_user_48h_rtp_max_percent; + form.jackpot_user_day_rtp_max_percent = form.jackpot_user_round_rtp_max_percent; } return form; } @@ -156,6 +182,17 @@ export function formToConfig(config, form) { ? 0 : numberFromForm(form[field]); } + if (form[LEGACY_JACKPOT_RTP_MODE]) { + // 旧输入的纯函数 round-trip 保持原 shape,避免调用方只做本地比较时出现伪 diff;真正保存时 + // luckyGiftConfigPayload 会把这两个别名归一成 canonical HTTP 字段,绝不会双发两套字段。 + output.jackpot_user_72h_rtp_max_percent = output.jackpot_user_48h_rtp_max_percent; + output.jackpot_user_day_rtp_max_percent = output.jackpot_user_round_rtp_max_percent; + delete output.jackpot_user_48h_rtp_max_percent; + delete output.jackpot_user_round_rtp_max_percent; + } else { + delete output.jackpot_user_72h_rtp_max_percent; + delete output.jackpot_user_day_rtp_max_percent; + } return output; } @@ -307,23 +344,24 @@ export function validateLuckyGiftForm(form) { if (number("jackpot_global_rtp_max_percent") <= 0 || number("jackpot_global_rtp_max_percent") > 100) errors.push("全局大奖 RTP 上限必须在 0%-100% 之间"); if ( - number("jackpot_user_day_rtp_max_percent") <= 0 || - number("jackpot_user_day_rtp_max_percent") > number("jackpot_global_rtp_max_percent") + number("jackpot_user_round_rtp_max_percent") <= 0 || + number("jackpot_user_round_rtp_max_percent") > number("jackpot_global_rtp_max_percent") ) - errors.push("用户每日 RTP 上限必须大于 0 且不超过全局上限"); + errors.push("用户本轮 RTP 触发线必须大于 0 且不超过全局上限"); if ( - number("jackpot_user_72h_rtp_max_percent") <= 0 || - number("jackpot_user_72h_rtp_max_percent") > number("jackpot_global_rtp_max_percent") + number("jackpot_user_48h_rtp_max_percent") <= 0 || + number("jackpot_user_48h_rtp_max_percent") > number("jackpot_global_rtp_max_percent") ) - errors.push("用户 72 小时 RTP 上限必须大于 0 且不超过全局上限"); + errors.push("用户 48 小时 RTP 触发线必须大于 0 且不超过全局上限"); if (number("max_jackpot_hits_per_user_day") <= 0) errors.push("用户每日大奖次数必须大于 0"); if (number("jackpot_spend_threshold_coins") < 0) errors.push("大奖消费门槛不能小于 0"); + if (form[LEGACY_JACKPOT_RTP_MODE] && form.enabled && number("jackpot_spend_threshold_coins") <= 0) + errors.push("启用前必须填写用户日累计消费触发门槛(金币)"); for (const [field, label] of payoutCapFields) { if (number(field) < 0) errors.push(`${label}不能小于 0`); } validateRechargeStages(form.stages || [], errors, form.enabled); if (form.enabled) { - if (number("jackpot_spend_threshold_coins") <= 0) errors.push("启用前必须填写用户日累计消费触发门槛(金币)"); for (const [field, label] of payoutCapFields) { if (number(field) <= 0) errors.push(`启用前必须填写${label}`); } @@ -452,6 +490,17 @@ function formConfigNumber(config, snake, camel, fallback) { return formNumber(config[snake] ?? config[camel] ?? fallback); } +function firstConfigValue(config, keys) { + for (const key of keys) { + if (config[key] !== undefined && config[key] !== null) return config[key]; + } + return undefined; +} + +function hasConfigValue(config, keys) { + return firstConfigValue(config, keys) !== undefined; +} + function snakeToCamel(value) { return value.replace(/_([a-z0-9])/g, (_, letter) => letter.toUpperCase()); } diff --git a/ops-center/src/styles/index.css b/ops-center/src/styles/index.css index b565b0c..636911b 100644 --- a/ops-center/src/styles/index.css +++ b/ops-center/src/styles/index.css @@ -445,6 +445,35 @@ body { line-height: 1.5; } +.ops-jackpot-flow { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--border); +} + +.ops-jackpot-flow > div { + display: grid; + align-content: start; + gap: 4px; + background: var(--bg-card-strong, #f8fafc); + padding: var(--space-2) var(--space-3); +} + +.ops-jackpot-flow strong { + color: var(--text-primary); + font-size: 12px; +} + +.ops-jackpot-flow span { + color: var(--text-secondary); + font-size: 11px; + line-height: 1.5; +} + .ops-field-label { display: inline-flex; min-width: 0; @@ -896,6 +925,10 @@ body { grid-template-columns: minmax(0, 1fr); } + .ops-jackpot-flow { + grid-template-columns: minmax(0, 1fr); + } + .ops-stage-tabs { display: flex; max-width: 100vw;