feat(admin): 完善后台角色管理与权限同步,新增当前管理员信息接口
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user