feat: add wallet transfer reconcile command and schedule task
新增钱包划转对账命令行工具与定时任务,用于扫描主站与彩票侧的转账流水差异,记录掉单和异常划转单,并生成对账报告。
This commit is contained in:
42
app/Console/Commands/WalletTransferReconcileCommand.php
Normal file
42
app/Console/Commands/WalletTransferReconcileCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
254
app/Services/Wallet/WalletTransferReconcileDetector.php
Normal file
254
app/Services/Wallet/WalletTransferReconcileDetector.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
})
|
||||
|
||||
61
tests/Feature/WalletTransferReconcileCommandTest.php
Normal file
61
tests/Feature/WalletTransferReconcileCommandTest.php
Normal 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');
|
||||
});
|
||||
Reference in New Issue
Block a user