diff --git a/ops-center/src/components/LuckyGiftConfigDialog.test.jsx b/ops-center/src/components/LuckyGiftConfigDialog.test.jsx index 1002ae1..1739fcd 100644 --- a/ops-center/src/components/LuckyGiftConfigDialog.test.jsx +++ b/ops-center/src/components/LuckyGiftConfigDialog.test.jsx @@ -30,12 +30,18 @@ test("keeps every dynamic_v3 control reachable while locking rule-time pool inje ].forEach((label) => expect(within(dialog).getByLabelText((accessibleName) => accessibleName.startsWith(label))).toBeInTheDocument(), ); + expect(within(dialog).getByLabelText(/用户日累计消费触发门槛/)).toBeEnabled(); expect(within(dialog).getAllByRole("tab")).toHaveLength(3); expect(within(dialog).getByRole("columnheader", { name: "倍率" })).toBeInTheDocument(); expect(within(dialog).getByRole("columnheader", { name: "普通中奖机会 %" })).toBeInTheDocument(); expect(within(dialog).getByRole("heading", { name: "独立大奖路径" })).toBeInTheDocument(); - expect(within(dialog).getByText("4. 未成功才走普通开奖")).toBeInTheDocument(); + ["共享开奖前提", "48 小时亏损补偿", "累计消费达标"].forEach((heading) => { + expect(within(dialog).getByRole("heading", { name: heading })).toBeInTheDocument(); + }); + expect(within(dialog).getByText(/实际 RTP 小于或等于配置上限才通过/)).toBeInTheDocument(); + expect(within(dialog).getByText(/每满一次门槛就产生 1 个大奖资格/)).toBeInTheDocument(); + expect(within(dialog).getByText(/大盘、水位、次数或限额不通过时资格会跨日保留/)).toBeInTheDocument(); expect(within(dialog).getByLabelText("新手阶段第 1 档倍率")).toBeInTheDocument(); expect(within(dialog).getByLabelText("新手阶段第 2 档概率 %")).toHaveAttribute("readonly"); expect(within(dialog).getByRole("button", { name: "删除新手阶段第 1 档" })).toBeInTheDocument(); diff --git a/ops-center/src/components/LuckyGiftConfigSections.jsx b/ops-center/src/components/LuckyGiftConfigSections.jsx index 92df6c1..f3fd41c 100644 --- a/ops-center/src/components/LuckyGiftConfigSections.jsx +++ b/ops-center/src/components/LuckyGiftConfigSections.jsx @@ -355,58 +355,83 @@ export function LuckyGiftConfigSections({ appOptions, form, isCreate, onChange } id="ops-config-section-jackpot" > - -
- + +
+ + } + required + size="small" + value={form.jackpot_multipliers} + onChange={(event) => + onChange("jackpot_multipliers", event.target.value) + } /> - } - required - size="small" - value={form.jackpot_multipliers} - onChange={(event) => onChange("jackpot_multipliers", event.target.value)} - /> - - - - - + + +
+
+ + +
+ + +
+
+ + +
+ +
+
@@ -474,30 +499,15 @@ function SectionHeading({ detail, id, ok = false, title }) { ); } -function JackpotFlowSummary() { +function JackpotRuleGroup({ children, description, id, title }) { return ( -
-
- 1. 检查独立大奖资格 - - 大盘 RTP 通过后,还要同时满足用户本轮、近 48 小时返还条件和当日大奖次数限制。 - +
+
+

{title}

+

{description}

-
- 2. 尝试可支付大奖 - 资格通过后,从奖池余额和各项返奖上限都付得起的独立大奖倍率中选择一档。 -
-
- 3. 大奖成功直接结束 - 本抽直接采用独立大奖结果并累计一次大奖次数,不再叠加或继续抽普通奖档。 -
-
- 4. 未成功才走普通开奖 - - 大奖资格未通过或没有可支付倍率时,才按当前阶段普通概率开奖;普通同倍率不需要大奖资格,也不计大奖次数。 - -
-
+ {children} + ); } diff --git a/ops-center/src/components/LuckyGiftFieldHelp.jsx b/ops-center/src/components/LuckyGiftFieldHelp.jsx index 4db296e..07b2aec 100644 --- a/ops-center/src/components/LuckyGiftFieldHelp.jsx +++ b/ops-center/src/components/LuckyGiftFieldHelp.jsx @@ -23,7 +23,7 @@ const FIELD_HELP = { enabled: { summary: "决定新版本发布后是否立即用于真实送礼。关闭时规则仍会保存,但不会触发幸运开奖。", adjust: "首次配置或大幅调整时可先保持停用,核对资金比例和上限后再启用。", - constraint: "启用 dynamic_v3 前必须填完三项大奖 RTP 触发线和所有返奖上限。", + constraint: "启用 dynamic_v3 前必须填完三项大奖 RTP 触发线、累计消费门槛和所有返奖上限。", }, target_rtp_percent: { summary: "表示基础奖档的配置目标。例如填 98,代表各阶段的“倍率 × 基础概率”预计返还约为 98%。", @@ -117,34 +117,34 @@ const FIELD_HELP = { }, jackpot_multipliers: { summary: - "列出独立大奖路径可能采用的倍数,例如 200、500、1000。它不并入普通奖档概率:同一抽先尝试独立大奖,未成功才进入普通开奖。", + "列出 48 小时亏损补偿和累计消费达标两种大奖策略可能采用的倍数,例如 200、500、1000。它不并入普通奖档概率:大奖未成功才进入普通开奖。", adjust: "增加或调高倍数会放大独立大奖金额和资金压力;删除倍数只会减少可选大奖,不会删除普通奖档。", constraint: - "用逗号分隔,必须都大于 1 且从小到大;同倍率可以同时出现在普通奖档中。这里的“独立”只指触发条件、中奖概率和每日大奖次数独立;两条路径的真实返奖仍共用当前奖池、RTP 统计和六项返奖上限。普通命中不计入大奖次数。", + "用逗号分隔,必须都大于 1 且从小到大;同倍率可以同时出现在普通奖档中。这里的“独立大奖”是相对普通概率开奖而言;48 小时补偿与累计消费两种大奖策略共享每日大奖总次数、当前奖池、RTP 统计和六项返奖上限。普通命中不计入大奖次数。", }, jackpot_global_rtp_max_percent: { summary: - "系统每次达到结算流水后,会结束并重置一轮大盘 RTP。刚结算的本轮大盘 RTP 不高于这条线时,才继续检查用户的两项返还条件。", - adjust: "调高会让补偿大奖更容易进入候选;调低会要求全局返还更低才触发。", + "两种大奖策略共同使用的大盘安全线。48 小时亏损补偿使用产生资格时的结算轮次;累计消费达标在送礼时读取最新一个已经关闭的大盘结算轮次。实际大盘 RTP 小于或等于这条线时才通过,等于也通过。", + adjust: "调高会让两种大奖更容易进入候选;调低会要求大盘返还更低才触发。", constraint: - "必须大于 0% 且不超过 100%。大盘 RTP 等于本项配置值时可以通过;还必须同时满足用户本轮 RTP 严格低于本轮触发线、用户近 48 小时 RTP 严格低于长期触发线,且奖池和各项限额足够。它只控制独立大奖,不会阻止普通概率命中相同倍率。", + "必须大于 0% 且不超过 100%。没有可用的已结算大盘样本时不会发大奖;奖池余额、每日大奖次数和全部返奖上限也必须同时足够。用户两项 RTP 只属于 48 小时亏损补偿,不限制累计消费达标策略。它不会阻止普通概率命中相同倍率。", }, jackpot_user_round_rtp_max_percent: { summary: "系统每次重置大盘 RTP 时,会按完全相同的起止时间结算这个用户的本轮 RTP。例如 9 点重置一次、10 点再次重置,10 点结算时就检查用户在 9 点到 10 点这一轮的总返奖 ÷ 总消费。", adjust: "调高会让更多本轮返还偏低的用户通过;调低会更严格。用户本轮 RTP 必须严格小于配置值,刚好等于也不通过。", constraint: - "只统计当前 App、当前奖池和同一结算轮次内的实际消费与返奖;本轮没有消费样本时不通过。它必须与近 48 小时条件同时通过,不能只满足其中一项。", + "只统计当前 App、当前奖池和同一结算轮次内的实际消费与返奖;本轮没有消费样本时不通过。它必须与近 48 小时条件同时通过,不能只满足其中一项;该条件不限制累计消费达标策略。", }, jackpot_user_48h_rtp_max_percent: { summary: "判断这个用户近 48 小时是否整体返还偏低。本轮结算时,用户注册已满 48 小时,系统才会把结算时点向前 48 小时内的返奖金币加总,再除以同期实际消费金币;同一 App、奖池下跨房间、主播和设备合并统计,结果严格低于这条线才通过。", adjust: "调高会更容易通过,调低会更严格;结果等于配置值也不通过。例如近 48 小时消费 10,000、返奖 9,500,RTP 为 95%,配置 96% 时通过;返奖 9,600、RTP 为 96% 时不通过。", constraint: - "先看注册时间:注册未满 48 小时直接不通过;注册已满 48 小时后,不要求连续玩满 48 小时,只统计窗口内真实发生的消费和返奖。即使前 47 小时都没玩、最近只玩 5 分钟,也用这 5 分钟的数据计算;整段时间没有消费时不通过。它还必须与大盘条件、用户本轮条件同时通过,并受每日大奖次数、奖池余额和所有返奖上限限制。", + "先看注册时间:注册未满 48 小时直接不通过;注册已满 48 小时后,不要求连续玩满 48 小时,只统计窗口内真实发生的消费和返奖。即使前 47 小时都没玩、最近只玩 5 分钟,也用这 5 分钟的数据计算;整段时间没有消费时不通过。它还必须与大盘条件、用户本轮条件同时通过,并受每日大奖次数、奖池余额和所有返奖上限限制;该条件不限制累计消费达标策略。", }, max_jackpot_hits_per_user_day: { - summary: "限制同一用户在当前奖池的一个 UTC 自然日内,通过独立大奖路径合计最多真正拿到多少次大奖。", + summary: "限制同一用户在当前奖池的一个 UTC 自然日内,通过 48 小时亏损补偿和累计消费达标两种策略合计最多真正拿到多少次大奖。", adjust: "调高允许同一用户一天通过独立大奖路径获得更多奖励;调低会更早停止该用户当天的独立大奖,不保证转给其他用户。", constraint: @@ -152,10 +152,10 @@ const FIELD_HELP = { }, jackpot_spend_threshold_coins: { summary: - "这是旧规则按用户日累计消费保存独立大奖资格时使用的历史字段。当前规则已停用这条路径,保留数值只为读取和回传旧版本配置。", - adjust: "当前不可编辑;无论原值是多少,都不会增加用户出大奖的机会。", + "同一应用、奖池和用户在一个 UTC 自然日内,每累计实际消费满这个金币数,就产生 1 个待用大奖资格。例如门槛 800,000,消费达到 800,000、1,600,000、2,400,000 时会各产生 1 个。", + adjust: "调低会更频繁地产生消费大奖资格;调高会要求用户先累计更多消费。资格数量按每个 UTC 自然日独立计算。", constraint: - "当前大奖必须经过大盘条件,并同时满足用户本轮 RTP 与近 48 小时 RTP 两项条件;这个历史字段不能替代或绕过任何一项。", + "启用 dynamic_v3 时必须大于 0。达标当次子抽不能立即使用新资格,从下一子抽开始尝试;大盘、水位、每日次数或任一返奖上限不满足时资格不会作废,而是跨日保留到能够支付。该策略与 48 小时亏损补偿独立,不要求注册满 48 小时,也不检查用户本轮或近 48 小时 RTP。", }, max_single_payout: { summary: "限制一抽最多能返多少金币;超过这个金额的奖档当次不会发出。", diff --git a/ops-center/src/configForm.js b/ops-center/src/configForm.js index 1c8de77..b8fedd3 100644 --- a/ops-center/src/configForm.js +++ b/ops-center/src/configForm.js @@ -355,7 +355,9 @@ export function validateLuckyGiftForm(form) { errors.push("用户 48 小时 RTP 触发线必须大于 0 且不超过全局上限"); if (number("max_jackpot_hits_per_user_day") <= 0) errors.push("用户每日大奖次数必须大于 0"); if (number("jackpot_spend_threshold_coins") < 0) errors.push("大奖消费门槛不能小于 0"); - if (form[LEGACY_JACKPOT_RTP_MODE] && form.enabled && number("jackpot_spend_threshold_coins") <= 0) + // 历史版本可以继续读取和展示 0;只有运营要发布为启用状态时,才要求为重新启用的消费策略 + // 提供正数门槛。该判断不依赖旧字段别名,避免 canonical 新版本绕过校验直接上线。 + if (form.strategy_version === DYNAMIC_STRATEGY && form.enabled && number("jackpot_spend_threshold_coins") <= 0) errors.push("启用前必须填写用户日累计消费触发门槛(金币)"); for (const [field, label] of payoutCapFields) { if (number(field) < 0) errors.push(`${label}不能小于 0`); diff --git a/ops-center/src/configForm.test.js b/ops-center/src/configForm.test.js index 787fb84..c5792ec 100644 --- a/ops-center/src/configForm.test.js +++ b/ops-center/src/configForm.test.js @@ -72,6 +72,7 @@ describe("ops center lucky gift dynamic_v3 form", () => { expect(errors).toContain("启用前普通阶段至少一个最低充值门槛必须大于 0"); expect(errors).toContain("高阶充值门槛必须逐维不低于正常阶段且至少一项更高"); + 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 10312cd..3a56f4b 100644 --- a/ops-center/src/styles/index.css +++ b/ops-center/src/styles/index.css @@ -445,30 +445,37 @@ body { line-height: 1.5; } -.ops-jackpot-flow { +.ops-jackpot-rule-groups { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1px; - overflow: hidden; - border: 1px solid var(--border); - border-radius: var(--radius-control); - background: var(--border); + gap: var(--space-4); } -.ops-jackpot-flow > div { +.ops-jackpot-rule-group { display: grid; - align-content: start; - gap: 4px; - background: var(--bg-card-strong, #f8fafc); - padding: var(--space-2) var(--space-3); + gap: var(--space-3); + margin: 0; + padding: 0; } -.ops-jackpot-flow strong { +.ops-jackpot-rule-group + .ops-jackpot-rule-group { + border-top: 1px solid var(--border); + padding-top: var(--space-4); +} + +.ops-jackpot-rule-group__head { + display: grid; + gap: 3px; +} + +.ops-jackpot-rule-group__head h4 { + margin: 0; color: var(--text-primary); - font-size: 12px; + font-size: 13px; + line-height: 1.4; } -.ops-jackpot-flow span { +.ops-jackpot-rule-group__head p { + margin: 0; color: var(--text-secondary); font-size: 11px; line-height: 1.5; @@ -925,10 +932,6 @@ body { grid-template-columns: minmax(0, 1fr); } - .ops-jackpot-flow { - grid-template-columns: minmax(0, 1fr); - } - .ops-stage-tabs { display: flex; max-width: 100vw;