0.使用模拟数据进行充值和提现
1.优化提现接口/api/finance/withdrawCreate 2.优化充值接口/api/finance/depositCreate
This commit is contained in:
14
.env-example
14
.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 =
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, mixed>}
|
||||
*/
|
||||
private function confirmMockDepositPendingPayment(string $orderNo, string $sourceLabel): array
|
||||
{
|
||||
DepositOrderExpireService::expirePendingOrders(null, $orderNo);
|
||||
$row = Db::name('deposit_order')->where('order_no', $orderNo)->find();
|
||||
if (!is_array($row)) {
|
||||
return [
|
||||
'error' => $this->mobileError(2003, 'Order does not exist'),
|
||||
'payload' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$payChannel = is_string($row['pay_channel'] ?? null) ? strtolower(trim($row['pay_channel'])) : '';
|
||||
if ($payChannel !== MockPay::CHANNEL_CODE) {
|
||||
return [
|
||||
'error' => $this->mobileError(2000, 'Order is not a mock pay deposit'),
|
||||
'payload' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$status = is_numeric($row['status'] ?? null) ? intval($row['status']) : -1;
|
||||
if ($status === 1) {
|
||||
$order = DepositOrder::where('order_no', $orderNo)->find();
|
||||
$payload = $this->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 '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>'
|
||||
. '<title>模拟充值</title><style>'
|
||||
. 'body{font-family:system-ui,sans-serif;background:#f5f7fa;margin:0;padding:24px;}'
|
||||
. '.card{max-width:420px;margin:40px auto;background:#fff;border-radius:12px;padding:24px;box-shadow:0 4px 24px rgba(0,0,0,.08);}'
|
||||
. 'h1{font-size:20px;margin:0 0 8px;}p{color:#666;line-height:1.6;margin:8px 0;}'
|
||||
. '.amt{font-size:28px;color:#1677ff;font-weight:700;margin:16px 0;}'
|
||||
. 'button{width:100%;padding:14px;font-size:16px;border:0;border-radius:8px;background:#1677ff;color:#fff;cursor:pointer;margin-top:12px;}'
|
||||
. 'button:disabled{background:#ccc;}'
|
||||
. '.hint{font-size:13px;color:#999;}'
|
||||
. '.ok{display:none;margin-top:16px;padding:12px;background:#f6ffed;border:1px solid #b7eb8f;border-radius:8px;color:#389e0d;line-height:1.6;}'
|
||||
. '</style></head><body>'
|
||||
. '<div class="card"><h1>模拟充值收银台</h1>'
|
||||
. '<p class="hint">订单号:' . $orderNoEsc . '</p>'
|
||||
. '<p>充值金额 <strong>' . $amountEsc . '</strong>,赠送 <strong>' . $bonusEsc . '</strong></p>'
|
||||
. '<div class="amt">预计到账 ' . $totalEsc . '</div>'
|
||||
. '<p class="hint">链接有效期至 ' . $expireEsc . '(约 3 分钟,过期后订单将自动失效)</p>'
|
||||
. '<button type="button" id="btnPay">确认支付</button>'
|
||||
. '<div class="ok" id="okBox"><strong>支付成功(模拟)</strong><br/>订单已提交,需管理员在后台审核通过后才会入账。</div>'
|
||||
. '<script>(function(){var confirmUrl=' . $confirmUrlJs . ',orderNo=' . $orderNoJs . ';'
|
||||
. 'document.getElementById("btnPay").onclick=function(){var btn=this;btn.disabled=true;btn.textContent="处理中...";'
|
||||
. 'fetch(confirmUrl,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},'
|
||||
. 'body:"order_no="+encodeURIComponent(orderNo)}).then(function(r){return r.json();}).then(function(res){'
|
||||
. 'if(res&&res.code===1){document.getElementById("okBox").style.display="block";btn.style.display="none";}'
|
||||
. 'else{alert((res&&res.message)||"支付失败");btn.disabled=false;btn.textContent="确认支付";}})'
|
||||
. '.catch(function(){alert("网络错误,请重试");btn.disabled=false;btn.textContent="确认支付";});};})();</script>'
|
||||
. '</div></body></html>';
|
||||
}
|
||||
|
||||
private function renderMockDepositMessageHtml(string $title, string $hint): string
|
||||
{
|
||||
$titleEsc = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
|
||||
$hintHtml = $hint !== '' ? '<p>' . htmlspecialchars($hint, ENT_QUOTES, 'UTF-8') . '</p>' : '';
|
||||
|
||||
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>'
|
||||
. '<title>' . $titleEsc . '</title>'
|
||||
. '<style>body{font-family:system-ui,sans-serif;background:#f5f7fa;padding:40px;text-align:center;}'
|
||||
. '.card{max-width:400px;margin:0 auto;background:#fff;padding:32px;border-radius:12px;}</style></head><body>'
|
||||
. '<div class="card"><h1>' . $titleEsc . '</h1>' . $hintHtml . '</div></body></html>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 将订单模型转换为统一的创建/详情响应数据
|
||||
*
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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~',
|
||||
|
||||
@@ -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~' => '资料更新成功~',
|
||||
|
||||
@@ -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<string, mixed>
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
363
app/common/library/finance/MockPay.php
Normal file
363
app/common/library/finance/MockPay.php
Normal file
@@ -0,0 +1,363 @@
|
||||
<?php
|
||||
|
||||
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
|
||||
namespace app\common\library\finance;
|
||||
|
||||
use app\common\service\DepositOrderExpireService;
|
||||
|
||||
/**
|
||||
|
||||
* 模拟支付(无真实商户网关):用于开发/联调充值与提现审核。
|
||||
|
||||
*/
|
||||
|
||||
final class MockPay
|
||||
|
||||
{
|
||||
|
||||
public const CHANNEL_CODE = 'mock';
|
||||
|
||||
|
||||
|
||||
/** 待审核(用户已在模拟页确认支付,等待后台审核) */
|
||||
|
||||
public const DEPOSIT_STATUS_PENDING_REVIEW = 3;
|
||||
|
||||
|
||||
|
||||
public static function isEnabled(): bool
|
||||
|
||||
{
|
||||
|
||||
$raw = getenv('FINANCE_MOCK_PAY_ENABLED');
|
||||
|
||||
if (is_string($raw) && trim($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;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
$cfg = config('app.finance_mock_pay_enabled', null);
|
||||
|
||||
if ($cfg === true) {
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
if ($cfg === false) {
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
$debugRaw = getenv('APP_DEBUG');
|
||||
|
||||
if (is_string($debugRaw) && trim($debugRaw) !== '') {
|
||||
|
||||
return in_array(strtolower(trim($debugRaw)), ['1', 'true', 'yes', 'on'], true);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 提现审核后是否走模拟出金(不调用 DDPay)。
|
||||
* - pay_channel=mock:始终模拟;
|
||||
* - FINANCE_MOCK_PAY_ENABLED 开启:ddpay/空 一律模拟(审核通过即成功);
|
||||
* - 未开启 mock 且未配置 DDPay:ddpay/空 也模拟,避免误调网关。
|
||||
*/
|
||||
public static function shouldSimulateWithdrawPayout(string $payChannel): bool
|
||||
{
|
||||
$ch = strtolower(trim($payChannel));
|
||||
if ($ch === self::CHANNEL_CODE) {
|
||||
return true;
|
||||
}
|
||||
if ($ch !== '' && $ch !== 'ddpay') {
|
||||
return false;
|
||||
}
|
||||
if (self::isEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !DDPayGateway::isConfigured();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
* 计算链接过期时间与签名(防猜单号)
|
||||
|
||||
*
|
||||
|
||||
* @return array{expire_at: int, sign: string}
|
||||
|
||||
*/
|
||||
|
||||
public static function buildDepositLinkAuth(string $orderNo, int $createTime): array
|
||||
|
||||
{
|
||||
|
||||
$expireAt = $createTime + DepositOrderExpireService::pendingExpireSeconds();
|
||||
|
||||
|
||||
|
||||
return [
|
||||
|
||||
'expire_at' => $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';
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,81 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use support\think\Db;
|
||||
|
||||
/**
|
||||
* 充值待支付订单超时处理:
|
||||
* - 同用户最多允许 3 笔待支付订单
|
||||
* - 待支付订单创建后 60 秒未支付,自动标记为失败并写失败原因
|
||||
*/
|
||||
final class DepositOrderExpireService
|
||||
{
|
||||
public const MAX_PENDING_DEPOSIT = 3;
|
||||
public const EXPIRE_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* 超时失效处理。
|
||||
*
|
||||
* @param int|null $userId 仅处理某用户;null 表示不过滤用户
|
||||
* @param string|null $orderNo 仅处理某订单;null 表示不过滤订单
|
||||
*
|
||||
* @return int 本次转失败的订单数
|
||||
*/
|
||||
public static function expirePendingOrders(?int $userId = null, ?string $orderNo = null): int
|
||||
{
|
||||
$expireBefore = time() - self::EXPIRE_SECONDS;
|
||||
$query = Db::name('deposit_order')
|
||||
->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();
|
||||
}
|
||||
}
|
||||
|
||||
<?php
|
||||
|
||||
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
|
||||
|
||||
use support\think\Db;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
* 充值待支付订单超时处理:
|
||||
|
||||
* - 同用户最多允许 3 笔待支付订单
|
||||
|
||||
* - 待支付订单创建后超过有效期未支付,自动标记为失败
|
||||
|
||||
* - 有效秒数由 .env DEPOSIT_PENDING_EXPIRE_SECONDS 配置(默认 60),全渠道统一
|
||||
|
||||
*/
|
||||
|
||||
final class DepositOrderExpireService
|
||||
|
||||
{
|
||||
|
||||
public const MAX_PENDING_DEPOSIT = 3;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
* 待支付充值单有效秒数(与 config app.deposit_pending_expire_seconds / .env 一致)
|
||||
|
||||
*/
|
||||
|
||||
public static function pendingExpireSeconds(): int
|
||||
|
||||
{
|
||||
|
||||
$cfg = config('app.deposit_pending_expire_seconds', 60);
|
||||
|
||||
if (is_numeric($cfg)) {
|
||||
|
||||
$v = intval($cfg);
|
||||
|
||||
if ($v > 0) {
|
||||
|
||||
return $v;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return 60;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
* @param array<string, mixed> $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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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(含义:每页数量)
|
||||
|
||||
204
public/mock-deposit.html
Normal file
204
public/mock-deposit.html
Normal file
@@ -0,0 +1,204 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>模拟充值收银台</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background: #f5f7fa; margin: 0; padding: 24px; }
|
||||
.card { max-width: 420px; margin: 40px auto; background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 4px 24px rgba(0,0,0,.08); }
|
||||
h1 { font-size: 20px; margin: 0 0 8px; }
|
||||
p { color: #666; line-height: 1.6; margin: 8px 0; }
|
||||
.amt { font-size: 28px; color: #1677ff; font-weight: 700; margin: 16px 0; }
|
||||
.countdown { font-size: 15px; color: #fa8c16; font-weight: 600; margin: 12px 0; }
|
||||
.countdown.expired { color: #cf1322; }
|
||||
button { width: 100%; padding: 14px; font-size: 16px; border: 0; border-radius: 8px; background: #1677ff; color: #fff; cursor: pointer; margin-top: 12px; }
|
||||
button:disabled { background: #ccc; cursor: not-allowed; }
|
||||
.hint { font-size: 13px; color: #999; }
|
||||
.ok { display: none; margin-top: 16px; padding: 12px; background: #f6ffed; border: 1px solid #b7eb8f; border-radius: 8px; color: #389e0d; line-height: 1.6; }
|
||||
.info { margin-top: 12px; padding: 12px; background: #e6f4ff; border: 1px solid #91caff; border-radius: 8px; color: #0958d9; font-size: 14px; line-height: 1.6; }
|
||||
.err { margin-top: 12px; padding: 12px; background: #fff2f0; border: 1px solid #ffccc7; border-radius: 8px; color: #cf1322; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>模拟充值收银台</h1>
|
||||
<p class="hint">订单号:<span id="orderNo">-</span></p>
|
||||
<div id="amountBlock" style="display:none;">
|
||||
<p>充值金额 <strong id="amount">-</strong>,赠送 <strong id="bonus">-</strong></p>
|
||||
<div class="amt">预计到账 <span id="total">-</span></div>
|
||||
</div>
|
||||
<p class="countdown" id="countdown">正在加载…</p>
|
||||
<p class="hint" id="expireLine"></p>
|
||||
<button type="button" id="btnPay">确认支付</button>
|
||||
<div class="ok" id="okBox"><strong>支付成功(模拟)</strong><br />订单已提交,需管理员在后台审核通过后才会入账。</div>
|
||||
<div id="msg"></div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var orderNo = params.get('order_no') || '';
|
||||
var apiBase = (params.get('api_base') || '').replace(/\/$/, '');
|
||||
var expireAt = parseInt(params.get('expire_at') || '0', 10);
|
||||
var sign = params.get('sign') || '';
|
||||
if (!apiBase) {
|
||||
apiBase = window.location.protocol + '//' + window.location.hostname + (window.location.port ? ':' + window.location.port : '');
|
||||
}
|
||||
document.getElementById('orderNo').textContent = orderNo || '(缺少 order_no)';
|
||||
|
||||
var countdownEl = document.getElementById('countdown');
|
||||
var expireLineEl = document.getElementById('expireLine');
|
||||
var btnPay = document.getElementById('btnPay');
|
||||
var countdownTimer = null;
|
||||
|
||||
function showErr(text) {
|
||||
document.getElementById('msg').innerHTML = '<div class="err">' + text + '</div>';
|
||||
}
|
||||
function showInfo(text) {
|
||||
document.getElementById('msg').innerHTML = '<div class="info">' + text + '</div>';
|
||||
}
|
||||
function pad(n) { return n < 10 ? '0' + n : String(n); }
|
||||
function formatTs(ts) {
|
||||
var d = new Date(ts * 1000);
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' '
|
||||
+ pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
|
||||
}
|
||||
function formatRemain(sec) {
|
||||
if (sec <= 0) return '00:00';
|
||||
var m = Math.floor(sec / 60);
|
||||
var s = sec % 60;
|
||||
return pad(m) + ':' + pad(s);
|
||||
}
|
||||
function updateCountdown(remaining) {
|
||||
if (remaining <= 0) {
|
||||
countdownEl.textContent = '支付链接已过期,请返回应用重新发起充值';
|
||||
countdownEl.className = 'countdown expired';
|
||||
btnPay.disabled = true;
|
||||
return;
|
||||
}
|
||||
countdownEl.textContent = '剩余支付时间 ' + formatRemain(remaining);
|
||||
countdownEl.className = 'countdown';
|
||||
if (expireAt > 0) {
|
||||
expireLineEl.textContent = '链接有效期至 ' + formatTs(expireAt);
|
||||
}
|
||||
}
|
||||
function startCountdown(remaining) {
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
var left = remaining;
|
||||
updateCountdown(left);
|
||||
countdownTimer = setInterval(function () {
|
||||
left -= 1;
|
||||
updateCountdown(left);
|
||||
if (left <= 0) clearInterval(countdownTimer);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function applyStatus(data) {
|
||||
var amount = data.amount;
|
||||
var bonus = data.bonus_amount;
|
||||
var total = data.total_amount;
|
||||
if (amount !== undefined) {
|
||||
document.getElementById('amount').textContent = Number(amount).toFixed(2);
|
||||
document.getElementById('bonus').textContent = Number(bonus || 0).toFixed(2);
|
||||
document.getElementById('total').textContent = Number(total || 0).toFixed(2);
|
||||
document.getElementById('amountBlock').style.display = 'block';
|
||||
}
|
||||
if (data.expire_at) expireAt = data.expire_at;
|
||||
var remaining = data.remaining_seconds !== undefined ? data.remaining_seconds : Math.max(0, expireAt - Math.floor(Date.now() / 1000));
|
||||
startCountdown(remaining);
|
||||
|
||||
if (data.status === 'paid') {
|
||||
showInfo('该订单已入账。');
|
||||
btnPay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
if (data.status === 'pending_review') {
|
||||
document.getElementById('okBox').style.display = 'block';
|
||||
btnPay.style.display = 'none';
|
||||
showInfo('您已提交支付,请等待管理员审核通过后到账。');
|
||||
return;
|
||||
}
|
||||
if (data.status === 'failed') {
|
||||
showErr(data.reject_reason || '订单已失效或超时,请重新发起充值。');
|
||||
btnPay.disabled = true;
|
||||
return;
|
||||
}
|
||||
if (!data.can_pay) {
|
||||
btnPay.disabled = true;
|
||||
if (remaining <= 0) {
|
||||
showErr('支付链接已过期,请返回应用重新发起充值。');
|
||||
}
|
||||
} else {
|
||||
btnPay.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadStatus() {
|
||||
if (!orderNo || !sign || expireAt <= 0) {
|
||||
showErr('链接无效或缺少签名参数,请通过 App 内「去支付」打开完整链接。');
|
||||
btnPay.disabled = true;
|
||||
countdownEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
var statusUrl = apiBase + '/api/finance/mockDepositStatus?' + new URLSearchParams({
|
||||
order_no: orderNo,
|
||||
expire_at: String(expireAt),
|
||||
sign: sign
|
||||
}).toString();
|
||||
fetch(statusUrl, { method: 'GET', credentials: 'omit' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res && res.code === 1 && res.data) {
|
||||
applyStatus(res.data);
|
||||
} else {
|
||||
showErr((res && res.message) ? res.message : '无法加载订单信息');
|
||||
btnPay.disabled = true;
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
showErr('无法连接支付服务,请检查网络或 api_base 配置。');
|
||||
btnPay.disabled = true;
|
||||
});
|
||||
}
|
||||
|
||||
btnPay.onclick = function () {
|
||||
if (!orderNo || !sign || expireAt <= 0) return;
|
||||
btnPay.disabled = true;
|
||||
btnPay.textContent = '处理中...';
|
||||
var confirmUrl = apiBase + '/api/finance/mockDepositConfirm';
|
||||
fetch(confirmUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
order_no: orderNo,
|
||||
expire_at: String(expireAt),
|
||||
sign: sign
|
||||
}).toString()
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res && res.code === 1) {
|
||||
document.getElementById('okBox').style.display = 'block';
|
||||
btnPay.style.display = 'none';
|
||||
document.getElementById('msg').innerHTML = '';
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
countdownEl.textContent = '已提交,等待后台审核';
|
||||
countdownEl.className = 'countdown';
|
||||
} else {
|
||||
showErr((res && res.message) ? res.message : '提交失败');
|
||||
btnPay.disabled = false;
|
||||
btnPay.textContent = '确认支付';
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
showErr('网络错误,请稍后重试。');
|
||||
btnPay.disabled = false;
|
||||
btnPay.textContent = '确认支付';
|
||||
});
|
||||
};
|
||||
|
||||
loadStatus();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
204
web/public/mock-deposit.html
Normal file
204
web/public/mock-deposit.html
Normal file
@@ -0,0 +1,204 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>模拟充值收银台</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background: #f5f7fa; margin: 0; padding: 24px; }
|
||||
.card { max-width: 420px; margin: 40px auto; background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 4px 24px rgba(0,0,0,.08); }
|
||||
h1 { font-size: 20px; margin: 0 0 8px; }
|
||||
p { color: #666; line-height: 1.6; margin: 8px 0; }
|
||||
.amt { font-size: 28px; color: #1677ff; font-weight: 700; margin: 16px 0; }
|
||||
.countdown { font-size: 15px; color: #fa8c16; font-weight: 600; margin: 12px 0; }
|
||||
.countdown.expired { color: #cf1322; }
|
||||
button { width: 100%; padding: 14px; font-size: 16px; border: 0; border-radius: 8px; background: #1677ff; color: #fff; cursor: pointer; margin-top: 12px; }
|
||||
button:disabled { background: #ccc; cursor: not-allowed; }
|
||||
.hint { font-size: 13px; color: #999; }
|
||||
.ok { display: none; margin-top: 16px; padding: 12px; background: #f6ffed; border: 1px solid #b7eb8f; border-radius: 8px; color: #389e0d; line-height: 1.6; }
|
||||
.info { margin-top: 12px; padding: 12px; background: #e6f4ff; border: 1px solid #91caff; border-radius: 8px; color: #0958d9; font-size: 14px; line-height: 1.6; }
|
||||
.err { margin-top: 12px; padding: 12px; background: #fff2f0; border: 1px solid #ffccc7; border-radius: 8px; color: #cf1322; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>模拟充值收银台</h1>
|
||||
<p class="hint">订单号:<span id="orderNo">-</span></p>
|
||||
<div id="amountBlock" style="display:none;">
|
||||
<p>充值金额 <strong id="amount">-</strong>,赠送 <strong id="bonus">-</strong></p>
|
||||
<div class="amt">预计到账 <span id="total">-</span></div>
|
||||
</div>
|
||||
<p class="countdown" id="countdown">正在加载…</p>
|
||||
<p class="hint" id="expireLine"></p>
|
||||
<button type="button" id="btnPay">确认支付</button>
|
||||
<div class="ok" id="okBox"><strong>支付成功(模拟)</strong><br />订单已提交,需管理员在后台审核通过后才会入账。</div>
|
||||
<div id="msg"></div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var orderNo = params.get('order_no') || '';
|
||||
var apiBase = (params.get('api_base') || '').replace(/\/$/, '');
|
||||
var expireAt = parseInt(params.get('expire_at') || '0', 10);
|
||||
var sign = params.get('sign') || '';
|
||||
if (!apiBase) {
|
||||
apiBase = window.location.protocol + '//' + window.location.hostname + (window.location.port ? ':' + window.location.port : '');
|
||||
}
|
||||
document.getElementById('orderNo').textContent = orderNo || '(缺少 order_no)';
|
||||
|
||||
var countdownEl = document.getElementById('countdown');
|
||||
var expireLineEl = document.getElementById('expireLine');
|
||||
var btnPay = document.getElementById('btnPay');
|
||||
var countdownTimer = null;
|
||||
|
||||
function showErr(text) {
|
||||
document.getElementById('msg').innerHTML = '<div class="err">' + text + '</div>';
|
||||
}
|
||||
function showInfo(text) {
|
||||
document.getElementById('msg').innerHTML = '<div class="info">' + text + '</div>';
|
||||
}
|
||||
function pad(n) { return n < 10 ? '0' + n : String(n); }
|
||||
function formatTs(ts) {
|
||||
var d = new Date(ts * 1000);
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' '
|
||||
+ pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
|
||||
}
|
||||
function formatRemain(sec) {
|
||||
if (sec <= 0) return '00:00';
|
||||
var m = Math.floor(sec / 60);
|
||||
var s = sec % 60;
|
||||
return pad(m) + ':' + pad(s);
|
||||
}
|
||||
function updateCountdown(remaining) {
|
||||
if (remaining <= 0) {
|
||||
countdownEl.textContent = '支付链接已过期,请返回应用重新发起充值';
|
||||
countdownEl.className = 'countdown expired';
|
||||
btnPay.disabled = true;
|
||||
return;
|
||||
}
|
||||
countdownEl.textContent = '剩余支付时间 ' + formatRemain(remaining);
|
||||
countdownEl.className = 'countdown';
|
||||
if (expireAt > 0) {
|
||||
expireLineEl.textContent = '链接有效期至 ' + formatTs(expireAt);
|
||||
}
|
||||
}
|
||||
function startCountdown(remaining) {
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
var left = remaining;
|
||||
updateCountdown(left);
|
||||
countdownTimer = setInterval(function () {
|
||||
left -= 1;
|
||||
updateCountdown(left);
|
||||
if (left <= 0) clearInterval(countdownTimer);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function applyStatus(data) {
|
||||
var amount = data.amount;
|
||||
var bonus = data.bonus_amount;
|
||||
var total = data.total_amount;
|
||||
if (amount !== undefined) {
|
||||
document.getElementById('amount').textContent = Number(amount).toFixed(2);
|
||||
document.getElementById('bonus').textContent = Number(bonus || 0).toFixed(2);
|
||||
document.getElementById('total').textContent = Number(total || 0).toFixed(2);
|
||||
document.getElementById('amountBlock').style.display = 'block';
|
||||
}
|
||||
if (data.expire_at) expireAt = data.expire_at;
|
||||
var remaining = data.remaining_seconds !== undefined ? data.remaining_seconds : Math.max(0, expireAt - Math.floor(Date.now() / 1000));
|
||||
startCountdown(remaining);
|
||||
|
||||
if (data.status === 'paid') {
|
||||
showInfo('该订单已入账。');
|
||||
btnPay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
if (data.status === 'pending_review') {
|
||||
document.getElementById('okBox').style.display = 'block';
|
||||
btnPay.style.display = 'none';
|
||||
showInfo('您已提交支付,请等待管理员审核通过后到账。');
|
||||
return;
|
||||
}
|
||||
if (data.status === 'failed') {
|
||||
showErr(data.reject_reason || '订单已失效或超时,请重新发起充值。');
|
||||
btnPay.disabled = true;
|
||||
return;
|
||||
}
|
||||
if (!data.can_pay) {
|
||||
btnPay.disabled = true;
|
||||
if (remaining <= 0) {
|
||||
showErr('支付链接已过期,请返回应用重新发起充值。');
|
||||
}
|
||||
} else {
|
||||
btnPay.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadStatus() {
|
||||
if (!orderNo || !sign || expireAt <= 0) {
|
||||
showErr('链接无效或缺少签名参数,请通过 App 内「去支付」打开完整链接。');
|
||||
btnPay.disabled = true;
|
||||
countdownEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
var statusUrl = apiBase + '/api/finance/mockDepositStatus?' + new URLSearchParams({
|
||||
order_no: orderNo,
|
||||
expire_at: String(expireAt),
|
||||
sign: sign
|
||||
}).toString();
|
||||
fetch(statusUrl, { method: 'GET', credentials: 'omit' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res && res.code === 1 && res.data) {
|
||||
applyStatus(res.data);
|
||||
} else {
|
||||
showErr((res && res.message) ? res.message : '无法加载订单信息');
|
||||
btnPay.disabled = true;
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
showErr('无法连接支付服务,请检查网络或 api_base 配置。');
|
||||
btnPay.disabled = true;
|
||||
});
|
||||
}
|
||||
|
||||
btnPay.onclick = function () {
|
||||
if (!orderNo || !sign || expireAt <= 0) return;
|
||||
btnPay.disabled = true;
|
||||
btnPay.textContent = '处理中...';
|
||||
var confirmUrl = apiBase + '/api/finance/mockDepositConfirm';
|
||||
fetch(confirmUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
order_no: orderNo,
|
||||
expire_at: String(expireAt),
|
||||
sign: sign
|
||||
}).toString()
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res && res.code === 1) {
|
||||
document.getElementById('okBox').style.display = 'block';
|
||||
btnPay.style.display = 'none';
|
||||
document.getElementById('msg').innerHTML = '';
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
countdownEl.textContent = '已提交,等待后台审核';
|
||||
countdownEl.className = 'countdown';
|
||||
} else {
|
||||
showErr((res && res.message) ? res.message : '提交失败');
|
||||
btnPay.disabled = false;
|
||||
btnPay.textContent = '确认支付';
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
showErr('网络错误,请稍后重试。');
|
||||
btnPay.disabled = false;
|
||||
btnPay.textContent = '确认支付';
|
||||
});
|
||||
};
|
||||
|
||||
loadStatus();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: '该订单已审核,无需重复操作',
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export default {
|
||||
channel_name: '渠道',
|
||||
review_admin_username: '审核人',
|
||||
review_title: '提现审核',
|
||||
review_btn: '审核',
|
||||
review_reject_title: '提现拒绝',
|
||||
review_btn_approve: '通过',
|
||||
review_btn_reject: '拒绝',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
class="ba-operate-dialog deposit-detail-dialog"
|
||||
class="ba-operate-dialog deposit-review-dialog"
|
||||
:close-on-click-modal="false"
|
||||
:model-value="isOpen"
|
||||
width="640px"
|
||||
@@ -8,7 +8,13 @@
|
||||
>
|
||||
<template #header>
|
||||
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
|
||||
{{ t('order.depositOrder.detail_title') }}
|
||||
{{
|
||||
step === 'reject'
|
||||
? t('order.depositOrder.review_reject_title')
|
||||
: isPendingReview
|
||||
? t('order.depositOrder.review_title')
|
||||
: t('order.depositOrder.detail_title')
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -18,7 +24,7 @@
|
||||
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + (baTable.form.labelWidth ?? 120) / 2 + 'px)'"
|
||||
>
|
||||
<el-form
|
||||
v-if="!loading"
|
||||
v-if="!loading && step === 'review'"
|
||||
:label-position="config.layout.shrink ? 'top' : 'right'"
|
||||
:label-width="(baTable.form.labelWidth ?? 120) + 'px'"
|
||||
@submit.prevent=""
|
||||
@@ -38,7 +44,6 @@
|
||||
<el-form-item :label="t('order.depositOrder.status')">
|
||||
<el-tag :type="statusTagType" effect="dark" size="small">{{ statusLabel }}</el-tag>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('order.depositOrder.amount')">
|
||||
<el-input :model-value="amountText" readonly />
|
||||
</el-form-item>
|
||||
@@ -48,7 +53,6 @@
|
||||
<el-form-item :label="t('order.depositOrder.total_credit')">
|
||||
<el-input :model-value="totalCreditText" readonly />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('order.depositOrder.pay_channel')">
|
||||
<el-input :model-value="form.pay_channel || '-'" readonly />
|
||||
</el-form-item>
|
||||
@@ -58,6 +62,15 @@
|
||||
<el-form-item :label="t('order.depositOrder.deposit_tier_id')">
|
||||
<el-input :model-value="form.deposit_tier_id || '-'" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.reject_reason" :label="t('order.depositOrder.reject_reason')">
|
||||
<el-input :model-value="form.reject_reason" type="textarea" :rows="2" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!isPendingReview" :label="t('order.depositOrder.review_admin_username')">
|
||||
<el-input :model-value="form.review_admin_text" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!isPendingReview" :label="t('order.depositOrder.review_time')">
|
||||
<el-input :model-value="form.review_time_text" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('order.depositOrder.remark')">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" readonly />
|
||||
</el-form-item>
|
||||
@@ -65,20 +78,67 @@
|
||||
<el-input :model-value="form.create_time_text" readonly />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-form
|
||||
v-if="!loading && step === 'reject'"
|
||||
ref="rejectFormRef"
|
||||
:model="rejectForm"
|
||||
:rules="rejectRules"
|
||||
:label-position="config.layout.shrink ? 'top' : 'right'"
|
||||
:label-width="(baTable.form.labelWidth ?? 120) + 'px'"
|
||||
@submit.prevent=""
|
||||
>
|
||||
<el-alert
|
||||
class="review-reject-hint"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
:title="t('order.depositOrder.review_reject_tip')"
|
||||
/>
|
||||
<el-form-item :label="t('order.depositOrder.order_no')">
|
||||
<el-input :model-value="form.order_no" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('order.depositOrder.total_credit')">
|
||||
<el-input :model-value="totalCreditText" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('order.depositOrder.reject_reason')" prop="remark">
|
||||
<el-input
|
||||
v-model="rejectForm.remark"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="255"
|
||||
show-word-limit
|
||||
:placeholder="t('order.depositOrder.review_reject_placeholder')"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
|
||||
<template #footer>
|
||||
<div class="detail-footer">
|
||||
<el-button type="primary" v-blur @click="onDialogClose">{{ t('order.depositOrder.close_btn') }}</el-button>
|
||||
<div class="review-footer">
|
||||
<template v-if="step === 'review'">
|
||||
<el-button @click="onDialogClose">{{ isPendingReview ? t('Cancel') : t('order.depositOrder.close_btn') }}</el-button>
|
||||
<template v-if="isPendingReview">
|
||||
<el-button type="danger" :loading="submitting" @click="gotoReject">{{ t('order.depositOrder.review_btn_reject') }}</el-button>
|
||||
<el-button type="primary" v-blur :loading="submitting" @click="submitApprove">{{ t('order.depositOrder.review_btn_approve') }}</el-button>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button @click="backToReview">{{ t('order.depositOrder.review_btn_back') }}</el-button>
|
||||
<el-button type="danger" v-blur :loading="submitting" @click="submitReject">{{ t('order.depositOrder.review_btn_confirm_reject') }}</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive, ref, watch } from 'vue'
|
||||
import type { FormInstance, FormItemRule } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, inject, reactive, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import createAxios from '/@/utils/axios'
|
||||
import { useConfig } from '/@/stores/config'
|
||||
import type baTableClass from '/@/utils/baTable'
|
||||
|
||||
@@ -86,7 +146,12 @@ const config = useConfig()
|
||||
const baTable = inject('baTable') as baTableClass
|
||||
const { t } = useI18n()
|
||||
|
||||
const rejectFormRef = useTemplateRef<FormInstance>('rejectFormRef')
|
||||
|
||||
type Step = 'review' | 'reject'
|
||||
const step = ref<Step>('review')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
@@ -98,13 +163,29 @@ const form = reactive({
|
||||
pay_time_text: '-',
|
||||
deposit_tier_id: '',
|
||||
remark: '',
|
||||
reject_reason: '',
|
||||
create_time_text: '-',
|
||||
review_time_text: '-',
|
||||
review_admin_text: '-',
|
||||
amount: 0,
|
||||
bonus_amount: 0,
|
||||
status: 0,
|
||||
})
|
||||
|
||||
const rejectForm = reactive({
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const isOpen = computed(() => ['Edit'].includes(baTable.form.operate ?? ''))
|
||||
const isPendingReview = computed(() => form.status === 3)
|
||||
|
||||
watch(isOpen, (visible) => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
step.value = 'review'
|
||||
rejectForm.remark = ''
|
||||
})
|
||||
|
||||
watch(
|
||||
() => ({ visible: isOpen.value, loadingState: baTable.form.loading, items: baTable.form.items }),
|
||||
@@ -128,13 +209,16 @@ const hydrate = () => {
|
||||
form.pay_channel = String(row['pay_channel'] ?? '')
|
||||
form.deposit_tier_id = String(row['deposit_tier_id'] ?? '')
|
||||
form.remark = String(row['remark'] ?? '')
|
||||
form.reject_reason = String(row['reject_reason'] ?? '')
|
||||
form.amount = parseNumber(row['amount'])
|
||||
form.bonus_amount = parseNumber(row['bonus_amount'])
|
||||
form.status = Number(row['status'] ?? 0)
|
||||
form.create_time_text = formatTime(row['create_time'])
|
||||
form.pay_time_text = formatTime(row['pay_time'])
|
||||
form.review_time_text = formatTime(row['review_time'])
|
||||
form.user_text = resolveRelationText(row, 'user', row['user_id'])
|
||||
form.channel_text = resolveRelationText(row, 'channel', row['channel_id'])
|
||||
form.review_admin_text = resolveRelationText(row, 'reviewAdmin', row['review_admin_id'])
|
||||
}
|
||||
|
||||
const statusLabel = computed(() => t('order.depositOrder.status ' + form.status))
|
||||
@@ -155,10 +239,91 @@ const amountText = computed(() => formatAmount(form.amount))
|
||||
const bonusText = computed(() => formatAmount(form.bonus_amount))
|
||||
const totalCreditText = computed(() => formatAmount(Number((form.amount + form.bonus_amount).toFixed(2))))
|
||||
|
||||
const rejectRules: Record<string, FormItemRule[]> = {
|
||||
remark: [
|
||||
{
|
||||
required: true,
|
||||
validator: (_r, value, cb) => {
|
||||
const text = typeof value === 'string' ? value.trim() : ''
|
||||
if (text === '') {
|
||||
cb(new Error(t('order.depositOrder.reject_reason_required')))
|
||||
return
|
||||
}
|
||||
cb()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const onDialogClose = () => {
|
||||
if (submitting.value) {
|
||||
return
|
||||
}
|
||||
step.value = 'review'
|
||||
baTable.toggleForm()
|
||||
}
|
||||
|
||||
const gotoReject = () => {
|
||||
step.value = 'reject'
|
||||
rejectForm.remark = ''
|
||||
}
|
||||
|
||||
const backToReview = () => {
|
||||
step.value = 'review'
|
||||
}
|
||||
|
||||
const submitApprove = async () => {
|
||||
if (!isPendingReview.value) {
|
||||
ElMessage.warning(t('order.depositOrder.already_reviewed'))
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await createAxios(
|
||||
{
|
||||
url: '/admin/order.DepositOrder/approve',
|
||||
method: 'POST',
|
||||
data: { id: form.id },
|
||||
},
|
||||
{ showSuccessMessage: true }
|
||||
)
|
||||
baTable.onTableHeaderAction('refresh', {})
|
||||
baTable.toggleForm()
|
||||
} catch (_e) {
|
||||
// axios interceptor
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitReject = async () => {
|
||||
const formEl = rejectFormRef.value
|
||||
if (!formEl) return
|
||||
const valid = await formEl.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await createAxios(
|
||||
{
|
||||
url: '/admin/order.DepositOrder/reject',
|
||||
method: 'POST',
|
||||
data: {
|
||||
id: form.id,
|
||||
remark: rejectForm.remark.trim(),
|
||||
},
|
||||
},
|
||||
{ showSuccessMessage: true }
|
||||
)
|
||||
baTable.onTableHeaderAction('refresh', {})
|
||||
baTable.toggleForm()
|
||||
} catch (_e) {
|
||||
// axios interceptor
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function parseNumber(raw: unknown): number {
|
||||
if (raw === null || raw === undefined || raw === '') return 0
|
||||
const n = Number(raw)
|
||||
@@ -210,22 +375,19 @@ function resolveRelationText(row: Record<string, unknown>, relationKey: string,
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.deposit-detail-dialog {
|
||||
.deposit-review-dialog {
|
||||
:deep(.el-dialog__body) {
|
||||
padding-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-footer {
|
||||
.review-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
:deep(.deposit-detail-dialog) {
|
||||
width: calc(100vw - 24px) !important;
|
||||
max-width: 100vw;
|
||||
}
|
||||
.review-reject-hint {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user