From e3f26ba1f76a19fc23263ecf235c21842fe26e90 Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Sat, 18 Apr 2026 15:19:36 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=8E=A5=E5=8F=A3=E5=92=8C?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/controller/config/DepositTier.php | 130 +++++ app/admin/controller/order/DepositOrder.php | 72 ++- app/admin/controller/order/WithdrawOrder.php | 387 ++++++++++++++ app/admin/controller/user/User.php | 2 +- app/api/controller/Account.php | 59 ++- app/api/controller/Auth.php | 2 +- app/api/controller/Finance.php | 493 +++++++++++++++--- app/api/controller/Game.php | 26 +- app/api/controller/Notice.php | 8 +- app/api/controller/Wallet.php | 35 +- app/api/lang/en.php | 10 +- app/api/lang/zh-cn.php | 8 + .../library/finance/DepositSettlement.php | 218 ++++++++ app/common/library/finance/WithdrawFlow.php | 164 ++++++ app/common/library/game/DepositTier.php | 351 +++++++++++++ app/common/model/BetOrder.php | 2 - app/common/model/User.php | 3 +- app/common/service/GameBetSettleService.php | 39 +- app/common/service/GameLiveService.php | 5 +- app/common/service/GameRecordStatService.php | 6 +- config/route.php | 28 +- web/src/lang/backend/en/config/depositTier.ts | 29 ++ web/src/lang/backend/en/game/betOrder.ts | 4 +- web/src/lang/backend/en/game/live.ts | 2 +- web/src/lang/backend/en/game/user.ts | 3 +- web/src/lang/backend/en/order/betOrder.ts | 4 +- web/src/lang/backend/en/order/depositOrder.ts | 8 +- .../lang/backend/en/order/withdrawOrder.ts | 15 +- web/src/lang/backend/en/user/user.ts | 3 +- .../lang/backend/zh-cn/config/depositTier.ts | 29 ++ web/src/lang/backend/zh-cn/game/betOrder.ts | 4 +- web/src/lang/backend/zh-cn/game/live.ts | 2 +- web/src/lang/backend/zh-cn/game/user.ts | 3 +- web/src/lang/backend/zh-cn/order/betOrder.ts | 4 +- .../lang/backend/zh-cn/order/depositOrder.ts | 10 +- .../lang/backend/zh-cn/order/withdrawOrder.ts | 15 +- web/src/lang/backend/zh-cn/user/user.ts | 5 +- .../backend/config/depositTier/index.vue | 251 +++++++++ web/src/views/backend/game/live/index.vue | 2 +- .../views/backend/order/betOrder/index.vue | 9 - .../backend/order/depositOrder/index.vue | 68 ++- .../backend/order/depositOrder/popupForm.vue | 233 ++++++++- .../backend/order/withdrawOrder/index.vue | 98 +++- .../backend/order/withdrawOrder/popupForm.vue | 436 +++++++++++++++- web/src/views/backend/user/user/index.vue | 18 +- 45 files changed, 3071 insertions(+), 232 deletions(-) create mode 100644 app/admin/controller/config/DepositTier.php create mode 100644 app/common/library/finance/DepositSettlement.php create mode 100644 app/common/library/finance/WithdrawFlow.php create mode 100644 app/common/library/game/DepositTier.php create mode 100644 web/src/lang/backend/en/config/depositTier.ts create mode 100644 web/src/lang/backend/zh-cn/config/depositTier.ts create mode 100644 web/src/views/backend/config/depositTier/index.vue diff --git a/app/admin/controller/config/DepositTier.php b/app/admin/controller/config/DepositTier.php new file mode 100644 index 0000000..e612f02 --- /dev/null +++ b/app/admin/controller/config/DepositTier.php @@ -0,0 +1,130 @@ +auth) { + return false; + } + $controllerPath = get_controller_path($request); + if (!$controllerPath) { + return false; + } + $paths = []; + $paths[] = $controllerPath . '/' . $action; + $parts = explode('/', $controllerPath); + foreach ($parts as &$part) { + if (str_contains($part, '_')) { + $part = lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $part)))); + } + } + $paths[] = implode('/', $parts) . '/' . $action; + foreach (array_values(array_unique($paths)) as $path) { + if ($this->auth->check($path)) { + return true; + } + } + return false; + } + + protected function initController(WebmanRequest $request): ?Response + { + return null; + } + + /** + * 读取 game_config.deposit_tier 的档位列表 + */ + public function index(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + if (!$this->hasNodePermission($request, 'index')) { + return $this->error(__('You have no permission'), [], 401); + } + if ($request->method() !== 'GET') { + return $this->error(__('Parameter error')); + } + $row = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find(); + $items = DepositTierLib::parseFromConfigValue($row['config_value'] ?? null); + return $this->success('', [ + 'items' => $items, + ]); + } + + /** + * 保存 JSON 数组(value_type=json) + */ + public function save(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + if (!$this->hasNodePermission($request, 'save')) { + return $this->error(__('You have no permission'), [], 401); + } + if ($request->method() !== 'POST') { + return $this->error(__('Parameter error')); + } + $payload = $request->post(); + if (!is_array($payload)) { + return $this->error(__('Parameter %s can not be empty', [''])); + } + $items = $payload['items'] ?? null; + if (!is_array($items)) { + return $this->error('items 必须为数组'); + } + try { + $clean = DepositTierLib::prepareItemsForSave(array_values($items)); + $json = DepositTierLib::encodeForDb($clean); + } catch (InvalidArgumentException $e) { + return $this->error($e->getMessage()); + } + + $now = time(); + try { + $exists = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find(); + if ($exists) { + Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->update([ + 'config_value' => $json, + 'value_type' => 'json', + 'update_time' => $now, + ]); + } else { + Db::name('game_config')->insert([ + 'config_key' => DepositTierLib::CONFIG_KEY, + 'config_value' => $json, + 'value_type' => 'json', + 'remark' => '充值档位 JSON 数组(独立表单维护)', + 'create_time' => $now, + 'update_time' => $now, + ]); + } + } catch (Throwable $e) { + return $this->error($e->getMessage()); + } + + return $this->success(__('Saved successfully')); + } +} diff --git a/app/admin/controller/order/DepositOrder.php b/app/admin/controller/order/DepositOrder.php index aec067b..b2fe358 100644 --- a/app/admin/controller/order/DepositOrder.php +++ b/app/admin/controller/order/DepositOrder.php @@ -9,6 +9,13 @@ use Webman\Http\Request as WebmanRequest; /** * 充值订单 + * + * 订单的"由 0 转 1(成功入账)"统一走 app\common\library\finance\DepositSettlement。 + * 当前充值接口为 mock 支付网关,点击即成功;后台不再保留人工审核按钮, + * 如需人工补单,请通过后续专门的"补单/冲正"工具完成,而不是在这个 CRUD 里直接改 status。 + * + * 编辑入口现在只用于"查看详情":GET 返回订单 + 关联的 user/channel 信息, + * 阻止 POST 任何改字段的动作(保证金额、状态只能由结算服务变更)。 */ class DepositOrder extends Backend { @@ -18,7 +25,7 @@ class DepositOrder extends Backend protected bool $modelSceneValidate = true; - protected string|array $quickSearchField = ['id', 'order_no', 'pay_channel', 'remark']; + protected string|array $quickSearchField = ['id', 'order_no', 'pay_channel', 'remark', 'deposit_tier_id', 'idempotency_key']; protected string|array $defaultSortField = ['id' => 'desc']; @@ -65,6 +72,69 @@ class DepositOrder extends Backend ]); } + /** + * GET 时返回关联信息,便于前端详情弹窗直接渲染 user.username / channel.name; + * POST 一律拒绝,保证充值订单的金额/状态只能由结算服务变更。 + */ + protected function _edit(): Response + { + $pk = $this->model->getPk(); + $id = $this->request ? ($this->request->post($pk) ?? $this->request->get($pk)) : null; + if ($id === null || $id === '') { + return $this->error(__('Parameter error')); + } + + if ($this->request && $this->request->method() === 'POST') { + return $this->error('充值订单为自动入账,禁止直接修改,如需补单请走专用工具'); + } + + $row = $this->loadWithRelations(intval(strval($id))); + if (!$row) { + return $this->error(__('Record not found')); + } + if (!$this->checkChannelScoped($row)) { + return $this->error(__('You have no permission')); + } + + return $this->success('', ['row' => $row]); + } + + private function loadWithRelations(int $id): ?array + { + $row = $this->model + ->withJoin($this->withJoinTable, $this->withJoinType) + ->with($this->withJoinTable) + ->visible([ + 'user' => ['username', 'phone'], + 'channel' => ['name'], + ]) + ->where($this->model->getTable() . '.id', $id) + ->find(); + if (!$row) { + return null; + } + return $row->toArray(); + } + + private function checkChannelScoped(array $row): bool + { + if (!$this->auth || $this->auth->isSuperAdmin()) { + return true; + } + $channelIds = $this->getScopedChannelIdsForFilter(); + if ($channelIds === []) { + return false; + } + $raw = $row['channel_id'] ?? null; + if ($raw === null || $raw === '') { + return false; + } + if (!is_numeric(strval($raw))) { + return false; + } + return in_array(intval(strval($raw)), $channelIds, true); + } + /** * @return int[] */ diff --git a/app/admin/controller/order/WithdrawOrder.php b/app/admin/controller/order/WithdrawOrder.php index b368569..a5ff8e9 100644 --- a/app/admin/controller/order/WithdrawOrder.php +++ b/app/admin/controller/order/WithdrawOrder.php @@ -5,10 +5,16 @@ namespace app\admin\controller\order; use app\common\controller\Backend; use support\think\Db; use support\Response; +use Throwable; use Webman\Http\Request as WebmanRequest; /** * 提现订单 + * + * 当前审核流转: + * - 用户端提交提现时,立即冻结余额(user.coin - apply_amount)并生成 withdraw_order(status=0)与 withdraw 流水(direction=2)。 + * - 管理员在后台审核:通过(approve)→ status=1;拒绝(reject)→ status=2 并回冲用户余额与流水。 + * - 通过流程不再额外扣钱包,因为申请时已冻结;仅在管理员调整 amount/fee 时写一条差额流水。 */ class WithdrawOrder extends Backend { @@ -66,6 +72,387 @@ class WithdrawOrder extends Backend ]); } + /** + * GET 时返回关联信息,便于编辑弹窗直接渲染 user.username/channel.name + */ + protected function _edit(): Response + { + $pk = $this->model->getPk(); + $id = $this->request ? ($this->request->post($pk) ?? $this->request->get($pk)) : null; + if ($id === null || $id === '') { + return $this->error(__('Parameter error')); + } + + if ($this->request && $this->request->method() === 'POST') { + // 历史 CRUD 的 POST 编辑已被 approve/reject 替代,这里阻止直接改金额绕过审核流程 + return $this->error('请使用通过/拒绝按钮完成审核'); + } + + $row = $this->loadWithRelations(intval(strval($id))); + if (!$row) { + return $this->error(__('Record not found')); + } + if (!$this->checkChannelScoped($row)) { + return $this->error(__('You have no permission')); + } + return $this->success('', ['row' => $row]); + } + + /** + * 审核通过:允许调整 amount/fee;actual_amount 自动为 amount - fee。 + * 对金额差额自动在用户钱包与流水中做增减,保持账务平衡。 + */ + public function approve(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + if ($request->method() !== 'POST') { + return $this->error(__('Parameter error')); + } + + $id = $this->intParam($request->post('id')); + if ($id <= 0) { + return $this->error(__('Parameter error')); + } + + $newAmount = $this->decimalParam($request->post('amount'), '0'); + $newFee = $this->decimalParam($request->post('fee'), '0'); + if (bccomp($newAmount, '0', 4) <= 0) { + return $this->error('申请金额必须大于 0'); + } + if (bccomp($newFee, '0', 4) < 0) { + return $this->error('手续费不能为负'); + } + if (bccomp($newFee, $newAmount, 4) > 0) { + return $this->error('手续费不能大于申请金额'); + } + $newActual = bcsub($newAmount, $newFee, 4); + + $remarkRaw = $request->post('remark'); + $remark = is_string($remarkRaw) ? trim($remarkRaw) : ''; + + $order = Db::name('withdraw_order')->where('id', $id)->find(); + if (!$order) { + return $this->error(__('Record not found')); + } + if (!$this->checkChannelScoped($order)) { + return $this->error(__('You have no permission')); + } + $currentStatus = $this->intParam($order['status'] ?? 0); + if ($currentStatus !== 0) { + return $this->error('该订单已审核,无需重复操作'); + } + + $userId = $this->intParam($order['user_id'] ?? 0); + if ($userId <= 0) { + return $this->error('订单缺少用户信息'); + } + $oldAmount = bcadd(strval($order['amount'] ?? '0'), '0', 4); + $diff = bcsub($newAmount, $oldAmount, 4); + + $now = time(); + $adminId = $this->intParam($this->auth->id ?? 0); + $adminName = $this->adminDisplayName(); + $channelIdRaw = $order['channel_id'] ?? null; + $channelId = ($channelIdRaw === null || $channelIdRaw === '') + ? null + : $this->intParam($channelIdRaw); + if ($remark === '') { + $remark = '管理员(' . $adminName . ')审核通过:金额 ' + . $this->shortAmount($newAmount) . ',手续费 ' . $this->shortAmount($newFee) + . ',实际到账 ' . $this->shortAmount($newActual); + } + + Db::startTrans(); + try { + // 金额调整差额处理 + $cmp = bccomp($diff, '0', 4); + if ($cmp > 0) { + // 新金额更大:再冻结用户 diff + $userRow = Db::name('user')->where('id', $userId)->find(); + if (!$userRow) { + Db::rollback(); + return $this->error('关联用户不存在'); + } + $beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 4); + if (bccomp($beforeCoin, $diff, 4) < 0) { + Db::rollback(); + return $this->error('用户余额不足以补扣调整差额'); + } + $afterCoin = bcsub($beforeCoin, $diff, 4); + Db::name('user')->where('id', $userId)->update([ + 'coin' => $afterCoin, + 'total_withdraw_coin' => Db::raw('total_withdraw_coin + ' . $diff), + 'update_time' => $now, + ]); + Db::name('user_wallet_record')->insert([ + 'user_id' => $userId, + 'channel_id' => $channelId, + 'biz_type' => 'withdraw', + 'direction' => 2, + 'amount' => $diff, + 'balance_before' => $beforeCoin, + 'balance_after' => $afterCoin, + 'ref_type' => 'withdraw_order', + 'ref_id' => $id, + 'idempotency_key' => 'wd_adjust_add_' . strval($order['order_no'] ?? $id) . '_' . $now, + 'operator_admin_id' => $adminId > 0 ? $adminId : null, + 'remark' => '管理员(' . $adminName . ')审核调增申请金额差额 ' + . $this->shortAmount($diff), + 'create_time' => $now, + ]); + } elseif ($cmp < 0) { + // 新金额更小:退回差额 + $abs = bcsub('0', $diff, 4); + $userRow = Db::name('user')->where('id', $userId)->find(); + if (!$userRow) { + Db::rollback(); + return $this->error('关联用户不存在'); + } + $beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 4); + $afterCoin = bcadd($beforeCoin, $abs, 4); + Db::name('user')->where('id', $userId)->update([ + 'coin' => $afterCoin, + 'total_withdraw_coin' => Db::raw('total_withdraw_coin - ' . $abs), + 'update_time' => $now, + ]); + Db::name('user_wallet_record')->insert([ + 'user_id' => $userId, + 'channel_id' => $channelId, + 'biz_type' => 'withdraw_refund', + 'direction' => 1, + 'amount' => $abs, + 'balance_before' => $beforeCoin, + 'balance_after' => $afterCoin, + 'ref_type' => 'withdraw_order', + 'ref_id' => $id, + 'idempotency_key' => 'wd_adjust_sub_' . strval($order['order_no'] ?? $id) . '_' . $now, + 'operator_admin_id' => $adminId > 0 ? $adminId : null, + 'remark' => '管理员(' . $adminName . ')审核调减申请金额差额 ' + . $this->shortAmount($abs), + 'create_time' => $now, + ]); + } + + Db::name('withdraw_order')->where('id', $id)->update([ + 'amount' => $newAmount, + 'fee' => $newFee, + 'actual_amount' => $newActual, + 'status' => 1, + 'review_admin_id' => $adminId > 0 ? $adminId : null, + 'review_time' => $now, + 'remark' => substr($remark, 0, 255), + 'update_time' => $now, + ]); + Db::commit(); + } catch (Throwable $e) { + Db::rollback(); + return $this->error($e->getMessage()); + } + + return $this->success('审核通过', [ + 'id' => $id, + 'amount' => $newAmount, + 'fee' => $newFee, + 'actual_amount' => $newActual, + 'status' => 1, + ]); + } + + /** + * 审核拒绝:必须填写驳回原因(remark)。 + * 回冲申请时的冻结:user.coin += amount;total_withdraw_coin -= amount;写一条 withdraw_refund 流水。 + */ + public function reject(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + if ($request->method() !== 'POST') { + return $this->error(__('Parameter error')); + } + + $id = $this->intParam($request->post('id')); + if ($id <= 0) { + return $this->error(__('Parameter error')); + } + $remarkRaw = $request->post('remark'); + $remark = is_string($remarkRaw) ? trim($remarkRaw) : ''; + if ($remark === '') { + return $this->error('请填写拒绝原因'); + } + + $order = Db::name('withdraw_order')->where('id', $id)->find(); + if (!$order) { + return $this->error(__('Record not found')); + } + if (!$this->checkChannelScoped($order)) { + return $this->error(__('You have no permission')); + } + $currentStatus = $this->intParam($order['status'] ?? 0); + if ($currentStatus !== 0) { + return $this->error('该订单已审核,无需重复操作'); + } + + $userId = $this->intParam($order['user_id'] ?? 0); + if ($userId <= 0) { + return $this->error('订单缺少用户信息'); + } + $amount = bcadd(strval($order['amount'] ?? '0'), '0', 4); + $channelIdRaw = $order['channel_id'] ?? null; + $channelId = ($channelIdRaw === null || $channelIdRaw === '') + ? null + : $this->intParam($channelIdRaw); + + $now = time(); + $adminId = $this->intParam($this->auth->id ?? 0); + $adminName = $this->adminDisplayName(); + + Db::startTrans(); + try { + $userRow = Db::name('user')->where('id', $userId)->find(); + if (!$userRow) { + Db::rollback(); + return $this->error('关联用户不存在'); + } + $beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 4); + $afterCoin = bcadd($beforeCoin, $amount, 4); + 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' => $id, + 'idempotency_key' => 'wd_reject_' . strval($order['order_no'] ?? $id) . '_' . $now, + 'operator_admin_id' => $adminId > 0 ? $adminId : null, + 'remark' => '管理员(' . $adminName . ')驳回提现,退回冻结金额 ' + . $this->shortAmount($amount) . ':' . $remark, + 'create_time' => $now, + ]); + + Db::name('withdraw_order')->where('id', $id)->update([ + 'status' => 2, + 'review_admin_id' => $adminId > 0 ? $adminId : null, + 'review_time' => $now, + 'remark' => substr($remark, 0, 255), + 'update_time' => $now, + ]); + Db::commit(); + } catch (Throwable $e) { + Db::rollback(); + return $this->error($e->getMessage()); + } + + return $this->success('审核已拒绝', [ + 'id' => $id, + 'status' => 2, + 'remark' => $remark, + ]); + } + + private function loadWithRelations(int $id): ?array + { + $row = $this->model + ->withJoin($this->withJoinTable, $this->withJoinType) + ->with($this->withJoinTable) + ->visible([ + 'user' => ['username', 'phone'], + 'channel' => ['name'], + 'reviewAdmin' => ['username'], + ]) + ->where($this->model->getTable() . '.id', $id) + ->find(); + if (!$row) { + return null; + } + return $row->toArray(); + } + + private function checkChannelScoped(array|object $row): bool + { + if (!$this->auth || $this->auth->isSuperAdmin()) { + return true; + } + $channelIds = $this->getScopedChannelIdsForFilter(); + if ($channelIds === []) { + return false; + } + $raw = is_array($row) ? ($row['channel_id'] ?? null) : ($row->channel_id ?? null); + if ($raw === null || $raw === '') { + // 无归属渠道的数据只有超管可见 + return false; + } + $cid = $this->intParam($raw); + return in_array($cid, $channelIds, true); + } + + private function intParam($raw): int + { + if ($raw === null || $raw === '') { + return 0; + } + if (!is_numeric(strval($raw))) { + return 0; + } + return intval(strval($raw)); + } + + private function decimalParam($raw, string $default): string + { + if ($raw === null || $raw === '' || !is_numeric(strval($raw))) { + return bcadd($default, '0', 4); + } + return bcadd(strval($raw), '0', 4); + } + + private function adminDisplayName(): string + { + if (!$this->auth) { + return 'admin'; + } + $name = $this->auth->username ?? null; + if (is_string($name) && $name !== '') { + return $name; + } + $id = $this->intParam($this->auth->id ?? 0); + return '#' . strval($id); + } + + /** + * 把 4 位小数金额压缩成最多 2 位小数用于展示(不影响落库精度) + */ + private function shortAmount(string $amount): string + { + if (!is_numeric($amount)) { + return $amount; + } + $normalized = bcadd($amount, '0', 4); + $negative = false; + if (str_starts_with($normalized, '-')) { + $negative = true; + $normalized = substr($normalized, 1); + } + $parts = explode('.', $normalized, 2); + $intPart = $parts[0] ?? '0'; + $fracPart = $parts[1] ?? '0000'; + $displayFrac = substr($fracPart, 0, 2); + $v = $intPart . '.' . str_pad($displayFrac, 2, '0'); + return $negative ? ('-' . $v) : $v; + } + /** * @return int[] */ diff --git a/app/admin/controller/user/User.php b/app/admin/controller/user/User.php index 9287fd3..e5c4cbe 100644 --- a/app/admin/controller/user/User.php +++ b/app/admin/controller/user/User.php @@ -20,7 +20,7 @@ class User extends Backend */ protected ?object $model = null; - protected array|string $preExcludeFields = ['id', 'uuid', 'create_time', 'update_time', 'invite_code', 'coin', 'total_deposit_coin', 'total_valid_bet_coin']; + protected array|string $preExcludeFields = ['id', 'uuid', 'create_time', 'update_time', 'invite_code', 'coin', 'total_deposit_coin', 'total_withdraw_coin', 'bet_flow_coin']; protected array $withJoinTable = ['channel', 'admin']; diff --git a/app/api/controller/Account.php b/app/api/controller/Account.php index f09e16d..20a7711 100644 --- a/app/api/controller/Account.php +++ b/app/api/controller/Account.php @@ -5,12 +5,14 @@ namespace app\api\controller; use ba\Date; use ba\Captcha; use ba\Random; +use app\common\library\finance\WithdrawFlow; use app\common\model\User; use app\common\facade\Token; use app\common\model\UserScoreLog; use app\common\model\UserMoneyLog; use app\common\controller\Frontend; use app\common\facade\Token as TokenFacade; +use support\think\Db; use support\validation\Validator; use support\validation\ValidationException; use Webman\Http\Request; @@ -41,17 +43,60 @@ class Account extends Frontend } $user = $this->auth->getUser(); + $userId = intval(strval($user->id)); + $coinBalance = WithdrawFlow::amountString($user->coin ?? '0'); + + // 打码量 / 提现配额快照 + $flow = 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($coinBalance, $flow); + + // 待审核提现订单数(配合 /api/finance/withdrawCreate 的 3 笔上限) + $pendingWithdrawCount = Db::name('withdraw_order') + ->where('user_id', $userId) + ->where('status', 0) + ->count(); + $payload = [ 'code' => 1, 'message' => __('ok'), 'data' => [ - 'uuid' => $user->uuid ?? '', - 'username' => $user->username, - 'head_image' => $user->avatar ?? '', - 'coin' => $user->coin, - 'current_streak' => $user->current_streak ?? 0, - 'channel_id' => $user->channel_id, - 'risk_flags' => $user->risk_flags ?? 0, + 'uuid' => $user->uuid ?? '', + 'username' => $user->username, + 'head_image' => $user->avatar ?? '', + 'phone' => $user->phone ?? '', + 'email' => $user->email ?? '', + 'register_invite_code' => $user->register_invite_code ?? '', + 'channel_id' => $user->channel_id, + 'risk_flags' => $user->risk_flags ?? 0, + 'current_streak' => $user->current_streak ?? 0, + 'last_bet_period_no' => $user->last_bet_period_no ?? '', + 'create_time' => $user->create_time ?? 0, + + // 资金字段(4 位小数字符串,与 /api/wallet/balanceSummary 对齐) + 'coin' => $coinBalance, + 'coin_balance' => $coinBalance, + 'frozen_balance' => '0.0000', + 'total_deposit_coin' => WithdrawFlow::amountString($user->total_deposit_coin ?? '0'), + 'total_withdraw_coin' => WithdrawFlow::amountString($user->total_withdraw_coin ?? '0'), + 'bet_flow_coin' => $flow['bet_flow_coin'], + 'max_withdrawable' => $maxWithdrawable, + 'withdraw_flow' => [ + 'ratio' => $flow['ratio'], + 'net_deposit' => $flow['net_deposit'], + 'required_bet_flow' => $flow['required_bet_flow'], + 'remaining_bet_flow' => $flow['remaining_bet_flow'], + 'eligible' => $flow['eligible'], + 'max_withdraw_by_flow' => $flow['flow_unlimited'] ? null : $flow['max_withdraw_by_flow'], + 'flow_unlimited' => $flow['flow_unlimited'], + 'pending_withdraw' => [ + 'count' => $pendingWithdrawCount, + 'max' => WithdrawFlow::MAX_PENDING_WITHDRAW, + ], + ], ], ]; return \response(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 200, ['Content-Type' => 'application/json']); diff --git a/app/api/controller/Auth.php b/app/api/controller/Auth.php index fef7960..84eaf05 100644 --- a/app/api/controller/Auth.php +++ b/app/api/controller/Auth.php @@ -15,7 +15,7 @@ use support\Response; class Auth extends MobileBase { protected array $noNeedLogin = ['register', 'login', 'refreshToken', 'userRegister', 'userLogin', 'tokenRefresh']; - protected array $noNeedAuthToken = ['register', 'login', 'refreshToken', 'userRegister', 'userLogin', 'tokenRefresh']; + protected array $noNeedAuthToken = ['register', 'refreshToken', 'userRegister', 'tokenRefresh']; public function userRegister(Request $request): Response { diff --git a/app/api/controller/Finance.php b/app/api/controller/Finance.php index 5a16cd1..9f3bed3 100644 --- a/app/api/controller/Finance.php +++ b/app/api/controller/Finance.php @@ -4,57 +4,230 @@ 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 Webman\Http\Request; 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; } - $payAmountFiat = (string) $request->post('pay_amount_fiat', ''); - $fiatCurrency = trim((string) $request->post('fiat_currency', '')); - $channel = trim((string) $request->post('channel', '')); - $idempotencyKey = trim((string) $request->post('idempotency_key', '')); - if ($payAmountFiat === '' || $fiatCurrency === '' || $channel === '' || $idempotencyKey === '') { + + $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); - $coinAmount = $payAmountFiat; - DepositOrder::create([ - 'order_no' => $orderNo, - 'user_id' => $this->auth->id, - 'fiat_currency' => $fiatCurrency, - 'fiat_amount' => $payAmountFiat, - 'fx_rate' => '1.00000000', - 'coin_amount' => $coinAmount, - 'gateway' => $channel, - 'status' => 0, - 'create_time' => time(), - 'update_time' => time(), - ]); + $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'] : '', + ]; - return $this->mobileSuccess([ - 'order_no' => $orderNo, - 'coin_amount' => $coinAmount, - 'pay_url' => '', - 'status' => 'pending', - ]); + $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 = trim((string) $request->get('order_no', '')); + $orderNo = $this->stringParam($request->input('order_no')); if ($orderNo === '') { return $this->mobileError(1001, 'Missing parameters'); } @@ -62,12 +235,47 @@ class Finance extends MobileBase 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([ - 'order_no' => $order->order_no, - 'status' => $this->mapDepositStatus($order->status), - 'coin_amount' => $order->coin_amount, - 'create_time' => $order->create_time, - 'finish_time' => $order->paid_at, + 'list' => $list, + 'pagination' => [ + 'page' => $paginate->currentPage(), + 'page_size' => $paginate->listRows(), + 'total' => $paginate->total(), + ], ]); } @@ -77,51 +285,142 @@ class Finance extends MobileBase if ($response !== null) { return $response; } - $withdrawCoin = (string) $request->post('withdraw_coin', ''); - $receiveAccount = trim((string) $request->post('receive_account', '')); - $receiveType = trim((string) $request->post('receive_type', '')); - $idempotencyKey = trim((string) $request->post('idempotency_key', '')); + $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(); - if (bccomp((string) $user->coin, $withdrawCoin, 4) < 0) { + $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); - WithdrawOrder::create([ - 'order_no' => $orderNo, - 'user_id' => $user->id, - 'apply_amount' => $withdrawCoin, - 'fee_amount' => $feeCoin, - 'actual_amount' => $actualArrivalCoin, - 'fiat_currency' => '', - 'need_audit' => 1, - 'audit_status' => 0, - 'reject_reason' => '', - 'create_time' => time(), - 'update_time' => time(), - ]); + $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, + '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 = trim((string) $request->get('order_no', '')); + $orderNo = $this->stringParam($request->input('order_no')); if ($orderNo === '') { return $this->mobileError(1001, 'Missing parameters'); } @@ -129,16 +428,79 @@ class Finance extends MobileBase 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($order->audit_status), - 'withdraw_coin' => $order->apply_amount, - 'fee_coin' => $order->fee_amount, - 'reject_reason' => $order->reject_reason === '' ? null : $order->reject_reason, - 'create_time' => $order->create_time, + '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) { @@ -150,12 +512,16 @@ class Finance extends MobileBase return 'pending'; } - private function mapWithdrawStatus($auditStatus): string + /** + * 映射 withdraw_order.status(0 待审 / 1 通过 / 2 拒绝 / 3 已打款)到移动端状态字符串 + */ + private function mapWithdrawStatus($statusCode): string { - if ($this->intValue($auditStatus) === 1) { + $code = $this->intValue($statusCode); + if ($code === 1 || $code === 3) { return 'approved'; } - if ($this->intValue($auditStatus) === 2) { + if ($code === 2) { return 'rejected'; } return 'pending_review'; @@ -170,4 +536,3 @@ class Finance extends MobileBase return $result; } } - diff --git a/app/api/controller/Game.php b/app/api/controller/Game.php index ac25dce..447b713 100644 --- a/app/api/controller/Game.php +++ b/app/api/controller/Game.php @@ -95,7 +95,7 @@ class Game extends MobileBase if ($response !== null) { return $response; } - $limit = $this->intValue($request->get('limit', 30)); + $limit = $this->intValue($request->input('limit', 30)); if ($limit < 1) { $limit = 30; } @@ -133,6 +133,12 @@ class Game extends MobileBase ]); } + /** + * 提交下注:入参极简——period_no + numbers + bet_amount(整笔总金额) + idempotency_key。 + * + * 下注判定:开奖号码 ∈ pick_numbers 即算中奖,赔付按整笔 total_amount × odds 计算 + * (odds 定义见 GameBetSettleService::BASE_ODDS 与 streak_at_bet)。 + */ public function betPlace(Request $request): Response { $response = $this->initializeMobile($request); @@ -141,11 +147,16 @@ class Game extends MobileBase } $periodNo = trim((string) $request->post('period_no', '')); $numbersRaw = $request->post('numbers', ''); - $betAmount = (string) $request->post('bet_amount', ''); + $betAmount = trim((string) $request->post('bet_amount', '')); $idempotencyKey = trim((string) $request->post('idempotency_key', '')); if ($periodNo === '' || $betAmount === '' || $idempotencyKey === '') { return $this->mobileError(1001, 'Missing parameters'); } + if (!is_numeric($betAmount) || bccomp($betAmount, '0', 4) <= 0) { + return $this->mobileError(1003, 'Invalid parameter value'); + } + $totalAmount = bcadd($betAmount, '0', 4); + $numbers = $this->parseBetNumbersFromRequest($numbersRaw); if ($numbers === []) { return $this->mobileError(1003, 'Invalid parameter value'); @@ -164,8 +175,6 @@ class Game extends MobileBase } $user = $this->auth->getUser(); - $pickCount = count($numbers); - $totalAmount = bcmul($betAmount, (string) $pickCount, 4); if (bccomp((string) $user->coin, $totalAmount, 4) < 0) { return $this->mobileError(2001, 'Insufficient balance'); } @@ -199,8 +208,6 @@ class Game extends MobileBase 'user_id' => $user->id, 'channel_id' => $user->channel_id, 'pick_numbers' => $numbers, - 'unit_amount' => $betAmount, - 'pick_count' => $pickCount, 'total_amount' => $totalAmount, 'streak_at_bet' => $user->current_streak ?? 0, 'is_auto' => 0, @@ -231,8 +238,8 @@ class Game extends MobileBase if ($response !== null) { return $response; } - $page = $this->intValue($request->get('page', 1)); - $pageSize = $this->intValue($request->get('page_size', 20)); + $page = $this->intValue($request->input('page', 1)); + $pageSize = $this->intValue($request->input('page_size', 20)); $paginate = BetOrder::where('user_id', $this->auth->id)->order('id', 'desc')->paginate([ 'page' => $page, 'list_rows' => $pageSize, @@ -244,7 +251,8 @@ class Game extends MobileBase 'order_no' => (string) $item->id, 'period_no' => $item->period_no, 'numbers' => $item->pick_numbers ?? [], - 'bet_amount' => $item->unit_amount, + // 整笔压注金额(与请求 bet_amount 语义一致) + 'bet_amount' => $item->total_amount, 'total_amount' => $item->total_amount, 'result_number' => null, 'win_amount' => $item->win_amount, diff --git a/app/api/controller/Notice.php b/app/api/controller/Notice.php index e4d3b4f..3729696 100644 --- a/app/api/controller/Notice.php +++ b/app/api/controller/Notice.php @@ -17,8 +17,8 @@ class Notice extends MobileBase if ($response !== null) { return $response; } - $page = $this->intValue($request->get('page', 1), 1); - $pageSize = $this->intValue($request->get('page_size', 20), 20); + $page = $this->intValue($request->input('page', 1), 1); + $pageSize = $this->intValue($request->input('page_size', 20), 20); $paginate = OperationNotice::where('status', 1)->order('id', 'desc')->paginate([ 'page' => $page, @@ -55,7 +55,7 @@ class Notice extends MobileBase if ($response !== null) { return $response; } - $id = $this->intValue($request->get('id', 0), 0); + $id = $this->intValue($request->input('notice_id', 0), 0); if ($id < 1) { return $this->mobileError(1001, 'Missing parameters'); } @@ -79,7 +79,7 @@ class Notice extends MobileBase if ($response !== null) { return $response; } - $noticeId = $this->intValue($request->post('notice_id', 0), 0); + $noticeId = $this->intValue($request->input('notice_id', 0), 0); if ($noticeId < 1) { return $this->mobileError(1001, 'Missing parameters'); } diff --git a/app/api/controller/Wallet.php b/app/api/controller/Wallet.php index afb90d3..a99fd74 100644 --- a/app/api/controller/Wallet.php +++ b/app/api/controller/Wallet.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace app\api\controller; +use app\common\library\finance\WithdrawFlow; use app\common\model\UserWalletRecord; use Webman\Http\Request; use support\Response; @@ -17,12 +18,30 @@ class Wallet extends MobileBase return $response; } $user = $this->auth->getUser(); + $coinBalance = WithdrawFlow::amountString($user->coin ?? '0'); + $flow = WithdrawFlow::status(intval($user->id), [ + '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($coinBalance, $flow); return $this->mobileSuccess([ - 'coin_balance' => $user->coin, - 'frozen_balance' => '0.0000', - 'total_deposit_coin' => $user->total_deposit_coin ?? '0.0000', - 'total_valid_bet_coin' => $user->total_valid_bet_coin ?? '0.0000', - 'withdrawable_balance' => $user->coin, + 'coin_balance' => $coinBalance, + 'frozen_balance' => '0.0000', + 'withdrawable_balance' => $coinBalance, + 'max_withdrawable' => $maxWithdrawable, + 'total_deposit_coin' => WithdrawFlow::amountString($user->total_deposit_coin ?? '0'), + 'total_withdraw_coin' => WithdrawFlow::amountString($user->total_withdraw_coin ?? '0'), + 'bet_flow_coin' => $flow['bet_flow_coin'], + 'withdraw_flow' => [ + 'ratio' => $flow['ratio'], + 'net_deposit' => $flow['net_deposit'], + 'required_bet_flow' => $flow['required_bet_flow'], + 'remaining_bet_flow' => $flow['remaining_bet_flow'], + 'eligible' => $flow['eligible'], + 'max_withdraw_by_flow' => $flow['flow_unlimited'] ? null : $flow['max_withdraw_by_flow'], + 'flow_unlimited' => $flow['flow_unlimited'], + ], ]); } @@ -32,9 +51,9 @@ class Wallet extends MobileBase if ($response !== null) { return $response; } - $type = trim((string) $request->get('type', 'all')); - $page = $this->intValue($request->get('page', 1), 1); - $pageSize = $this->intValue($request->get('page_size', 20), 20); + $type = trim((string) $request->input('type', 'all')); + $page = $this->intValue($request->input('page', 1), 1); + $pageSize = $this->intValue($request->input('page_size', 20), 20); $query = UserWalletRecord::where('user_id', $this->auth->id)->order('id', 'desc'); if ($type !== '' && $type !== 'all') { diff --git a/app/api/lang/en.php b/app/api/lang/en.php index 3b9ffbe..807bc33 100644 --- a/app/api/lang/en.php +++ b/app/api/lang/en.php @@ -12,7 +12,7 @@ return [ 'Please login first' => 'Please login first!', 'You have no permission' => 'No permission to operate!', 'Captcha error' => 'Captcha error!', - 'ok' => 'ok', + 'ok' => 'success', 'Missing parameters' => 'Missing parameters', 'Invalid parameter format' => 'Invalid parameter format', 'Invalid parameter value' => 'Invalid parameter value', @@ -38,6 +38,14 @@ return [ 'Current process does not allow this operation' => 'Current process does not allow this operation', 'Order does not exist' => 'Order does not exist', 'Notice does not exist' => 'Notice does not exist', + // Deposit / Withdraw + 'Idempotency key is too long' => 'Idempotency key is too long', + 'Idempotency key conflict' => 'Idempotency key conflict, please do not submit repeatedly', + 'Deposit tier not available' => 'The selected deposit tier is not available', + 'Order not found after settle' => 'Order not found after settlement', + 'Invalid withdraw amount' => 'Invalid withdraw amount', + 'Withdraw exceeds available bet flow' => 'The withdraw amount exceeds the available bet-flow quota', + 'Too many pending withdraw orders' => 'You already have withdraw orders under review, please wait for them to be processed', // Member center account 'Data updated successfully~' => 'Data updated successfully~', 'Password has been changed~' => 'Password has been changed~', diff --git a/app/api/lang/zh-cn.php b/app/api/lang/zh-cn.php index fa2de12..4775563 100644 --- a/app/api/lang/zh-cn.php +++ b/app/api/lang/zh-cn.php @@ -70,6 +70,14 @@ return [ 'Current process does not allow this operation' => '当前流程不允许该操作', 'Order does not exist' => '订单不存在', 'Notice does not exist' => '公告不存在', + // 充值 / 提现 + 'Idempotency key is too long' => '幂等键过长', + 'Idempotency key conflict' => '幂等键冲突(请勿重复提交)', + 'Deposit tier not available' => '所选充值档位不可用', + 'Order not found after settle' => '充值成功后未找到订单', + 'Invalid withdraw amount' => '提现金额不合法', + 'Withdraw exceeds available bet flow' => '提现金额超出可提现额度', + 'Too many pending withdraw orders' => '用户当前存在多笔提现订单,请等待审核', // 会员中心 account 'Data updated successfully~' => '资料更新成功~', 'Password has been changed~' => '密码已修改~', diff --git a/app/common/library/finance/DepositSettlement.php b/app/common/library/finance/DepositSettlement.php new file mode 100644 index 0000000..e0ee5f7 --- /dev/null +++ b/app/common/library/finance/DepositSettlement.php @@ -0,0 +1,218 @@ +where('id', $orderId)->find(); + if (!$order) { + throw new RuntimeException('订单不存在'); + } + + $orderNo = is_string($order['order_no']) ? $order['order_no'] : strval($order['order_no']); + if ($orderNo === '') { + throw new RuntimeException('订单号为空'); + } + + $statusRaw = $order['status'] ?? 0; + $status = is_numeric($statusRaw) ? intval($statusRaw) : 0; + + // 如果已结算,直接返回已有结果(幂等) + if ($status === 1) { + $userId = is_numeric($order['user_id'] ?? null) ? intval($order['user_id']) : 0; + $coinAfter = '0.0000'; + if ($userId > 0) { + $coin = Db::name('user')->where('id', $userId)->value('coin'); + $coinAfter = is_string($coin) ? $coin : strval($coin); + } + $amt = self::amountString($order['amount'] ?? '0'); + $bns = self::amountString($order['bonus_amount'] ?? '0'); + return [ + 'order_id' => $orderId, + 'order_no' => $orderNo, + 'amount' => $amt, + 'bonus_amount' => $bns, + 'credit' => bcadd($amt, $bns, 4), + 'balance_before' => $coinAfter, + 'balance_after' => $coinAfter, + 'pay_time' => is_numeric($order['pay_time'] ?? null) ? intval($order['pay_time']) : 0, + 'already_settled' => true, + ]; + } + + if ($status !== 0) { + throw new RuntimeException('订单状态不允许结算'); + } + + $amount = self::amountString($order['amount'] ?? '0'); + if (bccomp($amount, '0', 4) <= 0) { + throw new RuntimeException('订单金额异常'); + } + $bonus = self::amountString($order['bonus_amount'] ?? '0'); + if (bccomp($bonus, '0', 4) < 0) { + $bonus = '0.0000'; + } + $credit = bcadd($amount, $bonus, 4); + + $userId = is_numeric($order['user_id'] ?? null) ? intval($order['user_id']) : 0; + if ($userId <= 0) { + throw new RuntimeException('订单所属玩家无效'); + } + + $user = Db::name('user')->where('id', $userId)->find(); + if (!$user) { + throw new RuntimeException('玩家不存在'); + } + + $channelId = is_numeric($order['channel_id'] ?? null) ? intval($order['channel_id']) : null; + $balanceBefore = self::amountString($user['coin'] ?? '0'); + $balanceAfter = bcadd($balanceBefore, $credit, 4); + + $now = time(); + $baseRemark = is_string($order['remark'] ?? null) ? $order['remark'] : ''; + // 备注包含充值与赠送的明细,方便后续稽核 + $detail = sprintf('amount=%s,bonus=%s,credit=%s', $amount, $bonus, $credit); + $note = sprintf('[%s] %s (%s)', $source, $sourceLabel, $detail); + $combined = $baseRemark === '' ? $note : ($baseRemark . ' | ' . $note); + if ($extraRemark !== null && $extraRemark !== '') { + $combined .= ' | ' . $extraRemark; + } + $finalRemark = mb_substr($combined, 0, 255); + + $walletIdem = 'deposit_settle_' . $orderNo; + + Db::startTrans(); + try { + $affected = Db::name('deposit_order') + ->where('id', $orderId) + ->where('status', 0) + ->update([ + 'status' => 1, + 'pay_time' => $now, + 'review_admin_id' => $operatorAdminId, + 'review_time' => $operatorAdminId !== null ? $now : null, + 'remark' => $finalRemark, + 'update_time' => $now, + ]); + if ($affected <= 0) { + throw new RuntimeException('订单状态已变更,请刷新后重试'); + } + + Db::name('user')->where('id', $userId)->update([ + 'coin' => $balanceAfter, + 'update_time' => $now, + ]); + + $walletExists = Db::name('user_wallet_record') + ->where('idempotency_key', $walletIdem) + ->value('id'); + if (!$walletExists) { + Db::name('user_wallet_record')->insert([ + 'user_id' => $userId, + 'channel_id' => $channelId, + 'biz_type' => 'deposit', + 'direction' => 1, + 'amount' => $credit, + 'balance_before' => $balanceBefore, + 'balance_after' => $balanceAfter, + 'ref_type' => 'deposit_order', + 'ref_id' => $orderId, + 'idempotency_key' => $walletIdem, + 'operator_admin_id' => $operatorAdminId, + 'remark' => mb_substr($note, 0, 500), + 'create_time' => $now, + ]); + } + + Db::commit(); + } catch (Throwable $e) { + Db::rollback(); + throw new RuntimeException($e->getMessage()); + } + + return [ + 'order_id' => $orderId, + 'order_no' => $orderNo, + 'amount' => $amount, + 'bonus_amount' => $bonus, + 'credit' => $credit, + 'balance_before' => $balanceBefore, + 'balance_after' => $balanceAfter, + 'pay_time' => $now, + 'already_settled' => false, + ]; + } + + /** + * 将任意数值输入格式化为 4 位小数字符串(不做强制类型转换) + */ + private static 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 (!is_numeric($s)) { + return '0.0000'; + } + return bcadd($s, '0', 4); + } +} diff --git a/app/common/library/finance/WithdrawFlow.php b/app/common/library/finance/WithdrawFlow.php new file mode 100644 index 0000000..d1af828 --- /dev/null +++ b/app/common/library/finance/WithdrawFlow.php @@ -0,0 +1,164 @@ += (total_deposit - total_withdraw) × ratio 已被 + * "单笔上限 ≤ max_withdraw_by_flow" 取代且语义等价更细腻:任何通过新校验的请求必然 + * 也满足旧门槛口径。字段 required_bet_flow / remaining_bet_flow / eligible 保留仅作展示。 + */ +final class WithdrawFlow +{ + public const CONFIG_KEY = 'withdraw_bet_flow_ratio'; + + public const DEFAULT_RATIO = '1.0000'; + + /** 当 ratio = 0(不限打码)时,max_withdraw_by_flow 用此哨兵表示"无限"。14 位整数位足够覆盖任何业务金额。 */ + public const UNLIMITED_FLOW = '99999999999999.9999'; + + /** 单用户最多允许同时存在的「待审核」(withdraw_order.status=0) 提现订单数。 */ + public const MAX_PENDING_WITHDRAW = 3; + + /** + * 读取当前打码倍数(字符串 4 位小数,至少 0) + */ + public static function ratio(): string + { + $row = Db::name('game_config')->where('config_key', self::CONFIG_KEY)->find(); + if (!$row) { + return self::DEFAULT_RATIO; + } + $val = $row['config_value'] ?? ''; + if (!is_string($val) || trim($val) === '' || !is_numeric(trim($val))) { + return self::DEFAULT_RATIO; + } + $normalized = bcadd(trim($val), '0', 4); + if (bccomp($normalized, '0', 4) < 0) { + return '0.0000'; + } + return $normalized; + } + + /** + * 归一化金额字段到 4 位小数字符串,非法输入返回 '0.0000' + */ + public static function amountString($raw): string + { + if ($raw === null || $raw === '') { + return '0.0000'; + } + if (is_string($raw)) { + $s = trim($raw); + } elseif (is_int($raw) || is_float($raw)) { + $s = strval($raw); + } else { + return '0.0000'; + } + if (!is_numeric($s)) { + return '0.0000'; + } + return bcadd($s, '0', 4); + } + + /** + * 核算玩家当前打码量状态 + * + * @param array{ + * total_deposit_coin?: mixed, + * total_withdraw_coin?: mixed, + * bet_flow_coin?: mixed, + * }|null $userSnapshot 允许外部传入字典(节省一次查询);为 null 时按 $userId 从库取 + * + * @return array{ + * ratio: string, + * net_deposit: string, + * required_bet_flow: string, + * bet_flow_coin: string, + * remaining_bet_flow: string, + * eligible: bool, + * max_withdraw_by_flow: string, + * flow_unlimited: bool, + * } + */ + public static function status(?int $userId, ?array $userSnapshot = null): array + { + if ($userSnapshot === null && $userId !== null) { + $userSnapshot = Db::name('user') + ->field(['total_deposit_coin', 'total_withdraw_coin', 'bet_flow_coin']) + ->where('id', $userId) + ->find(); + } + $userSnapshot = is_array($userSnapshot) ? $userSnapshot : []; + + $deposit = self::amountString($userSnapshot['total_deposit_coin'] ?? '0'); + $withdraw = self::amountString($userSnapshot['total_withdraw_coin'] ?? '0'); + $flow = self::amountString($userSnapshot['bet_flow_coin'] ?? '0'); + + $net = bcsub($deposit, $withdraw, 4); + if (bccomp($net, '0', 4) < 0) { + $net = '0.0000'; + } + + $ratio = self::ratio(); + $required = bcmul($net, $ratio, 4); + $remaining = bcsub($required, $flow, 4); + if (bccomp($remaining, '0', 4) < 0) { + $remaining = '0.0000'; + } + $eligible = bccomp($flow, $required, 4) >= 0; + + // max_withdraw_by_flow = max(0, bet_flow_coin / ratio - total_withdraw_coin) + $unlimited = bccomp($ratio, '0', 4) === 0; + if ($unlimited) { + $maxByFlow = self::UNLIMITED_FLOW; + } else { + $lifetime = bcdiv($flow, $ratio, 4); + $maxByFlow = bcsub($lifetime, $withdraw, 4); + if (bccomp($maxByFlow, '0', 4) < 0) { + $maxByFlow = '0.0000'; + } + } + + return [ + 'ratio' => $ratio, + 'net_deposit' => $net, + 'required_bet_flow' => $required, + 'bet_flow_coin' => $flow, + 'remaining_bet_flow' => $remaining, + 'eligible' => $eligible, + 'max_withdraw_by_flow' => $maxByFlow, + 'flow_unlimited' => $unlimited, + ]; + } + + /** + * 取单笔最大可提现额 = min(coin_balance, max_withdraw_by_flow)。 + * 返回值为 4 位小数字符串,已与 ratio=0(不限)逻辑兼容。 + */ + public static function maxWithdrawable(string $coinBalance, array $flowStatus): string + { + $coin = self::amountString($coinBalance); + if (bccomp($coin, '0', 4) < 0) { + $coin = '0.0000'; + } + if (!empty($flowStatus['flow_unlimited'])) { + return $coin; + } + $byFlow = self::amountString($flowStatus['max_withdraw_by_flow'] ?? '0'); + return bccomp($coin, $byFlow, 4) <= 0 ? $coin : $byFlow; + } +} diff --git a/app/common/library/game/DepositTier.php b/app/common/library/game/DepositTier.php new file mode 100644 index 0000000..5940d45 --- /dev/null +++ b/app/common/library/game/DepositTier.php @@ -0,0 +1,351 @@ + + */ + public static function parseFromConfigValue($raw): array + { + if (!is_string($raw) || trim($raw) === '') { + return []; + } + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return []; + } + if (isset($decoded['tiers']) && is_array($decoded['tiers'])) { + $list = $decoded['tiers']; + } else { + $list = $decoded; + } + return self::normalizeList($list); + } + + /** + * @param list $items + */ + public static function normalizeList(array $items): array + { + $out = []; + foreach ($items as $row) { + if (!is_array($row)) { + continue; + } + $id = isset($row['id']) && is_string($row['id']) ? trim($row['id']) : ''; + if ($id === '') { + $id = self::generateId(); + } + + $title = self::stringField($row, 'title'); + if ($title === '') { + // 兼容历史:字段名 name 或更老的 account_name + $title = self::stringField($row, 'name'); + if ($title === '') { + $title = self::stringField($row, 'account_name'); + } + } + $titleEn = self::stringField($row, 'title_en'); + + $amount = self::normalizeAmount($row['amount'] ?? ''); + $bonus = self::normalizeAmount($row['bonus_amount'] ?? '0'); + + $desc = self::stringField($row, 'desc'); + if ($desc === '') { + $desc = self::stringField($row, 'remark'); + } + $descEn = self::stringField($row, 'desc_en'); + + $sort = isset($row['sort']) && is_numeric($row['sort']) ? intval($row['sort']) : 0; + $status = isset($row['status']) && is_numeric($row['status']) ? intval($row['status']) : 1; + $status = $status === 1 ? 1 : 0; + + $out[] = [ + 'id' => $id, + 'title' => $title, + 'title_en' => $titleEn, + 'amount' => $amount, + 'bonus_amount' => $bonus, + 'desc' => $desc, + 'desc_en' => $descEn, + 'sort' => $sort, + 'status' => $status, + ]; + } + usort($out, static function (array $a, array $b): int { + if ($a['sort'] !== $b['sort']) { + return $a['sort'] <=> $b['sort']; + } + $ida = is_string($a['id']) ? $a['id'] : ''; + $idb = is_string($b['id']) ? $b['id'] : ''; + return strcmp($ida, $idb); + }); + return $out; + } + + /** + * 校验 POST 数据并输出用于入库的清洁数据 + * + * @param list> $items + * + * @throws InvalidArgumentException + */ + public static function prepareItemsForSave(array $items): array + { + $seenId = []; + $out = []; + foreach ($items as $idx => $row) { + $no = $idx + 1; + if (!is_array($row)) { + throw new InvalidArgumentException('第 ' . $no . ' 行格式错误'); + } + + $id = isset($row['id']) && is_string($row['id']) ? trim($row['id']) : ''; + if ($id === '') { + $id = self::generateId(); + } + if (!preg_match('/^[a-zA-Z0-9_\-]{1,32}$/', $id)) { + throw new InvalidArgumentException('第 ' . $no . ' 行 ID 非法'); + } + if (isset($seenId[$id])) { + throw new InvalidArgumentException('档位 ID 重复:' . $id); + } + $seenId[$id] = true; + + $title = self::stringField($row, 'title'); + if ($title === '') { + // 兼容上游(例如自动迁移脚本)传递历史 name 字段 + $title = self::stringField($row, 'name'); + } + if ($title === '') { + throw new InvalidArgumentException('第 ' . $no . ' 行中文充值名称不能为空'); + } + if (mb_strlen($title) > 64) { + throw new InvalidArgumentException('第 ' . $no . ' 行中文充值名称过长'); + } + + $titleEn = self::stringField($row, 'title_en'); + if (mb_strlen($titleEn) > 64) { + throw new InvalidArgumentException('第 ' . $no . ' 行英文充值名称过长'); + } + + $amount = self::normalizeAmount($row['amount'] ?? ''); + if (bccomp($amount, '0', 4) <= 0) { + throw new InvalidArgumentException('第 ' . $no . ' 行充值金额必须大于 0'); + } + + $bonus = self::normalizeAmount($row['bonus_amount'] ?? '0'); + if (bccomp($bonus, '0', 4) < 0) { + throw new InvalidArgumentException('第 ' . $no . ' 行赠送金额不能为负数'); + } + + $desc = self::stringField($row, 'desc'); + if (mb_strlen($desc) > 255) { + throw new InvalidArgumentException('第 ' . $no . ' 行中文描述过长'); + } + + $descEn = self::stringField($row, 'desc_en'); + if (mb_strlen($descEn) > 255) { + throw new InvalidArgumentException('第 ' . $no . ' 行英文描述过长'); + } + + $sort = isset($row['sort']) && is_numeric($row['sort']) ? intval($row['sort']) : 0; + $statusRaw = isset($row['status']) && is_numeric($row['status']) ? intval($row['status']) : 1; + $status = $statusRaw === 1 ? 1 : 0; + + $out[] = [ + 'id' => $id, + 'title' => $title, + 'title_en' => $titleEn, + 'amount' => $amount, + 'bonus_amount' => $bonus, + 'desc' => $desc, + 'desc_en' => $descEn, + 'sort' => $sort, + 'status' => $status, + ]; + } + + usort($out, static function (array $a, array $b): int { + if ($a['sort'] !== $b['sort']) { + return $a['sort'] <=> $b['sort']; + } + $ida = is_string($a['id']) ? $a['id'] : ''; + $idb = is_string($b['id']) ? $b['id'] : ''; + return strcmp($ida, $idb); + }); + return $out; + } + + /** + * @param list> $items + */ + public static function encodeForDb(array $items): string + { + $encoded = json_encode($items, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($encoded === false) { + throw new InvalidArgumentException('JSON 编码失败'); + } + return $encoded; + } + + /** + * 过滤出启用档位并按 sort 升序,供移动端选择 + */ + public static function publicList(array $items): array + { + $enabled = array_values(array_filter($items, static function (array $row): bool { + if (!isset($row['status'])) { + return false; + } + $val = is_numeric($row['status']) ? intval($row['status']) : 0; + return $val === 1; + })); + usort($enabled, static function (array $a, array $b): int { + $sa = isset($a['sort']) && is_numeric($a['sort']) ? intval($a['sort']) : 0; + $sb = isset($b['sort']) && is_numeric($b['sort']) ? intval($b['sort']) : 0; + if ($sa !== $sb) { + return $sa <=> $sb; + } + $ida = isset($a['id']) && is_string($a['id']) ? $a['id'] : ''; + $idb = isset($b['id']) && is_string($b['id']) ? $b['id'] : ''; + return strcmp($ida, $idb); + }); + return $enabled; + } + + /** + * 按 ID 从档位列表中取出指定档位;未找到返回 null + */ + public static function findById(array $items, string $id): ?array + { + foreach ($items as $row) { + if (!is_array($row)) { + continue; + } + $rid = $row['id'] ?? ''; + if (is_string($rid) && $rid === $id) { + return $row; + } + } + return null; + } + + /** + * 根据语言选择档位对外展示的 title/desc。 + * + * @param array $item + * @return array{title: string, desc: string} + */ + public static function localize(array $item, string $lang): array + { + $title = self::stringField($item, 'title'); + $titleEn = self::stringField($item, 'title_en'); + $desc = self::stringField($item, 'desc'); + $descEn = self::stringField($item, 'desc_en'); + + $isEn = self::isEnglishLang($lang); + $pickedTitle = $isEn ? ($titleEn !== '' ? $titleEn : $title) : ($title !== '' ? $title : $titleEn); + $pickedDesc = $isEn ? ($descEn !== '' ? $descEn : $desc) : ($desc !== '' ? $desc : $descEn); + + return [ + 'title' => $pickedTitle, + 'desc' => $pickedDesc, + ]; + } + + /** + * 生成 10 位稳定 ID(t_ + 8 位随机 base32) + */ + public static function generateId(): string + { + $chars = 'abcdefghijkmnpqrstuvwxyz23456789'; + $len = strlen($chars); + $id = 't_'; + for ($i = 0; $i < 8; $i++) { + $id .= $chars[random_int(0, $len - 1)]; + } + return $id; + } + + /** + * 将金额归一化为 4 位小数字符串;非法输入返回 '0.0000' + */ + public static function normalizeAmount($raw): string + { + if ($raw === null || $raw === '') { + return '0.0000'; + } + if (is_string($raw)) { + $s = trim($raw); + } elseif (is_int($raw) || is_float($raw)) { + $s = strval($raw); + } else { + return '0.0000'; + } + $s = str_replace(',', '.', $s); + if (!is_numeric($s)) { + return '0.0000'; + } + return bcadd($s, '0', 4); + } + + /** + * 从数组取字符串字段并 trim,非字符串返回空串 + * + * @param array $row + */ + private static function stringField(array $row, string $key): string + { + if (!isset($row[$key])) { + return ''; + } + $v = $row[$key]; + return is_string($v) ? trim($v) : ''; + } + + private static function isEnglishLang(string $lang): bool + { + $normalized = strtolower(str_replace('_', '-', trim($lang))); + if ($normalized === '') { + return false; + } + return $normalized === 'en' || str_starts_with($normalized, 'en-'); + } +} diff --git a/app/common/model/BetOrder.php b/app/common/model/BetOrder.php index eaba40a..46ac2bd 100644 --- a/app/common/model/BetOrder.php +++ b/app/common/model/BetOrder.php @@ -16,12 +16,10 @@ class BetOrder extends Model 'create_time' => 'integer', 'update_time' => 'integer', 'pick_numbers' => 'json', - 'unit_amount' => 'string', 'total_amount' => 'string', 'win_amount' => 'string', 'jackpot_extra_amount' => 'string', 'status' => 'integer', - 'pick_count' => 'integer', 'streak_at_bet' => 'integer', 'is_auto' => 'integer', ]; diff --git a/app/common/model/User.php b/app/common/model/User.php index 159b185..bd65664 100644 --- a/app/common/model/User.php +++ b/app/common/model/User.php @@ -42,7 +42,8 @@ class User extends Model 'update_time' => 'integer', 'coin' => 'string', 'total_deposit_coin' => 'string', - 'total_valid_bet_coin' => 'string', + 'total_withdraw_coin' => 'string', + 'bet_flow_coin' => 'string', 'risk_flags' => 'integer', 'current_streak' => 'integer', ]; diff --git a/app/common/service/GameBetSettleService.php b/app/common/service/GameBetSettleService.php index 823d666..6860ce2 100644 --- a/app/common/service/GameBetSettleService.php +++ b/app/common/service/GameBetSettleService.php @@ -56,6 +56,9 @@ final class GameBetSettleService continue; } + // 结算刚刚成功(status 1 → 2):把本单下注总额 1:1 累加到用户打码量 + self::creditUserBetFlow($bet, $now); + if (bccomp($win, '0', 4) <= 0) { continue; } @@ -106,7 +109,7 @@ final class GameBetSettleService } /** - * 单注应付派彩:命中开奖号码时 unit × (连胜+1) × 33(与 GameLiveService 一致)。 + * 应付派彩:开奖号码 ∈ pick_numbers 即中奖;整笔 total_amount × (连胜+1) × 33(与 GameLiveService 一致)。 */ public static function computeWinAmount(array $bet, int $resultNumber): string { @@ -121,11 +124,41 @@ final class GameBetSettleService if (!in_array($resultNumber, array_map('intval', $pickNumbers), true)) { return '0.0000'; } - $unit = (string) ($bet['unit_amount'] ?? '0'); + $total = (string) ($bet['total_amount'] ?? '0'); $streak = (int) ($bet['streak_at_bet'] ?? 0); $odds = (string) (($streak + 1) * self::BASE_ODDS); - return bcmul($unit, $odds, 4); + return bcmul($total, $odds, 4); + } + + /** + * 累加玩家打码量(流水):按本注单 total_amount 1:1 加到 user.bet_flow_coin。 + * + * 幂等性由调用点保证:只有 bet_order 首次从 status=1 变更为 status=2(返回 $affected=1) + * 时才会调用本方法,重复结算不会触发。 + */ + private static function creditUserBetFlow(array $bet, int $now): void + { + $userId = isset($bet['user_id']) && is_numeric($bet['user_id']) ? intval($bet['user_id']) : 0; + if ($userId <= 0) { + return; + } + $totalRaw = $bet['total_amount'] ?? '0'; + $total = is_string($totalRaw) ? trim($totalRaw) : (is_numeric($totalRaw) ? strval($totalRaw) : '0'); + if ($total === '' || !is_numeric($total)) { + return; + } + $flow = bcadd($total, '0', 4); + if (bccomp($flow, '0', 4) <= 0) { + return; + } + // 原子加法:避免读-改-写导致的并发覆盖;$flow 已由 bcadd 归一化为纯数字字符串,不存在 SQL 注入 + Db::name('user') + ->where('id', $userId) + ->update([ + 'bet_flow_coin' => Db::raw('bet_flow_coin + ' . $flow), + 'update_time' => $now, + ]); } private static function creditUserPayout(array $bet, int $betId, string $winAmount, int $now): void diff --git a/app/common/service/GameLiveService.php b/app/common/service/GameLiveService.php index c56868b..d73699e 100644 --- a/app/common/service/GameLiveService.php +++ b/app/common/service/GameLiveService.php @@ -89,7 +89,6 @@ final class GameLiveService 'user_id' => (int) $row['user_id'], 'period_no' => (string) $row['period_no'], 'pick_numbers' => $row['pick_numbers'], - 'unit_amount' => (string) $row['unit_amount'], 'total_amount' => (string) $row['total_amount'], 'streak_at_bet' => (int) $row['streak_at_bet'], 'create_time' => (int) $row['create_time'], @@ -303,10 +302,10 @@ final class GameLiveService if (!in_array($number, array_map('intval', $pickNumbers), true)) { continue; } - $unit = (string) ($bet['unit_amount'] ?? '0'); + $total = (string) ($bet['total_amount'] ?? '0'); $streak = (int) ($bet['streak_at_bet'] ?? 0); $odds = (string) (($streak + 1) * self::BASE_ODDS); - $orderPayout = bcmul($unit, $odds, 4); + $orderPayout = bcmul($total, $odds, 4); $payout = bcadd($payout, $orderPayout, 4); } return $payout; diff --git a/app/common/service/GameRecordStatService.php b/app/common/service/GameRecordStatService.php index 577a270..3d94175 100644 --- a/app/common/service/GameRecordStatService.php +++ b/app/common/service/GameRecordStatService.php @@ -82,7 +82,7 @@ final class GameRecordStatService } /** - * 与 GameLiveService::estimateLossForNumber 中单注派彩一致:命中号码时 unit × (streak+1) × 33。 + * 与 GameLiveService::estimateLossForNumber 中派彩一致:命中号码时 total_amount × (streak+1) × 33。 */ private static function estimatePayoutForBet(array $bet, int $resultNumber): string { @@ -97,10 +97,10 @@ final class GameRecordStatService if (!in_array($resultNumber, array_map('intval', $pickNumbers), true)) { return '0.0000'; } - $unit = (string) ($bet['unit_amount'] ?? '0'); + $total = (string) ($bet['total_amount'] ?? '0'); $streak = (int) ($bet['streak_at_bet'] ?? 0); $odds = (string) (($streak + 1) * self::BASE_ODDS); - return bcmul($unit, $odds, 4); + return bcmul($total, $odds, 4); } } diff --git a/config/route.php b/config/route.php index 40eb92c..0d7c715 100644 --- a/config/route.php +++ b/config/route.php @@ -112,36 +112,40 @@ Route::post('/api/account/retrievePassword', [\app\api\controller\Account::class Route::post('/api/ems/send', [\app\api\controller\Ems::class, 'send']); // ==================== 移动端用户接口(统一收口到 /api/user/*) ==================== +// 约定:移动端所有业务接口一律使用 POST 调用;查询类同时兼容 GET 便于浏览器调试。 Route::post('/api/user/register', [\app\api\controller\Auth::class, 'userRegister']); Route::post('/api/user/login', [\app\api\controller\Auth::class, 'userLogin']); Route::post('/api/user/refreshToken', [\app\api\controller\Auth::class, 'tokenRefresh']); -Route::get('/api/user/profile', [\app\api\controller\Account::class, 'userProfile']); +Route::add(['GET', 'POST'], '/api/user/profile', [\app\api\controller\Account::class, 'userProfile']); Route::post('/api/user/retrievePassword', [\app\api\controller\Account::class, 'retrievePassword']); // 兼容旧移动端路径,后续客户端切换完成后可移除 Route::post('/api/auth/userRegister', [\app\api\controller\Auth::class, 'userRegister']); Route::post('/api/auth/userLogin', [\app\api\controller\Auth::class, 'userLogin']); Route::post('/api/auth/tokenRefresh', [\app\api\controller\Auth::class, 'tokenRefresh']); -Route::get('/api/account/userProfile', [\app\api\controller\Account::class, 'userProfile']); +Route::add(['GET', 'POST'], '/api/account/userProfile', [\app\api\controller\Account::class, 'userProfile']); -Route::get('/api/game/lobbyInit', [\app\api\controller\Game::class, 'lobbyInit']); -Route::get('/api/game/dictionaryList', [\app\api\controller\Game::class, 'dictionaryList']); -Route::get('/api/game/periodHistory', [\app\api\controller\Game::class, 'periodHistory']); -Route::get('/api/game/periodCurrent', [\app\api\controller\Game::class, 'periodCurrent']); +Route::add(['GET', 'POST'], '/api/game/lobbyInit', [\app\api\controller\Game::class, 'lobbyInit']); +Route::add(['GET', 'POST'], '/api/game/dictionaryList', [\app\api\controller\Game::class, 'dictionaryList']); +Route::add(['GET', 'POST'], '/api/game/periodHistory', [\app\api\controller\Game::class, 'periodHistory']); +Route::add(['GET', 'POST'], '/api/game/periodCurrent', [\app\api\controller\Game::class, 'periodCurrent']); Route::post('/api/game/betPlace', [\app\api\controller\Game::class, 'betPlace']); -Route::get('/api/game/betMyOrders', [\app\api\controller\Game::class, 'betMyOrders']); +Route::add(['GET', 'POST'], '/api/game/betMyOrders', [\app\api\controller\Game::class, 'betMyOrders']); -Route::get('/api/wallet/balanceSummary', [\app\api\controller\Wallet::class, 'balanceSummary']); -Route::get('/api/wallet/recordList', [\app\api\controller\Wallet::class, 'recordList']); +Route::add(['GET', 'POST'], '/api/wallet/balanceSummary', [\app\api\controller\Wallet::class, 'balanceSummary']); +Route::add(['GET', 'POST'], '/api/wallet/recordList', [\app\api\controller\Wallet::class, 'recordList']); +Route::add(['GET', 'POST'], '/api/finance/depositTierList', [\app\api\controller\Finance::class, 'depositTierList']); Route::post('/api/finance/depositCreate', [\app\api\controller\Finance::class, 'depositCreate']); -Route::get('/api/finance/depositDetail', [\app\api\controller\Finance::class, 'depositDetail']); +Route::add(['GET', 'POST'], '/api/finance/depositDetail', [\app\api\controller\Finance::class, 'depositDetail']); +Route::add(['GET', 'POST'], '/api/finance/depositList', [\app\api\controller\Finance::class, 'depositList']); Route::post('/api/finance/withdrawCreate', [\app\api\controller\Finance::class, 'withdrawCreate']); -Route::get('/api/finance/withdrawDetail', [\app\api\controller\Finance::class, 'withdrawDetail']); +Route::add(['GET', 'POST'], '/api/finance/withdrawDetail', [\app\api\controller\Finance::class, 'withdrawDetail']); +Route::add(['GET', 'POST'], '/api/finance/withdrawList', [\app\api\controller\Finance::class, 'withdrawList']); Route::get('/api/notice/noticeList', [\app\api\controller\Notice::class, 'noticeList']); Route::get('/api/notice/noticeDetail', [\app\api\controller\Notice::class, 'noticeDetail']); -Route::post('/api/notice/noticeConfirm', [\app\api\controller\Notice::class, 'noticeConfirm']); +Route::get('/api/notice/noticeConfirm', [\app\api\controller\Notice::class, 'noticeConfirm']); // ==================== Admin 路由 ==================== // Admin 多为 JSON API,前端可能用 GET 传参查列表、POST 提交表单,使用 any 确保兼容 diff --git a/web/src/lang/backend/en/config/depositTier.ts b/web/src/lang/backend/en/config/depositTier.ts new file mode 100644 index 0000000..889917d --- /dev/null +++ b/web/src/lang/backend/en/config/depositTier.ts @@ -0,0 +1,29 @@ +export default { + title: 'Deposit Tiers', + desc: 'Configure the deposit tiers players can pick when creating a deposit order. In the third-party payment mode, only tier specs (name, amount, bonus, description) are maintained; receiving accounts are no longer stored here. Maintain both Chinese and English text for the title and description: the mobile API returns the language matching the request `lang` header, falling back to Chinese if English is blank. Changes take effect immediately.', + btn_add: 'Add Tier', + btn_save: 'Save', + btn_remove: 'Delete', + confirm_remove: 'Delete this deposit tier?', + tier_id: 'Tier ID', + auto_id: '(generated on save)', + sort: 'Sort', + status: 'Enabled', + title_col: 'Title (ZH)', + title_ph: 'e.g. 新手首充、VIP 高额充值', + title_en_col: 'Title (EN)', + title_en_ph: 'e.g. Starter Pack, VIP Recharge', + amount: 'Amount', + amount_ph: 'e.g. 100.00', + bonus_amount: 'Bonus', + bonus_ph: 'e.g. 20.00, use 0 if none', + desc_col: 'Description (ZH)', + desc_ph: 'Optional Chinese description, up to 255 chars', + desc_en_col: 'Description (EN)', + desc_en_ph: 'Optional English description, up to 255 chars', + currency: '', + operate: 'Action', + err_title: 'Row {no}: Chinese title is required', + err_amount: 'Row {no}: amount must be a number greater than 0', + err_bonus: 'Row {no}: bonus must be a number no less than 0', +} diff --git a/web/src/lang/backend/en/game/betOrder.ts b/web/src/lang/backend/en/game/betOrder.ts index 7dc7f43..af84b80 100644 --- a/web/src/lang/backend/en/game/betOrder.ts +++ b/web/src/lang/backend/en/game/betOrder.ts @@ -6,9 +6,7 @@ export default { user_id: 'User ID', channel_id: 'Channel ID', pick_numbers: 'Picks', - unit_amount: 'Unit amount', - pick_count: 'Pick count', - total_amount: 'Total', + total_amount: 'Total bet amount', streak_at_bet: 'Streak at bet', is_auto: 'Auto', 'is_auto 0': 'Manual', diff --git a/web/src/lang/backend/en/game/live.ts b/web/src/lang/backend/en/game/live.ts index d0e04c3..d2bfc5a 100644 --- a/web/src/lang/backend/en/game/live.ts +++ b/web/src/lang/backend/en/game/live.ts @@ -18,6 +18,6 @@ export default { bet_id: 'Bet ID', user_id: 'Player ID', pick_numbers: 'Pick numbers', - unit_amount: 'Unit amount', + total_amount: 'Total bet amount', streak_at_bet: 'Streak at bet', } diff --git a/web/src/lang/backend/en/game/user.ts b/web/src/lang/backend/en/game/user.ts index 6a8a0a7..a625b43 100644 --- a/web/src/lang/backend/en/game/user.ts +++ b/web/src/lang/backend/en/game/user.ts @@ -11,7 +11,8 @@ export default { coin: 'Coin balance', coin_placeholder: 'decimal(18,4)', total_deposit_coin: 'Total deposit (coin)', - total_valid_bet_coin: 'Total valid bet (coin)', + total_withdraw_coin: 'Total withdraw (coin)', + bet_flow_coin: 'Bet flow (coin)', risk_flags: 'Risk', risk_none: 'None', risk_no_login: 'No login', diff --git a/web/src/lang/backend/en/order/betOrder.ts b/web/src/lang/backend/en/order/betOrder.ts index ead2c0a..e451da3 100644 --- a/web/src/lang/backend/en/order/betOrder.ts +++ b/web/src/lang/backend/en/order/betOrder.ts @@ -6,9 +6,7 @@ user_id: 'User ID', channel_id: 'Channel ID', pick_numbers: 'Picks', - unit_amount: 'Unit amount', - pick_count: 'Pick count', - total_amount: 'Total', + total_amount: 'Total bet amount', streak_at_bet: 'Streak at bet', is_auto: 'Auto', 'is_auto 0': 'Manual', diff --git a/web/src/lang/backend/en/order/depositOrder.ts b/web/src/lang/backend/en/order/depositOrder.ts index 8022dbf..af30c6d 100644 --- a/web/src/lang/backend/en/order/depositOrder.ts +++ b/web/src/lang/backend/en/order/depositOrder.ts @@ -1,10 +1,13 @@ export default { - 'quick Search Fields': 'Order No./User ID/Pay channel', + 'quick Search Fields': 'Order No./User ID/Pay channel/Tier/Idempotency key', id: 'ID', order_no: 'Order No.', + idempotency_key: 'Idempotency key', user_id: 'User ID', channel_id: 'Channel ID', amount: 'Amount', + bonus_amount: 'Bonus', + total_credit: 'Total credit', status: 'Status', 'status 0': 'Pending', 'status 1': 'Success', @@ -12,9 +15,12 @@ export default { 'status 3': 'Canceled', pay_channel: 'Pay channel', pay_time: 'Pay time', + deposit_tier_id: 'Deposit tier', remark: 'Remark', create_time: 'Created', update_time: 'Updated', user_username: 'Username', channel_name: 'Channel', + detail_title: 'Deposit Order Detail', + close_btn: 'Close', } diff --git a/web/src/lang/backend/en/order/withdrawOrder.ts b/web/src/lang/backend/en/order/withdrawOrder.ts index 6377f77..ab4c056 100644 --- a/web/src/lang/backend/en/order/withdrawOrder.ts +++ b/web/src/lang/backend/en/order/withdrawOrder.ts @@ -17,7 +17,20 @@ export default { remark: 'Remark', create_time: 'Created', update_time: 'Updated', - user_username: 'Username', + user_username: 'User', channel_name: 'Channel', review_admin_username: 'Reviewer', + review_title: 'Withdraw review', + review_reject_title: 'Reject withdraw', + review_btn_approve: 'Approve', + review_btn_reject: 'Reject', + review_btn_back: 'Back', + review_btn_confirm_reject: 'Confirm reject', + review_reject_tip: 'Rejected withdrawals will refund the frozen amount back to the user wallet.', + review_reject_placeholder: 'Enter reject reason (visible to the user on mobile history)', + reject_reason_required: 'Please enter reject reason', + already_reviewed: 'This order has already been reviewed', + amount_invalid: 'Apply amount must be greater than 0', + fee_invalid: 'Fee cannot be negative', + fee_exceed_amount: 'Fee cannot exceed apply amount', } diff --git a/web/src/lang/backend/en/user/user.ts b/web/src/lang/backend/en/user/user.ts index 41efcc0..775b9b4 100644 --- a/web/src/lang/backend/en/user/user.ts +++ b/web/src/lang/backend/en/user/user.ts @@ -11,7 +11,8 @@ export default { coin: 'Coin balance', coin_placeholder: 'Amounts are displayed with 2 decimals', total_deposit_coin: 'Total deposit (coin)', - total_valid_bet_coin: 'Total valid bet (coin)', + total_withdraw_coin: 'Total withdraw (coin)', + bet_flow_coin: 'Bet flow (coin)', risk_flags: 'Risk', risk_none: 'None', risk_no_login: 'No login', diff --git a/web/src/lang/backend/zh-cn/config/depositTier.ts b/web/src/lang/backend/zh-cn/config/depositTier.ts new file mode 100644 index 0000000..2b63bf6 --- /dev/null +++ b/web/src/lang/backend/zh-cn/config/depositTier.ts @@ -0,0 +1,29 @@ +export default { + title: '充值档位', + desc: '配置玩家创建充值订单时可选的充值档位。第三方支付模式下仅需维护档位规格:名称、充值金额、赠送金额、描述等;不再保存收款账户信息。充值名称/描述需分别维护中英文两套:移动端接口会根据请求头 `lang` 返回对应语言,英文缺省时回退到中文。修改后立即生效。', + btn_add: '新增档位', + btn_save: '保存', + btn_remove: '删除', + confirm_remove: '确定删除该充值档位?', + tier_id: '档位 ID', + auto_id: '(保存时生成)', + sort: '排序', + status: '启用', + title_col: '充值名称(中文)', + title_ph: '例如:新手首充、VIP 高额充值', + title_en_col: '充值名称(英文)', + title_en_ph: 'e.g. Starter Pack, VIP Recharge', + amount: '充值金额', + amount_ph: '例如:100.00', + bonus_amount: '赠送金额', + bonus_ph: '例如:20.00,无赠送填 0', + desc_col: '描述(中文)', + desc_ph: '可选,展示给中文玩家的档位说明,最长 255 字', + desc_en_col: '描述(英文)', + desc_en_ph: 'Optional English description for EN players, up to 255 chars', + currency: '币', + operate: '操作', + err_title: '第 {no} 行:中文充值名称不能为空', + err_amount: '第 {no} 行:充值金额必须为大于 0 的数字', + err_bonus: '第 {no} 行:赠送金额必须为不小于 0 的数字', +} diff --git a/web/src/lang/backend/zh-cn/game/betOrder.ts b/web/src/lang/backend/zh-cn/game/betOrder.ts index 8ffa7a7..54bb0e6 100644 --- a/web/src/lang/backend/zh-cn/game/betOrder.ts +++ b/web/src/lang/backend/zh-cn/game/betOrder.ts @@ -6,9 +6,7 @@ export default { user_id: '用户ID', channel_id: '渠道ID', pick_numbers: '选号', - unit_amount: '单号金额', - pick_count: '选号个数', - total_amount: '总金额', + total_amount: '压注总额', streak_at_bet: '下注时连胜', is_auto: '托管', 'is_auto 0': '手动', diff --git a/web/src/lang/backend/zh-cn/game/live.ts b/web/src/lang/backend/zh-cn/game/live.ts index 6fbeb74..b68f475 100644 --- a/web/src/lang/backend/zh-cn/game/live.ts +++ b/web/src/lang/backend/zh-cn/game/live.ts @@ -18,6 +18,6 @@ export default { bet_id: '注单ID', user_id: '玩家ID', pick_numbers: '压注号码', - unit_amount: '单号金额', + total_amount: '压注总额', streak_at_bet: '下注时连胜', } diff --git a/web/src/lang/backend/zh-cn/game/user.ts b/web/src/lang/backend/zh-cn/game/user.ts index d7398dd..9844406 100644 --- a/web/src/lang/backend/zh-cn/game/user.ts +++ b/web/src/lang/backend/zh-cn/game/user.ts @@ -11,7 +11,8 @@ export default { coin: '游戏币余额', coin_placeholder: 'decimal(18,4),禁止业务用浮点存库', total_deposit_coin: '累计充值(币)', - total_valid_bet_coin: '累计有效投注(币)', + total_withdraw_coin: '累计提现(币)', + bet_flow_coin: '打码量/流水(币)', risk_flags: '风控', risk_none: '无限制', risk_no_login: '禁止登录', diff --git a/web/src/lang/backend/zh-cn/order/betOrder.ts b/web/src/lang/backend/zh-cn/order/betOrder.ts index 832ccd2..8a94f5c 100644 --- a/web/src/lang/backend/zh-cn/order/betOrder.ts +++ b/web/src/lang/backend/zh-cn/order/betOrder.ts @@ -6,9 +6,7 @@ user_id: '用户ID', channel_id: '渠道ID', pick_numbers: '选号', - unit_amount: '单号金额', - pick_count: '选号个数', - total_amount: '总金额', + total_amount: '压注总额', streak_at_bet: '下注时连胜', is_auto: '托管', 'is_auto 0': '手动', diff --git a/web/src/lang/backend/zh-cn/order/depositOrder.ts b/web/src/lang/backend/zh-cn/order/depositOrder.ts index 3e202f6..cefc125 100644 --- a/web/src/lang/backend/zh-cn/order/depositOrder.ts +++ b/web/src/lang/backend/zh-cn/order/depositOrder.ts @@ -1,20 +1,26 @@ export default { - 'quick Search Fields': '订单号/用户ID/支付通道', + 'quick Search Fields': '订单号/用户ID/支付通道/档位ID/幂等键', id: 'ID', order_no: '订单号', + idempotency_key: '幂等键', user_id: '用户ID', user_username: '用户名', channel_id: '渠道ID', channel_name: '渠道', amount: '金额', + bonus_amount: '赠送金额', + total_credit: '实际到账', status: '状态', - 'status 0': '待处理', + 'status 0': '待支付', 'status 1': '成功', 'status 2': '失败', 'status 3': '已取消', pay_channel: '支付通道', pay_time: '支付时间', + deposit_tier_id: '充值档位', remark: '备注', create_time: '创建时间', update_time: '更新时间', + detail_title: '充值订单详情', + close_btn: '关闭', } diff --git a/web/src/lang/backend/zh-cn/order/withdrawOrder.ts b/web/src/lang/backend/zh-cn/order/withdrawOrder.ts index d4f8368..87c2939 100644 --- a/web/src/lang/backend/zh-cn/order/withdrawOrder.ts +++ b/web/src/lang/backend/zh-cn/order/withdrawOrder.ts @@ -17,7 +17,20 @@ export default { remark: '备注', create_time: '创建时间', update_time: '更新时间', - user_username: '用户名', + user_username: '用户', channel_name: '渠道', review_admin_username: '审核人', + review_title: '提现审核', + review_reject_title: '提现拒绝', + review_btn_approve: '通过', + review_btn_reject: '拒绝', + review_btn_back: '返回', + review_btn_confirm_reject: '确认拒绝', + review_reject_tip: '拒绝审核后,冻结的提现金额将原路退回用户钱包余额。', + review_reject_placeholder: '请输入拒绝原因,玩家可在提现记录中看到该说明', + reject_reason_required: '请输入拒绝原因', + already_reviewed: '该订单已审核,无需重复操作', + amount_invalid: '申请金额必须大于 0', + fee_invalid: '手续费不能为负', + fee_exceed_amount: '手续费不能大于申请金额', } diff --git a/web/src/lang/backend/zh-cn/user/user.ts b/web/src/lang/backend/zh-cn/user/user.ts index 0221e8b..5c22a7e 100644 --- a/web/src/lang/backend/zh-cn/user/user.ts +++ b/web/src/lang/backend/zh-cn/user/user.ts @@ -1,4 +1,4 @@ -export default { +export default { id: 'ID', username: '用户名', password: '密码', @@ -11,7 +11,8 @@ coin: '余额', coin_placeholder: '金额展示统一两位小数', total_deposit_coin: '累计充值(币)', - total_valid_bet_coin: '累计有效投注(币)', + total_withdraw_coin: '累计提现(币)', + bet_flow_coin: '打码量/流水(币)', risk_flags: '风控', risk_none: '无限制', risk_no_login: '禁止登录', diff --git a/web/src/views/backend/config/depositTier/index.vue b/web/src/views/backend/config/depositTier/index.vue new file mode 100644 index 0000000..570ea57 --- /dev/null +++ b/web/src/views/backend/config/depositTier/index.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/web/src/views/backend/game/live/index.vue b/web/src/views/backend/game/live/index.vue index e86d5b2..058fe91 100644 --- a/web/src/views/backend/game/live/index.vue +++ b/web/src/views/backend/game/live/index.vue @@ -48,7 +48,7 @@ {{ formatPicks(scope.row.pick_numbers) }} - + diff --git a/web/src/views/backend/order/betOrder/index.vue b/web/src/views/backend/order/betOrder/index.vue index 41ca510..9c08a30 100644 --- a/web/src/views/backend/order/betOrder/index.vue +++ b/web/src/views/backend/order/betOrder/index.vue @@ -142,15 +142,6 @@ const baTable = new baTableClass( operator: false, formatter: formatPickNumbers, }, - { - label: t('order.betOrder.unit_amount'), - prop: 'unit_amount', - align: 'center', - minWidth: 110, - operator: 'RANGE', - formatter: formatAmount, - }, - { label: t('order.betOrder.pick_count'), prop: 'pick_count', align: 'center', width: 90, operator: 'RANGE' }, { label: t('order.betOrder.total_amount'), prop: 'total_amount', diff --git a/web/src/views/backend/order/depositOrder/index.vue b/web/src/views/backend/order/depositOrder/index.vue index b50256e..0a89acc 100644 --- a/web/src/views/backend/order/depositOrder/index.vue +++ b/web/src/views/backend/order/depositOrder/index.vue @@ -1,9 +1,9 @@ -