diff --git a/assets/js/cms-download.js b/assets/js/cms-download.js index 4211a84a..ad9613a9 100644 --- a/assets/js/cms-download.js +++ b/assets/js/cms-download.js @@ -366,8 +366,13 @@ }; } - function buildPreviewUrl(postId, priceType, couponCode) { - var previewUrl = REST_BASE + '/pay/content/' + postId; + function payApiBase(apiKind) { + return apiKind === 'topic' ? '/pay/topic/' : '/pay/content/'; + } + + function buildPreviewUrl(postId, priceType, couponCode, apiKind) { + apiKind = apiKind || 'content'; + var previewUrl = REST_BASE + payApiBase(apiKind) + postId; var qs = []; if (priceType) qs.push('price_type=' + encodeURIComponent(priceType)); if (couponCode) qs.push('coupon_code=' + encodeURIComponent(couponCode)); @@ -375,8 +380,8 @@ return previewUrl; } - function fetchPreview(postId, priceType, couponCode) { - return fetch(buildPreviewUrl(postId, priceType, couponCode), { + function fetchPreview(postId, priceType, couponCode, apiKind) { + return fetch(buildPreviewUrl(postId, priceType, couponCode, apiKind), { method: 'GET', credentials: 'same-origin', headers: restHeaders(), @@ -385,17 +390,18 @@ }); } - function openCashier(postId, returnFocusEl, priceType, couponCode) { + function openCashier(postId, returnFocusEl, priceType, couponCode, apiKind) { if (!postId) return; priceType = priceType || ''; couponCode = couponCode || ''; + apiKind = apiKind || 'content'; open( '
' + tt('loading', '加载中…') + '
', returnFocusEl ); - fetchPreview(postId, priceType, couponCode) + fetchPreview(postId, priceType, couponCode, apiKind) .then(function (r) { if (!r.ok || (r.json && r.json.code)) { const code = r.json && r.json.code ? r.json.code : ''; @@ -418,6 +424,7 @@ var prev = r.json || {}; if (priceType) prev._price_type = priceType; if (couponCode) prev._coupon_code = couponCode; + prev._api_kind = apiKind; renderCashier(prev, returnFocusEl); }) .catch(function () { @@ -435,13 +442,19 @@ renderError('', { vip_url: preview.vip_url }, returnFocusEl, 'aksrt_need_vip'); return; } - if (preview.has_paid_access || preview.is_free) { + if (preview.has_paid_access) { + window.location.reload(); + return; + } + // Free VIP topics still need a free-order unlock POST; content free can reload. + if (preview.is_free && preview.pay_type !== 'topic') { window.location.reload(); return; } const isPoints = preview.pay_modo === 'points'; const priceType = (preview._price_type || preview.price_type || ''); + const apiKind = preview._api_kind || (preview.pay_type === 'topic' ? 'topic' : 'content'); let amountLabel = escapeHtml(preview.amount_label || ''); let baseLabel = escapeHtml(preview.base_label || ''); const listLabel = escapeHtml(preview.list_amount_label || preview.amount_label || amountLabel); @@ -541,7 +554,7 @@ checkBtn.disabled = true; const old = checkBtn.innerHTML; checkBtn.innerHTML = tt('checking', '检查中…'); - fetchPreview(preview.post_id, priceType, code) + fetchPreview(preview.post_id, priceType, code, apiKind) .then(function (r) { checkBtn.disabled = false; checkBtn.innerHTML = old; @@ -552,6 +565,7 @@ var prev = r.json || {}; if (priceType) prev._price_type = priceType; prev._coupon_code = code; + prev._api_kind = apiKind; renderCashier(prev, returnFocusEl); }) .catch(function () { @@ -571,13 +585,14 @@ if (checked) paymentMethod = checked.value; const input = document.querySelector('input[name="aksrt_coupon_code"]'); const code = input ? String(input.value || '').trim() : (preview.coupon_code || preview._coupon_code || ''); - submitPurchase(preview.post_id, mode, paymentMethod, submitBtn, returnFocusEl, code); + submitPurchase(preview.post_id, mode, paymentMethod, submitBtn, returnFocusEl, code, apiKind); }); } } - function submitPurchase(postId, mode, paymentMethod, button, returnFocusEl, couponCode) { + function submitPurchase(postId, mode, paymentMethod, button, returnFocusEl, couponCode, apiKind) { if (!postId) return; + apiKind = apiKind || 'content'; const original = button.innerHTML; button.disabled = true; button.innerHTML = tt('processing', '处理中…'); @@ -588,7 +603,7 @@ if (pt) payload.price_type = pt; if (mode === 'cash' && couponCode) payload.coupon_code = couponCode; - fetch(REST_BASE + '/pay/content/' + postId, { + fetch(REST_BASE + payApiBase(apiKind) + postId, { method: 'POST', credentials: 'same-origin', headers: restHeaders(), @@ -643,6 +658,14 @@ return; } + const topicBuyBtn = e.target.closest('[data-aksrt-pay-topic]'); + if (topicBuyBtn) { + e.preventDefault(); + const topicId = parseInt(topicBuyBtn.getAttribute('data-aksrt-pay-topic') || '0', 10); + if (topicId) openCashier(topicId, topicBuyBtn, '', '', 'topic'); + return; + } + const buyBtn = e.target.closest('[data-action="aksrt-need-purchase"], [data-aksrt-pay-content]'); if (buyBtn) { e.preventDefault(); @@ -654,7 +677,7 @@ 10 ); const priceType = buyBtn.getAttribute('data-price-type') || ''; - if (postId) openCashier(postId, buyBtn, priceType); + if (postId) openCashier(postId, buyBtn, priceType, '', 'content'); return; } @@ -662,7 +685,7 @@ if (cashierLink && cashierLink.hasAttribute('data-aksrt-pay-content')) { e.preventDefault(); const postId = parseInt(cashierLink.getAttribute('data-aksrt-pay-content') || '0', 10); - if (postId) openCashier(postId, cashierLink); + if (postId) openCashier(postId, cashierLink, '', '', 'content'); return; } diff --git a/assets/js/forum-governance.js b/assets/js/forum-governance.js index 581617b1..fe03b0b2 100644 --- a/assets/js/forum-governance.js +++ b/assets/js/forum-governance.js @@ -675,49 +675,6 @@ }); } - function bindPurchaseButton(button) { - button.addEventListener('click', function () { - var topicId = parseInt(button.getAttribute('data-topic-id') || '0', 10); - var price = button.getAttribute('data-price') || '0'; - var isFree = button.getAttribute('data-free') === '1'; - var feedback = button.parentElement ? button.parentElement.querySelector('.js-aksrt-action-feedback') : null; - - if (!topicId) { - return; - } - - var confirmMsg = isFree - ? '确认使用 VIP 免费解锁此主题?' - : ('确认使用余额支付 ¥' + price + ' 购买此主题?'); - if (!window.confirm(confirmMsg)) { - return; - } - - button.disabled = true; - setFeedback(feedback, isFree ? '正在解锁…' : '正在支付…', 'is-loading'); - - var data = new FormData(); - data.append('action', 'aksrt_forum_purchase_topic'); - data.append('topic_id', topicId); - data.append('_nonce', rootData.forumNonce || ''); - - fetch((rootData.ajaxUrl || '/wp-admin/admin-ajax.php'), { - method: 'POST', - body: data, - credentials: 'same-origin', - }).then(function (r) { return r.json(); }).then(function (payload) { - if (!payload.success) { - throw new Error((payload.data && payload.data.message) || '购买失败'); - } - setFeedback(feedback, '购买成功,正在刷新…', 'is-success'); - setTimeout(function () { window.location.reload(); }, 800); - }).catch(function (error) { - setFeedback(feedback, error.message || '购买失败', 'is-error'); - button.disabled = false; - }); - }); - } - function formatFollowPayLabel(pay) { pay = pay || {}; if (pay.is_free) { @@ -1558,7 +1515,6 @@ document.querySelectorAll('.js-aksrt-topic-score').forEach(bindScoreButton); document.querySelectorAll('.js-aksrt-adopt-answer').forEach(bindAdoptButton); document.querySelectorAll('.js-aksrt-unlock-topic').forEach(bindUnlockButton); - document.querySelectorAll('.js-aksrt-purchase-topic').forEach(bindPurchaseButton); document.querySelectorAll('.js-aksrt-follow-plate').forEach(bindFollowPlateButton); bindForumCommentEngine(); bindForumHomeTabs(); diff --git a/docs/zibll-gap-matrix.md b/docs/zibll-gap-matrix.md index bafe9f1c..6af6ad7e 100644 --- a/docs/zibll-gap-matrix.md +++ b/docs/zibll-gap-matrix.md @@ -99,8 +99,8 @@ Note: zibll「商品管理」`zibpay_product_page` is **paid article sales**, no | 2b Shipping/after-sale/coupons/income/rebate | done | | 3 users.php submenus | done | | 4 SPA strip | done (ops routes redirect to native) | -| 5+ behavior parity | done — native ops shell + plate follow-pay + topic pay matrix + sales report/download logs + hidecontent + users commerce + order filters/clear-closed + post pay_type filter + bulk content-pay edit + income/rebate user filter | -| 5 remaining | optional: multi-gateway cashier for forum topics (alipay/wechat) | +| 5+ behavior parity | done — native ops shell + plate follow-pay + topic pay matrix + multi-gateway topic cashier (`AKSRT_Topic_Pay` /pay/topic) + sales report/download logs + hidecontent + users commerce + order filters/clear-closed + post pay_type filter + bulk content-pay edit + income/rebate user filter | +| 5 remaining | optional: topic coupon burn (P1), plate follow VIP1/2 dual tier + multi-gateway (P2) | ## Dual-UI rule diff --git a/functions.php b/functions.php index c0d7c035..27c7ed6c 100644 --- a/functions.php +++ b/functions.php @@ -177,6 +177,9 @@ if (class_exists('AKSRT_Download')) { if (class_exists('AKSRT_Content_Pay')) { AKSRT_Content_Pay::get_instance()->init(); } +if (class_exists('AKSRT_Topic_Pay')) { + AKSRT_Topic_Pay::get_instance()->init(); +} if (class_exists('AKSRT_Vip_Service')) { AKSRT_Vip_Service::get_instance()->init(); } diff --git a/inc/bootstrap.php b/inc/bootstrap.php index 3dddba83..66daf583 100644 --- a/inc/bootstrap.php +++ b/inc/bootstrap.php @@ -232,6 +232,9 @@ require_once AKSRT_DIR . '/inc/classes/class-aksrt-download.php'; // 付费内容收银台(post/_aksrt_pay → resource 订单) require_once AKSRT_DIR . '/inc/classes/class-aksrt-content-pay.php'; +// 论坛主题收银台(visibility=pay → resource 订单 + 多网关) +require_once AKSRT_DIR . '/inc/classes/class-aksrt-topic-pay.php'; + // VIP 开通服务(package → ORDER_PRODUCT_VIP) require_once AKSRT_DIR . '/inc/classes/class-aksrt-vip-service.php'; diff --git a/inc/classes/class-aksrt-content-pay.php b/inc/classes/class-aksrt-content-pay.php index f5f7c2b8..090cf0b3 100644 --- a/inc/classes/class-aksrt-content-pay.php +++ b/inc/classes/class-aksrt-content-pay.php @@ -1056,35 +1056,13 @@ class AKSRT_Content_Pay { ]; } - $methods = []; - $ids = $logged_in - ? ['balance', 'alipay', 'wechatpay', 'paypal', 'stripe'] - : ['alipay', 'wechatpay', 'paypal', 'stripe']; - if (!class_exists('AKSRT_Payment_Gateway')) { - return $logged_in - ? [['id' => 'balance', 'name' => __('站内余额', 'aksrt')]] - : [['id' => 'alipay', 'name' => __('支付宝', 'aksrt')]]; + if (function_exists('aksrt_list_cash_payment_methods')) { + return aksrt_list_cash_payment_methods($logged_in); } - $gw = AKSRT_Payment_Gateway::get_instance(); - foreach ($ids as $id) { - $g = $gw->get_gateway($id); - if (!$g) { - continue; - } - if (method_exists($g, 'is_configured') && !$g->is_configured()) { - continue; - } - $methods[] = [ - 'id' => $id, - 'name' => method_exists($g, 'get_name') ? (string) $g->get_name() : $id, - ]; - } - if ($methods === []) { - $methods[] = $logged_in - ? ['id' => 'balance', 'name' => __('站内余额', 'aksrt')] - : ['id' => 'alipay', 'name' => __('支付宝', 'aksrt')]; - } - return $methods; + + return $logged_in + ? [['id' => 'balance', 'name' => __('站内余额', 'aksrt')]] + : [['id' => 'alipay', 'name' => __('支付宝', 'aksrt')]]; } diff --git a/inc/classes/class-aksrt-forum.php b/inc/classes/class-aksrt-forum.php index 7d74a667..e0d5786d 100644 --- a/inc/classes/class-aksrt-forum.php +++ b/inc/classes/class-aksrt-forum.php @@ -353,7 +353,6 @@ class AKSRT_Forum { add_action('wp_ajax_aksrt_forum_follow_plate', [$this, 'ajax_follow_plate']); add_action('wp_ajax_aksrt_forum_purchase_plate_follow', [$this, 'ajax_purchase_plate_follow']); add_action('wp_ajax_aksrt_forum_unlock_topic', [$this, 'ajax_unlock_topic']); - add_action('wp_ajax_aksrt_forum_purchase_topic', [$this, 'ajax_purchase_topic']); // §7.4 draft persistence add_action('wp_ajax_aksrt_forum_save_draft', [$this, 'ajax_save_draft']); @@ -459,10 +458,6 @@ class AKSRT_Forum { $this->topic_service()->ajax_unlock_topic(); } - public function ajax_purchase_topic(): void { - $this->topic_service()->ajax_purchase_topic(); - } - /** * Lightweight result helper for topic service actions. * @@ -607,6 +602,10 @@ class AKSRT_Forum { return $this->topic_service()->resolve_topic_pay_price($topic_id, $user_id); } + public function mark_topic_unlocked(int $user_id, int $topic_id): void { + $this->topic_service()->mark_topic_unlocked($user_id, $topic_id); + } + public function prepare_vote_meta(array $data): array { return $this->topic_service()->prepare_vote_meta($data); } diff --git a/inc/classes/class-aksrt-topic-pay.php b/inc/classes/class-aksrt-topic-pay.php new file mode 100644 index 00000000..8e701aba --- /dev/null +++ b/inc/classes/class-aksrt-topic-pay.php @@ -0,0 +1,642 @@ +\\d+)', [ + [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_preview'], + 'permission_callback' => [$this, 'check_rest_nonce'], + ], + [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_purchase'], + 'permission_callback' => [$this, 'check_rest_nonce'], + ], + ]); + } + + /** + * @param WP_REST_Request $request + * @return true|WP_Error + */ + public function check_rest_nonce(WP_REST_Request $request) { + $nonce = $request->get_header('x-wp-nonce'); + if (!$nonce) { + return new WP_Error('aksrt_rest_no_nonce', __('缺少安全令牌', 'aksrt'), ['status' => 403]); + } + if (!wp_verify_nonce($nonce, 'wp_rest')) { + return new WP_Error('aksrt_rest_bad_nonce', __('安全令牌已过期,请刷新页面', 'aksrt'), ['status' => 403]); + } + return true; + } + + public function rest_preview(WP_REST_Request $request) { + $topic_id = (int) $request['id']; + $uid = get_current_user_id(); + $coupon_code = sanitize_text_field((string) ($request->get_param('coupon_code') ?? $request->get_param('coupon') ?? '')); + $preview = $this->preview($topic_id, $uid, $coupon_code); + if (is_wp_error($preview)) { + return $preview; + } + return new WP_REST_Response($preview, 200); + } + + public function rest_purchase(WP_REST_Request $request) { + $topic_id = (int) $request['id']; + $uid = get_current_user_id(); + if ($uid <= 0) { + $post = get_post($topic_id); + return new WP_Error('aksrt_need_login', __('请先登录后再购买。', 'aksrt'), [ + 'status' => 401, + 'login_url' => wp_login_url($post ? get_permalink($post) : home_url('/')), + ]); + } + + $params = $request->get_json_params(); + if (!is_array($params)) { + $params = $request->get_params(); + } + + $payment_method = sanitize_key((string) ($params['payment_method'] ?? 'balance')); + if ($payment_method === '' || $payment_method === 'cash') { + $payment_method = 'balance'; + } + $coupon_code = sanitize_text_field((string) ($params['coupon_code'] ?? $params['coupon'] ?? '')); + + return $this->rest_wrap_result( + $this->create_cash_order($topic_id, $uid, $payment_method, $coupon_code) + ); + } + + /** + * @param mixed $result + * @return WP_REST_Response|WP_Error + */ + private function rest_wrap_result($result) { + if (is_wp_error($result)) { + return $result; + } + if (empty($result['success'])) { + $code = sanitize_key((string) ($result['code'] ?? 'aksrt_pay_failed')); + $status = (int) ($result['status'] ?? 400); + return new WP_Error( + $code !== '' ? $code : 'aksrt_pay_failed', + (string) ($result['message'] ?? __('支付失败', 'aksrt')), + array_merge( + ['status' => $status > 0 ? $status : 400], + is_array($result['data'] ?? null) ? $result['data'] : [] + ) + ); + } + return new WP_REST_Response($result, 200); + } + + /** + * Cashier preview payload (content-pay field subset for cms-download). + * + * @return array|WP_Error + */ + public function preview(int $topic_id, int $user_id = 0, string $coupon_code = '') { + $assert = $this->assert_topic($topic_id); + if (is_wp_error($assert)) { + return $assert; + } + /** @var WP_Post $post */ + $post = $assert['post']; + $uid = $user_id > 0 ? $user_id : get_current_user_id(); + + if ($uid <= 0) { + return [ + 'post_id' => $topic_id, + 'title' => get_the_title($post), + 'permalink' => get_permalink($post), + 'pay_modo' => '0', + 'pay_type' => 'topic', + 'pay_type_label' => __('付费主题', 'aksrt'), + 'amount' => 0, + 'base' => 0, + 'list_amount' => 0, + 'payable' => 0, + 'currency' => '¥', + 'amount_label' => '', + 'base_label' => '', + 'list_amount_label' => '', + 'is_free' => false, + 'has_paid_access' => false, + 'need_login' => true, + 'allow_guest' => false, + 'need_vip' => false, + 'need_purchase' => true, + 'logged_in' => false, + 'balance' => 0, + 'balance_enough' => false, + 'payment_methods' => [], + 'login_url' => wp_login_url(get_permalink($post)), + 'vip_url' => home_url('/'), + 'credits_url' => home_url('/'), + 'coupon_allowed' => false, + 'coupon_desc' => '', + 'coupon_code' => '', + 'coupon' => null, + 'coupon_error' => '', + 'coupon_discount' => 0, + 'coupon_discount_label' => '', + 'rebate_discount' => 0, + 'rebate_discount_label' => '', + 'points_deduction_allowed' => false, + 'card_pass_allowed' => false, + 'price_type' => '', + ]; + } + + $priced = $this->resolve_price($topic_id, $uid); + $amount = round((float) ($priced['amount'] ?? 0), 2); + $base = round((float) ($priced['base'] ?? $amount), 2); + $is_free = !empty($priced['is_free']) || $amount <= 0; + $has_paid = $this->user_has_paid($uid, $topic_id); + + $balance = 0; + if (class_exists('AKSRT_Credits')) { + $balance = (int) AKSRT_Credits::balance($uid); + } + + $methods = function_exists('aksrt_list_cash_payment_methods') + ? aksrt_list_cash_payment_methods(true) + : [['id' => 'balance', 'name' => __('站内余额', 'aksrt')]]; + + $amount_label = $is_free + ? __('免费', 'aksrt') + : sprintf('¥%s', number_format($amount, 2, '.', '')); + $base_label = sprintf('¥%s', number_format($base, 2, '.', '')); + + $coupon_allowed = $this->is_coupon_allowed($topic_id); + // P0: coupon applied on purchase path only when allowed; preview soft field. + unset($coupon_code); + + return [ + 'post_id' => $topic_id, + 'title' => get_the_title($post), + 'permalink' => get_permalink($post), + 'pay_modo' => '0', + 'pay_type' => 'topic', + 'pay_type_label' => __('付费主题', 'aksrt'), + 'amount' => $amount, + 'base' => $base, + 'list_amount' => $amount, + 'list_amount_label' => $amount_label, + 'payable' => $amount, + 'currency' => '¥', + 'amount_label' => $amount_label, + 'base_label' => $base_label, + 'is_free' => $is_free, + 'has_paid_access' => $has_paid, + 'paid_order' => null, + 'pending_order' => null, + 'need_login' => false, + 'allow_guest' => false, + 'need_vip' => false, + 'need_purchase' => !$has_paid && !$is_free, + 'logged_in' => true, + 'balance' => $balance, + 'balance_enough' => true, + 'payment_methods' => $methods, + 'login_url' => wp_login_url(get_permalink($post)), + 'vip_url' => home_url('/'), + 'credits_url' => home_url('/'), + 'coupon_allowed' => $coupon_allowed, + 'coupon_desc' => '', + 'coupon_code' => '', + 'coupon' => null, + 'coupon_error' => '', + 'coupon_discount' => 0, + 'coupon_discount_label' => '', + 'rebate_discount' => 0, + 'rebate_discount_label' => '', + 'points_deduction_allowed' => false, + 'card_pass_allowed' => false, + 'price_type' => '', + 'vip_level' => (int) ($priced['vip_level'] ?? 0), + 'is_vip_price' => !empty($priced['is_vip_price']), + ]; + } + + /** + * Create cash order and invoke gateway. + * + * @return array|WP_Error + */ + public function create_cash_order(int $topic_id, int $user_id, string $payment_method = 'balance', string $coupon_code = '') { + if ($user_id <= 0) { + return new WP_Error('aksrt_need_login', __('请先登录后再购买。', 'aksrt'), ['status' => 401]); + } + + $assert = $this->assert_topic($topic_id); + if (is_wp_error($assert)) { + return $assert; + } + /** @var WP_Post $post */ + $post = $assert['post']; + + if ($this->user_has_paid($user_id, $topic_id)) { + return new WP_Error('aksrt_already_paid', __('已购买,可直接查看。', 'aksrt'), ['status' => 400]); + } + + $priced = $this->resolve_price($topic_id, $user_id); + $amount = round((float) ($priced['amount'] ?? 0), 2); + $base = round((float) ($priced['base'] ?? $amount), 2); + $is_free = !empty($priced['is_free']) || $amount <= 0; + + // Coupon reserved (P1): ignore invalid empty codes; no discount stack yet beyond VIP. + $coupon_code = sanitize_text_field($coupon_code); + if (!$this->is_coupon_allowed($topic_id)) { + $coupon_code = ''; + } + + if ($is_free) { + return $this->create_free_paid_order($topic_id, $user_id, $post, $priced); + } + + if ($amount <= 0) { + return new WP_Error('aksrt_price_unset', __('价格未设置', 'aksrt'), ['status' => 400]); + } + + if (!class_exists('AKSRT_Order') || !class_exists('AKSRT_Order_Repository')) { + return new WP_Error('aksrt_pay_unavailable', __('支付系统不可用。', 'aksrt'), ['status' => 500]); + } + + $payment_method = sanitize_key($payment_method); + if ($payment_method === '') { + $payment_method = 'balance'; + } + + $pending = $this->find_pending_topic_order($user_id, $topic_id); + $can_resume = $pending && abs((float) ($pending['amount'] ?? 0) - $amount) < 0.001; + + if ($can_resume) { + $order_number = (string) ($pending['order_number'] ?? ''); + $order = $pending; + } else { + $note = wp_json_encode([ + 'topic_pay' => 1, + 'topic_id' => $topic_id, + 'pay_modo' => '0', + 'vip_level' => (int) ($priced['vip_level'] ?? 0), + 'base' => $base, + 'list_amount' => $amount, + 'amount' => $amount, + 'payable' => $amount, + 'coupon_code' => $coupon_code, + 'coupon_discount' => 0, + 'coupon' => [], + 'ip_address' => function_exists('aksrt_pay_client_ip') ? aksrt_pay_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? ''), + ], 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_RESOURCE, + 'product_id' => $topic_id, + 'amount' => $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'] = sprintf( + /* translators: %s: topic title */ + __('购买论坛主题「%s」', 'aksrt'), + get_the_title($post) + ); + $order['payment_method'] = $payment_method; + $order['amount'] = $amount; + + if (!class_exists('AKSRT_Payment_Gateway')) { + return new WP_Error('aksrt_pay_unavailable', __('支付网关不可用。', 'aksrt'), ['status' => 500]); + } + + $gateway_manager = AKSRT_Payment_Gateway::get_instance(); + $gateway = $gateway_manager->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'])) { + $msg = (string) ($payment_result['message'] ?? __('支付失败。', 'aksrt')); + $code = ($payment_method === 'balance' && (str_contains($msg, '余额') || str_contains($msg, '不足'))) + ? 'aksrt_balance_insufficient' + : 'aksrt_pay_failed'; + return new WP_Error($code, $msg, [ + '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 [ + 'success' => true, + 'data' => [ + 'redirect' => true, + 'payment_url' => $payment_url, + 'qr_content' => $code_url, + 'order_number' => $order_number, + 'amount' => $amount, + 'pay_modo' => '0', + 'message' => $payment_result['message'] ?? __('请在新页面完成支付。', 'aksrt'), + 'reload' => false, + 'poll' => true, + ], + ]; + } + + // Sync gateway (balance): mark paid. + $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->mark_unlocked($user_id, $topic_id); + + return [ + 'success' => true, + 'data' => [ + 'order_number' => $order_number, + 'amount' => $amount, + 'pay_modo' => '0', + 'message' => $payment_result['message'] ?? __('支付成功', 'aksrt'), + 'reload' => true, + ], + ]; + } + + /** + * On paid: write unlock list for forum topics. + */ + public function on_order_paid(string $order_number, array $paid_order): void { + $product_type = sanitize_key((string) ($paid_order['product_type'] ?? '')); + if ($product_type !== AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE) { + return; + } + + $topic_id = (int) ($paid_order['product_id'] ?? 0); + $user_id = (int) ($paid_order['user_id'] ?? 0); + if ($topic_id <= 0 || $user_id <= 0) { + return; + } + + $meta = $this->decode_order_note_meta($paid_order); + $is_topic_pay = !empty($meta['topic_pay']); + if (!$is_topic_pay) { + $post = get_post($topic_id); + if (!($post instanceof WP_Post) || $post->post_type !== 'aksrt_forum_topic') { + return; + } + } + + $this->mark_unlocked($user_id, $topic_id); + } + + /** + * @return array{post:WP_Post}|WP_Error + */ + private function assert_topic(int $topic_id) { + $post = get_post($topic_id); + if (!($post instanceof WP_Post) || $post->post_type !== 'aksrt_forum_topic') { + return new WP_Error('aksrt_invalid', __('主题不存在', 'aksrt'), ['status' => 404]); + } + if ($post->post_status !== 'publish') { + return new WP_Error('aksrt_invalid', __('主题不可购买', 'aksrt'), ['status' => 404]); + } + + $visibility = sanitize_key((string) get_post_meta($topic_id, '_aksrt_visibility', true)); + if ($visibility !== AKSRT_Forum::VISIBILITY_PAY) { + return new WP_Error('aksrt_not_paid', __('该主题不是付费可见内容', 'aksrt'), ['status' => 400]); + } + + return ['post' => $post]; + } + + /** + * @return array{amount:float,is_vip_price:bool,is_free:bool,label:string,base:float,vip_set:bool,vip_level:int} + */ + private function resolve_price(int $topic_id, int $user_id): array { + if (class_exists('AKSRT_Forum')) { + return AKSRT_Forum::get_instance()->resolve_topic_pay_price($topic_id, $user_id); + } + return [ + 'amount' => max(0.0, (float) get_post_meta($topic_id, '_aksrt_pay_price', true)), + 'is_vip_price' => false, + 'is_free' => false, + 'label' => '', + 'base' => max(0.0, (float) get_post_meta($topic_id, '_aksrt_pay_price', true)), + 'vip_set' => false, + 'vip_level' => 0, + ]; + } + + private function user_has_paid(int $user_id, int $topic_id): bool { + if (class_exists('AKSRT_Forum')) { + return AKSRT_Forum::get_instance()->has_user_paid_topic($user_id, $topic_id); + } + if (class_exists('AKSRT_Order')) { + return AKSRT_Order::get_instance()->has_paid_access( + $user_id, + AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE, + $topic_id + ); + } + return false; + } + + private function mark_unlocked(int $user_id, int $topic_id): void { + if (class_exists('AKSRT_Forum') && method_exists('AKSRT_Forum', 'mark_topic_unlocked')) { + AKSRT_Forum::get_instance()->mark_topic_unlocked($user_id, $topic_id); + return; + } + // Fallback user meta write. + if ($user_id <= 0 || $topic_id <= 0) { + return; + } + $unlocked = get_user_meta($user_id, 'aksrt_unlocked_topics', true); + if (!is_array($unlocked)) { + $unlocked = []; + } + $unlocked[] = $topic_id; + update_user_meta($user_id, 'aksrt_unlocked_topics', array_values(array_unique(array_map('intval', $unlocked)))); + } + + private function is_coupon_allowed(int $topic_id): bool { + // P0/P1: honor reserved meta; coupon discount stack deferred to P1. + return (int) get_post_meta($topic_id, '_aksrt_topic_coupon_s', true) === 1; + } + + /** + * @return array|WP_Error + */ + private function create_free_paid_order(int $topic_id, int $user_id, WP_Post $post, array $priced) { + if (!class_exists('AKSRT_Order') || !class_exists('AKSRT_Order_Repository')) { + $this->mark_unlocked($user_id, $topic_id); + return [ + 'success' => true, + 'data' => [ + 'order_number' => '', + 'amount' => 0, + 'pay_modo' => '0', + 'free' => true, + 'message' => __('VIP 免费解锁成功', 'aksrt'), + 'reload' => true, + ], + ]; + } + + $note = wp_json_encode([ + 'topic_pay' => 1, + 'topic_id' => $topic_id, + 'pay_modo' => '0', + 'vip_level' => (int) ($priced['vip_level'] ?? 0), + 'base' => (float) ($priced['base'] ?? 0), + 'list_amount' => 0, + 'amount' => 0, + 'payable' => 0, + 'coupon_code' => '', + 'coupon_discount' => 0, + 'free' => 1, + ], 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_RESOURCE, + 'product_id' => $topic_id, + 'amount' => 0.0, + 'currency' => 'CNY', + 'status' => AKSRT_Order::STATUS_PENDING, + 'payment_method' => 'free', + 'note' => is_string($note) ? $note : '', + 'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS), + ]); + + if ($order_number === '') { + $this->mark_unlocked($user_id, $topic_id); + return [ + 'success' => true, + 'data' => [ + 'order_number' => '', + 'amount' => 0, + 'free' => true, + 'message' => __('VIP 免费解锁成功', 'aksrt'), + 'reload' => true, + ], + ]; + } + + AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_PAID, [ + 'payment_method' => 'free', + 'payment_transaction_id' => 'free_' . $order_number, + 'note' => __('VIP 免费解锁主题。', 'aksrt'), + ]); + $this->mark_unlocked($user_id, $topic_id); + + return [ + 'success' => true, + 'data' => [ + 'order_number' => $order_number, + 'amount' => 0, + 'pay_modo' => '0', + 'free' => true, + 'message' => __('VIP 免费解锁成功', 'aksrt'), + 'reload' => true, + ], + ]; + } + + private function find_pending_topic_order(int $user_id, int $topic_id): ?array { + if ($user_id <= 0 || $topic_id <= 0 || !class_exists('AKSRT_Order_Repository')) { + return null; + } + $orders = AKSRT_Order_Repository::get_instance()->get_user_orders($user_id, [ + 'product_type' => AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE, + 'status' => AKSRT_Domain_Model::ORDER_STATUS_PENDING, + 'limit' => 20, + 'offset' => 0, + ]); + foreach ($orders as $order) { + if ((int) ($order['product_id'] ?? 0) !== $topic_id) { + continue; + } + $meta = $this->decode_order_note_meta($order); + if (!empty($meta['topic_pay']) || $this->is_forum_topic_id($topic_id)) { + return $order; + } + } + return null; + } + + private function is_forum_topic_id(int $topic_id): bool { + $post = get_post($topic_id); + return $post instanceof WP_Post && $post->post_type === 'aksrt_forum_topic'; + } + + private function decode_order_note_meta(array $order): array { + $note = (string) ($order['note'] ?? ''); + if ($note === '') { + return []; + } + $decoded = json_decode($note, true); + return is_array($decoded) ? $decoded : []; + } +} diff --git a/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-3.php b/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-3.php index 3da00434..6cc460fa 100644 --- a/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-3.php +++ b/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-3.php @@ -408,121 +408,6 @@ trait AKSRT_Forum_Topic_Service_Part_3 { return $this->forum->success(__('解锁成功', 'aksrt')); } - /** - * Cash/balance purchase for VISIBILITY_PAY topics. - * - * Reuses AKSRT_Order product_type=resource + balance credits conversion - * (1 CNY = 100 credits, same as AKSRT_Balance_Gateway). - * - * @return array{success:bool,message:string} - */ - public function purchase_topic_with_balance(int $user_id, int $topic_id): array { - $post = get_post($topic_id); - if (!$post || $post->post_type !== 'aksrt_forum_topic') { - return $this->forum->error(__('主题不存在', 'aksrt')); - } - - if ($user_id <= 0) { - return $this->forum->error(__('请先登录', 'aksrt')); - } - - $visibility = (string) get_post_meta($topic_id, '_aksrt_visibility', true); - if ($visibility !== AKSRT_Forum::VISIBILITY_PAY) { - return $this->forum->error(__('该主题不是付费可见内容', 'aksrt')); - } - - if ($this->has_user_paid_topic($user_id, $topic_id)) { - return $this->forum->success(__('已购买', 'aksrt')); - } - - $price = $this->resolve_topic_pay_price($topic_id, $user_id); - if (!empty($price['is_free'])) { - $this->mark_topic_unlocked($user_id, $topic_id); - return $this->forum->success(__('VIP 免费解锁成功', 'aksrt'), [ - 'amount' => 0, - 'free' => true, - ]); - } - - $amount = (float) ($price['amount'] ?? 0); - if ($amount <= 0) { - return $this->forum->error(__('价格未设置', 'aksrt')); - } - - if (!class_exists('AKSRT_Order') || !class_exists('AKSRT_Credits')) { - return $this->forum->error(__('支付系统不可用', 'aksrt')); - } - - $product_type = class_exists('AKSRT_Domain_Model') - ? AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE - : 'resource'; - - $order_number = AKSRT_Order::get_instance()->create($user_id, $product_type, $topic_id, $amount, 'CNY'); - if ($order_number === '') { - return $this->forum->error(__('创建订单失败', 'aksrt')); - } - - // Align with AKSRT_Balance_Gateway: 1 CNY = 100 credits. - $credits_cost = (int) ceil($amount * 100); - $balance = AKSRT_Credits::get_balance($user_id); - if ($balance < $credits_cost) { - AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_CANCELLED, [ - 'note' => __('余额不足,自动取消', 'aksrt'), - ]); - return $this->forum->error(sprintf( - __('余额不足,需要 %d 积分(约 ¥%s),当前余额 %d', 'aksrt'), - $credits_cost, - number_format($amount, 2, '.', ''), - $balance - )); - } - - $deducted = AKSRT_Credits::deduct( - $user_id, - $credits_cost, - 'forum_topic_pay', - [ - 'topic_id' => $topic_id, - 'order' => $order_number, - ], - sprintf(__('购买帖子「%s」', 'aksrt'), get_the_title($topic_id)) - ); - - if (is_wp_error($deducted) || $deducted === false) { - AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_CANCELLED, [ - 'note' => __('扣款失败,自动取消', 'aksrt'), - ]); - $msg = is_wp_error($deducted) ? $deducted->get_error_message() : __('扣款失败', 'aksrt'); - return $this->forum->error($msg ?: __('扣款失败', 'aksrt')); - } - - $paid = AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_PAID, [ - 'payment_method' => 'balance', - 'payment_transaction_id' => 'BAL_' . $order_number, - 'note' => sprintf(__('论坛付费主题余额支付 %d 积分', 'aksrt'), $credits_cost), - ]); - - if (!$paid) { - // Best-effort refund so user is not charged without access. - AKSRT_Credits::add( - $user_id, - $credits_cost, - 'forum_topic_pay_refund', - ['topic_id' => $topic_id, 'order' => $order_number], - sprintf(__('购买失败退款:%s', 'aksrt'), get_the_title($topic_id)) - ); - return $this->forum->error(__('订单确认失败,已尝试退回积分', 'aksrt')); - } - - $this->mark_topic_unlocked($user_id, $topic_id); - - return $this->forum->success(__('购买成功', 'aksrt'), [ - 'order_number' => $order_number, - 'amount' => $amount, - 'credits_cost' => $credits_cost, - ]); - } - /** * Resolve cash price for a pay-visibility topic. * diff --git a/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-4.php b/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-4.php index 20e7e491..be4d2a9e 100644 --- a/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-4.php +++ b/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-4.php @@ -263,25 +263,4 @@ trait AKSRT_Forum_Topic_Service_Part_4 { wp_send_json_error(['message' => $result['message'] ?? __('解锁失败', 'aksrt')]); } } - - public function ajax_purchase_topic(): void { - check_ajax_referer('aksrt_forum_action', '_nonce'); - - $user_id = get_current_user_id(); - if ($user_id <= 0) { - wp_send_json_error(['message' => __('请先登录', 'aksrt')]); - } - - $topic_id = (int) ($_POST['topic_id'] ?? 0); - if ($topic_id <= 0) { - wp_send_json_error(['message' => __('主题不存在', 'aksrt')]); - } - - $result = $this->purchase_topic_with_balance($user_id, $topic_id); - if (!empty($result['success'])) { - wp_send_json_success($result); - } else { - wp_send_json_error(['message' => $result['message'] ?? __('购买失败', 'aksrt')]); - } - } } diff --git a/inc/classes/scripts-traits/trait-aksrt-scripts-part-1b.php b/inc/classes/scripts-traits/trait-aksrt-scripts-part-1b.php index 3f9eab3e..bfa5282b 100644 --- a/inc/classes/scripts-traits/trait-aksrt-scripts-part-1b.php +++ b/inc/classes/scripts-traits/trait-aksrt-scripts-part-1b.php @@ -661,8 +661,12 @@ trait AKSRT_Scripts_Part_1b { $enqueue_module('resource-archive'); } - // 单篇资源 / 付费内容 / 独立下载页:下载 + 收银台脚本 - if ($is_single_resource || $is_paid_content_single || $is_download_file_page) { + // 付费论坛主题:多网关收银台(与内容共用 cms-download.js) + $is_pay_forum_topic = is_singular('aksrt_forum_topic') + && sanitize_key((string) get_post_meta(get_queried_object_id(), '_aksrt_visibility', true)) === 'pay'; + + // 单篇资源 / 付费内容 / 独立下载页 / 付费主题:下载 + 收银台脚本 + if ($is_single_resource || $is_paid_content_single || $is_download_file_page || $is_pay_forum_topic) { $cms_download_js_path = get_template_directory() . '/assets/js/cms-download.js'; wp_enqueue_script( 'aksrt-cms-download', diff --git a/inc/helpers/aksrt-pay.php b/inc/helpers/aksrt-pay.php index f4032f3b..8b23a867 100644 --- a/inc/helpers/aksrt-pay.php +++ b/inc/helpers/aksrt-pay.php @@ -74,6 +74,49 @@ function aksrt_get_pay_types(): array { ]; } +/** + * Cash payment methods available for content/topic cashiers. + * + * @return array