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;