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
@@ -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: '登录与验证',
+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';