feat: upgrade widgets and affiliate commission flow

This commit is contained in:
2026-08-16 18:29:31 +08:00
parent 22c0c9df9c
commit 698a9beb74
33 changed files with 709 additions and 227 deletions
+203
View File
@@ -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;
}
}