From d98f8d7975167de6ad07853ec33c030899f8df8b Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 31 Oct 2026 21:06:52 +0800 Subject: [PATCH] feat(admin): redesign withdrawal review page and drop dead withdrawal code - Split PayWithdrawals into shell + PayWithdrawalsList flat sibling trait - Add process/policy callout, open/paid/fee stats, status views with counts - Filter by source and payout method; reset keeps the status view - Show net payout with gross/fee breakdown, copyable payee, note column - Replace inline select form with per-row actions and a confirm dialog; reject requires a reason; double-submit guard; retry reuses the idempotency key; redirect back preserving filters and paging - Withdrawal: add get_status_counts() and method filter; get_stats() now returns open_gross / paid_net / paid_fee - Remove unused REST routes (/payment/withdrawals, /transition), admin_withdrawals(), update_status(), has_open_withdrawal(), count_open(), resolve_gross_amount() Co-Authored-By: Claude Fable 5 --- assets/css/admin/withdrawals.css | 85 ++++ assets/js/admin-withdrawals.js | 149 +++++++ src/Admin/Native/PayWithdrawals.php | 419 ++++++++---------- src/Admin/Native/PayWithdrawalsList.php | 389 ++++++++++++++++ src/Admin/PaymentController.php | 91 ---- src/Payment/CommissionLedger.php | 27 -- src/Payment/Withdrawal.php | 100 ++--- tests/admin/pay-pages-pagination.test.cjs | 11 +- .../financial-flow-contract.test.cjs | 5 +- 9 files changed, 862 insertions(+), 414 deletions(-) create mode 100644 assets/css/admin/withdrawals.css create mode 100644 assets/js/admin-withdrawals.js create mode 100644 src/Admin/Native/PayWithdrawalsList.php diff --git a/assets/css/admin/withdrawals.css b/assets/css/admin/withdrawals.css new file mode 100644 index 00000000..7622815c --- /dev/null +++ b/assets/css/admin/withdrawals.css @@ -0,0 +1,85 @@ +/* 提现审核(aksrt_withdraw):流程提示 + 统计 + 状态视图 + 筛选 + 申请表 + 处理弹窗。复用 native-ui.css 的 --an-* 令牌,规则限定在本页类名下。 */ +.aksrt-wd-page [hidden], +.aksrt-wd-dialog [hidden] { display: none !important; } + +/* ---------- 状态视图 ---------- */ +.aksrt-wd-page .aksrt-wd-views { display: flex; width: fit-content; max-width: 100%; } + +/* ---------- 筛选栏 ---------- */ +.aksrt-wd-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0; } +.aksrt-wd-filters label { display: inline-flex; margin: 0; } +.aksrt-wd-filters__search { flex: 1 1 220px; } +.aksrt-wd-filters__search input { width: 100%; } +.aksrt-wd-filters__range { display: inline-flex; align-items: center; gap: 6px; color: var(--an-muted); } +.aksrt-wd-page .aksrt-wd-filters__id { width: 96px; } +.aksrt-wd-filters__submit { display: inline-flex; gap: 8px; } + +/* ---------- 申请表 ---------- */ +.aksrt-wd-table { min-width: 1080px; table-layout: fixed; } +.aksrt-wd-table .column-number { width: 15%; } +.aksrt-wd-table .column-user { width: 14%; } +.aksrt-wd-table .column-amount { width: 15%; } +.aksrt-wd-table .column-payee { width: 20%; } +.aksrt-wd-table .column-status { width: 11%; } +.aksrt-wd-table .column-note { width: 13%; } +.aksrt-wd-table .column-actions { width: 12%; } +.aksrt-wd-table td { vertical-align: top; } +.aksrt-wd-table .column-amount { font-variant-numeric: tabular-nums; } +.aksrt-wd-table tr.is-retry { background: #fff8eb; } +.aksrt-wd-sub { margin-top: 4px; color: var(--an-muted); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; } +.aksrt-wd-note { max-height: 4.5em; overflow: auto; color: var(--an-text-2); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; } + +/* 收款方式标签 + 可点击复制的收款信息 */ +.aksrt-wd-method { display: inline-block; padding: 0 6px; color: var(--an-text-2); font-size: 12px; line-height: 20px; background: var(--an-bg-soft); border-radius: 4px; } +.aksrt-wd-method--alipay { color: #1677ff; background: #e8f1ff; } +.aksrt-wd-method--wechat { color: #07a85c; background: #e6f7ee; } +.aksrt-wd-account { display: flex; align-items: flex-start; gap: 5px; max-width: 100%; margin-top: 6px; padding: 4px 7px; color: var(--an-text); background: var(--an-bg-soft); border: 1px solid transparent; border-radius: 5px; text-align: left; font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; cursor: pointer; } +.aksrt-wd-account .dashicons { flex: none; width: 14px; height: 14px; margin-top: 2px; color: var(--an-muted); font-size: 14px; } +.aksrt-wd-account:hover { color: var(--an-primary); background: var(--an-primary-soft); border-color: var(--an-primary); } +.aksrt-wd-account:focus-visible { outline: 2px solid var(--an-primary); outline-offset: 2px; } + +.aksrt-wd-actions { display: flex; flex-wrap: wrap; gap: 6px; } + +/* ---------- 处理弹窗 ---------- */ +html.aksrt-wd-modal-open { overflow: hidden; } +.aksrt-wd-dialog { width: min(560px, calc(100vw - 32px)); max-width: none; max-height: calc(100vh - 48px); max-height: calc(100dvh - 48px); margin: auto; padding: 0; overflow: hidden; color: var(--an-text-2); background: var(--an-card); border: 0; border-radius: var(--an-radius); box-shadow: 0 16px 64px rgba(15, 23, 42, .2); } +.aksrt-wd-dialog::backdrop { background: rgba(15, 23, 42, .45); } +.aksrt-wd-dialog__frame { display: flex; flex-direction: column; max-height: inherit; margin: 0; } +.aksrt-wd-dialog__head { display: flex; flex: none; align-items: center; justify-content: space-between; gap: 16px; padding: 18px 24px; border-bottom: 1px solid var(--an-border-soft); } +.aksrt-wd-dialog .aksrt-wd-dialog__head h2 { margin: 0; padding: 0; color: var(--an-text); font-size: 18px; font-weight: 600; line-height: 1.5; } +.aksrt-wd-dialog__close { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 0; color: var(--an-text-2); background: transparent; border: 0; border-radius: var(--an-radius-sm); cursor: pointer; } +.aksrt-wd-dialog__close:hover { color: var(--an-text); background: var(--an-bg-soft); } +.aksrt-wd-dialog__body { display: flex; flex-direction: column; gap: 14px; min-height: 0; padding: 20px 24px; overflow-y: auto; overscroll-behavior: contain; } +.aksrt-wd-dialog__body .aksrt-native-callout { margin: 0; } +.aksrt-wd-dialog__foot { display: flex; flex: none; justify-content: flex-end; gap: 8px; padding: 14px 24px; border-top: 1px solid var(--an-border-soft); } +.aksrt-wd-dialog__foot .button { min-width: 110px; } +.aksrt-wd-dialog .button:disabled { opacity: .65; cursor: not-allowed; } +.aksrt-wd-dialog .button.button-primary.is-danger { color: #fff; background: var(--an-danger); border-color: var(--an-danger); } +.aksrt-wd-dialog .button.button-primary.is-danger:hover { color: #fff; background: #b8284a; border-color: #b8284a; } +.aksrt-wd-dialog :is(a, button, input, select, textarea):focus-visible { outline: 2px solid var(--an-primary); outline-offset: 2px; } +.aksrt-wd-dialog textarea { resize: vertical; } + +/* 记录摘要:两列网格,应打款金额放大,收款信息独占一行 */ +.aksrt-wd-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 16px; margin: 0; padding: 14px 16px; background: var(--an-bg-soft); border-radius: var(--an-radius-sm); } +.aksrt-wd-summary__item { min-width: 0; } +.aksrt-wd-summary dt { color: var(--an-muted); font-size: 12px; } +.aksrt-wd-summary dd { margin: 2px 0 0; color: var(--an-text); overflow-wrap: anywhere; } +.aksrt-wd-summary__item--net dd { color: var(--an-primary); font-size: 18px; font-weight: 600; font-variant-numeric: tabular-nums; } +.aksrt-wd-summary__item--payee { grid-column: 1 / -1; } + +/* ---------- 复制提示 ---------- */ +.aksrt-wd-toast { position: fixed; right: 24px; bottom: 24px; z-index: 100000; max-width: min(360px, calc(100vw - 48px)); padding: 10px 14px; color: #fff; background: rgba(15, 23, 42, .9); border-radius: var(--an-radius-sm); font-size: 13px; opacity: 0; transform: translateY(8px); transition: opacity .2s, transform .2s; pointer-events: none; } +.aksrt-wd-toast.is-visible { opacity: 1; transform: none; } + +@media (max-width: 782px) { + .aksrt-wd-filters__search { flex-basis: 100%; } + .aksrt-wd-filters__range { flex-wrap: wrap; } +} +@media (max-width: 600px) { + .aksrt-wd-dialog { width: calc(100vw - 24px); max-height: calc(100dvh - 24px); } + .aksrt-wd-dialog__head, + .aksrt-wd-dialog__body, + .aksrt-wd-dialog__foot { padding-left: 16px; padding-right: 16px; } + .aksrt-wd-summary { grid-template-columns: 1fr; } + .aksrt-wd-dialog__foot .button { flex: 1; min-width: 0; } +} diff --git a/assets/js/admin-withdrawals.js b/assets/js/admin-withdrawals.js new file mode 100644 index 00000000..093fcb6e --- /dev/null +++ b/assets/js/admin-withdrawals.js @@ -0,0 +1,149 @@ +/** 提现审核:共享处理弹窗(开始处理 / 确认打款 / 拒绝)、复制收款信息、防重复提交。仅本页入队。 */ +(function () { + 'use strict'; + + var root = document.querySelector('.aksrt-wd-page'); + if (!root) return; + + var dialog = root.querySelector('#aksrt-wd-dialog'); + var form = dialog ? dialog.querySelector('[data-aksrt-wd-form]') : null; + var note = form ? form.querySelector('[data-aksrt-wd-note]') : null; + var toast = root.querySelector('[data-aksrt-wd-toast]'); + var toastTimer; + var opener = null; + + function notify(key) { + if (!toast) return; + clearTimeout(toastTimer); + toast.textContent = toast.getAttribute('data-' + key); + toast.classList.add('is-visible'); + toastTimer = setTimeout(function () { toast.classList.remove('is-visible'); }, 3000); + } + + function lockScroll() { + document.documentElement.classList.toggle('aksrt-wd-modal-open', !!(dialog && dialog.open)); + } + + /** data-mode 可列多个状态(空格分隔):只显示与当前目标状态匹配的说明、标题与提交按钮。 */ + function applyMode(mode) { + form.closest('dialog').querySelectorAll('[data-mode]').forEach(function (el) { + el.hidden = el.getAttribute('data-mode').split(' ').indexOf(mode) === -1; + }); + if (note) note.required = mode === 'rejected'; + } + + function resetSubmits() { + delete form.dataset.submitting; + form.querySelectorAll('[data-aksrt-wd-submit]').forEach(function (button) { + button.disabled = false; + if (button.dataset.idleLabel) button.textContent = button.dataset.idleLabel; + }); + } + + function openFor(button) { + var mode = button.getAttribute('data-aksrt-wd-open'); + ['id', 'nonce', 'key'].forEach(function (field) { + form.querySelector('[data-field="' + field + '"]').value = button.dataset[field] || ''; + }); + form.querySelector('[data-field="status"]').value = mode; + dialog.querySelectorAll('[data-summary]').forEach(function (dd) { + dd.textContent = button.dataset[dd.getAttribute('data-summary')] || '—'; + }); + if (note) note.value = ''; + resetSubmits(); + applyMode(mode); + opener = button; + dialog.showModal(); + lockScroll(); + if (mode === 'rejected' && note) note.focus(); + } + + if (dialog && form && typeof dialog.showModal === 'function') { + var startedOutside = false; + + function outside(event) { + var rect = dialog.getBoundingClientRect(); + return event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom; + } + function requestClose() { + if (!form.dataset.submitting) dialog.close(); + } + + root.addEventListener('click', function (event) { + var button = event.target.closest('[data-aksrt-wd-open]'); + if (button) openFor(button); + }); + dialog.querySelectorAll('[data-aksrt-wd-close]').forEach(function (button) { + button.addEventListener('click', requestClose); + }); + dialog.addEventListener('pointerdown', function (event) { + startedOutside = event.target === dialog && outside(event); + }); + dialog.addEventListener('click', function (event) { + if (startedOutside && event.target === dialog && outside(event)) requestClose(); + startedOutside = false; + }); + dialog.addEventListener('cancel', function (event) { + if (form.dataset.submitting) event.preventDefault(); + }); + dialog.addEventListener('close', function () { + lockScroll(); + if (opener && opener.isConnected) opener.focus(); + opener = null; + }); + + form.addEventListener('submit', function (event) { + if (form.dataset.submitting) { + event.preventDefault(); + return; + } + if (note && note.required && note.value.trim() === '') { + event.preventDefault(); + note.value = ''; + note.reportValidity(); + return; + } + form.dataset.submitting = '1'; + var submit = form.querySelector('[data-aksrt-wd-submit]:not([hidden])'); + if (submit) { + submit.dataset.idleLabel = submit.textContent; + submit.textContent = form.getAttribute('data-busy-label'); + // 延后禁用:同步禁用 submit 按钮会让部分浏览器丢掉这次提交。 + setTimeout(function () { submit.disabled = true; }, 0); + } + }); + + // bfcache 返回时恢复按钮,避免弹窗停在提交中状态。 + window.addEventListener('pageshow', function (event) { + if (event.persisted) resetSubmits(); + }); + } + + root.addEventListener('click', async function (event) { + var button = event.target.closest('[data-clipboard-text]'); + if (!button) return; + var value = button.getAttribute('data-clipboard-text'); + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(value); + } else { + var input = document.createElement('textarea'); + input.value = value; + input.className = 'screen-reader-text'; + root.appendChild(input); + input.select(); + var copied = false; + try { + copied = document.execCommand('copy'); + } finally { + input.remove(); + button.focus(); + } + if (!copied) throw new Error('copy'); + } + notify('copied'); + } catch (error) { + notify('copy-failed'); + } + }); +})(); diff --git a/src/Admin/Native/PayWithdrawals.php b/src/Admin/Native/PayWithdrawals.php index b331f474..c534bf55 100644 --- a/src/Admin/Native/PayWithdrawals.php +++ b/src/Admin/Native/PayWithdrawals.php @@ -1,17 +1,30 @@ ensure_cap(); - NativeUi::page_open(); - NativeUi::header(__('提现审核', 'aksrt'), '', 'dashicons-money-alt'); - $this->render_flash(); + NativeUi::page_open('aksrt-wd-page'); if (!class_exists('\AKSRT\Payment\Withdrawal')) { + NativeUi::header(__('提现审核', 'aksrt'), '', 'dashicons-money-alt'); echo '

' . esc_html__('提现模块未初始化。', 'aksrt') . '

'; NativeUi::page_close(); return; } - $status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : ''; - $keyword = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : ''; - $user_filter = isset($_GET['user_id']) ? max(0, (int) $_GET['user_id']) : 0; - $date_from = isset($_GET['date_from']) ? $this->sanitize_date((string) wp_unslash($_GET['date_from'])) : ''; - $date_to = isset($_GET['date_to']) ? $this->sanitize_date((string) wp_unslash($_GET['date_to'])) : ''; - $page_num = max(1, (int) ($_GET['paged'] ?? 1)); - $per_page = AdminListPagination::DEFAULT_PER_PAGE; - $svc = \AKSRT\Payment\Withdrawal::get_instance(); - $stats = $svc->get_stats(); - $retry_id = isset($_GET['retry_id']) ? max(0, (int) $_GET['retry_id']) : 0; - $retry_key = isset($_GET['retry_key']) ? sanitize_text_field((string) wp_unslash($_GET['retry_key'])) : ''; - if (!wp_is_uuid($retry_key)) { - $retry_key = ''; - } - - NativeUi::stats([ - ['label' => __('总申请', 'aksrt'), 'value' => (int) ($stats['total'] ?? 0), 'icon' => 'dashicons-money-alt', 'tone' => 'info'], - ['label' => __('待处理', 'aksrt'), 'value' => (int) ($stats['pending'] ?? 0), 'icon' => 'dashicons-clock', 'tone' => 'warning'], - ['label' => __('处理中', 'aksrt'), 'value' => (int) ($stats['processing'] ?? 0), 'icon' => 'dashicons-update', 'tone' => 'info'], - ['label' => __('已完成', 'aksrt'), 'value' => (int) ($stats['completed'] ?? 0), 'icon' => 'dashicons-yes-alt', 'tone' => 'success'], - ['label' => __('已拒绝', 'aksrt'), 'value' => (int) ($stats['rejected'] ?? 0), 'icon' => 'dashicons-no-alt', 'tone' => 'danger'], - ['label' => __('已打款', 'aksrt'), 'value' => (string) ($stats['total_paid'] ?? '0.00'), 'unit' => '¥', 'icon' => 'dashicons-chart-line', 'tone' => 'success'], - ]); - - NativeUi::card_open(__('筛选', 'aksrt')); - echo '
'; - echo ''; - echo ' '; - echo ' '; - echo ' '; - echo ' '; - echo ' '; - submit_button(__('筛选', 'aksrt'), 'secondary', '', false); - if ($status !== '' || $keyword !== '' || $user_filter > 0 || $date_from !== '' || $date_to !== '') { - echo ' ' . esc_html__('清除条件', 'aksrt') . ''; - } - echo '
'; - NativeUi::card_close(); - - $items = $svc->get_all([ - 'status' => $status, - 'keyword' => $keyword, - 'user_id' => $user_filter, - 'date_from' => $date_from, - 'date_to' => $date_to, - 'limit' => $per_page, - 'offset' => ($page_num - 1) * $per_page, - ]); - $total = $svc->get_count([ - 'status' => $status, - 'keyword' => $keyword, - 'user_id' => $user_filter, - 'date_from' => $date_from, - 'date_to' => $date_to, - ]); - $pages = max(1, (int) ceil($total / $per_page)); - - // 页码越界(手工改 URL、或筛选后总数变小)会让 offset 落到数据之外:表格空掉 - // 且分页条不输出,只能手改 URL 退出。回退到最后一页重查一次。 - if ($page_num > $pages) { - $page_num = $pages; - $items = $svc->get_all([ - 'status' => $status, - 'keyword' => $keyword, - 'user_id' => $user_filter, - 'date_from' => $date_from, - 'date_to' => $date_to, - 'limit' => $per_page, - 'offset' => ($page_num - 1) * $per_page, - ]); - } - - // 翻页链接显式带上当前筛选条件(不含 paged),口径与上方筛选表单一致。 - // retry_id / retry_key 是一次性动作参数,绝不能进翻页链接。 - $query_args = array_filter([ - 'status' => $status !== '' ? $status : null, - 's' => $keyword !== '' ? $keyword : null, - 'user_id' => $user_filter > 0 ? $user_filter : null, - 'date_from' => $date_from !== '' ? $date_from : null, - 'date_to' => $date_to !== '' ? $date_to : null, - ], static function ($value): bool { - return $value !== null; - }); - - $status_map = [ - 'pending' => [__('待处理', 'aksrt'), 'warning'], - 'processing' => [__('处理中', 'aksrt'), 'info'], - 'completed' => [__('已完成', 'aksrt'), 'success'], - 'rejected' => [__('已拒绝', 'aksrt'), 'danger'], - ]; - - NativeUi::table_card_open(__('提现申请', 'aksrt')); - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - - if (!$items) { - echo NativeUi::empty_row(7, __('暂无提现记录', 'aksrt')); - } - - foreach ($items as $row) { - $id = (int) ($row['id'] ?? 0); - $user_id = (int) ($row['user_id'] ?? 0); - $st = (string) ($row['status'] ?? ''); - $gross = (string) ($row['gross_amount'] ?? '0.00'); - $net = (string) ($row['amount'] ?? '0.00'); - $fee = (int) ($row['fee_rate'] ?? 0); - $acct = (string) ($row['display_account'] ?? $row['account_info'] ?? ''); - $amount_txt = sprintf( - /* translators: 1: gross 2: net 3: fee percent */ - __('申请 %1$s / 到账 %2$s(手续费 %3$d%%)', 'aksrt'), - $gross, - $net, - $fee - ); - - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - continue; - } - $request_key = $retry_id === $id && $retry_key !== '' ? $retry_key : wp_generate_uuid4(); - echo ''; - echo ''; - echo ''; - echo ''; - wp_nonce_field('aksrt_pay_update_withdrawal_' . $id); - echo ''; - echo ''; - submit_button(__('更新', 'aksrt'), 'small', '', false); - echo ''; - echo ''; - echo ''; - } - - echo '
' . esc_html__('订单号', 'aksrt') . '' . esc_html__('用户', 'aksrt') . '' . esc_html__('金额明细', 'aksrt') . '' . esc_html__('渠道 / 账户', 'aksrt') . '' . esc_html__('状态', 'aksrt') . '' . esc_html__('申请 / 处理时间', 'aksrt') . '' . esc_html__('操作', 'aksrt') . '
' . esc_html((string) ($row['withdrawal_number'] ?? '')) . '' . NativeUi::user_cell($user_id) . '' . esc_html($amount_txt) . '' . esc_html((string) ($row['method'] ?? '')) . '
' . esc_html($acct) . '
' . NativeUi::status_badge($st, $status_map) . '' . esc_html((string) ($row['created_at'] ?? '')) . '
' . esc_html((string) ($row['processed_at'] ?? '') ?: '—') . '
'; - $next_statuses = $st === 'pending' ? ['processing', 'rejected'] : ($st === 'processing' ? ['completed', 'rejected'] : []); - if ($next_statuses === []) { - echo '—'; - echo '
'; - NativeUi::table_wrap_close(); - AdminListPagination::render(self::PAGE_SLUG, $total, $page_num, $per_page, $query_args); - NativeUi::card_close(); - + $this->render_withdrawals_list_page(); NativeUi::page_close(); } - public function handle_update_withdrawal(): void { - if (!current_user_can(self::CAPABILITY)) { - wp_die(esc_html__('权限不足', 'aksrt')); - } + /* ------------------------------------------------------------------ + * admin-post 处理器 + * ----------------------------------------------------------------*/ - $id = isset($_POST['withdrawal_id']) ? (int) $_POST['withdrawal_id'] : 0; - $new_status = isset($_POST['new_status']) ? sanitize_key((string) wp_unslash($_POST['new_status'])) : ''; - $note = isset($_POST['note']) ? sanitize_textarea_field((string) wp_unslash($_POST['note'])) : ''; - $request_key = isset($_POST['idempotency_key']) ? sanitize_text_field((string) wp_unslash($_POST['idempotency_key'])) : ''; + public function handle_update_withdrawal(): void { + $this->ensure_cap(); + $id = isset($_POST['withdrawal_id']) ? (int) $_POST['withdrawal_id'] : 0; check_admin_referer('aksrt_pay_update_withdrawal_' . $id); - $msg = 'withdraw_failed'; - if ($id > 0 && $new_status !== '' && wp_is_uuid($request_key) && class_exists('\AKSRT\Payment\Withdrawal')) { - $operation_key = \AKSRT\Ledger\OperationKey::deterministic('admin-withdraw-transition', [get_current_user_id(), strtolower($request_key)]); - $result = \AKSRT\Payment\Withdrawal::get_instance()->process_status_change($id, $new_status, $note, $operation_key); - if (!is_wp_error($result) && $result) { - $msg = 'withdraw_updated'; - } elseif (is_wp_error($result) && $result->get_error_code() === 'aksrt_balance_insufficient') { - $msg = 'withdraw_refund_failed'; - } + $new_status = isset($_POST['new_status']) ? sanitize_key((string) wp_unslash($_POST['new_status'])) : ''; + $note = isset($_POST['note']) ? trim(sanitize_textarea_field((string) wp_unslash($_POST['note']))) : ''; + $request_key = isset($_POST['idempotency_key']) ? sanitize_text_field((string) wp_unslash($_POST['idempotency_key'])) : ''; + + if ($id <= 0 || !in_array($new_status, self::TARGET_STATUSES, true) || !wp_is_uuid($request_key)) { + $this->redirect_back(['aksrt_pay_msg' => 'withdraw_failed']); + } + // 拒绝原因会随站内信通知用户,必须填写。 + if ($new_status === Withdrawal::STATUS_REJECTED && $note === '') { + $this->redirect_back(['aksrt_pay_msg' => 'withdraw_note_required']); } - $redirect_args = ['aksrt_pay_msg' => $msg]; - if ($msg !== 'withdraw_updated' && wp_is_uuid($request_key)) { - $redirect_args['retry_id'] = $id; - $redirect_args['retry_key'] = $request_key; - } - wp_safe_redirect($this->page_url('aksrt_withdraw', $redirect_args)); + $operation_key = \AKSRT\Ledger\OperationKey::deterministic('admin-withdraw-transition', [get_current_user_id(), strtolower($request_key)]); + $result = Withdrawal::get_instance()->process_status_change($id, $new_status, $note, $operation_key); + + if (is_array($result)) { + $this->redirect_back([ + 'aksrt_pay_msg' => 'withdraw_' . $new_status, + 'number' => (string) ($result['withdrawal_number'] ?? ''), + ]); + } + + $code = is_wp_error($result) ? $result->get_error_code() : ''; + $msg = [ + 'aksrt_withdraw_transition' => 'withdraw_stale', + 'aksrt_withdraw_idempotency_conflict' => 'withdraw_stale', + 'aksrt_balance_insufficient' => 'withdraw_refund_failed', + ][$code] ?? 'withdraw_failed'; + + // 可重试的失败带回同一个幂等键:重提同一操作不会重复入账。 + $args = ['aksrt_pay_msg' => $msg]; + if ($msg !== 'withdraw_stale') { + $args['retry_id'] = $id; + $args['retry_key'] = $request_key; + } + $this->redirect_back($args); + } + + /** 回到发起操作的列表(保留筛选与分页);来源不是本页时落到列表首页。 */ + private function redirect_back(array $args): void { + $query = []; + $referer = wp_get_referer(); + if (is_string($referer) && $referer !== '') { + parse_str((string) wp_parse_url($referer, PHP_URL_QUERY), $parsed); + if (($parsed['page'] ?? '') === self::PAGE_SLUG) { + unset($parsed['page'], $parsed['aksrt_pay_msg'], $parsed['number'], $parsed['retry_id'], $parsed['retry_key']); + $query = $parsed; + } + } + wp_safe_redirect($this->page_url(array_merge($query, $args))); exit; } + /* ------------------------------------------------------------------ + * 展示助手 + * ----------------------------------------------------------------*/ + + /** @return array */ + private function status_labels(): array { + return [ + Withdrawal::STATUS_PENDING => __('待处理', 'aksrt'), + Withdrawal::STATUS_PROCESSING => __('处理中', 'aksrt'), + Withdrawal::STATUS_COMPLETED => __('已完成', 'aksrt'), + Withdrawal::STATUS_REJECTED => __('已拒绝', 'aksrt'), + ]; + } + + /** @return array */ + private function status_badge_map(): array { + $labels = $this->status_labels(); + $tones = [ + Withdrawal::STATUS_PENDING => 'warning', + Withdrawal::STATUS_PROCESSING => 'info', + Withdrawal::STATUS_COMPLETED => 'success', + Withdrawal::STATUS_REJECTED => 'danger', + ]; + $map = []; + foreach ($labels as $key => $label) { + $map[$key] = [$label, $tones[$key]]; + } + return $map; + } + + /** 状态机:每个状态在后台可流转到的下一状态,与 Withdrawal::process_status_change() 一致。 */ + private function next_statuses(string $status): array { + return [ + Withdrawal::STATUS_PENDING => [Withdrawal::STATUS_PROCESSING, Withdrawal::STATUS_REJECTED], + Withdrawal::STATUS_PROCESSING => [Withdrawal::STATUS_COMPLETED, Withdrawal::STATUS_REJECTED], + ][$status] ?? []; + } + + /** @return array */ + private function source_labels(): array { + return [ + Withdrawal::SOURCE_BALANCE => __('余额钱包', 'aksrt'), + Withdrawal::SOURCE_COMMISSION => __('推广佣金', 'aksrt'), + ]; + } + + /** @return array */ + private function method_labels(): array { + return [ + 'alipay' => __('支付宝', 'aksrt'), + 'wechat' => __('微信', 'aksrt'), + ]; + } + + private function format_money(string $decimal): string { + try { + return '¥' . \AKSRT\Ledger\AssetAmount::balance($decimal)->formatted(); + } catch (\InvalidArgumentException $e) { + return '¥0.00'; + } + } + + /** 手续费 = 申请毛额 − 到账净额,按分计算避免浮点误差。 */ + private function fee_decimal(string $gross, string $net): string { + try { + $fee = \AKSRT\Ledger\AssetAmount::balance($gross)->minor() - \AKSRT\Ledger\AssetAmount::balance($net)->minor(); + } catch (\InvalidArgumentException $e) { + return '0.00'; + } + return \AKSRT\Ledger\AssetAmount::decimal_from_minor(max(0, $fee)); + } + + /** 行内操作的幂等键:上次失败的那一行沿用带回的键,其余行每次渲染新生成。 */ + private function request_key_for(int $id, int $retry_id, string $retry_key): string { + return $retry_id === $id && wp_is_uuid($retry_key) ? $retry_key : wp_generate_uuid4(); + } + + private function render_flash(): void { + if (empty($_GET['aksrt_pay_msg'])) { + return; + } + $code = sanitize_key((string) wp_unslash($_GET['aksrt_pay_msg'])); + $number = isset($_GET['number']) ? sanitize_text_field((string) wp_unslash($_GET['number'])) : ''; + $suffix = $number !== '' ? sprintf(__('(订单号 %s)', 'aksrt'), $number) : ''; + // 码 => [文案, notice 类型] + $map = [ + 'withdraw_processing' => [__('已标记为处理中,请尽快完成打款。', 'aksrt') . $suffix, 'success'], + 'withdraw_completed' => [__('已确认打款完成,用户已收到通知。', 'aksrt') . $suffix, 'success'], + 'withdraw_rejected' => [__('已拒绝提现,冻结金额已退回用户,拒绝原因已通知用户。', 'aksrt') . $suffix, 'success'], + 'withdraw_note_required' => [__('拒绝提现必须填写拒绝原因。', 'aksrt'), 'warning'], + 'withdraw_stale' => [__('操作未执行:该申请的状态已被更新,请查看最新状态后再处理。', 'aksrt'), 'warning'], + 'withdraw_refund_failed' => [__('状态未更新:冻结金额退回失败,请检查用户钱包后重试。', 'aksrt'), 'error'], + 'withdraw_failed' => [__('提现状态更新失败,请重试。重试不会重复入账。', 'aksrt'), 'error'], + ]; + if (!isset($map[$code])) { + return; + } + [$message, $type] = $map[$code]; + echo '

' . esc_html($message) . '

'; + } + private function ensure_cap(): void { if (!current_user_can(self::CAPABILITY)) { wp_die(esc_html__('您没有权限访问此页面。', 'aksrt')); } } - private function render_flash(): void { - if (empty($_GET['aksrt_pay_msg'])) { - return; - } - $code = sanitize_key((string) wp_unslash($_GET['aksrt_pay_msg'])); - $map = [ - 'withdraw_updated' => __('提现状态已更新。', 'aksrt'), - 'withdraw_failed' => __('提现状态更新失败。', 'aksrt'), - 'withdraw_refund_failed' => __('状态未更新:余额退回失败,请检查钱包后重试。', 'aksrt'), - ]; - if (!isset($map[$code])) { - return; - } - $class = str_contains($code, 'failed') ? 'notice-error' : 'notice-success'; - echo '

' . esc_html($map[$code]) . '

'; + /** 参数值统一 urlencode:add_query_arg 不编码值,搜索词里的 & / # 会截断链接。 */ + private function page_url(array $args = []): string { + return add_query_arg(urlencode_deep(array_merge(['page' => self::PAGE_SLUG], $args)), admin_url('admin.php')); } - - private function page_url(string $slug, array $args = []): string { - return add_query_arg(array_merge(['page' => $slug], $args), admin_url('admin.php')); - } - - private function sanitize_date(string $date): string { - return preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1 ? $date : ''; - } - } diff --git a/src/Admin/Native/PayWithdrawalsList.php b/src/Admin/Native/PayWithdrawalsList.php new file mode 100644 index 00000000..f013e008 --- /dev/null +++ b/src/Admin/Native/PayWithdrawalsList.php @@ -0,0 +1,389 @@ + __('提现策略', 'aksrt'), + 'url' => admin_url('admin.php?page=aksrt-settings#/payment/withdrawal'), + 'icon' => 'dashicons-admin-generic', + ]] + ); + $this->render_flash(); + + $svc = Withdrawal::get_instance(); + $filters = $this->list_filters(); + $per_page = AdminListPagination::DEFAULT_PER_PAGE; + $page_num = max(1, (int) ($_GET['paged'] ?? 1)); + $retry_id = isset($_GET['retry_id']) ? max(0, (int) $_GET['retry_id']) : 0; + $retry_key = isset($_GET['retry_key']) ? sanitize_text_field((string) wp_unslash($_GET['retry_key'])) : ''; + + // 翻页、状态视图与重置链接共用的筛选参数(不含 paged)。 + // retry_id / retry_key 是一次性动作参数,绝不能进翻页链接。 + $query_args = array_filter([ + 'status' => $filters['status'], + 's' => $filters['keyword'], + 'source' => $filters['source'], + 'method' => $filters['method'], + 'user_id' => $filters['user_id'], + 'date_from' => $filters['date_from'], + 'date_to' => $filters['date_to'], + ], static function ($value): bool { + return $value !== '' && $value !== 0; + }); + + $stats = $svc->get_stats(); + $this->render_process_callout($stats); + $this->render_withdrawal_stats($stats); + + // 状态计数同时给出状态视图的数字与当前视图的总数,查询前就能夹取页码。 + $repo_args = array_diff_key($filters, ['status' => true]); + $counts = $svc->get_status_counts($repo_args); + $total = $filters['status'] !== '' ? (int) ($counts[$filters['status']] ?? 0) : array_sum($counts); + $pages = max(1, (int) ceil($total / $per_page)); + + // 页码越界(手工改 URL、或筛选后总数变小)会让 offset 落到数据之外:表格空掉 + // 且分页条不输出,只能手改 URL 退出。先夹到最后一页再查。 + if ($page_num > $pages) { + $page_num = $pages; + } + + $items = $svc->get_all($repo_args + [ + 'status' => $filters['status'], + 'limit' => $per_page, + 'offset' => ($page_num - 1) * $per_page, + ]); + $user_ids = array_values(array_unique(array_filter(array_map('intval', array_column($items, 'user_id'))))); + if ($user_ids !== []) { + cache_users($user_ids); + } + + $this->render_status_views($filters['status'], $query_args, $counts); + $this->render_filter_card($filters, $query_args); + + NativeUi::table_card_open(__('提现申请', 'aksrt')); + echo ''; + echo ''; + foreach ([ + 'number' => esc_html__('订单号', 'aksrt'), + 'user' => esc_html__('用户', 'aksrt'), + 'amount' => esc_html__('金额', 'aksrt'), + 'payee' => esc_html__('收款信息', 'aksrt'), + 'status' => esc_html__('状态', 'aksrt'), + 'note' => esc_html__('备注', 'aksrt'), + 'actions' => esc_html__('操作', 'aksrt'), + ] as $key => $label) { + echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped above. + } + echo ''; + + if (!$items) { + $filtered = array_diff_key($query_args, ['status' => true]) !== []; + echo NativeUi::empty_row(7, __('暂无提现记录', 'aksrt'), $filtered // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ? __('没有符合当前筛选条件的申请,请调整或重置筛选。', 'aksrt') + : __('用户在用户中心发起余额或佣金提现后,申请会出现在这里。', 'aksrt')); + } + foreach ($items as $row) { + $this->render_withdrawal_row($row, $retry_id, $retry_key); + } + + echo '
' . esc_html__('提现申请', 'aksrt') . '
' . $label . '
'; + NativeUi::table_wrap_close(); + AdminListPagination::render(self::PAGE_SLUG, $total, $page_num, $per_page, $query_args); + NativeUi::card_close(); + + $this->render_process_dialog(); + echo '
'; + } + + private function render_withdrawal_row(array $row, int $retry_id, string $retry_key): void { + $id = (int) ($row['id'] ?? 0); + $user_id = (int) ($row['user_id'] ?? 0); + $status = (string) ($row['status'] ?? ''); + $number = (string) ($row['withdrawal_number'] ?? ''); + $gross = (string) ($row['gross_amount'] ?? '0.00'); + $net = (string) ($row['amount'] ?? '0.00'); + $source = (string) ($row['source'] ?? Withdrawal::SOURCE_BALANCE); + $method = (string) ($row['method'] ?? ''); + $account = (string) ($row['display_account'] ?? ''); + $note = trim((string) ($row['note'] ?? '')); + $source_label = $this->source_labels()[$source] ?? $source; + $method_label = $this->method_labels()[$method] ?? ($method !== '' ? $method : '—'); + $user = $user_id > 0 ? get_userdata($user_id) : false; + + echo ''; + echo '' . esc_html($number !== '' ? $number : '#' . $id) . '' + . '
' . esc_html(sprintf(__('%s 申请', 'aksrt'), (string) ($row['created_at'] ?? ''))) . '
'; + echo '' . NativeUi::user_cell($user_id) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + . '
' . esc_html(sprintf(__('来源:%s', 'aksrt'), $source_label)) . '
'; + + echo '' . esc_html($this->format_money($net)) . '' + . '
' . esc_html(sprintf( + /* translators: 1 gross 2 fee 3 fee percent */ + __('申请 %1$s · 手续费 %2$s(%3$d%%)', 'aksrt'), + $this->format_money($gross), + $this->format_money($this->fee_decimal($gross, $net)), + (int) ($row['fee_rate'] ?? 0) + )) . '
'; + + echo '' . esc_html($method_label) . ''; + if ($account !== '') { + echo ''; + } + echo ''; + + $processed = (string) ($row['processed_at'] ?? ''); + echo '' . NativeUi::status_badge($status, $this->status_badge_map()) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + . ($processed !== '' ? '
' . esc_html(sprintf(__('%s 处理', 'aksrt'), $processed)) . '
' : '') . ''; + + echo '' . ($note !== '' + ? '
' . nl2br(esc_html($note)) . '
' + : '—') . ''; + + echo ''; + $next = $this->next_statuses($status); + if ($next === []) { + echo '—'; + return; + } + $data = [ + 'id' => (string) $id, + 'nonce' => wp_create_nonce('aksrt_pay_update_withdrawal_' . $id), + 'key' => $this->request_key_for($id, $retry_id, $retry_key), + 'number' => $number !== '' ? $number : '#' . $id, + 'user' => $user ? $user->display_name . ' #' . $user_id : '#' . $user_id, + 'net' => $this->format_money($net), + 'gross' => $this->format_money($gross), + 'source' => $source_label, + 'payee' => $method_label . ($account !== '' ? ' · ' . $account : ''), + ]; + $attrs = ''; + foreach ($data as $key => $value) { + $attrs .= ' data-' . $key . '="' . esc_attr($value) . '"'; + } + $buttons = [ + Withdrawal::STATUS_PROCESSING => [__('开始处理', 'aksrt'), 'button-primary'], + Withdrawal::STATUS_COMPLETED => [__('确认打款', 'aksrt'), 'button-primary'], + Withdrawal::STATUS_REJECTED => [__('拒绝', 'aksrt'), 'is-danger'], + ]; + echo '
'; + foreach ($next as $target) { + [$label, $class] = $buttons[$target]; + echo ''; + } + echo '
'; + } + + /** 处理流程 + 当前提现策略;有待办时用警示色提醒冻结金额。 */ + private function render_process_callout(array $stats): void { + $settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_withdrawal_settings() : []; + $open = (int) ($stats['pending'] ?? 0) + (int) ($stats['processing'] ?? 0); + $policy = sprintf( + /* translators: 1 min amount 2 fee percent 3 daily count 4 daily limit */ + __('当前策略:最低 %1$s,手续费 %2$d%%,每日 %3$d 次 / %4$s。', 'aksrt'), + $this->format_money((string) ($settings['min_amount'] ?? '0.00')), + (int) ($settings['service_charge_rate'] ?? 0), + (int) ($settings['daily_count'] ?? 0), + $this->format_money((string) ($settings['daily_limit'] ?? '0.00')) + ); + $flow = __('处理流程:开始处理 → 按收款信息线下打款 → 确认打款。确认打款与拒绝都不可撤销。', 'aksrt'); + + echo '
'; + if ($open > 0) { + echo '' . esc_html(sprintf( + /* translators: 1 open count 2 frozen amount */ + __('%1$d 笔申请待办,冻结中 %2$s。', 'aksrt'), + $open, + $this->format_money((string) ($stats['open_gross'] ?? '0.00')) + )) . ' '; + } + echo esc_html($flow . ' ' . $policy) . '
'; + } + + private function render_withdrawal_stats(array $stats): void { + NativeUi::stats([ + ['label' => __('待处理', 'aksrt'), 'value' => number_format_i18n((int) ($stats['pending'] ?? 0)), 'url' => $this->page_url(['status' => Withdrawal::STATUS_PENDING]), 'icon' => 'dashicons-clock', 'tone' => 'warning'], + ['label' => __('处理中', 'aksrt'), 'value' => number_format_i18n((int) ($stats['processing'] ?? 0)), 'url' => $this->page_url(['status' => Withdrawal::STATUS_PROCESSING]), 'icon' => 'dashicons-update', 'tone' => 'info'], + ['label' => __('冻结中金额', 'aksrt'), 'value' => $this->format_money((string) ($stats['open_gross'] ?? '0.00')), 'hint' => __('待处理 + 处理中的申请毛额', 'aksrt'), 'icon' => 'dashicons-lock', 'tone' => 'warning'], + ['label' => __('已打款', 'aksrt'), 'value' => $this->format_money((string) ($stats['paid_net'] ?? '0.00')), 'url' => $this->page_url(['status' => Withdrawal::STATUS_COMPLETED]), 'hint' => sprintf( + /* translators: 1 completed count 2 fee total */ + __('%1$s 笔 · 手续费 %2$s', 'aksrt'), + number_format_i18n((int) ($stats['completed'] ?? 0)), + $this->format_money((string) ($stats['paid_fee'] ?? '0.00')) + ), 'icon' => 'dashicons-yes-alt', 'tone' => 'success'], + ['label' => __('已拒绝', 'aksrt'), 'value' => number_format_i18n((int) ($stats['rejected'] ?? 0)), 'url' => $this->page_url(['status' => Withdrawal::STATUS_REJECTED]), 'icon' => 'dashicons-no-alt', 'tone' => 'danger'], + ]); + } + + /** @param array $counts */ + private function render_status_views(string $current, array $query_args, array $counts): void { + $base = array_diff_key($query_args, ['status' => true]); + $views = ['' => [__('全部', 'aksrt'), array_sum($counts)]]; + foreach ($this->status_labels() as $key => $label) { + $views[$key] = [$label, (int) ($counts[$key] ?? 0)]; + } + + echo ''; + } + + /** + * @param array $filters + * @param array $query_args + */ + private function render_filter_card(array $filters, array $query_args): void { + NativeUi::card_open('', '', false, 'aksrt-wd-filter-card'); + echo ''; + NativeUi::card_close(); + } + + /** @param array $options */ + private function filter_select(string $name, string $label, array $options, string $current): string { + $html = ''; + } + + /** + * 共享处理弹窗:行内按钮把记录摘要、nonce 与幂等键写进来(admin-withdrawals.js), + * 三种目标状态各自的说明与备注要求用 data-mode 区分。 + */ + private function render_process_dialog(): void { + echo ''; + echo '
'; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + wp_referer_field(); + + $titles = [ + Withdrawal::STATUS_PROCESSING => __('开始处理提现', 'aksrt'), + Withdrawal::STATUS_COMPLETED => __('确认已打款', 'aksrt'), + Withdrawal::STATUS_REJECTED => __('拒绝提现', 'aksrt'), + ]; + echo '

'; + foreach ($titles as $mode => $title) { + echo ''; + } + echo '

'; + + echo '
'; + foreach ([ + 'number' => __('订单号', 'aksrt'), + 'user' => __('用户', 'aksrt'), + 'net' => __('应打款', 'aksrt'), + 'gross' => __('申请金额', 'aksrt'), + 'source' => __('来源', 'aksrt'), + 'payee' => __('收款信息', 'aksrt'), + ] as $field => $label) { + echo '
' . esc_html($label) . '
'; + } + echo '
'; + + $hints = [ + Withdrawal::STATUS_PROCESSING => [__('标记为处理中后,请按上方收款信息线下打款,完成后回到这里确认打款。', 'aksrt'), ''], + Withdrawal::STATUS_COMPLETED => [__('请确认已向用户实际打出「应打款」金额。确认后状态不可更改,用户会收到完成通知。', 'aksrt'), 'is-warning'], + Withdrawal::STATUS_REJECTED => [__('拒绝后,冻结的申请金额会退回用户的原资金池,拒绝原因会通知用户。此操作不可撤销。', 'aksrt'), 'is-danger'], + ]; + foreach ($hints as $mode => [$hint, $tone]) { + echo ''; + } + + echo ''; + echo '
'; + + $submits = [ + Withdrawal::STATUS_PROCESSING => [__('开始处理', 'aksrt'), 'button-primary'], + Withdrawal::STATUS_COMPLETED => [__('确认已打款', 'aksrt'), 'button-primary'], + Withdrawal::STATUS_REJECTED => [__('确认拒绝', 'aksrt'), 'button-primary is-danger'], + ]; + echo '
'; + foreach ($submits as $mode => [$label, $class]) { + echo ''; + } + echo '
'; + } + + /** + * 从请求读取并校验筛选条件;非法值一律视为未筛选。 + * + * @return array{status:string,keyword:string,source:string,method:string,user_id:int,date_from:string,date_to:string} + */ + private function list_filters(): array { + $read = static function (string $key): string { + return isset($_GET[$key]) ? sanitize_text_field((string) wp_unslash($_GET[$key])) : ''; + }; + $date = static function (string $value): string { + return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : ''; + }; + $status = sanitize_key($read('status')); + $source = sanitize_key($read('source')); + $method = sanitize_key($read('method')); + + return [ + 'status' => isset($this->status_labels()[$status]) ? $status : '', + 'keyword' => trim($read('s')), + 'source' => isset($this->source_labels()[$source]) ? $source : '', + 'method' => isset($this->method_labels()[$method]) ? $method : '', + 'user_id' => max(0, (int) $read('user_id')), + 'date_from' => $date($read('date_from')), + 'date_to' => $date($read('date_to')), + ]; + } +} diff --git a/src/Admin/PaymentController.php b/src/Admin/PaymentController.php index 03593518..929900c3 100644 --- a/src/Admin/PaymentController.php +++ b/src/Admin/PaymentController.php @@ -18,96 +18,5 @@ class PaymentController extends ControllerBase { public function register_routes(): void { $this->register_section_routes('payment', 'payment'); - - register_rest_route(self::API_NAMESPACE, '/payment/withdrawals', [ - 'methods' => 'GET', - 'callback' => [$this, 'get_withdrawals'], - 'permission_callback' => [$this, 'check_permission'], - 'args' => [ - 'page' => ['type' => 'integer', 'minimum' => 1, 'default' => 1], - 'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 50, 'default' => 20], - 'status' => ['type' => 'string', 'enum' => ['', 'pending', 'processing', 'completed', 'rejected']], - ], - ]); - - // Withdrawal status update - register_rest_route(self::API_NAMESPACE, '/payment/withdrawals/(?P\d+)', [ - 'methods' => 'POST', - 'callback' => [$this, 'update_withdrawal_status'], - 'permission_callback' => [$this, 'check_permission'], - 'args' => [ - 'id' => ['type' => 'integer', 'minimum' => 1, 'required' => true], - 'status' => ['type' => 'string', 'required' => true, 'enum' => ['processing', 'completed', 'rejected']], - 'note' => ['type' => 'string', 'default' => '', 'maxLength' => 1000, 'sanitize_callback' => 'sanitize_textarea_field'], - ], - ]); } - - public function get_withdrawals(\WP_REST_Request $request): \WP_REST_Response { - if (!class_exists('\AKSRT\Payment\Withdrawal')) { - return $this->success(['data' => ['items' => [], 'total' => 0, 'page' => 1, 'total_pages' => 1, 'stats' => []]]); - } - - $status = sanitize_key((string) $request->get_param('status')); - $page = max(1, (int) $request->get_param('page')); - $per_page = max(1, min(50, (int) $request->get_param('per_page'))) ?: 20; - - $items = \AKSRT\Payment\Withdrawal::get_instance()->get_all([ - 'status' => $status, - 'limit' => $per_page, - 'offset' => ($page - 1) * $per_page, - ]); - - $total = \AKSRT\Payment\Withdrawal::get_instance()->get_count(['status' => $status]); - $stats = \AKSRT\Payment\Withdrawal::get_instance()->get_stats(); - - return $this->success([ - 'data' => [ - 'items' => $items, - 'total' => $total, - 'page' => $page, - 'total_pages' => max(1, (int) ceil($total / $per_page)), - 'stats' => $stats, - ], - ]); - } - - /** - * POST /payment/withdrawals/{id} — 更新提现状态 - */ - public function update_withdrawal_status(\WP_REST_Request $request): \WP_REST_Response { - if (!class_exists('\AKSRT\Payment\Withdrawal')) { - return $this->error('提现模块未初始化', 500); - } - - $id = (int) $request->get_param('id'); - $params = $this->get_json_params($request); - $new_status = sanitize_key((string) ($params['status'] ?? '')); - $note = sanitize_textarea_field((string) ($params['note'] ?? '')); - - $valid_statuses = ['processing', 'completed', 'rejected']; - if (!in_array($new_status, $valid_statuses, true)) { - return $this->error('无效的提现状态'); - } - - if ($id <= 0) { - return $this->error('无效的提现 ID'); - } - - $operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-withdraw-transition', get_current_user_id()); - if (is_wp_error($operation_key)) { - return $this->error($operation_key->get_error_message(), (int) ($operation_key->get_error_data()['status'] ?? 400)); - } - $result = \AKSRT\Payment\Withdrawal::get_instance()->process_status_change($id, $new_status, $note, $operation_key); - - if (is_wp_error($result)) { - $data = $result->get_error_data(); - return $this->error($result->get_error_message(), is_array($data) ? (int) ($data['status'] ?? 400) : 400); - } - if (is_array($result)) { - return $this->success(array_merge(['message' => __('提现状态已更新', 'aksrt')], $result)); - } - return $this->error(__('更新失败', 'aksrt')); - } - } diff --git a/src/Payment/CommissionLedger.php b/src/Payment/CommissionLedger.php index ca523c63..25ac74c5 100644 --- a/src/Payment/CommissionLedger.php +++ b/src/Payment/CommissionLedger.php @@ -459,16 +459,6 @@ final class CommissionLedger { ], ], ]); - register_rest_route('aksrt/v1', '/payment/withdrawals/(?P\d+)/transition', [ - 'methods' => \WP_REST_Server::CREATABLE, - 'callback' => [$this, 'transition_withdrawal'], - 'permission_callback' => [$this, 'check_admin_write_rest'], - 'args' => [ - 'id' => ['type' => 'integer', 'minimum' => 1, 'required' => true], - 'status' => ['type' => 'string', 'required' => true, 'enum' => ['processing', 'completed', 'rejected']], - 'note' => ['type' => 'string', 'default' => '', 'maxLength' => 1000, 'sanitize_callback' => 'sanitize_textarea_field'], - ], - ]); } public function check_user_rest(\WP_REST_Request $request) { return is_user_logged_in() ? true : new \WP_Error('aksrt_auth_required', __('请先登录', 'aksrt'), ['status' => 401]); @@ -477,13 +467,6 @@ final class CommissionLedger { $allowed = $this->check_user_rest($request); return is_wp_error($allowed) ? $allowed : $this->verify_rest_nonce($request); } - public function check_admin_rest(\WP_REST_Request $request) { - return current_user_can('manage_options') ? true : new \WP_Error('aksrt_admin_forbidden', __('无权执行此操作', 'aksrt'), ['status' => 403]); - } - public function check_admin_write_rest(\WP_REST_Request $request) { - $allowed = $this->check_admin_rest($request); - return is_wp_error($allowed) ? $allowed : $this->verify_rest_nonce($request); - } private function verify_rest_nonce(\WP_REST_Request $request) { $nonce = (string) $request->get_header('X-WP-Nonce'); return $nonce !== '' && wp_verify_nonce($nonce, 'wp_rest') @@ -545,16 +528,6 @@ final class CommissionLedger { if (!in_array($method,(array)$settings['payout_channels'],true) || $account==='') return new \WP_Error('affiliate_withdraw_account',__('请提供有效的收款渠道和收款码。','aksrt'),['status'=>400]); $result=\AKSRT\Payment\Withdrawal::get_instance()->create_balance_request($user_id,$amount->minor(),$method,$account,$settings,$operation_key,$source); if(is_wp_error($result)) return $result; return new \WP_REST_Response(['success'=>true,'data'=>$result],201); } - public function admin_withdrawals(array $args): array { $service=\AKSRT\Payment\Withdrawal::get_instance(); return ['items'=>$service->get_all(['status'=>sanitize_key((string)($args['status']??'')),'limit'=>max(1,min(100,(int)($args['per_page']??20))),'offset'=>max(0,((int)($args['page']??1)-1)*max(1,(int)($args['per_page']??20)))]),'stats'=>$service->get_stats()]; } - public function transition_withdrawal(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { - $operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-withdraw-transition', get_current_user_id()); - if (is_wp_error($operation_key)) return $operation_key; - $status = sanitize_key((string) $request->get_param('status')); - $result = \AKSRT\Payment\Withdrawal::get_instance()->process_status_change((int) $request['id'], $status, (string) $request->get_param('note'), $operation_key); - if (is_wp_error($result)) return $result; - $data = is_array($result) ? $result : []; - return new \WP_REST_Response(array_merge(['success' => true, 'status' => $status], $data), 200); - } private function balance_decimal(string $value): string { try { return \AKSRT\Ledger\AssetAmount::balance($value)->decimal(); diff --git a/src/Payment/Withdrawal.php b/src/Payment/Withdrawal.php index 98e22931..4eeb4811 100644 --- a/src/Payment/Withdrawal.php +++ b/src/Payment/Withdrawal.php @@ -319,28 +319,6 @@ class Withdrawal { return is_array($row) ? $row : null; } - /** - * 更新提现状态(无余额副作用)。优先使用 process_status_change()。 - */ - public function update_status(int $withdrawal_id, string $new_status, string $note = ''): bool { - global $wpdb; - - $update = ['status' => sanitize_key($new_status)]; - $format = ['%s']; - - if ($new_status === self::STATUS_COMPLETED || $new_status === self::STATUS_REJECTED) { - $update['processed_at'] = current_time('mysql'); - $format[] = '%s'; - } - - if ($note !== '') { - $update['note'] = sanitize_textarea_field($note); - $format[] = '%s'; - } - - return $wpdb->update($this->table(), $update, ['id' => $withdrawal_id], $format, ['%d']) !== false; - } - /** * Status transition with balance side-effects. * Reject of balance-source holds refunds gross_amount once (idempotent). @@ -519,8 +497,7 @@ class Withdrawal { // Notify only after the financial transaction has committed. $replayed = ($ledger_result instanceof \AKSRT\Ledger\LedgerResult && $ledger_result->replayed) || is_array($operation_replay); if (class_exists('\AKSRT\Messages\Message') && $user_id > 0 && in_array($new_status, [self::STATUS_COMPLETED, self::STATUS_REJECTED], true) && !$replayed) { - $gross = $this->resolve_gross_amount($row, $meta); - $amount_txt = $gross; + $amount_txt = $this->database_balance_decimal((string) ($row['gross_amount'] ?? '0')); if ($new_status === self::STATUS_REJECTED) { $title = __('提现已拒绝', 'aksrt'); $body = sprintf(__('您申请提现 %s 元已被拒绝,冻结金额已退回钱包。', 'aksrt'), $amount_txt); @@ -556,28 +533,6 @@ class Withdrawal { return \AKSRT\Ledger\AssetAmount::from_minor($asset, $minor)->decimal(); } - /** - * Whether user has open (pending/processing) withdrawals. - */ - public function has_open_withdrawal(int $user_id): bool { - return $this->count_open($user_id) > 0; - } - - /** - * Count open withdrawals. - */ - public function count_open(int $user_id): int { - global $wpdb; - if ($user_id <= 0) { - return 0; - } - return (int) $wpdb->get_var($wpdb->prepare( - "SELECT COUNT(*) FROM {$this->table()} - WHERE user_id = %d AND status IN ('pending','processing')", - $user_id - )); - } - /** * Sum of frozen gross amounts still open (balance source). */ @@ -704,6 +659,11 @@ class Withdrawal { $where[] = 'account_info LIKE %s'; $params[] = '%' . $wpdb->esc_like('"source":"' . $source . '"') . '%'; } + $method = sanitize_key((string) ($args['method'] ?? '')); + if ($method !== '') { + $where[] = 'method = %s'; + $params[] = $method; + } $keyword = trim(sanitize_text_field((string) ($args['keyword'] ?? ''))); if ($keyword !== '') { $like = '%' . $wpdb->esc_like($keyword) . '%'; @@ -734,6 +694,12 @@ class Withdrawal { return [$where, $params]; } + /** + * 全站统计:各状态条数;open_gross 为待处理 + 处理中仍冻结的申请毛额, + * paid_net / paid_fee 为已完成申请的到账净额与手续费合计。 + * + * @return array{total:int,pending:int,processing:int,completed:int,rejected:int,open_gross:string,paid_net:string,paid_fee:string} + */ public function get_stats(): array { global $wpdb; @@ -744,7 +710,9 @@ class Withdrawal { SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) as processing, SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, - COALESCE(SUM(CASE WHEN status = 'completed' THEN amount ELSE 0 END), 0) as total_paid + COALESCE(SUM(CASE WHEN status IN ('pending','processing') THEN gross_amount ELSE 0 END), 0) as open_gross, + COALESCE(SUM(CASE WHEN status = 'completed' THEN amount ELSE 0 END), 0) as paid_net, + COALESCE(SUM(CASE WHEN status = 'completed' THEN gross_amount - amount ELSE 0 END), 0) as paid_fee FROM {$this->table()}", ARRAY_A ); @@ -755,19 +723,42 @@ class Withdrawal { 'processing' => (int) ($row['processing'] ?? 0), 'rejected' => (int) ($row['rejected'] ?? 0), 'completed' => (int) ($row['completed'] ?? 0), - 'total_paid' => $this->database_balance_decimal((string) ($row['total_paid'] ?? '0')), + 'open_gross' => $this->database_balance_decimal((string) ($row['open_gross'] ?? '0')), + 'paid_net' => $this->database_balance_decimal((string) ($row['paid_net'] ?? '0')), + 'paid_fee' => $this->database_balance_decimal((string) ($row['paid_fee'] ?? '0')), ]; } + /** + * 后台筛选条件下各状态的条数(忽略 status 参数):状态视图计数与当前视图总数共用一次查询。 + * + * @return array + */ + public function get_status_counts(array $args = []): array { + global $wpdb; + unset($args['status']); + [$where, $params] = $this->build_admin_filters($args); + $where_sql = $where ? ' WHERE ' . implode(' AND ', $where) : ''; + $sql = "SELECT status, COUNT(*) AS c FROM {$this->table()}{$where_sql} GROUP BY status"; + $rows = (array) ($params ? $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A) : $wpdb->get_results($sql, ARRAY_A)); + $counts = array_fill_keys([self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED], 0); + foreach ($rows as $row) { + $status = (string) ($row['status'] ?? ''); + if (isset($counts[$status])) { + $counts[$status] = (int) ($row['c'] ?? 0); + } + } + return $counts; + } + /** * @param array $row * @return array */ private function normalize_row(array $row): array { $meta = $this->parse_account_meta((string) ($row['account_info'] ?? '')); - $gross = $this->resolve_gross_amount($row, $meta); $row['meta'] = $meta; - $row['gross_amount'] = $gross; + $row['gross_amount'] = $this->database_balance_decimal((string) ($row['gross_amount'] ?? '0')); $row['amount'] = $this->database_balance_decimal((string) ($row['amount'] ?? '0')); $row['fee_rate'] = (int) ($meta['fee_rate'] ?? 0); $display = (string) ($meta['account'] ?? $row['account_info'] ?? ''); @@ -807,15 +798,6 @@ class Withdrawal { return $source === 'balance'; } - /** - * @param array $row - * @param array $meta - */ - private function resolve_gross_amount(array $row, array $meta): string { - unset($meta); - return $this->database_balance_decimal((string) ($row['gross_amount'] ?? '0')); - } - private function merge_note(string $old, string $new): string { $new = sanitize_textarea_field($new); if ($new === '') { diff --git a/tests/admin/pay-pages-pagination.test.cjs b/tests/admin/pay-pages-pagination.test.cjs index 8a7b80db..ea6b0a3f 100644 --- a/tests/admin/pay-pages-pagination.test.cjs +++ b/tests/admin/pay-pages-pagination.test.cjs @@ -43,7 +43,7 @@ const pages = { products: ['PayProducts', 'PayProductsList', 'PayProductsEditor'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n'), coupons: read('src/Admin/Native/PayCoupons.php'), income: ['PayIncome', 'PayIncomeList'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n'), - withdrawals: read('src/Admin/Native/PayWithdrawals.php'), + withdrawals: ['PayWithdrawals', 'PayWithdrawalsList'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n'), chargeCards, }; @@ -143,10 +143,17 @@ test('每页都在查询前夹取页码,越界时重查一次', () => { '创作分成必须在查询列表前完成计数与页码夹取' ); + // 提现审核同样先按状态计数夹取页码,再只查一次列表。 + assert.equal(count(pages.withdrawals, /\$svc->get_all\(/g), 1, '提现审核只应查询一次列表'); + assert.ok( + pages.withdrawals.indexOf('get_status_counts(') < pages.withdrawals.indexOf('if ($page_num > $pages) {') + && pages.withdrawals.indexOf('if ($page_num > $pages) {') < pages.withdrawals.indexOf('$svc->get_all('), + '提现审核必须在查询列表前完成计数与页码夹取' + ); + const requeries = { products: [/\$query\s*=\s*\$this->query_paid_posts\(/g], coupons: [/\$svc->get_list\(/g], - withdrawals: [/\$svc->get_all\(/g], chargeCards: [/\$svc->get_list\(/g], }; diff --git a/tests/widget-system/financial-flow-contract.test.cjs b/tests/widget-system/financial-flow-contract.test.cjs index d245b226..cb0df8b3 100644 --- a/tests/widget-system/financial-flow-contract.test.cjs +++ b/tests/widget-system/financial-flow-contract.test.cjs @@ -59,8 +59,9 @@ test('orders have no independent flow page or visible flow number', () => { assert.equal(fs.existsSync(path.join(root, 'src/Finance/FinancialFlowQuery.php')), false) assert.match(payOrders, /订单明细/) assert.doesNotMatch(payOrders, /账本流水号|资金流水号/) - assert.match(read('src/Admin/Native/PayWithdrawals.php'), /esc_html__\('订单号'/) - assert.doesNotMatch(read('src/Admin/Native/PayWithdrawals.php'), /esc_html__\('流水号'|提现单号/) + const withdrawals = ['PayWithdrawals', 'PayWithdrawalsList'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n') + assert.match(withdrawals, /esc_html__\('订单号'/) + assert.doesNotMatch(withdrawals, /esc_html__\('流水号'|提现单号/) // 前台订单详情只展示公开订单号,账本流水号不下发也不展示。 assert.match(read('assets/js/order-center.js'), /\["订单号", order\.order_number\]/) assert.doesNotMatch(read('assets/js/order-center.js'), /flow_number|账本流水号/)