diff --git a/functions.php b/functions.php index 11c2bd62..6a85c6b0 100644 --- a/functions.php +++ b/functions.php @@ -199,6 +199,10 @@ if (class_exists('AKSRT_Download_State')) { if (class_exists('AKSRT_Docs_Presenter')) { AKSRT_Docs_Presenter::get_instance(); } +// Homepage module preview REST + query var (render still via helpers / static methods) +if (class_exists('AKSRT_Home_Modules')) { + AKSRT_Home_Modules::get_instance()->init(); +} if (class_exists('AKSRT_Content_Visibility')) { AKSRT_Content_Visibility::get_instance()->init(); } diff --git a/inc/bootstrap.php b/inc/bootstrap.php index 2414e6c0..c14e78d9 100644 --- a/inc/bootstrap.php +++ b/inc/bootstrap.php @@ -67,6 +67,7 @@ require_once AKSRT_DIR . '/inc/classes/core/setup-traits/trait-aksrt-setup-part- require_once AKSRT_DIR . '/inc/classes/core/setup-traits/trait-aksrt-setup-part-2.php'; require_once AKSRT_DIR . '/inc/classes/core/class-aksrt-setup.php'; require_once AKSRT_DIR . '/inc/classes/core/scripts-traits/trait-aksrt-scripts-part-1b.php'; +require_once AKSRT_DIR . '/inc/classes/core/scripts-traits/trait-aksrt-scripts-part-1c.php'; require_once AKSRT_DIR . '/inc/classes/core/scripts-traits/trait-aksrt-scripts-part-2.php'; require_once AKSRT_DIR . '/inc/classes/core/class-aksrt-scripts.php'; require_once AKSRT_DIR . '/inc/classes/frontend/class-aksrt-inline-style-builder.php'; @@ -93,6 +94,7 @@ require_once AKSRT_DIR . '/inc/classes/settings/class-aksrt-settings-integration require_once AKSRT_DIR . '/inc/classes/settings/class-aksrt-settings-extension-runtime.php'; require_once AKSRT_DIR . '/inc/classes/settings/class-aksrt-settings-forum-shop.php'; require_once AKSRT_DIR . '/inc/classes/settings/class-aksrt-settings-capabilities.php'; +require_once AKSRT_DIR . '/inc/classes/settings/trait-aksrt-settings-facade-bulk.php'; require_once AKSRT_DIR . '/inc/classes/settings/class-aksrt-settings.php'; // --- admin: SPA controllers + schema + native ops --- @@ -210,6 +212,7 @@ require_once AKSRT_DIR . '/inc/classes/auth/class-aksrt-email-rollback.php'; // --- user --- require_once AKSRT_DIR . '/inc/classes/user/class-aksrt-user.php'; +require_once AKSRT_DIR . '/inc/classes/user/class-aksrt-user-notifications.php'; require_once AKSRT_DIR . '/inc/classes/user/class-aksrt-user-center.php'; require_once AKSRT_DIR . '/inc/classes/user/class-aksrt-user-home.php'; require_once AKSRT_DIR . '/inc/classes/user/class-aksrt-user-restriction.php'; @@ -231,6 +234,9 @@ require_once AKSRT_DIR . '/inc/classes/core/class-aksrt-db-migrator.php'; // --- credits / wallet --- require_once AKSRT_DIR . '/inc/classes/credits/class-aksrt-credits.php'; require_once AKSRT_DIR . '/inc/classes/credits/class-aksrt-balance.php'; +require_once AKSRT_DIR . '/inc/classes/credits/balance-service-traits/trait-aksrt-balance-service-rest.php'; +require_once AKSRT_DIR . '/inc/classes/credits/balance-service-traits/trait-aksrt-balance-service-charge.php'; +require_once AKSRT_DIR . '/inc/classes/credits/balance-service-traits/trait-aksrt-balance-service-points.php'; require_once AKSRT_DIR . '/inc/classes/credits/class-aksrt-balance-service.php'; require_once AKSRT_DIR . '/inc/classes/credits/class-aksrt-points-free.php'; require_once AKSRT_DIR . '/inc/classes/credits/class-aksrt-credits-earn.php'; @@ -239,13 +245,19 @@ require_once AKSRT_DIR . '/inc/classes/credits/class-aksrt-invite.php'; // --- content pay / download / VIP --- require_once AKSRT_DIR . '/inc/classes/content/class-aksrt-download.php'; +require_once AKSRT_DIR . '/inc/classes/content/content-pay-traits/trait-aksrt-content-pay-rest.php'; +require_once AKSRT_DIR . '/inc/classes/content/content-pay-traits/trait-aksrt-content-pay-orders.php'; +require_once AKSRT_DIR . '/inc/classes/content/content-pay-traits/trait-aksrt-content-pay-helpers.php'; require_once AKSRT_DIR . '/inc/classes/content/class-aksrt-content-pay.php'; require_once AKSRT_DIR . '/inc/classes/forum/class-aksrt-topic-pay.php'; require_once AKSRT_DIR . '/inc/classes/forum/class-aksrt-plate-follow-pay.php'; +require_once AKSRT_DIR . '/inc/classes/content/vip-service-traits/trait-aksrt-vip-service-rest.php'; +require_once AKSRT_DIR . '/inc/classes/content/vip-service-traits/trait-aksrt-vip-service-purchase.php'; +require_once AKSRT_DIR . '/inc/classes/content/vip-service-traits/trait-aksrt-vip-service-packages.php'; require_once AKSRT_DIR . '/inc/classes/content/class-aksrt-vip-service.php'; require_once AKSRT_DIR . '/inc/classes/content/class-aksrt-download-state.php'; require_once AKSRT_DIR . '/inc/classes/core/class-aksrt-docs-presenter.php'; -// CMS 首页模块(类存在;模板侧目前主要通过 helpers 渲染) +// CMS 首页模块(预览 REST + 默认模板;模板主路径仍在 helpers) require_once AKSRT_DIR . '/inc/classes/core/class-aksrt-home-modules.php'; require_once AKSRT_DIR . '/inc/classes/content/class-aksrt-content-visibility.php'; @@ -271,6 +283,8 @@ require_once AKSRT_DIR . '/inc/classes/messages/class-aksrt-private-message.php' require_once AKSRT_DIR . '/inc/classes/messages/class-aksrt-report-center.php'; require_once AKSRT_DIR . '/inc/classes/payment/class-aksrt-coupon.php'; require_once AKSRT_DIR . '/inc/classes/payment/class-aksrt-order.php'; +require_once AKSRT_DIR . '/inc/classes/payment/order-repository-traits/trait-aksrt-order-repository-core.php'; +require_once AKSRT_DIR . '/inc/classes/payment/order-repository-traits/trait-aksrt-order-repository-admin.php'; require_once AKSRT_DIR . '/inc/classes/payment/class-aksrt-order-repository.php'; require_once AKSRT_DIR . '/inc/classes/payment/class-aksrt-payment-gateway.php'; require_once AKSRT_DIR . '/inc/classes/payment/gateways/class-aksrt-alipay-gateway.php'; @@ -345,19 +359,15 @@ require_once AKSRT_DIR . '/inc/classes/core/class-aksrt-post-meta-box.php'; require_once AKSRT_DIR . '/inc/classes/core/class-aksrt-resource-meta-box.php'; // --- widgets --- -require_once AKSRT_DIR . '/inc/classes/widgets/class-aksrt-widgets-original.php'; -require_once AKSRT_DIR . '/inc/classes/widgets/class-aksrt-widgets-new.php'; -require_once AKSRT_DIR . '/inc/classes/widgets/class-aksrt-widgets-batch1.php'; -// 自动加载 widgets 目录下的所有小工具文件(含 registry 之外的独立 widget) +// 包文件(多 widget 合一)+ 独立 widget 文件 + 注册中心。 +// 使用单一 glob:目录内所有 class-*.php(含 original/new/batch1/registry)。 +// require_once 幂等;新增 widget 只需丢进 widgets/ 即可被加载。 $aksrt_widgets_dir = AKSRT_DIR . '/inc/classes/widgets/'; if (is_dir($aksrt_widgets_dir)) { foreach (glob($aksrt_widgets_dir . 'class-*.php') as $aksrt_widget_file) { - // Registry 文件单独 require,避免与下方显式加载重复声明;glob 会再次命中 - // 但 require_once 幂等,安全。 require_once $aksrt_widget_file; } } -require_once AKSRT_DIR . '/inc/classes/widgets/class-aksrt-widgets.php'; // ============================================================ // 阶段3:核心功能模块(Gravatar替换 / SMTP邮件) diff --git a/inc/classes/content/class-aksrt-content-pay.php b/inc/classes/content/class-aksrt-content-pay.php index e6eb347d..725a7a7a 100644 --- a/inc/classes/content/class-aksrt-content-pay.php +++ b/inc/classes/content/class-aksrt-content-pay.php @@ -11,9 +11,14 @@ defined('ABSPATH') || exit; class AKSRT_Content_Pay { + use AKSRT_Content_Pay_Rest; + use AKSRT_Content_Pay_Orders; + use AKSRT_Content_Pay_Helpers; private static $instance = null; + + public static function get_instance(): self { if (null === self::$instance) { self::$instance = new self(); @@ -21,1183 +26,11 @@ class AKSRT_Content_Pay { return self::$instance; } + + public function init(): void { add_action('rest_api_init', [$this, 'register_routes']); add_action('aksrt_order_paid', [$this, 'on_order_paid'], 15, 2); } - public function register_routes(): void { - register_rest_route('aksrt/v1', '/pay/content/(?P\\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'], - ], - ]); - - register_rest_route('aksrt/v1', '/pay/content/order/(?P[A-Za-z0-9_-]+)', [ - 'methods' => 'GET', - 'callback' => [$this, 'rest_order_status'], - 'permission_callback' => [$this, 'check_rest_nonce'], - ]); - } - - /** - * REST nonce for logged-in users (wp_rest). Guests get 401 on purchase paths. - * - * @param WP_REST_Request $request - * @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; - } - - /** - * GET /pay/content/{id} - * query: coupon_code?, price_type? - */ - public function rest_preview(WP_REST_Request $request) { - $post_id = (int) $request['id']; - $uid = get_current_user_id(); - $coupon_code = sanitize_text_field((string) ($request->get_param('coupon_code') ?? $request->get_param('coupon') ?? '')); - $price_type = sanitize_key((string) ($request->get_param('price_type') ?? '')); - if ($price_type !== 'download_limit_over') { - $price_type = ''; - } - $preview = $this->preview($post_id, $uid, $coupon_code, $price_type); - if (is_wp_error($preview)) { - return $preview; - } - return new WP_REST_Response($preview, 200); - } - - /** - * POST /pay/content/{id} - * body: { mode?: points|cash, payment_method?: balance|alipay|..., price_type?: ''|download_limit_over, coupon_code?: string } - */ - public function rest_purchase(WP_REST_Request $request) { - $post_id = (int) $request['id']; - $uid = get_current_user_id(); - - $params = $request->get_json_params(); - if (!is_array($params)) { - $params = $request->get_params(); - } - - $price_type = sanitize_key((string) ($params['price_type'] ?? '')); - if ($price_type !== 'download_limit_over') { - $price_type = ''; - } - $coupon_code = sanitize_text_field((string) ($params['coupon_code'] ?? $params['coupon'] ?? '')); - - // Guest allowed only for cash + pay_limit=0 + setting on. - if ($uid <= 0) { - $post = get_post($post_id); - $allow_guest = function_exists('aksrt_allow_guest_content_pay') && aksrt_allow_guest_content_pay($post); - if (!$allow_guest) { - return new WP_Error('aksrt_need_login', __('请先登录后再购买。', 'aksrt'), [ - 'status' => 401, - 'login_url' => wp_login_url($post ? get_permalink($post) : home_url('/')), - ]); - } - } - - $gate = $this->assert_purchasable($post_id, $uid, $price_type); - if (is_wp_error($gate)) { - return $gate; - } - - $priced = $gate['priced']; - $pay_modo = (($priced['modo'] ?? '0') === 'points') ? 'points' : '0'; - - $mode = sanitize_key((string) ($params['mode'] ?? '')); - if ($mode === '') { - $mode = $pay_modo === 'points' ? 'points' : 'cash'; - } - if ($mode === 'points' || $pay_modo === 'points') { - if ($uid <= 0) { - return new WP_Error('aksrt_need_login', __('积分支付请先登录。', 'aksrt'), ['status' => 401]); - } - // Points path: ignore coupon (zibll parity). - return $this->rest_wrap_result($this->create_and_pay_points($post_id, $uid, $price_type)); - } - - $payment_method = sanitize_key((string) ($params['payment_method'] ?? 'balance')); - if ($payment_method === '' || $payment_method === 'cash') { - $payment_method = 'balance'; - } - // Guests cannot use balance/points gateways. - if ($uid <= 0 && in_array($payment_method, ['balance', 'points'], true)) { - $payment_method = 'alipay'; - } - return $this->rest_wrap_result($this->create_cash_order($post_id, $uid, $payment_method, $price_type, $coupon_code)); - } - - /** - * GET /pay/content/order/{order_number} - */ - public function rest_order_status(WP_REST_Request $request) { - $uid = get_current_user_id(); - $order_number = sanitize_text_field((string) $request['order_number']); - if ($order_number === '' || !class_exists('AKSRT_Order_Repository')) { - return new WP_Error('aksrt_invalid', __('订单不存在', 'aksrt'), ['status' => 404]); - } - $repo = AKSRT_Order_Repository::get_instance(); - if ($uid > 0) { - $order = $repo->get_user_order($uid, $order_number); - } else { - $order = $repo->get($order_number); - // Guest may only poll own guest cash orders (user_id=0): content/topic resource or plate_follow. - $ptype = sanitize_key((string) ($order['product_type'] ?? '')); - $guest_types = [ - AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE, - AKSRT_Domain_Model::ORDER_PRODUCT_PLATE_FOLLOW, - ]; - if ($order && ((int) ($order['user_id'] ?? -1) !== 0 || !in_array($ptype, $guest_types, true))) { - $order = null; - } - } - if (!$order) { - return new WP_Error('aksrt_invalid', __('订单不存在', 'aksrt'), ['status' => 404]); - } - $status = (string) ($order['status'] ?? ''); - $paid = class_exists('AKSRT_Domain_Model') && AKSRT_Domain_Model::is_paid_order_status($status); - if ($paid && $uid <= 0 && function_exists('aksrt_set_guest_pay_cookies')) { - // Ensure cookie set even if notify raced ahead of browser cookie. - aksrt_set_guest_pay_cookies($order); - } - return new WP_REST_Response([ - 'order_number' => $order_number, - 'status' => $status, - 'paid' => $paid, - 'amount' => (float) ($order['amount'] ?? 0), - 'currency' => (string) ($order['currency'] ?? ''), - 'product_id' => (int) ($order['product_id'] ?? 0), - 'product_type' => (string) ($order['product_type'] ?? ''), - 'reload' => $paid, - ], 200); - } - - /** - * Bump content sales when a resource order is paid. - * - * @param string $order_number - * @param array $paid_order - */ - 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_RESOURCE) { - return; - } - $post_id = (int) ($paid_order['product_id'] ?? 0); - if ($post_id <= 0) { - return; - } - // Only count normal posts (content pay), not necessarily forum topics. - $post = get_post($post_id); - if (!($post instanceof WP_Post) || $post->post_type !== 'post') { - return; - } - if (function_exists('aksrt_bump_content_sales_volume')) { - aksrt_bump_content_sales_volume($post_id, 1); - } else { - $key = AKSRT_Domain_Model::RESOURCE_SALES_VOLUME_KEY; - $cur = (int) get_post_meta($post_id, $key, true); - update_post_meta($post_id, $key, $cur + 1); - } - - // Guest cookie unlock (user_id=0). - $uid = (int) ($paid_order['user_id'] ?? 0); - if ($uid <= 0 && function_exists('aksrt_set_guest_content_pay_cookies')) { - // Ensure full order row for token material. - if (class_exists('AKSRT_Order_Repository')) { - $full = AKSRT_Order_Repository::get_instance()->get($order_number); - if (is_array($full)) { - $paid_order = array_merge($full, $paid_order); - } - } - aksrt_set_guest_content_pay_cookies($paid_order); - } - - // Consume coupon + write affiliate commission (cash content only; idempotent). - $this->finalize_content_order_side_effects($order_number, $paid_order); - } - - /** - * On paid: burn coupon (if any) and create affiliate rebate log. - */ - private function finalize_content_order_side_effects(string $order_number, array $order): void { - $meta = $this->decode_order_note_meta($order); - if (empty($meta['content_pay'])) { - return; - } - // Points: no coupon burn / no cash commission. - if (($meta['pay_modo'] ?? '') === 'points') { - return; - } - - $buyer_id = (int) ($order['user_id'] ?? 0); - - // Coupon burn (shared helper; idempotent via coupon_usage.order_number). - if (function_exists('aksrt_burn_order_coupon')) { - aksrt_burn_order_coupon($order_number, $meta, $buyer_id); - } - - // Affiliate commission (does not reduce buyer pay). - $referrer_id = (int) ($meta['referrer_id'] ?? 0); - $rebate_price = round((float) ($meta['rebate_price'] ?? 0), 2); - $pay_amount = round((float) ($order['amount'] ?? $meta['payable'] ?? 0), 2); - if ($referrer_id <= 0 || $rebate_price <= 0 || $pay_amount <= 0) { - return; - } - if (!class_exists('AKSRT_Rebate')) { - return; - } - if ($this->rebate_log_exists($order_number, $referrer_id)) { - return; - } - AKSRT_Rebate::get_instance()->create([ - 'user_id' => $referrer_id, - 'order_number' => $order_number, - 'source_user_id' => $buyer_id, - 'amount' => $rebate_price, - 'type' => AKSRT_Rebate::TYPE_ORDER, - 'note' => sprintf( - /* translators: 1: order number 2: post id */ - __('内容推广返佣 订单 %1$s 内容 #%2$d', 'aksrt'), - $order_number, - (int) ($order['product_id'] ?? $meta['post_id'] ?? 0) - ), - ]); - } - - /** - * @param array $order - * @return array - */ - private function decode_order_note_meta(array $order): array { - if (is_array($order['meta_payload'] ?? null) && $order['meta_payload'] !== []) { - return $order['meta_payload']; - } - $note = (string) ($order['note'] ?? ''); - if ($note === '') { - return []; - } - $decoded = json_decode($note, true); - return is_array($decoded) ? $decoded : []; - } - - private function rebate_log_exists(string $order_number, int $referrer_id): bool { - global $wpdb; - $table = AKSRT_DB_Migrator::table('rebate_log'); - if ($table === '' || $order_number === '') { - return false; - } - $found = $wpdb->get_var($wpdb->prepare( - "SELECT id FROM {$table} WHERE order_number = %s AND user_id = %d LIMIT 1", - $order_number, - $referrer_id - )); - return !empty($found); - } - - /** - * @param array|WP_Error $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); - } - - /** - * Cashier preview payload. - * - * @return array|WP_Error - */ - public function preview(int $post_id, int $user_id = 0, string $coupon_code = '', string $price_type = '') { - $post = get_post($post_id); - if (!($post instanceof WP_Post) || $post->post_status !== 'publish') { - return new WP_Error('aksrt_invalid', __('内容不存在', 'aksrt'), ['status' => 404]); - } - - $meta = function_exists('aksrt_get_pay_meta') ? aksrt_get_pay_meta($post) : []; - $pay_type = AKSRT_Domain_Model::normalize_pay_type((string) ($meta['pay_type'] ?? AKSRT_Domain_Model::PAY_TYPE_NO)); - if ($pay_type === AKSRT_Domain_Model::PAY_TYPE_NO) { - return new WP_Error('aksrt_not_paid', __('该内容无需购买', 'aksrt'), ['status' => 400]); - } - - $uid = $user_id > 0 ? $user_id : get_current_user_id(); - $priced = function_exists('aksrt_get_user_pay_price') - ? aksrt_get_user_pay_price($post, $uid) - : ['amount' => 0.0, 'base' => 0.0, 'modo' => '0', 'currency' => '¥', 'is_free' => true, 'pay_limit' => 0, 'vip_level' => 0, 'original' => 0.0, 'vip1' => 0.0, 'vip2' => 0.0]; - - // Optional overbuy override for preview display. - if ($price_type === 'download_limit_over') { - $over = max(0.0, (float) ($meta['download_limit_over_price'] ?? 0)); - if ($over > 0) { - $priced['amount'] = $over; - $priced['base'] = $over; - $priced['is_free'] = false; - } - } - - $pay_modo = (($priced['modo'] ?? '0') === 'points') ? 'points' : '0'; - $pay_limit = (int) ($priced['pay_limit'] ?? 0); - $vip_level = (int) ($priced['vip_level'] ?? 0); - $amount = (float) ($priced['amount'] ?? 0); - $base = (float) ($priced['base'] ?? $amount); - $over_price = max(0.0, (float) ($meta['download_limit_over_price'] ?? 0)); - - $list_amount = $amount; - $rebate_discount = 0.0; - $coupon_discount = 0.0; - $coupon_payload = null; - $coupon_error = ''; - $referrer_active = false; - $referrer_id = 0; - $rebate_price = 0.0; - $payable = $amount; - - $coupon_allowed = $pay_modo !== 'points' && $this->is_coupon_allowed($post_id, $meta); - $coupon_desc = ''; - if ($coupon_allowed) { - $coupon_desc = trim((string) ($meta['coupon_desc'] ?? '')); - if ($coupon_desc === '' && class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_content_coupon_settings')) { - $coupon_desc = (string) (AKSRT_Settings::get_content_coupon_settings()['desc'] ?? ''); - } - } - - if ($pay_modo !== 'points') { - $discounted = $this->apply_cash_discounts($priced, $post, $meta, $uid, $coupon_code, true); - if (is_wp_error($discounted)) { - // Preview: soft-fail coupon (show error, keep base payable without coupon). - $coupon_error = $discounted->get_error_message(); - $discounted = $this->apply_cash_discounts($priced, $post, $meta, $uid, '', true); - } - if (!is_wp_error($discounted)) { - $list_amount = (float) ($discounted['list_amount'] ?? $amount); - $rebate_discount = (float) ($discounted['rebate_discount'] ?? 0); - $coupon_discount = (float) ($discounted['coupon_discount'] ?? 0); - $coupon_payload = $discounted['coupon'] ?? null; - $payable = (float) ($discounted['payable'] ?? $amount); - $referrer_id = (int) ($discounted['referrer_id'] ?? 0); - $referrer_active = $referrer_id > 0; - $rebate_price = (float) ($discounted['rebate_price'] ?? 0); - $amount = $payable; - } - } - - $has_paid = false; - $paid_order = null; - if ($uid > 0 && class_exists('AKSRT_Order')) { - $has_paid = AKSRT_Order::get_instance()->has_paid_access( - $uid, - AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE, - $post_id - ); - if ($has_paid && function_exists('aksrt_get_user_content_paid_order')) { - $paid_order = aksrt_get_user_content_paid_order($post_id, $uid); - } - } - if (!$has_paid && $uid <= 0 && function_exists('aksrt_get_guest_content_paid_order')) { - $guest_order = aksrt_get_guest_content_paid_order($post_id); - if ($guest_order) { - $has_paid = true; - $paid_order = $guest_order; - } - } - - $pending_order = null; - if ($uid > 0 && !$has_paid && class_exists('AKSRT_Order_Repository')) { - $pending_order = $this->find_pending_content_order($uid, $post_id); - } - - $need_vip = $pay_limit > 0 && $vip_level < $pay_limit; - $allow_guest = function_exists('aksrt_allow_guest_content_pay') && aksrt_allow_guest_content_pay($post); - $currency = (string) ($priced['currency'] ?? ($pay_modo === 'points' - ? (class_exists('AKSRT_Settings') ? AKSRT_Settings::get_currency_name() : __('积分', 'aksrt')) - : '¥')); - - $balance = ($uid > 0 && class_exists('AKSRT_Credits')) ? AKSRT_Credits::balance($uid) : 0; - $methods = $this->available_payment_methods($pay_modo, $uid > 0); - - $amount_label = $this->format_amount_label($amount, $pay_modo, $currency); - $base_label = $this->format_amount_label($base, $pay_modo, $currency); - $list_label = $this->format_amount_label($list_amount, $pay_modo, $currency); - $over_label = $over_price > 0 ? $this->format_amount_label($over_price, $pay_modo, $currency) : ''; - - $vip_page = function_exists('aksrt_get_user_center_tab_url') - ? aksrt_get_user_center_tab_url('credits') - : (function_exists('aksrt_get_user_center_url') - ? aksrt_get_user_center_url() - : home_url('/')); - - $free_limit = $uid > 0 && function_exists('aksrt_get_user_free_download_limit') - ? aksrt_get_user_free_download_limit($uid) : 0; - $free_used = $uid > 0 && function_exists('aksrt_get_user_free_downloaded_number') - ? aksrt_get_user_free_downloaded_number($uid) : 0; - $quota_over = $free_limit > 0 && $free_used >= $free_limit; - - return [ - 'post_id' => $post_id, - 'title' => get_the_title($post), - 'permalink' => get_permalink($post), - 'pay_type' => $pay_type, - 'pay_type_label' => AKSRT_Domain_Model::pay_type_labels()[$pay_type] ?? $pay_type, - 'pay_modo' => $pay_modo, - 'pay_limit' => $pay_limit, - 'vip_level' => $vip_level, - 'amount' => $pay_modo === 'points' ? (int) ceil($amount) : round($amount, 2), - 'base' => $pay_modo === 'points' ? (int) ceil($base) : round($base, 2), - 'list_amount' => $pay_modo === 'points' ? (int) ceil($list_amount) : round($list_amount, 2), - 'list_amount_label' => $list_label, - 'payable' => $pay_modo === 'points' ? (int) ceil($payable) : round($payable, 2), - 'original' => (float) ($priced['original'] ?? 0), - 'vip1' => (float) ($priced['vip1'] ?? 0), - 'vip2' => (float) ($priced['vip2'] ?? 0), - 'currency' => $currency, - 'amount_label' => $amount_label, - 'base_label' => $base_label, - 'is_free' => !empty($priced['is_free']) || $amount <= 0, - 'has_paid_access' => $has_paid, - 'paid_order' => $paid_order ? [ - 'order_number' => (string) ($paid_order['order_number'] ?? ''), - 'paid_at' => (string) ($paid_order['paid_at'] ?? $paid_order['created_at'] ?? ''), - 'amount' => (float) ($paid_order['amount'] ?? 0), - 'currency' => (string) ($paid_order['currency'] ?? ''), - 'payment_method' => (string) ($paid_order['payment_method'] ?? ''), - ] : null, - 'pending_order' => $pending_order ? [ - 'order_number' => (string) ($pending_order['order_number'] ?? ''), - 'amount' => (float) ($pending_order['amount'] ?? 0), - 'payment_method' => (string) ($pending_order['payment_method'] ?? ''), - 'created_at' => (string) ($pending_order['created_at'] ?? ''), - ] : null, - 'need_login' => $uid <= 0 && !$allow_guest, - 'allow_guest' => $allow_guest, - 'need_vip' => $need_vip, - 'need_purchase' => !$has_paid && !$need_vip && $amount > 0, - 'logged_in' => $uid > 0, - 'balance' => $balance, - 'balance_enough' => $pay_modo !== 'points' || $balance >= (int) ceil($amount), - 'payment_methods' => $methods, - 'login_url' => wp_login_url(get_permalink($post)), - 'vip_url' => $vip_page, - 'credits_url' => $vip_page, - 'download_limit_over_price' => $pay_modo === 'points' ? (int) ceil($over_price) : round($over_price, 2), - 'download_limit_over_label' => $over_label, - 'allow_limit_over' => $over_price > 0, - 'quota_over' => $quota_over, - 'free_limit' => $free_limit, - 'free_used' => $free_used, - 'coupon_allowed' => $coupon_allowed, - 'coupon_desc' => $coupon_desc, - 'coupon_code' => $coupon_code, - 'coupon' => $coupon_payload, - 'coupon_error' => $coupon_error, - 'rebate_discount' => round($rebate_discount, 2), - 'rebate_discount_label' => $rebate_discount > 0 ? ('-' . $this->format_amount_label($rebate_discount, $pay_modo, $currency)) : '', - 'coupon_discount' => round($coupon_discount, 2), - 'coupon_discount_label' => $coupon_discount > 0 ? ('-' . $this->format_amount_label($coupon_discount, $pay_modo, $currency)) : '', - 'referrer_active' => $referrer_active, - 'discounts' => [ - 'list_amount' => round($list_amount, 2), - 'rebate_discount' => round($rebate_discount, 2), - 'coupon_discount' => round($coupon_discount, 2), - 'total_discount' => round(max(0, $list_amount - $payable), 2), - 'payable' => round($payable, 2), - 'rebate_price' => round($rebate_price, 2), - ], - // zibll: points_deduction runtime hard-off; card_pass not a content payment method. - 'points_deduction_allowed' => function_exists('aksrt_is_allow_points_deduction') - ? (bool) aksrt_is_allow_points_deduction('content') - : false, - 'card_pass_allowed' => function_exists('aksrt_is_content_card_pass_pay_allowed') - ? (bool) aksrt_is_content_card_pass_pay_allowed($post) - : false, - 'price_type' => $price_type, - ]; - } - - /** - * Points one-shot purchase → permanent resource order. - * - * @return array|WP_Error - */ - public function create_and_pay_points(int $post_id, int $user_id, string $price_type = '') { - $assert = $this->assert_purchasable($post_id, $user_id, $price_type); - if (is_wp_error($assert)) { - return $assert; - } - - $priced = $assert['priced']; - if ((($priced['modo'] ?? '0') !== 'points')) { - return new WP_Error('aksrt_wrong_modo', __('该内容不支持积分支付。', 'aksrt'), ['status' => 400]); - } - - $amount = (int) ceil((float) ($priced['amount'] ?? 0)); - if ($amount <= 0) { - return [ - 'success' => true, - 'data' => [ - 'free' => true, - 'message' => __('无需支付,已可访问。', 'aksrt'), - 'reload' => true, - ], - ]; - } - - if (!class_exists('AKSRT_Order') || !class_exists('AKSRT_Credits')) { - return new WP_Error('aksrt_pay_unavailable', __('支付系统不可用。', 'aksrt'), ['status' => 500]); - } - - $balance = AKSRT_Credits::balance($user_id); - if ($balance < $amount) { - return new WP_Error('aksrt_credits_insufficient', __('积分不足,暂时无法支付。', 'aksrt'), [ - 'status' => 402, - 'balance' => $balance, - 'cost' => $amount, - ]); - } - - $note = wp_json_encode([ - 'content_pay' => 1, - 'pay_modo' => 'points', - 'post_id' => $post_id, - 'price_type' => $price_type, - 'vip_level' => (int) ($priced['vip_level'] ?? 0), - 'base' => (float) ($priced['base'] ?? $amount), - 'amount' => $amount, - ], 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_RESOURCE, - 'product_id' => $post_id, - 'amount' => (float) $amount, - 'currency' => 'points', - 'status' => AKSRT_Order::STATUS_PENDING, - 'payment_method' => 'points', - '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]); - } - - $deducted = AKSRT_Credits::deduct( - $user_id, - $amount, - 'content_points_pay', - ['type' => 'post', 'id' => $post_id, 'order' => $order_number], - sprintf(__('积分购买《%s》', 'aksrt'), get_the_title($post_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 new WP_Error('aksrt_credits_deduct_failed', $msg ?: __('积分扣除失败。', 'aksrt'), ['status' => 400]); - } - - $paid = AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_PAID, [ - 'payment_method' => 'points', - 'payment_transaction_id' => 'points_' . $order_number, - 'note' => sprintf(__('积分支付 %d 积分。', 'aksrt'), $amount), - ]); - - if (!$paid) { - AKSRT_Credits::add( - $user_id, - $amount, - 'content_points_pay_refund', - ['type' => 'post', 'id' => $post_id, 'order' => $order_number], - sprintf(__('购买失败退款:%s', 'aksrt'), get_the_title($post_id)) - ); - return new WP_Error('aksrt_order_confirm_failed', __('订单确认失败,已尝试退回积分。', 'aksrt'), ['status' => 500]); - } - - return [ - 'success' => true, - 'data' => [ - 'order_number' => $order_number, - 'amount' => $amount, - 'pay_modo' => 'points', - 'balance' => AKSRT_Credits::balance($user_id), - 'message' => __('支付成功', 'aksrt'), - 'reload' => true, - ], - ]; - } - - /** - * Cash order create + balance/gateway pay. - * - * @return array|WP_Error - */ - public function create_cash_order(int $post_id, int $user_id, string $payment_method = 'balance', string $price_type = '', string $coupon_code = '') { - $assert = $this->assert_purchasable($post_id, $user_id, $price_type); - if (is_wp_error($assert)) { - return $assert; - } - - $post = $assert['post']; - $meta = $assert['meta']; - $priced = $assert['priced']; - if ((($priced['modo'] ?? '0') === 'points')) { - return new WP_Error('aksrt_wrong_modo', __('该内容仅支持积分支付。', 'aksrt'), ['status' => 400]); - } - - // Card pass cannot stack coupon (zibll). Content cashier does not sell via card_pass. - $payment_method = sanitize_key($payment_method); - if ($payment_method === 'card_pass') { - if (!function_exists('aksrt_is_content_card_pass_pay_allowed') || !aksrt_is_content_card_pass_pay_allowed($post)) { - return new WP_Error( - 'aksrt_card_pass_not_allowed', - __('内容购买不支持卡密支付,请在用户中心使用卡密兑换余额/会员/积分。', 'aksrt'), - ['status' => 400] - ); - } - $coupon_code = ''; - } - - $discounted = $this->apply_cash_discounts($priced, $post, $meta, $user_id, $coupon_code, false); - if (is_wp_error($discounted)) { - return $discounted; - } - - $amount = round((float) ($discounted['payable'] ?? 0), 2); - $list_amount = round((float) ($discounted['list_amount'] ?? $amount), 2); - $rebate_discount = round((float) ($discounted['rebate_discount'] ?? 0), 2); - $coupon_discount = round((float) ($discounted['coupon_discount'] ?? 0), 2); - $coupon_meta = is_array($discounted['coupon'] ?? null) ? $discounted['coupon'] : []; - $referrer_id = (int) ($discounted['referrer_id'] ?? 0); - $rebate_price = round((float) ($discounted['rebate_price'] ?? 0), 2); - - if ($amount <= 0) { - // Free after discount (or free content): create 0-paid order for access + burn coupon. - if (!class_exists('AKSRT_Order') || !class_exists('AKSRT_Order_Repository')) { - return new WP_Error('aksrt_pay_unavailable', __('支付系统不可用。', 'aksrt'), ['status' => 500]); - } - $note = wp_json_encode([ - 'content_pay' => 1, - 'pay_modo' => '0', - 'post_id' => $post_id, - 'price_type' => $price_type, - 'vip_level' => (int) ($priced['vip_level'] ?? 0), - 'base' => (float) ($priced['base'] ?? $list_amount), - 'list_amount' => $list_amount, - 'rebate_discount' => $rebate_discount, - 'coupon_discount' => $coupon_discount, - 'discount' => $coupon_discount, - 'coupon' => $coupon_meta, - 'coupon_code' => (string) ($coupon_meta['code'] ?? $coupon_code), - 'amount' => 0, - 'payable' => 0, - 'referrer_id' => $referrer_id, - 'rebate_price' => 0, - 'ip_address' => function_exists('aksrt_pay_client_ip') ? aksrt_pay_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? ''), - 'guest' => $user_id <= 0 ? 1 : 0, - ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - - $order_number = AKSRT_Order_Repository::get_instance()->create([ - 'user_id' => max(0, $user_id), - 'product_type' => AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE, - 'product_id' => $post_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 === '') { - return new WP_Error('aksrt_order_create_failed', __('创建订单失败。', 'aksrt'), ['status' => 500]); - } - $paid = AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_PAID, [ - 'payment_method' => 'free', - 'payment_transaction_id' => 'free_' . $order_number, - 'note' => __('优惠后无需支付。', 'aksrt'), - ]); - if (!$paid) { - return new WP_Error('aksrt_order_confirm_failed', __('订单确认失败。', 'aksrt'), ['status' => 500]); - } - return [ - 'success' => true, - 'data' => [ - 'order_number' => $order_number, - 'amount' => 0, - 'pay_modo' => '0', - 'free' => true, - 'message' => __('无需支付,已可访问。', 'aksrt'), - 'reload' => true, - ], - ]; - } - - if (!class_exists('AKSRT_Order') || !class_exists('AKSRT_Order_Repository')) { - return new WP_Error('aksrt_pay_unavailable', __('支付系统不可用。', 'aksrt'), ['status' => 500]); - } - - if ($payment_method === '') { - $payment_method = $user_id > 0 ? 'balance' : 'alipay'; - } - if ($user_id <= 0 && in_array($payment_method, ['balance', 'points'], true)) { - $payment_method = 'alipay'; - } - - $coupon_code_norm = (string) ($coupon_meta['code'] ?? $coupon_code); - - // Resume existing pending content order for this post when amount + coupon match (logged-in only). - $pending = $user_id > 0 ? $this->find_pending_content_order($user_id, $post_id) : null; - $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'] ?? ''); - $order = $pending; - } else { - $note = wp_json_encode([ - 'content_pay' => 1, - 'pay_modo' => '0', - 'post_id' => $post_id, - 'price_type' => $price_type, - 'vip_level' => (int) ($priced['vip_level'] ?? 0), - 'base' => (float) ($priced['base'] ?? $list_amount), - 'list_amount' => $list_amount, - 'rebate_discount' => $rebate_discount, - 'coupon_discount' => $coupon_discount, - 'discount' => $coupon_discount, - 'coupon' => $coupon_meta, - 'coupon_code' => $coupon_code_norm, - 'amount' => $amount, - 'payable' => $amount, - 'referrer_id' => $referrer_id, - 'rebate_price' => $rebate_price, - 'ip_address' => function_exists('aksrt_pay_client_ip') ? aksrt_pay_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? ''), - 'guest' => $user_id <= 0 ? 1 : 0, - ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - - $order_number = AKSRT_Order_Repository::get_instance()->create([ - 'user_id' => max(0, $user_id), - 'product_type' => AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE, - 'product_id' => $post_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]); - } - } - - $payment_method = sanitize_key($payment_method ?: (string) ($order['payment_method'] ?? 'balance')); - if ($payment_method === '') { - $payment_method = $user_id > 0 ? 'balance' : 'alipay'; - } - - $order['subject'] = sprintf( - /* translators: %s: post title */ - __('购买《%s》', 'aksrt'), - get_the_title($post_id) - ); - $order['payment_method'] = $payment_method; - // Ensure gateway uses discounted amount. - $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, - ], - ]; - } - - // Sync gateway (balance): mark paid. - $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]); - } - - return [ - 'success' => true, - 'data' => [ - 'order_number' => $order_number, - 'amount' => $amount, - 'pay_modo' => '0', - 'message' => $payment_result['message'] ?? __('支付成功', 'aksrt'), - 'reload' => true, - ], - ]; - } - - - /** - * @return array{post:WP_Post,meta:array,priced:array}|WP_Error - */ - private function assert_purchasable(int $post_id, int $user_id, string $price_type = '') { - $post = get_post($post_id); - if (!($post instanceof WP_Post) || $post->post_status !== 'publish') { - return new WP_Error('aksrt_invalid', __('内容不存在', 'aksrt'), ['status' => 404]); - } - - $meta = function_exists('aksrt_get_pay_meta') ? aksrt_get_pay_meta($post) : []; - $pay_type = AKSRT_Domain_Model::normalize_pay_type((string) ($meta['pay_type'] ?? AKSRT_Domain_Model::PAY_TYPE_NO)); - if ($pay_type === AKSRT_Domain_Model::PAY_TYPE_NO && $price_type !== 'download_limit_over') { - return new WP_Error('aksrt_not_paid', __('该内容无需购买', 'aksrt'), ['status' => 400]); - } - if ($pay_type === AKSRT_Domain_Model::PAY_TYPE_VIP) { - return new WP_Error('aksrt_need_vip', __('该内容为 VIP 专属,请开通会员。', 'aksrt'), ['status' => 403]); - } - - $pay_modo_meta = (($meta['pay_modo'] ?? '0') === 'points') ? 'points' : '0'; - $allow_guest = function_exists('aksrt_allow_guest_content_pay') && aksrt_allow_guest_content_pay($post); - if ($user_id <= 0) { - if ($pay_modo_meta === 'points' || !$allow_guest) { - return new WP_Error('aksrt_need_login', __('请先登录后再购买。', 'aksrt'), [ - 'status' => 401, - 'login_url' => wp_login_url(get_permalink($post)), - ]); - } - } - - $priced = function_exists('aksrt_get_user_pay_price') - ? aksrt_get_user_pay_price($post, $user_id) - : ['amount' => 0.0, 'modo' => '0', 'pay_limit' => 0, 'vip_level' => 0, 'is_free' => true, 'base' => 0.0]; - - // Free daily quota overbuy: override amount, ignore VIP discount (zibll). - if ($price_type === 'download_limit_over') { - $over = max(0.0, (float) ($meta['download_limit_over_price'] ?? 0)); - if ($over <= 0) { - return new WP_Error('aksrt_no_overbuy', __('未开放超额下载购买。', 'aksrt'), ['status' => 400]); - } - $priced['amount'] = $over; - $priced['base'] = $over; - $priced['is_free'] = false; - $priced['vip_amount'] = -1.0; - } - - $pay_limit = (int) ($priced['pay_limit'] ?? 0); - $vip_level = (int) ($priced['vip_level'] ?? 0); - if ($pay_limit > 0 && $vip_level < $pay_limit && $price_type !== 'download_limit_over') { - return new WP_Error('aksrt_need_vip', sprintf( - /* translators: %d: required VIP level */ - __('该资源仅限 VIP%s 及以上会员购买/下载。', 'aksrt'), - max(1, $pay_limit) - ), [ - 'status' => 403, - 'pay_limit' => $pay_limit, - ]); - } - - if ($user_id > 0 && class_exists('AKSRT_Order') && AKSRT_Order::get_instance()->has_paid_access( - $user_id, - AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE, - $post_id - )) { - return new WP_Error('aksrt_already_paid', __('您已购买该内容。', 'aksrt'), [ - 'status' => 200, - 'has_paid_access' => true, - 'reload' => true, - ]); - } - if ($user_id <= 0 && function_exists('aksrt_guest_has_paid_access') && aksrt_guest_has_paid_access($post_id)) { - return new WP_Error('aksrt_already_paid', __('您已购买该内容。', 'aksrt'), [ - 'status' => 200, - 'has_paid_access' => true, - 'reload' => true, - ]); - } - - return [ - 'post' => $post, - 'meta' => $meta, - 'priced' => $priced, - ]; - } - - /** - * @return array|null - */ - private function find_pending_content_order(int $user_id, int $post_id): ?array { - if ($user_id <= 0 || $post_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_RESOURCE, - 'status' => AKSRT_Domain_Model::ORDER_STATUS_PENDING, - 'limit' => 20, - 'offset' => 0, - ]); - foreach ($orders as $order) { - if ((int) ($order['product_id'] ?? 0) === $post_id) { - return $order; - } - } - return null; - } - - /** - * @return array - */ - private function available_payment_methods(string $pay_modo, bool $logged_in = true): array { - if ($pay_modo === 'points') { - return [ - ['id' => 'points', 'name' => __('积分支付', 'aksrt')], - ]; - } - - if (function_exists('aksrt_list_cash_payment_methods')) { - return aksrt_list_cash_payment_methods($logged_in); - } - - return $logged_in - ? [['id' => 'balance', 'name' => __('站内余额', 'aksrt')]] - : [['id' => 'alipay', 'name' => __('支付宝', 'aksrt')]]; - } - - - private function is_coupon_allowed(int $post_id, array $meta): bool { - if (!empty($meta['coupon_s'])) { - return true; - } - if (function_exists('aksrt_is_content_coupon_allowed')) { - return aksrt_is_content_coupon_allowed($post_id); - } - if (class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_content_coupon_settings')) { - return !empty(AKSRT_Settings::get_content_coupon_settings()['enabled']); - } - if (class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_paid_content_settings')) { - return !empty(AKSRT_Settings::get_paid_content_settings()['coupon_post_s']); - } - return false; - } - - /** - * @return array{id:int,ratio:float} - */ - private function resolve_referrer_context(int $user_id): array { - $empty = ['id' => 0, 'ratio' => 0.0]; - if (!class_exists('AKSRT_Settings') || !method_exists('AKSRT_Settings', 'get_payment_affiliate_settings')) { - return $empty; - } - $aff = AKSRT_Settings::get_payment_affiliate_settings(); - if (empty($aff['enabled'])) { - return $empty; - } - $referrer_id = 0; - if (class_exists('AKSRT_Invite') && method_exists(AKSRT_Invite::get_instance(), 'get_referrer')) { - $referrer_id = (int) AKSRT_Invite::get_instance()->get_referrer($user_id); - } - if ($referrer_id <= 0) { - return $empty; - } - - $ratio = (float) ($aff['ratio'] ?? 0); - $user_ratio = is_array($aff['user_ratio'] ?? null) ? $aff['user_ratio'] : []; - $ref_vip = 0; - if (function_exists('aksrt_get_user_vip_level')) { - $ref_vip = (int) aksrt_get_user_vip_level($referrer_id); - } - if ($ref_vip >= 2 && isset($user_ratio['vip2']) && (float) $user_ratio['vip2'] > 0) { - $ratio = (float) $user_ratio['vip2']; - } elseif ($ref_vip >= 1 && isset($user_ratio['vip1']) && (float) $user_ratio['vip1'] > 0) { - $ratio = (float) $user_ratio['vip1']; - } elseif (isset($user_ratio['user'])) { - $ratio = (float) $user_ratio['user']; - } - $ratio = max(0.0, min(100.0, $ratio)); - if ($ratio <= 0) { - return $empty; - } - return ['id' => $referrer_id, 'ratio' => $ratio]; - } - - /** - * Cash discount chain: VIP-priced list → 推广折扣 → coupon → payable + commission snapshot. - * - * @param array $priced - * @param array $meta - * @return array|WP_Error - */ - private function apply_cash_discounts(array $priced, WP_Post $post, array $meta, int $user_id, string $coupon_code = '', bool $soft_coupon = false) { - $list_amount = round(max(0.0, (float) ($priced['amount'] ?? 0)), 2); - $pay = $list_amount; - $rebate_discount = 0.0; - $coupon_discount = 0.0; - $coupon_meta = []; - - $ref = $this->resolve_referrer_context($user_id); - $referrer_id = (int) ($ref['id'] ?? 0); - $ratio = (float) ($ref['ratio'] ?? 0); - - // 1) Buyer-facing 推广折扣 (fixed). - $fixed = max(0.0, (float) ($meta['pay_rebate_discount'] ?? 0)); - if ($referrer_id > 0 && $ratio > 0 && $fixed > 0 && $pay > 0) { - $rebate_discount = round(min($pay, $fixed), 2); - $pay = round(max(0.0, $pay - $rebate_discount), 2); - } - - // 2) Coupon after rebate discount. - $coupon_code = sanitize_text_field($coupon_code); - if ($coupon_code !== '') { - if (!$this->is_coupon_allowed((int) $post->ID, $meta)) { - $err = new WP_Error('aksrt_coupon_not_allowed', __('当前内容不支持优惠码。', 'aksrt'), ['status' => 400]); - if (!$soft_coupon) { - return $err; - } - } elseif (!class_exists('AKSRT_Coupon')) { - $err = new WP_Error('aksrt_coupon_unavailable', __('优惠券系统不可用。', 'aksrt'), ['status' => 500]); - if (!$soft_coupon) { - return $err; - } - } else { - $result = AKSRT_Coupon::get_instance()->validate($coupon_code, $pay, [ - 'user_id' => max(0, $user_id), - 'product_ids' => [(int) $post->ID], - 'context' => 'content', - ]); - if (empty($result['valid'])) { - $err = new WP_Error( - 'aksrt_coupon_error', - (string) ($result['message'] ?? __('优惠码不可用。', 'aksrt')), - ['status' => 400] - ); - if (!$soft_coupon) { - return $err; - } - } 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, - ]; - } - } - } - - $payable = round(max(0.0, $pay), 2); - $rebate_price = 0.0; - if ($referrer_id > 0 && $ratio > 0 && $payable > 0) { - $rebate_price = round($payable * $ratio / 100, 2); - } - - return [ - 'list_amount' => $list_amount, - 'rebate_discount' => $rebate_discount, - 'coupon_discount' => $coupon_discount, - 'coupon' => $coupon_meta, - 'payable' => $payable, - 'referrer_id' => $referrer_id, - 'rebate_price' => $rebate_price, - 'ratio' => $ratio, - ]; - } - - private function format_amount_label(float $amount, string $pay_modo, string $currency): string { - if ($amount <= 0) { - return (string) __('免费', 'aksrt'); - } - $n = function_exists('aksrt_format_price') ? aksrt_format_price($amount) : (string) $amount; - if ($pay_modo === 'points') { - return $n . ' ' . $currency; - } - return $currency . $n; - } } diff --git a/inc/classes/content/class-aksrt-vip-service.php b/inc/classes/content/class-aksrt-vip-service.php index c1d40a3c..63e00ee4 100644 --- a/inc/classes/content/class-aksrt-vip-service.php +++ b/inc/classes/content/class-aksrt-vip-service.php @@ -13,9 +13,14 @@ defined('ABSPATH') || exit; class AKSRT_Vip_Service { + use AKSRT_Vip_Service_Rest; + use AKSRT_Vip_Service_Purchase; + use AKSRT_Vip_Service_Packages; private static $instance = null; + + public static function get_instance(): self { if (null === self::$instance) { self::$instance = new self(); @@ -23,1145 +28,11 @@ class AKSRT_Vip_Service { return self::$instance; } + + public function init(): void { add_action('rest_api_init', [$this, 'register_routes']); add_action('aksrt_order_paid', [$this, 'on_order_paid'], 20, 2); } - public function register_routes(): void { - register_rest_route('aksrt/v1', '/pay/vip', [ - [ - 'methods' => 'GET', - 'callback' => [$this, 'rest_catalog'], - 'permission_callback' => [$this, 'check_rest_nonce'], - ], - [ - 'methods' => 'POST', - 'callback' => [$this, 'rest_purchase'], - 'permission_callback' => [$this, 'check_rest_nonce'], - ], - ]); - - register_rest_route('aksrt/v1', '/pay/vip/points-exchange', [ - 'methods' => 'POST', - 'callback' => [$this, 'rest_points_exchange'], - 'permission_callback' => [$this, 'check_rest_nonce'], - ]); - } - - /** - * @param WP_REST_Request $request - * @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; - } - - /** - * GET /pay/vip - */ - public function rest_catalog(WP_REST_Request $request) { - $uid = get_current_user_id(); - return new WP_REST_Response($this->get_catalog($uid), 200); - } - - /** - * POST /pay/vip - * body: { package_id: int, payment_method?: string } - */ - public function rest_purchase(WP_REST_Request $request) { - $uid = get_current_user_id(); - if ($uid <= 0) { - return new WP_Error('aksrt_need_login', __('请先登录后再开通会员。', 'aksrt'), [ - 'status' => 401, - 'login_url' => function_exists('aksrt_get_login_url') ? aksrt_get_login_url() : wp_login_url(), - ]); - } - - $params = $request->get_json_params(); - if (!is_array($params)) { - $params = $request->get_params(); - } - $package_id = (int) ($params['package_id'] ?? 0); - $payment_method = sanitize_key((string) ($params['payment_method'] ?? 'balance')); - if ($payment_method === '' || $payment_method === 'cash') { - $payment_method = 'balance'; - } - - $result = $this->purchase($uid, $package_id, $payment_method); - if (is_wp_error($result)) { - return $result; - } - if (empty($result['success'])) { - return new WP_Error( - sanitize_key((string) ($result['code'] ?? 'aksrt_vip_pay_failed')), - (string) ($result['message'] ?? __('开通失败', 'aksrt')), - ['status' => (int) ($result['status'] ?? 400)] - ); - } - return new WP_REST_Response($result, 200); - } - - /** - * POST /pay/vip/points-exchange - * body: { package_id: int } // points package id = 5000 + index - */ - public function rest_points_exchange(WP_REST_Request $request) { - $uid = get_current_user_id(); - if ($uid <= 0) { - return new WP_Error('aksrt_need_login', __('请先登录后再兑换。', 'aksrt'), ['status' => 401]); - } - $params = $request->get_json_params(); - if (!is_array($params)) { - $params = $request->get_params(); - } - $package_id = (int) ($params['package_id'] ?? 0); - $result = $this->exchange_with_points($uid, $package_id); - if (is_wp_error($result)) { - return $result; - } - return new WP_REST_Response($result, 200); - } - - /** - * @return array - */ - public function get_catalog(int $user_id = 0): array { - $vip = class_exists('AKSRT_Settings') - ? AKSRT_Settings::get_payment_vip_settings() - : ['enabled' => false, 'packages_raw' => '', 'levels' => [], 'renew' => [], 'upgrade' => [], 'points_exchange' => []]; - - $level = function_exists('aksrt_get_user_vip_level') ? aksrt_get_user_vip_level($user_id) : 0; - $expires = $user_id > 0 ? (int) get_user_meta($user_id, 'aksrt_vip_expires', true) : 0; - $is_permanent = ($level > 0 && $expires <= 0); - $packages = $this->parse_packages((string) ($vip['packages_raw'] ?? '')); - $levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : []; - $renew = is_array($vip['renew'] ?? null) ? $vip['renew'] : []; - $upgrade = is_array($vip['upgrade'] ?? null) ? $vip['upgrade'] : []; - $points_ex = is_array($vip['points_exchange'] ?? null) ? $vip['points_exchange'] : []; - $renew_enabled = !isset($renew['enabled']) || !empty($renew['enabled']); - $upgrade_enabled = !isset($upgrade['enabled']) || !empty($upgrade['enabled']); - $points_enabled = !empty($points_ex['enabled']) - && (!class_exists('AKSRT_Settings') || AKSRT_Settings::credits_enabled()); - - $level_packages = []; - foreach ([1, 2] as $lv) { - if (empty($levels[$lv]['enabled'])) { - continue; - } - if ($is_permanent && $lv <= $level) { - continue; - } - if ($level > 0 && $lv === $level && !$renew_enabled) { - continue; - } - if ($level > 0 && $lv > $level && !$upgrade_enabled) { - continue; - } - - $name = (string) ($levels[$lv]['name'] ?? ('VIP' . $lv)); - $items = []; - foreach ($packages as $idx => $pkg) { - $base_price = (float) $pkg['price']; - $days = (int) $pkg['days']; - $action = 'pay'; - if ($level > 0 && $lv === $level) { - $action = 'renew'; - } elseif ($level > 0 && $lv > $level) { - $action = 'upgrade'; - } - - $priced = $this->apply_action_price($base_price, $action, $renew, $upgrade, $lv, $level, $expires, $is_permanent, $days); - if ($priced === null) { - continue; - } - - $items[] = [ - 'package_id' => $this->encode_package_id($lv, $idx + 1), - 'level' => $lv, - 'index' => $idx + 1, - 'action' => $action, - 'price' => (float) $priced['price'], - 'base_price' => $base_price, - 'show_price' => (float) ($priced['show_price'] ?? 0), - 'discount_tag' => (string) ($priced['tag'] ?? ''), - 'days' => $days, - 'title' => (string) $pkg['title'], - 'price_label' => $this->format_cash_label((float) $priced['price']), - 'show_price_label' => !empty($priced['show_price']) - ? $this->format_cash_label((float) $priced['show_price']) - : '', - 'days_label' => ($days <= 0) - ? __('永久', 'aksrt') - : sprintf(__('%d 天', 'aksrt'), $days), - 'cta_label' => $action === 'renew' - ? __('立即续费', 'aksrt') - : ($action === 'upgrade' ? __('立即升级', 'aksrt') : __('立即开通', 'aksrt')), - ]; - } - - // Dedicated upgrade product: fixed or remaining-days unit price. - if ($level === 1 && $lv === 2 && $upgrade_enabled) { - $upgrade_items = $this->build_upgrade_items($user_id, $expires, $is_permanent, (string) ($upgrade['product'] ?? '')); - foreach ($upgrade_items as $ui) { - $items[] = $ui; - } - } - - if ($items === []) { - continue; - } - $level_packages[] = [ - 'level' => $lv, - 'name' => $name, - 'action' => ($level > 0 && $lv === $level) ? 'renew' : (($level > 0 && $lv > $level) ? 'upgrade' : 'pay'), - 'items' => $items, - ]; - } - - $points_packages = []; - if ($points_enabled && $level <= 0) { - $points_packages = $this->get_points_exchange_packages($vip, $levels); - } - - $balance = ($user_id > 0 && class_exists('AKSRT_Credits')) ? AKSRT_Credits::balance($user_id) : 0; - $methods = $this->available_payment_methods(); - $currency_name = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_currency_name() : __('积分', 'aksrt'); - - return [ - 'enabled' => !empty($vip['enabled']), - 'desc' => (string) ($vip['desc'] ?? ''), - 'help_text' => (string) ($vip['help_text'] ?? ''), - 'user_level' => $level, - 'user_expires' => $expires, - 'user_is_permanent' => $is_permanent, - 'user_expires_label' => $this->format_expires_label($expires, $level), - 'user_points' => $balance, - 'currency_name' => $currency_name, - 'renew_enabled' => $renew_enabled, - 'upgrade_enabled' => $upgrade_enabled, - 'renew_desc' => (string) ($renew['desc'] ?? ''), - 'upgrade_desc' => (string) ($upgrade['desc'] ?? ''), - 'level_packages' => $level_packages, - 'points_exchange' => [ - 'enabled' => $points_enabled && $level <= 0, - 'desc' => (string) ($points_ex['desc'] ?? ''), - 'packages' => $points_packages, - ], - 'payment_methods' => $methods, - 'vip_url' => function_exists('aksrt_get_user_center_tab_url') - ? aksrt_get_user_center_tab_url('credits') - : (function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/')), - 'logged_in' => $user_id > 0, - ]; - } - - /** - * @return array|WP_Error - */ - public function purchase(int $user_id, int $package_id, string $payment_method = 'balance') { - if ($user_id <= 0) { - return new WP_Error('aksrt_need_login', __('请先登录后再开通会员。', 'aksrt'), ['status' => 401]); - } - - $decoded = $this->decode_package_id($package_id); - if (!$decoded) { - return new WP_Error('aksrt_invalid_package', __('套餐无效', 'aksrt'), ['status' => 400]); - } - $level = (int) $decoded['level']; - $index = (int) $decoded['index']; - - $vip = class_exists('AKSRT_Settings') - ? AKSRT_Settings::get_payment_vip_settings() - : ['enabled' => false, 'packages_raw' => '', 'levels' => [], 'renew' => [], 'upgrade' => []]; - if (empty($vip['enabled'])) { - return new WP_Error('aksrt_vip_disabled', __('会员功能未启用', 'aksrt'), ['status' => 400]); - } - $levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : []; - if (empty($levels[$level]['enabled'])) { - return new WP_Error('aksrt_vip_level_disabled', __('该会员等级未启用', 'aksrt'), ['status' => 400]); - } - - $user_level = function_exists('aksrt_get_user_vip_level') ? aksrt_get_user_vip_level($user_id) : 0; - $expires = (int) get_user_meta($user_id, 'aksrt_vip_expires', true); - $is_permanent = ($user_level > 0 && $expires <= 0); - $renew = is_array($vip['renew'] ?? null) ? $vip['renew'] : []; - $upgrade = is_array($vip['upgrade'] ?? null) ? $vip['upgrade'] : []; - $renew_enabled = !isset($renew['enabled']) || !empty($renew['enabled']); - $upgrade_enabled = !isset($upgrade['enabled']) || !empty($upgrade['enabled']); - - if ($is_permanent && $level <= $user_level) { - return new WP_Error('aksrt_vip_permanent', __('您已是永久会员,无需续费。', 'aksrt'), ['status' => 400]); - } - if ($user_level > 0 && $level === $user_level && !$renew_enabled) { - return new WP_Error('aksrt_vip_renew_disabled', __('续费功能未开启。', 'aksrt'), ['status' => 400]); - } - if ($user_level > 0 && $level > $user_level && !$upgrade_enabled) { - return new WP_Error('aksrt_vip_upgrade_disabled', __('升级功能未开启。', 'aksrt'), ['status' => 400]); - } - if ($user_level > 0 && $level < $user_level) { - return new WP_Error('aksrt_vip_downgrade', __('不支持降级购买。', 'aksrt'), ['status' => 400]); - } - - $action = 'pay'; - if ($user_level > 0 && $level === $user_level) { - $action = 'renew'; - } elseif ($user_level > 0 && $level > $user_level) { - $action = 'upgrade'; - } - - // Dedicated upgrade product: - // 901 permanent/fixed, 902 unit remaining days, 903 jump permanent. - $upgrade_mode = ''; - if (in_array($index, [901, 902, 903], true)) { - $upgrade_items = $this->build_upgrade_items($user_id, $expires, $is_permanent, (string) ($upgrade['product'] ?? '')); - $match = null; - foreach ($upgrade_items as $ui) { - if ((int) ($ui['package_id'] ?? 0) === $package_id) { - $match = $ui; - break; - } - } - if (!$match || $level !== 2) { - return new WP_Error('aksrt_invalid_package', __('升级套餐不存在', 'aksrt'), ['status' => 400]); - } - $pkg = [ - 'price' => (float) $match['price'], - 'days' => (int) $match['days'], - 'title' => (string) $match['title'], - ]; - $amount = round((float) $match['price'], 2); - $days = (int) $match['days']; - $base_price = (float) ($match['base_price'] ?? $amount); - $discount_tag = (string) ($match['discount_tag'] ?? __('升级专享', 'aksrt')); - $upgrade_mode = sanitize_key((string) ($match['upgrade_mode'] ?? '')); - $action = 'upgrade'; - } else { - $packages = $this->parse_packages((string) ($vip['packages_raw'] ?? '')); - $pkg = $packages[$index - 1] ?? null; - if (!$pkg) { - return new WP_Error('aksrt_invalid_package', __('套餐不存在', 'aksrt'), ['status' => 400]); - } - $base_price = (float) $pkg['price']; - $days = (int) $pkg['days']; - $priced = $this->apply_action_price($base_price, $action, $renew, $upgrade, $level, $user_level, $expires, $is_permanent, $days); - if ($priced === null) { - return new WP_Error('aksrt_invalid_package', __('当前无法购买该套餐', 'aksrt'), ['status' => 400]); - } - $amount = round((float) $priced['price'], 2); - $discount_tag = (string) ($priced['tag'] ?? ''); - } - - if ($amount <= 0) { - return new WP_Error('aksrt_invalid_price', __('套餐价格无效', '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'; - } - - $note = wp_json_encode([ - 'vip_pay' => 1, - 'level' => $level, - 'days' => $days, - 'package_index' => $index, - 'package_title' => (string) ($pkg['title'] ?? ''), - 'package_id' => $package_id, - 'action' => $action, - 'upgrade_mode' => $upgrade_mode, - 'base_price' => $base_price, - 'discount_tag' => $discount_tag, - ], 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_VIP, - 'product_id' => $package_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]); - } - - $level_name = (string) ($levels[$level]['name'] ?? ('VIP' . $level)); - $action_label = $action === 'renew' ? __('续费', 'aksrt') : ($action === 'upgrade' ? __('升级', 'aksrt') : __('开通', 'aksrt')); - $order['subject'] = sprintf( - /* translators: 1: action 2: vip name 3: package title */ - __('%1$s%2$s · %3$s', 'aksrt'), - $action_label, - $level_name, - (string) ($pkg['title'] ?? '') - ); - $order['payment_method'] = $payment_method; - - if (!class_exists('AKSRT_Payment_Gateway')) { - return new WP_Error('aksrt_pay_unavailable', __('支付网关不可用', 'aksrt'), ['status' => 500]); - } - $gateway = AKSRT_Payment_Gateway::get_instance()->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, - '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]); - } - - return [ - 'success' => true, - 'data' => [ - 'order_number' => $order_number, - 'amount' => $amount, - 'level' => $level, - 'days' => $days, - 'action' => $action, - 'message' => $action === 'renew' - ? __('会员续费成功', 'aksrt') - : ($action === 'upgrade' ? __('会员升级成功', 'aksrt') : __('会员开通成功', 'aksrt')), - 'reload' => true, - ], - ]; - } - - /** - * Activate VIP on paid order. - * - * @param string $order_number - * @param array $paid_order - */ - public function on_order_paid(string $order_number, array $paid_order): void { - if (sanitize_key((string) ($paid_order['product_type'] ?? '')) !== AKSRT_Domain_Model::ORDER_PRODUCT_VIP) { - return; - } - $user_id = (int) ($paid_order['user_id'] ?? 0); - if ($user_id <= 0) { - return; - } - - $meta = []; - if (!empty($paid_order['note']) && is_string($paid_order['note'])) { - $decoded = json_decode($paid_order['note'], true); - if (is_array($decoded)) { - $meta = $decoded; - } - } - if (empty($meta) && is_array($paid_order['meta_payload'] ?? null)) { - $meta = $paid_order['meta_payload']; - } - - $level = (int) ($meta['level'] ?? 0); - $days = (int) ($meta['days'] ?? 0); - $upgrade_mode = sanitize_key((string) ($meta['upgrade_mode'] ?? '')); - if ($level <= 0) { - $decoded = $this->decode_package_id((int) ($paid_order['product_id'] ?? 0)); - if ($decoded) { - $level = (int) $decoded['level']; - } - } - if ($level < 1) { - $level = 1; - } - if ($level > 2) { - $level = 2; - } - - // unit upgrade: raise level only, keep current expiry (zibll). - if ($upgrade_mode === 'unit') { - $this->activate_vip($user_id, $level, 0, true); - } else { - $this->activate_vip($user_id, $level, $days, false); - } - } - - /** - * Apply membership meta. - * - * @param bool $keep_expiry When true (unit upgrade), only raise level and do not change expiry. - * When days<=0 and keep_expiry=false, set permanent (expires=0). - */ - public function activate_vip(int $user_id, int $level, int $days = 0, bool $keep_expiry = false): void { - if ($user_id <= 0) { - return; - } - $level = max(1, min(2, $level)); - $current_level = (int) get_user_meta($user_id, 'aksrt_vip_level', true); - $new_level = max($current_level, $level); - update_user_meta($user_id, 'aksrt_vip_level', $new_level); - - // Unit upgrade (zibll): keep vip_exp_date, only raise level. - if ($keep_expiry) { - return; - } - - if ($days <= 0) { - // Permanent. - update_user_meta($user_id, 'aksrt_vip_expires', 0); - return; - } - - $current_exp = (int) get_user_meta($user_id, 'aksrt_vip_expires', true); - if ($current_exp === 0 && $current_level > 0) { - // Already permanent — keep permanent. - return; - } - $base = max($current_exp, time()); - update_user_meta($user_id, 'aksrt_vip_expires', $base + ($days * DAY_IN_SECONDS)); - } - - /** - * Parse packages_raw lines: price|days|title - * - * @return array - */ - public function parse_packages(string $raw): array { - $lines = preg_split('/\r\n|\r|\n/', $raw) ?: []; - $out = []; - foreach ($lines as $line) { - $line = trim((string) $line); - if ($line === '') { - continue; - } - $parts = array_map('trim', explode('|', $line)); - $price = isset($parts[0]) ? (float) $parts[0] : 0.0; - $days = isset($parts[1]) ? (int) $parts[1] : 0; - $title = isset($parts[2]) ? sanitize_text_field($parts[2]) : ''; - if ($price <= 0) { - continue; - } - if ($title === '') { - $title = $days > 0 - ? sprintf(__('%d天会员', 'aksrt'), $days) - : __('永久会员', 'aksrt'); - } - $out[] = [ - 'price' => round($price, 2), - 'days' => max(0, $days), - 'title' => $title, - ]; - } - return $out; - } - - /** - * Points exchange (non-VIP only, zibll pay_vip_points_exchange). - * - * @return array|WP_Error - */ - public function exchange_with_points(int $user_id, int $package_id) { - if ($user_id <= 0) { - return new WP_Error('aksrt_need_login', __('请先登录后再兑换。', 'aksrt'), ['status' => 401]); - } - if (!class_exists('AKSRT_Credits')) { - return new WP_Error('aksrt_credits_unavailable', __('积分系统不可用', 'aksrt'), ['status' => 500]); - } - if (class_exists('AKSRT_Settings') && !AKSRT_Settings::credits_enabled()) { - return new WP_Error('aksrt_credits_disabled', __('积分功能未启用', 'aksrt'), ['status' => 400]); - } - - $vip = class_exists('AKSRT_Settings') - ? AKSRT_Settings::get_payment_vip_settings() - : []; - $points_ex = is_array($vip['points_exchange'] ?? null) ? $vip['points_exchange'] : []; - if (empty($points_ex['enabled'])) { - return new WP_Error('aksrt_points_exchange_disabled', __('积分兑换会员未启用', 'aksrt'), ['status' => 400]); - } - - $user_level = function_exists('aksrt_get_user_vip_level') ? aksrt_get_user_vip_level($user_id) : 0; - if ($user_level > 0) { - return new WP_Error('aksrt_already_vip', __('您已是会员,无法使用积分兑换。', 'aksrt'), ['status' => 400]); - } - - $levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : []; - $packages = $this->get_points_exchange_packages($vip, $levels); - $match = null; - foreach ($packages as $pkg) { - if ((int) ($pkg['package_id'] ?? 0) === $package_id) { - $match = $pkg; - break; - } - } - if (!$match) { - return new WP_Error('aksrt_invalid_package', __('兑换套餐不存在', 'aksrt'), ['status' => 400]); - } - - $cost = (int) ($match['points'] ?? 0); - $level = (int) ($match['level'] ?? 1); - $days = (int) ($match['days'] ?? 0); - if ($cost <= 0) { - return new WP_Error('aksrt_invalid_price', __('兑换积分无效', 'aksrt'), ['status' => 400]); - } - - $balance = AKSRT_Credits::balance($user_id); - if ($balance < $cost) { - return new WP_Error('aksrt_credits_insufficient', __('积分不足,暂时无法兑换', 'aksrt'), [ - 'status' => 402, - 'balance' => $balance, - 'cost' => $cost, - ]); - } - - if (!class_exists('AKSRT_Order') || !class_exists('AKSRT_Order_Repository')) { - return new WP_Error('aksrt_pay_unavailable', __('订单系统不可用', 'aksrt'), ['status' => 500]); - } - - $note = wp_json_encode([ - 'vip_pay' => 1, - 'vip_pay_type' => 'points_exchange', - 'level' => $level, - 'days' => $days, - 'points' => $cost, - 'package_id' => $package_id, - 'package_title' => (string) ($match['title'] ?? ''), - 'action' => 'points_exchange', - ], 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_VIP, - 'product_id' => $package_id, - 'amount' => (float) $cost, - 'currency' => 'points', - 'status' => AKSRT_Order::STATUS_PENDING, - 'payment_method' => 'points', - '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]); - } - - $deducted = AKSRT_Credits::deduct( - $user_id, - $cost, - 'vip_points_exchange', - ['order' => $order_number, 'level' => $level, 'days' => $days], - sprintf(__('积分兑换会员 · %s', 'aksrt'), (string) ($match['title'] ?? '')) - ); - 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 new WP_Error('aksrt_credits_deduct_failed', $msg ?: __('积分扣除失败', 'aksrt'), ['status' => 400]); - } - - $paid = AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_PAID, [ - 'payment_method' => 'points', - 'payment_transaction_id' => 'points_' . $order_number, - 'note' => sprintf(__('积分兑换会员 %d 积分。', 'aksrt'), $cost), - ]); - if (!$paid) { - AKSRT_Credits::add( - $user_id, - $cost, - 'vip_points_exchange_refund', - ['order' => $order_number], - __('会员兑换失败退款', 'aksrt') - ); - return new WP_Error('aksrt_order_confirm_failed', __('兑换确认失败,已尝试退回积分', 'aksrt'), ['status' => 500]); - } - - return [ - 'success' => true, - 'data' => [ - 'order_number' => $order_number, - 'points' => $cost, - 'level' => $level, - 'days' => $days, - 'balance' => AKSRT_Credits::balance($user_id), - 'message' => __('兑换成功', 'aksrt'), - 'reload' => true, - ], - ]; - } - - /** - * Build upgrade package items (zibll vip_upgrade_product parity). - * - * upgrade.product multi-line formats: - * - permanent VIP1: permanent|price|title[|show_price] - * - fixed (alias): price|days|title - * - timed unit: unit|daily_price|title - * - timed jump permanent: jump|price|title[|show_price] - * - * Rules: - * - permanent user: only permanent/fixed (days=0 preferred) - * - timed user: unit (keep expiry) + optional jump (to permanent) - * - * package index: 901 permanent/fixed, 902 unit, 903 jump - * - * @return array> - */ - private function build_upgrade_items(int $user_id, int $expires, bool $is_permanent, string $raw): array { - $raw = trim($raw); - if ($raw === '') { - return []; - } - $items = []; - $lines = preg_split('/\r\n|\r|\n/', $raw) ?: []; - $has_permanent = false; - $has_unit = false; - $has_jump = false; - - foreach ($lines as $line) { - $line = trim((string) $line); - if ($line === '') { - continue; - } - $parts = array_map('trim', explode('|', $line)); - $mode = strtolower((string) ($parts[0] ?? '')); - - // unit|daily|title — timed VIP1 only. - if ($mode === 'unit') { - if ($is_permanent || $expires <= time() || $has_unit) { - continue; - } - $daily = isset($parts[1]) ? (float) $parts[1] : 0.0; - if ($daily <= 0) { - continue; - } - $remaining = (int) ceil(max(1, ($expires - time()) / DAY_IN_SECONDS)); - $price = round(max(0.01, $remaining * $daily), 2); - $title = sanitize_text_field((string) ($parts[2] ?? '')); - if ($title === '') { - $title = sprintf(__('按剩余 %d 天升级', 'aksrt'), $remaining); - } - $items[] = [ - 'package_id' => $this->encode_package_id(2, 902), - 'level' => 2, - 'index' => 902, - 'action' => 'upgrade', - 'price' => $price, - 'base_price' => $price, - 'show_price' => 0.0, - 'discount_tag' => __('剩余天数', 'aksrt'), - 'days' => $remaining, - 'title' => $title, - 'price_label' => $this->format_cash_label($price), - 'show_price_label' => '', - 'days_label' => sprintf(__('%d 天', 'aksrt'), $remaining), - 'cta_label' => __('立即升级', 'aksrt'), - 'is_upgrade_product' => true, - 'upgrade_mode' => 'unit', - ]; - $has_unit = true; - continue; - } - - // jump|price|title[|show_price] — timed VIP1 only → permanent VIP2. - if ($mode === 'jump') { - if ($is_permanent || $has_jump) { - continue; - } - $price = isset($parts[1]) ? (float) $parts[1] : 0.0; - if ($price <= 0) { - continue; - } - $title = sanitize_text_field((string) ($parts[2] ?? '')); - if ($title === '') { - $title = __('升级为永久会员', 'aksrt'); - } - $show = isset($parts[3]) ? (float) $parts[3] : 0.0; - $items[] = [ - 'package_id' => $this->encode_package_id(2, 903), - 'level' => 2, - 'index' => 903, - 'action' => 'upgrade', - 'price' => round($price, 2), - 'base_price' => round($price, 2), - 'show_price' => max(0.0, $show), - 'discount_tag' => __('永久升级', 'aksrt'), - 'days' => 0, - 'title' => $title, - 'price_label' => $this->format_cash_label($price), - 'show_price_label' => $show > 0 ? $this->format_cash_label($show) : '', - 'days_label' => __('永久', 'aksrt'), - 'cta_label' => __('立即升级', 'aksrt'), - 'is_upgrade_product' => true, - 'upgrade_mode' => 'jump', - ]; - $has_jump = true; - continue; - } - - // permanent|price|title[|show_price] - if ($mode === 'permanent') { - if ($has_permanent) { - continue; - } - $price = isset($parts[1]) ? (float) $parts[1] : 0.0; - if ($price <= 0) { - continue; - } - $title = sanitize_text_field((string) ($parts[2] ?? '')); - if ($title === '') { - $title = __('永久会员升级', 'aksrt'); - } - $show = isset($parts[3]) ? (float) $parts[3] : 0.0; - $items[] = [ - 'package_id' => $this->encode_package_id(2, 901), - 'level' => 2, - 'index' => 901, - 'action' => 'upgrade', - 'price' => round($price, 2), - 'base_price' => round($price, 2), - 'show_price' => max(0.0, $show), - 'discount_tag' => __('升级专享', 'aksrt'), - 'days' => 0, - 'title' => $title, - 'price_label' => $this->format_cash_label($price), - 'show_price_label' => $show > 0 ? $this->format_cash_label($show) : '', - 'days_label' => __('永久', 'aksrt'), - 'cta_label' => __('立即升级', 'aksrt'), - 'is_upgrade_product' => true, - 'upgrade_mode' => 'permanent', - ]; - $has_permanent = true; - continue; - } - - // fixed: price|days|title - $parsed = $this->parse_packages($line); - if (empty($parsed[0]) || $has_permanent) { - continue; - } - // For permanent VIP1, only accept days=0 fixed lines as permanent upgrade. - if ($is_permanent && (int) $parsed[0]['days'] > 0) { - continue; - } - $pkg = $parsed[0]; - $items[] = [ - 'package_id' => $this->encode_package_id(2, 901), - 'level' => 2, - 'index' => 901, - 'action' => 'upgrade', - 'price' => (float) $pkg['price'], - 'base_price' => (float) $pkg['price'], - 'show_price' => 0.0, - 'discount_tag' => __('升级专享', 'aksrt'), - 'days' => (int) $pkg['days'], - 'title' => (string) $pkg['title'], - 'price_label' => $this->format_cash_label((float) $pkg['price']), - 'show_price_label' => '', - 'days_label' => ((int) $pkg['days'] <= 0) - ? __('永久', 'aksrt') - : sprintf(__('%d 天', 'aksrt'), (int) $pkg['days']), - 'cta_label' => __('立即升级', 'aksrt'), - 'is_upgrade_product' => true, - 'upgrade_mode' => ((int) $pkg['days'] <= 0) ? 'permanent' : 'fixed', - ]; - $has_permanent = true; - } - - // Permanent VIP1 should not see unit/jump. - if ($is_permanent) { - $items = array_values(array_filter($items, static function ($it) { - $m = (string) ($it['upgrade_mode'] ?? ''); - return in_array($m, ['permanent', 'fixed'], true); - })); - } - - return $items; - } - - /** - * Points exchange packages: level|points|days|title - * - * @param array $vip - * @param array $levels - * @return array> - */ - public function get_points_exchange_packages(array $vip, array $levels): array { - $points_ex = is_array($vip['points_exchange'] ?? null) ? $vip['points_exchange'] : []; - $raw = (string) ($points_ex['packages_raw'] ?? ''); - $currency = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_currency_name() : __('积分', 'aksrt'); - $out = []; - $lines = preg_split('/\r\n|\r|\n/', $raw) ?: []; - $i = 0; - foreach ($lines as $line) { - $line = trim((string) $line); - if ($line === '') { - continue; - } - $parts = array_map('trim', explode('|', $line)); - if (count($parts) < 3) { - continue; - } - $lv = (int) ($parts[0] ?? 0); - $pts = (int) ($parts[1] ?? 0); - $days = (int) ($parts[2] ?? 0); - $title = sanitize_text_field((string) ($parts[3] ?? '')); - if ($lv < 1 || $lv > 2 || $pts <= 0) { - continue; - } - if (empty($levels[$lv]['enabled'])) { - continue; - } - $i++; - if ($title === '') { - $lv_name = (string) ($levels[$lv]['name'] ?? ('VIP' . $lv)); - $title = $days > 0 - ? sprintf(__('%1$s · %2$d天', 'aksrt'), $lv_name, $days) - : sprintf(__('%s · 永久', 'aksrt'), $lv_name); - } - $out[] = [ - 'package_id' => 5000 + $i, - 'level' => $lv, - 'index' => $i, - 'points' => $pts, - 'days' => max(0, $days), - 'title' => $title, - 'price_label' => $pts . ' ' . $currency, - 'days_label' => $days > 0 ? sprintf(__('%d 天', 'aksrt'), $days) : __('永久', 'aksrt'), - 'cta_label' => __('立即兑换', 'aksrt'), - ]; - } - return $out; - } - - /** - * Single upgrade product line: price|days|title - * - * @return array{price:float,days:int,title:string}|null - */ - public function parse_upgrade_product_line(string $raw): ?array { - $raw = trim($raw); - if ($raw === '') { - return null; - } - $lines = preg_split('/\r\n|\r|\n/', $raw) ?: []; - foreach ($lines as $line) { - $line = trim((string) $line); - if ($line === '' || str_starts_with(strtolower($line), 'unit|')) { - continue; - } - $parsed = $this->parse_packages($line); - if (!empty($parsed[0])) { - return $parsed[0]; - } - } - return null; - } - - /** - * Apply renew discount/reduce to same-level packages. - * - * @param array $renew - * @param array $upgrade - * @return array{price:float,show_price:float,tag:string}|null - */ - private function apply_action_price( - float $base_price, - string $action, - array $renew, - array $upgrade, - int $target_level, - int $user_level, - int $expires, - bool $is_permanent, - int $days - ): ?array { - if ($base_price <= 0) { - return null; - } - - if ($action === 'renew' && $is_permanent) { - return null; - } - - if ($action !== 'renew') { - return [ - 'price' => round($base_price, 2), - 'show_price' => 0.0, - 'tag' => '', - ]; - } - - $type = sanitize_key((string) ($renew['price_type'] ?? 'discount')); - $price = $base_price; - $show = $base_price; - $tag = ''; - - if ($type === 'discount') { - $discount = (float) ($renew['discount'] ?? 8); - if ($discount <= 0) { - $discount = 8; - } - if ($discount <= 10) { - $price = round($base_price * ($discount / 10), 2); - $tag = sprintf( - /* translators: %s: discount like 8 */ - __('%s折续费', 'aksrt'), - rtrim(rtrim(number_format($discount, 1, '.', ''), '0'), '.') - ); - } else { - $price = round($base_price * (min(100, $discount) / 100), 2); - $tag = sprintf(__('%s%% 续费价', 'aksrt'), (string) (int) $discount); - } - } elseif ($type === 'reduce') { - $reduce = max(0, (float) ($renew['reduce'] ?? 20)); - $price = round(max(0.01, $base_price - $reduce), 2); - $tag = sprintf( - /* translators: %s: amount */ - __('续费立减%s', 'aksrt'), - $this->format_cash_label($reduce) - ); - } else { - $show = 0.0; - $tag = __('续费同价', 'aksrt'); - } - - if ($price < 0.01) { - $price = 0.01; - } - if ($show > 0 && $price >= $show) { - $show = 0.0; - $tag = __('续费', 'aksrt'); - } - - return [ - 'price' => $price, - 'show_price' => $show > $price ? $show : 0.0, - 'tag' => $tag, - ]; - } - - public function encode_package_id(int $level, int $index): int { - return (max(1, min(2, $level)) * 1000) + max(1, $index); - } - - /** - * @return array{level:int,index:int}|null - */ - public function decode_package_id(int $package_id): ?array { - if ($package_id < 1001) { - return null; - } - $level = intdiv($package_id, 1000); - $index = $package_id % 1000; - if ($level < 1 || $level > 2 || $index < 1) { - return null; - } - return ['level' => $level, 'index' => $index]; - } - - /** - * @return array - */ - private function available_payment_methods(): array { - $methods = []; - if (!class_exists('AKSRT_Payment_Gateway')) { - return [['id' => 'balance', 'name' => __('站内余额', 'aksrt')]]; - } - $gw = AKSRT_Payment_Gateway::get_instance(); - foreach (['balance', 'alipay', 'wechatpay', 'paypal', 'stripe'] as $id) { - $g = $gw->get_gateway($id); - if (!$g) { - continue; - } - if (method_exists($g, 'is_configured') && !$g->is_configured()) { - continue; - } - $methods[] = [ - 'id' => $id, - 'name' => method_exists($g, 'get_name') ? (string) $g->get_name() : $id, - ]; - } - if ($methods === []) { - $methods[] = ['id' => 'balance', 'name' => __('站内余额', 'aksrt')]; - } - return $methods; - } - - private function format_cash_label(float $amount): string { - $symbol = '¥'; - if (class_exists('AKSRT_Settings')) { - $paid = AKSRT_Settings::get_paid_content_settings(); - $symbol = (string) ($paid['display']['currency_symbol'] ?? '¥'); - } - $n = function_exists('aksrt_format_price') ? aksrt_format_price($amount) : (string) $amount; - return $symbol . $n; - } - - private function format_expires_label(int $expires, int $level): string { - if ($level <= 0) { - return __('未开通', 'aksrt'); - } - if ($expires <= 0) { - return __('永久有效', 'aksrt'); - } - if ($expires < time()) { - return __('已过期', 'aksrt'); - } - return sprintf( - /* translators: %s: date */ - __('到期时间:%s', 'aksrt'), - wp_date('Y-m-d H:i', $expires) - ); - } } diff --git a/inc/classes/content/content-pay-traits/trait-aksrt-content-pay-helpers.php b/inc/classes/content/content-pay-traits/trait-aksrt-content-pay-helpers.php new file mode 100644 index 00000000..d48fa562 --- /dev/null +++ b/inc/classes/content/content-pay-traits/trait-aksrt-content-pay-helpers.php @@ -0,0 +1,497 @@ +post_status !== 'publish') { + return new WP_Error('aksrt_invalid', __('内容不存在', 'aksrt'), ['status' => 404]); + } + + $meta = function_exists('aksrt_get_pay_meta') ? aksrt_get_pay_meta($post) : []; + $pay_type = AKSRT_Domain_Model::normalize_pay_type((string) ($meta['pay_type'] ?? AKSRT_Domain_Model::PAY_TYPE_NO)); + if ($pay_type === AKSRT_Domain_Model::PAY_TYPE_NO) { + return new WP_Error('aksrt_not_paid', __('该内容无需购买', 'aksrt'), ['status' => 400]); + } + + $uid = $user_id > 0 ? $user_id : get_current_user_id(); + $priced = function_exists('aksrt_get_user_pay_price') + ? aksrt_get_user_pay_price($post, $uid) + : ['amount' => 0.0, 'base' => 0.0, 'modo' => '0', 'currency' => '¥', 'is_free' => true, 'pay_limit' => 0, 'vip_level' => 0, 'original' => 0.0, 'vip1' => 0.0, 'vip2' => 0.0]; + + // Optional overbuy override for preview display. + if ($price_type === 'download_limit_over') { + $over = max(0.0, (float) ($meta['download_limit_over_price'] ?? 0)); + if ($over > 0) { + $priced['amount'] = $over; + $priced['base'] = $over; + $priced['is_free'] = false; + } + } + + $pay_modo = (($priced['modo'] ?? '0') === 'points') ? 'points' : '0'; + $pay_limit = (int) ($priced['pay_limit'] ?? 0); + $vip_level = (int) ($priced['vip_level'] ?? 0); + $amount = (float) ($priced['amount'] ?? 0); + $base = (float) ($priced['base'] ?? $amount); + $over_price = max(0.0, (float) ($meta['download_limit_over_price'] ?? 0)); + + $list_amount = $amount; + $rebate_discount = 0.0; + $coupon_discount = 0.0; + $coupon_payload = null; + $coupon_error = ''; + $referrer_active = false; + $referrer_id = 0; + $rebate_price = 0.0; + $payable = $amount; + + $coupon_allowed = $pay_modo !== 'points' && $this->is_coupon_allowed($post_id, $meta); + $coupon_desc = ''; + if ($coupon_allowed) { + $coupon_desc = trim((string) ($meta['coupon_desc'] ?? '')); + if ($coupon_desc === '' && class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_content_coupon_settings')) { + $coupon_desc = (string) (AKSRT_Settings::get_content_coupon_settings()['desc'] ?? ''); + } + } + + if ($pay_modo !== 'points') { + $discounted = $this->apply_cash_discounts($priced, $post, $meta, $uid, $coupon_code, true); + if (is_wp_error($discounted)) { + // Preview: soft-fail coupon (show error, keep base payable without coupon). + $coupon_error = $discounted->get_error_message(); + $discounted = $this->apply_cash_discounts($priced, $post, $meta, $uid, '', true); + } + if (!is_wp_error($discounted)) { + $list_amount = (float) ($discounted['list_amount'] ?? $amount); + $rebate_discount = (float) ($discounted['rebate_discount'] ?? 0); + $coupon_discount = (float) ($discounted['coupon_discount'] ?? 0); + $coupon_payload = $discounted['coupon'] ?? null; + $payable = (float) ($discounted['payable'] ?? $amount); + $referrer_id = (int) ($discounted['referrer_id'] ?? 0); + $referrer_active = $referrer_id > 0; + $rebate_price = (float) ($discounted['rebate_price'] ?? 0); + $amount = $payable; + } + } + + $has_paid = false; + $paid_order = null; + if ($uid > 0 && class_exists('AKSRT_Order')) { + $has_paid = AKSRT_Order::get_instance()->has_paid_access( + $uid, + AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE, + $post_id + ); + if ($has_paid && function_exists('aksrt_get_user_content_paid_order')) { + $paid_order = aksrt_get_user_content_paid_order($post_id, $uid); + } + } + if (!$has_paid && $uid <= 0 && function_exists('aksrt_get_guest_content_paid_order')) { + $guest_order = aksrt_get_guest_content_paid_order($post_id); + if ($guest_order) { + $has_paid = true; + $paid_order = $guest_order; + } + } + + $pending_order = null; + if ($uid > 0 && !$has_paid && class_exists('AKSRT_Order_Repository')) { + $pending_order = $this->find_pending_content_order($uid, $post_id); + } + + $need_vip = $pay_limit > 0 && $vip_level < $pay_limit; + $allow_guest = function_exists('aksrt_allow_guest_content_pay') && aksrt_allow_guest_content_pay($post); + $currency = (string) ($priced['currency'] ?? ($pay_modo === 'points' + ? (class_exists('AKSRT_Settings') ? AKSRT_Settings::get_currency_name() : __('积分', 'aksrt')) + : '¥')); + + $balance = ($uid > 0 && class_exists('AKSRT_Credits')) ? AKSRT_Credits::balance($uid) : 0; + $methods = $this->available_payment_methods($pay_modo, $uid > 0); + + $amount_label = $this->format_amount_label($amount, $pay_modo, $currency); + $base_label = $this->format_amount_label($base, $pay_modo, $currency); + $list_label = $this->format_amount_label($list_amount, $pay_modo, $currency); + $over_label = $over_price > 0 ? $this->format_amount_label($over_price, $pay_modo, $currency) : ''; + + $vip_page = function_exists('aksrt_get_user_center_tab_url') + ? aksrt_get_user_center_tab_url('credits') + : (function_exists('aksrt_get_user_center_url') + ? aksrt_get_user_center_url() + : home_url('/')); + + $free_limit = $uid > 0 && function_exists('aksrt_get_user_free_download_limit') + ? aksrt_get_user_free_download_limit($uid) : 0; + $free_used = $uid > 0 && function_exists('aksrt_get_user_free_downloaded_number') + ? aksrt_get_user_free_downloaded_number($uid) : 0; + $quota_over = $free_limit > 0 && $free_used >= $free_limit; + + return [ + 'post_id' => $post_id, + 'title' => get_the_title($post), + 'permalink' => get_permalink($post), + 'pay_type' => $pay_type, + 'pay_type_label' => AKSRT_Domain_Model::pay_type_labels()[$pay_type] ?? $pay_type, + 'pay_modo' => $pay_modo, + 'pay_limit' => $pay_limit, + 'vip_level' => $vip_level, + 'amount' => $pay_modo === 'points' ? (int) ceil($amount) : round($amount, 2), + 'base' => $pay_modo === 'points' ? (int) ceil($base) : round($base, 2), + 'list_amount' => $pay_modo === 'points' ? (int) ceil($list_amount) : round($list_amount, 2), + 'list_amount_label' => $list_label, + 'payable' => $pay_modo === 'points' ? (int) ceil($payable) : round($payable, 2), + 'original' => (float) ($priced['original'] ?? 0), + 'vip1' => (float) ($priced['vip1'] ?? 0), + 'vip2' => (float) ($priced['vip2'] ?? 0), + 'currency' => $currency, + 'amount_label' => $amount_label, + 'base_label' => $base_label, + 'is_free' => !empty($priced['is_free']) || $amount <= 0, + 'has_paid_access' => $has_paid, + 'paid_order' => $paid_order ? [ + 'order_number' => (string) ($paid_order['order_number'] ?? ''), + 'paid_at' => (string) ($paid_order['paid_at'] ?? $paid_order['created_at'] ?? ''), + 'amount' => (float) ($paid_order['amount'] ?? 0), + 'currency' => (string) ($paid_order['currency'] ?? ''), + 'payment_method' => (string) ($paid_order['payment_method'] ?? ''), + ] : null, + 'pending_order' => $pending_order ? [ + 'order_number' => (string) ($pending_order['order_number'] ?? ''), + 'amount' => (float) ($pending_order['amount'] ?? 0), + 'payment_method' => (string) ($pending_order['payment_method'] ?? ''), + 'created_at' => (string) ($pending_order['created_at'] ?? ''), + ] : null, + 'need_login' => $uid <= 0 && !$allow_guest, + 'allow_guest' => $allow_guest, + 'need_vip' => $need_vip, + 'need_purchase' => !$has_paid && !$need_vip && $amount > 0, + 'logged_in' => $uid > 0, + 'balance' => $balance, + 'balance_enough' => $pay_modo !== 'points' || $balance >= (int) ceil($amount), + 'payment_methods' => $methods, + 'login_url' => wp_login_url(get_permalink($post)), + 'vip_url' => $vip_page, + 'credits_url' => $vip_page, + 'download_limit_over_price' => $pay_modo === 'points' ? (int) ceil($over_price) : round($over_price, 2), + 'download_limit_over_label' => $over_label, + 'allow_limit_over' => $over_price > 0, + 'quota_over' => $quota_over, + 'free_limit' => $free_limit, + 'free_used' => $free_used, + 'coupon_allowed' => $coupon_allowed, + 'coupon_desc' => $coupon_desc, + 'coupon_code' => $coupon_code, + 'coupon' => $coupon_payload, + 'coupon_error' => $coupon_error, + 'rebate_discount' => round($rebate_discount, 2), + 'rebate_discount_label' => $rebate_discount > 0 ? ('-' . $this->format_amount_label($rebate_discount, $pay_modo, $currency)) : '', + 'coupon_discount' => round($coupon_discount, 2), + 'coupon_discount_label' => $coupon_discount > 0 ? ('-' . $this->format_amount_label($coupon_discount, $pay_modo, $currency)) : '', + 'referrer_active' => $referrer_active, + 'discounts' => [ + 'list_amount' => round($list_amount, 2), + 'rebate_discount' => round($rebate_discount, 2), + 'coupon_discount' => round($coupon_discount, 2), + 'total_discount' => round(max(0, $list_amount - $payable), 2), + 'payable' => round($payable, 2), + 'rebate_price' => round($rebate_price, 2), + ], + // zibll: points_deduction runtime hard-off; card_pass not a content payment method. + 'points_deduction_allowed' => function_exists('aksrt_is_allow_points_deduction') + ? (bool) aksrt_is_allow_points_deduction('content') + : false, + 'card_pass_allowed' => function_exists('aksrt_is_content_card_pass_pay_allowed') + ? (bool) aksrt_is_content_card_pass_pay_allowed($post) + : false, + 'price_type' => $price_type, + ]; + } + + + + + /** + * @return array{post:WP_Post,meta:array,priced:array}|WP_Error + */ + private function assert_purchasable(int $post_id, int $user_id, string $price_type = '') { + $post = get_post($post_id); + if (!($post instanceof WP_Post) || $post->post_status !== 'publish') { + return new WP_Error('aksrt_invalid', __('内容不存在', 'aksrt'), ['status' => 404]); + } + + $meta = function_exists('aksrt_get_pay_meta') ? aksrt_get_pay_meta($post) : []; + $pay_type = AKSRT_Domain_Model::normalize_pay_type((string) ($meta['pay_type'] ?? AKSRT_Domain_Model::PAY_TYPE_NO)); + if ($pay_type === AKSRT_Domain_Model::PAY_TYPE_NO && $price_type !== 'download_limit_over') { + return new WP_Error('aksrt_not_paid', __('该内容无需购买', 'aksrt'), ['status' => 400]); + } + if ($pay_type === AKSRT_Domain_Model::PAY_TYPE_VIP) { + return new WP_Error('aksrt_need_vip', __('该内容为 VIP 专属,请开通会员。', 'aksrt'), ['status' => 403]); + } + + $pay_modo_meta = (($meta['pay_modo'] ?? '0') === 'points') ? 'points' : '0'; + $allow_guest = function_exists('aksrt_allow_guest_content_pay') && aksrt_allow_guest_content_pay($post); + if ($user_id <= 0) { + if ($pay_modo_meta === 'points' || !$allow_guest) { + return new WP_Error('aksrt_need_login', __('请先登录后再购买。', 'aksrt'), [ + 'status' => 401, + 'login_url' => wp_login_url(get_permalink($post)), + ]); + } + } + + $priced = function_exists('aksrt_get_user_pay_price') + ? aksrt_get_user_pay_price($post, $user_id) + : ['amount' => 0.0, 'modo' => '0', 'pay_limit' => 0, 'vip_level' => 0, 'is_free' => true, 'base' => 0.0]; + + // Free daily quota overbuy: override amount, ignore VIP discount (zibll). + if ($price_type === 'download_limit_over') { + $over = max(0.0, (float) ($meta['download_limit_over_price'] ?? 0)); + if ($over <= 0) { + return new WP_Error('aksrt_no_overbuy', __('未开放超额下载购买。', 'aksrt'), ['status' => 400]); + } + $priced['amount'] = $over; + $priced['base'] = $over; + $priced['is_free'] = false; + $priced['vip_amount'] = -1.0; + } + + $pay_limit = (int) ($priced['pay_limit'] ?? 0); + $vip_level = (int) ($priced['vip_level'] ?? 0); + if ($pay_limit > 0 && $vip_level < $pay_limit && $price_type !== 'download_limit_over') { + return new WP_Error('aksrt_need_vip', sprintf( + /* translators: %d: required VIP level */ + __('该资源仅限 VIP%s 及以上会员购买/下载。', 'aksrt'), + max(1, $pay_limit) + ), [ + 'status' => 403, + 'pay_limit' => $pay_limit, + ]); + } + + if ($user_id > 0 && class_exists('AKSRT_Order') && AKSRT_Order::get_instance()->has_paid_access( + $user_id, + AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE, + $post_id + )) { + return new WP_Error('aksrt_already_paid', __('您已购买该内容。', 'aksrt'), [ + 'status' => 200, + 'has_paid_access' => true, + 'reload' => true, + ]); + } + if ($user_id <= 0 && function_exists('aksrt_guest_has_paid_access') && aksrt_guest_has_paid_access($post_id)) { + return new WP_Error('aksrt_already_paid', __('您已购买该内容。', 'aksrt'), [ + 'status' => 200, + 'has_paid_access' => true, + 'reload' => true, + ]); + } + + return [ + 'post' => $post, + 'meta' => $meta, + 'priced' => $priced, + ]; + } + + + + /** + * @return array + */ + private function available_payment_methods(string $pay_modo, bool $logged_in = true): array { + if ($pay_modo === 'points') { + return [ + ['id' => 'points', 'name' => __('积分支付', 'aksrt')], + ]; + } + + if (function_exists('aksrt_list_cash_payment_methods')) { + return aksrt_list_cash_payment_methods($logged_in); + } + + return $logged_in + ? [['id' => 'balance', 'name' => __('站内余额', 'aksrt')]] + : [['id' => 'alipay', 'name' => __('支付宝', 'aksrt')]]; + } + + + + + private function is_coupon_allowed(int $post_id, array $meta): bool { + if (!empty($meta['coupon_s'])) { + return true; + } + if (function_exists('aksrt_is_content_coupon_allowed')) { + return aksrt_is_content_coupon_allowed($post_id); + } + if (class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_content_coupon_settings')) { + return !empty(AKSRT_Settings::get_content_coupon_settings()['enabled']); + } + if (class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_paid_content_settings')) { + return !empty(AKSRT_Settings::get_paid_content_settings()['coupon_post_s']); + } + return false; + } + + + + /** + * @return array{id:int,ratio:float} + */ + private function resolve_referrer_context(int $user_id): array { + $empty = ['id' => 0, 'ratio' => 0.0]; + if (!class_exists('AKSRT_Settings') || !method_exists('AKSRT_Settings', 'get_payment_affiliate_settings')) { + return $empty; + } + $aff = AKSRT_Settings::get_payment_affiliate_settings(); + if (empty($aff['enabled'])) { + return $empty; + } + $referrer_id = 0; + if (class_exists('AKSRT_Invite') && method_exists(AKSRT_Invite::get_instance(), 'get_referrer')) { + $referrer_id = (int) AKSRT_Invite::get_instance()->get_referrer($user_id); + } + if ($referrer_id <= 0) { + return $empty; + } + + $ratio = (float) ($aff['ratio'] ?? 0); + $user_ratio = is_array($aff['user_ratio'] ?? null) ? $aff['user_ratio'] : []; + $ref_vip = 0; + if (function_exists('aksrt_get_user_vip_level')) { + $ref_vip = (int) aksrt_get_user_vip_level($referrer_id); + } + if ($ref_vip >= 2 && isset($user_ratio['vip2']) && (float) $user_ratio['vip2'] > 0) { + $ratio = (float) $user_ratio['vip2']; + } elseif ($ref_vip >= 1 && isset($user_ratio['vip1']) && (float) $user_ratio['vip1'] > 0) { + $ratio = (float) $user_ratio['vip1']; + } elseif (isset($user_ratio['user'])) { + $ratio = (float) $user_ratio['user']; + } + $ratio = max(0.0, min(100.0, $ratio)); + if ($ratio <= 0) { + return $empty; + } + return ['id' => $referrer_id, 'ratio' => $ratio]; + } + + + + /** + * Cash discount chain: VIP-priced list → 推广折扣 → coupon → payable + commission snapshot. + * + * @param array $priced + * @param array $meta + * @return array|WP_Error + */ + private function apply_cash_discounts(array $priced, WP_Post $post, array $meta, int $user_id, string $coupon_code = '', bool $soft_coupon = false) { + $list_amount = round(max(0.0, (float) ($priced['amount'] ?? 0)), 2); + $pay = $list_amount; + $rebate_discount = 0.0; + $coupon_discount = 0.0; + $coupon_meta = []; + + $ref = $this->resolve_referrer_context($user_id); + $referrer_id = (int) ($ref['id'] ?? 0); + $ratio = (float) ($ref['ratio'] ?? 0); + + // 1) Buyer-facing 推广折扣 (fixed). + $fixed = max(0.0, (float) ($meta['pay_rebate_discount'] ?? 0)); + if ($referrer_id > 0 && $ratio > 0 && $fixed > 0 && $pay > 0) { + $rebate_discount = round(min($pay, $fixed), 2); + $pay = round(max(0.0, $pay - $rebate_discount), 2); + } + + // 2) Coupon after rebate discount. + $coupon_code = sanitize_text_field($coupon_code); + if ($coupon_code !== '') { + if (!$this->is_coupon_allowed((int) $post->ID, $meta)) { + $err = new WP_Error('aksrt_coupon_not_allowed', __('当前内容不支持优惠码。', 'aksrt'), ['status' => 400]); + if (!$soft_coupon) { + return $err; + } + } elseif (!class_exists('AKSRT_Coupon')) { + $err = new WP_Error('aksrt_coupon_unavailable', __('优惠券系统不可用。', 'aksrt'), ['status' => 500]); + if (!$soft_coupon) { + return $err; + } + } else { + $result = AKSRT_Coupon::get_instance()->validate($coupon_code, $pay, [ + 'user_id' => max(0, $user_id), + 'product_ids' => [(int) $post->ID], + 'context' => 'content', + ]); + if (empty($result['valid'])) { + $err = new WP_Error( + 'aksrt_coupon_error', + (string) ($result['message'] ?? __('优惠码不可用。', 'aksrt')), + ['status' => 400] + ); + if (!$soft_coupon) { + return $err; + } + } 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, + ]; + } + } + } + + $payable = round(max(0.0, $pay), 2); + $rebate_price = 0.0; + if ($referrer_id > 0 && $ratio > 0 && $payable > 0) { + $rebate_price = round($payable * $ratio / 100, 2); + } + + return [ + 'list_amount' => $list_amount, + 'rebate_discount' => $rebate_discount, + 'coupon_discount' => $coupon_discount, + 'coupon' => $coupon_meta, + 'payable' => $payable, + 'referrer_id' => $referrer_id, + 'rebate_price' => $rebate_price, + 'ratio' => $ratio, + ]; + } + + + + private function format_amount_label(float $amount, string $pay_modo, string $currency): string { + if ($amount <= 0) { + return (string) __('免费', 'aksrt'); + } + $n = function_exists('aksrt_format_price') ? aksrt_format_price($amount) : (string) $amount; + if ($pay_modo === 'points') { + return $n . ' ' . $currency; + } + return $currency . $n; + } + +} diff --git a/inc/classes/content/content-pay-traits/trait-aksrt-content-pay-orders.php b/inc/classes/content/content-pay-traits/trait-aksrt-content-pay-orders.php new file mode 100644 index 00000000..43d9da70 --- /dev/null +++ b/inc/classes/content/content-pay-traits/trait-aksrt-content-pay-orders.php @@ -0,0 +1,547 @@ +post_type !== 'post') { + return; + } + if (function_exists('aksrt_bump_content_sales_volume')) { + aksrt_bump_content_sales_volume($post_id, 1); + } else { + $key = AKSRT_Domain_Model::RESOURCE_SALES_VOLUME_KEY; + $cur = (int) get_post_meta($post_id, $key, true); + update_post_meta($post_id, $key, $cur + 1); + } + + // Guest cookie unlock (user_id=0). + $uid = (int) ($paid_order['user_id'] ?? 0); + if ($uid <= 0 && function_exists('aksrt_set_guest_content_pay_cookies')) { + // Ensure full order row for token material. + if (class_exists('AKSRT_Order_Repository')) { + $full = AKSRT_Order_Repository::get_instance()->get($order_number); + if (is_array($full)) { + $paid_order = array_merge($full, $paid_order); + } + } + aksrt_set_guest_content_pay_cookies($paid_order); + } + + // Consume coupon + write affiliate commission (cash content only; idempotent). + $this->finalize_content_order_side_effects($order_number, $paid_order); + } + + + + /** + * On paid: burn coupon (if any) and create affiliate rebate log. + */ + private function finalize_content_order_side_effects(string $order_number, array $order): void { + $meta = $this->decode_order_note_meta($order); + if (empty($meta['content_pay'])) { + return; + } + // Points: no coupon burn / no cash commission. + if (($meta['pay_modo'] ?? '') === 'points') { + return; + } + + $buyer_id = (int) ($order['user_id'] ?? 0); + + // Coupon burn (shared helper; idempotent via coupon_usage.order_number). + if (function_exists('aksrt_burn_order_coupon')) { + aksrt_burn_order_coupon($order_number, $meta, $buyer_id); + } + + // Affiliate commission (does not reduce buyer pay). + $referrer_id = (int) ($meta['referrer_id'] ?? 0); + $rebate_price = round((float) ($meta['rebate_price'] ?? 0), 2); + $pay_amount = round((float) ($order['amount'] ?? $meta['payable'] ?? 0), 2); + if ($referrer_id <= 0 || $rebate_price <= 0 || $pay_amount <= 0) { + return; + } + if (!class_exists('AKSRT_Rebate')) { + return; + } + if ($this->rebate_log_exists($order_number, $referrer_id)) { + return; + } + AKSRT_Rebate::get_instance()->create([ + 'user_id' => $referrer_id, + 'order_number' => $order_number, + 'source_user_id' => $buyer_id, + 'amount' => $rebate_price, + 'type' => AKSRT_Rebate::TYPE_ORDER, + 'note' => sprintf( + /* translators: 1: order number 2: post id */ + __('内容推广返佣 订单 %1$s 内容 #%2$d', 'aksrt'), + $order_number, + (int) ($order['product_id'] ?? $meta['post_id'] ?? 0) + ), + ]); + } + + + + /** + * @param array $order + * @return array + */ + private function decode_order_note_meta(array $order): array { + if (is_array($order['meta_payload'] ?? null) && $order['meta_payload'] !== []) { + return $order['meta_payload']; + } + $note = (string) ($order['note'] ?? ''); + if ($note === '') { + return []; + } + $decoded = json_decode($note, true); + return is_array($decoded) ? $decoded : []; + } + + + + private function rebate_log_exists(string $order_number, int $referrer_id): bool { + global $wpdb; + $table = AKSRT_DB_Migrator::table('rebate_log'); + if ($table === '' || $order_number === '') { + return false; + } + $found = $wpdb->get_var($wpdb->prepare( + "SELECT id FROM {$table} WHERE order_number = %s AND user_id = %d LIMIT 1", + $order_number, + $referrer_id + )); + return !empty($found); + } + + + + /** + * Points one-shot purchase → permanent resource order. + * + * @return array|WP_Error + */ + public function create_and_pay_points(int $post_id, int $user_id, string $price_type = '') { + $assert = $this->assert_purchasable($post_id, $user_id, $price_type); + if (is_wp_error($assert)) { + return $assert; + } + + $priced = $assert['priced']; + if ((($priced['modo'] ?? '0') !== 'points')) { + return new WP_Error('aksrt_wrong_modo', __('该内容不支持积分支付。', 'aksrt'), ['status' => 400]); + } + + $amount = (int) ceil((float) ($priced['amount'] ?? 0)); + if ($amount <= 0) { + return [ + 'success' => true, + 'data' => [ + 'free' => true, + 'message' => __('无需支付,已可访问。', 'aksrt'), + 'reload' => true, + ], + ]; + } + + if (!class_exists('AKSRT_Order') || !class_exists('AKSRT_Credits')) { + return new WP_Error('aksrt_pay_unavailable', __('支付系统不可用。', 'aksrt'), ['status' => 500]); + } + + $balance = AKSRT_Credits::balance($user_id); + if ($balance < $amount) { + return new WP_Error('aksrt_credits_insufficient', __('积分不足,暂时无法支付。', 'aksrt'), [ + 'status' => 402, + 'balance' => $balance, + 'cost' => $amount, + ]); + } + + $note = wp_json_encode([ + 'content_pay' => 1, + 'pay_modo' => 'points', + 'post_id' => $post_id, + 'price_type' => $price_type, + 'vip_level' => (int) ($priced['vip_level'] ?? 0), + 'base' => (float) ($priced['base'] ?? $amount), + 'amount' => $amount, + ], 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_RESOURCE, + 'product_id' => $post_id, + 'amount' => (float) $amount, + 'currency' => 'points', + 'status' => AKSRT_Order::STATUS_PENDING, + 'payment_method' => 'points', + '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]); + } + + $deducted = AKSRT_Credits::deduct( + $user_id, + $amount, + 'content_points_pay', + ['type' => 'post', 'id' => $post_id, 'order' => $order_number], + sprintf(__('积分购买《%s》', 'aksrt'), get_the_title($post_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 new WP_Error('aksrt_credits_deduct_failed', $msg ?: __('积分扣除失败。', 'aksrt'), ['status' => 400]); + } + + $paid = AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_PAID, [ + 'payment_method' => 'points', + 'payment_transaction_id' => 'points_' . $order_number, + 'note' => sprintf(__('积分支付 %d 积分。', 'aksrt'), $amount), + ]); + + if (!$paid) { + AKSRT_Credits::add( + $user_id, + $amount, + 'content_points_pay_refund', + ['type' => 'post', 'id' => $post_id, 'order' => $order_number], + sprintf(__('购买失败退款:%s', 'aksrt'), get_the_title($post_id)) + ); + return new WP_Error('aksrt_order_confirm_failed', __('订单确认失败,已尝试退回积分。', 'aksrt'), ['status' => 500]); + } + + return [ + 'success' => true, + 'data' => [ + 'order_number' => $order_number, + 'amount' => $amount, + 'pay_modo' => 'points', + 'balance' => AKSRT_Credits::balance($user_id), + 'message' => __('支付成功', 'aksrt'), + 'reload' => true, + ], + ]; + } + + + + /** + * Cash order create + balance/gateway pay. + * + * @return array|WP_Error + */ + public function create_cash_order(int $post_id, int $user_id, string $payment_method = 'balance', string $price_type = '', string $coupon_code = '') { + $assert = $this->assert_purchasable($post_id, $user_id, $price_type); + if (is_wp_error($assert)) { + return $assert; + } + + $post = $assert['post']; + $meta = $assert['meta']; + $priced = $assert['priced']; + if ((($priced['modo'] ?? '0') === 'points')) { + return new WP_Error('aksrt_wrong_modo', __('该内容仅支持积分支付。', 'aksrt'), ['status' => 400]); + } + + // Card pass cannot stack coupon (zibll). Content cashier does not sell via card_pass. + $payment_method = sanitize_key($payment_method); + if ($payment_method === 'card_pass') { + if (!function_exists('aksrt_is_content_card_pass_pay_allowed') || !aksrt_is_content_card_pass_pay_allowed($post)) { + return new WP_Error( + 'aksrt_card_pass_not_allowed', + __('内容购买不支持卡密支付,请在用户中心使用卡密兑换余额/会员/积分。', 'aksrt'), + ['status' => 400] + ); + } + $coupon_code = ''; + } + + $discounted = $this->apply_cash_discounts($priced, $post, $meta, $user_id, $coupon_code, false); + if (is_wp_error($discounted)) { + return $discounted; + } + + $amount = round((float) ($discounted['payable'] ?? 0), 2); + $list_amount = round((float) ($discounted['list_amount'] ?? $amount), 2); + $rebate_discount = round((float) ($discounted['rebate_discount'] ?? 0), 2); + $coupon_discount = round((float) ($discounted['coupon_discount'] ?? 0), 2); + $coupon_meta = is_array($discounted['coupon'] ?? null) ? $discounted['coupon'] : []; + $referrer_id = (int) ($discounted['referrer_id'] ?? 0); + $rebate_price = round((float) ($discounted['rebate_price'] ?? 0), 2); + + if ($amount <= 0) { + // Free after discount (or free content): create 0-paid order for access + burn coupon. + if (!class_exists('AKSRT_Order') || !class_exists('AKSRT_Order_Repository')) { + return new WP_Error('aksrt_pay_unavailable', __('支付系统不可用。', 'aksrt'), ['status' => 500]); + } + $note = wp_json_encode([ + 'content_pay' => 1, + 'pay_modo' => '0', + 'post_id' => $post_id, + 'price_type' => $price_type, + 'vip_level' => (int) ($priced['vip_level'] ?? 0), + 'base' => (float) ($priced['base'] ?? $list_amount), + 'list_amount' => $list_amount, + 'rebate_discount' => $rebate_discount, + 'coupon_discount' => $coupon_discount, + 'discount' => $coupon_discount, + 'coupon' => $coupon_meta, + 'coupon_code' => (string) ($coupon_meta['code'] ?? $coupon_code), + 'amount' => 0, + 'payable' => 0, + 'referrer_id' => $referrer_id, + 'rebate_price' => 0, + 'ip_address' => function_exists('aksrt_pay_client_ip') ? aksrt_pay_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? ''), + 'guest' => $user_id <= 0 ? 1 : 0, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + $order_number = AKSRT_Order_Repository::get_instance()->create([ + 'user_id' => max(0, $user_id), + 'product_type' => AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE, + 'product_id' => $post_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 === '') { + return new WP_Error('aksrt_order_create_failed', __('创建订单失败。', 'aksrt'), ['status' => 500]); + } + $paid = AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_PAID, [ + 'payment_method' => 'free', + 'payment_transaction_id' => 'free_' . $order_number, + 'note' => __('优惠后无需支付。', 'aksrt'), + ]); + if (!$paid) { + return new WP_Error('aksrt_order_confirm_failed', __('订单确认失败。', 'aksrt'), ['status' => 500]); + } + return [ + 'success' => true, + 'data' => [ + 'order_number' => $order_number, + 'amount' => 0, + 'pay_modo' => '0', + 'free' => true, + 'message' => __('无需支付,已可访问。', 'aksrt'), + 'reload' => true, + ], + ]; + } + + if (!class_exists('AKSRT_Order') || !class_exists('AKSRT_Order_Repository')) { + return new WP_Error('aksrt_pay_unavailable', __('支付系统不可用。', 'aksrt'), ['status' => 500]); + } + + if ($payment_method === '') { + $payment_method = $user_id > 0 ? 'balance' : 'alipay'; + } + if ($user_id <= 0 && in_array($payment_method, ['balance', 'points'], true)) { + $payment_method = 'alipay'; + } + + $coupon_code_norm = (string) ($coupon_meta['code'] ?? $coupon_code); + + // Resume existing pending content order for this post when amount + coupon match (logged-in only). + $pending = $user_id > 0 ? $this->find_pending_content_order($user_id, $post_id) : null; + $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'] ?? ''); + $order = $pending; + } else { + $note = wp_json_encode([ + 'content_pay' => 1, + 'pay_modo' => '0', + 'post_id' => $post_id, + 'price_type' => $price_type, + 'vip_level' => (int) ($priced['vip_level'] ?? 0), + 'base' => (float) ($priced['base'] ?? $list_amount), + 'list_amount' => $list_amount, + 'rebate_discount' => $rebate_discount, + 'coupon_discount' => $coupon_discount, + 'discount' => $coupon_discount, + 'coupon' => $coupon_meta, + 'coupon_code' => $coupon_code_norm, + 'amount' => $amount, + 'payable' => $amount, + 'referrer_id' => $referrer_id, + 'rebate_price' => $rebate_price, + 'ip_address' => function_exists('aksrt_pay_client_ip') ? aksrt_pay_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? ''), + 'guest' => $user_id <= 0 ? 1 : 0, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + $order_number = AKSRT_Order_Repository::get_instance()->create([ + 'user_id' => max(0, $user_id), + 'product_type' => AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE, + 'product_id' => $post_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]); + } + } + + $payment_method = sanitize_key($payment_method ?: (string) ($order['payment_method'] ?? 'balance')); + if ($payment_method === '') { + $payment_method = $user_id > 0 ? 'balance' : 'alipay'; + } + + $order['subject'] = sprintf( + /* translators: %s: post title */ + __('购买《%s》', 'aksrt'), + get_the_title($post_id) + ); + $order['payment_method'] = $payment_method; + // Ensure gateway uses discounted amount. + $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, + ], + ]; + } + + // Sync gateway (balance): mark paid. + $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]); + } + + return [ + 'success' => true, + 'data' => [ + 'order_number' => $order_number, + 'amount' => $amount, + 'pay_modo' => '0', + 'message' => $payment_result['message'] ?? __('支付成功', 'aksrt'), + 'reload' => true, + ], + ]; + } + + + + /** + * @return array|null + */ + private function find_pending_content_order(int $user_id, int $post_id): ?array { + if ($user_id <= 0 || $post_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_RESOURCE, + 'status' => AKSRT_Domain_Model::ORDER_STATUS_PENDING, + 'limit' => 20, + 'offset' => 0, + ]); + foreach ($orders as $order) { + if ((int) ($order['product_id'] ?? 0) === $post_id) { + return $order; + } + } + return null; + } + +} diff --git a/inc/classes/content/content-pay-traits/trait-aksrt-content-pay-rest.php b/inc/classes/content/content-pay-traits/trait-aksrt-content-pay-rest.php new file mode 100644 index 00000000..cf495569 --- /dev/null +++ b/inc/classes/content/content-pay-traits/trait-aksrt-content-pay-rest.php @@ -0,0 +1,207 @@ +\\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'], + ], + ]); + + register_rest_route('aksrt/v1', '/pay/content/order/(?P[A-Za-z0-9_-]+)', [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_order_status'], + 'permission_callback' => [$this, 'check_rest_nonce'], + ]); + } + + + + /** + * REST nonce for logged-in users (wp_rest). Guests get 401 on purchase paths. + * + * @param WP_REST_Request $request + * @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; + } + + + + /** + * GET /pay/content/{id} + * query: coupon_code?, price_type? + */ + public function rest_preview(WP_REST_Request $request) { + $post_id = (int) $request['id']; + $uid = get_current_user_id(); + $coupon_code = sanitize_text_field((string) ($request->get_param('coupon_code') ?? $request->get_param('coupon') ?? '')); + $price_type = sanitize_key((string) ($request->get_param('price_type') ?? '')); + if ($price_type !== 'download_limit_over') { + $price_type = ''; + } + $preview = $this->preview($post_id, $uid, $coupon_code, $price_type); + if (is_wp_error($preview)) { + return $preview; + } + return new WP_REST_Response($preview, 200); + } + + + + /** + * POST /pay/content/{id} + * body: { mode?: points|cash, payment_method?: balance|alipay|..., price_type?: ''|download_limit_over, coupon_code?: string } + */ + public function rest_purchase(WP_REST_Request $request) { + $post_id = (int) $request['id']; + $uid = get_current_user_id(); + + $params = $request->get_json_params(); + if (!is_array($params)) { + $params = $request->get_params(); + } + + $price_type = sanitize_key((string) ($params['price_type'] ?? '')); + if ($price_type !== 'download_limit_over') { + $price_type = ''; + } + $coupon_code = sanitize_text_field((string) ($params['coupon_code'] ?? $params['coupon'] ?? '')); + + // Guest allowed only for cash + pay_limit=0 + setting on. + if ($uid <= 0) { + $post = get_post($post_id); + $allow_guest = function_exists('aksrt_allow_guest_content_pay') && aksrt_allow_guest_content_pay($post); + if (!$allow_guest) { + return new WP_Error('aksrt_need_login', __('请先登录后再购买。', 'aksrt'), [ + 'status' => 401, + 'login_url' => wp_login_url($post ? get_permalink($post) : home_url('/')), + ]); + } + } + + $gate = $this->assert_purchasable($post_id, $uid, $price_type); + if (is_wp_error($gate)) { + return $gate; + } + + $priced = $gate['priced']; + $pay_modo = (($priced['modo'] ?? '0') === 'points') ? 'points' : '0'; + + $mode = sanitize_key((string) ($params['mode'] ?? '')); + if ($mode === '') { + $mode = $pay_modo === 'points' ? 'points' : 'cash'; + } + if ($mode === 'points' || $pay_modo === 'points') { + if ($uid <= 0) { + return new WP_Error('aksrt_need_login', __('积分支付请先登录。', 'aksrt'), ['status' => 401]); + } + // Points path: ignore coupon (zibll parity). + return $this->rest_wrap_result($this->create_and_pay_points($post_id, $uid, $price_type)); + } + + $payment_method = sanitize_key((string) ($params['payment_method'] ?? 'balance')); + if ($payment_method === '' || $payment_method === 'cash') { + $payment_method = 'balance'; + } + // Guests cannot use balance/points gateways. + if ($uid <= 0 && in_array($payment_method, ['balance', 'points'], true)) { + $payment_method = 'alipay'; + } + return $this->rest_wrap_result($this->create_cash_order($post_id, $uid, $payment_method, $price_type, $coupon_code)); + } + + + + /** + * GET /pay/content/order/{order_number} + */ + public function rest_order_status(WP_REST_Request $request) { + $uid = get_current_user_id(); + $order_number = sanitize_text_field((string) $request['order_number']); + if ($order_number === '' || !class_exists('AKSRT_Order_Repository')) { + return new WP_Error('aksrt_invalid', __('订单不存在', 'aksrt'), ['status' => 404]); + } + $repo = AKSRT_Order_Repository::get_instance(); + if ($uid > 0) { + $order = $repo->get_user_order($uid, $order_number); + } else { + $order = $repo->get($order_number); + // Guest may only poll own guest cash orders (user_id=0): content/topic resource or plate_follow. + $ptype = sanitize_key((string) ($order['product_type'] ?? '')); + $guest_types = [ + AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE, + AKSRT_Domain_Model::ORDER_PRODUCT_PLATE_FOLLOW, + ]; + if ($order && ((int) ($order['user_id'] ?? -1) !== 0 || !in_array($ptype, $guest_types, true))) { + $order = null; + } + } + if (!$order) { + return new WP_Error('aksrt_invalid', __('订单不存在', 'aksrt'), ['status' => 404]); + } + $status = (string) ($order['status'] ?? ''); + $paid = class_exists('AKSRT_Domain_Model') && AKSRT_Domain_Model::is_paid_order_status($status); + if ($paid && $uid <= 0 && function_exists('aksrt_set_guest_pay_cookies')) { + // Ensure cookie set even if notify raced ahead of browser cookie. + aksrt_set_guest_pay_cookies($order); + } + return new WP_REST_Response([ + 'order_number' => $order_number, + 'status' => $status, + 'paid' => $paid, + 'amount' => (float) ($order['amount'] ?? 0), + 'currency' => (string) ($order['currency'] ?? ''), + 'product_id' => (int) ($order['product_id'] ?? 0), + 'product_type' => (string) ($order['product_type'] ?? ''), + 'reload' => $paid, + ], 200); + } + + + + /** + * @param array|WP_Error $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); + } + +} diff --git a/inc/classes/content/vip-service-traits/trait-aksrt-vip-service-packages.php b/inc/classes/content/vip-service-traits/trait-aksrt-vip-service-packages.php new file mode 100644 index 00000000..3054b34f --- /dev/null +++ b/inc/classes/content/vip-service-traits/trait-aksrt-vip-service-packages.php @@ -0,0 +1,491 @@ + + */ + public function parse_packages(string $raw): array { + $lines = preg_split('/\r\n|\r|\n/', $raw) ?: []; + $out = []; + foreach ($lines as $line) { + $line = trim((string) $line); + if ($line === '') { + continue; + } + $parts = array_map('trim', explode('|', $line)); + $price = isset($parts[0]) ? (float) $parts[0] : 0.0; + $days = isset($parts[1]) ? (int) $parts[1] : 0; + $title = isset($parts[2]) ? sanitize_text_field($parts[2]) : ''; + if ($price <= 0) { + continue; + } + if ($title === '') { + $title = $days > 0 + ? sprintf(__('%d天会员', 'aksrt'), $days) + : __('永久会员', 'aksrt'); + } + $out[] = [ + 'price' => round($price, 2), + 'days' => max(0, $days), + 'title' => $title, + ]; + } + return $out; + } + + + + /** + * Build upgrade package items (zibll vip_upgrade_product parity). + * + * upgrade.product multi-line formats: + * - permanent VIP1: permanent|price|title[|show_price] + * - fixed (alias): price|days|title + * - timed unit: unit|daily_price|title + * - timed jump permanent: jump|price|title[|show_price] + * + * Rules: + * - permanent user: only permanent/fixed (days=0 preferred) + * - timed user: unit (keep expiry) + optional jump (to permanent) + * + * package index: 901 permanent/fixed, 902 unit, 903 jump + * + * @return array> + */ + private function build_upgrade_items(int $user_id, int $expires, bool $is_permanent, string $raw): array { + $raw = trim($raw); + if ($raw === '') { + return []; + } + $items = []; + $lines = preg_split('/\r\n|\r|\n/', $raw) ?: []; + $has_permanent = false; + $has_unit = false; + $has_jump = false; + + foreach ($lines as $line) { + $line = trim((string) $line); + if ($line === '') { + continue; + } + $parts = array_map('trim', explode('|', $line)); + $mode = strtolower((string) ($parts[0] ?? '')); + + // unit|daily|title — timed VIP1 only. + if ($mode === 'unit') { + if ($is_permanent || $expires <= time() || $has_unit) { + continue; + } + $daily = isset($parts[1]) ? (float) $parts[1] : 0.0; + if ($daily <= 0) { + continue; + } + $remaining = (int) ceil(max(1, ($expires - time()) / DAY_IN_SECONDS)); + $price = round(max(0.01, $remaining * $daily), 2); + $title = sanitize_text_field((string) ($parts[2] ?? '')); + if ($title === '') { + $title = sprintf(__('按剩余 %d 天升级', 'aksrt'), $remaining); + } + $items[] = [ + 'package_id' => $this->encode_package_id(2, 902), + 'level' => 2, + 'index' => 902, + 'action' => 'upgrade', + 'price' => $price, + 'base_price' => $price, + 'show_price' => 0.0, + 'discount_tag' => __('剩余天数', 'aksrt'), + 'days' => $remaining, + 'title' => $title, + 'price_label' => $this->format_cash_label($price), + 'show_price_label' => '', + 'days_label' => sprintf(__('%d 天', 'aksrt'), $remaining), + 'cta_label' => __('立即升级', 'aksrt'), + 'is_upgrade_product' => true, + 'upgrade_mode' => 'unit', + ]; + $has_unit = true; + continue; + } + + // jump|price|title[|show_price] — timed VIP1 only → permanent VIP2. + if ($mode === 'jump') { + if ($is_permanent || $has_jump) { + continue; + } + $price = isset($parts[1]) ? (float) $parts[1] : 0.0; + if ($price <= 0) { + continue; + } + $title = sanitize_text_field((string) ($parts[2] ?? '')); + if ($title === '') { + $title = __('升级为永久会员', 'aksrt'); + } + $show = isset($parts[3]) ? (float) $parts[3] : 0.0; + $items[] = [ + 'package_id' => $this->encode_package_id(2, 903), + 'level' => 2, + 'index' => 903, + 'action' => 'upgrade', + 'price' => round($price, 2), + 'base_price' => round($price, 2), + 'show_price' => max(0.0, $show), + 'discount_tag' => __('永久升级', 'aksrt'), + 'days' => 0, + 'title' => $title, + 'price_label' => $this->format_cash_label($price), + 'show_price_label' => $show > 0 ? $this->format_cash_label($show) : '', + 'days_label' => __('永久', 'aksrt'), + 'cta_label' => __('立即升级', 'aksrt'), + 'is_upgrade_product' => true, + 'upgrade_mode' => 'jump', + ]; + $has_jump = true; + continue; + } + + // permanent|price|title[|show_price] + if ($mode === 'permanent') { + if ($has_permanent) { + continue; + } + $price = isset($parts[1]) ? (float) $parts[1] : 0.0; + if ($price <= 0) { + continue; + } + $title = sanitize_text_field((string) ($parts[2] ?? '')); + if ($title === '') { + $title = __('永久会员升级', 'aksrt'); + } + $show = isset($parts[3]) ? (float) $parts[3] : 0.0; + $items[] = [ + 'package_id' => $this->encode_package_id(2, 901), + 'level' => 2, + 'index' => 901, + 'action' => 'upgrade', + 'price' => round($price, 2), + 'base_price' => round($price, 2), + 'show_price' => max(0.0, $show), + 'discount_tag' => __('升级专享', 'aksrt'), + 'days' => 0, + 'title' => $title, + 'price_label' => $this->format_cash_label($price), + 'show_price_label' => $show > 0 ? $this->format_cash_label($show) : '', + 'days_label' => __('永久', 'aksrt'), + 'cta_label' => __('立即升级', 'aksrt'), + 'is_upgrade_product' => true, + 'upgrade_mode' => 'permanent', + ]; + $has_permanent = true; + continue; + } + + // fixed: price|days|title + $parsed = $this->parse_packages($line); + if (empty($parsed[0]) || $has_permanent) { + continue; + } + // For permanent VIP1, only accept days=0 fixed lines as permanent upgrade. + if ($is_permanent && (int) $parsed[0]['days'] > 0) { + continue; + } + $pkg = $parsed[0]; + $items[] = [ + 'package_id' => $this->encode_package_id(2, 901), + 'level' => 2, + 'index' => 901, + 'action' => 'upgrade', + 'price' => (float) $pkg['price'], + 'base_price' => (float) $pkg['price'], + 'show_price' => 0.0, + 'discount_tag' => __('升级专享', 'aksrt'), + 'days' => (int) $pkg['days'], + 'title' => (string) $pkg['title'], + 'price_label' => $this->format_cash_label((float) $pkg['price']), + 'show_price_label' => '', + 'days_label' => ((int) $pkg['days'] <= 0) + ? __('永久', 'aksrt') + : sprintf(__('%d 天', 'aksrt'), (int) $pkg['days']), + 'cta_label' => __('立即升级', 'aksrt'), + 'is_upgrade_product' => true, + 'upgrade_mode' => ((int) $pkg['days'] <= 0) ? 'permanent' : 'fixed', + ]; + $has_permanent = true; + } + + // Permanent VIP1 should not see unit/jump. + if ($is_permanent) { + $items = array_values(array_filter($items, static function ($it) { + $m = (string) ($it['upgrade_mode'] ?? ''); + return in_array($m, ['permanent', 'fixed'], true); + })); + } + + return $items; + } + + + + /** + * Points exchange packages: level|points|days|title + * + * @param array $vip + * @param array $levels + * @return array> + */ + public function get_points_exchange_packages(array $vip, array $levels): array { + $points_ex = is_array($vip['points_exchange'] ?? null) ? $vip['points_exchange'] : []; + $raw = (string) ($points_ex['packages_raw'] ?? ''); + $currency = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_currency_name() : __('积分', 'aksrt'); + $out = []; + $lines = preg_split('/\r\n|\r|\n/', $raw) ?: []; + $i = 0; + foreach ($lines as $line) { + $line = trim((string) $line); + if ($line === '') { + continue; + } + $parts = array_map('trim', explode('|', $line)); + if (count($parts) < 3) { + continue; + } + $lv = (int) ($parts[0] ?? 0); + $pts = (int) ($parts[1] ?? 0); + $days = (int) ($parts[2] ?? 0); + $title = sanitize_text_field((string) ($parts[3] ?? '')); + if ($lv < 1 || $lv > 2 || $pts <= 0) { + continue; + } + if (empty($levels[$lv]['enabled'])) { + continue; + } + $i++; + if ($title === '') { + $lv_name = (string) ($levels[$lv]['name'] ?? ('VIP' . $lv)); + $title = $days > 0 + ? sprintf(__('%1$s · %2$d天', 'aksrt'), $lv_name, $days) + : sprintf(__('%s · 永久', 'aksrt'), $lv_name); + } + $out[] = [ + 'package_id' => 5000 + $i, + 'level' => $lv, + 'index' => $i, + 'points' => $pts, + 'days' => max(0, $days), + 'title' => $title, + 'price_label' => $pts . ' ' . $currency, + 'days_label' => $days > 0 ? sprintf(__('%d 天', 'aksrt'), $days) : __('永久', 'aksrt'), + 'cta_label' => __('立即兑换', 'aksrt'), + ]; + } + return $out; + } + + + + /** + * Single upgrade product line: price|days|title + * + * @return array{price:float,days:int,title:string}|null + */ + public function parse_upgrade_product_line(string $raw): ?array { + $raw = trim($raw); + if ($raw === '') { + return null; + } + $lines = preg_split('/\r\n|\r|\n/', $raw) ?: []; + foreach ($lines as $line) { + $line = trim((string) $line); + if ($line === '' || str_starts_with(strtolower($line), 'unit|')) { + continue; + } + $parsed = $this->parse_packages($line); + if (!empty($parsed[0])) { + return $parsed[0]; + } + } + return null; + } + + + + /** + * Apply renew discount/reduce to same-level packages. + * + * @param array $renew + * @param array $upgrade + * @return array{price:float,show_price:float,tag:string}|null + */ + private function apply_action_price( + float $base_price, + string $action, + array $renew, + array $upgrade, + int $target_level, + int $user_level, + int $expires, + bool $is_permanent, + int $days + ): ?array { + if ($base_price <= 0) { + return null; + } + + if ($action === 'renew' && $is_permanent) { + return null; + } + + if ($action !== 'renew') { + return [ + 'price' => round($base_price, 2), + 'show_price' => 0.0, + 'tag' => '', + ]; + } + + $type = sanitize_key((string) ($renew['price_type'] ?? 'discount')); + $price = $base_price; + $show = $base_price; + $tag = ''; + + if ($type === 'discount') { + $discount = (float) ($renew['discount'] ?? 8); + if ($discount <= 0) { + $discount = 8; + } + if ($discount <= 10) { + $price = round($base_price * ($discount / 10), 2); + $tag = sprintf( + /* translators: %s: discount like 8 */ + __('%s折续费', 'aksrt'), + rtrim(rtrim(number_format($discount, 1, '.', ''), '0'), '.') + ); + } else { + $price = round($base_price * (min(100, $discount) / 100), 2); + $tag = sprintf(__('%s%% 续费价', 'aksrt'), (string) (int) $discount); + } + } elseif ($type === 'reduce') { + $reduce = max(0, (float) ($renew['reduce'] ?? 20)); + $price = round(max(0.01, $base_price - $reduce), 2); + $tag = sprintf( + /* translators: %s: amount */ + __('续费立减%s', 'aksrt'), + $this->format_cash_label($reduce) + ); + } else { + $show = 0.0; + $tag = __('续费同价', 'aksrt'); + } + + if ($price < 0.01) { + $price = 0.01; + } + if ($show > 0 && $price >= $show) { + $show = 0.0; + $tag = __('续费', 'aksrt'); + } + + return [ + 'price' => $price, + 'show_price' => $show > $price ? $show : 0.0, + 'tag' => $tag, + ]; + } + + + + public function encode_package_id(int $level, int $index): int { + return (max(1, min(2, $level)) * 1000) + max(1, $index); + } + + + + /** + * @return array{level:int,index:int}|null + */ + public function decode_package_id(int $package_id): ?array { + if ($package_id < 1001) { + return null; + } + $level = intdiv($package_id, 1000); + $index = $package_id % 1000; + if ($level < 1 || $level > 2 || $index < 1) { + return null; + } + return ['level' => $level, 'index' => $index]; + } + + + + /** + * @return array + */ + private function available_payment_methods(): array { + $methods = []; + if (!class_exists('AKSRT_Payment_Gateway')) { + return [['id' => 'balance', 'name' => __('站内余额', 'aksrt')]]; + } + $gw = AKSRT_Payment_Gateway::get_instance(); + foreach (['balance', 'alipay', 'wechatpay', 'paypal', 'stripe'] as $id) { + $g = $gw->get_gateway($id); + if (!$g) { + continue; + } + if (method_exists($g, 'is_configured') && !$g->is_configured()) { + continue; + } + $methods[] = [ + 'id' => $id, + 'name' => method_exists($g, 'get_name') ? (string) $g->get_name() : $id, + ]; + } + if ($methods === []) { + $methods[] = ['id' => 'balance', 'name' => __('站内余额', 'aksrt')]; + } + return $methods; + } + + + + private function format_cash_label(float $amount): string { + $symbol = '¥'; + if (class_exists('AKSRT_Settings')) { + $paid = AKSRT_Settings::get_paid_content_settings(); + $symbol = (string) ($paid['display']['currency_symbol'] ?? '¥'); + } + $n = function_exists('aksrt_format_price') ? aksrt_format_price($amount) : (string) $amount; + return $symbol . $n; + } + + + + private function format_expires_label(int $expires, int $level): string { + if ($level <= 0) { + return __('未开通', 'aksrt'); + } + if ($expires <= 0) { + return __('永久有效', 'aksrt'); + } + if ($expires < time()) { + return __('已过期', 'aksrt'); + } + return sprintf( + /* translators: %s: date */ + __('到期时间:%s', 'aksrt'), + wp_date('Y-m-d H:i', $expires) + ); + } + +} diff --git a/inc/classes/content/vip-service-traits/trait-aksrt-vip-service-purchase.php b/inc/classes/content/vip-service-traits/trait-aksrt-vip-service-purchase.php new file mode 100644 index 00000000..63d63f23 --- /dev/null +++ b/inc/classes/content/vip-service-traits/trait-aksrt-vip-service-purchase.php @@ -0,0 +1,598 @@ + + */ + public function get_catalog(int $user_id = 0): array { + $vip = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_payment_vip_settings() + : ['enabled' => false, 'packages_raw' => '', 'levels' => [], 'renew' => [], 'upgrade' => [], 'points_exchange' => []]; + + $level = function_exists('aksrt_get_user_vip_level') ? aksrt_get_user_vip_level($user_id) : 0; + $expires = $user_id > 0 ? (int) get_user_meta($user_id, 'aksrt_vip_expires', true) : 0; + $is_permanent = ($level > 0 && $expires <= 0); + $packages = $this->parse_packages((string) ($vip['packages_raw'] ?? '')); + $levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : []; + $renew = is_array($vip['renew'] ?? null) ? $vip['renew'] : []; + $upgrade = is_array($vip['upgrade'] ?? null) ? $vip['upgrade'] : []; + $points_ex = is_array($vip['points_exchange'] ?? null) ? $vip['points_exchange'] : []; + $renew_enabled = !isset($renew['enabled']) || !empty($renew['enabled']); + $upgrade_enabled = !isset($upgrade['enabled']) || !empty($upgrade['enabled']); + $points_enabled = !empty($points_ex['enabled']) + && (!class_exists('AKSRT_Settings') || AKSRT_Settings::credits_enabled()); + + $level_packages = []; + foreach ([1, 2] as $lv) { + if (empty($levels[$lv]['enabled'])) { + continue; + } + if ($is_permanent && $lv <= $level) { + continue; + } + if ($level > 0 && $lv === $level && !$renew_enabled) { + continue; + } + if ($level > 0 && $lv > $level && !$upgrade_enabled) { + continue; + } + + $name = (string) ($levels[$lv]['name'] ?? ('VIP' . $lv)); + $items = []; + foreach ($packages as $idx => $pkg) { + $base_price = (float) $pkg['price']; + $days = (int) $pkg['days']; + $action = 'pay'; + if ($level > 0 && $lv === $level) { + $action = 'renew'; + } elseif ($level > 0 && $lv > $level) { + $action = 'upgrade'; + } + + $priced = $this->apply_action_price($base_price, $action, $renew, $upgrade, $lv, $level, $expires, $is_permanent, $days); + if ($priced === null) { + continue; + } + + $items[] = [ + 'package_id' => $this->encode_package_id($lv, $idx + 1), + 'level' => $lv, + 'index' => $idx + 1, + 'action' => $action, + 'price' => (float) $priced['price'], + 'base_price' => $base_price, + 'show_price' => (float) ($priced['show_price'] ?? 0), + 'discount_tag' => (string) ($priced['tag'] ?? ''), + 'days' => $days, + 'title' => (string) $pkg['title'], + 'price_label' => $this->format_cash_label((float) $priced['price']), + 'show_price_label' => !empty($priced['show_price']) + ? $this->format_cash_label((float) $priced['show_price']) + : '', + 'days_label' => ($days <= 0) + ? __('永久', 'aksrt') + : sprintf(__('%d 天', 'aksrt'), $days), + 'cta_label' => $action === 'renew' + ? __('立即续费', 'aksrt') + : ($action === 'upgrade' ? __('立即升级', 'aksrt') : __('立即开通', 'aksrt')), + ]; + } + + // Dedicated upgrade product: fixed or remaining-days unit price. + if ($level === 1 && $lv === 2 && $upgrade_enabled) { + $upgrade_items = $this->build_upgrade_items($user_id, $expires, $is_permanent, (string) ($upgrade['product'] ?? '')); + foreach ($upgrade_items as $ui) { + $items[] = $ui; + } + } + + if ($items === []) { + continue; + } + $level_packages[] = [ + 'level' => $lv, + 'name' => $name, + 'action' => ($level > 0 && $lv === $level) ? 'renew' : (($level > 0 && $lv > $level) ? 'upgrade' : 'pay'), + 'items' => $items, + ]; + } + + $points_packages = []; + if ($points_enabled && $level <= 0) { + $points_packages = $this->get_points_exchange_packages($vip, $levels); + } + + $balance = ($user_id > 0 && class_exists('AKSRT_Credits')) ? AKSRT_Credits::balance($user_id) : 0; + $methods = $this->available_payment_methods(); + $currency_name = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_currency_name() : __('积分', 'aksrt'); + + return [ + 'enabled' => !empty($vip['enabled']), + 'desc' => (string) ($vip['desc'] ?? ''), + 'help_text' => (string) ($vip['help_text'] ?? ''), + 'user_level' => $level, + 'user_expires' => $expires, + 'user_is_permanent' => $is_permanent, + 'user_expires_label' => $this->format_expires_label($expires, $level), + 'user_points' => $balance, + 'currency_name' => $currency_name, + 'renew_enabled' => $renew_enabled, + 'upgrade_enabled' => $upgrade_enabled, + 'renew_desc' => (string) ($renew['desc'] ?? ''), + 'upgrade_desc' => (string) ($upgrade['desc'] ?? ''), + 'level_packages' => $level_packages, + 'points_exchange' => [ + 'enabled' => $points_enabled && $level <= 0, + 'desc' => (string) ($points_ex['desc'] ?? ''), + 'packages' => $points_packages, + ], + 'payment_methods' => $methods, + 'vip_url' => function_exists('aksrt_get_user_center_tab_url') + ? aksrt_get_user_center_tab_url('credits') + : (function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/')), + 'logged_in' => $user_id > 0, + ]; + } + + + + /** + * @return array|WP_Error + */ + public function purchase(int $user_id, int $package_id, string $payment_method = 'balance') { + if ($user_id <= 0) { + return new WP_Error('aksrt_need_login', __('请先登录后再开通会员。', 'aksrt'), ['status' => 401]); + } + + $decoded = $this->decode_package_id($package_id); + if (!$decoded) { + return new WP_Error('aksrt_invalid_package', __('套餐无效', 'aksrt'), ['status' => 400]); + } + $level = (int) $decoded['level']; + $index = (int) $decoded['index']; + + $vip = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_payment_vip_settings() + : ['enabled' => false, 'packages_raw' => '', 'levels' => [], 'renew' => [], 'upgrade' => []]; + if (empty($vip['enabled'])) { + return new WP_Error('aksrt_vip_disabled', __('会员功能未启用', 'aksrt'), ['status' => 400]); + } + $levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : []; + if (empty($levels[$level]['enabled'])) { + return new WP_Error('aksrt_vip_level_disabled', __('该会员等级未启用', 'aksrt'), ['status' => 400]); + } + + $user_level = function_exists('aksrt_get_user_vip_level') ? aksrt_get_user_vip_level($user_id) : 0; + $expires = (int) get_user_meta($user_id, 'aksrt_vip_expires', true); + $is_permanent = ($user_level > 0 && $expires <= 0); + $renew = is_array($vip['renew'] ?? null) ? $vip['renew'] : []; + $upgrade = is_array($vip['upgrade'] ?? null) ? $vip['upgrade'] : []; + $renew_enabled = !isset($renew['enabled']) || !empty($renew['enabled']); + $upgrade_enabled = !isset($upgrade['enabled']) || !empty($upgrade['enabled']); + + if ($is_permanent && $level <= $user_level) { + return new WP_Error('aksrt_vip_permanent', __('您已是永久会员,无需续费。', 'aksrt'), ['status' => 400]); + } + if ($user_level > 0 && $level === $user_level && !$renew_enabled) { + return new WP_Error('aksrt_vip_renew_disabled', __('续费功能未开启。', 'aksrt'), ['status' => 400]); + } + if ($user_level > 0 && $level > $user_level && !$upgrade_enabled) { + return new WP_Error('aksrt_vip_upgrade_disabled', __('升级功能未开启。', 'aksrt'), ['status' => 400]); + } + if ($user_level > 0 && $level < $user_level) { + return new WP_Error('aksrt_vip_downgrade', __('不支持降级购买。', 'aksrt'), ['status' => 400]); + } + + $action = 'pay'; + if ($user_level > 0 && $level === $user_level) { + $action = 'renew'; + } elseif ($user_level > 0 && $level > $user_level) { + $action = 'upgrade'; + } + + // Dedicated upgrade product: + // 901 permanent/fixed, 902 unit remaining days, 903 jump permanent. + $upgrade_mode = ''; + if (in_array($index, [901, 902, 903], true)) { + $upgrade_items = $this->build_upgrade_items($user_id, $expires, $is_permanent, (string) ($upgrade['product'] ?? '')); + $match = null; + foreach ($upgrade_items as $ui) { + if ((int) ($ui['package_id'] ?? 0) === $package_id) { + $match = $ui; + break; + } + } + if (!$match || $level !== 2) { + return new WP_Error('aksrt_invalid_package', __('升级套餐不存在', 'aksrt'), ['status' => 400]); + } + $pkg = [ + 'price' => (float) $match['price'], + 'days' => (int) $match['days'], + 'title' => (string) $match['title'], + ]; + $amount = round((float) $match['price'], 2); + $days = (int) $match['days']; + $base_price = (float) ($match['base_price'] ?? $amount); + $discount_tag = (string) ($match['discount_tag'] ?? __('升级专享', 'aksrt')); + $upgrade_mode = sanitize_key((string) ($match['upgrade_mode'] ?? '')); + $action = 'upgrade'; + } else { + $packages = $this->parse_packages((string) ($vip['packages_raw'] ?? '')); + $pkg = $packages[$index - 1] ?? null; + if (!$pkg) { + return new WP_Error('aksrt_invalid_package', __('套餐不存在', 'aksrt'), ['status' => 400]); + } + $base_price = (float) $pkg['price']; + $days = (int) $pkg['days']; + $priced = $this->apply_action_price($base_price, $action, $renew, $upgrade, $level, $user_level, $expires, $is_permanent, $days); + if ($priced === null) { + return new WP_Error('aksrt_invalid_package', __('当前无法购买该套餐', 'aksrt'), ['status' => 400]); + } + $amount = round((float) $priced['price'], 2); + $discount_tag = (string) ($priced['tag'] ?? ''); + } + + if ($amount <= 0) { + return new WP_Error('aksrt_invalid_price', __('套餐价格无效', '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'; + } + + $note = wp_json_encode([ + 'vip_pay' => 1, + 'level' => $level, + 'days' => $days, + 'package_index' => $index, + 'package_title' => (string) ($pkg['title'] ?? ''), + 'package_id' => $package_id, + 'action' => $action, + 'upgrade_mode' => $upgrade_mode, + 'base_price' => $base_price, + 'discount_tag' => $discount_tag, + ], 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_VIP, + 'product_id' => $package_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]); + } + + $level_name = (string) ($levels[$level]['name'] ?? ('VIP' . $level)); + $action_label = $action === 'renew' ? __('续费', 'aksrt') : ($action === 'upgrade' ? __('升级', 'aksrt') : __('开通', 'aksrt')); + $order['subject'] = sprintf( + /* translators: 1: action 2: vip name 3: package title */ + __('%1$s%2$s · %3$s', 'aksrt'), + $action_label, + $level_name, + (string) ($pkg['title'] ?? '') + ); + $order['payment_method'] = $payment_method; + + if (!class_exists('AKSRT_Payment_Gateway')) { + return new WP_Error('aksrt_pay_unavailable', __('支付网关不可用', 'aksrt'), ['status' => 500]); + } + $gateway = AKSRT_Payment_Gateway::get_instance()->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, + '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]); + } + + return [ + 'success' => true, + 'data' => [ + 'order_number' => $order_number, + 'amount' => $amount, + 'level' => $level, + 'days' => $days, + 'action' => $action, + 'message' => $action === 'renew' + ? __('会员续费成功', 'aksrt') + : ($action === 'upgrade' ? __('会员升级成功', 'aksrt') : __('会员开通成功', 'aksrt')), + 'reload' => true, + ], + ]; + } + + + + /** + * Activate VIP on paid order. + * + * @param string $order_number + * @param array $paid_order + */ + public function on_order_paid(string $order_number, array $paid_order): void { + if (sanitize_key((string) ($paid_order['product_type'] ?? '')) !== AKSRT_Domain_Model::ORDER_PRODUCT_VIP) { + return; + } + $user_id = (int) ($paid_order['user_id'] ?? 0); + if ($user_id <= 0) { + return; + } + + $meta = []; + if (!empty($paid_order['note']) && is_string($paid_order['note'])) { + $decoded = json_decode($paid_order['note'], true); + if (is_array($decoded)) { + $meta = $decoded; + } + } + if (empty($meta) && is_array($paid_order['meta_payload'] ?? null)) { + $meta = $paid_order['meta_payload']; + } + + $level = (int) ($meta['level'] ?? 0); + $days = (int) ($meta['days'] ?? 0); + $upgrade_mode = sanitize_key((string) ($meta['upgrade_mode'] ?? '')); + if ($level <= 0) { + $decoded = $this->decode_package_id((int) ($paid_order['product_id'] ?? 0)); + if ($decoded) { + $level = (int) $decoded['level']; + } + } + if ($level < 1) { + $level = 1; + } + if ($level > 2) { + $level = 2; + } + + // unit upgrade: raise level only, keep current expiry (zibll). + if ($upgrade_mode === 'unit') { + $this->activate_vip($user_id, $level, 0, true); + } else { + $this->activate_vip($user_id, $level, $days, false); + } + } + + + + /** + * Apply membership meta. + * + * @param bool $keep_expiry When true (unit upgrade), only raise level and do not change expiry. + * When days<=0 and keep_expiry=false, set permanent (expires=0). + */ + public function activate_vip(int $user_id, int $level, int $days = 0, bool $keep_expiry = false): void { + if ($user_id <= 0) { + return; + } + $level = max(1, min(2, $level)); + $current_level = (int) get_user_meta($user_id, 'aksrt_vip_level', true); + $new_level = max($current_level, $level); + update_user_meta($user_id, 'aksrt_vip_level', $new_level); + + // Unit upgrade (zibll): keep vip_exp_date, only raise level. + if ($keep_expiry) { + return; + } + + if ($days <= 0) { + // Permanent. + update_user_meta($user_id, 'aksrt_vip_expires', 0); + return; + } + + $current_exp = (int) get_user_meta($user_id, 'aksrt_vip_expires', true); + if ($current_exp === 0 && $current_level > 0) { + // Already permanent — keep permanent. + return; + } + $base = max($current_exp, time()); + update_user_meta($user_id, 'aksrt_vip_expires', $base + ($days * DAY_IN_SECONDS)); + } + + + + /** + * Points exchange (non-VIP only, zibll pay_vip_points_exchange). + * + * @return array|WP_Error + */ + public function exchange_with_points(int $user_id, int $package_id) { + if ($user_id <= 0) { + return new WP_Error('aksrt_need_login', __('请先登录后再兑换。', 'aksrt'), ['status' => 401]); + } + if (!class_exists('AKSRT_Credits')) { + return new WP_Error('aksrt_credits_unavailable', __('积分系统不可用', 'aksrt'), ['status' => 500]); + } + if (class_exists('AKSRT_Settings') && !AKSRT_Settings::credits_enabled()) { + return new WP_Error('aksrt_credits_disabled', __('积分功能未启用', 'aksrt'), ['status' => 400]); + } + + $vip = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_payment_vip_settings() + : []; + $points_ex = is_array($vip['points_exchange'] ?? null) ? $vip['points_exchange'] : []; + if (empty($points_ex['enabled'])) { + return new WP_Error('aksrt_points_exchange_disabled', __('积分兑换会员未启用', 'aksrt'), ['status' => 400]); + } + + $user_level = function_exists('aksrt_get_user_vip_level') ? aksrt_get_user_vip_level($user_id) : 0; + if ($user_level > 0) { + return new WP_Error('aksrt_already_vip', __('您已是会员,无法使用积分兑换。', 'aksrt'), ['status' => 400]); + } + + $levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : []; + $packages = $this->get_points_exchange_packages($vip, $levels); + $match = null; + foreach ($packages as $pkg) { + if ((int) ($pkg['package_id'] ?? 0) === $package_id) { + $match = $pkg; + break; + } + } + if (!$match) { + return new WP_Error('aksrt_invalid_package', __('兑换套餐不存在', 'aksrt'), ['status' => 400]); + } + + $cost = (int) ($match['points'] ?? 0); + $level = (int) ($match['level'] ?? 1); + $days = (int) ($match['days'] ?? 0); + if ($cost <= 0) { + return new WP_Error('aksrt_invalid_price', __('兑换积分无效', 'aksrt'), ['status' => 400]); + } + + $balance = AKSRT_Credits::balance($user_id); + if ($balance < $cost) { + return new WP_Error('aksrt_credits_insufficient', __('积分不足,暂时无法兑换', 'aksrt'), [ + 'status' => 402, + 'balance' => $balance, + 'cost' => $cost, + ]); + } + + if (!class_exists('AKSRT_Order') || !class_exists('AKSRT_Order_Repository')) { + return new WP_Error('aksrt_pay_unavailable', __('订单系统不可用', 'aksrt'), ['status' => 500]); + } + + $note = wp_json_encode([ + 'vip_pay' => 1, + 'vip_pay_type' => 'points_exchange', + 'level' => $level, + 'days' => $days, + 'points' => $cost, + 'package_id' => $package_id, + 'package_title' => (string) ($match['title'] ?? ''), + 'action' => 'points_exchange', + ], 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_VIP, + 'product_id' => $package_id, + 'amount' => (float) $cost, + 'currency' => 'points', + 'status' => AKSRT_Order::STATUS_PENDING, + 'payment_method' => 'points', + '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]); + } + + $deducted = AKSRT_Credits::deduct( + $user_id, + $cost, + 'vip_points_exchange', + ['order' => $order_number, 'level' => $level, 'days' => $days], + sprintf(__('积分兑换会员 · %s', 'aksrt'), (string) ($match['title'] ?? '')) + ); + 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 new WP_Error('aksrt_credits_deduct_failed', $msg ?: __('积分扣除失败', 'aksrt'), ['status' => 400]); + } + + $paid = AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_PAID, [ + 'payment_method' => 'points', + 'payment_transaction_id' => 'points_' . $order_number, + 'note' => sprintf(__('积分兑换会员 %d 积分。', 'aksrt'), $cost), + ]); + if (!$paid) { + AKSRT_Credits::add( + $user_id, + $cost, + 'vip_points_exchange_refund', + ['order' => $order_number], + __('会员兑换失败退款', 'aksrt') + ); + return new WP_Error('aksrt_order_confirm_failed', __('兑换确认失败,已尝试退回积分', 'aksrt'), ['status' => 500]); + } + + return [ + 'success' => true, + 'data' => [ + 'order_number' => $order_number, + 'points' => $cost, + 'level' => $level, + 'days' => $days, + 'balance' => AKSRT_Credits::balance($user_id), + 'message' => __('兑换成功', 'aksrt'), + 'reload' => true, + ], + ]; + } + +} diff --git a/inc/classes/content/vip-service-traits/trait-aksrt-vip-service-rest.php b/inc/classes/content/vip-service-traits/trait-aksrt-vip-service-rest.php new file mode 100644 index 00000000..203d0fcf --- /dev/null +++ b/inc/classes/content/vip-service-traits/trait-aksrt-vip-service-rest.php @@ -0,0 +1,124 @@ + 'GET', + 'callback' => [$this, 'rest_catalog'], + 'permission_callback' => [$this, 'check_rest_nonce'], + ], + [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_purchase'], + 'permission_callback' => [$this, 'check_rest_nonce'], + ], + ]); + + register_rest_route('aksrt/v1', '/pay/vip/points-exchange', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_points_exchange'], + 'permission_callback' => [$this, 'check_rest_nonce'], + ]); + } + + + + /** + * @param WP_REST_Request $request + * @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; + } + + + + /** + * GET /pay/vip + */ + public function rest_catalog(WP_REST_Request $request) { + $uid = get_current_user_id(); + return new WP_REST_Response($this->get_catalog($uid), 200); + } + + + + /** + * POST /pay/vip + * body: { package_id: int, payment_method?: string } + */ + public function rest_purchase(WP_REST_Request $request) { + $uid = get_current_user_id(); + if ($uid <= 0) { + return new WP_Error('aksrt_need_login', __('请先登录后再开通会员。', 'aksrt'), [ + 'status' => 401, + 'login_url' => function_exists('aksrt_get_login_url') ? aksrt_get_login_url() : wp_login_url(), + ]); + } + + $params = $request->get_json_params(); + if (!is_array($params)) { + $params = $request->get_params(); + } + $package_id = (int) ($params['package_id'] ?? 0); + $payment_method = sanitize_key((string) ($params['payment_method'] ?? 'balance')); + if ($payment_method === '' || $payment_method === 'cash') { + $payment_method = 'balance'; + } + + $result = $this->purchase($uid, $package_id, $payment_method); + if (is_wp_error($result)) { + return $result; + } + if (empty($result['success'])) { + return new WP_Error( + sanitize_key((string) ($result['code'] ?? 'aksrt_vip_pay_failed')), + (string) ($result['message'] ?? __('开通失败', 'aksrt')), + ['status' => (int) ($result['status'] ?? 400)] + ); + } + return new WP_REST_Response($result, 200); + } + + + + /** + * POST /pay/vip/points-exchange + * body: { package_id: int } // points package id = 5000 + index + */ + public function rest_points_exchange(WP_REST_Request $request) { + $uid = get_current_user_id(); + if ($uid <= 0) { + return new WP_Error('aksrt_need_login', __('请先登录后再兑换。', 'aksrt'), ['status' => 401]); + } + $params = $request->get_json_params(); + if (!is_array($params)) { + $params = $request->get_params(); + } + $package_id = (int) ($params['package_id'] ?? 0); + $result = $this->exchange_with_points($uid, $package_id); + if (is_wp_error($result)) { + return $result; + } + return new WP_REST_Response($result, 200); + } + +} diff --git a/inc/classes/core/class-aksrt-scripts.php b/inc/classes/core/class-aksrt-scripts.php index 56b2ba11..913ce7bf 100644 --- a/inc/classes/core/class-aksrt-scripts.php +++ b/inc/classes/core/class-aksrt-scripts.php @@ -11,6 +11,7 @@ defined('ABSPATH') || exit; class AKSRT_Scripts { use AKSRT_Scripts_Part_1b; + use AKSRT_Scripts_Part_1c; use AKSRT_Scripts_Part_2; diff --git a/inc/classes/core/scripts-traits/trait-aksrt-scripts-part-1b.php b/inc/classes/core/scripts-traits/trait-aksrt-scripts-part-1b.php index 46551a46..2150c062 100644 --- a/inc/classes/core/scripts-traits/trait-aksrt-scripts-part-1b.php +++ b/inc/classes/core/scripts-traits/trait-aksrt-scripts-part-1b.php @@ -775,579 +775,8 @@ trait AKSRT_Scripts_Part_1b { } } - // ===== 5. 字体 ===== - // 5a. 拉丁字体 - Inter (UI) + Source Serif 4 (正文) - wp_enqueue_style( - 'aksrt-fonts', - 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,500;0,8..60,600;0,8..60,700;1,8..60,400&display=swap', - [], - null - ); - - // 5b. 中文字体 - jsDelivr CDN (思源黑体 / 思源宋体) - // 仅 regular 字重,避免拖慢首屏;其它字重通过 system fallback (PingFang/微软雅黑) - // Fontsource v5 已按 unicode-range 自动分包,浏览器只会下载用到的简中子集 - wp_enqueue_style( - 'aksrt-fonts-cn', - 'https://cdn.jsdelivr.net/npm/@fontsource/noto-sans-sc@5.2.9/400.css', - [], - null - ); - wp_enqueue_style( - 'aksrt-fonts-cn-serif', - 'https://cdn.jsdelivr.net/npm/@fontsource/noto-serif-sc@5.2.9/400.css', - [], - null - ); - - // ===== 6. 图标系统 ===== - // Font Awesome 5 全站加载(评论回复、单篇互动栏、profile 联系方式等 - // 多处模板使用 fa-* 类名,作为全主题图标包统一使用) - wp_enqueue_style( - 'aksrt-font-awesome-5', - 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css', - [], - '5.15.4' - ); - - // ===== 7. JS ===== - - // Swiper.js(首页/归档/CMS 模块/单篇文章页加载) - $needs_swiper = $is_home_or_archive || $is_single_post || $is_single_resource; - if ($needs_swiper) { - wp_enqueue_style( - 'aksrt-swiper', - $base_uri . '/assets/css/frontend/swiper-bundle.min.css', - [], - '11.0' - ); - wp_enqueue_script( - 'aksrt-swiper', - $base_uri . '/assets/js/swiper-bundle.min.js', - [], - '11.0', - true - ); - } - - wp_enqueue_script( - 'aksrt-qrcode', - 'https://cdn.jsdelivr.net/npm/kjua@0.9.0/dist/kjua.min.js', - [], - '0.9.0', - true - ); - - // Rehydrate system — must load before aksrt-script so AksrtFrontend is available - wp_enqueue_script( - 'aksrt-rehydrate', - $base_uri . '/assets/js/rehydrate.js', - [], - file_exists(get_template_directory() . '/assets/js/rehydrate.js') - ? (string) filemtime(get_template_directory() . '/assets/js/rehydrate.js') - : $version, - true - ); - - // Runtime infrastructure: tbquire / notyf / confirm / refreshModal / scrollTopTo / [data-*] declarative - $runtime_js = get_template_directory() . '/assets/js/aksrt-runtime.js'; - if (file_exists($runtime_js)) { - wp_enqueue_script( - 'aksrt-runtime', - $base_uri . '/assets/js/aksrt-runtime.js', - ['aksrt-rehydrate'], - (string) filemtime($runtime_js), - true - ); - } - - // Core modules (split from main.js) — register with AksrtFrontend - $core_modules = [ - 'aksrt-core-theme' => 'theme.js', - 'aksrt-core-nav' => 'nav.js', - 'aksrt-core-search' => 'search.js', - 'aksrt-core-dropdown' => 'dropdown.js', - 'aksrt-core-scroll' => 'scroll.js', - 'aksrt-core-slider' => 'slider.js', - 'aksrt-core-article' => 'article.js', - 'aksrt-core-comments' => 'comments.js', - 'aksrt-core-pagination' => 'pagination.js', - 'aksrt-core-archive' => 'archive.js', - 'aksrt-core-footer-tabbar' => 'footer-tabbar.js', - 'aksrt-core-misc' => 'misc.js', - ]; - $core_handles = []; - foreach ($core_modules as $handle => $file) { - $path = get_template_directory() . '/assets/js/core/' . $file; - if (file_exists($path)) { - // Depend on aksrt-runtime so window.aksrtData (localized below) is available. - $deps = ['aksrt-rehydrate', 'aksrt-runtime']; - if ($handle === 'aksrt-core-slider') { - $deps = array_merge($deps, $needs_swiper ? ['aksrt-swiper'] : []); - } - wp_enqueue_script( - $handle, - $base_uri . '/assets/js/core/' . $file, - $deps, - (string) filemtime($path), - true - ); - $core_handles[] = $handle; - } - } - - wp_enqueue_script( - 'aksrt-script', - $base_uri . '/assets/js/main.js', - array_merge( - ['jquery', 'bootstrap5', 'aksrt-rehydrate', 'aksrt-runtime'], - $core_handles - ), - file_exists(get_template_directory() . '/assets/js/main.js') - ? (string) filemtime(get_template_directory() . '/assets/js/main.js') - : $version, - true - ); - - // SVG icon data + helper (must load before other scripts that use icons) - $svg_icon_js = get_template_directory() . '/assets/js/svg-icon.js'; - if (file_exists($svg_icon_js)) { - wp_enqueue_script( - 'aksrt-svg-icon', - $base_uri . '/assets/js/svg-icon.js', - [], - (string) filemtime($svg_icon_js), - false // load in so icons are available early - ); - } - - $svg_helper_js = get_template_directory() . '/assets/js/svg-icon-helper.js'; - if (file_exists($svg_helper_js)) { - wp_enqueue_script( - 'aksrt-svg-icon-helper', - $base_uri . '/assets/js/svg-icon-helper.js', - ['aksrt-svg-icon'], - (string) filemtime($svg_helper_js), - true - ); - } - - // Page loading indicator (top progress bar) - $loader_js = get_template_directory() . '/assets/js/loader.js'; - if (file_exists($loader_js)) { - wp_enqueue_script( - 'aksrt-loader', - $base_uri . '/assets/js/loader.js', - [], - (string) filemtime($loader_js), - true - ); - } - - // Code block enhancements (copy button + language label) - $precode_js = get_template_directory() . '/assets/js/precode.js'; - if (file_exists($precode_js)) { - wp_enqueue_script( - 'aksrt-precode', - $base_uri . '/assets/js/precode.js', - [], - (string) filemtime($precode_js), - true - ); - } - - // Input field enhancements (auto-resize, counter, validation) - $input_expand_js = get_template_directory() . '/assets/js/input-expand.js'; - if (file_exists($input_expand_js)) { - wp_enqueue_script( - 'aksrt-input-expand', - $base_uri . '/assets/js/input-expand.js', - [], - (string) filemtime($input_expand_js), - true - ); - } - - // Instant page prefetch (hover to preload) - $instant_page_js = get_template_directory() . '/assets/js/instant-page.js'; - if (file_exists($instant_page_js)) { - wp_enqueue_script( - 'aksrt-instant-page', - $base_uri . '/assets/js/instant-page.js', - [], - (string) filemtime($instant_page_js), - false // load in for early prefetch capability - ); - } - - // Touch gesture support (swipe, pull-to-dismiss) - $mini_touch_js = get_template_directory() . '/assets/js/mini-touch.js'; - if (file_exists($mini_touch_js)) { - wp_enqueue_script( - 'aksrt-mini-touch', - $base_uri . '/assets/js/mini-touch.js', - [], - (string) filemtime($mini_touch_js), - true - ); - } - - // Section navs: article TOC / scrollspy - $section_navs_js = get_template_directory() . '/assets/js/section-navs.js'; - if (file_exists($section_navs_js)) { - wp_enqueue_script( - 'aksrt-section-navs', - $base_uri . '/assets/js/section-navs.js', - ['aksrt-rehydrate', 'aksrt-runtime'], - (string) filemtime($section_navs_js), - true - ); - } - - // Video player (MP4/HLS/FLV) - $video_player_js = get_template_directory() . '/assets/js/video-player.js'; - if (file_exists($video_player_js)) { - wp_enqueue_script( - 'aksrt-video-player', - $base_uri . '/assets/js/video-player.js', - [], - (string) filemtime($video_player_js), - true - ); - } - - // Image color extraction - $color_extract_js = get_template_directory() . '/assets/js/color-extract.js'; - if (file_exists($color_extract_js)) { - wp_enqueue_script( - 'aksrt-color-extract', - $base_uri . '/assets/js/color-extract.js', - [], - (string) filemtime($color_extract_js), - true - ); - } - - $widget_set_js = get_template_directory() . '/assets/js/widget-set.js'; - if (file_exists($widget_set_js)) { - wp_enqueue_script( - 'aksrt-widget-set', - $base_uri . '/assets/js/widget-set.js', - ['aksrt-script', 'aksrt-qrcode'], - (string) filemtime($widget_set_js), - true - ); - } - - // ===== 全局 UI 增强(Toast、骨架屏、悬停动效、灯箱、滚动感知)===== - $enhancements_css = get_template_directory() . '/assets/css/frontend/enhancements.css'; - if (file_exists($enhancements_css)) { - wp_enqueue_style( - 'aksrt-enhancements', - $base_uri . '/assets/css/frontend/enhancements.css', - [$core_dep], - (string) filemtime($enhancements_css) - ); - } - - $enhancements_js = get_template_directory() . '/assets/js/enhancements.js'; - if (file_exists($enhancements_js)) { - wp_enqueue_script( - 'aksrt-enhancements', - $base_uri . '/assets/js/enhancements.js', - ['aksrt-script'], - (string) filemtime($enhancements_js), - true - ); - } - - $enhancements2_js = get_template_directory() . '/assets/js/enhancements2.js'; - if (file_exists($enhancements2_js)) { - wp_enqueue_script( - 'aksrt-enhancements2', - $base_uri . '/assets/js/enhancements2.js', - ['aksrt-script'], - (string) filemtime($enhancements2_js), - true - ); - } - - // Reward (打赏) - $reward_js = get_template_directory() . '/assets/js/reward.js'; - if (file_exists($reward_js)) { - wp_enqueue_script( - 'aksrt-reward', - $base_uri . '/assets/js/reward.js', - ['aksrt-script'], - (string) filemtime($reward_js), - true - ); - } - - // Effects (count-up, countdown, marquee, tilt, FPS detection) - $effects_js = get_template_directory() . '/assets/js/effects.js'; - if (file_exists($effects_js)) { - wp_enqueue_script( - 'aksrt-effects', - $base_uri . '/assets/js/effects.js', - ['aksrt-script'], - (string) filemtime($effects_js), - true - ); - } - - // Yiyan (一言轮播) - $yiyan_js = get_template_directory() . '/assets/js/yiyan.js'; - if (file_exists($yiyan_js)) { - wp_enqueue_script( - 'aksrt-yiyan', - $base_uri . '/assets/js/yiyan.js', - [], - (string) filemtime($yiyan_js), - true - ); - } - - // Frontend translate (默认关) - $translate_js = get_template_directory() . '/assets/js/frontend-translate.js'; - if (file_exists($translate_js)) { - wp_enqueue_script( - 'aksrt-frontend-translate', - $base_uri . '/assets/js/frontend-translate.js', - [], - (string) filemtime($translate_js), - true - ); - } - - // Content protection (copy notice / right-click / F12) - $content_protect_js = get_template_directory() . '/assets/js/content-protect.js'; - if (file_exists($content_protect_js)) { - wp_enqueue_script( - 'aksrt-content-protect', - $base_uri . '/assets/js/content-protect.js', - [], - (string) filemtime($content_protect_js), - false // load in for early binding - ); - } - - // Site notice popup (global, controlled by Settings) - $site_notice_css = get_template_directory() . '/assets/css/frontend/site-notice.css'; - if (file_exists($site_notice_css)) { - wp_enqueue_style( - 'aksrt-site-notice', - $base_uri . '/assets/css/frontend/site-notice.css', - [$core_dep], - (string) filemtime($site_notice_css) - ); - } - $site_notice_js = get_template_directory() . '/assets/js/site-notice.js'; - if (file_exists($site_notice_js)) { - wp_enqueue_script( - 'aksrt-site-notice', - $base_uri . '/assets/js/site-notice.js', - [], - (string) filemtime($site_notice_js), - true - ); - } - - // 弹窗登录/注册(仅未登录用户) - if (!is_user_logged_in()) { - $modal_auth_js = get_template_directory() . '/assets/js/modal-auth.js'; - if (file_exists($modal_auth_js)) { - wp_enqueue_script( - 'aksrt-modal-auth', - $base_uri . '/assets/js/modal-auth.js', - ['aksrt-script'], - (string) filemtime($modal_auth_js), - true - ); - } - } - - if ($is_fixed_homepage) { - $homepage_js_path = get_template_directory() . '/assets/js/homepage-fixed.js'; - wp_enqueue_script( - 'aksrt-homepage-fixed', - $base_uri . '/assets/js/homepage-fixed.js', - ['aksrt-script'], - file_exists($homepage_js_path) ? (string) filemtime($homepage_js_path) : $version, - true - ); - } - - - // Comment toolbar (emoji, code, bold, Ctrl+Enter) — only when the form is open. - if (is_singular() && comments_open()) { - $comment_tools_path = get_template_directory() . '/assets/js/comment-tools.js'; - wp_enqueue_script( - 'aksrt-comment-tools', - $base_uri . '/assets/js/comment-tools.js', - ['aksrt-script'], - file_exists($comment_tools_path) ? (string) filemtime($comment_tools_path) : $version, - true - ); - } - - // Comment actions client (like / edit / delete / moderate / pin) — load - // wherever comments can exist, including closed-comment posts. Submission - // itself is handled by main.js initCommentSystem(). - if (is_singular() && (comments_open() || (int) get_comments_number() > 0)) { - $comment_ajax_path = get_template_directory() . '/assets/js/comment-ajax.js'; - if (file_exists($comment_ajax_path)) { - wp_enqueue_script( - 'aksrt-comment-ajax', - $base_uri . '/assets/js/comment-ajax.js', - ['aksrt-script'], - (string) filemtime($comment_ajax_path), - true - ); - } - } - - if (is_singular() || is_page_template('templates/pages/page-forums.php')) { - $page_template_path = get_template_directory() . '/assets/js/page-template.js'; - if (file_exists($page_template_path)) { - wp_enqueue_script( - 'aksrt-page-template', - $base_uri . '/assets/js/page-template.js', - ['aksrt-script'], - (string) filemtime($page_template_path), - true - ); - } - } - - // Post/resource share functionality - if ($is_single_post || $is_single_resource) { - $weixin_share_path = get_template_directory() . '/assets/js/weixin-share.js'; - if (file_exists($weixin_share_path)) { - wp_enqueue_script( - 'aksrt-weixin-share', - $base_uri . '/assets/js/weixin-share.js', - ['aksrt-script'], - (string) filemtime($weixin_share_path), - true - ); - } - - $post_share_path = get_template_directory() . '/assets/js/post-share.js'; - if (file_exists($post_share_path)) { - wp_enqueue_script( - 'aksrt-post-share', - $base_uri . '/assets/js/post-share.js', - ['aksrt-script', 'aksrt-weixin-share', 'aksrt-share-data'], - (string) filemtime($post_share_path), - true - ); - } - } - - // Page navigation (TOC highlight, smooth scroll, doc search) - // 评论回复脚本 - if (is_singular() && comments_open() && get_option('thread_comments')) { - wp_enqueue_script('comment-reply'); - } - - $runtime_routes = $this->resolve_frontend_routes(); - - // 数据注入 - 全站统一消费 window.aksrtData - // Attach to aksrt-runtime (loaded before all core modules) so ajaxUrl/nonce - // exist before pagination/search/article handlers run. - $aksrt_data_handle = wp_script_is('aksrt-runtime', 'registered') || wp_script_is('aksrt-runtime', 'enqueued') - ? 'aksrt-runtime' - : 'aksrt-script'; - wp_localize_script($aksrt_data_handle, 'aksrtData', [ - 'ajaxUrl' => admin_url('admin-ajax.php'), - 'nonce' => wp_create_nonce('aksrt_public_nonce'), - 'restNonce' => wp_create_nonce('wp_rest'), - 'visibilityNonce' => wp_create_nonce('aksrt_visibility'), - 'siteUrl' => home_url('/'), - 'themeUrl' => AKSRT_URI, - 'themeMode' => class_exists('AKSRT_Settings') ? AKSRT_Settings::theme_mode_js_value() : 'auto', - 'debug' => defined('WP_DEBUG') && WP_DEBUG, - 'debugLog' => defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG, - 'postPaginationMode' => class_exists('AKSRT_Settings') ? AKSRT_Settings::get_post_pagination_mode() : 'load_more', - 'commentPaginationMode' => class_exists('AKSRT_Settings') ? AKSRT_Settings::get_comment_pagination_mode() : 'numbered', - 'homepagePostsPerPage' => function_exists('aksrt_homepage_get_tab_posts_per_page') ? aksrt_homepage_get_tab_posts_per_page() : (int) get_option('posts_per_page'), - 'homepageDefaultSort' => class_exists('AKSRT_Settings') ? AKSRT_Settings::homepage_default_sort() : 'modified', - 'homepageDefaultView' => class_exists('AKSRT_Settings') ? AKSRT_Settings::homepage_default_view() : 'list', - 'homepageSortAjax' => class_exists('AKSRT_Settings') ? AKSRT_Settings::homepage_sort_ajax_enabled() : true, - 'homepageStickyTabs' => class_exists('AKSRT_Settings') ? AKSRT_Settings::homepage_sticky_tabs_enabled() : true, - 'homePostsListEnabled' => !class_exists('AKSRT_Settings') || AKSRT_Settings::home_posts_list_enabled(), - 'homeListSortEnabled' => !class_exists('AKSRT_Settings') || AKSRT_Settings::home_list_sort_enabled(), - 'homeListSortDropdown' => class_exists('AKSRT_Settings') && AKSRT_Settings::home_list_sort_dropdown(), - // AJAX 翻页文案 - 'ajaxTrigger' => (class_exists('AKSRT_Settings') && ($t = AKSRT_Settings::get_post_list_style_settings()['ajax_trigger_text'] ?? '') !== '') ? $t : __('加载更多', 'aksrt'), - 'ajaxNomore' => (class_exists('AKSRT_Settings') && ($n = AKSRT_Settings::get_post_list_style_settings()['ajax_nomore_text'] ?? '') !== '') ? $n : __('没有更多了', 'aksrt'), - 'ajaxLoading' => __('正在加载...', 'aksrt'), - 'iasMax' => class_exists('AKSRT_Settings') ? AKSRT_Settings::get_post_list_style_settings()['ias_max'] : 3, - 'commentIasAuto' => class_exists('AKSRT_Settings') ? AKSRT_Settings::get_comment_display_settings()['paging_ajax_ias'] : true, - 'commentUploadEnabled' => class_exists('AKSRT_Settings') ? !empty((AKSRT_Settings::get_comment_display_settings()['toolbar']['upload_img'] ?? false)) : false, - 'commentUploadUrl' => esc_url_raw(rest_url('aksrt/v1/comment/upload-image')), - 'commentUploadMaxSize' => 2 * 1024 * 1024, - 'commentIasMax' => class_exists('AKSRT_Settings') ? AKSRT_Settings::get_comment_display_settings()['paging_ajax_ias_max'] : 3, - 'stickyEnabled' => class_exists('AKSRT_Settings') ? AKSRT_Settings::get_post_list_style_settings()['sticky_enabled'] : true, - 'stickyPages' => class_exists('AKSRT_Settings') ? AKSRT_Settings::get_post_list_style_settings()['sticky_pages'] : ['home', 'cat'], - 'badgeShow' => class_exists('AKSRT_Settings') ? AKSRT_Settings::get_post_list_style_settings()['badge_show'] : true, - 'badgeTypes' => class_exists('AKSRT_Settings') ? AKSRT_Settings::get_post_list_style_settings()['badge_types'] : ['pay', 'tag', 'topics', 'cat'], - 'search' => [ - 'mode' => class_exists('AKSRT_Settings') ? AKSRT_Settings::search_mode() : 'default', - 'placeholder' => function_exists('aksrt_get_search_placeholder') ? aksrt_get_search_placeholder() : __('开启精彩搜索...', 'aksrt'), - 'historyEnabled' => function_exists('aksrt_search_history_enabled') ? aksrt_search_history_enabled() : true, - 'suggestionsEnabled' => function_exists('aksrt_search_suggestions_enabled') ? aksrt_search_suggestions_enabled() : false, - 'hotKeywords' => function_exists('aksrt_get_search_hot_keywords') ? aksrt_get_search_hot_keywords() : [], - 'popularTitle' => class_exists('AKSRT_Settings') ? AKSRT_Settings::search_popular_title() : __('热门搜索', 'aksrt'), - 'contentTypes' => class_exists('AKSRT_Settings') ? AKSRT_Settings::get_search_content_types()['types'] : ['post', 'category', 'tag'], - 'defaultType' => class_exists('AKSRT_Settings') ? AKSRT_Settings::get_search_content_types()['default'] : 'post', - ], - // 用户状态 - 'isLoggedIn' => is_user_logged_in(), - 'userId' => get_current_user_id(), - 'cartUrl' => $runtime_routes['cart'], - 'ordersUrl' => $runtime_routes['orders'], - 'userCenterUrl' => function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/'), - 'routes' => [ - 'home' => home_url('/'), - 'login' => function_exists('aksrt_get_login_url') ? aksrt_get_login_url() : wp_login_url(), - 'register' => function_exists('aksrt_get_register_url') ? aksrt_get_register_url() : wp_registration_url(), - 'resetPassword' => function_exists('aksrt_get_reset_password_url') ? aksrt_get_reset_password_url() : wp_lostpassword_url(), - 'profile' => function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/profile/'), - 'cart' => $runtime_routes['cart'], - 'orders' => $runtime_routes['orders'], - 'resources' => $runtime_routes['resources'], - ], - 'shell' => [ - 'wrapperId' => '', - 'navId' => 'site-nav', - 'searchPanelId' => 'nav-search-panel', - 'drawerId' => 'nav-drawer', - 'userMenuId' => 'user-dropdown', - 'floatActionsId' => 'float-right-btns', - 'tabbarId' => 'footer-tabbar', - ], - 'themeUri' => get_template_directory_uri(), - 'codeHighlight' => class_exists('AKSRT_Settings') - ? AKSRT_Settings::get_code_highlight_config() - : ['enabled' => true, 'lightTheme' => 'enlighter', 'darkTheme' => 'monokai', 'lineNumbers' => false, 'showButtons' => true, 'maxHeight' => 500], - 'lazyload' => class_exists('AKSRT_Settings') - ? AKSRT_Settings::get_lazyload_config() - : ['enabled' => true, 'animation' => 'blur', 'placeholder_url' => '', 'blur' => true], - 'systemNotice' => class_exists('AKSRT_Settings') - ? AKSRT_Settings::get_system_notice_config() - : ['enabled' => false], - 'contentProtect' => [ - 'enabled' => class_exists('AKSRT_Settings') && (bool) AKSRT_Settings::get_value('content_protect_enabled', false), - 'copyNotice' => class_exists('AKSRT_Settings') && (bool) AKSRT_Settings::get_value('content_protect_copy_notice', false), - 'copyNoticeText' => class_exists('AKSRT_Settings') ? (string) AKSRT_Settings::get_value('content_protect_copy_text', '来自本站,请尊重原创版权。') : '', - 'disableRightClick'=> class_exists('AKSRT_Settings') && (bool) AKSRT_Settings::get_value('content_protect_right_click', false), - 'detectDevTools' => class_exists('AKSRT_Settings') && (bool) AKSRT_Settings::get_value('content_protect_f12', false), - ], - 'translate' => class_exists('AKSRT_Settings') - ? AKSRT_Settings::get_i18n_config() - : ['enabled' => false, 'autoDetect' => true, 'defaultLang' => 'zh-CN'], - ]); + + $this->enqueue_public_fonts_js_and_data($base_uri, $core_dep, $deps, $is_fixed_homepage, $is_home_or_archive, $is_single_post, $is_single_resource, $path, $version); } + } diff --git a/inc/classes/core/scripts-traits/trait-aksrt-scripts-part-1c.php b/inc/classes/core/scripts-traits/trait-aksrt-scripts-part-1c.php new file mode 100644 index 00000000..b6b1e0c6 --- /dev/null +++ b/inc/classes/core/scripts-traits/trait-aksrt-scripts-part-1c.php @@ -0,0 +1,591 @@ + 'theme.js', + 'aksrt-core-nav' => 'nav.js', + 'aksrt-core-search' => 'search.js', + 'aksrt-core-dropdown' => 'dropdown.js', + 'aksrt-core-scroll' => 'scroll.js', + 'aksrt-core-slider' => 'slider.js', + 'aksrt-core-article' => 'article.js', + 'aksrt-core-comments' => 'comments.js', + 'aksrt-core-pagination' => 'pagination.js', + 'aksrt-core-archive' => 'archive.js', + 'aksrt-core-footer-tabbar' => 'footer-tabbar.js', + 'aksrt-core-misc' => 'misc.js', + ]; + $core_handles = []; + foreach ($core_modules as $handle => $file) { + $path = get_template_directory() . '/assets/js/core/' . $file; + if (file_exists($path)) { + // Depend on aksrt-runtime so window.aksrtData (localized below) is available. + $deps = ['aksrt-rehydrate', 'aksrt-runtime']; + if ($handle === 'aksrt-core-slider') { + $deps = array_merge($deps, $needs_swiper ? ['aksrt-swiper'] : []); + } + wp_enqueue_script( + $handle, + $base_uri . '/assets/js/core/' . $file, + $deps, + (string) filemtime($path), + true + ); + $core_handles[] = $handle; + } + } + + wp_enqueue_script( + 'aksrt-script', + $base_uri . '/assets/js/main.js', + array_merge( + ['jquery', 'bootstrap5', 'aksrt-rehydrate', 'aksrt-runtime'], + $core_handles + ), + file_exists(get_template_directory() . '/assets/js/main.js') + ? (string) filemtime(get_template_directory() . '/assets/js/main.js') + : $version, + true + ); + + // SVG icon data + helper (must load before other scripts that use icons) + $svg_icon_js = get_template_directory() . '/assets/js/svg-icon.js'; + if (file_exists($svg_icon_js)) { + wp_enqueue_script( + 'aksrt-svg-icon', + $base_uri . '/assets/js/svg-icon.js', + [], + (string) filemtime($svg_icon_js), + false // load in so icons are available early + ); + } + + $svg_helper_js = get_template_directory() . '/assets/js/svg-icon-helper.js'; + if (file_exists($svg_helper_js)) { + wp_enqueue_script( + 'aksrt-svg-icon-helper', + $base_uri . '/assets/js/svg-icon-helper.js', + ['aksrt-svg-icon'], + (string) filemtime($svg_helper_js), + true + ); + } + + // Page loading indicator (top progress bar) + $loader_js = get_template_directory() . '/assets/js/loader.js'; + if (file_exists($loader_js)) { + wp_enqueue_script( + 'aksrt-loader', + $base_uri . '/assets/js/loader.js', + [], + (string) filemtime($loader_js), + true + ); + } + + // Code block enhancements (copy button + language label) + $precode_js = get_template_directory() . '/assets/js/precode.js'; + if (file_exists($precode_js)) { + wp_enqueue_script( + 'aksrt-precode', + $base_uri . '/assets/js/precode.js', + [], + (string) filemtime($precode_js), + true + ); + } + + // Input field enhancements (auto-resize, counter, validation) + $input_expand_js = get_template_directory() . '/assets/js/input-expand.js'; + if (file_exists($input_expand_js)) { + wp_enqueue_script( + 'aksrt-input-expand', + $base_uri . '/assets/js/input-expand.js', + [], + (string) filemtime($input_expand_js), + true + ); + } + + // Instant page prefetch (hover to preload) + $instant_page_js = get_template_directory() . '/assets/js/instant-page.js'; + if (file_exists($instant_page_js)) { + wp_enqueue_script( + 'aksrt-instant-page', + $base_uri . '/assets/js/instant-page.js', + [], + (string) filemtime($instant_page_js), + false // load in for early prefetch capability + ); + } + + // Touch gesture support (swipe, pull-to-dismiss) + $mini_touch_js = get_template_directory() . '/assets/js/mini-touch.js'; + if (file_exists($mini_touch_js)) { + wp_enqueue_script( + 'aksrt-mini-touch', + $base_uri . '/assets/js/mini-touch.js', + [], + (string) filemtime($mini_touch_js), + true + ); + } + + // Section navs: article TOC / scrollspy + $section_navs_js = get_template_directory() . '/assets/js/section-navs.js'; + if (file_exists($section_navs_js)) { + wp_enqueue_script( + 'aksrt-section-navs', + $base_uri . '/assets/js/section-navs.js', + ['aksrt-rehydrate', 'aksrt-runtime'], + (string) filemtime($section_navs_js), + true + ); + } + + // Video player (MP4/HLS/FLV) + $video_player_js = get_template_directory() . '/assets/js/video-player.js'; + if (file_exists($video_player_js)) { + wp_enqueue_script( + 'aksrt-video-player', + $base_uri . '/assets/js/video-player.js', + [], + (string) filemtime($video_player_js), + true + ); + } + + // Image color extraction + $color_extract_js = get_template_directory() . '/assets/js/color-extract.js'; + if (file_exists($color_extract_js)) { + wp_enqueue_script( + 'aksrt-color-extract', + $base_uri . '/assets/js/color-extract.js', + [], + (string) filemtime($color_extract_js), + true + ); + } + + $widget_set_js = get_template_directory() . '/assets/js/widget-set.js'; + if (file_exists($widget_set_js)) { + wp_enqueue_script( + 'aksrt-widget-set', + $base_uri . '/assets/js/widget-set.js', + ['aksrt-script', 'aksrt-qrcode'], + (string) filemtime($widget_set_js), + true + ); + } + + // ===== 全局 UI 增强(Toast、骨架屏、悬停动效、灯箱、滚动感知)===== + $enhancements_css = get_template_directory() . '/assets/css/frontend/enhancements.css'; + if (file_exists($enhancements_css)) { + wp_enqueue_style( + 'aksrt-enhancements', + $base_uri . '/assets/css/frontend/enhancements.css', + [$core_dep], + (string) filemtime($enhancements_css) + ); + } + + $enhancements_js = get_template_directory() . '/assets/js/enhancements.js'; + if (file_exists($enhancements_js)) { + wp_enqueue_script( + 'aksrt-enhancements', + $base_uri . '/assets/js/enhancements.js', + ['aksrt-script'], + (string) filemtime($enhancements_js), + true + ); + } + + $enhancements2_js = get_template_directory() . '/assets/js/enhancements2.js'; + if (file_exists($enhancements2_js)) { + wp_enqueue_script( + 'aksrt-enhancements2', + $base_uri . '/assets/js/enhancements2.js', + ['aksrt-script'], + (string) filemtime($enhancements2_js), + true + ); + } + + // Reward (打赏) + $reward_js = get_template_directory() . '/assets/js/reward.js'; + if (file_exists($reward_js)) { + wp_enqueue_script( + 'aksrt-reward', + $base_uri . '/assets/js/reward.js', + ['aksrt-script'], + (string) filemtime($reward_js), + true + ); + } + + // Effects (count-up, countdown, marquee, tilt, FPS detection) + $effects_js = get_template_directory() . '/assets/js/effects.js'; + if (file_exists($effects_js)) { + wp_enqueue_script( + 'aksrt-effects', + $base_uri . '/assets/js/effects.js', + ['aksrt-script'], + (string) filemtime($effects_js), + true + ); + } + + // Yiyan (一言轮播) + $yiyan_js = get_template_directory() . '/assets/js/yiyan.js'; + if (file_exists($yiyan_js)) { + wp_enqueue_script( + 'aksrt-yiyan', + $base_uri . '/assets/js/yiyan.js', + [], + (string) filemtime($yiyan_js), + true + ); + } + + // Frontend translate (默认关) + $translate_js = get_template_directory() . '/assets/js/frontend-translate.js'; + if (file_exists($translate_js)) { + wp_enqueue_script( + 'aksrt-frontend-translate', + $base_uri . '/assets/js/frontend-translate.js', + [], + (string) filemtime($translate_js), + true + ); + } + + // Content protection (copy notice / right-click / F12) + $content_protect_js = get_template_directory() . '/assets/js/content-protect.js'; + if (file_exists($content_protect_js)) { + wp_enqueue_script( + 'aksrt-content-protect', + $base_uri . '/assets/js/content-protect.js', + [], + (string) filemtime($content_protect_js), + false // load in for early binding + ); + } + + // Site notice popup (global, controlled by Settings) + $site_notice_css = get_template_directory() . '/assets/css/frontend/site-notice.css'; + if (file_exists($site_notice_css)) { + wp_enqueue_style( + 'aksrt-site-notice', + $base_uri . '/assets/css/frontend/site-notice.css', + [$core_dep], + (string) filemtime($site_notice_css) + ); + } + $site_notice_js = get_template_directory() . '/assets/js/site-notice.js'; + if (file_exists($site_notice_js)) { + wp_enqueue_script( + 'aksrt-site-notice', + $base_uri . '/assets/js/site-notice.js', + [], + (string) filemtime($site_notice_js), + true + ); + } + + // 弹窗登录/注册(仅未登录用户) + if (!is_user_logged_in()) { + $modal_auth_js = get_template_directory() . '/assets/js/modal-auth.js'; + if (file_exists($modal_auth_js)) { + wp_enqueue_script( + 'aksrt-modal-auth', + $base_uri . '/assets/js/modal-auth.js', + ['aksrt-script'], + (string) filemtime($modal_auth_js), + true + ); + } + } + + if ($is_fixed_homepage) { + $homepage_js_path = get_template_directory() . '/assets/js/homepage-fixed.js'; + wp_enqueue_script( + 'aksrt-homepage-fixed', + $base_uri . '/assets/js/homepage-fixed.js', + ['aksrt-script'], + file_exists($homepage_js_path) ? (string) filemtime($homepage_js_path) : $version, + true + ); + } + + + // Comment toolbar (emoji, code, bold, Ctrl+Enter) — only when the form is open. + if (is_singular() && comments_open()) { + $comment_tools_path = get_template_directory() . '/assets/js/comment-tools.js'; + wp_enqueue_script( + 'aksrt-comment-tools', + $base_uri . '/assets/js/comment-tools.js', + ['aksrt-script'], + file_exists($comment_tools_path) ? (string) filemtime($comment_tools_path) : $version, + true + ); + } + + // Comment actions client (like / edit / delete / moderate / pin) — load + // wherever comments can exist, including closed-comment posts. Submission + // itself is handled by main.js initCommentSystem(). + if (is_singular() && (comments_open() || (int) get_comments_number() > 0)) { + $comment_ajax_path = get_template_directory() . '/assets/js/comment-ajax.js'; + if (file_exists($comment_ajax_path)) { + wp_enqueue_script( + 'aksrt-comment-ajax', + $base_uri . '/assets/js/comment-ajax.js', + ['aksrt-script'], + (string) filemtime($comment_ajax_path), + true + ); + } + } + + if (is_singular() || is_page_template('templates/pages/page-forums.php')) { + $page_template_path = get_template_directory() . '/assets/js/page-template.js'; + if (file_exists($page_template_path)) { + wp_enqueue_script( + 'aksrt-page-template', + $base_uri . '/assets/js/page-template.js', + ['aksrt-script'], + (string) filemtime($page_template_path), + true + ); + } + } + + // Post/resource share functionality + if ($is_single_post || $is_single_resource) { + $weixin_share_path = get_template_directory() . '/assets/js/weixin-share.js'; + if (file_exists($weixin_share_path)) { + wp_enqueue_script( + 'aksrt-weixin-share', + $base_uri . '/assets/js/weixin-share.js', + ['aksrt-script'], + (string) filemtime($weixin_share_path), + true + ); + } + + $post_share_path = get_template_directory() . '/assets/js/post-share.js'; + if (file_exists($post_share_path)) { + wp_enqueue_script( + 'aksrt-post-share', + $base_uri . '/assets/js/post-share.js', + ['aksrt-script', 'aksrt-weixin-share', 'aksrt-share-data'], + (string) filemtime($post_share_path), + true + ); + } + } + + // Page navigation (TOC highlight, smooth scroll, doc search) + // 评论回复脚本 + if (is_singular() && comments_open() && get_option('thread_comments')) { + wp_enqueue_script('comment-reply'); + } + + $runtime_routes = $this->resolve_frontend_routes(); + + // 数据注入 - 全站统一消费 window.aksrtData + // Attach to aksrt-runtime (loaded before all core modules) so ajaxUrl/nonce + // exist before pagination/search/article handlers run. + $aksrt_data_handle = wp_script_is('aksrt-runtime', 'registered') || wp_script_is('aksrt-runtime', 'enqueued') + ? 'aksrt-runtime' + : 'aksrt-script'; + wp_localize_script($aksrt_data_handle, 'aksrtData', [ + 'ajaxUrl' => admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce('aksrt_public_nonce'), + 'restNonce' => wp_create_nonce('wp_rest'), + 'visibilityNonce' => wp_create_nonce('aksrt_visibility'), + 'siteUrl' => home_url('/'), + 'themeUrl' => AKSRT_URI, + 'themeMode' => class_exists('AKSRT_Settings') ? AKSRT_Settings::theme_mode_js_value() : 'auto', + 'debug' => defined('WP_DEBUG') && WP_DEBUG, + 'debugLog' => defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG, + 'postPaginationMode' => class_exists('AKSRT_Settings') ? AKSRT_Settings::get_post_pagination_mode() : 'load_more', + 'commentPaginationMode' => class_exists('AKSRT_Settings') ? AKSRT_Settings::get_comment_pagination_mode() : 'numbered', + 'homepagePostsPerPage' => function_exists('aksrt_homepage_get_tab_posts_per_page') ? aksrt_homepage_get_tab_posts_per_page() : (int) get_option('posts_per_page'), + 'homepageDefaultSort' => class_exists('AKSRT_Settings') ? AKSRT_Settings::homepage_default_sort() : 'modified', + 'homepageDefaultView' => class_exists('AKSRT_Settings') ? AKSRT_Settings::homepage_default_view() : 'list', + 'homepageSortAjax' => class_exists('AKSRT_Settings') ? AKSRT_Settings::homepage_sort_ajax_enabled() : true, + 'homepageStickyTabs' => class_exists('AKSRT_Settings') ? AKSRT_Settings::homepage_sticky_tabs_enabled() : true, + 'homePostsListEnabled' => !class_exists('AKSRT_Settings') || AKSRT_Settings::home_posts_list_enabled(), + 'homeListSortEnabled' => !class_exists('AKSRT_Settings') || AKSRT_Settings::home_list_sort_enabled(), + 'homeListSortDropdown' => class_exists('AKSRT_Settings') && AKSRT_Settings::home_list_sort_dropdown(), + // AJAX 翻页文案 + 'ajaxTrigger' => (class_exists('AKSRT_Settings') && ($t = AKSRT_Settings::get_post_list_style_settings()['ajax_trigger_text'] ?? '') !== '') ? $t : __('加载更多', 'aksrt'), + 'ajaxNomore' => (class_exists('AKSRT_Settings') && ($n = AKSRT_Settings::get_post_list_style_settings()['ajax_nomore_text'] ?? '') !== '') ? $n : __('没有更多了', 'aksrt'), + 'ajaxLoading' => __('正在加载...', 'aksrt'), + 'iasMax' => class_exists('AKSRT_Settings') ? AKSRT_Settings::get_post_list_style_settings()['ias_max'] : 3, + 'commentIasAuto' => class_exists('AKSRT_Settings') ? AKSRT_Settings::get_comment_display_settings()['paging_ajax_ias'] : true, + 'commentUploadEnabled' => class_exists('AKSRT_Settings') ? !empty((AKSRT_Settings::get_comment_display_settings()['toolbar']['upload_img'] ?? false)) : false, + 'commentUploadUrl' => esc_url_raw(rest_url('aksrt/v1/comment/upload-image')), + 'commentUploadMaxSize' => 2 * 1024 * 1024, + 'commentIasMax' => class_exists('AKSRT_Settings') ? AKSRT_Settings::get_comment_display_settings()['paging_ajax_ias_max'] : 3, + 'stickyEnabled' => class_exists('AKSRT_Settings') ? AKSRT_Settings::get_post_list_style_settings()['sticky_enabled'] : true, + 'stickyPages' => class_exists('AKSRT_Settings') ? AKSRT_Settings::get_post_list_style_settings()['sticky_pages'] : ['home', 'cat'], + 'badgeShow' => class_exists('AKSRT_Settings') ? AKSRT_Settings::get_post_list_style_settings()['badge_show'] : true, + 'badgeTypes' => class_exists('AKSRT_Settings') ? AKSRT_Settings::get_post_list_style_settings()['badge_types'] : ['pay', 'tag', 'topics', 'cat'], + 'search' => [ + 'mode' => class_exists('AKSRT_Settings') ? AKSRT_Settings::search_mode() : 'default', + 'placeholder' => function_exists('aksrt_get_search_placeholder') ? aksrt_get_search_placeholder() : __('开启精彩搜索...', 'aksrt'), + 'historyEnabled' => function_exists('aksrt_search_history_enabled') ? aksrt_search_history_enabled() : true, + 'suggestionsEnabled' => function_exists('aksrt_search_suggestions_enabled') ? aksrt_search_suggestions_enabled() : false, + 'hotKeywords' => function_exists('aksrt_get_search_hot_keywords') ? aksrt_get_search_hot_keywords() : [], + 'popularTitle' => class_exists('AKSRT_Settings') ? AKSRT_Settings::search_popular_title() : __('热门搜索', 'aksrt'), + 'contentTypes' => class_exists('AKSRT_Settings') ? AKSRT_Settings::get_search_content_types()['types'] : ['post', 'category', 'tag'], + 'defaultType' => class_exists('AKSRT_Settings') ? AKSRT_Settings::get_search_content_types()['default'] : 'post', + ], + // 用户状态 + 'isLoggedIn' => is_user_logged_in(), + 'userId' => get_current_user_id(), + 'cartUrl' => $runtime_routes['cart'], + 'ordersUrl' => $runtime_routes['orders'], + 'userCenterUrl' => function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/'), + 'routes' => [ + 'home' => home_url('/'), + 'login' => function_exists('aksrt_get_login_url') ? aksrt_get_login_url() : wp_login_url(), + 'register' => function_exists('aksrt_get_register_url') ? aksrt_get_register_url() : wp_registration_url(), + 'resetPassword' => function_exists('aksrt_get_reset_password_url') ? aksrt_get_reset_password_url() : wp_lostpassword_url(), + 'profile' => function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/profile/'), + 'cart' => $runtime_routes['cart'], + 'orders' => $runtime_routes['orders'], + 'resources' => $runtime_routes['resources'], + ], + 'shell' => [ + 'wrapperId' => '', + 'navId' => 'site-nav', + 'searchPanelId' => 'nav-search-panel', + 'drawerId' => 'nav-drawer', + 'userMenuId' => 'user-dropdown', + 'floatActionsId' => 'float-right-btns', + 'tabbarId' => 'footer-tabbar', + ], + 'themeUri' => get_template_directory_uri(), + 'codeHighlight' => class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_code_highlight_config() + : ['enabled' => true, 'lightTheme' => 'enlighter', 'darkTheme' => 'monokai', 'lineNumbers' => false, 'showButtons' => true, 'maxHeight' => 500], + 'lazyload' => class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_lazyload_config() + : ['enabled' => true, 'animation' => 'blur', 'placeholder_url' => '', 'blur' => true], + 'systemNotice' => class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_system_notice_config() + : ['enabled' => false], + 'contentProtect' => [ + 'enabled' => class_exists('AKSRT_Settings') && (bool) AKSRT_Settings::get_value('content_protect_enabled', false), + 'copyNotice' => class_exists('AKSRT_Settings') && (bool) AKSRT_Settings::get_value('content_protect_copy_notice', false), + 'copyNoticeText' => class_exists('AKSRT_Settings') ? (string) AKSRT_Settings::get_value('content_protect_copy_text', '来自本站,请尊重原创版权。') : '', + 'disableRightClick'=> class_exists('AKSRT_Settings') && (bool) AKSRT_Settings::get_value('content_protect_right_click', false), + 'detectDevTools' => class_exists('AKSRT_Settings') && (bool) AKSRT_Settings::get_value('content_protect_f12', false), + ], + 'translate' => class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_i18n_config() + : ['enabled' => false, 'autoDetect' => true, 'defaultLang' => 'zh-CN'], + ]); + } + +} diff --git a/inc/classes/credits/balance-service-traits/trait-aksrt-balance-service-charge.php b/inc/classes/credits/balance-service-traits/trait-aksrt-balance-service-charge.php new file mode 100644 index 00000000..afbf8d20 --- /dev/null +++ b/inc/classes/credits/balance-service-traits/trait-aksrt-balance-service-charge.php @@ -0,0 +1,354 @@ + $order Order row. + */ + public function on_order_paid(string $order_number, array $order = []): void { + if ($order === [] && class_exists('AKSRT_Order_Repository')) { + $order = AKSRT_Order_Repository::get_instance()->get($order_number) ?: []; + } + if (!$order) { + return; + } + $type = sanitize_key((string) ($order['product_type'] ?? '')); + $user_id = (int) ($order['user_id'] ?? 0); + if ($user_id <= 0) { + return; + } + + // Points purchase fulfill (zibll order_type=9). + if ($type === AKSRT_Domain_Model::ORDER_PRODUCT_POINTS_BUY) { + if (!class_exists('AKSRT_Credits')) { + return; + } + $note = json_decode((string) ($order['note'] ?? ''), true); + if (!is_array($note)) { + $note = []; + } + if (!empty($note['points_credited'])) { + return; + } + $rate = 100; + if (class_exists('AKSRT_Settings')) { + $rate = max(1, (int) (AKSRT_Settings::get_all()['points_rate'] ?? 100)); + } + $points = isset($note['points_amount']) + ? (int) $note['points_amount'] + : (int) round((float) ($order['amount'] ?? 0) * $rate); + if ($points <= 0) { + return; + } + $add = AKSRT_Credits::add( + $user_id, + $points, + 'points_buy', + ['type' => 'order', 'order' => $order_number], + sprintf(__('购买积分订单 %s', 'aksrt'), $order_number) + ); + if (is_wp_error($add)) { + return; + } + $note['points_credited'] = 1; + $note['credited_points'] = $points; + global $wpdb; + $wpdb->update( + $wpdb->prefix . 'aksrt_order', + ['note' => wp_json_encode($note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)], + ['order_number' => $order_number], + ['%s'], + ['%s'] + ); + return; + } + + if ($type !== AKSRT_Domain_Model::ORDER_PRODUCT_RECHARGE) { + return; + } + if (!class_exists('AKSRT_Balance')) { + return; + } + $note = json_decode((string) ($order['note'] ?? ''), true); + if (!is_array($note)) { + $note = []; + } + if (!empty($note['balance_credited'])) { + return; + } + $credit_amount = isset($note['credit_amount']) + ? round((float) $note['credit_amount'], 2) + : round((float) ($order['amount'] ?? 0), 2); + if ($credit_amount <= 0) { + return; + } + $result = AKSRT_Balance::add( + $user_id, + $credit_amount, + 'recharge', + ['type' => 'order', 'order' => $order_number], + sprintf(__('余额充值订单 %s', 'aksrt'), $order_number) + ); + if (is_wp_error($result)) { + return; + } + $note['balance_credited'] = 1; + $note['credited_amount'] = $credit_amount; + global $wpdb; + $wpdb->update( + $wpdb->prefix . 'aksrt_order', + ['note' => wp_json_encode($note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)], + ['order_number' => $order_number], + ['%s'], + ['%s'] + ); + } + + + + /** + * @return array + */ + public function get_charge_view(int $user_id): array { + $cfg = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_payment_recharge_settings() : []; + $packages = $this->parse_packages((string) ($cfg['packages_raw'] ?? '')); + $custom_s = !isset($cfg['product']['custom_s']) || !empty($cfg['product']['custom_s']); + $custom_limit = max(1, (int) ($cfg['product']['custom_limit'] ?? 9999)); + $methods = []; + if (function_exists('aksrt_list_cash_payment_methods')) { + // Exclude balance gateway for recharge (cannot pay wallet with wallet). + foreach (aksrt_list_cash_payment_methods(true) as $m) { + $id = is_array($m) ? (string) ($m['id'] ?? '') : (string) $m; + if ($id === '' || $id === 'balance') { + continue; + } + $methods[] = is_array($m) ? $m : ['id' => $id, 'name' => $id]; + } + } + return [ + 'enabled' => !empty($cfg['enabled']), + 'desc' => (string) ($cfg['desc'] ?? ''), + 'wallet' => class_exists('AKSRT_Balance') ? AKSRT_Balance::balance($user_id) : 0.0, + 'packages' => $packages, + 'custom_s' => $custom_s, + 'custom_limit' => $custom_limit, + 'currency_mark' => (string) ($cfg['product']['tag'] ?? '元'), + 'payment_methods' => $methods, + 'transfer' => [ + 'enabled' => !empty($cfg['transfer']['enabled']), + 'service_charge' => (int) ($cfg['transfer']['service_charge'] ?? 0), + 'desc' => (string) ($cfg['transfer']['desc'] ?? ''), + ], + 'exchange' => [ + 'enabled' => !empty($cfg['exchange']['enabled']), + 'rate' => (int) ($cfg['exchange']['rate'] ?? 100), + 'desc' => (string) ($cfg['exchange']['desc'] ?? ''), + ], + 'pass_charge' => is_array($cfg['pass_charge'] ?? null) ? $cfg['pass_charge'] : [], + ]; + } + + + + /** + * @return array + */ + public function parse_packages(string $raw): array { + $out = []; + $lines = preg_split('/\r\n|\r|\n/', $raw) ?: []; + $i = 0; + foreach ($lines as $line) { + $line = trim((string) $line); + if ($line === '') { + continue; + } + // pay|credit|title OR credit|pay|title — Zibll uses price(credit)|pay_price|tag + // Admin help: 支付金额|到账金额|标题 + $parts = array_map('trim', explode('|', $line)); + if (count($parts) < 2) { + continue; + } + $pay = (float) $parts[0]; + $credit = (float) $parts[1]; + $title = $parts[2] ?? ''; + if ($pay <= 0 || $credit <= 0) { + continue; + } + $out[] = [ + 'key' => (string) $i, + 'pay_price' => round($pay, 2), + 'price' => round($credit, 2), + 'title' => $title !== '' ? $title : sprintf(__('充值 %s 元', 'aksrt'), (string) $credit), + 'tag' => $title, + ]; + $i++; + } + return $out; + } + + + + /** + * @return array|WP_Error + */ + public function create_charge_order(int $user_id, string $package_key, float $custom_price, string $payment_method) { + if ($user_id <= 0) { + return new WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]); + } + if (!class_exists('AKSRT_Order_Repository') || !class_exists('AKSRT_Order')) { + return new WP_Error('aksrt_pay_unavailable', __('订单系统不可用', 'aksrt'), ['status' => 500]); + } + $view = $this->get_charge_view($user_id); + $credit_amount = 0.0; + $pay_amount = 0.0; + $title = __('余额充值', 'aksrt'); + + if ($package_key === 'custom' || $package_key === '') { + if (empty($view['custom_s'])) { + return new WP_Error('aksrt_custom_closed', __('未开放自定义金额', 'aksrt'), ['status' => 400]); + } + $custom_price = round($custom_price, 2); + $min = 1.0; + $max = (float) $view['custom_limit']; + if ($custom_price < $min || $custom_price > $max) { + return new WP_Error('aksrt_custom_limit', sprintf(__('充值金额需在 %s ~ %s 之间', 'aksrt'), (string) $min, (string) $max), ['status' => 400]); + } + $pay_amount = $custom_price; + $credit_amount = $custom_price; + $title = sprintf(__('自定义充值 %s 元', 'aksrt'), (string) $custom_price); + $package_key = 'custom'; + } else { + $found = null; + foreach ((array) $view['packages'] as $pkg) { + if ((string) ($pkg['key'] ?? '') === (string) $package_key) { + $found = $pkg; + break; + } + } + if (!$found) { + return new WP_Error('aksrt_invalid_package', __('充值套餐无效', 'aksrt'), ['status' => 400]); + } + $pay_amount = (float) $found['pay_price']; + $credit_amount = (float) $found['price']; + $title = (string) $found['title']; + } + + if ($pay_amount <= 0 || $credit_amount <= 0) { + return new WP_Error('aksrt_invalid_price', __('金额无效', 'aksrt'), ['status' => 400]); + } + + $payment_method = sanitize_key($payment_method); + if ($payment_method === '' || $payment_method === 'balance') { + // Prefer first non-balance method. + $methods = $view['payment_methods'] ?? []; + $payment_method = !empty($methods[0]['id']) ? (string) $methods[0]['id'] : 'alipay'; + } + + $note = wp_json_encode([ + 'balance_recharge' => 1, + 'package' => $package_key, + 'credit_amount' => $credit_amount, + 'pay_amount' => $pay_amount, + 'title' => $title, + ], 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_RECHARGE, + 'product_id' => 0, + 'amount' => $pay_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'] = $title; + $order['payment_method'] = $payment_method; + + if (!class_exists('AKSRT_Payment_Gateway')) { + return new WP_Error('aksrt_pay_unavailable', __('支付网关不可用', 'aksrt'), ['status' => 500]); + } + $gateway = AKSRT_Payment_Gateway::get_instance()->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'])) { + return new WP_Error( + 'aksrt_pay_failed', + (string) ($payment_result['message'] ?? __('支付失败', 'aksrt')), + ['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 [ + 'redirect' => true, + 'payment_url' => $payment_url, + 'qr_content' => $code_url, + 'order_number' => $order_number, + 'amount' => $pay_amount, + 'credit_amount' => $credit_amount, + 'message' => $payment_result['message'] ?? __('请在新页面完成支付。', 'aksrt'), + 'reload' => false, + 'poll' => true, + ]; + } + + // Synchronous gateway (rare for recharge): mark paid → on_order_paid credits wallet. + $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]); + } + // Ensure credit even if status hook shape differs. + $this->on_order_paid($order_number, AKSRT_Order_Repository::get_instance()->get($order_number) ?: $order); + + return [ + 'redirect' => false, + 'order_number' => $order_number, + 'amount' => $pay_amount, + 'credit_amount' => $credit_amount, + 'wallet' => AKSRT_Balance::balance($user_id), + 'message' => __('充值成功', 'aksrt'), + 'reload' => true, + ]; + } + +} diff --git a/inc/classes/credits/balance-service-traits/trait-aksrt-balance-service-points.php b/inc/classes/credits/balance-service-traits/trait-aksrt-balance-service-points.php new file mode 100644 index 00000000..03e284a7 --- /dev/null +++ b/inc/classes/credits/balance-service-traits/trait-aksrt-balance-service-points.php @@ -0,0 +1,244 @@ + + */ + public function get_points_buy_view(int $user_id): array { + $all = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_all() : []; + $rate = max(1, (int) ($all['points_rate'] ?? 100)); + $packages_raw = (string) ($all['pay_points_product'] ?? ''); + $packages = $this->parse_points_packages($packages_raw); + $custom_s = !isset($all['pay_points_product_custom_s']) || !empty($all['pay_points_product_custom_s']); + $custom_limit = max(1, (int) ($all['pay_points_product_custom_limit'] ?? 99999)); + $methods = []; + if (function_exists('aksrt_list_cash_payment_methods')) { + foreach (aksrt_list_cash_payment_methods(true) as $m) { + $id = is_array($m) ? (string) ($m['id'] ?? '') : (string) $m; + if ($id === '') { + continue; + } + if ($id === 'balance' && !(class_exists('AKSRT_Balance') && AKSRT_Balance::enabled())) { + continue; + } + $methods[] = is_array($m) ? $m : ['id' => $id, 'name' => $id]; + } + } + if (class_exists('AKSRT_Balance') && AKSRT_Balance::enabled()) { + $has_bal = false; + foreach ($methods as $m) { + if (($m['id'] ?? '') === 'balance') { + $has_bal = true; + break; + } + } + if (!$has_bal) { + array_unshift($methods, ['id' => 'balance', 'name' => __('站内余额', 'aksrt')]); + } + } + return [ + 'enabled' => $this->points_buy_enabled(), + 'desc' => (string) ($all['pay_points_desc'] ?? ''), + 'rate' => $rate, + 'points' => class_exists('AKSRT_Credits') ? AKSRT_Credits::balance($user_id) : 0, + 'packages' => $packages, + 'custom_s' => $custom_s, + 'custom_limit' => $custom_limit, + 'payment_methods' => $methods, + ]; + } + + + + /** + * @return array + */ + public function parse_points_packages(string $raw): array { + $out = []; + $lines = preg_split('/\r\n|\r|\n/', $raw) ?: []; + $i = 0; + foreach ($lines as $line) { + $line = trim((string) $line); + if ($line === '') { + continue; + } + // 支付金额|积分数量|标题 + $parts = array_map('trim', explode('|', $line)); + if (count($parts) < 2) { + continue; + } + $pay = (float) $parts[0]; + $points = (int) $parts[1]; + $title = $parts[2] ?? ''; + if ($pay <= 0 || $points <= 0) { + continue; + } + $out[] = [ + 'key' => (string) $i, + 'pay_price' => round($pay, 2), + 'points' => $points, + 'title' => $title !== '' ? $title : sprintf(__('%d 积分', 'aksrt'), $points), + ]; + $i++; + } + return $out; + } + + + + /** + * @return array|WP_Error + */ + public function create_points_buy_order(int $user_id, string $package_key, int $custom_points, string $payment_method) { + if ($user_id <= 0) { + return new WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]); + } + if (!class_exists('AKSRT_Order_Repository') || !class_exists('AKSRT_Order')) { + return new WP_Error('aksrt_pay_unavailable', __('订单系统不可用', 'aksrt'), ['status' => 500]); + } + $view = $this->get_points_buy_view($user_id); + $rate = max(1, (int) ($view['rate'] ?? 100)); + $points_amount = 0; + $pay_amount = 0.0; + $title = __('购买积分', 'aksrt'); + + if ($package_key === 'custom' || $package_key === '') { + if (empty($view['custom_s'])) { + return new WP_Error('aksrt_custom_closed', __('未开放自定义数量', 'aksrt'), ['status' => 400]); + } + $custom_points = max(0, $custom_points); + $max = (int) ($view['custom_limit'] ?? 99999); + if ($custom_points < $rate || $custom_points > $max) { + return new WP_Error('aksrt_custom_limit', sprintf(__('购买积分需在 %d ~ %d 之间', 'aksrt'), $rate, $max), ['status' => 400]); + } + $points_amount = $custom_points; + $pay_amount = round($custom_points / $rate, 2); + if ($pay_amount <= 0) { + $pay_amount = 0.01; + } + $title = sprintf(__('购买 %d 积分', 'aksrt'), $points_amount); + $package_key = 'custom'; + } else { + $found = null; + foreach ((array) $view['packages'] as $pkg) { + if ((string) ($pkg['key'] ?? '') === (string) $package_key) { + $found = $pkg; + break; + } + } + if (!$found) { + return new WP_Error('aksrt_invalid_package', __('积分套餐无效', 'aksrt'), ['status' => 400]); + } + $points_amount = (int) $found['points']; + $pay_amount = (float) $found['pay_price']; + $title = (string) $found['title']; + } + if ($points_amount <= 0 || $pay_amount <= 0) { + return new WP_Error('aksrt_invalid_price', __('金额无效', 'aksrt'), ['status' => 400]); + } + + $payment_method = sanitize_key($payment_method); + if ($payment_method === '') { + $methods = $view['payment_methods'] ?? []; + $payment_method = !empty($methods[0]['id']) ? (string) $methods[0]['id'] : 'alipay'; + } + + $note = wp_json_encode([ + 'points_buy' => 1, + 'package' => $package_key, + 'points_amount' => $points_amount, + 'pay_amount' => $pay_amount, + 'title' => $title, + ], 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_POINTS_BUY, + 'product_id' => 0, + 'amount' => $pay_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'] = $title; + $order['payment_method'] = $payment_method; + + if (!class_exists('AKSRT_Payment_Gateway')) { + return new WP_Error('aksrt_pay_unavailable', __('支付网关不可用', 'aksrt'), ['status' => 500]); + } + $gateway = AKSRT_Payment_Gateway::get_instance()->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'])) { + return new WP_Error('aksrt_pay_failed', (string) ($payment_result['message'] ?? __('支付失败', 'aksrt')), ['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 [ + 'redirect' => true, + 'payment_url' => $payment_url, + 'qr_content' => $code_url, + 'order_number' => $order_number, + 'amount' => $pay_amount, + 'points_amount' => $points_amount, + '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->on_order_paid($order_number, AKSRT_Order_Repository::get_instance()->get($order_number) ?: $order); + return [ + 'redirect' => false, + 'order_number' => $order_number, + 'amount' => $pay_amount, + 'points_amount' => $points_amount, + 'points' => AKSRT_Credits::balance($user_id), + 'message' => __('购买成功', 'aksrt'), + 'reload' => true, + ]; + } + +} diff --git a/inc/classes/credits/balance-service-traits/trait-aksrt-balance-service-rest.php b/inc/classes/credits/balance-service-traits/trait-aksrt-balance-service-rest.php new file mode 100644 index 00000000..066538c5 --- /dev/null +++ b/inc/classes/credits/balance-service-traits/trait-aksrt-balance-service-rest.php @@ -0,0 +1,449 @@ + WP_REST_Server::READABLE, + 'callback' => [$this, 'rest_charge_options'], + 'permission_callback' => static function () { + return is_user_logged_in(); + }, + ], + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'rest_charge_create'], + 'permission_callback' => static function () { + return is_user_logged_in(); + }, + ], + ]); + + register_rest_route('aksrt/v1', '/pay/transfer', [ + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'rest_transfer'], + 'permission_callback' => static function () { + return is_user_logged_in(); + }, + ], + ]); + + register_rest_route('aksrt/v1', '/pay/transfer/search', [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [$this, 'rest_transfer_search'], + 'permission_callback' => static function () { + return is_user_logged_in(); + }, + ], + ]); + + register_rest_route('aksrt/v1', '/pay/balance/exchange', [ + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'rest_exchange_from_credits'], + 'permission_callback' => static function () { + return is_user_logged_in(); + }, + ], + ]); + + register_rest_route('aksrt/v1', '/pay/points/buy', [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [$this, 'rest_points_buy_options'], + 'permission_callback' => static function () { + return is_user_logged_in(); + }, + ], + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'rest_points_buy_create'], + 'permission_callback' => static function () { + return is_user_logged_in(); + }, + ], + ]); + + register_rest_route('aksrt/v1', '/pay/balance/withdraw', [ + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'rest_balance_withdraw'], + 'permission_callback' => static function () { + return is_user_logged_in(); + }, + ], + ]); + } + + + + /** + * GET charge options (packages + methods + limits). + */ + public function rest_charge_options(WP_REST_Request $request) { + if (!class_exists('AKSRT_Balance') || !AKSRT_Balance::enabled()) { + return new WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]); + } + $user_id = get_current_user_id(); + return rest_ensure_response([ + 'success' => true, + 'data' => $this->get_charge_view($user_id), + ]); + } + + + + /** + * POST create recharge order and initiate payment. + */ + public function rest_charge_create(WP_REST_Request $request) { + if (!class_exists('AKSRT_Balance') || !AKSRT_Balance::enabled()) { + return new WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]); + } + $user_id = get_current_user_id(); + $params = $request->get_json_params(); + if (!is_array($params)) { + $params = $request->get_params(); + } + $package_key = sanitize_text_field((string) ($params['package'] ?? 'custom')); + $custom_price = isset($params['custom_price']) ? (float) $params['custom_price'] : 0.0; + $payment_method = sanitize_key((string) ($params['payment_method'] ?? 'alipay')); + + $result = $this->create_charge_order($user_id, $package_key, $custom_price, $payment_method); + if (is_wp_error($result)) { + return $result; + } + return rest_ensure_response(['success' => true, 'data' => $result]); + } + + + + /** + * Transfer balance or points between users. + */ + public function rest_transfer(WP_REST_Request $request) { + $params = $request->get_json_params(); + if (!is_array($params)) { + $params = $request->get_params(); + } + $type = sanitize_key((string) ($params['type'] ?? 'balance')); + $recipient = (int) ($params['recipient'] ?? 0); + $amount_raw = $params['amount'] ?? $params['price'] ?? 0; + $from_id = get_current_user_id(); + + if (!in_array($type, ['balance', 'points'], true)) { + return new WP_Error('aksrt_transfer_type', __('转账类型无效', 'aksrt'), ['status' => 400]); + } + if ($from_id <= 0) { + return new WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]); + } + if ($recipient <= 0 || $recipient === $from_id) { + return new WP_Error('aksrt_transfer_user', __('请选择有效的收款用户', 'aksrt'), ['status' => 400]); + } + if (!get_userdata($recipient)) { + return new WP_Error('aksrt_transfer_user', __('收款用户不存在', 'aksrt'), ['status' => 404]); + } + + $cfg = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_payment_recharge_settings() : []; + if ($type === 'balance') { + if (empty($cfg['enabled']) || empty($cfg['transfer']['enabled'])) { + return new WP_Error('aksrt_transfer_closed', __('余额转账未开启', 'aksrt'), ['status' => 403]); + } + if (class_exists('AKSRT_Settings_Capabilities') && !AKSRT_Settings_Capabilities::user_can('balance_transfer', $from_id)) { + return new WP_Error('aksrt_transfer_forbidden', __('当前账号无权转账余额', 'aksrt'), ['status' => 403]); + } + $amount = round((float) $amount_raw, 2); + if ($amount <= 0) { + return new WP_Error('aksrt_transfer_amount', __('请输入有效金额', 'aksrt'), ['status' => 400]); + } + if (!class_exists('AKSRT_Balance')) { + return new WP_Error('aksrt_balance_unavailable', __('余额系统不可用', 'aksrt'), ['status' => 500]); + } + $from_bal = AKSRT_Balance::balance($from_id); + if ($from_bal < $amount) { + return new WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), ['status' => 402]); + } + $fee_rate = max(0, min(50, (int) ($cfg['transfer']['service_charge'] ?? 0))); + $credit = $fee_rate > 0 ? round($amount * (100 - $fee_rate) / 100, 2) : $amount; + + $from_user = wp_get_current_user(); + $to_user = get_userdata($recipient); + $deduct = AKSRT_Balance::deduct( + $from_id, + $amount, + 'transfer_out', + ['type' => 'user', 'id' => $recipient], + sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient) + ); + if (is_wp_error($deduct)) { + return $deduct; + } + AKSRT_Balance::add( + $recipient, + $credit, + 'transfer_in', + ['type' => 'user', 'id' => $from_id], + sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id) + ); + return rest_ensure_response([ + 'success' => true, + 'data' => [ + 'message' => __('转账成功', 'aksrt'), + 'reload' => true, + 'balance' => AKSRT_Balance::balance($from_id), + ], + ]); + } + + // points transfer + $points_transfer_on = !empty(AKSRT_Settings::get_all()['points_transfer_s']); + if (!class_exists('AKSRT_Settings') || !AKSRT_Settings::credits_enabled() || !$points_transfer_on) { + return new WP_Error('aksrt_transfer_closed', __('积分转账未开启', 'aksrt'), ['status' => 403]); + } + if (class_exists('AKSRT_Settings_Capabilities') && !AKSRT_Settings_Capabilities::user_can('points_transfer', $from_id)) { + return new WP_Error('aksrt_transfer_forbidden', __('当前账号无权转账积分', 'aksrt'), ['status' => 403]); + } + $amount = (int) $amount_raw; + if ($amount <= 0) { + return new WP_Error('aksrt_transfer_amount', __('请输入有效积分', 'aksrt'), ['status' => 400]); + } + if (!class_exists('AKSRT_Credits')) { + return new WP_Error('aksrt_credits_unavailable', __('积分系统不可用', 'aksrt'), ['status' => 500]); + } + $fee_rate = max(0, min(50, (int) (AKSRT_Settings::get_all()['points_service_charge'] ?? 0))); + $credit = $fee_rate > 0 ? (int) floor($amount * (100 - $fee_rate) / 100) : $amount; + $from_user = wp_get_current_user(); + $to_user = get_userdata($recipient); + $deduct = AKSRT_Credits::deduct( + $from_id, + $amount, + 'transfer_out', + ['type' => 'user', 'id' => $recipient], + sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient) + ); + if (is_wp_error($deduct)) { + return $deduct; + } + AKSRT_Credits::add( + $recipient, + $credit, + 'transfer_in', + ['type' => 'user', 'id' => $from_id], + sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id) + ); + return rest_ensure_response([ + 'success' => true, + 'data' => [ + 'message' => __('转账成功', 'aksrt'), + 'reload' => true, + 'balance' => AKSRT_Credits::balance($from_id), + ], + ]); + } + + + + public function rest_transfer_search(WP_REST_Request $request) { + $s = sanitize_text_field((string) $request->get_param('s')); + if (mb_strlen($s) < 1) { + return rest_ensure_response(['success' => true, 'data' => ['users' => []]]); + } + $exclude = [get_current_user_id()]; + $q = new WP_User_Query([ + 'search' => '*' . $s . '*', + 'search_columns' => ['user_login', 'user_nicename', 'display_name', 'user_email'], + 'exclude' => $exclude, + 'number' => 20, + 'fields' => ['ID', 'display_name', 'user_login'], + ]); + $users = []; + foreach ((array) $q->get_results() as $u) { + $users[] = [ + 'id' => (int) $u->ID, + 'display_name' => (string) $u->display_name, + 'user_login' => (string) $u->user_login, + 'avatar' => get_avatar_url((int) $u->ID, ['size' => 48]), + ]; + } + return rest_ensure_response(['success' => true, 'data' => ['users' => $users]]); + } + + + + /** + * Exchange points → cash wallet (pay_balance_exchange_*). + */ + public function rest_exchange_from_credits(WP_REST_Request $request) { + if (!class_exists('AKSRT_Balance') || !AKSRT_Balance::enabled()) { + return new WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]); + } + $cfg = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_payment_recharge_settings() : []; + if (empty($cfg['exchange']['enabled'])) { + return new WP_Error('aksrt_exchange_closed', __('积分兑换余额未开启', 'aksrt'), ['status' => 403]); + } + if (!class_exists('AKSRT_Credits') || !AKSRT_Settings::credits_enabled()) { + return new WP_Error('aksrt_credits_disabled', __('积分功能未启用', 'aksrt'), ['status' => 403]); + } + $params = $request->get_json_params(); + if (!is_array($params)) { + $params = $request->get_params(); + } + $points = (int) ($params['points'] ?? 0); + $rate = max(1, (int) ($cfg['exchange']['rate'] ?? 100)); // N points = 1 yuan + if ($points < $rate) { + return new WP_Error('aksrt_exchange_min', sprintf(__('最少兑换 %d 积分', 'aksrt'), $rate), ['status' => 400]); + } + // Floor to whole yuan. + $yuan = (int) floor($points / $rate); + $cost = $yuan * $rate; + if ($yuan <= 0 || $cost <= 0) { + return new WP_Error('aksrt_exchange_amount', __('兑换数量无效', 'aksrt'), ['status' => 400]); + } + $user_id = get_current_user_id(); + $deduct = AKSRT_Credits::deduct( + $user_id, + $cost, + 'exchange_to_balance', + ['type' => 'balance'], + sprintf(__('积分兑换余额 %d 元', 'aksrt'), $yuan) + ); + if (is_wp_error($deduct)) { + return $deduct; + } + $add = AKSRT_Balance::add( + $user_id, + (float) $yuan, + 'exchange_from_credits', + ['type' => 'credits'], + sprintf(__('积分兑换:消耗 %d 积分', 'aksrt'), $cost) + ); + if (is_wp_error($add)) { + // best-effort refund points + AKSRT_Credits::add($user_id, $cost, 'exchange_refund', [], __('兑换失败退回积分', 'aksrt')); + return $add; + } + return rest_ensure_response([ + 'success' => true, + 'data' => [ + 'message' => sprintf(__('兑换成功:+%d 元', 'aksrt'), $yuan), + 'wallet_balance' => AKSRT_Balance::balance($user_id), + 'credit_balance' => AKSRT_Credits::balance($user_id), + 'reload' => true, + ], + ]); + } + + + + public function rest_points_buy_options(WP_REST_Request $request) { + if (!$this->points_buy_enabled()) { + return new WP_Error('aksrt_points_buy_disabled', __('购买积分未开启', 'aksrt'), ['status' => 403]); + } + return rest_ensure_response([ + 'success' => true, + 'data' => $this->get_points_buy_view(get_current_user_id()), + ]); + } + + + + public function rest_points_buy_create(WP_REST_Request $request) { + if (!$this->points_buy_enabled()) { + return new WP_Error('aksrt_points_buy_disabled', __('购买积分未开启', 'aksrt'), ['status' => 403]); + } + $params = $request->get_json_params(); + if (!is_array($params)) { + $params = $request->get_params(); + } + $package_key = sanitize_text_field((string) ($params['package'] ?? 'custom')); + $custom_points = (int) ($params['custom_points'] ?? $params['points'] ?? 0); + $payment_method = sanitize_key((string) ($params['payment_method'] ?? 'alipay')); + $result = $this->create_points_buy_order(get_current_user_id(), $package_key, $custom_points, $payment_method); + if (is_wp_error($result)) { + return $result; + } + return rest_ensure_response(['success' => true, 'data' => $result]); + } + + + + public function rest_balance_withdraw(WP_REST_Request $request) { + if (!class_exists('AKSRT_Balance') || !AKSRT_Balance::enabled()) { + return new WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]); + } + $settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_all() : []; + if (empty($settings['pay_balance_withdraw_s'])) { + return new WP_Error('aksrt_withdraw_closed', __('余额提现未开启', 'aksrt'), ['status' => 403]); + } + if (!class_exists('AKSRT_Withdrawal')) { + return new WP_Error('aksrt_withdraw_unavailable', __('提现系统不可用', 'aksrt'), ['status' => 500]); + } + $params = $request->get_json_params(); + if (!is_array($params)) { + $params = $request->get_params(); + } + $user_id = get_current_user_id(); + if (class_exists('AKSRT_Settings_Capabilities') && !AKSRT_Settings_Capabilities::user_can('balance_withdraw', $user_id)) { + return new WP_Error('aksrt_withdraw_forbidden', __('当前账号无权提现', 'aksrt'), ['status' => 403]); + } + $amount = round((float) ($params['amount'] ?? 0), 2); + $method = sanitize_key((string) ($params['method'] ?? 'alipay')); + $account = sanitize_textarea_field((string) ($params['account_info'] ?? $params['account'] ?? '')); + $lowest = max(1, (float) ($settings['pay_balance_withdraw_lowest_money'] ?? $settings['pay_rebate_withdraw_lowest_money'] ?? 50)); + if ($amount < $lowest) { + return new WP_Error('aksrt_withdraw_min', sprintf(__('最低提现 %s 元', 'aksrt'), (string) $lowest), ['status' => 400]); + } + if ($account === '') { + return new WP_Error('aksrt_withdraw_account', __('请填写收款账号信息', 'aksrt'), ['status' => 400]); + } + // One open balance withdrawal at a time (Zibll parity). + if (AKSRT_Withdrawal::get_instance()->has_open_withdrawal($user_id)) { + return new WP_Error('aksrt_withdraw_pending', __('您有处理中的提现申请,请等待完成后再申请', 'aksrt'), ['status' => 409]); + } + $wallet = AKSRT_Balance::balance($user_id); + if ($wallet < $amount) { + return new WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), ['status' => 402]); + } + $deduct = AKSRT_Balance::deduct($user_id, $amount, 'withdraw_hold', ['type' => 'withdraw'], __('余额提现申请冻结', 'aksrt')); + if (is_wp_error($deduct)) { + return $deduct; + } + $fee_rate = max(0, min(50, (int) ($settings['withdraw_service_charge'] ?? 0))); + $net = $fee_rate > 0 ? round($amount * (100 - $fee_rate) / 100, 2) : $amount; + $account_payload = wp_json_encode([ + 'account' => $account, + 'gross_amount' => $amount, + 'fee_rate' => $fee_rate, + 'source' => 'balance', + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $account; + $wid = AKSRT_Withdrawal::get_instance()->create($user_id, $net, $method !== '' ? $method : 'alipay', $account_payload); + if ($wid <= 0) { + AKSRT_Balance::add($user_id, $amount, 'withdraw_refund', [], __('提现申请失败退回', 'aksrt')); + return new WP_Error('aksrt_withdraw_failed', __('提现申请失败', 'aksrt'), ['status' => 500]); + } + return rest_ensure_response([ + 'success' => true, + 'data' => [ + 'message' => __('提现申请已提交,请等待处理', 'aksrt'), + 'withdrawal_id' => $wid, + 'amount' => $amount, + 'net_amount' => $net, + 'wallet_balance' => AKSRT_Balance::balance($user_id), + 'reload' => true, + ], + ]); + } + +} diff --git a/inc/classes/credits/class-aksrt-balance-service.php b/inc/classes/credits/class-aksrt-balance-service.php index db5ed8e3..0c4eacea 100644 --- a/inc/classes/credits/class-aksrt-balance-service.php +++ b/inc/classes/credits/class-aksrt-balance-service.php @@ -13,9 +13,14 @@ defined('ABSPATH') || exit; class AKSRT_Balance_Service { + use AKSRT_Balance_Service_Rest; + use AKSRT_Balance_Service_Charge; + use AKSRT_Balance_Service_Points; private static $instance = null; + + public static function get_instance(): self { if (null === self::$instance) { self::$instance = new self(); @@ -23,986 +28,11 @@ class AKSRT_Balance_Service { return self::$instance; } + + public function init(): void { add_action('rest_api_init', [$this, 'register_routes']); add_action('aksrt_order_paid', [$this, 'on_order_paid'], 20, 2); } - public function register_routes(): void { - register_rest_route('aksrt/v1', '/pay/balance/charge', [ - [ - 'methods' => WP_REST_Server::READABLE, - 'callback' => [$this, 'rest_charge_options'], - 'permission_callback' => static function () { - return is_user_logged_in(); - }, - ], - [ - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => [$this, 'rest_charge_create'], - 'permission_callback' => static function () { - return is_user_logged_in(); - }, - ], - ]); - - register_rest_route('aksrt/v1', '/pay/transfer', [ - [ - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => [$this, 'rest_transfer'], - 'permission_callback' => static function () { - return is_user_logged_in(); - }, - ], - ]); - - register_rest_route('aksrt/v1', '/pay/transfer/search', [ - [ - 'methods' => WP_REST_Server::READABLE, - 'callback' => [$this, 'rest_transfer_search'], - 'permission_callback' => static function () { - return is_user_logged_in(); - }, - ], - ]); - - register_rest_route('aksrt/v1', '/pay/balance/exchange', [ - [ - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => [$this, 'rest_exchange_from_credits'], - 'permission_callback' => static function () { - return is_user_logged_in(); - }, - ], - ]); - - register_rest_route('aksrt/v1', '/pay/points/buy', [ - [ - 'methods' => WP_REST_Server::READABLE, - 'callback' => [$this, 'rest_points_buy_options'], - 'permission_callback' => static function () { - return is_user_logged_in(); - }, - ], - [ - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => [$this, 'rest_points_buy_create'], - 'permission_callback' => static function () { - return is_user_logged_in(); - }, - ], - ]); - - register_rest_route('aksrt/v1', '/pay/balance/withdraw', [ - [ - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => [$this, 'rest_balance_withdraw'], - 'permission_callback' => static function () { - return is_user_logged_in(); - }, - ], - ]); - } - - /** - * GET charge options (packages + methods + limits). - */ - public function rest_charge_options(WP_REST_Request $request) { - if (!class_exists('AKSRT_Balance') || !AKSRT_Balance::enabled()) { - return new WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]); - } - $user_id = get_current_user_id(); - return rest_ensure_response([ - 'success' => true, - 'data' => $this->get_charge_view($user_id), - ]); - } - - /** - * POST create recharge order and initiate payment. - */ - public function rest_charge_create(WP_REST_Request $request) { - if (!class_exists('AKSRT_Balance') || !AKSRT_Balance::enabled()) { - return new WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]); - } - $user_id = get_current_user_id(); - $params = $request->get_json_params(); - if (!is_array($params)) { - $params = $request->get_params(); - } - $package_key = sanitize_text_field((string) ($params['package'] ?? 'custom')); - $custom_price = isset($params['custom_price']) ? (float) $params['custom_price'] : 0.0; - $payment_method = sanitize_key((string) ($params['payment_method'] ?? 'alipay')); - - $result = $this->create_charge_order($user_id, $package_key, $custom_price, $payment_method); - if (is_wp_error($result)) { - return $result; - } - return rest_ensure_response(['success' => true, 'data' => $result]); - } - - /** - * Transfer balance or points between users. - */ - public function rest_transfer(WP_REST_Request $request) { - $params = $request->get_json_params(); - if (!is_array($params)) { - $params = $request->get_params(); - } - $type = sanitize_key((string) ($params['type'] ?? 'balance')); - $recipient = (int) ($params['recipient'] ?? 0); - $amount_raw = $params['amount'] ?? $params['price'] ?? 0; - $from_id = get_current_user_id(); - - if (!in_array($type, ['balance', 'points'], true)) { - return new WP_Error('aksrt_transfer_type', __('转账类型无效', 'aksrt'), ['status' => 400]); - } - if ($from_id <= 0) { - return new WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]); - } - if ($recipient <= 0 || $recipient === $from_id) { - return new WP_Error('aksrt_transfer_user', __('请选择有效的收款用户', 'aksrt'), ['status' => 400]); - } - if (!get_userdata($recipient)) { - return new WP_Error('aksrt_transfer_user', __('收款用户不存在', 'aksrt'), ['status' => 404]); - } - - $cfg = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_payment_recharge_settings() : []; - if ($type === 'balance') { - if (empty($cfg['enabled']) || empty($cfg['transfer']['enabled'])) { - return new WP_Error('aksrt_transfer_closed', __('余额转账未开启', 'aksrt'), ['status' => 403]); - } - if (class_exists('AKSRT_Settings_Capabilities') && !AKSRT_Settings_Capabilities::user_can('balance_transfer', $from_id)) { - return new WP_Error('aksrt_transfer_forbidden', __('当前账号无权转账余额', 'aksrt'), ['status' => 403]); - } - $amount = round((float) $amount_raw, 2); - if ($amount <= 0) { - return new WP_Error('aksrt_transfer_amount', __('请输入有效金额', 'aksrt'), ['status' => 400]); - } - if (!class_exists('AKSRT_Balance')) { - return new WP_Error('aksrt_balance_unavailable', __('余额系统不可用', 'aksrt'), ['status' => 500]); - } - $from_bal = AKSRT_Balance::balance($from_id); - if ($from_bal < $amount) { - return new WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), ['status' => 402]); - } - $fee_rate = max(0, min(50, (int) ($cfg['transfer']['service_charge'] ?? 0))); - $credit = $fee_rate > 0 ? round($amount * (100 - $fee_rate) / 100, 2) : $amount; - - $from_user = wp_get_current_user(); - $to_user = get_userdata($recipient); - $deduct = AKSRT_Balance::deduct( - $from_id, - $amount, - 'transfer_out', - ['type' => 'user', 'id' => $recipient], - sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient) - ); - if (is_wp_error($deduct)) { - return $deduct; - } - AKSRT_Balance::add( - $recipient, - $credit, - 'transfer_in', - ['type' => 'user', 'id' => $from_id], - sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id) - ); - return rest_ensure_response([ - 'success' => true, - 'data' => [ - 'message' => __('转账成功', 'aksrt'), - 'reload' => true, - 'balance' => AKSRT_Balance::balance($from_id), - ], - ]); - } - - // points transfer - $points_transfer_on = !empty(AKSRT_Settings::get_all()['points_transfer_s']); - if (!class_exists('AKSRT_Settings') || !AKSRT_Settings::credits_enabled() || !$points_transfer_on) { - return new WP_Error('aksrt_transfer_closed', __('积分转账未开启', 'aksrt'), ['status' => 403]); - } - if (class_exists('AKSRT_Settings_Capabilities') && !AKSRT_Settings_Capabilities::user_can('points_transfer', $from_id)) { - return new WP_Error('aksrt_transfer_forbidden', __('当前账号无权转账积分', 'aksrt'), ['status' => 403]); - } - $amount = (int) $amount_raw; - if ($amount <= 0) { - return new WP_Error('aksrt_transfer_amount', __('请输入有效积分', 'aksrt'), ['status' => 400]); - } - if (!class_exists('AKSRT_Credits')) { - return new WP_Error('aksrt_credits_unavailable', __('积分系统不可用', 'aksrt'), ['status' => 500]); - } - $fee_rate = max(0, min(50, (int) (AKSRT_Settings::get_all()['points_service_charge'] ?? 0))); - $credit = $fee_rate > 0 ? (int) floor($amount * (100 - $fee_rate) / 100) : $amount; - $from_user = wp_get_current_user(); - $to_user = get_userdata($recipient); - $deduct = AKSRT_Credits::deduct( - $from_id, - $amount, - 'transfer_out', - ['type' => 'user', 'id' => $recipient], - sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient) - ); - if (is_wp_error($deduct)) { - return $deduct; - } - AKSRT_Credits::add( - $recipient, - $credit, - 'transfer_in', - ['type' => 'user', 'id' => $from_id], - sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id) - ); - return rest_ensure_response([ - 'success' => true, - 'data' => [ - 'message' => __('转账成功', 'aksrt'), - 'reload' => true, - 'balance' => AKSRT_Credits::balance($from_id), - ], - ]); - } - - public function rest_transfer_search(WP_REST_Request $request) { - $s = sanitize_text_field((string) $request->get_param('s')); - if (mb_strlen($s) < 1) { - return rest_ensure_response(['success' => true, 'data' => ['users' => []]]); - } - $exclude = [get_current_user_id()]; - $q = new WP_User_Query([ - 'search' => '*' . $s . '*', - 'search_columns' => ['user_login', 'user_nicename', 'display_name', 'user_email'], - 'exclude' => $exclude, - 'number' => 20, - 'fields' => ['ID', 'display_name', 'user_login'], - ]); - $users = []; - foreach ((array) $q->get_results() as $u) { - $users[] = [ - 'id' => (int) $u->ID, - 'display_name' => (string) $u->display_name, - 'user_login' => (string) $u->user_login, - 'avatar' => get_avatar_url((int) $u->ID, ['size' => 48]), - ]; - } - return rest_ensure_response(['success' => true, 'data' => ['users' => $users]]); - } - - /** - * Exchange points → cash wallet (pay_balance_exchange_*). - */ - public function rest_exchange_from_credits(WP_REST_Request $request) { - if (!class_exists('AKSRT_Balance') || !AKSRT_Balance::enabled()) { - return new WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]); - } - $cfg = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_payment_recharge_settings() : []; - if (empty($cfg['exchange']['enabled'])) { - return new WP_Error('aksrt_exchange_closed', __('积分兑换余额未开启', 'aksrt'), ['status' => 403]); - } - if (!class_exists('AKSRT_Credits') || !AKSRT_Settings::credits_enabled()) { - return new WP_Error('aksrt_credits_disabled', __('积分功能未启用', 'aksrt'), ['status' => 403]); - } - $params = $request->get_json_params(); - if (!is_array($params)) { - $params = $request->get_params(); - } - $points = (int) ($params['points'] ?? 0); - $rate = max(1, (int) ($cfg['exchange']['rate'] ?? 100)); // N points = 1 yuan - if ($points < $rate) { - return new WP_Error('aksrt_exchange_min', sprintf(__('最少兑换 %d 积分', 'aksrt'), $rate), ['status' => 400]); - } - // Floor to whole yuan. - $yuan = (int) floor($points / $rate); - $cost = $yuan * $rate; - if ($yuan <= 0 || $cost <= 0) { - return new WP_Error('aksrt_exchange_amount', __('兑换数量无效', 'aksrt'), ['status' => 400]); - } - $user_id = get_current_user_id(); - $deduct = AKSRT_Credits::deduct( - $user_id, - $cost, - 'exchange_to_balance', - ['type' => 'balance'], - sprintf(__('积分兑换余额 %d 元', 'aksrt'), $yuan) - ); - if (is_wp_error($deduct)) { - return $deduct; - } - $add = AKSRT_Balance::add( - $user_id, - (float) $yuan, - 'exchange_from_credits', - ['type' => 'credits'], - sprintf(__('积分兑换:消耗 %d 积分', 'aksrt'), $cost) - ); - if (is_wp_error($add)) { - // best-effort refund points - AKSRT_Credits::add($user_id, $cost, 'exchange_refund', [], __('兑换失败退回积分', 'aksrt')); - return $add; - } - return rest_ensure_response([ - 'success' => true, - 'data' => [ - 'message' => sprintf(__('兑换成功:+%d 元', 'aksrt'), $yuan), - 'wallet_balance' => AKSRT_Balance::balance($user_id), - 'credit_balance' => AKSRT_Credits::balance($user_id), - 'reload' => true, - ], - ]); - } - - /** - * When a recharge order is paid, credit the wallet. - * - * @param string $order_number Order number. - * @param array $order Order row. - */ - public function on_order_paid(string $order_number, array $order = []): void { - if ($order === [] && class_exists('AKSRT_Order_Repository')) { - $order = AKSRT_Order_Repository::get_instance()->get($order_number) ?: []; - } - if (!$order) { - return; - } - $type = sanitize_key((string) ($order['product_type'] ?? '')); - $user_id = (int) ($order['user_id'] ?? 0); - if ($user_id <= 0) { - return; - } - - // Points purchase fulfill (zibll order_type=9). - if ($type === AKSRT_Domain_Model::ORDER_PRODUCT_POINTS_BUY) { - if (!class_exists('AKSRT_Credits')) { - return; - } - $note = json_decode((string) ($order['note'] ?? ''), true); - if (!is_array($note)) { - $note = []; - } - if (!empty($note['points_credited'])) { - return; - } - $rate = 100; - if (class_exists('AKSRT_Settings')) { - $rate = max(1, (int) (AKSRT_Settings::get_all()['points_rate'] ?? 100)); - } - $points = isset($note['points_amount']) - ? (int) $note['points_amount'] - : (int) round((float) ($order['amount'] ?? 0) * $rate); - if ($points <= 0) { - return; - } - $add = AKSRT_Credits::add( - $user_id, - $points, - 'points_buy', - ['type' => 'order', 'order' => $order_number], - sprintf(__('购买积分订单 %s', 'aksrt'), $order_number) - ); - if (is_wp_error($add)) { - return; - } - $note['points_credited'] = 1; - $note['credited_points'] = $points; - global $wpdb; - $wpdb->update( - $wpdb->prefix . 'aksrt_order', - ['note' => wp_json_encode($note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)], - ['order_number' => $order_number], - ['%s'], - ['%s'] - ); - return; - } - - if ($type !== AKSRT_Domain_Model::ORDER_PRODUCT_RECHARGE) { - return; - } - if (!class_exists('AKSRT_Balance')) { - return; - } - $note = json_decode((string) ($order['note'] ?? ''), true); - if (!is_array($note)) { - $note = []; - } - if (!empty($note['balance_credited'])) { - return; - } - $credit_amount = isset($note['credit_amount']) - ? round((float) $note['credit_amount'], 2) - : round((float) ($order['amount'] ?? 0), 2); - if ($credit_amount <= 0) { - return; - } - $result = AKSRT_Balance::add( - $user_id, - $credit_amount, - 'recharge', - ['type' => 'order', 'order' => $order_number], - sprintf(__('余额充值订单 %s', 'aksrt'), $order_number) - ); - if (is_wp_error($result)) { - return; - } - $note['balance_credited'] = 1; - $note['credited_amount'] = $credit_amount; - global $wpdb; - $wpdb->update( - $wpdb->prefix . 'aksrt_order', - ['note' => wp_json_encode($note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)], - ['order_number' => $order_number], - ['%s'], - ['%s'] - ); - } - - /** - * @return array - */ - public function get_charge_view(int $user_id): array { - $cfg = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_payment_recharge_settings() : []; - $packages = $this->parse_packages((string) ($cfg['packages_raw'] ?? '')); - $custom_s = !isset($cfg['product']['custom_s']) || !empty($cfg['product']['custom_s']); - $custom_limit = max(1, (int) ($cfg['product']['custom_limit'] ?? 9999)); - $methods = []; - if (function_exists('aksrt_list_cash_payment_methods')) { - // Exclude balance gateway for recharge (cannot pay wallet with wallet). - foreach (aksrt_list_cash_payment_methods(true) as $m) { - $id = is_array($m) ? (string) ($m['id'] ?? '') : (string) $m; - if ($id === '' || $id === 'balance') { - continue; - } - $methods[] = is_array($m) ? $m : ['id' => $id, 'name' => $id]; - } - } - return [ - 'enabled' => !empty($cfg['enabled']), - 'desc' => (string) ($cfg['desc'] ?? ''), - 'wallet' => class_exists('AKSRT_Balance') ? AKSRT_Balance::balance($user_id) : 0.0, - 'packages' => $packages, - 'custom_s' => $custom_s, - 'custom_limit' => $custom_limit, - 'currency_mark' => (string) ($cfg['product']['tag'] ?? '元'), - 'payment_methods' => $methods, - 'transfer' => [ - 'enabled' => !empty($cfg['transfer']['enabled']), - 'service_charge' => (int) ($cfg['transfer']['service_charge'] ?? 0), - 'desc' => (string) ($cfg['transfer']['desc'] ?? ''), - ], - 'exchange' => [ - 'enabled' => !empty($cfg['exchange']['enabled']), - 'rate' => (int) ($cfg['exchange']['rate'] ?? 100), - 'desc' => (string) ($cfg['exchange']['desc'] ?? ''), - ], - 'pass_charge' => is_array($cfg['pass_charge'] ?? null) ? $cfg['pass_charge'] : [], - ]; - } - - /** - * @return array - */ - public function parse_packages(string $raw): array { - $out = []; - $lines = preg_split('/\r\n|\r|\n/', $raw) ?: []; - $i = 0; - foreach ($lines as $line) { - $line = trim((string) $line); - if ($line === '') { - continue; - } - // pay|credit|title OR credit|pay|title — Zibll uses price(credit)|pay_price|tag - // Admin help: 支付金额|到账金额|标题 - $parts = array_map('trim', explode('|', $line)); - if (count($parts) < 2) { - continue; - } - $pay = (float) $parts[0]; - $credit = (float) $parts[1]; - $title = $parts[2] ?? ''; - if ($pay <= 0 || $credit <= 0) { - continue; - } - $out[] = [ - 'key' => (string) $i, - 'pay_price' => round($pay, 2), - 'price' => round($credit, 2), - 'title' => $title !== '' ? $title : sprintf(__('充值 %s 元', 'aksrt'), (string) $credit), - 'tag' => $title, - ]; - $i++; - } - return $out; - } - - /** - * @return array|WP_Error - */ - public function create_charge_order(int $user_id, string $package_key, float $custom_price, string $payment_method) { - if ($user_id <= 0) { - return new WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]); - } - if (!class_exists('AKSRT_Order_Repository') || !class_exists('AKSRT_Order')) { - return new WP_Error('aksrt_pay_unavailable', __('订单系统不可用', 'aksrt'), ['status' => 500]); - } - $view = $this->get_charge_view($user_id); - $credit_amount = 0.0; - $pay_amount = 0.0; - $title = __('余额充值', 'aksrt'); - - if ($package_key === 'custom' || $package_key === '') { - if (empty($view['custom_s'])) { - return new WP_Error('aksrt_custom_closed', __('未开放自定义金额', 'aksrt'), ['status' => 400]); - } - $custom_price = round($custom_price, 2); - $min = 1.0; - $max = (float) $view['custom_limit']; - if ($custom_price < $min || $custom_price > $max) { - return new WP_Error('aksrt_custom_limit', sprintf(__('充值金额需在 %s ~ %s 之间', 'aksrt'), (string) $min, (string) $max), ['status' => 400]); - } - $pay_amount = $custom_price; - $credit_amount = $custom_price; - $title = sprintf(__('自定义充值 %s 元', 'aksrt'), (string) $custom_price); - $package_key = 'custom'; - } else { - $found = null; - foreach ((array) $view['packages'] as $pkg) { - if ((string) ($pkg['key'] ?? '') === (string) $package_key) { - $found = $pkg; - break; - } - } - if (!$found) { - return new WP_Error('aksrt_invalid_package', __('充值套餐无效', 'aksrt'), ['status' => 400]); - } - $pay_amount = (float) $found['pay_price']; - $credit_amount = (float) $found['price']; - $title = (string) $found['title']; - } - - if ($pay_amount <= 0 || $credit_amount <= 0) { - return new WP_Error('aksrt_invalid_price', __('金额无效', 'aksrt'), ['status' => 400]); - } - - $payment_method = sanitize_key($payment_method); - if ($payment_method === '' || $payment_method === 'balance') { - // Prefer first non-balance method. - $methods = $view['payment_methods'] ?? []; - $payment_method = !empty($methods[0]['id']) ? (string) $methods[0]['id'] : 'alipay'; - } - - $note = wp_json_encode([ - 'balance_recharge' => 1, - 'package' => $package_key, - 'credit_amount' => $credit_amount, - 'pay_amount' => $pay_amount, - 'title' => $title, - ], 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_RECHARGE, - 'product_id' => 0, - 'amount' => $pay_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'] = $title; - $order['payment_method'] = $payment_method; - - if (!class_exists('AKSRT_Payment_Gateway')) { - return new WP_Error('aksrt_pay_unavailable', __('支付网关不可用', 'aksrt'), ['status' => 500]); - } - $gateway = AKSRT_Payment_Gateway::get_instance()->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'])) { - return new WP_Error( - 'aksrt_pay_failed', - (string) ($payment_result['message'] ?? __('支付失败', 'aksrt')), - ['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 [ - 'redirect' => true, - 'payment_url' => $payment_url, - 'qr_content' => $code_url, - 'order_number' => $order_number, - 'amount' => $pay_amount, - 'credit_amount' => $credit_amount, - 'message' => $payment_result['message'] ?? __('请在新页面完成支付。', 'aksrt'), - 'reload' => false, - 'poll' => true, - ]; - } - - // Synchronous gateway (rare for recharge): mark paid → on_order_paid credits wallet. - $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]); - } - // Ensure credit even if status hook shape differs. - $this->on_order_paid($order_number, AKSRT_Order_Repository::get_instance()->get($order_number) ?: $order); - - return [ - 'redirect' => false, - 'order_number' => $order_number, - 'amount' => $pay_amount, - 'credit_amount' => $credit_amount, - 'wallet' => AKSRT_Balance::balance($user_id), - 'message' => __('充值成功', 'aksrt'), - 'reload' => true, - ]; - } - - public function rest_points_buy_options(WP_REST_Request $request) { - if (!$this->points_buy_enabled()) { - return new WP_Error('aksrt_points_buy_disabled', __('购买积分未开启', 'aksrt'), ['status' => 403]); - } - return rest_ensure_response([ - 'success' => true, - 'data' => $this->get_points_buy_view(get_current_user_id()), - ]); - } - - public function rest_points_buy_create(WP_REST_Request $request) { - if (!$this->points_buy_enabled()) { - return new WP_Error('aksrt_points_buy_disabled', __('购买积分未开启', 'aksrt'), ['status' => 403]); - } - $params = $request->get_json_params(); - if (!is_array($params)) { - $params = $request->get_params(); - } - $package_key = sanitize_text_field((string) ($params['package'] ?? 'custom')); - $custom_points = (int) ($params['custom_points'] ?? $params['points'] ?? 0); - $payment_method = sanitize_key((string) ($params['payment_method'] ?? 'alipay')); - $result = $this->create_points_buy_order(get_current_user_id(), $package_key, $custom_points, $payment_method); - if (is_wp_error($result)) { - return $result; - } - return rest_ensure_response(['success' => true, 'data' => $result]); - } - - public function rest_balance_withdraw(WP_REST_Request $request) { - if (!class_exists('AKSRT_Balance') || !AKSRT_Balance::enabled()) { - return new WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]); - } - $settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_all() : []; - if (empty($settings['pay_balance_withdraw_s'])) { - return new WP_Error('aksrt_withdraw_closed', __('余额提现未开启', 'aksrt'), ['status' => 403]); - } - if (!class_exists('AKSRT_Withdrawal')) { - return new WP_Error('aksrt_withdraw_unavailable', __('提现系统不可用', 'aksrt'), ['status' => 500]); - } - $params = $request->get_json_params(); - if (!is_array($params)) { - $params = $request->get_params(); - } - $user_id = get_current_user_id(); - if (class_exists('AKSRT_Settings_Capabilities') && !AKSRT_Settings_Capabilities::user_can('balance_withdraw', $user_id)) { - return new WP_Error('aksrt_withdraw_forbidden', __('当前账号无权提现', 'aksrt'), ['status' => 403]); - } - $amount = round((float) ($params['amount'] ?? 0), 2); - $method = sanitize_key((string) ($params['method'] ?? 'alipay')); - $account = sanitize_textarea_field((string) ($params['account_info'] ?? $params['account'] ?? '')); - $lowest = max(1, (float) ($settings['pay_balance_withdraw_lowest_money'] ?? $settings['pay_rebate_withdraw_lowest_money'] ?? 50)); - if ($amount < $lowest) { - return new WP_Error('aksrt_withdraw_min', sprintf(__('最低提现 %s 元', 'aksrt'), (string) $lowest), ['status' => 400]); - } - if ($account === '') { - return new WP_Error('aksrt_withdraw_account', __('请填写收款账号信息', 'aksrt'), ['status' => 400]); - } - // One open balance withdrawal at a time (Zibll parity). - if (AKSRT_Withdrawal::get_instance()->has_open_withdrawal($user_id)) { - return new WP_Error('aksrt_withdraw_pending', __('您有处理中的提现申请,请等待完成后再申请', 'aksrt'), ['status' => 409]); - } - $wallet = AKSRT_Balance::balance($user_id); - if ($wallet < $amount) { - return new WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), ['status' => 402]); - } - $deduct = AKSRT_Balance::deduct($user_id, $amount, 'withdraw_hold', ['type' => 'withdraw'], __('余额提现申请冻结', 'aksrt')); - if (is_wp_error($deduct)) { - return $deduct; - } - $fee_rate = max(0, min(50, (int) ($settings['withdraw_service_charge'] ?? 0))); - $net = $fee_rate > 0 ? round($amount * (100 - $fee_rate) / 100, 2) : $amount; - $account_payload = wp_json_encode([ - 'account' => $account, - 'gross_amount' => $amount, - 'fee_rate' => $fee_rate, - 'source' => 'balance', - ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $account; - $wid = AKSRT_Withdrawal::get_instance()->create($user_id, $net, $method !== '' ? $method : 'alipay', $account_payload); - if ($wid <= 0) { - AKSRT_Balance::add($user_id, $amount, 'withdraw_refund', [], __('提现申请失败退回', 'aksrt')); - return new WP_Error('aksrt_withdraw_failed', __('提现申请失败', 'aksrt'), ['status' => 500]); - } - return rest_ensure_response([ - 'success' => true, - 'data' => [ - 'message' => __('提现申请已提交,请等待处理', 'aksrt'), - 'withdrawal_id' => $wid, - 'amount' => $amount, - 'net_amount' => $net, - 'wallet_balance' => AKSRT_Balance::balance($user_id), - 'reload' => true, - ], - ]); - } - - public function points_buy_enabled(): bool { - if (!class_exists('AKSRT_Settings') || !AKSRT_Settings::credits_enabled()) { - return false; - } - $all = AKSRT_Settings::get_all(); - return !empty($all['points_pay_s']); - } - - /** - * @return array - */ - public function get_points_buy_view(int $user_id): array { - $all = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_all() : []; - $rate = max(1, (int) ($all['points_rate'] ?? 100)); - $packages_raw = (string) ($all['pay_points_product'] ?? ''); - $packages = $this->parse_points_packages($packages_raw); - $custom_s = !isset($all['pay_points_product_custom_s']) || !empty($all['pay_points_product_custom_s']); - $custom_limit = max(1, (int) ($all['pay_points_product_custom_limit'] ?? 99999)); - $methods = []; - if (function_exists('aksrt_list_cash_payment_methods')) { - foreach (aksrt_list_cash_payment_methods(true) as $m) { - $id = is_array($m) ? (string) ($m['id'] ?? '') : (string) $m; - if ($id === '') { - continue; - } - if ($id === 'balance' && !(class_exists('AKSRT_Balance') && AKSRT_Balance::enabled())) { - continue; - } - $methods[] = is_array($m) ? $m : ['id' => $id, 'name' => $id]; - } - } - if (class_exists('AKSRT_Balance') && AKSRT_Balance::enabled()) { - $has_bal = false; - foreach ($methods as $m) { - if (($m['id'] ?? '') === 'balance') { - $has_bal = true; - break; - } - } - if (!$has_bal) { - array_unshift($methods, ['id' => 'balance', 'name' => __('站内余额', 'aksrt')]); - } - } - return [ - 'enabled' => $this->points_buy_enabled(), - 'desc' => (string) ($all['pay_points_desc'] ?? ''), - 'rate' => $rate, - 'points' => class_exists('AKSRT_Credits') ? AKSRT_Credits::balance($user_id) : 0, - 'packages' => $packages, - 'custom_s' => $custom_s, - 'custom_limit' => $custom_limit, - 'payment_methods' => $methods, - ]; - } - - /** - * @return array - */ - public function parse_points_packages(string $raw): array { - $out = []; - $lines = preg_split('/\r\n|\r|\n/', $raw) ?: []; - $i = 0; - foreach ($lines as $line) { - $line = trim((string) $line); - if ($line === '') { - continue; - } - // 支付金额|积分数量|标题 - $parts = array_map('trim', explode('|', $line)); - if (count($parts) < 2) { - continue; - } - $pay = (float) $parts[0]; - $points = (int) $parts[1]; - $title = $parts[2] ?? ''; - if ($pay <= 0 || $points <= 0) { - continue; - } - $out[] = [ - 'key' => (string) $i, - 'pay_price' => round($pay, 2), - 'points' => $points, - 'title' => $title !== '' ? $title : sprintf(__('%d 积分', 'aksrt'), $points), - ]; - $i++; - } - return $out; - } - - /** - * @return array|WP_Error - */ - public function create_points_buy_order(int $user_id, string $package_key, int $custom_points, string $payment_method) { - if ($user_id <= 0) { - return new WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]); - } - if (!class_exists('AKSRT_Order_Repository') || !class_exists('AKSRT_Order')) { - return new WP_Error('aksrt_pay_unavailable', __('订单系统不可用', 'aksrt'), ['status' => 500]); - } - $view = $this->get_points_buy_view($user_id); - $rate = max(1, (int) ($view['rate'] ?? 100)); - $points_amount = 0; - $pay_amount = 0.0; - $title = __('购买积分', 'aksrt'); - - if ($package_key === 'custom' || $package_key === '') { - if (empty($view['custom_s'])) { - return new WP_Error('aksrt_custom_closed', __('未开放自定义数量', 'aksrt'), ['status' => 400]); - } - $custom_points = max(0, $custom_points); - $max = (int) ($view['custom_limit'] ?? 99999); - if ($custom_points < $rate || $custom_points > $max) { - return new WP_Error('aksrt_custom_limit', sprintf(__('购买积分需在 %d ~ %d 之间', 'aksrt'), $rate, $max), ['status' => 400]); - } - $points_amount = $custom_points; - $pay_amount = round($custom_points / $rate, 2); - if ($pay_amount <= 0) { - $pay_amount = 0.01; - } - $title = sprintf(__('购买 %d 积分', 'aksrt'), $points_amount); - $package_key = 'custom'; - } else { - $found = null; - foreach ((array) $view['packages'] as $pkg) { - if ((string) ($pkg['key'] ?? '') === (string) $package_key) { - $found = $pkg; - break; - } - } - if (!$found) { - return new WP_Error('aksrt_invalid_package', __('积分套餐无效', 'aksrt'), ['status' => 400]); - } - $points_amount = (int) $found['points']; - $pay_amount = (float) $found['pay_price']; - $title = (string) $found['title']; - } - if ($points_amount <= 0 || $pay_amount <= 0) { - return new WP_Error('aksrt_invalid_price', __('金额无效', 'aksrt'), ['status' => 400]); - } - - $payment_method = sanitize_key($payment_method); - if ($payment_method === '') { - $methods = $view['payment_methods'] ?? []; - $payment_method = !empty($methods[0]['id']) ? (string) $methods[0]['id'] : 'alipay'; - } - - $note = wp_json_encode([ - 'points_buy' => 1, - 'package' => $package_key, - 'points_amount' => $points_amount, - 'pay_amount' => $pay_amount, - 'title' => $title, - ], 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_POINTS_BUY, - 'product_id' => 0, - 'amount' => $pay_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'] = $title; - $order['payment_method'] = $payment_method; - - if (!class_exists('AKSRT_Payment_Gateway')) { - return new WP_Error('aksrt_pay_unavailable', __('支付网关不可用', 'aksrt'), ['status' => 500]); - } - $gateway = AKSRT_Payment_Gateway::get_instance()->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'])) { - return new WP_Error('aksrt_pay_failed', (string) ($payment_result['message'] ?? __('支付失败', 'aksrt')), ['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 [ - 'redirect' => true, - 'payment_url' => $payment_url, - 'qr_content' => $code_url, - 'order_number' => $order_number, - 'amount' => $pay_amount, - 'points_amount' => $points_amount, - '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->on_order_paid($order_number, AKSRT_Order_Repository::get_instance()->get($order_number) ?: $order); - return [ - 'redirect' => false, - 'order_number' => $order_number, - 'amount' => $pay_amount, - 'points_amount' => $points_amount, - 'points' => AKSRT_Credits::balance($user_id), - 'message' => __('购买成功', 'aksrt'), - 'reload' => true, - ]; - } - } diff --git a/inc/classes/payment/class-aksrt-order-repository.php b/inc/classes/payment/class-aksrt-order-repository.php index bb347b0a..da489df4 100644 --- a/inc/classes/payment/class-aksrt-order-repository.php +++ b/inc/classes/payment/class-aksrt-order-repository.php @@ -11,1013 +11,16 @@ defined('ABSPATH') || exit; class AKSRT_Order_Repository { + use AKSRT_Order_Repository_Core; + use AKSRT_Order_Repository_Admin; private static $instance = null; + + public static function get_instance(): self { if (null === self::$instance) self::$instance = new self(); return self::$instance; } - private function table(): string { - return AKSRT_DB_Migrator::table('order'); - } - - /* ------------------------------------------------------------------ - * CRUD - * ----------------------------------------------------------------*/ - - /** - * 创建订单(写入表) - */ - public function create(array $data): string { - global $wpdb; - - $order_number = $data['order_number'] ?? $this->generate_order_number(); - - $wpdb->insert($this->table(), [ - 'order_number' => $order_number, - 'user_id' => (int) ($data['user_id'] ?? 0), - 'product_type' => sanitize_key((string) ($data['product_type'] ?? '')), - 'product_id' => (int) ($data['product_id'] ?? 0), - 'amount' => (float) ($data['amount'] ?? 0), - 'currency' => sanitize_key((string) ($data['currency'] ?? 'CNY')), - 'status' => AKSRT_Domain_Model::normalize_order_status((string) ($data['status'] ?? AKSRT_Domain_Model::ORDER_STATUS_PENDING)), - 'payment_method' => sanitize_key((string) ($data['payment_method'] ?? '')), - 'payment_transaction_id' => sanitize_text_field((string) ($data['payment_transaction_id'] ?? '')), - 'created_at' => $data['created_at'] ?? current_time('mysql'), - 'paid_at' => $data['paid_at'] ?? null, - 'expires_at' => $data['expires_at'] ?? null, - 'note' => sanitize_textarea_field((string) ($data['note'] ?? '')), - ], ['%s', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']); - - if ($wpdb->insert_id) { - return $order_number; - } - - return ''; - } - - /** - * 获取单个订单 - */ - public function get(string $order_number): ?array { - global $wpdb; - - $row = $wpdb->get_row($wpdb->prepare( - "SELECT * FROM {$this->table()} WHERE order_number = %s", - $order_number - ), ARRAY_A); - - if ($row) { - return $this->normalize_row($row); - } - - return null; - } - - /** - * 获取用户订单列表 - */ - public function get_user_orders(int $user_id, array $args = []): array { - global $wpdb; - - $where = []; - $params = []; - - if ($user_id > 0) { - $where[] = 'user_id = %d'; - $params[] = $user_id; - } - - $status = sanitize_key((string) ($args['status'] ?? '')); - if ($status !== '') { - $where[] = 'status = %s'; - $params[] = $status; - } - - $product_type = sanitize_key((string) ($args['product_type'] ?? '')); - if ($product_type !== '') { - $where[] = 'product_type = %s'; - $params[] = $product_type; - } - - $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); - $offset = max(0, (int) ($args['offset'] ?? 0)); - - $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; - $sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; - $params[] = $limit; - $params[] = $offset; - - $rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); - - return array_map([$this, 'normalize_row'], (array) $rows); - } - - /** - * 获取单个用户订单。 - */ - public function get_user_order(int $user_id, string $order_number): ?array { - $order = $this->get($order_number); - if (!$order) { - return null; - } - - if ($user_id > 0 && (int) ($order['user_id'] ?? 0) !== $user_id) { - return null; - } - - return $order; - } - - /** - * 获取用户订单总数。 - */ - public function count_user_orders(int $user_id, array $args = []): int { - global $wpdb; - - if ($user_id <= 0) { - return 0; - } - - $where = ['user_id = %d']; - $params = [$user_id]; - - $status = sanitize_key((string) ($args['status'] ?? '')); - if ($status !== '') { - $where[] = 'status = %s'; - $params[] = $status; - } - - $product_type = sanitize_key((string) ($args['product_type'] ?? '')); - if ($product_type !== '') { - $where[] = 'product_type = %s'; - $params[] = $product_type; - } - - $where_sql = 'WHERE ' . implode(' AND ', $where); - $sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}"; - - return (int) $wpdb->get_var($wpdb->prepare($sql, $params)); - } - - /** - * Build a stable admin-facing order context payload for workbench pages. - */ - public function get_admin_order_context(string $order_number): array { - $order_number = sanitize_text_field($order_number); - $contexts = $this->get_admin_order_context_map([$order_number]); - - return $contexts[$order_number] ?? $this->get_empty_admin_order_context($order_number); - } - - /** - * Build admin-facing order context payloads in bulk to avoid per-row order queries. - * - * @param array $order_numbers - * @return array> - */ - public function get_admin_order_context_map(array $order_numbers): array { - global $wpdb; - - $order_numbers = array_values(array_unique(array_filter(array_map( - static function ($order_number): string { - return sanitize_text_field((string) $order_number); - }, - $order_numbers - )))); - - if (empty($order_numbers)) { - return []; - } - - $contexts = []; - foreach ($order_numbers as $order_number) { - $contexts[$order_number] = $this->get_empty_admin_order_context($order_number); - } - - $placeholders = implode(', ', array_fill(0, count($order_numbers), '%s')); - $sql = "SELECT * FROM {$this->table()} WHERE order_number IN ({$placeholders})"; - $rows = $wpdb->get_results($wpdb->prepare($sql, $order_numbers), ARRAY_A); - - if (empty($rows)) { - return $contexts; - } - - $orders = []; - foreach ((array) $rows as $row) { - $order = $this->normalize_row($row); - $orders[(string) ($order['order_number'] ?? '')] = $order; - } - - $resolved_order_numbers = array_keys($orders); - $shipping_map = class_exists('AKSRT_Shipping') - ? AKSRT_Shipping::get_instance()->get_summary_map($resolved_order_numbers) - : []; - $after_sale_map = class_exists('AKSRT_After_Sale') - ? AKSRT_After_Sale::get_instance()->get_summary_map($resolved_order_numbers) - : []; - - foreach ($orders as $order_number => $order) { - $contexts[$order_number] = $this->build_admin_order_context($order, [ - 'shipping_summary' => $shipping_map[$order_number] ?? [], - 'after_sale_summary' => $after_sale_map[$order_number] ?? [], - ]); - } - - return $contexts; - } - - /** - * 更新订单状态(带状态机验证) - */ - /** - * Merge operator admin note into order note meta without destroying JSON payload. - */ - public function update_admin_note(string $order_number, string $admin_note): bool { - global $wpdb; - - $order = $this->get($order_number); - if (!$order) { - return false; - } - - $raw = isset($order['note']) && is_string($order['note']) ? $order['note'] : ''; - $decoded = $raw !== '' ? json_decode($raw, true) : null; - $admin_note = sanitize_textarea_field($admin_note); - - if (is_array($decoded)) { - $decoded['admin_note'] = $admin_note; - $new_note = wp_json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - } elseif ($raw !== '') { - $new_note = wp_json_encode([ - 'customer_note' => $raw, - 'admin_note' => $admin_note, - ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - } else { - $new_note = wp_json_encode([ - 'admin_note' => $admin_note, - ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - } - - $result = $wpdb->update( - $this->table(), - ['note' => $new_note], - ['order_number' => $order_number], - ['%s'], - ['%s'] - ); - - return $result !== false; - } - - public function update_status(string $order_number, string $new_status, array $extra = []): bool { - global $wpdb; - - $valid = AKSRT_Domain_Model::order_status_labels(); - if (!isset($valid[$new_status])) return false; - - $order = $this->get($order_number); - if (!$order) return false; - - $current_status = $order['status'] ?? 'created'; - - if (empty($extra['force']) && !AKSRT_Domain_Model::can_transition_order_status($current_status, $new_status)) { - return false; - } - - $update_data = ['status' => $new_status]; - $format = ['%s']; - - $time_fields = [ - 'paid' => 'paid_at', - 'delivered' => 'delivered_at', - 'completed' => 'completed_at', - 'cancelled' => 'cancelled_at', - 'refunded' => 'refunded_at', - ]; - - foreach ($time_fields as $status_key => $field) { - if ($new_status === $status_key) { - $update_data[$field] = current_time('mysql'); - $format[] = '%s'; - } - } - - if (!empty($extra['payment_method'])) { - $update_data['payment_method'] = sanitize_key($extra['payment_method']); - $format[] = '%s'; - } - - if (!empty($extra['payment_transaction_id'])) { - $update_data['payment_transaction_id'] = sanitize_text_field($extra['payment_transaction_id']); - $format[] = '%s'; - } - - if (!empty($extra['note'])) { - // Preserve JSON pay meta (coupon/topic_pay/content_pay/…); stash human text as status_note. - $status_note = sanitize_textarea_field((string) $extra['note']); - $raw_note = isset($order['note']) && is_string($order['note']) ? $order['note'] : ''; - $decoded_note = $raw_note !== '' ? json_decode($raw_note, true) : null; - if (is_array($decoded_note)) { - $decoded_note['status_note'] = $status_note; - $merged = wp_json_encode($decoded_note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - $update_data['note'] = is_string($merged) ? $merged : $status_note; - } else { - $update_data['note'] = $status_note; - } - $format[] = '%s'; - } - - $result = $wpdb->update($this->table(), $update_data, ['order_number' => $order_number], $format, ['%s']); - - if ($result !== false) { - // 到账事件:让充值/VIP/商品履约等挂接(单一履约入口)。 - // 仅在「首次」转为 paid 时触发,避免回调多次到达重复履约。 - if ($new_status === AKSRT_Domain_Model::ORDER_STATUS_PAID && $current_status !== AKSRT_Domain_Model::ORDER_STATUS_PAID) { - $paid_order = array_merge($order, [ - 'status' => $new_status, - 'payment_method' => $update_data['payment_method'] ?? ($order['payment_method'] ?? ''), - 'payment_transaction_id' => $update_data['payment_transaction_id'] ?? ($order['payment_transaction_id'] ?? ''), - ]); - if (isset($update_data['note'])) { - // Keep pre-update JSON payload for listeners (coupon burn etc.); - // only attach status_note if callers mutated note. - $pre_meta = []; - $pre_raw = isset($order['note']) && is_string($order['note']) ? $order['note'] : ''; - if ($pre_raw !== '') { - $pre_decoded = json_decode($pre_raw, true); - if (is_array($pre_decoded)) { - $pre_meta = $pre_decoded; - } - } - if ($pre_meta !== []) { - $paid_order['meta_payload'] = $pre_meta; - // Prefer merged DB note (JSON + status_note) for downstream reads. - $paid_order['note'] = $update_data['note']; - } - } - - /** - * 订单到账。 - * - * @param string $order_number 订单号 - * @param array $paid_order 订单数据(status=paid) - */ - do_action('aksrt_order_paid', $order_number, $paid_order); - } - } - - return $result !== false; - } - - /** - * 管理后台订单列表 — 支持 search(order_number / product_type 模糊匹配) - */ - /** - * Paid orders that still need shipping (no shipping row yet). - * - * @return array{items: array>, total: int, page: int, max_pages: int} - */ - public function get_pending_shipment_orders(array $args = []): array { - global $wpdb; - - $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); - $offset = max(0, (int) ($args['offset'] ?? 0)); - $search = sanitize_text_field((string) ($args['search'] ?? $args['s'] ?? '')); - - $order_table = $this->table(); - // Must match AKSRT_Shipping::table() key. - $shipping_table = class_exists('AKSRT_DB_Migrator') - ? AKSRT_DB_Migrator::table('order_shipping') - : $wpdb->prefix . 'aksrt_order_shipping'; - - $where = ["o.status = %s"]; - $params = ['paid']; - - if ($search !== '') { - $where[] = '(o.order_number LIKE %s OR o.product_type LIKE %s)'; - $like = '%' . $wpdb->esc_like($search) . '%'; - $params[] = $like; - $params[] = $like; - } - - // NOT EXISTS avoids ONLY_FULL_GROUP_BY issues from LEFT JOIN + GROUP BY. - $where[] = "NOT EXISTS ( - SELECT 1 FROM {$shipping_table} s - WHERE s.order_number = o.order_number - )"; - $where_sql = 'WHERE ' . implode(' AND ', $where); - - $count_sql = "SELECT COUNT(*) FROM {$order_table} o {$where_sql}"; - $total = (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)); - - $sql = "SELECT o.* - FROM {$order_table} o - {$where_sql} - ORDER BY COALESCE(o.paid_at, o.created_at) DESC, o.id DESC - LIMIT %d OFFSET %d"; - $query_params = $params; - $query_params[] = $limit; - $query_params[] = $offset; - $rows = $wpdb->get_results($wpdb->prepare($sql, $query_params), ARRAY_A); - $items = array_map([$this, 'normalize_row'], (array) $rows); - - return [ - 'items' => $items, - 'total' => $total, - 'page' => (int) floor($offset / $limit) + 1, - 'max_pages' => (int) ceil(max(1, $total) / $limit), - ]; - } - - public function get_orders_admin(array $args = [], string $search = ''): array { - global $wpdb; - - [$where, $params] = $this->build_admin_order_filters($args, $search); - - $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); - $offset = max(0, (int) ($args['offset'] ?? 0)); - - $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; - $sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; - $params[] = $limit; - $params[] = $offset; - - $rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); - return array_map([$this, 'normalize_row'], (array) $rows); - } - - /** - * 管理后台订单计数 — 与 get_orders_admin 共用过滤条件 - */ - public function get_orders_count(array $args = [], string $search = ''): int { - global $wpdb; - - [$where, $params] = $this->build_admin_order_filters($args, $search); - - $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; - $sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}"; - - if (!empty($params)) { - return (int) $wpdb->get_var($wpdb->prepare($sql, $params)); - } - return (int) $wpdb->get_var($sql); - } - - /** - * Aggregate paid content (resource) sales by product_id (post ID). - * - * @param array{ - * product_type?: string, - * product_ids?: int[], - * status_in?: string[], - * limit?: int, - * offset?: int, - * orderby?: string, - * order?: string - * } $args - * @return array{items: list, total: int} - */ - public function get_content_sales_summary(array $args = []): array { - global $wpdb; - - $product_type = sanitize_key((string) ($args['product_type'] ?? AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE)); - if ($product_type === '') { - $product_type = AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE; - } - - $status_in = $args['status_in'] ?? AKSRT_Domain_Model::paid_order_statuses(); - if (!is_array($status_in) || empty($status_in)) { - $status_in = AKSRT_Domain_Model::paid_order_statuses(); - } - $status_in = array_values(array_filter(array_map('sanitize_key', $status_in))); - if (empty($status_in)) { - return ['items' => [], 'total' => 0]; - } - - $where = ['product_type = %s', 'product_id > 0']; - $params = [$product_type]; - - $status_placeholders = implode(', ', array_fill(0, count($status_in), '%s')); - $where[] = "status IN ({$status_placeholders})"; - foreach ($status_in as $st) { - $params[] = $st; - } - - $product_ids = $args['product_ids'] ?? []; - if (is_array($product_ids) && !empty($product_ids)) { - $ids = array_values(array_unique(array_filter(array_map('intval', $product_ids), static function ($id) { - return $id > 0; - }))); - if (!empty($ids)) { - $id_ph = implode(', ', array_fill(0, count($ids), '%d')); - $where[] = "product_id IN ({$id_ph})"; - foreach ($ids as $id) { - $params[] = $id; - } - } - } - - $where_sql = 'WHERE ' . implode(' AND ', $where); - $table = $this->table(); - - $total_sql = "SELECT COUNT(DISTINCT product_id) FROM {$table} {$where_sql}"; - $total = (int) $wpdb->get_var($wpdb->prepare($total_sql, $params)); - - $orderby_map = [ - 'sales_count' => 'sales_count', - 'revenue' => 'revenue', - 'avg_amount' => 'avg_amount', - 'product_id' => 'product_id', - ]; - $orderby_key = sanitize_key((string) ($args['orderby'] ?? 'sales_count')); - $orderby_col = $orderby_map[$orderby_key] ?? 'sales_count'; - $order_dir = strtoupper((string) ($args['order'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC'; - - $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); - $offset = max(0, (int) ($args['offset'] ?? 0)); - - $list_sql = "SELECT product_id, - product_type, - COUNT(*) AS sales_count, - AVG(amount) AS avg_amount, - SUM(amount) AS revenue - FROM {$table} - {$where_sql} - GROUP BY product_id, product_type - ORDER BY {$orderby_col} {$order_dir} - LIMIT %d OFFSET %d"; - $list_params = $params; - $list_params[] = $limit; - $list_params[] = $offset; - - $rows = $wpdb->get_results($wpdb->prepare($list_sql, $list_params), ARRAY_A); - $items = []; - foreach ((array) $rows as $row) { - $items[] = [ - 'product_id' => (int) ($row['product_id'] ?? 0), - 'product_type' => (string) ($row['product_type'] ?? $product_type), - 'sales_count' => (int) ($row['sales_count'] ?? 0), - 'avg_amount' => round((float) ($row['avg_amount'] ?? 0), 2), - 'revenue' => round((float) ($row['revenue'] ?? 0), 2), - ]; - } - - return [ - 'items' => $items, - 'total' => $total, - ]; - } - - /** - * Paid order totals for a set of product IDs (resource content). - * - * @param int[] $product_ids - * @return array - */ - public function get_content_sales_map(array $product_ids, string $product_type = ''): array { - $ids = array_values(array_unique(array_filter(array_map('intval', $product_ids), static function ($id) { - return $id > 0; - }))); - if (empty($ids)) { - return []; - } - - if ($product_type === '') { - $product_type = AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE; - } - - $summary = $this->get_content_sales_summary([ - 'product_type' => $product_type, - 'product_ids' => $ids, - 'limit' => max(count($ids), 1), - 'offset' => 0, - 'orderby' => 'product_id', - 'order' => 'ASC', - ]); - - $map = []; - foreach ($summary['items'] as $item) { - $pid = (int) ($item['product_id'] ?? 0); - if ($pid <= 0) { - continue; - } - $map[$pid] = [ - 'sales_count' => (int) ($item['sales_count'] ?? 0), - 'avg_amount' => (float) ($item['avg_amount'] ?? 0), - 'revenue' => (float) ($item['revenue'] ?? 0), - ]; - } - return $map; - } - - /** - * Shared WHERE builder for admin order list/count. - * - * @return array{0: string[], 1: array} - */ - private function build_admin_order_filters(array $args, string $search = ''): array { - global $wpdb; - - $where = []; - $params = []; - - $status = sanitize_key((string) ($args['status'] ?? '')); - if ($status !== '' && $status !== 'all') { - $where[] = 'status = %s'; - $params[] = $status; - } - - $product_type = sanitize_key((string) ($args['product_type'] ?? '')); - if ($product_type !== '') { - $where[] = 'product_type = %s'; - $params[] = $product_type; - } - - $product_id = (int) ($args['product_id'] ?? 0); - if ($product_id > 0) { - $where[] = 'product_id = %d'; - $params[] = $product_id; - } - - $user_id = (int) ($args['user_id'] ?? 0); - if ($user_id > 0) { - $where[] = 'user_id = %d'; - $params[] = $user_id; - } - - $payment_method = sanitize_key((string) ($args['payment_method'] ?? '')); - if ($payment_method !== '') { - $where[] = 'payment_method = %s'; - $params[] = $payment_method; - } - - if ($search !== '') { - // Numeric search may mean user_id or product_id; also match order number / type. - if (ctype_digit($search)) { - $where[] = '(order_number LIKE %s OR product_type LIKE %s OR user_id = %d OR product_id = %d)'; - $like = '%' . $wpdb->esc_like($search) . '%'; - $params[] = $like; - $params[] = $like; - $params[] = (int) $search; - $params[] = (int) $search; - } else { - $where[] = '(order_number LIKE %s OR product_type LIKE %s OR payment_method LIKE %s)'; - $like = '%' . $wpdb->esc_like($search) . '%'; - $params[] = $like; - $params[] = $like; - $params[] = $like; - } - } - - return [$where, $params]; - } - - /** - * Delete closed (cancelled/expired) orders older than $days days. - * Mirrors zibll ZibPay::clear_order(14). - * - * @return int Number of rows deleted. - */ - public function clear_closed_orders(int $days = 14): int { - global $wpdb; - - $days = max(1, min(3650, $days)); - $table = $this->table(); - $cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS)); - // Prefer cancelled_at when set; fall back to created_at for older rows. - $sql = "DELETE FROM {$table} - WHERE status IN ('cancelled', 'expired') - AND COALESCE(NULLIF(cancelled_at, ''), created_at) < %s"; - $deleted = $wpdb->query($wpdb->prepare($sql, $cutoff)); - return $deleted === false ? 0 : (int) $deleted; - } - - /** - * Hard-delete a single order by order_number (admin hygiene). - */ - public function delete_order(string $order_number): bool { - global $wpdb; - $order_number = sanitize_text_field($order_number); - if ($order_number === '') { - return false; - } - $ok = $wpdb->delete($this->table(), ['order_number' => $order_number], ['%s']); - return $ok !== false && $ok > 0; - } - - /** - * 获取订单统计 - */ - public function get_stats(int $user_id = 0): array { - global $wpdb; - - $where = ''; - $params = []; - if ($user_id > 0) { - $where = 'WHERE user_id = %d'; - $params[] = $user_id; - } - - $sql = "SELECT - COUNT(*) as total, - SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending, - SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as paid, - SUM(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END) as delivered, - SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, - SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled, - SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) as refunded, - SUM(CASE WHEN status IN ('paid','delivered','completed') THEN amount ELSE 0 END) as revenue - FROM {$this->table()} {$where}"; - - if (!empty($params)) { - $row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A); - } else { - $row = $wpdb->get_row($sql, ARRAY_A); - } - - return [ - 'total' => (int) ($row['total'] ?? 0), - 'pending' => (int) ($row['pending'] ?? 0), - 'paid' => (int) ($row['paid'] ?? 0), - 'delivered' => (int) ($row['delivered'] ?? 0), - 'completed' => (int) ($row['completed'] ?? 0), - 'cancelled' => (int) ($row['cancelled'] ?? 0), - 'refunded' => (int) ($row['refunded'] ?? 0), - 'revenue' => (float) ($row['revenue'] ?? 0), - ]; - } - - public function has_paid_access(int $user_id, string $product_type, int $product_id): bool { - if ($user_id <= 0 || $product_id <= 0) { - return false; - } - - return null !== $this->find_latest_success_order($user_id, $product_type, $product_id); - } - - public function get_valid_vip_order(int $user_id): ?array { - return $this->find_latest_success_order($user_id, AKSRT_Domain_Model::ORDER_PRODUCT_VIP, 0, true); - } - - /* ------------------------------------------------------------------ - * Migration bridge: option → table - * ----------------------------------------------------------------*/ - - /** - * 一次性迁移:把 option 中的订单搬到表中。 - * 幂等 — 已存在的 order_number 不会重复插入。 - */ - public function migrate_from_option(): int { - global $wpdb; - - $orders = get_option('aksrt_orders', []); - if (!is_array($orders) || empty($orders)) return 0; - - $migrated = 0; - foreach ($orders as $order_number => $data) { - $existing = $wpdb->get_var($wpdb->prepare( - "SELECT id FROM {$this->table()} WHERE order_number = %s", - $order_number - )); - if ($existing) continue; - - $wpdb->insert($this->table(), [ - 'order_number' => $order_number, - 'user_id' => (int) ($data['user_id'] ?? 0), - 'product_type' => sanitize_key((string) ($data['product_type'] ?? '')), - 'product_id' => (int) ($data['product_id'] ?? 0), - 'amount' => (float) ($data['amount'] ?? 0), - 'currency' => sanitize_key((string) ($data['currency'] ?? 'CNY')), - 'status' => AKSRT_Domain_Model::normalize_order_status((string) ($data['status'] ?? AKSRT_Domain_Model::ORDER_STATUS_PENDING)), - 'payment_method' => sanitize_key((string) ($data['payment_method'] ?? '')), - 'created_at' => $data['created_at'] ?? current_time('mysql'), - 'paid_at' => $data['paid_at'] ?? null, - 'delivered_at' => $data['delivered_at'] ?? null, - 'completed_at' => $data['completed_at'] ?? null, - 'cancelled_at' => $data['cancelled_at'] ?? null, - 'refunded_at' => $data['refunded_at'] ?? null, - 'expires_at' => $data['expires_at'] ?? null, - 'note' => '', - ], ['%s', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']); - - if ($wpdb->insert_id) $migrated++; - } - - update_option(AKSRT_Domain_Model::ORDER_MIGRATED_OPTION, time()); - - return $migrated; - } - - /* ------------------------------------------------------------------ - * Internal helpers - * ----------------------------------------------------------------*/ - - private function normalize_row(array $row): array { - $row['id'] = (int) $row['id']; - $row['user_id'] = (int) $row['user_id']; - $row['product_id'] = (int) $row['product_id']; - $row['amount'] = (float) $row['amount']; - $row['status'] = AKSRT_Domain_Model::normalize_order_status((string) ($row['status'] ?? '')); - - $row['meta_payload'] = []; - if (!empty($row['note']) && is_string($row['note'])) { - $decoded = json_decode($row['note'], true); - if (is_array($decoded)) { - $row['meta_payload'] = $decoded; - if (isset($decoded['customer_note']) && is_string($decoded['customer_note'])) { - $row['note_text'] = $decoded['customer_note']; - } - } else { - $row['note_text'] = $row['note']; - } - } - - return $row; - } - - private function find_latest_success_order(int $user_id, string $product_type, int $product_id = 0, bool $ignore_product_id = false): ?array { - global $wpdb; - - if ($user_id <= 0 || $product_type === '') { - return null; - } - - $status_placeholders = implode(', ', array_fill(0, count(AKSRT_Domain_Model::paid_order_statuses()), '%s')); - $params = array_merge( - [$user_id, sanitize_key($product_type)], - $ignore_product_id ? [] : [$product_id], - AKSRT_Domain_Model::paid_order_statuses() - ); - - $product_sql = $ignore_product_id ? '' : ' AND product_id = %d'; - $sql = "SELECT * FROM {$this->table()} - WHERE user_id = %d - AND product_type = %s{$product_sql} - AND status IN ({$status_placeholders}) - ORDER BY COALESCE(paid_at, created_at) DESC, id DESC - LIMIT 1"; - - $row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A); - - return is_array($row) ? $this->normalize_row($row) : null; - } - - private function get_empty_admin_order_context(string $order_number): array { - return [ - 'exists' => false, - 'summary' => [ - 'order_number' => $order_number, - 'status' => '', - 'status_label' => '', - 'amount' => 0.0, - 'currency_symbol' => '¥', - 'created_at' => '', - 'paid_at' => '', - ], - 'buyer' => [], - 'address' => [], - 'payment_method' => [], - 'shipping_method' => [], - 'shipping' => [ - 'count' => 0, - 'latest' => [], - ], - 'after_sale' => [ - 'count' => 0, - 'latest' => [], - ], - 'coupon' => [], - 'pricing' => [ - 'subtotal' => 0.0, - 'shipping_fee' => 0.0, - 'discount' => 0.0, - 'amount' => 0.0, - ], - 'items' => [], - 'customer_note' => '', - 'raw_order' => null, - ]; - } - - /** - * @param array $order - * @param array $related - * @return array - */ - private function build_admin_order_context(array $order, array $related = []): array { - $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; - $user = get_userdata((int) ($order['user_id'] ?? 0)); - $status = (string) ($order['status'] ?? ''); - $address = is_array($meta['address'] ?? null) ? $meta['address'] : []; - $payment_method = is_array($meta['payment_method'] ?? null) ? $meta['payment_method'] : []; - $shipping_method = is_array($meta['shipping_method'] ?? null) ? $meta['shipping_method'] : []; - $coupon = is_array($meta['coupon'] ?? null) ? $meta['coupon'] : []; - $items = is_array($meta['items'] ?? null) ? $meta['items'] : []; - $shipping_fee = (float) ($meta['shipping_fee'] ?? 0); - $discount = (float) ($meta['discount'] ?? 0); - $amount = (float) ($order['amount'] ?? 0); - $subtotal = $this->calculate_order_subtotal($items, max(0, $amount + $discount - $shipping_fee)); - $shipping_summary = is_array($related['shipping_summary'] ?? null) ? $related['shipping_summary'] : []; - $after_sale_summary = is_array($related['after_sale_summary'] ?? null) ? $related['after_sale_summary'] : []; - - $address['full_text'] = $this->build_address_text($address); - - return [ - 'exists' => true, - 'summary' => [ - 'order_number' => (string) ($order['order_number'] ?? ''), - 'status' => $status, - 'status_label' => class_exists('AKSRT_Order') ? (AKSRT_Order::get_valid_statuses()[$status] ?? $status) : $status, - 'amount' => $amount, - 'currency_symbol' => '¥', - 'created_at' => (string) ($order['created_at'] ?? ''), - 'paid_at' => (string) ($order['paid_at'] ?? ''), - 'product_type' => (string) ($order['product_type'] ?? ''), - 'product_id' => (int) ($order['product_id'] ?? 0), - ], - 'buyer' => [ - 'id' => (int) ($order['user_id'] ?? 0), - 'display_name' => $user ? (string) $user->display_name : '', - 'user_login' => $user ? (string) $user->user_login : '', - 'user_email' => $user ? (string) $user->user_email : '', - ], - 'address' => $address, - 'payment_method' => [ - 'value' => (string) ($payment_method['value'] ?? $order['payment_method'] ?? ''), - 'label' => (string) ($payment_method['label'] ?? $order['payment_method'] ?? ''), - ], - 'shipping_method' => [ - 'value' => (string) ($shipping_method['value'] ?? ''), - 'label' => (string) ($shipping_method['label'] ?? ''), - 'fee' => $shipping_fee, - ], - 'shipping' => [ - 'count' => (int) ($shipping_summary['count'] ?? 0), - 'latest' => is_array($shipping_summary['latest'] ?? null) ? $shipping_summary['latest'] : [], - ], - 'after_sale' => [ - 'count' => (int) ($after_sale_summary['count'] ?? 0), - 'latest' => is_array($after_sale_summary['latest'] ?? null) ? $after_sale_summary['latest'] : [], - ], - 'coupon' => $coupon, - 'pricing' => [ - 'subtotal' => $subtotal, - 'shipping_fee' => $shipping_fee, - 'discount' => $discount, - 'amount' => $amount, - ], - 'items' => $items, - 'customer_note' => (string) ($meta['customer_note'] ?? $order['note_text'] ?? ''), - 'raw_order' => $order, - ]; - } - - /** - * @param array> $items - */ - private function calculate_order_subtotal(array $items, float $fallback): float { - if (empty($items)) { - return round($fallback, 2); - } - - $subtotal = 0.0; - foreach ($items as $item) { - if (isset($item['line_total'])) { - $subtotal += (float) $item['line_total']; - continue; - } - - $subtotal += (float) ($item['price'] ?? 0) * max(1, (int) ($item['quantity'] ?? 1)); - } - - if ($subtotal <= 0) { - return round($fallback, 2); - } - - return round($subtotal, 2); - } - - private function build_address_text(array $address): string { - $segments = []; - - foreach (['name', 'phone', 'province', 'city', 'district', 'address'] as $key) { - $value = trim((string) ($address[$key] ?? '')); - if ($value !== '') { - $segments[] = $value; - } - } - - if (!empty($address['full_text']) && is_string($address['full_text'])) { - return (string) $address['full_text']; - } - - return implode(' / ', $segments); - } - - private function generate_order_number(): string { - return 'AKS' . date('YmdHis') . str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT); - } } diff --git a/inc/classes/payment/order-repository-traits/trait-aksrt-order-repository-admin.php b/inc/classes/payment/order-repository-traits/trait-aksrt-order-repository-admin.php new file mode 100644 index 00000000..ef3b2cfb --- /dev/null +++ b/inc/classes/payment/order-repository-traits/trait-aksrt-order-repository-admin.php @@ -0,0 +1,603 @@ +get_admin_order_context_map([$order_number]); + + return $contexts[$order_number] ?? $this->get_empty_admin_order_context($order_number); + } + + + + /** + * Build admin-facing order context payloads in bulk to avoid per-row order queries. + * + * @param array $order_numbers + * @return array> + */ + public function get_admin_order_context_map(array $order_numbers): array { + global $wpdb; + + $order_numbers = array_values(array_unique(array_filter(array_map( + static function ($order_number): string { + return sanitize_text_field((string) $order_number); + }, + $order_numbers + )))); + + if (empty($order_numbers)) { + return []; + } + + $contexts = []; + foreach ($order_numbers as $order_number) { + $contexts[$order_number] = $this->get_empty_admin_order_context($order_number); + } + + $placeholders = implode(', ', array_fill(0, count($order_numbers), '%s')); + $sql = "SELECT * FROM {$this->table()} WHERE order_number IN ({$placeholders})"; + $rows = $wpdb->get_results($wpdb->prepare($sql, $order_numbers), ARRAY_A); + + if (empty($rows)) { + return $contexts; + } + + $orders = []; + foreach ((array) $rows as $row) { + $order = $this->normalize_row($row); + $orders[(string) ($order['order_number'] ?? '')] = $order; + } + + $resolved_order_numbers = array_keys($orders); + $shipping_map = class_exists('AKSRT_Shipping') + ? AKSRT_Shipping::get_instance()->get_summary_map($resolved_order_numbers) + : []; + $after_sale_map = class_exists('AKSRT_After_Sale') + ? AKSRT_After_Sale::get_instance()->get_summary_map($resolved_order_numbers) + : []; + + foreach ($orders as $order_number => $order) { + $contexts[$order_number] = $this->build_admin_order_context($order, [ + 'shipping_summary' => $shipping_map[$order_number] ?? [], + 'after_sale_summary' => $after_sale_map[$order_number] ?? [], + ]); + } + + return $contexts; + } + + + + /** + * 更新订单状态(带状态机验证) + */ + /** + * Merge operator admin note into order note meta without destroying JSON payload. + */ + public function update_admin_note(string $order_number, string $admin_note): bool { + global $wpdb; + + $order = $this->get($order_number); + if (!$order) { + return false; + } + + $raw = isset($order['note']) && is_string($order['note']) ? $order['note'] : ''; + $decoded = $raw !== '' ? json_decode($raw, true) : null; + $admin_note = sanitize_textarea_field($admin_note); + + if (is_array($decoded)) { + $decoded['admin_note'] = $admin_note; + $new_note = wp_json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } elseif ($raw !== '') { + $new_note = wp_json_encode([ + 'customer_note' => $raw, + 'admin_note' => $admin_note, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } else { + $new_note = wp_json_encode([ + 'admin_note' => $admin_note, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + $result = $wpdb->update( + $this->table(), + ['note' => $new_note], + ['order_number' => $order_number], + ['%s'], + ['%s'] + ); + + return $result !== false; + } + + + + /** + * 管理后台订单列表 — 支持 search(order_number / product_type 模糊匹配) + */ + /** + * Paid orders that still need shipping (no shipping row yet). + * + * @return array{items: array>, total: int, page: int, max_pages: int} + */ + public function get_pending_shipment_orders(array $args = []): array { + global $wpdb; + + $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); + $offset = max(0, (int) ($args['offset'] ?? 0)); + $search = sanitize_text_field((string) ($args['search'] ?? $args['s'] ?? '')); + + $order_table = $this->table(); + // Must match AKSRT_Shipping::table() key. + $shipping_table = class_exists('AKSRT_DB_Migrator') + ? AKSRT_DB_Migrator::table('order_shipping') + : $wpdb->prefix . 'aksrt_order_shipping'; + + $where = ["o.status = %s"]; + $params = ['paid']; + + if ($search !== '') { + $where[] = '(o.order_number LIKE %s OR o.product_type LIKE %s)'; + $like = '%' . $wpdb->esc_like($search) . '%'; + $params[] = $like; + $params[] = $like; + } + + // NOT EXISTS avoids ONLY_FULL_GROUP_BY issues from LEFT JOIN + GROUP BY. + $where[] = "NOT EXISTS ( + SELECT 1 FROM {$shipping_table} s + WHERE s.order_number = o.order_number + )"; + $where_sql = 'WHERE ' . implode(' AND ', $where); + + $count_sql = "SELECT COUNT(*) FROM {$order_table} o {$where_sql}"; + $total = (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)); + + $sql = "SELECT o.* + FROM {$order_table} o + {$where_sql} + ORDER BY COALESCE(o.paid_at, o.created_at) DESC, o.id DESC + LIMIT %d OFFSET %d"; + $query_params = $params; + $query_params[] = $limit; + $query_params[] = $offset; + $rows = $wpdb->get_results($wpdb->prepare($sql, $query_params), ARRAY_A); + $items = array_map([$this, 'normalize_row'], (array) $rows); + + return [ + 'items' => $items, + 'total' => $total, + 'page' => (int) floor($offset / $limit) + 1, + 'max_pages' => (int) ceil(max(1, $total) / $limit), + ]; + } + + + + public function get_orders_admin(array $args = [], string $search = ''): array { + global $wpdb; + + [$where, $params] = $this->build_admin_order_filters($args, $search); + + $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); + $offset = max(0, (int) ($args['offset'] ?? 0)); + + $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; + $sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; + $params[] = $limit; + $params[] = $offset; + + $rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); + return array_map([$this, 'normalize_row'], (array) $rows); + } + + + + /** + * 管理后台订单计数 — 与 get_orders_admin 共用过滤条件 + */ + public function get_orders_count(array $args = [], string $search = ''): int { + global $wpdb; + + [$where, $params] = $this->build_admin_order_filters($args, $search); + + $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; + $sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}"; + + if (!empty($params)) { + return (int) $wpdb->get_var($wpdb->prepare($sql, $params)); + } + return (int) $wpdb->get_var($sql); + } + + + + /** + * Aggregate paid content (resource) sales by product_id (post ID). + * + * @param array{ + * product_type?: string, + * product_ids?: int[], + * status_in?: string[], + * limit?: int, + * offset?: int, + * orderby?: string, + * order?: string + * } $args + * @return array{items: list, total: int} + */ + public function get_content_sales_summary(array $args = []): array { + global $wpdb; + + $product_type = sanitize_key((string) ($args['product_type'] ?? AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE)); + if ($product_type === '') { + $product_type = AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE; + } + + $status_in = $args['status_in'] ?? AKSRT_Domain_Model::paid_order_statuses(); + if (!is_array($status_in) || empty($status_in)) { + $status_in = AKSRT_Domain_Model::paid_order_statuses(); + } + $status_in = array_values(array_filter(array_map('sanitize_key', $status_in))); + if (empty($status_in)) { + return ['items' => [], 'total' => 0]; + } + + $where = ['product_type = %s', 'product_id > 0']; + $params = [$product_type]; + + $status_placeholders = implode(', ', array_fill(0, count($status_in), '%s')); + $where[] = "status IN ({$status_placeholders})"; + foreach ($status_in as $st) { + $params[] = $st; + } + + $product_ids = $args['product_ids'] ?? []; + if (is_array($product_ids) && !empty($product_ids)) { + $ids = array_values(array_unique(array_filter(array_map('intval', $product_ids), static function ($id) { + return $id > 0; + }))); + if (!empty($ids)) { + $id_ph = implode(', ', array_fill(0, count($ids), '%d')); + $where[] = "product_id IN ({$id_ph})"; + foreach ($ids as $id) { + $params[] = $id; + } + } + } + + $where_sql = 'WHERE ' . implode(' AND ', $where); + $table = $this->table(); + + $total_sql = "SELECT COUNT(DISTINCT product_id) FROM {$table} {$where_sql}"; + $total = (int) $wpdb->get_var($wpdb->prepare($total_sql, $params)); + + $orderby_map = [ + 'sales_count' => 'sales_count', + 'revenue' => 'revenue', + 'avg_amount' => 'avg_amount', + 'product_id' => 'product_id', + ]; + $orderby_key = sanitize_key((string) ($args['orderby'] ?? 'sales_count')); + $orderby_col = $orderby_map[$orderby_key] ?? 'sales_count'; + $order_dir = strtoupper((string) ($args['order'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC'; + + $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); + $offset = max(0, (int) ($args['offset'] ?? 0)); + + $list_sql = "SELECT product_id, + product_type, + COUNT(*) AS sales_count, + AVG(amount) AS avg_amount, + SUM(amount) AS revenue + FROM {$table} + {$where_sql} + GROUP BY product_id, product_type + ORDER BY {$orderby_col} {$order_dir} + LIMIT %d OFFSET %d"; + $list_params = $params; + $list_params[] = $limit; + $list_params[] = $offset; + + $rows = $wpdb->get_results($wpdb->prepare($list_sql, $list_params), ARRAY_A); + $items = []; + foreach ((array) $rows as $row) { + $items[] = [ + 'product_id' => (int) ($row['product_id'] ?? 0), + 'product_type' => (string) ($row['product_type'] ?? $product_type), + 'sales_count' => (int) ($row['sales_count'] ?? 0), + 'avg_amount' => round((float) ($row['avg_amount'] ?? 0), 2), + 'revenue' => round((float) ($row['revenue'] ?? 0), 2), + ]; + } + + return [ + 'items' => $items, + 'total' => $total, + ]; + } + + + + /** + * Paid order totals for a set of product IDs (resource content). + * + * @param int[] $product_ids + * @return array + */ + public function get_content_sales_map(array $product_ids, string $product_type = ''): array { + $ids = array_values(array_unique(array_filter(array_map('intval', $product_ids), static function ($id) { + return $id > 0; + }))); + if (empty($ids)) { + return []; + } + + if ($product_type === '') { + $product_type = AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE; + } + + $summary = $this->get_content_sales_summary([ + 'product_type' => $product_type, + 'product_ids' => $ids, + 'limit' => max(count($ids), 1), + 'offset' => 0, + 'orderby' => 'product_id', + 'order' => 'ASC', + ]); + + $map = []; + foreach ($summary['items'] as $item) { + $pid = (int) ($item['product_id'] ?? 0); + if ($pid <= 0) { + continue; + } + $map[$pid] = [ + 'sales_count' => (int) ($item['sales_count'] ?? 0), + 'avg_amount' => (float) ($item['avg_amount'] ?? 0), + 'revenue' => (float) ($item['revenue'] ?? 0), + ]; + } + return $map; + } + + + + /** + * Shared WHERE builder for admin order list/count. + * + * @return array{0: string[], 1: array} + */ + private function build_admin_order_filters(array $args, string $search = ''): array { + global $wpdb; + + $where = []; + $params = []; + + $status = sanitize_key((string) ($args['status'] ?? '')); + if ($status !== '' && $status !== 'all') { + $where[] = 'status = %s'; + $params[] = $status; + } + + $product_type = sanitize_key((string) ($args['product_type'] ?? '')); + if ($product_type !== '') { + $where[] = 'product_type = %s'; + $params[] = $product_type; + } + + $product_id = (int) ($args['product_id'] ?? 0); + if ($product_id > 0) { + $where[] = 'product_id = %d'; + $params[] = $product_id; + } + + $user_id = (int) ($args['user_id'] ?? 0); + if ($user_id > 0) { + $where[] = 'user_id = %d'; + $params[] = $user_id; + } + + $payment_method = sanitize_key((string) ($args['payment_method'] ?? '')); + if ($payment_method !== '') { + $where[] = 'payment_method = %s'; + $params[] = $payment_method; + } + + if ($search !== '') { + // Numeric search may mean user_id or product_id; also match order number / type. + if (ctype_digit($search)) { + $where[] = '(order_number LIKE %s OR product_type LIKE %s OR user_id = %d OR product_id = %d)'; + $like = '%' . $wpdb->esc_like($search) . '%'; + $params[] = $like; + $params[] = $like; + $params[] = (int) $search; + $params[] = (int) $search; + } else { + $where[] = '(order_number LIKE %s OR product_type LIKE %s OR payment_method LIKE %s)'; + $like = '%' . $wpdb->esc_like($search) . '%'; + $params[] = $like; + $params[] = $like; + $params[] = $like; + } + } + + return [$where, $params]; + } + + + + private function get_empty_admin_order_context(string $order_number): array { + return [ + 'exists' => false, + 'summary' => [ + 'order_number' => $order_number, + 'status' => '', + 'status_label' => '', + 'amount' => 0.0, + 'currency_symbol' => '¥', + 'created_at' => '', + 'paid_at' => '', + ], + 'buyer' => [], + 'address' => [], + 'payment_method' => [], + 'shipping_method' => [], + 'shipping' => [ + 'count' => 0, + 'latest' => [], + ], + 'after_sale' => [ + 'count' => 0, + 'latest' => [], + ], + 'coupon' => [], + 'pricing' => [ + 'subtotal' => 0.0, + 'shipping_fee' => 0.0, + 'discount' => 0.0, + 'amount' => 0.0, + ], + 'items' => [], + 'customer_note' => '', + 'raw_order' => null, + ]; + } + + + + /** + * @param array $order + * @param array $related + * @return array + */ + private function build_admin_order_context(array $order, array $related = []): array { + $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; + $user = get_userdata((int) ($order['user_id'] ?? 0)); + $status = (string) ($order['status'] ?? ''); + $address = is_array($meta['address'] ?? null) ? $meta['address'] : []; + $payment_method = is_array($meta['payment_method'] ?? null) ? $meta['payment_method'] : []; + $shipping_method = is_array($meta['shipping_method'] ?? null) ? $meta['shipping_method'] : []; + $coupon = is_array($meta['coupon'] ?? null) ? $meta['coupon'] : []; + $items = is_array($meta['items'] ?? null) ? $meta['items'] : []; + $shipping_fee = (float) ($meta['shipping_fee'] ?? 0); + $discount = (float) ($meta['discount'] ?? 0); + $amount = (float) ($order['amount'] ?? 0); + $subtotal = $this->calculate_order_subtotal($items, max(0, $amount + $discount - $shipping_fee)); + $shipping_summary = is_array($related['shipping_summary'] ?? null) ? $related['shipping_summary'] : []; + $after_sale_summary = is_array($related['after_sale_summary'] ?? null) ? $related['after_sale_summary'] : []; + + $address['full_text'] = $this->build_address_text($address); + + return [ + 'exists' => true, + 'summary' => [ + 'order_number' => (string) ($order['order_number'] ?? ''), + 'status' => $status, + 'status_label' => class_exists('AKSRT_Order') ? (AKSRT_Order::get_valid_statuses()[$status] ?? $status) : $status, + 'amount' => $amount, + 'currency_symbol' => '¥', + 'created_at' => (string) ($order['created_at'] ?? ''), + 'paid_at' => (string) ($order['paid_at'] ?? ''), + 'product_type' => (string) ($order['product_type'] ?? ''), + 'product_id' => (int) ($order['product_id'] ?? 0), + ], + 'buyer' => [ + 'id' => (int) ($order['user_id'] ?? 0), + 'display_name' => $user ? (string) $user->display_name : '', + 'user_login' => $user ? (string) $user->user_login : '', + 'user_email' => $user ? (string) $user->user_email : '', + ], + 'address' => $address, + 'payment_method' => [ + 'value' => (string) ($payment_method['value'] ?? $order['payment_method'] ?? ''), + 'label' => (string) ($payment_method['label'] ?? $order['payment_method'] ?? ''), + ], + 'shipping_method' => [ + 'value' => (string) ($shipping_method['value'] ?? ''), + 'label' => (string) ($shipping_method['label'] ?? ''), + 'fee' => $shipping_fee, + ], + 'shipping' => [ + 'count' => (int) ($shipping_summary['count'] ?? 0), + 'latest' => is_array($shipping_summary['latest'] ?? null) ? $shipping_summary['latest'] : [], + ], + 'after_sale' => [ + 'count' => (int) ($after_sale_summary['count'] ?? 0), + 'latest' => is_array($after_sale_summary['latest'] ?? null) ? $after_sale_summary['latest'] : [], + ], + 'coupon' => $coupon, + 'pricing' => [ + 'subtotal' => $subtotal, + 'shipping_fee' => $shipping_fee, + 'discount' => $discount, + 'amount' => $amount, + ], + 'items' => $items, + 'customer_note' => (string) ($meta['customer_note'] ?? $order['note_text'] ?? ''), + 'raw_order' => $order, + ]; + } + + + + /** + * @param array> $items + */ + private function calculate_order_subtotal(array $items, float $fallback): float { + if (empty($items)) { + return round($fallback, 2); + } + + $subtotal = 0.0; + foreach ($items as $item) { + if (isset($item['line_total'])) { + $subtotal += (float) $item['line_total']; + continue; + } + + $subtotal += (float) ($item['price'] ?? 0) * max(1, (int) ($item['quantity'] ?? 1)); + } + + if ($subtotal <= 0) { + return round($fallback, 2); + } + + return round($subtotal, 2); + } + + + + private function build_address_text(array $address): string { + $segments = []; + + foreach (['name', 'phone', 'province', 'city', 'district', 'address'] as $key) { + $value = trim((string) ($address[$key] ?? '')); + if ($value !== '') { + $segments[] = $value; + } + } + + if (!empty($address['full_text']) && is_string($address['full_text'])) { + return (string) $address['full_text']; + } + + return implode(' / ', $segments); + } + +} diff --git a/inc/classes/payment/order-repository-traits/trait-aksrt-order-repository-core.php b/inc/classes/payment/order-repository-traits/trait-aksrt-order-repository-core.php new file mode 100644 index 00000000..452c3c5a --- /dev/null +++ b/inc/classes/payment/order-repository-traits/trait-aksrt-order-repository-core.php @@ -0,0 +1,481 @@ +generate_order_number(); + + $wpdb->insert($this->table(), [ + 'order_number' => $order_number, + 'user_id' => (int) ($data['user_id'] ?? 0), + 'product_type' => sanitize_key((string) ($data['product_type'] ?? '')), + 'product_id' => (int) ($data['product_id'] ?? 0), + 'amount' => (float) ($data['amount'] ?? 0), + 'currency' => sanitize_key((string) ($data['currency'] ?? 'CNY')), + 'status' => AKSRT_Domain_Model::normalize_order_status((string) ($data['status'] ?? AKSRT_Domain_Model::ORDER_STATUS_PENDING)), + 'payment_method' => sanitize_key((string) ($data['payment_method'] ?? '')), + 'payment_transaction_id' => sanitize_text_field((string) ($data['payment_transaction_id'] ?? '')), + 'created_at' => $data['created_at'] ?? current_time('mysql'), + 'paid_at' => $data['paid_at'] ?? null, + 'expires_at' => $data['expires_at'] ?? null, + 'note' => sanitize_textarea_field((string) ($data['note'] ?? '')), + ], ['%s', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']); + + if ($wpdb->insert_id) { + return $order_number; + } + + return ''; + } + + + + /** + * 获取单个订单 + */ + public function get(string $order_number): ?array { + global $wpdb; + + $row = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM {$this->table()} WHERE order_number = %s", + $order_number + ), ARRAY_A); + + if ($row) { + return $this->normalize_row($row); + } + + return null; + } + + + + /** + * 获取用户订单列表 + */ + public function get_user_orders(int $user_id, array $args = []): array { + global $wpdb; + + $where = []; + $params = []; + + if ($user_id > 0) { + $where[] = 'user_id = %d'; + $params[] = $user_id; + } + + $status = sanitize_key((string) ($args['status'] ?? '')); + if ($status !== '') { + $where[] = 'status = %s'; + $params[] = $status; + } + + $product_type = sanitize_key((string) ($args['product_type'] ?? '')); + if ($product_type !== '') { + $where[] = 'product_type = %s'; + $params[] = $product_type; + } + + $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); + $offset = max(0, (int) ($args['offset'] ?? 0)); + + $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; + $sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; + $params[] = $limit; + $params[] = $offset; + + $rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); + + return array_map([$this, 'normalize_row'], (array) $rows); + } + + + + /** + * 获取单个用户订单。 + */ + public function get_user_order(int $user_id, string $order_number): ?array { + $order = $this->get($order_number); + if (!$order) { + return null; + } + + if ($user_id > 0 && (int) ($order['user_id'] ?? 0) !== $user_id) { + return null; + } + + return $order; + } + + + + /** + * 获取用户订单总数。 + */ + public function count_user_orders(int $user_id, array $args = []): int { + global $wpdb; + + if ($user_id <= 0) { + return 0; + } + + $where = ['user_id = %d']; + $params = [$user_id]; + + $status = sanitize_key((string) ($args['status'] ?? '')); + if ($status !== '') { + $where[] = 'status = %s'; + $params[] = $status; + } + + $product_type = sanitize_key((string) ($args['product_type'] ?? '')); + if ($product_type !== '') { + $where[] = 'product_type = %s'; + $params[] = $product_type; + } + + $where_sql = 'WHERE ' . implode(' AND ', $where); + $sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}"; + + return (int) $wpdb->get_var($wpdb->prepare($sql, $params)); + } + + + + public function update_status(string $order_number, string $new_status, array $extra = []): bool { + global $wpdb; + + $valid = AKSRT_Domain_Model::order_status_labels(); + if (!isset($valid[$new_status])) return false; + + $order = $this->get($order_number); + if (!$order) return false; + + $current_status = $order['status'] ?? 'created'; + + if (empty($extra['force']) && !AKSRT_Domain_Model::can_transition_order_status($current_status, $new_status)) { + return false; + } + + $update_data = ['status' => $new_status]; + $format = ['%s']; + + $time_fields = [ + 'paid' => 'paid_at', + 'delivered' => 'delivered_at', + 'completed' => 'completed_at', + 'cancelled' => 'cancelled_at', + 'refunded' => 'refunded_at', + ]; + + foreach ($time_fields as $status_key => $field) { + if ($new_status === $status_key) { + $update_data[$field] = current_time('mysql'); + $format[] = '%s'; + } + } + + if (!empty($extra['payment_method'])) { + $update_data['payment_method'] = sanitize_key($extra['payment_method']); + $format[] = '%s'; + } + + if (!empty($extra['payment_transaction_id'])) { + $update_data['payment_transaction_id'] = sanitize_text_field($extra['payment_transaction_id']); + $format[] = '%s'; + } + + if (!empty($extra['note'])) { + // Preserve JSON pay meta (coupon/topic_pay/content_pay/…); stash human text as status_note. + $status_note = sanitize_textarea_field((string) $extra['note']); + $raw_note = isset($order['note']) && is_string($order['note']) ? $order['note'] : ''; + $decoded_note = $raw_note !== '' ? json_decode($raw_note, true) : null; + if (is_array($decoded_note)) { + $decoded_note['status_note'] = $status_note; + $merged = wp_json_encode($decoded_note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $update_data['note'] = is_string($merged) ? $merged : $status_note; + } else { + $update_data['note'] = $status_note; + } + $format[] = '%s'; + } + + $result = $wpdb->update($this->table(), $update_data, ['order_number' => $order_number], $format, ['%s']); + + if ($result !== false) { + // 到账事件:让充值/VIP/商品履约等挂接(单一履约入口)。 + // 仅在「首次」转为 paid 时触发,避免回调多次到达重复履约。 + if ($new_status === AKSRT_Domain_Model::ORDER_STATUS_PAID && $current_status !== AKSRT_Domain_Model::ORDER_STATUS_PAID) { + $paid_order = array_merge($order, [ + 'status' => $new_status, + 'payment_method' => $update_data['payment_method'] ?? ($order['payment_method'] ?? ''), + 'payment_transaction_id' => $update_data['payment_transaction_id'] ?? ($order['payment_transaction_id'] ?? ''), + ]); + if (isset($update_data['note'])) { + // Keep pre-update JSON payload for listeners (coupon burn etc.); + // only attach status_note if callers mutated note. + $pre_meta = []; + $pre_raw = isset($order['note']) && is_string($order['note']) ? $order['note'] : ''; + if ($pre_raw !== '') { + $pre_decoded = json_decode($pre_raw, true); + if (is_array($pre_decoded)) { + $pre_meta = $pre_decoded; + } + } + if ($pre_meta !== []) { + $paid_order['meta_payload'] = $pre_meta; + // Prefer merged DB note (JSON + status_note) for downstream reads. + $paid_order['note'] = $update_data['note']; + } + } + + /** + * 订单到账。 + * + * @param string $order_number 订单号 + * @param array $paid_order 订单数据(status=paid) + */ + do_action('aksrt_order_paid', $order_number, $paid_order); + } + } + + return $result !== false; + } + + + + /** + * Delete closed (cancelled/expired) orders older than $days days. + * Mirrors zibll ZibPay::clear_order(14). + * + * @return int Number of rows deleted. + */ + public function clear_closed_orders(int $days = 14): int { + global $wpdb; + + $days = max(1, min(3650, $days)); + $table = $this->table(); + $cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS)); + // Prefer cancelled_at when set; fall back to created_at for older rows. + $sql = "DELETE FROM {$table} + WHERE status IN ('cancelled', 'expired') + AND COALESCE(NULLIF(cancelled_at, ''), created_at) < %s"; + $deleted = $wpdb->query($wpdb->prepare($sql, $cutoff)); + return $deleted === false ? 0 : (int) $deleted; + } + + + + /** + * Hard-delete a single order by order_number (admin hygiene). + */ + public function delete_order(string $order_number): bool { + global $wpdb; + $order_number = sanitize_text_field($order_number); + if ($order_number === '') { + return false; + } + $ok = $wpdb->delete($this->table(), ['order_number' => $order_number], ['%s']); + return $ok !== false && $ok > 0; + } + + + + /** + * 获取订单统计 + */ + public function get_stats(int $user_id = 0): array { + global $wpdb; + + $where = ''; + $params = []; + if ($user_id > 0) { + $where = 'WHERE user_id = %d'; + $params[] = $user_id; + } + + $sql = "SELECT + COUNT(*) as total, + SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending, + SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as paid, + SUM(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END) as delivered, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, + SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled, + SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) as refunded, + SUM(CASE WHEN status IN ('paid','delivered','completed') THEN amount ELSE 0 END) as revenue + FROM {$this->table()} {$where}"; + + if (!empty($params)) { + $row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A); + } else { + $row = $wpdb->get_row($sql, ARRAY_A); + } + + return [ + 'total' => (int) ($row['total'] ?? 0), + 'pending' => (int) ($row['pending'] ?? 0), + 'paid' => (int) ($row['paid'] ?? 0), + 'delivered' => (int) ($row['delivered'] ?? 0), + 'completed' => (int) ($row['completed'] ?? 0), + 'cancelled' => (int) ($row['cancelled'] ?? 0), + 'refunded' => (int) ($row['refunded'] ?? 0), + 'revenue' => (float) ($row['revenue'] ?? 0), + ]; + } + + + + public function has_paid_access(int $user_id, string $product_type, int $product_id): bool { + if ($user_id <= 0 || $product_id <= 0) { + return false; + } + + return null !== $this->find_latest_success_order($user_id, $product_type, $product_id); + } + + + + public function get_valid_vip_order(int $user_id): ?array { + return $this->find_latest_success_order($user_id, AKSRT_Domain_Model::ORDER_PRODUCT_VIP, 0, true); + } + + + + /* ------------------------------------------------------------------ + * Migration bridge: option → table + * ----------------------------------------------------------------*/ + + /** + * 一次性迁移:把 option 中的订单搬到表中。 + * 幂等 — 已存在的 order_number 不会重复插入。 + */ + public function migrate_from_option(): int { + global $wpdb; + + $orders = get_option('aksrt_orders', []); + if (!is_array($orders) || empty($orders)) return 0; + + $migrated = 0; + foreach ($orders as $order_number => $data) { + $existing = $wpdb->get_var($wpdb->prepare( + "SELECT id FROM {$this->table()} WHERE order_number = %s", + $order_number + )); + if ($existing) continue; + + $wpdb->insert($this->table(), [ + 'order_number' => $order_number, + 'user_id' => (int) ($data['user_id'] ?? 0), + 'product_type' => sanitize_key((string) ($data['product_type'] ?? '')), + 'product_id' => (int) ($data['product_id'] ?? 0), + 'amount' => (float) ($data['amount'] ?? 0), + 'currency' => sanitize_key((string) ($data['currency'] ?? 'CNY')), + 'status' => AKSRT_Domain_Model::normalize_order_status((string) ($data['status'] ?? AKSRT_Domain_Model::ORDER_STATUS_PENDING)), + 'payment_method' => sanitize_key((string) ($data['payment_method'] ?? '')), + 'created_at' => $data['created_at'] ?? current_time('mysql'), + 'paid_at' => $data['paid_at'] ?? null, + 'delivered_at' => $data['delivered_at'] ?? null, + 'completed_at' => $data['completed_at'] ?? null, + 'cancelled_at' => $data['cancelled_at'] ?? null, + 'refunded_at' => $data['refunded_at'] ?? null, + 'expires_at' => $data['expires_at'] ?? null, + 'note' => '', + ], ['%s', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']); + + if ($wpdb->insert_id) $migrated++; + } + + update_option(AKSRT_Domain_Model::ORDER_MIGRATED_OPTION, time()); + + return $migrated; + } + + + + /* ------------------------------------------------------------------ + * Internal helpers + * ----------------------------------------------------------------*/ + + private function normalize_row(array $row): array { + $row['id'] = (int) $row['id']; + $row['user_id'] = (int) $row['user_id']; + $row['product_id'] = (int) $row['product_id']; + $row['amount'] = (float) $row['amount']; + $row['status'] = AKSRT_Domain_Model::normalize_order_status((string) ($row['status'] ?? '')); + + $row['meta_payload'] = []; + if (!empty($row['note']) && is_string($row['note'])) { + $decoded = json_decode($row['note'], true); + if (is_array($decoded)) { + $row['meta_payload'] = $decoded; + if (isset($decoded['customer_note']) && is_string($decoded['customer_note'])) { + $row['note_text'] = $decoded['customer_note']; + } + } else { + $row['note_text'] = $row['note']; + } + } + + return $row; + } + + + + private function find_latest_success_order(int $user_id, string $product_type, int $product_id = 0, bool $ignore_product_id = false): ?array { + global $wpdb; + + if ($user_id <= 0 || $product_type === '') { + return null; + } + + $status_placeholders = implode(', ', array_fill(0, count(AKSRT_Domain_Model::paid_order_statuses()), '%s')); + $params = array_merge( + [$user_id, sanitize_key($product_type)], + $ignore_product_id ? [] : [$product_id], + AKSRT_Domain_Model::paid_order_statuses() + ); + + $product_sql = $ignore_product_id ? '' : ' AND product_id = %d'; + $sql = "SELECT * FROM {$this->table()} + WHERE user_id = %d + AND product_type = %s{$product_sql} + AND status IN ({$status_placeholders}) + ORDER BY COALESCE(paid_at, created_at) DESC, id DESC + LIMIT 1"; + + $row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A); + + return is_array($row) ? $this->normalize_row($row) : null; + } + + + + private function generate_order_number(): string { + return 'AKS' . date('YmdHis') . str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT); + } + +} diff --git a/inc/classes/settings/class-aksrt-settings.php b/inc/classes/settings/class-aksrt-settings.php index e2d15094..57c913a7 100644 --- a/inc/classes/settings/class-aksrt-settings.php +++ b/inc/classes/settings/class-aksrt-settings.php @@ -10,6 +10,7 @@ defined('ABSPATH') || exit; class AKSRT_Settings { + use AKSRT_Settings_Domain_Groups; use AKSRT_Settings_Display_Page_Groups; use AKSRT_Settings_Credits_Payment_Groups; @@ -18,14 +19,21 @@ class AKSRT_Settings { use AKSRT_Settings_Homepage_Reader; private static $instance = null; + use AKSRT_Settings_Facade_Bulk; + + private function __construct() {} + + public static function get_instance(): self { if (null === self::$instance) self::$instance = new self(); return self::$instance; } + + public function init(): void { // 注册设置API(可选) add_action('customize_register', [$this, 'customizer']); @@ -51,15 +59,7 @@ class AKSRT_Settings { add_action('deleted_option', [self::class, 'on_option_updated'], 10, 1); } - /** - * Per-request memoization of merged settings. - * - * 同一请求内多次调用 get_all() 仅读 1 次 option。 - * 后端写入 option 时通过 updated_option hook 失效。 - * - * @var array|null - */ - private static $cached_all = null; + /** * Option 变更时失效缓存(响应所有主题相关 option key)。 @@ -75,6 +75,8 @@ class AKSRT_Settings { } } + + /** * 文章变更时清理分类推荐缓存 */ @@ -85,6 +87,8 @@ class AKSRT_Settings { self::flush_category_recommend_cache(); } + + // ============================================ // 设置读取方法 // ============================================ @@ -105,6 +109,8 @@ class AKSRT_Settings { return $settings; } + + /** * 显式刷新缓存(外部直接修改 option 后可调用)。 */ @@ -112,36 +118,7 @@ class AKSRT_Settings { self::$cached_all = null; } - // ============================================ - // Option Registry — domain/section → option key 映射 - // ============================================ - /** - * 所有独立 option key(非 aksrt_settings)。 - * 用于导入导出完整性检查。 - */ - public const STANDALONE_OPTION_KEYS = [ - 'aksrt_mail_smtp', - 'aksrt_oauth', - 'aksrt_captcha_config', - 'aksrt_moderation_config', - 'aksrt_footer_settings', - 'aksrt_footer_tabbar', - 'aksrt_floating_buttons', - 'aksrt_credit_settings', - 'aksrt_notification_settings', - 'aksrt_friend_links', - 'aksrt_banners', - 'aksrt_category_recommend', - ]; - - /** - * 包含 secret 字段的 option key — 空值保留旧值策略。 - */ - public const SECRET_OPTION_KEYS = [ - 'aksrt_mail_smtp', - 'aksrt_oauth', - ]; /** * 读取指定 domain/section 的配置(委托 Schema Provider)。 @@ -155,6 +132,8 @@ class AKSRT_Settings { return $provider->read_settings($domain, $section); } + + /** * 写入指定 domain/section 的配置(委托 Schema Provider)。 * @@ -179,6 +158,8 @@ class AKSRT_Settings { return $result; } + + /** * 获取指定 domain/section 的默认值。 */ @@ -187,6 +168,8 @@ class AKSRT_Settings { return $provider->get_defaults($domain, $section); } + + /** * 重置指定 domain/section 到默认值。 */ @@ -202,223 +185,141 @@ class AKSRT_Settings { return $result; } + + public static function get_value(string $key, $default = null) { $settings = self::get_all(); return array_key_exists($key, $settings) ? $settings[$key] : $default; } - /** 获取导航 Logo 宽度 */ - public static function logo_width(): int { - $width = (int) self::get_value('logo_width', 150); - return max(50, min(400, $width)); - } - /** 获取Favicon URL */ - public static function custom_favicon_url(): string { - $favicon = (string) self::get_value('site_favicon', ''); - return $favicon !== '' ? $favicon : ''; - } - - public static function favicon_url(): string { - $custom = self::custom_favicon_url(); - if ($custom !== '') { - return $custom; - } - - $site_icon = get_site_icon_url(512); - return $site_icon ? (string) $site_icon : self::default_asset_url('default-favicon.svg'); - } - - /** 获取About显示名 */ - /** - * 让 SEO 设置中的 Favicon 覆盖 WordPress 原生 site_icon 输出链路。 - * - * @param string $url - * @param int $size - * @param int $blog_id - * @return string - */ - public static function filter_site_icon_url($url, $size, $blog_id): string { - $custom = self::custom_favicon_url(); - return $custom !== '' ? $custom : (string) $url; - } - - /** - * 尝试把自定义 favicon 同步到 WordPress 原生 site_icon 选项。 - * - * 仅当 URL 能映射到媒体库附件时才同步;外链仍通过 get_site_icon_url 过滤覆盖。 - */ - public static function sync_native_site_icon(string $favicon_url): void { - $favicon_url = trim(esc_url_raw($favicon_url)); - - if ($favicon_url === '') { - return; - } - - $attachment_id = attachment_url_to_postid($favicon_url); - if ($attachment_id > 0 && (int) get_option('site_icon', 0) !== $attachment_id) { - update_option('site_icon', $attachment_id); - } - } - - /** 启动时对齐原生 site_icon,同步自定义 site_favicon 到原生 site_icon。 */ - public static function maybe_sync_native_site_icon(): void { - $custom = self::custom_favicon_url(); - if ($custom === '') { - return; - } - - self::sync_native_site_icon($custom); - } - - public static function about_display_name(): string { - $s = self::get_all(); - return !empty($s['about_display_name']) ? $s['about_display_name'] : get_bloginfo('name'); - } - - /** 获取About简介 */ - public static function about_bio(): string { - $s = self::get_all(); - return !empty($s['about_bio']) ? $s['about_bio'] : get_bloginfo('description'); - } - - /** 获取About头像URL */ - public static function about_avatar(): string { - $s = self::get_all(); - return $s['about_avatar'] ?? ($s['admin_avatar_url'] ?? ''); - } - - /** 获取头像镜像源 */ - public static function avatar_source(): string { - $source = sanitize_text_field((string) self::get_value('avatar_source', 'cn.cravatar.com')); - $allowed = ['cn.cravatar.com', 'cn.gravatar.com', 'gravatar.com']; - return in_array($source, $allowed, true) ? $source : 'cn.cravatar.com'; - } - - /** 获取SEO关键词 */ - public static function seo_keywords(): string { - $s = self::get_all(); - return $s['seo_keywords'] ?? ''; - } - - /** 获取自定义头部代码 */ - public static function custom_head_code(): string { - $code = (string) self::get_value('head_code', ''); - if ($code === '') { - $code = (string) self::get_value('custom_js_header', ''); - } - return $code; - } - - /** 获取自定义 CSS */ - public static function custom_css(): string { - return (string) self::get_value('custom_css', ''); - } - - /** 获取自定义底部代码 */ - public static function custom_footer_code(): string { - $code = (string) self::get_value('foot_code', ''); - if ($code === '') { - $code = (string) self::get_value('custom_js_footer', ''); - } - return $code; - } - - /** 获取统计代码 */ - public static function trackcode(): string { - return trim((string) self::get_value('trackcode', '')); - } /** 获取搜索框占位文案 */ public static function search_placeholder(): string { return AKSRT_Settings_Theme_Search::search_placeholder(); } + + /** 获取搜索模式 */ public static function search_mode(): string { return AKSRT_Settings_Theme_Search::search_mode(); } + + /** 是否启用搜索历史 */ public static function search_history_enabled(): bool { return AKSRT_Settings_Theme_Search::search_history_enabled(); } + + /** 是否启用热门搜索 */ public static function hot_search_enabled(): bool { return AKSRT_Settings_Theme_Search::hot_search_enabled(); } + + /** 获取热门搜索关键词 */ public static function hot_search_keywords(): array { return AKSRT_Settings_Theme_Search::hot_search_keywords(); } + + /** 热门搜索标题(留空使用默认) */ public static function search_popular_title(): string { return AKSRT_Settings_Theme_Search::search_popular_title(); } + + /** 置顶搜索词(始终显示在最前面) */ public static function search_popular_sticky(): array { return AKSRT_Settings_Theme_Search::search_popular_sticky(); } + + /** 是否启用搜索建议 */ public static function search_suggestions_enabled(): bool { return AKSRT_Settings_Theme_Search::search_suggestions_enabled(); } + + /** 是否在搜索面板显示热门文章 */ public static function search_posts_enabled(): bool { return AKSRT_Settings_Theme_Search::search_posts_enabled(); } + + /** 搜索结果是否排除页面 */ public static function search_no_page(): bool { return AKSRT_Settings_Theme_Search::search_no_page(); } + + /** 获取搜索排除分类 ID 列表 */ public static function search_exclude_cats(): array { return AKSRT_Settings_Theme_Search::search_exclude_cats(); } + + /** 是否启用分类搜索选择器 */ public static function search_cat_enabled(): bool { return AKSRT_Settings_Theme_Search::search_cat_enabled(); } + + /** 获取默认搜索分类 */ public static function search_cat_in(): string { return AKSRT_Settings_Theme_Search::search_cat_in(); } + + /** 是否显示更多分类 */ public static function search_more_cat_obj(): bool { return AKSRT_Settings_Theme_Search::search_more_cat_obj(); } + + /** 是否启用自定义搜索匹配范围 */ public static function search_custom_key_enabled(): bool { return AKSRT_Settings_Theme_Search::search_custom_key_enabled(); } + + /** 获取自定义搜索匹配字段列表 */ public static function search_custom_key(): array { return AKSRT_Settings_Theme_Search::search_custom_key(); } + + /** 是否启用搜索结果 taxonomy 筛选 */ public static function search_facets_enabled(): bool { return AKSRT_Settings_Theme_Search::search_facets_enabled(); } + + /** 获取搜索结果筛选 taxonomy 排序列表 */ public static function search_facets_order(): array { return AKSRT_Settings_Theme_Search::search_facets_order(); } + + /** * Meilisearch integration configuration. * @@ -428,6 +329,8 @@ class AKSRT_Settings { return AKSRT_Settings_Theme_Search::get_meilisearch_config(); } + + /** * pre_get_posts 过滤:应用搜索排除配置 * - search_no_page: 排除页面 @@ -437,6 +340,8 @@ class AKSRT_Settings { AKSRT_Settings_Theme_Search::filter_search_query($query); } + + /** * pre_get_posts: apply global list_orderby on archive/tag/category/search pages. * @@ -446,6 +351,8 @@ class AKSRT_Settings { AKSRT_Settings_Post_Display::apply_list_orderby($query); } + + /** * 获取搜索内容类型配置 * @@ -455,81 +362,113 @@ class AKSRT_Settings { return AKSRT_Settings_Theme_Search::get_search_content_types(); } + + /** 默认主题模式 (white-theme / dark-theme / time-auto) */ public static function theme_mode(): string { return AKSRT_Settings_Theme_Search::theme_mode(); } + + /** 主题模式 → JS 可消费的简化值 (light / dark / time-auto) */ public static function theme_mode_js_value(): string { return AKSRT_Settings_Theme_Search::theme_mode_js_value(); } + + /** time-auto:对齐 zibll 早晚 8 点自动切换语义。 */ public static function time_auto_theme_mode(): string { return AKSRT_Settings_Theme_Search::time_auto_theme_mode(); } + + /** 首屏主题模式:优先消费 zibll 兼容 cookie,回退后台默认值。 */ public static function initial_theme_mode(): string { return AKSRT_Settings_Theme_Search::initial_theme_mode(); } + + /** 获取主题色 */ public static function primary_color(): string { return AKSRT_Settings_Theme_Search::primary_color(); } + + /** 获取主题皮肤预设 hex(解析 palette 选项为 #hex 格式) */ public static function theme_skin(): string { return AKSRT_Settings_Theme_Search::theme_skin(); } + + /** 获取暗色模式独立主题色 */ public static function dark_primary_color(): string { return AKSRT_Settings_Theme_Search::dark_primary_color(); } + + /** 获取主题切换按钮显示位置列表 */ public static function get_theme_mode_button_positions(): array { return AKSRT_Settings_Theme_Search::get_theme_mode_button_positions(); } + + /** 获取最终生效的主题色(skin 优先于 custom primary_color) */ public static function resolved_primary_color(): string { return AKSRT_Settings_Theme_Search::resolved_primary_color(); } + + /** 获取全局圆角 */ public static function border_radius(): int { return AKSRT_Settings_Theme_Search::border_radius(); } + + /** 获取卡片圆角值(独立设置优先,否则 border_radius + 4) */ public static function card_radius(): int { return AKSRT_Settings_Theme_Search::card_radius(); } + + /** 全站加载动画开关 */ public static function is_qj_loading_enabled(): bool { return AKSRT_Settings_Theme_Search::is_qj_loading_enabled(); } + + /** 加载动画样式 (no1–no10) */ public static function get_qj_loading_style(): string { return AKSRT_Settings_Theme_Search::get_qj_loading_style(); } + + /** 获取内容最大宽度 */ public static function container_max_width(): int { return AKSRT_Settings_Theme_Search::container_max_width(); } + + /** 获取全局字体方案 */ public static function font_family(): string { return AKSRT_Settings_Theme_Search::font_family(); } + + /** * 获取代码高亮配置 * @@ -539,6 +478,8 @@ class AKSRT_Settings { return AKSRT_Settings_Theme_Search::get_code_highlight_config(); } + + /** * 获取系统公告配置 * @@ -548,6 +489,8 @@ class AKSRT_Settings { return AKSRT_Settings_Theme_Search::get_system_notice_config(); } + + /** * 获取侧边栏配置 * @@ -557,6 +500,8 @@ class AKSRT_Settings { return AKSRT_Settings_Theme_Search::get_sidebar_config(); } + + /** * 获取多语言翻译配置 * @@ -566,180 +511,7 @@ class AKSRT_Settings { return AKSRT_Settings_Theme_Search::get_i18n_config(); } - /** 获取 ICP 备案号 */ - public static function icp_filing(): string { - return trim((string) self::get_value('icp_filing', '')); - } - /** 获取公安备案号 */ - public static function police_filing(): string { - return trim((string) self::get_value('police_number', '')); - } - - /** - * 获取备案配置(统一 page-display footer schema 和全局设置) - * @return array {icp_enabled, icp_number, police_enabled, police_number} - */ - public static function get_filing_settings(): array { - $s = self::get_all(); - $icp_enabled = !isset($s['footer_icp']) || !empty($s['footer_icp']); - $police_enabled = !isset($s['footer_police']) || !empty($s['footer_police']); - // 优先读 footer schema 的 icp_number,回退到全局 icp_filing - $icp_number = trim((string) ($s['icp_number'] ?? $s['icp_filing'] ?? '')); - $police_number = trim((string) ($s['police_number'] ?? '')); - return [ - 'icp_enabled' => $icp_enabled, - 'icp_number' => $icp_number, - 'police_enabled' => $police_enabled, - 'police_number' => $police_number, - ]; - } - - /** 获取页脚附加文字 */ - public static function footer_text(): string { - return trim((string) self::get_value('footer_text', '')); - } - - /** - * Header navigation display settings. - * - * @return array{fixed:bool,search_enabled:bool,user_menu:bool,publish_btn:bool,mobile_drawer:bool,nav_style:string,max_width:int,scroll_down_hide:bool,layout:string,mobile_layout:string,mobile_align:string} - */ - public static function get_header_nav_settings(): array { - $settings = self::get_all(); - $nav_style = sanitize_key((string) ($settings['header_nav_style'] ?? 'default')); - $max_width = (int) ($settings['header_max_width'] ?? 1200); - $layout = sanitize_key((string) ($settings['header_layout'] ?? '2')); - $ml = sanitize_key((string) ($settings['mobile_header_layout'] ?? 'center')); - $ma = sanitize_key((string) ($settings['mobile_navbar_align'] ?? 'left')); - - if (!in_array($nav_style, ['default', 'transparent', 'blur'], true)) { - $nav_style = 'default'; - } - - if (!in_array($max_width, [0, 1080, 1200, 1320], true)) { - $max_width = 1200; - } - - return [ - 'fixed' => !isset($settings['header_fixed']) || !empty($settings['header_fixed']), - 'search_enabled' => !isset($settings['header_search_enabled']) || !empty($settings['header_search_enabled']), - 'user_menu' => !isset($settings['header_user_menu']) || !empty($settings['header_user_menu']), - 'publish_btn' => !isset($settings['header_publish_btn']) || !empty($settings['header_publish_btn']), - 'mobile_drawer' => !isset($settings['header_mobile_drawer']) || !empty($settings['header_mobile_drawer']), - 'nav_style' => $nav_style, - 'max_width' => $max_width, - 'scroll_down_hide'=> !empty($settings['header_scroll_down_hide']), - 'layout' => in_array($layout, ['1', '2', '3'], true) ? $layout : '2', - 'mobile_layout' => in_array($ml, ['center', 'left'], true) ? $ml : 'center', - 'mobile_align' => in_array($ma, ['top', 'left', 'right'], true) ? $ma : 'left', - ]; - } - - /** - * Header publish button configuration. - * - * @return array{enabled:bool,text:string,radius:int,class:string} - */ - public static function get_header_newposts_config(): array { - $s = self::get_all(); - - return [ - 'enabled' => !empty($s['header_newposts_enabled']), - 'text' => trim((string) ($s['header_newposts_text'] ?? ' 发布')), - 'radius' => max(0, min(30, (int) ($s['header_newposts_radius'] ?? 20))), - 'class' => sanitize_html_class((string) ($s['header_newposts_class'] ?? '')), - ]; - } - - /** - * Header theme color configuration. - * - * @return array{custom:bool,bg_custom:string,bg:string,color_custom:string} - */ - public static function get_header_theme_config(): array { - $s = self::get_all(); - - return [ - 'custom' => !empty($s['header_theme_custom']), - 'bg_custom' => sanitize_hex_color((string) ($s['header_theme_bg_custom'] ?? '')) ?: '', - 'bg' => sanitize_hex_color((string) ($s['header_theme_bg'] ?? '')) ?: '', - 'color_custom' => sanitize_hex_color((string) ($s['header_theme_color_custom'] ?? '')) ?: '', - ]; - } - - /** - * Category archive display settings. - * - * @return array - */ - public static function get_category_page_settings(): array { - $settings = self::get_all(); - $header_style = sanitize_key((string) ($settings['category_header_style'] ?? 'cover')); - $list_mode = sanitize_key((string) ($settings['category_list_mode'] ?? 'grid')); - $per_page = (int) ($settings['category_per_page'] ?? 12); - $orderby = sanitize_key((string) ($settings['category_orderby'] ?? 'modified')); - - if (!in_array($header_style, ['cover', 'simple', 'none'], true)) { - $header_style = 'cover'; - } - - if (!in_array($list_mode, ['grid', 'list', 'compact'], true)) { - $list_mode = 'grid'; - } - - if (!in_array($orderby, ['modified', 'date', 'views', 'comments', 'likes'], true)) { - $orderby = 'modified'; - } - - return [ - 'header_style' => $header_style, - 'cover_enabled' => !isset($settings['category_cover_enabled']) || !empty($settings['category_cover_enabled']), - 'desc_enabled' => !isset($settings['category_desc_enabled']) || !empty($settings['category_desc_enabled']), - 'default_cover' => esc_url_raw((string) ($settings['category_default_cover'] ?? '')), - 'list_mode' => $list_mode, - 'filter_bar' => !isset($settings['category_filter_bar']) || !empty($settings['category_filter_bar']), - 'sidebar' => !isset($settings['category_sidebar']) || !empty($settings['category_sidebar']), - 'per_page' => max(4, min(48, $per_page)), - 'post_count' => !empty($settings['category_post_count']), - 'orderby' => $orderby, - ]; - } - - /** - * Tag archive display settings. - * - * @return array - */ - public static function get_tag_page_settings(): array { - $settings = self::get_all(); - $header_style = sanitize_key((string) ($settings['tag_header_style'] ?? 'simple')); - $list_mode = sanitize_key((string) ($settings['tag_list_mode'] ?? 'grid')); - $per_page = (int) ($settings['tag_per_page'] ?? 12); - $orderby = sanitize_key((string) ($settings['tag_orderby'] ?? 'count')); - - if (!in_array($header_style, ['cover', 'simple', 'none'], true)) { - $header_style = 'simple'; - } - - if (!in_array($list_mode, ['grid', 'list'], true)) { - $list_mode = 'grid'; - } - - if (!in_array($orderby, ['count', 'name', 'modified'], true)) { - $orderby = 'count'; - } - - return [ - 'header_style' => $header_style, - 'default_cover' => esc_url_raw((string) ($settings['tag_default_cover'] ?? '')), - 'list_mode' => $list_mode, - 'sidebar' => !isset($settings['tag_sidebar']) || !empty($settings['tag_sidebar']), - 'per_page' => max(4, min(48, $per_page)), - 'post_count' => !empty($settings['tag_post_count']), - 'orderby' => $orderby, - ]; - } /** * Public user-home display settings. @@ -750,6 +522,8 @@ class AKSRT_Settings { return AKSRT_Settings_User_Runtime::get_user_home_settings(); } + + /** * Private user-center display settings. * @@ -759,6 +533,8 @@ class AKSRT_Settings { return AKSRT_Settings_User_Runtime::get_user_center_settings(); } + + /** * User identity, level, badge, and report governance settings. * @@ -773,84 +549,7 @@ class AKSRT_Settings { return AKSRT_Settings_User_Runtime::get_user_identity_settings(); } - /** - * Misc page display settings (404, search, author). - * - * @return array - */ - public static function get_other_page_settings(): array { - $settings = self::get_all(); - $message = trim((string) ($settings['page_404_message'] ?? '')); - $search_per_page = max(4, min(48, (int) ($settings['page_search_per_page'] ?? 12))); - $author_per_page = max(4, min(48, (int) ($settings['page_author_posts_per_page'] ?? 12))); - $header_style = sanitize_key((string) ($settings['page_header_style'] ?? '1')); - if (!in_array($header_style, ['', '1', '2', '3'], true)) { - $header_style = '1'; - } - - return [ - 'header_style' => $header_style, - 'header_cover_img' => esc_url_raw((string) ($settings['page_header_cover_img'] ?? '')), - 'page_404_message' => $message !== '' ? $message : __('页面未找到', 'aksrt'), - 'page_404_search' => !isset($settings['page_404_search']) || !empty($settings['page_404_search']), - 'page_404_popular' => !isset($settings['page_404_popular']) || !empty($settings['page_404_popular']), - 'page_404_back' => !isset($settings['page_404_back']) || !empty($settings['page_404_back']), - 'page_search_sidebar' => !isset($settings['page_search_sidebar']) || !empty($settings['page_search_sidebar']), - 'page_search_per_page' => $search_per_page, - 'page_author_cover' => !isset($settings['page_author_cover']) || !empty($settings['page_author_cover']), - 'page_author_sidebar' => !isset($settings['page_author_sidebar']) || !empty($settings['page_author_sidebar']), - 'page_author_posts_per_page' => $author_per_page, - ]; - } - - /** - * Special/topic page display settings. - * - * @return array - */ - public static function get_special_page_settings(): array { - $s = self::get_all(); - $layout = sanitize_key((string) ($s['special_layout'] ?? 'grid')); - $source_type = sanitize_key((string) ($s['special_source_type'] ?? 'category')); - $subpage_display = sanitize_key((string) ($s['special_subpage_display'] ?? 'cards')); - $pagination = sanitize_key((string) ($s['special_pagination'] ?? 'numeric')); - $orderby = sanitize_key((string) ($s['special_orderby'] ?? 'date')); - - if (!in_array($layout, ['grid', 'list', 'masonry'], true)) { - $layout = 'grid'; - } - if (!in_array($source_type, ['category', 'tag', 'custom'], true)) { - $source_type = 'category'; - } - if (!in_array($subpage_display, ['cards', 'list', 'grid'], true)) { - $subpage_display = 'cards'; - } - if (!in_array($pagination, ['numeric', 'load_more', 'infinite'], true)) { - $pagination = 'numeric'; - } - if (!in_array($orderby, ['date', 'modified', 'views', 'comments'], true)) { - $orderby = 'date'; - } - - return [ - 'cover_enabled' => !empty($s['special_cover_enabled']), - 'summary_enabled' => !empty($s['special_summary_enabled']), - 'summary_length' => max(50, min(500, (int) ($s['special_summary_length'] ?? 200))), - 'default_cover' => esc_url_raw((string) ($s['special_default_cover'] ?? '')), - 'layout' => $layout, - 'columns' => max(1, min(4, (int) ($s['special_columns'] ?? 2))), - 'source_type' => $source_type, - 'source_ids' => trim((string) ($s['special_source_ids'] ?? '')), - 'subpage_display' => $subpage_display, - 'subpage_count' => max(1, min(20, (int) ($s['special_subpage_count'] ?? 6))), - 'sidebar' => !empty($s['special_sidebar']), - 'pagination' => $pagination, - 'per_page' => max(4, min(48, (int) ($s['special_per_page'] ?? 12))), - 'post_count' => !empty($s['special_post_count']), - 'orderby' => $orderby, - ]; - } /** * Post-list card style and meta display settings. @@ -861,6 +560,8 @@ class AKSRT_Settings { return AKSRT_Settings_Post_Display::get_post_list_style_settings(); } + + /** * Post-list thumbnail policy settings. * @@ -870,6 +571,8 @@ class AKSRT_Settings { return AKSRT_Settings_Post_Display::get_post_thumb_settings(); } + + /** * Post-list filter toolbar settings. * @@ -879,6 +582,8 @@ class AKSRT_Settings { return AKSRT_Settings_Post_Display::get_post_filter_settings(); } + + /** * Get custom filter definitions for a specific category. * Finds the matching cat_custom_filter binding, falls back to global custom_filter. @@ -890,54 +595,7 @@ class AKSRT_Settings { return AKSRT_Settings_Post_Display::get_filter_config_for_category($cat_id); } - /** - * Single post/page display settings. - * - * @return array{toc_enabled:bool,toc_depth:int,reading_progress:bool,lightbox:bool,related_enabled:bool,related_count:int,like_enabled:bool,favorite_enabled:bool,share_enabled:bool,copyright_enabled:bool,print_enabled:bool,pdf_export_enabled:bool,word_count_enabled:bool,reading_time_enabled:bool} - */ - public static function get_post_page_settings(): array { - // Canonical assembly lives in the post-display helper (related style/source split, aliases). - if (class_exists('AKSRT_Settings_Post_Display')) { - return AKSRT_Settings_Post_Display::get_post_page_settings(); - } - $settings = self::get_all(); - return [ - 'toc_enabled' => !isset($settings['post_toc_enabled']) || !empty($settings['post_toc_enabled']), - 'toc_depth' => max(1, min(6, (int) ($settings['post_toc_depth'] ?? 3))), - 'reading_progress' => !isset($settings['post_reading_progress']) || !empty($settings['post_reading_progress']), - 'breadcrumbs' => !isset($settings['breadcrumbs_single_s']) || !empty($settings['breadcrumbs_single_s']), - 'breadcrumbs_home' => trim((string) ($settings['breadcrumbs_home_text'] ?? '')) ?: __('首页', 'aksrt'), - 'related_enabled' => !isset($settings['post_related_enabled']) || !empty($settings['post_related_enabled']), - 'related_count' => max(1, min(20, (int) ($settings['post_related_count'] ?? 6))), - 'related_style' => 'img', - 'related_source' => 'category', - 'related_type' => 'category', - 'related_orderby' => 'views', - 'related_title' => sanitize_text_field((string) ($settings['post_related_title'] ?? '相关推荐')), - ]; - } - - /** - * Share and poster settings. - * - * @return array{wechat_enabled:bool,wechat_appid:string,wechat_app_secret:string,only_logo:bool,compatible_s:bool,poster_img:string,poster_logo:string,share_img:string,share_desc:string} - */ - public static function get_share_settings(): array { - $settings = self::get_all(); - - return [ - 'wechat_enabled' => !empty($settings['share_wechat_s']), - 'wechat_appid' => sanitize_text_field((string) ($settings['share_wechat_appid'] ?? '')), - 'wechat_app_secret' => sanitize_text_field((string) ($settings['share_wechat_app_secret'] ?? '')), - 'only_logo' => !empty($settings['share_only_logo']), - 'compatible_s' => !empty($settings['share_img_compatible_s']), - 'poster_img' => esc_url_raw((string) ($settings['share_img_byimg'] ?? '')), - 'poster_logo' => esc_url_raw((string) ($settings['share_logo'] ?? '')), - 'share_img' => esc_url_raw((string) ($settings['share_img'] ?? '')), - 'share_desc' => sanitize_textarea_field((string) ($settings['share_desc'] ?? '')) ?: __('扫一扫 立即查看', 'aksrt'), - ]; - } /** * Front submit settings. @@ -948,6 +606,8 @@ class AKSRT_Settings { return AKSRT_Settings_Post_Display::get_submit_settings(); } + + /** * Comment display and behavior settings. * @@ -957,10 +617,14 @@ class AKSRT_Settings { return AKSRT_Settings_Post_Display::get_comment_display_settings(); } + + public static function get_withdrawal_settings(): array { return AKSRT_Settings_Payment_Runtime::get_withdrawal_settings(); } + + /** * pre_get_posts: apply post_list_per_page on archive/tag/category/search/home pages. * @@ -970,71 +634,64 @@ class AKSRT_Settings { AKSRT_Settings_Post_Display::apply_list_per_page($query); } + + /** 获取Banner列表 */ public static function get_banners(string $position = ''): array { return AKSRT_Settings_Display_Runtime::get_banners($position); } + + /** 保存Banner */ public static function save_banner(array $data): int { return AKSRT_Settings_Display_Runtime::save_banner($data); } + + /** 删除Banner */ public static function delete_banner(string $id): void { AKSRT_Settings_Display_Runtime::delete_banner($id); } + + /** 获取友链列表 */ public static function get_friend_links(): array { return AKSRT_Settings_Display_Runtime::get_friend_links(); } + + /** 保存友链 */ public static function save_friend_links(array $links): void { AKSRT_Settings_Display_Runtime::save_friend_links($links); } - /** 获取SMTP配置 */ - public static function get_smtp(): array { - return get_option('aksrt_mail_smtp', [ - 'enabled' => false, - 'host' => '', - 'port' => 465, - 'username' => '', - 'password' => '', - 'encryption' => 'ssl', - 'from_name' => '', - 'from_address' => '', - ]); - } - /** 获取邮件品牌设置(发件人昵称/标题前缀/底部内容) */ - public static function get_mail_branding(): array { - $s = self::get_all(); - return [ - 'showname' => trim((string) ($s['mail_showname'] ?? '')), - 'title_prefix' => trim((string) ($s['mail_title_prefix'] ?? '')), - 'description' => trim((string) ($s['mail_description'] ?? '')), - 'more_content' => trim((string) ($s['mail_more_content'] ?? '')), - ]; - } /** 私信邮件接收组限制 (all/vip/vip2/admin) */ public static function get_email_private_receive_limit(): string { return AKSRT_Settings_Integration::get_email_private_receive_limit(); } + + /** 获取OAuth配置 */ public static function get_oauth(): array { return AKSRT_Settings_Integration::get_oauth(); } + + /** 检查OAuth平台是否已配置 */ public static function oauth_configured(string $provider): bool { return AKSRT_Settings_Integration::oauth_configured($provider); } + + // ============================================ // OAuth 代理登录 & 自动创建用户 // ============================================ @@ -1044,26 +701,36 @@ class AKSRT_Settings { return AKSRT_Settings_Integration::oauth_proxy_mode(); } + + /** OAuth 代理登录 HMAC 密钥 */ public static function oauth_proxy_secret(): string { return AKSRT_Settings_Integration::oauth_proxy_secret(); } + + /** OAuth 代理登录服务端地址(客户端模式使用) */ public static function oauth_proxy_server_url(): string { return AKSRT_Settings_Integration::oauth_proxy_server_url(); } + + /** OAuth 代理登录允许的平台列表(服务端模式使用) */ public static function oauth_proxy_allowed_providers(): array { return AKSRT_Settings_Integration::oauth_proxy_allowed_providers(); } + + /** OAuth 代理登录启用的平台列表(客户端模式使用) */ public static function oauth_proxy_client_providers(): array { return AKSRT_Settings_Integration::oauth_proxy_client_providers(); } + + /** * 获取微信公众号登录完整配置 * @@ -1073,6 +740,8 @@ class AKSRT_Settings { return AKSRT_Settings_Integration::get_weixingzh_settings(); } + + /** * 获取 OAuth 代理登录完整配置(aksrt_settings 优先,BC fallback aksrt_oauth) * @@ -1082,6 +751,8 @@ class AKSRT_Settings { return AKSRT_Settings_Integration::get_oauth_proxy_settings(); } + + // ============================================ // 底部信息配置 // ============================================ @@ -1094,31 +765,43 @@ class AKSRT_Settings { return AKSRT_Settings_Home_Cms::get_footer_settings(); } + + /** 获取联系图标列表(仅返回启用的) */ public static function get_footer_contacts(): array { return AKSRT_Settings_Home_Cms::get_footer_contacts(); } + + /** 获取快速链接列表(仅返回启用的,按排序) */ public static function get_quick_links(): array { return AKSRT_Settings_Home_Cms::get_quick_links(); } + + /** 获取更多链接列表(仅返回启用的,按排序) */ public static function get_more_links(): array { return AKSRT_Settings_Home_Cms::get_more_links(); } + + /** 获取热门分类显示数量 */ public static function get_hot_categories_count(): int { return AKSRT_Settings_Home_Cms::get_hot_categories_count(); } + + /** 获取版权文字 */ public static function get_copyright_text(): string { return AKSRT_Settings_Home_Cms::get_copyright_text(); } + + /** * 获取页脚品牌列(第一列)配置 * @return array {m_s, img, img_dark, title, custom_code} @@ -1127,6 +810,8 @@ class AKSRT_Settings { return AKSRT_Settings_Home_Cms::get_footer_brand_settings(); } + + /** * 获取页脚扩展联系方式配置 * @return array {m_s, wechat_img, qq, weibo, email, more, code_1, code_2} @@ -1135,6 +820,8 @@ class AKSRT_Settings { return AKSRT_Settings_Home_Cms::get_footer_contact_extended_settings(); } + + /** * 获取页脚二维码图片组(第三列) * @return array {m_s, items: [{text, img}]} @@ -1143,6 +830,8 @@ class AKSRT_Settings { return AKSRT_Settings_Home_Cms::get_footer_mini_img_settings(); } + + /** * 获取页脚底部自定义代码 */ @@ -1150,11 +839,15 @@ class AKSRT_Settings { return AKSRT_Settings_Home_Cms::get_footer_customize_code(); } + + /** 获取审核配置 */ public static function get_moderation(): array { return AKSRT_Settings_Extension_Runtime::get_moderation(); } + + // ============================================ // 文章布局配置 // ============================================ @@ -1171,6 +864,8 @@ class AKSRT_Settings { return AKSRT_Settings_Post_Display::get_archive_layout(); } + + /** * 获取文章列表分页模式 * @return string numbered|load_more|infinite @@ -1179,6 +874,8 @@ class AKSRT_Settings { return AKSRT_Settings_Post_Display::get_post_pagination_mode(); } + + /** * 获取评论列表分页模式 * @return string numbered|load_more|infinite @@ -1187,40 +884,7 @@ class AKSRT_Settings { return AKSRT_Settings_Post_Display::get_comment_pagination_mode(); } - /** - * 获取懒加载配置 - * - * 返回 schema 定义的全部字段:全局开关、动画、占位图、模糊预览、 - * 以及 8 项粒度范围开关。 - * - * @return array {enabled, animation, placeholder_url, blur, avatar, content, comment, private, slider, cover, other, widget} - */ - public static function get_lazyload_config(): array { - $s = self::get_all(); - // placeholder 兼容:schema 存 lazyload_placeholder,REST/Vue 存 lazyload_placeholder_url - $placeholder = ''; - if (!empty($s['lazyload_placeholder'])) { - $placeholder = $s['lazyload_placeholder']; - } elseif (!empty($s['lazyload_placeholder_url'])) { - $placeholder = $s['lazyload_placeholder_url']; - } - - return [ - 'enabled' => !isset($s['lazyload_enabled']) || !empty($s['lazyload_enabled']), - 'animation' => $s['lazyload_animation'] ?? 'blur', - 'placeholder_url' => (string) $placeholder, - 'blur' => !isset($s['lazyload_blur']) || !empty($s['lazyload_blur']), - 'avatar' => !isset($s['lazyload_avatar']) || !empty($s['lazyload_avatar']), - 'content' => !empty($s['lazyload_content']), - 'comment' => !isset($s['lazyload_comment']) || !empty($s['lazyload_comment']), - 'private' => !isset($s['lazyload_private']) || !empty($s['lazyload_private']), - 'slider' => !isset($s['lazyload_slider']) || !empty($s['lazyload_slider']), - 'cover' => !isset($s['lazyload_cover']) || !empty($s['lazyload_cover']), - 'other' => !isset($s['lazyload_other']) || !empty($s['lazyload_other']), - 'widget' => !isset($s['lazyload_widget']) || !empty($s['lazyload_widget']), - ]; - } // ============================================ // CMS 配置 @@ -1238,6 +902,8 @@ class AKSRT_Settings { return AKSRT_Settings_Home_Cms::get_site_profile(); } + + /** * CMS 功能开关(合并默认值)。 * @return array @@ -1246,6 +912,8 @@ class AKSRT_Settings { return AKSRT_Settings_Home_Cms::get_cms_features(); } + + /** * 单项 CMS 功能开关读取。 */ @@ -1253,8 +921,4 @@ class AKSRT_Settings { return AKSRT_Settings_Home_Cms::cms_feature($key, $default); } - /** 所有邮件事件类型常量 */ - public const EMAIL_EVENTS = AKSRT_Settings_Mail::EMAIL_EVENTS; } - -return AKSRT_Settings::get_instance(); diff --git a/inc/classes/settings/trait-aksrt-settings-facade-bulk.php b/inc/classes/settings/trait-aksrt-settings-facade-bulk.php new file mode 100644 index 00000000..4e5f123d --- /dev/null +++ b/inc/classes/settings/trait-aksrt-settings-facade-bulk.php @@ -0,0 +1,559 @@ + !isset($settings['header_fixed']) || !empty($settings['header_fixed']), + 'search_enabled' => !isset($settings['header_search_enabled']) || !empty($settings['header_search_enabled']), + 'user_menu' => !isset($settings['header_user_menu']) || !empty($settings['header_user_menu']), + 'publish_btn' => !isset($settings['header_publish_btn']) || !empty($settings['header_publish_btn']), + 'mobile_drawer' => !isset($settings['header_mobile_drawer']) || !empty($settings['header_mobile_drawer']), + 'nav_style' => $nav_style, + 'max_width' => $max_width, + 'scroll_down_hide'=> !empty($settings['header_scroll_down_hide']), + 'layout' => in_array($layout, ['1', '2', '3'], true) ? $layout : '2', + 'mobile_layout' => in_array($ml, ['center', 'left'], true) ? $ml : 'center', + 'mobile_align' => in_array($ma, ['top', 'left', 'right'], true) ? $ma : 'left', + ]; + } + + + + /** + * Header publish button configuration. + * + * @return array{enabled:bool,text:string,radius:int,class:string} + */ + public static function get_header_newposts_config(): array { + $s = self::get_all(); + + return [ + 'enabled' => !empty($s['header_newposts_enabled']), + 'text' => trim((string) ($s['header_newposts_text'] ?? ' 发布')), + 'radius' => max(0, min(30, (int) ($s['header_newposts_radius'] ?? 20))), + 'class' => sanitize_html_class((string) ($s['header_newposts_class'] ?? '')), + ]; + } + + + + /** + * Header theme color configuration. + * + * @return array{custom:bool,bg_custom:string,bg:string,color_custom:string} + */ + public static function get_header_theme_config(): array { + $s = self::get_all(); + + return [ + 'custom' => !empty($s['header_theme_custom']), + 'bg_custom' => sanitize_hex_color((string) ($s['header_theme_bg_custom'] ?? '')) ?: '', + 'bg' => sanitize_hex_color((string) ($s['header_theme_bg'] ?? '')) ?: '', + 'color_custom' => sanitize_hex_color((string) ($s['header_theme_color_custom'] ?? '')) ?: '', + ]; + } + + + + /** + * Category archive display settings. + * + * @return array + */ + public static function get_category_page_settings(): array { + $settings = self::get_all(); + $header_style = sanitize_key((string) ($settings['category_header_style'] ?? 'cover')); + $list_mode = sanitize_key((string) ($settings['category_list_mode'] ?? 'grid')); + $per_page = (int) ($settings['category_per_page'] ?? 12); + $orderby = sanitize_key((string) ($settings['category_orderby'] ?? 'modified')); + + if (!in_array($header_style, ['cover', 'simple', 'none'], true)) { + $header_style = 'cover'; + } + + if (!in_array($list_mode, ['grid', 'list', 'compact'], true)) { + $list_mode = 'grid'; + } + + if (!in_array($orderby, ['modified', 'date', 'views', 'comments', 'likes'], true)) { + $orderby = 'modified'; + } + + return [ + 'header_style' => $header_style, + 'cover_enabled' => !isset($settings['category_cover_enabled']) || !empty($settings['category_cover_enabled']), + 'desc_enabled' => !isset($settings['category_desc_enabled']) || !empty($settings['category_desc_enabled']), + 'default_cover' => esc_url_raw((string) ($settings['category_default_cover'] ?? '')), + 'list_mode' => $list_mode, + 'filter_bar' => !isset($settings['category_filter_bar']) || !empty($settings['category_filter_bar']), + 'sidebar' => !isset($settings['category_sidebar']) || !empty($settings['category_sidebar']), + 'per_page' => max(4, min(48, $per_page)), + 'post_count' => !empty($settings['category_post_count']), + 'orderby' => $orderby, + ]; + } + + + + /** + * Tag archive display settings. + * + * @return array + */ + public static function get_tag_page_settings(): array { + $settings = self::get_all(); + $header_style = sanitize_key((string) ($settings['tag_header_style'] ?? 'simple')); + $list_mode = sanitize_key((string) ($settings['tag_list_mode'] ?? 'grid')); + $per_page = (int) ($settings['tag_per_page'] ?? 12); + $orderby = sanitize_key((string) ($settings['tag_orderby'] ?? 'count')); + + if (!in_array($header_style, ['cover', 'simple', 'none'], true)) { + $header_style = 'simple'; + } + + if (!in_array($list_mode, ['grid', 'list'], true)) { + $list_mode = 'grid'; + } + + if (!in_array($orderby, ['count', 'name', 'modified'], true)) { + $orderby = 'count'; + } + + return [ + 'header_style' => $header_style, + 'default_cover' => esc_url_raw((string) ($settings['tag_default_cover'] ?? '')), + 'list_mode' => $list_mode, + 'sidebar' => !isset($settings['tag_sidebar']) || !empty($settings['tag_sidebar']), + 'per_page' => max(4, min(48, $per_page)), + 'post_count' => !empty($settings['tag_post_count']), + 'orderby' => $orderby, + ]; + } + + + + /** + * Misc page display settings (404, search, author). + * + * @return array + */ + public static function get_other_page_settings(): array { + $settings = self::get_all(); + $message = trim((string) ($settings['page_404_message'] ?? '')); + $search_per_page = max(4, min(48, (int) ($settings['page_search_per_page'] ?? 12))); + $author_per_page = max(4, min(48, (int) ($settings['page_author_posts_per_page'] ?? 12))); + + $header_style = sanitize_key((string) ($settings['page_header_style'] ?? '1')); + if (!in_array($header_style, ['', '1', '2', '3'], true)) { + $header_style = '1'; + } + + return [ + 'header_style' => $header_style, + 'header_cover_img' => esc_url_raw((string) ($settings['page_header_cover_img'] ?? '')), + 'page_404_message' => $message !== '' ? $message : __('页面未找到', 'aksrt'), + 'page_404_search' => !isset($settings['page_404_search']) || !empty($settings['page_404_search']), + 'page_404_popular' => !isset($settings['page_404_popular']) || !empty($settings['page_404_popular']), + 'page_404_back' => !isset($settings['page_404_back']) || !empty($settings['page_404_back']), + 'page_search_sidebar' => !isset($settings['page_search_sidebar']) || !empty($settings['page_search_sidebar']), + 'page_search_per_page' => $search_per_page, + 'page_author_cover' => !isset($settings['page_author_cover']) || !empty($settings['page_author_cover']), + 'page_author_sidebar' => !isset($settings['page_author_sidebar']) || !empty($settings['page_author_sidebar']), + 'page_author_posts_per_page' => $author_per_page, + ]; + } + + + + /** + * Special/topic page display settings. + * + * @return array + */ + public static function get_special_page_settings(): array { + $s = self::get_all(); + $layout = sanitize_key((string) ($s['special_layout'] ?? 'grid')); + $source_type = sanitize_key((string) ($s['special_source_type'] ?? 'category')); + $subpage_display = sanitize_key((string) ($s['special_subpage_display'] ?? 'cards')); + $pagination = sanitize_key((string) ($s['special_pagination'] ?? 'numeric')); + $orderby = sanitize_key((string) ($s['special_orderby'] ?? 'date')); + + if (!in_array($layout, ['grid', 'list', 'masonry'], true)) { + $layout = 'grid'; + } + if (!in_array($source_type, ['category', 'tag', 'custom'], true)) { + $source_type = 'category'; + } + if (!in_array($subpage_display, ['cards', 'list', 'grid'], true)) { + $subpage_display = 'cards'; + } + if (!in_array($pagination, ['numeric', 'load_more', 'infinite'], true)) { + $pagination = 'numeric'; + } + if (!in_array($orderby, ['date', 'modified', 'views', 'comments'], true)) { + $orderby = 'date'; + } + + return [ + 'cover_enabled' => !empty($s['special_cover_enabled']), + 'summary_enabled' => !empty($s['special_summary_enabled']), + 'summary_length' => max(50, min(500, (int) ($s['special_summary_length'] ?? 200))), + 'default_cover' => esc_url_raw((string) ($s['special_default_cover'] ?? '')), + 'layout' => $layout, + 'columns' => max(1, min(4, (int) ($s['special_columns'] ?? 2))), + 'source_type' => $source_type, + 'source_ids' => trim((string) ($s['special_source_ids'] ?? '')), + 'subpage_display' => $subpage_display, + 'subpage_count' => max(1, min(20, (int) ($s['special_subpage_count'] ?? 6))), + 'sidebar' => !empty($s['special_sidebar']), + 'pagination' => $pagination, + 'per_page' => max(4, min(48, (int) ($s['special_per_page'] ?? 12))), + 'post_count' => !empty($s['special_post_count']), + 'orderby' => $orderby, + ]; + } + + + + /** + * Share and poster settings. + * + * @return array{wechat_enabled:bool,wechat_appid:string,wechat_app_secret:string,only_logo:bool,compatible_s:bool,poster_img:string,poster_logo:string,share_img:string,share_desc:string} + */ + public static function get_share_settings(): array { + $settings = self::get_all(); + + return [ + 'wechat_enabled' => !empty($settings['share_wechat_s']), + 'wechat_appid' => sanitize_text_field((string) ($settings['share_wechat_appid'] ?? '')), + 'wechat_app_secret' => sanitize_text_field((string) ($settings['share_wechat_app_secret'] ?? '')), + 'only_logo' => !empty($settings['share_only_logo']), + 'compatible_s' => !empty($settings['share_img_compatible_s']), + 'poster_img' => esc_url_raw((string) ($settings['share_img_byimg'] ?? '')), + 'poster_logo' => esc_url_raw((string) ($settings['share_logo'] ?? '')), + 'share_img' => esc_url_raw((string) ($settings['share_img'] ?? '')), + 'share_desc' => sanitize_textarea_field((string) ($settings['share_desc'] ?? '')) ?: __('扫一扫 立即查看', 'aksrt'), + ]; + } + + + + /** + * Single post/page display settings. + * + * @return array{toc_enabled:bool,toc_depth:int,reading_progress:bool,lightbox:bool,related_enabled:bool,related_count:int,like_enabled:bool,favorite_enabled:bool,share_enabled:bool,copyright_enabled:bool,print_enabled:bool,pdf_export_enabled:bool,word_count_enabled:bool,reading_time_enabled:bool} + */ + public static function get_post_page_settings(): array { + // Canonical assembly lives in the post-display helper (related style/source split, aliases). + if (class_exists('AKSRT_Settings_Post_Display')) { + return AKSRT_Settings_Post_Display::get_post_page_settings(); + } + + $settings = self::get_all(); + return [ + 'toc_enabled' => !isset($settings['post_toc_enabled']) || !empty($settings['post_toc_enabled']), + 'toc_depth' => max(1, min(6, (int) ($settings['post_toc_depth'] ?? 3))), + 'reading_progress' => !isset($settings['post_reading_progress']) || !empty($settings['post_reading_progress']), + 'breadcrumbs' => !isset($settings['breadcrumbs_single_s']) || !empty($settings['breadcrumbs_single_s']), + 'breadcrumbs_home' => trim((string) ($settings['breadcrumbs_home_text'] ?? '')) ?: __('首页', 'aksrt'), + 'related_enabled' => !isset($settings['post_related_enabled']) || !empty($settings['post_related_enabled']), + 'related_count' => max(1, min(20, (int) ($settings['post_related_count'] ?? 6))), + 'related_style' => 'img', + 'related_source' => 'category', + 'related_type' => 'category', + 'related_orderby' => 'views', + 'related_title' => sanitize_text_field((string) ($settings['post_related_title'] ?? '相关推荐')), + ]; + } + + + + /** + * 获取懒加载配置 + * + * 返回 schema 定义的全部字段:全局开关、动画、占位图、模糊预览、 + * 以及 8 项粒度范围开关。 + * + * @return array {enabled, animation, placeholder_url, blur, avatar, content, comment, private, slider, cover, other, widget} + */ + public static function get_lazyload_config(): array { + $s = self::get_all(); + + // placeholder 兼容:schema 存 lazyload_placeholder,REST/Vue 存 lazyload_placeholder_url + $placeholder = ''; + if (!empty($s['lazyload_placeholder'])) { + $placeholder = $s['lazyload_placeholder']; + } elseif (!empty($s['lazyload_placeholder_url'])) { + $placeholder = $s['lazyload_placeholder_url']; + } + + return [ + 'enabled' => !isset($s['lazyload_enabled']) || !empty($s['lazyload_enabled']), + 'animation' => $s['lazyload_animation'] ?? 'blur', + 'placeholder_url' => (string) $placeholder, + 'blur' => !isset($s['lazyload_blur']) || !empty($s['lazyload_blur']), + 'avatar' => !isset($s['lazyload_avatar']) || !empty($s['lazyload_avatar']), + 'content' => !empty($s['lazyload_content']), + 'comment' => !isset($s['lazyload_comment']) || !empty($s['lazyload_comment']), + 'private' => !isset($s['lazyload_private']) || !empty($s['lazyload_private']), + 'slider' => !isset($s['lazyload_slider']) || !empty($s['lazyload_slider']), + 'cover' => !isset($s['lazyload_cover']) || !empty($s['lazyload_cover']), + 'other' => !isset($s['lazyload_other']) || !empty($s['lazyload_other']), + 'widget' => !isset($s['lazyload_widget']) || !empty($s['lazyload_widget']), + ]; + } + + + + /** + * 获取备案配置(统一 page-display footer schema 和全局设置) + * @return array {icp_enabled, icp_number, police_enabled, police_number} + */ + public static function get_filing_settings(): array { + $s = self::get_all(); + $icp_enabled = !isset($s['footer_icp']) || !empty($s['footer_icp']); + $police_enabled = !isset($s['footer_police']) || !empty($s['footer_police']); + // 优先读 footer schema 的 icp_number,回退到全局 icp_filing + $icp_number = trim((string) ($s['icp_number'] ?? $s['icp_filing'] ?? '')); + $police_number = trim((string) ($s['police_number'] ?? '')); + return [ + 'icp_enabled' => $icp_enabled, + 'icp_number' => $icp_number, + 'police_enabled' => $police_enabled, + 'police_number' => $police_number, + ]; + } + + + + /** 获取页脚附加文字 */ + public static function footer_text(): string { + return trim((string) self::get_value('footer_text', '')); + } + + + + /** 获取 ICP 备案号 */ + public static function icp_filing(): string { + return trim((string) self::get_value('icp_filing', '')); + } + + + + /** 获取公安备案号 */ + public static function police_filing(): string { + return trim((string) self::get_value('police_number', '')); + } + + + + /** 获取导航 Logo 宽度 */ + public static function logo_width(): int { + $width = (int) self::get_value('logo_width', 150); + return max(50, min(400, $width)); + } + + + + /** 获取Favicon URL */ + public static function custom_favicon_url(): string { + $favicon = (string) self::get_value('site_favicon', ''); + return $favicon !== '' ? $favicon : ''; + } + + + + public static function favicon_url(): string { + $custom = self::custom_favicon_url(); + if ($custom !== '') { + return $custom; + } + + $site_icon = get_site_icon_url(512); + return $site_icon ? (string) $site_icon : self::default_asset_url('default-favicon.svg'); + } + + + + /** 获取About显示名 */ + /** + * 让 SEO 设置中的 Favicon 覆盖 WordPress 原生 site_icon 输出链路。 + * + * @param string $url + * @param int $size + * @param int $blog_id + * @return string + */ + public static function filter_site_icon_url($url, $size, $blog_id): string { + $custom = self::custom_favicon_url(); + return $custom !== '' ? $custom : (string) $url; + } + + + + /** + * 尝试把自定义 favicon 同步到 WordPress 原生 site_icon 选项。 + * + * 仅当 URL 能映射到媒体库附件时才同步;外链仍通过 get_site_icon_url 过滤覆盖。 + */ + public static function sync_native_site_icon(string $favicon_url): void { + $favicon_url = trim(esc_url_raw($favicon_url)); + + if ($favicon_url === '') { + return; + } + + $attachment_id = attachment_url_to_postid($favicon_url); + if ($attachment_id > 0 && (int) get_option('site_icon', 0) !== $attachment_id) { + update_option('site_icon', $attachment_id); + } + } + + + + /** 启动时对齐原生 site_icon,同步自定义 site_favicon 到原生 site_icon。 */ + public static function maybe_sync_native_site_icon(): void { + $custom = self::custom_favicon_url(); + if ($custom === '') { + return; + } + + self::sync_native_site_icon($custom); + } + + + + public static function about_display_name(): string { + $s = self::get_all(); + return !empty($s['about_display_name']) ? $s['about_display_name'] : get_bloginfo('name'); + } + + + + /** 获取About简介 */ + public static function about_bio(): string { + $s = self::get_all(); + return !empty($s['about_bio']) ? $s['about_bio'] : get_bloginfo('description'); + } + + + + /** 获取About头像URL */ + public static function about_avatar(): string { + $s = self::get_all(); + return $s['about_avatar'] ?? ($s['admin_avatar_url'] ?? ''); + } + + + + /** 获取头像镜像源 */ + public static function avatar_source(): string { + $source = sanitize_text_field((string) self::get_value('avatar_source', 'cn.cravatar.com')); + $allowed = ['cn.cravatar.com', 'cn.gravatar.com', 'gravatar.com']; + return in_array($source, $allowed, true) ? $source : 'cn.cravatar.com'; + } + + + + /** 获取SEO关键词 */ + public static function seo_keywords(): string { + $s = self::get_all(); + return $s['seo_keywords'] ?? ''; + } + + + + /** 获取自定义头部代码 */ + public static function custom_head_code(): string { + $code = (string) self::get_value('head_code', ''); + if ($code === '') { + $code = (string) self::get_value('custom_js_header', ''); + } + return $code; + } + + + + /** 获取自定义 CSS */ + public static function custom_css(): string { + return (string) self::get_value('custom_css', ''); + } + + + + /** 获取自定义底部代码 */ + public static function custom_footer_code(): string { + $code = (string) self::get_value('foot_code', ''); + if ($code === '') { + $code = (string) self::get_value('custom_js_footer', ''); + } + return $code; + } + + + + /** 获取统计代码 */ + public static function trackcode(): string { + return trim((string) self::get_value('trackcode', '')); + } + + + + /** 获取SMTP配置 */ + public static function get_smtp(): array { + return get_option('aksrt_mail_smtp', [ + 'enabled' => false, + 'host' => '', + 'port' => 465, + 'username' => '', + 'password' => '', + 'encryption' => 'ssl', + 'from_name' => '', + 'from_address' => '', + ]); + } + + + + /** 获取邮件品牌设置(发件人昵称/标题前缀/底部内容) */ + public static function get_mail_branding(): array { + $s = self::get_all(); + return [ + 'showname' => trim((string) ($s['mail_showname'] ?? '')), + 'title_prefix' => trim((string) ($s['mail_title_prefix'] ?? '')), + 'description' => trim((string) ($s['mail_description'] ?? '')), + 'more_content' => trim((string) ($s['mail_more_content'] ?? '')), + ]; + } + +} diff --git a/inc/classes/user/class-aksrt-user.php b/inc/classes/user/class-aksrt-user.php index 3cd5560a..8dd35e25 100644 --- a/inc/classes/user/class-aksrt-user.php +++ b/inc/classes/user/class-aksrt-user.php @@ -1,1312 +1,15 @@ [ - 'file' => 'templates/pages/page-login.php', - 'slug' => 'login', - 'title' => '登录', - 'fallback' => '/login/', - ], - 'register' => [ - 'file' => 'templates/pages/page-register.php', - 'slug' => 'register', - 'title' => '注册', - 'fallback' => '/register/', - ], - 'bind' => [ - 'file' => 'templates/pages/page-bind-account.php', - 'slug' => 'bind-account', - 'title' => '绑定账号', - 'fallback' => '/bind-account/', - ], - 'reset-password' => [ - 'file' => 'templates/pages/page-reset-password.php', - 'slug' => 'reset-password', - 'title' => '重置密码', - 'fallback' => '/reset-password/', - ], - 'verify-email' => [ - 'file' => 'templates/pages/page-verify-email.php', - 'slug' => 'verify-email', - 'title' => '邮箱验证', - 'fallback' => '/verify-email/', - ], - 'categories' => [ - 'file' => 'templates/pages/page-categories.php', - 'slug' => 'categories', - 'title' => '分类目录', - 'fallback' => '/categories/', - ], - 'friends' => [ - 'file' => 'templates/pages/page-friends.php', - 'slug' => 'friends', - 'title' => '友情链接', - 'fallback' => '/friends/', - ], - 'profile' => [ - 'file' => 'templates/pages/page-profile.php', - 'slug' => 'profile', - 'title' => '个人中心', - 'fallback' => '/', - ], -]; - -// Zibll-style v2 routing keeps only true fixed template pages in the registry. -$_aksrt_special_templates = [ - 'sign' => [ - 'file' => 'templates/pages/page-sign.php', - 'slug' => 'sign', - 'title' => '登录/注册', - 'fallback' => '/sign/', - ], - 'bind' => [ - 'file' => 'templates/pages/page-bind-account.php', - 'slug' => 'bind-account', - 'title' => '绑定账号', - 'fallback' => '/bind-account/', - ], - 'reset-password' => [ - 'file' => 'templates/pages/page-reset-password.php', - 'slug' => 'reset-password', - 'title' => '重置密码', - 'fallback' => '/reset-password/', - ], - 'verify-email' => [ - 'file' => 'templates/pages/page-verify-email.php', - 'slug' => 'verify-email', - 'title' => '邮箱验证', - 'fallback' => '/verify-email/', - ], - 'categories' => [ - 'file' => 'templates/pages/page-categories.php', - 'slug' => 'categories', - 'title' => '分类目录', - 'fallback' => '/categories/', - ], - 'friends' => [ - 'file' => 'templates/pages/page-friends.php', - 'slug' => 'friends', - 'title' => '友情链接', - 'fallback' => '/friends/', - ], - 'profile' => [ - 'file' => 'templates/pages/page-profile.php', - 'slug' => 'profile', - 'title' => '个人中心', - 'fallback' => '/profile/', - ], -]; - -/** @var array 缓存 key=模板文件路径 => value=WP_Post|null */ -$_aksrt_template_page_cache = []; - - -// ============================================================ -// 二、核心工具函数 -// ============================================================ - -/** - * 查找使用指定模板文件的页面(按模板匹配,自适应任意 slug) - * - * @param string $template_file 相对于主题的模板路径 - * @return WP_Post|null - */ -function aksrt_find_template_page(string $template_file) { - global $_aksrt_template_page_cache; - if (isset($_aksrt_template_page_cache[$template_file])) { - return $_aksrt_template_page_cache[$template_file]; - } - - $page = null; - $pages = get_pages([ - 'meta_key' => '_wp_page_template', - 'meta_value' => $template_file, - 'number' => 1, - 'post_status' => 'publish', - ]); - if (!empty($pages)) { - $page = $pages[0]; - } - - $_aksrt_template_page_cache[$template_file] = $page; - return $page; -} - -/** - * 获取使用指定模板的页面 URL - * - * @param string $key 注册表 key(当前仅 profile 使用) - * @return string 页面 URL 或回退地址 - */ -function aksrt_get_template_url(string $key): string { - global $_aksrt_special_templates; - if (!isset($_aksrt_special_templates[$key])) { - return home_url('/'); - } - - $cfg = $_aksrt_special_templates[$key]; - $page = aksrt_find_template_page($cfg['file']); - - if (!$page) { - aksrt_ensure_template_page($key); - $page = aksrt_find_template_page($cfg['file']); - } - - if ($page) { - return get_permalink($page->ID); - } - - return home_url($cfg['fallback']); -} - -/** - * 定位模板文件的绝对路径 - * - * @param string $template_file 相对于主题的路径 - * @return string|false 文件绝对路径或 false - */ -function aksrt_locate_template_file(string $template_file) { - $path = locate_template([$template_file]); - if ($path) return $path; - - $full = AKSRT_DIR . '/' . $template_file; - return file_exists($full) ? $full : false; -} - -/** - * Resolve a public-facing page template file from stored meta or page slug. - */ -function aksrt_resolve_page_template_file(string $template_file = '', string $post_slug = ''): string { - $template_file = ltrim($template_file, '/\\'); - $post_slug = sanitize_title($post_slug); - - $legacy_auth_templates = [ - 'templates/pages/page-login.php' => 'login', - 'templates/pages/page-register.php' => 'register', - 'templates/pages/page-bind-account.php' => 'bind', - 'templates/pages/page-reset-password.php' => 'reset-password', - 'templates/pages/page-verify-email.php' => 'verify-email', - ]; - - if ($template_file !== '') { - if ($template_file === 'templates/pages/page-sign.php') { - return aksrt_get_auth_template_file(aksrt_get_auth_tab(), true); - } - - if (!empty($legacy_auth_templates[$template_file])) { - return aksrt_get_auth_template_file((string) $legacy_auth_templates[$template_file], true); - } - - if (strpos($template_file, 'templates/pages/') === 0 && aksrt_locate_template_file($template_file)) { - return $template_file; - } - } - - if ($post_slug !== '') { - $auth_slug_map = [ - 'sign' => aksrt_get_auth_tab(), - 'login' => 'login', - 'register' => 'register', - 'bind-account' => 'bind', - 'reset-password' => 'reset-password', - 'verify-email' => 'verify-email', - ]; - - if (!empty($auth_slug_map[$post_slug])) { - return aksrt_get_auth_template_file((string) $auth_slug_map[$post_slug], true); - } - - $slug_map = [ - 'categories' => 'templates/pages/page-categories.php', - 'friends' => 'templates/pages/page-friends.php', - 'profile' => 'templates/pages/page-profile.php', - ]; - - if (!empty($slug_map[$post_slug]) && aksrt_locate_template_file($slug_map[$post_slug])) { - return $slug_map[$post_slug]; - } - } - - return ''; -} - -/** - * Normalize auth view keys used by the unified sign page. - */ -function aksrt_normalize_auth_tab(string $tab): string { - $tab = sanitize_key($tab); - - switch ($tab) { - case 'signin': - case 'login': - return 'login'; - case 'signup': - case 'register': - return 'register'; - case 'bind': - case 'bind-account': - return 'bind'; - case 'reset': - case 'lostpassword': - case 'reset-password': - return 'reset-password'; - case 'verify': - case 'verify-email': - return 'verify-email'; - default: - return 'login'; - } -} - -/** - * Whether an auth tab should be rendered by the unified sign page. - */ -function aksrt_auth_tab_uses_unified_sign_page(string $tab): bool { - return in_array( - aksrt_normalize_auth_tab($tab), - ['login', 'register'], - true - ); -} - -/** - * Resolve the auth template file, preferring the unified sign page when present. - */ -function aksrt_get_auth_template_file(string $tab = 'login', bool $prefer_sign_page = true): string { - $normalized_tab = aksrt_normalize_auth_tab($tab); - $sign_template = 'templates/pages/page-sign.php'; - - if ( - $prefer_sign_page - && aksrt_auth_tab_uses_unified_sign_page($normalized_tab) - && aksrt_locate_template_file($sign_template) - ) { - return $sign_template; - } - - switch ($normalized_tab) { - case 'register': - return 'templates/pages/page-register.php'; - case 'bind': - return 'templates/pages/page-bind-account.php'; - case 'reset-password': - return 'templates/pages/page-reset-password.php'; - case 'verify-email': - return 'templates/pages/page-verify-email.php'; - default: - return 'templates/pages/page-login.php'; - } -} - -/** - * Return the active auth view for sign routes and the sign page template. - */ -function aksrt_get_auth_tab(): string { - $query_tab = get_query_var('aksrt_auth_tab'); - if (is_string($query_tab) && $query_tab !== '') { - return aksrt_normalize_auth_tab($query_tab); - } - - if (!empty($_GET['tab'])) { - return aksrt_normalize_auth_tab((string) wp_unslash($_GET['tab'])); - } - - $post = get_queried_object(); - if ($post instanceof WP_Post) { - return aksrt_normalize_auth_tab((string) $post->post_name); - } - - return 'login'; -} - -/** - * 清除模板页面缓存 - */ -function aksrt_clear_template_page_cache(): void { - global $_aksrt_template_page_cache; - $_aksrt_template_page_cache = []; -} - -/** - * Prepare the public user-home template context and return its template path. - */ -function aksrt_get_user_home_template_path(): string { - global $_aksrt_special_templates, $aksrt_profile_user; - - $nicename = (string) get_query_var('user_nicename'); - if ($nicename === '') { - return ''; - } - - $user = get_user_by('slug', $nicename); - if (!$user instanceof WP_User) { - return ''; - } - - $template_path = aksrt_locate_template_file('templates/pages/page-user-home.php'); - - if (!$template_path) { - return ''; - } - - $aksrt_profile_user = $user; - return $template_path; -} - -/** - * Resolve the active special-page template path for the current request. - */ -function aksrt_get_current_special_template_path(): string { - if (get_query_var('user_nicename')) { - return aksrt_get_user_home_template_path(); - } - - if (get_query_var('aksrt_user_center')) { - $profile_template = aksrt_locate_template_file('templates/pages/page-profile.php'); - return $profile_template ?: ''; - } - - if (get_query_var('aksrt_sign')) { - $auth_template = aksrt_get_auth_template_file((string) get_query_var('aksrt_auth_tab'), true); - $auth_template_path = aksrt_locate_template_file($auth_template); - return $auth_template_path ?: ''; - } - - if (is_page()) { - $page_id = get_queried_object_id(); - if ($page_id > 0) { - $page_template = get_post_meta($page_id, '_wp_page_template', true); - $resolved_template = aksrt_resolve_page_template_file( - is_string($page_template) ? $page_template : '', - (string) get_post_field('post_name', $page_id) - ); - - if ($resolved_template !== '') { - $resolved_path = aksrt_locate_template_file($resolved_template); - if ($resolved_path) { - return $resolved_path; - } - } - } - } - - return ''; -} - -/** - * 根据当前请求路径识别系统模板页 key。 - * - * 用于前台直接访问固定 slug 时的自动补页。 - */ -function aksrt_get_requested_template_key(): string { - global $_aksrt_special_templates; - - $request_uri = isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : ''; - if ($request_uri === '') { - return ''; - } - - $request_path = wp_parse_url($request_uri, PHP_URL_PATH); - if (!is_string($request_path) || $request_path === '') { - return ''; - } - - $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH); - if ($home_path !== '' && str_starts_with($request_path, $home_path)) { - $request_path = (string) substr($request_path, strlen($home_path)); - } - - $request_slug = trim($request_path, '/'); - if ($request_slug === '') { - return ''; - } - - foreach ($_aksrt_special_templates as $key => $cfg) { - $slug = isset($cfg['slug']) ? sanitize_title((string) $cfg['slug']) : ''; - if ($slug !== '' && $slug === $request_slug) { - return (string) $key; - } - } - - return ''; -} - -/** - * 确保使用指定模板的页面存在,缺失则自动创建 - * - * @param string $key 注册表 key - */ -function aksrt_ensure_template_page(string $key): void { - global $_aksrt_special_templates; - if (!isset($_aksrt_special_templates[$key])) return; - - $cfg = $_aksrt_special_templates[$key]; - $desired_slug = sanitize_title($cfg['slug'] ?? $key); - - // 已有页面使用此模板? - if (aksrt_find_template_page($cfg['file'])) return; - - if ($desired_slug !== '') { - $existing_page = get_page_by_path($desired_slug, OBJECT, 'page'); - if ($existing_page instanceof WP_Post) { - if ($existing_page->post_status !== 'publish') { - wp_update_post([ - 'ID' => $existing_page->ID, - 'post_status' => 'publish', - ]); - } - - update_post_meta($existing_page->ID, '_wp_page_template', $cfg['file']); - aksrt_clear_template_page_cache(); - return; - } - } - - // 自动创建 - $page_id = wp_insert_post([ - 'post_title' => $cfg['title'], - 'post_content' => '', - 'post_status' => 'publish', - 'post_type' => 'page', - 'post_name' => $desired_slug !== '' ? $desired_slug : $key, - 'comment_status' => 'closed', - 'ping_status' => 'closed', - ]); - - if (!is_wp_error($page_id) && $page_id > 0) { - update_post_meta($page_id, '_wp_page_template', $cfg['file']); - aksrt_clear_template_page_cache(); - } -} - -// ============================================================ -// 三、URL 路由与模板加载 -// ============================================================ - -// --- 3a. /user/{昵称} 自定义重写 --- -add_filter('query_vars', function ($vars) { - $vars[] = 'aksrt_user_center'; - $vars[] = 'user_nicename'; - return $vars; -}); - -add_action('template_redirect', function () { - if (is_admin() || wp_doing_ajax() || is_feed() || is_embed() || is_preview()) { - return; - } - - $resolved_path = aksrt_get_current_special_template_path(); - if ($resolved_path === '') { - return; - } - - status_header(200); - load_template($resolved_path, true); - exit; -}, 100); - -add_action('init', function () { - add_rewrite_tag('%aksrt_user_center%', '([^&]+)'); - add_rewrite_tag('%user_nicename%', '([^/]+)'); - add_rewrite_rule('^profile/?$', 'index.php?aksrt_user_center=1', 'top'); - add_rewrite_rule('^user/([^/]+)/?$', 'index.php?user_nicename=$matches[1]', 'top'); -}); - -// --- 3b. 统一模板拦截器(核心)--- -// 拦截所有使用主题特殊模板的页面请求,确保正确加载 -add_filter('template_include', function ($template) { - $resolved_path = aksrt_get_current_special_template_path(); - if ($resolved_path !== '') { - return $resolved_path; - } - - return $template; - - global $_aksrt_special_templates; - - // A) /user/{昵称} 路由 → 加载公开主页模板 - $nicename = get_query_var('user_nicename'); - if (!empty($nicename)) { - $user = get_user_by('slug', $nicename); - if (!$user) return $template; // 不存在的用户 → 默认 404 - - $tpl = aksrt_locate_template_file('templates/pages/page-user-home.php'); - if (!$tpl) { - // 兜底:如果新模板不存在,回退到旧 profile 模板 - $tpl = aksrt_locate_template_file($_aksrt_special_templates['profile']['file']); - } - if (!$tpl) return $template; - - global $aksrt_profile_user; - $aksrt_profile_user = $user; - return $tpl; - } - - // B) 普通页面 → 检测是否使用了我们的特殊模板,确保加载正确的模板文件 - // (防止 WP 在某些情况下未能正确解析自定义页面模板) - if (is_page()) { - $page_id = get_queried_object_id(); - if ($page_id) { - $page_template = get_post_meta($page_id, '_wp_page_template', true); - if ($page_template && strpos($page_template, 'templates/pages/') === 0) { - $tpl = aksrt_locate_template_file($page_template); - if ($tpl) return $tpl; - } - } - } - - return $template; -}, 99); - -add_filter('template_include', function ($template) { - $resolved_path = aksrt_get_current_special_template_path(); - if ($resolved_path !== '') { - return $resolved_path; - } - - if (get_query_var('aksrt_user_center')) { - $profile_template = aksrt_locate_template_file('templates/pages/page-profile.php'); - if ($profile_template) { - return $profile_template; - } - } - - if (is_page()) { - $page_id = get_queried_object_id(); - if ($page_id > 0) { - $page_template = get_post_meta($page_id, '_wp_page_template', true); - $resolved_template = aksrt_resolve_page_template_file( - is_string($page_template) ? $page_template : '', - (string) get_post_field('post_name', $page_id) - ); - - if ($resolved_template !== '') { - $resolved_path = aksrt_locate_template_file($resolved_template); - if ($resolved_path) { - return $resolved_path; - } - } - } - } - - return $template; -}, 100); - -// ============================================================ -// 四、自动维护 + flush -// ============================================================ -add_action('init', function () { - global $_aksrt_special_templates; - - // 后台/AJAX:检查并创建缺失页面 - $pages_bootstrapped = get_option('aksrt_template_pages_bootstrapped'); - $requested_template_key = (!is_admin() && !wp_doing_ajax()) ? aksrt_get_requested_template_key() : ''; - if (is_admin() || wp_doing_ajax() || $pages_bootstrapped !== AKSRT_VERSION) { - foreach ($_aksrt_special_templates as $key => $cfg) { - if ($key === 'profile') { - continue; - } - aksrt_ensure_template_page($key); - } - - if ($pages_bootstrapped !== AKSRT_VERSION) { - update_option('aksrt_template_pages_bootstrapped', AKSRT_VERSION); - } - } elseif ($requested_template_key !== '' && $requested_template_key !== 'profile') { - aksrt_ensure_template_page($requested_template_key); - } - - // 统一 flush 检查 - $rewrite_version = AKSRT_VERSION . ':sign-profile-routing-v1'; - $flushed = get_option('aksrt_user_rules_flushed'); - if ($flushed !== $rewrite_version) { - flush_rewrite_rules(false); - update_option('aksrt_user_rules_flushed', $rewrite_version); - } -}); - -// 页面变更时清缓存 -add_action('save_post', function ($pid) { if (get_post_type($pid) === 'page') aksrt_clear_template_page_cache(); }); -add_action('delete_post', function ($pid) { if (get_post_type($pid) === 'page') aksrt_clear_template_page_cache(); }); - -// ============================================================ -// 五、公开 API 函数(兼容旧代码调用) -// ============================================================ - -if (!function_exists('aksrt_get_user_center_url')) : -/** - * 获取本人中心 URL(私有空间入口) - * - * @param string $tab 可选 tab 参数 - * @return string - */ -function aksrt_get_user_center_url(string $tab = ''): string { - if (get_option('permalink_structure')) { - $url = home_url('/profile/'); - } else { - $url = add_query_arg('aksrt_user_center', '1', home_url('/')); - } - - if ($tab !== '') { - $url = add_query_arg('tab', $tab, $url); - } - return $url; -} -endif; - -if (!function_exists('aksrt_get_orders_page_url')) : -/** - * 订单中心页面 URL(模板 templates/pages/page-orders.php)。 - * 未指派页面时返回空串,调用方应隐藏入口。 - */ -function aksrt_get_orders_page_url(): string { - return function_exists('aksrt_get_page_url_by_template') - ? aksrt_get_page_url_by_template('templates/pages/page-orders.php') - : ''; -} -endif; - -if (!function_exists('aksrt_get_cart_page_url')) : -/** - * 购物车页面 URL(模板 templates/pages/page-cart.php)。 - */ -function aksrt_get_cart_page_url(): string { - return function_exists('aksrt_get_page_url_by_template') - ? aksrt_get_page_url_by_template('templates/pages/page-cart.php') - : ''; -} -endif; - -if (!function_exists('aksrt_get_revenue_page_url')) : -/** - * 收益中心页面 URL(模板 templates/pages/page-revenue.php)。 - */ -function aksrt_get_revenue_page_url(): string { - return function_exists('aksrt_get_page_url_by_template') - ? aksrt_get_page_url_by_template('templates/pages/page-revenue.php') - : ''; -} -endif; - -if (!function_exists('aksrt_get_messages_page_url')) : -/** - * 消息中心页面 URL(模板 templates/pages/page-messages.php)。 - */ -function aksrt_get_messages_page_url(): string { - return function_exists('aksrt_get_page_url_by_template') - ? aksrt_get_page_url_by_template('templates/pages/page-messages.php') - : ''; -} -endif; - -if (!function_exists('aksrt_get_user_home_url')) : -/** - * 获取公开用户主页 URL(/user/{nicename}/) - * - * @param WP_User|int|string|null $user 用户对象、ID、nicename 或 null(当前用户) - * @return string - */ -function aksrt_get_user_home_url($user = null): string { - $slug = ''; - if ($user instanceof WP_User) { - $slug = $user->user_nicename; - } elseif (is_int($user) && $user > 0) { - $u = get_userdata($user); - $slug = $u ? $u->user_nicename : ''; - } elseif (is_string($user) && $user) { - $slug = $user; - } else { - $current = wp_get_current_user(); - $slug = $current->ID ? $current->user_nicename : ''; - } - if (!$slug) return home_url('/'); - return home_url('/user/' . trailingslashit($slug)); -} -endif; - -if (!function_exists('aksrt_is_user_center_page')) : -/** - * 判断当前是否为本人中心页面 - */ -function aksrt_is_user_center_page(): bool { - return (bool) get_query_var('aksrt_user_center') - || (is_page_template('templates/pages/page-profile.php') && !(bool) get_query_var('user_nicename')); -} -endif; - -if (!function_exists('aksrt_is_user_home_page')) : -/** - * 判断当前是否为公开用户主页 - */ -function aksrt_is_user_home_page(): bool { - return (bool) get_query_var('user_nicename') - || is_page_template('templates/pages/page-user-home.php'); -} -endif; - -if (!function_exists('aksrt_get_profile_url')) : -/** - * 兼容层:获取用户主页 URL - * - * @deprecated 使用 aksrt_get_user_center_url() 或 aksrt_get_user_home_url() 替代 - * @param WP_User|int|string|null $user - * @return string - */ -function aksrt_get_profile_url($user = null): string { - // 无参调用 → 当前用户主页(公开页) - if ($user === null) { - return aksrt_get_user_home_url(); - } - return aksrt_get_user_home_url($user); -} -endif; - -if (!function_exists('aksrt_get_sign_page_url')) : -/** - * Get the unified auth page URL, with legacy route fallback while the page self-heals. - * - * @param string $tab - * @param array $args - */ -function aksrt_get_sign_page_url(string $tab = 'login', array $args = []): string { - $tab = aksrt_normalize_auth_tab($tab); - - $legacy_fallbacks = [ - 'login' => home_url('/login/'), - 'register' => home_url('/register/'), - 'bind' => home_url('/bind-account/'), - 'reset-password' => home_url('/reset-password/'), - 'verify-email' => home_url('/verify-email/'), - ]; - - $page = aksrt_find_template_page('templates/pages/page-sign.php'); - if (!$page) { - aksrt_ensure_template_page('sign'); - $page = aksrt_find_template_page('templates/pages/page-sign.php'); - } - - if ($page instanceof WP_Post) { - $url = get_permalink($page->ID); - $args = ['tab' => $tab] + $args; - return add_query_arg($args, $url); - } - - $url = $legacy_fallbacks[$tab] ?? home_url('/login/'); - return !empty($args) ? add_query_arg($args, $url) : $url; -} - - -/** - * Determine whether the current request is served by the unified auth experience. - */ -function aksrt_is_auth_page_request(): bool { - return (bool) get_query_var('aksrt_sign') - || is_page_template('templates/pages/page-sign.php') - || is_page_template('templates/pages/page-login.php') - || is_page_template('templates/pages/page-register.php') - || is_page_template('templates/pages/page-bind-account.php') - || is_page_template('templates/pages/page-reset-password.php') - || is_page_template('templates/pages/page-verify-email.php'); -} -endif; - -if (!function_exists('aksrt_get_login_url')) : -/** 获取登录页 URL */ -function aksrt_get_login_url($redirect = '') { - $url = aksrt_get_sign_page_url('login'); - if ($redirect) { - $url = add_query_arg('redirect_to', urlencode($redirect), $url); - } - return $url; -} -endif; - -if (!function_exists('aksrt_get_register_url')) : -/** 获取注册页 URL */ -function aksrt_get_register_url() { - return aksrt_get_sign_page_url('register'); -} -endif; - -if (!function_exists('aksrt_get_bind_url')) : -/** 获取绑定账号页面 URL */ -function aksrt_get_bind_url($transient_key = '') { - $url = aksrt_get_template_url('bind'); - if ($transient_key) { - $url = add_query_arg('obk', $transient_key, $url); - } - return $url; -} -endif; - -if (!function_exists('aksrt_get_logout_url')) : -/** 获取退出登录 URL */ -function aksrt_get_logout_url($redirect = '') { - $target = $redirect ?: home_url('/'); - $url = add_query_arg('redirect_to', rawurlencode($target), home_url('/logout/')); - return wp_nonce_url($url, 'aksrt-logout'); -} -endif; - -if (!function_exists('aksrt_get_reset_password_url')) : -/** - * 获取重置密码页面 URL - * - * @param string $token 主题重置 token(可选,设置密码时需要) - * @param int $user_id 用户 ID(可选,设置密码时需要) - * @return string - */ -function aksrt_get_reset_password_url(string $token = '', int $user_id = 0): string { - $url = aksrt_get_template_url('reset-password'); - if ($token !== '' && $user_id > 0) { - $url = add_query_arg([ - 'token' => $token, - 'uid' => $user_id, - ], $url); - } - return $url; -} -endif; - -if (!function_exists('aksrt_get_verify_email_url')) : -/** 获取邮箱验证页面 URL */ -function aksrt_get_verify_email_url(int $user_id = 0, array $args = []): string { - $url = aksrt_get_template_url('verify-email'); - if ($user_id > 0) { - $args['uid'] = $user_id; - } - if (!empty($args)) { - $url = add_query_arg($args, $url); - } - return $url; -} -endif; - -// Notifications live in the same user/ domain; bootstrap also loads it. -if (!class_exists('AKSRT_User_Notifications', false) && file_exists(__DIR__ . '/class-aksrt-user-notifications.php')) { - require_once __DIR__ . '/class-aksrt-user-notifications.php'; -} - -// ============================================================ -// 六、作者链接改写 -// ============================================================ -add_filter('author_link', function ($link, $author_id) { - $user = get_userdata($author_id); - $slug = $user ? $user->user_nicename : 'user-' . $author_id; - return home_url('/user/' . trailingslashit($slug)); -}, 10, 2); - -add_action('template_redirect', function () { - if (!is_author()) return; - $author_id = get_queried_object_id(); - if ($author_id) { - $user = get_userdata($author_id); - $slug = $user ? $user->user_nicename : 'user-' . $author_id; - wp_safe_redirect(home_url('/user/' . trailingslashit($slug)), 301); - exit; - } -}); - -// ============================================================ -// AJAX: 保存用户个人资料(显示名称、简介、联系方式) -// ============================================================ -add_action('wp_ajax_aksrt_save_profile', function () { - if (!isset($_POST['_profile_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_profile_nonce'])), 'aksrt_profile_save')) { - wp_send_json_error(['message' => __('安全验证失败,请刷新页面重试', 'aksrt')]); - } - - $user_id = isset($_POST['user_id']) ? (int) $_POST['user_id'] : 0; - $result = function_exists('aksrt_save_user_center_profile') - ? aksrt_save_user_center_profile($user_id, $_POST) - : new WP_Error('aksrt_profile_unavailable', __('保存失败', 'aksrt')); - - if (is_wp_error($result)) { - wp_send_json_error(['message' => $result->get_error_message()]); - } - - wp_send_json_success(['message' => __('保存成功!', 'aksrt')]); -}); - -// ============================================================ -// AJAX: 上传打赏收款码 -// ============================================================ -add_action('wp_ajax_aksrt_upload_reward_qr', function () { - if (!isset($_POST['_profile_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_profile_nonce'])), 'aksrt_profile_save')) { - wp_send_json_error(['message' => __('安全验证失败,请刷新页面重试', 'aksrt')]); - } - $user_id = get_current_user_id(); - if ($user_id <= 0) { - wp_send_json_error(['message' => __('请先登录', 'aksrt')]); - } - if (function_exists('aksrt_rewards_enabled') && !aksrt_rewards_enabled()) { - wp_send_json_error(['message' => __('打赏功能未启用', 'aksrt')]); - } - $kind = isset($_POST['kind']) ? sanitize_key((string) $_POST['kind']) : ''; - if (!in_array($kind, ['weixin', 'alipay'], true)) { - wp_send_json_error(['message' => __('无效类型', 'aksrt')]); - } - if (empty($_FILES['file']['name'])) { - wp_send_json_error(['message' => __('请选择图片', 'aksrt')]); - } - - require_once ABSPATH . 'wp-admin/includes/file.php'; - require_once ABSPATH . 'wp-admin/includes/media.php'; - require_once ABSPATH . 'wp-admin/includes/image.php'; - - $file = $_FILES['file']; - $check = wp_check_filetype_and_ext($file['tmp_name'], $file['name']); - $allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; - $mime = (string) ($check['type'] ?? $file['type'] ?? ''); - if ($mime === '' || !in_array($mime, $allowed, true)) { - wp_send_json_error(['message' => __('仅支持 JPG/PNG/GIF/WebP 图片', 'aksrt')]); - } - if (!empty($file['size']) && (int) $file['size'] > 2 * 1024 * 1024) { - wp_send_json_error(['message' => __('图片不能超过 2MB', 'aksrt')]); - } - - $attachment_id = media_handle_upload('file', 0); - if (is_wp_error($attachment_id)) { - wp_send_json_error(['message' => $attachment_id->get_error_message()]); - } - $meta = $kind === 'weixin' ? 'aksrt_rewards_weixin' : 'aksrt_rewards_alipay'; - update_user_meta($user_id, $meta, (int) $attachment_id); - $url = wp_get_attachment_image_url((int) $attachment_id, 'full'); - wp_send_json_success([ - 'message' => __('上传成功', 'aksrt'), - 'id' => (int) $attachment_id, - 'url' => is_string($url) ? $url : '', - 'kind' => $kind, - ]); -}); - - -// ============================================================ -// AJAX: 发送邮箱验证码(修改密码/更改邮箱通用) -// ============================================================ - -if (!function_exists('aksrt_get_email_code_key')) : -function aksrt_get_email_code_key(int $user_id, string $scene): string { - return 'aksrt_email_code_' . $scene . '_' . $user_id; -} -endif; - -if (!function_exists('aksrt_get_email_code_rate_key')) : -function aksrt_get_email_code_rate_key(int $user_id, string $scene, string $target = ''): string { - $suffix = $target !== '' ? '_' . md5(strtolower($target)) : ''; - return 'aksrt_email_code_rate_' . $scene . '_' . $user_id . $suffix; -} -endif; - -if (!function_exists('aksrt_store_email_code')) : -function aksrt_store_email_code(int $user_id, string $scene, string $code, string $target = ''): void { - set_transient( - aksrt_get_email_code_key($user_id, $scene), - [ - 'hash' => wp_hash_password($code), - 'target' => $target, - 'issued_at' => time(), - ], - 5 * MINUTE_IN_SECONDS - ); -} -endif; - -if (!function_exists('aksrt_consume_email_code')) : -function aksrt_consume_email_code(int $user_id, string $scene, string $code, string $target = ''): bool { - $key = aksrt_get_email_code_key($user_id, $scene); - $stored = get_transient($key); - if (!is_array($stored) || empty($stored['hash']) || !wp_check_password($code, $stored['hash'])) { - return false; - } - if ($target !== '' && strtolower((string) ($stored['target'] ?? '')) !== strtolower($target)) { - return false; - } - - delete_transient($key); - return true; -} - -endif; - -add_action('wp_ajax_aksrt_send_email_code', function () { - if ( ! isset( $_POST['_settings_nonce'] ) || ! wp_verify_nonce( $_POST['_settings_nonce'], 'aksrt_account_settings' ) ) { - wp_send_json_error( array( 'message' => __( '安全验证失败,请刷新页面重试', 'aksrt' ) ) ); - } - - if ( ! is_user_logged_in() ) { - wp_send_json_error( array( 'message' => __( '请先登录', 'aksrt' ) ) ); - } - - $scene = isset( $_POST['scene'] ) ? sanitize_key( (string) $_POST['scene'] ) : ''; - if ( ! in_array( $scene, array( 'change_password', 'change_email' ), true ) ) { - wp_send_json_error( array( 'message' => __( '无效的验证场景', 'aksrt' ) ) ); - } - - if ( function_exists( 'aksrt_require_captcha' ) ) { - $captcha_result = aksrt_require_captcha( $scene, wp_unslash( $_POST ) ); - if ( $captcha_result !== true ) { - wp_send_json_error( array( 'message' => $captcha_result ) ); - } - } - - $current_user = wp_get_current_user(); - $target_email = ''; - $success_message = ''; - - if ( $scene === 'change_password' ) { - $target_email = $current_user->user_email; - if ( empty( $target_email ) || ! is_email( $target_email ) ) { - wp_send_json_error( array( 'message' => __( '当前账号未绑定有效邮箱', 'aksrt' ) ) ); - } - $success_message = __( '验证码已发送到您的注册邮箱', 'aksrt' ); - } else { - $new_email = isset( $_POST['new_email'] ) ? trim( wp_unslash( $_POST['new_email'] ) ) : ''; - $current_password = isset( $_POST['current_password'] ) ? trim( wp_unslash( $_POST['current_password'] ) ) : ''; - - if ( empty( $current_password ) || ! wp_check_password( $current_password, $current_user->user_pass, $current_user->ID ) ) { - wp_send_json_error( array( 'message' => __( '当前密码不正确', 'aksrt' ) ) ); - } - if ( empty( $new_email ) || ! is_email( $new_email ) ) { - wp_send_json_error( array( 'message' => __( '请输入有效的新邮箱地址', 'aksrt' ) ) ); - } - if ( strtolower( $new_email ) === strtolower( $current_user->user_email ) ) { - wp_send_json_error( array( 'message' => __( '新邮箱与当前邮箱相同', 'aksrt' ) ) ); - } - if ( email_exists( $new_email ) ) { - wp_send_json_error( array( 'message' => __( '该邮箱已被其他账号使用', 'aksrt' ) ) ); - } - - $target_email = $new_email; - $success_message = __( '验证码已发送到新邮箱,请完成验证后再提交', 'aksrt' ); - } - - // 频率限制:60秒内不能重复发送 - $rate_key = aksrt_get_email_code_rate_key( $current_user->ID, $scene, $target_email ); - if ( get_transient( $rate_key ) ) { - wp_send_json_error( array( 'message' => __( '操作过于频繁,请稍后再试', 'aksrt' ) ) ); - } - set_transient( $rate_key, true, 60 ); - - // 生成验证码并同步发送,只有真正发信成功后才写入验证码缓存。 - $code = str_pad( (string) random_int( 0, 999999 ), 6, '0', STR_PAD_LEFT ); - - $site_name = get_bloginfo( 'name' ); - $default_subject = '[' . $site_name . '] ' . __( '邮箱验证码', 'aksrt' ); - $default_content = sprintf( - __( "您好 %s,\n\n您正在进行敏感操作验证,验证码为:\n\n【%s】\n\n验证码有效期为 5 分钟。如非本人操作,请忽略此邮件。", 'aksrt' ), - $current_user->display_name, - $code - ); - $sent = class_exists( 'AKSRT_Settings' ) - ? AKSRT_Settings::send_event_email( - 'change_code', $target_email, $default_subject, $default_content, - [ '{username}' => $current_user->display_name, '{site_name}' => $site_name, '{code}' => $code ] - ) - : wp_mail( $target_email, $default_subject, $default_content, [ 'Content-Type: text/plain; charset=UTF-8' ] ); - - if ( ! $sent ) { - delete_transient( $rate_key ); - error_log( '[AKSRT] 验证码邮件发送失败 user_id=' . $current_user->ID . ' email=' . $target_email . ' scene=' . $scene ); - wp_send_json_error( array( 'message' => __( '验证码邮件发送失败,请稍后重试', 'aksrt' ) ) ); - } - - aksrt_store_email_code( $current_user->ID, $scene, $code, $target_email ); - - wp_send_json_success( array( 'message' => $success_message ) ); -}); - -// ============================================================ -// AJAX: 修改密码 -// ============================================================ -add_action('wp_ajax_aksrt_change_password', function () { - if ( ! isset( $_POST['_settings_nonce'] ) || ! wp_verify_nonce( $_POST['_settings_nonce'], 'aksrt_account_settings' ) ) { - wp_send_json_error( array( 'message' => __( '安全验证失败,请刷新页面重试', 'aksrt' ) ) ); - } - - $user_id = get_current_user_id(); - if ( ! $user_id ) { - wp_send_json_error( array( 'message' => __( '请先登录', 'aksrt' ) ) ); - } - - $old_password = isset( $_POST['old_password'] ) ? trim( wp_unslash( $_POST['old_password'] ) ) : ''; - $new_password = isset( $_POST['new_password'] ) ? trim( wp_unslash( $_POST['new_password'] ) ) : ''; - $new_password_confirm = isset( $_POST['new_password_confirm'] ) ? trim( wp_unslash( $_POST['new_password_confirm'] ) ) : ''; - $email_code = isset( $_POST['email_code'] ) ? sanitize_text_field( $_POST['email_code'] ) : ''; - - // 验证原密码 - $user = get_userdata( $user_id ); - if ( ! $user || ! wp_check_password( $old_password, $user->data->user_pass, $user_id ) ) { - wp_send_json_error( array( 'message' => __( '原密码不正确', 'aksrt' ) ) ); - } - - // 验证新密码强度 - if ( mb_strlen( $new_password ) < 6 ) { - wp_send_json_error( array( 'message' => __( '新密码长度至少6位', 'aksrt' ) ) ); - } - if ( mb_strlen( $new_password ) > 128 ) { - wp_send_json_error( array( 'message' => __( '新密码过长', 'aksrt' ) ) ); - } - if ( $new_password !== $new_password_confirm ) { - wp_send_json_error( array( 'message' => __( '两次输入的新密码不一致', 'aksrt' ) ) ); - } - - // 人机验证 - if ( function_exists( 'aksrt_require_captcha' ) ) { - $captcha_result = aksrt_require_captcha( 'change_password', wp_unslash( $_POST ) ); - if ( $captcha_result !== true ) { - wp_send_json_error( array( 'message' => $captcha_result ) ); - } - } - - // 验证邮箱验证码 - if ( ! aksrt_consume_email_code( $user_id, 'change_password', $email_code, $user->user_email ) ) { - wp_send_json_error( array( 'message' => __( '邮箱验证码错误或已过期', 'aksrt' ) ) ); - } - - // 设置新密码 - wp_set_password( $new_password, $user_id ); - - if ( class_exists( 'WP_Session_Tokens' ) ) { - WP_Session_Tokens::get_instance( $user_id )->destroy_all(); - } - - // 清除登录态 - wp_clear_auth_cookie(); - - wp_send_json_success( array( - 'message' => __( '密码修改成功,即将跳转到登录页…', 'aksrt' ), - 'redirect_to' => aksrt_get_login_url(), - )); -}); - -// ============================================================ -// AJAX: 更改邮箱 -// ============================================================ -add_action('wp_ajax_aksrt_change_email', function () { - if ( ! isset( $_POST['_settings_nonce'] ) || ! wp_verify_nonce( $_POST['_settings_nonce'], 'aksrt_account_settings' ) ) { - wp_send_json_error( array( 'message' => __( '安全验证失败,请刷新页面重试', 'aksrt' ) ) ); - } - - $user_id = get_current_user_id(); - if ( ! $user_id ) { - wp_send_json_error( array( 'message' => __( '请先登录', 'aksrt' ) ) ); - } - - $current_password = isset( $_POST['current_password'] ) ? trim( wp_unslash( $_POST['current_password'] ) ) : ''; - $new_email = isset( $_POST['new_email'] ) ? trim( wp_unslash( $_POST['new_email'] ) ) : ''; - $email_code = isset( $_POST['email_code'] ) ? sanitize_text_field( $_POST['email_code'] ) : ''; - - $current_user = wp_get_current_user(); - if ( empty( $current_password ) || ! wp_check_password( $current_password, $current_user->user_pass, $current_user->ID ) ) { - wp_send_json_error( array( 'message' => __( '当前密码不正确', 'aksrt' ) ) ); - } - - // 验证新邮箱格式 - if ( empty( $new_email ) || ! is_email( $new_email ) ) { - wp_send_json_error( array( 'message' => __( '请输入有效的邮箱地址', 'aksrt' ) ) ); - } - - if ( strtolower( $new_email ) === strtolower( $current_user->user_email ) ) { - wp_send_json_error( array( 'message' => __( '新邮箱与当前邮箱相同', 'aksrt' ) ) ); - } - - // 检查邮箱是否已被使用 - if ( email_exists( $new_email ) ) { - wp_send_json_error( array( 'message' => __( '该邮箱已被其他账号使用', 'aksrt' ) ) ); - } - - // 人机验证 - if ( function_exists( 'aksrt_require_captcha' ) ) { - $captcha_result = aksrt_require_captcha( 'change_email', wp_unslash( $_POST ) ); - if ( $captcha_result !== true ) { - wp_send_json_error( array( 'message' => $captcha_result ) ); - } - } - - // 验证邮箱验证码 - if ( ! aksrt_consume_email_code( $user_id, 'change_email', $email_code, $new_email ) ) { - wp_send_json_error( array( 'message' => __( '邮箱验证码错误或已过期', 'aksrt' ) ) ); - } - - // 更新邮箱 - $old_email = $current_user->user_email; - $update_result = wp_update_user( [ - 'ID' => $user_id, - 'user_email' => $new_email, - ] ); - - if ( is_wp_error( $update_result ) ) { - wp_send_json_error( array( 'message' => $update_result->get_error_message() ?: __( '邮箱更新失败,请稍后重试', 'aksrt' ) ) ); - } - - if ( is_email( $old_email ) ) { - $site_name = get_bloginfo( 'name' ); - $default_subject = '[' . $site_name . '] ' . __( '账号邮箱已变更', 'aksrt' ); - $default_content = sprintf( - __( "您好 %s,\n\n您的账号邮箱已成功修改为:%s\n\n如果这不是您的操作,请立即联系站点管理员。", 'aksrt' ), - $current_user->display_name, - $new_email - ); - if ( class_exists( 'AKSRT_Settings' ) ) { - AKSRT_Settings::send_event_email( - 'change_notice', $old_email, $default_subject, $default_content, - [ '{username}' => $current_user->display_name, '{site_name}' => $site_name, '{new_email}' => $new_email ] - ); - } else { - wp_mail( $old_email, $default_subject, $default_content, [ 'Content-Type: text/plain; charset=UTF-8' ] ); - } - } - - // ====== 邮箱变更后重置验证状态(阶段 5)====== - // 1. 写入 14 天回滚窗口 meta(管理员可在此期内还原旧邮箱) - // 2. 把 aksrt_email_verified 重置为 0 - // 3. 若功能启用 → 立即给新邮箱发送验证邮件 - update_user_meta( $user_id, 'aksrt_email_previous', $old_email ); - update_user_meta( $user_id, 'aksrt_email_changed_at', time() ); - - $verify_url = ''; - $verify_message = ''; - if ( class_exists( 'AKSRT_Settings' ) && class_exists( 'AKSRT_Auth_Service' ) - && AKSRT_Settings::registration_requires_email_verification() ) { - update_user_meta( $user_id, 'aksrt_email_verified', 0 ); - // 用户对自身的 edit_user 权限映射到 'read',因此 admin_send_verification 会通过权限校验。 - $verify_result = AKSRT_Auth_Service::admin_send_verification( $user_id, $user_id ); - if ( ! empty( $verify_result['success'] ) ) { - $verify_url = function_exists('aksrt_get_verify_email_url') ? aksrt_get_verify_email_url( $user_id ) : ''; - $verify_message = __( '请前往新邮箱完成验证,否则下次登录将被拦截。', 'aksrt' ); - } - } - - if ( class_exists( 'WP_Session_Tokens' ) ) { - WP_Session_Tokens::get_instance( $user_id )->destroy_all(); - } - - // 清除登录态 - wp_clear_auth_cookie(); - - wp_send_json_success( array( - 'message' => $verify_message !== '' - ? __( '邮箱修改成功,请前往新邮箱完成验证后重新登录…', 'aksrt' ) - : __( '邮箱修改成功,即将跳转到登录页…', 'aksrt' ), - 'redirect_to' => $verify_url !== '' ? $verify_url : aksrt_get_login_url(), - 'verify_url' => $verify_url, - )); -}); +require_once __DIR__ . '/user-parts/aksrt-user-template-registry.php'; +require_once __DIR__ . '/user-parts/aksrt-user-urls.php'; +require_once __DIR__ . '/user-parts/aksrt-user-routing.php'; +require_once __DIR__ . '/user-parts/aksrt-user-account-ajax.php'; diff --git a/inc/classes/user/user-parts/aksrt-user-account-ajax.php b/inc/classes/user/user-parts/aksrt-user-account-ajax.php new file mode 100644 index 00000000..dfde8814 --- /dev/null +++ b/inc/classes/user/user-parts/aksrt-user-account-ajax.php @@ -0,0 +1,392 @@ + __('安全验证失败,请刷新页面重试', 'aksrt')]); + } + + $user_id = isset($_POST['user_id']) ? (int) $_POST['user_id'] : 0; + $result = function_exists('aksrt_save_user_center_profile') + ? aksrt_save_user_center_profile($user_id, $_POST) + : new WP_Error('aksrt_profile_unavailable', __('保存失败', 'aksrt')); + + if (is_wp_error($result)) { + wp_send_json_error(['message' => $result->get_error_message()]); + } + + wp_send_json_success(['message' => __('保存成功!', 'aksrt')]); +}); + +// ============================================================ +// AJAX: 上传打赏收款码 +// ============================================================ +add_action('wp_ajax_aksrt_upload_reward_qr', function () { + if (!isset($_POST['_profile_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_profile_nonce'])), 'aksrt_profile_save')) { + wp_send_json_error(['message' => __('安全验证失败,请刷新页面重试', 'aksrt')]); + } + $user_id = get_current_user_id(); + if ($user_id <= 0) { + wp_send_json_error(['message' => __('请先登录', 'aksrt')]); + } + if (function_exists('aksrt_rewards_enabled') && !aksrt_rewards_enabled()) { + wp_send_json_error(['message' => __('打赏功能未启用', 'aksrt')]); + } + $kind = isset($_POST['kind']) ? sanitize_key((string) $_POST['kind']) : ''; + if (!in_array($kind, ['weixin', 'alipay'], true)) { + wp_send_json_error(['message' => __('无效类型', 'aksrt')]); + } + if (empty($_FILES['file']['name'])) { + wp_send_json_error(['message' => __('请选择图片', 'aksrt')]); + } + + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/media.php'; + require_once ABSPATH . 'wp-admin/includes/image.php'; + + $file = $_FILES['file']; + $check = wp_check_filetype_and_ext($file['tmp_name'], $file['name']); + $allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + $mime = (string) ($check['type'] ?? $file['type'] ?? ''); + if ($mime === '' || !in_array($mime, $allowed, true)) { + wp_send_json_error(['message' => __('仅支持 JPG/PNG/GIF/WebP 图片', 'aksrt')]); + } + if (!empty($file['size']) && (int) $file['size'] > 2 * 1024 * 1024) { + wp_send_json_error(['message' => __('图片不能超过 2MB', 'aksrt')]); + } + + $attachment_id = media_handle_upload('file', 0); + if (is_wp_error($attachment_id)) { + wp_send_json_error(['message' => $attachment_id->get_error_message()]); + } + $meta = $kind === 'weixin' ? 'aksrt_rewards_weixin' : 'aksrt_rewards_alipay'; + update_user_meta($user_id, $meta, (int) $attachment_id); + $url = wp_get_attachment_image_url((int) $attachment_id, 'full'); + wp_send_json_success([ + 'message' => __('上传成功', 'aksrt'), + 'id' => (int) $attachment_id, + 'url' => is_string($url) ? $url : '', + 'kind' => $kind, + ]); +}); + + +// ============================================================ +// AJAX: 发送邮箱验证码(修改密码/更改邮箱通用) +// ============================================================ + +if (!function_exists('aksrt_get_email_code_key')) : +function aksrt_get_email_code_key(int $user_id, string $scene): string { + return 'aksrt_email_code_' . $scene . '_' . $user_id; +} +endif; + +if (!function_exists('aksrt_get_email_code_rate_key')) : +function aksrt_get_email_code_rate_key(int $user_id, string $scene, string $target = ''): string { + $suffix = $target !== '' ? '_' . md5(strtolower($target)) : ''; + return 'aksrt_email_code_rate_' . $scene . '_' . $user_id . $suffix; +} +endif; + +if (!function_exists('aksrt_store_email_code')) : +function aksrt_store_email_code(int $user_id, string $scene, string $code, string $target = ''): void { + set_transient( + aksrt_get_email_code_key($user_id, $scene), + [ + 'hash' => wp_hash_password($code), + 'target' => $target, + 'issued_at' => time(), + ], + 5 * MINUTE_IN_SECONDS + ); +} +endif; + +if (!function_exists('aksrt_consume_email_code')) : +function aksrt_consume_email_code(int $user_id, string $scene, string $code, string $target = ''): bool { + $key = aksrt_get_email_code_key($user_id, $scene); + $stored = get_transient($key); + if (!is_array($stored) || empty($stored['hash']) || !wp_check_password($code, $stored['hash'])) { + return false; + } + if ($target !== '' && strtolower((string) ($stored['target'] ?? '')) !== strtolower($target)) { + return false; + } + + delete_transient($key); + return true; +} + +endif; + +add_action('wp_ajax_aksrt_send_email_code', function () { + if ( ! isset( $_POST['_settings_nonce'] ) || ! wp_verify_nonce( $_POST['_settings_nonce'], 'aksrt_account_settings' ) ) { + wp_send_json_error( array( 'message' => __( '安全验证失败,请刷新页面重试', 'aksrt' ) ) ); + } + + if ( ! is_user_logged_in() ) { + wp_send_json_error( array( 'message' => __( '请先登录', 'aksrt' ) ) ); + } + + $scene = isset( $_POST['scene'] ) ? sanitize_key( (string) $_POST['scene'] ) : ''; + if ( ! in_array( $scene, array( 'change_password', 'change_email' ), true ) ) { + wp_send_json_error( array( 'message' => __( '无效的验证场景', 'aksrt' ) ) ); + } + + if ( function_exists( 'aksrt_require_captcha' ) ) { + $captcha_result = aksrt_require_captcha( $scene, wp_unslash( $_POST ) ); + if ( $captcha_result !== true ) { + wp_send_json_error( array( 'message' => $captcha_result ) ); + } + } + + $current_user = wp_get_current_user(); + $target_email = ''; + $success_message = ''; + + if ( $scene === 'change_password' ) { + $target_email = $current_user->user_email; + if ( empty( $target_email ) || ! is_email( $target_email ) ) { + wp_send_json_error( array( 'message' => __( '当前账号未绑定有效邮箱', 'aksrt' ) ) ); + } + $success_message = __( '验证码已发送到您的注册邮箱', 'aksrt' ); + } else { + $new_email = isset( $_POST['new_email'] ) ? trim( wp_unslash( $_POST['new_email'] ) ) : ''; + $current_password = isset( $_POST['current_password'] ) ? trim( wp_unslash( $_POST['current_password'] ) ) : ''; + + if ( empty( $current_password ) || ! wp_check_password( $current_password, $current_user->user_pass, $current_user->ID ) ) { + wp_send_json_error( array( 'message' => __( '当前密码不正确', 'aksrt' ) ) ); + } + if ( empty( $new_email ) || ! is_email( $new_email ) ) { + wp_send_json_error( array( 'message' => __( '请输入有效的新邮箱地址', 'aksrt' ) ) ); + } + if ( strtolower( $new_email ) === strtolower( $current_user->user_email ) ) { + wp_send_json_error( array( 'message' => __( '新邮箱与当前邮箱相同', 'aksrt' ) ) ); + } + if ( email_exists( $new_email ) ) { + wp_send_json_error( array( 'message' => __( '该邮箱已被其他账号使用', 'aksrt' ) ) ); + } + + $target_email = $new_email; + $success_message = __( '验证码已发送到新邮箱,请完成验证后再提交', 'aksrt' ); + } + + // 频率限制:60秒内不能重复发送 + $rate_key = aksrt_get_email_code_rate_key( $current_user->ID, $scene, $target_email ); + if ( get_transient( $rate_key ) ) { + wp_send_json_error( array( 'message' => __( '操作过于频繁,请稍后再试', 'aksrt' ) ) ); + } + set_transient( $rate_key, true, 60 ); + + // 生成验证码并同步发送,只有真正发信成功后才写入验证码缓存。 + $code = str_pad( (string) random_int( 0, 999999 ), 6, '0', STR_PAD_LEFT ); + + $site_name = get_bloginfo( 'name' ); + $default_subject = '[' . $site_name . '] ' . __( '邮箱验证码', 'aksrt' ); + $default_content = sprintf( + __( "您好 %s,\n\n您正在进行敏感操作验证,验证码为:\n\n【%s】\n\n验证码有效期为 5 分钟。如非本人操作,请忽略此邮件。", 'aksrt' ), + $current_user->display_name, + $code + ); + $sent = class_exists( 'AKSRT_Settings' ) + ? AKSRT_Settings::send_event_email( + 'change_code', $target_email, $default_subject, $default_content, + [ '{username}' => $current_user->display_name, '{site_name}' => $site_name, '{code}' => $code ] + ) + : wp_mail( $target_email, $default_subject, $default_content, [ 'Content-Type: text/plain; charset=UTF-8' ] ); + + if ( ! $sent ) { + delete_transient( $rate_key ); + error_log( '[AKSRT] 验证码邮件发送失败 user_id=' . $current_user->ID . ' email=' . $target_email . ' scene=' . $scene ); + wp_send_json_error( array( 'message' => __( '验证码邮件发送失败,请稍后重试', 'aksrt' ) ) ); + } + + aksrt_store_email_code( $current_user->ID, $scene, $code, $target_email ); + + wp_send_json_success( array( 'message' => $success_message ) ); +}); + +// ============================================================ +// AJAX: 修改密码 +// ============================================================ +add_action('wp_ajax_aksrt_change_password', function () { + if ( ! isset( $_POST['_settings_nonce'] ) || ! wp_verify_nonce( $_POST['_settings_nonce'], 'aksrt_account_settings' ) ) { + wp_send_json_error( array( 'message' => __( '安全验证失败,请刷新页面重试', 'aksrt' ) ) ); + } + + $user_id = get_current_user_id(); + if ( ! $user_id ) { + wp_send_json_error( array( 'message' => __( '请先登录', 'aksrt' ) ) ); + } + + $old_password = isset( $_POST['old_password'] ) ? trim( wp_unslash( $_POST['old_password'] ) ) : ''; + $new_password = isset( $_POST['new_password'] ) ? trim( wp_unslash( $_POST['new_password'] ) ) : ''; + $new_password_confirm = isset( $_POST['new_password_confirm'] ) ? trim( wp_unslash( $_POST['new_password_confirm'] ) ) : ''; + $email_code = isset( $_POST['email_code'] ) ? sanitize_text_field( $_POST['email_code'] ) : ''; + + // 验证原密码 + $user = get_userdata( $user_id ); + if ( ! $user || ! wp_check_password( $old_password, $user->data->user_pass, $user_id ) ) { + wp_send_json_error( array( 'message' => __( '原密码不正确', 'aksrt' ) ) ); + } + + // 验证新密码强度 + if ( mb_strlen( $new_password ) < 6 ) { + wp_send_json_error( array( 'message' => __( '新密码长度至少6位', 'aksrt' ) ) ); + } + if ( mb_strlen( $new_password ) > 128 ) { + wp_send_json_error( array( 'message' => __( '新密码过长', 'aksrt' ) ) ); + } + if ( $new_password !== $new_password_confirm ) { + wp_send_json_error( array( 'message' => __( '两次输入的新密码不一致', 'aksrt' ) ) ); + } + + // 人机验证 + if ( function_exists( 'aksrt_require_captcha' ) ) { + $captcha_result = aksrt_require_captcha( 'change_password', wp_unslash( $_POST ) ); + if ( $captcha_result !== true ) { + wp_send_json_error( array( 'message' => $captcha_result ) ); + } + } + + // 验证邮箱验证码 + if ( ! aksrt_consume_email_code( $user_id, 'change_password', $email_code, $user->user_email ) ) { + wp_send_json_error( array( 'message' => __( '邮箱验证码错误或已过期', 'aksrt' ) ) ); + } + + // 设置新密码 + wp_set_password( $new_password, $user_id ); + + if ( class_exists( 'WP_Session_Tokens' ) ) { + WP_Session_Tokens::get_instance( $user_id )->destroy_all(); + } + + // 清除登录态 + wp_clear_auth_cookie(); + + wp_send_json_success( array( + 'message' => __( '密码修改成功,即将跳转到登录页…', 'aksrt' ), + 'redirect_to' => aksrt_get_login_url(), + )); +}); + +// ============================================================ +// AJAX: 更改邮箱 +// ============================================================ +add_action('wp_ajax_aksrt_change_email', function () { + if ( ! isset( $_POST['_settings_nonce'] ) || ! wp_verify_nonce( $_POST['_settings_nonce'], 'aksrt_account_settings' ) ) { + wp_send_json_error( array( 'message' => __( '安全验证失败,请刷新页面重试', 'aksrt' ) ) ); + } + + $user_id = get_current_user_id(); + if ( ! $user_id ) { + wp_send_json_error( array( 'message' => __( '请先登录', 'aksrt' ) ) ); + } + + $current_password = isset( $_POST['current_password'] ) ? trim( wp_unslash( $_POST['current_password'] ) ) : ''; + $new_email = isset( $_POST['new_email'] ) ? trim( wp_unslash( $_POST['new_email'] ) ) : ''; + $email_code = isset( $_POST['email_code'] ) ? sanitize_text_field( $_POST['email_code'] ) : ''; + + $current_user = wp_get_current_user(); + if ( empty( $current_password ) || ! wp_check_password( $current_password, $current_user->user_pass, $current_user->ID ) ) { + wp_send_json_error( array( 'message' => __( '当前密码不正确', 'aksrt' ) ) ); + } + + // 验证新邮箱格式 + if ( empty( $new_email ) || ! is_email( $new_email ) ) { + wp_send_json_error( array( 'message' => __( '请输入有效的邮箱地址', 'aksrt' ) ) ); + } + + if ( strtolower( $new_email ) === strtolower( $current_user->user_email ) ) { + wp_send_json_error( array( 'message' => __( '新邮箱与当前邮箱相同', 'aksrt' ) ) ); + } + + // 检查邮箱是否已被使用 + if ( email_exists( $new_email ) ) { + wp_send_json_error( array( 'message' => __( '该邮箱已被其他账号使用', 'aksrt' ) ) ); + } + + // 人机验证 + if ( function_exists( 'aksrt_require_captcha' ) ) { + $captcha_result = aksrt_require_captcha( 'change_email', wp_unslash( $_POST ) ); + if ( $captcha_result !== true ) { + wp_send_json_error( array( 'message' => $captcha_result ) ); + } + } + + // 验证邮箱验证码 + if ( ! aksrt_consume_email_code( $user_id, 'change_email', $email_code, $new_email ) ) { + wp_send_json_error( array( 'message' => __( '邮箱验证码错误或已过期', 'aksrt' ) ) ); + } + + // 更新邮箱 + $old_email = $current_user->user_email; + $update_result = wp_update_user( [ + 'ID' => $user_id, + 'user_email' => $new_email, + ] ); + + if ( is_wp_error( $update_result ) ) { + wp_send_json_error( array( 'message' => $update_result->get_error_message() ?: __( '邮箱更新失败,请稍后重试', 'aksrt' ) ) ); + } + + if ( is_email( $old_email ) ) { + $site_name = get_bloginfo( 'name' ); + $default_subject = '[' . $site_name . '] ' . __( '账号邮箱已变更', 'aksrt' ); + $default_content = sprintf( + __( "您好 %s,\n\n您的账号邮箱已成功修改为:%s\n\n如果这不是您的操作,请立即联系站点管理员。", 'aksrt' ), + $current_user->display_name, + $new_email + ); + if ( class_exists( 'AKSRT_Settings' ) ) { + AKSRT_Settings::send_event_email( + 'change_notice', $old_email, $default_subject, $default_content, + [ '{username}' => $current_user->display_name, '{site_name}' => $site_name, '{new_email}' => $new_email ] + ); + } else { + wp_mail( $old_email, $default_subject, $default_content, [ 'Content-Type: text/plain; charset=UTF-8' ] ); + } + } + + // ====== 邮箱变更后重置验证状态(阶段 5)====== + // 1. 写入 14 天回滚窗口 meta(管理员可在此期内还原旧邮箱) + // 2. 把 aksrt_email_verified 重置为 0 + // 3. 若功能启用 → 立即给新邮箱发送验证邮件 + update_user_meta( $user_id, 'aksrt_email_previous', $old_email ); + update_user_meta( $user_id, 'aksrt_email_changed_at', time() ); + + $verify_url = ''; + $verify_message = ''; + if ( class_exists( 'AKSRT_Settings' ) && class_exists( 'AKSRT_Auth_Service' ) + && AKSRT_Settings::registration_requires_email_verification() ) { + update_user_meta( $user_id, 'aksrt_email_verified', 0 ); + // 用户对自身的 edit_user 权限映射到 'read',因此 admin_send_verification 会通过权限校验。 + $verify_result = AKSRT_Auth_Service::admin_send_verification( $user_id, $user_id ); + if ( ! empty( $verify_result['success'] ) ) { + $verify_url = function_exists('aksrt_get_verify_email_url') ? aksrt_get_verify_email_url( $user_id ) : ''; + $verify_message = __( '请前往新邮箱完成验证,否则下次登录将被拦截。', 'aksrt' ); + } + } + + if ( class_exists( 'WP_Session_Tokens' ) ) { + WP_Session_Tokens::get_instance( $user_id )->destroy_all(); + } + + // 清除登录态 + wp_clear_auth_cookie(); + + wp_send_json_success( array( + 'message' => $verify_message !== '' + ? __( '邮箱修改成功,请前往新邮箱完成验证后重新登录…', 'aksrt' ) + : __( '邮箱修改成功,即将跳转到登录页…', 'aksrt' ), + 'redirect_to' => $verify_url !== '' ? $verify_url : aksrt_get_login_url(), + 'verify_url' => $verify_url, + )); +}); diff --git a/inc/classes/user/user-parts/aksrt-user-routing.php b/inc/classes/user/user-parts/aksrt-user-routing.php new file mode 100644 index 00000000..4d605ae5 --- /dev/null +++ b/inc/classes/user/user-parts/aksrt-user-routing.php @@ -0,0 +1,181 @@ + 0) { + $page_template = get_post_meta($page_id, '_wp_page_template', true); + $resolved_template = aksrt_resolve_page_template_file( + is_string($page_template) ? $page_template : '', + (string) get_post_field('post_name', $page_id) + ); + + if ($resolved_template !== '') { + $resolved_path = aksrt_locate_template_file($resolved_template); + if ($resolved_path) { + return $resolved_path; + } + } + } + } + + return $template; +}, 100); + +// ============================================================ +// 四、自动维护 + flush +// ============================================================ +add_action('init', function () { + global $_aksrt_special_templates; + + // 后台/AJAX:检查并创建缺失页面 + $pages_bootstrapped = get_option('aksrt_template_pages_bootstrapped'); + $requested_template_key = (!is_admin() && !wp_doing_ajax()) ? aksrt_get_requested_template_key() : ''; + if (is_admin() || wp_doing_ajax() || $pages_bootstrapped !== AKSRT_VERSION) { + foreach ($_aksrt_special_templates as $key => $cfg) { + if ($key === 'profile') { + continue; + } + aksrt_ensure_template_page($key); + } + + if ($pages_bootstrapped !== AKSRT_VERSION) { + update_option('aksrt_template_pages_bootstrapped', AKSRT_VERSION); + } + } elseif ($requested_template_key !== '' && $requested_template_key !== 'profile') { + aksrt_ensure_template_page($requested_template_key); + } + + // 统一 flush 检查 + $rewrite_version = AKSRT_VERSION . ':sign-profile-routing-v1'; + $flushed = get_option('aksrt_user_rules_flushed'); + if ($flushed !== $rewrite_version) { + flush_rewrite_rules(false); + update_option('aksrt_user_rules_flushed', $rewrite_version); + } +}); + +// 页面变更时清缓存 +add_action('save_post', function ($pid) { if (get_post_type($pid) === 'page') aksrt_clear_template_page_cache(); }); +add_action('delete_post', function ($pid) { if (get_post_type($pid) === 'page') aksrt_clear_template_page_cache(); }); + +// ============================================================ +// 六、作者链接改写 +// ============================================================ +add_filter('author_link', function ($link, $author_id) { + $user = get_userdata($author_id); + $slug = $user ? $user->user_nicename : 'user-' . $author_id; + return home_url('/user/' . trailingslashit($slug)); +}, 10, 2); + +add_action('template_redirect', function () { + if (!is_author()) return; + $author_id = get_queried_object_id(); + if ($author_id) { + $user = get_userdata($author_id); + $slug = $user ? $user->user_nicename : 'user-' . $author_id; + wp_safe_redirect(home_url('/user/' . trailingslashit($slug)), 301); + exit; + } +}); + +// ============================================================ +// AJAX: 保存用户个人资料(显示名称、简介、联系方式) diff --git a/inc/classes/user/user-parts/aksrt-user-template-registry.php b/inc/classes/user/user-parts/aksrt-user-template-registry.php new file mode 100644 index 00000000..79d48b87 --- /dev/null +++ b/inc/classes/user/user-parts/aksrt-user-template-registry.php @@ -0,0 +1,492 @@ + [ + 'file' => 'templates/pages/page-login.php', + 'slug' => 'login', + 'title' => '登录', + 'fallback' => '/login/', + ], + 'register' => [ + 'file' => 'templates/pages/page-register.php', + 'slug' => 'register', + 'title' => '注册', + 'fallback' => '/register/', + ], + 'bind' => [ + 'file' => 'templates/pages/page-bind-account.php', + 'slug' => 'bind-account', + 'title' => '绑定账号', + 'fallback' => '/bind-account/', + ], + 'reset-password' => [ + 'file' => 'templates/pages/page-reset-password.php', + 'slug' => 'reset-password', + 'title' => '重置密码', + 'fallback' => '/reset-password/', + ], + 'verify-email' => [ + 'file' => 'templates/pages/page-verify-email.php', + 'slug' => 'verify-email', + 'title' => '邮箱验证', + 'fallback' => '/verify-email/', + ], + 'categories' => [ + 'file' => 'templates/pages/page-categories.php', + 'slug' => 'categories', + 'title' => '分类目录', + 'fallback' => '/categories/', + ], + 'friends' => [ + 'file' => 'templates/pages/page-friends.php', + 'slug' => 'friends', + 'title' => '友情链接', + 'fallback' => '/friends/', + ], + 'profile' => [ + 'file' => 'templates/pages/page-profile.php', + 'slug' => 'profile', + 'title' => '个人中心', + 'fallback' => '/', + ], +]; + +// Zibll-style v2 routing keeps only true fixed template pages in the registry. +$_aksrt_special_templates = [ + 'sign' => [ + 'file' => 'templates/pages/page-sign.php', + 'slug' => 'sign', + 'title' => '登录/注册', + 'fallback' => '/sign/', + ], + 'bind' => [ + 'file' => 'templates/pages/page-bind-account.php', + 'slug' => 'bind-account', + 'title' => '绑定账号', + 'fallback' => '/bind-account/', + ], + 'reset-password' => [ + 'file' => 'templates/pages/page-reset-password.php', + 'slug' => 'reset-password', + 'title' => '重置密码', + 'fallback' => '/reset-password/', + ], + 'verify-email' => [ + 'file' => 'templates/pages/page-verify-email.php', + 'slug' => 'verify-email', + 'title' => '邮箱验证', + 'fallback' => '/verify-email/', + ], + 'categories' => [ + 'file' => 'templates/pages/page-categories.php', + 'slug' => 'categories', + 'title' => '分类目录', + 'fallback' => '/categories/', + ], + 'friends' => [ + 'file' => 'templates/pages/page-friends.php', + 'slug' => 'friends', + 'title' => '友情链接', + 'fallback' => '/friends/', + ], + 'profile' => [ + 'file' => 'templates/pages/page-profile.php', + 'slug' => 'profile', + 'title' => '个人中心', + 'fallback' => '/profile/', + ], +]; + +/** @var array 缓存 key=模板文件路径 => value=WP_Post|null */ +$_aksrt_template_page_cache = []; + + +// ============================================================ +// 二、核心工具函数 +// ============================================================ + +/** + * 查找使用指定模板文件的页面(按模板匹配,自适应任意 slug) + * + * @param string $template_file 相对于主题的模板路径 + * @return WP_Post|null + */ +function aksrt_find_template_page(string $template_file) { + global $_aksrt_template_page_cache; + if (isset($_aksrt_template_page_cache[$template_file])) { + return $_aksrt_template_page_cache[$template_file]; + } + + $page = null; + $pages = get_pages([ + 'meta_key' => '_wp_page_template', + 'meta_value' => $template_file, + 'number' => 1, + 'post_status' => 'publish', + ]); + if (!empty($pages)) { + $page = $pages[0]; + } + + $_aksrt_template_page_cache[$template_file] = $page; + return $page; +} + +/** + * 获取使用指定模板的页面 URL + * + * @param string $key 注册表 key(当前仅 profile 使用) + * @return string 页面 URL 或回退地址 + */ +function aksrt_get_template_url(string $key): string { + global $_aksrt_special_templates; + if (!isset($_aksrt_special_templates[$key])) { + return home_url('/'); + } + + $cfg = $_aksrt_special_templates[$key]; + $page = aksrt_find_template_page($cfg['file']); + + if (!$page) { + aksrt_ensure_template_page($key); + $page = aksrt_find_template_page($cfg['file']); + } + + if ($page) { + return get_permalink($page->ID); + } + + return home_url($cfg['fallback']); +} + +/** + * 定位模板文件的绝对路径 + * + * @param string $template_file 相对于主题的路径 + * @return string|false 文件绝对路径或 false + */ +function aksrt_locate_template_file(string $template_file) { + $path = locate_template([$template_file]); + if ($path) return $path; + + $full = AKSRT_DIR . '/' . $template_file; + return file_exists($full) ? $full : false; +} + +/** + * Resolve a public-facing page template file from stored meta or page slug. + */ +function aksrt_resolve_page_template_file(string $template_file = '', string $post_slug = ''): string { + $template_file = ltrim($template_file, '/\\'); + $post_slug = sanitize_title($post_slug); + + $legacy_auth_templates = [ + 'templates/pages/page-login.php' => 'login', + 'templates/pages/page-register.php' => 'register', + 'templates/pages/page-bind-account.php' => 'bind', + 'templates/pages/page-reset-password.php' => 'reset-password', + 'templates/pages/page-verify-email.php' => 'verify-email', + ]; + + if ($template_file !== '') { + if ($template_file === 'templates/pages/page-sign.php') { + return aksrt_get_auth_template_file(aksrt_get_auth_tab(), true); + } + + if (!empty($legacy_auth_templates[$template_file])) { + return aksrt_get_auth_template_file((string) $legacy_auth_templates[$template_file], true); + } + + if (strpos($template_file, 'templates/pages/') === 0 && aksrt_locate_template_file($template_file)) { + return $template_file; + } + } + + if ($post_slug !== '') { + $auth_slug_map = [ + 'sign' => aksrt_get_auth_tab(), + 'login' => 'login', + 'register' => 'register', + 'bind-account' => 'bind', + 'reset-password' => 'reset-password', + 'verify-email' => 'verify-email', + ]; + + if (!empty($auth_slug_map[$post_slug])) { + return aksrt_get_auth_template_file((string) $auth_slug_map[$post_slug], true); + } + + $slug_map = [ + 'categories' => 'templates/pages/page-categories.php', + 'friends' => 'templates/pages/page-friends.php', + 'profile' => 'templates/pages/page-profile.php', + ]; + + if (!empty($slug_map[$post_slug]) && aksrt_locate_template_file($slug_map[$post_slug])) { + return $slug_map[$post_slug]; + } + } + + return ''; +} + +/** + * Normalize auth view keys used by the unified sign page. + */ +function aksrt_normalize_auth_tab(string $tab): string { + $tab = sanitize_key($tab); + + switch ($tab) { + case 'signin': + case 'login': + return 'login'; + case 'signup': + case 'register': + return 'register'; + case 'bind': + case 'bind-account': + return 'bind'; + case 'reset': + case 'lostpassword': + case 'reset-password': + return 'reset-password'; + case 'verify': + case 'verify-email': + return 'verify-email'; + default: + return 'login'; + } +} + +/** + * Whether an auth tab should be rendered by the unified sign page. + */ +function aksrt_auth_tab_uses_unified_sign_page(string $tab): bool { + return in_array( + aksrt_normalize_auth_tab($tab), + ['login', 'register'], + true + ); +} + +/** + * Resolve the auth template file, preferring the unified sign page when present. + */ +function aksrt_get_auth_template_file(string $tab = 'login', bool $prefer_sign_page = true): string { + $normalized_tab = aksrt_normalize_auth_tab($tab); + $sign_template = 'templates/pages/page-sign.php'; + + if ( + $prefer_sign_page + && aksrt_auth_tab_uses_unified_sign_page($normalized_tab) + && aksrt_locate_template_file($sign_template) + ) { + return $sign_template; + } + + switch ($normalized_tab) { + case 'register': + return 'templates/pages/page-register.php'; + case 'bind': + return 'templates/pages/page-bind-account.php'; + case 'reset-password': + return 'templates/pages/page-reset-password.php'; + case 'verify-email': + return 'templates/pages/page-verify-email.php'; + default: + return 'templates/pages/page-login.php'; + } +} + +/** + * Return the active auth view for sign routes and the sign page template. + */ +function aksrt_get_auth_tab(): string { + $query_tab = get_query_var('aksrt_auth_tab'); + if (is_string($query_tab) && $query_tab !== '') { + return aksrt_normalize_auth_tab($query_tab); + } + + if (!empty($_GET['tab'])) { + return aksrt_normalize_auth_tab((string) wp_unslash($_GET['tab'])); + } + + $post = get_queried_object(); + if ($post instanceof WP_Post) { + return aksrt_normalize_auth_tab((string) $post->post_name); + } + + return 'login'; +} + +/** + * 清除模板页面缓存 + */ +function aksrt_clear_template_page_cache(): void { + global $_aksrt_template_page_cache; + $_aksrt_template_page_cache = []; +} + +/** + * Prepare the public user-home template context and return its template path. + */ +function aksrt_get_user_home_template_path(): string { + global $_aksrt_special_templates, $aksrt_profile_user; + + $nicename = (string) get_query_var('user_nicename'); + if ($nicename === '') { + return ''; + } + + $user = get_user_by('slug', $nicename); + if (!$user instanceof WP_User) { + return ''; + } + + $template_path = aksrt_locate_template_file('templates/pages/page-user-home.php'); + + if (!$template_path) { + return ''; + } + + $aksrt_profile_user = $user; + return $template_path; +} + +/** + * Resolve the active special-page template path for the current request. + */ +function aksrt_get_current_special_template_path(): string { + if (get_query_var('user_nicename')) { + return aksrt_get_user_home_template_path(); + } + + if (get_query_var('aksrt_user_center')) { + $profile_template = aksrt_locate_template_file('templates/pages/page-profile.php'); + return $profile_template ?: ''; + } + + if (get_query_var('aksrt_sign')) { + $auth_template = aksrt_get_auth_template_file((string) get_query_var('aksrt_auth_tab'), true); + $auth_template_path = aksrt_locate_template_file($auth_template); + return $auth_template_path ?: ''; + } + + if (is_page()) { + $page_id = get_queried_object_id(); + if ($page_id > 0) { + $page_template = get_post_meta($page_id, '_wp_page_template', true); + $resolved_template = aksrt_resolve_page_template_file( + is_string($page_template) ? $page_template : '', + (string) get_post_field('post_name', $page_id) + ); + + if ($resolved_template !== '') { + $resolved_path = aksrt_locate_template_file($resolved_template); + if ($resolved_path) { + return $resolved_path; + } + } + } + } + + return ''; +} + +/** + * 根据当前请求路径识别系统模板页 key。 + * + * 用于前台直接访问固定 slug 时的自动补页。 + */ +function aksrt_get_requested_template_key(): string { + global $_aksrt_special_templates; + + $request_uri = isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : ''; + if ($request_uri === '') { + return ''; + } + + $request_path = wp_parse_url($request_uri, PHP_URL_PATH); + if (!is_string($request_path) || $request_path === '') { + return ''; + } + + $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH); + if ($home_path !== '' && str_starts_with($request_path, $home_path)) { + $request_path = (string) substr($request_path, strlen($home_path)); + } + + $request_slug = trim($request_path, '/'); + if ($request_slug === '') { + return ''; + } + + foreach ($_aksrt_special_templates as $key => $cfg) { + $slug = isset($cfg['slug']) ? sanitize_title((string) $cfg['slug']) : ''; + if ($slug !== '' && $slug === $request_slug) { + return (string) $key; + } + } + + return ''; +} + +/** + * 确保使用指定模板的页面存在,缺失则自动创建 + * + * @param string $key 注册表 key + */ +function aksrt_ensure_template_page(string $key): void { + global $_aksrt_special_templates; + if (!isset($_aksrt_special_templates[$key])) return; + + $cfg = $_aksrt_special_templates[$key]; + $desired_slug = sanitize_title($cfg['slug'] ?? $key); + + // 已有页面使用此模板? + if (aksrt_find_template_page($cfg['file'])) return; + + if ($desired_slug !== '') { + $existing_page = get_page_by_path($desired_slug, OBJECT, 'page'); + if ($existing_page instanceof WP_Post) { + if ($existing_page->post_status !== 'publish') { + wp_update_post([ + 'ID' => $existing_page->ID, + 'post_status' => 'publish', + ]); + } + + update_post_meta($existing_page->ID, '_wp_page_template', $cfg['file']); + aksrt_clear_template_page_cache(); + return; + } + } + + // 自动创建 + $page_id = wp_insert_post([ + 'post_title' => $cfg['title'], + 'post_content' => '', + 'post_status' => 'publish', + 'post_type' => 'page', + 'post_name' => $desired_slug !== '' ? $desired_slug : $key, + 'comment_status' => 'closed', + 'ping_status' => 'closed', + ]); + + if (!is_wp_error($page_id) && $page_id > 0) { + update_post_meta($page_id, '_wp_page_template', $cfg['file']); + aksrt_clear_template_page_cache(); + } +} + diff --git a/inc/classes/user/user-parts/aksrt-user-urls.php b/inc/classes/user/user-parts/aksrt-user-urls.php new file mode 100644 index 00000000..6fd6c95c --- /dev/null +++ b/inc/classes/user/user-parts/aksrt-user-urls.php @@ -0,0 +1,267 @@ +user_nicename; + } elseif (is_int($user) && $user > 0) { + $u = get_userdata($user); + $slug = $u ? $u->user_nicename : ''; + } elseif (is_string($user) && $user) { + $slug = $user; + } else { + $current = wp_get_current_user(); + $slug = $current->ID ? $current->user_nicename : ''; + } + if (!$slug) return home_url('/'); + return home_url('/user/' . trailingslashit($slug)); +} +endif; + +if (!function_exists('aksrt_is_user_center_page')) : +/** + * 判断当前是否为本人中心页面 + */ +function aksrt_is_user_center_page(): bool { + return (bool) get_query_var('aksrt_user_center') + || (is_page_template('templates/pages/page-profile.php') && !(bool) get_query_var('user_nicename')); +} +endif; + +if (!function_exists('aksrt_is_user_home_page')) : +/** + * 判断当前是否为公开用户主页 + */ +function aksrt_is_user_home_page(): bool { + return (bool) get_query_var('user_nicename') + || is_page_template('templates/pages/page-user-home.php'); +} +endif; + +if (!function_exists('aksrt_get_profile_url')) : +/** + * 兼容层:获取用户主页 URL + * + * @deprecated 使用 aksrt_get_user_center_url() 或 aksrt_get_user_home_url() 替代 + * @param WP_User|int|string|null $user + * @return string + */ +function aksrt_get_profile_url($user = null): string { + // 无参调用 → 当前用户主页(公开页) + if ($user === null) { + return aksrt_get_user_home_url(); + } + return aksrt_get_user_home_url($user); +} +endif; + +if (!function_exists('aksrt_get_sign_page_url')) : +/** + * Get the unified auth page URL, with legacy route fallback while the page self-heals. + * + * @param string $tab + * @param array $args + */ +function aksrt_get_sign_page_url(string $tab = 'login', array $args = []): string { + $tab = aksrt_normalize_auth_tab($tab); + + $legacy_fallbacks = [ + 'login' => home_url('/login/'), + 'register' => home_url('/register/'), + 'bind' => home_url('/bind-account/'), + 'reset-password' => home_url('/reset-password/'), + 'verify-email' => home_url('/verify-email/'), + ]; + + $page = aksrt_find_template_page('templates/pages/page-sign.php'); + if (!$page) { + aksrt_ensure_template_page('sign'); + $page = aksrt_find_template_page('templates/pages/page-sign.php'); + } + + if ($page instanceof WP_Post) { + $url = get_permalink($page->ID); + $args = ['tab' => $tab] + $args; + return add_query_arg($args, $url); + } + + $url = $legacy_fallbacks[$tab] ?? home_url('/login/'); + return !empty($args) ? add_query_arg($args, $url) : $url; +} + + +/** + * Determine whether the current request is served by the unified auth experience. + */ +function aksrt_is_auth_page_request(): bool { + return (bool) get_query_var('aksrt_sign') + || is_page_template('templates/pages/page-sign.php') + || is_page_template('templates/pages/page-login.php') + || is_page_template('templates/pages/page-register.php') + || is_page_template('templates/pages/page-bind-account.php') + || is_page_template('templates/pages/page-reset-password.php') + || is_page_template('templates/pages/page-verify-email.php'); +} +endif; + +if (!function_exists('aksrt_get_login_url')) : +/** 获取登录页 URL */ +function aksrt_get_login_url($redirect = '') { + $url = aksrt_get_sign_page_url('login'); + if ($redirect) { + $url = add_query_arg('redirect_to', urlencode($redirect), $url); + } + return $url; +} +endif; + +if (!function_exists('aksrt_get_register_url')) : +/** 获取注册页 URL */ +function aksrt_get_register_url() { + return aksrt_get_sign_page_url('register'); +} +endif; + +if (!function_exists('aksrt_get_bind_url')) : +/** 获取绑定账号页面 URL */ +function aksrt_get_bind_url($transient_key = '') { + $url = aksrt_get_template_url('bind'); + if ($transient_key) { + $url = add_query_arg('obk', $transient_key, $url); + } + return $url; +} +endif; + +if (!function_exists('aksrt_get_logout_url')) : +/** 获取退出登录 URL */ +function aksrt_get_logout_url($redirect = '') { + $target = $redirect ?: home_url('/'); + $url = add_query_arg('redirect_to', rawurlencode($target), home_url('/logout/')); + return wp_nonce_url($url, 'aksrt-logout'); +} +endif; + +if (!function_exists('aksrt_get_reset_password_url')) : +/** + * 获取重置密码页面 URL + * + * @param string $token 主题重置 token(可选,设置密码时需要) + * @param int $user_id 用户 ID(可选,设置密码时需要) + * @return string + */ +function aksrt_get_reset_password_url(string $token = '', int $user_id = 0): string { + $url = aksrt_get_template_url('reset-password'); + if ($token !== '' && $user_id > 0) { + $url = add_query_arg([ + 'token' => $token, + 'uid' => $user_id, + ], $url); + } + return $url; +} +endif; + +if (!function_exists('aksrt_get_verify_email_url')) : +/** 获取邮箱验证页面 URL */ +function aksrt_get_verify_email_url(int $user_id = 0, array $args = []): string { + $url = aksrt_get_template_url('verify-email'); + if ($user_id > 0) { + $args['uid'] = $user_id; + } + if (!empty($args)) { + $url = add_query_arg($args, $url); + } + return $url; +} +endif; + +// Notifications live in the same user/ domain; bootstrap also loads it. +if (!class_exists('AKSRT_User_Notifications', false) && file_exists(__DIR__ . '/class-aksrt-user-notifications.php')) { + require_once __DIR__ . '/class-aksrt-user-notifications.php'; +} + diff --git a/tools/adr-classes-domain-placement.md b/tools/adr-classes-domain-placement.md new file mode 100644 index 00000000..991fb92e --- /dev/null +++ b/tools/adr-classes-domain-placement.md @@ -0,0 +1,37 @@ +# ADR: Domain folder placement for `inc/classes` + +## Status +Accepted (2026-07-17) + +## Context +After moving root class files into domain folders, freeze placement for boundary types so later moves are intentional. + +## Decision + +| Concern | Domain | Why | +|---------|--------|-----| +| Theme shell (setup, scripts, templates, SEO, meta boxes, DB migrator, front-submit, docs presenter, home modules, stats, redirects, update checker, comment actions) | `core/` | Shared CMS plumbing | +| SPA shell + controllers + schema + native ops | `admin/` | Dual-surface IA | +| Settings facade + runtime delegates | `settings/` | Single public settings API | +| Auth, OAuth, captcha, moderation, email rollback | `auth/` | Identity & access | +| User center/home/badges/level/metrics/notifications/follow | `user/` | Account surface | +| Credits, balance wallet, signin, invite, points-free | `credits/` | Points + cash wallet | +| Download tokens/state, content pay, VIP, visibility | `content/` | Post-centric paid content | +| Topic pay, plate follow pay | `forum/` | Forum SKUs (not generic content pay) | +| Orders, gateways, coupon, charge-card, revenue, creator-revenue, withdrawal, rebate, discount, after-sale, shipping, wechat-notify | `payment/` | Commerce + settlement | +| Shop services/meta/reviews | `shop/` | Product catalog / cart | +| SMS stack | `sms/` | Messaging channel | +| AI runtime (admin AI controller stays in `admin/`) | `ai/` | Generation helpers | +| Message, private message, site notice, report center | `messages/` | In-site messaging | +| MeiliSearch, search-push, ip-geo, content-audit, api-audit | `extensions/` | Optional integrations | +| Widgets + registry | `widgets/` | WP widgets | +| Inline style / custom code / loading overlay | `frontend/` | Asset render helpers | + +### Rules +- Traits live next to owner: `{domain}/{slug}-traits/`. +- Loader: manual `require_once` in `inc/bootstrap.php` only; traits before shells. +- Do not move `topic-pay` / `plate-follow-pay` into `content/` without updating this ADR. +- `creator-revenue` and `wechat-notify` stay under `payment/`. + +## Out of scope +PSR-4 class namespaces; class renames.