feat(payment): complete creator income center
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
)) . '</p>';
|
||||
|
||||
echo '<form id="aksrt-pay-batch-settle-income" method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin:12px 0;">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_batch_settle_income" />';
|
||||
wp_nonce_field('aksrt_pay_batch_settle_income');
|
||||
submit_button(__('批量结算选中记录', 'aksrt'), 'secondary', '', false);
|
||||
echo '</form>';
|
||||
|
||||
echo '<form method="get" style="margin:12px 0;">';
|
||||
echo '<input type="hidden" name="page" value="aksrt_income" />';
|
||||
echo '<select name="status">';
|
||||
foreach (['' => __('全部状态', 'aksrt'), 'pending' => __('待结算', 'aksrt'), 'settled' => __('已结算', 'aksrt')] as $key => $label) {
|
||||
foreach (['' => __('全部状态', 'aksrt'), 'pending' => __('待结算', 'aksrt'), 'settled' => __('已结算', 'aksrt'), 'cancelled' => __('已取消', 'aksrt'), 'reversed' => __('已冲正', 'aksrt'), 'pending_recovery' => __('待追缴', 'aksrt')] as $key => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($key), selected($status, $key, false), esc_html($label));
|
||||
}
|
||||
echo '</select> ';
|
||||
echo '<input type="number" min="0" name="user_id" value="' . esc_attr($user_id > 0 ? (string) $user_id : '') . '" placeholder="' . esc_attr__('用户 ID', 'aksrt') . '" class="small-text" /> ';
|
||||
echo '<input type="number" min="0" name="post_id" value="' . esc_attr($post_id > 0 ? (string) $post_id : '') . '" placeholder="' . esc_attr__('文章 ID', 'aksrt') . '" class="small-text" /> ';
|
||||
echo '<select name="currency"><option value="">' . esc_html__('全部货币', 'aksrt') . '</option><option value="CNY"' . selected($currency, 'CNY', false) . '>CNY</option><option value="points"' . selected($currency, 'points', false) . '>' . esc_html__('积分', 'aksrt') . '</option></select> ';
|
||||
echo '<input type="search" name="order_number" value="' . esc_attr($order_number) . '" placeholder="' . esc_attr__('订单号', 'aksrt') . '" /> ';
|
||||
echo '<input type="date" name="date_from" value="' . esc_attr($date_from) . '" /> — <input type="date" name="date_to" value="' . esc_attr($date_to) . '" /> ';
|
||||
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
|
||||
if ($user_id > 0) {
|
||||
echo ' <a class="button button-small" href="' . esc_url($this->page_url('aksrt_income')) . '">' . esc_html__('清除用户过滤', 'aksrt') . '</a>';
|
||||
@@ -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 '<table class="wp-list-table widefat fixed striped"><thead><tr>';
|
||||
echo '<th>ID</th><th>' . esc_html__('用户', 'aksrt') . '</th><th>' . esc_html__('订单号', 'aksrt') . '</th><th>' . esc_html__('金额', 'aksrt') . '</th><th>' . esc_html__('状态', 'aksrt') . '</th><th>' . esc_html__('时间', 'aksrt') . '</th><th>' . esc_html__('操作', 'aksrt') . '</th>';
|
||||
echo '<th><input type="checkbox" form="aksrt-pay-batch-settle-income" aria-label="' . esc_attr__('全选待结算记录', 'aksrt') . '" /></th><th>ID</th><th>' . esc_html__('用户', 'aksrt') . '</th><th>' . esc_html__('订单号', 'aksrt') . '</th><th>' . esc_html__('金额', 'aksrt') . '</th><th>' . esc_html__('状态', 'aksrt') . '</th><th>' . esc_html__('时间', 'aksrt') . '</th><th>' . esc_html__('操作', 'aksrt') . '</th>';
|
||||
echo '</tr></thead><tbody>';
|
||||
if (!$items) {
|
||||
echo '<tr><td colspan="7">' . esc_html__('暂无分成记录', 'aksrt') . '</td></tr>';
|
||||
echo '<tr><td colspan="8">' . esc_html__('暂无分成记录', 'aksrt') . '</td></tr>';
|
||||
}
|
||||
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 '<tr>';
|
||||
echo '<td>' . esc_html((string) $id) . '</td>';
|
||||
echo '<td>' . ($user ? esc_html($user->display_name) : esc_html((string) $user_id)) . '</td>';
|
||||
echo '<td>' . ($st === 'pending' ? '<input type="checkbox" form="aksrt-pay-batch-settle-income" name="revenue_ids[]" value="' . esc_attr((string) $id) . '" />' : '') . '</td>';
|
||||
echo '<td>' . esc_html((string) $id) . '</td><td>' . ($user ? esc_html($user->display_name) : esc_html((string) $user_id)) . '</td>';
|
||||
echo '<td><code>' . esc_html((string) ($row['order_number'] ?? '')) . '</code></td>';
|
||||
echo '<td>' . esc_html(number_format((float) ($row['amount'] ?? 0), 2)) . '</td>';
|
||||
echo '<td>' . esc_html($st) . '</td>';
|
||||
@@ -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])) {
|
||||
|
||||
@@ -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<id>\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<order_id>[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' => []]]);
|
||||
|
||||
@@ -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' => ''],
|
||||
]],
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+318
-25
@@ -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<int,int> $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]);
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
+18
-1
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user