From d57fc6f934f45884a4066b41cb24879b46f91f58 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 16 Jul 2026 15:38:55 +0800 Subject: [PATCH 1/4] feat(user-center): points buy, balance withdraw, and asset record tabs Add cash purchase of points (points_pay_s), wallet withdraw requests via AKSRT_Withdrawal, Zibll-style dual-card layout with tabbed balance/points ledgers, and admin switches for buy-points packages and balance withdraw. --- .../src/schema/sections/payment/recharge.js | 11 + .../src/schema/sections/users/credits.js | 8 +- assets/css/routes/user-center/credits.css | 33 ++ assets/js/credits.js | 149 +++++++ inc/classes/class-aksrt-balance-service.php | 398 +++++++++++++++++- inc/classes/class-aksrt-balance.php | 2 + inc/helpers/aksrt-domain-model.php | 2 + .../user-center-renderers/credits-balance.php | 62 ++- .../user-center-renderers/credits-wallet.php | 34 ++ .../user-center-renderers/profile-credits.php | 97 ++++- 10 files changed, 780 insertions(+), 16 deletions(-) diff --git a/assets/admin/src/schema/sections/payment/recharge.js b/assets/admin/src/schema/sections/payment/recharge.js index 6c74626b..fd4a2223 100644 --- a/assets/admin/src/schema/sections/payment/recharge.js +++ b/assets/admin/src/schema/sections/payment/recharge.js @@ -23,6 +23,8 @@ export const rechargeSchema = { pay_balance_exchange_s: false, pay_balance_exchange_rate: 100, pay_balance_exchange_desc: '', + pay_balance_withdraw_s: false, + pay_balance_withdraw_lowest_money: 50, }, groups: [ { @@ -86,5 +88,14 @@ export const rechargeSchema = { { key: 'pay_balance_exchange_desc', label: '兑换说明', type: 'textarea', default: '', dependsOn: { key: 'pay_balance_exchange_s', value: true } }, ], }, + { + key: 'withdraw', + title: '余额提现', + icon: 'fas fa-hand-holding-usd', + fields: [ + { key: 'pay_balance_withdraw_s', label: '启用余额提现', type: 'switch', default: false, dependsOn: { key: 'pay_balance_s', value: true } }, + { key: 'pay_balance_withdraw_lowest_money', label: '最低提现金额', type: 'number', default: 50, min: 1, max: 99999, dependsOn: { key: 'pay_balance_withdraw_s', value: true } }, + ], + }, ], } diff --git a/assets/admin/src/schema/sections/users/credits.js b/assets/admin/src/schema/sections/users/credits.js index e8659acb..b9da1ef7 100644 --- a/assets/admin/src/schema/sections/users/credits.js +++ b/assets/admin/src/schema/sections/users/credits.js @@ -38,8 +38,12 @@ export const creditsSchema = { icon: 'fas fa-coins', fields: [ { key: 'points_s', label: '启用积分系统', type: 'switch', default: true }, - { key: 'points_pay_s', label: '积分支付', type: 'switch', default: false }, - { key: 'points_rate', label: '积分兑换比例', type: 'number', default: 100, min: 1, max: 10000 }, + { key: 'points_pay_s', label: '启用购买积分', type: 'switch', default: false, help: '对齐 zibll points_pay_s:允许用户用现金/余额购买积分。' }, + { key: 'points_rate', label: '积分汇率(1元=N积分)', type: 'number', default: 100, min: 1, max: 10000, dependsOn: { key: 'points_pay_s', value: true } }, + { key: 'pay_points_desc', label: '购买积分说明', type: 'textarea', default: '', dependsOn: { key: 'points_pay_s', value: true } }, + { key: 'pay_points_product', label: '积分套餐', type: 'textarea', default: '', help: '每行:支付金额|积分数量|标题。留空则仅自定义数量。', dependsOn: { key: 'points_pay_s', value: true } }, + { key: 'pay_points_product_custom_s', label: '允许自定义积分数量', type: 'switch', default: true, dependsOn: { key: 'points_pay_s', value: true } }, + { key: 'pay_points_product_custom_limit', label: '自定义积分上限', type: 'number', default: 99999, min: 1, max: 999999, dependsOn: { key: 'points_pay_s', value: true } }, { key: 'points_name', label: '积分名称', type: 'input', default: '积分' }, { key: 'points_decimal', label: '积分小数位数', type: 'number', default: 2, min: 0, max: 4 }, ], diff --git a/assets/css/routes/user-center/credits.css b/assets/css/routes/user-center/credits.css index c36e4126..9f29d0c7 100644 --- a/assets/css/routes/user-center/credits.css +++ b/assets/css/routes/user-center/credits.css @@ -282,6 +282,39 @@ body.uc-modal-open { margin-top: 12px; } +/* Asset record tabs (Zibll tab-nav-theme) */ +.uc-asset-tabs { + margin-top: 8px; + padding: 12px 16px 16px; +} +.uc-asset-tabs__nav { + display: flex; + gap: 18px; + list-style: none; + margin: 0 0 12px; + padding: 0 0 10px; + border-bottom: 1px solid var(--color-line-soft, rgba(0,0,0,.06)); +} +.uc-asset-tabs__nav li a { + display: inline-block; + padding: 4px 0; + color: var(--muted-color, #777); + text-decoration: none; + font-weight: 700; + border-bottom: 2px solid transparent; +} +.uc-asset-tabs__nav li.active a, +.uc-asset-tabs__nav li a:hover { + color: var(--focus-color, #f04494); + border-bottom-color: var(--focus-color, #f04494); +} +.uc-asset-tabs__content .tab-pane[hidden] { + display: none !important; +} +.uc-asset-tabs__content .tab-pane.is-active { + display: block; +} + .uc-content #panel-credits .uc-transactions { background: transparent; border: 0; diff --git a/assets/js/credits.js b/assets/js/credits.js index 46ba85e7..36bc8fb8 100644 --- a/assets/js/credits.js +++ b/assets/js/credits.js @@ -775,6 +775,152 @@ }); } + function initPointsBuy() { + var modal = document.querySelector('[data-aksrt-points-buy-modal]'); + if (!modal) return; + var form = modal.querySelector('[data-aksrt-points-buy-form]'); + var tip = modal.querySelector('[data-aksrt-points-buy-tip]'); + var busy = false; + function setTip(text, isError) { + if (!tip) return; + tip.textContent = text || ''; + tip.classList.toggle('is-error', !!isError); + } + document.querySelectorAll('[data-aksrt-points-buy-open]').forEach(function (btn) { + btn.addEventListener('click', function () { openModal(modal); }); + }); + modal.querySelectorAll('[data-aksrt-points-buy-close]').forEach(function (btn) { + btn.addEventListener('click', function () { closeModal(modal); }); + }); + modal.querySelectorAll('.uc-charge-package input').forEach(function (inp) { + inp.addEventListener('change', function () { + modal.querySelectorAll('.uc-charge-package').forEach(function (el) { + el.classList.toggle('is-active', el.contains(inp) && inp.checked); + }); + }); + }); + if (!form) return; + form.addEventListener('submit', function (e) { + e.preventDefault(); + if (busy) return; + var pkgInput = form.querySelector('input[name="points_package"]:checked') || form.querySelector('input[name="points_package"]'); + var pkg = pkgInput ? pkgInput.value : 'custom'; + var customEl = form.querySelector('[data-aksrt-points-buy-custom]'); + var custom = customEl ? parseInt(customEl.value || '0', 10) : 0; + if (custom > 0) pkg = 'custom'; + if (pkg === 'custom' && !(custom > 0)) { + setTip('请输入购买积分数量', true); + return; + } + var methodEl = form.querySelector('input[name="points_pay_method"]:checked'); + var method = methodEl ? methodEl.value : 'alipay'; + busy = true; + setTip('正在创建订单…'); + api('/pay/points/buy', { + method: 'POST', + body: JSON.stringify({ + package: pkg, + custom_points: custom, + payment_method: method + }) + }).then(function (r) { + busy = false; + if (!r.ok || (r.json && r.json.code && !r.json.success)) { + setTip((r.json && r.json.message) || '购买失败', true); + return; + } + var data = (r.json && r.json.data) ? r.json.data : r.json; + if (data && data.redirect && (data.payment_url || data.qr_content)) { + if (data.payment_url) window.open(data.payment_url, '_blank'); + setTip(data.message || '请完成支付,支付成功后将自动刷新。'); + if (data.order_number) pollPaidOrder(data.order_number); + return; + } + setTip((data && data.message) || '购买成功,正在刷新…'); + window.location.reload(); + }).catch(function () { + busy = false; + setTip('网络错误,请重试', true); + }); + }); + } + + function initWithdraw() { + var modal = document.querySelector('[data-aksrt-withdraw-modal]'); + if (!modal) return; + var form = modal.querySelector('[data-aksrt-withdraw-form]'); + var tip = modal.querySelector('[data-aksrt-withdraw-tip]'); + var busy = false; + function setTip(text, isError) { + if (!tip) return; + tip.textContent = text || ''; + tip.classList.toggle('is-error', !!isError); + } + document.querySelectorAll('[data-aksrt-withdraw-open]').forEach(function (btn) { + btn.addEventListener('click', function () { openModal(modal); }); + }); + modal.querySelectorAll('[data-aksrt-withdraw-close]').forEach(function (btn) { + btn.addEventListener('click', function () { closeModal(modal); }); + }); + if (!form) return; + form.addEventListener('submit', function (e) { + e.preventDefault(); + if (busy) return; + var amountEl = form.querySelector('[data-aksrt-withdraw-amount]'); + var methodEl = form.querySelector('[data-aksrt-withdraw-method]'); + var accountEl = form.querySelector('[data-aksrt-withdraw-account]'); + var amount = amountEl ? parseFloat(amountEl.value || '0') : 0; + var method = methodEl ? methodEl.value : 'alipay'; + var account = accountEl ? String(accountEl.value || '').trim() : ''; + if (!(amount > 0)) { + setTip('请输入提现金额', true); + return; + } + if (!account) { + setTip('请填写收款账号信息', true); + return; + } + busy = true; + setTip('正在提交…'); + api('/pay/balance/withdraw', { + method: 'POST', + body: JSON.stringify({ amount: amount, method: method, account_info: account }) + }).then(function (r) { + busy = false; + if (!r.ok || (r.json && r.json.code && !r.json.success)) { + setTip((r.json && r.json.message) || '提现失败', true); + return; + } + var data = (r.json && r.json.data) ? r.json.data : r.json; + setTip((data && data.message) || '申请已提交,正在刷新…'); + window.location.reload(); + }).catch(function () { + busy = false; + setTip('网络错误,请重试', true); + }); + }); + } + + function initAssetTabs() { + var nav = document.querySelectorAll('[data-aksrt-asset-tab]'); + if (!nav.length) return; + nav.forEach(function (link) { + link.addEventListener('click', function (e) { + e.preventDefault(); + var key = link.getAttribute('data-aksrt-asset-tab'); + document.querySelectorAll('[data-aksrt-asset-tab]').forEach(function (a) { + var li = a.closest('li'); + if (li) li.classList.toggle('active', a === link); + }); + document.querySelectorAll('[data-aksrt-asset-pane]').forEach(function (pane) { + var on = pane.getAttribute('data-aksrt-asset-pane') === key; + pane.classList.toggle('is-active', on); + pane.hidden = !on; + }); + }); + }); + } + function bootAssets() { initSignin(); initInvite(); @@ -784,6 +930,9 @@ initBalanceCharge(); initTransfer(); initExchange(); + initPointsBuy(); + initWithdraw(); + initAssetTabs(); } if (document.readyState === 'loading') { diff --git a/inc/classes/class-aksrt-balance-service.php b/inc/classes/class-aksrt-balance-service.php index 3e6d0a3d..e7f22892 100644 --- a/inc/classes/class-aksrt-balance-service.php +++ b/inc/classes/class-aksrt-balance-service.php @@ -75,6 +75,33 @@ class AKSRT_Balance_Service { }, ], ]); + + register_rest_route('aksrt/v1', '/pay/points/buy', [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [$this, 'rest_points_buy_options'], + 'permission_callback' => static function () { + return is_user_logged_in(); + }, + ], + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'rest_points_buy_create'], + 'permission_callback' => static function () { + return is_user_logged_in(); + }, + ], + ]); + + register_rest_route('aksrt/v1', '/pay/balance/withdraw', [ + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'rest_balance_withdraw'], + 'permission_callback' => static function () { + return is_user_logged_in(); + }, + ], + ]); } /** @@ -333,14 +360,62 @@ class AKSRT_Balance_Service { return; } $type = sanitize_key((string) ($order['product_type'] ?? '')); + $user_id = (int) ($order['user_id'] ?? 0); + if ($user_id <= 0) { + return; + } + + // Points purchase fulfill (zibll order_type=9). + if ($type === AKSRT_Domain_Model::ORDER_PRODUCT_POINTS_BUY) { + if (!class_exists('AKSRT_Credits')) { + return; + } + $note = json_decode((string) ($order['note'] ?? ''), true); + if (!is_array($note)) { + $note = []; + } + if (!empty($note['points_credited'])) { + return; + } + $rate = 100; + if (class_exists('AKSRT_Settings')) { + $rate = max(1, (int) (AKSRT_Settings::get_all()['points_rate'] ?? 100)); + } + $points = isset($note['points_amount']) + ? (int) $note['points_amount'] + : (int) round((float) ($order['amount'] ?? 0) * $rate); + if ($points <= 0) { + return; + } + $add = AKSRT_Credits::add( + $user_id, + $points, + 'points_buy', + ['type' => 'order', 'order' => $order_number], + sprintf(__('购买积分订单 %s', 'aksrt'), $order_number) + ); + if (is_wp_error($add)) { + return; + } + $note['points_credited'] = 1; + $note['credited_points'] = $points; + global $wpdb; + $wpdb->update( + $wpdb->prefix . 'aksrt_order', + ['note' => wp_json_encode($note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)], + ['order_number' => $order_number], + ['%s'], + ['%s'] + ); + return; + } + if ($type !== AKSRT_Domain_Model::ORDER_PRODUCT_RECHARGE) { return; } - $user_id = (int) ($order['user_id'] ?? 0); - if ($user_id <= 0 || !class_exists('AKSRT_Balance')) { + if (!class_exists('AKSRT_Balance')) { return; } - // Prevent double credit. $note = json_decode((string) ($order['note'] ?? ''), true); if (!is_array($note)) { $note = []; @@ -366,11 +441,9 @@ class AKSRT_Balance_Service { } $note['balance_credited'] = 1; $note['credited_amount'] = $credit_amount; - // Mark credited in note (best-effort; private table accessor). global $wpdb; - $tbl = $wpdb->prefix . 'aksrt_order'; $wpdb->update( - $tbl, + $wpdb->prefix . 'aksrt_order', ['note' => wp_json_encode($note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)], ['order_number' => $order_number], ['%s'], @@ -606,4 +679,317 @@ class AKSRT_Balance_Service { 'reload' => true, ]; } + + public function rest_points_buy_options(WP_REST_Request $request) { + if (!$this->points_buy_enabled()) { + return new WP_Error('aksrt_points_buy_disabled', __('购买积分未开启', 'aksrt'), ['status' => 403]); + } + return rest_ensure_response([ + 'success' => true, + 'data' => $this->get_points_buy_view(get_current_user_id()), + ]); + } + + public function rest_points_buy_create(WP_REST_Request $request) { + if (!$this->points_buy_enabled()) { + return new WP_Error('aksrt_points_buy_disabled', __('购买积分未开启', 'aksrt'), ['status' => 403]); + } + $params = $request->get_json_params(); + if (!is_array($params)) { + $params = $request->get_params(); + } + $package_key = sanitize_text_field((string) ($params['package'] ?? 'custom')); + $custom_points = (int) ($params['custom_points'] ?? $params['points'] ?? 0); + $payment_method = sanitize_key((string) ($params['payment_method'] ?? 'alipay')); + $result = $this->create_points_buy_order(get_current_user_id(), $package_key, $custom_points, $payment_method); + if (is_wp_error($result)) { + return $result; + } + return rest_ensure_response(['success' => true, 'data' => $result]); + } + + public function rest_balance_withdraw(WP_REST_Request $request) { + if (!class_exists('AKSRT_Balance') || !AKSRT_Balance::enabled()) { + return new WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]); + } + $settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_all() : []; + if (empty($settings['pay_balance_withdraw_s'])) { + return new WP_Error('aksrt_withdraw_closed', __('余额提现未开启', 'aksrt'), ['status' => 403]); + } + if (!class_exists('AKSRT_Withdrawal')) { + return new WP_Error('aksrt_withdraw_unavailable', __('提现系统不可用', 'aksrt'), ['status' => 500]); + } + $params = $request->get_json_params(); + if (!is_array($params)) { + $params = $request->get_params(); + } + $user_id = get_current_user_id(); + $amount = round((float) ($params['amount'] ?? 0), 2); + $method = sanitize_key((string) ($params['method'] ?? 'alipay')); + $account = sanitize_textarea_field((string) ($params['account_info'] ?? $params['account'] ?? '')); + $lowest = max(1, (float) ($settings['pay_balance_withdraw_lowest_money'] ?? $settings['pay_rebate_withdraw_lowest_money'] ?? 50)); + if ($amount < $lowest) { + return new WP_Error('aksrt_withdraw_min', sprintf(__('最低提现 %s 元', 'aksrt'), (string) $lowest), ['status' => 400]); + } + if ($account === '') { + return new WP_Error('aksrt_withdraw_account', __('请填写收款账号信息', 'aksrt'), ['status' => 400]); + } + $wallet = AKSRT_Balance::balance($user_id); + if ($wallet < $amount) { + return new WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), ['status' => 402]); + } + $deduct = AKSRT_Balance::deduct($user_id, $amount, 'withdraw_hold', ['type' => 'withdraw'], __('余额提现申请冻结', 'aksrt')); + if (is_wp_error($deduct)) { + return $deduct; + } + $fee_rate = max(0, min(50, (int) ($settings['withdraw_service_charge'] ?? 0))); + $net = $fee_rate > 0 ? round($amount * (100 - $fee_rate) / 100, 2) : $amount; + $account_payload = wp_json_encode([ + 'account' => $account, + 'gross_amount' => $amount, + 'fee_rate' => $fee_rate, + 'source' => 'balance', + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $account; + $wid = AKSRT_Withdrawal::get_instance()->create($user_id, $net, $method !== '' ? $method : 'alipay', $account_payload); + if ($wid <= 0) { + AKSRT_Balance::add($user_id, $amount, 'withdraw_refund', [], __('提现申请失败退回', 'aksrt')); + return new WP_Error('aksrt_withdraw_failed', __('提现申请失败', 'aksrt'), ['status' => 500]); + } + return rest_ensure_response([ + 'success' => true, + 'data' => [ + 'message' => __('提现申请已提交,请等待处理', 'aksrt'), + 'withdrawal_id' => $wid, + 'amount' => $amount, + 'net_amount' => $net, + 'wallet_balance' => AKSRT_Balance::balance($user_id), + 'reload' => true, + ], + ]); + } + + public function points_buy_enabled(): bool { + if (!class_exists('AKSRT_Settings') || !AKSRT_Settings::credits_enabled()) { + return false; + } + $all = AKSRT_Settings::get_all(); + return !empty($all['points_pay_s']); + } + + /** + * @return array + */ + public function get_points_buy_view(int $user_id): array { + $all = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_all() : []; + $rate = max(1, (int) ($all['points_rate'] ?? 100)); + $packages_raw = (string) ($all['pay_points_product'] ?? ''); + $packages = $this->parse_points_packages($packages_raw); + $custom_s = !isset($all['pay_points_product_custom_s']) || !empty($all['pay_points_product_custom_s']); + $custom_limit = max(1, (int) ($all['pay_points_product_custom_limit'] ?? 99999)); + $methods = []; + if (function_exists('aksrt_list_cash_payment_methods')) { + foreach (aksrt_list_cash_payment_methods(true) as $m) { + $id = is_array($m) ? (string) ($m['id'] ?? '') : (string) $m; + if ($id === '') { + continue; + } + if ($id === 'balance' && !(class_exists('AKSRT_Balance') && AKSRT_Balance::enabled())) { + continue; + } + $methods[] = is_array($m) ? $m : ['id' => $id, 'name' => $id]; + } + } + if (class_exists('AKSRT_Balance') && AKSRT_Balance::enabled()) { + $has_bal = false; + foreach ($methods as $m) { + if (($m['id'] ?? '') === 'balance') { + $has_bal = true; + break; + } + } + if (!$has_bal) { + array_unshift($methods, ['id' => 'balance', 'name' => __('站内余额', 'aksrt')]); + } + } + return [ + 'enabled' => $this->points_buy_enabled(), + 'desc' => (string) ($all['pay_points_desc'] ?? ''), + 'rate' => $rate, + 'points' => class_exists('AKSRT_Credits') ? AKSRT_Credits::balance($user_id) : 0, + 'packages' => $packages, + 'custom_s' => $custom_s, + 'custom_limit' => $custom_limit, + 'payment_methods' => $methods, + ]; + } + + /** + * @return array + */ + public function parse_points_packages(string $raw): array { + $out = []; + $lines = preg_split('/\r\n|\r|\n/', $raw) ?: []; + $i = 0; + foreach ($lines as $line) { + $line = trim((string) $line); + if ($line === '') { + continue; + } + // 支付金额|积分数量|标题 + $parts = array_map('trim', explode('|', $line)); + if (count($parts) < 2) { + continue; + } + $pay = (float) $parts[0]; + $points = (int) $parts[1]; + $title = $parts[2] ?? ''; + if ($pay <= 0 || $points <= 0) { + continue; + } + $out[] = [ + 'key' => (string) $i, + 'pay_price' => round($pay, 2), + 'points' => $points, + 'title' => $title !== '' ? $title : sprintf(__('%d 积分', 'aksrt'), $points), + ]; + $i++; + } + return $out; + } + + /** + * @return array|WP_Error + */ + public function create_points_buy_order(int $user_id, string $package_key, int $custom_points, string $payment_method) { + if ($user_id <= 0) { + return new WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]); + } + if (!class_exists('AKSRT_Order_Repository') || !class_exists('AKSRT_Order')) { + return new WP_Error('aksrt_pay_unavailable', __('订单系统不可用', 'aksrt'), ['status' => 500]); + } + $view = $this->get_points_buy_view($user_id); + $rate = max(1, (int) ($view['rate'] ?? 100)); + $points_amount = 0; + $pay_amount = 0.0; + $title = __('购买积分', 'aksrt'); + + if ($package_key === 'custom' || $package_key === '') { + if (empty($view['custom_s'])) { + return new WP_Error('aksrt_custom_closed', __('未开放自定义数量', 'aksrt'), ['status' => 400]); + } + $custom_points = max(0, $custom_points); + $max = (int) ($view['custom_limit'] ?? 99999); + if ($custom_points < $rate || $custom_points > $max) { + return new WP_Error('aksrt_custom_limit', sprintf(__('购买积分需在 %d ~ %d 之间', 'aksrt'), $rate, $max), ['status' => 400]); + } + $points_amount = $custom_points; + $pay_amount = round($custom_points / $rate, 2); + if ($pay_amount <= 0) { + $pay_amount = 0.01; + } + $title = sprintf(__('购买 %d 积分', 'aksrt'), $points_amount); + $package_key = 'custom'; + } else { + $found = null; + foreach ((array) $view['packages'] as $pkg) { + if ((string) ($pkg['key'] ?? '') === (string) $package_key) { + $found = $pkg; + break; + } + } + if (!$found) { + return new WP_Error('aksrt_invalid_package', __('积分套餐无效', 'aksrt'), ['status' => 400]); + } + $points_amount = (int) $found['points']; + $pay_amount = (float) $found['pay_price']; + $title = (string) $found['title']; + } + if ($points_amount <= 0 || $pay_amount <= 0) { + return new WP_Error('aksrt_invalid_price', __('金额无效', 'aksrt'), ['status' => 400]); + } + + $payment_method = sanitize_key($payment_method); + if ($payment_method === '') { + $methods = $view['payment_methods'] ?? []; + $payment_method = !empty($methods[0]['id']) ? (string) $methods[0]['id'] : 'alipay'; + } + + $note = wp_json_encode([ + 'points_buy' => 1, + 'package' => $package_key, + 'points_amount' => $points_amount, + 'pay_amount' => $pay_amount, + 'title' => $title, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + $order_number = AKSRT_Order_Repository::get_instance()->create([ + 'user_id' => $user_id, + 'product_type' => AKSRT_Domain_Model::ORDER_PRODUCT_POINTS_BUY, + 'product_id' => 0, + 'amount' => $pay_amount, + 'currency' => 'CNY', + 'status' => AKSRT_Order::STATUS_PENDING, + 'payment_method' => $payment_method, + 'note' => is_string($note) ? $note : '', + 'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS), + ]); + if ($order_number === '') { + return new WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]); + } + $order = AKSRT_Order_Repository::get_instance()->get($order_number); + if (!$order) { + return new WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]); + } + $order['subject'] = $title; + $order['payment_method'] = $payment_method; + + if (!class_exists('AKSRT_Payment_Gateway')) { + return new WP_Error('aksrt_pay_unavailable', __('支付网关不可用', 'aksrt'), ['status' => 500]); + } + $gateway = AKSRT_Payment_Gateway::get_instance()->get_gateway($payment_method); + if (!$gateway) { + return new WP_Error('aksrt_gateway_unavailable', __('当前支付方式不可用', 'aksrt'), ['status' => 400, 'order_number' => $order_number]); + } + if (method_exists($gateway, 'is_configured') && !$gateway->is_configured()) { + return new WP_Error('aksrt_gateway_unconfigured', __('该支付方式尚未配置,请联系站长。', 'aksrt'), ['status' => 400, 'order_number' => $order_number]); + } + $payment_result = $gateway->create_payment($order); + if (empty($payment_result['success'])) { + return new WP_Error('aksrt_pay_failed', (string) ($payment_result['message'] ?? __('支付失败', 'aksrt')), ['status' => 402, 'order_number' => $order_number]); + } + $code_url = (string) ($payment_result['payment_data']['code_url'] ?? ''); + $payment_url = (string) ($payment_result['payment_url'] ?? ''); + if ($payment_url !== '' || $code_url !== '') { + return [ + 'redirect' => true, + 'payment_url' => $payment_url, + 'qr_content' => $code_url, + 'order_number' => $order_number, + 'amount' => $pay_amount, + 'points_amount' => $points_amount, + 'message' => $payment_result['message'] ?? __('请在新页面完成支付。', 'aksrt'), + 'reload' => false, + 'poll' => true, + ]; + } + $paid = AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_PAID, [ + 'payment_method' => $payment_method, + 'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')), + 'note' => __('购买积分支付完成', 'aksrt'), + ]); + if (!$paid) { + return new WP_Error('aksrt_order_confirm_failed', __('支付确认失败', 'aksrt'), ['status' => 500]); + } + $this->on_order_paid($order_number, AKSRT_Order_Repository::get_instance()->get($order_number) ?: $order); + return [ + 'redirect' => false, + 'order_number' => $order_number, + 'amount' => $pay_amount, + 'points_amount' => $points_amount, + 'points' => AKSRT_Credits::balance($user_id), + 'message' => __('购买成功', 'aksrt'), + 'reload' => true, + ]; + } + } diff --git a/inc/classes/class-aksrt-balance.php b/inc/classes/class-aksrt-balance.php index d8553d77..855e73f2 100644 --- a/inc/classes/class-aksrt-balance.php +++ b/inc/classes/class-aksrt-balance.php @@ -270,6 +270,8 @@ class AKSRT_Balance { 'transfer_out' => __('转出', 'aksrt'), 'admin' => __('管理员调整', 'aksrt'), 'exchange_from_credits' => __('积分兑换', 'aksrt'), + 'withdraw_hold' => __('提现冻结', 'aksrt'), + 'withdraw_refund' => __('提现退回', 'aksrt'), ]; $key = sanitize_key($reason); return $map[$key] ?? ($reason !== '' ? $reason : __('余额变动', 'aksrt')); diff --git a/inc/helpers/aksrt-domain-model.php b/inc/helpers/aksrt-domain-model.php index 898a408e..f040fd92 100644 --- a/inc/helpers/aksrt-domain-model.php +++ b/inc/helpers/aksrt-domain-model.php @@ -30,6 +30,8 @@ class AKSRT_Domain_Model { public const ORDER_PRODUCT_RESOURCE = 'resource'; public const ORDER_PRODUCT_VIP = 'vip'; public const ORDER_PRODUCT_RECHARGE = 'recharge'; + /** Cash purchase of points (zibll order_type=9). */ + public const ORDER_PRODUCT_POINTS_BUY = 'points_buy'; public const ORDER_PRODUCT_SHOP = 'shop_product'; /** Paid plate follow (zibll order_type=11). product_id = plate post ID. */ public const ORDER_PRODUCT_PLATE_FOLLOW = 'plate_follow'; diff --git a/inc/helpers/user-center-renderers/credits-balance.php b/inc/helpers/user-center-renderers/credits-balance.php index b4c21a75..83628e61 100644 --- a/inc/helpers/user-center-renderers/credits-balance.php +++ b/inc/helpers/user-center-renderers/credits-balance.php @@ -18,7 +18,10 @@ if (class_exists('AKSRT_Charge_Card')) { $opts = AKSRT_Charge_Card::get_instance()->get_public_options(); $card_pass_credits = is_array($opts['credits'] ?? null) ? $opts['credits'] : $card_pass_credits; } -$points_transfer_on = class_exists('AKSRT_Settings') && !empty(AKSRT_Settings::get_all()['points_transfer_s']); +$all_settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_all() : []; +$points_transfer_on = !empty($all_settings['points_transfer_s']); +$points_buy_on = class_exists('AKSRT_Balance_Service') && AKSRT_Balance_Service::get_instance()->points_buy_enabled(); +$points_buy_view = $points_buy_on ? AKSRT_Balance_Service::get_instance()->get_points_buy_view((int) ($user_id ?? 0)) : []; $currency = (string) ($currency_name ?? __('积分', 'aksrt')); ?>
@@ -41,11 +44,68 @@ $currency = (string) ($currency_name ?? __('积分', 'aksrt')); + + +

+ + + +
diff --git a/inc/helpers/user-center-renderers/credits-wallet.php b/inc/helpers/user-center-renderers/credits-wallet.php index c831e143..462fe5c7 100644 --- a/inc/helpers/user-center-renderers/credits-wallet.php +++ b/inc/helpers/user-center-renderers/credits-wallet.php @@ -23,6 +23,9 @@ $recharge = class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'g : []; $transfer_on = !empty($recharge['transfer']['enabled']); $exchange_on = !empty($recharge['exchange']['enabled']); +$all_settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_all() : []; +$withdraw_on = !empty($all_settings['pay_balance_withdraw_s']); +$withdraw_min = max(1, (float) ($all_settings['pay_balance_withdraw_lowest_money'] ?? $all_settings['pay_rebate_withdraw_lowest_money'] ?? 50)); $card_pass_balance = ['enabled' => false, 'desc' => '', 'only_password' => true]; if (class_exists('AKSRT_Charge_Card')) { $opts = AKSRT_Charge_Card::get_instance()->get_public_options(); @@ -56,6 +59,9 @@ $mark = (string) ($charge_view['currency_mark'] ?? '元'); + + +
@@ -200,6 +206,32 @@ $mark = (string) ($charge_view['currency_mark'] ?? '元'); + + + + +

@@ -259,4 +291,6 @@ $mark = (string) ($charge_view['currency_mark'] ?? '元');

+ +
diff --git a/inc/helpers/user-center-renderers/profile-credits.php b/inc/helpers/user-center-renderers/profile-credits.php index 6d1d0460..410355fd 100644 --- a/inc/helpers/user-center-renderers/profile-credits.php +++ b/inc/helpers/user-center-renderers/profile-credits.php @@ -2,7 +2,7 @@ /** * User center assets panel orchestrator (wallet + points). * - * Zibll user-tab-balance parity layout. + * Zibll user-tab-balance parity: dual cards + tabbed ledgers. * * @package AKSRT */ @@ -22,15 +22,16 @@ if ($show_wallet || $show_credits) :
- +
- +
@@ -42,6 +43,88 @@ if (!empty($show_vip)) { } if ($show_credits) { require $dir . '/credits-signin.php'; - require $dir . '/credits-transactions.php'; } + +// Tabbed ledgers (Zibll record tabs). +if ($show_wallet || $show_credits) : + $active_record = isset($_GET['asset_tab']) ? sanitize_key(wp_unslash($_GET['asset_tab'])) : ''; + if ($active_record === '' || !in_array($active_record, ['balance', 'points'], true)) { + $active_record = $show_wallet ? 'balance' : 'points'; + } + ?> +
+
    + +
  • + +
  • + + +
  • + +
  • + +
+
+ +
> + $user_id, 'page' => $wallet_page, 'per_page' => 10]) + : ['items' => [], 'total' => 0]; + ?> +
+ +
+ = 0 ? 'is-positive' : 'is-negative'; + $delta_txt = ($delta > 0 ? '+' : '') . (class_exists('AKSRT_Balance') ? AKSRT_Balance::format($delta) : number_format_i18n($delta, 2)); + ?> +
+
+ +
+
+
+ + + () + +
+
+
+
+ +
+
+ +
+ 1 && function_exists('aksrt_render_profile_pagination')) { + aksrt_render_profile_pagination($total_pages, $wallet_page, 'wallet_page'); + } + ?> + +
+

