新增全局流水号

This commit is contained in:
2026-08-25 05:33:01 +08:00
parent 90fd59b647
commit 07c4e969c3
33 changed files with 1176 additions and 73 deletions
+2
View File
@@ -112,6 +112,7 @@ class BalanceGateway implements GatewayInterface {
'success' => true,
'transaction_id' => $transaction_code,
'ledger_transaction_id' => $result->transaction_id,
'flow_number' => $result->flow_number,
'replayed' => $result->replayed,
'order_committed' => true,
'message' => __('余额支付成功', 'aksrt'),
@@ -267,6 +268,7 @@ class BalanceGateway implements GatewayInterface {
'refund_id' => 'BAL_REF_' . $order_number,
'transaction_id' => $result->transaction_id,
'ledger_transaction_id' => $result->transaction_id,
'flow_number' => $result->flow_number,
'replayed' => $result->replayed,
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'message' => __('退款成功', 'aksrt'),
+7 -3
View File
@@ -376,20 +376,24 @@ trait OrderRepositoryAdmin {
}
if ($search !== '') {
// Numeric search may mean user_id or product_id; also match order number / type.
$flow_table = \AKSRT\Core\DBMigrator::table('financial_flow');
$flow_match = "EXISTS (SELECT 1 FROM {$flow_table} ff WHERE ff.order_number = " . $this->table() . '.order_number AND ff.flow_number LIKE %s)';
// Numeric search may mean user_id or product_id; also match order or flow number / type.
if (ctype_digit($search)) {
$where[] = '(order_number LIKE %s OR product_type LIKE %s OR user_id = %d OR product_id = %d)';
$where[] = "(order_number LIKE %s OR product_type LIKE %s OR user_id = %d OR product_id = %d OR {$flow_match})";
$like = '%' . $wpdb->esc_like($search) . '%';
$params[] = $like;
$params[] = $like;
$params[] = (int) $search;
$params[] = (int) $search;
$params[] = $like;
} else {
$where[] = '(order_number LIKE %s OR product_type LIKE %s OR payment_method LIKE %s)';
$where[] = "(order_number LIKE %s OR product_type LIKE %s OR payment_method LIKE %s OR {$flow_match})";
$like = '%' . $wpdb->esc_like($search) . '%';
$params[] = $like;
$params[] = $like;
$params[] = $like;
$params[] = $like;
}
}
+21 -1
View File
@@ -368,8 +368,10 @@ trait OrderRepositoryCore {
}
}
private function update_status_in_transaction(string $order_number, string $new_status, array $extra = []): bool {
private function update_status_in_transaction(string $order_number, string $new_status, array $extra = []): bool {
global $wpdb;
$ledger_active = class_exists('\AKSRT\Ledger\LedgerService')
&& \AKSRT\Ledger\LedgerService::get_instance()->transaction_active();
$valid = \AKSRT\Core\DomainModel::order_status_labels();
if (!isset($valid[$new_status])) return false;
@@ -499,6 +501,13 @@ trait OrderRepositoryCore {
}
}
if (!$ledger_active) {
$flow_number = \AKSRT\Finance\FinancialFlowService::get_instance()->record_order_payment($paid_order, true);
if (is_wp_error($flow_number)) {
return false;
}
}
if (class_exists('\AKSRT\Payment\CommissionLedger')) {
$commission = \AKSRT\Payment\CommissionLedger::get_instance()->record_order_transition(
$order_number,
@@ -535,6 +544,17 @@ trait OrderRepositoryCore {
'args' => [$order_number, $paid_order],
];
}
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED
&& $current_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING
&& !$ledger_active
) {
$refunded_order = array_merge($order, $update_data, ['status' => $new_status]);
$flow_number = \AKSRT\Finance\FinancialFlowService::get_instance()->record_order_refund($refunded_order, true);
if (is_wp_error($flow_number)) {
return false;
}
}
}
return $result !== false;
+1
View File
@@ -131,6 +131,7 @@ class PaymentOrchestrator {
'redirect' => false,
'transaction_id' => $transaction_id,
'ledger_transaction_id' => (int) ($result['ledger_transaction_id'] ?? 0),
'flow_number' => (string) ($result['flow_number'] ?? ''),
'replayed' => !empty($result['replayed']),
'message' => (string) ($result['message'] ?? __('支付成功。', 'aksrt')),
];
+5
View File
@@ -226,6 +226,9 @@ class UserOrders {
])),
];
if ($detail) {
$view['flow_number'] = $order_number !== '' && $group !== OrderCenterQuery::STATUS_PENDING
? \AKSRT\Finance\FinancialFlowQuery::get_instance()->order_payment_number($order_number)
: '';
$view['paid_at'] = (string) ($order['paid_at'] ?? '');
$view['expires_at'] = (string) ($order['expires_at'] ?? '');
$view['reconciliation'] = $reconciliation ?: ['state' => 'not_needed', 'required' => false];
@@ -388,6 +391,7 @@ class UserOrders {
'order_number' => $order_number,
'order' => $this->get_order_detail_view($user_id, $order_number),
'transaction_id' => (int) $history['items'][0]['id'],
'flow_number' => \AKSRT\Finance\FinancialFlowQuery::get_instance()->order_payment_number($order_number),
'replayed' => true,
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'message' => __('支付成功。', 'aksrt'),
@@ -424,6 +428,7 @@ class UserOrders {
'order_number' => $order_number,
'order' => empty($result['redirect']) ? $this->get_order_detail_view($user_id, $order_number) : null,
'transaction_id' => (int) ($result['ledger_transaction_id'] ?? 0),
'flow_number' => (string) ($result['flow_number'] ?? ''),
'replayed' => !empty($result['replayed']),
'balance' => $payment_method === 'balance' ? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id) : null,
'message' => (string) ($result['message'] ?? ''),
+96 -31
View File
@@ -99,7 +99,7 @@ class Withdrawal {
/**
* Atomically validates quotas, freezes wallet funds, writes the ledger and creates an order.
* @return array{withdrawal_id:int,net_amount:string,wallet_balance:string,transaction_id:int,replayed:bool,usage:array}|\WP_Error
* @return array{withdrawal_id:int,withdrawal_number:string,net_amount:string,wallet_balance:string,transaction_id:int,flow_number:string,replayed:bool,usage:array}|\WP_Error
*/
public function create_balance_request(int $user_id, int $gross_minor, string $method, string $account, array $settings, string $operation_key) {
global $wpdb;
@@ -115,6 +115,7 @@ class Withdrawal {
$window = $this->daily_window((string) ($settings['reset_time'] ?? '00:00'));
$usage = null;
$withdrawal_id = 0;
$withdrawal_number = '';
$guard = function () use ($user_id, $gross_minor, $settings, $window, &$usage) {
global $wpdb;
$wpdb->get_results($wpdb->prepare(
@@ -137,7 +138,7 @@ class Withdrawal {
}
return true;
};
$mutation = function (int $transaction_id) use ($user_id, $gross_minor, $net_minor, $fee_rate, $method, $account, $operation_key, &$withdrawal_id) {
$mutation = function (int $transaction_id) use ($user_id, $gross_minor, $net_minor, $fee_rate, $method, $account, $operation_key, &$withdrawal_id, &$withdrawal_number) {
global $wpdb;
$payload = wp_json_encode([
'account' => $account,
@@ -146,16 +147,26 @@ class Withdrawal {
'source' => 'balance',
'operation_key' => $operation_key,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $account;
$ok = $wpdb->insert($this->table(), [
'user_id' => $user_id,
'amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $net_minor)->decimal(),
'gross_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $gross_minor)->decimal(),
'method' => sanitize_key($method),
'account_info' => $payload,
'status' => self::STATUS_PENDING,
'ledger_transaction_id' => $transaction_id,
'created_at' => current_time('mysql'),
], ['%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s']);
$order_settings = \AKSRT\Settings\Settings::get_order_settings();
$prefix = \AKSRT\Finance\BusinessNumber::normalize_prefix($order_settings['withdrawal_prefix'] ?? 'TX', 'TX');
$ok = false;
for ($attempt = 1; $attempt <= 5; $attempt++) {
$withdrawal_number = \AKSRT\Finance\BusinessNumber::candidate($prefix);
$ok = $wpdb->insert($this->table(), [
'withdrawal_number' => $withdrawal_number,
'user_id' => $user_id,
'amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $net_minor)->decimal(),
'gross_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $gross_minor)->decimal(),
'method' => sanitize_key($method),
'account_info' => $payload,
'status' => self::STATUS_PENDING,
'ledger_transaction_id' => $transaction_id,
'created_at' => current_time('mysql'),
], ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s']);
if ($ok === 1) {
break;
}
}
if ($ok !== 1) {
return new \WP_Error('aksrt_withdraw_failed', __('提现申请失败', 'aksrt'), ['status' => 500]);
}
@@ -179,10 +190,12 @@ class Withdrawal {
return $result;
}
if ($withdrawal_id <= 0) {
$withdrawal_id = (int) $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$this->table()} WHERE ledger_transaction_id = %d",
$replayed_row = $wpdb->get_row($wpdb->prepare(
"SELECT id, withdrawal_number FROM {$this->table()} WHERE ledger_transaction_id = %d",
$result->transaction_id
));
), ARRAY_A);
$withdrawal_id = (int) ($replayed_row['id'] ?? 0);
$withdrawal_number = (string) ($replayed_row['withdrawal_number'] ?? '');
}
if ($withdrawal_id <= 0) {
return new \WP_Error('aksrt_withdraw_missing', __('提现记录不存在', 'aksrt'), ['status' => 500]);
@@ -197,9 +210,11 @@ class Withdrawal {
}
return [
'withdrawal_id' => $withdrawal_id,
'withdrawal_number' => $withdrawal_number,
'net_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $net_minor)->decimal(),
'wallet_balance' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($user_id, \AKSRT\Ledger\AssetType::BALANCE))->decimal(),
'transaction_id' => $result->transaction_id,
'flow_number' => $result->flow_number,
'replayed' => $result->replayed,
'usage' => [
'count' => $usage['count'],
@@ -253,7 +268,7 @@ class Withdrawal {
* Status transition with balance side-effects.
* Reject of balance-source holds refunds gross_amount once (idempotent).
*
* @return array{transaction_id:int,replayed:bool,wallet_balance:string}|\WP_Error
* @return array{transaction_id:int,flow_number:string,withdrawal_number:string,replayed:bool,wallet_balance:string}|\WP_Error
*/
public function process_status_change(int $withdrawal_id, string $new_status, string $note = '', string $operation_key = '') {
$new_status = sanitize_key($new_status);
@@ -287,6 +302,8 @@ class Withdrawal {
}
if (is_array($preflight_replay)) {
$preflight_replay['wallet_balance'] = \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal((int) ($row['user_id'] ?? 0));
$preflight_replay['withdrawal_number'] = (string) ($row['withdrawal_number'] ?? '');
$preflight_replay['flow_number'] = \AKSRT\Finance\FinancialFlowService::get_instance()->number_for_operation($operation_key);
return $preflight_replay;
}
$transitions = [
@@ -302,6 +319,7 @@ class Withdrawal {
$user_id = (int) ($row['user_id'] ?? 0);
$ledger_result = null;
$completed_flow_number = '';
$locked_row = null;
$locked_meta = [];
$operation_replay = null;
@@ -400,6 +418,19 @@ class Withdrawal {
}
$locked = $lock_and_validate();
$updated = is_wp_error($locked) ? $locked : $update_locked(0);
if (!is_wp_error($updated) && $new_status === self::STATUS_COMPLETED && !is_array($operation_replay)) {
$flow = \AKSRT\Finance\FinancialFlowService::get_instance()->record_withdrawal_payout(
array_merge((array) $locked_row, ['status' => $new_status]),
$operation_key,
(new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s.u'),
true
);
if (is_wp_error($flow)) {
$updated = $flow;
} else {
$completed_flow_number = $flow;
}
}
if (is_wp_error($updated) || $wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
return is_wp_error($updated) ? $updated : new \WP_Error('aksrt_withdraw_update', __('提现状态更新失败', 'aksrt'), ['status' => 500]);
@@ -429,6 +460,8 @@ class Withdrawal {
return [
'transaction_id' => $ledger_result instanceof \AKSRT\Ledger\LedgerResult ? $ledger_result->transaction_id : 0,
'flow_number' => $ledger_result instanceof \AKSRT\Ledger\LedgerResult ? $ledger_result->flow_number : $completed_flow_number,
'withdrawal_number' => (string) ($row['withdrawal_number'] ?? ''),
'replayed' => $replayed,
'wallet_balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
];
@@ -528,15 +561,7 @@ class Withdrawal {
*/
public function get_all(array $args = []): array {
global $wpdb;
$where = [];
$params = [];
$status = sanitize_key((string) ($args['status'] ?? ''));
if ($status !== '') {
$where[] = 'status = %s';
$params[] = $status;
}
[$where, $params] = $this->build_admin_filters($args);
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
@@ -556,15 +581,55 @@ class Withdrawal {
public function get_count(array $args = []): int {
global $wpdb;
[$where, $params] = $this->build_admin_filters($args);
$where_sql = $where ? ' WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT COUNT(*) FROM {$this->table()}{$where_sql}";
return (int) ($params ? $wpdb->get_var($wpdb->prepare($sql, $params)) : $wpdb->get_var($sql));
}
/** @return array{0:array<int,string>,1:array<int,mixed>} */
private function build_admin_filters(array $args): array {
global $wpdb;
$where = [];
$params = [];
$status = sanitize_key((string) ($args['status'] ?? ''));
if ($status !== '') {
return (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$this->table()} WHERE status = %s",
$status
));
if (in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED], true)) {
$where[] = 'status = %s';
$params[] = $status;
}
return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$this->table()}");
$user_id = max(0, (int) ($args['user_id'] ?? 0));
if ($user_id > 0) {
$where[] = 'user_id = %d';
$params[] = $user_id;
}
$keyword = trim(sanitize_text_field((string) ($args['keyword'] ?? '')));
if ($keyword !== '') {
$like = '%' . $wpdb->esc_like($keyword) . '%';
if (ctype_digit($keyword)) {
$where[] = '(withdrawal_number LIKE %s OR user_id = %d)';
$params[] = $like;
$params[] = (int) $keyword;
} else {
$where[] = 'withdrawal_number LIKE %s';
$params[] = $like;
}
}
if (!empty($args['date_from'])) {
$where[] = 'created_at >= %s';
$params[] = (string) $args['date_from'] . ' 00:00:00';
}
if (!empty($args['date_to'])) {
try {
$end = (new \DateTimeImmutable((string) $args['date_to'] . ' 00:00:00', wp_timezone()))->modify('+1 day')->format('Y-m-d H:i:s');
} catch (\Exception $e) {
$end = '';
}
if ($end !== '') {
$where[] = 'created_at < %s';
$params[] = $end;
}
}
return [$where, $params];
}
public function get_stats(): array {