doge 1 день назад
Родитель
Сommit
340429a77f

+ 5 - 0
app/Http/Controllers/admin/Promotion.php

@@ -21,6 +21,11 @@ class Promotion extends Controller
     public function options(PromotionAdminService $s) { return $this->run(fn () => $s->options()); }
     public function submit(PromotionAdminService $s) { return $this->run(fn () => $s->save(request()->all())); }
     public function records(PromotionAdminService $s) { return $this->run(fn () => $s->records(request()->all())); }
+    /** 全部首充活动共用的禁止投注游戏大类:GET 读取,POST 保存。 */
+    public function firstDepositRestriction(PromotionAdminService $s)
+    {
+        return $this->run(fn () => request()->isMethod('post') ? $s->saveFirstDepositRestriction(request()->all()) : $s->firstDepositRestriction());
+    }
     public function detail(PromotionAdminService $s)
     {
         return $this->run(function () use ($s) {

+ 26 - 20
app/Services/PromotionAdminService.php

@@ -23,9 +23,30 @@ class PromotionAdminService
             'share_levels' => $this->shareLevels(),
             'prizes' => PromotionRules::PRIZES, 'tasks' => PromotionRules::TASKS,
             'reward_modes' => [['value' => 'cash', 'label' => '现金'], ['value' => 'coupon', 'label' => '优惠券']],
-            'coupon_categories' => array_map(fn ($value) => ['value' => $value, 'label' => ShareDefaults::categories()[$value]], PromotionRules::COUPON_CATEGORIES),
-            // 首充奖励“禁止的游戏大类”多选项,与返佣规则的游戏大类一致。
-            'forbidden_categories' => $this->forbiddenOptions(ShareDefaults::commissionCategories())];
+            'coupon_categories' => array_map(fn ($value) => ['value' => $value, 'label' => ShareDefaults::categories()[$value]], PromotionRules::COUPON_CATEGORIES)];
+    }
+
+    /** 全部首充活动共用的“禁止投注游戏大类”设置;选项与返佣规则的游戏大类一致。 */
+    public function firstDepositRestriction(): array
+    {
+        return ['categories' => PromotionRules::forbiddenSetting(), 'options' => $this->forbiddenOptions(ShareDefaults::commissionCategories())];
+    }
+
+    public function saveFirstDepositRestriction(array $input): array
+    {
+        if (!array_key_exists('categories', $input)) throw ValidationException::withMessages(['categories' => '请传categories,不限制时传空数组']);
+        $categories = PromotionRules::forbiddenCategories($input['categories']);
+        if (count($categories) === count(ShareDefaults::commissionCategories())) {
+            throw ValidationException::withMessages(['categories' => '至少保留一个可投注的游戏大类']);
+        }
+        DB::transaction(function () use ($categories) {
+            $before = PromotionRules::forbiddenSetting();
+            DB::table('config')->updateOrInsert(['field' => PromotionRules::FORBIDDEN_CONFIG], ['val' => json_encode($categories)]);
+            if (Schema::hasTable('operation_audits')) {
+                OperationAuditService::record('promotion_first_deposit_restriction', null, 'update', ['categories' => $before], ['categories' => $categories]);
+            }
+        }, 3);
+        return $this->firstDepositRestriction();
     }
 
     private function forbiddenOptions(array $categories): array
@@ -62,12 +83,8 @@ class PromotionAdminService
             'start_time' => $row->start_time === null ? null : date('Y-m-d H:i:s', (int)$row->start_time), 'end_time' => $row->end_time === null ? null : date('Y-m-d H:i:s', (int)$row->end_time),
             'first_published_at' => DB::table('promotion_campaigns')->where('activity_id', $row->id)->value('first_published_at')]);
         unset($data['sub_title']);
-        if ((int) $row->type === PromotionRules::FIRST_DEPOSIT) {
-            // 旧配置没有该键时按不限制展示;列表直接展示名称,编辑表单回填 params.forbidden_categories。
-            $data['params']['forbidden_categories'] = $data['params']['forbidden_categories'] ?? [];
-            $names = ShareDefaults::commissionCategories();
-            $data['forbidden_categories'] = $this->forbiddenOptions(array_intersect_key($names, array_flip($data['params']['forbidden_categories'])));
-        }
+        // 禁止投注的游戏大类改为全部首充活动共用的设置,不在单个活动里返回旧值。
+        if ((int) $row->type === PromotionRules::FIRST_DEPOSIT && is_array($data['params'])) unset($data['params']['forbidden_categories']);
         if (in_array((int) $row->type, PromotionRules::FIXED, true)) unset($data['image']);
         if (PromotionTranslations::fields((int) $row->type) === ['content']) {
             foreach ($data['translations'] as &$text) unset($text['title']);
@@ -124,7 +141,6 @@ class PromotionAdminService
             if ($values['title'] === '') $values['title'] = PromotionRules::types()[(int)$data['type']];
             $rules = PromotionRules::normalize((int)$data['type'], $params);
             if ((int)$data['type'] === PromotionRules::UPGRADE_INVITE) $this->checkUpgradeTiers($rules, (int)$values['status'] === 1);
-            if ((int)$data['type'] === PromotionRules::FIRST_DEPOSIT) $this->checkFirstDepositCategories($rules);
             $values['params'] = json_encode($rules);
             $values = array_replace($values, ActivityPeriod::forSave($data, $row->exists, true));
             $endTime = array_key_exists('end_time', $values) ? $values['end_time'] : $row->end_time;
@@ -146,13 +162,6 @@ class PromotionAdminService
         }
     }
 
