From 5398af0a55d36af197c2ba9839433f4a2d5f5bb5 Mon Sep 17 00:00:00 2001 From: kang Date: Fri, 15 May 2026 10:41:39 +0800 Subject: [PATCH] feat: add wallet transfer reconcile command and schedule task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增钱包划转对账命令行工具与定时任务,用于扫描主站与彩票侧的转账流水差异,记录掉单和异常划转单,并生成对账报告。 --- .../WalletTransferReconcileCommand.php | 42 +++ .../WalletTransferReconcileDetector.php | 254 ++++++++++++++++++ bootstrap/app.php | 3 + .../WalletTransferReconcileCommandTest.php | 61 +++++ 4 files changed, 360 insertions(+) create mode 100644 app/Console/Commands/WalletTransferReconcileCommand.php create mode 100644 app/Services/Wallet/WalletTransferReconcileDetector.php create mode 100644 tests/Feature/WalletTransferReconcileCommandTest.php diff --git a/app/Console/Commands/WalletTransferReconcileCommand.php b/app/Console/Commands/WalletTransferReconcileCommand.php new file mode 100644 index 0000000..409f3c2 --- /dev/null +++ b/app/Console/Commands/WalletTransferReconcileCommand.php @@ -0,0 +1,42 @@ +option('lookback-hours')); + $staleMinutes = max(1, (int) $this->option('stale-minutes')); + $limit = max(1, (int) $this->option('limit')); + + $job = $detector->scanAndRecord( + now()->subHours($lookbackHours), + now(), + $limit, + $staleMinutes, + ); + + if ($job === null) { + $this->info('No wallet transfer discrepancies found.'); + return self::SUCCESS; + } + + $summary = $job->summary_json ?? []; + $this->info(sprintf( + 'Created reconcile job %s | items: %d | mismatches: %d', + $job->job_no, + (int) ($summary['item_count'] ?? 0), + (int) ($summary['mismatch_count'] ?? 0), + )); + + return self::SUCCESS; + } +} diff --git a/app/Services/Wallet/WalletTransferReconcileDetector.php b/app/Services/Wallet/WalletTransferReconcileDetector.php new file mode 100644 index 0000000..6f7e113 --- /dev/null +++ b/app/Services/Wallet/WalletTransferReconcileDetector.php @@ -0,0 +1,254 @@ +copy()->subDay(); + $limit = max(1, $limit); + $staleMinutes = max(1, $staleMinutes); + + $staleCutoff = $periodEnd->copy()->subMinutes($staleMinutes); + + $orders = TransferOrder::query() + ->where(function ($q) use ($periodStart, $periodEnd, $staleCutoff): void { + $q->whereBetween('created_at', [$periodStart, $periodEnd]) + ->orWhere('status', 'pending_reconcile') + ->orWhere(function ($inner) use ($staleCutoff): void { + $inner->where('status', 'processing') + ->where('created_at', '<=', $staleCutoff); + }); + }) + ->orderBy('id') + ->limit($limit) + ->get(); + + if ($orders->isEmpty()) { + return null; + } + + $txnsByTransferNo = WalletTxn::query() + ->whereIn('biz_no', $orders->pluck('transfer_no')->all()) + ->orderBy('id') + ->get() + ->groupBy(static fn (WalletTxn $txn): string => (string) $txn->biz_no); + + $items = []; + foreach ($orders as $order) { + $issue = $this->issueForOrder( + $order, + $txnsByTransferNo->get((string) $order->transfer_no, collect()), + $periodEnd, + $staleMinutes, + ); + + if ($issue !== null) { + $items[] = $issue; + } + } + + if ($items === []) { + return null; + } + + return DB::transaction(function () use ($items, $periodStart, $periodEnd): ReconcileJob { + $jobNo = 'REC'.now()->format('YmdHis').strtoupper(str_replace('-', '', Str::uuid()->toString())); + + $job = ReconcileJob::query()->create([ + 'job_no' => $jobNo, + 'admin_user_id' => null, + 'reconcile_type' => self::RECONCILE_TYPE, + 'status' => 'completed', + 'period_start' => $periodStart, + 'period_end' => $periodEnd, + 'summary_json' => [ + 'item_count' => count($items), + 'mismatch_count' => count($items), + ], + 'finished_at' => now(), + ]); + + foreach ($items as $row) { + ReconcileItem::query()->create([ + 'reconcile_job_id' => (int) $job->getKey(), + 'side_a_ref' => $row['side_a_ref'], + 'side_b_ref' => $row['side_b_ref'], + 'difference_amount' => (int) $row['difference_amount'], + 'status' => $row['status'], + 'resolved_at' => null, + ]); + } + + return $job->fresh(['items']); + }); + } + + /** + * @return array{side_a_ref: string, side_b_ref: ?string, difference_amount: int, status: string}|null + */ + private function issueForOrder(TransferOrder $order, Collection $txns, Carbon $periodEnd, int $staleMinutes): ?array + { + $amount = (int) $order->amount; + $latestTxnNo = $this->latestTxnNo($txns); + $createdAt = $order->created_at; + + if ($order->status === 'processing') { + if ($createdAt !== null && $createdAt->greaterThan($periodEnd->copy()->subMinutes($staleMinutes))) { + return null; + } + + return [ + 'side_a_ref' => (string) $order->transfer_no, + 'side_b_ref' => $latestTxnNo, + 'difference_amount' => $amount, + 'status' => 'stale_processing', + ]; + } + + if ($order->status === 'pending_reconcile') { + return [ + 'side_a_ref' => (string) $order->transfer_no, + 'side_b_ref' => $latestTxnNo, + 'difference_amount' => $amount, + 'status' => 'pending_reconcile', + ]; + } + + if ($order->status === 'success') { + $primaryBizType = $order->direction === 'out' ? 'transfer_out' : 'transfer_in'; + if (! $this->hasTxn($txns, $primaryBizType, 'posted')) { + return [ + 'side_a_ref' => (string) $order->transfer_no, + 'side_b_ref' => $latestTxnNo, + 'difference_amount' => $amount, + 'status' => 'missing_wallet_txn', + ]; + } + + if ($order->direction === 'out' && $this->hasTxn($txns, 'transfer_out_refund')) { + return [ + 'side_a_ref' => (string) $order->transfer_no, + 'side_b_ref' => $latestTxnNo, + 'difference_amount' => $amount, + 'status' => 'unexpected_wallet_txn', + ]; + } + + if ($order->direction === 'in' && $txns->count() !== 1) { + return [ + 'side_a_ref' => (string) $order->transfer_no, + 'side_b_ref' => $latestTxnNo, + 'difference_amount' => $amount, + 'status' => 'unexpected_wallet_txn', + ]; + } + + return null; + } + + if ($order->status === 'failed') { + if ($order->direction === 'in') { + if ($txns->isNotEmpty()) { + return [ + 'side_a_ref' => (string) $order->transfer_no, + 'side_b_ref' => $latestTxnNo, + 'difference_amount' => $amount, + 'status' => 'unexpected_wallet_txn', + ]; + } + + return null; + } + + if (($order->fail_reason ?? '') === 'insufficient_balance') { + if ($txns->isNotEmpty()) { + return [ + 'side_a_ref' => (string) $order->transfer_no, + 'side_b_ref' => $latestTxnNo, + 'difference_amount' => $amount, + 'status' => 'unexpected_wallet_txn', + ]; + } + + return null; + } + + $hasTransferOut = $this->hasTxn($txns, 'transfer_out', 'posted'); + $hasRefund = $this->hasTxn($txns, 'transfer_out_refund', 'posted'); + if (! $hasTransferOut || ! $hasRefund) { + return [ + 'side_a_ref' => (string) $order->transfer_no, + 'side_b_ref' => $latestTxnNo, + 'difference_amount' => $amount, + 'status' => ! $hasTransferOut ? 'missing_wallet_txn' : 'missing_refund', + ]; + } + + return null; + } + + if ($order->status === 'reversed') { + if ($order->direction === 'out') { + $hasTransferOut = $this->hasTxn($txns, 'transfer_out', 'posted'); + $hasReversal = $this->hasTxn($txns, 'reversal', 'posted'); + if (! $hasTransferOut || ! $hasReversal) { + return [ + 'side_a_ref' => (string) $order->transfer_no, + 'side_b_ref' => $latestTxnNo, + 'difference_amount' => $amount, + 'status' => ! $hasReversal ? 'missing_reversal' : 'missing_wallet_txn', + ]; + } + } + + return null; + } + + return null; + } + + private function hasTxn(Collection $txns, string $bizType, ?string $status = null): bool + { + return $txns->contains(static function (WalletTxn $txn) use ($bizType, $status): bool { + if ($txn->biz_type !== $bizType) { + return false; + } + + return $status === null || $txn->status === $status; + }); + } + + private function latestTxnNo(Collection $txns): ?string + { + /** @var WalletTxn|null $txn */ + $txn = $txns->sortByDesc('id')->first(); + + return $txn !== null ? (string) $txn->txn_no : null; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 4fd4908..12e62f4 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -168,6 +168,9 @@ return Application::configure(basePath: dirname(__DIR__)) }) ->withSchedule(function (Schedule $schedule): void { $schedule->command('lottery:draw-tick')->everyMinute(); + $schedule->command('lottery:wallet-transfer-reconcile --lookback-hours=24 --stale-minutes=15 --limit=1000') + ->everyTenMinutes() + ->withoutOverlapping(); /** @see docs/01-界面文档.md §2.1 `draw.countdown` */ $schedule->command('lottery:hall-countdown')->everySecond(); }) diff --git a/tests/Feature/WalletTransferReconcileCommandTest.php b/tests/Feature/WalletTransferReconcileCommandTest.php new file mode 100644 index 0000000..9f8ce1b --- /dev/null +++ b/tests/Feature/WalletTransferReconcileCommandTest.php @@ -0,0 +1,61 @@ +create([ + 'site_code' => 'main', + 'site_player_id' => 'reconcile-1', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + TransferOrder::query()->create([ + 'transfer_no' => 'TO_missing_txn', + 'player_id' => $player->id, + 'direction' => 'out', + 'currency_code' => 'NPR', + 'amount' => 300, + 'idempotent_key' => 'reconcile-missing', + 'status' => 'success', + 'external_request_payload' => null, + 'external_response_payload' => null, + 'external_ref_no' => 'MAIN-001', + 'fail_reason' => null, + 'finished_at' => now(), + ]); + + TransferOrder::query()->create([ + 'transfer_no' => 'TO_pending', + 'player_id' => $player->id, + 'direction' => 'out', + 'currency_code' => 'NPR', + 'amount' => 180, + 'idempotent_key' => 'reconcile-pending', + 'status' => 'pending_reconcile', + 'external_request_payload' => null, + 'external_response_payload' => null, + 'external_ref_no' => null, + 'fail_reason' => 'main_site_timeout', + 'finished_at' => null, + ]); + + $this->artisan('lottery:wallet-transfer-reconcile --lookback-hours=24 --stale-minutes=15 --limit=50') + ->assertExitCode(0); + + $job = ReconcileJob::query()->where('reconcile_type', 'wallet_transfer')->latest('id')->first(); + expect($job)->not->toBeNull() + ->and($job?->summary_json['item_count'] ?? null)->toBe(2) + ->and($job?->summary_json['mismatch_count'] ?? null)->toBe(2) + ->and($job?->items()->count())->toBe(2); + + $statuses = $job?->items()->pluck('status')->all() ?? []; + expect($statuses)->toContain('missing_wallet_txn', 'pending_reconcile'); +});