Files
wordpress-theme-aksrt/src/Payment/CommissionLedger.php
T

566 lines
29 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/** Forward-only affiliate commission ledger. */
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
final class CommissionLedger {
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('rest_api_init', [$this, 'register_routes']);
add_action('aksrt_order_commission', [$this, 'record_order_transition']);
// 佣金已改为即时入账,历史遗留的结算 cron 需主动清理(无兼容层)。
$timestamp = wp_next_scheduled('aksrt_affiliate_settle_due');
if ($timestamp) {
wp_unschedule_event($timestamp, 'aksrt_affiliate_settle_due');
}
}
private function table(string $name): string { return \AKSRT\Core\DBMigrator::table($name); }
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, string $order_number): bool {
$result = $this->record_paid_order([
'order_number' => $order_number,
'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 {
$currency = sanitize_key((string) ($order['currency'] ?? ''));
if ($currency === \AKSRT\Ledger\AssetType::POINTS->value) return 0;
if (!\AKSRT\Settings\Settings::affiliate_enabled()) return 0;
$type = sanitize_key((string) ($order['product_type'] ?? ''));
$map = [
'vip' => 'membership',
'resource' => 'content',
'reading' => 'content',
'download' => 'content',
'image' => 'content',
'video' => 'content',
];
$order_type = $map[$type] ?? $type;
$settings = \AKSRT\Settings\Settings::get_affiliate_settings();
$order_no = sanitize_text_field((string) ($order['order_number'] ?? ''));
$buyer = (int) ($order['user_id'] ?? 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);
// 自我返佣(参考主题「自己推广自己」,购买时归因):无外部推广人且开关开启时,
// 买家用自己的(有效)推广码即成为自身推广人。resolve() 结构上永不返回自身,
// 故这里显式补齐,且要求买家推广码有效以复用「禁用即停返佣」的口径。
if ($promoter <= 0
&& !empty($settings['self_rebate'])
&& ReferralAttribution::get_instance()->is_promoter_code_active($buyer)) {
$promoter = $buyer;
}
if ($promoter <= 0) return 0;
// 开关开启时,推广者的推广码已被禁用(或缺失)则不再产生新佣金。
if (!empty($settings['disabled_code_stops_commission'])
&& !ReferralAttribution::get_instance()->is_promoter_code_active($promoter)) {
return 0;
}
// 返佣订单类型按推广者会员等级分档(参考主题:每档独立勾选)。
$promoter_level = function_exists('aksrt_get_user_vip_level') ? (int) aksrt_get_user_vip_level($promoter) : 0;
if (!in_array($order_type, \AKSRT\Settings\PaymentRuntime::affiliate_order_types_for_level($promoter_level), true)) return 0;
$ratio = \AKSRT\Settings\PaymentRuntime::affiliate_ratio_for_user($promoter);
$commission_minor = \AKSRT\Ledger\AssetAmount::percentage($amount->minor(), $ratio);
if ($commission_minor <= 0) return 0;
$commission = \AKSRT\Ledger\AssetAmount::commission_from_minor($commission_minor)->decimal();
global $wpdb;
$table = $this->table('commission_ledger');
$identity = $order_type === 'credits' && !empty($order['exchange_ref'])
? 'credits:' . sanitize_key((string) $order['exchange_ref'])
: $order_no . ':' . $order_type;
$key = hash('sha256', 'affiliate:' . $identity);
$now = current_time('mysql', true);
// 佣金明细账先落库(status=credited),作为入账溯源;随后把佣金即时
// credit 到 COMMISSION 资金池(先抵扣欠款)。两步分别幂等。
$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'=>'credited','idempotency_key'=>$key,'created_at'=>$now], ['%d','%d','%s','%s','%s','%d','%s','%s','%s','%s','%s']);
if ($ok) {
$ledger_id = (int) $wpdb->insert_id;
$this->credit_commission($promoter, $commission_minor, $ledger_id, $order_no);
return $ledger_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;
}
// 明细行已存在(订单支付回调重放):入账用确定性 operation_key 幂等,安全重放。
$this->credit_commission($promoter, $commission_minor, (int) $existing['id'], $order_no);
return (int) $existing['id'];
}
/**
* 佣金即时入账:全额 credit 到 COMMISSION 资金池,再机会性偿还历史欠款。
*
* 两段式而非「净额入池」,是为了金额守恒可证:
* 1) 入池分录金额固定 = 佣金全额,operation_key 由明细行 id 派生,天然幂等/防重放。
* 2) 偿债在独立账务事务里做:池扣减 min(欠款, 池余额) 且欠款同额递减,两者在同一
* 事务内以锁定值为准,等额增减 → 永不凭空增减钱;并发导致失败则本次跳过,
* 欠款留到下一笔佣金再偿,绝不出错。
*/
private function credit_commission(int $promoter, int $commission_minor, int $ledger_id, string $order_no): void {
if ($promoter <= 0 || $commission_minor <= 0) {
return;
}
$credit = \AKSRT\Ledger\LedgerService::get_instance()->credit(
$promoter, \AKSRT\Ledger\AssetType::COMMISSION, $commission_minor,
\AKSRT\Ledger\OperationKey::deterministic('commission-credit', [$ledger_id]),
'affiliate_commission',
['type' => 'commission', 'id' => $ledger_id],
sprintf(__('推广佣金入账(订单 %s)', 'aksrt'), $order_no)
);
if (!is_wp_error($credit)) {
$this->repay_debt_from_pool($promoter);
}
}
/**
* 机会性偿债:从 COMMISSION 池扣减 min(欠款, 池余额),欠款同额递减,单个账务事务内原子完成。
* 偿债额 ≤ 池余额 → 扣减不会透支;扣减额 == 欠款递减额 → 金额守恒。并发/余额不足则本次跳过。
*/
private function repay_debt_from_pool(int $promoter): void {
if ($promoter <= 0) {
return;
}
global $wpdb;
$debt_table = $this->table('commission_debt');
$debt = (int) $wpdb->get_var($wpdb->prepare("SELECT debt_minor FROM {$debt_table} WHERE user_id=%d", $promoter));
if ($debt <= 0) {
return;
}
$pool = \AKSRT\Ledger\LedgerService::get_instance()->balance($promoter, \AKSRT\Ledger\AssetType::COMMISSION);
$repay = max(0, min($debt, $pool));
if ($repay <= 0) {
return;
}
// guard 内锁定欠款行复核:并发偿债已把欠款降到不足 repay,则可重试/跳过。
$guard = function () use ($debt_table, $promoter, $repay) {
global $wpdb;
$locked = (int) $wpdb->get_var($wpdb->prepare("SELECT debt_minor FROM {$debt_table} WHERE user_id=%d FOR UPDATE", $promoter));
return $locked >= $repay ? true : new \WP_Error('aksrt_commission_debt', __('佣金欠款已变化', 'aksrt'), ['status' => 409]);
};
$mutation = function () use ($debt_table, $promoter, $repay) {
global $wpdb;
return $wpdb->query($wpdb->prepare(
"UPDATE {$debt_table} SET debt_minor = debt_minor - %d, updated_at = %s WHERE user_id=%d AND debt_minor >= %d",
$repay, current_time('mysql', true), $promoter, $repay
)) === 1 ? true : new \WP_Error('aksrt_commission_debt', __('佣金欠款抵扣失败', 'aksrt'), ['status' => 409]);
};
// 偿债是机会性动作、由锁定的 debt_minor>=repay 保证不重复扣减,无需跨调用幂等;
// 故用唯一 operation_key,避免 (promoter,debt,pool) 元组偶然重现被误判为重放而漏偿。
\AKSRT\Ledger\LedgerService::get_instance()->debit(
$promoter, \AKSRT\Ledger\AssetType::COMMISSION, $repay,
\AKSRT\Ledger\OperationKey::unique('commission-debt-repay'),
'affiliate_commission_debt',
['type' => 'commission_debt', 'id' => $promoter],
__('推广佣金偿还历史欠款', 'aksrt'),
0, [], $mutation, $guard
);
}
/**
* 退款准备:返回本订单待收回的佣金计划。即时入账模型下佣金状态只有
* credited | cancelled,可收回额 = original + 既有调整(正值)。
*
* @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 ($status !== 'credited') {
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;
}
/**
* 退款应用(在买家退款账务事务内运行):仅做佣金明细账的登记——冲正
* adjustment_total 并置为 cancelled,写入不可变调整记录。这里不移动资金池
* (避免池不足时中止买家退款);真正的池收回 + 欠款挂账由 clawback_after_refund
* 在退款事务提交后独立执行。
*
* @param array<string,mixed> $plan
*/
public function apply_full_refund(array $plan) {
if (empty($plan['active'])) return true;
global $wpdb;
$negative = '-' . \AKSRT\Ledger\AssetAmount::commission_from_minor((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]);
}
/**
* 退款后的资金池收回(在买家退款事务提交后调用,不属于买家退款关键路径):
* 从 COMMISSION 池收回 min(应收回, 池余额),不足部分记为欠款,后续新佣金优先偿还。
*
* 金额守恒 + 幂等:
* - 池扣减带重试,locked 池余额 < take 时重试并缩小 take,绝不透支中止;
* - 池扣减用 source_key 派生的确定性 operation_key,重复调用天然去重;
* - 欠款挂账用独立 source_key 一次性插入 commission_adjustment(唯一约束),
* 仅首次插入成功者累加欠款,杜绝重复挂账。
*
* @param array<string,mixed> $plan
*/
public function clawback_after_refund(array $plan): void {
if (empty($plan['active'])) {
return;
}
$promoter = (int) ($plan['promoter_id'] ?? 0);
$clawback = (int) ($plan['amount_minor'] ?? 0);
$source_key = (string) ($plan['source_key'] ?? '');
if ($promoter <= 0 || $clawback <= 0 || $source_key === '') {
return;
}
$ledger = \AKSRT\Ledger\LedgerService::get_instance();
// 收回的幂等边界是明细行的 cancelled 状态(apply_full_refund 已置位且带唯一 source_key):
// 单笔退款只会调用一次收回。这里每次 debit 用唯一 operation_key,避免「不同金额复用
// 同一确定性 key 被当作重放而漏收」。池扣减额恒 ≤ 锁定池余额,绝不透支。
$remaining = $clawback;
for ($attempt = 1; $attempt <= 5 && $remaining > 0; $attempt++) {
$pool = $ledger->balance($promoter, \AKSRT\Ledger\AssetType::COMMISSION);
$take = max(0, min($remaining, $pool));
if ($take <= 0) {
break;
}
$guard = function () use ($promoter, $take, $ledger) {
$locked = $ledger->balance($promoter, \AKSRT\Ledger\AssetType::COMMISSION);
return $locked >= $take ? true : new \WP_Error('aksrt_commission_pool', __('佣金池余额已变化', 'aksrt'), ['status' => 409]);
};
$result = $ledger->debit(
$promoter, \AKSRT\Ledger\AssetType::COMMISSION, $take,
\AKSRT\Ledger\OperationKey::unique('commission-refund-clawback'), 'affiliate_commission_refund',
['type' => 'commission_refund', 'key' => $source_key],
__('推广佣金退款收回', 'aksrt'),
get_current_user_id(), [], null, $guard
);
if (!is_wp_error($result)) {
$remaining -= $take;
continue;
}
// 池余额被并发提现/收回改变:重试并以新的锁定余额缩小 take。
}
if ($remaining > 0) {
$this->record_debt($promoter, $remaining, $source_key, (int) ($plan['id'] ?? 0));
}
}
/**
* 挂账欠款:以独立 source_key 一次性写入调整记录(唯一约束保证幂等),
* 仅首次插入成功时累加用户欠款计数。
*/
private function record_debt(int $promoter, int $shortfall_minor, string $source_key, int $ledger_id): void {
if ($promoter <= 0 || $shortfall_minor <= 0) {
return;
}
global $wpdb;
$debt_source = hash('sha256', 'commission-refund-debt:' . $source_key);
$inserted = $wpdb->insert($this->table('commission_adjustment'), [
'ledger_id' => $ledger_id,
'promoter_id' => $promoter,
'amount' => '-' . \AKSRT\Ledger\AssetAmount::commission_from_minor($shortfall_minor)->decimal(),
'reason' => 'refund_debt',
'source_key' => $debt_source,
'operator_id' => get_current_user_id(),
'created_at' => current_time('mysql', true),
], ['%d', '%d', '%s', '%s', '%s', '%d', '%s']);
if ($inserted !== 1) {
// 已挂账(重复调用):唯一约束拦截,不重复累加欠款。
return;
}
$debt_table = $this->table('commission_debt');
$now = current_time('mysql', true);
$wpdb->query($wpdb->prepare(
"INSERT INTO {$debt_table} (user_id, debt_minor, updated_at) VALUES (%d, %d, %s)
ON DUPLICATE KEY UPDATE debt_minor = debt_minor + %d, updated_at = %s",
$promoter, $shortfall_minor, $now, $shortfall_minor, $now
));
}
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;
}
/**
* 佣金总览(即时入账模型):
* - total 累计佣金(明细账原始佣金累加,不含退款冲正)
* - pool 当前可提现佣金(COMMISSION 资金池余额)
* - withdrawn 已提现(佣金来源、已完成的提现毛额累加)
* - debt 当前挂账欠款(退款收回不足产生,后续佣金优先偿还)
* 无冻结结算期,故不再有 pending/settled/wallet 语义。
*/
public function overview(int $user_id): array {
global $wpdb; $table=$this->table('commission_ledger');
$total = (string) $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(original_amount),0) FROM {$table} WHERE promoter_id=%d",
$user_id
));
$withdrawn = '0.00';
$withdrawal_table = $this->table('withdrawal');
if ($withdrawal_table !== '') {
$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 AND account_info LIKE %s",
$user_id,
'%"source":"commission"%'
)));
}
$debt_minor = (int) $wpdb->get_var($wpdb->prepare(
"SELECT debt_minor FROM {$this->table('commission_debt')} WHERE user_id=%d",
$user_id
));
$pool_minor = \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::COMMISSION);
return [
'total' => $this->balance_decimal($total),
'pool' => \AKSRT\Ledger\AssetAmount::commission_from_minor($pool_minor)->decimal(),
'withdrawn' => $withdrawn,
'debt' => \AKSRT\Ledger\AssetAmount::commission_from_minor(max(0, $debt_minor))->decimal(),
];
}
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,['credited','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 {
$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,
]);
$withdrawal_args = $list_args;
$withdrawal_args['per_page']['default'] = 10;
$withdrawal_args['per_page']['maximum'] = 10;
$withdrawal_args['source'] = ['type' => 'string', 'enum' => ['balance', 'commission'], 'default' => 'commission'];
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' => $withdrawal_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'],
'source' => ['type' => 'string', 'enum' => ['balance', 'commission'], 'default' => 'commission'],
],
],
]);
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 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 withdrawals(int $user_id, array $args): array {
$service = \AKSRT\Payment\Withdrawal::get_instance();
$page = max(1, (int) ($args['page'] ?? 1));
$per_page = 10;
$status = sanitize_key((string) ($args['status'] ?? ''));
// 提现记录按来源隔离:历史弹窗仅在余额页调用(无 source),默认 balance;
// 佣金页如需查询佣金提现记录须显式传 source=commission。
$source = sanitize_key((string) ($args['source'] ?? '')) === \AKSRT\Payment\Withdrawal::SOURCE_COMMISSION
? \AKSRT\Payment\Withdrawal::SOURCE_COMMISSION
: \AKSRT\Payment\Withdrawal::SOURCE_BALANCE;
$filters = ['user_id' => $user_id, 'source' => $source];
if ($status !== '') {
$filters['status'] = $status;
}
$total = $service->get_count($filters);
$max_pages = max(1, (int) ceil($total / $per_page));
$page = min($page, $max_pages);
return [
'items' => $service->get_user_withdrawals($user_id, [
'status' => $status,
'source' => $source,
'limit' => $per_page,
'offset' => ($page - 1) * $per_page,
]),
'total' => $total,
'page' => $page,
'per_page' => $per_page,
'max_pages' => $max_pages,
];
}
public function create_withdrawal(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
$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();
// 提现来源:commission → 佣金池;其余 → 余额钱包(充值 + 创作分成)。
$source = sanitize_key((string) $request->get_param('source')) === \AKSRT\Payment\Withdrawal::SOURCE_COMMISSION
? \AKSRT\Payment\Withdrawal::SOURCE_COMMISSION
: \AKSRT\Payment\Withdrawal::SOURCE_BALANCE;
$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 ($source === \AKSRT\Payment\Withdrawal::SOURCE_COMMISSION) {
if (empty(\AKSRT\Settings\Settings::get_affiliate_settings()['enabled'])) return new \WP_Error('affiliate_disabled',__('推广返佣未启用。','aksrt'),['status'=>403]);
} else {
$all=\AKSRT\Settings\Settings::get_all();
if (empty($all['pay_balance_withdraw_s'])) return new \WP_Error('balance_withdraw_disabled',__('余额提现未启用。','aksrt'),['status'=>403]);
}
if (class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Settings\Capabilities::user_can('balance_withdraw', $user_id)) {
return new \WP_Error('withdraw_forbidden',__('当前账号不可提现。','aksrt'),['status'=>403]);
}
$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->minor(),$method,$account,$settings,$operation_key,$source); 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(\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';
}
}
}