Compare commits

..
3 Commits
Author SHA1 Message Date
kate522 3dd3f71c97 feat(invite-code): one-time invite codes with rewards, usage log, import/export
- 一次性邀请码:批量生成(次数/有效期/备注/四类奖励)、原子消费防超发、
  开启注册验证时奖励延迟发放(账本幂等键)
- Zibll 兼容文本导入(points/balance/vip/vip_time/level_integral,分隔符可自定义,
  第三段标识存为备注)、CSV 全字段导出与可回流文本导出
- 使用记录表 aksrt_invite_code_usage + 后台使用者列;注册表单 400ms 防抖实时校验
- 注册门控三模式(close/open/must)设置链路(PHP schema + admin SPA schema)
- 契约测试覆盖建表/消费竞态/导入导出/备注全链路
2026-09-21 20:53:36 +08:00
kate522 20387fdb06 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)
- 契约测试固化归因/海报/信任代理链路
2026-09-21 20:53:22 +08:00
kate522 2618530afb chore(repo): track full tests/ and tools/ suites, drop per-file whitelist 2026-09-21 20:53:12 +08:00
48 changed files with 11949 additions and 29 deletions

No files matched your search

+2 -22
View File
@@ -11,29 +11,9 @@
/.specs/
/docs/
# tools/: 只版本化两个共享入口,其余为本地脚本。
/tools/*
!/tools/build-public-assets.mjs
!/tools/wp-cli-visual-fixtures.php
# tests/: 放行整个套件,按名字排除本地专有产物。
#
# 这里刻意从「逐文件白名单」改为「先放行目录、再逐个排除」的反向写法:
# 白名单要求每新增一个 *.test.cjs 都同步改本文件,一旦漏改,该测试文件会静默
# 不被跟踪,在 CI 上表现为「测试数量莫名变少」,且极难排查。反向写法下新增测试
# 自动纳入版本控制,只有确需本地专有的文件才需要追加排除。
#
# 下列 10 个路径是切换前就已处于「忽略且未跟踪」状态的本地专有文件,逐一保留原状。
!/tests/
/tests/visual-results/
/tests/fixtures/widget-golden-v1.json
/tests/widget-system/a11y-contract.test.cjs
/tests/widget-system/admin-core.test.cjs
/tests/widget-system/captcha-cloud-contract.test.cjs
/tests/widget-system/comment-hard-cut-contract.test.cjs
/tests/widget-system/download-config-contract.test.cjs
/tests/widget-system/run.php
/tests/widget-system/scratch-term-aggregation-harness.php
/CLAUDE.md
/AGENTS.md
+11
View File
@@ -167,6 +167,17 @@ WordPress 后台左侧 **Admin Bar** → ⚡ **AKSRT 主题设置**
- `支付&付费`:下载/付费内容中心、余额充值、VIP、创作分成、推广返佣、收款接口
- `备份&导入`:导入导出、健康检查、迁移状态、备份与更新
### 4. 推广、邀请与佣金(用户中心 → 收益)
- **推广链接**:用户中心「收益」页展示带 `?ref=` 的推广链接、二维码(可下载 PNG)与推广海报(按「推广返佣」设置中的海报文案生成)。归因模式(首触/末触)在「支付&付费 → 推广返佣 → 推广归因模式」配置。
- **一次性邀请码**:后台「用户 → 邀请码管理」批量生成(每码次数/有效期/备注/四类奖励:积分、余额、VIP、等级积分),支持文本导入(一行一码,格式 `邀请码 奖励 标识`,第三段标识存为备注;奖励参数串与 Zibll 兼容:`points=20&balance=30&vip=1&vip_time=5&level_integral=100`,分隔符可自定义)与两种导出(CSV 全字段 / 文本格式,文本导出可直接回流导入)。注册门控模式在「用户&互动 → 登录注册 → 邀请码注册」。消费为原子操作,并发注册不会超发;开启注册验证时奖励延迟到邮箱验证后发放。
- **佣金**:订单支付成功后按推广者等级分档比例记录到佣金账本,经「结算观察期」后自动结算入钱包,支持提现与退款联动回滚。「推广码禁用后停止返佣」开关默认关闭。
### 5. 部署注意事项
- **佣金结算时效**:结算依赖小时级 wp-cron,并有收益页访问触发的懒结算兜底。高流量站点建议禁用 `WP_CRON_LOCK` 场景下的默认 WP-Cron,改用系统 crontab 定时请求 `wp-cron.php`,保证结算与过期任务准时执行。
- **反代 / CDN 与 IP 风控**:主题默认按 Cloudflare → 通用代理头顺序识别真实 IP。若需收紧伪造风险,添加 `add_filter('aksrt_trusted_proxies', fn() => ['10.0.0.0/8', '173.245.48.0/20'])`(填入你的代理/CDN 网段);名单非空时仅当 `REMOTE_ADDR` 命中名单才采纳转发头,否则直接使用 `REMOTE_ADDR`(推广奖励的 IP 日上限、验证码等均依赖该函数)。
---
## 开发说明
@@ -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: '开启后,推广者的推广码被禁用(或缺失)时,其名下已绑定用户的新订单不再产生佣金;关闭则绑定关系继续生效' },
],
},
{
@@ -22,6 +22,9 @@ export const authSchema = {
invit_code_s: 'close',
invit_code_open_desc: '如您受到邀请,可填写推广码。部分推广码会有额外积分赠送',
invit_code_must_desc: '<div>本站需使用推广码注册</div><div>获取推广码请与客服联系</div>',
invite_code_s: 'close',
invite_code_open_desc: '请输入邀请码,注册成功后奖励将自动发放',
invite_code_must_desc: '<div>本站需凭邀请码注册</div><div>获取邀请码请与客服联系</div>',
user_signin_phone_s: false,
user_signin_nopas_s: false,
user_signin_nopas_type: 'email',
@@ -84,6 +87,16 @@ export const authSchema = {
{ key: 'invit_code_must_desc', label: '必填提示文案', type: 'textarea', default: '<div>本站需使用推广码注册</div><div>获取推广码请与客服联系</div>', dependsOn: { key: 'invit_code_s', value: 'must' }, help: '支持安全 HTML' },
],
},
{
key: 'invite_code',
title: '邀请码注册',
icon: 'fa fas fa-envelope-open-text',
fields: [
{ key: 'invite_code_s', label: '邀请码注册模式', type: 'radio', default: 'close', options: [{ label: '关闭', value: 'close' }, { label: '选填邀请码', value: 'open' }, { label: '必须填写邀请码', value: 'must' }], help: '一次性邀请码与推广码相互独立:邀请码用于控制注册并按码发放奖励,在「用户 → 邀请码管理」批量生成' },
{ key: 'invite_code_open_desc', label: '选填提示文案', type: 'input', default: '请输入邀请码,注册成功后奖励将自动发放', dependsOn: { key: 'invite_code_s', value: 'open' } },
{ key: 'invite_code_must_desc', label: '必填提示文案', type: 'textarea', default: '<div>本站需凭邀请码注册</div><div>获取邀请码请与客服联系</div>', dependsOn: { key: 'invite_code_s', value: 'must' }, help: '支持安全 HTML' },
],
},
{
key: 'verification',
title: '登录与验证',
@@ -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; }
}
+32
View File
@@ -506,6 +506,37 @@
});
}
// 邀请码输入实时校验(公开只读接口;提交时服务端仍是硬门禁)。
function initInviteVerify(root) {
root.querySelectorAll('input[data-invite-verify]').forEach(function (input) {
if (input.dataset.inviteVerifyBound === '1') return;
input.dataset.inviteVerifyBound = '1';
var feedback = root.querySelector('[data-invite-feedback]');
var timer = null;
function setFeedback(text, isError) {
if (!feedback) return;
if (!text) { feedback.hidden = true; feedback.textContent = ''; feedback.classList.remove('is-error'); return; }
feedback.hidden = false;
feedback.textContent = text;
feedback.classList.toggle('is-error', !!isError);
}
input.addEventListener('input', function () {
if (timer) window.clearTimeout(timer);
var code = input.value.trim();
if (!code) { setFeedback(''); return; }
timer = window.setTimeout(function () {
window.fetch('/wp-json/aksrt/v1/invite-code/verify?code=' + encodeURIComponent(code), { credentials: 'same-origin' })
.then(function (response) { return response.ok ? response.json() : null; })
.then(function (payload) {
if (!payload) { setFeedback(''); return; }
setFeedback(payload.valid ? '邀请码有效。' : '邀请码无效或已用完。', !payload.valid);
})
.catch(function () { setFeedback(''); });
}, 400);
});
});
}
function initRoot(root) {
root.querySelectorAll('input').forEach(function (input) {
if (!input.hasAttribute('placeholder')) input.setAttribute('placeholder', ' ');
@@ -521,6 +552,7 @@
if (!button.getAttribute('aria-label')) button.setAttribute('aria-label', '显示密码');
});
setOAuthRedirect(root, root.dataset.redirectTo || '');
initInviteVerify(root);
var initial = root.dataset.initialTab || 'signin';
if (initial === 'oauth' && window.matchMedia && window.matchMedia('(max-width: 767px)').matches) initial = 'signin';
var mayActivateQr = initial !== 'oauth' || document.visibilityState === 'visible';
+179
View File
@@ -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 开通 =====
+1
View File
@@ -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<t;e+=1){r[e]=new Array(t);for(var n=0;n<t;n+=1)r[e][n]=null}return r}(l=4*u+17),e(0,0),e(l-7,0),e(0,l-7),i(),o(),v(t,r),7<=u&&h(t),null==n&&(n=w(u,f,c)),d(n,r)}var u=t,f=y[r],g=null,l=0,n=null,c=[],s={},e=function(t,r){for(var e=-1;e<=7;e+=1)if(!(t+e<=-1||l<=t+e))for(var n=-1;n<=7;n+=1)r+n<=-1||l<=r+n||(g[t+e][r+n]=0<=e&&e<=6&&(0==n||6==n)||0<=n&&n<=6&&(0==e||6==e)||2<=e&&e<=4&&2<=n&&n<=4)},o=function(){for(var t=8;t<l-8;t+=1)null==g[t][6]&&(g[t][6]=t%2==0);for(var r=8;r<l-8;r+=1)null==g[6][r]&&(g[6][r]=r%2==0)},i=function(){for(var t=B.getPatternPosition(u),r=0;r<t.length;r+=1)for(var e=0;e<t.length;e+=1){var n=t[r],o=t[e];if(null==g[n][o])for(var i=-2;i<=2;i+=1)for(var a=-2;a<=2;a+=1)g[n+i][o+a]=-2==i||2==i||-2==a||2==a||0==i&&0==a}},h=function(t){for(var r=B.getBCHTypeNumber(u),e=0;e<18;e+=1){var n=!t&&1==(r>>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<u;u-=2)for(6==u&&(u-=1);;){for(var f=0;f<2;f+=1)if(null==g[n][u-f]){var c=!1;i<t.length&&(c=1==(t[i]>>>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;i<e.length;i+=1){var a=e[i];o.put(a.getMode(),4),o.put(a.getLength(),B.getLengthInBits(a.getMode(),t)),a.write(o)}var u=0;for(i=0;i<n.length;i+=1)u+=n[i].dataCount;if(o.getLengthInBits()>8*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<r.length;u+=1){var f=r[u].dataCount,c=r[u].totalCount-f;n=Math.max(n,f),o=Math.max(o,c),i[u]=new Array(f);for(var g=0;g<i[u].length;g+=1)i[u][g]=255&t.getBuffer()[g+e];e+=f;var l=B.getErrorCorrectPolynomial(c),h=C(i[u],l.getLength()-1).mod(l);a[u]=new Array(l.getLength()-1);for(g=0;g<a[u].length;g+=1){var s=g+h.getLength()-a[u].length;a[u][g]=0<=s?h.getAt(s):0}}var v=0;for(g=0;g<r.length;g+=1)v+=r[g].totalCount;var d=new Array(v),w=0;for(g=0;g<n;g+=1)for(u=0;u<r.length;u+=1)g<i[u].length&&(d[w]=i[u][g],w+=1);for(g=0;g<o;g+=1)for(u=0;u<r.length;u+=1)g<a[u].length&&(d[w]=a[u][g],w+=1);return d}(o,n)};s.addData=function(t,r){var e=null;switch(r=r||"Byte"){case"Numeric":e=x(t);break;case"Alphanumeric":e=m(t);break;case"Byte":e=L(t);break;case"Kanji":e=D(t);break;default:throw"mode:"+r}c.push(e),n=null},s.isDark=function(t,r){if(t<0||l<=t||r<0||l<=r)throw t+","+r;return g[t][r]},s.getModuleCount=function(){return l},s.make=function(){if(u<1){for(var t=1;t<40;t++){for(var r=b.getRSBlocks(t,f),e=M(),n=0;n<c.length;n++){var o=c[n];e.put(o.getMode(),4),e.put(o.getLength(),B.getLengthInBits(o.getMode(),t)),o.write(e)}var i=0;for(n=0;n<r.length;n++)i+=r[n].dataCount;if(e.getLengthInBits()<=8*i)break}u=t}a(!1,function(){for(var t=0,r=0,e=0;e<8;e+=1){a(!0,e);var n=B.getLostPoint(s);(0==e||n<t)&&(t=n,r=e)}return r}())},s.createTableTag=function(t,r){t=t||2;var e="";e+='<table style="',e+=" border-width: 0px; border-style: none;",e+=" border-collapse: collapse;",e+=" padding: 0px; margin: "+(r=void 0===r?4*t:r)+"px;",e+='">',e+="<tbody>";for(var n=0;n<s.getModuleCount();n+=1){e+="<tr>";for(var o=0;o<s.getModuleCount();o+=1)e+='<td style="',e+=" border-width: 0px; border-style: none;",e+=" border-collapse: collapse;",e+=" padding: 0px; margin: 0px;",e+=" width: "+t+"px;",e+=" height: "+t+"px;",e+=" background-color: ",e+=s.isDark(n,o)?"#000000":"#ffffff",e+=";",e+='"/>';e+="</tr>"}return e+="</tbody>",e+="</table>"},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+='<svg version="1.1" xmlns="http://www.w3.org/2000/svg"',g+=o.scalable?"":' width="'+c+'px" height="'+c+'px"',g+=' viewBox="0 0 '+c+" "+c+'" ',g+=' preserveAspectRatio="xMinYMin meet"',g+=n.text||e.text?' role="img" aria-labelledby="'+p([n.id,e.id].join(" ").trim())+'"':"",g+=">",g+=n.text?'<title id="'+p(n.id)+'">'+p(n.text)+"</title>":"",g+=e.text?'<description id="'+p(e.id)+'">'+p(e.text)+"</description>":"",g+='<rect width="100%" height="100%" fill="white" cx="0" cy="0"/>',g+='<path d="',a=0;a<s.getModuleCount();a+=1)foLine truncated
+3
View File
@@ -215,6 +215,9 @@ if (class_exists('AKSRT\\Payment\\ReferralAttribution')) {
if (class_exists('AKSRT\\Payment\\CommissionLedger')) {
\AKSRT\Payment\CommissionLedger::get_instance()->init();
}
if (class_exists('AKSRT\\Payment\\InviteCode')) {
\AKSRT\Payment\InviteCode::get_instance()->init();
}
if (class_exists('\AKSRT\Content\Download')) {
\AKSRT\Content\Download::get_instance()->init();
}
@@ -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) : ?>
+54
View File
@@ -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) {
+10
View File
@@ -12,6 +12,7 @@ class UsersMenus {
use UsersMenusVerify;
use UsersMenusVip;
use UsersMenusReferral;
use UsersMenusInviteCode;
use UsersMenusTickets;
public const CAPABILITY = 'manage_options';
@@ -50,6 +51,15 @@ class UsersMenus {
[$this, 'render_referral_codes_page']
);
add_submenu_page(
'users.php',
__('邀请码管理', 'aksrt'),
__('邀请码管理', 'aksrt'),
'edit_users',
'aksrt_invite_codes',
[$this, 'render_invite_codes_page']
);
if (!current_user_can(self::CAPABILITY)) {
return;
}
+293
View File
@@ -0,0 +1,293 @@
<?php
/**
* Native invite-code operations.
*
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
defined('ABSPATH') || exit;
trait UsersMenusInviteCode {
public function render_invite_codes_page(): void {
if (!current_user_can('edit_users')) {
wp_die(esc_html__('您没有权限访问此页面。', 'aksrt'));
}
$endpoint = rest_url('aksrt/v1/invite-codes');
$nonce = wp_create_nonce('wp_rest');
?>
<div class="wrap" id="aksrt-invite-ops">
<h1><?php esc_html_e('邀请码管理', 'aksrt'); ?></h1>
<p><?php esc_html_e('一次性邀请码用于控制注册并发放注册奖励。每个码可限制使用次数与有效期,奖励在注册(或完成邮箱验证)后自动发放。', 'aksrt'); ?></p>
<h2><?php esc_html_e('批量生成', 'aksrt'); ?></h2>
<div class="tablenav top">
<div class="alignleft actions">
<label for="aksrt-inv-count"><?php esc_html_e('数量', 'aksrt'); ?></label>
<input type="number" id="aksrt-inv-count" min="1" max="500" value="10" class="small-text">
<label for="aksrt-inv-max-uses"><?php esc_html_e('每码可用次数', 'aksrt'); ?></label>
<input type="number" id="aksrt-inv-max-uses" min="0" max="9999" value="1" class="small-text">
<label for="aksrt-inv-exp-days"><?php esc_html_e('有效天数', 'aksrt'); ?></label>
<input type="number" id="aksrt-inv-exp-days" min="0" max="3650" value="30" class="small-text">
<label for="aksrt-inv-points"><?php esc_html_e('积分奖励', 'aksrt'); ?></label>
<input type="number" id="aksrt-inv-points" min="0" value="0" class="small-text">
<label for="aksrt-inv-balance"><?php esc_html_e('余额奖励(元)', 'aksrt'); ?></label>
<input type="number" id="aksrt-inv-balance" min="0" step="0.01" value="0" class="small-text">
<label for="aksrt-inv-vip-level"><?php esc_html_e('赠VIP', 'aksrt'); ?></label>
<select id="aksrt-inv-vip-level">
<option value="0"><?php esc_html_e('不赠送', 'aksrt'); ?></option>
<option value="1"><?php esc_html_e('一级', 'aksrt'); ?></option>
<option value="2"><?php esc_html_e('二级', 'aksrt'); ?></option>
</select>
<label for="aksrt-inv-vip-days"><?php esc_html_e('VIP天数', 'aksrt'); ?></label>
<input type="number" id="aksrt-inv-vip-days" min="0" value="0" class="small-text">
<label for="aksrt-inv-level-points"><?php esc_html_e('等级积分', 'aksrt'); ?></label>
<input type="number" id="aksrt-inv-level-points" min="0" value="0" class="small-text">
<label for="aksrt-inv-remark"><?php esc_html_e('备注', 'aksrt'); ?></label>
<input type="text" id="aksrt-inv-remark" maxlength="191" class="regular-text">
<button type="button" class="button button-primary" id="aksrt-inv-generate"><?php esc_html_e('生成', 'aksrt'); ?></button>
<button type="button" class="button" id="aksrt-inv-import-toggle"><?php esc_html_e('导入', 'aksrt'); ?></button>
<a class="button" id="aksrt-inv-export" href="<?php echo esc_url(add_query_arg('_wpnonce', $nonce, rest_url('aksrt/v1/invite-codes/export'))); ?>"><?php esc_html_e('导出 CSV', 'aksrt'); ?></a>
<a class="button" id="aksrt-inv-export-text" href="<?php echo esc_url(add_query_arg('_wpnonce', $nonce, rest_url('aksrt/v1/invite-codes/export-text'))); ?>"><?php esc_html_e('导出文本', 'aksrt'); ?></a>
</div>
</div>
<div id="aksrt-inv-import-panel" style="display:none;">
<p>
<?php esc_html_e('一行一个邀请码,单行格式:', 'aksrt'); ?>
<code><?php esc_html_e('邀请码 分隔符 奖励 分隔符 标识', 'aksrt'); ?></code>
(<?php esc_html_e('第三段标识将作为备注保存', 'aksrt'); ?>)
</p>
<p>
<?php esc_html_e('奖励参数串(与 Zibll 兼容):', 'aksrt'); ?>
<code>points=20&balance=30&vip=1&vip_time=5&level_integral=100</code>
<?php esc_html_e(',无需奖励填', 'aksrt'); ?> <code><?php esc_html_e('无', 'aksrt'); ?></code><?php esc_html_e('。示例:', 'aksrt'); ?><code>93FW0XAY 无 没有奖励</code>
</p>
<p>
<label for="aksrt-inv-import-division"><?php esc_html_e('分隔符', 'aksrt'); ?></label>
<input type="text" id="aksrt-inv-import-division" value=" " maxlength="4" class="small-text">
<label for="aksrt-inv-import-max-uses"><?php esc_html_e('每码可用次数', 'aksrt'); ?></label>
<input type="number" id="aksrt-inv-import-max-uses" min="0" max="9999" value="1" class="small-text">
<label for="aksrt-inv-import-exp-days"><?php esc_html_e('有效天数', 'aksrt'); ?></label>
<input type="number" id="aksrt-inv-import-exp-days" min="0" max="3650" value="0" class="small-text">
</p>
<p><textarea id="aksrt-inv-import-data" rows="8" class="large-text code" style="resize:both;" placeholder="93FW0XAY 无 没有奖励&#10;P86NPWKI points=10&#10;AUF9D2B4 balance=30&amp;vip=1&amp;vip_time=5"></textarea></p>
<p><button type="button" class="button button-primary" id="aksrt-inv-import-submit"><?php esc_html_e('开始导入', 'aksrt'); ?></button></p>
</div>
<div id="aksrt-inv-generated" style="display:none;">
<p><strong><?php esc_html_e('本批邀请码(点击复制):', 'aksrt'); ?></strong></p>
<p id="aksrt-inv-codes" style="user-select:all;word-break:break-all;"></p>
</div>
<h2><?php esc_html_e('邀请码列表', 'aksrt'); ?></h2>
<div class="tablenav top">
<div class="alignleft actions">
<select id="aksrt-inv-status">
<option value=""><?php esc_html_e('全部状态', 'aksrt'); ?></option>
<option value="active"><?php esc_html_e('启用', 'aksrt'); ?></option>
<option value="disabled"><?php esc_html_e('禁用', 'aksrt'); ?></option>
</select>
<input type="search" id="aksrt-inv-search" placeholder="<?php esc_attr_e('搜索邀请码或批次', 'aksrt'); ?>">
<button type="button" class="button" id="aksrt-inv-filter"><?php esc_html_e('筛选', 'aksrt'); ?></button>
</div>
</div>
<div id="aksrt-inv-notice" role="status" aria-live="polite"></div>
<table class="wp-list-table widefat fixed striped">
<thead><tr>
<th><?php esc_html_e('邀请码', 'aksrt'); ?></th>
<th><?php esc_html_e('批次', 'aksrt'); ?></th>
<th><?php esc_html_e('备注', 'aksrt'); ?></th>
<th><?php esc_html_e('奖励', 'aksrt'); ?></th>
<th style="text-align:right;"><?php esc_html_e('已用/上限', 'aksrt'); ?></th>
<th><?php esc_html_e('使用者', 'aksrt'); ?></th>
<th><?php esc_html_e('状态', 'aksrt'); ?></th>
<th><?php esc_html_e('过期时间', 'aksrt'); ?></th>
<th><?php esc_html_e('创建时间', 'aksrt'); ?></th>
<th><?php esc_html_e('操作', 'aksrt'); ?></th>
</tr></thead>
<tbody id="aksrt-inv-rows"><tr><td colspan="10"><?php esc_html_e('正在加载…', 'aksrt'); ?></td></tr></tbody>
</table>
<div class="tablenav bottom"><div class="tablenav-pages" id="aksrt-inv-pages"></div></div>
</div>
<script>
(() => {
const init = () => {
const root = document.getElementById('aksrt-invite-ops');
if (!root || root.dataset.aksrtInviteReady === '1') return;
root.dataset.aksrtInviteReady = '1';
const endpoint = <?php echo wp_json_encode($endpoint); ?>;
const nonce = <?php echo wp_json_encode($nonce); ?>;
const rows = root.querySelector('#aksrt-inv-rows');
const pages = root.querySelector('#aksrt-inv-pages');
const notice = root.querySelector('#aksrt-inv-notice');
const search = root.querySelector('#aksrt-inv-search');
const status = root.querySelector('#aksrt-inv-status');
const filter = root.querySelector('#aksrt-inv-filter');
const generated = root.querySelector('#aksrt-inv-generated');
const generatedCodes = root.querySelector('#aksrt-inv-codes');
let currentPage = 1;
const escapeHtml = (value) => String(value ?? '').replace(/[&<>'"]/g, (char) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#039;', '"': '&quot;'
})[char]);
const setNotice = (message, isError = false) => {
notice.className = message ? `notice notice-${isError ? 'error' : 'success'} inline` : '';
notice.innerHTML = message ? `<p>${escapeHtml(message)}</p>` : '';
};
const request = async (url, options = {}) => {
const response = await fetch(url, {
credentials: 'same-origin',
...options,
headers: { 'X-WP-Nonce': nonce, 'Content-Type': 'application/json', ...(options.headers || {}) },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.message || '<?php echo esc_js(__('请求失败', 'aksrt')); ?>');
return payload;
};
const rewardText = (rewards) => {
const parts = [];
if (Number(rewards.points) > 0) parts.push(`<?php echo esc_js(__('积分', 'aksrt')); ?>+${rewards.points}`);
if (Number(rewards.balance_minor) > 0) parts.push(`<?php echo esc_js(__('余额', 'aksrt')); ?>+${(Number(rewards.balance_minor) / 100).toFixed(2)}`);
if (Number(rewards.vip_level) > 0 && Number(rewards.vip_days) > 0) parts.push(`VIP${rewards.vip_level}×${rewards.vip_days}${'<?php echo esc_js(__('天', 'aksrt')); ?>'}`);
if (Number(rewards.level_points) > 0) parts.push(`<?php echo esc_js(__('等级积分', 'aksrt')); ?>+${rewards.level_points}`);
return parts.join(' / ') || '—';
};
const statusText = (item) => item.expired
? '<?php echo esc_js(__('已过期', 'aksrt')); ?>'
: ({active: '<?php echo esc_js(__('启用', 'aksrt')); ?>', disabled: '<?php echo esc_js(__('禁用', 'aksrt')); ?>'}[item.status] || item.status);
const load = async (page = 1) => {
currentPage = page;
setNotice('');
rows.innerHTML = '<tr><td colspan="10"><?php echo esc_js(__('正在加载…', 'aksrt')); ?></td></tr>';
const url = new URL(endpoint);
url.searchParams.set('page', String(page));
url.searchParams.set('per_page', '20');
if (status.value) url.searchParams.set('status', status.value);
if (search.value.trim()) url.searchParams.set('search', search.value.trim());
try {
const data = await request(url.toString());
rows.innerHTML = data.items.length ? data.items.map((item) => `
<tr>
<td><code style="user-select:all;">${escapeHtml(item.code)}</code></td>
<td><small>${escapeHtml(item.batch_id || '—')}</small></td>
<td>${escapeHtml(item.remark || '—')}</td>
<td>${escapeHtml(rewardText(item.rewards))}</td>
<td style="text-align:right;">${Number(item.used_count)}/${item.max_uses > 0 ? Number(item.max_uses) : '∞'}</td>
<td>${(item.recent_users || []).map((u) => escapeHtml(u.name)).join('、') || '—'}</td>
<td>${escapeHtml(statusText(item))}</td>
<td>${item.expires_at ? escapeHtml(item.expires_at) : '—'}</td>
<td>${escapeHtml(item.created_at)}</td>
<td>${item.status === 'active' && !item.expired
? `<button class="button" data-inv-action="disable" data-inv-id="${Number(item.id)}"><?php echo esc_js(__('禁用', 'aksrt')); ?></button>`
: `<button class="button" data-inv-action="enable" data-inv-id="${Number(item.id)}"><?php echo esc_js(__('启用', 'aksrt')); ?></button>`}</td>
</tr>`).join('') : '<tr><td colspan="10"><?php echo esc_js(__('暂无邀请码', 'aksrt')); ?></td></tr>';
pages.innerHTML = Array.from({length: Number(data.max_pages || 1)}, (_, index) => {
const number = index + 1;
return `<button type="button" class="button ${number === Number(data.page) ? 'button-primary' : ''}" data-inv-page="${number}">${number}</button>`;
}).join(' ');
} catch (error) {
rows.innerHTML = '<tr><td colspan="10"><?php echo esc_js(__('邀请码数据加载失败', 'aksrt')); ?></td></tr>';
setNotice(error.message, true);
}
};
root.querySelector('#aksrt-inv-generate').addEventListener('click', async (event) => {
const button = event.currentTarget;
button.disabled = true;
try {
const data = await request(`${endpoint}/generate`, {
method: 'POST',
body: JSON.stringify({
count: Number(root.querySelector('#aksrt-inv-count').value) || 1,
max_uses: Number(root.querySelector('#aksrt-inv-max-uses').value) || 0,
expires_days: Number(root.querySelector('#aksrt-inv-exp-days').value) || 0,
remark: root.querySelector('#aksrt-inv-remark').value.trim(),
rewards: {
points: Number(root.querySelector('#aksrt-inv-points').value) || 0,
balance: Number(root.querySelector('#aksrt-inv-balance').value) || 0,
vip_level: Number(root.querySelector('#aksrt-inv-vip-level').value) || 0,
vip_days: Number(root.querySelector('#aksrt-inv-vip-days').value) || 0,
level_points: Number(root.querySelector('#aksrt-inv-level-points').value) || 0,
},
}),
});
generated.style.display = '';
generatedCodes.textContent = data.codes.join(', ');
setNotice(`<?php echo esc_js(__('已生成', 'aksrt')); ?> ${data.count} <?php echo esc_js(__('个邀请码', 'aksrt')); ?>`);
await load(1);
} catch (error) {
setNotice(error.message, true);
} finally {
button.disabled = false;
}
});
root.querySelector('#aksrt-inv-import-toggle').addEventListener('click', (event) => {
const panel = root.querySelector('#aksrt-inv-import-panel');
panel.style.display = panel.style.display === 'none' ? '' : 'none';
event.currentTarget.setAttribute('aria-expanded', panel.style.display === 'none' ? 'false' : 'true');
});
root.querySelector('#aksrt-inv-import-submit').addEventListener('click', async (event) => {
const button = event.currentTarget;
const data = root.querySelector('#aksrt-inv-import-data').value;
if (!data.trim()) { setNotice('<?php echo esc_js(__('请粘贴需要导入的数据', 'aksrt')); ?>', true); return; }
button.disabled = true;
try {
const result = await request(`${endpoint}/import`, {
method: 'POST',
body: JSON.stringify({
data,
division: root.querySelector('#aksrt-inv-import-division').value || ' ',
max_uses: Number(root.querySelector('#aksrt-inv-import-max-uses').value) || 0,
expires_days: Number(root.querySelector('#aksrt-inv-import-exp-days').value) || 0,
}),
});
const failedDetail = (result.failed_codes || []).length
? `,<?php echo esc_js(__('失败:', 'aksrt')); ?>${result.failed_codes.join('、')}`
: '';
setNotice(`<?php echo esc_js(__('导入完成:成功', 'aksrt')); ?> ${result.imported} <?php echo esc_js(__('个', 'aksrt')); ?>` +
(result.failed ? `,<?php echo esc_js(__('失败', 'aksrt')); ?> ${result.failed} <?php echo esc_js(__('个', 'aksrt')); ?>` : '') + failedDetail,
result.failed > 0);
root.querySelector('#aksrt-inv-import-data').value = '';
await load(1);
} catch (error) {
setNotice(error.message, true);
} finally {
button.disabled = false;
}
});
filter.addEventListener('click', () => load(1));
search.addEventListener('keydown', (event) => { if (event.key === 'Enter') load(1); });
root.addEventListener('click', async (event) => {
const pageButton = event.target.closest('[data-inv-page]');
if (pageButton) return load(Number(pageButton.dataset.invPage));
const button = event.target.closest('[data-inv-action]');
if (!button) return;
button.disabled = true;
try {
await request(`${endpoint}/${Number(button.dataset.invId)}/${button.dataset.invAction}`, { method: 'POST', body: '{}' });
setNotice('<?php echo esc_js(__('邀请码状态已更新', 'aksrt')); ?>');
await load(currentPage);
} catch (error) {
setNotice(error.message, true);
button.disabled = false;
}
});
load();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();
</script>
<?php
}
}
+5
View File
@@ -312,6 +312,10 @@ class SchemaPayment {
]],
['key' => 'pay_rebate_show_users', 'label' => '用户中心显示我推荐的用户', 'type' => 'switch', 'default' => true, 'dependsOn' => ['key' => 'pay_rebate_judgment', 'value' => 'all']],
['key' => 'pay_rebate_self_s', 'label' => '自己推广自己', 'type' => 'switch', 'default' => true, 'dependsOn' => ['key' => 'pay_rebate_s', 'value' => true], 'help' => '允许使用自己的推广链接购买商品并获得佣金'],
['key' => 'referral_attribution_model', 'label' => '推广归因模式', 'type' => 'radio', 'default' => 'first_touch', 'options' => [
['label' => '首次触点(首触优先)', 'value' => 'first_touch'],
['label' => '最近触点(末触覆盖)', 'value' => 'last_touch'],
], 'help' => '首触:访客首次通过推广链接进入后,归因固定且不被后续链接覆盖;末触:最近一次点击的推广码覆盖之前的归因'],
]],
['key' => 'rules', 'title' => '返佣规则', 'fields' => [
['key' => 'rebate_rule', 'label' => '为不同用户类型设置返佣规则', 'type' => 'tabbed', 'tabs' => [
@@ -344,6 +348,7 @@ class SchemaPayment {
]],
]],
['key' => 'affiliate_settlement_days', 'label' => '结算观察期(天)', 'type' => 'number', 'default' => 7, 'min' => 0, 'max' => 90, 'dependsOn' => ['key' => 'pay_rebate_s', 'value' => true], 'help' => '订单完成后经过观察期,佣金才会结算到可提现余额'],
['key' => 'affiliate_disabled_code_stops_commission', 'label' => '推广码禁用后停止返佣', 'type' => 'switch', 'default' => false, 'dependsOn' => ['key' => 'pay_rebate_s', 'value' => true], 'help' => '开启后,推广者的推广码被禁用(或缺失)时,其名下已绑定用户的新订单不再产生佣金;关闭则绑定关系继续生效'],
]],
['key' => 'text', 'title' => '返佣文案', 'fields' => [
['key' => 'pay_rebate_text_desc', 'label' => '返佣文案(一句话简介)', 'type' => 'input', 'default' => '加入分享计划,获得高额奖励', 'dependsOn' => ['key' => 'pay_rebate_s', 'value' => true]],
+9
View File
@@ -195,6 +195,15 @@ class SchemaUsers {
['key' => 'invit_code_open_desc', 'label' => '选填提示', 'type' => 'input', 'default' => '如您受到邀请,可填写推广码。部分推广码会有额外积分赠送'],
['key' => 'invit_code_must_desc', 'label' => '必填提示', 'type' => 'textarea', 'default' => '<div>本站需使用推广码注册</div><div>获取推广码请与客服联系</div>'],
]],
['key' => 'invite_inline', 'title' => '邀请码注册', 'fields' => [
['key' => 'invite_code_s', 'label' => '邀请码注册模式', 'type' => 'radio', 'default' => 'close', 'options' => [
['label' => '关闭', 'value' => 'close'],
['label' => '选填', 'value' => 'open'],
['label' => '必填', 'value' => 'must'],
], 'help' => '一次性邀请码与推广码相互独立:邀请码用于控制注册并按码发放奖励,在「用户 → 邀请码管理」批量生成'],
['key' => 'invite_code_open_desc', 'label' => '选填提示', 'type' => 'input', 'default' => '请输入邀请码,注册成功后奖励将自动发放'],
['key' => 'invite_code_must_desc', 'label' => '必填提示', 'type' => 'textarea', 'default' => '<div>本站需凭邀请码注册</div><div>获取邀请码请与客服联系</div>'],
]],
['key' => 'sign_page_ui', 'title' => '页面 UI 配置', 'fields' => [
['key' => 'sign_page_background', 'label' => '页面背景图', 'type' => 'gallery', 'default' => [], 'desc' => '登录注册页背景图,多图随机'],
['key' => 'sign_theme_mode', 'label' => '主题模式', 'type' => 'radio', 'default' => 'no', 'options' => [
+21
View File
@@ -122,11 +122,32 @@ trait ServicePart1a {
return ['success' => false, 'error' => __('推广码无效,请检查后重试', 'aksrt')];
}
$invite_service = class_exists('\AKSRT\Payment\InviteCode')
? \AKSRT\Payment\InviteCode::get_instance()
: null;
if ($invite_service) {
$invite_service->clear_registration_context();
$invite_mode = $invite_service->field_mode();
$submitted_invite = isset($post['invite_code']) ? sanitize_key((string) wp_unslash($post['invite_code'])) : '';
if ($invite_mode === 'required' && $submitted_invite === '') {
return ['success' => false, 'error' => __('请填写邀请码', 'aksrt')];
}
if ($submitted_invite !== '') {
if (!$invite_service->is_valid($submitted_invite)) {
return ['success' => false, 'error' => __('邀请码无效或已用完,请检查后重试', 'aksrt')];
}
$invite_service->set_registration_context($submitted_invite);
}
}
$user_id = wp_create_user($user_login, $password, $user_email);
if (is_wp_error($user_id)) {
if ($ref_service) {
$ref_service->clear_registration_context();
}
if ($invite_service) {
$invite_service->clear_registration_context();
}
return ['success' => false, 'error' => $user_id->get_error_message()];
}
$ref_notice = $ref_service ? $ref_service->take_registration_notice() : '';
+21
View File
@@ -96,12 +96,33 @@ trait ServicePart1b {
return ['success' => false, 'error' => __('推广码无效,请检查后重试', 'aksrt')];
}
$invite_service = class_exists('\AKSRT\Payment\InviteCode')
? \AKSRT\Payment\InviteCode::get_instance()
: null;
if ($invite_service) {
$invite_service->clear_registration_context();
$invite_mode = $invite_service->field_mode();
$submitted_invite = sanitize_key((string) ($post['invite_code'] ?? ''));
if ($invite_mode === 'required' && $submitted_invite === '') {
return ['success' => false, 'error' => __('请填写邀请码', 'aksrt')];
}
if ($submitted_invite !== '') {
if (!$invite_service->is_valid($submitted_invite)) {
return ['success' => false, 'error' => __('邀请码无效或已用完,请检查后重试', 'aksrt')];
}
$invite_service->set_registration_context($submitted_invite);
}
}
// 创建用户
$user_id = wp_create_user($username, $pass, '');
if (is_wp_error($user_id)) {
if ($ref_service) {
$ref_service->clear_registration_context();
}
if ($invite_service) {
$invite_service->clear_registration_context();
}
return ['success' => false, 'error' => $user_id->get_error_message()];
}
+34
View File
@@ -376,6 +376,40 @@ trait DBMigratorPart2 {
) ENGINE=InnoDB {$charset_collate};";
dbDelta($sql_charge_card);
// ── one-time invite codes (registration gate + per-batch rewards) ──
$sql_invite_code = "CREATE TABLE {$prefix}aksrt_invite_code (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
code VARCHAR(32) NOT NULL,
batch_id VARCHAR(32) NOT NULL DEFAULT '',
remark VARCHAR(191) NOT NULL DEFAULT '',
rewards TEXT DEFAULT NULL,
max_uses INT UNSIGNED NOT NULL DEFAULT 1,
used_count INT UNSIGNED NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'active',
expires_at DATETIME DEFAULT NULL,
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uniq_invite_code (code),
KEY idx_batch (batch_id),
KEY idx_status (status),
KEY idx_expires (expires_at)
) ENGINE=InnoDB {$charset_collate};";
dbDelta($sql_invite_code);
// ── invite-code usage log (who consumed which code) ──
$sql_invite_code_usage = "CREATE TABLE {$prefix}aksrt_invite_code_usage (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
code_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
used_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uniq_invite_usage (code_id, user_id),
KEY idx_usage_user (user_id),
KEY idx_usage_code (code_id)
) ENGINE=InnoDB {$charset_collate};";
dbDelta($sql_invite_code_usage);
// ── canonical Ref attribution and permanent user binding ──
$sql_affiliate_relation = "CREATE TABLE {$prefix}aksrt_affiliate_relation (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+2
View File
@@ -32,6 +32,8 @@ trait DBMigratorPart3 {
'follow' => 'aksrt_follow',
'coupon' => 'aksrt_coupon',
'charge_card' => 'aksrt_charge_card',
'invite_code' => 'aksrt_invite_code',
'invite_code_usage' => 'aksrt_invite_code_usage',
'affiliate_relation' => 'aksrt_affiliate_relation',
'commission_ledger' => 'aksrt_commission_ledger',
'commission_adjustment' => 'aksrt_commission_adjustment',
+14 -1
View File
@@ -691,11 +691,24 @@ trait ScriptsPart1b {
// profile 页:加载 credits.js(签到 + 邀请 + 提现交互)
if ($is_profile_page) {
$affiliate_deps = [];
if ($affiliate_on) {
// 推广二维码生成库(本地打包,无外部 CDN)。
$qrcode_js_path = get_template_directory() . '/assets/js/vendor/qrcode.min.js';
wp_enqueue_script(
'aksrt-qrcode',
$base_uri . '/assets/js/vendor/qrcode.min.js',
[],
file_exists($qrcode_js_path) ? (string) filemtime($qrcode_js_path) : $version,
true
);
$affiliate_deps[] = 'aksrt-qrcode';
}
$credits_js_path = get_template_directory() . '/assets/js/credits.js';
wp_enqueue_script(
'aksrt-credits',
$base_uri . '/assets/js/credits.js',
['loader_js', 'aksrt-vip-coordinator'],
array_merge(['loader_js', 'aksrt-vip-coordinator'], $affiliate_deps),
file_exists($credits_js_path) ? (string) filemtime($credits_js_path) : $version,
true
);
+14
View File
@@ -57,6 +57,11 @@ final class CommissionLedger {
if ($order_no === '' || $buyer <= 0 || $amount->minor() <= 0) return 0;
$promoter = ReferralAttribution::get_instance()->resolve($buyer);
if ($promoter <= 0) return 0;
// 开关开启时,推广者的推广码已被禁用(或缺失)则不再产生新佣金。
if (!empty($settings['disabled_code_stops_commission'])
&& !ReferralAttribution::get_instance()->is_promoter_code_active($promoter)) {
return 0;
}
// 返佣订单类型按推广者会员等级分档(参考主题:每档独立勾选)。
$promoter_level = function_exists('aksrt_get_user_vip_level') ? (int) aksrt_get_user_vip_level($promoter) : 0;
if (!in_array($order_type, \AKSRT\Settings\PaymentRuntime::affiliate_order_types_for_level($promoter_level), true)) return 0;
@@ -98,6 +103,14 @@ final class CommissionLedger {
foreach ((array) $rows as $row) if ($this->settle_one((int)$row['id'])) $count++;
return $count;
}
/** 懒结算兜底:收益相关入口顺带结算到期佣金,弥补 wp-cron 在低流量站点的时效缺口。 */
private static bool $lazy_settled = false;
public function lazy_settle_due(): void {
if (self::$lazy_settled) return;
self::$lazy_settled = true;
if (!\AKSRT\Settings\Settings::affiliate_enabled()) return;
$this->settle_due(20);
}
private function settle_one(int $id, string $operation_key = '') {
global $wpdb; $table = $this->table('commission_ledger');
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id=%d", $id), ARRAY_A);
@@ -228,6 +241,7 @@ final class CommissionLedger {
return $negative ? -$minor : $minor;
}
public function overview(int $user_id): array {
$this->lazy_settle_due();
global $wpdb; $table=$this->table('commission_ledger');
$row=$wpdb->get_row($wpdb->prepare("SELECT COALESCE(SUM(original_amount+adjustment_total),0) total, COALESCE(SUM(CASE WHEN status='pending' THEN original_amount+adjustment_total ELSE 0 END),0) pending, COALESCE(SUM(CASE WHEN status='settled' THEN original_amount+adjustment_total ELSE 0 END),0) settled FROM {$table} WHERE promoter_id=%d",$user_id),ARRAY_A) ?: [];
$withdrawn = '0.00';
+663
View File
@@ -0,0 +1,663 @@
<?php
/**
* One-time invite codes: registration gating, atomic consumption, per-batch rewards.
*
* 消费走单条条件 UPDATE(affected_rows === 1 判定),并发注册不可能超发。
*
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
final class InviteCode {
private const META_PENDING = 'aksrt_invite_code_pending';
private const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
private const CODE_LENGTH = 10;
private static ?self $instance = null;
/** 注册请求内待消费的邀请码(经服务层校验后写入)。 */
private static string $pending_code = '';
public static function get_instance(): self {
return self::$instance ??= new self();
}
private function __construct() {}
public function init(): void {
add_action('user_register', [$this, 'handle_registration'], 30, 1);
add_action('aksrt_email_verified', [$this, 'handle_email_verified'], 10, 1);
add_action('rest_api_init', [$this, 'register_routes']);
}
/* ------------------------------------------------------------------
* Settings
* ----------------------------------------------------------------*/
/** @return array{enabled:bool,register_field:string,open_desc:string,required_desc:string} */
public function registration_settings(): array {
return class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_invite_code_registration_settings()
: ['enabled' => false, 'register_field' => 'hidden', 'open_desc' => '', 'required_desc' => ''];
}
public function field_mode(): string {
$settings = $this->registration_settings();
return !empty($settings['enabled']) ? (string) $settings['register_field'] : 'hidden';
}
/* ------------------------------------------------------------------
* Validation & consumption
* ----------------------------------------------------------------*/
private function table(): string {
return \AKSRT\Core\DBMigrator::table('invite_code');
}
/** 码是否可用(存在、启用、未过期、未超发)。 */
public function is_valid(string $code): bool {
global $wpdb;
$table = $this->table();
$code = sanitize_key($code);
if ($code === '' || $table === '') {
return false;
}
$row = $wpdb->get_row($wpdb->prepare(
"SELECT id FROM {$table} WHERE code = %s AND status = 'active' AND (max_uses = 0 OR used_count < max_uses) AND (expires_at IS NULL OR expires_at > %s)",
$code,
current_time('mysql')
));
return $row !== null;
}
public function clear_registration_context(): void {
self::$pending_code = '';
}
public function set_registration_context(string $code): void {
self::$pending_code = sanitize_key($code);
}
/** user_register 钩子:原子消费注册上下文中的邀请码,按需延迟或立即发奖。 */
public function handle_registration(int $user_id): void {
$code = self::$pending_code;
self::$pending_code = '';
if ($code === '' || $user_id <= 0) {
return;
}
$row = $this->consume($code, $user_id);
if (!is_array($row)) {
return;
}
if (class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::signup_verification_enabled()) {
// 站点要求注册前验证:奖励延迟到邮箱/手机验证通过,防止占坑刷奖。
update_user_meta($user_id, self::META_PENDING, (int) $row['id']);
return;
}
$this->grant_reward($user_id, (int) $row['id'], (string) $row['rewards']);
}
/** aksrt_email_verified 钩子:补发延迟奖励。 */
public function handle_email_verified(int $user_id): void {
if ($user_id <= 0) {
return;
}
$code_id = (int) get_user_meta($user_id, self::META_PENDING, true);
if ($code_id <= 0) {
return;
}
delete_user_meta($user_id, self::META_PENDING);
global $wpdb;
$table = $this->table();
if ($table === '') {
return;
}
$row = $wpdb->get_row($wpdb->prepare("SELECT id, rewards FROM {$table} WHERE id = %d", $code_id), ARRAY_A);
if (is_array($row)) {
$this->grant_reward($user_id, (int) $row['id'], (string) $row['rewards']);
}
}
/**
* 原子消费:单条条件 UPDATE,affected_rows === 1 才算成功;同时写入使用记录。
*
* @return array{id:int,rewards:string}|\null
*/
private function consume(string $code, int $user_id = 0): ?array {
global $wpdb;
$table = $this->table();
$code = sanitize_key($code);
if ($code === '' || $table === '') {
return null;
}
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$table} SET used_count = used_count + 1 WHERE code = %s AND status = 'active' AND (max_uses = 0 OR used_count < max_uses) AND (expires_at IS NULL OR expires_at > %s)",
$code,
current_time('mysql')
));
if ($updated !== 1) {
return null;
}
$row = $wpdb->get_row($wpdb->prepare("SELECT id, rewards FROM {$table} WHERE code = %s", $code), ARRAY_A);
if (!is_array($row)) {
return null;
}
if ($user_id > 0) {
$usage_table = \AKSRT\Core\DBMigrator::table('invite_code_usage');
if ($usage_table !== '') {
$wpdb->insert($usage_table, [
'code_id' => (int) $row['id'],
'user_id' => $user_id,
'used_at' => current_time('mysql'),
], ['%d', '%d', '%s']);
}
}
return ['id' => (int) $row['id'], 'rewards' => (string) $row['rewards']];
}
/* ------------------------------------------------------------------
* Rewards
* ----------------------------------------------------------------*/
/** @return array{points:int,balance_minor:int,vip_level:int,vip_days:int,level_points:int} */
private function normalize_rewards(string $rewards_json): array {
$raw = json_decode($rewards_json, true);
$raw = is_array($raw) ? $raw : [];
$clamp = static fn($value): int => max(0, (int) $value);
return [
'points' => $clamp($raw['points'] ?? 0),
'balance_minor' => $clamp(round(((float) ($raw['balance'] ?? 0)) * 100)),
'vip_level' => min(2, $clamp($raw['vip_level'] ?? 0)),
'vip_days' => $clamp($raw['vip_days'] ?? 0),
'level_points' => $clamp($raw['level_points'] ?? 0),
];
}
private function grant_reward(int $user_id, int $code_id, string $rewards_json): void {
$rewards = $this->normalize_rewards($rewards_json);
if ($user_id <= 0 || $code_id <= 0) {
return;
}
if ($rewards['points'] > 0) {
\AKSRT\Ledger\LedgerService::get_instance()->credit(
$user_id,
\AKSRT\Ledger\AssetType::POINTS,
$rewards['points'],
\AKSRT\Ledger\OperationKey::deterministic('invite-reward', [$code_id, $user_id, 'points']),
'invite_code',
['type' => 'invite_code', 'id' => $code_id],
__('邀请码注册奖励', 'aksrt')
);
}
if ($rewards['balance_minor'] > 0) {
\AKSRT\Ledger\LedgerService::get_instance()->credit(
$user_id,
\AKSRT\Ledger\AssetType::BALANCE,
$rewards['balance_minor'],
\AKSRT\Ledger\OperationKey::deterministic('invite-reward', [$code_id, $user_id, 'balance']),
'invite_code',
['type' => 'invite_code', 'id' => $code_id],
__('邀请码注册奖励', 'aksrt')
);
}
if ($rewards['vip_level'] > 0 && $rewards['vip_days'] > 0 && class_exists('\AKSRT\Content\VipService')) {
\AKSRT\Content\VipService::get_instance()->activate_vip($user_id, $rewards['vip_level'], $rewards['vip_days']);
}
if ($rewards['level_points'] > 0 && class_exists('\AKSRT\User\Level')) {
\AKSRT\User\Level::add($user_id, 'invite_code', $rewards['level_points'], true);
}
do_action('aksrt_invite_code_rewarded', $user_id, $code_id, $rewards);
}
/* ------------------------------------------------------------------
* Admin operations
* ----------------------------------------------------------------*/
/**
* 批量生成邀请码。
*
* @return array<string>|\WP_Error 生成的码列表
*/
public function generate_batch(array $args) {
global $wpdb;
$table = $this->table();
if ($table === '') {
return new \WP_Error('aksrt_invite_db', __('数据表不可用', 'aksrt'), ['status' => 500]);
}
$count = min(500, max(1, (int) ($args['count'] ?? 1)));
$max_uses = max(0, (int) ($args['max_uses'] ?? 1));
$expires_days = max(0, (int) ($args['expires_days'] ?? 0));
$rewards = $this->normalize_rewards((string) wp_json_encode($args['rewards'] ?? []));
if (max($rewards['points'], $rewards['balance_minor'], $rewards['vip_level'] * $rewards['vip_days'], $rewards['level_points']) === 0) {
$rewards = ['points' => 0, 'balance_minor' => 0, 'vip_level' => 0, 'vip_days' => 0, 'level_points' => 0];
}
$rewards_json = (string) wp_json_encode($rewards, JSON_UNESCAPED_UNICODE);
$remark = sanitize_text_field((string) ($args['remark'] ?? ''));
$batch_id = 'B' . gmdate('YmdHis') . wp_generate_password(4, false, false);
$expires_at = $expires_days > 0 ? gmdate('Y-m-d H:i:s', time() + $expires_days * DAY_IN_SECONDS) : null;
$created_by = get_current_user_id();
$created_at = current_time('mysql');
$codes = [];
for ($i = 0; $i < $count; $i++) {
$code = '';
for ($attempt = 0; $attempt < 20; $attempt++) {
$candidate = '';
for ($j = 0; $j < self::CODE_LENGTH; $j++) {
$candidate .= self::ALPHABET[random_int(0, strlen(self::ALPHABET) - 1)];
}
$exists = $wpdb->get_var($wpdb->prepare("SELECT id FROM {$table} WHERE code = %s", $candidate));
if ($exists === null) {
$code = $candidate;
break;
}
}
if ($code === '') {
continue;
}
$inserted = $wpdb->insert($table, [
'code' => $code,
'batch_id' => $batch_id,
'remark' => $remark,
'rewards' => $rewards_json,
'max_uses' => $max_uses,
'status' => 'active',
'expires_at' => $expires_at,
'created_by' => $created_by,
'created_at' => $created_at,
], ['%s', '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%s']);
if ($inserted === 1) {
$codes[] = $code;
}
}
if ($codes === []) {
return new \WP_Error('aksrt_invite_generate', __('邀请码生成失败,请重试', 'aksrt'), ['status' => 500]);
}
return $codes;
}
/**
* 文本导入邀请码(对齐 Zibll 格式):一行一码,`邀请码<分隔符>奖励<分隔符>标识`。
* 奖励参数串:points=20&balance=30&vip=1&vip_time=5&level_integral=100(balance 单位为元),"无" 或留空表示无奖励。
*
* @return array{imported:int,failed:int,failed_codes:array<string>}|\WP_Error
*/
public function import_codes(array $args) {
global $wpdb;
$table = $this->table();
if ($table === '') {
return new \WP_Error('aksrt_invite_db', __('数据表不可用', 'aksrt'), ['status' => 500]);
}
$raw = (string) ($args['data'] ?? '');
$division = trim((string) ($args['division'] ?? ' '));
if ($division === '') {
$division = ' ';
}
$max_uses = max(0, (int) ($args['max_uses'] ?? 1));
$expires_days = max(0, (int) ($args['expires_days'] ?? 0));
$batch_id = 'I' . gmdate('YmdHis') . wp_generate_password(4, false, false);
$expires_at = $expires_days > 0 ? gmdate('Y-m-d H:i:s', time() + $expires_days * DAY_IN_SECONDS) : null;
$created_by = get_current_user_id();
$created_at = current_time('mysql');
$lines = array_slice(preg_split('/\r\n|\r|\n/', $raw) ?: [], 0, 1000);
$imported = 0;
$failed = 0;
$failed_codes = [];
foreach ($lines as $line) {
$line = trim((string) $line);
if ($line === '') {
continue;
}
$parts = $division === ' ' ? preg_split('/\s+/', $line, 3) : explode($division, (string) $line, 3);
$code = strtoupper(preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($parts[0] ?? '')));
if ($code === '') {
$failed++;
continue;
}
$exists = $wpdb->get_var($wpdb->prepare("SELECT id FROM {$table} WHERE code = %s", $code));
if ($exists !== null) {
$failed++;
$failed_codes[] = $code;
continue;
}
$rewards_json = $this->parse_reward_string(trim((string) ($parts[1] ?? '')));
$remark = sanitize_text_field(trim((string) ($parts[2] ?? '')));
$inserted = $wpdb->insert($table, [
'code' => $code,
'batch_id' => $batch_id,
'remark' => $remark,
'rewards' => $rewards_json,
'max_uses' => $max_uses,
'status' => 'active',
'expires_at' => $expires_at,
'created_by' => $created_by,
'created_at' => $created_at,
], ['%s', '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%s']);
if ($inserted === 1) {
$imported++;
} else {
$failed++;
$failed_codes[] = $code;
}
}
if ($imported === 0 && $failed === 0) {
return new \WP_Error('aksrt_invite_import_empty', __('请粘贴需要导入的数据', 'aksrt'), ['status' => 400]);
}
return ['imported' => $imported, 'failed' => $failed, 'failed_codes' => array_slice($failed_codes, 0, 20)];
}
/** Zibll 兼容奖励参数串 → 归一化奖励 JSON("无"/空串 → 全零)。 */
private function parse_reward_string(string $reward): string {
$reward = trim($reward);
if ($reward === '' || in_array($reward, ['无', 'none', '0'], true)) {
return (string) wp_json_encode(['points' => 0, 'balance_minor' => 0, 'vip_level' => 0, 'vip_days' => 0, 'level_points' => 0], JSON_UNESCAPED_UNICODE);
}
$map = ['points' => 'points', 'balance' => 'balance', 'vip' => 'vip_level', 'vip_time' => 'vip_days', 'level_integral' => 'level_points'];
$parsed = [];
foreach (explode('&', $reward) as $pair) {
[$key, $value] = array_pad(explode('=', trim($pair), 2), 2, '');
$key = strtolower(trim($key));
if (isset($map[$key]) && is_numeric($value)) {
$parsed[$map[$key]] = (int) $value;
}
}
return (string) wp_json_encode($parsed, JSON_UNESCAPED_UNICODE);
}
/** 奖励 JSON → 导入格式参数串(零奖励返回"无"),与 parse_reward_string 互逆。 */
private function reward_param_string(string $rewards_json): string {
$rewards = $this->normalize_rewards($rewards_json);
$parts = [];
if ($rewards['points'] > 0) {
$parts[] = 'points=' . $rewards['points'];
}
if ($rewards['balance_minor'] > 0) {
$parts[] = 'balance=' . rtrim(rtrim((string) ($rewards['balance_minor'] / 100), '0'), '.');
}
if ($rewards['vip_level'] > 0) {
$parts[] = 'vip=' . $rewards['vip_level'] . '&vip_time=' . $rewards['vip_days'];
}
if ($rewards['level_points'] > 0) {
$parts[] = 'level_integral=' . $rewards['level_points'];
}
return $parts === [] ? '无' : implode('&', $parts);
}
public function set_status(int $id, string $status): bool {
global $wpdb;
$table = $this->table();
if ($id <= 0 || $table === '' || !in_array($status, ['active', 'disabled'], true)) {
return false;
}
return $wpdb->update($table, ['status' => $status], ['id' => $id], ['%s'], ['%d']) === 1;
}
/** @return array{items:array<int,array<string,mixed>>,total:int,page:int,per_page:int,max_pages:int} */
public function list_codes(array $args): array {
global $wpdb;
$table = $this->table();
$page = max(1, (int) ($args['page'] ?? 1));
$per_page = max(1, min(100, (int) ($args['per_page'] ?? 20)));
$status = sanitize_key((string) ($args['status'] ?? ''));
$search = trim((string) ($args['search'] ?? ''));
if ($table === '') {
return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $per_page, 'max_pages' => 1];
}
$where = ['1=1'];
$params = [];
if (in_array($status, ['active', 'disabled'], true)) {
$where[] = 'status = %s';
$params[] = $status;
}
if ($search !== '') {
$where[] = '(code LIKE %s OR batch_id LIKE %s)';
$like = '%' . $wpdb->esc_like($search) . '%';
array_push($params, $like, $like);
}
$where_sql = implode(' AND ', $where);
$total = (int) ($params
? $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE {$where_sql}", $params))
: $wpdb->get_var("SELECT COUNT(*) FROM {$table}"));
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$table} WHERE {$where_sql} ORDER BY id DESC LIMIT %d OFFSET %d",
array_merge($params, [$per_page, ($page - 1) * $per_page])
), ARRAY_A);
$items = [];
$code_ids = array_map(static fn($row) => (int) $row['id'], (array) $rows);
$recent_users = $this->recent_users_map($code_ids);
foreach ((array) $rows as $row) {
$rewards = $this->normalize_rewards((string) ($row['rewards'] ?? ''));
$items[] = [
'id' => (int) $row['id'],
'code' => (string) $row['code'],
'batch_id' => (string) $row['batch_id'],
'remark' => (string) ($row['remark'] ?? ''),
'rewards' => $rewards,
'max_uses' => (int) $row['max_uses'],
'used_count' => (int) $row['used_count'],
'recent_users' => $recent_users[(int) $row['id']] ?? [],
'status' => (string) $row['status'],
'expired' => !empty($row['expires_at']) && strtotime((string) $row['expires_at']) <= time(),
'expires_at' => (string) ($row['expires_at'] ?? ''),
'created_at' => (string) ($row['created_at'] ?? ''),
];
}
return [
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $per_page,
'max_pages' => max(1, (int) ceil($total / $per_page)),
];
}
/**
* 批量取每个码最近的使用者(最多 3 个),用于运营列表展示。
*
* @param array<int,int> $code_ids
* @return array<int,array<int,array{user_id:int,name:string,used_at:string}>>
*/
private function recent_users_map(array $code_ids): array {
global $wpdb;
if ($code_ids === []) {
return [];
}
$usage_table = \AKSRT\Core\DBMigrator::table('invite_code_usage');
if ($usage_table === '') {
return [];
}
$placeholders = implode(',', array_fill(0, count($code_ids), '%d'));
$rows = $wpdb->get_results($wpdb->prepare(
// 最近三名使用者:按 code_id 分组取时间最新的记录。
"SELECT u.code_id, u.user_id, u.used_at, wu.display_name
FROM {$usage_table} u
INNER JOIN {$wpdb->users} wu ON wu.ID = u.user_id
INNER JOIN {$usage_table} u2 ON u2.code_id = u.code_id AND u2.used_at <= u.used_at
WHERE u.code_id IN ({$placeholders})
GROUP BY u.code_id, u.user_id, u.used_at, wu.display_name
HAVING COUNT(*) <= 3
ORDER BY u.code_id ASC, u.used_at DESC",
$code_ids
), ARRAY_A);
$map = [];
foreach ((array) $rows as $row) {
$map[(int) $row['code_id']][] = [
'user_id' => (int) $row['user_id'],
'name' => (string) $row['display_name'],
'used_at' => (string) $row['used_at'],
];
}
return $map;
}
/* ------------------------------------------------------------------
* REST
* ----------------------------------------------------------------*/
public function register_routes(): void {
// 公开校验:注册表单实时检查邀请码是否可用(只读、无敏感信息)。
register_rest_route('aksrt/v1', '/invite-code/verify', [
'methods' => \WP_REST_Server::READABLE,
'callback' => fn(\WP_REST_Request $request) => rest_ensure_response([
'valid' => $this->is_valid((string) $request['code']),
]),
'permission_callback' => '__return_true',
'args' => [
'code' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key'],
],
]);
$admin_guard = fn() => current_user_can('edit_users')
? true
: new \WP_Error('aksrt_invite_forbidden', __('无权执行此操作', 'aksrt'), ['status' => 403]);
$write_guard = function () use ($admin_guard) {
$allowed = $admin_guard();
if (is_wp_error($allowed)) {
return $allowed;
}
$nonce = (string) isset($_SERVER['HTTP_X_WP_NONCE']) ? sanitize_text_field((string) wp_unslash($_SERVER['HTTP_X_WP_NONCE'])) : '';
return $nonce !== '' && wp_verify_nonce($nonce, 'wp_rest')
? true
: new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
};
register_rest_route('aksrt/v1', '/invite-codes', [
'methods' => \WP_REST_Server::READABLE,
'callback' => fn(\WP_REST_Request $request) => rest_ensure_response($this->list_codes($request->get_params())),
'permission_callback' => $admin_guard,
'args' => [
'page' => ['type' => 'integer', 'default' => 1, 'minimum' => 1],
'per_page' => ['type' => 'integer', 'default' => 20, 'minimum' => 1, 'maximum' => 100],
'search' => ['type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field'],
'status' => ['type' => 'string', 'default' => '', 'enum' => ['', 'active', 'disabled']],
],
]);
register_rest_route('aksrt/v1', '/invite-codes/generate', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => fn(\WP_REST_Request $request) => $this->rest_generate($request),
'permission_callback' => $write_guard,
]);
register_rest_route('aksrt/v1', '/invite-codes/import', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => function (\WP_REST_Request $request) {
$result = $this->import_codes([
'data' => (string) $request->get_param('data'),
'division' => (string) $request->get_param('division'),
'max_uses' => (int) $request->get_param('max_uses'),
'expires_days' => (int) $request->get_param('expires_days'),
]);
return is_wp_error($result) ? $result : new \WP_REST_Response(['success' => true] + $result, 201);
},
'permission_callback' => $write_guard,
'args' => [
'data' => ['type' => 'string', 'required' => true],
'division' => ['type' => 'string', 'default' => ' ', 'sanitize_callback' => 'sanitize_text_field'],
'max_uses' => ['type' => 'integer', 'default' => 1, 'minimum' => 0, 'maximum' => 9999],
'expires_days' => ['type' => 'integer', 'default' => 0, 'minimum' => 0, 'maximum' => 3650],
],
]);
register_rest_route('aksrt/v1', '/invite-codes/export', [
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'rest_export'],
'permission_callback' => $admin_guard,
]);
register_rest_route('aksrt/v1', '/invite-codes/export-text', [
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'rest_export_text'],
'permission_callback' => $admin_guard,
'args' => [
'division' => ['type' => 'string', 'default' => ' ', 'sanitize_callback' => 'sanitize_text_field'],
],
]);
foreach (['disable' => 'disabled', 'enable' => 'active'] as $action => $status) {
register_rest_route('aksrt/v1', '/invite-codes/(?P<id>\d+)/' . $action, [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => function (\WP_REST_Request $request) use ($status) {
$updated = $this->set_status((int) $request['id'], $status);
return $updated
? rest_ensure_response(['success' => true, 'id' => (int) $request['id'], 'status' => $status])
: new \WP_Error('aksrt_invite_status', __('邀请码状态更新失败', 'aksrt'), ['status' => 409]);
},
'permission_callback' => $write_guard,
'args' => ['id' => ['type' => 'integer', 'minimum' => 1, 'required' => true]],
]);
}
}
private function rest_generate(\WP_REST_Request $request) {
$rewards = $request->get_param('rewards');
$codes = $this->generate_batch([
'count' => (int) $request->get_param('count'),
'max_uses' => (int) $request->get_param('max_uses'),
'expires_days' => (int) $request->get_param('expires_days'),
'remark' => (string) $request->get_param('remark'),
'rewards' => is_array($rewards) ? $rewards : [],
]);
if (is_wp_error($codes)) {
return $codes;
}
return new \WP_REST_Response(['success' => true, 'codes' => $codes, 'count' => count($codes)], 201);
}
public function rest_export(): \WP_REST_Response {
global $wpdb;
$table = $this->table();
$rows = $wpdb->get_results("SELECT code, batch_id, remark, rewards, max_uses, used_count, status, expires_at, created_at FROM {$table} ORDER BY id DESC LIMIT 10000", ARRAY_A);
$lines = ['code,batch_id,remark,rewards,max_uses,used_count,status,expires_at,created_at'];
foreach ((array) $rows as $row) {
$row['rewards'] = $this->reward_param_string((string) ($row['rewards'] ?? ''));
$lines[] = implode(',', array_map(static fn($value) => '"' . str_replace('"', '""', (string) $value) . '"', array_values($row)));
}
$response = new \WP_REST_Response(implode("\n", $lines), 200);
$response->set_headers([
'Content-Type' => 'text/csv; charset=utf-8',
'Content-Disposition' => 'attachment; filename="aksrt-invite-codes.csv"',
]);
return $response;
}
/** 文本导出:与导入格式互通(`码 分隔符 奖励 标识`),导出即可回流导入。 */
public function rest_export_text(\WP_REST_Request $request): \WP_REST_Response {
global $wpdb;
$table = $this->table();
$division = trim((string) ($request['division'] ?? ' '));
if ($division === '') {
$division = ' ';
}
$rows = $wpdb->get_results("SELECT code, remark, rewards FROM {$table} ORDER BY id DESC LIMIT 10000", ARRAY_A);
$lines = [];
foreach ((array) $rows as $row) {
$line = $row['code'] . $division . $this->reward_param_string((string) ($row['rewards'] ?? ''));
if ((string) ($row['remark'] ?? '') !== '') {
$line .= $division . $row['remark'];
}
$lines[] = $line;
}
$response = new \WP_REST_Response(implode("\n", $lines), 200);
$response->set_headers([
'Content-Type' => 'text/plain; charset=utf-8',
'Content-Disposition' => 'attachment; filename="aksrt-invite-codes.txt"',
]);
return $response;
}
}
+32 -5
View File
@@ -51,7 +51,12 @@ final class ReferralAttribution {
return;
}
if ($this->cookie_ref() !== '') {
$current = $this->cookie_ref();
if ($current === $raw) {
return;
}
// 首触模式:已有归因不覆盖;末触模式:最近一次点击覆盖之前的归因。
if ($current !== '' && $this->attribution_model() !== 'last_touch') {
return;
}
@@ -75,21 +80,28 @@ final class ReferralAttribution {
$submitted_ref = sanitize_key($submitted_ref);
$cookie_ref = $this->cookie_ref();
$submitted_valid = $submitted_ref !== '' && $this->resolve_promoter($submitted_ref) > 0;
$last_touch = $this->attribution_model() === 'last_touch';
$conflict = $cookie_ref !== '' && $submitted_ref !== '' && $submitted_ref !== $cookie_ref;
if ($conflict) {
self::$registration_notice = __('检测到不同的推广码,已按首次访问的推广关系绑定。', 'aksrt');
self::$registration_notice = $last_touch
? __('检测到不同的推广码,已按最近访问的推广关系绑定。', 'aksrt')
: __('检测到不同的推广码,已按首次访问的推广关系绑定。', 'aksrt');
}
$ref = $cookie_ref !== '' ? $cookie_ref : ($submitted_valid ? $submitted_ref : '');
// 首触:cookie 优先;末触:注册时填写的有效推广码优先。
$ref = $cookie_ref !== '' && !($last_touch && $submitted_valid)
? $cookie_ref
: ($submitted_valid ? $submitted_ref : $cookie_ref);
self::$pending_registration_ref = $ref;
$source = $ref === '' ? 'none' : ($ref === $cookie_ref ? 'cookie' : 'submitted');
return [
'ref' => $ref,
'valid' => $ref !== '',
'submitted_valid' => $submitted_valid,
'conflict' => $conflict,
'source' => $cookie_ref !== '' ? 'cookie' : ($submitted_valid ? 'submitted' : 'none'),
'source' => $source,
];
}
@@ -377,6 +389,7 @@ final class ReferralAttribution {
}
public function rest_me(): \WP_REST_Response {
\AKSRT\Payment\CommissionLedger::get_instance()->lazy_settle_due();
$user_id = get_current_user_id();
$link = $this->create_link($user_id);
$stats = $this->stats($user_id);
@@ -387,6 +400,8 @@ final class ReferralAttribution {
'ref' => $link['ref'],
'url' => $link['url'],
'status' => $link['status'],
'site_name' => get_bloginfo('name'),
'poster' => (array) ($affiliate['poster'] ?? ['enabled' => false]),
'features' => [
'registration' => !empty($registration['enabled']),
'points_reward' => !empty($reward['enabled']),
@@ -612,7 +627,19 @@ final class ReferralAttribution {
private function shared_settings(): array {
return class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_referral_settings()
: ['code_length' => 12, 'window_days' => 30];
: ['code_length' => 12, 'window_days' => 30, 'attribution_model' => 'first_touch'];
}
private function attribution_model(): string {
return (string) ($this->shared_settings()['attribution_model'] ?? 'first_touch');
}
/** 推广者的推广码当前是否处于激活状态(码缺失视为未激活)。 */
public function is_promoter_code_active(int $promoter_id): bool {
if ($promoter_id <= 0) {
return false;
}
return $this->code_record($promoter_id)['status'] === self::STATUS_ACTIVE;
}
private function registration_settings(): array {
+5
View File
@@ -41,6 +41,11 @@ trait CreditsPaymentGroups {
return CreditsRuntime::get_referral_registration_settings();
}
/** @return array{enabled:bool,register_field:string,open_desc:string,required_desc:string} */
public static function get_invite_code_registration_settings(): array {
return CreditsRuntime::get_invite_code_registration_settings();
}
/**
* 积分功能是否启用(仅积分系统开关)。
*/
+32 -1
View File
@@ -91,13 +91,18 @@ class CreditsRuntime {
/**
* Shared ref-code settings.
*
* @return array{code_length:int,window_days:int}
* @return array{code_length:int,window_days:int,attribution_model:string}
*/
public static function get_referral_settings(): array {
$all = Settings::get_all();
$attribution = sanitize_key((string) ($all['referral_attribution_model'] ?? 'first_touch'));
if (!in_array($attribution, ['first_touch', 'last_touch'], true)) {
$attribution = 'first_touch';
}
return [
'code_length' => max(4, min(12, (int) ($all['referral_code_length'] ?? 12))),
'window_days' => max(1, min(365, (int) ($all['referral_window_days'] ?? 30))),
'attribution_model' => $attribution,
];
}
@@ -145,6 +150,32 @@ class CreditsRuntime {
];
}
/**
* @return array{enabled:bool,register_field:string,open_desc:string,required_desc:string}
*/
public static function get_invite_code_registration_settings(): array {
$all = Settings::get_all();
$mode = sanitize_key((string) ($all['invite_code_s'] ?? 'close'));
if (!in_array($mode, ['close', 'open', 'must'], true)) {
$mode = 'close';
}
$open_desc = trim((string) ($all['invite_code_open_desc'] ?? ''));
if ($open_desc === '') {
$open_desc = __('请输入邀请码,注册成功后奖励将自动发放', 'aksrt');
}
$required_desc = trim((string) ($all['invite_code_must_desc'] ?? ''));
if ($required_desc === '') {
$required_desc = __('<div>本站需凭邀请码注册</div><div>获取邀请码请与客服联系</div>', 'aksrt');
}
return [
'enabled' => $mode !== 'close',
'register_field' => $mode === 'must' ? 'required' : ($mode === 'open' ? 'optional' : 'hidden'),
'open_desc' => $open_desc,
'required_desc' => $required_desc,
];
}
/**
* 积分功能是否启用。
* 仅看积分系统开关;不再与 cms_features.download_credit 联合(该开关表示积分购买模式,非按次扣费)。
+7
View File
@@ -339,9 +339,16 @@ class PaymentRuntime {
],
'order_types' => $types_by_level[0],
'order_types_by_level' => $types_by_level,
'disabled_code_stops_commission' => !empty($settings['affiliate_disabled_code_stops_commission']),
'window_days' => (int) CreditsRuntime::get_referral_settings()['window_days'],
'settlement_days' => max(0, min(90, (int) ($settings['affiliate_settlement_days'] ?? 7))),
'promotion_text' => sanitize_textarea_field((string) ($settings['pay_rebate_text_details'] ?? '')),
'poster' => [
'enabled' => !isset($settings['pay_rebate_poster_s']) || !empty($settings['pay_rebate_poster_s']),
'image' => esc_url_raw((string) ($settings['pay_rebate_poster_img'] ?? '')),
'title' => sanitize_text_field((string) ($settings['pay_rebate_poster_title'] ?? '')),
'description' => sanitize_text_field((string) ($settings['pay_rebate_poster_desc'] ?? '')),
],
];
}
+4
View File
@@ -34,6 +34,9 @@ $referral = $settings ? $settings::get_referral_registration_settings() : ['enab
$ref_mode = !empty($referral['enabled']) ? (string) ($referral['register_field'] ?? 'hidden') : 'hidden';
$ref_hint = $ref_mode === 'required' ? (string) ($referral['required_desc'] ?? '') : (string) ($referral['open_desc'] ?? '');
$ref_value = !empty($_GET['ref']) ? sanitize_key(wp_unslash((string) $_GET['ref'])) : (!empty($_COOKIE['aksrt_referral']) ? sanitize_key(wp_unslash((string) $_COOKIE['aksrt_referral'])) : '');
$invite_code_settings = $settings ? $settings::get_invite_code_registration_settings() : ['enabled' => false, 'register_field' => 'hidden', 'open_desc' => '', 'required_desc' => ''];
$invite_mode = !empty($invite_code_settings['enabled']) ? (string) ($invite_code_settings['register_field'] ?? 'hidden') : 'hidden';
$invite_hint = $invite_mode === 'required' ? (string) ($invite_code_settings['required_desc'] ?? '') : (string) ($invite_code_settings['open_desc'] ?? '');
$oauth_providers = class_exists('\AKSRT\Auth\OAuth') ? \AKSRT\Auth\OAuth::get_instance()->get_auth_providers_data() : [];
$wechat_qr = false;
foreach ($oauth_providers as $provider) {
@@ -94,6 +97,7 @@ $oauth_bind_pass = $oauth_intent === 'bind'
<div class="floating-input has-action"><input type="password" name="password" id="<?php echo esc_attr($unique); ?>-register-password" autocomplete="new-password" minlength="6" required><label for="<?php echo esc_attr($unique); ?>-register-password"><?php esc_html_e('密码', 'aksrt'); ?></label><button type="button" class="password-show" data-password-show aria-label="<?php esc_attr_e('显示密码', 'aksrt'); ?>" aria-pressed="false"><i class="fa fas fa-eye"></i></button></div>
<?php if (!$omit_confirm) : ?><div class="floating-input has-action"><input type="password" name="password_confirm" id="<?php echo esc_attr($unique); ?>-register-confirm" autocomplete="new-password" minlength="6" required><label for="<?php echo esc_attr($unique); ?>-register-confirm"><?php esc_html_e('重复密码', 'aksrt'); ?></label><button type="button" class="password-show" data-password-show aria-label="<?php esc_attr_e('显示密码', 'aksrt'); ?>" aria-pressed="false"><i class="fa fas fa-eye"></i></button></div><?php endif; ?>
<?php if ($ref_mode !== 'hidden') : ?><div class="floating-input"><input name="ref" value="<?php echo esc_attr($ref_value); ?>" id="<?php echo esc_attr($unique); ?>-register-ref" <?php echo $ref_mode === 'required' ? 'required' : ''; ?>><label for="<?php echo esc_attr($unique); ?>-register-ref"><?php echo $ref_mode === 'required' ? esc_html__('推广码', 'aksrt') : esc_html__('推广码(选填)', 'aksrt'); ?></label></div><?php if ($ref_hint !== '') : ?><div class="sign-hint"><?php echo wp_kses_post($ref_hint); ?></div><?php endif; ?><?php endif; ?>
<?php if ($invite_mode !== 'hidden') : ?><div class="floating-input"><input name="invite_code" id="<?php echo esc_attr($unique); ?>-register-invite" data-invite-verify <?php echo $invite_mode === 'required' ? 'required' : ''; ?>><label for="<?php echo esc_attr($unique); ?>-register-invite"><?php echo $invite_mode === 'required' ? esc_html__('邀请码', 'aksrt') : esc_html__('邀请码(选填)', 'aksrt'); ?></label></div><small class="sign-hint" data-invite-feedback role="status" aria-live="polite" hidden></small><?php if ($invite_hint !== '') : ?><div class="sign-hint"><?php echo wp_kses_post($invite_hint); ?></div><?php endif; ?><?php endif; ?>
<?php if (!empty($agreement['enabled']) && !empty($agreement['required'])) : ?><label class="sign-agreement"><input type="checkbox" name="agree_terms" value="1" required><span><?php echo $agreement['label_html']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span></label><?php elseif (!empty($agreement['enabled'])) : ?><div class="sign-agreement sign-agreement-implicit"><?php esc_html_e('注册即表示', 'aksrt'); ?><?php echo $agreement['label_html']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div><?php endif; ?>
<?php if (function_exists('aksrt_render_captcha')) { aksrt_render_captcha('register'); } ?>
<input type="hidden" name="channel" value="<?php echo esc_attr($signup_type === 'phone' ? 'phone' : ''); ?>">
File diff suppressed because it is too large. Load diff
+11
View File
@@ -0,0 +1,11 @@
# Widget visual results
This directory is intentionally empty until validation runs against the user-provided WordPress site.
The browser run must create the screenshot paths declared by `tests/fixtures/widget-golden-v1.json` and a `manifest.json` using schema `aksrt.widget-visual-results.v1`. Validate completed results with:
```text
php tools/validate-widget-golden.php --require-results
```
The validator requires all 222 viewport/theme screenshots, every declared interaction state, zero console errors, zero unexpected network failures, zero critical/serious accessibility findings, reduced-motion verification, a maximum 2 px component-boundary delta, and a maximum 1.5% pixel difference.
@@ -0,0 +1,28 @@
# AKSRT widget static accessibility audit
Scope: `src/WidgetSystem/Renderers/`, `assets/widgets/frontend.js`, and `assets/widgets/frontend.css`.
The source audit was repeated after remediation. Browser, screen-reader, Lighthouse/axe, console, and network validation remain pending until a target WordPress URL and browser MCP are available.
## Critical (WCAG A)
No static critical issue was found.
## Serious (WCAG AA)
No unresolved static serious issue remains.
## Resolved source findings
- **WCAG 2.1.1 Keyboard:** article and feature tabs now implement Arrow Left/Right, Home/End, focus movement, roving `tabindex`, and stable tab/panel associations.
- **WCAG 2.4.7 Focus Visible:** all widget links, buttons, inputs, selects, textareas, and explicit tabindex targets receive high-contrast light, dark, and forced-colors focus rings.
- **WCAG 2.2.2 Pause, Stop, Hide:** autoplay carousels pause on keyboard focus and expose a labeled, persistent pause/resume button; reduced-motion mode continues to disable autoplay.
- **WCAG 4.1.3 Status Messages:** AJAX containers expose `aria-live` and `aria-busy`, loading skeletons use `role="status"`, and video failure uses an assertive alert.
These contracts are enforced by `tests/widget-system/a11y-contract.test.cjs`.
## Browser verification still required
- Confirm Swiper-generated pagination bullets, disabled navigation states, and pause/resume behavior with the bundled runtime.
- Verify 200% zoom, high-contrast mode, dark-mode contrast, focus order, and focus retention after AJAX replacement.
- Run Lighthouse or axe and perform keyboard-only checks at 1440, 1024, and 390 px in light and dark modes.
@@ -0,0 +1,33 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '../..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
const js = read('assets/widgets/frontend.js');
const css = read('assets/widgets/frontend.css');
const content = read('src/WidgetSystem/Renderers/ContentRenderer.php');
const knowledge = read('src/WidgetSystem/Renderers/KnowledgeProductRenderer.php');
const general = read('src/WidgetSystem/Renderers/GeneralRenderer.php');
for (const key of ['ArrowRight', 'ArrowLeft', 'Home', 'End']) assert.match(js, new RegExp(key));
assert.match(js, /setAttribute\('tabindex', tab === button \? '0' : '-1'\)/);
assert.match(js, /setAttribute\('aria-labelledby', button\.id\)/);
assert.match(content, /role="tab"[^;]+aria-controls=/);
assert.match(content, /role="tabpanel"[^;]+aria-labelledby=/);
assert.match(knowledge, /role="tab"[^;]+aria-controls=/);
assert.match(knowledge, /role="tabpanel"[^;]+aria-labelledby=/);
assert.match(css, /:focus-visible/);
assert.match(css, /forced-colors: active/);
assert.match(content, /aria-live="polite" aria-busy="true"/);
assert.match(content, /role="status"/);
assert.match(js, /setAttribute\('aria-busy', 'true'\)/);
assert.match(js, /setAttribute\('aria-busy', 'false'\)/);
assert.match(general, /role="alert" aria-live="assertive"/);
assert.match(css, /prefers-reduced-motion: reduce/);
assert.match(js, /prefers-reduced-motion: reduce/);
console.log('Widget accessibility contract tests passed: keyboard tabs, focus, live states and reduced motion.');
+20
View File
@@ -0,0 +1,20 @@
'use strict';
const assert = require('node:assert/strict');
const core = require('../../assets/widgets/admin-core.js');
assert.deepEqual(core.parseDependency('["mode","==","cards"]'), ['mode', '==', 'cards']);
assert.deepEqual(core.parseDependency('{broken'), []);
assert.equal(core.dependencyVisible(['mode', '==', 'cards'], 'cards'), true);
assert.equal(core.dependencyVisible(['mode', '==', 'cards'], 'list'), false);
assert.equal(core.dependencyVisible(['mode', '!=', 'cards'], 'list'), true);
assert.equal(core.dependencyVisible(['mode', '!=', 'cards'], 'cards'), false);
assert.equal(core.dependencyVisible([], 'anything'), true);
assert.equal(
core.replaceRepeaterIndexes('widget-aksrt_widget_slider[4][slides][__index_ab12cd34__][layers][9][title]', [2, 1]),
'widget-aksrt_widget_slider[4][slides][2][layers][1][title]'
);
assert.equal(core.replaceRepeaterIndexes('plain[name]', [1]), 'plain[name]');
console.log('Widget admin core tests passed: dependency visibility and nested repeater indexing.');
@@ -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,189 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
const exists = (file) => fs.existsSync(path.join(root, file));
test('comment area styles are fully owned (hard cut from vendor main.css)', () => {
const layout = read('assets/css/frontend/comments/layout.css');
const mobile = read('assets/css/frontend/comments/mobile-item.css');
// 1:1 移植的关键布局选择器必须存在于自有文件。
const layoutSelectors = [
'#comments .comment-box .comments-closed',
'#comments #postcomments .comt-ctrl .but',
'#comment {',
'.comment-signarea {',
'#postcomments .comment-footer {',
'#postcomments .avatar-img {',
'#postcomments .children {',
'#postcomments .comt-meta > span:not(:empty)',
'#postcomments .comment-approve-link.approve {',
'#postcomments .comment-approve-link.unapprove {',
'#postcomments .comt-meta > span .comment-trash-link {',
'.comment-content {',
'#postcomments .comment-content img {',
'#comments .virtual-input {',
'.commentlist .no-more {',
];
for (const selector of layoutSelectors) {
assert.ok(layout.includes(selector), `layout.css must contain ${selector}`);
}
// 桌面隐藏必须带 #comments 前缀(id 特异性),任何后加载的
// .flex{display:flex} 单类工具规则都无法在宽屏把假输入条顶出来。
assert.equal(layout.includes('\n.virtual-input {'), false, 'desktop hide must be id-scoped, not bare .virtual-input');
// 移动端弹层 / 虚拟输入 / 640px 覆盖。
const mobileSelectors = [
'@media (max-width: 767px)',
'@media (max-width: 640px)',
'#comments .virtual-input {',
'.drop-fixed-sm .dropdown-menu,',
'.mobile-fixed {',
'#respond.mobile-fixed {',
'.drop-fixed-sm .dropdown-menu::before {',
'#comments .comt-box {',
'#postcomments .commentlist > .comment > .list-inline .comment-footer {',
];
for (const selector of mobileSelectors) {
assert.ok(mobile.includes(selector), `mobile-item.css must contain ${selector}`);
}
// 自有文件不得包含表情/快捷回复样式。
for (const [file, source] of [['layout.css', layout], ['mobile-item.css', mobile]]) {
for (const token of ['smilie', 'dropdown-quick-often', 'quick-reply-item', 'quick-reply-action']) {
assert.equal(source.includes(token), false, `${file} must not contain ${token}`);
}
}
// 构建产物 comments.css 聚合了自有规则。
const built = read('assets/css/build/comments.css');
for (const selector of ['#postcomments .comt-meta > span:not(:empty)', '.mobile-fixed {', '#comments .virtual-input {', '#comments .comt-title {']) {
assert.ok(built.includes(selector), `build/comments.css must contain ${selector}`);
}
});
test('vendor main.css no longer carries comment-area rules (no dual path)', () => {
for (const file of ['assets/css/main.css', 'assets/css/main.min.css']) {
const source = read(file);
for (const token of [
'#postcomments',
'.commentlist',
'.comt-',
'.comment-signarea',
'.comment-filter',
'.mobile-fixed',
'.drop-fixed-sm',
'#respond',
'#comments',
]) {
assert.equal(source.includes(token), false, `${file} must not contain ${token}`);
}
// 小部件与共享规则仍留在全站表。
assert.ok(source.includes('.comment-widget-item'), `${file} must keep .comment-widget-item`);
assert.ok(source.includes('.comment-mini-lists'), `${file} must keep .comment-mini-lists`);
assert.ok(source.includes('.content-after-box'), `${file} must keep .content-after-box`);
assert.ok(source.includes('.theme-pagination.pagenav'), `${file} must keep .theme-pagination.pagenav`);
}
});
test('quick reply is removed across the owned comment stack', () => {
const files = [
'assets/admin/src/schema/sections/post-list/comment.js',
'assets/css/main.css',
'assets/css/build/comments.css',
'assets/js/comment-tools.js',
'inc/helpers/aksrt-comments.php',
'inc/helpers/comments/form-parts.php',
'src/Admin/Schema/SchemaPostList.php',
'src/Core/ScriptsPart1c.php',
'src/Settings/PostDisplay.php',
];
const tokens = [
'comment_quick_s',
'comment_quick_often',
'quick_phrases',
'parse_comment_quick_phrases',
'quick-reply-item',
'dropdown-quick-often',
'input-quick',
'commentQuickPhrases',
'快捷回复',
];
for (const file of files) {
const source = read(file);
for (const token of tokens) {
assert.equal(source.includes(token), false, `${file} must not contain ${token}`);
}
}
});
test('dead vendor comment archives stay deleted', () => {
for (const file of [
'assets/js/runtime/comment.js',
'assets/js/runtime/comment.min.js',
'assets/js/runtime/input-expand.js',
'assets/js/runtime/input-expand.min.js',
]) {
assert.equal(exists(file), false, `${file} must not exist`);
}
const loader = read('assets/js/runtime/loader.js');
assert.doesNotMatch(loader, /['"](?:comment|input-expand)['"]\s*:/, 'legacy comment modules must have no loader mapping');
assert.doesNotMatch(read('assets/js/runtime/main.js'), /stubVendorCommentModule|unbindVendorAjaxLoop/);
});
test('every comment-rendering singular loads the owned build bundle', () => {
// 旧的静态聚合文件已删除:它缺 layout.css 且 @import 无版本号,
// 曾导致页面/资源页手机端 .mobile-fixed 抽屉规则缺失、表单直接内联显示。
assert.equal(exists('assets/css/frontend/comments.css'), false, 'stale frontend/comments.css aggregator must not exist');
const part1b = read('src/Core/ScriptsPart1b.php');
// 统一入口:任何渲染评论区的 singular 都加载 build 聚合。
assert.ok(
part1b.includes('is_singular() && !empty($singular_assets[\'needs_comments\'])'),
'ScriptsPart1b must enqueue the comments bundle for every comment-rendering singular'
);
assert.ok(part1b.includes('assets/css/build/'), 'comments enqueue must point at the build bundle');
// 旧的两处按需入口必须移除(否则资源页/页面回到缺规则的聚合文件)。
assert.equal(part1b.includes("$enqueue_module('comments')"), false, 'stale aggregator enqueue must be gone');
});
test('comment markup keeps the 1:1 legacy DOM contract', () => {
const item = read('inc/helpers/comments/item.php');
// 点赞按钮(legacy: zib_get_comment_like — svg + text + count)。
assert.ok(item.includes("<text></text><count>"), 'like button must render <text> before <count>');
assert.ok(item.includes('aksrt_get_comment_icon(\'like\''), 'like button must use the sprite icon');
assert.ok(item.includes('aksrt_get_comment_icon(\'topping\')'), 'topping badge must use the sprite icon');
assert.ok(item.includes('aksrt_get_comment_icon(\'approve\''), 'approve action must use the sprite icon');
assert.ok(item.includes('aksrt_get_comment_icon(\'menu_2\')'), 'action dropdown must use the sprite icon');
assert.ok(item.includes('data-toggle="tooltip"'), 'time/reply tooltip parity');
assert.equal(item.includes('fa fa-thumbs-up'), false, 'legacy fa like icon must be gone');
const comments = read('inc/helpers/aksrt-comments.php');
assert.ok(comments.includes('post_password_required() || !comments_open()'), 'closed comments must render nothing');
assert.ok(comments.includes('user_url_enabled'), 'guest url field toggle must be wired');
assert.ok(comments.includes("name=\"url\""), 'guest url input must render');
assert.ok(comments.includes("aksrt_get_comment_icon('signup', 'icon mr10')"), 'signarea signup must use the sprite icon');
assert.ok(comments.includes('aksrt_render_vip_badge'), 'comt-title must render the vip badge (legacy: zibpay_get_vip_icon)');
assert.equal(comments.includes('aksrt_render_user_identity_chips'), false, 'identity chips must not crowd the narrow comt-title');
const formParts = read('inc/helpers/comments/form-parts.php');
assert.ok(formParts.includes("case 'link':"), 'link dropdown case must exist');
assert.equal(formParts.includes("case 'quick':"), false, 'quick case must be gone');
assert.equal(formParts.includes('fa-bolt'), false, 'quick button icon must be gone');
const icons = read('inc/helpers/comments/icons.php');
assert.ok(icons.includes('function aksrt_get_comment_icon'), 'sprite icon helper must exist');
const bootstrap = read('inc/bootstrap.php');
assert.ok(bootstrap.includes("comments/icons.php"), 'bootstrap must load the icon helper');
// 设置契约:link / user_url 开关进入显示设置。
const settings = read('src/Settings/PostDisplay.php');
assert.ok(settings.includes("'link' => !empty($settings['comment_link'])"), 'toolbar link toggle');
assert.ok(settings.includes("'user_url' => !empty($settings['comment_user_url_s'])"), 'user url toggle');
});
@@ -0,0 +1,173 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
test('download settings section exists on both schema sides with matching defaults', () => {
const php = read('src/Admin/Schema/SchemaPermissions.php');
const vue = read('assets/admin/src/schema/sections/permissions/index.js');
// PHP side is the authoritative contract.
assert.match(php, /'download' => \[/);
assert.match(php, /'key'\s+=> 'download'/);
assert.match(php, /'option_key' => 'aksrt_download_config'/);
// Field parity: same four keys with the same defaults on both ends.
const fields = [
['enabled', 'true', 'enabled: true'],
['url_alias', "'download'", "url_alias: 'download'"],
['default_max_uses', '1', 'default_max_uses: 1'],
['ttl_minutes', '10', 'ttl_minutes: 10'],
];
for (const [key, phpDefault, vueDefault] of fields) {
const phpField = new RegExp(`'key' => '${key}'[^\\]]*'default' => ${phpDefault}`);
assert.match(php, phpField, `PHP default mismatch for ${key}`);
assert.match(vue, new RegExp(`${vueDefault}`), `Vue default mismatch for ${key}`);
}
// The link rules depend on the master switch on both ends.
assert.match(php, /'title' => '下载总开关'/);
assert.match(php, /'dependsOn' => \['key' => 'enabled', 'value' => true\]/);
assert.match(vue, /dependsOn: \{ key: 'enabled', value: true \}/);
// Vue mirrors labels so admins see the same wording as validation errors.
assert.match(vue, /download: \{/);
assert.match(vue, /下载链接 URL 别名/);
assert.match(vue, /下载链接默认使用次数/);
assert.match(vue, /下载链接默认有效期限(分钟)/);
});
test('the download settings page is registered under the permissions menu', () => {
const menu = read('assets/admin/src/constants/sections/permissions.js');
assert.match(menu, /schemaPage\(\{\s*key: 'permissions-download'/);
assert.match(menu, /route: '\/permissions\/download'/);
assert.match(menu, /canonicalDomain: 'permissions',\s*section: 'download',/);
const settings = read('src/Settings/Settings.php');
assert.match(settings, /'aksrt_download_config'/);
});
test('invalid url aliases are rejected server-side with admin-facing messages', () => {
const provider = read('src/Admin/SchemaProvider.php');
assert.match(provider, /\$domain === 'permissions' && \$section === 'download'/);
assert.match(provider, /URL 别名不能为空/);
assert.match(provider, /\/\^\[A-Za-z0-9_-\]\+\$\//);
assert.match(provider, /禁止中文、空格和 \/ \\\\ \? # & 等特殊符号/);
assert.match(provider, /reserved_download_aliases/);
assert.match(provider, /'wp-json', 'wp-admin', 'wp-content', 'wp-includes'/);
});
test('Download.php derives endpoint, ttl and max uses from configuration', () => {
const dl = read('src/Content/Download.php');
// Dead hard-coded values are gone.
assert.doesNotMatch(dl, /const ENDPOINT/);
assert.doesNotMatch(dl, /const TOKEN_TTL/);
assert.doesNotMatch(dl, /aksrt-dl/);
// Rewrite regex and URL building share the configured alias.
assert.match(dl, /preg_quote\(\$this->config\(\)\['endpoint'\], '\/'\)/);
assert.match(dl, /home_url\('\/' \. \$config\['endpoint'\] \. '\/' \. \$token\)/);
assert.match(dl, /add_action\('init', \[\$this, 'maybe_flush_rewrite'\], 99\)/);
assert.match(dl, /flush_rewrite_rules\(false\)/);
assert.match(dl, /aksrt_dl_rewrite_version/);
// TTL and per-token max uses come from the settings section.
assert.match(dl, /'expires_in'\s+=> \$config\['ttl'\]/);
assert.match(dl, /'max_uses'\s+=> \(int\) \$config\['max_uses'\]/);
});
test('usage is counted only after full delivery, never for probes', () => {
const dl = read('src/Content/Download.php');
// Probe detection covers HEAD requests and speculative prefetches, which
// validate availability but consume nothing.
assert.match(dl, /private function is_probe_request\(\): bool/);
assert.match(dl, /REQUEST_METHOD.*=== 'HEAD'/);
assert.match(dl, /HTTP_SEC_PURPOSE/);
assert.match(dl, /'prefetch'/);
assert.match(dl, /!\$is_probe\) \{\s*\$this->consume_token/);
// Local streaming splits headers from body and aborts stop consumption.
assert.match(dl, /private function send_download_headers\(string \$path\): void/);
assert.match(dl, /private function stream_body\(string \$path\): bool/);
assert.match(dl, /if \(connection_aborted\(\)\) \{/);
assert.match(dl, /return !connection_aborted\(\);/);
// Atomic cap so concurrent finishes can not overshoot max_uses.
assert.match(dl, /SET used_count = used_count \+ 1 WHERE token = %s AND used_count < max_uses/);
// Expired / exhausted links stay a dead end instead of re-issuing silently.
assert.match(dl, /status_header\(410\)/);
assert.match(dl, /请回到资源页重新获取/);
assert.doesNotMatch(dl, /fail_410\(\);\s*issue_token/);
});
test('expired links render a themed flat 410 page that follows the theme color', () => {
const dl = read('src/Content/Download.php');
// Full theme chrome instead of a bare wp_die page.
assert.match(dl, /private function render_expired_page\(\): void/);
assert.match(dl, /get_header\(\)/);
assert.match(dl, /get_footer\(\)/);
// wp_die remains only as the safety net when output already started.
assert.match(dl, /headers_sent\(\)/);
// Flat hero keyed to the runtime theme variables (light + html.dark).
assert.match(dl, /var\(--color-primary-soft/);
assert.match(dl, /var\(--color-primary/);
assert.match(dl, /var\(--key-color/);
assert.match(dl, /var\(--muted-4-color/);
// Theme button system and FA5 icon form only (never "fa fa-").
assert.match(dl, /class="btn-primary"/);
assert.match(dl, /class="btn-outline"/);
assert.match(dl, /fa fas fa-hourglass-end/);
assert.doesNotMatch(dl, /\bfa fa-/);
// The 410 page is a utility rewrite; the header component must never match
// a page type for it (custom rewrites retain the is_home flag).
const slider = read('inc/helpers/homepage/header-components.php');
assert.match(slider, /class_exists\('\\AKSRT\\Content\\Download'\)/);
assert.match(slider, /get_query_var\(\\AKSRT\\Content\\Download::QUERY_VAR\)/);
});
test('token generation retries on primary-key collisions', () => {
const dl = read('src/Content/Download.php');
assert.match(dl, /PRIMARY KEY\(token\) already guarantees uniqueness/);
assert.match(dl, /for \(\$attempt = 0; \$attempt < 3; \$attempt\+\+\)/);
});
test('master switch off puts the token system to sleep and renders direct links', () => {
const dl = read('src/Content/Download.php');
const box = read('templates/parts/resource-download-box.php');
const pay = read('inc/helpers/aksrt-pay.php');
// The switch is a public runtime check; missing key means enabled.
assert.match(dl, /public function is_enabled\(\): bool/);
assert.match(dl, /!isset\(\$cfg\['enabled'\]\) \|\| !empty\(\$cfg\['enabled'\]\)/);
// Rewrite, REST routes and token serving all bail out when disabled.
assert.match(dl, /if \(!\$this->is_enabled\(\)\) \{\s*return;\s*\}/);
assert.match(dl, /Master switch off: the token system is retired/);
// The unused policy endpoint stays removed.
assert.doesNotMatch(dl, /download\/policy/);
assert.doesNotMatch(dl, /rest_policy/);
// Direct mode renders anchors to the raw source URL instead of token buttons.
assert.match(box, /\$direct_download = class_exists\('\\AKSRT\\Content\\Download'\)/);
assert.match(box, /elseif \(\$direct_download\) \{/);
assert.match(box, /href="<\?php echo esc_url\(\$btn_href\); \?>/);
assert.match(box, /data-clipboard-text="<\?php echo esc_attr\(\$extract_code\); \?>/);
assert.match(pay, /if \(\$direct_download\) \{\s*\$buttons \.= '<a class="mr10 but /);
// Dead standalone-page helper stays removed.
assert.doesNotMatch(pay, /aksrt_is_download_alone_page_enabled/);
});
@@ -0,0 +1,132 @@
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('invite code table has a unique code key and a usage log table', () => {
const schema = read('src', 'Core', 'DBMigratorPart2.php')
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_invite_code \([\s\S]*?UNIQUE KEY uniq_invite_code \(code\)/)
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_invite_code \([\s\S]*?\) ENGINE=InnoDB/)
// 使用记录表:运营需要知道"哪个用户用了哪个码",且 (code_id, user_id) 唯一防重复记账。
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_invite_code_usage \([\s\S]*?UNIQUE KEY uniq_invite_usage \(code_id, user_id\)/)
const registry = read('src', 'Core', 'DBMigratorPart3.php')
assert.match(registry, /'invite_code'\s+=>\s+'aksrt_invite_code'/)
assert.match(registry, /'invite_code_usage'\s+=>\s+'aksrt_invite_code_usage'/)
})
test('consumption is a single conditional UPDATE, not select-then-update', () => {
const service = read('src', 'Payment', 'InviteCode.php')
// TOCTOU 防线:消费必须是一条带条件的 UPDATE,且以 affected_rows === 1 判定。
assert.match(service, /SET used_count = used_count \+ 1 WHERE code = %s AND status = 'active' AND \(max_uses = 0 OR used_count < max_uses\)/)
assert.match(service, /\$updated !== 1/)
// 消费成功后写入使用记录(使用者视图)。
assert.match(service, /invite_code_usage/)
assert.match(service, /recent_users_map/)
// 码生成必须使用 CSPRNG。
assert.match(service, /random_int\(/)
assert.doesNotMatch(service, /str_shuffle|mt_rand|rand\(/)
// 奖励幂等:确定性操作键。
assert.match(service, /OperationKey::deterministic\('invite-reward'/)
})
test('registration services gate on the invite-code field mode', () => {
for (const file of ['src/Auth/ServicePart1a.php', 'src/Auth/ServicePart1b.php']) {
const service = read(...file.split('/'))
assert.match(service, /InviteCode::get_instance\(\)/)
assert.match(service, /请填写邀请码/)
assert.match(service, /邀请码无效或已用完,请检查后重试/)
// 校验必须发生在 wp_create_user 之前。
assert.ok(
service.indexOf('is_valid($submitted_invite)') < service.indexOf('wp_create_user('),
`${file}: invite-code validation must run before user creation`
)
}
})
test('rewards defer to email verification when signup verification is on', () => {
const service = read('src', 'Payment', 'InviteCode.php')
assert.match(service, /signup_verification_enabled\(\)/)
assert.match(service, /aksrt_email_verified/)
assert.match(service, /META_PENDING/)
})
test('invite-code surfaces: form field, admin page, menus and both schemas', () => {
const template = read('templates', 'parts', 'auth-content.php')
assert.match(template, /name="invite_code"/)
assert.match(template, /get_invite_code_registration_settings/)
const page = read('src', 'Admin', 'Native', 'UsersMenusInviteCode.php')
assert.match(page, /render_invite_codes_page/)
assert.match(page, /\$\{endpoint\}\/generate/)
assert.match(page, /_wpnonce/)
const menus = read('src', 'Admin', 'Native', 'UsersMenus.php')
assert.match(menus, /use UsersMenusInviteCode/)
assert.match(menus, /aksrt_invite_codes/)
const bootstrap = read('functions.php')
assert.match(bootstrap, /InviteCode::get_instance\(\)->init\(\)/)
const phpSchema = read('src', 'Admin', 'Schema', 'SchemaUsers.php')
const jsSchema = read('assets', 'admin', 'src', 'schema', 'sections', 'users', 'auth.js')
for (const schema of [phpSchema, jsSchema]) {
assert.match(schema, /invite_code_s/)
assert.match(schema, /invite_code_open_desc/)
assert.match(schema, /invite_code_must_desc/)
}
})
test('import accepts Zibll-compatible lines with per-code rewards', () => {
const service = read('src', 'Payment', 'InviteCode.php')
assert.match(service, /public function import_codes\(array \$args\)/)
// Zibll 行格式:`邀请码<分隔符>奖励<分隔符>标识`,奖励参数串键位兼容。
assert.match(service, /level_integral/)
assert.match(service, /vip_time/)
assert.match(service, /in_array\(\$reward, \['无', 'none', '0'\]/)
// 单次导入上限与重复码不计入成功。
assert.match(service, /array_slice\(preg_split\('\/\\r\\n\|\\r\|\\n\/', \$raw\) \?: \[\], 0, 1000\)/)
assert.match(service, /\$exists !== null/)
assert.match(service, /\$failed_codes\[\] = \$code/)
// REST 写入口 + 后台导入面板。
assert.match(service, /invite-codes\/import/)
const page = read('src', 'Admin', 'Native', 'UsersMenusInviteCode.php')
assert.match(page, /aksrt-inv-import-data/)
assert.match(page, /\$\{endpoint\}\/import/)
assert.match(page, /aksrt-inv-import-division/)
})
test('per-code remark persists through generate/import and both exports round-trip', () => {
const schema = read('src', 'Core', 'DBMigratorPart2.php')
assert.match(schema, /remark\s+VARCHAR\(191\) NOT NULL DEFAULT ''/)
const service = read('src', 'Payment', 'InviteCode.php')
// 生成与导入都落 remark;导入第三段"标识"→备注。
assert.match(service, /\$remark = sanitize_text_field\(\(string\) \(\$args\['remark'\] \?\? ''\)\)/)
assert.match(service, /\$remark = sanitize_text_field\(trim\(\(string\) \(\$parts\[2\] \?\? ''\)\)\)/)
assert.match(service, /'remark'\s+=>\s+\$remark/)
// 导出奖励参数串与 parse_reward_string 互逆(balance 分→元,零奖励"无")。
assert.match(service, /private function reward_param_string\(string \$rewards_json\): string/)
assert.match(service, /'balance=' \. /)
assert.match(service, /\$parts === \[\] \? '无'/)
// CSV 含 remark+奖励列;文本导出与导入格式互通。
assert.match(service, /code,batch_id,remark,rewards,max_uses/)
assert.match(service, /invite-codes\/export-text/)
assert.match(service, /\$row\['code'\] \. \$division \. \$this->reward_param_string/)
const page = read('src', 'Admin', 'Native', 'UsersMenusInviteCode.php')
assert.match(page, /aksrt-inv-remark/)
assert.match(page, /remark: root\.querySelector\('#aksrt-inv-remark'\)/)
assert.match(page, /invite-codes\/export-text/)
assert.match(page, /item\.remark \|\| '—'/)
})
test('settings reader exposes normalized invite-code registration mode', () => {
const runtime = read('src', 'Settings', 'CreditsRuntime.php')
assert.match(runtime, /get_invite_code_registration_settings\(\)/)
assert.match(runtime, /\['close', 'open', 'must'\]/)
const groups = read('src', 'Settings', 'CreditsPaymentGroups.php')
assert.match(groups, /CreditsRuntime::get_invite_code_registration_settings\(\)/)
})
@@ -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\)/)
})
+351
View File
@@ -0,0 +1,351 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
use AKSRT\WidgetSystem\FieldSchema;
use AKSRT\WidgetSystem\FormRenderer;
use AKSRT\WidgetSystem\PostQuery;
use AKSRT\WidgetSystem\RestController;
use AKSRT\WidgetSystem\SchemaWidget;
use AKSRT\WidgetSystem\WidgetDefinition;
use AKSRT\WidgetSystem\WidgetRegistry;
use AKSRT\WidgetSystem\WidgetTransfer;
use AKSRT\WidgetSystem\WidgetVisibility;
use AKSRT\WidgetSystem\WidgetSchemas;
use AKSRT\WidgetSystem\Renderers\ContentRenderer;
use AKSRT\WidgetSystem\Renderers\GeneralRenderer;
use AKSRT\WidgetSystem\Renderers\SliderRenderer;
final class WidgetSystemTest {
private int $assertions = 0;
private array $failures = [];
public function run(): void {
$this->schema_contract();
$this->query_contract();
$this->modern_widget_contract();
$this->visibility_contract();
$this->transfer_round_trip();
$this->rest_contract();
$this->payment_states();
if ($this->failures) {
fwrite(STDERR, "Widget system tests failed:\n- " . implode("\n- ", $this->failures) . "\n");
exit(1);
}
echo "Widget system tests passed: {$this->assertions} assertions across schema, query, visibility, transfer, REST and payment states.\n";
}
private function schema_contract(): void {
$fields = [
['id' => 'mode', 'type' => 'select', 'default' => 'cards', 'options' => ['cards' => 'Cards', 'list' => 'List']],
['id' => 'count', 'type' => 'slider', 'default' => 3, 'min' => 1, 'max' => 8],
['id' => 'ids', 'type' => 'text', 'sanitize' => 'ids'],
['id' => 'link', 'type' => 'link'],
['id' => 'layout', 'type' => 'fieldset', 'fields' => [
['id' => 'enabled', 'type' => 'switcher', 'default' => false],
['id' => 'gap', 'type' => 'number', 'default' => 8, 'min' => 0, 'max' => 40],
]],
['id' => 'rows', 'type' => 'group', 'max_items' => 2, 'fields' => [
['id' => 'title', 'type' => 'text'],
['id' => 'tone', 'type' => 'radio', 'default' => 'blue', 'options' => ['blue' => 'Blue', 'red' => 'Red']],
]],
];
$defaults = FieldSchema::defaults($fields);
$this->same('cards', $defaults['mode'], 'schema defaults preserve enums');
$this->same(['enabled' => false, 'gap' => 8], $defaults['layout'], 'nested defaults are recursive');
$normalized = FieldSchema::normalize($fields, [
'mode' => 'invalid', 'count' => 99, 'ids' => '2, 2,7 bad -4',
'link' => ['url' => 'javascript:alert(1)', 'text' => '<b>课程</b>', 'target' => 'popup'],
'layout' => ['enabled' => 'yes', 'gap' => -9, 'unknown' => 'drop'],
'rows' => [
['title' => '<script>x</script>第一项', 'tone' => 'red'],
['title' => '第二项', 'tone' => 'invalid'],
['title' => '第三项'],
],
]);
$this->same('cards', $normalized['mode'], 'invalid enum falls back');
$this->same(8, $normalized['count'], 'numeric value clamps to maximum');
$this->same('2,7,4', $normalized['ids'], 'ID list is unique and normalized');
$this->same(['url' => '', 'text' => '课程', 'target' => ''], $normalized['link'], 'link parts sanitize independently');
$this->same(['enabled' => true, 'gap' => 0], $normalized['layout'], 'nested values clamp and drop unknown keys');
$this->same(2, count($normalized['rows']), 'group max_items is enforced');
$this->same('blue', $normalized['rows'][1]['tone'], 'nested enum fallback is enforced');
$definition = new WidgetDefinition([
'slug' => 'dependency_test', 'name' => 'Dependency Test',
'common' => ['title' => false, 'affix' => false, 'visibility' => false, 'animation' => false, 'layout' => false],
'fields' => [
['id' => 'controller', 'type' => 'select', 'default' => 'off', 'options' => ['off' => 'Off', 'on' => 'On']],
['id' => 'dependent', 'type' => 'text', 'dependency' => ['controller', '==', 'on']],
],
]);
$widget = new SchemaWidget($definition);
ob_start();
$widget->render_full_form(['controller' => 'on', 'dependent' => 'visible']);
$html = (string) ob_get_clean();
$this->contains('data-dependency=', $html, 'form exports dependency rule');
$this->contains('&quot;controller&quot;', $html, 'dependency JSON is safely escaped');
}
private function query_contract(): void {
$args = PostQuery::arguments([
'count' => 99, 'category_ids' => '2,5', 'category_id' => 9, 'tag_ids' => '8,13',
'limit_day' => 14, 'orderby' => 'like', 'order' => 'asc', 'pay_type' => 'gallery',
], 0);
$this->same(50, $args['posts_per_page'], 'query count clamps');
$this->same(1, $args['paged'], 'query page clamps');
$this->same([9], $args['category__in'], 'segment category overrides category list');
$this->same([8, 13], $args['tag__in'], 'query tag filter is normalized');
$this->same('ASC', $args['order'], 'query order allowlist is applied');
$this->same('meta_value_num', $args['orderby'], 'metric ordering uses numeric meta');
$this->contains('gallery', json_encode($args['meta_query']), 'DomainModel pay type reaches meta query');
$reference = PostQuery::arguments([
'count' => 6, 'cat' => '1, -2,3 bad',
'orderby' => 'views', 'order' => 'DESC', 'limit_day' => 7, 'paginate' => '',
], 3);
$this->same([1, 3], $reference['category__in'], 'reference cat includes positive IDs');
$this->same([2], $reference['category__not_in'], 'reference cat excludes negative IDs');
$this->false(isset($reference['tax_query']), 'topic filters are removed with the topics feature');
$this->same('aksrt_post_views', $reference['meta_key'], 'reference view ordering uses numeric view meta');
$this->false(isset($reference['meta_query']), 'modern reference queries do not apply commerce filters by default');
$this->same(1, $reference['paged'], 'no-pagination queries stay on first page');
$paywalled = PostQuery::arguments(['cat' => '1', 'zibpay_type' => ['1', '6'], 'paginate' => ''], 1, ['cat' => '1']);
$this->contains('reading', json_encode($paywalled['meta_query'] ?? []), 'tab commerce filters map to pay meta');
$this->contains('video', json_encode($paywalled['meta_query'] ?? []), 'tab commerce filters cover every selected type');
$invalid = PostQuery::arguments(['cat' => '', 'orderby' => 'drop table'], 1, ['cat' => '']);
$this->false(isset($invalid['meta_query']), 'modern reference fields never add commerce filters');
$this->same('date', $invalid['orderby'], 'unknown orderby falls back');
$tabs = ['tabs' => [['title' => 'A', 'category_id' => 2], ['title' => 'B', 'category_id' => 3]]];
$this->same(3, PostQuery::segment($tabs, '1')['category_id'], 'valid tab segment resolves');
$this->same(2, PostQuery::segment($tabs, '99')['category_id'], 'invalid tab segment falls back to first');
}
private function modern_widget_contract(): void {
$tab_fields = WidgetSchemas::for('tab_posts');
$tab_defaults = FieldSchema::defaults($tab_fields);
$this->same('mini', $tab_defaults['style'], 'modern tabs default to mini');
$this->same(6, $tab_defaults['count'], 'modern tabs default to six posts');
$this->same(['热门推荐', '最近更新', '猜你喜欢'], array_column($tab_defaults['tabs'], 'title'), 'modern tab defaults match reference ordering');
$tab = FieldSchema::normalize($tab_fields, ['tabs' => [[
'title' => '<b>栏目</b><script>x</script>', 'cat' => '1,-2 3', 'zibpay_type' => ['1', '6'],
'order' => 'asc',
]]]);
$this->same('1,-2,3', $tab['tabs'][0]['cat'], 'tab cat keeps signed reference IDs');
$this->false(isset($tab['tabs'][0]['topics']), 'topic filters are gone from tab configuration');
$this->same(['1', '6'], $tab['tabs'][0]['zibpay_type'], 'tab commerce filters are preserved');
$this->contains('<b>栏目</b>', $tab['tabs'][0]['title'], 'tab title keeps controlled HTML');
$this->false(str_contains($tab['tabs'][0]['title'], '<script>'), 'tab title strips unsafe HTML');
$slider_defaults = FieldSchema::defaults(WidgetSchemas::for('slider'));
$this->true($slider_defaults['option']['button'], 'modern slider defaults to navigation buttons');
$this->true($slider_defaults['option']['pagination'], 'modern slider defaults to pagination');
$this->same(240, $slider_defaults['option']['m_height'], 'modern slider keeps reference mobile height default');
$this->same(2, count($slider_defaults['slides'][0]['image_layer']), 'modern slider ships the two reference layer assets');
$single = SliderRenderer::render('slider', ['slides' => [['background' => 'https://example.test/a.jpg', 'text' => ['title' => '<b>A</b>']]], 'option' => ['button' => true, 'pagination' => true]]);
$this->contains('new-swiper slide-widget mb20', $single, 'standalone slider emits reference wrapper');
$this->contains('swiper-container-initialized', $single, 'single slider is marked initialized');
$this->false(str_contains($single, 'swiper-button-prev'), 'single slider suppresses buttons');
$this->false(str_contains($single, 'swiper-pagination'), 'single slider suppresses pagination');
$multi = SliderRenderer::render('slider', ['slides' => [['background' => 'https://example.test/a.jpg'], ['background' => 'https://example.test/b.jpg']], 'option' => ['button' => true, 'pagination' => true]]);
$this->contains('swiper-button-prev', $multi, 'multi slider emits buttons');
$this->contains('swiper-pagination kaoyou', $multi, 'multi slider emits reference pagination');
$combo_defaults = FieldSchema::defaults(WidgetSchemas::for('slider_media_combo'));
$this->same(4, count($combo_defaults['covers']), 'combo defaults to four graphic covers');
$this->false(isset($combo_defaults['side_cards']), 'combo no longer exposes legacy side cards');
$combo = SliderRenderer::render('slider_media_combo', FieldSchema::normalize(WidgetSchemas::for('slider_media_combo'), [
'slides' => [['background' => 'https://example.test/slide.jpg', 'text' => ['title' => '幻灯片一']]],
'covers' => [
['image' => 'https://example.test/a.jpg', 'title' => '卡片一'],
['image' => 'https://example.test/b.jpg'],
],
]));
$this->contains('widget-slider-graphic m-vertical', $combo, 'combo emits reference wrapper');
$this->contains('new-swiper slide-widget scale-height', $combo, 'combo slider keeps reference classes');
$this->contains('<span><img class="radius8 lazyload swiper-lazy"', $combo, 'combo slide keeps span wrapper and lazy contract');
$this->contains('cover-col mb10', $combo, 'combo emits reference cover column');
$this->contains('widget-graphic-cover mb10', $combo, 'combo cover keeps reference wrapper');
$this->contains('col-sm-6 col-xs-6', $combo, 'combo emits reference grid columns');
$this->contains('graphic-text this-font', $combo, 'combo emits reference cover text');
$this->contains('--cover-ratio:72%', $combo, 'combo derives cover ratio from layout options');
$this->same('', SliderRenderer::render('slider_media_combo', []), 'combo renders nothing without content');
$this->same(
preg_match_all('/<div\b/', $combo),
substr_count($combo, '</div>'),
'combo markup is div-balanced (cover-col must be a sibling of the slider)'
);
$this->same(preg_match_all('/<div\b/', $single), substr_count($single, '</div>'), 'slider markup is div-balanced');
$this->same(preg_match_all('/<div\b/', $multi), substr_count($multi, '</div>'), 'multi slider markup is div-balanced');
$button_defaults = FieldSchema::defaults(WidgetSchemas::for('buttons'));
$this->same('large', $button_defaults['pc_padding'], 'button group keeps reference PC size');
$this->same('medium', $button_defaults['m_padding'], 'button group keeps reference mobile size');
$this->same(12, $button_defaults['pc_gap'], 'button group keeps reference PC spacing');
$this->same(6, $button_defaults['m_gap'], 'button group keeps reference mobile spacing');
$buttons = GeneralRenderer::render('buttons', FieldSchema::normalize(WidgetSchemas::for('buttons'), ['items' => [[
'text' => '<b>开始</b>', 'icon_left' => 'fa-play', 'icon_right' => 'fa-angle-right',
'link' => ['url' => 'https://example.test/start', 'target' => '_blank'], 'color' => 'jb-blue',
]]]));
$this->contains('widget-buttons mb20 pc-size-large m-size-medium', $buttons, 'button group emits reference wrapper');
$this->contains('widget-buttons-wrap flex hh align-center', $buttons, 'button group emits reference flex contract');
$this->contains('开始', $buttons, 'button text is rendered');
$notice_defaults = FieldSchema::defaults(WidgetSchemas::for('notice'));
$this->same(3, $notice_defaults['interval'], 'notice keeps reference interval');
$this->same('c-blue', $notice_defaults['color'], 'notice keeps reference color');
$this->same(2, count($notice_defaults['img_ids']), 'notice ships reference custom messages');
$notice = GeneralRenderer::render('notice', $notice_defaults);
$this->contains('swiper-bulletin mb20 c-blue main-radius', $notice, 'notice emits reference surface classes');
$this->contains('data-direction="vertical" data-loop="true" data-autoplay="1"', $notice, 'notice emits reference swiper options');
$posts_defaults = FieldSchema::defaults(WidgetSchemas::for('posts'));
$this->same('ajax', $posts_defaults['load_mode'], 'posts default to lazy ajax loading');
$this->same(12, $posts_defaults['count'], 'posts default count matches reference');
$this->same('', $posts_defaults['paginate'], 'posts default to no pager');
$this->same('list', $posts_defaults['style'], 'posts default to list style');
$oneline_defaults = FieldSchema::defaults(WidgetSchemas::for('oneline_posts'));
$this->same('card', $oneline_defaults['style'], 'oneline posts default to card style');
$this->false(isset($oneline_defaults['paginate']), 'oneline posts never paginate');
$hot_defaults = FieldSchema::defaults(WidgetSchemas::for('hot_posts'));
$this->same('views', $hot_defaults['orderby'], 'hot posts rank by views by default');
$this->same(6, $hot_defaults['count'], 'hot posts default count matches reference');
$term_cards_defaults = FieldSchema::defaults(WidgetSchemas::for('term_cards'));
$this->same('style-1', $term_cards_defaults['type'], 'term cards default to the simple style');
$this->same('2', $term_cards_defaults['pc_row'], 'term cards default to two per row');
$this->same(60, $term_cards_defaults['height_scale'], 'term cards keep reference cover ratio');
$term_aggregation_defaults = FieldSchema::defaults(WidgetSchemas::for('term_aggregation'));
$this->same('modified', $term_aggregation_defaults['orderby'], 'category aggregation orders by modified');
$this->same(4, $term_aggregation_defaults['count'], 'category aggregation keeps reference post count');
$media_defaults = FieldSchema::defaults(WidgetSchemas::for('media_cards'));
$this->same('4', $media_defaults['pc_row'], 'graphic covers default to four per row');
$this->same(30, $media_defaults['height_scale'], 'graphic covers keep reference ratio');
$this->same([], $media_defaults['covers'], 'graphic covers ship empty content');
$partners_defaults = FieldSchema::defaults(WidgetSchemas::for('partners'));
$this->same('3', $partners_defaults['rows'], 'partners default to three rows');
$this->same('card', $partners_defaults['style_type'], 'partners default to card style');
$this->same('link', $partners_defaults['link_type'], 'partners read from link manager by default');
$combo_defaults_info = FieldSchema::defaults(WidgetSchemas::for('combo_info'));
$this->same('立即体验', $combo_defaults_info['cta_text'], 'combo info keeps reference CTA copy');
$this->same('auto', $combo_defaults_info['cards'][0]['band_color'] ?? 'auto', 'combo info defaults to auto band color');
$comments_defaults = FieldSchema::defaults(WidgetSchemas::for('recent_comments'));
$this->same(8, $comments_defaults['limit'], 'recent comments default count matches reference');
$this->same('', $comments_defaults['style'], 'recent comments default to compact style');
$toc_defaults = FieldSchema::defaults(WidgetSchemas::for('posts_toc'));
$this->same('文章目录', $toc_defaults['title'], 'posts toc keeps reference default title');
$this->false(isset($toc_defaults['min_headings']), 'posts toc drops legacy heading options');
}
private function visibility_contract(): void {
$this->true(WidgetVisibility::should_render([], 0, false), 'unrestricted widget renders');
$this->true(WidgetVisibility::should_render(['show_id_type' => 'show', 'show_ids' => '3,8'], 8, true), 'show list matches singular ID');
$this->false(WidgetVisibility::should_render(['show_id_type' => 'show', 'show_ids' => '3,8'], 4, true), 'show list rejects other IDs');
$this->false(WidgetVisibility::should_render(['show_id_type' => 'show', 'show_ids' => '3,8'], 0, false), 'show list rejects archives');
$this->false(WidgetVisibility::should_render(['show_id_type' => 'hide', 'show_ids' => '3,8'], 3, true), 'hide list suppresses matching ID');
$this->true(WidgetVisibility::should_render(['show_id_type' => 'hide', 'show_ids' => '3,8'], 0, false), 'hide list keeps archives');
$this->same('aksrt-widget--only-pc', WidgetVisibility::device_class(['show_type' => 'only_pc']), 'PC device class is stable');
$this->same('aksrt-widget--only-mobile', WidgetVisibility::device_class(['show_type' => 'only_sm']), 'mobile device class is stable');
$this->same('', WidgetVisibility::device_class(['show_type' => 'invalid']), 'invalid device rule adds no class');
}
private function transfer_round_trip(): void {
aksrt_test_reset_state();
$GLOBALS['wp_registered_sidebars'] = ['home_sidebar' => [], 'single_sidebar' => []];
update_option('widget_aksrt_widget_posts', [2 => ['title' => '<b>课程列表</b>', 'count' => 999, 'style' => 'mini'], '_multiwidget' => 1]);
$GLOBALS['aksrt_test_state']['sidebars'] = ['home_sidebar' => ['text-1', 'aksrt_widget_posts-2']];
$payload = WidgetTransfer::export('home_sidebar');
$this->same(WidgetTransfer::FORMAT, $payload['format'], 'export format is versioned');
$this->same(1, count($payload['sidebars']['home_sidebar']), 'export ignores non-schema widgets');
$this->same(20, $payload['sidebars']['home_sidebar'][0]['settings']['count'], 'export normalizes saved settings');
update_option('widget_aksrt_widget_posts', []);
$GLOBALS['aksrt_test_state']['sidebars']['single_sidebar'] = ['text-9'];
$result = WidgetTransfer::import($payload, 'append', 'single_sidebar');
$this->same(['sidebars' => 1, 'widgets' => 1], $result, 'append import reports changes');
$this->same(['text-9', 'aksrt_widget_posts-1'], $GLOBALS['aksrt_test_state']['sidebars']['single_sidebar'], 'append import preserves existing widgets');
$round_trip = WidgetTransfer::export('single_sidebar');
$this->same($payload['sidebars']['home_sidebar'][0], $round_trip['sidebars']['single_sidebar'][0], 'export/import round trip preserves normalized module');
$replacement = WidgetTransfer::template_payload('links_navigation');
$result = WidgetTransfer::import($replacement, 'replace', 'single_sidebar');
$this->same(3, $result['widgets'], 'targeted template import combines template areas');
$this->false(in_array('text-9', $GLOBALS['aksrt_test_state']['sidebars']['single_sidebar'], true), 'replace import removes previous assignment');
try {
WidgetTransfer::import(['format' => 'legacy.widgets', 'sidebars' => []]);
$this->failures[] = 'transfer rejects legacy formats: no exception thrown';
} catch (InvalidArgumentException $error) {
$this->contains(WidgetTransfer::FORMAT, $error->getMessage(), 'legacy import rejection names accepted format');
}
}
private function rest_contract(): void {
aksrt_test_reset_state();
$controller = new RestController();
$controller->register_routes();
$this->same(2, count($GLOBALS['aksrt_test_state']['routes']), 'REST registers items and quote routes');
$route = $GLOBALS['aksrt_test_state']['routes']['aksrt/v1/widgets/(?P<widget>[a-z][a-z0-9_]*)/items'];
$this->same('__return_true', $route['permission_callback'], 'public items route is read-only accessible');
$this->true($controller->validate_widget('posts'), 'known widget validates');
$this->false($controller->validate_widget('shop_products'), 'unknown/shop widget is rejected');
$error = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_posts-1', 'page' => 1, 'query' => 'arbitrary']));
$this->error('aksrt_widget_unknown_parameter', $error, 'REST rejects arbitrary query parameters');
$error = $controller->get_items(new WP_REST_Request(['widget' => 'search', 'instance' => 'aksrt_widget_search-1', 'page' => 1]));
$this->error('aksrt_widget_items_unsupported', $error, 'REST rejects non-dynamic widgets');
$error = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_posts-1', 'page' => 1]));
$this->error('aksrt_widget_instance_not_registered', $error, 'REST requires an assigned instance');
$GLOBALS['aksrt_test_state']['sidebars'] = ['home_top_content' => ['aksrt_widget_posts-1']];
update_option('widget_aksrt_widget_posts', [1 => ['title' => 'Saved only', 'count' => 2, 'style' => 'list'], '_multiwidget' => 1]);
$response = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_posts-1', 'page' => 2, 'segment' => '']));
$this->true($response instanceof WP_REST_Response, 'registered instance returns REST response');
$data = $response->get_data();
$this->true($data['success'], 'REST success envelope is stable');
$this->same(2, $data['page'], 'REST page reaches saved-query pipeline');
$this->contains('暂无内容', $data['html'], 'REST returns deterministic empty state');
$this->same(4, end($GLOBALS['aksrt_test_state']['query_args'])['posts_per_page'], 'REST reloads saved normalized configuration');
$error = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_hot_posts-1', 'page' => 1]));
$this->error('aksrt_widget_invalid_instance', $error, 'REST binds instance ID to requested widget');
}
private function payment_states(): void {
aksrt_test_reset_state();
$GLOBALS['aksrt_test_state']['current_post'] = new WP_Post(77, 5);
$method = new ReflectionMethod(ContentRenderer::class, 'post_pay');
$render = static fn(): string => (string) $method->invoke(null, ['theme' => 'jb-red']);
$GLOBALS['aksrt_test_state']['pay_meta'] = ['pay_type' => 'no', 'price' => 0];
$this->same('', $render(), 'unconfigured article renders nothing and aborts the widget');
$authorized = [
'free' => ['pay_type' => 'reading', 'price' => 0],
'purchased' => ['pay_type' => 'reading', 'price' => 29],
'vip' => ['pay_type' => 'vip', 'price' => 0],
];
foreach ($authorized as $label => $meta) {
$GLOBALS['aksrt_test_state']['pay_meta'] = $meta;
$GLOBALS['aksrt_test_state']['paid_access'] = true;
$this->contains('内容已解锁', $render(), $label . ' authorization delegates to existing access service');
}
$GLOBALS['aksrt_test_state']['pay_meta'] = ['pay_type' => 'video', 'price' => 49];
$GLOBALS['aksrt_test_state']['paid_access'] = false;
$GLOBALS['aksrt_test_state']['logged_in'] = false;
$this->contains('guest-locked', $render(), 'guest unauthorized state delegates to paywall');
$GLOBALS['aksrt_test_state']['logged_in'] = true;
$this->contains('member-locked', $render(), 'logged-in unauthorized state delegates to paywall');
}
private function same($expected, $actual, string $message): void {
$this->assertions++;
if ($expected !== $actual) $this->failures[] = $message . ' (expected ' . var_export($expected, true) . ', got ' . var_export($actual, true) . ')';
}
private function true($actual, string $message): void { $this->same(true, (bool) $actual, $message); }
private function false($actual, string $message): void { $this->same(false, (bool) $actual, $message); }
private function contains(string $needle, ?string $haystack, string $message): void {
$this->assertions++;
if ($haystack === null || !str_contains($haystack, $needle)) $this->failures[] = $message . ' (missing ' . $needle . ')';
}
private function error(string $code, $value, string $message): void {
$this->assertions++;
if (!$value instanceof WP_Error || $value->get_error_code() !== $code) $this->failures[] = $message . ' (expected error ' . $code . ')';
}
}
(new WidgetSystemTest())->run();
@@ -0,0 +1,322 @@
<?php
/**
* Scratch harness: render term_aggregation with a fuller WP stub surface
* to reproduce the "widget does not work" report. Local-only, not tracked.
*/
declare(strict_types=1);
if (!defined('ABSPATH')) {
define('ABSPATH', __DIR__ . DIRECTORY_SEPARATOR);
}
if (!defined('DAY_IN_SECONDS')) {
define('DAY_IN_SECONDS', 86400);
}
error_reporting(E_ALL);
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
if (!(error_reporting() & $severity)) {
return false;
}
throw new ErrorException($message, 0, $severity, $file, $line);
});
class WP_Error {
public function __construct(private string $code = '', private string $message = '') {}
public function get_error_code(): string { return $this->code; }
public function get_error_message(): string { return $this->message; }
}
class WP_Post {
public int $ID;
public function __construct(int $id) { $this->ID = $id; }
}
class WP_Term {
public int $term_id;
public string $name = '';
public string $description = '';
public string $taxonomy = 'category';
public int $term_taxonomy_id;
public function __construct(int $term_id, string $name, string $description = '', string $taxonomy = 'category') {
$this->term_id = $term_id;
$this->name = $name;
$this->description = $description;
$this->taxonomy = $taxonomy;
$this->term_taxonomy_id = $term_id;
}
}
class WP_User {
public int $ID;
public string $display_name;
public function __construct(int $id, string $name) { $this->ID = $id; $this->display_name = $name; }
}
class wpdb {
public string $postmeta = 'wp_postmeta';
public string $posts = 'wp_posts';
public string $term_relationships = 'wp_term_relationships';
public function prepare(string $query, ...$args): string {
foreach ($args as $arg) {
$query = preg_replace('/%[sd]/', is_numeric($arg) ? (string) (int) $arg : "'" . (string) $arg . "'", $query, 1);
}
return $query;
}
public function get_var(string $query): ?string {
unset($query);
return '12345';
}
}
$GLOBALS['wpdb'] = new wpdb();
// Fake posts the stub WP_Query will serve, keyed by expected cat.
$GLOBALS['aksrt_harness_posts'] = [
['id' => 101, 'title' => '第一篇文章', 'cat' => 5],
['id' => 102, 'title' => '第二篇文章', 'cat' => 5],
['id' => 103, 'title' => '第三篇文章', 'cat' => 5],
['id' => 104, 'title' => '第四篇文章', 'cat' => 5],
];
$GLOBALS['aksrt_harness_cursor'] = 0;
class WP_Query {
public array $posts = [];
public int $found_posts = 0;
public function __construct(public array $query_args = []) {
$cat_in = (array) ($query_args['category__in'] ?? []);
$matched = array_values(array_filter($GLOBALS['aksrt_harness_posts'], static fn(array $p): bool => in_array($p['cat'], $cat_in, true)));
$this->found_posts = count($matched);
foreach ($matched as $post) {
$this->posts[] = new WP_Post($post['id']);
$GLOBALS['aksrt_harness_lookup'][$post['id']] = $post;
}
}
public function have_posts(): bool { return $GLOBALS['aksrt_harness_cursor'] < count($this->posts); }
public function the_post(): void {
$post = $this->posts[$GLOBALS['aksrt_harness_cursor']];
$GLOBALS['aksrt_harness_cursor']++;
$GLOBALS['aksrt_harness_current_post'] = $GLOBALS['aksrt_harness_lookup'][$post->ID] ?? ['id' => $post->ID, 'title' => '文章' . $post->ID];
}
}
function __($text, $domain = null): string { unset($domain); return (string) $text; }
function esc_html__($text, $domain = null): string { return esc_html(__($text, $domain)); }
function esc_attr__($text, $domain = null): string { return esc_attr(__($text, $domain)); }
function esc_html($value): string { return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
function esc_attr($value): string { return esc_html($value); }
function esc_url_raw($value): string {
$value = trim((string) $value);
return preg_match('#^(https?://|/)#', $value) ? $value : '';
}
function esc_url($value): string { return esc_attr(esc_url_raw($value)); }
function absint($value): int { return abs((int) $value); }
function sanitize_key($value): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $value)) ?? ''; }
function sanitize_text_field($value): string { return trim(strip_tags((string) $value)); }
function sanitize_textarea_field($value): string { return trim(strip_tags((string) $value)); }
function sanitize_html_class($value): string { return preg_replace('/[^A-Za-z0-9_-]/', '', (string) $value) ?? ''; }
function wp_parse_args($args, array $defaults = []): array { return array_merge($defaults, is_array($args) ? $args : []); }
function wp_kses($value, array $allowed): string {
unset($allowed);
return strip_tags((string) $value, '<i><span><item><b><strong><em><badge><div>');
}
function wp_kses_post($value): string { return strip_tags((string) $value, '<a><b><br><p><span>'); }
function wp_strip_all_tags($value): string { return trim(strip_tags((string) $value)); }
function wp_trim_words($text, int $count = 55, string $more = '…'): string {
$words = preg_split('/\s+/', trim((string) $text)) ?: [];
if (count($words) <= $count) return implode(' ', $words);
return implode(' ', array_slice($words, 0, $count)) . $more;
}
function wp_is_mobile(): bool { return false; }
function wp_reset_postdata(): void {}
function is_wp_error($value): bool { return $value instanceof WP_Error; }
function is_super_admin(): bool { return false; }
function is_sticky($id = 0): bool { unset($id); return false; }
function current_time(string $type): int { return $type === 'timestamp' ? time() : 0; }
function human_time_diff(int $from, int $to = 0): string {
unset($to);
return (($from % 3600) + 1) . ' 小时';
}
function get_avatar_url($id, array $args = []): string { return 'https://example.test/avatar-' . (int) $id . '.jpg'; }
function get_post_meta($id, $key = '', $single = false) { unset($id, $key, $single); return ''; }
function get_term_meta(int $term_id, string $key, bool $single = false) {
if ($key === 'aksrt_category_cover_id') {
return $term_id === 5 ? 55 : 0;
}
return '';
}
function wp_get_attachment_url(int $id): string { return $id === 55 ? 'https://example.test/cover-cat-5.jpg' : ''; }
function get_posts(array $args): array {
unset($args);
return [102];
}
function get_the_post_thumbnail_url($post = null, string $size = 'post-thumbnail') {
unset($post, $size);
return 'https://example.test/thumb-cat-8.jpg';
}
function get_terms(array $args): array {
unset($args);
return [new WP_Term(5, '技术分享', '这里是分类描述文本'), new WP_Term(8, '生活记录', '')];
}
function get_term(int $term_id, string $taxonomy = '') {
unset($taxonomy);
return match ($term_id) {
5 => new WP_Term(5, '技术分享', '这里是分类描述文本'),
8 => new WP_Term(8, '生活记录', ''),
default => new WP_Error('invalid_term', 'Term not found'),
};
}
function get_term_link(WP_Term $term) {
return 'https://example.test/cat-' . $term->term_id;
}
function get_the_ID(): int { return (int) ($GLOBALS['aksrt_harness_current_post']['id'] ?? 0); }
function get_the_title($id = 0): string {
unset($id);
return (string) ($GLOBALS['aksrt_harness_current_post']['title'] ?? '');
}
function get_permalink($id = 0): string { return 'https://example.test/post-' . (int) ($id ?: get_the_ID()); }
function get_the_excerpt($id = 0): string { unset($id); return '这是摘要'; }
function get_post_field($field, $id = 0, $context = 'display') {
unset($context);
return $field === 'post_author' ? 1 : '';
}
function get_userdata($id): ?WP_User { return $id > 0 ? new WP_User((int) $id, '作者' . (int) $id) : null; }
function get_the_time($format, $id = 0): string { unset($format, $id); return '2026-09-01 10:00:00'; }
function get_the_modified_time($format, $id = 0): string { unset($format, $id); return '2026-09-04 10:00:00'; }
function get_the_category($id = 0): array { unset($id); return [new WP_Term(5, '技术分享')]; }
function get_the_tags($id = 0) { unset($id); return []; }
function get_comments_number($id = 0): int { unset($id); return 3; }
function get_comments_link($id = 0): string { return get_permalink($id) . '#comments'; }
function comments_open($id = 0): bool { unset($id); return true; }
function get_post_type($id = 0): string { unset($id); return 'post'; }
function has_post_format($formats, $id = 0): bool { unset($formats, $id); return false; }
function aksrt_get_pay_meta($post = null): array { unset($post); return ['pay_type' => 'no', 'price' => 0]; }
function aksrt_get_post_views($id = 0): int { return (int) ($id % 90) + 10; }
function aksrt_get_post_likes($id = 0): int { return (int) ($id % 7); }
function aksrt_get_post_favorites($id = 0): int { return (int) ($id % 5); }
function aksrt_resource_card_badge_text($id = 0): string { unset($id); return ''; }
function aksrt_format_number($num): string { return (string) $num; }
function aksrt_is_lazy(string $key, bool $default = false): bool { unset($key); return $default; }
function aksrt_get_lazy_thumb(string $size = ''): string { unset($size); return 'https://example.test/lazy.svg'; }
function aksrt_get_lazy_attr(string $type, string $src, string $class = '', string $lazy_src = ''): string {
$url = $lazy_src !== '' ? $lazy_src : $src;
return ' class="' . esc_attr($class) . '" src="' . esc_url($url) . '" data-src="' . esc_url($src) . '" data-lazy-type="' . esc_attr($type) . '"';
}
function aksrt_renderer_resolve_images($post_id, array $cfg): array {
unset($post_id, $cfg);
return ['primary' => '', 'has_real' => false, 'gallery' => [], 'slides' => [], 'is_slide' => false, 'video_url' => ''];
}
// --- full pipeline stubs (AssetManager / SchemaWidget / registry) ---
function wp_register_style(...$args): void { unset($args); }
function wp_register_script(...$args): void { unset($args); }
function wp_enqueue_style(...$args): void { unset($args); }
function wp_enqueue_script(...$args): void { unset($args); }
function wp_add_inline_script(...$args): void { unset($args); }
function rest_url(string $path = ''): string { return 'https://example.test/wp-json/' . $path; }
function get_template_directory(): string { return dirname(__DIR__, 2); }
function get_template_directory_uri(): string { return 'https://example.test/theme'; }
function wp_json_encode($value): string { return (string) json_encode($value, JSON_UNESCAPED_UNICODE); }
function esc_textarea($value): string { return esc_html($value); }
function checked($actual, $expected = true, bool $display = true): string {
$result = $actual == $expected ? 'checked="checked"' : '';
if ($display) echo $result;
return $result;
}
function selected($actual, $expected = true, bool $display = true): string {
$result = $actual == $expected ? 'selected="selected"' : '';
if ($display) echo $result;
return $result;
}
class WP_Widget {
public string $id_base = '';
public string $id = '';
public int $number = 0;
public function __construct(string $id_base = '') { $this->id_base = $id_base; }
public function get_field_name(string $field): string { return 'widget-' . $this->id_base . '[' . $this->number . '][' . $field . ']'; }
public function get_field_id(string $field): string { return 'widget-' . $this->id_base . '-' . $this->number . '-' . $field; }
public function _set(int $number): void { $this->number = $number; $this->id = $this->id_base . '-' . $number; }
}
require dirname(__DIR__, 2) . '/vendor/autoload.php';
require dirname(__DIR__, 2) . '/inc/helpers/aksrt-graphic-card.php';
require dirname(__DIR__, 2) . '/inc/helpers/aksrt-widgets-markup.php';
require dirname(__DIR__, 2) . '/inc/helpers/posts-renderer/post-data.php';
use AKSRT\WidgetSystem\Renderers\ContentRenderer;
$instance = [
'term_id' => [5, 8],
'pc_row' => 2,
'orderby' => 'modified',
'order' => 'desc',
'count' => 4,
'target_blank' => false,
'obs_animation' => '',
'animation_repeat' => false,
];
try {
$html = ContentRenderer::render('term_aggregation', $instance);
echo "=== RENDER OK ===\n";
echo $html;
echo "\n=== END ===\n";
} catch (Throwable $e) {
echo '=== FATAL: ' . get_class($e) . ': ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine() . "\n";
echo $e->getTraceAsString() . "\n";
}
// Single-term variant (is_row=false) + animation variant.
try {
$GLOBALS['aksrt_harness_cursor'] = 0;
$single = ContentRenderer::render('term_aggregation', array_merge($instance, ['term_id' => [5], 'obs_animation' => 'fade']));
echo "=== SINGLE OK (len=" . strlen($single) . ") ===\n";
echo $single . "\n";
} catch (Throwable $e) {
echo '=== SINGLE FATAL: ' . get_class($e) . ': ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine() . "\n";
}
// Full pipeline: registry -> definition -> normalize(raw form input '5,8') -> FrontendRenderer.
try {
$definitions = \AKSRT\WidgetSystem\WidgetRegistry::all();
echo "=== REGISTRY: term_aggregation present = " . (isset($definitions['term_aggregation']) ? 'yes' : 'NO') . " ===\n";
$definition = $definitions['term_aggregation'];
$widget = new \AKSRT\WidgetSystem\SchemaWidget($definition);
$widget->_set(2);
$normalized = $definition->normalize(['term_id' => '5,8']);
echo "=== NORMALIZED term_id = " . json_encode($normalized['term_id']) . " ===\n";
$GLOBALS['aksrt_harness_cursor'] = 0;
ob_start();
\AKSRT\WidgetSystem\FrontendRenderer::render(
$definition,
['before_widget' => '<section class="w">', 'after_widget' => '</section>'],
$normalized,
$widget
);
$full = (string) ob_get_clean();
echo "=== FULL PATH OK (len=" . strlen($full) . ") ===\n";
echo $full . "\n";
// Empty selection must render nothing at all.
ob_start();
\AKSRT\WidgetSystem\FrontendRenderer::render($definition, ['before_widget' => '<section>', 'after_widget' => '</section>'], $definition->normalize([]), $widget);
$empty = (string) ob_get_clean();
echo "=== EMPTY SELECTION OUTPUT len=" . strlen($empty) . " ===\n";
// Admin form HTML for the terms field (what the widgets screen renders).
ob_start();
$widget->render_full_form($definition->normalize(['term_id' => '5,8']));
$form = (string) ob_get_clean();
if (preg_match('/<div class="aksrt-widget-field aksrt-widget-field--terms".*?<\/div>\s*<\/div>\s*<\/div>/s', $form, $m)) {
echo "=== TERMS FIELD HTML ===\n" . $m[0] . "\n";
}
echo "=== FORM contains hidden terms value name = " . (strpos($form, 'name="widget-aksrt_widget_term_aggregation[2][term_id]"') !== false ? 'yes' : 'NO') . " ===\n";
echo "=== FORM hidden value = ";
preg_match('/class="aksrt-widget-terms__value"[^>]*value="([^"]*)"/', $form, $v);
echo ($v[1] ?? '(not found)') . " ===\n";
} catch (Throwable $e) {
echo '=== FULL PATH FATAL: ' . get_class($e) . ': ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine() . "\n";
echo $e->getTraceAsString() . "\n";
}
+50
View File
@@ -0,0 +1,50 @@
# Admin contract map
Generated by process, not a migration guide. Source of truth for menu ↔ schema wiring.
## Rules
1. Every `schemaPage` must have matching PHP section under the same domain.
2. Enum field values must be identical in PHP schema and JS schema.
3. REST `'callback' => [$this, 'method']` methods must exist (class or trait).
4. Deploy `assets/admin/build/` as one atomic unit after `npm run build`.
5. No compat aliases, dual-read, or silent frontend catch for missing APIs.
## Audit
```bash
php tools/audit-admin-contracts.php
```
Exit code `1` means hard contract errors.
## Domain builders
| Domain | PHP builder map key | Menu constants file |
|--------|---------------------|---------------------|
| global | `AKSRT_Admin_Schema_Global` | `assets/admin/src/constants/sections/global.js` |
| page_display | `AKSRT_Admin_Schema_Page_Display` | `page-display.js` |
| post_list | `AKSRT_Admin_Schema_Post_List` | `post-list.js` |
| permissions | `AKSRT_Admin_Schema_Permissions` | `permissions.js` |
| users | `AKSRT_Admin_Schema_Users` | `users.js` |
| payment | `AKSRT_Admin_Schema_Payment` | `payment.js` |
| extensions | `AKSRT_Admin_Schema_Extensions` | `extensions.js` |
| tools | `AKSRT_Admin_Schema_Tools` | `tools.js` |
| shop | `AKSRT_Admin_Schema_Shop` | `shop.js` |
## Known custom pages (not schema REST)
These use dedicated Vue pages + dedicated REST modules:
- Homepage: `HomepageSettings` + `/settings/homepage`
- Footer: `FooterSettings` + footer settings API
- Permissions capabilities matrix: `PermissionsCapabilities` + `/permissions/capabilities`
- Shop products/orders and other operational tables
## Deploy checklist
1. `cd assets/admin && npm ci && npm run build`
2. Confirm `build/index.js` and every dynamically imported `./Chunk.hash.js` exist
3. Replace the entire remote `assets/admin/build/` directory
4. Hard-refresh wp-admin (or purge CDN)
5. Re-run `php tools/audit-admin-contracts.php`
+271
View File
@@ -0,0 +1,271 @@
{
"generated_at": "2026-09-06T05:00:37+00:00",
"errors": [],
"warnings": [
"customPage tools-update section 'tools/update' not in PHP schema (ok if pure operational page)",
"customPage users-wechat-template section 'users/wechat-template' not in PHP schema (ok if pure operational page)",
"enum incomplete parse for 'alipay_payment_type' (php subset of js): php=[page] js=[page,wap]",
"enum incomplete parse for 'avatar_external' (php subset of js): php=[] js=[,gravatar,weavatar,cravatar,geekzu,loli,sep,v2ex]",
"enum incomplete parse for 'backup_frequency' (php subset of js): php=[daily] js=[daily,weekly,monthly]",
"enum incomplete parse for 'bind_reminder' (php subset of js): php=[email] js=[email,phone,email_phone,close]",
"enum incomplete parse for 'comment_city_type' (php subset of js): php=[province_city] js=[province_city,province,country_province]",
"enum incomplete parse for 'comment_order' (php subset of js): php=[asc] js=[asc,desc]",
"enum incomplete parse for 'comment_pagination_type' (php subset of js): php=[numbered] js=[numbered,load_more,infinite]",
"enum incomplete parse for 'content_audit_on_failure' (php subset of js): php=[allow] js=[allow,block]",
"enum incomplete parse for 'content_audit_provider' (php subset of js): php=[baidu] js=[baidu,null]",
"enum incomplete parse for 'epay_device' (php subset of js): php=[auto] js=[auto,pc,mobile]",
"enum incomplete parse for 'font_family' (php subset of js): php=[system] js=[system,noto-sans-sc,noto-serif-sc,custom]",
"enum incomplete parse for 'front_submit_editor_mode' (php subset of js): php=[rich] js=[rich,markdown,plain]",
"enum incomplete parse for 'front_submit_review_mode' (php subset of js): php=[pending] js=[pending,publish,draft]",
"enum incomplete parse for 'i18n_default_lang' (php subset of js): php=[zh-CN] js=[zh-CN,en,zh-TW,ja,ko,fr,de,es,ru]",
"enum incomplete parse for 'i18n_switcher_position' (php subset of js): php=[header] js=[header,footer,both,none]",
"enum incomplete parse for 'i18n_url_strategy' (php subset of js): php=[query] js=[query,path,subdomain]",
"enum incomplete parse for 'img_position' (php subset of js): php=[left] js=[left,right]",
"enum incomplete parse for 'invit_code_s' (php subset of js): php=[close] js=[close,open,must]",
"enum incomplete parse for 'ip_addr_sdk' (php subset of js): php=[polling] js=[polling,qq,amap,null]",
"enum incomplete parse for 'ip_geo_format' (php subset of js): php=[province_city] js=[province_city,province,country_province]",
"enum incomplete parse for 'lazy_animation' (php subset of js): php=[fade] js=[blur,fade,scale]",
"enum incomplete parse for 'list_meta_show' (php subset of js): php=[view] js=[view,like,comm]",
"enum incomplete parse for 'list_orderby' (php subset of js): php=[date] js=[date,modified,default]",
"enum incomplete parse for 'list_show_type' (php subset of js): php=[separate] js=[separate,no_margin]",
"enum incomplete parse for 'list_thumb_fit' (php subset of js): php=[] js=[,contain]",
"enum incomplete parse for 'list_type' (php subset of js): php=[text] js=[text,thumb,thumb_if_has,card]",
"enum incomplete parse for 'mandatory_bind' (php subset of js): php=[email] js=[email,phone,email_phone,close]",
"enum incomplete parse for 'message_paginate_type' (php subset of js): php=[ajax_lists] js=[ajax_lists,default]",
"enum incomplete parse for 'oauth_bind_type' (php subset of js): php=[auto] js=[auto,manual]",
"enum incomplete parse for 'oauth_proxy_mode' (php subset of js): php=[close] js=[close,server,client]",
"enum incomplete parse for 'paging_ajax_s' (php subset of js): php=[1] js=[1,0]",
"enum incomplete parse for 'pay_box_position' (php subset of js): php=[box_top] js=[box_top,top,bottom,box_bottom]",
"enum incomplete parse for 'post_author_card_orderby' (php subset of js): php=[views] js=[views,date,modified,comment_count]",
"enum incomplete parse for 'post_author_card_style' (php subset of js): php=[card] js=[card,list]",
"enum incomplete parse for 'post_pay_box_position' (php subset of js): php=[content] js=[content,mask]",
"enum incomplete parse for 'post_related_orderby' (php subset of js): php=[modified] js=[modified,date,views,like,comment_count,favorite,rand]",
"enum incomplete parse for 'post_related_type' (php subset of js): php=[img] js=[img,list,text]",
"enum incomplete parse for 'post_time_source' (php subset of js): php=[date] js=[date,modified]",
"enum incomplete parse for 'search_type_in' (php subset of js): php=[post] js=[post,user]",
"enum incomplete parse for 'sign_card_position' (php subset of js): php=[left] js=[left,center,right]",
"enum incomplete parse for 'sign_theme_mode' (php subset of js): php=[no] js=[no,white-theme,dark-theme]",
"enum incomplete parse for 'style' (php subset of js): php=[null] js=[null,style2]",
"enum incomplete parse for 'system_notice_btn1_color' (php subset of js): php=[primary] js=[primary,success,warning,danger,info,purple,dark]",
"enum incomplete parse for 'system_notice_btn2_color' (php subset of js): php=[primary] js=[primary,success,warning,danger,info,purple,dark]",
"enum incomplete parse for 'system_notice_btn3_color' (php subset of js): php=[primary] js=[primary,success,warning,danger,info,purple,dark]",
"enum incomplete parse for 'system_notice_btn4_color' (php subset of js): php=[primary] js=[primary,success,warning,danger,info,purple,dark]",
"enum incomplete parse for 'system_notice_policy' (php subset of js): php=[] js=[,signin,vip,free,auth]",
"enum incomplete parse for 'system_notice_size' (php subset of js): php=[] js=[,modal-mini,modal-sm,modal-lg]",
"enum incomplete parse for 'theme_mode' (php subset of js): php=[white-theme] js=[white-theme,dark-theme,time-auto]",
"enum incomplete parse for 'user_email_limit_type' (php subset of js): php=[not] js=[not,allow]",
"enum incomplete parse for 'user_sign_type' (php subset of js): php=[modal] js=[modal,page]",
"enum incomplete parse for 'user_signin_nopas_active' (php subset of js): php=[nopas] js=[nopas,pas,only_nopas]",
"enum incomplete parse for 'wechat_payment_type' (php subset of js): php=[native] js=[native,h5]",
"enum incomplete parse for 'weixingzh_gzh_type' (php subset of js): php=[service] js=[service,subscribe]"
],
"info": [
"PHP schema domains: global, page_display, post_list, permissions, users, payment, extensions, tools, ExtensionsAudit, ExtensionsIP, ExtensionsPush, ToolsBackup, ToolsHealth, ToolsImportExport",
"Menu pages discovered: 61",
"admin build ok; chunks=32"
],
"domains": {
"global": {
"sections": [
"code-highlight",
"common",
"custom-code",
"email",
"i18n",
"layout",
"logo",
"search",
"seo",
"system-notice"
],
"files": [
"src/Admin/Schema/GlobalSchema.php"
]
},
"page_display": {
"sections": [
"category",
"footer",
"header-components",
"header-nav",
"homepage",
"other",
"tag",
"user-home"
],
"files": [
"src/Admin/Schema/SchemaPageDisplay.php"
]
},
"post_list": {
"sections": [
"comment",
"feature",
"filter",
"list",
"page",
"pay",
"share",
"submit",
"thumb"
],
"files": [
"src/Admin/Schema/SchemaPostList.php",
"src/Admin/Schema/PostList/SchemaPostListList.php"
]
},
"permissions": {
"sections": [
"attachment",
"capabilities",
"download",
"login",
"moderation"
],
"files": [
"src/Admin/Schema/SchemaPermissions.php"
]
},
"users": {
"sections": [
"auth",
"badge",
"credits",
"features",
"level",
"notifications",
"oauth",
"report",
"sms",
"verify",
"wechat"
],
"files": [
"src/Admin/Schema/SchemaUsers.php"
]
},
"payment": {
"sections": [
"affiliate",
"card-pass",
"gateway",
"order",
"recharge",
"revenue",
"vip",
"withdrawal"
],
"files": [
"src/Admin/Schema/SchemaPayment.php"
]
},
"extensions": {
"sections": [
"audit",
"ip",
"push"
],
"files": [
"src/Admin/Schema/SchemaExtensions.php"
]
},
"tools": {
"sections": [
"backup",
"health",
"import-export"
],
"files": [
"src/Admin/Schema/SchemaTools.php"
]
},
"ExtensionsAudit": {
"sections": [
"audit"
],
"files": [
"src/Admin/Schema/SchemaExtensionsAudit.php"
]
},
"ExtensionsIP": {
"sections": [
"ip"
],
"files": [
"src/Admin/Schema/SchemaExtensionsIP.php"
]
},
"ExtensionsPush": {
"sections": [
"push"
],
"files": [
"src/Admin/Schema/SchemaExtensionsPush.php"
]
},
"ToolsBackup": {
"sections": [
"backup"
],
"files": [
"src/Admin/Schema/SchemaToolsBackup.php"
]
},
"ToolsHealth": {
"sections": [
"health"
],
"files": [
"src/Admin/Schema/SchemaToolsHealth.php"
]
},
"ToolsImportExport": {
"sections": [
"import-export"
],
"files": [
"src/Admin/Schema/SchemaToolsImportExport.php"
]
}
},
"page_count": 61,
"build": {
"present": true,
"entry": true,
"missing_chunks": [],
"chunks": [
"SchemaPage.CcW0AW7T.js",
"vendor-vue.BxZIRU0_.js",
"shared-api.BUPhZW2Y.js",
"FaIcon.NZ6TDkNu.js",
"shared-api.DnsQ5cxS.js",
"vendor-misc.bELTaXdf.js",
"level.E3ANSDAX.js",
"badge.CKTsmH1z.js",
"vendor-naive.CKYCZmXD.js",
"DeferredPage.DpQpYrho.js",
"NativeOpsRedirect.CHwEgYbu.js",
"FloatingButtonsSettings.JM4lmjpr.js",
"vendor-draggable.BooRVfGb.js",
"PageHeader.CtT29f8Y.js",
"FooterTabbarSettings.C6AggjrQ.js",
"PostPageSettings.uzxR41nA.js",
"CommentSettings.sqwcq8XD.js",
"PermissionsCapabilities.CjA3yZya.js",
"AttachmentPermissions.Dzsg-DfQ.js",
"CmsCredits.BYsXdX89.js",
"ThemeInfoPage.jxAxkM2b.js",
"ThemeAuthorizationPage.DMbNRHuq.js",
"DocsPage.mFfiXsYT.js",
"UsersVerify.ryf_AQd6.js",
"UsersLevel.BpnbcbVe.js",
"UsersBadge.DCbaumfe.js",
"UsersWechatTemplate.Dn21sVHP.js",
"PaymentGateway.DuX_0Di_.js",
"ToolsImportExport.BxEsl-fY.js",
"ToolsHealth.B1bVNkRU.js",
"ToolsUpdate.DZedmIdt.js",
"ExtensionAI.BmvoDo9B.js"
]
}
}
+61
View File
@@ -0,0 +1,61 @@
# ADR: Domain folder placement + PSR-4 namespaces for theme types
## Status
Accepted (2026-07-17); **updated 2026-08-02** for Composer PSR-4 root flatten to `src/`.
## Context
Domain folders were frozen after the root→domain move. Theme types then migrated from global `AKSRT_*` + manual `require_once` to real PHP namespaces under `src/` with Composer PSR-4.
## Decision
### Domain ownership (unchanged)
| Concern | Domain | Why |
|---------|--------|-----|
| Theme shell (setup, scripts, templates, SEO, meta boxes, DB migrator, front-submit, docs presenter, home modules, stats, redirects, update checker, comment actions) | `Core` | Shared CMS plumbing |
| SPA shell + controllers + schema + native ops | `Admin` | Dual-surface IA |
| Settings facade + runtime delegates | `Settings` | Single public settings API |
| Auth, OAuth, captcha, moderation, email rollback | `Auth` | Identity & access |
| User center/home/badges/level/metrics/notifications/follow | `User` | Account surface |
| Credits, balance wallet, signin, invite, points-free | `Credits` | Points + cash wallet |
| Download tokens/state, content pay, VIP, visibility | `Content` | Post-centric paid content |
| Orders, gateways, coupon, charge-card, revenue, creator-revenue, withdrawal, rebate, discount, after-sale, shipping, wechat-notify | `Payment` | Commerce + settlement |
| Shop services/meta/reviews | `Shop` | Product catalog / cart |
| SMS stack | `Sms` | Messaging channel |
| AI runtime (admin AI controller stays in `Admin`) | `Ai` | Generation helpers |
| Message, private message, site notice, report center | `Messages` | In-site messaging |
| MeiliSearch, search-push, ip-geo, content-audit, api-audit | `Extensions` | Optional integrations |
| Widgets + registry | `Widgets` | WP widgets |
| Inline style / custom code / loading overlay | `Frontend` | Asset render helpers |
### PSR-4 layout
- Composer: `"AKSRT\\": "src/"`
- Namespace `AKSRT\{Domain}\…` ↔ path `src/{Domain}/…`
- One type per file; StudlyCaps filename = short class/trait/interface name
- Nested segments: `Admin\Schema`, `Admin\Native`, `Payment\Gateways`, `Admin\Schema\PostList`
- OAuth stays under `Auth` (`AKSRT\Auth\OAuth`) — no separate `oauth/` tree
- Do not move `TopicPay` / `PlateFollowPay` into `Content` without updating this ADR
- `CreatorRevenue` and `WeChatNotify` stay under `Payment`
### Loader
1. `functions.php` loads `vendor/autoload.php` (Composer: third-party + theme PSR-4)
2. Immediately after, canary `class_exists(\AKSRT\Core\DomainModel::class)` — if missing, `wp_die` with `composer install` / `composer dump-autoload -o` (stale `vendor/` after PSR-4 cutover is the common failure)
3. `inc/bootstrap.php` loads helpers + procedural modules (`inc/modules/*`) + core-hooks only
4. Singleton `init()` remains in `functions.php` using FQCNs
Deploy note: `vendor/` is gitignored. After pull/upload, run `composer install --no-dev --optimize-autoloader` (or at least `composer dump-autoload -o`) in the theme root so `autoload_psr4` includes `"AKSRT\\": ["…/src"]`.
### Procedural (not PSR-4)
- `inc/helpers/*` function libraries
- `inc/modules/*` (captcha, user-hub/parts, user-notifications, widgets-register, …)
- `inc/core-hooks.php` (+ parts)
The one-shot `tools/build-psr4-map.php` migrator (and `tools/psr4-map/` artifacts) were removed when `inc/classes` was deleted — they scanned the old tree and cannot run anymore.
## Out of scope
- Dual-read `class_alias` shims for old `AKSRT_*` global names (rewrite references instead)
- Converting all helpers to classes
+529
View File
@@ -0,0 +1,529 @@
<?php
/**
* Admin contract audit — menu ↔ PHP schema ↔ REST $this callbacks ↔ build integrity.
*
* Usage (from theme root):
* php tools/audit-admin-contracts.php
*
* Exit 1 on hard errors.
*/
declare(strict_types=1);
$themeRoot = dirname(__DIR__);
$errors = [];
$warnings = [];
$info = [];
function rel(string $path, string $root): string {
return str_replace('\\', '/', ltrim(str_replace($root, '', $path), '\\/'));
}
/**
* Return the source of the first balanced [ ... ] array assigned to $needle,
* brackets included; null when absent or unbalanced.
*/
function extract_balanced_block(string $src, string $needle): ?string {
$pos = strpos($src, $needle);
if ($pos === false) {
return null;
}
$open = strpos($src, '[', $pos);
if ($open === false) {
return null;
}
$depth = 0;
$len = strlen($src);
for ($i = $open; $i < $len; $i++) {
$ch = $src[$i];
if ($ch === "'" || $ch === '"') {
$quote = $ch;
$i++;
while ($i < $len) {
if ($src[$i] === '\\') {
$i += 2;
continue;
}
if ($src[$i] === $quote) {
break;
}
$i++;
}
continue;
}
if ($ch === '[') {
$depth++;
} elseif ($ch === ']') {
$depth--;
if ($depth === 0) {
return substr($src, $open, $i - $open + 1);
}
}
}
return null;
}
/**
* String keys written directly inside an outer [ ... ] block (bracket depth 1),
* regardless of whether their value is an inline array, Class::build() or
* self::helper(). Nested 'k => v' pairs deeper down are ignored.
*
* @return string[]
*/
function top_level_array_keys(string $block): array {
$keys = [];
$depth = 0;
$len = strlen($block);
for ($i = 0; $i < $len; $i++) {
$ch = $block[$i];
if ($ch === "'" || $ch === '"') {
$start = $i;
$quote = $ch;
$i++;
while ($i < $len) {
if ($block[$i] === '\\') {
$i += 2;
continue;
}
if ($block[$i] === $quote) {
break;
}
$i++;
}
$j = $i + 1;
while ($j < $len && ctype_space($block[$j])) {
$j++;
}
if ($depth === 1 && substr($block, $j, 2) === '=>') {
$keys[] = substr($block, $start + 1, $i - $start - 1);
}
continue;
}
if ($ch === '[') {
$depth++;
} elseif ($ch === ']') {
$depth--;
}
}
return $keys;
}
/**
* Extract section keys from composite schema builders (including delegated leaf builders).
*
* @return array<string, array{sections: string[], files: string[]}>
*/
function collect_php_schema_sections(string $themeRoot): array {
$map = [];
$register = static function (string $domain, string $section, string $file) use (&$map, $themeRoot): void {
if ($domain === '' || $section === '') {
return;
}
if (!isset($map[$domain])) {
$map[$domain] = ['sections' => [], 'files' => []];
}
$map[$domain]['sections'][$section] = true;
$fileRel = rel($file, $themeRoot);
if (!in_array($fileRel, $map[$domain]['files'], true)) {
$map[$domain]['files'][] = $fileRel;
}
};
// Primary domain builders.
$primary = [
'global' => $themeRoot . '/src/Admin/Schema/GlobalSchema.php',
'page_display' => $themeRoot . '/src/Admin/Schema/SchemaPageDisplay.php',
'post_list' => $themeRoot . '/src/Admin/Schema/SchemaPostList.php',
'permissions' => $themeRoot . '/src/Admin/Schema/SchemaPermissions.php',
'users' => $themeRoot . '/src/Admin/Schema/SchemaUsers.php',
'payment' => $themeRoot . '/src/Admin/Schema/SchemaPayment.php',
'extensions' => $themeRoot . '/src/Admin/Schema/SchemaExtensions.php',
'tools' => $themeRoot . '/src/Admin/Schema/SchemaTools.php',
'shop' => $themeRoot . '/src/Admin/Schema/SchemaShop.php',
];
foreach ($primary as $domain => $file) {
if (!is_file($file)) {
continue;
}
$src = file_get_contents($file);
if ($src === false) {
continue;
}
// Only keys at the first level of the 'sections' block are sections;
// whole-file regexes also match nested field/group arrays ('all' => [,
// 'fields' => [, ...) and miss self::helper() delegates.
$block = extract_balanced_block($src, "'sections'");
if ($block === null) {
continue;
}
foreach (top_level_array_keys($block) as $section) {
if (in_array($section, ['domain', 'title', 'version', 'option_key', 'key', 'sections', 'groups', 'fields'], true)) {
continue;
}
$register($domain, $section, $file);
}
}
// Leaf builders that return a single section with 'key' => 'section-id'
$leafFiles = array_merge(glob($themeRoot . '/src/Admin/Schema/*.php') ?: [], glob($themeRoot . '/src/Admin/Schema/*/*.php') ?: []);
foreach ($leafFiles as $file) {
$src = file_get_contents($file);
if ($src === false) {
continue;
}
// Skip pure composites that only delegate.
$isComposite = (bool) preg_match("/'sections'\\s*=>\\s*\\[/", $src);
if ($isComposite) {
continue;
}
if (!preg_match("/'key'\\s*=>\\s*'([a-z0-9_-]+)'/", $src, $km)) {
continue;
}
$section = $km[1];
// Infer domain from filename: class-aksrt-admin-schema-{domain}-{section}.php
$base = basename($file, '.php');
$base = preg_replace('/^(class-aksrt-admin-schema-|Schema)/', '', $base);
$parts = explode('-', $base);
// domain may be multi-token (page_display is page-display in menus but page_display in PHP).
// Prefer matching known domains by prefix.
$domain = null;
foreach (array_keys($primary) as $candidate) {
$candidateDash = str_replace('_', '-', $candidate);
if ($base === $candidateDash || str_starts_with($base, $candidateDash . '-')) {
$domain = $candidate;
break;
}
}
if ($domain === null) {
// Fallback: first token / PSR-4 Schema* short names.
if (str_contains($base, 'PostList')) {
$domain = 'post_list';
} else {
$domain = str_replace('-', '_', $parts[0] ?? '');
}
}
// Leaf under Schema/PostList/ with key list → post_list/list
if ($domain === 'post_list' && $section === 'list') {
$register($domain, $section, $file);
continue;
}
$register($domain, $section, $file);
}
foreach ($map as $domain => $payload) {
$map[$domain]['sections'] = array_keys($payload['sections']);
sort($map[$domain]['sections']);
}
return $map;
}
function collect_menu_pages(string $themeRoot): array {
$dir = $themeRoot . '/assets/admin/src/constants/sections';
$pages = [];
foreach (glob($dir . '/*.js') ?: [] as $file) {
$base = basename($file);
if (in_array($base, ['helpers.js', 'index.js'], true)) {
continue;
}
$src = file_get_contents($file);
if ($src === false) {
continue;
}
foreach (['schemaPage' => 'schemaPage', 'customPage' => 'customPage', 'deferredPage' => 'deferredPage'] as $fn => $kind) {
if (!preg_match_all('/' . $fn . '\\(\\s*\\{([\\s\\S]*?)\\}\\s*\\)/', $src, $blocks)) {
continue;
}
foreach ($blocks[1] as $block) {
$page = ['file' => rel($file, $themeRoot), 'kind' => $kind];
foreach (['key', 'canonicalDomain', 'section', 'route', 'component', 'name'] as $prop) {
if (preg_match('/' . $prop . ":\\s*'([^']*)'/", $block, $m)) {
$page[$prop === 'canonicalDomain' ? 'domain' : $prop] = $m[1];
}
}
$pages[] = $page;
}
}
}
return $pages;
}
/**
* Collect enum options for fields with balanced-enough heuristics:
* only consider field objects that include both type radio|select and an options array
* on nearby lines, keyed by the field key property.
*
* @return array{php: array<string, string[]>, js: array<string, string[]>}
*/
function collect_enum_maps(string $themeRoot): array {
$php = [];
$js = [];
$phpFiles = array_merge(glob($themeRoot . '/src/Admin/Schema/*.php') ?: [], glob($themeRoot . '/src/Admin/Schema/*/*.php') ?: []);
foreach ($phpFiles as $file) {
$src = file_get_contents($file);
if ($src === false) {
continue;
}
// Split roughly by field array starts: ['key' => '...'
if (!preg_match_all("/\\['key'\\s*=>\\s*'([^']+)'([\\s\\S]*?)(?=\\['key'\\s*=>|\\]\\s*,\\s*\\[\\s*'key'|\\]\\s*\\]\\s*,\\s*\\[\\s*'key'|$)/", $src, $chunks, PREG_SET_ORDER)) {
continue;
}
foreach ($chunks as $chunk) {
$key = $chunk[1];
$body = $chunk[2];
if (!preg_match("/'type'\\s*=>\\s*'(radio|select)'/", $body)) {
continue;
}
if (!preg_match("/'options'\\s*=>\\s*\\[([\\s\\S]*?)\\]/", $body, $om)) {
continue;
}
$values = [];
if (preg_match_all("/'value'\\s*=>\\s*'([^']*)'/", $om[1], $vm)) {
$values = $vm[1];
}
// Keep first definition; warn later if duplicates differ.
if (!isset($php[$key])) {
$php[$key] = $values;
}
}
}
$jsRoot = $themeRoot . '/assets/admin/src/schema/sections';
if (is_dir($jsRoot)) {
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($jsRoot, FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $file) {
if ($file->getExtension() !== 'js') {
continue;
}
$src = file_get_contents($file->getPathname());
if ($src === false) {
continue;
}
if (!preg_match_all("/\\{\\s*key:\\s*'([^']+)'([\\s\\S]*?)(?=\\{\\s*key:\\s*'|$)/", $src, $chunks, PREG_SET_ORDER)) {
continue;
}
foreach ($chunks as $chunk) {
$key = $chunk[1];
$body = $chunk[2];
// Stop body at next field-ish boundary if options not closed well.
if (!preg_match("/type:\\s*'(radio|select)'/", $body)) {
continue;
}
if (!preg_match("/options:\\s*\\[([\\s\\S]*?)\\]/", $body, $om)) {
continue;
}
$values = [];
if (preg_match_all("/value:\\s*'([^']*)'/", $om[1], $vm)) {
$values = $vm[1];
}
if (!isset($js[$key])) {
$js[$key] = $values;
}
}
}
}
return ['php' => $php, 'js' => $js];
}
function collect_missing_this_callbacks(string $themeRoot): array {
$issues = [];
$adminRoot = $themeRoot . '/src/Admin';
$methodIndex = [];
if (!is_dir($adminRoot)) {
return $issues;
}
$allPhp = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($adminRoot, FilesystemIterator::SKIP_DOTS));
foreach ($allPhp as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$src = file_get_contents($file->getPathname());
if ($src === false) {
continue;
}
if (preg_match_all('/function\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*\\(/', $src, $mm)) {
foreach ($mm[1] as $method) {
$methodIndex[$method] = true;
}
}
}
foreach ($allPhp as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$path = $file->getPathname();
$src = file_get_contents($path);
if ($src === false) {
continue;
}
if (!preg_match_all("/'callback'\\s*=>\\s*\\[\\s*\\\$this\\s*,\\s*'([^']+)'\\s*\\]/", $src, $matches)) {
continue;
}
foreach ($matches[1] as $method) {
if (empty($methodIndex[$method])) {
$issues[] = [
'file' => rel($path, $themeRoot),
'method' => $method,
];
}
}
}
return $issues;
}
function audit_build_manifest(string $themeRoot): array {
$buildDir = $themeRoot . '/assets/admin/build';
$result = [
'present' => is_dir($buildDir),
'entry' => false,
'missing_chunks' => [],
'chunks' => [],
];
if (!$result['present']) {
return $result;
}
$entry = $buildDir . '/index.js';
$result['entry'] = is_file($entry);
if (!$result['entry']) {
return $result;
}
$src = file_get_contents($entry);
if ($src === false) {
return $result;
}
if (preg_match_all('/\\.\\/([A-Za-z0-9_.-]+\\.js)/', $src, $m)) {
$result['chunks'] = array_values(array_unique($m[1]));
foreach ($result['chunks'] as $chunk) {
if (!is_file($buildDir . '/' . $chunk)) {
$result['missing_chunks'][] = $chunk;
}
}
}
return $result;
}
$phpSchemas = collect_php_schema_sections($themeRoot);
$menuPages = collect_menu_pages($themeRoot);
$enums = collect_enum_maps($themeRoot);
$restIssues = collect_missing_this_callbacks($themeRoot);
$build = audit_build_manifest($themeRoot);
$info[] = 'PHP schema domains: ' . implode(', ', array_keys($phpSchemas));
$info[] = 'Menu pages discovered: ' . count($menuPages);
foreach ($menuPages as $page) {
if (($page['kind'] ?? '') !== 'schemaPage') {
continue;
}
$domain = $page['domain'] ?? '';
$section = $page['section'] ?? '';
$key = $page['key'] ?? '(unknown)';
if ($domain === '' || $section === '') {
$errors[] = "schemaPage {$key} missing canonicalDomain/section in {$page['file']}";
continue;
}
if (!isset($phpSchemas[$domain])) {
$errors[] = "schemaPage {$key} domain '{$domain}' has no PHP schema builder";
continue;
}
if (!in_array($section, $phpSchemas[$domain]['sections'], true)) {
$errors[] = "schemaPage {$key} section '{$domain}/{$section}' missing in PHP schema; known=[" . implode(',', $phpSchemas[$domain]['sections']) . '] files=' . implode(',', $phpSchemas[$domain]['files']);
}
}
foreach ($menuPages as $page) {
if (($page['kind'] ?? '') !== 'customPage') {
continue;
}
$domain = $page['domain'] ?? '';
$section = $page['section'] ?? '';
if ($domain === '' || $section === '') {
continue;
}
if (!isset($phpSchemas[$domain])) {
$warnings[] = "customPage {$page['key']} references domain '{$domain}' without PHP schema map entry";
continue;
}
if (!in_array($section, $phpSchemas[$domain]['sections'], true)) {
$warnings[] = "customPage {$page['key']} section '{$domain}/{$section}' not in PHP schema (ok if pure operational page)";
}
}
// Enum mismatches only for shared keys with non-empty option sets on both sides.
// Heuristic parsers often capture only the default option from dense PHP arrays;
// treat "PHP is a non-empty proper subset of JS" as a parser warning, not a hard error.
// Hard error when sets are disjoint or PHP introduces values JS does not have.
$shared = array_intersect(array_keys($enums['php']), array_keys($enums['js']));
sort($shared);
foreach ($shared as $key) {
$phpValues = $enums['php'][$key];
$jsValues = $enums['js'][$key];
if ($phpValues === [] && $jsValues === []) {
continue;
}
$phpSorted = $phpValues;
$jsSorted = $jsValues;
sort($phpSorted);
sort($jsSorted);
if ($phpSorted === $jsSorted) {
continue;
}
$phpOnly = array_values(array_diff($phpValues, $jsValues));
$jsOnly = array_values(array_diff($jsValues, $phpValues));
if ($phpOnly === [] && $jsOnly !== [] && $phpValues !== []) {
$warnings[] = "enum incomplete parse for '{$key}' (php subset of js): php=[" . implode(',', $phpValues) . '] js=[' . implode(',', $jsValues) . ']';
continue;
}
$errors[] = "enum mismatch for '{$key}': php=[" . implode(',', $phpValues) . '] js=[' . implode(',', $jsValues) . '] php_only=[' . implode(',', $phpOnly) . '] js_only=[' . implode(',', $jsOnly) . ']';
}
foreach ($restIssues as $issue) {
$errors[] = "REST \$this callback missing: {$issue['method']} in {$issue['file']}";
}
if (!$build['present']) {
$warnings[] = 'assets/admin/build/ missing — run: cd assets/admin && npm run build';
} elseif (!$build['entry']) {
$errors[] = 'assets/admin/build/index.js missing';
} elseif ($build['missing_chunks']) {
$errors[] = 'admin build missing chunks: ' . implode(', ', $build['missing_chunks']);
} else {
$info[] = 'admin build ok; chunks=' . count($build['chunks']);
}
echo "AKSRT admin contract audit\n";
echo str_repeat('=', 48) . "\n";
foreach ($info as $line) {
echo "[info] {$line}\n";
}
foreach ($warnings as $line) {
echo "[warn] {$line}\n";
}
foreach ($errors as $line) {
echo "[error] {$line}\n";
}
echo str_repeat('-', 48) . "\n";
echo 'errors=' . count($errors) . ' warnings=' . count($warnings) . "\n";
$summaryPath = $themeRoot . '/tools/admin-contract-report.json';
file_put_contents($summaryPath, json_encode([
'generated_at' => gmdate('c'),
'errors' => $errors,
'warnings' => $warnings,
'info' => $info,
'domains' => $phpSchemas,
'page_count' => count($menuPages),
'build' => $build,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
echo "wrote tools/admin-contract-report.json\n";
// Ignore report in git by default? Keep it for CI artifact; do not commit noise if huge.
exit(count($errors) > 0 ? 1 : 0);
+320
View File
@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
if (!defined('ABSPATH')) {
define('ABSPATH', __DIR__ . DIRECTORY_SEPARATOR);
}
// Minimal WordPress-compatible stubs keep the schema audit runnable without a site checkout.
if (!function_exists('sanitize_text_field')) {
function sanitize_text_field($value): string {
return trim(preg_replace('/[\r\n\t]+/', ' ', strip_tags((string) $value)) ?? '');
}
}
if (!function_exists('__')) {
function __($text): string {
return (string) $text;
}
}
if (!function_exists('sanitize_textarea_field')) {
function sanitize_textarea_field($value): string {
return trim(strip_tags((string) $value));
}
}
if (!function_exists('wp_kses_post')) {
function wp_kses_post($value): string {
return strip_tags((string) $value, '<a><b><strong><em><br><p><span><ul><ol><li>');
}
}
if (!function_exists('esc_url_raw')) {
function esc_url_raw($value): string {
$value = trim((string) $value);
return filter_var($value, FILTER_VALIDATE_URL) ? $value : '';
}
}
if (!function_exists('absint')) {
function absint($value): int {
return abs((int) $value);
}
}
if (!function_exists('sanitize_hex_color')) {
function sanitize_hex_color($value): string {
$value = (string) $value;
return preg_match('/^#(?:[0-9a-fA-F]{3}){1,2}$/', $value) ? strtolower($value) : '';
}
}
require dirname(__DIR__) . '/vendor/autoload.php';
use AKSRT\WidgetSystem\FieldSchema;
use AKSRT\WidgetSystem\PostQuery;
use AKSRT\WidgetSystem\RendererRegistry;
use AKSRT\WidgetSystem\WidgetAreas;
use AKSRT\WidgetSystem\WidgetRegistry;
use AKSRT\WidgetSystem\WidgetTransfer;
$errors = [];
$expected = [
'search', 'posts_toc', 'quote', 'icon_cards', 'video', 'embed', 'media_cards',
'icon_cover_cards', 'partners', 'combo_info', 'text_title', 'buttons', 'layout_gap',
'tag_cloud', 'recent_comments', 'links', 'notice', 'term_cards', 'term_aggregation',
'hot_posts', 'post_pay', 'posts', 'tab_posts', 'oneline_posts', 'pricing_plans',
'reviews', 'faq', 'stats', 'highlights', 'feature_intro', 'tilt_cases', 'slider',
'slider_media_combo', 'user_card', 'author_card', 'user_ranking', 'user_list',
];
$registry = WidgetRegistry::all();
if (count($registry) !== 37) {
$errors[] = 'Registry must contain exactly 37 widgets; got ' . count($registry) . '.';
}
if (array_keys($registry) !== $expected) {
$errors[] = 'Registry slugs or ordering differ from the frozen contract.';
}
$expected_areas = [
'all_top_fluid', 'all_bottom_fluid', 'all_sidebar_top', 'all_sidebar_bottom',
'home_top_fluid', 'home_top_content', 'home_bottom_content', 'home_bottom_fluid', 'home_sidebar',
'single_top_fluid', 'single_top_content', 'single_bottom_content', 'single_bottom_fluid', 'single_sidebar',
'cat_top_fluid', 'cat_top_content', 'cat_bottom_content', 'cat_bottom_fluid', 'cat_sidebar',
'tag_top_fluid', 'tag_top_content', 'tag_bottom_content', 'tag_bottom_fluid', 'tag_sidebar',
'search_top_fluid', 'search_top_content', 'search_bottom_content', 'search_bottom_fluid', 'search_sidebar',
'author_top_fluid', 'author_bottom_fluid', 'msg_top_fluid', 'msg_bottom_fluid',
'user_top_fluid', 'user_top_content', 'user_bottom_fluid',
'newposts_sidebar_top', 'newposts_sidebar_bottom', 'all_footer', 'mobile_nav_fluid',
];
if (WidgetAreas::static_ids() !== $expected_areas || count(WidgetAreas::static_ids()) !== 40) {
$errors[] = 'Widget area registry must contain the exact 40 static areas.';
}
$templates = WidgetTransfer::templates();
$expected_templates = ['article_home', 'article_list', 'links_navigation', 'article_sidebar', 'knowledge_landing'];
if (array_keys($templates) !== $expected_templates || count($templates) !== 5) {
$errors[] = 'Import/export must expose the exact five built-in templates.';
}
foreach ($templates as $template => $contract) {
foreach ((array) ($contract['sidebars'] ?? []) as $items) {
foreach ((array) $items as $item) {
if (!isset($registry[(string) ($item['widget'] ?? '')])) {
$errors[] = $template . ': template contains an unknown widget.';
}
}
}
}
$supported = FieldSchema::supported_types();
$walk = static function (array $fields, string $owner) use (&$walk, &$errors, $supported): void {
$ids = [];
foreach ($fields as $field) {
$id = (string) ($field['id'] ?? '');
$type = (string) ($field['type'] ?? 'text');
if (!in_array($type, $supported, true)) {
$errors[] = $owner . ': unsupported field type ' . $type . '.';
}
if ($id !== '') {
if (isset($ids[$id])) {
$errors[] = $owner . ': duplicate field id ' . $id . '.';
}
$ids[$id] = true;
}
if (!empty($field['fields']) && is_array($field['fields'])) {
$walk($field['fields'], $owner . '.' . ($id ?: $type));
}
}
};
foreach ($registry as $slug => $definition) {
if ($definition->id_base() !== 'aksrt_widget_' . $slug) {
$errors[] = $slug . ': invalid id_base.';
}
if (preg_match('/(?:bbs|forum|shop|cart|inventory|shipping)/i', $slug . ' ' . $definition->renderer())) {
$errors[] = $slug . ': forbidden forum/shop contract.';
}
$fields = $definition->fields();
$walk($fields, $slug);
$field_ids = array_column($fields, 'id');
$required_common = ['show_type', 'show_id_type', 'show_ids', 'layout_pin_pc', 'layout_bg'];
if (($definition->common()['animation'] ?? true)) {
$required_common[] = 'animation_in';
}
foreach ($required_common as $required) {
if (!in_array($required, $field_ids, true)) {
$errors[] = $slug . ': missing common field ' . $required . '.';
}
}
}
$renderers = RendererRegistry::all();
if (count($renderers) !== 37 || array_keys($renderers) !== $expected) {
$errors[] = 'Renderer registry must map the exact 37 frozen slugs.';
}
foreach ($renderers as $slug => $renderer) {
if (!is_callable([$renderer, 'render'])) {
$errors[] = $slug . ': renderer is not callable.';
}
}
$dynamic = ['posts', 'tab_posts', 'oneline_posts'];
foreach ($expected as $slug) {
if (RendererRegistry::supports_items($slug) !== in_array($slug, $dynamic, true)) {
$errors[] = $slug . ': invalid dynamic items capability.';
}
}
if (!defined('DAY_IN_SECONDS')) {
define('DAY_IN_SECONDS', 86400);
}
$query_args = PostQuery::arguments([
'count' => 7,
'category_ids' => '2,5',
'tag_ids' => '8,13',
'limit_day' => 30,
'orderby' => 'views',
'order' => 'ASC',
'pay_type' => 'video',
], 3);
if (($query_args['posts_per_page'] ?? 0) !== 7 || ($query_args['paged'] ?? 0) !== 3) {
$errors[] = 'Post query pagination contract failed.';
}
if (($query_args['category__in'] ?? []) !== [2, 5] || ($query_args['tag__in'] ?? []) !== [8, 13]) {
$errors[] = 'Post query taxonomy contract failed.';
}
if (($query_args['orderby'] ?? '') !== 'meta_value_num' || ($query_args['order'] ?? '') !== 'ASC') {
$errors[] = 'Post query ordering contract failed.';
}
$pay_query = json_encode($query_args['meta_query'] ?? []);
if (!is_string($pay_query) || !str_contains($pay_query, 'video')) {
$errors[] = 'Post query paid-content contract failed.';
}
$rest_source = (string) file_get_contents(dirname(__DIR__) . '/src/WidgetSystem/RestController.php');
foreach ([
"'permission_callback' => '__return_true'",
"'instance' => ['type' => 'string', 'required' => true",
"'page' => ['type' => 'integer'",
"'segment' => ['type' => 'string'",
'wp_get_sidebars_widgets()',
'$widget->get_settings()',
] as $contract) {
if (!str_contains($rest_source, $contract)) {
$errors[] = 'REST controller missing contract: ' . $contract;
}
}
if (preg_match('/\$_(?:GET|POST|REQUEST)/', $rest_source)) {
$errors[] = 'REST controller must use WP_REST_Request only.';
}
$frontend_files = [
dirname(__DIR__) . '/assets/widgets/frontend.css',
dirname(__DIR__) . '/assets/widgets/frontend.js',
];
foreach ($frontend_files as $frontend_file) {
if (!is_file($frontend_file) || filesize($frontend_file) === 0) {
$errors[] = 'Missing frontend widget asset: ' . basename($frontend_file);
}
}
$root = dirname(__DIR__);
$legacy_paths = [
'/src/Core/WidgetAjax.php',
'/src/Core/SetupPart1.php',
'/src/Widgets',
'/inc/modules/widgets-register.php',
'/inc/modules/widget-common.php',
'/inc/modules/widget-areas.php',
'/assets/css/frontend/widgets.css',
'/assets/css/frontend/widgets',
'/assets/css/frontend/widget-batch5.css',
'/assets/js/widget-set.js',
];
foreach ($legacy_paths as $legacy_path) {
if (file_exists($root . $legacy_path)) {
$errors[] = 'Legacy widget runtime remains: ' . $legacy_path;
}
}
$runtime_source = (string) file_get_contents($root . '/src/WidgetSystem/Runtime.php');
foreach (['WidgetAreas::class', 'WidgetRegistry::all()', 'register_widget(new SchemaWidget', 'unregister_core_widgets'] as $contract) {
if (!str_contains($runtime_source, $contract)) {
$errors[] = 'Widget runtime missing contract: ' . $contract;
}
}
$asset_source = (string) file_get_contents($root . '/src/WidgetSystem/AssetManager.php');
if (str_contains($asset_source, 'cdn.jsdelivr.net')) {
$errors[] = 'Widget assets must not use CDN dependencies.';
}
$admin_import_sources = (string) file_get_contents($root . '/src/Admin/ImportExportController.php')
. (string) file_get_contents($root . '/src/Admin/ImportExportControllerPart1.php');
foreach (['/widgets/export', '/widgets/import', '/widgets/templates', 'aksrt_widget_templates'] as $legacy_contract) {
if (str_contains($admin_import_sources, $legacy_contract)) {
$errors[] = 'Legacy widget import/export contract remains: ' . $legacy_contract;
}
}
$pinned_assets = [
'DPlayer.min.js' => '361689807fc1e856f868df91036e41195d2c255619bd1c5526849c5ad7491241',
'hls.min.js' => 'e876282a26116e323c8ed3e86bed3838a80dbbe3a2faa09f834bee86963ff760',
'flv.min.js' => '0668d36757cdc17804d5c0bd6fe8956d76370e73a733bf5bbc0eddbfbca2c6cc',
'dash.all.min.js' => '3e753969c16a2a78f420a25d24869f14721eb1d284418f9f22d35a31c3693cd9',
'vanilla-tilt.min.js' => 'de6fa8d3f40dbae2726a72f7d1ae46bb0588267af05355bef1d128f737f4a282',
];
foreach ($pinned_assets as $asset => $hash) {
$path = $root . '/assets/widgets/third-party/' . $asset;
if (!is_file($path)) {
$errors[] = 'Missing pinned widget dependency: ' . $asset;
} else {
$contents = (string) file_get_contents($path);
$raw_hash = hash('sha256', $contents);
$lf_hash = hash('sha256', str_replace("\r\n", "\n", $contents));
if ($raw_hash !== $hash && $lf_hash !== $hash) {
$errors[] = 'Pinned widget dependency changed: ' . $asset;
}
}
}
if (!is_file($root . '/assets/widgets/third-party/THIRD_PARTY.md')) {
$errors[] = 'Missing widget dependency license manifest.';
}
$posts = $registry['posts'];
$normalized = $posts->normalize([
'title' => '<script>alert(1)</script> 最新课程',
'show_type' => 'invalid',
'show_id_type' => 'show',
'show_ids' => '3, 3,9 bad -4',
'count' => 999,
'style' => 'broken',
'cat' => '4,-7',
]);
if ($normalized['count'] !== 20 || $normalized['style'] !== 'list' || $normalized['show_type'] !== 'all') {
$errors[] = 'Schema clamping or enum fallback failed.';
}
if ($normalized['show_ids'] !== '3,9,4' || $normalized['cat'] !== '4,-7') {
$errors[] = 'ID list normalization failed: ' . $normalized['show_ids'];
}
if (str_contains($normalized['title'], '<script>')) {
$errors[] = 'Text sanitization failed.';
}
$baseline_file = dirname(__DIR__) . '/.specs/baselines/widget-reference.json';
$baseline = json_decode((string) file_get_contents($baseline_file), true);
if (!is_array($baseline) || ($baseline['source_policy']['modern_widget_count'] ?? null) !== 37) {
$errors[] = 'Reference baseline is missing or invalid.';
} elseif (is_dir((string) ($baseline['source_root'] ?? ''))) {
foreach ((array) ($baseline['files'] ?? []) as $file) {
$path = rtrim((string) $baseline['source_root'], '\\/') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) $file['path']);
if (!is_file($path)) {
$errors[] = 'Reference file missing: ' . $path;
continue;
}
if (hash_file('sha256', $path) !== ($file['sha256'] ?? '')) {
$errors[] = 'Reference file changed: ' . $file['path'];
}
}
}
if ($errors) {
fwrite(STDERR, "Widget system audit failed:\n- " . implode("\n- ", $errors) . "\n");
exit(1);
}
echo "Widget system audit passed: 37 widgets, 40 areas, 5 templates, single runtime and pinned assets.\n";
+229
View File
@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/tests/widget-system/bootstrap.php';
use AKSRT\WidgetSystem\FieldSchema;
use AKSRT\WidgetSystem\WidgetRegistry;
$root = dirname(__DIR__);
$baseline_path = $root . '/.specs/baselines/widget-reference.json';
$baseline = json_decode((string) file_get_contents($baseline_path), true, 512, JSON_THROW_ON_ERROR);
if (($baseline['schema'] ?? '') !== 'aksrt.widget-reference.v1') {
throw new RuntimeException('Frozen widget reference baseline is invalid.');
}
$reference_root = rtrim((string) ($baseline['source_root'] ?? ''), '\\/');
$reference_hashes = [];
foreach ((array) ($baseline['files'] ?? []) as $file) {
$relative = (string) ($file['path'] ?? '');
$expected = (string) ($file['sha256'] ?? '');
$path = $reference_root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative);
if (!is_file($path) || hash_file('sha256', $path) !== $expected) {
throw new RuntimeException('Frozen reference changed or is missing: ' . $relative);
}
$reference_hashes[$relative] = $expected;
}
$group_sources = [
'general' => 'inc/widgets/widget-more.php',
'content' => 'inc/widgets/widget-posts.php',
'knowledge_product' => 'inc/widgets/widget-produck.php',
'slider' => 'inc/widgets/widget-slider.php',
'user' => 'inc/widgets/widget-user.php',
];
$viewports = [
['name' => 'desktop', 'width' => 1440, 'height' => 1100],
['name' => 'tablet', 'width' => 1024, 'height' => 1000],
['name' => 'mobile', 'width' => 390, 'height' => 844],
];
$modes = ['light', 'dark'];
$images = [
'https://fixtures.aksrt.test/media/course-cover-01.jpg',
'https://fixtures.aksrt.test/media/course-cover-02.jpg',
'https://fixtures.aksrt.test/media/course-cover-03.jpg',
];
function fixture_text(string $id, string $slug, int $index): string {
$labels = [
'question' => '购买后可以长期学习吗?',
'answer' => '可以,已购买内容会永久保存在你的学习中心。',
'name' => 'AKSRT 学员 ' . ($index + 1),
'author' => 'AKSRT 学员 ' . ($index + 1),
'role' => '知识产品体验官',
'price' => '¥99',
'value' => (string) (($index + 1) * 1280) . '+',
'label' => '累计学习人数',
'heading' => '从入门到精通的系统学习路径',
'description' => '固定内容用于组件 DOM、响应式和视觉差异验收。',
'content' => '结构清晰、案例完整,并持续更新配套资料。',
'title' => 'AKSRT 固定示例 ' . ($index + 1),
'text' => '保持好奇,持续创造。',
];
if (isset($labels[$id])) {
return $labels[$id];
}
if (str_contains($id, 'video')) {
return 'https://fixtures.aksrt.test/media/lesson.m3u8';
}
if (str_contains($id, 'url')) {
return 'https://fixtures.aksrt.test/' . $slug . '/' . ($index + 1);
}
if (str_contains($id, 'ids')) {
return str_contains($id, 'term') || str_contains($id, 'category') ? '101,102,103' : '';
}
return 'AKSRT ' . str_replace('_', ' ', $id) . ' ' . ($index + 1);
}
function fixture_value(array $field, string $slug, int $index, array $images) {
$type = (string) ($field['type'] ?? 'text');
$id = (string) ($field['id'] ?? '');
$default = $field['default'] ?? null;
if ($id === 'title') return 'AKSRT · ' . ucfirst(str_replace('_', ' ', $slug));
if ($id === 'subtitle') return '固定内容与交互状态视觉验收';
if ($id === 'title_highlight') return 'AKSRT';
if ($id === 'show_type') return 'all';
if ($id === 'show_id_type' || $id === 'show_ids') return '';
if ($id === 'layout_bg') {
return [
'light' => ['color' => '#ffffff', 'image' => '', 'position' => 'center center', 'repeat' => 'no-repeat', 'attachment' => 'scroll', 'size' => 'cover'],
'dark' => ['color' => '#1d1e24', 'image' => '', 'position' => 'center center', 'repeat' => 'no-repeat', 'attachment' => 'scroll', 'size' => 'cover'],
];
}
if ($type === 'group') {
$rows = [];
$count = min(3, max(1, (int) ($field['max_items'] ?? 3)));
for ($row = 0; $row < $count; $row++) {
$item = [];
foreach ((array) ($field['fields'] ?? []) as $child) {
$child_id = (string) ($child['id'] ?? '');
if ($child_id !== '') $item[$child_id] = fixture_value($child, $slug, $row, $images);
}
$rows[] = $item;
}
return $rows;
}
if (in_array($type, ['fieldset', 'accordion'], true)) {
$value = [];
foreach ((array) ($field['fields'] ?? []) as $child) {
$child_id = (string) ($child['id'] ?? '');
if ($child_id !== '') $value[$child_id] = fixture_value($child, $slug, $index, $images);
}
return $value;
}
if ($type === 'link') return ['url' => 'https://fixtures.aksrt.test/' . $slug, 'text' => '查看详情', 'target' => ''];
if ($type === 'upload') return $images[$index % count($images)];
if ($type === 'icon') return 'fa fas fa-graduation-cap';
if ($type === 'color') return '#667eea';
if ($type === 'spacing') return ['top' => 0, 'right' => 0, 'bottom' => 20, 'left' => 0];
if ($type === 'background') return ['color' => '#ffffff', 'image' => '', 'position' => 'center center', 'repeat' => 'no-repeat', 'attachment' => 'scroll', 'size' => 'cover'];
if ($type === 'switcher') return $default === null ? true : (bool) $default;
if (in_array($type, ['number', 'spinner', 'slider'], true)) return $default ?? ($field['min'] ?? 1);
if (in_array($type, ['radio', 'button_set', 'select', 'palette'], true)) return $default ?? (array_key_first((array) ($field['options'] ?? [])) ?? '');
if ($type === 'checkbox') return $default ?? [];
if ($id === 'title_link') return ['url' => 'https://fixtures.aksrt.test/' . $slug, 'text' => '查看更多', 'target' => ''];
if ($default !== null && $default !== '') return $default;
return fixture_text($id, $slug, $index);
}
$dynamic = ['posts', 'tab_posts', 'oneline_posts', 'hot_posts'];
$expandable = ['posts_toc', 'faq'];
$tabbed = ['tab_posts', 'feature_intro'];
$carousel = ['partners', 'notice', 'oneline_posts', 'reviews', 'slider', 'slider_media_combo'];
$components = [];
$matrix = [];
foreach (WidgetRegistry::all() as $slug => $definition) {
$settings = [];
foreach ($definition->fields() as $field) {
$id = (string) ($field['id'] ?? '');
$type = (string) ($field['type'] ?? 'text');
if ($id === '' || in_array($type, ['content', 'subheading', 'submessage'], true)) continue;
$settings[$id] = fixture_value($field, $slug, 0, $images);
}
$settings = $definition->normalize($settings);
$states = ['default', 'hover', 'focus'];
if (in_array($slug, $expandable, true)) $states[] = 'expanded';
if (in_array($slug, $tabbed, true)) $states[] = 'alternate-tab';
if (in_array($slug, $carousel, true)) $states[] = 'next-slide';
if (in_array($slug, $dynamic, true)) $states = array_merge($states, ['loading', 'empty', 'error']);
if ($slug === 'post_pay') $states = array_merge($states, ['guest-locked', 'member-locked', 'authorized', 'unconfigured']);
if ($slug === 'user_card') $states = array_merge($states, ['guest', 'signed-in']);
$source = $group_sources[$definition->group()] ?? 'inc/widgets/widget-index.php';
$components[$slug] = [
'id_base' => $definition->id_base(),
'name' => $definition->name(),
'group' => $definition->group(),
'size' => $definition->size(),
'reference' => ['file' => $source, 'sha256' => $reference_hashes[$source] ?? ''],
'field_ids' => array_values(array_filter(array_map(static fn(array $field): string => (string) ($field['id'] ?? ''), $definition->fields()))),
'settings' => $settings,
'root_selector' => '[data-aksrt-widget="' . $slug . '"]',
'states' => array_values(array_unique($states)),
];
foreach ($viewports as $viewport) {
foreach ($modes as $mode) {
$id = $slug . ':' . $viewport['name'] . ':' . $mode;
$matrix[$id] = [
'widget' => $slug,
'viewport' => $viewport,
'mode' => $mode,
'path' => 'tests/visual-results/' . $slug . '/' . $viewport['name'] . '-' . $mode . '.png',
];
}
}
}
$fixture = [
'schema' => 'aksrt.widget-golden.v1',
'captured_at' => (string) ($baseline['captured_at'] ?? ''),
'reference' => [
'source_root' => $reference_root,
'fingerprint' => hash('sha256', implode('', $reference_hashes)),
'files' => $reference_hashes,
'policy' => $baseline['source_policy'] ?? [],
],
'acceptance' => [
'dimension_delta_px_max' => 2,
'pixel_difference_percent_max' => 1.5,
'console_errors_max' => 0,
'unexpected_network_failures_max' => 0,
'a11y_critical_max' => 0,
'a11y_serious_max' => 0,
'reduced_motion_required' => true,
],
'dataset' => [
'site' => ['name' => 'AKSRT 知识库', 'url' => 'https://fixtures.aksrt.test'],
'terms' => [
['id' => 101, 'taxonomy' => 'category', 'name' => '产品设计', 'count' => 24],
['id' => 102, 'taxonomy' => 'category', 'name' => '前端开发', 'count' => 36],
['id' => 103, 'taxonomy' => 'post_tag', 'name' => '系统课程', 'count' => 18],
],
'users' => [
['id' => 201, 'name' => '林知夏', 'role' => '讲师', 'points' => 9680, 'followers' => 3280],
['id' => 202, 'name' => '周屿', 'role' => '作者', 'points' => 7420, 'followers' => 2160],
['id' => 203, 'name' => '陈墨', 'role' => '学员', 'points' => 5360, 'followers' => 980],
],
'posts' => [
['id' => 301, 'title' => '从零构建知识产品体系', 'pay_type' => 'reading', 'author' => 201, 'category' => 101],
['id' => 302, 'title' => '现代 WordPress 主题工程实践', 'pay_type' => 'video', 'author' => 202, 'category' => 102],
['id' => 303, 'title' => '高质量课程资料包', 'pay_type' => 'download', 'author' => 201, 'category' => 102],
['id' => 304, 'title' => '视觉案例图库', 'pay_type' => 'gallery', 'author' => 202, 'category' => 101],
['id' => 305, 'title' => '会员专属进阶课', 'pay_type' => 'vip', 'author' => 201, 'category' => 101],
['id' => 306, 'title' => '公开学习指南', 'pay_type' => 'no', 'author' => 203, 'category' => 102],
],
'comments' => [
['id' => 401, 'user' => 203, 'post' => 301, 'content' => '课程结构清晰,案例非常实用。'],
['id' => 402, 'user' => 202, 'post' => 302, 'content' => '对主题工程化讲解得很完整。'],
],
'payment_states' => ['guest-locked', 'member-locked', 'free', 'purchased', 'vip-authorized', 'unconfigured'],
'media' => ['images' => $images, 'video' => 'https://fixtures.aksrt.test/media/lesson.m3u8'],
],
'components' => $components,
'screenshot_matrix' => $matrix,
];
$output = $root . '/tests/fixtures/widget-golden-v1.json';
file_put_contents($output, json_encode($fixture, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n");
echo 'Generated widget golden fixture: ' . count($components) . ' components, ' . count($matrix) . " screenshots.\n";
+102
View File
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
function createClassList() {
const values = new Set();
return {
add(name) { values.add(name); },
remove(name) { values.delete(name); },
contains(name) { return values.has(name); },
toggle(name, force) {
if (force === undefined ? !values.has(name) : force) values.add(name);
else values.delete(name);
},
};
}
function createButton(attributes = {}) {
const listeners = new Map();
return {
addEventListener(type, listener) {
if (!listeners.has(type)) listeners.set(type, []);
listeners.get(type).push(listener);
},
getAttribute(name) { return attributes[name] || null; },
listenerCount(type) { return (listeners.get(type) || []).length; },
click() { (listeners.get('click') || []).forEach((listener) => listener()); },
};
}
function createModal() {
return {
hidden: true,
dataset: {},
classList: createClassList(),
querySelector() { return null; },
querySelectorAll() { return []; },
};
}
const autoFunHandlers = new Map();
const body = { classList: createClassList() };
const chargeButton = createButton();
const transferButton = createButton({ 'data-aksrt-transfer-open': 'balance' });
const chargeModal = createModal();
const transferModal = createModal();
let contentInjected = false;
const documentStub = {
body,
querySelector(selector) {
if (!contentInjected) return null;
if (selector === '[data-aksrt-charge-modal]') return chargeModal;
if (selector === '[data-aksrt-transfer-modal]') return transferModal;
return null;
},
querySelectorAll(selector) {
if (!contentInjected) return [];
if (selector === '[data-aksrt-charge-open]') return [chargeButton];
if (selector === '[data-aksrt-transfer-open]') return [transferButton];
return [];
},
};
globalThis.document = documentStub;
globalThis.window = {
aksrtData: {},
AksrtRequest: {
request() { return Promise.resolve({ ok: false, json: {} }); },
},
};
globalThis.jQuery = function () {
return {
off(name) { autoFunHandlers.delete(name); return this; },
on(name, handler) { autoFunHandlers.set(name, handler); return this; },
};
};
const testFile = fileURLToPath(new URL('../assets/js/credits.js', import.meta.url));
const source = readFileSync(testFile, 'utf8');
new Function(source)();
assert.ok(autoFunHandlers.has('auto_fun.aksrt-credits'), 'credits.js should own one namespaced auto_fun subscription');
contentInjected = true;
const rehydrateCredits = autoFunHandlers.get('auto_fun.aksrt-credits');
rehydrateCredits();
rehydrateCredits();
assert.equal(chargeButton.listenerCount('click'), 1, 'AJAX-injected recharge button should bind exactly once');
assert.equal(transferButton.listenerCount('click'), 1, 'AJAX-injected transfer button should bind exactly once');
chargeButton.click();
assert.equal(chargeModal.hidden, false, 'recharge click should open the AJAX-injected modal');
assert.equal(chargeModal.classList.contains('is-open'), true, 'recharge modal should receive its open state');
transferButton.click();
assert.equal(transferModal.hidden, false, 'transfer click should open the AJAX-injected modal');
assert.equal(transferModal.classList.contains('is-open'), true, 'transfer modal should receive its open state');
console.log('credits AJAX rehydrate regression: ok');
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/tests/widget-system/bootstrap.php';
use AKSRT\WidgetSystem\WidgetRegistry;
$root = dirname(__DIR__);
$fixture_path = $root . '/tests/fixtures/widget-golden-v1.json';
$fixture = json_decode((string) file_get_contents($fixture_path), true, 512, JSON_THROW_ON_ERROR);
$errors = [];
if (($fixture['schema'] ?? '') !== 'aksrt.widget-golden.v1') $errors[] = 'Invalid golden fixture schema.';
$components = (array) ($fixture['components'] ?? []);
$matrix = (array) ($fixture['screenshot_matrix'] ?? []);
if (array_keys($components) !== array_keys(WidgetRegistry::all()) || count($components) !== 37) $errors[] = 'Golden fixture must contain the exact 37 registry widgets.';
if (count($matrix) !== 222) $errors[] = 'Screenshot matrix must contain 37 × 3 × 2 = 222 entries.';
foreach ($components as $slug => $component) {
$definition = WidgetRegistry::get((string) $slug);
if ($definition === null) continue;
if (($component['id_base'] ?? '') !== $definition->id_base()) $errors[] = $slug . ': id_base mismatch.';
if (($component['settings'] ?? []) !== $definition->normalize((array) ($component['settings'] ?? []))) $errors[] = $slug . ': settings are not normalized.';
foreach (['default', 'hover', 'focus'] as $state) {
if (!in_array($state, (array) ($component['states'] ?? []), true)) $errors[] = $slug . ': missing state ' . $state . '.';
}
}
foreach ((array) ($fixture['reference']['files'] ?? []) as $relative => $expected) {
$path = rtrim((string) ($fixture['reference']['source_root'] ?? ''), '\\/') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) $relative);
if (!is_file($path) || hash_file('sha256', $path) !== $expected) $errors[] = 'Reference drift: ' . $relative;
}
$require_results = in_array('--require-results', $argv, true);
if ($require_results) {
$result_path = $root . '/tests/visual-results/manifest.json';
if (!is_file($result_path)) {
$errors[] = 'Visual result manifest is missing.';
} else {
$result = json_decode((string) file_get_contents($result_path), true, 512, JSON_THROW_ON_ERROR);
if (($result['schema'] ?? '') !== 'aksrt.widget-visual-results.v1') $errors[] = 'Invalid visual result schema.';
$screenshots = (array) ($result['screenshots'] ?? []);
foreach ($matrix as $id => $expected) {
$actual = (array) ($screenshots[$id] ?? []);
$path = $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) ($expected['path'] ?? ''));
if (!$actual || !is_file($path)) $errors[] = $id . ': screenshot result is missing.';
}
foreach ($components as $slug => $component) {
$states = (array) ($result['components'][$slug]['states'] ?? []);
foreach ((array) ($component['states'] ?? []) as $state) {
if (($states[$state] ?? '') !== 'pass') $errors[] = $slug . ': state not passed: ' . $state . '.';
}
}
$acceptance = (array) ($fixture['acceptance'] ?? []);
$metrics = (array) ($result['metrics'] ?? []);
if ((float) ($metrics['dimension_delta_px_max'] ?? INF) > (float) $acceptance['dimension_delta_px_max']) $errors[] = 'Dimension delta exceeds threshold.';
if ((float) ($metrics['pixel_difference_percent_max'] ?? INF) > (float) $acceptance['pixel_difference_percent_max']) $errors[] = 'Pixel difference exceeds threshold.';
if ((int) ($result['console']['errors'] ?? PHP_INT_MAX) > (int) $acceptance['console_errors_max']) $errors[] = 'Console contains errors.';
if ((int) ($result['network']['unexpected_failures'] ?? PHP_INT_MAX) > (int) $acceptance['unexpected_network_failures_max']) $errors[] = 'Network contains unexpected failures.';
if ((int) ($result['a11y']['critical'] ?? PHP_INT_MAX) > (int) $acceptance['a11y_critical_max']) $errors[] = 'Accessibility critical findings remain.';
if ((int) ($result['a11y']['serious'] ?? PHP_INT_MAX) > (int) $acceptance['a11y_serious_max']) $errors[] = 'Accessibility serious findings remain.';
if (empty($result['reduced_motion']['passed'])) $errors[] = 'Reduced-motion behavior was not verified.';
}
}
if ($errors) {
fwrite(STDERR, "Widget golden validation failed:\n- " . implode("\n- ", array_slice($errors, 0, 50)) . "\n");
exit(1);
}
echo 'Widget golden fixture passed: 37 components, 222 screenshot targets, frozen reference intact';
echo $require_results ? ", runtime results passed.\n" : ", runtime results pending.\n";