-    private function checkFirstDepositCategories(array $rules): void
-    {
-        if (!PromotionRules::hasPlayableCategory($rules)) {
-            throw ValidationException::withMessages(['params' => '首充奖励有打码量时,至少保留一个可投注的游戏大类']);
-        }
-    }
-
     private function shareLevels(): array
     {
         if (!Schema::hasTable('share_levels')) return [];
@@ -186,9 +195,6 @@ class PromotionAdminService
             if ($status === 1 && (int)$row->type === PromotionRules::UPGRADE_INVITE) {
                 $this->checkUpgradeTiers(PromotionRules::normalize(PromotionRules::UPGRADE_INVITE, json_decode($row->params, true) ?: []), true);
             }
-            if ($status === 1 && (int)$row->type === PromotionRules::FIRST_DEPOSIT) {
-                $this->checkFirstDepositCategories(PromotionRules::normalize(PromotionRules::FIRST_DEPOSIT, json_decode($row->params, true) ?: []));
-            }
             $before = $row->toArray(); $row->status = $status; $row->save(); PromotionService::publish($row);
             if (Schema::hasTable('operation_audits')) OperationAuditService::record('promotion', $id, 'status', $before, $row->toArray());
             return $this->format($row);

+ 5 - 1
app/Services/PromotionService.php

@@ -32,7 +32,11 @@ class PromotionService
 
     private function invalid(string $message): void { throw ValidationException::withMessages(['promotion' => $message]); }
     private function json($value): array { return is_array($value) ? $value : (json_decode((string)$value, true) ?: []); }
-    private function rules(PromotionActivity $activity): array { return Rules::normalize((int)$activity->type, $this->json($activity->params)); }
+    private function rules(PromotionActivity $activity): array
+    {
+        $rules = Rules::normalize((int)$activity->type, $this->json($activity->params));
+        return (int)$activity->type === Rules::FIRST_DEPOSIT ? Rules::withForbidden($rules) : $rules;
+    }
     private function money(int $cents): string { return bcdiv((string)$cents, '100', 2); }
 
     public function heartbeat(int $userId, string $token, string $platform): array

+ 1 - 1
app/Support/FirstDepositChoice.php

@@ -80,7 +80,7 @@ final class FirstDepositChoice
             || !ActivityEligibilityService::evaluate($activity, ActivityEligibilityService::memberContext($member->member_id))['is_eligible']) {
             self::invalid('首充活动已变更,请刷新后重新确认');
         }
