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 {
|
->withSchedule(function (Schedule $schedule): void {
|
||||||
$schedule->command('lottery:draw-tick')->everyMinute();
|
$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` */
|
/** @see docs/01-界面文档.md §2.1 `draw.countdown` */
|
||||||
$schedule->command('lottery:hall-countdown')->everySecond();
|
$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