'desc']; protected string|array $orderGuarantee = ['id' => 'desc']; protected array $withJoinTable = ['user', 'channel', 'reviewAdmin']; protected function initController(WebmanRequest $request): ?Response { $this->model = new \app\common\model\WithdrawOrder(); return null; } protected function _index(): Response { if ($this->request && $this->request->get('select')) { return $this->select($this->request); } list($where, $alias, $limit, $order) = $this->queryBuilder(); $table = strtolower($this->model->getTable()); $mainShort = $alias[$table] ?? ''; if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) { $where[] = ['user.admin_id', 'in', $this->scopedAdminIds()]; } $res = $this->model ->withJoin($this->withJoinTable, $this->withJoinType) ->with($this->withJoinTable) ->visible([ 'user' => ['username', 'phone'], 'channel' => ['name'], 'reviewAdmin' => ['username'], ]) ->alias($alias) ->where($where) ->order($order) ->paginate($limit); return $this->success('', [ 'list' => $res->items(), 'total' => $res->total(), 'remark' => get_route_remark(), ]); } /** * GET 时返回关联信息,便于编辑弹窗直接渲染 user.username/channel.name */ protected function _edit(): Response { $pk = $this->model->getPk(); $id = $this->request ? ($this->request->post($pk) ?? $this->request->get($pk)) : null; if ($id === null || $id === '') { return $this->error(__('Parameter error')); } if ($this->request && $this->request->method() === 'POST') { // 历史 CRUD 的 POST 编辑已被 approve/reject 替代,这里阻止直接改金额绕过审核流程 return $this->error(__('Please use approve/reject buttons to complete the review')); } $row = $this->loadWithRelations(intval(strval($id))); if (!$row) { return $this->error(__('Record not found')); } if (!$this->checkChannelScoped($row)) { return $this->error(__('You have no permission')); } return $this->success('', ['row' => $row]); } /** * 审核通过:允许调整 amount/fee;actual_amount 自动为 amount - fee。 * 对金额差额自动在用户钱包与流水中做增减,保持账务平衡。 */ public function approve(WebmanRequest $request): Response { $response = $this->initializeBackend($request); if ($response !== null) { return $response; } if ($request->method() !== 'POST') { return $this->error(__('Parameter error')); } $id = $this->intParam($request->post('id')); if ($id <= 0) { return $this->error(__('Parameter error')); } $newAmount = $this->decimalParam($request->post('amount'), '0'); $newFee = $this->decimalParam($request->post('fee'), '0'); if (bccomp($newAmount, '0', 2) <= 0) { return $this->error(__('Apply amount must be greater than 0')); } if (bccomp($newFee, '0', 2) < 0) { return $this->error(__('Fee cannot be negative')); } if (bccomp($newFee, $newAmount, 2) > 0) { return $this->error(__('Fee cannot be greater than apply amount')); } $newActual = bcsub($newAmount, $newFee, 2); $remarkRaw = $request->post('remark'); $remark = is_string($remarkRaw) ? trim($remarkRaw) : ''; $order = Db::name('withdraw_order')->where('id', $id)->find(); if (!$order) { return $this->error(__('Record not found')); } if (!$this->checkChannelScoped($order)) { return $this->error(__('You have no permission')); } $currentStatus = $this->intParam($order['status'] ?? 0); if ($currentStatus !== 0) { return $this->error(__('This order has already been reviewed')); } $userId = $this->intParam($order['user_id'] ?? 0); if ($userId <= 0) { return $this->error(__('Order is missing user info')); } $oldAmount = bcadd(strval($order['amount'] ?? '0'), '0', 2); $diff = bcsub($newAmount, $oldAmount, 2); $now = time(); $adminId = $this->intParam($this->auth->id ?? 0); $adminName = $this->adminDisplayName(); $channelIdRaw = $order['channel_id'] ?? null; $channelId = ($channelIdRaw === null || $channelIdRaw === '') ? null : $this->intParam($channelIdRaw); if ($remark === '') { $remark = '管理员(' . $adminName . ')审核通过:金额 ' . $this->shortAmount($newAmount) . ',手续费 ' . $this->shortAmount($newFee) . ',实际到账 ' . $this->shortAmount($newActual); } Db::startTrans(); try { // 金额调整差额处理 $cmp = bccomp($diff, '0', 2); if ($cmp > 0) { // 新金额更大:再冻结用户 diff $userRow = Db::name('user')->where('id', $userId)->find(); if (!$userRow) { Db::rollback(); return $this->error(__('Related user does not exist')); } $beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 2); if (bccomp($beforeCoin, $diff, 2) < 0) { Db::rollback(); return $this->error(__('User balance is insufficient to cover the adjustment difference')); } $afterCoin = bcsub($beforeCoin, $diff, 2); Db::name('user')->where('id', $userId)->update([ 'coin' => $afterCoin, 'total_withdraw_coin' => Db::raw('total_withdraw_coin + ' . $diff), 'update_time' => $now, ]); Db::name('user_wallet_record')->insert([ 'user_id' => $userId, 'channel_id' => $channelId, 'biz_type' => 'withdraw', 'direction' => 2, 'amount' => $diff, 'balance_before' => $beforeCoin, 'balance_after' => $afterCoin, 'ref_type' => 'withdraw_order', 'ref_id' => $id, 'idempotency_key' => 'wd_adjust_add_' . strval($order['order_no'] ?? $id) . '_' . $now, 'operator_admin_id' => $adminId > 0 ? $adminId : null, 'remark' => '管理员(' . $adminName . ')审核调增申请金额差额 ' . $this->shortAmount($diff), 'create_time' => $now, ]); } elseif ($cmp < 0) { // 新金额更小:退回差额 $abs = bcsub('0', $diff, 2); $userRow = Db::name('user')->where('id', $userId)->find(); if (!$userRow) { Db::rollback(); return $this->error(__('Related user does not exist')); } $beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 2); $afterCoin = bcadd($beforeCoin, $abs, 2); Db::name('user')->where('id', $userId)->update([ 'coin' => $afterCoin, 'total_withdraw_coin' => Db::raw('total_withdraw_coin - ' . $abs), 'update_time' => $now, ]); Db::name('user_wallet_record')->insert([ 'user_id' => $userId, 'channel_id' => $channelId, 'biz_type' => 'withdraw_refund', 'direction' => 1, 'amount' => $abs, 'balance_before' => $beforeCoin, 'balance_after' => $afterCoin, 'ref_type' => 'withdraw_order', 'ref_id' => $id, 'idempotency_key' => 'wd_adjust_sub_' . strval($order['order_no'] ?? $id) . '_' . $now, 'operator_admin_id' => $adminId > 0 ? $adminId : null, 'remark' => '管理员(' . $adminName . ')审核调减申请金额差额 ' . $this->shortAmount($abs), 'create_time' => $now, ]); } Db::name('withdraw_order')->where('id', $id)->update([ 'amount' => $newAmount, 'fee' => $newFee, 'actual_amount' => $newActual, 'status' => 1, 'review_admin_id' => $adminId > 0 ? $adminId : null, 'review_time' => $now, 'remark' => substr($remark, 0, 255), 'update_time' => $now, ]); Db::commit(); } catch (Throwable $e) { Db::rollback(); return $this->error($e->getMessage()); } // 审核通过后自动发起 DDPAY 出金(并在失败时回冲余额) try { $fresh = Db::name('withdraw_order')->where('id', $id)->find(); if (!is_array($fresh)) { // 理论上不会发生:只写失败备注 Db::name('withdraw_order')->where('id', $id)->update([ 'remark' => '[ddpay] payout order missing', 'update_time' => time(), ]); } else { $orderNo = is_string($fresh['order_no'] ?? null) ? trim($fresh['order_no'] ?? '') : strval($fresh['order_no'] ?? ''); $receiveType = is_string($fresh['receive_type'] ?? null) ? strtolower(trim($fresh['receive_type'] ?? '')) : ''; $payChannel = is_string($fresh['pay_channel'] ?? null) ? strtolower(trim($fresh['pay_channel'] ?? '')) : ''; if ($payChannel === '') { $payChannel = 'ddpay'; } // 当前仅 ddpay + bank 类型自动出金(与移动端 withdrawCreate 校验一致) if ($orderNo !== '' && $receiveType === 'bank' && $payChannel === 'ddpay') { $base = \app\common\library\finance\DDPayGateway::publicBaseUrlForCallbacks($request); if ($base === '') { $base = 'https://' . strval($request->host()); } $callbackUrl = rtrim($base, '/') . '/api/finance/ddpayPayoutNotify'; $payoutAmount = is_string($fresh['actual_amount'] ?? null) && $fresh['actual_amount'] !== '' ? trim($fresh['actual_amount']) : $newActual; $receiverName = is_string($fresh['ddpay_receiver_name'] ?? null) ? trim($fresh['ddpay_receiver_name'] ?? '') : ''; $receiverAccount = is_string($fresh['receive_account'] ?? null) ? trim($fresh['receive_account'] ?? '') : ''; $bankName = is_string($fresh['ddpay_bank_name'] ?? null) ? trim($fresh['ddpay_bank_name'] ?? '') : ''; $bankBranch = is_string($fresh['ddpay_bank_branch'] ?? null) ? trim($fresh['ddpay_bank_branch'] ?? '') : 'N/A'; if ($receiverName === '' || $receiverAccount === '' || $bankName === '') { // 缺少 DDPAY 出金字段:回冲并置为失败 Db::startTrans(); try { $amountRefund = bcadd(strval($fresh['amount'] ?? '0'), '0', 2); $updated = Db::name('withdraw_order') ->where('id', $id) ->where('status', 1) ->update([ 'status' => 2, 'remark' => '[ddpay] missing payout fields', 'update_time' => time(), ]); if ($updated > 0 && bccomp($amountRefund, '0', 2) > 0) { $userIdRefund = intval(strval($fresh['user_id'] ?? 0)); $userRow = Db::name('user')->where('id', $userIdRefund)->find(); $beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 2); $afterCoin = bcadd($beforeCoin, $amountRefund, 2); Db::name('user')->where('id', $userIdRefund)->update([ 'coin' => $afterCoin, 'total_withdraw_coin' => Db::raw('total_withdraw_coin - ' . $amountRefund), 'update_time' => time(), ]); $idempotencyKey = 'wd_ddpay_failed_' . strval($orderNo); $exists = Db::name('user_wallet_record')->where('idempotency_key', $idempotencyKey)->find(); if (!$exists) { $channelId = null; if (isset($fresh['channel_id']) && is_numeric(strval($fresh['channel_id']))) { $channelId = intval(strval($fresh['channel_id'])); } Db::name('user_wallet_record')->insert([ 'user_id' => $userIdRefund, 'channel_id' => $channelId, 'biz_type' => 'withdraw_refund', 'direction' => 1, 'amount' => $amountRefund, 'balance_before' => $beforeCoin, 'balance_after' => $afterCoin, 'ref_type' => 'withdraw_order', 'ref_id' => intval(strval($id)), 'idempotency_key' => $idempotencyKey, 'operator_admin_id' => null, 'remark' => '[ddpay] missing payout fields refund', 'create_time' => time(), ]); } } Db::commit(); } catch (Throwable $e2) { Db::rollback(); } } else { $clientId = config('app.ddpay_client_id', ''); $identifier = config('app.ddpay_identifier', ''); $ddReq = [ 'client_id' => $clientId, 'identifier' => $identifier, 'bill_number' => $orderNo, 'amount' => $payoutAmount, 'receiver_name' => $receiverName, 'receiver_account' => $receiverAccount, 'bank[name]' => $bankName, 'bank_branch' => $bankBranch, 'callback_url' => $callbackUrl, ]; $ddResp = []; $ts = ''; try { $ddResp = DDPayGateway::payoutInitiation($ddReq); $ts = is_string($ddResp['transaction_status'] ?? null) ? strtolower(trim($ddResp['transaction_status'] ?? '')) : ''; } catch (Throwable $e) { // initiation 异常:同“failed”处理,回冲并置失败 $ts = 'failed'; $ddResp = ['error' => (string) $e->getMessage()]; } if (is_array($ddResp)) { Db::name('withdraw_order') ->where('id', $id) ->update([ 'ddpay_payout_snapshot' => json_encode([ 'init_request' => $ddReq, 'init_response' => $ddResp, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ]); } if ($ts === 'completed') { Db::name('withdraw_order') ->where('id', $id) ->where('status', 1) ->update([ 'status' => 3, 'remark' => '[ddpay] payout completed', 'update_time' => time(), ]); } elseif ($ts === 'failed') { $amountRefund = bcadd(strval($fresh['amount'] ?? '0'), '0', 2); Db::startTrans(); try { $updated = Db::name('withdraw_order') ->where('id', $id) ->where('status', 1) ->update([ 'status' => 2, 'remark' => '[ddpay] payout failed', 'update_time' => time(), ]); if ($updated > 0 && bccomp($amountRefund, '0', 2) > 0) { $userIdRefund = intval(strval($fresh['user_id'] ?? 0)); $userRow = Db::name('user')->where('id', $userIdRefund)->find(); $beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 2); $afterCoin = bcadd($beforeCoin, $amountRefund, 2); Db::name('user')->where('id', $userIdRefund)->update([ 'coin' => $afterCoin, 'total_withdraw_coin' => Db::raw('total_withdraw_coin - ' . $amountRefund), 'update_time' => time(), ]); $idempotencyKey = 'wd_ddpay_failed_' . strval($orderNo); $exists = Db::name('user_wallet_record')->where('idempotency_key', $idempotencyKey)->find(); if (!$exists) { $channelId = null; if (isset($fresh['channel_id']) && is_numeric(strval($fresh['channel_id']))) { $channelId = intval(strval($fresh['channel_id'])); } Db::name('user_wallet_record')->insert([ 'user_id' => $userIdRefund, 'channel_id' => $channelId, 'biz_type' => 'withdraw_refund', 'direction' => 1, 'amount' => $amountRefund, 'balance_before' => $beforeCoin, 'balance_after' => $afterCoin, 'ref_type' => 'withdraw_order', 'ref_id' => intval(strval($id)), 'idempotency_key' => $idempotencyKey, 'operator_admin_id' => null, 'remark' => '[ddpay] payout failed refund', 'create_time' => time(), ]); } } Db::commit(); } catch (Throwable $e3) { Db::rollback(); } } else { // pending:按文档做一次 status inquiry 兜底(Webhook 仍会最终落账) try { $inq = DDPayGateway::payoutStatusInquiry([ 'client_id' => $clientId, 'bill_number' => $orderNo, ]); $ts2 = is_string($inq['transaction_status'] ?? null) ? strtolower(trim($inq['transaction_status'] ?? '')) : ''; if ($ts2 === 'completed') { Db::name('withdraw_order') ->where('id', $id) ->where('status', 1) ->update([ 'status' => 3, 'remark' => '[ddpay] payout completed (after status inquiry)', 'update_time' => time(), ]); } elseif ($ts2 === 'failed') { $amountRefund = bcadd(strval($fresh['amount'] ?? '0'), '0', 2); Db::startTrans(); try { $updated = Db::name('withdraw_order') ->where('id', $id) ->where('status', 1) ->update([ 'status' => 2, 'remark' => '[ddpay] payout failed (after status inquiry)', 'update_time' => time(), ]); if ($updated > 0 && bccomp($amountRefund, '0', 2) > 0) { $userIdRefund = intval(strval($fresh['user_id'] ?? 0)); $userRow = Db::name('user')->where('id', $userIdRefund)->find(); $beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 2); $afterCoin = bcadd($beforeCoin, $amountRefund, 2); Db::name('user')->where('id', $userIdRefund)->update([ 'coin' => $afterCoin, 'total_withdraw_coin' => Db::raw('total_withdraw_coin - ' . $amountRefund), 'update_time' => time(), ]); $idempotencyKey = 'wd_ddpay_failed_' . strval($orderNo); $exists = Db::name('user_wallet_record')->where('idempotency_key', $idempotencyKey)->find(); if (!$exists) { $channelId = null; if (isset($fresh['channel_id']) && is_numeric(strval($fresh['channel_id']))) { $channelId = intval(strval($fresh['channel_id'])); } Db::name('user_wallet_record')->insert([ 'user_id' => $userIdRefund, 'channel_id' => $channelId, 'biz_type' => 'withdraw_refund', 'direction' => 1, 'amount' => $amountRefund, 'balance_before' => $beforeCoin, 'balance_after' => $afterCoin, 'ref_type' => 'withdraw_order', 'ref_id' => intval(strval($id)), 'idempotency_key' => $idempotencyKey, 'operator_admin_id' => null, 'remark' => '[ddpay] payout failed refund (after status inquiry)', 'create_time' => time(), ]); } } Db::commit(); } catch (Throwable $e4) { Db::rollback(); } } } catch (Throwable $e5) { // ignore:Webhook 会兜底 } } } } } } catch (Throwable $e) { // 外部出金调用失败不阻断审核流:只记录 remark(避免阻塞用户提现) Db::name('withdraw_order')->where('id', $id)->update([ 'remark' => '[ddpay] payout flow exception: ' . substr((string) $e->getMessage(), 0, 200), 'update_time' => time(), ]); } return $this->success(__('Approved'), [ 'id' => $id, 'amount' => $newAmount, 'fee' => $newFee, 'actual_amount' => $newActual, 'status' => 1, ]); } /** * 审核拒绝:必须填写驳回原因(remark)。 * 回冲申请时的冻结:user.coin += amount;total_withdraw_coin -= amount;写一条 withdraw_refund 流水。 */ public function reject(WebmanRequest $request): Response { $response = $this->initializeBackend($request); if ($response !== null) { return $response; } if ($request->method() !== 'POST') { return $this->error(__('Parameter error')); } $id = $this->intParam($request->post('id')); if ($id <= 0) { return $this->error(__('Parameter error')); } $remarkRaw = $request->post('remark'); $remark = is_string($remarkRaw) ? trim($remarkRaw) : ''; if ($remark === '') { return $this->error(__('Please provide reject reason')); } $order = Db::name('withdraw_order')->where('id', $id)->find(); if (!$order) { return $this->error(__('Record not found')); } if (!$this->checkChannelScoped($order)) { return $this->error(__('You have no permission')); } $currentStatus = $this->intParam($order['status'] ?? 0); if ($currentStatus !== 0) { return $this->error(__('This order has already been reviewed')); } $userId = $this->intParam($order['user_id'] ?? 0); if ($userId <= 0) { return $this->error(__('Order is missing user info')); } $amount = bcadd(strval($order['amount'] ?? '0'), '0', 2); $channelIdRaw = $order['channel_id'] ?? null; $channelId = ($channelIdRaw === null || $channelIdRaw === '') ? null : $this->intParam($channelIdRaw); $now = time(); $adminId = $this->intParam($this->auth->id ?? 0); $adminName = $this->adminDisplayName(); Db::startTrans(); try { $userRow = Db::name('user')->where('id', $userId)->find(); if (!$userRow) { Db::rollback(); return $this->error(__('Related user does not exist')); } $beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 2); $afterCoin = bcadd($beforeCoin, $amount, 2); Db::name('user')->where('id', $userId)->update([ 'coin' => $afterCoin, 'total_withdraw_coin' => Db::raw('total_withdraw_coin - ' . $amount), 'update_time' => $now, ]); Db::name('user_wallet_record')->insert([ 'user_id' => $userId, 'channel_id' => $channelId, 'biz_type' => 'withdraw_refund', 'direction' => 1, 'amount' => $amount, 'balance_before' => $beforeCoin, 'balance_after' => $afterCoin, 'ref_type' => 'withdraw_order', 'ref_id' => $id, 'idempotency_key' => 'wd_reject_' . strval($order['order_no'] ?? $id) . '_' . $now, 'operator_admin_id' => $adminId > 0 ? $adminId : null, 'remark' => '管理员(' . $adminName . ')驳回提现,退回冻结金额 ' . $this->shortAmount($amount) . ':' . $remark, 'create_time' => $now, ]); Db::name('withdraw_order')->where('id', $id)->update([ 'status' => 2, 'review_admin_id' => $adminId > 0 ? $adminId : null, 'review_time' => $now, 'remark' => substr($remark, 0, 255), 'update_time' => $now, ]); Db::commit(); } catch (Throwable $e) { Db::rollback(); return $this->error($e->getMessage()); } return $this->success(__('Rejected'), [ 'id' => $id, 'status' => 2, 'remark' => $remark, ]); } private function loadWithRelations(int $id): ?array { $row = $this->model ->withJoin($this->withJoinTable, $this->withJoinType) ->with($this->withJoinTable) ->visible([ 'user' => ['username', 'phone'], 'channel' => ['name'], 'reviewAdmin' => ['username'], ]) ->where($this->model->getTable() . '.id', $id) ->find(); if (!$row) { return null; } return $row->toArray(); } private function checkChannelScoped(array|object $row): bool { if (!$this->auth || $this->auth->isSuperAdmin()) { return true; } $uidRaw = is_array($row) ? ($row['user_id'] ?? null) : ($row->user_id ?? null); $uid = $this->intParam($uidRaw); if ($uid <= 0) { return false; } $user = Db::name('user')->field(['id', 'admin_id'])->where('id', $uid)->find(); if (!is_array($user)) { return false; } $ownerAdminId = $this->intParam($user['admin_id'] ?? 0); return $ownerAdminId > 0 && in_array($ownerAdminId, $this->scopedAdminIds(), true); } private function intParam($raw): int { if ($raw === null || $raw === '') { return 0; } if (!is_numeric(strval($raw))) { return 0; } return intval(strval($raw)); } private function decimalParam($raw, string $default): string { if ($raw === null || $raw === '' || !is_numeric(strval($raw))) { return bcadd($default, '0', 2); } return bcadd(strval($raw), '0', 2); } private function adminDisplayName(): string { if (!$this->auth) { return 'admin'; } $name = $this->auth->username ?? null; if (is_string($name) && $name !== '') { return $name; } $id = $this->intParam($this->auth->id ?? 0); return '#' . strval($id); } /** * 当前管理员可见的管理员ID集合(本人 + 下级角色组内管理员) * * @return int[] */ private function scopedAdminIds(): array { if (!$this->auth) { return [0]; } if ($this->auth->isSuperAdmin()) { return []; } $groupIds = $this->auth->getAdminChildGroups(); $adminIds = $groupIds ? $this->auth->getGroupAdmins($groupIds) : []; $adminIds[] = $this->auth->id; $adminIds = array_map(fn($id) => $this->intParam($id), $adminIds); $adminIds = array_values(array_unique(array_filter($adminIds, fn($id) => $id > 0))); return $adminIds === [] ? [0] : $adminIds; } /** * 把 2 位小数金额压缩成最多 2 位小数用于展示(不影响落库精度) */ private function shortAmount(string $amount): string { if (!is_numeric($amount)) { return $amount; } $normalized = bcadd($amount, '0', 2); $negative = false; if (str_starts_with($normalized, '-')) { $negative = true; $normalized = substr($normalized, 1); } $parts = explode('.', $normalized, 2); $intPart = $parts[0] ?? '0'; $fracPart = $parts[1] ?? '0000'; $displayFrac = substr($fracPart, 0, 2); $v = $intPart . '.' . str_pad($displayFrac, 2, '0'); return $negative ? ('-' . $v) : $v; } }