diff --git a/assets/admin/src/schema/sections/payment/affiliate.js b/assets/admin/src/schema/sections/payment/affiliate.js
index 81ee30f8..50792461 100644
--- a/assets/admin/src/schema/sections/payment/affiliate.js
+++ b/assets/admin/src/schema/sections/payment/affiliate.js
@@ -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: '
通过您的推广链接打开本站后,在本站购买商品即可获得佣金
通过您的推广链接注册后的用户今后购买的商品均可获得佣金
申请提现后,需后台人工处理,一般2-3小时,请耐心等待
',
@@ -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: '开启后,推广者的推广码被禁用(或缺失)时,其名下已绑定用户的新订单不再产生佣金;关闭则绑定关系继续生效' },
],
},
{
diff --git a/assets/css/routes/user-center/revenue-earnings.css b/assets/css/routes/user-center/revenue-earnings.css
index c08a51f6..c65e9b93 100644
--- a/assets/css/routes/user-center/revenue-earnings.css
+++ b/assets/css/routes/user-center/revenue-earnings.css
@@ -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; }
}
diff --git a/assets/js/credits.js b/assets/js/credits.js
index 2280e585..44298ac9 100644
--- a/assets/js/credits.js
+++ b/assets/js/credits.js
@@ -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 开通 =====
diff --git a/assets/js/vendor/qrcode.min.js b/assets/js/vendor/qrcode.min.js
new file mode 100644
index 00000000..1434c6f7
--- /dev/null
+++ b/assets/js/vendor/qrcode.min.js
@@ -0,0 +1 @@
+var qrcode=function(){function i(t,r){function a(t,r){g=function(t){for(var r=new Array(t),e=0;e>e&1);g[Math.floor(e/3)][e%3+l-8-3]=n}for(e=0;e<18;e+=1){n=!t&&1==(r>>e&1);g[e%3+l-8-3][Math.floor(e/3)]=n}},v=function(t,r){for(var e=f<<3|r,n=B.getBCHTypeInfo(e),o=0;o<15;o+=1){var i=!t&&1==(n>>o&1);o<6?g[o][8]=i:o<8?g[o+1][8]=i:g[l-15+o][8]=i}for(o=0;o<15;o+=1){i=!t&&1==(n>>o&1);o<8?g[8][l-o-1]=i:o<9?g[8][15-o-1+1]=i:g[8][15-o-1]=i}g[l-8][8]=!t},d=function(t,r){for(var e=-1,n=l-1,o=7,i=0,a=B.getMaskFunction(r),u=l-1;0>>o&1)),a(n,u-f)&&(c=!c),g[n][u-f]=c,-1==(o-=1)&&(i+=1,o=7)}if((n+=e)<0||l<=n){n-=e,e=-e;break}}},w=function(t,r,e){for(var n=b.getRSBlocks(t,r),o=M(),i=0;i8*u)throw"code length overflow. ("+o.getLengthInBits()+">"+8*u+")";for(o.getLengthInBits()+4<=8*u&&o.put(0,4);o.getLengthInBits()%8!=0;)o.putBit(!1);for(;!(o.getLengthInBits()>=8*u||(o.put(236,8),o.getLengthInBits()>=8*u));)o.put(17,8);return function(t,r){for(var e=0,n=0,o=0,i=new Array(r.length),a=new Array(r.length),u=0;u',e+="";for(var n=0;n";for(var o=0;o';e+=""}return e+="",e+=""},s.createSvgTag=function(t,r,e,n){var o={};"object"==typeof t&&(t=(o=t).cellSize,r=o.margin,e=o.alt,n=o.title),t=t||2,r=void 0===r?4*t:r,(e="string"==typeof e?{text:e}:e||{}).text=e.text||null,e.id=e.text?e.id||"qrcode-description":null,(n="string"==typeof n?{text:n}:n||{}).text=n.text||null,n.id=n.text?n.id||"qrcode-title":null;var i,a,u,f,c=s.getModuleCount()*t+2*r,g="";for(f="l"+t+",0 0,"+t+" -"+t+",0 0,-"+t+"z ",g+='"},s.createDataURL=function(o,t){o=o||2,t=void 0===t?4*o:t;var r=s.getModuleCount()*o+2*t,i=t,a=r-t;return I(r,r,function(t,r){if(i<=t&&t"};var p=function(t){for(var r="",e=0;e":r+=">";break;case"&":r+="&";break;case'"':r+=""";break;default:r+=n}}return r};return s.createASCII=function(t,r){if((t=t||1)<2)return function(t){t=void 0===t?2:t;var r,e,n,o,i,a=1*s.getModuleCount()+2*t,u=t,f=a-t,c={"██":"█","█ ":"▀"," █":"▄"," ":" "},g={"██":"▀","█ ":"▀"," █":" "," ":" "},l="";for(r=0;r>>8),r.push(255&o)):r.push(a)}}return r}};var r,t,a=1,u=2,o=4,f=8,y={L:1,M:0,Q:3,H:2},e=0,n=1,c=2,g=3,l=4,h=5,s=6,v=7,B=(r=[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],(t={}).getBCHTypeInfo=function(t){for(var r=t<<10;0<=d(r)-d(1335);)r^=1335<>>=1;return r}var w=function(){for(var r=new Array(256),e=new Array(256),t=0;t<8;t+=1)r[t]=1<>>8)},writeBytes:function(t,r,e){r=r||0,e=e||t.length;for(var n=0;n>>7-t%8&1)},put:function(t,r){for(var e=0;e>>r-e-1&1))},getLengthInBits:function(){return n},putBit:function(t){var r=Math.floor(n/8);e.length<=r&&e.push(0),t&&(e[r]|=128>>>n%8),n+=1}};return o},x=function(t){var r=a,n=t,e={getMode:function(){return r},getLength:function(t){return n.length},write:function(t){for(var r=n,e=0;e+2>>8&255)+(255&n),t.put(n,13),e+=2}if(e=e.length){if(0==i)return-1;throw"unexpected end of file./"+i}var t=e.charAt(n);if(n+=1,"="==t)return i=0,-1;t.match(/^\s$/)||(o=o<<6|a(t.charCodeAt(0)),i+=6)}var r=o>>>i-8&255;return i-=8,r}},a=function(t){if(65<=t&&t<=90)return t-65;if(97<=t&&t<=122)return t-97+26;if(48<=t&&t<=57)return t-48+52;if(43==t)return 62;if(47==t)return 63;throw"c:"+t};return r},I=function(t,r,e){for(var n=function(t,r){var n=t,o=r,l=new Array(t*r),e={setPixel:function(t,r,e){l[r*n+t]=e},write:function(t){t.writeString("GIF87a"),t.writeShort(n),t.writeShort(o),t.writeByte(128),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(255),t.writeByte(255),t.writeByte(255),t.writeString(","),t.writeShort(0),t.writeShort(0),t.writeShort(n),t.writeShort(o),t.writeByte(0);var r=i(2);t.writeByte(2);for(var e=0;255>>r!=0)throw"length over";for(;8<=n+r;)e.writeByte(255&(t<>>=8-n,n=o=0;o|=t<>>o-6),o-=6},t.flush=function(){if(0>6,128|63&n):n<55296||57344<=n?r.push(224|n>>12,128|n>>6&63,128|63&n):(e++,n=65536+((1023&n)<<10|1023&t.charCodeAt(e)),r.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n))}return r}(t)},function(t){"function"==typeof define&&define.amd?define([],t):"object"==typeof exports&&(module.exports=t())}(function(){return qrcode});
\ No newline at end of file
diff --git a/inc/helpers/user-center-renderers/profile-revenue.php b/inc/helpers/user-center-renderers/profile-revenue.php
index 5df51e47..97363782 100644
--- a/inc/helpers/user-center-renderers/profile-revenue.php
+++ b/inc/helpers/user-center-renderers/profile-revenue.php
@@ -145,6 +145,17 @@ $status_labels = [
+
%
diff --git a/inc/modules/captcha.php b/inc/modules/captcha.php
index c846a23e..dd87188d 100644
--- a/inc/modules/captcha.php
+++ b/inc/modules/captcha.php
@@ -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) {
diff --git a/src/Admin/Schema/SchemaPayment.php b/src/Admin/Schema/SchemaPayment.php
index 38b941f1..5326df50 100644
--- a/src/Admin/Schema/SchemaPayment.php
+++ b/src/Admin/Schema/SchemaPayment.php
@@ -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]],
diff --git a/src/Core/ScriptsPart1b.php b/src/Core/ScriptsPart1b.php
index 445cc160..676804b7 100644
--- a/src/Core/ScriptsPart1b.php
+++ b/src/Core/ScriptsPart1b.php
@@ -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
);
diff --git a/src/Payment/CommissionLedger.php b/src/Payment/CommissionLedger.php
index 09c7c5a3..e8095699 100644
--- a/src/Payment/CommissionLedger.php
+++ b/src/Payment/CommissionLedger.php
@@ -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';
diff --git a/src/Payment/ReferralAttribution.php b/src/Payment/ReferralAttribution.php
index 994952a9..a5a22022 100644
--- a/src/Payment/ReferralAttribution.php
+++ b/src/Payment/ReferralAttribution.php
@@ -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 {
diff --git a/src/Settings/PaymentRuntime.php b/src/Settings/PaymentRuntime.php
index de4d1024..2240de08 100644
--- a/src/Settings/PaymentRuntime.php
+++ b/src/Settings/PaymentRuntime.php
@@ -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'] ?? '')),
+ ],
];
}
diff --git a/tests/widget-system/captcha-cloud-contract.test.cjs b/tests/widget-system/captcha-cloud-contract.test.cjs
new file mode 100644
index 00000000..9c4d4f20
--- /dev/null
+++ b/tests/widget-system/captcha-cloud-contract.test.cjs
@@ -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, /