Просмотр исходного кода

统一提现打码量、人工冻结解冻与提现冻结

- 充值/人工充值按平台提现打码倍数(withdraw_wager_multiple)累加打码量
- 活动赠送直接进入可用余额,配置了打码量的才累加,不再走冻结金额
- 所有打码量完成才能提现,提现申请后重置当前/剩余打码量
- 会员列表、人工稽查新增冻结/解冻(金额+打码量)与上下分打码量参数
- 提现资金冻结到冻结余额,拒绝/失败退回,成功扣除
- 冻结记录列表、wagering:sync 定时刷新、wagering:migrate-frozen-rewards 一次性迁移

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
doge 4 часов назад
Родитель
Сommit
b9ac844e5e
36 измененных файлов с 2050 добавлено и 292 удалено
  1. 188 0
      app/Console/Commands/MigrateFrozenRewards.php
  2. 79 0
      app/Console/Commands/SyncWagering.php
  3. 1 0
      app/Console/Kernel.php
  4. 10 2
      app/Http/Controllers/admin/ManualAudit.php
  5. 3 0
      app/Http/Controllers/admin/PaymentOrder.php
  6. 27 6
      app/Http/Controllers/admin/Wallet.php
  7. 70 0
      app/Http/Controllers/admin/WalletFreeze.php
  8. 19 39
      app/Http/Controllers/api/Wallet.php
  9. 1 0
      app/Models/ManualAudit.php
  10. 1 1
      app/Models/PaymentOrder.php
  11. 12 0
      app/Models/WagerRecord.php
  12. 3 1
      app/Models/Wallet.php
  13. 41 0
      app/Models/WalletFreezeRecord.php
  14. 1 1
      app/Models/Withdraw.php
  15. 2 0
      app/Services/ActivityUserService.php
  16. 39 39
      app/Services/BalanceLogService.php
  17. 81 16
      app/Services/ManualAuditService.php
  18. 43 93
      app/Services/PaymentOrderService.php
  19. 8 8
      app/Services/PromotionService.php
  20. 10 28
      app/Services/QianBaoWithdrawService.php
  21. 2 0
      app/Services/RewardGrantService.php
  22. 2 0
      app/Services/UserService.php
  23. 215 0
      app/Services/WageringService.php
  24. 173 0
      app/Services/WalletFreezeService.php
  25. 157 0
      app/Services/WithdrawFundsService.php
  26. 46 41
      app/Services/WithdrawService.php
  27. 158 0
      database/migrations/2026_11_04_140000_create_wagering_and_wallet_freezes.php
  28. 62 0
      database/migrations/2026_11_04_141000_add_wallet_freeze_admin_menus.php
  29. 1 0
      docs/前端交付/bot-28/后台/README.md
  30. 106 0
      docs/前端交付/bot-28/后台/打码量与冻结金额-后台接口.md
  31. 59 0
      docs/前端接口/提现打码量与冻结记录-前台接入.md
  32. 58 0
      docs/后端/提现打码量与冻结金额-部署说明.md
  33. 7 0
      routes/admin.php
  34. 13 11
      tests/Integration/ActivityRewardEligibilityTest.php
  35. 9 6
      tests/Integration/PromotionActivitiesTest.php
  36. 343 0
      tests/Integration/WageringTest.php

+ 188 - 0
app/Console/Commands/MigrateFrozenRewards.php

@@ -0,0 +1,188 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\BalanceLog;
+use App\Models\Wallet;
+use App\Models\WagerRecord;
+use App\Services\PromotionFacts;
+use App\Services\WageringService;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 上线一次性迁移:活动奖励不再走冻结金额。
+ * - 仍冻结的 即充即送 / 老用户回归(balance_logs type=2, frozen_status=1)和 locked 的新活动奖励:冻结余额转回可用余额,
+ *   未完成的流水计入统一打码量。
+ * - 进行中的 activity_user 活动:剩余打码量计入统一打码量(资金早已在可用余额)。
+ * 以 wager_records(source=migration, source_id=来源) 保证重复执行不会重复处理。
+ */
+class MigrateFrozenRewards extends Command
+{
+    private const LEGACY_TYPES = ['即充即送', '老用户回归'];
+
+    protected $signature = 'wagering:migrate-frozen-rewards {--dry-run : 只统计不写入} {--member= : 只处理指定会员ID}';
+    protected $description = '把冻结中的活动奖励转入可用余额,并把未完成流水计入提现打码量';
+
+    /** @var array<int,true> 已匹配过的 wallet_bonus,避免同一笔充值的两条同额奖励共用一条流水要求 */
+    private array $usedBonusIds = [];
+
+    private array $stats = ['wallets' => 0, 'legacy_logs' => 0, 'promotion_rewards' => 0, 'activity_users' => 0,
+        'amount' => '0', 'wager' => '0', 'skipped' => 0];
+
+    public function handle(): int
+    {
+        if (!WageringService::enabled()) {
+            $this->error('请先执行数据库迁移(wager_records / wallets.wager_required)');
+            return self::FAILURE;
+        }
+        $dryRun = (bool)$this->option('dry-run');
+        foreach ($this->members() as $memberId) {
+            try {
+                DB::transaction(fn () => $this->migrateMember((string)$memberId, $dryRun), 3);
+            } catch (\Throwable $e) {
+                ++$this->stats['skipped'];
+                $this->warn("会员 {$memberId} 迁移失败:" . $e->getMessage());
+                if ($dryRun) continue;
+            }
+        }
+        $this->table(array_keys($this->stats), [array_map(fn ($v) => is_string($v) ? bcadd($v, '0', 2) : $v, $this->stats)]);
+        $this->info($dryRun ? 'dry-run 完成,未写入任何数据' : '迁移完成');
+        return $this->stats['skipped'] ? self::FAILURE : self::SUCCESS;
+    }
+
+    /** @return string[] */
+    private function members(): array
+    {
+        if ($this->option('member')) return [(string)$this->option('member')];
+        $ids = BalanceLog::where('type', 2)->where('frozen_status', 1)->whereIn('change_type', self::LEGACY_TYPES)->distinct()->pluck('member_id')->all();
+        if (Schema::hasTable('promotion_rewards')) {
+            $ids = array_merge($ids, DB::table('promotion_rewards as r')->join('users as u', 'u.id', '=', 'r.user_id')
+                ->where('r.status', 'locked')->distinct()->pluck('u.member_id')->all());
+        }
+        if (Schema::hasTable('activity_user')) {
+            $ids = array_merge($ids, DB::table('activity_user')->where('status', \App\Models\ActivityUser::STATUS_IN_PROGRESS)->distinct()->pluck('member_id')->all());
+        }
+        return array_values(array_unique(array_map('strval', $ids)));
+    }
+
+    private function migrateMember(string $memberId, bool $dryRun): void
+    {
+        $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
+        if (!$wallet) throw new \RuntimeException('钱包不存在');
+        $user = DB::table('users')->where('member_id', $memberId)->first(['id']);
+        $now = PromotionFacts::now();
+        $moved = '0'; $wager = '0'; $touched = false;
+
+        // 1) 旧版冻结返彩
+        foreach (BalanceLog::where('member_id', $memberId)->where('type', 2)->where('frozen_status', 1)
+                     ->whereIn('change_type', self::LEGACY_TYPES)->orderBy('id')->lockForUpdate()->get() as $log) {
+            $key = 'balance_log:' . $log->id;
+            if ($this->done($memberId, $key)) continue;
+            $amount = bcadd((string)$log->amount, '0', 10);
+            $need = $this->legacyNeed($memberId, $log, $amount);
+            $this->move($wallet, $amount, $log->id, '旧版' . $log->change_type . '转可用余额', $dryRun);
+            if (!$dryRun) {
+                $log->frozen_status = 2; $log->save();
+                $this->record($wallet, $need, $key, $log->change_type . '冻结奖励迁移');
+            }
+            $moved = bcadd($moved, $amount, 10); $wager = bcadd($wager, $need, 10); ++$this->stats['legacy_logs']; $touched = true;
+        }
+        // 旧版1倍流水在 melbet 的 wallets.flow_balance 里累计,抵扣迁移的打码量
+        if ($touched && Schema::hasColumn('wallets', 'flow_balance')) {
+            $carry = bcadd((string)($wallet->getAttribute('flow_balance') ?? '0'), '0', 10);
+            if (bccomp($carry, '0', 10) > 0) {
+                $offset = bccomp($carry, $wager, 10) > 0 ? $wager : $carry;
+                if (!$dryRun) {
+                    $this->record($wallet, bcsub('0', $offset, 10), 'flow_balance:' . $wallet->id . ':' . $now, '旧版流水余额抵扣');
+                    Wallet::where('id', $wallet->id)->update(['flow_balance' => 0]);
+                }
+                $wager = bcsub($wager, $offset, 10);
+            }
+        }
+
+        // 2) 新活动 locked 奖励
+        if ($user && Schema::hasTable('promotion_rewards')) {
+            $facts = new PromotionFacts();
+            foreach (DB::table('promotion_rewards')->where('user_id', $user->id)->where('status', 'locked')->orderBy('id')->lockForUpdate()->get() as $reward) {
+                $key = 'promotion_reward:' . $reward->id;
+                if ($this->done($memberId, $key)) continue;
+                $snapshot = json_decode((string)$reward->flow_snapshot, true) ?: [];
+                $effective = $facts->total($facts->betting([(int)$user->id], $snapshot['from'] ?? $now, null, $snapshot['cursors'] ?? [])['effective']);
+                $need = WageringService::remaining($reward->required_flow, $effective);
+                $amount = bcadd((string)$reward->amount, '0', 10);
+                $log = $this->move($wallet, $amount, $reward->id, '新活动奖励转可用余额', $dryRun);
+                if (!$dryRun) {
+                    if ($reward->credited_log_id) DB::table('balance_logs')->where('id', $reward->credited_log_id)->update(['frozen_status' => 2]);
+                    DB::table('promotion_rewards')->where('id', $reward->id)->update(['status' => 'paid', 'unlocked_log_id' => $log?->id,
+                        'unlocked_at' => $now, 'updated_at' => $now]);
+                    $this->record($wallet, $need, $key, '新活动冻结奖励迁移');
+                }
+                $moved = bcadd($moved, $amount, 10); $wager = bcadd($wager, $need, 10); ++$this->stats['promotion_rewards'];
+            }
+        }
+
+        // 3) 进行中的申请活动:资金已在可用余额,只迁移剩余打码量
+        if (Schema::hasTable('activity_user')) {
+            foreach (DB::table('activity_user')->where('member_id', $memberId)->where('status', \App\Models\ActivityUser::STATUS_IN_PROGRESS)->get() as $row) {
+                $key = 'activity_user:' . $row->id;
+                if ($this->done($memberId, $key)) continue;
+                // 上线后按新流程发放的活动,发奖时已写入打码量,不能再迁移一次
+                if (WagerRecord::where('member_id', $memberId)->where('source', 'activity:activity_user')->where('source_id', (string)$row->id)->exists()) continue;
+                $need = WageringService::remaining($row->betting_amount ?? '0', $row->effective_betting_amount ?? '0');
+                if (!$dryRun) $this->record($wallet, $need, $key, '申请活动剩余打码量迁移');
+                $wager = bcadd($wager, $need, 10); ++$this->stats['activity_users'];
+            }
+        }
+
+        ++$this->stats['wallets'];
+        $this->stats['amount'] = bcadd($this->stats['amount'], $moved, 10);
+        $this->stats['wager'] = bcadd($this->stats['wager'], $wager, 10);
+        if ($this->output->isVerbose()) $this->line("{$memberId}: 转入可用 " . bcadd($moved, '0', 2) . ',打码量 +' . bcadd($wager, '0', 2));
+    }
+
+    /** 匹配旧 wallet_bonus 的 need_flow(配置的倍数流水),没有则按旧规则1倍。 */
+    private function legacyNeed(string $memberId, BalanceLog $log, string $amount): string
+    {
+        if (!Schema::hasTable('wallet_bonus')) return $amount;
+        $bonus = DB::table('wallet_bonus')->where('member_id', $memberId)->where('related_id', $log->related_id)
+            ->where('amount', $log->amount)->where('status', 1)->whereNotIn('id', array_keys($this->usedBonusIds) ?: [0])
+            ->orderBy('id')->first();
+        if (!$bonus) return $amount;
+        $this->usedBonusIds[(int)$bonus->id] = true;
+        return $bonus->need_flow !== null ? bcadd((string)$bonus->need_flow, '0', 10) : $amount;
+    }
+
+    private function move(Wallet $wallet, string $amount, $relatedId, string $remark, bool $dryRun): ?BalanceLog
+    {
+        $beforeFrozen = bcadd((string)$wallet->frozen_balance, '0', 10);
+        if (bccomp($beforeFrozen, $amount, 10) < 0) throw new \RuntimeException('冻结余额不足:' . bcadd($beforeFrozen, '0', 2) . ' < ' . bcadd($amount, '0', 2));
+        $before = bcadd((string)$wallet->available_balance, '0', 10);
+        $wallet->frozen_balance = bcsub($beforeFrozen, $amount, 10);
+        $wallet->available_balance = bcadd($before, $amount, 10);
+        if ($dryRun) return null;
+        $wallet->save();
+        BalanceLog::create(['member_id' => $wallet->member_id, 'type' => 2, 'change_type' => '流水解冻', 'amount' => bcsub('0', $amount, 10),
+            'before_balance' => $beforeFrozen, 'after_balance' => $wallet->frozen_balance, 'related_id' => $relatedId, 'remark' => $remark]);
+        return BalanceLog::create(['member_id' => $wallet->member_id, 'type' => 1, 'change_type' => '流水解冻', 'amount' => $amount,
+            'before_balance' => $before, 'after_balance' => $wallet->available_balance, 'related_id' => $relatedId, 'remark' => $remark]);
+    }
+
+    private function done(string $memberId, string $key): bool
+    {
+        return WagerRecord::where('member_id', $memberId)->where('source', 'migration')->where('source_id', $key)->exists();
+    }
+
+    /** 无论打码量是否为0都写一条迁移记录,作为幂等标记。 */
+    private function record(Wallet $wallet, string $need, string $key, string $remark): void
+    {
+        if (bccomp($need, '0', 10) !== 0) {
+            WageringService::addRequirement($wallet, $need, 'migration', $key, $remark);
+            return;
+        }
+        WagerRecord::create(['user_id' => $wallet->user_id, 'member_id' => (string)$wallet->member_id, 'cycle_id' => (int)$wallet->wager_cycle_id,
+            'source' => 'migration', 'source_id' => $key, 'amount' => '0', 'before_required' => (string)$wallet->wager_required,
+            'after_required' => (string)$wallet->wager_required, 'completed' => (string)$wallet->wager_completed, 'remark' => $remark]);
+    }
+}

+ 79 - 0
app/Console/Commands/SyncWagering.php

@@ -0,0 +1,79 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\Wallet;
+use App\Services\PromotionFacts;
+use App\Services\WageringService;
+use App\Support\SyncCommandLock;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Schema;
+
+/** 刷新钱包缓存的“当前打码量”。只重算最近有投注变动的会员;--all 用于上线后首次全量计算。 */
+class SyncWagering extends Command
+{
+    private const SINCE_KEY = 'wagering:sync:since';
+    /** 上次刷新失败的会员,下次运行重试;时间窗口照常前进,避免一个坏钱包让扫描范围无限扩大 */
+    private const FAILED_KEY = 'wagering:sync:failed';
+
+    protected $signature = 'wagering:sync {--all : 重算全部钱包} {--member= : 只重算指定会员ID}';
+    protected $description = '按有效投注刷新会员当前打码量/剩余打码量';
+
+    public function handle(SyncCommandLock $lock): int
+    {
+        $exit = $lock->run('wagering:sync', fn () => $this->synchronize());
+        if ($exit === null) $this->info('打码量同步已在运行,跳过本次执行');
+        return $exit ?? self::SUCCESS;
+    }
+
+    private function synchronize(): int
+    {
+        if (!WageringService::enabled()) return self::SUCCESS;
+        $startedAt = PromotionFacts::now();
+        $errors = 0; $count = 0; $failed = [];
+        $refresh = function ($wallets) use (&$errors, &$count, &$failed) {
+            $facts = new PromotionFacts();
+            foreach ($wallets as $wallet) {
+                try { WageringService::refresh($wallet, $facts); ++$count; }
+                catch (\Throwable $e) {
+                    ++$errors; $failed[] = (string)$wallet->member_id;
+                    Log::error('wagering_sync_failed', ['member_id' => $wallet->member_id, 'exception' => $e]);
+                }
+            }
+        };
+
+        if ($this->option('member')) {
+            $refresh(Wallet::where('member_id', (string)$this->option('member'))->get());
+        } elseif ($this->option('all')) {
+            Wallet::query()->orderBy('id')->chunkById(200, $refresh);
+        } else {
+            // 与上次运行重叠两分钟,覆盖注单延迟结算和同步时钟误差。
+            $since = Cache::get(self::SINCE_KEY) ?? date('Y-m-d H:i:s', strtotime($startedAt) - 600);
+            $since = date('Y-m-d H:i:s', strtotime($since) - 120);
+            $members = array_values(array_unique(array_merge($this->changedMembers($since), (array)Cache::get(self::FAILED_KEY, []))));
+            foreach (array_chunk($members, 200) as $chunk) {
+                $refresh(Wallet::whereIn('member_id', $chunk)->get());
+            }
+            Cache::forever(self::SINCE_KEY, $startedAt);
+            Cache::forever(self::FAILED_KEY, array_values(array_unique($failed)));
+        }
+        $this->info('打码量同步完成,刷新:' . $count . ',失败:' . $errors);
+        return $errors ? self::FAILURE : self::SUCCESS;
+    }
+
+    /** @return string[] */
+    private function changedMembers(string $since): array
+    {
+        $members = [];
+        foreach (['share_bet_facts', 'bets', 'jisu_game_order'] as $table) {
+            if (!Schema::hasTable($table) || !Schema::hasColumn($table, 'updated_at')) continue;
+            foreach (DB::table($table)->where('updated_at', '>=', $since)->whereNotNull('member_id')->distinct()->pluck('member_id') as $memberId) {
+                $members[(string)$memberId] = true;
+            }
+        }
+        return array_keys($members);
+    }
+}

+ 1 - 0
app/Console/Kernel.php

@@ -30,6 +30,7 @@ class Kernel extends ConsoleKernel
         $schedule->command('messages:dispatch')->everyMinute()->withoutOverlapping(10);
         $schedule->command('reporting:sync')->everyMinute()->withoutOverlapping(30);
         $schedule->command('promotions:sync')->everyMinute()->withoutOverlapping(30);
+        $schedule->command('wagering:sync')->everyMinute()->withoutOverlapping(30);
         $schedule->command('third-game:sync-orders')
             ->everyMinute()
             ->withoutOverlapping(5);

+ 10 - 2
app/Http/Controllers/admin/ManualAudit.php

@@ -64,6 +64,8 @@ class ManualAudit extends Controller
                 'account' => ['nullable', 'required_without:member_id', 'string', 'max:64'],
                 'change_type' => ['required', 'string', 'in:' . implode(',', BalanceLogService::$manualRecharge)],
                 'amount' => ['required', 'numeric', 'min:0.01', 'regex:' . self::AMOUNT_PATTERN],
+                // 人工充值按平台倍数自动计算;其他类型自行填写,留空为0(可直接提现)
+                'wager_amount' => ['nullable', 'numeric', 'min:0', 'regex:' . self::AMOUNT_PATTERN],
                 'remark' => ['nullable', 'string', 'max:255'],
             ]);
             $remark = trim((string)($params['remark'] ?? ''));
@@ -73,7 +75,8 @@ class ManualAudit extends Controller
                 ManualAuditService::resolveMemberId($params['member_id'] ?? null, $params['account'] ?? null),
                 (string)$params['amount'],
                 (string)$params['change_type'],
-                $remark
+                $remark,
+                isset($params['wager_amount']) ? (string)$params['wager_amount'] : null
             );
 
             return $this->adjustmentResponse($result);
@@ -92,6 +95,9 @@ class ManualAudit extends Controller
                 'member_id' => ['nullable', 'required_without:account', 'string', 'max:64'],
                 'account' => ['nullable', 'required_without:member_id', 'string', 'max:64'],
                 'amount' => ['required', 'numeric', 'min:0.01', 'regex:' . self::AMOUNT_PATTERN],
+                // 人工充值按平台倍数扣除打码量;其他类型扣除 wager_amount;不传不调整打码量
+                'recharge_type' => ['nullable', 'string', 'in:' . implode(',', BalanceLogService::$manualRecharge)],
+                'wager_amount' => ['nullable', 'numeric', 'min:0', 'regex:' . self::AMOUNT_PATTERN],
                 'remark' => ['nullable', 'string', 'max:255'],
             ]);
             $remark = trim((string)($params['remark'] ?? ''));
