diff --git a/ops-center/src/components/LuckyGiftConfigDialog.jsx b/ops-center/src/components/LuckyGiftConfigDialog.jsx index 0cf4a16..590c4f3 100644 --- a/ops-center/src/components/LuckyGiftConfigDialog.jsx +++ b/ops-center/src/components/LuckyGiftConfigDialog.jsx @@ -29,6 +29,7 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", : form.stages[0]?.stage; const activeStageConfig = form.stages.find((stage) => stage.stage === activeStageKey); const activeSummary = stageSummaries.find((summary) => summary.stageKey === activeStageKey); + const normalStageConfig = form.stages.find((stage) => stage.stage === "normal"); useEffect(() => { if (!validationErrors.length) return; @@ -152,7 +153,7 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", - + - + @@ -193,6 +197,7 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", activeStageConfig={activeStageConfig} activeStageKey={activeStageKey} activeSummary={activeSummary} + normalStageConfig={normalStageConfig} stageSummaries={stageSummaries} strategyVersion={form.strategy_version} onAddTier={addStageTier} diff --git a/ops-center/src/components/LuckyGiftConfigDialog.test.jsx b/ops-center/src/components/LuckyGiftConfigDialog.test.jsx index 3a49876..2b54a32 100644 --- a/ops-center/src/components/LuckyGiftConfigDialog.test.jsx +++ b/ops-center/src/components/LuckyGiftConfigDialog.test.jsx @@ -24,6 +24,7 @@ test("keeps every dynamic_v3 control reachable while locking rule-time pool inje "充值加权窗口 (ms)", "大奖倍率", "全局大奖 RTP 上限 (%)", + "用户日累计消费触发门槛(金币)", "单次返奖上限", "主播每日上限", ].forEach((label) => @@ -35,6 +36,14 @@ test("keeps every dynamic_v3 control reachable while locking rule-time pool inje expect(within(dialog).getByLabelText("新手阶段第 1 档倍率")).toBeInTheDocument(); expect(within(dialog).getByLabelText("新手阶段第 2 档概率 %")).toHaveAttribute("readonly"); expect(within(dialog).getByRole("button", { name: "删除新手阶段第 1 档" })).toBeInTheDocument(); + expect(within(dialog).getByLabelText("新手阶段派生充值范围")).toHaveTextContent("7 日充值 < 0 或 30 日充值 < 1"); + expect( + within(dialog).getByText("任一未达到即为新手;7 日与 30 日均达到门槛(含等于)后进入普通。"), + ).toBeInTheDocument(); + + fireEvent.click(within(dialog).getByRole("tab", { name: /普通/ })); + expect(within(dialog).getByLabelText("7 日最低充值(金币)")).toHaveValue(0); + expect(within(dialog).getByLabelText("30 日最低充值(金币)")).toHaveValue(1); }); test("section navigation handles reduced motion and falls back after switching from dynamic_v3 to fixed_v2", async () => { diff --git a/ops-center/src/components/LuckyGiftConfigSections.jsx b/ops-center/src/components/LuckyGiftConfigSections.jsx index 97a78b1..966d003 100644 --- a/ops-center/src/components/LuckyGiftConfigSections.jsx +++ b/ops-center/src/components/LuckyGiftConfigSections.jsx @@ -217,7 +217,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange }

- 收礼对象收益必须与该奖池实际送礼类型在各适用地区的返币比例一致,否则发布校验失败。 + 发布前请核对收礼对象收益与实际送礼返币比例;不一致时,V3 真实开奖会拒绝账务结算。

@@ -296,7 +296,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } disabled form={form} helperText="请通过奖池水位操作注资" - label="初始奖池金币(固定为 0)" + label="初始奖池金币" name="initial_pool_coins" onChange={onChange} /> @@ -308,7 +308,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } /> @@ -321,7 +321,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } /> @@ -334,7 +334,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } /> @@ -355,6 +355,7 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } id="ops-config-section-jackpot" > +
@@ -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 {