feat(ops): configure user 24h RTP downweight
This commit is contained in:
parent
fe63d1a3ce
commit
10928534e7
@ -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),
|
||||
|
||||
@ -345,6 +345,20 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
|
||||
step="0.01"
|
||||
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>
|
||||
</section>
|
||||
|
||||
|
||||
@ -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。它不并入普通奖档概率:大奖未成功才进入普通开奖。",
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user