doge 1 день назад
Родитель
Сommit
72b4a41ab6

+ 1 - 1
app/Http/Controllers/admin/PaymentOrder.php

@@ -136,7 +136,7 @@ class PaymentOrder extends Controller
                 $beforeBalance = (string)$wallet->available_balance;
                 $wallet->available_balance = $availableBalance;
                 $wallet->save();
-                $principal = BalanceLogService::addLog($memberId, $amount, $beforeBalance, $availableBalance, $changeType, $info->id, $remark);
+                $principal = BalanceLogService::addLog($memberId, $amount, $beforeBalance, $availableBalance, $changeType, $info->id, $remark, orderSource: 'payment_orders');
                 \App\Services\RechargeBonusService::grant('payment_order', $info, $principal);
             }
             

+ 1 - 1
app/Http/Controllers/admin/Wallet.php

@@ -399,7 +399,7 @@ class Wallet extends Controller
                 $beforeBalance = (string)$wallet->available_balance;
                 $wallet->available_balance = $available_balance;
                 $wallet->save();
-                $principal = BalanceLogService::addLog($recharge->member_id, $rate_amount, $beforeBalance, $available_balance, "充值", $recharge->id, "用户充值后台审核到账");
+                $principal = BalanceLogService::addLog($recharge->member_id, $rate_amount, $beforeBalance, $available_balance, "充值", $recharge->id, "用户充值后台审核到账", orderSource: 'recharges');
                 \App\Services\RechargeBonusService::grant('recharge', $recharge, $principal);
 
                 $text = "充值结果通知\n";

+ 14 - 0
app/Http/Controllers/api/TelegramWebHook.php

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\api;
 
 use App\Constants\StepStatus;
 use App\Constants\Util;
+use App\Models\User;
 use App\Services\LogService;
 use App\Services\PublicService;
 use App\Services\QianBaoWithdrawService;
@@ -11,6 +12,7 @@ use App\Services\SanJinRechargeService;
 use App\Services\SecretService;
 use App\Services\SettlementService;
 use App\Services\TopUpService;
+use App\Services\TelegramFirstDeposit;
 use App\Services\UserService;
 use App\Services\WalletService;
 use App\Services\WithdrawService;
@@ -55,6 +57,16 @@ class TelegramWebHook extends BaseController
                 $from = $callbackQuery->from;
                 $data = $callbackQuery->data; // 获取 callback_data
                 $callbackId = $callbackQuery->id; // 获取 callback_query 的 ID
+                if (is_string($data) && str_starts_with($data, 'fd:')) {
+                    if ($message->chat->type === 'private' && (string) $message->chat->id === (string) $from->id) {
+                        $member = User::where('member_id', $from->id)->first();
+                        if ($member) {
+                            App::setLocale($member->language ?: 'zh');
+                            $telegram->sendMessage(TelegramFirstDeposit::callback($member, $data));
+                        }
+                    }
+                    return response()->json(['status' => 'ok']);
+                }
                 Util::delCache($message->chat->id);
                 DB::beginTransaction();
                 try {
@@ -165,6 +177,8 @@ class TelegramWebHook extends BaseController
                 $returnMsg = KeyboardService::checkStart($chatId, $text);
                 if ($returnMsg) return $returnMsg;
                 switch ($text) {
+                    case '/firstdeposit':
+                        return TelegramFirstDeposit::menu($user);
                     case "/start":
                         Util::delCache($chatId);
                         self::setReplyKeyboard($chatId, $user->language);

+ 15 - 5
app/Http/Controllers/api/Wallet.php

@@ -12,6 +12,7 @@ use App\Models\Recharge;
 use App\Models\Wallet as WalletModel;
 use App\Models\Withdraw;
 use App\Models\User;
+use App\Support\FirstDepositChoice;
 use App\Models\Bank;
 use App\Models\Address;
 use App\Models\RechargeChannel;
@@ -95,6 +96,7 @@ class Wallet extends BaseController
                 'payment_type' => ['required_without:channel_id', 'nullable', 'string'],
                 'channel_id' => ['nullable', 'integer', 'min:1'],
             ]);
+            $joinFirstDeposit = FirstDepositChoice::parse(request()->input('join_first_deposit'));
             $channelId = !empty($params['channel_id']) ? (int)$params['channel_id'] : null;
             if ($channelId) {
                 $channelType = RechargeChannel::query()->where('data_type', 1)->whereKey($channelId)->value('type');
@@ -127,10 +129,10 @@ class Wallet extends BaseController
                 throw new Exception(lang("不支持此充值方式").$user->recharge_channel_group_id);
             }
             
+            // Confirm before contacting the provider. The wallet lock is released before the external request.
+            FirstDepositChoice::record($user, $joinFirstDeposit, 'online_pay', request()->input('first_deposit_offer'));
             $res = PaymentOrderService::createPay($member_id, $params['amount'], $params['payment_type'], $channelId);
-            if ($res['code'] == 0) { 
-                return $this->success($res);
-            }
+            if ($res['code'] == 0) return $this->success($res);
             return $this->error($res['text']);
         } catch (ValidationException $e) {
             return $this->error($e->validator->errors()->first());
@@ -194,6 +196,7 @@ class Wallet extends BaseController
                 'toAddress' => ['required', 'string'],
                 'image' => ['required', 'url'],
             ]);
+            $joinFirstDeposit = FirstDepositChoice::parse(request()->input('join_first_deposit'));
 
             $member_id = request()->user->member_id;
             $user = User::where('member_id', $member_id)->first();
@@ -212,7 +215,10 @@ class Wallet extends BaseController
             $recharge->status = 0;
             $recharge->type = 2;
             $recharge->image = Util::replacePartInUrl($params['image']);
-            $recharge->save();
+            DB::transaction(function () use ($recharge, $user, $joinFirstDeposit) {
+                FirstDepositChoice::record($user, $joinFirstDeposit, 'usdt_recharge', request()->input('first_deposit_offer'));
+                $recharge->save();
+            });
             return $this->success($recharge,'提交成功');
 
         } catch (ValidationException $e) {
@@ -231,6 +237,7 @@ class Wallet extends BaseController
                 'amount' => ['required', 'numeric', 'min:0.01'],
                 'payment_type' => ['required', 'integer'],
             ]);
+            $joinFirstDeposit = FirstDepositChoice::parse(request()->input('join_first_deposit'));
             
             $member_id = request()->user->member_id;
             $user = User::where('member_id', $member_id)->first();
@@ -252,7 +259,10 @@ class Wallet extends BaseController
             $data['status'] = PaymentOrderService::STATUS_STAY;
             $data['remark'] = '';
             // 创建待处理状态的提现记录
