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)
- 契约测试覆盖建表/消费竞态/导入导出/备注全链路
This commit is contained in:
2026-09-21 20:53:36 +08:00
parent 20387fdb06
commit 3dd3f71c97
16 changed files with 1285 additions and 1 deletions
+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';