+
+ +
+
+ + +
> + +
+ +
+
+ From 4da003663d0a151324f206f5de1a04ddfb929781 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 16 Jul 2026 16:28:56 +0800 Subject: [PATCH 2/4] fix(pay): refund wallet on withdraw reject and show pending history Close the freeze/refund loop when admin rejects a balance withdrawal, block concurrent open applications, and surface processing state plus server-rendered withdraw history on the wallet card. --- assets/css/routes/user-center/credits.css | 38 ++ assets/js/credits.js | 26 +- .../class-aksrt-admin-pay-withdrawals.php | 34 +- inc/classes/class-aksrt-balance-service.php | 4 + inc/classes/class-aksrt-withdrawal.php | 475 +++++++++++++----- .../user-center-renderers/credits-wallet.php | 73 ++- 6 files changed, 514 insertions(+), 136 deletions(-) diff --git a/assets/css/routes/user-center/credits.css b/assets/css/routes/user-center/credits.css index 9f29d0c7..07ddc9f7 100644 --- a/assets/css/routes/user-center/credits.css +++ b/assets/css/routes/user-center/credits.css @@ -928,3 +928,41 @@ body.uc-modal-open { } .uc-card-pass__tip.is-error { color: #b91c1c; } + + +/* Withdraw history + pending foot */ +.uc-asset-card__foot { + margin-top: 8px; + padding-top: 8px; + border-top: 1px dashed var(--muted-border-color, rgba(0,0,0,.08)); +} +.uc-asset-card__foot button { + background: none; + border: 0; + padding: 0; + cursor: pointer; +} +.uc-asset-card__head-action { + background: none; + border: 0; + cursor: pointer; +} +.uc-withdraw-history__item:last-child { + border-bottom: 0 !important; +} + + +/* Points tasks tab */ +.uc-points-tasks .border-bottom, +.uc-points-date .border-bottom, +.uc-withdraw-history__item.border-bottom { + border-bottom: 1px solid var(--muted-border-color, rgba(0,0,0,.08)); +} +.uc-points-tasks button, +.uc-points-date button { + background: none; + border: 0; + cursor: pointer; + padding: 0; +} +.focus-color { color: var(--focus-color, #f59e0b); } diff --git a/assets/js/credits.js b/assets/js/credits.js index 36bc8fb8..efbe0433 100644 --- a/assets/js/credits.js +++ b/assets/js/credits.js @@ -908,10 +908,17 @@ link.addEventListener('click', function (e) { e.preventDefault(); var key = link.getAttribute('data-aksrt-asset-tab'); - document.querySelectorAll('[data-aksrt-asset-tab]').forEach(function (a) { + document.querySelectorAll('.uc-asset-tabs__nav [data-aksrt-asset-tab]').forEach(function (a) { var li = a.closest('li'); - if (li) li.classList.toggle('active', a === link); + if (!li) return; + var aKey = a.getAttribute('data-aksrt-asset-tab'); + var on = aKey === key || (key === 'points_date' && aKey === 'points_free'); + li.classList.toggle('active', on); }); + var tabs = document.querySelector('.uc-asset-tabs'); + if (tabs && typeof tabs.scrollIntoView === 'function') { + tabs.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } document.querySelectorAll('[data-aksrt-asset-pane]').forEach(function (pane) { var on = pane.getAttribute('data-aksrt-asset-pane') === key; pane.classList.toggle('is-active', on); @@ -921,6 +928,20 @@ }); } + + function initWithdrawHistory() { + var modal = document.querySelector('[data-aksrt-withdraw-history-modal]'); + if (!modal) return; + function open() { modal.hidden = false; document.body.classList.add('uc-modal-open'); } + function close() { modal.hidden = true; document.body.classList.remove('uc-modal-open'); } + document.querySelectorAll('[data-aksrt-withdraw-history-open]').forEach(function (btn) { + btn.addEventListener('click', function (e) { e.preventDefault(); open(); }); + }); + modal.querySelectorAll('[data-aksrt-withdraw-history-close]').forEach(function (btn) { + btn.addEventListener('click', function (e) { e.preventDefault(); close(); }); + }); + } + function bootAssets() { initSignin(); initInvite(); @@ -932,6 +953,7 @@ initExchange(); initPointsBuy(); initWithdraw(); + initWithdrawHistory(); initAssetTabs(); } diff --git a/inc/classes/admin/native/class-aksrt-admin-pay-withdrawals.php b/inc/classes/admin/native/class-aksrt-admin-pay-withdrawals.php index f80e8a42..08197e28 100644 --- a/inc/classes/admin/native/class-aksrt-admin-pay-withdrawals.php +++ b/inc/classes/admin/native/class-aksrt-admin-pay-withdrawals.php @@ -107,13 +107,27 @@ class AKSRT_Admin_Pay_Withdrawals { $user_id = (int) ($row['user_id'] ?? 0); $user = $user_id > 0 ? get_userdata($user_id) : null; $st = (string) ($row['status'] ?? ''); + $gross = (float) ($row['gross_amount'] ?? $row['amount'] ?? 0); + $net = (float) ($row['amount'] ?? 0); + $fee = (int) ($row['fee_rate'] ?? 0); + $acct = (string) ($row['display_account'] ?? $row['account_info'] ?? ''); + $amount_txt = number_format($net, 2); + if ($gross > 0 && abs($gross - $net) > 0.001) { + $amount_txt = sprintf( + /* translators: 1: gross 2: net 3: fee percent */ + __('申请 %1$s / 到账 %2$s(手续费 %3$d%%)', 'aksrt'), + number_format($gross, 2), + number_format($net, 2), + $fee + ); + } echo ''; echo '' . esc_html((string) $id) . ''; echo '' . ($user ? esc_html($user->display_name . ' (#' . $user_id . ')') : esc_html((string) $user_id)) . ''; - echo '' . esc_html(number_format((float) ($row['amount'] ?? 0), 2)) . ''; + echo '' . esc_html($amount_txt) . ''; echo '' . esc_html((string) ($row['method'] ?? '')) . ''; - echo '' . esc_html((string) ($row['account_info'] ?? '')) . ''; + echo '' . esc_html($acct) . ''; echo '' . esc_html($status_options[$st] ?? $st) . ''; echo '' . esc_html((string) ($row['created_at'] ?? '')) . ''; echo ''; @@ -169,13 +183,18 @@ class AKSRT_Admin_Pay_Withdrawals { $note = isset($_POST['note']) ? sanitize_textarea_field((string) wp_unslash($_POST['note'])) : ''; check_admin_referer('aksrt_pay_update_withdrawal_' . $id); - $ok = false; + $msg = 'withdraw_failed'; if ($id > 0 && $new_status !== '' && class_exists('AKSRT_Withdrawal')) { - $ok = AKSRT_Withdrawal::get_instance()->update_status($id, $new_status, $note); + $result = AKSRT_Withdrawal::get_instance()->process_status_change($id, $new_status, $note); + if (!is_wp_error($result) && $result) { + $msg = 'withdraw_updated'; + } elseif (is_wp_error($result) && $result->get_error_code() === 'aksrt_balance') { + $msg = 'withdraw_refund_failed'; + } } wp_safe_redirect($this->page_url('aksrt_withdraw', [ - 'aksrt_pay_msg' => $ok ? 'withdraw_updated' : 'withdraw_failed', + 'aksrt_pay_msg' => $msg, ])); exit; } @@ -208,8 +227,9 @@ class AKSRT_Admin_Pay_Withdrawals { } $code = sanitize_key((string) wp_unslash($_GET['aksrt_pay_msg'])); $map = [ - 'withdraw_updated' => __('提现状态已更新。', 'aksrt'), - 'withdraw_failed' => __('提现状态更新失败。', 'aksrt'), + 'withdraw_updated' => __('提现状态已更新。', 'aksrt'), + 'withdraw_failed' => __('提现状态更新失败。', 'aksrt'), + 'withdraw_refund_failed' => __('状态未更新:余额退回失败,请检查钱包后重试。', 'aksrt'), ]; if (!isset($map[$code])) { return; diff --git a/inc/classes/class-aksrt-balance-service.php b/inc/classes/class-aksrt-balance-service.php index e7f22892..f4452fa2 100644 --- a/inc/classes/class-aksrt-balance-service.php +++ b/inc/classes/class-aksrt-balance-service.php @@ -734,6 +734,10 @@ class AKSRT_Balance_Service { if ($account === '') { return new WP_Error('aksrt_withdraw_account', __('请填写收款账号信息', 'aksrt'), ['status' => 400]); } + // One open balance withdrawal at a time (Zibll parity). + if (AKSRT_Withdrawal::get_instance()->has_open_withdrawal($user_id)) { + return new WP_Error('aksrt_withdraw_pending', __('您有处理中的提现申请,请等待完成后再申请', 'aksrt'), ['status' => 409]); + } $wallet = AKSRT_Balance::balance($user_id); if ($wallet < $amount) { return new WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), ['status' => 402]); diff --git a/inc/classes/class-aksrt-withdrawal.php b/inc/classes/class-aksrt-withdrawal.php index 413ed541..8c8a363c 100644 --- a/inc/classes/class-aksrt-withdrawal.php +++ b/inc/classes/class-aksrt-withdrawal.php @@ -1,8 +1,6 @@ insert($this->table(), [ - 'user_id' => $user_id, - 'amount' => $amount, - 'method' => sanitize_key($method), - 'account_info' => sanitize_textarea_field($account_info), - 'status' => self::STATUS_PENDING, - 'created_at' => current_time('mysql'), - ], ['%d', '%f', '%s', '%s', '%s', '%s']); + $wpdb->insert($this->table(), [ + 'user_id' => $user_id, + 'amount' => $amount, + 'method' => sanitize_key($method), + 'account_info' => sanitize_textarea_field($account_info), + 'status' => self::STATUS_PENDING, + 'created_at' => current_time('mysql'), + ], ['%d', '%f', '%s', '%s', '%s', '%s']); - return (int) $wpdb->insert_id; - } + return (int) $wpdb->insert_id; + } - /** - * 更新提现状态 - */ - public function update_status(int $withdrawal_id, string $new_status, string $note = ''): bool { - global $wpdb; + /** + * Get single row. + * + * @return array|null + */ + public function get(int $withdrawal_id): ?array { + global $wpdb; + if ($withdrawal_id <= 0) { + return null; + } + $row = $wpdb->get_row( + $wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d LIMIT 1", $withdrawal_id), + ARRAY_A + ); + return is_array($row) ? $row : null; + } - $update = ['status' => sanitize_key($new_status)]; - $format = ['%s']; + /** + * 更新提现状态(无余额副作用)。优先使用 process_status_change()。 + */ + public function update_status(int $withdrawal_id, string $new_status, string $note = ''): bool { + global $wpdb; - if ($new_status === self::STATUS_COMPLETED || $new_status === self::STATUS_REJECTED) { - $update['processed_at'] = current_time('mysql'); - $format[] = '%s'; - } + $update = ['status' => sanitize_key($new_status)]; + $format = ['%s']; - if ($note !== '') { - $update['note'] = sanitize_textarea_field($note); - $format[] = '%s'; - } + if ($new_status === self::STATUS_COMPLETED || $new_status === self::STATUS_REJECTED) { + $update['processed_at'] = current_time('mysql'); + $format[] = '%s'; + } - return $wpdb->update($this->table(), $update, ['id' => $withdrawal_id], $format, ['%d']) !== false; - } + if ($note !== '') { + $update['note'] = sanitize_textarea_field($note); + $format[] = '%s'; + } - /** - * 获取用户提现列表 - */ - public function get_user_withdrawals(int $user_id, array $args = []): array { - global $wpdb; + return $wpdb->update($this->table(), $update, ['id' => $withdrawal_id], $format, ['%d']) !== false; + } - $where = ['user_id = %d']; - $params = [$user_id]; + /** + * Status transition with balance side-effects. + * Reject of balance-source holds refunds gross_amount once (idempotent). + * + * @return true|WP_Error + */ + public function process_status_change(int $withdrawal_id, string $new_status, string $note = '') { + $new_status = sanitize_key($new_status); + $allowed = [ + self::STATUS_PENDING, + self::STATUS_APPROVED, + self::STATUS_PROCESSING, + self::STATUS_COMPLETED, + self::STATUS_REJECTED, + ]; + if ($withdrawal_id <= 0 || !in_array($new_status, $allowed, true)) { + return new WP_Error('aksrt_withdraw_status', __('提现状态无效', 'aksrt')); + } - $status = sanitize_key((string) ($args['status'] ?? '')); - if ($status !== '') { - $where[] = 'status = %s'; - $params[] = $status; - } + $row = $this->get($withdrawal_id); + if (!$row) { + return new WP_Error('aksrt_withdraw_missing', __('提现记录不存在', 'aksrt')); + } - $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); - $offset = max(0, (int) ($args['offset'] ?? 0)); + $old_status = sanitize_key((string) ($row['status'] ?? '')); + if ($old_status === $new_status && $note === '') { + return true; + } - $sql = "SELECT * FROM {$this->table()} WHERE " . implode(' AND ', $where) . " ORDER BY created_at DESC LIMIT %d OFFSET %d"; - $params[] = $limit; - $params[] = $offset; + $meta = $this->parse_account_meta((string) ($row['account_info'] ?? '')); + $user_id = (int) ($row['user_id'] ?? 0); - return (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); - } + // Refund frozen balance when moving into rejected (once). + if ( + $new_status === self::STATUS_REJECTED + && $old_status !== self::STATUS_REJECTED + && empty($meta['balance_refunded']) + && $this->is_balance_source($meta) + && class_exists('AKSRT_Balance') + && $user_id > 0 + ) { + $gross = $this->resolve_gross_amount($row, $meta); + if ($gross > 0) { + $refund = AKSRT_Balance::add( + $user_id, + $gross, + 'withdraw_refund', + ['type' => 'withdraw', 'id' => $withdrawal_id], + sprintf(__('提现拒绝退回 #%d', 'aksrt'), $withdrawal_id) + ); + if (is_wp_error($refund)) { + return $refund; + } + $meta['balance_refunded'] = true; + $meta['refunded_at'] = current_time('mysql'); + } + } - /** - * 获取所有提现列表(管理后台) - */ - public function get_all(array $args = []): array { - global $wpdb; + $account_info = $this->encode_account_meta($meta, (string) ($row['account_info'] ?? '')); + $merged_note = $this->merge_note((string) ($row['note'] ?? ''), $note); - $where = []; - $params = []; + global $wpdb; + $update = [ + 'status' => $new_status, + 'account_info' => $account_info, + ]; + $format = ['%s', '%s']; - $status = sanitize_key((string) ($args['status'] ?? '')); - if ($status !== '') { - $where[] = 'status = %s'; - $params[] = $status; - } + if ($new_status === self::STATUS_COMPLETED || $new_status === self::STATUS_REJECTED) { + $update['processed_at'] = current_time('mysql'); + $format[] = '%s'; + } + if ($merged_note !== '') { + $update['note'] = $merged_note; + $format[] = '%s'; + } - $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); - $offset = max(0, (int) ($args['offset'] ?? 0)); + $ok = $wpdb->update($this->table(), $update, ['id' => $withdrawal_id], $format, ['%d']) !== false; + return $ok ? true : new WP_Error('aksrt_withdraw_update', __('提现状态更新失败', 'aksrt')); + } - $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; - $sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; - $params[] = $limit; - $params[] = $offset; + /** + * Whether user has open (pending/processing/approved) withdrawals. + */ + public function has_open_withdrawal(int $user_id): bool { + return $this->count_open($user_id) > 0; + } - if (!empty($params)) { - return (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); - } - return (array) $wpdb->get_results($sql, ARRAY_A); - } + /** + * 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','approved','processing')", + $user_id + )); + } - /** - * 获取提现总数(支持状态过滤) - */ - public function get_count(array $args = []): int { - global $wpdb; + /** + * Sum of frozen gross amounts still open (balance source). + */ + public function sum_pending_hold(int $user_id): float { + global $wpdb; + if ($user_id <= 0) { + return 0.0; + } + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT amount, account_info FROM {$this->table()} + WHERE user_id = %d AND status IN ('pending','approved','processing')", + $user_id + ), ARRAY_A); + $total = 0.0; + foreach ((array) $rows as $row) { + $meta = $this->parse_account_meta((string) ($row['account_info'] ?? '')); + if (!$this->is_balance_source($meta) || !empty($meta['balance_refunded'])) { + continue; + } + $total += $this->resolve_gross_amount($row, $meta); + } + return round($total, 2); + } - $status = sanitize_key((string) ($args['status'] ?? '')); - if ($status !== '') { - return (int) $wpdb->get_var($wpdb->prepare( - "SELECT COUNT(*) FROM {$this->table()} WHERE status = %s", - $status - )); - } - return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$this->table()}"); - } + /** + * 获取用户提现列表 + * + * @param array{status?:string,limit?:int,offset?:int,open_only?:bool} $args + * @return list> + */ + public function get_user_withdrawals(int $user_id, array $args = []): array { + global $wpdb; - /** - * 获取提现统计 - */ - public function get_stats(): array { - global $wpdb; + $where = ['user_id = %d']; + $params = [$user_id]; - $row = $wpdb->get_row( - "SELECT - COUNT(*) as total, - SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending, - 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 - FROM {$this->table()}", - ARRAY_A - ); + if (!empty($args['open_only'])) { + $where[] = "status IN ('pending','approved','processing')"; + } else { + $status = sanitize_key((string) ($args['status'] ?? '')); + if ($status !== '') { + $where[] = 'status = %s'; + $params[] = $status; + } + } - return [ - 'total' => (int) ($row['total'] ?? 0), - 'pending' => (int) ($row['pending'] ?? 0), - 'completed' => (int) ($row['completed'] ?? 0), - 'total_paid' => (float) ($row['total_paid'] ?? 0), - ]; - } + $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); + $offset = max(0, (int) ($args['offset'] ?? 0)); + + $sql = "SELECT * FROM {$this->table()} WHERE " . implode(' AND ', $where) . ' ORDER BY created_at DESC LIMIT %d OFFSET %d'; + $params[] = $limit; + $params[] = $offset; + + $rows = (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); + return array_map([$this, 'normalize_row'], $rows); + } + + /** + * 获取所有提现列表(管理后台) + * + * @return list> + */ + public function get_all(array $args = []): array { + global $wpdb; + + $where = []; + $params = []; + + $status = sanitize_key((string) ($args['status'] ?? '')); + if ($status !== '') { + $where[] = 'status = %s'; + $params[] = $status; + } + + $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); + $offset = max(0, (int) ($args['offset'] ?? 0)); + + $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; + $sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; + $params[] = $limit; + $params[] = $offset; + + if (!empty($params)) { + $rows = (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); + } else { + $rows = (array) $wpdb->get_results($sql, ARRAY_A); + } + return array_map([$this, 'normalize_row'], $rows); + } + + public function get_count(array $args = []): int { + global $wpdb; + + $status = sanitize_key((string) ($args['status'] ?? '')); + if ($status !== '') { + return (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$this->table()} WHERE status = %s", + $status + )); + } + return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$this->table()}"); + } + + public function get_stats(): array { + global $wpdb; + + $row = $wpdb->get_row( + "SELECT + COUNT(*) as total, + SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending, + 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 + FROM {$this->table()}", + ARRAY_A + ); + + return [ + 'total' => (int) ($row['total'] ?? 0), + 'pending' => (int) ($row['pending'] ?? 0), + 'completed' => (int) ($row['completed'] ?? 0), + 'total_paid' => (float) ($row['total_paid'] ?? 0), + ]; + } + + /** + * @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['fee_rate'] = (int) ($meta['fee_rate'] ?? 0); + $row['display_account'] = (string) ($meta['account'] ?? $row['account_info'] ?? ''); + $row['source'] = (string) ($meta['source'] ?? 'balance'); + return $row; + } + + /** + * @return array + */ + private function parse_account_meta(string $raw): array { + $raw = trim($raw); + if ($raw === '') { + return []; + } + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : ['account' => $raw]; + } + + /** + * @param array $meta + */ + private function encode_account_meta(array $meta, string $fallback_raw): string { + if ($meta === []) { + return $fallback_raw; + } + $json = wp_json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + return is_string($json) ? $json : $fallback_raw; + } + + /** + * @param array $meta + */ + private function is_balance_source(array $meta): bool { + $source = sanitize_key((string) ($meta['source'] ?? 'balance')); + // Empty / balance = cash wallet freeze path. + return $source === '' || $source === 'balance'; + } + + /** + * @param array $row + * @param array $meta + */ + private function resolve_gross_amount(array $row, array $meta): float { + if (isset($meta['gross_amount'])) { + return max(0, round((float) $meta['gross_amount'], 2)); + } + // Legacy rows: amount was net or gross without meta. + return max(0, round((float) ($row['amount'] ?? 0), 2)); + } + + private function merge_note(string $old, string $new): string { + $new = sanitize_textarea_field($new); + if ($new === '') { + return sanitize_textarea_field($old); + } + $old = sanitize_textarea_field($old); + if ($old === '') { + return $new; + } + return $old . "\n" . $new; + } } diff --git a/inc/helpers/user-center-renderers/credits-wallet.php b/inc/helpers/user-center-renderers/credits-wallet.php index 462fe5c7..f33b7d7d 100644 --- a/inc/helpers/user-center-renderers/credits-wallet.php +++ b/inc/helpers/user-center-renderers/credits-wallet.php @@ -26,6 +26,14 @@ $exchange_on = !empty($recharge['exchange']['enabled']); $all_settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_all() : []; $withdraw_on = !empty($all_settings['pay_balance_withdraw_s']); $withdraw_min = max(1, (float) ($all_settings['pay_balance_withdraw_lowest_money'] ?? $all_settings['pay_rebate_withdraw_lowest_money'] ?? 50)); +$withdraw_pending_hold = 0.0; +$withdraw_history = []; +if ($withdraw_on && class_exists('AKSRT_Withdrawal')) { + $wd_svc = AKSRT_Withdrawal::get_instance(); + $withdraw_pending_hold = $wd_svc->sum_pending_hold((int) ($user_id ?? 0)); + $withdraw_history = $wd_svc->get_user_withdrawals((int) ($user_id ?? 0), ['limit' => 30]); +} +$withdraw_processing = $withdraw_pending_hold > 0; $card_pass_balance = ['enabled' => false, 'desc' => '', 'only_password' => true]; if (class_exists('AKSRT_Charge_Card')) { $opts = AKSRT_Charge_Card::get_instance()->get_public_options(); @@ -49,6 +57,12 @@ $mark = (string) ($charge_view['currency_mark'] ?? '元');
+ + +
@@ -60,7 +74,11 @@ $mark = (string) ($charge_view['currency_mark'] ?? '元'); - + + + + +
@@ -74,6 +92,12 @@ $mark = (string) ($charge_view['currency_mark'] ?? '元'); + +
+ + +
+
@@ -231,6 +255,53 @@ $mark = (string) ($charge_view['currency_mark'] ?? '元'); + + __('待处理', 'aksrt'), + 'approved' => __('已通过', 'aksrt'), + 'processing' => __('处理中', 'aksrt'), + 'completed' => __('已完成', 'aksrt'), + 'rejected' => __('已拒绝', 'aksrt'), + ]; + ?> + + +
From 15a883eb54976f908798e5c7bb3257ace7fb5e81 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 16 Jul 2026 16:29:15 +0800 Subject: [PATCH 3/4] feat(credits): free points tasks matrix, day cap, and tasks tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AKSRT_Points_Free for catalog/day-max/daily detail, wire the full earn matrix (login, post, like, favorite, follow, comment, forum), expose 积分任务 + 每日详情 tabs, and expand admin free-task settings. --- .../src/schema/sections/users/credits.js | 41 +- inc/bootstrap.php | 2 + .../schema/class-aksrt-admin-schema-users.php | 22 +- inc/classes/class-aksrt-comment-actions.php | 3 + inc/classes/class-aksrt-credits-earn.php | 424 ++++++++++-------- inc/classes/class-aksrt-follow.php | 6 +- inc/classes/class-aksrt-points-free.php | 283 ++++++++++++ inc/classes/class-aksrt-signin.php | 15 +- inc/helpers/aksrt-credits.php | 42 +- inc/helpers/aksrt-frontend-tags.php | 6 + .../user-center-renderers/credits-balance.php | 1 + .../credits-points-tasks.php | 95 ++++ .../user-center-renderers/profile-credits.php | 20 +- 13 files changed, 728 insertions(+), 232 deletions(-) create mode 100644 inc/classes/class-aksrt-points-free.php create mode 100644 inc/helpers/user-center-renderers/credits-points-tasks.php diff --git a/assets/admin/src/schema/sections/users/credits.js b/assets/admin/src/schema/sections/users/credits.js index b9da1ef7..0199f2ca 100644 --- a/assets/admin/src/schema/sections/users/credits.js +++ b/assets/admin/src/schema/sections/users/credits.js @@ -9,12 +9,20 @@ export const creditsSchema = { points_rate: 100, points_name: '积分', points_decimal: 2, - points_earn_comment: 1, - points_earn_post: 5, - points_earn_login: 1, - points_earn_like_received: 1, - points_earn_invite: 10, points_earn_daily_limit: 50, + points_free_sign_up: 10, + points_free_sign_in: 1, + points_free_login: 1, + points_free_post_new: 5, + points_free_post_like: 1, + points_free_post_favorite: 1, + points_free_comment_new: 1, + points_free_comment_like: 1, + points_free_followed: 1, + points_free_bbs_posts_new: 5, + points_free_bbs_reply: 2, + points_free_bbs_essence: 10, + points_earn_invite: 10, points_spend_comment: 0, points_spend_download: 0, points_spend_view_hidden: 0, @@ -50,15 +58,22 @@ export const creditsSchema = { }, { key: 'earn', - title: '积分获取', - icon: 'fas fa-arrow-up', + title: '积分获取 / 任务', fields: [ - { key: 'points_earn_comment', label: '评论奖励', type: 'number', default: 1, min: 0, max: 100 }, - { key: 'points_earn_post', label: '发布文章奖励', type: 'number', default: 5, min: 0, max: 100 }, - { key: 'points_earn_login', label: '每日登录奖励', type: 'number', default: 1, min: 0, max: 100 }, - { key: 'points_earn_like_received', label: '收到点赞奖励', type: 'number', default: 1, min: 0, max: 100 }, - { key: 'points_earn_invite', label: '邀请注册奖励', type: 'number', default: 10, min: 0, max: 1000 }, - { key: 'points_earn_daily_limit', label: '每日获取上限', type: 'number', default: 50, min: 0, max: 9999, help: '0为不限制' }, + { key: 'points_earn_daily_limit', label: '每日免费积分上限', type: 'number', default: 50, min: 0, max: 9999, help: '0为不限制' }, + { key: 'points_free_sign_up', label: '注册奖励', type: 'number', default: 10, min: 0, max: 10000 }, + { key: 'points_free_sign_in', label: '签到展示分值', type: 'number', default: 1, min: 0, max: 1000 }, + { key: 'points_free_login', label: '每日登录', type: 'number', default: 1, min: 0, max: 100 }, + { key: 'points_free_post_new', label: '发布文章', type: 'number', default: 5, min: 0, max: 1000 }, + { key: 'points_free_post_like', label: '文章被赞', type: 'number', default: 1, min: 0, max: 100 }, + { key: 'points_free_post_favorite', label: '文章被收藏', type: 'number', default: 1, min: 0, max: 100 }, + { key: 'points_free_comment_new', label: '发表评论', type: 'number', default: 1, min: 0, max: 100 }, + { key: 'points_free_comment_like', label: '评论被赞', type: 'number', default: 1, min: 0, max: 100 }, + { key: 'points_free_followed', label: '被关注', type: 'number', default: 1, min: 0, max: 100 }, + { key: 'points_free_bbs_posts_new', label: '论坛发帖', type: 'number', default: 5, min: 0, max: 1000 }, + { key: 'points_free_bbs_reply', label: '论坛回复', type: 'number', default: 2, min: 0, max: 1000 }, + { key: 'points_free_bbs_essence', label: '帖子加精', type: 'number', default: 10, min: 0, max: 1000 }, + { key: 'points_earn_invite', label: '邀请注册奖励', type: 'number', default: 10, min: 0, max: 1000, help: '邀请不计入免费日上限' }, ], }, { diff --git a/inc/bootstrap.php b/inc/bootstrap.php index f74a3a50..e5573087 100644 --- a/inc/bootstrap.php +++ b/inc/bootstrap.php @@ -216,6 +216,7 @@ require_once AKSRT_DIR . '/inc/classes/class-aksrt-email-rollback.php'; // 原生用户菜单:积分管理 require_once AKSRT_DIR . '/inc/classes/class-aksrt-user-credits-admin.php'; +require_once AKSRT_DIR . '/inc/classes/class-aksrt-user-balance-admin.php'; // 加载数据表迁移器(CMS / 积分 / 下载 token) require_once AKSRT_DIR . '/inc/classes/db-traits/trait-aksrt-db-migrator-part-2.php'; @@ -227,6 +228,7 @@ require_once AKSRT_DIR . '/inc/classes/class-aksrt-credits.php'; // 站内现金钱包(与积分分离) require_once AKSRT_DIR . '/inc/classes/class-aksrt-balance.php'; require_once AKSRT_DIR . '/inc/classes/class-aksrt-balance-service.php'; +require_once AKSRT_DIR . '/inc/classes/class-aksrt-points-free.php'; require_once AKSRT_DIR . '/inc/classes/class-aksrt-credits-earn.php'; require_once AKSRT_DIR . '/inc/classes/class-aksrt-signin.php'; require_once AKSRT_DIR . '/inc/classes/class-aksrt-invite.php'; diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-users.php b/inc/classes/admin/schema/class-aksrt-admin-schema-users.php index f2d4ac62..86055c6a 100644 --- a/inc/classes/admin/schema/class-aksrt-admin-schema-users.php +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-users.php @@ -414,13 +414,21 @@ class AKSRT_Admin_Schema_Users { ['key' => 'points_name', 'label' => '积分名称', 'type' => 'input', 'default' => '积分'], ['key' => 'points_decimal', 'label' => '积分小数位数', 'type' => 'number', 'default' => 2, 'min' => 0, 'max' => 4], ]], - ['key' => 'earn', 'title' => '积分获取', 'fields' => [ - ['key' => 'points_earn_comment', 'label' => '评论奖励', 'type' => 'number', 'default' => 1, 'min' => 0, 'max' => 100], - ['key' => 'points_earn_post', 'label' => '发布文章奖励', 'type' => 'number', 'default' => 5, 'min' => 0, 'max' => 100], - ['key' => 'points_earn_login', 'label' => '每日登录奖励', 'type' => 'number', 'default' => 1, 'min' => 0, 'max' => 100], - ['key' => 'points_earn_like_received', 'label' => '收到点赞奖励', 'type' => 'number', 'default' => 1, 'min' => 0, 'max' => 100], - ['key' => 'points_earn_invite', 'label' => '邀请注册奖励', 'type' => 'number', 'default' => 10, 'min' => 0, 'max' => 1000], - ['key' => 'points_earn_daily_limit', 'label' => '每日获取上限', 'type' => 'number', 'default' => 50, 'min' => 0, 'max' => 9999, 'help' => '0为不限制'], + ['key' => 'earn', 'title' => '积分获取 / 任务', 'fields' => [ + ['key' => 'points_earn_daily_limit', 'label' => '每日免费积分上限', 'type' => 'number', 'default' => 50, 'min' => 0, 'max' => 9999, 'help' => '0为不限制;免费任务合计'], + ['key' => 'points_free_sign_up', 'label' => '注册奖励', 'type' => 'number', 'default' => 10, 'min' => 0, 'max' => 10000], + ['key' => 'points_free_sign_in', 'label' => '签到展示分值', 'type' => 'number', 'default' => 1, 'min' => 0, 'max' => 1000], + ['key' => 'points_free_login', 'label' => '每日登录', 'type' => 'number', 'default' => 1, 'min' => 0, 'max' => 100], + ['key' => 'points_free_post_new', 'label' => '发布文章', 'type' => 'number', 'default' => 5, 'min' => 0, 'max' => 1000], + ['key' => 'points_free_post_like', 'label' => '文章被赞', 'type' => 'number', 'default' => 1, 'min' => 0, 'max' => 100], + ['key' => 'points_free_post_favorite', 'label' => '文章被收藏', 'type' => 'number', 'default' => 1, 'min' => 0, 'max' => 100], + ['key' => 'points_free_comment_new', 'label' => '发表评论', 'type' => 'number', 'default' => 1, 'min' => 0, 'max' => 100], + ['key' => 'points_free_comment_like', 'label' => '评论被赞', 'type' => 'number', 'default' => 1, 'min' => 0, 'max' => 100], + ['key' => 'points_free_followed', 'label' => '被关注', 'type' => 'number', 'default' => 1, 'min' => 0, 'max' => 100], + ['key' => 'points_free_bbs_posts_new', 'label' => '论坛发帖', 'type' => 'number', 'default' => 5, 'min' => 0, 'max' => 1000], + ['key' => 'points_free_bbs_reply', 'label' => '论坛回复', 'type' => 'number', 'default' => 2, 'min' => 0, 'max' => 1000], + ['key' => 'points_free_bbs_essence', 'label' => '帖子加精', 'type' => 'number', 'default' => 10, 'min' => 0, 'max' => 1000], + ['key' => 'points_earn_invite', 'label' => '邀请注册奖励', 'type' => 'number', 'default' => 10, 'min' => 0, 'max' => 1000, 'help' => '邀请奖励不走免费日上限'], ]], ['key' => 'spend', 'title' => '积分消费', 'fields' => [ ['key' => 'points_spend_comment', 'label' => '评论消耗', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 100], diff --git a/inc/classes/class-aksrt-comment-actions.php b/inc/classes/class-aksrt-comment-actions.php index 41ecb7e8..712188fc 100644 --- a/inc/classes/class-aksrt-comment-actions.php +++ b/inc/classes/class-aksrt-comment-actions.php @@ -526,6 +526,9 @@ class AKSRT_Comment_Actions { if ($target !== $current) { aksrt_set_comment_like_state($comment_id, $target); $count = aksrt_update_comment_counter($comment_id, 'aksrt_comment_likes', $target ? 1 : -1); + if (is_user_logged_in()) { + do_action('aksrt_comment_liked', $comment_id, get_current_user_id(), (bool) $target); + } } else { $count = aksrt_get_comment_likes($comment_id); } diff --git a/inc/classes/class-aksrt-credits-earn.php b/inc/classes/class-aksrt-credits-earn.php index c5c38f1d..877bafe9 100644 --- a/inc/classes/class-aksrt-credits-earn.php +++ b/inc/classes/class-aksrt-credits-earn.php @@ -1,13 +1,6 @@ 'user', 'id' => $user_id]); + } - /** - * 论坛发帖积分奖励 - */ - public function on_forum_topic_created(int $user_id, int $post_id): void { - if (!class_exists('AKSRT_Credits') || !class_exists('AKSRT_Settings')) return; - $cfg = AKSRT_Settings::get_credit_settings(); - if (empty($cfg['enabled'])) return; - $reward = $cfg['forum_topic_reward'] ?? []; - if (empty($reward['enabled'])) return; + /** + * Daily first login free points. + * + * @param string $user_login + * @param WP_User $user + */ + public function on_wp_login($user_login, $user = null): void { + unset($user_login); + $user_id = $user instanceof WP_User ? (int) $user->ID : 0; + if ($user_id <= 0 || !class_exists('AKSRT_Points_Free')) { + return; + } + $today = (new DateTimeImmutable('today', wp_timezone()))->format('Y-m-d'); + $last = (string) get_user_meta($user_id, AKSRT_Points_Free::META_LOGIN, true); + if ($last === $today) { + return; + } + $granted = AKSRT_Points_Free::grant($user_id, 'login', 0, ['type' => 'user', 'id' => $user_id]); + if (!is_wp_error($granted) && $granted >= 0) { + update_user_meta($user_id, AKSRT_Points_Free::META_LOGIN, $today); + } + } - $amount = (int) ($reward['amount'] ?? 0); - if ($amount <= 0) return; + /** + * @param string $new_status + * @param string $old_status + * @param WP_Post $post + */ + public function on_post_status($new_status, $old_status, $post): void { + if (!($post instanceof WP_Post) || $post->post_type !== 'post') { + return; + } + if ($new_status !== 'publish' || $old_status === 'publish') { + return; + } + $user_id = (int) $post->post_author; + if ($user_id <= 0 || !class_exists('AKSRT_Points_Free')) { + return; + } + if (get_post_meta($post->ID, 'aksrt_credit_post_rewarded', true)) { + return; + } + $granted = AKSRT_Points_Free::grant($user_id, 'post_new', 0, ['type' => 'post', 'id' => (int) $post->ID]); + if (!is_wp_error($granted) && $granted > 0) { + update_post_meta($post->ID, 'aksrt_credit_post_rewarded', 1); + } + } - $daily_limit = (int) ($reward['daily_limit'] ?? 5); - $today_count = $this->today_forum_reward_count($user_id, 'topic'); - if ($today_count >= $daily_limit) return; + public function on_comment_inserted(int $comment_id, $comment): void { + $cfg = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_credit_settings() : []; + $reward = is_array($cfg['comment_reward'] ?? null) ? $cfg['comment_reward'] : []; + // Legacy on_approved=true → wait for transition; default true. + $on_approved = !isset($reward['on_approved']) || !empty($reward['on_approved']); + if ($on_approved) { + return; + } + $comment = $comment instanceof WP_Comment ? $comment : get_comment($comment_id); + if (!($comment instanceof WP_Comment) || (int) $comment->comment_approved !== 1) { + return; + } + $this->reward_comment($comment); + } - AKSRT_Credits::add($user_id, $amount, 'forum_topic', ['type' => 'post', 'id' => $post_id]); - } + public function on_comment_transition($new_status, $old_status, $comment): void { + unset($old_status); + if ($new_status !== 'approved' || !($comment instanceof WP_Comment)) { + return; + } + $this->reward_comment($comment); + } - /** - * 今日论坛奖励次数 - */ - private function today_forum_reward_count(int $user_id, string $type = 'topic'): int { - global $wpdb; - $table = AKSRT_DB_Migrator::table('credit_log'); - if (!$table) return 0; + private function reward_comment(WP_Comment $comment): void { + $user_id = (int) $comment->user_id; + if ($user_id <= 0 || !class_exists('AKSRT_Points_Free')) { + return; + } + if (get_comment_meta($comment->comment_ID, 'aksrt_credit_rewarded', true)) { + return; + } + // Skip forum topic replies (handled separately). + $post = get_post((int) $comment->comment_post_ID); + if ($post && $post->post_type === 'aksrt_forum_topic') { + return; + } + $cfg = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_credit_settings() : []; + $reward = is_array($cfg['comment_reward'] ?? null) ? $cfg['comment_reward'] : []; + $min_length = (int) ($reward['min_length'] ?? 0); + if ($min_length > 0 && mb_strlen(wp_strip_all_tags((string) $comment->comment_content)) < $min_length) { + return; + } + $granted = AKSRT_Points_Free::grant($user_id, 'comment_new', 0, [ + 'type' => 'comment', + 'id' => (int) $comment->comment_ID, + ]); + if (!is_wp_error($granted)) { + update_comment_meta($comment->comment_ID, 'aksrt_credit_rewarded', $granted > 0 ? 1 : 'capped'); + } + } - $today = current_time('Y-m-d'); - return (int) $wpdb->get_var($wpdb->prepare( - "SELECT COUNT(*) FROM {$table} WHERE user_id = %d AND reason = %s AND DATE(created_at) = %s", - $user_id, 'forum_' . $type, $today - )); - } + /** + * @param int $post_id + * @param int $actor_id liker + * @param bool $active + */ + public function on_post_liked(int $post_id, int $actor_id, bool $active): void { + if (!$active || $post_id <= 0 || !class_exists('AKSRT_Points_Free')) { + return; + } + $post = get_post($post_id); + if (!$post || $post->post_type !== 'post') { + return; + } + $author = (int) $post->post_author; + if ($author <= 0 || $author === $actor_id) { + return; + } + AKSRT_Points_Free::grant($author, 'post_like', 0, ['type' => 'post', 'id' => $post_id]); + } - /** - * 注册赠送。 - */ - public function on_user_register(int $user_id): void { - if (!class_exists('AKSRT_Credits') || !class_exists('AKSRT_Settings')) return; - $cfg = AKSRT_Settings::get_credit_settings(); - if (empty($cfg['enabled'])) return; - $bonus = (int) ($cfg['register_bonus'] ?? 0); - if ($bonus <= 0) return; + public function on_post_favorited(int $post_id, int $actor_id, bool $active): void { + if (!$active || $post_id <= 0 || !class_exists('AKSRT_Points_Free')) { + return; + } + $post = get_post($post_id); + if (!$post || $post->post_type !== 'post') { + return; + } + $author = (int) $post->post_author; + if ($author <= 0 || $author === $actor_id) { + return; + } + AKSRT_Points_Free::grant($author, 'post_favorite', 0, ['type' => 'post', 'id' => $post_id]); + } - AKSRT_Credits::add($user_id, $bonus, 'register', ['type' => 'user', 'id' => $user_id]); - } + public function on_comment_liked(int $comment_id, int $actor_id, bool $active): void { + if (!$active || $comment_id <= 0 || !class_exists('AKSRT_Points_Free')) { + return; + } + $comment = get_comment($comment_id); + if (!$comment) { + return; + } + $author = (int) $comment->user_id; + if ($author <= 0 || $author === $actor_id) { + return; + } + AKSRT_Points_Free::grant($author, 'comment_like', 0, ['type' => 'comment', 'id' => $comment_id]); + } - /** - * 评论插入(直接发表无需审核场景)。 - */ - public function on_comment_inserted(int $comment_id, $comment): void { - if (!class_exists('AKSRT_Settings')) return; - $cfg = AKSRT_Settings::get_credit_settings(); - $reward = $cfg['comment_reward'] ?? []; - if (empty($cfg['enabled']) || empty($reward['enabled'])) return; + public function on_user_followed(int $follower_id, int $following_id): void { + if ($following_id <= 0 || $follower_id === $following_id || !class_exists('AKSRT_Points_Free')) { + return; + } + AKSRT_Points_Free::grant($following_id, 'followed', 0, ['type' => 'user', 'id' => $follower_id]); + } - // 仅审核通过后发放 → 不在 insert 时处理 - if (!empty($reward['on_approved'])) return; + public function on_forum_topic_created(int $user_id, int $post_id): void { + if ($user_id <= 0 || !class_exists('AKSRT_Points_Free')) { + return; + } + AKSRT_Points_Free::grant($user_id, 'bbs_posts_new', 0, ['type' => 'post', 'id' => $post_id]); + } - $comment = $comment instanceof WP_Comment ? $comment : get_comment($comment_id); - if (!($comment instanceof WP_Comment)) return; + public function on_forum_reply_posted(int $comment_id, $approved, array $commentdata = []): void { + unset($commentdata); + if ((int) $approved !== 1 && $approved !== '1' && $approved !== true) { + // Still load comment and check. + } + $comment = get_comment($comment_id); + if (!$comment || (int) $comment->comment_approved !== 1) { + return; + } + $post = get_post((int) $comment->comment_post_ID); + if (!$post || $post->post_type !== 'aksrt_forum_topic') { + return; + } + $user_id = (int) $comment->user_id; + if ($user_id <= 0 || !class_exists('AKSRT_Points_Free')) { + return; + } + if (get_comment_meta($comment_id, 'aksrt_credit_forum_reply', true)) { + return; + } + $granted = AKSRT_Points_Free::grant($user_id, 'bbs_reply', 0, ['type' => 'comment', 'id' => $comment_id]); + if (!is_wp_error($granted)) { + update_comment_meta($comment_id, 'aksrt_credit_forum_reply', 1); + } + } - // 只奖励已批准的评论 - if ((int) $comment->comment_approved !== 1) return; - - $this->maybe_reward_comment($comment, $reward); - } - - /** - * 评论状态流转。 - */ - public function on_comment_transition($new_status, $old_status, $comment): void { - if (!class_exists('AKSRT_Settings')) return; - $cfg = AKSRT_Settings::get_credit_settings(); - $reward = $cfg['comment_reward'] ?? []; - if (empty($cfg['enabled']) || empty($reward['enabled'])) return; - if (empty($reward['on_approved'])) return; - - if ($new_status !== 'approved') return; - // 跳过已经处理过的:以 commentmeta 标记 - if (!($comment instanceof WP_Comment)) return; - - $this->maybe_reward_comment($comment, $reward); - } - - /** - * 真正的评论奖励逻辑(含字数 / 每日上限 / 去重)。 - */ - private function maybe_reward_comment(WP_Comment $comment, array $reward): void { - $user_id = (int) $comment->user_id; - if ($user_id <= 0) return; - - // 防重复:commentmeta 标记 - if (get_comment_meta($comment->comment_ID, 'aksrt_credit_rewarded', true)) return; - - $min_length = (int) ($reward['min_length'] ?? 0); - if ($min_length > 0 && mb_strlen(strip_tags($comment->comment_content)) < $min_length) return; - - $amount = (int) ($reward['amount'] ?? 0); - if ($amount <= 0) return; - - // 每日上限 - $cap = (int) ($reward['daily_cap'] ?? 0); - if ($cap > 0) { - $today_count = $this->today_comment_reward_count($user_id); - if ($today_count >= $cap) { - update_comment_meta($comment->comment_ID, 'aksrt_credit_rewarded', 'capped'); - return; - } - } - - $result = AKSRT_Credits::add( - $user_id, - $amount, - 'comment', - ['type' => 'comment', 'id' => (int) $comment->comment_ID] - ); - if (!is_wp_error($result)) { - update_comment_meta($comment->comment_ID, 'aksrt_credit_rewarded', 1); - } - } - - /** - * 当日评论奖励次数(查询 credit_log)。 - */ - private function today_comment_reward_count(int $user_id): int { - global $wpdb; - if (!class_exists('AKSRT_DB_Migrator')) return 0; - $table = AKSRT_DB_Migrator::table('credit_log'); - if (!$table) return 0; - - // 站点时区当日 00:00 起 - $tz = wp_timezone(); - $today = (new DateTimeImmutable('today', $tz))->format('Y-m-d H:i:s'); - // 转 UTC(数据库 created_at 用 mysql UTC) - $today_utc = (new DateTimeImmutable('today', $tz))->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'); - - return (int) $wpdb->get_var($wpdb->prepare( - "SELECT COUNT(*) FROM {$table} WHERE user_id = %d AND reason = 'comment' AND created_at >= %s", - $user_id, - $today_utc - )); - } - - // ================================================================ - // Forum credits (§7.7) - // ================================================================ - - public function on_forum_reply_posted(int $comment_id, $approved, array $commentdata = []): void { - if (!class_exists('AKSRT_Credits') || !class_exists('AKSRT_Settings')) return; - if (!class_exists('AKSRT_Forum')) return; - - $comment = get_comment($comment_id); - if (!$comment) return; - - $post = get_post((int) $comment->comment_post_ID); - if (!$post || $post->post_type !== 'aksrt_forum_topic') return; - - if ((int) $comment->comment_approved !== 1) return; - - $user_id = (int) $comment->user_id; - if ($user_id <= 0) return; - - $forum_cfg = AKSRT_Settings::get_forum_settings(); - $amount = (int) ($forum_cfg['credits']['comment_new'] ?? 2); - if ($amount <= 0) return; - - $daily_max = (int) ($forum_cfg['credits']['daily_max'] ?? 50); - $today_count = $this->today_forum_reward_count($user_id, 'reply'); - if ($today_count >= $daily_max) return; - - AKSRT_Credits::add($user_id, $amount, 'forum_reply', [ - 'type' => 'comment', - 'id' => $comment_id, - ]); - } - - public function on_forum_topic_featured(int $topic_id, int $operator_id): void { - // Essence reward is handled in AKSRT_Forum::reward_essence(). - // This hook is for additional integrations if needed. - } + public function on_forum_topic_featured(int $topic_id, int $operator_id): void { + unset($operator_id); + $post = get_post($topic_id); + if (!$post || $post->post_type !== 'aksrt_forum_topic') { + return; + } + $user_id = (int) $post->post_author; + if ($user_id <= 0 || !class_exists('AKSRT_Points_Free')) { + return; + } + if (get_post_meta($topic_id, 'aksrt_credit_essence_rewarded', true)) { + return; + } + $granted = AKSRT_Points_Free::grant($user_id, 'bbs_essence', 0, ['type' => 'post', 'id' => $topic_id]); + if (!is_wp_error($granted) && $granted > 0) { + update_post_meta($topic_id, 'aksrt_credit_essence_rewarded', 1); + } + } } diff --git a/inc/classes/class-aksrt-follow.php b/inc/classes/class-aksrt-follow.php index 28cbeab0..75d543c8 100644 --- a/inc/classes/class-aksrt-follow.php +++ b/inc/classes/class-aksrt-follow.php @@ -47,7 +47,11 @@ class AKSRT_Follow { 'created_at' => current_time('mysql'), ], ['%d', '%d', '%s']); - return $result !== false; + if ($result === false) { + return false; + } + do_action('aksrt_user_followed', $follower_id, $following_id); + return true; } /** diff --git a/inc/classes/class-aksrt-points-free.php b/inc/classes/class-aksrt-points-free.php new file mode 100644 index 00000000..b4d1bae7 --- /dev/null +++ b/inc/classes/class-aksrt-points-free.php @@ -0,0 +1,283 @@ + [label, short_desc, default_points] + * + * @return array + */ + public static function catalog(): array { + return [ + 'sign_up' => [__('注册奖励', 'aksrt'), __('新用户注册成功后发放', 'aksrt'), 10], + 'sign_in' => [__('每日签到', 'aksrt'), __('完成每日签到', 'aksrt'), 1], + 'login' => [__('每日登录', 'aksrt'), __('每日首次登录', 'aksrt'), 1], + 'followed' => [__('被关注', 'aksrt'), __('被其他用户关注', 'aksrt'), 1], + 'post_new' => [__('发布文章', 'aksrt'), __('发布并通过审核的文章', 'aksrt'), 5], + 'post_like' => [__('文章被赞', 'aksrt'), __('你的文章被点赞', 'aksrt'), 1], + 'post_favorite' => [__('文章被收藏', 'aksrt'), __('你的文章被收藏', 'aksrt'), 1], + 'comment_new' => [__('发表评论', 'aksrt'), __('发表并通过审核的评论', 'aksrt'), 1], + 'comment_like' => [__('评论被赞', 'aksrt'), __('你的评论被点赞', 'aksrt'), 1], + 'bbs_posts_new' => [__('论坛发帖', 'aksrt'), __('发布论坛主题', 'aksrt'), 5], + 'bbs_reply' => [__('论坛回复', 'aksrt'), __('回复论坛主题', 'aksrt'), 2], + 'bbs_essence' => [__('帖子加精', 'aksrt'), __('主题被设为精华', 'aksrt'), 10], + ]; + } + + /** + * Runtime free-task settings from theme options. + * + * @return array{day_max:int,tasks:array} + */ + public static function settings(): array { + $all = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_all() : []; + $credit = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_credit_settings() : []; + + $tasks = []; + foreach (self::catalog() as $key => $meta) { + $flat_key = 'points_free_' . $key; + if (array_key_exists($flat_key, $all)) { + $tasks[$key] = max(0, (int) $all[$flat_key]); + continue; + } + // Fallback mapping from legacy / parallel settings. + $tasks[$key] = self::legacy_task_points($key, $all, $credit, (int) $meta[2]); + } + + $day_max = array_key_exists('points_earn_daily_limit', $all) + ? max(0, (int) $all['points_earn_daily_limit']) + : 50; + + return [ + 'day_max' => $day_max, + 'tasks' => $tasks, + ]; + } + + /** + * Points configured for a task (0 = disabled). + */ + public static function task_points(string $key): int { + $cfg = self::settings(); + return max(0, (int) ($cfg['tasks'][$key] ?? 0)); + } + + /** + * Whether free points system can run (credits enabled). + */ + public static function enabled(): bool { + return class_exists('AKSRT_Settings') && AKSRT_Settings::credits_enabled() && class_exists('AKSRT_Credits'); + } + + /** + * Today's free points total. + */ + public static function today_total(int $user_id): int { + if ($user_id <= 0) { + return 0; + } + $detail = self::detail_map($user_id); + $today = self::today_key(); + return max(0, (int) ($detail[$today] ?? 0)); + } + + /** + * Remaining free points allowed today (PHP_INT_MAX if unlimited). + */ + public static function remaining_today(int $user_id): int { + $day_max = (int) self::settings()['day_max']; + if ($day_max <= 0) { + return PHP_INT_MAX; + } + return max(0, $day_max - self::today_total($user_id)); + } + + /** + * Daily free points detail (newest first). + * + * @return array + */ + public static function date_detail(int $user_id): array { + $detail = self::detail_map($user_id); + krsort($detail, SORT_STRING); + return $detail; + } + + /** + * Grant free points for a task. Respects day_max; clamps amount. + * + * @param array{type?:string,id?:int} $ref + * @return int|WP_Error granted amount (0 if skipped) + */ + public static function grant(int $user_id, string $task_key, int $points = 0, array $ref = []) { + if (!self::enabled() || $user_id <= 0) { + return 0; + } + $task_key = sanitize_key($task_key); + if ($task_key === '') { + return 0; + } + if ($points <= 0) { + $points = self::task_points($task_key); + } + $points = max(0, (int) $points); + if ($points <= 0) { + return 0; + } + + $remaining = self::remaining_today($user_id); + if ($remaining <= 0) { + return 0; + } + if ($points > $remaining) { + $points = $remaining; + } + + $reason = 'free_' . $task_key; + $result = AKSRT_Credits::add( + $user_id, + $points, + $reason, + $ref, + self::catalog()[$task_key][0] ?? $task_key + ); + if (is_wp_error($result)) { + return $result; + } + + self::bump_detail($user_id, $points); + return $points; + } + + /** + * Grant with explicit amount (e.g. signin streak reward), still under day_max. + * + * @return int|WP_Error + */ + public static function grant_amount(int $user_id, int $points, string $task_key, array $ref = []) { + return self::grant($user_id, $task_key, $points, $ref); + } + + /** + * Task list DTO for user center UI. + * + * @return list + */ + public static function task_list_for_user(int $user_id = 0): array { + unset($user_id); // reserved for per-user progress later + $cfg = self::settings(); + $list = []; + foreach (self::catalog() as $key => $meta) { + $pts = (int) ($cfg['tasks'][$key] ?? 0); + // Hide sign_up from daily task list (one-time); still grant on register. + if ($key === 'sign_up') { + continue; + } + if ($pts <= 0) { + continue; + } + $list[] = [ + 'key' => $key, + 'title' => $meta[0], + 'desc' => $meta[1], + 'points' => $pts, + 'enabled' => true, + ]; + } + return $list; + } + + private static function today_key(): string { + return (new DateTimeImmutable('today', wp_timezone()))->format('Y-m-d'); + } + + /** + * @return array + */ + private static function detail_map(int $user_id): array { + $raw = get_user_meta($user_id, self::META_DETAIL, true); + if (!is_array($raw)) { + return []; + } + $out = []; + foreach ($raw as $day => $val) { + $day = is_string($day) ? $day : (string) $day; + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $day)) { + continue; + } + $out[$day] = max(0, (int) $val); + } + return $out; + } + + private static function bump_detail(int $user_id, int $points): void { + if ($points <= 0) { + return; + } + $detail = self::detail_map($user_id); + $today = self::today_key(); + $detail[$today] = max(0, (int) ($detail[$today] ?? 0)) + $points; + // Keep ~50 newest days. + krsort($detail, SORT_STRING); + $detail = array_slice($detail, 0, 50, true); + update_user_meta($user_id, self::META_DETAIL, $detail); + } + + /** + * @param array $all + * @param array $credit + */ + private static function legacy_task_points(string $key, array $all, array $credit, int $default): int { + switch ($key) { + case 'sign_up': + return max(0, (int) ($credit['register_bonus'] ?? $default)); + case 'sign_in': + $signin = is_array($credit['daily_signin'] ?? null) ? $credit['daily_signin'] : []; + if (isset($signin['enabled']) && empty($signin['enabled'])) { + return 0; + } + return max(0, (int) ($signin['base'] ?? $default)); + case 'login': + return max(0, (int) ($all['points_earn_login'] ?? $default)); + case 'post_new': + return max(0, (int) ($all['points_earn_post'] ?? $default)); + case 'post_like': + return max(0, (int) ($all['points_earn_like_received'] ?? $default)); + case 'comment_new': + if (isset($all['points_earn_comment'])) { + return max(0, (int) $all['points_earn_comment']); + } + $cr = is_array($credit['comment_reward'] ?? null) ? $credit['comment_reward'] : []; + if (isset($cr['enabled']) && empty($cr['enabled'])) { + return 0; + } + return max(0, (int) ($cr['amount'] ?? $default)); + case 'bbs_posts_new': + if (class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_forum_settings')) { + $f = AKSRT_Settings::get_forum_settings(); + return max(0, (int) ($f['credits']['post_new'] ?? $f['credits']['topic_new'] ?? $all['bbs_credits_post_new'] ?? $default)); + } + return max(0, (int) ($all['bbs_credits_post_new'] ?? $default)); + case 'bbs_reply': + if (class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_forum_settings')) { + $f = AKSRT_Settings::get_forum_settings(); + return max(0, (int) ($f['credits']['comment_new'] ?? $default)); + } + return $default; + default: + return $default; + } + } +} diff --git a/inc/classes/class-aksrt-signin.php b/inc/classes/class-aksrt-signin.php index 2c8e9612..4ebd0431 100644 --- a/inc/classes/class-aksrt-signin.php +++ b/inc/classes/class-aksrt-signin.php @@ -130,8 +130,19 @@ class AKSRT_Signin { return new WP_Error('aksrt_signin_dup', __('签到失败', 'aksrt'), ['status' => 409]); } - if ($reward > 0 && class_exists('AKSRT_Credits')) { - AKSRT_Credits::add($user_id, $reward, 'signin', ['type' => 'user', 'id' => $user_id]); + if ($reward > 0) { + if (class_exists('AKSRT_Points_Free')) { + $granted = AKSRT_Points_Free::grant_amount($user_id, $reward, 'sign_in', ['type' => 'user', 'id' => $user_id]); + if (is_wp_error($granted)) { + if (class_exists('AKSRT_Credits')) { + AKSRT_Credits::add($user_id, $reward, 'signin', ['type' => 'user', 'id' => $user_id]); + } + } else { + $reward = (int) $granted; + } + } elseif (class_exists('AKSRT_Credits')) { + AKSRT_Credits::add($user_id, $reward, 'signin', ['type' => 'user', 'id' => $user_id]); + } } return new WP_REST_Response([ diff --git a/inc/helpers/aksrt-credits.php b/inc/helpers/aksrt-credits.php index 3de1cc9a..f5919a1f 100644 --- a/inc/helpers/aksrt-credits.php +++ b/inc/helpers/aksrt-credits.php @@ -39,15 +39,39 @@ if (!function_exists('aksrt_credits_label')) { */ function aksrt_credits_label(string $reason): string { $map = [ - 'register' => __('注册赠送', 'aksrt'), - 'signin' => __('每日签到', 'aksrt'), - 'comment' => __('发表评论', 'aksrt'), - 'invite' => __('邀请奖励', 'aksrt'), - 'invitee' => __('被邀请奖励', 'aksrt'), - 'download' => __('下载消费', 'aksrt'), - 'admin' => __('管理员调整', 'aksrt'), - 'refund' => __('退款', 'aksrt'), + 'register' => __('注册赠送', 'aksrt'), + 'signin' => __('每日签到', 'aksrt'), + 'comment' => __('发表评论', 'aksrt'), + 'invite' => __('邀请奖励', 'aksrt'), + 'invitee' => __('被邀请奖励', 'aksrt'), + 'download' => __('下载消费', 'aksrt'), + 'admin' => __('管理员调整', 'aksrt'), + 'refund' => __('退款', 'aksrt'), + 'forum_topic' => __('论坛发帖', 'aksrt'), + 'forum_reply' => __('论坛回复', 'aksrt'), + 'free_sign_up' => __('注册奖励', 'aksrt'), + 'free_sign_in' => __('每日签到', 'aksrt'), + 'free_login' => __('每日登录', 'aksrt'), + 'free_followed'=> __('被关注', 'aksrt'), + 'free_post_new'=> __('发布文章', 'aksrt'), + 'free_post_like'=> __('文章被赞', 'aksrt'), + 'free_post_favorite'=> __('文章被收藏', 'aksrt'), + 'free_comment_new'=> __('发表评论', 'aksrt'), + 'free_comment_like'=> __('评论被赞', 'aksrt'), + 'free_bbs_posts_new'=> __('论坛发帖', 'aksrt'), + 'free_bbs_reply'=> __('论坛回复', 'aksrt'), + 'free_bbs_essence'=> __('帖子加精', 'aksrt'), ]; - return $map[$reason] ?? $reason; + if (isset($map[$reason])) { + return $map[$reason]; + } + if (str_starts_with($reason, 'free_') && class_exists('AKSRT_Points_Free')) { + $key = substr($reason, 5); + $cat = AKSRT_Points_Free::catalog(); + if (isset($cat[$key][0])) { + return (string) $cat[$key][0]; + } + } + return $reason; } } diff --git a/inc/helpers/aksrt-frontend-tags.php b/inc/helpers/aksrt-frontend-tags.php index 6ca0e03b..1a3e94d1 100644 --- a/inc/helpers/aksrt-frontend-tags.php +++ b/inc/helpers/aksrt-frontend-tags.php @@ -775,6 +775,9 @@ if (!function_exists('aksrt_toggle_like_ajax')) : aksrt_set_interaction_cookie(aksrt_get_interaction_cookie_name('like', $post_id), $target); } $count = aksrt_update_post_counter($post_id, '_aksrt_like_count', $target ? 1 : -1); + if (is_user_logged_in()) { + do_action('aksrt_post_liked', $post_id, get_current_user_id(), (bool) $target); + } } else { $count = aksrt_get_post_likes($post_id); } @@ -801,6 +804,9 @@ if (!function_exists('aksrt_toggle_favorite_ajax')) : aksrt_set_interaction_cookie(aksrt_get_interaction_cookie_name('favorite', $post_id), $target); } $count = aksrt_update_post_counter($post_id, '_aksrt_favorite_count', $target ? 1 : -1); + if (is_user_logged_in()) { + do_action('aksrt_post_favorited', $post_id, get_current_user_id(), (bool) $target); + } } else { $count = aksrt_get_post_favorites($post_id); } diff --git a/inc/helpers/user-center-renderers/credits-balance.php b/inc/helpers/user-center-renderers/credits-balance.php index 83628e61..84b9f4dd 100644 --- a/inc/helpers/user-center-renderers/credits-balance.php +++ b/inc/helpers/user-center-renderers/credits-balance.php @@ -50,6 +50,7 @@ $currency = (string) ($currency_name ?? __('积分', 'aksrt'));

+ + + +
+
+
+ +
+ + $val) : ?> +
+
+
+
+
+ + +

+ +
+ +
+ 0 || $day_max > 0) : ?> +
+
+
+
+ +' . esc_html((string) $today) . '' + ); + ?> +
+
+ 0) { + if ($today >= $day_max) { + printf(esc_html__('今日获取的免费积分已达到 %s,将不再获取', 'aksrt'), esc_html((string) $day_max)); + } else { + printf(esc_html__('每日可免费获取 %s 积分,超过后将不再获取', 'aksrt'), esc_html((string) $day_max)); + } + } else { + esc_html_e('免费积分无每日上限', 'aksrt'); + } + ?> +
+
+ +
+
+ + + + +
+
+
+
+
+
+ + + + +
+
+ + +

