Move ~94 root class shells and root *-traits into domain directories (core, auth, user, credits, content, payment/gateways, forum, shop, sms, ai, messages, extensions, widgets, settings, admin). Update bootstrap require paths, co-locate traits with owners, fix forum/user relative requires, drop duplicate shop controller requires, and wire home-modules load. Path-only; class names and init order unchanged.
414 lines
12 KiB
PHP
414 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* AKSRT Withdrawal — 提现台账 + 状态副作用(冻结退回)
|
|
*
|
|
* @package AKSRT
|
|
*/
|
|
|
|
defined('ABSPATH') || exit;
|
|
|
|
class AKSRT_Withdrawal {
|
|
|
|
public const STATUS_PENDING = 'pending';
|
|
public const STATUS_APPROVED = 'approved';
|
|
public const STATUS_PROCESSING = 'processing';
|
|
public const STATUS_COMPLETED = 'completed';
|
|
public const STATUS_REJECTED = 'rejected';
|
|
|
|
private static $instance = null;
|
|
|
|
public static function get_instance(): self {
|
|
if (null === self::$instance) {
|
|
self::$instance = new self();
|
|
}
|
|
return self::$instance;
|
|
}
|
|
|
|
private function table(): string {
|
|
return AKSRT_DB_Migrator::table('withdrawal');
|
|
}
|
|
|
|
/**
|
|
* 创建提现申请
|
|
*/
|
|
public function create(int $user_id, float $amount, string $method = '', string $account_info = ''): int {
|
|
global $wpdb;
|
|
|
|
if ($user_id <= 0 || $amount <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
$wpdb->insert($this->table(), [
|
|
'user_id' => $user_id,
|
|
'amount' => $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']);
|
|
|
|
return (int) $wpdb->insert_id;
|
|
}
|
|
|
|
/**
|
|
* 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 true|WP_Error
|
|
*/
|
|
public function process_status_change(int $withdrawal_id, string $new_status, string $note = '') {
|
|
$new_status = sanitize_key($new_status);
|
|
$allowed = [
|
|
self::STATUS_PENDING,
|
|
self::STATUS_APPROVED,
|
|
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'));
|
|
}
|
|
|
|
$row = $this->get($withdrawal_id);
|
|
if (!$row) {
|
|
return new WP_Error('aksrt_withdraw_missing', __('提现记录不存在', 'aksrt'));
|
|
}
|
|
|
|
$old_status = sanitize_key((string) ($row['status'] ?? ''));
|
|
if ($old_status === $new_status && $note === '') {
|
|
return true;
|
|
}
|
|
|
|
$meta = $this->parse_account_meta((string) ($row['account_info'] ?? ''));
|
|
$user_id = (int) ($row['user_id'] ?? 0);
|
|
|
|
// 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_Balance')
|
|
&& $user_id > 0
|
|
) {
|
|
$gross = $this->resolve_gross_amount($row, $meta);
|
|
if ($gross > 0) {
|
|
$refund = AKSRT_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');
|
|
}
|
|
}
|
|
|
|
$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 (Zibll withdraw_reply parity).
|
|
if (class_exists('AKSRT_Message') && $user_id > 0 && in_array($new_status, [self::STATUS_COMPLETED, self::STATUS_REJECTED], true)) {
|
|
$gross = $this->resolve_gross_amount($row, $meta);
|
|
$amount_txt = class_exists('AKSRT_Balance') ? AKSRT_Balance::format($gross) : number_format($gross, 2);
|
|
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_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') : '',
|
|
]);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Whether user has open (pending/processing/approved) 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','approved','processing')",
|
|
$user_id
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Sum of frozen gross amounts still open (balance source).
|
|
*/
|
|
public function sum_pending_hold(int $user_id): float {
|
|
global $wpdb;
|
|
if ($user_id <= 0) {
|
|
return 0.0;
|
|
}
|
|
$rows = $wpdb->get_results($wpdb->prepare(
|
|
"SELECT amount, account_info FROM {$this->table()}
|
|
WHERE user_id = %d AND status IN ('pending','approved','processing')",
|
|
$user_id
|
|
), ARRAY_A);
|
|
$total = 0.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'])) {
|
|
continue;
|
|
}
|
|
$total += $this->resolve_gross_amount($row, $meta);
|
|
}
|
|
return round($total, 2);
|
|
}
|
|
|
|
/**
|
|
* 获取用户提现列表
|
|
*
|
|
* @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','approved','processing')";
|
|
} else {
|
|
$status = sanitize_key((string) ($args['status'] ?? ''));
|
|
if ($status !== '') {
|
|
$where[] = 'status = %s';
|
|
$params[] = $status;
|
|
}
|
|
}
|
|
|
|
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
|
$offset = max(0, (int) ($args['offset'] ?? 0));
|
|
|
|
$sql = "SELECT * FROM {$this->table()} WHERE " . implode(' AND ', $where) . ' ORDER BY created_at DESC LIMIT %d OFFSET %d';
|
|
$params[] = $limit;
|
|
$params[] = $offset;
|
|
|
|
$rows = (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
|
|
return array_map([$this, 'normalize_row'], $rows);
|
|
}
|
|
|
|
/**
|
|
* 获取所有提现列表(管理后台)
|
|
*
|
|
* @return list<array<string,mixed>>
|
|
*/
|
|
public function get_all(array $args = []): array {
|
|
global $wpdb;
|
|
|
|
$where = [];
|
|
$params = [];
|
|
|
|
$status = sanitize_key((string) ($args['status'] ?? ''));
|
|
if ($status !== '') {
|
|
$where[] = 'status = %s';
|
|
$params[] = $status;
|
|
}
|
|
|
|
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
|
$offset = max(0, (int) ($args['offset'] ?? 0));
|
|
|
|
$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;
|
|
|
|
$status = sanitize_key((string) ($args['status'] ?? ''));
|
|
if ($status !== '') {
|
|
return (int) $wpdb->get_var($wpdb->prepare(
|
|
"SELECT COUNT(*) FROM {$this->table()} WHERE status = %s",
|
|
$status
|
|
));
|
|
}
|
|
return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$this->table()}");
|
|
}
|
|
|
|
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 = '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),
|
|
'completed' => (int) ($row['completed'] ?? 0),
|
|
'total_paid' => (float) ($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['fee_rate'] = (int) ($meta['fee_rate'] ?? 0);
|
|
$row['display_account'] = (string) ($meta['account'] ?? $row['account_info'] ?? '');
|
|
$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'] ?? 'balance'));
|
|
// Empty / balance = cash wallet freeze path.
|
|
return $source === '' || $source === 'balance';
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $row
|
|
* @param array<string,mixed> $meta
|
|
*/
|
|
private function resolve_gross_amount(array $row, array $meta): float {
|
|
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 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;
|
|
}
|
|
}
|