@@ -413,7 +415,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
@@ -443,7 +445,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }
/>
@@ -467,6 +469,25 @@ function SectionHeading({ detail, id, ok = false, title }) {
);
}
+function JackpotFlowSummary() {
+ return (
+
+
+ 1. 消费达标优先
+ 当天累计消费每跨过一次门槛,就保存一次大奖资格;资格从下一抽开始使用。
+
+
+ 2. 返还偏低补偿
+ 没有待用资格时,整体、用户当天和近 72 小时平均返还都不高于触发线,才尝试大奖。
+
+
+ 3. 只从付得起的档位选
+ 先剔除奖池或限额不足的倍率,再在剩余倍率中等机会选择;消费资格付不起时会保留。
+
+
+ );
+}
+
function NumberField({ disabled = false, form, helperText, label, name, onChange, step = "1" }) {
return (
- {strategyVersion === "dynamic_v3" ? (
+ {strategyVersion === "dynamic_v3" && activeStageConfig.stage === "novice" ? (
+
+ ) : strategyVersion === "dynamic_v3" ? (
进入本阶段所需充值
}
+ label={
+
+ }
size="small"
slotProps={{ htmlInput: { min: 0, step: 1 } }}
type="number"
@@ -115,8 +120,9 @@ export function LuckyGiftStageDesigner({
}
/>
}
+ label={
+
+ }
size="small"
slotProps={{ htmlInput: { min: 0, step: 1 } }}
type="number"
@@ -237,6 +243,29 @@ export function LuckyGiftStageDesigner({
);
}
+function NoviceRechargeRange({ normalStageConfig }) {
+ const normal7D = Number(normalStageConfig?.min_recharge_7d_coins || 0);
+ const normal30D = Number(normalStageConfig?.min_recharge_30d_coins || 0);
+ // normal 是二维 AND 下限,合法配置只需至少一维大于 0;为 0 的维度也必须照实展示。
+ const configured = Number.isFinite(normal7D) && Number.isFinite(normal30D) && (normal7D > 0 || normal30D > 0);
+
+ return (
+
+
派生范围
+
+
+ 7 日充值 < {formatNumber(normal7D)} 或 30 日充值 < {formatNumber(normal30D)}
+
+ {/* 普通阶段需要两个下限同时成立;因此任一维未达到都应留在新手,等于下限则进入普通。 */}
+
+ {configured ? "" : "普通阶段门槛待配置;"}任一未达到即为新手;7 日与 30
+ 日均达到门槛(含等于)后进入普通。
+
+
+
+ );
+}
+
function plainStageStatus(status) {
if (status === "RTP 偏离") return "平均返还偏离";
if (status === "概率未闭合") return "概率合计不为 100%";
diff --git a/ops-center/src/configForm.js b/ops-center/src/configForm.js
index bc7f3cf..24655d3 100644
--- a/ops-center/src/configForm.js
+++ b/ops-center/src/configForm.js
@@ -12,7 +12,7 @@ import { DEFAULT_POOL_ID } from "./api.js";
const DYNAMIC_STRATEGY = "dynamic_v3";
const PPM_SCALE = 1_000_000;
-// 只有跨 App 通用的算法参数才有默认值;50 美元等值门槛和六维金额上限必须由运营按 App 金币口径填写,
+// 只有跨 App 通用的算法参数才有默认值;用户日累计消费触发门槛和六维金额上限必须由运营按 App 奖池的金币口径填写,
// 因而默认保持 0 且规则停用,避免把某个 App 的预算误发到其他 App。
const dynamicDefaults = {
anchor_daily_payout_cap: 0,
@@ -131,18 +131,23 @@ export function formToConfig(config, form) {
enabled: Boolean(form.enabled),
jackpot_multipliers: multiplierListFromForm(form.jackpot_multipliers),
pool_id: form.pool_id,
- stages: (form.stages || []).map((stage) => ({
- min_recharge_30d_coins: numberFromForm(stage.min_recharge_30d_coins),
- min_recharge_7d_coins: numberFromForm(stage.min_recharge_7d_coins),
- stage: stage.stage,
- tiers: (stage.tiers || []).map((tier) => ({
- enabled: tier.enabled !== false,
- high_water_only: Boolean(tier.highWaterOnly),
- multiplier: numberFromForm(tier.multiplier),
- probability_percent: numberFromForm(tier.probabilityPercent),
- tier_id: tier.tierId || "",
- })),
- })),
+ stages: (form.stages || []).map((stage) => {
+ // dynamic_v3 的新手范围由普通阶段下限反向派生;0/0 只是旧 DTO 要求的兜底哨兵值,
+ // 不是可运营的“充值为 0 才是新手”规则。序列化时强制归零,避免历史异常值改变分层。
+ const isDynamicNovice = form.strategy_version === DYNAMIC_STRATEGY && stage.stage === "novice";
+ return {
+ min_recharge_30d_coins: isDynamicNovice ? 0 : numberFromForm(stage.min_recharge_30d_coins),
+ min_recharge_7d_coins: isDynamicNovice ? 0 : numberFromForm(stage.min_recharge_7d_coins),
+ stage: stage.stage,
+ tiers: (stage.tiers || []).map((tier) => ({
+ enabled: tier.enabled !== false,
+ high_water_only: Boolean(tier.highWaterOnly),
+ multiplier: numberFromForm(tier.multiplier),
+ probability_percent: numberFromForm(tier.probabilityPercent),
+ tier_id: tier.tierId || "",
+ })),
+ };
+ }),
strategy_version: form.strategy_version,
};
for (const field of numericFields) {
@@ -316,9 +321,9 @@ export function validateLuckyGiftForm(form) {
for (const [field, label] of payoutCapFields) {
if (number(field) < 0) errors.push(`${label}不能小于 0`);
}
- validateRechargeStages(form.stages || [], errors);
+ validateRechargeStages(form.stages || [], errors, form.enabled);
if (form.enabled) {
- if (number("jackpot_spend_threshold_coins") <= 0) errors.push("启用前必须填写 50 美元等值的大奖消费门槛");
+ if (number("jackpot_spend_threshold_coins") <= 0) errors.push("启用前必须填写用户日累计消费触发门槛(金币)");
for (const [field, label] of payoutCapFields) {
if (number(field) <= 0) errors.push(`启用前必须填写${label}`);
}
@@ -345,11 +350,15 @@ function normalizeFormStages(stages, targetRTPPercent, strategyVersion) {
: correctedDefaultTiers(stageKey, targetRTPPercent);
const [default7d, default30d] =
strategyVersion === DYNAMIC_STRATEGY ? defaultRechargeThreshold(stageKey) : [0, 0];
+ const isDynamicNovice = strategyVersion === DYNAMIC_STRATEGY && stageKey === "novice";
return {
- min_recharge_7d_coins: formNumber(source?.min_recharge_7d_coins ?? source?.minRecharge7DCoins ?? default7d),
- min_recharge_30d_coins: formNumber(
- source?.min_recharge_30d_coins ?? source?.minRecharge30DCoins ?? default30d,
- ),
+ // 只在表单内保留兼容哨兵值;界面上的新手上界始终从普通门槛派生,不读取历史 novice 门槛。
+ min_recharge_7d_coins: isDynamicNovice
+ ? "0"
+ : formNumber(source?.min_recharge_7d_coins ?? source?.minRecharge7DCoins ?? default7d),
+ min_recharge_30d_coins: isDynamicNovice
+ ? "0"
+ : formNumber(source?.min_recharge_30d_coins ?? source?.minRecharge30DCoins ?? default30d),
stage: stageKey,
tiers: sourceTiers.map((tier) => ({
enabled: tier.enabled !== false,
@@ -382,13 +391,10 @@ function defaultTier(tierID, multiplier, probabilityPercent) {
return { enabled: true, highWaterOnly: false, multiplier, probabilityPercent, tierId: tierID };
}
-function validateRechargeStages(stages, errors) {
- const novice = stages.find((stage) => stage.stage === "novice");
+function validateRechargeStages(stages, errors, requireComplete) {
const normal = stages.find((stage) => stage.stage === "normal");
const advanced = stages.find((stage) => stage.stage === "advanced");
const threshold = (stage, field) => numberFromForm(stage?.[field]);
- if (threshold(novice, "min_recharge_7d_coins") !== 0 || threshold(novice, "min_recharge_30d_coins") !== 0)
- errors.push("新手阶段充值门槛必须为 0 / 0");
const normal7d = threshold(normal, "min_recharge_7d_coins");
const normal30d = threshold(normal, "min_recharge_30d_coins");
const advanced7d = threshold(advanced, "min_recharge_7d_coins");
@@ -397,15 +403,21 @@ function validateRechargeStages(stages, errors) {
errors.push("充值阶段门槛不能小于 0");
return;
}
- if (normal7d === 0 && normal30d === 0) errors.push("正常阶段至少一个充值门槛必须大于 0");
+ const isUnconfigured = [normal7d, normal30d, advanced7d, advanced30d].every((value) => value === 0);
+ // 新建的停用草稿可以全 0,避免猜测 App 金币价值;一旦开始填写或准备启用,
+ // 就必须遵守 owner 的二维下限契约:normal 至少一维大于 0,advanced 逐维不低且至少一维更高。
+ if (!requireComplete && isUnconfigured) return;
+ if (normal7d === 0 && normal30d === 0)
+ errors.push(
+ requireComplete ? "启用前普通阶段至少一个最低充值门槛必须大于 0" : "普通阶段至少一个充值门槛必须大于 0",
+ );
if (advanced7d < normal7d || advanced30d < normal30d || (advanced7d === normal7d && advanced30d === normal30d))
errors.push("高阶充值门槛必须逐维不低于正常阶段且至少一项更高");
}
-function defaultRechargeThreshold(stage) {
- if (stage === "novice") return [0, 0];
- if (stage === "normal") return [0, 1];
- return [1, 1];
+function defaultRechargeThreshold() {
+ // 充值门槛受 App 金币比例影响,新草稿不猜测 1 金币等于可用配置;运营填完后才能启用。
+ return [0, 0];
}
function defaultNumber(field, strategyVersion) {
diff --git a/ops-center/src/configForm.test.js b/ops-center/src/configForm.test.js
index a20ec5c..971a489 100644
--- a/ops-center/src/configForm.test.js
+++ b/ops-center/src/configForm.test.js
@@ -18,8 +18,8 @@ describe("ops center lucky gift dynamic_v3 form", () => {
});
expect(form.stages.map((stage) => [stage.min_recharge_7d_coins, stage.min_recharge_30d_coins])).toEqual([
["0", "0"],
- ["0", "1"],
- ["1", "1"],
+ ["0", "0"],
+ ["0", "0"],
]);
expect(form.stages.map((stage) => stage.tiers.map((tier) => Number(tier.probabilityPercent)))).toEqual([
[5, 4, 86, 5],
@@ -52,10 +52,15 @@ describe("ops center lucky gift dynamic_v3 form", () => {
test("drops legacy dynamic initial seed when loading and publishing a new version", () => {
const legacy = completeDynamicConfig();
legacy.initial_pool_coins = 1_000_000;
+ legacy.stages[0].min_recharge_7d_coins = 12_345;
+ legacy.stages[0].min_recharge_30d_coins = 67_890;
const form = configToForm(legacy);
expect(form.initial_pool_coins).toBe("0");
- expect(formToConfig(legacy, form).initial_pool_coins).toBe(0);
+ expect(form.stages[0]).toMatchObject({ min_recharge_7d_coins: "0", min_recharge_30d_coins: "0" });
+ const output = formToConfig(legacy, form);
+ expect(output.initial_pool_coins).toBe(0);
+ expect(output.stages[0]).toMatchObject({ min_recharge_7d_coins: 0, min_recharge_30d_coins: 0 });
});
test("allows disabled drafts but blocks enabled dynamic rules until app monetary limits are filled", () => {
@@ -65,7 +70,9 @@ describe("ops center lucky gift dynamic_v3 form", () => {
const enabled = { ...draft, enabled: true };
const errors = validateLuckyGiftForm(enabled);
- expect(errors).toContain("启用前必须填写 50 美元等值的大奖消费门槛");
+ expect(errors).toContain("启用前必须填写用户日累计消费触发门槛(金币)");
+ expect(errors).toContain("启用前普通阶段至少一个最低充值门槛必须大于 0");
+ expect(errors).toContain("高阶充值门槛必须逐维不低于正常阶段且至少一项更高");
expect(errors).toContain("启用前必须填写单次返奖上限");
expect(errors).toContain("启用前必须填写主播每日上限");
});
diff --git a/ops-center/src/styles/index.css b/ops-center/src/styles/index.css
index e5bb8fa..b565b0c 100644
--- a/ops-center/src/styles/index.css
+++ b/ops-center/src/styles/index.css
@@ -674,6 +674,27 @@ body {
font-weight: 800;
}
+.ops-stage-threshold-rule {
+ display: grid;
+ grid-column: 2 / -1;
+ gap: 2px;
+ min-width: 0;
+ border-left: 3px solid var(--primary);
+ background: var(--bg-card-strong, #f8fafc);
+ padding: var(--space-2) var(--space-3);
+}
+
+.ops-stage-threshold-rule strong {
+ color: var(--text-primary);
+ font-size: 13px;
+}
+
+.ops-stage-threshold-rule small {
+ color: var(--text-secondary);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
.ops-tier-table {
display: grid;
min-width: 0;
@@ -911,6 +932,10 @@ body {
grid-column: 1 / -1;
}
+ .ops-stage-threshold-rule {
+ grid-column: 1 / -1;
+ }
+
/* 小屏保留完整列头并让表格横向滚动,控件同时有阶段+行号标签,避免一列卡片失去字段关系。 */
.ops-tier-head,
.ops-tier-row {