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

37
--no-progress Normal file
View File

@@ -0,0 +1,37 @@
[00:00:00.010986042 / 00:00:00.010986042] [9599616 bytes] Test Runner Configured
[00:00:00.020327292 / 00:00:00.009341250] [12034144 bytes] PHPUnit Started (PHPUnit 12.5.24 using PHP 8.5.5 (cli) on Darwin)
[00:00:00.021274209 / 00:00:00.000946917] [12040696 bytes] Test Runner Configured
[00:00:00.021449250 / 00:00:00.000175041] [12079864 bytes] Bootstrap Finished (/Users/kang/Work/lotterySystem/lotterLaravel/vendor/autoload.php)
[00:00:00.023767959 / 00:00:00.002318709] [12398648 bytes] Event Facade Sealed
[00:00:00.046732459 / 00:00:00.022964500] [22973880 bytes] Test Suite Loaded (265 tests)
[00:00:00.047165417 / 00:00:00.000432958] [23094456 bytes] Test Runner Started
[00:00:00.047533125 / 00:00:00.000367708] [23199176 bytes] Test Suite Sorted
[00:00:00.050435750 / 00:00:00.002902625] [23387240 bytes] Test Suite Filtered (1 test)
[00:00:00.050934834 / 00:00:00.000499084] [23387240 bytes] Test Runner Execution Started (1 test)
[00:00:00.051671750 / 00:00:00.000736916] [23441552 bytes] Test Suite Started (/Users/kang/Work/lotterySystem/lotterLaravel/phpunit.xml, 1 test)
[00:00:00.052156000 / 00:00:00.000484250] [23441552 bytes] Test Suite Started (Feature, 1 test)
[00:00:00.052311250 / 00:00:00.000155250] [23441552 bytes] Test Suite Started (P\Tests\Feature\AdminIntegrationSiteApiTest, 1 test)
[00:00:00.052695167 / 00:00:00.000383917] [23441552 bytes] Before First Test Method Called (P\Tests\Feature\AdminIntegrationSiteApiTest::setUpBeforeClass)
[00:00:00.052720459 / 00:00:00.000025292] [23441552 bytes] Before First Test Method Finished:
- P\Tests\Feature\AdminIntegrationSiteApiTest::setUpBeforeClass
[00:00:00.053158709 / 00:00:00.000438250] [23441552 bytes] Test Preparation Started (P\Tests\Feature\AdminIntegrationSiteApiTest::__pest_evaluable_super_admin_can_create_integration_site_and_receive_secrets_once)
[00:00:00.229504042 / 00:00:00.176345333] [53442936 bytes] Before Test Method Called (P\Tests\Feature\AdminIntegrationSiteApiTest::setUp)
[00:00:00.229581417 / 00:00:00.000077375] [53442936 bytes] Before Test Method Finished:
- P\Tests\Feature\AdminIntegrationSiteApiTest::setUp
[00:00:00.229620167 / 00:00:00.000038750] [53287848 bytes] Test Prepared (P\Tests\Feature\AdminIntegrationSiteApiTest::__pest_evaluable_super_admin_can_create_integration_site_and_receive_secrets_once)
[00:00:00.243854542 / 00:00:00.014234375] [56820064 bytes] Test Failed (P\Tests\Feature\AdminIntegrationSiteApiTest::__pest_evaluable_super_admin_can_create_integration_site_and_receive_secrets_once)
Expected response status code [201] but received 500.
Failed asserting that 500 is identical to 201.
[00:00:00.247484959 / 00:00:00.003630417] [58276136 bytes] After Test Method Called (P\Tests\Feature\AdminIntegrationSiteApiTest::tearDown)
[00:00:00.247537625 / 00:00:00.000052666] [58276136 bytes] After Test Method Finished:
- P\Tests\Feature\AdminIntegrationSiteApiTest::tearDown
[00:00:00.248189209 / 00:00:00.000651584] [58276136 bytes] Test Finished (P\Tests\Feature\AdminIntegrationSiteApiTest::__pest_evaluable_super_admin_can_create_integration_site_and_receive_secrets_once)
[00:00:00.248247167 / 00:00:00.000057958] [58276136 bytes] After Last Test Method Called (P\Tests\Feature\AdminIntegrationSiteApiTest::tearDownAfterClass)
[00:00:00.248271375 / 00:00:00.000024208] [58276136 bytes] After Last Test Method Finished:
- P\Tests\Feature\AdminIntegrationSiteApiTest::tearDownAfterClass
[00:00:00.248293000 / 00:00:00.000021625] [58276136 bytes] Test Suite Finished (P\Tests\Feature\AdminIntegrationSiteApiTest, 1 test)
[00:00:00.249083834 / 00:00:00.000790834] [58276136 bytes] Test Suite Finished (Feature, 1 test)
[00:00:00.249148292 / 00:00:00.000064458] [58276136 bytes] Test Suite Finished (/Users/kang/Work/lotterySystem/lotterLaravel/phpunit.xml, 1 test)
[00:00:00.249474334 / 00:00:00.000326042] [58276136 bytes] Test Runner Execution Finished
[00:00:00.249581250 / 00:00:00.000106916] [58276136 bytes] Test Runner Finished
[00:00:00.249956709 / 00:00:00.000375459] [58276136 bytes] PHPUnit Finished (Shell Exit Code: 1)

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Integration;
use App\Models\AdminSite;
use App\Models\Player;
use App\Support\ApiResponse;
use App\Lottery\ErrorCode;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\Wallet\HttpMainSiteWalletBalanceClient;
use App\Support\AdminIntegrationSiteAccess;
use App\Http\Requests\Admin\AdminIntegrationSiteConnectivityTestRequest;
final class AdminIntegrationSiteConnectivityTestController extends Controller
{
public function __invoke(
AdminIntegrationSiteConnectivityTestRequest $request,
AdminSite $admin_site,
HttpMainSiteWalletBalanceClient $balanceClient,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if (! AdminIntegrationSiteAccess::canAccess($admin, $admin_site)) {
return ApiResponse::error('无权访问该站点', ErrorCode::AdminForbidden->value, null, 403);
}
$sitePlayerId = trim((string) $request->validated('site_player_id'));
$currencyCode = trim((string) ($request->validated('currency_code') ?? $admin_site->currency_code ?? 'NPR'));
if ($currencyCode === '') {
$currencyCode = 'NPR';
}
$player = Player::query()
->where('site_code', $admin_site->code)
->where('site_player_id', $sitePlayerId)
->first();
$playerSource = 'database';
if ($player === null) {
$playerSource = 'synthetic';
$player = new Player([
'site_code' => $admin_site->code,
'site_player_id' => $sitePlayerId,
'default_currency' => $currencyCode,
]);
}
$probe = $balanceClient->probe($player, $currencyCode);
return ApiResponse::success([
'site_code' => (string) $admin_site->code,
'site_player_id' => $sitePlayerId,
'player_source' => $playerSource,
'probe' => $probe->toArray(),
]);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Integration;
use App\Models\AdminSite;
use App\Support\ApiResponse;
use App\Lottery\ErrorCode;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminIntegrationSiteAccess;
use App\Support\AdminIntegrationSitePresenter;
use Symfony\Component\HttpFoundation\StreamedResponse;
final class AdminIntegrationSiteExportController extends Controller
{
public function __invoke(Request $request, AdminSite $admin_site): JsonResponse|StreamedResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if (! AdminIntegrationSiteAccess::canAccess($admin, $admin_site)) {
return ApiResponse::error('无权访问该站点', ErrorCode::AdminForbidden->value, null, 403);
}
$sheet = AdminIntegrationSitePresenter::parameterSheet($admin_site);
$format = strtolower(trim((string) $request->query('format', 'json')));
if ($format === 'csv') {
$filename = 'integration-'.$admin_site->code.'-'.now()->format('Ymd-His').'.csv';
return response()->streamDownload(function () use ($sheet): void {
$out = fopen('php://output', 'w');
fputcsv($out, ['field', 'value']);
foreach ($sheet as $key => $value) {
if (is_array($value)) {
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
}
fputcsv($out, [(string) $key, is_scalar($value) || $value === null ? (string) $value : json_encode($value)]);
}
fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
return ApiResponse::success($sheet);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Integration;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminIntegrationSiteAccess;
use App\Support\AdminIntegrationSitePresenter;
final class AdminIntegrationSiteIndexController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$items = AdminIntegrationSiteAccess::queryFor($admin)
->get()
->map(static fn ($site): array => AdminIntegrationSitePresenter::listItem($site))
->all();
return ApiResponse::success(['items' => $items]);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Integration;
use App\Models\AdminSite;
use App\Support\ApiResponse;
use App\Lottery\ErrorCode;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\AuditLogger;
use App\Services\Integration\IntegrationSiteService;
use App\Support\AdminIntegrationSiteAccess;
use App\Support\AdminIntegrationSitePresenter;
use App\Http\Middleware\RecordAdminApiAudit;
final class AdminIntegrationSiteRotateSecretsController extends Controller
{
public function __invoke(
Request $request,
AdminSite $admin_site,
IntegrationSiteService $service,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if (! AdminIntegrationSiteAccess::canAccess($admin, $admin_site)) {
return ApiResponse::error('无权操作该站点', ErrorCode::AdminForbidden->value, null, 403);
}
$result = $service->rotateSecrets($admin_site);
$site = $result['site'];
$payload = AdminIntegrationSitePresenter::withPlainSecretsOnce(
AdminIntegrationSitePresenter::detail($site),
$result['secrets'],
);
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: 'integration',
actionCode: 'rotate_secrets',
targetType: 'admin_site',
targetId: (string) $site->id,
afterJson: ['code' => $site->code, 'rotated' => true],
);
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
return ApiResponse::success($payload);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Integration;
use App\Models\AdminSite;
use App\Support\ApiResponse;
use App\Lottery\ErrorCode;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminIntegrationSiteAccess;
use App\Support\AdminIntegrationSitePresenter;
final class AdminIntegrationSiteShowController extends Controller
{
public function __invoke(Request $request, AdminSite $admin_site): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if (! AdminIntegrationSiteAccess::canAccess($admin, $admin_site)) {
return ApiResponse::error('无权访问该站点', ErrorCode::AdminForbidden->value, null, 403);
}
return ApiResponse::success(AdminIntegrationSitePresenter::detail($admin_site));
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Integration;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\AuditLogger;
use App\Services\Integration\IntegrationSiteService;
use App\Support\AdminIntegrationSitePresenter;
use App\Http\Requests\Admin\AdminIntegrationSiteStoreRequest;
use App\Http\Middleware\RecordAdminApiAudit;
final class AdminIntegrationSiteStoreController extends Controller
{
public function __invoke(
AdminIntegrationSiteStoreRequest $request,
IntegrationSiteService $service,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$result = $service->create($request->validated());
$site = $result['site'];
$payload = AdminIntegrationSitePresenter::withPlainSecretsOnce(
AdminIntegrationSitePresenter::detail($site),
$result['secrets'],
);
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: 'integration',
actionCode: 'create',
targetType: 'admin_site',
targetId: (string) $site->id,
afterJson: AdminIntegrationSitePresenter::detail($site),
);
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
return ApiResponse::success($payload)->setStatusCode(201);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Integration;
use App\Models\AdminSite;
use App\Support\ApiResponse;
use App\Lottery\ErrorCode;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\AuditLogger;
use App\Services\Integration\IntegrationSiteService;
use App\Support\AdminIntegrationSiteAccess;
use App\Support\AdminIntegrationSitePresenter;
use App\Http\Requests\Admin\AdminIntegrationSiteUpdateRequest;
use App\Http\Middleware\RecordAdminApiAudit;
final class AdminIntegrationSiteUpdateController extends Controller
{
public function __invoke(
AdminIntegrationSiteUpdateRequest $request,
AdminSite $admin_site,
IntegrationSiteService $service,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if (! AdminIntegrationSiteAccess::canAccess($admin, $admin_site)) {
return ApiResponse::error('无权修改该站点', ErrorCode::AdminForbidden->value, null, 403);
}
$before = AdminIntegrationSitePresenter::detail($admin_site);
$site = $service->update($admin_site, $request->validated());
$after = AdminIntegrationSitePresenter::detail($site);
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: 'integration',
actionCode: 'update',
targetType: 'admin_site',
targetId: (string) $site->id,
beforeJson: $before,
afterJson: $after,
);
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
return ApiResponse::success($after);
}
}

View File

@@ -8,6 +8,7 @@ use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminSiteScope;
use Illuminate\Database\Eloquent\Relations\HasMany;
/** DELETE /api/v1/admin/players/{player} */
@@ -15,6 +16,13 @@ final class AdminPlayerDestroyController extends Controller
{
public function __invoke(Request $request, Player $player): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if ($denied = AdminSiteScope::denyUnlessPlayerAccessible($admin, $player)) {
return $denied;
}
$hasWallets = Player::query()
->whereKey($player->getKey())
->whereHas('wallets', static fn (HasMany $q) => $q->whereRaw('balance != 0'))

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Player;
use App\Models\Player;
use App\Support\ApiResponse;
use App\Support\AdminSiteScope;
use App\Support\PlayerApiPresenter;
use App\Http\Controllers\Controller;
use App\Services\AuditLogger;
@@ -15,6 +16,13 @@ final class AdminPlayerFreezeController extends Controller
{
public function __invoke(Request $request, Player $player): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if ($denied = AdminSiteScope::denyUnlessPlayerAccessible($admin, $player)) {
return $denied;
}
$before = PlayerApiPresenter::listItem($player);
$player->forceFill(['status' => 1])->save();

View File

@@ -8,6 +8,7 @@ use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminApiList;
use App\Support\AdminSiteScope;
use App\Support\PlayerApiPresenter;
/** GET /api/v1/admin/players */
@@ -15,14 +16,24 @@ final class AdminPlayerIndexController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$p = AdminApiList::readPaging($request);
$keyword = trim((string) $request->query('keyword', ''));
$status = $request->query('status');
$siteCode = $request->query('site_code');
$q = Player::query()
->with(['wallets' => static fn ($wq) => $wq->orderBy('wallet_type')->orderBy('currency_code')])
->orderByDesc('id');
AdminSiteScope::applyPlayerFilters(
$q,
$admin,
is_string($siteCode) ? $siteCode : null,
);
if ($keyword !== '') {
$term = '%'.addcslashes($keyword, '%_\\').'%';
$q->where(static function ($sub) use ($term): void {

View File

@@ -4,14 +4,24 @@ namespace App\Http\Controllers\Api\V1\Admin\Player;
use App\Models\Player;
use App\Support\ApiResponse;
use App\Support\AdminSiteScope;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Support\PlayerApiPresenter;
/** GET /api/v1/admin/players/{player} */
final class AdminPlayerShowController extends Controller
{
public function __invoke(Player $player): JsonResponse
public function __invoke(Request $request, Player $player): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if ($denied = AdminSiteScope::denyUnlessPlayerAccessible($admin, $player)) {
return $denied;
}
return ApiResponse::success(PlayerApiPresenter::listItem($player));
}
}

View File

@@ -6,6 +6,7 @@ use App\Models\Player;
use App\Lottery\ErrorCode;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use App\Support\AdminSiteScope;
use App\Support\PlayerApiPresenter;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\AdminPlayerStoreRequest;
@@ -15,6 +16,19 @@ final class AdminPlayerStoreController extends Controller
{
public function __invoke(AdminPlayerStoreRequest $request): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$siteCode = (string) $request->validated('site_code');
if (! AdminSiteScope::siteCodeAllowed($admin, $siteCode)) {
return ApiResponse::error(
'无权在该站点下创建玩家',
ErrorCode::AdminForbidden->value,
null,
403,
);
}
$exists = Player::query()
->where('site_code', $request->validated('site_code'))
->where('site_player_id', $request->validated('site_player_id'))

View File

@@ -7,6 +7,7 @@ use App\Models\TicketItem;
use App\Support\ApiResponse;
use App\Support\CurrencyFormatter;
use App\Support\PaginationTrait;
use App\Support\AdminSiteScope;
use App\Support\TicketItemListFilters;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
@@ -24,6 +25,13 @@ final class AdminPlayerTicketItemsIndexController extends Controller
public function __invoke(AdminPlayerTicketItemsRequest $request, Player $player): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if ($denied = AdminSiteScope::denyUnlessPlayerAccessible($admin, $player)) {
return $denied;
}
$perPage = $this->perPage($request, 'per_page', 10, 50);
$page = $this->page($request);
$drawNo = $request->validated('draw_no');

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Player;
use App\Models\Player;
use App\Support\ApiResponse;
use App\Support\AdminSiteScope;
use App\Support\PlayerApiPresenter;
use App\Http\Controllers\Controller;
use App\Services\AuditLogger;
@@ -15,6 +16,13 @@ final class AdminPlayerUnfreezeController extends Controller
{
public function __invoke(Request $request, Player $player): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if ($denied = AdminSiteScope::denyUnlessPlayerAccessible($admin, $player)) {
return $denied;
}
$before = PlayerApiPresenter::listItem($player);
$player->forceFill(['status' => 0])->save();

View File

@@ -7,6 +7,7 @@ use App\Lottery\ErrorCode;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminSiteScope;
use App\Support\PlayerApiPresenter;
use App\Http\Requests\Admin\AdminPlayerUpdateRequest;
@@ -15,6 +16,13 @@ final class AdminPlayerUpdateController extends Controller
{
public function __invoke(AdminPlayerUpdateRequest $request, Player $player): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if ($denied = AdminSiteScope::denyUnlessPlayerAccessible($admin, $player)) {
return $denied;
}
$data = $request->validated();
if (isset($data['status'])) {

View File

@@ -5,8 +5,10 @@ namespace App\Http\Controllers\Api\V1\Admin\Player;
use App\Models\Player;
use App\Models\PlayerWallet;
use App\Support\ApiResponse;
use App\Support\AdminSiteScope;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
/**
* 后台:按玩家查询钱包余额(`player_wallets` 全币种)。
@@ -15,8 +17,15 @@ use App\Http\Controllers\Controller;
*/
final class PlayerWalletShowController extends Controller
{
public function __invoke(Player $player): JsonResponse
public function __invoke(Request $request, Player $player): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if ($denied = AdminSiteScope::denyUnlessPlayerAccessible($admin, $player)) {
return $denied;
}
$wallets = PlayerWallet::query()
->where('player_id', $player->id)
->orderBy('wallet_type')

View File

@@ -8,8 +8,10 @@ use App\Models\TicketItem;
use App\Support\ApiResponse;
use App\Support\CurrencyFormatter;
use App\Support\PaginationTrait;
use App\Support\AdminSiteScope;
use App\Support\TicketItemListFilters;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 后台:全量注单列表。
@@ -30,6 +32,9 @@ final class AdminTicketItemIndexController extends Controller
public function __invoke(TicketItemListRequest $request): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$validated = $request->validated();
$perPage = $this->perPage($request, 'per_page', 10, 100);
@@ -81,6 +86,12 @@ final class AdminTicketItemIndexController extends Controller
is_string($validated['end_date'] ?? null) ? $validated['end_date'] : null,
);
AdminSiteScope::applyViaPlayerRelationWithSiteCode(
$query,
$admin,
is_string($validated['site_code'] ?? null) ? $validated['site_code'] : null,
);
$paginator = $query->paginate(perPage: $perPage, page: $page, columns: ['*']);
$items = collect($paginator->items())->map(function (TicketItem $row): array {

View File

@@ -7,6 +7,7 @@ use App\Support\ApiResponse;
use App\Models\TransferOrder;
use App\Support\PaginationTrait;
use Illuminate\Http\JsonResponse;
use App\Support\AdminSiteScope;
use App\Support\CurrencyFormatter;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\TransferOrderListRequest;
@@ -34,6 +35,9 @@ final class TransferOrderListController extends Controller
public function __invoke(TransferOrderListRequest $request): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$validated = $request->validated();
$perPage = $this->perPage($request, 'per_page', 10, 100);
@@ -81,7 +85,12 @@ final class TransferOrderListController extends Controller
}
}
$admin = $request->lotteryAdmin();
AdminSiteScope::applyViaPlayerRelationWithSiteCode(
$query,
$admin,
is_string($validated['site_code'] ?? null) ? $validated['site_code'] : null,
);
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
$items = $paginator->getCollection()->map(
fn (TransferOrder $o) => $this->formatRow($o, $admin instanceof AdminUser ? $admin : null),

View File

@@ -6,6 +6,7 @@ use App\Models\WalletTxn;
use App\Support\ApiResponse;
use App\Support\PaginationTrait;
use Illuminate\Http\JsonResponse;
use App\Support\AdminSiteScope;
use App\Support\CurrencyFormatter;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\WalletTransactionListRequest;
@@ -33,6 +34,9 @@ final class WalletTransactionListController extends Controller
public function __invoke(WalletTransactionListRequest $request): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$validated = $request->validated();
$perPage = $this->perPage($request, 'per_page', 10, 100);
@@ -84,6 +88,12 @@ final class WalletTransactionListController extends Controller
}
}
AdminSiteScope::applyViaPlayerRelationWithSiteCode(
$query,
$admin,
is_string($validated['site_code'] ?? null) ? $validated['site_code'] : null,
);
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
$items = $paginator->getCollection()->map(fn (WalletTxn $t) => $this->formatRow($t));

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
/**
* @see \App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteConnectivityTestController
*/
final class AdminIntegrationSiteConnectivityTestRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'site_player_id' => ['required', 'string', 'max:128'],
'currency_code' => ['sometimes', 'nullable', 'string', 'max:16'],
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class AdminIntegrationSiteStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'code' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9][a-z0-9_-]*$/', Rule::unique('admin_sites', 'code')],
'name' => ['required', 'string', 'max:128'],
'currency_code' => ['sometimes', 'string', 'max:16'],
'status' => ['sometimes', 'integer', 'in:0,1'],
'wallet_api_url' => ['nullable', 'string', 'max:512'],
'wallet_debit_path' => ['sometimes', 'string', 'max:128'],
'wallet_credit_path' => ['sometimes', 'string', 'max:128'],
'wallet_balance_path' => ['sometimes', 'string', 'max:128'],
'wallet_timeout_seconds' => ['sometimes', 'integer', 'min:1', 'max:120'],
'iframe_allowed_origins' => ['nullable', 'array'],
'iframe_allowed_origins.*' => ['string', 'max:512'],
'lottery_h5_base_url' => ['nullable', 'string', 'max:512'],
'notes' => ['nullable', 'string', 'max:5000'],
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
final class AdminIntegrationSiteUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:128'],
'currency_code' => ['sometimes', 'string', 'max:16'],
'status' => ['sometimes', 'integer', 'in:0,1'],
'wallet_api_url' => ['nullable', 'string', 'max:512'],
'wallet_debit_path' => ['sometimes', 'string', 'max:128'],
'wallet_credit_path' => ['sometimes', 'string', 'max:128'],
'wallet_balance_path' => ['sometimes', 'string', 'max:128'],
'wallet_timeout_seconds' => ['sometimes', 'integer', 'min:1', 'max:120'],
'iframe_allowed_origins' => ['nullable', 'array'],
'iframe_allowed_origins.*' => ['string', 'max:512'],
'lottery_h5_base_url' => ['nullable', 'string', 'max:512'],
'notes' => ['nullable', 'string', 'max:5000'],
];
}
}

View File

@@ -27,6 +27,7 @@ final class TicketItemListRequest extends FormRequest
'size' => ['sometimes', 'integer', 'min:1', 'max:100'],
'player_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'player_account' => ['sometimes', 'nullable', 'string', 'max:128'],
'site_code' => ['sometimes', 'nullable', 'string', 'max:64'],
'draw_no' => ['sometimes', 'nullable', 'string', 'max:32'],
'status' => ['sometimes'],
'status.*' => ['string', 'max:32'],

View File

@@ -27,6 +27,7 @@ final class TransferOrderListRequest extends FormRequest
'size' => ['sometimes', 'integer', 'min:1', 'max:100'],
'player_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'player_account' => ['sometimes', 'nullable', 'string', 'max:128'],
'site_code' => ['sometimes', 'nullable', 'string', 'max:64'],
'transfer_no' => ['sometimes', 'nullable', 'string', 'max:96'],
'external_ref_no' => ['sometimes', 'nullable', 'string', 'max:96'],
'created_from' => ['sometimes', 'nullable', 'date_format:Y-m-d'],

View File

@@ -27,6 +27,7 @@ final class WalletTransactionListRequest extends FormRequest
'size' => ['sometimes', 'integer', 'min:1', 'max:100'],
'player_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'player_account' => ['sometimes', 'nullable', 'string', 'max:128'],
'site_code' => ['sometimes', 'nullable', 'string', 'max:64'],
'txn_no' => ['sometimes', 'nullable', 'string', 'max:96'],
'external_ref_no' => ['sometimes', 'nullable', 'string', 'max:96'],
'created_from' => ['sometimes', 'nullable', 'date_format:Y-m-d'],

73
app/Models/AdminSite.php Normal file
View File

@@ -0,0 +1,73 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* 后台站点 / 主站接入配置({@see $table admin_sites})。
*
* `code` 对外即 JWT 与钱包回调中的 `site_code`,创建后不可修改。
*/
final class AdminSite extends Model
{
protected $table = 'admin_sites';
protected $fillable = [
'code',
'name',
'currency_code',
'status',
'is_default',
'extra_json',
'wallet_api_url',
'wallet_debit_path',
'wallet_credit_path',
'wallet_balance_path',
'wallet_api_key_encrypted',
'sso_jwt_secret_encrypted',
'wallet_timeout_seconds',
'iframe_allowed_origins',
'lottery_h5_base_url',
'notes',
];
protected function casts(): array
{
return [
'status' => 'integer',
'is_default' => 'boolean',
'extra_json' => 'array',
'iframe_allowed_origins' => 'array',
'wallet_timeout_seconds' => 'integer',
];
}
public function isEnabled(): bool
{
return (int) $this->status === 1;
}
public function decryptedSsoJwtSecret(): ?string
{
return $this->decryptSecret($this->sso_jwt_secret_encrypted);
}
public function decryptedWalletApiKey(): ?string
{
return $this->decryptSecret($this->wallet_api_key_encrypted);
}
private function decryptSecret(?string $encrypted): ?string
{
if (! is_string($encrypted) || $encrypted === '') {
return null;
}
try {
return decrypt($encrypted);
} catch (\Throwable) {
return null;
}
}
}

View File

@@ -114,6 +114,28 @@ final class AdminUser extends Authenticatable
return $this->roles()->where('admin_roles.slug', self::ROLE_SUPER_ADMIN)->exists();
}
/**
* 可访问的 admin_sites.id 列表;`null` 表示不限制(超管)。
*
* @return list<int>|null
*/
public function accessibleAdminSiteIds(): ?array
{
if ($this->isSuperAdmin()) {
return null;
}
$ids = DB::table('admin_user_site_roles')
->where('admin_user_id', $this->id)
->distinct()
->pluck('site_id')
->map(static fn ($id): int => (int) $id)
->values()
->all();
return $ids;
}
/**
* 仅来自「直接授权」的 menu_action.permission_code默认站点 site_id null 的历史行)。
*

View File

@@ -19,14 +19,7 @@ final class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
$this->app->singleton(MainSiteWalletGateway::class, function (): MainSiteWalletGateway {
$url = config('lottery.main_site.wallet_api_url');
if (! is_string($url) || trim($url) === '') {
return new StubMainSiteWalletGateway;
}
return new HttpMainSiteWalletGateway;
});
$this->app->singleton(MainSiteWalletGateway::class, HttpMainSiteWalletGateway::class);
}
/**

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;
}
}

View File

@@ -10,6 +10,7 @@ use Illuminate\Http\Request;
use App\Support\PlayerTokenAesUnwrap;
use Illuminate\Database\QueryException;
use App\Exceptions\PlayerAuthenticationException;
use App\Services\Integration\PartnerSiteConfigResolver;
/**
* 从请求头解析玩家身份,返回已落库的 {@see Player}
@@ -36,6 +37,10 @@ final class PlayerTokenResolver
/** players.status与迁移注释一致 */
private const PLAYER_STATUS_ACTIVE = 0;
public function __construct(
private readonly PartnerSiteConfigResolver $partnerSiteConfigResolver,
) {}
public function resolve(Request $request): Player
{
$header = $request->header('Authorization', '');
@@ -56,17 +61,27 @@ final class PlayerTokenResolver
if ($this->devBypassAllowed() && str_starts_with($token, 'dev:')) {
$player = $this->resolveDevToken($token);
} else {
// 与 .env 中 MAIN_SITE_SSO_JWT_SECRET 一致,用于 firebase/php-jwt 验签
$secret = config('lottery.main_site.sso_jwt_secret');
$jwtPlain = $this->unwrapOpaqueToJwtString($token);
$siteCode = $this->partnerSiteConfigResolver->peekSiteCodeFromJwt($jwtPlain);
if ($siteCode === null) {
throw new PlayerAuthenticationException('JWT 缺少站点标识', ErrorCode::PlayerTokenInvalid->value);
}
$siteConfig = $this->partnerSiteConfigResolver->resolveBySiteCode($siteCode);
if (! $siteConfig->enabled) {
throw new PlayerAuthenticationException('站点已停用', ErrorCode::PlayerAccountSuspended->value, 403);
}
$secret = $siteConfig->ssoJwtSecret;
if (! is_string($secret) || $secret === '') {
throw new PlayerAuthenticationException(
'SSO 未配置(MAIN_SITE_SSO_JWT_SECRET',
'SSO 未配置(站点 '.$siteCode.'',
ErrorCode::PlayerSsoSecretNotConfigured->value,
503,
);
}
$player = $this->resolveJwtOrAesWrappedJwt($token, $secret);
$player = $this->resolveJwt($jwtPlain, $secret);
}
$this->assertPlayerActive($player);

View File

@@ -4,58 +4,156 @@ namespace App\Services\Wallet;
use App\Models\Player;
use Illuminate\Support\Facades\Http;
use App\Services\Integration\PartnerSiteConfigResolver;
/**
* 查询主站钱包余额(供玩家端余额接口填充 main_balance
*/
final class HttpMainSiteWalletBalanceClient
{
public function __construct(
private readonly PartnerSiteConfigResolver $partnerSiteConfigResolver,
) {}
public function fetch(Player $player, string $currencyCode): ?int
{
$base = rtrim((string) config('lottery.main_site.wallet_api_url'), '/');
if ($base === '') {
return null;
$probe = $this->probe($player, $currencyCode);
return $probe->success ? $probe->mainBalanceMinor : null;
}
$path = (string) config('lottery.main_site.wallet_balance_path', '/wallet/balance');
public function probe(Player $player, string $currencyCode): MainSiteWalletBalanceProbeResult
{
$config = $this->partnerSiteConfigResolver->resolveForPlayer($player);
$currencyCode = trim($currencyCode) !== '' ? trim($currencyCode) : 'NPR';
if (! $config->enabled) {
return new MainSiteWalletBalanceProbeResult(
success: false,
mainBalanceMinor: null,
currencyCode: $currencyCode,
requestUrl: '',
httpStatus: null,
message: '接入站点已停用或未配置',
responseBody: null,
);
}
if (! $config->hasWalletApi()) {
return new MainSiteWalletBalanceProbeResult(
success: false,
mainBalanceMinor: null,
currencyCode: $currencyCode,
requestUrl: '',
httpStatus: null,
message: '未配置主站钱包 API URL',
responseBody: null,
);
}
$base = rtrim((string) $config->walletApiUrl, '/');
$path = $config->walletBalancePath;
$url = $base.'/'.ltrim($path, '/');
$timeout = (int) config('lottery.main_site.wallet_timeout', 10);
$apiKey = config('lottery.main_site.wallet_api_key');
$timeout = $config->walletTimeoutSeconds;
$apiKey = $config->walletApiKey;
$headers = ['Accept' => 'application/json'];
if (is_string($apiKey) && $apiKey !== '') {
$headers['Authorization'] = 'Bearer '.$apiKey;
}
$query = [
'site_code' => $player->site_code,
'site_player_id' => $player->site_player_id,
'currency_code' => $currencyCode,
];
try {
$response = Http::withHeaders($headers)
->timeout($timeout)
->acceptJson()
->get($url, [
'site_code' => $player->site_code,
'site_player_id' => $player->site_player_id,
'currency_code' => $currencyCode,
]);
} catch (\Throwable) {
return null;
->get($url, $query);
} catch (\Throwable $e) {
return new MainSiteWalletBalanceProbeResult(
success: false,
mainBalanceMinor: null,
currencyCode: $currencyCode,
requestUrl: $url.'?'.http_build_query($query),
httpStatus: null,
message: '请求失败: '.$e->getMessage(),
responseBody: null,
);
}
$httpStatus = $response->status();
$payload = $response->json();
$preview = is_array($payload) ? self::truncateResponsePreview($payload) : null;
if (! $response->successful()) {
return null;
$message = is_array($payload) && is_string($payload['message'] ?? null)
? (string) $payload['message']
: 'HTTP '.$httpStatus;
return new MainSiteWalletBalanceProbeResult(
success: false,
mainBalanceMinor: null,
currencyCode: $currencyCode,
requestUrl: $url.'?'.http_build_query($query),
httpStatus: $httpStatus,
message: $message,
responseBody: $preview,
);
}
$payload = $response->json();
if (! is_array($payload)) {
return null;
return new MainSiteWalletBalanceProbeResult(
success: false,
mainBalanceMinor: null,
currencyCode: $currencyCode,
requestUrl: $url.'?'.http_build_query($query),
httpStatus: $httpStatus,
message: '响应不是 JSON 对象',
responseBody: null,
);
}
$raw = data_get($payload, 'data.main_balance')
?? data_get($payload, 'main_balance');
if (! is_numeric($raw)) {
return null;
return new MainSiteWalletBalanceProbeResult(
success: false,
mainBalanceMinor: null,
currencyCode: $currencyCode,
requestUrl: $url.'?'.http_build_query($query),
httpStatus: $httpStatus,
message: '响应缺少 main_balance 数值',
responseBody: $preview,
);
}
return max(0, (int) $raw);
return new MainSiteWalletBalanceProbeResult(
success: true,
mainBalanceMinor: max(0, (int) $raw),
currencyCode: (string) (data_get($payload, 'data.currency_code') ?? $currencyCode),
requestUrl: $url.'?'.http_build_query($query),
httpStatus: $httpStatus,
message: null,
responseBody: $preview,
);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private static function truncateResponsePreview(array $payload): array
{
$json = json_encode($payload, JSON_UNESCAPED_UNICODE);
if (is_string($json) && strlen($json) > 512) {
return ['_truncated' => true, 'preview' => substr($json, 0, 512)];
}
return $payload;
}
}

View File

@@ -5,24 +5,32 @@ namespace App\Services\Wallet;
use App\Models\Player;
use Illuminate\Support\Facades\Http;
use GuzzleHttp\Exception\ConnectException;
use App\Services\Integration\PartnerSiteConfigResolver;
/**
* 通过 HTTP 调用主站钱包 API路径见 config lottery.main_site.wallet_*_path
*/
final class HttpMainSiteWalletGateway implements MainSiteWalletGateway
{
public function __construct(
private readonly PartnerSiteConfigResolver $partnerSiteConfigResolver,
) {}
public function debitMainForLotteryDeposit(
Player $player,
string $currencyCode,
int $amountMinor,
string $idempotentKey,
): MainSiteWalletResult {
$config = $this->partnerSiteConfigResolver->resolveForPlayer($player);
return $this->post(
(string) config('lottery.main_site.wallet_debit_path'),
$config->walletDebitPath,
$player,
$currencyCode,
$amountMinor,
$idempotentKey,
$config,
);
}
@@ -32,12 +40,15 @@ final class HttpMainSiteWalletGateway implements MainSiteWalletGateway
int $amountMinor,
string $idempotentKey,
): MainSiteWalletResult {
$config = $this->partnerSiteConfigResolver->resolveForPlayer($player);
return $this->post(
(string) config('lottery.main_site.wallet_credit_path'),
$config->walletCreditPath,
$player,
$currencyCode,
$amountMinor,
$idempotentKey,
$config,
);
}
@@ -47,11 +58,24 @@ final class HttpMainSiteWalletGateway implements MainSiteWalletGateway
string $currencyCode,
int $amountMinor,
string $idempotentKey,
\App\Services\Integration\PartnerSiteConfig $config,
): MainSiteWalletResult {
$base = rtrim((string) config('lottery.main_site.wallet_api_url'), '/');
if (! $config->hasWalletApi()) {
return MainSiteWalletResult::success(null, ['stub' => true, 'reason' => 'wallet_api_not_configured'], [
'site_code' => $player->site_code,
'site_player_id' => $player->site_player_id,
'player_id' => $player->id,
'currency_code' => $currencyCode,
'amount_minor' => $amountMinor,
'idempotent_key' => $idempotentKey,
'_meta' => ['stub' => true],
]);
}
$base = rtrim((string) $config->walletApiUrl, '/');
$url = $base.'/'.ltrim($path, '/');
$timeout = (int) config('lottery.main_site.wallet_timeout', 10);
$apiKey = config('lottery.main_site.wallet_api_key');
$timeout = $config->walletTimeoutSeconds;
$apiKey = $config->walletApiKey;
$requestBody = [
'site_code' => $player->site_code,

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Services\Wallet;
/**
* 主站 balance 探测结果(后台联调检测用,含诊断信息)。
*/
final readonly class MainSiteWalletBalanceProbeResult
{
public function __construct(
public bool $success,
public ?int $mainBalanceMinor,
public string $currencyCode,
public string $requestUrl,
public ?int $httpStatus,
public ?string $message,
public ?array $responseBody,
) {}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'success' => $this->success,
'main_balance_minor' => $this->mainBalanceMinor,
'currency_code' => $this->currencyCode,
'request_url' => $this->requestUrl,
'http_status' => $this->httpStatus,
'message' => $this->message,
'response_preview' => $this->responseBody,
];
}
}

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);
}
}

View File

@@ -40,6 +40,14 @@ return [
'wallet_balance_path' => env('MAIN_SITE_WALLET_BALANCE_PATH', '/wallet/balance'),
],
/*
| integration主站接入站点admin_sites 扩展字段);未配置库表时可回退 main_site env。
*/
'integration' => [
'default_site_code' => env('LOTTERY_DEFAULT_SITE_CODE', 'default_site'),
'legacy_env_site_code' => env('LOTTERY_LEGACY_MAIN_SITE_CODE', 'default_site'),
],
/*
| player_auth配合 app/Services/PlayerTokenResolver.php
|

View File

@@ -0,0 +1,237 @@
<?php
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use App\Support\AdminAuthorizationRegistry;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
Schema::table('admin_sites', function (Blueprint $table): void {
$table->string('wallet_api_url', 512)->nullable()->after('extra_json');
$table->string('wallet_debit_path', 128)->default('/wallet/debit-for-lottery')->after('wallet_api_url');
$table->string('wallet_credit_path', 128)->default('/wallet/credit-from-lottery')->after('wallet_debit_path');
$table->string('wallet_balance_path', 128)->default('/wallet/balance')->after('wallet_credit_path');
$table->text('wallet_api_key_encrypted')->nullable()->after('wallet_balance_path');
$table->text('sso_jwt_secret_encrypted')->nullable()->after('wallet_api_key_encrypted');
$table->unsignedSmallInteger('wallet_timeout_seconds')->default(10)->after('sso_jwt_secret_encrypted');
$table->json('iframe_allowed_origins')->nullable()->after('wallet_timeout_seconds');
$table->string('lottery_h5_base_url', 512)->nullable()->after('iframe_allowed_origins');
$table->text('notes')->nullable()->after('lottery_h5_base_url');
});
$this->seedIntegrationMenuActions();
$this->backfillDefaultSiteFromEnv();
$this->syncIntegrationApiResources();
}
public function down(): void
{
$resourceIds = DB::table('admin_api_resources')
->where('code', 'like', 'admin.integration-sites.%')
->pluck('id')
->all();
if ($resourceIds !== []) {
DB::table('admin_role_api_resources')->whereIn('api_resource_id', $resourceIds)->delete();
DB::table('admin_api_resource_bindings')->whereIn('api_resource_id', $resourceIds)->delete();
DB::table('admin_api_resources')->whereIn('id', $resourceIds)->delete();
}
Schema::table('admin_sites', function (Blueprint $table): void {
$table->dropColumn([
'wallet_api_url',
'wallet_debit_path',
'wallet_credit_path',
'wallet_balance_path',
'wallet_api_key_encrypted',
'sso_jwt_secret_encrypted',
'wallet_timeout_seconds',
'iframe_allowed_origins',
'lottery_h5_base_url',
'notes',
]);
});
}
private function seedIntegrationMenuActions(): void
{
$now = Carbon::now();
$viewActionId = DB::table('admin_action_catalog')->where('code', 'view')->value('id');
$manageActionId = DB::table('admin_action_catalog')->where('code', 'manage')->value('id');
if ($viewActionId === null || $manageActionId === null) {
return;
}
$configMenuId = DB::table('admin_menus')->where('code', 'config')->value('id');
if ($configMenuId === null) {
return;
}
$integrationMenuId = DB::table('admin_menus')->where('code', 'config.integration')->value('id');
if ($integrationMenuId === null) {
$integrationMenuId = DB::table('admin_menus')->insertGetId([
'parent_id' => (int) $configMenuId,
'menu_type' => 'page',
'code' => 'config.integration',
'name' => '主站接入站点',
'path' => '/admin/config/integration-sites',
'route_name' => 'admin.config.integration',
'component' => 'config/integration',
'icon' => null,
'active_menu_code' => null,
'sort_order' => 45,
'is_visible' => true,
'is_cache' => false,
'is_external' => false,
'status' => 1,
'meta_json' => null,
'created_at' => $now,
'updated_at' => $now,
]);
}
foreach ([
['permission_code' => 'integration.site.view', 'action_id' => (int) $viewActionId, 'name' => '接入站点查看'],
['permission_code' => 'integration.site.manage', 'action_id' => (int) $manageActionId, 'name' => '接入站点管理'],
] as $row) {
$exists = DB::table('admin_menu_actions')
->where('permission_code', $row['permission_code'])
->exists();
if ($exists) {
continue;
}
DB::table('admin_menu_actions')->insert([
'menu_id' => (int) $integrationMenuId,
'action_id' => $row['action_id'],
'permission_code' => $row['permission_code'],
'name' => $row['name'],
'status' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
private function backfillDefaultSiteFromEnv(): void
{
$siteId = DB::table('admin_sites')->where('is_default', true)->value('id')
?? DB::table('admin_sites')->orderBy('id')->value('id');
if ($siteId === null) {
return;
}
$walletUrl = env('MAIN_SITE_WALLET_API_URL');
$ssoSecret = env('MAIN_SITE_SSO_JWT_SECRET');
$walletKey = env('MAIN_SITE_WALLET_API_KEY');
$payload = [
'updated_at' => Carbon::now(),
];
if (is_string($walletUrl) && trim($walletUrl) !== '') {
$payload['wallet_api_url'] = rtrim(trim($walletUrl), '/');
}
$debitPath = env('MAIN_SITE_WALLET_DEBIT_PATH');
if (is_string($debitPath) && $debitPath !== '') {
$payload['wallet_debit_path'] = $debitPath;
}
$creditPath = env('MAIN_SITE_WALLET_CREDIT_PATH');
if (is_string($creditPath) && $creditPath !== '') {
$payload['wallet_credit_path'] = $creditPath;
}
$balancePath = env('MAIN_SITE_WALLET_BALANCE_PATH');
if (is_string($balancePath) && $balancePath !== '') {
$payload['wallet_balance_path'] = $balancePath;
}
$timeout = env('MAIN_SITE_WALLET_TIMEOUT');
if (is_numeric($timeout)) {
$payload['wallet_timeout_seconds'] = max(1, (int) $timeout);
}
if (is_string($ssoSecret) && $ssoSecret !== '') {
$payload['sso_jwt_secret_encrypted'] = encrypt($ssoSecret);
}
if (is_string($walletKey) && $walletKey !== '') {
$payload['wallet_api_key_encrypted'] = encrypt($walletKey);
}
if (count($payload) > 1) {
DB::table('admin_sites')->where('id', (int) $siteId)->update($payload);
}
}
private function syncIntegrationApiResources(): void
{
if (! Schema::hasTable('admin_api_resources')) {
return;
}
$now = Carbon::now();
$menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code');
$resources = array_values(array_filter(
AdminAuthorizationRegistry::resources(),
static fn (array $resource): bool => str_starts_with((string) $resource['code'], 'admin.integration-sites.'),
));
foreach ($resources as $resource) {
$resourceId = DB::table('admin_api_resources')
->where('code', $resource['code'])
->value('id');
$payload = [
'module_code' => $resource['module_code'],
'name' => $resource['name'],
'http_method' => $resource['http_method'],
'uri_pattern' => $resource['uri_pattern'],
'route_name' => $resource['route_name'],
'auth_mode' => $resource['auth_mode'],
'is_audit_required' => $resource['is_audit_required'],
'status' => 1,
'meta_json' => null,
'updated_at' => $now,
];
if ($resourceId === null) {
$resourceId = DB::table('admin_api_resources')->insertGetId($payload + [
'code' => $resource['code'],
'created_at' => $now,
]);
} else {
DB::table('admin_api_resources')
->where('id', (int) $resourceId)
->update($payload);
}
DB::table('admin_api_resource_bindings')
->where('api_resource_id', (int) $resourceId)
->delete();
foreach ($resource['permission_codes'] as $permissionCode) {
$menuActionId = $menuActionIds[$permissionCode] ?? null;
if ($menuActionId === null) {
continue;
}
DB::table('admin_api_resource_bindings')->insert([
'api_resource_id' => (int) $resourceId,
'menu_action_id' => (int) $menuActionId,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
}
};

View File

@@ -0,0 +1,105 @@
<?php
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use App\Support\AdminAuthorizationRegistry;
use Illuminate\Database\Migrations\Migration;
/**
* 为已执行 140000 的环境补种 integration 权限动作并同步 API 绑定。
*/
return new class extends Migration
{
public function up(): void
{
$now = Carbon::now();
$viewActionId = DB::table('admin_action_catalog')->where('code', 'view')->value('id');
$manageActionId = DB::table('admin_action_catalog')->where('code', 'manage')->value('id');
if ($viewActionId === null || $manageActionId === null) {
return;
}
$configMenuId = DB::table('admin_menus')->where('code', 'config')->value('id');
if ($configMenuId === null) {
return;
}
$integrationMenuId = DB::table('admin_menus')->where('code', 'config.integration')->value('id');
if ($integrationMenuId === null) {
$integrationMenuId = DB::table('admin_menus')->insertGetId([
'parent_id' => (int) $configMenuId,
'menu_type' => 'page',
'code' => 'config.integration',
'name' => '主站接入站点',
'path' => '/admin/config/integration-sites',
'route_name' => 'admin.config.integration',
'component' => 'config/integration',
'icon' => null,
'active_menu_code' => null,
'sort_order' => 45,
'is_visible' => true,
'is_cache' => false,
'is_external' => false,
'status' => 1,
'meta_json' => null,
'created_at' => $now,
'updated_at' => $now,
]);
}
foreach ([
['permission_code' => 'integration.site.view', 'action_id' => (int) $viewActionId, 'name' => '接入站点查看'],
['permission_code' => 'integration.site.manage', 'action_id' => (int) $manageActionId, 'name' => '接入站点管理'],
] as $row) {
if (DB::table('admin_menu_actions')->where('permission_code', $row['permission_code'])->exists()) {
continue;
}
DB::table('admin_menu_actions')->insert([
'menu_id' => (int) $integrationMenuId,
'action_id' => $row['action_id'],
'permission_code' => $row['permission_code'],
'name' => $row['name'],
'status' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
}
$menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code');
$resources = array_values(array_filter(
AdminAuthorizationRegistry::resources(),
static fn (array $resource): bool => str_starts_with((string) $resource['code'], 'admin.integration-sites.'),
));
foreach ($resources as $resource) {
$resourceId = DB::table('admin_api_resources')->where('code', $resource['code'])->value('id');
if ($resourceId === null) {
continue;
}
DB::table('admin_api_resource_bindings')
->where('api_resource_id', (int) $resourceId)
->delete();
foreach ($resource['permission_codes'] as $permissionCode) {
$menuActionId = $menuActionIds[$permissionCode] ?? null;
if ($menuActionId === null) {
continue;
}
DB::table('admin_api_resource_bindings')->insert([
'api_resource_id' => (int) $resourceId,
'menu_action_id' => (int) $menuActionId,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
}
public function down(): void
{
// 保留 menu_actions / bindings避免回滚后超管无法管理已创建的接入站点。
}
};

View File

@@ -27,6 +27,7 @@ Route::prefix('v1')->group(function (): void {
require __DIR__.'/api/v1/admin/wallet.php';
require __DIR__.'/api/v1/admin/player.php';
require __DIR__.'/api/v1/admin/currency.php';
require __DIR__.'/api/v1/admin/integration.php';
require __DIR__.'/api/v1/admin/ticket.php';
require __DIR__.'/api/v1/admin/draw.php';
require __DIR__.'/api/v1/admin/jackpot.php';

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteIndexController;
use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteStoreController;
use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteShowController;
use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteUpdateController;
use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteRotateSecretsController;
use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteConnectivityTestController;
use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteExportController;
Route::middleware('admin.api-resource')
->group(function (): void {
Route::get('integration-sites', AdminIntegrationSiteIndexController::class)
->name('api.v1.admin.integration-sites.index');
Route::post('integration-sites', AdminIntegrationSiteStoreController::class)
->name('api.v1.admin.integration-sites.store');
Route::get('integration-sites/{admin_site}', AdminIntegrationSiteShowController::class)
->name('api.v1.admin.integration-sites.show');
Route::put('integration-sites/{admin_site}', AdminIntegrationSiteUpdateController::class)
->name('api.v1.admin.integration-sites.update');
Route::post('integration-sites/{admin_site}/rotate-secrets', AdminIntegrationSiteRotateSecretsController::class)
->name('api.v1.admin.integration-sites.rotate-secrets');
Route::post('integration-sites/{admin_site}/connectivity-test', AdminIntegrationSiteConnectivityTestController::class)
->name('api.v1.admin.integration-sites.connectivity-test');
Route::get('integration-sites/{admin_site}/export', AdminIntegrationSiteExportController::class)
->name('api.v1.admin.integration-sites.export');
});

View File

@@ -0,0 +1,325 @@
<?php
use App\Models\AdminSite;
use App\Models\AuditLog;
use App\Models\AdminUser;
use App\Models\Player;
use App\Services\Integration\PartnerSiteConfig;
use App\Services\Integration\PartnerSiteConfigResolver;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
function integrationAdminToken(): string
{
$admin = AdminUser::query()->create([
'username' => 'integration_admin',
'name' => 'Integration Admin',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
test('super admin can create integration site and receive secrets once', function (): void {
$token = integrationAdminToken();
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'partner-a',
'name' => 'Partner A',
'wallet_api_url' => 'https://wallet.partner-a.test',
'status' => 1,
]);
$response->assertCreated()
->assertJsonPath('code', 0)
->assertJsonPath('data.code', 'partner-a')
->assertJsonPath('data.secrets_display_once', true)
->assertJsonStructure([
'data' => [
'secrets' => ['sso_jwt_secret', 'wallet_api_key'],
],
]);
$site = AdminSite::query()->where('code', 'partner-a')->first();
expect($site)->not->toBeNull();
expect($site?->decryptedSsoJwtSecret())->not->toBeEmpty();
expect(AuditLog::query()->where('module_code', 'integration')->where('action_code', 'create')->exists())->toBeTrue();
});
test('integration site code cannot be changed on update', function (): void {
$token = integrationAdminToken();
$create = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'partner-b',
'name' => 'Partner B',
]);
$create->assertCreated();
$id = (int) $create->json('data.id');
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/integration-sites/'.$id, [
'name' => 'Partner B Renamed',
'status' => 1,
])
->assertOk()
->assertJsonPath('data.code', 'partner-b')
->assertJsonPath('data.name', 'Partner B Renamed');
});
test('partner site config resolver cache roundtrip returns partner site config', function (): void {
AdminSite::query()->create([
'code' => 'cache-roundtrip',
'name' => 'Cache',
'currency_code' => 'NPR',
'status' => 1,
'is_default' => false,
'sso_jwt_secret_encrypted' => encrypt('cache-sso'),
'wallet_api_key_encrypted' => encrypt('cache-wallet'),
]);
$resolver = app(PartnerSiteConfigResolver::class);
$first = $resolver->resolveBySiteCode('cache-roundtrip');
$second = $resolver->resolveBySiteCode('cache-roundtrip');
expect($second)->toBeInstanceOf(PartnerSiteConfig::class)
->and($second->ssoJwtSecret)->toBe('cache-sso');
});
test('partner site config resolver reads database secrets', function (): void {
AdminSite::query()->create([
'code' => 'partner-db',
'name' => 'DB Partner',
'currency_code' => 'NPR',
'status' => 1,
'is_default' => false,
'sso_jwt_secret_encrypted' => encrypt('db-sso-secret'),
'wallet_api_key_encrypted' => encrypt('db-wallet-key'),
'wallet_api_url' => 'https://wallet.db.test',
]);
$config = app(PartnerSiteConfigResolver::class)->resolveBySiteCode('partner-db');
expect($config->source)->toBe('database')
->and($config->ssoJwtSecret)->toBe('db-sso-secret')
->and($config->walletApiKey)->toBe('db-wallet-key')
->and($config->walletApiUrl)->toBe('https://wallet.db.test');
});
test('rotate secrets returns new plaintext once', function (): void {
$token = integrationAdminToken();
$create = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'partner-rotate',
'name' => 'Rotate',
]);
$id = (int) $create->json('data.id');
$oldSecret = (string) $create->json('data.secrets.sso_jwt_secret');
$rotate = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites/'.$id.'/rotate-secrets');
$rotate->assertOk();
$newSecret = (string) $rotate->json('data.secrets.sso_jwt_secret');
expect($newSecret)->not->toBe($oldSecret);
expect(AuditLog::query()->where('action_code', 'rotate_secrets')->exists())->toBeTrue();
});
test('connectivity test probes partner balance api', function (): void {
Http::fake([
'https://wallet.probe.test/*' => Http::response([
'success' => true,
'data' => ['main_balance' => 12345, 'currency_code' => 'NPR'],
], 200),
]);
$token = integrationAdminToken();
$create = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'probe-site',
'name' => 'Probe',
'wallet_api_url' => 'https://wallet.probe.test',
]);
$id = (int) $create->json('data.id');
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites/'.$id.'/connectivity-test', [
'site_player_id' => '10001',
'currency_code' => 'NPR',
]);
$response->assertOk()
->assertJsonPath('data.probe.success', true)
->assertJsonPath('data.probe.main_balance_minor', 12345);
});
test('export parameter sheet excludes plaintext secrets', function (): void {
$token = integrationAdminToken();
$create = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'export-site',
'name' => 'Export',
'wallet_api_url' => 'https://wallet.export.test',
]);
$id = (int) $create->json('data.id');
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/integration-sites/'.$id.'/export');
$response->assertOk()
->assertJsonPath('data.site_code', 'export-site')
->assertJsonPath('data.sso_secret_masked', '••••••••')
->assertJsonMissingPath('data.secrets')
->assertJsonMissingPath('data.sso_jwt_secret');
});
test('site scoped admin only sees bound integration sites', function (): void {
$token = integrationAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'site-a',
'name' => 'Site A',
])
->assertCreated();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'site-b',
'name' => 'Site B',
])
->assertCreated();
$siteAId = (int) AdminSite::query()->where('code', 'site-a')->value('id');
$siteBId = (int) AdminSite::query()->where('code', 'site-b')->value('id');
$scopedAdmin = AdminUser::query()->create([
'username' => 'integration_scoped',
'name' => 'Scoped',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$roleId = (int) DB::table('admin_roles')->insertGetId([
'slug' => 'integration_scoped_role',
'name' => 'Integration Scoped',
'code' => 'integration_scoped_role',
'created_at' => now(),
'updated_at' => now(),
]);
$viewActionId = (int) DB::table('admin_menu_actions')
->where('permission_code', 'integration.site.view')
->value('id');
DB::table('admin_role_menu_actions')->insert([
'role_id' => $roleId,
'menu_action_id' => $viewActionId,
]);
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $scopedAdmin->id,
'site_id' => $siteAId,
'role_id' => $roleId,
'granted_at' => now(),
]);
$scopedToken = $scopedAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken;
app('auth')->forgetGuards();
$list = $this->withHeader('Authorization', 'Bearer '.$scopedToken)
->getJson('/api/v1/admin/integration-sites')
->assertOk();
$codes = collect($list->json('data.items'))->pluck('code')->all();
expect($codes)->toContain('site-a')->not->toContain('site-b');
$this->withHeader('Authorization', 'Bearer '.$scopedToken)
->getJson('/api/v1/admin/integration-sites/'.$siteBId)
->assertForbidden();
});
test('player list is filtered by admin site binding', function (): void {
$this->seed(\Database\Seeders\CurrencySeeder::class);
Player::query()->create([
'site_code' => 'site-a',
'site_player_id' => 'pa-1',
'username' => 'pa1',
'nickname' => 'PA1',
'default_currency' => 'NPR',
'status' => 0,
]);
Player::query()->create([
'site_code' => 'site-b',
'site_player_id' => 'pb-1',
'username' => 'pb1',
'nickname' => 'PB1',
'default_currency' => 'NPR',
'status' => 0,
]);
AdminSite::query()->firstOrCreate(['code' => 'site-a'], ['name' => 'A', 'currency_code' => 'NPR', 'status' => 1]);
AdminSite::query()->firstOrCreate(['code' => 'site-b'], ['name' => 'B', 'currency_code' => 'NPR', 'status' => 1]);
$siteAId = (int) AdminSite::query()->where('code', 'site-a')->value('id');
$scopedAdmin = AdminUser::query()->create([
'username' => 'player_scoped',
'name' => 'Player Scoped',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$roleId = (int) DB::table('admin_roles')->insertGetId([
'slug' => 'player_scoped_role',
'name' => 'Player Scoped',
'code' => 'player_scoped_role',
'created_at' => now(),
'updated_at' => now(),
]);
$viewActionId = (int) DB::table('admin_menu_actions')
->where('permission_code', 'service.players.view')
->value('id');
DB::table('admin_role_menu_actions')->insert([
'role_id' => $roleId,
'menu_action_id' => $viewActionId,
]);
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $scopedAdmin->id,
'site_id' => $siteAId,
'role_id' => $roleId,
'granted_at' => now(),
]);
$scopedToken = $scopedAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$response = $this->withHeader('Authorization', 'Bearer '.$scopedToken)
->getJson('/api/v1/admin/players')
->assertOk();
$siteCodes = collect($response->json('data.items'))->pluck('site_code')->unique()->values()->all();
expect($siteCodes)->toBe(['site-a']);
});