@@ -100,7 +106,9 @@ class ManualAudit extends Controller
                 (int)request()->user->id,
                 ManualAuditService::resolveMemberId($params['member_id'] ?? null, $params['account'] ?? null),
                 (string)$params['amount'],
-                $remark
+                $remark,
+                isset($params['recharge_type']) ? (string)$params['recharge_type'] : null,
+                isset($params['wager_amount']) ? (string)$params['wager_amount'] : null
             );
 
             return $this->adjustmentResponse($result);

+ 3 - 0
app/Http/Controllers/admin/PaymentOrder.php

@@ -156,10 +156,13 @@ class PaymentOrder extends Controller
                         $order = PaymentOrderModel::where('id', $id)
                             ->where('type', 4)
                             ->where('status', PaymentOrderService::STATUS_STAY)
+                            ->lockForUpdate()
                             ->first();
                         if (!$order) throw new Exception("订单不存在_{$id}", HttpStatus::CUSTOM_ERROR);
                         $order->status = PaymentOrderService::STATUS_SUCCESS;
                         $order->save();
+                        // 人工提现打款完成:扣除冻结余额
+                        \App\Services\WithdrawFundsService::settle(\App\Services\WithdrawFundsService::PAYMENT_ORDERS, $order);
                     } else {
                         $ret = PaymentOrderService::createPayout($id);
                         if ($ret['code'] !== 0) throw new Exception($ret['msg'], HttpStatus::CUSTOM_ERROR);

+ 27 - 6
app/Http/Controllers/admin/Wallet.php

@@ -6,6 +6,8 @@ use App\Constants\HttpStatus;
 use App\Http\Controllers\Controller;
 use App\Models\Recharge;
 use App\Services\BalanceLogService;
+use App\Services\ManualAuditService;
+use App\Services\WageringService;
 use App\Services\MenuService;
 use App\Services\RechargeService;
 use App\Services\TopUpService;
@@ -131,9 +133,14 @@ class Wallet extends Controller
                 'amount' => ['required', 'numeric', 'min:0.01'],
                 'member_id' => ['required', 'string', 'min:1'],
                 'remark' => ['required', 'string', 'min:1'],
+                // 人工充值按平台倍数扣除打码量;其他类型扣除 wager_amount;不传不调整打码量
+                'recharge_type' => ['nullable', 'string', 'in:' . implode(',', BalanceLogService::$manualRecharge)],
+                'wager_amount' => ['nullable', 'numeric', 'min:0'],
             ]);
             $memberId = request()->input('member_id');
             $amount = request()->input('amount');
+            [$wagerDelta, $writeWager] = ManualAuditService::wagerDelta(true, (string)$amount, ManualAuditService::DEBIT_CHANGE_TYPE,
+                request()->input('recharge_type'), request()->filled('wager_amount') ? (string)request()->input('wager_amount') : null);
             $amount = $amount * -1;
             $remark = request()->input('remark');
             $key = 'api_request_' . md5($memberId . json_encode([
@@ -143,13 +150,17 @@ class Wallet extends Controller
                     ]));
             if (Cache::has($key)) throw new Exception("请求太频繁,请稍后再试。", HttpStatus::CUSTOM_ERROR);
             Cache::put($key, true, 3);
-            $wallet = WalletModel::where('member_id', $memberId)->first();
+            $wallet = WalletModel::where('member_id', $memberId)->lockForUpdate()->first();
             if (!$wallet) throw new Exception('用户不存在', HttpStatus::CUSTOM_ERROR);
-            $availableBalance = bcadd($wallet->available_balance, $amount, 10);
+            $beforeBalance = (string)$wallet->available_balance;
+            $availableBalance = bcadd($beforeBalance, $amount, 10);
             if ($availableBalance < 0) throw new Exception('可用余额不足', HttpStatus::CUSTOM_ERROR);
-            BalanceLogService::addLog($memberId, $amount, $wallet->available_balance, $availableBalance, "人工扣款", null, $remark);
             $wallet->available_balance = $availableBalance;
             $wallet->save();
+            $log = BalanceLogService::addLog($memberId, $amount, $beforeBalance, $availableBalance, "人工扣款", null, $remark);
+            if ($writeWager) {
+                WageringService::addRequirement($wallet, $wagerDelta, 'manual_debit', 'balance_log:' . $log->id, $remark, (int)request()->user->id);
+            }
 
             DB::commit();
         } catch (ValidationException $e) {
@@ -176,11 +187,15 @@ class Wallet extends Controller
                 'member_id' => ['required', 'string', 'min:1'],
                 'remark' => ['required', 'string', 'min:1'],
                 'change_type' => ['required', 'string', 'in:' . implode(',', BalanceLogService::$manualRecharge)],
+                // 人工充值按平台倍数自动计算;其他类型自行填写,留空为0
+                'wager_amount' => ['nullable', 'numeric', 'min:0'],
             ]);
             $memberId = request()->input('member_id');
             $amount = request()->input('amount');
             $remark = request()->input('remark');
             $changeType = request()->input('change_type');
+            [$wagerDelta, $writeWager] = ManualAuditService::wagerDelta(false, (string)$amount, (string)$changeType, null,
+                request()->filled('wager_amount') ? (string)request()->input('wager_amount') : null);
             $key = 'api_request_' . md5($memberId . json_encode([
                         'amount' => $amount,
                         'remark' => $remark,
@@ -188,12 +203,18 @@ class Wallet extends Controller
                     ]));
             if (Cache::has($key)) throw new Exception("请求太频繁,请稍后再试。", HttpStatus::CUSTOM_ERROR);
             Cache::put($key, true, 3);
-            $wallet = WalletModel::where('member_id', $memberId)->first();
+            $wallet = WalletModel::where('member_id', $memberId)->lockForUpdate()->first();
             if (!$wallet) throw new Exception('用户不存在', HttpStatus::CUSTOM_ERROR);
-            $availableBalance = bcadd($wallet->available_balance, $amount, 10);
-            BalanceLogService::addLog($memberId, $amount, $wallet->available_balance, $availableBalance, $changeType, null, $remark);
+            $beforeBalance = (string)$wallet->available_balance;
+            $availableBalance = bcadd($beforeBalance, $amount, 10);
+            // 先保存余额再写日志:充值钩子(首充奖励、即充即送、打码量)会重新锁定并更新钱包
             $wallet->available_balance = $availableBalance;
             $wallet->save();
+            $log = BalanceLogService::addLog($memberId, $amount, $beforeBalance, $availableBalance, $changeType, null, $remark);
+            if ($writeWager) {
+                $wallet = WalletModel::where('member_id', $memberId)->lockForUpdate()->first();
+                WageringService::addRequirement($wallet, $wagerDelta, 'manual_credit', 'balance_log:' . $log->id, $remark, (int)request()->user->id);
+            }
 
             DB::commit();
         } catch (ValidationException $e) {

+ 70 - 0
app/Http/Controllers/admin/WalletFreeze.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Models\WalletFreezeRecord;
+use App\Services\ManualAuditService;
+use App\Services\WalletFreezeService;
+use Exception;
+use Illuminate\Validation\ValidationException;
+
+/** 会员列表 / 人工稽查的冻结、解冻,以及冻结记录列表。 */
+class WalletFreeze extends Controller
+{
+    private const AMOUNT_PATTERN = '/^\d{1,20}(?:\.\d{1,10})?$/';
+
+    public function index()
+    {
+        try {
+            $params = request()->validate([
+                'page' => ['nullable', 'integer', 'min:1'],
+                'limit' => ['nullable', 'integer', 'min:1', 'max:200'],
+                'member_id' => ['nullable', 'string', 'max:64'],
+                'account' => ['nullable', 'string', 'max:64'],
+                'type' => ['nullable', 'string', 'in:' . implode(',', array_keys(WalletFreezeRecord::TYPE_LABELS))],
+                'status' => ['nullable', 'string', 'in:' . implode(',', array_keys(WalletFreezeRecord::STATUS_LABELS))],
+                'start_date' => ['nullable', 'date_format:Y-m-d', 'required_with:end_date'],
+                'end_date' => ['nullable', 'date_format:Y-m-d', 'required_with:start_date', 'after_or_equal:start_date'],
+            ]);
+            return $this->success(WalletFreezeService::list($params));
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+    }
+
+    public function freeze()
+    {
+        return $this->apply(true);
+    }
+
+    public function unfreeze()
+    {
+        return $this->apply(false);
+    }
+
+    private function apply(bool $isFreeze)
+    {
+        try {
+            $params = request()->validate([
+                'request_id' => ['required', 'uuid'],
+                'member_id' => ['nullable', 'required_without:account', 'string', 'max:64'],
+                'account' => ['nullable', 'required_without:member_id', 'string', 'max:64'],
+                'amount' => ['required', 'numeric', 'min:0.01', 'regex:' . self::AMOUNT_PATTERN],
+                'wager_amount' => ['required', 'numeric', 'min:0', 'regex:' . self::AMOUNT_PATTERN],
+                'reason' => ['required', 'string', 'min:1', 'max:255'],
+            ]);
+            $memberId = ManualAuditService::resolveMemberId($params['member_id'] ?? null, $params['account'] ?? null);
+            $method = $isFreeze ? 'freeze' : 'unfreeze';
+            return $this->success(WalletFreezeService::$method((string)$params['request_id'], (int)request()->user->id, $memberId,
+                (string)$params['amount'], (string)$params['wager_amount'], trim((string)$params['reason'])));
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+    }
+}

+ 19 - 39
app/Http/Controllers/api/Wallet.php

@@ -17,12 +17,14 @@ use App\Models\Address;
 use App\Models\RechargeChannel;
 use App\Models\PaymentOrder;
 use App\Services\BalanceLogService;
+use App\Services\WithdrawFundsService;
 use App\Services\PaymentOrderService;
 use App\Services\Payment\JdPayService;
 use App\Services\Payment\NoPayService;
 use App\Services\Payment\ZimuPayService;
 use App\Services\QianBaoWithdrawService;
 use App\Services\WithdrawService;
+use App\Services\LogService;
 use Illuminate\Support\Facades\DB;
 
 
@@ -347,24 +349,18 @@ class Wallet extends BaseController
             if ($amount > $rate_usdt_amount) {
                 throw new Exception(lang("余额不足") . "{$serviceCharge} USDT");
             }
-            $wallet = WalletModel::where('member_id', $member_id)->first();
-            $changeAmount = bcmul(($amount * -1), $rate, 2);
-            $beforeBalance = $wallet->available_balance;
-            $afterBalance = bcsub($wallet->available_balance, $rate_rmb_amount, 2);
-            $wallet->available_balance = $afterBalance;
-            $wallet->save();
-
-            $withdraw = Withdraw::create([
-                'member_id' => $member_id,
-                'amount' => $amount,
-                'service_charge' => $serviceCharge,
-                'to_account' => $real,
-                'address' => $address,
-                'exchange_rate' => $rate,
-                'status' => 0,
-                'after_balance' => $afterBalance
-            ]);
-            BalanceLogService::addLog($member_id, $changeAmount, $beforeBalance, $afterBalance, '提现', $withdraw->id, '');
+            // 校验打码量 → 可用余额转入冻结余额 → 创建提现单 → 重置打码周期
+            [$withdraw] = WithdrawFundsService::hold($member_id, $rate_rmb_amount, '提现', '', WithdrawFundsService::WITHDRAWS,
+                fn ($before, $after) => Withdraw::create([
+                    'member_id' => $member_id,
+                    'amount' => $amount,
+                    'service_charge' => $serviceCharge,
+                    'to_account' => $real,
+                    'address' => $address,
+                    'exchange_rate' => $rate,
+                    'status' => 0,
+                    'after_balance' => $after,
+                ]));
             return $this->success($withdraw,'提交成功');
             
         } catch (ValidationException $e) {
@@ -431,20 +427,7 @@ class Wallet extends BaseController
             
             if ($params['channel'] == 'rgtx') {
                 
-                DB::beginTransaction();
                 try {
-                    $wallet = WalletService::findOne(['member_id' => $member_id]);
-                    if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
-                    $balance = $wallet->available_balance;
-                    if (bccomp($balance, $amount, 2) < 0) {
-                        throw new Exception("您的钱包余额不足!", HttpStatus::CUSTOM_ERROR);
-                    }
-                    $available_balance = bcsub($balance, $amount, 10);
-
-                    // 先预扣款(锁定资金)
-                    $wallet->available_balance = $available_balance;
-                    if (!$wallet->save()) throw new Exception('钱包更新失败!', HttpStatus::CUSTOM_ERROR);
-
                     $rate = RechargeChannel::where('type', 'rgtx')->value('rate');
                     $rate = $rate ?? 1;
                     $data = [];
@@ -458,18 +441,15 @@ class Wallet extends BaseController
                     $data['account'] = $params['account'];
                     $data['card_no'] = $params['card_no'];
                     $data['status'] = PaymentOrderService::STATUS_STAY;
-                    // 创建待处理状态的提现记录
-                    $info = PaymentOrder::create($data);
-                    // 记录余额变动日志
-                    BalanceLogService::addLog($member_id, $amount * -1, $balance, $available_balance, '人工提现', $info->id, '钱宝提现费率:'.($rate  * 100) . '%');
-
-                    $balance = bcadd($available_balance, 0, 2);
-                    DB::commit();
+                    // 校验打码量 → 冻结资金 → 创建待处理的提现记录 → 重置打码周期
+                    [$info] = WithdrawFundsService::hold($member_id, $amount, '人工提现', '钱宝提现费率:'.($rate  * 100) . '%',
+                        WithdrawFundsService::PAYMENT_ORDERS, fn () => PaymentOrder::create($data));
                 } catch (Exception $e) {
-                    DB::rollBack();
                     if ($e->getCode() === HttpStatus::CUSTOM_ERROR) {
                         return $this->error($e->getMessage());
                     }
+                    LogService::error($e);
+                    return $this->error('系统繁忙,请稍后重试!');
                 }
                 return $this->success($info,'提交成功');
             } else {

+ 1 - 0
app/Models/ManualAudit.php

@@ -31,6 +31,7 @@ class ManualAudit extends BaseModel
         'status',
         'remark',
         'failure_reason',
+        'wager_amount',
     ];
 
     protected $casts = [

+ 1 - 1
app/Models/PaymentOrder.php

@@ -16,7 +16,7 @@ class PaymentOrder extends BaseModel
 {
 
     protected $table = 'payment_orders';
-    protected $fillable = ['type', 'order_no', 'member_id', 'amount', 'channel', 'admin_note','bank_name', 'account', 'card_no', 'status', 'callback_url', 'callback_data', 'remark', 'pay_no', 'pay_url', 'pay_data', 'fee', 'payment_type', 'is_send', 'is_locked', 'recharge_bonus_snapshot'];
+    protected $fillable = ['type', 'order_no', 'member_id', 'amount', 'channel', 'admin_note','bank_name', 'account', 'card_no', 'status', 'callback_url', 'callback_data', 'remark', 'pay_no', 'pay_url', 'pay_data', 'fee', 'payment_type', 'is_send', 'is_locked', 'recharge_bonus_snapshot', 'frozen_amount'];
     protected $hidden = ['recharge_bonus_snapshot'];
     protected $casts = ['recharge_bonus_snapshot' => 'array'];
 

+ 12 - 0
app/Models/WagerRecord.php

@@ -0,0 +1,12 @@
+<?php
+
+namespace App\Models;
+
+/** 打码量变动明细:每次增加/减少所需打码量或重置周期都会写一条。 */
+class WagerRecord extends BaseModel
+{
+    protected $table = 'wager_records';
+    protected $hidden = ['updated_at'];
+    protected $fillable = ['user_id', 'member_id', 'cycle_id', 'source', 'source_id', 'amount', 'before_required',
+        'after_required', 'completed', 'admin_id', 'remark'];
+}

+ 3 - 1
app/Models/Wallet.php

@@ -3,6 +3,8 @@
 namespace App\Models;
 class Wallet extends BaseModel
 {
+    protected $hidden = ['created_at', 'updated_at', 'wager_cycle_cursors'];
     protected $table = 'wallets';
-    protected $fillable = ['user_id','member_id', 'coin', 'available_balance','frozen_balance','total_balance','yuebao', 'yuebao_invest'];
+    protected $fillable = ['user_id','member_id', 'coin', 'available_balance','frozen_balance','total_balance','yuebao', 'yuebao_invest',
+        'wager_required', 'wager_completed', 'wager_cycle_id', 'wager_cycle_from', 'wager_cycle_cursors', 'wager_synced_at'];
 }

+ 41 - 0
app/Models/WalletFreezeRecord.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace App\Models;
+
+/** 冻结记录:人工冻结、人工解冻、提现冻结。 */
+class WalletFreezeRecord extends BaseModel
+{
+    public const TYPE_MANUAL_FREEZE = 'manual_freeze';
+    public const TYPE_MANUAL_UNFREEZE = 'manual_unfreeze';
+    public const TYPE_WITHDRAW_HOLD = 'withdraw_hold';
+
+    public const STATUS_FROZEN = 'frozen';
+    public const STATUS_DONE = 'done';
+    public const STATUS_HOLDING = 'holding';
+    public const STATUS_RELEASED = 'released';
+    public const STATUS_SETTLED = 'settled';
+
+    public const TYPE_LABELS = [
+        self::TYPE_MANUAL_FREEZE => '人工冻结',
+        self::TYPE_MANUAL_UNFREEZE => '人工解冻',
+        self::TYPE_WITHDRAW_HOLD => '提现冻结',
+    ];
+
+    public const STATUS_LABELS = [
+        self::STATUS_FROZEN => '已冻结',
+        self::STATUS_DONE => '已解冻',
+        self::STATUS_HOLDING => '提现审核中',
+        self::STATUS_RELEASED => '已退回余额',
+        self::STATUS_SETTLED => '提现成功已扣除',
+    ];
+
+    protected $table = 'wallet_freeze_records';
+    protected $hidden = ['updated_at'];
+    protected $fillable = ['user_id', 'member_id', 'type', 'amount', 'wager_amount', 'reason', 'status', 'related_type',
+        'related_id', 'request_id', 'admin_id', 'before_available', 'after_available', 'before_frozen', 'after_frozen', 'finished_at'];
+
+    public function member()
+    {
+        return $this->belongsTo(User::class, 'member_id', 'member_id')->select(['id', 'member_id', 'username', 'account', 'first_name']);
+    }
+}

+ 1 - 1
app/Models/Withdraw.php

@@ -8,7 +8,7 @@ class Withdraw extends BaseModel
 {
 
     protected $table = 'withdraws';
-    protected $fillable = ['member_id', 'amount', 'service_charge', 'to_account', 'address', 'exchange_rate', 'status', 'after_balance', 'remark', 'admin_note', 'is_locked'];
+    protected $fillable = ['member_id', 'amount', 'service_charge', 'to_account', 'address', 'exchange_rate', 'status', 'after_balance', 'remark', 'admin_note', 'is_locked', 'frozen_amount'];
     protected $hidden = [];
 
     public function member(): BelongsTo

+ 2 - 0
app/Services/ActivityUserService.php

@@ -128,6 +128,8 @@ class ActivityUserService extends BaseService
                 $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");
+                // 活动打码量计入统一提现打码量
+                WageringService::addRequirement($wallet, (string)$bettingAmount, 'activity:activity_user', $activityUser->id, (string)$activityUser->title);
             });
             if (!$granted) throw new Exception($activity->quota_enabled
                 ? '活动获奖份数已用完,已停止新用户发奖' : '活动预算不足,已停止发奖', HttpStatus::CUSTOM_ERROR);

+ 39 - 39
app/Services/BalanceLogService.php

@@ -41,7 +41,8 @@ class BalanceLogService extends BaseService
         'SG飞艇投注','SG飞艇退款','SG飞艇中奖',
         'SG快3投注','SG快3退款','SG快3中奖',
         //'足球游戏投注','足球游戏退款','足球游戏中奖',
-        '资产转移', '比比返', '返水', '回水', '笔笔返', '投注退分','充值返现','即充即送','老用户回归','余额宝转入','转出至余额宝','余额宝利息','流水解冻','分享佣金领取'
+        '资产转移', '比比返', '返水', '回水', '笔笔返', '投注退分','充值返现','即充即送','老用户回归','余额宝转入','转出至余额宝','余额宝利息','流水解冻','分享佣金领取',
+        '人工冻结', '人工解冻', '人工提现'
     ];
 
     public static function init($telegram, $data, $chatId, $firstName, $messageId, $callbackId): void
@@ -247,55 +248,52 @@ class BalanceLogService extends BaseService
                 $activity_type = RechargeChannelGroup::getActivityType($user->recharge_channel_group_id);
                 if ($activity_type) {
                     // USDT confirmation has no outer transaction. Keep every reward write
-                    // and its recipient slot pending until the frozen balance is saved.
+                    // and its recipient slot pending until the wallet 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('账户钱包不存在');
+                        $credit = function (string $changeType, $bonusAmount, string $needFlow) use ($walletInfo, $memberId, $related_id, $remark_amount) {
+                            $before = (string)$walletInfo->available_balance;
+                            $after = bcadd($before, (string)$bonusAmount, 10);
+                            $log = static::$MODEL::create([
+                                'type' => 1,
+                                'member_id' => $memberId,
+                                'amount' => $bonusAmount,
+                                'before_balance' => $before,
+                                'after_balance' => $after,
+                                'change_type' => $changeType,
+                                'related_id' => $related_id,
+                                'remark' => '充值金额为:'.$remark_amount,
+                            ]);
+                            $walletInfo->available_balance = $after;
+                            WageringService::addRequirement($walletInfo, $needFlow, 'activity:' . $changeType, $log->id, '充值金额为:'.$remark_amount);
+                        };
                         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);
-                            }
+                            $needFlow = '0';
+                            $bonusAmount = self::calculateRechargeBonus($amount,$memberId, $related_id, $needFlow);
+                            if ($bonusAmount > 0) $credit('即充即送', $bonusAmount, $needFlow);
                         }
 
                         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);
