setTimezone($tz) : new \DateTimeImmutable('now', $tz); $today_reset = new \DateTimeImmutable($local_now->format('Y-m-d') . ' ' . $reset_time . ':00', $tz); $start = $local_now < $today_reset ? $today_reset->modify('-1 day') : $today_reset; return ['start' => $start->format('Y-m-d H:i:s'), 'end' => $start->modify('+1 day')->format('Y-m-d H:i:s')]; } /** @return array{count:int,total:string,remaining_count:int,remaining_amount:string,window:array{start:string,end:string}} */ public function daily_usage(int $user_id, array $settings): array { $window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00')); return $this->daily_usage_in_window($user_id, $settings, $window); } /** @return array{count:int,total:string,remaining_count:int,remaining_amount:string,window:array{start:string,end:string}} */ private function daily_usage_in_window(int $user_id, array $settings, array $window): array { $usage = $this->daily_usage_minor_in_window($user_id, $settings, $window); return [ 'count' => $usage['count'], 'total' => $this->balance_decimal($usage['total_minor']), 'remaining_count' => $usage['remaining_count'], 'remaining_amount' => $this->balance_decimal($usage['remaining_minor']), 'window' => $window, ]; } /** @return array{count:int,total_minor:int,remaining_count:int,remaining_minor:int,window:array{start:string,end:string}} */ private function daily_usage_minor_in_window(int $user_id, array $settings, array $window, bool $lock = false): array { global $wpdb; $sql = $wpdb->prepare( "SELECT id, gross_amount, amount FROM {$this->table()} WHERE user_id = %d AND created_at >= %s AND created_at < %s ORDER BY id ASC" . ($lock ? ' FOR UPDATE' : ''), $user_id, $window['start'], $window['end'] ); $rows = (array) $wpdb->get_results($sql, ARRAY_A); $total_minor = 0; foreach ($rows as $row) { $decimal = (string) ($row['gross_amount'] ?? ''); try { $row_minor = \AKSRT\Ledger\AssetAmount::balance($decimal)->minor(); if ($row_minor <= 0) { throw new \InvalidArgumentException('withdrawal_amount_invalid'); } if ($total_minor > PHP_INT_MAX - $row_minor) { throw new \RuntimeException('withdrawal_total_overflow'); } $total_minor += $row_minor; } catch (\InvalidArgumentException $e) { throw new \RuntimeException('withdrawal_amount_invalid'); } } $max_count = max(0, (int) ($settings['daily_count'] ?? 3)); $max_minor = $this->setting_minor($settings['daily_limit'] ?? '500.00'); return [ 'count' => count($rows), 'total_minor' => $total_minor, 'remaining_count' => max(0, $max_count - count($rows)), 'remaining_minor' => max(0, $max_minor - $total_minor), 'window' => $window, ]; } /** * Atomically validates quotas, freezes wallet funds, writes the ledger and creates an order. * @return array{withdrawal_id:int,withdrawal_number:string,net_amount:string,wallet_balance:string,transaction_id:int,flow_number:string,replayed:bool,usage:array}|\WP_Error */ public function create_balance_request(int $user_id, int $gross_minor, string $method, string $account, array $settings, string $operation_key) { global $wpdb; if ($user_id <= 0 || $gross_minor <= 0) { return new \WP_Error('aksrt_withdraw_amount', __('提现金额必须大于零', 'aksrt'), ['status' => 400]); } $fee_rate = max(0, min(50, (int) ($settings['service_charge_rate'] ?? 5))); $fee_minor = (intdiv($gross_minor, 100) * $fee_rate) + intdiv(($gross_minor % 100) * $fee_rate, 100); $net_minor = $gross_minor - $fee_minor; if ($net_minor < 1) { return new \WP_Error('aksrt_withdraw_net', __('扣除手续费后到账金额必须至少为 0.01 元', 'aksrt'), ['status' => 400]); } $window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00')); $usage = null; $withdrawal_id = 0; $withdrawal_number = ''; $guard = function () use ($user_id, $gross_minor, $settings, $window, &$usage) { global $wpdb; $wpdb->get_results($wpdb->prepare( "SELECT id FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing') ORDER BY id ASC FOR UPDATE", $user_id )); $open = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing')", $user_id )); if ($open > 0) { return new \WP_Error('withdraw_pending', __('已有待处理提现申请', 'aksrt'), ['status' => 409]); } $usage = $this->daily_usage_minor_in_window($user_id, $settings, $window, true); if ($usage['remaining_count'] <= 0) { return new \WP_Error('withdraw_count_limit', __('当日提现次数已用尽', 'aksrt'), ['status' => 409]); } if ($gross_minor > $usage['remaining_minor']) { return new \WP_Error('withdraw_amount_limit', __('本次提现后将超出单日提现总额上限', 'aksrt'), ['status' => 409]); } return true; }; $mutation = function (int $transaction_id) use ($user_id, $gross_minor, $net_minor, $fee_rate, $method, $account, $operation_key, &$withdrawal_id, &$withdrawal_number) { global $wpdb; $payload = wp_json_encode([ 'account' => $account, 'gross_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $gross_minor)->decimal(), 'fee_rate' => $fee_rate, 'source' => 'balance', 'operation_key' => $operation_key, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $account; $ok = false; for ($attempt = 1; $attempt <= 5; $attempt++) { $withdrawal_number = \AKSRT\Finance\BusinessNumber::allocate(); $ok = $wpdb->insert($this->table(), [ 'withdrawal_number' => $withdrawal_number, 'user_id' => $user_id, 'amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $net_minor)->decimal(), 'gross_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $gross_minor)->decimal(), 'method' => sanitize_key($method), 'account_info' => $payload, 'status' => self::STATUS_PENDING, 'ledger_transaction_id' => $transaction_id, 'created_at' => current_time('mysql'), ], ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s']); if ($ok === 1) { break; } } if ($ok !== 1) { return new \WP_Error('aksrt_withdraw_failed', __('提现申请失败', 'aksrt'), ['status' => 500]); } $withdrawal_id = (int) $wpdb->insert_id; return true; }; $result = \AKSRT\Ledger\LedgerService::get_instance()->debit( $user_id, \AKSRT\Ledger\AssetType::BALANCE, $gross_minor, $operation_key, 'withdraw_hold', ['type' => 'withdraw', 'key' => $operation_key], __('余额提现申请冻结', 'aksrt'), $user_id, ['fee_minor' => $fee_minor, 'net_minor' => $net_minor], $mutation, $guard ); if (is_wp_error($result)) { return $result; } if ($withdrawal_id <= 0) { $replayed_row = $wpdb->get_row($wpdb->prepare( "SELECT id, withdrawal_number FROM {$this->table()} WHERE ledger_transaction_id = %d", $result->transaction_id ), ARRAY_A); $withdrawal_id = (int) ($replayed_row['id'] ?? 0); $withdrawal_number = (string) ($replayed_row['withdrawal_number'] ?? ''); } if ($withdrawal_id <= 0) { return new \WP_Error('aksrt_withdraw_missing', __('提现记录不存在', 'aksrt'), ['status' => 500]); } if (!is_array($usage)) { $usage = $this->daily_usage_minor_in_window($user_id, $settings, $window); } else { $usage['count']++; $usage['total_minor'] += $gross_minor; $usage['remaining_count'] = max(0, $usage['remaining_count'] - 1); $usage['remaining_minor'] = max(0, $usage['remaining_minor'] - $gross_minor); } if ($withdrawal_id > 0 && !$result->replayed) { do_action('aksrt_withdrawal_submitted', $user_id, $this->get($withdrawal_id) ?: []); } return [ 'withdrawal_id' => $withdrawal_id, 'withdrawal_number' => $withdrawal_number, 'net_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $net_minor)->decimal(), 'wallet_balance' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($user_id, \AKSRT\Ledger\AssetType::BALANCE))->decimal(), 'transaction_id' => $result->transaction_id, 'flow_number' => $result->flow_number, 'replayed' => $result->replayed, 'usage' => [ 'count' => $usage['count'], 'total' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $usage['total_minor'])->decimal(), 'remaining_count' => $usage['remaining_count'], 'remaining_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $usage['remaining_minor'])->decimal(), ], ]; } /** * 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 array{transaction_id:int,flow_number:string,withdrawal_number:string,replayed:bool,wallet_balance:string}|\WP_Error */ public function process_status_change(int $withdrawal_id, string $new_status, string $note = '', string $operation_key = '') { $new_status = sanitize_key($new_status); $note = sanitize_textarea_field($note); $allowed = [ self::STATUS_PENDING, 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'), ['status' => 400]); } if ($operation_key === '' || strlen($operation_key) > 191 || !preg_match('/^[A-Za-z0-9:._-]+$/', $operation_key)) { return new \WP_Error('aksrt_withdraw_operation_key', __('提现状态操作标识无效', 'aksrt'), ['status' => 400]); } $row = $this->get($withdrawal_id); if (!$row) { return new \WP_Error('aksrt_withdraw_missing', __('提现记录不存在', 'aksrt'), ['status' => 404]); } $payload_hash = hash('sha256', (string) wp_json_encode([ 'withdrawal_id' => $withdrawal_id, 'status' => $new_status, 'note' => $note, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); $meta = $this->parse_account_meta((string) ($row['account_info'] ?? '')); $preflight_replay = $this->status_operation_result($meta, $operation_key, $payload_hash); if (is_wp_error($preflight_replay)) { return $preflight_replay; } if (is_array($preflight_replay)) { $preflight_replay['wallet_balance'] = \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal((int) ($row['user_id'] ?? 0)); $preflight_replay['withdrawal_number'] = (string) ($row['withdrawal_number'] ?? ''); $preflight_replay['flow_number'] = \AKSRT\Finance\FinancialFlowService::get_instance()->number_for_operation($operation_key); return $preflight_replay; } $transitions = [ self::STATUS_PENDING => [self::STATUS_PROCESSING, self::STATUS_REJECTED], self::STATUS_PROCESSING => [self::STATUS_COMPLETED, self::STATUS_REJECTED], self::STATUS_COMPLETED => [], self::STATUS_REJECTED => [], ]; $old_status = sanitize_key((string) ($row['status'] ?? '')); if (!in_array($new_status, $transitions[$old_status] ?? [], true)) { return new \WP_Error('aksrt_withdraw_transition', __('提现状态转换不合法', 'aksrt'), ['status' => 409]); } $user_id = (int) ($row['user_id'] ?? 0); $ledger_result = null; $completed_flow_number = ''; $locked_row = null; $locked_meta = []; $operation_replay = null; $lock_and_validate = function () use ($withdrawal_id, $new_status, $transitions, $operation_key, $payload_hash, &$locked_row, &$locked_meta, &$operation_replay) { global $wpdb; $current = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$this->table()} WHERE id = %d FOR UPDATE", $withdrawal_id ), ARRAY_A); if (!is_array($current)) { return new \WP_Error('aksrt_withdraw_missing', __('提现记录不存在', 'aksrt'), ['status' => 404]); } $locked_meta = $this->parse_account_meta((string) ($current['account_info'] ?? '')); $replay = $this->status_operation_result($locked_meta, $operation_key, $payload_hash); if (is_wp_error($replay)) { return $replay; } if (is_array($replay)) { $locked_row = $current; $operation_replay = $replay; return true; } $current_status = sanitize_key((string) ($current['status'] ?? '')); if (!in_array($new_status, $transitions[$current_status] ?? [], true)) { return new \WP_Error('aksrt_withdraw_transition', __('提现状态转换不合法', 'aksrt'), ['status' => 409]); } $locked_row = $current; return true; }; $update_locked = function (int $transaction_id = 0) use ($withdrawal_id, $new_status, $note, $operation_key, $payload_hash, &$locked_row, &$locked_meta, &$operation_replay) { global $wpdb; if (!is_array($locked_row)) { return new \WP_Error('aksrt_withdraw_update', __('提现记录未锁定', 'aksrt'), ['status' => 500]); } if (is_array($operation_replay)) { return true; } $update = ['status' => $new_status]; $formats = ['%s']; $locked_meta['status_operations'] = is_array($locked_meta['status_operations'] ?? null) ? $locked_meta['status_operations'] : []; $locked_meta['status_operations'][$operation_key] = [ 'payload_hash' => $payload_hash, 'status' => $new_status, 'transaction_id' => max(0, $transaction_id), ]; if ($new_status === self::STATUS_REJECTED) { $locked_meta['released_at'] = current_time('mysql'); } $update['account_info'] = $this->encode_account_meta($locked_meta, ''); $formats[] = '%s'; if (in_array($new_status, [self::STATUS_COMPLETED, self::STATUS_REJECTED], true)) { $update['processed_at'] = current_time('mysql'); $formats[] = '%s'; } $merged_note = $this->merge_note((string) ($locked_row['note'] ?? ''), $note); if ($merged_note !== '') { $update['note'] = $merged_note; $formats[] = '%s'; } return $wpdb->update($this->table(), $update, ['id' => $withdrawal_id, 'status' => (string) $locked_row['status']], $formats, ['%d', '%s']) === 1 ? true : new \WP_Error('aksrt_withdraw_update', __('提现状态更新失败', 'aksrt'), ['status' => 500]); }; if ($new_status === self::STATUS_REJECTED && $this->is_balance_source($meta) && $user_id > 0) { try { $gross_minor = \AKSRT\Ledger\AssetAmount::balance((string) ($row['gross_amount'] ?? ''))->minor(); } catch (\InvalidArgumentException $e) { return new \WP_Error('aksrt_withdraw_amount', __('提现记录金额无效', 'aksrt'), ['status' => 500]); } if ($gross_minor <= 0) { return new \WP_Error('aksrt_withdraw_amount', __('提现记录金额无效', 'aksrt'), ['status' => 500]); } $ledger_result = \AKSRT\Ledger\LedgerService::get_instance()->credit( $user_id, \AKSRT\Ledger\AssetType::BALANCE, $gross_minor, $operation_key !== '' ? $operation_key : \AKSRT\Ledger\OperationKey::deterministic('withdraw-refund', [$withdrawal_id]), 'withdraw_refund', ['type' => 'withdraw', 'id' => $withdrawal_id], sprintf(__('提现拒绝退回 #%d', 'aksrt'), $withdrawal_id), get_current_user_id(), ['withdrawal_id' => $withdrawal_id, 'target_status' => $new_status, 'note_hash' => hash('sha256', $note)], static fn(int $transaction_id) => $update_locked($transaction_id), static fn() => $lock_and_validate() ); if (is_wp_error($ledger_result)) { return $ledger_result; } } else { global $wpdb; if ($wpdb->query('START TRANSACTION') === false) { return new \WP_Error('aksrt_withdraw_update', __('提现状态更新失败', 'aksrt'), ['status' => 500]); } $locked = $lock_and_validate(); $updated = is_wp_error($locked) ? $locked : $update_locked(0); if (!is_wp_error($updated) && $new_status === self::STATUS_COMPLETED && !is_array($operation_replay)) { $flow = \AKSRT\Finance\FinancialFlowService::get_instance()->record_withdrawal_payout( array_merge((array) $locked_row, ['status' => $new_status]), $operation_key, (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s.u'), true ); if (is_wp_error($flow)) { $updated = $flow; } else { $completed_flow_number = $flow; } } if (is_wp_error($updated) || $wpdb->query('COMMIT') === false) { $wpdb->query('ROLLBACK'); return is_wp_error($updated) ? $updated : new \WP_Error('aksrt_withdraw_update', __('提现状态更新失败', 'aksrt'), ['status' => 500]); } } // Notify only after the financial transaction has committed. $replayed = ($ledger_result instanceof \AKSRT\Ledger\LedgerResult && $ledger_result->replayed) || is_array($operation_replay); if (class_exists('\AKSRT\Messages\Message') && $user_id > 0 && in_array($new_status, [self::STATUS_COMPLETED, self::STATUS_REJECTED], true) && !$replayed) { $gross = $this->resolve_gross_amount($row, $meta); $amount_txt = $gross; 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\Messages\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') : '', ]); } if (!$replayed && in_array($new_status, [self::STATUS_COMPLETED, self::STATUS_REJECTED], true)) { do_action('aksrt_withdrawal_status_changed', $user_id, $row, $new_status, $note); } return [ 'transaction_id' => $ledger_result instanceof \AKSRT\Ledger\LedgerResult ? $ledger_result->transaction_id : 0, 'flow_number' => $ledger_result instanceof \AKSRT\Ledger\LedgerResult ? $ledger_result->flow_number : $completed_flow_number, 'withdrawal_number' => (string) ($row['withdrawal_number'] ?? ''), 'replayed' => $replayed, 'wallet_balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id), ]; } /** * Whether user has open (pending/processing) 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','processing')", $user_id )); } /** * Sum of frozen gross amounts still open (balance source). */ public function sum_pending_hold(int $user_id): string { global $wpdb; if ($user_id <= 0) { return '0.00'; } $rows = $wpdb->get_results($wpdb->prepare( "SELECT gross_amount, account_info FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing')", $user_id ), ARRAY_A); $total_minor = 0; foreach ((array) $rows as $row) { $meta = $this->parse_account_meta((string) ($row['account_info'] ?? '')); if (!$this->is_balance_source($meta)) { continue; } try { $minor = \AKSRT\Ledger\AssetAmount::balance((string) ($row['gross_amount'] ?? ''))->minor(); if ($minor <= 0 || $total_minor > PHP_INT_MAX - $minor) { return '0.00'; } $total_minor += $minor; } catch (\InvalidArgumentException $e) { return '0.00'; } } return $this->balance_decimal($total_minor); } /** * 获取用户提现列表 * * @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','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] = $this->build_admin_filters($args); $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; [$where, $params] = $this->build_admin_filters($args); $where_sql = $where ? ' WHERE ' . implode(' AND ', $where) : ''; $sql = "SELECT COUNT(*) FROM {$this->table()}{$where_sql}"; return (int) ($params ? $wpdb->get_var($wpdb->prepare($sql, $params)) : $wpdb->get_var($sql)); } /** @return array{0:array,1:array} */ private function build_admin_filters(array $args): array { global $wpdb; $where = []; $params = []; $status = sanitize_key((string) ($args['status'] ?? '')); if (in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED], true)) { $where[] = 'status = %s'; $params[] = $status; } $user_id = max(0, (int) ($args['user_id'] ?? 0)); if ($user_id > 0) { $where[] = 'user_id = %d'; $params[] = $user_id; } $keyword = trim(sanitize_text_field((string) ($args['keyword'] ?? ''))); if ($keyword !== '') { $like = '%' . $wpdb->esc_like($keyword) . '%'; if (ctype_digit($keyword)) { $where[] = '(withdrawal_number LIKE %s OR user_id = %d)'; $params[] = $like; $params[] = (int) $keyword; } else { $where[] = 'withdrawal_number LIKE %s'; $params[] = $like; } } if (!empty($args['date_from'])) { $where[] = 'created_at >= %s'; $params[] = (string) $args['date_from'] . ' 00:00:00'; } if (!empty($args['date_to'])) { try { $end = (new \DateTimeImmutable((string) $args['date_to'] . ' 00:00:00', wp_timezone()))->modify('+1 day')->format('Y-m-d H:i:s'); } catch (\Exception $e) { $end = ''; } if ($end !== '') { $where[] = 'created_at < %s'; $params[] = $end; } } return [$where, $params]; } 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 = 'processing' THEN 1 ELSE 0 END) as processing, SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected, 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), 'processing' => (int) ($row['processing'] ?? 0), 'rejected' => (int) ($row['rejected'] ?? 0), 'completed' => (int) ($row['completed'] ?? 0), 'total_paid' => $this->database_balance_decimal((string) ($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['amount'] = $this->database_balance_decimal((string) ($row['amount'] ?? '0')); $row['fee_rate'] = (int) ($meta['fee_rate'] ?? 0); $display = (string) ($meta['account'] ?? $row['account_info'] ?? ''); $row['display_account'] = $display; $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'] ?? '')); return $source === 'balance'; } /** * @param array $row * @param array $meta */ private function resolve_gross_amount(array $row, array $meta): string { unset($meta); return $this->database_balance_decimal((string) ($row['gross_amount'] ?? '0')); } 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; } private function setting_minor(mixed $value): int { if (!is_string($value)) { throw new \RuntimeException('withdrawal_setting_invalid'); } try { return \AKSRT\Ledger\AssetAmount::balance(trim($value))->minor(); } catch (\InvalidArgumentException $e) { throw new \RuntimeException('withdrawal_setting_invalid'); } } /** @return array{transaction_id:int,replayed:bool}|\WP_Error|null */ private function status_operation_result(array $meta, string $operation_key, string $payload_hash) { $history = is_array($meta['status_operations'] ?? null) ? $meta['status_operations'] : []; $stored = $history[$operation_key] ?? null; if (!is_array($stored)) { return null; } $stored_hash = (string) ($stored['payload_hash'] ?? ''); if ($stored_hash === '' || !hash_equals($stored_hash, $payload_hash)) { return new \WP_Error('aksrt_withdraw_idempotency_conflict', __('相同操作标识已用于其他提现状态请求', 'aksrt'), ['status' => 409]); } return [ 'transaction_id' => max(0, (int) ($stored['transaction_id'] ?? 0)), 'replayed' => true, ]; } private function database_balance_decimal(string $value): string { try { return \AKSRT\Ledger\AssetAmount::balance($value)->decimal(); } catch (\InvalidArgumentException $e) { return '0.00'; } } private function balance_decimal(int $minor): string { return \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, max(0, $minor))->decimal(); } }