-        $rules = PromotionRules::normalize(PromotionRules::FIRST_DEPOSIT, json_decode($activity->params, true) ?: []);
+        $rules = PromotionRules::withForbidden(PromotionRules::normalize(PromotionRules::FIRST_DEPOSIT, json_decode($activity->params, true) ?: []));
         if (!PromotionRules::hasPlayableCategory($rules, $platform === 'TG' ? 'self:lottery' : null)
             || !hash_equals(self::offer($id, $platform, $rules, $activity->start_time, $activity->end_time), $offer)) {
             self::invalid('首充活动已变更,请刷新后重新确认');

+ 24 - 6
app/Support/PromotionRules.php

@@ -3,6 +3,7 @@
 namespace App\Support;
 
 use App\Services\Sharing\ShareDefaults;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Validator;
 use Illuminate\Validation\ValidationException;
 
@@ -33,7 +34,7 @@ final class PromotionRules
         return $common + match ($type) {
             7 => ['loss_amount' => '500.00', 'invite_count' => 3, 'reward_amount' => '500.00', 'reward_mode' => 'cash',
                 'coupon_valid_from' => null, 'coupon_valid_until' => null, 'coupon_categories' => [], 'inviter_coupons' => [], 'invitee_coupons' => []],
-            8 => ['min_deposit' => '100.00', 'reward_amount' => '10.00', 'forbidden_categories' => []],
+            8 => ['min_deposit' => '100.00', 'reward_amount' => '10.00'],
             9 => ['completion_probability' => '20.00', 'online_seconds' => 3600,
                 'deposit_amount' => '3000.00', 'team_deposit_amount' => '500.00',
                 'bet_amount' => '5000.00', 'team_bet_amount' => '3000.00'],
@@ -44,13 +45,13 @@ final class PromotionRules
     public static function normalize(int $type, array $input): array
     {
         $defaults = self::defaults($type);
+        // 禁止投注的游戏大类改为全部首充活动共用的设置;旧配置里遗留的该键直接忽略。
+        if ($type === self::FIRST_DEPOSIT) unset($input['forbidden_categories']);
         Validator::make(['params' => $input], ['params' => ['array:' . implode(',', array_keys($defaults))]])->validate();
         $data = array_replace($defaults, $input);
         if ($type === self::UPGRADE_INVITE) return ['tiers' => self::tiers($data['tiers'])];
         $coupon = array_intersect_key($data, array_flip(self::COUPON_KEYS));
         $data = array_diff_key($data, $coupon);
-        $forbidden = array_key_exists('forbidden_categories', $data) ? ['forbidden_categories' => self::forbiddenCategories($data['forbidden_categories'])] : [];
-        unset($data['forbidden_categories']);
         $rules = [];
         foreach ($data as $key => $value) {
             $rules[$key] = match ($key) {
@@ -63,17 +64,34 @@ final class PromotionRules
         }
         Validator::make($data, $rules)->validate();
         foreach ($data as $key => $value) $data[$key] = in_array($key, ['invite_count', 'online_seconds'], true) ? (int)$value : bcadd((string)$value, '0', 2);
-        return ($coupon ? $data + self::coupons($coupon, $data['reward_amount']) : $data) + $forbidden;
+        return $coupon ? $data + self::coupons($coupon, $data['reward_amount']) : $data;
     }
 
-    /** 首充活动期间禁止投注的游戏大类:与返佣规则同一套大类,去重后按大类顺序保存。 */
+    /** 全部首充活动共用的禁止投注游戏大类,存于 config 表(melbet 读取同一项)。 */
+    public const FORBIDDEN_CONFIG = 'first_deposit_forbidden_categories';
+
+    /** 当前设置;未配置或内容损坏时视为不限制。 */
+    public static function forbiddenSetting(): array
+    {
+        $value = json_decode((string) DB::table('config')->where('field', self::FORBIDDEN_CONFIG)->value('val'), true);
+        try { return self::forbiddenCategories(is_array($value) ? $value : []); }
+        catch (ValidationException) { return []; }
+    }
+
+    /** 首充活动规则附上当前的全局禁止大类,供展示、参加确认和发奖快照使用。 */
+    public static function withForbidden(array $rules): array
+    {
+        return $rules + ['forbidden_categories' => self::forbiddenSetting()];
+    }
+
+    /** 禁止投注的游戏大类:与返佣规则同一套大类,去重后按大类顺序保存。 */
     public static function forbiddenCategories($value): array
     {
         $options = array_keys(ShareDefaults::commissionCategories());
         if ($value === null) return [];
         if (!is_array($value) || array_values($value) !== $value || array_filter($value, fn ($item) => !is_string($item) || !in_array($item, $options, true))
             || count(array_unique($value)) !== count($value)) {
-            throw ValidationException::withMessages(['params' => 'forbidden_categories参数错误:只能选择返佣规则中的游戏大类且不能重复']);
+            throw ValidationException::withMessages(['categories' => 'categories参数错误:只能选择返佣规则中的游戏大类且不能重复']);
         }
         return array_values(array_intersect($options, $value));
     }

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

@@ -1,6 +1,6 @@
 # 首充活动参加选择与禁止游戏:会员端接入
 
-首充活动改为会员自愿参加,默认不参加。会员参加后,在活动期间、首充奖励打码量完成前,不能投注后台配置的“禁止的游戏大类”。
+首充活动改为会员自愿参加,默认不参加。会员参加后,在活动期间、首充奖励打码量完成前,不能投注后台统一设置的“禁止的游戏大类”(对全部首充活动生效)。
 
 接口主机:`/api/promotion/*`、`/api/wallet/recharge`(USDT 凭证充值)和各下注接口在 **melbet_sport-api**;`/api/wallet/createPay`、`/api/wallet/paymentOrder` 在 **bot-28**。使用现有会员 Token,请求头 `lang` 传 `zh/en/vi`。
 
@@ -164,4 +164,4 @@
 
 ## 4. 活动列表与详情
 
-`GET /api/promotion/list?type=8` 和 `GET /api/promotion/detail` 的 `rules` 新增 `forbidden_categories`,为游戏大类 value 数组,例如 `["third:2","self:lottery"]`;空数组表示不限制。
+`GET /api/promotion/list?type=8` 和 `GET /api/promotion/detail` 的 `rules` 新增 `forbidden_categories`,为游戏大类 value 数组,例如 `["third:2","self:lottery"]`;空数组表示不限制。每个首充活动返回的都是同一份统一设置。

+ 35 - 43
docs/总后台/独立活动/首充奖励-禁止游戏大类-后台接入.md

@@ -1,64 +1,56 @@
 # 首充奖励:禁止的游戏大类(后台接入)
 
-首充奖励(`/admin/promotion?type=8`)新增规则“禁止的游戏大类”。会员参加首充活动后,在活动期间、首充奖励打码量完成前,不能投注所选大类。原接口、权限不变。
+“禁止的游戏大类”是**全部首充活动共用的一项设置**,不属于某一条首充奖励。会员参加任一首充活动后,在活动期间、首充奖励打码量完成前,不能投注所选大类。
 
-## 选项
+首充奖励列表(`/admin/promotion?type=8`)和新增/编辑表单里**没有**这个字段。
 
-`GET /admin/promotion/options` 新增 `forbidden_categories`,作为多选项,与返佣规则的游戏大类一致:
+## 读取设置
 
-```json
-"forbidden_categories": [
-  {"value": "third:1", "label": "真人"},
-  {"value": "third:2", "label": "电子"},
-  {"value": "third:4", "label": "体育"},
-  {"value": "third:5", "label": "电竞"},
-  {"value": "third:6", "label": "捕鱼"},
-  {"value": "third:7", "label": "棋牌"},
-  {"value": "self:sport", "label": "F1体育"},
-  {"value": "self:lottery", "label": "F1彩票"}
-]
-```
-
-`defaults["8"].forbidden_categories` 默认为 `[]`(不限制)。
+`GET /admin/promotion/first-deposit-restriction`
 
-## 保存
-
-`POST /admin/promotion/submit` 的 `params` 新增 `forbidden_categories`,传选中项的 `value` 数组:
+权限沿用 `admin/promotion`。返回:
 
 ```json
 {
-  "type": 8,
-  "params": {
-    "min_deposit": "100",
-    "reward_amount": "25",
-    "turnover_ratio": "2",
-    "forbidden_categories": ["third:2", "self:lottery"]
-  }
+  "categories": ["third:2", "self:lottery"],
+  "options": [
+    {"value": "third:1", "label": "真人"},
+    {"value": "third:2", "label": "电子"},
+    {"value": "third:4", "label": "体育"},
+    {"value": "third:5", "label": "电竞"},
+    {"value": "third:6", "label": "捕鱼"},
+    {"value": "third:7", "label": "棋牌"},
+    {"value": "self:sport", "label": "F1体育"},
+    {"value": "self:lottery", "label": "F1彩票"}
+  ]
 }
 ```
 
-- 传 `[]` 表示不限制。
-- 编辑时不传该字段会保留原值。
-- 只能传上面选项中的值且不能重复,否则返回 `code=-3`,`msg` 为 `forbidden_categories参数错误:只能选择返佣规则中的游戏大类且不能重复`。
-- 奖励有打码量时,至少保留一个可投注的游戏大类。
-- 返回顺序按选项顺序整理,与提交顺序无关。
+| 字段 | 说明 |
+|---|---|
+| `categories` | 当前已选的大类 value 数组,空数组表示不限制 |
+| `options` | 多选项,与返佣规则的游戏大类一致 |
 
-## 列表与详情
+## 保存设置
 
-`GET /admin/promotion?type=8` 和 `GET /admin/promotion/detail?id=` 的每条首充奖励新增:
+`POST /admin/promotion/first-deposit-restriction`
 
-| 字段 | 说明 |
-|---|---|
-| `params.forbidden_categories` | 已选 value 数组,编辑表单回填多选 |
-| `forbidden_categories` | 已选项 `[{value,label}]`,列表直接展示 `label`;为空数组时展示为“不限制” |
+权限沿用 `admin/promotion/submit`。请求体:
+
+```json
+{"categories": ["third:2", "self:lottery"]}
+```
 
-旧配置没有该规则时两者都返回 `[]`。
+- `categories` 必传;不限制时传 `[]`。
+- 只能传 `options` 里的值且不能重复,否则返回 `code=-3`,`msg` 为 `categories参数错误:只能选择返佣规则中的游戏大类且不能重复`。
+- 不能把 8 个大类全部选中,否则返回 `code=-3`,`msg` 为 `至少保留一个可投注的游戏大类`。
+- 成功返回与读取接口相同的结构,`categories` 按选项顺序整理。
 
 ## 生效说明
 
-- 修改后的条款供之后确认参加的会员选择。已确认的选择按确认时的条款发奖,活动有效期、参与资格和奖励额度在到账时核对。
-- 已发放的奖励按记录的条款限制。历史配置禁投全部大类时解除禁投,打码要求保留。
-- 切换前已下单、没有选择记录的充值按原规则发放首充奖励。
-- Telegram 只展示不限发布客户端、可在机器人内完成打码的活动。
+- 设置对全部首充活动生效。会员确认参加时记录当时的设置,修改后只影响之后确认参加的会员;已确认或已发奖的会员按确认时的设置限制。
+- 修改设置后,会员端已打开的首充页面需要刷新后重新确认参加。
+- 首充活动的其余规则(`min_deposit/reward_amount/turnover_ratio` 等)仍在每条首充奖励里配置,接口不变。
+- Telegram 机器人只展示能在机器人内完成打码的首充活动:禁止了 `F1彩票` 且奖励有打码量时,机器人内不提供首充活动。
 
 对应会员端文档:[《首充活动参加选择与禁止游戏:会员端接入》](../../前端接口/独立活动/首充活动参加与禁止游戏-会员接入.md)。

+ 41 - 0
docs/总后台/独立活动/首充禁止游戏大类改为统一设置-前端修改说明.md

@@ -0,0 +1,41 @@
+# 首充禁止游戏大类改为统一设置:前端修改说明
+
+“禁止的游戏大类”从每条首充奖励的配置项,改为**全部首充活动共用的一项设置**。
+
+## 总后台需要修改
+
+### 1. 去掉首充奖励里的字段
+
+首充奖励(`/admin/promotion?type=8`):
+
+| 位置 | 修改 |
+|---|---|
+| 列表 | 删除“禁止的游戏大类”列 |
+| 新增/编辑表单 | 删除“禁止的游戏大类”多选 |
+| 提交 `POST /admin/promotion/submit` | `params` 不再传 `forbidden_categories`(仍传时会被忽略,不报错) |
+
+接口返回同步变化:
+
+- `GET /admin/promotion/options` 不再返回 `forbidden_categories`,`defaults["8"]` 也不再包含该键。
+- `GET /admin/promotion?type=8`、`GET /admin/promotion/detail` 不再返回 `forbidden_categories` 和 `params.forbidden_categories`。
+
+### 2. 新增统一设置入口
+
+在首充奖励页面(例如列表上方按钮或弹窗)新增“禁止的游戏大类”设置,使用新接口:
+
+| 方法 | 路径 | 权限 | 用途 |
+|---|---|---|---|
+| GET | `/admin/promotion/first-deposit-restriction` | `admin/promotion` | 读取当前设置和多选项 |
+| POST | `/admin/promotion/first-deposit-restriction` | `admin/promotion/submit` | 保存,请求体 `{"categories": [...]}` |
+
+读取返回 `{categories, options}`:`categories` 回填多选,`options` 为选项 `[{value,label}]`。保存时 `categories` 必传,不限制传 `[]`;不能全选 8 个大类。完整字段和错误提示见[《首充奖励:禁止的游戏大类(后台接入)》](首充奖励-禁止游戏大类-后台接入.md)。
+
+## 会员端
+
+接口和字段都不变,不需要修改:
+
+- `GET /api/promotion/firstDeposit` 的 `forbidden_categories`、`notice` 返回统一设置。
+- `GET /api/promotion/list?type=8`、`GET /api/promotion/detail` 的 `rules.forbidden_categories` 对每个首充活动都返回同一份设置。
+- 下注被拒绝时仍返回 `code=11003`。
+
+后台修改设置后,会员端已打开的首充页面提交参加时会提示“首充活动已变更,请刷新后重新确认”,重新调用 `GET /api/promotion/firstDeposit` 即可。

+ 2 - 0
routes/admin.php

@@ -103,6 +103,8 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::post('/submit', [\App\Http\Controllers\admin\Promotion::class, 'submit']);
             Route::post('/status', [\App\Http\Controllers\admin\Promotion::class, 'status'])->defaults('permission_uri', 'admin/promotion/submit');
             Route::get('/records', [\App\Http\Controllers\admin\Promotion::class, 'records']);
+            Route::get('/first-deposit-restriction', [\App\Http\Controllers\admin\Promotion::class, 'firstDepositRestriction'])->defaults('permission_uri', 'admin/promotion');
+            Route::post('/first-deposit-restriction', [\App\Http\Controllers\admin\Promotion::class, 'firstDepositRestriction'])->defaults('permission_uri', 'admin/promotion/submit');
         });
         Route::prefix('/reporting')->group(function () {
             Route::get('/options', [Reporting::class, 'options'])->defaults('permission_uri', 'admin/reporting/summary');

+ 72 - 39
tests/Integration/PromotionActivitiesTest.php

@@ -55,6 +55,7 @@ class PromotionActivitiesTest extends TestCase
             'user_login' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,user_id TEXT,status INTEGER,platform TEXT,created_at TEXT',
             'reporting_activity' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,user_id INTEGER,session_hash TEXT,observed_at TEXT',
             'share_levels' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,level INTEGER UNIQUE,name TEXT',
+            'config' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,field TEXT,val TEXT',
             'menus' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,parent_id INTEGER,title TEXT,icon TEXT,uri TEXT,permission_name TEXT,sort INTEGER,status INTEGER,type INTEGER,created_at TEXT,updated_at TEXT',
         ] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')');
         foreach (['2026_10_13_120000_add_activity_eligibility_fields.php', '2026_10_14_120000_add_activity_reward_quotas.php', '2026_10_25_120000_add_game_category_to_activity_rewards.php', '2026_10_26_160000_create_promotion_activities.php', '2026_10_27_130000_initialize_fixed_promotion_activities.php', '2026_10_27_140000_add_promotion_activity_translations.php', '2026_11_03_120000_add_promotion_coupons.php', '2026_11_03_170000_repeat_loss_invite_rounds.php', '2026_11_04_120000_create_upgrade_invite_promotion.php'] as $migration) (require dirname(__DIR__, 2) . '/database/migrations/' . $migration)->up();
@@ -99,6 +100,12 @@ class PromotionActivitiesTest extends TestCase
         return FirstDepositChoice::record(User::find($id), $joined, 'test', $offer);
     }
 
