AgentWithdrawalService.php 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. <?php
  2. namespace App\Services\Agent;
  3. use App\Models\Agent\Agent;
  4. use App\Models\Agent\AgentBalanceLog;
  5. use App\Models\Agent\AgentWithdrawAccount;
  6. use App\Models\Agent\AgentWithdrawal;
  7. use Illuminate\Support\Facades\DB;
  8. use Illuminate\Support\Facades\Hash;
  9. use Illuminate\Support\Str;
  10. /**
  11. * 代理提现,仅 USDT。代理填写 USDT 数量,按提现汇率(汇率 + 汇率差)折算后从余额冻结;
  12. * 合营代理只能提取超出保证金的盈利部分。打款由总后台人工完成。
  13. */
  14. class AgentWithdrawalService
  15. {
  16. public function __construct(private ?AgentWalletService $wallets = null)
  17. {
  18. $this->wallets ??= new AgentWalletService();
  19. }
  20. public function request(Agent $agent, int $accountId, string $usdtAmount, string $withdrawPassword, string $requestId, string $remark = ''): AgentWithdrawal
  21. {
  22. if (bccomp($usdtAmount, '0', 4) <= 0) {
  23. throw new \InvalidArgumentException('提现金额必须大于0');
  24. }
  25. $requestKey = 'agent:' . $agent->id . ':' . $requestId;
  26. return DB::transaction(function () use ($agent, $accountId, $usdtAmount, $withdrawPassword, $remark, $requestKey): AgentWithdrawal {
  27. $lockedAgent = Agent::query()->lockForUpdate()->findOrFail($agent->id);
  28. if ((int) $lockedAgent->status !== Agent::STATUS_ENABLED) {
  29. throw new \RuntimeException('代理账号已禁用');
  30. }
  31. if ($lockedAgent->withdraw_password === '' || !Hash::check($withdrawPassword, (string) $lockedAgent->withdraw_password)) {
  32. throw new \InvalidArgumentException('提现密码错误');
  33. }
  34. // First consistent read is after the agent lock, so concurrent retries see the committed request.
  35. $existing = AgentWithdrawal::query()->where('request_key', $requestKey)->first();
  36. if ($existing) {
  37. $this->assertSameRequest($existing, $accountId, $usdtAmount);
  38. return $existing;
  39. }
  40. $account = AgentWithdrawAccount::query()
  41. ->where('agent_id', $lockedAgent->id)
  42. ->where('status', 1)
  43. ->lockForUpdate()
  44. ->findOrFail($accountId);
  45. if ($account->type !== 'usdt') {
  46. throw new \RuntimeException('仅支持提现到 USDT 地址');
  47. }
  48. $rate = AgentExchangeRate::withdraw();
  49. $amount = bcmul($usdtAmount, $rate, 4);
  50. if (bccomp($this->wallets->withdrawable($lockedAgent), $amount, 4) < 0) {
  51. throw new \RuntimeException($lockedAgent->isJoint() ? '可提现盈利不足(保证金不可提现)' : '代理余额不足');
  52. }
  53. $fee = bcdiv(bcmul($amount, (string) $lockedAgent->withdraw_fee_rate, 8), '100', 4);
  54. $actual = bcsub($amount, $fee, 4);
  55. if (bccomp($actual, '0', 4) <= 0) {
  56. throw new \RuntimeException('扣除手续费后到账金额必须大于0');
  57. }
  58. $before = bcadd((string) $lockedAgent->balance, '0', 4);
  59. $lockedAgent->balance = bcsub($before, $amount, 4);
  60. $lockedAgent->realtime_balance = bcsub((string) $lockedAgent->realtime_balance, $amount, 4);
  61. $lockedAgent->frozen_balance = bcadd((string) $lockedAgent->frozen_balance, $amount, 4);
  62. $lockedAgent->save();
  63. $withdrawal = AgentWithdrawal::query()->create([
  64. 'order_no' => 'AW' . now()->format('YmdHis') . strtoupper(Str::random(8)),
  65. 'request_key' => $requestKey,
  66. 'agent_id' => $lockedAgent->id,
  67. 'account_id' => $account->id,
  68. 'account_type' => $account->type,
  69. 'account_snapshot' => [
  70. 'type' => $account->type,
  71. 'name' => $account->name,
  72. 'account' => $account->account,
  73. 'network' => $account->network,
  74. ],
  75. 'amount' => $amount,
  76. 'fee' => $fee,
  77. 'actual_amount' => $actual,
  78. 'usdt_amount' => $usdtAmount,
  79. 'actual_usdt_amount' => bcdiv($actual, $rate, 4),
  80. 'exchange_rate' => $rate,
  81. 'status' => 'pending',
  82. 'remark' => $remark,
  83. ]);
  84. AgentBalanceLog::query()->create([
  85. 'agent_id' => $lockedAgent->id,
  86. 'type' => 'withdraw_freeze',
  87. 'amount' => bcsub('0', $amount, 4),
  88. 'before_balance' => $before,
  89. 'after_balance' => $lockedAgent->balance,
  90. 'reference_type' => 'agent_withdrawal',
  91. 'reference_id' => $withdrawal->id,
  92. 'operator_type' => 'agent',
  93. 'operator_id' => $lockedAgent->id,
  94. 'idempotency_key' => 'withdraw-freeze:' . $withdrawal->id,
  95. 'remark' => $remark,
  96. ]);
  97. return $withdrawal->fresh();
  98. });
  99. }
  100. public function audit(int $withdrawalId, string $action, int $adminId, string $remark = ''): AgentWithdrawal
  101. {
  102. if (!in_array($action, ['approve', 'reject', 'paid'], true)) {
  103. throw new \InvalidArgumentException('审核动作错误');
  104. }
  105. return DB::transaction(function () use ($withdrawalId, $action, $adminId, $remark): AgentWithdrawal {
  106. $withdrawal = AgentWithdrawal::query()->lockForUpdate()->findOrFail($withdrawalId);
  107. $agent = Agent::query()->lockForUpdate()->findOrFail($withdrawal->agent_id);
  108. if ($action === 'approve') {
  109. if ($withdrawal->status !== 'pending') {
  110. throw new \RuntimeException('只有待审核订单可以通过');
  111. }
  112. $withdrawal->status = 'approved';
  113. $withdrawal->audited_at = now();
  114. $withdrawal->audit_admin_id = $adminId;
  115. } elseif ($action === 'reject') {
  116. if (!in_array($withdrawal->status, ['pending', 'approved'], true)) {
  117. throw new \RuntimeException('当前订单不能驳回');
  118. }
  119. $before = bcadd((string) $agent->balance, '0', 4);
  120. if (bccomp((string) $agent->frozen_balance, (string) $withdrawal->amount, 4) < 0) {
  121. throw new \RuntimeException('代理冻结余额异常');
  122. }
  123. $agent->balance = bcadd($before, (string) $withdrawal->amount, 4);
  124. $agent->realtime_balance = bcadd((string) $agent->realtime_balance, (string) $withdrawal->amount, 4);
  125. $agent->frozen_balance = bcsub((string) $agent->frozen_balance, (string) $withdrawal->amount, 4);
  126. $agent->save();
  127. AgentBalanceLog::query()->firstOrCreate(
  128. ['idempotency_key' => 'withdraw-refund:' . $withdrawal->id],
  129. [
  130. 'agent_id' => $agent->id,
  131. 'type' => 'withdraw_refund',
  132. 'amount' => $withdrawal->amount,
  133. 'before_balance' => $before,
  134. 'after_balance' => $agent->balance,
  135. 'reference_type' => 'agent_withdrawal',
  136. 'reference_id' => $withdrawal->id,
  137. 'operator_type' => 'admin',
  138. 'operator_id' => $adminId,
  139. 'remark' => $remark,
  140. ]
  141. );
  142. $withdrawal->status = 'rejected';
  143. $withdrawal->audited_at = now();
  144. $withdrawal->audit_admin_id = $adminId;
  145. $this->wallets->evaluateActivation($agent);
  146. } else {
  147. if ($withdrawal->status !== 'approved') {
  148. throw new \RuntimeException('只有已通过订单可以标记已付款');
  149. }
  150. if (bccomp((string) $agent->frozen_balance, (string) $withdrawal->amount, 4) < 0) {
  151. throw new \RuntimeException('代理冻结余额异常');
  152. }
  153. $agent->frozen_balance = bcsub((string) $agent->frozen_balance, (string) $withdrawal->amount, 4);
  154. $agent->save();
  155. $withdrawal->status = 'paid';
  156. $withdrawal->paid_at = now();
  157. }
  158. $withdrawal->audit_remark = $remark;
  159. $withdrawal->save();
  160. return $withdrawal->fresh();
  161. });
  162. }
  163. private function assertSameRequest(AgentWithdrawal $withdrawal, int $accountId, string $usdtAmount): void
  164. {
  165. if ((int) $withdrawal->account_id !== $accountId || bccomp((string) $withdrawal->usdt_amount, $usdtAmount, 4) !== 0) {
  166. throw new \RuntimeException('request_id 已用于其他提现申请');
  167. }
  168. }
  169. }