-            $info = PaymentOrder::create($data);
+            $info = DB::transaction(function () use ($data, $user, $joinFirstDeposit) {
+                FirstDepositChoice::record($user, $joinFirstDeposit, 'manual_pay', request()->input('first_deposit_offer'));
+                return PaymentOrder::create($data);
+            });
             return $this->success($info,'提交成功,请等待人工回复');
         } catch (ValidationException $e) {
             return $this->error($e->validator->errors()->first());

+ 6 - 6
app/Services/BalanceLogService.php

@@ -165,19 +165,19 @@ class BalanceLogService extends BaseService
      * @param {*} $remark
      * @return {*}
      */
-    public static function addLog($memberId, $amount, $before_balance, $after_balance, $change_type, $related_id, $remark, $room_id = null)
+    public static function addLog($memberId, $amount, $before_balance, $after_balance, $change_type, $related_id, $remark, $room_id = null, ?string $orderSource = null)
     {
         if (in_array($change_type, self::$levelRechargeTypes, true)
             && DB::connection()->getSchemaBuilder()->hasTable('promotion_first_deposits')) {
-            return DB::transaction(function () use ($memberId, $amount, $before_balance, $after_balance, $change_type, $related_id, $remark, $room_id) {
+            return DB::transaction(function () use ($memberId, $amount, $before_balance, $after_balance, $change_type, $related_id, $remark, $room_id, $orderSource) {
                 Wallet::where('member_id', $memberId)->lockForUpdate()->first();
-                return self::writeLog($memberId, $amount, $before_balance, $after_balance, $change_type, $related_id, $remark, $room_id);
+                return self::writeLog($memberId, $amount, $before_balance, $after_balance, $change_type, $related_id, $remark, $room_id, $orderSource);
             }, 3);
         }
-        return self::writeLog($memberId, $amount, $before_balance, $after_balance, $change_type, $related_id, $remark, $room_id);
+        return self::writeLog($memberId, $amount, $before_balance, $after_balance, $change_type, $related_id, $remark, $room_id, $orderSource);
     }
 
-    private static function writeLog($memberId, $amount, $before_balance, $after_balance, $change_type, $related_id, $remark, $room_id = null)
+    private static function writeLog($memberId, $amount, $before_balance, $after_balance, $change_type, $related_id, $remark, $room_id, ?string $orderSource)
     {
         //回水相关
         if (in_array($change_type, static::$computeBackFlowChangeTypes) && !\App\Support\MemberAudience::isSimulatedMember($memberId)) { // 模拟会员不累计回水
@@ -286,7 +286,7 @@ class BalanceLogService extends BaseService
             WageringService::addRequirementFor((string)$memberId, WageringService::platformRequirement($amount), 'recharge', $result->id, $change_type);
         }
 
-        if (in_array($change_type, self::$levelRechargeTypes, true)) (new PromotionService())->onDeposit($result->refresh());
+        if (in_array($change_type, self::$levelRechargeTypes, true)) (new PromotionService())->onDeposit($result->refresh(), $orderSource);
         return $result;
     }
 

+ 8 - 0
app/Services/BetService.php

@@ -9,6 +9,7 @@ use App\Constants\Util;
 use App\Models\Message;
 use App\Models\PcIssue;
 use App\Models\User;
+use App\Models\Wallet;
 use App\Models\LhcOrder;
 use App\Models\Config;
 use App\Models\LhcOrderItem;
@@ -288,6 +289,13 @@ class BetService extends BaseService
 
         DB::beginTransaction();
         try {
+            Wallet::where('member_id', $memberId)->lockForUpdate()->first(['id']);
+            if ((new PromotionService())->lotteryRestricted($memberId)) {
+                DB::commit();
+                $msg['text'] = lang('活动期间禁止投注该游戏');
+                if ($messageId) $msg['reply_to_message_id'] = $messageId;
+                return $msg;
+            }
             $pc28Switch = (int) Config::where('field', 'pc28_switch')->value('val');
             $template = LotteryOdds::template($pc28Switch === 1 ? 'jisu28' : 'canada28');
             $text = lang('下注期数') . ":{issue_no}\n";

+ 1 - 1
app/Services/PaymentOrderService.php

@@ -753,7 +753,7 @@ class PaymentOrderService extends BaseService
                 $wallet->available_balance = $availableBalance;
                 $wallet->save();
 
-                $principal = BalanceLogService::addLog($order->member_id, $payAmount, $balance, $availableBalance, '三方充值', $order->id, '');
+                $principal = BalanceLogService::addLog($order->member_id, $payAmount, $balance, $availableBalance, '三方充值', $order->id, '', orderSource: 'payment_orders');
                 RechargeBonusService::grant('payment_order', $order, $principal);
 
                 // Deposit hooks can grant an immediately available first-deposit reward.

+ 27 - 1
app/Services/PromotionAdminService.php

@@ -23,7 +23,16 @@ 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)];
+            'coupon_categories' => array_map(fn ($value) => ['value' => $value, 'label' => ShareDefaults::categories()[$value]], PromotionRules::COUPON_CATEGORIES),
+            // 首充奖励“禁止的游戏大类”多选项,与返佣规则的游戏大类一致。
+            'forbidden_categories' => $this->forbiddenOptions(ShareDefaults::commissionCategories())];
+    }
+
+    private function forbiddenOptions(array $categories): array
+    {
+        $options = [];
+        foreach ($categories as $value => $label) $options[] = ['value' => $value, 'label' => $label];
+        return $options;
     }
 
     public function listing(array $input): array
@@ -53,6 +62,12 @@ 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 (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']);
@@ -109,6 +124,7 @@ 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;
@@ -130,6 +146,13 @@ 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 [];
@@ -163,6 +186,9 @@ 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);

+ 46 - 11
app/Services/PromotionService.php

@@ -9,6 +9,8 @@ use App\Models\Wallet;
 use App\Support\PromotionRules as Rules;
 use App\Support\PromotionTranslations;
 use App\Support\ActivityPeriod;
+use App\Support\FirstDepositChoice;
+use App\Services\Sharing\ShareDefaults;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Schema;
 use Illuminate\Validation\ValidationException;
@@ -413,30 +415,40 @@ class PromotionService
         });
     }
 
-    public function onDeposit(BalanceLog $deposit): void
+    public function onDeposit(BalanceLog $deposit, ?string $orderSource = null): void
     {
-        if (!DB::connection()->getSchemaBuilder()->hasTable('promotion_first_deposits') || (int)$deposit->type !== 1 || !in_array($deposit->change_type, ['充值', '人工充值', '三方充值'], true) || bccomp((string)$deposit->amount, '0', 10) <= 0) return;
+        if (!DB::connection()->getSchemaBuilder()->hasTable('promotion_first_deposits') || (int)$deposit->type !== 1
+            || !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)
-                ->whereIn('change_type', ['充值', '人工充值', '三方充值'])->orderBy('created_at')->orderBy('id')->value('id');
+                ->whereIn('change_type', FirstDepositChoice::DEPOSIT_TYPES)->orderBy('created_at')->orderBy('id')->lockForUpdate()->value('id');
             if ((int)$first !== (int)$deposit->id) return;
             $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;
-            $activities = ActivityPeriod::active(PromotionActivity::where('type', Rules::FIRST_DEPOSIT)->where('status', 1), now()->timestamp)->orderByDesc('sort')->orderByDesc('id')->get();
-            foreach ($activities as $candidate) {
-                $activity = $this->activity($candidate->id, true);
-                if (!PromotionTranslations::select($activity->toArray(), $member->language ?: 'zh')) continue;
-                if ((int)$activity->status !== 1 || (int)$activity->type !== Rules::FIRST_DEPOSIT) continue;
-                $published = DB::table('promotion_campaigns')->where('activity_id', $activity->id)->value('first_published_at');
+            $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;
+
+            $activities = ActivityPeriod::active(PromotionActivity::where('type', Rules::FIRST_DEPOSIT)->where('status', 1), now()->timestamp);
+            $ids = $activityId ? [$activityId] : $activities->orderByDesc('sort')->orderByDesc('id')->pluck('id');
+            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;
+                $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;
-                $rules = $this->rules($activity);
+                // 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;
                 try { $this->capacity($activity, $member, $rules['reward_amount']); } catch (ValidationException $e) { continue; }
                 $account = $this->account($activity, $member);
@@ -446,6 +458,29 @@ class PromotionService
         }, 3);
     }
 
