feat(forum): P1 topic coupons, P2 plate follow gateway, cash creator revenue

- Topic_Pay: soft/hard coupon validate, payable amount, free-after-discount, paid burn
- Creator_Revenue: settle content/topic/plate_follow cash (type=order), keep shop path
- Plate VIP1/VIP2 meta + resolve_plate_follow_pay_price via aksrt_get_user_vip_level
- New AKSRT_Plate_Follow_Pay REST multi-gateway; remove Credits×100 money bypass
- Frontend: cms-download plate_follow apiKind + forum-governance money cashier bridge
This commit is contained in:
2026-07-15 13:04:04 +08:00
parent 1a15ae7cea
commit 78d3189f8f
14 changed files with 1189 additions and 169 deletions
+264 -49
View File
@@ -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<string,mixed> $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,
],
];