积分与余额账务系统硬切重构

This commit is contained in:
kate522 committed 2026-08-24 15:59:11 +08:00
1 parent ca12226b39
commit 5cae5b5c93
159 files changed
+7275 -4718

No files matched your search

-379
View File
@@ -1,379 +0,0 @@
<?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 = '', bool $dispatch_hooks = true) {
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);
if ($dispatch_hooks) {
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 (legacy 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'),
'points_exchange_out' => __('余额兑换积分', 'aksrt'),
'points_exchange_refund' => __('余额兑换积分退款', 'aksrt'),
'creator_income' => __('创作收益结算', 'aksrt'),
'rebate' => __('推广返佣结算', 'aksrt'),
'withdraw_hold' => __('提现冻结', 'aksrt'),
'withdraw_refund' => __('提现退回', 'aksrt'),
];
$key = sanitize_key($reason);
return $map[$key] ?? ($reason !== '' ? $reason : __('余额变动', 'aksrt'));
}
}
-1
View File
@@ -26,7 +26,6 @@ class BalanceService {
public function init(): void {
add_action('rest_api_init', [$this, 'register_routes']);
add_action('aksrt_order_paid', [$this, 'on_order_paid'], 20, 2);
}
}
+178 -108
View File
@@ -12,93 +12,96 @@ trait BalanceServiceCharge {
/**
* When a recharge order is paid, credit the wallet.
* Atomically confirms a recharge order and credits its cash account.
*
* @param string $order_number Order number.
* @param array<string,mixed> $order Order row.
* @param array<string,mixed> $order
*/
public function on_order_paid(string $order_number, array $order = []): void {
if ($order === [] && class_exists('\AKSRT\Payment\OrderRepository')) {
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number) ?: [];
}
if (!$order) {
return;
}
$type = sanitize_key((string) ($order['product_type'] ?? ''));
public function complete_recharge_payment(array $order, string $payment_method, string $provider_transaction_id, string $status_note = ''): bool {
$order_number = sanitize_text_field((string) ($order['order_number'] ?? ''));
$user_id = (int) ($order['user_id'] ?? 0);
if ($user_id <= 0) {
return;
}
if ($type !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE) {
return;
}
if (!class_exists('AKSRT\\Credits\\Balance')) {
return;
}
// Honor pay_auto_to_balance (default on). When off, leave order paid without wallet credit.
if (class_exists('\AKSRT\Settings\Settings') && method_exists('\AKSRT\Settings\Settings', 'get_payment_recharge_settings')) {
$recharge_cfg = \AKSRT\Settings\Settings::get_payment_recharge_settings();
if (isset($recharge_cfg['auto_to_balance']) && empty($recharge_cfg['auto_to_balance'])) {
return;
}
if ($order_number === '' || $user_id <= 0 || sanitize_key((string) ($order['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE) {
return false;
}
$note = json_decode((string) ($order['note'] ?? ''), true);
if (!is_array($note)) {
$note = [];
$note = is_array($note) ? $note : [];
if (!isset($note['credit_amount']) || !is_string($note['credit_amount'])) {
return false;
}
if (!empty($note['balance_credited'])) {
return;
$amount_raw = $note['credit_amount'];
try {
$credit = \AKSRT\Ledger\AssetAmount::balance($amount_raw);
} catch (\InvalidArgumentException $e) {
return false;
}
$credit_amount = isset($note['credit_amount'])
? round((float) $note['credit_amount'], 2)
: round((float) ($order['amount'] ?? 0), 2);
if ($credit_amount <= 0) {
return;
if ($credit->minor() <= 0) {
return false;
}
global $wpdb;
if ($wpdb->query('START TRANSACTION') === false) {
return;
}
$result = Balance::add(
$payment_method = sanitize_key($payment_method);
$provider_transaction_id = sanitize_text_field($provider_transaction_id);
$locked_status = '';
$locked_note = [];
$guard = function () use ($order_number, $user_id, &$locked_status, &$locked_note) {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('order');
$current = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE order_number = %s FOR UPDATE", $order_number), ARRAY_A);
if (!is_array($current)
|| (int) ($current['user_id'] ?? 0) !== $user_id
|| sanitize_key((string) ($current['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
|| !in_array((string) ($current['status'] ?? ''), [\AKSRT\Payment\Order::STATUS_CREATED, \AKSRT\Payment\Order::STATUS_PENDING], true)
) {
return new \WP_Error('aksrt_order_confirm_failed', __('订单状态确认失败', 'aksrt'), ['status' => 409]);
}
$locked_status = (string) $current['status'];
$decoded = json_decode((string) ($current['note'] ?? ''), true);
$locked_note = is_array($decoded) ? $decoded : [];
return true;
};
$mutation = function () use ($order_number, $payment_method, $provider_transaction_id, $status_note, $credit, &$locked_status, &$locked_note) {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('order');
$locked_note['ledger_credited'] = 1;
$locked_note['credited_amount'] = $credit->decimal();
if ($status_note !== '') {
$locked_note['status_note'] = sanitize_textarea_field($status_note);
}
$now = current_time('mysql');
$updated = $wpdb->update($table, [
'status' => \AKSRT\Payment\Order::STATUS_COMPLETED,
'payment_method' => $payment_method,
'payment_transaction_id' => $provider_transaction_id,
'paid_at' => $now,
'completed_at' => $now,
'note' => wp_json_encode($locked_note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
], ['order_number' => $order_number, 'status' => $locked_status], ['%s', '%s', '%s', '%s', '%s', '%s'], ['%s', '%s']);
return $updated === 1 ? true : new \WP_Error('aksrt_order_confirm_failed', __('订单状态确认失败', 'aksrt'), ['status' => 409]);
};
$result = \AKSRT\Ledger\LedgerService::get_instance()->credit(
$user_id,
$credit_amount,
\AKSRT\Ledger\AssetType::BALANCE,
$credit->minor(),
\AKSRT\Ledger\OperationKey::deterministic('recharge', [$order_number]),
'recharge',
['type' => 'order', 'order' => $order_number],
sprintf(__('余额充值订单 %s', 'aksrt'), $order_number),
false
$user_id,
['payment_method' => $payment_method, 'provider_transaction_id' => $provider_transaction_id],
$mutation,
$guard
);
if (is_wp_error($result)) {
$wpdb->query('ROLLBACK');
return;
return false;
}
$note['balance_credited'] = 1;
$note['credited_amount'] = $credit_amount;
$updated = $wpdb->update(
$wpdb->prefix . 'aksrt_order',
['note' => wp_json_encode($note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)],
['order_number' => $order_number],
['%s'],
['%s']
);
if ($updated === false) {
$wpdb->query('ROLLBACK');
wp_cache_delete($user_id, 'user_meta');
return;
}
if ($wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
wp_cache_delete($user_id, 'user_meta');
return;
}
do_action('aksrt_balance_added', $user_id, $credit_amount, 'recharge', (float) $result);
if (class_exists('\AKSRT\Payment\OrderRepository')) {
\AKSRT\Payment\OrderRepository::get_instance()->update_status(
$order_number,
\AKSRT\Payment\Order::STATUS_COMPLETED,
['note' => __('充值金额已到账。', 'aksrt')]
);
if (!$result->replayed) {
$paid_order = array_merge($order, [
'status' => \AKSRT\Payment\Order::STATUS_PAID,
'payment_method' => $payment_method,
'payment_transaction_id' => $provider_transaction_id,
]);
do_action('aksrt_order_status_changed', $order_number, \AKSRT\Payment\Order::STATUS_PAID, $paid_order);
do_action('aksrt_order_paid', $order_number, $paid_order);
do_action('aksrt_order_status_changed', $order_number, \AKSRT\Payment\Order::STATUS_COMPLETED, array_merge($paid_order, ['status' => \AKSRT\Payment\Order::STATUS_COMPLETED]));
}
return true;
}
@@ -125,7 +128,7 @@ trait BalanceServiceCharge {
return [
'enabled' => !empty($cfg['enabled']),
'desc' => (string) ($cfg['desc'] ?? ''),
'wallet' => class_exists('AKSRT\\Credits\\Balance') ? Balance::balance($user_id) : 0.0,
'wallet' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'packages' => $packages,
'custom_s' => $custom_s,
'custom_limit' => $custom_limit,
@@ -143,7 +146,7 @@ trait BalanceServiceCharge {
/**
* @return array<int,array{key:string,price:float,pay_price:float,title:string,tag:string}>
* @return array<int,array{key:string,price:string,pay_price:string,title:string,tag:string}>
*/
public function parse_packages(string $raw): array {
$out = [];
@@ -154,23 +157,25 @@ trait BalanceServiceCharge {
if ($line === '') {
continue;
}
// pay|credit|title OR credit|pay|title — legacy uses price(credit)|pay_price|tag
// Admin help: 支付金额|到账金额|标题
$parts = array_map('trim', explode('|', $line));
if (count($parts) < 2) {
continue;
}
$pay = (float) $parts[0];
$credit = (float) $parts[1];
try {
$pay = \AKSRT\Ledger\AssetAmount::balance((string) $parts[0]);
$credit = \AKSRT\Ledger\AssetAmount::balance((string) $parts[1]);
} catch (\InvalidArgumentException $e) {
continue;
}
$title = $parts[2] ?? '';
if ($pay <= 0 || $credit <= 0) {
if ($pay->minor() <= 0 || $credit->minor() <= 0) {
continue;
}
$out[] = [
'key' => (string) $i,
'pay_price' => round($pay, 2),
'price' => round($credit, 2),
'title' => $title !== '' ? $title : sprintf(__('充值 %s 元', 'aksrt'), (string) $credit),
'pay_price' => $pay->decimal(),
'price' => $credit->decimal(),
'title' => $title !== '' ? $title : sprintf(__('充值 %s 元', 'aksrt'), $credit->decimal()),
'tag' => $title,
];
$i++;
@@ -183,7 +188,7 @@ trait BalanceServiceCharge {
/**
* @return array<string,mixed>|\WP_Error
*/
public function create_charge_order(int $user_id, string $package_key, float $custom_price, string $payment_method) {
public function create_charge_order(int $user_id, string $package_key, string $custom_price, string $payment_method, string $operation_key) {
if ($user_id <= 0) {
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
}
@@ -191,23 +196,26 @@ trait BalanceServiceCharge {
return new \WP_Error('aksrt_pay_unavailable', __('订单系统不可用', 'aksrt'), ['status' => 500]);
}
$view = $this->get_charge_view($user_id);
$credit_amount = 0.0;
$pay_amount = 0.0;
$credit_amount = null;
$pay_amount = null;
$title = __('余额充值', 'aksrt');
if ($package_key === 'custom' || $package_key === '') {
if (empty($view['custom_s'])) {
return new \WP_Error('aksrt_custom_closed', __('未开放自定义金额', 'aksrt'), ['status' => 400]);
}
$custom_price = round($custom_price, 2);
$min = 1.0;
$max = (float) $view['custom_limit'];
if ($custom_price < $min || $custom_price > $max) {
return new \WP_Error('aksrt_custom_limit', sprintf(__('充值金额需在 %s ~ %s 之间', 'aksrt'), (string) $min, (string) $max), ['status' => 400]);
try {
$custom_amount = \AKSRT\Ledger\AssetAmount::balance($custom_price);
} catch (\InvalidArgumentException $e) {
return new \WP_Error('aksrt_invalid_price', __('金额格式无效', 'aksrt'), ['status' => 400]);
}
$pay_amount = $custom_price;
$credit_amount = $custom_price;
$title = sprintf(__('自定义充值 %s 元', 'aksrt'), (string) $custom_price);
$max = max(1, (int) $view['custom_limit']);
if ($custom_amount->minor() < 100 || $custom_amount->minor() > $max * 100) {
return new \WP_Error('aksrt_custom_limit', sprintf(__('充值金额需在 1 ~ %d 之间', 'aksrt'), $max), ['status' => 400]);
}
$pay_amount = $custom_amount;
$credit_amount = $custom_amount;
$title = sprintf(__('自定义充值 %s 元', 'aksrt'), $custom_amount->decimal());
$package_key = 'custom';
} else {
$found = null;
@@ -220,14 +228,24 @@ trait BalanceServiceCharge {
if (!$found) {
return new \WP_Error('aksrt_invalid_package', __('充值套餐无效', 'aksrt'), ['status' => 400]);
}
$pay_amount = (float) $found['pay_price'];
$credit_amount = (float) $found['price'];
try {
$pay_amount = \AKSRT\Ledger\AssetAmount::balance((string) $found['pay_price']);
$credit_amount = \AKSRT\Ledger\AssetAmount::balance((string) $found['price']);
} catch (\InvalidArgumentException $e) {
return new \WP_Error('aksrt_invalid_package', __('充值套餐金额无效', 'aksrt'), ['status' => 400]);
}
$title = (string) $found['title'];
}
if ($pay_amount <= 0 || $credit_amount <= 0) {
if (!($pay_amount instanceof \AKSRT\Ledger\AssetAmount)
|| !($credit_amount instanceof \AKSRT\Ledger\AssetAmount)
|| $pay_amount->minor() <= 0
|| $credit_amount->minor() <= 0
) {
return new \WP_Error('aksrt_invalid_price', __('金额无效', 'aksrt'), ['status' => 400]);
}
$pay_decimal = $pay_amount->decimal();
$credit_decimal = $credit_amount->decimal();
$payment_method = sanitize_key($payment_method);
if ($payment_method === '' || $payment_method === 'balance') {
@@ -239,22 +257,46 @@ trait BalanceServiceCharge {
$note = wp_json_encode([
'balance_recharge' => 1,
'package' => $package_key,
'credit_amount' => $credit_amount,
'pay_amount' => $pay_amount,
'credit_amount' => $credit_decimal,
'pay_amount' => $pay_decimal,
'title' => $title,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([
$repo = \AKSRT\Payment\OrderRepository::get_instance();
$existing = $repo->get_by_request_key($operation_key);
if (is_array($existing)) {
$existing_note = json_decode((string) ($existing['note'] ?? ''), true);
$existing_note = is_array($existing_note) ? $existing_note : [];
$existing_credit = isset($existing_note['credit_amount']) && is_string($existing_note['credit_amount'])
? $existing_note['credit_amount']
: '';
$existing_pay = isset($existing_note['pay_amount']) && is_string($existing_note['pay_amount'])
? $existing_note['pay_amount']
: '';
$same = (int) ($existing['user_id'] ?? 0) === $user_id
&& sanitize_key((string) ($existing['product_type'] ?? '')) === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
&& sanitize_key((string) ($existing['payment_method'] ?? '')) === $payment_method
&& (string) ($existing_note['package'] ?? '') === $package_key
&& hash_equals($existing_credit, $credit_decimal)
&& hash_equals($existing_pay, $pay_decimal);
if (!$same) {
return new \WP_Error('aksrt_ledger_idempotency_conflict', __('相同操作标识已用于其他充值请求', 'aksrt'), ['status' => 409]);
}
$order_number = (string) $existing['order_number'];
} else {
$order_number = $repo->create([
'user_id' => $user_id,
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE,
'product_id' => 0,
'amount' => $pay_amount,
'amount' => $pay_decimal,
'currency' => 'CNY',
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
'payment_method' => $payment_method,
'note' => is_string($note) ? $note : '',
'expires_at' => \AKSRT\Settings\PaymentRuntime::get_order_expiry_at(),
]);
'request_key' => $operation_key,
]);
}
if ($order_number === '') {
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
}
@@ -263,6 +305,28 @@ trait BalanceServiceCharge {
if (!$order) {
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
}
if (is_array($existing) && in_array((string) ($order['status'] ?? ''), [
\AKSRT\Payment\Order::STATUS_PAID,
\AKSRT\Payment\Order::STATUS_COMPLETED,
], true)) {
$ledger_key = \AKSRT\Ledger\OperationKey::deterministic('recharge', [$order_number]);
$history = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['operation_key' => $ledger_key, 'page' => 1, 'per_page' => 1]);
$transaction_id = (int) ($history['items'][0]['id'] ?? 0);
if ($transaction_id <= 0) {
return new \WP_Error('aksrt_recharge_state', __('充值订单与账本状态不一致', 'aksrt'), ['status' => 409]);
}
return [
'redirect' => false,
'order_number' => $order_number,
'amount' => $pay_decimal,
'credit_amount' => $credit_decimal,
'transaction_id' => $transaction_id,
'replayed' => true,
'wallet' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'message' => __('充值成功', 'aksrt'),
'reload' => true,
];
}
$order['subject'] = $title;
$order['payment_method'] = $payment_method;
@@ -285,7 +349,7 @@ trait BalanceServiceCharge {
return new \WP_Error(
(string) ($payment_result['code'] ?? 'aksrt_pay_failed'),
(string) ($payment_result['message'] ?? __('支付失败', 'aksrt')),
['status' => 402, 'order_number' => $order_number]
['status' => (int) ($payment_result['status'] ?? 400), 'order_number' => $order_number]
);
}
@@ -295,23 +359,29 @@ trait BalanceServiceCharge {
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
'order_number' => $order_number,
'amount' => $pay_amount,
'credit_amount' => $credit_amount,
'amount' => $pay_decimal,
'credit_amount' => $credit_decimal,
'message' => $payment_result['message'],
'reload' => false,
'poll' => true,
'transaction_id' => 0,
'replayed' => false,
'wallet' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
];
}
// Synchronous gateway: PaymentOrchestrator's paid transition already fired
// aksrt_order_paid, so fulfillment has run through the single hook path.
// The synchronous gateway has already committed the recharge and order state.
$ledger_key = \AKSRT\Ledger\OperationKey::deterministic('recharge', [$order_number]);
$history = \AKSRT\Ledger\LedgerQuery::get_instance()->history(['operation_key' => $ledger_key, 'page' => 1, 'per_page' => 1]);
return [
'redirect' => false,
'order_number' => $order_number,
'amount' => $pay_amount,
'credit_amount' => $credit_amount,
'wallet' => Balance::balance($user_id),
'amount' => $pay_decimal,
'credit_amount' => $credit_decimal,
'transaction_id' => (int) ($history['items'][0]['id'] ?? 0),
'replayed' => false,
'wallet' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'message' => __('充值成功', 'aksrt'),
'reload' => true,
];
+164 -182
View File
@@ -16,16 +16,17 @@ trait BalanceServiceRest {
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'rest_charge_options'],
'permission_callback' => static function () {
return is_user_logged_in();
},
'permission_callback' => [$this, 'check_asset_rest'],
],
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'rest_charge_create'],
'permission_callback' => static function () {
return is_user_logged_in();
},
'permission_callback' => [$this, 'check_asset_rest'],
'args' => [
'package' => ['type' => 'string', 'default' => 'custom', 'sanitize_callback' => 'sanitize_text_field'],
'custom_price' => ['type' => 'string', 'default' => '0', 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
'payment_method' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key'],
],
],
]);
@@ -33,9 +34,18 @@ trait BalanceServiceRest {
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'rest_transfer'],
'permission_callback' => static function () {
return is_user_logged_in();
},
'permission_callback' => [$this, 'check_asset_rest'],
'args' => [
'type' => ['type' => 'string', 'required' => true, 'enum' => ['balance', 'points']],
'recipient' => ['type' => 'integer', 'required' => true, 'minimum' => 1],
'amount' => [
'required' => true,
'oneOf' => [
['type' => 'integer', 'minimum' => 1],
['type' => 'string', 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
],
],
],
],
]);
@@ -43,9 +53,7 @@ trait BalanceServiceRest {
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'rest_transfer_search'],
'permission_callback' => static function () {
return is_user_logged_in();
},
'permission_callback' => [$this, 'check_asset_rest'],
],
]);
@@ -53,21 +61,30 @@ trait BalanceServiceRest {
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'rest_exchange_balance_for_points'],
'permission_callback' => static function () {
return is_user_logged_in();
},
'permission_callback' => [$this, 'check_asset_rest'],
'args' => ['amount' => ['type' => 'string', 'required' => true, 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$']],
],
]);
register_rest_route('aksrt/v1', '/pay/balance/withdraw', [
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'rest_balance_withdraw'],
'permission_callback' => static function () {
return is_user_logged_in();
},
],
]);
}
public function check_asset_rest(\WP_REST_Request $request) {
if (!is_user_logged_in()) {
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
}
$nonce = (string) $request->get_header('X-WP-Nonce');
if ($nonce === '' || !wp_verify_nonce($nonce, 'wp_rest')) {
return new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
}
return true;
}
private function balance_feature_enabled(): bool {
if (!class_exists('\\AKSRT\\Settings\\Settings')) {
return false;
}
$cfg = \AKSRT\Settings\Settings::get_payment_recharge_settings();
return !empty($cfg['enabled']);
}
@@ -76,7 +93,7 @@ trait BalanceServiceRest {
* GET charge options (packages + methods + limits).
*/
public function rest_charge_options(\WP_REST_Request $request) {
if (!class_exists('AKSRT\\Credits\\Balance') || !Balance::enabled()) {
if (!$this->balance_feature_enabled()) {
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
}
$user_id = get_current_user_id();
@@ -92,19 +109,23 @@ trait BalanceServiceRest {
* POST create recharge order and initiate payment.
*/
public function rest_charge_create(\WP_REST_Request $request) {
if (!class_exists('AKSRT\\Credits\\Balance') || !Balance::enabled()) {
if (!$this->balance_feature_enabled()) {
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
}
$user_id = get_current_user_id();
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'recharge-create', $user_id);
if (is_wp_error($operation_key)) {
return $operation_key;
}
$params = $request->get_json_params();
if (!is_array($params)) {
$params = $request->get_params();
}
$package_key = sanitize_text_field((string) ($params['package'] ?? 'custom'));
$custom_price = isset($params['custom_price']) ? (float) $params['custom_price'] : 0.0;
$custom_price = isset($params['custom_price']) ? (string) $params['custom_price'] : '0';
$payment_method = sanitize_key((string) ($params['payment_method'] ?? 'alipay'));
$result = $this->create_charge_order($user_id, $package_key, $custom_price, $payment_method);
$result = $this->create_charge_order($user_id, $package_key, $custom_price, $payment_method, $operation_key);
if (is_wp_error($result)) {
return $result;
}
@@ -123,8 +144,12 @@ trait BalanceServiceRest {
}
$type = sanitize_key((string) ($params['type'] ?? 'balance'));
$recipient = (int) ($params['recipient'] ?? 0);
$amount_raw = $params['amount'] ?? $params['price'] ?? 0;
$amount_raw = $params['amount'] ?? '';
$from_id = get_current_user_id();
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'transfer', $from_id);
if (is_wp_error($operation_key)) {
return $operation_key;
}
if (!in_array($type, ['balance', 'points'], true)) {
return new \WP_Error('aksrt_transfer_type', __('转账类型无效', 'aksrt'), ['status' => 400]);
@@ -147,45 +172,43 @@ trait BalanceServiceRest {
if (class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Settings\Capabilities::user_can('balance_transfer', $from_id)) {
return new \WP_Error('aksrt_transfer_forbidden', __('当前账号无权转账余额', 'aksrt'), ['status' => 403]);
}
$amount = round((float) $amount_raw, 2);
if ($amount <= 0) {
try {
$amount = \AKSRT\Ledger\AssetAmount::balance(is_string($amount_raw) ? $amount_raw : '');
} catch (\InvalidArgumentException $e) {
return new \WP_Error('aksrt_transfer_amount', __('请输入有效金额', 'aksrt'), ['status' => 400]);
}
if (!class_exists('AKSRT\\Credits\\Balance')) {
return new \WP_Error('aksrt_balance_unavailable', __('余额系统不可用', 'aksrt'), ['status' => 500]);
}
$from_bal = Balance::balance($from_id);
if ($from_bal < $amount) {
return new \WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), ['status' => 402]);
if ($amount->minor() <= 0) {
return new \WP_Error('aksrt_transfer_amount', __('请输入有效金额', 'aksrt'), ['status' => 400]);
}
$fee_rate = max(0, min(50, (int) ($cfg['transfer']['service_charge'] ?? 0)));
$credit = $fee_rate > 0 ? round($amount * (100 - $fee_rate) / 100, 2) : $amount;
$fee_minor = $this->percentage_minor($amount->minor(), $fee_rate);
$from_user = wp_get_current_user();
$to_user = get_userdata($recipient);
$deduct = Balance::deduct(
$result = \AKSRT\Ledger\LedgerService::get_instance()->transfer(
$from_id,
$amount,
'transfer_out',
['type' => 'user', 'id' => $recipient],
sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient)
);
if (is_wp_error($deduct)) {
return $deduct;
}
Balance::add(
$recipient,
$credit,
'transfer_in',
['type' => 'user', 'id' => $from_id],
sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id)
\AKSRT\Ledger\AssetType::BALANCE,
$amount->minor(),
$fee_minor,
$operation_key,
'transfer',
['type' => 'user', 'id' => $recipient],
sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient),
sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id),
['fee_rate' => $fee_rate]
);
if (is_wp_error($result)) {
return $result;
}
return rest_ensure_response([
'success' => true,
'data' => [
'message' => __('转账成功', 'aksrt'),
'reload' => true,
'balance' => Balance::balance($from_id),
'transaction_id' => $result->transaction_id,
'replayed' => $result->replayed,
'balance' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($from_id, \AKSRT\Ledger\AssetType::BALANCE))->decimal(),
],
]);
}
@@ -201,57 +224,42 @@ trait BalanceServiceRest {
if (class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Settings\Capabilities::user_can('points_transfer', $from_id)) {
return new \WP_Error('aksrt_transfer_forbidden', __('当前账号无权转账积分', 'aksrt'), ['status' => 403]);
}
if (filter_var($amount_raw, FILTER_VALIDATE_INT) === false || (int) $amount_raw <= 0) {
if (!is_int($amount_raw) || $amount_raw <= 0) {
return new \WP_Error('aksrt_transfer_amount', __('请输入有效积分', 'aksrt'), ['status' => 400]);
}
$amount = (int) $amount_raw;
$amount = $amount_raw;
$min_amount = max(1, (int) ($points_settings['points_transfer_min'] ?? 1));
if ($amount < $min_amount) {
return new \WP_Error('aksrt_transfer_minimum', sprintf(__('单笔转账至少需要 %d 积分', 'aksrt'), $min_amount), ['status' => 400]);
}
if (!class_exists('AKSRT\\Credits\\Credits')) {
return new \WP_Error('aksrt_credits_unavailable', __('积分系统不可用', 'aksrt'), ['status' => 500]);
}
$fee_rate = max(0, min(50, (int) ($points_settings['points_service_charge'] ?? 0)));
$credit = $fee_rate > 0 ? (int) floor($amount * (100 - $fee_rate) / 100) : $amount;
$fee = $this->percentage_minor($amount, $fee_rate);
$from_user = wp_get_current_user();
$to_user = get_userdata($recipient);
$deduct = Credits::deduct(
$result = \AKSRT\Ledger\LedgerService::get_instance()->transfer(
$from_id,
$amount,
'transfer_out',
['type' => 'user', 'id' => $recipient],
sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient)
);
if (is_wp_error($deduct)) {
return $deduct;
}
$add = Credits::add(
$recipient,
$credit,
'transfer_in',
['type' => 'user', 'id' => $from_id],
sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id)
\AKSRT\Ledger\AssetType::POINTS,
$amount,
$fee,
$operation_key,
'transfer',
['type' => 'user', 'id' => $recipient],
sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient),
sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id),
['fee_rate' => $fee_rate]
);
if (is_wp_error($add)) {
$refund = Credits::add(
$from_id,
$amount,
'transfer_refund',
['type' => 'user', 'id' => $recipient],
__('收款积分入账失败,已退回转账积分', 'aksrt')
);
if (is_wp_error($refund)) {
return new \WP_Error('aksrt_transfer_recovery_failed', __('收款积分入账失败,转账积分退回失败,请联系管理员', 'aksrt'), ['status' => 500]);
}
return $add;
if (is_wp_error($result)) {
return $result;
}
return rest_ensure_response([
'success' => true,
'data' => [
'message' => __('转账成功', 'aksrt'),
'reload' => true,
'balance' => Credits::balance($from_id),
'transaction_id' => $result->transaction_id,
'replayed' => $result->replayed,
'balance' => $result->balance($from_id, \AKSRT\Ledger\AssetType::POINTS),
],
]);
}
@@ -289,11 +297,11 @@ trait BalanceServiceRest {
* Exchange integer cash-wallet balance for points.
*/
public function rest_exchange_balance_for_points(\WP_REST_Request $request) {
if (!class_exists('AKSRT\\Credits\\Balance') || !Balance::enabled()) {
if (!$this->balance_feature_enabled()) {
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
}
if (!class_exists('AKSRT\\Credits\\Credits') || !class_exists('AKSRT\\Settings\\Settings') || !\AKSRT\Settings\Settings::credits_enabled()) {
return new \WP_Error('aksrt_credits_disabled', __('积分功能未启用', 'aksrt'), ['status' => 403]);
if (!class_exists('AKSRT\\Settings\\Settings') || !\AKSRT\Settings\Settings::credits_enabled()) {
return new \WP_Error('aksrt_points_disabled', __('积分功能未启用', 'aksrt'), ['status' => 403]);
}
$cfg = \AKSRT\Settings\Settings::get_payment_recharge_settings();
$exchange = is_array($cfg['points_exchange'] ?? null) ? $cfg['points_exchange'] : [];
@@ -304,121 +312,95 @@ trait BalanceServiceRest {
if (!is_array($params)) {
$params = $request->get_params();
}
$raw_amount = $params['amount'] ?? 0;
if (filter_var($raw_amount, FILTER_VALIDATE_INT) === false || (int) $raw_amount <= 0) {
return new \WP_Error('aksrt_exchange_amount', __('兑换余额必须为正整数', 'aksrt'), ['status' => 400]);
$raw_amount = $params['amount'] ?? '';
try {
$amount = \AKSRT\Ledger\AssetAmount::balance(is_string($raw_amount) ? $raw_amount : '');
} catch (\InvalidArgumentException $e) {
return new \WP_Error('aksrt_exchange_amount', __('兑换余额格式无效', 'aksrt'), ['status' => 400]);
}
if ($amount->minor() <= 0) {
return new \WP_Error('aksrt_exchange_amount', __('兑换余额必须大于零', 'aksrt'), ['status' => 400]);
}
$amount = (int) $raw_amount;
$rate = max(1, (int) ($exchange['rate'] ?? 100));
$daily_max = max(1, (int) ($exchange['daily_max_points'] ?? 100000));
if ($amount > intdiv($daily_max, $rate)) {
$scaled = $this->multiply_non_negative($amount->minor(), $rate);
if (is_wp_error($scaled)) {
return $scaled;
}
if ($scaled % 100 !== 0) {
return new \WP_Error('aksrt_exchange_precision', __('该金额无法兑换为整数积分', 'aksrt'), ['status' => 400]);
}
$points = intdiv($scaled, 100);
if ($points > $daily_max) {
return new \WP_Error('aksrt_exchange_daily_limit', sprintf(__('今日购买积分上限为 %d', 'aksrt'), $daily_max), ['status' => 400]);
}
$points = $amount * $rate;
$min_points = max(1, (int) ($exchange['min_points'] ?? 100));
if ($points < $min_points) {
return new \WP_Error('aksrt_exchange_min', sprintf(__('单笔至少购买 %d 积分', 'aksrt'), $min_points), ['status' => 400]);
}
$user_id = get_current_user_id();
$today_points = $this->today_points_exchange_total($user_id);
if ($today_points + $points > $daily_max) {
return new \WP_Error('aksrt_exchange_daily_limit', sprintf(__('今日购买积分上限为 %d,当前还可购买 %d', 'aksrt'), $daily_max, max(0, $daily_max - $today_points)), ['status' => 400]);
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'points-exchange', $user_id);
if (is_wp_error($operation_key)) {
return $operation_key;
}
try {
$exchange_ref = random_int(1, PHP_INT_MAX);
} catch (\Throwable $e) {
$exchange_ref = (int) round(microtime(true) * 1000000);
$site_now = current_datetime();
$start = (clone $site_now)->setTime(0, 0, 0);
$end = (clone $start)->modify('+1 day');
$utc = new \DateTimeZone('UTC');
$start_gmt = $start->setTimezone($utc)->format('Y-m-d H:i:s.u');
$end_gmt = $end->setTimezone($utc)->format('Y-m-d H:i:s.u');
$guard = static function () use ($user_id, $points, $daily_max, $start_gmt, $end_gmt) {
$used = \AKSRT\Ledger\LedgerQuery::get_instance()->sum_credits($user_id, 'points_exchange', $start_gmt, $end_gmt);
if ($used + $points > $daily_max) {
return new \WP_Error('aksrt_exchange_daily_limit', sprintf(__('今日购买积分上限为 %d,当前还可购买 %d', 'aksrt'), $daily_max, max(0, $daily_max - $used)), ['status' => 400]);
}
return true;
};
$business_mutation = static function () use ($user_id, $amount, $points, $operation_key) {
if (class_exists('\\AKSRT\\Payment\\CommissionLedger')) {
return \AKSRT\Payment\CommissionLedger::get_instance()->record_points_exchange($user_id, $amount->decimal(), $points, $operation_key);
}
return true;
};
$result = \AKSRT\Ledger\LedgerService::get_instance()->exchange(
$user_id,
$amount->minor(),
$points,
$operation_key,
'points_exchange',
['type' => 'points_exchange', 'key' => $operation_key],
sprintf(__('余额兑换 %d 积分', 'aksrt'), $points),
['rate' => $rate],
$guard,
$business_mutation
);
if (is_wp_error($result)) {
return $result;
}
$ref = ['type' => 'points_exchange', 'id' => $exchange_ref];
$deduct = Balance::deduct($user_id, (float) $amount, 'points_exchange_out', $ref, sprintf(__('余额兑换 %d 积分', 'aksrt'), $points));
if (is_wp_error($deduct)) return $deduct;
$add = Credits::add($user_id, $points, 'points_exchange_in', $ref, sprintf(__('余额兑换:扣除 %d 余额', 'aksrt'), $amount));
if (is_wp_error($add)) {
$refund = Balance::add($user_id, (float) $amount, 'points_exchange_refund', $ref, __('积分入账失败,已退回兑换余额', 'aksrt'));
if (is_wp_error($refund)) return new \WP_Error('aksrt_exchange_recovery_failed', __('积分入账失败,余额退回失败,请联系管理员', 'aksrt'), ['status' => 500]);
return $add;
}
do_action('aksrt_points_exchange_completed', $user_id, (float) $amount, $points, (string) $exchange_ref);
return rest_ensure_response(['success' => true, 'data' => [
'message' => sprintf(__('兑换成功,获得 %d 积分', 'aksrt'), $points),
'wallet_balance' => Balance::balance($user_id),
'credit_balance' => Credits::balance($user_id),
'transaction_id' => $result->transaction_id,
'replayed' => $result->replayed,
'wallet_balance' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($user_id, \AKSRT\Ledger\AssetType::BALANCE))->decimal(),
'credit_balance' => $result->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
'points' => $points,
'reload' => true,
]]);
}
private function today_points_exchange_total(int $user_id): int {
if ($user_id <= 0 || !class_exists('AKSRT\\Core\\DBMigrator')) return 0;
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('credit_log');
if ($table === '') return 0;
$site_now = current_datetime();
$start = (clone $site_now)->setTime(0, 0, 0);
$end = (clone $start)->modify('+1 day');
$utc = new \DateTimeZone('UTC');
$sql = "SELECT COALESCE(SUM(delta), 0) FROM {$table} WHERE user_id = %d AND reason = %s AND created_at >= %s AND created_at < %s";
$start_gmt = $start->setTimezone($utc)->format('Y-m-d H:i:s');
$end_gmt = $end->setTimezone($utc)->format('Y-m-d H:i:s');
return max(0, (int) $wpdb->get_var($wpdb->prepare($sql, $user_id, 'points_exchange_in', $start_gmt, $end_gmt)));
private function percentage_minor(int $amount, int $rate): int {
if ($amount <= 0 || $rate <= 0) {
return 0;
}
return (intdiv($amount, 100) * $rate) + intdiv(($amount % 100) * $rate, 100);
}
public function rest_balance_withdraw(\WP_REST_Request $request) {
if (!class_exists('AKSRT\\Credits\\Balance') || !Balance::enabled()) {
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
/** @return int|\WP_Error */
private function multiply_non_negative(int $left, int $right) {
if ($left < 0 || $right < 0 || ($right > 0 && $left > intdiv(PHP_INT_MAX, $right))) {
return new \WP_Error('aksrt_ledger_overflow', __('资产金额超出可用范围', 'aksrt'), ['status' => 400]);
}
$settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
if (empty($settings['pay_balance_withdraw_s'])) {
return new \WP_Error('aksrt_withdraw_closed', __('余额提现未开启', 'aksrt'), ['status' => 403]);
}
if (!class_exists('\AKSRT\Payment\Withdrawal')) {
return new \WP_Error('aksrt_withdraw_unavailable', __('提现系统不可用', 'aksrt'), ['status' => 500]);
}
$params = $request->get_json_params();
if (!is_array($params)) {
$params = $request->get_params();
}
$user_id = get_current_user_id();
if (class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Settings\Capabilities::user_can('balance_withdraw', $user_id)) {
return new \WP_Error('aksrt_withdraw_forbidden', __('当前账号无权提现', 'aksrt'), ['status' => 403]);
}
$raw_amount = $params['amount'] ?? 0;
if (filter_var($raw_amount, FILTER_VALIDATE_INT) === false || (int) $raw_amount <= 0) {
return new \WP_Error('aksrt_withdraw_amount', __('提现金额必须为正整数', 'aksrt'), ['status' => 400]);
}
$amount = (int) $raw_amount;
$method = sanitize_key((string) ($params['method'] ?? 'alipay'));
$method = $method !== '' ? $method : 'alipay';
$account = sanitize_textarea_field((string) ($params['account_info'] ?? $params['account'] ?? ''));
$withdraw_settings = class_exists('\\AKSRT\\Settings\\Settings')
? \AKSRT\Settings\Settings::get_withdrawal_settings()
: ['min_amount' => 50, 'service_charge_rate' => 5, 'daily_count' => 3, 'daily_limit' => 50, 'reset_time' => '00:00'];
$lowest = (float) $withdraw_settings['min_amount'];
if ($amount < $lowest) {
return new \WP_Error('withdraw_min_amount', sprintf(__('提现金额低于最低提现门槛(%s 元)', 'aksrt'), (string) $lowest), ['status' => 400]);
}
if ($account === '') {
return new \WP_Error('aksrt_withdraw_account', __('请填写收款账号信息', 'aksrt'), ['status' => 400]);
}
if (!in_array($method, (array) ($withdraw_settings['payout_channels'] ?? []), true)) {
return new \WP_Error('aksrt_withdraw_channel', __('当前收款渠道不可用', 'aksrt'), ['status' => 400]);
}
$result = \AKSRT\Payment\Withdrawal::get_instance()->create_balance_request($user_id, $amount, $method, $account, $withdraw_settings);
if (is_wp_error($result)) {
return $result;
}
return rest_ensure_response([
'success' => true,
'data' => [
'message' => __('提现申请已提交,请等待处理', 'aksrt'),
'withdrawal_id' => $result['withdrawal_id'],
'amount' => $amount,
'net_amount' => $result['net_amount'],
'wallet_balance' => $result['wallet_balance'],
'remaining_count' => $result['usage']['remaining_count'],
'remaining_amount' => $result['usage']['remaining_amount'],
],
]);
return $left * $right;
}
}
-305
View File
@@ -1,305 +0,0 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Credits;
defined('ABSPATH') || exit;
class Credits {
/** user_meta 键名 */
const META_KEY = 'aksrt_credits';
/**
* 取用户余额。
*/
public static function balance(int $user_id): int {
if ($user_id <= 0) return 0;
$val = get_user_meta($user_id, self::META_KEY, true);
return is_numeric($val) ? (int) $val : 0;
}
/**
* Backward-compatible alias for credit spending.
*/
public static function get_balance(int $user_id): int {
return self::balance($user_id);
}
/**
* 增加积分。
*
* @param int $user_id
* @param int $delta 必须 > 0
* @param string $reason 例 register/signin/comment/referral/admin
* @param array $ref ['type' => 'post|comment|user|referral', 'id' => int]
* @return int|\WP_Error 新余额或错误
*/
public static function add(int $user_id, int $delta, string $reason, array $ref = [], string $remark = '', bool $dispatch_hooks = true) {
if ($user_id <= 0) return new \WP_Error('aksrt_credits_user', __('用户无效', 'aksrt'));
if ($delta <= 0) return new \WP_Error('aksrt_credits_delta', __('增加积分必须为正数', 'aksrt'));
$new_balance = self::atomic_update($user_id, $delta, false);
if ($new_balance === null) {
return new \WP_Error('aksrt_credits_db', __('积分写入失败', 'aksrt'));
}
self::log($user_id, $delta, $new_balance, $reason, $ref, $remark);
if ($dispatch_hooks) {
do_action('aksrt_credits_added', $user_id, $delta, $reason, $new_balance);
}
return $new_balance;
}
/**
* 扣减积分。
*
* @return int|\WP_Error 新余额或错误
*/
public static function deduct(int $user_id, int $cost, string $reason, array $ref = [], string $remark = '') {
if ($user_id <= 0) return new \WP_Error('aksrt_credits_user', __('用户无效', 'aksrt'));
if ($cost < 0) return new \WP_Error('aksrt_credits_delta', __('扣减积分必须为非负数', 'aksrt'));
if ($cost === 0) {
return self::balance($user_id);
}
$new_balance = self::atomic_update($user_id, $cost, true);
if ($new_balance === null) {
$current = self::balance($user_id);
return new \WP_Error('aksrt_credits_insufficient', __('积分不足', 'aksrt'), [
'balance' => $current,
'cost' => $cost,
]);
}
self::log($user_id, -$cost, $new_balance, $reason, $ref, $remark);
do_action('aksrt_credits_deducted', $user_id, $cost, $reason, $new_balance);
return $new_balance;
}
/**
* 原子加减:用单条 SQL UPDATE 修改 user_meta,避免 read-modify-write 竞态。
*
* @param int $user_id
* @param int $abs_delta 绝对值
* @param bool $is_deduct true=扣减(带余额检查),false=增加
* @return int|null 新余额;失败(余额不足或 DB 错误)返回 null
*/
private static function atomic_update(int $user_id, int $abs_delta, bool $is_deduct): ?int {
global $wpdb;
// 确保 meta 行存在(首次扣费可能没有 row)
$existing = get_user_meta($user_id, self::META_KEY, true);
if ($existing === '' || $existing === false || $existing === null) {
update_user_meta($user_id, self::META_KEY, 0);
}
$meta_key = self::META_KEY;
if ($is_deduct) {
// 扣减:仅当 meta_value >= cost 时才更新
$sql = $wpdb->prepare(
"UPDATE {$wpdb->usermeta}
SET meta_value = CAST(meta_value AS SIGNED) - %d
WHERE user_id = %d
AND meta_key = %s
AND CAST(meta_value AS SIGNED) >= %d",
$abs_delta,
$user_id,
$meta_key,
$abs_delta
);
} else {
$sql = $wpdb->prepare(
"UPDATE {$wpdb->usermeta}
SET meta_value = CAST(meta_value AS SIGNED) + %d
WHERE user_id = %d
AND meta_key = %s",
$abs_delta,
$user_id,
$meta_key
);
}
$affected = $wpdb->query($sql);
if ($affected === false || $affected === 0) {
return null;
}
// 失效 user meta cache,下次 get_user_meta 会重读
wp_cache_delete($user_id, 'user_meta');
$new = (int) get_user_meta($user_id, self::META_KEY, true);
return $new;
}
/**
* 写一条流水。
*
* @param int $user_id
* @param int $delta 本次变动(正/负/0)
* @param int $balance_after 操作后余额
* @param string $reason
* @param array $ref
*/
public static function log(int $user_id, int $delta, int $balance_after, string $reason, array $ref = [], string $remark = ''): void {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('credit_log');
if (!$table) return;
$ref_type = isset($ref['type']) ? sanitize_key((string) $ref['type']) : null;
$ref_id = isset($ref['id']) ? (int) $ref['id'] : null;
$wpdb->insert(
$table,
[
'user_id' => $user_id,
'delta' => $delta,
'balance_after' => $balance_after,
'reason' => substr($reason, 0, 64),
'remark' => $remark !== '' ? substr($remark, 0, 255) : null,
'ref_type' => $ref_type,
'ref_id' => $ref_id,
'ip' => self::client_ip(),
'ua' => self::client_ua(),
'created_at' => current_time('mysql', true),
],
['%d', '%d', '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s']
);
}
/**
* 取流水(管理员页面用)。
*
* @param array $args { user_id, reason, page, per_page, date_from, date_to }
* @return array { items, total, page, per_page }
*/
public static function get_log(array $args = []): array {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('credit_log');
if (!$table) {
return ['items' => [], 'total' => 0, 'page' => 1, 'per_page' => 20];
}
$page = max(1, (int) ($args['page'] ?? 1));
$per_page = min(100, max(1, (int) ($args['per_page'] ?? 20)));
$offset = ($page - 1) * $per_page;
$where = ['1=1'];
$params = [];
if (!empty($args['user_id'])) {
$where[] = 'user_id = %d';
$params[] = (int) $args['user_id'];
}
if (!empty($args['reason'])) {
$where[] = 'reason = %s';
$params[] = sanitize_key((string) $args['reason']);
}
if (!empty($args['date_from'])) {
$where[] = 'created_at >= %s';
$params[] = (string) $args['date_from'];
}
if (!empty($args['date_to'])) {
$where[] = 'created_at <= %s';
$params[] = (string) $args['date_to'];
}
$where_sql = implode(' AND ', $where);
// total
$count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
$total = (int) ($params
? $wpdb->get_var($wpdb->prepare($count_sql, $params))
: $wpdb->get_var($count_sql));
// items
$items_sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d";
$items_params = array_merge($params, [$per_page, $offset]);
$items = $wpdb->get_results($wpdb->prepare($items_sql, $items_params), ARRAY_A);
if (!is_array($items)) $items = [];
// user 名字预填
$uid_set = array_unique(array_map(fn($r) => (int) $r['user_id'], $items));
$names = [];
foreach ($uid_set as $uid) {
$u = get_userdata($uid);
if ($u) $names[$uid] = $u->display_name;
}
foreach ($items as &$row) {
$row['user_display_name'] = $names[(int) $row['user_id']] ?? '#' . $row['user_id'];
$row['delta'] = (int) $row['delta'];
$row['balance_after'] = (int) $row['balance_after'];
}
return [
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $per_page,
];
}
/**
* 查询用户余额列表(管理员)。
*
* @param array $args { search, page, per_page }
* @return array
*/
public static function list_users(array $args = []): array {
$page = max(1, (int) ($args['page'] ?? 1));
$per_page = min(100, max(1, (int) ($args['per_page'] ?? 20)));
$q = [
'number' => $per_page,
'paged' => $page,
'orderby' => 'registered',
'order' => 'DESC',
];
if (!empty($args['search'])) {
$q['search'] = '*' . esc_attr((string) $args['search']) . '*';
$q['search_columns'] = ['user_login', 'user_email', 'display_name', 'user_nicename'];
}
$user_query = new \WP_User_Query($q);
$users = $user_query->get_results();
$items = [];
foreach ($users as $u) {
$items[] = [
'id' => (int) $u->ID,
'login' => $u->user_login,
'email' => $u->user_email,
'display_name' => $u->display_name,
'balance' => self::balance((int) $u->ID),
'registered' => $u->user_registered,
];
}
return [
'items' => $items,
'total' => (int) $user_query->get_total(),
'page' => $page,
'per_page' => $per_page,
];
}
// ---------- 工具方法 ----------
private static function client_ip(): string {
if (function_exists('aksrt_get_client_ip')) {
return (string) aksrt_get_client_ip();
}
if (!empty($_SERVER['REMOTE_ADDR'])) {
return (string) $_SERVER['REMOTE_ADDR'];
}
return '';
}
private static function client_ua(): string {
$ua = isset($_SERVER['HTTP_USER_AGENT']) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
return substr($ua, 0, 255);
}
}
+18 -46
View File
@@ -40,17 +40,10 @@ class PointsFree {
*/
public static function settings(): array {
$all = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
$credit = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_credit_settings() : [];
$tasks = [];
foreach (self::catalog() as $key => $meta) {
$flat_key = 'points_free_' . $key;
if (array_key_exists($flat_key, $all)) {
$tasks[$key] = max(0, (int) $all[$flat_key]);
continue;
}
// Fallback mapping from legacy / parallel settings.
$tasks[$key] = self::legacy_task_points($key, $all, $credit, (int) $meta[2]);
$tasks[$key] = max(0, (int) ($all[$flat_key] ?? $meta[2]));
}
$day_max = array_key_exists('points_earn_daily_limit', $all)
@@ -75,7 +68,7 @@ class PointsFree {
* Whether free points system can run (credits enabled).
*/
public static function enabled(): bool {
return class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::credits_enabled() && class_exists('AKSRT\\Credits\\Credits');
return class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::credits_enabled();
}
/**
@@ -143,19 +136,31 @@ class PointsFree {
}
$reason = 'free_' . $task_key;
$result = Credits::add(
$today = self::today_key();
$operation_key = \AKSRT\Ledger\OperationKey::deterministic('points-free', [$user_id, $task_key, $today]);
$result = \AKSRT\Ledger\LedgerService::get_instance()->credit(
$user_id,
\AKSRT\Ledger\AssetType::POINTS,
$points,
$operation_key,
$reason,
$ref,
self::catalog()[$task_key][0] ?? $task_key
self::catalog()[$task_key][0] ?? $task_key,
0,
['task' => $task_key, 'date' => $today],
static function () use ($user_id, $points, $task_key) {
if (self::remaining_today($user_id) < $points) {
return new \WP_Error('aksrt_points_daily_limit', __('今日积分奖励已达上限', 'aksrt'), ['status' => 409]);
}
self::bump_detail($user_id, $points);
self::mark_task_done_today($user_id, $task_key);
return true;
}
);
if (is_wp_error($result)) {
return $result;
}
self::bump_detail($user_id, $points);
self::mark_task_done_today($user_id, $task_key);
return $points;
}
@@ -297,37 +302,4 @@ class PointsFree {
update_user_meta($user_id, self::META_DETAIL, $detail);
}
/**
* @param array<string,mixed> $all
* @param array<string,mixed> $credit
*/
private static function legacy_task_points(string $key, array $all, array $credit, int $default): int {
switch ($key) {
case 'sign_up':
return max(0, (int) ($credit['register_bonus'] ?? $default));
case 'sign_in':
$signin = is_array($credit['daily_signin'] ?? null) ? $credit['daily_signin'] : [];
if (isset($signin['enabled']) && empty($signin['enabled'])) {
return 0;
}
return max(0, (int) ($signin['base'] ?? $default));
case 'login':
return max(0, (int) ($all['points_earn_login'] ?? $default));
case 'post_new':
return max(0, (int) ($all['points_earn_post'] ?? $default));
case 'post_like':
return max(0, (int) ($all['points_earn_like_received'] ?? $default));
case 'comment_new':
if (isset($all['points_earn_comment'])) {
return max(0, (int) $all['points_earn_comment']);
}
$cr = is_array($credit['comment_reward'] ?? null) ? $credit['comment_reward'] : [];
if (isset($cr['enabled']) && empty($cr['enabled'])) {
return 0;
}
return max(0, (int) ($cr['amount'] ?? $default));
default:
return $default;
}
}
}
+118 -64
View File
@@ -27,33 +27,36 @@ class Signin {
register_rest_route('aksrt/v1', '/signin/today', [
'methods' => 'GET',
'callback' => [$this, 'rest_today'],
'permission_callback' => function () {
return is_user_logged_in();
},
'permission_callback' => [$this, 'check_rest_permission'],
]);
register_rest_route('aksrt/v1', '/signin', [
'methods' => 'POST',
'callback' => [$this, 'rest_signin'],
'permission_callback' => function () {
return is_user_logged_in();
},
'permission_callback' => [$this, 'check_rest_permission'],
]);
register_rest_route('aksrt/v1', '/signin/history', [
'methods' => 'GET',
'callback' => [$this, 'rest_history'],
'permission_callback' => function () {
return is_user_logged_in();
},
'permission_callback' => [$this, 'check_rest_permission'],
]);
register_rest_route('aksrt/v1', '/signin/details', [
'methods' => 'GET',
'callback' => [$this, 'rest_details_html'],
'permission_callback' => function () {
return is_user_logged_in();
},
'permission_callback' => [$this, 'check_rest_permission'],
]);
}
public function check_rest_permission(\WP_REST_Request $request) {
if (!is_user_logged_in()) {
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
}
$nonce = (string) $request->get_header('X-WP-Nonce');
if ($nonce === '' || !wp_verify_nonce($nonce, 'wp_rest')) {
return new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
}
return true;
}
/** GET /signin/today */
public function rest_today(\WP_REST_Request $request) {
$user_id = get_current_user_id();
@@ -99,10 +102,6 @@ class Signin {
}
$today_str = $this->today_date();
if ($this->get_record($user_id, $today_str)) {
return new \WP_Error('aksrt_signin_dup', __('今日已签到', 'aksrt'), ['status' => 409]);
}
// 人机验证(若启用)
if (function_exists('aksrt_require_captcha')) {
$body = $request->get_json_params();
@@ -116,59 +115,75 @@ class Signin {
}
}
$current_streak = $this->compute_current_streak($user_id, $today_str);
$new_streak = $current_streak + 1;
$reward = $this->calc_reward($new_streak, $signin_cfg);
$existing_record = $this->get_record($user_id, $today_str);
if (is_array($existing_record)) {
$new_streak = max(1, (int) ($existing_record['streak'] ?? 1));
$reward = max(0, (int) ($existing_record['reward'] ?? 0));
} else {
$current_streak = $this->compute_current_streak($user_id, $today_str);
$new_streak = $current_streak + 1;
$reward = $this->calc_reward($new_streak, $signin_cfg);
}
$table = \AKSRT\Core\DBMigrator::table('signin_log');
if (!$table) {
return new \WP_Error('aksrt_signin_db', __('数据表不存在', 'aksrt'), ['status' => 500]);
}
$inserted = $wpdb->insert(
$table,
[
'user_id' => $user_id,
'signin_date' => $today_str,
'streak' => $new_streak,
'reward' => $reward,
],
['%d', '%s', '%d', '%d']
);
if ($inserted === false) {
return new \WP_Error('aksrt_signin_dup', __('签到失败', 'aksrt'), ['status' => 409]);
}
$operation_key = \AKSRT\Ledger\OperationKey::deterministic('signin', [$user_id, $today_str]);
$transaction_id = null;
$replayed = false;
if ($reward > 0) {
$guard = static function () use ($wpdb, $table, $user_id, $today_str) {
$existing = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$table} WHERE user_id = %d AND signin_date = %s FOR UPDATE",
$user_id,
$today_str
));
return $existing === null
? true
: new \WP_Error('aksrt_signin_dup', __('今日已签到', 'aksrt'), ['status' => 409]);
};
$mutation = static function () use ($wpdb, $table, $user_id, $today_str, $new_streak, $reward) {
$inserted = $wpdb->insert($table, [
'user_id' => $user_id,
'signin_date' => $today_str,
'streak' => $new_streak,
'reward' => $reward,
], ['%d', '%s', '%d', '%d']);
return $inserted === 1
? true
: new \WP_Error('aksrt_signin_db', __('签到记录写入失败', 'aksrt'), ['status' => 500]);
};
$ledger_result = \AKSRT\Ledger\LedgerService::get_instance()->credit(
$user_id,
\AKSRT\Ledger\AssetType::POINTS,
$reward,
$operation_key,
'signin',
['type' => 'signin', 'key' => $today_str],
__('每日签到奖励', 'aksrt'),
$user_id,
['signin_date' => $today_str, 'streak' => $new_streak],
$mutation,
$guard
);
if (is_wp_error($ledger_result)) {
return $ledger_result;
}
$transaction_id = $ledger_result->transaction_id;
$replayed = $ledger_result->replayed;
} else {
$stored = $this->store_zero_reward_signin($user_id, $today_str, $new_streak);
if (is_wp_error($stored)) {
return $stored;
}
$replayed = $stored;
}
do_action('aksrt_user_signed_in', $user_id, $reward);
if ($reward > 0) {
if (class_exists('AKSRT\\Credits\\PointsFree')) {
$granted = PointsFree::grant_amount($user_id, $reward, 'sign_in', ['type' => 'user', 'id' => $user_id]);
if (is_wp_error($granted)) {
if (class_exists('AKSRT\\Credits\\Credits')) {
Credits::add($user_id, $reward, 'signin', ['type' => 'user', 'id' => $user_id]);
}
} else {
$reward = (int) $granted;
}
} elseif (class_exists('AKSRT\\Credits\\Credits')) {
Credits::add($user_id, $reward, 'signin', ['type' => 'user', 'id' => $user_id]);
}
}
// Keep lightweight meta for header/badge consumers (legacy checkin_all_day).
update_user_meta($user_id, 'aksrt_signin_' . $today_str, 1);
$all_day = (int) get_user_meta($user_id, 'aksrt_checkin_all_day', true);
if ($all_day <= 0) {
// Fallback to legacy key if migrated users only have that meta.
$all_day = (int) get_user_meta($user_id, 'checkin_all_day', true);
}
$all_day = $all_day + 1;
update_user_meta($user_id, 'aksrt_checkin_all_day', $all_day);
update_user_meta($user_id, 'checkin_all_day', $all_day); // legacy alias
update_user_meta($user_id, 'aksrt_checkin_continuous_day', $new_streak);
update_user_meta($user_id, 'checkin_continuous_day', $new_streak); // legacy alias
do_action('aksrt_checkin_done', $user_id);
if (!$replayed) {
$this->after_signin_committed($user_id, $today_str, $new_streak, $reward);
}
return new \WP_REST_Response([
'success' => true,
@@ -180,10 +195,49 @@ class Signin {
),
'streak' => $new_streak,
'reward' => $reward,
'balance' => class_exists('AKSRT\Credits\Credits') ? Credits::balance($user_id) : 0,
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
'transaction_id' => $transaction_id,
'replayed' => $replayed,
], 200);
}
/** @return bool|\WP_Error True means the existing row was replayed. */
private function store_zero_reward_signin(int $user_id, string $date, int $streak) {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('signin_log');
if ($wpdb->query('START TRANSACTION') === false) {
return new \WP_Error('aksrt_signin_db', __('无法开启签到事务', 'aksrt'), ['status' => 500]);
}
$existing = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$table} WHERE user_id = %d AND signin_date = %s FOR UPDATE",
$user_id,
$date
));
if ($existing !== null) {
$wpdb->query('COMMIT');
return true;
}
$inserted = $wpdb->insert($table, [
'user_id' => $user_id,
'signin_date' => $date,
'streak' => $streak,
'reward' => 0,
], ['%d', '%s', '%d', '%d']);
if ($inserted !== 1 || $wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_signin_db', __('签到记录写入失败', 'aksrt'), ['status' => 500]);
}
return false;
}
private function after_signin_committed(int $user_id, string $date, int $streak, int $reward): void {
update_user_meta($user_id, 'aksrt_signin_' . $date, 1);
update_user_meta($user_id, 'aksrt_checkin_all_day', $this->get_total_days($user_id));
update_user_meta($user_id, 'aksrt_checkin_continuous_day', $streak);
do_action('aksrt_user_signed_in', $user_id, $reward);
do_action('aksrt_checkin_done', $user_id);
}
/** GET /signin/history — last N days for calendar/modals. */
public function rest_history(\WP_REST_Request $request) {
$user_id = get_current_user_id();
@@ -260,7 +314,7 @@ class Signin {
$rec = $this->get_record($user_id, $today);
$streak = $this->compute_current_streak($user_id, $today);
$total = $this->get_total_days($user_id);
$balance = class_exists('\AKSRT\Credits\Credits') ? (int) Credits::balance($user_id) : 0;
$balance = \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS);
$currency = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_currency_name()
: __('积分', 'aksrt');