doge 1 روز پیش
والد
کامیت
cff5a9aeee

+ 27 - 10
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) {
+        DB::transaction(function () use ($member, $deposit, $orderSource) {
             $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,28 +434,45 @@ 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 (!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');
                 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;
-                $rules = $this->rules($activity);
-                // 全部大类都禁止且有打码量时打码量无法完成,这样的活动不发。
-                if (!Rules::hasPlayableCategory($rules) || bccomp((string)$deposit->amount, (string)$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));
+            // 奖励高的优先;奖励相同时会员确认的活动优先,其次按活动排序。
+            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);

+ 2 - 5
docs/前端交付/README.md

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

+ 101 - 0
docs/前端交付/首充活动不选具体活动-前端修改单.md

@@ -0,0 +1,101 @@
+# 首充活动不选具体活动-前端修改单(2026-11-12)
+
+## 规则
+
+首充活动有两件事,逻辑分开:
+
+| | 谁决定 | 前端怎么做 |
+|---|---|---|
+| **是否参加**首充活动 | 会员自己选 | 保留「参加首充活动」开关,默认不参加 |
+| **拿哪个档位**的奖励 | 系统按首笔充值金额自动决定 | 不用让会员选,也不用传是哪个活动 |
+
+例如后台有「100 送 10」「1000 送 50」两个首充活动,会员选了参加:
+
+| 首笔充值 | 50 | 500 | 1000 | 2000 |
+|---|---|---|---|---|
+| 送 | 不送 | 10 | 50 | 50 |
+
+- 会员没参加(或选了不参加),首笔充值不送。
+- 只看首笔到账的充值;首笔没达到任何档位,后面的充值也不补。
+- 高档的份数或额度用完时,发下一档。
+- 打码倍数按实际发放的那个活动计算;打码量完成前,不能投注后台设置的禁止游戏大类。
+
+## 接口
+
+### 1. 读取首充信息
+
+`GET /api/promotion/firstDeposit?platform=H5`(`platform` 取 `PC` / `H5` / `APP`)
+
+```json
+{
+  "has_deposited": false,
+  "can_choose": true,
+  "joined": false,
+  "selection": null,
+  "first_deposit_offer": "3.H5.5e884898da28047151d0e56f8dc62927",
+  "activities": [
+    {"activity_id": 5, "title": "首充送50", "image": "https://…/5.png", "min_deposit": "1000", "reward_amount": "50", "turnover_ratio": "3", "start_time": 1793289600, "end_time": null},
+    {"activity_id": 3, "title": "首充送10", "image": "https://…/3.png", "min_deposit": "100", "reward_amount": "10", "turnover_ratio": "2", "start_time": 1793289600, "end_time": 1824825599}
+  ],
+  "forbidden_categories": [{"value": "third:2", "label": "电子"}],
+  "notice": "参加首充活动后,活动期间在完成首充奖励打码量前不能投注:电子",
+  "restrictions": []
+}
+```
+
+| 字段 | 用法 |
+|---|---|
+| `can_choose` | `true` 时显示「参加首充活动」开关;已有到账充值或没有首充活动时为 `false`,隐藏开关 |
+| `joined` | 会员当前是否已参加,用来设置开关状态 |
+| `first_deposit_offer` | **参加用的凭据**,只有这一个,打开开关时原样提交 |
+| `activities` | 全部首充档位,**只用来展示**(最低充值、奖励、打码倍数),不用让会员选 |
+| `notice` | 参加后的禁投提示,显示在开关旁边 |
+| `restrictions` | 已领到首充奖励、限制还在生效的记录(剩余打码量) |
+
+`activities` 里没有凭据,也没有 `selected`,前端不需要关心会员会拿到哪个档位。
+
+### 2. 提交参加 / 不参加
+
+打开或关闭开关时:
+
+`POST /api/promotion/firstDepositChoice`
+
+```json
+{"platform": "H5", "join": 1, "first_deposit_offer": "3.H5.5e884898da28047151d0e56f8dc62927"}
+```
+
+```json
+{"platform": "H5", "join": 0}
+```
+
+- `join=1` 时带上**外层**的 `first_deposit_offer`;`join=0` 不用带。
+- 返回结构和第 1 步相同,用它刷新页面。
+- 首笔充值到账前可以随时改。
+
+也可以在充值下单时一起提交(`POST /api/wallet/recharge`、`/api/wallet/createPay`、`/api/wallet/paymentOrder`):
+
+| 参数 | 说明 |
+|---|---|
+| `join_first_deposit` | `1` 参加、`0` 不参加;不传表示不改动之前的选择 |
+| `first_deposit_offer` | `join_first_deposit=1` 时带上外层的 `first_deposit_offer` |
+
+### 3. 错误提示
+
+| msg | 处理 |
+|---|---|
+| `请刷新首充活动并确认参加` | 提交参加时没带 `first_deposit_offer`,检查请求参数 |
+| `首充活动已变更,请刷新后重新确认` | 后台改了首充活动或禁投设置,重新调第 1 步,给会员看新的说明后再提交 |
+| `已有充值记录,不能再选择首充活动` | 首笔已到账,隐藏开关 |
+
+## 前端要改的
+
+1. 首充区域只放一个「参加首充活动」开关,下面列出 `activities` 的所有档位和 `notice`。
+2. 不要做「选哪个活动」的单选,提交时也不传活动 ID。
+3. 打开开关提交 `join=1` 和外层的 `first_deposit_offer`;关闭提交 `join=0`。
+
+## 验收
+
+1. 有「100 送 10」「1000 送 50」两个首充活动:会员打开「参加」,首笔充 1000,到账送 50。
+2. 另一个会员参加后首笔充 500,送 10;首笔充 50 不送。
+3. 不参加的会员首笔充 1000,不送。
+4. 首笔到账后,开关隐藏,不能再改。

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

@@ -29,10 +29,10 @@
 ```js
 // 打开充值页 / 活动弹窗时
 const res = await getFirstDeposit({ platform })       // GET /api/promotion/firstDeposit
-this.firstDeposit = res.data                         // res.data.activities 里每项都有 first_deposit_offer
+this.firstDeposit = res.data                         // 外层的 first_deposit_offer 就是参加用的凭据
 
-// 会员打开「参加」:传任意一项的凭据(到账时按金额自动取最高档)
-const saved = await saveFirstDepositChoice({ platform, join: 1, first_deposit_offer: res.data.activities[0].first_deposit_offer })
+// 会员打开「参加」:传外层的凭据,不用选具体活动(到账时按金额自动取最高档)
+const saved = await saveFirstDepositChoice({ platform, join: 1, first_deposit_offer: this.firstDeposit.first_deposit_offer })
 this.firstDeposit = saved.data                       // 返回结构同上,activities 已经刷新
 ```
 

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

@@ -7,7 +7,7 @@
 
 ## 修改
 
-`GET /api/promotion/firstDeposit?platform=PC` 新增 **`activities` 数组**,返回当前平台**全部**可参加的首充活动。每个活动带自己的 `first_deposit_offer`(参加时用的凭据)和 `selected`。到账时按充值金额自动取最高档发奖,见下面「发奖规则」。
+`GET /api/promotion/firstDeposit?platform=PC` 新增 **`activities` 数组**,返回当前平台**全部**可参加的首充活动(档位),只用于展示。会员不用选具体活动,参加时提交外层的 `first_deposit_offer`;到账时按充值金额自动取最高档发奖,见下面「发奖规则」。
 
 ```json
 {
@@ -24,9 +24,7 @@
       "reward_amount": "60",
       "turnover_ratio": "3",
       "start_time": 1793289600,
-      "end_time": null,
-      "selected": false,
-      "first_deposit_offer": "5.PC.0c1f…"
+      "end_time": null
     },
     {
       "activity_id": 3,
@@ -36,9 +34,7 @@
       "reward_amount": "25",
       "turnover_ratio": "2",
       "start_time": 1793289600,
-      "end_time": 1824825599,
-      "selected": true,
-      "first_deposit_offer": "3.PC.5e88…"
+      "end_time": 1824825599
     }
   ],
   "first_deposit_offer": "5.PC.0c1f…",
@@ -60,14 +56,13 @@
 | `reward_amount` | 奖励金额 |
 | `turnover_ratio` | 打码倍数 |
 | `start_time` / `end_time` | 秒级时间戳,`null` 表示长期 |
-| `selected` | 是否就是会员确认参加时提交的那个活动 |
-| `first_deposit_offer` | 这个活动的凭据,会员选这个活动参加时原样提交;已有充值记录时为 `null` |
 
 其他说明:
 - 没有可参加的活动时,`activities` 是空数组,`can_choose=false`。
 - 排序和活动列表一致。
 - 禁止的游戏大类对全部首充活动统一,所以 `forbidden_categories`、`notice` 仍然只有一份,放在外层。
-- **旧字段保留**:`activity`、`first_deposit_offer` 等于 `activities[0]`,前端改完之前不会报错。新页面改用 `activities`,不要再用这两个旧字段。
+- 外层 `first_deposit_offer` 是「参加首充」的凭据,会员打开「参加」时提交它,**不用选具体活动**。
+- 旧字段 `activity` 等于 `activities[0]`,新页面改用 `activities`。
 
 ## PC 和 H5 数量不同是正常的
 
@@ -81,7 +76,6 @@
 |---|---|---|---|---|
 | 100 送 10、1000 送 50 | 不送 | 送 10 | 送 50 | 送 50 |
 
-- 和会员提交的是哪个活动的凭据无关。比如会员确认时提交的是「100 送 10」,充 1000 仍然送 50。
 - 打码倍数按实际发放的那个活动计算。
 - 高档的份数或额度用完时,发下一档。
 - 只在会员确认时所在的端(PC / H5 / APP)发布的活动里选。只发布在 PC 的活动,H5 上确认的会员拿不到。
@@ -91,21 +85,21 @@
 
 1. 充值页的首充区域改成按 `activities` 渲染,**把所有档位都展示出来**(标题、图片、最低充值、奖励、打码倍数),让会员知道充多少送多少。
 2. 只需要一个「参加首充活动」开关,**不用让会员在档位之间单选**。
-3. 打开开关时提交「参加」,传 `activities` 里**任意一项**的 `first_deposit_offer` 即可(建议用第一项):
+3. 打开开关时提交「参加」,传**外层**的 `first_deposit_offer`:
 
 ```json
 POST /api/promotion/firstDepositChoice
 {"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. 关闭开关传 `join=0`。
-5. 已参加的状态看外层的 `joined`,不要看 `selected`。
+5. 已参加的状态看外层的 `joined`。
 6. `firstDepositChoice` 返回的结构和 `firstDeposit` 相同,用返回的数据刷新即可,不用再调一次 `firstDeposit`。
 
 ## 验收
 
 1. PC 上有两个首充活动时,`activities` 返回两个;H5 只返回发布在 H5 的活动。
 2. 有「100 送 10」「1000 送 50」两个活动,会员参加后首充 1000,到账送 50;另一个会员首充 500,送 10;首充 50 不送。
-3. 首笔充值到账后,`can_choose=false`,每项的 `first_deposit_offer` 为 `null`。
+3. 首笔充值到账后,`can_choose=false`,`first_deposit_offer` 为 `null`。

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

@@ -1,52 +0,0 @@
-# 首充活动默认参加-前端修改单(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. 领到首充奖励后,在打码量完成前投注后台设置的禁止大类,提示「活动期间禁止投注该游戏」。

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

@@ -1,5 +1,3 @@
-> **2026-11-12 更新:首充不再需要会员勾选,首笔到账自动按金额发放最高档。** 下面第 2 节「提交会员的选择」已不再起作用,充值页去掉勾选框、下单不传首充参数,详见《首充活动默认参加-前端修改单》。
-
 # 首充活动参加选择与禁止游戏:会员端接入
 
 首充活动改为会员自愿参加,默认不参加。会员参加后,在活动期间、首充奖励打码量完成前,不能投注后台统一设置的“禁止的游戏大类”(对全部首充活动生效)。
@@ -20,9 +18,9 @@
 | `can_choose` | 是否显示“参加首充活动”选项:没有充值记录且当前有可参加的首充活动时为 `true` |
 | `joined` | 当前保存的选择,默认 `false`(不参加) |
 | `selection` | 已确认的选择 `{activity_id, rules}`,未确认时为 `null`。用 `rules` 展示已经确认的充值门槛、奖励、打码倍数和禁投大类 |
-| `activities` | **当前平台全部可参加的首充活动**(数组,可能有多个),全部展示给会员;参加时传任意一项的凭据,到账按金额自动取最高档,见 2.3。没有可参加的活动时为空数组 |
-| `first_deposit_offer` | 旧字段,等于 `activities[0].first_deposit_offer`。新前端用 `activities` 里每项自己的凭据 |
-| `activity` | 旧字段,等于 `activities[0]`(不含 `image`、`selected`、`first_deposit_offer`),没有时为 `null`。新前端用 `activities` |
+| `activities` | **当前平台全部首充档位**(数组,可能有多个),只用于展示;会员不选具体活动,到账按金额自动取最高档,见 2.3。没有可参加的活动时为空数组 |
+| `first_deposit_offer` | 「参加首充」的凭据。会员确认参加时原样提交;已有充值记录或没有可参加的活动时为 `null` |
+| `activity` | 旧字段,等于 `activities[0]`(不含 `image`),没有时为 `null`。新前端用 `activities` |
 | `forbidden_categories` | 参加后活动期间禁止投注的游戏大类,数组 `[{value,label}]`,`label` 按请求头语言返回;没有限制时为空数组 |
 | `notice` | 按请求头语言返回的限制提示文字,没有限制时为空字符串 |
 | `restrictions` | 已领到首充奖励、当前仍在限制中的记录,见下表;不在限制中为空数组 |
@@ -47,8 +45,6 @@
 | `image` | 活动图片 |
 | `min_deposit` / `reward_amount` / `turnover_ratio` | 最低充值、奖励金额、打码倍数 |
 | `start_time` / `end_time` | 秒级时间戳,`null` 表示长期 |
-| `selected` | 是否就是会员当前参加的活动 |
-| `first_deposit_offer` | 这个活动的凭据,会员选这个活动参加时原样回传;已有充值记录时为 `null` |
 
 活动按发布平台过滤(`platform` 传 PC 和 H5 结果可能不同),排序和活动列表一致。禁止的游戏大类是全部首充活动统一的设置,所以 `forbidden_categories`、`notice` 只有一份。
 
@@ -63,8 +59,8 @@
   "joined": false,
   "selection": null,
   "activities": [
-    {"activity_id": 5, "title": "首充送60", "image": "https://…/5.png", "min_deposit": "200", "reward_amount": "60", "turnover_ratio": "3", "start_time": 1793289600, "end_time": null, "selected": false, "first_deposit_offer": "5.PC.0c1f…"},
-    {"activity_id": 3, "title": "首充奖励", "image": "https://…/3.png", "min_deposit": "100", "reward_amount": "25", "turnover_ratio": "2", "start_time": 1793289600, "end_time": 1824825599, "selected": false, "first_deposit_offer": "3.PC.5e88…"}
+    {"activity_id": 5, "title": "首充送60", "image": "https://…/5.png", "min_deposit": "200", "reward_amount": "60", "turnover_ratio": "3", "start_time": 1793289600, "end_time": null},
+    {"activity_id": 3, "title": "首充奖励", "image": "https://…/3.png", "min_deposit": "100", "reward_amount": "25", "turnover_ratio": "2", "start_time": 1793289600, "end_time": 1824825599}
   ],
   "first_deposit_offer": "5.PC.0c1f…",
   "activity": {"activity_id": 5, "title": "首充送60", "min_deposit": "200", "reward_amount": "60", "turnover_ratio": "3", "start_time": 1793289600, "end_time": null},
@@ -103,7 +99,7 @@
 
 | 取值 | 含义 |
 |---|---|
-| `1` | 确认参加,同时传 `activities[]` 里任意一项的 `first_deposit_offer` |
+| `1` | 确认参加,同时传 `first_deposit_offer` |
 | `0` | 不参加 |
 | 不传 | 不改动之前保存的选择 |
 
@@ -128,7 +124,7 @@
 |---|---|
 | `platform` | `PC/H5/APP` |
 | `join` | `1` 参加,`0` 不参加 |
-| `first_deposit_offer` | `join=1` 时必传,传 `activities[]` 里任意一项的凭据(同一 `platform`) |
+| `first_deposit_offer` | `join=1` 时必传,从同一 `platform` 的首充状态接口取外层的 `first_deposit_offer` |
 
 成功返回与 `GET /api/promotion/firstDeposit` 相同的结构。已有充值记录时返回失败,`msg` 为 `已有充值记录,不能再选择首充活动`。
 
@@ -137,7 +133,7 @@
 - 以首笔充值到账前最后一次保存的选择为准。切换前已下单且没有选择记录的充值按原有首充规则处理。
 - 首笔充值到账后不能再修改选择。
 - 首笔充值时不参加,第二笔及以后的充值都不再算首充,不会再有首充奖励。
-- 有多个首充活动时按档位发放:首笔充值到账时,在会员确认时所在端发布的首充活动里,取充值金额达到门槛、奖励最高的一个(份数或额度用完时取下一档),和提交的是哪个活动的凭据无关。会员确认的那个活动按确认时的条款发放;禁止游戏大类按确认时的设置。活动需有效并有可用奖励额度。
+- 有多个首充活动时按档位发放:首笔充值到账时,在会员确认时所在端发布的首充活动里,取充值金额达到门槛、奖励最高的一个(份数或额度用完时取下一档)。会员确认的那个活动按确认时的条款发放;禁止游戏大类按确认时的设置。活动需有效并有可用奖励额度。
 - Telegram 会员可在充值菜单或用 `/firstdeposit` 确认选择。
 
 ## 3. 下注和进入游戏被拒绝

+ 96 - 18
tests/Integration/PromotionActivitiesTest.php

@@ -928,22 +928,28 @@ class PromotionActivitiesTest extends TestCase
         $this->assertSame(0, $admin->records(['activity_id' => $loss, 'type' => 7, 'kind' => 'rewards'])['total']);
     }
 
-    public function test_first_deposit_is_automatic_without_opt_in(): void
+    public function test_first_deposit_requires_opt_in_and_declining_consumes_the_first_deposit(): 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(1, DB::table('promotion_rewards')->where('activity_id', $id)->where('user_id', 1)->count());
+        $this->assertSame(0, DB::table('promotion_rewards')->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, '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->service->onDeposit($this->deposit(2, '100'));
         $this->assertSame(1, DB::table('promotion_rewards')->where('activity_id', $id)->where('user_id', 2)->count());
-        // 首笔没达到门槛就不发,后面的充值也不再补。
-        $this->member(3);
-        $this->service->onDeposit($this->deposit(3, '50')); $this->service->onDeposit($this->deposit(3, '500'));
+        // 首笔到账后选择不能再修改。
+        $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->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
@@ -976,38 +982,110 @@ 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:5'], $snapshot['forbidden_categories']);
+        $this->assertSame(['third:1', 'third:2', 'self:sport'], $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:充 1000 发 50,充 500 发 10,充 50 不发;高档份数用完发下一档。
+        // 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, 'turnover_ratio' => 2]);
-        $this->restrict(['self:lottery']);
+        $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', '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');
+        $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'));
+        $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);
+        $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);
+        $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();
+        $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