MigrateFrozenRewards.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. <?php
  2. namespace App\Console\Commands;
  3. use App\Models\BalanceLog;
  4. use App\Models\Wallet;
  5. use App\Models\WagerRecord;
  6. use App\Services\PromotionFacts;
  7. use App\Services\WageringService;
  8. use Illuminate\Console\Command;
  9. use Illuminate\Support\Facades\DB;
  10. use Illuminate\Support\Facades\Schema;
  11. /**
  12. * 上线一次性迁移:活动奖励不再走冻结金额。
  13. * - 仍冻结的 即充即送 / 老用户回归(balance_logs type=2, frozen_status=1)和 locked 的新活动奖励:冻结余额转回可用余额,
  14. * 未完成的流水计入统一打码量。
  15. * - 进行中的 activity_user 活动:剩余打码量计入统一打码量(资金早已在可用余额)。
  16. * 以 wager_records(source=migration, source_id=来源) 保证重复执行不会重复处理。
  17. */
  18. class MigrateFrozenRewards extends Command
  19. {
  20. private const LEGACY_TYPES = ['即充即送', '老用户回归'];
  21. protected $signature = 'wagering:migrate-frozen-rewards {--dry-run : 只统计不写入} {--member= : 只处理指定会员ID} {--wager-only : 不动冻结金额,只把进行中申请活动的剩余打码量计入统一打码量}';
  22. protected $description = '把冻结中的活动奖励转入可用余额,并把未完成流水计入提现打码量';
  23. /** @var array<int,true> 已匹配过的 wallet_bonus,避免同一笔充值的两条同额奖励共用一条流水要求 */
  24. private array $usedBonusIds = [];
  25. private array $stats = ['wallets' => 0, 'legacy_logs' => 0, 'promotion_rewards' => 0, 'activity_users' => 0,
  26. 'amount' => '0', 'wager' => '0', 'skipped' => 0];
  27. public function handle(): int
  28. {
  29. if (!WageringService::enabled()) {
  30. $this->error('请先执行数据库迁移(wager_records / wallets.wager_required)');
  31. return self::FAILURE;
  32. }
  33. $dryRun = (bool)$this->option('dry-run');
  34. $wagerOnly = (bool)$this->option('wager-only');
  35. foreach ($this->members($wagerOnly) as $memberId) {
  36. try {
  37. DB::transaction(fn () => $this->migrateMember((string)$memberId, $dryRun, $wagerOnly), 3);
  38. } catch (\Throwable $e) {
  39. ++$this->stats['skipped'];
  40. $this->warn("会员 {$memberId} 迁移失败:" . $e->getMessage());
  41. if ($dryRun) continue;
  42. }
  43. }
  44. $this->table(array_keys($this->stats), [array_map(fn ($v) => is_string($v) ? bcadd($v, '0', 2) : $v, $this->stats)]);
  45. $this->info($dryRun ? 'dry-run 完成,未写入任何数据' : '迁移完成');
  46. return $this->stats['skipped'] ? self::FAILURE : self::SUCCESS;
  47. }
  48. /** @return string[] */
  49. private function members(bool $wagerOnly): array
  50. {
  51. if ($this->option('member')) return [(string)$this->option('member')];
  52. $ids = $wagerOnly ? [] : BalanceLog::where('type', 2)->where('frozen_status', 1)->whereIn('change_type', self::LEGACY_TYPES)->distinct()->pluck('member_id')->all();
  53. if (!$wagerOnly && Schema::hasTable('promotion_rewards')) {
  54. $ids = array_merge($ids, DB::table('promotion_rewards as r')->join('users as u', 'u.id', '=', 'r.user_id')
  55. ->where('r.status', 'locked')->distinct()->pluck('u.member_id')->all());
  56. }
  57. if (Schema::hasTable('activity_user')) {
  58. $ids = array_merge($ids, DB::table('activity_user')->where('status', \App\Models\ActivityUser::STATUS_IN_PROGRESS)->distinct()->pluck('member_id')->all());
  59. }
  60. return array_values(array_unique(array_map('strval', $ids)));
  61. }
  62. private function migrateMember(string $memberId, bool $dryRun, bool $wagerOnly = false): void
  63. {
  64. $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
  65. if (!$wallet) throw new \RuntimeException('钱包不存在');
  66. $user = DB::table('users')->where('member_id', $memberId)->first(['id']);
  67. $now = PromotionFacts::now();
  68. $moved = '0'; $wager = '0'; $touched = false;
  69. // --wager-only:旧冻结奖励留在冻结余额,继续由原有解冻逻辑处理
  70. // 1) 旧版冻结返彩
  71. if (!$wagerOnly) foreach (BalanceLog::where('member_id', $memberId)->where('type', 2)->where('frozen_status', 1)
  72. ->whereIn('change_type', self::LEGACY_TYPES)->orderBy('id')->lockForUpdate()->get() as $log) {
  73. $key = 'balance_log:' . $log->id;
  74. if ($this->done($memberId, $key)) continue;
  75. $amount = bcadd((string)$log->amount, '0', 10);
  76. $need = $this->legacyNeed($memberId, $log, $amount);
  77. $this->move($wallet, $amount, $log->id, '旧版' . $log->change_type . '转可用余额', $dryRun);
  78. if (!$dryRun) {
  79. $log->frozen_status = 2; $log->save();
  80. $this->record($wallet, $need, $key, $log->change_type . '冻结奖励迁移');
  81. }
  82. $moved = bcadd($moved, $amount, 10); $wager = bcadd($wager, $need, 10); ++$this->stats['legacy_logs']; $touched = true;
  83. }
  84. // 旧版1倍流水在 melbet 的 wallets.flow_balance 里累计,抵扣迁移的打码量
  85. if ($touched && Schema::hasColumn('wallets', 'flow_balance')) {
  86. $carry = bcadd((string)($wallet->getAttribute('flow_balance') ?? '0'), '0', 10);
  87. if (bccomp($carry, '0', 10) > 0) {
  88. $offset = bccomp($carry, $wager, 10) > 0 ? $wager : $carry;
  89. if (!$dryRun) {
  90. $this->record($wallet, bcsub('0', $offset, 10), 'flow_balance:' . $wallet->id . ':' . $now, '旧版流水余额抵扣');
  91. Wallet::where('id', $wallet->id)->update(['flow_balance' => 0]);
  92. }
  93. $wager = bcsub($wager, $offset, 10);
  94. }
  95. }
  96. // 2) 新活动 locked 奖励
  97. if (!$wagerOnly && $user && Schema::hasTable('promotion_rewards')) {
  98. $facts = new PromotionFacts();
  99. foreach (DB::table('promotion_rewards')->where('user_id', $user->id)->where('status', 'locked')->orderBy('id')->lockForUpdate()->get() as $reward) {
  100. $key = 'promotion_reward:' . $reward->id;
  101. if ($this->done($memberId, $key)) continue;
  102. $snapshot = json_decode((string)$reward->flow_snapshot, true) ?: [];
  103. $effective = $facts->total($facts->betting([(int)$user->id], $snapshot['from'] ?? $now, null, $snapshot['cursors'] ?? [])['effective']);
  104. $need = WageringService::remaining($reward->required_flow, $effective);
  105. $amount = bcadd((string)$reward->amount, '0', 10);
  106. $log = $this->move($wallet, $amount, $reward->id, '新活动奖励转可用余额', $dryRun);
  107. if (!$dryRun) {
  108. if ($reward->credited_log_id) DB::table('balance_logs')->where('id', $reward->credited_log_id)->update(['frozen_status' => 2]);
  109. DB::table('promotion_rewards')->where('id', $reward->id)->update(['status' => 'paid', 'unlocked_log_id' => $log?->id,
  110. 'unlocked_at' => $now, 'updated_at' => $now]);
  111. $this->record($wallet, $need, $key, '新活动冻结奖励迁移');
  112. }
  113. $moved = bcadd($moved, $amount, 10); $wager = bcadd($wager, $need, 10); ++$this->stats['promotion_rewards'];
  114. }
  115. }
  116. // 3) 进行中的申请活动:资金已在可用余额,只迁移剩余打码量
  117. if (Schema::hasTable('activity_user')) {
  118. foreach (DB::table('activity_user')->where('member_id', $memberId)->where('status', \App\Models\ActivityUser::STATUS_IN_PROGRESS)->get() as $row) {
  119. $key = 'activity_user:' . $row->id;
  120. if ($this->done($memberId, $key)) continue;
  121. // 上线后按新流程发放的活动,发奖时已写入打码量,不能再迁移一次
  122. if (WagerRecord::where('member_id', $memberId)->where('source', 'activity:activity_user')->where('source_id', (string)$row->id)->exists()) continue;
  123. $need = WageringService::remaining($row->betting_amount ?? '0', $row->effective_betting_amount ?? '0');
  124. if (!$dryRun) $this->record($wallet, $need, $key, '申请活动剩余打码量迁移');
  125. $wager = bcadd($wager, $need, 10); ++$this->stats['activity_users'];
  126. }
  127. }
  128. ++$this->stats['wallets'];
  129. $this->stats['amount'] = bcadd($this->stats['amount'], $moved, 10);
  130. $this->stats['wager'] = bcadd($this->stats['wager'], $wager, 10);
  131. if ($this->output->isVerbose()) $this->line("{$memberId}: 转入可用 " . bcadd($moved, '0', 2) . ',打码量 +' . bcadd($wager, '0', 2));
  132. }
  133. /** 匹配旧 wallet_bonus 的 need_flow(配置的倍数流水),没有则按旧规则1倍。 */
  134. private function legacyNeed(string $memberId, BalanceLog $log, string $amount): string
  135. {
  136. if (!Schema::hasTable('wallet_bonus')) return $amount;
  137. $bonus = DB::table('wallet_bonus')->where('member_id', $memberId)->where('related_id', $log->related_id)
  138. ->where('amount', $log->amount)->where('status', 1)->whereNotIn('id', array_keys($this->usedBonusIds) ?: [0])
  139. ->orderBy('id')->first();
  140. if (!$bonus) return $amount;
  141. $this->usedBonusIds[(int)$bonus->id] = true;
  142. return $bonus->need_flow !== null ? bcadd((string)$bonus->need_flow, '0', 10) : $amount;
  143. }
  144. private function move(Wallet $wallet, string $amount, $relatedId, string $remark, bool $dryRun): ?BalanceLog
  145. {
  146. $beforeFrozen = bcadd((string)$wallet->frozen_balance, '0', 10);
  147. if (bccomp($beforeFrozen, $amount, 10) < 0) throw new \RuntimeException('冻结余额不足:' . bcadd($beforeFrozen, '0', 2) . ' < ' . bcadd($amount, '0', 2));
  148. $before = bcadd((string)$wallet->available_balance, '0', 10);
  149. $wallet->frozen_balance = bcsub($beforeFrozen, $amount, 10);
  150. $wallet->available_balance = bcadd($before, $amount, 10);
  151. if ($dryRun) return null;
  152. $wallet->save();
  153. BalanceLog::create(['member_id' => $wallet->member_id, 'type' => 2, 'change_type' => '流水解冻', 'amount' => bcsub('0', $amount, 10),
  154. 'before_balance' => $beforeFrozen, 'after_balance' => $wallet->frozen_balance, 'related_id' => $relatedId, 'remark' => $remark]);
  155. return BalanceLog::create(['member_id' => $wallet->member_id, 'type' => 1, 'change_type' => '流水解冻', 'amount' => $amount,
  156. 'before_balance' => $before, 'after_balance' => $wallet->available_balance, 'related_id' => $relatedId, 'remark' => $remark]);
  157. }
  158. private function done(string $memberId, string $key): bool
  159. {
  160. return WagerRecord::where('member_id', $memberId)->where('source', 'migration')->where('source_id', $key)->exists();
  161. }
  162. /** 无论打码量是否为0都写一条迁移记录,作为幂等标记。 */
  163. private function record(Wallet $wallet, string $need, string $key, string $remark): void
  164. {
  165. if (bccomp($need, '0', 10) !== 0) {
  166. WageringService::addRequirement($wallet, $need, 'migration', $key, $remark);
  167. return;
  168. }
  169. WagerRecord::create(['user_id' => $wallet->user_id, 'member_id' => (string)$wallet->member_id, 'cycle_id' => (int)$wallet->wager_cycle_id,
  170. 'source' => 'migration', 'source_id' => $key, 'amount' => '0', 'before_required' => (string)$wallet->wager_required,
  171. 'after_required' => (string)$wallet->wager_required, 'completed' => (string)$wallet->wager_completed, 'remark' => $remark]);
  172. }
  173. }