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

+ 10 - 27
app/Services/PromotionService.php

@@ -425,7 +425,7 @@ class PromotionService
             || !in_array($deposit->change_type, FirstDepositChoice::DEPOSIT_TYPES, true) || bccomp((string)$deposit->amount, '0', 10) <= 0) return;
         $member = User::where('member_id', $deposit->member_id)->first();
         if (!$member || (int)$member->from === 2) return;
-        DB::transaction(function () use ($member, $deposit, $orderSource) {
+        DB::transaction(function () use ($member, $deposit) {
             $wallet = Wallet::where('member_id', $member->member_id)->lockForUpdate()->first();
             if (!$wallet) $this->invalid('会员钱包不存在');
             $first = DB::table('balance_logs')->where('member_id', $member->member_id)->where('type', 1)->where('amount', '>', 0)
@@ -434,45 +434,28 @@ class PromotionService
             $inserted = DB::table('promotion_first_deposits')->insertOrIgnore(['user_id' => $member->id, 'deposit_log_id' => $deposit->id,
                 'created_at' => PromotionFacts::now(), 'updated_at' => PromotionFacts::now()]);
             if (!$inserted) return;
-            $choice = FirstDepositChoice::selection((int) $member->id, true);
-            if ($choice && !(int) $choice->joined) return;
-            $activityId = (int) ($choice->activity_id ?? 0);
-            if (!$activityId && FirstDepositChoice::ready() && !FirstDepositChoice::legacyOrder($orderSource, (int) $deposit->related_id)) return;
-
-            // 多个首充活动按档位发放:在所有可参加的首充活动里,取充值金额达到门槛、奖励最高的一个(份数或额度用完时取下一档)。
-            // 会员确认的禁止游戏大类(全部首充活动统一)沿用确认时的快照;选中的那个活动沿用确认时的完整规则。
-            // 只在会员确认时所在的端(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 ((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;
+                if (!PromotionTranslations::select($activity->toArray(), $member->language ?: 'zh')) continue;
                 $published = DB::table('promotion_campaigns')->where('activity_id', $activity->id)->sharedLock()->value('first_published_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;
-                // 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;
+                $rules = $this->rules($activity);
+                // 全部大类都禁止且有打码量时打码量无法完成,这样的活动不发。
+                if (!Rules::hasPlayableCategory($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)));
+            // 奖励高的优先;奖励相同时按活动排序。
+            usort($candidates, fn ($a, $b) => bccomp((string)$b[1]['reward_amount'], (string)$a[1]['reward_amount'], 10));
             foreach ($candidates as [$activity, $rules]) {
                 try { $this->capacity($activity, $member, $rules['reward_amount']); } catch (ValidationException $e) { continue; }
                 $account = $this->account($activity, $member);

+ 4 - 0
docs/前端交付/README.md

@@ -2,6 +2,10 @@
 
 本目录是2026-10-26的接口文档交付副本,按**项目 → 前台/后台/代理端**组织。
 
+## 首充活动默认参加(2026-11-12)
+
+- [首充活动默认参加-前端修改单](首充活动默认参加-前端修改单.md):首充不再需要会员勾选。首笔充值到账时自动按金额发放最高档(100 送 10、1000 送 50,充 1000 送 50)。充值页去掉勾选框,下单不再传 `join_first_deposit`、`first_deposit_offer`,不再调用 `firstDepositChoice`;`firstDeposit` 接口只用来展示档位和限制说明。**取代下面「首充活动提交」里的参加 / 不参加流程。**
+
 ## 首充活动提交(2026-11-12)
 
 - [首充活动返回多个-前端修改单](首充活动返回多个-前端修改单.md):`GET /api/promotion/firstDeposit` 原来只在 `activity` 里返回一个首充活动;新增 `activities` 数组,返回当前平台全部可参加的首充活动,每项带自己的 `first_deposit_offer` 和 `selected`。旧字段 `activity`、`first_deposit_offer` 保留,等于第一个。到账时按充值金额自动取最高档发奖(100 送 10、1000 送 50,充 1000 送 50),前端只需一个「参加」开关。

+ 52 - 0
docs/前端交付/首充活动默认参加-前端修改单.md

@@ -0,0 +1,52 @@
+# 首充活动默认参加-前端修改单(2026-11-12)
+
+## 改动
+
+首充活动**不再需要会员勾选参加**。会员首笔充值到账时,系统自动按充值金额发放**最高档**的首充奖励。
+
+| 首充活动 | 充值 50 | 充值 500 | 充值 1000 | 充值 2000 |
+|---|---|---|---|---|
+| 100 送 10、1000 送 50 | 不送 | 送 10 | 送 50 | 送 50 |
+
+发奖规则:
+- 只看**首笔到账**的充值。首笔没达到任何档位的门槛就不送,后面的充值也不再补。
+- 在所有进行中的首充活动里,取充值金额达到门槛、奖励最高的一个发放。高档的份数或额度用完时发下一档。
+- 打码倍数按实际发放的那个活动计算。
+- 禁止投注的游戏大类按**到账时**后台的设置;打码量完成前,不能投注这些游戏。
+- 会员以前保存过「参加 / 不参加」的选择,一律不再生效,都按上面的规则自动发放。
+
+## 前端要改的
+
+### 会员前台(PC / H5 / APP)
+
+1. 充值页**去掉「参加首充活动」的勾选框**。
+2. 充值下单时**不要再传** `join_first_deposit`、`first_deposit_offer`:
+   - `POST /api/wallet/recharge`(USDT 凭证充值)
+   - `POST /api/wallet/createPay`(在线支付)
+   - `POST /api/wallet/paymentOrder`(人工充值)
+
+   传了也不会报错,只是不再起作用。
+3. 不再调用 `POST /api/promotion/firstDepositChoice`。
+4. 可以继续用 `GET /api/promotion/firstDeposit?platform=H5` 在充值页**展示首充档位和限制说明**:
+
+| 字段 | 用法 |
+|---|---|
+| `has_deposited` | `true` 表示已经有到账的充值,首充奖励已经不能再拿,不显示首充档位 |
+| `activities` | 全部首充档位(标题、图片、`min_deposit` 最低充值、`reward_amount` 奖励、`turnover_ratio` 打码倍数),展示给会员看 |
+| `notice` | 限制提示,例如「参加首充活动后,活动期间在完成首充奖励打码量前不能投注:电子、F1彩票」,显示在档位下面 |
+| `restrictions` | 已经领到首充奖励、限制还在生效的记录(剩余打码量等),可以在充值页或个人中心显示 |
+
+以下字段不用再管:`can_choose`、`joined`、`selection`、`first_deposit_offer`、`activities[].first_deposit_offer`、`activities[].selected`。
+
+建议文案:「首笔充值满 100 送 10,满 1000 送 50,到账自动发放。领取后需完成打码量,期间不能投注:电子、F1彩票。」
+
+### Telegram
+
+Telegram 的首充菜单里还有「参加 / 不参加」的按钮,现在点了不起作用,到账同样自动发放。
+
+## 验收
+
+1. 充值页没有勾选框,下单不传首充参数。
+2. 有「100 送 10」「1000 送 50」两个首充活动:新会员首笔充 1000,到账送 50;另一个新会员首笔充 500,送 10;首笔充 50 不送,之后再充 1000 也不送。
+3. 以前选过「不参加」的会员,首笔到账照样发放。
+4. 领到首充奖励后,在打码量完成前投注后台设置的禁止大类,提示「活动期间禁止投注该游戏」。

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

@@ -1,3 +1,5 @@
+> **2026-11-12 更新:首充不再需要会员勾选,首笔到账自动按金额发放最高档。** 下面第 2 节「提交会员的选择」已不再起作用,充值页去掉勾选框、下单不传首充参数,详见《首充活动默认参加-前端修改单》。
+
 # 首充活动参加选择与禁止游戏:会员端接入
 
 首充活动改为会员自愿参加,默认不参加。会员参加后,在活动期间、首充奖励打码量完成前,不能投注后台统一设置的“禁止的游戏大类”(对全部首充活动生效)。

+ 18 - 96
tests/Integration/PromotionActivitiesTest.php

@@ -928,28 +928,22 @@ class PromotionActivitiesTest extends TestCase
         $this->assertSame(0, $admin->records(['activity_id' => $loss, 'type' => 7, 'kind' => 'rewards'])['total']);
     }
 
-    public function test_first_deposit_requires_opt_in_and_declining_consumes_the_first_deposit(): void
+    public function test_first_deposit_is_automatic_without_opt_in(): void
     {
         $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]);
-        // 默认不勾选:没有记录选择的会员首笔到账不发奖,第二笔起也不再算首充。
+        // 不需要勾选:没有任何选择记录的会员首笔到账也发奖,第二笔起不再算首充。
         $this->service->onDeposit($this->deposit(1, '100'));
-        $this->invalid(fn () => $this->join(1), '已有充值记录,不能再选择首充活动');
         $this->service->onDeposit($this->deposit(1, '1000'));
-        $this->assertSame(0, DB::table('promotion_rewards')->count());
+        $this->assertSame(1, DB::table('promotion_rewards')->where('activity_id', $id)->where('user_id', 1)->count());
         $this->assertSame(1, DB::table('promotion_first_deposits')->where('user_id', 1)->count());
-        // 明确选择不参加后又改为参加:以首笔到账前最后一次选择为准。
-        $this->assertTrue($this->join(2, '0')); $this->assertTrue($this->join(2, true));
-        $this->assertSame(1, (int)DB::table('promotion_first_deposit_choices')->where('user_id', 2)->value('joined'));
-        $this->assertFalse($this->join(2, null));
+        // 旧前端保存过「不参加」也照样发奖。
+        $this->assertTrue($this->join(2, '0'));
         $this->service->onDeposit($this->deposit(2, '100'));
         $this->assertSame(1, DB::table('promotion_rewards')->where('activity_id', $id)->where('user_id', 2)->count());
-        // 首笔到账后选择不能再修改。
-        $this->assertFalse($this->join(2, 0));
-        $this->assertSame(1, (int)DB::table('promotion_first_deposit_choices')->where('user_id', 2)->value('joined'));
-        $this->member(3); $this->join(3, 0);
-        $this->service->onDeposit($this->deposit(3, '100')); $this->service->onDeposit($this->deposit(3, '500'));
+        // 首笔没达到门槛就不发,后面的充值也不再补。
+        $this->member(3);
+        $this->service->onDeposit($this->deposit(3, '50')); $this->service->onDeposit($this->deposit(3, '500'));
         $this->assertSame(0, DB::table('promotion_rewards')->where('user_id', 3)->count());
-        $this->invalid(fn () => \App\Support\FirstDepositChoice::parse('yes'), 'join_first_deposit只能为0或1');
     }
 
     public function test_first_deposit_restriction_is_one_setting_shared_by_every_campaign(): void
@@ -982,110 +976,38 @@ class PromotionActivitiesTest extends TestCase
         $this->assertArrayNotHasKey('forbidden_categories', json_decode(DB::table('promotion_activities')->where('id', $second)->value('params'), true));
         $this->assertSame('12.00', $saved['params']['reward_amount']);
 
-        // 每个首充活动都使用同一设置;发奖快照记录会员确认时的设置。
+        // 每个首充活动都使用同一设置;发奖快照记录到账时的设置。
         foreach ($this->service->listing(1, 'H5', 'zh', 8)['data'] as $item) $this->assertSame(['third:1', 'third:2', 'self:sport'], $item['rules']['forbidden_categories']);
         $admin->save(['id' => $first, 'type' => 8, 'status' => 1, 'translations' => $this->texts(), 'sort' => 9, 'params' => ['turnover_ratio' => 2]]);
-        $this->join(1);
         $this->restrict(['third:5']);
         $this->service->onDeposit($this->deposit(1, '100'));
         $snapshot = json_decode(DB::table('promotion_rewards')->where('user_id', 1)->value('rule_snapshot'), true);
-        $this->assertSame(['third:1', 'third:2', 'self:sport'], $snapshot['forbidden_categories']);
+        $this->assertSame(['third:5'], $snapshot['forbidden_categories']);
         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 不发。
+        // 100 送 10、1000 送 50:充 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);
+        $high = $this->activity(8, ['min_deposit' => 1000, 'reward_amount' => 50, 'turnover_ratio' => 2]);
+        $this->restrict(['self:lottery']);
         $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));
