feature_enabled()) { return; } $raw = isset($_GET['ref']) ? sanitize_key(wp_unslash((string) $_GET['ref'])) : ''; if ($raw === '') { return; } $promoter_id = $this->resolve_promoter($raw); if ($promoter_id <= 0 || (is_user_logged_in() && $promoter_id === get_current_user_id())) { return; } if ($this->cookie_ref() !== '') { return; } if (!headers_sent()) { $ttl_days = (int) ($this->shared_settings()['window_days'] ?? 30); setcookie(self::COOKIE, $raw, [ 'expires' => time() + DAY_IN_SECONDS * max(1, min(365, $ttl_days)), 'path' => COOKIEPATH ?: '/', 'domain' => COOKIE_DOMAIN, 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax', ]); $_COOKIE[self::COOKIE] = $raw; } } /** @return array{ref:string,valid:bool,submitted_valid:bool,conflict:bool,source:string} */ public function prepare_registration(string $submitted_ref): array { self::$registration_notice = ''; $submitted_ref = sanitize_key($submitted_ref); $cookie_ref = $this->cookie_ref(); $submitted_valid = $submitted_ref !== '' && $this->resolve_promoter($submitted_ref) > 0; $conflict = $cookie_ref !== '' && $submitted_ref !== '' && $submitted_ref !== $cookie_ref; if ($conflict) { self::$registration_notice = __('检测到不同的推广码,已按首次访问的推广关系绑定。', 'aksrt'); } $ref = $cookie_ref !== '' ? $cookie_ref : ($submitted_valid ? $submitted_ref : ''); self::$pending_registration_ref = $ref; return [ 'ref' => $ref, 'valid' => $ref !== '', 'submitted_valid' => $submitted_valid, 'conflict' => $conflict, 'source' => $cookie_ref !== '' ? 'cookie' : ($submitted_valid ? 'submitted' : 'none'), ]; } public function clear_registration_context(): void { self::$pending_registration_ref = ''; } public function take_registration_notice(): string { $notice = self::$registration_notice; self::$registration_notice = ''; return $notice; } public function bind_registered_user(int $invitee_id): void { if ($invitee_id <= 0 || !$this->feature_enabled()) { self::$pending_registration_ref = ''; return; } $ref = self::$pending_registration_ref !== '' ? self::$pending_registration_ref : $this->cookie_ref(); self::$pending_registration_ref = ''; $promoter_id = $this->resolve_promoter($ref); if ($ref === '' || $promoter_id <= 0 || $promoter_id === $invitee_id) { return; } global $wpdb; $table = \AKSRT\Core\DBMigrator::table('affiliate_relation'); if ($table === '') { return; } $existing = (int) $wpdb->get_var($wpdb->prepare( "SELECT id FROM {$table} WHERE invitee_id = %d LIMIT 1", $invitee_id )); if ($existing > 0) { $this->clear_cookie(); return; } $reward = $this->reward_settings(); $now = current_time('mysql', true); $inserted = $wpdb->insert($table, [ 'referral_code' => $ref, 'promoter_id' => $promoter_id, 'invitee_id' => $invitee_id, 'first_touch_at' => $now, 'bound_at' => $now, 'status' => 'bound', 'reward_status' => !empty($reward['enabled']) ? 'pending' : 'skipped', 'registration_ip' => $this->client_ip(), 'created_at' => $now, ], ['%s', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s']); if ($inserted) { $this->clear_cookie(); if (!empty($reward['enabled']) && (empty($reward['require_verify']) || $this->is_verified($invitee_id))) { $this->reward_registered_user($invitee_id); } } } public function reward_registered_user(int $invitee_id): void { $settings = $this->reward_settings(); if ($invitee_id <= 0) { return; } global $wpdb; $table = \AKSRT\Core\DBMigrator::table('affiliate_relation'); if ($table === '') { return; } if (empty($settings['enabled'])) { $wpdb->update( $table, ['reward_status' => 'skipped'], ['invitee_id' => $invitee_id, 'reward_status' => 'pending'], ['%s'], ['%d', '%s'] ); return; } if (!empty($settings['require_verify']) && !$this->is_verified($invitee_id)) { return; } $row = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$table} WHERE invitee_id = %d AND status = 'bound'", $invitee_id ), ARRAY_A); if (!is_array($row) || (string) ($row['reward_status'] ?? 'pending') !== 'pending') { return; } $relation_id = (int) $row['id']; $promoter_id = (int) $row['promoter_id']; if ($promoter_id <= 0) { $wpdb->update($table, ['reward_status' => 'skipped'], ['id' => $relation_id, 'reward_status' => 'pending'], ['%s'], ['%d', '%s']); return; } $promoter_amount = max(0, (int) ($settings['promoter_amount'] ?? 0)); $referred_amount = max(0, (int) ($settings['referred_amount'] ?? 0)); $entries = []; if ($promoter_amount > 0) { $entries[] = ['user_id' => $promoter_id, 'asset' => \AKSRT\Ledger\AssetType::POINTS->value, 'delta_minor' => $promoter_amount, 'entry_type' => 'credit', 'counterparty_user_id' => $invitee_id, 'remark' => __('推广注册奖励', 'aksrt')]; } if ($referred_amount > 0) { $entries[] = ['user_id' => $invitee_id, 'asset' => \AKSRT\Ledger\AssetType::POINTS->value, 'delta_minor' => $referred_amount, 'entry_type' => 'credit', 'counterparty_user_id' => $promoter_id, 'remark' => __('推广注册新用户奖励', 'aksrt')]; } if ($entries === []) { $wpdb->update($table, ['reward_status' => 'rewarded', 'rewarded_at' => current_time('mysql', true)], ['id' => $relation_id, 'reward_status' => 'pending'], ['%s', '%s'], ['%d', '%s']); return; } $level_points = max(0, (int) ($settings['level_points'] ?? 0)); $command = new \AKSRT\Ledger\LedgerCommand( \AKSRT\Ledger\OperationKey::deterministic('referral-reward', [$relation_id]), 'referral', $entries, 'referral', (string) $relation_id, 0, ['invitee_id' => $invitee_id, 'promoter_id' => $promoter_id] ); $guard = function () use ($wpdb, $table, $relation_id, $settings, $row) { $locked = $wpdb->get_row($wpdb->prepare("SELECT reward_status FROM {$table} WHERE id = %d FOR UPDATE", $relation_id), ARRAY_A); if (!is_array($locked) || (string) $locked['reward_status'] !== 'pending') { return new \WP_Error('aksrt_referral_state', __('推广奖励状态已变化', 'aksrt'), ['status' => 409]); } return $this->ip_daily_check($row, $settings) ? true : new \WP_Error('aksrt_referral_daily_limit', __('当前网络今日推广奖励次数已达上限', 'aksrt'), ['status' => 409]); }; $mutation = function () use ($wpdb, $table, $relation_id, $level_points, $promoter_id) { if ($level_points > 0 && class_exists('\AKSRT\User\Level')) { \AKSRT\User\Level::add($promoter_id, 'referral', $level_points, true); } $updated = $wpdb->update( $table, ['reward_status' => 'rewarded', 'rewarded_at' => current_time('mysql', true)], ['id' => $relation_id, 'reward_status' => 'pending'], ['%s', '%s'], ['%d', '%s'] ); return $updated === 1 ? true : new \WP_Error('aksrt_referral_update', __('推广奖励状态更新失败', 'aksrt'), ['status' => 500]); }; $result = \AKSRT\Ledger\LedgerService::get_instance()->post($command, $guard, $mutation); if (!is_wp_error($result) && !$result->replayed) { do_action('aksrt_referral_rewarded', $relation_id, $promoter_id, $invitee_id); } } public function create_link(int $user_id, string $url = ''): array { $record = $this->code_record($user_id); if ($record['status'] === 'missing' && $this->feature_enabled()) { $code = $this->generate_code($user_id, false); $record = ['code' => $code, 'status' => $code !== '' ? self::STATUS_ACTIVE : 'missing']; } $target = $url !== '' ? $url : home_url('/'); $active_code = $record['status'] === self::STATUS_ACTIVE ? $record['code'] : ''; return [ 'ref' => $active_code, 'url' => $active_code !== '' ? add_query_arg('ref', $active_code, $target) : '', 'status' => $record['status'], ]; } public function get_or_create_code(int $user_id): string { return (string) ($this->create_link($user_id)['ref'] ?? ''); } public function validate_ref(string $ref): bool { return $this->resolve_promoter($ref) > 0; } public function resolve(int $user_id = 0): int { $user_id = $user_id > 0 ? $user_id : get_current_user_id(); if ($user_id <= 0) { return 0; } global $wpdb; $table = \AKSRT\Core\DBMigrator::table('affiliate_relation'); $bound = $table !== '' ? (int) $wpdb->get_var($wpdb->prepare( "SELECT promoter_id FROM {$table} WHERE invitee_id = %d AND status = 'bound' LIMIT 1", $user_id )) : 0; return $bound > 0 && $bound !== $user_id ? $bound : 0; } public function stats(int $promoter_id): array { global $wpdb; $table = \AKSRT\Core\DBMigrator::table('affiliate_relation'); if ($promoter_id <= 0 || $table === '') { return ['promoters' => 0, 'effective_payers' => 0]; } $promoters = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE promoter_id = %d AND status = 'bound'", $promoter_id )); $ledger = \AKSRT\Core\DBMigrator::table('commission_ledger'); $payers = $ledger !== '' ? (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(DISTINCT source_user_id) FROM {$ledger} WHERE promoter_id = %d AND original_amount > 0", $promoter_id )) : 0; return ['promoters' => $promoters, 'effective_payers' => $payers]; } /** @return array{items:array>,total:int,page:int,per_page:int,max_pages:int} */ public function list_referred_users(int $promoter_id, int $page = 1, int $per_page = 10): array { global $wpdb; $table = \AKSRT\Core\DBMigrator::table('affiliate_relation'); $page = max(1, $page); $per_page = max(1, min(50, $per_page)); if ($promoter_id <= 0 || $table === '') { return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $per_page, 'max_pages' => 1]; } $total = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE promoter_id=%d AND status='bound' AND invitee_id > 0", $promoter_id )); $rows = $wpdb->get_results($wpdb->prepare( "SELECT invitee_id, bound_at, status FROM {$table} WHERE promoter_id=%d AND status='bound' AND invitee_id > 0 ORDER BY id DESC LIMIT %d OFFSET %d", $promoter_id, $per_page, ($page - 1) * $per_page ), ARRAY_A); $items = []; foreach ((array) $rows as $row) { $user_id = (int) ($row['invitee_id'] ?? 0); $user = $user_id > 0 ? get_userdata($user_id) : null; if (!$user instanceof \WP_User) { continue; } $items[] = [ 'user_id' => $user_id, 'name' => $user->display_name, 'avatar' => get_avatar_url($user_id, ['size' => 64]), 'bound_at' => (string) ($row['bound_at'] ?? ''), 'status' => (string) ($row['status'] ?? 'bound'), ]; } return [ 'items' => $items, 'total' => $total, 'page' => $page, 'per_page' => $per_page, 'max_pages' => max(1, (int) ceil($total / $per_page)), ]; } public function register_routes(): void { register_rest_route('aksrt/v1', '/revenue/me', [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'rest_me'], 'permission_callback' => static fn() => is_user_logged_in(), ]); register_rest_route('aksrt/v1', '/revenue/codes', [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'rest_codes'], 'permission_callback' => [$this, 'can_manage_codes'], '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' => 'all', 'enum' => ['all', self::STATUS_ACTIVE, self::STATUS_DISABLED, 'missing'], ], ], ]); foreach (['generate', 'disable', 'regenerate'] as $action) { register_rest_route('aksrt/v1', '/revenue/codes/(?P\d+)/' . $action, [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => function (\WP_REST_Request $request) use ($action) { return $this->rest_mutate_code($request, $action); }, 'permission_callback' => [$this, 'can_manage_codes'], 'args' => [ 'user_id' => ['type' => 'integer', 'required' => true, 'minimum' => 1], ], ]); } } public function can_manage_codes(): bool { return current_user_can('edit_users'); } public function rest_me(): \WP_REST_Response { $user_id = get_current_user_id(); $link = $this->create_link($user_id); $stats = $this->stats($user_id); $affiliate = \AKSRT\Settings\Settings::get_affiliate_settings(); $registration = $this->registration_settings(); $reward = $this->reward_settings(); return new \WP_REST_Response([ 'ref' => $link['ref'], 'url' => $link['url'], 'status' => $link['status'], 'features' => [ 'registration' => !empty($registration['enabled']), 'points_reward' => !empty($reward['enabled']), 'affiliate' => !empty($affiliate['enabled']), ], 'current_ratio' => (int) \AKSRT\Settings\Settings::affiliate_ratio_for_user($user_id), 'ratios' => (array) ($affiliate['ratios'] ?? []), 'order_types' => (array) ($affiliate['order_types'] ?? ['membership', 'content']), 'rules' => [ 'window_days' => (int) ($this->shared_settings()['window_days'] ?? 30), 'settlement_days' => (int) ($affiliate['settlement_days'] ?? 7), ], 'stats' => $stats, ], 200); } public function rest_codes(\WP_REST_Request $request): \WP_REST_Response { return new \WP_REST_Response($this->list_codes([ 'page' => (int) $request['page'], 'per_page' => (int) $request['per_page'], 'search' => (string) $request['search'], 'status' => (string) $request['status'], ]), 200); } public function rest_mutate_code(\WP_REST_Request $request, string $action) { $user_id = (int) $request['user_id']; if (!get_userdata($user_id)) { return new \WP_Error('aksrt_ref_user_missing', __('用户不存在', 'aksrt'), ['status' => 404]); } $record = $this->code_record($user_id); if ($action === 'generate') { if ($record['status'] !== 'missing') { return new \WP_Error('aksrt_ref_exists', __('该用户已有推广码', 'aksrt'), ['status' => 409]); } $code = $this->generate_code($user_id, false); } elseif ($action === 'disable') { if ($record['status'] !== self::STATUS_ACTIVE) { return new \WP_Error('aksrt_ref_not_active', __('推广码当前不可禁用', 'aksrt'), ['status' => 409]); } update_user_meta($user_id, self::META_STATUS, self::STATUS_DISABLED); if ($this->code_record($user_id)['status'] !== self::STATUS_DISABLED) { return new \WP_Error('aksrt_ref_disable_failed', __('推广码禁用失败', 'aksrt'), ['status' => 500]); } $code = $record['code']; } elseif ($action === 'regenerate') { if ($record['status'] === 'missing') { return new \WP_Error('aksrt_ref_missing', __('该用户尚未生成推广码', 'aksrt'), ['status' => 409]); } $code = $this->generate_code($user_id, true); } else { return new \WP_Error('aksrt_ref_action', __('不支持的推广码操作', 'aksrt'), ['status' => 400]); } if ($code === '') { return new \WP_Error('aksrt_ref_generate_failed', __('推广码生成失败', 'aksrt'), ['status' => 500]); } return rest_ensure_response([ 'user_id' => $user_id, 'ref' => $code, 'status' => $action === 'disable' ? self::STATUS_DISABLED : self::STATUS_ACTIVE, ]); } private function list_codes(array $args): array { global $wpdb; $page = max(1, (int) ($args['page'] ?? 1)); $per_page = max(1, min(100, (int) ($args['per_page'] ?? 20))); $status = (string) ($args['status'] ?? 'all'); $search = trim((string) ($args['search'] ?? '')); $where = ['1=1']; $params = []; if ($search !== '') { $like = '%' . $wpdb->esc_like($search) . '%'; $where[] = '(u.user_login LIKE %s OR u.display_name LIKE %s OR u.user_email LIKE %s OR code.meta_value LIKE %s)'; array_push($params, $like, $like, $like, $like); } if ($status === 'missing') { $where[] = "(code.meta_value IS NULL OR code.meta_value = '')"; } elseif ($status === self::STATUS_DISABLED) { $where[] = "code.meta_value <> '' AND state.meta_value = 'disabled'"; } elseif ($status === self::STATUS_ACTIVE) { $where[] = "code.meta_value <> '' AND (state.meta_value IS NULL OR state.meta_value <> 'disabled')"; } $where_sql = implode(' AND ', $where); $joins = $wpdb->prepare( "LEFT JOIN {$wpdb->usermeta} code ON code.user_id=u.ID AND code.meta_key=%s LEFT JOIN {$wpdb->usermeta} state ON state.user_id=u.ID AND state.meta_key=%s", self::META_CODE, self::META_STATUS ); $count_sql = "SELECT COUNT(DISTINCT u.ID) FROM {$wpdb->users} u {$joins} WHERE {$where_sql}"; $total = (int) ($params ? $wpdb->get_var($wpdb->prepare($count_sql, $params)) : $wpdb->get_var($count_sql)); $relation_table = \AKSRT\Core\DBMigrator::table('affiliate_relation'); $ledger_table = \AKSRT\Core\DBMigrator::table('commission_ledger'); $stats_joins = "LEFT JOIN (SELECT promoter_id, COUNT(*) AS bound_count FROM {$relation_table} WHERE status='bound' GROUP BY promoter_id) relation_stats ON relation_stats.promoter_id=u.ID " . "LEFT JOIN (SELECT promoter_id, COUNT(DISTINCT source_user_id) AS effective_payers FROM {$ledger_table} WHERE original_amount > 0 GROUP BY promoter_id) payer_stats ON payer_stats.promoter_id=u.ID"; $list_sql = "SELECT DISTINCT u.ID, u.user_login, u.display_name, u.user_email, code.meta_value AS ref, state.meta_value AS ref_status, " . "COALESCE(relation_stats.bound_count, 0) AS bound_count, COALESCE(payer_stats.effective_payers, 0) AS effective_payers " . "FROM {$wpdb->users} u {$joins} {$stats_joins} WHERE {$where_sql} ORDER BY u.ID DESC LIMIT %d OFFSET %d"; $list_params = array_merge($params, [$per_page, ($page - 1) * $per_page]); $rows = $wpdb->get_results($wpdb->prepare($list_sql, $list_params), ARRAY_A); $items = []; foreach ((array) $rows as $row) { $user_id = (int) $row['ID']; $ref = sanitize_key((string) ($row['ref'] ?? '')); $items[] = [ 'user' => [ 'id' => $user_id, 'login' => (string) $row['user_login'], 'name' => (string) $row['display_name'], 'email' => (string) $row['user_email'], ], 'ref' => $ref, 'status' => $ref === '' ? 'missing' : ((string) ($row['ref_status'] ?? '') === self::STATUS_DISABLED ? self::STATUS_DISABLED : self::STATUS_ACTIVE), 'bound_count' => (int) ($row['bound_count'] ?? 0), 'effective_payers' => (int) ($row['effective_payers'] ?? 0), ]; } return [ 'items' => $items, 'total' => $total, 'page' => $page, 'per_page' => $per_page, 'max_pages' => max(1, (int) ceil($total / $per_page)), ]; } private function generate_code(int $user_id, bool $force): string { if ($user_id <= 0) { return ''; } $record = $this->code_record($user_id); if (!$force && $record['status'] !== 'missing') { return $record['status'] === self::STATUS_ACTIVE ? $record['code'] : ''; } $length = (int) ($this->shared_settings()['code_length'] ?? 12); $alphabet = 'abcdefghjkmnpqrstuvwxyz23456789'; for ($attempt = 0; $attempt < 20; $attempt++) { $code = ''; for ($i = 0; $i < $length; $i++) { $code .= $alphabet[random_int(0, strlen($alphabet) - 1)]; } $owners = get_users([ 'meta_key' => self::META_CODE, 'meta_value' => $code, 'number' => 1, 'fields' => 'ID', ]); if (empty($owners)) { update_user_meta($user_id, self::META_CODE, $code); update_user_meta($user_id, self::META_STATUS, self::STATUS_ACTIVE); $updated = $this->code_record($user_id); if ($updated['code'] === $code && $updated['status'] === self::STATUS_ACTIVE) { return $code; } } } return ''; } /** @return array{code:string,status:string} */ private function code_record(int $user_id): array { $code = sanitize_key((string) get_user_meta($user_id, self::META_CODE, true)); if ($code === '') { return ['code' => '', 'status' => 'missing']; } $status = (string) get_user_meta($user_id, self::META_STATUS, true); return [ 'code' => $code, 'status' => $status === self::STATUS_DISABLED ? self::STATUS_DISABLED : self::STATUS_ACTIVE, ]; } private function resolve_promoter(string $code): int { $code = sanitize_key($code); if ($code === '') { return 0; } $users = get_users([ 'meta_key' => self::META_CODE, 'meta_value' => $code, 'number' => 1, 'fields' => 'ID', ]); if (empty($users)) { return 0; } $user_id = (int) $users[0]; return $this->code_record($user_id)['status'] === self::STATUS_ACTIVE ? $user_id : 0; } private function cookie_ref(): string { $code = isset($_COOKIE[self::COOKIE]) ? sanitize_key((string) wp_unslash($_COOKIE[self::COOKIE])) : ''; return $code !== '' && $this->resolve_promoter($code) > 0 ? $code : ''; } private function clear_cookie(): void { if (!headers_sent()) { setcookie(self::COOKIE, '', time() - HOUR_IN_SECONDS, COOKIEPATH ?: '/', COOKIE_DOMAIN); } unset($_COOKIE[self::COOKIE]); } private function feature_enabled(): bool { $registration = $this->registration_settings(); $reward = $this->reward_settings(); $affiliate = class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::affiliate_enabled(); return !empty($registration['enabled']) || !empty($reward['enabled']) || $affiliate; } private function shared_settings(): array { return class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_referral_settings() : ['code_length' => 12, 'window_days' => 30]; } private function registration_settings(): array { return class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_referral_registration_settings() : ['enabled' => false, 'register_field' => 'hidden', 'reject_invalid' => true]; } private function reward_settings(): array { return class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_referral_reward_settings() : ['enabled' => false]; } private function is_verified(int $user_id): bool { return (int) get_user_meta($user_id, 'aksrt_email_verified', true) === 1; } private function ip_daily_check(array $relation, array $settings): bool { $cap = max(0, (int) ($settings['ip_daily_cap'] ?? 0)); $ip = (string) ($relation['registration_ip'] ?? ''); if ($cap <= 0 || $ip === '') { return true; } global $wpdb; $table = \AKSRT\Core\DBMigrator::table('affiliate_relation'); $today_utc = (new \DateTimeImmutable('today', wp_timezone())) ->setTimezone(new \DateTimeZone('UTC')) ->format('Y-m-d H:i:s'); $ids = $wpdb->get_col($wpdb->prepare( "SELECT id FROM {$table} WHERE registration_ip = %s AND reward_status = 'rewarded' AND rewarded_at >= %s ORDER BY id ASC FOR UPDATE", $ip, $today_utc )); return count($ids) < $cap; } private function client_ip(): string { return function_exists('aksrt_get_client_ip') ? substr((string) aksrt_get_client_ip(), 0, 64) : ''; } }