MigrateFrozenRewards.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  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}';
  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. foreach ($this->members() as $memberId) {
  35. try {
  36. DB::transaction(fn () => $this->migrateMember((string)$memberId, $dryRun), 3);
  37. } catch (\Throwable $e) {
  38. ++$this->stats['skipped'];
  39. $this->warn("会员 {$memberId} 迁移失败:" . $e->getMessage());
  40. if ($dryRun) continue;
  41. }
  42. }
  43. $this->table(array_keys($this->stats), [array_map(fn ($v) => is_string($v) ? bcadd($v, '0', 2) : $v, $this->stats)]);
  44. $this->info($dryRun ? 'dry-run 完成,未写入任何数据' : '迁移完成');
  45. return $this->stats['skipped'] ? self::FAILURE : self::SUCCESS;
  46. }
  47. /** @return string[] */
  48. private function members(): array
  49. {
  50. if ($this->option('member')) return [(string)$this->option('member')];
  51. $ids = BalanceLog::where('type', 2)->where('frozen_status', 1)->whereIn('change_type', self::LEGACY_TYPES)->distinct()->pluck('member_id')->all();
  52. if (Schema::hasTable('promotion_rewards')) {
  53. $ids = array_merge($ids, DB::table('promotion_rewards as r')->join('users as u', 'u.id', '=', 'r.user_id')
  54. ->where('r.status', 'locked')->distinct()->pluck('u.member_id')->all());
  55. }
  56. if (Schema::hasTable('activity_user')) {
  57. $ids = array_merge($ids, DB::table('activity_user')->where('status', \App\Models\ActivityUser::STATUS_IN_PROGRESS)->distinct()->pluck('member_id')->all());
  58. }
  59. return array_values(array_unique(array_map('strval', $ids)));
  60. }
  61. private function migrateMember(string $memberId, bool $dryRun): void
  62. {
  63. $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
  64. if (!$wallet) throw new \RuntimeException('钱包不存在');
  65. $user = DB::table('users')->where('member_id', $memberId)->first(['id']);
  66. $now = PromotionFacts::now();
  67. $moved = '0'; $wager = '0'; $touched = false;
  68. // 1) 旧版冻结返彩
  69. foreach (BalanceLog::where('member_id', $memberId)->where('type', 2)->where('frozen_status', 1)
  70. ->whereIn('change_type', self::LEGACY_TYPES)->orderBy('id')->lockForUpdate()->get() as $log) {
  71. $key = 'balance_log:' . $log->id;
  72. if ($this->done($memberId, $key)) continue;
  73. $amount = bcadd((string)$log->amount, '0', 10);
  74. $need = $this->legacyNeed($memberId, $log, $amount);
  75. $this->move($wallet, $amount, $log->id, '旧版' . $log->change_type . '转可用余额', $dryRun);
  76. if (!$dryRun) {
  77. $log->frozen_status = 2; $log->save();
  78. $this->record($wallet, $need, $key, $log->change_type . '冻结奖励迁移');
  79. }
  80. $moved = bcadd($moved, $amount, 10); $wager = bcadd($wager, $need, 10); ++$this->stats['legacy_logs']; $touched = true;
  81. }
  82. // 旧版1倍流水在 melbet 的 wallets.flow_balance 里累计,抵扣迁移的打码量
  83. if ($touched && Schema::hasColumn('wallets', 'flow_balance')) {
  84. $carry = bcadd((string)($wallet->getAttribute('flow_balance') ?? '0'), '0', 10);
  85. if (bccomp($carry, '0', 10) > 0) {
  86. $offset = bccomp($carry, $wager, 10) > 0 ? $wager : $carry;
  87. if (!$dryRun) {
  88. $this->record($wallet, bcsub('0', $offset, 10), 'flow_balance:' . $wallet->id . ':' . $now, '旧版流水余额抵扣');
  89. Wallet::where('id', $wallet->id)->update(['flow_balance' => 0]);
  90. }
  91. $wager = bcsub($wager, $offset, 10);
  92. }
  93. }
  94. // 2) 新活动 locked 奖励
  95. if ($user && Schema::hasTable('promotion_rewards')) {
  96. $facts = new PromotionFacts();
  97. foreach (DB::table('promotion_rewards')->where('user_id', $user->id)->where('status', 'locked')->orderBy('id')->lockForUpdate()->get() as $reward) {
  98. $key = 'promotion_reward:' . $reward->id;
  99. if ($this->done($memberId, $key)) continue;
  100. $snapshot = json_decode((string)$reward->flow_snapshot, true) ?: [];
  101. $effective = $facts->total($facts->betting([(int)$user->id], $snapshot['from'] ?? $now, null, $snapshot['cursors'] ?? [])['effective']);
  102. $need = WageringService::remaining($reward->required_flow, $effective);
  103. $amount = bcadd((string)$reward->amount, '0', 10);
  104. $log = $this->move($wallet, $amount, $reward->id, '新活动奖励转可用余额', $dryRun);
  105. if (!$dryRun) {
  106. if ($reward->credited_log_id) DB::table('balance_logs')->where('id', $reward->credited_log_id)->update(['frozen_status' => 2]);
  107. DB::table('promotion_rewards')->where('id', $reward->id)->update(['status' => 'paid', 'unlocked_log_id' => $log?->id,
  108. 'unlocked_at' => $now, 'updated_at' => $now]);
  109. $this->record($wallet, $need, $key, '新活动冻结奖励迁移');
  110. }
  111. $moved = bcadd($moved, $amount, 10); $wager = bcadd($wager, $need, 10); ++$this->stats['promotion_rewards'];
  112. }
  113. }
  114. // 3) 进行中的申请活动:资金已在可用余额,只迁移剩余打码量
  115. if (Schema::hasTable('activity_user')) {
  116. foreach (DB::table('activity_user')->where('member_id', $memberId)->where('status', \App\Models\ActivityUser::STATUS_IN_PROGRESS)->get() as $row) {
  117. $key = 'activity_user:' . $row->id;
  118. if ($this->done($memberId, $key)) continue;
  119. // 上线后按新流程发放的活动,发奖时已写入打码量,不能再迁移一次
  120. if (WagerRecord::where('member_id', $memberId)->where('source', 'activity:activity_user')->where('source_id', (string)$row->id)->exists()) continue;
  121. $need = WageringService::remaining($row->betting_amount ?? '0', $row->effective_betting_amount ?? '0');
  122. if (!$dryRun) $this->record($wallet, $need, $key, '申请活动剩余打码量迁移');
  123. $wager = bcadd($wager, $need, 10); ++$this->stats['activity_users'];
  124. }
  125. }
  126. ++$this->stats['wallets'];
  127. $this->stats['amount'] = bcadd($this->stats['amount'], $moved, 10);
  128. $this->stats['wager'] = bcadd($this->stats['wager'], $wager, 10);
  129. if ($this->output->isVerbose()) $this->line("{$memberId}: 转入可用 " . bcadd($moved, '0', 2) . ',打码量 +' . bcadd($wager, '0', 2));
  130. }
  131. /** 匹配旧 wallet_bonus 的 need_flow(配置的倍数流水),没有则按旧规则1倍。 */
  132. private function legacyNeed(string $memberId, BalanceLog $log, string $amount): string
  133. {
  134. if (!Schema::hasTable('wallet_bonus')) return $amount;
  135. $bonus = DB::table('wallet_bonus')->where('member_id', $memberId)->where('related_id', $log->related_id)
  136. ->where('amount', $log->amount)->where('status', 1)->whereNotIn('id', array_keys($this->usedBonusIds) ?: [0])
  137. ->orderBy('id')->first();
  138. if (!$bonus) return $amount;
  139. $this->usedBonusIds[(int)$bonus->id] = true;
  140. return $bonus->need_flow !== null ? bcadd((string)$bonus->need_flow, '0', 10) : $amount;
  141. }
  142. private function move(Wallet $wallet, string $amount, $relatedId, string $remark, bool $dryRun): ?BalanceLog
  143. {
  144. $beforeFrozen = bcadd((string)$wallet->frozen_balance, '0', 10);
  145. if (bccomp($beforeFrozen, $amount, 10) < 0) throw new \RuntimeException('冻结余额不足:' . bcadd($beforeFrozen, '0', 2) . ' < ' . bcadd($amount, '0', 2));
  146. $before = bcadd((string)$wallet->available_balance, '0', 10);
  147. $wallet->frozen_balance = bcsub($beforeFrozen, $amount, 10);
  148. $wallet->available_balance = bcadd($before, $amount, 10);
  149. if ($dryRun) return null;
  150. $wallet->save();
  151. BalanceLog::create(['member_id' => $wallet->member_id, 'type' => 2, 'change_type' => '流水解冻', 'amount' => bcsub('0', $amount, 10),
  152. 'before_balance' => $beforeFrozen, 'after_balance' => $wallet->frozen_balance, 'related_id' => $relatedId, 'remark' => $remark]);
  153. return BalanceLog::create(['member_id' => $wallet->member_id, 'type' => 1, 'change_type' => '流水解冻', 'amount' => $amount,
  154. 'before_balance' => $before, 'after_balance' => $wallet->available_balance, 'related_id' => $relatedId, 'remark' => $remark]);
  155. }
  156. private function done(string $memberId, string $key): bool
  157. {
  158. return WagerRecord::where('member_id', $memberId)->where('source', 'migration')->where('source_id', $key)->exists();
  159. }
  160. /** 无论打码量是否为0都写一条迁移记录,作为幂等标记。 */
  161. private function record(Wallet $wallet, string $need, string $key, string $remark): void
  162. {
  163. if (bccomp($need, '0', 10) !== 0) {
  164. WageringService::addRequirement($wallet, $need, 'migration', $key, $remark);
  165. return;
  166. }
  167. WagerRecord::create(['user_id' => $wallet->user_id, 'member_id' => (string)$wallet->member_id, 'cycle_id' => (int)$wallet->wager_cycle_id,
  168. 'source' => 'migration', 'source_id' => $key, 'amount' => '0', 'before_required' => (string)$wallet->wager_required,
  169. 'after_required' => (string)$wallet->wager_required, 'completed' => (string)$wallet->wager_completed, 'remark' => $remark]);
  170. }
  171. }