-                            }
+                            $needFlow = '0';
+                            $bonusAmount = self::calculateUserReturnRechargeBonus($amount,$memberId, $level, $related_id, $needFlow);
+                            if ($bonusAmount > 0) $credit('老用户回归', $bonusAmount, $needFlow);
                         }
-                    
+
                         $walletInfo->save();
                     });
                 }
             }
         }
         
+        //充值/人工充值按平台配置的提现打码倍数增加打码量
+        if (in_array($change_type, self::$levelRechargeTypes, true) && bccomp((string)$amount, '0', 10) > 0) {
+            WageringService::addRequirementFor((string)$memberId, WageringService::platformRequirement($amount), 'recharge', $result->id, $change_type);
+        }
+
         if (in_array($change_type, self::$levelRechargeTypes, true)) (new PromotionService())->onDeposit($result->refresh());
         return $result;
     }
@@ -449,7 +447,7 @@ class BalanceLogService extends BaseService
      * 计算充值的返现金额
      * @param float $amount 充值金额(人民币)
      */
-    public static function calculateRechargeBonus($amount,$memberId, $related_id = null)
+    public static function calculateRechargeBonus($amount,$memberId, $related_id = null, &$needFlow = null)
     {
         $activity = ActivityRewardService::activeForMember(3, $memberId);
         if (!$activity) {
@@ -482,14 +480,15 @@ class BalanceLogService extends BaseService
         $calculatedBonus = bcmul($amount, $rate, 2); // 保留两位小数
         $bonusAmount = min($calculatedBonus, $maxBonus);
 
-        //倍率流水
-        $need_flow = !empty($params['reward_turnover_ratio']) ? bcmul($bonusAmount, $params['reward_turnover_ratio'], 2) : $bonusAmount;
+        //倍率流水:未配置打码倍数时无需打码,可直接提现
+        $need_flow = !empty($params['reward_turnover_ratio']) ? bcmul($bonusAmount, $params['reward_turnover_ratio'], 2) : '0';
         
         //返彩流水
         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;
 
+        $needFlow = $need_flow;
         return $bonusAmount;
     }
 
@@ -497,7 +496,7 @@ class BalanceLogService extends BaseService
      * 计算老用户回归当日的充值返现金额
      * @param float $amount 充值金额(人民币)
      */
-    public static function calculateUserReturnRechargeBonus($amount,$memberId, $level, $related_id = null)
+    public static function calculateUserReturnRechargeBonus($amount,$memberId, $level, $related_id = null, &$needFlow = null)
     {
         $activity = ActivityRewardService::activeForMember(4, $memberId);
         if (!$activity) {
@@ -525,7 +524,7 @@ class BalanceLogService extends BaseService
                 if ($level >= $rule['start_level'] && $level <= $rule['end_level']) {
                     $bonusAmount = $rule['bonus'];
                     //倍率流水
-                    $need_flow = !empty($rule['turnover_rate']) ? bcmul($bonusAmount, $rule['turnover_rate'], 2) : $bonusAmount;
+                    $need_flow = !empty($rule['turnover_rate']) ? bcmul($bonusAmount, $rule['turnover_rate'], 2) : '0';
                     break;
                 }
             }
@@ -539,6 +538,7 @@ class BalanceLogService extends BaseService
             WalletBonus::addData($memberId, 3, $bonusAmount, $activity->id, $related_id, $need_flow);
         })) return false;
 
+        $needFlow = $need_flow;
         return $bonusAmount;
     }
 

+ 81 - 16
app/Services/ManualAuditService.php

@@ -18,6 +18,8 @@ use Illuminate\Support\Facades\Log;
 class ManualAuditService
 {
     public const DEBIT_CHANGE_TYPE = '人工扣款';
+    /** 上下分选择该充值类型时,按平台配置的提现打码倍数计算打码量 */
+    public const PLATFORM_WAGER_TYPE = '人工充值';
     public const STATUS_FAILED = ManualAuditRecord::STATUS_FAILED;
     public const STATUS_SUCCESS = ManualAuditRecord::STATUS_SUCCESS;
 
@@ -34,10 +36,15 @@ class ManualAuditService
             ->get();
 
         $accounts = self::accounts($rows->pluck('member_id')->all());
+        // 会员当前打码量(所需/当前/剩余),便于上下分、冻结时参考
+        $wallets = WageringService::enabled()
+            ? Wallet::whereIn('member_id', $rows->pluck('member_id')->unique()->values()->all() ?: [''])->get()->keyBy('member_id')
+            : collect();
 
         return [
             'total' => $total,
-            'data' => $rows->map(static fn(ManualAuditRecord $row): array => self::format($row, $accounts))->all(),
+            'data' => $rows->map(static fn(ManualAuditRecord $row): array => self::format($row, $accounts)
+                + ['member_wager' => WageringService::summary($wallets->get((string)$row->member_id))])->all(),
             'options' => [
                 'transaction_types' => [
                     ['label' => '人工上分', 'value' => 'credit'],
@@ -51,6 +58,9 @@ class ManualAuditService
                     ['label' => '失败', 'value' => self::STATUS_FAILED],
                     ['label' => '成功', 'value' => self::STATUS_SUCCESS],
                 ],
+                // 人工充值按平台提现打码倍数自动增减打码量,其他类型需填写 wager_amount
+                'platform_wager_type' => self::PLATFORM_WAGER_TYPE,
+                'withdraw_wager_multiple' => WageringService::multiple(),
             ],
         ];
     }
@@ -91,23 +101,32 @@ class ManualAuditService
             ->map(static fn($account): string => (string)($account ?? ''))->all();
     }
 
+    /**
+     * @param string|null $wagerAmount 非“人工充值”类型时填写的打码量(为空视为0,可直接提现)
+     */
     public static function credit(
         string $requestId,
         int $adminId,
         string $memberId,
         string $amount,
         string $changeType,
-        string $remark
+        string $remark,
+        ?string $wagerAmount = null
     ): array {
-        return self::adjust($requestId, $adminId, $memberId, $amount, $changeType, $remark, false);
+        return self::adjust($requestId, $adminId, $memberId, $amount, $changeType, $remark, false, null, $wagerAmount);
     }
 
+    /**
+     * @param string|null $rechargeType 下分对应的充值类型:人工充值按平台倍数扣除打码量,其他类型扣除 $wagerAmount;为空不调整打码量
+     */
     public static function debit(
         string $requestId,
         int $adminId,
         string $memberId,
         string $amount,
-        string $remark
+        string $remark,
+        ?string $rechargeType = null,
+        ?string $wagerAmount = null
     ): array {
         return self::adjust(
             $requestId,
@@ -116,10 +135,28 @@ class ManualAuditService
             $amount,
             self::DEBIT_CHANGE_TYPE,
             $remark,
-            true
+            true,
+            $rechargeType,
+            $wagerAmount
         );
     }
 
+    /**
+     * 本次上下分对打码量的有符号调整。上分“人工充值”由充值钩子自动按平台倍数增加,这里只返回展示值。
+     * @return array{0:string,1:bool} [有符号打码量, 是否需要在此处手动写入]
+     */
+    public static function wagerDelta(bool $isDebit, string $amount, string $changeType, ?string $rechargeType, ?string $wagerAmount): array
+    {
+        $custom = bcadd((string)($wagerAmount ?? '0'), '0', 10);
+        if (!$isDebit) {
+            if ($changeType === self::PLATFORM_WAGER_TYPE) return [WageringService::platformRequirement($amount), false];
+            return [$custom, true];
+        }
+        if ($rechargeType === null || $rechargeType === '') return ['0', false];
+        $value = $rechargeType === self::PLATFORM_WAGER_TYPE ? WageringService::platformRequirement($amount) : $custom;
+        return [bcsub('0', $value, 10), true];
+    }
+
     private static function adjust(
         string $requestId,
         int $adminId,
@@ -127,20 +164,26 @@ class ManualAuditService
         string $amount,
         string $changeType,
         string $remark,
-        bool $isDebit
+        bool $isDebit,
+        ?string $rechargeType = null,
+        ?string $wagerAmount = null
     ): array {
         $signedAmount = bcadd($amount, '0', 10);
         if ($isDebit) {
             $signedAmount = bcmul($signedAmount, '-1', 10);
         }
         $transactionType = $isDebit ? 'debit' : 'credit';
+        [$wagerDelta, $writeWager] = self::wagerDelta($isDebit, $amount, $changeType, $rechargeType, $wagerAmount);
         $requestHash = self::requestHash(
             $adminId,
             $memberId,
             $signedAmount,
             $transactionType,
             $changeType,
-            $remark
+            $remark,
+            // 只摘要请求原始参数,平台倍数调整后同一 request_id 重试仍能识别为同一请求
+            $rechargeType === null && $wagerAmount === null ? null
+                : ['recharge_type' => $rechargeType, 'wager_amount' => $wagerAmount === null ? null : bcadd($wagerAmount, '0', 10)]
         );
 
         $existing = ManualAuditRecord::where('request_id', $requestId)->first();
@@ -159,9 +202,11 @@ class ManualAuditService
                 $signedAmount,
                 $transactionType,
                 $changeType,
-                $remark
+                $remark,
+                $wagerDelta,
+                $writeWager
             ): ManualAuditRecord {
-                $audit = ManualAuditRecord::create([
+                $audit = ManualAuditRecord::create(self::withWager([
                     'request_id' => $requestId,
                     'request_hash' => $requestHash,
                     'admin_id' => $adminId,
@@ -178,7 +223,7 @@ class ManualAuditService
                     'status' => self::STATUS_FAILED,
                     'remark' => $remark,
                     'failure_reason' => '',
-                ]);
+                ], $wagerDelta));
 
                 // Keep the same lock order as payment callbacks: wallet first, user second.
                 $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
@@ -208,6 +253,11 @@ class ManualAuditService
                     return self::fail($audit, '可用余额不足');
                 }
 
+                // 先保存余额再写日志:充值钩子(首充奖励、即充即送、打码量)会在同一事务内重新锁定并更新钱包。
+                $wallet->available_balance = $afterBalance;
+                if (!$wallet->save()) {
+                    throw new Exception('钱包更新失败', HttpStatus::CUSTOM_ERROR);
+                }
                 $log = BalanceLogService::addLog(
                     $memberId,
                     $signedAmount,
@@ -217,9 +267,10 @@ class ManualAuditService
                     null,
                     $remark
                 );
-                $wallet->available_balance = $afterBalance;
-                if (!$wallet->save()) {
-                    throw new Exception('钱包更新失败', HttpStatus::CUSTOM_ERROR);
+                if ($writeWager) {
+                    $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
+                    WageringService::addRequirement($wallet, $wagerDelta, bccomp($signedAmount, '0', 10) < 0 ? 'manual_debit' : 'manual_credit',
+                        $audit->id, $changeType . ($remark !== '' ? ';' . $remark : ''), $adminId);
                 }
 
                 $audit->balance_log_id = $log->id;
@@ -246,6 +297,15 @@ class ManualAuditService
         return self::format($audit, self::accounts([$memberId]));
     }
 
+    private static ?bool $hasWagerColumn = null;
+
+    private static function withWager(array $data, string $wagerDelta): array
+    {
+        self::$hasWagerColumn ??= DB::connection()->getSchemaBuilder()->hasColumn('manual_audits', 'wager_amount');
+        if (self::$hasWagerColumn) $data['wager_amount'] = $wagerDelta;
+        return $data;
+    }
+
     private static function fail(ManualAuditRecord $audit, string $reason): ManualAuditRecord
     {
         $audit->status = self::STATUS_FAILED;
@@ -317,6 +377,7 @@ class ManualAuditService
             'status' => $status,
             'status_text' => $status === self::STATUS_SUCCESS ? '成功' : '失败',
             'remark' => (string)($row->remark ?? ''),
+            'wager_amount' => self::nullableMoney($row->getAttribute('wager_amount')) ?? '0.00',
             'failure_reason' => (string)($row->failure_reason ?? ''),
             'created_at' => (string)$row->created_at,
         ];
@@ -346,16 +407,20 @@ class ManualAuditService
         string $signedAmount,
         string $transactionType,
         string $changeType,
