doge 18 часов назад
Родитель
Сommit
197bcd88a1

+ 6 - 3
app/Http/Controllers/admin/ActivityReward.php

@@ -27,7 +27,7 @@ class ActivityReward extends Controller
             $where = ActivityRewardService::getWhere($params);
             $query = ActivityRewardModel::where($where);
             $count = $query->count();
-            $list = $query->orderByDesc('id')
+            $list = $query->orderByDesc('sort')->orderByDesc('id')
                 ->forPage($page, $limit)->get()->toArray();
             foreach ($list as &$item) {
                 if (!empty($item['params'])) {
@@ -59,11 +59,14 @@ class ActivityReward extends Controller
                 'detail_banner' => ['required'],
                 'pc_banner' => ['required'],
                 'status' => ['required', 'integer', 'min:0', 'max:1'],
-                'type' => ['required'],
+                'type' => ['required', 'integer', 'min:1', 'max:6'],
                 'lang' => ['required', 'string'],
                 'content' => ['nullable', 'string'],
                 'params' => ['nullable', 'array'],
             ]);
+            $params = array_merge($params, array_intersect_key(request()->all(), ActivityRewardService::configurationDefaults()));
+            // Wrong-mode inputs are rejected rather than silently disabling a limit.
+            $params = array_merge($params, array_intersect_key(request()->all(), ['budget' => true, 'budget_used' => true]));
             $params['params'] = empty($params['params']) ? null : json_encode($params['params']);
             ActivityRewardService::submit($params);
             DB::commit();
@@ -95,4 +98,4 @@ class ActivityReward extends Controller
         }
         return $this->success([], '删除成功');
     }
-}
+}

+ 2 - 2
app/Http/Controllers/admin/ActivityUser.php

@@ -44,7 +44,7 @@ class ActivityUser extends Controller
         try {
             $params = request()->validate([
                 'id' => ['required', 'integer', 'min:1'],
-                'amount' => ['required', 'numeric', 'min:0.01'],
+                'amount' => ['required', 'numeric', 'min:0.01', 'regex:/^\d{1,12}(\.\d{1,2})?$/'],
                 'betting_amount' => ['required', 'integer', 'min:0', 'max:9999999'],
             ]);
             ActivityUserService::gift($params['id'], $params['amount'], $params['betting_amount']);
@@ -92,4 +92,4 @@ class ActivityUser extends Controller
         }
         return $this->success($result);
     }
-}
+}

+ 28 - 12
app/Http/Controllers/api/ActivityReward.php

@@ -9,6 +9,8 @@ use App\Models\ActivityUser;
 use App\Models\Config;
 use App\Models\Recharge;
 use App\Services\ActivityRewardService;
+use App\Services\ActivityEligibilityService;
+use App\Services\ActivityQuotaService;
 use App\Services\ActivityUserService;
 use App\Services\ConfigService;
 use App\Services\PhoneCodeService;
@@ -95,13 +97,24 @@ class ActivityReward extends BaseController
             request()->validate([
                 'member_id' => ['required', 'integer', 'exists:users,member_id'],
                 'activity_id' => ['required', 'integer'],
+                'platform' => ['nullable', 'string', 'in:PC,H5,APP'],
             ]);
             $memberId = request()->input('member_id');
+            $activityId = request()->input('activity_id');
+            $activity = ActivityRewardService::findOne(['status' => ActivityRewardModel::STATUS_UP, 'id' => $activityId, 'type' => 1]);
+            if (!$activity) throw new Exception('活动不存在', HttpStatus::CUSTOM_ERROR);
+            if (!ActivityEligibilityService::isPublishedOn($activity, request()->input('platform'))) {
+                throw new Exception('此平台未发布该活动', HttpStatus::CUSTOM_ERROR);
+            }
+            $eligibility = ActivityEligibilityService::evaluate($activity, ActivityEligibilityService::memberContext($memberId));
+            if (!$eligibility['is_eligible']) throw new Exception($eligibility['ineligible_reason'], HttpStatus::CUSTOM_ERROR);
+            if (!ActivityQuotaService::canAward($activity, $memberId)) {
+                throw new Exception($activity->quota_enabled ? '活动获奖份数已用完' : '活动预算已用完', HttpStatus::CUSTOM_ERROR);
+            }
             $user = UserService::findOne(['member_id' => $memberId]);
             if (empty($user->phone)) throw new Exception('请先绑定手机号', HttpStatus::NOT_BIND_PHONE);
 
 
-            $activityId = request()->input('activity_id');
             if (ActivityUser::where('member_id', $memberId)->where('activity_id', $activityId)->exists()) {
                 throw new Exception('已参与过此活动,每个活动仅可参加一次', HttpStatus::CUSTOM_ERROR);
             }
@@ -126,13 +139,6 @@ class ActivityReward extends BaseController
             }
 
             $time = time();
-            $activity = ActivityRewardService::findOne([
-//                'start_time' => ['<=', $time],
-//                'end_time' => ['>=', $time],
-                'status' => ActivityRewardModel::STATUS_UP,
-                'id' => $activityId
-            ]);
-            if (!$activity) throw new Exception('活动不存在', HttpStatus::CUSTOM_ERROR);
             if (strtotime($activity->start_time) > $time) throw new Exception("活动未开始$activity->start_time", HttpStatus::CUSTOM_ERROR);
             if (strtotime($activity->end_time) < $time) throw new Exception('活动已结束', HttpStatus::CUSTOM_ERROR);
 
@@ -162,8 +168,10 @@ class ActivityReward extends BaseController
     public function index(): JsonResponse
     {
         try {
-            request()->validate(['member_id' => ['nullable', 'string']]);
+            request()->validate(['member_id' => ['nullable', 'string'], 'platform' => ['nullable', 'string', 'in:PC,H5,APP']]);
             $memberId = request()->input('member_id');
+            $context = ActivityEligibilityService::memberContext($memberId);
+            $platform = request()->input('platform');
             $time = time();
             $query = ActivityRewardModel::where(ActivityRewardService::getWhere([
 //                'start_time' => ['<=', $time],
@@ -176,8 +184,16 @@ class ActivityReward extends BaseController
                     $query1->where('member_id', $memberId);
                 }]);
             }
-            $list = $query->orderByDesc('id')->get();
-            $list->transform(function ($activity) use ($memberId) {
+            $list = $query->orderByDesc('sort')->orderByDesc('id')->get()
+                ->filter(function ($activity) use ($context, $platform) {
+                    if (!ActivityEligibilityService::isPublishedOn($activity, $platform)) return false;
+                    $eligibility = ActivityEligibilityService::evaluate($activity, $context);
+                    return $eligibility['is_eligible'] || $activity->show_ineligible;
+                })->values();
+            $list = $list->map(function ($activity) use ($memberId, $context) {
+                $eligibility = ActivityEligibilityService::evaluate($activity, $context);
+                $activity->is_eligible = $eligibility['is_eligible'];
+                $activity->ineligible_reason = $eligibility['ineligible_reason'];
                 if (!empty($memberId)) {
                     $activity->is_claimed = $activity->activityUser->isNotEmpty();
                 } else {
@@ -193,4 +209,4 @@ class ActivityReward extends BaseController
         }
         return $this->success($list);
     }
-}
+}

+ 1 - 1
app/Http/Controllers/api/Settle.php

@@ -94,7 +94,7 @@ class Settle extends Controller
             $balance_after_bet = bcsub($balance, $amount, 2);
             BalanceLogService::addLog(
                 $member_id,
-                $amount,
+                -$amount,
                 $balance,
                 $balance_after_bet,
                 $betType,

+ 11 - 1
app/Models/ActivityReward.php

@@ -20,7 +20,17 @@ class ActivityReward extends BaseModel
     const STATUS_DOWN = 0;
 
     protected $table = 'activity_rewards';
-    protected $fillable = ['title', 'sub_title', 'detail_image', 'start_time', 'end_time', 'status','type','lang','content', 'params','pc_image','detail_banner', 'pc_banner'];
+    protected $fillable = ['title', 'sub_title', 'detail_image', 'start_time', 'end_time', 'status','type','lang','content', 'params','pc_image','detail_banner', 'pc_banner',
+        'publish_platforms', 'budget', 'reward_quota', 'sort', 'register_time_limited', 'register_start_time', 'register_end_time',
+        'register_platforms', 'deposit_limited', 'deposit_min', 'deposit_max', 'show_ineligible'];
+
+    protected $casts = [
+        'publish_platforms' => 'array', 'register_platforms' => 'array',
+        'register_time_limited' => 'boolean', 'deposit_limited' => 'boolean',
+        'show_ineligible' => 'boolean', 'reward_quota' => 'integer', 'rewarded_count' => 'integer',
+        'quota_enabled' => 'boolean', 'budget' => 'decimal:2', 'budget_used' => 'decimal:2',
+        'deposit_min' => 'decimal:2', 'deposit_max' => 'decimal:2', 'sort' => 'integer',
+    ];
 
     public function activityUser(): HasMany
     {

+ 10 - 0
app/Models/ActivityRewardRecipient.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App\Models;
+
+class ActivityRewardRecipient extends BaseModel
+{
+    protected $table = 'activity_reward_recipients';
+    protected $fillable = ['activity_id', 'member_id', 'created_at'];
+    public $timestamps = false;
+}

+ 2 - 2
app/Models/YuebaoItem.php

@@ -4,11 +4,11 @@ namespace App\Models;
 class YuebaoItem extends BaseModel
 {
     protected $table = 'yuebao_item';
-    protected $fillable = ['user_id','principal','surplus_principal','daily_interest_rate','status'];
+    protected $fillable = ['user_id','principal','surplus_principal','daily_interest_rate','status', 'activity_id'];
     protected $hidden = [];
     
     public $timestamps = true;
     protected $dateFormat = 'U'; // U 代表 UNIX 时间戳(int)
 
  
-}
+}

+ 32 - 0
app/Services/ActivityBudgetService.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\ActivityReward;
+use Illuminate\Support\Facades\DB;
+
+/** Preserve the monetary budget behavior for activities created before quotas. */
+class ActivityBudgetService
+{
+    public static function award(ActivityReward $activity, $amount, callable $grant): bool
+    {
+        if (!is_numeric($amount) || !preg_match('/^\d{1,12}(\.\d{1,2})?$/', (string)$amount)
+            || bccomp((string)$amount, '0', 2) <= 0) {
+            return false;
+        }
+        return DB::transaction(function () use ($activity, $amount, $grant) {
+            $locked = ActivityReward::where('id', $activity->id)->lockForUpdate()->first();
+            if (!$locked) return false;
+            if ((int)$locked->type === 2) {
+                $grant();
+                return true;
+            }
+            $used = bcadd((string)($locked->budget_used ?? '0'), (string)$amount, 2);
+            if ($locked->budget !== null && bccomp($used, (string)$locked->budget, 2) > 0) return false;
+            $grant();
+            $locked->budget_used = $used;
+            $locked->save();
+            return true;
+        });
+    }
+}

+ 43 - 0
app/Services/ActivityEligibilityService.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\ActivityReward;
+use App\Models\BalanceLog;
+use App\Models\User;
+use App\Models\UserLogin;
+use App\Support\ActivityEligibility;
+
+class ActivityEligibilityService
+{
+    public static function memberContext($memberId): ?array
+    {
+        if ($memberId === null || $memberId === '') {
+            return null;
+        }
+        $user = User::where('member_id', $memberId)->first();
+        if (!$user) {
+            return null;
+        }
+        $platform = UserLogin::where('user_id', $memberId)
+            ->where('status', UserLogin::STATUS_SUCCESS)->orderBy('id')->value('platform');
+        return [
+            'registered_at' => $user->getRawOriginal('created_at'),
+            'register_platform' => ActivityEligibility::normalizePlatform($platform),
+            // Same categories as getTotalRecharge(), retaining the database decimal precision.
+            'deposit_total' => (string)BalanceLog::where('member_id', $memberId)->where('type', 1)
+                ->whereIn('change_type', ['充值', '人工充值', '三方充值'])
+                ->selectRaw('COALESCE(SUM(amount), 0) AS total')->value('total'),
+        ];
+    }
+
+    public static function evaluate(ActivityReward $activity, ?array $member): array
+    {
+        return ActivityEligibility::evaluate($activity->toArray(), $member);
+    }
+
+    public static function isPublishedOn(ActivityReward $activity, ?string $platform): bool
+    {
+        return ActivityEligibility::isPublishedOn($activity->toArray(), $platform);
+    }
+}

+ 73 - 0
app/Services/ActivityQuotaService.php

@@ -0,0 +1,73 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\ActivityReward;
+use App\Models\ActivityRewardRecipient;
+use Illuminate\Support\Facades\DB;
+
+class ActivityQuotaService
+{
+    /** A preliminary check; award() always checks again under the activity lock. */
+    public static function canAward(ActivityReward $activity, $memberId): bool
+    {
+        if ((int)$activity->type === 2) return true;
+        if (!$activity->quota_enabled) {
+            return $activity->budget === null || bccomp((string)($activity->budget_used ?? '0'), (string)$activity->budget, 2) < 0;
+        }
+        $member = static::memberKey($memberId);
+        if ($member === null) return false;
+        if (static::hasReceived($activity->id, $member)) return true;
+        return $activity->reward_quota === null || (int)$activity->rewarded_count < (int)$activity->reward_quota;
+    }
+
+    /** Count a member only on their first successful positive reward for this activity. */
+    public static function award(ActivityReward $activity, $memberId, $amount, callable $grant): bool
+    {
+        $member = static::memberKey($memberId);
+        if ($member === null || !is_numeric($amount)
+            || !preg_match('/^\d{1,12}(\.\d{1,2})?$/', (string)$amount)
+            || bccomp((string)$amount, '0', 2) <= 0) {
+            return false;
+        }
+        return DB::transaction(function () use ($activity, $member, $amount, $grant) {
+            $locked = ActivityReward::where('id', $activity->id)->lockForUpdate()->first();
+            if (!$locked) return false;
+            if ((int)$locked->type === 2) {
+                $grant();
+                return true;
+            }
+            if (!$locked->quota_enabled) return ActivityBudgetService::award($locked, $amount, $grant);
+            // Match the locked counter with a current read, even inside an older caller snapshot.
+            $received = static::hasReceived($locked->id, $member, true);
+            if (!$received && $locked->reward_quota !== null
+                && (int)$locked->rewarded_count >= (int)$locked->reward_quota) {
+                return false;
+            }
+            $grant();
+            if (!$received) {
+                ActivityRewardRecipient::create([
+                    'activity_id' => $locked->id,
+                    'member_id' => $member,
+                    'created_at' => date('Y-m-d H:i:s'),
+                ]);
+                $locked->rewarded_count = (int)$locked->rewarded_count + 1;
+                $locked->save();
+            }
+            return true;
+        });
+    }
+
+    private static function hasReceived($activityId, string $memberId, bool $currentRead = false): bool
+    {
+        $query = ActivityRewardRecipient::where('activity_id', $activityId)->where('member_id', $memberId);
+        return $currentRead ? $query->lockForUpdate()->first() !== null : $query->exists();
+    }
+
+    private static function memberKey($memberId): ?string
+    {
+        if (!is_int($memberId) && !is_string($memberId)) return null;
+        $member = trim((string)$memberId);
+        return $member === '' || $member === '0' || strlen($member) > 64 ? null : $member;
+    }
+}

+ 85 - 3
app/Services/ActivityRewardService.php

@@ -9,6 +9,8 @@ use App\Models\Config;
 use App\Models\Role;
 use App\Models\UserSession;
 use App\Models\User;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rule;
 use Exception;
 
 class ActivityRewardService extends BaseService
@@ -16,6 +18,68 @@ class ActivityRewardService extends BaseService
 
     public static string $MODEL = ActivityReward::class;
 
+    public static function configurationDefaults(): array
+    {
+        return [
+            'publish_platforms' => [], 'budget' => null, 'reward_quota' => null, 'sort' => 0,
+            'register_time_limited' => false, 'register_start_time' => null, 'register_end_time' => null,
+            'register_platforms' => [], 'deposit_limited' => false, 'deposit_min' => null,
+            'deposit_max' => null, 'show_ineligible' => true,
+        ];
+    }
+
+    private static function configuration(array $params, ?ActivityReward $existing): array
+    {
+        $defaults = static::configurationDefaults();
+        if ((int)($params['type'] ?? $existing?->type) === 2) {
+            return $defaults;
+        }
+        $quotaMode = !$existing || $existing->quota_enabled;
+        Validator::make($params, [$quotaMode ? 'budget' : 'reward_quota' => ['prohibited']])->validate();
+        $previous = $existing ? $existing->only(array_keys($defaults)) : [];
+        foreach (['publish_platforms', 'register_platforms'] as $field) {
+            if (($previous[$field] ?? null) === null) $previous[$field] = [];
+        }
+        $config = array_replace($defaults, $previous, array_intersect_key($params, $defaults));
+        if ($quotaMode) $config['budget'] = null;
+        else $config['reward_quota'] = null;
+        if (empty($config['register_time_limited'])) {
+            $config['register_start_time'] = $config['register_end_time'] = null;
+        }
+        if (empty($config['deposit_limited'])) {
+            $config['deposit_min'] = $config['deposit_max'] = null;
+        }
+        $money = ['nullable', 'numeric', 'min:0', 'regex:/^\d{1,12}(\.\d{1,2})?$/'];
+        Validator::make($config, [
+            'publish_platforms' => ['present', 'array'],
+            'publish_platforms.*' => ['required', 'string', 'distinct', Rule::in(['PC', 'H5', 'APP'])],
+            'reward_quota' => ['nullable', 'integer', 'min:0', 'max:2147483647'],
+            'budget' => $money,
+            'sort' => ['required', 'integer', 'min:0', 'max:4294967295'],
+            'register_time_limited' => ['required', 'boolean'],
+            'register_start_time' => ['nullable', 'required_if:register_time_limited,1', 'date'],
+            'register_end_time' => ['nullable', 'required_if:register_time_limited,1', 'date', 'after_or_equal:register_start_time'],
+            'register_platforms' => ['present', 'array'],
+            'register_platforms.*' => ['required', 'string', 'distinct', Rule::in(['PC', 'H5', 'APP'])],
+            'deposit_limited' => ['required', 'boolean'],
+            'deposit_min' => array_merge($money, ['required_if:deposit_limited,1']),
+            'deposit_max' => array_merge($money, ['required_if:deposit_limited,1', 'gte:deposit_min']),
+            'show_ineligible' => ['required', 'boolean'],
+        ])->validate();
+        foreach (['budget', 'deposit_min', 'deposit_max'] as $field) {
+            if ($config[$field] !== null) {
+                $config[$field] = bcadd((string)$config[$field], '0', 2);
+            }
+        }
+        if ($config['reward_quota'] !== null) $config['reward_quota'] = (int)$config['reward_quota'];
+        foreach (['register_start_time', 'register_end_time'] as $field) {
+            if ($config[$field] !== null) {
+                $config[$field] = date('Y-m-d H:i:s', strtotime($config[$field]));
+            }
+        }
+        return $config;
+    }
+
     /**
      * @description: 查询单条数据
      * @param array $search
@@ -26,6 +90,20 @@ class ActivityRewardService extends BaseService
         return static::$MODEL::where(static::getWhere($search))->first();
     }
 
+    public static function activeForMember(int $type, $memberId): ?ActivityReward
+    {
+        $context = ActivityEligibilityService::memberContext($memberId);
+        $activities = ActivityReward::where('lang', 'zh')->where('type', $type)->where('status', 1)
+            ->where('start_time', '<=', time())->where('end_time', '>=', time())
+            ->orderByDesc('sort')->orderByDesc('id')->get();
+        foreach ($activities as $activity) {
+            if (!ActivityEligibilityService::evaluate($activity, $context)['is_eligible']) continue;
+            if (!ActivityQuotaService::canAward($activity, $memberId)) continue;
+            return $activity;
+        }
+        return null;
+    }
+
     public static function getWhere(array $search = []): array
     {
         $where = [];
@@ -87,6 +165,10 @@ class ActivityRewardService extends BaseService
      */
     public static function submit(array $params = []): bool
     {
+        $info = !empty($params['id']) ? static::findOne(['id' => $params['id']]) : null;
+        if (!empty($params['id']) && !$info) throw new Exception('操作失败', HttpStatus::CUSTOM_ERROR);
+        $params = array_merge($params, static::configuration($params, $info));
+        unset($params['rewarded_count'], $params['quota_enabled'], $params['budget_used']); // Server-owned fields.
         if (isset($params['start_time']))
             $params['start_time'] = strtotime($params['start_time']);
         if (isset($params['end_time']))
@@ -101,11 +183,11 @@ class ActivityRewardService extends BaseService
             $params['pc_banner'] = Util::replacePartInUrl($params['pc_banner']);
 
         if (!empty($params['id'])) {
-            $info = static::findOne(['id' => $params['id']]);
-            if (!$info) throw new Exception("操作失败", HttpStatus::CUSTOM_ERROR);
             $info->update($params);
         } else {
-            static::$MODEL::create($params);
+            $info = new ActivityReward($params);
+            $info->quota_enabled = true; // Existing rows stay false; clients cannot change the mode.
+            $info->save();
         }
         return true;
     }

+ 44 - 23
app/Services/ActivityUserService.php

@@ -9,6 +9,7 @@ use App\Models\ActivityReward;
 use App\Models\ActivityUser;
 use App\Models\Wallet as WalletModel;
 use Exception;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Database\Eloquent\Builder;
 
 class ActivityUserService extends BaseService
@@ -99,27 +100,47 @@ class ActivityUserService extends BaseService
      */
     public static function gift($id, $amount, $bettingAmount): void
     {
-        $activityUser = static::findOne(['id' => $id]);
-        if (!$activityUser) throw new Exception('活动不存在', HttpStatus::CUSTOM_ERROR);
-        if ($activityUser->status == static::$MODEL::STATUS_IN_PROGRESS) throw new Exception('充值失败:活动进行中', HttpStatus::CUSTOM_ERROR);
-        if ($activityUser->status == static::$MODEL::STATUS_COMPLETE) throw new Exception("充值失败:活动已完成", HttpStatus::CUSTOM_ERROR);
-        if ($activityUser->status != static::$MODEL::STATUS_APPLY) throw new Exception('充值失败:状态异常', HttpStatus::CUSTOM_ERROR);
-        $activityUser->status = static::$MODEL::STATUS_IN_PROGRESS;
-        $activityUser->betting_amount = $bettingAmount;
-        $activityUser->gift_amount = $amount;
-        $activityUser->effective_betting_amount = 0;
-        $activityUser->save();
-        $memberId = $activityUser->member_id;
-        $res = WalletService::updateBalance($memberId, $amount);
-        BalanceLogService::addLog($memberId, $amount, $res['before_balance'], $res['after_balance'], "优惠活动", $activityUser->id, "$activityUser->title; 赠送:$activityUser->gift_amount;  打码量:$activityUser->betting_amount");
-
-        $availableBalance = floatval($res['after_balance']);
-        // 去除多余0后,再用 sprintf 补足两位
-        $availableBalance = sprintf('%.2f', $availableBalance);
-        $text = "余额变动通知\n";
-        $text .= "变动金额:" . ($amount > 0 ? '+' : '') . "{$amount}\n";
-        $text .= "总余额为:{$availableBalance}\n";
-        $text .= "说明:优惠活动赠送";
-        TopUpService::notifyTransferSuccess($memberId, $text);
+        $notification = DB::transaction(function () use ($id, $amount, $bettingAmount) {
+            $application = ActivityUser::where('id', $id)->first();
+            if (!$application) throw new Exception('活动不存在', HttpStatus::CUSTOM_ERROR);
+            // Wallet first, matching deposit and settlement transactions.
+            $wallet = WalletModel::where('member_id', $application->member_id)->lockForUpdate()->first();
+            if (!$wallet) throw new Exception('账户钱包不存在', HttpStatus::CUSTOM_ERROR);
+            $activityUser = ActivityUser::where('id', $id)->lockForUpdate()->first();
+            if (!$activityUser) throw new Exception('活动不存在', HttpStatus::CUSTOM_ERROR);
+            if ($activityUser->status == static::$MODEL::STATUS_IN_PROGRESS) throw new Exception('充值失败:活动进行中', HttpStatus::CUSTOM_ERROR);
+            if ($activityUser->status == static::$MODEL::STATUS_COMPLETE) throw new Exception("充值失败:活动已完成", HttpStatus::CUSTOM_ERROR);
+            if ($activityUser->status != static::$MODEL::STATUS_APPLY) throw new Exception('充值失败:状态异常', HttpStatus::CUSTOM_ERROR);
+            $activity = ActivityReward::where('id', $activityUser->activity_id)->first();
+            if (!$activity) throw new Exception('活动不存在', HttpStatus::CUSTOM_ERROR);
+            $eligibility = ActivityEligibilityService::evaluate($activity, ActivityEligibilityService::memberContext($activityUser->member_id));
+            if (!$eligibility['is_eligible']) throw new Exception($eligibility['ineligible_reason'], HttpStatus::CUSTOM_ERROR);
+            $res = null;
+            $granted = ActivityQuotaService::award($activity, $activityUser->member_id, $amount, function () use ($activityUser, $wallet, $amount, $bettingAmount, &$res) {
+                $activityUser->status = static::$MODEL::STATUS_IN_PROGRESS;
+                $activityUser->betting_amount = $bettingAmount;
+                $activityUser->gift_amount = $amount;
+                $activityUser->effective_betting_amount = 0;
+                $activityUser->save();
+                $memberId = $activityUser->member_id;
+                $before = (string)$wallet->available_balance;
+                $wallet->available_balance = bcadd($before, (string)$amount, 2);
+                $wallet->save();
+                $res = ['before_balance' => $before, 'after_balance' => $wallet->available_balance];
+                BalanceLogService::addLog($memberId, $amount, $res['before_balance'], $res['after_balance'], "优惠活动", $activityUser->id, "$activityUser->title; 赠送:$activityUser->gift_amount;  打码量:$activityUser->betting_amount");
+            });
+            if (!$granted) throw new Exception($activity->quota_enabled
+                ? '活动获奖份数已用完,已停止新用户发奖' : '活动预算不足,已停止发奖', HttpStatus::CUSTOM_ERROR);
+
+            $availableBalance = floatval($res['after_balance']);
+            // 去除多余0后,再用 sprintf 补足两位
+            $availableBalance = sprintf('%.2f', $availableBalance);
+            $text = "余额变动通知\n";
+            $text .= "变动金额:" . ($amount > 0 ? '+' : '') . "{$amount}\n";
+            $text .= "总余额为:{$availableBalance}\n";
+            $text .= "说明:优惠活动赠送";
+            return ['member_id' => $activityUser->member_id, 'text' => $text];
+        });
+        TopUpService::notifyTransferSuccess($notification['member_id'], $notification['text']);
     }
-}
+}

+ 50 - 40
app/Services/BalanceLogService.php

@@ -11,6 +11,7 @@ use App\Models\Wallet;
 use App\Models\Level;
 use App\Models\RechargeChannelGroup;
 use App\Models\UserLogin;
+use Illuminate\Support\Facades\DB;
 
 // 余额额变动记录
 class BalanceLogService extends BaseService
@@ -208,47 +209,52 @@ class BalanceLogService extends BaseService
                 //用户活动
                 $activity_type = RechargeChannelGroup::getActivityType($user->recharge_channel_group_id);
                 if ($activity_type) {
-                    $walletInfo = Wallet::where('member_id', $memberId)->first();
-                    if (in_array('recharge', $activity_type)) {
-                        //即充即送-返彩活动
-                        $bonusAmount = self::calculateRechargeBonus($amount,$memberId, $related_id);
-                        if ($bonusAmount > 0) {
-                            static::$MODEL::create([
-                                'type' => 2,
-                                'member_id' => $memberId,
-                                'amount' => $bonusAmount,
-                                'before_balance' => $walletInfo->frozen_balance,
-                                'after_balance' => bcadd($walletInfo->frozen_balance, $bonusAmount, 2),
-                                'change_type' => '即充即送',
-                                'frozen_status' => 1,
-                                'related_id' => $related_id,
-                                'remark' => '充值金额为:'.$remark_amount,
-                            ]);
-                            $walletInfo->frozen_balance = bcadd($walletInfo->frozen_balance, $bonusAmount, 2);
+                    // USDT confirmation has no outer transaction. Keep every reward write
+                    // and its recipient slot pending until the frozen balance is saved.
+                    DB::transaction(function () use ($activity_type, $memberId, $amount, $related_id, $level, $remark_amount) {
+                        $walletInfo = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
+                        if (!$walletInfo) throw new \RuntimeException('账户钱包不存在');
+                        if (in_array('recharge', $activity_type)) {
+                            //即充即送-返彩活动
+                            $bonusAmount = self::calculateRechargeBonus($amount,$memberId, $related_id);
+                            if ($bonusAmount > 0) {
+                                static::$MODEL::create([
+                                    'type' => 2,
+                                    'member_id' => $memberId,
+                                    'amount' => $bonusAmount,
+                                    'before_balance' => $walletInfo->frozen_balance,
+                                    'after_balance' => bcadd($walletInfo->frozen_balance, $bonusAmount, 2),
+                                    'change_type' => '即充即送',
+                                    'frozen_status' => 1,
+                                    'related_id' => $related_id,
+                                    'remark' => '充值金额为:'.$remark_amount,
+                                ]);
+                                $walletInfo->frozen_balance = bcadd($walletInfo->frozen_balance, $bonusAmount, 2);
+                            }
                         }
-                    }
 
-                    if (in_array('old_user', $activity_type)) {
-                        //老用户回归-返彩活动
-                        $bonusAmount = self::calculateUserReturnRechargeBonus($amount,$memberId, $level, $related_id);
+                        if (in_array('old_user', $activity_type)) {
+                            //老用户回归-返彩活动
+                            $bonusAmount = self::calculateUserReturnRechargeBonus($amount,$memberId, $level, $related_id);
                         
-                        if ($bonusAmount > 0) {
-                            static::$MODEL::create([
-                                'type' => 2,
-                                'member_id' => $memberId,
-                                'amount' => $bonusAmount,
-                                'before_balance' => $walletInfo->frozen_balance,
-                                'after_balance' => bcadd($walletInfo->frozen_balance, $bonusAmount, 2),
-                                'change_type' => '老用户回归',
-                                'frozen_status' => 1,
-                                'related_id' => $related_id,
-                                'remark' => '充值金额为:'.$remark_amount,
-                            ]);
-                            $walletInfo->frozen_balance = bcadd($walletInfo->frozen_balance, $bonusAmount, 2);
+                            if ($bonusAmount > 0) {
+                                static::$MODEL::create([
+                                    'type' => 2,
+                                    'member_id' => $memberId,
+                                    'amount' => $bonusAmount,
+                                    'before_balance' => $walletInfo->frozen_balance,
+                                    'after_balance' => bcadd($walletInfo->frozen_balance, $bonusAmount, 2),
+                                    'change_type' => '老用户回归',
+                                    'frozen_status' => 1,
+                                    'related_id' => $related_id,
+                                    'remark' => '充值金额为:'.$remark_amount,
+                                ]);
+                                $walletInfo->frozen_balance = bcadd($walletInfo->frozen_balance, $bonusAmount, 2);
+                            }
                         }
-                    }
                     
-                    $walletInfo->save();
+                        $walletInfo->save();
+                    });
                 }
             }
         }
@@ -391,7 +397,7 @@ class BalanceLogService extends BaseService
      */
     public static function calculateRechargeBonus($amount,$memberId, $related_id = null)
     {
-        $activity = ActivityReward::where('lang','zh')->where('type',3)->where('status',1)->where('start_time', '<', time())->where('end_time', '>', time())->first();
+        $activity = ActivityRewardService::activeForMember(3, $memberId);
         if (!$activity) {
             return false;
         }
@@ -426,7 +432,9 @@ class BalanceLogService extends BaseService
         $need_flow = !empty($params['reward_turnover_ratio']) ? bcmul($bonusAmount, $params['reward_turnover_ratio'], 2) : $bonusAmount;
         
         //返彩流水
-        WalletBonus::addData($memberId, 1, $bonusAmount, $activity->id, $related_id, $need_flow);
+        if (!ActivityQuotaService::award($activity, $memberId, $bonusAmount, function () use ($memberId, $bonusAmount, $activity, $related_id, $need_flow) {
+            WalletBonus::addData($memberId, 1, $bonusAmount, $activity->id, $related_id, $need_flow);
+        })) return false;
 
         return $bonusAmount;
     }
@@ -437,7 +445,7 @@ class BalanceLogService extends BaseService
      */
     public static function calculateUserReturnRechargeBonus($amount,$memberId, $level, $related_id = null)
     {
-        $activity = ActivityReward::where('lang','zh')->where('type',4)->where('status',1)->where('start_time', '<', time())->where('end_time', '>', time())->first();
+        $activity = ActivityRewardService::activeForMember(4, $memberId);
         if (!$activity) {
             return false;
         }
@@ -473,7 +481,9 @@ class BalanceLogService extends BaseService
         }
         
         //返彩流水
-        WalletBonus::addData($memberId, 3, $bonusAmount, $activity->id, $related_id, $need_flow);
+        if (!ActivityQuotaService::award($activity, $memberId, $bonusAmount, function () use ($memberId, $bonusAmount, $activity, $related_id, $need_flow) {
+            WalletBonus::addData($memberId, 3, $bonusAmount, $activity->id, $related_id, $need_flow);
+        })) return false;
 
         return $bonusAmount;
     }