+        $this->assertSame([$high, '50.00', '100.00'], [(int) $reward->activity_id, number_format((float) $reward->amount, 2, '.', ''), number_format((float) $reward->required_flow, 2, '.', '')]);
+        $this->assertSame(['self:lottery'], json_decode($reward->rule_snapshot, true)['forbidden_categories'], 'restriction set at credit time');
 
-        $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->assertSame($low, (int) DB::table('promotion_rewards')->where('user_id', 2)->value('activity_id'));
 
-        $this->member(3); $pick(3, $low);
+        $this->member(3);
         $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->member(4);
         $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();
-        $base = ['type' => 8, 'status' => 1, 'translations' => $this->texts()];
-        $h5 = $admin->save($base + ['sort' => 1, 'publish_platforms' => ['H5'], 'params' => ['reward_amount' => 25, 'turnover_ratio' => 2]])['id'];
-        $admin->save($base + ['sort' => 2, 'publish_platforms' => ['PC'], 'params' => ['reward_amount' => 80, 'turnover_ratio' => 2]]);
-        $shown = $this->service->listing(1, 'H5', 'zh', 8)['data'][0];
-        $offer = FirstDepositChoice::offer($shown['activity_id'], 'H5', $shown['rules'], null, null);
-        $this->assertTrue(FirstDepositChoice::record(User::find(1), true, 'test', $offer));
-        // 全局禁止大类变更也会让已展示的活动凭据失效。
-        $this->restrict(['self:sport']);
-        $this->invalid(fn () => FirstDepositChoice::record(User::find(2), true, 'test', $offer), '首充活动已变更,请刷新后重新确认');
-        $admin->save($base + ['id' => $h5, 'params' => ['reward_amount' => 50]]);
-        $this->service->onDeposit($this->deposit(1, '100'));
-        $reward = DB::table('promotion_rewards')->where('user_id', 1)->first();
-        $this->assertSame($h5, (int) $reward->activity_id);
-        $this->assertSame('25.00', json_decode($reward->rule_snapshot, true)['reward_amount']);
-        $this->assertSame([], json_decode($reward->rule_snapshot, true)['forbidden_categories']);
-    }
-
-    public function test_pending_order_cutover_is_source_specific_and_keeps_legacy_terms(): void
-    {
-        $id = $this->activity(8, ['reward_amount' => 25, 'turnover_ratio' => 2]); $this->restrict(['self:lottery']);
-        DB::statement('CREATE TABLE bot_payment_orders (id INTEGER PRIMARY KEY)');
-        DB::table('payment_orders')->insert(['id' => 1]);
-        $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_11_12_130000_bind_first_deposit_choices.php';
-        $migration->down();
-        $migration->up();
-        DB::table('payment_orders')->insert(['id' => 2]);
-        $migration->up();
-
-        $old = $this->deposit(1, '100', type: '三方充值');
-        $old->related_id = 1;
-        $old->save();
-        $this->service->onDeposit($old, 'payment_orders');
-        $new = $this->deposit(2, '100', type: '三方充值');
-        $new->related_id = 2;
-        $new->save();
-        $this->service->onDeposit($new, 'payment_orders');
-        $this->member(3);
-        $otherSource = $this->deposit(3, '100');
-        $otherSource->related_id = 1;
-        $otherSource->save();
-        $this->service->onDeposit($otherSource, 'recharges');
-
-        $this->assertSame([1], DB::table('promotion_rewards')->where('activity_id', $id)->pluck('user_id')->all());
-        $this->assertSame([], json_decode(DB::table('promotion_rewards')->value('rule_snapshot'), true)['forbidden_categories']);
     }
 
     public function test_legacy_campaign_params_cannot_forbid_every_category_any_more(): void