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 @@ + +
+

+

+ +

+
+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +

+
+
+ + + +
+
+ +
+ + + + + + + + + + + + + + +
+
+
+ + '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\(\)/) +})