diff --git a/.env-example b/.env-example index 1e1d6fc..da27b98 100644 --- a/.env-example +++ b/.env-example @@ -38,7 +38,7 @@ GAME_HOT_CACHE_QUEUE_CONSUMER_TICK = 0.1 GAME_HOT_CACHE_QUEUE_CONSUMER_BATCH = 80 # 移动端接口鉴权(/api/v1/authToken) -AUTH_TOKEN_SECRET = 564d14asdasd113e46542asd6das1a2a +AUTH_TOKEN_SECRET = # H5/后台联调共用:WebSocket 连接地址(建议带 /ws/ 路径) # HTTPS 域名请使用 wss:// @@ -49,6 +49,12 @@ H5_WEBSOCKET_URL = wss://zihua-api.h55555game.top/ws/ # 当前代码注册表仅内置 ddpay;一般无需再追加。示例:DEPOSIT_CHANNELS_REGISTRY_JSON = DEPOSIT_CHANNELS_REGISTRY_JSON = +# 模拟支付(channel_code=mock):未接入 DDPay 时用于联调充值/提现;生产请设为 0 +FINANCE_MOCK_PAY_ENABLED = 1 + +# 充值待支付有效秒数(超时自动失败、支付链接倒计时;mock/ddpay 全渠道统一,默认 60) +DEPOSIT_PENDING_EXPIRE_SECONDS = 60 + # ========== DDPay Payment Gateway(文档:docs/DDPay Payment Gateway_v1.1.3_zh.md)========== # 公网 HTTPS 根地址,无尾斜杠;用于拼接入金/出金 callback_url。生产必填;不配则从请求 Host 推导(本地可能为 http)。 DDPAY_PUBLIC_BASE_URL = @@ -64,3 +70,9 @@ DDPAY_DEPOSIT_INIT_URL = DDPAY_DEPOSIT_STATUS_URL = DDPAY_PAYOUT_INIT_URL = DDPAY_PAYOUT_STATUS_URL = + +# 模拟充值:前端 public/mock-deposit.html 所在站点根地址(无尾斜杠) +# 开发:Vite 一般为 http://127.0.0.1:5173;生产:与后台同域则填 DDPAY_PUBLIC_BASE_URL 或留空(自动用 API 公网根) +# MOCK_DEPOSIT_HTML_BASE = http://127.0.0.1:5173 +# 模拟充值链接签名密钥(不配则回退 AUTH_TOKEN_SECRET) +# FINANCE_MOCK_PAY_LINK_SECRET = diff --git a/app/admin/controller/order/DepositOrder.php b/app/admin/controller/order/DepositOrder.php index 30e411a..238e25e 100644 --- a/app/admin/controller/order/DepositOrder.php +++ b/app/admin/controller/order/DepositOrder.php @@ -3,18 +3,19 @@ namespace app\admin\controller\order; use app\common\controller\Backend; +use app\common\library\finance\DepositSettlement; +use app\common\library\finance\MockPay; +use RuntimeException; +use support\think\Db; use support\Response; +use Throwable; use Webman\Http\Request as WebmanRequest; /** * 充值订单 * - * 订单的"由 0 转 1(成功入账)"统一走 app\common\library\finance\DepositSettlement。 - * 当前充值接口为 mock 支付网关,点击即成功;后台不再保留人工审核按钮, - * 如需人工补单,请通过后续专门的"补单/冲正"工具完成,而不是在这个 CRUD 里直接改 status。 - * - * 编辑入口现在只用于"查看详情":GET 返回订单 + 关联的 user/channel 信息, - * 阻止 POST 任何改字段的动作(保证金额、状态只能由结算服务变更)。 + * 模拟支付流程:用户确认支付后 status=3(待审核),管理员 approve 后由 DepositSettlement 入账。 + * 编辑入口用于查看详情;审核通过/驳回走 approve/reject。 */ class DepositOrder extends Backend { @@ -30,7 +31,7 @@ class DepositOrder extends Backend protected string|array $orderGuarantee = ['id' => 'desc']; - protected array $withJoinTable = ['user', 'channel']; + protected array $withJoinTable = ['user', 'channel', 'reviewAdmin']; protected function initController(WebmanRequest $request): ?Response { @@ -58,6 +59,7 @@ class DepositOrder extends Backend ->visible([ 'user' => ['username', 'phone'], 'channel' => ['name'], + 'reviewAdmin' => ['username'], ]) ->alias($alias) ->where($where) @@ -93,7 +95,7 @@ class DepositOrder extends Backend } if ($this->request && $this->request->method() === 'POST') { - return $this->error(__('Deposit orders are auto-settled; direct modification is not allowed. Use the dedicated tool for manual adjustment.')); + return $this->error(__('Please use approve/reject buttons to complete the review')); } $row = $this->loadWithRelations(intval(strval($id))); @@ -113,8 +115,9 @@ class DepositOrder extends Backend ->withJoin($this->withJoinTable, $this->withJoinType) ->with($this->withJoinTable) ->visible([ - 'user' => ['username', 'phone', 'admin_id'], - 'channel' => ['name'], + 'user' => ['username', 'phone', 'admin_id'], + 'channel' => ['name'], + 'reviewAdmin' => ['username'], ]) ->where($this->model->getTable() . '.id', $id) ->find(); @@ -164,4 +167,190 @@ class DepositOrder extends Backend return $adminIds === [] ? [0] : $adminIds; } + /** + * 审核通过:将待审核订单结算入账(status 3 -> 1) + */ + 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')); + } + + $scoped = $this->loadWithRelations($id); + if (!$scoped) { + return $this->error(__('Record not found')); + } + if (!$this->checkChannelScoped($scoped)) { + return $this->error(__('You have no permission')); + } + + $order = Db::name('deposit_order')->where('id', $id)->find(); + if (!$order) { + return $this->error(__('Record not found')); + } + + $currentStatus = $this->intParam($order['status'] ?? 0); + if ($currentStatus !== MockPay::DEPOSIT_STATUS_PENDING_REVIEW) { + return $this->error(__('This order has already been reviewed')); + } + + $now = time(); + $adminId = $this->intParam($this->auth->id ?? 0); + $adminName = $this->adminDisplayName(); + $extraRemark = '管理员(' . $adminName . ')审核通过并入账'; + + try { + DepositSettlement::settle( + $id, + DepositSettlement::SOURCE_ADMIN_APPROVE, + 'admin approve mock deposit', + $adminId > 0 ? $adminId : null, + $extraRemark + ); + } catch (RuntimeException $e) { + return $this->error($e->getMessage()); + } catch (Throwable $e) { + return $this->error($e->getMessage()); + } + + $patch = [ + 'update_time' => $now, + ]; + if ($this->depositOrderHasColumn('review_admin_id')) { + $patch['review_admin_id'] = $adminId > 0 ? $adminId : null; + } + if ($this->depositOrderHasColumn('review_time')) { + $patch['review_time'] = $now; + } + Db::name('deposit_order')->where('id', $id)->where('status', 1)->update($patch); + + return $this->success(__('Approved')); + } + + /** + * 审核驳回:必须填写备注(reject_reason) + */ + 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(__('Please provide reject reason')); + } + + $scoped = $this->loadWithRelations($id); + if (!$scoped) { + return $this->error(__('Record not found')); + } + if (!$this->checkChannelScoped($scoped)) { + return $this->error(__('You have no permission')); + } + + $order = Db::name('deposit_order')->where('id', $id)->find(); + if (!$order) { + return $this->error(__('Record not found')); + } + + $currentStatus = $this->intParam($order['status'] ?? 0); + if ($currentStatus !== MockPay::DEPOSIT_STATUS_PENDING_REVIEW) { + return $this->error(__('This order has already been reviewed')); + } + + $now = time(); + $adminId = $this->intParam($this->auth->id ?? 0); + $adminName = $this->adminDisplayName(); + $rejectReason = mb_substr($remark, 0, 255); + $baseRemark = is_string($order['remark'] ?? null) ? trim($order['remark']) : ''; + $note = '管理员(' . $adminName . ')驳回:' . $rejectReason; + $combined = $baseRemark === '' ? $note : mb_substr($baseRemark . ' | ' . $note, 0, 255); + + $update = [ + 'status' => 2, + 'remark' => $combined, + 'update_time' => $now, + ]; + if ($this->depositOrderHasColumn('reject_reason')) { + $update['reject_reason'] = $rejectReason; + } + if ($this->depositOrderHasColumn('review_admin_id')) { + $update['review_admin_id'] = $adminId > 0 ? $adminId : null; + } + if ($this->depositOrderHasColumn('review_time')) { + $update['review_time'] = $now; + } + + $affected = Db::name('deposit_order') + ->where('id', $id) + ->where('status', MockPay::DEPOSIT_STATUS_PENDING_REVIEW) + ->update($update); + if (!is_numeric($affected) || intval($affected) <= 0) { + return $this->error(__('This order has already been reviewed')); + } + + return $this->success(__('Rejected')); + } + + private function depositOrderHasColumn(string $column): bool + { + static $cache = []; + if (array_key_exists($column, $cache)) { + return $cache[$column]; + } + try { + $rows = Db::query('SHOW COLUMNS FROM `deposit_order` LIKE ?', [$column]); + $cache[$column] = is_array($rows) && $rows !== []; + } catch (Throwable $e) { + $cache[$column] = false; + } + + return $cache[$column]; + } + + private function intParam($raw): int + { + if ($raw === null || $raw === '') { + return 0; + } + if (is_numeric(strval($raw))) { + return intval(strval($raw)); + } + + return 0; + } + + private function adminDisplayName(): string + { + if (!$this->auth) { + return 'admin'; + } + $username = $this->auth->username ?? ''; + if (is_string($username) && trim($username) !== '') { + return trim($username); + } + + return 'admin#' . strval($this->auth->id ?? 0); + } + } diff --git a/app/admin/controller/order/WithdrawOrder.php b/app/admin/controller/order/WithdrawOrder.php index e469ac4..203c874 100644 --- a/app/admin/controller/order/WithdrawOrder.php +++ b/app/admin/controller/order/WithdrawOrder.php @@ -4,6 +4,7 @@ namespace app\admin\controller\order; use app\common\controller\Backend; use app\common\library\finance\DDPayGateway; +use app\common\library\finance\MockPay; use support\think\Db; use support\Response; use Throwable; @@ -265,12 +266,21 @@ class WithdrawOrder extends Backend $orderNo = is_string($fresh['order_no'] ?? null) ? trim($fresh['order_no'] ?? '') : strval($fresh['order_no'] ?? ''); $receiveType = is_string($fresh['receive_type'] ?? null) ? strtolower(trim($fresh['receive_type'] ?? '')) : ''; $payChannel = is_string($fresh['pay_channel'] ?? null) ? strtolower(trim($fresh['pay_channel'] ?? '')) : ''; - if ($payChannel === '') { - $payChannel = 'ddpay'; - } - // 当前仅 ddpay + bank 类型自动出金(与移动端 withdrawCreate 校验一致) - if ($orderNo !== '' && $receiveType === 'bank' && $payChannel === 'ddpay') { + // 模拟出金:审核通过即标记已打款,不回冲、不调用 DDPay + if ($orderNo !== '' && MockPay::shouldSimulateWithdrawPayout($payChannel)) { + $prevRemark = is_string($fresh['remark'] ?? null) ? trim($fresh['remark']) : ''; + $mockNote = '[mock] 管理员(' . $adminName . ')审核通过,模拟打款成功'; + $finalRemark = $prevRemark === '' ? $mockNote : mb_substr($prevRemark . ' | ' . $mockNote, 0, 255); + Db::name('withdraw_order') + ->where('id', $id) + ->where('status', 1) + ->update([ + 'status' => 3, + 'remark' => $finalRemark, + 'update_time' => time(), + ]); + } elseif ($orderNo !== '' && $receiveType === 'bank' && $payChannel === 'ddpay') { $base = \app\common\library\finance\DDPayGateway::publicBaseUrlForCallbacks($request); if ($base === '') { $base = 'https://' . strval($request->host()); @@ -514,12 +524,15 @@ class WithdrawOrder extends Backend ]); } + $finalStatus = Db::name('withdraw_order')->where('id', $id)->value('status'); + $finalStatusInt = is_numeric($finalStatus) ? intval($finalStatus) : 1; + return $this->success(__('Approved'), [ 'id' => $id, 'amount' => $newAmount, 'fee' => $newFee, 'actual_amount' => $newActual, - 'status' => 1, + 'status' => $finalStatusInt, ]); } diff --git a/app/api/controller/Finance.php b/app/api/controller/Finance.php index c16632e..5b6d6af 100644 --- a/app/api/controller/Finance.php +++ b/app/api/controller/Finance.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace app\api\controller; use app\common\library\finance\DepositSettlement; +use app\common\library\finance\MockPay; use app\common\library\finance\DDPayGateway; use app\common\library\finance\WithdrawFlow; use app\common\library\game\DepositChannel as DepositChannelLib; @@ -30,16 +31,22 @@ class Finance extends MobileBase protected array $noNeedLogin = [ 'ddpayDepositNotify', 'ddpayPayoutNotify', + 'mockDepositPage', + 'mockDepositConfirm', + 'mockDepositStatus', ]; /** - * DDPay 回调与重定向允许浏览器直接访问(无 auth-token)。 + * DDPay 回调、模拟收银台页允许浏览器直接访问(无 auth-token)。 */ protected array $noNeedAuthToken = [ 'ddpayDepositNotify', 'ddpayDepositRedirect', 'ddpayPayoutNotify', 'ddpayPayoutRedirect', + 'mockDepositPage', + 'mockDepositConfirm', + 'mockDepositStatus', ]; /** @@ -98,14 +105,14 @@ class Finance extends MobileBase } /** - * 创建充值订单(仅 DDPay) + * 创建充值订单(DDPay 或模拟支付 mock) * - * `channel_code` 必须为 `ddpay`。服务端创建待支付订单后调用 DDPay「入金发起」,返回三方 `payment_url` 作为 `pay_url`; - * 入账由 `ddpayDepositNotify` Webhook 验签后调用 `DepositSettlement::settle`(或发起响应中 `transaction_status=completed` 时同步结算)。 + * - `channel_code=mock`:返回 `pay_url`(模拟收银台,3 分钟有效);用户确认后 status=3 待审核,后台通过后入账。 + * - `channel_code=ddpay`:调用 DDPay「入金发起」;入账由回调或同步 completed 结算。 * * 请求:application/json 或 x-www-form-urlencoded - * - tier_id / tier_key、channel_code=ddpay、idempotency_key:必填 - * - payment_type、payer_name、payer_bank_name:DDPay 入金必填(见 DDPay 文档与移动端接口说明) + * - tier_id / tier_key、channel_code、idempotency_key:必填 + * - DDPay 渠道另需 payment_type、payer_name、payer_bank_name * * 响应:`order_no` / `amount` / `pay_channel` / `paid` / `pay_url` / `status` / `create_time` / `pay_time` */ @@ -128,8 +135,23 @@ class Finance extends MobileBase if (mb_strlen($idempotencyKey) > 64) { return $this->mobileError(1002, 'Idempotency key is too long'); } - if ($channelCode !== 'ddpay') { - return $this->mobileError(2004, 'Deposit only supports DDPay'); + if ($channelCode === MockPay::CHANNEL_CODE) { + if (!MockPay::isEnabled()) { + return $this->mobileError(2004, 'Mock pay channel is disabled'); + } + } elseif ($channelCode === 'ddpay') { + if (!DDPayGateway::isConfigured()) { + if (!MockPay::isEnabled()) { + return $this->mobileError(2004, 'DDPay is not configured', [ + 'suggest_channel_code' => MockPay::CHANNEL_CODE, + 'hint' => 'Set FINANCE_MOCK_PAY_ENABLED=1 or configure DDPAY_* in .env', + ]); + } + // 未配置 DDPay 商户:联调环境自动改用 mock(无需改 Apifox 参数) + $channelCode = MockPay::CHANNEL_CODE; + } + } else { + return $this->mobileError(2004, 'Pay channel not supported'); } $tiers = $this->loadEnabledTiers(); @@ -165,7 +187,7 @@ class Finance extends MobileBase return $this->mobileError(2005, 'Too many pending deposit orders', [ 'max_pending' => DepositOrderExpireService::MAX_PENDING_DEPOSIT, 'pending_count' => $pendingCount, - 'expire_seconds' => DepositOrderExpireService::EXPIRE_SECONDS, + 'expire_seconds' => DepositOrderExpireService::pendingExpireSeconds(), ]); } @@ -189,6 +211,9 @@ class Finance extends MobileBase 'desc_en' => is_string($tier['desc_en'] ?? null) ? $tier['desc_en'] : '', 'channel_code' => $channelCode, ]; + if ($channelCode === MockPay::CHANNEL_CODE && strtolower($this->stringParam($request->input('channel_code'))) === 'ddpay') { + $tierSnapshot['ddpay_fallback'] = true; + } $now = time(); $channelId = null; @@ -208,7 +233,9 @@ class Finance extends MobileBase 'pay_channel' => $channelCode, 'deposit_tier_id' => $tier['id'], 'pay_account_snapshot' => json_encode($tierSnapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - 'remark' => '', + 'remark' => $channelCode === MockPay::CHANNEL_CODE && ($tierSnapshot['ddpay_fallback'] ?? false) + ? '[mock] auto fallback: DDPay not configured' + : '', 'create_time' => $now, 'update_time' => $now, ]); @@ -226,6 +253,10 @@ class Finance extends MobileBase $orderId = is_numeric($order->id ?? null) ? intval(strval($order->id)) : 0; $publicOrigin = $this->publicOriginFromRequest($request); + if ($channelCode === MockPay::CHANNEL_CODE) { + return $this->finishMockDepositCreate($order, $orderId, $orderNo, $publicOrigin); + } + // DDPay 入金:创建订单后,调用三方「入金发起」拿到 payment_url,并在回调里验签结算。 $toString = static function (mixed $v): string { if (is_string($v)) { @@ -257,6 +288,9 @@ class Finance extends MobileBase if ($paymentType === '' || $payerName === '' || $payerBankName === '') { return $this->mobileError(1001, 'Missing DDPay parameters'); } + if (!$this->isValidDdpayPaymentType($paymentType)) { + return $this->mobileError(1001, 'Invalid DDPay payment_type'); + } $callbackUrl = rtrim($publicOrigin, '/') . '/api/finance/ddpayDepositNotify'; $redirectUrl = rtrim($publicOrigin, '/') . '/api/finance/ddpayDepositRedirect?order_no=' . rawurlencode($orderNo); @@ -295,7 +329,13 @@ class Finance extends MobileBase ]); } - return $this->mobileError(2000, 'DDPay deposit initiation failed'); + $reason = trim($e->getMessage()); + if ($reason === '') { + $reason = 'DDPay deposit initiation failed'; + } + return $this->mobileError(2000, 'DDPay deposit initiation failed', [ + 'gateway_reason' => $reason, + ]); } $ts = ''; @@ -357,6 +397,326 @@ class Finance extends MobileBase return $this->mobileSuccess($this->buildDepositResponse($order, $publicOrigin)); } + /** + * 兼容旧链接:校验订单后 302 跳转到前端静态收银台(带签名) + */ + public function mockDepositPage(Request $request): Response + { + if (!MockPay::isEnabled()) { + return response($this->renderMockDepositMessageHtml('模拟支付未开启', ''), 404, [ + 'Content-Type' => 'text/html; charset=utf-8', + ]); + } + + $orderNo = $this->stringParam($request->input('order_no')); + if ($orderNo === '') { + return response($this->renderMockDepositMessageHtml('缺少订单号', ''), 400, [ + 'Content-Type' => 'text/html; charset=utf-8', + ]); + } + + $redirectUrl = $this->buildMockDepositFrontendUrl($orderNo, $this->publicOriginFromRequest($request)); + if ($redirectUrl === '') { + return response($this->renderMockDepositMessageHtml('订单不存在', ''), 404, [ + 'Content-Type' => 'text/html; charset=utf-8', + ]); + } + + return redirect($redirectUrl); + } + + /** + * 模拟收银台页拉取订单状态(前端静态页调用,须携带 sign + expire_at) + */ + public function mockDepositStatus(Request $request): Response + { + if (!MockPay::isEnabled()) { + return $this->mobileError(2004, 'Mock pay channel is disabled'); + } + + $orderNo = $this->stringParam($request->input('order_no')); + $expireAt = $this->intParam($request->input('expire_at')); + $sign = $this->stringParam($request->input('sign')); + if ($orderNo === '' || $expireAt <= 0 || $sign === '') { + return $this->mobileError(1001, 'Missing parameters'); + } + if (!$this->verifyMockDepositLinkSign($orderNo, $expireAt, $sign)) { + return $this->mobileError(1002, 'Invalid payment link signature'); + } + + DepositOrderExpireService::expirePendingOrders(null, $orderNo); + $row = Db::name('deposit_order')->where('order_no', $orderNo)->find(); + if (!is_array($row)) { + return $this->mobileError(2003, 'Order does not exist'); + } + + $payChannel = is_string($row['pay_channel'] ?? null) ? strtolower(trim($row['pay_channel'])) : ''; + if ($payChannel !== MockPay::CHANNEL_CODE) { + return $this->mobileError(2000, 'Order is not a mock pay deposit'); + } + + $statusCode = is_numeric($row['status'] ?? null) ? intval($row['status']) : -1; + $amount = $this->amountString($row['amount'] ?? '0'); + $bonus = $this->amountString($row['bonus_amount'] ?? '0'); + $now = time(); + $remaining = $expireAt > $now ? ($expireAt - $now) : 0; + $canPay = $statusCode === 0 && DepositOrderExpireService::isPendingPaymentValid($row) && $remaining > 0; + + return $this->mobileSuccess([ + 'order_no' => $orderNo, + 'amount' => $this->amountNumber($amount), + 'bonus_amount' => $this->amountNumber($bonus), + 'total_amount' => $this->amountNumber(bcadd($amount, $bonus, 2)), + 'status' => $this->mapDepositStatus($statusCode), + 'status_code' => $statusCode, + 'expire_at' => $expireAt, + 'remaining_seconds' => $remaining, + 'can_pay' => $canPay, + 'reject_reason' => is_string($row['reject_reason'] ?? null) && trim($row['reject_reason']) !== '' + ? trim($row['reject_reason']) + : null, + ]); + } + + /** + * 模拟收银台确认支付(浏览器页内调用,无需 auth-token) + */ + public function mockDepositConfirm(Request $request): Response + { + if (!MockPay::isEnabled()) { + return $this->mobileError(2004, 'Mock pay channel is disabled'); + } + $orderNo = $this->stringParam($request->input('order_no')); + $expireAt = $this->intParam($request->input('expire_at')); + $sign = $this->stringParam($request->input('sign')); + if ($orderNo === '') { + return $this->mobileError(1001, 'Missing parameters'); + } + if ($expireAt <= 0 || $sign === '') { + return $this->mobileError(1001, 'Missing payment link signature'); + } + if (!$this->verifyMockDepositLinkSign($orderNo, $expireAt, $sign)) { + return $this->mobileError(1002, 'Invalid payment link signature'); + } + + $result = $this->confirmMockDepositPendingPayment($orderNo, 'mock page confirm'); + if ($result['error'] !== null) { + return $result['error']; + } + + return $this->mobileSuccess($result['payload']); + } + + /** + * 模拟支付:确认已支付(需登录,API 客户端调用) + */ + public function mockDepositPay(Request $request): Response + { + $response = $this->initializeMobile($request); + if ($response !== null) { + return $response; + } + if (!MockPay::isEnabled()) { + return $this->mobileError(2004, 'Mock pay channel is disabled'); + } + + $orderNo = $this->stringParam($request->input('order_no')); + if ($orderNo === '') { + return $this->mobileError(1001, 'Missing parameters'); + } + + $order = DepositOrder::where('order_no', $orderNo)->where('user_id', $this->auth->id)->find(); + if (!$order) { + return $this->mobileError(2003, 'Order does not exist'); + } + + $result = $this->confirmMockDepositPendingPayment($orderNo, 'mock api confirm'); + if ($result['error'] !== null) { + return $result['error']; + } + + return $this->mobileSuccess($result['payload']); + } + + /** + * @return array{error: ?Response, payload: array} + */ + private function confirmMockDepositPendingPayment(string $orderNo, string $sourceLabel): array + { + DepositOrderExpireService::expirePendingOrders(null, $orderNo); + $row = Db::name('deposit_order')->where('order_no', $orderNo)->find(); + if (!is_array($row)) { + return [ + 'error' => $this->mobileError(2003, 'Order does not exist'), + 'payload' => [], + ]; + } + + $payChannel = is_string($row['pay_channel'] ?? null) ? strtolower(trim($row['pay_channel'])) : ''; + if ($payChannel !== MockPay::CHANNEL_CODE) { + return [ + 'error' => $this->mobileError(2000, 'Order is not a mock pay deposit'), + 'payload' => [], + ]; + } + + $status = is_numeric($row['status'] ?? null) ? intval($row['status']) : -1; + if ($status === 1) { + $order = DepositOrder::where('order_no', $orderNo)->find(); + $payload = $this->buildDepositResponse($order, null); + $payload['review_required'] = false; + return ['error' => null, 'payload' => $payload]; + } + if ($status === MockPay::DEPOSIT_STATUS_PENDING_REVIEW) { + $order = DepositOrder::where('order_no', $orderNo)->find(); + $payload = $this->buildDepositResponse($order, null); + $payload['review_required'] = true; + return ['error' => null, 'payload' => $payload]; + } + if ($status !== 0 || !DepositOrderExpireService::isPendingPaymentValid($row)) { + return [ + 'error' => $this->mobileError(2000, 'Order cannot be paid'), + 'payload' => [], + ]; + } + + $orderId = is_numeric($row['id'] ?? null) ? intval($row['id']) : 0; + if ($orderId <= 0) { + return [ + 'error' => $this->mobileError(2000, 'Order id invalid'), + 'payload' => [], + ]; + } + + $now = time(); + $snap = []; + $snapRaw = $row['pay_account_snapshot'] ?? ''; + if (is_string($snapRaw) && trim($snapRaw) !== '') { + $decoded = json_decode($snapRaw, true); + if (is_array($decoded)) { + $snap = $decoded; + } + } + $snap['mock_paid_at'] = $now; + $snap['mock_paid_source'] = $sourceLabel; + + Db::name('deposit_order')->where('id', $orderId)->where('status', 0)->update([ + 'status' => MockPay::DEPOSIT_STATUS_PENDING_REVIEW, + 'remark' => '[mock] 玩家已支付,待管理员审核', + 'pay_account_snapshot' => json_encode($snap, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'update_time' => $now, + ]); + + $order = DepositOrder::where('id', $orderId)->find(); + $payload = $this->buildDepositResponse($order, null); + $payload['review_required'] = true; + $payload['mock_pay_success'] = true; + $payload['mock_pay_message'] = 'Payment submitted. Pending admin review.'; + + return ['error' => null, 'payload' => $payload]; + } + + /** + * @param \app\common\model\DepositOrder|object $order + */ + private function finishMockDepositCreate($order, int $orderId, string $orderNo, string $publicOrigin): Response + { + $amountStr = $this->amountString($order->amount ?? '0'); + $bonusStr = $this->amountString($order->bonus_amount ?? '0'); + $createTime = is_numeric($order->create_time ?? null) ? intval(strval($order->create_time)) : time(); + $linkAuth = MockPay::buildDepositLinkAuth($orderNo, $createTime); + $expireAt = $linkAuth['expire_at']; + $sign = $linkAuth['sign']; + $payUrl = MockPay::depositPageUrl($orderNo, $publicOrigin, $expireAt, $sign, $amountStr, $bonusStr); + + if ($orderId > 0) { + $snap = []; + $snapRaw = $order->pay_account_snapshot ?? ''; + if (is_string($snapRaw) && trim($snapRaw) !== '') { + $decoded = json_decode($snapRaw, true); + if (is_array($decoded)) { + $snap = $decoded; + } + } + $snap['payment_url'] = $payUrl; + $snap['mock'] = true; + $snap['expire_at'] = $expireAt; + $snap['mock_pay_sign'] = $sign; + Db::name('deposit_order')->where('id', $orderId)->update([ + 'pay_account_snapshot' => json_encode($snap, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'update_time' => time(), + ]); + $refreshed = DepositOrder::where('id', $orderId)->find(); + if ($refreshed) { + $order = $refreshed; + } + } + + $payload = $this->buildDepositResponse($order, $publicOrigin); + $payload['expire_at'] = $expireAt; + $payload['expire_seconds'] = DepositOrderExpireService::pendingExpireSeconds(); + + return $this->mobileSuccess($payload); + } + + private function renderMockDepositCheckoutHtml( + string $orderNo, + string $amount, + string $bonus, + int $expireAt, + string $confirmUrl + ): string { + $total = bcadd($amount, $bonus, 2); + $expireText = date('Y-m-d H:i:s', $expireAt); + + $orderNoEsc = htmlspecialchars($orderNo, ENT_QUOTES, 'UTF-8'); + $amountEsc = htmlspecialchars($amount, ENT_QUOTES, 'UTF-8'); + $bonusEsc = htmlspecialchars($bonus, ENT_QUOTES, 'UTF-8'); + $totalEsc = htmlspecialchars($total, ENT_QUOTES, 'UTF-8'); + $expireEsc = htmlspecialchars($expireText, ENT_QUOTES, 'UTF-8'); + $orderNoJs = json_encode($orderNo, JSON_UNESCAPED_UNICODE); + $confirmUrlJs = json_encode($confirmUrl, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + return '' + . '模拟充值' + . '