+    /** 全部首充活动共用的禁止投注大类。 */
+    private function restrict($categories): array
+    {
+        return (new PromotionAdminService())->saveFirstDepositRestriction(['categories' => $categories]);
+    }
+
     private function deposit(int $id, string $amount, ?string $time = null, string $type = '充值'): BalanceLog
     {
         return BalanceLog::create(['member_id' => 'm' . $id, 'type' => 1, 'amount' => $amount, 'change_type' => $type, 'created_at' => $time ?? PromotionFacts::now()]);
@@ -782,7 +789,6 @@ class PromotionActivitiesTest extends TestCase
     public function test_real_chain_credit_hook_awards_first_deposit_once_and_rolls_back_a_failed_award(): void
     {
         foreach ([
-            'config' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,field TEXT,val TEXT',
             'level' => 'id INTEGER PRIMARY KEY,level INTEGER,recharge TEXT',
             'recharge_channel_group' => 'id INTEGER PRIMARY KEY,activity_type TEXT,deleted_at TEXT',
             'backflow' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,date TEXT,member_id TEXT,recharge_amount TEXT DEFAULT "0",withdrawal_amount TEXT DEFAULT "0",backflow_ratio TEXT,amount TEXT,created_at TEXT,updated_at TEXT',
@@ -831,7 +837,6 @@ class PromotionActivitiesTest extends TestCase
     public function test_manual_payment_approval_keeps_first_deposit_bonus_instead_of_overwriting_it_with_principal(): void
     {
         foreach ([
-            'config' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,field TEXT,val TEXT',
             'level' => 'id INTEGER PRIMARY KEY,level INTEGER,recharge TEXT',
             'recharge_channel_group' => 'id INTEGER PRIMARY KEY,activity_type TEXT,deleted_at TEXT',
             'backflow' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,date TEXT,member_id TEXT,recharge_amount TEXT DEFAULT "0",withdrawal_amount TEXT DEFAULT "0",backflow_ratio TEXT,amount TEXT,created_at TEXT,updated_at TEXT',
@@ -947,34 +952,45 @@ class PromotionActivitiesTest extends TestCase
         $this->invalid(fn () => \App\Support\FirstDepositChoice::parse('yes'), 'join_first_deposit只能为0或1');
     }
 
-    public function test_first_deposit_forbidden_categories_use_commission_categories_and_show_in_admin_list(): void
+    public function test_first_deposit_restriction_is_one_setting_shared_by_every_campaign(): void
     {
         $admin = new PromotionAdminService();
         $options = $admin->options();
-        $this->assertSame(array_keys(\App\Services\Sharing\ShareDefaults::commissionCategories()), array_column($options['forbidden_categories'], 'value'));
-        $this->assertSame([], $options['defaults'][8]['forbidden_categories']);
-        $this->assertNotContains('third:3', array_column($options['forbidden_categories'], 'value'));
-        $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25, 'forbidden_categories' => ['self:sport', 'third:2', 'third:1']]);
-        $detail = $admin->detail($id);
-        $this->assertSame(['third:1', 'third:2', 'self:sport'], $detail['params']['forbidden_categories']);
-        $this->assertSame([['value' => 'third:1', 'label' => '真人'], ['value' => 'third:2', 'label' => '电子'], ['value' => 'self:sport', 'label' => 'F1体育']], $detail['forbidden_categories']);
-        $row = $admin->listing(['type' => 8])['data'][0];
-        $this->assertSame(['真人', '电子', 'F1体育'], array_column($row['forbidden_categories'], 'label'));
-        // 局部编辑其他规则时保留禁止大类,传空数组清空。
-        $base = ['id' => $id, 'type' => 8, 'status' => 1, 'translations' => $this->texts()];
-        $this->assertSame(['third:1', 'third:2', 'self:sport'], $admin->save($base + ['params' => ['reward_amount' => 30]])['params']['forbidden_categories']);
-        foreach ([['third:3'], ['third:1', 'third:1'], 'third:1', ['x' => 'third:1'], [4]] as $bad) {
-            $this->invalid(fn () => $admin->save($base + ['params' => ['forbidden_categories' => $bad]]), 'forbidden_categories参数错误');
+        $this->assertArrayNotHasKey('forbidden_categories', $options);
+        $this->assertArrayNotHasKey('forbidden_categories', $options['defaults'][8]);
+        $setting = $admin->firstDepositRestriction();
+        $this->assertSame([], $setting['categories']);
+        $this->assertSame(array_keys(\App\Services\Sharing\ShareDefaults::commissionCategories()), array_column($setting['options'], 'value'));
+        $this->assertNotContains('third:3', array_column($setting['options'], 'value'));
+        $this->assertSame(['third:1', 'third:2', 'self:sport'], $this->restrict(['self:sport', 'third:2', 'third:1'])['categories']);
+        $this->assertSame(['third:1', 'third:2', 'self:sport'], $admin->firstDepositRestriction()['categories']);
+        foreach ([['third:3'], ['third:1', 'third:1'], 'third:1', ['x' => 'third:1'], [4]] as $bad) $this->invalid(fn () => $this->restrict($bad), 'categories参数错误');
+        $this->invalid(fn () => $this->restrict(array_keys(\App\Services\Sharing\ShareDefaults::commissionCategories())), '至少保留一个可投注的游戏大类');
+        $this->invalid(fn () => $admin->saveFirstDepositRestriction([]), '请传categories');
+        $this->assertSame(['third:1', 'third:2', 'self:sport'], $admin->firstDepositRestriction()['categories']);
+
+        // 活动里不再保存、返回禁止大类;旧前端仍传该参数时忽略。
+        $first = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25, 'forbidden_categories' => ['third:5']]);
+        $second = $this->activity(8, ['min_deposit' => 50, 'reward_amount' => 10]);
+        $this->assertArrayNotHasKey('forbidden_categories', json_decode(DB::table('promotion_activities')->where('id', $first)->value('params'), true));
+        DB::table('promotion_activities')->where('id', $second)->update(['params' => json_encode(['min_deposit' => '50.00', 'reward_amount' => '10.00', 'turnover_ratio' => '0.00', 'forbidden_categories' => ['third:5']])]);
+        foreach ([$admin->detail($first), $admin->detail($second), $admin->listing(['type' => 8])['data'][0]] as $row) {
+            $this->assertArrayNotHasKey('forbidden_categories', $row);
+            $this->assertArrayNotHasKey('forbidden_categories', $row['params']);
         }
-        $this->assertSame([], $admin->save($base + ['params' => ['forbidden_categories' => []]])['forbidden_categories']);
-        // 旧配置没有该键时按不限制返回。
-        DB::table('promotion_activities')->where('id', $id)->update(['params' => json_encode(['min_deposit' => '100.00', 'reward_amount' => '25.00', 'turnover_ratio' => '0.00'])]);
-        $this->assertSame([], $admin->detail($id)['params']['forbidden_categories']);
-        // 发奖规则快照带上禁止大类,供会员端下注时判断。
-        $admin->save($base + ['params' => ['forbidden_categories' => ['third:5'], 'turnover_ratio' => 2]]);
-        $this->join(1); $this->service->onDeposit($this->deposit(1, '100'));
+        $saved = $admin->save(['id' => $second, 'type' => 8, 'status' => 1, 'translations' => $this->texts(), 'params' => ['reward_amount' => 12]]);
+        $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_confirmed_campaign_and_terms(): void
@@ -982,14 +998,14 @@ class PromotionActivitiesTest extends TestCase
         $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, 'forbidden_categories' => ['self:lottery'],
-        ]]);
+        $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));
-        $admin->save($base + ['id' => $h5, 'params' => ['reward_amount' => 50, 'forbidden_categories' => ['self:sport']]]);
+        // 全局禁止大类变更也会让已展示的活动凭据失效。
+        $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);
@@ -999,7 +1015,7 @@ class PromotionActivitiesTest extends TestCase
 
     public function test_pending_order_cutover_is_source_specific_and_keeps_legacy_terms(): void
     {
-        $id = $this->activity(8, ['reward_amount' => 25, 'turnover_ratio' => 2, 'forbidden_categories' => ['self:lottery']]);
+        $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';
@@ -1026,22 +1042,20 @@ class PromotionActivitiesTest extends TestCase
         $this->assertSame([], json_decode(DB::table('promotion_rewards')->value('rule_snapshot'), true)['forbidden_categories']);
     }
 