+ 69 - 0
app/Support/ActivityEligibility.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace App\Support;
+
+/** Framework-independent rules, mirrored in melbet_sport-api. */
+class ActivityEligibility
+{
+    public static function normalizePlatform(?string $platform): ?string
+    {
+        return match (strtoupper(trim($platform ?? ''))) {
+            'PC' => 'PC',
+            'H5', 'WECHAT', '微信' => 'H5',
+            'APP', 'IOS', 'ANDROID' => 'APP',
+            default => null,
+        };
+    }
+
+    public static function isPublishedOn(array $activity, ?string $platform): bool
+    {
+        if ((int)($activity['type'] ?? 0) === 2) {
+            return true;
+        }
+        $platforms = self::platforms($activity['publish_platforms'] ?? []);
+        return !$platforms || in_array(self::normalizePlatform($platform), $platforms, true);
+    }
+
+    public static function evaluate(array $activity, ?array $member): array
+    {
+        if ((int)($activity['type'] ?? 0) === 2) {
+            return self::result();
+        }
+        if (!$member) {
+            return self::result('请先登录');
+        }
+        if (!empty($activity['register_time_limited'])) {
+            $registered = strtotime($member['registered_at'] ?? '');
+            $start = strtotime($activity['register_start_time'] ?? '');
+            $end = strtotime($activity['register_end_time'] ?? '');
+            if (!$registered || !$start || !$end || $registered < $start || $registered > $end) {
+                return self::result('不符合会员注册时间条件');
+            }
+        }
+        $platforms = self::platforms($activity['register_platforms'] ?? []);
+        if ($platforms && !in_array(self::normalizePlatform($member['register_platform'] ?? null), $platforms, true)) {
+            return self::result('不符合会员注册平台条件');
+        }
+        if (!empty($activity['deposit_limited'])) {
+            $min = $activity['deposit_min'] ?? null;
+            $max = $activity['deposit_max'] ?? null;
+            $total = $member['deposit_total'] ?? null;
+            if (!is_numeric($min) || !is_numeric($max) || !is_numeric($total)
+                || bccomp((string)$total, (string)$min, 2) < 0
+                || bccomp((string)$total, (string)$max, 2) > 0) {
+                return self::result('不符合会员生涯存款条件');
+            }
+        }
+        return self::result();
+    }
+
+    private static function platforms($value): array
+    {
+        return is_array($value) ? $value : (json_decode($value ?? '[]', true) ?: []);
+    }
+
+    private static function result(string $reason = ''): array
+    {
+        return ['is_eligible' => $reason === '', 'ineligible_reason' => $reason];
+    }
+}

+ 45 - 0
database/migrations/2026_10_13_120000_add_activity_eligibility_fields.php

@@ -0,0 +1,45 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up()
+    {
+        Schema::table('activity_rewards', function (Blueprint $table) {
+            $table->json('publish_platforms')->nullable()->comment('发布平台PC/H5/APP,空表示全部');
+            $table->decimal('budget', 14, 2)->nullable()->comment('奖励总预算,NULL不限,0停止发奖');
+            $table->decimal('budget_used', 14, 2)->default(0)->comment('上线后已发奖励总额');
+            $table->unsignedInteger('sort')->default(0);
+            $table->boolean('register_time_limited')->default(false);
+            $table->dateTime('register_start_time')->nullable();
+            $table->dateTime('register_end_time')->nullable();
+            $table->json('register_platforms')->nullable()->comment('会员首次成功登录平台,空表示全部');
+            $table->boolean('deposit_limited')->default(false);
+            $table->decimal('deposit_min', 14, 2)->nullable();
+            $table->decimal('deposit_max', 14, 2)->nullable();
+            $table->boolean('show_ineligible')->default(true)->comment('不符合会员资格时是否展示');
+        });
+        if (Schema::hasTable('yuebao_item') && !Schema::hasColumn('yuebao_item', 'activity_id')) {
+            Schema::table('yuebao_item', function (Blueprint $table) {
+                $table->unsignedBigInteger('activity_id')->nullable()->index()->comment('新持仓关联活动预算');
+            });
+        }
+    }
+
+    public function down()
+    {
+        if (Schema::hasTable('yuebao_item') && Schema::hasColumn('yuebao_item', 'activity_id')) {
+            Schema::table('yuebao_item', function (Blueprint $table) {
+                $table->dropIndex(['activity_id']);
+                $table->dropColumn('activity_id');
+            });
+        }
+        Schema::table('activity_rewards', function (Blueprint $table) {
+            $table->dropColumn(['publish_platforms', 'budget', 'budget_used', 'sort',
+                'register_time_limited', 'register_start_time', 'register_end_time',
+                'register_platforms', 'deposit_limited', 'deposit_min', 'deposit_max', 'show_ineligible']);
+        });
+    }
+};

+ 34 - 0
database/migrations/2026_10_14_120000_add_activity_reward_quotas.php

@@ -0,0 +1,34 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up()
+    {
+        Schema::table('activity_rewards', function (Blueprint $table) {
+            $table->boolean('quota_enabled')->default(false)->comment('仅新建活动启用份数;旧活动保留原逻辑');
+            $table->unsignedInteger('reward_quota')->nullable()->comment('不同获奖会员份数,NULL不限,0停止新用户获奖');
+            $table->unsignedInteger('rewarded_count')->default(0)->comment('已成功获奖的不同会员人数');
+        });
+        Schema::create('activity_reward_recipients', function (Blueprint $table) {
+            $table->engine = 'InnoDB';
+            $table->id();
+            $table->unsignedBigInteger('activity_id');
+            $table->string('member_id', 64);
+            $table->timestamp('created_at')->useCurrent();
+            $table->unique(['activity_id', 'member_id'], 'activity_recipient_unique');
+        });
+        // Money totals cannot be converted into distinct recipient counts.
+        // Existing activities remain in their original monetary-budget mode.
+    }
+
+    public function down()
+    {
+        Schema::dropIfExists('activity_reward_recipients');
+        Schema::table('activity_rewards', function (Blueprint $table) {
+            $table->dropColumn(['quota_enabled', 'reward_quota', 'rewarded_count']);
+        });
+    }
+};

+ 117 - 0
docs/前端接口/活动/资格条件与展示开关.md

