feat: add wallet transfer reconcile command and schedule task

新增钱包划转对账命令行工具与定时任务,用于扫描主站与彩票侧的转账流水差异,记录掉单和异常划转单,并生成对账报告。
This commit is contained in:
2026-05-15 10:41:39 +08:00
parent 4c5a5285ff
commit 5398af0a55
4 changed files with 360 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Services\Wallet\WalletTransferReconcileDetector;
final class WalletTransferReconcileCommand extends Command
{
protected $signature = 'lottery:wallet-transfer-reconcile {--lookback-hours=24 : 回看时间窗口} {--stale-minutes=15 : processing 超过多久算作掉单} {--limit=500 : 每次最多扫描多少笔转账单}';
protected $description = '扫描主站与彩票侧划转差异,生成钱包对账单';
public function handle(WalletTransferReconcileDetector $detector): int
{
$lookbackHours = max(1, (int) $this->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;
}
}

View File

@@ -0,0 +1,254 @@
<?php
namespace App\Services\Wallet;
use Carbon\Carbon;
use App\Models\WalletTxn;
use App\Models\TransferOrder;
use App\Models\ReconcileJob;
use App\Models\ReconcileItem;
use Illuminate\Support\Str;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* 钱包划转自动对账扫描器。
*
* 作用:
* - 发现主站与彩票侧的流水不一致
* - 发现长时间停留在 processing / pending_reconcile 的划转单
* - 将结果落到 reconcile_jobs / reconcile_items供后台查看与后续处理
*/
final class WalletTransferReconcileDetector
{
private const RECONCILE_TYPE = 'wallet_transfer';
public function scanAndRecord(
?Carbon $periodStart = null,
?Carbon $periodEnd = null,
int $limit = 500,
int $staleMinutes = 15,
): ?ReconcileJob {
$periodEnd ??= now();
$periodStart ??= $periodEnd->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;
}
}

View File

@@ -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();
})

View File

@@ -0,0 +1,61 @@
<?php
use App\Models\Player;
use App\Models\ReconcileJob;
use App\Models\TransferOrder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('wallet transfer reconcile command records missing and pending transfer discrepancies', function (): void {
$player = Player::query()->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');
});