AgentWithdrawalService.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. <?php
  2. namespace App\Services\Agent;
  3. use App\Models\Agent\Agent;
  4. use App\Models\Agent\AgentBalanceLog;
  5. use App\Models\Agent\AgentSetting;
  6. use App\Models\Agent\AgentWithdrawAccount;
  7. use App\Models\Agent\AgentWithdrawal;
  8. use App\Models\RechargeChannel;
  9. use App\Services\PaymentOrderService;
  10. use App\Services\WithdrawalDestination;
  11. use App\Services\WithdrawalName;
  12. use Illuminate\Support\Facades\DB;
  13. use Illuminate\Support\Facades\Hash;
  14. use Illuminate\Support\Str;
  15. /**
  16. * 代理提现,和会员前台一样使用总后台配置的提现通道(agent_settings.recharge_channel_group_id 对应组合的提现类型):
  17. * - usdt:填 USDT 数量,按提现汇率(汇率 + 汇率差)折算后从余额冻结,总后台人工打款;
  18. * - rgtx 人工提现:填人民币金额,总后台人工打款;
  19. * - 三方代付(DF001/DF002/DF005 钱宝、JDpay、NOwithdraw、ZIMUwithdraw、ZIMUcash):填人民币金额,
  20. * 总后台审核通过时向三方下单(PaymentOrderService::requestPayout),三方回调成功扣冻结、失败退回余额。
  21. * 合营代理只能提取超出保证金的盈利部分。代理手续费按 agents.withdraw_fee_rate 从提现金额中扣除。
  22. * 状态:pending 待审核 → approved 已通过(人工打款)/ processing 三方代付中 → paid 已打款;rejected 已驳回、failed 代付失败(均退回余额)。
  23. */
  24. class AgentWithdrawalService
  25. {
  26. public const MANUAL_CHANNELS = ['usdt', 'rgtx'];
  27. public const OPEN_STATUSES = ['pending', 'approved', 'processing'];
  28. private const MAX_ACCOUNTS_PER_CHANNEL = 5;
  29. public function __construct(private ?AgentWalletService $wallets = null)
  30. {
  31. $this->wallets ??= new AgentWalletService();
  32. }
  33. public static function isProviderChannel(string $channel): bool
  34. {
  35. return !in_array($channel, self::MANUAL_CHANNELS, true);
  36. }
  37. /** 提现通道,格式同会员 GET /api/wallet/withdrawChannel。 */
  38. public function channels(Agent $agent): array
  39. {
  40. $list = WithdrawalDestination::describeChannels(RechargeChannel::getFormatChannel(2, $this->groupId()));
  41. foreach ($list as &$item) {
  42. $item['requires_real_name'] = WithdrawalName::usesOrderName((string) $item['value']);
  43. }
  44. unset($item);
  45. return ['list' => array_values($list), 'real_name' => (string) $agent->real_name];
  46. }
  47. public function accounts(Agent $agent, ?string $channel = null): array
  48. {
  49. $query = AgentWithdrawAccount::query()->where('agent_id', $agent->id);
  50. if ($channel !== null && $channel !== '') $query->where('type', $channel);
  51. return $query->orderByDesc('id')->get()->map(fn (AgentWithdrawAccount $account) => $this->present($account, $agent))->all();
  52. }
  53. /** 新增/修改提现账户。USDT:network + account(地址);其他通道:card_no、bank_name 等,规则同会员绑卡。 */
  54. public function saveAccount(Agent $agent, array $data): array
  55. {
  56. $channel = (string) ($data['type'] ?? 'usdt');
  57. if (RechargeChannel::checkWithdrawChannel($channel, $this->groupId()) === false) throw new \RuntimeException('不支持此提现方式');
  58. if ($channel === 'usdt') {
  59. $network = strtoupper(trim((string) ($data['network'] ?? '')));
  60. $address = trim((string) ($data['account'] ?? ''));
  61. if (!in_array($network, ['TRC20', 'ERC20'], true)) throw new \InvalidArgumentException('请选择公链 TRC20 或 ERC20');
  62. $pattern = $network === 'ERC20' ? '/^0x[0-9a-fA-F]{40}$/D' : '/^T[1-9A-HJ-NP-Za-km-z]{33}$/D';
  63. if (!preg_match($pattern, $address)) throw new \InvalidArgumentException('USDT 地址格式错误');
  64. $fields = ['account' => $address, 'network' => $network, 'bank_name' => '', 'details' => null];
  65. } else {
  66. if (!isset($data['card_no']) && isset($data['account'])) $data['card_no'] = $data['account'];
  67. try {
  68. $destination = WithdrawalDestination::resolve($channel, $data);
  69. } catch (\Exception $e) {
  70. throw new \InvalidArgumentException($e->getMessage());
  71. }
  72. $details = array_filter([
  73. 'bank_branch' => $destination['bank_branch'] ?? null,
  74. 'qr_image' => $destination['qr_image'] ?? null,
  75. 'payout_method' => $channel === 'rgtx' ? WithdrawalDestination::manualResponse($destination['bank_name'], $destination['card_no'], $destination['qr_image'] ?? null)['payout_method'] : null,
  76. ], fn ($v) => $v !== null && $v !== '');
  77. $fields = ['account' => $destination['card_no'], 'network' => '', 'bank_name' => $destination['bank_name'], 'details' => $details ?: null];
  78. }
  79. return DB::transaction(function () use ($agent, $data, $channel, $fields) {
  80. $locked = Agent::query()->lockForUpdate()->findOrFail($agent->id);
  81. if (WithdrawalName::usesOrderName($channel) && trim((string) $locked->real_name) === '') {
  82. // 和会员一样:首次绑定需要填写真实姓名,之后不能自行修改(由总后台修改)。
  83. $name = WithdrawalName::normalize((string) ($data['real_name'] ?? ''));
  84. if ($name === '') throw new \InvalidArgumentException('请输入真实姓名');
  85. $locked->real_name = $name;
  86. $locked->save();
  87. }
  88. if (!empty($data['id'])) {
  89. $account = AgentWithdrawAccount::query()->where('agent_id', $locked->id)->lockForUpdate()->findOrFail((int) $data['id']);
  90. if ($account->type !== $channel) throw new \InvalidArgumentException('不能修改账户的提现方式');
  91. } else {
  92. $count = AgentWithdrawAccount::query()->where('agent_id', $locked->id)->where('type', $channel)->count();
  93. if ($count >= self::MAX_ACCOUNTS_PER_CHANNEL) throw new \RuntimeException('已达添加上限');
  94. $account = new AgentWithdrawAccount(['agent_id' => $locked->id, 'type' => $channel]);
  95. }
  96. $account->fill($fields + [
  97. 'name' => trim((string) ($data['name'] ?? $account->name ?? '')),
  98. 'status' => (int) ($data['status'] ?? $account->status ?? 1),
  99. ])->save();
  100. return $this->present($account->fresh(), $locked);
  101. });
  102. }
  103. /**
  104. * 申请提现。$amount:USDT 账户为 USDT 数量,其他通道为人民币金额(最多 2 位小数)。
  105. */
  106. public function request(Agent $agent, int $accountId, string $amount, string $withdrawPassword, string $requestId, string $remark = ''): AgentWithdrawal
  107. {
  108. if (bccomp($amount, '0', 4) <= 0) {
  109. throw new \InvalidArgumentException('提现金额必须大于0');
  110. }
  111. $requestKey = 'agent:' . $agent->id . ':' . $requestId;
  112. return DB::transaction(function () use ($agent, $accountId, $amount, $withdrawPassword, $remark, $requestKey): AgentWithdrawal {
  113. $lockedAgent = Agent::query()->lockForUpdate()->findOrFail($agent->id);
  114. if ((int) $lockedAgent->status !== Agent::STATUS_ENABLED) {
  115. throw new \RuntimeException('代理账号已禁用');
  116. }
  117. if ($lockedAgent->withdraw_password === '' || !Hash::check($withdrawPassword, (string) $lockedAgent->withdraw_password)) {
  118. throw new \InvalidArgumentException('提现密码错误');
  119. }
  120. // First consistent read is after the agent lock, so concurrent retries see the committed request.
  121. $existing = AgentWithdrawal::query()->where('request_key', $requestKey)->first();
  122. if ($existing) {
  123. $this->assertSameRequest($existing, $accountId, $amount);
  124. return $existing;
  125. }
  126. $account = AgentWithdrawAccount::query()
  127. ->where('agent_id', $lockedAgent->id)
  128. ->where('status', 1)
  129. ->lockForUpdate()
  130. ->findOrFail($accountId);
  131. $channel = (string) $account->type;
  132. $config = RechargeChannel::checkWithdrawChannel($channel, $this->groupId());
  133. if ($config === false) throw new \RuntimeException('不支持此提现方式');
  134. $min = (float) ($config->min ?? 0);
  135. $max = (float) ($config->max ?? 0);
  136. if ((float) $amount < $min || ($max > 0 && (float) $amount > $max)) {
  137. throw new \RuntimeException("提现金额范围为{$min}-{$max}" . ($channel === 'usdt' ? ' USDT' : ''));
  138. }
  139. $holder = '';
  140. if (WithdrawalName::usesOrderName($channel)) {
  141. $holder = trim((string) $lockedAgent->real_name);
  142. if ($holder === '') throw new \RuntimeException('请先在提现账户中填写真实姓名');
  143. }
  144. if ($channel === 'usdt') {
  145. $rate = AgentExchangeRate::withdraw();
  146. $usdtAmount = bcadd($amount, '0', 4);
  147. $rmbAmount = bcmul($usdtAmount, $rate, 4);
  148. $scale = 4;
  149. } else {
  150. if (!preg_match('/^\d+(\.\d{1,2})?$/D', $amount)) throw new \InvalidArgumentException('提现金额最多 2 位小数');
  151. $rate = '0';
  152. $usdtAmount = '0';
  153. $rmbAmount = bcadd($amount, '0', 4);
  154. $scale = 2; // 三方按分打款,手续费和到账金额保留 2 位
  155. }
  156. if (bccomp($this->wallets->withdrawable($lockedAgent), $rmbAmount, 4) < 0) {
  157. throw new \RuntimeException($lockedAgent->isJoint() ? '可提现盈利不足(保证金不可提现)' : '代理余额不足');
  158. }
  159. $fee = bcdiv(bcmul($rmbAmount, (string) $lockedAgent->withdraw_fee_rate, 8), '100', $scale);
  160. $actual = bcsub($rmbAmount, $fee, 4);
  161. if (bccomp($actual, '0', 4) <= 0) {
  162. throw new \RuntimeException('扣除手续费后到账金额必须大于0');
  163. }
  164. $before = bcadd((string) $lockedAgent->balance, '0', 4);
  165. $lockedAgent->balance = bcsub($before, $rmbAmount, 4);
  166. $lockedAgent->realtime_balance = bcsub((string) $lockedAgent->realtime_balance, $rmbAmount, 4);
  167. $lockedAgent->frozen_balance = bcadd((string) $lockedAgent->frozen_balance, $rmbAmount, 4);
  168. $lockedAgent->save();
  169. $details = (array) ($account->details ?? []);
  170. $withdrawal = AgentWithdrawal::query()->create([
  171. 'order_no' => 'AW' . now()->format('YmdHis') . strtoupper(Str::random(8)),
  172. 'request_key' => $requestKey,
  173. 'agent_id' => $lockedAgent->id,
  174. 'account_id' => $account->id,
  175. 'account_type' => $channel,
  176. 'account_snapshot' => array_filter([
  177. 'type' => $channel,
  178. 'name' => $account->name,
  179. 'account' => $account->account,
  180. 'network' => $account->network,
  181. 'bank_name' => $account->bank_name,
  182. 'bank_branch' => $details['bank_branch'] ?? null,
  183. 'qr_image' => $details['qr_image'] ?? null,
  184. 'payout_method' => $details['payout_method'] ?? null,
  185. 'holder_name' => $holder,
  186. ], fn ($v) => $v !== null),
  187. 'amount' => $rmbAmount,
  188. 'fee' => $fee,
  189. 'actual_amount' => $actual,
  190. 'usdt_amount' => $usdtAmount,
  191. 'actual_usdt_amount' => $channel === 'usdt' ? bcdiv($actual, $rate, 4) : '0',
  192. 'exchange_rate' => $rate,
  193. 'status' => 'pending',
  194. 'remark' => $remark,
  195. ]);
  196. AgentBalanceLog::query()->create([
  197. 'agent_id' => $lockedAgent->id,
  198. 'type' => 'withdraw_freeze',
  199. 'amount' => bcsub('0', $rmbAmount, 4),
  200. 'before_balance' => $before,
  201. 'after_balance' => $lockedAgent->balance,
  202. 'reference_type' => 'agent_withdrawal',
  203. 'reference_id' => $withdrawal->id,
  204. 'operator_type' => 'agent',
  205. 'operator_id' => $lockedAgent->id,
  206. 'idempotency_key' => 'withdraw-freeze:' . $withdrawal->id,
  207. 'remark' => $remark,
  208. ]);
  209. return $withdrawal->fresh();
  210. });
  211. }
  212. /**
  213. * 总后台审核:
  214. * - approve:USDT / 人工提现变为 approved,等人工打款后 paid;三方通道直接向三方下单,变为 processing,下单失败保持待审核并返回原因;
  215. * - reject:pending / approved 驳回并退回余额;
  216. * - paid:approved 的人工打款完成。
  217. */
  218. public function audit(int $withdrawalId, string $action, int $adminId, string $remark = ''): AgentWithdrawal
  219. {
  220. if (!in_array($action, ['approve', 'reject', 'paid'], true)) {
  221. throw new \InvalidArgumentException('审核动作错误');
  222. }
  223. return DB::transaction(function () use ($withdrawalId, $action, $adminId, $remark): AgentWithdrawal {
  224. $withdrawal = AgentWithdrawal::query()->lockForUpdate()->findOrFail($withdrawalId);
  225. $agent = Agent::query()->lockForUpdate()->findOrFail($withdrawal->agent_id);
  226. $channel = (string) ($withdrawal->account_type ?: 'usdt');
  227. if ($action === 'approve') {
  228. if ($withdrawal->status !== 'pending') {
  229. throw new \RuntimeException('只有待审核订单可以通过');
  230. }
  231. if (self::isProviderChannel($channel)) {
  232. $snapshot = (array) $withdrawal->account_snapshot;
  233. try {
  234. $ret = PaymentOrderService::requestPayout($channel, (string) $withdrawal->order_no, AgentPaymentService::payerId((int) $agent->id),
  235. (string) $withdrawal->actual_amount, (string) ($snapshot['holder_name'] ?? ''), (string) ($snapshot['account'] ?? ''),
  236. (string) ($snapshot['bank_name'] ?? ''), ['agent_withdrawal_id' => $withdrawal->id]);
  237. } catch (\Exception $e) {
  238. throw new \RuntimeException($e->getMessage() ?: '代付下单失败');
  239. }
  240. $withdrawal->pay_no = $ret['pay_no'];
  241. $withdrawal->pay_data = $ret['pay_data'];
  242. $withdrawal->status = 'processing';
  243. } else {
  244. $withdrawal->status = 'approved';
  245. }
  246. $withdrawal->audited_at = now();
  247. $withdrawal->audit_admin_id = $adminId;
  248. } elseif ($action === 'reject') {
  249. if (!in_array($withdrawal->status, ['pending', 'approved'], true)) {
  250. throw new \RuntimeException('当前订单不能驳回');
  251. }
  252. $this->refund($withdrawal, $agent, 'admin', $adminId, $remark);
  253. $withdrawal->status = 'rejected';
  254. $withdrawal->audited_at = now();
  255. $withdrawal->audit_admin_id = $adminId;
  256. } else {
  257. if ($withdrawal->status !== 'approved') {
  258. throw new \RuntimeException('只有已通过订单可以标记已付款');
  259. }
  260. $this->settle($withdrawal, $agent);
  261. }
  262. $withdrawal->audit_remark = $remark;
  263. $withdrawal->save();
  264. return $withdrawal->fresh();
  265. });
  266. }
  267. /** 三方代付回调结果:true 成功、false 失败、null 处理中(只记录回调)。只处理 processing 状态,重复回调无副作用。 */
  268. public function providerResult(int $withdrawalId, ?bool $success, array $params): void
  269. {
  270. DB::transaction(function () use ($withdrawalId, $success, $params) {
  271. $withdrawal = AgentWithdrawal::query()->lockForUpdate()->find($withdrawalId);
  272. if (!$withdrawal || $withdrawal->status !== 'processing') return;
  273. $withdrawal->callback_data = json_encode($params, JSON_UNESCAPED_UNICODE);
  274. if ($success !== null) {
  275. $agent = Agent::query()->lockForUpdate()->findOrFail($withdrawal->agent_id);
  276. if ($success) {
  277. $this->settle($withdrawal, $agent);
  278. } else {
  279. $this->refund($withdrawal, $agent, 'system', null, '三方代付失败');
  280. $withdrawal->status = 'failed';
  281. $withdrawal->audit_remark = trim($withdrawal->audit_remark . ' 三方代付失败,已退回余额');
  282. }
  283. }
  284. $withdrawal->save();
  285. });
  286. }
  287. private function settle(AgentWithdrawal $withdrawal, Agent $agent): void
  288. {
  289. if (bccomp((string) $agent->frozen_balance, (string) $withdrawal->amount, 4) < 0) {
  290. throw new \RuntimeException('代理冻结余额异常');
  291. }
  292. $agent->frozen_balance = bcsub((string) $agent->frozen_balance, (string) $withdrawal->amount, 4);
  293. $agent->save();
  294. $withdrawal->status = 'paid';
  295. $withdrawal->paid_at = now();
  296. }
  297. private function refund(AgentWithdrawal $withdrawal, Agent $agent, string $operatorType, ?int $operatorId, string $remark): void
  298. {
  299. $before = bcadd((string) $agent->balance, '0', 4);
  300. if (bccomp((string) $agent->frozen_balance, (string) $withdrawal->amount, 4) < 0) {
  301. throw new \RuntimeException('代理冻结余额异常');
  302. }
  303. $agent->balance = bcadd($before, (string) $withdrawal->amount, 4);
  304. $agent->realtime_balance = bcadd((string) $agent->realtime_balance, (string) $withdrawal->amount, 4);
  305. $agent->frozen_balance = bcsub((string) $agent->frozen_balance, (string) $withdrawal->amount, 4);
  306. $agent->save();
  307. AgentBalanceLog::query()->firstOrCreate(
  308. ['idempotency_key' => 'withdraw-refund:' . $withdrawal->id],
  309. [
  310. 'agent_id' => $agent->id,
  311. 'type' => 'withdraw_refund',
  312. 'amount' => $withdrawal->amount,
  313. 'before_balance' => $before,
  314. 'after_balance' => $agent->balance,
  315. 'reference_type' => 'agent_withdrawal',
  316. 'reference_id' => $withdrawal->id,
  317. 'operator_type' => $operatorType,
  318. 'operator_id' => $operatorId,
  319. 'remark' => $remark,
  320. ]
  321. );
  322. $this->wallets->evaluateActivation($agent);
  323. }
  324. private function groupId(): int
  325. {
  326. return max(1, (int) AgentSetting::current()->recharge_channel_group_id);
  327. }
  328. /** 账户输出:details 展开到顶层;人工提现补 payout_method;需要实名的通道带上持卡人姓名。 */
  329. private function present(AgentWithdrawAccount $account, Agent $agent): array
  330. {
  331. $row = $account->toArray();
  332. $details = (array) ($row['details'] ?? []);
  333. unset($row['details']);
  334. $row['channel'] = $row['type'];
  335. $row['card_no'] = $row['type'] === 'usdt' ? '' : (string) $row['account'];
  336. $row['bank_branch'] = $details['bank_branch'] ?? null;
  337. $row['qr_image'] = $details['qr_image'] ?? null;
  338. $row['payout_method'] = $details['payout_method'] ?? null;
  339. $row['holder_name'] = WithdrawalName::usesOrderName((string) $row['type']) ? (string) $agent->real_name : '';
  340. return $row;
  341. }
  342. private function assertSameRequest(AgentWithdrawal $withdrawal, int $accountId, string $amount): void
  343. {
  344. $submitted = ($withdrawal->account_type ?: 'usdt') === 'usdt' ? (string) $withdrawal->usdt_amount : (string) $withdrawal->amount;
  345. if ((int) $withdrawal->account_id !== $accountId || bccomp($submitted, $amount, 4) !== 0) {
  346. throw new \RuntimeException('request_id 已用于其他提现申请');
  347. }
  348. }
  349. }