Files
webman-buildadmin/app/api/controller/Finance.php
2026-04-24 13:49:38 +08:00

983 lines
43 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\DepositMockGateway;
use app\common\library\finance\DepositSettlement;
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\Response;
use support\think\Db;
use Throwable;
use Webman\Http\Request;
use function response;
class Finance extends MobileBase
{
/**
* 模拟第三方收银台页与支付回调,无需 user-token仅 HMAC 防篡改。
*/
protected array $noNeedLogin = ['depositMockPayPage', 'depositMockNotify'];
/**
* 允许浏览器直接打开 pay_url 而不带 auth-token。
*/
protected array $noNeedAuthToken = ['depositMockPayPage', 'depositMockNotify'];
/**
* 充值档位列表(仅启用档位,按 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),
];
}
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));
}
/**
* 创建充值订单
*
* 当前为 mock 支付网关:本接口仅创建待支付订单并返回 pay_url。
* 未来接入真实第三方支付时,仅需替换 pay_url 生成与回调验签,入账仍在回调中调用 DepositSettlement::settle。
*
* 请求application/json 或 x-www-form-urlencoded
* - tier_id / tier_key: 必填,档位唯一标识(与 depositTierList 中 id、tier_key 一致)
* - channel_code: 必填,支付渠道代码(与 depositTierList 各档位 channels[].code 一致)
* - idempotency_key: 必填,客户端幂等键,短时间内重复提交只生成一次订单
*
* 流程:仅创建 `status=0` 的待支付订单,返回 `pay_url`(含签名的模拟「第三方收银台」页);玩家打开后点确认,
* 由服务端 `depositMockNotify` 模拟网关联调完成入账。未来接入真实三方时,将「打开 pay_url + 等回调」替换为
* 真网关,入账仍走 `DepositSettlement::settle`。
*
* 响应(统一结构,未来接入第三方也保持此形状):
* - 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');
}
$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';
}
$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);
}
// 仅落待支付单;真实入账在模拟网关联调 depositMockNotify 中完成
return $this->mobileSuccess($this->buildDepositResponse($order, $this->publicOriginFromRequest($request)));
}
/**
* 将订单模型转换为统一的创建/详情响应数据
*
* @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 = '';
if ($this->intValue($order->status) === 0 && $on !== '') {
$payUrl = DepositMockGateway::payPageUrl($on, $publicOrigin);
}
return [
'order_no' => $on,
'amount' => $this->amountNumber($amount),
'bonus_amount' => $this->amountNumber($bonus),
'total_amount' => $this->amountNumber($total),
'status' => $status,
'paid' => $paid,
'pay_channel' => is_string($order->pay_channel) ? $order->pay_channel : strval($order->pay_channel),
'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,
];
}
/**
* 根据请求拼出公网 origin用于给客户端直接可用的完整 pay_url。
*/
private function publicOriginFromRequest(Request $request): string
{
$proto = strtolower((string) $request->header('x-forwarded-proto', ''));
$https = $proto === 'https' || strtolower((string) $request->header('x-forwarded-ssl', '')) === 'on';
$scheme = $https ? 'https' : 'http';
$host = trim((string) $request->header('host', ''));
if ($host === '') {
$host = trim((string) ($request->header('x-forwarded-host', '')));
}
if ($host === '') {
$host = '127.0.0.1:8787';
}
return $scheme . '://' . $host;
}
/**
* 模拟第三方支付收银台HTML。玩家浏览器打开点击按钮即向 depositMockNotify 发起回调。
*/
public function depositMockPayPage(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$orderNo = $this->stringParam($request->input('order_no'));
$sign = $this->stringParam($request->input('sign'));
if ($orderNo === '' || $sign === '' || !DepositMockGateway::verifyOrderNo($orderNo, $sign)) {
return response('Invalid or expired payment link', 403, [
'Content-Type' => 'text/plain; charset=utf-8',
]);
}
$order = DepositOrder::where('order_no', $orderNo)->find();
if (!$order) {
return response('Order not found', 404, [
'Content-Type' => 'text/plain; charset=utf-8',
]);
}
DepositOrderExpireService::expirePendingOrders(null, $orderNo);
$order = DepositOrder::where('order_no', $orderNo)->find();
if (!$order) {
return response('Order not found', 404, [
'Content-Type' => 'text/plain; charset=utf-8',
]);
}
if ($this->intValue($order->status) !== 0) {
$st = $this->mapDepositStatus($order->status);
$msg = 'Order status: ' . $st;
if ($st === 'paid') {
$msg = 'This order is already paid. You can return to the app.';
}
$msgEsc = htmlspecialchars($msg, ENT_QUOTES, 'UTF-8');
return response('<!doctype html><html><head><meta charset="utf-8"><title>充值</title></head><body><p>' . $msgEsc . '</p></body></html>', 200, [
'Content-Type' => 'text/html; charset=utf-8',
]);
}
$amount = $this->amountString($order->amount);
$bonus = $this->amountString($order->bonus_amount);
$noEsc = htmlspecialchars($orderNo, ENT_QUOTES, 'UTF-8');
$signEsc = htmlspecialchars($sign, ENT_QUOTES, 'UTF-8');
$payChannel = is_string($order->pay_channel) ? htmlspecialchars($order->pay_channel, ENT_QUOTES, 'UTF-8') : '';
$html = '<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>模拟支付</title></head><body style="font-family:system-ui;padding:1rem">';
$html .= '<h2>模拟第三方收银台</h2>';
$html .= '<p>订单号:' . $noEsc . '</p>';
$html .= '<p>支付渠道:' . $payChannel . '</p>';
$html .= '<p>金额(法币/标价):' . htmlspecialchars($amount, ENT_QUOTES, 'UTF-8') . ' + 赠送 ' . htmlspecialchars($bonus, ENT_QUOTES, 'UTF-8') . '(币)</p>';
$html .= '<p>点击下方按钮即视为<strong>第三方支付成功</strong>,服务端会回调并到账。</p>';
$html .= '<form method="post" action="/api/finance/depositMockNotify" style="margin-top:1rem">';
$html .= '<input type="hidden" name="order_no" value="' . $noEsc . '">';
$html .= '<input type="hidden" name="sign" value="' . $signEsc . '">';
$html .= '<button type="submit" style="padding:0.5rem 1rem">确认支付(模拟成功)</button>';
$html .= '</form></body></html>';
return response($html, 200, [
'Content-Type' => 'text/html; charset=utf-8',
]);
}
/**
* 模拟第三方异步通知:验签后调用 DepositSettlement::settle 入账。
*/
public function depositMockNotify(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$orderNo = $this->stringParam($request->input('order_no'));
$sign = $this->stringParam($request->input('sign'));
if ($orderNo === '' || $sign === '') {
return $this->mobileError(1001, 'Missing parameters');
}
if (!DepositMockGateway::verifyOrderNo($orderNo, $sign)) {
return $this->mobileError(1003, 'Invalid parameter value');
}
$order = DepositOrder::where('order_no', $orderNo)->find();
if (!$order) {
return $this->mobileError(2003, 'Order does not exist');
}
DepositOrderExpireService::expirePendingOrders(null, $orderNo);
$order = DepositOrder::where('order_no', $orderNo)->find();
if (!$order) {
return $this->mobileError(2003, 'Order does not exist');
}
if ($this->intValue($order->status) !== 0) {
return $this->mobileSuccess($this->buildDepositResponse($order, null));
}
$orderId = intval(strval($order->id));
if ($orderId <= 0) {
return $this->mobileError(2000, 'Order id invalid');
}
$pc = is_string($order->pay_channel) ? $order->pay_channel : strval($order->pay_channel);
try {
$result = DepositSettlement::settle(
$orderId,
DepositSettlement::SOURCE_THIRD_PARTY,
'mock third party notify',
null,
'channel_code=' . $pc
);
} catch (Throwable $e) {
return $this->mobileError(2000, $e->getMessage());
}
$fresh = DepositOrder::where('order_no', $orderNo)->find();
if (!$fresh) {
return $this->mobileError(2000, 'Order not found after settle');
}
return $this->mobileSuccess($this->buildDepositResponse($fresh, null));
}
/**
* 将任意金额输入归一化为 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) : '');
$receiveAccount = trim(is_string($request->post('receive_account', '')) ? $request->post('receive_account', '') : '');
$receiveType = trim(is_string($request->post('receive_type', '')) ? $request->post('receive_type', '') : '');
$idempotencyKey = trim(is_string($request->post('idempotency_key', '')) ? $request->post('idempotency_key', '') : '');
if ($withdrawCoin === '' || $receiveAccount === '' || $receiveType === '' || $idempotencyKey === '') {
return $this->mobileError(1001, 'Missing parameters');
}
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);
$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,
'amount' => $withdrawCoin,
'fee' => $feeCoin,
'actual_amount' => $actualArrivalCoin,
'receive_type' => $receiveType,
'receive_account' => $receiveAccount,
'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 ?? ''),
'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,
];
}
$banks = [];
if (isset($cfg['withdraw_banks']) && is_array($cfg['withdraw_banks'])) {
$list = $cfg['withdraw_banks'];
usort($list, function (array $a, array $b): int {
$cmp = $this->sortBySortKeyOnly($a, $b);
if ($cmp !== 0) {
return $cmp;
}
$ca = isset($a['code']) && is_string($a['code']) ? $a['code'] : '';
$cb = isset($b['code']) && is_string($b['code']) ? $b['code'] : '';
return strcmp($ca, $cb);
});
foreach ($list as $b) {
if (!is_array($b)) {
continue;
}
$code = isset($b['code']) && is_string($b['code']) ? $b['code'] : '';
if ($code === '') {
continue;
}
$banks[] = [
'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';
$wf = $cfg['withdraw_fields'] ?? [];
$reqCard = is_array($wf) && !empty($wf['require_cardholder']);
$reqAcct = is_array($wf) && !empty($wf['require_bank_account']);
$reqMail = is_array($wf) && !empty($wf['require_email']);
$reqMob = is_array($wf) && !empty($wf['require_mobile']);
$payChannels = [];
$effectiveCh = DepositChannelLib::effectiveRowsFromDb();
$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,
'withdraw' => [
'banks' => $banks,
'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,
'fields' => [
'require_cardholder' => $reqCard,
'require_bank_account' => $reqAcct,
'require_email' => $reqMail,
'require_mobile' => $reqMob,
],
],
]);
}
/**
* @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);
}
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;
}
}