feat: 新增命令和迁移以优化抽奖数据管理

- 新增 `LotteryDevPruneDrawBacklogCommand` 命令,用于按营业日区间删除积压的抽奖期号,并支持干运行和级联删除相关数据。
- 添加多个迁移文件以同步数据库结构,包括重命名重复的迁移文件、添加用户名字段、迁移抽奖状态到领域字典、合并显示名称字段、扩展审计日志目标类型字段,以及细化后台权限管理。
- 更新 `AdminRbacAndUserSeeder` 以包含角色代码字段,确保一致性与可维护性。
This commit is contained in:
2026-05-25 15:33:33 +08:00
parent e27a00f260
commit 6a8cdbe3b8
9 changed files with 258 additions and 4 deletions

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Console\Commands;
use App\Models\Draw;
use Illuminate\Console\Command;
use App\Services\Draw\DrawTickService;
use Illuminate\Support\Facades\DB;
/**
* 开发/测试库:按营业日区间删除积压期号(级联清理关联数据)。
*
* 仅允许 local / testing 环境执行。
*/
final class LotteryDevPruneDrawBacklogCommand extends Command
{
protected $signature = 'lottery:dev-prune-draw-backlog
{--from=2026-05-11 : 营业日起Y-m-d}
{--to=2026-05-22 : 营业日止Y-m-d}
{--dry-run : 只统计,不删除}
{--force : 跳过确认}
{--tick : 删除后执行 lottery:draw-tick积压多时可能占内存}';
protected $description = '【仅 dev/test】删除指定营业日区间的期号积压cascade';
public function handle(DrawTickService $tickService): int
{
if (! app()->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;
}
}

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* 将历史上重复时间戳的 migration 文件名同步到 migrations 表,避免重命名后被视为未执行。
*
* 新装库UPDATE 命中 0 行,无影响。
* 已有库:把旧文件名改为新文件名,与重命名后的 PHP 文件一致。
*
* 若从旧文件名升级且 `php artisan migrate` 报「列已存在」等重复执行错误,请先单独执行本文件再全量 migrate
* php artisan migrate --path=database/migrations/2026_05_09_119999_rename_duplicate_migration_filenames_in_table.php
* php artisan migrate
*/
return new class extends Migration
{
/** @var array<string, string> */
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]);
}
}
};

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
/**
* admin_api_resources.code 最长可达 128;中间件将完整 code 写入 audit_logs.target_type。
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('audit_logs', function (Blueprint $table): void {
$table->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();
});
}
};

View File

@@ -30,13 +30,13 @@ final class AdminRbacAndUserSeeder extends Seeder
{ {
$super = AdminRole::query()->updateOrCreate( $super = AdminRole::query()->updateOrCreate(
['slug' => AdminUser::ROLE_SUPER_ADMIN], ['slug' => AdminUser::ROLE_SUPER_ADMIN],
['name' => '超级管理员'], ['code' => AdminUser::ROLE_SUPER_ADMIN, 'name' => '超级管理员'],
); );
$this->syncRolePermissions($super, $this->allCatalogSlugs()); $this->syncRolePermissions($super, $this->allCatalogSlugs());
$risk = AdminRole::query()->updateOrCreate( $risk = AdminRole::query()->updateOrCreate(
['slug' => 'risk_operator'], ['slug' => 'risk_operator'],
['name' => '风控运营员'], ['code' => 'risk_operator', 'name' => '风控运营员'],
); );
$this->syncRolePermissions($risk, [ $this->syncRolePermissions($risk, [
'prd.dashboard.view', 'prd.dashboard.view',
@@ -59,7 +59,7 @@ final class AdminRbacAndUserSeeder extends Seeder
$finance = AdminRole::query()->updateOrCreate( $finance = AdminRole::query()->updateOrCreate(
['slug' => 'finance'], ['slug' => 'finance'],
['name' => '财务/对账员'], ['code' => 'finance', 'name' => '财务/对账员'],
); );
$this->syncRolePermissions($finance, [ $this->syncRolePermissions($finance, [
'prd.dashboard.view', 'prd.dashboard.view',
@@ -79,7 +79,7 @@ final class AdminRbacAndUserSeeder extends Seeder
$cs = AdminRole::query()->updateOrCreate( $cs = AdminRole::query()->updateOrCreate(
['slug' => 'customer_service'], ['slug' => 'customer_service'],
['name' => '客服人员'], ['code' => 'customer_service', 'name' => '客服人员'],
); );
$this->syncRolePermissions($cs, [ $this->syncRolePermissions($cs, [
'prd.dashboard.view', 'prd.dashboard.view',

View File

@@ -4,11 +4,81 @@ use App\Models\AuditLog;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Models\Draw; use App\Models\Draw;
use App\Lottery\DrawStatus; use App\Lottery\DrawStatus;
use App\Lottery\DrawResultBatchStatus;
use App\Models\DrawResultBatch;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); 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 { test('admin api audit middleware records draw reopen', function (): void {
$admin = AdminUser::query()->create([ $admin = AdminUser::query()->create([
'username' => 'audit_reopen_admin', 'username' => 'audit_reopen_admin',