user_id * * 注意:option 大小受限(默认非 autoload),10 万级用户没问题, * 百万级仍可走,性能瓶颈在 update_option 写入;此处采用懒写策略: * 仅在 get_or_create_code 生成新码时同步写入。 */ const OPT_REVERSE_MAP = 'aksrt_invite_code_map'; private static $instance = null; private function __construct() {} public static function get_instance(): self { if (null === self::$instance) self::$instance = new self(); return self::$instance; } public function init(): void { // 在请求最早期捕获邀请码 add_action('init', [$this, 'capture_invite_cookie'], 1); // 注册时记录邀请关系 add_action('user_register', [$this, 'on_user_register'], 20, 1); // 邮箱验证后兑现 add_action('aksrt_email_verified', [$this, 'on_email_verified'], 10, 1); // 用户删除时清理邀请码映射 add_action('delete_user', [$this, 'on_delete_user'], 10, 1); add_action('rest_api_init', [$this, 'register_routes']); } public function register_routes(): void { register_rest_route('aksrt/v1', '/invite/me', [ 'methods' => 'GET', 'callback' => [$this, 'rest_me'], 'permission_callback' => function () { return is_user_logged_in(); }, ]); register_rest_route('aksrt/v1', '/invite/list', [ 'methods' => 'GET', 'callback' => [$this, 'rest_list'], 'permission_callback' => function () { return is_user_logged_in(); }, 'args' => [ 'page' => [ 'type' => 'integer', 'default' => 1, 'minimum' => 1, ], 'per_page' => [ 'type' => 'integer', 'default' => 10, 'minimum' => 1, 'maximum' => 50, ], ], ]); } /** Capture the unified ?ref=XYZ referral parameter. */ public function capture_invite_cookie(): void { if (is_admin()) return; $raw_code = $_GET['ref'] ?? ''; if ($raw_code === '') return; // 邀请注册功能关闭时不处理 if (class_exists('\AKSRT\Settings\Settings') && !\AKSRT\Settings\Settings::invite_registration_enabled()) return; $code = sanitize_key(wp_unslash((string) $raw_code)); if ($code === '') return; // 校验邀请码是否对应真实用户 $inviter_id = $this->resolve_code($code); if ($inviter_id <= 0) return; // 已登录用户不能用自己的邀请码 if (is_user_logged_in() && $inviter_id === get_current_user_id()) return; // 不覆盖已存在的(防多次访问被覆盖) if (!empty($_COOKIE[self::COOKIE_NAME])) return; $expires = time() + DAY_IN_SECONDS * self::COOKIE_TTL_DAYS; // 不覆盖 secure 标记的检测 $secure = is_ssl(); // headers_sent 检查 if (!headers_sent()) { setcookie(self::COOKIE_NAME, $code, [ 'expires' => $expires, 'path' => COOKIEPATH ?: '/', 'domain' => COOKIE_DOMAIN, 'secure' => $secure, 'httponly' => true, 'samesite' => 'Lax', ]); $_COOKIE[self::COOKIE_NAME] = $code; } } /** 注册:写入 aksrt_invite 表 */ public function on_user_register(int $invitee_id): void { // 邀请注册功能关闭时不记录邀请关系 if (class_exists('\AKSRT\Settings\Settings') && !\AKSRT\Settings\Settings::invite_registration_enabled()) return; // 优先级 1:表单显式填写的邀请码(由 \AKSRT\Auth\Service 注入) $code = ''; $inviter_id = 0; if (class_exists('\AKSRT\Auth\Service')) { $ctx = \AKSRT\Auth\Service::take_pending_invite_context(); if (!empty($ctx['inviter_id']) && $ctx['inviter_id'] !== $invitee_id) { $inviter_id = (int) $ctx['inviter_id']; $code = (string) $ctx['code']; } } // 优先级 2:fallback 到 cookie if ($inviter_id <= 0) { if (empty($_COOKIE[self::COOKIE_NAME])) return; $code = sanitize_key((string) $_COOKIE[self::COOKIE_NAME]); if ($code === '') return; $inviter_id = $this->get_user_by_code($code); } if ($inviter_id <= 0 || $inviter_id === $invitee_id) return; $this->record_invite_registration($inviter_id, $invitee_id, $code, 'registered'); // 清 cookie,避免再次触发 if (!headers_sent()) { setcookie(self::COOKIE_NAME, '', time() - 3600, COOKIEPATH ?: '/', COOKIE_DOMAIN); } // 若配置不要求邮箱验证就立即兑现 $invite_cfg = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_invite_reward_settings() : [ 'require_verify' => true, ]; if (empty($invite_cfg['require_verify'])) { $this->reward_pair($inviter_id, $invitee_id); } } /** 邮箱验证后兑现奖励(钩子由 \AKSRT\Auth\Service 触发,可后续接入) */ public function on_email_verified(int $user_id): void { // 邀请注册功能关闭时不兑现奖励 if (class_exists('\AKSRT\Settings\Settings') && !\AKSRT\Settings\Settings::invite_registration_enabled()) return; global $wpdb; $table = \AKSRT\Core\DBMigrator::table('invite'); if (!$table) return; $row = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$table} WHERE invitee_id = %d AND status IN ('registered', 'verified') LIMIT 1", $user_id ), ARRAY_A); if (!$row) return; $wpdb->update( $table, ['status' => 'verified'], ['id' => (int) $row['id']], ['%s'], ['%d'] ); if ((int) $row['inviter_id'] <= 0) { return; } $this->reward_pair((int) $row['inviter_id'], $user_id); } /** 用户被删除时清理邀请码映射 */ public function on_delete_user(int $user_id): void { $code = (string) get_user_meta($user_id, self::META_INVITE_CODE, true); if ($code === '') return; $map = $this->load_reverse_map(); if (isset($map[$code]) && (int) $map[$code] === $user_id) { unset($map[$code]); update_option(self::OPT_REVERSE_MAP, $map, false); } } /** 双向发放奖励 */ private function reward_pair(int $inviter_id, int $invitee_id): void { if (!class_exists('AKSRT\\Credits\\Credits') || !class_exists('\AKSRT\Settings\Settings')) return; global $wpdb; $table = \AKSRT\Core\DBMigrator::table('invite'); if (!$table) return; $claimed = $wpdb->update( $table, ['status' => 'rewarding'], ['inviter_id' => $inviter_id, 'invitee_id' => $invitee_id, 'status' => 'verified'], ['%s'], ['%d', '%d', '%s'] ); if ($claimed !== 1) return; $invite_cfg = \AKSRT\Settings\Settings::get_invite_reward_settings(); if (empty($invite_cfg['enabled']) || !$this->ip_daily_check($invite_cfg)) { $wpdb->update($table, ['status' => 'verified'], ['inviter_id' => $inviter_id, 'invitee_id' => $invitee_id], ['%s'], ['%d', '%d']); return; } $inviter_amount = (int) ($invite_cfg['inviter_amount'] ?? 0); $invitee_amount = (int) ($invite_cfg['invitee_amount'] ?? 0); $level_integral = max(0, (int) ($invite_cfg['invit_reward_level'] ?? 0)); if ($inviter_amount > 0) { Credits::add($inviter_id, $inviter_amount, 'invite', ['type' => 'user', 'id' => $invitee_id]); } if ($invitee_amount > 0) { Credits::add($invitee_id, $invitee_amount, 'invitee', ['type' => 'user', 'id' => $inviter_id]); } if ($level_integral > 0 && class_exists('\AKSRT\User\Level')) { \AKSRT\User\Level::add($inviter_id, 'invitation', $level_integral, true); } // 标记 rewarded $wpdb->update( $table, [ 'status' => 'rewarded', 'rewarded_at' => current_time('mysql', true), ], [ 'inviter_id' => $inviter_id, 'invitee_id' => $invitee_id, ], ['%s', '%s'], ['%d', '%d'] ); } /** 同 IP 当日邀请兑现次数限制 */ private function ip_daily_check(array $invite_cfg): bool { $cap = (int) ($invite_cfg['ip_daily_cap'] ?? 3); if ($cap <= 0) return true; $ip = function_exists('aksrt_get_client_ip') ? aksrt_get_client_ip() : ''; if (!$ip) return true; global $wpdb; $log_table = \AKSRT\Core\DBMigrator::table('credit_log'); if (!$log_table) return true; $today_utc = (new \DateTimeImmutable('today', wp_timezone()))->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s'); $count = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$log_table} WHERE reason = 'invite' AND ip = %s AND created_at >= %s", $ip, $today_utc )); return $count < $cap; } /** 取或生成用户的邀请码 */ public function get_or_create_code(int $user_id): string { $code = (string) get_user_meta($user_id, self::META_INVITE_CODE, true); // 邀请码长度(后台可配置,4-12,默认 6) $code_length = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::invite_code_length() : max(4, min(12, (int) aksrt_get_setting('invite_code_length', 6))); // 兼容配置变更:已有码只要长度合理就复用 if ($code !== '' && strlen($code) <= $code_length + 2) return $code; // 生成唯一码 $alphabet = 'abcdefghjkmnpqrstuvwxyz23456789'; $map = $this->load_reverse_map(); for ($i = 0; $i < 5; $i++) { $code = ''; for ($j = 0; $j < $code_length; $j++) { $code .= $alphabet[random_int(0, strlen($alphabet) - 1)]; } if (!isset($map[$code]) && !$this->code_exists_legacy($code)) break; } update_user_meta($user_id, self::META_INVITE_CODE, $code); // 同步反向映射 $map[$code] = $user_id; update_option(self::OPT_REVERSE_MAP, $map, false); return $code; } /** * 公开 API:根据邀请码反查邀请人 user_id(不存在返回 0)。 * 给注册流程在 wp_create_user 之前做"邀请码是否合法"的预校验用。 */ /** * Resolve affiliate / invite referrer for a buyer (zibll-style). * * Priority: * 1. Logged-in: inviter_id from aksrt_invite table for this invitee * 2. Cookie invite code → resolve_code * * Respects pay_rebate_self_s when available (via affiliate settings). */ public function get_referrer(int $user_id = 0): int { $uid = $user_id > 0 ? $user_id : get_current_user_id(); $referrer_id = 0; if ($uid > 0) { global $wpdb; $table = \AKSRT\Core\DBMigrator::table('invite'); if ($table) { $referrer_id = (int) $wpdb->get_var($wpdb->prepare( "SELECT inviter_id FROM {$table} WHERE invitee_id = %d AND inviter_id > 0 ORDER BY id DESC LIMIT 1", $uid )); } } if ($referrer_id <= 0) { $code = ''; if (!empty($_COOKIE[self::COOKIE_NAME])) { $code = sanitize_key((string) $_COOKIE[self::COOKIE_NAME]); } if ($code !== '') { $referrer_id = $this->resolve_code($code); } } if ($referrer_id <= 0) { return 0; } // Self-purchase rebate gate (default allow, matching pay_rebate_self_s default true). $allow_self = true; if (class_exists('\AKSRT\Settings\Settings') && method_exists('\AKSRT\Settings\Settings', 'get_payment_affiliate_settings')) { $aff = \AKSRT\Settings\Settings::get_payment_affiliate_settings(); $allow_self = !isset($aff['self_s']) || !empty($aff['self_s']); } if ($uid > 0 && $referrer_id === $uid && !$allow_self) { return 0; } return $referrer_id; } public function resolve_code(string $code): int { $code = sanitize_key($code); if ($code === '') return 0; return $this->get_user_by_code($code); } /** * 校验邀请码是否合法。 * * 用户邀请码与后台批量生成的独立邀请码都视为有效。 */ public function validate_code(string $code): bool { $code = sanitize_key($code); if ($code === '') { return false; } if ($this->get_user_by_code($code) > 0) { return true; } return $this->find_standalone_code_index($code) >= 0; } /** * 注册完成后处理邀请码。 * * - 用户邀请码:沿用 inviter/invitee canonical 关系 * - 独立邀请码:记录使用人并标记为已消耗,不参与奖励 */ public function handle_registration_invite(int $invitee_id, string $invite_code): void { $invitee_id = (int) $invitee_id; $invite_code = sanitize_key($invite_code); if ($invitee_id <= 0 || $invite_code === '') { return; } if (class_exists('\AKSRT\Settings\Settings') && !\AKSRT\Settings\Settings::invite_registration_enabled()) { return; } $inviter_id = $this->get_user_by_code($invite_code); if ($inviter_id > 0) { if ($inviter_id === $invitee_id) { return; } $this->record_invite_registration($inviter_id, $invitee_id, $invite_code, 'registered'); return; } $index = $this->find_standalone_code_index($invite_code); if ($index < 0) { return; } $requires_verify = class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::registration_requires_email_verification(); $status = $requires_verify ? 'registered' : 'verified'; if ($this->record_invite_registration(0, $invitee_id, $invite_code, $status)) { $this->mark_standalone_code_used($invite_code, $invitee_id); } } /** 反查邀请码对应的用户(O(1) option 查表,回退 user_meta 扫描) */ private function get_user_by_code(string $code): int { if ($code === '') return 0; $map = $this->load_reverse_map(); if (isset($map[$code])) { $uid = (int) $map[$code]; if ($uid > 0) { // 校验:确保该用户的 meta 仍然指向该 code(用户可能已被删除或 meta 被清空) $meta_code = (string) get_user_meta($uid, self::META_INVITE_CODE, true); if ($meta_code === $code) { return $uid; } // 失配 → 修正映射 unset($map[$code]); update_option(self::OPT_REVERSE_MAP, $map, false); } } // 回退 + 自愈:扫描 user_meta(应只在迁移期或映射损坏时触发) $uid = $this->code_exists_legacy($code); if ($uid > 0) { $map[$code] = $uid; update_option(self::OPT_REVERSE_MAP, $map, false); } return $uid; } private function load_reverse_map(): array { $map = get_option(self::OPT_REVERSE_MAP, []); return is_array($map) ? $map : []; } /** * 写入邀请关系记录。 */ private function record_invite_registration(int $inviter_id, int $invitee_id, string $code, string $status = 'registered'): bool { global $wpdb; $table = \AKSRT\Core\DBMigrator::table('invite'); if (!$table || $invitee_id <= 0 || $code === '') { return false; } $existing_code = $wpdb->get_var($wpdb->prepare( "SELECT id FROM {$table} WHERE invite_code = %s LIMIT 1", $code )); if ($existing_code) { return false; } $existing_invitee = $wpdb->get_var($wpdb->prepare( "SELECT id FROM {$table} WHERE invitee_id = %d LIMIT 1", $invitee_id )); if ($existing_invitee) { return false; } $result = $wpdb->insert( $table, [ 'inviter_id' => max(0, $inviter_id), 'invitee_id' => $invitee_id, 'invite_code' => $code, 'status' => $status, 'created_at' => current_time('mysql', true), ], ['%d', '%d', '%s', '%s', '%s'] ); return (bool) $result; } private function load_standalone_codes(): array { $codes = get_option(self::OPT_STANDALONE_CODES, []); return is_array($codes) ? array_values($codes) : []; } private function save_standalone_codes(array $codes): void { update_option(self::OPT_STANDALONE_CODES, array_values($codes), false); } private function normalize_standalone_code(array $entry): array { $code = sanitize_key((string) ($entry['code'] ?? '')); $used_by = (int) ($entry['used_by'] ?? 0); $used = !empty($entry['used']) || $used_by > 0; return [ 'code' => $code, 'created_at' => sanitize_text_field((string) ($entry['created_at'] ?? '')), 'used' => $used, 'used_by' => $used_by, 'used_at' => sanitize_text_field((string) ($entry['used_at'] ?? '')), 'disabled' => !empty($entry['disabled']), 'note' => sanitize_textarea_field((string) ($entry['note'] ?? '')), 'batch_id' => sanitize_text_field((string) ($entry['batch_id'] ?? '')), 'created_by' => (int) ($entry['created_by'] ?? 0), ]; } private function find_standalone_code_index(string $code): int { $code = sanitize_key($code); $codes = $this->load_standalone_codes(); foreach ($codes as $index => $entry) { $normalized = $this->normalize_standalone_code((array) $entry); if ($normalized['code'] !== $code) { continue; } if ($normalized['disabled'] || $normalized['used']) { return -1; } return $index; } return -1; } private function mark_standalone_code_used(string $code, int $invitee_id): void { $code = sanitize_key($code); $codes = $this->load_standalone_codes(); foreach ($codes as $index => $entry) { $normalized = $this->normalize_standalone_code((array) $entry); if ($normalized['code'] !== $code) { continue; } $normalized['used'] = true; $normalized['used_by'] = $invitee_id; $normalized['used_at'] = current_time('mysql', true); $codes[$index] = $normalized; $this->save_standalone_codes($codes); return; } } /** 旧路径:通过 user_meta 反查(保留作为自愈兜底) */ private function code_exists_legacy(string $code): int { $users = get_users([ 'meta_key' => self::META_INVITE_CODE, 'meta_value' => $code, 'number' => 1, 'fields' => 'ID', ]); if (empty($users)) return 0; return (int) $users[0]; } private function code_exists(string $code): bool { return $this->get_user_by_code($code) > 0; } // ---------- REST ---------- public function rest_me(\WP_REST_Request $request) { if (class_exists('\AKSRT\Settings\Settings') && !\AKSRT\Settings\Settings::invite_registration_enabled()) { return new \WP_REST_Response(['code' => 'error', 'message' => '邀请注册功能已关闭'], 403); } $user_id = get_current_user_id(); $code = $this->get_or_create_code($user_id); global $wpdb; $table = \AKSRT\Core\DBMigrator::table('invite'); $stats = [ 'registered' => 0, 'verified' => 0, 'rewarded' => 0, ]; if ($table) { $rows = $wpdb->get_results($wpdb->prepare( "SELECT status, COUNT(*) AS c FROM {$table} WHERE inviter_id = %d GROUP BY status", $user_id ), ARRAY_A); if (is_array($rows)) { foreach ($rows as $r) { $status = (string) $r['status']; $count = (int) $r['c']; // The stored status is terminal; dashboard stats are milestone totals. if ($status === 'registered') { $stats['registered'] += $count; } elseif ($status === 'verified') { $stats['registered'] += $count; $stats['verified'] += $count; } elseif ($status === 'rewarded') { $stats['registered'] += $count; $stats['verified'] += $count; $stats['rewarded'] += $count; } } } } $invite_url = add_query_arg('ref', $code, home_url('/')); return new \WP_REST_Response([ 'code' => $code, 'invite_url' => $invite_url, 'stats' => $stats, ], 200); } public function rest_list(\WP_REST_Request $request) { if (class_exists('\AKSRT\Settings\Settings') && !\AKSRT\Settings\Settings::invite_registration_enabled()) { return new \WP_REST_Response(['code' => 'error', 'message' => '邀请注册功能已关闭'], 403); } global $wpdb; $user_id = get_current_user_id(); $page = max(1, (int) $request->get_param('page')); $per_page = min(50, max(1, (int) $request->get_param('per_page') ?: 10)); $table = \AKSRT\Core\DBMigrator::table('invite'); if (!$table) { return new \WP_REST_Response(['items' => [], 'total' => 0], 200); } $total = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE inviter_id = %d", $user_id )); $total_pages = max(1, (int) ceil($total / $per_page)); $page = min($page, $total_pages); $offset = ($page - 1) * $per_page; $rows = $wpdb->get_results($wpdb->prepare( "SELECT * FROM {$table} WHERE inviter_id = %d ORDER BY created_at DESC LIMIT %d OFFSET %d", $user_id, $per_page, $offset ), ARRAY_A); $items = []; foreach ((array) $rows as $r) { $invitee = $r['invitee_id'] ? get_userdata((int) $r['invitee_id']) : null; $items[] = [ 'id' => (int) $r['id'], 'invitee_id' => (int) $r['invitee_id'], 'invitee_name' => $invitee ? $invitee->display_name : '', 'invitee_avatar' => $invitee ? get_avatar_url((int) $invitee->ID, ['size' => 64]) : '', 'status' => (string) $r['status'], 'created_at' => (string) $r['created_at'], 'rewarded_at' => (string) $r['rewarded_at'], ]; } return new \WP_REST_Response([ 'items' => $items, 'total' => $total, 'page' => $page, 'per_page' => $per_page, 'total_pages' => $total_pages, ], 200); } }