Files
webman-buildadmin/app/api/controller/Finance.php
2026-04-18 15:19:36 +08:00

539 lines
21 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\WithdrawFlow;
use app\common\library\game\DepositTier as DepositTierLib;
use app\common\model\DepositOrder;
use app\common\model\GameConfig;
use app\common\model\WithdrawOrder;
use support\Response;
use support\think\Db;
use Throwable;
use Webman\Http\Request;
class Finance extends MobileBase
{
/**
* 充值档位列表(仅启用档位,按 sort 升序)
*/
public function depositTierList(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$lang = $this->currentLang();
$tiers = $this->loadEnabledTiers();
$list = [];
foreach ($tiers as $tier) {
$amount = $this->amountString($tier['amount'] ?? '0');
$bonus = $this->amountString($tier['bonus_amount'] ?? '0');
$total = bcadd($amount, $bonus, 4);
$localized = DepositTierLib::localize($tier, $lang);
$list[] = [
'id' => $tier['id'],
'title' => $localized['title'],
'amount' => $amount,
'bonus_amount' => $bonus,
'total_amount' => $total,
'desc' => $localized['desc'],
];
}
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 进入网关"
* 并把入账动作放到网关回调里完成(回调中调用 DepositSettlement::settle
*
* 请求application/json 或 x-www-form-urlencoded
* - tier_id: 必填,档位 ID需在 game_config.deposit_tier 启用档位内)
* - idempotency_key: 必填,客户端幂等键,短时间内重复提交只生成一次订单
*
* 响应(统一结构,未来接入第三方也保持此形状):
* - 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'));
$idempotencyKey = $this->stringParam($request->input('idempotency_key'));
if ($tierId === '' || $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');
}
// 幂等命中:直接返回已有订单
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));
}
} catch (Throwable $e) {
// 忽略幂等查询失败,继续创建
}
$user = $this->auth->getUser();
$orderNo = 'DP' . date('YmdHis') . substr(str_replace('.', '', uniqid('', true)), -6);
$tierSnapshot = [
'id' => $tier['id'],
'title' => is_string($tier['title'] ?? null) ? $tier['title'] : '',
'title_en' => is_string($tier['title_en'] ?? null) ? $tier['title_en'] : '',
'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'] : '',
];
$now = time();
$channelId = null;
if (isset($user->channel_id) && is_numeric(strval($user->channel_id))) {
$channelId = intval(strval($user->channel_id));
}
$orderId = 0;
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' => 'mock_gateway',
'deposit_tier_id' => $tier['id'],
'proof_image' => '',
'pay_account_snapshot' => json_encode($tierSnapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'remark' => '',
'create_time' => $now,
'update_time' => $now,
]);
$orderId = intval($order->id);
} 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));
}
}
return $this->mobileError(2000, $msg);
}
// Mock 网关:立即结算,入账到钱包
try {
DepositSettlement::settle(
$orderId,
DepositSettlement::SOURCE_MOCK_GATEWAY,
'mock gateway auto settled'
);
} catch (Throwable $e) {
return $this->mobileError(2000, $e->getMessage());
}
$settled = DepositOrder::where('id', $orderId)->find();
if (!$settled) {
return $this->mobileError(2000, 'Order not found after settle');
}
return $this->mobileSuccess($this->buildDepositResponse($settled));
}
/**
* 将订单模型转换为统一的创建/详情响应数据
*/
private function buildDepositResponse($order): array
{
$status = $this->mapDepositStatus($order->status);
$paid = $status === 'paid';
$amount = $this->amountString($order->amount);
$bonus = $this->amountString($order->bonus_amount);
$total = bcadd($amount, $bonus, 4);
return [
'order_no' => is_string($order->order_no) ? $order->order_no : strval($order->order_no),
'amount' => $amount,
'bonus_amount' => $bonus,
'total_amount' => $total,
'status' => $status,
'paid' => $paid,
'pay_channel' => is_string($order->pay_channel) ? $order->pay_channel : strval($order->pay_channel),
'pay_url' => '',
'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,
];
}
/**
* 将任意金额输入归一化为 4 位小数字符串(不做类型强制转换)
*/
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.0000';
}
if ($s === '' || !is_numeric($s)) {
return '0.0000';
}
return bcadd($s, '0', 4);
}
/**
* 查看充值订单详情(原 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');
}
$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));
}
/**
* 查询当前用户的充值订单列表(分页)。列表项返回 order_no / amount / bonus_amount / status
* 其他字段请调用 /api/finance/depositDetail。
*/
public function depositList(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 = 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->amountString($row->amount ?? '0'),
'bonus_amount' => $this->amountString($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 (!is_numeric($withdrawCoin) || bccomp($withdrawCoin, '0', 4) <= 0) {
return $this->mobileError(1001, 'Invalid withdraw amount');
}
$withdrawCoin = bcadd($withdrawCoin, '0', 4);
$user = $this->auth->getUser();
$userId = intval(strval($user->id));
// 待审核订单数限制:同一用户最多 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', 4);
if (bccomp($balanceBefore, $withdrawCoin, 4) < 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, 4) > 0) {
return $this->mobileError(2002, 'Withdraw exceeds available bet flow', [
'max_withdrawable' => $maxWithdrawable,
'coin_balance' => $balanceBefore,
'bet_flow_coin' => $flowStatus['bet_flow_coin'],
'total_withdraw_coin' => WithdrawFlow::amountString($user->total_withdraw_coin ?? '0'),
'ratio' => $flowStatus['ratio'],
'max_withdraw_by_flow' => $flowStatus['flow_unlimited'] ? null : $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', 4);
$actualArrivalCoin = bcsub($withdrawCoin, $feeCoin, 4);
$balanceAfter = bcsub($balanceBefore, $withdrawCoin, 4);
$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,
'user_id' => $userId,
'channel_id' => $channelId,
'amount' => $withdrawCoin,
'fee' => $feeCoin,
'actual_amount' => $actualArrivalCoin,
'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' => $feeCoin,
'actual_arrival_coin' => $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' => $order->amount,
'fee_coin' => $order->fee,
'actual_arrival_coin' => $order->actual_amount,
'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->amountString($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(),
],
]);
}
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);
}
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;
}
}