Files
wordpress-theme-aksrt/src/AKSRT/Credits/Balance.php
T

375 lines
12 KiB
PHP

<?php
/**
* @package AKSRT
*/
namespace AKSRT\Credits;
defined('ABSPATH') || exit;
class Balance {
public const META_KEY = 'aksrt_balance';
/**
* Current cash balance (yuan).
*/
public static function balance(int $user_id): float {
if ($user_id <= 0) {
return 0.0;
}
$val = get_user_meta($user_id, self::META_KEY, true);
return is_numeric($val) ? round((float) $val, 2) : 0.0;
}
public static function get_balance(int $user_id): float {
return self::balance($user_id);
}
/**
* Credit cash wallet.
*
* @param array<string,mixed> $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<string,mixed> $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<string,mixed> $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<string,mixed> $args
* @return array{items:array<int,array<string,mixed>>,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<string,mixed> $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'));
}
}