feat(admin): 完善后台角色管理与权限同步,新增当前管理员信息接口

This commit is contained in:
2026-05-19 14:39:54 +08:00
parent 063cb98311
commit 057ddecaa1
30 changed files with 1286 additions and 124 deletions

View File

@@ -3,6 +3,7 @@
use App\Models\AdminUser;
use App\Lottery\ErrorCode;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -13,6 +14,52 @@ test('admin ping requires authentication', function () {
->assertJsonPath('code', ErrorCode::AdminUnauthenticated->value);
});
test('admin auth me returns current admin profile', function () {
$admin = AdminUser::query()->create([
'username' => 'admin_me',
'name' => '管理员本人',
'email' => null,
'password' => 'secret-strong',
'status' => 0,
]);
$roleId = DB::table('admin_roles')->insertGetId([
'code' => 'super_admin',
'slug' => 'super_admin',
'name' => '超级管理员',
'description' => null,
'status' => 1,
'is_system' => true,
'sort_order' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$siteId = DB::table('admin_sites')->insertGetId([
'code' => 'default',
'name' => '默认站点',
'is_default' => true,
'status' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $admin->id,
'site_id' => $siteId,
'role_id' => $roleId,
'granted_at' => now(),
]);
$token = $admin->createToken('admin-api', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/auth/me')
->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->assertJsonPath('data.admin.username', 'admin_me')
->assertJsonPath('data.admin.navigation.0.segment', 'dashboard');
});
test('admin login returns bearer token when captcha passes validation', function () {
AdminUser::query()->create([
'username' => 'tester',

View File

@@ -4,6 +4,7 @@ use App\Models\AuditLog;
use App\Models\AdminUser;
use App\Models\Player;
use App\Models\PlayerWallet;
use App\Models\AdminRole;
use App\Services\AuditLogger;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -24,6 +25,39 @@ function playerManageAdminToken(): string
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
function playerPermissionAdminToken(string $username, array $permissionSlugs): string
{
$admin = AdminUser::query()->create([
'username' => $username,
'name' => 'Player Permission Admin',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$role = AdminRole::query()->create([
'slug' => 'role_'.$username,
'name' => 'Role '.$username,
]);
$role->syncLegacyPermissionSlugs($permissionSlugs);
$admin->roles()->sync([
(int) $role->id => [
'site_id' => AdminUser::defaultAdminSiteId(),
'granted_at' => now(),
],
]);
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
function playerPermissionRequest($test, string $token)
{
app('auth')->forgetGuards();
return $test->withHeader('Authorization', 'Bearer '.$token);
}
test('admin can freeze and unfreeze player with audit log', function (): void {
$player = Player::query()->create([
'site_code' => 'main',
@@ -65,3 +99,84 @@ test('admin can freeze and unfreeze player with audit log', function (): void {
expect(AuditLog::query()->where('module_code', 'player_manage')->count())->toBe(2);
});
test('player manage permission gates write and freeze APIs separately from view permissions', function (): void {
$player = Player::query()->create([
'site_code' => 'main',
'site_player_id' => 'perm-1',
'username' => 'perm_user',
'nickname' => 'Perm',
'default_currency' => 'NPR',
'status' => 0,
]);
$financeToken = playerPermissionAdminToken('player_finance_viewer', ['prd.users.view_finance']);
playerPermissionRequest($this, $financeToken)
->getJson('/api/v1/admin/players?per_page=10')
->assertOk();
playerPermissionRequest($this, $financeToken)
->getJson('/api/v1/admin/players/'.$player->id.'/wallets')
->assertOk();
playerPermissionRequest($this, $financeToken)
->getJson('/api/v1/admin/players/'.$player->id.'/ticket-items')
->assertForbidden();
playerPermissionRequest($this, $financeToken)
->postJson('/api/v1/admin/players', [
'site_code' => 'main',
'site_player_id' => 'created-by-finance',
'default_currency' => 'NPR',
])
->assertForbidden();
playerPermissionRequest($this, $financeToken)
->putJson('/api/v1/admin/players/'.$player->id, ['nickname' => 'blocked'])
->assertForbidden();
playerPermissionRequest($this, $financeToken)
->postJson('/api/v1/admin/players/'.$player->id.'/freeze')
->assertForbidden();
$csToken = playerPermissionAdminToken('player_cs_viewer', ['prd.users.view_cs']);
playerPermissionRequest($this, $csToken)
->getJson('/api/v1/admin/players?per_page=10')
->assertOk();
playerPermissionRequest($this, $csToken)
->getJson('/api/v1/admin/players/'.$player->id.'/ticket-items')
->assertOk();
playerPermissionRequest($this, $csToken)
->getJson('/api/v1/admin/players/'.$player->id.'/wallets')
->assertForbidden();
$freezeToken = playerPermissionAdminToken('player_freezer', ['prd.player_freeze.manage']);
playerPermissionRequest($this, $freezeToken)
->getJson('/api/v1/admin/players?per_page=10')
->assertForbidden();
playerPermissionRequest($this, $freezeToken)
->postJson('/api/v1/admin/players/'.$player->id.'/freeze')
->assertOk()
->assertJsonPath('data.status', 1);
playerPermissionRequest($this, $freezeToken)
->postJson('/api/v1/admin/players/'.$player->id.'/unfreeze')
->assertOk()
->assertJsonPath('data.status', 0);
$manageToken = playerPermissionAdminToken('player_manager', ['prd.users.manage']);
playerPermissionRequest($this, $manageToken)
->getJson('/api/v1/admin/players/'.$player->id.'/wallets')
->assertOk();
playerPermissionRequest($this, $manageToken)
->getJson('/api/v1/admin/players/'.$player->id.'/ticket-items')
->assertOk();
});

View File

@@ -28,29 +28,23 @@ test('admin settlement batches index is authenticated', function (): void {
});
test('admin jackpot pools index returns rows', function (): void {
JackpotPool::query()->create([
'currency_code' => 'NPR',
'current_amount' => 100,
'contribution_rate' => '0.01',
'trigger_threshold' => 1000,
'payout_rate' => '0.5',
'force_trigger_draw_gap' => 10,
'min_bet_amount' => 0,
'status' => 1,
'last_trigger_draw_id' => null,
]);
$token = mintSettlementAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/jackpot/pools')
->assertOk()
->assertJsonPath('data.items.0.currency_code', 'NPR')
->assertJsonPath('data.items.0.contribution_rate', '0.0200')
->assertJsonPath('data.items.0.trigger_threshold', 100000000)
->assertJsonPath('data.items.0.payout_rate', '0.5000')
->assertJsonPath('data.items.0.force_trigger_draw_gap', 100)
->assertJsonPath('data.items.0.min_bet_amount', 100)
->assertJsonPath('data.items.0.status', 0)
->assertJsonPath('data.items.0.combo_trigger_play_codes', []);
});
test('admin can update jackpot combo trigger and manually burst pool', function (): void {
$pool = JackpotPool::query()->create([
'currency_code' => 'NPR',
$pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
$pool->forceFill([
'current_amount' => 1000,
'contribution_rate' => '0.01',
'trigger_threshold' => 1000,
@@ -59,7 +53,7 @@ test('admin can update jackpot combo trigger and manually burst pool', function
'min_bet_amount' => 0,
'status' => 1,
'last_trigger_draw_id' => null,
]);
])->save();
$draw = Draw::query()->create([
'draw_no' => '20260518-001',
'business_date' => '2026-05-18',

View File

@@ -25,23 +25,7 @@ function makeAdminWithPermissions(string $username, array $permissionSlugs): str
'name' => 'Role '.$username,
]);
$codes = [];
foreach ($permissionSlugs as $slug) {
$codes = array_merge($codes, AdminPermissionBridge::menuActionCodesForLegacy($slug));
}
$codes = array_values(array_unique($codes));
$ids = DB::table('admin_menu_actions')
->whereIn('permission_code', $codes)
->where('status', 1)
->pluck('id')
->all();
foreach ($ids as $mid) {
DB::table('admin_role_menu_actions')->insert([
'role_id' => $role->id,
'menu_action_id' => (int) $mid,
]);
}
$role->syncLegacyPermissionSlugs($permissionSlugs);
$siteId = AdminUser::defaultAdminSiteId();
$admin->roles()->sync([
@@ -129,7 +113,7 @@ test('admin can list users and sync direct permissions', function (): void {
});
test('admin can sync user roles for default site', function (): void {
$token = makeAdminWithPermissions('rbac_role_editor', ['prd.admin_user.manage']);
$token = makeAdminWithPermissions('rbac_role_editor', ['prd.admin_user.manage', 'prd.admin_role.manage']);
$r1 = AdminRole::query()->create(['slug' => 'role_sync_a', 'name' => 'Role A']);
$r2 = AdminRole::query()->create(['slug' => 'role_sync_b', 'name' => 'Role B']);
@@ -154,6 +138,104 @@ test('admin can sync user roles for default site', function (): void {
expect($slugs)->toBe(['role_sync_a', 'role_sync_b']);
});
test('permission catalog groups permissions by admin navigation order', function (): void {
$token = makeAdminWithPermissions('nav_group_catalog', ['prd.admin_user.manage', 'prd.admin_role.manage']);
$groups = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/admin-user-permission-catalog')
->assertOk()
->json('data.permission_menu_groups');
expect(array_column($groups, 'key'))->toBe([
'admin_users',
'admin_roles',
'players',
'wallet',
'draws',
'config',
'risk',
'settlement',
'reconcile',
'tickets',
'reports',
'audit',
]);
expect($groups[0]['label'])->toBe('管理列表');
expect(array_column($groups[0]['permissions'], 'slug'))->toBe(['prd.admin_user.manage']);
expect($groups[1]['label'])->toBe('角色管理');
expect(array_column($groups[1]['permissions'], 'slug'))->toBe(['prd.admin_role.manage']);
$groupsByKey = collect($groups)->keyBy('key');
expect(array_column($groupsByKey['tickets']['permissions'], 'slug'))->toBe([
'prd.users.view_cs',
'prd.users.manage',
'prd.report.player',
]);
expect(array_column($groupsByKey['config']['permissions'], 'slug'))->toContain(
'prd.jackpot.manage',
'prd.jackpot.view',
);
expect(array_column($groupsByKey['reconcile']['permissions'], 'slug'))->toBe([
'prd.wallet_reconcile.manage',
'prd.wallet_reconcile.view',
'prd.wallet_reconcile.view_cs',
]);
});
test('admin can repair role permissions from the full catalog after role creation', function (): void {
$token = makeAdminWithPermissions('role_permission_repairer', ['prd.admin_user.manage', 'prd.admin_role.manage']);
$catalog = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/admin-user-permission-catalog')
->assertOk()
->json('data');
$catalogSlugs = collect($catalog['permission_menu_groups'])
->flatMap(static fn (array $group): array => array_column($group['permissions'], 'slug'))
->unique()
->values()
->all();
expect($catalogSlugs)
->toContain('prd.admin_user.manage')
->toContain('prd.admin_role.manage')
->toContain('prd.report.player')
->toContain('prd.wallet_reconcile.manage');
$role = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/admin-roles', [
'slug' => 'repairable_role',
'name' => 'Repairable Role',
'permission_slugs' => [],
])
->assertOk()
->assertJsonPath('data.permission_slugs', [])
->json('data');
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/admin-roles/'.$role['id'].'/permissions', [
'permission_slugs' => ['prd.report.player', 'prd.wallet_reconcile.manage'],
])
->assertOk()
->assertJsonPath('data.slug', 'repairable_role')
->assertJsonPath('data.permission_slugs', ['prd.report.player', 'prd.wallet_reconcile.manage']);
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/admin-roles/'.$role['id'].'/permissions', [
'permission_slugs' => ['prd.admin_role.manage'],
])
->assertOk()
->assertJsonPath('data.permission_slugs', ['prd.admin_role.manage']);
$persistedPermissions = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/admin-roles')
->assertOk()
->json('data.items');
$persistedRole = collect($persistedPermissions)->firstWhere('slug', 'repairable_role');
expect($persistedRole['permission_slugs'])->toBe(['prd.admin_role.manage']);
});
test('admin can create update and delete users with crud rules', function (): void {
$token = makeAdminWithPermissions('crud_actor', ['prd.admin_user.manage']);

View File

@@ -109,6 +109,109 @@ test('admin filters abnormal transfer orders', function (): void {
$resp->assertOk()->assertJsonPath('data.total', 2);
});
test('admin transfer order list exposes available reconcile actions by status', function (): void {
$token = makeAdminToken();
$player = Player::query()->create([
'site_code' => 'main',
'site_player_id' => 'action-player',
'username' => null,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
foreach (
[
['TI_processing', 'processing'],
['TI_failed', 'failed'],
['TI_wait', 'pending_reconcile'],
['TI_done', 'success'],
] as [$no, $st]
) {
TransferOrder::query()->create([
'transfer_no' => $no,
'player_id' => $player->id,
'direction' => 'in',
'currency_code' => 'NPR',
'amount' => 100,
'idempotent_key' => 'action-'.$no,
'status' => $st,
'external_request_payload' => null,
'external_response_payload' => null,
'external_ref_no' => null,
'fail_reason' => null,
'finished_at' => $st === 'success' ? now() : null,
]);
}
$items = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/wallet/transfer-orders?per_page=10')
->assertOk()
->json('data.items');
$byNo = collect($items)->keyBy('transfer_no');
expect($byNo['TI_processing']['can_reverse'])->toBeFalse()
->and($byNo['TI_processing']['can_manually_process'])->toBeTrue()
->and($byNo['TI_failed']['can_reverse'])->toBeFalse()
->and($byNo['TI_failed']['can_manually_process'])->toBeTrue()
->and($byNo['TI_wait']['can_reverse'])->toBeTrue()
->and($byNo['TI_wait']['can_manually_process'])->toBeTrue()
->and($byNo['TI_done']['can_reverse'])->toBeFalse()
->and($byNo['TI_done']['can_manually_process'])->toBeFalse();
});
test('admin can manually process abnormal transfer orders except completed ones', function (): void {
$token = makeAdminToken();
$player = Player::query()->create([
'site_code' => 'main',
'site_player_id' => 'manual-player',
'username' => null,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
foreach (
[
['TI_processing_manual', 'processing'],
['TI_failed_manual', 'failed'],
['TI_success_manual', 'success'],
] as [$no, $st]
) {
TransferOrder::query()->create([
'transfer_no' => $no,
'player_id' => $player->id,
'direction' => 'in',
'currency_code' => 'NPR',
'amount' => 100,
'idempotent_key' => 'manual-'.$no,
'status' => $st,
'external_request_payload' => null,
'external_response_payload' => null,
'external_ref_no' => null,
'fail_reason' => null,
'finished_at' => $st === 'success' ? now() : null,
]);
}
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/wallet/transfer-orders/TI_processing_manual/manually-process')
->assertOk()
->assertJsonPath('data.status', 'manually_processed');
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/wallet/transfer-orders/TI_failed_manual/manually-process')
->assertOk()
->assertJsonPath('data.status', 'manually_processed');
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/wallet/transfer-orders/TI_success_manual/manually-process')
->assertStatus(422);
});
test('admin lists wallet transactions and filters abnormal', function (): void {
$token = makeAdminToken();