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

@@ -0,0 +1,117 @@
<?php
namespace App\Services\Integration;
use App\Models\AdminSite;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
final class IntegrationSiteService
{
public function __construct(
private readonly PartnerSiteConfigResolver $configResolver,
) {}
/**
* @param array<string, mixed> $data
* @return array{site: AdminSite, secrets: array{sso_jwt_secret: string, wallet_api_key: string}}
*/
public function create(array $data): array
{
$secrets = $this->generateSecrets();
$site = DB::transaction(function () use ($data, $secrets): AdminSite {
return AdminSite::query()->create([
'code' => (string) $data['code'],
'name' => (string) $data['name'],
'currency_code' => (string) ($data['currency_code'] ?? 'NPR'),
'status' => (int) ($data['status'] ?? 1),
'is_default' => false,
'wallet_api_url' => $this->nullableTrim($data['wallet_api_url'] ?? null),
'wallet_debit_path' => (string) ($data['wallet_debit_path'] ?? '/wallet/debit-for-lottery'),
'wallet_credit_path' => (string) ($data['wallet_credit_path'] ?? '/wallet/credit-from-lottery'),
'wallet_balance_path' => (string) ($data['wallet_balance_path'] ?? '/wallet/balance'),
'wallet_timeout_seconds' => max(1, (int) ($data['wallet_timeout_seconds'] ?? 10)),
'iframe_allowed_origins' => $data['iframe_allowed_origins'] ?? null,
'lottery_h5_base_url' => $this->nullableTrim($data['lottery_h5_base_url'] ?? null),
'notes' => $this->nullableTrim($data['notes'] ?? null),
'sso_jwt_secret_encrypted' => encrypt($secrets['sso_jwt_secret']),
'wallet_api_key_encrypted' => encrypt($secrets['wallet_api_key']),
]);
});
$this->configResolver->forgetCache((string) $site->code);
return ['site' => $site->fresh(), 'secrets' => $secrets];
}
/**
* @param array<string, mixed> $data
*/
public function update(AdminSite $site, array $data): AdminSite
{
$site->fill([
'name' => (string) $data['name'],
'currency_code' => (string) ($data['currency_code'] ?? $site->currency_code),
'status' => (int) ($data['status'] ?? $site->status),
'wallet_api_url' => array_key_exists('wallet_api_url', $data)
? $this->nullableTrim($data['wallet_api_url'])
: $site->wallet_api_url,
'wallet_debit_path' => (string) ($data['wallet_debit_path'] ?? $site->wallet_debit_path),
'wallet_credit_path' => (string) ($data['wallet_credit_path'] ?? $site->wallet_credit_path),
'wallet_balance_path' => (string) ($data['wallet_balance_path'] ?? $site->wallet_balance_path),
'wallet_timeout_seconds' => max(1, (int) ($data['wallet_timeout_seconds'] ?? $site->wallet_timeout_seconds)),
'iframe_allowed_origins' => $data['iframe_allowed_origins'] ?? $site->iframe_allowed_origins,
'lottery_h5_base_url' => array_key_exists('lottery_h5_base_url', $data)
? $this->nullableTrim($data['lottery_h5_base_url'])
: $site->lottery_h5_base_url,
'notes' => array_key_exists('notes', $data)
? $this->nullableTrim($data['notes'])
: $site->notes,
]);
$site->save();
$this->configResolver->forgetCache((string) $site->code);
return $site->fresh();
}
/**
* @return array{site: AdminSite, secrets: array{sso_jwt_secret: string, wallet_api_key: string}}
*/
public function rotateSecrets(AdminSite $site): array
{
$secrets = $this->generateSecrets();
$site->forceFill([
'sso_jwt_secret_encrypted' => encrypt($secrets['sso_jwt_secret']),
'wallet_api_key_encrypted' => encrypt($secrets['wallet_api_key']),
])->save();
$this->configResolver->forgetCache((string) $site->code);
return ['site' => $site->fresh(), 'secrets' => $secrets];
}
/**
* @return array{sso_jwt_secret: string, wallet_api_key: string}
*/
private function generateSecrets(): array
{
return [
'sso_jwt_secret' => Str::random(48),
'wallet_api_key' => Str::random(40),
];
}
private function nullableTrim(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Services\Integration;
/**
* 运行时主站接入配置(由库表或 legacy env 解析而来)。
*/
final readonly class PartnerSiteConfig
{
public const SOURCE_DATABASE = 'database';
public const SOURCE_LEGACY_ENV = 'legacy_env';
public function __construct(
public string $siteCode,
public bool $enabled,
public ?string $walletApiUrl,
public string $walletDebitPath,
public string $walletCreditPath,
public string $walletBalancePath,
public ?string $ssoJwtSecret,
public ?string $walletApiKey,
public int $walletTimeoutSeconds,
public string $source,
) {}
public function hasWalletApi(): bool
{
return is_string($this->walletApiUrl) && trim($this->walletApiUrl) !== '';
}
public function hasSsoSecret(): bool
{
return is_string($this->ssoJwtSecret) && $this->ssoJwtSecret !== '';
}
/**
* 可安全写入 Cache 的数组形态(避免 readonly 对象序列化产生 __PHP_Incomplete_Class
*
* @return array<string, mixed>
*/
public function toCacheArray(): array
{
return [
'site_code' => $this->siteCode,
'enabled' => $this->enabled,
'wallet_api_url' => $this->walletApiUrl,
'wallet_debit_path' => $this->walletDebitPath,
'wallet_credit_path' => $this->walletCreditPath,
'wallet_balance_path' => $this->walletBalancePath,
'sso_jwt_secret' => $this->ssoJwtSecret,
'wallet_api_key' => $this->walletApiKey,
'wallet_timeout_seconds' => $this->walletTimeoutSeconds,
'source' => $this->source,
];
}
/**
* @param array<string, mixed> $data
*/
public static function fromCacheArray(array $data): self
{
return new self(
siteCode: (string) ($data['site_code'] ?? ''),
enabled: (bool) ($data['enabled'] ?? false),
walletApiUrl: isset($data['wallet_api_url']) && is_string($data['wallet_api_url']) && $data['wallet_api_url'] !== ''
? $data['wallet_api_url']
: null,
walletDebitPath: (string) ($data['wallet_debit_path'] ?? '/wallet/debit-for-lottery'),
walletCreditPath: (string) ($data['wallet_credit_path'] ?? '/wallet/credit-from-lottery'),
walletBalancePath: (string) ($data['wallet_balance_path'] ?? '/wallet/balance'),
ssoJwtSecret: isset($data['sso_jwt_secret']) && is_string($data['sso_jwt_secret']) && $data['sso_jwt_secret'] !== ''
? $data['sso_jwt_secret']
: null,
walletApiKey: isset($data['wallet_api_key']) && is_string($data['wallet_api_key']) && $data['wallet_api_key'] !== ''
? $data['wallet_api_key']
: null,
walletTimeoutSeconds: max(1, (int) ($data['wallet_timeout_seconds'] ?? 10)),
source: (string) ($data['source'] ?? self::SOURCE_DATABASE),
);
}
}

View File

@@ -0,0 +1,164 @@
<?php
namespace App\Services\Integration;
use App\Models\AdminSite;
use App\Models\Player;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* {@see site_code} 解析主站对接配置;未命中库表时回退全局 MAIN_SITE_* env并写 warning 日志)。
*/
final class PartnerSiteConfigResolver
{
private const CACHE_PREFIX = 'partner_site_config:';
private const CACHE_TTL_SECONDS = 60;
public function resolveForPlayer(Player $player): PartnerSiteConfig
{
return $this->resolveBySiteCode((string) $player->site_code);
}
public function resolveBySiteCode(string $siteCode): PartnerSiteConfig
{
$siteCode = trim($siteCode);
if ($siteCode === '') {
return $this->legacyFallbackConfig($siteCode);
}
$cacheKey = self::CACHE_PREFIX.$siteCode;
/** @var array<string, mixed> $cached */
$cached = Cache::remember($cacheKey, self::CACHE_TTL_SECONDS, function () use ($siteCode): array {
$site = AdminSite::query()->where('code', $siteCode)->first();
$config = $site !== null
? $this->fromAdminSite($site)
: $this->legacyFallbackConfig($siteCode);
return $config->toCacheArray();
});
return PartnerSiteConfig::fromCacheArray($cached);
}
public function forgetCache(string $siteCode): void
{
Cache::forget(self::CACHE_PREFIX.trim($siteCode));
}
/**
* 从未验签的 JWT 中读取 site_code仅用于选取验签密钥
*/
public function peekSiteCodeFromJwt(string $jwt): ?string
{
$jwt = trim($jwt);
$parts = explode('.', $jwt);
if (count($parts) !== 3) {
return null;
}
$payloadJson = $this->base64UrlDecode($parts[1]);
if ($payloadJson === null) {
return null;
}
$payload = json_decode($payloadJson, true);
if (! is_array($payload)) {
return null;
}
$siteKey = (string) config('lottery.player_auth.jwt.claim_site_code', 'site_code');
$siteCode = $payload[$siteKey] ?? null;
return is_string($siteCode) && $siteCode !== '' ? $siteCode : null;
}
private function fromAdminSite(AdminSite $site): PartnerSiteConfig
{
return new PartnerSiteConfig(
siteCode: (string) $site->code,
enabled: $site->isEnabled(),
walletApiUrl: is_string($site->wallet_api_url) && $site->wallet_api_url !== ''
? rtrim($site->wallet_api_url, '/')
: null,
walletDebitPath: (string) ($site->wallet_debit_path ?: '/wallet/debit-for-lottery'),
walletCreditPath: (string) ($site->wallet_credit_path ?: '/wallet/credit-from-lottery'),
walletBalancePath: (string) ($site->wallet_balance_path ?: '/wallet/balance'),
ssoJwtSecret: $site->decryptedSsoJwtSecret(),
walletApiKey: $site->decryptedWalletApiKey(),
walletTimeoutSeconds: max(1, (int) ($site->wallet_timeout_seconds ?? 10)),
source: PartnerSiteConfig::SOURCE_DATABASE,
);
}
private function legacyFallbackConfig(string $siteCode): PartnerSiteConfig
{
$defaultCode = (string) config('lottery.integration.default_site_code', 'default_site');
$legacyCodes = array_filter([
$defaultCode,
(string) config('lottery.integration.legacy_env_site_code', ''),
]);
$sso = config('lottery.main_site.sso_jwt_secret');
$walletUrl = config('lottery.main_site.wallet_api_url');
$walletKey = config('lottery.main_site.wallet_api_key');
$hasLegacy = (is_string($sso) && $sso !== '')
|| (is_string($walletUrl) && trim((string) $walletUrl) !== '');
if ($hasLegacy && (
$siteCode === ''
|| in_array($siteCode, $legacyCodes, true)
|| app()->environment(['local', 'testing'])
)) {
if ($siteCode !== '') {
Log::warning('partner_site_config.legacy_env_fallback', [
'site_code' => $siteCode,
'hint' => 'Configure admin_sites row for this site_code',
]);
}
return new PartnerSiteConfig(
siteCode: $siteCode !== '' ? $siteCode : $defaultCode,
enabled: true,
walletApiUrl: is_string($walletUrl) && trim($walletUrl) !== ''
? rtrim(trim($walletUrl), '/')
: null,
walletDebitPath: (string) config('lottery.main_site.wallet_debit_path', '/wallet/debit-for-lottery'),
walletCreditPath: (string) config('lottery.main_site.wallet_credit_path', '/wallet/credit-from-lottery'),
walletBalancePath: (string) config('lottery.main_site.wallet_balance_path', '/wallet/balance'),
ssoJwtSecret: is_string($sso) && $sso !== '' ? $sso : null,
walletApiKey: is_string($walletKey) && $walletKey !== '' ? $walletKey : null,
walletTimeoutSeconds: max(1, (int) config('lottery.main_site.wallet_timeout', 10)),
source: PartnerSiteConfig::SOURCE_LEGACY_ENV,
);
}
return new PartnerSiteConfig(
siteCode: $siteCode,
enabled: false,
walletApiUrl: null,
walletDebitPath: '/wallet/debit-for-lottery',
walletCreditPath: '/wallet/credit-from-lottery',
walletBalancePath: '/wallet/balance',
ssoJwtSecret: null,
walletApiKey: null,
walletTimeoutSeconds: 10,
source: PartnerSiteConfig::SOURCE_DATABASE,
);
}
private function base64UrlDecode(string $segment): ?string
{
$remainder = strlen($segment) % 4;
if ($remainder > 0) {
$segment .= str_repeat('=', 4 - $remainder);
}
$decoded = base64_decode(strtr($segment, '-_', '+/'), true);
return $decoded === false ? null : $decoded;
}
}