+    /** Called inside the Telegram bet transaction after locking the member's wallet. */
+    public function lotteryRestricted(string $memberId): bool
+    {
+        if (!FirstDepositChoice::ready()) return false;
+        $userId = DB::table('users')->where('member_id', $memberId)->value('id');
+        $rewards = DB::table('promotion_rewards')->where('user_id', $userId)->whereIn('status', ['paid', 'locked'])
+            ->where('required_flow', '>', 0)->sharedLock()->get();
+        $categories = array_keys(ShareDefaults::commissionCategories());
+        foreach ($rewards as $reward) {
+            $configured = (array) ($this->json($reward->rule_snapshot)['forbidden_categories'] ?? []);
+            $forbidden = array_intersect($categories, array_filter($configured, 'is_string'));
+            if (!in_array('self:lottery', $forbidden, true) || count($forbidden) === count($categories)) continue;
+            $activity = DB::table('promotion_activities')->where('id', $reward->activity_id)->where('type', Rules::FIRST_DEPOSIT)
+                ->where('status', 1)->sharedLock()->first(['start_time', 'end_time']);
+            if (!$activity || !ActivityPeriod::contains($activity->start_time, $activity->end_time, now()->timestamp)) continue;
+            $flow = $this->json($reward->flow_snapshot);
+            $from = $flow['from'] ?? $reward->created_at;
+            $completed = $this->facts->total($this->facts->betting([(int) $userId], $from, null, $flow['cursors'] ?? [])['effective']);
+            if (bccomp($completed, (string) $reward->required_flow, 10) < 0) return true;
+        }
+        return false;
+    }
+
     private function state(PromotionActivity $activity, User $member, ?object $account, ?object $round, ?string $lang = null): array
     {
         $rules = $this->rules($activity); $type = (int)$activity->type;

+ 1 - 1
app/Services/RechargeService.php

@@ -531,7 +531,7 @@ class RechargeService extends BaseService
             AdminRealtime::record('recharges', $info->id);
             $before = (string)$wallet->available_balance;
             $wallet->available_balance = bcadd($before, $amount, 10); $wallet->save();
-            $principal = BalanceLogService::addLog($info->member_id, $amount, $before, $wallet->available_balance, '充值', $info->id, '');
+            $principal = BalanceLogService::addLog($info->member_id, $amount, $before, $wallet->available_balance, '充值', $info->id, '', orderSource: 'recharges');
             RechargeBonusService::grant('recharge', $info, $principal);
             RechargeBonusService::markChainCredited($info, $principal);
             return true;

+ 6 - 1
app/Services/SanJinRechargeService.php

@@ -8,6 +8,7 @@ use App\Models\Config;
 use App\Models\PaymentOrder;
 use App\Models\Wallet;
 use App\Models\User;
+use App\Support\FirstDepositChoice;
 use Illuminate\Support\Facades\Cache;
 use Telegram\Bot\Api;
 use App\Services\Payment\SanJinService;
@@ -223,7 +224,11 @@ class SanJinRechargeService extends BaseService
 
         $text = lang("请选择支付的通道")." \n";
         $keyboard = [];
-        $groupId = (int)(User::query()->where('member_id', $chatId)->value('recharge_channel_group_id') ?: 1);
+        $member = User::where('member_id', $chatId)->first(['id', 'member_id', 'recharge_channel_group_id']);
+        $groupId = (int) ($member->recharge_channel_group_id ?? 1) ?: 1;
+        if ($member && FirstDepositChoice::ready() && !FirstDepositChoice::deposited((int) $member->id, (string) $member->member_id)) {
+            $keyboard[] = [['text' => lang('首充活动'), 'callback_data' => 'fd:show']];
+        }
         $channel = SanJinService::getChannel('', $groupId);
         $keyboard[] = [
             ['text' => 'USDT', 'callback_data' => "topup@@topup"],

+ 81 - 0
app/Services/TelegramFirstDeposit.php

@@ -0,0 +1,81 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\User;
+use App\Services\Sharing\ShareDefaults;
+use App\Support\FirstDepositChoice;
+use App\Support\PromotionRules;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Validation\ValidationException;
+
+final class TelegramFirstDeposit
+{
+    public static function menu(User $member): array
+    {
+        if (!FirstDepositChoice::ready() || FirstDepositChoice::deposited((int) $member->id, (string) $member->member_id)) {
+            return self::reply($member, lang('没有可参加的首充活动'));
+        }
+
+        $activity = null;
+        foreach ((new PromotionService())->listing((int) $member->id, 'TG', $member->language ?: 'zh', PromotionRules::FIRST_DEPOSIT)['data'] as $item) {
+            if ($item['is_eligible'] && PromotionRules::hasPlayableCategory($item['rules'], 'self:lottery')) {
+                $activity = $item;
+                break;
+            }
+        }
+        $selection = FirstDepositChoice::selection((int) $member->id);
+        if (!$activity && !(int) ($selection->joined ?? 0)) return self::reply($member, lang('没有可参加的首充活动'));
+
+        $text = lang('首充活动');
+        $offer = null;
+        $nonce = bin2hex(random_bytes(16));
+        $buttons = [];
+        if ($activity) {
+            $rules = $activity['rules'];
+            $offer = FirstDepositChoice::offer((int) $activity['activity_id'], 'TG', $rules, $activity['start_time'], $activity['end_time']);
+            $text = $activity['title'] . "\n" . lang('最低首充 :minimum,奖励 :reward,打码倍数 :ratio', [
+                'minimum' => $rules['min_deposit'], 'reward' => $rules['reward_amount'], 'ratio' => $rules['turnover_ratio'],
+            ]);
+            $flow = bcmul($rules['reward_amount'], $rules['turnover_ratio'], 2);
+            if (bccomp($flow, '0', 2) > 0 && $rules['forbidden_categories']) {
+                $categories = array_intersect_key(ShareDefaults::commissionCategories(), array_flip($rules['forbidden_categories']));
+                $text .= "\n" . lang('活动期间完成 :flow 打码量前禁止投注 :games', [
+                    'flow' => $flow, 'games' => implode('、', array_map('lang', $categories)),
+                ]);
+            }
+            $buttons[] = ['text' => lang('参加首充活动'), 'callback_data' => 'fd:' . $nonce . ':1'];
+        }
+        if ((int) ($selection->joined ?? 0)) $text .= "\n" . lang('已保存参加选择');
+        $buttons[] = ['text' => lang('不参加首充活动'), 'callback_data' => 'fd:' . $nonce . ':0'];
+        Cache::put(self::key($member, $nonce), ['offer' => $offer], now()->addMinutes(15));
+        return self::reply($member, $text, [$buttons]);
+    }
+
+    public static function callback(User $member, string $data): array
+    {
+        if ($data === 'fd:show') return self::menu($member);
+        if (!preg_match('/^fd:([a-f0-9]{32}):([01])$/D', $data, $parts)) {
+            return self::reply($member, lang('请重新打开首充活动'));
+        }
+        $issued = Cache::pull(self::key($member, $parts[1]));
+        if (!is_array($issued)) return self::reply($member, lang('请重新打开首充活动'));
+        try {
+            $saved = FirstDepositChoice::record($member, $parts[2] === '1', 'telegram', $issued['offer']);
+        } catch (ValidationException $e) {
+            return self::reply($member, lang($e->validator->errors()->first()));
+        }
+        return self::reply($member, lang($saved ? '首充活动选择已保存' : '已有充值记录,不能再选择首充活动'));
+    }
+
+    private static function key(User $member, string $nonce): string
+    {
+        return 'telegram_first_deposit:' . $member->id . ':' . $nonce;
+    }
+
+    private static function reply(User $member, string $text, array $buttons = []): array
+    {
+        $buttons[] = [['text' => lang('➕充值'), 'callback_data' => 'topup@@sj_apply']];
+        return ['chat_id' => $member->member_id, 'text' => $text, 'reply_markup' => json_encode(['inline_keyboard' => $buttons])];
+    }
+}

+ 129 - 0
app/Support/FirstDepositChoice.php

@@ -0,0 +1,129 @@
+<?php
+
+namespace App\Support;
+
+use App\Models\PromotionActivity;
+use App\Models\User;
+use App\Models\Wallet;
+use App\Services\ActivityEligibilityService;
+use App\Services\PromotionFacts;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\ValidationException;
+
+/**
+ * 与 melbet_sport-api 的 FirstDepositChoice 共用选择表及活动凭据。
+ */
+final class FirstDepositChoice
+{
+    public const TABLE = 'promotion_first_deposit_choices';
+    public const DEPOSIT_TYPES = ['充值', '人工充值', '三方充值'];
+    private const CUTOVERS = 'promotion_first_deposit_cutovers';
+    private static ?\WeakMap $readyConnections = null;
+
+    /** 解析 join_first_deposit:未传返回 null(不改动已有选择),0/1 之外报错。 */
+    public static function parse($value): ?bool
+    {
+        if ($value === null || $value === '') return null;
+        if (in_array($value, [1, '1', true, 'true'], true)) return true;
+        if (in_array($value, [0, '0', false, 'false'], true)) return false;
+        throw ValidationException::withMessages(['join_first_deposit' => 'join_first_deposit只能为0或1']);
+    }
+
+    public static function offer(int $activityId, string $platform, array $rules, ?int $start, ?int $end): string
+    {
+        $terms = [$activityId, $platform, $start, $end, $rules['min_deposit'], $rules['reward_amount'],
+            $rules['turnover_ratio'], $rules['forbidden_categories']];
+        return $activityId . '.' . $platform . '.' . substr(hash('sha256', json_encode($terms, JSON_THROW_ON_ERROR)), 0, 32);
+    }
+
+    /** 与到账共用钱包行锁,锁内读取历史并一次写入完整选择。 */
+    public static function record(User $member, ?bool $joined, string $source, $offer = null): bool
+    {
+        if ($joined === null) return false;
+        if (!self::ready()) self::invalid('首充活动暂不可用,请稍后重试');
+
+        return DB::transaction(function () use ($member, $joined, $source, $offer) {
+            $userId = (int) $member->id;
+            $memberId = (string) $member->member_id;
+            if (!Wallet::where('member_id', $memberId)->lockForUpdate()->first(['id'])) self::invalid('会员钱包不存在');
+            if (self::deposited($userId, $memberId, true)) {
+                if ($joined) self::invalid('已有充值记录,不能再选择首充活动');
+                return false;
+            }
+
+            $activityId = null;
+            $snapshot = null;
+            if ($joined) {
+                [$activityId, $rules] = self::confirmedRules($member, $source, $offer);
+                $snapshot = json_encode($rules, JSON_THROW_ON_ERROR);
+            }
+            $now = PromotionFacts::now();
+            DB::table(self::TABLE)->upsert([
+                'user_id' => $userId, 'member_id' => $memberId, 'joined' => (int) $joined, 'source' => $source,
+                'activity_id' => $activityId, 'rule_snapshot' => $snapshot, 'created_at' => $now, 'updated_at' => $now,
+            ], ['user_id'], ['joined', 'source', 'activity_id', 'rule_snapshot', 'updated_at']);
+            return true;
+        }, 3);
+    }
+
+    private static function confirmedRules(User $member, string $source, $offer): array
+    {
+        if (!is_string($offer) || !preg_match('/^([1-9]\d{0,18})\.(PC|H5|APP|TG)\.[a-f0-9]{32}$/D', $offer, $parts)) {
+            self::invalid('请刷新首充活动并确认参加');
+        }
+        $id = (int) $parts[1];
+        $platform = $parts[2];
+        $activity = PromotionActivity::where('type', PromotionRules::FIRST_DEPOSIT)->whereKey($id)->lockForUpdate()->first();
+        if ((int) $member->from === 2 || ($source === 'telegram') !== ($platform === 'TG') || !$activity || (int) $activity->status !== 1
+            || !ActivityPeriod::contains($activity->start_time, $activity->end_time, now()->timestamp)
+            || !ActivityEligibilityService::isPublishedOn($activity, $platform)
+            || !ActivityEligibilityService::evaluate($activity, ActivityEligibilityService::memberContext($member->member_id))['is_eligible']) {
+            self::invalid('首充活动已变更,请刷新后重新确认');
+        }
+        $rules = 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('首充活动已变更,请刷新后重新确认');
+        }
+        return [$id, $rules];
+    }
+
+    public static function ready(): bool
+    {
+        self::$readyConnections ??= new \WeakMap();
+        $connection = DB::connection();
+        $pdo = $connection->getPdo();
+        if (isset(self::$readyConnections[$pdo])) return true;
+        // Both cutovers must exist before the opt-in rules take effect.
+        if (!$connection->getSchemaBuilder()->hasTable(self::CUTOVERS)
+            || $connection->table(self::CUTOVERS)->useWritePdo()->count() !== 2) return false;
+        return self::$readyConnections[$pdo] = true;
+    }
+
+    public static function selection(int $userId, bool $lock = false): ?object
+    {
+        if (!self::ready()) return null;
+        $query = DB::table(self::TABLE)->where('user_id', $userId);
+        return ($lock ? $query->lockForUpdate() : $query)->first();
+    }
+
+    public static function legacyOrder(?string $source, int $orderId): bool
+    {
+        if (!in_array($source, ['payment_orders', 'recharges'], true) || $orderId <= 0) return false;
+        return $orderId <= (int) DB::table(self::CUTOVERS)->where('source', $source)->value('last_order_id');
+    }
+
+    public static function deposited(int $userId, string $memberId, bool $lock = false): bool
+    {
+        $first = DB::table('promotion_first_deposits')->where('user_id', $userId);
+        if (($lock ? $first->lockForUpdate() : $first)->first(['user_id'])) return true;
+        $logs = DB::table('balance_logs')->where('member_id', $memberId)->where('type', 1)
+            ->where('amount', '>', 0)->whereIn('change_type', self::DEPOSIT_TYPES);
+        return (bool) ($lock ? $logs->lockForUpdate() : $logs)->first(['id']);
+    }
+
+    private static function invalid(string $message): never
+    {
+        throw ValidationException::withMessages(['join_first_deposit' => $message]);
+    }
+}

+ 24 - 2
app/Support/PromotionRules.php

@@ -2,6 +2,7 @@
 
 namespace App\Support;
 
+use App\Services\Sharing\ShareDefaults;
 use Illuminate\Support\Facades\Validator;
 use Illuminate\Validation\ValidationException;
 
@@ -32,7 +33,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'],
+            8 => ['min_deposit' => '100.00', 'reward_amount' => '10.00', 'forbidden_categories' => []],
             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'],
@@ -48,6 +49,8 @@ final class PromotionRules
         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) {
@@ -60,7 +63,26 @@ 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;
+        return ($coupon ? $data + self::coupons($coupon, $data['reward_amount']) : $data) + $forbidden;
+    }
+
+    /** 首充活动期间禁止投注的游戏大类:与返佣规则同一套大类,去重后按大类顺序保存。 */
+    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参数错误:只能选择返佣规则中的游戏大类且不能重复']);
+        }
+        return array_values(array_intersect($options, $value));
+    }
+
+    public static function hasPlayableCategory(array $rules, ?string $onlyCategory = null): bool
+    {
+        if (bccomp(bcmul($rules['reward_amount'], $rules['turnover_ratio'], 2), '0', 2) === 0) return true;
+        $categories = $onlyCategory === null ? array_keys(ShareDefaults::commissionCategories()) : [$onlyCategory];
+        return (bool) array_diff($categories, $rules['forbidden_categories']);
     }
 
     /** Coupon settings stay well-formed in cash mode so switching modes keeps them; completeness is enforced only in coupon mode. */

