871 lines
34 KiB
PHP
871 lines
34 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;
|
|
}
|
|
|
|
public const SOURCE_BALANCE = 'balance';
|
|
public const SOURCE_COMMISSION = 'commission';
|
|
|
|
private function table(): string {
|
|
return \AKSRT\Core\DBMigrator::table('withdrawal');
|
|
}
|
|
|
|
/**
|
|
* 提现来源 → 资产池映射。commission 走独立佣金池,其余归余额钱包。
|
|
*/
|
|
private function source_asset(string $source): \AKSRT\Ledger\AssetType {
|
|
return sanitize_key($source) === self::SOURCE_COMMISSION
|
|
? \AKSRT\Ledger\AssetType::COMMISSION
|
|
: \AKSRT\Ledger\AssetType::BALANCE;
|
|
}
|
|
|
|
private function normalize_source(string $source): string {
|
|
return sanitize_key($source) === self::SOURCE_COMMISSION ? self::SOURCE_COMMISSION : self::SOURCE_BALANCE;
|
|
}
|
|
|
|
/**
|
|
* 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, string $source = self::SOURCE_BALANCE): array {
|
|
$window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00'));
|
|
return $this->daily_usage_in_window($user_id, $settings, $window, $source);
|
|
}
|
|
|
|
/** @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, string $source = self::SOURCE_BALANCE): array {
|
|
$usage = $this->daily_usage_minor_in_window($user_id, $settings, $window, false, $source);
|
|
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, string $source = self::SOURCE_BALANCE): array {
|
|
global $wpdb;
|
|
// 每个资金池(source)独立计每日次数/额度:按 account_info 里的 source 标记过滤。
|
|
$source_like = '%' . $wpdb->esc_like('"source":"' . $this->normalize_source($source) . '"') . '%';
|
|
$sql = $wpdb->prepare(
|
|
"SELECT id, gross_amount, amount FROM {$this->table()} WHERE user_id = %d AND created_at >= %s AND created_at < %s AND account_info LIKE %s ORDER BY id ASC" . ($lock ? ' FOR UPDATE' : ''),
|
|
$user_id,
|
|
$window['start'],
|
|
$window['end'],
|
|
$source_like
|
|
);
|
|
$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, string $source = self::SOURCE_BALANCE) {
|
|
global $wpdb;
|
|
if ($user_id <= 0 || $gross_minor <= 0) {
|
|
return new \WP_Error('aksrt_withdraw_amount', __('提现金额必须大于零', 'aksrt'), ['status' => 400]);
|
|
}
|
|
$source = $this->normalize_source($source);
|
|
$asset = $this->source_asset($source);
|
|
$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]);
|
|
}
|
|
$request_hash = hash('sha256', (string) wp_json_encode([
|
|
'user_id' => $user_id,
|
|
'gross_minor' => $gross_minor,
|
|
'net_minor' => $net_minor,
|
|
'fee_rate' => $fee_rate,
|
|
'method' => sanitize_key($method),
|
|
'account' => sanitize_text_field($account),
|
|
'source' => $source,
|
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
|
$existing_request = $wpdb->get_row($wpdb->prepare(
|
|
"SELECT * FROM {$this->table()} WHERE operation_key = %s LIMIT 1",
|
|
$operation_key
|
|
), ARRAY_A);
|
|
if (is_array($existing_request)) {
|
|
$existing_meta = $this->parse_account_meta((string) ($existing_request['account_info'] ?? ''));
|
|
if ((int) ($existing_request['user_id'] ?? 0) !== $user_id
|
|
|| !hash_equals((string) ($existing_meta['request_hash'] ?? ''), $request_hash)) {
|
|
return new \WP_Error('aksrt_withdraw_idempotency_conflict', __('相同提现操作标识已用于其他请求', 'aksrt'), ['status' => 409]);
|
|
}
|
|
$window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00'));
|
|
$usage = $this->daily_usage_minor_in_window($user_id, $settings, $window, false, $source);
|
|
return [
|
|
'withdrawal_id' => (int) $existing_request['id'],
|
|
'withdrawal_number' => (string) ($existing_request['withdrawal_number'] ?? ''),
|
|
'net_amount' => $this->database_balance_decimal((string) ($existing_request['amount'] ?? '0.00')),
|
|
'source' => $source,
|
|
'wallet_balance' => $this->source_balance_decimal($user_id, $source),
|
|
'transaction_id' => max(0, (int) ($existing_request['ledger_transaction_id'] ?? 0)),
|
|
'flow_number' => $source === self::SOURCE_COMMISSION
|
|
? \AKSRT\Finance\FinancialFlowService::get_instance()->number_for_operation($operation_key)
|
|
: '',
|
|
'replayed' => true,
|
|
'usage' => [
|
|
'count' => $usage['count'],
|
|
'total' => $this->balance_decimal($usage['total_minor']),
|
|
'remaining_count' => $usage['remaining_count'],
|
|
'remaining_amount' => $this->balance_decimal($usage['remaining_minor']),
|
|
],
|
|
];
|
|
}
|
|
$window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00'));
|
|
$usage = null;
|
|
$withdrawal_id = 0;
|
|
$withdrawal_number = '';
|
|
$source_like = '%' . $wpdb->esc_like('"source":"' . $source . '"') . '%';
|
|
$guard = function () use ($user_id, $gross_minor, $settings, $window, $source, $source_like, &$usage) {
|
|
global $wpdb;
|
|
// 每个资金池独立的「已有待处理申请」闸门:只锁定同 source 的 pending/processing 行。
|
|
$wpdb->get_results($wpdb->prepare(
|
|
"SELECT id FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing') AND account_info LIKE %s ORDER BY id ASC FOR UPDATE",
|
|
$user_id,
|
|
$source_like
|
|
));
|
|
$open = (int) $wpdb->get_var($wpdb->prepare(
|
|
"SELECT COUNT(*) FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing') AND account_info LIKE %s",
|
|
$user_id,
|
|
$source_like
|
|
));
|
|
if ($open > 0) {
|
|
return new \WP_Error('withdraw_pending', __('已有待处理提现申请', 'aksrt'), ['status' => 409]);
|
|
}
|
|
$usage = $this->daily_usage_minor_in_window($user_id, $settings, $window, true, $source);
|
|
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, $request_hash, $source, &$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' => $source,
|
|
'operation_key' => $operation_key,
|
|
'request_hash' => $request_hash,
|
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $account;
|
|
$ok = false;
|
|
for ($attempt = 1; $attempt <= 5; $attempt++) {
|
|
$withdrawal_number = \AKSRT\Finance\BusinessNumber::allocate();
|
|
$insert_data = [
|
|
'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,
|
|
'operation_key' => $operation_key,
|
|
'created_at' => current_time('mysql'),
|
|
];
|
|
$insert_formats = ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s'];
|
|
if ($transaction_id > 0) {
|
|
$insert_data['ledger_transaction_id'] = $transaction_id;
|
|
$insert_formats[] = '%d';
|
|
}
|
|
$ok = $wpdb->insert($this->table(), $insert_data, $insert_formats);
|
|
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,
|
|
$asset,
|
|
$gross_minor,
|
|
$operation_key,
|
|
'withdraw_hold',
|
|
['type' => 'withdraw', 'key' => $operation_key],
|
|
$source === self::SOURCE_COMMISSION ? __('推广佣金提现申请冻结', 'aksrt') : __('余额提现申请冻结', 'aksrt'),
|
|
$user_id,
|
|
['fee_minor' => $fee_minor, 'net_minor' => $net_minor, 'source' => $source],
|
|
$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 operation_key = %s OR (%d > 0 AND ledger_transaction_id = %d) ORDER BY id DESC LIMIT 1",
|
|
$operation_key,
|
|
$result->transaction_id,
|
|
$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(),
|
|
'source' => $source,
|
|
'wallet_balance' => \AKSRT\Ledger\AssetAmount::from_minor($asset, $result->balance($user_id, $asset))->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'] = $this->source_balance_decimal((int) ($row['user_id'] ?? 0), $this->normalize_source((string) ($meta['source'] ?? self::SOURCE_BALANCE)));
|
|
$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]);
|
|
};
|
|
|
|
// 拒绝提现时把冻结毛额退回原始资金池(balance→余额钱包,commission→佣金池)。
|
|
$refund_source = $this->normalize_source((string) ($meta['source'] ?? self::SOURCE_BALANCE));
|
|
if ($new_status === self::STATUS_REJECTED && $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,
|
|
$this->source_asset($refund_source),
|
|
$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' => $this->source_balance_decimal($user_id, $refund_source),
|
|
];
|
|
}
|
|
|
|
private function source_balance_decimal(int $user_id, string $source): string {
|
|
$asset = $this->source_asset($source);
|
|
$minor = \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, $asset);
|
|
return \AKSRT\Ledger\AssetAmount::from_minor($asset, $minor)->decimal();
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
|
|
// 按资金池来源过滤:收益页只看佣金提现,余额页只看余额提现。
|
|
$source = sanitize_key((string) ($args['source'] ?? ''));
|
|
if ($source === self::SOURCE_BALANCE || $source === self::SOURCE_COMMISSION) {
|
|
$where[] = 'account_info LIKE %s';
|
|
$params[] = '%' . $wpdb->esc_like('"source":"' . $source . '"') . '%';
|
|
}
|
|
|
|
$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;
|
|
}
|
|
// 资金池来源过滤:commission / balance 各自独立统计。
|
|
$source = sanitize_key((string) ($args['source'] ?? ''));
|
|
if (in_array($source, [self::SOURCE_BALANCE, self::SOURCE_COMMISSION], true)) {
|
|
$where[] = 'account_info LIKE %s';
|
|
$params[] = '%' . $wpdb->esc_like('"source":"' . $source . '"') . '%';
|
|
}
|
|
$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();
|
|
}
|
|
}
|