$order */ public function create_from_paid_order(array $order): int { global $wpdb; $order_number = sanitize_text_field((string) ($order['order_number'] ?? '')); if ($order_number === '' || (string) ($order['status'] ?? '') !== 'completed') { return 0; } $meta = $this->decode_order_meta($order); if (empty($meta['content_pay'])) { return 0; } $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', $author_id, $amount, [ 'order_number' => $order_number, 'currency' => $currency, 'revenue_id' => $id, ]); } return $id; } /** * Legacy generic insertion retained as the native income page entry point. */ public function add(int $user_id, float $amount, string $order_number = '', string $type = 'income'): int { global $wpdb; 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 or more pending records through matching atomic balance services. * * @param int|array $record_ids * @param int $actor_id */ 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; if (!$this->is_enabled()) { return 0; } $settings = \AKSRT\Settings\Settings::get_payment_revenue_settings(); if (($settings['settlement_mode'] ?? 'automatic') !== 'automatic') { return 0; } $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 )); $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'] ?? '')); if ($status !== '') { $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, 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))); $offset = max(0, (int) ($args['offset'] ?? 0)); $where = []; $params = []; if (($status = sanitize_key((string) ($args['status'] ?? ''))) !== '') { $where[] = 'status = %s'; $params[] = $status; } if (($user_id = (int) ($args['user_id'] ?? 0)) > 0) { $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]); $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)]; } 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)); } private function decode_order_meta(array $order): array { if (is_array($order['meta_payload'] ?? null)) { return $order['meta_payload']; } $decoded = json_decode((string) ($order['note'] ?? ''), true); return is_array($decoded) ? $decoded : []; } }