doge 2 дней назад
Родитель
Сommit
11746d21f9

+ 28 - 8
app/Services/PromotionService.php

@@ -439,21 +439,41 @@ class PromotionService
             $activityId = (int) ($choice->activity_id ?? 0);
             if (!$activityId && FirstDepositChoice::ready() && !FirstDepositChoice::legacyOrder($orderSource, (int) $deposit->related_id)) return;
 
-            $activities = ActivityPeriod::active(PromotionActivity::where('type', Rules::FIRST_DEPOSIT)->where('status', 1), now()->timestamp);
-            $ids = $activityId ? [$activityId] : $activities->orderByDesc('sort')->orderByDesc('id')->pluck('id');
+            // 多个首充活动按档位发放:在所有可参加的首充活动里,取充值金额达到门槛、奖励最高的一个(份数或额度用完时取下一档)。
+            // 会员确认的禁止游戏大类(全部首充活动统一)沿用确认时的快照;选中的那个活动沿用确认时的完整规则。
+            // 只在会员确认时所在的端(PC/H5/APP/TG)发布的活动里选;上线前的选择没有记录端,按会员确认的那个活动发布的端。
+            $snapshot = $activityId ? $this->json($choice->rule_snapshot) : null;
+            $platforms = isset($snapshot['platform']) ? [$snapshot['platform']] : [];
+            unset($snapshot['platform']);
+            if ($activityId && !$platforms && ($confirmed = PromotionActivity::find($activityId))) {
+                $platforms = array_values(array_filter(['PC', 'H5', 'APP', 'TG'], fn ($p) => ActivityEligibilityService::isPublishedOn($confirmed, $p)));
+            }
+            $ids = ActivityPeriod::active(PromotionActivity::where('type', Rules::FIRST_DEPOSIT)->where('status', 1), now()->timestamp)
+                ->orderByDesc('sort')->orderByDesc('id')->pluck('id')->all();
+            if ($activityId) $ids = array_values(array_unique(array_merge([$activityId], $ids)));
+            $creditedAt = (string)$deposit->getRawOriginal('created_at');
+            $candidates = [];
             foreach ($ids as $id) {
                 $activity = PromotionActivity::whereKey($id)->lockForUpdate()->first();
                 if (!$activity || (int)$activity->status !== 1 || (int)$activity->type !== Rules::FIRST_DEPOSIT
                     || !ActivityPeriod::contains($activity->start_time, $activity->end_time, now()->timestamp)) continue;
-                if (!$activityId && !PromotionTranslations::select($activity->toArray(), $member->language ?: 'zh')) continue;
+                if ((int)$activity->id !== $activityId && !PromotionTranslations::select($activity->toArray(), $member->language ?: 'zh')) continue;
+                if ($activityId && (int)$activity->id !== $activityId
+                    && !array_filter($platforms, fn ($p) => ActivityEligibilityService::isPublishedOn($activity, $p))) continue;
                 $published = DB::table('promotion_campaigns')->where('activity_id', $activity->id)->sharedLock()->value('first_published_at');
-                $creditedAt = (string)$deposit->getRawOriginal('created_at');
                 if (!$published || $creditedAt < $published || !ActivityPeriod::contains($activity->start_time, $activity->end_time, strtotime($creditedAt))) continue;
                 if (!ActivityEligibilityService::evaluate($activity, ActivityEligibilityService::memberContext($member->member_id))['is_eligible']) continue;
-                // The member accepted one set of rules. Pre-cutover orders keep their legacy reward without new restrictions.
-                $rules = $activityId ? $this->json($choice->rule_snapshot) : array_replace($this->rules($activity), ['forbidden_categories' => []]);
-                if (!$rules) continue;
-                if (bccomp((string)$deposit->amount, $rules['min_deposit'], 10) < 0) continue;
+                // Pre-cutover orders keep their legacy reward without new restrictions.
+                if ($snapshot === null) $rules = array_replace($this->rules($activity), ['forbidden_categories' => []]);
+                elseif ((int)$activity->id === $activityId) $rules = $snapshot;
+                else $rules = array_replace($this->rules($activity), ['forbidden_categories' => $snapshot['forbidden_categories'] ?? []]);
+                if (!$rules || bccomp((string)$deposit->amount, (string)$rules['min_deposit'], 10) < 0) continue;
+                $candidates[] = [$activity, $rules];
+            }
+            // 奖励高的优先;奖励相同时会员确认的活动优先,其次按活动排序。
+            usort($candidates, fn ($a, $b) => bccomp((string)$b[1]['reward_amount'], (string)$a[1]['reward_amount'], 10)
+                ?: (((int)$b[0]->id === $activityId) <=> ((int)$a[0]->id === $activityId)));
+            foreach ($candidates as [$activity, $rules]) {
                 try { $this->capacity($activity, $member, $rules['reward_amount']); } catch (ValidationException $e) { continue; }
                 $account = $this->account($activity, $member);
                 $reward = $this->reserve($activity, $member, $account, 1, $rules['reward_amount'], $rules + ['deposit_log_id' => $deposit->id]);

+ 4 - 3
app/Support/FirstDepositChoice.php

@@ -60,8 +60,9 @@ final class FirstDepositChoice
             $activityId = null;
             $snapshot = null;
             if ($joined) {
-                [$activityId, $rules] = self::confirmedRules($member, $source, $offer);
-                $snapshot = json_encode($rules, JSON_THROW_ON_ERROR);
+                [$activityId, $rules, $platform] = self::confirmedRules($member, $source, $offer);
+                // platform:会员在哪个端确认的,到账按档位发奖时只在这个端发布的首充活动里选
+                $snapshot = json_encode($rules + ['platform' => $platform], JSON_THROW_ON_ERROR);
             }
             $now = PromotionFacts::now();
             DB::table(self::TABLE)->upsert([
@@ -91,7 +92,7 @@ final class FirstDepositChoice
             || !hash_equals(self::offer($id, $platform, $rules, $activity->start_time, $activity->end_time), $offer)) {
             self::invalid('首充活动已变更,请刷新后重新确认');
         }
-        return [$id, $rules];
+        return [$id, $rules, $platform];
     }
 
     public static function ready(): bool

+ 1 - 1
docs/前端交付/README.md

@@ -4,7 +4,7 @@
 
 ## 首充活动提交(2026-11-12)
 
-- [首充活动返回多个-前端修改单](首充活动返回多个-前端修改单.md):`GET /api/promotion/firstDeposit` 原来只在 `activity` 里返回一个首充活动;新增 `activities` 数组,返回当前平台全部可参加的首充活动,每项带自己的 `first_deposit_offer` 和 `selected`。旧字段 `activity`、`first_deposit_offer` 保留,等于第一个。
+- [首充活动返回多个-前端修改单](首充活动返回多个-前端修改单.md):`GET /api/promotion/firstDeposit` 原来只在 `activity` 里返回一个首充活动;新增 `activities` 数组,返回当前平台全部可参加的首充活动,每项带自己的 `first_deposit_offer` 和 `selected`。旧字段 `activity`、`first_deposit_offer` 保留,等于第一个。到账时按充值金额自动取最高档发奖(100 送 10、1000 送 50,充 1000 送 50),前端只需一个「参加」开关。
 - [首充活动提交-前端修正单](首充活动提交-前端修正单.md):提示「请刷新首充活动并确认参加」是因为提交「参加」时没带 `first_deposit_offer`。首笔充值到账前可以随时修改;提交「参加」都要带上最近一次接口返回的 `first_deposit_offer`。已参加的会员再提交「参加」不带凭据也会保持原选择。
 
 ## 公司营业报表(2026-11-12)

+ 5 - 6
docs/前端交付/首充活动提交-前端修正单.md

@@ -31,8 +31,8 @@
 const res = await getFirstDeposit({ platform })       // GET /api/promotion/firstDeposit
 this.firstDeposit = res.data                         // res.data.activities 里每项都有 first_deposit_offer
 
-// 会员选中某个活动参加:传这一项的凭据
-const saved = await saveFirstDepositChoice({ platform, join: 1, first_deposit_offer: item.first_deposit_offer })
+// 会员打开「参加」:传任意一项的凭据(到账时按金额自动取最高档)
+const saved = await saveFirstDepositChoice({ platform, join: 1, first_deposit_offer: res.data.activities[0].first_deposit_offer })
 this.firstDeposit = saved.data                       // 返回结构同上,activities 已经刷新
 ```
 
@@ -42,11 +42,10 @@ this.firstDeposit = saved.data                       // 返回结构同上,act
 
 | 场景 | 接口 | 参数 |
 |---|---|---|
-| 选中某个活动参加 | `POST /api/promotion/firstDepositChoice` | `platform`、`join=1`、所选活动的 `first_deposit_offer` |
-| 改为参加另一个活动 | `POST /api/promotion/firstDepositChoice` | `platform`、`join=1`、另一个活动的 `first_deposit_offer` |
+| 打开「参加首充活动」 | `POST /api/promotion/firstDepositChoice` | `platform`、`join=1`、`first_deposit_offer` |
 | 取消,改为「不参加」 | `POST /api/promotion/firstDepositChoice` | `platform`、`join=0` |
-| 不参加后又改回「参加」 | `POST /api/promotion/firstDepositChoice` | `platform`、`join=1`、所选活动的 `first_deposit_offer`(用上一次返回里的值) |
-| 充值下单时一起提交「参加」 | `POST /api/wallet/recharge`、`/api/wallet/createPay`、`/api/wallet/paymentOrder` | `join_first_deposit=1`、所选活动的 `first_deposit_offer` |
+| 不参加后又改回「参加」 | `POST /api/promotion/firstDepositChoice` | `platform`、`join=1`、`first_deposit_offer`(用上一次返回里的值) |
+| 充值下单时一起提交「参加」 | `POST /api/wallet/recharge`、`/api/wallet/createPay`、`/api/wallet/paymentOrder` | `join_first_deposit=1`、`first_deposit_offer` |
 | 充值下单时一起提交「不参加」 | 同上 | `join_first_deposit=0` |
 | 充值下单,不改动之前的选择 | 同上 | 两个参数都不传 |
 

+ 28 - 13
docs/前端交付/首充活动返回多个-前端修改单.md

@@ -2,11 +2,12 @@
 
 ## 问题
 
-活动页能看到两个首充活动,但充值页的接口 `GET /api/promotion/firstDeposit` 只返回一个:`activity` 是一个对象,后端只放了第一个可参加的活动。
+1. 活动页能看到两个首充活动,但充值页的接口 `GET /api/promotion/firstDeposit` 只返回一个:`activity` 是一个对象,后端只放了第一个可参加的活动。
+2. 有「100 送 10」「1000 送 50」两个首充活动,会员充值 1000 只送了 10:会员参加时提交的是第一个活动的凭据,到账只按这个活动发奖。
 
 ## 修改
 
-`GET /api/promotion/firstDeposit?platform=PC` 新增 **`activities` 数组**,返回当前平台**全部**可参加的首充活动。每个活动带自己的 `first_deposit_offer`(参加时用的凭据)和 `selected`(是否就是会员当前参加的活动)。
+`GET /api/promotion/firstDeposit?platform=PC` 新增 **`activities` 数组**,返回当前平台**全部**可参加的首充活动。每个活动带自己的 `first_deposit_offer`(参加时用的凭据)和 `selected`。到账时按充值金额自动取最高档发奖,见下面「发奖规则」。
 
 ```json
 {
@@ -59,7 +60,7 @@
 | `reward_amount` | 奖励金额 |
 | `turnover_ratio` | 打码倍数 |
 | `start_time` / `end_time` | 秒级时间戳,`null` 表示长期 |
-| `selected` | 是否就是会员当前参加的活动 |
+| `selected` | 是否就是会员确认参加时提交的那个活动 |
 | `first_deposit_offer` | 这个活动的凭据,会员选这个活动参加时原样提交;已有充值记录时为 `null` |
 
 其他说明:
@@ -72,25 +73,39 @@
 
 活动按后台设置的**发布平台**过滤。比如一个活动只勾了 PC,传 `platform=H5` 时就不会返回它,所以会出现 PC 两个、H5 一个。如果 H5 也要显示,在后台把这个活动的发布平台勾上 H5。
 
+## 发奖规则:按充值金额自动取最高档
+
+多个首充活动按**档位**发放。会员只要选择「参加首充活动」,首笔充值到账时,后端在所有可参加的首充活动里,取**充值金额达到门槛、奖励最高**的一个发放:
+
+| 首充活动 | 充值 50 | 充值 500 | 充值 1000 | 充值 2000 |
+|---|---|---|---|---|
+| 100 送 10、1000 送 50 | 不送 | 送 10 | 送 50 | 送 50 |
+
+- 和会员提交的是哪个活动的凭据无关。比如会员确认时提交的是「100 送 10」,充 1000 仍然送 50。
+- 打码倍数按实际发放的那个活动计算。
+- 高档的份数或额度用完时,发下一档。
+- 只在会员确认时所在的端(PC / H5 / APP)发布的活动里选。只发布在 PC 的活动,H5 上确认的会员拿不到。
+- 禁止投注的游戏大类对全部首充活动统一,按会员确认时的设置。
+
 ## 前端要改的
 
-1. 充值页的首充区域改成按 `activities` 渲染列表,每项显示标题、图片、最低充值、奖励、打码倍数。
-2. 会员只能参加**其中一个**活动(单选),`selected=true` 的那项显示为已参加。
-3. 提交「参加」时,传**所选那一项**的 `first_deposit_offer`:
+1. 充值页的首充区域改成按 `activities` 渲染,**把所有档位都展示出来**(标题、图片、最低充值、奖励、打码倍数),让会员知道充多少送多少。
+2. 只需要一个「参加首充活动」开关,**不用让会员在档位之间单选**。
+3. 打开开关时提交「参加」,传 `activities` 里**任意一项**的 `first_deposit_offer` 即可(建议用第一项):
 
 ```json
 POST /api/promotion/firstDepositChoice
-{"platform": "PC", "join": 1, "first_deposit_offer": "3.PC.5e88…"}
+{"platform": "PC", "join": 1, "first_deposit_offer": "5.PC.0c1f…"}
 ```
 
-充值下单时一起提交也一样:`join_first_deposit=1`,加上所选活动的 `first_deposit_offer`。
+充值下单时一起提交也一样:`join_first_deposit=1`,加上任意一项的 `first_deposit_offer`。
 
-4. 想换成另一个活动,直接用另一项的 `first_deposit_offer` 再提交一次;不参加就传 `join=0`。
-5. `firstDepositChoice` 返回的结构和 `firstDeposit` 相同,用返回的数据刷新列表即可,不用再调一次 `firstDeposit`。
+4. 关闭开关传 `join=0`。
+5. 已参加的状态看外层的 `joined`,不要看 `selected`。
+6. `firstDepositChoice` 返回的结构和 `firstDeposit` 相同,用返回的数据刷新即可,不用再调一次 `firstDeposit`。
 
 ## 验收
 
 1. PC 上有两个首充活动时,`activities` 返回两个;H5 只返回发布在 H5 的活动。
-2. 选第二个活动参加,返回里这一项 `selected=true`,`selection.activity_id` 是它的 ID。
-3. 改选第一个活动,`selected` 跟着变。
-4. 首笔充值到账后,`can_choose=false`,每项的 `first_deposit_offer` 为 `null`。
+2. 有「100 送 10」「1000 送 50」两个活动,会员参加后首充 1000,到账送 50;另一个会员首充 500,送 10;首充 50 不送。
+3. 首笔充值到账后,`can_choose=false`,每项的 `first_deposit_offer` 为 `null`。

+ 4 - 4
docs/前端接口/独立活动/首充活动参加与禁止游戏-会员接入.md

@@ -18,7 +18,7 @@
 | `can_choose` | 是否显示“参加首充活动”选项:没有充值记录且当前有可参加的首充活动时为 `true` |
 | `joined` | 当前保存的选择,默认 `false`(不参加) |
 | `selection` | 已确认的选择 `{activity_id, rules}`,未确认时为 `null`。用 `rules` 展示已经确认的充值门槛、奖励、打码倍数和禁投大类 |
-| `activities` | **当前平台全部可参加的首充活动**(数组,可能有多个),会员选其中一个参加,见下表;没有可参加的活动时为空数组 |
+| `activities` | **当前平台全部可参加的首充活动**(数组,可能有多个),全部展示给会员;参加时传任意一项的凭据,到账按金额自动取最高档,见 2.3。没有可参加的活动时为空数组 |
 | `first_deposit_offer` | 旧字段,等于 `activities[0].first_deposit_offer`。新前端用 `activities` 里每项自己的凭据 |
 | `activity` | 旧字段,等于 `activities[0]`(不含 `image`、`selected`、`first_deposit_offer`),没有时为 `null`。新前端用 `activities` |
 | `forbidden_categories` | 参加后活动期间禁止投注的游戏大类,数组 `[{value,label}]`,`label` 按请求头语言返回;没有限制时为空数组 |
@@ -101,7 +101,7 @@
 
 | 取值 | 含义 |
 |---|---|
-| `1` | 确认参加,同时传所选活动的 `first_deposit_offer` |
+| `1` | 确认参加,同时传 `activities[]` 里任意一项的 `first_deposit_offer` |
 | `0` | 不参加 |
 | 不传 | 不改动之前保存的选择 |
 
@@ -126,7 +126,7 @@
 |---|---|
 | `platform` | `PC/H5/APP` |
 | `join` | `1` 参加,`0` 不参加 |
-| `first_deposit_offer` | `join=1` 时必传,传会员所选活动在 `activities[]` 里的凭据(同一 `platform`) |
+| `first_deposit_offer` | `join=1` 时必传,传 `activities[]` 里任意一项的凭据(同一 `platform`) |
 
 成功返回与 `GET /api/promotion/firstDeposit` 相同的结构。已有充值记录时返回失败,`msg` 为 `已有充值记录,不能再选择首充活动`。
 
@@ -135,7 +135,7 @@
 - 以首笔充值到账前最后一次保存的选择为准。切换前已下单且没有选择记录的充值按原有首充规则处理。
 - 首笔充值到账后不能再修改选择。
 - 首笔充值时不参加,第二笔及以后的充值都不再算首充,不会再有首充奖励。
-- 奖励按确认的活动及条款发放,首笔充值仍需满足门槛,且活动需有效并有可用奖励额度。
+- 有多个首充活动时按档位发放:首笔充值到账时,在会员确认时所在端发布的首充活动里,取充值金额达到门槛、奖励最高的一个(份数或额度用完时取下一档),和提交的是哪个活动的凭据无关。会员确认的那个活动按确认时的条款发放;禁止游戏大类按确认时的设置。活动需有效并有可用奖励额度。
 - Telegram 会员可在充值菜单或用 `/firstdeposit` 确认选择。
 
 ## 3. 下注和进入游戏被拒绝

+ 46 - 0
tests/Integration/PromotionActivitiesTest.php

@@ -993,6 +993,52 @@ class PromotionActivitiesTest extends TestCase
         foreach ($this->service->listing(2, 'H5', 'zh', 8)['data'] as $item) $this->assertSame(['third:5'], $item['rules']['forbidden_categories']);
     }
 
+    public function test_first_deposit_pays_the_highest_tier_the_deposit_reaches(): void
+    {
+        // 100 送 10、1000 送 50:会员确认的是 100 送 10(第一个活动),充 1000 发 50,充 500 发 10,充 50 不发。
+        $low = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 10]);
+        $high = $this->activity(8, ['min_deposit' => 1000, 'reward_amount' => 50]);
+        $pick = function (int $member, int $activityId) {
+            $item = collect($this->service->listing($member, 'H5', 'zh', 8)['data'])->firstWhere('activity_id', $activityId);
+            $offer = FirstDepositChoice::offer($activityId, 'H5', $item['rules'], $item['start_time'], $item['end_time']);
+            $this->assertTrue(FirstDepositChoice::record(User::find($member), true, 'test', $offer));
+        };
+        $pick(1, $low);
+        $this->service->onDeposit($this->deposit(1, '1000'));
+        $reward = DB::table('promotion_rewards')->where('user_id', 1)->first();
+        $this->assertSame([$high, '50.00'], [(int) $reward->activity_id, number_format((float) $reward->amount, 2, '.', '')]);
+        $this->assertArrayNotHasKey('platform', json_decode($reward->rule_snapshot, true));
+
+        $pick(2, $high);
+        $this->service->onDeposit($this->deposit(2, '500'));
+        $this->assertSame($low, (int) DB::table('promotion_rewards')->where('user_id', 2)->value('activity_id'), 'falls back to the tier the deposit reaches');
+
+        $this->member(3); $pick(3, $low);
+        $this->service->onDeposit($this->deposit(3, '50'));
+        $this->assertSame(0, DB::table('promotion_rewards')->where('user_id', 3)->count());
+
+        // 高档份数用完时发下一档。
+        DB::table('promotion_activities')->where('id', $high)->update(['reward_quota' => 1, 'rewarded_count' => 1]);
+        $this->member(4); $pick(4, $low);
+        $this->service->onDeposit($this->deposit(4, '2000'));
+        $this->assertSame($low, (int) DB::table('promotion_rewards')->where('user_id', 4)->value('activity_id'));
+
+        // 只发布在别的端的活动不参与:H5 确认的会员不会拿到只发布在 PC 的高档。
+        DB::table('promotion_activities')->where('id', $high)->update(['reward_quota' => null, 'publish_platforms' => '["PC"]']);
+        $this->member(5); $pick(5, $low);
+        $this->service->onDeposit($this->deposit(5, '1000'));
+        $this->assertSame($low, (int) DB::table('promotion_rewards')->where('user_id', 5)->value('activity_id'));
+
+        // 上线前的选择没有记录端:按确认的活动发布的端判断。
+        DB::table('promotion_activities')->where('id', $high)->update(['publish_platforms' => '["PC","H5","APP"]']);
+        $this->member(6); $pick(6, $low);
+        $row = DB::table('promotion_first_deposit_choices')->where('user_id', 6);
+        $legacy = json_decode($row->value('rule_snapshot'), true); unset($legacy['platform']);
+        $row->update(['rule_snapshot' => json_encode($legacy)]);
+        $this->service->onDeposit($this->deposit(6, '1000'));
+        $this->assertSame($high, (int) DB::table('promotion_rewards')->where('user_id', 6)->value('activity_id'));
+    }
+
     public function test_first_deposit_pays_the_confirmed_campaign_and_terms(): void
     {
         $admin = new PromotionAdminService();