WithdrawFundsService.php 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. <?php
  2. namespace App\Services;
  3. use App\Constants\HttpStatus;
  4. use App\Models\User;
  5. use App\Models\Wallet;
  6. use App\Models\WalletFreezeRecord as Record;
  7. use Exception;
  8. use Illuminate\Database\Eloquent\Model;
  9. use Illuminate\Support\Facades\DB;
  10. /**
  11. * 提现资金冻结:
  12. * - hold:申请时校验打码量,可用余额转入冻结余额,创建提现单并重置打码周期。
  13. * - release:审核拒绝 / 三方失败 / 撤回,冻结余额退回可用余额。
  14. * - settle:提现成功,扣除冻结余额。
  15. * frozen_amount = 0 的旧单(上线前申请,已直接扣可用余额)保持原逻辑:拒绝退回可用余额,成功不再扣款。
  16. */
  17. class WithdrawFundsService
  18. {
  19. public const WITHDRAWS = 'withdraws';
  20. public const PAYMENT_ORDERS = 'payment_orders';
  21. /** @var array<int,bool> */
  22. private static array $enabled = [];
  23. public static function enabled(): bool
  24. {
  25. if (!WageringService::enabled()) return false;
  26. $key = spl_object_id(DB::connection()->getPdo());
  27. if (!array_key_exists($key, self::$enabled)) {
  28. $schema = DB::connection()->getSchemaBuilder();
  29. self::$enabled[$key] = $schema->hasTable('wallet_freeze_records')
  30. && $schema->hasColumn('withdraws', 'frozen_amount') && $schema->hasColumn('payment_orders', 'frozen_amount');
  31. }
  32. return self::$enabled[$key];
  33. }
  34. public static function flushSchemaCache(): void
  35. {
  36. self::$enabled = [];
  37. }
  38. /**
  39. * @param callable(string $beforeAvailable, string $afterAvailable): Model $createOrder 创建提现单
  40. * @return array{0: Model, 1: Wallet}
  41. */
  42. public static function hold(string $memberId, $amount, string $changeType, string $remark, string $relatedType, callable $createOrder, ?string $logAmount = null): array
  43. {
  44. // 先在锁外重算当前打码量(耗时查询不占用钱包行锁);锁内只用缓存值判断。
  45. // 之后才结算的注单只会让缓存偏低,不会让未完成打码量的会员提前提现。
  46. $unlocked = WageringService::enabled() ? Wallet::where('member_id', $memberId)->first() : null;
  47. if ($unlocked && bccomp(WageringService::remaining($unlocked->wager_required, $unlocked->wager_completed), '0', 10) > 0) {
  48. WageringService::refresh($unlocked);
  49. }
  50. return DB::transaction(function () use ($memberId, $amount, $changeType, $remark, $relatedType, $createOrder, $logAmount) {
  51. $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
  52. if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
  53. $amount = bcadd((string)$amount, '0', 10);
  54. if (bccomp($amount, '0', 10) <= 0) throw new Exception('提现金额错误', HttpStatus::CUSTOM_ERROR);
  55. $beforeAvailable = bcadd((string)$wallet->available_balance, '0', 10);
  56. if (bccomp($beforeAvailable, $amount, 10) < 0) throw new Exception('您的钱包余额不足!', HttpStatus::CUSTOM_ERROR);
  57. WageringService::assertWithdrawable($wallet, false);
  58. $enabled = self::enabled();
  59. $beforeFrozen = bcadd((string)$wallet->frozen_balance, '0', 10);
  60. $afterAvailable = bcsub($beforeAvailable, $amount, 10);
  61. $afterFrozen = $enabled ? bcadd($beforeFrozen, $amount, 10) : $beforeFrozen;
  62. $wallet->available_balance = $afterAvailable;
  63. $wallet->frozen_balance = $afterFrozen;
  64. if (!$wallet->save()) throw new Exception('钱包更新失败!', HttpStatus::CUSTOM_ERROR);
  65. $order = $createOrder($beforeAvailable, $afterAvailable);
  66. if ($enabled) {
  67. $order->frozen_amount = $amount;
  68. $order->save();
  69. }
  70. BalanceLogService::addLog($memberId, $logAmount ?? bcsub('0', $amount, 10), $beforeAvailable, $afterAvailable, $changeType, $order->id, $remark);
  71. if ($enabled) {
  72. Record::create([
  73. 'user_id' => User::where('member_id', $memberId)->value('id'), 'member_id' => $memberId,
  74. 'type' => Record::TYPE_WITHDRAW_HOLD, 'amount' => $amount, 'wager_amount' => '0',
  75. 'reason' => $changeType . '申请冻结', 'status' => Record::STATUS_HOLDING,
  76. 'related_type' => $relatedType, 'related_id' => $order->id,
  77. 'before_available' => $beforeAvailable, 'after_available' => $afterAvailable,
  78. 'before_frozen' => $beforeFrozen, 'after_frozen' => $afterFrozen,
  79. ]);
  80. }
  81. WageringService::resetCycle($wallet, 'reset', $relatedType . ':' . $order->id);
  82. return [$order, $wallet];
  83. }, 3);
  84. }
  85. /**
  86. * 退回提现资金到可用余额(拒绝 / 失败 / 撤回)。调用方负责先把提现单改为终态,避免重复退回。
  87. * @param string $legacyAmount 旧单(frozen_amount=0)应退回的金额
  88. * @return array{before: string, after: string, amount: string}|null 已处理过返回 null
  89. */
  90. public static function release(string $relatedType, Model $order, $legacyAmount, string $changeType, string $remark): ?array
  91. {
  92. return DB::transaction(function () use ($relatedType, $order, $legacyAmount, $changeType, $remark) {
  93. $wallet = Wallet::where('member_id', $order->member_id)->lockForUpdate()->first();
  94. if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
  95. $frozenAmount = self::frozenAmount($order);
  96. $record = null;
  97. if (bccomp($frozenAmount, '0', 10) > 0) {
  98. $record = self::holdRecord($relatedType, (int)$order->id);
  99. if ($record && $record->status !== Record::STATUS_HOLDING) return null;
  100. $amount = $frozenAmount;
  101. $beforeFrozen = bcadd((string)$wallet->frozen_balance, '0', 10);
  102. if (bccomp($beforeFrozen, $amount, 10) < 0) throw new Exception('冻结余额不足,无法退回提现', HttpStatus::CUSTOM_ERROR);
  103. $wallet->frozen_balance = bcsub($beforeFrozen, $amount, 10);
  104. } else {
  105. $amount = bcadd((string)$legacyAmount, '0', 10);
  106. }
  107. $before = bcadd((string)$wallet->available_balance, '0', 10);
  108. $after = bcadd($before, $amount, 10);
  109. $wallet->available_balance = $after;
  110. if (!$wallet->save()) throw new Exception('钱包更新失败', HttpStatus::CUSTOM_ERROR);
  111. BalanceLogService::addLog($order->member_id, $amount, $before, $after, $changeType, $order->id, $remark);
  112. $record?->update(['status' => Record::STATUS_RELEASED, 'finished_at' => PromotionFacts::now(),
  113. 'after_available' => $after, 'after_frozen' => $wallet->frozen_balance]);
  114. return ['before' => $before, 'after' => $after, 'amount' => $amount];
  115. }, 3);
  116. }
  117. /** 提现成功:扣除冻结余额。旧单无需处理。 */
  118. public static function settle(string $relatedType, Model $order): void
  119. {
  120. $frozenAmount = self::frozenAmount($order);
  121. if (bccomp($frozenAmount, '0', 10) <= 0) return;
  122. DB::transaction(function () use ($relatedType, $order, $frozenAmount) {
  123. $wallet = Wallet::where('member_id', $order->member_id)->lockForUpdate()->first();
  124. if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
  125. $record = self::holdRecord($relatedType, (int)$order->id);
  126. if ($record && $record->status !== Record::STATUS_HOLDING) return;
  127. $beforeFrozen = bcadd((string)$wallet->frozen_balance, '0', 10);
  128. if (bccomp($beforeFrozen, $frozenAmount, 10) < 0) throw new Exception('冻结余额不足,无法完成提现', HttpStatus::CUSTOM_ERROR);
  129. $wallet->frozen_balance = bcsub($beforeFrozen, $frozenAmount, 10);
  130. if (!$wallet->save()) throw new Exception('钱包更新失败', HttpStatus::CUSTOM_ERROR);
  131. $record?->update(['status' => Record::STATUS_SETTLED, 'finished_at' => PromotionFacts::now(), 'after_frozen' => $wallet->frozen_balance]);
  132. }, 3);
  133. }
  134. private static function frozenAmount(Model $order): string
  135. {
  136. return bcadd((string)($order->getAttribute('frozen_amount') ?? '0'), '0', 10);
  137. }
  138. private static function holdRecord(string $relatedType, int $id): ?Record
  139. {
  140. if (!self::enabled()) return null;
  141. return Record::where('related_type', $relatedType)->where('related_id', $id)
  142. ->where('type', Record::TYPE_WITHDRAW_HOLD)->lockForUpdate()->first();
  143. }
  144. }