积分与余额账务系统硬切重构
This commit is contained in:
1 parent
ca12226b39
commit
5cae5b5c93
159 files changed
+7275
-4718
No files matched your search
+332
-169
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user