+ 42 - 0
database/migrations/2026_11_12_120000_add_first_deposit_join_choice.php

@@ -0,0 +1,42 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 首充活动改为会员自愿参加:会员在首笔充值到账前的选择记录在这里,
+ * 首笔充值到账时只有 joined=1 才发放首充奖励;未记录视为不参加。
+ * 会员端(melbet)下注时按 user_id 查询首充奖励,补充索引。
+ */
+return new class extends Migration {
+    public function up(): void
+    {
+        if (!Schema::hasTable('promotion_first_deposit_choices')) {
+            Schema::create('promotion_first_deposit_choices', function (Blueprint $t) {
+                $t->unsignedBigInteger('user_id')->primary(); $t->string('member_id', 64);
+                $t->boolean('joined')->default(false); $t->string('source', 32)->default('');
+                $t->timestamps();
+            });
+        }
+        if (Schema::hasTable('promotion_rewards') && !$this->hasIndex('promotion_rewards', 'promotion_rewards_user_activity')) {
+            Schema::table('promotion_rewards', fn (Blueprint $t) => $t->index(['user_id', 'activity_id'], 'promotion_rewards_user_activity'));
+        }
+    }
+
+    public function down(): void
+    {
+        if (Schema::hasTable('promotion_rewards') && $this->hasIndex('promotion_rewards', 'promotion_rewards_user_activity')) {
+            Schema::table('promotion_rewards', fn (Blueprint $t) => $t->dropIndex('promotion_rewards_user_activity'));
+        }
+        Schema::dropIfExists('promotion_first_deposit_choices');
+    }
+
+    private function hasIndex(string $table, string $name): bool
+    {
+        foreach (Schema::getIndexes($table) as $index) {
+            if (in_array($index['name'], [$name, Schema::getConnection()->getTablePrefix() . $name], true)) return true;
+        }
+        return false;
+    }
+};

+ 38 - 0
database/migrations/2026_11_12_130000_bind_first_deposit_choices.php

