积分与余额账务系统硬切重构
This commit is contained in:
@@ -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,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user