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:
@@ -27,6 +27,8 @@ export const affiliateSchema = {
|
||||
pay_rebate_user_s_2: ['all'],
|
||||
pay_rebate_ratio_vip_2: 20,
|
||||
affiliate_settlement_days: 7,
|
||||
affiliate_disabled_code_stops_commission: false,
|
||||
referral_attribution_model: 'first_touch',
|
||||
pay_rebate_text_desc: '加入分享计划,获得高额奖励',
|
||||
pay_rebate_text_details_title: '返佣详解',
|
||||
pay_rebate_text_details: '<p>通过您的推广链接打开本站后,在本站购买商品即可获得佣金</p><p>通过您的推广链接注册后的用户今后购买的商品均可获得佣金</p><p>申请提现后,需后台人工处理,一般2-3小时,请耐心等待</p>',
|
||||
@@ -77,6 +79,17 @@ export const affiliateSchema = {
|
||||
},
|
||||
{ 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: '首触:访客首次通过推广链接进入后,归因固定且不被后续链接覆盖;末触:最近一次点击的推广码覆盖之前的归因',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -140,6 +153,7 @@ export const affiliateSchema = {
|
||||
],
|
||||
},
|
||||
{ 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: '开启后,推广者的推广码被禁用(或缺失)时,其名下已绑定用户的新订单不再产生佣金;关闭则绑定关系继续生效' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -211,6 +211,15 @@
|
||||
.uc-content .uc-revenue-summary-row--link button { min-width: 64px; height: 38px; padding: 0 12px; border-radius: 7px; }
|
||||
.uc-content .uc-revenue-summary-row--link small { grid-column: 2 / -1; min-height: 16px; color: var(--color-success, #16a34a); font-size: 12px; }
|
||||
.uc-content .uc-revenue-summary-row--link small.is-error { color: var(--color-error, #dc2626); }
|
||||
|
||||
/* 推广二维码 — bundled qrcode canvas + download. */
|
||||
.uc-content .uc-revenue-summary-row--qr { align-items: flex-start; }
|
||||
.uc-content .uc-revenue-qr { display: grid; justify-items: start; gap: 8px; }
|
||||
.uc-content .uc-revenue-qr canvas { display: block; width: 148px; height: 148px; border: 1px solid var(--color-line-soft, rgba(15, 23, 42, .12)); border-radius: 10px; background: #fff; }
|
||||
.uc-content .uc-revenue-qr__actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.uc-content .uc-revenue-qr__actions button { min-width: 96px; height: 32px; padding: 0 12px; border-radius: 7px; }
|
||||
.uc-content .uc-revenue-qr small { min-height: 16px; color: var(--color-success, #16a34a); font-size: 12px; }
|
||||
.uc-content .uc-revenue-qr small.is-error { color: var(--color-error, #dc2626); }
|
||||
.uc-content .uc-revenue-summary-row--total > div { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.uc-content .uc-revenue-summary-row--total strong { padding: 7px 11px; border-radius: 7px; color: var(--color-primary); background: var(--surface-2, #f8fafc); font-size: 14px; }
|
||||
.uc-content .uc-revenue-summary-row--explain { align-items: start; padding-top: 16px; border-bottom: 0; }
|
||||
@@ -382,4 +391,5 @@
|
||||
.uc-content .uc-revenue-summary-row--link small { grid-column: 1 / -1; }
|
||||
.uc-content .uc-revenue-summary-row--explain { grid-template-columns: 1fr; }
|
||||
.uc-content .uc-revenue-summary-row--explain .uc-reward-richtext { grid-column: 1; }
|
||||
.uc-content .uc-revenue-summary-row--qr { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -137,6 +137,8 @@
|
||||
var ratio = root.querySelector('[data-affiliate-ratio]'); if (ratio) ratio.textContent = String(j.current_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);
|
||||
renderAffiliateQr(root, j.url || '');
|
||||
initAffiliatePoster(root, j.url || '', j.poster || {}, j.site_name || document.title);
|
||||
}).catch(function () { setFeedback('推广数据加载失败。', true); });
|
||||
if (copy) copy.addEventListener('click', function () {
|
||||
if (!input || !input.value) return;
|
||||
@@ -146,6 +148,183 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Referral QR code (bundled qrcode-generator, no CDN) =====
|
||||
function renderAffiliateQr(root, url) {
|
||||
var row = root.querySelector('[data-affiliate-qr-row]');
|
||||
var canvas = root.querySelector('[data-affiliate-qr]');
|
||||
if (!row || !canvas) return;
|
||||
if (!url || typeof window.qrcode !== 'function') {
|
||||
row.hidden = true;
|
||||
return;
|
||||
}
|
||||
var feedback = root.querySelector('[data-affiliate-qr-feedback]');
|
||||
function setQrFeedback(text, error) { if (feedback) { feedback.textContent = text; feedback.classList.toggle('is-error', !!error); } }
|
||||
try {
|
||||
var qr = window.qrcode(0, 'M');
|
||||
qr.addData(url);
|
||||
qr.make();
|
||||
var count = qr.getModuleCount();
|
||||
var cell = 4, margin = 4;
|
||||
var size = (count + margin * 2) * cell;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
var ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
ctx.fillStyle = '#0f172a';
|
||||
for (var r = 0; r < count; r++) {
|
||||
for (var c = 0; c < count; c++) {
|
||||
if (qr.isDark(r, c)) {
|
||||
ctx.fillRect((c + margin) * cell, (r + margin) * cell, cell, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
row.hidden = true;
|
||||
return;
|
||||
}
|
||||
var download = root.querySelector('[data-affiliate-qr-download]');
|
||||
if (download) download.addEventListener('click', function () {
|
||||
try {
|
||||
var link = document.createElement('a');
|
||||
link.download = 'referral-qr.png';
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setQrFeedback('二维码已下载。');
|
||||
} catch (err) {
|
||||
setQrFeedback('二维码下载失败,请截图保存。', true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Referral poster (canvas: hero image + copy + QR + link) =====
|
||||
function initAffiliatePoster(root, url, poster, siteName) {
|
||||
var button = root.querySelector('[data-affiliate-poster]');
|
||||
if (!button) return;
|
||||
if (!url || !poster || poster.enabled !== true) return;
|
||||
button.hidden = false;
|
||||
button.addEventListener('click', function () {
|
||||
var feedback = root.querySelector('[data-affiliate-qr-feedback]');
|
||||
function setPosterFeedback(text, error) { if (feedback) { feedback.textContent = text; feedback.classList.toggle('is-error', !!error); } }
|
||||
if (typeof window.qrcode !== 'function') { setPosterFeedback('二维码组件未加载。', true); return; }
|
||||
button.disabled = true;
|
||||
buildPosterCanvas(url, poster, siteName).then(function (canvas) {
|
||||
var link = document.createElement('a');
|
||||
link.download = 'referral-poster.png';
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setPosterFeedback('推广海报已生成并下载。');
|
||||
}).catch(function () {
|
||||
setPosterFeedback('海报生成失败,请重试。', true);
|
||||
}).finally(function () {
|
||||
button.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildPosterCanvas(url, poster, siteName) {
|
||||
var width = 750, height = 1000;
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
var ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
var loadImage = null;
|
||||
if (poster.image) {
|
||||
loadImage = new Promise(function (resolve) {
|
||||
var image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = function () { resolve(image); };
|
||||
image.onerror = function () { resolve(null); };
|
||||
image.src = poster.image;
|
||||
});
|
||||
} else {
|
||||
loadImage = Promise.resolve(null);
|
||||
}
|
||||
return loadImage.then(function (image) {
|
||||
// 顶部图(cover 裁切)或纯色占位。
|
||||
if (image) {
|
||||
var ratio = Math.max(width / image.width, 360 / image.height);
|
||||
var drawWidth = image.width * ratio;
|
||||
var drawHeight = image.height * ratio;
|
||||
ctx.drawImage(image, (width - drawWidth) / 2, 0, drawWidth, drawHeight, 0, 0, width, 360);
|
||||
} else {
|
||||
ctx.fillStyle = '#0f172a';
|
||||
ctx.fillRect(0, 0, width, 200);
|
||||
}
|
||||
var cursorY = image ? 420 : 280;
|
||||
ctx.fillStyle = '#0f172a';
|
||||
ctx.font = 'bold 38px sans-serif';
|
||||
var title = String(poster.title || siteName || '');
|
||||
var titleLines = wrapText(ctx, title, width - 80).slice(0, 2);
|
||||
for (var t = 0; t < titleLines.length; t++) {
|
||||
ctx.fillText(titleLines[t], 40, cursorY);
|
||||
cursorY += 56;
|
||||
}
|
||||
cursorY += 8;
|
||||
var description = String(poster.description || '');
|
||||
if (description) {
|
||||
ctx.fillStyle = '#475569';
|
||||
ctx.font = '26px sans-serif';
|
||||
var lines = wrapText(ctx, description, width - 80, 26).slice(0, 3);
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
ctx.fillText(lines[i], 40, cursorY);
|
||||
cursorY += 40;
|
||||
}
|
||||
}
|
||||
// 二维码 + 链接 + 站点名。
|
||||
var qr = window.qrcode(0, 'M');
|
||||
qr.addData(url);
|
||||
qr.make();
|
||||
var count = qr.getModuleCount();
|
||||
var cell = 6, margin = 4;
|
||||
var qrSize = (count + margin * 2) * cell;
|
||||
var qrY = height - qrSize - 130;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(40 - 8, qrY - 8, qrSize + 16, qrSize + 16);
|
||||
ctx.strokeStyle = '#e2e8f0';
|
||||
ctx.strokeRect(40 - 8, qrY - 8, qrSize + 16, qrSize + 16);
|
||||
ctx.fillStyle = '#0f172a';
|
||||
for (var r = 0; r < count; r++) {
|
||||
for (var c = 0; c < count; c++) {
|
||||
if (qr.isDark(r, c)) {
|
||||
ctx.fillRect(40 + (c + margin) * cell, qrY + (r + margin) * cell, cell, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.fillStyle = '#475569';
|
||||
ctx.font = '24px sans-serif';
|
||||
ctx.fillText('扫描二维码访问', 40 + qrSize + 40, qrY + 40);
|
||||
ctx.fillStyle = '#0f172a';
|
||||
ctx.font = 'bold 28px sans-serif';
|
||||
ctx.fillText(String(siteName || ''), 40 + qrSize + 40, qrY + 90);
|
||||
ctx.fillStyle = '#94a3b8';
|
||||
ctx.font = '20px sans-serif';
|
||||
ctx.fillText(url.slice(0, 60), 40, height - 40);
|
||||
return canvas;
|
||||
});
|
||||
}
|
||||
|
||||
function wrapText(ctx, text, maxWidth) {
|
||||
var lines = [];
|
||||
var current = '';
|
||||
for (var i = 0; i < text.length; i++) {
|
||||
if (ctx.measureText(current + text[i]).width > maxWidth && current) {
|
||||
lines.push(current);
|
||||
current = text[i];
|
||||
} else {
|
||||
current += text[i];
|
||||
}
|
||||
}
|
||||
if (current) lines.push(current);
|
||||
return lines;
|
||||
}
|
||||
|
||||
window.AKSRTAffiliateRevenueInit = initAffiliateRevenue;
|
||||
|
||||
// ===== VIP 开通 =====
|
||||
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -145,6 +145,17 @@ $status_labels = [
|
||||
<button type="button" class="btn-primary btn-sm" data-affiliate-copy><?php esc_html_e('复制', 'aksrt'); ?></button>
|
||||
<small data-affiliate-feedback role="status" aria-live="polite"></small>
|
||||
</div>
|
||||
<div class="uc-revenue-summary-row uc-revenue-summary-row--qr" data-affiliate-qr-row>
|
||||
<span><?php esc_html_e('推广二维码', 'aksrt'); ?></span>
|
||||
<div class="uc-revenue-qr">
|
||||
<canvas data-affiliate-qr width="148" height="148" aria-label="<?php esc_attr_e('推广链接二维码', 'aksrt'); ?>"></canvas>
|
||||
<div class="uc-revenue-qr__actions">
|
||||
<button type="button" class="btn-sm" data-affiliate-qr-download><?php esc_html_e('下载二维码', 'aksrt'); ?></button>
|
||||
<button type="button" class="btn-sm" data-affiliate-poster hidden><?php esc_html_e('生成推广海报', 'aksrt'); ?></button>
|
||||
</div>
|
||||
<small data-affiliate-qr-feedback role="status" aria-live="polite"></small>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($affiliate_on) : ?><div class="uc-revenue-summary-row"><span><?php esc_html_e('佣金比例', 'aksrt'); ?></span><strong data-affiliate-ratio><?php echo esc_html((string) $commission_ratio); ?>%</strong></div><?php endif; ?>
|
||||
<div class="uc-revenue-summary-row"><span><?php esc_html_e('推广人数', 'aksrt'); ?></span><div class="uc-revenue-count"><strong data-affiliate-promoters>—</strong><em><?php esc_html_e('人', 'aksrt'); ?></em></div></div>
|
||||
<?php if ($affiliate_on) : ?>
|
||||
|
||||
@@ -570,6 +570,15 @@ add_action('admin_notices', static function () {
|
||||
// ============================================================
|
||||
|
||||
function aksrt_get_client_ip(): string {
|
||||
$remote = isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : '';
|
||||
|
||||
// 信任代理名单:过滤返回非空 CIDR/IP 列表时,仅当 REMOTE_ADDR 命中名单
|
||||
// 才采纳 CDN / 反代头;否则客户端可伪造这些头绕过 IP 维度的风控。
|
||||
$trusted = apply_filters('aksrt_trusted_proxies', []);
|
||||
if (is_array($trusted) && $trusted !== [] && $remote !== '' && !aksrt_ip_in_list($remote, $trusted)) {
|
||||
return $remote;
|
||||
}
|
||||
|
||||
// Prefer CDN / reverse-proxy headers (Cloudflare first), then generic proxies.
|
||||
$keys = [
|
||||
'HTTP_CF_CONNECTING_IP',
|
||||
@@ -601,6 +610,51 @@ function aksrt_get_client_ip(): string {
|
||||
return isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : '0.0.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* IP 是否命中精确 IP 或 CIDR 名单(IPv4 与 IPv6)。
|
||||
*
|
||||
* @param string $ip 待判定 IP。
|
||||
* @param array $list 精确 IP / CIDR 段列表。
|
||||
*/
|
||||
function aksrt_ip_in_list(string $ip, array $list): bool {
|
||||
foreach ($list as $entry) {
|
||||
$entry = trim((string) $entry);
|
||||
if ($entry === '') {
|
||||
continue;
|
||||
}
|
||||
if (!str_contains($entry, '/')) {
|
||||
if (strcasecmp($entry, $ip) === 0) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
[$subnet, $prefix] = explode('/', $entry, 2);
|
||||
$prefix = (int) $prefix;
|
||||
$ip_binary = @inet_pton($ip);
|
||||
$subnet_binary = @inet_pton($subnet);
|
||||
if ($ip_binary === false || $subnet_binary === false || strlen($ip_binary) !== strlen($subnet_binary)) {
|
||||
continue;
|
||||
}
|
||||
$max_bits = strlen($ip_binary) * 8;
|
||||
if ($prefix < 0 || $prefix > $max_bits) {
|
||||
continue;
|
||||
}
|
||||
$full_bytes = intdiv($prefix, 8);
|
||||
$remain_bits = $prefix % 8;
|
||||
if ($full_bytes > 0 && substr($ip_binary, 0, $full_bytes) !== substr($subnet_binary, 0, $full_bytes)) {
|
||||
continue;
|
||||
}
|
||||
if ($remain_bits > 0) {
|
||||
$mask = chr((0xFF << (8 - $remain_bits)) & 0xFF);
|
||||
if ((substr($ip_binary, $full_bytes, 1) & $mask) !== (substr($subnet_binary, $full_bytes, 1) & $mask)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Native wp-comments-post + any path that omits comment_author_IP.
|
||||
// Ensures comment_author_IP (and thus aksrt_comment_addr) is the real client IP behind CDN.
|
||||
add_filter('pre_comment_user_ip', static function ($ip) {
|
||||
|
||||
@@ -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]],
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'] ?? '')),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const root = path.resolve(__dirname, '..', '..');
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
|
||||
test('captcha PHP security harness passes', () => {
|
||||
const output = execFileSync('php', [path.join(root, 'tests/widget-system/captcha-security-harness.php')], { encoding: 'utf8' });
|
||||
assert.match(output, /Captcha security harness passed/);
|
||||
});
|
||||
|
||||
test('captcha exchange binds provider tokens to scene browser and one-time business proofs', () => {
|
||||
const module = read('inc/modules/captcha.php');
|
||||
const front = read('assets/js/captcha-front.js');
|
||||
assert.match(module, /\/captcha\/exchange/);
|
||||
assert.match(module, /aksrt_captcha_issue_challenge/);
|
||||
assert.match(module, /aksrt_captcha_validate_challenge/);
|
||||
assert.match(module, /aksrt_captcha_issue_proof/);
|
||||
assert.match(module, /aksrt_captcha_consume_proof/);
|
||||
assert.match(module, /browser_hash/);
|
||||
assert.match(front, /exchange_url/);
|
||||
assert.match(front, /aksrt_captcha_proof/);
|
||||
assert.match(front, /runLocked/);
|
||||
});
|
||||
|
||||
test('captcha defaults are opt-in and provider metadata is centralized', () => {
|
||||
const module = read('inc/modules/captcha.php');
|
||||
const phpSchema = read('src/Admin/Schema/SchemaPermissions.php');
|
||||
const uiSchema = read('assets/admin/src/schema/sections/permissions/index.js');
|
||||
assert.match(module, /function aksrt_captcha_provider_metadata/);
|
||||
assert.match(module, /'enabled_scenarios'\s*=>\s*\[\]/);
|
||||
assert.match(phpSchema, /'default'\s*=>\s*\[\]/);
|
||||
assert.match(uiSchema, /enabled_scenarios:\s*\[\]/);
|
||||
assert.match(phpSchema, /aksrt_captcha_provider_metadata/);
|
||||
});
|
||||
|
||||
test('captcha verification is fail-closed with a true-or-WP_Error contract', () => {
|
||||
const module = read('inc/modules/captcha.php');
|
||||
assert.match(module, /return aksrt_captcha_error\('aksrt_captcha_config'/);
|
||||
assert.match(module, /@return true\|\\WP_Error/);
|
||||
assert.doesNotMatch(module, /is_wp_error\(\$result\) \? \$result->get_error_message\(\) : true/);
|
||||
});
|
||||
|
||||
test('cloudflare siteverify performs one request and strictly binds action, cdata, and hostname', () => {
|
||||
const module = read('inc/modules/captcha.php');
|
||||
const start = module.indexOf('function aksrt_verify_cloudflare');
|
||||
const end = module.indexOf('function aksrt_verify_geetest', start);
|
||||
const cloudflare = module.slice(start, end);
|
||||
assert.equal((cloudflare.match(/wp_remote_post\(/g) || []).length, 1);
|
||||
assert.match(cloudflare, /array_key_exists\('action'/);
|
||||
assert.match(cloudflare, /array_key_exists\('cdata'/);
|
||||
assert.match(cloudflare, /hostname mismatch/);
|
||||
assert.match(cloudflare, /hash_equals/);
|
||||
});
|
||||
|
||||
test('TC3 signing derives tc3_request from the service key', () => {
|
||||
const module = read('inc/modules/captcha.php');
|
||||
assert.match(module, /hash_hmac\('sha256', 'tc3_request', \$secret_service, true\)/);
|
||||
assert.doesNotMatch(module, /hash_hmac\('sha256', 'tc3_request', \$secret_signing, true\)/);
|
||||
});
|
||||
|
||||
test('nested captcha secrets are masked, preserved, explicitly clearable, and excluded from standalone exports', () => {
|
||||
const provider = read('src/Admin/SchemaProvider.php');
|
||||
const settings = read('src/Settings/Settings.php');
|
||||
const page = read('assets/admin/src/pages/SchemaPage.vue');
|
||||
assert.match(provider, /mask_captcha_provider_secrets/);
|
||||
assert.match(provider, /merge_captcha_provider_config/);
|
||||
assert.match(provider, /\$value === '' \|\| \$value === '\*\*\*'/);
|
||||
assert.match(settings, /SCHEMA_MANAGED_STANDALONE_OPTION_KEYS/);
|
||||
assert.match(settings, /'aksrt_captcha_config'/);
|
||||
assert.match(page, /captchaSecretsToClear/);
|
||||
assert.match(page, /clear-secret/);
|
||||
});
|
||||
|
||||
test('admin captcha test obtains a frontend token then calls the protected server verifier', () => {
|
||||
const controller = read('src/Admin/ImportExportController.php');
|
||||
const api = read('assets/admin/src/api/modules/schema.js');
|
||||
const page = read('assets/admin/src/pages/SchemaPage.vue');
|
||||
assert.match(controller, /'\/captcha\/test'/);
|
||||
assert.match(controller, /aksrt_verify_captcha_provider/);
|
||||
assert.match(api, /testCaptcha\(provider, token\)/);
|
||||
assert.match(page, /submitCaptchaTest/);
|
||||
assert.match(page, /action:\s*'aksrt-admin-test'/);
|
||||
assert.match(page, /cdata:\s*'admin-captcha-test'/);
|
||||
assert.doesNotMatch(page, /配置正确/);
|
||||
});
|
||||
|
||||
test('Cloudflare custom domain setting is removed', () => {
|
||||
const component = read('assets/admin/src/components/captcha/CloudflareConfig.vue');
|
||||
assert.doesNotMatch(component, /自定义域名|model\.domain/);
|
||||
});
|
||||
|
||||
test('cloud captcha server verifies over the WP HTTP API without disabling TLS', () => {
|
||||
const module = read('inc/modules/captcha.php');
|
||||
assert.doesNotMatch(module, /curl_init|curl_setopt|CURLOPT_SSL_VERIFYPEER/);
|
||||
const posts = (module.match(/wp_remote_post\(/g) || []).length;
|
||||
assert.ok(posts >= 4, `expected all four providers on wp_remote_post, found ${posts}`);
|
||||
assert.match(module, /wp_generate_uuid4\(\)/, 'aliyun SignatureNonce must not be time-derived');
|
||||
assert.doesNotMatch(module, /uniqid\(/);
|
||||
});
|
||||
|
||||
test('cloud captcha verify requests use a 5s timeout and log raw failures', () => {
|
||||
const module = read('inc/modules/captcha.php');
|
||||
const timeouts = [...module.matchAll(/'timeout'\s*=>\s*(\d+)/g)].map((m) => Number(m[1]));
|
||||
assert.ok(timeouts.length >= 4, 'every provider request must declare a timeout');
|
||||
for (const timeout of timeouts) {
|
||||
assert.strictEqual(timeout, 5);
|
||||
}
|
||||
assert.match(module, /error_log\('\[AKSRT Captcha\]/);
|
||||
});
|
||||
|
||||
test('business requests consume one-time proofs without a shared download bypass', () => {
|
||||
const module = read('inc/modules/captcha.php');
|
||||
assert.match(module, /aksrt_captcha_consume_proof/);
|
||||
assert.doesNotMatch(module, /aksrt_captcha_pass_ttl|aksrt_captcha_pass_key|aksrt_captcha_pass_/);
|
||||
});
|
||||
|
||||
test('cloudflare siteverify stays bound to the aksrt action and scene', () => {
|
||||
const module = read('inc/modules/captcha.php');
|
||||
assert.match(module, /aksrt_verify_cloudflare\(array \$pcfg, array \$post_data, string \$client_ip, string \$scene = ''(?:, string \$action = 'aksrt-verify')?\)/);
|
||||
assert.match(module, /action mismatch/);
|
||||
assert.match(module, /cdata mismatch/);
|
||||
});
|
||||
|
||||
test('captcha render is a data-driven mount without inline scripts or stylesheets', () => {
|
||||
const module = read('inc/modules/captcha.php');
|
||||
assert.match(module, /data-aksrt-captcha-config/);
|
||||
assert.doesNotMatch(module, /<script|<link rel/);
|
||||
});
|
||||
|
||||
test('legacy local captcha markers stay gone', () => {
|
||||
const module = read('inc/modules/captcha.php');
|
||||
assert.doesNotMatch(module, /machine-verification|imagecaptcha|slidercaptcha|canvas_yz/);
|
||||
});
|
||||
|
||||
test('captcha front runtime ships inside the public bundle', () => {
|
||||
const build = read('tools/build-public-assets.mjs');
|
||||
assert.match(build, /'assets\/js\/captcha-front\.js'/);
|
||||
assert.match(build, /'assets\/css\/shared\/captcha\.css'/);
|
||||
const front = read('assets/js/captcha-front.js');
|
||||
assert.match(front, /AKSRTCaptcha/);
|
||||
assert.match(front, /data-aksrt-captcha-config/);
|
||||
assert.match(front, /cdata: C\.scene/, 'turnstile widget must bind cdata to the scene');
|
||||
});
|
||||
|
||||
test('captcha public runtime exposes only the shared API and resolves scoped uid instances', () => {
|
||||
const front = read('assets/js/captcha-front.js');
|
||||
assert.match(front, /window\.AKSRTCaptcha\s*=\s*Object\.freeze\(\{[\s\S]*verify:\s*verify,[\s\S]*collect:\s*collect,[\s\S]*reset:\s*reset,[\s\S]*run:\s*run,[\s\S]*TOKEN_FIELDS:\s*TOKEN_FIELDS/);
|
||||
assert.match(front, /instances\[instance\.uid\]\s*=\s*instance/);
|
||||
assert.match(front, /function resolve\(scene, scope\)/);
|
||||
assert.doesNotMatch(front, /AKSRTCaptchaState/);
|
||||
assert.doesNotMatch(front, /tencent_sid/);
|
||||
});
|
||||
|
||||
test('captcha consumers use the shared API and reset every token-bearing request', () => {
|
||||
const consumers = [
|
||||
'assets/js/auth.js',
|
||||
'assets/js/core/comments.js',
|
||||
'assets/js/routes/user-center.js',
|
||||
'assets/js/cms-download.js',
|
||||
'assets/js/signin.js',
|
||||
'assets/js/front-submit.js',
|
||||
].map(read).join('\n');
|
||||
assert.match(consumers, /AKSRTCaptcha\.run|captcha\.run/);
|
||||
assert.match(consumers, /AKSRTCaptcha\.reset|captcha\.reset|resetCaptcha/);
|
||||
assert.doesNotMatch(consumers, /AKSRTCaptchaState/);
|
||||
assert.doesNotMatch(consumers, /tencent_sid/);
|
||||
assert.doesNotMatch(read('assets/js/core/comments.js'), /wp-comments-post|performLegacySubmit/);
|
||||
});
|
||||
|
||||
test('sign-in waits for main and fails closed when its enabled captcha mount is unavailable', () => {
|
||||
const signin = read('assets/js/signin.js');
|
||||
const scripts = read('src/Core/ScriptsPart1c.php');
|
||||
assert.match(signin, /tbquire\(\['main'\], boot\)/);
|
||||
assert.match(signin, /var captchaRequired = !!window\.aksrtData\.captchaSignin/);
|
||||
assert.match(signin, /captchaRequired && \(!captcha \|\| !mount\)[\s\S]*?return;/);
|
||||
assert.doesNotMatch(signin, /captcha \? captcha\.run\('signin', mount\) : Promise\.resolve\(\{\}\)/);
|
||||
assert.match(scripts, /'captchaSignin'\s*=>\s*function_exists\('aksrt_is_captcha_enabled_for'\)\s*&&\s*aksrt_is_captcha_enabled_for\('signin'\)/);
|
||||
});
|
||||
|
||||
test('auth and account security flows consume fresh captcha tickets', () => {
|
||||
const auth = read('assets/js/auth.js');
|
||||
assert.match(auth, /request\('code',[\s\S]*\.finally\(function \(\) \{[\s\S]*resetCaptcha/);
|
||||
assert.match(auth, /function submit\(form\)[\s\S]*captcha\.run\(scene,[\s\S]*request\(action,[\s\S]*resetCaptcha/);
|
||||
assert.match(auth, /qrPayload\.bind_pass = root\.dataset\.bindPass/);
|
||||
assert.match(auth, /data\.bind_pass = root\.dataset\.bindPass/);
|
||||
|
||||
const center = read('assets/js/routes/user-center.js');
|
||||
assert.match(center, /runCaptcha\('bind_phone', form\)[\s\S]*\/user\/phone-code/);
|
||||
assert.match(center, /runCaptcha\('bind_phone', form\)[\s\S]*\/user\/phone/);
|
||||
assert.match(center, /runCaptcha\(scene, fm\)[\s\S]*resetCaptcha\(scene, fm\)/);
|
||||
assert.match(center, /\/oauth\/bind-pass/);
|
||||
assert.match(center, /bind_verification_required:\s*'请先完成人机验证后再绑定'/);
|
||||
});
|
||||
test('captcha admin schema drops the dead single-option trigger mode', () => {
|
||||
const schema = read('src/Admin/Schema/SchemaPermissions.php');
|
||||
assert.doesNotMatch(schema, /'mode'/);
|
||||
const ui = read('assets/admin/src/schema/sections/permissions/index.js');
|
||||
assert.doesNotMatch(ui, /\bmode\s*:/);
|
||||
assert.doesNotMatch(ui, /'mode'/);
|
||||
const provider = read('src/Admin/SchemaProvider.php');
|
||||
assert.doesNotMatch(provider, /'mode'/);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const root = path.resolve(__dirname, '..', '..')
|
||||
const read = (...segments) => fs.readFileSync(path.join(root, ...segments), 'utf8')
|
||||
|
||||
test('referral attribution model is a single runtime source with schema surface', () => {
|
||||
const runtime = read('src', 'Settings', 'CreditsRuntime.php')
|
||||
const attribution = read('src', 'Payment', 'ReferralAttribution.php')
|
||||
const phpSchema = read('src', 'Admin', 'Schema', 'SchemaPayment.php')
|
||||
const jsSchema = read('assets', 'admin', 'src', 'schema', 'sections', 'payment', 'affiliate.js')
|
||||
|
||||
// 运行时:唯一枚举与默认值。
|
||||
assert.match(runtime, /referral_attribution_model/)
|
||||
assert.match(runtime, /\['first_touch', 'last_touch'\]/)
|
||||
assert.match(runtime, /'attribution_model' => \$attribution/)
|
||||
|
||||
// 归因行为:capture 末触覆盖、首触保持;注册仲裁按模式取优先方。
|
||||
assert.match(attribution, /\$this->attribution_model\(\) !== 'last_touch'/)
|
||||
assert.match(attribution, /!?\(\$last_touch && \$submitted_valid\)/)
|
||||
assert.match(attribution, /已按最近访问的推广关系绑定/)
|
||||
assert.match(attribution, /已按首次访问的推广关系绑定/)
|
||||
|
||||
// 管理端双 schema 同步暴露该字段。
|
||||
for (const schema of [phpSchema, jsSchema]) {
|
||||
assert.match(schema, /referral_attribution_model/)
|
||||
assert.match(schema, /first_touch/)
|
||||
assert.match(schema, /last_touch/)
|
||||
}
|
||||
})
|
||||
|
||||
test('disabled-code stop-commission switch is wired end to end', () => {
|
||||
const runtime = read('src', 'Settings', 'PaymentRuntime.php')
|
||||
const ledger = read('src', 'Payment', 'CommissionLedger.php')
|
||||
const attribution = read('src', 'Payment', 'ReferralAttribution.php')
|
||||
const phpSchema = read('src', 'Admin', 'Schema', 'SchemaPayment.php')
|
||||
const jsSchema = read('assets', 'admin', 'src', 'schema', 'sections', 'payment', 'affiliate.js')
|
||||
|
||||
assert.match(runtime, /'disabled_code_stops_commission' => !empty\(\$settings\['affiliate_disabled_code_stops_commission'\]\)/)
|
||||
// 记账入口必须在 resolve 推广人之后、写 ledger 之前校验推广码状态。
|
||||
assert.match(ledger, /ReferralAttribution::get_instance\(\)->resolve\(\$buyer\)/)
|
||||
assert.match(ledger, /is_promoter_code_active\(\$promoter\)/)
|
||||
assert.ok(
|
||||
ledger.indexOf('is_promoter_code_active($promoter)') < ledger.indexOf('$wpdb->insert($table'),
|
||||
'disabled-code gate must run before the ledger insert'
|
||||
)
|
||||
assert.match(attribution, /public function is_promoter_code_active\(int \$promoter_id\): bool/)
|
||||
for (const schema of [phpSchema, jsSchema]) {
|
||||
assert.match(schema, /affiliate_disabled_code_stops_commission/)
|
||||
}
|
||||
})
|
||||
|
||||
test('commission base is the discounted payable amount, not the list price', () => {
|
||||
// 下单侧:content 订单金额取优惠链(推广让利 + 优惠券)后的应付净额。
|
||||
const payOrders = read('src', 'Content', 'PayOrders.php')
|
||||
assert.match(payOrders, /\$amount_minor = max\(0, \(int\) \(\$discounted\['payable_minor'\] \?\? 0\)\)/)
|
||||
// 记账侧:佣金基数直接取订单实付金额。
|
||||
const ledger = read('src', 'Payment', 'CommissionLedger.php')
|
||||
assert.match(ledger, /AssetAmount::balance\(is_string\(\$order\['amount'\] \?\? null\) \? \$order\['amount'\] : ''\)/)
|
||||
})
|
||||
|
||||
test('lazy settlement backstop guards the earnings entry points', () => {
|
||||
const ledger = read('src', 'Payment', 'CommissionLedger.php')
|
||||
const attribution = read('src', 'Payment', 'ReferralAttribution.php')
|
||||
|
||||
assert.match(ledger, /private static bool \$lazy_settled = false/)
|
||||
assert.match(ledger, /public function lazy_settle_due\(\): void/)
|
||||
assert.match(ledger, /if \(!\\AKSRT\\Settings\\Settings::affiliate_enabled\(\)\) return;/)
|
||||
assert.ok(
|
||||
ledger.indexOf('lazy_settle_due()') < ledger.indexOf('global \$wpdb; \$table=\$this->table\(\'commission_ledger\'\); \$row=\$wpdb->get_row'),
|
||||
'overview() must trigger the backstop before aggregating balances'
|
||||
)
|
||||
// 收益总览与我的推广两个入口都接入了兜底。
|
||||
assert.match(attribution, /CommissionLedger::get_instance\(\)->lazy_settle_due\(\)/)
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const root = path.resolve(__dirname, '..', '..')
|
||||
const read = (...segments) => fs.readFileSync(path.join(root, ...segments), 'utf8')
|
||||
|
||||
test('QR generator is bundled locally; no external QR CDN in front-end sources', () => {
|
||||
const vendor = path.join(root, 'assets', 'js', 'vendor', 'qrcode.min.js')
|
||||
assert.ok(fs.existsSync(vendor), 'assets/js/vendor/qrcode.min.js must be committed')
|
||||
const script = fs.readFileSync(vendor, 'utf8')
|
||||
assert.ok(script.length > 10000, 'qrcode.min.js must not be a placeholder')
|
||||
// 前端引用面不允许出现外部二维码 CDN。
|
||||
const sources = [
|
||||
read('assets', 'js', 'credits.js'),
|
||||
read('inc', 'helpers', 'user-center-renderers', 'profile-revenue.php'),
|
||||
read('src', 'Core', 'ScriptsPart1b.php'),
|
||||
].join('\n')
|
||||
assert.doesNotMatch(sources, /cdnjs\.cloudflare\.com[^"']*qrcode|cdn\.jsdelivr\.net[^"']*qrcode|unpkg\.com[^"']*qrcode/)
|
||||
})
|
||||
|
||||
test('revenue page renders the QR row and credits.js draws + downloads it', () => {
|
||||
const template = read('inc', 'helpers', 'user-center-renderers', 'profile-revenue.php')
|
||||
assert.match(template, /data-affiliate-qr-row/)
|
||||
assert.match(template, /data-affiliate-qr(?!-)/)
|
||||
assert.match(template, /data-affiliate-qr-download/)
|
||||
assert.match(template, /data-affiliate-poster/)
|
||||
|
||||
const script = read('assets', 'js', 'credits.js')
|
||||
assert.match(script, /renderAffiliateQr\(root, j\.url \|\| ''\)/)
|
||||
// 库缺失时整行隐藏,不抛错。
|
||||
assert.match(script, /typeof window\.qrcode !== 'function'/)
|
||||
assert.match(script, /row\.hidden = true/)
|
||||
// Canvas 绘制(isDark 逐模块填充)+ PNG 下载。
|
||||
assert.match(script, /qr\.isDark\(r, c\)/)
|
||||
assert.match(script, /canvas\.toDataURL\('image\/png'\)/)
|
||||
})
|
||||
|
||||
test('poster canvas is built from admin-configured copy and gated on the poster switch', () => {
|
||||
const runtime = read('src', 'Settings', 'PaymentRuntime.php')
|
||||
assert.match(runtime, /'poster' => \[/)
|
||||
assert.match(runtime, /pay_rebate_poster_s/)
|
||||
assert.match(runtime, /pay_rebate_poster_img/)
|
||||
assert.match(runtime, /pay_rebate_poster_title/)
|
||||
assert.match(runtime, /pay_rebate_poster_desc/)
|
||||
|
||||
const attribution = read('src', 'Payment', 'ReferralAttribution.php')
|
||||
assert.match(attribution, /'poster' => \(array\) \(\$affiliate\['poster'\] \?\? \['enabled' => false\]\)/)
|
||||
assert.match(attribution, /'site_name' => get_bloginfo\('name'\)/)
|
||||
|
||||
const script = read('assets', 'js', 'credits.js')
|
||||
// 海报仅在开关开启时暴露按钮;绘制含顶部图、文案换行、二维码与站点名。
|
||||
assert.match(script, /initAffiliatePoster\(root, j\.url \|\| '', j\.poster \|\| \{\}, j\.site_name \|\| document\.title\)/)
|
||||
assert.match(script, /poster\.enabled !== true\) return/)
|
||||
assert.match(script, /function buildPosterCanvas/)
|
||||
assert.match(script, /function wrapText\(ctx, text, maxWidth\)/)
|
||||
assert.match(script, /image\.crossOrigin = 'anonymous'/)
|
||||
})
|
||||
|
||||
test('invite-code input verifies live against the public REST endpoint', () => {
|
||||
const template = read('templates', 'parts', 'auth-content.php')
|
||||
assert.match(template, /data-invite-verify/)
|
||||
assert.match(template, /data-invite-feedback/)
|
||||
|
||||
const script = read('assets', 'js', 'auth.js')
|
||||
assert.match(script, /function initInviteVerify\(root\)/)
|
||||
assert.match(script, /input\[data-invite-verify\]/)
|
||||
assert.match(script, /initInviteVerify\(root\)/)
|
||||
assert.match(script, /\/wp-json\/aksrt\/v1\/invite-code\/verify\?code=/)
|
||||
})
|
||||
|
||||
test('qrcode asset is enqueued only for profile + affiliate and credits depends on it', () => {
|
||||
const scripts = read('src', 'Core', 'ScriptsPart1b.php')
|
||||
assert.match(scripts, /assets\/js\/vendor\/qrcode\.min\.js/)
|
||||
assert.match(scripts, /'aksrt-qrcode'/)
|
||||
// credits.js 的依赖数组必须在 enqueue 调用内合并 QR 库句柄。
|
||||
assert.match(scripts, /array_merge\(\['loader_js', 'aksrt-vip-coordinator'\], \$affiliate_deps\)/)
|
||||
})
|
||||
|
||||
test('client IP honours the trusted-proxy filter before CDN headers', () => {
|
||||
const captcha = read('inc', 'modules', 'captcha.php')
|
||||
assert.match(captcha, /apply_filters\('aksrt_trusted_proxies', \[\]\)/)
|
||||
assert.match(captcha, /function aksrt_ip_in_list\(string \$ip, array \$list\): bool/)
|
||||
// CIDR 比较必须先检查同族地址长度,避免跨族误判。
|
||||
assert.match(captcha, /strlen\(\$ip_binary\) !== strlen\(\$subnet_binary\)/)
|
||||
// 过滤命中失败时直接回落 REMOTE_ADDR,不读转发头。
|
||||
assert.match(captcha, /!aksrt_ip_in_list\(\$remote, \$trusted\)/)
|
||||
})
|
||||
Reference in New Issue
Block a user