@@ -0,0 +1,117 @@
+# 活动资格、获奖份数与展示开关
+
+活动接口支持发布平台、排序、会员资格条件及「不符合条件是否展示」。获奖份数仅用于新建的非足球首页活动,旧活动保持原金额预算规则;`type=2` 足球首页豁免。本次只改后端,不编写前端代码;本文件供接口接入参考。
+
+**新活动 10 份表示最多 10 位不同会员成功获奖**,不按金额或发奖次数扣减。是否可重复领奖、次数、周期及资格完全沿用具体活动原规则;份数检查通过不代表可突破这些规则。原规则允许再次获奖时,同一会员不重复占份数。
+
+## 接口与字段
+
+后台接口保持不变:
+
+- `GET /admin/ActivityReward?page=1&limit=10`:列表返回新增字段,编辑时直接回显。
+- `POST /admin/ActivityReward/submit`:不传 `id` 新增,传 `id` 编辑;新增字段放在请求根级,不能放入 `params`。
+
+| 字段 | 类型 / 默认值 | 表单与提交规则 |
+|---|---|---|
+| `publish_platforms` | 数组 / `[]` | 「发布平台」多选,值为 `PC`、`H5`、`APP`;空数组表示全部平台 |
+| `quota_enabled` | 布尔 / 旧活动 `false` | 只读创建来源标记;后台新建活动均为 `true`,足球首页仍豁免;编辑不能改变 |
+| `reward_quota` | 整数或 `null` / `null` | 新份数活动「获奖份数」;`null` 不限,`0` 禁止新的会员首次获奖;范围 `0`~`2147483647` |
+| `rewarded_count` | 整数 / `0` | 新份数活动已获奖的不同会员数,只读,不提交;旧活动不使用此计数 |
+| `budget` | 金额字符串或 `null` | 旧非足球活动金额预算,`null` 不限、`0` 停止发奖;新活动返回 `null` |
+| `budget_used` | 金额字符串 | 旧活动已发金额,只读;新活动返回 `"0.00"` |
+| `sort` | 整数 / `0` | 排序值越大越靠前;范围 `0`~`4294967295`,同值按 ID 倒序 |
+| `register_time_limited` | 布尔或 `0/1` / `false` | 「会员注册区间」:不限 `0`,注册时间 `1` |
+| `register_start_time` | 日期时间或 `null` / `null` | 限制注册时间时必填,格式 `YYYY-MM-DD HH:mm:ss` |
+| `register_end_time` | 日期时间或 `null` / `null` | 限制注册时间时必填,不早于开始时间;两端均包含 |
+| `register_platforms` | 数组 / `[]` | 「会员注册平台」多选 `PC`、`H5`、`APP`;空数组不限 |
+| `deposit_limited` | 布尔或 `0/1` / `false` | 「会员存款总额」:不限 `0`,生涯存款 `1` |
+| `deposit_min` | 金额字符串或 `null` / `null` | 限制存款时必填,下限包含 |
+| `deposit_max` | 金额字符串或 `null` / `null` | 限制存款时必填,不小于下限,上限包含 |
+| `show_ineligible` | 布尔或 `0/1` / `true` | 「不符合条件是否展示」:是 `1`,否 `0` |
+
+`reward_quota` 必须为整数,不接受金额或小数。存款金额非负,最多 12 位整数、2 位小数,建议用字符串提交。平台数组不能重复,也不能提交中文标签。
+
+接口保留表中两套字段,非足球活动按 `quota_enabled` 判断规则:旧活动 `false`,按原接口回显并修改 `budget`;新活动 `true`,只使用 `reward_quota`、`rewarded_count`,提交非空 `budget` 金额会被拒绝。足球首页无论标记真假都不使用这些限制。
+
+`quota_enabled` 误传会被忽略,编辑包括改变活动类型也不改变此标记。旧非足球活动提交非空 `reward_quota` 会被拒绝,不能转成份数活动;不要把旧金额原样改名为份数。
+
+新建非足球活动默认开启份数模式,编辑省略配置保留原值;清空平台限制传 `[]`,份数活动取消限制传 `null`。注册时间或存款选择「不限」时提交开关 `0`,后端清空旧上下限。调整份数不清零已获奖人数,不限份数也登记首次获奖会员;旧活动不参与新登记和计数。
+
+未编辑的历史活动,后台列表中的平台字段可能为 `null`,回显时按 `[]`(不限)处理;保存时提交数组。
+
+## 保存示例
+
+原活动字段及 `params` 的类型配置仍按现有表单提交,下面示例为即充即送:
+
+```json
+{
+  "title": "秋季充值活动",
+  "sub_title": "限十位符合条件的会员获奖",
+  "start_time": "2026-10-15 00:00:00",
+  "end_time": "2026-10-31 23:59:59",
+  "detail_image": "/uploads/activity-app.jpg",
+  "pc_image": "/uploads/activity-pc.jpg",
+  "detail_banner": "/uploads/activity-app-banner.jpg",
+  "pc_banner": "/uploads/activity-pc-banner.jpg",
+  "status": 1,
+  "type": 3,
+  "lang": "zh",
+  "content": "活动说明",
+  "params": {
+    "reward_ratio": 5,
+    "min_recharge_amount": 100,
+    "reward_turnover_ratio": 1
+  },
+  "publish_platforms": ["PC", "H5", "APP"],
+  "reward_quota": 10,
+  "sort": 10,
+  "register_time_limited": 1,
+  "register_start_time": "2026-10-01 00:00:00",
+  "register_end_time": "2026-10-20 23:59:59",
+  "register_platforms": ["H5", "APP"],
+  "deposit_limited": 1,
+  "deposit_min": "100.00",
+  "deposit_max": "5000.00",
+  "show_ineligible": 1
+}
+```
+
+保存成功后刷新列表;失败沿用接口 `msg` 提示。示例不提交 `quota_enabled`,由服务端创建时确定;`rewarded_count`、`budget_used` 为只读计数,误传会被忽略,页面不能重置。
+
+## 表单交互
+
+- 非足球首页显示上述区域。注册时间、存款选择「不限」时禁用范围输入;选择限制时要求上下限同时填写。
+- 「不符合条件是否展示」默认选「是」。说明文案可写:展示仅允许查看活动,参与及发奖仍需符合条件。
+- 编辑时按只读模式展示金额预算或获奖份数,旧活动不提供切换模式。足球首页不使用上述新增条件,后端忽略这些配置,基本字段照常校验。
+- 份数说明:只有首次正数奖励实际入账占一份,查询、符合资格、申请、余额宝本金转入均不占;首奖失败不占。满额阻止新的获奖会员,已获奖会员仍须通过具体活动原领奖规则,不重复占份数。
+
+例如新活动份数为 `10`:A 首次获奖 `5.00`,人数为 `1`;若原规则允许 A 再获奖 `20.00`,人数仍为 `1`;B 首次获奖后人数为 `2`。原规则只允许一次领奖的活动,A 仍不能再次领奖。
+
+## 会员端列表与详情
+
+站点 `melbet_sport-api` 使用 `GET /api/index/activity` 和 `GET /api/index/activityInfo?id=活动ID`;Telegram 活动页使用 bot-28 的 `GET /api/ActivityReward`,后者仍只返回 `type=1` 活动。
+
+站点活动列表和详情均按 `Lang` 请求头筛选语种,未提供时默认 `zh`。
+
+两个入口都应显式传当前客户端 `platform=PC`、`H5` 或 `APP`。站点也支持 `platform` 请求头,并兼容 `WECHAT/微信 → H5`、`IOS/ANDROID → APP`;前端统一提交标准值即可。配置了发布平台后,未传或无法识别平台的请求看不到该活动;空数组不受此限制。不要用注册平台或浏览器标识推断当前客户端。
+
+站点沿用现有登录态;bot-28 活动页沿用 `member_id`。返回活动新增:
+
+| 字段 | 用途 |
+|---|---|
+| `is_eligible` | 当前会员是否符合资格 |
+| `ineligible_reason` | 不符合原因;符合时为空字符串 |
+
+`show_ineligible=1` 时,不符合资格的会员可查看活动,页面禁用参与按钮并显示原因。游客返回 `is_eligible=false`、`ineligible_reason="请先登录"`,可引导登录。`show_ineligible=0` 时,游客及不符合资格的会员都不会收到该活动;详情接口也不能绕过隐藏规则。发布平台过滤始终生效,与展示开关无关。
+
+足球首页跳过新增的平台、资格、展示及获奖名额限制,游客也返回符合资格。其排序按默认 `0` 处理。其余活动按 `sort` 倒序、ID 倒序显示,站点过滤后再分页,数量与可见列表一致。
+
+资格结果只反映会员条件,名额满后活动仍可能展示;参与、自动奖励、余额宝新转入由后端检查所属模式及原规则。份数活动可展示 `reward_quota`、`rewarded_count`,但不能仅凭这两个数认定当前会员能否获奖;旧活动继续按 `budget`、`budget_used` 判断金额预算。
+
+新份数活动中,已获奖会员在满额后仍可按原规则新转入余额宝、领取后续利息;未获奖会员不能在满额时创建新持仓。已创建但尚未获奖的关联持仓,结算时若名额已满,利息为 `0`,本金正常返还。关联旧活动的持仓继续原金额预算规则,历史未关联持仓保持原行为。
+
+会员注册平台取首次成功登录记录;历史记录缺失或未知的平台,在配置了注册平台限制时不符合。生涯存款按人民币流水类别累计:计入「充值」「人工充值」「三方充值」,已入账的当前充值只计一次。全部「人工充值」记录均计入,包括历史 `related_id=0` 的后台调整;不按业务名称另行判断是否属于赠送。「注册赠送」「优惠活动」「余额宝利息」「充值返现」等其他流水类别及未入账充值不计入。
+
+bot-28 的 `POST /api/ActivityReward/participate` 继续提交 `member_id`、`activity_id`,增加 `platform`。接口仍仅支持 `type=1`,无论活动是否允许展示,不符合资格都不能参与。
+
+建议联调:资格边界、平台、游客、展示开关、足球首页,以及满额时新会员首奖被拒绝、已获奖会员按原规则再次获奖、首奖失败不占名额。

+ 97 - 0
docs/后端/活动资格与预算控制.md

@@ -0,0 +1,97 @@
+# 活动资格与获奖名额控制
+
+活动支持发布平台、排序、会员资格条件及不符合条件展示开关。仅新建非足球首页活动启用不同获奖会员份数,旧活动保持原金额预算规则;足球首页(`type=2`)豁免。新活动 `10` 份指最多 `10` 位不同会员成功获奖,不按金额或发奖次数计算。
+
+## 代码与数据
+
+bot-28 管理配置并处理 Telegram 活动参与、人工赠送、即充即送和老用户回归奖励。站点 `melbet_sport-api` 处理 `index/activity`、`index/activityInfo`、余额宝新转入及利息结算,两个项目共用 `bot_activity_rewards`。
+
+- bot-28:`ActivityRewardService` 负责保存与活动选择,资格服务负责会员条件;`ActivityQuotaService` 处理新活动份数,`ActivityBudgetService` 保留旧活动金额预算。
+- 站点:`MemberActivityService` 统一会员上下文;资格与名额服务用于列表筛选及余额宝结算,两端共用获奖会员登记。
+- `quota_enabled` 是只读创建来源标记:旧活动默认 `false`,后台新建活动均为 `true`,包括足球首页;足球首页先豁免所有新增限制。编辑或改变类型均不改变标记,请求传入此字段会被忽略。
+- 份数活动的 `reward_quota` 为可空非负整数,范围 `0`~`2147483647`;`null` 不限,`0` 禁止新的会员首次获奖。`rewarded_count` 默认 `0`,记录不同获奖会员数,只读。
+- 新迁移 `2026_10_14_120000_add_activity_reward_quotas.php` 添加份数字段和 `activity_reward_recipients`,唯一键为 `(activity_id, member_id)`,表名遵循数据库现有前缀,供两端共同登记。
+- 上一迁移 `2026_10_13_120000_add_activity_eligibility_fields.php` 仍提供资格字段;存在 `yuebao_item` 时添加可空、带索引的 `activity_id`。
+
+接口保留 `quota_enabled` 及两套字段。旧非足球活动继续金额预算判断,可按原规则编辑 `budget`,不统计新登记或人数;新活动返回 `budget=null`、`budget_used=0`,只使用份数字段。新非足球活动禁止非空 `budget`,旧非足球活动禁止非空 `reward_quota`,不能通过请求切换。不得换算金额或推算人数。
+
+金额仍使用十进制计算,PHP 需要 BCMath。平台数组为空不限;本轮迁移不改变旧活动已有条件及金额预算。
+
+编辑省略配置保留原值,关闭注册时间/存款限制清空旧范围。足球首页忽略新增条件。`rewarded_count`、`budget_used` 由服务维护,提交值会被忽略,编辑不能重置。
+
+## 资格口径
+
+注册时间使用会员 `created_at` 原值,开始、结束时间均包含。限制启用时必须同时提供上下限。
+
+注册平台使用 `user_login` 中该会员最早的成功登录记录,按 ID 升序取第一条;标准值为 `PC/H5/APP`,微信映射为 H5,iOS/Android 映射为 APP。历史缺失或未知平台,在设置平台限制时判定不符合,不根据会员来源、当前设备或推广渠道猜测。
+
+生涯存款取 `balance_logs` 中该会员 `type=1`,且 `change_type` 为「充值」「人工充值」「三方充值」的 `amount` 总和。全部「人工充值」均计入,包括历史 `related_id=0` 的后台调整;依据流水类别统计,不根据财务业务名称另行排除赠送。「注册赠送」「优惠活动」「余额宝利息」「充值返现」等其他类别不计入。保留数据库十进制金额,不转浮点。自动充值奖励在充值流水已写入后校验,所以当前充值已在总额中,不再另加。
+
+游客在非足球首页返回不符合,原因为「请先登录」。`show_ineligible=0` 隐藏游客及不符合会员,`1` 允许查看;参与、人工发奖、自动奖励、余额宝新转入仍校验资格。展示开关不会放开发奖。
+
+发布平台按请求显式提供的 `platform` 检查。站点支持查询参数或同名请求头及平台别名;bot-28 使用请求参数 `PC/H5/APP`。不根据 User-Agent 或注册平台推断当前客户端。非空发布平台配置且请求平台缺失/未知时不可见。后台、会员列表和自动活动选择按排序倒序、ID 倒序;足球新增排序按默认 `0`。
+
+站点活动列表与详情统一使用 `Lang` 请求头,默认 `zh`,避免无语种头时两入口筛选语言不同。
+
+## 获奖名额与事务
+
+以下名额规则仅用于 `quota_enabled=true` 的非足球活动。只有会员首次获得正数奖励并实际入账,才新增登记并将 `rewarded_count` 加 `1`。查询、符合条件、申请、余额宝本金转入均不占;不限份数也登记首次获奖会员。
+
+是否重复领奖、次数、周期、资格完全沿用具体活动原规则,份数检查不改变这些规则。原规则允许再次领奖时,同一会员不重复占份数;满额只阻止新的获奖会员,已登记会员仍须通过原规则。提高份数可接纳新会员,调低不追回奖励或删除登记。
+
+例如份数 `10`:A 首奖 `5.00` 占 `1` 份,若原规则允许 A 再获奖 `20.00` 仍占 `1` 份,B 首奖后合计 `2` 份;已有 `10` 位时 C 首奖被拒绝。只允许一次领奖的活动,A 仍不能再次领奖。
+
+发奖先在事务内锁定活动行,再用数据库当前读检查获奖记录,避免 MySQL 可重复读隔离下的旧外层快照把已获奖会员当成新会员。新会员有剩余名额才登记并增加人数;登记、计数、奖金、奖励流水及钱包入账须在同一事务,首奖失败全部回滚。两端保持先活动、后获奖记录的锁顺序;列表及预检查不加此锁,实际发奖必须重新校验。人工赠送发奖前重新检查资格。
+
+余额宝一次结算涉及多个活动时统一按活动 ID 升序锁定,手动转出、自动结算保持同一顺序。
+
+即充即送和回归奖励保留 `BalanceLogService::addLog()` 奖励事务,先锁钱包,再按模式执行旧金额预算扣减或新名额登记,以及奖金记录、奖励流水、冻结余额保存。没有外层事务时奖励也整体提交或回滚,失败不撤销此前已完成的本金充值;有外层事务时服从最终提交或回滚。
+
+## 余额宝与历史数据
+
+新持仓保存 `activity_id` 及当时利率、到期条件。关联份数活动时,转入检查资格和当前会员 `canAward`,本金不预占;满额后已获奖会员按原规则仍可新转入,未获奖会员被拒绝。关联旧活动时继续原金额预算规则,不登记新获奖人数。
+
+份数活动持仓转出、自动到期按保存条件计算,首次利息入账占一份,同会员后续利息不重复占。已建持仓但未获奖的会员,结算时满额则利息为 `0`、本金正常返还。资格变化不阻止既有持仓本金转出;旧活动利息仍按旧预算结算。
+
+同时修正手动余额宝转出的利息重复入账:本金返还可用余额,利息仅入冻结余额一次,并增加累计收益,与自动到期结算一致;利息不再同时增加可用余额和冻结余额。转出返回的 `total` 仍为本金加利息合计,不表示全部变为可用余额。
+
+份数只统计新模式活动的首次成功获奖会员。迁移对所有已有活动设 `quota_enabled=false`,不修改旧预算、不纳入新登记、不回填或推算历史人数。历史未关联持仓保持 `activity_id` 为空并继续旧结算行为,不自动归属活动。
+
+## 发布步骤
+
+1. 在已确认数据库和发布窗口的环境按顺序执行指定迁移;上一迁移已执行时只执行新的份数迁移,不运行全部待执行迁移:
+
+   ```sh
+   php artisan migrate --path=database/migrations/2026_10_13_120000_add_activity_eligibility_fields.php --force
+   php artisan migrate --path=database/migrations/2026_10_14_120000_add_activity_reward_quotas.php --force
+   ```
+
+2. 确认字段、获奖会员表及唯一键,核对已有活动 `quota_enabled=false` 且旧预算保留;余额宝环境还要确认 `bot_yuebao_item.activity_id`。若上一迁移时余额宝表不存在,后来创建须补关联字段。
+3. 迁移先于两端对应代码,再按现有流程刷新缓存、重载服务及队列。两端须同时支持模式分流,旧代码会漏记新活动份数。
+4. 用户明确要求本次不用编写前端,没有修改管理弹窗;接口字段及以后接入页面的参考见 [资格条件与展示开关](../前端接口/活动/资格条件与展示开关.md)。
+
+份数迁移回退会删除模式、名额及获奖登记,钱包奖励不会回退;已有新活动或发奖后优先保留结构。确需删除须先备份核对,旧金额列不是新名额登记的恢复来源。
+
+本次未执行生产迁移、部署、提交、真实发奖、支付、Telegram 操作、前端代码修改或历史回填。
+
+## 验证
+
+`tests/Integration/ActivityRewardEligibilityTest.php` 使用隔离 Laravel / SQLite,不加载业务 `.env` 或应用服务提供器。活动目标套件通过 39 个测试、531 个断言,涵盖旧金额模式保留、份数、只读模式、不同会员首奖、去重、满额及回滚。锁检查使用实际查询编译器验证先活动后获奖记录的当前读及预检查不锁;不把 SQLite 当作 MySQL 可重复读模拟器。
+
+```sh
+php -d error_reporting=24575 vendor/bin/phpunit --bootstrap tests/bootstrap-agent.php tests/Integration/ActivityRewardEligibilityTest.php --do-not-cache-result
+```
+
+独立依赖目录可通过 `AGENT_TEST_AUTOLOAD` 指向对应 `vendor/autoload.php`,并使用该目录下的 PHPUnit 运行;不需要配置业务数据库。PHP 8.4 使用上述错误级别避开旧框架 deprecated 提示。若 macOS 本机 PHP 缺少其所需动态库,可仅为本次测试命令设置指向匹配库目录的 `DYLD_LIBRARY_PATH`,无需修改项目或业务环境配置。
+
+站点回归脚本 `tests/Regression/activity_site_eligibility.php` 使用实际 ThinkORM 及隔离 SQLite,验证筛选、会员 ID 对应、名额登记、余额宝和旧持仓;请求和控制器基类使用测试替身,不加载完整登录鉴权链。
+
+```sh
+php tests/Regression/activity_site_eligibility.php
+```
+
+脚本默认读取相邻 `melbet_sport-api` 代码与依赖,可通过 `ACTIVITY_SITE_REPO` 指定站点目录。
+
+站点修正应用到实际项目后,回归通过 95 个断言;旧站点金额预算服务保持字节不变。上述结果仅针对活动目标套件及站点活动回归,不表示整个仓库全部测试通过。
+
+SQLite 不能证明 MySQL 行锁并发行为,仍需隔离 MySQL 验证多个入口同时首奖不会超过不同获奖人数限额;真实站点登录态及完整鉴权链未联调。

