$ref * @return float|\WP_Error New balance. */ public static function add(int $user_id, float $delta, string $reason, array $ref = [], string $remark = '') { if ($user_id <= 0) { return new \WP_Error('aksrt_balance_user', __('用户无效', 'aksrt')); } $delta = round((float) $delta, 2); if ($delta <= 0) { return new \WP_Error('aksrt_balance_delta', __('增加余额必须为正数', 'aksrt')); } $new = self::atomic_update($user_id, $delta, false); if ($new === null) { return new \WP_Error('aksrt_balance_db', __('余额写入失败', 'aksrt')); } self::log($user_id, $delta, $new, $reason, $ref, $remark); do_action('aksrt_balance_added', $user_id, $delta, $reason, $new); return $new; } /** * Debit cash wallet. * * @param array $ref * @return float|\WP_Error New balance. */ public static function deduct(int $user_id, float $cost, string $reason, array $ref = [], string $remark = '') { if ($user_id <= 0) { return new \WP_Error('aksrt_balance_user', __('用户无效', 'aksrt')); } $cost = round((float) $cost, 2); if ($cost < 0) { return new \WP_Error('aksrt_balance_delta', __('扣减余额必须为非负数', 'aksrt')); } if ($cost === 0.0) { return self::balance($user_id); } $new = self::atomic_update($user_id, $cost, true); if ($new === null) { $current = self::balance($user_id); return new \WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), [ 'balance' => $current, 'cost' => $cost, ]); } self::log($user_id, -$cost, $new, $reason, $ref, $remark); do_action('aksrt_balance_deducted', $user_id, $cost, $reason, $new); return $new; } /** * @return float|null */ private static function atomic_update(int $user_id, float $abs_delta, bool $is_deduct) { global $wpdb; $abs_delta = round(abs($abs_delta), 2); $meta_key = self::META_KEY; $table = $wpdb->usermeta; // Ensure meta row exists. $exists = $wpdb->get_var($wpdb->prepare( "SELECT umeta_id FROM {$table} WHERE user_id = %d AND meta_key = %s LIMIT 1", $user_id, $meta_key )); if (!$exists) { add_user_meta($user_id, $meta_key, '0', true); } if ($is_deduct) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $updated = $wpdb->query($wpdb->prepare( "UPDATE {$table} SET meta_value = CAST(ROUND(CAST(meta_value AS DECIMAL(18,2)) - %f, 2) AS CHAR) WHERE user_id = %d AND meta_key = %s AND CAST(meta_value AS DECIMAL(18,2)) >= %f", $abs_delta, $user_id, $meta_key, $abs_delta )); } else { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $updated = $wpdb->query($wpdb->prepare( "UPDATE {$table} SET meta_value = CAST(ROUND(CAST(meta_value AS DECIMAL(18,2)) + %f, 2) AS CHAR) WHERE user_id = %d AND meta_key = %s", $abs_delta, $user_id, $meta_key )); } if ($updated !== 1) { return null; } wp_cache_delete($user_id, 'user_meta'); return self::balance($user_id); } /** * @param array $ref */ public static function log(int $user_id, float $delta, float $balance_after, string $reason, array $ref = [], string $remark = ''): void { global $wpdb; if (!class_exists('\AKSRT\Core\DBMigrator')) { return; } $table = \AKSRT\Core\DBMigrator::table('balance_log'); if ($table === '') { return; } $ip = ''; if (!empty($_SERVER['REMOTE_ADDR'])) { $ip = sanitize_text_field(wp_unslash((string) $_SERVER['REMOTE_ADDR'])); } $ua = ''; if (!empty($_SERVER['HTTP_USER_AGENT'])) { $ua = substr(sanitize_text_field(wp_unslash((string) $_SERVER['HTTP_USER_AGENT'])), 0, 255); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->insert( $table, [ 'user_id' => $user_id, 'delta' => round($delta, 2), 'balance_after' => round($balance_after, 2), 'reason' => sanitize_key(substr($reason, 0, 64)), 'remark' => $remark !== '' ? sanitize_text_field(substr($remark, 0, 255)) : null, 'ref_type' => isset($ref['type']) ? sanitize_key((string) $ref['type']) : null, 'ref_id' => isset($ref['id']) ? (int) $ref['id'] : (isset($ref['ref_id']) ? (int) $ref['ref_id'] : null), 'ip' => $ip !== '' ? $ip : null, 'ua' => $ua !== '' ? $ua : null, 'created_at' => current_time('mysql', true), ], ['%d', '%f', '%f', '%s', '%s', '%s', '%d', '%s', '%s', '%s'] ); } /** * @param array $args * @return array{items:array>,total:int} */ public static function get_log(array $args = []): array { global $wpdb; if (!class_exists('\AKSRT\Core\DBMigrator')) { return ['items' => [], 'total' => 0]; } $table = \AKSRT\Core\DBMigrator::table('balance_log'); if ($table === '') { return ['items' => [], 'total' => 0]; } $user_id = max(0, (int) ($args['user_id'] ?? 0)); $page = max(1, (int) ($args['page'] ?? 1)); $per_page = max(1, min(50, (int) ($args['per_page'] ?? 10))); $offset = ($page - 1) * $per_page; $where = '1=1'; $params = []; if ($user_id > 0) { $where .= ' AND user_id = %d'; $params[] = $user_id; } // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $total_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where}"; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $total = (int) ($params ? $wpdb->get_var($wpdb->prepare($total_sql, $params)) : $wpdb->get_var($total_sql)); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $list_sql = "SELECT * FROM {$table} WHERE {$where} ORDER BY id DESC LIMIT %d OFFSET %d"; $list_params = array_merge($params, [$per_page, $offset]); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $rows = $wpdb->get_results($wpdb->prepare($list_sql, $list_params), ARRAY_A); if (!is_array($rows)) { $rows = []; } $items = []; foreach ($rows as $row) { $items[] = [ 'id' => (int) ($row['id'] ?? 0), 'user_id' => (int) ($row['user_id'] ?? 0), 'delta' => round((float) ($row['delta'] ?? 0), 2), 'balance_after' => round((float) ($row['balance_after'] ?? 0), 2), 'reason' => (string) ($row['reason'] ?? ''), 'remark' => (string) ($row['remark'] ?? ''), 'created_at' => (string) ($row['created_at'] ?? ''), ]; } return ['items' => $items, 'total' => $total]; } /** * Whether cash wallet feature is enabled (Zibll pay_balance_s). */ public static function enabled(): bool { if (!class_exists('\AKSRT\Settings\Settings')) { return false; } if (method_exists('\AKSRT\Settings\Settings', 'get_payment_recharge_settings')) { $cfg = \AKSRT\Settings\Settings::get_payment_recharge_settings(); return !empty($cfg['enabled']); } return !empty(\AKSRT\Settings\Settings::get_all()['pay_balance_s']); } /** * Atomically transfer cash balance between two users. * * @param array $ref * @return array{sender_balance:float,recipient_balance:float}|\WP_Error */ public static function transfer(int $sender_id, int $recipient_id, float $amount, string $reason_out = 'tip_sent', string $reason_in = 'tip_received', array $ref = [], string $remark = '') { global $wpdb; $amount = round($amount, 2); if ($sender_id <= 0 || $recipient_id <= 0 || $sender_id === $recipient_id) { return new \WP_Error('aksrt_balance_transfer_user', __('转账用户无效', 'aksrt')); } if ($amount <= 0) { return new \WP_Error('aksrt_balance_transfer_amount', __('转账金额必须大于零', 'aksrt')); } if (!class_exists('\AKSRT\Core\DBMigrator')) { return new \WP_Error('aksrt_balance_transfer_unavailable', __('余额服务不可用', 'aksrt')); } foreach ([$sender_id, $recipient_id] as $user_id) { if (!metadata_exists('user', $user_id, self::META_KEY)) { add_user_meta($user_id, self::META_KEY, '0', true); } } $meta_table = $wpdb->usermeta; $log_table = \AKSRT\Core\DBMigrator::table('balance_log'); $locked_ids = [$sender_id, $recipient_id]; sort($locked_ids, SORT_NUMERIC); $wpdb->query('START TRANSACTION'); try { foreach ($locked_ids as $user_id) { $wpdb->get_var($wpdb->prepare( "SELECT umeta_id FROM {$meta_table} WHERE user_id = %d AND meta_key = %s FOR UPDATE", $user_id, self::META_KEY )); } $sender_before = (float) $wpdb->get_var($wpdb->prepare( "SELECT meta_value FROM {$meta_table} WHERE user_id = %d AND meta_key = %s LIMIT 1", $sender_id, self::META_KEY )); if ($sender_before < $amount) { $wpdb->query('ROLLBACK'); return new \WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt')); } $sender_after = round($sender_before - $amount, 2); $recipient_before = (float) $wpdb->get_var($wpdb->prepare( "SELECT meta_value FROM {$meta_table} WHERE user_id = %d AND meta_key = %s LIMIT 1", $recipient_id, self::META_KEY )); $recipient_after = round($recipient_before + $amount, 2); $sender_updated = $wpdb->update($meta_table, ['meta_value' => (string) $sender_after], ['user_id' => $sender_id, 'meta_key' => self::META_KEY], ['%s'], ['%d', '%s']); $recipient_updated = $wpdb->update($meta_table, ['meta_value' => (string) $recipient_after], ['user_id' => $recipient_id, 'meta_key' => self::META_KEY], ['%s'], ['%d', '%s']); if ($sender_updated !== 1 || $recipient_updated !== 1) { throw new \RuntimeException('balance_update_failed'); } $ref_type = isset($ref['type']) ? sanitize_key((string) $ref['type']) : 'tip'; $ref_id = isset($ref['id']) ? (int) $ref['id'] : null; $created_at = current_time('mysql', true); $log_rows = [ [$sender_id, -$amount, $sender_after, $reason_out], [$recipient_id, $amount, $recipient_after, $reason_in], ]; foreach ($log_rows as [$user_id, $delta, $balance_after, $reason]) { $inserted = $wpdb->insert($log_table, [ 'user_id' => $user_id, 'delta' => $delta, 'balance_after' => $balance_after, 'reason' => sanitize_key($reason), 'remark' => $remark !== '' ? sanitize_text_field(substr($remark, 0, 255)) : null, 'ref_type' => $ref_type, 'ref_id' => $ref_id, 'created_at' => $created_at, ], ['%d', '%f', '%f', '%s', '%s', '%s', '%d', '%s']); if ($inserted !== 1) { throw new \RuntimeException('balance_log_failed'); } } $wpdb->query('COMMIT'); wp_cache_delete($sender_id, 'user_meta'); wp_cache_delete($recipient_id, 'user_meta'); do_action('aksrt_balance_transferred', $sender_id, $recipient_id, $amount, $ref); return ['sender_balance' => $sender_after, 'recipient_balance' => $recipient_after]; } catch (\Throwable $e) { $wpdb->query('ROLLBACK'); return new \WP_Error('aksrt_balance_transfer_failed', __('打赏入账失败,请稍后重试', 'aksrt')); } } /** * Format yuan for display. */ public static function format(float $amount): string { return number_format_i18n(round($amount, 2), 2); } /** * Reason label for ledger UI. */ public static function reason_label(string $reason): string { $map = [ 'card_pass_balance' => __('卡密充值', 'aksrt'), 'order_payment' => __('订单支付', 'aksrt'), 'order_refund' => __('订单退款', 'aksrt'), 'recharge' => __('余额充值', 'aksrt'), 'transfer_in' => __('转入', 'aksrt'), 'transfer_out' => __('转出', 'aksrt'), 'tip_sent' => __('打赏支出', 'aksrt'), 'tip_received' => __('收到打赏', 'aksrt'), 'admin' => __('管理员调整', 'aksrt'), 'exchange_from_credits' => __('积分兑换', 'aksrt'), 'withdraw_hold' => __('提现冻结', 'aksrt'), 'withdraw_refund' => __('提现退回', 'aksrt'), ]; $key = sanitize_key($reason); return $map[$key] ?? ($reason !== '' ? $reason : __('余额变动', 'aksrt')); } }