doge пре 2 дана
родитељ
комит
3983e1a923

+ 6 - 4
app/Support/PromotionRules.php

@@ -16,7 +16,7 @@ final class PromotionRules
     public const TASKS = ['online', 'login', 'invite', 'deposit', 'team_deposit', 'bet', 'team_bet'];
     public const DAILY = ['online', 'login'];
     public const PRIZES = ['0-666', '100', '50', '200', '350', '500', '150', '366'];
-    public const ORDINARY_POOL_RULE = '未完成本轮全部任务时,普通抽奖最多累计至600。任务完成后,未命中666时按剩余差额和达成概率控制单次奖额。可用奖项等概率,固定奖项按面额发放。';
+    public const ORDINARY_POOL_RULE = '未完成本轮全部任务时不会达到666,累计越接近666单次奖额越小。任务完成后,未命中666时按剩余差额和达成概率控制单次奖额。可用奖项等概率,固定奖项按面额发放。';
     public const REWARD_MODES = ['cash', 'coupon'];
     public const COUPON_CATEGORIES = ['self:sport', 'self:lottery'];
     private const COUPON_KEYS = ['reward_mode', 'coupon_valid_from', 'coupon_valid_until', 'coupon_categories', 'inviter_coupons', 'invitee_coupons'];
@@ -154,9 +154,11 @@ final class PromotionRules
             $pacedSpins = (int)min(10, ceil(20000 / max(1, $probabilityBps)));
             $maxAmount = min($remaining - 1, max($remaining - $pacedSpins * 100, intdiv($remaining - 1, $pacedSpins), 1));
         } else {
-            // Leave 66 for the qualified stage and award at most a quarter of the available room, rounded up to a cent.
-            $headroom = max(0, 60000 - $coins);
-            $maxAmount = intdiv($headroom + 3, 4);
+            // No hard stop at 600: always leave 1.00 for the qualified stage. Below 600 award at most a quarter
+            // of the room, above it at most a twentieth, so amounts shrink toward 666 without collapsing to zero.
+            $headroom = max(0, 66500 - $coins);
+            $divisor = $coins < 60000 ? 4 : 20;
+            $maxAmount = intdiv($headroom + $divisor - 1, $divisor);
         }
         if ($maxAmount === 0) return ['prize_index' => 0, 'amount_cents' => 0, 'reached' => false];
 

+ 1 - 1
docs/前端交付/melbet_sport-api/前台/独立活动/转盘活动-会员接口.md

