feat: upgrade widgets and affiliate commission flow
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
/** Forward-only affiliate commission ledger. */
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class CommissionLedger {
|
||||
private static ?self $instance = null;
|
||||
private const DEBT_META = 'aksrt_affiliate_debt';
|
||||
public static function get_instance(): self { return self::$instance ??= new self(); }
|
||||
private function __construct() {}
|
||||
public function init(): void {
|
||||
add_action('aksrt_order_paid', [$this, 'on_order_paid'], 20, 2);
|
||||
add_action('aksrt_order_status_changed', [$this, 'on_order_status_changed'], 20, 3);
|
||||
add_action('aksrt_affiliate_settle_due', [$this, 'settle_due']);
|
||||
add_action('rest_api_init', [$this, 'register_routes']);
|
||||
if (!wp_next_scheduled('aksrt_affiliate_settle_due')) {
|
||||
wp_schedule_event(time() + HOUR_IN_SECONDS, 'hourly', 'aksrt_affiliate_settle_due');
|
||||
}
|
||||
}
|
||||
private function table(string $name): string { return \AKSRT\Core\DBMigrator::table($name); }
|
||||
public function on_order_paid(string $order_number, array $order = []): void { $this->record_paid_order(array_merge($order, ['order_number' => $order_number])); }
|
||||
public function on_order_status_changed(string $order_number, string $status, array $order = []): void {
|
||||
if (in_array($status, ['refunding', 'refunded'], true)) { $this->reverse($order_number, (float) ($order['amount'] ?? 0), 'refund'); }
|
||||
}
|
||||
public function record_paid_order(array $order): int {
|
||||
if (!\AKSRT\Settings\Settings::affiliate_enabled()) return 0;
|
||||
$type = sanitize_key((string) ($order['product_type'] ?? ''));
|
||||
$map = ['vip' => 'membership', 'resource' => 'content'];
|
||||
$order_type = $map[$type] ?? $type;
|
||||
$settings = \AKSRT\Settings\Settings::get_affiliate_settings();
|
||||
if (!in_array($order_type, (array) $settings['order_types'], true)) return 0;
|
||||
$order_no = sanitize_text_field((string) ($order['order_number'] ?? ''));
|
||||
$buyer = (int) ($order['user_id'] ?? 0);
|
||||
$amount = round((float) ($order['amount'] ?? 0), 2);
|
||||
if ($order_no === '' || $buyer <= 0 || $amount <= 0) return 0;
|
||||
$promoter = ReferralAttribution::get_instance()->resolve($buyer);
|
||||
if ($promoter <= 0 || $promoter === $buyer) return 0;
|
||||
if (!$settings['allow_self_purchase'] && $promoter === $buyer) return 0;
|
||||
$ratio = (float) $settings['ratio'];
|
||||
$commission = round($amount * $ratio / 100, 2);
|
||||
if ($commission <= 0) return 0;
|
||||
global $wpdb;
|
||||
$table = $this->table('commission_ledger');
|
||||
$key = hash('sha256', 'affiliate:' . $order_no . ':' . $order_type);
|
||||
$now = current_time('mysql', true);
|
||||
$available = gmdate('Y-m-d H:i:s', time() + ((int) $settings['settlement_days'] * DAY_IN_SECONDS));
|
||||
$ok = $wpdb->insert($table, ['promoter_id'=>$promoter,'source_user_id'=>$buyer,'order_number'=>$order_no,'order_type'=>$order_type,'eligible_base'=>$amount,'ratio'=>$ratio,'original_amount'=>$commission,'adjustment_total'=>0,'status'=>'pending','idempotency_key'=>$key,'available_at'=>$available,'created_at'=>$now], ['%d','%d','%s','%s','%f','%f','%f','%f','%s','%s','%s','%s']);
|
||||
return $ok ? (int) $wpdb->insert_id : (int) $wpdb->get_var($wpdb->prepare("SELECT id FROM {$table} WHERE idempotency_key=%s", $key));
|
||||
}
|
||||
public function settle_due(int $limit = 100): int {
|
||||
global $wpdb; $table = $this->table('commission_ledger');
|
||||
$rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$table} WHERE status='pending' AND available_at <= %s ORDER BY id ASC LIMIT %d", current_time('mysql', true), max(1,min(500,$limit))), ARRAY_A);
|
||||
$count = 0;
|
||||
foreach ((array) $rows as $row) if ($this->settle_one((int)$row['id'])) $count++;
|
||||
return $count;
|
||||
}
|
||||
private function settle_one(int $id): bool {
|
||||
global $wpdb; $table = $this->table('commission_ledger');
|
||||
$wpdb->query('START TRANSACTION');
|
||||
try {
|
||||
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id=%d AND status='pending' FOR UPDATE", $id), ARRAY_A);
|
||||
if (!$row) { $wpdb->query('ROLLBACK'); return false; }
|
||||
$amount = round((float)$row['original_amount'] + (float)$row['adjustment_total'], 2);
|
||||
if ($amount > 0 && class_exists('\\AKSRT\\Credits\\Balance')) {
|
||||
$credited = \AKSRT\Credits\Balance::add((int)$row['promoter_id'], $amount, 'affiliate_commission', ['type'=>'commission','id'=>$id], sprintf(__('推广佣金结算(订单 %s)','aksrt'), $row['order_number']));
|
||||
if (is_wp_error($credited)) { $wpdb->query('ROLLBACK'); return false; }
|
||||
}
|
||||
$updated = $wpdb->query($wpdb->prepare("UPDATE {$table} SET status='settled', settled_at=%s WHERE id=%d AND status='pending'", current_time('mysql', true), $id)) === 1;
|
||||
if (!$updated) { $wpdb->query('ROLLBACK'); return false; }
|
||||
$wpdb->query('COMMIT'); return true;
|
||||
} catch (\Throwable $e) { $wpdb->query('ROLLBACK'); return false; }
|
||||
}
|
||||
public function reverse(string $order_number, float $refund_amount, string $reason = 'refund'): int {
|
||||
global $wpdb; $table = $this->table('commission_ledger'); $adjust = $this->table('commission_adjustment');
|
||||
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE order_number=%s LIMIT 1", $order_number), ARRAY_A);
|
||||
if (!$row || $refund_amount <= 0) return 0;
|
||||
$base = max(0.01, (float)$row['eligible_base']); $negative = -round((float)$row['original_amount'] * min(1, $refund_amount / $base), 2);
|
||||
$source = hash('sha256', 'refund:' . $order_number . ':' . number_format($refund_amount,2,'.',''));
|
||||
$ok = $wpdb->insert($adjust, ['ledger_id'=>$row['id'],'promoter_id'=>$row['promoter_id'],'amount'=>$negative,'reason'=>sanitize_key($reason),'source_key'=>$source,'operator_id'=>0,'created_at'=>current_time('mysql', true)], ['%d','%d','%f','%s','%s','%d','%s']);
|
||||
if (!$ok) return 0;
|
||||
$wpdb->query($wpdb->prepare("UPDATE {$table} SET adjustment_total=adjustment_total+%f, status=IF(status='pending','cancelled',status) WHERE id=%d", $negative, $row['id']));
|
||||
if ($row['status'] === 'settled' && class_exists('\\AKSRT\\Credits\\Balance')) {
|
||||
$debit = min((float)$row['original_amount'], abs($negative));
|
||||
$result = \AKSRT\Credits\Balance::deduct((int)$row['promoter_id'], $debit, 'affiliate_refund', ['type'=>'commission','id'=>$row['id']], __('推广佣金退款冲正','aksrt'));
|
||||
if (is_wp_error($result)) update_user_meta((int)$row['promoter_id'], self::DEBT_META, round((float)get_user_meta((int)$row['promoter_id'], self::DEBT_META, true) + $debit, 2));
|
||||
}
|
||||
return (int)$wpdb->insert_id;
|
||||
}
|
||||
public function overview(int $user_id): array {
|
||||
global $wpdb; $table=$this->table('commission_ledger');
|
||||
$row=$wpdb->get_row($wpdb->prepare("SELECT COALESCE(SUM(original_amount+adjustment_total),0) total, COALESCE(SUM(CASE WHEN status='pending' THEN original_amount+adjustment_total ELSE 0 END),0) pending, COALESCE(SUM(CASE WHEN status='settled' THEN original_amount+adjustment_total ELSE 0 END),0) settled FROM {$table} WHERE promoter_id=%d",$user_id),ARRAY_A) ?: [];
|
||||
return ['total'=>round((float)($row['total']??0),2),'pending'=>round((float)($row['pending']??0),2),'settled'=>round((float)($row['settled']??0),2),'wallet'=>class_exists('\\AKSRT\\Credits\\Balance')?\AKSRT\Credits\Balance::balance($user_id):0.0,'debt'=>round((float)get_user_meta($user_id,self::DEBT_META,true),2)];
|
||||
}
|
||||
public function list_entries(int $user_id, array $args=[]): array {
|
||||
global $wpdb; $table=$this->table('commission_ledger'); $limit=max(1,min(100,(int)($args['per_page']??20))); $page=max(1,(int)($args['page']??1)); $offset=($page-1)*$limit; $where='promoter_id=%d'; $params=[$user_id]; $status=sanitize_key((string)($args['status']??'')); if(in_array($status,['pending','settled','cancelled'],true)){ $where.=' AND status=%s'; $params[]=$status; } $total=(int)$wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE {$where}",$params)); $rows=$wpdb->get_results($wpdb->prepare("SELECT * FROM {$table} WHERE {$where} ORDER BY id DESC LIMIT %d OFFSET %d",array_merge($params,[$limit,$offset])),ARRAY_A); return ['items'=>$rows?:[],'total'=>$total,'page'=>$page,'per_page'=>$limit,'max_pages'=>max(1,(int)ceil($total/$limit))];
|
||||
}
|
||||
public function register_routes(): void {
|
||||
register_rest_route('aksrt/v1','/earnings/overview',['methods'=>\WP_REST_Server::READABLE,'callback'=>fn()=>rest_ensure_response($this->overview(get_current_user_id())),'permission_callback'=>fn()=>is_user_logged_in()]);
|
||||
register_rest_route('aksrt/v1','/earnings/commissions',['methods'=>\WP_REST_Server::READABLE,'callback'=>fn(\WP_REST_Request $r)=>rest_ensure_response($this->list_entries(get_current_user_id(),$r->get_params())),'permission_callback'=>fn()=>is_user_logged_in()]);
|
||||
register_rest_route('aksrt/v1','/earnings/withdrawals',['methods'=>\WP_REST_Server::READABLE,'callback'=>fn(\WP_REST_Request $r)=>rest_ensure_response($this->withdrawals(get_current_user_id(),$r->get_params())),'permission_callback'=>fn()=>is_user_logged_in()]);
|
||||
register_rest_route('aksrt/v1','/earnings/withdrawals',['methods'=>\WP_REST_Server::CREATABLE,'callback'=>[$this,'create_withdrawal'],'permission_callback'=>fn()=>is_user_logged_in()]);
|
||||
register_rest_route('aksrt/v1','/payment/affiliate/ledger/(?P<id>\d+)/settle',['methods'=>\WP_REST_Server::CREATABLE,'callback'=>fn(\WP_REST_Request $r)=>$this->admin_settle((int)$r['id']),'permission_callback'=>fn()=>current_user_can('manage_options')]);
|
||||
register_rest_route('aksrt/v1','/payment/affiliate/ledger/(?P<id>\d+)/cancel',['methods'=>\WP_REST_Server::CREATABLE,'callback'=>fn(\WP_REST_Request $r)=>$this->admin_cancel((int)$r['id']),'permission_callback'=>fn()=>current_user_can('manage_options')]);
|
||||
register_rest_route('aksrt/v1','/payment/affiliate/overview',['methods'=>\WP_REST_Server::READABLE,'callback'=>fn()=>rest_ensure_response($this->admin_overview()),'permission_callback'=>fn()=>current_user_can('manage_options')]);
|
||||
register_rest_route('aksrt/v1','/payment/affiliate/ledger',['methods'=>\WP_REST_Server::READABLE,'callback'=>fn(\WP_REST_Request $r)=>rest_ensure_response($this->admin_ledger($r->get_params())),'permission_callback'=>fn()=>current_user_can('manage_options')]);
|
||||
register_rest_route('aksrt/v1','/payment/withdrawals/(?P<id>\d+)/transition',['methods'=>\WP_REST_Server::CREATABLE,'callback'=>fn(\WP_REST_Request $r)=>$this->transition_withdrawal((int)$r['id'],(string)$r->get_param('status'),(string)$r->get_param('note')),'permission_callback'=>fn()=>current_user_can('manage_options')]);
|
||||
}
|
||||
public function admin_settle(int $id): \WP_REST_Response { $ok=$this->settle_one($id); return new \WP_REST_Response(['success'=>$ok],$ok?200:409); }
|
||||
public function admin_cancel(int $id): \WP_REST_Response { global $wpdb; $ok=$wpdb->query($wpdb->prepare("UPDATE {$this->table('commission_ledger')} SET status='cancelled' WHERE id=%d AND status='pending'",$id))===1; return new \WP_REST_Response(['success'=>$ok],$ok?200:409); }
|
||||
public function admin_overview(): array { global $wpdb; $table=$this->table('commission_ledger'); $row=$wpdb->get_row("SELECT COUNT(*) total, SUM(status='pending') pending, SUM(status='settled') settled, COALESCE(SUM(original_amount+adjustment_total),0) amount FROM {$table}",ARRAY_A) ?: []; return ['total'=>(int)($row['total']??0),'pending'=>(int)($row['pending']??0),'settled'=>(int)($row['settled']??0),'amount'=>(float)($row['amount']??0)]; }
|
||||
public function admin_ledger(array $args): array { global $wpdb; $table=$this->table('commission_ledger'); $limit=max(1,min(100,(int)($args['per_page']??20))); $page=max(1,(int)($args['page']??1)); $offset=($page-1)*$limit; $status=sanitize_key((string)($args['status']??'')); $where='1=1'; $params=[]; if(in_array($status,['pending','settled','cancelled'],true)){ $where.=' AND status=%s'; $params[]=$status; } $total=(int)($wpdb->get_var($params?$wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE {$where}",$params):"SELECT COUNT(*) FROM {$table}")); $rows=$wpdb->get_results($wpdb->prepare("SELECT * FROM {$table} WHERE {$where} ORDER BY id DESC LIMIT %d OFFSET %d",array_merge($params,[$limit,$offset])),ARRAY_A); return ['items'=>$rows?:[],'total'=>$total,'page'=>$page,'per_page'=>$limit,'max_pages'=>max(1,(int)ceil($total/$limit))]; }
|
||||
public function withdrawals(int $user_id, array $args): array { return \AKSRT\Payment\Withdrawal::get_instance()->get_user_withdrawals($user_id, ['status'=>sanitize_key((string)($args['status']??'')),'limit'=>max(1,min(100,(int)($args['per_page']??20))),'offset'=>max(0,((int)($args['page']??1)-1)*max(1,(int)($args['per_page']??20)))]); }
|
||||
public function create_withdrawal(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
|
||||
$user_id=get_current_user_id(); $amount=(int)round((float)$request->get_param('amount')); $method=sanitize_key((string)$request->get_param('method')); $account=sanitize_text_field((string)$request->get_param('account')); $settings=\AKSRT\Settings\Settings::get_withdrawal_settings(); $affiliate=\AKSRT\Settings\Settings::get_affiliate_settings();
|
||||
if (empty($affiliate['enabled'])) return new \WP_Error('affiliate_disabled',__('推广返佣未启用。','aksrt'),['status'=>403]);
|
||||
if (\AKSRT\Payment\Withdrawal::get_instance()->has_open_withdrawal($user_id)) return new \WP_Error('affiliate_withdraw_pending',__('已有待处理提现申请。','aksrt'),['status'=>409]);
|
||||
if ($amount < (int)ceil((float)$affiliate['min_withdrawal'])) return new \WP_Error('affiliate_withdraw_min',__('未达到最低提现金额。','aksrt'),['status'=>400]);
|
||||
if ((float)get_user_meta($user_id,self::DEBT_META,true)>0) return new \WP_Error('affiliate_withdraw_debt',__('存在待偿还的佣金欠款,暂不可提现。','aksrt'),['status'=>409]);
|
||||
if (!in_array($method,(array)$affiliate['payout_channels'],true) || $account==='') return new \WP_Error('affiliate_withdraw_account',__('请提供有效的收款渠道和收款码。','aksrt'),['status'=>400]);
|
||||
$result=\AKSRT\Payment\Withdrawal::get_instance()->create_balance_request($user_id,$amount,$method,$account,$settings); if(is_wp_error($result)) return $result; return new \WP_REST_Response(['success'=>true,'data'=>$result],201);
|
||||
}
|
||||
public function admin_withdrawals(array $args): array { $service=\AKSRT\Payment\Withdrawal::get_instance(); return ['items'=>$service->get_all(['status'=>sanitize_key((string)($args['status']??'')),'limit'=>max(1,min(100,(int)($args['per_page']??20))),'offset'=>max(0,((int)($args['page']??1)-1)*max(1,(int)($args['per_page']??20)))]),'stats'=>$service->get_stats()]; }
|
||||
public function transition_withdrawal(int $id,string $status,string $note): \WP_REST_Response|\WP_Error { $status = ['approved'=>'processing','paid'=>'completed'][$status] ?? $status; $result=\AKSRT\Payment\Withdrawal::get_instance()->process_status_change($id,$status,$note); if(is_wp_error($result)) return $result; return new \WP_REST_Response(['success'=>true,'status'=>$status],200); }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,11 @@ class Withdrawal {
|
||||
}
|
||||
$wpdb->query('START TRANSACTION');
|
||||
try {
|
||||
$open = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing') FOR UPDATE", $user_id));
|
||||
if ($open > 0) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return new \WP_Error('withdraw_pending', __('已有待处理提现申请', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$meta_id = $wpdb->get_var($wpdb->prepare("SELECT umeta_id FROM {$meta_table} WHERE user_id = %d AND meta_key = %s LIMIT 1 FOR UPDATE", $user_id, $meta_key));
|
||||
if (!$meta_id) {
|
||||
throw new \RuntimeException('wallet row unavailable');
|
||||
@@ -222,6 +227,15 @@ class Withdrawal {
|
||||
if ($old_status === $new_status && $note === '') {
|
||||
return true;
|
||||
}
|
||||
$transitions = [
|
||||
self::STATUS_PENDING => [self::STATUS_PROCESSING, self::STATUS_REJECTED],
|
||||
self::STATUS_PROCESSING => [self::STATUS_COMPLETED, self::STATUS_REJECTED],
|
||||
self::STATUS_COMPLETED => [],
|
||||
self::STATUS_REJECTED => [],
|
||||
];
|
||||
if (!in_array($new_status, $transitions[$old_status] ?? [], true)) {
|
||||
return new \WP_Error('aksrt_withdraw_transition', __('提现状态转换不合法', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
|
||||
$meta = $this->parse_account_meta((string) ($row['account_info'] ?? ''));
|
||||
$user_id = (int) ($row['user_id'] ?? 0);
|
||||
@@ -459,7 +473,8 @@ class Withdrawal {
|
||||
$row['meta'] = $meta;
|
||||
$row['gross_amount'] = $gross;
|
||||
$row['fee_rate'] = (int) ($meta['fee_rate'] ?? 0);
|
||||
$row['display_account'] = (string) ($meta['account'] ?? $row['account_info'] ?? '');
|
||||
$display = (string) ($meta['account'] ?? $row['account_info'] ?? '');
|
||||
$row['display_account'] = strlen($display) > 4 ? substr($display, 0, 2) . str_repeat('*', max(2, strlen($display) - 4)) . substr($display, -2) : '****';
|
||||
$row['source'] = (string) ($meta['source'] ?? 'balance');
|
||||
return $row;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user