feat: upgrade widgets and affiliate commission flow #2

Merged
kate522 merged 1 commits from worktree/fix-tools-all into main 2026-08-16 10:30:15 +00:00
33 changed files with 709 additions and 227 deletions
Showing only changes of commit 698a9beb74 - Show all commits
@@ -72,3 +72,24 @@
.uc-content .uc-promotion-copy { width: 100%; }
.uc-content .uc-promotion-record { align-items: flex-start; }
}
/* New affiliate contract cards. */
.uc-content .aksrt-affiliate-panel { display:grid; gap:18px; padding:22px; border:1px solid var(--border,rgba(0,0,0,.08)); border-radius:var(--card-radius,16px); background:var(--surface, #fff); box-shadow:0 8px 24px rgba(15,23,42,.06); }
.uc-content .uc-panel-heading h2 { margin:0 0 4px; color:var(--text,#111827); }
.uc-content .uc-panel-heading p,.uc-content .uc-affiliate-note { margin:0; color:var(--muted,#6b7280); line-height:1.6; }
.uc-content .uc-affiliate-link-card { display:flex; align-items:end; gap:10px; padding:14px; border-radius:12px; background:var(--surface-muted,#f8fafc); }
.uc-content .uc-affiliate-link-card>div { flex:1; min-width:0; display:grid; gap:6px; }
.uc-content .uc-affiliate-link-card input { width:100%; min-width:0; height:40px; padding:0 10px; border:1px solid var(--border,#e5e7eb); border-radius:8px; background:var(--surface,#fff); color:var(--text,#111827); }
.uc-content .uc-affiliate-link-card button { white-space:nowrap; }
.uc-content .uc-affiliate-stats { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; }
.uc-content .uc-affiliate-stats article { display:grid; gap:5px; padding:14px; border:1px solid var(--border,#e5e7eb); border-radius:12px; }
.uc-content .uc-affiliate-stats article:first-child strong { color:var(--danger,#dc2626); }
.uc-content .uc-affiliate-stats article strong { color:var(--primary,#2563eb); font-size:24px; font-variant-numeric:tabular-nums; }
.uc-content .uc-affiliate-link-card [data-affiliate-feedback] { grid-column:1/-1; margin:0; min-height:18px; color:var(--success,#16a34a); font-size:12px; }
.uc-content .uc-affiliate-link-card [data-affiliate-feedback].is-error { color:var(--danger,#dc2626); }
.uc-content .uc-affiliate-accounts { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; padding-top:4px; }
.uc-content .uc-affiliate-accounts label { display:grid; gap:5px; color:var(--muted,#6b7280); font-size:12px; }
.uc-content .uc-affiliate-accounts input { height:38px; padding:0 10px; border:1px solid var(--border,#e5e7eb); border-radius:8px; background:var(--surface,#fff); color:var(--text,#111827); }
.uc-content .uc-affiliate-accounts button { justify-self:start; }
.uc-content .uc-affiliate-accounts p { grid-column:1/-1; margin:0; color:var(--success,#16a34a); font-size:12px; }
@media (max-width:640px) { .uc-content .aksrt-affiliate-panel { padding:16px; } .uc-content .uc-affiliate-link-card { align-items:stretch; flex-direction:column; } .uc-content .uc-affiliate-link-card button { width:100%; } .uc-content .uc-affiliate-stats,.uc-content .uc-affiliate-accounts { grid-template-columns:1fr; } }
+36 -2
View File
@@ -383,6 +383,39 @@
});
}
// ===== Forward-only affiliate promotion center =====
function initAffiliatePromotion() {
var root = document.querySelector('[data-aksrt-promotion-page]');
if (!root || root.dataset.affiliateBound) return;
root.dataset.affiliateBound = '1';
var input = root.querySelector('[data-affiliate-link]');
var copy = root.querySelector('[data-affiliate-copy]');
var feedback = root.querySelector('[data-affiliate-feedback]');
function setFeedback(text, error) { if (feedback) { feedback.textContent = text; feedback.classList.toggle('is-error', !!error); } }
api('/promotion/me').then(function (r) {
if (!r.ok) { setFeedback((r.json && r.json.message) || '推广数据加载失败。', true); return; }
var j = r.json || {}, stats = j.stats || {};
if (input) input.value = j.url || '';
if (copy) copy.disabled = !j.url;
var ratio = root.querySelector('[data-affiliate-ratio]'); if (ratio) ratio.textContent = String(j.ratio || 0) + '%';
var promoters = root.querySelector('[data-affiliate-promoters]'); if (promoters) promoters.textContent = String(stats.promoters || 0);
var payers = root.querySelector('[data-affiliate-payers]'); if (payers) payers.textContent = String(stats.effective_payers || 0);
}).catch(function () { setFeedback('推广数据加载失败。', true); });
if (copy) copy.addEventListener('click', function () {
if (!input || !input.value) return;
var done = function () { setFeedback('推广链接已复制。'); };
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(input.value).then(done).catch(function () { input.focus(); input.select(); setFeedback('复制失败,请手动复制。', true); });
else { input.focus(); input.select(); try { document.execCommand('copy'); done(); } catch (e) { setFeedback('复制失败,请手动复制。', true); } }
});
var accountForm = root.querySelector('[data-affiliate-accounts]');
if (accountForm) accountForm.addEventListener('submit', function (event) {
event.preventDefault();
var feedback = accountForm.querySelector('[data-affiliate-account-feedback]');
var accounts = {}; new FormData(accountForm).forEach(function (value, key) { if (String(value).trim()) accounts[key] = String(value).trim(); });
api('/promotion/payout-accounts', { method: 'PUT', body: JSON.stringify({ accounts: accounts }) }).then(function (r) { if (feedback) feedback.textContent = r.ok ? '收款方式已保存。' : ((r.json && r.json.message) || '保存失败。'); }).catch(function () { if (feedback) feedback.textContent = '网络错误,请稍后重试。'; });
});
}
// ===== VIP 积分兑换 =====
@@ -917,9 +950,9 @@
}
busy = true;
setTip('正在提交…');
api('/pay/balance/withdraw', {
api('/earnings/withdrawals', {
method: 'POST',
body: JSON.stringify({ amount: amount, method: method, account_info: account })
body: JSON.stringify({ amount: amount, method: method, account: account })
}).then(function (r) {
busy = false;
if (!r.ok || (r.json && r.json.code && !r.json.success)) {
@@ -997,6 +1030,7 @@
initSignin();
initSigninGoto();
initInvite();
initAffiliatePromotion();
initVipShop();
initVipPointsExchange();
initCardPass();
+3 -3
View File
@@ -31,7 +31,7 @@
}
function request(payload) {
return fetch(rest + '/pay/balance/withdraw', {
return fetch(rest + '/earnings/withdrawals', {
method: 'POST',
credentials: 'same-origin',
headers: {
@@ -53,7 +53,7 @@
var values = new FormData(form);
var amount = Number(values.get('amount') || 0);
var method = String(values.get('method') || 'alipay');
var account = String(values.get('account_info') || '').trim();
var account = String(values.get('account') || values.get('account_info') || '').trim();
var minimum = Number(page.getAttribute('data-min-amount') || 50);
if (amount < minimum) {
setMessage('最低提现金额为 ' + minimum, true);
@@ -69,7 +69,7 @@
button.disabled = true;
button.textContent = '提交中…';
}
request({ amount: amount, method: method, account_info: account }).then(function (result) {
request({ amount: amount, method: method, account: account }).then(function (result) {
if (button) {
button.disabled = false;
button.textContent = '提交提现申请';
+6 -3
View File
@@ -202,6 +202,12 @@ if (class_exists('\AKSRT\Credits\Invite')) {
if (class_exists('AKSRT\\Payment\\CreatorRevenue')) {
\AKSRT\Payment\CreatorRevenue::get_instance()->init();
}
if (class_exists('AKSRT\\Payment\\ReferralAttribution')) {
\AKSRT\Payment\ReferralAttribution::get_instance()->init();
}
if (class_exists('AKSRT\\Payment\\CommissionLedger')) {
\AKSRT\Payment\CommissionLedger::get_instance()->init();
}
if (class_exists('\AKSRT\Content\Download')) {
\AKSRT\Content\Download::get_instance()->init();
}
@@ -409,9 +415,6 @@ if (is_admin() && class_exists('\AKSRT\Admin\Native\PayWithdrawals')) {
if (is_admin() && class_exists('\AKSRT\Admin\Native\PayIncome')) {
\AKSRT\Admin\Native\PayIncome::get_instance()->init();
}
if (is_admin() && class_exists('\AKSRT\Admin\Native\PayRebate')) {
\AKSRT\Admin\Native\PayRebate::get_instance()->init();
}
// WordPress 原生用户菜单:消息/邀请码/认证/禁封(zibll users.php 子菜单对齐)
if (is_admin() && class_exists('\AKSRT\Admin\Native\UsersMenus')) {
+7 -7
View File
@@ -133,8 +133,8 @@ if (!function_exists('aksrt_get_user_center_tab_url')) {
*/
function aksrt_get_user_center_tab_url(string $tab): string {
$tab = sanitize_key($tab);
if ('invite' === $tab || 'promotion' === $tab) {
$tab = 'revenue';
if ('invite' === $tab) {
$tab = 'promotion';
}
return aksrt_get_user_center_url($tab);
}
@@ -180,6 +180,7 @@ if (!function_exists('aksrt_capture_user_center_view')) {
'vip' => 'vip',
'orders' => 'orders',
'auth' => 'auth',
'promotion' => 'promotion',
'revenue' => 'revenue',
'settings' => 'settings',
'header' => 'header',
@@ -205,7 +206,7 @@ if (!function_exists('aksrt_capture_user_center_panel')) {
* Capture a built-in or registered user-center panel body.
*/
function aksrt_capture_user_center_panel(string $tab_key, array $tab, array $context): string {
$built_in_tabs = ['profile', 'level', 'creator', 'posts', 'follow', 'comments', 'favorites', 'credits', 'vip', 'orders', 'auth', 'revenue', 'settings'];
$built_in_tabs = ['profile', 'level', 'creator', 'posts', 'follow', 'comments', 'favorites', 'credits', 'vip', 'orders', 'auth', 'promotion', 'revenue', 'settings'];
if (in_array($tab_key, $built_in_tabs, true)) {
return aksrt_capture_user_center_view($tab_key, $context);
}
@@ -314,8 +315,8 @@ if (!function_exists('aksrt_user_center_resolve_tabs')) {
}
if ('signin' === $active || 'assets' === $active) {
$active = 'credits';
} elseif ('invite' === $active || 'promotion' === $active) {
$active = 'revenue';
} elseif ('invite' === $active) {
$active = 'promotion';
} elseif ('vip' === $active) {
// Prefer dedicated vip tab when registered; else credits (VIP shop).
$active = in_array('vip', $keys, true) ? 'vip' : 'credits';
@@ -354,8 +355,7 @@ if (!function_exists('aksrt_user_center_runtime_config')) {
'activeTab' => $active_tab,
'tabAliases' => [
'signin' => 'credits',
'invite' => 'revenue',
'promotion' => 'revenue',
'invite' => 'promotion',
'assets' => 'credits',
// vip → vip when panel exists; SPA may still map missing keys via server resolve.
'vip' => 'vip',
@@ -2,9 +2,7 @@
/**
* User center promotion panel (invite / 推广中心).
*
* Standalone dashboard tab that hosts the invite card. Reuses the existing
* invite REST contract and credits.js `initInvite()` binding via the
* `[data-aksrt-invite]` card in profile-invite.php.
* Standalone dashboard tab for the forward-only promotion contract.
*
* @package AKSRT
*/
@@ -18,8 +16,12 @@ if ($user_id <= 0) {
return;
}
$currency_name = (string) ($context['currency_name'] ?? __('积分', 'aksrt'));
$settings = class_exists('\\AKSRT\\Settings\\Settings') ? \AKSRT\Settings\Settings::get_affiliate_settings() : [];
?>
<div class="uc-promotion-page" data-aksrt-promotion-page>
<?php require __DIR__ . '/profile-invite.php'; ?>
</div>
<section class="uc-promotion-page aksrt-affiliate-panel" data-aksrt-promotion-page>
<header class="uc-panel-heading"><h2><?php esc_html_e('推广中心', 'aksrt'); ?></h2><p><?php echo esc_html($settings['promotion_text'] ?? __('分享推广链接,好友完成会员或付费内容订单后获得佣金。', 'aksrt')); ?></p></header>
<div class="uc-affiliate-link-card" data-affiliate-card><div><span class="muted-2-color"><?php esc_html_e('推广链接', 'aksrt'); ?></span><input type="text" readonly data-affiliate-link aria-label="<?php esc_attr_e('推广链接', 'aksrt'); ?>"></div><button type="button" class="btn-primary btn-sm" data-affiliate-copy><i class="fas fa-copy" aria-hidden="true"></i><?php esc_html_e('复制链接', 'aksrt'); ?></button><p data-affiliate-feedback role="status" aria-live="polite"></p></div>
<div class="uc-affiliate-stats" data-affiliate-stats><article><span><?php esc_html_e('当前比例', 'aksrt'); ?></span><strong data-affiliate-ratio>—</strong></article><article><span><?php esc_html_e('推广人数', 'aksrt'); ?></span><strong data-affiliate-promoters>—</strong></article><article><span><?php esc_html_e('有效付费人数', 'aksrt'); ?></span><strong data-affiliate-payers>—</strong></article></div>
<form class="uc-affiliate-accounts" data-affiliate-accounts><label><?php esc_html_e('支付宝收款码/账户', 'aksrt'); ?><input type="text" name="alipay" autocomplete="off"></label><label><?php esc_html_e('微信收款码/账户', 'aksrt'); ?><input type="text" name="wechat" autocomplete="off"></label><button type="submit" class="btn-secondary btn-sm"><?php esc_html_e('保存收款方式', 'aksrt'); ?></button><p data-affiliate-account-feedback role="status" aria-live="polite"></p></form>
<p class="uc-affiliate-note"><?php esc_html_e('仅会员和付费内容订单参与返佣;首次触达后 30 天内绑定,禁止自邀和换绑。', 'aksrt'); ?></p>
</section>
@@ -20,42 +20,33 @@ $affiliate = class_exists('\AKSRT\Settings\Settings')
$withdrawal = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_withdrawal_settings()
: [];
$all_settings = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_all()
: [];
$withdraw_on = !empty($all_settings['pay_balance_withdraw_s'])
$withdraw_on = !empty($affiliate['enabled'])
&& class_exists('\AKSRT\Credits\Balance')
&& \AKSRT\Credits\Balance::enabled();
if ($withdraw_on && class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Settings\Capabilities::user_can('balance_withdraw', $user_id)) {
$withdraw_on = false;
}
$withdraw_min = max(1, (float) ($all_settings['pay_balance_withdraw_lowest_money'] ?? $all_settings['pay_rebate_withdraw_lowest_money'] ?? $withdrawal['min_amount'] ?? 50));
$withdraw_min = max(1, (float) ($affiliate['min_withdrawal'] ?? $withdrawal['min_amount'] ?? 50));
$wallet_fmt = class_exists('\AKSRT\Credits\Balance')
? \AKSRT\Credits\Balance::format((float) \AKSRT\Credits\Balance::balance($user_id))
: '0.00';
$commission_stats = class_exists('\AKSRT\Payment\Rebate')
? \AKSRT\Payment\Rebate::get_instance()->get_stats($user_id)
: ['total' => 0, 'pending' => 0, 'pending_amount' => 0, 'settled' => 0, 'total_amount' => 0, 'settled_amount' => 0];
$commission_stats = class_exists('\AKSRT\Payment\CommissionLedger')
? \AKSRT\Payment\CommissionLedger::get_instance()->overview($user_id)
: ['total' => 0, 'pending' => 0, 'settled' => 0, 'wallet' => 0];
$available_commission = class_exists('\AKSRT\Credits\Balance')
? (float) \AKSRT\Credits\Balance::balance($user_id)
: (float) ($commission_stats['settled_amount'] ?? 0);
$vip_ratios = is_array($affiliate['user_ratio'] ?? null) ? $affiliate['user_ratio'] : [];
$commission_ratio = (int) ($vip_ratios['user'] ?? $affiliate['ratio'] ?? 0);
$current_vip = function_exists('aksrt_get_user_vip_level') ? (int) aksrt_get_user_vip_level($user_id) : 0;
if ($current_vip >= 2 && !empty($vip_ratios['vip2'])) {
$commission_ratio = (int) $vip_ratios['vip2'];
} elseif ($current_vip >= 1 && !empty($vip_ratios['vip1'])) {
$commission_ratio = (int) $vip_ratios['vip1'];
}
$details_text = trim((string) ($affiliate['text']['details'] ?? ''));
: (float) ($commission_stats['settled'] ?? 0);
$vip_ratios = [];
$commission_ratio = (float) ($affiliate['ratio'] ?? 0);
$details_text = trim((string) ($affiliate['earnings_text'] ?? ''));
if ($details_text === '') {
$details_text = trim((string) ($affiliate['note'] ?? '')) ?: __('好友通过推广链接完成符合条件的订单后,系统会按当前佣金比例记录返佣,结算后进入可提现余额。', 'aksrt');
$details_text = __('好友通过推广链接完成符合条件的订单后,系统会按当前佣金比例记录返佣,结算后进入可提现余额。', 'aksrt');
}
$revenue_request = is_array($context['revenue_request'] ?? null) ? $context['revenue_request'] : [];
$active_reward_tab = sanitize_key((string) ($revenue_request['reward_tab'] ?? ($_GET['reward_tab'] ?? 'detail')));
if (!in_array($active_reward_tab, ['detail', 'orders', 'users'], true)) {
if (!in_array($active_reward_tab, ['detail', 'orders'], true)) {
$active_reward_tab = 'detail';
}
$base_url = function_exists('aksrt_get_user_center_tab_url')
@@ -69,22 +60,20 @@ $format_amount = static function ($amount): string {
};
$commission_page = max(1, (int) ($revenue_request['commission_page'] ?? ($_GET['commission_page'] ?? 1)));
$commission_view = class_exists('\AKSRT\Payment\Rebate')
? \AKSRT\Payment\Rebate::get_instance()->get_list([
'user_id' => $user_id,
'limit' => 10,
'offset' => ($commission_page - 1) * 10,
$commission_view = class_exists('\AKSRT\Payment\CommissionLedger')
? \AKSRT\Payment\CommissionLedger::get_instance()->list_entries($user_id, [
'per_page' => 10,
'page' => $commission_page,
])
: ['items' => [], 'total' => 0, 'max_pages' => 1, 'page' => 1];
$commission_items = (array) ($commission_view['items'] ?? []);
$commission_total_pages = max(1, (int) ($commission_view['max_pages'] ?? ceil(max(1, (int) ($commission_view['total'] ?? 0)) / 10)));
if ($commission_page > $commission_total_pages) {
$commission_page = $commission_total_pages;
if (class_exists('\AKSRT\Payment\Rebate')) {
$commission_view = \AKSRT\Payment\Rebate::get_instance()->get_list([
'user_id' => $user_id,
'limit' => 10,
'offset' => ($commission_page - 1) * 10,
if (class_exists('\AKSRT\Payment\CommissionLedger')) {
$commission_view = \AKSRT\Payment\CommissionLedger::get_instance()->list_entries($user_id, [
'per_page' => 10,
'page' => $commission_page,
]);
}
}
@@ -110,7 +99,7 @@ $status_labels = [
<div class="uc-commission-card__body">
<span><?php esc_html_e('我的佣金', 'aksrt'); ?></span>
<strong><?php echo esc_html($format_amount($available_commission)); ?></strong>
<span><?php printf(esc_html__('累计佣金 %s,待结算 %s', 'aksrt'), esc_html($format_amount($commission_stats['total_amount'] ?? 0)), esc_html($format_amount($commission_stats['pending_amount'] ?? 0))); ?></span>
<span><?php printf(esc_html__('累计佣金 %s,待结算 %s', 'aksrt'), esc_html($format_amount($commission_stats['total'] ?? 0)), esc_html($format_amount($commission_stats['pending'] ?? 0))); ?></span>
</div>
<?php if ($withdraw_on) : ?>
<button type="button" class="uc-commission-card__action" data-aksrt-withdraw-open><i class="fas fa-arrow-right" aria-hidden="true"></i><?php esc_html_e('立即提现', 'aksrt'); ?></button>
@@ -120,35 +109,15 @@ $status_labels = [
<section class="uc-reward-area" aria-labelledby="uc-reward-area-title">
<div class="uc-reward-area__heading">
<h3 id="uc-reward-area-title"><?php esc_html_e('推广奖励', 'aksrt'); ?></h3>
<span><?php esc_html_e('邀请与返佣数据', 'aksrt'); ?></span>
<h3 id="uc-reward-area-title"><?php esc_html_e('收益明细', 'aksrt'); ?></h3>
<span><?php esc_html_e('佣金账本与提现状态', '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>
<nav class="uc-reward-tabs" aria-label="<?php esc_attr_e('收益明细', 'aksrt'); ?>">
<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>
<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>
</nav>
<?php if ($active_reward_tab === 'detail') : ?>
<div class="uc-reward-pane uc-reward-pane--detail">
<section class="uc-reward-block" aria-labelledby="uc-referral-link-title">
<div class="uc-reward-block__heading"><h4 id="uc-referral-link-title"><?php esc_html_e('推广链接', 'aksrt'); ?></h4><p><?php esc_html_e('分享链接,好友注册后会自动建立邀请关系。', 'aksrt'); ?></p></div>
<div class="uc-invite-share">
<div class="uc-invite-code"><span><?php esc_html_e('我的邀请码', 'aksrt'); ?></span><strong data-invite-code>--</strong></div>
<label class="screen-reader-text" for="aksrt-invite-link"><?php esc_html_e('推广链接', 'aksrt'); ?></label>
<div class="uc-invite-link-row"><input id="aksrt-invite-link" type="text" data-invite-link readonly value=""><button type="button" class="btn-primary btn-sm" data-invite-copy disabled><i class="fas fa-copy" aria-hidden="true"></i><span><?php esc_html_e('复制链接', 'aksrt'); ?></span></button></div>
<p class="uc-reward-status" data-invite-status role="status" aria-live="polite"><?php esc_html_e('正在加载推广数据...', 'aksrt'); ?></p>
<p class="uc-reward-copy-feedback" data-invite-copy-feedback role="status" aria-live="polite"></p>
</div>
</section>
<section class="uc-reward-block" aria-labelledby="uc-ratio-title">
<div class="uc-reward-block__heading"><h4 id="uc-ratio-title"><?php esc_html_e('佣金比例', 'aksrt'); ?></h4><p><?php esc_html_e('当前推广身份对应的返佣规则。', 'aksrt'); ?></p></div>
<dl class="uc-ratio-list">
<div><dt><?php esc_html_e('普通用户', 'aksrt'); ?></dt><dd><?php echo esc_html((string) ($vip_ratios['user'] ?? $commission_ratio)); ?>%</dd></div>
<div><dt>VIP 1</dt><dd><?php echo esc_html((string) ($vip_ratios['vip1'] ?? 0)); ?>%</dd></div>
<div><dt>VIP 2</dt><dd><?php echo esc_html((string) ($vip_ratios['vip2'] ?? 0)); ?>%</dd></div>
</dl>
</section>
<section class="uc-reward-block uc-reward-block--details" aria-labelledby="uc-details-title">
<div class="uc-reward-block__heading"><h4 id="uc-details-title"><?php esc_html_e('返佣详解', 'aksrt'); ?></h4></div>
<div class="uc-reward-richtext"><?php echo wp_kses_post(wpautop($details_text)); ?></div>
@@ -164,17 +133,14 @@ $status_labels = [
$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');
?>
<article class="uc-commission-row"><span class="uc-commission-row__icon"><i class="fas fa-receipt" 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($item['amount'] ?? 0)); ?></strong><span class="is-<?php echo esc_attr($status); ?>"><?php echo esc_html($status_labels[$status] ?? $status); ?></span></div></article>
<article class="uc-commission-row"><span class="uc-commission-row__icon"><i class="fas fa-receipt" 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((float) ($item['original_amount'] ?? 0) + (float) ($item['adjustment_total'] ?? 0))); ?></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('通过推广链接注册的用户会显示在这里。', 'aksrt'); ?></p></div><strong data-invite-total><?php esc_html_e('加载中...', 'aksrt'); ?></strong></div>
<div class="uc-invite-list is-loading" data-invite-list aria-live="polite"><p class="uc-reward-empty"><?php esc_html_e('正在加载邀请记录...', 'aksrt'); ?></p></div>
</div>
<div class="uc-reward-pane"><p class="uc-reward-empty"><?php esc_html_e('暂无收益明细。', 'aksrt'); ?></p></div>
<?php endif; ?>
</section>
</div>
@@ -23,7 +23,6 @@ $withdraw_fee_rate = (int) ($withdraw_settings['service_charge_rate'] ?? 5);
<select class="uc-card-pass__input" name="method" data-aksrt-withdraw-method>
<option value="alipay"><?php esc_html_e('支付宝', 'aksrt'); ?></option>
<option value="wechat"><?php esc_html_e('微信', 'aksrt'); ?></option>
<option value="bank"><?php esc_html_e('银行卡', 'aksrt'); ?></option>
</select>
<label class="muted-color em09 mt10"><?php esc_html_e('收款账户信息', 'aksrt'); ?></label>
<textarea class="uc-card-pass__input" name="account" rows="3" data-aksrt-withdraw-account placeholder="<?php esc_attr_e('姓名 + 账户 / 卡号', 'aksrt'); ?>"></textarea>
+29
View File
@@ -0,0 +1,29 @@
<?php
/** Shared forward widget contract. */
defined('ABSPATH') || exit;
function aksrt_widget_common_defaults(): array {
return class_exists('\\AKSRT\\Widgets\\CommonWidgetFields') ? \AKSRT\Widgets\CommonWidgetFields::defaults() : ['subtitle'=>'','title_link'=>'','display_pc'=>1,'display_mobile'=>1,'sticky'=>0,'animation'=>'none','background'=>'surface','dark_background'=>'surface'];
}
function aksrt_widget_common_normalize(array $instance): array {
if (class_exists('\\AKSRT\\Widgets\\CommonWidgetFields')) return \AKSRT\Widgets\CommonWidgetFields::normalize($instance);
$d = aksrt_widget_common_defaults();
$out = $instance;
$out['subtitle'] = sanitize_text_field((string)($instance['subtitle'] ?? $d['subtitle']));
$out['title_link'] = esc_url_raw((string)($instance['title_link'] ?? ''));
foreach (['display_pc','display_mobile','sticky'] as $key) $out[$key] = !empty($instance[$key]) ? 1 : 0;
$out['animation'] = in_array(($a = sanitize_key((string)($instance['animation'] ?? 'none'))), ['none','fade','slide'], true) ? $a : 'none';
$out['background'] = in_array(($b = sanitize_key((string)($instance['background'] ?? 'surface'))), ['surface','transparent','primary'], true) ? $b : 'surface';
$out['dark_background'] = in_array(($db = sanitize_key((string)($instance['dark_background'] ?? 'surface'))), ['surface','transparent','primary'], true) ? $db : 'surface';
return $out;
}
function aksrt_widget_common_form(WP_Widget $widget, array $instance): void {
$v = aksrt_widget_common_normalize($instance);
$field = static function(string $key) use ($widget): string { return esc_attr($widget->get_field_name($key)); };
echo '<p><label for="' . esc_attr($widget->get_field_id('subtitle')) . '">' . esc_html__('副标题', 'aksrt') . '</label><input class="widefat" id="' . esc_attr($widget->get_field_id('subtitle')) . '" name="' . $field('subtitle') . '" type="text" value="' . esc_attr($v['subtitle']) . '"></p>';
echo '<p><label for="' . esc_attr($widget->get_field_id('title_link')) . '">' . esc_html__('标题链接', 'aksrt') . '</label><input class="widefat" id="' . esc_attr($widget->get_field_id('title_link')) . '" name="' . $field('title_link') . '" type="url" value="' . esc_attr($v['title_link']) . '"></p>';
echo '<p><label><input type="checkbox" name="' . $field('display_pc') . '" value="1" ' . checked($v['display_pc'], 1, false) . '> ' . esc_html__('桌面端显示', 'aksrt') . '</label> <label><input type="checkbox" name="' . $field('display_mobile') . '" value="1" ' . checked($v['display_mobile'], 1, false) . '> ' . esc_html__('移动端显示', 'aksrt') . '</label> <label><input type="checkbox" name="' . $field('sticky') . '" value="1" ' . checked($v['sticky'], 1, false) . '> ' . esc_html__('侧栏吸顶', 'aksrt') . '</label></p>';
echo '<p><label>' . esc_html__('入场动画', 'aksrt') . '<select class="widefat" name="' . $field('animation') . '"><option value="none" ' . selected($v['animation'], 'none', false) . '>None</option><option value="fade" ' . selected($v['animation'], 'fade', false) . '>Fade</option><option value="slide" ' . selected($v['animation'], 'slide', false) . '>Slide</option></select></label></p>';
}
add_filter('widget_update_callback', static function($instance, $new_instance, $old_instance, $widget) { return is_array($instance) ? aksrt_widget_common_normalize($instance) : $instance; }, 10, 4);
add_action('in_widget_form', static function(WP_Widget $widget, &$return, array $instance): void { aksrt_widget_common_form($widget, $instance); }, 20, 3);
+12
View File
@@ -8,6 +8,7 @@
defined('ABSPATH') || exit;
require_once AKSRT_DIR . '/inc/modules/widget-areas.php';
require_once AKSRT_DIR . '/inc/modules/widget-common.php';
/**
* Register custom widgets.
@@ -68,6 +69,12 @@ function aksrt_register_widgets() {
register_widget('\AKSRT\Widgets\WidgetMainPosts');
register_widget('\AKSRT\Widgets\WidgetTermAggregation');
register_widget('\AKSRT\Widgets\WidgetIconCoverCards');
register_widget('\AKSRT\Widgets\PricingPlansWidget');
register_widget('\AKSRT\Widgets\TestimonialsWidget');
register_widget('\AKSRT\Widgets\FaqWidget');
register_widget('\AKSRT\Widgets\StatsWidget');
register_widget('\AKSRT\Widgets\HighlightsWidget');
register_widget('\AKSRT\Widgets\PartnersWidget');
}
add_action('widgets_init', 'aksrt_register_widgets');
@@ -102,6 +109,9 @@ add_filter('block_type_metadata', function ($metadata) {
$aksrt_widgets = [
'aksrt_categories',
'aksrt_tag_cloud',
'aksrt_hot_posts','aksrt_tab_posts','aksrt_category_block','aksrt_category_tabs','aksrt_category_recommend',
'aksrt_search','aksrt_notice','aksrt_recent_comments','aksrt_icon_cards','aksrt_buttons','aksrt_announcement','aksrt_user_card','aksrt_cover_cards','aksrt_video_player','aksrt_yiyan','aksrt_slider','aksrt_iframe','aksrt_combo_info','aksrt_text_title','aksrt_term_cards','aksrt_post_pay_box','aksrt_author_card','aksrt_oneline_posts','aksrt_main_posts','aksrt_term_aggregation','aksrt_icon_cover_cards',
'aksrt_pricing_plans','aksrt_testimonials','aksrt_faq','aksrt_stats','aksrt_highlights','aksrt_partners',
];
if (!empty($metadata['name']) && in_array($metadata['name'], $aksrt_widgets, true)) {
@@ -123,6 +133,8 @@ add_action('in_widget_form', function (WP_Widget $widget): void {
'aksrt_category_block' => 'hybrid',
'aksrt_category_tabs' => 'hybrid',
'aksrt_category_recommend' => 'homepage',
'aksrt_search'=>'sidebar','aksrt_notice'=>'sidebar','aksrt_recent_comments'=>'sidebar','aksrt_icon_cards'=>'hybrid','aksrt_buttons'=>'hybrid','aksrt_announcement'=>'homepage','aksrt_user_card'=>'sidebar','aksrt_cover_cards'=>'homepage','aksrt_video_player'=>'content','aksrt_yiyan'=>'sidebar','aksrt_slider'=>'homepage','aksrt_iframe'=>'content','aksrt_combo_info'=>'homepage','aksrt_text_title'=>'hybrid','aksrt_term_cards'=>'homepage','aksrt_post_pay_box'=>'content','aksrt_author_card'=>'content','aksrt_oneline_posts'=>'sidebar','aksrt_main_posts'=>'homepage','aksrt_term_aggregation'=>'homepage','aksrt_icon_cover_cards'=>'homepage',
'aksrt_pricing_plans'=>'homepage','aksrt_testimonials'=>'homepage','aksrt_faq'=>'content','aksrt_stats'=>'homepage','aksrt_highlights'=>'homepage','aksrt_partners'=>'homepage',
];
$scope = $map[$widget->id_base] ?? '';
-27
View File
@@ -97,33 +97,6 @@ class PaymentController extends ControllerBase {
'permission_callback' => [$this, 'check_permission'],
]);
// ── Rebate ──
// Rebate list
register_rest_route(self::API_NAMESPACE, '/payment/rebate/log', [
'methods' => 'GET',
'callback' => [$this, 'get_rebate_list'],
'permission_callback' => [$this, 'check_permission'],
]);
register_rest_route(self::API_NAMESPACE, '/payment/rebate/(?P<id>\d+)', [
'methods' => 'GET',
'callback' => [$this, 'get_rebate_detail'],
'permission_callback' => [$this, 'check_permission'],
]);
// Rebate settle
register_rest_route(self::API_NAMESPACE, '/payment/rebate/(?P<id>\d+)/settle', [
'methods' => 'POST',
'callback' => [$this, 'settle_rebate'],
'permission_callback' => [$this, 'check_permission'],
]);
// Rebate cancel
register_rest_route(self::API_NAMESPACE, '/payment/rebate/(?P<id>\d+)/cancel', [
'methods' => 'POST',
'callback' => [$this, 'cancel_rebate'],
'permission_callback' => [$this, 'check_permission'],
]);
}
public function rest_payment_overview(\WP_REST_Request $request): \WP_REST_Response {
+16 -26
View File
@@ -142,28 +142,19 @@ class SchemaPayment {
'option_key' => 'aksrt_settings',
'groups' => [
['key' => 'policy', 'title' => '返佣策略', 'fields' => [
['key' => 'pay_rebate_s', 'label' => '启用推广返佣', 'type' => 'switch', 'default' => false],
['key' => 'pay_rebate_ratio', 'label' => '返佣比例(%)', 'type' => 'number', 'default' => 10, 'min' => 1, 'max' => 100],
['key' => 'payment_affiliate_window_days', 'label' => '归因窗口(天)', 'type' => 'number', 'default' => 30, 'min' => 1, 'max' => 365],
['key' => 'payment_affiliate_require_paid_order', 'label' => '仅对已支付订单返佣', 'type' => 'switch', 'default' => true],
['key' => 'pay_rebate_self_s', 'label' => '自购返佣', 'type' => 'switch', 'default' => true],
['key' => 'pay_rebate_show_users', 'label' => '显示推广用户', 'type' => 'switch', 'default' => true],
]],
['key' => 'user-ratio', 'title' => '用户返佣比例', 'fields' => [
['key' => 'payment_affiliate_user_ratio', 'label' => '普通用户比例(%)', 'type' => 'number', 'default' => 10, 'min' => 0, 'max' => 100],
['key' => 'pay_rebate_ratio_vip_1', 'label' => 'VIP1比例(%)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 100],
['key' => 'pay_rebate_ratio_vip_2', 'label' => 'VIP2比例(%)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 100],
]],
['key' => 'poster', 'title' => '推广海报', 'fields' => [
['key' => 'pay_rebate_poster_s', 'label' => '启用推广海报', 'type' => 'switch', 'default' => true],
['key' => 'pay_rebate_poster_img', 'label' => '海报背景图', 'type' => 'media', 'default' => ''],
['key' => 'pay_rebate_poster_title', 'label' => '海报标题', 'type' => 'input', 'default' => ''],
['key' => 'pay_rebate_poster_desc', 'label' => '海报描述', 'type' => 'input', 'default' => ''],
['key' => 'affiliate_enabled', 'label' => '启用推广返佣', 'type' => 'switch', 'default' => false],
['key' => 'affiliate_ratio', 'label' => '全站佣金比例(%)', 'type' => 'number', 'default' => 5, 'min' => 0, 'max' => 100],
['key' => 'affiliate_order_types', 'label' => '适用订单类型', 'type' => 'checkbox', 'options' => [['value' => 'membership', 'label' => '会员订单'], ['value' => 'content', 'label' => '付费内容']], 'default' => ['membership', 'content']],
['key' => 'affiliate_touch_window_days', 'label' => '首次触达窗口(天)', 'type' => 'number', 'default' => 30, 'min' => 1, 'max' => 365],
['key' => 'affiliate_settlement_days', 'label' => '结算观察期(天)', 'type' => 'number', 'default' => 7, 'min' => 0, 'max' => 90],
['key' => 'affiliate_allow_self_purchase', 'label' => '允许自购返佣', 'type' => 'switch', 'default' => false],
['key' => 'affiliate_show_stats', 'label' => '展示推广统计', 'type' => 'switch', 'default' => true],
]],
['key' => 'text', 'title' => '文案设置', 'fields' => [
['key' => 'pay_rebate_text_desc', 'label' => '返佣说明', 'type' => 'textarea', 'default' => ''],
['key' => 'pay_rebate_text_details', 'label' => '明细说明', 'type' => 'textarea', 'default' => ''],
['key' => 'pay_rebate_text_discount', 'label' => '折扣文案', 'type' => 'input', 'default' => ''],
['key' => 'affiliate_promotion_text', 'label' => '推广说明', 'type' => 'textarea', 'default' => ''],
['key' => 'affiliate_earnings_text', 'label' => '收益说明', 'type' => 'textarea', 'default' => ''],
['key' => 'affiliate_withdrawal_agreement', 'label' => '提现协议', 'type' => 'textarea', 'default' => ''],
['key' => 'affiliate_payout_channels', 'label' => '可用收款渠道', 'type' => 'checkbox', 'options' => [['value' => 'alipay', 'label' => '支付宝收款码'], ['value' => 'wechat', 'label' => '微信收款码']], 'default' => ['alipay', 'wechat']],
]],
],
],
@@ -326,12 +317,11 @@ class SchemaPayment {
'option_key' => 'aksrt_settings',
'groups' => [
['key' => 'basic', 'title' => '提现设置', 'fields' => [
['key' => 'pay_balance_withdraw_s', 'label' => '启用余额提现', 'type' => 'switch', 'default' => false],
['key' => 'pay_rebate_withdraw_lowest_money', 'label' => '最低提现金额', 'type' => 'number', 'default' => 50, 'min' => 0, 'max' => 99999, 'dependsOn' => ['key' => 'pay_balance_withdraw_s', 'value' => true]],
['key' => 'withdraw_service_charge', 'label' => '提现手续费(%)', 'type' => 'number', 'default' => 5, 'min' => 0, 'max' => 50, 'dependsOn' => ['key' => 'pay_balance_withdraw_s', 'value' => true]],
['key' => 'withdraw_daily_count', 'label' => '每日可提现次数', 'type' => 'number', 'default' => 3, 'min' => 0, 'max' => 99999, 'sanitize_type' => 'int', 'dependsOn' => ['key' => 'pay_balance_withdraw_s', 'value' => true]],
['key' => 'withdraw_daily_limit', 'label' => '每日提现上限', 'type' => 'number', 'default' => 50, 'min' => 0, 'max' => 999999, 'dependsOn' => ['key' => 'pay_balance_withdraw_s', 'value' => true]],
['key' => 'withdraw_reset_time', 'label' => '重置时间', 'type' => 'input', 'default' => '00:00', 'help' => '按站点 WordPress 时区重置,格式 HH:MM。', 'dependsOn' => ['key' => 'pay_balance_withdraw_s', 'value' => true]],
['key' => 'affiliate_min_withdrawal', 'label' => '最低提现金额', 'type' => 'number', 'default' => 50, 'min' => 0, 'max' => 99999],
['key' => 'affiliate_withdrawal_fee', 'label' => '提现手续费(%)', 'type' => 'number', 'default' => 5, 'min' => 0, 'max' => 100],
['key' => 'affiliate_daily_withdrawal_count', 'label' => '每日可提现次数', 'type' => 'number', 'default' => 3, 'min' => 0, 'max' => 99999],
['key' => 'affiliate_daily_withdrawal_limit', 'label' => '每日提现上限', 'type' => 'number', 'default' => 500, 'min' => 0, 'max' => 999999],
['key' => 'affiliate_withdrawal_reset_time', 'label' => '重置时间', 'type' => 'input', 'default' => '00:00', 'help' => '按站点 WordPress 时区重置,格式 HH:MM。'],
]],
],
],
+4 -8
View File
@@ -210,10 +210,10 @@ class SchemaProvider {
if ($domain === 'payment' && $section === 'withdrawal') {
$stored = get_option('aksrt_settings', []);
$merged = is_array($stored) ? array_merge($stored, $data) : $data;
$count = $merged['withdraw_daily_count'] ?? 3;
$min = (float) ($merged['pay_rebate_withdraw_lowest_money'] ?? 50);
$limit = (float) ($merged['withdraw_daily_limit'] ?? 50);
$time = (string) ($merged['withdraw_reset_time'] ?? '00:00');
$count = $merged['affiliate_daily_withdrawal_count'] ?? 3;
$min = (float) ($merged['affiliate_min_withdrawal'] ?? 50);
$limit = (float) ($merged['affiliate_daily_withdrawal_limit'] ?? 500);
$time = (string) ($merged['affiliate_withdrawal_reset_time'] ?? '00:00');
if (filter_var($count, FILTER_VALIDATE_INT) === false || (int) $count < 0) {
$errors[] = __('每日可提现次数必须是非负整数', 'aksrt');
}
@@ -336,10 +336,6 @@ class SchemaProvider {
$stored[$key] = $value;
}
if (($schema['key'] ?? '') === 'withdrawal') {
unset($stored['pay_rebate_withdraw_text_details'], $stored['pay_balance_withdraw_lowest_money']);
}
// Captcha stores a single positive scene list.
if ($option_key === 'aksrt_captcha_config' && function_exists('aksrt_captcha_scenes')) {
$stored['enabled_scenarios'] = function_exists('aksrt_captcha_sanitize_scene_list')
+2 -42
View File
@@ -92,32 +92,8 @@ trait PayOrders {
aksrt_burn_order_coupon($order_number, $meta, $buyer_id);
}
// Affiliate commission (does not reduce buyer pay).
$referrer_id = (int) ($meta['referrer_id'] ?? 0);
$rebate_price = round((float) ($meta['rebate_price'] ?? 0), 2);
$pay_amount = round((float) ($order['amount'] ?? $meta['payable'] ?? 0), 2);
if ($referrer_id <= 0 || $rebate_price <= 0 || $pay_amount <= 0) {
return;
}
if (!class_exists('\AKSRT\Payment\Rebate')) {
return;
}
if ($this->rebate_log_exists($order_number, $referrer_id)) {
return;
}
\AKSRT\Payment\Rebate::get_instance()->create([
'user_id' => $referrer_id,
'order_number' => $order_number,
'source_user_id' => $buyer_id,
'amount' => $rebate_price,
'type' => \AKSRT\Payment\Rebate::TYPE_ORDER,
'note' => sprintf(
/* translators: 1: order number 2: post id */
__('内容推广返佣 订单 %1$s 内容 #%2$d', 'aksrt'),
$order_number,
(int) ($order['product_id'] ?? $meta['post_id'] ?? 0)
),
]);
// Affiliate commission is recorded exactly once by CommissionLedger on
// the canonical aksrt_order_paid event. No order-side legacy writes.
}
@@ -140,22 +116,6 @@ trait PayOrders {
private function rebate_log_exists(string $order_number, int $referrer_id): bool {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('rebate_log');
if ($table === '' || $order_number === '') {
return false;
}
$found = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$table} WHERE order_number = %s AND user_id = %d LIMIT 1",
$order_number,
$referrer_id
));
return !empty($found);
}
/**
* Points one-shot purchase → permanent resource order.
*
+1 -1
View File
@@ -14,7 +14,7 @@ class DBMigrator {
use DBMigratorVip;
/** Current schema version; changing it re-runs dbDelta. */
const SCHEMA_VERSION = '1.22.0';
const SCHEMA_VERSION = '1.23.0';
const OPT_KEY = 'aksrt_db_schema_version';
+78
View File
@@ -377,6 +377,84 @@ trait DBMigratorPart2 {
) {$charset_collate};";
dbDelta($sql_rebate_log);
// ── affiliate attribution (new contract; no legacy invite reads) ──
$sql_affiliate_relation = "CREATE TABLE {$prefix}aksrt_affiliate_relation (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
referral_code VARCHAR(32) NOT NULL,
promoter_id BIGINT UNSIGNED NOT NULL,
invitee_id BIGINT UNSIGNED DEFAULT NULL,
first_touch_at DATETIME NOT NULL,
bound_at DATETIME DEFAULT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'captured',
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_referral_code (referral_code),
UNIQUE KEY uniq_invitee (invitee_id),
KEY idx_promoter (promoter_id),
KEY idx_status_touch (status, first_touch_at)
) {$charset_collate};";
dbDelta($sql_affiliate_relation);
// ── immutable commission ledger ──
$sql_commission_ledger = "CREATE TABLE {$prefix}aksrt_commission_ledger (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
promoter_id BIGINT UNSIGNED NOT NULL,
source_user_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
order_number VARCHAR(64) NOT NULL,
order_type VARCHAR(32) NOT NULL,
eligible_base DECIMAL(18,2) NOT NULL DEFAULT 0,
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',
idempotency_key VARCHAR(128) NOT NULL,
available_at DATETIME NOT NULL,
settled_at DATETIME DEFAULT NULL,
created_at DATETIME NOT NULL,
note TEXT DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uniq_commission_idempotency (idempotency_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)
) {$charset_collate};";
dbDelta($sql_commission_ledger);
// ── immutable commission adjustments (refund/debt/manual) ──
$sql_commission_adjustment = "CREATE TABLE {$prefix}aksrt_commission_adjustment (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
ledger_id BIGINT UNSIGNED NOT NULL,
promoter_id BIGINT UNSIGNED NOT NULL,
amount DECIMAL(18,2) NOT NULL DEFAULT 0,
reason VARCHAR(32) NOT NULL,
source_key VARCHAR(128) NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
note TEXT DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uniq_adjustment_source (source_key),
KEY idx_ledger (ledger_id),
KEY idx_promoter (promoter_id)
) {$charset_collate};";
dbDelta($sql_commission_adjustment);
// ── payout account snapshots ──
$sql_payout_account = "CREATE TABLE {$prefix}aksrt_payout_account (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
channel VARCHAR(20) NOT NULL,
attachment_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
account_hash CHAR(64) NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uniq_user_channel (user_id, channel),
KEY idx_user_status (user_id, status)
) {$charset_collate};";
dbDelta($sql_payout_account);
// ── order_items (§6.5 多商品多商家) ──
$sql_order_items = "CREATE TABLE {$prefix}aksrt_order_item (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+4
View File
@@ -33,6 +33,10 @@ trait DBMigratorPart3 {
'coupon' => 'aksrt_coupon',
'charge_card' => 'aksrt_charge_card',
'rebate_log' => 'aksrt_rebate_log',
'affiliate_relation' => 'aksrt_affiliate_relation',
'commission_ledger' => 'aksrt_commission_ledger',
'commission_adjustment' => 'aksrt_commission_adjustment',
'payout_account' => 'aksrt_payout_account',
'coupon_usage' => 'aksrt_coupon_usage',
'user_restriction' => 'aksrt_user_restriction',
'identity_application' => 'aksrt_identity_application',
+2 -1
View File
@@ -337,12 +337,13 @@ class WidgetAjax {
}
private function fail(): \WP_REST_Response {
return new \WP_REST_Response(['success' => false, 'message' => 'unavailable'], 500);
return new \WP_REST_Response(['success' => false, 'code' => 'widget_unavailable', 'message' => __('小工具服务暂不可用。', 'aksrt'), 'data' => ['items' => [], 'page' => 1, 'max_pages' => 1, 'total' => 0]], 500);
}
private function payload(array $payload, int $page): \WP_REST_Response {
return new \WP_REST_Response([
'success' => true,
'code' => 'ok',
'html' => (string) ($payload['html'] ?? ''),
'paginate_html' => (string) ($payload['paginate_html'] ?? ''),
'has_more' => !empty($payload['has_more']),
+125
View File
@@ -0,0 +1,125 @@
<?php
/** Forward-only affiliate commission ledger. */
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
final class CommissionLedger {
private static ?self $instance = null;
private const DEBT_META = 'aksrt_affiliate_debt';
public static function get_instance(): self { return self::$instance ??= new self(); }
private function __construct() {}
public function init(): void {
add_action('aksrt_order_paid', [$this, 'on_order_paid'], 20, 2);
add_action('aksrt_order_status_changed', [$this, 'on_order_status_changed'], 20, 3);
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');
}
}
private function table(string $name): string { return \AKSRT\Core\DBMigrator::table($name); }
public function on_order_paid(string $order_number, array $order = []): void { $this->record_paid_order(array_merge($order, ['order_number' => $order_number])); }
public function on_order_status_changed(string $order_number, string $status, array $order = []): void {
if (in_array($status, ['refunding', 'refunded'], true)) { $this->reverse($order_number, (float) ($order['amount'] ?? 0), 'refund'); }
}
public function record_paid_order(array $order): int {
if (!\AKSRT\Settings\Settings::affiliate_enabled()) return 0;
$type = sanitize_key((string) ($order['product_type'] ?? ''));
$map = ['vip' => 'membership', 'resource' => 'content'];
$order_type = $map[$type] ?? $type;
$settings = \AKSRT\Settings\Settings::get_affiliate_settings();
if (!in_array($order_type, (array) $settings['order_types'], true)) return 0;
$order_no = sanitize_text_field((string) ($order['order_number'] ?? ''));
$buyer = (int) ($order['user_id'] ?? 0);
$amount = round((float) ($order['amount'] ?? 0), 2);
if ($order_no === '' || $buyer <= 0 || $amount <= 0) return 0;
$promoter = ReferralAttribution::get_instance()->resolve($buyer);
if ($promoter <= 0 || $promoter === $buyer) return 0;
if (!$settings['allow_self_purchase'] && $promoter === $buyer) return 0;
$ratio = (float) $settings['ratio'];
$commission = round($amount * $ratio / 100, 2);
if ($commission <= 0) return 0;
global $wpdb;
$table = $this->table('commission_ledger');
$key = hash('sha256', 'affiliate:' . $order_no . ':' . $order_type);
$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,'ratio'=>$ratio,'original_amount'=>$commission,'adjustment_total'=>0,'status'=>'pending','idempotency_key'=>$key,'available_at'=>$available,'created_at'=>$now], ['%d','%d','%s','%s','%f','%f','%f','%f','%s','%s','%s','%s']);
return $ok ? (int) $wpdb->insert_id : (int) $wpdb->get_var($wpdb->prepare("SELECT id FROM {$table} WHERE idempotency_key=%s", $key));
}
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): bool {
global $wpdb; $table = $this->table('commission_ledger');
$wpdb->query('START TRANSACTION');
try {
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id=%d AND status='pending' FOR UPDATE", $id), ARRAY_A);
if (!$row) { $wpdb->query('ROLLBACK'); return false; }
$amount = round((float)$row['original_amount'] + (float)$row['adjustment_total'], 2);
if ($amount > 0 && class_exists('\\AKSRT\\Credits\\Balance')) {
$credited = \AKSRT\Credits\Balance::add((int)$row['promoter_id'], $amount, 'affiliate_commission', ['type'=>'commission','id'=>$id], sprintf(__('推广佣金结算(订单 %s)','aksrt'), $row['order_number']));
if (is_wp_error($credited)) { $wpdb->query('ROLLBACK'); return false; }
}
$updated = $wpdb->query($wpdb->prepare("UPDATE {$table} SET status='settled', settled_at=%s WHERE id=%d AND status='pending'", current_time('mysql', true), $id)) === 1;
if (!$updated) { $wpdb->query('ROLLBACK'); return false; }
$wpdb->query('COMMIT'); return true;
} catch (\Throwable $e) { $wpdb->query('ROLLBACK'); return false; }
}
public function reverse(string $order_number, float $refund_amount, string $reason = 'refund'): int {
global $wpdb; $table = $this->table('commission_ledger'); $adjust = $this->table('commission_adjustment');
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE order_number=%s LIMIT 1", $order_number), ARRAY_A);
if (!$row || $refund_amount <= 0) return 0;
$base = max(0.01, (float)$row['eligible_base']); $negative = -round((float)$row['original_amount'] * min(1, $refund_amount / $base), 2);
$source = hash('sha256', 'refund:' . $order_number . ':' . number_format($refund_amount,2,'.',''));
$ok = $wpdb->insert($adjust, ['ledger_id'=>$row['id'],'promoter_id'=>$row['promoter_id'],'amount'=>$negative,'reason'=>sanitize_key($reason),'source_key'=>$source,'operator_id'=>0,'created_at'=>current_time('mysql', true)], ['%d','%d','%f','%s','%s','%d','%s']);
if (!$ok) return 0;
$wpdb->query($wpdb->prepare("UPDATE {$table} SET adjustment_total=adjustment_total+%f, status=IF(status='pending','cancelled',status) WHERE id=%d", $negative, $row['id']));
if ($row['status'] === 'settled' && class_exists('\\AKSRT\\Credits\\Balance')) {
$debit = min((float)$row['original_amount'], abs($negative));
$result = \AKSRT\Credits\Balance::deduct((int)$row['promoter_id'], $debit, 'affiliate_refund', ['type'=>'commission','id'=>$row['id']], __('推广佣金退款冲正','aksrt'));
if (is_wp_error($result)) update_user_meta((int)$row['promoter_id'], self::DEBT_META, round((float)get_user_meta((int)$row['promoter_id'], self::DEBT_META, true) + $debit, 2));
}
return (int)$wpdb->insert_id;
}
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) ?: [];
return ['total'=>round((float)($row['total']??0),2),'pending'=>round((float)($row['pending']??0),2),'settled'=>round((float)($row['settled']??0),2),'wallet'=>class_exists('\\AKSRT\\Credits\\Balance')?\AKSRT\Credits\Balance::balance($user_id):0.0,'debt'=>round((float)get_user_meta($user_id,self::DEBT_META,true),2)];
}
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))];
}
public function register_routes(): void {
register_rest_route('aksrt/v1','/earnings/overview',['methods'=>\WP_REST_Server::READABLE,'callback'=>fn()=>rest_ensure_response($this->overview(get_current_user_id())),'permission_callback'=>fn()=>is_user_logged_in()]);
register_rest_route('aksrt/v1','/earnings/commissions',['methods'=>\WP_REST_Server::READABLE,'callback'=>fn(\WP_REST_Request $r)=>rest_ensure_response($this->list_entries(get_current_user_id(),$r->get_params())),'permission_callback'=>fn()=>is_user_logged_in()]);
register_rest_route('aksrt/v1','/earnings/withdrawals',['methods'=>\WP_REST_Server::READABLE,'callback'=>fn(\WP_REST_Request $r)=>rest_ensure_response($this->withdrawals(get_current_user_id(),$r->get_params())),'permission_callback'=>fn()=>is_user_logged_in()]);
register_rest_route('aksrt/v1','/earnings/withdrawals',['methods'=>\WP_REST_Server::CREATABLE,'callback'=>[$this,'create_withdrawal'],'permission_callback'=>fn()=>is_user_logged_in()]);
register_rest_route('aksrt/v1','/payment/affiliate/ledger/(?P<id>\d+)/settle',['methods'=>\WP_REST_Server::CREATABLE,'callback'=>fn(\WP_REST_Request $r)=>$this->admin_settle((int)$r['id']),'permission_callback'=>fn()=>current_user_can('manage_options')]);
register_rest_route('aksrt/v1','/payment/affiliate/ledger/(?P<id>\d+)/cancel',['methods'=>\WP_REST_Server::CREATABLE,'callback'=>fn(\WP_REST_Request $r)=>$this->admin_cancel((int)$r['id']),'permission_callback'=>fn()=>current_user_can('manage_options')]);
register_rest_route('aksrt/v1','/payment/affiliate/overview',['methods'=>\WP_REST_Server::READABLE,'callback'=>fn()=>rest_ensure_response($this->admin_overview()),'permission_callback'=>fn()=>current_user_can('manage_options')]);
register_rest_route('aksrt/v1','/payment/affiliate/ledger',['methods'=>\WP_REST_Server::READABLE,'callback'=>fn(\WP_REST_Request $r)=>rest_ensure_response($this->admin_ledger($r->get_params())),'permission_callback'=>fn()=>current_user_can('manage_options')]);
register_rest_route('aksrt/v1','/payment/withdrawals/(?P<id>\d+)/transition',['methods'=>\WP_REST_Server::CREATABLE,'callback'=>fn(\WP_REST_Request $r)=>$this->transition_withdrawal((int)$r['id'],(string)$r->get_param('status'),(string)$r->get_param('note')),'permission_callback'=>fn()=>current_user_can('manage_options')]);
}
public function admin_settle(int $id): \WP_REST_Response { $ok=$this->settle_one($id); return new \WP_REST_Response(['success'=>$ok],$ok?200:409); }
public function admin_cancel(int $id): \WP_REST_Response { global $wpdb; $ok=$wpdb->query($wpdb->prepare("UPDATE {$this->table('commission_ledger')} SET status='cancelled' WHERE id=%d AND status='pending'",$id))===1; return new \WP_REST_Response(['success'=>$ok],$ok?200:409); }
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'=>(float)($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 { return \AKSRT\Payment\Withdrawal::get_instance()->get_user_withdrawals($user_id, ['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)))]); }
public function create_withdrawal(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
$user_id=get_current_user_id(); $amount=(int)round((float)$request->get_param('amount')); $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();
if (empty($affiliate['enabled'])) return new \WP_Error('affiliate_disabled',__('推广返佣未启用。','aksrt'),['status'=>403]);
if (\AKSRT\Payment\Withdrawal::get_instance()->has_open_withdrawal($user_id)) return new \WP_Error('affiliate_withdraw_pending',__('已有待处理提现申请。','aksrt'),['status'=>409]);
if ($amount < (int)ceil((float)$affiliate['min_withdrawal'])) return new \WP_Error('affiliate_withdraw_min',__('未达到最低提现金额。','aksrt'),['status'=>400]);
if ((float)get_user_meta($user_id,self::DEBT_META,true)>0) return new \WP_Error('affiliate_withdraw_debt',__('存在待偿还的佣金欠款,暂不可提现。','aksrt'),['status'=>409]);
if (!in_array($method,(array)$affiliate['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,$method,$account,$settings); 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(int $id,string $status,string $note): \WP_REST_Response|\WP_Error { $status = ['approved'=>'processing','paid'=>'completed'][$status] ?? $status; $result=\AKSRT\Payment\Withdrawal::get_instance()->process_status_change($id,$status,$note); if(is_wp_error($result)) return $result; return new \WP_REST_Response(['success'=>true,'status'=>$status],200); }
}
+203
View File
@@ -0,0 +1,203 @@
<?php
/**
* First-touch referral attribution for the affiliate contract.
*
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
final class ReferralAttribution {
private const COOKIE = 'aksrt_referral';
private const META_CODE = 'aksrt_referral_code';
private static ?self $instance = null;
public static function get_instance(): self {
return self::$instance ??= new self();
}
private function __construct() {}
public function init(): void {
add_action('init', [$this, 'capture'], 1);
add_action('user_register', [$this, 'bind_registered_user'], 20, 1);
add_action('rest_api_init', [$this, 'register_routes']);
}
/** Capture only the new ref contract; invite/legacy parameters are ignored. */
public function capture(): void {
if (is_admin() || !class_exists('\AKSRT\Settings\Settings') || !\AKSRT\Settings\Settings::affiliate_enabled()) {
return;
}
$raw = isset($_GET['ref']) ? sanitize_key(wp_unslash((string) $_GET['ref'])) : '';
if ($raw === '' || $this->resolve_promoter($raw) <= 0 || !empty($_COOKIE[self::COOKIE])) {
return;
}
if (!headers_sent()) {
$ttl_days = class_exists('\\AKSRT\\Settings\\Settings') ? (int) (\AKSRT\Settings\Settings::get_affiliate_settings()['window_days'] ?? 30) : 30;
setcookie(self::COOKIE, $raw, [
'expires' => time() + DAY_IN_SECONDS * max(1, min(365, $ttl_days)),
'path' => COOKIEPATH ?: '/',
'domain' => COOKIE_DOMAIN,
'secure' => is_ssl(),
'httponly' => true,
'samesite' => 'Lax',
]);
$_COOKIE[self::COOKIE] = $raw;
}
}
public function create_link(int $user_id, string $url = ''): array {
$code = $this->get_or_create_code($user_id);
$target = $url !== '' ? $url : home_url('/');
return ['code' => $code, 'url' => add_query_arg('ref', $code, $target)];
}
public function get_or_create_code(int $user_id): string {
if ($user_id <= 0) {
return '';
}
$existing = sanitize_key((string) get_user_meta($user_id, self::META_CODE, true));
if ($existing !== '' && strlen($existing) >= 10) {
return $existing;
}
$alphabet = 'abcdefghjkmnpqrstuvwxyz23456789';
for ($attempt = 0; $attempt < 10; $attempt++) {
$code = '';
for ($i = 0; $i < 12; $i++) {
$code .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
if ($this->resolve_promoter($code) <= 0) {
update_user_meta($user_id, self::META_CODE, $code);
return $code;
}
}
return '';
}
public function bind_registered_user(int $invitee_id): void {
if ($invitee_id <= 0 || !class_exists('\AKSRT\Settings\Settings') || !\AKSRT\Settings\Settings::affiliate_enabled()) {
return;
}
global $wpdb;
$code = sanitize_key((string) ($_COOKIE[self::COOKIE] ?? ''));
$promoter_id = $this->resolve_promoter($code);
if ($code === '' || $promoter_id <= 0 || $promoter_id === $invitee_id) {
return;
}
$table = \AKSRT\Core\DBMigrator::table('affiliate_relation');
if ($table === '') {
return;
}
$existing = $wpdb->get_var($wpdb->prepare("SELECT id FROM {$table} WHERE invitee_id = %d LIMIT 1", $invitee_id));
if ($existing) {
return;
}
$now = current_time('mysql', true);
$wpdb->insert($table, [
'referral_code' => $code,
'promoter_id' => $promoter_id,
'invitee_id' => $invitee_id,
'first_touch_at'=> $now,
'bound_at' => $now,
'status' => 'bound',
'created_at' => $now,
], ['%s', '%d', '%d', '%s', '%s', '%s', '%s']);
if (!headers_sent()) {
setcookie(self::COOKIE, '', time() - HOUR_IN_SECONDS, COOKIEPATH ?: '/', COOKIE_DOMAIN);
}
}
public function resolve(int $user_id = 0): int {
$user_id = $user_id > 0 ? $user_id : get_current_user_id();
if ($user_id <= 0) {
return 0;
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('affiliate_relation');
$bound = $table !== '' ? (int) $wpdb->get_var($wpdb->prepare("SELECT promoter_id FROM {$table} WHERE invitee_id = %d AND status = 'bound' LIMIT 1", $user_id)) : 0;
if ($bound > 0) {
return $bound === $user_id ? 0 : $bound;
}
// Orders may only use a permanently bound relation. A browsing cookie
// never becomes an implicit post-login override.
return 0;
}
public function stats(int $promoter_id): array {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('affiliate_relation');
if ($promoter_id <= 0 || $table === '') {
return ['promoters' => 0, 'effective_payers' => 0];
}
$promoters = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE promoter_id = %d AND status = 'bound'", $promoter_id));
$ledger = \AKSRT\Core\DBMigrator::table('commission_ledger');
$payers = $ledger !== '' ? (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(DISTINCT source_user_id) FROM {$ledger} WHERE promoter_id = %d AND original_amount > 0", $promoter_id)) : 0;
return ['promoters' => $promoters, 'effective_payers' => $payers];
}
public function register_routes(): void {
register_rest_route('aksrt/v1', '/promotion/me', [
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'rest_me'],
'permission_callback' => static fn() => is_user_logged_in(),
]);
register_rest_route('aksrt/v1', '/promotion/payout-accounts', [
'methods' => \WP_REST_Server::EDITABLE,
'callback' => [$this, 'rest_save_payout_accounts'],
'permission_callback' => static fn() => is_user_logged_in(),
'args' => ['accounts' => ['required' => true, 'type' => 'array']],
]);
}
public function rest_me(): \WP_REST_Response {
$uid = get_current_user_id();
$link = $this->create_link($uid);
$stats = $this->stats($uid);
$settings = \AKSRT\Settings\Settings::get_affiliate_settings();
return new \WP_REST_Response([
'code' => $link['code'],
'url' => $link['url'],
'ratio' => (float) ($settings['ratio'] ?? 5),
'order_types' => (array) ($settings['order_types'] ?? ['membership', 'content']),
'rules' => ['window_days' => (int) ($settings['window_days'] ?? 30), 'settlement_days' => (int) ($settings['settlement_days'] ?? 7)],
'stats' => $stats,
], 200);
}
public function rest_save_payout_accounts(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
$accounts = $request->get_param('accounts');
if (!is_array($accounts)) {
return new \WP_Error('aksrt_payout_accounts', __('收款账户格式无效。', 'aksrt'), ['status' => 400]);
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('payout_account');
if ($table === '') {
return new \WP_Error('aksrt_payout_unavailable', __('收款账户服务不可用。', 'aksrt'), ['status' => 500]);
}
$uid = get_current_user_id();
$now = current_time('mysql', true);
foreach (['alipay', 'wechat'] as $channel) {
$value = isset($accounts[$channel]) ? sanitize_text_field((string) $accounts[$channel]) : '';
if ($value === '') {
$wpdb->update($table, ['status' => 'inactive', 'updated_at' => $now], ['user_id' => $uid, 'channel' => $channel], ['%s', '%s'], ['%d', '%s']);
continue;
}
$wpdb->replace($table, ['user_id' => $uid, 'channel' => $channel, 'attachment_id' => 0, 'account_hash' => hash('sha256', $value), 'status' => 'active', 'created_at' => $now, 'updated_at' => $now], ['%d', '%s', '%d', '%s', '%s', '%s', '%s']);
}
return new \WP_REST_Response(['success' => true], 200);
}
private function resolve_promoter(string $code): int {
$code = sanitize_key($code);
if ($code === '') {
return 0;
}
$users = get_users(['meta_key' => self::META_CODE, 'meta_value' => $code, 'number' => 1, 'fields' => 'ID']);
return !empty($users) ? (int) $users[0] : 0;
}
}
+16 -1
View File
@@ -110,6 +110,11 @@ class Withdrawal {
}
$wpdb->query('START TRANSACTION');
try {
$open = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing') FOR UPDATE", $user_id));
if ($open > 0) {
$wpdb->query('ROLLBACK');
return new \WP_Error('withdraw_pending', __('已有待处理提现申请', 'aksrt'), ['status' => 409]);
}
$meta_id = $wpdb->get_var($wpdb->prepare("SELECT umeta_id FROM {$meta_table} WHERE user_id = %d AND meta_key = %s LIMIT 1 FOR UPDATE", $user_id, $meta_key));
if (!$meta_id) {
throw new \RuntimeException('wallet row unavailable');
@@ -222,6 +227,15 @@ class Withdrawal {
if ($old_status === $new_status && $note === '') {
return true;
}
$transitions = [
self::STATUS_PENDING => [self::STATUS_PROCESSING, self::STATUS_REJECTED],
self::STATUS_PROCESSING => [self::STATUS_COMPLETED, self::STATUS_REJECTED],
self::STATUS_COMPLETED => [],
self::STATUS_REJECTED => [],
];
if (!in_array($new_status, $transitions[$old_status] ?? [], true)) {
return new \WP_Error('aksrt_withdraw_transition', __('提现状态转换不合法', 'aksrt'), ['status' => 409]);
}
$meta = $this->parse_account_meta((string) ($row['account_info'] ?? ''));
$user_id = (int) ($row['user_id'] ?? 0);
@@ -459,7 +473,8 @@ class Withdrawal {
$row['meta'] = $meta;
$row['gross_amount'] = $gross;
$row['fee_rate'] = (int) ($meta['fee_rate'] ?? 0);
$row['display_account'] = (string) ($meta['account'] ?? $row['account_info'] ?? '');
$display = (string) ($meta['account'] ?? $row['account_info'] ?? '');
$row['display_account'] = strlen($display) > 4 ? substr($display, 0, 2) . str_repeat('*', max(2, strlen($display) - 4)) . substr($display, -2) : '****';
$row['source'] = (string) ($meta['source'] ?? 'balance');
return $row;
}
+5
View File
@@ -182,6 +182,11 @@ trait CreditsPaymentGroups {
return PaymentRuntime::get_payment_affiliate_settings();
}
public static function affiliate_enabled(): bool { return PaymentRuntime::affiliate_enabled(); }
public static function affiliate_ratio(): float { return PaymentRuntime::affiliate_ratio(); }
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{mode:string,test_enabled:bool,manual_review:bool,callback_secret:string,help_text:string}
*/
+35 -32
View File
@@ -282,35 +282,38 @@ class PaymentRuntime {
*/
public static function get_payment_affiliate_settings(): array {
$settings = Settings::get_all();
$note = trim((string) ($settings['pay_rebate_text_desc'] ?? ''));
$ratio = max(0, min(100, (float) ($settings['affiliate_ratio'] ?? 5)));
$fee = max(0, min(100, (float) ($settings['affiliate_withdrawal_fee'] ?? 5)));
$types = (array) ($settings['affiliate_order_types'] ?? ['membership', 'content']);
$types = array_values(array_intersect(['membership', 'content'], array_map('sanitize_key', $types)));
if (!$types) { $types = ['membership', 'content']; }
$channels = array_values(array_intersect(['alipay', 'wechat'], array_map('sanitize_key', (array) ($settings['affiliate_payout_channels'] ?? ['alipay', 'wechat']))));
if (!$channels) { $channels = ['alipay', 'wechat']; }
return [
'enabled' => !empty($settings['pay_rebate_s']),
'ratio' => max(0, min(100, (int) ($settings['pay_rebate_ratio'] ?? 10))),
'window_days' => max(1, min(365, (int) ($settings['payment_affiliate_window_days'] ?? 30))),
'require_paid_order' => !isset($settings['payment_affiliate_require_paid_order']) || !empty($settings['payment_affiliate_require_paid_order']),
'self_s' => !isset($settings['pay_rebate_self_s']) || !empty($settings['pay_rebate_self_s']),
'show_users' => !isset($settings['pay_rebate_show_users']) || !empty($settings['pay_rebate_show_users']),
'note' => $note !== '' ? $note : __('返佣链路复用邀请体系,订单归因和结算自动衔接当前策略字段。', 'aksrt'),
'user_ratio' => [
'user' => max(0, min(100, (int) ($settings['payment_affiliate_user_ratio'] ?? $settings['pay_rebate_ratio'] ?? 10))),
'vip1' => max(0, min(100, (int) ($settings['pay_rebate_ratio_vip_1'] ?? 0))),
'vip2' => max(0, min(100, (int) ($settings['pay_rebate_ratio_vip_2'] ?? 0))),
],
'poster' => [
'enabled' => !isset($settings['pay_rebate_poster_s']) || !empty($settings['pay_rebate_poster_s']),
'img' => esc_url_raw((string) ($settings['pay_rebate_poster_img'] ?? '')),
'title' => sanitize_text_field((string) ($settings['pay_rebate_poster_title'] ?? '')),
'desc' => sanitize_text_field((string) ($settings['pay_rebate_poster_desc'] ?? '')),
],
'text' => [
'desc' => sanitize_textarea_field((string) ($settings['pay_rebate_text_desc'] ?? '')),
'details' => sanitize_textarea_field((string) ($settings['pay_rebate_text_details'] ?? '')),
'discount' => sanitize_text_field((string) ($settings['pay_rebate_text_discount'] ?? '')),
],
'enabled' => !empty($settings['affiliate_enabled']),
'ratio' => $ratio,
'order_types' => $types,
'window_days' => max(1, min(365, (int) ($settings['affiliate_touch_window_days'] ?? 30))),
'settlement_days' => max(0, min(90, (int) ($settings['affiliate_settlement_days'] ?? 7))),
'min_withdrawal' => max(0, min(999999, (float) ($settings['affiliate_min_withdrawal'] ?? 50))),
'withdrawal_fee' => $fee,
'payout_channels' => $channels,
'allow_self_purchase' => !empty($settings['affiliate_allow_self_purchase']),
'show_stats' => !isset($settings['affiliate_show_stats']) || !empty($settings['affiliate_show_stats']),
'promotion_text' => sanitize_textarea_field((string) ($settings['affiliate_promotion_text'] ?? '')),
'earnings_text' => sanitize_textarea_field((string) ($settings['affiliate_earnings_text'] ?? '')),
'withdrawal_agreement' => sanitize_textarea_field((string) ($settings['affiliate_withdrawal_agreement'] ?? '')),
];
}
public static function affiliate_enabled(): bool { return !empty(self::get_payment_affiliate_settings()['enabled']); }
public static function affiliate_ratio(): float { return (float) self::get_payment_affiliate_settings()['ratio']; }
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];
}
/**
* @return array<string,mixed>
*/
@@ -438,14 +441,14 @@ class PaymentRuntime {
/** @return array{min_amount:float,service_charge_rate:int,daily_count:int,daily_limit:float,reset_time:string} */
public static function get_withdrawal_settings(): array {
$s = Settings::get_all();
$affiliate = self::get_payment_affiliate_settings();
return [
'min_amount' => max(0, min(99999, (float) ($s['pay_rebate_withdraw_lowest_money'] ?? 50))),
'service_charge_rate' => max(0, min(50, (int) ($s['withdraw_service_charge'] ?? 5))),
'daily_count' => max(0, min(99999, (int) ($s['withdraw_daily_count'] ?? 3))),
'daily_limit' => max(0, min(999999, (float) ($s['withdraw_daily_limit'] ?? 50))),
'reset_time' => preg_match('/^(?:[01]\\d|2[0-3]):[0-5]\\d$/', (string) ($s['withdraw_reset_time'] ?? '00:00'))
? (string) $s['withdraw_reset_time']
'min_amount' => (float) $affiliate['min_withdrawal'],
'service_charge_rate' => (int) round((float) $affiliate['withdrawal_fee']),
'daily_count' => max(0, min(99999, (int) ($s['affiliate_daily_withdrawal_count'] ?? 3))),
'daily_limit' => max(0, min(999999, (float) ($s['affiliate_daily_withdrawal_limit'] ?? 500))),
'reset_time' => preg_match('/^(?:[01]\\d|2[0-3]):[0-5]\\d$/', (string) ($s['affiliate_withdrawal_reset_time'] ?? '00:00'))
? (string) $s['affiliate_withdrawal_reset_time']
: '00:00',
];
}
+2 -2
View File
@@ -84,14 +84,14 @@ class UserRuntime {
*/
public static function get_user_center_settings(): array {
$settings = Settings::get_all();
$raw_tabs = $settings['user_center_tabs'] ?? 'profile,level,comments,credits,revenue,favorites,auth';
$raw_tabs = $settings['user_center_tabs'] ?? 'profile,level,comments,credits,promotion,revenue,favorites,auth';
$tabs = is_array($raw_tabs)
? $raw_tabs
: preg_split('/[\s,]+/', (string) $raw_tabs, -1, PREG_SPLIT_NO_EMPTY);
// Built-in switchable panels. VIP is always auto-injected when enabled
// by AKSRT\User\Center — keep it allowed for explicit config too.
$allowed_tabs = ['profile', 'level', 'comments', 'favorites', 'credits', 'revenue', 'vip', 'orders', 'auth', 'settings'];
$allowed_tabs = ['profile', 'level', 'comments', 'favorites', 'credits', 'promotion', 'revenue', 'vip', 'orders', 'auth', 'settings'];
$normalized_tabs = ['profile'];
foreach ((array) $tabs as $tab) {
+20 -4
View File
@@ -78,7 +78,7 @@ class Center {
$revenue_request = [];
if ($tab === 'revenue') {
$reward_tab = sanitize_key((string) $request->get_param('reward_tab'));
if (!in_array($reward_tab, ['detail', 'orders', 'users'], true)) {
if (!in_array($reward_tab, ['detail', 'orders'], true)) {
$reward_tab = 'detail';
}
$revenue_request = [
@@ -188,6 +188,12 @@ class Center {
'priority' => 55,
'type' => 'panel',
],
'promotion' => [
'label' => __('推广中心', 'aksrt'),
'icon' => 'share-alt',
'priority' => 54,
'type' => 'panel',
],
'notif' => [
'label' => __('消息通知', 'aksrt'),
'icon' => 'bell',
@@ -218,9 +224,10 @@ class Center {
&& !empty(\AKSRT\User\Level::settings()['enabled']);
$credits_on = class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::credits_enabled();
$balance_on = class_exists('\AKSRT\Credits\Balance') && \AKSRT\Credits\Balance::enabled();
$revenue_tab_on = class_exists('\AKSRT\Settings\Settings')
&& !empty(\AKSRT\Settings\Settings::get_payment_affiliate_settings()['enabled'])
&& \AKSRT\Settings\Settings::invite_registration_enabled();
$promotion_tab_on = class_exists('\AKSRT\Settings\Settings')
&& !empty(\AKSRT\Settings\Settings::get_affiliate_settings()['enabled']);
$revenue_tab_on = $promotion_tab_on || (class_exists('\AKSRT\Settings\Settings')
&& !empty(\AKSRT\Settings\Settings::get_payment_revenue_settings()['enabled']));
$creator_tab_on = class_exists('\AKSRT\Settings\Settings')
&& !empty(\AKSRT\Settings\Settings::get_payment_revenue_settings()['enabled']);
$vip_on = class_exists('\AKSRT\Settings\Settings')
@@ -299,6 +306,12 @@ class Center {
}
array_splice($order, $insert_at, 0, ['revenue']);
}
if ($promotion_tab_on && !in_array('promotion', $order, true)) {
$insert_at = count($order);
$idx = array_search('revenue', $order, true);
if ($idx !== false) { $insert_at = (int) $idx; }
array_splice($order, $insert_at, 0, ['promotion']);
}
$auth_on = class_exists('\AKSRT\User\AuthApply')
&& !empty(\AKSRT\User\AuthApply::settings()['enabled']);
if ($auth_on && !in_array('auth', $order, true)) {
@@ -321,6 +334,9 @@ class Center {
if ($key === 'revenue' && !$revenue_tab_on) {
continue;
}
if ($key === 'promotion' && !$promotion_tab_on) {
continue;
}
if ($key === 'vip' && !$vip_on) {
continue;
+11
View File
@@ -0,0 +1,11 @@
<?php
/** Canonical shared widget field contract. */
namespace AKSRT\Widgets;
defined('ABSPATH') || exit;
final class CommonWidgetFields {
public static function defaults(): array { return ['subtitle'=>'','title_link'=>'','display_pc'=>1,'display_mobile'=>1,'sticky'=>0,'animation'=>'none','background'=>'surface','dark_background'=>'surface']; }
public static function normalize(array $instance): array {
$d=self::defaults(); $out=$instance; $out['subtitle']=sanitize_text_field((string)($instance['subtitle']??$d['subtitle'])); $out['title_link']=esc_url_raw((string)($instance['title_link']??'')); foreach(['display_pc','display_mobile','sticky'] as $k)$out[$k]=!empty($instance[$k])?1:0; $a=sanitize_key((string)($instance['animation']??'none')); $out['animation']=in_array($a,['none','fade','slide'],true)?$a:'none'; $b=sanitize_key((string)($instance['background']??'surface')); $out['background']=in_array($b,['surface','transparent','primary'],true)?$b:'surface'; $db=sanitize_key((string)($instance['dark_background']??'surface')); $out['dark_background']=in_array($db,['surface','transparent','primary'],true)?$db:'surface'; return $out;
}
}
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace AKSRT\Widgets;
require_once __DIR__ . '/MarketingBlocksWidget.php';
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace AKSRT\Widgets;
require_once __DIR__ . '/MarketingBlocksWidget.php';
+18
View File
@@ -0,0 +1,18 @@
<?php
/** Structured marketing widgets (no commerce/forum behavior). */
namespace AKSRT\Widgets;
defined('ABSPATH') || exit;
abstract class MarketingWidgetBase extends \WP_Widget {
protected string $mode = 'cards';
public function __construct(string $id, string $name, string $mode) { $this->mode=$mode; parent::__construct($id, esc_html__('AKSRT '.$name,'aksrt'), ['description'=>esc_html__('结构化营销展示模块','aksrt')]); }
public function form($instance): void { $title=sanitize_text_field((string)($instance['title']??'')); $items=(array)($instance['items']??[]); if(!$items && !empty($instance['items_raw'])) $items=array_filter(array_map('trim',preg_split('/\r?\n/',(string)$instance['items_raw']))); $raw=implode("\n",array_map(static fn($row)=>is_array($row)?implode('|',array_map('strval',$row)):(string)$row,$items)); echo '<p><label>'.esc_html__('标题','aksrt').'</label><input class="widefat" name="'.esc_attr($this->get_field_name('title')).'" value="'.esc_attr($title).'"></p><p><label>'.esc_html__('条目(每行:标题|描述|链接)','aksrt').'</label><textarea class="widefat" rows="6" name="'.esc_attr($this->get_field_name('items_raw')).'">'.esc_textarea($raw).'</textarea></p>'; }
public function update($new_instance,$old_instance): array { $rows=[]; foreach(preg_split('/\r?\n/',(string)($new_instance['items_raw']??'')) as $line){$parts=array_map('trim',explode('|',$line,3)); if(trim(implode('',$parts))==='')continue; $rows[]=['title'=>sanitize_text_field($parts[0]??''),'desc'=>sanitize_text_field($parts[1]??''),'url'=>esc_url_raw($parts[2]??'')];} return ['title'=>sanitize_text_field($new_instance['title']??''),'items'=>$rows]; }
public function widget($args,$instance): void { echo $args['before_widget']; $title=apply_filters('widget_title',$instance['title']??'',$instance,$this->id_base); if($title) echo $args['before_title'].esc_html($title).$args['after_title']; $items=(array)($instance['items']??[]); echo '<div class="aksrt-marketing-block aksrt-marketing-block--'.esc_attr($this->mode).'">'; if(!$items){echo '<p class="widget-empty">'.esc_html__('暂无内容','aksrt').'</p>';} else { foreach($items as $item){$title=sanitize_text_field((string)($item['title']??''));$desc=sanitize_text_field((string)($item['desc']??''));$url=esc_url((string)($item['url']??''));$tag=$url?'<a href="'.$url.'">':'<div>'; echo '<article class="aksrt-marketing-card">'.$tag.'<strong>'.esc_html($title).'</strong>'.($desc!==''?'<p>'.esc_html($desc).'</p>':'').($url?'</a>':'</div>').'</article>'; }} echo '</div>'.$args['after_widget']; }
}
final class PricingPlansWidget extends MarketingWidgetBase { public function __construct(){parent::__construct('aksrt_pricing_plans','定价计划','pricing');} }
final class TestimonialsWidget extends MarketingWidgetBase { public function __construct(){parent::__construct('aksrt_testimonials','评价轮播','testimonials');} }
final class FaqWidget extends MarketingWidgetBase { public function __construct(){parent::__construct('aksrt_faq','FAQ 常见问题','faq');} }
final class StatsWidget extends MarketingWidgetBase { public function __construct(){parent::__construct('aksrt_stats','数据统计','stats');} }
final class HighlightsWidget extends MarketingWidgetBase { public function __construct(){parent::__construct('aksrt_highlights','亮点卡片','highlights');} }
final class PartnersWidget extends MarketingWidgetBase { public function __construct(){parent::__construct('aksrt_partners','合作伙伴滚动','partners');} }
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace AKSRT\Widgets;
require_once __DIR__ . '/MarketingBlocksWidget.php';
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace AKSRT\Widgets;
require_once __DIR__ . '/MarketingBlocksWidget.php';
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace AKSRT\Widgets;
require_once __DIR__ . '/MarketingBlocksWidget.php';
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace AKSRT\Widgets;
require_once __DIR__ . '/MarketingBlocksWidget.php';