@@ -0,0 +1,38 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        if (!Schema::hasColumn('promotion_first_deposit_choices', 'activity_id')) {
+            Schema::table('promotion_first_deposit_choices', fn (Blueprint $table) => $table->unsignedBigInteger('activity_id')->nullable());
+        }
+        if (!Schema::hasColumn('promotion_first_deposit_choices', 'rule_snapshot')) {
+            Schema::table('promotion_first_deposit_choices', fn (Blueprint $table) => $table->json('rule_snapshot')->nullable());
+        }
+
+        if (!Schema::hasTable('promotion_first_deposit_cutovers')) {
+            Schema::create('promotion_first_deposit_cutovers', function (Blueprint $table) {
+                $table->string('source', 32)->primary();
+                $table->unsignedBigInteger('last_order_id');
+            });
+        }
+
+        // Keep the original automatic reward for orders already placed at cutover.
+        $cutovers = array_map(fn (string $source) => [
+            'source' => $source,
+            'last_order_id' => Schema::hasTable($source) ? (int) DB::table($source)->useWritePdo()->max('id') : 0,
+        ], ['payment_orders', 'recharges']);
+        DB::table('promotion_first_deposit_cutovers')->insertOrIgnore($cutovers);
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('promotion_first_deposit_cutovers');
+        Schema::table('promotion_first_deposit_choices', fn (Blueprint $table) => $table->dropColumn(['activity_id', 'rule_snapshot']));
+    }
+};

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

@@ -0,0 +1,167 @@
+# 首充活动参加选择与禁止游戏:会员端接入
+
+首充活动改为会员自愿参加,默认不参加。会员参加后,在活动期间、首充奖励打码量完成前,不能投注后台配置的“禁止的游戏大类”。
+
+接口主机:`/api/promotion/*`、`/api/wallet/recharge`(USDT 凭证充值)和各下注接口在 **melbet_sport-api**;`/api/wallet/createPay`、`/api/wallet/paymentOrder` 在 **bot-28**。使用现有会员 Token,请求头 `lang` 传 `zh/en/vi`。
+
+## 1. 充值页读取首充状态
+
+`GET /api/promotion/firstDeposit?platform=H5`
+
+`platform` 必填,取 `PC/H5/APP`。
+
+返回 `data`:
+
+| 字段 | 说明 |
+|---|---|
+| `has_deposited` | 是否已有成功到账的充值 |
+| `can_choose` | 是否显示“参加首充活动”选项:没有充值记录且当前有可参加的首充活动时为 `true` |
+| `joined` | 当前保存的选择,默认 `false`(不参加) |
+| `selection` | 已确认的选择 `{activity_id, rules}`,未确认时为 `null`。用 `rules` 展示已经确认的充值门槛、奖励、打码倍数和禁投大类 |
+| `first_deposit_offer` | 本页展示的活动凭据。会员确认参加时原样回传,无可参加活动时为 `null` |
+| `activity` | 当前首充活动,无可参加活动时为 `null`。字段:`activity_id/title/min_deposit/reward_amount/turnover_ratio/start_time/end_time`,时间为秒级时间戳,`null` 表示长期 |
+| `forbidden_categories` | 参加后活动期间禁止投注的游戏大类,数组 `[{value,label}]`,`label` 按请求头语言返回;没有限制时为空数组 |
+| `notice` | 按请求头语言返回的限制提示文字,没有限制时为空字符串 |
+| `restrictions` | 已领到首充奖励、当前仍在限制中的记录,见下表;不在限制中为空数组 |
+
+`restrictions[]`:
+
+| 字段 | 说明 |
+|---|---|
+| `activity_id` | 首充活动ID |
+| `reward_id` | 奖励记录ID |
+| `forbidden_categories` | 禁止的游戏大类 `[{value,label}]` |
+| `required_flow` | 首充奖励需要的打码量 |
+| `completed_flow` | 已完成打码量 |
+| `remaining_flow` | 剩余打码量 |
+
+以上金额为字符串:整数不带小数,小数保留两位。
+
+示例:
+
+```json
+{
+  "has_deposited": false,
+  "can_choose": true,
+  "joined": false,
+  "selection": null,
+  "first_deposit_offer": "3.H5.5e884898da28047151d0e56f8dc62927",
+  "activity": {"activity_id": 3, "title": "首充奖励", "min_deposit": "100", "reward_amount": "25", "turnover_ratio": "2", "start_time": 1793289600, "end_time": 1824825599},
+  "forbidden_categories": [{"value": "third:2", "label": "电子"}, {"value": "self:lottery", "label": "F1彩票"}],
+  "notice": "参加首充活动后,活动期间在完成首充奖励打码量前不能投注:电子、F1彩票",
+  "restrictions": []
+}
+```
+
+`can_choose=false` 时不显示新的参加选项。会员确认前展示 `notice`。已有 `selection` 时展示已确认条款,即使页面同时有新的可选活动。
+
+游戏大类取值:
+
+| value | 中文 | 说明 |
+|---|---|---|
+| `third:1` | 真人 | 三方游戏 gameType=1 |
+| `third:2` | 电子 | 三方游戏 gameType=2 |
+| `third:4` | 体育 | 三方游戏 gameType=4 |
+| `third:5` | 电竞 | 三方游戏 gameType=5 |
+| `third:6` | 捕鱼 | 三方游戏 gameType=6 |
+| `third:7` | 棋牌 | 三方游戏 gameType=7 |
+| `self:sport` | F1体育 | 自营体育、足球游戏 |
+| `self:lottery` | F1彩票 | 自营彩票(PC28、极速28、六合彩、极速彩) |
+
+## 2. 提交会员的选择
+
+### 2.1 充值下单时一并提交(推荐)
+
+以下充值接口新增可选参数 `join_first_deposit`:
+
+| 接口 | 主机 |
+|---|---|
+| `POST /api/wallet/recharge`(USDT 上传凭证充值) | melbet_sport-api |
+| `POST /api/wallet/createPay`(在线支付) | bot-28 |
+| `POST /api/wallet/paymentOrder`(人工充值) | bot-28 |
+
+| 取值 | 含义 |
+|---|---|
+| `1` | 确认参加,同时传 `first_deposit_offer` |
+| `0` | 不参加 |
+| 不传 | 不改动之前保存的选择 |
+
+无效取值或凭据会在创建订单前被拒绝。若活动已变更,重新读取活动并请会员确认。已有选择时,普通充值下单省略这两个字段。在线支付先保存选择,再发起支付;支付通道失败时,已保存的选择仍有效。
+
+```json
+{
+  "amount": "100",
+  "payment_type": 1,
+  "join_first_deposit": 1,
+  "first_deposit_offer": "3.H5.5e884898da28047151d0e56f8dc62927"
+}
+```
+
+### 2.2 单独保存选择
+
+没有下单接口的充值方式(如自动链上充值,会员直接转账到收款地址)在勾选/取消时调用:
+
+`POST /api/promotion/firstDepositChoice`
+
+| 参数 | 说明 |
+|---|---|
+| `platform` | `PC/H5/APP` |
+| `join` | `1` 参加,`0` 不参加 |
+| `first_deposit_offer` | `join=1` 时必传,从同一 `platform` 的首充状态接口取值 |
+
+成功返回与 `GET /api/promotion/firstDeposit` 相同的结构。已有充值记录时返回失败,`msg` 为 `已有充值记录,不能再选择首充活动`。
+
+### 2.3 选择生效规则
+
+- 以首笔充值到账前最后一次保存的选择为准。切换前已下单且没有选择记录的充值按原有首充规则处理。
+- 首笔充值到账后不能再修改选择。
+- 首笔充值时不参加,第二笔及以后的充值都不再算首充,不会再有首充奖励。
+- 奖励按确认的活动及条款发放,首笔充值仍需满足门槛,且活动需有效并有可用奖励额度。
+- Telegram 会员可在充值菜单或用 `/firstdeposit` 确认选择。
+
+## 3. 下注和进入游戏被拒绝
+
+已领取首充奖励的会员,在首充活动上架且处于活动时间内、首充奖励打码量未完成时,投注或进入禁止的游戏大类会失败:
+
+```json
+{
+  "code": 11003,
+  "show": 1,
+  "msg": "活动期间禁止投注该游戏",
+  "data": {
+    "category": "self:lottery",
+    "category_name": "F1彩票",
+    "activity_id": 3,
+    "required_flow": "50",
+    "completed_flow": "30",
+    "remaining_flow": "20"
+  }
+}
+```
+
+- `msg` 按请求头 `lang` 返回;未传或取值无效时按会员资料语言返回:
+  - zh:`活动期间禁止投注该游戏`
+  - en:`Betting on this game is not allowed during the promotion`
+  - vi:`Không được đặt cược trò chơi này trong thời gian khuyến mãi`
+- `category_name` 与 `msg` 使用同一语言。
+- 金额字段与活动接口格式一致:整数不带小数,小数保留两位(如 `20.5 → "20.50"`)。
+- 暂时无法确认限制时同样拒绝下注,返回 `code=0`,`msg` 按请求头语言:zh `暂时无法确认活动限制,请稍后重试`、en `Unable to verify promotion restrictions. Please try again later.`、vi `Tạm thời không thể xác minh giới hạn khuyến mãi, vui lòng thử lại sau.`
+- 打码量按发奖后已结算的有效投注累计,未结算注单不计。打码量完成、活动下架或活动结束后即可正常投注。
+
+会返回该错误的接口:
+
+| 接口 | 游戏大类 |
+|---|---|
+| `POST /api/game/bet` | `self:lottery` |
+| `POST /api/lhcOrder/submit` | `self:lottery` |
+| `POST /api/jisuGameOrder/submit` | `self:lottery` |
+| `POST /api/order/submit` | `self:sport` |
+| `POST /api/order/submit_delete` | `self:sport` |
+| `POST /api/sportGameOrder/submit` | `self:sport` |
+| `POST /api/egame/gameUrl`(进入三方游戏并转入余额) | `third:{gameType}`,按游戏实际所属大类判断 |
+
+`/api/egame/gameUrl` 被拒绝时不会转入余额,也不返回游戏链接。
+
+## 4. 活动列表与详情
+
+`GET /api/promotion/list?type=8` 和 `GET /api/promotion/detail` 的 `rules` 新增 `forbidden_categories`,为游戏大类 value 数组,例如 `["third:2","self:lottery"]`;空数组表示不限制。

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

