diff --git a/README.md b/README.md
index 773ae749..bb301b81 100644
--- a/README.md
+++ b/README.md
@@ -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 日上限、验证码等均依赖该函数)。
+
---
## 开发说明
diff --git a/assets/admin/src/schema/sections/users/auth.js b/assets/admin/src/schema/sections/users/auth.js
index 91e38620..079ed8e2 100644
--- a/assets/admin/src/schema/sections/users/auth.js
+++ b/assets/admin/src/schema/sections/users/auth.js
@@ -22,6 +22,9 @@ export const authSchema = {
invit_code_s: 'close',
invit_code_open_desc: '如您受到邀请,可填写推广码。部分推广码会有额外积分赠送',
invit_code_must_desc: '
本站需使用推广码注册
获取推广码请与客服联系
',
+ invite_code_s: 'close',
+ invite_code_open_desc: '请输入邀请码,注册成功后奖励将自动发放',
+ invite_code_must_desc: '本站需凭邀请码注册
获取邀请码请与客服联系
',
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: '本站需使用推广码注册
获取推广码请与客服联系
', 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: '本站需凭邀请码注册
获取邀请码请与客服联系
', dependsOn: { key: 'invite_code_s', value: 'must' }, help: '支持安全 HTML' },
+ ],
+ },
{
key: 'verification',
title: '登录与验证',
diff --git a/assets/js/auth.js b/assets/js/auth.js
index d5fcd8d8..d7c5fe07 100644
--- a/assets/js/auth.js
+++ b/assets/js/auth.js
@@ -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';
diff --git a/functions.php b/functions.php
index bfd1abbe..97280115 100644
--- a/functions.php
+++ b/functions.php
@@ -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();
}
diff --git a/src/Admin/Native/UsersMenus.php b/src/Admin/Native/UsersMenus.php
index 4f71b9eb..ab2b0e0c 100644
--- a/src/Admin/Native/UsersMenus.php
+++ b/src/Admin/Native/UsersMenus.php
@@ -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;
}
diff --git a/src/Admin/Native/UsersMenusInviteCode.php b/src/Admin/Native/UsersMenusInviteCode.php
new file mode 100644
index 00000000..f74c2dd7
--- /dev/null
+++ b/src/Admin/Native/UsersMenusInviteCode.php
@@ -0,0 +1,293 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ()
+
+
+
+ points=20&balance=30&vip=1&vip_time=5&level_integral=100
+ 93FW0XAY 无 没有奖励
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+
+ |
+
+
+
+
+ 'invit_code_open_desc', 'label' => '选填提示', 'type' => 'input', 'default' => '如您受到邀请,可填写推广码。部分推广码会有额外积分赠送'],
['key' => 'invit_code_must_desc', 'label' => '必填提示', 'type' => 'textarea', 'default' => '本站需使用推广码注册
获取推广码请与客服联系
'],
]],
+ ['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' => '本站需凭邀请码注册
获取邀请码请与客服联系
'],
+ ]],
['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' => [
diff --git a/src/Auth/ServicePart1a.php b/src/Auth/ServicePart1a.php
index aa8e2215..67a1f0f7 100644
--- a/src/Auth/ServicePart1a.php
+++ b/src/Auth/ServicePart1a.php
@@ -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() : '';
diff --git a/src/Auth/ServicePart1b.php b/src/Auth/ServicePart1b.php
index 39f144e2..deb2c60c 100644
--- a/src/Auth/ServicePart1b.php
+++ b/src/Auth/ServicePart1b.php
@@ -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()];
}
diff --git a/src/Core/DBMigratorPart2.php b/src/Core/DBMigratorPart2.php
index 2ab228bf..ffae75ec 100644
--- a/src/Core/DBMigratorPart2.php
+++ b/src/Core/DBMigratorPart2.php
@@ -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,
diff --git a/src/Core/DBMigratorPart3.php b/src/Core/DBMigratorPart3.php
index 31738354..04f9dc15 100644
--- a/src/Core/DBMigratorPart3.php
+++ b/src/Core/DBMigratorPart3.php
@@ -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',
diff --git a/src/Payment/InviteCode.php b/src/Payment/InviteCode.php
new file mode 100644
index 00000000..92b3ff72
--- /dev/null
+++ b/src/Payment/InviteCode.php
@@ -0,0 +1,663 @@
+ 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|\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}|\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>,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 $code_ids
+ * @return array>
+ */
+ 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\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;
+ }
+}
diff --git a/src/Settings/CreditsPaymentGroups.php b/src/Settings/CreditsPaymentGroups.php
index c71e46d0..ddecbf01 100644
--- a/src/Settings/CreditsPaymentGroups.php
+++ b/src/Settings/CreditsPaymentGroups.php
@@ -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();
+ }
+
/**
* 积分功能是否启用(仅积分系统开关)。
*/
diff --git a/src/Settings/CreditsRuntime.php b/src/Settings/CreditsRuntime.php
index f685af6e..9d9400ac 100644
--- a/src/Settings/CreditsRuntime.php
+++ b/src/Settings/CreditsRuntime.php
@@ -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 = __('本站需凭邀请码注册
获取邀请码请与客服联系
', '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 联合(该开关表示积分购买模式,非按次扣费)。
diff --git a/templates/parts/auth-content.php b/templates/parts/auth-content.php
index caca9f16..0bbcf171 100644
--- a/templates/parts/auth-content.php
+++ b/templates/parts/auth-content.php
@@ -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'
>
+ >
diff --git a/tests/widget-system/invite-code-contract.test.cjs b/tests/widget-system/invite-code-contract.test.cjs
new file mode 100644
index 00000000..3edc47ed
--- /dev/null
+++ b/tests/widget-system/invite-code-contract.test.cjs
@@ -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\(\)/)
+})