From 450b9f44b90db6f969de0f140901c340838640f5 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 7 Aug 2026 09:15:42 +0800 Subject: [PATCH 1/2] feat: add creator income order tracking\n\nCo-Authored-By: Claude Fable 5 --- src/Admin/Native/PayIncome.php | 2 +- src/Content/PayOrders.php | 9 +- src/Core/DBMigrator.php | 2 +- src/Core/DBMigratorPart2.php | 20 ++- src/Payment/CreatorRevenue.php | 187 ++++---------------- src/Payment/Revenue.php | 292 +++++++++++++++++--------------- src/Settings/PaymentRuntime.php | 6 +- 7 files changed, 217 insertions(+), 301 deletions(-) diff --git a/src/Admin/Native/PayIncome.php b/src/Admin/Native/PayIncome.php index c4e2686d..e5e6bef8 100644 --- a/src/Admin/Native/PayIncome.php +++ b/src/Admin/Native/PayIncome.php @@ -88,7 +88,7 @@ class PayIncome { } foreach ($items as $row) { $id = (int) ($row['id'] ?? 0); - $user_id = (int) ($row['user_id'] ?? 0); + $user_id = (int) ($row['author_id'] ?? 0); $user = $user_id > 0 ? get_userdata($user_id) : null; $st = (string) ($row['status'] ?? ''); echo ''; diff --git a/src/Content/PayOrders.php b/src/Content/PayOrders.php index b0012039..2a8a49a0 100644 --- a/src/Content/PayOrders.php +++ b/src/Content/PayOrders.php @@ -55,11 +55,18 @@ trait PayOrders { // Consume coupon + write affiliate commission (cash content only; idempotent). $this->finalize_content_order_side_effects($order_number, $paid_order); if (class_exists('\AKSRT\Payment\OrderRepository')) { - \AKSRT\Payment\OrderRepository::get_instance()->update_status( + $completed = \AKSRT\Payment\OrderRepository::get_instance()->update_status( $order_number, \AKSRT\Payment\Order::STATUS_COMPLETED, ['note' => __('内容权益已发放。', 'aksrt')] ); + if ($completed && class_exists('\AKSRT\Payment\CreatorRevenue')) { + $completed_order = array_merge($paid_order, [ + 'order_number' => $order_number, + 'status' => \AKSRT\Payment\Order::STATUS_COMPLETED, + ]); + \AKSRT\Payment\CreatorRevenue::get_instance()->create_from_paid_order($completed_order); + } } } diff --git a/src/Core/DBMigrator.php b/src/Core/DBMigrator.php index 6226d0a8..c409687e 100644 --- a/src/Core/DBMigrator.php +++ b/src/Core/DBMigrator.php @@ -13,7 +13,7 @@ class DBMigrator { use DBMigratorIdentity; /** Current schema version; changing it re-runs dbDelta. */ - const SCHEMA_VERSION = '1.18.0'; + const SCHEMA_VERSION = '1.19.0'; const OPT_KEY = 'aksrt_db_schema_version'; diff --git a/src/Core/DBMigratorPart2.php b/src/Core/DBMigratorPart2.php index 949c8973..3cf19c52 100644 --- a/src/Core/DBMigratorPart2.php +++ b/src/Core/DBMigratorPart2.php @@ -187,19 +187,29 @@ trait DBMigratorPart2 { ) {$charset_collate};"; dbDelta($sql_order); - // ---------- 9. 收益台账 ---------- + // ---------- 9. 作者收益台账 ---------- $sql_revenue = "CREATE TABLE {$prefix}aksrt_revenue ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - user_id BIGINT UNSIGNED NOT NULL, + author_id BIGINT UNSIGNED NOT NULL DEFAULT 0, order_number VARCHAR(32) NOT NULL DEFAULT '', - amount DECIMAL(10,2) NOT NULL DEFAULT 0, - type VARCHAR(32) NOT NULL DEFAULT 'income', + post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + buyer_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + currency VARCHAR(8) NOT NULL DEFAULT 'CNY', + original_amount DECIMAL(18,2) NOT NULL DEFAULT 0, + share_ratio DECIMAL(5,2) NOT NULL DEFAULT 0, + author_level TINYINT UNSIGNED NOT NULL DEFAULT 0, + amount DECIMAL(18,2) NOT NULL DEFAULT 0, + type VARCHAR(32) NOT NULL DEFAULT 'order', status VARCHAR(20) NOT NULL DEFAULT 'pending', created_at DATETIME NOT NULL, settled_at DATETIME DEFAULT NULL, PRIMARY KEY (id), - KEY idx_user (user_id), + UNIQUE KEY uniq_creator_order (author_id, order_number), + KEY idx_author (author_id), KEY idx_order (order_number), + KEY idx_post (post_id), + KEY idx_buyer (buyer_id), + KEY idx_currency (currency), KEY idx_status (status), KEY idx_created (created_at) ) {$charset_collate};"; diff --git a/src/Payment/CreatorRevenue.php b/src/Payment/CreatorRevenue.php index 47f718a4..c9d9f5fa 100644 --- a/src/Payment/CreatorRevenue.php +++ b/src/Payment/CreatorRevenue.php @@ -9,164 +9,35 @@ defined('ABSPATH') || exit; class CreatorRevenue { - private static ?CreatorRevenue $instance = null; + private static ?CreatorRevenue $instance = null; - public static function get_instance(): self { - if (null === self::$instance) { - self::$instance = new self(); - } - return self::$instance; - } - - public function init(): void { - add_action('aksrt_order_paid', [$this, 'handle_order_paid'], 20, 2); - } - - /** - * Whether creator revenue settlement is enabled. - */ - private function is_enabled(): bool { - if (!class_exists('AKSRT\\Payment\\Revenue') || !class_exists('\AKSRT\Settings\Settings')) { - return false; - } - $settings = \AKSRT\Settings\Settings::get_payment_revenue_settings(); - return !empty($settings['enabled']); - } - - /** - * Resolve share ratio (percentage 0-100) for a given product author. - */ - private function resolve_share_ratio(int $author_id): float { - $settings = \AKSRT\Settings\Settings::get_payment_revenue_settings(); - $ratio = (float) ($settings['share_ratio'] ?? 0); - - // VIP-tier author overrides, if configured (>0 wins). - if ($author_id > 0 && function_exists('aksrt_get_user_vip_level')) { - $level = (int) aksrt_get_user_vip_level($author_id); - if ($level === 1 && (float) ($settings['ratio_vip1'] ?? 0) > 0) { - $ratio = (float) $settings['ratio_vip1']; - } elseif ($level >= 2 && (float) ($settings['ratio_vip2'] ?? 0) > 0) { - $ratio = (float) $settings['ratio_vip2']; - } - } - - return max(0.0, min(100.0, $ratio)); - } - - /** - * Guard: has this order already been settled into revenue? - */ - private function already_settled(string $order_number): bool { - if ($order_number === '' || !class_exists('\AKSRT\Core\DBMigrator')) { - return false; - } - global $wpdb; - $table = \AKSRT\Core\DBMigrator::table('revenue'); - if ($table === '') { - return false; - } - $count = (int) $wpdb->get_var($wpdb->prepare( - "SELECT COUNT(*) FROM {$table} WHERE order_number = %s AND type = %s", - $order_number, - 'order' - )); - return $count > 0; - } - - /** - * aksrt_order_paid listener: credit creators their share. - * - * @param string $order_number Paid order number. - * @param array $order Order row (status=paid). - */ - public function handle_order_paid(string $order_number, array $order): void { - if (!$this->is_enabled() || $order_number === '') { - return; - } - if ($this->already_settled($order_number)) { - return; - } - - $buyer_id = (int) ($order['user_id'] ?? 0); - - $this->settle_non_shop_cash_order($order_number, $order, $buyer_id); - } - - /** - * Content / topic cash orders (no shop line items). - */ - private function settle_non_shop_cash_order(string $order_number, array $order, int $buyer_id): void { - $meta = $this->decode_note($order); - if (($meta['pay_modo'] ?? '') === 'points') { - return; - } - - $payment_method = sanitize_key((string) ($order['payment_method'] ?? '')); - $currency = sanitize_key((string) ($order['currency'] ?? '')); - if ($payment_method === 'points' || $currency === 'points') { - return; - } - - $base_amount = round((float) ($order['amount'] ?? $meta['payable'] ?? 0), 2); - if ($base_amount <= 0) { - return; - } - - $product_type = sanitize_key((string) ($order['product_type'] ?? '')); - $product_id = (int) ($order['product_id'] ?? 0); - if ($product_id <= 0) { - return; - } - - $post = get_post($product_id); - if (!($post instanceof \WP_Post)) { - return; - } - - $is_content = !empty($meta['content_pay']) - && $post->post_type === 'post'; - - // Resource without content_pay and not a known cash kind: skip. - if (!$is_content) { - return; - } - - $author_id = (int) $post->post_author; - if ($author_id <= 0 || $author_id === $buyer_id) { - return; - } - - $ratio = $this->resolve_share_ratio($author_id); - if ($ratio <= 0) { - return; - } - - $amount = round($base_amount * $ratio / 100, 2); - if ($amount <= 0) { - return; - } - - // Re-check after shop-less path; shop path already returned. - if ($this->already_settled($order_number)) { - return; - } - - Revenue::get_instance()->add($author_id, $amount, $order_number, 'order'); - } - - /** - * @param array $order - * @return array - */ - private function decode_note(array $order): array { - if (isset($order['meta_payload']) && is_array($order['meta_payload'])) { - return $order['meta_payload']; - } - $note = (string) ($order['note'] ?? ''); - if ($note === '') { - return []; - } - $decoded = json_decode($note, true); - return is_array($decoded) ? $decoded : []; + public static function get_instance(): self { + if (null === self::$instance) { + self::$instance = new self(); } + return self::$instance; } + + public function init(): void { + // Revenue is generated by the content fulfillment completion boundary. + } + + /** + * Create creator income after content entitlement has been delivered. + * + * @param array $order + */ + public function create_from_paid_order(array $order): int { + if (!$this->is_enabled() || !class_exists('AKSRT\\Payment\\Revenue')) { + return 0; + } + return Revenue::get_instance()->create_from_paid_order($order); + } + + private function is_enabled(): bool { + if (!class_exists('AKSRT\\Settings\\Settings')) { + return false; + } + return !empty(\AKSRT\Settings\Settings::get_payment_revenue_settings()['enabled']); + } +} diff --git a/src/Payment/Revenue.php b/src/Payment/Revenue.php index 49287f33..9f687aed 100644 --- a/src/Payment/Revenue.php +++ b/src/Payment/Revenue.php @@ -12,7 +12,9 @@ class Revenue { private static $instance = null; public static function get_instance(): self { - if (null === self::$instance) self::$instance = new self(); + if (null === self::$instance) { + self::$instance = new self(); + } return self::$instance; } @@ -21,27 +23,90 @@ class Revenue { } /** - * 记录收益 + * Create one pending creator-income record from a completed paid order. + * + * @param array $order */ - public function add(int $user_id, float $amount, string $order_number = '', string $type = 'income'): int { + public function create_from_paid_order(array $order): int { global $wpdb; - if ($user_id <= 0 || $amount <= 0) return 0; + $order_number = sanitize_text_field((string) ($order['order_number'] ?? '')); + if ($order_number === '' || (string) ($order['status'] ?? '') !== 'completed') { + return 0; + } - $wpdb->insert($this->table(), [ - 'user_id' => $user_id, - 'order_number' => sanitize_text_field($order_number), - 'amount' => $amount, - 'type' => sanitize_key($type), - 'status' => 'pending', - 'created_at' => current_time('mysql'), - ], ['%d', '%s', '%f', '%s', '%s', '%s']); + $meta = $this->decode_order_meta($order); + if (empty($meta['content_pay'])) { + return 0; + } - $id = (int) $wpdb->insert_id; + $post_id = (int) ($order['product_id'] ?? $meta['post_id'] ?? 0); + $buyer_id = (int) ($order['user_id'] ?? 0); + $post = $post_id > 0 ? get_post($post_id) : null; + if (!($post instanceof \WP_Post) || $post->post_type !== 'post') { + return 0; + } + + $author_id = (int) $post->post_author; + if ($author_id <= 0 || $author_id === $buyer_id) { + return 0; + } + + $currency = sanitize_key((string) ($order['currency'] ?? 'CNY')); + $is_points = $currency === 'points' || ($meta['pay_modo'] ?? '') === 'points'; + $currency = $is_points ? 'points' : 'CNY'; + $original_amount = round((float) ($order['amount'] ?? $meta['amount'] ?? $meta['payable'] ?? 0), 2); + if ($is_points) { + $original_amount = (float) ceil($original_amount); + } + if ($original_amount <= 0) { + return 0; + } + + $author_level = function_exists('aksrt_get_user_vip_level') + ? max(0, (int) aksrt_get_user_vip_level($author_id)) + : 0; + $ratio = $this->resolve_share_ratio($author_level); + if ($ratio <= 0) { + return 0; + } + $amount = $is_points + ? (float) max(1, (int) floor($original_amount * $ratio / 100)) + : round($original_amount * $ratio / 100, 2); + if ($amount <= 0) { + return 0; + } + + $inserted = $wpdb->insert($this->table(), [ + 'author_id' => $author_id, + 'order_number' => $order_number, + 'post_id' => $post_id, + 'buyer_id' => $buyer_id, + 'currency' => $currency, + 'original_amount' => $original_amount, + 'share_ratio' => $ratio, + 'author_level' => $author_level, + 'amount' => $amount, + 'type' => 'order', + 'status' => 'pending', + 'created_at' => current_time('mysql'), + ], ['%d', '%s', '%d', '%d', '%s', '%f', '%f', '%d', '%f', '%s', '%s', '%s']); + + if ($inserted === false) { + // Duplicate callbacks are a successful no-op by contract. + $existing = $wpdb->get_var($wpdb->prepare( + "SELECT id FROM {$this->table()} WHERE author_id = %d AND order_number = %s LIMIT 1", + $author_id, + $order_number + )); + return (int) $existing; + } + + $id = (int) $wpdb->insert_id; if ($id > 0) { - do_action('aksrt_creator_income_added', $user_id, $amount, [ + do_action('aksrt_creator_income_added', $author_id, $amount, [ 'order_number' => $order_number, - 'type' => $type, + 'currency' => $currency, 'revenue_id' => $id, ]); } @@ -49,159 +114,122 @@ class Revenue { } /** - * 结算收益 + * Legacy generic insertion retained as the native income page entry point. */ - public function settle(int $revenue_id): bool { + public function add(int $user_id, float $amount, string $order_number = '', string $type = 'income'): int { global $wpdb; - - if (!class_exists('\AKSRT\Credits\Balance')) { - return false; - } - - $wpdb->query('START TRANSACTION'); - try { - $revenue = $wpdb->get_row($wpdb->prepare( - "SELECT id, user_id, amount, order_number FROM {$this->table()} WHERE id = %d AND status = %s FOR UPDATE", - $revenue_id, - 'pending' - ), ARRAY_A); - if (!is_array($revenue)) { - $wpdb->query('ROLLBACK'); - return false; - } - - $credited = \AKSRT\Credits\Balance::add( - (int) $revenue['user_id'], - (float) $revenue['amount'], - 'creator_income', - ['type' => 'revenue', 'id' => $revenue_id], - sprintf(__('创作收益结算(订单 %s)', 'aksrt'), (string) $revenue['order_number']) - ); - if (is_wp_error($credited)) { - $wpdb->query('ROLLBACK'); - return false; - } - - $settled = $wpdb->update( - $this->table(), - ['status' => 'settled', 'settled_at' => current_time('mysql')], - ['id' => $revenue_id, 'status' => 'pending'], - ['%s', '%s'], - ['%d', '%s'] - ); - if ($settled !== 1) { - $wpdb->query('ROLLBACK'); - return false; - } - - $wpdb->query('COMMIT'); - return true; - } catch (\Throwable $e) { - $wpdb->query('ROLLBACK'); - return false; + if ($user_id <= 0 || $amount <= 0) { + return 0; } + $order_number = sanitize_text_field($order_number); + $wpdb->insert($this->table(), [ + 'author_id' => $user_id, + 'order_number' => $order_number, + 'amount' => round($amount, 2), + 'original_amount' => round($amount, 2), + 'currency' => 'CNY', + 'type' => sanitize_key($type), + 'status' => 'pending', + 'created_at' => current_time('mysql'), + ], ['%d', '%s', '%f', '%f', '%s', '%s', '%s', '%s']); + return (int) $wpdb->insert_id; } /** - * 获取用户收益列表 + * Settle one pending record through its matching atomic balance service. */ + public function settle(int $revenue_id): bool { + global $wpdb; + $revenue = $wpdb->get_row($wpdb->prepare( + "SELECT id, author_id, amount, currency, order_number FROM {$this->table()} WHERE id = %d AND status = %s", + $revenue_id, + 'pending' + ), ARRAY_A); + if (!is_array($revenue)) { + return false; + } + + if ((string) $revenue['currency'] === 'points' && class_exists('\\AKSRT\\Credits\\Credits')) { + $credited = \AKSRT\Credits\Credits::add((int) $revenue['author_id'], (int) $revenue['amount'], 'creator_income', ['type' => 'revenue', 'id' => $revenue_id]); + } elseif (class_exists('\\AKSRT\\Credits\\Balance')) { + $credited = \AKSRT\Credits\Balance::add((int) $revenue['author_id'], (float) $revenue['amount'], 'creator_income', ['type' => 'revenue', 'id' => $revenue_id]); + } else { + return false; + } + if (is_wp_error($credited)) { + return false; + } + + $updated = $wpdb->query($wpdb->prepare( + "UPDATE {$this->table()} SET status = %s, settled_at = %s WHERE id = %d AND status = %s", + 'settled', current_time('mysql'), $revenue_id, 'pending' + )); + return $updated === 1; + } + public function get_user_revenues(int $user_id, array $args = []): array { global $wpdb; - - $where = ['user_id = %d']; + $where = ['author_id = %d']; $params = [$user_id]; - $status = sanitize_key((string) ($args['status'] ?? '')); if ($status !== '') { $where[] = 'status = %s'; $params[] = $status; } - - $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); + $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); $offset = max(0, (int) ($args['offset'] ?? 0)); - - $sql = "SELECT * FROM {$this->table()} WHERE " . implode(' AND ', $where) . " ORDER BY created_at DESC LIMIT %d OFFSET %d"; $params[] = $limit; $params[] = $offset; - - return (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); + return (array) $wpdb->get_results($wpdb->prepare( + "SELECT * FROM {$this->table()} WHERE " . implode(' AND ', $where) . " ORDER BY created_at DESC LIMIT %d OFFSET %d", + $params + ), ARRAY_A); } - /** - * Admin list (all users) with optional status / user filter. - * - * @param array{status?:string,user_id?:int,limit?:int,offset?:int} $args - * @return array{items:array,total:int,page:int,max_pages:int} - */ public function get_list(array $args = []): array { global $wpdb; - - $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); + $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); $offset = max(0, (int) ($args['offset'] ?? 0)); - $status = sanitize_key((string) ($args['status'] ?? '')); - $user_id = (int) ($args['user_id'] ?? 0); - - $where = []; + $where = []; $params = []; - if ($status !== '') { - $where[] = 'status = %s'; + if (($status = sanitize_key((string) ($args['status'] ?? ''))) !== '') { + $where[] = 'status = %s'; $params[] = $status; } - if ($user_id > 0) { - $where[] = 'user_id = %d'; + if (($user_id = (int) ($args['user_id'] ?? 0)) > 0) { + $where[] = 'author_id = %d'; $params[] = $user_id; } $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; - - $count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}"; - $total = $params - ? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)) - : (int) $wpdb->get_var($count_sql); - - $sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; - $params[] = $limit; - $params[] = $offset; - $items = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); - - return [ - 'items' => $items ?: [], - 'total' => $total, - 'page' => (int) floor($offset / $limit) + 1, - 'max_pages' => (int) ceil(max(1, $total) / $limit), - ]; + $total = (int) ($params ? $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$this->table()} {$where_sql}", $params)) : $wpdb->get_var("SELECT COUNT(*) FROM {$this->table()}")); + $items_params = array_merge($params, [$limit, $offset]); + $items = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d", $items_params), ARRAY_A); + return ['items' => $items ?: [], 'total' => $total, 'page' => (int) floor($offset / $limit) + 1, 'max_pages' => (int) ceil(max(1, $total) / $limit)]; } - /** - * 获取收益统计 - */ public function get_stats(int $user_id = 0): array { global $wpdb; + $where = $user_id > 0 ? 'WHERE author_id = %d' : ''; + $params = $user_id > 0 ? [$user_id] : []; + $row = $params ? $wpdb->get_row($wpdb->prepare("SELECT COALESCE(SUM(amount),0) total, COALESCE(SUM(CASE WHEN status='pending' THEN amount ELSE 0 END),0) pending, COALESCE(SUM(CASE WHEN status='settled' THEN amount ELSE 0 END),0) settled, COUNT(*) count FROM {$this->table()} {$where}", $params), ARRAY_A) : $wpdb->get_row("SELECT COALESCE(SUM(amount),0) total, COALESCE(SUM(CASE WHEN status='pending' THEN amount ELSE 0 END),0) pending, COALESCE(SUM(CASE WHEN status='settled' THEN amount ELSE 0 END),0) settled, COUNT(*) count FROM {$this->table()}", ARRAY_A); + return ['total' => (float) ($row['total'] ?? 0), 'pending' => (float) ($row['pending'] ?? 0), 'settled' => (float) ($row['settled'] ?? 0), 'count' => (int) ($row['count'] ?? 0)]; + } - $where = ''; - $params = []; - if ($user_id > 0) { - $where = 'WHERE user_id = %d'; - $params[] = $user_id; + private function resolve_share_ratio(int $author_level): float { + $settings = \AKSRT\Settings\Settings::get_payment_revenue_settings(); + $ratio = $author_level >= 2 ? 80.0 : ($author_level === 1 ? 70.0 : 60.0); + $key = $author_level >= 2 ? 'ratio_vip2' : ($author_level === 1 ? 'ratio_vip1' : 'share_ratio'); + if (isset($settings[$key]) && is_numeric($settings[$key])) { + $ratio = (float) $settings[$key]; } + return max(0, min(100, $ratio)); + } - $sql = "SELECT - COALESCE(SUM(amount), 0) as total, - COALESCE(SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END), 0) as pending, - COALESCE(SUM(CASE WHEN status = 'settled' THEN amount ELSE 0 END), 0) as settled, - COUNT(*) as count - 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); + private function decode_order_meta(array $order): array { + if (is_array($order['meta_payload'] ?? null)) { + return $order['meta_payload']; } - - return [ - 'total' => (float) ($row['total'] ?? 0), - 'pending' => (float) ($row['pending'] ?? 0), - 'settled' => (float) ($row['settled'] ?? 0), - 'count' => (int) ($row['count'] ?? 0), - ]; + $decoded = json_decode((string) ($order['note'] ?? ''), true); + return is_array($decoded) ? $decoded : []; } } diff --git a/src/Settings/PaymentRuntime.php b/src/Settings/PaymentRuntime.php index 04b8376d..981e338e 100644 --- a/src/Settings/PaymentRuntime.php +++ b/src/Settings/PaymentRuntime.php @@ -245,9 +245,9 @@ class PaymentRuntime { return [ 'enabled' => !empty($settings['pay_income_s']), - 'share_ratio' => max(0, min(100, (int) ($settings['payment_revenue_share_ratio'] ?? 70))), - 'ratio_vip1' => max(0, min(100, (int) ($settings['ratio_vip_1'] ?? 0))), - 'ratio_vip2' => max(0, min(100, (int) ($settings['ratio_vip_2'] ?? 0))), + 'share_ratio' => max(0, min(100, (int) ($settings['payment_revenue_share_ratio'] ?? 60))), + 'ratio_vip1' => max(0, min(100, (int) ($settings['ratio_vip_1'] ?? 70))), + 'ratio_vip2' => max(0, min(100, (int) ($settings['ratio_vip_2'] ?? 80))), 'settlement_threshold' => max(0, (int) ($settings['payment_revenue_settlement_threshold'] ?? 100)), 'cycle_days' => max(1, min(365, (int) ($settings['payment_revenue_cycle_days'] ?? 30))), 'points_rule' => sanitize_textarea_field((string) ($settings['payment_revenue_points_rule'] ?? '')), From 9e6ae32fc1d81ecafea1006bb46f884add109e6b Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 7 Aug 2026 10:59:58 +0800 Subject: [PATCH 2/2] feat(payment): complete creator income center Co-Authored-By: Claude Fable 5 --- .specs/plans/user-creator-income-center.md | 22 +- .../src/schema/sections/payment/revenue.js | 4 + assets/css/routes/profile/lists.css | 4 + assets/js/routes/user-center.js | 40 +- functions.php | 2 +- .../user-center-renderers/profile-creator.php | 88 ++--- src/Admin/Native/PayIncome.php | 58 ++- src/Admin/PaymentController.php | 71 +++- src/Admin/Schema/SchemaPayment.php | 2 + src/Core/DBMigrator.php | 2 +- src/Core/DBMigratorPart2.php | 4 + src/Payment/CreatorRevenue.php | 18 +- src/Payment/OrderRepositoryCore.php | 1 + src/Payment/Revenue.php | 343 ++++++++++++++++-- src/Settings/PaymentRuntime.php | 6 + src/User/Center.php | 19 +- 16 files changed, 550 insertions(+), 134 deletions(-) diff --git a/.specs/plans/user-creator-income-center.md b/.specs/plans/user-creator-income-center.md index 36858e1c..821101a0 100644 --- a/.specs/plans/user-creator-income-center.md +++ b/.specs/plans/user-creator-income-center.md @@ -28,12 +28,12 @@ ### Done when -- [ ] `src/Core/DBMigratorPart2.php` 或最终负责收益表 schema 的文件包含作者、订单、文章、购买者、货币、原始支付金额、比例快照、会员等级、作者收益金额、状态和时间字段,并同步提升 schema 版本。 -- [ ] `src/Payment/Revenue.php` 或拆分后的作者收益服务实现 `create_from_paid_order(order)`,且同一订单重复调用只保留一条作者收益记录。 -- [ ] 现金订单仅创建现金收益记录,积分订单仅创建积分收益记录;作者购买自己的付费文章不会创建收益记录。 -- [ ] 普通用户、一级会员、二级会员分别按 60%、70%、80% 计算,并把生成时比例保存到收益记录。 -- [ ] 支付成功但权益未完成时不创建收益;权益完成后的支付流程能够创建待结算记录。 -- [ ] 创作分成关闭时,收益生成入口被跳过;相关收益服务测试覆盖上述场景。 +- [x] `src/Core/DBMigratorPart2.php` 或最终负责收益表 schema 的文件包含作者、订单、文章、购买者、货币、原始支付金额、比例快照、会员等级、作者收益金额、状态和时间字段,并同步提升 schema 版本。 +- [x] `src/Payment/Revenue.php` 或拆分后的作者收益服务实现 `create_from_paid_order(order)`,且同一订单重复调用只保留一条作者收益记录。 +- [x] 现金订单仅创建现金收益记录,积分订单仅创建积分收益记录;作者购买自己的付费文章不会创建收益记录。 +- [x] 普通用户、一级会员、二级会员分别按 60%、70%、80% 计算,并把生成时比例保存到收益记录。 +- [x] 支付成功但权益未完成时不创建收益;权益完成后的支付流程能够创建待结算记录。 +- [x] 创作分成关闭时,收益生成入口被跳过;相关收益服务测试覆盖上述场景。 --- @@ -47,11 +47,11 @@ ### Done when -- [ ] `src/Payment/Revenue.php` 或拆分后的结算服务实现 `settle(record_ids, actor)`,现金和积分分别调用对应原子余额服务并写入对应流水。 -- [ ] `settlement scheduler` 仅在自动模式下处理达到配置延迟时间的待结算记录,默认延迟 24 小时;手动模式下定时任务不入账。 -- [ ] 同一收益被重复结算、并发结算或重复定时扫描时,只能成功入账一次。 -- [ ] `Refund reversal handler` 实现 `cancel_or_reverse(order_id, reason)`:未入钱包记录变为已取消/退款,已入钱包记录按货币执行冲正。 -- [ ] 冲正时余额不足不会产生负余额,收益记录转为待追缴,并保存应冲正金额、错误信息和审计信息。 +- [x] `src/Payment/Revenue.php` 或拆分后的结算服务实现 `settle(record_ids, actor)`,现金和积分分别调用对应原子余额服务并写入对应流水。 +- [x] `settlement scheduler` 仅在自动模式下处理达到配置延迟时间的待结算记录,默认延迟 24 小时;手动模式下定时任务不入账。 +- [x] 同一收益被重复结算、并发结算或重复定时扫描时,只能成功入账一次。 +- [x] `Refund reversal handler` 实现 `cancel_or_reverse(order_id, reason)`:未入钱包记录变为已取消/退款,已入钱包记录按货币执行冲正。 +- [x] 冲正时余额不足不会产生负余额,收益记录转为待追缴,并保存应冲正金额、错误信息和审计信息。 - [ ] 重复退款通知和重复冲正请求保持幂等;服务测试覆盖自动结算、手动结算、退款前结算、退款后结算和余额不足场景。 --- diff --git a/assets/admin/src/schema/sections/payment/revenue.js b/assets/admin/src/schema/sections/payment/revenue.js index fb43c3d1..22ac339d 100644 --- a/assets/admin/src/schema/sections/payment/revenue.js +++ b/assets/admin/src/schema/sections/payment/revenue.js @@ -10,6 +10,8 @@ export const revenueSchema = { ratio_vip_2: 0, payment_revenue_settlement_threshold: 100, payment_revenue_cycle_days: 30, + payment_revenue_settlement_mode: 'automatic', + payment_revenue_settlement_delay_hours: 24, payment_revenue_points_rule: '', payment_revenue_note: '分成规则已就绪,订单入账后自动按比例结算。', pay_income_desc_details: '', @@ -27,6 +29,8 @@ export const revenueSchema = { { key: 'ratio_vip_2', label: 'VIP2分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } }, { key: 'payment_revenue_settlement_threshold', label: '最小结算金额', type: 'number', default: 100, min: 0, max: 100000, dependsOn: { key: 'pay_income_s', value: true } }, { key: 'payment_revenue_cycle_days', label: '结算周期(天)', type: 'number', default: 30, min: 1, max: 365, dependsOn: { key: 'pay_income_s', value: true } }, + { key: 'payment_revenue_settlement_mode', label: '结算模式', type: 'select', default: 'automatic', options: [{ value: 'automatic', label: '自动结算' }, { value: 'manual', label: '手动结算' }], dependsOn: { key: 'pay_income_s', value: true } }, + { key: 'payment_revenue_settlement_delay_hours', label: '结算延迟(小时)', type: 'number', default: 24, min: 0, max: 8760, dependsOn: { key: 'pay_income_s', value: true } }, { key: 'payment_revenue_points_rule', label: '积分规则', type: 'textarea', default: '', dependsOn: { key: 'pay_income_s', value: true } }, { key: 'payment_revenue_note', label: '结算备注', type: 'textarea', default: '分成策略和台账结构已就绪,订单入账后自动按比例结算。', dependsOn: { key: 'pay_income_s', value: true } }, ], diff --git a/assets/css/routes/profile/lists.css b/assets/css/routes/profile/lists.css index 73adffe3..70a1b809 100644 --- a/assets/css/routes/profile/lists.css +++ b/assets/css/routes/profile/lists.css @@ -709,6 +709,10 @@ border-bottom: 0; } +/* ==================== 作者收益中心 ==================== */ +.creator-income { display:flex; flex-direction:column; gap:16px; }.creator-income__hero { padding:24px 26px; border-radius:var(--radius-lg); color:#fff; background:linear-gradient(135deg,var(--color-primary),#7c3aed); }.creator-income__hero p { margin:0 0 6px; opacity:.82; font-size:13px; }.creator-income__hero h2 { margin:0; color:inherit; }.creator-income__tabs { display:flex; gap:8px; flex-wrap:wrap; }.creator-income__tab { padding:7px 12px; border-radius:var(--radius-pill); color:var(--color-ink-3); text-decoration:none; }.creator-income__tab.is-active,.creator-income__tab:hover { color:var(--color-primary); background:var(--color-primary-soft); }.creator-income__cards { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }.creator-income-card { padding:18px; border:1px solid var(--color-line); border-radius:var(--radius-lg); background:var(--surface-1); }.creator-income-card h3 { margin:0 0 14px; }.creator-income-card__metrics { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; }.creator-income-card__metrics strong,.creator-income-card__metrics small { display:block; }.creator-income-card__metrics strong { color:var(--color-ink); font-size:18px; }.creator-income-card__metrics small { margin-top:4px; color:var(--color-ink-4); font-size:11px; }.creator-income__info,.creator-income__details { padding:20px; }.creator-income__info h3,.creator-income__toolbar h3 { margin:0; }.creator-income__info div,.creator-income__toolbar p { margin:6px 0 0; color:var(--color-ink-4); font-size:13px; }.creator-income__toolbar { display:flex; justify-content:space-between; align-items:flex-end; gap:12px; margin-bottom:14px; }.creator-income__table-wrap { overflow-x:auto; }.creator-income__table { width:100%; border-collapse:collapse; font-size:12px; }.creator-income__table th,.creator-income__table td { padding:10px 8px; border-bottom:1px solid var(--color-line); text-align:left; white-space:nowrap; }.creator-income-status { padding:3px 7px; border-radius:var(--radius-pill); color:var(--color-primary); background:var(--color-primary-soft); }.creator-income-status--cancelled,.creator-income-status--reversed { color:var(--color-ink-4); background:var(--surface-2); }.creator-income-status--pending_recovery { color:var(--color-error); background:rgba(220,38,38,.08); }.creator-income__empty { padding:40px 20px; text-align:center; color:var(--color-ink-4); }.creator-income__empty i { font-size:28px; color:var(--color-primary); } .creator-income__details select { margin-left:6px; } +@media (max-width:767px) { .creator-income__cards { grid-template-columns:1fr; }.creator-income-card__metrics { grid-template-columns:repeat(2,minmax(0,1fr)); }.creator-income__toolbar { align-items:flex-start; flex-direction:column; } } + /* ==================== 独立创作中心工作台 ==================== */ .creator-workspace { display: flex; flex-direction: column; gap: 16px; } .creator-workspace__hero { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 24px 26px; border-radius: var(--radius-lg); color: #fff; background: linear-gradient(135deg, var(--color-primary), #7c3aed); } diff --git a/assets/js/routes/user-center.js b/assets/js/routes/user-center.js index ebef7126..5a48424e 100644 --- a/assets/js/routes/user-center.js +++ b/assets/js/routes/user-center.js @@ -79,6 +79,12 @@ if (!restUrl) return Promise.reject(new Error('rest-unavailable')); target.classList.add('is-loading'); var url = restUrl.replace(/\/$/, '') + '/user-center/panel/' + encodeURIComponent(name); + if (name === 'creator') { + var params = new URLSearchParams(window.location.search); + ['income_currency', 'income_status', 'income_page'].forEach(function(key) { + if (params.get(key)) url += (url.indexOf('?') === -1 ? '?' : '&') + key + '=' + encodeURIComponent(params.get(key)); + }); + } target._aksrtPanelRequest = fetch(url, { credentials: 'same-origin', headers: { 'X-WP-Nonce': (window.aksrtData && (window.aksrtData.restNonce || window.aksrtData.nonce)) || '' } @@ -699,36 +705,6 @@ }); } - /* ==================== 创作中心文章操作 ==================== */ - function initCreatorPostActions() { - document.addEventListener('click', function(e) { - var btn = e.target.closest('[data-action="delete-post"]'); - if (!btn || btn.disabled) return; - if (!confirm(i18n.confirmDeletePost || '确定删除这篇文章?')) return; - var postId = btn.getAttribute('data-post-id'); - var nonce = btn.getAttribute('data-nonce') || submitNonce; - if (!postId || !nonce) return; - btn.disabled = true; - var body = new URLSearchParams(); - body.set('action', 'aksrt_delete_post'); - body.set('id', postId); - body.set('_nonce', nonce); - fetch(ajaxUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, credentials: 'same-origin', body: body.toString() }) - .then(function(r) { return r.json(); }) - .then(function(res) { - if (res && res.success) { - var item = btn.closest('.profile-list-item, .creator-post-row'); - if (item) item.remove(); - showToast((res.data && res.data.message) || '文章已经删除'); - } else { - btn.disabled = false; - showToast((res && res.data && res.data.message) || i18n.operationFailed || '操作失败'); - } - }) - .catch(function() { btn.disabled = false; showToast(i18n.networkError || '网络错误'); }); - }); - } - if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function() { @@ -736,7 +712,6 @@ initProfileForm(); initAuthForm(); initUnfavorite(); - initCreatorPostActions(); initNotifications(); initSettings(); }); @@ -745,8 +720,7 @@ initProfileForm(); initAuthForm(); initUnfavorite(); - initCreatorPostActions(); - initNotifications(); + initNotifications(); initSettings(); } diff --git a/functions.php b/functions.php index d5b0c8df..a97eb615 100644 --- a/functions.php +++ b/functions.php @@ -111,7 +111,7 @@ add_filter('query_vars', function ($vars) { // 主题切换离开时:清理本主题注册的 cron 任务 add_action('switch_theme', function () { - $hooks = ['aksrt_dl_token_cleanup', 'aksrt_email_verification_backfill_batch', 'aksrt_notification_cleanup']; + $hooks = ['aksrt_dl_token_cleanup', 'aksrt_email_verification_backfill_batch', 'aksrt_notification_cleanup', 'aksrt_creator_income_settlement']; foreach ($hooks as $hook) { $next = wp_next_scheduled($hook); while ($next) { diff --git a/inc/helpers/user-center-renderers/profile-creator.php b/inc/helpers/user-center-renderers/profile-creator.php index 3583f49b..d109796b 100644 --- a/inc/helpers/user-center-renderers/profile-creator.php +++ b/inc/helpers/user-center-renderers/profile-creator.php @@ -1,6 +1,6 @@ can_submit((int) $user_id); -$counts = isset($user_status_counts) && is_array($user_status_counts) ? $user_status_counts : []; -$total = array_sum(array_map('intval', $counts)); -$creator_filter = isset($_GET['creator_status']) ? sanitize_key(wp_unslash($_GET['creator_status'])) : 'all'; -if (!in_array($creator_filter, ['all', 'publish', 'draft', 'pending', 'private'], true)) { - $creator_filter = 'all'; +$user_id = (int) ($context['user_id'] ?? get_current_user_id()); +if ($user_id <= 0) { + echo '
' . esc_html__('请先登录', 'aksrt') . '
'; + return; } -$creator_url = aksrt_get_user_center_tab_url('creator'); -$filters = [ - 'all' => __('全部文章', 'aksrt'), - 'publish' => __('已发布', 'aksrt'), - 'draft' => __('草稿', 'aksrt'), - 'pending' => __('待审核', 'aksrt'), - 'private' => __('私密', 'aksrt'), -]; +$settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_payment_revenue_settings() : []; +if (empty($settings['enabled'])) { + echo '
' . esc_html__('创作收益功能暂未开启。', 'aksrt') . '
'; + return; +} +$request = is_array($context['creator_income_request'] ?? null) ? $context['creator_income_request'] : []; +$currency = sanitize_key((string) ($request['currency'] ?? ($_GET['income_currency'] ?? ''))); +$status = sanitize_key((string) ($request['status'] ?? ($_GET['income_status'] ?? ''))); +$currency = in_array($currency, ['', 'CNY', 'points'], true) ? $currency : ''; +$status = in_array($status, ['', 'pending', 'settled', 'cancelled', 'reversed', 'pending_recovery'], true) ? $status : ''; +$page = max(1, min(100, (int) ($request['page'] ?? ($_GET['income_page'] ?? 1)))); +$view = class_exists('\AKSRT\Payment\Revenue') ? \AKSRT\Payment\Revenue::get_instance()->get_user_income_view($user_id, ['currency' => $currency, 'status' => $status, 'page' => $page]) : ['stats' => [], 'items' => [], 'max_pages' => 1, 'page' => 1]; +$stats = (array) ($view['stats'] ?? []); +$items = (array) ($view['items'] ?? []); +$base_url = function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('creator') : home_url('/profile/?tab=creator'); +$filter_url = static function (array $extra) use ($base_url): string { return add_query_arg(array_merge(['tab' => 'creator'], $extra), remove_query_arg(['income_page'])); }; +$format = static function ($amount, string $unit): string { return number_format_i18n((float) $amount, $unit === 'points' ? 0 : 2); }; +$status_labels = ['pending' => __('待结算', 'aksrt'), 'settled' => __('已入钱包', 'aksrt'), 'cancelled' => __('已取消', 'aksrt'), 'reversed' => __('已冲正', 'aksrt'), 'pending_recovery' => __('待追缴', 'aksrt')]; ?> -
-
-
-

-

-

-
- - - - - -
- -
-
-
-
-
-
- -
-
-

- -
- $creator_filter]; - get_template_part('templates/parts/user-center/creator-posts', null, ['context' => $context, 'creator_args' => $creator_args]); - ?> -
+
+

+ +
+ __('现金', 'aksrt'), 'points' => __('积分', 'aksrt')] as $unit => $label) : ?>

