insert($this->table(), [ 'user_id' => $user_id, 'amount' => $amount, 'method' => sanitize_key($method), 'account_info' => sanitize_textarea_field($account_info), 'status' => self::STATUS_PENDING, 'created_at' => current_time('mysql'), ], ['%d', '%f', '%s', '%s', '%s', '%s']); return (int) $wpdb->insert_id; } /** * Get single row. * * @return array|null */ public function get(int $withdrawal_id): ?array { global $wpdb; if ($withdrawal_id <= 0) { return null; } $row = $wpdb->get_row( $wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d LIMIT 1", $withdrawal_id), ARRAY_A ); return is_array($row) ? $row : null; } /** * 更新提现状态(无余额副作用)。优先使用 process_status_change()。 */ public function update_status(int $withdrawal_id, string $new_status, string $note = ''): bool { global $wpdb; $update = ['status' => sanitize_key($new_status)]; $format = ['%s']; if ($new_status === self::STATUS_COMPLETED || $new_status === self::STATUS_REJECTED) { $update['processed_at'] = current_time('mysql'); $format[] = '%s'; } if ($note !== '') { $update['note'] = sanitize_textarea_field($note); $format[] = '%s'; } return $wpdb->update($this->table(), $update, ['id' => $withdrawal_id], $format, ['%d']) !== false; } /** * Status transition with balance side-effects. * Reject of balance-source holds refunds gross_amount once (idempotent). * * @return true|WP_Error */ public function process_status_change(int $withdrawal_id, string $new_status, string $note = '') { $new_status = sanitize_key($new_status); $allowed = [ self::STATUS_PENDING, self::STATUS_APPROVED, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED, ]; if ($withdrawal_id <= 0 || !in_array($new_status, $allowed, true)) { return new WP_Error('aksrt_withdraw_status', __('提现状态无效', 'aksrt')); } $row = $this->get($withdrawal_id); if (!$row) { return new WP_Error('aksrt_withdraw_missing', __('提现记录不存在', 'aksrt')); } $old_status = sanitize_key((string) ($row['status'] ?? '')); if ($old_status === $new_status && $note === '') { return true; } $meta = $this->parse_account_meta((string) ($row['account_info'] ?? '')); $user_id = (int) ($row['user_id'] ?? 0); // Refund frozen balance when moving into rejected (once). if ( $new_status === self::STATUS_REJECTED && $old_status !== self::STATUS_REJECTED && empty($meta['balance_refunded']) && $this->is_balance_source($meta) && class_exists('AKSRT_Balance') && $user_id > 0 ) { $gross = $this->resolve_gross_amount($row, $meta); if ($gross > 0) { $refund = AKSRT_Balance::add( $user_id, $gross, 'withdraw_refund', ['type' => 'withdraw', 'id' => $withdrawal_id], sprintf(__('提现拒绝退回 #%d', 'aksrt'), $withdrawal_id) ); if (is_wp_error($refund)) { return $refund; } $meta['balance_refunded'] = true; $meta['refunded_at'] = current_time('mysql'); } } $account_info = $this->encode_account_meta($meta, (string) ($row['account_info'] ?? '')); $merged_note = $this->merge_note((string) ($row['note'] ?? ''), $note); global $wpdb; $update = [ 'status' => $new_status, 'account_info' => $account_info, ]; $format = ['%s', '%s']; if ($new_status === self::STATUS_COMPLETED || $new_status === self::STATUS_REJECTED) { $update['processed_at'] = current_time('mysql'); $format[] = '%s'; } if ($merged_note !== '') { $update['note'] = $merged_note; $format[] = '%s'; } $ok = $wpdb->update($this->table(), $update, ['id' => $withdrawal_id], $format, ['%d']) !== false; if (!$ok) { return new WP_Error('aksrt_withdraw_update', __('提现状态更新失败', 'aksrt')); } // Notify user on terminal status (Zibll withdraw_reply parity). if (class_exists('AKSRT_Message') && $user_id > 0 && in_array($new_status, [self::STATUS_COMPLETED, self::STATUS_REJECTED], true)) { $gross = $this->resolve_gross_amount($row, $meta); $amount_txt = class_exists('AKSRT_Balance') ? AKSRT_Balance::format($gross) : number_format($gross, 2); if ($new_status === self::STATUS_REJECTED) { $title = __('提现已拒绝', 'aksrt'); $body = sprintf(__('您申请提现 %s 元已被拒绝,冻结金额已退回钱包。', 'aksrt'), $amount_txt); if ($note !== '') { $body .= ' ' . sprintf(__('备注:%s', 'aksrt'), $note); } } else { $title = __('提现已完成', 'aksrt'); $body = sprintf(__('您申请提现 %s 元已处理完成。', 'aksrt'), $amount_txt); } AKSRT_Message::get_instance()->send($user_id, 'withdraw', $body, [ 'title' => $title, 'target_url' => function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url('credits') : '', ]); } return true; } /** * Whether user has open (pending/processing/approved) withdrawals. */ public function has_open_withdrawal(int $user_id): bool { return $this->count_open($user_id) > 0; } /** * Count open withdrawals. */ public function count_open(int $user_id): int { global $wpdb; if ($user_id <= 0) { return 0; } return (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','approved','processing')", $user_id )); } /** * Sum of frozen gross amounts still open (balance source). */ public function sum_pending_hold(int $user_id): float { global $wpdb; if ($user_id <= 0) { return 0.0; } $rows = $wpdb->get_results($wpdb->prepare( "SELECT amount, account_info FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','approved','processing')", $user_id ), ARRAY_A); $total = 0.0; foreach ((array) $rows as $row) { $meta = $this->parse_account_meta((string) ($row['account_info'] ?? '')); if (!$this->is_balance_source($meta) || !empty($meta['balance_refunded'])) { continue; } $total += $this->resolve_gross_amount($row, $meta); } return round($total, 2); } /** * 获取用户提现列表 * * @param array{status?:string,limit?:int,offset?:int,open_only?:bool} $args * @return list> */ public function get_user_withdrawals(int $user_id, array $args = []): array { global $wpdb; $where = ['user_id = %d']; $params = [$user_id]; if (!empty($args['open_only'])) { $where[] = "status IN ('pending','approved','processing')"; } else { $status = sanitize_key((string) ($args['status'] ?? '')); if ($status !== '') { $where[] = 'status = %s'; $params[] = $status; } } $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); $offset = max(0, (int) ($args['offset'] ?? 0)); $sql = "SELECT * FROM {$this->table()} WHERE " . implode(' AND ', $where) . ' ORDER BY created_at DESC LIMIT %d OFFSET %d'; $params[] = $limit; $params[] = $offset; $rows = (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); return array_map([$this, 'normalize_row'], $rows); } /** * 获取所有提现列表(管理后台) * * @return list> */ public function get_all(array $args = []): array { global $wpdb; $where = []; $params = []; $status = sanitize_key((string) ($args['status'] ?? '')); if ($status !== '') { $where[] = 'status = %s'; $params[] = $status; } $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); $offset = max(0, (int) ($args['offset'] ?? 0)); $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; $sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; $params[] = $limit; $params[] = $offset; if (!empty($params)) { $rows = (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); } else { $rows = (array) $wpdb->get_results($sql, ARRAY_A); } return array_map([$this, 'normalize_row'], $rows); } public function get_count(array $args = []): int { global $wpdb; $status = sanitize_key((string) ($args['status'] ?? '')); if ($status !== '') { return (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$this->table()} WHERE status = %s", $status )); } return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$this->table()}"); } public function get_stats(): array { global $wpdb; $row = $wpdb->get_row( "SELECT COUNT(*) as total, SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, COALESCE(SUM(CASE WHEN status = 'completed' THEN amount ELSE 0 END), 0) as total_paid FROM {$this->table()}", ARRAY_A ); return [ 'total' => (int) ($row['total'] ?? 0), 'pending' => (int) ($row['pending'] ?? 0), 'completed' => (int) ($row['completed'] ?? 0), 'total_paid' => (float) ($row['total_paid'] ?? 0), ]; } /** * @param array $row * @return array */ private function normalize_row(array $row): array { $meta = $this->parse_account_meta((string) ($row['account_info'] ?? '')); $gross = $this->resolve_gross_amount($row, $meta); $row['meta'] = $meta; $row['gross_amount'] = $gross; $row['fee_rate'] = (int) ($meta['fee_rate'] ?? 0); $row['display_account'] = (string) ($meta['account'] ?? $row['account_info'] ?? ''); $row['source'] = (string) ($meta['source'] ?? 'balance'); return $row; } /** * @return array */ private function parse_account_meta(string $raw): array { $raw = trim($raw); if ($raw === '') { return []; } $decoded = json_decode($raw, true); return is_array($decoded) ? $decoded : ['account' => $raw]; } /** * @param array $meta */ private function encode_account_meta(array $meta, string $fallback_raw): string { if ($meta === []) { return $fallback_raw; } $json = wp_json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); return is_string($json) ? $json : $fallback_raw; } /** * @param array $meta */ private function is_balance_source(array $meta): bool { $source = sanitize_key((string) ($meta['source'] ?? 'balance')); // Empty / balance = cash wallet freeze path. return $source === '' || $source === 'balance'; } /** * @param array $row * @param array $meta */ private function resolve_gross_amount(array $row, array $meta): float { if (isset($meta['gross_amount'])) { return max(0, round((float) $meta['gross_amount'], 2)); } // Legacy rows: amount was net or gross without meta. return max(0, round((float) ($row['amount'] ?? 0), 2)); } private function merge_note(string $old, string $new): string { $new = sanitize_textarea_field($new); if ($new === '') { return sanitize_textarea_field($old); } $old = sanitize_textarea_field($old); if ($old === '') { return $new; } return $old . "\n" . $new; } }