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

This commit is contained in:
2026-08-24 15:59:11 +08:00
parent ca12226b39
commit 5cae5b5c93
159 changed files with 7275 additions and 4718 deletions
+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,
];