Files
wordpress-theme-aksrt/inc/classes/class-aksrt-invite.php
T

618 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* AKSRT Invite
*
* 邀请注册系统:
* - 邀请人:每人一个唯一邀请码(aksrt_invite_code user_meta),生成后复用
* - 被邀请人:访问 ?invite=XYZ 时 set cookie aksrt_invite (30 天)
* - 注册时:从 cookie 读 invite_code,写入 aksrt_invite 表 status=registered
* - 邮箱验证完成后:状态升级 verified → 触发奖励 → status=rewarded
* - 反作弊:同 IP 当日上限(ip_daily_cap)
*
* REST:
* GET /invite/me 当前用户邀请码 + 统计
* GET /invite/list 当前用户的邀请列表
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Invite {
const COOKIE_NAME = 'aksrt_invite';
const COOKIE_TTL_DAYS = 30;
const META_INVITE_CODE = 'aksrt_invite_code';
const OPT_STANDALONE_CODES = 'aksrt_standalone_invite_codes';
/**
* 反向映射 option:code => 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();
},
]);
}
/** 捕获 ?invite=XYZ */
public function capture_invite_cookie(): void {
if (is_admin()) return;
if (empty($_GET['invite'])) return;
// 邀请注册功能关闭时不处理
if (class_exists('AKSRT_Settings') && !AKSRT_Settings::invite_registration_enabled()) return;
$code = sanitize_key((string) $_GET['invite']);
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') && !AKSRT_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')
? AKSRT_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') && !AKSRT_Settings::invite_registration_enabled()) return;
global $wpdb;
$table = AKSRT_DB_Migrator::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') || !class_exists('AKSRT_Settings')) return;
$invite_cfg = AKSRT_Settings::get_invite_reward_settings();
if (empty($invite_cfg['enabled'])) return;
// 反作弊:同 IP 当日上限
if (!$this->ip_daily_check($invite_cfg)) return;
$inviter_amount = (int) ($invite_cfg['inviter_amount'] ?? 0);
$invitee_amount = (int) ($invite_cfg['invitee_amount'] ?? 0);
if ($inviter_amount > 0) {
AKSRT_Credits::add($inviter_id, $inviter_amount, 'invite', ['type' => 'user', 'id' => $invitee_id]);
}
if ($invitee_amount > 0) {
AKSRT_Credits::add($invitee_id, $invitee_amount, 'invitee', ['type' => 'user', 'id' => $inviter_id]);
}
// 标记 rewarded
global $wpdb;
$table = AKSRT_DB_Migrator::table('invite');
if ($table) {
$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_DB_Migrator::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')
? AKSRT_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 之前做"邀请码是否合法"的预校验用。
*/
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') && !AKSRT_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') && AKSRT_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_DB_Migrator::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') && !AKSRT_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_DB_Migrator::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('invite', $code, aksrt_get_register_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') && !AKSRT_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') ?: 20));
$offset = ($page - 1) * $per_page;
$table = AKSRT_DB_Migrator::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
));
$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,
], 200);
}
}