feat: 增强玩家管理功能,集成接入站点权限控制

在多个玩家相关控制器中引入 AdminSiteScope,确保管理员在执行操作前具备相应的接入站点权限。更新 Player 相关请求以支持 site_code 参数,增强权限验证逻辑,确保系统安全性与灵活性。同时,新增 AdminUser 模型方法以获取可访问的站点 ID 列表,优化权限管理。
This commit is contained in:
2026-05-27 13:36:23 +08:00
parent b649c862ef
commit a10135d6ee
47 changed files with 2265 additions and 38 deletions

View File

@@ -32,6 +32,9 @@ final class AdminAuthorizationRegistry
['slug' => 'prd.player_freeze.manage', 'name' => '冻结/解冻玩家·可管理', 'nav_segment' => 'players', 'permission_codes' => ['service.players.freeze']],
['slug' => 'prd.currency.manage', 'name' => '币种管理·可管理', 'nav_segment' => 'settings', 'permission_codes' => ['service.currency.manage']],
['slug' => 'prd.integration.view', 'name' => '接入站点·查看', 'nav_segment' => 'integration', 'permission_codes' => ['integration.site.view']],
['slug' => 'prd.integration.manage', 'name' => '接入站点·可管理', 'nav_segment' => 'integration', 'permission_codes' => ['integration.site.manage']],
['slug' => 'prd.wallet_reconcile.manage', 'name' => '钱包对账·可管理', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.manage', 'service.reconcile.manage', 'service.wallet.adjust']],
['slug' => 'prd.wallet_reconcile.view', 'name' => '钱包对账·查看', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.view', 'service.reconcile.view']],
['slug' => 'prd.wallet_reconcile.view_cs', 'name' => '钱包对账·客服单用户', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.view', 'service.reconcile.view']],
@@ -93,6 +96,7 @@ final class AdminAuthorizationRegistry
'tickets' => '玩家注单',
'audit' => '审计日志',
'settings' => '系统设置',
'integration' => '接入站点',
];
return array_map(
@@ -135,6 +139,7 @@ final class AdminAuthorizationRegistry
['segment' => 'reconcile', 'label' => 'Reconcile', 'href' => '/admin/reconcile', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
['segment' => 'reports', 'label' => 'Reports', 'href' => '/admin/reports', 'requiredAny' => ['prd.report.view']],
['segment' => 'currencies', 'label' => 'Currencies', 'href' => '/admin/currencies', 'requiredAny' => ['prd.currency.manage']],
['segment' => 'integration', 'label' => 'Integration sites', 'href' => '/admin/config/integration-sites', 'activeMatchPrefix' => '/admin/config/integration-sites', 'requiredAny' => ['prd.integration.view', 'prd.integration.manage']],
// 权限与系统
['segment' => 'admin_users', 'label' => 'Admin Users', 'href' => '/admin/admin-users', 'requiredAny' => ['prd.admin_user.manage']],
['segment' => 'admin_roles', 'label' => 'Admin Roles', 'href' => '/admin/admin-roles', 'requiredAny' => ['prd.admin_role.manage']],
@@ -212,6 +217,7 @@ final class AdminAuthorizationRegistry
'tickets' => ['prd.tickets.view'],
'audit' => ['prd.audit.view'],
'settings' => ['prd.wallet_reconcile.manage', 'prd.currency.manage'],
'integration' => ['prd.integration.view', 'prd.integration.manage'],
];
if (isset($explicit[$segment])) {
@@ -387,6 +393,14 @@ final class AdminAuthorizationRegistry
['code' => 'admin.currencies.update', 'module_code' => 'settings', 'name' => '更新币种', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/currencies/{currency}', 'route_name' => 'api.v1.admin.currencies.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.currency.manage']],
['code' => 'admin.currencies.destroy', 'module_code' => 'settings', 'name' => '删除币种', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/currencies/{currency}', 'route_name' => 'api.v1.admin.currencies.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.currency.manage']],
['code' => 'admin.integration-sites.index', 'module_code' => 'integration', 'name' => '接入站点列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/integration-sites', 'route_name' => 'api.v1.admin.integration-sites.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['integration.site.view', 'integration.site.manage']],
['code' => 'admin.integration-sites.store', 'module_code' => 'integration', 'name' => '创建接入站点', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/integration-sites', 'route_name' => 'api.v1.admin.integration-sites.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['integration.site.manage']],
['code' => 'admin.integration-sites.show', 'module_code' => 'integration', 'name' => '接入站点详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}', 'route_name' => 'api.v1.admin.integration-sites.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['integration.site.view', 'integration.site.manage']],
['code' => 'admin.integration-sites.update', 'module_code' => 'integration', 'name' => '更新接入站点', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}', 'route_name' => 'api.v1.admin.integration-sites.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['integration.site.manage']],
['code' => 'admin.integration-sites.rotate-secrets', 'module_code' => 'integration', 'name' => '重置接入密钥', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}/rotate-secrets', 'route_name' => 'api.v1.admin.integration-sites.rotate-secrets', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['integration.site.manage']],
['code' => 'admin.integration-sites.connectivity-test', 'module_code' => 'integration', 'name' => '接入站点联通检测', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}/connectivity-test', 'route_name' => 'api.v1.admin.integration-sites.connectivity-test', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['integration.site.view', 'integration.site.manage']],
['code' => 'admin.integration-sites.export', 'module_code' => 'integration', 'name' => '导出接入参数表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}/export', 'route_name' => 'api.v1.admin.integration-sites.export', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['integration.site.view', 'integration.site.manage']],
['code' => 'admin.draws.index', 'module_code' => 'draw', 'name' => '期开奖列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws', 'route_name' => 'api.v1.admin.draws.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],
['code' => 'admin.draws.show', 'module_code' => 'draw', 'name' => '期开奖详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}', 'route_name' => 'api.v1.admin.draws.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],
['code' => 'admin.draws.finance-summary', 'module_code' => 'draw', 'name' => '期开奖资金摘要', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/finance-summary', 'route_name' => 'api.v1.admin.draws.finance-summary', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Support;
use App\Models\AdminSite;
use App\Models\AdminUser;
use Illuminate\Database\Eloquent\Builder;
final class AdminIntegrationSiteAccess
{
/**
* @return Builder<AdminSite>
*/
public static function queryFor(AdminUser $admin): Builder
{
$query = AdminSite::query()->orderBy('code');
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds === null) {
return $query;
}
if ($siteIds === []) {
return $query->whereRaw('0 = 1');
}
return $query->whereIn('id', $siteIds);
}
public static function canAccess(AdminUser $admin, AdminSite $site): bool
{
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds === null) {
return true;
}
return in_array((int) $site->id, $siteIds, true);
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace App\Support;
use App\Models\AdminSite;
final class AdminIntegrationSitePresenter
{
/**
* @return array<string, mixed>
*/
public static function listItem(AdminSite $site): array
{
return [
'id' => (int) $site->id,
'code' => (string) $site->code,
'name' => (string) $site->name,
'currency_code' => (string) $site->currency_code,
'status' => (int) $site->status,
'wallet_api_url' => $site->wallet_api_url,
'wallet_timeout_seconds' => (int) ($site->wallet_timeout_seconds ?? 10),
'has_sso_secret' => is_string($site->sso_jwt_secret_encrypted) && $site->sso_jwt_secret_encrypted !== '',
'has_wallet_api_key' => is_string($site->wallet_api_key_encrypted) && $site->wallet_api_key_encrypted !== '',
'sso_secret_masked' => is_string($site->sso_jwt_secret_encrypted) && $site->sso_jwt_secret_encrypted !== ''
? '••••••••'
: null,
'wallet_api_key_masked' => is_string($site->wallet_api_key_encrypted) && $site->wallet_api_key_encrypted !== ''
? '••••••••'
: null,
'updated_at' => $site->updated_at?->toIso8601String(),
];
}
/**
* @return array<string, mixed>
*/
public static function detail(AdminSite $site): array
{
return array_merge(self::listItem($site), [
'wallet_debit_path' => (string) $site->wallet_debit_path,
'wallet_credit_path' => (string) $site->wallet_credit_path,
'wallet_balance_path' => (string) $site->wallet_balance_path,
'iframe_allowed_origins' => $site->iframe_allowed_origins ?? [],
'lottery_h5_base_url' => $site->lottery_h5_base_url,
'notes' => $site->notes,
'is_default' => (bool) $site->is_default,
'created_at' => $site->created_at?->toIso8601String(),
]);
}
/**
* @param array{sso_jwt_secret: string, wallet_api_key: string} $secrets
* @return array<string, mixed>
*/
public static function withPlainSecretsOnce(array $payload, array $secrets): array
{
return array_merge($payload, [
'secrets' => [
'sso_jwt_secret' => $secrets['sso_jwt_secret'],
'wallet_api_key' => $secrets['wallet_api_key'],
],
'secrets_display_once' => true,
]);
}
/**
* 交接/联调参数表(不含密钥明文)。
*
* @return array<string, mixed>
*/
public static function parameterSheet(AdminSite $site): array
{
$walletBase = is_string($site->wallet_api_url) && $site->wallet_api_url !== ''
? rtrim($site->wallet_api_url, '/')
: null;
$fullUrl = static function (?string $path) use ($walletBase): ?string {
if ($walletBase === null || ! is_string($path) || $path === '') {
return null;
}
return $walletBase.'/'.ltrim($path, '/');
};
$h5Base = $site->lottery_h5_base_url;
if (! is_string($h5Base) || trim($h5Base) === '') {
$h5Base = config('lottery.main_site.base_url');
}
return [
'exported_at' => now()->toIso8601String(),
'site_code' => (string) $site->code,
'name' => (string) $site->name,
'status' => (int) $site->status === 1 ? 'enabled' : 'disabled',
'currency_code' => (string) $site->currency_code,
'lottery_h5_base_url' => $h5Base,
'wallet_api_url' => $site->wallet_api_url,
'wallet_balance_url' => $fullUrl($site->wallet_balance_path),
'wallet_debit_url' => $fullUrl($site->wallet_debit_path),
'wallet_credit_url' => $fullUrl($site->wallet_credit_path),
'wallet_balance_path' => (string) $site->wallet_balance_path,
'wallet_debit_path' => (string) $site->wallet_debit_path,
'wallet_credit_path' => (string) $site->wallet_credit_path,
'wallet_timeout_seconds' => (int) ($site->wallet_timeout_seconds ?? 10),
'iframe_allowed_origins' => $site->iframe_allowed_origins ?? [],
'sso_secret_configured' => is_string($site->sso_jwt_secret_encrypted) && $site->sso_jwt_secret_encrypted !== '',
'sso_secret_masked' => is_string($site->sso_jwt_secret_encrypted) && $site->sso_jwt_secret_encrypted !== ''
? '••••••••'
: null,
'wallet_api_key_configured' => is_string($site->wallet_api_key_encrypted) && $site->wallet_api_key_encrypted !== '',
'wallet_api_key_masked' => is_string($site->wallet_api_key_encrypted) && $site->wallet_api_key_encrypted !== ''
? '••••••••'
: null,
'notes' => $site->notes,
'security_note' => '密钥明文仅于创建/重置时展示一次,请勿通过导出或邮件长期传播。',
];
}
}

View File

@@ -0,0 +1,155 @@
<?php
namespace App\Support;
use App\Models\AdminSite;
use App\Models\AdminUser;
use App\Models\Player;
use App\Lottery\ErrorCode;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
/**
* 后台站点数据范围:非超管仅可访问 {@see AdminUser::accessibleAdminSiteIds()} 绑定站点。
*
* 对应产品「site_only / site_all_data」运行时以 admin_user_site_roles 为准(非已废弃的 admin_data_scopes 表)。
*/
final class AdminSiteScope
{
/**
* @return list<string>|null `null` 表示不限制(超管)
*/
public static function accessibleSiteCodes(AdminUser $admin): ?array
{
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds === null) {
return null;
}
if ($siteIds === []) {
return [];
}
return AdminSite::query()
->whereIn('id', $siteIds)
->orderBy('code')
->pluck('code')
->map(static fn ($code): string => (string) $code)
->values()
->all();
}
public static function siteCodeAllowed(AdminUser $admin, string $siteCode): bool
{
$allowed = self::accessibleSiteCodes($admin);
if ($allowed === null) {
return true;
}
return in_array($siteCode, $allowed, true);
}
public static function playerAccessible(AdminUser $admin, Player $player): bool
{
return self::siteCodeAllowed($admin, (string) $player->site_code);
}
/**
* @param Builder<Player> $query
*/
public static function applyToPlayerQuery(Builder $query, AdminUser $admin): void
{
$codes = self::accessibleSiteCodes($admin);
if ($codes === null) {
return;
}
if ($codes === []) {
$query->whereRaw('0 = 1');
return;
}
$query->whereIn('site_code', $codes);
}
/**
* 在站点范围基础上,可选按请求的 site_code 再收窄。
*
* @param Builder<Player> $query
*/
public static function applyPlayerFilters(Builder $query, AdminUser $admin, ?string $requestedSiteCode): void
{
self::applyToPlayerQuery($query, $admin);
$siteCode = is_string($requestedSiteCode) ? trim($requestedSiteCode) : '';
if ($siteCode === '') {
return;
}
if (! self::siteCodeAllowed($admin, $siteCode)) {
$query->whereRaw('0 = 1');
return;
}
$query->where('site_code', $siteCode);
}
/**
* @param Builder<mixed> $query
*/
public static function applyViaPlayerRelation(Builder $query, AdminUser $admin, string $relation = 'player'): void
{
$codes = self::accessibleSiteCodes($admin);
if ($codes === null) {
return;
}
if ($codes === []) {
$query->whereRaw('0 = 1');
return;
}
$query->whereHas($relation, static function (Builder $playerQuery) use ($codes): void {
$playerQuery->whereIn('site_code', $codes);
});
}
/**
* @param Builder<mixed> $query
*/
public static function applyViaPlayerRelationWithSiteCode(
Builder $query,
AdminUser $admin,
?string $requestedSiteCode,
string $relation = 'player',
): void {
self::applyViaPlayerRelation($query, $admin, $relation);
$siteCode = is_string($requestedSiteCode) ? trim($requestedSiteCode) : '';
if ($siteCode === '') {
return;
}
if (! self::siteCodeAllowed($admin, $siteCode)) {
$query->whereRaw('0 = 1');
return;
}
$query->whereHas($relation, static function (Builder $playerQuery) use ($siteCode): void {
$playerQuery->where('site_code', $siteCode);
});
}
public static function denyUnlessPlayerAccessible(AdminUser $admin, Player $player): ?JsonResponse
{
if (! self::playerAccessible($admin, $player)) {
return ApiResponse::error('无权访问该站点下的玩家', ErrorCode::AdminForbidden->value, null, 403);
}
return null;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Support;
final class IntegrationSiteSecretMask
{
public static function mask(?string $plain): ?string
{
if ($plain === null || $plain === '') {
return null;
}
$len = strlen($plain);
if ($len <= 8) {
return str_repeat('*', $len);
}
return substr($plain, 0, 4).str_repeat('*', max(4, $len - 8)).substr($plain, -4);
}
}