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

1962 lines
85 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\MockPay;
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',
'mockDepositPage',
'mockDepositConfirm',
'mockDepositStatus',
];
/**
* DDPay 回调、模拟收银台页允许浏览器直接访问(无 auth-token
*/
protected array $noNeedAuthToken = [
'ddpayDepositNotify',
'ddpayDepositRedirect',
'ddpayPayoutNotify',
'ddpayPayoutRedirect',
'mockDepositPage',
'mockDepositConfirm',
'mockDepositStatus',
];
/**
* 充值档位列表(仅启用档位,按 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 或模拟支付 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<string, mixed>}
*/
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<string, mixed> $payload
*
* @return array<string, mixed>
*/
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 '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>'
. '<title>模拟充值</title><style>'
. 'body{font-family:system-ui,sans-serif;background:#f5f7fa;margin:0;padding:24px;}'
. '.card{max-width:420px;margin:40px auto;background:#fff;border-radius:12px;padding:24px;box-shadow:0 4px 24px rgba(0,0,0,.08);}'
. 'h1{font-size:20px;margin:0 0 8px;}p{color:#666;line-height:1.6;margin:8px 0;}'
. '.amt{font-size:28px;color:#1677ff;font-weight:700;margin:16px 0;}'
. 'button{width:100%;padding:14px;font-size:16px;border:0;border-radius:8px;background:#1677ff;color:#fff;cursor:pointer;margin-top:12px;}'
. 'button:disabled{background:#ccc;}'
. '.btn-back{display:none;background:#fff;color:#cf1322;border:1px solid #d9d9d9;}'
. '.hint{font-size:13px;color:#999;}'
. '.ok{display:none;margin-top:16px;padding:12px;background:#f6ffed;border:1px solid #b7eb8f;border-radius:8px;color:#389e0d;line-height:1.6;}'
. '</style></head><body>'
. '<div class="card"><h1>模拟充值收银台</h1>'
. '<p class="hint">订单号:' . $orderNoEsc . '</p>'
. '<p>充值金额 <strong>' . $amountEsc . '</strong>,赠送 <strong>' . $bonusEsc . '</strong></p>'
. '<div class="amt">预计到账 ' . $totalEsc . '</div>'
. '<p class="hint">链接有效期至 ' . $expireEsc . '(约 3 分钟,过期后订单将自动失效)</p>'
. '<button type="button" id="btnPay">确认支付</button>'
. '<div class="ok" id="okBox"><strong>支付成功(模拟)</strong><br/>订单已提交,需管理员在后台审核通过后才会入账。</div>'
. '<button type="button" class="btn-back" id="btnBackGame">返回游戏</button>'
. '<script>(function(){var confirmUrl=' . $confirmUrlJs . ',orderNo=' . $orderNoJs . ',returnGameUrl=' . $returnGameUrlJs . ';'
. 'function showBackGame(){if(!returnGameUrl){return;}var btn=document.getElementById("btnBackGame");btn.style.display="block";btn.onclick=function(){window.location.href=returnGameUrl;};}'
. 'document.getElementById("btnPay").onclick=function(){var btn=this;btn.disabled=true;btn.textContent="处理中...";'
. 'fetch(confirmUrl,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},'
. 'body:"order_no="+encodeURIComponent(orderNo)}).then(function(r){return r.json();}).then(function(res){'
. 'if(res&&res.code===1){document.getElementById("okBox").style.display="block";btn.style.display="none";'
. 'if(res.data&&res.data.return_game_url){returnGameUrl=res.data.return_game_url;}showBackGame();}'
. 'else{alert((res&&res.message)||"支付失败");btn.disabled=false;btn.textContent="确认支付";}})'
. '.catch(function(){alert("网络错误,请重试");btn.disabled=false;btn.textContent="确认支付";});};})();</script>'
. '</div></body></html>';
}
private function renderMockDepositMessageHtml(string $title, string $hint): string
{
$titleEsc = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
$hintHtml = $hint !== '' ? '<p>' . htmlspecialchars($hint, ENT_QUOTES, 'UTF-8') . '</p>' : '';
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>'
. '<title>' . $titleEsc . '</title>'
. '<style>body{font-family:system-ui,sans-serif;background:#f5f7fa;padding:40px;text-align:center;}'
. '.card{max-width:400px;margin:0 auto;background:#fff;padding:32px;border-radius:12px;}</style></head><body>'
. '<div class="card"><h1>' . $titleEsc . '</h1>' . $hintHtml . '</div></body></html>';
}
/**
* 将订单模型转换为统一的创建/详情响应数据
*
* @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() 需要传 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, '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<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);
}
/**
* DDPay 入金 payment_type官方枚举 01=FPX / 02=duitnow / 03=ewallet
*/
private function isValidDdpayPaymentType(string $paymentType): bool
{
return in_array($paymentType, ['01', '02', '03'], true);
}
/**
* 收款人手机号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
{
$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.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;
}
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);
}
}