Files
wordpress-theme-aksrt/src/Payment/Revenue.php
T
2026-08-24 17:21:06 +08:00

586 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
class Revenue {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
private function table(): string {
return \AKSRT\Core\DBMigrator::table('revenue');
}
/**
* Create one pending creator-income record from a paid order.
*
* @param array<string,mixed> $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<int,int> $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<int,int> $record_ids
* @param int $actor_id
* @return array{settled:array<int,int>,failed:array<int,int>}
*/
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<string,mixed> $row @param array<string,mixed> $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<string,mixed> $row @return array<string,mixed> */
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 : [];
}
}