+ +
+ diff --git a/inc/helpers/user-center-renderers/profile-credits.php b/inc/helpers/user-center-renderers/profile-credits.php index 410355fd..2b9d59a6 100644 --- a/inc/helpers/user-center-renderers/profile-credits.php +++ b/inc/helpers/user-center-renderers/profile-credits.php @@ -48,9 +48,10 @@ if ($show_credits) { // Tabbed ledgers (Zibll record tabs). if ($show_wallet || $show_credits) : $active_record = isset($_GET['asset_tab']) ? sanitize_key(wp_unslash($_GET['asset_tab'])) : ''; - if ($active_record === '' || !in_array($active_record, ['balance', 'points'], true)) { + if ($active_record === '' || !in_array($active_record, ['balance', 'points', 'points_free', 'points_date'], true)) { $active_record = $show_wallet ? 'balance' : 'points'; } + $show_tasks = $show_credits && class_exists('AKSRT_Points_Free') && AKSRT_Points_Free::enabled(); ?>
    @@ -64,6 +65,11 @@ if ($show_wallet || $show_credits) : + +
  • + +
  • +
@@ -100,6 +106,10 @@ if ($show_wallet || $show_credits) :
+ + + +
@@ -123,6 +133,14 @@ if ($show_wallet || $show_credits) : + +
> + +
+
> + +
+ Date: Thu, 16 Jul 2026 16:29:15 +0800 Subject: [PATCH 4/4] feat(admin): cash wallet adjust on user profile Mirror credits admin with list column, profile form, and balance ledger for manual wallet adjustments. --- functions.php | 3 + .../class-aksrt-user-balance-admin.php | 162 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 inc/classes/class-aksrt-user-balance-admin.php diff --git a/functions.php b/functions.php index c1b8e16f..5be1371d 100644 --- a/functions.php +++ b/functions.php @@ -360,6 +360,9 @@ if (class_exists('AKSRT_Email_Rollback')) { if (class_exists('AKSRT_User_Credits_Admin')) { AKSRT_User_Credits_Admin::get_instance()->init(); } +if (class_exists('AKSRT_User_Balance_Admin')) { + AKSRT_User_Balance_Admin::get_instance()->init(); +} // WordPress 原生侧栏:支付/订单运营中心(zibll zibpay 对齐) if (is_admin() && class_exists('AKSRT_Admin_Pay_Menus')) { diff --git a/inc/classes/class-aksrt-user-balance-admin.php b/inc/classes/class-aksrt-user-balance-admin.php new file mode 100644 index 00000000..06f6bde5 --- /dev/null +++ b/inc/classes/class-aksrt-user-balance-admin.php @@ -0,0 +1,162 @@ +' . esc_html(AKSRT_Balance::format(AKSRT_Balance::balance($user_id))) . ''; + } + + public function sortable(array $columns): array { + $columns['aksrt_wallet_balance'] = 'aksrt_wallet_balance'; + return $columns; + } + + public function sort_users($query): void { + if (!is_admin() || $query->get('orderby') !== 'aksrt_wallet_balance') { + return; + } + $query->set('meta_key', 'aksrt_balance'); + $query->set('orderby', 'meta_value_num'); + } + + public function render_section(WP_User $user): void { + if (!class_exists('AKSRT_Balance') || !AKSRT_Balance::enabled()) { + return; + } + $balance = AKSRT_Balance::balance((int) $user->ID); + $can = $this->can_manage(); + $log = AKSRT_Balance::get_log(['user_id' => (int) $user->ID, 'page' => 1, 'per_page' => 8]); + ?> + +

+ + + + + + + + + + + + + + + + + can_manage() || !class_exists('AKSRT_Balance') || !AKSRT_Balance::enabled()) { + return; + } + if (empty($_POST['_aksrt_balance_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash((string) $_POST['_aksrt_balance_nonce'])), 'aksrt_user_balance_adjust_' . $user_id)) { + return; + } + if (!isset($_POST['aksrt_balance_delta']) || $_POST['aksrt_balance_delta'] === '') { + return; + } + $delta = round((float) wp_unslash($_POST['aksrt_balance_delta']), 2); + if (abs($delta) < 0.0001) { + return; + } + $reason = isset($_POST['aksrt_balance_reason']) + ? sanitize_text_field(wp_unslash((string) $_POST['aksrt_balance_reason'])) + : __('管理员调整', 'aksrt'); + if ($delta > 0) { + $result = AKSRT_Balance::add($user_id, $delta, 'admin', ['type' => 'user', 'id' => get_current_user_id()], $reason); + } else { + $result = AKSRT_Balance::deduct($user_id, abs($delta), 'admin', ['type' => 'user', 'id' => get_current_user_id()], $reason); + } + if (is_wp_error($result)) { + set_transient('aksrt_admin_notice_' . get_current_user_id(), [[ + 'type' => 'error', + 'msg' => $result->get_error_message(), + ]], 30); + return; + } + set_transient('aksrt_admin_notice_' . get_current_user_id(), [[ + 'type' => 'success', + 'msg' => __('现金余额已更新', 'aksrt'), + ]], 30); + } +}