-    public function test_first_deposit_with_turnover_cannot_forbid_every_category(): void
+    public function test_legacy_campaign_params_cannot_forbid_every_category_any_more(): void
     {
         $all = array_keys(\App\Services\Sharing\ShareDefaults::commissionCategories());
-        $rules = ['turnover_ratio' => 2, 'forbidden_categories' => $all];
-        $error = '首充奖励有打码量时,至少保留一个可投注的游戏大类';
-        $this->invalid(fn () => $this->activity(8, $rules), $error);
         $draft = $this->activity(8, [], 0);
-        DB::table('promotion_activities')->where('id', $draft)->update(['params' => json_encode($rules)]);
-        $this->invalid(fn () => (new PromotionAdminService())->status($draft, 1), $error);
+        DB::table('promotion_activities')->where('id', $draft)->update(['params' => json_encode(['turnover_ratio' => 2, 'forbidden_categories' => $all])]);
+        (new PromotionAdminService())->status($draft, 1);
+        $this->assertSame([], $this->service->listing(1, 'H5', 'zh', 8)['data'][0]['rules']['forbidden_categories']);
     }
 
     public function test_telegram_choice_is_bound_to_the_member_and_its_restriction_lifts_after_turnover(): void
     {
         \Illuminate\Support\Facades\Cache::swap(new \Illuminate\Cache\Repository(new \Illuminate\Cache\ArrayStore()));
-        $activity = $this->activity(8, ['reward_amount' => 25, 'turnover_ratio' => 2, 'forbidden_categories' => ['self:sport']]);
-        $lotteryForbidden = $this->activity(8, ['reward_amount' => 25, 'turnover_ratio' => 2, 'forbidden_categories' => ['self:lottery']]);
+        $activity = $this->activity(8, ['reward_amount' => 25, 'turnover_ratio' => 2]);
+        $this->restrict(['self:sport']);
         $member = User::find(1);
         $reply = TelegramFirstDeposit::menu($member);
         $button = json_decode($reply['reply_markup'], true)['inline_keyboard'][0][0]['callback_data'];
@@ -1052,11 +1066,30 @@ class PromotionActivitiesTest extends TestCase
         $this->service->onDeposit($this->deposit(1, '100'));
         $this->assertFalse($this->service->lotteryRestricted('m1'));
 
+        // 禁投 F1彩票 时 Telegram 没有可参加的首充活动,网页端仍可参加。
+        $this->restrict(['self:lottery']);
+        $this->member(3);
+        $this->assertSame(1, count(json_decode(TelegramFirstDeposit::menu(User::find(3))['reply_markup'], true)['inline_keyboard']));
         $this->assertTrue($this->join(2));
-        $this->assertSame($lotteryForbidden, (int) FirstDepositChoice::selection(2)->activity_id);
+        $this->assertSame($activity, (int) FirstDepositChoice::selection(2)->activity_id);
         $this->service->onDeposit($this->deposit(2, '100'));
         $this->assertTrue($this->service->lotteryRestricted('m2'));
         $this->bet(2, '50', '-50', PromotionFacts::now());
         $this->assertFalse($this->service->lotteryRestricted('m2'));
     }
