diff --git a/assets/js/cms-download.js b/assets/js/cms-download.js index ad9613a9..38f73aa1 100644 --- a/assets/js/cms-download.js +++ b/assets/js/cms-download.js @@ -367,7 +367,9 @@ } function payApiBase(apiKind) { - return apiKind === 'topic' ? '/pay/topic/' : '/pay/content/'; + if (apiKind === 'topic') return '/pay/topic/'; + if (apiKind === 'plate_follow') return '/pay/plate-follow/'; + return '/pay/content/'; } function buildPreviewUrl(postId, priceType, couponCode, apiKind) { @@ -414,7 +416,7 @@ renderError(r.json.message || '', data, returnFocusEl, 'aksrt_need_vip'); return; } - if (code === 'aksrt_already_paid') { + if (code === 'aksrt_already_paid' || code === 'aksrt_already_followed') { window.location.reload(); return; } @@ -446,15 +448,16 @@ 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') { + // Free VIP topic/plate_follow still need a free-order unlock POST; content free can reload. + if (preview.is_free && preview.pay_type !== 'topic' && preview.pay_type !== 'plate_follow') { 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'); + const apiKind = preview._api_kind + || (preview.pay_type === 'topic' ? 'topic' : (preview.pay_type === 'plate_follow' ? 'plate_follow' : 'content')); let amountLabel = escapeHtml(preview.amount_label || ''); let baseLabel = escapeHtml(preview.base_label || ''); const listLabel = escapeHtml(preview.list_amount_label || preview.amount_label || amountLabel); @@ -616,7 +619,7 @@ if (!r.ok || (r.json && r.json.code && !r.json.success)) { const code = r.json && r.json.code ? r.json.code : ''; const data = (r.json && r.json.data) || {}; - if (code === 'aksrt_already_paid') { + if (code === 'aksrt_already_paid' || code === 'aksrt_already_followed') { window.location.reload(); return; } @@ -666,6 +669,14 @@ return; } + const plateFollowBuyBtn = e.target.closest('[data-aksrt-pay-plate-follow]'); + if (plateFollowBuyBtn) { + e.preventDefault(); + const plateId = parseInt(plateFollowBuyBtn.getAttribute('data-aksrt-pay-plate-follow') || '0', 10); + if (plateId) openCashier(plateId, plateFollowBuyBtn, '', '', 'plate_follow'); + return; + } + const buyBtn = e.target.closest('[data-action="aksrt-need-purchase"], [data-aksrt-pay-content]'); if (buyBtn) { e.preventDefault(); @@ -696,6 +707,11 @@ requestDownload(btn); }); + // Bridge for forum-governance plate money cashier. + window.aksrtOpenCashier = function (postId, returnFocusEl, priceType, couponCode, apiKind) { + openCashier(postId, returnFocusEl || null, priceType || '', couponCode || '', apiKind || 'content'); + }; + // ===== 注入 modal 基础样式(避免必须额外加载 CSS)===== const style = document.createElement('style'); style.textContent = diff --git a/assets/js/forum-governance.js b/assets/js/forum-governance.js index fe03b0b2..3d2d7e3e 100644 --- a/assets/js/forum-governance.js +++ b/assets/js/forum-governance.js @@ -690,10 +690,27 @@ } function purchasePlateFollow(plateId, button, payHint) { + payHint = payHint || {}; + var modo = String(payHint.modo || 'money'); + + // Cash: multi-gateway cashier (no Credits×100 bypass). + if (modo === 'money' && !payHint.is_free) { + button.disabled = false; + if (typeof window.aksrtOpenCashier === 'function') { + window.aksrtOpenCashier(plateId, button, '', '', 'plate_follow'); + return; + } + var msgCashier = '收银台未加载,请刷新页面后重试'; + if (typeof window.aksrtToast === 'function') { window.aksrtToast(msgCashier, 'error'); } + else { window.alert(msgCashier); } + return; + } + + // Points / VIP free: keep AJAX purchase path. var confirmMsg = '确认付费关注该版块?'; - if (payHint && payHint.is_free) { + if (payHint.is_free) { confirmMsg = '确认使用 VIP 免费关注该版块?'; - } else if (payHint) { + } else { confirmMsg = '确认支付 ' + formatFollowPayLabel(payHint) + ' 关注该版块?'; } if (!window.confirm(confirmMsg)) { @@ -712,14 +729,19 @@ credentials: 'same-origin', }).then(function (r) { return r.json(); }).then(function (payload) { if (!payload.success) { - throw new Error((payload.data && payload.data.message) || '支付失败'); + var errData = payload.data || {}; + // Server may redirect money path to cashier. + if (errData.require_cashier && typeof window.aksrtOpenCashier === 'function') { + button.disabled = false; + window.aksrtOpenCashier(plateId, button, '', '', 'plate_follow'); + return; + } + throw new Error(errData.message || '支付失败'); } var res = payload.data || {}; button.setAttribute('data-followed', '1'); button.classList.add('is-followed'); - var label = button.querySelector('span') || button; if (button.querySelector('i') && button.childNodes.length) { - // keep icon, replace trailing text via last text-ish content button.innerHTML = ' 已关注'; } else { button.textContent = '已关注'; diff --git a/docs/zibll-gap-matrix.md b/docs/zibll-gap-matrix.md index 6af6ad7e..8cf6e551 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 + 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) | +| 5+ behavior parity | done — native ops shell + plate follow-pay + topic pay matrix + multi-gateway topic cashier (`AKSRT_Topic_Pay` /pay/topic) + topic coupon burn (P1) + plate follow VIP1/2 + multi-gateway (`AKSRT_Plate_Follow_Pay` /pay/plate-follow) + cash creator revenue (content/topic/plate) + 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 polish only (plate coupons, guest topic/plate purchase — out of scope) | ## Dual-UI rule diff --git a/functions.php b/functions.php index 27c7ed6c..8ae4fd4c 100644 --- a/functions.php +++ b/functions.php @@ -180,6 +180,9 @@ if (class_exists('AKSRT_Content_Pay')) { if (class_exists('AKSRT_Topic_Pay')) { AKSRT_Topic_Pay::get_instance()->init(); } +if (class_exists('AKSRT_Plate_Follow_Pay')) { + AKSRT_Plate_Follow_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 66daf583..89c8e8dd 100644 --- a/inc/bootstrap.php +++ b/inc/bootstrap.php @@ -235,6 +235,9 @@ 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'; +// 版块付费关注收银台(plate_follow 订单 + 多网关) +require_once AKSRT_DIR . '/inc/classes/class-aksrt-plate-follow-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-creator-revenue.php b/inc/classes/class-aksrt-creator-revenue.php index 853963e6..c4e270a4 100644 --- a/inc/classes/class-aksrt-creator-revenue.php +++ b/inc/classes/class-aksrt-creator-revenue.php @@ -2,16 +2,15 @@ /** * AKSRT Creator Revenue Settlement. * - * Wires order-paid + non-shop paid content into the pre-existing revenue ledger - * (`aksrt_revenue` table + AKSRT_Revenue service). This closes the deferred - * "结算引擎" gap: the ledger, service and share-ratio settings already existed - * but nothing credited creator revenue when an order was paid. + * Wires order-paid into the pre-existing revenue ledger + * (`aksrt_revenue` table + AKSRT_Revenue service). * - * Scope (kept intentionally narrow): - * - Only shop-product orders and post-level paid content credit revenue. - * - Share ratio comes from AKSRT_Settings::get_payment_revenue_settings(). - * - Buyer == author is skipped (no self-dealing). - * - Idempotent per order_number via a guard row lookup. + * Scope: + * - Shop product orders (line items via AKSRT_Shop::get_order_items) + * - Non-shop cash: content_pay / topic_pay / plate_follow_pay + * - Share ratio from AKSRT_Settings::get_payment_revenue_settings() + * - Buyer == author skipped; amount≤0 skipped; points cash skipped + * - Idempotent per order_number via type=order guard row * * @package AKSRT */ @@ -110,10 +109,18 @@ if (!class_exists('AKSRT_Creator_Revenue')) { } } - if (empty($items)) { - return; // Only structured shop-product orders settle creator revenue here. + if (!empty($items)) { + $this->settle_shop_items($order_number, $buyer_id, $items); + return; } + $this->settle_non_shop_cash_order($order_number, $order, $buyer_id); + } + + /** + * @param list> $items + */ + private function settle_shop_items(string $order_number, int $buyer_id, array $items): void { $revenue = AKSRT_Revenue::get_instance(); foreach ($items as $item) { @@ -125,7 +132,7 @@ if (!class_exists('AKSRT_Creator_Revenue')) { $author_id = (int) get_post_field('post_author', $product_id); if ($author_id <= 0 || $author_id === $buyer_id) { - continue; // No author, or buyer is the author. + continue; } $ratio = $this->resolve_share_ratio($author_id); @@ -141,5 +148,96 @@ if (!class_exists('AKSRT_Creator_Revenue')) { $revenue->add($author_id, $amount, $order_number, 'order'); } } + + /** + * Content / topic / plate_follow cash orders (no shop line items). + */ + private function settle_non_shop_cash_order(string $order_number, array $order, int $buyer_id): void { + $meta = $this->decode_note($order); + if (($meta['pay_modo'] ?? '') === 'points') { + return; + } + + $payment_method = sanitize_key((string) ($order['payment_method'] ?? '')); + $currency = sanitize_key((string) ($order['currency'] ?? '')); + if ($payment_method === 'points' || $currency === 'points') { + return; + } + + $base_amount = round((float) ($order['amount'] ?? $meta['payable'] ?? 0), 2); + if ($base_amount <= 0) { + return; + } + + $product_type = sanitize_key((string) ($order['product_type'] ?? '')); + $product_id = (int) ($order['product_id'] ?? 0); + if ($product_id <= 0) { + return; + } + + $post = get_post($product_id); + if (!($post instanceof WP_Post)) { + return; + } + + $is_plate = !empty($meta['plate_follow_pay']) + || $product_type === AKSRT_Domain_Model::ORDER_PRODUCT_PLATE_FOLLOW; + $is_topic = !empty($meta['topic_pay']) + || ($product_type === AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE + && $post->post_type === 'aksrt_forum_topic'); + $is_content = !empty($meta['content_pay']) + && $post->post_type === 'post'; + + // Resource without content_pay/topic_pay and not a known cash kind: skip. + if (!$is_plate && !$is_topic && !$is_content) { + return; + } + + // Guard: plate must be plate CPT; topic must be topic CPT. + if ($is_plate && $post->post_type !== 'aksrt_forum_plate') { + return; + } + if ($is_topic && $post->post_type !== 'aksrt_forum_topic') { + return; + } + + $author_id = (int) $post->post_author; + if ($author_id <= 0 || $author_id === $buyer_id) { + return; + } + + $ratio = $this->resolve_share_ratio($author_id); + if ($ratio <= 0) { + return; + } + + $amount = round($base_amount * $ratio / 100, 2); + if ($amount <= 0) { + return; + } + + // Re-check after shop-less path; shop path already returned. + if ($this->already_settled($order_number)) { + return; + } + + AKSRT_Revenue::get_instance()->add($author_id, $amount, $order_number, 'order'); + } + + /** + * @param array $order + * @return array + */ + private function decode_note(array $order): array { + if (isset($order['meta_payload']) && is_array($order['meta_payload'])) { + return $order['meta_payload']; + } + $note = (string) ($order['note'] ?? ''); + if ($note === '') { + return []; + } + $decoded = json_decode($note, true); + return is_array($decoded) ? $decoded : []; + } } } diff --git a/inc/classes/class-aksrt-forum.php b/inc/classes/class-aksrt-forum.php index e0d5786d..19fb412c 100644 --- a/inc/classes/class-aksrt-forum.php +++ b/inc/classes/class-aksrt-forum.php @@ -270,6 +270,18 @@ class AKSRT_Forum { return $this->board_service()->is_user_following_plate($user_id, $plate_id); } + public function is_plate_follow_pay_enabled(int $plate_id): bool { + return $this->board_service()->is_plate_follow_pay_enabled($plate_id); + } + + public function resolve_plate_follow_pay_price(int $plate_id, int $user_id = 0): array { + return $this->board_service()->resolve_plate_follow_pay_price($plate_id, $user_id); + } + + public function mark_plate_followed(int $user_id, int $plate_id): void { + $this->board_service()->mark_plate_followed($user_id, $plate_id); + } + public function toggle_follow_plate(int $user_id, int $plate_id): array { return $this->board_service()->toggle_follow_plate($user_id, $plate_id); } diff --git a/inc/classes/class-aksrt-plate-follow-pay.php b/inc/classes/class-aksrt-plate-follow-pay.php new file mode 100644 index 00000000..65edb0db --- /dev/null +++ b/inc/classes/class-aksrt-plate-follow-pay.php @@ -0,0 +1,626 @@ +\\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'], + ], + ]); + } + + /** + * @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) { + $plate_id = (int) $request['id']; + $uid = get_current_user_id(); + $preview = $this->preview($plate_id, $uid); + if (is_wp_error($preview)) { + return $preview; + } + return new WP_REST_Response($preview, 200); + } + + public function rest_purchase(WP_REST_Request $request) { + $plate_id = (int) $request['id']; + $uid = get_current_user_id(); + if ($uid <= 0) { + $post = get_post($plate_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'; + } + + return $this->rest_wrap_result( + $this->create_cash_order($plate_id, $uid, $payment_method) + ); + } + + /** + * @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); + } + + /** + * @return array|WP_Error + */ + public function preview(int $plate_id, int $user_id = 0) { + $assert = $this->assert_plate($plate_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' => $plate_id, + 'title' => get_the_title($post), + 'permalink' => get_permalink($post), + 'pay_modo' => '0', + 'pay_type' => 'plate_follow', + '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($plate_id, $uid); + if (($priced['modo'] ?? 'money') === 'points') { + return new WP_Error('aksrt_wrong_modo', __('该版块仅支持积分关注。', 'aksrt'), ['status' => 400]); + } + + $amount = round((float) ($priced['amount'] ?? 0), 2); + $base = round((float) ($priced['base'] ?? $amount), 2); + $is_free = !empty($priced['is_free']) || $amount <= 0; + $has_followed = $this->user_has_followed($uid, $plate_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, '.', '')); + + return [ + 'post_id' => $plate_id, + 'title' => get_the_title($post), + 'permalink' => get_permalink($post), + 'pay_modo' => '0', + 'pay_type' => 'plate_follow', + '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_followed, + 'paid_order' => null, + 'pending_order' => null, + 'need_login' => false, + 'allow_guest' => false, + 'need_vip' => false, + 'need_purchase' => !$has_followed && !$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' => 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' => '', + 'vip_level' => (int) ($priced['vip_level'] ?? 0), + 'is_vip_price' => !empty($priced['is_vip_price']), + ]; + } + + /** + * @return array|WP_Error + */ + public function create_cash_order(int $plate_id, int $user_id, string $payment_method = 'balance') { + if ($user_id <= 0) { + return new WP_Error('aksrt_need_login', __('请先登录后再关注。', 'aksrt'), ['status' => 401]); + } + + $assert = $this->assert_plate($plate_id); + if (is_wp_error($assert)) { + return $assert; + } + /** @var WP_Post $post */ + $post = $assert['post']; + + if ($this->user_has_followed($user_id, $plate_id)) { + return new WP_Error('aksrt_already_followed', __('已关注该版块。', 'aksrt'), ['status' => 400]); + } + + $priced = $this->resolve_price($plate_id, $user_id); + if (($priced['modo'] ?? 'money') === 'points') { + return new WP_Error('aksrt_wrong_modo', __('该版块仅支持积分关注。', 'aksrt'), ['status' => 400]); + } + + $amount = round((float) ($priced['amount'] ?? 0), 2); + $base = round((float) ($priced['base'] ?? $amount), 2); + $is_free = !empty($priced['is_free']) || $amount <= 0; + + if ($is_free) { + return $this->create_free_paid_order($plate_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_order($user_id, $plate_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([ + 'plate_follow_pay' => 1, + 'plate_id' => $plate_id, + 'pay_modo' => '0', + 'vip_level' => (int) ($priced['vip_level'] ?? 0), + 'base' => $base, + 'list_amount' => $amount, + 'amount' => $amount, + 'payable' => $amount, + '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_PLATE_FOLLOW, + 'product_id' => $plate_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: plate 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, + ], + ]; + } + + $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_followed($user_id, $plate_id); + + return [ + 'success' => true, + 'data' => [ + 'order_number' => $order_number, + 'amount' => $amount, + 'pay_modo' => '0', + 'message' => $payment_result['message'] ?? __('关注成功', 'aksrt'), + 'reload' => true, + ], + ]; + } + + 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_PLATE_FOLLOW) { + return; + } + + $plate_id = (int) ($paid_order['product_id'] ?? 0); + $user_id = (int) ($paid_order['user_id'] ?? 0); + if ($plate_id <= 0 || $user_id <= 0) { + return; + } + + $meta = $this->decode_order_note_meta($paid_order); + if (empty($meta['plate_follow_pay'])) { + $post = get_post($plate_id); + if (!($post instanceof WP_Post) || $post->post_type !== 'aksrt_forum_plate') { + return; + } + } + + $this->mark_followed($user_id, $plate_id); + } + + /** + * @return array{post:WP_Post}|WP_Error + */ + private function assert_plate(int $plate_id) { + $post = get_post($plate_id); + if (!($post instanceof WP_Post) || $post->post_type !== 'aksrt_forum_plate') { + return new WP_Error('aksrt_invalid', __('版块不存在', 'aksrt'), ['status' => 404]); + } + if ($post->post_status !== 'publish') { + return new WP_Error('aksrt_invalid', __('版块不可关注', 'aksrt'), ['status' => 404]); + } + + if (!$this->is_follow_pay_enabled($plate_id)) { + return new WP_Error('aksrt_not_paid', __('该版块未开启付费关注', 'aksrt'), ['status' => 400]); + } + + $modo = sanitize_key((string) (get_post_meta($plate_id, '_aksrt_follow_pay_modo', true) ?: 'money')); + if ($modo === 'points') { + return new WP_Error('aksrt_wrong_modo', __('该版块仅支持积分关注。', 'aksrt'), ['status' => 400]); + } + + return ['post' => $post]; + } + + private function is_follow_pay_enabled(int $plate_id): bool { + if (class_exists('AKSRT_Forum') && method_exists('AKSRT_Forum', 'is_plate_follow_pay_enabled')) { + return (bool) AKSRT_Forum::get_instance()->is_plate_follow_pay_enabled($plate_id); + } + return (int) get_post_meta($plate_id, '_aksrt_follow_pay', true) === 1; + } + + /** + * @return array{amount:float,modo:string,is_vip_price:bool,is_free:bool,label:string,base:float,vip_level:int} + */ + private function resolve_price(int $plate_id, int $user_id): array { + if (class_exists('AKSRT_Forum') && method_exists('AKSRT_Forum', 'resolve_plate_follow_pay_price')) { + return AKSRT_Forum::get_instance()->resolve_plate_follow_pay_price($plate_id, $user_id); + } + return [ + 'amount' => max(0.0, (float) get_post_meta($plate_id, '_aksrt_follow_pay_price', true)), + 'modo' => 'money', + 'is_vip_price' => false, + 'is_free' => false, + 'label' => '', + 'base' => max(0.0, (float) get_post_meta($plate_id, '_aksrt_follow_pay_price', true)), + 'vip_level' => 0, + ]; + } + + private function user_has_followed(int $user_id, int $plate_id): bool { + if ($user_id <= 0 || $plate_id <= 0) { + return false; + } + if (class_exists('AKSRT_Forum') && method_exists('AKSRT_Forum', 'is_user_following_plate')) { + return (bool) AKSRT_Forum::get_instance()->is_user_following_plate($user_id, $plate_id); + } + $follow_list = get_user_meta($user_id, 'aksrt_follow_plate', true); + if (!is_array($follow_list)) { + return false; + } + return in_array($plate_id, array_map('intval', $follow_list), true); + } + + private function mark_followed(int $user_id, int $plate_id): void { + if (class_exists('AKSRT_Forum') && method_exists('AKSRT_Forum', 'mark_plate_followed')) { + AKSRT_Forum::get_instance()->mark_plate_followed($user_id, $plate_id); + return; + } + if ($user_id <= 0 || $plate_id <= 0) { + return; + } + $follow_list = get_user_meta($user_id, 'aksrt_follow_plate', true); + if (!is_array($follow_list)) { + $follow_list = []; + } + $follow_list[] = $plate_id; + update_user_meta($user_id, 'aksrt_follow_plate', array_values(array_unique(array_map('intval', $follow_list)))); + } + + /** + * @return array|WP_Error + */ + private function create_free_paid_order(int $plate_id, int $user_id, WP_Post $post, array $priced) { + if (!class_exists('AKSRT_Order') || !class_exists('AKSRT_Order_Repository')) { + $this->mark_followed($user_id, $plate_id); + return [ + 'success' => true, + 'data' => [ + 'order_number' => '', + 'amount' => 0, + 'pay_modo' => '0', + 'free' => true, + 'message' => __('VIP 免费关注成功', 'aksrt'), + 'reload' => true, + ], + ]; + } + + $note = wp_json_encode([ + 'plate_follow_pay' => 1, + 'plate_id' => $plate_id, + 'pay_modo' => '0', + 'vip_level' => (int) ($priced['vip_level'] ?? 0), + 'base' => (float) ($priced['base'] ?? 0), + 'list_amount' => 0, + 'amount' => 0, + 'payable' => 0, + 'free' => 1, + '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_PLATE_FOLLOW, + 'product_id' => $plate_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_followed($user_id, $plate_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_followed($user_id, $plate_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_order(int $user_id, int $plate_id): ?array { + if ($user_id <= 0 || $plate_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_PLATE_FOLLOW, + 'status' => AKSRT_Domain_Model::ORDER_STATUS_PENDING, + 'limit' => 20, + 'offset' => 0, + ]); + foreach ($orders as $order) { + if ((int) ($order['product_id'] ?? 0) !== $plate_id) { + continue; + } + $meta = $this->decode_order_note_meta($order); + if (!empty($meta['plate_follow_pay'])) { + return $order; + } + return $order; + } + return null; + } + + 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/class-aksrt-topic-pay.php b/inc/classes/class-aksrt-topic-pay.php index 8e701aba..d1061417 100644 --- a/inc/classes/class-aksrt-topic-pay.php +++ b/inc/classes/class-aksrt-topic-pay.php @@ -7,6 +7,7 @@ * - product_type = resource, product_id = topic_id * - order note.topic_pay = 1 * - unlock: has_user_paid_topic (order OR unlock list OR VIP free) + * - coupon: _aksrt_topic_coupon_s + AKSRT_Coupon (cash only; no affiliate stack) * * @package AKSRT */ @@ -133,6 +134,7 @@ class AKSRT_Topic_Pay { /** @var WP_Post $post */ $post = $assert['post']; $uid = $user_id > 0 ? $user_id : get_current_user_id(); + $coupon_code = sanitize_text_field($coupon_code); if ($uid <= 0) { return [ @@ -179,9 +181,9 @@ class AKSRT_Topic_Pay { } $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; + $list_amount = round((float) ($priced['amount'] ?? 0), 2); + $base = round((float) ($priced['base'] ?? $list_amount), 2); + $vip_free = !empty($priced['is_free']) || ($list_amount <= 0 && !empty($priced['is_vip_price'])); $has_paid = $this->user_has_paid($uid, $topic_id); $balance = 0; @@ -193,15 +195,41 @@ class AKSRT_Topic_Pay { ? aksrt_list_cash_payment_methods(true) : [['id' => 'balance', 'name' => __('站内余额', 'aksrt')]]; + $coupon_allowed = $this->is_coupon_allowed($topic_id); + $coupon_error = ''; + $coupon_discount = 0.0; + $coupon_meta = null; + $applied_code = ''; + $payable = $list_amount; + + if (!$vip_free && $list_amount > 0) { + $discounted = $this->apply_topic_cash_discounts($priced, $topic_id, $uid, $coupon_code, true); + if (is_wp_error($discounted)) { + $coupon_error = $discounted->get_error_message(); + $discounted = $this->apply_topic_cash_discounts($priced, $topic_id, $uid, '', true); + } + if (!is_wp_error($discounted)) { + $list_amount = round((float) ($discounted['list_amount'] ?? $list_amount), 2); + $payable = round((float) ($discounted['payable'] ?? $list_amount), 2); + $coupon_discount = round((float) ($discounted['coupon_discount'] ?? 0), 2); + $coupon_meta = !empty($discounted['coupon']) ? $discounted['coupon'] : null; + $applied_code = (string) ($discounted['coupon_code'] ?? ($coupon_meta['code'] ?? '')); + if ($coupon_error === '' && !empty($discounted['coupon_error'])) { + $coupon_error = (string) $discounted['coupon_error']; + } + } + } elseif ($vip_free) { + $payable = 0.0; + $list_amount = 0.0; + } + + $is_free = $vip_free || $payable <= 0; $amount_label = $is_free ? __('免费', 'aksrt') - : sprintf('¥%s', number_format($amount, 2, '.', '')); + : sprintf('¥%s', number_format($payable, 2, '.', '')); + $list_label = sprintf('¥%s', number_format($list_amount > 0 ? $list_amount : $base, 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), @@ -209,11 +237,11 @@ class AKSRT_Topic_Pay { 'pay_modo' => '0', 'pay_type' => 'topic', 'pay_type_label' => __('付费主题', 'aksrt'), - 'amount' => $amount, + 'amount' => $payable, 'base' => $base, - 'list_amount' => $amount, - 'list_amount_label' => $amount_label, - 'payable' => $amount, + 'list_amount' => $list_amount > 0 ? $list_amount : $base, + 'list_amount_label' => $list_label, + 'payable' => $payable, 'currency' => '¥', 'amount_label' => $amount_label, 'base_label' => $base_label, @@ -233,12 +261,14 @@ class AKSRT_Topic_Pay { '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' => '', + 'coupon_desc' => $coupon_allowed ? __('支持优惠码', 'aksrt') : '', + 'coupon_code' => $applied_code, + 'coupon' => $coupon_meta, + 'coupon_error' => $coupon_error, + 'coupon_discount' => $coupon_discount, + 'coupon_discount_label' => $coupon_discount > 0 + ? ('-' . sprintf('¥%s', number_format($coupon_discount, 2, '.', ''))) + : '', 'rebate_discount' => 0, 'rebate_discount_label' => '', 'points_deduction_allowed' => false, @@ -271,24 +301,52 @@ class AKSRT_Topic_Pay { } $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. + $list_amount = round((float) ($priced['amount'] ?? 0), 2); + $base = round((float) ($priced['base'] ?? $list_amount), 2); + $vip_free = !empty($priced['is_free']) || ($list_amount <= 0 && !empty($priced['is_vip_price'])); $coupon_code = sanitize_text_field($coupon_code); - if (!$this->is_coupon_allowed($topic_id)) { - $coupon_code = ''; + + // VIP free (no purchase price) — no coupon stack. + if ($vip_free) { + return $this->create_free_paid_order($topic_id, $user_id, $post, $priced, [ + 'list_amount' => 0.0, + 'payable' => 0.0, + 'coupon_code' => '', + 'coupon_discount' => 0.0, + 'coupon' => [], + 'message' => __('VIP 免费解锁成功', 'aksrt'), + 'paid_note' => __('VIP 免费解锁主题。', 'aksrt'), + ]); } - if ($is_free) { - return $this->create_free_paid_order($topic_id, $user_id, $post, $priced); - } - - if ($amount <= 0) { + if ($list_amount <= 0) { return new WP_Error('aksrt_price_unset', __('价格未设置', 'aksrt'), ['status' => 400]); } + $discounted = $this->apply_topic_cash_discounts($priced, $topic_id, $user_id, $coupon_code, false); + if (is_wp_error($discounted)) { + return $discounted; + } + + $list_amount = round((float) ($discounted['list_amount'] ?? $list_amount), 2); + $amount = round((float) ($discounted['payable'] ?? $list_amount), 2); + $coupon_discount = round((float) ($discounted['coupon_discount'] ?? 0), 2); + $coupon_meta = is_array($discounted['coupon'] ?? null) ? $discounted['coupon'] : []; + $coupon_code_norm = (string) ($coupon_meta['code'] ?? $discounted['coupon_code'] ?? $coupon_code); + + // Free after coupon discount. + if ($amount <= 0) { + return $this->create_free_paid_order($topic_id, $user_id, $post, $priced, [ + 'list_amount' => $list_amount, + 'payable' => 0.0, + 'coupon_code' => $coupon_code_norm, + 'coupon_discount' => $coupon_discount, + 'coupon' => $coupon_meta, + 'message' => __('无需支付,已可访问。', 'aksrt'), + 'paid_note' => __('优惠后无需支付。', 'aksrt'), + ]); + } + if (!class_exists('AKSRT_Order') || !class_exists('AKSRT_Order_Repository')) { return new WP_Error('aksrt_pay_unavailable', __('支付系统不可用。', 'aksrt'), ['status' => 500]); } @@ -299,7 +357,17 @@ class AKSRT_Topic_Pay { } $pending = $this->find_pending_topic_order($user_id, $topic_id); - $can_resume = $pending && abs((float) ($pending['amount'] ?? 0) - $amount) < 0.001; + $can_resume = false; + if ($pending && abs((float) ($pending['amount'] ?? 0) - $amount) < 0.001) { + $pending_meta = $this->decode_order_note_meta($pending); + $pending_coupon = (string) ($pending_meta['coupon_code'] ?? ''); + if (is_array($pending_meta['coupon'] ?? null)) { + $pending_coupon = (string) ($pending_meta['coupon']['code'] ?? $pending_coupon); + } + if ($pending_coupon === $coupon_code_norm) { + $can_resume = true; + } + } if ($can_resume) { $order_number = (string) ($pending['order_number'] ?? ''); @@ -311,12 +379,12 @@ class AKSRT_Topic_Pay { 'pay_modo' => '0', 'vip_level' => (int) ($priced['vip_level'] ?? 0), 'base' => $base, - 'list_amount' => $amount, + 'list_amount' => $list_amount, 'amount' => $amount, 'payable' => $amount, - 'coupon_code' => $coupon_code, - 'coupon_discount' => 0, - 'coupon' => [], + 'coupon_code' => $coupon_code_norm, + 'coupon_discount' => $coupon_discount, + 'coupon' => $coupon_meta, 'ip_address' => function_exists('aksrt_pay_client_ip') ? aksrt_pay_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? ''), ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); @@ -425,7 +493,7 @@ class AKSRT_Topic_Pay { } /** - * On paid: write unlock list for forum topics. + * On paid: unlock topic + burn coupon (idempotent). */ public function on_order_paid(string $order_number, array $paid_order): void { $product_type = sanitize_key((string) ($paid_order['product_type'] ?? '')); @@ -449,6 +517,7 @@ class AKSRT_Topic_Pay { } $this->mark_unlocked($user_id, $topic_id); + $this->finalize_topic_order_side_effects($order_number, $paid_order); } /** @@ -508,7 +577,6 @@ class AKSRT_Topic_Pay { AKSRT_Forum::get_instance()->mark_topic_unlocked($user_id, $topic_id); return; } - // Fallback user meta write. if ($user_id <= 0 || $topic_id <= 0) { return; } @@ -521,14 +589,155 @@ class AKSRT_Topic_Pay { } 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; } /** + * VIP list price → coupon → payable. No affiliate rebate on topics. + * + * @param array $priced * @return array|WP_Error */ - private function create_free_paid_order(int $topic_id, int $user_id, WP_Post $post, array $priced) { + private function apply_topic_cash_discounts(array $priced, int $topic_id, int $user_id, string $coupon_code = '', bool $soft_coupon = false) { + $list_amount = round(max(0.0, (float) ($priced['amount'] ?? 0)), 2); + $pay = $list_amount; + $coupon_discount = 0.0; + $coupon_meta = []; + $coupon_error = ''; + $coupon_code = sanitize_text_field($coupon_code); + + if ($coupon_code !== '') { + if (!$this->is_coupon_allowed($topic_id)) { + $err = new WP_Error('aksrt_coupon_not_allowed', __('当前主题不支持优惠码。', 'aksrt'), ['status' => 400]); + if (!$soft_coupon) { + return $err; + } + $coupon_error = $err->get_error_message(); + $coupon_code = ''; + } elseif (!class_exists('AKSRT_Coupon')) { + $err = new WP_Error('aksrt_coupon_unavailable', __('优惠券系统不可用。', 'aksrt'), ['status' => 500]); + if (!$soft_coupon) { + return $err; + } + $coupon_error = $err->get_error_message(); + $coupon_code = ''; + } else { + $result = AKSRT_Coupon::get_instance()->validate($coupon_code, $pay, [ + 'user_id' => max(0, $user_id), + 'product_ids' => [$topic_id], + 'context' => 'topic', + ]); + if (empty($result['valid'])) { + $err = new WP_Error( + 'aksrt_coupon_error', + (string) ($result['message'] ?? __('优惠码不可用。', 'aksrt')), + ['status' => 400] + ); + if (!$soft_coupon) { + return $err; + } + $coupon_error = $err->get_error_message(); + $coupon_code = ''; + } else { + $coupon_discount = round(max(0.0, (float) ($result['discount'] ?? 0)), 2); + if ($coupon_discount > $pay) { + $coupon_discount = $pay; + } + $pay = round(max(0.0, $pay - $coupon_discount), 2); + $c = is_array($result['coupon'] ?? null) ? $result['coupon'] : []; + $coupon_meta = [ + 'id' => (int) ($c['id'] ?? 0), + 'code' => (string) ($c['code'] ?? $coupon_code), + 'type' => (string) ($c['type'] ?? ''), + 'value' => (float) ($c['value'] ?? 0), + 'discount' => $coupon_discount, + ]; + $coupon_code = (string) $coupon_meta['code']; + } + } + } + + return [ + 'list_amount' => $list_amount, + 'coupon_discount' => $coupon_discount, + 'coupon' => $coupon_meta, + 'coupon_code' => $coupon_code, + 'coupon_error' => $coupon_error, + 'payable' => round(max(0.0, $pay), 2), + ]; + } + + /** + * Burn coupon after paid (idempotent via coupon_usage.order_number). + */ + private function finalize_topic_order_side_effects(string $order_number, array $order): void { + $meta = $this->decode_order_note_meta($order); + if (empty($meta['topic_pay'])) { + return; + } + if (($meta['pay_modo'] ?? '') === 'points') { + return; + } + + $buyer_id = (int) ($order['user_id'] ?? 0); + $coupon = is_array($meta['coupon'] ?? null) ? $meta['coupon'] : []; + $coupon_code = sanitize_text_field((string) ($coupon['code'] ?? $meta['coupon_code'] ?? '')); + $coupon_discount = (float) ($meta['coupon_discount'] ?? $meta['discount'] ?? ($coupon['discount'] ?? 0)); + if ($coupon_code === '' || !class_exists('AKSRT_Coupon') || $this->coupon_usage_exists($order_number)) { + return; + } + + $coupon_api = AKSRT_Coupon::get_instance(); + $coupon_api->use_coupon($coupon_code); + $validated = $coupon_api->validate($coupon_code, max(0.01, $coupon_discount), ['user_id' => $buyer_id]); + $coupon_id = (int) ($validated['coupon']['id'] ?? $coupon['id'] ?? 0); + if ($coupon_id <= 0 && !empty($coupon['id'])) { + $coupon_id = (int) $coupon['id']; + } + if ($coupon_id > 0) { + $coupon_api->log_usage($coupon_id, max(0, $buyer_id), $order_number, max(0.0, $coupon_discount)); + } + } + + private function coupon_usage_exists(string $order_number): bool { + if ($order_number === '' || !class_exists('AKSRT_DB_Migrator')) { + return false; + } + global $wpdb; + $table = AKSRT_DB_Migrator::table('coupon_usage'); + if ($table === '') { + return false; + } + $found = $wpdb->get_var($wpdb->prepare( + "SELECT id FROM {$table} WHERE order_number = %s LIMIT 1", + $order_number + )); + return !empty($found); + } + + /** + * Free VIP or free-after-discount paid order. + * + * @param array{ + * list_amount?:float, + * payable?:float, + * coupon_code?:string, + * coupon_discount?:float, + * coupon?:array, + * message?:string, + * paid_note?:string + * } $opts + * @return array|WP_Error + */ + private function create_free_paid_order(int $topic_id, int $user_id, WP_Post $post, array $priced, array $opts = []) { + $list_amount = round((float) ($opts['list_amount'] ?? 0), 2); + $payable = round((float) ($opts['payable'] ?? 0), 2); + $coupon_code = sanitize_text_field((string) ($opts['coupon_code'] ?? '')); + $coupon_discount = round((float) ($opts['coupon_discount'] ?? 0), 2); + $coupon_meta = is_array($opts['coupon'] ?? null) ? $opts['coupon'] : []; + $message = (string) ($opts['message'] ?? __('VIP 免费解锁成功', 'aksrt')); + $paid_note = (string) ($opts['paid_note'] ?? __('VIP 免费解锁主题。', 'aksrt')); + if (!class_exists('AKSRT_Order') || !class_exists('AKSRT_Order_Repository')) { $this->mark_unlocked($user_id, $topic_id); return [ @@ -538,7 +747,7 @@ class AKSRT_Topic_Pay { 'amount' => 0, 'pay_modo' => '0', 'free' => true, - 'message' => __('VIP 免费解锁成功', 'aksrt'), + 'message' => $message, 'reload' => true, ], ]; @@ -550,12 +759,14 @@ class AKSRT_Topic_Pay { '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, + 'list_amount' => $list_amount, + 'amount' => $payable, + 'payable' => $payable, + 'coupon_code' => $coupon_code, + 'coupon_discount' => $coupon_discount, + 'coupon' => $coupon_meta, 'free' => 1, + '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([ @@ -578,17 +789,21 @@ class AKSRT_Topic_Pay { 'order_number' => '', 'amount' => 0, 'free' => true, - 'message' => __('VIP 免费解锁成功', 'aksrt'), + 'message' => $message, 'reload' => true, ], ]; } - AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_PAID, [ + $paid = AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_PAID, [ 'payment_method' => 'free', 'payment_transaction_id' => 'free_' . $order_number, - 'note' => __('VIP 免费解锁主题。', 'aksrt'), + 'note' => $paid_note, ]); + if (!$paid) { + return new WP_Error('aksrt_order_confirm_failed', __('订单确认失败。', 'aksrt'), ['status' => 500]); + } + $this->mark_unlocked($user_id, $topic_id); return [ @@ -598,7 +813,7 @@ class AKSRT_Topic_Pay { 'amount' => 0, 'pay_modo' => '0', 'free' => true, - 'message' => __('VIP 免费解锁成功', 'aksrt'), + 'message' => $message, 'reload' => true, ], ]; diff --git a/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-2.php b/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-2.php index 9956c9f7..71da4dd5 100644 --- a/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-2.php +++ b/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-2.php @@ -408,7 +408,7 @@ trait AKSRT_Forum_Board_Service_Part_2 { } /** - * @return array{amount:float|int,modo:string,is_vip_price:bool,is_free:bool,label:string,base:float|int,currency:string} + * @return array{amount:float|int,modo:string,is_vip_price:bool,is_free:bool,label:string,base:float|int,currency:string,vip_level:int,vip_set:bool} */ public function resolve_plate_follow_pay_price(int $plate_id, int $user_id = 0): array { $modo = sanitize_key((string) (get_post_meta($plate_id, '_aksrt_follow_pay_modo', true) ?: 'money')); @@ -421,19 +421,54 @@ trait AKSRT_Forum_Board_Service_Part_2 { ? max(0, (int) get_post_meta($plate_id, '_aksrt_follow_pay_points', true)) : max(0.0, (float) get_post_meta($plate_id, '_aksrt_follow_pay_price', true)); - $vip_raw = get_post_meta($plate_id, '_aksrt_follow_pay_vip_price', true); - $vip_set = ($vip_raw !== '' && $vip_raw !== null && $vip_raw !== false); - $vip_price = $vip_set - ? ($is_points ? max(0, (int) $vip_raw) : max(0.0, (float) $vip_raw)) - : 0; - - $is_vip = $user_id > 0 && function_exists('aksrt_is_vip') && aksrt_is_vip($user_id); - if (!$is_vip && $user_id > 0 && function_exists('aksrt_is_vip_exempt') && aksrt_is_vip_exempt($user_id)) { - $is_vip = true; + $vip_level = 0; + if ($user_id > 0) { + if (function_exists('aksrt_get_user_vip_level')) { + $vip_level = (int) aksrt_get_user_vip_level($user_id); + } elseif (function_exists('aksrt_is_vip2') && aksrt_is_vip2($user_id)) { + $vip_level = 2; + } elseif (function_exists('aksrt_is_vip') && aksrt_is_vip($user_id)) { + $vip_level = 1; + } + if ($vip_level <= 0 && function_exists('aksrt_is_vip_exempt') && aksrt_is_vip_exempt($user_id)) { + $vip_level = 1; + } } - if ($is_vip && $vip_set) { - $amount = $vip_price; + $pick = static function ($raw) use ($is_points) { + if ($raw === '' || $raw === null || $raw === false) { + return null; + } + if (!is_numeric($raw)) { + return null; + } + return $is_points ? max(0, (int) $raw) : max(0.0, (float) $raw); + }; + + $vip2 = $pick(get_post_meta($plate_id, '_aksrt_follow_pay_vip2_price', true)); + $vip1 = $pick(get_post_meta($plate_id, '_aksrt_follow_pay_vip1_price', true)); + if ($vip1 === null) { + // Legacy single VIP key → vip1. + $vip1 = $pick(get_post_meta($plate_id, '_aksrt_follow_pay_vip_price', true)); + } + + $amount = $base; + $is_vip_price = false; + $vip_set = ($vip1 !== null || $vip2 !== null); + + if ($vip_level >= 2 && $vip2 !== null) { + $amount = $vip2; + $is_vip_price = true; + } elseif ($vip_level >= 1 && $vip1 !== null) { + $amount = $vip1; + $is_vip_price = true; + } elseif ($vip_level >= 2 && $vip1 !== null) { + // VIP2 user falls back to VIP1 when VIP2 unset. + $amount = $vip1; + $is_vip_price = true; + } + + if ($is_vip_price) { return [ 'amount' => $amount, 'modo' => $modo, @@ -446,6 +481,8 @@ trait AKSRT_Forum_Board_Service_Part_2 { : sprintf(__('VIP ¥%s', 'aksrt'), number_format((float) $amount, 2, '.', ''))), 'base' => $base, 'currency' => $is_points ? 'points' : 'CNY', + 'vip_level' => $vip_level, + 'vip_set' => true, ]; } @@ -459,6 +496,8 @@ trait AKSRT_Forum_Board_Service_Part_2 { : sprintf(__('¥%s', 'aksrt'), number_format((float) $base, 2, '.', '')), 'base' => $base, 'currency' => $is_points ? 'points' : 'CNY', + 'vip_level' => $vip_level, + 'vip_set' => $vip_set, ]; } @@ -549,69 +588,12 @@ trait AKSRT_Forum_Board_Service_Part_2 { ]); } - // Cash via balance credits (same as topic pay). - if (!class_exists('AKSRT_Order') || !class_exists('AKSRT_Domain_Model')) { - return $this->forum->error(__('支付系统不可用', 'aksrt')); - } - $cash = max(0.0, (float) $amount); - if ($cash <= 0) { - return $this->forum->error(__('价格未设置', 'aksrt')); - } - $product_type = AKSRT_Domain_Model::ORDER_PRODUCT_PLATE_FOLLOW; - $order_number = AKSRT_Order::get_instance()->create($user_id, $product_type, $plate_id, $cash, 'CNY'); - if ($order_number === '') { - return $this->forum->error(__('创建订单失败', 'aksrt')); - } - $credits_cost = (int) ceil($cash * 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($cash, 2, '.', ''), - $balance - )); - } - $deducted = AKSRT_Credits::deduct( - $user_id, - $credits_cost, - 'forum_plate_follow_pay', - ['plate_id' => $plate_id, 'order' => $order_number], - sprintf(__('付费关注版块「%s」', 'aksrt'), get_the_title($plate_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) { - AKSRT_Credits::add( - $user_id, - $credits_cost, - 'forum_plate_follow_refund', - ['plate_id' => $plate_id, 'order' => $order_number], - sprintf(__('关注失败退款:%s', 'aksrt'), get_the_title($plate_id)) - ); - return $this->forum->error(__('订单确认失败,已尝试退回积分', 'aksrt')); - } - - $this->mark_plate_followed($user_id, $plate_id); - return $this->forum->success(__('关注成功', 'aksrt'), [ - 'followed' => true, - 'follow_count' => (int) get_post_meta($plate_id, '_aksrt_follow_count', true), - 'order_number' => $order_number, - 'amount' => $cash, - 'credits_cost' => $credits_cost, + // Cash: multi-gateway cashier only (AKSRT_Plate_Follow_Pay REST). No Credits×100 bypass. + return $this->forum->error(__('请通过收银台支付关注费用。', 'aksrt'), [ + 'require_cashier' => true, + 'pay' => $price, + 'modo' => 'money', + 'plate_id' => $plate_id, ]); } diff --git a/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-3.php b/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-3.php index 1d7e50e1..149ce79e 100644 --- a/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-3.php +++ b/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-3.php @@ -165,9 +165,16 @@ trait AKSRT_Forum_Board_Service_Part_3 { $result = $this->purchase_plate_follow($user_id, $plate_id); if (empty($result['success'])) { - wp_send_json_error([ + $err = [ 'message' => (string) ($result['message'] ?? __('支付失败', 'aksrt')), - ]); + ]; + if (!empty($result['require_cashier'])) { + $err['require_cashier'] = true; + $err['pay'] = $result['pay'] ?? []; + $err['modo'] = (string) ($result['modo'] ?? 'money'); + $err['plate_id'] = (int) ($result['plate_id'] ?? $plate_id); + } + wp_send_json_error($err); } wp_send_json_success($result); } diff --git a/inc/classes/forum/class-aksrt-plate-meta-box.php b/inc/classes/forum/class-aksrt-plate-meta-box.php index 993b03c8..004a8410 100644 --- a/inc/classes/forum/class-aksrt-plate-meta-box.php +++ b/inc/classes/forum/class-aksrt-plate-meta-box.php @@ -168,9 +168,10 @@ class AKSRT_Plate_Meta_Box {

