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:
@@ -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 日上限、验证码等均依赖该函数)。
|
||||
|
||||
---
|
||||
|
||||
## 开发说明
|
||||
|
||||
@@ -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: '登录与验证',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 无 没有奖励 P86NPWKI points=10 AUF9D2B4 balance=30&vip=1&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) => ({
|
||||
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'
|
||||
})[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
|
||||
}
|
||||
}
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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() : '';
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 积分功能是否启用(仅积分系统开关)。
|
||||
*/
|
||||
|
||||
@@ -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 联合(该开关表示积分购买模式,非按次扣费)。
|
||||
|
||||
@@ -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' : ''); ?>">
|
||||
|
||||
@@ -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\(\)/)
|
||||
})
|
||||
Reference in New Issue
Block a user