$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) {