@@ -0,0 +1,64 @@
+# 首充奖励:禁止的游戏大类(后台接入)
+
+首充奖励(`/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` 默认为 `[]`(不限制)。
+
+## 保存
+
+`POST /admin/promotion/submit` 的 `params` 新增 `forbidden_categories`,传选中项的 `value` 数组:
+
+```json
+{
+  "type": 8,
+  "params": {
+    "min_deposit": "100",
+    "reward_amount": "25",
+    "turnover_ratio": "2",
+    "forbidden_categories": ["third:2", "self:lottery"]
+  }
+}
+```
+
+- 传 `[]` 表示不限制。
+- 编辑时不传该字段会保留原值。
+- 只能传上面选项中的值且不能重复,否则返回 `code=-3`,`msg` 为 `forbidden_categories参数错误:只能选择返佣规则中的游戏大类且不能重复`。
+- 奖励有打码量时,至少保留一个可投注的游戏大类。
+- 返回顺序按选项顺序整理,与提交顺序无关。
+
+## 列表与详情
+
+`GET /admin/promotion?type=8` 和 `GET /admin/promotion/detail?id=` 的每条首充奖励新增:
+
+| 字段 | 说明 |
+|---|---|
+| `params.forbidden_categories` | 已选 value 数组,编辑表单回填多选 |
+| `forbidden_categories` | 已选项 `[{value,label}]`,列表直接展示 `label`;为空数组时展示为“不限制” |
+
+旧配置没有该规则时两者都返回 `[]`。
+
+## 生效说明
+
+- 修改后的条款供之后确认参加的会员选择。已确认的选择按确认时的条款发奖,活动有效期、参与资格和奖励额度在到账时核对。
+- 已发放的奖励按记录的条款限制。历史配置禁投全部大类时解除禁投,打码要求保留。
+- 切换前已下单、没有选择记录的充值按原规则发放首充奖励。
+- Telegram 只展示不限发布客户端、可在机器人内完成打码的活动。
+
+对应会员端文档:[《首充活动参加选择与禁止游戏:会员端接入》](../../前端接口/独立活动/首充活动参加与禁止游戏-会员接入.md)。

+ 22 - 0
lang/en/messages.php

@@ -478,4 +478,26 @@ return [
     '不支持此充值方式' => 'Not Support This Recharge Type', 
     '不支持此提现方式' =>  'Not Support This Withdrawal Type',
     '您已有1笔提现订单正常处理中!' => 'You Have 1 Withdrawal Order In Normal Processing!',
+    '首充活动' => 'First deposit promotion',
+    '参加首充活动' => 'Join the promotion',
+    '不参加首充活动' => 'Skip the promotion',
+    '没有可参加的首充活动' => 'No first deposit promotion is available',
+    '最低首充 :minimum,奖励 :reward,打码倍数 :ratio' => 'Minimum deposit :minimum, bonus :reward, turnover multiplier :ratio',
+    '活动期间完成 :flow 打码量前禁止投注 :games' => 'During the promotion, complete :flow in turnover before betting on :games',
+    '已保存参加选择' => 'You have a saved opt-in',
+    '请重新打开首充活动' => 'Open the first deposit promotion again',
+    '首充活动选择已保存' => 'First deposit choice saved',
+    '已有充值记录,不能再选择首充活动' => 'You can only change your choice before your first deposit',
+    '请刷新首充活动并确认参加' => 'Refresh the first deposit promotion and confirm your choice',
+    '首充活动已变更,请刷新后重新确认' => 'The promotion has changed. Refresh and confirm again',
+    '首充活动暂不可用,请稍后重试' => 'The first deposit promotion is temporarily unavailable',
+    '活动期间禁止投注该游戏' => 'Betting on this game is not allowed during the promotion',
+    '真人' => 'Live Casino',
+    '电子' => 'Slots',
+    '体育' => 'Sports',
+    '电竞' => 'E-sports',
+    '捕鱼' => 'Fishing',
+    '棋牌' => 'Card Games',
+    'F1体育' => 'F1 Sports',
+    'F1彩票' => 'F1 Lottery',
 ];

+ 22 - 0
lang/vi/messages.php

@@ -478,4 +478,26 @@ return [
     '不支持此充值方式' => 'Không hỗ trợ phương thức nạp này',
     '不支持此提现方式' => 'Không hỗ trợ phương thức rút này',
     '您已有1笔提现订单正常处理中!' => 'Bạn đã có 1 lệnh rút tiền đang được xử lý!',
+    '首充活动' => 'Khuyến mãi nạp đầu',
+    '参加首充活动' => 'Tham gia khuyến mãi',
+    '不参加首充活动' => 'Bỏ qua khuyến mãi',
+    '没有可参加的首充活动' => 'Không có khuyến mãi nạp đầu khả dụng',
+    '最低首充 :minimum,奖励 :reward,打码倍数 :ratio' => 'Nạp tối thiểu :minimum, thưởng :reward, hệ số doanh số cược :ratio',
+    '活动期间完成 :flow 打码量前禁止投注 :games' => 'Trong thời gian khuyến mãi, hãy hoàn thành :flow doanh số cược trước khi cược :games',
+    '已保存参加选择' => 'Bạn đã lưu lựa chọn tham gia',
+    '请重新打开首充活动' => 'Vui lòng mở lại khuyến mãi nạp đầu',
+    '首充活动选择已保存' => 'Đã lưu lựa chọn khuyến mãi nạp đầu',
+    '已有充值记录,不能再选择首充活动' => 'Bạn chỉ có thể đổi lựa chọn trước lần nạp đầu tiên',
+    '请刷新首充活动并确认参加' => 'Vui lòng tải lại khuyến mãi nạp đầu và xác nhận lựa chọn',
+    '首充活动已变更,请刷新后重新确认' => 'Khuyến mãi đã thay đổi. Vui lòng tải lại và xác nhận lại',
+    '首充活动暂不可用,请稍后重试' => 'Khuyến mãi nạp đầu tạm thời chưa khả dụng',
+    '活动期间禁止投注该游戏' => 'Không được đặt cược trò chơi này trong thời gian khuyến mãi',
+    '真人' => 'Casino trực tuyến',
+    '电子' => 'Nổ hũ',
+    '体育' => 'Thể thao',
+    '电竞' => 'Thể thao điện tử',
+    '捕鱼' => 'Bắn cá',
+    '棋牌' => 'Game bài',
+    'F1体育' => 'Thể thao F1',
+    'F1彩票' => 'Xổ số F1',
 ];

+ 22 - 0
lang/zh/messages.php

@@ -478,4 +478,26 @@ return [
     '不支持此充值方式' => '不支持此充值方式',
     '不支持此提现方式' => '不支持此提现方式',
     '您已有1笔提现订单正常处理中!' => '您已有1笔提现订单正常处理中!',
+    '首充活动' => '首充活动',
+    '参加首充活动' => '参加首充活动',
+    '不参加首充活动' => '不参加首充活动',
+    '没有可参加的首充活动' => '没有可参加的首充活动',
+    '最低首充 :minimum,奖励 :reward,打码倍数 :ratio' => '最低首充 :minimum,奖励 :reward,打码倍数 :ratio',
+    '活动期间完成 :flow 打码量前禁止投注 :games' => '活动期间完成 :flow 打码量前禁止投注 :games',
+    '已保存参加选择' => '已保存参加选择',
+    '请重新打开首充活动' => '请重新打开首充活动',
+    '首充活动选择已保存' => '首充活动选择已保存',
+    '已有充值记录,不能再选择首充活动' => '已有充值记录,不能再选择首充活动',
+    '请刷新首充活动并确认参加' => '请刷新首充活动并确认参加',
+    '首充活动已变更,请刷新后重新确认' => '首充活动已变更,请刷新后重新确认',
+    '首充活动暂不可用,请稍后重试' => '首充活动暂不可用,请稍后重试',
+    '活动期间禁止投注该游戏' => '活动期间禁止投注该游戏',
+    '真人' => '真人',
+    '电子' => '电子',
+    '体育' => '体育',
+    '电竞' => '电竞',
+    '捕鱼' => '捕鱼',
+    '棋牌' => '棋牌',
+    'F1体育' => 'F1体育',
+    'F1彩票' => 'F1彩票',
 ];

+ 30 - 0
tests/Integration/LotteryBetSourceTest.php

@@ -269,6 +269,36 @@ class LotteryBetSourceTest extends TestCase
         $this->assertTrue(LotteryOdds::items(LotteryOdds::template('jisu:2')->id)->where('item_id', $itemId)->exists());
     }
 
