$order */ public function create_from_paid_order(array $order): int { global $wpdb; if (!$this->is_enabled()) { return 0; } $order_number = sanitize_text_field((string) ($order['order_number'] ?? '')); if ($order_number === '' || (string) ($order['status'] ?? '') !== \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) { return 0; } $product_type = sanitize_key((string) ($order['product_type'] ?? '')); if ($product_type !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) { 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_minor = $this->order_amount_minor($order['amount'] ?? $meta['amount'] ?? $meta['payable'] ?? null, $is_points); if ($original_minor === null || $original_minor <= 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($currency, $author_level); if ($ratio <= 0) { return 0; } $amount_minor = $this->percentage_minor($original_minor, $ratio, !$is_points); if ($is_points) { $amount_minor = max(1, $amount_minor); } if ($amount_minor <= 0) { return 0; } $original_amount = $this->stored_amount($original_minor, $is_points); $amount = $this->stored_amount($amount_minor, $is_points); $expected = [ 'author_id' => $author_id, 'order_number' => $order_number, 'post_id' => $post_id, 'buyer_id' => $buyer_id, 'currency' => $currency, 'original_minor' => $original_minor, 'ratio' => $ratio, 'amount_minor' => $amount_minor, ]; $existing = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY id ASC LIMIT 1", $order_number ), ARRAY_A); if (is_array($existing)) { return $this->revenue_matches($existing, $expected) ? (int) $existing['id'] : -1; } $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', $is_points ? '%d' : '%s', '%d', '%d', $is_points ? '%d' : '%s', '%s', '%s', '%s']); if ($inserted === false) { $existing = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY id ASC LIMIT 1", $order_number ), ARRAY_A); return is_array($existing) && $this->revenue_matches($existing, $expected) ? (int) $existing['id'] : -1; } 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, string $operation_key = ''): bool { if (!is_array($record_ids)) { return $this->settle_result((int) $record_ids, $actor_id, $operation_key) instanceof \AKSRT\Ledger\LedgerResult; } $result = $this->settle_batch($record_ids, $actor_id, $operation_key); return !empty($result['settled']) && empty($result['failed']); } /** @return \AKSRT\Ledger\LedgerResult|\WP_Error */ public function settle_result(int $revenue_id, int $actor_id = 0, string $operation_key = '') { if ($revenue_id <= 0 || !$this->is_enabled()) { return new \WP_Error('aksrt_revenue_invalid', __('收益记录无效或结算功能未启用', 'aksrt'), ['status' => 400]); } return $this->settle_one($revenue_id, $actor_id, $operation_key); } /** * Settle a batch while preserving per-record outcomes for operational feedback. * * @param int|array $record_ids * @param int $actor_id * @return array{settled:array,failed:array} */ public function settle_batch($record_ids, int $actor_id = 0, string $operation_key = ''): array { $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 ['settled' => [], 'failed' => $ids]; } $settled = []; $failed = []; foreach ($ids as $revenue_id) { $item_key = $operation_key !== '' && count($ids) === 1 ? $operation_key : ($operation_key !== '' ? \AKSRT\Ledger\OperationKey::deterministic('revenue-settle-item', [$operation_key, $revenue_id]) : ''); if ($this->settle_one($revenue_id, $actor_id, $item_key) instanceof \AKSRT\Ledger\LedgerResult) { $settled[] = $revenue_id; } else { $failed[] = $revenue_id; } } return ['settled' => $settled, 'failed' => $failed]; } /** * Settle all pending records whose rolling earning cycle has ended. * * @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(); $cycle_days = max(1, (int) ($settings['cycle_days'] ?? 30)); $timezone = function_exists('wp_timezone') ? wp_timezone() : new \DateTimeZone('UTC'); $cutoff = (new \DateTimeImmutable('now', $timezone)) ->modify('-' . $cycle_days . ' days') ->format('Y-m-d H:i:s'); $limit = max(1, min(500, $limit)); $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; } /** @return \AKSRT\Ledger\LedgerResult|\WP_Error */ private function settle_one(int $revenue_id, int $actor_id, string $operation_key = '') { global $wpdb; $revenue = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d", $revenue_id), ARRAY_A); if (!is_array($revenue)) { return new \WP_Error('aksrt_revenue_missing', __('收益记录不存在', 'aksrt'), ['status' => 404]); } $asset = (string) ($revenue['currency'] ?? '') === 'points' ? \AKSRT\Ledger\AssetType::POINTS : \AKSRT\Ledger\AssetType::BALANCE; try { $amount_minor = $this->database_amount_minor((string) $revenue['amount'], $asset === \AKSRT\Ledger\AssetType::POINTS); } catch (\InvalidArgumentException $e) { return new \WP_Error('aksrt_revenue_amount', __('收益金额无效', 'aksrt'), ['status' => 500]); } if ($amount_minor === null || $amount_minor <= 0 || (int) ($revenue['author_id'] ?? 0) <= 0) { return new \WP_Error('aksrt_revenue_amount', __('收益金额或收款用户无效', 'aksrt'), ['status' => 500]); } $expected = [ 'author_id' => (int) $revenue['author_id'], 'currency' => (string) $revenue['currency'], 'amount' => (string) $revenue['amount'], ]; $guard = function () use ($revenue_id, $expected) { global $wpdb; $current = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d FOR UPDATE", $revenue_id), ARRAY_A); if (!is_array($current) || (string) ($current['status'] ?? '') !== 'pending' || (int) ($current['author_id'] ?? 0) !== $expected['author_id'] || (string) ($current['currency'] ?? '') !== $expected['currency'] || (string) ($current['amount'] ?? '') !== $expected['amount'] ) { return new \WP_Error('aksrt_revenue_state', __('收益记录状态已变化', 'aksrt'), ['status' => 409]); } return true; }; $mutation = function () use ($revenue_id, $actor_id) { global $wpdb; $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' )); return $updated === 1 ? true : new \WP_Error('aksrt_revenue_state', __('收益记录状态已变化', 'aksrt'), ['status' => 409]); }; $result = \AKSRT\Ledger\LedgerService::get_instance()->credit( $expected['author_id'], $asset, $amount_minor, $operation_key !== '' ? $operation_key : \AKSRT\Ledger\OperationKey::deterministic('revenue-settle', [$revenue_id]), 'creator_income', ['type' => 'revenue', 'id' => $revenue_id], sprintf(__('创作收益结算(订单 %s)', 'aksrt'), (string) ($revenue['order_number'] ?? '')), $actor_id, ['currency' => $expected['currency']], $mutation, $guard ); return $result; } 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", "status IN ('pending', 'settled')"]; $params = [$user_id]; $status = sanitize_key((string) ($args['status'] ?? '')); if (in_array($status, ['pending', 'settled'], true)) { $where[] = 'status = %s'; $params[] = $status; } $currency = strtolower(sanitize_key((string) ($args['currency'] ?? ''))); if (in_array($currency, ['cny', 'points'], true)) { $currency = $currency === 'points' ? 'points' : 'CNY'; $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; $rows = (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 array_map([$this, 'normalize_revenue_row'], $rows); } /** * 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.00', 'points' => 0], 'total' => ['CNY' => '0.00', 'points' => 0], 'pending' => ['CNY' => '0.00', 'points' => 0], 'settled' => ['CNY' => '0.00', 'points' => 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 = strtolower(sanitize_key((string) ($args['currency'] ?? ''))); if (in_array($currency, ['cny', 'points'], true)) { $currency = $currency === 'points' ? 'points' : 'CNY'; $where .= ' AND currency = %s'; $params[] = $currency; } $today = current_time('Y-m-d'); $stats_params = array_merge($params, [$today]); $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) = %s THEN amount ELSE 0 END), 0) today_amount FROM {$this->table()} WHERE {$where} GROUP BY currency", $stats_params ), ARRAY_A); foreach ((array) $stats_row as $row) { $key = (string) ($row['currency'] ?? ''); if (!isset($empty_stats['total'][$key])) { continue; } $is_points = $key === 'points'; $empty_stats['today'][$key] = $this->display_amount((string) ($row['today_amount'] ?? '0'), $is_points); $empty_stats['total'][$key] = $this->display_amount((string) ($row['total_amount'] ?? '0'), $is_points); $empty_stats['pending'][$key] = $this->display_amount((string) ($row['pending_amount'] ?? '0'), $is_points); $empty_stats['settled'][$key] = $this->display_amount((string) ($row['settled_amount'] ?? '0'), $is_points); } $status = sanitize_key((string) ($args['status'] ?? '')); if (!in_array($status, ['pending', 'settled'], true)) { $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 . " AND status IN ('pending', 'settled')" . ($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 = ["status IN ('pending', 'settled')"]; $params = []; $status = sanitize_key((string) ($args['status'] ?? '')); if (in_array($status, ['pending', 'settled'], true)) { $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 = strtolower(sanitize_key((string) ($args['currency'] ?? ''))); if (in_array($currency, ['cny', 'points'], true)) { $currency = $currency === 'points' ? 'points' : 'CNY'; $where[] = 'currency = %s'; $params[] = $currency; } $order_status = sanitize_key((string) ($args['order_status'] ?? '')); $valid_order_statuses = class_exists('\\AKSRT\\Core\\DomainModel') ? \AKSRT\Core\DomainModel::order_status_labels() : []; $orders_table = \AKSRT\Core\DBMigrator::table('order'); if ($orders_table !== '' && isset($valid_order_statuses[$order_status])) { $where[] = "EXISTS (SELECT 1 FROM {$orders_table} AS income_order WHERE income_order.order_number = {$this->table()}.order_number AND income_order.status = %s)"; $params[] = $order_status; } $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) : ''; $count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}"; $total = (int) ($params ? $wpdb->get_var($wpdb->prepare($count_sql, $params)) : $wpdb->get_var($count_sql)); $items_params = array_merge($params, [$limit, $offset]); $items = array_map([$this, 'normalize_revenue_row'], (array) $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)]; } /** * Return separate cash and points totals; amounts must never be added across units. * * @return array{cash:array{total:string,pending:string,settled:string,count:int},points:array{total:int,pending:int,settled:int,count:int}} */ public function get_stats(int $user_id = 0): array { global $wpdb; $empty = [ 'cash' => ['total' => '0.00', 'pending' => '0.00', 'settled' => '0.00', 'count' => 0], 'points' => ['total' => 0, 'pending' => 0, 'settled' => 0, 'count' => 0], ]; $where = ["status IN ('pending', 'settled')"]; $params = []; if ($user_id > 0) { $where[] = 'author_id = %d'; $params[] = $user_id; } $stats_sql = "SELECT currency, 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 " . implode(' AND ', $where) . " GROUP BY currency"; $rows = $wpdb->get_results($params ? $wpdb->prepare($stats_sql, $params) : $stats_sql, ARRAY_A); foreach ((array) $rows as $row) { $key = (string) ($row['currency'] ?? '') === 'points' ? 'points' : 'cash'; $is_points = $key === 'points'; $empty[$key] = [ 'total' => $this->display_amount((string) ($row['total'] ?? '0'), $is_points), 'pending' => $this->display_amount((string) ($row['pending'] ?? '0'), $is_points), 'settled' => $this->display_amount((string) ($row['settled'] ?? '0'), $is_points), 'count' => (int) ($row['count'] ?? 0), ]; } return $empty; } private function resolve_share_ratio(string $currency, int $author_level): int { $settings = \AKSRT\Settings\Settings::get_payment_revenue_settings(); $bucket = $currency === 'points' ? 'points' : 'cash'; $level_key = $author_level >= 2 ? 'vip2' : ($author_level === 1 ? 'vip1' : 'ordinary'); $ratio = $settings['ratios'][$bucket][$level_key] ?? 0; return max(0, min(100, (int) $ratio)); } private function order_amount_minor(mixed $value, bool $is_points): ?int { if ($is_points) { $raw = is_int($value) ? (string) $value : (is_string($value) ? trim($value) : ''); if (!preg_match('/^(0|[1-9]\d*)$/', $raw)) { return null; } $max = (string) PHP_INT_MAX; if (strlen($raw) > strlen($max) || (strlen($raw) === strlen($max) && strcmp($raw, $max) > 0)) { return null; } return (int) $raw; } if (!is_string($value)) { return null; } try { return \AKSRT\Ledger\AssetAmount::balance(trim($value))->minor(); } catch (\InvalidArgumentException $e) { return null; } } private function percentage_minor(int $base_minor, int $ratio, bool $round_half_up): int { $whole = intdiv($base_minor, 100) * $ratio; $remainder = ($base_minor % 100) * $ratio; return $whole + intdiv($remainder + ($round_half_up ? 50 : 0), 100); } private function stored_amount(int $minor, bool $is_points): string|int { return $is_points ? $minor : \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $minor)->decimal(); } private function database_amount_minor(string $value, bool $is_points): ?int { if ($is_points) { if (!preg_match('/^(0|[1-9]\d*)(?:\.0+)?$/', $value, $matches)) { return null; } return $this->order_amount_minor($matches[1], true); } try { return \AKSRT\Ledger\AssetAmount::balance($value)->minor(); } catch (\InvalidArgumentException $e) { return null; } } /** @param array $row @param array $expected */ private function revenue_matches(array $row, array $expected): bool { $is_points = $expected['currency'] === 'points'; return (int) ($row['author_id'] ?? 0) === $expected['author_id'] && (string) ($row['order_number'] ?? '') === $expected['order_number'] && (int) ($row['post_id'] ?? 0) === $expected['post_id'] && (int) ($row['buyer_id'] ?? 0) === $expected['buyer_id'] && (string) ($row['currency'] ?? '') === $expected['currency'] && $this->database_amount_minor((string) ($row['original_amount'] ?? ''), $is_points) === $expected['original_minor'] && (int) ($row['share_ratio'] ?? -1) === $expected['ratio'] && $this->database_amount_minor((string) ($row['amount'] ?? ''), $is_points) === $expected['amount_minor']; } private function display_amount(string $value, bool $is_points): string|int { $minor = $this->database_amount_minor($value, $is_points); if ($minor === null) { return $is_points ? 0 : '0.00'; } return $this->stored_amount($minor, $is_points); } /** @param array $row @return array */ private function normalize_revenue_row(array $row): array { $is_points = (string) ($row['currency'] ?? '') === 'points'; foreach (['original_amount', 'amount', 'reversal_amount'] as $field) { $row[$field] = $this->display_amount((string) ($row[$field] ?? '0'), $is_points); } $row['share_ratio'] = (int) ($row['share_ratio'] ?? 0); return $row; } 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 : []; } }