-        string $remark
+        string $remark,
+        ?array $wager = null
     ): string {
-        $payload = json_encode([
+        $data = [
             'admin_id' => $adminId,
             'member_id' => $memberId,
             'amount' => $signedAmount,
             'transaction_type' => $transactionType,
             'change_type' => $changeType,
             'remark' => $remark,
-        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
+        ];
+        // 未传打码量参数的请求保持旧摘要,避免升级前后重放同一 request_id 被判为冲突
+        if ($wager !== null) $data['wager'] = $wager;
+        $payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
 
         return hash('sha256', $payload);
     }

+ 43 - 93
app/Services/PaymentOrderService.php

@@ -733,27 +733,22 @@ class PaymentOrderService extends BaseService
     public static function withdrawalFailed($orderId, $remark): array
     {
         try {
-            $order = PaymentOrder::where('id', $orderId)
-                ->whereIn('type', [2,4])
-                ->where('status', self::STATUS_STAY)
-                ->first();
-            if (!$order) throw new Exception("订单不存在_{$orderId}", HttpStatus::CUSTOM_ERROR);
-            // 更新提现记录状态为失败
-            $order->status = self::STATUS_FAIL;
-            $order->remark = $remark;
-            if (!$order->save()) {
-                throw new Exception("更新失败,请重试", HttpStatus::CUSTOM_ERROR);
-            }
-            //钱包余额增加
-            $wallet = WalletService::findOne(['member_id' => $order->member_id]);
-            $beforeBalance = $wallet->available_balance;
-            $availableBalance = bcadd($wallet->available_balance, $order->amount, 10);
-            $wallet->available_balance = $availableBalance;
-            if (!$wallet->save()) {
-                throw new Exception("更新失败,请重试", HttpStatus::CUSTOM_ERROR);
-            }
-            // 记录退款日志
-            BalanceLogService::addLog($order->member_id, $order->amount, $beforeBalance, $availableBalance, '三方提现', $order->id, '提现失败退款');
+            DB::transaction(function () use ($orderId, $remark) {
+                $order = PaymentOrder::where('id', $orderId)
+                    ->whereIn('type', [2,4])
+                    ->where('status', self::STATUS_STAY)
+                    ->lockForUpdate()
+                    ->first();
+                if (!$order) throw new Exception("订单不存在_{$orderId}", HttpStatus::CUSTOM_ERROR);
+                // 更新提现记录状态为失败
+                $order->status = self::STATUS_FAIL;
+                $order->remark = $remark;
+                if (!$order->save()) {
+                    throw new Exception("更新失败,请重试", HttpStatus::CUSTOM_ERROR);
+                }
+                // 冻结余额退回可用余额,并记录退款日志
+                WithdrawFundsService::release(WithdrawFundsService::PAYMENT_ORDERS, $order, $order->amount, '三方提现', '提现失败退款');
+            }, 3);
 
         } catch (Exception $e) {
             return ['code' => HttpStatus::CUSTOM_ERROR, 'msg' => $e->getMessage()];
@@ -919,22 +914,6 @@ class PaymentOrderService extends BaseService
         DB::beginTransaction();
 
         try {
-            $wallet = WalletService::findOne(['member_id' => $memberId]);
-            if (!$wallet) {
-                DB::rollBack();
-                $result['text'] = '钱包不存在!';
-                return $result;
-            }
-
-            $balance = $wallet->available_balance;
-            if (bccomp($balance, $amount, 2) < 0) {
-                DB::rollBack();
-                $result['text'] = '您的钱包余额不足!';
-                return $result;
-            }
-
-            $available_balance = bcsub($balance, $amount, 10);
-
             $data = [];
             $data['type'] = self::TYPE_PAYOUT;
             $order_no = self::createOrderNo('sj' . $data['type'] . '_', $memberId);
@@ -959,20 +938,11 @@ class PaymentOrderService extends BaseService
             $data['status'] = self::STATUS_STAY;
             $data['remark'] = '提现费率:' . ($channelRate * 100) . '%+2';
 
-            // 先预扣款(锁定资金)
-            $wallet->available_balance = $available_balance;
-            if (!$wallet->save()) {
-                DB::rollBack();
-                $result['text'] = '钱包更新失败!';
-                return $result;
-            }
-
-            // 创建待处理状态的提现记录
-            $info = static::$MODEL::create($data);
+            // 校验打码量 → 冻结资金 → 创建待处理的提现记录 → 重置打码周期
+            [$info, $wallet] = WithdrawFundsService::hold((string)$memberId, $default_amount, '三方提现', '钱宝提现费率:' . ($channelRate * 100) . '%+2',
+                WithdrawFundsService::PAYMENT_ORDERS, fn () => static::$MODEL::create($data));
             $id = $info->id;
-
-            // 记录余额变动日志
-            BalanceLogService::addLog($memberId, $default_amount * -1, $balance, $available_balance, '三方提现', $id, '钱宝提现费率:' . ($channelRate * 100) . '%+2');
+            $available_balance = $wallet->available_balance;
 
             // 提交事务,确保预扣款成功
             DB::commit();
@@ -981,7 +951,7 @@ class PaymentOrderService extends BaseService
         catch (Exception $e) {
             // 预扣款失败,回滚事务
             DB::rollBack();
-            $result['text'] = '系统繁忙,请稍后重试!';
+            $result['text'] = $e->getCode() === HttpStatus::CUSTOM_ERROR ? $e->getMessage() : '系统繁忙,请稍后重试!';
             Log::error('提现预扣款失败: ' . $e->getMessage(), [
                 'member_id' => $memberId,
                 'amount' => $amount
@@ -1138,17 +1108,13 @@ class PaymentOrderService extends BaseService
             // 三方支付失败,需要回滚之前的预扣款
             DB::beginTransaction();
             try {
-                // 恢复钱包余额
-                $wallet->available_balance = $balance;
-                $wallet->save();
-
                 // 更新提现记录状态为失败
                 $info->status = self::STATUS_FAIL;
                 $info->remark = $failureMessage;
                 $info->save();
 
-                // 记录退款日志
-                BalanceLogService::addLog($memberId, $default_amount, $available_balance, $balance, '三方提现', $id, '提现失败退款');
+                // 冻结余额退回可用余额,并记录退款日志
+                WithdrawFundsService::release(WithdrawFundsService::PAYMENT_ORDERS, $info, $default_amount, '三方提现', '提现失败退款');
 
                 DB::commit();
                 $result['text'] = $failureMessage;
@@ -1300,10 +1266,16 @@ class PaymentOrderService extends BaseService
         $data['callback_data'] = json_encode($params, JSON_UNESCAPED_UNICODE);
         DB::beginTransaction();
         try {
+            $locked = static::$MODEL::where('id', $info->id)->lockForUpdate()->first();
+            if (!$locked || $locked->status != self::STATUS_PROCESS) {
+                DB::rollBack();
+                return;
+            }
             if ((string)$params['status'] === JdPayService::REMIT_STATUS_SUCCESS) {
                 $data['status'] = self::STATUS_SUCCESS;
                 $res = static::$MODEL::where(['id' => $info->id])->update($data);
                 AdminRealtime::record('payment_orders', $info->id);
+                WithdrawFundsService::settle(WithdrawFundsService::PAYMENT_ORDERS, $locked);
                 if ($res) {
                     $text = "✅ 提现通知 \n";
                     $text .= "提现平台:{$info->bank_name} \n";
@@ -1318,13 +1290,7 @@ class PaymentOrderService extends BaseService
                 $res = static::$MODEL::where(['id' => $info->id])->update($data);
                 AdminRealtime::record('payment_orders', $info->id);
 
-                $wallet = WalletService::findOne(['member_id' => $info->member_id]);
-                $balance = $wallet->available_balance;
-                $available_balance = bcadd($balance, $amount, 10);
-                $wallet->available_balance = $available_balance;
-                $wallet->save();
-
-                BalanceLogService::addLog($memberId, $amount, $balance, $available_balance, '三方提现', $info->id, '提现失败退款');
+                WithdrawFundsService::release(WithdrawFundsService::PAYMENT_ORDERS, $locked, $amount, '三方提现', '提现失败退款');
                 if ($res) {
                     $text = "❌ 提现通知 \n";
                     $text .= "提现平台:{$info->bank_name} \n";
@@ -1357,21 +1323,13 @@ class PaymentOrderService extends BaseService
                 if ((string)$params['state'] === NoPayService::STATE_SUCCESS) {
                     $order->status = self::STATUS_SUCCESS;
                     $order->save();
+                    WithdrawFundsService::settle(WithdrawFundsService::PAYMENT_ORDERS, $order);
                     $notification = "✅ 提现通知 \n提现平台:{$order->bank_name} \n收款人:{$order->account} \nNO钱包账号:{$order->card_no} \n提现金额:{$order->amount} \n提现成功,金额已到账,请注意查收!";
                 } elseif ((string)$params['state'] === NoPayService::STATE_FAIL) {
                     $order->status = self::STATUS_FAIL;
                     $order->save();
 
-                    $wallet = WalletModel::where('member_id', $order->member_id)->lockForUpdate()->first();
-                    if (!$wallet) {
-                        throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
-                    }
-                    $balance = $wallet->available_balance;
-                    $availableBalance = bcadd($balance, NoPayService::amount($order->amount), 10);
-                    $wallet->available_balance = $availableBalance;
-                    $wallet->save();
-
-                    BalanceLogService::addLog($order->member_id, $order->amount, $balance, $availableBalance, '三方提现', $order->id, '提现失败退款');
+                    WithdrawFundsService::release(WithdrawFundsService::PAYMENT_ORDERS, $order, NoPayService::amount($order->amount), '三方提现', '提现失败退款');
                     $notification = "❌ 提现通知 \n提现平台:{$order->bank_name} \n收款人:{$order->account} \nNO钱包账号:{$order->card_no} \n提现金额:{$order->amount} \n提现失败,金额已返回钱包,请注意查收!";
                 } else {
                     $order->save();
@@ -1411,22 +1369,14 @@ class PaymentOrderService extends BaseService
                 if ((string)$params['status'] === $successStatus) {
                     $order->status = self::STATUS_SUCCESS;
                     $order->save();
+                    WithdrawFundsService::settle(WithdrawFundsService::PAYMENT_ORDERS, $order);
                     $accountLabel = ZimuPayService::isCashChannel($order->channel) ? '收款账号' : '808钱包地址';
                     $notification = "✅ 提现通知 \n提现平台:{$order->bank_name} \n收款人:{$order->account} \n{$accountLabel}:{$order->card_no} \n提现金额:{$order->amount} \n提现成功,金额已到账,请注意查收!";
                 } elseif ((string)$params['status'] === $failStatus) {
                     $order->status = self::STATUS_FAIL;
                     $order->save();
 
-                    $wallet = WalletModel::where('member_id', $order->member_id)->lockForUpdate()->first();
-                    if (!$wallet) {
-                        throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
-                    }
-                    $balance = $wallet->available_balance;
-                    $availableBalance = bcadd($balance, ZimuPayService::amount($order->amount), 10);
-                    $wallet->available_balance = $availableBalance;
-                    $wallet->save();
-
-                    BalanceLogService::addLog($order->member_id, $order->amount, $balance, $availableBalance, '三方提现', $order->id, '提现失败退款');
+                    WithdrawFundsService::release(WithdrawFundsService::PAYMENT_ORDERS, $order, ZimuPayService::amount($order->amount), '三方提现', '提现失败退款');
                     $accountLabel = ZimuPayService::isCashChannel($order->channel) ? '收款账号' : '808钱包地址';
                     $notification = "❌ 提现通知 \n提现平台:{$order->bank_name} \n收款人:{$order->account} \n{$accountLabel}:{$order->card_no} \n提现金额:{$order->amount} \n提现失败,金额已返回钱包,请注意查收!";
                 } else {
@@ -1456,10 +1406,17 @@ class PaymentOrderService extends BaseService
         $data['callback_data'] = json_encode($params, JSON_UNESCAPED_UNICODE);
         DB::beginTransaction();
         try {
+            // 只处理“处理中”的代付单,避免重复回调重复退款
+            $locked = static::$MODEL::where('id', $info->id)->lockForUpdate()->first();
+            if (!$locked || $locked->status != self::STATUS_PROCESS) {
+                DB::rollBack();
+                return;
+            }
             if ($params['state'] == 1) {
                 $data['status'] = self::STATUS_SUCCESS;
                 $res = static::$MODEL::where(['order_no' => $params['orderNo']])->update($data);
                 AdminRealtime::record('payment_orders', $info->id);
+                WithdrawFundsService::settle(WithdrawFundsService::PAYMENT_ORDERS, $locked);
                 if ($res) {
                     $text = "✅ 提现通知 \n";
                     $text .= "提现平台:{$info->bank_name} \n";
@@ -1474,15 +1431,8 @@ class PaymentOrderService extends BaseService
                 $res = static::$MODEL::where(['order_no' => $params['orderNo']])->update($data);
                 AdminRealtime::record('payment_orders', $info->id);
 
-                $wallet = WalletService::findOne(['member_id' => $info->member_id]);
-
-                $balance = $wallet->available_balance; // 钱包当前余额
-
-                $available_balance = bcadd($balance, $params['amount'], 10);
-                $wallet->available_balance = $available_balance;
-                $wallet->save();
-                // 记录退款日志
-                BalanceLogService::addLog($memberId, $amount, $balance, $available_balance, '三方提现', $info->id, '提现失败退款');
+                // 冻结余额退回可用余额,并记录退款日志
+                WithdrawFundsService::release(WithdrawFundsService::PAYMENT_ORDERS, $locked, $amount, '三方提现', '提现失败退款');
                 if ($res) {
                     $text = "❌ 提现通知 \n";
                     $text .= "提现平台:{$info->bank_name} \n";

+ 8 - 8
app/Services/PromotionService.php

@@ -326,15 +326,15 @@ class PromotionService
     private function pay(object $reward, User $member, Wallet $wallet): object
     {
         if ($reward->status !== 'reserved') return $reward;
-        $frozen = bccomp((string)$reward->required_flow, '0', 2) > 0;
-        $field = $frozen ? 'frozen_balance' : 'available_balance';
-        $before = (string)$wallet->$field; $wallet->$field = bcadd($before, (string)$reward->amount, 10); $wallet->save();
-        $log = BalanceLog::create(['member_id' => $member->member_id, 'type' => $frozen ? 2 : 1, 'change_type' => '优惠活动',
-            'amount' => $reward->amount, 'before_balance' => $before, 'after_balance' => $wallet->$field, 'related_id' => $reward->id,
-            // Frozen promotion rewards are intentionally unlocked by this service, never by the legacy 1x list.
-            'frozen_status' => $frozen ? 1 : null, 'remark' => '新活动奖励; activity=' . $reward->activity_id . '; round=' . $reward->round_no]);
+        // 活动奖金直接进入可用余额;设置了流水倍数的奖励把 required_flow 计入会员提现打码量。
+        $before = (string)$wallet->available_balance; $wallet->available_balance = bcadd($before, (string)$reward->amount, 10); $wallet->save();
+        $log = BalanceLog::create(['member_id' => $member->member_id, 'type' => 1, 'change_type' => '优惠活动',
+            'amount' => $reward->amount, 'before_balance' => $before, 'after_balance' => $wallet->available_balance, 'related_id' => $reward->id,
+            'frozen_status' => null, 'remark' => '新活动奖励; activity=' . $reward->activity_id . '; round=' . $reward->round_no]);
+        WageringService::addRequirement($wallet, (string)$reward->required_flow, 'activity:promotion', $reward->id,
+            '新活动奖励; activity=' . $reward->activity_id . '; round=' . $reward->round_no);
         $now = PromotionFacts::now();
-        DB::table('promotion_rewards')->where('id', $reward->id)->update(['status' => $frozen ? 'locked' : 'paid', 'credited_log_id' => $log->id,
+        DB::table('promotion_rewards')->where('id', $reward->id)->update(['status' => 'paid', 'credited_log_id' => $log->id,
             'flow_snapshot' => json_encode(['from' => $now, 'cursors' => $this->facts->cursors()]), 'updated_at' => $now]);
         return DB::table('promotion_rewards')->where('id', $reward->id)->first();
     }

+ 10 - 28
app/Services/QianBaoWithdrawService.php

@@ -155,9 +155,9 @@ class QianBaoWithdrawService
             ];
         }
 
-        $activityUser = ActivityUserService::findOne(['member_id' => $chatId, 'status' => ActivityUser::STATUS_IN_PROGRESS]);
-        if ($activityUser) {
-            $text = lang("您有未完成的活动") . "\n";
+        $wagerLeft = WageringService::remainingFor((string)$chatId);
+        if ($wagerLeft !== null) {
+            $text = lang("打码量未完成,剩余打码量") . ":{$wagerLeft}\n";
             $text .= lang("任何疑问都可以联系唯一客服") . ":@{$serviceAccount}";
             return [
                 'chat_id' => $chatId,
@@ -412,9 +412,9 @@ class QianBaoWithdrawService
     private static function confirm($chatId, $messageId)
     {
         $serviceAccount = Config::where('field', 'service_customer')->first()->val;
-        $activityUser = ActivityUserService::findOne(['member_id' => $chatId, 'status' => ActivityUser::STATUS_IN_PROGRESS]);
-        if ($activityUser) {
-            $text = lang("您有未完成的活动") . "\n";
+        $wagerLeft = WageringService::remainingFor((string)$chatId);
+        if ($wagerLeft !== null) {
+            $text = lang("打码量未完成,剩余打码量") . ":{$wagerLeft}\n";
             $text .= lang("任何疑问都可以联系唯一客服") . ":@{$serviceAccount}";
             return [
                 'chat_id' => $chatId,
@@ -460,21 +460,7 @@ class QianBaoWithdrawService
             return ['chat_id' => $memberId, 'code' => HttpStatus::CUSTOM_ERROR, 'text' => "提现金额范围为{$minAmount}-{$maxAmount}"];
         }
         $channelRate = (float)($channelConfig->rate ?? 0);
-        DB::beginTransaction();
         try {
-            $wallet = WalletService::findOne(['member_id' => $memberId]);
-            if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
-            $balance = $wallet->available_balance;
-            if (bccomp($balance, $amount, 2) < 0) {
-                throw new Exception("您的钱包余额不足!", HttpStatus::CUSTOM_ERROR);
-            }
-            $available_balance = bcsub($balance, $amount, 10);
-
-            // 先预扣款(锁定资金)
-            $wallet->available_balance = $available_balance;
-            if (!$wallet->save()) throw new Exception('钱包更新失败!', HttpStatus::CUSTOM_ERROR);
-
-
             $data = [];
             $data['type'] = PaymentOrderService::TYPE_PAYOUT;
             $data['order_no'] = PaymentOrderService::createOrderNo('sj' . $data['type'] . '_', $memberId);
@@ -496,22 +482,18 @@ class QianBaoWithdrawService
             }
             $data['status'] = PaymentOrderService::STATUS_STAY;
             $data['remark'] = '提现费率:' . ($channelRate * 100) . '%+2';
-            // 创建待处理状态的提现记录
-            $info = PaymentOrder::create($data);
-            // 记录余额变动日志
-            BalanceLogService::addLog($memberId, $default_amount * -1, $balance, $available_balance, '三方提现', $info->id, '钱宝提现费率:' . ($channelRate * 100) . '%+2');
-
+            // 校验打码量 → 冻结资金 → 创建待处理的提现记录 → 重置打码周期
+            [$info, $wallet] = WithdrawFundsService::hold((string)$memberId, $amount, '三方提现', '钱宝提现费率:' . ($channelRate * 100) . '%+2',
+                WithdrawFundsService::PAYMENT_ORDERS, fn () => PaymentOrder::create($data));
 
-            $balance = bcadd($available_balance, 0, 2);
+            $balance = bcadd((string)$wallet->available_balance, 0, 2);
             $text = "✅ 提现申请已提交!\n\n";
             $text .= "钱包余额:{$balance} RMB\n";
             $text .= "提现金额:{$default_amount} RMB\n";
             $text .= "⌛️请等待系统处理, 到账时间可能需要几分钟!\n";
             $result['text'] = $text;
-            DB::commit();
         } //
         catch (Exception $e) {
-            DB::rollBack();
             LogService::error($e);
             $result['text'] = "系统发生了错误,请联系管理员";
             $result['code'] = -1;

+ 2 - 0
app/Services/RewardGrantService.php

@@ -74,6 +74,8 @@ class RewardGrantService
                     $changeType, $reward->id, $remark);
                 if (bccomp($result['need_flow'], '0', 2) > 0) {
                     WalletBonus::addData($reward->member_id, $type === 'rebate' ? 4 : 5, $amount, 0, $reward->id, $result['need_flow']);
+                    // 规则配置了打码倍数时计入提现打码量
+                    WageringService::addRequirement($wallet, $result['need_flow'], 'activity:' . $changeType, $reward->id, $remark);
                 }
             }
             $reward->amount = $amount;

+ 2 - 0
app/Services/UserService.php

@@ -146,6 +146,8 @@ class UserService extends BaseService
         foreach($list as &$item) {
             $item['total_consume'] = BalanceLog::getTotalConsume($item->member_id);//用户累计消费总额
             $item['third_game_total_balance'] = $thirdGameBalances[(string) $item->member_id] ?? null;
+            // 当前周期打码量:wager_required 所需、wager_completed 当前打码量、wager_remaining 剩余打码量(提现后重置)
+            foreach (WageringService::summary($item->wallet) as $key => $value) $item[$key] = $value;
         }
         return ['total' => $total, 'data' => $list];
     }

+ 215 - 0
app/Services/WageringService.php

@@ -0,0 +1,215 @@
+<?php
+
+namespace App\Services;
+
+use App\Constants\HttpStatus;
+use App\Models\Config;
+use App\Models\User;
+use App\Models\WagerRecord;
+use App\Models\Wallet;
+use Exception;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * 统一提现打码量。
+ *
+ * 一个打码周期从上一次提现申请开始:
+ * - wager_required:本周期所有打码要求之和(充值×平台倍数、活动配置打码量、后台填写打码量等累加)。
+ * - wager_completed:本周期的有效投注(与活动解冻同一口径 PromotionFacts / EffectiveBet),由 wagering:sync 缓存。
+ * - 剩余打码量 = max(required - completed, 0),大于0不可提现;提现申请成功后重置周期。
+ */
+class WageringService
+{
+    public const CONFIG_FIELD = 'withdraw_wager_multiple';
+
+    /** @var array<int,bool> 按数据库连接缓存表结构探测结果 */
+    private static array $enabled = [];
+
+    /** 表结构未升级(或隔离测试未建表)时整个打码量逻辑静默跳过。 */
+    public static function enabled(): bool
+    {
+        $key = spl_object_id(DB::connection()->getPdo());
+        if (!array_key_exists($key, self::$enabled)) {
+            $schema = DB::connection()->getSchemaBuilder();
+            self::$enabled[$key] = $schema->hasTable('wager_records') && $schema->hasColumn('wallets', 'wager_required');
+        }
+        return self::$enabled[$key];
+    }
+
+    /** 测试与迁移后重置结构探测缓存。 */
+    public static function flushSchemaCache(): void
+    {
+        self::$enabled = [];
+        self::$multiple = null;
+        self::$userIds = [];
+        WithdrawFundsService::flushSchemaCache();
+    }
+
+    /** @var array{0:int,1:int,2:string}|null [连接, 读取时间, 倍数],短时缓存,后台修改后几秒内生效 */
+    private static ?array $multiple = null;
+
+    /** 平台提现打码倍数,未配置时为1倍。 */
+    public static function multiple(): string
+    {
+        if (!self::enabled()) return '1';
+        $key = spl_object_id(DB::connection()->getPdo());
+        if (self::$multiple !== null && self::$multiple[0] === $key && time() - self::$multiple[1] < 10) return self::$multiple[2];
+        $val = Config::where('field', self::CONFIG_FIELD)->value('val');
+        $result = self::normalizeMultiple($val);
+        self::$multiple = [$key, time(), $result];
+        return $result;
+    }
+
+    private static function normalizeMultiple($val): string
+    {
+        if ($val === null || $val === '' || !is_numeric($val) || bccomp((string)$val, '0', 10) < 0) return '1';
+        return bcadd((string)$val, '0', 10);
+    }
+
+    /** 充值/人工充值金额对应的平台打码量。 */
+    public static function platformRequirement($amount): string
+    {
+        $amount = ltrim(bcadd((string)$amount, '0', 10), '-');
+        return bcmul($amount, self::multiple(), 10);
+    }
+
+    /**
+     * 调整所需打码量(有符号,结果不低于0),调用方必须已在事务中锁定该钱包行。
+     * 只写 wager_required 一列,不会覆盖调用方钱包对象上尚未保存的余额改动。
+     */
+    public static function addRequirement(Wallet $wallet, $amount, string $source, $sourceId = null, string $remark = '', ?int $adminId = null): ?WagerRecord
+    {
+        if (!self::enabled()) return null;
+        $amount = bcadd((string)$amount, '0', 10);
+        if (bccomp($amount, '0', 10) === 0) return null;
+        // 以库内当前值为准(调用方已持有行锁),不依赖调用方可能过期的钱包对象
+        $before = bcadd((string)(Wallet::where('id', $wallet->id)->value('wager_required') ?? '0'), '0', 10);
+        $after = bcadd($before, $amount, 10);
+        if (bccomp($after, '0', 10) < 0) $after = bcadd('0', '0', 10);
+        Wallet::where('id', $wallet->id)->update(['wager_required' => $after]);
+        self::setClean($wallet, 'wager_required', $after);
+        return WagerRecord::create([
+            'user_id' => self::userId($wallet), 'member_id' => (string)$wallet->member_id,
+            'cycle_id' => (int)($wallet->getAttribute('wager_cycle_id') ?? 0), 'source' => $source,
+            'source_id' => $sourceId === null ? null : (string)$sourceId, 'amount' => $amount,
+            'before_required' => $before, 'after_required' => $after,
+            'completed' => (string)($wallet->getAttribute('wager_completed') ?? '0'), 'admin_id' => $adminId,
+            'remark' => mb_substr($remark, 0, 255),
+        ]);
+    }
+
+    /** 自行锁钱包后调整打码量;供没有持有钱包锁的流程(活动发放、充值钩子)调用。 */
+    public static function addRequirementFor(string $memberId, $amount, string $source, $sourceId = null, string $remark = '', ?int $adminId = null): ?WagerRecord
+    {
+        if (!self::enabled() || bccomp(bcadd((string)$amount, '0', 10), '0', 10) === 0) return null;
+        return DB::transaction(function () use ($memberId, $amount, $source, $sourceId, $remark, $adminId) {
+            $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
+            if (!$wallet) return null;
+            return self::addRequirement($wallet, $amount, $source, $sourceId, $remark, $adminId);
+        }, 3);
+    }
+
+    /** 重新计算本周期已完成的有效投注并写回缓存(周期已被重置时不覆盖)。 */
+    public static function refresh(Wallet $wallet, ?PromotionFacts $facts = null): string
+    {
+        if (!self::enabled()) return '0';
+        $userId = self::userId($wallet);
+        $from = (string)($wallet->getAttribute('wager_cycle_from') ?: ($wallet->getRawOriginal('created_at') ?: '1970-01-01 00:00:00'));
+        $cursors = json_decode((string)($wallet->getAttribute('wager_cycle_cursors') ?: '[]'), true) ?: [];
+        $completed = '0';
+        if ($userId) {
+            $facts ??= new PromotionFacts();
+            $completed = $facts->total($facts->betting([$userId], $from, null, $cursors)['effective']);
+        }
+        $completed = bcadd($completed, '0', 10);
+        $now = PromotionFacts::now();
+        Wallet::where('id', $wallet->id)->where('wager_cycle_id', (int)($wallet->getAttribute('wager_cycle_id') ?? 0))
+            ->update(['wager_completed' => $completed, 'wager_synced_at' => $now]);
+        self::setClean($wallet, 'wager_completed', $completed);
+        self::setClean($wallet, 'wager_synced_at', $now);
+        return $completed;
+    }
+
+    public static function remaining($required, $completed): string
+    {
+        $left = bcsub(bcadd((string)($required ?? '0'), '0', 10), bcadd((string)($completed ?? '0'), '0', 10), 10);
+        return bccomp($left, '0', 10) > 0 ? $left : bcadd('0', '0', 10);
+    }
+
+    /** 接口展示用:所需打码量、当前打码量、剩余打码量(两位小数)。 */
+    public static function summary($wallet): array
+    {
+        $required = (string)(data_get($wallet, 'wager_required') ?? '0');
+        $completed = (string)(data_get($wallet, 'wager_completed') ?? '0');
+        return [
+            'wager_required' => bcadd($required, '0', 2),
+            'wager_completed' => bcadd($completed, '0', 2),
+            'wager_remaining' => bcadd(self::remaining($required, $completed), '0', 2),
+        ];
+    }
+
+    /** 提现前校验:任意一项打码量未完成(合计剩余>0)都不能提现。调用方需已锁定钱包。 */
+    public static function assertWithdrawable(Wallet $wallet, bool $recompute = true): void
+    {
+        if (!self::enabled()) return;
+        $required = (string)($wallet->getAttribute('wager_required') ?? '0');
+        if (bccomp($required, '0', 10) <= 0) return;
+        $completed = (string)($wallet->getAttribute('wager_completed') ?? '0');
+        if ($recompute && bccomp(self::remaining($required, $completed), '0', 10) > 0) $completed = self::refresh($wallet);
+        $remaining = self::remaining($required, $completed);
+        if (bccomp($remaining, '0', 10) > 0) {
+            throw new Exception('打码量未完成,剩余打码量:' . bcadd($remaining, '0', 2), HttpStatus::CUSTOM_ERROR);
+        }
+    }
+
+    /** 提现入口的提示用(不加锁):打码量未完成时返回剩余打码量(两位小数),否则返回 null。 */
+    public static function remainingFor(string $memberId): ?string
+    {
+        if (!self::enabled()) return null;
+        $wallet = Wallet::where('member_id', $memberId)->first();
+        if (!$wallet || bccomp((string)($wallet->wager_required ?? '0'), '0', 10) <= 0) return null;
+        $remaining = self::remaining($wallet->wager_required, self::refresh($wallet));
+        return bccomp($remaining, '0', 10) > 0 ? bcadd($remaining, '0', 2) : null;
+    }
+
+    /** 提现申请成功后开启新周期:所需与当前打码量清零。调用方需已锁定钱包。 */
+    public static function resetCycle(Wallet $wallet, string $source = 'reset', $sourceId = null, string $remark = '提现重置打码量'): void
+    {
+        if (!self::enabled()) return;
+        $before = bcadd((string)($wallet->getAttribute('wager_required') ?? '0'), '0', 10);
+        $completed = (string)($wallet->getAttribute('wager_completed') ?? '0');
+        $cycle = (int)($wallet->getAttribute('wager_cycle_id') ?? 0);
+        $now = PromotionFacts::now();
+        $values = ['wager_required' => '0', 'wager_completed' => '0', 'wager_cycle_id' => $cycle + 1,
+            'wager_cycle_from' => $now, 'wager_cycle_cursors' => json_encode((new PromotionFacts())->cursors()), 'wager_synced_at' => $now];
+        Wallet::where('id', $wallet->id)->update($values);
+        foreach ($values as $key => $value) self::setClean($wallet, $key, $value);
+        WagerRecord::create([
+            'user_id' => self::userId($wallet), 'member_id' => (string)$wallet->member_id, 'cycle_id' => $cycle,
+            'source' => $source, 'source_id' => $sourceId === null ? null : (string)$sourceId,
+            'amount' => bcsub('0', $before, 10), 'before_required' => $before, 'after_required' => '0',
+            'completed' => $completed, 'remark' => $remark,
+        ]);
+    }
+
+    /** @var array<string,int|null> member_id => users.id(不会变化,进程内缓存) */
+    private static array $userIds = [];
+
+    private static function userId(Wallet $wallet): ?int
+    {
+        $memberId = (string)$wallet->member_id;
+        if (!array_key_exists($memberId, self::$userIds)) {
+            if (count(self::$userIds) > 5000) self::$userIds = [];
+            $id = User::where('member_id', $memberId)->value('id');
+            self::$userIds[$memberId] = $id ? (int)$id : ($wallet->user_id ? (int)$wallet->user_id : null);
+        }
+        return self::$userIds[$memberId];
+    }
+
+    /** 已直接写库的列同步到模型原始值,避免调用方后续 save() 用旧值覆盖。 */
+    private static function setClean(Wallet $wallet, string $key, $value): void
+    {
+        $wallet->setAttribute($key, $value);
+        $wallet->syncOriginalAttribute($key);
+    }
+}

+ 173 - 0
app/Services/WalletFreezeService.php

@@ -0,0 +1,173 @@
+<?php
+
+namespace App\Services;
+
+use App\Constants\HttpStatus;
+use App\Models\User;
+use App\Models\Wallet;
+use App\Models\WalletFreezeRecord as Record;
+use Carbon\Carbon;
+use Exception;
+use Illuminate\Database\QueryException;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * 后台人工冻结 / 解冻。
+ * 冻结:可用余额 → 冻结余额,并减少打码量;解冻:冻结余额 → 可用余额,并增加打码量。
+ * 解冻金额不超过该会员未解冻的人工冻结金额,提现冻结中的资金不能被人工解冻。
+ */
+class WalletFreezeService
+{
+    public static function freeze(string $requestId, int $adminId, string $memberId, string $amount, string $wagerAmount, string $reason): array
+    {
+        return self::apply(true, $requestId, $adminId, $memberId, $amount, $wagerAmount, $reason);
+    }
+
+    public static function unfreeze(string $requestId, int $adminId, string $memberId, string $amount, string $wagerAmount, string $reason): array
+    {
+        return self::apply(false, $requestId, $adminId, $memberId, $amount, $wagerAmount, $reason);
+    }
+
+    /** 会员尚未解冻的人工冻结金额。 */
+    public static function manualFrozen(string $memberId): string
+    {
+        $frozen = (string)Record::where('member_id', $memberId)->where('type', Record::TYPE_MANUAL_FREEZE)->sum('amount');
+        $unfrozen = (string)Record::where('member_id', $memberId)->where('type', Record::TYPE_MANUAL_UNFREEZE)->sum('amount');
+        $left = bcsub(bcadd($frozen, '0', 10), bcadd($unfrozen, '0', 10), 10);
+        return bccomp($left, '0', 10) > 0 ? $left : '0';
+    }
+
+    private static function apply(bool $isFreeze, string $requestId, int $adminId, string $memberId, string $amount, string $wagerAmount, string $reason): array
+    {
+        $type = $isFreeze ? Record::TYPE_MANUAL_FREEZE : Record::TYPE_MANUAL_UNFREEZE;
+        $amount = bcadd($amount, '0', 10);
+        $wagerAmount = bcadd($wagerAmount === '' ? '0' : $wagerAmount, '0', 10);
+        if (bccomp($amount, '0', 10) <= 0) throw new Exception('金额必须大于0', HttpStatus::CUSTOM_ERROR);
+        if (bccomp($wagerAmount, '0', 10) < 0) throw new Exception('打码量不能小于0', HttpStatus::CUSTOM_ERROR);
+
+        $replay = fn () => self::replay($requestId, $type, $memberId, $amount, $wagerAmount);
+        if ($existing = $replay()) return $existing;
+
+        try {
+            $record = DB::transaction(function () use ($isFreeze, $type, $requestId, $adminId, $memberId, $amount, $wagerAmount, $reason) {
+                $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
+                if (!$wallet) throw new Exception('用户钱包不存在', HttpStatus::CUSTOM_ERROR);
+                $user = User::where('member_id', $memberId)->first(['id']);
+                if (!$user) throw new Exception('用户不存在', HttpStatus::CUSTOM_ERROR);
+
+                $beforeAvailable = bcadd((string)$wallet->available_balance, '0', 10);
+                $beforeFrozen = bcadd((string)$wallet->frozen_balance, '0', 10);
+                if ($isFreeze) {
+                    if (bccomp($beforeAvailable, $amount, 10) < 0) throw new Exception('可用余额不足', HttpStatus::CUSTOM_ERROR);
+                    $afterAvailable = bcsub($beforeAvailable, $amount, 10);
+                    $afterFrozen = bcadd($beforeFrozen, $amount, 10);
+                } else {
+                    $limit = self::manualFrozen($memberId);
+                    if (bccomp($beforeFrozen, $limit, 10) < 0) $limit = $beforeFrozen;
+                    if (bccomp($limit, $amount, 10) < 0) throw new Exception('可解冻金额不足,最多可解冻:' . bcadd($limit, '0', 2), HttpStatus::CUSTOM_ERROR);
+                    $afterAvailable = bcadd($beforeAvailable, $amount, 10);
+                    $afterFrozen = bcsub($beforeFrozen, $amount, 10);
+                }
+                $wallet->available_balance = $afterAvailable;
+                $wallet->frozen_balance = $afterFrozen;
+                if (!$wallet->save()) throw new Exception('钱包更新失败', HttpStatus::CUSTOM_ERROR);
+
+                $record = Record::create([
+                    'user_id' => $user->id, 'member_id' => $memberId, 'type' => $type, 'amount' => $amount,
+                    'wager_amount' => $wagerAmount, 'reason' => mb_substr($reason, 0, 255),
+                    'status' => $isFreeze ? Record::STATUS_FROZEN : Record::STATUS_DONE, 'request_id' => $requestId,
+                    'admin_id' => $adminId, 'before_available' => $beforeAvailable, 'after_available' => $afterAvailable,
+                    'before_frozen' => $beforeFrozen, 'after_frozen' => $afterFrozen,
+                ]);
+                BalanceLogService::addLog($memberId, $isFreeze ? bcsub('0', $amount, 10) : $amount, $beforeAvailable, $afterAvailable,
+                    $isFreeze ? '人工冻结' : '人工解冻', $record->id, $reason);
+                WageringService::addRequirement($wallet, $isFreeze ? bcsub('0', $wagerAmount, 10) : $wagerAmount,
+                    $isFreeze ? 'freeze' : 'unfreeze', $record->id, $reason, $adminId);
+                return $record;
+            }, 3);
+        } catch (QueryException $e) {
+            // 同一 request_id 并发提交:唯一索引冲突后返回已成功的那一笔
+            if ($existing = $replay()) return $existing;
+            throw $e;
+        }
+        return self::format($record);
+    }
+
+    private static function replay(string $requestId, string $type, string $memberId, string $amount, string $wagerAmount): ?array
+    {
+        $existing = Record::where('request_id', $requestId)->first();
+        if (!$existing) return null;
+        if ($existing->type !== $type || (string)$existing->member_id !== $memberId
+            || bccomp((string)$existing->amount, $amount, 10) !== 0 || bccomp((string)$existing->wager_amount, $wagerAmount, 10) !== 0) {
+            throw new Exception('request_id 已被其他冻结操作使用', HttpStatus::CUSTOM_ERROR);
+        }
+        return self::format($existing);
+    }
+
+    /** 冻结记录列表(后台)。 */
+    public static function list(array $params): array
+    {
+        $page = max(1, (int)($params['page'] ?? 1));
+        $limit = min(200, max(1, (int)($params['limit'] ?? 20)));
+        $query = Record::query()->with('member');
+        if (!empty($params['member_id'])) $query->where('member_id', (string)$params['member_id']);
+        if (!empty($params['account'])) {
+            $query->whereIn('member_id', User::select('member_id')->where('account', 'like', '%' . trim((string)$params['account']) . '%'));
+        }
+        if (!empty($params['type'])) $query->where('type', (string)$params['type']);
+        if (!empty($params['status'])) $query->where('status', (string)$params['status']);
+        if (!empty($params['start_date']) && !empty($params['end_date'])) {
+            $query->whereBetween('created_at', [
+                Carbon::parse($params['start_date'], 'Asia/Shanghai')->startOfDay(),
+                Carbon::parse($params['end_date'], 'Asia/Shanghai')->endOfDay(),
+            ]);
+        }
+        $total = (clone $query)->count('id');
+        $rows = $query->orderByDesc('id')->forPage($page, $limit)->get();
+        $admins = DB::table('admin')->whereIn('id', $rows->pluck('admin_id')->filter()->unique()->all() ?: [0])->pluck('username', 'id');
+        return [
+            'total' => $total,
+            'data' => $rows->map(fn (Record $row) => self::format($row, $admins->all()))->all(),
+            'options' => [
+                'types' => self::options(Record::TYPE_LABELS),
+                'statuses' => self::options(Record::STATUS_LABELS),
+            ],
+        ];
+    }
+
+    public static function format(Record $row, array $admins = []): array
+    {
+        $member = $row->relationLoaded('member') ? $row->member : null;
+        return [
+            'id' => (int)$row->id,
+            'member_id' => (string)$row->member_id,
+            'account' => (string)($member->account ?? ''),
+            'username' => (string)($member->username ?? ''),
+            'first_name' => (string)($member->first_name ?? ''),
+            'type' => (string)$row->type,
+            'type_text' => Record::TYPE_LABELS[$row->type] ?? $row->type,
+            'amount' => bcadd((string)$row->amount, '0', 2),
+            'wager_amount' => bcadd((string)$row->wager_amount, '0', 2),
+            'reason' => (string)$row->reason,
+            'status' => (string)$row->status,
+            'status_text' => Record::STATUS_LABELS[$row->status] ?? $row->status,
+            'related_type' => $row->related_type,
+            'related_id' => $row->related_id === null ? null : (int)$row->related_id,
+            'admin_id' => $row->admin_id === null ? null : (int)$row->admin_id,
+            'admin_name' => $row->admin_id === null ? '' : (string)($admins[$row->admin_id] ?? ''),
+            'before_available' => bcadd((string)$row->before_available, '0', 2),
+            'after_available' => bcadd((string)$row->after_available, '0', 2),
+            'before_frozen' => bcadd((string)$row->before_frozen, '0', 2),
+            'after_frozen' => bcadd((string)$row->after_frozen, '0', 2),
+            'finished_at' => $row->finished_at ? (string)$row->finished_at : null,
+            'created_at' => (string)$row->created_at,
+        ];
+    }
+
+    private static function options(array $labels): array
+    {
+        $result = [];
+        foreach ($labels as $value => $label) $result[] = ['label' => $label, 'value' => $value];
+        return $result;
+    }
+}

+ 157 - 0
app/Services/WithdrawFundsService.php

@@ -0,0 +1,157 @@
+<?php
+
+namespace App\Services;
+
+use App\Constants\HttpStatus;
+use App\Models\User;
+use App\Models\Wallet;
+use App\Models\WalletFreezeRecord as Record;
+use Exception;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * 提现资金冻结:
+ * - hold:申请时校验打码量,可用余额转入冻结余额,创建提现单并重置打码周期。
+ * - release:审核拒绝 / 三方失败 / 撤回,冻结余额退回可用余额。
+ * - settle:提现成功,扣除冻结余额。
+ * frozen_amount = 0 的旧单(上线前申请,已直接扣可用余额)保持原逻辑:拒绝退回可用余额,成功不再扣款。
+ */
+class WithdrawFundsService
+{
+    public const WITHDRAWS = 'withdraws';
+    public const PAYMENT_ORDERS = 'payment_orders';
+
+    /** @var array<int,bool> */
+    private static array $enabled = [];
+
+    public static function enabled(): bool
+    {
+        if (!WageringService::enabled()) return false;
+        $key = spl_object_id(DB::connection()->getPdo());
+        if (!array_key_exists($key, self::$enabled)) {
+            $schema = DB::connection()->getSchemaBuilder();
+            self::$enabled[$key] = $schema->hasTable('wallet_freeze_records')
+                && $schema->hasColumn('withdraws', 'frozen_amount') && $schema->hasColumn('payment_orders', 'frozen_amount');
+        }
+        return self::$enabled[$key];
+    }
+
+    public static function flushSchemaCache(): void
+    {
+        self::$enabled = [];
+    }
+
+    /**
+     * @param callable(string $beforeAvailable, string $afterAvailable): Model $createOrder 创建提现单
+     * @return array{0: Model, 1: Wallet}
+     */
+    public static function hold(string $memberId, $amount, string $changeType, string $remark, string $relatedType, callable $createOrder, ?string $logAmount = null): array
+    {
+        // 先在锁外重算当前打码量(耗时查询不占用钱包行锁);锁内只用缓存值判断。
+        // 之后才结算的注单只会让缓存偏低,不会让未完成打码量的会员提前提现。
+        $unlocked = WageringService::enabled() ? Wallet::where('member_id', $memberId)->first() : null;
+        if ($unlocked && bccomp(WageringService::remaining($unlocked->wager_required, $unlocked->wager_completed), '0', 10) > 0) {
+            WageringService::refresh($unlocked);
+        }
+        return DB::transaction(function () use ($memberId, $amount, $changeType, $remark, $relatedType, $createOrder, $logAmount) {
+            $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
+            if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
+            $amount = bcadd((string)$amount, '0', 10);
+            if (bccomp($amount, '0', 10) <= 0) throw new Exception('提现金额错误', HttpStatus::CUSTOM_ERROR);
+            $beforeAvailable = bcadd((string)$wallet->available_balance, '0', 10);
+            if (bccomp($beforeAvailable, $amount, 10) < 0) throw new Exception('您的钱包余额不足!', HttpStatus::CUSTOM_ERROR);
+            WageringService::assertWithdrawable($wallet, false);
+
+            $enabled = self::enabled();
+            $beforeFrozen = bcadd((string)$wallet->frozen_balance, '0', 10);
+            $afterAvailable = bcsub($beforeAvailable, $amount, 10);
+            $afterFrozen = $enabled ? bcadd($beforeFrozen, $amount, 10) : $beforeFrozen;
+            $wallet->available_balance = $afterAvailable;
+            $wallet->frozen_balance = $afterFrozen;
+            if (!$wallet->save()) throw new Exception('钱包更新失败!', HttpStatus::CUSTOM_ERROR);
+
+            $order = $createOrder($beforeAvailable, $afterAvailable);
+            if ($enabled) {
+                $order->frozen_amount = $amount;
+                $order->save();
+            }
+            BalanceLogService::addLog($memberId, $logAmount ?? bcsub('0', $amount, 10), $beforeAvailable, $afterAvailable, $changeType, $order->id, $remark);
+            if ($enabled) {
+                Record::create([
+                    'user_id' => User::where('member_id', $memberId)->value('id'), 'member_id' => $memberId,
+                    'type' => Record::TYPE_WITHDRAW_HOLD, 'amount' => $amount, 'wager_amount' => '0',
+                    'reason' => $changeType . '申请冻结', 'status' => Record::STATUS_HOLDING,
+                    'related_type' => $relatedType, 'related_id' => $order->id,
+                    'before_available' => $beforeAvailable, 'after_available' => $afterAvailable,
+                    'before_frozen' => $beforeFrozen, 'after_frozen' => $afterFrozen,
+                ]);
+            }
+            WageringService::resetCycle($wallet, 'reset', $relatedType . ':' . $order->id);
+            return [$order, $wallet];
+        }, 3);
+    }
+
+    /**
+     * 退回提现资金到可用余额(拒绝 / 失败 / 撤回)。调用方负责先把提现单改为终态,避免重复退回。
+     * @param string $legacyAmount 旧单(frozen_amount=0)应退回的金额
+     * @return array{before: string, after: string, amount: string}|null 已处理过返回 null
+     */
+    public static function release(string $relatedType, Model $order, $legacyAmount, string $changeType, string $remark): ?array
+    {
+        return DB::transaction(function () use ($relatedType, $order, $legacyAmount, $changeType, $remark) {
+            $wallet = Wallet::where('member_id', $order->member_id)->lockForUpdate()->first();
+            if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
+            $frozenAmount = self::frozenAmount($order);
+            $record = null;
+            if (bccomp($frozenAmount, '0', 10) > 0) {
+                $record = self::holdRecord($relatedType, (int)$order->id);
+                if ($record && $record->status !== Record::STATUS_HOLDING) return null;
+                $amount = $frozenAmount;
+                $beforeFrozen = bcadd((string)$wallet->frozen_balance, '0', 10);
+                if (bccomp($beforeFrozen, $amount, 10) < 0) throw new Exception('冻结余额不足,无法退回提现', HttpStatus::CUSTOM_ERROR);
+                $wallet->frozen_balance = bcsub($beforeFrozen, $amount, 10);
+            } else {
+                $amount = bcadd((string)$legacyAmount, '0', 10);
+            }
+            $before = bcadd((string)$wallet->available_balance, '0', 10);
+            $after = bcadd($before, $amount, 10);
+            $wallet->available_balance = $after;
+            if (!$wallet->save()) throw new Exception('钱包更新失败', HttpStatus::CUSTOM_ERROR);
+            BalanceLogService::addLog($order->member_id, $amount, $before, $after, $changeType, $order->id, $remark);
+            $record?->update(['status' => Record::STATUS_RELEASED, 'finished_at' => PromotionFacts::now(),
+                'after_available' => $after, 'after_frozen' => $wallet->frozen_balance]);
+            return ['before' => $before, 'after' => $after, 'amount' => $amount];
+        }, 3);
+    }
+
+    /** 提现成功:扣除冻结余额。旧单无需处理。 */
+    public static function settle(string $relatedType, Model $order): void
+    {
+        $frozenAmount = self::frozenAmount($order);
+        if (bccomp($frozenAmount, '0', 10) <= 0) return;
+        DB::transaction(function () use ($relatedType, $order, $frozenAmount) {
+            $wallet = Wallet::where('member_id', $order->member_id)->lockForUpdate()->first();
+            if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
+            $record = self::holdRecord($relatedType, (int)$order->id);
+            if ($record && $record->status !== Record::STATUS_HOLDING) return;
+            $beforeFrozen = bcadd((string)$wallet->frozen_balance, '0', 10);
+            if (bccomp($beforeFrozen, $frozenAmount, 10) < 0) throw new Exception('冻结余额不足,无法完成提现', HttpStatus::CUSTOM_ERROR);
+            $wallet->frozen_balance = bcsub($beforeFrozen, $frozenAmount, 10);
+            if (!$wallet->save()) throw new Exception('钱包更新失败', HttpStatus::CUSTOM_ERROR);
+            $record?->update(['status' => Record::STATUS_SETTLED, 'finished_at' => PromotionFacts::now(), 'after_frozen' => $wallet->frozen_balance]);
+        }, 3);
+    }
+
+    private static function frozenAmount(Model $order): string
+    {
+        return bcadd((string)($order->getAttribute('frozen_amount') ?? '0'), '0', 10);
+    }
+
+    private static function holdRecord(string $relatedType, int $id): ?Record
+    {
+        if (!self::enabled()) return null;
+        return Record::where('related_type', $relatedType)->where('related_id', $id)
+            ->where('type', Record::TYPE_WITHDRAW_HOLD)->lockForUpdate()->first();
+    }
+}

+ 46 - 41
app/Services/WithdrawService.php

@@ -17,6 +17,7 @@ use App\Models\Wallet;
 use App\Models\Withdraw;
 use Exception;
 use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
 use LaravelLang\Publisher\Console\Add;
 use Telegram\Bot\Api;
 use Telegram\Bot\Exceptions\TelegramSDKException;
@@ -362,9 +363,9 @@ class WithdrawService
     public static function done($chatId, $messageId, $firstName)
     {
         $serviceAccount = Config::where('field', 'service_customer')->first()->val;
-        $activityUser = ActivityUserService::findOne(['member_id' => $chatId, 'status' => ActivityUser::STATUS_IN_PROGRESS]);
-        if ($activityUser) {
-            $text = lang("您有未完成的活动") . "\n";
+        $wagerLeft = WageringService::remainingFor((string)$chatId);
+        if ($wagerLeft !== null) {
+            $text = lang("打码量未完成,剩余打码量") . ":{$wagerLeft}\n";
             $text .= lang("任何疑问都可以联系唯一客服") . ":@{$serviceAccount}";
             return [
                 'chat_id' => $chatId,
@@ -425,26 +426,28 @@ class WithdrawService
         }
 
 
-        $wallet = Wallet::where('member_id', $chatId)->first();
-        $changeAmount = bcmul(($amount * -1), $rate, 2);
-        $beforeBalance = $wallet->available_balance;
-        $afterBalance = bcsub($wallet->available_balance, $rate_rmb_amount, 2);
-        $wallet->available_balance = $afterBalance;
-        $wallet->save();
-
-        $withdraw = Withdraw::create([
-            'member_id' => $chatId,
-            'amount' => $amount,
-            'service_charge' => $serviceCharge,
-            'to_account' => $real,
-            'address' => $address,
-            'exchange_rate' => $rate,
-            'status' => 0,
-            'after_balance' => $afterBalance
-        ]);
-
-
-        BalanceLogService::addLog($chatId, $changeAmount, $beforeBalance, $afterBalance, '提现', $withdraw->id, '');
+        // 校验打码量 → 可用余额转入冻结余额 → 创建提现单 → 重置打码周期
+        try {
+            [, $wallet] = WithdrawFundsService::hold((string)$chatId, $rate_rmb_amount, '提现', '', WithdrawFundsService::WITHDRAWS,
+                fn ($before, $after) => Withdraw::create([
+                    'member_id' => $chatId,
+                    'amount' => $amount,
+                    'service_charge' => $serviceCharge,
+                    'to_account' => $real,
+                    'address' => $address,
+                    'exchange_rate' => $rate,
+                    'status' => 0,
+                    'after_balance' => $after,
+                ]));
+        } catch (\Exception $e) {
+            if ($e->getCode() !== HttpStatus::CUSTOM_ERROR) LogService::error($e);
+            return [
+                'chat_id' => $chatId,
+                'text' => '⚠️' . ($e->getCode() === HttpStatus::CUSTOM_ERROR ? $e->getMessage() : lang('系统繁忙,请稍后重试!')),
+                'message_id' => $messageId,
+            ];
+        }
+        $afterBalance = $wallet->available_balance;
         $temp = floatval($afterBalance);
         $text = "✅ " . lang("提现申请已提交!") . "\n\n";
         $text .= lang("钱包余额") . ":{$temp} RMB\n";
@@ -641,25 +644,27 @@ class WithdrawService
 
     public static function setStatus($id, $status, $remark)
     {
-        $w = WithdrawService::findOne(['id' => $id, 'status' => 0]);
+        $w = DB::transaction(function () use ($id, $status, $remark) {
+            $w = Withdraw::where('id', $id)->where('status', 0)->lockForUpdate()->first();
+            if (!$w) return null;
+            // 汇率
+            $rate = $w->exchange_rate ?? 1;
+            if ($status == 1) {
+                $w->status = 1;
+                $w->save();
+                // 提现成功:扣除冻结余额
+                WithdrawFundsService::settle(WithdrawFundsService::WITHDRAWS, $w);
+            } else if ($status == 2) {
+                $rate_rmb_amount = bcmul($w->amount, $rate, 2);  // 提现金额 折合RMB(旧单退回金额)
+                $w->status = 2;
+                $w->remark = $remark;
+                $w->save();
+                // 审核拒绝:冻结余额退回可用余额
+                WithdrawFundsService::release(WithdrawFundsService::WITHDRAWS, $w, $rate_rmb_amount, '提现', '');
+            }
+            return $w;
+        }, 3);
         if (!$w) return 0;
-        // 汇率
-        $rate = $w->exchange_rate ?? 1;
-        if ($status == 1) {
-            $w->status = 1;
-            $w->save();
-        } else if ($status == 2) {
-            $rate_rmb_amount = bcmul($w->amount, $rate, 2);  // 提现金额 折合RMB
-            $w->status = 2;
-
-            $w->remark = $remark;
-            $w->save();
-            $wallet = WalletService::findOne(['member_id' => $w->member_id]);
-            $afterBalance = bcadd($wallet->available_balance, $rate_rmb_amount, 10);
-            BalanceLogService::addLog($w->member_id, $w->rate_rmb_amount, $wallet->available_balance, $afterBalance, '提现', $w->id, '');
-            $wallet->available_balance = $afterBalance;
-            $wallet->save();
-        }
 
         $arr = ['⏳️申请中', '✅️成功', '❌️失败'];
         $text = "📢 提现结果通知\n\n";

+ 158 - 0
database/migrations/2026_11_04_140000_create_wagering_and_wallet_freezes.php

@@ -0,0 +1,158 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 统一提现打码量 + 冻结金额:
+ * - wallets 记录当前周期的所需打码量与已完成打码量(有效投注)。
+ * - wager_records 记录每一次打码量变动;wallet_freeze_records 记录人工冻结/解冻与提现冻结。
+ * - withdraws / payment_orders.frozen_amount > 0 表示该提现单的资金冻结在 frozen_balance 中。
+ */
+return new class extends Migration {
+    public function up(): void
+    {
+        if (Schema::hasTable('wallets')) {
+            Schema::table('wallets', function (Blueprint $t) {
+                if (!Schema::hasColumn('wallets', 'wager_required')) $t->decimal('wager_required', 30, 10)->default(0)->comment('当前周期所需打码量');
+                if (!Schema::hasColumn('wallets', 'wager_completed')) $t->decimal('wager_completed', 30, 10)->default(0)->comment('当前打码量:本周期已完成的有效投注(缓存)');
+                if (!Schema::hasColumn('wallets', 'wager_cycle_id')) $t->unsignedInteger('wager_cycle_id')->default(0)->comment('打码周期序号,每次提现+1');
+                if (!Schema::hasColumn('wallets', 'wager_cycle_from')) $t->dateTime('wager_cycle_from')->nullable()->comment('打码周期开始时间');
+                if (!Schema::hasColumn('wallets', 'wager_cycle_cursors')) $t->text('wager_cycle_cursors')->nullable()->comment('周期开始时各投注表最大ID');
+                if (!Schema::hasColumn('wallets', 'wager_synced_at')) $t->dateTime('wager_synced_at')->nullable()->comment('当前打码量最后计算时间');
+            });
+        }
+
+        if (!Schema::hasTable('wager_records')) {
+            Schema::create('wager_records', function (Blueprint $t) {
+                $t->id();
+                $t->unsignedBigInteger('user_id')->nullable();
+                $t->string('member_id', 64);
+                $t->unsignedInteger('cycle_id')->default(0);
+                $t->string('source', 40)->comment('recharge/manual_credit/manual_debit/activity:*/freeze/unfreeze/migration/reset');
+                $t->string('source_id', 64)->nullable();
+                $t->decimal('amount', 30, 10)->comment('打码量变动(有符号)');
+                $t->decimal('before_required', 30, 10)->default(0);
+                $t->decimal('after_required', 30, 10)->default(0);
+                $t->decimal('completed', 30, 10)->default(0)->comment('变动时的当前打码量');
+                $t->unsignedBigInteger('admin_id')->nullable();
+                $t->string('remark', 255)->default('');
+                $t->timestamps();
+                $t->index(['member_id', 'id']);
+                $t->index(['source', 'source_id']);
+            });
+        }
+
+        if (!Schema::hasTable('wallet_freeze_records')) {
+            Schema::create('wallet_freeze_records', function (Blueprint $t) {
+                $t->id();
+                $t->unsignedBigInteger('user_id')->nullable();
+                $t->string('member_id', 64);
+                $t->string('type', 30)->comment('manual_freeze 人工冻结 / manual_unfreeze 人工解冻 / withdraw_hold 提现冻结');
+                $t->decimal('amount', 30, 10);
+                $t->decimal('wager_amount', 30, 10)->default(0)->comment('冻结时减少/解冻时增加的打码量');
+                $t->string('reason', 255)->default('');
+                $t->string('status', 20)->comment('frozen/done/holding/released/settled');
+                $t->string('related_type', 40)->nullable()->comment('withdraws / payment_orders');
+                $t->unsignedBigInteger('related_id')->nullable();
+                $t->string('request_id', 64)->nullable()->unique();
+                $t->unsignedBigInteger('admin_id')->nullable();
+                $t->decimal('before_available', 30, 10)->default(0);
+                $t->decimal('after_available', 30, 10)->default(0);
+                $t->decimal('before_frozen', 30, 10)->default(0);
+                $t->decimal('after_frozen', 30, 10)->default(0);
+                $t->dateTime('finished_at')->nullable()->comment('提现冻结退回/扣除时间');
+                $t->timestamps();
+                $t->index(['member_id', 'id']);
+                $t->index(['related_type', 'related_id']);
+                $t->index(['type', 'status']);
+                $t->index('created_at');
+            });
+        }
+
+        foreach (['withdraws', 'payment_orders'] as $table) {
+            if (Schema::hasTable($table) && !Schema::hasColumn($table, 'frozen_amount')) {
+                Schema::table($table, function (Blueprint $t) {
+                    $t->decimal('frozen_amount', 30, 10)->default(0)->comment('提现冻结金额;0为旧单(申请时已直接扣除可用余额)');
+                });
+            }
+        }
+
+        if (Schema::hasTable('manual_audits') && !Schema::hasColumn('manual_audits', 'wager_amount')) {
+            Schema::table('manual_audits', function (Blueprint $t) {
+                $t->decimal('wager_amount', 30, 10)->default(0)->comment('本次上下分的打码量变动(有符号)');
+            });
+        }
+
+        if (Schema::hasTable('config') && !DB::table('config')->where('field', 'withdraw_wager_multiple')->exists()) {
+            DB::table('config')->insert([
+                'field' => 'withdraw_wager_multiple',
+                'val' => '1',
+                'remark' => '提现打码倍数:充值/人工充值金额 × 倍数 = 需完成的打码量',
+                'group_id' => 1,
+                'created_at' => now(),
+                'updated_at' => now(),
+            ]);
+        }
+
+        // wagering:sync 每分钟按 updated_at 找有投注变动的会员。
+        foreach (['share_bet_facts', 'bets', 'jisu_game_order'] as $table) {
+            if (Schema::hasTable($table) && Schema::hasColumn($table, 'updated_at') && !Schema::hasIndex($table, ['updated_at'])) {
+                Schema::table($table, fn (Blueprint $t) => $t->index('updated_at', $table . '_wagering_updated_at_index'));
+            }
+        }
+
+        $this->backfillCycles();
+    }
+
+    /** 当前周期从最近一次提现开始(无提现则从钱包创建时间开始),首次部署时所需打码量为0。 */
+    private function backfillCycles(): void
+    {
+        if (!Schema::hasTable('wallets') || !Schema::hasColumn('wallets', 'wager_cycle_from')) return;
+        $p = DB::getTablePrefix();
+        $sources = [];
+        if (Schema::hasTable('withdraws')) {
+            $sources[] = "(SELECT MAX(w.created_at) FROM {$p}withdraws w WHERE w.member_id = {$p}wallets.member_id AND w.status IN (0, 1))";
+        }
+        if (Schema::hasTable('payment_orders')) {
+            $sources[] = "(SELECT MAX(o.created_at) FROM {$p}payment_orders o WHERE o.member_id = {$p}wallets.member_id AND o.type IN (2, 4) AND o.status IN (0, 1, 2))";
+        }
+        $sqlite = DB::connection()->getDriverName() === 'sqlite';
+        $greatest = $sqlite ? 'MAX' : 'GREATEST';
+        $now = $sqlite ? "datetime('now')" : 'NOW()';
+        $latest = match (count($sources)) {
+            0 => 'NULL',
+            1 => $sources[0],
+            default => $greatest . '(COALESCE(' . $sources[0] . ", '1970-01-01'), COALESCE(" . $sources[1] . ", '1970-01-01'))",
+        };
+        $created = Schema::hasColumn('wallets', 'created_at') ? 'created_at' : 'NULL';
+        DB::statement("UPDATE {$p}wallets SET wager_cycle_cursors = '[]', wager_cycle_from = COALESCE(NULLIF($latest, '1970-01-01'), $created, $now) WHERE wager_cycle_from IS NULL");
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('wager_records');
+        Schema::dropIfExists('wallet_freeze_records');
+        foreach (['withdraws', 'payment_orders'] as $table) {
+            if (Schema::hasTable($table) && Schema::hasColumn($table, 'frozen_amount')) {
+                Schema::table($table, fn (Blueprint $t) => $t->dropColumn('frozen_amount'));
+            }
+        }
+        if (Schema::hasTable('manual_audits') && Schema::hasColumn('manual_audits', 'wager_amount')) {
+            Schema::table('manual_audits', fn (Blueprint $t) => $t->dropColumn('wager_amount'));
+        }
+        if (Schema::hasTable('wallets')) {
+            foreach (['wager_required', 'wager_completed', 'wager_cycle_id', 'wager_cycle_from', 'wager_cycle_cursors', 'wager_synced_at'] as $column) {
+                if (Schema::hasColumn('wallets', $column)) Schema::table('wallets', fn (Blueprint $t) => $t->dropColumn($column));
+            }
+        }
+        foreach (['share_bet_facts', 'bets', 'jisu_game_order'] as $table) {
+            if (Schema::hasTable($table) && Schema::hasIndex($table, $table . '_wagering_updated_at_index')) {
+                Schema::table($table, fn (Blueprint $t) => $t->dropIndex($table . '_wagering_updated_at_index'));
+            }
+        }
+        DB::table('config')->where('field', 'withdraw_wager_multiple')->delete();
+    }
+};

+ 62 - 0
database/migrations/2026_11_04_141000_add_wallet_freeze_admin_menus.php

@@ -0,0 +1,62 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/** 会员管理 → 冻结记录 菜单,以及会员列表/人工稽查的冻结、解冻按钮权限。 */
+return new class extends Migration {
+    private const MENU_URI = 'walletFreeze';
+
+    private const BUTTONS = [
+        'admin/walletFreeze' => '冻结记录列表',
+        'admin/user/freeze' => '会员列表-冻结',
+        'admin/user/unfreeze' => '会员列表-解冻',
+        'admin/manualAudit/freeze' => '人工稽查-冻结',
+        'admin/manualAudit/unfreeze' => '人工稽查-解冻',
+    ];
+
+    public function up(): void
+    {
+        if (!Schema::hasTable('menus')) return;
+        $now = date('Y-m-d H:i:s');
+        $parent = DB::table('menus')->where('uri', 'user')->where('type', 1)->where('parent_id', 0)->first(['id'])
+            ?? DB::table('menus')->where('title', '会员管理')->where('type', 1)->first(['id']);
+        $parentId = $parent ? (int)$parent->id : 0;
+
+        $menu = DB::table('menus')->where('uri', self::MENU_URI)->where('type', 1)->first(['id']);
+        if ($menu) {
+            $menuId = (int)$menu->id;
+            DB::table('menus')->where('id', $menuId)->update(['parent_id' => $parentId, 'status' => 1, 'updated_at' => $now]);
+        } else {
+            $menuId = DB::table('menus')->insertGetId([
+                'parent_id' => $parentId, 'title' => '冻结记录', 'icon' => null, 'uri' => self::MENU_URI,
+                'permission_name' => 'user.wallet_freeze', 'sort' => 5, 'status' => 1, 'type' => 1,
+                'created_at' => $now, 'updated_at' => $now,
+            ]);
+        }
+
+        $sort = 0;
+        foreach (self::BUTTONS as $uri => $title) {
+            $button = DB::table('menus')->where('uri', $uri)->first(['id']);
+            if ($button) {
+                DB::table('menus')->where('id', $button->id)->update(['parent_id' => $menuId, 'title' => $title, 'status' => 1, 'type' => 2, 'updated_at' => $now]);
+            } else {
+                DB::table('menus')->insert([
+                    'parent_id' => $menuId, 'title' => $title, 'icon' => null, 'uri' => $uri,
+                    'permission_name' => str_replace('/', '.', $uri), 'sort' => $sort, 'status' => 1, 'type' => 2,
+                    'created_at' => $now, 'updated_at' => $now,
+                ]);
+            }
+            $sort++;
+        }
+    }
+
+    public function down(): void
+    {
+        if (!Schema::hasTable('menus')) return;
+        $ids = DB::table('menus')->whereIn('uri', array_merge([self::MENU_URI], array_keys(self::BUTTONS)))->pluck('id');
+        if (Schema::hasTable('role_menu')) DB::table('role_menu')->whereIn('menu_id', $ids)->delete();
+        DB::table('menus')->whereIn('id', $ids)->delete();
+    }
+};

+ 1 - 0
docs/前端交付/bot-28/后台/README.md

@@ -16,6 +16,7 @@
 | 转盘任务、概率配置与记录 | [转盘活动-后台接口](独立活动/转盘活动-后台接口.md) |
 | 报表页面修正:移除三个入口,统一有效会员条件 | [报表页面调整-移除三个入口](报表页面调整-移除三个入口.md) |
 | 报表汇总:营业/推广/分享页签及明细 | [报表汇总-前端接入](报表汇总-前端接入.md) |
+| 提现打码量与冻结金额:会员列表打码量、上下分打码量、冻结/解冻、冻结记录(2026-11-04) | [打码量与冻结金额-后台接口](打码量与冻结金额-后台接口.md) |
 | 资金流动:区间总计与每日列表 | [资金流动-前端接入](资金流动-前端接入.md) |
 | 龙虎榜:五类排名与分页 | [龙虎榜-前端接入](龙虎榜-前端接入.md) |
 | 三份报表接入文档目录 | [报表文档目录](龙虎榜资金流动与报表-接口.md) |

+ 106 - 0
docs/前端交付/bot-28/后台/打码量与冻结金额-后台接口.md

@@ -0,0 +1,106 @@
+# 打码量与冻结金额:后台接口
+
+All endpoints in this doc are served by the bot-28 admin service and use admin authentication. A successful call returns `code=0`. All amounts are strings with 2 decimal places.
+
+## Business rules
+
+| Item | Rule |
+|---|---|
+| 打码量 (wagering) | Each user has one current cycle. **Required wagering** is the sum of every requirement: deposit/人工充值 × the platform multiple, the wagering configured on each activity, and wagering entered by admins. **Current wagering** (当前打码量) is the effective bets placed in this cycle. **Remaining wagering** (剩余打码量) is required minus current, floored at 0. |
+| Withdrawal | Withdrawal is blocked while remaining wagering is above 0, so a single unfinished activity requirement is enough to block it. After a successful withdrawal request, the cycle resets: required and current both go back to 0. |
+| Effective bet | Same rule as activity turnover: settled, non-voided bets, counted as min(stake, \|profit/loss\|). The cache refreshes every minute. |
+| Platform multiple | Config field `withdraw_wager_multiple` (提现打码倍数), default `1`. Edit it in the existing system config list. |
+| Activity rewards | All rewards go straight into **available balance** and no longer use frozen balance. Rewards with a configured wagering requirement add it; rewards without one can be withdrawn immediately and do not use the platform multiple. |
+| Withdrawal freeze | On a withdrawal request, available balance moves into frozen balance. If the request is rejected or fails, the money returns to available balance. If it succeeds, the money is deducted from frozen balance. |
+
+## 1. 会员列表 new fields
+
+`GET /admin/user` (existing endpoint). Each `data.data[]` row gains:
+
+| Field | Meaning |
+|---|---|
+| `wager_required` | Required wagering in the current cycle |
+| `wager_completed` | 当前打码量 (current wagering) |
+| `wager_remaining` | 剩余打码量 (remaining wagering); the user can withdraw when this is 0 |
+| `total_consume` | Unchanged: lifetime bet volume (the old 打码量 column) |
+| `wallet.frozen_balance` | Frozen balance: manual freezes plus withdrawals under review |
+
+Each `data.data[]` row in the 人工稽查 list `GET /admin/manualAudit` gains a `member_wager` object with the same three fields, plus `wager_amount`, the signed wagering change made by that operation.
+
+## 2. 上下分: wagering params
+
+### 人工稽查
+
+`POST /admin/manualAudit/topUp`, adding to the existing params:
+
+| Param | Required | Notes |
+|---|---|---|
+| `wager_amount` | No | Only used when `change_type` is not `人工充值`. Wagering to add, ≥0; empty means 0 (withdrawable immediately). With `人工充值`, the system adds amount × platform multiple and ignores this param. |
+
+`POST /admin/manualAudit/debit`, adding to the existing params:
+
+| Param | Required | Notes |
+|---|---|---|
+| `recharge_type` | No | Credit type this debit corresponds to: `人工充值` / `注册赠送` / `优惠活动`. Omit it to leave wagering unchanged. |
+| `wager_amount` | No | Used only when `recharge_type` is not `人工充值`: the wagering to deduct, ≥0. With `人工充值`, the system deducts amount × platform multiple. |
+
+The list endpoint `options` gains `platform_wager_type` (`人工充值`) and `withdraw_wager_multiple` (the current multiple), so the form can preview the result. The balance log for a debit still uses change type `人工扣款`.
+
+### Member list: legacy 上下分
+
+`POST /admin/wallet/topUp` takes `wager_amount`, and `POST /admin/wallet/debiting` takes `recharge_type` and `wager_amount`. Both follow the same rules as above.
+
+## 3. Freeze / unfreeze
+
+Same params and response for both. The member list and 人工稽查 each have their own permission URI:
+
+| Action | Member list | 人工稽查 |
+|---|---|---|
+| Freeze | `POST /admin/user/freeze` | `POST /admin/manualAudit/freeze` |
+| Unfreeze | `POST /admin/user/unfreeze` | `POST /admin/manualAudit/unfreeze` |
+
+| Param | Required | Notes |
+|---|---|---|
+| `request_id` | Yes | UUID generated by the front end per submission. Resubmitting the same ID returns the original result; submitting it with different content is rejected. |
+| `member_id` / `account` | One of the two | Same as 人工稽查 |
+| `amount` | Yes | Amount, ≥0.01 |
+| `wager_amount` | Yes | Wagering, ≥0. Freezing **reduces** wagering by this amount; unfreezing **increases** it. |
+| `reason` | Yes | Reason, at most 255 characters |
+
+- Freeze: moves available balance into frozen balance. Fails if available balance is too low.
+- Unfreeze: moves frozen balance back to available balance. The cap is the user's **outstanding manual freeze amount**; money frozen for a withdrawal under review cannot be unfrozen manually. When over the cap, the error says `可解冻金额不足,最多可解冻:X`.
+- Each operation writes a balance log of type `人工冻结` (−amount) or `人工解冻` (+amount).
+
+Response `data` (a single freeze record; same fields as the list below):
+
+```json
+{ "id": 12, "member_id": "6001", "account": "tg123", "type": "manual_freeze", "type_text": "人工冻结",
+  "amount": "30.00", "wager_amount": "40.00", "reason": "风控冻结", "status": "frozen", "status_text": "已冻结",
+  "related_type": null, "related_id": null, "admin_id": 1, "admin_name": "admin",
+  "before_available": "100.00", "after_available": "70.00", "before_frozen": "0.00", "after_frozen": "30.00",
+  "finished_at": null, "created_at": "2026-11-04 15:00:00" }
+```
+
+## 4. Freeze record list
+
+`GET /admin/walletFreeze`
+
+| Param | Notes |
+|---|---|
+| `page`, `limit` | `limit` ≤200, default 20 |
+| `member_id`, `account` | Filter by member |
+| `type` | `manual_freeze` 人工冻结 / `manual_unfreeze` 人工解冻 / `withdraw_hold` 提现冻结 |
+| `status` | `frozen` 已冻结 / `done` 已解冻 / `holding` 提现审核中 / `released` 已退回余额 / `settled` 提现成功已扣除 |
+| `start_date`, `end_date` | Y-m-d, pass both together |
+
+Response: `data.total`, `data.data[]` (fields as in section 3), and `data.options.types` / `data.options.statuses` (each item has `label` and `value`). For a withdrawal freeze, `related_type` is `withdraws` (USDT) or `payment_orders` (RMB) and `related_id` is the withdrawal order ID.
+
+## 5. Permissions and menu
+
+The migration adds a 会员管理 → 冻结记录 menu (front-end route key `walletFreeze`) and these buttons:
+
+- `admin/walletFreeze`
+- `admin/user/freeze`, `admin/user/unfreeze`
+- `admin/manualAudit/freeze`, `admin/manualAudit/unfreeze`
+
+After launch, assign these buttons to the roles that need them. Roles that don't have them cannot call these endpoints; the super admin is not restricted.

+ 59 - 0
docs/前端接口/提现打码量与冻结记录-前台接入.md

@@ -0,0 +1,59 @@
+# 提现打码量与冻结记录:前台接入
+
+The endpoints in this doc are served by `melbet_sport-api` and require member login.
+
+## Rules
+
+- **Before withdrawing, all wagering must be complete.** Required wagering is the sum of: deposits × the platform multiple, plus each activity's configured wagering, plus any wagering an admin entered. It is met with effective bets. If any amount remains, withdrawal is blocked.
+- Once a withdrawal request goes through, the current wagering and remaining wagering both reset to 0, and a new cycle starts.
+- Activity rewards go straight into available balance. Rewards with no wagering requirement can be withdrawn immediately.
+- When a withdrawal is requested, the amount moves into **frozen balance**. If the request is rejected or withdrawn, the money returns to available balance. If it succeeds, the money is deducted from frozen balance.
+- The balance endpoints show current wagering refreshed about once a minute. A withdrawal request recomputes it live, but a bet only counts once it is settled and synced into the bet records (usually within a minute).
+
+## 1. Account balance
+
+`GET /api/wallet/getUsdt` (existing endpoint). `data` gains these fields:
+
+| Field | Meaning |
+|---|---|
+| `money` | Available balance (existing) |
+| `frozen_money` | Frozen balance (existing): withdrawals under review plus admin freezes |
+| `total_money` | Total balance = available + frozen |
+| `wager_required` | Required wagering in the current cycle |
+| `wager_completed` | Current wagering: effective bets placed in this cycle |
+| `wager_remaining` | Remaining wagering; the user can withdraw when this is `0.00` |
+
+The user info endpoint (`UserLogic::info`, the existing endpoint) returns the same `total_money`, `wager_required`, `wager_completed` and `wager_remaining`.
+
+## 2. Withdrawal
+
+`POST /api/wallet/withdraw` (existing endpoint, parameters unchanged). When wagering is incomplete, the call returns a failure with the message `打码量未完成,剩余打码量:50.00`. Show this message as-is, or show `wager_remaining` from the balance endpoint.
+
+Withdrawal cancellation, `withdrawCancel`, is unchanged; frozen money returns to available balance.
+
+## 3. Freeze records
+
+```http
+GET /api/wallet/frozenRecords?page=1&limit=20
+```
+
+| Parameter | Notes |
+|---|---|
+| `page`, `limit` | Same as other list endpoints |
+| `type` | Optional: `manual_freeze` (人工冻结) / `manual_unfreeze` (人工解冻) / `withdraw_hold` (提现冻结) |
+| `start_time`, `end_time` | Optional, Y-m-d |
+
+Response `data`: `count` (total), `list` (records), `extend.type_list` (type filter options). Each item in `data.list[]`:
+
+| Field | Meaning |
+|---|---|
+| `id` | Record ID |
+| `type` / `type_text` | Type and its display text |
+| `amount` | Amount |
+| `wager_amount` | Wagering change: an admin freeze reduces wagering, an unfreeze increases it |
+| `reason` | Reason |
+| `status` / `status_text` | `frozen` 已冻结, `done` 已解冻, `holding` 提现审核中, `released` 已退回余额, `settled` 提现成功已扣除 |
+| `related_type`, `related_id` | For withdrawal freezes, the matching withdrawal order (`withdraws` for USDT, `payment_orders` for RMB) |
+| `created_at` | Freeze time |
+| `finished_at` | When a withdrawal freeze was returned or deducted |
+

+ 58 - 0
docs/后端/提现打码量与冻结金额-部署说明.md

@@ -0,0 +1,58 @@
+# 提现打码量与冻结金额:部署说明
+
+This change spans bot-28 and melbet_sport-api, which share one database. **Deploy in this order:**
+
+1. bot-28: run the migrations.
+   - `2026_11_04_140000_create_wagering_and_wallet_freezes` creates the wallet wagering columns, `wager_records`, `wallet_freeze_records`, `frozen_amount` on withdrawal orders, `manual_audits.wager_amount`, config `withdraw_wager_multiple=1`, and `updated_at` indexes on `share_bet_facts`, `bets` and `jisu_game_order`. It also backfills each wallet's cycle start to its most recent withdrawal time (or the wallet's creation time if it has none).
+   - `2026_11_04_141000_add_wallet_freeze_admin_menus` adds the 冻结记录 menu and the freeze/unfreeze buttons.
+2. Deploy the bot-28 code.
+3. Deploy melbet_sport-api. Its code checks whether the wallet has the new columns; before the migration has run, it keeps the old behaviour.
+4. Initial calculation: run `php artisan wagering:sync --all` once. After that the scheduler runs `wagering:sync` every minute.
+5. Migrate frozen rewards, dry run first:
+   ```bash
+   php artisan wagering:migrate-frozen-rewards --dry-run -v
+   php artisan wagering:migrate-frozen-rewards
+   ```
+6. In the admin role settings, assign the new buttons: `admin/walletFreeze`, `admin/user/freeze|unfreeze`, `admin/manualAudit/freeze|unfreeze`.
+
+## Behaviour changes
+
+| Area | Before | Now |
+|---|---|---|
+| Deposits (USDT, third-party, approved manual deposits, admin 人工充值) | No wagering | Adds amount × `withdraw_wager_multiple` |
+| 即充即送 / 老用户回归 | Credited to frozen balance, unfrozen at 1x of the bet amount | Credited to available balance. With a wagering multiple configured, adds bonus × multiple; without one, **adds no wagering** (the old 1x fallback is gone) |
+| New activities (first deposit, net-loss invite, wheel) | Rewards with turnover were `locked` in frozen balance | Credited to available balance and the status is set straight to `paid`. `required_flow` counts toward wagering |
+| 笔笔返 / 回水 | Credited to available balance; wagering never enforced | Unchanged, but `need_flow` now counts toward wagering |
+| Applied activities (activity_user) | Withdrawals blocked while in progress (only PC28 bets counted) | Gift wagering counts toward the unified wagering. The Telegram withdrawal check now uses the unified wagering |
+| Withdrawal request | Deducted from available balance | Checks wagering, moves available balance to frozen balance, writes a `withdraw_hold` record, resets the cycle |
+| Rejection / provider failure / user cancellation | Refunded to available balance | Returned from frozen balance to available balance |
+| Approval / provider success callback | No money movement | Deducted from frozen balance |
+| Orders pending at deploy time (`frozen_amount=0`) | — | Keep the old logic: a rejection refunds to available balance, a success deducts nothing |
+
+Balance log change types for deductions and refunds are unchanged (`提现` / `三方提现` / `人工提现`), so reporting definitions are unaffected. New types `人工冻结` and `人工解冻` are added; reports do not count them.
+
+## Related fixes
+
+- The log amount for a rejected USDT withdrawal was empty; it is now recorded correctly.
+- The QianBao payout callback had no status guard, so a repeated callback could refund twice. It now only processes orders that are in progress.
+- 人工稽查 top-ups and the legacy member-list top-up now save the wallet before writing the balance log. Previously the first-deposit reward and other deposit hooks wrote to the wallet in the same transaction and were overwritten by the caller's later save.
+- Every withdrawal request, rejection and callback path now locks the wallet row.
+- melbet bank withdrawals wrote the ledger key `transaction_type` instead of `change_type`, so the debit had no change type. It is now logged as `三方提现` (the same type bot-28 uses for payout orders and their refunds) and linked to its order through `related_id`.
+- Withdrawal requests recompute current wagering live outside the wallet lock (bot-28 with `PromotionFacts`, melbet with its port of it); only the cached value is checked under the lock.
+- If `wagering:sync` fails for some members, the time window still moves forward and those members are retried on the next run.
+
+## What the migration command does
+
+`wagering:migrate-frozen-rewards`:
+
+- Still-frozen 即充即送 / 老用户回归 logs (`type=2, frozen_status=1`): moved to available balance with paired `流水解冻` logs. Wagering added is the matching `wallet_bonus.need_flow`, or 1x the bonus if there is none, minus the user's old `wallets.flow_balance` carry-over, which is then reset to 0.
+- New-activity rewards still `locked`: moved to available balance and set to `paid`. Wagering added is `required_flow` minus effective bets since the reward was paid, floored at 0.
+- In-progress `activity_user` rows: no money moves; the remaining wagering (`betting_amount` − `effective_betting_amount`) is added.
+- Each processed source writes a `wager_records` row with `source=migration`, so running the command again does not reprocess anything. If a user's frozen balance is too low, that user is skipped with a warning and the command exits with a failure code; that user needs manual handling.
+- 余额宝利息 (Yuebao interest) is not an activity, so it stays on melbet's old 1x unlock.
+
+## Troubleshooting
+
+- Wagering changes for a user: `wager_records`, filtered by `member_id`. Values for `source`: `recharge`, `activity:*`, `manual_credit` / `manual_debit`, `freeze` / `unfreeze`, `migration`, `reset`.
+- Recalculate one user: `php artisan wagering:sync --member=<member ID>`.
+- Withdrawal freezes: `wallet_freeze_records` where `type='withdraw_hold'`, joined to the order through `related_type` and `related_id`.

+ 7 - 0
routes/admin.php

@@ -47,6 +47,7 @@ use App\Http\Controllers\admin\Egame;
 use App\Http\Controllers\admin\Online;
 use App\Http\Controllers\admin\Funds;
 use App\Http\Controllers\admin\ManualAudit;
+use App\Http\Controllers\admin\WalletFreeze;
 use App\Http\Controllers\admin\ThirdPartyDeposit;
 use App\Http\Controllers\admin\ThirdGameOrder;
 use App\Http\Controllers\admin\Agent as AgentAdmin;
@@ -381,8 +382,12 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::get('/detail', [ManualAudit::class, 'detail']);
             Route::post('/topUp', [ManualAudit::class, 'topUp']);
             Route::post('/debit', [ManualAudit::class, 'debit']);
+            Route::post('/freeze', [WalletFreeze::class, 'freeze']);
+            Route::post('/unfreeze', [WalletFreeze::class, 'unfreeze']);
         });
 
+        Route::get('/walletFreeze', [WalletFreeze::class, 'index']);
+
         Route::prefix('/thirdPartyDeposit')->group(function () {
             Route::get('/', [ThirdPartyDeposit::class, 'index']);
             Route::get('/detail', [ThirdPartyDeposit::class, 'detail']);
@@ -448,6 +453,8 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::get('/registerDomain', [User::class, 'registerDomain']);
             Route::post('/setRechargeChannelGroup', [User::class, 'setRechargeChannelGroup']);
             Route::post('/setPassword', [User::class, 'setPassword']);
+            Route::post('/freeze', [WalletFreeze::class, 'freeze']);
+            Route::post('/unfreeze', [WalletFreeze::class, 'unfreeze']);
             Route::get('/thirdGame/detail', [User::class, 'thirdGameDetail']);
             Route::post('/thirdGame/recycle', [User::class, 'recycleThirdGameBalance']);
             Route::get('/thirdGame/recycleRecords', [User::class, 'thirdGameRecycleRecords']);

+ 13 - 11
tests/Integration/ActivityRewardEligibilityTest.php

@@ -725,12 +725,14 @@ class ActivityRewardEligibilityTest extends TestCase
         ]);
         $this->request('/admin/paymentOrder/manualAudit', ['id' => 900, 'status' => 2], 'POST');
         $this->assertSame(0, (new AdminPaymentOrder())->manualAudit()->getData(true)['code']);
-        $this->assertEquals('200.00', DB::table('wallets')->value('available_balance'));
-        $this->assertEquals('14.00', DB::table('wallets')->value('frozen_balance'));
+        // 活动赠送直接进入可用余额,不再走冻结金额
+        $this->assertEquals('212.00', DB::table('wallets')->value('available_balance'));
+        $this->assertEquals('2.00', DB::table('wallets')->value('frozen_balance'));
         $this->assertSame(1, (int)$recharge->fresh()->rewarded_count);
         $this->assertSame(1, (int)$return->fresh()->rewarded_count);
         $this->assertSame(2, DB::table('wallet_bonus')->where('related_id', 900)->count());
-        $this->assertSame(2, DB::table('balance_logs')->where('type', 2)->where('related_id', 900)->count());
+        $this->assertSame(0, DB::table('balance_logs')->where('type', 2)->count());
+        $this->assertSame(2, DB::table('balance_logs')->where('type', 1)->where('related_id', 900)->whereIn('change_type', ['即充即送', '老用户回归'])->count());
         $this->assertSame('充值', DB::table('balance_logs')->where('type', 1)->value('change_type'));
     }
 
@@ -740,7 +742,7 @@ class ActivityRewardEligibilityTest extends TestCase
         [$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
+                WHEN NEW.available_balance > OLD.available_balance
                 BEGIN SELECT RAISE(ABORT, 'injected reward write failure'); END");
         } else {
             DB::statement("CREATE TRIGGER fail_reward_ledger BEFORE INSERT ON bot_balance_logs
@@ -759,7 +761,7 @@ class ActivityRewardEligibilityTest extends TestCase
         $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->assertSame(0, DB::table('balance_logs')->whereIn('change_type', ['即充即送', '老用户回归'])->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.
@@ -774,13 +776,13 @@ class ActivityRewardEligibilityTest extends TestCase
         $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->assertEquals('2.00', DB::table('wallets')->value('frozen_balance'));
+        $this->assertEquals('112.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->assertEquals('12.00', DB::table('balance_logs')->where('type', 1)->whereIn('change_type', ['即充即送', '老用户回归'])->sum('amount'));
+        $this->assertEquals('100.00', DB::table('balance_logs')->where('change_type', '即充即送')->value('before_balance'));
+        $this->assertEquals('105.00', DB::table('balance_logs')->where('change_type', '老用户回归')->value('before_balance'));
         $this->assertSame(1, DB::table('balance_logs')->where('change_type', '充值')->count());
     }
 
@@ -790,7 +792,7 @@ class ActivityRewardEligibilityTest extends TestCase
         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'));
+        $this->assertEquals('112.00', DB::table('wallets')->value('available_balance'));
         DB::rollBack();
         $this->assertSame(0, (int)$recharge->fresh()->rewarded_count);
         $this->assertSame(0, (int)$return->fresh()->rewarded_count);

+ 9 - 6
tests/Integration/PromotionActivitiesTest.php

@@ -498,15 +498,18 @@ class PromotionActivitiesTest extends TestCase
         $this->travel('2026-10-27 00:00:00'); $this->assertSame(2, $facts->policy()['id']);
     }
 
-    public function test_turnover_rewards_only_unlock_from_new_bets_and_only_once(): void
+    public function test_turnover_rewards_are_paid_to_available_balance_and_never_locked(): void
     {
+        // 活动奖金不再冻结:直接进入可用余额,流水要求计入统一提现打码量(required_flow 仍保留在奖励记录上)
         $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25, 'turnover_ratio' => 2]);
-        $this->bet(1, '100', '-100', '2026-10-26 12:00:00');
-        $this->service->onDeposit($this->deposit(1, '100')); $this->assertSame(0, $this->service->unlock(1));
-        $this->bet(1, '1000', '-49', '2026-10-26 12:00:00'); $this->assertSame(0, $this->service->unlock(1));
-        $this->bet(1, '1', '-1', '2026-10-26 12:00:00'); $this->assertSame(1, $this->service->unlock(1)); $this->assertSame(0, $this->service->unlock(1));
+        $this->service->onDeposit($this->deposit(1, '100'));
+        $reward = DB::table('promotion_rewards')->where('activity_id', $id)->first();
+        $this->assertSame('paid', $reward->status);
+        $this->assertSame(0, bccomp('50', (string)$reward->required_flow, 2));
         $this->assertSame('25.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
-        $this->assertSame('0.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('frozen_balance'));
+        $this->assertSame('0', (string)DB::table('wallets')->where('member_id', 'm1')->value('frozen_balance'));
+        $this->assertSame(1, (int)DB::table('balance_logs')->where('change_type', '优惠活动')->where('type', 1)->count());
+        $this->bet(1, '1000', '-100', '2026-10-26 12:00:00'); $this->assertSame(0, $this->service->unlock(1));
     }
 
     public function test_quota_is_checked_before_consuming_a_spin_and_a_win_reserves_its_slot(): void

+ 343 - 0
tests/Integration/WageringTest.php

@@ -0,0 +1,343 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Console\Commands\MigrateFrozenRewards;
+use App\Models\Wallet;
+use App\Models\Withdraw;
+use App\Services\BalanceLogService;
+use App\Services\ManualAuditService;
+use App\Services\WageringService;
+use App\Services\WalletFreezeService;
+use App\Services\WithdrawFundsService;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Foundation\Application;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Input\ArrayInput;
+use Symfony\Component\Console\Output\BufferedOutput;
+
+/** 统一提现打码量、人工冻结/解冻与提现冻结,直接跑真实服务和上线迁移(SQLite)。 */
+class WageringTest 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' => ['timezone' => 'Asia/Shanghai'], 'third_game' => ['currency' => 'CNY']]));
+        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());
+        $schema = $db->schema();
+        $schema->create('users', function (Blueprint $t) {
+            $t->id(); $t->string('member_id')->unique(); $t->string('account')->default(''); $t->string('username')->default('');
+            $t->string('first_name')->default(''); $t->integer('from')->default(1); $t->integer('level')->default(0);
+            $t->integer('recharge_channel_group_id')->default(1); $t->string('agent_user_code')->nullable(); $t->string('user_code')->nullable();
+            $t->string('language')->default('zh'); $t->timestamps();
+        });
+        $schema->create('wallets', function (Blueprint $t) {
+            $t->id(); $t->unsignedBigInteger('user_id')->nullable(); $t->string('member_id')->unique();
+            $t->decimal('available_balance', 30, 10)->default(0); $t->decimal('frozen_balance', 30, 10)->default(0);
+            $t->decimal('flow_balance', 10, 2)->default(0); $t->timestamps();
+        });
+        $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', 30, 10);
+            $t->decimal('before_balance', 30, 10)->nullable(); $t->decimal('after_balance', 30, 10)->nullable();
+            $t->integer('related_id')->nullable(); $t->integer('room_id')->nullable(); $t->integer('frozen_status')->nullable();
+            $t->text('remark')->nullable(); $t->timestamps();
+        });
+        $schema->create('config', function (Blueprint $t) {
+            $t->id(); $t->string('field'); $t->string('val'); $t->string('remark')->default(''); $t->integer('group_id')->default(1); $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')->nullable(); $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();
+        });
+        $schema->create('withdraws', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->decimal('amount', 30, 10); $t->decimal('service_charge', 30, 10)->default(0);
+            $t->decimal('to_account', 30, 10)->default(0); $t->string('address')->default(''); $t->decimal('exchange_rate', 20, 4)->default(1);
+            $t->integer('status')->default(0); $t->decimal('after_balance', 30, 10)->nullable(); $t->string('remark')->nullable(); $t->timestamps();
+        });
+        $schema->create('payment_orders', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->integer('type'); $t->integer('status'); $t->decimal('amount', 30, 10); $t->timestamps();
+        });
+        $schema->create('share_bet_facts', function (Blueprint $t) {
+            $t->id(); $t->string('source', 32); $t->string('source_id', 64); $t->unsignedBigInteger('user_id'); $t->string('member_id', 64);
+            $t->string('category', 32)->default('lottery'); $t->dateTime('bet_at'); $t->date('bet_date'); $t->dateTime('settled_at')->nullable();
+            foreach (['bet_amount', 'net_profit', 'effective_amount'] as $c) $t->decimal($c, 24, 10)->default(0);
+            $t->boolean('settled')->default(true); $t->boolean('voided')->default(false); $t->string('currency', 8)->default('CNY');
+            $t->string('source_version', 64)->default('1'); $t->timestamps();
+        });
+        $schema->create('admin', function (Blueprint $t) { $t->id(); $t->string('username'); $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('admin')->insert(['id' => 9, 'username' => 'auditor']);
+        DB::table('recharge_channel_group')->insert(['id' => 1, 'activity_type' => '']);
+        DB::table('users')->insert(['id' => 1, 'member_id' => 'm1', 'account' => 'acc1', 'created_at' => '2026-01-01 00:00:00']);
+        DB::table('wallets')->insert(['user_id' => 1, 'member_id' => 'm1', 'available_balance' => '100', 'created_at' => '2026-01-01 00:00:00']);
+        WageringService::flushSchemaCache();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_04_140000_create_wagering_and_wallet_freezes.php')->up();
+        WageringService::flushSchemaCache();
+        DB::table('config')->where('field', WageringService::CONFIG_FIELD)->update(['val' => '2']);
+    }
+
+    protected function tearDown(): void
+    {
+        WageringService::flushSchemaCache();
+        DB::disconnect();
+        Facade::clearResolvedInstances();
+        parent::tearDown();
+    }
+
+    private function wallet(): Wallet
+    {
+        return Wallet::where('member_id', 'm1')->first();
+    }
+
+    private function bet(string $stake, string $profit): void
+    {
+        static $id = 0;
+        $now = now('Asia/Shanghai')->format('Y-m-d H:i:s');
+        DB::table('share_bet_facts')->insert(['source' => 'lhc_order_item', 'source_id' => (string)++$id, 'user_id' => 1, 'member_id' => 'm1',
+            'bet_at' => $now, 'bet_date' => substr($now, 0, 10), 'bet_amount' => $stake, 'net_profit' => $profit, 'created_at' => $now, 'updated_at' => $now]);
+    }
+
+    private function hold(string $amount): Withdraw
+    {
+        [$order] = WithdrawFundsService::hold('m1', $amount, '提现', '', WithdrawFundsService::WITHDRAWS,
+            fn ($before, $after) => Withdraw::create(['member_id' => 'm1', 'amount' => $amount, 'status' => 0, 'after_balance' => $after]));
+        return $order;
+    }
+
+    private function money($value): string
+    {
+        return bcadd((string)$value, '0', 2);
+    }
+
+    private function assertBlocked(callable $work, string $message): void
+    {
+        try { $work(); $this->fail('Expected: ' . $message); }
+        catch (\Exception $e) { $this->assertStringContainsString($message, $e->getMessage()); }
+    }
+
+    public function test_migration_starts_a_cycle_and_configures_the_platform_multiple(): void
+    {
+        $wallet = $this->wallet();
+        $this->assertSame('2026-01-01 00:00:00', (string)$wallet->wager_cycle_from);
+        $this->assertSame('[]', (string)$wallet->wager_cycle_cursors);
+        $this->assertSame('2.0000000000', WageringService::multiple());
+        $this->assertSame(1, DB::table('config')->where('field', WageringService::CONFIG_FIELD)->count());
+    }
+
+    public function test_deposit_requirement_blocks_withdrawal_until_effective_bets_complete_it_then_resets(): void
+    {
+        BalanceLogService::addLog('m1', '100', '0', '100', '人工充值', null, '');
+        $this->assertSame('200.00', $this->money($this->wallet()->wager_required));
+        $this->assertSame('recharge', DB::table('wager_records')->value('source'));
+
+        $this->bet('150', '-150');
+        $this->assertBlocked(fn () => $this->hold('50'), '剩余打码量:50.00');
+        $this->bet('100', '1'); // 有效投注 = min(本金, |盈亏|) = 1
+        $this->assertBlocked(fn () => $this->hold('50'), '剩余打码量:49.00');
+        $this->bet('49', '49');
+        $this->assertSame(['wager_required' => '200.00', 'wager_completed' => '200.00', 'wager_remaining' => '0.00'],
+            WageringService::summary(tap($this->wallet(), fn ($w) => WageringService::refresh($w))));
+
+        $order = $this->hold('50');
+        $wallet = $this->wallet();
+        $this->assertSame('50.00', $this->money($wallet->available_balance));
+        $this->assertSame('50.00', $this->money($wallet->frozen_balance));
+        $this->assertSame('50.00', $this->money($order->fresh()->frozen_amount));
+        $this->assertSame(['wager_required' => '0.00', 'wager_completed' => '0.00', 'wager_remaining' => '0.00'], WageringService::summary($wallet));
+        $this->assertSame(1, (int)$wallet->wager_cycle_id);
+        $this->assertSame('holding', DB::table('wallet_freeze_records')->where('type', 'withdraw_hold')->value('status'));
+        $this->assertSame('-50.00', $this->money(DB::table('balance_logs')->where('change_type', '提现')->value('amount')));
+        // 新周期只统计重置后的新注单
+        WageringService::refresh($wallet);
+        $this->assertSame('0.00', $this->money($wallet->fresh()->wager_completed));
+    }
+
+    public function test_requirements_from_several_sources_add_up_and_any_unfinished_one_blocks(): void
+    {
+        $wallet = $this->wallet();
+        WageringService::addRequirement($wallet, '30', 'activity:即充即送', 1);
+        WageringService::addRequirement($wallet, '20', 'activity:promotion', 2);
+        $this->bet('45', '-45');
+        $this->assertBlocked(fn () => $this->hold('10'), '剩余打码量:5.00');
+        $this->bet('5', '-5');
+        $this->hold('10');
+        $this->assertSame('90.00', $this->money($this->wallet()->available_balance));
+    }
+
+    public function test_requirement_increment_is_kept_when_the_caller_holds_a_stale_wallet(): void
+    {
+        $stale = $this->wallet();
+        WageringService::addRequirementFor('m1', '50', 'recharge', 1);
+        WageringService::addRequirement($stale, '20', 'activity:promotion', 2);
+        $this->assertSame('70.00', $this->money($this->wallet()->wager_required));
+        $this->assertSame('50.00', $this->money(DB::table('wager_records')->where('source_id', '2')->value('before_required')));
+    }
+
+    public function test_reward_without_requirement_is_withdrawable_and_requirement_never_goes_negative(): void
+    {
+        $wallet = $this->wallet();
+        $this->assertNull(WageringService::addRequirement($wallet, '0', 'activity:即充即送', 1));
+        WageringService::addRequirement($wallet, '-5', 'freeze', 1);
+        $this->assertSame('0.00', $this->money($wallet->fresh()->wager_required));
+        $this->hold('10');
+        $this->assertSame('90.00', $this->money($this->wallet()->available_balance));
+    }
+
+    public function test_reject_releases_and_approve_settles_the_frozen_withdrawal_exactly_once(): void
+    {
+        $order = $this->hold('40');
+        $this->assertNotNull(WithdrawFundsService::release(WithdrawFundsService::WITHDRAWS, $order, '999', '提现', ''));
+        $this->assertNull(WithdrawFundsService::release(WithdrawFundsService::WITHDRAWS, $order, '999', '提现', ''));
+        $wallet = $this->wallet();
+        $this->assertSame('100.00', $this->money($wallet->available_balance));
+        $this->assertSame('0.00', $this->money($wallet->frozen_balance));
+        $this->assertSame('released', DB::table('wallet_freeze_records')->value('status'));
+        $this->assertSame('40.00', $this->money(DB::table('balance_logs')->where('change_type', '提现')->where('amount', '>', 0)->value('amount')));
+
+        $order = $this->hold('30');
+        WithdrawFundsService::settle(WithdrawFundsService::WITHDRAWS, $order);
+        WithdrawFundsService::settle(WithdrawFundsService::WITHDRAWS, $order);
+        $wallet = $this->wallet();
+        $this->assertSame('70.00', $this->money($wallet->available_balance));
+        $this->assertSame('0.00', $this->money($wallet->frozen_balance));
+        $this->assertSame('settled', DB::table('wallet_freeze_records')->orderByDesc('id')->value('status'));
+    }
+
+    public function test_orders_created_before_launch_keep_the_old_refund_behaviour(): void
+    {
+        $legacy = Withdraw::create(['member_id' => 'm1', 'amount' => '25', 'status' => 0]);
+        WithdrawFundsService::settle(WithdrawFundsService::WITHDRAWS, $legacy);
+        $this->assertSame('100.00', $this->money($this->wallet()->available_balance));
+        WithdrawFundsService::release(WithdrawFundsService::WITHDRAWS, $legacy, '25', '提现', '');
+        $wallet = $this->wallet();
+        $this->assertSame('125.00', $this->money($wallet->available_balance));
+        $this->assertSame('0.00', $this->money($wallet->frozen_balance));
+    }
+
+    public function test_manual_freeze_and_unfreeze_move_balance_and_adjust_wagering(): void
+    {
+        WageringService::addRequirement($this->wallet(), '100', 'recharge', 1);
+        $frozen = WalletFreezeService::freeze('11111111-1111-4111-8111-111111111111', 9, 'm1', '30', '40', '风控冻结');
+        $this->assertSame('人工冻结', $frozen['type_text']);
+        $wallet = $this->wallet();
+        $this->assertSame('70.00', $this->money($wallet->available_balance));
+        $this->assertSame('30.00', $this->money($wallet->frozen_balance));
+        $this->assertSame('60.00', $this->money($wallet->wager_required));
+        $this->assertSame('-30.00', $this->money(DB::table('balance_logs')->where('change_type', '人工冻结')->value('amount')));
+
+        // 提现冻结的资金不能被人工解冻
+        $this->bet('60', '-60');
+        $this->hold('10');
+        $this->assertSame('40.00', $this->money($this->wallet()->frozen_balance));
+        $this->assertBlocked(fn () => WalletFreezeService::unfreeze('22222222-2222-4222-8222-222222222222', 9, 'm1', '35', '0', 'x'), '最多可解冻:30.00');
+
+        WalletFreezeService::unfreeze('33333333-3333-4333-8333-333333333333', 9, 'm1', '20', '15', '核实解冻');
+        $wallet = $this->wallet();
+        $this->assertSame('80.00', $this->money($wallet->available_balance));
+        $this->assertSame('20.00', $this->money($wallet->frozen_balance));
+        $this->assertSame('15.00', $this->money($wallet->wager_required)); // 提现已把周期重置为0,解冻再加15
+        $this->assertSame('10.0000000000', WalletFreezeService::manualFrozen('m1'));
+
+        // 同一 request_id 重放返回原记录,不同内容拒绝
+        $replay = WalletFreezeService::unfreeze('33333333-3333-4333-8333-333333333333', 9, 'm1', '20', '15', '核实解冻');
+        $this->assertSame(3, DB::table('wallet_freeze_records')->whereIn('type', ['manual_freeze', 'manual_unfreeze', 'withdraw_hold'])->count());
+        $this->assertSame('20.00', $replay['amount']);
+        $this->assertBlocked(fn () => WalletFreezeService::unfreeze('33333333-3333-4333-8333-333333333333', 9, 'm1', '21', '15', 'x'), 'request_id');
+
+        $list = WalletFreezeService::list(['member_id' => 'm1']);
+        $this->assertSame(3, $list['total']);
+        $this->assertSame('acc1', $list['data'][0]['account']);
+        $this->assertSame('auditor', $list['data'][0]['admin_name']);
+    }
+
+    public function test_manual_balance_adjustment_wagering_rules(): void
+    {
+        $this->assertSame([WageringService::platformRequirement('10'), false], ManualAuditService::wagerDelta(false, '10', '人工充值', null, '999'));
+        $this->assertSame('20.0000000000', WageringService::platformRequirement('10'));
+        $this->assertSame(['30.0000000000', true], ManualAuditService::wagerDelta(false, '10', '注册赠送', null, '30'));
+        $this->assertSame(['0.0000000000', true], ManualAuditService::wagerDelta(false, '10', '优惠活动', null, null));
+        $this->assertSame(['-20.0000000000', true], ManualAuditService::wagerDelta(true, '10', '人工扣款', '人工充值', '999'));
+        $this->assertSame(['-5.0000000000', true], ManualAuditService::wagerDelta(true, '10', '人工扣款', '优惠活动', '5'));
+        $this->assertSame(['0', false], ManualAuditService::wagerDelta(true, '10', '人工扣款', null, null));
+    }
+
+    public function test_refresh_never_overwrites_a_cycle_that_was_reset_meanwhile(): void
+    {
+        $stale = $this->wallet();
+        $this->bet('80', '-80');
+        Wallet::where('id', $stale->id)->update(['wager_cycle_id' => 5, 'wager_completed' => 0]);
+        WageringService::refresh($stale);
+        $this->assertSame('0.00', $this->money($this->wallet()->wager_completed));
+        WageringService::refresh($this->wallet());
+        $this->assertSame('80.00', $this->money($this->wallet()->wager_completed));
+    }
+
+    public function test_one_time_migration_moves_frozen_rewards_to_available_and_carries_their_wagering(): void
+    {
+        DB::table('wallets')->where('member_id', 'm1')->update(['frozen_balance' => '25', 'flow_balance' => '4']);
+        DB::table('balance_logs')->insert([
+            ['member_id' => 'm1', 'type' => 2, 'change_type' => '即充即送', 'amount' => '10', 'related_id' => 7, 'frozen_status' => 1],
+            ['member_id' => 'm1', 'type' => 2, 'change_type' => '老用户回归', 'amount' => '10', 'related_id' => 7, 'frozen_status' => 1],
+            ['member_id' => 'm1', 'type' => 2, 'change_type' => '老用户回归', 'amount' => '5', 'related_id' => 8, 'frozen_status' => 1],
+        ]);
+        DB::connection()->getSchemaBuilder()->create('wallet_bonus', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $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();
+        });
+        // 同一笔充值的两条同额奖励各自对应一条 wallet_bonus
+        DB::table('wallet_bonus')->insert([
+            ['member_id' => 'm1', 'activity_id' => 1, 'related_id' => 7, 'amount' => '10', 'need_flow' => '30', 'remaind_flow' => '30', 'status' => 1],
+            ['member_id' => 'm1', 'activity_id' => 2, 'related_id' => 7, 'amount' => '10', 'need_flow' => '10', 'remaind_flow' => '10', 'status' => 1],
+        ]);
+
+        $run = function (array $options) {
+            $command = new MigrateFrozenRewards();
+            $command->setLaravel($this->app);
+            $output = new BufferedOutput();
+            $command->run(new ArrayInput($options), $output);
+            return $output->fetch();
+        };
+        $run(['--dry-run' => true]);
+        $this->assertSame('25.00', $this->money($this->wallet()->frozen_balance));
+        $this->assertSame(0, DB::table('wager_records')->count());
+
+        // 进行中的申请活动:一条是上线前的(迁移剩余打码量),一条已按新流程记过打码量(跳过)
+        DB::connection()->getSchemaBuilder()->create('activity_user', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->integer('status'); $t->decimal('betting_amount', 20, 2); $t->decimal('effective_betting_amount', 20, 2);
+        });
+        DB::table('activity_user')->insert([
+            ['id' => 1, 'member_id' => 'm1', 'status' => \App\Models\ActivityUser::STATUS_IN_PROGRESS, 'betting_amount' => '20', 'effective_betting_amount' => '5'],
+            ['id' => 2, 'member_id' => 'm1', 'status' => \App\Models\ActivityUser::STATUS_IN_PROGRESS, 'betting_amount' => '100', 'effective_betting_amount' => '0'],
+        ]);
+        WageringService::addRequirement($this->wallet(), '100', 'activity:activity_user', 2, '新流程发奖');
+
+        $run([]);
+        $run([]); // 重复执行不重复处理
+        $wallet = $this->wallet();
+        $this->assertSame('0.00', $this->money($wallet->frozen_balance));
+        $this->assertSame('125.00', $this->money($wallet->available_balance));
+        // 新流程已记 100;两条同额奖励分别取 30 和 10,无记录的按1倍5,减去旧流水余额4;上线前的申请活动剩余 15
+        $this->assertSame('156.00', $this->money($wallet->wager_required));
+        $this->assertSame(0, DB::table('balance_logs')->where('type', 2)->where('frozen_status', 1)->count());
+        $this->assertSame(3, DB::table('balance_logs')->where('change_type', '流水解冻')->where('type', 1)->count());
+    }
+}