diff --git a/app/Http/Controllers/Api/V1/Admin/Dashboard/AdminDashboardController.php b/app/Http/Controllers/Api/V1/Admin/Dashboard/AdminDashboardController.php new file mode 100644 index 0000000..3c6ba5f --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Dashboard/AdminDashboardController.php @@ -0,0 +1,36 @@ +lotteryAdmin(); + if (! $admin instanceof AdminUser) { + return ApiResponse::error( + trans('admin.unauthenticated', [], $request->lotteryLocale()), + ErrorCode::AdminUnauthenticated->value, + null, + 401, + ); + } + + return ApiResponse::success($this->dashboard->build($admin)); + } +} diff --git a/app/Services/Admin/AdminDashboardSnapshotBuilder.php b/app/Services/Admin/AdminDashboardSnapshotBuilder.php new file mode 100644 index 0000000..a0bff9a --- /dev/null +++ b/app/Services/Admin/AdminDashboardSnapshotBuilder.php @@ -0,0 +1,267 @@ + */ + public function build(AdminUser $admin): array + { + $hall = $this->hallSnapshot->build(); + $canDraw = $this->canDrawFinanceAndRisk($admin); + $canWallet = $this->canWalletReconcile($admin); + + $out = [ + 'hall' => $hall, + 'resolved_draw' => null, + 'finance' => null, + 'draw' => null, + 'risk' => null, + 'abnormal_transfer_total' => null, + 'warnings' => [], + 'capabilities' => [ + 'draw_finance_risk' => $canDraw, + 'wallet_transfer_view' => $canWallet, + ], + ]; + + if ($hall === null) { + return $out; + } + + $drawNo = (string) ($hall['draw_no'] ?? ''); + $draw = Draw::query()->where('draw_no', $drawNo)->first(); + + if ($draw === null) { + $out['warnings'][] = [ + 'code' => 'draw_row_missing', + 'message' => '大厅期号在 draws 表中未找到对应行。', + ]; + + return $out; + } + + $out['resolved_draw'] = [ + 'id' => (int) $draw->id, + 'draw_no' => $draw->draw_no, + ]; + + if ($canDraw) { + $out['finance'] = $this->financeSummary($draw); + $out['draw'] = $this->drawPanel($draw); + $out['risk'] = $this->riskPanel($draw); + } + + if ($canWallet) { + $out['abnormal_transfer_total'] = $this->abnormalTransferTotal(); + } + + return $out; + } + + private function canDrawFinanceAndRisk(AdminUser $admin): bool + { + return $admin->hasAdminPermission('prd.draw_result.manage') + || $admin->hasAdminPermission('prd.draw_result.view'); + } + + private function canWalletReconcile(AdminUser $admin): bool + { + return $admin->hasAdminPermission('prd.wallet_reconcile.manage') + || $admin->hasAdminPermission('prd.wallet_reconcile.view') + || $admin->hasAdminPermission('prd.wallet_reconcile.view_cs'); + } + + private function abnormalTransferTotal(): int + { + return (int) TransferOrder::query() + ->whereIn('status', ['processing', 'failed', 'pending_reconcile']) + ->count(); + } + + /** @return array */ + private function financeSummary(Draw $draw): array + { + $drawId = (int) $draw->id; + + $totalBetMinor = (int) TicketOrder::query()->where('draw_id', $drawId)->sum('total_actual_deduct'); + $orderCount = (int) TicketOrder::query()->where('draw_id', $drawId)->count(); + $itemCount = (int) TicketItem::query()->where('draw_id', $drawId)->count(); + + $currencyCode = (string) (TicketOrder::query() + ->where('draw_id', $drawId) + ->value('currency_code') ?? ''); + + $totalWinMinor = (int) TicketItem::query()->where('draw_id', $drawId)->sum('win_amount'); + $totalJackpotWinMinor = (int) TicketItem::query()->where('draw_id', $drawId)->sum('jackpot_win_amount'); + $totalPayoutMinor = $totalWinMinor + $totalJackpotWinMinor; + $approxHouseGrossMinor = $totalBetMinor - $totalPayoutMinor; + + $batches = SettlementBatch::query() + ->where('draw_id', $drawId) + ->orderByDesc('id') + ->limit(30) + ->get(['id', 'status', 'total_ticket_count', 'total_win_count', 'total_payout_amount', 'total_jackpot_payout_amount', 'finished_at']); + + $batchRows = $batches->map(static function (SettlementBatch $b): array { + return [ + 'id' => (int) $b->id, + 'status' => $b->status, + 'total_ticket_count' => (int) $b->total_ticket_count, + 'total_win_count' => (int) $b->total_win_count, + 'total_payout_amount' => (int) $b->total_payout_amount, + 'total_jackpot_payout_amount' => (int) $b->total_jackpot_payout_amount, + 'finished_at' => $b->finished_at?->toIso8601String(), + ]; + })->values()->all(); + + return [ + 'draw_id' => $drawId, + 'draw_no' => $draw->draw_no, + 'draw_status' => $draw->status, + 'currency_code' => $currencyCode !== '' ? $currencyCode : null, + 'order_count' => $orderCount, + 'ticket_item_count' => $itemCount, + 'total_bet_minor' => $totalBetMinor, + 'total_win_payout_minor' => $totalWinMinor, + 'total_jackpot_win_minor' => $totalJackpotWinMinor, + 'total_payout_minor' => $totalPayoutMinor, + 'approx_house_gross_minor' => $approxHouseGrossMinor, + 'settlement_batches' => $batchRows, + ]; + } + + /** @return array */ + private function drawPanel(Draw $draw): array + { + $nowUtc = now()->utc(); + $batchCounts = [ + 'total' => $draw->resultBatches()->count(), + 'pending_review' => $draw->resultBatches() + ->where('status', DrawResultBatchStatus::PendingReview->value) + ->count(), + 'published' => $draw->resultBatches() + ->where('status', DrawResultBatchStatus::Published->value) + ->count(), + ]; + + return [ + 'id' => (int) $draw->id, + 'draw_no' => $draw->draw_no, + 'business_date' => $draw->business_date instanceof Carbon + ? $draw->business_date->format('Y-m-d') + : (string) $draw->business_date, + 'sequence_no' => (int) $draw->sequence_no, + 'status' => $draw->status, + 'hall_preview_status' => $this->hallSnapshot->effectiveHallDisplayStatus($draw, $nowUtc), + 'result_batch_counts' => $batchCounts, + ]; + } + + /** @return array */ + private function riskPanel(Draw $draw): array + { + $drawId = (int) $draw->id; + + $sums = RiskPool::query() + ->where('draw_id', $drawId) + ->selectRaw('COALESCE(SUM(locked_amount), 0) as locked, COALESCE(SUM(total_cap_amount), 0) as cap') + ->first(); + + $locked = (int) (($sums?->locked) ?? 0); + $cap = (int) (($sums?->cap) ?? 0); + $usagePercent = $cap > 0 ? round(($locked / $cap) * 100, 4) : 0.0; + + $hotPools = RiskPool::query() + ->where('draw_id', $drawId) + ->orderByRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) DESC') + ->orderByDesc('locked_amount') + ->orderBy('normalized_number') + ->limit(100) + ->get(); + + $hotRows = $hotPools->map(fn (RiskPool $pool) => $this->riskPoolRow($pool))->values()->all(); + + $buckets = ['d4' => 0, 'd3' => 0, 'd2' => 0, 'special' => 0, 'other' => 0]; + RiskPool::query() + ->where('draw_id', $drawId) + ->where('sold_out_status', 1) + ->select(['id', 'normalized_number']) + ->chunkById(500, function ($rows) use (&$buckets): void { + foreach ($rows as $row) { + $k = $this->soldOutBucketKey((string) $row->normalized_number); + $buckets[$k]++; + } + }); + + return [ + 'draw_id' => $drawId, + 'draw_no' => $draw->draw_no, + 'locked_amount' => $locked, + 'cap_amount' => $cap, + 'usage_percent' => $usagePercent, + 'hot_pool_rows' => $hotRows, + 'sold_out_buckets' => $buckets, + ]; + } + + /** @return array */ + private function riskPoolRow(RiskPool $pool): array + { + $cap = (int) $pool->total_cap_amount; + $locked = (int) $pool->locked_amount; + + return [ + 'normalized_number' => $pool->normalized_number, + 'total_cap_amount' => $cap, + 'locked_amount' => $locked, + 'remaining_amount' => (int) $pool->remaining_amount, + 'sold_out_status' => (int) $pool->sold_out_status, + 'is_sold_out' => (int) $pool->sold_out_status === 1, + 'usage_ratio' => $cap > 0 ? round($locked / $cap, 6) : null, + 'version' => (int) $pool->version, + ]; + } + + /** @return 'd4'|'d3'|'d2'|'special'|'other' */ + private function soldOutBucketKey(string $normalizedNumber): string + { + $raw = trim($normalizedNumber); + if (preg_match('/[A-Za-z]/', $raw) === 1) { + return 'special'; + } + $digits = preg_replace('/\D/', '', $raw) ?? ''; + $len = strlen($digits) > 0 ? strlen($digits) : strlen($raw); + if ($len >= 4) { + return 'd4'; + } + if ($len === 3) { + return 'd3'; + } + if ($len === 2) { + return 'd2'; + } + + return 'other'; + } +} diff --git a/routes/api.php b/routes/api.php index f7827b5..a827c85 100644 --- a/routes/api.php +++ b/routes/api.php @@ -18,6 +18,7 @@ use App\Http\Controllers\Api\V1\Admin\Config\RiskCapVersionIndexController; use App\Http\Controllers\Api\V1\Admin\Config\RiskCapVersionPublishController; use App\Http\Controllers\Api\V1\Admin\Config\RiskCapVersionShowController; use App\Http\Controllers\Api\V1\Admin\Config\RiskCapVersionStoreController; +use App\Http\Controllers\Api\V1\Admin\Dashboard\AdminDashboardController; use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawFinanceSummaryController; use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawIndexController; use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawResultBatchesIndexController; @@ -146,6 +147,9 @@ Route::prefix('v1')->group(function (): void { // 名称:后台接口连通性探测(需 Bearer Token;不校验细粒度 RBAC) Route::get('ping', AdminPingController::class)->name('ping'); + /** 首页仪表盘:聚合大厅 + 当期财务/风控/待办计数(细粒度权限在控制器内按块判断) */ + Route::get('dashboard', AdminDashboardController::class)->name('dashboard'); + /** §8 钱包对账:超管可管、风控查看、财务可管、客服单用户 */ Route::middleware('admin.permission:prd.wallet_reconcile.manage|prd.wallet_reconcile.view|prd.wallet_reconcile.view_cs')->group(function (): void { Route::get('wallet/transfer-orders', TransferOrderListController::class) diff --git a/tests/Feature/AdminDashboardApiTest.php b/tests/Feature/AdminDashboardApiTest.php new file mode 100644 index 0000000..b804c1d --- /dev/null +++ b/tests/Feature/AdminDashboardApiTest.php @@ -0,0 +1,73 @@ +create([ + 'draw_no' => '20260512-010', + 'business_date' => '2026-05-12', + 'sequence_no' => 10, + 'status' => 'open', + 'start_time' => now()->subHour(), + 'close_time' => now()->addHour(), + 'draw_time' => now()->addHours(2), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'normalized_number' => '1234', + 'total_cap_amount' => 1_000_000, + 'locked_amount' => 200_000, + 'remaining_amount' => 800_000, + 'sold_out_status' => 0, + 'version' => 1, + ]); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'normalized_number' => '9999', + 'total_cap_amount' => 100, + 'locked_amount' => 100, + 'remaining_amount' => 0, + 'sold_out_status' => 1, + 'version' => 2, + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'dash_admin', + 'name' => 'Dash QA', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/dashboard') + ->assertOk() + ->assertJsonPath('data.hall.draw_no', '20260512-010') + ->assertJsonPath('data.resolved_draw.id', $draw->id) + ->assertJsonPath('data.capabilities.draw_finance_risk', true) + ->assertJsonPath('data.capabilities.wallet_transfer_view', true) + ->assertJsonPath('data.finance.draw_id', $draw->id) + ->assertJsonPath('data.draw.result_batch_counts.total', 0) + ->assertJsonPath('data.risk.locked_amount', 200_100) + ->assertJsonPath('data.risk.cap_amount', 1_000_100) + ->assertJsonPath('data.risk.sold_out_buckets.d4', 1); +}); + +test('admin dashboard returns 401 without token', function (): void { + $this->getJson('/api/v1/admin/dashboard')->assertStatus(401); +});