merge: explain 48h lucky gift jackpot eligibility

This commit is contained in:
zhx 2026-07-15 20:36:59 +08:00
commit 17a94eaf94
5 changed files with 155 additions and 50 deletions

View File

@ -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(

View File

@ -377,15 +377,15 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
/>
<NumberField
form={form}
label="用户当日返还触发线 (%)"
name="jackpot_user_day_rtp_max_percent"
label="用户本轮返还触发线 (%)"
name="jackpot_user_round_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="用户近 72 小时返还触发线 (%)"
name="jackpot_user_72h_rtp_max_percent"
label="用户近 48 小时返还触发线 (%)"
name="jackpot_user_48h_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
@ -396,11 +396,13 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
onChange={onChange}
/>
<NumberField
disabled
form={form}
helperText="按应用奖池规则配置"
label="用户日累计消费触发门槛(金币)"
helperText="仅兼容历史配置,当前规则不参与大奖资格"
label="用户日累计消费触发门槛(金币)— 已停用"
name="jackpot_spend_threshold_coins"
onChange={onChange}
required={false}
/>
</div>
</section>
@ -473,28 +475,30 @@ function JackpotFlowSummary() {
return (
<div className="ops-jackpot-flow" aria-label="大奖产生方式">
<div>
<strong>1. 消费达标优先</strong>
<span>当天累计消费每跨过一次门槛就保存一次大奖资格资格从下一抽开始使用</span>
<strong>1. 大盘先通过</strong>
<span>本轮大盘 RTP 不高于全局触发线才继续检查用户等于触发线也可通过</span>
</div>
<div>
<strong>2. 返还偏低补偿</strong>
<span>没有待用资格时整体用户当天和近 72 小时平均返还都不高于触发线才尝试大奖</span>
<strong>2. 用户两项都通过</strong>
<span>用户本轮 RTP 和近 48 小时 RTP 都必须严格低于各自触发线少一项都不能出大奖</span>
</div>
<div>
<strong>3. 只从付得起的档位选</strong>
<span>先剔除奖池或限额不足的倍率再在剩余倍率中等机会选择消费资格付不起时会保留</span>
<strong>3. 下次送礼尝试大奖</strong>
<span>
用户下次送礼时先剔除奖池或限额付不起的倍率再随机选择没有可支付倍率就不出大奖本轮资格也不会顺延或重复使用
</span>
</div>
</div>
);
}
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 (
<TextField
disabled={disabled}
helperText={helperText}
label={<LuckyGiftFieldLabel field={name} label={label} />}
required
required={required}
size="small"
slotProps={{ htmlInput: { inputMode: "decimal", min: 0, step } }}
type="number"

View File

@ -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,500RTP 为 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: "限制一抽最多能返多少金币;超过这个金额的奖档当次不会发出。",

View File

@ -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());
}

View File

@ -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;