积分与余额账务系统硬切重构

This commit is contained in:
kate522 committed 2026-08-24 15:59:11 +08:00
1 parent ca12226b39
commit 5cae5b5c93
159 files changed
+7275 -4718

No files matched your search

+347 -78
View File
@@ -6,13 +6,9 @@ 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_points_exchange_completed', [$this, 'on_points_exchange_completed'], 20, 4);
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')) {
@@ -20,20 +16,29 @@ final class CommissionLedger {
}
}
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 on_points_exchange_completed(int $user_id, float $amount, int $points, string $exchange_ref): void {
$this->record_paid_order([
'order_number' => 'points-exchange-' . sanitize_key($exchange_ref),
'product_type' => 'credits',
'user_id' => $user_id,
'amount' => $amount,
'exchange_ref' => $exchange_ref,
'points' => $points,
]);
}
public function record_order_transition(string $order_number, string $status, array $order = []) {
if ($status !== Order::STATUS_PAID) {
return true;
}
$result = $this->record_paid_order(array_merge($order, [
'order_number' => $order_number,
'status' => $status,
]));
return $result >= 0
? true
: new \WP_Error('aksrt_commission_write_failed', __('返佣记录写入失败', 'aksrt'), ['status' => 500]);
}
public function record_points_exchange(int $user_id, string $amount, int $points, string $operation_key): bool {
$result = $this->record_paid_order([
'order_number' => 'PX' . substr(hash('sha256', $operation_key), 0, 30),
'product_type' => 'credits',
'user_id' => $user_id,
'amount' => $amount,
'exchange_ref' => $operation_key,
'points' => $points,
]);
return $result >= 0;
}
public function record_paid_order(array $order): int {
if (!\AKSRT\Settings\Settings::affiliate_enabled()) return 0;
$type = sanitize_key((string) ($order['product_type'] ?? ''));
@@ -43,13 +48,19 @@ final class CommissionLedger {
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;
try {
$amount = \AKSRT\Ledger\AssetAmount::balance(is_string($order['amount'] ?? null) ? $order['amount'] : '');
} catch (\InvalidArgumentException $e) {
return -1;
}
if ($order_no === '' || $buyer <= 0 || $amount->minor() <= 0) return 0;
$promoter = ReferralAttribution::get_instance()->resolve($buyer);
if ($promoter <= 0) return 0;
$ratio = (float) \AKSRT\Settings\PaymentRuntime::affiliate_ratio_for_user($promoter);
$commission = round($amount * $ratio / 100, 2);
if ($commission <= 0) return 0;
$ratio = \AKSRT\Settings\PaymentRuntime::affiliate_ratio_for_user($promoter);
$commission_minor = (intdiv($amount->minor(), 100) * $ratio)
+ intdiv((($amount->minor() % 100) * $ratio) + 50, 100);
if ($commission_minor <= 0) return 0;
$commission = \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $commission_minor)->decimal();
global $wpdb;
$table = $this->table('commission_ledger');
$identity = $order_type === 'credits' && !empty($order['exchange_ref'])
@@ -58,8 +69,23 @@ final class CommissionLedger {
$key = hash('sha256', 'affiliate:' . $identity);
$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));
$ok = $wpdb->insert($table, ['promoter_id'=>$promoter,'source_user_id'=>$buyer,'order_number'=>$order_no,'order_type'=>$order_type,'eligible_base'=>$amount->decimal(),'ratio'=>$ratio,'original_amount'=>$commission,'adjustment_total'=>'0.00','status'=>'pending','idempotency_key'=>$key,'available_at'=>$available,'created_at'=>$now], ['%d','%d','%s','%s','%s','%d','%s','%s','%s','%s','%s','%s']);
if ($ok) {
return (int) $wpdb->insert_id;
}
$existing = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE idempotency_key=%s", $key), ARRAY_A);
if (!is_array($existing)
|| (int) $existing['promoter_id'] !== $promoter
|| (int) $existing['source_user_id'] !== $buyer
|| (string) $existing['order_number'] !== $order_no
|| (string) $existing['order_type'] !== $order_type
|| (string) $existing['eligible_base'] !== $amount->decimal()
|| (int) $existing['ratio'] !== $ratio
|| (string) $existing['original_amount'] !== $commission
) {
return -1;
}
return (int) $existing['id'];
}
public function settle_due(int $limit = 100): int {
global $wpdb; $table = $this->table('commission_ledger');
@@ -68,79 +94,322 @@ final class CommissionLedger {
foreach ((array) $rows as $row) if ($this->settle_one((int)$row['id'])) $count++;
return $count;
}
private function settle_one(int $id): bool {
private function settle_one(int $id, string $operation_key = '') {
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;
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id=%d", $id), ARRAY_A);
if (!is_array($row)) return false;
try {
$original_minor = $this->signed_balance_minor((string) ($row['original_amount'] ?? '0'));
$adjustment_minor = $this->signed_balance_minor((string) ($row['adjustment_total'] ?? '0'));
if ($adjustment_minor > 0 && $original_minor > PHP_INT_MAX - $adjustment_minor) {
return false;
}
if ($adjustment_minor < 0 && $original_minor < PHP_INT_MIN - $adjustment_minor) {
return false;
}
$amount_minor = $original_minor + $adjustment_minor;
} catch (\InvalidArgumentException $e) { return false; }
if ($amount_minor <= 0 || (int)$row['promoter_id'] <= 0) return false;
$guard = function () use ($table, $id, $row) {
global $wpdb;
$current = $wpdb->get_row($wpdb->prepare("SELECT promoter_id, original_amount, adjustment_total, status FROM {$table} WHERE id=%d FOR UPDATE", $id), ARRAY_A);
if (!is_array($current) || (string)$current['status'] !== 'pending'
|| (int)$current['promoter_id'] !== (int)$row['promoter_id']
|| (string)$current['original_amount'] !== (string)$row['original_amount']
|| (string)$current['adjustment_total'] !== (string)$row['adjustment_total']) {
return new \WP_Error('aksrt_commission_state', __('佣金记录状态已变化','aksrt'), ['status'=>409]);
}
return true;
};
$mutation = function () use ($table, $id) {
global $wpdb;
return $wpdb->query($wpdb->prepare("UPDATE {$table} SET status='settled', settled_at=%s WHERE id=%d AND status='pending'", current_time('mysql', true), $id)) === 1
? true : new \WP_Error('aksrt_commission_state', __('佣金记录状态已变化','aksrt'), ['status'=>409]);
};
$result = \AKSRT\Ledger\LedgerService::get_instance()->credit(
(int)$row['promoter_id'], \AKSRT\Ledger\AssetType::BALANCE, $amount_minor,
$operation_key !== '' ? $operation_key : \AKSRT\Ledger\OperationKey::deterministic('commission-settle', [$id]), 'affiliate_commission',
['type'=>'commission','id'=>$id], sprintf(__('推广佣金结算(订单 %s)','aksrt'), $row['order_number']),
0, [], $mutation, $guard
);
return is_wp_error($result) ? false : $result;
}
/** @return array<string,mixed>|\WP_Error */
public function prepare_full_refund(string $order_number) {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table('commission_ledger')} WHERE order_number = %s LIMIT 1",
$order_number
), ARRAY_A);
if (!is_array($row) || (string) ($row['status'] ?? '') === 'cancelled') {
return ['active' => false];
}
try {
$original_minor = $this->signed_balance_minor((string) ($row['original_amount'] ?? '0'));
$adjustment_minor = $this->signed_balance_minor((string) ($row['adjustment_total'] ?? '0'));
} catch (\InvalidArgumentException $e) {
return new \WP_Error('aksrt_commission_amount', __('返佣金额无效', 'aksrt'), ['status' => 500]);
}
$remaining_minor = $original_minor + $adjustment_minor;
if ($remaining_minor <= 0) {
return ['active' => false];
}
$status = (string) ($row['status'] ?? '');
if (!in_array($status, ['pending', 'settled'], true)) {
return new \WP_Error('aksrt_commission_state', __('返佣记录状态不可退款', 'aksrt'), ['status' => 409]);
}
return [
'active' => true,
'id' => (int) $row['id'],
'promoter_id' => (int) $row['promoter_id'],
'status' => $status,
'original_amount' => (string) $row['original_amount'],
'adjustment_total' => (string) $row['adjustment_total'],
'amount_minor' => $remaining_minor,
'source_key' => hash('sha256', 'order-full-refund:' . $order_number),
];
}
/** @param array<string,mixed> $plan */
public function guard_full_refund(array $plan) {
if (empty($plan['active'])) return true;
global $wpdb;
$current = $wpdb->get_row($wpdb->prepare(
"SELECT promoter_id, status, original_amount, adjustment_total FROM {$this->table('commission_ledger')} WHERE id = %d FOR UPDATE",
(int) $plan['id']
), ARRAY_A);
if (!is_array($current)
|| (int) ($current['promoter_id'] ?? 0) !== (int) $plan['promoter_id']
|| (string) ($current['status'] ?? '') !== (string) $plan['status']
|| (string) ($current['original_amount'] ?? '') !== (string) $plan['original_amount']
|| (string) ($current['adjustment_total'] ?? '') !== (string) $plan['adjustment_total']
) {
return new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]);
}
return true;
}
/** @param array<string,mixed> $plan */
public function apply_full_refund(array $plan) {
if (empty($plan['active'])) return true;
global $wpdb;
$negative = '-' . \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, (int) $plan['amount_minor'])->decimal();
$inserted = $wpdb->insert($this->table('commission_adjustment'), [
'ledger_id' => (int) $plan['id'],
'promoter_id' => (int) $plan['promoter_id'],
'amount' => $negative,
'reason' => 'order_refund',
'source_key' => (string) $plan['source_key'],
'operator_id' => get_current_user_id(),
'created_at' => current_time('mysql', true),
], ['%d', '%d', '%s', '%s', '%s', '%d', '%s']);
if ($inserted !== 1) {
return new \WP_Error('aksrt_commission_adjustment', __('返佣退款记录写入失败', 'aksrt'), ['status' => 409]);
}
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table('commission_ledger')} SET adjustment_total = adjustment_total + %s, status = 'cancelled' WHERE id = %d AND status = %s",
$negative,
(int) $plan['id'],
(string) $plan['status']
));
return $updated === 1
? true
: new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]);
}
private function signed_balance_minor(string $decimal): int {
$negative = str_starts_with($decimal, '-');
$value = $negative ? substr($decimal, 1) : $decimal;
$minor = \AKSRT\Ledger\AssetAmount::balance($value)->minor();
return $negative ? -$minor : $minor;
}
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) ?: [];
$withdrawn = 0.0;
$withdrawn = '0.00';
$withdrawal_table = $this->table('withdrawal');
if ($withdrawal_table !== '') {
$withdrawn = (float) $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(CASE WHEN status='completed' THEN CASE WHEN gross_amount > 0 THEN gross_amount ELSE amount END ELSE 0 END),0) FROM {$withdrawal_table} WHERE user_id=%d",
$withdrawn = $this->balance_decimal((string) $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(CASE WHEN status='completed' THEN gross_amount ELSE 0 END),0) FROM {$withdrawal_table} WHERE user_id=%d",
$user_id
));
)));
}
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,'withdrawn'=>round($withdrawn,2),'debt'=>round((float)get_user_meta($user_id,self::DEBT_META,true),2)];
return ['total'=>$this->balance_decimal((string)($row['total']??'0')),'pending'=>$this->balance_decimal((string)($row['pending']??'0')),'settled'=>$this->balance_decimal((string)($row['settled']??'0')),'wallet'=>\AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),'withdrawn'=>$withdrawn];
}
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')]);
$list_args = [
'page' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 20],
'status' => ['type' => 'string', 'enum' => ['pending', 'processing', 'completed', 'rejected', 'settled', 'cancelled']],
];
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' => [$this, 'check_user_rest'],
]);
register_rest_route('aksrt/v1', '/earnings/commissions', [
'methods' => \WP_REST_Server::READABLE,
'callback' => fn(\WP_REST_Request $request) => rest_ensure_response($this->list_entries(get_current_user_id(), $request->get_params())),
'permission_callback' => [$this, 'check_user_rest'],
'args' => $list_args,
]);
register_rest_route('aksrt/v1', '/earnings/withdrawals', [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => fn(\WP_REST_Request $request) => rest_ensure_response($this->withdrawals(get_current_user_id(), $request->get_params())),
'permission_callback' => [$this, 'check_user_rest'],
'args' => $list_args,
],
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'create_withdrawal'],
'permission_callback' => [$this, 'check_user_write_rest'],
'args' => [
'amount' => ['type' => 'string', 'required' => true, 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
'method' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key'],
'account' => ['type' => 'string', 'required' => true, 'minLength' => 1, 'maxLength' => 500, 'sanitize_callback' => 'sanitize_text_field'],
],
],
]);
register_rest_route('aksrt/v1', '/payment/affiliate/ledger/(?P<id>\d+)/settle', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'admin_settle'],
'permission_callback' => [$this, 'check_admin_write_rest'],
'args' => ['id' => ['type' => 'integer', 'minimum' => 1, 'required' => true]],
]);
register_rest_route('aksrt/v1', '/payment/affiliate/ledger/(?P<id>\d+)/cancel', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'admin_cancel'],
'permission_callback' => [$this, 'check_admin_write_rest'],
'args' => ['id' => ['type' => 'integer', 'minimum' => 1, 'required' => true]],
]);
register_rest_route('aksrt/v1', '/payment/affiliate/overview', [
'methods' => \WP_REST_Server::READABLE,
'callback' => fn() => rest_ensure_response($this->admin_overview()),
'permission_callback' => [$this, 'check_admin_rest'],
]);
register_rest_route('aksrt/v1', '/payment/affiliate/ledger', [
'methods' => \WP_REST_Server::READABLE,
'callback' => fn(\WP_REST_Request $request) => rest_ensure_response($this->admin_ledger($request->get_params())),
'permission_callback' => [$this, 'check_admin_rest'],
'args' => $list_args,
]);
register_rest_route('aksrt/v1', '/payment/withdrawals/(?P<id>\d+)/transition', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'transition_withdrawal'],
'permission_callback' => [$this, 'check_admin_write_rest'],
'args' => [
'id' => ['type' => 'integer', 'minimum' => 1, 'required' => true],
'status' => ['type' => 'string', 'required' => true, 'enum' => ['processing', 'completed', 'rejected']],
'note' => ['type' => 'string', 'default' => '', 'maxLength' => 1000, 'sanitize_callback' => 'sanitize_textarea_field'],
],
]);
}
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 check_user_rest(\WP_REST_Request $request) {
return is_user_logged_in() ? true : new \WP_Error('aksrt_auth_required', __('请先登录', 'aksrt'), ['status' => 401]);
}
public function check_user_write_rest(\WP_REST_Request $request) {
$allowed = $this->check_user_rest($request);
return is_wp_error($allowed) ? $allowed : $this->verify_rest_nonce($request);
}
public function check_admin_rest(\WP_REST_Request $request) {
return current_user_can('manage_options') ? true : new \WP_Error('aksrt_admin_forbidden', __('无权执行此操作', 'aksrt'), ['status' => 403]);
}
public function check_admin_write_rest(\WP_REST_Request $request) {
$allowed = $this->check_admin_rest($request);
return is_wp_error($allowed) ? $allowed : $this->verify_rest_nonce($request);
}
private function verify_rest_nonce(\WP_REST_Request $request) {
$nonce = (string) $request->get_header('X-WP-Nonce');
return $nonce !== '' && wp_verify_nonce($nonce, 'wp_rest')
? true
: new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
}
public function admin_settle(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-commission-settle', get_current_user_id());
if (is_wp_error($operation_key)) return $operation_key;
$result = $this->settle_one((int) $request['id'], $operation_key);
if (!$result instanceof \AKSRT\Ledger\LedgerResult) {
return new \WP_REST_Response(['success' => false, 'message' => __('佣金结算失败', 'aksrt')], 409);
}
return new \WP_REST_Response(['success' => true, 'transaction_id' => $result->transaction_id, 'replayed' => $result->replayed], 200);
}
public function admin_cancel(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-commission-cancel', get_current_user_id());
if (is_wp_error($operation_key)) return $operation_key;
global $wpdb;
$id = (int) $request['id'];
$payload_hash = hash('sha256', (string) wp_json_encode(['id' => $id, 'status' => 'cancelled']));
if ($wpdb->query('START TRANSACTION') === false) {
return new \WP_Error('aksrt_commission_db', __('无法开启返佣取消事务', 'aksrt'), ['status' => 500]);
}
$row = $wpdb->get_row($wpdb->prepare("SELECT status, cancel_operation_key, cancel_payload_hash FROM {$this->table('commission_ledger')} WHERE id=%d FOR UPDATE", $id), ARRAY_A);
if (!is_array($row)) {
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_commission_missing', __('返佣记录不存在', 'aksrt'), ['status' => 404]);
}
$stored_key = (string) ($row['cancel_operation_key'] ?? '');
$stored_hash = (string) ($row['cancel_payload_hash'] ?? '');
if ($stored_key !== '') {
if (hash_equals($stored_key, $operation_key) && $stored_hash !== '' && hash_equals($stored_hash, $payload_hash) && (string) $row['status'] === 'cancelled') {
if ($wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_commission_db', __('返佣取消重放失败', 'aksrt'), ['status' => 500]);
}
return new \WP_REST_Response(['success' => true, 'operation_key' => $operation_key, 'transaction_id' => 0, 'replayed' => true], 200);
}
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_commission_idempotency_conflict', __('相同操作标识已用于其他返佣取消请求', 'aksrt'), ['status' => 409]);
}
if ((string) ($row['status'] ?? '') !== 'pending') {
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]);
}
$updated = $wpdb->update($this->table('commission_ledger'), [
'status' => 'cancelled',
'cancel_operation_key' => $operation_key,
'cancel_payload_hash' => $payload_hash,
], ['id' => $id, 'status' => 'pending'], ['%s', '%s', '%s'], ['%d', '%s']);
if ($updated !== 1 || $wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
$conflict = $wpdb->get_var($wpdb->prepare("SELECT id FROM {$this->table('commission_ledger')} WHERE cancel_operation_key=%s", $operation_key));
return new \WP_Error($conflict ? 'aksrt_commission_idempotency_conflict' : 'aksrt_commission_db', $conflict ? __('相同操作标识已用于其他返佣取消请求', 'aksrt') : __('返佣取消失败', 'aksrt'), ['status' => $conflict ? 409 : 500]);
}
return new \WP_REST_Response(['success' => true, 'operation_key' => $operation_key, 'transaction_id' => 0, 'replayed' => false], 200);
}
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'=>$this->balance_decimal((string)($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();
$user_id=get_current_user_id(); $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();
$raw_amount=$request->get_param('amount');
try { $amount=\AKSRT\Ledger\AssetAmount::balance(is_string($raw_amount)?$raw_amount:''); }
catch (\InvalidArgumentException $e) { return new \WP_Error('affiliate_withdraw_amount',__('提现金额格式无效。','aksrt'),['status'=>400]); }
$operation_key=\AKSRT\Ledger\OperationKey::from_request($request,'affiliate-withdraw',$user_id); if(is_wp_error($operation_key)) return $operation_key;
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)$settings['min_amount'])) 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]);
$minimum=\AKSRT\Ledger\AssetAmount::balance(is_string($settings['min_amount'] ?? null) ? $settings['min_amount'] : '');
if ($amount->minor() < $minimum->minor()) return new \WP_Error('affiliate_withdraw_min',__('未达到最低提现金额。','aksrt'),['status'=>400]);
if (!in_array($method,(array)$settings['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);
$result=\AKSRT\Payment\Withdrawal::get_instance()->create_balance_request($user_id,$amount->minor(),$method,$account,$settings,$operation_key); 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); }
public function transition_withdrawal(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-withdraw-transition', get_current_user_id());
if (is_wp_error($operation_key)) return $operation_key;
$status = sanitize_key((string) $request->get_param('status'));
$result = \AKSRT\Payment\Withdrawal::get_instance()->process_status_change((int) $request['id'], $status, (string) $request->get_param('note'), $operation_key);
if (is_wp_error($result)) return $result;
$data = is_array($result) ? $result : [];
return new \WP_REST_Response(array_merge(['success' => true, 'status' => $status], $data), 200);
}
private function balance_decimal(string $value): string {
try {
return \AKSRT\Ledger\AssetAmount::balance($value)->decimal();
} catch (\InvalidArgumentException $e) {
return '0.00';
}
}
}