diff --git a/app/Console/Commands/LotteryDevPruneDrawBacklogCommand.php b/app/Console/Commands/LotteryDevPruneDrawBacklogCommand.php new file mode 100644 index 0000000..19a5191 --- /dev/null +++ b/app/Console/Commands/LotteryDevPruneDrawBacklogCommand.php @@ -0,0 +1,119 @@ +environment(['local', 'testing'])) { + $this->error('Refused: only allowed in local or testing environment.'); + + return self::FAILURE; + } + + $from = (string) $this->option('from'); + $to = (string) $this->option('to'); + $dryRun = (bool) $this->option('dry-run'); + + $query = Draw::query()->whereBetween('business_date', [$from, $to]); + $total = (int) $query->count(); + + if ($total === 0) { + $this->info("No draws found between {$from} and {$to}."); + + return self::SUCCESS; + } + + $byStatus = (clone $query) + ->selectRaw('status, count(*) as c') + ->groupBy('status') + ->orderByDesc('c') + ->pluck('c', 'status'); + + $drawIds = (clone $query)->pluck('id'); + $ticketOrders = (int) DB::table('ticket_orders')->whereIn('draw_id', $drawIds)->count(); + + $this->table( + ['Metric', 'Value'], + [ + ['Date range', "{$from} .. {$to}"], + ['Draws to delete', (string) $total], + ['Ticket orders (cascade)', (string) $ticketOrders], + ], + ); + + $this->line('By status:'); + foreach ($byStatus as $status => $count) { + $this->line(" {$status}: {$count}"); + } + + if ($dryRun) { + $this->info('Dry run — no rows deleted.'); + + return self::SUCCESS; + } + + if (! $this->option('force') && ! $this->confirm("Delete {$total} draws and related rows?", false)) { + $this->warn('Aborted.'); + + return self::SUCCESS; + } + + $deleted = 0; + (clone $query)->orderBy('id')->chunkById(200, function ($draws) use (&$deleted): void { + foreach ($draws as $draw) { + $draw->delete(); + $deleted++; + } + $this->output->write('.'); + }); + + $this->newLine(); + $this->info("Deleted {$deleted} draws."); + + if ($this->option('tick')) { + $report = $tickService->tick(); + $this->info(sprintf( + 'Post-tick: status_updates=%d rng=%d planned_created=%d', + array_sum($report['status_updates'] ?? []), + $report['rng_rung'], + $report['planned']['created'] ?? 0, + )); + } else { + $this->comment('Skipped tick (pass --tick to run draw-tick after prune).'); + } + + $head = Draw::query() + ->whereNotIn('status', ['settled', 'cancelled']) + ->orderBy('draw_time') + ->first(['draw_no', 'status', 'draw_time']); + + if ($head !== null) { + $this->info("Hall pipeline head is now: {$head->draw_no} ({$head->status}) @ {$head->draw_time}"); + } else { + $this->info('Hall pipeline head: none (all settled/cancelled).'); + } + + return self::SUCCESS; + } +} diff --git a/database/migrations/2026_05_09_119999_rename_duplicate_migration_filenames_in_table.php b/database/migrations/2026_05_09_119999_rename_duplicate_migration_filenames_in_table.php new file mode 100644 index 0000000..b038b31 --- /dev/null +++ b/database/migrations/2026_05_09_119999_rename_duplicate_migration_filenames_in_table.php @@ -0,0 +1,40 @@ + */ + private const RENAMES = [ + '2026_05_09_120000_add_username_and_nullable_email_to_admin_users' => '2026_05_09_120001_add_username_and_nullable_email_to_admin_users', + '2026_05_09_120000_migrate_draw_status_to_domain_dict' => '2026_05_09_120002_migrate_draw_status_to_domain_dict', + '2026_05_25_120000_consolidate_play_display_name_columns' => '2026_05_25_120001_consolidate_play_display_name_columns', + '2026_05_25_120000_expand_audit_logs_target_type' => '2026_05_25_120002_expand_audit_logs_target_type', + '2026_05_25_120000_refine_admin_permission_granularity' => '2026_05_25_120003_refine_admin_permission_granularity', + ]; + + public function up(): void + { + foreach (self::RENAMES as $from => $to) { + DB::table('migrations')->where('migration', $from)->update(['migration' => $to]); + } + } + + public function down(): void + { + foreach (self::RENAMES as $from => $to) { + DB::table('migrations')->where('migration', $to)->update(['migration' => $from]); + } + } +}; diff --git a/database/migrations/2026_05_09_120000_add_username_and_nullable_email_to_admin_users.php b/database/migrations/2026_05_09_120001_add_username_and_nullable_email_to_admin_users.php similarity index 100% rename from database/migrations/2026_05_09_120000_add_username_and_nullable_email_to_admin_users.php rename to database/migrations/2026_05_09_120001_add_username_and_nullable_email_to_admin_users.php diff --git a/database/migrations/2026_05_09_120000_migrate_draw_status_to_domain_dict.php b/database/migrations/2026_05_09_120002_migrate_draw_status_to_domain_dict.php similarity index 100% rename from database/migrations/2026_05_09_120000_migrate_draw_status_to_domain_dict.php rename to database/migrations/2026_05_09_120002_migrate_draw_status_to_domain_dict.php diff --git a/database/migrations/2026_05_25_120000_consolidate_play_display_name_columns.php b/database/migrations/2026_05_25_120001_consolidate_play_display_name_columns.php similarity index 100% rename from database/migrations/2026_05_25_120000_consolidate_play_display_name_columns.php rename to database/migrations/2026_05_25_120001_consolidate_play_display_name_columns.php diff --git a/database/migrations/2026_05_25_120002_expand_audit_logs_target_type.php b/database/migrations/2026_05_25_120002_expand_audit_logs_target_type.php new file mode 100644 index 0000000..f86a6e4 --- /dev/null +++ b/database/migrations/2026_05_25_120002_expand_audit_logs_target_type.php @@ -0,0 +1,25 @@ +string('target_type', 128)->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('audit_logs', function (Blueprint $table): void { + $table->string('target_type', 32)->nullable()->change(); + }); + } +}; diff --git a/database/migrations/2026_05_25_120000_refine_admin_permission_granularity.php b/database/migrations/2026_05_25_120003_refine_admin_permission_granularity.php similarity index 100% rename from database/migrations/2026_05_25_120000_refine_admin_permission_granularity.php rename to database/migrations/2026_05_25_120003_refine_admin_permission_granularity.php diff --git a/database/seeders/AdminRbacAndUserSeeder.php b/database/seeders/AdminRbacAndUserSeeder.php index aed1223..3a584b7 100644 --- a/database/seeders/AdminRbacAndUserSeeder.php +++ b/database/seeders/AdminRbacAndUserSeeder.php @@ -30,13 +30,13 @@ final class AdminRbacAndUserSeeder extends Seeder { $super = AdminRole::query()->updateOrCreate( ['slug' => AdminUser::ROLE_SUPER_ADMIN], - ['name' => '超级管理员'], + ['code' => AdminUser::ROLE_SUPER_ADMIN, 'name' => '超级管理员'], ); $this->syncRolePermissions($super, $this->allCatalogSlugs()); $risk = AdminRole::query()->updateOrCreate( ['slug' => 'risk_operator'], - ['name' => '风控运营员'], + ['code' => 'risk_operator', 'name' => '风控运营员'], ); $this->syncRolePermissions($risk, [ 'prd.dashboard.view', @@ -59,7 +59,7 @@ final class AdminRbacAndUserSeeder extends Seeder $finance = AdminRole::query()->updateOrCreate( ['slug' => 'finance'], - ['name' => '财务/对账员'], + ['code' => 'finance', 'name' => '财务/对账员'], ); $this->syncRolePermissions($finance, [ 'prd.dashboard.view', @@ -79,7 +79,7 @@ final class AdminRbacAndUserSeeder extends Seeder $cs = AdminRole::query()->updateOrCreate( ['slug' => 'customer_service'], - ['name' => '客服人员'], + ['code' => 'customer_service', 'name' => '客服人员'], ); $this->syncRolePermissions($cs, [ 'prd.dashboard.view', diff --git a/tests/Feature/AdminApiAuditMiddlewareTest.php b/tests/Feature/AdminApiAuditMiddlewareTest.php index 4af2856..662cb80 100644 --- a/tests/Feature/AdminApiAuditMiddlewareTest.php +++ b/tests/Feature/AdminApiAuditMiddlewareTest.php @@ -4,11 +4,81 @@ use App\Models\AuditLog; use App\Models\AdminUser; use App\Models\Draw; use App\Lottery\DrawStatus; +use App\Lottery\DrawResultBatchStatus; +use App\Models\DrawResultBatch; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); +function seedAdminApiResourceForAudit(string $code, string $routeName, string $moduleCode = 'draw'): void +{ + $now = now(); + DB::table('admin_api_resources')->updateOrInsert( + ['code' => $code], + [ + 'module_code' => $moduleCode, + 'name' => $code, + 'http_method' => 'POST', + 'uri_pattern' => '/test', + 'route_name' => $routeName, + 'auth_mode' => 'permission_required', + 'is_audit_required' => true, + 'status' => 1, + 'updated_at' => $now, + 'created_at' => $now, + ], + ); +} + +test('admin api audit middleware records draw result batch publish with long target_type', function (): void { + seedAdminApiResourceForAudit( + 'admin.draws.result-batches.publish', + 'api.v1.admin.draws.result-batches.publish', + ); + + $admin = AdminUser::query()->create([ + 'username' => 'audit_publish_admin', + 'name' => 'Audit Publish', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $draw = Draw::query()->create([ + 'draw_no' => '20260525-100', + 'business_date' => '2026-05-25', + 'sequence_no' => 100, + 'status' => DrawStatus::Review->value, + 'settle_version' => 0, + ]); + + $batch = DrawResultBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_version' => 1, + 'source_type' => 'rng', + 'status' => DrawResultBatchStatus::PendingReview->value, + ]); + + $before = AuditLog::query()->count(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson("/api/v1/admin/draws/{$draw->id}/result-batches/{$batch->id}/publish") + ->assertOk(); + + expect(AuditLog::query()->count())->toBe($before + 1); + + /** @var AuditLog $row */ + $row = AuditLog::query()->latest('id')->first(); + expect($row->module_code)->toBe('draw') + ->and($row->action_code)->toBe('publish') + ->and($row->target_type)->toBe('admin.draws.result-batches.publish') + ->and($row->target_id)->toBe((string) $batch->id); +}); + test('admin api audit middleware records draw reopen', function (): void { $admin = AdminUser::query()->create([ 'username' => 'audit_reopen_admin',