From 698a9beb74cdcb1d4a75633cf19bcff76cb07748 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 16 Aug 2026 18:29:31 +0800 Subject: [PATCH] feat: upgrade widgets and affiliate commission flow --- assets/css/routes/user-center/promotion.css | 21 ++ assets/js/credits.js | 40 +++- assets/js/revenue.js | 6 +- functions.php | 9 +- inc/helpers/aksrt-user-center.php | 14 +- .../profile-promotion.php | 16 +- .../user-center-renderers/profile-revenue.php | 84 +++----- .../user-center-renderers/withdraw-modal.php | 1 - inc/modules/widget-common.php | 29 +++ inc/modules/widgets-register.php | 12 ++ src/Admin/PaymentController.php | 27 --- src/Admin/Schema/SchemaPayment.php | 42 ++-- src/Admin/SchemaProvider.php | 12 +- src/Content/PayOrders.php | 44 +--- src/Core/DBMigrator.php | 2 +- src/Core/DBMigratorPart2.php | 78 +++++++ src/Core/DBMigratorPart3.php | 4 + src/Core/WidgetAjax.php | 3 +- src/Payment/CommissionLedger.php | 125 +++++++++++ src/Payment/ReferralAttribution.php | 203 ++++++++++++++++++ src/Payment/Withdrawal.php | 17 +- src/Settings/CreditsPaymentGroups.php | 5 + src/Settings/PaymentRuntime.php | 67 +++--- src/Settings/UserRuntime.php | 4 +- src/User/Center.php | 24 ++- src/Widgets/CommonWidgetFields.php | 11 + src/Widgets/FaqWidget.php | 3 + src/Widgets/HighlightsWidget.php | 3 + src/Widgets/MarketingBlocksWidget.php | 18 ++ src/Widgets/PartnersWidget.php | 3 + src/Widgets/PricingPlansWidget.php | 3 + src/Widgets/StatsWidget.php | 3 + src/Widgets/TestimonialsWidget.php | 3 + 33 files changed, 709 insertions(+), 227 deletions(-) create mode 100644 inc/modules/widget-common.php create mode 100644 src/Payment/CommissionLedger.php create mode 100644 src/Payment/ReferralAttribution.php create mode 100644 src/Widgets/CommonWidgetFields.php create mode 100644 src/Widgets/FaqWidget.php create mode 100644 src/Widgets/HighlightsWidget.php create mode 100644 src/Widgets/MarketingBlocksWidget.php create mode 100644 src/Widgets/PartnersWidget.php create mode 100644 src/Widgets/PricingPlansWidget.php create mode 100644 src/Widgets/StatsWidget.php create mode 100644 src/Widgets/TestimonialsWidget.php diff --git a/assets/css/routes/user-center/promotion.css b/assets/css/routes/user-center/promotion.css index 7b1306ad..7af47243 100644 --- a/assets/css/routes/user-center/promotion.css +++ b/assets/css/routes/user-center/promotion.css @@ -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; } } diff --git a/assets/js/credits.js b/assets/js/credits.js index 866aea27..1b848a40 100644 --- a/assets/js/credits.js +++ b/assets/js/credits.js @@ -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)) { @@ -996,7 +1029,8 @@ function bootAssets() { initSignin(); initSigninGoto(); - initInvite(); + initInvite(); + initAffiliatePromotion(); initVipShop(); initVipPointsExchange(); initCardPass(); diff --git a/assets/js/revenue.js b/assets/js/revenue.js index 1795d62b..f6eb9170 100644 --- a/assets/js/revenue.js +++ b/assets/js/revenue.js @@ -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 = '提交提现申请'; diff --git a/functions.php b/functions.php index 53de23c1..f66d67bb 100644 --- a/functions.php +++ b/functions.php @@ -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')) { diff --git a/inc/helpers/aksrt-user-center.php b/inc/helpers/aksrt-user-center.php index 2bb65e36..d4379a6f 100644 --- a/inc/helpers/aksrt-user-center.php +++ b/inc/helpers/aksrt-user-center.php @@ -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', diff --git a/inc/helpers/user-center-renderers/profile-promotion.php b/inc/helpers/user-center-renderers/profile-promotion.php index 1dcbf053..14342f66 100644 --- a/inc/helpers/user-center-renderers/profile-promotion.php +++ b/inc/helpers/user-center-renderers/profile-promotion.php @@ -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() : []; ?> -
- -
+
+

+ +
—
—
—
+

+

+
diff --git a/inc/helpers/user-center-renderers/profile-revenue.php b/inc/helpers/user-center-renderers/profile-revenue.php index 2c502e01..96057d5d 100644 --- a/inc/helpers/user-center-renderers/profile-revenue.php +++ b/inc/helpers/user-center-renderers/profile-revenue.php @@ -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 = [
- +
@@ -120,35 +109,15 @@ $status_labels = [
-

- +

+
-
diff --git a/inc/helpers/user-center-renderers/withdraw-modal.php b/inc/helpers/user-center-renderers/withdraw-modal.php index 9247135d..d1418c9e 100644 --- a/inc/helpers/user-center-renderers/withdraw-modal.php +++ b/inc/helpers/user-center-renderers/withdraw-modal.php @@ -23,7 +23,6 @@ $withdraw_fee_rate = (int) ($withdraw_settings['service_charge_rate'] ?? 5); diff --git a/inc/modules/widget-common.php b/inc/modules/widget-common.php new file mode 100644 index 00000000..867c2115 --- /dev/null +++ b/inc/modules/widget-common.php @@ -0,0 +1,29 @@ +'','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 '

'; + echo '

'; + echo '

'; + echo '

'; +} +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); diff --git a/inc/modules/widgets-register.php b/inc/modules/widgets-register.php index 3c454227..5e88eb42 100644 --- a/inc/modules/widgets-register.php +++ b/inc/modules/widgets-register.php @@ -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] ?? ''; diff --git a/src/Admin/PaymentController.php b/src/Admin/PaymentController.php index 1252a2a8..f8a2e548 100644 --- a/src/Admin/PaymentController.php +++ b/src/Admin/PaymentController.php @@ -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\d+)', [ - 'methods' => 'GET', - 'callback' => [$this, 'get_rebate_detail'], - 'permission_callback' => [$this, 'check_permission'], - ]); - - // Rebate settle - register_rest_route(self::API_NAMESPACE, '/payment/rebate/(?P\d+)/settle', [ - 'methods' => 'POST', - 'callback' => [$this, 'settle_rebate'], - 'permission_callback' => [$this, 'check_permission'], - ]); - - // Rebate cancel - register_rest_route(self::API_NAMESPACE, '/payment/rebate/(?P\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 { diff --git a/src/Admin/Schema/SchemaPayment.php b/src/Admin/Schema/SchemaPayment.php index 541fb537..ce5257e2 100644 --- a/src/Admin/Schema/SchemaPayment.php +++ b/src/Admin/Schema/SchemaPayment.php @@ -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。'], ]], ], ], diff --git a/src/Admin/SchemaProvider.php b/src/Admin/SchemaProvider.php index 75105dcf..e4307b54 100644 --- a/src/Admin/SchemaProvider.php +++ b/src/Admin/SchemaProvider.php @@ -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') diff --git a/src/Content/PayOrders.php b/src/Content/PayOrders.php index b36b4ea1..06ed8f2d 100644 --- a/src/Content/PayOrders.php +++ b/src/Content/PayOrders.php @@ -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. * diff --git a/src/Core/DBMigrator.php b/src/Core/DBMigrator.php index 14ab076c..826a520b 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 = '1.22.0'; + const SCHEMA_VERSION = '1.23.0'; const OPT_KEY = 'aksrt_db_schema_version'; diff --git a/src/Core/DBMigratorPart2.php b/src/Core/DBMigratorPart2.php index 4fc41bda..4f356712 100644 --- a/src/Core/DBMigratorPart2.php +++ b/src/Core/DBMigratorPart2.php @@ -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, diff --git a/src/Core/DBMigratorPart3.php b/src/Core/DBMigratorPart3.php index 10ad66e8..6d015147 100644 --- a/src/Core/DBMigratorPart3.php +++ b/src/Core/DBMigratorPart3.php @@ -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', diff --git a/src/Core/WidgetAjax.php b/src/Core/WidgetAjax.php index eaf61eea..23788f96 100644 --- a/src/Core/WidgetAjax.php +++ b/src/Core/WidgetAjax.php @@ -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']), diff --git a/src/Payment/CommissionLedger.php b/src/Payment/CommissionLedger.php new file mode 100644 index 00000000..755bb111 --- /dev/null +++ b/src/Payment/CommissionLedger.php @@ -0,0 +1,125 @@ +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\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\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\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); } +} diff --git a/src/Payment/ReferralAttribution.php b/src/Payment/ReferralAttribution.php new file mode 100644 index 00000000..dc5139f6 --- /dev/null +++ b/src/Payment/ReferralAttribution.php @@ -0,0 +1,203 @@ +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; + } +} diff --git a/src/Payment/Withdrawal.php b/src/Payment/Withdrawal.php index a1d06669..a366efef 100644 --- a/src/Payment/Withdrawal.php +++ b/src/Payment/Withdrawal.php @@ -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; } diff --git a/src/Settings/CreditsPaymentGroups.php b/src/Settings/CreditsPaymentGroups.php index ef6a86ac..676145bd 100644 --- a/src/Settings/CreditsPaymentGroups.php +++ b/src/Settings/CreditsPaymentGroups.php @@ -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} */ diff --git a/src/Settings/PaymentRuntime.php b/src/Settings/PaymentRuntime.php index 05b65a6f..daf62497 100644 --- a/src/Settings/PaymentRuntime.php +++ b/src/Settings/PaymentRuntime.php @@ -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 */ @@ -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', ]; } diff --git a/src/Settings/UserRuntime.php b/src/Settings/UserRuntime.php index 7463de34..9a389405 100644 --- a/src/Settings/UserRuntime.php +++ b/src/Settings/UserRuntime.php @@ -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) { diff --git a/src/User/Center.php b/src/User/Center.php index ab78601c..443fc90e 100644 --- a/src/User/Center.php +++ b/src/User/Center.php @@ -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; diff --git a/src/Widgets/CommonWidgetFields.php b/src/Widgets/CommonWidgetFields.php new file mode 100644 index 00000000..cbb4323d --- /dev/null +++ b/src/Widgets/CommonWidgetFields.php @@ -0,0 +1,11 @@ +'','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; + } +} diff --git a/src/Widgets/FaqWidget.php b/src/Widgets/FaqWidget.php new file mode 100644 index 00000000..185d66f0 --- /dev/null +++ b/src/Widgets/FaqWidget.php @@ -0,0 +1,3 @@ +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 '

'; } + 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 '
'; if(!$items){echo '

'.esc_html__('暂无内容','aksrt').'

';} 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?'':'').''; }} echo '
'.$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');} } diff --git a/src/Widgets/PartnersWidget.php b/src/Widgets/PartnersWidget.php new file mode 100644 index 00000000..185d66f0 --- /dev/null +++ b/src/Widgets/PartnersWidget.php @@ -0,0 +1,3 @@ +