diff --git a/assets/js/cms-download.js b/assets/js/cms-download.js index 38f73aa1..19d6205b 100644 --- a/assets/js/cms-download.js +++ b/assets/js/cms-download.js @@ -448,7 +448,7 @@ window.location.reload(); return; } - // Free VIP topic/plate_follow still need a free-order unlock POST; content free can reload. + // Content free: reload. Topic/plate free: auto free-order POST (VIP free / free-after-coupon). if (preview.is_free && preview.pay_type !== 'topic' && preview.pay_type !== 'plate_follow') { window.location.reload(); return; @@ -458,6 +458,25 @@ const priceType = (preview._price_type || preview.price_type || ''); const apiKind = preview._api_kind || (preview.pay_type === 'topic' ? 'topic' : (preview.pay_type === 'plate_follow' ? 'plate_follow' : 'content')); + + // Auto-submit free topic/plate orders so user doesn't click through empty cash UI. + if (preview.is_free && (apiKind === 'topic' || apiKind === 'plate_follow') && !isPoints) { + open( + '
' + tt('processing', '处理中…') + '
', + returnFocusEl + ); + const freeBtn = document.createElement('button'); + freeBtn.type = 'button'; + freeBtn.style.display = 'none'; + freeBtn.setAttribute('data-price-type', priceType || ''); + document.body.appendChild(freeBtn); + const freeCode = String(preview.coupon_code || preview._coupon_code || ''); + submitPurchase(preview.post_id, 'cash', 'balance', freeBtn, returnFocusEl, freeCode, apiKind); + try { freeBtn.remove(); } catch (e0) { /* ignore */ } + return; + } + let amountLabel = escapeHtml(preview.amount_label || ''); let baseLabel = escapeHtml(preview.base_label || ''); const listLabel = escapeHtml(preview.list_amount_label || preview.amount_label || amountLabel); diff --git a/docs/zibll-gap-matrix.md b/docs/zibll-gap-matrix.md index 99692f72..39fb4870 100644 --- a/docs/zibll-gap-matrix.md +++ b/docs/zibll-gap-matrix.md @@ -100,7 +100,7 @@ Note: zibll「商品管理」`zibpay_product_page` is **paid article sales**, no | 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) + 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); order note JSON preserve + shared coupon burn helper done | +| 5 remaining | optional polish only (guest topic/plate purchase — out of scope); plate follow coupons + free auto-order + note JSON preserve + shared coupon burn done | ## Dual-UI rule diff --git a/inc/classes/class-aksrt-plate-follow-pay.php b/inc/classes/class-aksrt-plate-follow-pay.php index 6a67b122..b39ba640 100644 --- a/inc/classes/class-aksrt-plate-follow-pay.php +++ b/inc/classes/class-aksrt-plate-follow-pay.php @@ -7,7 +7,7 @@ * - product_type = plate_follow, product_id = plate_id * - order note.plate_follow_pay = 1 * - paid → mark_plate_followed (idempotent) - * - no coupons this phase + * - optional coupons when _aksrt_follow_pay_coupon_s=1 (cash only) * * @package AKSRT */ @@ -62,7 +62,8 @@ class AKSRT_Plate_Follow_Pay { public function rest_preview(WP_REST_Request $request) { $plate_id = (int) $request['id']; $uid = get_current_user_id(); - $preview = $this->preview($plate_id, $uid); + $coupon_code = sanitize_text_field((string) ($request->get_param('coupon_code') ?? $request->get_param('coupon') ?? '')); + $preview = $this->preview($plate_id, $uid, $coupon_code); if (is_wp_error($preview)) { return $preview; } @@ -89,9 +90,10 @@ class AKSRT_Plate_Follow_Pay { 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($plate_id, $uid, $payment_method) + $this->create_cash_order($plate_id, $uid, $payment_method, $coupon_code) ); } @@ -121,7 +123,7 @@ class AKSRT_Plate_Follow_Pay { /** * @return array|WP_Error */ - public function preview(int $plate_id, int $user_id = 0) { + public function preview(int $plate_id, int $user_id = 0, string $coupon_code = '') { $assert = $this->assert_plate($plate_id); if (is_wp_error($assert)) { return $assert; @@ -129,6 +131,7 @@ class AKSRT_Plate_Follow_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 +182,9 @@ class AKSRT_Plate_Follow_Pay { 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; + $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_followed = $this->user_has_followed($uid, $plate_id); $balance = 0; @@ -193,9 +196,39 @@ class AKSRT_Plate_Follow_Pay { ? aksrt_list_cash_payment_methods(true) : [['id' => 'balance', 'name' => __('站内余额', 'aksrt')]]; + $coupon_allowed = $this->is_coupon_allowed($plate_id); + $coupon_error = ''; + $coupon_discount = 0.0; + $coupon_meta = null; + $applied_code = ''; + $payable = $list_amount; + + if (!$vip_free && $list_amount > 0) { + $discounted = $this->apply_plate_cash_discounts($priced, $plate_id, $uid, $coupon_code, true); + if (is_wp_error($discounted)) { + $coupon_error = $discounted->get_error_message(); + $discounted = $this->apply_plate_cash_discounts($priced, $plate_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, '.', '')); return [ @@ -205,11 +238,11 @@ class AKSRT_Plate_Follow_Pay { 'pay_modo' => '0', 'pay_type' => 'plate_follow', '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, @@ -228,13 +261,15 @@ class AKSRT_Plate_Follow_Pay { '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' => '', + 'coupon_allowed' => $coupon_allowed, + '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, @@ -248,7 +283,7 @@ class AKSRT_Plate_Follow_Pay { /** * @return array|WP_Error */ - public function create_cash_order(int $plate_id, int $user_id, string $payment_method = 'balance') { + public function create_cash_order(int $plate_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]); } @@ -269,18 +304,52 @@ class AKSRT_Plate_Follow_Pay { 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; + $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 ($is_free) { - return $this->create_free_paid_order($plate_id, $user_id, $post, $priced); + // VIP free — no coupon stack. + if ($vip_free) { + return $this->create_free_paid_order($plate_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 ($amount <= 0) { + if ($list_amount <= 0) { return new WP_Error('aksrt_price_unset', __('价格未设置', 'aksrt'), ['status' => 400]); } + $discounted = $this->apply_plate_cash_discounts($priced, $plate_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. + if ($amount <= 0) { + return $this->create_free_paid_order($plate_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]); } @@ -291,7 +360,17 @@ class AKSRT_Plate_Follow_Pay { } $pending = $this->find_pending_order($user_id, $plate_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'] ?? ''); @@ -303,9 +382,12 @@ class AKSRT_Plate_Follow_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_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); @@ -433,6 +515,7 @@ class AKSRT_Plate_Follow_Pay { } $this->mark_followed($user_id, $plate_id); + $this->finalize_plate_order_side_effects($order_number, $paid_order); } /** @@ -515,9 +598,28 @@ class AKSRT_Plate_Follow_Pay { } /** + * 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 $plate_id, int $user_id, WP_Post $post, array $priced) { + private function create_free_paid_order(int $plate_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_followed($user_id, $plate_id); return [ @@ -527,7 +629,7 @@ class AKSRT_Plate_Follow_Pay { 'amount' => 0, 'pay_modo' => '0', 'free' => true, - 'message' => __('VIP 免费关注成功', 'aksrt'), + 'message' => $message, 'reload' => true, ], ]; @@ -539,9 +641,12 @@ class AKSRT_Plate_Follow_Pay { 'pay_modo' => '0', 'vip_level' => (int) ($priced['vip_level'] ?? 0), 'base' => (float) ($priced['base'] ?? 0), - 'list_amount' => 0, - 'amount' => 0, - 'payable' => 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); @@ -566,17 +671,20 @@ class AKSRT_Plate_Follow_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_followed($user_id, $plate_id); return [ @@ -586,12 +694,105 @@ class AKSRT_Plate_Follow_Pay { 'amount' => 0, 'pay_modo' => '0', 'free' => true, - 'message' => __('VIP 免费关注成功', 'aksrt'), + 'message' => $message, 'reload' => true, ], ]; } + private function is_coupon_allowed(int $plate_id): bool { + return (int) get_post_meta($plate_id, '_aksrt_follow_pay_coupon_s', true) === 1; + } + + /** + * VIP list price → coupon → payable. + * + * @param array+ + +