+
+    public function test_admin_http_reads_and_saves_the_shared_first_deposit_restriction(): void
+    {
+        $controller = new \App\Http\Controllers\admin\Promotion(); $admin = new PromotionAdminService();
+        $this->app->instance('request', Request::create('/admin/promotion/first-deposit-restriction', 'POST', ['categories' => ['self:lottery', 'third:2']]));
+        $saved = $controller->firstDepositRestriction($admin)->getData(true);
+        $this->assertSame(0, $saved['code']); $this->assertSame(['third:2', 'self:lottery'], $saved['data']['categories']);
+        $this->app->instance('request', Request::create('/admin/promotion/first-deposit-restriction', 'POST', ['categories' => ['third:3']]));
+        $failed = $controller->firstDepositRestriction($admin)->getData(true);
+        $this->assertSame(-3, $failed['code']); $this->assertStringContainsString('categories参数错误', $failed['msg']);
+        $this->app->instance('request', Request::create('/admin/promotion/first-deposit-restriction', 'GET'));
+        $read = $controller->firstDepositRestriction($admin)->getData(true);
+        $this->assertSame(['third:2', 'self:lottery'], $read['data']['categories']);
+        $this->assertCount(8, $read['data']['options']);
+    }
 }

+ 1 - 1
tests/Integration/RechargeBonusTest.php

@@ -284,7 +284,7 @@ class RechargeBonusTest extends TestCase
         $end = now()->timestamp + 60;
         $id = DB::table('promotion_activities')->insertGetId(['title' => '首充奖励', 'type' => 8, 'status' => 1,
             'params' => json_encode($rules), 'start_time' => $start, 'end_time' => $end]);
-        $offer = \App\Support\FirstDepositChoice::offer($id, 'H5', $rules, $start, $end);
+        $offer = \App\Support\FirstDepositChoice::offer($id, 'H5', \App\Support\PromotionRules::withForbidden($rules), $start, $end);
         foreach (['to_address TEXT', 'image TEXT'] as $column) DB::statement('ALTER TABLE bot_recharges ADD COLUMN ' . $column);
         foreach (['payment_type TEXT', 'fee_rate TEXT', 'fee_fixed TEXT', 'fee TEXT'] as $column) DB::statement('ALTER TABLE bot_payment_orders ADD COLUMN ' . $column);
         $call = function (string $action, array $params) {