diff --git a/ops-center/src/api.js b/ops-center/src/api.js
index e206959..262c320 100644
--- a/ops-center/src/api.js
+++ b/ops-center/src/api.js
@@ -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),
diff --git a/ops-center/src/components/LuckyGiftConfigSections.jsx b/ops-center/src/components/LuckyGiftConfigSections.jsx
index f3fd41c..41eeb82 100644
--- a/ops-center/src/components/LuckyGiftConfigSections.jsx
+++ b/ops-center/src/components/LuckyGiftConfigSections.jsx
@@ -345,6 +345,20 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
step="0.01"
onChange={onChange}
/>
+
+
diff --git a/ops-center/src/components/LuckyGiftFieldHelp.jsx b/ops-center/src/components/LuckyGiftFieldHelp.jsx
index 07b2aec..c3c642b 100644
--- a/ops-center/src/components/LuckyGiftFieldHelp.jsx
+++ b/ops-center/src/components/LuckyGiftFieldHelp.jsx
@@ -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。它不并入普通奖档概率:大奖未成功才进入普通开奖。",
diff --git a/ops-center/src/configForm.js b/ops-center/src/configForm.js
index b8fedd3..84d24c2 100644
--- a/ops-center/src/configForm.js
+++ b/ops-center/src/configForm.js
@@ -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;