| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188 |
- <?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]);
- }
- }
|