feat(user-center): add cash wallet balance separate from credits

Introduce AKSRT_Balance (user_meta aksrt_balance + balance_log) for
Zibll-like pay_balance wallet. User center assets panel shows wallet
and points; balance gateway/card-pass/refunds credit cash when enabled.
This commit is contained in:
kate522 committed 2026-07-16 13:44:37 +08:00
1 parent f043d2c1ab
commit 8e0f9d989d
16 files changed
+637 -74

No files matched your search

+19 -9
View File
@@ -514,15 +514,25 @@ class AKSRT_After_Sale {
$payment_method = (string) ($order['payment_method'] ?? 'balance');
$order_number = (string) ($order['order_number'] ?? '');
// Balance refund: credit back via AKSRT_Credits
if ($payment_method === 'balance' && class_exists('AKSRT_Credits')) {
AKSRT_Credits::add(
$user_id,
(int) round($refund_amount),
sprintf(__('售后退款:订单 %s', 'aksrt'), $order_number),
['order_number' => $order_number, 'type' => 'refund'],
sprintf(__('售后退款金额 ¥%s', 'aksrt'), number_format($refund_amount, 2))
);
// Balance refund: prefer cash wallet, fall back to points.
if ($payment_method === 'balance') {
if (class_exists('AKSRT_Balance') && AKSRT_Balance::enabled()) {
AKSRT_Balance::add(
$user_id,
(float) $refund_amount,
'order_refund',
['type' => 'order', 'order' => $order_number],
sprintf(__('售后退款:订单 %s', 'aksrt'), $order_number)
);
} elseif (class_exists('AKSRT_Credits')) {
AKSRT_Credits::add(
$user_id,
(int) round($refund_amount),
'order_refund',
['order_number' => $order_number, 'type' => 'refund'],
sprintf(__('售后退款金额 ¥%s', 'aksrt'), number_format($refund_amount, 2))
);
}
}
// Deduct sales_volume for refunded items
+277
View File
@@ -0,0 +1,277 @@
<?php
/**
* AKSRT cash wallet (站内余额) — separate from points/credits.
*
* Storage:
* - Balance: user_meta `aksrt_balance` (float, 2 decimals)
* - Ledger: `{prefix}aksrt_balance_log`
*
* Mirrors Zibll zibpay_get_user_balance / zibpay_update_user_balance.
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_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_DB_Migrator')) {
return;
}
$table = AKSRT_DB_Migrator::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_DB_Migrator')) {
return ['items' => [], 'total' => 0];
}
$table = AKSRT_DB_Migrator::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')) {
return false;
}
if (method_exists('AKSRT_Settings', 'get_payment_recharge_settings')) {
$cfg = AKSRT_Settings::get_payment_recharge_settings();
return !empty($cfg['enabled']);
}
return !empty(AKSRT_Settings::get_all()['pay_balance_s']);
}
/**
* 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'),
'admin' => __('管理员调整', 'aksrt'),
'exchange_from_credits' => __('积分兑换', 'aksrt'),
];
$key = sanitize_key($reason);
return $map[$key] ?? ($reason !== '' ? $reason : __('余额变动', 'aksrt'));
}
}
+26 -7
View File
@@ -419,21 +419,40 @@ class AKSRT_Charge_Card {
*/
private function fulfill(int $user_id, string $type, array $meta, string $order_number, int $card_id) {
if ($type === self::TYPE_BALANCE) {
$amount = (int) round((float) ($meta['amount'] ?? 0));
if ($amount <= 0 || !class_exists('AKSRT_Credits')) {
$amount = round((float) ($meta['amount'] ?? 0), 2);
if ($amount <= 0) {
return new WP_Error('aksrt_card_amount', __('当前卡密可充值金额为0', 'aksrt'), ['status' => 400]);
}
$new = AKSRT_Credits::add($user_id, $amount, 'card_pass_balance', [
// Cash wallet first (Zibll pay_balance); fall back to points if wallet class missing.
if (class_exists('AKSRT_Balance')) {
$new = AKSRT_Balance::add($user_id, $amount, 'card_pass_balance', [
'type' => 'charge_card',
'id' => $card_id,
'order' => $order_number,
], sprintf(__('卡密充值 %s 元', 'aksrt'), (string) $amount));
if (is_wp_error($new)) {
return new WP_Error('aksrt_balance_fail', $new->get_error_message() ?: __('余额入账失败', 'aksrt'), ['status' => 500]);
}
return [
'message' => sprintf(__('卡密充值成功,充值金额:%s 元', 'aksrt'), (string) $amount),
'amount' => (float) $amount,
];
}
if (!class_exists('AKSRT_Credits')) {
return new WP_Error('aksrt_card_amount', __('余额系统不可用', 'aksrt'), ['status' => 500]);
}
$pts = (int) round($amount);
$new = AKSRT_Credits::add($user_id, $pts, 'card_pass_balance', [
'ref_type' => 'charge_card',
'ref_id' => $card_id,
'order' => $order_number,
], sprintf(__('卡密充值 %d', 'aksrt'), $amount));
if ($new === null || $new === false) {
], sprintf(__('卡密充值 %d', 'aksrt'), $pts));
if ($new === null || $new === false || is_wp_error($new)) {
return new WP_Error('aksrt_credit_fail', __('余额入账失败', 'aksrt'), ['status' => 500]);
}
return [
'message' => sprintf(__('卡密充值成功,充值金额:%s', 'aksrt'), (string) $amount),
'amount' => (float) $amount,
'message' => sprintf(__('卡密充值成功,充值金额:%s', 'aksrt'), (string) $pts),
'amount' => (float) $pts,
];
}
+1 -1
View File
@@ -19,7 +19,7 @@ class AKSRT_DB_Migrator {
use AKSRT_DB_Migrator_Part_3;
/** Current schema version; changing it re-runs dbDelta. */
const SCHEMA_VERSION = '1.14.0';
const SCHEMA_VERSION = '1.15.0';
const OPT_KEY = 'aksrt_db_schema_version';
+56 -13
View File
@@ -338,7 +338,7 @@ class AKSRT_Payment_Gateway {
/**
* 站内余额支付网关(内置实现)
*
* 使用积分/余额进行支付,无需第三方。
* 优先扣现金钱包(AKSRT_Balance / pay_balance);未启用时回退到积分。
*/
class AKSRT_Balance_Gateway implements AKSRT_Payment_Gateway_Interface {
@@ -351,7 +351,9 @@ class AKSRT_Balance_Gateway implements AKSRT_Payment_Gateway_Interface {
}
public function is_configured(): bool {
if (!class_exists('AKSRT_Credits')) {
$wallet_on = class_exists('AKSRT_Balance') && AKSRT_Balance::enabled();
$credits_on = class_exists('AKSRT_Credits');
if (!$wallet_on && !$credits_on) {
return false;
}
if (class_exists('AKSRT_Settings_Payment_Runtime')) {
@@ -361,17 +363,43 @@ class AKSRT_Balance_Gateway implements AKSRT_Payment_Gateway_Interface {
}
public function create_payment(array $order): array {
$user_id = (int) ($order['user_id'] ?? 0);
$amount = round((float) ($order['amount'] ?? 0), 2);
$order_number = (string) ($order['order_number'] ?? '');
// Prefer cash wallet when pay_balance_s is on.
if (class_exists('AKSRT_Balance') && AKSRT_Balance::enabled()) {
if ($user_id <= 0) {
return ['success' => false, 'message' => __('请先登录', 'aksrt')];
}
$balance = AKSRT_Balance::balance($user_id);
if ($balance < $amount) {
return ['success' => false, 'message' => __('余额不足', 'aksrt')];
}
$result = AKSRT_Balance::deduct(
$user_id,
$amount,
'order_payment',
['type' => 'order', 'id' => 0, 'order' => $order_number],
sprintf(__('订单 %s 支付', 'aksrt'), $order_number)
);
if (is_wp_error($result)) {
return ['success' => false, 'message' => $result->get_error_message() ?: __('扣款失败', 'aksrt')];
}
return [
'success' => true,
'transaction_id' => 'BAL_' . ($order_number !== '' ? $order_number : (string) time()),
'message' => __('余额支付成功', 'aksrt'),
];
}
// Fallback: legacy points-as-balance (1 元 = 100 积分).
if (!class_exists('AKSRT_Credits')) {
return ['success' => false, 'message' => __('积分系统未启用', 'aksrt')];
}
$user_id = (int) ($order['user_id'] ?? 0);
$amount = (float) ($order['amount'] ?? 0);
$credits_cost = (int) ceil($amount * 100); // 1 元 = 100 积分
$order_number = (string) ($order['order_number'] ?? '');
$credits_cost = (int) ceil($amount * 100);
$balance = AKSRT_Credits::balance($user_id);
if ($balance < $credits_cost) {
return ['success' => false, 'message' => __('余额不足', 'aksrt')];
}
@@ -416,10 +444,6 @@ class AKSRT_Balance_Gateway implements AKSRT_Payment_Gateway_Interface {
}
public function refund(string $order_number, float $amount): array {
if (!class_exists('AKSRT_Credits')) {
return ['success' => false, 'message' => __('积分系统未启用', 'aksrt')];
}
$order_repo = AKSRT_Order_Repository::get_instance();
$order = $order_repo->get($order_number);
@@ -428,8 +452,27 @@ class AKSRT_Balance_Gateway implements AKSRT_Payment_Gateway_Interface {
}
$user_id = (int) $order['user_id'];
$credits_refund = (int) ceil($amount * 100);
$amount = round((float) $amount, 2);
if (class_exists('AKSRT_Balance') && AKSRT_Balance::enabled()) {
$result = AKSRT_Balance::add(
$user_id,
$amount,
'order_refund',
['type' => 'order', 'order' => $order_number],
sprintf(__('订单 %s 退款', 'aksrt'), $order_number)
);
if (is_wp_error($result)) {
return ['success' => false, 'message' => $result->get_error_message() ?: __('退款失败', 'aksrt')];
}
return ['success' => true, 'refund_id' => 'BAL_REF_' . $order_number, 'message' => __('退款成功', 'aksrt')];
}
if (!class_exists('AKSRT_Credits')) {
return ['success' => false, 'message' => __('积分系统未启用', 'aksrt')];
}
$credits_refund = (int) ceil($amount * 100);
$result = AKSRT_Credits::add(
$user_id,
$credits_refund,
+22 -2
View File
@@ -92,8 +92,16 @@ class AKSRT_User_Center {
$this->panels = [];
foreach ((array) ($settings['tabs'] ?? []) as $key) {
if ($key === 'credits' && (!class_exists('AKSRT_Settings') || !AKSRT_Settings::credits_enabled())) {
continue;
if ($key === 'credits') {
$credits_on = class_exists('AKSRT_Settings') && AKSRT_Settings::credits_enabled();
$balance_on = class_exists('AKSRT_Balance') && AKSRT_Balance::enabled();
if (!$credits_on && !$balance_on) {
continue;
}
if ($balance_on) {
$definitions['credits']['label'] = __('我的资产', 'aksrt');
$definitions['credits']['icon'] = 'wallet';
}
}
if (isset($definitions[$key])) {
@@ -101,6 +109,18 @@ class AKSRT_User_Center {
}
}
// Ensure assets panel when wallet and/or credits enabled (even if tabs list omitted credits).
$credits_on = class_exists('AKSRT_Settings') && AKSRT_Settings::credits_enabled();
$balance_on = class_exists('AKSRT_Balance') && AKSRT_Balance::enabled();
if (!isset($this->panels['credits']) && ($credits_on || $balance_on)) {
$panel = $definitions['credits'];
if ($balance_on) {
$panel['label'] = __('我的资产', 'aksrt');
$panel['icon'] = 'wallet';
}
$this->panels['credits'] = $panel;
}
if (!isset($this->panels['settings'])) {
$this->panels['settings'] = $definitions['settings'];
}
@@ -33,6 +33,25 @@ trait AKSRT_DB_Migrator_Part_2 {
) {$charset_collate};";
dbDelta($sql_credit_log);
// ---------- 1b. 现金余额流水(站内钱包,与积分分离) ----------
$sql_balance_log = "CREATE TABLE {$prefix}aksrt_balance_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
delta DECIMAL(18,2) NOT NULL,
balance_after DECIMAL(18,2) NOT NULL,
reason VARCHAR(64) NOT NULL,
remark VARCHAR(255) DEFAULT NULL,
ref_type VARCHAR(32) DEFAULT NULL,
ref_id BIGINT UNSIGNED DEFAULT NULL,
ip VARCHAR(64) DEFAULT NULL,
ua VARCHAR(255) DEFAULT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_user_time (user_id, created_at),
KEY idx_reason (reason)
) {$charset_collate};";
dbDelta($sql_balance_log);
// ---------- 2. 签到 ----------
$sql_signin = "CREATE TABLE {$prefix}aksrt_signin_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -11,6 +11,7 @@ trait AKSRT_DB_Migrator_Part_3 {
global $wpdb;
$allowed = [
'credit_log' => 'aksrt_credit_log',
'balance_log' => 'aksrt_balance_log',
'signin_log' => 'aksrt_signin_log',
'invite' => 'aksrt_invite',
'dl_token' => 'aksrt_dl_token',