diff --git a/assets/css/routes/user-center.css b/assets/css/routes/user-center.css
index 1a687e10..909fe89e 100644
--- a/assets/css/routes/user-center.css
+++ b/assets/css/routes/user-center.css
@@ -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");
diff --git a/assets/css/routes/user-center/revenue-earnings.css b/assets/css/routes/user-center/revenue-earnings.css
index 4d5bc9de..c7ff5643 100644
--- a/assets/css/routes/user-center/revenue-earnings.css
+++ b/assets/css/routes/user-center/revenue-earnings.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 推广收益:佣金详细页采用紧凑的一列信息清单。 */
diff --git a/assets/css/routes/user-center/revenue.css b/assets/css/routes/user-center/revenue.css
index 80e7f41e..bc8139ba 100644
--- a/assets/css/routes/user-center/revenue.css
+++ b/assets/css/routes/user-center/revenue.css
@@ -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));
}
diff --git a/assets/js/credits.js b/assets/js/credits.js
index 9572507f..218fce17 100644
--- a/assets/js/credits.js
+++ b/assets/js/credits.js
@@ -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;
diff --git a/assets/js/routes/user-center.js b/assets/js/routes/user-center.js
index 1f602431..dcebd010 100644
--- a/assets/js/routes/user-center.js
+++ b/assets/js/routes/user-center.js
@@ -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();
diff --git a/inc/helpers/user-center-renderers/profile-revenue.php b/inc/helpers/user-center-renderers/profile-revenue.php
index a990627b..23bc2ad5 100644
--- a/inc/helpers/user-center-renderers/profile-revenue.php
+++ b/inc/helpers/user-center-renderers/profile-revenue.php
@@ -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'),
];
?>
+
-
-
-
-
-
-
-
-
-
-
%
-
-
-
+
>
+
-
-
-
-
-
- display_name : __('推广用户', 'aksrt');
- ?>
-
-
-
-
- 1) : ?>
-
-
-
-
-
-
-
-
-
 ($item['avatar'] ?? '')); ?>)
-
-
- 1) : ?>
-
-
+
+
+
+
>
+
+
+
+ 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));
+ ?>
+
+
+
+ 1) : ?>
+
+
+
+
>
+
+
+
+
+
 ($item['avatar'] ?? '')); ?>)
+
+
+ 1) : ?>
+
+
'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' => [
diff --git a/src/Core/DBMigrator.php b/src/Core/DBMigrator.php
index a419cd1d..d85c6af1 100644
--- a/src/Core/DBMigrator.php
+++ b/src/Core/DBMigrator.php
@@ -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';
diff --git a/src/Core/DBMigratorPart2.php b/src/Core/DBMigratorPart2.php
index b9952fb9..5022f034 100644
--- a/src/Core/DBMigratorPart2.php
+++ b/src/Core/DBMigratorPart2.php
@@ -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,
diff --git a/src/Core/DBMigratorPart3.php b/src/Core/DBMigratorPart3.php
index 04f9dc15..9b89e4b1 100644
--- a/src/Core/DBMigratorPart3.php
+++ b/src/Core/DBMigratorPart3.php
@@ -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',
diff --git a/src/Ledger/AssetAmount.php b/src/Ledger/AssetAmount.php
index f13e4a2d..76ba2a8a 100644
--- a/src/Ledger/AssetAmount.php
+++ b/src/Ledger/AssetAmount.php
@@ -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();
}
diff --git a/src/Ledger/AssetType.php b/src/Ledger/AssetType.php
index 9e9394c1..37fb1793 100644
--- a/src/Ledger/AssetType.php
+++ b/src/Ledger/AssetType.php
@@ -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;
+ }
}
diff --git a/src/Ledger/OperationKey.php b/src/Ledger/OperationKey.php
index 363c001b..618d988c 100644
--- a/src/Ledger/OperationKey.php
+++ b/src/Ledger/OperationKey.php
@@ -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'));
diff --git a/src/Payment/BalanceGateway.php b/src/Payment/BalanceGateway.php
index 5cf87d4e..e5f37e92 100644
--- a/src/Payment/BalanceGateway.php
+++ b/src/Payment/BalanceGateway.php
@@ -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,
diff --git a/src/Payment/CommissionLedger.php b/src/Payment/CommissionLedger.php
index 05e6356e..fc0da93e 100644
--- a/src/Payment/CommissionLedger.php
+++ b/src/Payment/CommissionLedger.php
@@ -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
|\WP_Error */
+ }
+ /**
+ * 退款准备:返回本订单待收回的佣金计划。即时入账模型下佣金状态只有
+ * credited | cancelled,可收回额 = original + 既有调整(正值)。
+ *
+ * @return array|\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 $plan */
+ /**
+ * 退款守卫(在买家退款账务事务内运行):锁定佣金明细行并复核未变化。
+ * 仅校验明细行,不触碰资金池,故不会因促推者池不足而使买家退款失败。
+ *
+ * @param array $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 $plan */
+ /**
+ * 退款应用(在买家退款账务事务内运行):仅做佣金明细账的登记——冲正
+ * adjustment_total 并置为 cancelled,写入不可变调整记录。这里不移动资金池
+ * (避免池不足时中止买家退款);真正的池收回 + 欠款挂账由 clawback_after_refund
+ * 在退款事务提交后独立执行。
+ *
+ * @param array $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 $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\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\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\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 {
diff --git a/src/Payment/ReferralAttribution.php b/src/Payment/ReferralAttribution.php
index ebcbecfa..946b8db6 100644
--- a/src/Payment/ReferralAttribution.php
+++ b/src/Payment/ReferralAttribution.php
@@ -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);
}
diff --git a/src/Payment/Withdrawal.php b/src/Payment/Withdrawal.php
index cbe7963a..66304a49 100644
--- a/src/Payment/Withdrawal.php
+++ b/src/Payment/Withdrawal.php
@@ -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) . '%';
diff --git a/src/Settings/CreditsPaymentGroups.php b/src/Settings/CreditsPaymentGroups.php
index ddecbf01..b9045eb6 100644
--- a/src/Settings/CreditsPaymentGroups.php
+++ b/src/Settings/CreditsPaymentGroups.php
@@ -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
diff --git a/src/Settings/PaymentRuntime.php b/src/Settings/PaymentRuntime.php
index acfe54fb..a7ca17ef 100644
--- a/src/Settings/PaymentRuntime.php
+++ b/src/Settings/PaymentRuntime.php
@@ -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
diff --git a/tests/widget-system/asset-ledger-contract.test.cjs b/tests/widget-system/asset-ledger-contract.test.cjs
index a35d0d15..80ae7c42 100644
--- a/tests/widget-system/asset-ledger-contract.test.cjs
+++ b/tests/widget-system/asset-ledger-contract.test.cjs
@@ -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()')
diff --git a/tests/widget-system/referral-policy-contract.test.cjs b/tests/widget-system/referral-policy-contract.test.cjs
index fa4a055d..725ca490 100644
--- a/tests/widget-system/referral-policy-contract.test.cjs
+++ b/tests/widget-system/referral-policy-contract.test.cjs
@@ -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', () => {
diff --git a/tools/admin-contract-report.json b/tools/admin-contract-report.json
index e7ebf389..df218467 100644
--- a/tools/admin-contract-report.json
+++ b/tools/admin-contract-report.json
@@ -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)",