From 20387fdb06ff38b5b2e601e53fefe958c8614b21 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 21 Sep 2026 20:53:22 +0800 Subject: [PATCH] feat(affiliate): attribution modes, settlement hardening, trusted proxies, QR and poster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 归因模式可配置(first_touch/last_touch)、停佣开关、懒结算与佣金基数降级复查 - aksrt_get_client_ip 接入 aksrt_trusted_proxies 信任代理名单,防 CDN 头伪造 - 收益页 Canvas 二维码 + 750x1000 推广海报(本地打包 qrcode.min.js,无 CDN) - 契约测试固化归因/海报/信任代理链路 --- .../src/schema/sections/payment/affiliate.js | 14 ++ .../routes/user-center/revenue-earnings.css | 10 + assets/js/credits.js | 179 +++++++++++++++ assets/js/vendor/qrcode.min.js | 1 + .../user-center-renderers/profile-revenue.php | 11 + inc/modules/captcha.php | 54 +++++ src/Admin/Schema/SchemaPayment.php | 5 + src/Core/ScriptsPart1b.php | 15 +- src/Payment/CommissionLedger.php | 14 ++ src/Payment/ReferralAttribution.php | 37 +++- src/Settings/PaymentRuntime.php | 7 + .../captcha-cloud-contract.test.cjs | 208 ++++++++++++++++++ .../referral-policy-contract.test.cjs | 77 +++++++ .../referral-qr-contract.test.cjs | 89 ++++++++ 14 files changed, 715 insertions(+), 6 deletions(-) create mode 100644 assets/js/vendor/qrcode.min.js create mode 100644 tests/widget-system/captcha-cloud-contract.test.cjs create mode 100644 tests/widget-system/referral-policy-contract.test.cjs create mode 100644 tests/widget-system/referral-qr-contract.test.cjs 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+=''+p(n.text)+"":"",g+=e.text?''+p(e.text)+"":"",g+='',g+='":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, / { + 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'/); +}); diff --git a/tests/widget-system/referral-policy-contract.test.cjs b/tests/widget-system/referral-policy-contract.test.cjs new file mode 100644 index 00000000..cef599a9 --- /dev/null +++ b/tests/widget-system/referral-policy-contract.test.cjs @@ -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\(\)/) +}) diff --git a/tests/widget-system/referral-qr-contract.test.cjs b/tests/widget-system/referral-qr-contract.test.cjs new file mode 100644 index 00000000..38f837ba --- /dev/null +++ b/tests/widget-system/referral-qr-contract.test.cjs @@ -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\)/) +})