Merge pull request 'Worktree creator income phase1' (#402) from worktree-creator-income-phase1 into main

Reviewed-on: CYL/wordpress-theme-aksrt#402
This commit is contained in:
2026-08-07 11:03:30 +08:00
17 changed files with 721 additions and 389 deletions
+11 -11
View File
@@ -28,12 +28,12 @@
### Done when
- [ ] `src/Core/DBMigratorPart2.php` 或最终负责收益表 schema 的文件包含作者、订单、文章、购买者、货币、原始支付金额、比例快照、会员等级、作者收益金额、状态和时间字段,并同步提升 schema 版本。
- [ ] `src/Payment/Revenue.php` 或拆分后的作者收益服务实现 `create_from_paid_order(order)`,且同一订单重复调用只保留一条作者收益记录。
- [ ] 现金订单仅创建现金收益记录,积分订单仅创建积分收益记录;作者购买自己的付费文章不会创建收益记录。
- [ ] 普通用户、一级会员、二级会员分别按 60%、70%、80% 计算,并把生成时比例保存到收益记录。
- [ ] 支付成功但权益未完成时不创建收益;权益完成后的支付流程能够创建待结算记录。
- [ ] 创作分成关闭时,收益生成入口被跳过;相关收益服务测试覆盖上述场景。
- [x] `src/Core/DBMigratorPart2.php` 或最终负责收益表 schema 的文件包含作者、订单、文章、购买者、货币、原始支付金额、比例快照、会员等级、作者收益金额、状态和时间字段,并同步提升 schema 版本。
- [x] `src/Payment/Revenue.php` 或拆分后的作者收益服务实现 `create_from_paid_order(order)`,且同一订单重复调用只保留一条作者收益记录。
- [x] 现金订单仅创建现金收益记录,积分订单仅创建积分收益记录;作者购买自己的付费文章不会创建收益记录。
- [x] 普通用户、一级会员、二级会员分别按 60%、70%、80% 计算,并把生成时比例保存到收益记录。
- [x] 支付成功但权益未完成时不创建收益;权益完成后的支付流程能够创建待结算记录。
- [x] 创作分成关闭时,收益生成入口被跳过;相关收益服务测试覆盖上述场景。
---
@@ -47,11 +47,11 @@
### Done when
- [ ] `src/Payment/Revenue.php` 或拆分后的结算服务实现 `settle(record_ids, actor)`,现金和积分分别调用对应原子余额服务并写入对应流水。
- [ ] `settlement scheduler` 仅在自动模式下处理达到配置延迟时间的待结算记录,默认延迟 24 小时;手动模式下定时任务不入账。
- [ ] 同一收益被重复结算、并发结算或重复定时扫描时,只能成功入账一次。
- [ ] `Refund reversal handler` 实现 `cancel_or_reverse(order_id, reason)`:未入钱包记录变为已取消/退款,已入钱包记录按货币执行冲正。
- [ ] 冲正时余额不足不会产生负余额,收益记录转为待追缴,并保存应冲正金额、错误信息和审计信息。
- [x] `src/Payment/Revenue.php` 或拆分后的结算服务实现 `settle(record_ids, actor)`,现金和积分分别调用对应原子余额服务并写入对应流水。
- [x] `settlement scheduler` 仅在自动模式下处理达到配置延迟时间的待结算记录,默认延迟 24 小时;手动模式下定时任务不入账。
- [x] 同一收益被重复结算、并发结算或重复定时扫描时,只能成功入账一次。
- [x] `Refund reversal handler` 实现 `cancel_or_reverse(order_id, reason)`:未入钱包记录变为已取消/退款,已入钱包记录按货币执行冲正。
- [x] 冲正时余额不足不会产生负余额,收益记录转为待追缴,并保存应冲正金额、错误信息和审计信息。
- [ ] 重复退款通知和重复冲正请求保持幂等;服务测试覆盖自动结算、手动结算、退款前结算、退款后结算和余额不足场景。
---
@@ -10,6 +10,8 @@ export const revenueSchema = {
ratio_vip_2: 0,
payment_revenue_settlement_threshold: 100,
payment_revenue_cycle_days: 30,
payment_revenue_settlement_mode: 'automatic',
payment_revenue_settlement_delay_hours: 24,
payment_revenue_points_rule: '',
payment_revenue_note: '分成规则已就绪,订单入账后自动按比例结算。',
pay_income_desc_details: '',
@@ -27,6 +29,8 @@ export const revenueSchema = {
{ key: 'ratio_vip_2', label: 'VIP2分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_settlement_threshold', label: '最小结算金额', type: 'number', default: 100, min: 0, max: 100000, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_cycle_days', label: '结算周期(天)', type: 'number', default: 30, min: 1, max: 365, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_settlement_mode', label: '结算模式', type: 'select', default: 'automatic', options: [{ value: 'automatic', label: '自动结算' }, { value: 'manual', label: '手动结算' }], dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_settlement_delay_hours', label: '结算延迟(小时)', type: 'number', default: 24, min: 0, max: 8760, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_points_rule', label: '积分规则', type: 'textarea', default: '', dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_note', label: '结算备注', type: 'textarea', default: '分成策略和台账结构已就绪,订单入账后自动按比例结算。', dependsOn: { key: 'pay_income_s', value: true } },
],
+4
View File
@@ -709,6 +709,10 @@
border-bottom: 0;
}
/* ==================== 作者收益中心 ==================== */
.creator-income { display:flex; flex-direction:column; gap:16px; }.creator-income__hero { padding:24px 26px; border-radius:var(--radius-lg); color:#fff; background:linear-gradient(135deg,var(--color-primary),#7c3aed); }.creator-income__hero p { margin:0 0 6px; opacity:.82; font-size:13px; }.creator-income__hero h2 { margin:0; color:inherit; }.creator-income__tabs { display:flex; gap:8px; flex-wrap:wrap; }.creator-income__tab { padding:7px 12px; border-radius:var(--radius-pill); color:var(--color-ink-3); text-decoration:none; }.creator-income__tab.is-active,.creator-income__tab:hover { color:var(--color-primary); background:var(--color-primary-soft); }.creator-income__cards { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }.creator-income-card { padding:18px; border:1px solid var(--color-line); border-radius:var(--radius-lg); background:var(--surface-1); }.creator-income-card h3 { margin:0 0 14px; }.creator-income-card__metrics { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; }.creator-income-card__metrics strong,.creator-income-card__metrics small { display:block; }.creator-income-card__metrics strong { color:var(--color-ink); font-size:18px; }.creator-income-card__metrics small { margin-top:4px; color:var(--color-ink-4); font-size:11px; }.creator-income__info,.creator-income__details { padding:20px; }.creator-income__info h3,.creator-income__toolbar h3 { margin:0; }.creator-income__info div,.creator-income__toolbar p { margin:6px 0 0; color:var(--color-ink-4); font-size:13px; }.creator-income__toolbar { display:flex; justify-content:space-between; align-items:flex-end; gap:12px; margin-bottom:14px; }.creator-income__table-wrap { overflow-x:auto; }.creator-income__table { width:100%; border-collapse:collapse; font-size:12px; }.creator-income__table th,.creator-income__table td { padding:10px 8px; border-bottom:1px solid var(--color-line); text-align:left; white-space:nowrap; }.creator-income-status { padding:3px 7px; border-radius:var(--radius-pill); color:var(--color-primary); background:var(--color-primary-soft); }.creator-income-status--cancelled,.creator-income-status--reversed { color:var(--color-ink-4); background:var(--surface-2); }.creator-income-status--pending_recovery { color:var(--color-error); background:rgba(220,38,38,.08); }.creator-income__empty { padding:40px 20px; text-align:center; color:var(--color-ink-4); }.creator-income__empty i { font-size:28px; color:var(--color-primary); } .creator-income__details select { margin-left:6px; }
@media (max-width:767px) { .creator-income__cards { grid-template-columns:1fr; }.creator-income-card__metrics { grid-template-columns:repeat(2,minmax(0,1fr)); }.creator-income__toolbar { align-items:flex-start; flex-direction:column; } }
/* ==================== 独立创作中心工作台 ==================== */
.creator-workspace { display: flex; flex-direction: column; gap: 16px; }
.creator-workspace__hero { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 24px 26px; border-radius: var(--radius-lg); color: #fff; background: linear-gradient(135deg, var(--color-primary), #7c3aed); }
+7 -33
View File
@@ -79,6 +79,12 @@
if (!restUrl) return Promise.reject(new Error('rest-unavailable'));
target.classList.add('is-loading');
var url = restUrl.replace(/\/$/, '') + '/user-center/panel/' + encodeURIComponent(name);
if (name === 'creator') {
var params = new URLSearchParams(window.location.search);
['income_currency', 'income_status', 'income_page'].forEach(function(key) {
if (params.get(key)) url += (url.indexOf('?') === -1 ? '?' : '&') + key + '=' + encodeURIComponent(params.get(key));
});
}
target._aksrtPanelRequest = fetch(url, {
credentials: 'same-origin',
headers: { 'X-WP-Nonce': (window.aksrtData && (window.aksrtData.restNonce || window.aksrtData.nonce)) || '' }
@@ -699,36 +705,6 @@
});
}
/* ==================== 创作中心文章操作 ==================== */
function initCreatorPostActions() {
document.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action="delete-post"]');
if (!btn || btn.disabled) return;
if (!confirm(i18n.confirmDeletePost || '确定删除这篇文章?')) return;
var postId = btn.getAttribute('data-post-id');
var nonce = btn.getAttribute('data-nonce') || submitNonce;
if (!postId || !nonce) return;
btn.disabled = true;
var body = new URLSearchParams();
body.set('action', 'aksrt_delete_post');
body.set('id', postId);
body.set('_nonce', nonce);
fetch(ajaxUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, credentials: 'same-origin', body: body.toString() })
.then(function(r) { return r.json(); })
.then(function(res) {
if (res && res.success) {
var item = btn.closest('.profile-list-item, .creator-post-row');
if (item) item.remove();
showToast((res.data && res.data.message) || '文章已经删除');
} else {
btn.disabled = false;
showToast((res && res.data && res.data.message) || i18n.operationFailed || '操作失败');
}
})
.catch(function() { btn.disabled = false; showToast(i18n.networkError || '网络错误'); });
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
@@ -736,7 +712,6 @@
initProfileForm();
initAuthForm();
initUnfavorite();
initCreatorPostActions();
initNotifications();
initSettings();
});
@@ -745,8 +720,7 @@
initProfileForm();
initAuthForm();
initUnfavorite();
initCreatorPostActions();
initNotifications();
initNotifications();
initSettings();
}
+1 -1
View File
@@ -111,7 +111,7 @@ add_filter('query_vars', function ($vars) {
// 主题切换离开时:清理本主题注册的 cron 任务
add_action('switch_theme', function () {
$hooks = ['aksrt_dl_token_cleanup', 'aksrt_email_verification_backfill_batch', 'aksrt_notification_cleanup'];
$hooks = ['aksrt_dl_token_cleanup', 'aksrt_email_verification_backfill_batch', 'aksrt_notification_cleanup', 'aksrt_creator_income_settlement'];
foreach ($hooks as $hook) {
$next = wp_next_scheduled($hook);
while ($next) {
@@ -1,6 +1,6 @@
<?php
/**
* User center creator workspace.
* User center creator income panel.
*
* @package AKSRT
*/
@@ -8,58 +8,40 @@
defined('ABSPATH') || exit;
$context = is_array($args['context'] ?? null) ? $args['context'] : [];
extract($context, EXTR_SKIP); // phpcs:ignore WordPress.PHP.DontExtract.extract_extract
$can_submit = class_exists('\\AKSRT\\Core\\FrontSubmit')
&& \AKSRT\Core\FrontSubmit::get_instance()->can_submit((int) $user_id);
$counts = isset($user_status_counts) && is_array($user_status_counts) ? $user_status_counts : [];
$total = array_sum(array_map('intval', $counts));
$creator_filter = isset($_GET['creator_status']) ? sanitize_key(wp_unslash($_GET['creator_status'])) : 'all';
if (!in_array($creator_filter, ['all', 'publish', 'draft', 'pending', 'private'], true)) {
$creator_filter = 'all';
$user_id = (int) ($context['user_id'] ?? get_current_user_id());
if ($user_id <= 0) {
echo '<div class="uc-panel-empty muted-2-color">' . esc_html__('请先登录', 'aksrt') . '</div>';
return;
}
$creator_url = aksrt_get_user_center_tab_url('creator');
$filters = [
'all' => __('全部文章', 'aksrt'),
'publish' => __('已发布', 'aksrt'),
'draft' => __('草稿', 'aksrt'),
'pending' => __('待审核', 'aksrt'),
'private' => __('私密', 'aksrt'),
];
$settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_payment_revenue_settings() : [];
if (empty($settings['enabled'])) {
echo '<div class="uc-panel-empty muted-2-color">' . esc_html__('创作收益功能暂未开启。', 'aksrt') . '</div>';
return;
}
$request = is_array($context['creator_income_request'] ?? null) ? $context['creator_income_request'] : [];
$currency = sanitize_key((string) ($request['currency'] ?? ($_GET['income_currency'] ?? '')));
$status = sanitize_key((string) ($request['status'] ?? ($_GET['income_status'] ?? '')));
$currency = in_array($currency, ['', 'CNY', 'points'], true) ? $currency : '';
$status = in_array($status, ['', 'pending', 'settled', 'cancelled', 'reversed', 'pending_recovery'], true) ? $status : '';
$page = max(1, min(100, (int) ($request['page'] ?? ($_GET['income_page'] ?? 1))));
$view = class_exists('\AKSRT\Payment\Revenue') ? \AKSRT\Payment\Revenue::get_instance()->get_user_income_view($user_id, ['currency' => $currency, 'status' => $status, 'page' => $page]) : ['stats' => [], 'items' => [], 'max_pages' => 1, 'page' => 1];
$stats = (array) ($view['stats'] ?? []);
$items = (array) ($view['items'] ?? []);
$base_url = function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('creator') : home_url('/profile/?tab=creator');
$filter_url = static function (array $extra) use ($base_url): string { return add_query_arg(array_merge(['tab' => 'creator'], $extra), remove_query_arg(['income_page'])); };
$format = static function ($amount, string $unit): string { return number_format_i18n((float) $amount, $unit === 'points' ? 0 : 2); };
$status_labels = ['pending' => __('待结算', 'aksrt'), 'settled' => __('已入钱包', 'aksrt'), 'cancelled' => __('已取消', 'aksrt'), 'reversed' => __('已冲正', 'aksrt'), 'pending_recovery' => __('待追缴', 'aksrt')];
?>
<div class="creator-workspace">
<header class="creator-workspace__hero">
<div>
<p class="creator-workspace__eyebrow"><?php esc_html_e('内容工作台', 'aksrt'); ?></p>
<h2 class="creator-workspace__title"><?php esc_html_e('创作中心', 'aksrt'); ?></h2>
<p class="creator-workspace__intro"><?php esc_html_e('管理你的内容,查看发布进度,持续分享有价值的文章。', 'aksrt'); ?></p>
</div>
<?php if ($can_submit && !empty($new_post_url)) : ?>
<a class="creator-workspace__primary btn-primary" href="<?php echo esc_url($new_post_url); ?>">
<i class="fas fa-plus" aria-hidden="true"></i><?php esc_html_e('写新文章', 'aksrt'); ?>
</a>
<?php endif; ?>
</header>
<section class="creator-workspace__stats" aria-label="<?php esc_attr_e('创作数据', 'aksrt'); ?>">
<div class="creator-stat-card creator-stat-card--total"><span class="creator-stat-card__icon"><i class="fas fa-layer-group" aria-hidden="true"></i></span><span><strong><?php echo esc_html((string) $total); ?></strong><small><?php esc_html_e('全部文章', 'aksrt'); ?></small></span></div>
<div class="creator-stat-card"><span class="creator-stat-card__icon"><i class="fas fa-check" aria-hidden="true"></i></span><span><strong><?php echo esc_html((string) ($counts['publish'] ?? 0)); ?></strong><small><?php esc_html_e('已发布', 'aksrt'); ?></small></span></div>
<div class="creator-stat-card"><span class="creator-stat-card__icon"><i class="fas fa-pen" aria-hidden="true"></i></span><span><strong><?php echo esc_html((string) ($counts['draft'] ?? 0)); ?></strong><small><?php esc_html_e('草稿', 'aksrt'); ?></small></span></div>
<div class="creator-stat-card"><span class="creator-stat-card__icon"><i class="fas fa-clock" aria-hidden="true"></i></span><span><strong><?php echo esc_html((string) ($counts['pending'] ?? 0)); ?></strong><small><?php esc_html_e('待审核', 'aksrt'); ?></small></span></div>
</section>
<section class="creator-workspace__panel widget-card">
<div class="creator-workspace__toolbar">
<div><h3><?php esc_html_e('内容管理', 'aksrt'); ?></h3><p><?php esc_html_e('按状态查看并管理你的文章', 'aksrt'); ?></p></div>
<nav class="creator-workspace__filters" aria-label="<?php esc_attr_e('文章筛选', 'aksrt'); ?>">
<?php foreach ($filters as $key => $label) : $url = add_query_arg(['tab' => 'creator', 'creator_status' => $key], remove_query_arg(['creator_page'])); ?>
<a class="creator-filter<?php echo $creator_filter === $key ? ' is-active' : ''; ?>" href="<?php echo esc_url($url); ?>" aria-current="<?php echo $creator_filter === $key ? 'page' : 'false'; ?>"><?php echo esc_html($label); ?> <b><?php echo esc_html((string) ('all' === $key ? $total : ($counts[$key] ?? 0))); ?></b></a>
<?php endforeach; ?>
</nav>
</div>
<?php
$creator_args = ['status' => $creator_filter];
get_template_part('templates/parts/user-center/creator-posts', null, ['context' => $context, 'creator_args' => $creator_args]);
?>
</section>
<div class="creator-income" data-creator-income>
<header class="creator-income__hero"><p><?php esc_html_e('作者收益', 'aksrt'); ?></p><h2><?php esc_html_e('创作中心', 'aksrt'); ?></h2><p><?php esc_html_e('查看付费内容带来的现金和积分收益。', 'aksrt'); ?></p></header>
<nav class="creator-income__tabs" aria-label="<?php esc_attr_e('收益货币筛选', 'aksrt'); ?>">
<?php foreach (['' => __('全部', 'aksrt'), 'CNY' => __('现金收益', 'aksrt'), 'points' => __('积分收益', 'aksrt')] as $key => $label) : ?><a class="creator-income__tab<?php echo $currency === $key ? ' is-active' : ''; ?>" href="<?php echo esc_url($filter_url(['income_currency' => $key, 'income_status' => $status])); ?>"><?php echo esc_html($label); ?></a><?php endforeach; ?>
</nav>
<section class="creator-income__cards" aria-label="<?php esc_attr_e('收益概览', 'aksrt'); ?>">
<?php foreach (['CNY' => __('现金', 'aksrt'), 'points' => __('积分', 'aksrt')] as $unit => $label) : ?><article class="creator-income-card"><h3><?php echo esc_html($label); ?></h3><div class="creator-income-card__metrics"><?php foreach (['today' => __('今日收入', 'aksrt'), 'total' => __('累计收益', 'aksrt'), 'pending' => __('待结算', 'aksrt'), 'settled' => __('已入钱包', 'aksrt')] as $metric => $metric_label) : ?><span><strong><?php echo esc_html($format($stats[$metric][$unit] ?? 0, $unit)); ?></strong><small><?php echo esc_html($metric_label); ?></small></span><?php endforeach; ?></div></article><?php endforeach; ?>
</section>
<section class="creator-income__info widget-card"><h3><?php esc_html_e('创作分成说明', 'aksrt'); ?></h3><div><?php echo wp_kses((string) ($settings['description'] ?? $settings['note'] ?? __('订单完成并经过结算延迟后,作者分成会进入对应的钱包。退款订单会取消或冲正收益。', 'aksrt')), ['p' => [], 'br' => [], 'strong' => [], 'em' => [], 'ul' => [], 'ol' => [], 'li' => []]); ?></div></section>
<section class="creator-income__details widget-card"><div class="creator-income__toolbar"><div><h3><?php esc_html_e('收入明细', 'aksrt'); ?></h3><p><?php esc_html_e('按产生时间倒序显示,每页 20 条。', 'aksrt'); ?></p></div><form method="get" action="<?php echo esc_url($base_url); ?>"><input type="hidden" name="tab" value="creator" /><input type="hidden" name="income_currency" value="<?php echo esc_attr($currency); ?>" /><label><?php esc_html_e('状态', 'aksrt'); ?> <select name="income_status" onchange="this.form.submit()"><option value=""><?php esc_html_e('全部状态', 'aksrt'); ?></option><?php foreach ($status_labels as $key => $label) : ?><option value="<?php echo esc_attr($key); ?>"<?php selected($status, $key); ?>><?php echo esc_html($label); ?></option><?php endforeach; ?></select></label></form></div>
<?php if (!$items) : ?><div class="creator-income__empty"><i class="fas fa-chart-line" aria-hidden="true"></i><p><?php esc_html_e('暂无收益明细', 'aksrt'); ?></p></div><?php else : ?><div class="creator-income__table-wrap"><table class="creator-income__table"><thead><tr><th><?php esc_html_e('文章', 'aksrt'); ?></th><th><?php esc_html_e('订单', 'aksrt'); ?></th><th><?php esc_html_e('支付金额', 'aksrt'); ?></th><th><?php esc_html_e('作者分成', 'aksrt'); ?></th><th><?php esc_html_e('状态', 'aksrt'); ?></th><th><?php esc_html_e('产生时间', 'aksrt'); ?></th></tr></thead><tbody><?php foreach ($items as $item) : $post = get_post((int) ($item['post_id'] ?? 0)); $unit = (string) ($item['currency'] ?? 'CNY'); ?><tr><td><?php echo esc_html($post ? get_the_title($post) : __('已删除文章', 'aksrt')); ?></td><td><code><?php echo esc_html((string) ($item['order_number'] ?? '')); ?></code></td><td><?php echo esc_html($format($item['original_amount'] ?? 0, $unit) . ' ' . $unit); ?></td><td><?php echo esc_html($format($item['amount'] ?? 0, $unit) . ' ' . $unit); ?></td><td><span class="creator-income-status creator-income-status--<?php echo esc_attr(sanitize_key((string) ($item['status'] ?? ''))); ?>"><?php echo esc_html($status_labels[$item['status'] ?? ''] ?? (string) ($item['status'] ?? '')); ?></span></td><td><?php echo esc_html((string) ($item['created_at'] ?? '')); ?></td></tr><?php endforeach; ?></tbody></table></div><?php endif; ?>
</section>
</div>
+53 -7
View File
@@ -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,19 +102,19 @@ 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);
$user_id = (int) ($row['user_id'] ?? 0);
$user_id = (int) ($row['author_id'] ?? 0);
$user = $user_id > 0 ? get_userdata($user_id) : null;
$st = (string) ($row['status'] ?? '');
echo '<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])) {
+69 -2
View File
@@ -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' => []]]);
+2
View File
@@ -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' => ''],
]],
+8 -1
View File
@@ -55,11 +55,18 @@ trait PayOrders {
// Consume coupon + write affiliate commission (cash content only; idempotent).
$this->finalize_content_order_side_effects($order_number, $paid_order);
if (class_exists('\AKSRT\Payment\OrderRepository')) {
\AKSRT\Payment\OrderRepository::get_instance()->update_status(
$completed = \AKSRT\Payment\OrderRepository::get_instance()->update_status(
$order_number,
\AKSRT\Payment\Order::STATUS_COMPLETED,
['note' => __('内容权益已发放。', 'aksrt')]
);
if ($completed && class_exists('\AKSRT\Payment\CreatorRevenue')) {
$completed_order = array_merge($paid_order, [
'order_number' => $order_number,
'status' => \AKSRT\Payment\Order::STATUS_COMPLETED,
]);
\AKSRT\Payment\CreatorRevenue::get_instance()->create_from_paid_order($completed_order);
}
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ class DBMigrator {
use DBMigratorIdentity;
/** Current schema version; changing it re-runs dbDelta. */
const SCHEMA_VERSION = '1.18.0';
const SCHEMA_VERSION = '1.20.0';
const OPT_KEY = 'aksrt_db_schema_version';
+19 -5
View File
@@ -187,19 +187,33 @@ trait DBMigratorPart2 {
) {$charset_collate};";
dbDelta($sql_order);
// ---------- 9. 收益台账 ----------
// ---------- 9. 作者收益台账 ----------
$sql_revenue = "CREATE TABLE {$prefix}aksrt_revenue (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
author_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
order_number VARCHAR(32) NOT NULL DEFAULT '',
amount DECIMAL(10,2) NOT NULL DEFAULT 0,
type VARCHAR(32) NOT NULL DEFAULT 'income',
post_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
buyer_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
currency VARCHAR(8) NOT NULL DEFAULT 'CNY',
original_amount DECIMAL(18,2) NOT NULL DEFAULT 0,
share_ratio DECIMAL(5,2) NOT NULL DEFAULT 0,
author_level TINYINT UNSIGNED NOT NULL DEFAULT 0,
amount DECIMAL(18,2) NOT NULL DEFAULT 0,
type VARCHAR(32) NOT NULL DEFAULT 'order',
status VARCHAR(20) NOT NULL DEFAULT 'pending',
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),
KEY idx_user (user_id),
UNIQUE KEY uniq_creator_order (author_id, order_number),
KEY idx_author (author_id),
KEY idx_order (order_number),
KEY idx_post (post_id),
KEY idx_buyer (buyer_id),
KEY idx_currency (currency),
KEY idx_status (status),
KEY idx_created (created_at)
) {$charset_collate};";
+43 -156
View File
@@ -9,164 +9,51 @@ defined('ABSPATH') || exit;
class CreatorRevenue {
private static ?CreatorRevenue $instance = null;
private static ?CreatorRevenue $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
add_action('aksrt_order_paid', [$this, 'handle_order_paid'], 20, 2);
}
/**
* Whether creator revenue settlement is enabled.
*/
private function is_enabled(): bool {
if (!class_exists('AKSRT\\Payment\\Revenue') || !class_exists('\AKSRT\Settings\Settings')) {
return false;
}
$settings = \AKSRT\Settings\Settings::get_payment_revenue_settings();
return !empty($settings['enabled']);
}
/**
* Resolve share ratio (percentage 0-100) for a given product author.
*/
private function resolve_share_ratio(int $author_id): float {
$settings = \AKSRT\Settings\Settings::get_payment_revenue_settings();
$ratio = (float) ($settings['share_ratio'] ?? 0);
// VIP-tier author overrides, if configured (>0 wins).
if ($author_id > 0 && function_exists('aksrt_get_user_vip_level')) {
$level = (int) aksrt_get_user_vip_level($author_id);
if ($level === 1 && (float) ($settings['ratio_vip1'] ?? 0) > 0) {
$ratio = (float) $settings['ratio_vip1'];
} elseif ($level >= 2 && (float) ($settings['ratio_vip2'] ?? 0) > 0) {
$ratio = (float) $settings['ratio_vip2'];
}
}
return max(0.0, min(100.0, $ratio));
}
/**
* Guard: has this order already been settled into revenue?
*/
private function already_settled(string $order_number): bool {
if ($order_number === '' || !class_exists('\AKSRT\Core\DBMigrator')) {
return false;
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('revenue');
if ($table === '') {
return false;
}
$count = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE order_number = %s AND type = %s",
$order_number,
'order'
));
return $count > 0;
}
/**
* aksrt_order_paid listener: credit creators their share.
*
* @param string $order_number Paid order number.
* @param array $order Order row (status=paid).
*/
public function handle_order_paid(string $order_number, array $order): void {
if (!$this->is_enabled() || $order_number === '') {
return;
}
if ($this->already_settled($order_number)) {
return;
}
$buyer_id = (int) ($order['user_id'] ?? 0);
$this->settle_non_shop_cash_order($order_number, $order, $buyer_id);
}
/**
* Content / topic cash orders (no shop line items).
*/
private function settle_non_shop_cash_order(string $order_number, array $order, int $buyer_id): void {
$meta = $this->decode_note($order);
if (($meta['pay_modo'] ?? '') === 'points') {
return;
}
$payment_method = sanitize_key((string) ($order['payment_method'] ?? ''));
$currency = sanitize_key((string) ($order['currency'] ?? ''));
if ($payment_method === 'points' || $currency === 'points') {
return;
}
$base_amount = round((float) ($order['amount'] ?? $meta['payable'] ?? 0), 2);
if ($base_amount <= 0) {
return;
}
$product_type = sanitize_key((string) ($order['product_type'] ?? ''));
$product_id = (int) ($order['product_id'] ?? 0);
if ($product_id <= 0) {
return;
}
$post = get_post($product_id);
if (!($post instanceof \WP_Post)) {
return;
}
$is_content = !empty($meta['content_pay'])
&& $post->post_type === 'post';
// Resource without content_pay and not a known cash kind: skip.
if (!$is_content) {
return;
}
$author_id = (int) $post->post_author;
if ($author_id <= 0 || $author_id === $buyer_id) {
return;
}
$ratio = $this->resolve_share_ratio($author_id);
if ($ratio <= 0) {
return;
}
$amount = round($base_amount * $ratio / 100, 2);
if ($amount <= 0) {
return;
}
// Re-check after shop-less path; shop path already returned.
if ($this->already_settled($order_number)) {
return;
}
Revenue::get_instance()->add($author_id, $amount, $order_number, 'order');
}
/**
* @param array<string,mixed> $order
* @return array<string,mixed>
*/
private function decode_note(array $order): array {
if (isset($order['meta_payload']) && is_array($order['meta_payload'])) {
return $order['meta_payload'];
}
$note = (string) ($order['note'] ?? '');
if ($note === '') {
return [];
}
$decoded = json_decode($note, true);
return is_array($decoded) ? $decoded : [];
public function init(): void {
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));
}
}
/**
* Create creator income after content entitlement has been delivered.
*
* @param array<string,mixed> $order
*/
public function create_from_paid_order(array $order): int {
if (!$this->is_enabled() || !class_exists('AKSRT\\Payment\\Revenue')) {
return 0;
}
return Revenue::get_instance()->create_from_paid_order($order);
}
private function is_enabled(): bool {
if (!class_exists('AKSRT\\Settings\\Settings')) {
return false;
}
return !empty(\AKSRT\Settings\Settings::get_payment_revenue_settings()['enabled']);
}
}
+1
View File
@@ -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) {
+436 -115
View File
@@ -12,7 +12,9 @@ class Revenue {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) self::$instance = new self();
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
@@ -21,27 +23,90 @@ class Revenue {
}
/**
* 记录收益
* Create one pending creator-income record from a completed paid order.
*
* @param array<string,mixed> $order
*/
public function add(int $user_id, float $amount, string $order_number = '', string $type = 'income'): int {
public function create_from_paid_order(array $order): int {
global $wpdb;
if ($user_id <= 0 || $amount <= 0) return 0;
$order_number = sanitize_text_field((string) ($order['order_number'] ?? ''));
if ($order_number === '' || (string) ($order['status'] ?? '') !== 'completed') {
return 0;
}
$wpdb->insert($this->table(), [
'user_id' => $user_id,
'order_number' => sanitize_text_field($order_number),
'amount' => $amount,
'type' => sanitize_key($type),
'status' => 'pending',
'created_at' => current_time('mysql'),
], ['%d', '%s', '%f', '%s', '%s', '%s']);
$meta = $this->decode_order_meta($order);
if (empty($meta['content_pay'])) {
return 0;
}
$id = (int) $wpdb->insert_id;
$post_id = (int) ($order['product_id'] ?? $meta['post_id'] ?? 0);
$buyer_id = (int) ($order['user_id'] ?? 0);
$post = $post_id > 0 ? get_post($post_id) : null;
if (!($post instanceof \WP_Post) || $post->post_type !== 'post') {
return 0;
}
$author_id = (int) $post->post_author;
if ($author_id <= 0 || $author_id === $buyer_id) {
return 0;
}
$currency = sanitize_key((string) ($order['currency'] ?? 'CNY'));
$is_points = $currency === 'points' || ($meta['pay_modo'] ?? '') === 'points';
$currency = $is_points ? 'points' : 'CNY';
$original_amount = round((float) ($order['amount'] ?? $meta['amount'] ?? $meta['payable'] ?? 0), 2);
if ($is_points) {
$original_amount = (float) ceil($original_amount);
}
if ($original_amount <= 0) {
return 0;
}
$author_level = function_exists('aksrt_get_user_vip_level')
? max(0, (int) aksrt_get_user_vip_level($author_id))
: 0;
$ratio = $this->resolve_share_ratio($author_level);
if ($ratio <= 0) {
return 0;
}
$amount = $is_points
? (float) max(1, (int) floor($original_amount * $ratio / 100))
: round($original_amount * $ratio / 100, 2);
if ($amount <= 0) {
return 0;
}
$inserted = $wpdb->insert($this->table(), [
'author_id' => $author_id,
'order_number' => $order_number,
'post_id' => $post_id,
'buyer_id' => $buyer_id,
'currency' => $currency,
'original_amount' => $original_amount,
'share_ratio' => $ratio,
'author_level' => $author_level,
'amount' => $amount,
'type' => 'order',
'status' => 'pending',
'created_at' => current_time('mysql'),
], ['%d', '%s', '%d', '%d', '%s', '%f', '%f', '%d', '%f', '%s', '%s', '%s']);
if ($inserted === false) {
// Duplicate callbacks are a successful no-op by contract.
$existing = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$this->table()} WHERE author_id = %d AND order_number = %s LIMIT 1",
$author_id,
$order_number
));
return (int) $existing;
}
$id = (int) $wpdb->insert_id;
if ($id > 0) {
do_action('aksrt_creator_income_added', $user_id, $amount, [
do_action('aksrt_creator_income_added', $author_id, $amount, [
'order_number' => $order_number,
'type' => $type,
'currency' => $currency,
'revenue_id' => $id,
]);
}
@@ -49,51 +114,169 @@ class Revenue {
}
/**
* 结算收益
* Legacy generic insertion retained as the native income page entry point.
*/
public function settle(int $revenue_id): bool {
public function add(int $user_id, float $amount, string $order_number = '', string $type = 'income'): int {
global $wpdb;
if ($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;
}
if (!class_exists('\AKSRT\Credits\Balance')) {
/**
* 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): 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 {
$revenue = $wpdb->get_row($wpdb->prepare(
"SELECT id, user_id, amount, order_number FROM {$this->table()} WHERE id = %d AND status = %s FOR UPDATE",
$revenue_id,
'pending'
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE order_number = %s FOR UPDATE",
$order_number
), ARRAY_A);
if (!is_array($revenue)) {
$wpdb->query('ROLLBACK');
return false;
if (!is_array($rows) || !$rows) {
$wpdb->query('COMMIT');
return true;
}
$credited = \AKSRT\Credits\Balance::add(
(int) $revenue['user_id'],
(float) $revenue['amount'],
'creator_income',
['type' => 'revenue', 'id' => $revenue_id],
sprintf(__('创作收益结算(订单 %s)', 'aksrt'), (string) $revenue['order_number'])
);
if (is_wp_error($credited)) {
$wpdb->query('ROLLBACK');
return false;
}
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;
}
$settled = $wpdb->update(
$this->table(),
['status' => 'settled', 'settled_at' => current_time('mysql')],
['id' => $revenue_id, 'status' => 'pending'],
['%s', '%s'],
['%d', '%s']
);
if ($settled !== 1) {
$wpdb->query('ROLLBACK');
return false;
$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) {
@@ -102,106 +285,244 @@ class Revenue {
}
}
/**
* 获取用户收益列表
*/
public function get_user_revenues(int $user_id, array $args = []): array {
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;
$where = ['user_id = %d'];
$params = [$user_id];
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;
}
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$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));
$sql = "SELECT * FROM {$this->table()} WHERE " . implode(' AND ', $where) . " ORDER BY created_at DESC LIMIT %d OFFSET %d";
$params[] = $limit;
$params[] = $offset;
return (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
return (array) $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE " . implode(' AND ', $where) . " ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d",
$params
), ARRAY_A);
}
/**
* Admin list (all users) with optional status / user filter.
* Return current-author income statistics and paginated records.
*
* @param array{status?:string,user_id?:int,limit?:int,offset?:int} $args
* @return array{items:array<int,array>,total:int,page:int,max_pages:int}
* @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)));
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$status = sanitize_key((string) ($args['status'] ?? ''));
$user_id = (int) ($args['user_id'] ?? 0);
$where = [];
$where = [];
$params = [];
if ($status !== '') {
$where[] = 'status = %s';
if (($status = sanitize_key((string) ($args['status'] ?? ''))) !== '') {
$where[] = 'status = %s';
$params[] = $status;
}
if ($user_id > 0) {
$where[] = 'user_id = %d';
if (($user_id = (int) ($args['user_id'] ?? 0)) > 0) {
$where[] = 'author_id = %d';
$params[] = $user_id;
}
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) : '';
$count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
$total = $params
? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params))
: (int) $wpdb->get_var($count_sql);
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
$params[] = $limit;
$params[] = $offset;
$items = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
return [
'items' => $items ?: [],
'total' => $total,
'page' => (int) floor($offset / $limit) + 1,
'max_pages' => (int) ceil(max(1, $total) / $limit),
];
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$this->table()} {$where_sql}", $params)) : $wpdb->get_var("SELECT COUNT(*) FROM {$this->table()}"));
$items_params = array_merge($params, [$limit, $offset]);
$items = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d", $items_params), ARRAY_A);
return ['items' => $items ?: [], 'total' => $total, 'page' => (int) floor($offset / $limit) + 1, 'max_pages' => (int) ceil(max(1, $total) / $limit)];
}
/**
* 获取收益统计
*/
public function get_stats(int $user_id = 0): array {
global $wpdb;
$where = $user_id > 0 ? 'WHERE author_id = %d' : '';
$params = $user_id > 0 ? [$user_id] : [];
$row = $params ? $wpdb->get_row($wpdb->prepare("SELECT COALESCE(SUM(amount),0) total, COALESCE(SUM(CASE WHEN status='pending' THEN amount ELSE 0 END),0) pending, COALESCE(SUM(CASE WHEN status='settled' THEN amount ELSE 0 END),0) settled, COUNT(*) count FROM {$this->table()} {$where}", $params), ARRAY_A) : $wpdb->get_row("SELECT COALESCE(SUM(amount),0) total, COALESCE(SUM(CASE WHEN status='pending' THEN amount ELSE 0 END),0) pending, COALESCE(SUM(CASE WHEN status='settled' THEN amount ELSE 0 END),0) settled, COUNT(*) count FROM {$this->table()}", ARRAY_A);
return ['total' => (float) ($row['total'] ?? 0), 'pending' => (float) ($row['pending'] ?? 0), 'settled' => (float) ($row['settled'] ?? 0), 'count' => (int) ($row['count'] ?? 0)];
}
$where = '';
$params = [];
if ($user_id > 0) {
$where = 'WHERE user_id = %d';
$params[] = $user_id;
private function resolve_share_ratio(int $author_level): float {
$settings = \AKSRT\Settings\Settings::get_payment_revenue_settings();
$ratio = $author_level >= 2 ? 80.0 : ($author_level === 1 ? 70.0 : 60.0);
$key = $author_level >= 2 ? 'ratio_vip2' : ($author_level === 1 ? 'ratio_vip1' : 'share_ratio');
if (isset($settings[$key]) && is_numeric($settings[$key])) {
$ratio = (float) $settings[$key];
}
return max(0, min(100, $ratio));
}
$sql = "SELECT
COALESCE(SUM(amount), 0) as total,
COALESCE(SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END), 0) as pending,
COALESCE(SUM(CASE WHEN status = 'settled' THEN amount ELSE 0 END), 0) as settled,
COUNT(*) as count
FROM {$this->table()} {$where}";
if (!empty($params)) {
$row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A);
} else {
$row = $wpdb->get_row($sql, ARRAY_A);
private function decode_order_meta(array $order): array {
if (is_array($order['meta_payload'] ?? null)) {
return $order['meta_payload'];
}
return [
'total' => (float) ($row['total'] ?? 0),
'pending' => (float) ($row['pending'] ?? 0),
'settled' => (float) ($row['settled'] ?? 0),
'count' => (int) ($row['count'] ?? 0),
];
$decoded = json_decode((string) ($order['note'] ?? ''), true);
return is_array($decoded) ? $decoded : [];
}
}
+9 -3
View File
@@ -242,14 +242,20 @@ 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']),
'share_ratio' => max(0, min(100, (int) ($settings['payment_revenue_share_ratio'] ?? 70))),
'ratio_vip1' => max(0, min(100, (int) ($settings['ratio_vip_1'] ?? 0))),
'ratio_vip2' => max(0, min(100, (int) ($settings['ratio_vip_2'] ?? 0))),
'share_ratio' => max(0, min(100, (int) ($settings['payment_revenue_share_ratio'] ?? 60))),
'ratio_vip1' => max(0, min(100, (int) ($settings['ratio_vip_1'] ?? 70))),
'ratio_vip2' => max(0, min(100, (int) ($settings['ratio_vip_2'] ?? 80))),
'settlement_threshold' => max(0, (int) ($settings['payment_revenue_settlement_threshold'] ?? 100)),
'cycle_days' => max(1, min(365, (int) ($settings['payment_revenue_cycle_days'] ?? 30))),
'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
View File
@@ -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)) {