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|\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 $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 $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 $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\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'; } } }