模拟充值收银台

' + . '

订单号:' . $orderNoEsc . '

' + . '

充值金额 ' . $amountEsc . ',赠送 ' . $bonusEsc . '

' + . '
预计到账 ' . $totalEsc . '
' + . '

链接有效期至 ' . $expireEsc . '(约 3 分钟,过期后订单将自动失效)

' + . '' + . '
支付成功(模拟)
订单已提交,需管理员在后台审核通过后才会入账。
' + . '' + . '
'; + } + + private function renderMockDepositMessageHtml(string $title, string $hint): string + { + $titleEsc = htmlspecialchars($title, ENT_QUOTES, 'UTF-8'); + $hintHtml = $hint !== '' ? '

' . htmlspecialchars($hint, ENT_QUOTES, 'UTF-8') . '

' : ''; + + return '' + . '' . $titleEsc . '' + . '' + . '

' . $titleEsc . '

' . $hintHtml . '
'; + } + /** * 将订单模型转换为统一的创建/详情响应数据 * @@ -364,42 +724,72 @@ class Finance extends MobileBase */ private function buildDepositResponse($order, ?string $publicOrigin = null): array { - $status = $this->mapDepositStatus($order->status); + $statusCode = $this->intValue($order->status); + $status = $this->mapDepositStatus($statusCode); $paid = $status === 'paid'; $amount = $this->amountString($order->amount); $bonus = $this->amountString($order->bonus_amount); $total = bcadd($amount, $bonus, 2); $on = is_string($order->order_no) ? $order->order_no : strval($order->order_no); $payUrl = ''; + $expireAt = 0; $payChannel = is_string($order->pay_channel) ? $order->pay_channel : strval($order->pay_channel); - if ($this->intValue($order->status) === 0 && $on !== '') { - if ($payChannel === 'ddpay') { - $snapRaw = $order->pay_account_snapshot ?? null; - $snap = null; - if (is_array($snapRaw)) { - $snap = $snapRaw; - } elseif (is_string($snapRaw) && trim($snapRaw) !== '') { - $decoded = json_decode($snapRaw, true); - if (is_array($decoded)) { - $snap = $decoded; - } - } - if (is_array($snap) && isset($snap['payment_url']) && is_string($snap['payment_url'])) { - $payUrl = trim($snap['payment_url']); - } + $snapRaw = $order->pay_account_snapshot ?? null; + $snap = null; + if (is_array($snapRaw)) { + $snap = $snapRaw; + } elseif (is_string($snapRaw) && trim($snapRaw) !== '') { + $decoded = json_decode($snapRaw, true); + if (is_array($decoded)) { + $snap = $decoded; } } + if (is_array($snap) && isset($snap['payment_url']) && is_string($snap['payment_url'])) { + $payUrl = trim($snap['payment_url']); + } + if (is_array($snap) && isset($snap['expire_at']) && is_numeric($snap['expire_at'])) { + $expireAt = intval($snap['expire_at']); + } + if ($statusCode === 0 && $on !== '' && $payChannel === MockPay::CHANNEL_CODE) { + $origin = $publicOrigin !== null && $publicOrigin !== '' ? $publicOrigin : ''; + $createTime = is_numeric(strval($order->create_time ?? 0)) ? intval(strval($order->create_time)) : time(); + if ($expireAt <= 0) { + $expireAt = $createTime + DepositOrderExpireService::pendingExpireSeconds(); + } + $sign = ''; + if (is_array($snap) && isset($snap['mock_pay_sign']) && is_string($snap['mock_pay_sign'])) { + $sign = trim($snap['mock_pay_sign']); + } + if ($sign === '' || !MockPay::verifyDepositLink($on, $expireAt, $sign)) { + $linkAuth = MockPay::buildDepositLinkAuth($on, $createTime); + $expireAt = $linkAuth['expire_at']; + $sign = $linkAuth['sign']; + } + if ($payUrl === '') { + $payUrl = MockPay::depositPageUrl($on, $origin, $expireAt, $sign, $amount, $bonus); + } + } + if ($expireAt <= 0 && $statusCode === 0 && $payChannel === MockPay::CHANNEL_CODE) { + $createTime = is_numeric(strval($order->create_time ?? 0)) ? intval(strval($order->create_time)) : time(); + $expireAt = $createTime + DepositOrderExpireService::pendingExpireSeconds(); + } + $rejectReason = is_string($order->reject_reason ?? null) ? trim($order->reject_reason) : ''; + return [ - 'order_no' => $on, - 'amount' => $this->amountNumber($amount), - 'bonus_amount' => $this->amountNumber($bonus), - 'total_amount' => $this->amountNumber($total), - 'status' => $status, - 'paid' => $paid, - 'pay_channel' => $payChannel, - 'pay_url' => $payUrl, - 'create_time' => is_numeric(strval($order->create_time)) ? intval(strval($order->create_time)) : 0, - 'pay_time' => is_numeric(strval($order->pay_time)) ? intval(strval($order->pay_time)) : 0, + 'order_no' => $on, + 'amount' => $this->amountNumber($amount), + 'bonus_amount' => $this->amountNumber($bonus), + 'total_amount' => $this->amountNumber($total), + 'status' => $status, + 'paid' => $paid, + 'review_required' => $status === 'pending_review', + 'reject_reason' => $rejectReason !== '' ? $rejectReason : null, + 'pay_channel' => $payChannel, + 'pay_url' => $payUrl, + 'expire_at' => $expireAt > 0 ? $expireAt : null, + 'expire_seconds' => $statusCode === 0 ? DepositOrderExpireService::pendingExpireSeconds() : null, + 'create_time' => is_numeric(strval($order->create_time)) ? intval(strval($order->create_time)) : 0, + 'pay_time' => is_numeric(strval($order->pay_time)) ? intval(strval($order->pay_time)) : 0, ]; } @@ -846,7 +1236,18 @@ class Finance extends MobileBase return $this->mobileError(1001, 'Missing parameters'); } if (!in_array($channelCode, DepositChannelLib::withdrawPayoutChannelCodes(), true)) { - return $this->mobileError(2004, 'Withdraw only supports DDPay'); + return $this->mobileError(2004, 'Pay channel not supported'); + } + if ($channelCode === MockPay::CHANNEL_CODE && !MockPay::isEnabled()) { + return $this->mobileError(2004, 'Mock pay channel is disabled'); + } + if ($channelCode === 'ddpay' && !DDPayGateway::isConfigured()) { + if (!MockPay::isEnabled()) { + return $this->mobileError(2004, 'DDPay is not configured', [ + 'suggest_channel_code' => MockPay::CHANNEL_CODE, + ]); + } + $channelCode = MockPay::CHANNEL_CODE; } $effectiveChannels = $this->loadDepositChannelEffective(); if (!DepositChannelLib::assertChannelEnabled($channelCode, $effectiveChannels)) { @@ -1288,6 +1689,9 @@ class Finance extends MobileBase if ($code === '' || !isset($regCh[$code]) || !is_array($regCh[$code])) { continue; } + if ($code === MockPay::CHANNEL_CODE && !MockPay::isEnabled()) { + continue; + } $meta = $regCh[$code]; $nameZh = isset($meta['name']) && is_string($meta['name']) ? $meta['name'] : ''; $nameEn = isset($meta['name_en']) && is_string($meta['name_en']) ? $meta['name_en'] : ''; @@ -1397,6 +1801,14 @@ class Finance extends MobileBase return trim($raw); } + /** + * DDPay 入金 payment_type:官方枚举 01=FPX / 02=duitnow / 03=ewallet + */ + private function isValidDdpayPaymentType(string $paymentType): bool + { + return in_array($paymentType, ['01', '02', '03'], true); + } + /** * 收款人手机号:5–32 位,仅允许数字与常见分隔符(+ - 空格) */ @@ -1431,10 +1843,14 @@ class Finance extends MobileBase private function mapDepositStatus($status): string { - if ($this->intValue($status) === 1) { + $code = $this->intValue($status); + if ($code === 1) { return 'paid'; } - if ($this->intValue($status) === 2 || $this->intValue($status) === 3) { + if ($code === MockPay::DEPOSIT_STATUS_PENDING_REVIEW) { + return 'pending_review'; + } + if ($code === 2) { return 'failed'; } return 'pending'; @@ -1463,4 +1879,62 @@ class Finance extends MobileBase } return $result; } + + private function intParam($raw): int + { + if ($raw === null || $raw === '') { + return 0; + } + if (is_numeric(strval($raw))) { + return intval(strval($raw)); + } + + return 0; + } + + private function verifyMockDepositLinkSign(string $orderNo, int $expireAt, string $sign): bool + { + return MockPay::verifyDepositLink($orderNo, $expireAt, $sign); + } + + /** + * 生成带签名的前端静态收银台 URL;订单不存在或非 mock 时返回空串 + */ + private function buildMockDepositFrontendUrl(string $orderNo, string $publicOrigin): string + { + DepositOrderExpireService::expirePendingOrders(null, $orderNo); + $row = Db::name('deposit_order')->where('order_no', $orderNo)->find(); + if (!is_array($row)) { + return ''; + } + $payChannel = is_string($row['pay_channel'] ?? null) ? strtolower(trim($row['pay_channel'])) : ''; + if ($payChannel !== MockPay::CHANNEL_CODE) { + return ''; + } + + $createTime = is_numeric($row['create_time'] ?? null) ? intval($row['create_time']) : time(); + $amount = $this->amountString($row['amount'] ?? '0'); + $bonus = $this->amountString($row['bonus_amount'] ?? '0'); + $expireAt = 0; + $sign = ''; + $snapRaw = $row['pay_account_snapshot'] ?? ''; + if (is_string($snapRaw) && trim($snapRaw) !== '') { + $decoded = json_decode($snapRaw, true); + if (is_array($decoded)) { + if (isset($decoded['expire_at']) && is_numeric($decoded['expire_at'])) { + $expireAt = intval($decoded['expire_at']); + } + if (isset($decoded['mock_pay_sign']) && is_string($decoded['mock_pay_sign'])) { + $sign = trim($decoded['mock_pay_sign']); + } + } + } + if ($expireAt <= 0 || $sign === '' || !MockPay::verifyDepositLink($orderNo, $expireAt, $sign)) { + $linkAuth = MockPay::buildDepositLinkAuth($orderNo, $createTime); + $expireAt = $linkAuth['expire_at']; + $sign = $linkAuth['sign']; + } + + return MockPay::depositPageUrl($orderNo, $publicOrigin, $expireAt, $sign, $amount, $bonus); + } } diff --git a/app/api/lang/en.php b/app/api/lang/en.php index cacb29e..df582d0 100644 --- a/app/api/lang/en.php +++ b/app/api/lang/en.php @@ -20,6 +20,8 @@ return [ 'auth-token is invalid or expired' => 'auth-token is invalid or expired', 'Invalid secret' => 'Invalid secret', 'Invalid signature' => 'Invalid signature', + 'Invalid payment link signature' => 'Invalid payment link signature', + 'Missing payment link signature' => 'Missing payment link signature', 'Invalid timestamp' => 'Invalid timestamp', 'Invite code does not exist' => 'Invite code does not exist', 'Register only supports phone' => 'Register only supports phone', @@ -53,6 +55,7 @@ return [ 'Too many pending deposit orders' => 'You already have multiple pending deposit orders, please complete payment first or wait for timeout', 'Too many pending withdraw orders' => 'You already have withdraw orders under review, please wait for them to be processed', 'Missing DDPay parameters' => 'Missing DDPay parameters', + 'Invalid DDPay payment_type' => 'Invalid DDPay payment_type; use 01 (FPX), 02 (duitnow), or 03 (ewallet)', 'DDPay payout integration supports receive_type=bank only' => 'DDPay payout integration currently supports receive_type=bank only', 'Missing DDPay bank payout parameters' => 'Missing DDPay bank payout parameters', 'Bank code not configured for withdrawal' => 'The bank code is not configured for withdrawal', @@ -60,6 +63,11 @@ return [ 'Pay channel not available for this currency' => 'The payment channel is not available for this currency', 'DDPay deposit initiation failed' => 'DDPay deposit initiation failed', 'Deposit only supports DDPay' => 'Only DDPay deposits are supported (channel_code must be ddpay)', + 'Pay channel not supported' => 'Payment channel is not supported', + 'Mock pay channel is disabled' => 'Mock pay channel is disabled', + 'DDPay is not configured' => 'DDPay is not configured; use channel_code=mock or set DDPAY_* in .env', + 'Order is not a mock pay deposit' => 'This order is not a mock-pay deposit', + 'Order cannot be paid' => 'This order cannot be paid in its current status', 'Withdraw only supports DDPay' => 'Only DDPay withdrawals are supported (channel_code must be ddpay)', // Member center account 'Data updated successfully~' => 'Data updated successfully~', diff --git a/app/api/lang/zh-cn.php b/app/api/lang/zh-cn.php index 8e2c1b7..9718829 100644 --- a/app/api/lang/zh-cn.php +++ b/app/api/lang/zh-cn.php @@ -52,6 +52,8 @@ return [ 'auth-token is invalid or expired' => 'auth-token 无效或已过期', 'Invalid secret' => '密钥无效', 'Invalid signature' => '签名错误', + 'Invalid payment link signature' => '支付链接签名校验失败', + 'Missing payment link signature' => '缺少支付链接签名', 'Invalid timestamp' => '时间戳无效', 'Invite code does not exist' => '邀请码不存在', 'Register only supports phone' => '注册仅支持手机号', @@ -85,6 +87,7 @@ return [ 'Too many pending deposit orders' => '存在多笔待支付充值订单,请先完成支付或等待超时', 'Too many pending withdraw orders' => '用户当前存在多笔提现订单,请等待审核', 'Missing DDPay parameters' => '缺少 DDPay 支付参数', + 'Invalid DDPay payment_type' => 'DDPay 支付方式无效,请传 01(FPX)/02(duitnow)/03(ewallet)', 'DDPay payout integration supports receive_type=bank only' => 'DDPay 出金当前仅支持 bank 收款类型', 'Missing DDPay bank payout parameters' => '缺少 DDPay 银行出金参数', 'Bank code not configured for withdrawal' => '提现银行代码未在系统配置中维护', @@ -92,6 +95,11 @@ return [ 'Pay channel not available for this currency' => '当前币种不支持该支付渠道', 'DDPay deposit initiation failed' => 'DDPay 充值发起失败', 'Deposit only supports DDPay' => '仅支持 DDPay 充值(channel_code 须为 ddpay)', + 'Pay channel not supported' => '不支持的支付渠道', + 'Mock pay channel is disabled' => '模拟支付渠道已关闭', + 'DDPay is not configured' => '未配置 DDPay 商户,请使用 channel_code=mock 或配置 DDPAY_*', + 'Order is not a mock pay deposit' => '该订单不是模拟支付充值单', + 'Order cannot be paid' => '订单当前状态不可支付', 'Withdraw only supports DDPay' => '仅支持 DDPay 提现(channel_code 须为 ddpay)', // 会员中心 account 'Data updated successfully~' => '资料更新成功~', diff --git a/app/common/library/finance/DDPayGateway.php b/app/common/library/finance/DDPayGateway.php index ccf6ab7..5d79a83 100644 --- a/app/common/library/finance/DDPayGateway.php +++ b/app/common/library/finance/DDPayGateway.php @@ -48,6 +48,30 @@ final class DDPayGateway return $scheme . '://' . $host; } + /** + * 是否已配置 DDPay 入金所需项(未配置时不应调用三方接口) + */ + public static function isConfigured(): bool + { + $envMap = [ + 'ddpay_client_id' => 'DDPAY_CLIENT_ID', + 'ddpay_identifier' => 'DDPAY_IDENTIFIER', + 'ddpay_api_secret' => 'DDPAY_API_SECRET', + 'ddpay_deposit_init_url' => 'DDPAY_DEPOSIT_INIT_URL', + ]; + foreach ($envMap as $cfgKey => $envKey) { + $v = getenv($envKey); + if (!is_string($v) || trim($v) === '') { + $cfg = config('app.' . $cfgKey, ''); + if (!is_string($cfg) || trim($cfg) === '') { + return false; + } + } + } + + return true; + } + /** * @return array */ diff --git a/app/common/library/finance/DepositSettlement.php b/app/common/library/finance/DepositSettlement.php index 10f73dd..8dedae3 100644 --- a/app/common/library/finance/DepositSettlement.php +++ b/app/common/library/finance/DepositSettlement.php @@ -96,7 +96,7 @@ final class DepositSettlement ]; } - if ($status !== 0) { + if ($status !== 0 && $status !== 3) { throw new RuntimeException('Order status does not allow settlement'); } @@ -139,9 +139,10 @@ final class DepositSettlement Db::startTrans(); try { + $statusBefore = $status === 3 ? 3 : 0; $affected = Db::name('deposit_order') ->where('id', $orderId) - ->where('status', 0) + ->where('status', $statusBefore) ->update([ 'status' => 1, 'pay_time' => $now, diff --git a/app/common/library/finance/MockPay.php b/app/common/library/finance/MockPay.php new file mode 100644 index 0000000..fa31407 --- /dev/null +++ b/app/common/library/finance/MockPay.php @@ -0,0 +1,363 @@ + $expireAt, + + 'sign' => self::signDepositLink($orderNo, $expireAt), + + ]; + + } + + + + public static function signDepositLink(string $orderNo, int $expireAt): string + + { + + $params = [ + + 'expire_at' => strval($expireAt), + + 'order_no' => $orderNo, + + 'secret' => self::linkSecret(), + + ]; + + ksort($params); + + $pairs = []; + + foreach ($params as $key => $value) { + + $pairs[] = $key . '=' . $value; + + } + + + + return strtoupper(md5(implode('&', $pairs))); + + } + + + + public static function verifyDepositLink(string $orderNo, int $expireAt, string $sign): bool + + { + + $signNorm = strtoupper(trim($sign)); + + if ($signNorm === '' || $orderNo === '' || $expireAt <= 0) { + + return false; + + } + + + + return hash_equals(self::signDepositLink($orderNo, $expireAt), $signNorm); + + } + + + + /** + + * 前端静态收银台 URL(优先于服务端内联页) + + * + + * @param string $amountDisplay 2 位小数字符串,供页面展示 + + * @param string $bonusDisplay 2 位小数字符串 + + */ + + public static function depositPageUrl( + + string $orderNo, + + string $publicOrigin, + + int $expireAt, + + string $sign, + + string $amountDisplay = '', + + string $bonusDisplay = '' + + ): string { + + $htmlBase = self::resolveHtmlBase($publicOrigin); + + $apiBase = rtrim($publicOrigin, '/'); + + + + $query = [ + + 'order_no' => $orderNo, + + 'expire_at' => strval($expireAt), + + 'sign' => $sign, + + 'api_base' => $apiBase, + + ]; + + if ($amountDisplay !== '') { + + $query['amount'] = $amountDisplay; + + } + + if ($bonusDisplay !== '') { + + $query['bonus'] = $bonusDisplay; + + } + + + + return $htmlBase . '/mock-deposit.html?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986); + + } + + + + /** + + * 模拟页内确认支付接口(无需 auth-token;须携带 sign + expire_at) + + */ + + public static function depositConfirmUrl(string $orderNo, string $publicOrigin, int $expireAt, string $sign): string + + { + + $base = rtrim($publicOrigin, '/'); + + $query = http_build_query([ + + 'order_no' => $orderNo, + + 'expire_at' => strval($expireAt), + + 'sign' => $sign, + + ], '', '&', PHP_QUERY_RFC3986); + + + + return $base . '/api/finance/mockDepositConfirm?' . $query; + + } + + + + /** + + * 解析前端静态页根地址:MOCK_DEPOSIT_HTML_BASE > DDPAY_PUBLIC_BASE_URL > API 公网根 + + */ + + public static function resolveHtmlBase(string $publicOrigin): string + + { + + $raw = getenv('MOCK_DEPOSIT_HTML_BASE'); + + if (is_string($raw) && trim($raw) !== '') { + + return rtrim(trim($raw), '/'); + + } + + + + $ddpayPublic = getenv('DDPAY_PUBLIC_BASE_URL'); + + if (is_string($ddpayPublic) && trim($ddpayPublic) !== '') { + + return rtrim(trim($ddpayPublic), '/'); + + } + + + + $cfg = config('app.ddpay_public_base_url', ''); + + if (is_string($cfg) && trim($cfg) !== '') { + + return rtrim(trim($cfg), '/'); + + } + + + + return rtrim($publicOrigin, '/'); + + } + + + + private static function linkSecret(): string + + { + + $raw = getenv('FINANCE_MOCK_PAY_LINK_SECRET'); + + if (is_string($raw) && trim($raw) !== '') { + + return trim($raw); + + } + + $auth = getenv('AUTH_TOKEN_SECRET'); + + if (is_string($auth) && trim($auth) !== '') { + + return trim($auth); + + } + + + + return 'mock-deposit-link-dev-secret'; + + } + +} + + diff --git a/app/common/library/game/DepositChannel.php b/app/common/library/game/DepositChannel.php index af9bf7c..1087780 100644 --- a/app/common/library/game/DepositChannel.php +++ b/app/common/library/game/DepositChannel.php @@ -4,13 +4,14 @@ declare(strict_types=1); namespace app\common\library\game; +use app\common\library\finance\MockPay; use InvalidArgumentException; use support\think\Db; /** * 充值支付渠道:优先读取 game_config.finance_cashier.channels;无此键时回退 game_config.deposit_channel(迁移期镜像) * - * 每项:code(须在代码/环境注册表内)、sort、status(0/1)。**代码注册表当前仅内置 `ddpay`**(DDPay 网关)。 + * 每项:code(须在代码/环境注册表内)、sort、status(0/1)。内置 `ddpay`(DDPay)、`mock`(模拟支付,见 FINANCE_MOCK_PAY_ENABLED)。 * * 渠道展示名以代码注册表为准;运营只配置开关、排序与支持币种,默认兼容全部充值档位。 */ @@ -23,9 +24,9 @@ final class DepositChannel */ public static function codeRegistry(): array { - // 仅保留 DDPay:充值/回调只走网关文档约定,不再提供模拟或其它渠道码 $base = [ 'ddpay' => ['name' => 'DDPay', 'name_en' => 'DDPay', 'sort' => 10], + 'mock' => ['name' => '模拟支付', 'name_en' => 'Mock Pay', 'sort' => 5], ]; $extra = self::registryFromEnv(); foreach ($extra as $code => $meta) { @@ -287,6 +288,9 @@ final class DepositChannel if (!isset($registry[$code])) { continue; } + if ($code === MockPay::CHANNEL_CODE && !MockPay::isEnabled()) { + continue; + } if ($fiatCurrencyCode !== '' && !self::isCurrencyAllowedForRow($row, $fiatCurrencyCode)) { continue; } @@ -409,7 +413,12 @@ final class DepositChannel */ public static function withdrawPayoutChannelCodes(): array { - return ['ddpay']; + $codes = ['ddpay']; + if (MockPay::isEnabled()) { + $codes[] = MockPay::CHANNEL_CODE; + } + + return $codes; } /** diff --git a/app/common/model/DepositOrder.php b/app/common/model/DepositOrder.php index 0ab5f62..d62878a 100644 --- a/app/common/model/DepositOrder.php +++ b/app/common/model/DepositOrder.php @@ -27,4 +27,9 @@ class DepositOrder extends Model { return $this->belongsTo(Channel::class, 'channel_id', 'id'); } + + public function reviewAdmin(): \think\model\relation\BelongsTo + { + return $this->belongsTo(\app\admin\model\Admin::class, 'review_admin_id', 'id'); + } } diff --git a/app/common/service/DepositOrderExpireService.php b/app/common/service/DepositOrderExpireService.php index 5c61396..87fd19c 100644 --- a/app/common/service/DepositOrderExpireService.php +++ b/app/common/service/DepositOrderExpireService.php @@ -1,81 +1,127 @@ -where('status', 0) - ->where('create_time', '<=', $expireBefore); - if ($userId !== null && $userId > 0) { - $query->where('user_id', $userId); - } - if ($orderNo !== null && $orderNo !== '') { - $query->where('order_no', $orderNo); - } - $rows = $query->field(['id', 'remark'])->select()->toArray(); - if ($rows === []) { - return 0; - } - $now = time(); - $affectedCount = 0; - foreach ($rows as $row) { - $id = isset($row['id']) && is_numeric($row['id']) ? intval($row['id']) : 0; - if ($id <= 0) { - continue; - } - $oldRemark = isset($row['remark']) && is_string($row['remark']) ? trim($row['remark']) : ''; - $reason = '[timeout] unpaid over ' . self::EXPIRE_SECONDS . 's'; - $remark = $oldRemark === '' ? $reason : mb_substr($oldRemark . ' | ' . $reason, 0, 255); - $affected = Db::name('deposit_order') - ->where('id', $id) - ->where('status', 0) - ->update([ - 'status' => 2, - 'remark' => $remark, - 'update_time' => $now, - ]); - if (is_numeric($affected) && intval($affected) > 0) { - $affectedCount++; - } - } - - return $affectedCount; - } - - public static function pendingCountByUserId(int $userId): int - { - if ($userId <= 0) { - return 0; - } - - return Db::name('deposit_order') - ->where('user_id', $userId) - ->where('status', 0) - ->count(); - } -} - + 0) { + return $v; + } + } + + return 60; + } + + /** + * @param array $order + */ + public static function expireSecondsForOrder(array $order): int + { + return self::pendingExpireSeconds(); + } + + /** + * @param int|null $userId 仅处理某用户;null 表示不过滤用户 + * @param string|null $orderNo 仅处理某订单;null 表示不过滤订单 + * + * @return int 本次转失败的订单数 + */ + public static function expirePendingOrders(?int $userId = null, ?string $orderNo = null): int + { + $query = Db::name('deposit_order')->where('status', 0); + if ($userId !== null && $userId > 0) { + $query->where('user_id', $userId); + } + if ($orderNo !== null && $orderNo !== '') { + $query->where('order_no', $orderNo); + } + $rows = $query->field(['id', 'remark', 'pay_channel', 'create_time'])->select()->toArray(); + if ($rows === []) { + return 0; + } + $now = time(); + $expireSec = self::pendingExpireSeconds(); + $affectedCount = 0; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $id = isset($row['id']) && is_numeric($row['id']) ? intval($row['id']) : 0; + if ($id <= 0) { + continue; + } + $createTime = isset($row['create_time']) && is_numeric($row['create_time']) ? intval($row['create_time']) : 0; + if ($createTime > 0 && $createTime + $expireSec > $now) { + continue; + } + $oldRemark = isset($row['remark']) && is_string($row['remark']) ? trim($row['remark']) : ''; + $reason = '[timeout] unpaid over ' . $expireSec . 's'; + $remark = $oldRemark === '' ? $reason : mb_substr($oldRemark . ' | ' . $reason, 0, 255); + $affected = Db::name('deposit_order') + ->where('id', $id) + ->where('status', 0) + ->update([ + 'status' => 2, + 'remark' => $remark, + 'update_time' => $now, + ]); + if (is_numeric($affected) && intval($affected) > 0) { + $affectedCount++; + } + } + + return $affectedCount; + } + + public static function pendingCountByUserId(int $userId): int + { + if ($userId <= 0) { + return 0; + } + + return Db::name('deposit_order') + ->where('user_id', $userId) + ->where('status', 0) + ->count(); + } + + /** + * 订单是否仍在待支付有效期内 + * + * @param array $order + */ + public static function isPendingPaymentValid(array $order): bool + { + $status = isset($order['status']) && is_numeric($order['status']) ? intval($order['status']) : -1; + if ($status !== 0) { + return false; + } + $createTime = isset($order['create_time']) && is_numeric($order['create_time']) ? intval($order['create_time']) : 0; + if ($createTime <= 0) { + return false; + } + + return $createTime + self::pendingExpireSeconds() > time(); + } +} + \ No newline at end of file diff --git a/config/app.php b/config/app.php index 1c1d85f..dcce090 100644 --- a/config/app.php +++ b/config/app.php @@ -35,6 +35,39 @@ return [ 'ddpay_payout_init_url' => is_string(getenv('DDPAY_PAYOUT_INIT_URL')) ? trim(getenv('DDPAY_PAYOUT_INIT_URL')) : '', 'ddpay_payout_status_url' => is_string(getenv('DDPAY_PAYOUT_STATUS_URL')) ? trim(getenv('DDPAY_PAYOUT_STATUS_URL')) : '', + // 模拟支付(channel_code=mock):未接入 DDPay 时用于联调;FINANCE_MOCK_PAY_ENABLED=0 可关闭 + 'finance_mock_pay_enabled' => (static function (): bool { + $raw = getenv('FINANCE_MOCK_PAY_ENABLED'); + if (is_string($raw) && $raw !== '') { + $norm = strtolower(trim($raw)); + if (in_array($norm, ['0', 'false', 'no', 'off'], true)) { + return false; + } + if (in_array($norm, ['1', 'true', 'yes', 'on'], true)) { + return true; + } + } + $debugRaw = getenv('APP_DEBUG'); + if ($debugRaw === false || $debugRaw === '') { + return true; + } + + return in_array(strtolower(trim((string) $debugRaw)), ['1', 'true', 'yes', 'on'], true); + })(), + + /** 充值待支付订单有效秒数(超时未支付自动失败;支付链接倒计时与此一致) */ + 'deposit_pending_expire_seconds' => (static function (): int { + $raw = getenv('DEPOSIT_PENDING_EXPIRE_SECONDS'); + if (is_string($raw) && trim($raw) !== '') { + $v = filter_var(trim($raw), FILTER_VALIDATE_INT); + if ($v !== false && $v > 0) { + return $v; + } + } + + return 60; + })(), + 'debug' => true, 'error_reporting' => E_ALL, 'default_timezone' => 'Asia/Shanghai', diff --git a/config/route.php b/config/route.php index 7973681..5059713 100644 --- a/config/route.php +++ b/config/route.php @@ -137,6 +137,10 @@ Route::add(['GET', 'POST'], '/api/wallet/recordList', [\app\api\controller\Walle 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::add(['GET', 'POST'], '/api/finance/mockDepositPage', [\app\api\controller\Finance::class, 'mockDepositPage']); +Route::add(['GET', 'POST'], '/api/finance/mockDepositStatus', [\app\api\controller\Finance::class, 'mockDepositStatus']); +Route::add(['GET', 'POST'], '/api/finance/mockDepositConfirm', [\app\api\controller\Finance::class, 'mockDepositConfirm']); +Route::add(['GET', 'POST'], '/api/finance/mockDepositPay', [\app\api\controller\Finance::class, 'mockDepositPay']); Route::post('/api/finance/ddpayDepositNotify', [\app\api\controller\Finance::class, 'ddpayDepositNotify']); Route::add(['GET', 'POST'], '/api/finance/ddpayDepositRedirect', [\app\api\controller\Finance::class, 'ddpayDepositRedirect']); Route::post('/api/finance/ddpayPayoutNotify', [\app\api\controller\Finance::class, 'ddpayPayoutNotify']); diff --git a/docs/36字花-移动端接口设计草案.md b/docs/36字花-移动端接口设计草案.md index c972718..b307f84 100644 --- a/docs/36字花-移动端接口设计草案.md +++ b/docs/36字花-移动端接口设计草案.md @@ -452,19 +452,25 @@ - `Content-Type: application/json`(推荐)、`application/x-www-form-urlencoded` 或 **`multipart/form-data`**(如 Apifox 的 form-data);字段名与下表一致即可,服务端通过统一参数名读取,**不限制**为某一种 body 类型。 说明: -- **仅支持 DDPay**:`channel_code` 必须为 **`ddpay`**,否则返回 `code=2004`。 -- `depositCreate` 先创建 `status=pending` 的充值单,再调用 DDPay「入金发起」;若成功返回 `payment_url`,则 `pay_url=payment_url`。 -- 入账由 **`POST /api/finance/ddpayDepositNotify`** 验签后结算,或网关同步返回 `transaction_status=completed` 时结算。 -- 档位与渠道取自 `depositTierList`:`channels` 中仅会出现 `ddpay`(与后台「支付/收款配置」一致)。 -- 同一用户最多 3 笔待支付充值单;创建后 60 秒未支付会超时失败。 +- **模拟支付(推荐联调)**:`channel_code=mock`,无需 DDPay 商户配置;创建后返回 `pay_url`(**模拟收银台页面** `GET /api/finance/mockDepositPage`,浏览器打开;**链接有效期 3 分钟**,过期未支付则订单自动失败)。用户在收银台点击「确认支付」后,订单进入 **`pending_review`(待后台审核)**,不会立即入账;管理员在后台「充值订单」审核通过后才入账。 +- 可选 **`GET/POST /api/finance/mockDepositPay`**(需登录,与创建订单同一 `auth-token`):用于 App 内二次确认(与收银台确认二选一,幂等)。 +- **DDPay**:`channel_code=ddpay`;先创建待支付单,再调用 DDPay「入金发起」;若成功返回 `payment_url`,则 `pay_url=payment_url`。 +- 开关:`FINANCE_MOCK_PAY_ENABLED`(默认开发环境开启);关闭后 `mock` 渠道不可用。 +- 入账由 **`POST /api/finance/ddpayDepositNotify`** 验签后结算,或网关同步返回 `transaction_status=completed` 时结算;**`mock` 渠道**在用户确认支付后需 **后台审核通过** 才调用入账结算。 +- 档位与渠道取自 `depositTierList`:`channels` 中会出现后台已启用的渠道(包含 `mock` / `ddpay` 等)。 +- 同一用户最多 3 笔待支付充值单;**待支付**订单创建后,超过 `DEPOSIT_PENDING_EXPIRE_SECONDS`(默认 **60 秒**,见 `.env`)未支付会自动失败;支付链接倒计时与此一致。 请求参数: **A. 通用必填参数** - `tier_id`:string,必填(充值档位 ID;同义字段:`tier_key`) -- `channel_code`:string,必填,固定传 **`ddpay`** +- `channel_code`:string,必填,`mock`(模拟)或 `ddpay`(真实网关) - `idempotency_key`:string,必填,≤64(客户端幂等键,建议 UUID) +**A.1 模拟支付(`channel_code=mock`)** + +- 无需 `payment_type` / `payer_name` / `payer_bank_name` + **B. DDPay 渠道必填参数(`channel_code=ddpay`)** > **依据**:DDPay 官方《Payment Gateway》接口说明(与仓库内 `docs/DDPay Payment Gateway_v1.1.3_zh.md` / `docs/DDPay Payment Gateway_v1.1.3.pdf` 一致;以下「官方」均指该文档)。 @@ -507,9 +513,13 @@ - `bonus_amount`:string(2 位小数,含义:本次赠送金额,与所选档位 `bonus_amount` 一致,无赠送为 `0.00`) - `total_amount`:string(2 位小数,含义:实际入账总额 = amount + bonus_amount) - `pay_channel`:string(含义:支付通道标识,与请求中选择的 `channel_code` 一致,落库在订单上) -- `paid`:bool(含义:当前单据是否已到账;`true` 表示钱包已入账、`status=paid`;`false` 表示待玩家在第三方支付页面完成支付) -- `pay_url`:string(含义:DDPay 返回的收银台地址;**`paid=false`(待支付)** 时为三方 **`payment_url`(完整 URL)**;`paid=true` 时为空串) -- `status`:string(`pending`/`paid`/`failed`,含义:本接口创建成功时为 `pending`,入账完成后为 `paid`) +- `paid`:bool(含义:当前单据是否已到账;`true` 表示钱包已入账、`status=paid`;`false` 表示尚未入账;**`mock` 待审核**也为 `false`) +- `pay_url`:string(含义:收银台地址;**`pending`(待支付)** 时:`ddpay` 为三方 **`payment_url`(完整 URL)**;**`mock` 为模拟收银台**(`GET /api/finance/mockDepositPage`);**`paid=true` 或已进入待审核**时可能为空串) +- `review_required`:bool(含义:是否需要后台审核入账;**仅 `mock` 在用户确认支付后**为 `true`) +- `reject_reason`:string 或 null(含义:审核驳回原因;仅失败且存在驳回原因时有值) +- `expire_at`:int 或 null(含义:`pay_url` 过期时间戳(秒);主要用于 `mock` 待支付) +- `expire_seconds`:int 或 null(含义:与 `expire_at` 配套,待支付时返回,值同 `DEPOSIT_PENDING_EXPIRE_SECONDS`) +- `status`:string(`pending` / `pending_review` / `paid` / `failed`,含义:`pending`=待支付;`pending_review`(仅 mock)=用户已确认,等待后台审核;`paid`=已入账;`failed`=失败/超时/驳回) - `create_time`:int(含义:订单创建时间,秒级时间戳) - `pay_time`:int(含义:订单到账时间,未到账为 0) @@ -539,9 +549,17 @@ - `1002`:`idempotency_key` 过长,或与其他玩家的订单冲突 - `2000`:**通用**:订单落库或入账失败(事务回滚等,可能带原始错误描述);**DDPay**:调用「入金发起」失败(网络/HTTP/JSON/验签/`status_code≠0` 等),对外文案为「DDPay 充值发起失败」,**具体原因可查看该笔 `deposit_order.remark`(前缀 `[ddpay]`)或服务端日志** - `2003`:所选 `tier_id` 不存在、已停用或不在启用列表中 -- `2004`:`channel_code` 非 `ddpay`;或 `ddpay` 未启用;或当前档位币种与该渠道不允许的组合 +- `2004`:不支持的 `channel_code`;`mock` 已关闭;或 `ddpay` 未启用;或当前档位币种与该渠道不允许的组合 - `2005`:待支付充值单超过上限(`data.max_pending`、`data.pending_count`、`data.expire_seconds`) +### 5.4A 模拟支付(浏览器收银台 + 可选 App 确认) + +- **GET/POST** `/api/finance/mockDepositPage`:模拟收银台 HTML 页面(**无需登录**;`order_no` 必填;仅 `pay_channel=mock` 且 `status=pending` 且未过期可展示支付按钮) +- **GET/POST** `/api/finance/mockDepositConfirm`:收银台内确认支付(**无需登录**;将订单标记为 **`pending_review`**,不入账) +- **GET/POST** `/api/finance/mockDepositPay`:App 内确认(**需登录**;与上一接口二选一、幂等) + +说明:后台管理员在 **「充值订单」** 对待审核单执行审核通过/驳回后,`mock` 订单才会最终成功入账或失败(未接入真实商户前的模拟流程)。 + ### 5.5 查看充值订单详情 - **POST** `/api/finance/depositDetail` @@ -555,8 +573,12 @@ - `total_amount`:string(2 位小数,含义:入账总额) - `pay_channel`:string(含义:支付通道标识) - `paid`:bool(含义:是否已到账) -- `pay_url`:string(含义:第三方支付页面地址,已到账为空串) -- `status`:string(`pending`/`paid`/`failed`) +- `pay_url`:string(含义:收银台地址;已到账或无需再支付时可能为空串) +- `review_required`:bool(含义:是否等待后台审核入账) +- `reject_reason`:string 或 null(含义:驳回原因) +- `expire_at`:int 或 null(含义:`pay_url` 过期时间戳(秒)) +- `expire_seconds`:int 或 null(含义:与 `expire_at` 配套) +- `status`:string(`pending` / `pending_review` / `paid` / `failed`) - `create_time`:int(含义:订单创建时间) - `pay_time`:int(含义:订单到账时间,未到账为 0) @@ -574,7 +596,7 @@ - `order_no`:string(含义:充值订单号) - `amount`:string(2 位小数,含义:本单充值金额) - `bonus_amount`:string(2 位小数,含义:本单赠送金额,无赠送为 `0.00`) - - `status`:string(含义:订单状态,与 `depositDetail` 一致:`pending`/`paid`/`failed`) + - `status`:string(含义:订单状态,与 `depositDetail` 一致:`pending` / `pending_review` / `paid` / `failed`) - `pagination`:object(含义:分页信息) - `page`:int(含义:当前页码) - `page_size`:int(含义:每页数量) diff --git a/public/mock-deposit.html b/public/mock-deposit.html new file mode 100644 index 0000000..35f1c60 --- /dev/null +++ b/public/mock-deposit.html @@ -0,0 +1,204 @@ + + + + + + 模拟充值收银台 + + + +
+

