initializeMobile($request); if ($response !== null) { return $response; } $lang = $this->currentLang(); $tiers = $this->loadEnabledTiers(); $effectiveChannels = $this->loadDepositChannelEffective(); $list = []; foreach ($tiers as $tier) { $amount = $this->amountString($tier['amount'] ?? '0'); $bonus = $this->amountString($tier['bonus_amount'] ?? '0'); $total = bcadd($amount, $bonus, 2); $payAmount = $this->amountString($tier['pay_amount'] ?? '0'); $currency = isset($tier['currency']) && is_string($tier['currency']) ? strtoupper(trim($tier['currency'])) : 'CNY'; if ($currency === '') { $currency = 'CNY'; } $localized = DepositTierLib::localize($tier, $lang); $tierId = isset($tier['id']) && is_string($tier['id']) ? $tier['id'] : ''; $list[] = [ 'id' => $tierId, 'tier_key' => $tierId, 'title' => $localized['title'], 'currency' => $currency, 'pay_amount' => $this->amountNumber($payAmount), 'amount' => $this->amountNumber($amount), 'bonus_amount' => $this->amountNumber($bonus), 'total_amount' => $this->amountNumber($total), 'desc' => $localized['desc'], 'channels' => DepositChannelLib::channelsForTier($tierId, $effectiveChannels, $lang, $currency), ]; } return $this->mobileSuccess([ 'list' => $list, ]); } /** * 获取当前请求语言标识(由中间件 LoadLangPack 设置到 locale),规范为小写、以 "-" 连字 */ private function currentLang(): string { $lang = function_exists('locale') ? locale() : ''; if (!is_string($lang) || $lang === '') { return 'zh-cn'; } return strtolower(str_replace('_', '-', $lang)); } /** * 创建充值订单(DDPay 或模拟支付 mock) * * - `channel_code=mock`:返回 `pay_url`(模拟收银台,3 分钟有效);用户确认后 status=3 待审核,后台通过后入账。 * - `channel_code=ddpay`:调用 DDPay「入金发起」;入账由回调或同步 completed 结算。 * * 请求:application/json 或 x-www-form-urlencoded * - tier_id / tier_key、channel_code、idempotency_key:必填 * - DDPay 渠道另需 payment_type、payer_name、payer_bank_name * * 响应:`order_no` / `amount` / `pay_channel` / `paid` / `pay_url` / `status` / `create_time` / `pay_time` */ public function depositCreate(Request $request): Response { $response = $this->initializeMobile($request); if ($response !== null) { return $response; } $tierId = $this->stringParam($request->input('tier_id')); if ($tierId === '') { $tierId = $this->stringParam($request->input('tier_key')); } $channelCode = strtolower($this->stringParam($request->input('channel_code'))); $idempotencyKey = $this->stringParam($request->input('idempotency_key')); if ($tierId === '' || $channelCode === '' || $idempotencyKey === '') { return $this->mobileError(1001, 'Missing parameters'); } if (mb_strlen($idempotencyKey) > 64) { return $this->mobileError(1002, 'Idempotency key is too long'); } if ($channelCode === MockPay::CHANNEL_CODE) { if (!MockPay::isEnabled()) { return $this->mobileError(2004, 'Mock pay channel is disabled'); } } elseif ($channelCode === 'ddpay') { if (!DDPayGateway::isConfigured()) { if (!MockPay::isEnabled()) { return $this->mobileError(2004, 'DDPay is not configured', [ 'suggest_channel_code' => MockPay::CHANNEL_CODE, 'hint' => 'Set FINANCE_MOCK_PAY_ENABLED=1 or configure DDPAY_* in .env', ]); } // 未配置 DDPay 商户:联调环境自动改用 mock(无需改 Apifox 参数) $channelCode = MockPay::CHANNEL_CODE; } } else { return $this->mobileError(2004, 'Pay channel not supported'); } $tiers = $this->loadEnabledTiers(); $tier = DepositTierLib::findById($tiers, $tierId); if (!$tier) { return $this->mobileError(2003, 'Deposit tier not available'); } $effectiveChannels = $this->loadDepositChannelEffective(); if (!DepositChannelLib::assertChannelAllowsTier($channelCode, $tierId, $effectiveChannels)) { return $this->mobileError(2004, 'Pay channel not available'); } $user = $this->auth->getUser(); $userId = intval(strval($user->id)); // 先做超时清理,再做幂等命中与“最多三笔待支付”限制 DepositOrderExpireService::expirePendingOrders($userId, null); // 幂等命中:直接返回已有订单(允许客户端重试拿回同一 pay_url) try { $existing = DepositOrder::where('idempotency_key', $idempotencyKey)->find(); if ($existing) { if (intval($existing->user_id) !== intval($this->auth->id)) { return $this->mobileError(1002, 'Idempotency key conflict'); } return $this->mobileSuccess($this->buildDepositResponse($existing, $this->publicOriginFromRequest($request))); } } catch (Throwable $e) { // 忽略幂等查询失败,继续创建 } $pendingCount = DepositOrderExpireService::pendingCountByUserId($userId); if ($pendingCount >= DepositOrderExpireService::MAX_PENDING_DEPOSIT) { return $this->mobileError(2005, 'Too many pending deposit orders', [ 'max_pending' => DepositOrderExpireService::MAX_PENDING_DEPOSIT, 'pending_count' => $pendingCount, 'expire_seconds' => DepositOrderExpireService::pendingExpireSeconds(), ]); } $orderNo = 'DP' . date('YmdHis') . substr(str_replace('.', '', uniqid('', true)), -6); $curSnap = isset($tier['currency']) && is_string($tier['currency']) ? strtoupper(trim($tier['currency'])) : 'CNY'; if ($curSnap === '') { $curSnap = 'CNY'; } if (!DepositChannelLib::assertChannelAllowsCurrency($channelCode, $curSnap, $effectiveChannels)) { return $this->mobileError(2004, 'Pay channel not available for this currency'); } $tierSnapshot = [ 'id' => $tier['id'], 'title' => is_string($tier['title'] ?? null) ? $tier['title'] : '', 'title_en' => is_string($tier['title_en'] ?? null) ? $tier['title_en'] : '', 'currency' => $curSnap, 'pay_amount' => $this->amountString($tier['pay_amount'] ?? '0'), 'amount' => $this->amountString($tier['amount'] ?? '0'), 'bonus_amount' => $this->amountString($tier['bonus_amount'] ?? '0'), 'desc' => is_string($tier['desc'] ?? null) ? $tier['desc'] : '', 'desc_en' => is_string($tier['desc_en'] ?? null) ? $tier['desc_en'] : '', 'channel_code' => $channelCode, ]; if ($channelCode === MockPay::CHANNEL_CODE && strtolower($this->stringParam($request->input('channel_code'))) === 'ddpay') { $tierSnapshot['ddpay_fallback'] = true; } $now = time(); $channelId = null; if (isset($user->channel_id) && is_numeric(strval($user->channel_id))) { $channelId = intval(strval($user->channel_id)); } try { $order = DepositOrder::create([ 'order_no' => $orderNo, 'idempotency_key' => $idempotencyKey, 'user_id' => intval($user->id), 'channel_id' => $channelId, 'amount' => $tierSnapshot['amount'], 'bonus_amount' => $tierSnapshot['bonus_amount'], 'status' => 0, 'pay_channel' => $channelCode, 'deposit_tier_id' => $tier['id'], 'pay_account_snapshot' => json_encode($tierSnapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'remark' => $channelCode === MockPay::CHANNEL_CODE && ($tierSnapshot['ddpay_fallback'] ?? false) ? '[mock] auto fallback: DDPay not configured' : '', 'create_time' => $now, 'update_time' => $now, ]); } catch (Throwable $e) { $msg = $e->getMessage(); if (stripos($msg, 'Duplicate') !== false && stripos($msg, 'uk_deposit_order_idem') !== false) { $existing = DepositOrder::where('idempotency_key', $idempotencyKey)->find(); if ($existing) { return $this->mobileSuccess($this->buildDepositResponse($existing, $this->publicOriginFromRequest($request))); } } return $this->mobileError(2000, $msg); } $orderId = is_numeric($order->id ?? null) ? intval(strval($order->id)) : 0; $publicOrigin = $this->publicOriginFromRequest($request); if ($channelCode === MockPay::CHANNEL_CODE) { return $this->finishMockDepositCreate($order, $orderId, $orderNo, $publicOrigin); } // DDPay 入金:创建订单后,调用三方「入金发起」拿到 payment_url,并在回调里验签结算。 $toString = static function (mixed $v): string { if (is_string($v)) { return trim($v); } if (is_numeric($v)) { return trim(strval($v)); } return ''; }; $paymentType = $toString($request->input('payment_type')); if ($paymentType === '') { $paymentType = $toString($request->input('paymentType')); } $payerName = $toString($request->input('payer_name')); if ($payerName === '') { $payerName = $toString($request->input('payerName')); } $payerBankName = $toString($request->input('payer_bank_name')); if ($payerBankName === '') { $payerBankName = $toString($request->input('payer_bank[name]')); } if ($payerBankName === '') { $payerBankName = $toString($request->input('payerBankName')); } if ($paymentType === '' || $payerName === '' || $payerBankName === '') { return $this->mobileError(1001, 'Missing DDPay parameters'); } if (!$this->isValidDdpayPaymentType($paymentType)) { return $this->mobileError(1001, 'Invalid DDPay payment_type'); } $callbackUrl = rtrim($publicOrigin, '/') . '/api/finance/ddpayDepositNotify'; $redirectUrl = rtrim($publicOrigin, '/') . '/api/finance/ddpayDepositRedirect?order_no=' . rawurlencode($orderNo); $ddReq = [ 'client_id' => config('app.ddpay_client_id', ''), 'identifier' => config('app.ddpay_identifier', ''), 'order_id' => $orderNo, 'payment_type' => $paymentType, 'transaction_amount' => $tierSnapshot['pay_amount'], 'payer_name' => $payerName, 'payer_bank[name]' => $payerBankName, 'callback_url' => $callbackUrl, 'redirect_url' => $redirectUrl, ]; try { $ddResp = DDPayGateway::depositInitiation($ddReq); } catch (Throwable $e) { Log::error('[depositCreate] ddpay initiation failed: ' . json_encode([ 'order_no' => $orderNo, 'user_id' => $userId, 'exception' => get_class($e), 'reason' => $e->getMessage(), ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); if ($orderId > 0) { $remark = '[ddpay] ' . $e->getMessage(); $remark = mb_substr($remark, 0, 255); Db::name('deposit_order') ->where('id', $orderId) ->where('status', 0) ->update([ 'status' => 2, 'remark' => $remark, 'update_time' => time(), ]); } $reason = trim($e->getMessage()); if ($reason === '') { $reason = 'DDPay deposit initiation failed'; } return $this->mobileError(2000, 'DDPay deposit initiation failed', [ 'gateway_reason' => $reason, ]); } $ts = ''; if (isset($ddResp['transaction_status']) && is_string($ddResp['transaction_status'])) { $ts = strtolower(trim($ddResp['transaction_status'])); } $paymentUrl = ''; if (isset($ddResp['payment_url']) && is_string($ddResp['payment_url'])) { $paymentUrl = trim($ddResp['payment_url']); } if ($orderId > 0) { $snapUpdate = $tierSnapshot; if ($paymentUrl !== '') { $snapUpdate['payment_url'] = $paymentUrl; } $snapUpdate['ddpay'] = $ddResp; Db::name('deposit_order') ->where('id', $orderId) ->update([ 'pay_account_snapshot' => json_encode($snapUpdate, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ]); } if ($ts === 'completed' && $orderId > 0) { try { DepositSettlement::settle( $orderId, DepositSettlement::SOURCE_THIRD_PARTY, 'ddpay deposit initiation completed', null, 'transaction_status=' . $ts ); } catch (Throwable $e) { // 已有状态不允许 settlement 时忽略,返回当前订单状态即可 } } if ($ts === 'failed' && $orderId > 0) { $statusMsg = is_string($ddResp['status_message'] ?? null) ? trim($ddResp['status_message']) : 'DDPay transaction failed'; $remark = '[ddpay] ' . $statusMsg; $remark = mb_substr($remark, 0, 255); Db::name('deposit_order') ->where('id', $orderId) ->where('status', 0) ->update([ 'status' => 2, 'remark' => $remark, 'update_time' => time(), ]); } $fresh = DepositOrder::where('id', $orderId)->find(); if ($fresh) { return $this->mobileSuccess($this->buildDepositResponse($fresh, $publicOrigin)); } return $this->mobileSuccess($this->buildDepositResponse($order, $publicOrigin)); } /** * 兼容旧链接:校验订单后 302 跳转到前端静态收银台(带签名) */ public function mockDepositPage(Request $request): Response { if (!MockPay::isEnabled()) { return response($this->renderMockDepositMessageHtml('模拟支付未开启', ''), 404, [ 'Content-Type' => 'text/html; charset=utf-8', ]); } $orderNo = $this->stringParam($request->input('order_no')); if ($orderNo === '') { return response($this->renderMockDepositMessageHtml('缺少订单号', ''), 400, [ 'Content-Type' => 'text/html; charset=utf-8', ]); } $redirectUrl = $this->buildMockDepositFrontendUrl($orderNo, $this->publicOriginFromRequest($request)); if ($redirectUrl === '') { return response($this->renderMockDepositMessageHtml('订单不存在', ''), 404, [ 'Content-Type' => 'text/html; charset=utf-8', ]); } return redirect($redirectUrl); } /** * 模拟收银台页拉取订单状态(前端静态页调用,须携带 sign + expire_at) */ public function mockDepositStatus(Request $request): Response { if (!MockPay::isEnabled()) { return $this->mobileError(2004, 'Mock pay channel is disabled'); } $orderNo = $this->stringParam($request->input('order_no')); $expireAt = $this->intParam($request->input('expire_at')); $sign = $this->stringParam($request->input('sign')); if ($orderNo === '' || $expireAt <= 0 || $sign === '') { return $this->mobileError(1001, 'Missing parameters'); } if (!$this->verifyMockDepositLinkSign($orderNo, $expireAt, $sign)) { return $this->mobileError(1002, 'Invalid payment link signature'); } DepositOrderExpireService::expirePendingOrders(null, $orderNo); $row = Db::name('deposit_order')->where('order_no', $orderNo)->find(); if (!is_array($row)) { return $this->mobileError(2003, 'Order does not exist'); } $payChannel = is_string($row['pay_channel'] ?? null) ? strtolower(trim($row['pay_channel'])) : ''; if ($payChannel !== MockPay::CHANNEL_CODE) { return $this->mobileError(2000, 'Order is not a mock pay deposit'); } $statusCode = is_numeric($row['status'] ?? null) ? intval($row['status']) : -1; $amount = $this->amountString($row['amount'] ?? '0'); $bonus = $this->amountString($row['bonus_amount'] ?? '0'); $now = time(); $remaining = $expireAt > $now ? ($expireAt - $now) : 0; $canPay = $statusCode === 0 && DepositOrderExpireService::isPendingPaymentValid($row) && $remaining > 0; return $this->mobileSuccess($this->attachMockDepositReturnGameUrl([ 'order_no' => $orderNo, 'amount' => $this->amountNumber($amount), 'bonus_amount' => $this->amountNumber($bonus), 'total_amount' => $this->amountNumber(bcadd($amount, $bonus, 2)), 'status' => $this->mapDepositStatus($statusCode), 'status_code' => $statusCode, 'expire_at' => $expireAt, 'remaining_seconds' => $remaining, 'can_pay' => $canPay, 'reject_reason' => is_string($row['reject_reason'] ?? null) && trim($row['reject_reason']) !== '' ? trim($row['reject_reason']) : null, ])); } /** * 模拟收银台确认支付(浏览器页内调用,无需 auth-token) */ public function mockDepositConfirm(Request $request): Response { if (!MockPay::isEnabled()) { return $this->mobileError(2004, 'Mock pay channel is disabled'); } $orderNo = $this->stringParam($request->input('order_no')); $expireAt = $this->intParam($request->input('expire_at')); $sign = $this->stringParam($request->input('sign')); if ($orderNo === '') { return $this->mobileError(1001, 'Missing parameters'); } if ($expireAt <= 0 || $sign === '') { return $this->mobileError(1001, 'Missing payment link signature'); } if (!$this->verifyMockDepositLinkSign($orderNo, $expireAt, $sign)) { return $this->mobileError(1002, 'Invalid payment link signature'); } $result = $this->confirmMockDepositPendingPayment($orderNo, 'mock page confirm'); if ($result['error'] !== null) { return $result['error']; } return $this->mobileSuccess($result['payload']); } /** * 模拟支付:确认已支付(需登录,API 客户端调用) */ public function mockDepositPay(Request $request): Response { $response = $this->initializeMobile($request); if ($response !== null) { return $response; } if (!MockPay::isEnabled()) { return $this->mobileError(2004, 'Mock pay channel is disabled'); } $orderNo = $this->stringParam($request->input('order_no')); if ($orderNo === '') { return $this->mobileError(1001, 'Missing parameters'); } $order = DepositOrder::where('order_no', $orderNo)->where('user_id', $this->auth->id)->find(); if (!$order) { return $this->mobileError(2003, 'Order does not exist'); } $result = $this->confirmMockDepositPendingPayment($orderNo, 'mock api confirm'); if ($result['error'] !== null) { return $result['error']; } return $this->mobileSuccess($result['payload']); } /** * @return array{error: ?Response, payload: array} */ private function confirmMockDepositPendingPayment(string $orderNo, string $sourceLabel): array { DepositOrderExpireService::expirePendingOrders(null, $orderNo); $row = Db::name('deposit_order')->where('order_no', $orderNo)->find(); if (!is_array($row)) { return [ 'error' => $this->mobileError(2003, 'Order does not exist'), 'payload' => [], ]; } $payChannel = is_string($row['pay_channel'] ?? null) ? strtolower(trim($row['pay_channel'])) : ''; if ($payChannel !== MockPay::CHANNEL_CODE) { return [ 'error' => $this->mobileError(2000, 'Order is not a mock pay deposit'), 'payload' => [], ]; } $status = is_numeric($row['status'] ?? null) ? intval($row['status']) : -1; if ($status === 1) { $order = DepositOrder::where('order_no', $orderNo)->find(); $payload = $this->attachMockDepositReturnGameUrl($this->buildDepositResponse($order, null)); $payload['review_required'] = false; return ['error' => null, 'payload' => $payload]; } if ($status === MockPay::DEPOSIT_STATUS_PENDING_REVIEW) { $order = DepositOrder::where('order_no', $orderNo)->find(); $payload = $this->attachMockDepositReturnGameUrl($this->buildDepositResponse($order, null)); $payload['review_required'] = true; return ['error' => null, 'payload' => $payload]; } if ($status !== 0 || !DepositOrderExpireService::isPendingPaymentValid($row)) { return [ 'error' => $this->mobileError(2000, 'Order cannot be paid'), 'payload' => [], ]; } $orderId = is_numeric($row['id'] ?? null) ? intval($row['id']) : 0; if ($orderId <= 0) { return [ 'error' => $this->mobileError(2000, 'Order id invalid'), 'payload' => [], ]; } $now = time(); $snap = []; $snapRaw = $row['pay_account_snapshot'] ?? ''; if (is_string($snapRaw) && trim($snapRaw) !== '') { $decoded = json_decode($snapRaw, true); if (is_array($decoded)) { $snap = $decoded; } } $snap['mock_paid_at'] = $now; $snap['mock_paid_source'] = $sourceLabel; Db::name('deposit_order')->where('id', $orderId)->where('status', 0)->update([ 'status' => MockPay::DEPOSIT_STATUS_PENDING_REVIEW, 'remark' => '[mock] 玩家已支付,待管理员审核', 'pay_account_snapshot' => json_encode($snap, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'update_time' => $now, ]); $order = DepositOrder::where('id', $orderId)->find(); $payload = $this->attachMockDepositReturnGameUrl($this->buildDepositResponse($order, null)); $payload['review_required'] = true; $payload['mock_pay_success'] = true; $payload['mock_pay_message'] = 'Payment submitted. Pending admin review.'; return ['error' => null, 'payload' => $payload]; } /** * @param array $payload * * @return array */ private function attachMockDepositReturnGameUrl(array $payload): array { $returnGameUrl = MockPay::resolveGameReturnUrl(); if ($returnGameUrl !== '') { $payload['return_game_url'] = $returnGameUrl; } return $payload; } /** * @param \app\common\model\DepositOrder|object $order */ private function finishMockDepositCreate($order, int $orderId, string $orderNo, string $publicOrigin): Response { $amountStr = $this->amountString($order->amount ?? '0'); $bonusStr = $this->amountString($order->bonus_amount ?? '0'); $createTime = is_numeric($order->create_time ?? null) ? intval(strval($order->create_time)) : time(); $linkAuth = MockPay::buildDepositLinkAuth($orderNo, $createTime); $expireAt = $linkAuth['expire_at']; $sign = $linkAuth['sign']; $payUrl = MockPay::depositPageUrl($orderNo, $publicOrigin, $expireAt, $sign, $amountStr, $bonusStr); if ($orderId > 0) { $snap = []; $snapRaw = $order->pay_account_snapshot ?? ''; if (is_string($snapRaw) && trim($snapRaw) !== '') { $decoded = json_decode($snapRaw, true); if (is_array($decoded)) { $snap = $decoded; } } $snap['payment_url'] = $payUrl; $snap['mock'] = true; $snap['expire_at'] = $expireAt; $snap['mock_pay_sign'] = $sign; Db::name('deposit_order')->where('id', $orderId)->update([ 'pay_account_snapshot' => json_encode($snap, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'update_time' => time(), ]); $refreshed = DepositOrder::where('id', $orderId)->find(); if ($refreshed) { $order = $refreshed; } } $payload = $this->buildDepositResponse($order, $publicOrigin); $payload['expire_at'] = $expireAt; $payload['expire_seconds'] = DepositOrderExpireService::pendingExpireSeconds(); return $this->mobileSuccess($payload); } private function renderMockDepositCheckoutHtml( string $orderNo, string $amount, string $bonus, int $expireAt, string $confirmUrl ): string { $total = bcadd($amount, $bonus, 2); $expireText = date('Y-m-d H:i:s', $expireAt); $orderNoEsc = htmlspecialchars($orderNo, ENT_QUOTES, 'UTF-8'); $amountEsc = htmlspecialchars($amount, ENT_QUOTES, 'UTF-8'); $bonusEsc = htmlspecialchars($bonus, ENT_QUOTES, 'UTF-8'); $totalEsc = htmlspecialchars($total, ENT_QUOTES, 'UTF-8'); $expireEsc = htmlspecialchars($expireText, ENT_QUOTES, 'UTF-8'); $orderNoJs = json_encode($orderNo, JSON_UNESCAPED_UNICODE); $confirmUrlJs = json_encode($confirmUrl, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $returnGameUrl = MockPay::resolveGameReturnUrl(); $returnGameUrlJs = json_encode($returnGameUrl, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); return '' . '模拟充值' . '

模拟充值收银台

' . '

订单号:' . $orderNoEsc . '

' . '

充值金额 ' . $amountEsc . ',赠送 ' . $bonusEsc . '

' . '
预计到账 ' . $totalEsc . '
' . '

链接有效期至 ' . $expireEsc . '(约 3 分钟,过期后订单将自动失效)

' . '' . '
支付成功(模拟)
订单已提交,需管理员在后台审核通过后才会入账。
' . '' . '' . '
'; } private function renderMockDepositMessageHtml(string $title, string $hint): string { $titleEsc = htmlspecialchars($title, ENT_QUOTES, 'UTF-8'); $hintHtml = $hint !== '' ? '

' . htmlspecialchars($hint, ENT_QUOTES, 'UTF-8') . '

' : ''; return '' . '' . $titleEsc . '' . '' . '

' . $titleEsc . '

' . $hintHtml . '
'; } /** * 将订单模型转换为统一的创建/详情响应数据 * * @param string|null $publicOrigin 如 https://api.xxx.com,待支付时用于拼完整 pay_url;为 null 时仅返回以 / 开头的 path+query */ private function buildDepositResponse($order, ?string $publicOrigin = null): array { $statusCode = $this->intValue($order->status); $status = $this->mapDepositStatus($statusCode); $paid = $status === 'paid'; $amount = $this->amountString($order->amount); $bonus = $this->amountString($order->bonus_amount); $total = bcadd($amount, $bonus, 2); $on = is_string($order->order_no) ? $order->order_no : strval($order->order_no); $payUrl = ''; $expireAt = 0; $payChannel = is_string($order->pay_channel) ? $order->pay_channel : strval($order->pay_channel); $snapRaw = $order->pay_account_snapshot ?? null; $snap = null; if (is_array($snapRaw)) { $snap = $snapRaw; } elseif (is_string($snapRaw) && trim($snapRaw) !== '') { $decoded = json_decode($snapRaw, true); if (is_array($decoded)) { $snap = $decoded; } } if (is_array($snap) && isset($snap['payment_url']) && is_string($snap['payment_url'])) { $payUrl = trim($snap['payment_url']); } if (is_array($snap) && isset($snap['expire_at']) && is_numeric($snap['expire_at'])) { $expireAt = intval($snap['expire_at']); } if ($statusCode === 0 && $on !== '' && $payChannel === MockPay::CHANNEL_CODE) { $origin = $publicOrigin !== null && $publicOrigin !== '' ? $publicOrigin : ''; $createTime = is_numeric(strval($order->create_time ?? 0)) ? intval(strval($order->create_time)) : time(); if ($expireAt <= 0) { $expireAt = $createTime + DepositOrderExpireService::pendingExpireSeconds(); } $sign = ''; if (is_array($snap) && isset($snap['mock_pay_sign']) && is_string($snap['mock_pay_sign'])) { $sign = trim($snap['mock_pay_sign']); } if ($sign === '' || !MockPay::verifyDepositLink($on, $expireAt, $sign)) { $linkAuth = MockPay::buildDepositLinkAuth($on, $createTime); $expireAt = $linkAuth['expire_at']; $sign = $linkAuth['sign']; } if ($payUrl === '') { $payUrl = MockPay::depositPageUrl($on, $origin, $expireAt, $sign, $amount, $bonus); } } if ($expireAt <= 0 && $statusCode === 0 && $payChannel === MockPay::CHANNEL_CODE) { $createTime = is_numeric(strval($order->create_time ?? 0)) ? intval(strval($order->create_time)) : time(); $expireAt = $createTime + DepositOrderExpireService::pendingExpireSeconds(); } $rejectReason = is_string($order->reject_reason ?? null) ? trim($order->reject_reason) : ''; return [ 'order_no' => $on, 'amount' => $this->amountNumber($amount), 'bonus_amount' => $this->amountNumber($bonus), 'total_amount' => $this->amountNumber($total), 'status' => $status, 'paid' => $paid, 'review_required' => $status === 'pending_review', 'reject_reason' => $rejectReason !== '' ? $rejectReason : null, 'pay_channel' => $payChannel, 'pay_url' => $payUrl, 'expire_at' => $expireAt > 0 ? $expireAt : null, 'expire_seconds' => $statusCode === 0 ? DepositOrderExpireService::pendingExpireSeconds() : null, 'create_time' => is_numeric(strval($order->create_time)) ? intval(strval($order->create_time)) : 0, 'pay_time' => is_numeric(strval($order->pay_time)) ? intval(strval($order->pay_time)) : 0, ]; } /** * 公网根 URL:优先环境变量 DDPAY_PUBLIC_BASE_URL,否则按请求推导(见 DDPayGateway::publicBaseUrlForCallbacks)。 */ private function publicOriginFromRequest(Request $request): string { return DDPayGateway::publicBaseUrlForCallbacks($request); } /** * DDPay Webhook 回调:验签后把 deposit_order 更新为 paid/failed。 * * 文档要求:返回纯文本 + HTTP 200(避免三方重复推送)。 */ public function ddpayDepositNotify(Request $request): Response { // Webman Request::input() 需要传 key;Webhook 签名计算只依赖文档列出的固定字段。 $payload = [ 'client_id' => $request->input('client_id'), 'order_id' => $request->input('order_id'), 'transaction_status' => $request->input('transaction_status'), 'timestamp' => $request->input('timestamp'), 'transaction_amount' => $request->input('transaction_amount'), 'signature' => $request->input('signature'), ]; $verified = false; try { $verified = DDPayGateway::verifyWebhookSignature($payload); } catch (Throwable $e) { $verified = false; } if (!$verified) { return response('Invalid signature', 403, [ 'Content-Type' => 'text/plain; charset=utf-8', ]); } $orderNoRaw = $payload['order_id'] ?? ''; $orderNo = is_string($orderNoRaw) ? trim($orderNoRaw) : (is_numeric($orderNoRaw) ? strval($orderNoRaw) : ''); if ($orderNo === '') { return response('Missing order_id', 400, [ 'Content-Type' => 'text/plain; charset=utf-8', ]); } $statusRaw = $payload['transaction_status'] ?? ''; $status = is_string($statusRaw) ? strtolower(trim($statusRaw)) : ''; $order = DepositOrder::where('order_no', $orderNo)->find(); if (!$order) { // 订单不存在通常是传参错误:直接 ack 以避免重复重试轰炸。 return response('{"status":"ok"}', 200, [ 'Content-Type' => 'text/plain; charset=utf-8', ]); } // 快照写入(不影响主流程) try { $snapRaw = $order->pay_account_snapshot ?? null; $snap = null; if (is_array($snapRaw)) { $snap = $snapRaw; } elseif (is_string($snapRaw) && trim($snapRaw) !== '') { $decoded = json_decode($snapRaw, true); if (is_array($decoded)) { $snap = $decoded; } } if (!is_array($snap)) { $snap = []; } $snap['ddpay_webhook'] = $payload; Db::name('deposit_order') ->where('id', intval(strval($order->id))) ->update([ 'pay_account_snapshot' => json_encode($snap, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ]); } catch (Throwable $e) { // ignore } if ($this->intValue($order->status) === 0) { if ($status === 'completed') { try { DepositSettlement::settle( intval(strval($order->id)), DepositSettlement::SOURCE_THIRD_PARTY, 'ddpay webhook completed', null, 'transaction_status=' . $status ); } catch (Throwable $e) { // settlement 不允许非待支付状态时忽略 } } elseif ($status === 'failed') { $amtRaw = $payload['transaction_amount'] ?? null; $amt = is_string($amtRaw) ? trim($amtRaw) : (is_numeric($amtRaw) ? strval($amtRaw) : ''); $remark = '[ddpay] transaction failed' . ($amt !== '' ? ' amount=' . $amt : ''); $remark = mb_substr($remark, 0, 255); Db::name('deposit_order') ->where('id', intval(strval($order->id))) ->where('status', 0) ->update([ 'status' => 2, 'remark' => $remark, 'update_time' => time(), ]); } } return response('{"status":"ok"}', 200, [ 'Content-Type' => 'text/plain; charset=utf-8', ]); } /** * DDPay redirect_url:展示“请返回 APP 查看余额”提示。 */ public function ddpayDepositRedirect(Request $request): Response { $response = $this->initializeMobile($request); if ($response !== null) { return $response; } $orderNo = $this->stringParam($request->input('order_no')); $statusRaw = $request->input('transaction_status'); $status = is_string($statusRaw) ? strtolower(trim($statusRaw)) : ''; $lang = $this->currentLang(); $isZh = str_starts_with($lang, 'zh'); $msg = $isZh ? '已收到支付,请返回 App 查看余额。' : 'Payment received. You can return to the app to check your balance.'; if ($status === 'completed') { $msg = $isZh ? '支付已完成,建议返回 App 查看结果。' : 'Payment completed. Returning to the app is recommended.'; } elseif ($status === 'failed') { $msg = $isZh ? '支付失败,请稍后重试。' : 'Payment failed. Please try again.'; } $orderNoEsc = htmlspecialchars($orderNo, ENT_QUOTES, 'UTF-8'); $msgEsc = htmlspecialchars($msg, ENT_QUOTES, 'UTF-8'); $html = 'DDPay'; $html .= '

DDPay

'; if ($orderNoEsc !== '') { $html .= '

Order: ' . $orderNoEsc . '

'; } $html .= '

' . $msgEsc . '

'; $html .= ''; return response($html, 200, [ 'Content-Type' => 'text/html; charset=utf-8', ]); } /** * DDPay 出金 Webhook 回调:验签后更新 withdraw_order.status,并在 failed 时进行返还余额。 * * DDPAY 文档要求:返回纯文本 + HTTP 200。 */ public function ddpayPayoutNotify(Request $request): Response { // Webhook 签名计算只依赖文档列出的固定字段 $payload = [ 'client_id' => $request->input('client_id'), 'order_id' => $request->input('order_id'), 'transaction_status' => $request->input('transaction_status'), 'timestamp' => $request->input('timestamp'), 'transaction_amount' => $request->input('transaction_amount'), 'signature' => $request->input('signature'), ]; try { $verified = DDPayGateway::verifyWebhookSignature($payload); } catch (Throwable $e) { $verified = false; } if (!$verified) { return response('Invalid signature', 403, [ 'Content-Type' => 'text/plain; charset=utf-8', ]); } $orderNoRaw = $payload['order_id'] ?? ''; $orderNo = is_string($orderNoRaw) ? trim($orderNoRaw) : (is_numeric($orderNoRaw) ? strval($orderNoRaw) : ''); if ($orderNo === '') { return response('Missing order_id', 400, [ 'Content-Type' => 'text/plain; charset=utf-8', ]); } $statusStr = is_string($payload['transaction_status'] ?? '') ? strtolower(trim((string) $payload['transaction_status'])) : ''; $order = WithdrawOrder::where('order_no', $orderNo)->find(); if (!$order) { // 避免无效重试轰炸:订单不存在则 ack 200 return response('{"status":"ok"}', 200, [ 'Content-Type' => 'text/plain; charset=utf-8', ]); } $now = time(); $currentStatus = $this->intValue($order->status); if ($currentStatus !== 1) { // 只处理“已通过待打款”状态,避免重复返还 return response('{"status":"ok"}', 200, [ 'Content-Type' => 'text/plain; charset=utf-8', ]); } if ($statusStr === 'completed') { Db::name('withdraw_order') ->where('id', intval(strval($order->id))) ->where('status', 1) ->update([ 'status' => 3, 'remark' => '[ddpay] payout completed', 'update_time' => $now, ]); return response('{"status":"ok"}', 200, [ 'Content-Type' => 'text/plain; charset=utf-8', ]); } if ($statusStr === 'failed') { $amount = bcadd(strval($order->amount ?? '0'), '0', 2); if (bccomp($amount, '0', 2) <= 0) { // 金额异常直接置失败,不做返还 Db::name('withdraw_order') ->where('id', intval(strval($order->id))) ->where('status', 1) ->update([ 'status' => 2, 'remark' => '[ddpay] payout failed', 'update_time' => $now, ]); return response('{"status":"ok"}', 200, [ 'Content-Type' => 'text/plain; charset=utf-8', ]); } $userId = intval(strval($order->user_id ?? 0)); $channelId = null; if (isset($order->channel_id) && is_numeric(strval($order->channel_id))) { $channelId = intval(strval($order->channel_id)); } $idempotencyKey = 'wd_ddpay_failed_' . strval($order->order_no); Db::startTrans(); try { $walletExists = Db::name('user_wallet_record') ->where('idempotency_key', $idempotencyKey) ->find(); if ($walletExists) { // 已返还过,只需更新状态 Db::name('withdraw_order') ->where('id', intval(strval($order->id))) ->where('status', 1) ->update([ 'status' => 2, 'remark' => '[ddpay] payout failed (already refunded)', 'update_time' => time(), ]); Db::commit(); return response('{"status":"ok"}', 200, [ 'Content-Type' => 'text/plain; charset=utf-8', ]); } $userRow = Db::name('user')->where('id', $userId)->find(); if (!is_array($userRow)) { throw new RuntimeException('User not found for refund'); } $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' => intval(strval($order->id)), 'idempotency_key' => $idempotencyKey, 'operator_admin_id' => null, 'remark' => '[ddpay] payout failed refund', 'create_time' => $now, ]); Db::name('withdraw_order') ->where('id', intval(strval($order->id))) ->where('status', 1) ->update([ 'status' => 2, 'remark' => '[ddpay] payout failed', 'update_time' => $now, ]); Db::commit(); } catch (Throwable $e) { Db::rollback(); // 失败不 ack 以便三方重试;但仍需要避免无限循环 return response('Refund failed: ' . (string) $e->getMessage(), 500, [ 'Content-Type' => 'text/plain; charset=utf-8', ]); } return response('{"status":"ok"}', 200, [ 'Content-Type' => 'text/plain; charset=utf-8', ]); } // pending / other 状态:直接 ack return response('{"status":"ok"}', 200, [ 'Content-Type' => 'text/plain; charset=utf-8', ]); } /** * 将任意金额输入归一化为 2 位小数字符串(不做类型强制转换) */ private function amountString($raw): string { if (is_string($raw)) { $s = trim($raw); } elseif (is_int($raw) || is_float($raw)) { $s = strval($raw); } else { return '0.00'; } if ($s === '' || !is_numeric($s)) { return '0.00'; } return bcadd($s, '0', 2); } private function amountNumber($raw): float { return floatval($this->amountString($raw)); } /** * 查看充值订单详情(原 depositDetail)。根据 order_no 返回完整订单快照。 */ public function depositDetail(Request $request): Response { $response = $this->initializeMobile($request); if ($response !== null) { return $response; } $orderNo = $this->stringParam($request->input('order_no')); if ($orderNo === '') { return $this->mobileError(1001, 'Missing parameters'); } DepositOrderExpireService::expirePendingOrders(null, $orderNo); $order = DepositOrder::where('order_no', $orderNo)->where('user_id', $this->auth->id)->find(); if (!$order) { return $this->mobileError(2003, 'Order does not exist'); } return $this->mobileSuccess($this->buildDepositResponse($order, $this->publicOriginFromRequest($request))); } /** * 查询当前用户的充值订单列表(分页)。列表项返回 order_no / amount / bonus_amount / status, * 其他字段请调用 /api/finance/depositDetail。 */ public function depositList(Request $request): Response { $response = $this->initializeMobile($request); if ($response !== null) { return $response; } DepositOrderExpireService::expirePendingOrders(intval(strval($this->auth->id)), null); $page = $this->intValue($request->input('page', 1)); if ($page <= 0) { $page = 1; } $pageSize = $this->intValue($request->input('page_size', 20)); if ($pageSize <= 0 || $pageSize > 100) { $pageSize = 20; } $paginate = DepositOrder::where('user_id', $this->auth->id) ->order('id', 'desc') ->paginate(['page' => $page, 'list_rows' => $pageSize]); $list = []; foreach ($paginate->items() as $row) { $list[] = [ 'order_no' => $row->order_no, 'amount' => $this->amountNumber($row->amount ?? '0'), 'bonus_amount' => $this->amountNumber($row->bonus_amount ?? '0'), 'status' => $this->mapDepositStatus($row->status ?? null), ]; } return $this->mobileSuccess([ 'list' => $list, 'pagination' => [ 'page' => $paginate->currentPage(), 'page_size' => $paginate->listRows(), 'total' => $paginate->total(), ], ]); } public function withdrawCreate(Request $request): Response { $response = $this->initializeMobile($request); if ($response !== null) { return $response; } $withdrawCoinRaw = $request->post('withdraw_coin', ''); $withdrawCoin = is_string($withdrawCoinRaw) ? trim($withdrawCoinRaw) : (is_numeric($withdrawCoinRaw) ? strval($withdrawCoinRaw) : ''); $channelCode = strtolower($this->stringParam($request->post('channel_code'))); if ($channelCode === '') { $channelCode = strtolower($this->stringParam($request->post('pay_channel'))); } $receiveAccount = trim(is_string($request->post('receive_account', '')) ? $request->post('receive_account', '') : ''); $receiveType = trim(is_string($request->post('receive_type', '')) ? $request->post('receive_type', '') : ''); $receiveType = strtolower($receiveType); // DDPAY 出金(Payout)所需扩展字段:当前仅支持 receive_type=bank $receiverName = trim(is_string($request->post('receiver_name', '')) ? $request->post('receiver_name', '') : ''); $receiverEmail = trim(is_string($request->post('receiver_email', '')) ? $request->post('receiver_email', '') : ''); $receiverMobile = trim(is_string($request->post('receiver_mobile', '')) ? $request->post('receiver_mobile', '') : ''); $bankCode = trim(is_string($request->post('bank_code', '')) ? $request->post('bank_code', '') : ''); $bankBranch = trim(is_string($request->post('bank_branch', '')) ? $request->post('bank_branch', '') : ''); $idempotencyKey = trim(is_string($request->post('idempotency_key', '')) ? $request->post('idempotency_key', '') : ''); if ($withdrawCoin === '' || $channelCode === '' || $receiveAccount === '' || $receiveType === '' || $idempotencyKey === '' || $receiverEmail === '' || $receiverMobile === '') { return $this->mobileError(1001, 'Missing parameters'); } if (!in_array($channelCode, DepositChannelLib::withdrawPayoutChannelCodes(), true)) { return $this->mobileError(2004, 'Pay channel not supported'); } if ($channelCode === MockPay::CHANNEL_CODE && !MockPay::isEnabled()) { return $this->mobileError(2004, 'Mock pay channel is disabled'); } if ($channelCode === 'ddpay' && !DDPayGateway::isConfigured()) { if (!MockPay::isEnabled()) { return $this->mobileError(2004, 'DDPay is not configured', [ 'suggest_channel_code' => MockPay::CHANNEL_CODE, ]); } $channelCode = MockPay::CHANNEL_CODE; } $effectiveChannels = $this->loadDepositChannelEffective(); if (!DepositChannelLib::assertChannelEnabled($channelCode, $effectiveChannels)) { return $this->mobileError(2004, 'Pay channel not available'); } if (mb_strlen($receiverEmail) > 255 || !filter_var($receiverEmail, FILTER_VALIDATE_EMAIL)) { return $this->mobileError(1001, 'Invalid receiver email'); } if (mb_strlen($receiverMobile) > 64 || !$this->isValidReceiverMobile($receiverMobile)) { return $this->mobileError(1001, 'Invalid receiver mobile'); } if (mb_strlen($idempotencyKey) > 64) { return $this->mobileError(1002, 'Idempotency key is too long'); } if (!is_numeric($withdrawCoin) || bccomp($withdrawCoin, '0', 2) <= 0) { return $this->mobileError(1001, 'Invalid withdraw amount'); } $withdrawCoin = bcadd($withdrawCoin, '0', 2); // 按 DDPAY 文档接入:当前仅支持 bank 类型出金 if ($receiveType !== 'bank') { return $this->mobileError(2000, 'DDPay payout integration supports receive_type=bank only'); } if ($receiverName === '' || $bankCode === '') { return $this->mobileError(1001, 'Missing DDPay bank payout parameters'); } if ($bankBranch === '') { $bankBranch = 'N/A'; } // 映射 bank_code -> DDPAY 所需的完整银行名称(来自 financeCashier.withdraw_banks 配置) $row = GameConfig::where('config_key', FinanceCashierConfigLib::CONFIG_KEY)->find(); $cfg = FinanceCashierConfigLib::parseFromConfigValue($row?->config_value ?? null); $banks = is_array($cfg['withdraw_banks'] ?? null) ? $cfg['withdraw_banks'] : []; $bankCodeNorm = strtolower(trim($bankCode)); $ddpayBankName = ''; foreach ($banks as $b) { if (!is_array($b)) { continue; } $c = isset($b['code']) && is_string($b['code']) ? strtolower(trim($b['code'])) : ''; if ($c === '' || $c !== $bankCodeNorm) { continue; } $nameEn = isset($b['name_en']) && is_string($b['name_en']) ? trim($b['name_en']) : ''; $nameZh = isset($b['name_zh']) && is_string($b['name_zh']) ? trim($b['name_zh']) : ''; $ddpayBankName = $nameEn !== '' ? $nameEn : $nameZh; break; } if ($ddpayBankName === '') { return $this->mobileError(1001, 'Bank code not configured for withdrawal'); } $user = $this->auth->getUser(); $userId = intval(strval($user->id)); // 幂等:相同 idempotency_key 重试直接返回已创建订单 $idemOrder = Db::name('withdraw_order')->where('idempotency_key', $idempotencyKey)->find(); if ($idemOrder) { $idemUserId = is_numeric(strval($idemOrder['user_id'] ?? null)) ? intval(strval($idemOrder['user_id'])) : 0; if ($idemUserId !== $userId) { return $this->mobileError(1002, 'Idempotency key conflict'); } $idemStatus = $this->intValue($idemOrder['status'] ?? 0); return $this->mobileSuccess([ 'order_no' => is_string($idemOrder['order_no'] ?? null) ? $idemOrder['order_no'] : strval($idemOrder['order_no'] ?? ''), 'status' => $this->mapWithdrawStatus($idemStatus), 'fee_coin' => $this->amountNumber($idemOrder['fee'] ?? '0'), 'actual_arrival_coin' => $this->amountNumber($idemOrder['actual_amount'] ?? '0'), 'risk_review_required' => $idemStatus === 0, ]); } // 待审核订单数限制:同一用户最多 MAX_PENDING_WITHDRAW 笔 status=0(待审核) $pendingCount = Db::name('withdraw_order') ->where('user_id', $userId) ->where('status', 0) ->count(); if ($pendingCount >= WithdrawFlow::MAX_PENDING_WITHDRAW) { return $this->mobileError(2004, 'Too many pending withdraw orders', [ 'max_pending' => WithdrawFlow::MAX_PENDING_WITHDRAW, 'pending_count' => $pendingCount, ]); } $balanceBefore = bcadd(strval($user->coin ?? '0'), '0', 2); if (bccomp($balanceBefore, $withdrawCoin, 2) < 0) { return $this->mobileError(2001, 'Insufficient balance'); } // 单笔上限校验:提现金额 <= min(coin, max_withdraw_by_flow) // - max_withdraw_by_flow = max(0, bet_flow_coin / ratio - total_withdraw_coin) // - ratio = 0 视为"不限打码",上限仅取余额 // 超过上限直接回传 max_withdrawable,前端可据此提示"最大可提现金额为 XXX"。 $flowStatus = WithdrawFlow::status($userId, [ 'total_deposit_coin' => $user->total_deposit_coin ?? '0', 'total_withdraw_coin' => $user->total_withdraw_coin ?? '0', 'bet_flow_coin' => $user->bet_flow_coin ?? '0', ]); $maxWithdrawable = WithdrawFlow::maxWithdrawable($balanceBefore, $flowStatus); if (bccomp($withdrawCoin, $maxWithdrawable, 2) > 0) { return $this->mobileError(2002, 'Withdraw exceeds available bet flow', [ 'max_withdrawable' => $this->amountNumber($maxWithdrawable), 'coin_balance' => $this->amountNumber($balanceBefore), 'bet_flow_coin' => $this->amountNumber($flowStatus['bet_flow_coin']), 'total_withdraw_coin' => $this->amountNumber(WithdrawFlow::amountString($user->total_withdraw_coin ?? '0')), 'ratio' => floatval($flowStatus['ratio']), 'max_withdraw_by_flow' => $flowStatus['flow_unlimited'] ? null : $this->amountNumber($flowStatus['max_withdraw_by_flow']), ]); } $channelIdRaw = $user->channel_id ?? null; $channelId = ($channelIdRaw !== null && $channelIdRaw !== '' && is_numeric(strval($channelIdRaw))) ? intval(strval($channelIdRaw)) : null; $orderNo = 'WD' . date('YmdHis') . substr(str_replace('.', '', uniqid('', true)), -6); $feeCoin = bcmul($withdrawCoin, '0.005', 2); $actualArrivalCoin = bcsub($withdrawCoin, $feeCoin, 2); $balanceAfter = bcsub($balanceBefore, $withdrawCoin, 2); $now = time(); Db::startTrans(); try { // 钱包即时扣减(冻结语义):审核通过即定稿;审核驳回在管理端回冲。 $affected = Db::name('user') ->where('id', $userId) ->where('coin', '>=', $withdrawCoin) ->update([ 'coin' => Db::raw('coin - ' . $withdrawCoin), 'total_withdraw_coin' => Db::raw('total_withdraw_coin + ' . $withdrawCoin), 'update_time' => $now, ]); if ($affected <= 0) { Db::rollback(); return $this->mobileError(2001, 'Insufficient balance'); } $orderId = Db::name('withdraw_order')->insertGetId([ 'order_no' => $orderNo, 'idempotency_key' => $idempotencyKey, 'user_id' => $userId, 'channel_id' => $channelId, 'pay_channel' => $channelCode, 'amount' => $withdrawCoin, 'fee' => $feeCoin, 'actual_amount' => $actualArrivalCoin, 'receive_type' => $receiveType, 'receive_account' => $receiveAccount, 'receiver_email' => $receiverEmail, 'receiver_mobile' => $receiverMobile, 'ddpay_receiver_name' => $receiverName, 'ddpay_bank_name' => $ddpayBankName, 'ddpay_bank_branch' => $bankBranch, 'status' => 0, 'review_admin_id' => null, 'review_time' => null, 'remark' => '', 'create_time' => $now, 'update_time' => $now, ]); Db::name('user_wallet_record')->insert([ 'user_id' => $userId, 'channel_id' => $channelId, 'biz_type' => 'withdraw', 'direction' => 2, 'amount' => $withdrawCoin, 'balance_before' => $balanceBefore, 'balance_after' => $balanceAfter, 'ref_type' => 'withdraw_order', 'ref_id' => $orderId, 'idempotency_key' => 'wd_apply_' . $orderNo, 'operator_admin_id' => null, 'remark' => '用户申请提现(待审核冻结):' . $orderNo, 'create_time' => $now, ]); Db::commit(); } catch (Throwable $e) { Db::rollback(); return $this->mobileError(2000, $e->getMessage()); } return $this->mobileSuccess([ 'order_no' => $orderNo, 'status' => 'pending_review', 'fee_coin' => $this->amountNumber($feeCoin), 'actual_arrival_coin' => $this->amountNumber($actualArrivalCoin), 'risk_review_required' => true, ]); } /** * 查看提现订单详情(原 withdrawDetail)。根据 order_no 返回完整订单快照。 */ public function withdrawDetail(Request $request): Response { $response = $this->initializeMobile($request); if ($response !== null) { return $response; } $orderNo = $this->stringParam($request->input('order_no')); if ($orderNo === '') { return $this->mobileError(1001, 'Missing parameters'); } $order = WithdrawOrder::where('order_no', $orderNo)->where('user_id', $this->auth->id)->find(); if (!$order) { return $this->mobileError(2003, 'Order does not exist'); } $remarkRaw = $order->remark ?? ''; $remark = is_string($remarkRaw) ? $remarkRaw : strval($remarkRaw); $statusCode = $this->intValue($order->status); return $this->mobileSuccess([ 'order_no' => $order->order_no, 'status' => $this->mapWithdrawStatus($statusCode), 'withdraw_coin' => $this->amountNumber($order->amount ?? '0'), 'fee_coin' => $this->amountNumber($order->fee ?? '0'), 'actual_arrival_coin' => $this->amountNumber($order->actual_amount ?? '0'), 'receive_type' => is_string($order->receive_type ?? null) ? $order->receive_type : strval($order->receive_type ?? ''), 'receive_account' => is_string($order->receive_account ?? null) ? $order->receive_account : strval($order->receive_account ?? ''), 'pay_channel' => is_string($order->pay_channel ?? null) ? $order->pay_channel : strval($order->pay_channel ?? ''), 'receiver_email' => is_string($order->receiver_email ?? null) ? $order->receiver_email : strval($order->receiver_email ?? ''), 'receiver_mobile' => is_string($order->receiver_mobile ?? null) ? $order->receiver_mobile : strval($order->receiver_mobile ?? ''), 'reject_reason' => $statusCode === 2 && $remark !== '' ? $remark : null, 'create_time' => $order->create_time, 'review_time' => $order->review_time, ]); } /** * 查询当前用户的提现订单列表(分页)。列表项返回 order_no / amount / status, * 手续费、实到账、拒绝原因等请调用 /api/finance/withdrawDetail。 */ public function withdrawList(Request $request): Response { $response = $this->initializeMobile($request); if ($response !== null) { return $response; } $page = $this->intValue($request->input('page', 1)); if ($page <= 0) { $page = 1; } $pageSize = $this->intValue($request->input('page_size', 20)); if ($pageSize <= 0 || $pageSize > 100) { $pageSize = 20; } $paginate = WithdrawOrder::where('user_id', $this->auth->id) ->order('id', 'desc') ->paginate(['page' => $page, 'list_rows' => $pageSize]); $list = []; foreach ($paginate->items() as $row) { $list[] = [ 'order_no' => $row->order_no, 'amount' => $this->amountNumber($row->amount ?? '0'), 'status' => $this->mapWithdrawStatus($row->status ?? null), ]; } return $this->mobileSuccess([ 'list' => $list, 'pagination' => [ 'page' => $paginate->currentPage(), 'page_size' => $paginate->listRows(), 'total' => $paginate->total(), ], ]); } /** * 收银台配置:货币列表(含充值/提现汇率)、支付渠道(pay_channels)、提现银行与文案(供充值/提现页展示) */ public function cashierConfig(Request $request): Response { return $this->buildDepositWithdrawConfig($request); } /** * 充值/提现配置(推荐新接口,兼容 cashierConfig 相同返回结构) */ public function depositWithdrawConfig(Request $request): Response { return $this->buildDepositWithdrawConfig($request); } private function buildDepositWithdrawConfig(Request $request): Response { $response = $this->initializeMobile($request); if ($response !== null) { return $response; } $lang = $this->currentLang(); $isZh = str_starts_with($lang, 'zh'); $row = GameConfig::where('config_key', FinanceCashierConfigLib::CONFIG_KEY)->find(); $cfg = FinanceCashierConfigLib::parseFromConfigValue($row?->config_value ?? null); $pc = $cfg['platform_coin'] ?? []; $platformLabel = ''; if (is_array($pc)) { $platformLabel = $isZh ? (is_string($pc['label_zh'] ?? null) ? $pc['label_zh'] : '') : (is_string($pc['label_en'] ?? null) ? $pc['label_en'] : ''); } $currencies = []; if (isset($cfg['currencies']) && is_array($cfg['currencies'])) { $list = $cfg['currencies']; usort($list, function (array $a, array $b): int { return $this->sortBySortKeyThenCode($a, $b); }); foreach ($list as $c) { if (!is_array($c)) { continue; } $code = isset($c['code']) && is_string($c['code']) ? $c['code'] : ''; if ($code === '') { continue; } $dep = isset($c['deposit_coins_per_fiat']) && is_string($c['deposit_coins_per_fiat']) ? $c['deposit_coins_per_fiat'] : ''; $wdr = isset($c['withdraw_coins_per_fiat']) && is_string($c['withdraw_coins_per_fiat']) ? $c['withdraw_coins_per_fiat'] : ''; $currencies[] = [ 'code' => $code, 'label' => $isZh ? (is_string($c['label_zh'] ?? null) ? $c['label_zh'] : '') : (is_string($c['label_en'] ?? null) ? $c['label_en'] : ''), 'deposit_coins_per_fiat' => $dep, 'withdraw_coins_per_fiat' => $wdr, ]; } } $rates = []; foreach ($currencies as $c) { if (!is_array($c)) { continue; } $cur = isset($c['code']) && is_string($c['code']) ? $c['code'] : ''; $ratio = isset($c['withdraw_coins_per_fiat']) && is_string($c['withdraw_coins_per_fiat']) ? $c['withdraw_coins_per_fiat'] : ''; if ($cur === '' || $ratio === '') { continue; } $rates[] = [ 'currency' => $cur, 'diamonds_per_fiat_unit' => $ratio, ]; } $withdrawBanks = []; if (isset($cfg['withdraw_banks']) && is_array($cfg['withdraw_banks'])) { $list = $cfg['withdraw_banks']; usort($list, function (array $a, array $b): int { $ca = isset($a['currency_code']) && is_string($a['currency_code']) ? $a['currency_code'] : ''; $cb = isset($b['currency_code']) && is_string($b['currency_code']) ? $b['currency_code'] : ''; if ($ca !== $cb) { return strcmp($ca, $cb); } $cmp = $this->sortBySortKeyOnly($a, $b); if ($cmp !== 0) { return $cmp; } $ka = isset($a['code']) && is_string($a['code']) ? $a['code'] : ''; $kb = isset($b['code']) && is_string($b['code']) ? $b['code'] : ''; return strcmp($ka, $kb); }); foreach ($list as $b) { if (!is_array($b)) { continue; } $currencyCode = isset($b['currency_code']) && is_string($b['currency_code']) ? strtoupper(trim($b['currency_code'])) : ''; $code = isset($b['code']) && is_string($b['code']) ? $b['code'] : ''; if ($currencyCode === '' || $code === '') { continue; } $withdrawBanks[] = [ 'currency_code' => $currencyCode, 'code' => $code, 'name' => $isZh ? (is_string($b['name_zh'] ?? null) ? $b['name_zh'] : '') : (is_string($b['name_en'] ?? null) ? $b['name_en'] : ''), ]; } } $depositBanks = []; if (isset($cfg['deposit_banks']) && is_array($cfg['deposit_banks'])) { $list = $cfg['deposit_banks']; usort($list, function (array $a, array $b): int { $ca = isset($a['currency_code']) && is_string($a['currency_code']) ? $a['currency_code'] : ''; $cb = isset($b['currency_code']) && is_string($b['currency_code']) ? $b['currency_code'] : ''; if ($ca !== $cb) { return strcmp($ca, $cb); } $cmp = $this->sortBySortKeyOnly($a, $b); if ($cmp !== 0) { return $cmp; } $ka = isset($a['code']) && is_string($a['code']) ? $a['code'] : ''; $kb = isset($b['code']) && is_string($b['code']) ? $b['code'] : ''; return strcmp($ka, $kb); }); foreach ($list as $b) { if (!is_array($b)) { continue; } $currencyCode = isset($b['currency_code']) && is_string($b['currency_code']) ? strtoupper(trim($b['currency_code'])) : ''; $code = isset($b['code']) && is_string($b['code']) ? $b['code'] : ''; if ($currencyCode === '' || $code === '') { continue; } $depositBanks[] = [ 'currency_code' => $currencyCode, 'code' => $code, 'name' => $isZh ? (is_string($b['name_zh'] ?? null) ? $b['name_zh'] : '') : (is_string($b['name_en'] ?? null) ? $b['name_en'] : ''), ]; } } $wl = $cfg['withdraw_limits'] ?? []; $minEw = is_array($wl) && isset($wl['min_ewallet']) && is_string($wl['min_ewallet']) ? $wl['min_ewallet'] : '0'; $minBk = is_array($wl) && isset($wl['min_bank']) && is_string($wl['min_bank']) ? $wl['min_bank'] : '0'; $wc = $cfg['withdraw_copy'] ?? []; $rateMode = is_array($wc) && isset($wc['rate_mode']) && is_string($wc['rate_mode']) ? $wc['rate_mode'] : 'fixed'; $effectiveCh = DepositChannelLib::effectiveRowsFromDb(); $withdrawPayChannels = DepositChannelLib::channelsForWithdraw($effectiveCh, $lang); $payChannels = []; $regCh = DepositChannelLib::codeRegistry(); foreach ($effectiveCh as $row) { if (!is_array($row)) { continue; } $code = isset($row['code']) && is_string($row['code']) ? $row['code'] : ''; if ($code === '' || !isset($regCh[$code]) || !is_array($regCh[$code])) { continue; } if ($code === MockPay::CHANNEL_CODE && !MockPay::isEnabled()) { continue; } $meta = $regCh[$code]; $nameZh = isset($meta['name']) && is_string($meta['name']) ? $meta['name'] : ''; $nameEn = isset($meta['name_en']) && is_string($meta['name_en']) ? $meta['name_en'] : ''; $sortNum = filter_var($row['sort'] ?? 0, FILTER_VALIDATE_INT); $statusNum = filter_var($row['status'] ?? 0, FILTER_VALIDATE_INT); $payChannels[] = [ 'code' => $code, 'name' => $isZh ? $nameZh : ($nameEn !== '' ? $nameEn : $nameZh), 'sort' => $sortNum !== false ? $sortNum : 0, 'status' => $statusNum !== false ? $statusNum : 0, 'tier_ids' => isset($row['tier_ids']) && is_array($row['tier_ids']) ? $row['tier_ids'] : [], ]; } usort($payChannels, function (array $a, array $b): int { $sa = isset($a['sort']) ? filter_var($a['sort'], FILTER_VALIDATE_INT) : false; $sb = isset($b['sort']) ? filter_var($b['sort'], FILTER_VALIDATE_INT) : false; $ia = $sa === false ? 0 : $sa; $ib = $sb === false ? 0 : $sb; if ($ia !== $ib) { return $ia <=> $ib; } $ca = isset($a['code']) && is_string($a['code']) ? $a['code'] : ''; $cb = isset($b['code']) && is_string($b['code']) ? $b['code'] : ''; return strcmp($ca, $cb); }); return $this->mobileSuccess([ 'platform_coin_label' => $platformLabel, 'currencies' => $currencies, 'rates' => $rates, 'pay_channels' => $payChannels, 'deposit' => [ 'banks' => $depositBanks, ], 'withdraw' => [ 'pay_channels' => $withdrawPayChannels, 'banks' => $withdrawBanks, 'min_ewallet' => $minEw, 'min_bank' => $minBk, 'rate_hint' => $isZh ? (is_array($wc) && is_string($wc['rate_hint_zh'] ?? null) ? $wc['rate_hint_zh'] : '') : (is_array($wc) && is_string($wc['rate_hint_en'] ?? null) ? $wc['rate_hint_en'] : ''), 'processing_note' => $isZh ? (is_array($wc) && is_string($wc['processing_zh'] ?? null) ? $wc['processing_zh'] : '') : (is_array($wc) && is_string($wc['processing_en'] ?? null) ? $wc['processing_en'] : ''), 'fee_note' => $isZh ? (is_array($wc) && is_string($wc['fee_note_zh'] ?? null) ? $wc['fee_note_zh'] : '') : (is_array($wc) && is_string($wc['fee_note_en'] ?? null) ? $wc['fee_note_en'] : ''), 'rate_mode' => $rateMode, // 与 DDPay 出金及 withdrawCreate 一致,不由后台开关配置 'fields' => [ 'receive_type_bank_only' => true, 'require_channel_code' => true, 'require_receiver_name' => true, 'require_receive_account' => true, 'require_receiver_email' => true, 'require_receiver_mobile' => true, 'require_bank_code' => true, 'require_bank_branch' => false, ], ], ]); } /** * @param array $a * @param array $b */ private function sortBySortKeyThenCode(array $a, array $b): int { $sa = isset($a['sort']) ? filter_var($a['sort'], FILTER_VALIDATE_INT) : false; $sb = isset($b['sort']) ? filter_var($b['sort'], FILTER_VALIDATE_INT) : false; $ia = $sa === false ? 0 : $sa; $ib = $sb === false ? 0 : $sb; if ($ia !== $ib) { return $ia <=> $ib; } $ca = isset($a['code']) && is_string($a['code']) ? $a['code'] : ''; $cb = isset($b['code']) && is_string($b['code']) ? $b['code'] : ''; return strcmp($ca, $cb); } /** * @param array $a * @param array $b */ private function sortBySortKeyOnly(array $a, array $b): int { $sa = isset($a['sort']) ? filter_var($a['sort'], FILTER_VALIDATE_INT) : false; $sb = isset($b['sort']) ? filter_var($b['sort'], FILTER_VALIDATE_INT) : false; $ia = $sa === false ? 0 : $sa; $ib = $sb === false ? 0 : $sb; return $ia <=> $ib; } private function stringParam($raw): string { if ($raw === null) { return ''; } if (!is_string($raw)) { return ''; } return trim($raw); } /** * DDPay 入金 payment_type:官方枚举 01=FPX / 02=duitnow / 03=ewallet */ private function isValidDdpayPaymentType(string $paymentType): bool { return in_array($paymentType, ['01', '02', '03'], true); } /** * 收款人手机号:5–32 位,仅允许数字与常见分隔符(+ - 空格) */ private function isValidReceiverMobile(string $mobile): bool { $len = mb_strlen($mobile); if ($len < 5 || $len > 32) { return false; } if (!preg_match('/^[0-9+\-\s]+$/', $mobile)) { return false; } $digits = preg_replace('/\D/', '', $mobile); return is_string($digits) && strlen($digits) >= 5; } private function loadEnabledTiers(): array { $row = GameConfig::where('config_key', DepositTierLib::CONFIG_KEY)->find(); $all = DepositTierLib::parseFromConfigValue($row?->config_value ?? null); return DepositTierLib::publicList($all); } /** * @return list}> */ private function loadDepositChannelEffective(): array { return DepositChannelLib::effectiveRowsFromDb(); } private function mapDepositStatus($status): string { $code = $this->intValue($status); if ($code === 1) { return 'paid'; } if ($code === MockPay::DEPOSIT_STATUS_PENDING_REVIEW) { return 'pending_review'; } if ($code === 2) { return 'failed'; } return 'pending'; } /** * 映射 withdraw_order.status(0 待审 / 1 通过 / 2 拒绝 / 3 已打款)到移动端状态字符串 */ private function mapWithdrawStatus($statusCode): string { $code = $this->intValue($statusCode); if ($code === 1 || $code === 3) { return 'approved'; } if ($code === 2) { return 'rejected'; } return 'pending_review'; } private function intValue($value): int { $result = filter_var($value, FILTER_VALIDATE_INT); if ($result === false) { return 0; } return $result; } private function intParam($raw): int { if ($raw === null || $raw === '') { return 0; } if (is_numeric(strval($raw))) { return intval(strval($raw)); } return 0; } private function verifyMockDepositLinkSign(string $orderNo, int $expireAt, string $sign): bool { return MockPay::verifyDepositLink($orderNo, $expireAt, $sign); } /** * 生成带签名的前端静态收银台 URL;订单不存在或非 mock 时返回空串 */ private function buildMockDepositFrontendUrl(string $orderNo, string $publicOrigin): string { DepositOrderExpireService::expirePendingOrders(null, $orderNo); $row = Db::name('deposit_order')->where('order_no', $orderNo)->find(); if (!is_array($row)) { return ''; } $payChannel = is_string($row['pay_channel'] ?? null) ? strtolower(trim($row['pay_channel'])) : ''; if ($payChannel !== MockPay::CHANNEL_CODE) { return ''; } $createTime = is_numeric($row['create_time'] ?? null) ? intval($row['create_time']) : time(); $amount = $this->amountString($row['amount'] ?? '0'); $bonus = $this->amountString($row['bonus_amount'] ?? '0'); $expireAt = 0; $sign = ''; $snapRaw = $row['pay_account_snapshot'] ?? ''; if (is_string($snapRaw) && trim($snapRaw) !== '') { $decoded = json_decode($snapRaw, true); if (is_array($decoded)) { if (isset($decoded['expire_at']) && is_numeric($decoded['expire_at'])) { $expireAt = intval($decoded['expire_at']); } if (isset($decoded['mock_pay_sign']) && is_string($decoded['mock_pay_sign'])) { $sign = trim($decoded['mock_pay_sign']); } } } if ($expireAt <= 0 || $sign === '' || !MockPay::verifyDepositLink($orderNo, $expireAt, $sign)) { $linkAuth = MockPay::buildDepositLinkAuth($orderNo, $createTime); $expireAt = $linkAuth['expire_at']; $sign = $linkAuth['sign']; } return MockPay::depositPageUrl($orderNo, $publicOrigin, $expireAt, $sign, $amount, $bonus); } }