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:
@@ -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 =
|
||||
|
||||
@@ -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 = '<i class="fas fa-heart"></i> 已关注';
|
||||
} else {
|
||||
button.textContent = '已关注';
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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<array<string,mixed>> $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<string,mixed> $order
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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 : [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
<?php
|
||||
/**
|
||||
* AKSRT Plate Follow Pay — multi-gateway cashier for paid plate follow (cash only).
|
||||
*
|
||||
* Contract:
|
||||
* - modo = money plates only (points stay on purchase_plate_follow AJAX)
|
||||
* - product_type = plate_follow, product_id = plate_id
|
||||
* - order note.plate_follow_pay = 1
|
||||
* - paid → mark_plate_followed (idempotent)
|
||||
* - no coupons this phase
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class AKSRT_Plate_Follow_Pay {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
add_action('rest_api_init', [$this, 'register_routes']);
|
||||
add_action('aksrt_order_paid', [$this, 'on_order_paid'], 16, 2);
|
||||
}
|
||||
|
||||
public function register_routes(): void {
|
||||
register_rest_route('aksrt/v1', '/pay/plate-follow/(?P<id>\\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 : [];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -168,9 +168,10 @@ class AKSRT_Plate_Meta_Box {
|
||||
<p>
|
||||
<label for="aksrt_plate_follow_pay_modo"><strong><?php esc_html_e('关注支付方式', 'aksrt'); ?></strong></label>
|
||||
<select id="aksrt_plate_follow_pay_modo" name="aksrt_plate_follow_pay_modo" class="widefat">
|
||||
<option value="money" <?php selected(($follow_pay['modo'] ?? 'money'), 'money'); ?>><?php esc_html_e('现金(余额)', 'aksrt'); ?></option>
|
||||
<option value="money" <?php selected(($follow_pay['modo'] ?? 'money'), 'money'); ?>><?php esc_html_e('现金', 'aksrt'); ?></option>
|
||||
<option value="points" <?php selected(($follow_pay['modo'] ?? ''), 'points'); ?>><?php esc_html_e('积分', 'aksrt'); ?></option>
|
||||
</select>
|
||||
<span class="description" style="display:block;"><?php esc_html_e('现金走多网关收银台;积分仍即时扣点。', 'aksrt'); ?></span>
|
||||
</p>
|
||||
<p>
|
||||
<label for="aksrt_plate_follow_pay_price"><strong><?php esc_html_e('现金价格(元)', 'aksrt'); ?></strong></label>
|
||||
@@ -181,9 +182,13 @@ class AKSRT_Plate_Meta_Box {
|
||||
<input type="number" step="1" min="0" class="widefat" name="aksrt_plate_follow_pay_points" id="aksrt_plate_follow_pay_points" value="<?php echo esc_attr((string) (int) ($follow_pay['points'] ?? 0)); ?>">
|
||||
</p>
|
||||
<p>
|
||||
<label for="aksrt_plate_follow_pay_vip_price"><strong><?php esc_html_e('VIP 价格(可选)', 'aksrt'); ?></strong></label>
|
||||
<input type="number" step="0.01" min="0" class="widefat" name="aksrt_plate_follow_pay_vip_price" id="aksrt_plate_follow_pay_vip_price" value="<?php echo esc_attr((string) ($follow_pay['vip_price'] ?? '')); ?>" placeholder="<?php esc_attr_e('留空=不设 VIP 价;0=VIP 免费', 'aksrt'); ?>">
|
||||
<span class="description"><?php esc_html_e('现金模式用元;积分模式填积分数。', 'aksrt'); ?></span>
|
||||
<label for="aksrt_plate_follow_pay_vip1_price"><strong><?php esc_html_e('VIP1 价格(可选)', 'aksrt'); ?></strong></label>
|
||||
<input type="number" step="0.01" min="0" class="widefat" name="aksrt_plate_follow_pay_vip1_price" id="aksrt_plate_follow_pay_vip1_price" value="<?php echo esc_attr((string) ($follow_pay['vip1_price'] ?? '')); ?>" placeholder="<?php esc_attr_e('留空=不设 VIP1 价;0=VIP1 免费', 'aksrt'); ?>">
|
||||
</p>
|
||||
<p>
|
||||
<label for="aksrt_plate_follow_pay_vip2_price"><strong><?php esc_html_e('VIP2 价格(可选)', 'aksrt'); ?></strong></label>
|
||||
<input type="number" step="0.01" min="0" class="widefat" name="aksrt_plate_follow_pay_vip2_price" id="aksrt_plate_follow_pay_vip2_price" value="<?php echo esc_attr((string) ($follow_pay['vip2_price'] ?? '')); ?>" placeholder="<?php esc_attr_e('留空=不设 VIP2 价;0=VIP2 免费', 'aksrt'); ?>">
|
||||
<span class="description"><?php esc_html_e('现金模式用元;积分模式填积分数。未填 VIP1 时仍可读旧「VIP 价格」字段。', 'aksrt'); ?></span>
|
||||
</p>
|
||||
<hr>
|
||||
<?php
|
||||
@@ -391,6 +396,8 @@ class AKSRT_Plate_Meta_Box {
|
||||
delete_post_meta($post_id, '_aksrt_follow_pay_price');
|
||||
delete_post_meta($post_id, '_aksrt_follow_pay_points');
|
||||
delete_post_meta($post_id, '_aksrt_follow_pay_vip_price');
|
||||
delete_post_meta($post_id, '_aksrt_follow_pay_vip1_price');
|
||||
delete_post_meta($post_id, '_aksrt_follow_pay_vip2_price');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -399,16 +406,25 @@ class AKSRT_Plate_Meta_Box {
|
||||
update_post_meta($post_id, '_aksrt_follow_pay_price', $price);
|
||||
update_post_meta($post_id, '_aksrt_follow_pay_points', $points);
|
||||
|
||||
if (isset($_POST['aksrt_plate_follow_pay_vip_price']) && (string) wp_unslash($_POST['aksrt_plate_follow_pay_vip_price']) !== '') {
|
||||
$vip = max(0, (float) wp_unslash($_POST['aksrt_plate_follow_pay_vip_price']));
|
||||
update_post_meta($post_id, '_aksrt_follow_pay_vip_price', $vip);
|
||||
if (isset($_POST['aksrt_plate_follow_pay_vip1_price']) && (string) wp_unslash($_POST['aksrt_plate_follow_pay_vip1_price']) !== '') {
|
||||
$vip1 = max(0, (float) wp_unslash($_POST['aksrt_plate_follow_pay_vip1_price']));
|
||||
update_post_meta($post_id, '_aksrt_follow_pay_vip1_price', $vip1);
|
||||
// Keep legacy single VIP key in sync with VIP1 for old readers.
|
||||
update_post_meta($post_id, '_aksrt_follow_pay_vip_price', $vip1);
|
||||
} else {
|
||||
delete_post_meta($post_id, '_aksrt_follow_pay_vip1_price');
|
||||
delete_post_meta($post_id, '_aksrt_follow_pay_vip_price');
|
||||
}
|
||||
|
||||
if (isset($_POST['aksrt_plate_follow_pay_vip2_price']) && (string) wp_unslash($_POST['aksrt_plate_follow_pay_vip2_price']) !== '') {
|
||||
update_post_meta($post_id, '_aksrt_follow_pay_vip2_price', max(0, (float) wp_unslash($_POST['aksrt_plate_follow_pay_vip2_price'])));
|
||||
} else {
|
||||
delete_post_meta($post_id, '_aksrt_follow_pay_vip2_price');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{enabled:bool,modo:string,price:float,points:int,vip_price:string}
|
||||
* @return array{enabled:bool,modo:string,price:float,points:int,vip1_price:string,vip2_price:string}
|
||||
*/
|
||||
private function get_follow_pay_meta(int $plate_id): array {
|
||||
$enabled = (int) get_post_meta($plate_id, '_aksrt_follow_pay', true) === 1;
|
||||
@@ -416,15 +432,25 @@ class AKSRT_Plate_Meta_Box {
|
||||
if (!in_array($modo, ['money', 'points'], true)) {
|
||||
$modo = 'money';
|
||||
}
|
||||
$vip_raw = get_post_meta($plate_id, '_aksrt_follow_pay_vip_price', true);
|
||||
$vip_set = ($vip_raw !== '' && $vip_raw !== null && $vip_raw !== false);
|
||||
|
||||
$vip1_raw = get_post_meta($plate_id, '_aksrt_follow_pay_vip1_price', true);
|
||||
$vip1_set = ($vip1_raw !== '' && $vip1_raw !== null && $vip1_raw !== false);
|
||||
if (!$vip1_set) {
|
||||
// Legacy single VIP key → vip1.
|
||||
$vip1_raw = get_post_meta($plate_id, '_aksrt_follow_pay_vip_price', true);
|
||||
$vip1_set = ($vip1_raw !== '' && $vip1_raw !== null && $vip1_raw !== false);
|
||||
}
|
||||
|
||||
$vip2_raw = get_post_meta($plate_id, '_aksrt_follow_pay_vip2_price', true);
|
||||
$vip2_set = ($vip2_raw !== '' && $vip2_raw !== null && $vip2_raw !== false);
|
||||
|
||||
return [
|
||||
'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),
|
||||
'vip_price' => $vip_set ? (string) (float) $vip_raw : '',
|
||||
'vip1_price' => $vip1_set ? (string) (float) $vip1_raw : '',
|
||||
'vip2_price' => $vip2_set ? (string) (float) $vip2_raw : '',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user