积分与余额账务系统硬切重构

This commit is contained in:
kate522 committed 2026-08-24 15:59:11 +08:00
1 parent ca12226b39
commit 5cae5b5c93
159 files changed
+7275 -4718

No files matched your search

+332 -169
View File
@@ -27,29 +27,6 @@ class Withdrawal {
return \AKSRT\Core\DBMigrator::table('withdrawal');
}
/**
* 创建提现申请
*/
public function create(int $user_id, float $amount, string $method = '', string $account_info = '', ?float $gross_amount = null): int {
global $wpdb;
if ($user_id <= 0 || $amount <= 0) {
return 0;
}
$wpdb->insert($this->table(), [
'user_id' => $user_id,
'amount' => $amount,
'gross_amount' => $gross_amount === null ? $amount : $gross_amount,
'method' => sanitize_key($method),
'account_info' => sanitize_textarea_field($account_info),
'status' => self::STATUS_PENDING,
'created_at' => current_time('mysql'),
], ['%d', '%f', '%f', '%s', '%s', '%s', '%s']);
return (int) $wpdb->insert_id;
}
/**
* Daily withdrawal window in the configured WordPress timezone.
* @return array{start:string,end:string}
@@ -65,100 +42,172 @@ class Withdrawal {
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:float,remaining_count:int,remaining_amount:float,window:array{start:string,end:string}} */
/** @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:float,remaining_count:int,remaining_amount:float,window:array{start:string,end:string}} */
/** @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 {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT COUNT(*) AS count, COALESCE(SUM(CASE WHEN gross_amount > 0 THEN gross_amount ELSE amount END), 0) AS total FROM {$this->table()} WHERE user_id = %d AND created_at >= %s AND created_at < %s",
$user_id, $window['start'], $window['end']
), ARRAY_A);
$count = (int) ($row['count'] ?? 0);
$total = round((float) ($row['total'] ?? 0), 2);
$max_count = max(0, (int) ($settings['daily_count'] ?? 3));
$max_amount = max(0, (float) ($settings['daily_limit'] ?? 50));
$usage = $this->daily_usage_minor_in_window($user_id, $settings, $window);
return [
'count' => $count,
'total' => $total,
'remaining_count' => max(0, $max_count - $count),
'remaining_amount' => max(0, round($max_amount - $total, 2)),
'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,net_amount:float,wallet_balance:float,usage:array}|\WP_Error
* @return array{withdrawal_id:int,net_amount:string,wallet_balance:string,transaction_id:int,replayed:bool,usage:array}|\WP_Error
*/
public function create_balance_request(int $user_id, int $gross_amount, string $method, string $account, array $settings) {
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_amount <= 0) {
return new \WP_Error('aksrt_withdraw_amount', __('提现金额必须为正整数', 'aksrt'), ['status' => 400]);
if ($user_id <= 0 || $gross_minor <= 0) {
return new \WP_Error('aksrt_withdraw_amount', __('提现金额必须大于零', 'aksrt'), ['status' => 400]);
}
if (!class_exists('\AKSRT\\Credits\\Balance')) {
return new \WP_Error('aksrt_withdraw_unavailable', __('提现服务不可用', 'aksrt'), ['status' => 500]);
$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]);
}
$meta_table = $wpdb->usermeta;
$meta_key = \AKSRT\Credits\Balance::META_KEY;
if (!metadata_exists('user', $user_id, $meta_key)) {
add_user_meta($user_id, $meta_key, '0', true);
}
$wpdb->query('START TRANSACTION');
try {
$open = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing') FOR UPDATE", $user_id));
$window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00'));
$usage = null;
$withdrawal_id = 0;
$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) {
$wpdb->query('ROLLBACK');
return new \WP_Error('withdraw_pending', __('已有待处理提现申请', 'aksrt'), ['status' => 409]);
}
$meta_id = $wpdb->get_var($wpdb->prepare("SELECT umeta_id FROM {$meta_table} WHERE user_id = %d AND meta_key = %s LIMIT 1 FOR UPDATE", $user_id, $meta_key));
if (!$meta_id) {
throw new \RuntimeException('wallet row unavailable');
}
$window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00'));
$usage = $this->daily_usage_in_window($user_id, $settings, $window);
$usage = $this->daily_usage_minor_in_window($user_id, $settings, $window, true);
if ($usage['remaining_count'] <= 0) {
$wpdb->query('ROLLBACK');
return new \WP_Error('withdraw_count_limit', __('当日提现次数已用尽', 'aksrt'), ['status' => 409]);
}
if ($gross_amount > $usage['remaining_amount']) {
$wpdb->query('ROLLBACK');
if ($gross_minor > $usage['remaining_minor']) {
return new \WP_Error('withdraw_amount_limit', __('本次提现后将超出单日提现总额上限', 'aksrt'), ['status' => 409]);
}
$before = (float) $wpdb->get_var($wpdb->prepare("SELECT meta_value FROM {$meta_table} WHERE umeta_id = %d", $meta_id));
if ($before < $gross_amount) {
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), ['status' => 402]);
}
$after = round($before - $gross_amount, 2);
if ($wpdb->update($meta_table, ['meta_value' => (string) $after], ['umeta_id' => $meta_id], ['%s'], ['%d']) !== 1) {
throw new \RuntimeException('wallet update failed');
}
$fee_rate = max(0, min(50, (int) ($settings['service_charge_rate'] ?? 5)));
$net = round($gross_amount * (100 - $fee_rate) / 100, 2);
$payload = wp_json_encode(['account' => $account, 'gross_amount' => $gross_amount, 'fee_rate' => $fee_rate, 'source' => 'balance'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $account;
return true;
};
$mutation = function (int $transaction_id) use ($user_id, $gross_minor, $net_minor, $fee_rate, $method, $account, $operation_key, &$withdrawal_id) {
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 = $wpdb->insert($this->table(), [
'user_id' => $user_id, 'amount' => $net, 'gross_amount' => $gross_amount, 'method' => sanitize_key($method),
'account_info' => $payload, 'status' => self::STATUS_PENDING, 'created_at' => current_time('mysql'),
], ['%d', '%f', '%f', '%s', '%s', '%s', '%s']);
if (!$ok) { throw new \RuntimeException('withdrawal insert failed'); }
'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'),
], ['%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s']);
if ($ok !== 1) {
return new \WP_Error('aksrt_withdraw_failed', __('提现申请失败', 'aksrt'), ['status' => 500]);
}
$withdrawal_id = (int) $wpdb->insert_id;
\AKSRT\Credits\Balance::log($user_id, -$gross_amount, $after, 'withdraw_hold', ['type' => 'withdraw', 'id' => $withdrawal_id], __('余额提现申请冻结', 'aksrt'));
$wpdb->query('COMMIT');
wp_cache_delete($user_id, 'user_meta');
$usage['count']++;
$usage['total'] = round($usage['total'] + $gross_amount, 2);
$usage['remaining_count']--;
$usage['remaining_amount'] = max(0, round($usage['remaining_amount'] - $gross_amount, 2));
return ['withdrawal_id' => $withdrawal_id, 'net_amount' => $net, 'wallet_balance' => $after, 'usage' => $usage];
} catch (\Throwable $e) {
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_withdraw_failed', __('提现申请失败', 'aksrt'), ['status' => 500]);
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) {
$withdrawal_id = (int) $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$this->table()} WHERE ledger_transaction_id = %d",
$result->transaction_id
));
}
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);
}
return [
'withdrawal_id' => $withdrawal_id,
'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,
'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(),
],
];
}
/**
@@ -204,10 +253,11 @@ class Withdrawal {
* Status transition with balance side-effects.
* Reject of balance-source holds refunds gross_amount once (idempotent).
*
* @return true|\WP_Error
* @return array{transaction_id:int,replayed:bool,wallet_balance:string}|\WP_Error
*/
public function process_status_change(int $withdrawal_id, string $new_status, string $note = '') {
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,
@@ -215,17 +265,29 @@ class Withdrawal {
self::STATUS_REJECTED,
];
if ($withdrawal_id <= 0 || !in_array($new_status, $allowed, true)) {
return new \WP_Error('aksrt_withdraw_status', __('提现状态无效', 'aksrt'));
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'));
return new \WP_Error('aksrt_withdraw_missing', __('提现记录不存在', 'aksrt'), ['status' => 404]);
}
$old_status = sanitize_key((string) ($row['status'] ?? ''));
if ($old_status === $new_status && $note === '') {
return true;
$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));
return $preflight_replay;
}
$transitions = [
self::STATUS_PENDING => [self::STATUS_PROCESSING, self::STATUS_REJECTED],
@@ -233,67 +295,122 @@ class Withdrawal {
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]);
}
$meta = $this->parse_account_meta((string) ($row['account_info'] ?? ''));
$user_id = (int) ($row['user_id'] ?? 0);
$ledger_result = null;
$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]);
};
// 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\Credits\Balance')
&& $user_id > 0
) {
$gross = $this->resolve_gross_amount($row, $meta);
if ($gross > 0) {
$refund = \AKSRT\Credits\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');
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) || $wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
return is_wp_error($updated) ? $updated : new \WP_Error('aksrt_withdraw_update', __('提现状态更新失败', 'aksrt'), ['status' => 500]);
}
}
$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 (legacy withdraw_reply parity).
if (class_exists('\AKSRT\Messages\Message') && $user_id > 0 && in_array($new_status, [self::STATUS_COMPLETED, self::STATUS_REJECTED], true)) {
// 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 = class_exists('\AKSRT\Credits\Balance') ? \AKSRT\Credits\Balance::format($gross) : number_format($gross, 2);
$amount_txt = $gross;
if ($new_status === self::STATUS_REJECTED) {
$title = __('提现已拒绝', 'aksrt');
$body = sprintf(__('您申请提现 %s 元已被拒绝,冻结金额已退回钱包。', 'aksrt'), $amount_txt);
@@ -310,7 +427,11 @@ class Withdrawal {
]);
}
return true;
return [
'transaction_id' => $ledger_result instanceof \AKSRT\Ledger\LedgerResult ? $ledger_result->transaction_id : 0,
'replayed' => $replayed,
'wallet_balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
];
}
/**
@@ -338,25 +459,33 @@ class Withdrawal {
/**
* Sum of frozen gross amounts still open (balance source).
*/
public function sum_pending_hold(int $user_id): float {
public function sum_pending_hold(int $user_id): string {
global $wpdb;
if ($user_id <= 0) {
return 0.0;
return '0.00';
}
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT amount, account_info FROM {$this->table()}
"SELECT gross_amount, account_info FROM {$this->table()}
WHERE user_id = %d AND status IN ('pending','processing')",
$user_id
), ARRAY_A);
$total = 0.0;
$total_minor = 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'])) {
if (!$this->is_balance_source($meta)) {
continue;
}
$total += $this->resolve_gross_amount($row, $meta);
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 round($total, 2);
return $this->balance_decimal($total_minor);
}
/**
@@ -459,7 +588,7 @@ class Withdrawal {
'processing' => (int) ($row['processing'] ?? 0),
'rejected' => (int) ($row['rejected'] ?? 0),
'completed' => (int) ($row['completed'] ?? 0),
'total_paid' => (float) ($row['total_paid'] ?? 0),
'total_paid' => $this->database_balance_decimal((string) ($row['total_paid'] ?? '0')),
];
}
@@ -472,6 +601,7 @@ class Withdrawal {
$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'] = strlen($display) > 4 ? substr($display, 0, 2) . str_repeat('*', max(2, strlen($display) - 4)) . substr($display, -2) : '****';
@@ -506,24 +636,17 @@ class Withdrawal {
* @param array<string,mixed> $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';
$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): float {
if ((float) ($row['gross_amount'] ?? 0) > 0) {
return max(0, round((float) $row['gross_amount'], 2));
}
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 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 {
@@ -537,4 +660,44 @@ class Withdrawal {
}
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();
}
}