+

@@ -181,9 +182,13 @@ class AKSRT_Plate_Meta_Box {

- - - + + +

+

+ + +


$enabled, - 'modo' => $modo, - 'price' => (float) get_post_meta($plate_id, '_aksrt_follow_pay_price', true), - 'points' => (int) get_post_meta($plate_id, '_aksrt_follow_pay_points', true), - 'vip_price' => $vip_set ? (string) (float) $vip_raw : '', + 'enabled' => $enabled, + 'modo' => $modo, + 'price' => (float) get_post_meta($plate_id, '_aksrt_follow_pay_price', true), + 'points' => (int) get_post_meta($plate_id, '_aksrt_follow_pay_points', true), + 'vip1_price' => $vip1_set ? (string) (float) $vip1_raw : '', + 'vip2_price' => $vip2_set ? (string) (float) $vip2_raw : '', ]; } 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 bfa5282b..49bafcca 100644 --- a/inc/classes/scripts-traits/trait-aksrt-scripts-part-1b.php +++ b/inc/classes/scripts-traits/trait-aksrt-scripts-part-1b.php @@ -665,8 +665,16 @@ trait AKSRT_Scripts_Part_1b { $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) { + // 版块现金付费关注:多网关收银台 + $is_pay_forum_plate = false; + if (is_singular('aksrt_forum_plate')) { + $plate_qid = get_queried_object_id(); + $is_pay_forum_plate = (int) get_post_meta($plate_qid, '_aksrt_follow_pay', true) === 1 + && sanitize_key((string) (get_post_meta($plate_qid, '_aksrt_follow_pay_modo', true) ?: 'money')) === 'money'; + } + + // 单篇资源 / 付费内容 / 独立下载页 / 付费主题 / 付费关注版块:下载 + 收银台脚本 + if ($is_single_resource || $is_paid_content_single || $is_download_file_page || $is_pay_forum_topic || $is_pay_forum_plate) { $cms_download_js_path = get_template_directory() . '/assets/js/cms-download.js'; wp_enqueue_script( 'aksrt-cms-download', diff --git a/inc/core-hooks/part-1.php b/inc/core-hooks/part-1.php index 9459db9e..0f0ba38c 100644 --- a/inc/core-hooks/part-1.php +++ b/inc/core-hooks/part-1.php @@ -382,7 +382,9 @@ function aksrt_register_forum_topic_meta() { '_aksrt_follow_pay_modo' => ['type' => 'string', 'default' => 'money'], '_aksrt_follow_pay_price' => ['type' => 'number', 'default' => 0], '_aksrt_follow_pay_points' => ['type' => 'integer', 'default' => 0], - '_aksrt_follow_pay_vip_price' => ['type' => 'number', 'default' => 0], + '_aksrt_follow_pay_vip_price' => ['type' => 'number', 'default' => 0], // legacy read fallback → vip1 + '_aksrt_follow_pay_vip1_price' => ['type' => 'number', 'default' => 0], + '_aksrt_follow_pay_vip2_price' => ['type' => 'number', 'default' => 0], ]; foreach ($plate_meta as $key => $args) {