feat: unify invite flow with referral ref
This commit is contained in:
1 parent
f1d6809109
commit
b501cf4611
47 files changed
+1083
-2548
No files matched your search
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* First-touch referral attribution for the affiliate contract.
|
||||
* Canonical ref-code attribution, registration binding, and referral rewards.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
@@ -13,8 +13,13 @@ final class ReferralAttribution {
|
||||
|
||||
private const COOKIE = 'aksrt_referral';
|
||||
private const META_CODE = 'aksrt_referral_code';
|
||||
private const META_STATUS = 'aksrt_referral_code_status';
|
||||
private const STATUS_ACTIVE = 'active';
|
||||
private const STATUS_DISABLED = 'disabled';
|
||||
|
||||
private static ?self $instance = null;
|
||||
private static string $pending_registration_ref = '';
|
||||
private static string $registration_notice = '';
|
||||
|
||||
public static function get_instance(): self {
|
||||
return self::$instance ??= new self();
|
||||
@@ -25,20 +30,31 @@ final class ReferralAttribution {
|
||||
public function init(): void {
|
||||
add_action('init', [$this, 'capture'], 1);
|
||||
add_action('user_register', [$this, 'bind_registered_user'], 20, 1);
|
||||
add_action('aksrt_email_verified', [$this, 'reward_registered_user'], 10, 1);
|
||||
add_action('rest_api_init', [$this, 'register_routes']);
|
||||
}
|
||||
|
||||
/** Capture only the new ref contract; invite/legacy parameters are ignored. */
|
||||
public function capture(): void {
|
||||
if (is_admin() || !class_exists('\AKSRT\Settings\Settings') || !\AKSRT\Settings\Settings::affiliate_enabled()) {
|
||||
if (is_admin() || !$this->feature_enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$raw = isset($_GET['ref']) ? sanitize_key(wp_unslash((string) $_GET['ref'])) : '';
|
||||
if ($raw === '' || $this->resolve_promoter($raw) <= 0 || !empty($_COOKIE[self::COOKIE])) {
|
||||
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 = class_exists('\\AKSRT\\Settings\\Settings') ? (int) (\AKSRT\Settings\Settings::get_affiliate_settings()['window_days'] ?? 30) : 30;
|
||||
$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 ?: '/',
|
||||
@@ -51,67 +67,215 @@ final class ReferralAttribution {
|
||||
}
|
||||
}
|
||||
|
||||
public function create_link(int $user_id, string $url = ''): array {
|
||||
$code = $this->get_or_create_code($user_id);
|
||||
$target = $url !== '' ? $url : home_url('/');
|
||||
return ['code' => $code, 'url' => add_query_arg('ref', $code, $target)];
|
||||
/** @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 get_or_create_code(int $user_id): string {
|
||||
if ($user_id <= 0) {
|
||||
return '';
|
||||
}
|
||||
$existing = sanitize_key((string) get_user_meta($user_id, self::META_CODE, true));
|
||||
if ($existing !== '' && strlen($existing) >= 10) {
|
||||
return $existing;
|
||||
}
|
||||
$alphabet = 'abcdefghjkmnpqrstuvwxyz23456789';
|
||||
for ($attempt = 0; $attempt < 10; $attempt++) {
|
||||
$code = '';
|
||||
for ($i = 0; $i < 12; $i++) {
|
||||
$code .= $alphabet[random_int(0, strlen($alphabet) - 1)];
|
||||
}
|
||||
if ($this->resolve_promoter($code) <= 0) {
|
||||
update_user_meta($user_id, self::META_CODE, $code);
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
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 || !class_exists('\AKSRT\Settings\Settings') || !\AKSRT\Settings\Settings::affiliate_enabled()) {
|
||||
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;
|
||||
$code = sanitize_key((string) ($_COOKIE[self::COOKIE] ?? ''));
|
||||
$promoter_id = $this->resolve_promoter($code);
|
||||
if ($code === '' || $promoter_id <= 0 || $promoter_id === $invitee_id) {
|
||||
return;
|
||||
}
|
||||
$table = \AKSRT\Core\DBMigrator::table('affiliate_relation');
|
||||
if ($table === '') {
|
||||
return;
|
||||
}
|
||||
$existing = $wpdb->get_var($wpdb->prepare("SELECT id FROM {$table} WHERE invitee_id = %d LIMIT 1", $invitee_id));
|
||||
if ($existing) {
|
||||
|
||||
$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);
|
||||
$wpdb->insert($table, [
|
||||
'referral_code' => $code,
|
||||
'promoter_id' => $promoter_id,
|
||||
'invitee_id' => $invitee_id,
|
||||
'first_touch_at'=> $now,
|
||||
'bound_at' => $now,
|
||||
'status' => 'bound',
|
||||
'created_at' => $now,
|
||||
], ['%s', '%d', '%d', '%s', '%s', '%s', '%s']);
|
||||
if (!headers_sent()) {
|
||||
setcookie(self::COOKIE, '', time() - HOUR_IN_SECONDS, COOKIEPATH ?: '/', COOKIE_DOMAIN);
|
||||
$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 (!class_exists('\AKSRT\Credits\Credits')) {
|
||||
return;
|
||||
}
|
||||
if (!empty($settings['require_verify']) && !$this->is_verified($invitee_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$wpdb->query('START TRANSACTION');
|
||||
try {
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE invitee_id = %d AND status = 'bound' FOR UPDATE",
|
||||
$invitee_id
|
||||
), ARRAY_A);
|
||||
if (!is_array($row) || (string) ($row['reward_status'] ?? 'pending') !== 'pending') {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return;
|
||||
}
|
||||
|
||||
$relation_id = (int) $row['id'];
|
||||
$promoter_id = (int) $row['promoter_id'];
|
||||
if ($promoter_id <= 0 || !$this->ip_daily_check($row, $settings)) {
|
||||
$wpdb->update($table, ['reward_status' => 'skipped'], ['id' => $relation_id], ['%s'], ['%d']);
|
||||
$wpdb->query('COMMIT');
|
||||
return;
|
||||
}
|
||||
|
||||
$claimed = $wpdb->update(
|
||||
$table,
|
||||
['reward_status' => 'rewarding'],
|
||||
['id' => $relation_id, 'reward_status' => 'pending'],
|
||||
['%s'],
|
||||
['%d', '%s']
|
||||
);
|
||||
if ($claimed !== 1) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return;
|
||||
}
|
||||
|
||||
$ref = ['type' => 'referral', 'id' => $relation_id];
|
||||
$promoter_amount = max(0, (int) ($settings['promoter_amount'] ?? 0));
|
||||
$referred_amount = max(0, (int) ($settings['referred_amount'] ?? 0));
|
||||
if ($promoter_amount > 0) {
|
||||
$result = \AKSRT\Credits\Credits::add($promoter_id, $promoter_amount, 'referral', $ref, __('推广注册奖励', 'aksrt'), false);
|
||||
if (is_wp_error($result)) {
|
||||
throw new \RuntimeException($result->get_error_message());
|
||||
}
|
||||
}
|
||||
if ($referred_amount > 0) {
|
||||
$result = \AKSRT\Credits\Credits::add($invitee_id, $referred_amount, 'referral_new_user', $ref, __('推广注册新用户奖励', 'aksrt'), false);
|
||||
if (is_wp_error($result)) {
|
||||
throw new \RuntimeException($result->get_error_message());
|
||||
}
|
||||
}
|
||||
|
||||
$level_points = max(0, (int) ($settings['level_points'] ?? 0));
|
||||
if ($level_points > 0 && class_exists('\AKSRT\User\Level')) {
|
||||
\AKSRT\User\Level::add($promoter_id, 'referral', $level_points, true);
|
||||
}
|
||||
|
||||
$rewarded_at = current_time('mysql', true);
|
||||
$updated = $wpdb->update(
|
||||
$table,
|
||||
['reward_status' => 'rewarded', 'rewarded_at' => $rewarded_at],
|
||||
['id' => $relation_id, 'reward_status' => 'rewarding'],
|
||||
['%s', '%s'],
|
||||
['%d', '%s']
|
||||
);
|
||||
if ($updated !== 1) {
|
||||
throw new \RuntimeException('Unable to finalize referral reward.');
|
||||
}
|
||||
|
||||
$wpdb->query('COMMIT');
|
||||
do_action('aksrt_referral_rewarded', $relation_id, $promoter_id, $invitee_id);
|
||||
} catch (\Throwable $e) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -119,13 +283,11 @@ final class ReferralAttribution {
|
||||
}
|
||||
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;
|
||||
if ($bound > 0) {
|
||||
return $bound === $user_id ? 0 : $bound;
|
||||
}
|
||||
// Orders may only use a permanently bound relation. A browsing cookie
|
||||
// never becomes an implicit post-login override.
|
||||
return 0;
|
||||
$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 {
|
||||
@@ -134,17 +296,19 @@ final class ReferralAttribution {
|
||||
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));
|
||||
$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;
|
||||
$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 users bound through the forward-only ref relation.
|
||||
*
|
||||
* @return array{items:array<int,array<string,mixed>>,total:int,page:int,per_page:int,max_pages:int}
|
||||
*/
|
||||
/** @return array{items:array<int,array<string,mixed>>,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');
|
||||
@@ -158,27 +322,33 @@ final class ReferralAttribution {
|
||||
$promoter_id
|
||||
));
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT invitee_id, referral_code, bound_at, status FROM {$table} WHERE promoter_id=%d AND status='bound' AND invitee_id > 0 ORDER BY id DESC LIMIT %d OFFSET %d",
|
||||
"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) {
|
||||
$invitee_id = (int) ($row['invitee_id'] ?? 0);
|
||||
$user = $invitee_id > 0 ? get_userdata($invitee_id) : null;
|
||||
$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' => $invitee_id,
|
||||
'user_id' => $user_id,
|
||||
'name' => $user->display_name,
|
||||
'avatar' => get_avatar_url($invitee_id, ['size' => 64]),
|
||||
'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))];
|
||||
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 {
|
||||
@@ -187,30 +357,315 @@ final class ReferralAttribution {
|
||||
'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<user_id>\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('manage_users');
|
||||
}
|
||||
|
||||
public function rest_me(): \WP_REST_Response {
|
||||
$uid = get_current_user_id();
|
||||
$link = $this->create_link($uid);
|
||||
$stats = $this->stats($uid);
|
||||
$settings = \AKSRT\Settings\Settings::get_affiliate_settings();
|
||||
$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([
|
||||
'code' => $link['code'],
|
||||
'ref' => $link['ref'],
|
||||
'url' => $link['url'],
|
||||
'current_ratio' => (float) \AKSRT\Settings\Settings::affiliate_ratio_for_user($uid),
|
||||
'ratios' => (array) ($settings['ratios'] ?? []),
|
||||
'order_types' => (array) ($settings['order_types'] ?? ['membership', 'content']),
|
||||
'rules' => ['window_days' => (int) ($settings['window_days'] ?? 30), 'settlement_days' => (int) ($settings['settlement_days'] ?? 7)],
|
||||
'status' => $link['status'],
|
||||
'features' => [
|
||||
'registration' => !empty($registration['enabled']),
|
||||
'points_reward' => !empty($reward['enabled']),
|
||||
'affiliate' => !empty($affiliate['enabled']),
|
||||
],
|
||||
'current_ratio' => (float) \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']);
|
||||
return !empty($users) ? (int) $users[0] : 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');
|
||||
$count = (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$table} WHERE registration_ip = %s AND reward_status = 'rewarded' AND rewarded_at >= %s FOR UPDATE",
|
||||
$ip,
|
||||
$today_utc
|
||||
));
|
||||
return $count < $cap;
|
||||
}
|
||||
|
||||
private function client_ip(): string {
|
||||
return function_exists('aksrt_get_client_ip')
|
||||
? substr((string) aksrt_get_client_ip(), 0, 64)
|
||||
: '';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user