feat(revenue): immediate commission credit with independent withdrawable pool

Aligns affiliate commission with the reference theme: commission is credited
to a dedicated COMMISSION asset pool the moment an order is paid, removing the
7-day settlement freeze, the settlement cron, and the settlement_days setting.

- Ledger: add AssetType::COMMISSION (isolated from the recharge balance wallet)
- CommissionLedger: credit-on-record + opportunistic debt repayment; refunds
  claw back the full commission from the pool, carrying any shortfall as debt
  (aksrt_commission_debt) that future commission pays down first
- Withdrawal: make asset-aware via a source discriminator (commission vs
  balance); each pool keeps independent daily limits, pending gate, and
  reject-refund. Balance withdrawal (recharge + creator income) is unchanged
- Fix pay_rebate_self_s: honor self-commission at purchase time
- Revenue card: show pool balance + 累计/已提现 (drop 待结算), debt when present
- Schema 2.0.0 -> 2.1.0: add commission_debt, drop freeze columns
This commit is contained in:
2026-09-28 21:27:44 +08:00
parent cded20ae02
commit 61667aa03c
22 changed files with 569 additions and 268 deletions
-1
View File
@@ -3,7 +3,6 @@
@import url("user-center/vip.css");
@import url("user-center/level.css");
@import url("user-center/settings.css");
@import url("revenue.css");
@import url("user-center/revenue.css");
@import url("user-center/revenue-earnings.css");
@import url("user-center/responsive.css");
@@ -27,12 +27,16 @@
}
.uc-content .uc-commission-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.uc-content .uc-commission-card {
position: relative;
min-height: 116px;
padding: 16px 22px;
overflow: hidden;
border: 0;
border-radius: 18px;
box-shadow: 0 12px 28px rgba(15, 23, 42, .08);
@@ -56,7 +60,10 @@
.uc-content .uc-commission-card__body {
display: grid;
position: relative;
z-index: 1;
align-content: space-between;
gap: 8px;
min-height: 84px;
}
@@ -69,29 +76,73 @@
.uc-content .uc-commission-card__body > span:first-child {
margin: 0;
font-size: 18px;
font-weight: 700;
font-size: 15px;
opacity: .85;
}
.uc-content .uc-commission-card__body strong {
font-size: 32px;
font-size: 34px;
font-weight: 700;
line-height: 1;
}
.uc-content .uc-commission-card__body strong small {
margin-left: 4px;
font-size: 15px;
font-weight: 400;
}
.uc-content .uc-commission-card--ratio .uc-commission-card__body strong small {
margin-left: 6px;
font-size: 20px;
}
.uc-content .uc-commission-card__body > span:last-child,
.uc-content .uc-commission-card__body > p {
margin: 0;
font-size: 14px;
opacity: .92;
font-size: 13px;
opacity: .9;
}
.uc-content .uc-commission-card--ratio .uc-commission-card__body strong {
color: inherit;
/* 我的佣金:累计 / 已提现(含欠款)副信息。 */
.uc-content .uc-commission-card__meta {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
}
/* 待偿欠款:退款收回不足挂账,副信息里以描边标签弱提示。 */
.uc-content .uc-commission-card__debt {
padding: 0 8px;
border-radius: 999px;
background: rgba(255, 255, 255, .22);
}
/* 立即提现按钮:贴右居中,对齐参考主题 .abs-right。 */
.uc-content .uc-commission-card__action {
position: absolute;
top: 50%;
right: 16px;
z-index: 2;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 7px 14px;
transform: translateY(-50%);
border: 0;
border-radius: 999px;
color: #fff;
background: rgba(255, 255, 255, .22);
font-size: 13px;
cursor: pointer;
}
.uc-content .uc-commission-card__action:hover {
background: rgba(255, 255, 255, .32);
}
/* 提现按钮所在卡片:正文右侧预留空间,避免与按钮重叠。 */
.uc-content .uc-commission-card--balance:has(.uc-commission-card__action) .uc-commission-card__body {
padding-right: 96px;
}
/* 三 Tab 推广收益:佣金详细页采用紧凑的一列信息清单。 */
@@ -85,6 +85,12 @@
gap: 16px;
}
/* 三个 Tab 面板首屏全部直出,靠 hidden 属性做本地切换;
* display:grid 会覆盖 [hidden] 的 display:none,必须显式复位。 */
.uc-reward-pane[hidden] {
display: none;
}
.uc-reward-pane--detail {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
+23 -5
View File
@@ -934,8 +934,24 @@
modal.dataset.aksrtWithdrawInitialized = '1';
var form = modal.querySelector('[data-aksrt-withdraw-form]');
var busy = false;
// 提现来源随打开按钮传入:commission 走佣金池,其余走余额钱包。
// 每个来源的每日次数/额度不同,按钮上带了各自的 min/remaining,
// 打开时覆盖到金额输入框,提交时把 source 一并回传。
document.querySelectorAll('[data-aksrt-withdraw-open]').forEach(function (btn) {
btn.addEventListener('click', function () { openModal(modal); });
btn.addEventListener('click', function () {
var src = btn.getAttribute('data-aksrt-withdraw-source') || 'balance';
modal.dataset.aksrtWithdrawSource = src === 'commission' ? 'commission' : 'balance';
var amountEl = form ? form.querySelector('[data-aksrt-withdraw-amount]') : null;
if (amountEl) {
var min = btn.getAttribute('data-withdraw-min');
var remAmt = btn.getAttribute('data-withdraw-remaining-amount');
var remCnt = btn.getAttribute('data-withdraw-remaining-count');
if (min !== null) amountEl.setAttribute('data-withdraw-min', min);
if (remAmt !== null) amountEl.setAttribute('data-withdraw-remaining-amount', remAmt);
if (remCnt !== null) amountEl.setAttribute('data-withdraw-remaining-count', remCnt);
}
openModal(modal);
});
});
modal.querySelectorAll('[data-aksrt-withdraw-close]').forEach(function (btn) {
btn.addEventListener('click', function () { closeModal(modal); });
@@ -978,10 +994,11 @@
}
busy = true;
notyf('正在提交提现申请…', 'load', '', 'aksrt-balance-withdraw-toast');
var source = modal.dataset.aksrtWithdrawSource === 'commission' ? 'commission' : 'balance';
api('/earnings/withdrawals', {
method: 'POST',
headers: { 'Idempotency-Key': operationKey(form) },
body: JSON.stringify({ amount: amount, method: method, account: account })
body: JSON.stringify({ amount: amount, method: method, account: account, source: source })
}).then(function (r) {
busy = false;
if (!r.ok || (r.json && r.json.code && !r.json.success)) {
@@ -994,11 +1011,12 @@
if (data && data.withdrawal_number) successMessage += '(' + data.withdrawal_number + ')';
notyf(successMessage, 'success', 5, 'aksrt-balance-withdraw-toast');
closeModal(modal);
var panel = modal.closest ? modal.closest('.dashboard-panel') : null;
panel = panel || document.getElementById('panel-credits');
// 佣金提现刷新推广收益面板,余额提现刷新余额面板;各自拉取对应片段。
var panelName = source === 'commission' ? 'revenue' : 'credits';
var panel = document.getElementById('panel-' + panelName) || (modal.closest ? modal.closest('.dashboard-panel') : null);
var rest = (window.aksrtData && (window.aksrtData.restUrl || window.aksrtData.apiUrl)) || (window.aksrtUserCenter && window.aksrtUserCenter.restUrl) || '';
if (panel && rest) {
fetch(rest.replace(/\/$/, '') + '/user-center/panel/credits', { credentials: 'same-origin', headers: { 'X-WP-Nonce': (window.aksrtData && (window.aksrtData.restNonce || window.aksrtData.nonce)) || '' } })
fetch(rest.replace(/\/$/, '') + '/user-center/panel/' + panelName, { credentials: 'same-origin', headers: { 'X-WP-Nonce': (window.aksrtData && (window.aksrtData.restNonce || window.aksrtData.nonce)) || '' } })
.then(function (response) { return response.json(); })
.then(function (payload) {
if (!payload || !payload.success || !payload.data || typeof payload.data.html !== 'string') return;
+27
View File
@@ -158,6 +158,26 @@
return true;
}
/* 推广收益三个 Tab 的内容在首屏已由 PHP 全部直出,切换只需本地切换
* 显隐,无需任何网络往返,消除切换阻塞。分页仍走 loadRevenueTab。 */
function toggleRevenueTab(tabName) {
var target = document.getElementById('panel-revenue');
if (!target) return false;
var pane = target.querySelector('[data-reward-pane="' + tabName + '"]');
if (!pane) return false;
target.querySelectorAll('[data-reward-pane]').forEach(function(node) {
var active = node === pane;
node.hidden = !active;
});
target.querySelectorAll('.uc-reward-tabs a[data-reward-tab]').forEach(function(link) {
var active = link.getAttribute('data-reward-tab') === tabName;
link.classList.toggle('is-active', active);
if (active) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current');
});
return true;
}
function loadRevenueTab(queryParams) {
var target = document.getElementById('panel-revenue');
var restUrl = (window.aksrtData && (window.aksrtData.restUrl || window.aksrtData.apiUrl)) || cfg.restUrl || '';
@@ -260,6 +280,13 @@
if (link.closest('.uc-reward-tabs, .uc-reward-pagination')) {
e.preventDefault();
e.stopPropagation();
var rewardTab = link.getAttribute('data-reward-tab');
/* Tab 切换:内容已全部直出,纯本地切换显隐,零网络往返。 */
if (rewardTab && toggleRevenueTab(rewardTab)) {
if (window.history.replaceState) window.history.replaceState({}, '', '/user/revenue/');
return;
}
/* 分页:需要新一页数据,仍走 REST 片段加载。 */
loadRevenueTab(url.searchParams).then(function() {
if (window.history.replaceState) window.history.replaceState({}, '', '/user/revenue/');
if (typeof window.AKSRTAffiliateRevenueInit === 'function') window.AKSRTAffiliateRevenueInit();
@@ -43,8 +43,12 @@ $withdraw_min = $normalize_balance($withdrawal['min_amount'] ?? '50')->decimal()
$commission_stats = class_exists('\AKSRT\Payment\CommissionLedger')
? \AKSRT\Payment\CommissionLedger::get_instance()->overview($user_id)
: ['total' => 0, 'pending' => 0, 'settled' => 0, 'wallet' => 0];
$pending_commission = $normalize_balance($commission_stats['pending'] ?? '0')->decimal();
: ['total' => 0, 'pool' => 0, 'withdrawn' => 0, 'debt' => 0];
/* 佣金池提现的每日次数/额度:与余额提现相互独立,按 commission source 计。 */
$commission_withdraw_usage = ['remaining_count' => 0, 'remaining_amount' => '0.00'];
if ($withdraw_on && class_exists('\AKSRT\Payment\Withdrawal')) {
$commission_withdraw_usage = \AKSRT\Payment\Withdrawal::get_instance()->daily_usage($user_id, $withdrawal, \AKSRT\Payment\Withdrawal::SOURCE_COMMISSION);
}
$commission_ratio = class_exists('\AKSRT\Settings\Settings')
? (int) \AKSRT\Settings\Settings::affiliate_ratio_for_user($user_id)
: 0;
@@ -109,11 +113,24 @@ if ($commission_page > $commission_total_pages) {
}
$commission_items = (array) ($commission_view['items'] ?? []);
$status_labels = [
'pending' => __('待结算', 'aksrt'),
'settled' => __('已结算', 'aksrt'),
'cancelled' => __('已取消', 'aksrt'),
'credited' => __('已入账', 'aksrt'),
'cancelled' => __('已退款', 'aksrt'),
];
?>
<?php
$pool_amount = $format_amount($commission_stats['pool'] ?? 0);
$total_amount = $format_amount($commission_stats['total'] ?? 0);
$withdrawn_amount = $format_amount($commission_stats['withdrawn'] ?? 0);
$debt_amount = $format_amount($commission_stats['debt'] ?? 0);
$reward_panes = ['detail'];
if ($affiliate_on) {
$reward_panes[] = 'orders';
}
$reward_panes[] = 'users';
$pane_is_active = static function (string $tab) use ($active_reward_tab): bool {
return $active_reward_tab === $tab;
};
?>
<div class="uc-revenue-panel" data-aksrt-revenue-page>
<?php if ($affiliate_on) : ?>
<section class="uc-commission-summary" aria-label="<?php esc_attr_e('佣金概览', 'aksrt'); ?>">
@@ -127,9 +144,16 @@ $status_labels = [
<article class="uc-commission-card uc-commission-card--balance">
<div class="uc-commission-card__body">
<span><?php esc_html_e('我的佣金', 'aksrt'); ?></span>
<strong><small>¥</small><?php echo esc_html($format_amount($pending_commission)); ?></strong>
<span><?php printf(esc_html__('累计佣金 ¥%s', 'aksrt'), esc_html($format_amount($commission_stats['total'] ?? 0))); ?></span>
<strong><small>¥</small><?php echo esc_html($pool_amount); ?></strong>
<p class="uc-commission-card__meta">
<span><?php printf(esc_html__('累计佣金 ¥%s', 'aksrt'), esc_html($total_amount)); ?></span>
<span><?php printf(esc_html__('已提现 ¥%s', 'aksrt'), esc_html($withdrawn_amount)); ?></span>
<?php if (($commission_stats['debt'] ?? '0.00') !== '0.00') : ?><span class="uc-commission-card__debt"><?php printf(esc_html__('待偿欠款 ¥%s', 'aksrt'), esc_html($debt_amount)); ?></span><?php endif; ?>
</p>
</div>
<?php if ($withdraw_on) : ?>
<button type="button" class="uc-commission-card__action" data-aksrt-withdraw-open data-aksrt-withdraw-source="commission" data-withdraw-min="<?php echo esc_attr($withdraw_min); ?>" data-withdraw-remaining-amount="<?php echo esc_attr((string) ($commission_withdraw_usage['remaining_amount'] ?? '0.00')); ?>" data-withdraw-remaining-count="<?php echo esc_attr((string) ($commission_withdraw_usage['remaining_count'] ?? 0)); ?>"><?php esc_html_e('立即提现', 'aksrt'); ?><i class="fa fas fa-angle-right" aria-hidden="true"></i></button>
<?php endif; ?>
</article>
</section>
<?php endif; ?>
@@ -139,61 +163,59 @@ $status_labels = [
<span><?php echo esc_html($affiliate_on ? __('推广链接、佣金订单与推广用户', 'aksrt') : __('推广链接与推广用户', 'aksrt')); ?></span>
</div>
<nav class="uc-reward-tabs" aria-label="<?php esc_attr_e('收益明细', 'aksrt'); ?>">
<a <?php echo $active_reward_tab === 'detail' ? 'aria-current="page"' : ''; ?> class="<?php echo $active_reward_tab === 'detail' ? 'is-active' : ''; ?>" href="<?php echo esc_url($reward_url('detail')); ?>"><?php esc_html_e('推广详情', 'aksrt'); ?></a>
<?php if ($affiliate_on) : ?><a <?php echo $active_reward_tab === 'orders' ? 'aria-current="page"' : ''; ?> class="<?php echo $active_reward_tab === 'orders' ? 'is-active' : ''; ?>" href="<?php echo esc_url($reward_url('orders')); ?>"><?php esc_html_e('佣金明细', 'aksrt'); ?></a><?php endif; ?>
<a <?php echo $active_reward_tab === 'users' ? 'aria-current="page"' : ''; ?> class="<?php echo $active_reward_tab === 'users' ? 'is-active' : ''; ?>" href="<?php echo esc_url($reward_url('users')); ?>"><?php esc_html_e('推广用户', 'aksrt'); ?></a>
<a data-reward-tab="detail" <?php echo $pane_is_active('detail') ? 'aria-current="page"' : ''; ?> class="<?php echo $pane_is_active('detail') ? 'is-active' : ''; ?>" href="<?php echo esc_url($reward_url('detail')); ?>"><?php esc_html_e('推广详情', 'aksrt'); ?></a>
<?php if ($affiliate_on) : ?><a data-reward-tab="orders" <?php echo $pane_is_active('orders') ? 'aria-current="page"' : ''; ?> class="<?php echo $pane_is_active('orders') ? 'is-active' : ''; ?>" href="<?php echo esc_url($reward_url('orders')); ?>"><?php esc_html_e('佣金明细', 'aksrt'); ?></a><?php endif; ?>
<a data-reward-tab="users" <?php echo $pane_is_active('users') ? 'aria-current="page"' : ''; ?> class="<?php echo $pane_is_active('users') ? 'is-active' : ''; ?>" href="<?php echo esc_url($reward_url('users')); ?>"><?php esc_html_e('推广用户', 'aksrt'); ?></a>
</nav>
<?php if ($active_reward_tab === 'detail') : ?>
<div class="uc-reward-pane uc-reward-pane--detail" data-aksrt-revenue-page>
<div class="uc-revenue-summary-list">
<div class="uc-revenue-summary-row uc-revenue-summary-row--link">
<span><?php esc_html_e('推广链接', 'aksrt'); ?></span>
<input type="text" readonly data-affiliate-link value="<?php echo esc_attr($affiliate_link_url); ?>" aria-label="<?php esc_attr_e('推广链接', 'aksrt'); ?>">
<button type="button" class="btn-primary btn-sm" data-affiliate-copy><?php esc_html_e('复制链接', 'aksrt'); ?></button>
<?php if (!empty($affiliate['poster']['enabled'])) : ?><button type="button" class="btn-outline btn-sm" data-affiliate-poster hidden><?php esc_html_e('推广海报', 'aksrt'); ?></button><?php endif; ?>
<small data-affiliate-feedback role="status" aria-live="polite"></small>
</div>
<?php if ($affiliate_on) : ?>
<div class="uc-revenue-summary-row"><span><?php esc_html_e('佣金比例', 'aksrt'); ?></span><strong data-affiliate-ratio><?php echo esc_html((string) $commission_ratio); ?>%</strong></div>
<div class="uc-revenue-summary-row uc-revenue-summary-row--total"><span><?php esc_html_e('累计佣金', 'aksrt'); ?></span><div><strong><?php printf(esc_html__('累计 %s 元', 'aksrt'), esc_html($format_amount($commission_stats['total'] ?? 0))); ?></strong><strong><?php printf(esc_html__('待结算 %s 元', 'aksrt'), esc_html($format_amount($commission_stats['pending'] ?? 0))); ?></strong></div></div>
<div class="uc-revenue-summary-row uc-revenue-summary-row--explain"><span><?php echo esc_html($details_title); ?></span><div class="uc-reward-richtext"><?php echo wp_kses_post(wpautop($details_text)); ?></div></div>
<?php endif; ?>
<div class="uc-reward-pane uc-reward-pane--detail" data-reward-pane="detail"<?php echo $pane_is_active('detail') ? '' : ' hidden'; ?>>
<div class="uc-revenue-summary-list">
<div class="uc-revenue-summary-row uc-revenue-summary-row--link">
<span><?php esc_html_e('推广链接', 'aksrt'); ?></span>
<input type="text" readonly data-affiliate-link value="<?php echo esc_attr($affiliate_link_url); ?>" aria-label="<?php esc_attr_e('推广链接', 'aksrt'); ?>">
<button type="button" class="btn-primary btn-sm" data-affiliate-copy><?php esc_html_e('复制链接', 'aksrt'); ?></button>
<?php if (!empty($affiliate['poster']['enabled'])) : ?><button type="button" class="btn-outline btn-sm" data-affiliate-poster hidden><?php esc_html_e('推广海报', 'aksrt'); ?></button><?php endif; ?>
<small data-affiliate-feedback role="status" aria-live="polite"></small>
</div>
<?php if ($affiliate_on) : ?>
<div class="uc-revenue-summary-row"><span><?php esc_html_e('佣金比例', 'aksrt'); ?></span><strong data-affiliate-ratio><?php echo esc_html((string) $commission_ratio); ?>%</strong></div>
<div class="uc-revenue-summary-row uc-revenue-summary-row--total"><span><?php esc_html_e('累计佣金', 'aksrt'); ?></span><div><strong><?php printf(esc_html__('累计 %s 元', 'aksrt'), esc_html($total_amount)); ?></strong><strong><?php printf(esc_html__('可提现 %s 元', 'aksrt'), esc_html($pool_amount)); ?></strong><strong><?php printf(esc_html__('已提现 %s 元', 'aksrt'), esc_html($withdrawn_amount)); ?></strong><?php if (($commission_stats['debt'] ?? '0.00') !== '0.00') : ?><strong><?php printf(esc_html__('待偿欠款 %s 元', 'aksrt'), esc_html($debt_amount)); ?></strong><?php endif; ?></div></div>
<div class="uc-revenue-summary-row uc-revenue-summary-row--explain"><span><?php echo esc_html($details_title); ?></span><div class="uc-reward-richtext"><?php echo wp_kses_post(wpautop($details_text)); ?></div></div>
<?php endif; ?>
</div>
<?php elseif ($active_reward_tab === 'orders') : ?>
<div class="uc-reward-pane">
<div class="uc-reward-list-heading"><div><h4><?php esc_html_e('佣金订单', 'aksrt'); ?></h4><p><?php esc_html_e('每页显示 10 条佣金记录。', 'aksrt'); ?></p></div><strong><?php printf(esc_html__('%d 条', 'aksrt'), (int) ($commission_view['total'] ?? 0)); ?></strong></div>
<?php if ($commission_items) : ?>
<div class="uc-commission-list">
<?php foreach ($commission_items as $item) :
$status = sanitize_key((string) ($item['status'] ?? 'pending'));
$source_user = !empty($item['source_user_id']) ? get_userdata((int) $item['source_user_id']) : null;
$source_name = $source_user instanceof WP_User ? $source_user->display_name : __('推广用户', 'aksrt');
?>
<?php
$commission_minor = $signed_balance_minor($item['original_amount'] ?? '0') + $signed_balance_minor($item['adjustment_total'] ?? '0');
$commission_amount = \AKSRT\Ledger\AssetAmount::decimal_from_minor(max(0, $commission_minor));
?>
<article class="uc-commission-row"><span class="uc-commission-row__icon"><i class="fa fas fa-file-alt" aria-hidden="true"></i></span><div class="uc-commission-row__main"><strong><?php echo esc_html($source_name); ?></strong><span><?php printf(esc_html__('订单 %s', 'aksrt'), esc_html((string) ($item['order_number'] ?? '-'))); ?></span><time><?php echo esc_html((string) ($item['created_at'] ?? '')); ?></time></div><div class="uc-commission-row__value"><strong><?php echo esc_html($format_amount($commission_amount)); ?></strong><span class="is-<?php echo esc_attr($status); ?>"><?php echo esc_html($status_labels[$status] ?? $status); ?></span></div></article>
<?php endforeach; ?>
</div>
<?php if ($commission_total_pages > 1) : ?><nav class="uc-reward-pagination" aria-label="<?php esc_attr_e('佣金明细分页', 'aksrt'); ?>"><?php for ($i = 1; $i <= $commission_total_pages; $i++) : ?><a class="<?php echo $i === $commission_page ? 'is-active' : ''; ?>" href="<?php echo esc_url($reward_url('orders', ['commission_page' => $i])); ?>"><?php echo esc_html((string) $i); ?></a><?php endfor; ?></nav><?php endif; ?>
<?php else : ?><p class="uc-reward-empty"><?php esc_html_e('暂无佣金订单', 'aksrt'); ?></p><?php endif; ?>
</div>
<?php else : ?>
<div class="uc-reward-pane">
<div class="uc-reward-list-heading"><div><h4><?php esc_html_e('推广用户', 'aksrt'); ?></h4><p><?php esc_html_e('通过你的 ref 推广链接完成绑定的用户。', 'aksrt'); ?></p></div><strong><?php printf(esc_html__('%d 人', 'aksrt'), (int) ($user_view['total'] ?? 0)); ?></strong></div>
<?php if (!empty($user_view['items'])) : ?>
<div class="uc-revenue-user-list">
<?php foreach ($user_view['items'] as $item) : ?>
<article class="uc-revenue-user-row"><img src="<?php echo esc_url((string) ($item['avatar'] ?? '')); ?>" alt=""><div><strong><?php echo esc_html((string) ($item['name'] ?? __('推广用户', 'aksrt'))); ?></strong><small><?php echo esc_html((string) ($item['bound_at'] ?? '')); ?></small></div><span><?php esc_html_e('已绑定', 'aksrt'); ?></span></article>
<?php endforeach; ?>
</div>
<?php if ($user_total_pages > 1) : ?><nav class="uc-reward-pagination" aria-label="<?php esc_attr_e('推广用户分页', 'aksrt'); ?>"><?php for ($i = 1; $i <= $user_total_pages; $i++) : ?><a class="<?php echo $i === $user_page ? 'is-active' : ''; ?>" href="<?php echo esc_url($reward_url('users', ['user_page' => $i])); ?>"><?php echo esc_html((string) $i); ?></a><?php endfor; ?></nav><?php endif; ?>
<?php else : ?><p class="uc-reward-empty"><?php esc_html_e('暂无推广用户', 'aksrt'); ?></p><?php endif; ?>
</div>
</div>
<?php if ($affiliate_on) : ?>
<div class="uc-reward-pane" data-reward-pane="orders"<?php echo $pane_is_active('orders') ? '' : ' hidden'; ?>>
<div class="uc-reward-list-heading"><div><h4><?php esc_html_e('佣金订单', 'aksrt'); ?></h4><p><?php esc_html_e('每页显示 10 条佣金记录。', 'aksrt'); ?></p></div><strong><?php printf(esc_html__('%d 条', 'aksrt'), (int) ($commission_view['total'] ?? 0)); ?></strong></div>
<?php if ($commission_items) : ?>
<div class="uc-commission-list">
<?php foreach ($commission_items as $item) :
$status = sanitize_key((string) ($item['status'] ?? 'pending'));
$source_user = !empty($item['source_user_id']) ? get_userdata((int) $item['source_user_id']) : null;
$source_name = $source_user instanceof WP_User ? $source_user->display_name : __('推广用户', 'aksrt');
$commission_minor = $signed_balance_minor($item['original_amount'] ?? '0') + $signed_balance_minor($item['adjustment_total'] ?? '0');
$commission_amount = \AKSRT\Ledger\AssetAmount::decimal_from_minor(max(0, $commission_minor));
?>
<article class="uc-commission-row"><span class="uc-commission-row__icon"><i class="fa fas fa-file-alt" aria-hidden="true"></i></span><div class="uc-commission-row__main"><strong><?php echo esc_html($source_name); ?></strong><span><?php printf(esc_html__('订单 %s', 'aksrt'), esc_html((string) ($item['order_number'] ?? '-'))); ?></span><time><?php echo esc_html((string) ($item['created_at'] ?? '')); ?></time></div><div class="uc-commission-row__value"><strong><?php echo esc_html($format_amount($commission_amount)); ?></strong><span class="is-<?php echo esc_attr($status); ?>"><?php echo esc_html($status_labels[$status] ?? $status); ?></span></div></article>
<?php endforeach; ?>
</div>
<?php if ($commission_total_pages > 1) : ?><nav class="uc-reward-pagination" aria-label="<?php esc_attr_e('佣金明细分页', 'aksrt'); ?>"><?php for ($i = 1; $i <= $commission_total_pages; $i++) : ?><a class="<?php echo $i === $commission_page ? 'is-active' : ''; ?>" href="<?php echo esc_url($reward_url('orders', ['commission_page' => $i])); ?>"><?php echo esc_html((string) $i); ?></a><?php endfor; ?></nav><?php endif; ?>
<?php else : ?><p class="uc-reward-empty"><?php esc_html_e('暂无佣金订单', 'aksrt'); ?></p><?php endif; ?>
</div>
<?php endif; ?>
<div class="uc-reward-pane" data-reward-pane="users"<?php echo $pane_is_active('users') ? '' : ' hidden'; ?>>
<div class="uc-reward-list-heading"><div><h4><?php esc_html_e('推广用户', 'aksrt'); ?></h4><p><?php esc_html_e('通过你的 ref 推广链接完成绑定的用户。', 'aksrt'); ?></p></div><strong><?php printf(esc_html__('%d 人', 'aksrt'), (int) ($user_view['total'] ?? 0)); ?></strong></div>
<?php if (!empty($user_view['items'])) : ?>
<div class="uc-revenue-user-list">
<?php foreach ($user_view['items'] as $item) : ?>
<article class="uc-revenue-user-row"><img src="<?php echo esc_url((string) ($item['avatar'] ?? '')); ?>" alt=""><div><strong><?php echo esc_html((string) ($item['name'] ?? __('推广用户', 'aksrt'))); ?></strong><small><?php echo esc_html((string) ($item['bound_at'] ?? '')); ?></small></div><span><?php esc_html_e('已绑定', 'aksrt'); ?></span></article>
<?php endforeach; ?>
</div>
<?php if ($user_total_pages > 1) : ?><nav class="uc-reward-pagination" aria-label="<?php esc_attr_e('推广用户分页', 'aksrt'); ?>"><?php for ($i = 1; $i <= $user_total_pages; $i++) : ?><a class="<?php echo $i === $user_page ? 'is-active' : ''; ?>" href="<?php echo esc_url($reward_url('users', ['user_page' => $i])); ?>"><?php echo esc_html((string) $i); ?></a><?php endfor; ?></nav><?php endif; ?>
<?php else : ?><p class="uc-reward-empty"><?php esc_html_e('暂无推广用户', 'aksrt'); ?></p><?php endif; ?>
</div>
</section>
</div>
<?php if ($withdraw_on) {
-1
View File
@@ -347,7 +347,6 @@ class SchemaPayment {
['key' => 'pay_rebate_ratio_vip_2', 'label' => '二级会员返佣比例(%)', 'type' => 'number', 'default' => 20, 'min' => 0, 'max' => 100, 'dependsOn' => ['key' => 'pay_rebate_s', 'value' => true]],
]],
]],
['key' => 'affiliate_settlement_days', 'label' => '结算观察期(天)', 'type' => 'number', 'default' => 7, 'min' => 0, 'max' => 90, 'dependsOn' => ['key' => 'pay_rebate_s', 'value' => true], 'help' => '订单完成后经过观察期,佣金才会结算到可提现余额'],
['key' => 'affiliate_disabled_code_stops_commission', 'label' => '推广码禁用后停止返佣', 'type' => 'switch', 'default' => false, 'dependsOn' => ['key' => 'pay_rebate_s', 'value' => true], 'help' => '开启后,推广者的推广码被禁用(或缺失)时,其名下已绑定用户的新订单不再产生佣金;关闭则绑定关系继续生效'],
]],
['key' => 'text', 'title' => '返佣文案', 'fields' => [
+1 -1
View File
@@ -14,7 +14,7 @@ class DBMigrator {
use DBMigratorVip;
/** Current schema version; changing it re-runs dbDelta. */
const SCHEMA_VERSION = '2.0.0';
const SCHEMA_VERSION = '2.1.0';
const OPT_KEY = 'aksrt_db_schema_version';
+13 -4
View File
@@ -435,6 +435,8 @@ trait DBMigratorPart2 {
dbDelta($sql_affiliate_relation);
// ── immutable commission ledger ──
// 佣金即时入账(无冻结/结算期):下单支付即 credit 到 COMMISSION 资金池,
// 本表仅作佣金明细账 + 退款冲正的溯源记录。status: credited | cancelled。
$sql_commission_ledger = "CREATE TABLE {$prefix}aksrt_commission_ledger (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
promoter_id BIGINT UNSIGNED NOT NULL,
@@ -445,12 +447,10 @@ trait DBMigratorPart2 {
ratio DECIMAL(8,4) NOT NULL DEFAULT 0,
original_amount DECIMAL(18,2) NOT NULL DEFAULT 0,
adjustment_total DECIMAL(18,2) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
status VARCHAR(20) NOT NULL DEFAULT 'credited',
idempotency_key VARCHAR(128) NOT NULL,
cancel_operation_key VARCHAR(191) DEFAULT NULL,
cancel_payload_hash CHAR(64) DEFAULT NULL,
available_at DATETIME NOT NULL,
settled_at DATETIME DEFAULT NULL,
created_at DATETIME NOT NULL,
note TEXT DEFAULT NULL,
PRIMARY KEY (id),
@@ -458,11 +458,20 @@ trait DBMigratorPart2 {
UNIQUE KEY uniq_commission_cancel_operation (cancel_operation_key),
KEY idx_promoter_status (promoter_id, status),
KEY idx_order_type (order_number, order_type),
KEY idx_available (status, available_at),
KEY idx_created (created_at)
) ENGINE=InnoDB {$charset_collate};";
dbDelta($sql_commission_ledger);
// 佣金欠款挂账:退款全额收回后,若佣金池不足产生的差额记为欠款,
// 后续新增佣金先抵扣欠款再入池。每用户一行。
$sql_commission_debt = "CREATE TABLE {$prefix}aksrt_commission_debt (
user_id BIGINT UNSIGNED NOT NULL,
debt_minor BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL,
PRIMARY KEY (user_id)
) ENGINE=InnoDB {$charset_collate};";
dbDelta($sql_commission_debt);
// ── immutable commission adjustments (refund/debt/manual) ──
$sql_commission_adjustment = "CREATE TABLE {$prefix}aksrt_commission_adjustment (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+1
View File
@@ -37,6 +37,7 @@ trait DBMigratorPart3 {
'affiliate_relation' => 'aksrt_affiliate_relation',
'commission_ledger' => 'aksrt_commission_ledger',
'commission_adjustment' => 'aksrt_commission_adjustment',
'commission_debt' => 'aksrt_commission_debt',
'payout_account' => 'aksrt_payout_account',
'coupon_usage' => 'aksrt_coupon_usage',
'user_restriction' => 'aksrt_user_restriction',
+7
View File
@@ -37,6 +37,13 @@ final class AssetAmount {
return self::balance($decimal)->minor();
}
/**
* 佣金池与余额同为两位小数、同一 minor 口径,仅资产标签不同。
*/
public static function commission(string $decimal): self {
return new self(AssetType::COMMISSION, self::balance($decimal)->minor());
}
public static function decimal_from_minor(int $minor): string {
return self::from_minor(AssetType::BALANCE, $minor)->decimal();
}
+6
View File
@@ -10,8 +10,14 @@ defined('ABSPATH') || exit;
enum AssetType: string {
case POINTS = 'points';
case BALANCE = 'balance';
case COMMISSION = 'commission';
public static function from_value(self|string $asset): self {
return $asset instanceof self ? $asset : self::from($asset);
}
/** 是否为两位小数的货币型资产(非积分)。 */
public function is_currency(): bool {
return $this !== self::POINTS;
}
}
+8
View File
@@ -21,6 +21,14 @@ final class OperationKey {
return $key;
}
/**
* 生成一次性唯一 operation_key。用于自身已带并发守卫、且每次执行金额可能不同、
* 因而不能被幂等重放的账务操作(如机会性偿债、退款分次收回)。
*/
public static function unique(string $scope): string {
return self::deterministic($scope, [wp_generate_uuid4()]);
}
/** @return string|\WP_Error */
public static function from_request(\WP_REST_Request $request, string $scope, int $actor_user_id) {
$request_id = trim((string) $request->get_header('Idempotency-Key'));
+8 -10
View File
@@ -234,6 +234,10 @@ class BalanceGateway implements GatewayInterface {
}
return $commission->apply_full_refund($commission_plan);
};
// 只退买家余额。佣金收回不再作为分录混入本事务:即时入账模型下佣金在
// 独立 COMMISSION 池,收回可能超出池余额(转为欠款),若作为分录会因池
// 不足而中止买家退款。故 apply_full_refund 仅登记佣金明细账的冲正,真正
// 的池收回 + 欠款挂账由 clawback_after_refund 在本事务提交后独立执行。
$entries = [[
'user_id' => $user_id,
'asset' => \AKSRT\Ledger\AssetType::BALANCE->value,
@@ -241,16 +245,6 @@ class BalanceGateway implements GatewayInterface {
'entry_type' => 'refund',
'remark' => sprintf(__('订单 %s 退款', 'aksrt'), $order_number),
]];
if (!empty($commission_plan['active']) && (string) ($commission_plan['status'] ?? '') === 'settled') {
$entries[] = [
'user_id' => (int) $commission_plan['promoter_id'],
'asset' => \AKSRT\Ledger\AssetType::BALANCE->value,
'delta_minor' => -(int) $commission_plan['amount_minor'],
'entry_type' => 'commission_reversal',
'counterparty_user_id' => $user_id,
'remark' => sprintf(__('订单 %s 返佣退款冲正', 'aksrt'), $order_number),
];
}
$result = \AKSRT\Ledger\LedgerService::get_instance()->post(new \AKSRT\Ledger\LedgerCommand(
$operation_key,
'order_refund',
@@ -263,6 +257,10 @@ class BalanceGateway implements GatewayInterface {
if (is_wp_error($result)) {
return ['success' => false, 'message' => $result->get_error_message() ?: __('退款失败', 'aksrt')];
}
// 退款事务已提交:从佣金池收回本单佣金,不足部分挂账为欠款(非关键路径)。
if (!$result->replayed) {
$commission->clawback_after_refund($commission_plan);
}
return [
'success' => true,
'refund_id' => 'BAL_REF_' . $order_number,
+251 -145
View File
@@ -9,10 +9,11 @@ final class CommissionLedger {
public static function get_instance(): self { return self::$instance ??= new self(); }
private function __construct() {}
public function init(): void {
add_action('aksrt_affiliate_settle_due', [$this, 'settle_due']);
add_action('rest_api_init', [$this, 'register_routes']);
if (!wp_next_scheduled('aksrt_affiliate_settle_due')) {
wp_schedule_event(time() + HOUR_IN_SECONDS, 'hourly', 'aksrt_affiliate_settle_due');
// 佣金已改为即时入账,历史遗留的结算 cron 需主动清理(无兼容层)。
$timestamp = wp_next_scheduled('aksrt_affiliate_settle_due');
if ($timestamp) {
wp_unschedule_event($timestamp, 'aksrt_affiliate_settle_due');
}
}
private function table(string $name): string { return \AKSRT\Core\DBMigrator::table($name); }
@@ -56,6 +57,14 @@ final class CommissionLedger {
}
if ($order_no === '' || $buyer <= 0 || $amount->minor() <= 0) return 0;
$promoter = ReferralAttribution::get_instance()->resolve($buyer);
// 自我返佣(参考主题「自己推广自己」,购买时归因):无外部推广人且开关开启时,
// 买家用自己的(有效)推广码即成为自身推广人。resolve() 结构上永不返回自身,
// 故这里显式补齐,且要求买家推广码有效以复用「禁用即停返佣」的口径。
if ($promoter <= 0
&& !empty($settings['self_rebate'])
&& ReferralAttribution::get_instance()->is_promoter_code_active($buyer)) {
$promoter = $buyer;
}
if ($promoter <= 0) return 0;
// 开关开启时,推广者的推广码已被禁用(或缺失)则不再产生新佣金。
if (!empty($settings['disabled_code_stops_commission'])
@@ -66,10 +75,9 @@ final class CommissionLedger {
$promoter_level = function_exists('aksrt_get_user_vip_level') ? (int) aksrt_get_user_vip_level($promoter) : 0;
if (!in_array($order_type, \AKSRT\Settings\PaymentRuntime::affiliate_order_types_for_level($promoter_level), true)) return 0;
$ratio = \AKSRT\Settings\PaymentRuntime::affiliate_ratio_for_user($promoter);
$commission_minor = (intdiv($amount->minor(), 100) * $ratio)
+ intdiv((($amount->minor() % 100) * $ratio) + 50, 100);
$commission_minor = \AKSRT\Ledger\AssetAmount::percentage($amount->minor(), $ratio);
if ($commission_minor <= 0) return 0;
$commission = \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $commission_minor)->decimal();
$commission = \AKSRT\Ledger\AssetAmount::commission_from_minor($commission_minor)->decimal();
global $wpdb;
$table = $this->table('commission_ledger');
$identity = $order_type === 'credits' && !empty($order['exchange_ref'])
@@ -77,10 +85,13 @@ final class CommissionLedger {
: $order_no . ':' . $order_type;
$key = hash('sha256', 'affiliate:' . $identity);
$now = current_time('mysql', true);
$available = gmdate('Y-m-d H:i:s', time() + ((int) $settings['settlement_days'] * DAY_IN_SECONDS));
$ok = $wpdb->insert($table, ['promoter_id'=>$promoter,'source_user_id'=>$buyer,'order_number'=>$order_no,'order_type'=>$order_type,'eligible_base'=>$amount->decimal(),'ratio'=>$ratio,'original_amount'=>$commission,'adjustment_total'=>'0.00','status'=>'pending','idempotency_key'=>$key,'available_at'=>$available,'created_at'=>$now], ['%d','%d','%s','%s','%s','%d','%s','%s','%s','%s','%s','%s']);
// 佣金明细账先落库(status=credited),作为入账溯源;随后把佣金即时
// credit 到 COMMISSION 资金池(先抵扣欠款)。两步分别幂等。
$ok = $wpdb->insert($table, ['promoter_id'=>$promoter,'source_user_id'=>$buyer,'order_number'=>$order_no,'order_type'=>$order_type,'eligible_base'=>$amount->decimal(),'ratio'=>$ratio,'original_amount'=>$commission,'adjustment_total'=>'0.00','status'=>'credited','idempotency_key'=>$key,'created_at'=>$now], ['%d','%d','%s','%s','%s','%d','%s','%s','%s','%s','%s']);
if ($ok) {
return (int) $wpdb->insert_id;
$ledger_id = (int) $wpdb->insert_id;
$this->credit_commission($promoter, $commission_minor, $ledger_id, $order_no);
return $ledger_id;
}
$existing = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE idempotency_key=%s", $key), ARRAY_A);
if (!is_array($existing)
@@ -94,56 +105,85 @@ final class CommissionLedger {
) {
return -1;
}
// 明细行已存在(订单支付回调重放):入账用确定性 operation_key 幂等,安全重放。
$this->credit_commission($promoter, $commission_minor, (int) $existing['id'], $order_no);
return (int) $existing['id'];
}
public function settle_due(int $limit = 100): int {
global $wpdb; $table = $this->table('commission_ledger');
$rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$table} WHERE status='pending' AND available_at <= %s ORDER BY id ASC LIMIT %d", current_time('mysql', true), max(1,min(500,$limit))), ARRAY_A);
$count = 0;
foreach ((array) $rows as $row) if ($this->settle_one((int)$row['id'])) $count++;
return $count;
}
private function settle_one(int $id, string $operation_key = '') {
global $wpdb; $table = $this->table('commission_ledger');
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id=%d", $id), ARRAY_A);
if (!is_array($row)) return false;
try {
$original_minor = $this->signed_balance_minor((string) ($row['original_amount'] ?? '0'));
$adjustment_minor = $this->signed_balance_minor((string) ($row['adjustment_total'] ?? '0'));
if ($adjustment_minor > 0 && $original_minor > PHP_INT_MAX - $adjustment_minor) {
return false;
}
if ($adjustment_minor < 0 && $original_minor < PHP_INT_MIN - $adjustment_minor) {
return false;
}
$amount_minor = $original_minor + $adjustment_minor;
} catch (\InvalidArgumentException $e) { return false; }
if ($amount_minor <= 0 || (int)$row['promoter_id'] <= 0) return false;
$guard = function () use ($table, $id, $row) {
/**
* 佣金即时入账:全额 credit 到 COMMISSION 资金池,再机会性偿还历史欠款。
*
* 两段式而非「净额入池」,是为了金额守恒可证:
* 1) 入池分录金额固定 = 佣金全额,operation_key 由明细行 id 派生,天然幂等/防重放。
* 2) 偿债在独立账务事务里做:池扣减 min(欠款, 池余额) 且欠款同额递减,两者在同一
* 事务内以锁定值为准,等额增减 → 永不凭空增减钱;并发导致失败则本次跳过,
* 欠款留到下一笔佣金再偿,绝不出错。
*/
private function credit_commission(int $promoter, int $commission_minor, int $ledger_id, string $order_no): void {
if ($promoter <= 0 || $commission_minor <= 0) {
return;
}
$credit = \AKSRT\Ledger\LedgerService::get_instance()->credit(
$promoter, \AKSRT\Ledger\AssetType::COMMISSION, $commission_minor,
\AKSRT\Ledger\OperationKey::deterministic('commission-credit', [$ledger_id]),
'affiliate_commission',
['type' => 'commission', 'id' => $ledger_id],
sprintf(__('推广佣金入账(订单 %s)', 'aksrt'), $order_no)
);
if (!is_wp_error($credit)) {
$this->repay_debt_from_pool($promoter);
}
}
/**
* 机会性偿债:从 COMMISSION 池扣减 min(欠款, 池余额),欠款同额递减,单个账务事务内原子完成。
* 偿债额 ≤ 池余额 → 扣减不会透支;扣减额 == 欠款递减额 → 金额守恒。并发/余额不足则本次跳过。
*/
private function repay_debt_from_pool(int $promoter): void {
if ($promoter <= 0) {
return;
}
global $wpdb;
$debt_table = $this->table('commission_debt');
$debt = (int) $wpdb->get_var($wpdb->prepare("SELECT debt_minor FROM {$debt_table} WHERE user_id=%d", $promoter));
if ($debt <= 0) {
return;
}
$pool = \AKSRT\Ledger\LedgerService::get_instance()->balance($promoter, \AKSRT\Ledger\AssetType::COMMISSION);
$repay = max(0, min($debt, $pool));
if ($repay <= 0) {
return;
}
// guard 内锁定欠款行复核:并发偿债已把欠款降到不足 repay,则可重试/跳过。
$guard = function () use ($debt_table, $promoter, $repay) {
global $wpdb;
$current = $wpdb->get_row($wpdb->prepare("SELECT promoter_id, original_amount, adjustment_total, status FROM {$table} WHERE id=%d FOR UPDATE", $id), ARRAY_A);
if (!is_array($current) || (string)$current['status'] !== 'pending'
|| (int)$current['promoter_id'] !== (int)$row['promoter_id']
|| (string)$current['original_amount'] !== (string)$row['original_amount']
|| (string)$current['adjustment_total'] !== (string)$row['adjustment_total']) {
return new \WP_Error('aksrt_commission_state', __('佣金记录状态已变化','aksrt'), ['status'=>409]);
}
return true;
$locked = (int) $wpdb->get_var($wpdb->prepare("SELECT debt_minor FROM {$debt_table} WHERE user_id=%d FOR UPDATE", $promoter));
return $locked >= $repay ? true : new \WP_Error('aksrt_commission_debt', __('佣金欠款已变化', 'aksrt'), ['status' => 409]);
};
$mutation = function () use ($table, $id) {
$mutation = function () use ($debt_table, $promoter, $repay) {
global $wpdb;
return $wpdb->query($wpdb->prepare("UPDATE {$table} SET status='settled', settled_at=%s WHERE id=%d AND status='pending'", current_time('mysql', true), $id)) === 1
? true : new \WP_Error('aksrt_commission_state', __('佣金记录状态已变化','aksrt'), ['status'=>409]);
return $wpdb->query($wpdb->prepare(
"UPDATE {$debt_table} SET debt_minor = debt_minor - %d, updated_at = %s WHERE user_id=%d AND debt_minor >= %d",
$repay, current_time('mysql', true), $promoter, $repay
)) === 1 ? true : new \WP_Error('aksrt_commission_debt', __('佣金欠款抵扣失败', 'aksrt'), ['status' => 409]);
};
$result = \AKSRT\Ledger\LedgerService::get_instance()->credit(
(int)$row['promoter_id'], \AKSRT\Ledger\AssetType::BALANCE, $amount_minor,
$operation_key !== '' ? $operation_key : \AKSRT\Ledger\OperationKey::deterministic('commission-settle', [$id]), 'affiliate_commission',
['type'=>'commission','id'=>$id], sprintf(__('推广佣金结算(订单 %s)','aksrt'), $row['order_number']),
// 偿债是机会性动作、由锁定的 debt_minor>=repay 保证不重复扣减,无需跨调用幂等;
// 故用唯一 operation_key,避免 (promoter,debt,pool) 元组偶然重现被误判为重放而漏偿。
\AKSRT\Ledger\LedgerService::get_instance()->debit(
$promoter, \AKSRT\Ledger\AssetType::COMMISSION, $repay,
\AKSRT\Ledger\OperationKey::unique('commission-debt-repay'),
'affiliate_commission_debt',
['type' => 'commission_debt', 'id' => $promoter],
__('推广佣金偿还历史欠款', 'aksrt'),
0, [], $mutation, $guard
);
return is_wp_error($result) ? false : $result;
}
/** @return array<string,mixed>|\WP_Error */
}
/**
* 退款准备:返回本订单待收回的佣金计划。即时入账模型下佣金状态只有
* credited | cancelled,可收回额 = original + 既有调整(正值)。
*
* @return array<string,mixed>|\WP_Error
*/
public function prepare_full_refund(string $order_number) {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare(
@@ -164,7 +204,7 @@ final class CommissionLedger {
return ['active' => false];
}
$status = (string) ($row['status'] ?? '');
if (!in_array($status, ['pending', 'settled'], true)) {
if ($status !== 'credited') {
return new \WP_Error('aksrt_commission_state', __('返佣记录状态不可退款', 'aksrt'), ['status' => 409]);
}
return [
@@ -179,7 +219,12 @@ final class CommissionLedger {
];
}
/** @param array<string,mixed> $plan */
/**
* 退款守卫(在买家退款账务事务内运行):锁定佣金明细行并复核未变化。
* 仅校验明细行,不触碰资金池,故不会因促推者池不足而使买家退款失败。
*
* @param array<string,mixed> $plan
*/
public function guard_full_refund(array $plan) {
if (empty($plan['active'])) return true;
global $wpdb;
@@ -198,11 +243,18 @@ final class CommissionLedger {
return true;
}
/** @param array<string,mixed> $plan */
/**
* 退款应用(在买家退款账务事务内运行):仅做佣金明细账的登记——冲正
* adjustment_total 并置为 cancelled,写入不可变调整记录。这里不移动资金池
* (避免池不足时中止买家退款);真正的池收回 + 欠款挂账由 clawback_after_refund
* 在退款事务提交后独立执行。
*
* @param array<string,mixed> $plan
*/
public function apply_full_refund(array $plan) {
if (empty($plan['active'])) return true;
global $wpdb;
$negative = '-' . \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, (int) $plan['amount_minor'])->decimal();
$negative = '-' . \AKSRT\Ledger\AssetAmount::commission_from_minor((int) $plan['amount_minor'])->decimal();
$inserted = $wpdb->insert($this->table('commission_adjustment'), [
'ledger_id' => (int) $plan['id'],
'promoter_id' => (int) $plan['promoter_id'],
@@ -226,27 +278,137 @@ final class CommissionLedger {
: new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]);
}
/**
* 退款后的资金池收回(在买家退款事务提交后调用,不属于买家退款关键路径):
* 从 COMMISSION 池收回 min(应收回, 池余额),不足部分记为欠款,后续新佣金优先偿还。
*
* 金额守恒 + 幂等:
* - 池扣减带重试,locked 池余额 < take 时重试并缩小 take,绝不透支中止;
* - 池扣减用 source_key 派生的确定性 operation_key,重复调用天然去重;
* - 欠款挂账用独立 source_key 一次性插入 commission_adjustment(唯一约束),
* 仅首次插入成功者累加欠款,杜绝重复挂账。
*
* @param array<string,mixed> $plan
*/
public function clawback_after_refund(array $plan): void {
if (empty($plan['active'])) {
return;
}
$promoter = (int) ($plan['promoter_id'] ?? 0);
$clawback = (int) ($plan['amount_minor'] ?? 0);
$source_key = (string) ($plan['source_key'] ?? '');
if ($promoter <= 0 || $clawback <= 0 || $source_key === '') {
return;
}
$ledger = \AKSRT\Ledger\LedgerService::get_instance();
// 收回的幂等边界是明细行的 cancelled 状态(apply_full_refund 已置位且带唯一 source_key):
// 单笔退款只会调用一次收回。这里每次 debit 用唯一 operation_key,避免「不同金额复用
// 同一确定性 key 被当作重放而漏收」。池扣减额恒 ≤ 锁定池余额,绝不透支。
$remaining = $clawback;
for ($attempt = 1; $attempt <= 5 && $remaining > 0; $attempt++) {
$pool = $ledger->balance($promoter, \AKSRT\Ledger\AssetType::COMMISSION);
$take = max(0, min($remaining, $pool));
if ($take <= 0) {
break;
}
$guard = function () use ($promoter, $take, $ledger) {
$locked = $ledger->balance($promoter, \AKSRT\Ledger\AssetType::COMMISSION);
return $locked >= $take ? true : new \WP_Error('aksrt_commission_pool', __('佣金池余额已变化', 'aksrt'), ['status' => 409]);
};
$result = $ledger->debit(
$promoter, \AKSRT\Ledger\AssetType::COMMISSION, $take,
\AKSRT\Ledger\OperationKey::unique('commission-refund-clawback'), 'affiliate_commission_refund',
['type' => 'commission_refund', 'key' => $source_key],
__('推广佣金退款收回', 'aksrt'),
get_current_user_id(), [], null, $guard
);
if (!is_wp_error($result)) {
$remaining -= $take;
continue;
}
// 池余额被并发提现/收回改变:重试并以新的锁定余额缩小 take。
}
if ($remaining > 0) {
$this->record_debt($promoter, $remaining, $source_key, (int) ($plan['id'] ?? 0));
}
}
/**
* 挂账欠款:以独立 source_key 一次性写入调整记录(唯一约束保证幂等),
* 仅首次插入成功时累加用户欠款计数。
*/
private function record_debt(int $promoter, int $shortfall_minor, string $source_key, int $ledger_id): void {
if ($promoter <= 0 || $shortfall_minor <= 0) {
return;
}
global $wpdb;
$debt_source = hash('sha256', 'commission-refund-debt:' . $source_key);
$inserted = $wpdb->insert($this->table('commission_adjustment'), [
'ledger_id' => $ledger_id,
'promoter_id' => $promoter,
'amount' => '-' . \AKSRT\Ledger\AssetAmount::commission_from_minor($shortfall_minor)->decimal(),
'reason' => 'refund_debt',
'source_key' => $debt_source,
'operator_id' => get_current_user_id(),
'created_at' => current_time('mysql', true),
], ['%d', '%d', '%s', '%s', '%s', '%d', '%s']);
if ($inserted !== 1) {
// 已挂账(重复调用):唯一约束拦截,不重复累加欠款。
return;
}
$debt_table = $this->table('commission_debt');
$now = current_time('mysql', true);
$wpdb->query($wpdb->prepare(
"INSERT INTO {$debt_table} (user_id, debt_minor, updated_at) VALUES (%d, %d, %s)
ON DUPLICATE KEY UPDATE debt_minor = debt_minor + %d, updated_at = %s",
$promoter, $shortfall_minor, $now, $shortfall_minor, $now
));
}
private function signed_balance_minor(string $decimal): int {
$negative = str_starts_with($decimal, '-');
$value = $negative ? substr($decimal, 1) : $decimal;
$minor = \AKSRT\Ledger\AssetAmount::balance($value)->minor();
return $negative ? -$minor : $minor;
}
/**
* 佣金总览(即时入账模型):
* - total 累计佣金(明细账原始佣金累加,不含退款冲正)
* - pool 当前可提现佣金(COMMISSION 资金池余额)
* - withdrawn 已提现(佣金来源、已完成的提现毛额累加)
* - debt 当前挂账欠款(退款收回不足产生,后续佣金优先偿还)
* 无冻结结算期,故不再有 pending/settled/wallet 语义。
*/
public function overview(int $user_id): array {
global $wpdb; $table=$this->table('commission_ledger');
$row=$wpdb->get_row($wpdb->prepare("SELECT COALESCE(SUM(original_amount+adjustment_total),0) total, COALESCE(SUM(CASE WHEN status='pending' THEN original_amount+adjustment_total ELSE 0 END),0) pending, COALESCE(SUM(CASE WHEN status='settled' THEN original_amount+adjustment_total ELSE 0 END),0) settled FROM {$table} WHERE promoter_id=%d",$user_id),ARRAY_A) ?: [];
$withdrawn = '0.00';
$total = (string) $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(original_amount),0) FROM {$table} WHERE promoter_id=%d",
$user_id
));
$withdrawn = '0.00';
$withdrawal_table = $this->table('withdrawal');
if ($withdrawal_table !== '') {
$withdrawn = $this->balance_decimal((string) $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(CASE WHEN status='completed' THEN gross_amount ELSE 0 END),0) FROM {$withdrawal_table} WHERE user_id=%d",
$user_id
)));
$withdrawn = $this->balance_decimal((string) $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(CASE WHEN status='completed' THEN gross_amount ELSE 0 END),0) FROM {$withdrawal_table}
WHERE user_id=%d AND account_info LIKE %s",
$user_id,
'%"source":"commission"%'
)));
}
return ['total'=>$this->balance_decimal((string)($row['total']??'0')),'pending'=>$this->balance_decimal((string)($row['pending']??'0')),'settled'=>$this->balance_decimal((string)($row['settled']??'0')),'wallet'=>\AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),'withdrawn'=>$withdrawn];
$debt_minor = (int) $wpdb->get_var($wpdb->prepare(
"SELECT debt_minor FROM {$this->table('commission_debt')} WHERE user_id=%d",
$user_id
));
$pool_minor = \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::COMMISSION);
return [
'total' => $this->balance_decimal($total),
'pool' => \AKSRT\Ledger\AssetAmount::commission_from_minor($pool_minor)->decimal(),
'withdrawn' => $withdrawn,
'debt' => \AKSRT\Ledger\AssetAmount::commission_from_minor(max(0, $debt_minor))->decimal(),
];
}
public function list_entries(int $user_id, array $args=[]): array {
global $wpdb; $table=$this->table('commission_ledger'); $limit=max(1,min(100,(int)($args['per_page']??20))); $page=max(1,(int)($args['page']??1)); $offset=($page-1)*$limit; $where='promoter_id=%d'; $params=[$user_id]; $status=sanitize_key((string)($args['status']??'')); if(in_array($status,['pending','settled','cancelled'],true)){ $where.=' AND status=%s'; $params[]=$status; } $total=(int)$wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE {$where}",$params)); $rows=$wpdb->get_results($wpdb->prepare("SELECT * FROM {$table} WHERE {$where} ORDER BY id DESC LIMIT %d OFFSET %d",array_merge($params,[$limit,$offset])),ARRAY_A); return ['items'=>$rows?:[],'total'=>$total,'page'=>$page,'per_page'=>$limit,'max_pages'=>max(1,(int)ceil($total/$limit))];
global $wpdb; $table=$this->table('commission_ledger'); $limit=max(1,min(100,(int)($args['per_page']??20))); $page=max(1,(int)($args['page']??1)); $offset=($page-1)*$limit; $where='promoter_id=%d'; $params=[$user_id]; $status=sanitize_key((string)($args['status']??'')); if(in_array($status,['credited','cancelled'],true)){ $where.=' AND status=%s'; $params[]=$status; } $total=(int)$wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE {$where}",$params)); $rows=$wpdb->get_results($wpdb->prepare("SELECT * FROM {$table} WHERE {$where} ORDER BY id DESC LIMIT %d OFFSET %d",array_merge($params,[$limit,$offset])),ARRAY_A); return ['items'=>$rows?:[],'total'=>$total,'page'=>$page,'per_page'=>$limit,'max_pages'=>max(1,(int)ceil($total/$limit))];
}
public function register_routes(): void {
$list_args = [
@@ -268,6 +430,7 @@ final class CommissionLedger {
$withdrawal_args = $list_args;
$withdrawal_args['per_page']['default'] = 10;
$withdrawal_args['per_page']['maximum'] = 10;
$withdrawal_args['source'] = ['type' => 'string', 'enum' => ['balance', 'commission'], 'default' => 'commission'];
register_rest_route('aksrt/v1', '/earnings/withdrawals', [
[
'methods' => \WP_REST_Server::READABLE,
@@ -284,32 +447,10 @@ final class CommissionLedger {
'amount' => ['type' => 'string', 'required' => true, 'pattern' => '^(0|[1-9]\d*)(?:\.\d{1,2})?$'],
'method' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key'],
'account' => ['type' => 'string', 'required' => true, 'minLength' => 1, 'maxLength' => 500, 'sanitize_callback' => 'sanitize_text_field'],
'source' => ['type' => 'string', 'enum' => ['balance', 'commission'], 'default' => 'commission'],
],
],
]);
register_rest_route('aksrt/v1', '/payment/affiliate/ledger/(?P<id>\d+)/settle', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'admin_settle'],
'permission_callback' => [$this, 'check_admin_write_rest'],
'args' => ['id' => ['type' => 'integer', 'minimum' => 1, 'required' => true]],
]);
register_rest_route('aksrt/v1', '/payment/affiliate/ledger/(?P<id>\d+)/cancel', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'admin_cancel'],
'permission_callback' => [$this, 'check_admin_write_rest'],
'args' => ['id' => ['type' => 'integer', 'minimum' => 1, 'required' => true]],
]);
register_rest_route('aksrt/v1', '/payment/affiliate/overview', [
'methods' => \WP_REST_Server::READABLE,
'callback' => fn() => rest_ensure_response($this->admin_overview()),
'permission_callback' => [$this, 'check_admin_rest'],
]);
register_rest_route('aksrt/v1', '/payment/affiliate/ledger', [
'methods' => \WP_REST_Server::READABLE,
'callback' => fn(\WP_REST_Request $request) => rest_ensure_response($this->admin_ledger($request->get_params())),
'permission_callback' => [$this, 'check_admin_rest'],
'args' => $list_args,
]);
register_rest_route('aksrt/v1', '/payment/withdrawals/(?P<id>\d+)/transition', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'transition_withdrawal'],
@@ -341,66 +482,17 @@ final class CommissionLedger {
? true
: new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
}
public function admin_settle(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-commission-settle', get_current_user_id());
if (is_wp_error($operation_key)) return $operation_key;
$result = $this->settle_one((int) $request['id'], $operation_key);
if (!$result instanceof \AKSRT\Ledger\LedgerResult) {
return new \WP_REST_Response(['success' => false, 'message' => __('佣金结算失败', 'aksrt')], 409);
}
return new \WP_REST_Response(['success' => true, 'transaction_id' => $result->transaction_id, 'replayed' => $result->replayed], 200);
}
public function admin_cancel(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-commission-cancel', get_current_user_id());
if (is_wp_error($operation_key)) return $operation_key;
global $wpdb;
$id = (int) $request['id'];
$payload_hash = hash('sha256', (string) wp_json_encode(['id' => $id, 'status' => 'cancelled']));
if ($wpdb->query('START TRANSACTION') === false) {
return new \WP_Error('aksrt_commission_db', __('无法开启返佣取消事务', 'aksrt'), ['status' => 500]);
}
$row = $wpdb->get_row($wpdb->prepare("SELECT status, cancel_operation_key, cancel_payload_hash FROM {$this->table('commission_ledger')} WHERE id=%d FOR UPDATE", $id), ARRAY_A);
if (!is_array($row)) {
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_commission_missing', __('返佣记录不存在', 'aksrt'), ['status' => 404]);
}
$stored_key = (string) ($row['cancel_operation_key'] ?? '');
$stored_hash = (string) ($row['cancel_payload_hash'] ?? '');
if ($stored_key !== '') {
if (hash_equals($stored_key, $operation_key) && $stored_hash !== '' && hash_equals($stored_hash, $payload_hash) && (string) $row['status'] === 'cancelled') {
if ($wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_commission_db', __('返佣取消重放失败', 'aksrt'), ['status' => 500]);
}
return new \WP_REST_Response(['success' => true, 'operation_key' => $operation_key, 'transaction_id' => 0, 'replayed' => true], 200);
}
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_commission_idempotency_conflict', __('相同操作标识已用于其他返佣取消请求', 'aksrt'), ['status' => 409]);
}
if ((string) ($row['status'] ?? '') !== 'pending') {
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]);
}
$updated = $wpdb->update($this->table('commission_ledger'), [
'status' => 'cancelled',
'cancel_operation_key' => $operation_key,
'cancel_payload_hash' => $payload_hash,
], ['id' => $id, 'status' => 'pending'], ['%s', '%s', '%s'], ['%d', '%s']);
if ($updated !== 1 || $wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
$conflict = $wpdb->get_var($wpdb->prepare("SELECT id FROM {$this->table('commission_ledger')} WHERE cancel_operation_key=%s", $operation_key));
return new \WP_Error($conflict ? 'aksrt_commission_idempotency_conflict' : 'aksrt_commission_db', $conflict ? __('相同操作标识已用于其他返佣取消请求', 'aksrt') : __('返佣取消失败', 'aksrt'), ['status' => $conflict ? 409 : 500]);
}
return new \WP_REST_Response(['success' => true, 'operation_key' => $operation_key, 'transaction_id' => 0, 'replayed' => false], 200);
}
public function admin_overview(): array { global $wpdb; $table=$this->table('commission_ledger'); $row=$wpdb->get_row("SELECT COUNT(*) total, SUM(status='pending') pending, SUM(status='settled') settled, COALESCE(SUM(original_amount+adjustment_total),0) amount FROM {$table}",ARRAY_A) ?: []; return ['total'=>(int)($row['total']??0),'pending'=>(int)($row['pending']??0),'settled'=>(int)($row['settled']??0),'amount'=>$this->balance_decimal((string)($row['amount']??'0'))]; }
public function admin_ledger(array $args): array { global $wpdb; $table=$this->table('commission_ledger'); $limit=max(1,min(100,(int)($args['per_page']??20))); $page=max(1,(int)($args['page']??1)); $offset=($page-1)*$limit; $status=sanitize_key((string)($args['status']??'')); $where='1=1'; $params=[]; if(in_array($status,['pending','settled','cancelled'],true)){ $where.=' AND status=%s'; $params[]=$status; } $total=(int)($wpdb->get_var($params?$wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE {$where}",$params):"SELECT COUNT(*) FROM {$table}")); $rows=$wpdb->get_results($wpdb->prepare("SELECT * FROM {$table} WHERE {$where} ORDER BY id DESC LIMIT %d OFFSET %d",array_merge($params,[$limit,$offset])),ARRAY_A); return ['items'=>$rows?:[],'total'=>$total,'page'=>$page,'per_page'=>$limit,'max_pages'=>max(1,(int)ceil($total/$limit))]; }
public function withdrawals(int $user_id, array $args): array {
public function withdrawals(int $user_id, array $args): array {
$service = \AKSRT\Payment\Withdrawal::get_instance();
$page = max(1, (int) ($args['page'] ?? 1));
$per_page = 10;
$status = sanitize_key((string) ($args['status'] ?? ''));
$filters = ['user_id' => $user_id];
// 提现记录按来源隔离:历史弹窗仅在余额页调用(无 source),默认 balance;
// 佣金页如需查询佣金提现记录须显式传 source=commission。
$source = sanitize_key((string) ($args['source'] ?? '')) === \AKSRT\Payment\Withdrawal::SOURCE_COMMISSION
? \AKSRT\Payment\Withdrawal::SOURCE_COMMISSION
: \AKSRT\Payment\Withdrawal::SOURCE_BALANCE;
$filters = ['user_id' => $user_id, 'source' => $source];
if ($status !== '') {
$filters['status'] = $status;
}
@@ -410,6 +502,7 @@ final class CommissionLedger {
return [
'items' => $service->get_user_withdrawals($user_id, [
'status' => $status,
'source' => $source,
'limit' => $per_page,
'offset' => ($page - 1) * $per_page,
]),
@@ -420,16 +513,29 @@ final class CommissionLedger {
];
}
public function create_withdrawal(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
$user_id=get_current_user_id(); $method=sanitize_key((string)$request->get_param('method')); $account=sanitize_text_field((string)$request->get_param('account')); $settings=\AKSRT\Settings\Settings::get_withdrawal_settings(); $affiliate=\AKSRT\Settings\Settings::get_affiliate_settings();
$user_id=get_current_user_id(); $method=sanitize_key((string)$request->get_param('method')); $account=sanitize_text_field((string)$request->get_param('account')); $settings=\AKSRT\Settings\Settings::get_withdrawal_settings();
// 提现来源:commission → 佣金池;其余 → 余额钱包(充值 + 创作分成)。
$source = sanitize_key((string) $request->get_param('source')) === \AKSRT\Payment\Withdrawal::SOURCE_COMMISSION
? \AKSRT\Payment\Withdrawal::SOURCE_COMMISSION
: \AKSRT\Payment\Withdrawal::SOURCE_BALANCE;
$raw_amount=$request->get_param('amount');
try { $amount=\AKSRT\Ledger\AssetAmount::balance(is_string($raw_amount)?$raw_amount:''); }
catch (\InvalidArgumentException $e) { return new \WP_Error('affiliate_withdraw_amount',__('提现金额格式无效。','aksrt'),['status'=>400]); }
$operation_key=\AKSRT\Ledger\OperationKey::from_request($request,'affiliate-withdraw',$user_id); if(is_wp_error($operation_key)) return $operation_key;
if (empty($affiliate['enabled'])) return new \WP_Error('affiliate_disabled',__('推广返佣未启用。','aksrt'),['status'=>403]);
// 来源各自独立的开关闸门:佣金提现需返佣启用;余额提现需余额提现开关。
if ($source === \AKSRT\Payment\Withdrawal::SOURCE_COMMISSION) {
if (empty(\AKSRT\Settings\Settings::get_affiliate_settings()['enabled'])) return new \WP_Error('affiliate_disabled',__('推广返佣未启用。','aksrt'),['status'=>403]);
} else {
$all=\AKSRT\Settings\Settings::get_all();
if (empty($all['pay_balance_withdraw_s'])) return new \WP_Error('balance_withdraw_disabled',__('余额提现未启用。','aksrt'),['status'=>403]);
}
if (class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Settings\Capabilities::user_can('balance_withdraw', $user_id)) {
return new \WP_Error('withdraw_forbidden',__('当前账号不可提现。','aksrt'),['status'=>403]);
}
$minimum=\AKSRT\Ledger\AssetAmount::balance(is_string($settings['min_amount'] ?? null) ? $settings['min_amount'] : '');
if ($amount->minor() < $minimum->minor()) return new \WP_Error('affiliate_withdraw_min',__('未达到最低提现金额。','aksrt'),['status'=>400]);
if (!in_array($method,(array)$settings['payout_channels'],true) || $account==='') return new \WP_Error('affiliate_withdraw_account',__('请提供有效的收款渠道和收款码。','aksrt'),['status'=>400]);
$result=\AKSRT\Payment\Withdrawal::get_instance()->create_balance_request($user_id,$amount->minor(),$method,$account,$settings,$operation_key); if(is_wp_error($result)) return $result; return new \WP_REST_Response(['success'=>true,'data'=>$result],201);
$result=\AKSRT\Payment\Withdrawal::get_instance()->create_balance_request($user_id,$amount->minor(),$method,$account,$settings,$operation_key,$source); if(is_wp_error($result)) return $result; return new \WP_REST_Response(['success'=>true,'data'=>$result],201);
}
public function admin_withdrawals(array $args): array { $service=\AKSRT\Payment\Withdrawal::get_instance(); return ['items'=>$service->get_all(['status'=>sanitize_key((string)($args['status']??'')),'limit'=>max(1,min(100,(int)($args['per_page']??20))),'offset'=>max(0,((int)($args['page']??1)-1)*max(1,(int)($args['per_page']??20)))]),'stats'=>$service->get_stats()]; }
public function transition_withdrawal(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
-1
View File
@@ -364,7 +364,6 @@ final class ReferralAttribution {
'order_types' => (array) ($affiliate['order_types'] ?? ['membership', 'content']),
'rules' => [
'window_days' => (int) ($this->shared_settings()['window_days'] ?? 30),
'settlement_days' => (int) ($affiliate['settlement_days'] ?? 7),
],
], 200);
}
+63 -22
View File
@@ -23,10 +23,26 @@ class Withdrawal {
return self::$instance;
}
public const SOURCE_BALANCE = 'balance';
public const SOURCE_COMMISSION = 'commission';
private function table(): string {
return \AKSRT\Core\DBMigrator::table('withdrawal');
}
/**
* 提现来源 → 资产池映射。commission 走独立佣金池,其余归余额钱包。
*/
private function source_asset(string $source): \AKSRT\Ledger\AssetType {
return sanitize_key($source) === self::SOURCE_COMMISSION
? \AKSRT\Ledger\AssetType::COMMISSION
: \AKSRT\Ledger\AssetType::BALANCE;
}
private function normalize_source(string $source): string {
return sanitize_key($source) === self::SOURCE_COMMISSION ? self::SOURCE_COMMISSION : self::SOURCE_BALANCE;
}
/**
* Daily withdrawal window in the configured WordPress timezone.
* @return array{start:string,end:string}
@@ -43,14 +59,14 @@ class Withdrawal {
}
/** @return array{count:int,total:string,remaining_count:int,remaining_amount:string,window:array{start:string,end:string}} */
public function daily_usage(int $user_id, array $settings): array {
public function daily_usage(int $user_id, array $settings, string $source = self::SOURCE_BALANCE): array {
$window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00'));
return $this->daily_usage_in_window($user_id, $settings, $window);
return $this->daily_usage_in_window($user_id, $settings, $window, $source);
}
/** @return array{count:int,total:string,remaining_count:int,remaining_amount:string,window:array{start:string,end:string}} */
private function daily_usage_in_window(int $user_id, array $settings, array $window): array {
$usage = $this->daily_usage_minor_in_window($user_id, $settings, $window);
private function daily_usage_in_window(int $user_id, array $settings, array $window, string $source = self::SOURCE_BALANCE): array {
$usage = $this->daily_usage_minor_in_window($user_id, $settings, $window, false, $source);
return [
'count' => $usage['count'],
'total' => $this->balance_decimal($usage['total_minor']),
@@ -61,13 +77,16 @@ class Withdrawal {
}
/** @return array{count:int,total_minor:int,remaining_count:int,remaining_minor:int,window:array{start:string,end:string}} */
private function daily_usage_minor_in_window(int $user_id, array $settings, array $window, bool $lock = false): array {
private function daily_usage_minor_in_window(int $user_id, array $settings, array $window, bool $lock = false, string $source = self::SOURCE_BALANCE): array {
global $wpdb;
// 每个资金池(source)独立计每日次数/额度:按 account_info 里的 source 标记过滤。
$source_like = '%' . $wpdb->esc_like('"source":"' . $this->normalize_source($source) . '"') . '%';
$sql = $wpdb->prepare(
"SELECT id, gross_amount, amount FROM {$this->table()} WHERE user_id = %d AND created_at >= %s AND created_at < %s ORDER BY id ASC" . ($lock ? ' FOR UPDATE' : ''),
"SELECT id, gross_amount, amount FROM {$this->table()} WHERE user_id = %d AND created_at >= %s AND created_at < %s AND account_info LIKE %s ORDER BY id ASC" . ($lock ? ' FOR UPDATE' : ''),
$user_id,
$window['start'],
$window['end']
$window['end'],
$source_like
);
$rows = (array) $wpdb->get_results($sql, ARRAY_A);
$total_minor = 0;
@@ -101,11 +120,13 @@ class Withdrawal {
* Atomically validates quotas, freezes wallet funds, writes the ledger and creates an order.
* @return array{withdrawal_id:int,withdrawal_number:string,net_amount:string,wallet_balance:string,transaction_id:int,flow_number:string,replayed:bool,usage:array}|\WP_Error
*/
public function create_balance_request(int $user_id, int $gross_minor, string $method, string $account, array $settings, string $operation_key) {
public function create_balance_request(int $user_id, int $gross_minor, string $method, string $account, array $settings, string $operation_key, string $source = self::SOURCE_BALANCE) {
global $wpdb;
if ($user_id <= 0 || $gross_minor <= 0) {
return new \WP_Error('aksrt_withdraw_amount', __('提现金额必须大于零', 'aksrt'), ['status' => 400]);
}
$source = $this->normalize_source($source);
$asset = $this->source_asset($source);
$fee_rate = max(0, min(50, (int) ($settings['service_charge_rate'] ?? 5)));
$fee_minor = (intdiv($gross_minor, 100) * $fee_rate) + intdiv(($gross_minor % 100) * $fee_rate, 100);
$net_minor = $gross_minor - $fee_minor;
@@ -116,20 +137,24 @@ class Withdrawal {
$usage = null;
$withdrawal_id = 0;
$withdrawal_number = '';
$guard = function () use ($user_id, $gross_minor, $settings, $window, &$usage) {
$source_like = '%' . $wpdb->esc_like('"source":"' . $source . '"') . '%';
$guard = function () use ($user_id, $gross_minor, $settings, $window, $source, $source_like, &$usage) {
global $wpdb;
// 每个资金池独立的「已有待处理申请」闸门:只锁定同 source 的 pending/processing 行。
$wpdb->get_results($wpdb->prepare(
"SELECT id FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing') ORDER BY id ASC FOR UPDATE",
$user_id
"SELECT id FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing') AND account_info LIKE %s ORDER BY id ASC FOR UPDATE",
$user_id,
$source_like
));
$open = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing')",
$user_id
"SELECT COUNT(*) FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing') AND account_info LIKE %s",
$user_id,
$source_like
));
if ($open > 0) {
return new \WP_Error('withdraw_pending', __('已有待处理提现申请', 'aksrt'), ['status' => 409]);
}
$usage = $this->daily_usage_minor_in_window($user_id, $settings, $window, true);
$usage = $this->daily_usage_minor_in_window($user_id, $settings, $window, true, $source);
if ($usage['remaining_count'] <= 0) {
return new \WP_Error('withdraw_count_limit', __('当日提现次数已用尽', 'aksrt'), ['status' => 409]);
}
@@ -138,13 +163,13 @@ class Withdrawal {
}
return true;
};
$mutation = function (int $transaction_id) use ($user_id, $gross_minor, $net_minor, $fee_rate, $method, $account, $operation_key, &$withdrawal_id, &$withdrawal_number) {
$mutation = function (int $transaction_id) use ($user_id, $gross_minor, $net_minor, $fee_rate, $method, $account, $operation_key, $source, &$withdrawal_id, &$withdrawal_number) {
global $wpdb;
$payload = wp_json_encode([
'account' => $account,
'gross_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $gross_minor)->decimal(),
'fee_rate' => $fee_rate,
'source' => 'balance',
'source' => $source,
'operation_key' => $operation_key,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $account;
$ok = false;
@@ -173,14 +198,14 @@ class Withdrawal {
};
$result = \AKSRT\Ledger\LedgerService::get_instance()->debit(
$user_id,
\AKSRT\Ledger\AssetType::BALANCE,
$asset,
$gross_minor,
$operation_key,
'withdraw_hold',
['type' => 'withdraw', 'key' => $operation_key],
__('余额提现申请冻结', 'aksrt'),
$source === self::SOURCE_COMMISSION ? __('推广佣金提现申请冻结', 'aksrt') : __('余额提现申请冻结', 'aksrt'),
$user_id,
['fee_minor' => $fee_minor, 'net_minor' => $net_minor],
['fee_minor' => $fee_minor, 'net_minor' => $net_minor, 'source' => $source],
$mutation,
$guard
);
@@ -214,7 +239,8 @@ class Withdrawal {
'withdrawal_number' => $withdrawal_number,
'net_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $net_minor)->decimal(),
'wallet_balance' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($user_id, \AKSRT\Ledger\AssetType::BALANCE))->decimal(),
'source' => $source,
'wallet_balance' => \AKSRT\Ledger\AssetAmount::from_minor($asset, $result->balance($user_id, $asset))->decimal(),
'transaction_id' => $result->transaction_id,
'flow_number' => $result->flow_number,
'replayed' => $result->replayed,
@@ -388,7 +414,9 @@ class Withdrawal {
: new \WP_Error('aksrt_withdraw_update', __('提现状态更新失败', 'aksrt'), ['status' => 500]);
};
if ($new_status === self::STATUS_REJECTED && $this->is_balance_source($meta) && $user_id > 0) {
// 拒绝提现时把冻结毛额退回原始资金池(balance→余额钱包,commission→佣金池)。
$refund_source = $this->normalize_source((string) ($meta['source'] ?? self::SOURCE_BALANCE));
if ($new_status === self::STATUS_REJECTED && $user_id > 0) {
try {
$gross_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($row['gross_amount'] ?? ''))->minor();
} catch (\InvalidArgumentException $e) {
@@ -399,7 +427,7 @@ class Withdrawal {
}
$ledger_result = \AKSRT\Ledger\LedgerService::get_instance()->credit(
$user_id,
\AKSRT\Ledger\AssetType::BALANCE,
$this->source_asset($refund_source),
$gross_minor,
$operation_key !== '' ? $operation_key : \AKSRT\Ledger\OperationKey::deterministic('withdraw-refund', [$withdrawal_id]),
'withdraw_refund',
@@ -549,6 +577,13 @@ class Withdrawal {
}
}
// 按资金池来源过滤:收益页只看佣金提现,余额页只看余额提现。
$source = sanitize_key((string) ($args['source'] ?? ''));
if ($source === self::SOURCE_BALANCE || $source === self::SOURCE_COMMISSION) {
$where[] = 'account_info LIKE %s';
$params[] = '%' . $wpdb->esc_like('"source":"' . $source . '"') . '%';
}
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
@@ -608,6 +643,12 @@ class Withdrawal {
$where[] = 'user_id = %d';
$params[] = $user_id;
}
// 资金池来源过滤:commission / balance 各自独立统计。
$source = sanitize_key((string) ($args['source'] ?? ''));
if (in_array($source, [self::SOURCE_BALANCE, self::SOURCE_COMMISSION], true)) {
$where[] = 'account_info LIKE %s';
$params[] = '%' . $wpdb->esc_like('"source":"' . $source . '"') . '%';
}
$keyword = trim(sanitize_text_field((string) ($args['keyword'] ?? '')));
if ($keyword !== '') {
$like = '%' . $wpdb->esc_like($keyword) . '%';
-1
View File
@@ -162,7 +162,6 @@ trait CreditsPaymentGroups {
public static function affiliate_ratio_for_user(int $user_id = 0): int { return PaymentRuntime::affiliate_ratio_for_user($user_id > 0 ? $user_id : get_current_user_id()); }
public static function affiliate_ratio_for_level(int $level): int { return PaymentRuntime::affiliate_ratio_for_level($level); }
public static function get_affiliate_settings(): array { return PaymentRuntime::get_affiliate_settings(); }
public static function get_settlement_settings(): array { return PaymentRuntime::get_settlement_settings(); }
/**
* @return array<string,mixed>
-5
View File
@@ -341,7 +341,6 @@ class PaymentRuntime {
'order_types_by_level' => $types_by_level,
'disabled_code_stops_commission' => !empty($settings['affiliate_disabled_code_stops_commission']),
'window_days' => (int) CreditsRuntime::get_referral_settings()['window_days'],
'settlement_days' => max(0, min(90, (int) ($settings['affiliate_settlement_days'] ?? 7))),
'promotion_text' => sanitize_textarea_field((string) ($settings['pay_rebate_text_details'] ?? '')),
'details_title' => sanitize_text_field((string) ($settings['pay_rebate_text_details_title'] ?? '返佣详解')),
'poster' => [
@@ -375,10 +374,6 @@ class PaymentRuntime {
return (int) ($settings['ratios'][$key] ?? 0);
}
public static function get_affiliate_settings(): array { return self::get_payment_affiliate_settings(); }
public static function get_settlement_settings(): array {
$s = self::get_payment_affiliate_settings();
return ['observation_days' => (int) $s['settlement_days'], 'retry_limit' => 100];
}
/**
* Gateway channel selection. WeChat / Alipay each pick between the
@@ -135,7 +135,7 @@ test('core asset paths never use floating-point money helpers', () => {
test('points orders bypass cash commissions and purchase dead paths stay removed', () => {
const commission = fs.readFileSync(path.join(root, 'src', 'Payment', 'CommissionLedger.php'), 'utf8')
const paidStart = commission.indexOf('public function record_paid_order')
const paidEnd = commission.indexOf('public function settle_due', paidStart)
const paidEnd = commission.indexOf('private function credit_commission', paidStart)
const paidMethod = commission.slice(paidStart, paidEnd)
const pointsGuard = paidMethod.indexOf('AssetType::POINTS->value')
const affiliateGuard = paidMethod.indexOf('Settings::affiliate_enabled()')
@@ -61,19 +61,29 @@ test('commission base is the discounted payable amount, not the list price', ()
assert.match(ledger, /AssetAmount::balance\(is_string\(\$order\['amount'\] \?\? null\) \? \$order\['amount'\] : ''\)/)
})
test('settlement stays off the read path and runs on the cron schedule', () => {
test('commission is credited immediately with no settlement freeze', () => {
const ledger = read('src', 'Payment', 'CommissionLedger.php')
const attribution = read('src', 'Payment', 'ReferralAttribution.php')
// 懒结算兜底已移除:读路径不得顺带写库(收益页打开慢的主因)。
assert.doesNotMatch(ledger, /lazy_settle_due|\$lazy_settled/)
// overview() 与 rest_me() 都不再触发结算。
assert.doesNotMatch(ledger, /\$this->lazy_settle_due\(\)/)
assert.doesNotMatch(attribution, /lazy_settle_due/)
assert.doesNotMatch(attribution, /->stats\(/)
// 结算只由 hourly 的 wp-cron 事件驱动。
assert.match(ledger, /add_action\('aksrt_affiliate_settle_due', \[\$this, 'settle_due'\]\)/)
assert.match(ledger, /wp_schedule_event\(time\(\) \+ HOUR_IN_SECONDS, 'hourly', 'aksrt_affiliate_settle_due'\)/)
// 即时入账模型:不再有结算冻结期 / 结算 cron / settle_due 方法。
assert.doesNotMatch(ledger, /aksrt_affiliate_settle_due'?, \[\$this, 'settle_due'\]/)
assert.doesNotMatch(ledger, /wp_schedule_event\([^)]*aksrt_affiliate_settle_due/)
assert.doesNotMatch(ledger, /function settle_due|function settle_one/)
assert.doesNotMatch(ledger, /available_at|settled_at/)
// 遗留结算 cron 需在 init() 主动清理。
assert.match(ledger, /wp_unschedule_event\([^)]*aksrt_affiliate_settle_due/)
// 下单支付即把佣金 credit 到 COMMISSION 资金池。
assert.match(ledger, /AssetType::COMMISSION/)
assert.match(ledger, /private function credit_commission/)
// 佣金明细行状态为 credited(非 pending 冻结)。
assert.match(ledger, /'status'\s*=>\s*'credited'/)
})
test('referral codes are self-serve: no admin ops page, granted at registration', () => {
+1 -1
View File
@@ -1,5 +1,5 @@
{
"generated_at": "2026-09-27T07:36:13+00:00",
"generated_at": "2026-09-28T13:07:05+00:00",
"errors": [],
"warnings": [
"customPage tools-update section 'tools/update' not in PHP schema (ok if pure operational page)",