From a10135d6eeeafd89ddbb5693c996592c061d6c5a Mon Sep 17 00:00:00 2001 From: kang Date: Wed, 27 May 2026 13:36:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E7=8E=A9=E5=AE=B6?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C=E9=9B=86=E6=88=90?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E7=AB=99=E7=82=B9=E6=9D=83=E9=99=90=E6=8E=A7?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在多个玩家相关控制器中引入 AdminSiteScope,确保管理员在执行操作前具备相应的接入站点权限。更新 Player 相关请求以支持 site_code 参数,增强权限验证逻辑,确保系统安全性与灵活性。同时,新增 AdminUser 模型方法以获取可访问的站点 ID 列表,优化权限管理。 --- --no-progress | 37 ++ ...egrationSiteConnectivityTestController.php | 60 ++++ .../AdminIntegrationSiteExportController.php | 47 +++ .../AdminIntegrationSiteIndexController.php | 26 ++ ...IntegrationSiteRotateSecretsController.php | 52 +++ .../AdminIntegrationSiteShowController.php | 27 ++ .../AdminIntegrationSiteStoreController.php | 45 +++ .../AdminIntegrationSiteUpdateController.php | 50 +++ .../Player/AdminPlayerDestroyController.php | 8 + .../Player/AdminPlayerFreezeController.php | 8 + .../Player/AdminPlayerIndexController.php | 11 + .../Player/AdminPlayerShowController.php | 12 +- .../Player/AdminPlayerStoreController.php | 14 + .../AdminPlayerTicketItemsIndexController.php | 8 + .../Player/AdminPlayerUnfreezeController.php | 8 + .../Player/AdminPlayerUpdateController.php | 8 + .../Player/PlayerWalletShowController.php | 11 +- .../Ticket/AdminTicketItemIndexController.php | 11 + .../Wallet/TransferOrderListController.php | 11 +- .../WalletTransactionListController.php | 10 + ...IntegrationSiteConnectivityTestRequest.php | 27 ++ .../AdminIntegrationSiteStoreRequest.php | 34 ++ .../AdminIntegrationSiteUpdateRequest.php | 32 ++ .../Requests/Admin/TicketItemListRequest.php | 1 + .../Admin/TransferOrderListRequest.php | 1 + .../Admin/WalletTransactionListRequest.php | 1 + app/Models/AdminSite.php | 73 ++++ app/Models/AdminUser.php | 22 ++ app/Providers/AppServiceProvider.php | 9 +- .../Integration/IntegrationSiteService.php | 117 +++++++ .../Integration/PartnerSiteConfig.php | 82 +++++ .../Integration/PartnerSiteConfigResolver.php | 164 +++++++++ app/Services/PlayerTokenResolver.php | 23 +- .../HttpMainSiteWalletBalanceClient.php | 134 +++++++- .../Wallet/HttpMainSiteWalletGateway.php | 34 +- .../MainSiteWalletBalanceProbeResult.php | 35 ++ app/Support/AdminAuthorizationRegistry.php | 14 + app/Support/AdminIntegrationSiteAccess.php | 39 +++ app/Support/AdminIntegrationSitePresenter.php | 118 +++++++ app/Support/AdminSiteScope.php | 155 +++++++++ app/Support/IntegrationSiteSecretMask.php | 20 ++ config/lottery.php | 8 + ..._add_integration_fields_to_admin_sites.php | 237 +++++++++++++ ...7_140001_seed_integration_menu_actions.php | 105 ++++++ routes/api.php | 1 + routes/api/v1/admin/integration.php | 28 ++ tests/Feature/AdminIntegrationSiteApiTest.php | 325 ++++++++++++++++++ 47 files changed, 2265 insertions(+), 38 deletions(-) create mode 100644 --no-progress create mode 100644 app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteConnectivityTestController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteExportController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteIndexController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteRotateSecretsController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteShowController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteStoreController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteUpdateController.php create mode 100644 app/Http/Requests/Admin/AdminIntegrationSiteConnectivityTestRequest.php create mode 100644 app/Http/Requests/Admin/AdminIntegrationSiteStoreRequest.php create mode 100644 app/Http/Requests/Admin/AdminIntegrationSiteUpdateRequest.php create mode 100644 app/Models/AdminSite.php create mode 100644 app/Services/Integration/IntegrationSiteService.php create mode 100644 app/Services/Integration/PartnerSiteConfig.php create mode 100644 app/Services/Integration/PartnerSiteConfigResolver.php create mode 100644 app/Services/Wallet/MainSiteWalletBalanceProbeResult.php create mode 100644 app/Support/AdminIntegrationSiteAccess.php create mode 100644 app/Support/AdminIntegrationSitePresenter.php create mode 100644 app/Support/AdminSiteScope.php create mode 100644 app/Support/IntegrationSiteSecretMask.php create mode 100644 database/migrations/2026_05_27_140000_add_integration_fields_to_admin_sites.php create mode 100644 database/migrations/2026_05_27_140001_seed_integration_menu_actions.php create mode 100644 routes/api/v1/admin/integration.php create mode 100644 tests/Feature/AdminIntegrationSiteApiTest.php diff --git a/--no-progress b/--no-progress new file mode 100644 index 0000000..3b13235 --- /dev/null +++ b/--no-progress @@ -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) diff --git a/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteConnectivityTestController.php b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteConnectivityTestController.php new file mode 100644 index 0000000..d6041c8 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteConnectivityTestController.php @@ -0,0 +1,60 @@ +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(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteExportController.php b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteExportController.php new file mode 100644 index 0000000..f0d5870 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteExportController.php @@ -0,0 +1,47 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteIndexController.php b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteIndexController.php new file mode 100644 index 0000000..33c13fc --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteIndexController.php @@ -0,0 +1,26 @@ +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]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteRotateSecretsController.php b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteRotateSecretsController.php new file mode 100644 index 0000000..302cb96 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteRotateSecretsController.php @@ -0,0 +1,52 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteShowController.php b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteShowController.php new file mode 100644 index 0000000..28e3378 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteShowController.php @@ -0,0 +1,27 @@ +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)); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteStoreController.php b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteStoreController.php new file mode 100644 index 0000000..f106a65 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteStoreController.php @@ -0,0 +1,45 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteUpdateController.php b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteUpdateController.php new file mode 100644 index 0000000..f05aeee --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteUpdateController.php @@ -0,0 +1,50 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerDestroyController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerDestroyController.php index c5716d6..1e463aa 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerDestroyController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerDestroyController.php @@ -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')) diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerFreezeController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerFreezeController.php index e2714cb..a4b6254 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerFreezeController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerFreezeController.php @@ -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(); diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php index c21e189..4adc271 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php @@ -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 { diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerShowController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerShowController.php index a96034d..a8bf677 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerShowController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerShowController.php @@ -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)); } } diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php index f20ebfa..3eb202f 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php @@ -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')) diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php index 931bd98..8c50c29 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php @@ -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'); diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUnfreezeController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUnfreezeController.php index ba369da..03c9c24 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUnfreezeController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUnfreezeController.php @@ -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(); diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUpdateController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUpdateController.php index 04c997a..8432284 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUpdateController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUpdateController.php @@ -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'])) { diff --git a/app/Http/Controllers/Api/V1/Admin/Player/PlayerWalletShowController.php b/app/Http/Controllers/Api/V1/Admin/Player/PlayerWalletShowController.php index bb54cb3..304ccff 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/PlayerWalletShowController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/PlayerWalletShowController.php @@ -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') diff --git a/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php b/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php index def278d..199e01f 100644 --- a/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php @@ -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 { diff --git a/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php b/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php index f1da415..54cfe13 100644 --- a/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php +++ b/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php @@ -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), diff --git a/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php b/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php index 27832e9..d5db345 100644 --- a/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php +++ b/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php @@ -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)); diff --git a/app/Http/Requests/Admin/AdminIntegrationSiteConnectivityTestRequest.php b/app/Http/Requests/Admin/AdminIntegrationSiteConnectivityTestRequest.php new file mode 100644 index 0000000..badb3a9 --- /dev/null +++ b/app/Http/Requests/Admin/AdminIntegrationSiteConnectivityTestRequest.php @@ -0,0 +1,27 @@ +> + */ + public function rules(): array + { + return [ + 'site_player_id' => ['required', 'string', 'max:128'], + 'currency_code' => ['sometimes', 'nullable', 'string', 'max:16'], + ]; + } +} diff --git a/app/Http/Requests/Admin/AdminIntegrationSiteStoreRequest.php b/app/Http/Requests/Admin/AdminIntegrationSiteStoreRequest.php new file mode 100644 index 0000000..15e0c57 --- /dev/null +++ b/app/Http/Requests/Admin/AdminIntegrationSiteStoreRequest.php @@ -0,0 +1,34 @@ + */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Admin/AdminIntegrationSiteUpdateRequest.php b/app/Http/Requests/Admin/AdminIntegrationSiteUpdateRequest.php new file mode 100644 index 0000000..c184b93 --- /dev/null +++ b/app/Http/Requests/Admin/AdminIntegrationSiteUpdateRequest.php @@ -0,0 +1,32 @@ + */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Admin/TicketItemListRequest.php b/app/Http/Requests/Admin/TicketItemListRequest.php index eaa60fa..4cf9d34 100644 --- a/app/Http/Requests/Admin/TicketItemListRequest.php +++ b/app/Http/Requests/Admin/TicketItemListRequest.php @@ -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'], diff --git a/app/Http/Requests/Admin/TransferOrderListRequest.php b/app/Http/Requests/Admin/TransferOrderListRequest.php index 8a13926..67ad4e7 100644 --- a/app/Http/Requests/Admin/TransferOrderListRequest.php +++ b/app/Http/Requests/Admin/TransferOrderListRequest.php @@ -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'], diff --git a/app/Http/Requests/Admin/WalletTransactionListRequest.php b/app/Http/Requests/Admin/WalletTransactionListRequest.php index c75c227..2cf2754 100644 --- a/app/Http/Requests/Admin/WalletTransactionListRequest.php +++ b/app/Http/Requests/Admin/WalletTransactionListRequest.php @@ -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'], diff --git a/app/Models/AdminSite.php b/app/Models/AdminSite.php new file mode 100644 index 0000000..44dd9d6 --- /dev/null +++ b/app/Models/AdminSite.php @@ -0,0 +1,73 @@ + '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; + } + } +} diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php index b818d87..7bd12ef 100644 --- a/app/Models/AdminUser.php +++ b/app/Models/AdminUser.php @@ -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|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 的历史行)。 * diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f02706f..520190a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); } /** diff --git a/app/Services/Integration/IntegrationSiteService.php b/app/Services/Integration/IntegrationSiteService.php new file mode 100644 index 0000000..3243810 --- /dev/null +++ b/app/Services/Integration/IntegrationSiteService.php @@ -0,0 +1,117 @@ + $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 $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; + } +} diff --git a/app/Services/Integration/PartnerSiteConfig.php b/app/Services/Integration/PartnerSiteConfig.php new file mode 100644 index 0000000..9009ecf --- /dev/null +++ b/app/Services/Integration/PartnerSiteConfig.php @@ -0,0 +1,82 @@ +walletApiUrl) && trim($this->walletApiUrl) !== ''; + } + + public function hasSsoSecret(): bool + { + return is_string($this->ssoJwtSecret) && $this->ssoJwtSecret !== ''; + } + + /** + * 可安全写入 Cache 的数组形态(避免 readonly 对象序列化产生 __PHP_Incomplete_Class)。 + * + * @return array + */ + 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 $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), + ); + } +} diff --git a/app/Services/Integration/PartnerSiteConfigResolver.php b/app/Services/Integration/PartnerSiteConfigResolver.php new file mode 100644 index 0000000..3e35a91 --- /dev/null +++ b/app/Services/Integration/PartnerSiteConfigResolver.php @@ -0,0 +1,164 @@ +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 $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; + } +} diff --git a/app/Services/PlayerTokenResolver.php b/app/Services/PlayerTokenResolver.php index 46fb44d..cf880fd 100644 --- a/app/Services/PlayerTokenResolver.php +++ b/app/Services/PlayerTokenResolver.php @@ -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); diff --git a/app/Services/Wallet/HttpMainSiteWalletBalanceClient.php b/app/Services/Wallet/HttpMainSiteWalletBalanceClient.php index e69ba43..3e86ec9 100644 --- a/app/Services/Wallet/HttpMainSiteWalletBalanceClient.php +++ b/app/Services/Wallet/HttpMainSiteWalletBalanceClient.php @@ -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; + } + + 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, + ); } - $path = (string) config('lottery.main_site.wallet_balance_path', '/wallet/balance'); + 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 $payload + * @return array + */ + 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; } } diff --git a/app/Services/Wallet/HttpMainSiteWalletGateway.php b/app/Services/Wallet/HttpMainSiteWalletGateway.php index 4136ee8..bd4ddd1 100644 --- a/app/Services/Wallet/HttpMainSiteWalletGateway.php +++ b/app/Services/Wallet/HttpMainSiteWalletGateway.php @@ -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, diff --git a/app/Services/Wallet/MainSiteWalletBalanceProbeResult.php b/app/Services/Wallet/MainSiteWalletBalanceProbeResult.php new file mode 100644 index 0000000..193687b --- /dev/null +++ b/app/Services/Wallet/MainSiteWalletBalanceProbeResult.php @@ -0,0 +1,35 @@ + + */ + 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, + ]; + } +} diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index 0b28fa9..eb3c28a 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -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']], diff --git a/app/Support/AdminIntegrationSiteAccess.php b/app/Support/AdminIntegrationSiteAccess.php new file mode 100644 index 0000000..faab30d --- /dev/null +++ b/app/Support/AdminIntegrationSiteAccess.php @@ -0,0 +1,39 @@ + + */ + 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); + } +} diff --git a/app/Support/AdminIntegrationSitePresenter.php b/app/Support/AdminIntegrationSitePresenter.php new file mode 100644 index 0000000..2ec7dcf --- /dev/null +++ b/app/Support/AdminIntegrationSitePresenter.php @@ -0,0 +1,118 @@ + + */ + 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 + */ + 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 + */ + 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 + */ + 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' => '密钥明文仅于创建/重置时展示一次,请勿通过导出或邮件长期传播。', + ]; + } +} diff --git a/app/Support/AdminSiteScope.php b/app/Support/AdminSiteScope.php new file mode 100644 index 0000000..3802fc6 --- /dev/null +++ b/app/Support/AdminSiteScope.php @@ -0,0 +1,155 @@ +|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 $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 $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 $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 $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; + } +} diff --git a/app/Support/IntegrationSiteSecretMask.php b/app/Support/IntegrationSiteSecretMask.php new file mode 100644 index 0000000..cb02afa --- /dev/null +++ b/app/Support/IntegrationSiteSecretMask.php @@ -0,0 +1,20 @@ + 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 | diff --git a/database/migrations/2026_05_27_140000_add_integration_fields_to_admin_sites.php b/database/migrations/2026_05_27_140000_add_integration_fields_to_admin_sites.php new file mode 100644 index 0000000..355b446 --- /dev/null +++ b/database/migrations/2026_05_27_140000_add_integration_fields_to_admin_sites.php @@ -0,0 +1,237 @@ +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, + ]); + } + } + } +}; diff --git a/database/migrations/2026_05_27_140001_seed_integration_menu_actions.php b/database/migrations/2026_05_27_140001_seed_integration_menu_actions.php new file mode 100644 index 0000000..a394bb6 --- /dev/null +++ b/database/migrations/2026_05_27_140001_seed_integration_menu_actions.php @@ -0,0 +1,105 @@ +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,避免回滚后超管无法管理已创建的接入站点。 + } +}; diff --git a/routes/api.php b/routes/api.php index 04eb5c6..b3b2f85 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'; diff --git a/routes/api/v1/admin/integration.php b/routes/api/v1/admin/integration.php new file mode 100644 index 0000000..308dcf2 --- /dev/null +++ b/routes/api/v1/admin/integration.php @@ -0,0 +1,28 @@ +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'); + }); diff --git a/tests/Feature/AdminIntegrationSiteApiTest.php b/tests/Feature/AdminIntegrationSiteApiTest.php new file mode 100644 index 0000000..57aa80b --- /dev/null +++ b/tests/Feature/AdminIntegrationSiteApiTest.php @@ -0,0 +1,325 @@ +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']); +});