__('今日收入', 'aksrt'), 'total' => __('累计收益', 'aksrt'), 'pending' => __('待结算', 'aksrt'), 'settled' => __('已入钱包', 'aksrt')] as $metric => $metric_label) : ?>
+
+

[], 'br' => [], 'strong' => [], 'em' => [], 'ul' => [], 'ol' => [], 'li' => []]); ?>
+

+

+
diff --git a/src/Admin/Native/PayIncome.php b/src/Admin/Native/PayIncome.php index e5e6bef8..dc2d126d 100644 --- a/src/Admin/Native/PayIncome.php +++ b/src/Admin/Native/PayIncome.php @@ -26,6 +26,7 @@ class PayIncome { return; } add_action('admin_post_aksrt_pay_settle_income', [$this, 'handle_settle_income']); + add_action('admin_post_aksrt_pay_batch_settle_income', [$this, 'handle_batch_settle_income']); } public function render_income_page(): void { @@ -40,6 +41,11 @@ class PayIncome { $status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : ''; $user_id = isset($_GET['user_id']) ? max(0, (int) $_GET['user_id']) : 0; + $post_id = isset($_GET['post_id']) ? max(0, (int) $_GET['post_id']) : 0; + $currency = isset($_GET['currency']) ? sanitize_key((string) wp_unslash($_GET['currency'])) : ''; + $order_number = isset($_GET['order_number']) ? sanitize_text_field((string) wp_unslash($_GET['order_number'])) : ''; + $date_from = isset($_GET['date_from']) ? sanitize_text_field((string) wp_unslash($_GET['date_from'])) : ''; + $date_to = isset($_GET['date_to']) ? sanitize_text_field((string) wp_unslash($_GET['date_to'])) : ''; $page_num = max(1, (int) ($_GET['paged'] ?? 1)); $per_page = 20; $svc = \AKSRT\Payment\Revenue::get_instance(); @@ -53,14 +59,24 @@ class PayIncome { number_format((float) ($stats['settled'] ?? 0), 2) )) . '

'; + echo '
'; + echo ''; + wp_nonce_field('aksrt_pay_batch_settle_income'); + submit_button(__('批量结算选中记录', 'aksrt'), 'secondary', '', false); + echo '
'; + echo '
'; echo ''; echo ' '; echo ' '; + echo ' '; + echo ' '; + echo ' '; + echo ' — '; submit_button(__('筛选', 'aksrt'), 'secondary', '', false); if ($user_id > 0) { echo ' ' . esc_html__('清除用户过滤', 'aksrt') . ''; @@ -71,6 +87,11 @@ class PayIncome { 'status' => $status, 'limit' => $per_page, 'offset' => ($page_num - 1) * $per_page, + 'post_id' => $post_id, + 'currency' => $currency, + 'order_number' => $order_number, + 'date_from' => $date_from, + 'date_to' => $date_to, ]; if ($user_id > 0) { $list_args['user_id'] = $user_id; @@ -81,10 +102,10 @@ class PayIncome { $pages = max(1, (int) ($result['max_pages'] ?? 1)); echo ''; - echo ''; + echo ''; echo ''; if (!$items) { - echo ''; + echo ''; } foreach ($items as $row) { $id = (int) ($row['id'] ?? 0); @@ -92,8 +113,8 @@ class PayIncome { $user = $user_id > 0 ? get_userdata($user_id) : null; $st = (string) ($row['status'] ?? ''); echo ''; - echo ''; - echo ''; + echo ''; + echo ''; echo ''; echo ''; echo ''; @@ -122,13 +143,37 @@ class PayIncome { } $id = isset($_POST['revenue_id']) ? (int) $_POST['revenue_id'] : 0; check_admin_referer('aksrt_pay_settle_income_' . $id); - $ok = $id > 0 && class_exists('\AKSRT\Payment\Revenue') && \AKSRT\Payment\Revenue::get_instance()->settle($id); + $ok = $id > 0 && class_exists('\AKSRT\Payment\Revenue') && \AKSRT\Payment\Revenue::get_instance()->settle($id, get_current_user_id()); wp_safe_redirect($this->page_url('aksrt_income', ['aksrt_pay_msg' => $ok ? 'income_settled' : 'income_failed'])); exit; } + public function handle_batch_settle_income(): void { + if (!current_user_can(self::CAPABILITY)) { + wp_die(esc_html__('权限不足', 'aksrt')); + } + check_admin_referer('aksrt_pay_batch_settle_income'); + + $raw_ids = isset($_POST['revenue_ids']) ? wp_unslash($_POST['revenue_ids']) : []; + $ids = []; + if (is_array($raw_ids)) { + foreach ($raw_ids as $raw_id) { + $id = (int) $raw_id; + if ($id > 0) { + $ids[] = $id; + } + } + } + $ids = array_values(array_unique($ids)); + $ok = !empty($ids) + && class_exists('\\AKSRT\\Payment\\Revenue') + && \AKSRT\Payment\Revenue::get_instance()->settle($ids, get_current_user_id()); + wp_safe_redirect($this->page_url('aksrt_income', ['aksrt_pay_msg' => $ok ? 'income_batch_settled' : 'income_failed'])); + exit; + } + private function ensure_cap(): void { if (!current_user_can(self::CAPABILITY)) { wp_die(esc_html__('您没有权限访问此页面。', 'aksrt')); @@ -152,6 +197,7 @@ class PayIncome { $code = sanitize_key((string) wp_unslash($_GET['aksrt_pay_msg'])); $map = [ 'income_settled' => __('分成已结算。', 'aksrt'), + 'income_batch_settled' => __('选中分成已批量结算。', 'aksrt'), 'income_failed' => __('分成结算失败。', 'aksrt'), ]; if (!isset($map[$code])) { diff --git a/src/Admin/PaymentController.php b/src/Admin/PaymentController.php index 17f31ce2..484fc1f6 100644 --- a/src/Admin/PaymentController.php +++ b/src/Admin/PaymentController.php @@ -38,7 +38,22 @@ class PaymentController extends ControllerBase { ], ]); - // Withdrawal list — unified pagination: page + per_page + register_rest_route(self::API_NAMESPACE, '/payment/creator-income', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_creator_income'], + 'permission_callback' => [$this, 'check_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/payment/creator-income/(?P\d+)/settle', [ + 'methods' => 'POST', + 'callback' => [$this, 'settle_creator_income'], + 'permission_callback' => [$this, 'check_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/payment/creator-income/order/(?P[a-zA-Z0-9_-]+)/reverse', [ + 'methods' => 'POST', + 'callback' => [$this, 'reverse_creator_income'], + 'permission_callback' => [$this, 'check_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/payment/withdrawals', [ 'methods' => 'GET', 'callback' => [$this, 'get_withdrawals'], @@ -224,8 +239,60 @@ class PaymentController extends ControllerBase { } /** - * GET /payment/withdrawals — 提现列表,统一 page + per_page 分页 + * GET /payment/creator-income — creator income operations list. */ + public function get_creator_income(\WP_REST_Request $request): \WP_REST_Response { + if (!class_exists('\AKSRT\Payment\Revenue')) { + return $this->error(__('创作分成模块未启用', 'aksrt'), 500); + } + $page = max(1, (int) $request->get_param('page')); + $per_page = max(1, min(100, (int) ($request->get_param('per_page') ?: 20))); + $result = \AKSRT\Payment\Revenue::get_instance()->get_list([ + 'limit' => $per_page, + 'offset' => ($page - 1) * $per_page, + 'status' => sanitize_key((string) $request->get_param('status')), + 'user_id' => max(0, (int) $request->get_param('user_id')), + 'post_id' => max(0, (int) $request->get_param('post_id')), + 'currency' => sanitize_key((string) $request->get_param('currency')), + 'order_number' => sanitize_text_field((string) $request->get_param('order_number')), + 'date_from' => sanitize_text_field((string) $request->get_param('date_from')), + 'date_to' => sanitize_text_field((string) $request->get_param('date_to')), + ]); + $result['page'] = $page; + $result['per_page'] = $per_page; + return $this->success(['data' => $result]); + } + + /** + * POST /payment/creator-income/{id}/settle — settle one income record. + */ + public function settle_creator_income(\WP_REST_Request $request): \WP_REST_Response { + if (!class_exists('\AKSRT\Payment\Revenue')) { + return $this->error(__('创作分成模块未启用', 'aksrt'), 500); + } + $id = (int) $request->get_param('id'); + if ($id <= 0 || !\AKSRT\Payment\Revenue::get_instance()->settle($id, get_current_user_id())) { + return $this->error(__('结算失败', 'aksrt')); + } + return $this->success(['message' => __('创作分成已结算', 'aksrt')]); + } + + /** + * POST /payment/creator-income/order/{order_id}/reverse — cancel or reverse order income. + */ + public function reverse_creator_income(\WP_REST_Request $request): \WP_REST_Response { + if (!class_exists('\AKSRT\Payment\Revenue')) { + return $this->error(__('创作分成模块未启用', 'aksrt'), 500); + } + $order_id = sanitize_text_field((string) $request->get_param('order_id')); + $params = $this->get_json_params($request); + $reason = sanitize_textarea_field((string) ($params['reason'] ?? '')); + if ($order_id === '' || !\AKSRT\Payment\Revenue::get_instance()->cancel_or_reverse($order_id, $reason)) { + return $this->error(__('冲正失败', 'aksrt')); + } + return $this->success(['message' => __('创作分成已处理退款/冲正', 'aksrt')]); + } + public function get_withdrawals(\WP_REST_Request $request): \WP_REST_Response { if (!class_exists('\AKSRT\Payment\Withdrawal')) { return $this->success(['data' => ['items' => [], 'total' => 0, 'page' => 1, 'total_pages' => 1, 'stats' => []]]); diff --git a/src/Admin/Schema/SchemaPayment.php b/src/Admin/Schema/SchemaPayment.php index 5a0a39ea..311de06a 100644 --- a/src/Admin/Schema/SchemaPayment.php +++ b/src/Admin/Schema/SchemaPayment.php @@ -174,6 +174,8 @@ class SchemaPayment { ['key' => 'ratio_vip_2', 'label' => 'VIP2分成比例(%)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 100], ['key' => 'payment_revenue_settlement_threshold', 'label' => '最小结算金额', 'type' => 'number', 'default' => 100, 'min' => 1, 'max' => 99999], ['key' => 'payment_revenue_cycle_days', 'label' => '结算周期(天)', 'type' => 'number', 'default' => 30, 'min' => 1, 'max' => 365], + ['key' => 'payment_revenue_settlement_mode', 'label' => '结算模式', 'type' => 'select', 'default' => 'automatic', 'options' => [['value' => 'automatic', 'label' => '自动结算'], ['value' => 'manual', 'label' => '手动结算']]], + ['key' => 'payment_revenue_settlement_delay_hours', 'label' => '结算延迟(小时)', 'type' => 'number', 'default' => 24, 'min' => 0, 'max' => 8760], ['key' => 'payment_revenue_points_rule', 'label' => '积分规则', 'type' => 'textarea', 'default' => ''], ['key' => 'payment_revenue_note', 'label' => '结算备注', 'type' => 'textarea', 'default' => ''], ]], diff --git a/src/Core/DBMigrator.php b/src/Core/DBMigrator.php index c409687e..4483a928 100644 --- a/src/Core/DBMigrator.php +++ b/src/Core/DBMigrator.php @@ -13,7 +13,7 @@ class DBMigrator { use DBMigratorIdentity; /** Current schema version; changing it re-runs dbDelta. */ - const SCHEMA_VERSION = '1.19.0'; + const SCHEMA_VERSION = '1.20.0'; const OPT_KEY = 'aksrt_db_schema_version'; diff --git a/src/Core/DBMigratorPart2.php b/src/Core/DBMigratorPart2.php index 3cf19c52..9dce63a6 100644 --- a/src/Core/DBMigratorPart2.php +++ b/src/Core/DBMigratorPart2.php @@ -201,8 +201,12 @@ trait DBMigratorPart2 { amount DECIMAL(18,2) NOT NULL DEFAULT 0, type VARCHAR(32) NOT NULL DEFAULT 'order', status VARCHAR(20) NOT NULL DEFAULT 'pending', + reversal_amount DECIMAL(18,2) NOT NULL DEFAULT 0, + error_message TEXT DEFAULT NULL, + audit_json LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL, settled_at DATETIME DEFAULT NULL, + reversed_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uniq_creator_order (author_id, order_number), KEY idx_author (author_id), diff --git a/src/Payment/CreatorRevenue.php b/src/Payment/CreatorRevenue.php index c9d9f5fa..45a5c6eb 100644 --- a/src/Payment/CreatorRevenue.php +++ b/src/Payment/CreatorRevenue.php @@ -19,7 +19,23 @@ class CreatorRevenue { } public function init(): void { - // Revenue is generated by the content fulfillment completion boundary. + add_action('aksrt_creator_income_settlement', [$this, 'settle_due']); + add_action('aksrt_order_status_changed', [$this, 'handle_order_status_changed'], 10, 3); + if (!wp_next_scheduled('aksrt_creator_income_settlement')) { + wp_schedule_event(time() + HOUR_IN_SECONDS, 'hourly', 'aksrt_creator_income_settlement'); + } + } + + public function settle_due(): void { + if (class_exists('AKSRT\\Payment\\Revenue')) { + Revenue::get_instance()->settle_due(100, 0); + } + } + + public function handle_order_status_changed(string $order_number, string $new_status, array $order = []): void { + if (in_array($new_status, ['cancelled', 'refunding', 'refunded'], true) && class_exists('AKSRT\\Payment\\Revenue')) { + Revenue::get_instance()->cancel_or_reverse($order_number, sprintf(__('订单状态变更为%s', 'aksrt'), $new_status)); + } } /** diff --git a/src/Payment/OrderRepositoryCore.php b/src/Payment/OrderRepositoryCore.php index bf7ef938..ca9c4e5d 100644 --- a/src/Payment/OrderRepositoryCore.php +++ b/src/Payment/OrderRepositoryCore.php @@ -309,6 +309,7 @@ trait OrderRepositoryCore { } if ($result > 0) { + do_action('aksrt_order_status_changed', $order_number, $new_status, array_merge($order, $update_data)); // 到账事件:让充值/VIP/商品履约等挂接(单一履约入口)。 // 仅在「首次」转为 paid 时触发,避免回调多次到达重复履约。 if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID && $current_status !== \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) { diff --git a/src/Payment/Revenue.php b/src/Payment/Revenue.php index 9f687aed..86587935 100644 --- a/src/Payment/Revenue.php +++ b/src/Payment/Revenue.php @@ -136,39 +136,240 @@ class Revenue { } /** - * Settle one pending record through its matching atomic balance service. + * Settle one or more pending records through matching atomic balance services. + * + * @param int|array $record_ids + * @param int $actor_id */ - public function settle(int $revenue_id): bool { + public function settle($record_ids, int $actor_id = 0): bool { + $ids = is_array($record_ids) ? $record_ids : [$record_ids]; + $ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0))); + if (!$ids || !$this->is_enabled()) { + return false; + } + + foreach ($ids as $revenue_id) { + if (!$this->settle_one($revenue_id, $actor_id)) { + return false; + } + } + return true; + } + + /** + * Settle all due pending records in automatic mode. + * + * @return int Number of records settled. + */ + public function settle_due(int $limit = 100, int $actor_id = 0): int { global $wpdb; - $revenue = $wpdb->get_row($wpdb->prepare( - "SELECT id, author_id, amount, currency, order_number FROM {$this->table()} WHERE id = %d AND status = %s", - $revenue_id, - 'pending' - ), ARRAY_A); - if (!is_array($revenue)) { - return false; + + if (!$this->is_enabled()) { + return 0; + } + $settings = \AKSRT\Settings\Settings::get_payment_revenue_settings(); + if (($settings['settlement_mode'] ?? 'automatic') !== 'automatic') { + return 0; } - if ((string) $revenue['currency'] === 'points' && class_exists('\\AKSRT\\Credits\\Credits')) { - $credited = \AKSRT\Credits\Credits::add((int) $revenue['author_id'], (int) $revenue['amount'], 'creator_income', ['type' => 'revenue', 'id' => $revenue_id]); - } elseif (class_exists('\\AKSRT\\Credits\\Balance')) { - $credited = \AKSRT\Credits\Balance::add((int) $revenue['author_id'], (float) $revenue['amount'], 'creator_income', ['type' => 'revenue', 'id' => $revenue_id]); - } else { - return false; - } - if (is_wp_error($credited)) { - return false; - } - - $updated = $wpdb->query($wpdb->prepare( - "UPDATE {$this->table()} SET status = %s, settled_at = %s WHERE id = %d AND status = %s", - 'settled', current_time('mysql'), $revenue_id, 'pending' + $limit = max(1, min(500, $limit)); + $delay_hours = max(0, (int) ($settings['settlement_delay_hours'] ?? 24)); + $cutoff = gmdate('Y-m-d H:i:s', time() - ($delay_hours * HOUR_IN_SECONDS)); + $ids = $wpdb->get_col($wpdb->prepare( + "SELECT id FROM {$this->table()} WHERE status = %s AND created_at <= %s ORDER BY id ASC LIMIT %d", + 'pending', + $cutoff, + $limit )); - return $updated === 1; + + $settled = 0; + foreach ((array) $ids as $id) { + if ($this->settle((int) $id, $actor_id)) { + $settled++; + } + } + return $settled; + } + + /** + * Cancel a pending record or reverse a settled record for a refunded order. + * + * @param string|int $order_id Order number or local order id. + */ + public function cancel_or_reverse($order_id, string $reason = ''): bool { + global $wpdb; + + $order_number = sanitize_text_field((string) $order_id); + if ($order_number === '') { + return false; + } + $reason = sanitize_textarea_field($reason); + $wpdb->query('START TRANSACTION'); + try { + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT * FROM {$this->table()} WHERE order_number = %s FOR UPDATE", + $order_number + ), ARRAY_A); + if (!is_array($rows) || !$rows) { + $wpdb->query('COMMIT'); + return true; + } + + foreach ($rows as $row) { + $status = (string) ($row['status'] ?? ''); + $id = (int) ($row['id'] ?? 0); + if (in_array($status, ['cancelled', 'reversed'], true)) { + continue; + } + if ($status === 'pending') { + $updated = $wpdb->query($wpdb->prepare( + "UPDATE {$this->table()} SET status = %s, error_message = %s, audit_json = %s WHERE id = %d AND status = %s", + 'cancelled', + $reason, + $this->audit_json('cancelled', $reason), + $id, + 'pending' + )); + if ($updated !== 1) { + throw new \RuntimeException('revenue_cancel_failed'); + } + continue; + } + if (!in_array($status, ['settled', 'pending_recovery'], true)) { + continue; + } + if ($status === 'pending_recovery') { + continue; + } + + $amount = round((float) ($row['amount'] ?? 0), 2); + $user_id = (int) ($row['author_id'] ?? 0); + $ref = ['type' => 'creator_income_reversal', 'id' => $id]; + $debited = (string) ($row['currency'] ?? '') === 'points' + ? (class_exists('\\AKSRT\\Credits\\Credits') ? \AKSRT\Credits\Credits::deduct($user_id, (int) ceil($amount), 'creator_income_reversal', $ref, $reason) : new \WP_Error('credits_unavailable')) + : (class_exists('\\AKSRT\\Credits\\Balance') ? \AKSRT\Credits\Balance::deduct($user_id, $amount, 'creator_income_reversal', $ref, $reason) : new \WP_Error('balance_unavailable')); + if (is_wp_error($debited)) { + $updated = $wpdb->query($wpdb->prepare( + "UPDATE {$this->table()} SET status = %s, reversal_amount = %f, error_message = %s, audit_json = %s WHERE id = %d AND status = %s", + 'pending_recovery', + $amount, + sanitize_textarea_field($debited->get_error_message()), + $this->audit_json('pending_recovery', $debited->get_error_message()), + $id, + 'settled' + )); + if ($updated !== 1) { + throw new \RuntimeException('revenue_recovery_failed'); + } + continue; + } + $updated = $wpdb->query($wpdb->prepare( + "UPDATE {$this->table()} SET status = %s, reversal_amount = %f, reversed_at = %s, error_message = %s, audit_json = %s WHERE id = %d AND status = %s", + 'reversed', + $amount, + current_time('mysql'), + $reason, + $this->audit_json('reversed', $reason), + $id, + 'settled' + )); + if ($updated !== 1) { + throw new \RuntimeException('revenue_reverse_failed'); + } + } + $wpdb->query('COMMIT'); + return true; + } catch (\Throwable $e) { + $wpdb->query('ROLLBACK'); + return false; + } + } + + private function settle_one(int $revenue_id, int $actor_id): bool { + global $wpdb; + $wpdb->query('START TRANSACTION'); + try { + $revenue = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM {$this->table()} WHERE id = %d AND status = %s FOR UPDATE", + $revenue_id, + 'pending' + ), ARRAY_A); + if (!is_array($revenue)) { + $wpdb->query('COMMIT'); + return false; + } + + $ref = ['type' => 'revenue', 'id' => $revenue_id, 'currency' => (string) ($revenue['currency'] ?? 'CNY')]; + $remark = sprintf(__('创作收益结算(订单 %s)', 'aksrt'), (string) ($revenue['order_number'] ?? '')); + if (!$this->has_balance_entry((int) $revenue['author_id'], $ref)) { + $credited = (string) ($revenue['currency'] ?? '') === 'points' + ? (class_exists('\\AKSRT\\Credits\\Credits') ? \AKSRT\Credits\Credits::add((int) $revenue['author_id'], (int) ceil((float) $revenue['amount']), 'creator_income', $ref, $remark) : new \WP_Error('credits_unavailable')) + : (class_exists('\\AKSRT\\Credits\\Balance') ? \AKSRT\Credits\Balance::add((int) $revenue['author_id'], (float) $revenue['amount'], 'creator_income', $ref, $remark) : new \WP_Error('balance_unavailable')); + if (is_wp_error($credited)) { + $wpdb->query('ROLLBACK'); + return false; + } + } + $updated = $wpdb->query($wpdb->prepare( + "UPDATE {$this->table()} SET status = %s, settled_at = %s, audit_json = %s WHERE id = %d AND status = %s", + 'settled', + current_time('mysql'), + $this->audit_json('settled', '', $actor_id), + $revenue_id, + 'pending' + )); + if ($updated !== 1) { + throw new \RuntimeException('revenue_settle_failed'); + } + $wpdb->query('COMMIT'); + return true; + } catch (\Throwable $e) { + $wpdb->query('ROLLBACK'); + return false; + } + } + + private function has_balance_entry(int $user_id, array $ref): bool { + global $wpdb; + + if ($user_id <= 0 || (int) ($ref['id'] ?? 0) <= 0) { + return false; + } + $table = (string) ($ref['currency'] ?? '') === 'points' + ? \AKSRT\Core\DBMigrator::table('credit_log') + : \AKSRT\Core\DBMigrator::table('balance_log'); + if ($table === '') { + return false; + } + return (bool) $wpdb->get_var($wpdb->prepare( + "SELECT id FROM {$table} WHERE user_id = %d AND ref_type = %s AND ref_id = %d AND reason = %s LIMIT 1", + $user_id, + 'revenue', + (int) $ref['id'], + 'creator_income' + )); + } + + private function is_enabled(): bool { + return class_exists('\\AKSRT\\Settings\\Settings') + && !empty(\AKSRT\Settings\Settings::get_payment_revenue_settings()['enabled']); + } + + private function audit_json(string $action, string $message = '', int $actor_id = 0): string { + $json = wp_json_encode([ + 'action' => sanitize_key($action), + 'message' => sanitize_textarea_field($message), + 'actor_id' => max(0, $actor_id), + 'at' => current_time('mysql'), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + return is_string($json) ? $json : '{}'; } public function get_user_revenues(int $user_id, array $args = []): array { global $wpdb; + if ($user_id <= 0) { + return []; + } $where = ['author_id = %d']; $params = [$user_id]; $status = sanitize_key((string) ($args['status'] ?? '')); @@ -176,16 +377,87 @@ class Revenue { $where[] = 'status = %s'; $params[] = $status; } + $currency = sanitize_key((string) ($args['currency'] ?? '')); + if (in_array($currency, ['CNY', 'points'], true)) { + $where[] = 'currency = %s'; + $params[] = $currency; + } $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); $offset = max(0, (int) ($args['offset'] ?? 0)); $params[] = $limit; $params[] = $offset; return (array) $wpdb->get_results($wpdb->prepare( - "SELECT * FROM {$this->table()} WHERE " . implode(' AND ', $where) . " ORDER BY created_at DESC LIMIT %d OFFSET %d", + "SELECT * FROM {$this->table()} WHERE " . implode(' AND ', $where) . " ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d", $params ), ARRAY_A); } + /** + * Return current-author income statistics and paginated records. + * + * @return array{stats:array,items:array,total:int,max_pages:int,page:int,per_page:int} + */ + public function get_user_income_view(int $user_id, array $args = []): array { + global $wpdb; + $empty_stats = [ + 'today' => ['CNY' => 0.0, 'points' => 0.0], + 'total' => ['CNY' => 0.0, 'points' => 0.0], + 'pending' => ['CNY' => 0.0, 'points' => 0.0], + 'settled' => ['CNY' => 0.0, 'points' => 0.0], + ]; + if ($user_id <= 0) { + return ['stats' => $empty_stats, 'items' => [], 'total' => 0, 'max_pages' => 1, 'page' => 1, 'per_page' => 20]; + } + + $where = 'author_id = %d'; + $params = [$user_id]; + $currency = sanitize_key((string) ($args['currency'] ?? '')); + if (in_array($currency, ['CNY', 'points'], true)) { + $where .= ' AND currency = %s'; + $params[] = $currency; + } + $stats_row = $wpdb->get_results($wpdb->prepare( + "SELECT currency, + COALESCE(SUM(CASE WHEN status IN ('pending', 'settled') THEN amount ELSE 0 END), 0) total_amount, + COALESCE(SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END), 0) pending_amount, + COALESCE(SUM(CASE WHEN status = 'settled' THEN amount ELSE 0 END), 0) settled_amount, + COALESCE(SUM(CASE WHEN status IN ('pending', 'settled') AND DATE(created_at) = CURDATE() THEN amount ELSE 0 END), 0) today_amount + FROM {$this->table()} WHERE {$where} + GROUP BY currency", + $params + ), ARRAY_A); + foreach ((array) $stats_row as $row) { + $key = (string) ($row['currency'] ?? ''); + if (!isset($empty_stats['total'][$key])) { + continue; + } + $empty_stats['today'][$key] = (float) ($row['today_amount'] ?? 0); + $empty_stats['total'][$key] = (float) ($row['total_amount'] ?? 0); + $empty_stats['pending'][$key] = (float) ($row['pending_amount'] ?? 0); + $empty_stats['settled'][$key] = (float) ($row['settled_amount'] ?? 0); + } + + $status = sanitize_key((string) ($args['status'] ?? '')); + $list_args = ['currency' => $currency, 'status' => $status, 'limit' => 20, 'offset' => 0]; + $page = max(1, min(100, (int) ($args['page'] ?? 1))); + $list_args['offset'] = ($page - 1) * 20; + $items = $this->get_user_revenues($user_id, $list_args); + $count_where = $where . ($status !== '' ? ' AND status = %s' : ''); + $count_params = $params; + if ($status !== '') { + $count_params[] = $status; + } + $total = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$this->table()} WHERE {$count_where}", $count_params)); + return [ + 'stats' => $empty_stats, + 'items' => $items, + 'total' => $total, + 'max_pages' => max(1, (int) ceil($total / 20)), + 'page' => $page, + 'per_page' => 20, + ]; + } + public function get_list(array $args = []): array { global $wpdb; $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); @@ -200,6 +472,27 @@ class Revenue { $where[] = 'author_id = %d'; $params[] = $user_id; } + if (($post_id = (int) ($args['post_id'] ?? 0)) > 0) { + $where[] = 'post_id = %d'; + $params[] = $post_id; + } + $currency = sanitize_key((string) ($args['currency'] ?? '')); + if (in_array($currency, ['CNY', 'points'], true)) { + $where[] = 'currency = %s'; + $params[] = $currency; + } + $order_number = sanitize_text_field((string) ($args['order_number'] ?? '')); + if ($order_number !== '') { + $where[] = 'order_number LIKE %s'; + $params[] = '%' . $wpdb->esc_like($order_number) . '%'; + } + foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) { + $date = sanitize_text_field((string) ($args[$key] ?? '')); + if ($date !== '') { + $where[] = 'created_at ' . $operator . ' %s'; + $params[] = strlen($date) === 10 ? ($operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59') : $date; + } + } $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; $total = (int) ($params ? $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$this->table()} {$where_sql}", $params)) : $wpdb->get_var("SELECT COUNT(*) FROM {$this->table()}")); $items_params = array_merge($params, [$limit, $offset]); diff --git a/src/Settings/PaymentRuntime.php b/src/Settings/PaymentRuntime.php index 981e338e..9055849a 100644 --- a/src/Settings/PaymentRuntime.php +++ b/src/Settings/PaymentRuntime.php @@ -242,6 +242,10 @@ class PaymentRuntime { public static function get_payment_revenue_settings(): array { $settings = Settings::get_all(); $note = trim((string) ($settings['payment_revenue_note'] ?? '')); + $mode = sanitize_key((string) ($settings['payment_revenue_settlement_mode'] ?? 'automatic')); + if (!in_array($mode, ['automatic', 'manual'], true)) { + $mode = 'automatic'; + } return [ 'enabled' => !empty($settings['pay_income_s']), @@ -250,6 +254,8 @@ class PaymentRuntime { 'ratio_vip2' => max(0, min(100, (int) ($settings['ratio_vip_2'] ?? 80))), 'settlement_threshold' => max(0, (int) ($settings['payment_revenue_settlement_threshold'] ?? 100)), 'cycle_days' => max(1, min(365, (int) ($settings['payment_revenue_cycle_days'] ?? 30))), + 'settlement_mode' => $mode, + 'settlement_delay_hours' => max(0, min(8760, (int) ($settings['payment_revenue_settlement_delay_hours'] ?? 24))), 'points_rule' => sanitize_textarea_field((string) ($settings['payment_revenue_points_rule'] ?? '')), 'note' => $note !== '' ? $note : __('分成策略和台账结构已就绪,订单入账后自动按比例结算。', 'aksrt'), 'display' => [ diff --git a/src/User/Center.php b/src/User/Center.php index eeefd5c0..894d1d09 100644 --- a/src/User/Center.php +++ b/src/User/Center.php @@ -68,6 +68,17 @@ class Center { return new \WP_Error('aksrt_user_center_context', __('用户中心数据加载失败', 'aksrt'), ['status' => 500]); } + $income_request = []; + if ($tab === 'creator') { + $currency = sanitize_key((string) $request->get_param('income_currency')); + $status = sanitize_key((string) $request->get_param('income_status')); + $income_request = [ + 'currency' => in_array($currency, ['', 'CNY', 'points'], true) ? $currency : '', + 'status' => in_array($status, ['', 'pending', 'settled', 'cancelled', 'reversed', 'pending_recovery'], true) ? $status : '', + 'page' => max(1, min(100, (int) $request->get_param('income_page'))), + ]; + $context['creator_income_request'] = $income_request; + } $revenue_request = []; if ($tab === 'revenue') { $reward_tab = sanitize_key((string) $request->get_param('reward_tab')); @@ -95,6 +106,7 @@ class Center { 'tab' => $tab, 'html' => $html, 'reward_tab' => $revenue_request['reward_tab'] ?? '', + 'creator_income' => $income_request, 'commission_page' => $revenue_request['commission_page'] ?? 1, 'invite_page' => $revenue_request['invite_page'] ?? 1, ], @@ -210,6 +222,8 @@ class Center { $revenue_tab_on = class_exists('\AKSRT\Settings\Settings') && !empty(\AKSRT\Settings\Settings::get_payment_affiliate_settings()['enabled']) && \AKSRT\Settings\Settings::invite_registration_enabled(); + $creator_tab_on = class_exists('\AKSRT\Settings\Settings') + && !empty(\AKSRT\Settings\Settings::get_payment_revenue_settings()['enabled']); $vip_on = class_exists('\AKSRT\Settings\Settings') && !empty(\AKSRT\Settings\Settings::get_payment_vip_settings()['enabled']); @@ -217,10 +231,13 @@ class Center { $order = $configured; // Creator center is a dedicated workspace, separate from the posts archive. - if (!in_array('creator', $order, true)) { + if ($creator_tab_on && !in_array('creator', $order, true)) { $insert_at = array_search('profile', $order, true); array_splice($order, $insert_at === false ? 0 : (int) $insert_at + 1, 0, ['creator']); } + if (!$creator_tab_on) { + $order = array_values(array_filter($order, static fn(string $key): bool => $key !== 'creator')); + } // Growth level is a dedicated user-center surface, separate from spendable points. if ($level_on && !in_array('level', $order, true)) {
ID' . esc_html__('用户', 'aksrt') . '' . esc_html__('订单号', 'aksrt') . '' . esc_html__('金额', 'aksrt') . '' . esc_html__('状态', 'aksrt') . '' . esc_html__('时间', 'aksrt') . '' . esc_html__('操作', 'aksrt') . 'ID' . esc_html__('用户', 'aksrt') . '' . esc_html__('订单号', 'aksrt') . '' . esc_html__('金额', 'aksrt') . '' . esc_html__('状态', 'aksrt') . '' . esc_html__('时间', 'aksrt') . '' . esc_html__('操作', 'aksrt') . '
' . esc_html__('暂无分成记录', 'aksrt') . '
' . esc_html__('暂无分成记录', 'aksrt') . '
' . esc_html((string) $id) . '' . ($user ? esc_html($user->display_name) : esc_html((string) $user_id)) . '' . ($st === 'pending' ? '' : '') . '' . esc_html((string) $id) . '' . ($user ? esc_html($user->display_name) : esc_html((string) $user_id)) . '' . esc_html((string) ($row['order_number'] ?? '')) . '' . esc_html(number_format((float) ($row['amount'] ?? 0), 2)) . '' . esc_html($st) . '