feat: upgrade widgets and affiliate commission flow
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
/**
|
||||
* First-touch referral attribution for the affiliate contract.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class ReferralAttribution {
|
||||
|
||||
private const COOKIE = 'aksrt_referral';
|
||||
private const META_CODE = 'aksrt_referral_code';
|
||||
|
||||
private static ?self $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
return self::$instance ??= new self();
|
||||
}
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public function init(): void {
|
||||
add_action('init', [$this, 'capture'], 1);
|
||||
add_action('user_register', [$this, 'bind_registered_user'], 20, 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()) {
|
||||
return;
|
||||
}
|
||||
$raw = isset($_GET['ref']) ? sanitize_key(wp_unslash((string) $_GET['ref'])) : '';
|
||||
if ($raw === '' || $this->resolve_promoter($raw) <= 0 || !empty($_COOKIE[self::COOKIE])) {
|
||||
return;
|
||||
}
|
||||
if (!headers_sent()) {
|
||||
$ttl_days = class_exists('\\AKSRT\\Settings\\Settings') ? (int) (\AKSRT\Settings\Settings::get_affiliate_settings()['window_days'] ?? 30) : 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;
|
||||
}
|
||||
}
|
||||
|
||||
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)];
|
||||
}
|
||||
|
||||
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 bind_registered_user(int $invitee_id): void {
|
||||
if ($invitee_id <= 0 || !class_exists('\AKSRT\Settings\Settings') || !\AKSRT\Settings\Settings::affiliate_enabled()) {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
public function register_routes(): void {
|
||||
register_rest_route('aksrt/v1', '/promotion/me', [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'rest_me'],
|
||||
'permission_callback' => static fn() => is_user_logged_in(),
|
||||
]);
|
||||
register_rest_route('aksrt/v1', '/promotion/payout-accounts', [
|
||||
'methods' => \WP_REST_Server::EDITABLE,
|
||||
'callback' => [$this, 'rest_save_payout_accounts'],
|
||||
'permission_callback' => static fn() => is_user_logged_in(),
|
||||
'args' => ['accounts' => ['required' => true, 'type' => 'array']],
|
||||
]);
|
||||
}
|
||||
|
||||
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();
|
||||
return new \WP_REST_Response([
|
||||
'code' => $link['code'],
|
||||
'url' => $link['url'],
|
||||
'ratio' => (float) ($settings['ratio'] ?? 5),
|
||||
'order_types' => (array) ($settings['order_types'] ?? ['membership', 'content']),
|
||||
'rules' => ['window_days' => (int) ($settings['window_days'] ?? 30), 'settlement_days' => (int) ($settings['settlement_days'] ?? 7)],
|
||||
'stats' => $stats,
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function rest_save_payout_accounts(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
|
||||
$accounts = $request->get_param('accounts');
|
||||
if (!is_array($accounts)) {
|
||||
return new \WP_Error('aksrt_payout_accounts', __('收款账户格式无效。', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('payout_account');
|
||||
if ($table === '') {
|
||||
return new \WP_Error('aksrt_payout_unavailable', __('收款账户服务不可用。', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$uid = get_current_user_id();
|
||||
$now = current_time('mysql', true);
|
||||
foreach (['alipay', 'wechat'] as $channel) {
|
||||
$value = isset($accounts[$channel]) ? sanitize_text_field((string) $accounts[$channel]) : '';
|
||||
if ($value === '') {
|
||||
$wpdb->update($table, ['status' => 'inactive', 'updated_at' => $now], ['user_id' => $uid, 'channel' => $channel], ['%s', '%s'], ['%d', '%s']);
|
||||
continue;
|
||||
}
|
||||
$wpdb->replace($table, ['user_id' => $uid, 'channel' => $channel, 'attachment_id' => 0, 'account_hash' => hash('sha256', $value), 'status' => 'active', 'created_at' => $now, 'updated_at' => $now], ['%d', '%s', '%d', '%s', '%s', '%s', '%s']);
|
||||
}
|
||||
return new \WP_REST_Response(['success' => true], 200);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user