+ 1070 - 0
tests/Integration/ActivityRewardEligibilityTest.php

@@ -0,0 +1,1070 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\admin\ActivityReward as AdminActivityReward;
+use App\Http\Controllers\admin\ActivityUser as AdminActivityUser;
+use App\Http\Controllers\api\ActivityReward as ApiActivityReward;
+use App\Models\ActivityReward;
+use App\Services\ActivityQuotaService;
+use App\Services\ActivityEligibilityService;
+use App\Services\ActivityRewardService;
+use App\Services\BalanceLogService;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use Illuminate\Validation\Factory;
+use Illuminate\Validation\DatabasePresenceVerifier;
+use PHPUnit\Framework\TestCase;
+
+/** Runs real controllers and services against SQLite only; never boots application providers. */
+class ActivityRewardEligibilityTest extends TestCase
+{
+    private Application $app;
+
+    protected function setUp(): void
+    {
+        Facade::clearResolvedInstances();
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository([
+            'app' => ['locale' => 'zh', 'url' => 'https://activity.example.test', 'timezone' => 'Asia/Shanghai'],
+        ]));
+        Facade::setFacadeApplication($this->app);
+        $db = new Manager($this->app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setAsGlobal();
+        $db->bootEloquent();
+        $this->app->instance('db', $db->getDatabaseManager());
+        $this->app->bind('db.schema', fn () => $db->schema());
+        $translator = new Translator(new ArrayLoader(), 'zh');
+        $this->app->instance('translator', $translator);
+        $validator = new Factory($translator, $this->app);
+        $validator->setPresenceVerifier(new DatabasePresenceVerifier($db->getDatabaseManager()));
+        $this->app->instance('validator', $validator);
+        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
+            public function json($data, $status = 200, $headers = [], $options = 0) {
+                return new JsonResponse($data, $status, $headers, $options);
+            }
+        });
+        Request::macro('validate', function (array $rules) {
+            return app('validator')->make($this->all(), $rules)->validate();
+        });
+
+        $db->schema()->create('activity_rewards', function (Blueprint $t) {
+            $t->id(); $t->string('title'); $t->string('sub_title');
+            $t->integer('start_time'); $t->integer('end_time'); $t->integer('status')->default(1);
+            $t->integer('type')->default(1); $t->string('lang')->default('zh');
+            foreach (['detail_image', 'pc_image', 'detail_banner', 'pc_banner', 'content', 'params'] as $field) {
+                $t->text($field)->nullable();
+            }
+            $t->timestamps();
+        });
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_13_120000_add_activity_eligibility_fields.php')->up();
+        $db->schema()->create('users', function (Blueprint $t) {
+            $t->id(); $t->string('member_id')->unique(); $t->string('phone')->default('');
+            $t->string('register_domain')->default(''); $t->integer('from')->default(1);
+            $t->integer('level')->default(0); $t->integer('recharge_channel_group_id')->default(1);
+            $t->string('language')->default('zh'); $t->timestamps();
+        });
+        $db->schema()->create('user_login', function (Blueprint $t) {
+            $t->id(); $t->string('user_id'); $t->string('platform')->default('');
+            $t->integer('status')->default(1); $t->timestamp('logout_time')->nullable(); $t->timestamps();
+        });
+        $db->schema()->create('balance_logs', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->integer('type')->default(1);
+            $t->string('change_type'); $t->decimal('amount', 20, 2);
+            $t->decimal('before_balance', 20, 2)->nullable(); $t->decimal('after_balance', 20, 2)->nullable();
+            $t->integer('related_id')->nullable(); $t->integer('frozen_status')->nullable();
+            $t->text('remark')->nullable(); $t->timestamps();
+        });
+        $db->schema()->create('wallet_bonus', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->integer('type')->default(1);
+            $t->integer('activity_id'); $t->integer('related_id')->nullable();
+            $t->decimal('amount', 20, 2); $t->decimal('need_flow', 20, 2);
+            $t->decimal('remaind_flow', 20, 2); $t->integer('status'); $t->timestamps();
+        });
+        $db->schema()->create('activity_user', function (Blueprint $t) {
+            $t->id(); $t->integer('activity_id'); $t->string('member_id');
+            $t->string('title'); $t->string('sub_title'); $t->text('detail_image')->nullable();
+            $t->integer('start_time'); $t->integer('end_time'); $t->integer('part_in_time');
+            $t->integer('finish_time')->nullable(); $t->integer('status')->default(0);
+            $t->decimal('gift_amount', 20, 2)->default(0); $t->decimal('betting_amount', 20, 2)->default(0);
+            $t->decimal('effective_betting_amount', 20, 2)->default(0); $t->timestamps();
+        });
+        $db->schema()->create('payment_orders', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->integer('type'); $t->integer('status');
+            $t->decimal('amount', 20, 2); $t->timestamps();
+        });
+        $db->schema()->create('recharges', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->integer('status');
+            $t->decimal('amount', 20, 2)->default(0); $t->timestamps();
+        });
+        $db->schema()->create('wallets', function (Blueprint $t) {
+            $t->id(); $t->string('member_id')->unique();
+            $t->decimal('available_balance', 20, 2)->default(0);
+            $t->decimal('frozen_balance', 20, 2)->default(0); $t->timestamps();
+        });
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_14_120000_add_activity_reward_quotas.php')->up();
+        $this->request('/api/ActivityReward', []);
+    }
+
+    protected function tearDown(): void
+    {
+        DB::disconnect();
+        Facade::clearResolvedInstances();
+        parent::tearDown();
+    }
+
+    private function request(string $path, array $params, string $method = 'GET'): void
+    {
+        $this->app->instance('request', Request::create($path, $method, $params));
+    }
+
+    private function form(array $overrides = []): array
+    {
+        return $overrides + [
+            'title' => '资格测试活动', 'sub_title' => '参与资格',
+            'start_time' => '2000-01-01 00:00:00', 'end_time' => '2099-12-31 23:59:59',
+            'detail_image' => '/app.png', 'pc_image' => '/pc.png',
+            'detail_banner' => '/app-banner.png', 'pc_banner' => '/pc-banner.png',
+            'status' => 1, 'type' => 1, 'lang' => 'zh',
+        ];
+    }
+
+    private function save(array $params): array
+    {
+        $this->request('/admin/ActivityReward/submit', $this->form($params), 'POST');
+        return (new AdminActivityReward())->store()->getData(true);
+    }
+
+    private function activity(array $params = []): ActivityReward
+    {
+        $result = $this->save($params);
+        $this->assertSame(0, $result['code'], $result['msg']);
+        return ActivityReward::query()->latest('id')->first();
+    }
+
+    private function legacyActivity(array $params = []): ActivityReward
+    {
+        $data = $this->form($params);
+        $data['start_time'] = strtotime($data['start_time']);
+        $data['end_time'] = strtotime($data['end_time']);
+        if (isset($data['params']) && is_array($data['params'])) $data['params'] = json_encode($data['params']);
+        $id = DB::table('activity_rewards')->insertGetId($data);
+        return ActivityReward::findOrFail($id);
+    }
+
+    private function member(string $id = '1001', string $registeredAt = '2026-10-01 12:34:56', string $phone = ''): void
+    {
+        DB::table('users')->insert([
+            'member_id' => $id, 'phone' => $phone, 'created_at' => $registeredAt, 'updated_at' => $registeredAt,
+        ]);
+    }
+
+    private function login(string $id, string $platform, int $status = 1): void
+    {
+        DB::table('user_login')->insert([
+            'user_id' => $id, 'platform' => $platform, 'status' => $status,
+            'created_at' => '2026-10-01 12:34:56', 'updated_at' => '2026-10-01 12:34:56',
+        ]);
+    }
+
+    private function deposit(string $id, string $amount, string $category = '充值', int $type = 1): void
+    {
+        DB::table('balance_logs')->insert([
+            'member_id' => $id, 'amount' => $amount, 'change_type' => $category, 'type' => $type,
+            'created_at' => '2026-09-01 10:00:00', 'updated_at' => '2026-09-01 10:00:00',
+        ]);
+    }
+
+    private function eligibility(ActivityReward $activity, ?string $id): array
+    {
+        return ActivityEligibilityService::evaluate($activity, ActivityEligibilityService::memberContext($id));
+    }
+
+    private function list(array $params = []): array
+    {
+        $this->request('/api/ActivityReward', $params);
+        $result = (new ApiActivityReward())->index()->getData(true);
+        $this->assertSame(0, $result['code'], $result['msg']);
+        return $result['data'];
+    }
+
+    public function test_old_forms_and_old_database_rows_keep_unrestricted_defaults(): void
+    {
+        $activity = $this->activity();
+        $this->assertSame([], $activity->publish_platforms);
+        $this->assertSame([], $activity->register_platforms);
+        $this->assertFalse((bool)$activity->register_time_limited);
+        $this->assertFalse((bool)$activity->deposit_limited);
+        $this->assertTrue((bool)$activity->show_ineligible);
+        $this->assertNull($activity->reward_quota);
+        $this->assertSame(0, (int)$activity->rewarded_count);
+        $this->assertTrue((bool)$activity->quota_enabled);
+        $this->assertSame(0, (int)$activity->sort);
+        $this->assertFalse($this->eligibility($activity, null)['is_eligible']);
+        $this->assertSame('请先登录', $this->eligibility($activity, null)['ineligible_reason']);
+        $this->member();
+        $this->assertTrue($this->eligibility($activity, '1001')['is_eligible']);
+        $this->assertTrue(ActivityEligibilityService::isPublishedOn($activity, 'PC'));
+        $this->assertTrue(ActivityEligibilityService::isPublishedOn($activity, 'H5'));
+        $this->assertTrue(ActivityEligibilityService::isPublishedOn($activity, 'APP'));
+
+        DB::table('activity_rewards')->insert([
+            'title' => '旧数据', 'sub_title' => '无新字段', 'start_time' => 1, 'end_time' => 4102444799,
+            'status' => 1, 'type' => 1, 'lang' => 'zh',
+        ]);
+        $legacy = ActivityReward::query()->latest('id')->first();
+        $this->assertFalse((bool)$legacy->quota_enabled);
+        $this->assertTrue($this->eligibility($legacy, '1001')['is_eligible']);
+        $this->assertCount(2, $this->list());
+        $this->assertNull($legacy->publish_platforms);
+        $this->assertNull($legacy->register_platforms);
+        $result = $this->save(['id' => $legacy->id, 'title' => '旧数据正常编辑']);
+        $this->assertSame(0, $result['code'], $result['msg']);
+        $legacy->refresh();
+        $this->assertSame([], $legacy->publish_platforms);
+        $this->assertSame([], $legacy->register_platforms);
+        $this->assertTrue((bool)$legacy->show_ineligible);
+        $this->assertFalse((bool)$legacy->quota_enabled);
+    }
+
+    public function test_admin_controller_persists_all_new_fields_and_preserves_false_flags_on_omitted_update(): void
+    {
+        $activity = $this->activity([
+            'publish_platforms' => ['PC', 'APP'], 'reward_quota' => 1234, 'sort' => 7,
+            'register_time_limited' => 1, 'register_start_time' => '2026-10-01 12:34:56',
+            'register_end_time' => '2026-10-02 12:34:56', 'register_platforms' => ['H5'],
+            'deposit_limited' => 1, 'deposit_min' => '0.01', 'deposit_max' => '999999999999.99',
+            'show_ineligible' => 0,
+        ]);
+        $this->assertSame(['PC', 'APP'], $activity->publish_platforms);
+        $this->assertSame(['H5'], $activity->register_platforms);
+        $this->assertSame(1234, (int)$activity->reward_quota);
+        $this->assertSame(7, (int)$activity->sort);
+        $this->assertFalse((bool)$activity->show_ineligible);
+        $this->assertTrue((bool)$activity->deposit_limited);
+
+        $this->assertSame(0, $this->save(['id' => $activity->id, 'title' => '编辑名称'])['code']);
+        $activity->refresh();
+        $this->assertSame(['PC', 'APP'], $activity->publish_platforms);
+        $this->assertSame(['H5'], $activity->register_platforms);
+        $this->assertFalse((bool)$activity->show_ineligible);
+        $this->assertTrue((bool)$activity->register_time_limited);
+        $this->assertTrue((bool)$activity->deposit_limited);
+        $this->assertSame(1234, (int)$activity->reward_quota);
+
+        $this->assertSame(0, $this->save([
+            'id' => $activity->id, 'register_time_limited' => 0, 'deposit_limited' => 0,
+        ])['code']);
+        $this->assertSame(0, $this->save(['id' => $activity->id, 'title' => '再次编辑名称'])['code']);
+        $activity->refresh();
+        $this->assertFalse((bool)$activity->register_time_limited);
+        $this->assertFalse((bool)$activity->deposit_limited);
+        $this->assertFalse((bool)$activity->show_ineligible);
+        $this->assertNull($activity->register_start_time);
+        $this->assertNull($activity->register_end_time);
+        $this->assertNull($activity->deposit_min);
+        $this->assertNull($activity->deposit_max);
+    }
+
+    public function test_reward_quota_accepts_inclusive_integer_limits_and_nullable_unlimited(): void
+    {
+        foreach ([0, 2147483647, null] as $quota) {
+            $activity = $this->activity(['reward_quota' => $quota]);
+            $this->assertSame($quota, $activity->reward_quota);
+            $this->assertSame(0, (int)$activity->rewarded_count);
+        }
+    }
+
+    public function test_invalid_ranges_amount_precision_and_platform_lists_are_rejected_without_writes(): void
+    {
+        foreach ([
+            ['publish_platforms' => 'PC'], ['publish_platforms' => ['Android']], ['publish_platforms' => null],
+            ['register_platforms' => null],
+            ['register_platforms' => ['WEB']], ['register_platforms' => [['PC']]],
+            ['reward_quota' => -1], ['reward_quota' => '1.001'], ['reward_quota' => '1e2'],
+            ['reward_quota' => 2147483648], ['reward_quota' => []],
+            ['budget' => '1.00'],
+            ['sort' => -1], ['sort' => '1.5'], ['show_ineligible' => 2],
+            ['register_time_limited' => 1],
+            ['register_time_limited' => 1, 'register_start_time' => '2026-10-02', 'register_end_time' => '2026-10-01'],
+            ['register_time_limited' => 1, 'register_start_time' => 'not-a-date', 'register_end_time' => '2026-10-01'],
+            ['deposit_limited' => 1, 'deposit_min' => '100.01', 'deposit_max' => '100.00'],
+            ['deposit_limited' => 1, 'deposit_min' => '-0.01'],
+            ['deposit_limited' => 1, 'deposit_max' => '100.001'],
+            ['deposit_limited' => 1, 'deposit_max' => '1000000000000.00'],
+            ['deposit_limited' => 1, 'deposit_min' => '1.00'],
+            ['deposit_limited' => 1, 'deposit_max' => '100.00'],
+        ] as $invalid) {
+            $result = $this->save($invalid);
+            $this->assertNotSame(0, $result['code'], json_encode($invalid));
+            $this->assertSame(0, DB::table('activity_rewards')->count(), json_encode($invalid));
+        }
+    }
+
+    public function test_partial_update_validates_against_retained_bound_and_service_cannot_bypass_validation(): void
+    {
+        $activity = $this->activity(['deposit_limited' => 1, 'deposit_min' => '10.00', 'deposit_max' => '20.00']);
+        $this->assertNotSame(0, $this->save(['id' => $activity->id, 'deposit_min' => '20.01'])['code']);
+        $activity->refresh();
+        $this->assertSame('10.00', (string)$activity->deposit_min);
+        $this->assertSame('20.00', (string)$activity->deposit_max);
+        try {
+            ActivityRewardService::submit(['id' => $activity->id, 'deposit_max' => '-1.00']);
+            $this->fail('Direct service writes must reject invalid eligibility configuration.');
+        } catch (\Exception $e) {
+            $this->assertNotSame('', $e->getMessage());
+        }
+        $activity->refresh();
+        $this->assertSame('20.00', (string)$activity->deposit_max);
+    }
+
+    public function test_football_type_ignores_even_invalid_new_fields_and_resets_existing_limits(): void
+    {
+        $invalidFields = [
+            'publish_platforms' => 'invalid', 'reward_quota' => 'invalid', 'sort' => -1,
+            'register_time_limited' => 'invalid', 'register_start_time' => 'invalid',
+            'register_end_time' => 'invalid', 'register_platforms' => ['invalid'],
+            'deposit_limited' => 'invalid', 'deposit_min' => -1, 'deposit_max' => 'invalid',
+            'show_ineligible' => 'invalid',
+        ];
+        $football = $this->activity(['type' => 2] + $invalidFields);
+        $this->assertSame([], $football->publish_platforms);
+        $this->assertSame([], $football->register_platforms);
+        $this->assertFalse((bool)$football->register_time_limited);
+        $this->assertFalse((bool)$football->deposit_limited);
+        $this->assertTrue((bool)$football->show_ineligible);
+        $this->assertNull($football->reward_quota);
+        $this->assertSame(0, (int)$football->sort);
+        $this->assertTrue($this->eligibility($football, null)['is_eligible']);
+        $this->assertTrue(ActivityEligibilityService::isPublishedOn($football, 'PC'));
+
+        $restricted = $this->activity(['register_platforms' => ['APP'], 'deposit_limited' => 1, 'deposit_min' => 100, 'deposit_max' => 200]);
+        $this->assertSame(0, $this->save(['id' => $restricted->id, 'type' => 2] + $invalidFields)['code']);
+        $restricted->refresh();
+        $this->assertSame([], $restricted->register_platforms);
+        $this->assertFalse((bool)$restricted->deposit_limited);
+        $this->assertTrue($this->eligibility($restricted, null)['is_eligible']);
+    }
+
+    public function test_registration_datetime_is_exact_and_both_boundaries_are_inclusive(): void
+    {
+        $activity = $this->activity([
+            'register_time_limited' => 1, 'register_start_time' => '2026-10-01 12:34:56',
+            'register_end_time' => '2026-10-01 12:34:57',
+        ]);
+        foreach ([['1001', '12:34:55', false], ['1002', '12:34:56', true],
+            ['1003', '12:34:57', true], ['1004', '12:34:58', false]] as [$id, $time, $eligible]) {
+            $this->member($id, '2026-10-01 ' . $time);
+            $result = $this->eligibility($activity, $id);
+            $this->assertSame($eligible, $result['is_eligible'], $time);
+            $this->assertSame($eligible, $result['ineligible_reason'] === '');
+        }
+    }
+
+    public function test_lifetime_deposit_uses_wallet_currency_ledger_categories_and_inclusive_cents(): void
+    {
+        $this->member();
+        $this->deposit('1001', '150.00');
+        $this->deposit('1001', '25.23', '三方充值');
+        $this->deposit('1001', '26.00', '人工充值');
+        $this->deposit('1001', '9999.00', '注册赠送');
+        $this->deposit('1001', '8888.00', '优惠活动');
+        $this->deposit('1001', '7777.00', '充值', 2);
+        $this->deposit('1001', '-55.00', '投注');
+        DB::table('payment_orders')->insert(['member_id' => '1001', 'type' => 1, 'status' => 2, 'amount' => 777]);
+        DB::table('recharges')->insert(['member_id' => '1001', 'status' => 1, 'amount' => 222]);
+        $activity = $this->activity(['deposit_limited' => 1, 'deposit_min' => '201.23', 'deposit_max' => '201.23']);
+        $this->assertTrue($this->eligibility($activity, '1001')['is_eligible']);
+        foreach ([['deposit_min' => '201.24', 'deposit_max' => '999999999999.99'],
+            ['deposit_min' => '0.00', 'deposit_max' => '201.22']] as $limits) {
+            $this->assertSame(0, $this->save(['id' => $activity->id] + $limits)['code']);
+            $activity->refresh();
+            $this->assertFalse($this->eligibility($activity, '1001')['is_eligible']);
+        }
+    }
+
+    public function test_zero_deposit_is_supported_and_enabled_ranges_require_both_bounds(): void
+    {
+        $this->member();
+        $activity = $this->activity(['deposit_limited' => 1, 'deposit_min' => '0.00', 'deposit_max' => '0.00']);
+        $this->assertTrue($this->eligibility($activity, '1001')['is_eligible']);
+        $this->assertNotSame(0, $this->save(['deposit_limited' => 1, 'deposit_min' => '1.00'])['code']);
+        $this->assertNotSame(0, $this->save(['deposit_limited' => 1, 'deposit_max' => '0.00'])['code']);
+        $this->assertSame(1, DB::table('activity_rewards')->count());
+    }
+
+    public function test_registration_platform_uses_first_successful_login_and_groups_ios_android_as_app(): void
+    {
+        $activity = $this->activity(['register_platforms' => ['APP']]);
+        $this->member('1001');
+        $this->login('1001', 'PC', 0);
+        $this->login('1001', 'iOS');
+        $this->login('1001', 'PC');
+        $this->assertTrue($this->eligibility($activity, '1001')['is_eligible']);
+        $this->member('1002');
+        $this->login('1002', 'Android');
+        $this->assertTrue($this->eligibility($activity, '1002')['is_eligible']);
+        $this->member('1003');
+        $this->login('1003', 'H5');
+        $this->login('1003', 'Android');
+        $this->assertFalse($this->eligibility($activity, '1003')['is_eligible']);
+        $this->member('1004');
+        DB::table('users')->where('member_id', '1004')->update(['register_domain' => 'com.example.app']);
+        $this->assertFalse($this->eligibility($activity, '1004')['is_eligible']);
+        $this->member('1005');
+        $this->login('1005', '');
+        $this->login('1005', 'Android');
+        $this->assertFalse($this->eligibility($activity, '1005')['is_eligible']);
+
+        $h5 = $this->activity(['register_platforms' => ['H5']]);
+        $this->member('1006');
+        $this->login('1006', 'WeChat');
+        $this->assertTrue($this->eligibility($h5, '1006')['is_eligible']);
+    }
+
+    public function test_guest_and_member_display_choices_and_sort_order_are_enforced_by_controller(): void
+    {
+        $this->member();
+        $this->deposit('1001', '100.00');
+        $unrestricted = $this->activity(['sort' => 1]);
+        $hidden = $this->activity(['sort' => 9, 'deposit_limited' => 1, 'deposit_min' => '100.01', 'deposit_max' => '200.00', 'show_ineligible' => 0]);
+        $shown = $this->activity(['sort' => 7, 'deposit_limited' => 1, 'deposit_min' => '100.01', 'deposit_max' => '200.00', 'show_ineligible' => 1]);
+        $eligible = $this->activity(['sort' => 7, 'deposit_limited' => 1, 'deposit_min' => '100.00', 'deposit_max' => '200.00', 'show_ineligible' => 0]);
+        $this->activity(['type' => 2, 'sort' => 99]);
+        $this->activity(['status' => 0]);
+
+        $guest = $this->list();
+        $this->assertSame([$shown->id, $unrestricted->id], array_column($guest, 'id'));
+        $this->assertFalse($guest[0]['is_eligible']);
+        $this->assertNotSame('', $guest[0]['ineligible_reason']);
+        $this->assertFalse($guest[1]['is_eligible']);
+        $this->assertSame('请先登录', $guest[1]['ineligible_reason']);
+        $member = $this->list(['member_id' => '1001']);
+        $this->assertSame([$eligible->id, $shown->id, $unrestricted->id], array_column($member, 'id'));
+        $this->assertNotContains($hidden->id, array_column($member, 'id'));
+        $this->assertTrue($member[0]['is_eligible']);
+        $this->assertSame('', $member[0]['ineligible_reason']);
+        $this->assertFalse($member[1]['is_eligible']);
+        $this->assertFalse($member[0]['is_claimed']);
+    }
+
+    public function test_publication_platform_filter_is_independent_of_member_registration_platform(): void
+    {
+        $this->member();
+        $this->login('1001', 'iOS');
+        $pc = $this->activity(['publish_platforms' => ['PC'], 'register_platforms' => ['APP']]);
+        $h5 = $this->activity(['publish_platforms' => ['H5']]);
+        $app = $this->activity(['publish_platforms' => ['APP']]);
+        $all = $this->activity();
+        $this->assertTrue(ActivityEligibilityService::isPublishedOn($pc, 'PC'));
+        $this->assertFalse(ActivityEligibilityService::isPublishedOn($pc, 'APP'));
+        $this->assertTrue($this->eligibility($pc, '1001')['is_eligible']);
+        foreach ([['PC', $pc], ['H5', $h5], ['APP', $app]] as [$platform, $specific]) {
+            $rows = $this->list(['member_id' => '1001', 'platform' => $platform]);
+            $this->assertSame([$all->id, $specific->id], array_column($rows, 'id'), $platform);
+        }
+        $this->assertSame([$all->id], array_column($this->list(['member_id' => '1001']), 'id'));
+        $this->request('/api/ActivityReward', ['platform' => 'INVALID']);
+        $this->assertNotSame(0, (new ApiActivityReward())->index()->getData(true)['code']);
+    }
+
+    public function test_participation_rejects_ineligible_even_when_shown_and_before_existing_phone_checks(): void
+    {
+        $this->member();
+        $activity = $this->activity(['register_platforms' => ['APP'], 'show_ineligible' => 1]);
+        $reason = $this->eligibility($activity, '1001')['ineligible_reason'];
+        $this->request('/api/ActivityReward/participate', ['member_id' => '1001', 'activity_id' => $activity->id], 'POST');
+        $result = (new ApiActivityReward())->participate()->getData(true);
+        $this->assertNotSame(0, $result['code']);
+        $this->assertSame($reason, $result['msg']);
+        $this->assertNotSame(HttpStatus::NOT_BIND_PHONE, $result['code']);
+        $this->assertSame(0, DB::table('activity_user')->count());
+
+        $this->login('1001', 'Android');
+        $this->request('/api/ActivityReward/participate', ['member_id' => '1001', 'activity_id' => $activity->id], 'POST');
+        $this->assertSame(HttpStatus::NOT_BIND_PHONE, (new ApiActivityReward())->participate()->getData(true)['code']);
+    }
+
+    public function test_eligible_member_can_submit_activity_and_list_retains_claimed_state(): void
+    {
+        $this->member('1001', '2026-10-01 12:34:56', '15500001001');
+        $this->login('1001', 'PC');
+        $this->deposit('1001', '100.00');
+        DB::table('payment_orders')->insert(['member_id' => '1001', 'type' => 1, 'status' => 2, 'amount' => '100.00']);
+        $activity = $this->activity(['register_platforms' => ['PC'], 'deposit_limited' => 1, 'deposit_min' => '100.00', 'deposit_max' => '200.00', 'reward_quota' => 1]);
+        $this->request('/api/ActivityReward/participate', ['member_id' => '1001', 'activity_id' => $activity->id], 'POST');
+        $result = (new ApiActivityReward())->participate()->getData(true);
+        $this->assertSame(0, $result['code'], $result['msg']);
+        $this->assertSame(1, DB::table('activity_user')->count());
+        $this->assertSame(0, (int)$activity->fresh()->rewarded_count);
+        $this->assertSame(0, DB::table('activity_reward_recipients')->count());
+        $rows = $this->list(['member_id' => '1001']);
+        $this->assertTrue($rows[0]['is_eligible']);
+        $this->assertTrue($rows[0]['is_claimed']);
+    }
+
+    public function test_automatic_recharge_and_old_member_rewards_check_eligibility_before_bonus_side_effects(): void
+    {
+        $this->member();
+        $this->deposit('1001', '100.00');
+        $this->activity([
+            'type' => 3, 'deposit_limited' => 1, 'deposit_min' => '100.01', 'deposit_max' => '200.00',
+            'params' => ['reward_ratio' => 5, 'min_recharge_amount' => 0, 'reward_turnover_ratio' => 1],
+        ]);
+        $this->activity([
+            'type' => 4, 'register_platforms' => ['APP'],
+            'params' => ['no_login_days' => 1, 'reward_rules' => [['start_level' => 0, 'end_level' => 5, 'bonus' => 10]]],
+        ]);
+        $this->assertFalse(BalanceLogService::calculateRechargeBonus('100.00', '1001', 123));
+        $this->assertFalse(BalanceLogService::calculateUserReturnRechargeBonus('100.00', '1001', 1, 123));
+        $this->assertSame(0, DB::table('wallet_bonus')->count());
+        $this->assertSame(1, DB::table('balance_logs')->count());
+    }
+
+    /** Exercise the real addLog entry without relying on a caller-owned transaction. */
+    private function automaticRewardFixtures(): array
+    {
+        $schema = DB::connection()->getSchemaBuilder();
+        $schema->create('config', function (Blueprint $t) {
+            $t->id(); $t->string('field'); $t->string('val'); $t->timestamps();
+        });
+        $schema->create('level', function (Blueprint $t) {
+            $t->id(); $t->integer('level'); $t->decimal('recharge', 20, 2); $t->timestamps();
+        });
+        $schema->create('recharge_channel_group', function (Blueprint $t) {
+            $t->id(); $t->text('activity_type'); $t->softDeletes(); $t->timestamps();
+        });
+        $schema->create('backflow', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->string('date');
+            foreach (['recharge_amount', 'withdrawal_amount', 'backflow_ratio', 'amount'] as $field) {
+                $t->decimal($field, 20, 2)->default(0);
+            }
+            $t->timestamps();
+        });
+        DB::table('config')->insert([
+            ['field' => 'huishui_percentage', 'val' => '0'],
+            ['field' => 'huishui_restriction', 'val' => '999999'],
+            ['field' => 'recharge_rate', 'val' => '0'],
+        ]);
+        DB::table('level')->insert(['level' => 0, 'recharge' => '0']);
+        DB::table('recharge_channel_group')->insert(['id' => 1, 'activity_type' => 'recharge,old_user']);
+        DB::table('backflow')->insert(['member_id' => '1001', 'date' => date('Y-m')]);
+        $this->member();
+        DB::table('wallets')->insert(['member_id' => '1001', 'available_balance' => '100.00', 'frozen_balance' => '2.00']);
+        foreach ([date('Y-m-d 00:00:00', strtotime('-14 days')), date('Y-m-d 00:00:00')] as $loggedAt) {
+            DB::table('user_login')->insert([
+                'user_id' => '1001', 'status' => 1, 'platform' => 'PC',
+                'created_at' => $loggedAt, 'updated_at' => $loggedAt,
+            ]);
+        }
+        return [
+            $this->activity(['type' => 3, 'reward_quota' => 1,
+                'params' => ['reward_ratio' => 5, 'reward_turnover_ratio' => 1]]),
+            $this->activity(['type' => 4, 'reward_quota' => 1,
+                'params' => ['no_login_days' => 7,
+                    'reward_rules' => [['start_level' => 0, 'end_level' => 0, 'bonus' => '7.00', 'turnover_rate' => 1]]]]),
+        ];
+    }
+
+    public static function automaticRewardWriteFailures(): array
+    {
+        return [
+            'USDT wallet save failure' => ['充值', 'wallet'],
+            'third-party wallet save failure' => ['三方充值', 'wallet'],
+            'recharge reward ledger failure' => ['充值', '即充即送'],
+            'return reward ledger failure after first award' => ['三方充值', '老用户回归'],
+        ];
+    }
+
+    /** @dataProvider automaticRewardWriteFailures */
+    public function test_automatic_reward_failure_rolls_back_recipients_count_bonus_ledger_and_wallet(string $category, string $failure): void
+    {
+        [$recharge, $return] = $this->automaticRewardFixtures();
+        if ($failure === 'wallet') {
+            DB::statement("CREATE TRIGGER fail_reward_wallet BEFORE UPDATE ON bot_wallets
+                WHEN NEW.frozen_balance > OLD.frozen_balance
+                BEGIN SELECT RAISE(ABORT, 'injected reward write failure'); END");
+        } else {
+            DB::statement("CREATE TRIGGER fail_reward_ledger BEFORE INSERT ON bot_balance_logs
+                WHEN NEW.change_type = '$failure'
+                BEGIN SELECT RAISE(ABORT, 'injected reward write failure'); END");
+        }
+        $this->assertSame(0, DB::transactionLevel());
+        try {
+            BalanceLogService::addLog('1001', '100.00', '0.00', '100.00', $category, 900, '');
+            $this->fail('The injected database write failure must reach the caller.');
+        } catch (\Illuminate\Database\QueryException $e) {
+            $this->assertStringContainsString('injected reward write failure', $e->getMessage());
+        }
+        $this->assertSame(0, DB::transactionLevel());
+        $this->assertSame(0, (int)$recharge->fresh()->rewarded_count);
+        $this->assertSame(0, (int)$return->fresh()->rewarded_count);
+        $this->assertSame(0, DB::table('activity_reward_recipients')->count());
+        $this->assertSame(0, DB::table('wallet_bonus')->count());
+        $this->assertSame(0, DB::table('balance_logs')->where('type', 2)->count());
+        $this->assertEquals('2.00', DB::table('wallets')->value('frozen_balance'));
+        $this->assertEquals('100.00', DB::table('wallets')->value('available_balance'));
+        // The completed principal deposit stays recorded when only rewards fail.
+        $this->assertSame(1, DB::table('balance_logs')->where('change_type', $category)->count());
+    }
+
+    public function test_automatic_rewards_commit_one_recipient_per_activity_with_bonus_ledgers_and_wallet(): void
+    {
+        [$recharge, $return] = $this->automaticRewardFixtures();
+        BalanceLogService::addLog('1001', '100.00', '0.00', '100.00', '充值', 900, '');
+        $this->assertSame(0, DB::transactionLevel());
+        $this->assertSame(1, (int)$recharge->fresh()->rewarded_count);
+        $this->assertSame(1, (int)$return->fresh()->rewarded_count);
+        $this->assertSame(2, DB::table('activity_reward_recipients')->where('member_id', '1001')->count());
+        $this->assertEquals('14.00', DB::table('wallets')->value('frozen_balance'));
+        $this->assertEquals('100.00', DB::table('wallets')->value('available_balance'));
+        $this->assertSame(2, DB::table('wallet_bonus')->count());
+        $this->assertEquals('12.00', DB::table('wallet_bonus')->sum('amount'));
+        $this->assertEquals('12.00', DB::table('balance_logs')->where('type', 2)->sum('amount'));
+        $this->assertEquals('2.00', DB::table('balance_logs')->where('change_type', '即充即送')->value('before_balance'));
+        $this->assertEquals('7.00', DB::table('balance_logs')->where('change_type', '老用户回归')->value('before_balance'));
+        $this->assertSame(1, DB::table('balance_logs')->where('change_type', '充值')->count());
+    }
+
+    public function test_automatic_reward_transaction_respects_an_existing_caller_transaction(): void
+    {
+        [$recharge, $return] = $this->automaticRewardFixtures();
+        DB::beginTransaction();
+        BalanceLogService::addLog('1001', '100.00', '0.00', '100.00', '三方充值', 900, '');
+        $this->assertSame(1, DB::transactionLevel());
+        $this->assertEquals('14.00', DB::table('wallets')->value('frozen_balance'));
+        DB::rollBack();
+        $this->assertSame(0, (int)$recharge->fresh()->rewarded_count);
+        $this->assertSame(0, (int)$return->fresh()->rewarded_count);
+        $this->assertSame(0, DB::table('activity_reward_recipients')->count());
+        $this->assertEquals('2.00', DB::table('wallets')->value('frozen_balance'));
+        $this->assertEquals('100.00', DB::table('wallets')->value('available_balance'));
+        $this->assertSame(0, DB::table('wallet_bonus')->count());
+        $this->assertSame(0, DB::table('balance_logs')->count());
+    }
+
+    public function test_automatic_recharge_uses_already_posted_current_deposit_once(): void
+    {
+        $this->member();
+        $this->deposit('1001', '100.00');
+        $activity = $this->activity([
+            'type' => 3, 'deposit_limited' => 1, 'deposit_min' => '100.00', 'deposit_max' => '100.00',
+            'params' => ['reward_ratio' => 5, 'min_recharge_amount' => 0, 'reward_turnover_ratio' => 1],
+        ]);
+        $this->assertEquals('5.00', BalanceLogService::calculateRechargeBonus('100.00', '1001', 123));
+        $this->assertSame(1, DB::table('wallet_bonus')->where('activity_id', $activity->id)->count());
+        $this->assertEquals(5, DB::table('wallet_bonus')->value('amount'));
+        $this->assertSame(1, DB::table('balance_logs')->count());
+    }
+
+    public function test_automatic_selection_skips_higher_sorted_ineligible_and_exhausted_activities(): void
+    {
+        $this->member();
+        $this->login('1001', 'PC');
+        $this->deposit('1001', '100.00');
+        $rewardParams = ['reward_ratio' => 5, 'min_recharge_amount' => 0, 'reward_turnover_ratio' => 1];
+        $lower = $this->activity([
+            'type' => 3, 'sort' => 1, 'reward_quota' => 2, 'register_platforms' => ['PC'], 'params' => $rewardParams,
+        ]);
+        $higher = $this->activity([
+            'type' => 3, 'sort' => 2, 'reward_quota' => 100, 'register_platforms' => ['APP'], 'params' => $rewardParams,
+        ]);
+        $this->assertSame($lower->id, ActivityRewardService::activeForMember(3, '1001')->id);
+        $this->assertEquals('5.00', BalanceLogService::calculateRechargeBonus('100.00', '1001', 123));
+        $lower->refresh();
+        $higher->refresh();
+        $this->assertSame(1, (int)$lower->rewarded_count);
+        $this->assertSame(0, (int)$higher->rewarded_count);
+
+        $this->assertSame(0, $this->save([
+            'id' => $higher->id, 'type' => 3, 'reward_quota' => 0, 'register_platforms' => ['PC'], 'params' => $rewardParams,
+        ])['code']);
+        $higher->refresh();
+        $this->assertTrue($this->eligibility($higher, '1001')['is_eligible']);
+        $this->assertSame($lower->id, ActivityRewardService::activeForMember(3, '1001')->id);
+        $this->assertEquals('5.00', BalanceLogService::calculateRechargeBonus('100.00', '1001', 124));
+        $lower->refresh();
+        $higher->refresh();
+        $this->assertSame(1, (int)$lower->rewarded_count);
+        $this->assertSame(0, (int)$higher->rewarded_count);
+        $this->assertSame(2, DB::table('wallet_bonus')->where('activity_id', $lower->id)->count());
+        $this->assertSame(0, DB::table('wallet_bonus')->where('activity_id', $higher->id)->count());
+    }
+
+    public function test_ten_distinct_members_take_ten_slots_and_eleventh_is_denied_using_stale_model(): void
+    {
+        $activity = $this->activity(['reward_quota' => 10]);
+        $grants = [];
+        for ($member = 1; $member <= 10; $member++) {
+            $id = (string)(1000 + $member);
+            $this->member($id);
+            $amount = $member % 2 ? '0.01' : '12345.67';
+            $this->assertTrue(ActivityQuotaService::canAward($activity->fresh(), $id));
+            $this->assertTrue(ActivityQuotaService::award($activity, $id, $amount, function () use (&$grants, $id) {
+                $grants[] = $id;
+            }));
+        }
+        $this->member('1011');
+        $this->assertFalse(ActivityQuotaService::canAward($activity->fresh(), '1011'));
+        $this->assertFalse(ActivityQuotaService::award($activity, '1011', '0.01', function () { $this->fail('Eleventh member must be denied.'); }));
+        $this->assertSame(range(1001, 1010), array_map('intval', $grants));
+        $this->assertSame(10, (int)$activity->fresh()->rewarded_count);
+        $this->assertSame(10, DB::table('activity_reward_recipients')->where('activity_id', $activity->id)->count());
+    }
+
+    public function test_repeat_rewards_take_one_slot_and_same_member_counts_separately_in_each_activity(): void
+    {
+        $this->member();
+        $first = $this->activity(['reward_quota' => 1]);
+        $second = $this->activity(['reward_quota' => 1]);
+        $grants = 0;
+        foreach (['1.00', '2.50', '1000.00'] as $amount) {
+            $this->assertTrue(ActivityQuotaService::award($first, '1001', $amount, function () use (&$grants) { $grants++; }));
+        }
+        $this->assertTrue(ActivityQuotaService::canAward($first->fresh(), '1001'));
+        $this->assertFalse(ActivityQuotaService::canAward($first->fresh(), '1002'));
+        $this->assertTrue(ActivityQuotaService::award($second, '1001', '0.01', function () use (&$grants) { $grants++; }));
+        $this->assertSame(4, $grants);
+        $this->assertSame(1, (int)$first->fresh()->rewarded_count);
+        $this->assertSame(1, (int)$second->fresh()->rewarded_count);
+        $this->assertSame(2, DB::table('activity_reward_recipients')->where('member_id', '1001')->count());
+    }
+
+    /** Checks SQL lock intent only; SQLite cannot reproduce MySQL repeatable-read snapshots. */
+    public function test_award_locks_activity_then_recipient_for_current_read_but_precheck_does_not_lock(): void
+    {
+        $activity = $this->activity(['reward_quota' => 1]);
+        $grammar = new class extends \Illuminate\Database\Query\Grammars\SQLiteGrammar {
+            public array $reads = [];
+
+            public function compileSelect(\Illuminate\Database\Query\Builder $query)
+            {
+                if (in_array($query->from, ['activity_rewards', 'activity_reward_recipients'], true)) {
+                    $this->reads[] = ['table' => $query->from, 'lock' => $query->lock];
+                }
+                return parent::compileSelect($query);
+            }
+        };
+        $grammar->setTablePrefix(DB::connection()->getTablePrefix());
+        DB::connection()->setQueryGrammar($grammar);
+
+        $this->assertTrue(ActivityQuotaService::canAward($activity, '1001'));
+        $this->assertSame([['table' => 'activity_reward_recipients', 'lock' => null]], $grammar->reads);
+        $grammar->reads = [];
+
+        $this->assertTrue(ActivityQuotaService::award($activity, '1001', '1.00', function () {}));
+        $this->assertSame([
+            ['table' => 'activity_rewards', 'lock' => true],
+            ['table' => 'activity_reward_recipients', 'lock' => true],
+        ], $grammar->reads);
+        $grammar->reads = [];
+
+        $this->assertTrue(ActivityQuotaService::award($activity, '1001', '2.00', function () {}));
+        $this->assertSame([
+            ['table' => 'activity_rewards', 'lock' => true],
+            ['table' => 'activity_reward_recipients', 'lock' => true],
+        ], $grammar->reads);
+        $fresh = $activity->fresh();
+        $this->assertSame(1, (int)$fresh->rewarded_count);
+        $grammar->reads = [];
+
+        $this->assertTrue(ActivityQuotaService::canAward($fresh, '1001'));
+        $this->assertSame([['table' => 'activity_reward_recipients', 'lock' => null]], $grammar->reads);
+    }
+
+    public function test_quota_rejects_invalid_award_precision_amounts_and_empty_members_without_grant(): void
+    {
+        $activity = $this->activity(['reward_quota' => 1]);
+        foreach (['1.009', '1e0', '-0.01', '0', '0.00', '1000000000000.00', 'not-an-amount'] as $amount) {
+            $this->assertFalse(ActivityQuotaService::award($activity, '1001', $amount, function () use ($amount) {
+                $this->fail('Invalid award must not invoke grant: ' . $amount);
+            }), $amount);
+            $this->assertSame(0, (int)$activity->fresh()->rewarded_count, $amount);
+            $this->assertSame(0, DB::table('activity_reward_recipients')->count());
+        }
+        foreach ([null, ''] as $id) {
+            $this->assertFalse(ActivityQuotaService::canAward($activity, $id));
+            $this->assertFalse(ActivityQuotaService::award($activity, $id, '1.00', function () { $this->fail('Missing member must not take a slot.'); }));
+        }
+    }
+
+    public function test_failed_grant_rolls_back_recipient_counter_wallet_and_bonus_ledger(): void
+    {
+        $this->member();
+        DB::table('wallets')->insert(['member_id' => '1001', 'available_balance' => '50.00']);
+        $activity = $this->activity(['reward_quota' => 1]);
+        try {
+            ActivityQuotaService::award($activity, '1001', '2.50', function () use ($activity) {
+                DB::table('wallets')->where('member_id', '1001')->update(['available_balance' => '52.50']);
+                $this->deposit('1001', '2.50', '优惠活动');
+                DB::table('wallet_bonus')->insert([
+                    'member_id' => '1001', 'activity_id' => $activity->id, 'amount' => '2.50',
+                    'need_flow' => '2.50', 'remaind_flow' => '2.50', 'status' => 1,
+                ]);
+                throw new \RuntimeException('fixture grant failed');
+            });
+            $this->fail('Grant failures must propagate so callers can roll back the reward.');
+        } catch (\RuntimeException $e) {
+            $this->assertSame('fixture grant failed', $e->getMessage());
+        }
+        $this->assertSame(0, DB::table('balance_logs')->count());
+        $this->assertSame(0, DB::table('wallet_bonus')->count());
+        $this->assertSame(0, DB::table('activity_reward_recipients')->count());
+        $this->assertSame(0, (int)$activity->fresh()->rewarded_count);
+        $this->assertEquals('50.00', DB::table('wallets')->value('available_balance'));
+        $this->assertTrue(ActivityQuotaService::canAward($activity->fresh(), '1001'));
+        $this->assertTrue(ActivityQuotaService::award($activity, '1001', '10.00', function () {}));
+        $this->assertSame(1, (int)$activity->fresh()->rewarded_count);
+    }
+
+    public function test_outer_rollback_releases_recipient_and_count_with_reward_writes(): void
+    {
+        $activity = $this->activity(['reward_quota' => 1]);
+        DB::beginTransaction();
+        $this->assertTrue(ActivityQuotaService::award($activity, '1001', '1.00', function () { $this->deposit('1001', '1.00', '优惠活动'); }));
+        $this->assertSame(1, DB::transactionLevel());
+        $this->assertSame(1, (int)$activity->fresh()->rewarded_count);
+        $this->assertSame(1, DB::table('activity_reward_recipients')->count());
+        DB::rollBack();
+        $this->assertSame(0, (int)$activity->fresh()->rewarded_count);
+        $this->assertSame(0, DB::table('activity_reward_recipients')->count());
+        $this->assertSame(0, DB::table('balance_logs')->count());
+    }
+
+    public function test_zero_quota_blocks_new_members_unlimited_counts_members_and_football_bypasses_quota(): void
+    {
+        $zero = $this->activity(['reward_quota' => 0]);
+        $this->assertFalse(ActivityQuotaService::canAward($zero, '1001'));
+        $this->assertFalse(ActivityQuotaService::award($zero, '1001', '0.01', function () { $this->fail('Zero quota must block new recipients.'); }));
+        $unlimited = $this->activity();
+        foreach (['1001', '1002', '1001'] as $id) {
+            $this->assertTrue(ActivityQuotaService::award($unlimited, $id, '100.50', function () {}));
+        }
+        $this->assertNull($unlimited->fresh()->reward_quota);
+        $this->assertSame(2, (int)$unlimited->fresh()->rewarded_count);
+        $football = $this->activity(['type' => 2, 'reward_quota' => 0]);
+        $granted = false;
+        $this->assertTrue(ActivityQuotaService::award($football, '1001', '1.00', function () use (&$granted) { $granted = true; }));
+        $this->assertTrue($granted);
+        $this->assertSame(0, (int)$football->fresh()->rewarded_count);
+        $this->assertSame(0, DB::table('activity_reward_recipients')->where('activity_id', $football->id)->count());
+    }
+
+    public function test_lowering_quota_below_occupied_count_preserves_existing_recipient_and_rejects_new_member(): void
+    {
+        $activity = $this->activity(['reward_quota' => 2]);
+        foreach (['1001', '1002'] as $id) {
+            $this->assertTrue(ActivityQuotaService::award($activity, $id, '1.00', function () {}));
+        }
+        $this->assertSame(0, $this->save(['id' => $activity->id, 'reward_quota' => 0])['code']);
+        $activity->refresh();
+        $this->assertTrue(ActivityQuotaService::canAward($activity, '1001'));
+        $this->assertFalse(ActivityQuotaService::canAward($activity, '1003'));
+        $this->assertTrue(ActivityQuotaService::award($activity, '1001', '2.50', function () {}));
+        $this->assertFalse(ActivityQuotaService::award($activity, '1003', '0.01', function () { $this->fail('Reduced quota must block new recipients.'); }));
+        $this->assertSame(2, (int)$activity->fresh()->rewarded_count);
+        $this->assertSame(2, DB::table('activity_reward_recipients')->count());
+    }
+
+    public function test_admin_edits_cannot_reset_readonly_recipient_count_or_use_legacy_money_budget(): void
+    {
+        $activity = $this->activity(['reward_quota' => 1]);
+        $this->assertTrue(ActivityQuotaService::award($activity, '1001', '8.00', function () {}));
+        $result = $this->save(['id' => $activity->id, 'rewarded_count' => 0, 'reward_quota' => 2,
+            'quota_enabled' => 0, 'budget_used' => '10.00']);
+        $this->assertSame(0, $result['code'], $result['msg']);
+        $this->assertSame(1, (int)$activity->fresh()->rewarded_count);
+        $this->assertSame(2, (int)$activity->fresh()->reward_quota);
+        $this->assertTrue((bool)$activity->fresh()->quota_enabled);
+        $this->assertSame('0.00', (string)$activity->fresh()->budget_used);
+        ActivityRewardService::submit(['id' => $activity->id, 'rewarded_count' => 0]);
+        $this->assertSame(1, (int)$activity->fresh()->rewarded_count);
+        $this->assertNotSame(0, $this->save(['id' => $activity->id, 'budget' => '10.00'])['code']);
+        $this->assertSame(1, (int)$activity->fresh()->rewarded_count);
+        // Mixed-mode APIs retain old fields, with the server-owned mode identifying the rules.
+        $this->request('/admin/ActivityReward', ['page' => 1, 'limit' => 10]);
+        $admin = (new AdminActivityReward())->index()->getData(true);
+        $this->assertSame(0, $admin['code'], $admin['msg']);
+        foreach ([$admin['data']['data'][0], $this->list(['member_id' => '1001'])[0]] as $row) {
+            $this->assertNull($row['budget']);
+            $this->assertSame('0.00', (string)$row['budget_used']);
+            $this->assertTrue((bool)$row['quota_enabled']);
+            $this->assertSame(2, (int)$row['reward_quota']);
+            $this->assertSame(1, (int)$row['rewarded_count']);
+        }
+    }
+
+    public function test_automatic_rewards_stop_for_new_members_at_quota_but_repeat_reward_keeps_one_slot(): void
+    {
+        foreach (['1001', '1002'] as $id) {
+            $this->member($id);
+            $this->deposit($id, '100.00');
+        }
+        $params = ['reward_ratio' => 5, 'min_recharge_amount' => 0, 'reward_turnover_ratio' => 1];
+        $activity = $this->activity(['type' => 3, 'reward_quota' => 0, 'params' => $params]);
+        $this->assertFalse(BalanceLogService::calculateRechargeBonus('100.00', '1001', 123));
+        $this->assertSame(0, DB::table('wallet_bonus')->count());
+        $this->assertSame(0, (int)$activity->fresh()->rewarded_count);
+        $this->assertSame(0, $this->save(['id' => $activity->id, 'type' => 3, 'reward_quota' => 1, 'params' => $params])['code']);
+        $this->assertEquals('5.00', BalanceLogService::calculateRechargeBonus('100.00', '1001', 123));
+        $this->assertEquals('10.00', BalanceLogService::calculateRechargeBonus('200.00', '1001', 124));
+        $this->assertFalse(BalanceLogService::calculateRechargeBonus('100.00', '1002', 125));
+        $this->assertSame(2, DB::table('wallet_bonus')->count());
+        $this->assertSame(1, (int)$activity->fresh()->rewarded_count);
+        $this->assertSame(1, DB::table('activity_reward_recipients')->where('activity_id', $activity->id)->count());
+    }
+
+    public function test_manual_gift_rechecks_eligibility_and_quota_before_wallet_and_application_changes(): void
+    {
+        $this->member();
+        DB::table('wallets')->insert(['member_id' => '1001', 'available_balance' => '50.00']);
+        foreach ([['register_platforms' => ['APP']], ['reward_quota' => 0]] as $limits) {
+            $activity = $this->activity($limits);
+            $applicationId = DB::table('activity_user')->insertGetId([
+                'member_id' => '1001', 'activity_id' => $activity->id, 'status' => 0,
+                'title' => '待赠送', 'sub_title' => '资格检查', 'detail_image' => '/app.png',
+                'start_time' => 1, 'end_time' => 4102444799, 'part_in_time' => time(),
+            ]);
+            $this->request('/admin/ActivityUser/gift', ['id' => $applicationId, 'amount' => '1.00', 'betting_amount' => 1], 'POST');
+            $result = (new AdminActivityUser())->gift()->getData(true);
+            $this->assertSame(HttpStatus::CUSTOM_ERROR, $result['code'], $result['msg']);
+            $this->assertSame(0, (int)DB::table('activity_user')->where('id', $applicationId)->value('status'));
+            $this->assertEquals('50.00', DB::table('wallets')->value('available_balance'));
+            $this->assertSame(0, DB::table('balance_logs')->count());
+            $activity->refresh();
+            $this->assertSame(0, (int)$activity->rewarded_count);
+            $this->assertSame(0, DB::table('activity_reward_recipients')->count());
+        }
+    }
+
+    public function test_legacy_activity_retains_money_budget_and_cannot_be_switched_to_quota_by_client(): void
+    {
+        $this->member();
+        $legacy = $this->legacyActivity(['budget' => '10.00']);
+        $this->assertFalse((bool)$legacy->quota_enabled);
+        $this->assertTrue(ActivityQuotaService::award($legacy, '1001', '6.00', function () {}));
+        $this->assertFalse(ActivityQuotaService::award($legacy, '1001', '5.00', function () { $this->fail('Old money budget must reject an award above the remaining amount.'); }));
+        $this->assertTrue(ActivityQuotaService::award($legacy, '1001', '4.00', function () {}));
+        $legacy->refresh();
+        $this->assertSame('10.00', (string)$legacy->budget_used);
+        $this->assertFalse(ActivityQuotaService::canAward($legacy, '1001'));
+        $this->assertSame(0, (int)$legacy->rewarded_count);
+        $this->assertSame(0, DB::table('activity_reward_recipients')->count());
+
+        $this->assertNotSame(0, $this->save(['id' => $legacy->id, 'reward_quota' => 10])['code']);
+        $result = $this->save(['id' => $legacy->id, 'quota_enabled' => 1,
+            'budget' => '12.00', 'budget_used' => '0.00', 'rewarded_count' => 999]);
+        $this->assertSame(0, $result['code'], $result['msg']);
+        $legacy->refresh();
+        $this->assertFalse((bool)$legacy->quota_enabled);
+        $this->assertNull($legacy->reward_quota);
+        $this->assertSame('12.00', (string)$legacy->budget);
+        $this->assertSame('10.00', (string)$legacy->budget_used);
+        $this->assertSame(0, (int)$legacy->rewarded_count);
+        $new = $this->activity(['reward_quota' => 1, 'quota_enabled' => 0]);
+        $this->assertTrue((bool)$new->quota_enabled);
+        $this->assertTrue(ActivityQuotaService::award($new, '1001', '100.00', function () {}));
+        $this->assertSame(1, (int)$new->fresh()->rewarded_count);
+        $this->assertSame(1, DB::table('activity_reward_recipients')->count());
+        $rows = array_column($this->list(['member_id' => '1001']), null, 'id');
+        $this->assertFalse((bool)$rows[$legacy->id]['quota_enabled']);
+        $this->assertSame('12.00', (string)$rows[$legacy->id]['budget']);
+        $this->assertSame('10.00', (string)$rows[$legacy->id]['budget_used']);
+        $this->assertTrue((bool)$rows[$new->id]['quota_enabled']);
+    }
+
+    public function test_creation_mode_is_immutable_when_an_activity_type_changes(): void
+    {
+        $legacy = $this->legacyActivity(['type' => 2]);
+        $this->assertSame(0, $this->save(['id' => $legacy->id, 'type' => 1, 'quota_enabled' => 1])['code']);
+        $this->assertFalse((bool)$legacy->fresh()->quota_enabled);
+        $new = $this->activity(['type' => 2]);
+        $this->assertSame(0, $this->save(['id' => $new->id, 'type' => 1, 'reward_quota' => 1, 'quota_enabled' => 0])['code']);
+        $this->assertTrue((bool)$new->fresh()->quota_enabled);
+        $this->assertSame(1, (int)$new->fresh()->reward_quota);
+    }
+
+    public function test_manual_gift_failure_rolls_back_wallet_application_recipient_and_counter(): void
+    {
+        $this->member();
+        DB::table('wallets')->insert(['member_id' => '1001', 'available_balance' => '50.00']);
+        $activity = $this->activity(['reward_quota' => 1]);
+        $applicationId = DB::table('activity_user')->insertGetId([
+            'member_id' => '1001', 'activity_id' => $activity->id, 'status' => 0,
+            'title' => '待赠送', 'sub_title' => '资格检查', 'detail_image' => '/app.png',
+            'start_time' => 1, 'end_time' => 4102444799, 'part_in_time' => time(),
+        ]);
+        DB::statement("CREATE TRIGGER fail_manual_gift_ledger BEFORE INSERT ON bot_balance_logs
+            WHEN NEW.change_type = '优惠活动'
+            BEGIN SELECT RAISE(ABORT, 'injected manual reward failure'); END");
+        $this->request('/admin/ActivityUser/gift', ['id' => $applicationId, 'amount' => '1.00', 'betting_amount' => 1], 'POST');
+        $result = (new AdminActivityUser())->gift()->getData(true);
+        $this->assertNotSame(0, $result['code']);
+        $this->assertStringContainsString('injected manual reward failure', $result['msg']);
+        $this->assertEquals('50.00', DB::table('wallets')->value('available_balance'));
+        $this->assertSame(0, (int)DB::table('activity_user')->value('status'));
+        $this->assertSame(0, (int)$activity->fresh()->rewarded_count);
+        $this->assertSame(0, DB::table('activity_reward_recipients')->count());
+        $this->assertSame(0, DB::table('balance_logs')->count());
+    }
+
+    public function test_migration_adds_optional_yuebao_activity_reference_and_rolls_back_cleanly(): void
+    {
+        $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_13_120000_add_activity_eligibility_fields.php';
+        $migration->down();
+        DB::connection()->getSchemaBuilder()->create('yuebao_item', function (Blueprint $t) {
+            $t->id(); $t->decimal('principal', 20, 2)->default(0);
+        });
+        $migration->up();
+        $schema = DB::connection()->getSchemaBuilder();
+        $this->assertTrue($schema->hasColumn('yuebao_item', 'activity_id'));
+        $this->assertTrue($schema->hasColumn('activity_rewards', 'budget_used'));
+        $this->assertTrue($schema->hasColumn('activity_rewards', 'quota_enabled'));
+        DB::table('yuebao_item')->insert(['principal' => '100.00']);
+        $this->assertNull(DB::table('yuebao_item')->value('activity_id'));
+        $migration->down();
+        $this->assertFalse($schema->hasColumn('yuebao_item', 'activity_id'));
+        $this->assertFalse($schema->hasColumn('activity_rewards', 'budget_used'));
+        $this->assertEquals(100, DB::table('yuebao_item')->value('principal'));
+    }
+
+    public function test_quota_migration_up_down_and_unique_recipient_constraint_preserve_old_activity_storage(): void
+    {
+        $activity = $this->activity(['reward_quota' => 1]);
+        $this->assertTrue(ActivityQuotaService::award($activity, '1001', '1.00', function () {}));
+        try {
+            DB::table('activity_reward_recipients')->insert([
+                'activity_id' => $activity->id, 'member_id' => '1001', 'created_at' => '2026-10-14 12:00:00',
+            ]);
+            $this->fail('Recipient table must reject duplicate activity/member pairs.');
+        } catch (\Illuminate\Database\QueryException $e) {
+            $this->assertStringContainsString('UNIQUE', $e->getMessage());
+        }
+        $this->assertSame(1, DB::table('activity_reward_recipients')->count());
+        $schema = DB::connection()->getSchemaBuilder();
+        $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_14_120000_add_activity_reward_quotas.php';
+        $migration->down();
+        $this->assertFalse($schema->hasTable('activity_reward_recipients'));
+        $this->assertFalse($schema->hasColumn('activity_rewards', 'reward_quota'));
+        $this->assertFalse($schema->hasColumn('activity_rewards', 'rewarded_count'));
+        $this->assertFalse($schema->hasColumn('activity_rewards', 'quota_enabled'));
+        $this->assertTrue($schema->hasColumn('activity_rewards', 'budget'));
+        $this->assertTrue($schema->hasColumn('activity_rewards', 'budget_used'));
+        $this->assertSame(1, DB::table('activity_rewards')->count());
+        $migration->up();
+        $this->assertTrue($schema->hasTable('activity_reward_recipients'));
+        $this->assertNull(DB::table('activity_rewards')->value('reward_quota'));
+        $this->assertSame(0, (int)DB::table('activity_rewards')->value('rewarded_count'));
+        $this->assertFalse((bool)DB::table('activity_rewards')->value('quota_enabled'));
+    }
+}

+ 161 - 0
tests/Integration/SettleLedgerTest.php

@@ -0,0 +1,161 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\api\Settle;
+use App\Models\BalanceLog;
+use App\Models\Bet;
+use App\Models\PcIssue;
+use Illuminate\Cache\ArrayStore;
+use Illuminate\Cache\Repository as CacheRepository;
+use Illuminate\Config\Repository as ConfigRepository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\NullLogger;
+
+/** Uses real settlement code with an in-memory database and cache, without application providers. */
+class SettleLedgerTest extends TestCase
+{
+    private Application $app;
+
+    protected function setUp(): void
+    {
+        Facade::clearResolvedInstances();
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new ConfigRepository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']]));
+        $this->app->instance('translator', new Translator(new ArrayLoader(), 'zh'));
+        Facade::setFacadeApplication($this->app);
+
+        $db = new Manager($this->app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setAsGlobal();
+        $db->bootEloquent();
+        $this->app->instance('db', $db->getDatabaseManager());
+        $this->app->instance('cache', new CacheRepository(new ArrayStore()));
+        $this->app->instance('log', new NullLogger());
+        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
+            public function json($data, $status = 200, $headers = [], $options = 0): JsonResponse
+            {
+                return new JsonResponse($data, $status, $headers, $options);
+            }
+        });
+
+        foreach (['pc_issues', 'issues'] as $table) {
+            $db->schema()->create($table, function (Blueprint $t) {
+                $t->id();
+                $t->string('issue_no');
+                $t->string('winning_numbers');
+                $t->integer('status');
+                $t->dateTime('end_time');
+                $t->timestamps();
+            });
+        }
+        $db->schema()->create('gameplay_rules', function (Blueprint $t) {
+            $t->id();
+            $t->string('keywords');
+            $t->decimal('odds', 10, 2);
+            $t->timestamps();
+        });
+        $db->schema()->create('bets', function (Blueprint $t) {
+            $t->id();
+            $t->string('issue_no');
+            $t->string('keywords');
+            $t->decimal('amount', 20, 2);
+            $t->decimal('odds', 10, 2);
+            $t->integer('status');
+            $t->decimal('profit', 20, 2);
+            $t->integer('issue_id');
+            $t->string('member_id');
+            $t->string('user_id');
+            $t->timestamps();
+        });
+        $db->schema()->create('wallets', function (Blueprint $t) {
+            $t->id();
+            $t->string('member_id')->unique();
+            $t->decimal('available_balance', 20, 2);
+            $t->timestamps();
+        });
+        $db->schema()->create('balance_logs', function (Blueprint $t) {
+            $t->id();
+            $t->string('member_id');
+            $t->integer('type')->default(1);
+            $t->decimal('amount', 20, 2);
+            $t->decimal('before_balance', 20, 2);
+            $t->decimal('after_balance', 20, 2);
+            $t->string('change_type');
+            $t->integer('related_id');
+            $t->string('remark');
+            $t->timestamps();
+        });
+    }
+
+    protected function tearDown(): void
+    {
+        DB::disconnect();
+        Facade::clearResolvedInstances();
+        parent::tearDown();
+    }
+
+    public static function settlementGames(): array
+    {
+        return [
+            '极速28' => ['pc28', 'pc_issues', '极速28投注'],
+            '加拿大28' => ['ca28', 'issues', '加拿大28投注'],
+        ];
+    }
+
+    /** @dataProvider settlementGames */
+    public function test_settlement_ledger_matches_wallet_and_accumulated_consumption(string $game, string $issueTable, string $betType): void
+    {
+        DB::table($issueTable)->insert([
+            'issue_no' => '100001', 'winning_numbers' => '6,6,6',
+            'status' => PcIssue::STATUS_DRAW, 'end_time' => '2026-10-13 12:00:00',
+        ]);
+        DB::table('gameplay_rules')->insert(['keywords' => '大', 'odds' => '2.00']);
+        DB::table('wallets')->insert(['member_id' => '1001', 'available_balance' => '1000.50']);
+        // An existing real debit must accumulate with the injected debit rather than cancel it.
+        DB::table('balance_logs')->insert([
+            'member_id' => '1001', 'amount' => '-100.25',
+            'before_balance' => '1100.75', 'after_balance' => '1000.50',
+            'change_type' => $betType, 'related_id' => 999, 'remark' => 'existing bet',
+        ]);
+        $this->assertSame('100.25', BalanceLog::getTotalConsume('1001'));
+
+        $request = Request::create('/api/pcissue/verify', 'POST', [
+            'issue_no' => '100001', 'member_id' => '1001', 'keywords' => '大',
+            'amount' => '100.25', 'fake_time' => '2026-10-13 11:58:00', 'game' => $game,
+        ]);
+        $this->app->instance('request', $request);
+        $result = (new Settle())->inject($request)->getData(true);
+
+        $this->assertSame(200, $result['code'], $result['msg'] ?? '');
+        $this->assertSame('200.50', BalanceLog::getTotalConsume('1001'));
+        $walletBalance = DB::table('wallets')->where('member_id', '1001')->value('available_balance');
+        $this->assertSame('1100.75', bcadd((string)$walletBalance, '0', 2));
+        $this->assertSame('1100.75', bcadd((string)$result['balance_after'], '0', 2));
+
+        $logs = DB::table('balance_logs')->where('related_id', $result['order_id'])->orderBy('id')->get();
+        $this->assertCount(2, $logs);
+        $this->assertSame($betType, $logs[0]->change_type);
+        $this->assertSame('-100.25', bcadd((string)$logs[0]->amount, '0', 2));
+        $this->assertSame('900.25', bcadd((string)$logs[0]->after_balance, '0', 2));
+        $this->assertSame('中奖', $logs[1]->change_type);
+        $this->assertSame('200.50', bcadd((string)$logs[1]->amount, '0', 2));
+        $this->assertSame('1100.75', bcadd((string)$logs[1]->after_balance, '0', 2));
+        $this->assertSame(0, bccomp((string)$logs[0]->after_balance, (string)$logs[1]->before_balance, 2));
+        foreach ($logs as $log) {
+            $this->assertSame(0, bccomp((string)$log->amount, bcsub((string)$log->after_balance, (string)$log->before_balance, 2), 2));
+        }
+        $ledgerChange = bcadd((string)$logs[0]->amount, (string)$logs[1]->amount, 2);
+        $this->assertSame($ledgerChange, bcsub((string)$walletBalance, '1000.50', 2));
+        $this->assertSame(Bet::STATUS_SETTLED, (int)DB::table('bets')->where('id', $result['order_id'])->value('status'));
+    }
+}

+ 291 - 0
tests/Regression/activity_site_eligibility.php

@@ -0,0 +1,291 @@
+<?php
+namespace app\api\controller {
+    class BaseApiController {
+        protected int $userId = 0;
+        protected string $lang = 'zh';
+        public $request;
+        public function __construct($userId = 0) { $this->userId = $userId; $this->request = \request(); }
+        protected function success($message = '', $data = []) { return ['code' => 1, 'data' => $data, 'msg' => $message]; }
+        protected function fail($message = '') { return ['code' => 0, 'msg' => $message]; }
+    }
+}
+namespace app\api\lists {
+    class BaseApiDataLists {
+        protected int $userId = 0;
+        protected string $lang = 'zh';
+        protected array $params = [];
+        protected array $searchWhere = [];
+        public int $limitOffset = 0;
+        public int $limitLength = 1;
+        public function __construct($userId = 0, $type = null) { $this->userId = $userId; $this->params = ['type' => $type]; }
+        public function setParams($params): self { $this->params = $params; return $this; }
+    }
+}
+namespace {
+    function request() { return $GLOBALS['activity_test_request']; }
+    function env($key, $default = '') { return $default; }
+    function ensureUrl($url, $host = '') { return $url; }
+    function json($value) { return $value; }
+    class TestRequest {
+        public array $query = [];
+        public array $postData = [];
+        public array $headers = [];
+        public function get($key, $default = null) { return $this->query[$key] ?? $default; }
+        public function header($key, $default = null) { return $this->headers[strtolower($key)] ?? $default; }
+        public function post($key, $default = null, $filter = null) { $v = $this->postData[$key] ?? $default; return $filter ? $filter($v) : $v; }
+    }
+    $GLOBALS['activity_test_request'] = new TestRequest();
+    $repo = getenv('ACTIVITY_SITE_REPO') ?: dirname(__DIR__, 3) . '/melbet_sport-api';
+    $stage = getenv('ACTIVITY_SITE_STAGE') ?: $repo;
+    $loader = require $repo . '/vendor/autoload.php';
+    $loader->setPsr4('app\\', [$stage . '/app/', $repo . '/app/']);
+    $db = new \think\DbManager();
+    $db->setConfig(['default' => 'sqlite', 'connections' => ['sqlite' => ['type' => 'sqlite', 'database' => ':memory:', 'prefix' => '', 'fields_strict' => false]], 'auto_timestamp' => false]);
+    \think\Container::getInstance()->instance(\think\DbManager::class, $db);
+    $tables = [
+        'bot_users' => 'id INTEGER PRIMARY KEY,user_id TEXT,member_id TEXT,created_at TEXT,delete_time INTEGER,recharge_channel_group_id INTEGER',
+        'bot_user_login' => 'id INTEGER PRIMARY KEY,user_id TEXT,status INTEGER,platform TEXT',
+        'bot_balance_logs' => 'id INTEGER PRIMARY KEY,member_id TEXT,type INTEGER,change_type TEXT,amount TEXT,created_at TEXT,updated_at TEXT,remark TEXT,before_balance TEXT,after_balance TEXT,related_id INTEGER,frozen_status INTEGER',
+        'bot_recharge_channel_group' => 'id INTEGER PRIMARY KEY,activity_type TEXT',
+        'bot_activity_rewards' => 'id INTEGER PRIMARY KEY,title TEXT,sub_title TEXT,start_time INTEGER,end_time INTEGER,detail_image TEXT,type INTEGER,lang TEXT,pc_image TEXT,detail_banner TEXT,pc_banner TEXT,publish_platforms TEXT,budget TEXT,budget_used TEXT DEFAULT "0",quota_enabled INTEGER DEFAULT 0,reward_quota INTEGER,rewarded_count INTEGER DEFAULT 0,sort INTEGER DEFAULT 0,register_time_limited INTEGER,register_start_time TEXT,register_end_time TEXT,register_platforms TEXT,deposit_limited INTEGER,deposit_min TEXT,deposit_max TEXT,show_ineligible INTEGER DEFAULT 1,status INTEGER,params TEXT,created_at TEXT,updated_at TEXT',
+        'bot_activity_reward_recipients' => 'id INTEGER PRIMARY KEY,activity_id INTEGER NOT NULL,member_id TEXT NOT NULL,created_at TEXT,UNIQUE(activity_id,member_id)',
+        'bot_wallets' => 'id INTEGER PRIMARY KEY,member_id TEXT,available_balance TEXT,frozen_balance TEXT,yuebao TEXT,yuebao_invest TEXT',
+        'bot_yuebao_item' => 'id INTEGER PRIMARY KEY,user_id TEXT,activity_id INTEGER,principal TEXT,surplus_principal TEXT,status INTEGER DEFAULT 1,daily_interest_rate TEXT,expire_time INTEGER,create_time INTEGER DEFAULT 0,update_time INTEGER',
+        'bot_yuebao_log' => 'id INTEGER PRIMARY KEY,user_id TEXT,item_id INTEGER,amount TEXT,type INTEGER,interest TEXT,hold_hours TEXT,create_time INTEGER,update_time INTEGER',
+    ];
+    foreach ($tables as $table => $columns) { $db->execute('CREATE TABLE ' . $table . '(' . $columns . ')'); }
+    $assertions = 0;
+    function check($condition, $message) { global $assertions; ++$assertions; if (!$condition) { throw new \RuntimeException($message); } }
+    function row($table, $data) { \think\facade\Db::table($table)->insert($data); }
+    function activity($id, $override = []) {
+        row('bot_activity_rewards', array_merge(['id' => $id, 'title' => 'Activity ' . $id, 'status' => 1, 'type' => 5, 'lang' => 'zh', 'sort' => 0, 'start_time' => time() - 100, 'end_time' => time() + 10000, 'show_ineligible' => 1, 'budget_used' => '0.00', 'detail_image' => '', 'pc_image' => '', 'detail_banner' => '', 'pc_banner' => ''], $override));
+    }
+    row('bot_users', ['id' => 1, 'user_id' => '1001', 'member_id' => '2001', 'created_at' => '2026-10-01 12:00:00', 'recharge_channel_group_id' => 1]);
+    row('bot_user_login', ['id' => 1, 'user_id' => '2001', 'status' => 0, 'platform' => 'PC']);
+    row('bot_user_login', ['id' => 2, 'user_id' => '2001', 'status' => 1, 'platform' => 'iOS']);
+    row('bot_user_login', ['id' => 3, 'user_id' => '2001', 'status' => 1, 'platform' => 'PC']);
+    row('bot_recharge_channel_group', ['id' => 1, 'activity_type' => 'recharge,yuebao']);
+    row('bot_balance_logs', ['member_id' => '2001', 'type' => 1, 'change_type' => '充值', 'amount' => '99.99']);
+    row('bot_balance_logs', ['member_id' => '2001', 'type' => 1, 'change_type' => '三方充值', 'amount' => '100.00']);
+    row('bot_balance_logs', ['member_id' => '2001', 'type' => 2, 'change_type' => '充值', 'amount' => '999.00']);
+    row('bot_balance_logs', ['member_id' => '2001', 'type' => 1, 'change_type' => '即充即送', 'amount' => '999.00']);
+    $service = new \app\common\service\MemberActivityService(1001, 'PC');
+    check($service->memberId() === '2001', 'User ID must map to member ID');
+    $context = $service->context();
+    check($context['registered_at'] === '2026-10-01 12:00:00', 'Raw member registration time');
+    check($context['register_platform'] === 'APP', 'First successful login must determine registration platform');
+    check(bccomp($context['deposit_total'], '199.99', 2) === 0, 'Lifetime deposit excludes bonuses and frozen balance');
+    $queryCount = $db->getQueryTimes();
+    $service->context();
+    check($db->getQueryTimes() === $queryCount, 'Member context is cached');
+    activity(1, ['sort' => 4, 'deposit_limited' => 1, 'deposit_min' => '199.99', 'deposit_max' => '199.99', 'register_platforms' => '["APP"]']);
+    activity(2, ['sort' => 9, 'publish_platforms' => '["H5"]']);
+    activity(3, ['sort' => 8, 'show_ineligible' => 0, 'deposit_limited' => 1, 'deposit_min' => '300', 'deposit_max' => '400']);
+    activity(4, ['sort' => 7, 'show_ineligible' => 1, 'register_platforms' => '["PC"]']);
+    activity(5, ['sort' => 10, 'status' => 0]);
+    activity(6, ['type' => 2, 'sort' => 100, 'publish_platforms' => '["H5"]', 'show_ineligible' => 0, 'register_platforms' => '["PC"]']);
+    request()->query = ['platform' => 'PC'];
+    $lists = new \app\api\lists\ActivityRewardsLists(1001);
+    check($lists->count() === 3, 'Visible count is before pagination');
+    $queryCount = $db->getQueryTimes();
+    $page = $lists->lists();
+    check(count($page) === 1 && $page[0]['id'] === 4 && $page[0]['is_eligible'] === false, 'Visible ineligible activity sorted first');
+    check($db->getQueryTimes() === $queryCount, 'List and count share same visible rows');
+    $lists->limitOffset = 1;
+    check($lists->lists()[0]['id'] === 1, 'Filtered-out rows do not create pagination gaps');
+    $lists->limitOffset = 2;
+    check($lists->lists()[0]['id'] === 6, 'Football ignores new fields and has effective sort zero');
+    $controller = new \app\api\controller\IndexController(1001);
+    foreach ([2, 3, 5] as $id) { request()->query['id'] = $id; check($controller->activityInfo()['code'] === 0, 'Hidden ID detail must be denied: ' . $id); }
+    request()->query['id'] = 4;
+    check($controller->activityInfo()['data']['is_eligible'] === false, 'Visible ineligible detail exposes eligibility flag');
+    activity(7, ['type' => 4]);
+    request()->query['id'] = 7;
+    check($controller->activityInfo()['code'] === 0, 'Detail enforces recharge group without type query');
+    check((new \app\api\lists\ActivityRewardsLists(1001))->count() === 3, 'Omitted type list applies same disabled old-user group as detail');
+    check((new \app\api\lists\ActivityRewardsLists(1001, 4))->count() === 0, 'Explicit old-user type cannot bypass disabled group');
+    request()->query = [];
+    check((new \app\common\service\MemberActivityService(1001))->visibleActivity(['type' => 5, 'publish_platforms' => '["PC"]']) === null, 'Limited publication fails closed without explicit platform');
+    activity(8, ['type' => 6]);
+    \think\facade\Db::table('bot_recharge_channel_group')->where('id', 1)->update(['activity_type' => 'recharge']);
+    check((new \app\api\lists\ActivityRewardsLists(1001, 6))->count() === 0, 'Explicit Yuebao type cannot bypass disabled group');
+    request()->query = ['id' => 8];
+    check($controller->activityInfo()['code'] === 0, 'Yuebao detail matches group-disabled list');
+    \think\facade\Db::table('bot_recharge_channel_group')->where('id', 1)->update(['activity_type' => 'recharge,yuebao']);
+    \think\facade\Db::table('bot_activity_rewards')->where('id', 8)->delete();
+    $guest = new \app\common\service\MemberActivityService(0, 'PC');
+    check($guest->visibleActivity(['type' => 5, 'show_ineligible' => 0]) === null, 'Guests cannot see hidden ineligible activities');
+    check($guest->visibleActivity(['type' => 2, 'show_ineligible' => 0])['is_eligible'], 'Football ignores guest eligibility');
+    activity(10, ['quota_enabled' => 1, 'type' => 6, 'sort' => 3, 'reward_quota' => 1, 'params' => '{"daily_interest_rate":"1.00","number_of_consecutive_days_of_deposit":7}']);
+    activity(11, ['quota_enabled' => 1, 'type' => 6, 'sort' => 4, 'register_platforms' => '["PC"]']);
+    check(\app\common\model\ActivityRewards::getYuebaoActivity('2001')['id'] === 10, 'Yuebao skips higher-sorted ineligible activity');
+    check(\think\facade\Db::table('bot_activity_reward_recipients')->count() === 0, 'Eligibility and config reads do not occupy a winner slot');
+    \think\facade\Db::startTrans();
+    check(\app\common\service\ActivityQuotaService::consume(10, '2001', '100.00'), 'First positive reward occupies one distinct-user slot');
+    $recipientCurrentReads = 0;
+    $db->event('before_find', function ($query) use (&$recipientCurrentReads) {
+        if ($query->getTable() === 'bot_activity_reward_recipients' && $query->getOptions('lock')) {
+            ++$recipientCurrentReads;
+        }
+    });
+    $fullQuota = \think\facade\Db::table('bot_activity_rewards')->where('id', 10)->lock(true)->find();
+    check(\app\common\service\ActivityQuotaService::canAward($fullQuota, '2001', true), 'Current-read availability recognizes recorded winner at full quota');
+    check($recipientCurrentReads === 1, 'Locked availability reads recipient with row-lock query');
+    $beforeRepeatReads = $recipientCurrentReads;
+    check(\app\common\service\ActivityQuotaService::consume(10, '2001', '999.00'), 'Same winner can receive another reward when the quota is full');
+    check($recipientCurrentReads === $beforeRepeatReads + 1, 'Repeated consume uses exactly one current recipient read after activity lock');
+    check(!\app\common\service\ActivityQuotaService::consume(10, '2002', '0.01'), 'A different member cannot win after the user quota is full');
+    check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 10)->value('rewarded_count') === 1, 'Repeated same-member rewards count once independent of monetary amount');
+    \think\facade\Db::rollback();
+    check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 10)->value('rewarded_count') === 0 && \think\facade\Db::table('bot_activity_reward_recipients')->count() === 0, 'Recipient and awarded-user counter roll back together');
+    activity(12, ['quota_enabled' => 1, 'reward_quota' => 0]);
+    \think\facade\Db::startTrans();
+    check(\app\common\service\ActivityQuotaService::consume(12, '2001', '0.00'), 'Zero reward does not need or occupy a winner slot');
+    check(!\app\common\service\ActivityQuotaService::consume(12, '2001', '1.00'), 'Zero quota blocks first positive reward');
+    check(!\app\common\service\ActivityQuotaService::consume(12, '2001', '-1.00'), 'Negative reward does not occupy a winner slot');
+    \think\facade\Db::commit();
+    check(\think\facade\Db::table('bot_activity_reward_recipients')->where('activity_id', 12)->count() === 0, 'No recipient records for zero or denied rewards');
+    activity(13, ['quota_enabled' => 1, 'reward_quota' => 10]);
+    \think\facade\Db::startTrans();
+    for ($i = 1; $i <= 10; ++$i) {
+        check(\app\common\service\ActivityQuotaService::consume(13, 'winner-' . $i, (string)$i . '.00'), 'Distinct winner slot ' . $i . ' is allowed');
+    }
+    check(!\app\common\service\ActivityQuotaService::consume(13, 'winner-11', '1.00'), 'The eleventh distinct member cannot receive a quota-ten reward');
+    check(\app\common\service\ActivityQuotaService::consume(13, 'winner-1', '100000.00'), 'Existing winner remains allowed at ten distinct recipients');
+    \think\facade\Db::commit();
+    check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 13)->value('rewarded_count') === 10 && \think\facade\Db::table('bot_activity_reward_recipients')->where('activity_id', 13)->count() === 10, 'Quota ten counts exactly ten unique winners');
+    activity(14, ['quota_enabled' => 1, 'reward_quota' => null, 'budget' => '0.00', 'budget_used' => '999999.00']);
+    \think\facade\Db::startTrans();
+    check(\app\common\service\ActivityQuotaService::consume(14, '2001', '99999.00'), 'Unlimited user quota ignores historical monetary budget');
+    \think\facade\Db::commit();
+    $public = $service->visibleActivity(\think\facade\Db::table('bot_activity_rewards')->where('id', 14)->find());
+    check(array_key_exists('reward_quota', $public) && array_key_exists('rewarded_count', $public) && (int)$public['quota_enabled'] === 1, 'New activity response exposes quota mode and distinct-winner metadata');
+    row('bot_wallets', ['id' => 1, 'member_id' => '2001', 'available_balance' => '50.00', 'frozen_balance' => '0.00', 'yuebao' => '100.00', 'yuebao_invest' => '0.00']);
+    row('bot_yuebao_item', ['id' => 1, 'user_id' => '1001', 'activity_id' => 10, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() - 1, 'create_time' => time() - 86400]);
+    $command = new \app\api\command\YuebaoInvest();
+    $invest = new \ReflectionMethod($command, 'invest');
+    $invest->invoke($command);
+    $wallet = \think\facade\Db::table('bot_wallets')->where('id', 1)->find();
+    check(bccomp($wallet['available_balance'], '150.00', 2) === 0, 'Automatic expiry returns principal');
+    check(bccomp($wallet['frozen_balance'], '1.00', 2) === 0, 'Automatic expiry awards interest once');
+    check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 10)->value('rewarded_count') === 1, 'Automatic expiry records first distinct winner');
+    $invest->invoke($command);
+    check(\think\facade\Db::table('bot_yuebao_log')->count() === 1, 'Repeated expiry does not duplicate settlement');
+    row('bot_users', ['id' => 2, 'user_id' => '1002', 'member_id' => '2002', 'created_at' => '2026-10-01 12:00:00', 'recharge_channel_group_id' => 1]);
+    row('bot_wallets', ['id' => 2, 'member_id' => '2002', 'available_balance' => '50.00', 'frozen_balance' => '0.00', 'yuebao' => '100.00', 'yuebao_invest' => '0.00']);
+    row('bot_yuebao_item', ['id' => 2, 'user_id' => '1002', 'activity_id' => 10, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() - 1, 'create_time' => time() - 86400]);
+    $invest->invoke($command);
+    $wallet = \think\facade\Db::table('bot_wallets')->where('id', 2)->find();
+    check(bccomp($wallet['available_balance'], '150.00', 2) === 0 && bccomp($wallet['frozen_balance'], '0.00', 2) === 0, 'Full winner quota returns other member principal without interest');
+    \think\facade\Db::table('bot_wallets')->where('id', 1)->update(['yuebao' => '100.00']);
+    row('bot_yuebao_item', ['id' => 3, 'user_id' => '1001', 'activity_id' => 10, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() - 1, 'create_time' => time() - 86400]);
+    $invest->invoke($command);
+    $wallet = \think\facade\Db::table('bot_wallets')->where('id', 1)->find();
+    check(bccomp($wallet['available_balance'], '250.00', 2) === 0 && bccomp($wallet['frozen_balance'], '2.00', 2) === 0, 'Existing winner continues to receive saved-rate interest at full quota');
+    check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 10)->value('rewarded_count') === 1, 'Second position of same member does not add another winner');
+    $offset = 12 - (int)gmdate('G');
+    date_default_timezone_set('Etc/GMT' . ($offset >= 0 ? '-' : '+') . abs($offset));
+    activity(20, ['quota_enabled' => 1, 'type' => 6, 'reward_quota' => 1, 'params' => '{"daily_interest_rate":"1.00","number_of_consecutive_days_of_deposit":7}']);
+    \think\facade\Db::table('bot_wallets')->where('id', 1)->update(['yuebao' => '100.00']);
+    row('bot_yuebao_item', ['id' => 4, 'user_id' => '1001', 'activity_id' => 20, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() + 100, 'create_time' => time() - 86400]);
+    request()->postData = ['amount' => '100.00'];
+    $yuebao = new \app\api\controller\YuebaoController(1001);
+    $out = $yuebao->out();
+    $wallet = \think\facade\Db::table('bot_wallets')->where('id', 1)->find();
+    check($out['code'] === 1 && bccomp((string)$out['data']['interest'], '1.00', 2) === 0, 'Manual out awards interest and occupies first-user slot');
+    check(bccomp($wallet['available_balance'], '350.00', 2) === 0 && bccomp($wallet['frozen_balance'], '3.00', 2) === 0, 'Manual out credits principal available and interest frozen once');
+    check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 20)->value('rewarded_count') === 1, 'Manual payout counts one distinct member');
+    check($yuebao->out()['code'] === 0, 'Repeated manual out cannot settle the same position twice');
+    \think\facade\Db::table('bot_wallets')->where('id', 2)->update(['yuebao' => '100.00']);
+    row('bot_yuebao_item', ['id' => 5, 'user_id' => '1002', 'activity_id' => 20, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() + 100, 'create_time' => time() - 86400]);
+    check(bccomp((string)(new \app\api\controller\YuebaoController(1002))->out()['data']['interest'], '0', 2) === 0, 'Manual out denies other-member interest at full quota');
+    $wallet = \think\facade\Db::table('bot_wallets')->where('id', 2)->find();
+    check(bccomp($wallet['available_balance'], '250.00', 2) === 0 && bccomp($wallet['frozen_balance'], '0.00', 2) === 0, 'Manual quota denial preserves principal');
+    \think\facade\Db::table('bot_wallets')->where('id', 1)->update(['yuebao' => '100.00']);
+    row('bot_yuebao_item', ['id' => 6, 'user_id' => '1001', 'activity_id' => null, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() + 100, 'create_time' => time() - 86400]);
+    check(bccomp((string)$yuebao->out()['data']['interest'], '1.00', 2) === 0, 'Legacy unlinked position keeps saved rate without invented activity association');
+    check(\app\common\model\ActivityRewards::getYuebaoActivity('2001')['id'] === 10, 'Existing winner remains eligible for new positions when quota is full');
+    row('bot_users', ['id' => 3, 'user_id' => '1003', 'member_id' => '2003', 'created_at' => '2026-10-01 12:00:00', 'recharge_channel_group_id' => 1]);
+    row('bot_user_login', ['id' => 4, 'user_id' => '2003', 'status' => 1, 'platform' => 'iOS']);
+    row('bot_wallets', ['id' => 3, 'member_id' => '2003', 'available_balance' => '500.00', 'frozen_balance' => '0.00', 'yuebao' => '0.00', 'yuebao_invest' => '0.00']);
+    row('bot_balance_logs', ['member_id' => '2003', 'type' => 1, 'change_type' => '极速28投注', 'amount' => '-10000.00', 'created_at' => date('Y-m-d H:i:s')]);
+    $newMember = new \app\api\controller\YuebaoController(1003);
+    $beforeItems = \think\facade\Db::table('bot_yuebao_item')->count();
+    check($newMember->add()['code'] === 0, 'Different member cannot open linked position when all eligible configs have full quotas');
+    check(bccomp((string)\think\facade\Db::table('bot_wallets')->where('id', 3)->value('available_balance'), '500.00', 2) === 0 && \think\facade\Db::table('bot_yuebao_item')->count() === $beforeItems, 'Denied new position leaves wallet and positions unchanged');
+    activity(21, ['quota_enabled' => 1, 'type' => 6, 'sort' => 8, 'reward_quota' => 3, 'params' => '{"daily_interest_rate":"1.00","number_of_consecutive_days_of_deposit":7}']);
+    check($newMember->add()['code'] === 1, 'Qualified new position succeeds with user slots available');
+    check((int)\think\facade\Db::table('bot_yuebao_item')->order('id', 'desc')->value('activity_id') === 21, 'New position links selected activity for payout');
+    check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 21)->value('rewarded_count') === 0 && \think\facade\Db::table('bot_activity_reward_recipients')->where('activity_id', 21)->count() === 0, 'Principal transfer and position creation do not consume a winner slot');
+    activity(22, ['quota_enabled' => 1, 'type' => 6, 'sort' => 9, 'reward_quota' => 3, 'params' => '{"daily_interest_rate":"1.00"}']);
+    \think\facade\Db::startTrans();
+    $locked = \app\common\service\ActivityQuotaService::lockActivities([22, 21, 22, null, 0, 999]);
+    check(array_keys($locked) === [21, 22], 'Quota locks use unique ascending IDs and exclude absent rows');
+    \think\facade\Db::commit();
+    foreach ([[4, '3001', [[50,22],[51,21]]], [5, '3002', [[60,21],[61,22]]]] as [$userPk, $userId, $items]) {
+        row('bot_users', ['id' => $userPk, 'user_id' => $userId, 'member_id' => $userId, 'created_at' => '2026-10-01 12:00:00', 'recharge_channel_group_id' => 1]);
+        row('bot_wallets', ['id' => $userPk, 'member_id' => $userId, 'available_balance' => '0.00', 'frozen_balance' => '0.00', 'yuebao' => '200.00', 'yuebao_invest' => '0.00']);
+        foreach ($items as [$itemId, $activityId]) {
+            row('bot_yuebao_item', ['id' => $itemId, 'user_id' => $userId, 'activity_id' => $activityId, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() + 100, 'create_time' => time() - 86400]);
+        }
+        request()->postData = ['amount' => '200.00'];
+        $multi = (new \app\api\controller\YuebaoController((int)$userId))->out();
+        check($multi['code'] === 1 && bccomp((string)$multi['data']['interest'], '2.00', 2) === 0, 'Two linked quota payouts succeed independently of FIFO activity order for ' . $userId);
+    }
+    foreach ([21, 22] as $id) {
+        check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', $id)->value('rewarded_count') === 2, 'Each activity tracks two distinct winning members: ' . $id);
+    }
+    activity(23, ['quota_enabled' => 1, 'type' => 6, 'sort' => 100, 'reward_quota' => 1, 'params' => '{"daily_interest_rate":"999.00"}']);
+    $invalidateCandidates = true;
+    $db->event('before_find', function ($query) use (&$invalidateCandidates) {
+        if ($invalidateCandidates && $query->getTable() === 'bot_activity_rewards' && $query->getOptions('lock')) {
+            $invalidateCandidates = false;
+            \think\facade\Db::table('bot_activity_rewards')->where('id', 23)->delete();
+            \think\facade\Db::table('bot_activity_rewards')->where('id', 22)->update(['status' => 0]);
+        }
+    });
+    \think\facade\Db::startTrans();
+    $fresh = \app\common\model\ActivityRewards::getYuebaoActivity('2001');
+    \think\facade\Db::commit();
+    check($fresh['id'] === 21 && (string)$fresh['daily_interest_rate'] === '1.00', 'New position config excludes deleted or disabled snapshots after quota locks');
+    activity(30, ['quota_enabled' => 1, 'type' => 6, 'reward_quota' => 1]);
+    row('bot_users', ['id' => 6, 'user_id' => '3003', 'member_id' => '3003', 'created_at' => '2026-10-01 12:00:00', 'recharge_channel_group_id' => 1]);
+    row('bot_wallets', ['id' => 6, 'member_id' => '3003', 'available_balance' => '0.00', 'frozen_balance' => '0.00', 'yuebao' => '100.00', 'yuebao_invest' => '0.00']);
+    row('bot_yuebao_item', ['id' => 70, 'user_id' => '3003', 'activity_id' => 30, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() + 100, 'create_time' => time() - 86400]);
+    $db->execute("CREATE TRIGGER fail_yuebao_log BEFORE INSERT ON bot_yuebao_log WHEN NEW.item_id = 70 BEGIN SELECT RAISE(ABORT, 'simulated payout write failure'); END");
+    request()->postData = ['amount' => '100.00'];
+    $failureController = new \app\api\controller\YuebaoController(3003);
+    check($failureController->out()['code'] === 0, 'Actual settlement write failure rejects payout');
+    check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 30)->value('rewarded_count') === 0 && \think\facade\Db::table('bot_activity_reward_recipients')->where('activity_id', 30)->count() === 0, 'Failed payout rolls back recipient and distinct-winner slot');
+    $failedWallet = \think\facade\Db::table('bot_wallets')->where('id', 6)->find();
+    check(bccomp($failedWallet['yuebao'], '100.00', 2) === 0 && bccomp($failedWallet['available_balance'], '0.00', 2) === 0 && bccomp($failedWallet['frozen_balance'], '0.00', 2) === 0, 'Failed payout leaves original principal and balances intact');
+    check((int)\think\facade\Db::table('bot_yuebao_item')->where('id', 70)->value('status') === 1 && bccomp((string)\think\facade\Db::table('bot_yuebao_item')->where('id', 70)->value('surplus_principal'), '100.00', 2) === 0, 'Failed payout keeps the position unsettled');
+    $db->execute('DROP TRIGGER fail_yuebao_log');
+    check($failureController->out()['code'] === 1 && (int)\think\facade\Db::table('bot_activity_rewards')->where('id', 30)->value('rewarded_count') === 1, 'Retried successful payout occupies exactly one winner slot');
+    activity(31, ['quota_enabled' => 0, 'budget' => '10.00', 'budget_used' => '0.00', 'reward_quota' => 0]);
+    \think\facade\Db::startTrans();
+    check(\app\common\service\ActivityQuotaService::consume(31, '2001', '6.00'), 'Legacy activity still accepts six under monetary budget ten');
+    check(!\app\common\service\ActivityQuotaService::consume(31, '2002', '5.00'), 'Legacy activity denies five when only four money units remain');
+    check(!\app\common\service\ActivityQuotaService::consume(31, '2001', '5.00'), 'Legacy monetary cap applies to repeat member exactly as before');
+    check(bccomp((string)\think\facade\Db::table('bot_activity_rewards')->where('id', 31)->value('budget_used'), '6.00', 2) === 0, 'Denied legacy payout preserves consumed monetary budget');
+    check(\app\common\service\ActivityQuotaService::consume(31, '2001', '4.00'), 'Legacy activity accepts exact remaining monetary budget');
+    \think\facade\Db::commit();
+    $legacyData = \think\facade\Db::table('bot_activity_rewards')->where('id', 31)->find();
+    check(!\app\common\service\ActivityQuotaService::canAward($legacyData, '2001'), 'Legacy exhausted monetary budget remains exhausted');
+    check((int)$legacyData['rewarded_count'] === 0 && \think\facade\Db::table('bot_activity_reward_recipients')->where('activity_id', 31)->count() === 0, 'Legacy payouts never write distinct-user recipients or new count');
+    $legacyPublic = $service->visibleActivity($legacyData);
+    check(array_key_exists('budget', $legacyPublic) && array_key_exists('budget_used', $legacyPublic) && bccomp((string)$legacyPublic['budget'], '10.00', 2) === 0 && bccomp((string)$legacyPublic['budget_used'], '10.00', 2) === 0 && (int)$legacyPublic['quota_enabled'] === 0, 'Legacy activity response preserves original monetary fields and marks legacy mode');
+    activity(32, ['quota_enabled' => 0, 'budget' => null, 'reward_quota' => 0]);
+    \think\facade\Db::startTrans();
+    check(\app\common\service\ActivityQuotaService::consume(32, '2002', '1000.00'), 'Legacy unlimited monetary budget ignores new zero-user quota');
+    \think\facade\Db::commit();
+    check(bccomp((string)\think\facade\Db::table('bot_activity_rewards')->where('id', 32)->value('budget_used'), '1000.00', 2) === 0 && \think\facade\Db::table('bot_activity_reward_recipients')->where('activity_id', 32)->count() === 0, 'Legacy unlimited payouts retain money usage audit and do not count quota winners');
+    activity(33, ['type' => 2, 'quota_enabled' => 1, 'reward_quota' => 0, 'budget' => '0.00']);
+    $footballQuota = \think\facade\Db::table('bot_activity_rewards')->where('id', 33)->find();
+    check(\app\common\service\ActivityQuotaService::canAward($footballQuota, null), 'Football bypasses both modes even with new immutable marker enabled');
+    \think\facade\Db::startTrans();
+    check(\app\common\service\ActivityQuotaService::consume(33, '2001', '1000.00'), 'Football bypasses zero quota and zero monetary budget');
+    \think\facade\Db::commit();
+    check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 33)->value('rewarded_count') === 0 && \think\facade\Db::table('bot_activity_reward_recipients')->where('activity_id', 33)->count() === 0, 'Football never registers or consumes quota winners');
+    echo 'PASS ' . $assertions . " adapter assertions (actual ThinkORM + isolated in-memory SQLite; no live database)\n";
+}