feat(affiliate): attribution modes, settlement hardening, trusted proxies, QR and poster

- 归因模式可配置(first_touch/last_touch)、停佣开关、懒结算与佣金基数降级复查
- aksrt_get_client_ip 接入 aksrt_trusted_proxies 信任代理名单,防 CDN 头伪造
- 收益页 Canvas 二维码 + 750x1000 推广海报(本地打包 qrcode.min.js,无 CDN)
- 契约测试固化归因/海报/信任代理链路
This commit is contained in:
2026-09-21 20:53:22 +08:00
parent 2618530afb
commit 20387fdb06
14 changed files with 715 additions and 6 deletions
+5
View File
@@ -312,6 +312,10 @@ class SchemaPayment {
]],
['key' => 'pay_rebate_show_users', 'label' => '用户中心显示我推荐的用户', 'type' => 'switch', 'default' => true, 'dependsOn' => ['key' => 'pay_rebate_judgment', 'value' => 'all']],
['key' => 'pay_rebate_self_s', 'label' => '自己推广自己', 'type' => 'switch', 'default' => true, 'dependsOn' => ['key' => 'pay_rebate_s', 'value' => true], 'help' => '允许使用自己的推广链接购买商品并获得佣金'],
['key' => 'referral_attribution_model', 'label' => '推广归因模式', 'type' => 'radio', 'default' => 'first_touch', 'options' => [
['label' => '首次触点(首触优先)', 'value' => 'first_touch'],
['label' => '最近触点(末触覆盖)', 'value' => 'last_touch'],
], 'help' => '首触:访客首次通过推广链接进入后,归因固定且不被后续链接覆盖;末触:最近一次点击的推广码覆盖之前的归因'],
]],
['key' => 'rules', 'title' => '返佣规则', 'fields' => [
['key' => 'rebate_rule', 'label' => '为不同用户类型设置返佣规则', 'type' => 'tabbed', 'tabs' => [
@@ -344,6 +348,7 @@ class SchemaPayment {
]],
]],
['key' => 'affiliate_settlement_days', 'label' => '结算观察期(天)', 'type' => 'number', 'default' => 7, 'min' => 0, 'max' => 90, 'dependsOn' => ['key' => 'pay_rebate_s', 'value' => true], 'help' => '订单完成后经过观察期,佣金才会结算到可提现余额'],
['key' => 'affiliate_disabled_code_stops_commission', 'label' => '推广码禁用后停止返佣', 'type' => 'switch', 'default' => false, 'dependsOn' => ['key' => 'pay_rebate_s', 'value' => true], 'help' => '开启后,推广者的推广码被禁用(或缺失)时,其名下已绑定用户的新订单不再产生佣金;关闭则绑定关系继续生效'],
]],
['key' => 'text', 'title' => '返佣文案', 'fields' => [
['key' => 'pay_rebate_text_desc', 'label' => '返佣文案(一句话简介)', 'type' => 'input', 'default' => '加入分享计划,获得高额奖励', 'dependsOn' => ['key' => 'pay_rebate_s', 'value' => true]],
+14 -1
View File
@@ -691,11 +691,24 @@ trait ScriptsPart1b {
// profile 页:加载 credits.js(签到 + 邀请 + 提现交互)
if ($is_profile_page) {
$affiliate_deps = [];
if ($affiliate_on) {
// 推广二维码生成库(本地打包,无外部 CDN)。
$qrcode_js_path = get_template_directory() . '/assets/js/vendor/qrcode.min.js';
wp_enqueue_script(
'aksrt-qrcode',
$base_uri . '/assets/js/vendor/qrcode.min.js',
[],
file_exists($qrcode_js_path) ? (string) filemtime($qrcode_js_path) : $version,
true
);
$affiliate_deps[] = 'aksrt-qrcode';
}
$credits_js_path = get_template_directory() . '/assets/js/credits.js';
wp_enqueue_script(
'aksrt-credits',
$base_uri . '/assets/js/credits.js',
['loader_js', 'aksrt-vip-coordinator'],
array_merge(['loader_js', 'aksrt-vip-coordinator'], $affiliate_deps),
file_exists($credits_js_path) ? (string) filemtime($credits_js_path) : $version,
true
);
+14
View File
@@ -57,6 +57,11 @@ final class CommissionLedger {
if ($order_no === '' || $buyer <= 0 || $amount->minor() <= 0) return 0;
$promoter = ReferralAttribution::get_instance()->resolve($buyer);
if ($promoter <= 0) return 0;
// 开关开启时,推广者的推广码已被禁用(或缺失)则不再产生新佣金。
if (!empty($settings['disabled_code_stops_commission'])
&& !ReferralAttribution::get_instance()->is_promoter_code_active($promoter)) {
return 0;
}
// 返佣订单类型按推广者会员等级分档(参考主题:每档独立勾选)。
$promoter_level = function_exists('aksrt_get_user_vip_level') ? (int) aksrt_get_user_vip_level($promoter) : 0;
if (!in_array($order_type, \AKSRT\Settings\PaymentRuntime::affiliate_order_types_for_level($promoter_level), true)) return 0;
@@ -98,6 +103,14 @@ final class CommissionLedger {
foreach ((array) $rows as $row) if ($this->settle_one((int)$row['id'])) $count++;
return $count;
}
/** 懒结算兜底:收益相关入口顺带结算到期佣金,弥补 wp-cron 在低流量站点的时效缺口。 */
private static bool $lazy_settled = false;
public function lazy_settle_due(): void {
if (self::$lazy_settled) return;
self::$lazy_settled = true;
if (!\AKSRT\Settings\Settings::affiliate_enabled()) return;
$this->settle_due(20);
}
private function settle_one(int $id, string $operation_key = '') {
global $wpdb; $table = $this->table('commission_ledger');
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id=%d", $id), ARRAY_A);
@@ -228,6 +241,7 @@ final class CommissionLedger {
return $negative ? -$minor : $minor;
}
public function overview(int $user_id): array {
$this->lazy_settle_due();
global $wpdb; $table=$this->table('commission_ledger');
$row=$wpdb->get_row($wpdb->prepare("SELECT COALESCE(SUM(original_amount+adjustment_total),0) total, COALESCE(SUM(CASE WHEN status='pending' THEN original_amount+adjustment_total ELSE 0 END),0) pending, COALESCE(SUM(CASE WHEN status='settled' THEN original_amount+adjustment_total ELSE 0 END),0) settled FROM {$table} WHERE promoter_id=%d",$user_id),ARRAY_A) ?: [];
$withdrawn = '0.00';
+32 -5
View File
@@ -51,7 +51,12 @@ final class ReferralAttribution {
return;
}
if ($this->cookie_ref() !== '') {
$current = $this->cookie_ref();
if ($current === $raw) {
return;
}
// 首触模式:已有归因不覆盖;末触模式:最近一次点击覆盖之前的归因。
if ($current !== '' && $this->attribution_model() !== 'last_touch') {
return;
}
@@ -75,21 +80,28 @@ final class ReferralAttribution {
$submitted_ref = sanitize_key($submitted_ref);
$cookie_ref = $this->cookie_ref();
$submitted_valid = $submitted_ref !== '' && $this->resolve_promoter($submitted_ref) > 0;
$last_touch = $this->attribution_model() === 'last_touch';
$conflict = $cookie_ref !== '' && $submitted_ref !== '' && $submitted_ref !== $cookie_ref;
if ($conflict) {
self::$registration_notice = __('检测到不同的推广码,已按首次访问的推广关系绑定。', 'aksrt');
self::$registration_notice = $last_touch
? __('检测到不同的推广码,已按最近访问的推广关系绑定。', 'aksrt')
: __('检测到不同的推广码,已按首次访问的推广关系绑定。', 'aksrt');
}
$ref = $cookie_ref !== '' ? $cookie_ref : ($submitted_valid ? $submitted_ref : '');
// 首触:cookie 优先;末触:注册时填写的有效推广码优先。
$ref = $cookie_ref !== '' && !($last_touch && $submitted_valid)
? $cookie_ref
: ($submitted_valid ? $submitted_ref : $cookie_ref);
self::$pending_registration_ref = $ref;
$source = $ref === '' ? 'none' : ($ref === $cookie_ref ? 'cookie' : 'submitted');
return [
'ref' => $ref,
'valid' => $ref !== '',
'submitted_valid' => $submitted_valid,
'conflict' => $conflict,
'source' => $cookie_ref !== '' ? 'cookie' : ($submitted_valid ? 'submitted' : 'none'),
'source' => $source,
];
}
@@ -377,6 +389,7 @@ final class ReferralAttribution {
}
public function rest_me(): \WP_REST_Response {
\AKSRT\Payment\CommissionLedger::get_instance()->lazy_settle_due();
$user_id = get_current_user_id();
$link = $this->create_link($user_id);
$stats = $this->stats($user_id);
@@ -387,6 +400,8 @@ final class ReferralAttribution {
'ref' => $link['ref'],
'url' => $link['url'],
'status' => $link['status'],
'site_name' => get_bloginfo('name'),
'poster' => (array) ($affiliate['poster'] ?? ['enabled' => false]),
'features' => [
'registration' => !empty($registration['enabled']),
'points_reward' => !empty($reward['enabled']),
@@ -612,7 +627,19 @@ final class ReferralAttribution {
private function shared_settings(): array {
return class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_referral_settings()
: ['code_length' => 12, 'window_days' => 30];
: ['code_length' => 12, 'window_days' => 30, 'attribution_model' => 'first_touch'];
}
private function attribution_model(): string {
return (string) ($this->shared_settings()['attribution_model'] ?? 'first_touch');
}
/** 推广者的推广码当前是否处于激活状态(码缺失视为未激活)。 */
public function is_promoter_code_active(int $promoter_id): bool {
if ($promoter_id <= 0) {
return false;
}
return $this->code_record($promoter_id)['status'] === self::STATUS_ACTIVE;
}
private function registration_settings(): array {
+7
View File
@@ -339,9 +339,16 @@ class PaymentRuntime {
],
'order_types' => $types_by_level[0],
'order_types_by_level' => $types_by_level,
'disabled_code_stops_commission' => !empty($settings['affiliate_disabled_code_stops_commission']),
'window_days' => (int) CreditsRuntime::get_referral_settings()['window_days'],
'settlement_days' => max(0, min(90, (int) ($settings['affiliate_settlement_days'] ?? 7))),
'promotion_text' => sanitize_textarea_field((string) ($settings['pay_rebate_text_details'] ?? '')),
'poster' => [
'enabled' => !isset($settings['pay_rebate_poster_s']) || !empty($settings['pay_rebate_poster_s']),
'image' => esc_url_raw((string) ($settings['pay_rebate_poster_img'] ?? '')),
'title' => sanitize_text_field((string) ($settings['pay_rebate_poster_title'] ?? '')),
'description' => sanitize_text_field((string) ($settings['pay_rebate_poster_desc'] ?? '')),
],
];
}