+    #[RunInSeparateProcess]
+    #[PreserveGlobalState(false)]
+    public function test_telegram_bet_obeys_the_first_deposit_restriction_before_debiting(): void
+    {
+        $this->app->setLocale('zh');
+        foreach ([
+            '2026_10_26_120000_create_maintenance_settings.php',
+            '2026_10_26_160000_create_promotion_activities.php',
+            '2026_11_06_130000_add_bet_source_to_lhc_order.php',
+            '2026_11_12_120000_add_first_deposit_join_choice.php',
+            '2026_11_12_130000_bind_first_deposit_choices.php',
+        ] as $migration) (require dirname(__DIR__, 2) . '/database/migrations/' . $migration)->up();
+        DB::statement('CREATE TABLE bot_share_bet_facts (id INTEGER PRIMARY KEY,user_id INTEGER,source TEXT,source_id TEXT,
+            bet_at TEXT,bet_amount TEXT,net_profit TEXT,currency TEXT,settled INTEGER,voided INTEGER)');
+        $activity = DB::table('promotion_activities')->insertGetId(['title' => '首充', 'type' => 8, 'status' => 1,
+            'params' => '{}', 'start_time' => time() - 60, 'end_time' => time() + 60]);
+        $now = \App\Services\PromotionFacts::now();
+        DB::table('promotion_rewards')->insert(['activity_id' => $activity, 'user_id' => 1, 'account_id' => 1, 'round_no' => 1,
+            'amount' => '25', 'required_flow' => '50', 'status' => 'paid', 'rule_snapshot' => '{"forbidden_categories":["self:lottery"]}',
+            'created_at' => $now]);
+
+        $this->assertSame('活动期间禁止投注该游戏', BetService::bet('1001', '大10')['text']);
+        $this->assertSame(0, DB::table('lhc_order')->count());
+        $this->assertEquals(100, DB::table('wallets')->where('member_id', '1001')->value('available_balance'));
+
+        DB::table('share_bet_facts')->insert(['user_id' => 1, 'source' => 'sport_order', 'source_id' => '1', 'bet_at' => $now,
+            'bet_amount' => '50', 'net_profit' => '-50', 'currency' => config('third_game.currency', 'CNY'), 'settled' => 1, 'voided' => 0]);
+        $this->assertStringContainsString('下注成功', BetService::bet('1001', '大10')['text']);
+    }
+
     #[RunInSeparateProcess]
     #[PreserveGlobalState(false)]
     public function test_telegram_uses_the_selected_default_regardless_of_template_sort(): void

+ 160 - 6
tests/Integration/PromotionActivitiesTest.php

@@ -8,6 +8,9 @@ use App\Models\BalanceLog;
 use App\Services\PromotionAdminService;
 use App\Services\PromotionFacts;
 use App\Services\PromotionService;
+use App\Services\TelegramFirstDeposit;
+use App\Models\User;
+use App\Support\FirstDepositChoice;
 use App\Support\PromotionRules;
 use Carbon\Carbon;
 use Illuminate\Config\Repository;
@@ -56,6 +59,8 @@ class PromotionActivitiesTest extends TestCase
         ] 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();
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_07_180000_allow_permanent_activities.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_12_120000_add_first_deposit_join_choice.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_12_130000_bind_first_deposit_choices.php')->up();
         $this->member(1); $this->member(2, 1);
         DB::table('share_policies')->insert(['id' => 1, 'effective_date' => '2026-10-01', 'valid_member_min_deposit' => '200', 'valid_member_min_effective_bet' => '300']);
         $this->service = new PromotionService();
@@ -82,6 +87,18 @@ class PromotionActivitiesTest extends TestCase
             'vi' => ['title' => 'Khuyến mãi', 'content' => '<p>Nội dung</p>']];
     }
 
+    /** 会员在首笔充值前勾选参加首充活动。 */
+    private function join(int $id, $value = 1): bool
+    {
+        $joined = FirstDepositChoice::parse($value);
+        $offer = null;
+        if ($joined) {
+            $activity = $this->service->listing($id, 'H5', 'zh', 8)['data'][0];
+            $offer = FirstDepositChoice::offer($activity['activity_id'], 'H5', $activity['rules'], $activity['start_time'], $activity['end_time']);
+        }
+        return FirstDepositChoice::record(User::find($id), $joined, 'test', $offer);
+    }
+
     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()]);
@@ -134,7 +151,7 @@ class PromotionActivitiesTest extends TestCase
         }
         $this->assertSame(0, PromotionAdminService::expire());
         $id = (int)DB::table('promotion_activities')->where('type', 8)->value('id');
-        $this->service->onDeposit($this->deposit(1, '100'));
+        $this->join(1); $this->service->onDeposit($this->deposit(1, '100'));
         $this->assertSame(1, DB::table('promotion_rewards')->where('activity_id', $id)->count());
     }
 
@@ -312,7 +329,7 @@ class PromotionActivitiesTest extends TestCase
     public function test_first_deposit_translation_does_not_split_or_duplicate_awards(): void
     {
         $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]);
-        DB::table('users')->where('id', 1)->update(['language' => 'vi']);
+        DB::table('users')->where('id', 1)->update(['language' => 'vi']); $this->join(1);
         $deposit = $this->deposit(1, '100'); $this->service->onDeposit($deposit);
         $this->assertSame(1, DB::table('promotion_rewards')->where('activity_id', $id)->count());
         DB::table('users')->where('id', 1)->update(['language' => 'en']);