模拟充值收银台

+

订单号:-

+ +

正在加载…

+

+ +
支付成功(模拟)
订单已提交,需管理员在后台审核通过后才会入账。
+
+
+ + + diff --git a/web/public/mock-deposit.html b/web/public/mock-deposit.html new file mode 100644 index 0000000..35f1c60 --- /dev/null +++ b/web/public/mock-deposit.html @@ -0,0 +1,204 @@ + + + + + + 模拟充值收银台 + + + +
+

模拟充值收银台

+

订单号:-

+ +

正在加载…

+

+ +
支付成功(模拟)
订单已提交,需管理员在后台审核通过后才会入账。
+
+
+ + + diff --git a/web/src/lang/backend/en/order/depositOrder.ts b/web/src/lang/backend/en/order/depositOrder.ts index af30c6d..0c9594c 100644 --- a/web/src/lang/backend/en/order/depositOrder.ts +++ b/web/src/lang/backend/en/order/depositOrder.ts @@ -12,7 +12,7 @@ export default { 'status 0': 'Pending', 'status 1': 'Success', 'status 2': 'Failed', - 'status 3': 'Canceled', + 'status 3': 'Pending review', pay_channel: 'Pay channel', pay_time: 'Pay time', deposit_tier_id: 'Deposit tier', @@ -23,4 +23,18 @@ export default { channel_name: 'Channel', detail_title: 'Deposit Order Detail', close_btn: 'Close', + reject_reason: 'Reject reason', + review_admin_username: 'Reviewer', + review_time: 'Review time', + review_title: 'Deposit review', + review_reject_title: 'Reject deposit', + review_btn: 'Review', + review_btn_approve: 'Approve', + review_btn_reject: 'Reject', + review_btn_back: 'Back', + review_btn_confirm_reject: 'Confirm reject', + review_reject_tip: 'After rejection, the order is marked failed and no funds will be credited.', + review_reject_placeholder: 'Enter reject reason', + reject_reason_required: 'Please enter reject reason', + already_reviewed: 'This order has already been reviewed', } diff --git a/web/src/lang/backend/en/order/withdrawOrder.ts b/web/src/lang/backend/en/order/withdrawOrder.ts index 67a839d..7a3b578 100644 --- a/web/src/lang/backend/en/order/withdrawOrder.ts +++ b/web/src/lang/backend/en/order/withdrawOrder.ts @@ -28,6 +28,7 @@ export default { channel_name: 'Channel', review_admin_username: 'Reviewer', review_title: 'Withdraw review', + review_btn: 'Review', review_reject_title: 'Reject withdraw', review_btn_approve: 'Approve', review_btn_reject: 'Reject', diff --git a/web/src/lang/backend/zh-cn/config/financeCashierConfig.ts b/web/src/lang/backend/zh-cn/config/financeCashierConfig.ts index f0a2c06..a690b97 100644 --- a/web/src/lang/backend/zh-cn/config/financeCashierConfig.ts +++ b/web/src/lang/backend/zh-cn/config/financeCashierConfig.ts @@ -39,7 +39,7 @@ export default { ddpay_spec_intro: '当前提现走 DDPay 出金(Payout),移动端调用 withdrawCreate;充值渠道为 ddpay 时调用 depositCreate。下列为字段约定摘要,详细以仓库内 DDPay 文档与《36字花-移动端接口设计草案》为准。', ddpay_spec_li_withdraw: - '提现必填:channel_code=ddpay(支付渠道)、withdraw_coin、receive_type=bank、receive_account(收款账号)、receiver_name(与银行登记一致)、receiver_email、receiver_mobile、bank_code(须与本页「提现支持银行(按币种)」中 code 一致)、idempotency_key;bank_branch 选填,不传则服务端按 N/A 提交。', + '充值联调可用 channel_code=mock(模拟支付,无需 DDPay 配置);生产请用 ddpay。提现必填:channel_code=ddpay 或 mock(模拟)、withdraw_coin、receive_type=bank、receive_account、receiver_name、receiver_email、receiver_mobile、bank_code、idempotency_key;bank_branch 选填。', ddpay_spec_li_bank_table: '「银行名(英文)」将映射为 DDPay 的 bank[name],请与 DDPay 官方银行全称列表一致,否则出金可能被拒。', ddpay_spec_li_deposit: diff --git a/web/src/lang/backend/zh-cn/order/depositOrder.ts b/web/src/lang/backend/zh-cn/order/depositOrder.ts index cefc125..5e7372e 100644 --- a/web/src/lang/backend/zh-cn/order/depositOrder.ts +++ b/web/src/lang/backend/zh-cn/order/depositOrder.ts @@ -14,7 +14,7 @@ export default { 'status 0': '待支付', 'status 1': '成功', 'status 2': '失败', - 'status 3': '已取消', + 'status 3': '待审核', pay_channel: '支付通道', pay_time: '支付时间', deposit_tier_id: '充值档位', @@ -23,4 +23,18 @@ export default { update_time: '更新时间', detail_title: '充值订单详情', close_btn: '关闭', + reject_reason: '驳回原因', + review_admin_username: '审核人', + review_time: '审核时间', + review_title: '充值审核', + review_reject_title: '拒绝充值', + review_btn: '审核', + review_btn_approve: '通过', + review_btn_reject: '拒绝', + review_btn_back: '返回', + review_btn_confirm_reject: '确认拒绝', + review_reject_tip: '拒绝后订单将标记为失败,资金不会入账。', + review_reject_placeholder: '请输入驳回原因', + reject_reason_required: '请输入驳回原因', + already_reviewed: '该订单已审核,无需重复操作', } diff --git a/web/src/lang/backend/zh-cn/order/withdrawOrder.ts b/web/src/lang/backend/zh-cn/order/withdrawOrder.ts index 9510414..9c9363c 100644 --- a/web/src/lang/backend/zh-cn/order/withdrawOrder.ts +++ b/web/src/lang/backend/zh-cn/order/withdrawOrder.ts @@ -28,6 +28,7 @@ export default { channel_name: '渠道', review_admin_username: '审核人', review_title: '提现审核', + review_btn: '审核', review_reject_title: '提现拒绝', review_btn_approve: '通过', review_btn_reject: '拒绝', diff --git a/web/src/views/backend/order/depositOrder/index.vue b/web/src/views/backend/order/depositOrder/index.vue index 31921f4..9cc0a7d 100644 --- a/web/src/views/backend/order/depositOrder/index.vue +++ b/web/src/views/backend/order/depositOrder/index.vue @@ -30,6 +30,22 @@ defineOptions({ const { t } = useI18n() const tableRef = useTemplateRef('tableRef') const optButtons: OptButton[] = defaultOptButtons(['edit']) +const depositEditBtn = optButtons.find((b) => b.name === 'edit') +if (depositEditBtn) { + depositEditBtn.display = (row: TableRow) => Number(row.status) !== 3 +} +optButtons.push({ + render: 'tipButton', + name: 'depositReview', + title: 'order.depositOrder.review_btn', + text: '', + type: 'warning', + icon: 'fa fa-check-square-o', + display: (row: TableRow) => Number(row.status) === 3, + click: (row: TableRow) => { + baTable.toggleForm('Edit', [row[baTable.table.pk!]]) + }, +}) function formatAmount(_row: anyObj, _column: any, cellValue: unknown) { if (cellValue === null || cellValue === undefined || cellValue === '') { @@ -186,7 +202,7 @@ const baTable = new baTableClass( { label: t('Operate'), align: 'center', - width: 90, + width: 120, render: 'buttons', buttons: optButtons, operator: false, diff --git a/web/src/views/backend/order/depositOrder/popupForm.vue b/web/src/views/backend/order/depositOrder/popupForm.vue index 875fcde..a025dd4 100644 --- a/web/src/views/backend/order/depositOrder/popupForm.vue +++ b/web/src/views/backend/order/depositOrder/popupForm.vue @@ -1,6 +1,6 @@ diff --git a/web/src/views/backend/order/withdrawOrder/index.vue b/web/src/views/backend/order/withdrawOrder/index.vue index 36c42f2..1226e89 100644 --- a/web/src/views/backend/order/withdrawOrder/index.vue +++ b/web/src/views/backend/order/withdrawOrder/index.vue @@ -30,6 +30,22 @@ defineOptions({ const { t } = useI18n() const tableRef = useTemplateRef('tableRef') const optButtons: OptButton[] = defaultOptButtons(['edit']) +const editBtn = optButtons.find((b) => b.name === 'edit') +if (editBtn) { + editBtn.display = (row: TableRow) => Number(row.status) !== 0 +} +optButtons.push({ + render: 'tipButton', + name: 'review', + title: 'order.withdrawOrder.review_btn', + text: '', + type: 'warning', + icon: 'fa fa-check-square-o', + display: (row: TableRow) => Number(row.status) === 0, + click: (row: TableRow) => { + baTable.toggleForm('Edit', [row[baTable.table.pk!]]) + }, +}) function formatAmount(_row: anyObj, _column: any, cellValue: unknown) { if (cellValue === null || cellValue === undefined || cellValue === '') { @@ -151,9 +167,9 @@ const baTable = new baTableClass( effect: 'dark', custom: { '0': 'info', - '1': 'warning', - '2': 'success', - '3': 'danger', + '1': 'success', + '2': 'danger', + '3': 'success', }, replaceValue: { '0': t('order.withdrawOrder.status 0'), @@ -213,7 +229,7 @@ const baTable = new baTableClass( width: 170, timeFormat: 'yyyy-mm-dd hh:MM:ss', }, - { label: t('Operate'), align: 'center', width: 90, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' }, + { label: t('Operate'), align: 'center', width: 120, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' }, ], }, {