feat(users): normalize center routes and withdrawal limits

This commit is contained in:
2026-08-12 19:44:47 +08:00
parent 9004f7093f
commit dacd0095bf
24 changed files with 326 additions and 174 deletions
+112 -2
View File
@@ -30,7 +30,7 @@ class Withdrawal {
/**
* 创建提现申请
*/
public function create(int $user_id, float $amount, string $method = '', string $account_info = ''): int {
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) {
@@ -40,15 +40,122 @@ class Withdrawal {
$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', '%s', '%s', '%s', '%s']);
], ['%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}
*/
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:float,remaining_count:int,remaining_amount:float,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}} */
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));
return [
'count' => $count,
'total' => $total,
'remaining_count' => max(0, $max_count - $count),
'remaining_amount' => max(0, round($max_amount - $total, 2)),
'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
*/
public function create_balance_request(int $user_id, int $gross_amount, string $method, string $account, array $settings) {
global $wpdb;
if ($user_id <= 0 || $gross_amount <= 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]);
}
$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 {
$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);
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');
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;
$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'); }
$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]);
}
}
/**
* Get single row.
*
@@ -394,6 +501,9 @@ class Withdrawal {
* @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));
}