feat(ops): configure user 24h RTP downweight

This commit is contained in:
zhx 2026-07-16 15:38:03 +08:00
parent fe63d1a3ce
commit 10928534e7
4 changed files with 160 additions and 5 deletions

View File

@ -3,6 +3,7 @@ import { apiRequest } from "@/shared/api/request";
export const OPS_API_PREFIX = "/api/v1/admin/ops-center"; export const OPS_API_PREFIX = "/api/v1/admin/ops-center";
export const DEFAULT_POOL_ID = "default"; export const DEFAULT_POOL_ID = "default";
const USER_24H_RTP_DISABLED_FACTOR_PERCENT = 100;
export async function fetchOpsDashboard(query = {}) { export async function fetchOpsDashboard(query = {}) {
// Ops Center 是跨 App 的运营面板:先拉应用主数据,再按 app_code 扇出拉取奖池配置、余额和抽奖汇总。 // Ops Center 是跨 App 的运营面板:先拉应用主数据,再按 app_code 扇出拉取奖池配置、余额和抽奖汇总。
@ -278,6 +279,15 @@ function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") {
.trim() .trim()
.toLowerCase(); .toLowerCase();
const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion); 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 { return {
...config, ...config,
app_code: appCode, app_code: appCode,
@ -287,6 +297,13 @@ function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") {
rule_version: ruleVersion, rule_version: ruleVersion,
// 升级前规则没有 strategy_versionlucky-gift owner 明确按 fixed_v2 解释;列表和编辑表单必须沿用该兼容语义。 // 升级前规则没有 strategy_versionlucky-gift owner 明确按 fixed_v2 解释;列表和编辑表单必须沿用该兼容语义。
strategy_version: normalizeStrategyVersion(config.strategy_version ?? config.strategyVersion), 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") const strategyVersion = String(config.strategy_version || config.strategyVersion || "fixed_v2")
.trim() .trim()
.toLowerCase(); .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/奖档字段会让 // 规则版本是不可变快照PUT 必须完整回传 owner GET 返回的策略字段。只提交旧版 RTP/奖档字段会让
// 空 strategy_version 被服务端按 fixed_v2 发布,或让 dynamic_v3 因资金、水位、大奖字段归零而拒绝发布。 // 空 strategy_version 被服务端按 fixed_v2 发布,或让 dynamic_v3 因资金、水位、大奖字段归零而拒绝发布。
@ -393,6 +420,15 @@ function luckyGiftConfigPayload(config = {}) {
settlement_window_wager: numberValue(config.settlement_window_wager ?? config.settlementWindowWager), settlement_window_wager: numberValue(config.settlement_window_wager ?? config.settlementWindowWager),
stages: normalizeStages(config.stages), stages: normalizeStages(config.stages),
strategy_version: strategyVersion, 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_daily_payout_cap: numberValue(config.user_daily_payout_cap ?? config.userDailyPayoutCap),
user_hourly_payout_cap: numberValue(config.user_hourly_payout_cap ?? config.userHourlyPayoutCap), user_hourly_payout_cap: numberValue(config.user_hourly_payout_cap ?? config.userHourlyPayoutCap),
target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent), target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent),

View File

@ -345,6 +345,20 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
step="0.01" step="0.01"
onChange={onChange} onChange={onChange}
/> />
<NumberField
form={form}
label="用户近 24 小时 RTP 触发线 (%)"
name="user_24h_rtp_threshold_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="高 RTP 时普通中奖机会保留 (%)"
name="user_24h_ordinary_win_factor_percent"
step="0.01"
onChange={onChange}
/>
</div> </div>
</section> </section>

View File

@ -115,6 +115,20 @@ const FIELD_HELP = {
adjust: "调高会增强充值后的中奖体验,也会增加奖池消耗;调低则更温和。", adjust: "调高会增强充值后的中奖体验,也会增加奖池消耗;调低则更温和。",
constraint: "必须大于 100%,会与高低余额调整相乘;它只改变普通奖档,不会直接提高大奖触发机会。", 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: { jackpot_multipliers: {
summary: summary:
"列出 48 小时亏损补偿和累计消费达标两种大奖策略可能采用的倍数,例如 200、500、1000。它不并入普通奖档概率大奖未成功才进入普通开奖。", "列出 48 小时亏损补偿和累计消费达标两种大奖策略可能采用的倍数,例如 200、500、1000。它不并入普通奖档概率大奖未成功才进入普通开奖。",

View File

@ -12,6 +12,9 @@ import { DEFAULT_POOL_ID } from "./api.js";
const DYNAMIC_STRATEGY = "dynamic_v3"; const DYNAMIC_STRATEGY = "dynamic_v3";
const PPM_SCALE = 1_000_000; const PPM_SCALE = 1_000_000;
const LEGACY_JACKPOT_RTP_MODE = "__legacy_jackpot_rtp_fields"; 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 = { const legacyJackpotRTPAliases = {
jackpot_user_48h_rtp_max_percent: [ jackpot_user_48h_rtp_max_percent: [
"jackpot_user_72h_rtp_max_percent", "jackpot_user_72h_rtp_max_percent",
@ -24,6 +27,10 @@ const legacyJackpotRTPAliases = {
"jackpotUserDayRtpMaxPercent", "jackpotUserDayRtpMaxPercent",
], ],
}; };
const user24hRTPAliases = {
user_24h_ordinary_win_factor_percent: ["user24HOrdinaryWinFactorPercent"],
user_24h_rtp_threshold_percent: ["user24HRTPThresholdPercent", "user24hRTPThresholdPercent"],
};
// 只有跨 App 通用的算法参数才有默认值;六维金额上限必须由运营按 App 奖池的金币口径填写, // 只有跨 App 通用的算法参数才有默认值;六维金额上限必须由运营按 App 奖池的金币口径填写,
// 因而默认保持 0 且规则停用,避免把某个 App 的预算误发到其他 App。 // 因而默认保持 0 且规则停用,避免把某个 App 的预算误发到其他 App。
@ -52,6 +59,8 @@ const dynamicDefaults = {
recharge_boost_window_ms: 300_000, recharge_boost_window_ms: 300_000,
room_hourly_payout_cap: 0, room_hourly_payout_cap: 0,
target_rtp_percent: 98, 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_daily_payout_cap: 0,
user_hourly_payout_cap: 0, user_hourly_payout_cap: 0,
}; };
@ -84,6 +93,8 @@ const numericFields = [
"room_hourly_payout_cap", "room_hourly_payout_cap",
"settlement_window_wager", "settlement_window_wager",
"target_rtp_percent", "target_rtp_percent",
"user_24h_ordinary_win_factor_percent",
"user_24h_rtp_threshold_percent",
"user_daily_payout_cap", "user_daily_payout_cap",
"user_hourly_payout_cap", "user_hourly_payout_cap",
]; ];
@ -122,12 +133,16 @@ export function configToForm(config = {}) {
stages: normalizeFormStages(config.stages, targetRTPPercent, strategyVersion), stages: normalizeFormStages(config.stages, targetRTPPercent, strategyVersion),
strategy_version: strategyVersion, strategy_version: strategyVersion,
}; };
const newDynamicDraft = strategyVersion === DYNAMIC_STRATEGY && isNewDynamicDraft(config);
// 只在明确收到旧 day/72h 且缺少对应 canonical 字段时进入兼容模式。页面始终编辑 canonical 值, // 只在明确收到旧 day/72h 且缺少对应 canonical 字段时进入兼容模式。页面始终编辑 canonical 值,
// 这个标记仅让纯函数保留旧对象形状HTTP 层仍会归一成 round/48h 后再发送。 // 这个标记仅让纯函数保留旧对象形状HTTP 层仍会归一成 round/48h 后再发送。
form[LEGACY_JACKPOT_RTP_MODE] = Object.entries(legacyJackpotRTPAliases).some( form[LEGACY_JACKPOT_RTP_MODE] = Object.entries(legacyJackpotRTPAliases).some(
([canonical, aliases]) => ([canonical, aliases]) =>
!hasConfigValue(config, [canonical, snakeToCamel(canonical)]) && hasConfigValue(config, 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) { for (const field of numericFields) {
if (field === "target_rtp_percent") { if (field === "target_rtp_percent") {
form[field] = targetRTPPercent; form[field] = targetRTPPercent;
@ -138,10 +153,27 @@ export function configToForm(config = {}) {
form[field] = "0"; form[field] = "0";
continue; continue;
} }
const legacyAliases = legacyJackpotRTPAliases[field] || []; if (newDynamicDraft && field === "user_24h_rtp_threshold_percent") {
form[field] = hasConfigValue(config, [field, snakeToCamel(field)]) // 兼容 admin-server 先于本字段升级时返回的显式 0/100 草稿;未发布版本始终采用新建默认。
? formConfigNumber(config, field, snakeToCamel(field), defaultNumber(field, strategyVersion)) form[field] = String(USER_24H_RTP_DEFAULT_THRESHOLD_PERCENT);
: formNumber(firstConfigValue(config, legacyAliases) ?? defaultNumber(field, strategyVersion)); 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]) { if (form[LEGACY_JACKPOT_RTP_MODE]) {
form.jackpot_user_72h_rtp_max_percent = form.jackpot_user_48h_rtp_max_percent; form.jackpot_user_72h_rtp_max_percent = form.jackpot_user_48h_rtp_max_percent;
@ -182,6 +214,21 @@ export function formToConfig(config, form) {
? 0 ? 0
: numberFromForm(form[field]); : 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]) { if (form[LEGACY_JACKPOT_RTP_MODE]) {
// 旧输入的纯函数 round-trip 保持原 shape避免调用方只做本地比较时出现伪 diff真正保存时 // 旧输入的纯函数 round-trip 保持原 shape避免调用方只做本地比较时出现伪 diff真正保存时
// luckyGiftConfigPayload 会把这两个别名归一成 canonical HTTP 字段,绝不会双发两套字段。 // luckyGiftConfigPayload 会把这两个别名归一成 canonical HTTP 字段,绝不会双发两套字段。
@ -330,6 +377,9 @@ export function validateLuckyGiftForm(form) {
if (number("high_water_nonzero_factor_percent") <= 100) errors.push("高水位非零因子必须大于 100%"); if (number("high_water_nonzero_factor_percent") <= 100) errors.push("高水位非零因子必须大于 100%");
if (number("recharge_boost_window_ms") <= 0 || number("recharge_boost_factor_percent") <= 100) if (number("recharge_boost_window_ms") <= 0 || number("recharge_boost_factor_percent") <= 100)
errors.push("充值加权窗口必须大于 0 且因子必须大于 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 || "") const jackpotTokens = String(form.jackpot_multipliers || "")
.split(/[,\s]+/) .split(/[,\s]+/)
.filter(Boolean); .filter(Boolean);
@ -337,7 +387,9 @@ export function validateLuckyGiftForm(form) {
if ( if (
jackpotTokens.length !== jackpotMultiplierValues.length || jackpotTokens.length !== jackpotMultiplierValues.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 并严格递增"); errors.push("大奖倍率必须大于 1x 并严格递增");
} }
@ -469,6 +521,45 @@ function defaultNumber(field, strategyVersion) {
return strategyVersion === DYNAMIC_STRATEGY ? (dynamicDefaults[field] ?? 0) : 0; 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) { function multiplierListToForm(value, strategyVersion) {
if (Array.isArray(value) && value.length) return value.map(formatDecimal).join(", "); if (Array.isArray(value) && value.length) return value.map(formatDecimal).join(", ");
if (typeof value === "string" && value.trim()) return value; if (typeof value === "string" && value.trim()) return value;