feat: 增强玩家管理功能,集成接入站点权限控制
在多个玩家相关控制器中引入 AdminSiteScope,确保管理员在执行操作前具备相应的接入站点权限。更新 Player 相关请求以支持 site_code 参数,增强权限验证逻辑,确保系统安全性与灵活性。同时,新增 AdminUser 模型方法以获取可访问的站点 ID 列表,优化权限管理。
This commit is contained in:
37
--no-progress
Normal file
37
--no-progress
Normal file
@@ -0,0 +1,37 @@
|
||||
[00:00:00.010986042 / 00:00:00.010986042] [9599616 bytes] Test Runner Configured
|
||||
[00:00:00.020327292 / 00:00:00.009341250] [12034144 bytes] PHPUnit Started (PHPUnit 12.5.24 using PHP 8.5.5 (cli) on Darwin)
|
||||
[00:00:00.021274209 / 00:00:00.000946917] [12040696 bytes] Test Runner Configured
|
||||
[00:00:00.021449250 / 00:00:00.000175041] [12079864 bytes] Bootstrap Finished (/Users/kang/Work/lotterySystem/lotterLaravel/vendor/autoload.php)
|
||||
[00:00:00.023767959 / 00:00:00.002318709] [12398648 bytes] Event Facade Sealed
|
||||
[00:00:00.046732459 / 00:00:00.022964500] [22973880 bytes] Test Suite Loaded (265 tests)
|
||||
[00:00:00.047165417 / 00:00:00.000432958] [23094456 bytes] Test Runner Started
|
||||
[00:00:00.047533125 / 00:00:00.000367708] [23199176 bytes] Test Suite Sorted
|
||||
[00:00:00.050435750 / 00:00:00.002902625] [23387240 bytes] Test Suite Filtered (1 test)
|
||||
[00:00:00.050934834 / 00:00:00.000499084] [23387240 bytes] Test Runner Execution Started (1 test)
|
||||
[00:00:00.051671750 / 00:00:00.000736916] [23441552 bytes] Test Suite Started (/Users/kang/Work/lotterySystem/lotterLaravel/phpunit.xml, 1 test)
|
||||
[00:00:00.052156000 / 00:00:00.000484250] [23441552 bytes] Test Suite Started (Feature, 1 test)
|
||||
[00:00:00.052311250 / 00:00:00.000155250] [23441552 bytes] Test Suite Started (P\Tests\Feature\AdminIntegrationSiteApiTest, 1 test)
|
||||
[00:00:00.052695167 / 00:00:00.000383917] [23441552 bytes] Before First Test Method Called (P\Tests\Feature\AdminIntegrationSiteApiTest::setUpBeforeClass)
|
||||
[00:00:00.052720459 / 00:00:00.000025292] [23441552 bytes] Before First Test Method Finished:
|
||||
- P\Tests\Feature\AdminIntegrationSiteApiTest::setUpBeforeClass
|
||||
[00:00:00.053158709 / 00:00:00.000438250] [23441552 bytes] Test Preparation Started (P\Tests\Feature\AdminIntegrationSiteApiTest::__pest_evaluable_super_admin_can_create_integration_site_and_receive_secrets_once)
|
||||
[00:00:00.229504042 / 00:00:00.176345333] [53442936 bytes] Before Test Method Called (P\Tests\Feature\AdminIntegrationSiteApiTest::setUp)
|
||||
[00:00:00.229581417 / 00:00:00.000077375] [53442936 bytes] Before Test Method Finished:
|
||||
- P\Tests\Feature\AdminIntegrationSiteApiTest::setUp
|
||||
[00:00:00.229620167 / 00:00:00.000038750] [53287848 bytes] Test Prepared (P\Tests\Feature\AdminIntegrationSiteApiTest::__pest_evaluable_super_admin_can_create_integration_site_and_receive_secrets_once)
|
||||
[00:00:00.243854542 / 00:00:00.014234375] [56820064 bytes] Test Failed (P\Tests\Feature\AdminIntegrationSiteApiTest::__pest_evaluable_super_admin_can_create_integration_site_and_receive_secrets_once)
|
||||
Expected response status code [201] but received 500.
|
||||
Failed asserting that 500 is identical to 201.
|
||||
[00:00:00.247484959 / 00:00:00.003630417] [58276136 bytes] After Test Method Called (P\Tests\Feature\AdminIntegrationSiteApiTest::tearDown)
|
||||
[00:00:00.247537625 / 00:00:00.000052666] [58276136 bytes] After Test Method Finished:
|
||||
- P\Tests\Feature\AdminIntegrationSiteApiTest::tearDown
|
||||
[00:00:00.248189209 / 00:00:00.000651584] [58276136 bytes] Test Finished (P\Tests\Feature\AdminIntegrationSiteApiTest::__pest_evaluable_super_admin_can_create_integration_site_and_receive_secrets_once)
|
||||
[00:00:00.248247167 / 00:00:00.000057958] [58276136 bytes] After Last Test Method Called (P\Tests\Feature\AdminIntegrationSiteApiTest::tearDownAfterClass)
|
||||
[00:00:00.248271375 / 00:00:00.000024208] [58276136 bytes] After Last Test Method Finished:
|
||||
- P\Tests\Feature\AdminIntegrationSiteApiTest::tearDownAfterClass
|
||||
[00:00:00.248293000 / 00:00:00.000021625] [58276136 bytes] Test Suite Finished (P\Tests\Feature\AdminIntegrationSiteApiTest, 1 test)
|
||||
[00:00:00.249083834 / 00:00:00.000790834] [58276136 bytes] Test Suite Finished (Feature, 1 test)
|
||||
[00:00:00.249148292 / 00:00:00.000064458] [58276136 bytes] Test Suite Finished (/Users/kang/Work/lotterySystem/lotterLaravel/phpunit.xml, 1 test)
|
||||
[00:00:00.249474334 / 00:00:00.000326042] [58276136 bytes] Test Runner Execution Finished
|
||||
[00:00:00.249581250 / 00:00:00.000106916] [58276136 bytes] Test Runner Finished
|
||||
[00:00:00.249956709 / 00:00:00.000375459] [58276136 bytes] PHPUnit Finished (Shell Exit Code: 1)
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Integration;
|
||||
|
||||
use App\Models\AdminSite;
|
||||
use App\Models\Player;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Lottery\ErrorCode;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Wallet\HttpMainSiteWalletBalanceClient;
|
||||
use App\Support\AdminIntegrationSiteAccess;
|
||||
use App\Http\Requests\Admin\AdminIntegrationSiteConnectivityTestRequest;
|
||||
|
||||
final class AdminIntegrationSiteConnectivityTestController extends Controller
|
||||
{
|
||||
public function __invoke(
|
||||
AdminIntegrationSiteConnectivityTestRequest $request,
|
||||
AdminSite $admin_site,
|
||||
HttpMainSiteWalletBalanceClient $balanceClient,
|
||||
): JsonResponse {
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
if (! AdminIntegrationSiteAccess::canAccess($admin, $admin_site)) {
|
||||
return ApiResponse::error('无权访问该站点', ErrorCode::AdminForbidden->value, null, 403);
|
||||
}
|
||||
|
||||
$sitePlayerId = trim((string) $request->validated('site_player_id'));
|
||||
$currencyCode = trim((string) ($request->validated('currency_code') ?? $admin_site->currency_code ?? 'NPR'));
|
||||
if ($currencyCode === '') {
|
||||
$currencyCode = 'NPR';
|
||||
}
|
||||
|
||||
$player = Player::query()
|
||||
->where('site_code', $admin_site->code)
|
||||
->where('site_player_id', $sitePlayerId)
|
||||
->first();
|
||||
|
||||
$playerSource = 'database';
|
||||
if ($player === null) {
|
||||
$playerSource = 'synthetic';
|
||||
$player = new Player([
|
||||
'site_code' => $admin_site->code,
|
||||
'site_player_id' => $sitePlayerId,
|
||||
'default_currency' => $currencyCode,
|
||||
]);
|
||||
}
|
||||
|
||||
$probe = $balanceClient->probe($player, $currencyCode);
|
||||
|
||||
return ApiResponse::success([
|
||||
'site_code' => (string) $admin_site->code,
|
||||
'site_player_id' => $sitePlayerId,
|
||||
'player_source' => $playerSource,
|
||||
'probe' => $probe->toArray(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Integration;
|
||||
|
||||
use App\Models\AdminSite;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Lottery\ErrorCode;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AdminIntegrationSiteAccess;
|
||||
use App\Support\AdminIntegrationSitePresenter;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
final class AdminIntegrationSiteExportController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, AdminSite $admin_site): JsonResponse|StreamedResponse
|
||||
{
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
if (! AdminIntegrationSiteAccess::canAccess($admin, $admin_site)) {
|
||||
return ApiResponse::error('无权访问该站点', ErrorCode::AdminForbidden->value, null, 403);
|
||||
}
|
||||
|
||||
$sheet = AdminIntegrationSitePresenter::parameterSheet($admin_site);
|
||||
$format = strtolower(trim((string) $request->query('format', 'json')));
|
||||
|
||||
if ($format === 'csv') {
|
||||
$filename = 'integration-'.$admin_site->code.'-'.now()->format('Ymd-His').'.csv';
|
||||
|
||||
return response()->streamDownload(function () use ($sheet): void {
|
||||
$out = fopen('php://output', 'w');
|
||||
fputcsv($out, ['field', 'value']);
|
||||
foreach ($sheet as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
fputcsv($out, [(string) $key, is_scalar($value) || $value === null ? (string) $value : json_encode($value)]);
|
||||
}
|
||||
fclose($out);
|
||||
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||
}
|
||||
|
||||
return ApiResponse::success($sheet);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Integration;
|
||||
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AdminIntegrationSiteAccess;
|
||||
use App\Support\AdminIntegrationSitePresenter;
|
||||
|
||||
final class AdminIntegrationSiteIndexController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
$items = AdminIntegrationSiteAccess::queryFor($admin)
|
||||
->get()
|
||||
->map(static fn ($site): array => AdminIntegrationSitePresenter::listItem($site))
|
||||
->all();
|
||||
|
||||
return ApiResponse::success(['items' => $items]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Integration;
|
||||
|
||||
use App\Models\AdminSite;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Lottery\ErrorCode;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\AuditLogger;
|
||||
use App\Services\Integration\IntegrationSiteService;
|
||||
use App\Support\AdminIntegrationSiteAccess;
|
||||
use App\Support\AdminIntegrationSitePresenter;
|
||||
use App\Http\Middleware\RecordAdminApiAudit;
|
||||
|
||||
final class AdminIntegrationSiteRotateSecretsController extends Controller
|
||||
{
|
||||
public function __invoke(
|
||||
Request $request,
|
||||
AdminSite $admin_site,
|
||||
IntegrationSiteService $service,
|
||||
): JsonResponse {
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
if (! AdminIntegrationSiteAccess::canAccess($admin, $admin_site)) {
|
||||
return ApiResponse::error('无权操作该站点', ErrorCode::AdminForbidden->value, null, 403);
|
||||
}
|
||||
|
||||
$result = $service->rotateSecrets($admin_site);
|
||||
$site = $result['site'];
|
||||
|
||||
$payload = AdminIntegrationSitePresenter::withPlainSecretsOnce(
|
||||
AdminIntegrationSitePresenter::detail($site),
|
||||
$result['secrets'],
|
||||
);
|
||||
|
||||
AuditLogger::recordForAdmin(
|
||||
$admin,
|
||||
$request,
|
||||
moduleCode: 'integration',
|
||||
actionCode: 'rotate_secrets',
|
||||
targetType: 'admin_site',
|
||||
targetId: (string) $site->id,
|
||||
afterJson: ['code' => $site->code, 'rotated' => true],
|
||||
);
|
||||
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
|
||||
|
||||
return ApiResponse::success($payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Integration;
|
||||
|
||||
use App\Models\AdminSite;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Lottery\ErrorCode;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AdminIntegrationSiteAccess;
|
||||
use App\Support\AdminIntegrationSitePresenter;
|
||||
|
||||
final class AdminIntegrationSiteShowController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, AdminSite $admin_site): JsonResponse
|
||||
{
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
if (! AdminIntegrationSiteAccess::canAccess($admin, $admin_site)) {
|
||||
return ApiResponse::error('无权访问该站点', ErrorCode::AdminForbidden->value, null, 403);
|
||||
}
|
||||
|
||||
return ApiResponse::success(AdminIntegrationSitePresenter::detail($admin_site));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Integration;
|
||||
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\AuditLogger;
|
||||
use App\Services\Integration\IntegrationSiteService;
|
||||
use App\Support\AdminIntegrationSitePresenter;
|
||||
use App\Http\Requests\Admin\AdminIntegrationSiteStoreRequest;
|
||||
use App\Http\Middleware\RecordAdminApiAudit;
|
||||
|
||||
final class AdminIntegrationSiteStoreController extends Controller
|
||||
{
|
||||
public function __invoke(
|
||||
AdminIntegrationSiteStoreRequest $request,
|
||||
IntegrationSiteService $service,
|
||||
): JsonResponse {
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
$result = $service->create($request->validated());
|
||||
$site = $result['site'];
|
||||
|
||||
$payload = AdminIntegrationSitePresenter::withPlainSecretsOnce(
|
||||
AdminIntegrationSitePresenter::detail($site),
|
||||
$result['secrets'],
|
||||
);
|
||||
|
||||
AuditLogger::recordForAdmin(
|
||||
$admin,
|
||||
$request,
|
||||
moduleCode: 'integration',
|
||||
actionCode: 'create',
|
||||
targetType: 'admin_site',
|
||||
targetId: (string) $site->id,
|
||||
afterJson: AdminIntegrationSitePresenter::detail($site),
|
||||
);
|
||||
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
|
||||
|
||||
return ApiResponse::success($payload)->setStatusCode(201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Integration;
|
||||
|
||||
use App\Models\AdminSite;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Lottery\ErrorCode;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\AuditLogger;
|
||||
use App\Services\Integration\IntegrationSiteService;
|
||||
use App\Support\AdminIntegrationSiteAccess;
|
||||
use App\Support\AdminIntegrationSitePresenter;
|
||||
use App\Http\Requests\Admin\AdminIntegrationSiteUpdateRequest;
|
||||
use App\Http\Middleware\RecordAdminApiAudit;
|
||||
|
||||
final class AdminIntegrationSiteUpdateController extends Controller
|
||||
{
|
||||
public function __invoke(
|
||||
AdminIntegrationSiteUpdateRequest $request,
|
||||
AdminSite $admin_site,
|
||||
IntegrationSiteService $service,
|
||||
): JsonResponse {
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
if (! AdminIntegrationSiteAccess::canAccess($admin, $admin_site)) {
|
||||
return ApiResponse::error('无权修改该站点', ErrorCode::AdminForbidden->value, null, 403);
|
||||
}
|
||||
|
||||
$before = AdminIntegrationSitePresenter::detail($admin_site);
|
||||
$site = $service->update($admin_site, $request->validated());
|
||||
$after = AdminIntegrationSitePresenter::detail($site);
|
||||
|
||||
AuditLogger::recordForAdmin(
|
||||
$admin,
|
||||
$request,
|
||||
moduleCode: 'integration',
|
||||
actionCode: 'update',
|
||||
targetType: 'admin_site',
|
||||
targetId: (string) $site->id,
|
||||
beforeJson: $before,
|
||||
afterJson: $after,
|
||||
);
|
||||
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
|
||||
|
||||
return ApiResponse::success($after);
|
||||
}
|
||||
}
|
||||
@@ -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'))
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'])) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteConnectivityTestController
|
||||
*/
|
||||
final class AdminIntegrationSiteConnectivityTestRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'site_player_id' => ['required', 'string', 'max:128'],
|
||||
'currency_code' => ['sometimes', 'nullable', 'string', 'max:16'],
|
||||
];
|
||||
}
|
||||
}
|
||||
34
app/Http/Requests/Admin/AdminIntegrationSiteStoreRequest.php
Normal file
34
app/Http/Requests/Admin/AdminIntegrationSiteStoreRequest.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class AdminIntegrationSiteStoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'code' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9][a-z0-9_-]*$/', Rule::unique('admin_sites', 'code')],
|
||||
'name' => ['required', 'string', 'max:128'],
|
||||
'currency_code' => ['sometimes', 'string', 'max:16'],
|
||||
'status' => ['sometimes', 'integer', 'in:0,1'],
|
||||
'wallet_api_url' => ['nullable', 'string', 'max:512'],
|
||||
'wallet_debit_path' => ['sometimes', 'string', 'max:128'],
|
||||
'wallet_credit_path' => ['sometimes', 'string', 'max:128'],
|
||||
'wallet_balance_path' => ['sometimes', 'string', 'max:128'],
|
||||
'wallet_timeout_seconds' => ['sometimes', 'integer', 'min:1', 'max:120'],
|
||||
'iframe_allowed_origins' => ['nullable', 'array'],
|
||||
'iframe_allowed_origins.*' => ['string', 'max:512'],
|
||||
'lottery_h5_base_url' => ['nullable', 'string', 'max:512'],
|
||||
'notes' => ['nullable', 'string', 'max:5000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
final class AdminIntegrationSiteUpdateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:128'],
|
||||
'currency_code' => ['sometimes', 'string', 'max:16'],
|
||||
'status' => ['sometimes', 'integer', 'in:0,1'],
|
||||
'wallet_api_url' => ['nullable', 'string', 'max:512'],
|
||||
'wallet_debit_path' => ['sometimes', 'string', 'max:128'],
|
||||
'wallet_credit_path' => ['sometimes', 'string', 'max:128'],
|
||||
'wallet_balance_path' => ['sometimes', 'string', 'max:128'],
|
||||
'wallet_timeout_seconds' => ['sometimes', 'integer', 'min:1', 'max:120'],
|
||||
'iframe_allowed_origins' => ['nullable', 'array'],
|
||||
'iframe_allowed_origins.*' => ['string', 'max:512'],
|
||||
'lottery_h5_base_url' => ['nullable', 'string', 'max:512'],
|
||||
'notes' => ['nullable', 'string', 'max:5000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -27,6 +27,7 @@ final class WalletTransactionListRequest extends FormRequest
|
||||
'size' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
||||
'player_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||
'player_account' => ['sometimes', 'nullable', 'string', 'max:128'],
|
||||
'site_code' => ['sometimes', 'nullable', 'string', 'max:64'],
|
||||
'txn_no' => ['sometimes', 'nullable', 'string', 'max:96'],
|
||||
'external_ref_no' => ['sometimes', 'nullable', 'string', 'max:96'],
|
||||
'created_from' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
|
||||
|
||||
73
app/Models/AdminSite.php
Normal file
73
app/Models/AdminSite.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* 后台站点 / 主站接入配置({@see $table admin_sites})。
|
||||
*
|
||||
* `code` 对外即 JWT 与钱包回调中的 `site_code`,创建后不可修改。
|
||||
*/
|
||||
final class AdminSite extends Model
|
||||
{
|
||||
protected $table = 'admin_sites';
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'name',
|
||||
'currency_code',
|
||||
'status',
|
||||
'is_default',
|
||||
'extra_json',
|
||||
'wallet_api_url',
|
||||
'wallet_debit_path',
|
||||
'wallet_credit_path',
|
||||
'wallet_balance_path',
|
||||
'wallet_api_key_encrypted',
|
||||
'sso_jwt_secret_encrypted',
|
||||
'wallet_timeout_seconds',
|
||||
'iframe_allowed_origins',
|
||||
'lottery_h5_base_url',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => 'integer',
|
||||
'is_default' => 'boolean',
|
||||
'extra_json' => 'array',
|
||||
'iframe_allowed_origins' => 'array',
|
||||
'wallet_timeout_seconds' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return (int) $this->status === 1;
|
||||
}
|
||||
|
||||
public function decryptedSsoJwtSecret(): ?string
|
||||
{
|
||||
return $this->decryptSecret($this->sso_jwt_secret_encrypted);
|
||||
}
|
||||
|
||||
public function decryptedWalletApiKey(): ?string
|
||||
{
|
||||
return $this->decryptSecret($this->wallet_api_key_encrypted);
|
||||
}
|
||||
|
||||
private function decryptSecret(?string $encrypted): ?string
|
||||
{
|
||||
if (! is_string($encrypted) || $encrypted === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return decrypt($encrypted);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,28 @@ final class AdminUser extends Authenticatable
|
||||
return $this->roles()->where('admin_roles.slug', self::ROLE_SUPER_ADMIN)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 可访问的 admin_sites.id 列表;`null` 表示不限制(超管)。
|
||||
*
|
||||
* @return list<int>|null
|
||||
*/
|
||||
public function accessibleAdminSiteIds(): ?array
|
||||
{
|
||||
if ($this->isSuperAdmin()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ids = DB::table('admin_user_site_roles')
|
||||
->where('admin_user_id', $this->id)
|
||||
->distinct()
|
||||
->pluck('site_id')
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅来自「直接授权」的 menu_action.permission_code(默认站点,含 site_id 为 null 的历史行)。
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
117
app/Services/Integration/IntegrationSiteService.php
Normal file
117
app/Services/Integration/IntegrationSiteService.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Integration;
|
||||
|
||||
use App\Models\AdminSite;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class IntegrationSiteService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PartnerSiteConfigResolver $configResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array{site: AdminSite, secrets: array{sso_jwt_secret: string, wallet_api_key: string}}
|
||||
*/
|
||||
public function create(array $data): array
|
||||
{
|
||||
$secrets = $this->generateSecrets();
|
||||
|
||||
$site = DB::transaction(function () use ($data, $secrets): AdminSite {
|
||||
return AdminSite::query()->create([
|
||||
'code' => (string) $data['code'],
|
||||
'name' => (string) $data['name'],
|
||||
'currency_code' => (string) ($data['currency_code'] ?? 'NPR'),
|
||||
'status' => (int) ($data['status'] ?? 1),
|
||||
'is_default' => false,
|
||||
'wallet_api_url' => $this->nullableTrim($data['wallet_api_url'] ?? null),
|
||||
'wallet_debit_path' => (string) ($data['wallet_debit_path'] ?? '/wallet/debit-for-lottery'),
|
||||
'wallet_credit_path' => (string) ($data['wallet_credit_path'] ?? '/wallet/credit-from-lottery'),
|
||||
'wallet_balance_path' => (string) ($data['wallet_balance_path'] ?? '/wallet/balance'),
|
||||
'wallet_timeout_seconds' => max(1, (int) ($data['wallet_timeout_seconds'] ?? 10)),
|
||||
'iframe_allowed_origins' => $data['iframe_allowed_origins'] ?? null,
|
||||
'lottery_h5_base_url' => $this->nullableTrim($data['lottery_h5_base_url'] ?? null),
|
||||
'notes' => $this->nullableTrim($data['notes'] ?? null),
|
||||
'sso_jwt_secret_encrypted' => encrypt($secrets['sso_jwt_secret']),
|
||||
'wallet_api_key_encrypted' => encrypt($secrets['wallet_api_key']),
|
||||
]);
|
||||
});
|
||||
|
||||
$this->configResolver->forgetCache((string) $site->code);
|
||||
|
||||
return ['site' => $site->fresh(), 'secrets' => $secrets];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function update(AdminSite $site, array $data): AdminSite
|
||||
{
|
||||
$site->fill([
|
||||
'name' => (string) $data['name'],
|
||||
'currency_code' => (string) ($data['currency_code'] ?? $site->currency_code),
|
||||
'status' => (int) ($data['status'] ?? $site->status),
|
||||
'wallet_api_url' => array_key_exists('wallet_api_url', $data)
|
||||
? $this->nullableTrim($data['wallet_api_url'])
|
||||
: $site->wallet_api_url,
|
||||
'wallet_debit_path' => (string) ($data['wallet_debit_path'] ?? $site->wallet_debit_path),
|
||||
'wallet_credit_path' => (string) ($data['wallet_credit_path'] ?? $site->wallet_credit_path),
|
||||
'wallet_balance_path' => (string) ($data['wallet_balance_path'] ?? $site->wallet_balance_path),
|
||||
'wallet_timeout_seconds' => max(1, (int) ($data['wallet_timeout_seconds'] ?? $site->wallet_timeout_seconds)),
|
||||
'iframe_allowed_origins' => $data['iframe_allowed_origins'] ?? $site->iframe_allowed_origins,
|
||||
'lottery_h5_base_url' => array_key_exists('lottery_h5_base_url', $data)
|
||||
? $this->nullableTrim($data['lottery_h5_base_url'])
|
||||
: $site->lottery_h5_base_url,
|
||||
'notes' => array_key_exists('notes', $data)
|
||||
? $this->nullableTrim($data['notes'])
|
||||
: $site->notes,
|
||||
]);
|
||||
$site->save();
|
||||
|
||||
$this->configResolver->forgetCache((string) $site->code);
|
||||
|
||||
return $site->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{site: AdminSite, secrets: array{sso_jwt_secret: string, wallet_api_key: string}}
|
||||
*/
|
||||
public function rotateSecrets(AdminSite $site): array
|
||||
{
|
||||
$secrets = $this->generateSecrets();
|
||||
|
||||
$site->forceFill([
|
||||
'sso_jwt_secret_encrypted' => encrypt($secrets['sso_jwt_secret']),
|
||||
'wallet_api_key_encrypted' => encrypt($secrets['wallet_api_key']),
|
||||
])->save();
|
||||
|
||||
$this->configResolver->forgetCache((string) $site->code);
|
||||
|
||||
return ['site' => $site->fresh(), 'secrets' => $secrets];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{sso_jwt_secret: string, wallet_api_key: string}
|
||||
*/
|
||||
private function generateSecrets(): array
|
||||
{
|
||||
return [
|
||||
'sso_jwt_secret' => Str::random(48),
|
||||
'wallet_api_key' => Str::random(40),
|
||||
];
|
||||
}
|
||||
|
||||
private function nullableTrim(mixed $value): ?string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = trim($value);
|
||||
|
||||
return $trimmed === '' ? null : $trimmed;
|
||||
}
|
||||
}
|
||||
82
app/Services/Integration/PartnerSiteConfig.php
Normal file
82
app/Services/Integration/PartnerSiteConfig.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Integration;
|
||||
|
||||
/**
|
||||
* 运行时主站接入配置(由库表或 legacy env 解析而来)。
|
||||
*/
|
||||
final readonly class PartnerSiteConfig
|
||||
{
|
||||
public const SOURCE_DATABASE = 'database';
|
||||
|
||||
public const SOURCE_LEGACY_ENV = 'legacy_env';
|
||||
|
||||
public function __construct(
|
||||
public string $siteCode,
|
||||
public bool $enabled,
|
||||
public ?string $walletApiUrl,
|
||||
public string $walletDebitPath,
|
||||
public string $walletCreditPath,
|
||||
public string $walletBalancePath,
|
||||
public ?string $ssoJwtSecret,
|
||||
public ?string $walletApiKey,
|
||||
public int $walletTimeoutSeconds,
|
||||
public string $source,
|
||||
) {}
|
||||
|
||||
public function hasWalletApi(): bool
|
||||
{
|
||||
return is_string($this->walletApiUrl) && trim($this->walletApiUrl) !== '';
|
||||
}
|
||||
|
||||
public function hasSsoSecret(): bool
|
||||
{
|
||||
return is_string($this->ssoJwtSecret) && $this->ssoJwtSecret !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 可安全写入 Cache 的数组形态(避免 readonly 对象序列化产生 __PHP_Incomplete_Class)。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toCacheArray(): array
|
||||
{
|
||||
return [
|
||||
'site_code' => $this->siteCode,
|
||||
'enabled' => $this->enabled,
|
||||
'wallet_api_url' => $this->walletApiUrl,
|
||||
'wallet_debit_path' => $this->walletDebitPath,
|
||||
'wallet_credit_path' => $this->walletCreditPath,
|
||||
'wallet_balance_path' => $this->walletBalancePath,
|
||||
'sso_jwt_secret' => $this->ssoJwtSecret,
|
||||
'wallet_api_key' => $this->walletApiKey,
|
||||
'wallet_timeout_seconds' => $this->walletTimeoutSeconds,
|
||||
'source' => $this->source,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function fromCacheArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
siteCode: (string) ($data['site_code'] ?? ''),
|
||||
enabled: (bool) ($data['enabled'] ?? false),
|
||||
walletApiUrl: isset($data['wallet_api_url']) && is_string($data['wallet_api_url']) && $data['wallet_api_url'] !== ''
|
||||
? $data['wallet_api_url']
|
||||
: null,
|
||||
walletDebitPath: (string) ($data['wallet_debit_path'] ?? '/wallet/debit-for-lottery'),
|
||||
walletCreditPath: (string) ($data['wallet_credit_path'] ?? '/wallet/credit-from-lottery'),
|
||||
walletBalancePath: (string) ($data['wallet_balance_path'] ?? '/wallet/balance'),
|
||||
ssoJwtSecret: isset($data['sso_jwt_secret']) && is_string($data['sso_jwt_secret']) && $data['sso_jwt_secret'] !== ''
|
||||
? $data['sso_jwt_secret']
|
||||
: null,
|
||||
walletApiKey: isset($data['wallet_api_key']) && is_string($data['wallet_api_key']) && $data['wallet_api_key'] !== ''
|
||||
? $data['wallet_api_key']
|
||||
: null,
|
||||
walletTimeoutSeconds: max(1, (int) ($data['wallet_timeout_seconds'] ?? 10)),
|
||||
source: (string) ($data['source'] ?? self::SOURCE_DATABASE),
|
||||
);
|
||||
}
|
||||
}
|
||||
164
app/Services/Integration/PartnerSiteConfigResolver.php
Normal file
164
app/Services/Integration/PartnerSiteConfigResolver.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Integration;
|
||||
|
||||
use App\Models\AdminSite;
|
||||
use App\Models\Player;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 按 {@see site_code} 解析主站对接配置;未命中库表时回退全局 MAIN_SITE_* env(并写 warning 日志)。
|
||||
*/
|
||||
final class PartnerSiteConfigResolver
|
||||
{
|
||||
private const CACHE_PREFIX = 'partner_site_config:';
|
||||
|
||||
private const CACHE_TTL_SECONDS = 60;
|
||||
|
||||
public function resolveForPlayer(Player $player): PartnerSiteConfig
|
||||
{
|
||||
return $this->resolveBySiteCode((string) $player->site_code);
|
||||
}
|
||||
|
||||
public function resolveBySiteCode(string $siteCode): PartnerSiteConfig
|
||||
{
|
||||
$siteCode = trim($siteCode);
|
||||
if ($siteCode === '') {
|
||||
return $this->legacyFallbackConfig($siteCode);
|
||||
}
|
||||
|
||||
$cacheKey = self::CACHE_PREFIX.$siteCode;
|
||||
|
||||
/** @var array<string, mixed> $cached */
|
||||
$cached = Cache::remember($cacheKey, self::CACHE_TTL_SECONDS, function () use ($siteCode): array {
|
||||
$site = AdminSite::query()->where('code', $siteCode)->first();
|
||||
$config = $site !== null
|
||||
? $this->fromAdminSite($site)
|
||||
: $this->legacyFallbackConfig($siteCode);
|
||||
|
||||
return $config->toCacheArray();
|
||||
});
|
||||
|
||||
return PartnerSiteConfig::fromCacheArray($cached);
|
||||
}
|
||||
|
||||
public function forgetCache(string $siteCode): void
|
||||
{
|
||||
Cache::forget(self::CACHE_PREFIX.trim($siteCode));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从未验签的 JWT 中读取 site_code(仅用于选取验签密钥)。
|
||||
*/
|
||||
public function peekSiteCodeFromJwt(string $jwt): ?string
|
||||
{
|
||||
$jwt = trim($jwt);
|
||||
$parts = explode('.', $jwt);
|
||||
if (count($parts) !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payloadJson = $this->base64UrlDecode($parts[1]);
|
||||
if ($payloadJson === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = json_decode($payloadJson, true);
|
||||
if (! is_array($payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$siteKey = (string) config('lottery.player_auth.jwt.claim_site_code', 'site_code');
|
||||
$siteCode = $payload[$siteKey] ?? null;
|
||||
|
||||
return is_string($siteCode) && $siteCode !== '' ? $siteCode : null;
|
||||
}
|
||||
|
||||
private function fromAdminSite(AdminSite $site): PartnerSiteConfig
|
||||
{
|
||||
return new PartnerSiteConfig(
|
||||
siteCode: (string) $site->code,
|
||||
enabled: $site->isEnabled(),
|
||||
walletApiUrl: is_string($site->wallet_api_url) && $site->wallet_api_url !== ''
|
||||
? rtrim($site->wallet_api_url, '/')
|
||||
: null,
|
||||
walletDebitPath: (string) ($site->wallet_debit_path ?: '/wallet/debit-for-lottery'),
|
||||
walletCreditPath: (string) ($site->wallet_credit_path ?: '/wallet/credit-from-lottery'),
|
||||
walletBalancePath: (string) ($site->wallet_balance_path ?: '/wallet/balance'),
|
||||
ssoJwtSecret: $site->decryptedSsoJwtSecret(),
|
||||
walletApiKey: $site->decryptedWalletApiKey(),
|
||||
walletTimeoutSeconds: max(1, (int) ($site->wallet_timeout_seconds ?? 10)),
|
||||
source: PartnerSiteConfig::SOURCE_DATABASE,
|
||||
);
|
||||
}
|
||||
|
||||
private function legacyFallbackConfig(string $siteCode): PartnerSiteConfig
|
||||
{
|
||||
$defaultCode = (string) config('lottery.integration.default_site_code', 'default_site');
|
||||
$legacyCodes = array_filter([
|
||||
$defaultCode,
|
||||
(string) config('lottery.integration.legacy_env_site_code', ''),
|
||||
]);
|
||||
|
||||
$sso = config('lottery.main_site.sso_jwt_secret');
|
||||
$walletUrl = config('lottery.main_site.wallet_api_url');
|
||||
$walletKey = config('lottery.main_site.wallet_api_key');
|
||||
|
||||
$hasLegacy = (is_string($sso) && $sso !== '')
|
||||
|| (is_string($walletUrl) && trim((string) $walletUrl) !== '');
|
||||
|
||||
if ($hasLegacy && (
|
||||
$siteCode === ''
|
||||
|| in_array($siteCode, $legacyCodes, true)
|
||||
|| app()->environment(['local', 'testing'])
|
||||
)) {
|
||||
if ($siteCode !== '') {
|
||||
Log::warning('partner_site_config.legacy_env_fallback', [
|
||||
'site_code' => $siteCode,
|
||||
'hint' => 'Configure admin_sites row for this site_code',
|
||||
]);
|
||||
}
|
||||
|
||||
return new PartnerSiteConfig(
|
||||
siteCode: $siteCode !== '' ? $siteCode : $defaultCode,
|
||||
enabled: true,
|
||||
walletApiUrl: is_string($walletUrl) && trim($walletUrl) !== ''
|
||||
? rtrim(trim($walletUrl), '/')
|
||||
: null,
|
||||
walletDebitPath: (string) config('lottery.main_site.wallet_debit_path', '/wallet/debit-for-lottery'),
|
||||
walletCreditPath: (string) config('lottery.main_site.wallet_credit_path', '/wallet/credit-from-lottery'),
|
||||
walletBalancePath: (string) config('lottery.main_site.wallet_balance_path', '/wallet/balance'),
|
||||
ssoJwtSecret: is_string($sso) && $sso !== '' ? $sso : null,
|
||||
walletApiKey: is_string($walletKey) && $walletKey !== '' ? $walletKey : null,
|
||||
walletTimeoutSeconds: max(1, (int) config('lottery.main_site.wallet_timeout', 10)),
|
||||
source: PartnerSiteConfig::SOURCE_LEGACY_ENV,
|
||||
);
|
||||
}
|
||||
|
||||
return new PartnerSiteConfig(
|
||||
siteCode: $siteCode,
|
||||
enabled: false,
|
||||
walletApiUrl: null,
|
||||
walletDebitPath: '/wallet/debit-for-lottery',
|
||||
walletCreditPath: '/wallet/credit-from-lottery',
|
||||
walletBalancePath: '/wallet/balance',
|
||||
ssoJwtSecret: null,
|
||||
walletApiKey: null,
|
||||
walletTimeoutSeconds: 10,
|
||||
source: PartnerSiteConfig::SOURCE_DATABASE,
|
||||
);
|
||||
}
|
||||
|
||||
private function base64UrlDecode(string $segment): ?string
|
||||
{
|
||||
$remainder = strlen($segment) % 4;
|
||||
if ($remainder > 0) {
|
||||
$segment .= str_repeat('=', 4 - $remainder);
|
||||
}
|
||||
|
||||
$decoded = base64_decode(strtr($segment, '-_', '+/'), true);
|
||||
|
||||
return $decoded === false ? null : $decoded;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -4,58 +4,156 @@ namespace App\Services\Wallet;
|
||||
|
||||
use App\Models\Player;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use App\Services\Integration\PartnerSiteConfigResolver;
|
||||
|
||||
/**
|
||||
* 查询主站钱包余额(供玩家端余额接口填充 main_balance)。
|
||||
*/
|
||||
final class HttpMainSiteWalletBalanceClient
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PartnerSiteConfigResolver $partnerSiteConfigResolver,
|
||||
) {}
|
||||
|
||||
public function fetch(Player $player, string $currencyCode): ?int
|
||||
{
|
||||
$base = rtrim((string) config('lottery.main_site.wallet_api_url'), '/');
|
||||
if ($base === '') {
|
||||
return null;
|
||||
$probe = $this->probe($player, $currencyCode);
|
||||
|
||||
return $probe->success ? $probe->mainBalanceMinor : null;
|
||||
}
|
||||
|
||||
$path = (string) config('lottery.main_site.wallet_balance_path', '/wallet/balance');
|
||||
public function probe(Player $player, string $currencyCode): MainSiteWalletBalanceProbeResult
|
||||
{
|
||||
$config = $this->partnerSiteConfigResolver->resolveForPlayer($player);
|
||||
$currencyCode = trim($currencyCode) !== '' ? trim($currencyCode) : 'NPR';
|
||||
|
||||
if (! $config->enabled) {
|
||||
return new MainSiteWalletBalanceProbeResult(
|
||||
success: false,
|
||||
mainBalanceMinor: null,
|
||||
currencyCode: $currencyCode,
|
||||
requestUrl: '',
|
||||
httpStatus: null,
|
||||
message: '接入站点已停用或未配置',
|
||||
responseBody: null,
|
||||
);
|
||||
}
|
||||
|
||||
if (! $config->hasWalletApi()) {
|
||||
return new MainSiteWalletBalanceProbeResult(
|
||||
success: false,
|
||||
mainBalanceMinor: null,
|
||||
currencyCode: $currencyCode,
|
||||
requestUrl: '',
|
||||
httpStatus: null,
|
||||
message: '未配置主站钱包 API URL',
|
||||
responseBody: null,
|
||||
);
|
||||
}
|
||||
|
||||
$base = rtrim((string) $config->walletApiUrl, '/');
|
||||
$path = $config->walletBalancePath;
|
||||
$url = $base.'/'.ltrim($path, '/');
|
||||
$timeout = (int) config('lottery.main_site.wallet_timeout', 10);
|
||||
$apiKey = config('lottery.main_site.wallet_api_key');
|
||||
$timeout = $config->walletTimeoutSeconds;
|
||||
$apiKey = $config->walletApiKey;
|
||||
|
||||
$headers = ['Accept' => 'application/json'];
|
||||
if (is_string($apiKey) && $apiKey !== '') {
|
||||
$headers['Authorization'] = 'Bearer '.$apiKey;
|
||||
}
|
||||
|
||||
$query = [
|
||||
'site_code' => $player->site_code,
|
||||
'site_player_id' => $player->site_player_id,
|
||||
'currency_code' => $currencyCode,
|
||||
];
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders($headers)
|
||||
->timeout($timeout)
|
||||
->acceptJson()
|
||||
->get($url, [
|
||||
'site_code' => $player->site_code,
|
||||
'site_player_id' => $player->site_player_id,
|
||||
'currency_code' => $currencyCode,
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
->get($url, $query);
|
||||
} catch (\Throwable $e) {
|
||||
return new MainSiteWalletBalanceProbeResult(
|
||||
success: false,
|
||||
mainBalanceMinor: null,
|
||||
currencyCode: $currencyCode,
|
||||
requestUrl: $url.'?'.http_build_query($query),
|
||||
httpStatus: null,
|
||||
message: '请求失败: '.$e->getMessage(),
|
||||
responseBody: null,
|
||||
);
|
||||
}
|
||||
|
||||
$httpStatus = $response->status();
|
||||
$payload = $response->json();
|
||||
$preview = is_array($payload) ? self::truncateResponsePreview($payload) : null;
|
||||
|
||||
if (! $response->successful()) {
|
||||
return null;
|
||||
$message = is_array($payload) && is_string($payload['message'] ?? null)
|
||||
? (string) $payload['message']
|
||||
: 'HTTP '.$httpStatus;
|
||||
|
||||
return new MainSiteWalletBalanceProbeResult(
|
||||
success: false,
|
||||
mainBalanceMinor: null,
|
||||
currencyCode: $currencyCode,
|
||||
requestUrl: $url.'?'.http_build_query($query),
|
||||
httpStatus: $httpStatus,
|
||||
message: $message,
|
||||
responseBody: $preview,
|
||||
);
|
||||
}
|
||||
|
||||
$payload = $response->json();
|
||||
if (! is_array($payload)) {
|
||||
return null;
|
||||
return new MainSiteWalletBalanceProbeResult(
|
||||
success: false,
|
||||
mainBalanceMinor: null,
|
||||
currencyCode: $currencyCode,
|
||||
requestUrl: $url.'?'.http_build_query($query),
|
||||
httpStatus: $httpStatus,
|
||||
message: '响应不是 JSON 对象',
|
||||
responseBody: null,
|
||||
);
|
||||
}
|
||||
|
||||
$raw = data_get($payload, 'data.main_balance')
|
||||
?? data_get($payload, 'main_balance');
|
||||
|
||||
if (! is_numeric($raw)) {
|
||||
return null;
|
||||
return new MainSiteWalletBalanceProbeResult(
|
||||
success: false,
|
||||
mainBalanceMinor: null,
|
||||
currencyCode: $currencyCode,
|
||||
requestUrl: $url.'?'.http_build_query($query),
|
||||
httpStatus: $httpStatus,
|
||||
message: '响应缺少 main_balance 数值',
|
||||
responseBody: $preview,
|
||||
);
|
||||
}
|
||||
|
||||
return max(0, (int) $raw);
|
||||
return new MainSiteWalletBalanceProbeResult(
|
||||
success: true,
|
||||
mainBalanceMinor: max(0, (int) $raw),
|
||||
currencyCode: (string) (data_get($payload, 'data.currency_code') ?? $currencyCode),
|
||||
requestUrl: $url.'?'.http_build_query($query),
|
||||
httpStatus: $httpStatus,
|
||||
message: null,
|
||||
responseBody: $preview,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function truncateResponsePreview(array $payload): array
|
||||
{
|
||||
$json = json_encode($payload, JSON_UNESCAPED_UNICODE);
|
||||
if (is_string($json) && strlen($json) > 512) {
|
||||
return ['_truncated' => true, 'preview' => substr($json, 0, 512)];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
35
app/Services/Wallet/MainSiteWalletBalanceProbeResult.php
Normal file
35
app/Services/Wallet/MainSiteWalletBalanceProbeResult.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Wallet;
|
||||
|
||||
/**
|
||||
* 主站 balance 探测结果(后台联调检测用,含诊断信息)。
|
||||
*/
|
||||
final readonly class MainSiteWalletBalanceProbeResult
|
||||
{
|
||||
public function __construct(
|
||||
public bool $success,
|
||||
public ?int $mainBalanceMinor,
|
||||
public string $currencyCode,
|
||||
public string $requestUrl,
|
||||
public ?int $httpStatus,
|
||||
public ?string $message,
|
||||
public ?array $responseBody,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'success' => $this->success,
|
||||
'main_balance_minor' => $this->mainBalanceMinor,
|
||||
'currency_code' => $this->currencyCode,
|
||||
'request_url' => $this->requestUrl,
|
||||
'http_status' => $this->httpStatus,
|
||||
'message' => $this->message,
|
||||
'response_preview' => $this->responseBody,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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']],
|
||||
|
||||
39
app/Support/AdminIntegrationSiteAccess.php
Normal file
39
app/Support/AdminIntegrationSiteAccess.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AdminSite;
|
||||
use App\Models\AdminUser;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
final class AdminIntegrationSiteAccess
|
||||
{
|
||||
/**
|
||||
* @return Builder<AdminSite>
|
||||
*/
|
||||
public static function queryFor(AdminUser $admin): Builder
|
||||
{
|
||||
$query = AdminSite::query()->orderBy('code');
|
||||
|
||||
$siteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($siteIds === null) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
if ($siteIds === []) {
|
||||
return $query->whereRaw('0 = 1');
|
||||
}
|
||||
|
||||
return $query->whereIn('id', $siteIds);
|
||||
}
|
||||
|
||||
public static function canAccess(AdminUser $admin, AdminSite $site): bool
|
||||
{
|
||||
$siteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($siteIds === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array((int) $site->id, $siteIds, true);
|
||||
}
|
||||
}
|
||||
118
app/Support/AdminIntegrationSitePresenter.php
Normal file
118
app/Support/AdminIntegrationSitePresenter.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AdminSite;
|
||||
|
||||
final class AdminIntegrationSitePresenter
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function listItem(AdminSite $site): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $site->id,
|
||||
'code' => (string) $site->code,
|
||||
'name' => (string) $site->name,
|
||||
'currency_code' => (string) $site->currency_code,
|
||||
'status' => (int) $site->status,
|
||||
'wallet_api_url' => $site->wallet_api_url,
|
||||
'wallet_timeout_seconds' => (int) ($site->wallet_timeout_seconds ?? 10),
|
||||
'has_sso_secret' => is_string($site->sso_jwt_secret_encrypted) && $site->sso_jwt_secret_encrypted !== '',
|
||||
'has_wallet_api_key' => is_string($site->wallet_api_key_encrypted) && $site->wallet_api_key_encrypted !== '',
|
||||
'sso_secret_masked' => is_string($site->sso_jwt_secret_encrypted) && $site->sso_jwt_secret_encrypted !== ''
|
||||
? '••••••••'
|
||||
: null,
|
||||
'wallet_api_key_masked' => is_string($site->wallet_api_key_encrypted) && $site->wallet_api_key_encrypted !== ''
|
||||
? '••••••••'
|
||||
: null,
|
||||
'updated_at' => $site->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function detail(AdminSite $site): array
|
||||
{
|
||||
return array_merge(self::listItem($site), [
|
||||
'wallet_debit_path' => (string) $site->wallet_debit_path,
|
||||
'wallet_credit_path' => (string) $site->wallet_credit_path,
|
||||
'wallet_balance_path' => (string) $site->wallet_balance_path,
|
||||
'iframe_allowed_origins' => $site->iframe_allowed_origins ?? [],
|
||||
'lottery_h5_base_url' => $site->lottery_h5_base_url,
|
||||
'notes' => $site->notes,
|
||||
'is_default' => (bool) $site->is_default,
|
||||
'created_at' => $site->created_at?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{sso_jwt_secret: string, wallet_api_key: string} $secrets
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function withPlainSecretsOnce(array $payload, array $secrets): array
|
||||
{
|
||||
return array_merge($payload, [
|
||||
'secrets' => [
|
||||
'sso_jwt_secret' => $secrets['sso_jwt_secret'],
|
||||
'wallet_api_key' => $secrets['wallet_api_key'],
|
||||
],
|
||||
'secrets_display_once' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 交接/联调参数表(不含密钥明文)。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function parameterSheet(AdminSite $site): array
|
||||
{
|
||||
$walletBase = is_string($site->wallet_api_url) && $site->wallet_api_url !== ''
|
||||
? rtrim($site->wallet_api_url, '/')
|
||||
: null;
|
||||
|
||||
$fullUrl = static function (?string $path) use ($walletBase): ?string {
|
||||
if ($walletBase === null || ! is_string($path) || $path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $walletBase.'/'.ltrim($path, '/');
|
||||
};
|
||||
|
||||
$h5Base = $site->lottery_h5_base_url;
|
||||
if (! is_string($h5Base) || trim($h5Base) === '') {
|
||||
$h5Base = config('lottery.main_site.base_url');
|
||||
}
|
||||
|
||||
return [
|
||||
'exported_at' => now()->toIso8601String(),
|
||||
'site_code' => (string) $site->code,
|
||||
'name' => (string) $site->name,
|
||||
'status' => (int) $site->status === 1 ? 'enabled' : 'disabled',
|
||||
'currency_code' => (string) $site->currency_code,
|
||||
'lottery_h5_base_url' => $h5Base,
|
||||
'wallet_api_url' => $site->wallet_api_url,
|
||||
'wallet_balance_url' => $fullUrl($site->wallet_balance_path),
|
||||
'wallet_debit_url' => $fullUrl($site->wallet_debit_path),
|
||||
'wallet_credit_url' => $fullUrl($site->wallet_credit_path),
|
||||
'wallet_balance_path' => (string) $site->wallet_balance_path,
|
||||
'wallet_debit_path' => (string) $site->wallet_debit_path,
|
||||
'wallet_credit_path' => (string) $site->wallet_credit_path,
|
||||
'wallet_timeout_seconds' => (int) ($site->wallet_timeout_seconds ?? 10),
|
||||
'iframe_allowed_origins' => $site->iframe_allowed_origins ?? [],
|
||||
'sso_secret_configured' => is_string($site->sso_jwt_secret_encrypted) && $site->sso_jwt_secret_encrypted !== '',
|
||||
'sso_secret_masked' => is_string($site->sso_jwt_secret_encrypted) && $site->sso_jwt_secret_encrypted !== ''
|
||||
? '••••••••'
|
||||
: null,
|
||||
'wallet_api_key_configured' => is_string($site->wallet_api_key_encrypted) && $site->wallet_api_key_encrypted !== '',
|
||||
'wallet_api_key_masked' => is_string($site->wallet_api_key_encrypted) && $site->wallet_api_key_encrypted !== ''
|
||||
? '••••••••'
|
||||
: null,
|
||||
'notes' => $site->notes,
|
||||
'security_note' => '密钥明文仅于创建/重置时展示一次,请勿通过导出或邮件长期传播。',
|
||||
];
|
||||
}
|
||||
}
|
||||
155
app/Support/AdminSiteScope.php
Normal file
155
app/Support/AdminSiteScope.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AdminSite;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Player;
|
||||
use App\Lottery\ErrorCode;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* 后台站点数据范围:非超管仅可访问 {@see AdminUser::accessibleAdminSiteIds()} 绑定站点。
|
||||
*
|
||||
* 对应产品「site_only / site_all_data」;运行时以 admin_user_site_roles 为准(非已废弃的 admin_data_scopes 表)。
|
||||
*/
|
||||
final class AdminSiteScope
|
||||
{
|
||||
/**
|
||||
* @return list<string>|null `null` 表示不限制(超管)
|
||||
*/
|
||||
public static function accessibleSiteCodes(AdminUser $admin): ?array
|
||||
{
|
||||
$siteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($siteIds === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($siteIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return AdminSite::query()
|
||||
->whereIn('id', $siteIds)
|
||||
->orderBy('code')
|
||||
->pluck('code')
|
||||
->map(static fn ($code): string => (string) $code)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public static function siteCodeAllowed(AdminUser $admin, string $siteCode): bool
|
||||
{
|
||||
$allowed = self::accessibleSiteCodes($admin);
|
||||
if ($allowed === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($siteCode, $allowed, true);
|
||||
}
|
||||
|
||||
public static function playerAccessible(AdminUser $admin, Player $player): bool
|
||||
{
|
||||
return self::siteCodeAllowed($admin, (string) $player->site_code);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Player> $query
|
||||
*/
|
||||
public static function applyToPlayerQuery(Builder $query, AdminUser $admin): void
|
||||
{
|
||||
$codes = self::accessibleSiteCodes($admin);
|
||||
if ($codes === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($codes === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->whereIn('site_code', $codes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在站点范围基础上,可选按请求的 site_code 再收窄。
|
||||
*
|
||||
* @param Builder<Player> $query
|
||||
*/
|
||||
public static function applyPlayerFilters(Builder $query, AdminUser $admin, ?string $requestedSiteCode): void
|
||||
{
|
||||
self::applyToPlayerQuery($query, $admin);
|
||||
|
||||
$siteCode = is_string($requestedSiteCode) ? trim($requestedSiteCode) : '';
|
||||
if ($siteCode === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! self::siteCodeAllowed($admin, $siteCode)) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->where('site_code', $siteCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<mixed> $query
|
||||
*/
|
||||
public static function applyViaPlayerRelation(Builder $query, AdminUser $admin, string $relation = 'player'): void
|
||||
{
|
||||
$codes = self::accessibleSiteCodes($admin);
|
||||
if ($codes === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($codes === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->whereHas($relation, static function (Builder $playerQuery) use ($codes): void {
|
||||
$playerQuery->whereIn('site_code', $codes);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<mixed> $query
|
||||
*/
|
||||
public static function applyViaPlayerRelationWithSiteCode(
|
||||
Builder $query,
|
||||
AdminUser $admin,
|
||||
?string $requestedSiteCode,
|
||||
string $relation = 'player',
|
||||
): void {
|
||||
self::applyViaPlayerRelation($query, $admin, $relation);
|
||||
|
||||
$siteCode = is_string($requestedSiteCode) ? trim($requestedSiteCode) : '';
|
||||
if ($siteCode === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! self::siteCodeAllowed($admin, $siteCode)) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->whereHas($relation, static function (Builder $playerQuery) use ($siteCode): void {
|
||||
$playerQuery->where('site_code', $siteCode);
|
||||
});
|
||||
}
|
||||
|
||||
public static function denyUnlessPlayerAccessible(AdminUser $admin, Player $player): ?JsonResponse
|
||||
{
|
||||
if (! self::playerAccessible($admin, $player)) {
|
||||
return ApiResponse::error('无权访问该站点下的玩家', ErrorCode::AdminForbidden->value, null, 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
20
app/Support/IntegrationSiteSecretMask.php
Normal file
20
app/Support/IntegrationSiteSecretMask.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
final class IntegrationSiteSecretMask
|
||||
{
|
||||
public static function mask(?string $plain): ?string
|
||||
{
|
||||
if ($plain === null || $plain === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$len = strlen($plain);
|
||||
if ($len <= 8) {
|
||||
return str_repeat('*', $len);
|
||||
}
|
||||
|
||||
return substr($plain, 0, 4).str_repeat('*', max(4, $len - 8)).substr($plain, -4);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,14 @@ return [
|
||||
'wallet_balance_path' => env('MAIN_SITE_WALLET_BALANCE_PATH', '/wallet/balance'),
|
||||
],
|
||||
|
||||
/*
|
||||
| integration:主站接入站点(admin_sites 扩展字段);未配置库表时可回退 main_site env。
|
||||
*/
|
||||
'integration' => [
|
||||
'default_site_code' => env('LOTTERY_DEFAULT_SITE_CODE', 'default_site'),
|
||||
'legacy_env_site_code' => env('LOTTERY_LEGACY_MAIN_SITE_CODE', 'default_site'),
|
||||
],
|
||||
|
||||
/*
|
||||
| player_auth:配合 app/Services/PlayerTokenResolver.php
|
||||
|
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use App\Support\AdminAuthorizationRegistry;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('admin_sites', function (Blueprint $table): void {
|
||||
$table->string('wallet_api_url', 512)->nullable()->after('extra_json');
|
||||
$table->string('wallet_debit_path', 128)->default('/wallet/debit-for-lottery')->after('wallet_api_url');
|
||||
$table->string('wallet_credit_path', 128)->default('/wallet/credit-from-lottery')->after('wallet_debit_path');
|
||||
$table->string('wallet_balance_path', 128)->default('/wallet/balance')->after('wallet_credit_path');
|
||||
$table->text('wallet_api_key_encrypted')->nullable()->after('wallet_balance_path');
|
||||
$table->text('sso_jwt_secret_encrypted')->nullable()->after('wallet_api_key_encrypted');
|
||||
$table->unsignedSmallInteger('wallet_timeout_seconds')->default(10)->after('sso_jwt_secret_encrypted');
|
||||
$table->json('iframe_allowed_origins')->nullable()->after('wallet_timeout_seconds');
|
||||
$table->string('lottery_h5_base_url', 512)->nullable()->after('iframe_allowed_origins');
|
||||
$table->text('notes')->nullable()->after('lottery_h5_base_url');
|
||||
});
|
||||
|
||||
$this->seedIntegrationMenuActions();
|
||||
$this->backfillDefaultSiteFromEnv();
|
||||
$this->syncIntegrationApiResources();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$resourceIds = DB::table('admin_api_resources')
|
||||
->where('code', 'like', 'admin.integration-sites.%')
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
if ($resourceIds !== []) {
|
||||
DB::table('admin_role_api_resources')->whereIn('api_resource_id', $resourceIds)->delete();
|
||||
DB::table('admin_api_resource_bindings')->whereIn('api_resource_id', $resourceIds)->delete();
|
||||
DB::table('admin_api_resources')->whereIn('id', $resourceIds)->delete();
|
||||
}
|
||||
|
||||
Schema::table('admin_sites', function (Blueprint $table): void {
|
||||
$table->dropColumn([
|
||||
'wallet_api_url',
|
||||
'wallet_debit_path',
|
||||
'wallet_credit_path',
|
||||
'wallet_balance_path',
|
||||
'wallet_api_key_encrypted',
|
||||
'sso_jwt_secret_encrypted',
|
||||
'wallet_timeout_seconds',
|
||||
'iframe_allowed_origins',
|
||||
'lottery_h5_base_url',
|
||||
'notes',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
private function seedIntegrationMenuActions(): void
|
||||
{
|
||||
$now = Carbon::now();
|
||||
$viewActionId = DB::table('admin_action_catalog')->where('code', 'view')->value('id');
|
||||
$manageActionId = DB::table('admin_action_catalog')->where('code', 'manage')->value('id');
|
||||
if ($viewActionId === null || $manageActionId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$configMenuId = DB::table('admin_menus')->where('code', 'config')->value('id');
|
||||
if ($configMenuId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$integrationMenuId = DB::table('admin_menus')->where('code', 'config.integration')->value('id');
|
||||
if ($integrationMenuId === null) {
|
||||
$integrationMenuId = DB::table('admin_menus')->insertGetId([
|
||||
'parent_id' => (int) $configMenuId,
|
||||
'menu_type' => 'page',
|
||||
'code' => 'config.integration',
|
||||
'name' => '主站接入站点',
|
||||
'path' => '/admin/config/integration-sites',
|
||||
'route_name' => 'admin.config.integration',
|
||||
'component' => 'config/integration',
|
||||
'icon' => null,
|
||||
'active_menu_code' => null,
|
||||
'sort_order' => 45,
|
||||
'is_visible' => true,
|
||||
'is_cache' => false,
|
||||
'is_external' => false,
|
||||
'status' => 1,
|
||||
'meta_json' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ([
|
||||
['permission_code' => 'integration.site.view', 'action_id' => (int) $viewActionId, 'name' => '接入站点查看'],
|
||||
['permission_code' => 'integration.site.manage', 'action_id' => (int) $manageActionId, 'name' => '接入站点管理'],
|
||||
] as $row) {
|
||||
$exists = DB::table('admin_menu_actions')
|
||||
->where('permission_code', $row['permission_code'])
|
||||
->exists();
|
||||
if ($exists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('admin_menu_actions')->insert([
|
||||
'menu_id' => (int) $integrationMenuId,
|
||||
'action_id' => $row['action_id'],
|
||||
'permission_code' => $row['permission_code'],
|
||||
'name' => $row['name'],
|
||||
'status' => 1,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillDefaultSiteFromEnv(): void
|
||||
{
|
||||
$siteId = DB::table('admin_sites')->where('is_default', true)->value('id')
|
||||
?? DB::table('admin_sites')->orderBy('id')->value('id');
|
||||
|
||||
if ($siteId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$walletUrl = env('MAIN_SITE_WALLET_API_URL');
|
||||
$ssoSecret = env('MAIN_SITE_SSO_JWT_SECRET');
|
||||
$walletKey = env('MAIN_SITE_WALLET_API_KEY');
|
||||
|
||||
$payload = [
|
||||
'updated_at' => Carbon::now(),
|
||||
];
|
||||
|
||||
if (is_string($walletUrl) && trim($walletUrl) !== '') {
|
||||
$payload['wallet_api_url'] = rtrim(trim($walletUrl), '/');
|
||||
}
|
||||
|
||||
$debitPath = env('MAIN_SITE_WALLET_DEBIT_PATH');
|
||||
if (is_string($debitPath) && $debitPath !== '') {
|
||||
$payload['wallet_debit_path'] = $debitPath;
|
||||
}
|
||||
|
||||
$creditPath = env('MAIN_SITE_WALLET_CREDIT_PATH');
|
||||
if (is_string($creditPath) && $creditPath !== '') {
|
||||
$payload['wallet_credit_path'] = $creditPath;
|
||||
}
|
||||
|
||||
$balancePath = env('MAIN_SITE_WALLET_BALANCE_PATH');
|
||||
if (is_string($balancePath) && $balancePath !== '') {
|
||||
$payload['wallet_balance_path'] = $balancePath;
|
||||
}
|
||||
|
||||
$timeout = env('MAIN_SITE_WALLET_TIMEOUT');
|
||||
if (is_numeric($timeout)) {
|
||||
$payload['wallet_timeout_seconds'] = max(1, (int) $timeout);
|
||||
}
|
||||
|
||||
if (is_string($ssoSecret) && $ssoSecret !== '') {
|
||||
$payload['sso_jwt_secret_encrypted'] = encrypt($ssoSecret);
|
||||
}
|
||||
|
||||
if (is_string($walletKey) && $walletKey !== '') {
|
||||
$payload['wallet_api_key_encrypted'] = encrypt($walletKey);
|
||||
}
|
||||
|
||||
if (count($payload) > 1) {
|
||||
DB::table('admin_sites')->where('id', (int) $siteId)->update($payload);
|
||||
}
|
||||
}
|
||||
|
||||
private function syncIntegrationApiResources(): void
|
||||
{
|
||||
if (! Schema::hasTable('admin_api_resources')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = Carbon::now();
|
||||
$menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code');
|
||||
|
||||
$resources = array_values(array_filter(
|
||||
AdminAuthorizationRegistry::resources(),
|
||||
static fn (array $resource): bool => str_starts_with((string) $resource['code'], 'admin.integration-sites.'),
|
||||
));
|
||||
|
||||
foreach ($resources as $resource) {
|
||||
$resourceId = DB::table('admin_api_resources')
|
||||
->where('code', $resource['code'])
|
||||
->value('id');
|
||||
|
||||
$payload = [
|
||||
'module_code' => $resource['module_code'],
|
||||
'name' => $resource['name'],
|
||||
'http_method' => $resource['http_method'],
|
||||
'uri_pattern' => $resource['uri_pattern'],
|
||||
'route_name' => $resource['route_name'],
|
||||
'auth_mode' => $resource['auth_mode'],
|
||||
'is_audit_required' => $resource['is_audit_required'],
|
||||
'status' => 1,
|
||||
'meta_json' => null,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($resourceId === null) {
|
||||
$resourceId = DB::table('admin_api_resources')->insertGetId($payload + [
|
||||
'code' => $resource['code'],
|
||||
'created_at' => $now,
|
||||
]);
|
||||
} else {
|
||||
DB::table('admin_api_resources')
|
||||
->where('id', (int) $resourceId)
|
||||
->update($payload);
|
||||
}
|
||||
|
||||
DB::table('admin_api_resource_bindings')
|
||||
->where('api_resource_id', (int) $resourceId)
|
||||
->delete();
|
||||
|
||||
foreach ($resource['permission_codes'] as $permissionCode) {
|
||||
$menuActionId = $menuActionIds[$permissionCode] ?? null;
|
||||
if ($menuActionId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('admin_api_resource_bindings')->insert([
|
||||
'api_resource_id' => (int) $resourceId,
|
||||
'menu_action_id' => (int) $menuActionId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Support\AdminAuthorizationRegistry;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
/**
|
||||
* 为已执行 140000 的环境补种 integration 权限动作并同步 API 绑定。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$now = Carbon::now();
|
||||
$viewActionId = DB::table('admin_action_catalog')->where('code', 'view')->value('id');
|
||||
$manageActionId = DB::table('admin_action_catalog')->where('code', 'manage')->value('id');
|
||||
if ($viewActionId === null || $manageActionId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$configMenuId = DB::table('admin_menus')->where('code', 'config')->value('id');
|
||||
if ($configMenuId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$integrationMenuId = DB::table('admin_menus')->where('code', 'config.integration')->value('id');
|
||||
if ($integrationMenuId === null) {
|
||||
$integrationMenuId = DB::table('admin_menus')->insertGetId([
|
||||
'parent_id' => (int) $configMenuId,
|
||||
'menu_type' => 'page',
|
||||
'code' => 'config.integration',
|
||||
'name' => '主站接入站点',
|
||||
'path' => '/admin/config/integration-sites',
|
||||
'route_name' => 'admin.config.integration',
|
||||
'component' => 'config/integration',
|
||||
'icon' => null,
|
||||
'active_menu_code' => null,
|
||||
'sort_order' => 45,
|
||||
'is_visible' => true,
|
||||
'is_cache' => false,
|
||||
'is_external' => false,
|
||||
'status' => 1,
|
||||
'meta_json' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ([
|
||||
['permission_code' => 'integration.site.view', 'action_id' => (int) $viewActionId, 'name' => '接入站点查看'],
|
||||
['permission_code' => 'integration.site.manage', 'action_id' => (int) $manageActionId, 'name' => '接入站点管理'],
|
||||
] as $row) {
|
||||
if (DB::table('admin_menu_actions')->where('permission_code', $row['permission_code'])->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('admin_menu_actions')->insert([
|
||||
'menu_id' => (int) $integrationMenuId,
|
||||
'action_id' => $row['action_id'],
|
||||
'permission_code' => $row['permission_code'],
|
||||
'name' => $row['name'],
|
||||
'status' => 1,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code');
|
||||
$resources = array_values(array_filter(
|
||||
AdminAuthorizationRegistry::resources(),
|
||||
static fn (array $resource): bool => str_starts_with((string) $resource['code'], 'admin.integration-sites.'),
|
||||
));
|
||||
|
||||
foreach ($resources as $resource) {
|
||||
$resourceId = DB::table('admin_api_resources')->where('code', $resource['code'])->value('id');
|
||||
if ($resourceId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('admin_api_resource_bindings')
|
||||
->where('api_resource_id', (int) $resourceId)
|
||||
->delete();
|
||||
|
||||
foreach ($resource['permission_codes'] as $permissionCode) {
|
||||
$menuActionId = $menuActionIds[$permissionCode] ?? null;
|
||||
if ($menuActionId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('admin_api_resource_bindings')->insert([
|
||||
'api_resource_id' => (int) $resourceId,
|
||||
'menu_action_id' => (int) $menuActionId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// 保留 menu_actions / bindings,避免回滚后超管无法管理已创建的接入站点。
|
||||
}
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
28
routes/api/v1/admin/integration.php
Normal file
28
routes/api/v1/admin/integration.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteIndexController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteStoreController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteShowController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteUpdateController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteRotateSecretsController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteConnectivityTestController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteExportController;
|
||||
|
||||
Route::middleware('admin.api-resource')
|
||||
->group(function (): void {
|
||||
Route::get('integration-sites', AdminIntegrationSiteIndexController::class)
|
||||
->name('api.v1.admin.integration-sites.index');
|
||||
Route::post('integration-sites', AdminIntegrationSiteStoreController::class)
|
||||
->name('api.v1.admin.integration-sites.store');
|
||||
Route::get('integration-sites/{admin_site}', AdminIntegrationSiteShowController::class)
|
||||
->name('api.v1.admin.integration-sites.show');
|
||||
Route::put('integration-sites/{admin_site}', AdminIntegrationSiteUpdateController::class)
|
||||
->name('api.v1.admin.integration-sites.update');
|
||||
Route::post('integration-sites/{admin_site}/rotate-secrets', AdminIntegrationSiteRotateSecretsController::class)
|
||||
->name('api.v1.admin.integration-sites.rotate-secrets');
|
||||
Route::post('integration-sites/{admin_site}/connectivity-test', AdminIntegrationSiteConnectivityTestController::class)
|
||||
->name('api.v1.admin.integration-sites.connectivity-test');
|
||||
Route::get('integration-sites/{admin_site}/export', AdminIntegrationSiteExportController::class)
|
||||
->name('api.v1.admin.integration-sites.export');
|
||||
});
|
||||
325
tests/Feature/AdminIntegrationSiteApiTest.php
Normal file
325
tests/Feature/AdminIntegrationSiteApiTest.php
Normal file
@@ -0,0 +1,325 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminSite;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Player;
|
||||
use App\Services\Integration\PartnerSiteConfig;
|
||||
use App\Services\Integration\PartnerSiteConfigResolver;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
});
|
||||
|
||||
function integrationAdminToken(): string
|
||||
{
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'integration_admin',
|
||||
'name' => 'Integration Admin',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
|
||||
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
}
|
||||
|
||||
test('super admin can create integration site and receive secrets once', function (): void {
|
||||
$token = integrationAdminToken();
|
||||
|
||||
$response = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/integration-sites', [
|
||||
'code' => 'partner-a',
|
||||
'name' => 'Partner A',
|
||||
'wallet_api_url' => 'https://wallet.partner-a.test',
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('code', 0)
|
||||
->assertJsonPath('data.code', 'partner-a')
|
||||
->assertJsonPath('data.secrets_display_once', true)
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'secrets' => ['sso_jwt_secret', 'wallet_api_key'],
|
||||
],
|
||||
]);
|
||||
|
||||
$site = AdminSite::query()->where('code', 'partner-a')->first();
|
||||
expect($site)->not->toBeNull();
|
||||
expect($site?->decryptedSsoJwtSecret())->not->toBeEmpty();
|
||||
|
||||
expect(AuditLog::query()->where('module_code', 'integration')->where('action_code', 'create')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('integration site code cannot be changed on update', function (): void {
|
||||
$token = integrationAdminToken();
|
||||
|
||||
$create = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/integration-sites', [
|
||||
'code' => 'partner-b',
|
||||
'name' => 'Partner B',
|
||||
]);
|
||||
$create->assertCreated();
|
||||
$id = (int) $create->json('data.id');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/integration-sites/'.$id, [
|
||||
'name' => 'Partner B Renamed',
|
||||
'status' => 1,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.code', 'partner-b')
|
||||
->assertJsonPath('data.name', 'Partner B Renamed');
|
||||
});
|
||||
|
||||
test('partner site config resolver cache roundtrip returns partner site config', function (): void {
|
||||
AdminSite::query()->create([
|
||||
'code' => 'cache-roundtrip',
|
||||
'name' => 'Cache',
|
||||
'currency_code' => 'NPR',
|
||||
'status' => 1,
|
||||
'is_default' => false,
|
||||
'sso_jwt_secret_encrypted' => encrypt('cache-sso'),
|
||||
'wallet_api_key_encrypted' => encrypt('cache-wallet'),
|
||||
]);
|
||||
|
||||
$resolver = app(PartnerSiteConfigResolver::class);
|
||||
$first = $resolver->resolveBySiteCode('cache-roundtrip');
|
||||
$second = $resolver->resolveBySiteCode('cache-roundtrip');
|
||||
|
||||
expect($second)->toBeInstanceOf(PartnerSiteConfig::class)
|
||||
->and($second->ssoJwtSecret)->toBe('cache-sso');
|
||||
});
|
||||
|
||||
test('partner site config resolver reads database secrets', function (): void {
|
||||
AdminSite::query()->create([
|
||||
'code' => 'partner-db',
|
||||
'name' => 'DB Partner',
|
||||
'currency_code' => 'NPR',
|
||||
'status' => 1,
|
||||
'is_default' => false,
|
||||
'sso_jwt_secret_encrypted' => encrypt('db-sso-secret'),
|
||||
'wallet_api_key_encrypted' => encrypt('db-wallet-key'),
|
||||
'wallet_api_url' => 'https://wallet.db.test',
|
||||
]);
|
||||
|
||||
$config = app(PartnerSiteConfigResolver::class)->resolveBySiteCode('partner-db');
|
||||
|
||||
expect($config->source)->toBe('database')
|
||||
->and($config->ssoJwtSecret)->toBe('db-sso-secret')
|
||||
->and($config->walletApiKey)->toBe('db-wallet-key')
|
||||
->and($config->walletApiUrl)->toBe('https://wallet.db.test');
|
||||
});
|
||||
|
||||
test('rotate secrets returns new plaintext once', function (): void {
|
||||
$token = integrationAdminToken();
|
||||
|
||||
$create = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/integration-sites', [
|
||||
'code' => 'partner-rotate',
|
||||
'name' => 'Rotate',
|
||||
]);
|
||||
$id = (int) $create->json('data.id');
|
||||
$oldSecret = (string) $create->json('data.secrets.sso_jwt_secret');
|
||||
|
||||
$rotate = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/integration-sites/'.$id.'/rotate-secrets');
|
||||
$rotate->assertOk();
|
||||
$newSecret = (string) $rotate->json('data.secrets.sso_jwt_secret');
|
||||
|
||||
expect($newSecret)->not->toBe($oldSecret);
|
||||
|
||||
expect(AuditLog::query()->where('action_code', 'rotate_secrets')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('connectivity test probes partner balance api', function (): void {
|
||||
Http::fake([
|
||||
'https://wallet.probe.test/*' => Http::response([
|
||||
'success' => true,
|
||||
'data' => ['main_balance' => 12345, 'currency_code' => 'NPR'],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$token = integrationAdminToken();
|
||||
|
||||
$create = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/integration-sites', [
|
||||
'code' => 'probe-site',
|
||||
'name' => 'Probe',
|
||||
'wallet_api_url' => 'https://wallet.probe.test',
|
||||
]);
|
||||
$id = (int) $create->json('data.id');
|
||||
|
||||
$response = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/integration-sites/'.$id.'/connectivity-test', [
|
||||
'site_player_id' => '10001',
|
||||
'currency_code' => 'NPR',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.probe.success', true)
|
||||
->assertJsonPath('data.probe.main_balance_minor', 12345);
|
||||
});
|
||||
|
||||
test('export parameter sheet excludes plaintext secrets', function (): void {
|
||||
$token = integrationAdminToken();
|
||||
|
||||
$create = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/integration-sites', [
|
||||
'code' => 'export-site',
|
||||
'name' => 'Export',
|
||||
'wallet_api_url' => 'https://wallet.export.test',
|
||||
]);
|
||||
$id = (int) $create->json('data.id');
|
||||
|
||||
$response = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/integration-sites/'.$id.'/export');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.site_code', 'export-site')
|
||||
->assertJsonPath('data.sso_secret_masked', '••••••••')
|
||||
->assertJsonMissingPath('data.secrets')
|
||||
->assertJsonMissingPath('data.sso_jwt_secret');
|
||||
});
|
||||
|
||||
test('site scoped admin only sees bound integration sites', function (): void {
|
||||
$token = integrationAdminToken();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/integration-sites', [
|
||||
'code' => 'site-a',
|
||||
'name' => 'Site A',
|
||||
])
|
||||
->assertCreated();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/integration-sites', [
|
||||
'code' => 'site-b',
|
||||
'name' => 'Site B',
|
||||
])
|
||||
->assertCreated();
|
||||
|
||||
$siteAId = (int) AdminSite::query()->where('code', 'site-a')->value('id');
|
||||
$siteBId = (int) AdminSite::query()->where('code', 'site-b')->value('id');
|
||||
|
||||
$scopedAdmin = AdminUser::query()->create([
|
||||
'username' => 'integration_scoped',
|
||||
'name' => 'Scoped',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$roleId = (int) DB::table('admin_roles')->insertGetId([
|
||||
'slug' => 'integration_scoped_role',
|
||||
'name' => 'Integration Scoped',
|
||||
'code' => 'integration_scoped_role',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$viewActionId = (int) DB::table('admin_menu_actions')
|
||||
->where('permission_code', 'integration.site.view')
|
||||
->value('id');
|
||||
|
||||
DB::table('admin_role_menu_actions')->insert([
|
||||
'role_id' => $roleId,
|
||||
'menu_action_id' => $viewActionId,
|
||||
]);
|
||||
|
||||
DB::table('admin_user_site_roles')->insert([
|
||||
'admin_user_id' => $scopedAdmin->id,
|
||||
'site_id' => $siteAId,
|
||||
'role_id' => $roleId,
|
||||
'granted_at' => now(),
|
||||
]);
|
||||
|
||||
$scopedToken = $scopedAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
app('auth')->forgetGuards();
|
||||
|
||||
$list = $this->withHeader('Authorization', 'Bearer '.$scopedToken)
|
||||
->getJson('/api/v1/admin/integration-sites')
|
||||
->assertOk();
|
||||
|
||||
$codes = collect($list->json('data.items'))->pluck('code')->all();
|
||||
expect($codes)->toContain('site-a')->not->toContain('site-b');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$scopedToken)
|
||||
->getJson('/api/v1/admin/integration-sites/'.$siteBId)
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('player list is filtered by admin site binding', function (): void {
|
||||
$this->seed(\Database\Seeders\CurrencySeeder::class);
|
||||
|
||||
Player::query()->create([
|
||||
'site_code' => 'site-a',
|
||||
'site_player_id' => 'pa-1',
|
||||
'username' => 'pa1',
|
||||
'nickname' => 'PA1',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
Player::query()->create([
|
||||
'site_code' => 'site-b',
|
||||
'site_player_id' => 'pb-1',
|
||||
'username' => 'pb1',
|
||||
'nickname' => 'PB1',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
AdminSite::query()->firstOrCreate(['code' => 'site-a'], ['name' => 'A', 'currency_code' => 'NPR', 'status' => 1]);
|
||||
AdminSite::query()->firstOrCreate(['code' => 'site-b'], ['name' => 'B', 'currency_code' => 'NPR', 'status' => 1]);
|
||||
$siteAId = (int) AdminSite::query()->where('code', 'site-a')->value('id');
|
||||
|
||||
$scopedAdmin = AdminUser::query()->create([
|
||||
'username' => 'player_scoped',
|
||||
'name' => 'Player Scoped',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$roleId = (int) DB::table('admin_roles')->insertGetId([
|
||||
'slug' => 'player_scoped_role',
|
||||
'name' => 'Player Scoped',
|
||||
'code' => 'player_scoped_role',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$viewActionId = (int) DB::table('admin_menu_actions')
|
||||
->where('permission_code', 'service.players.view')
|
||||
->value('id');
|
||||
|
||||
DB::table('admin_role_menu_actions')->insert([
|
||||
'role_id' => $roleId,
|
||||
'menu_action_id' => $viewActionId,
|
||||
]);
|
||||
|
||||
DB::table('admin_user_site_roles')->insert([
|
||||
'admin_user_id' => $scopedAdmin->id,
|
||||
'site_id' => $siteAId,
|
||||
'role_id' => $roleId,
|
||||
'granted_at' => now(),
|
||||
]);
|
||||
|
||||
$scopedToken = $scopedAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$response = $this->withHeader('Authorization', 'Bearer '.$scopedToken)
|
||||
->getJson('/api/v1/admin/players')
|
||||
->assertOk();
|
||||
|
||||
$siteCodes = collect($response->json('data.items'))->pluck('site_code')->unique()->values()->all();
|
||||
expect($siteCodes)->toBe(['site-a']);
|
||||
});
|
||||
Reference in New Issue
Block a user