Files
wordpress-theme-aksrt/src/Payment/Withdrawal.php
T
kate522 aad83072a8 refactor(pay): 统一流水号,移除订单/流水/提现三类号码前缀
订单号、资金流水号、提现单号此前各自带前缀配置,同一笔业务在不同界面会
呈现多个号码口径,对账与客诉排查都要先做一次前缀换算。现收敛为单一流水号
分配器,不再按业务类型区分号码形态。

- 删除 payment_order_prefix / payment_flow_prefix /
  payment_withdrawal_prefix 三项配置项及其生成、校验链路
- BusinessNumber::allocate() 成为唯一入口,输出 {YmdHis}{6 位随机},
  共 20 位无前缀,满足 VARCHAR(32) 与网关 out_trade_no ≤ 32 的限制
- 订单、资金流水、提现、卡密渠道交易号四处生成点全部改走该分配器
- 后台支付中心表头「订单号 / 提现单号」统一为「流水号」
- 前台用户中心与订单卡片文案同步统一;前台订单详情合并为单行流水号,
  账本流水号不再下发到前台,避免同一单据出现两个对外号码
- 同步更新 financial-flow / pay-card / card-pass-order-numbers 契约测试
  与 BusinessNumberTest 断言

按用户要求不做双路逻辑与数据迁移,直接替换为统一方案,历史行不做转换。

验证:node --test tests/**/*.test.cjs 362/364 通过,新增 37 项契约;失败的
2 项在改动前的干净 HEAD 基线上同样失败(基线 327 项 / 失败 2 项),分别命中
遗留的 fa fa- 标记与 FA 版本号断言,与本次改动无关。phpunit 44 项 / 103
断言全部通过;php -l 全部改动的 PHP 文件通过。
2026-09-18 22:14:27 +08:00

775 lines
29 KiB
PHP

<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
class Withdrawal {
public const STATUS_PENDING = 'pending';
public const STATUS_PROCESSING = 'processing';
public const STATUS_COMPLETED = 'completed';
public const STATUS_REJECTED = 'rejected';
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
private function table(): string {
return \AKSRT\Core\DBMigrator::table('withdrawal');
}
/**
* Daily withdrawal window in the configured WordPress timezone.
* @return array{start:string,end:string}
*/
public function daily_window(string $reset_time = '00:00', ?\DateTimeInterface $now = null): array {
if (preg_match('/^(?:[01]\\d|2[0-3]):[0-5]\\d$/', $reset_time) !== 1) {
$reset_time = '00:00';
}
$tz = wp_timezone();
$local_now = $now ? \DateTimeImmutable::createFromInterface($now)->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<string,mixed>|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<array<string,mixed>>
*/
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<array<string,mixed>>
*/
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<int,string>,1:array<int,mixed>} */
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<string,mixed> $row
* @return array<string,mixed>
*/
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<string,mixed>
*/
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<string,mixed> $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<string,mixed> $meta
*/
private function is_balance_source(array $meta): bool {
$source = sanitize_key((string) ($meta['source'] ?? ''));
return $source === 'balance';
}
/**
* @param array<string,mixed> $row
* @param array<string,mixed> $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();
}
}