Files
webman-buildadmin/app/api/controller/Finance.php

1467 lines
63 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\api\controller;
use app\common\library\finance\DepositSettlement;
use app\common\library\finance\DDPayGateway;
use app\common\library\finance\WithdrawFlow;
use app\common\library\game\DepositChannel as DepositChannelLib;
use app\common\library\game\DepositTier as DepositTierLib;
use app\common\library\game\FinanceCashierConfig as FinanceCashierConfigLib;
use app\common\model\DepositOrder;
use app\common\model\GameConfig;
use app\common\model\WithdrawOrder;
use app\common\service\DepositOrderExpireService;
use support\Log;
use support\Response;
use support\think\Db;
use Throwable;
use RuntimeException;
use Webman\Http\Request;
use function response;
class Finance extends MobileBase
{
/**
* DDPay Webhook / 重定向等无需用户登录态。
*/
protected array $noNeedLogin = [
'ddpayDepositNotify',
'ddpayPayoutNotify',
];
/**
* DDPay 回调与重定向允许浏览器直接访问(无 auth-token
*/
protected array $noNeedAuthToken = [
'ddpayDepositNotify',
'ddpayDepositRedirect',
'ddpayPayoutNotify',
'ddpayPayoutRedirect',
];
/**
* 充值档位列表(仅启用档位,按 sort 升序)
*/
public function depositTierList(Request $request): Response
{
$response = $this->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
*
* `channel_code` 必须为 `ddpay`。服务端创建待支付订单后调用 DDPay「入金发起」返回三方 `payment_url` 作为 `pay_url`
* 入账由 `ddpayDepositNotify` Webhook 验签后调用 `DepositSettlement::settle`(或发起响应中 `transaction_status=completed` 时同步结算)。
*
* 请求application/json 或 x-www-form-urlencoded
* - tier_id / tier_key、channel_code=ddpay、idempotency_key必填
* - payment_type、payer_name、payer_bank_nameDDPay 入金必填(见 DDPay 文档与移动端接口说明)
*
* 响应:`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 !== 'ddpay') {
return $this->mobileError(2004, 'Deposit only supports DDPay');
}
$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::EXPIRE_SECONDS,
]);
}
$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,
];
$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' => '',
'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);
// 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');
}
$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(),
]);
}
return $this->mobileError(2000, 'DDPay deposit initiation failed');
}
$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));
}
/**
* 将订单模型转换为统一的创建/详情响应数据
*
* @param string|null $publicOrigin 如 https://api.xxx.com待支付时用于拼完整 pay_url为 null 时仅返回以 / 开头的 path+query
*/
private function buildDepositResponse($order, ?string $publicOrigin = null): array
{
$status = $this->mapDepositStatus($order->status);
$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 = '';
$payChannel = is_string($order->pay_channel) ? $order->pay_channel : strval($order->pay_channel);
if ($this->intValue($order->status) === 0 && $on !== '') {
if ($payChannel === 'ddpay') {
$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']);
}
}
}
return [
'order_no' => $on,
'amount' => $this->amountNumber($amount),
'bonus_amount' => $this->amountNumber($bonus),
'total_amount' => $this->amountNumber($total),
'status' => $status,
'paid' => $paid,
'pay_channel' => $payChannel,
'pay_url' => $payUrl,
'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() 需要传 keyWebhook 签名计算只依赖文档列出的固定字段。
$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 = '<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>DDPay</title></head><body style="font-family:system-ui;padding:1rem">';
$html .= '<h2>DDPay</h2>';
if ($orderNoEsc !== '') {
$html .= '<p>Order: ' . $orderNoEsc . '</p>';
}
$html .= '<p>' . $msgEsc . '</p>';
$html .= '</body></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, 'Withdraw only supports DDPay');
}
$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;
}
$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<string, mixed> $a
* @param array<string, mixed> $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<string, mixed> $a
* @param array<string, mixed> $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);
}
/**
* 收款人手机号532 位,仅允许数字与常见分隔符(+ - 空格)
*/
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<array{code: string, sort: int, status: int, tier_ids: list<string>}>
*/
private function loadDepositChannelEffective(): array
{
return DepositChannelLib::effectiveRowsFromDb();
}
private function mapDepositStatus($status): string
{
if ($this->intValue($status) === 1) {
return 'paid';
}
if ($this->intValue($status) === 2 || $this->intValue($status) === 3) {
return 'failed';
}
return 'pending';
}
/**
* 映射 withdraw_order.status0 待审 / 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;
}
}