@@ -72,7 +72,7 @@
 {"spin":{"id":12,"round_no":1,"prize_index":0,"prize":"0-666","amount":"65.75","coins":"666.00","reached":true,"conditions_complete":true,"probability":"20.00","created_at":"2026-10-26 15:00:00"},"spins":6,"replayed":false}
 ```
 
-未完成七项时概率固定0;全部完成后每次按后台配置概率判定(默认20%)。普通奖池不会让累计达到666:只抽累计后小于666的固定奖项及浮动奖项;可用奖项等概率,浮动奖项按分等概率取值。完成条件并命中时由浮动奖项补齐差额。界面展示服务端返回的 `ordinary_pool_rule`,固定面额不会被暗中截减。
+未完成七项时概率固定0,普通抽奖可持续累计但不会达到666,越接近666单次金额越小;全部完成后每次按后台配置概率判定(默认20%)。普通奖池不会让累计达到666:只抽累计后小于666的固定奖项及浮动奖项;可用奖项等概率,浮动奖项按分等概率取值。完成条件并命中时由浮动奖项补齐差额。界面展示服务端返回的 `ordinary_pool_rule`,固定面额不会被暗中截减。
 
 状态 `not_started/active/reached` 分别表示尚未初始化、正在累计、达到666待领取。达到后停抽,领取传本轮的 `round_no`。领取重试必须保留旧轮号,不用新轮号替换重试请求。奖金已预留,达到后下架也可领取。
 

+ 173 - 0
docs/总后台/余额宝配置-后台接入.md

@@ -0,0 +1,173 @@
+# 余额宝配置(前端接入)
+
+接口项目:`bot-28` 总后台 API,请求头带 `Authorization: Bearer <admin_token>`。成功返回 `code=0`,失败返回 `code=-3`,`msg` 为错误原因。
+
+余额宝已从“活动列表”中独立出来。以后余额宝的配置在**余额宝持仓页**(`/admin/yuebao/item` 对应的页面)维护。
+
+---
+
+## 一、前端要改的地方
+
+| 位置 | 改动 |
+|---|---|
+| 余额宝持仓列表页 | 在页面顶部(查询栏旁边)加一个“余额宝配置”按钮,点击后打开配置弹窗,弹窗用法见下文 |
+| 活动列表 · 类型下拉 | `GET /admin/ActivityReward/options` 返回的 `activity_types` 已经不含 `6 余额宝`,按接口返回渲染即可。如果前端写死了选项,请删掉“余额宝” |
+| 活动列表 | `GET /admin/ActivityReward` 不再返回余额宝那一行 |
+| 活动编辑弹窗 | 去掉 `type=6` 时显示“日化率”“连续存款天数”的逻辑 |
+| 活动接口拦截 | 通过 `POST /admin/ActivityReward/submit` 新建或编辑余额宝、通过 `POST /admin/ActivityReward/delete` 删除余额宝,都会返回 `code=-3`,提示“余额宝请在余额宝配置中维护” |
+
+余额宝配置**只有一条**,弹窗里只有保存,没有新增、删除和列表。
+
+---
+
+## 二、接口
+
+| 方法 | 路径 | 说明 |
+|---|---|---|
+| GET | `/admin/yuebao/config` | 读取配置,打开弹窗时调用 |
+| POST | `/admin/yuebao/config` | 保存配置。还没有配置时会新建,已有配置时直接覆盖 |
+
+### 1. 读取配置
+
+```http
+GET /admin/yuebao/config
+```
+
+还没有配置时,`data` 为 `null`,弹窗按空表单展示。
+
+已有配置时的返回示例:
+
+```json
+{
+  "code": 0,
+  "msg": "OK",
+  "data": {
+    "id": 1,
+    "title": "余额宝",
+    "sub_title": "余额宝",
+    "start_time": "2026-04-01",
+    "end_time": "2027-12-31",
+    "status": 1,
+    "lang": "zh",
+    "detail_image": "https://xxx/app.png",
+    "pc_image": "https://xxx/pc.png",
+    "content": "<div>...</div>",
+    "publish_platforms": [],
+    "platform_label": "全部平台",
+    "quota_enabled": false,
+    "budget": null,
+    "budget_used": "0.01",
+    "reward_quota": null,
+    "rewarded_count": 0,
+    "register_time_limited": false,
+    "register_start_time": null,
+    "register_end_time": null,
+    "register_platforms": [],
+    "deposit_limited": false,
+    "deposit_min": null,
+    "deposit_max": null,
+    "show_ineligible": true,
+    "daily_interest_rate": "0.246",
+    "number_of_consecutive_days_of_deposit": 7
+  }
+}
+```
+
+返回里没有 `type`(类型)、`game_category`(游戏类型)、`click_type`/`link*`/`click_position`(点击行为)、`sort`(排序)这几个字段,弹窗里也不要再显示这四项。
+
+### 2. 保存配置
+
+```http
+POST /admin/yuebao/config
+Content-Type: application/json
+```
+
+不需要传 `id`,后端会自动找到那一条配置。
+
+```json
+{
+  "title": "余额宝",
+  "sub_title": "余额宝",
+  "start_time": "2026-04-01 00:00:00",
+  "end_time": "2027-12-31 00:00:00",
+  "detail_image": "https://xxx/app.png",
+  "pc_image": "https://xxx/pc.png",
+  "status": 1,
+  "lang": "zh",
+  "content": "<div>...</div>",
+  "publish_platforms": [],
+  "budget": null,
+  "register_time_limited": false,
+  "register_platforms": [],
+  "deposit_limited": false,
+  "show_ineligible": true,
+  "daily_interest_rate": "0.246",
+  "number_of_consecutive_days_of_deposit": 7
+}
+```
+
+成功返回 `{"code": 0, "data": []}`。保存成功后请重新调一次 GET 刷新弹窗。
+
+---
+
+## 三、字段说明
+
+字段和原来活动编辑弹窗一致,只是去掉了类型、游戏类型、点击行为、排序这四项。
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `title` | string | 是 | 活动名称,最多 140 字 |
+| `sub_title` | string | 是 | 副标题,最多 140 字 |
+| `start_time` / `end_time` | string | 否 | 活动开始和结束时间,格式 `Y-m-d H:i:s`。两个要么都填,要么都留空,都留空表示永久活动。结束时间不能早于开始时间。GET 返回的格式是 `Y-m-d`,和活动列表一样 |
+| `detail_image` | string | 是 | App 详情图片,推荐 740×280px |
+| `pc_image` | string | 是 | PC 详情图片,推荐 1920×292px |
+| `status` | int | 是 | `1` 上架,`0` 下架 |
+| `lang` | string | 是 | 语种。**会员端只认 `zh`**,选其他语种后余额宝新转入会按日化率 0 计算,请保持“中文简体” |
+| `content` | string | 否 | 富文本内容 |
+| `publish_platforms` | string[] | 否 | 发布平台,可选 `PC`、`H5`、`APP`。传 `[]` 表示全部平台 |
+| `budget` | string | 见下 | 金额预算。传 `null` 或留空表示不限,传 `0` 表示停止发奖 |
+| `reward_quota` | int | 见下 | 获奖人数上限。传 `null` 表示不限 |
+| `register_time_limited` | bool | 否 | 会员注册区间:`false` 不限,`true` 按注册时间限制 |
+| `register_start_time` / `register_end_time` | string | `register_time_limited=true` 时必填 | 格式 `Y-m-d H:i:s`,结束时间不能早于开始时间 |
+| `register_platforms` | string[] | 否 | 会员注册平台,可选 `PC`、`H5`、`APP`。传 `[]` 表示不限 |
+| `deposit_limited` | bool | 否 | 会员存款总额:`false` 不限,`true` 按生涯存款限制 |
+| `deposit_min` / `deposit_max` | number | `deposit_limited=true` 时必填 | 最多 2 位小数,`deposit_max` 不能小于 `deposit_min` |
+| `show_ineligible` | bool | 否 | 会员不符合条件时是否仍展示活动 |
+| `daily_interest_rate` | number | 是 | **日化率,单位是 %**。`0.246` 表示每天 0.246%,取值范围 0~100 |
+| `number_of_consecutive_days_of_deposit` | int | 是 | **连续存款天数**,即每笔转入多少天后自动转出,取值范围 1~3650 |
+
+### 预算和名额:二选一,看 `quota_enabled`
+
+| `quota_enabled` | 表单显示 | 提交 | 回显已发放 |
+|---|---|---|---|
+| `false`(老配置,现网是这种) | “金额预算”输入框 | `budget`,不要传 `reward_quota` | `已发金额:{budget_used} 元` |
+| `true`(新建的配置) | “获奖人数上限”输入框 | `reward_quota`,**不要传 `budget`**,否则会报错 | `已获奖:{rewarded_count} 人` |
+
+`quota_enabled`、`budget_used`、`rewarded_count` 只用于展示,前端不要提交。
+
+### 不用填的字段
+
+- 没有传的字段,后端会保留原来的值。
+- `register_time_limited=false` 时,后端会把注册起止时间清空。
+- `deposit_limited=false` 时,后端会把存款上下限清空。
+
+---
+
+## 四、常见错误(`code=-3`)
+
+| 场景 | 说明 |
+|---|---|
+| 没填日化率或连续存款天数 | 提示“请填写日化率”或“请填写连续存款天数” |
+| 日化率小于 0 或大于 100,或连续存款天数小于 1 | 参数校验失败 |
+| 开始和结束时间只填了其中一个 | 提示“活动开始和结束时间必须同时填写或同时留空” |
+| 新配置(`quota_enabled=true`)提交了 `budget` | 参数校验失败 |
+| 在活动列表接口里操作余额宝 | 提示“余额宝请在余额宝配置中维护” |
+
+---
+
+## 五、业务说明(供运营参考)
+
+- 修改日化率和连续存款天数后,只对**新转入**的持仓生效。已有持仓仍按转入时记录的日化率和到期时间计算。
+- 下架配置或配置过期后,会员仍然可以新转入,但新持仓的日化率为 0(不产生利息),7 天后到期自动转出。已有持仓照常结算。
+- 会员不符合资格条件,或者预算/名额已用完时,新转入会被拒绝。
+- 资格条件和预算/名额的规则与原来的活动一致。

+ 5 - 2
tests/Integration/PromotionActivitiesTest.php

@@ -357,8 +357,11 @@ class PromotionActivitiesTest extends TestCase
     {
         // Choose the floating sector, force a miss, then take the maximum allowed amount.
         $maxFloat = fn ($min, $max) => $min === 0 ? 0 : $max;
-        $this->assertSame(15000, PromotionRules::draw(0, false, '20.00', $maxFloat)['amount_cents']);
-        $this->assertSame(0, PromotionRules::draw(60000, false, '100.00', $maxFloat)['amount_cents']);
+        $this->assertSame(16625, PromotionRules::draw(0, false, '20.00', $maxFloat)['amount_cents']);
+        // 未完成任务时600后不再归零,按剩余空间的1/20继续发放,并始终给完成阶段留1元。
+        $this->assertSame(325, PromotionRules::draw(60000, false, '100.00', $maxFloat)['amount_cents']);
+        $this->assertSame(1, PromotionRules::draw(66499, false, '100.00', $maxFloat)['amount_cents']);
+        $this->assertSame(0, PromotionRules::draw(66500, false, '100.00', $maxFloat)['amount_cents']);
         $fixed = PromotionRules::draw(0, false, '20.00', fn ($min, $max) => $max);
         $this->assertSame(6, $fixed['prize_index']);
         $this->assertSame(15000, $fixed['amount_cents']);