已匹配过的 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'); $wagerOnly = (bool)$this->option('wager-only'); foreach ($this->members($wagerOnly) as $memberId) { try { DB::transaction(fn () => $this->migrateMember((string)$memberId, $dryRun, $wagerOnly), 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(bool $wagerOnly): array { if ($this->option('member')) return [(string)$this->option('member')]; $ids = $wagerOnly ? [] : BalanceLog::where('type', 2)->where('frozen_status', 1)->whereIn('change_type', self::LEGACY_TYPES)->distinct()->pluck('member_id')->all(); if (!$wagerOnly && 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, bool $wagerOnly = false): 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; // --wager-only:旧冻结奖励留在冻结余额,继续由原有解冻逻辑处理 // 1) 旧版冻结返彩 if (!$wagerOnly) 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 (!$wagerOnly && $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]); } }