@@ -428,7 +445,7 @@ class PromotionActivitiesTest extends TestCase
 
     public function test_first_deposit_is_historical_first_and_does_not_refill_on_relisting(): void
     {
-        $id = $this->activity(8, ['min_deposit' => '100', 'reward_amount' => '25']);
+        $id = $this->activity(8, ['min_deposit' => '100', 'reward_amount' => '25']); $this->join(1); $this->join(2);
         $first = $this->deposit(1, '99'); $this->service->onDeposit($first); $this->service->onDeposit($first);
         $second = $this->deposit(1, '1000'); $this->service->onDeposit($second);
         $this->assertSame(0, DB::table('promotion_rewards')->count());
@@ -573,7 +590,7 @@ class PromotionActivitiesTest extends TestCase
     {
         // 活动奖金不再冻结:直接进入可用余额,流水要求计入统一提现打码量(required_flow 仍保留在奖励记录上)
         $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25, 'turnover_ratio' => 2]);
-        $this->service->onDeposit($this->deposit(1, '100'));
+        $this->join(1); $this->service->onDeposit($this->deposit(1, '100'));
         $reward = DB::table('promotion_rewards')->where('activity_id', $id)->first();
         $this->assertSame('paid', $reward->status);
         $this->assertSame(0, bccomp('50', (string)$reward->required_flow, 2));
@@ -772,7 +789,7 @@ class PromotionActivitiesTest extends TestCase
             'recharges' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,member_id TEXT,status INTEGER,amount TEXT,created_at TEXT,updated_at TEXT',
         ] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')');
         foreach (['huishui_percentage' => '0', 'huishui_restriction' => '0', 'recharge_rate' => '0'] as $field => $val) DB::table('config')->insert(compact('field', 'val'));
-        $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]);
+        $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]); $this->join(1); $this->join(2);
         $source = DB::table('recharges')->insertGetId(['member_id' => 'm1', 'status' => 0, 'amount' => 100]);
         $recharge = \App\Models\Recharge::find($source);
         $this->assertTrue(\App\Services\RechargeService::creditConfirmedRecharge($recharge, ['status' => 1], '100.00'));
@@ -821,7 +838,7 @@ class PromotionActivitiesTest extends TestCase
             'payment_orders' => 'id INTEGER PRIMARY KEY,member_id TEXT,status INTEGER,type INTEGER,amount TEXT,remark TEXT,state INTEGER,created_at TEXT,updated_at TEXT',
         ] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')');
         foreach (['huishui_percentage' => '0', 'huishui_restriction' => '0', 'recharge_rate' => '0'] as $field => $val) DB::table('config')->insert(compact('field', 'val'));
-        $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]);
+        $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]); $this->join(1);
         DB::table('payment_orders')->insert(['id' => 1, 'member_id' => 'm1', 'status' => 5, 'type' => 3, 'amount' => '100']);
         $this->app->instance('request', Request::create('/admin/paymentOrder/manualAudit', 'POST', ['id' => 1, 'status' => 2]));
         $body = (new \App\Http\Controllers\admin\PaymentOrder())->manualAudit()->getData(true);
@@ -905,4 +922,141 @@ class PromotionActivitiesTest extends TestCase
         $this->invalid(fn () => $admin->records(['activity_id' => 99999, 'kind' => 'rewards']), '独立活动不存在');
         $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
+    {
+        $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_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->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->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_forbidden_categories_use_commission_categories_and_show_in_admin_list(): 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->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'));
+        $snapshot = json_decode(DB::table('promotion_rewards')->where('user_id', 1)->value('rule_snapshot'), true);
+        $this->assertSame(['third:5'], $snapshot['forbidden_categories']);
+    }
+
+    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, 'forbidden_categories' => ['self:lottery'],
+        ]]);
+        $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->invalid(fn () => FirstDepositChoice::record(User::find(2), true, 'test', $offer), '首充活动已变更,请刷新后重新确认');
+        $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, 'forbidden_categories' => ['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_first_deposit_with_turnover_cannot_forbid_every_category(): 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);
+    }
+
+    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']]);
+        $member = User::find(1);
+        $reply = TelegramFirstDeposit::menu($member);
+        $button = json_decode($reply['reply_markup'], true)['inline_keyboard'][0][0]['callback_data'];
+        TelegramFirstDeposit::callback(User::find(2), $button);
+        $this->assertNull(FirstDepositChoice::selection(2));
+        TelegramFirstDeposit::callback($member, $button);
+        $this->assertSame($activity, (int) FirstDepositChoice::selection(1)->activity_id);
+        $this->service->onDeposit($this->deposit(1, '100'));
+        $this->assertFalse($this->service->lotteryRestricted('m1'));
+
+        $this->assertTrue($this->join(2));
+        $this->assertSame($lotteryForbidden, (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'));
+    }
 }

+ 46 - 0
tests/Integration/RechargeBonusTest.php

@@ -274,4 +274,50 @@ class RechargeBonusTest extends TestCase
         $this->assertSame('admin/rechargeBonus/save', $route->defaults['permission_uri']);
 
     }
+
+    public function test_member_deposit_orders_save_first_deposit_choice_until_the_first_deposit(): void
+    {
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_12_120000_add_first_deposit_join_choice.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_12_130000_bind_first_deposit_choices.php')->up();
+        $rules = \App\Support\PromotionRules::normalize(8, ['min_deposit' => 10]);
+        $start = now()->timestamp - 60;
+        $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);
+        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) {
+            $request = Request::create('/api/wallet/' . $action, 'POST', $params);
+            $request->user = \App\Models\User::find(1); $this->app->instance('request', $request);
+            return (new \App\Http\Controllers\api\Wallet())->{$action}()->getData(true);
+        };
+        $choice = fn () => DB::table('promotion_first_deposit_choices')->where('user_id', 1)->first();
+        $usdt = ['net' => 'TRC20', 'amount' => '10', 'toAddress' => 'addr', 'image' => 'https://example.test/a.png'];
+        $body = $call('recharge', $usdt + ['join_first_deposit' => 'yes']);
+        $this->assertNotSame(0, $body['code']); $this->assertSame('join_first_deposit只能为0或1', $body['msg']);
+        $this->assertSame(0, DB::table('recharges')->count());
+        $this->assertSame(0, $call('recharge', $usdt + ['join_first_deposit' => '1', 'first_deposit_offer' => $offer])['code']);
+        $this->assertSame([1, 'usdt_recharge'], [(int)$choice()->joined, $choice()->source]);
+        $manual = ['amount' => '100', 'payment_type' => 1];
+        DB::unprepared("CREATE TRIGGER reject_choice BEFORE UPDATE ON bot_promotion_first_deposit_choices BEGIN SELECT RAISE(ABORT, 'rejected in test'); END");
+        $this->assertNotSame(0, $call('paymentOrder', $manual + ['join_first_deposit' => '0'])['code']);
+        $this->assertSame(0, DB::table('payment_orders')->count());
+        DB::unprepared('DROP TRIGGER reject_choice');
+        DB::unprepared("CREATE TRIGGER reject_order BEFORE INSERT ON bot_payment_orders BEGIN SELECT RAISE(ABORT, 'rejected in test'); END");
+        $this->assertNotSame(0, $call('paymentOrder', $manual + ['join_first_deposit' => '0'])['code']);
+        $this->assertSame(1, (int) $choice()->joined);
+        DB::unprepared('DROP TRIGGER reject_order');
+        $this->assertSame(0, $call('paymentOrder', $manual + ['join_first_deposit' => '0'])['code']);
+        $this->assertSame([0, 'manual_pay'], [(int)$choice()->joined, $choice()->source]);
+        // 不传不改动已有选择。
+        $this->assertSame(0, $call('paymentOrder', ['amount' => '100', 'payment_type' => 1])['code']);
+        $this->assertSame([0, 'manual_pay'], [(int)$choice()->joined, $choice()->source]);
+        // 首笔到账后不再改动。
+        DB::table('balance_logs')->insert(['member_id' => 'm1', 'type' => 1, 'amount' => '100', 'change_type' => '充值', 'created_at' => '2026-10-26 12:00:00']);
+        $late = $call('recharge', $usdt + ['join_first_deposit' => 1, 'first_deposit_offer' => $offer]);
+        $this->assertSame('已有充值记录,不能再选择首充活动', $late['msg']);
+        $this->assertSame(1, DB::table('recharges')->count());
+        $this->assertSame(0, (int)$choice()->joined);
+    }
 }