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:
kate522 committed 2026-08-07 11:03:30 +08:00
commit 2d5a975bbe
17 files changed
+721 -389

No files matched your search

+11 -11
View File
@@ -28,12 +28,12 @@
### Done when ### Done when
- [ ] `src/Core/DBMigratorPart2.php` 或最终负责收益表 schema 的文件包含作者、订单、文章、购买者、货币、原始支付金额、比例快照、会员等级、作者收益金额、状态和时间字段,并同步提升 schema 版本。 - [x] `src/Core/DBMigratorPart2.php` 或最终负责收益表 schema 的文件包含作者、订单、文章、购买者、货币、原始支付金额、比例快照、会员等级、作者收益金额、状态和时间字段,并同步提升 schema 版本。
- [ ] `src/Payment/Revenue.php` 或拆分后的作者收益服务实现 `create_from_paid_order(order)`,且同一订单重复调用只保留一条作者收益记录。 - [x] `src/Payment/Revenue.php` 或拆分后的作者收益服务实现 `create_from_paid_order(order)`,且同一订单重复调用只保留一条作者收益记录。
- [ ] 现金订单仅创建现金收益记录,积分订单仅创建积分收益记录;作者购买自己的付费文章不会创建收益记录。 - [x] 现金订单仅创建现金收益记录,积分订单仅创建积分收益记录;作者购买自己的付费文章不会创建收益记录。
- [ ] 普通用户、一级会员、二级会员分别按 60%、70%、80% 计算,并把生成时比例保存到收益记录。 - [x] 普通用户、一级会员、二级会员分别按 60%、70%、80% 计算,并把生成时比例保存到收益记录。
- [ ] 支付成功但权益未完成时不创建收益;权益完成后的支付流程能够创建待结算记录。 - [x] 支付成功但权益未完成时不创建收益;权益完成后的支付流程能够创建待结算记录。
- [ ] 创作分成关闭时,收益生成入口被跳过;相关收益服务测试覆盖上述场景。 - [x] 创作分成关闭时,收益生成入口被跳过;相关收益服务测试覆盖上述场景。
--- ---
@@ -47,11 +47,11 @@
### Done when ### Done when
- [ ] `src/Payment/Revenue.php` 或拆分后的结算服务实现 `settle(record_ids, actor)`,现金和积分分别调用对应原子余额服务并写入对应流水。 - [x] `src/Payment/Revenue.php` 或拆分后的结算服务实现 `settle(record_ids, actor)`,现金和积分分别调用对应原子余额服务并写入对应流水。
- [ ] `settlement scheduler` 仅在自动模式下处理达到配置延迟时间的待结算记录,默认延迟 24 小时;手动模式下定时任务不入账。 - [x] `settlement scheduler` 仅在自动模式下处理达到配置延迟时间的待结算记录,默认延迟 24 小时;手动模式下定时任务不入账。
- [ ] 同一收益被重复结算、并发结算或重复定时扫描时,只能成功入账一次。 - [x] 同一收益被重复结算、并发结算或重复定时扫描时,只能成功入账一次。
- [ ] `Refund reversal handler` 实现 `cancel_or_reverse(order_id, reason)`:未入钱包记录变为已取消/退款,已入钱包记录按货币执行冲正。 - [x] `Refund reversal handler` 实现 `cancel_or_reverse(order_id, reason)`:未入钱包记录变为已取消/退款,已入钱包记录按货币执行冲正。
- [ ] 冲正时余额不足不会产生负余额,收益记录转为待追缴,并保存应冲正金额、错误信息和审计信息。 - [x] 冲正时余额不足不会产生负余额,收益记录转为待追缴,并保存应冲正金额、错误信息和审计信息。
- [ ] 重复退款通知和重复冲正请求保持幂等;服务测试覆盖自动结算、手动结算、退款前结算、退款后结算和余额不足场景。 - [ ] 重复退款通知和重复冲正请求保持幂等;服务测试覆盖自动结算、手动结算、退款前结算、退款后结算和余额不足场景。
--- ---
@@ -10,6 +10,8 @@ export const revenueSchema = {
ratio_vip_2: 0, ratio_vip_2: 0,
payment_revenue_settlement_threshold: 100, payment_revenue_settlement_threshold: 100,
payment_revenue_cycle_days: 30, payment_revenue_cycle_days: 30,
payment_revenue_settlement_mode: 'automatic',
payment_revenue_settlement_delay_hours: 24,
payment_revenue_points_rule: '', payment_revenue_points_rule: '',
payment_revenue_note: '分成规则已就绪,订单入账后自动按比例结算。', payment_revenue_note: '分成规则已就绪,订单入账后自动按比例结算。',
pay_income_desc_details: '', 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: '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_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_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_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 } }, { 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; 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 { 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); } .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')); if (!restUrl) return Promise.reject(new Error('rest-unavailable'));
target.classList.add('is-loading'); target.classList.add('is-loading');
var url = restUrl.replace(/\/$/, '') + '/user-center/panel/' + encodeURIComponent(name); 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, { target._aksrtPanelRequest = fetch(url, {
credentials: 'same-origin', credentials: 'same-origin',
headers: { 'X-WP-Nonce': (window.aksrtData && (window.aksrtData.restNonce || window.aksrtData.nonce)) || '' } 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') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
@@ -736,7 +712,6 @@
initProfileForm(); initProfileForm();
initAuthForm(); initAuthForm();
initUnfavorite(); initUnfavorite();
initCreatorPostActions();
initNotifications(); initNotifications();
initSettings(); initSettings();
}); });
@@ -745,8 +720,7 @@
initProfileForm(); initProfileForm();
initAuthForm(); initAuthForm();
initUnfavorite(); initUnfavorite();
initCreatorPostActions(); initNotifications();
initNotifications();
initSettings(); initSettings();
} }
+1 -1
View File
@@ -111,7 +111,7 @@ add_filter('query_vars', function ($vars) {
// 主题切换离开时:清理本主题注册的 cron 任务 // 主题切换离开时:清理本主题注册的 cron 任务
add_action('switch_theme', function () { 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) { foreach ($hooks as $hook) {
$next = wp_next_scheduled($hook); $next = wp_next_scheduled($hook);
while ($next) { while ($next) {
@@ -1,6 +1,6 @@
<?php <?php
/** /**
* User center creator workspace. * User center creator income panel.
* *
* @package AKSRT * @package AKSRT
*/ */
@@ -8,58 +8,40 @@
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
$context = is_array($args['context'] ?? null) ? $args['context'] : []; $context = is_array($args['context'] ?? null) ? $args['context'] : [];
extract($context, EXTR_SKIP); // phpcs:ignore WordPress.PHP.DontExtract.extract_extract $user_id = (int) ($context['user_id'] ?? get_current_user_id());
if ($user_id <= 0) {
$can_submit = class_exists('\\AKSRT\\Core\\FrontSubmit') echo '<div class="uc-panel-empty muted-2-color">' . esc_html__('请先登录', 'aksrt') . '</div>';
&& \AKSRT\Core\FrontSubmit::get_instance()->can_submit((int) $user_id); return;
$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';
} }
$creator_url = aksrt_get_user_center_tab_url('creator'); $settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_payment_revenue_settings() : [];
$filters = [ if (empty($settings['enabled'])) {
'all' => __('全部文章', 'aksrt'), echo '<div class="uc-panel-empty muted-2-color">' . esc_html__('创作收益功能暂未开启。', 'aksrt') . '</div>';
'publish' => __('已发布', 'aksrt'), return;
'draft' => __('草稿', 'aksrt'), }
'pending' => __('待审核', 'aksrt'), $request = is_array($context['creator_income_request'] ?? null) ? $context['creator_income_request'] : [];
'private' => __('私密', 'aksrt'), $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"> <div class="creator-income" data-creator-income>
<header class="creator-workspace__hero"> <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>
<div> <nav class="creator-income__tabs" aria-label="<?php esc_attr_e('收益货币筛选', 'aksrt'); ?>">
<p class="creator-workspace__eyebrow"><?php esc_html_e('内容工作台', 'aksrt'); ?></p> <?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; ?>
<h2 class="creator-workspace__title"><?php esc_html_e('创作中心', 'aksrt'); ?></h2> </nav>
<p class="creator-workspace__intro"><?php esc_html_e('管理你的内容,查看发布进度,持续分享有价值的文章。', 'aksrt'); ?></p> <section class="creator-income__cards" aria-label="<?php esc_attr_e('收益概览', 'aksrt'); ?>">
</div> <?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; ?>
<?php if ($can_submit && !empty($new_post_url)) : ?> </section>
<a class="creator-workspace__primary btn-primary" href="<?php echo esc_url($new_post_url); ?>"> <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>
<i class="fas fa-plus" aria-hidden="true"></i><?php esc_html_e('写新文章', 'aksrt'); ?> <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>
</a> <?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; ?>
<?php endif; ?> </section>
</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> </div>
+53 -7
View File
@@ -26,6 +26,7 @@ class PayIncome {
return; return;
} }
add_action('admin_post_aksrt_pay_settle_income', [$this, 'handle_settle_income']); 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 { public function render_income_page(): void {
@@ -40,6 +41,11 @@ class PayIncome {
$status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : ''; $status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : '';
$user_id = isset($_GET['user_id']) ? max(0, (int) $_GET['user_id']) : 0; $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)); $page_num = max(1, (int) ($_GET['paged'] ?? 1));
$per_page = 20; $per_page = 20;
$svc = \AKSRT\Payment\Revenue::get_instance(); $svc = \AKSRT\Payment\Revenue::get_instance();
@@ -53,14 +59,24 @@ class PayIncome {
number_format((float) ($stats['settled'] ?? 0), 2) number_format((float) ($stats['settled'] ?? 0), 2)
)) . '</p>'; )) . '</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 '<form method="get" style="margin:12px 0;">';
echo '<input type="hidden" name="page" value="aksrt_income" />'; echo '<input type="hidden" name="page" value="aksrt_income" />';
echo '<select name="status">'; 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)); printf('<option value="%s"%s>%s</option>', esc_attr($key), selected($status, $key, false), esc_html($label));
} }
echo '</select> '; 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="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); submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
if ($user_id > 0) { if ($user_id > 0) {
echo ' <a class="button button-small" href="' . esc_url($this->page_url('aksrt_income')) . '">' . esc_html__('清除用户过滤', 'aksrt') . '</a>'; 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, 'status' => $status,
'limit' => $per_page, 'limit' => $per_page,
'offset' => ($page_num - 1) * $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) { if ($user_id > 0) {
$list_args['user_id'] = $user_id; $list_args['user_id'] = $user_id;
@@ -81,19 +102,19 @@ class PayIncome {
$pages = max(1, (int) ($result['max_pages'] ?? 1)); $pages = max(1, (int) ($result['max_pages'] ?? 1));
echo '<table class="wp-list-table widefat fixed striped"><thead><tr>'; 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>'; echo '</tr></thead><tbody>';
if (!$items) { 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) { foreach ($items as $row) {
$id = (int) ($row['id'] ?? 0); $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; $user = $user_id > 0 ? get_userdata($user_id) : null;
$st = (string) ($row['status'] ?? ''); $st = (string) ($row['status'] ?? '');
echo '<tr>'; echo '<tr>';
echo '<td>' . esc_html((string) $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>' . ($user ? esc_html($user->display_name) : esc_html((string) $user_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><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(number_format((float) ($row['amount'] ?? 0), 2)) . '</td>';
echo '<td>' . esc_html($st) . '</td>'; echo '<td>' . esc_html($st) . '</td>';
@@ -122,13 +143,37 @@ class PayIncome {
} }
$id = isset($_POST['revenue_id']) ? (int) $_POST['revenue_id'] : 0; $id = isset($_POST['revenue_id']) ? (int) $_POST['revenue_id'] : 0;
check_admin_referer('aksrt_pay_settle_income_' . $id); 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'])); wp_safe_redirect($this->page_url('aksrt_income', ['aksrt_pay_msg' => $ok ? 'income_settled' : 'income_failed']));
exit; 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 { private function ensure_cap(): void {
if (!current_user_can(self::CAPABILITY)) { if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('您没有权限访问此页面。', 'aksrt')); wp_die(esc_html__('您没有权限访问此页面。', 'aksrt'));
@@ -152,6 +197,7 @@ class PayIncome {
$code = sanitize_key((string) wp_unslash($_GET['aksrt_pay_msg'])); $code = sanitize_key((string) wp_unslash($_GET['aksrt_pay_msg']));
$map = [ $map = [
'income_settled' => __('分成已结算。', 'aksrt'), 'income_settled' => __('分成已结算。', 'aksrt'),
'income_batch_settled' => __('选中分成已批量结算。', 'aksrt'),
'income_failed' => __('分成结算失败。', 'aksrt'), 'income_failed' => __('分成结算失败。', 'aksrt'),
]; ];
if (!isset($map[$code])) { 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', [ register_rest_route(self::API_NAMESPACE, '/payment/withdrawals', [
'methods' => 'GET', 'methods' => 'GET',
'callback' => [$this, 'get_withdrawals'], '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 { public function get_withdrawals(\WP_REST_Request $request): \WP_REST_Response {
if (!class_exists('\AKSRT\Payment\Withdrawal')) { if (!class_exists('\AKSRT\Payment\Withdrawal')) {
return $this->success(['data' => ['items' => [], 'total' => 0, 'page' => 1, 'total_pages' => 1, 'stats' => []]]); 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' => '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_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_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_points_rule', 'label' => '积分规则', 'type' => 'textarea', 'default' => ''],
['key' => 'payment_revenue_note', '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). // Consume coupon + write affiliate commission (cash content only; idempotent).
$this->finalize_content_order_side_effects($order_number, $paid_order); $this->finalize_content_order_side_effects($order_number, $paid_order);
if (class_exists('\AKSRT\Payment\OrderRepository')) { if (class_exists('\AKSRT\Payment\OrderRepository')) {
\AKSRT\Payment\OrderRepository::get_instance()->update_status( $completed = \AKSRT\Payment\OrderRepository::get_instance()->update_status(
$order_number, $order_number,
\AKSRT\Payment\Order::STATUS_COMPLETED, \AKSRT\Payment\Order::STATUS_COMPLETED,
['note' => __('内容权益已发放。', 'aksrt')] ['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; use DBMigratorIdentity;
/** Current schema version; changing it re-runs dbDelta. */ /** 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'; const OPT_KEY = 'aksrt_db_schema_version';
+19 -5
View File
@@ -187,19 +187,33 @@ trait DBMigratorPart2 {
) {$charset_collate};"; ) {$charset_collate};";
dbDelta($sql_order); dbDelta($sql_order);
// ---------- 9. 收益台账 ---------- // ---------- 9. 作者收益台账 ----------
$sql_revenue = "CREATE TABLE {$prefix}aksrt_revenue ( $sql_revenue = "CREATE TABLE {$prefix}aksrt_revenue (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, 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 '', order_number VARCHAR(32) NOT NULL DEFAULT '',
amount DECIMAL(10,2) NOT NULL DEFAULT 0, post_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
type VARCHAR(32) NOT NULL DEFAULT 'income', 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', 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, created_at DATETIME NOT NULL,
settled_at DATETIME DEFAULT NULL, settled_at DATETIME DEFAULT NULL,
reversed_at DATETIME DEFAULT NULL,
PRIMARY KEY (id), 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_order (order_number),
KEY idx_post (post_id),
KEY idx_buyer (buyer_id),
KEY idx_currency (currency),
KEY idx_status (status), KEY idx_status (status),
KEY idx_created (created_at) KEY idx_created (created_at)
) {$charset_collate};"; ) {$charset_collate};";
+43 -156
View File
@@ -9,164 +9,51 @@ defined('ABSPATH') || exit;
class CreatorRevenue { class CreatorRevenue {
private static ?CreatorRevenue $instance = null; private static ?CreatorRevenue $instance = null;
public static function get_instance(): self { public static function get_instance(): self {
if (null === self::$instance) { if (null === self::$instance) {
self::$instance = new self(); self::$instance = new self();
}
return self::$instance;
} }
return self::$instance;
}
public function init(): void { public function init(): void {
add_action('aksrt_order_paid', [$this, 'handle_order_paid'], 20, 2); 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');
* 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 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) { if ($result > 0) {
do_action('aksrt_order_status_changed', $order_number, $new_status, array_merge($order, $update_data));
// 到账事件:让充值/VIP/商品履约等挂接(单一履约入口)。 // 到账事件:让充值/VIP/商品履约等挂接(单一履约入口)。
// 仅在「首次」转为 paid 时触发,避免回调多次到达重复履约。 // 仅在「首次」转为 paid 时触发,避免回调多次到达重复履约。
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID && $current_status !== \AKSRT\Core\DomainModel::ORDER_STATUS_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; private static $instance = null;
public static function get_instance(): self { 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; 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; 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(), [ $meta = $this->decode_order_meta($order);
'user_id' => $user_id, if (empty($meta['content_pay'])) {
'order_number' => sanitize_text_field($order_number), return 0;
'amount' => $amount, }
'type' => sanitize_key($type),
'status' => 'pending',
'created_at' => current_time('mysql'),
], ['%d', '%s', '%f', '%s', '%s', '%s']);
$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) { 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, 'order_number' => $order_number,
'type' => $type, 'currency' => $currency,
'revenue_id' => $id, '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; 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; 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'); $wpdb->query('START TRANSACTION');
try { try {
$revenue = $wpdb->get_row($wpdb->prepare( $rows = $wpdb->get_results($wpdb->prepare(
"SELECT id, user_id, amount, order_number FROM {$this->table()} WHERE id = %d AND status = %s FOR UPDATE", "SELECT * FROM {$this->table()} WHERE order_number = %s FOR UPDATE",
$revenue_id, $order_number
'pending'
), ARRAY_A); ), ARRAY_A);
if (!is_array($revenue)) { if (!is_array($rows) || !$rows) {
$wpdb->query('ROLLBACK'); $wpdb->query('COMMIT');
return false; return true;
} }
$credited = \AKSRT\Credits\Balance::add( foreach ($rows as $row) {
(int) $revenue['user_id'], $status = (string) ($row['status'] ?? '');
(float) $revenue['amount'], $id = (int) ($row['id'] ?? 0);
'creator_income', if (in_array($status, ['cancelled', 'reversed'], true)) {
['type' => 'revenue', 'id' => $revenue_id], continue;
sprintf(__('创作收益结算(订单 %s)', 'aksrt'), (string) $revenue['order_number']) }
); if ($status === 'pending') {
if (is_wp_error($credited)) { $updated = $wpdb->query($wpdb->prepare(
$wpdb->query('ROLLBACK'); "UPDATE {$this->table()} SET status = %s, error_message = %s, audit_json = %s WHERE id = %d AND status = %s",
return false; '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( $amount = round((float) ($row['amount'] ?? 0), 2);
$this->table(), $user_id = (int) ($row['author_id'] ?? 0);
['status' => 'settled', 'settled_at' => current_time('mysql')], $ref = ['type' => 'creator_income_reversal', 'id' => $id];
['id' => $revenue_id, 'status' => 'pending'], $debited = (string) ($row['currency'] ?? '') === 'points'
['%s', '%s'], ? (class_exists('\\AKSRT\\Credits\\Credits') ? \AKSRT\Credits\Credits::deduct($user_id, (int) ceil($amount), 'creator_income_reversal', $ref, $reason) : new \WP_Error('credits_unavailable'))
['%d', '%s'] : (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)) {
if ($settled !== 1) { $updated = $wpdb->query($wpdb->prepare(
$wpdb->query('ROLLBACK'); "UPDATE {$this->table()} SET status = %s, reversal_amount = %f, error_message = %s, audit_json = %s WHERE id = %d AND status = %s",
return false; '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'); $wpdb->query('COMMIT');
return true; return true;
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -102,106 +285,244 @@ class Revenue {
} }
} }
/** private function settle_one(int $revenue_id, int $actor_id): bool {
* 获取用户收益列表 global $wpdb;
*/ $wpdb->query('START TRANSACTION');
public function get_user_revenues(int $user_id, array $args = []): array { 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; global $wpdb;
$where = ['user_id = %d']; if ($user_id <= 0 || (int) ($ref['id'] ?? 0) <= 0) {
$params = [$user_id]; 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'] ?? '')); $status = sanitize_key((string) ($args['status'] ?? ''));
if ($status !== '') { if ($status !== '') {
$where[] = 'status = %s'; $where[] = 'status = %s';
$params[] = $status; $params[] = $status;
} }
$currency = sanitize_key((string) ($args['currency'] ?? ''));
$limit = max(1, min(100, (int) ($args['limit'] ?? 20))); 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)); $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[] = $limit;
$params[] = $offset; $params[] = $offset;
return (array) $wpdb->get_results($wpdb->prepare(
return (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); "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{stats:array,items:array,total:int,max_pages:int,page:int,per_page:int}
* @return array{items:array<int,array>,total:int,page:int,max_pages: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 { public function get_list(array $args = []): array {
global $wpdb; 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)); $offset = max(0, (int) ($args['offset'] ?? 0));
$status = sanitize_key((string) ($args['status'] ?? '')); $where = [];
$user_id = (int) ($args['user_id'] ?? 0);
$where = [];
$params = []; $params = [];
if ($status !== '') { if (($status = sanitize_key((string) ($args['status'] ?? ''))) !== '') {
$where[] = 'status = %s'; $where[] = 'status = %s';
$params[] = $status; $params[] = $status;
} }
if ($user_id > 0) { if (($user_id = (int) ($args['user_id'] ?? 0)) > 0) {
$where[] = 'user_id = %d'; $where[] = 'author_id = %d';
$params[] = $user_id; $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) : ''; $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()}"));
$count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}"; $items_params = array_merge($params, [$limit, $offset]);
$total = $params $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);
? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)) return ['items' => $items ?: [], 'total' => $total, 'page' => (int) floor($offset / $limit) + 1, 'max_pages' => (int) ceil(max(1, $total) / $limit)];
: (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),
];
} }
/**
* 获取收益统计
*/
public function get_stats(int $user_id = 0): array { public function get_stats(int $user_id = 0): array {
global $wpdb; 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 = ''; private function resolve_share_ratio(int $author_level): float {
$params = []; $settings = \AKSRT\Settings\Settings::get_payment_revenue_settings();
if ($user_id > 0) { $ratio = $author_level >= 2 ? 80.0 : ($author_level === 1 ? 70.0 : 60.0);
$where = 'WHERE user_id = %d'; $key = $author_level >= 2 ? 'ratio_vip2' : ($author_level === 1 ? 'ratio_vip1' : 'share_ratio');
$params[] = $user_id; if (isset($settings[$key]) && is_numeric($settings[$key])) {
$ratio = (float) $settings[$key];
} }
return max(0, min(100, $ratio));
}
$sql = "SELECT private function decode_order_meta(array $order): array {
COALESCE(SUM(amount), 0) as total, if (is_array($order['meta_payload'] ?? null)) {
COALESCE(SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END), 0) as pending, return $order['meta_payload'];
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);
} }
$decoded = json_decode((string) ($order['note'] ?? ''), true);
return [ return is_array($decoded) ? $decoded : [];
'total' => (float) ($row['total'] ?? 0),
'pending' => (float) ($row['pending'] ?? 0),
'settled' => (float) ($row['settled'] ?? 0),
'count' => (int) ($row['count'] ?? 0),
];
} }
} }
+9 -3
View File
@@ -242,14 +242,20 @@ class PaymentRuntime {
public static function get_payment_revenue_settings(): array { public static function get_payment_revenue_settings(): array {
$settings = Settings::get_all(); $settings = Settings::get_all();
$note = trim((string) ($settings['payment_revenue_note'] ?? '')); $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 [ return [
'enabled' => !empty($settings['pay_income_s']), 'enabled' => !empty($settings['pay_income_s']),
'share_ratio' => max(0, min(100, (int) ($settings['payment_revenue_share_ratio'] ?? 70))), 'share_ratio' => max(0, min(100, (int) ($settings['payment_revenue_share_ratio'] ?? 60))),
'ratio_vip1' => max(0, min(100, (int) ($settings['ratio_vip_1'] ?? 0))), 'ratio_vip1' => max(0, min(100, (int) ($settings['ratio_vip_1'] ?? 70))),
'ratio_vip2' => max(0, min(100, (int) ($settings['ratio_vip_2'] ?? 0))), 'ratio_vip2' => max(0, min(100, (int) ($settings['ratio_vip_2'] ?? 80))),
'settlement_threshold' => max(0, (int) ($settings['payment_revenue_settlement_threshold'] ?? 100)), 'settlement_threshold' => max(0, (int) ($settings['payment_revenue_settlement_threshold'] ?? 100)),
'cycle_days' => max(1, min(365, (int) ($settings['payment_revenue_cycle_days'] ?? 30))), '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'] ?? '')), 'points_rule' => sanitize_textarea_field((string) ($settings['payment_revenue_points_rule'] ?? '')),
'note' => $note !== '' ? $note : __('分成策略和台账结构已就绪,订单入账后自动按比例结算。', 'aksrt'), 'note' => $note !== '' ? $note : __('分成策略和台账结构已就绪,订单入账后自动按比例结算。', 'aksrt'),
'display' => [ 'display' => [
+18 -1
View File
@@ -68,6 +68,17 @@ class Center {
return new \WP_Error('aksrt_user_center_context', __('用户中心数据加载失败', 'aksrt'), ['status' => 500]); 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 = []; $revenue_request = [];
if ($tab === 'revenue') { if ($tab === 'revenue') {
$reward_tab = sanitize_key((string) $request->get_param('reward_tab')); $reward_tab = sanitize_key((string) $request->get_param('reward_tab'));
@@ -95,6 +106,7 @@ class Center {
'tab' => $tab, 'tab' => $tab,
'html' => $html, 'html' => $html,
'reward_tab' => $revenue_request['reward_tab'] ?? '', 'reward_tab' => $revenue_request['reward_tab'] ?? '',
'creator_income' => $income_request,
'commission_page' => $revenue_request['commission_page'] ?? 1, 'commission_page' => $revenue_request['commission_page'] ?? 1,
'invite_page' => $revenue_request['invite_page'] ?? 1, 'invite_page' => $revenue_request['invite_page'] ?? 1,
], ],
@@ -210,6 +222,8 @@ class Center {
$revenue_tab_on = class_exists('\AKSRT\Settings\Settings') $revenue_tab_on = class_exists('\AKSRT\Settings\Settings')
&& !empty(\AKSRT\Settings\Settings::get_payment_affiliate_settings()['enabled']) && !empty(\AKSRT\Settings\Settings::get_payment_affiliate_settings()['enabled'])
&& \AKSRT\Settings\Settings::invite_registration_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') $vip_on = class_exists('\AKSRT\Settings\Settings')
&& !empty(\AKSRT\Settings\Settings::get_payment_vip_settings()['enabled']); && !empty(\AKSRT\Settings\Settings::get_payment_vip_settings()['enabled']);
@@ -217,10 +231,13 @@ class Center {
$order = $configured; $order = $configured;
// Creator center is a dedicated workspace, separate from the posts archive. // 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); $insert_at = array_search('profile', $order, true);
array_splice($order, $insert_at === false ? 0 : (int) $insert_at + 1, 0, ['creator']); 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. // Growth level is a dedicated user-center surface, separate from spendable points.
if ($level_on && !in_array('level', $order, true)) { if ($level_on && !in_array('level', $order, true)) {