feat: 增强管理员功能与数据处理

- 在多个控制器中引入 agent_node_id,以支持基于代理节点的权限和数据过滤。
- 更新 AdminRole 和 AdminUser 模型,新增角色范围和代理节点相关功能,提升角色管理的灵活性。
- 在请求验证中添加 agent_node_id 字段,确保 API 接口支持代理节点的相关操作。
- 优化 LotterySettings 服务,支持批量写入设置,提升配置管理的效率。
- 更新仪表板和报告服务,增强数据统计功能,确保管理员能够获取更全面的统计信息。
This commit is contained in:
2026-06-02 14:36:58 +08:00
parent d5232c756f
commit 0841fbed32
96 changed files with 5004 additions and 182 deletions

View File

@@ -3,12 +3,16 @@
namespace App\Http\Controllers\Api\V1\Admin;
use App\Http\Controllers\Controller;
use App\Http\Middleware\RecordAdminApiAudit;
use App\Http\Requests\Admin\AdminSettingBatchUpdateRequest;
use App\Http\Requests\Admin\AdminSettingIndexRequest;
use App\Http\Requests\Admin\AdminSettingUpdateRequest;
use App\Models\LotterySetting;
use App\Services\AuditLogger;
use App\Support\ApiResponse;
use App\Services\LotterySettings;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
/**
* 后台运营配置KV 设置)读写。
@@ -54,4 +58,47 @@ final class AdminSettingController extends Controller
'description' => $fresh->description_zh,
]);
}
public function batchUpdate(AdminSettingBatchUpdateRequest $request): JsonResponse
{
/** @var list<array{key: string, value: mixed}> $items */
$items = $request->validated('items');
DB::transaction(static function () use ($items): void {
LotterySettings::putMany($items);
});
$keys = array_map(static fn (array $item): string => (string) $item['key'], $items);
$rows = LotterySetting::query()
->whereIn('setting_key', $keys)
->orderBy('setting_key')
->get();
$admin = $request->lotteryAdmin();
if ($admin !== null) {
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: 'settings',
actionCode: 'batch_update',
targetType: 'lottery_settings',
targetId: 'batch',
beforeJson: null,
afterJson: [
'keys' => $keys,
'count' => count($keys),
],
);
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
}
return ApiResponse::success([
'items' => $rows->map(fn (LotterySetting $s): array => [
'key' => $s->setting_key,
'value' => $s->value_json,
'group' => $s->group_name,
'description' => $s->description_zh,
])->values()->all(),
]);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Models\AdminUser;
use App\Support\ApiResponse;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\Agent\AgentAdminUserService;
use App\Support\AdminAgentNodeAccess;
use App\Support\AdminUserApiPresenter;
use App\Http\Requests\Admin\AgentAdminUserRoleSyncRequest;
final class AgentAdminUserRoleSyncController extends Controller
{
public function __invoke(
AgentAdminUserRoleSyncRequest $request,
AdminUser $admin_user,
AgentAdminUserService $service,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$agent = $admin_user->primaryAgentNode();
if ($agent === null) {
abort(404);
}
$denied = AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent);
if ($denied !== null) {
return $denied;
}
if (! $admin->isSuperAdmin() && ! $admin->hasAdminPermission('prd.agent.user.manage')) {
return AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $agent);
}
$before = AdminUserApiPresenter::listItem($admin_user);
$user = $service->syncRoles($agent, $admin_user, $request->validated('role_ids'));
$after = AdminUserApiPresenter::listItem($user);
AuditLogger::recordForAdmin(
$admin,
$request,
'agent',
'agent_admin_user.sync_roles',
'admin_user',
(string) $user->id,
$before,
$after,
);
return ApiResponse::success($after);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller;
use App\Support\AdminAgentNodeAccess;
use App\Support\AdminUserApiPresenter;
final class AgentNodeAdminUserIndexController extends Controller
{
public function __invoke(Request $request, AgentNode $agent_node): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$denied = AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node);
if ($denied !== null) {
return $denied;
}
$userIds = DB::table('admin_user_agents')
->where('agent_node_id', $agent_node->id)
->pluck('admin_user_id');
$users = AdminUser::query()
->whereIn('id', $userIds)
->orderBy('username')
->get();
return ApiResponse::success([
'agent_node_id' => (int) $agent_node->id,
'items' => $users->map(static fn (AdminUser $user): array => AdminUserApiPresenter::listItem($user))->all(),
]);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Models\AgentNode;
use App\Support\ApiResponse;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\Agent\AgentAdminUserService;
use App\Support\AdminAgentNodeAccess;
use App\Support\AdminUserApiPresenter;
use App\Http\Requests\Admin\AgentAdminUserStoreRequest;
final class AgentNodeAdminUserStoreController extends Controller
{
public function __invoke(
AgentAdminUserStoreRequest $request,
AgentNode $agent_node,
AgentAdminUserService $service,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$denied = AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node);
if ($denied !== null) {
return $denied;
}
if (! $admin->isSuperAdmin() && ! $admin->hasAdminPermission('prd.agent.user.manage')) {
return AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $agent_node);
}
$user = $service->createUnderAgent($agent_node, $request->validated());
AuditLogger::recordForAdmin(
$admin,
$request,
'agent',
'agent_admin_user.create',
'admin_user',
(string) $user->id,
null,
AdminUserApiPresenter::listItem($user),
);
return ApiResponse::success(AdminUserApiPresenter::listItem($user))->setStatusCode(201);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Models\AgentNode;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminAgentNodeAccess;
use App\Support\AgentNodePresenter;
final class AgentNodeChildrenController extends Controller
{
public function __invoke(Request $request, AgentNode $agent_node): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$denied = AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node);
if ($denied !== null) {
return $denied;
}
$items = $agent_node->children()
->orderBy('code')
->get()
->map(static fn (AgentNode $child): array => AgentNodePresenter::item($child))
->all();
return ApiResponse::success(['items' => $items]);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Http\Controllers\Controller;
use App\Models\AgentNode;
use App\Services\Agent\AgentDelegationService;
use App\Support\AdminAgentNodeAccess;
use App\Support\AgentDelegationAuthorization;
use App\Support\ApiMessage;
use App\Support\ApiResponse;
use App\Lottery\ErrorCode;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class AgentNodeDelegationGrantIndexController extends Controller
{
public function __invoke(
Request $request,
AgentNode $agent_node,
AgentDelegationService $service,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$denied = AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node);
if ($denied !== null) {
return $denied;
}
if ($agent_node->isRoot()) {
return ApiMessage::errorResponse(
$request,
'admin.agent_delegation_root_denied',
ErrorCode::ValidationFailed->value,
null,
422,
);
}
if (! AgentDelegationAuthorization::childIsManageableBy($admin, $agent_node)) {
return ApiMessage::errorResponse(
$request,
'admin.agent_delegation_manage_denied',
ErrorCode::AdminForbidden->value,
null,
403,
);
}
return ApiResponse::success([
'child_agent_id' => (int) $agent_node->id,
'grants' => $service->listForChild($agent_node, $admin),
]);
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\AgentDelegationGrantSyncRequest;
use App\Models\AgentNode;
use App\Services\Agent\AgentDelegationService;
use App\Services\AuditLogger;
use App\Support\AdminAgentNodeAccess;
use App\Support\AgentDelegationAuthorization;
use App\Support\ApiMessage;
use App\Support\ApiResponse;
use App\Lottery\ErrorCode;
use Illuminate\Http\JsonResponse;
final class AgentNodeDelegationGrantSyncController extends Controller
{
public function __invoke(
AgentDelegationGrantSyncRequest $request,
AgentNode $agent_node,
AgentDelegationService $service,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$denied = AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node);
if ($denied !== null) {
return $denied;
}
if ($agent_node->isRoot()) {
return ApiMessage::errorResponse(
$request,
'admin.agent_delegation_root_denied',
ErrorCode::ValidationFailed->value,
null,
422,
);
}
if (! AgentDelegationAuthorization::childIsManageableBy($admin, $agent_node)) {
return ApiMessage::errorResponse(
$request,
'admin.agent_delegation_manage_denied',
ErrorCode::AdminForbidden->value,
null,
403,
);
}
$before = $service->listForChild($agent_node);
$grants = $request->validated('grants');
$after = $service->syncGrants($admin, $agent_node, $grants);
AuditLogger::recordForAdmin(
$admin,
$request,
'agent',
'agent.delegation.sync',
'agent_node',
(string) $agent_node->id,
['grants' => $before],
['grants' => $after],
);
return ApiResponse::success([
'child_agent_id' => (int) $agent_node->id,
'grants' => $after,
]);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Lottery\ErrorCode;
use App\Models\AgentNode;
use App\Support\ApiMessage;
use App\Support\ApiResponse;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\Agent\AgentNodeService;
use App\Support\AdminAgentNodeAccess;
use App\Support\AdminAgentScope;
use App\Support\AgentNodePresenter;
use Illuminate\Support\Facades\DB;
final class AgentNodeDestroyController extends Controller
{
public function __invoke(
\Illuminate\Http\Request $request,
AgentNode $agent_node,
AgentNodeService $service,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$denied = AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node);
if ($denied !== null) {
return $denied;
}
if (! AdminAgentScope::nodeManageableBy($admin, $agent_node)) {
return AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node)
?? AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $agent_node);
}
if ($agent_node->isRoot()) {
return ApiMessage::errorResponse($request, 'admin.agent_root_delete_denied', ErrorCode::ValidationFailed->value, null, 422);
}
if ($agent_node->children()->exists()) {
return ApiMessage::errorResponse($request, 'admin.agent_node_has_children_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);
}
if (DB::table('admin_user_agents')->where('agent_node_id', (int) $agent_node->id)->exists()) {
return ApiMessage::errorResponse($request, 'admin.agent_node_has_users_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);
}
if (DB::table('admin_roles')->where('owner_agent_id', (int) $agent_node->id)->exists()) {
return ApiMessage::errorResponse($request, 'admin.agent_node_has_roles_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);
}
$before = AgentNodePresenter::item($agent_node);
$service->destroy($agent_node);
AuditLogger::recordForAdmin(
$admin,
$request,
'system',
'agent_node.destroy',
'agent_node',
(string) $agent_node->id,
$before,
null,
);
return ApiResponse::success(null);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Models\AdminRole;
use App\Models\AgentNode;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminAgentNodeAccess;
use App\Support\AdminRoleApiPresenter;
final class AgentNodeRoleIndexController extends Controller
{
public function __invoke(Request $request, AgentNode $agent_node): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$denied = AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node);
if ($denied !== null) {
return $denied;
}
$roles = AdminRole::query()
->where('scope_type', AdminRole::SCOPE_AGENT)
->where('owner_agent_id', $agent_node->id)
->orderBy('sort_order')
->orderBy('id')
->get();
return ApiResponse::success([
'agent_node_id' => (int) $agent_node->id,
'items' => $roles->map(static fn (AdminRole $role): array => AdminRoleApiPresenter::item($role))->all(),
]);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Models\AgentNode;
use App\Support\ApiResponse;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\Agent\AgentRoleService;
use App\Support\AdminAgentNodeAccess;
use App\Support\AdminRoleApiPresenter;
use App\Http\Requests\Admin\AgentRoleStoreRequest;
final class AgentNodeRoleStoreController extends Controller
{
public function __invoke(
AgentRoleStoreRequest $request,
AgentNode $agent_node,
AgentRoleService $service,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$denied = AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node);
if ($denied !== null) {
return $denied;
}
if (! $admin->isSuperAdmin() && ! $admin->hasAdminPermission('prd.agent.role.manage')) {
return AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $agent_node);
}
$role = $service->createForAgent($admin, $agent_node, $request->validated());
AuditLogger::recordForAdmin(
$admin,
$request,
'agent',
'agent_role.create',
'admin_role',
(string) $role->id,
null,
AdminRoleApiPresenter::item($role),
);
return ApiResponse::success(AdminRoleApiPresenter::item($role));
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Models\AgentNode;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminAgentNodeAccess;
use App\Support\AgentNodePresenter;
final class AgentNodeShowController extends Controller
{
public function __invoke(Request $request, AgentNode $agent_node): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$denied = AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node);
if ($denied !== null) {
return $denied;
}
return ApiResponse::success(AgentNodePresenter::item($agent_node));
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Models\AgentNode;
use App\Support\ApiResponse;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\Agent\AgentNodeService;
use App\Support\AdminAgentNodeAccess;
use App\Support\AgentNodePresenter;
use App\Http\Requests\Admin\AgentNodeStoreRequest;
final class AgentNodeStoreController extends Controller
{
public function __invoke(AgentNodeStoreRequest $request, AgentNodeService $service): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$parent = AgentNode::query()->findOrFail((int) $request->validated('parent_id'));
$denied = AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $parent);
if ($denied !== null) {
return $denied;
}
$node = $service->createChild($admin, $request->validated());
AuditLogger::recordForAdmin(
$admin,
$request,
'system',
'agent_node.create',
'agent_node',
(string) $node->id,
null,
AgentNodePresenter::item($node),
);
return ApiResponse::success(AgentNodePresenter::item($node));
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminAgentNodeAccess;
use App\Support\AdminAgentScope;
use App\Support\AgentNodePresenter;
final class AgentNodeTreeController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$siteId = AdminAgentNodeAccess::resolveAdminSiteId(
$admin,
$request->integer('admin_site_id') ?: null,
);
$denied = AdminAgentNodeAccess::denyUnlessSiteResolved($admin, $siteId);
if ($denied !== null) {
return $denied;
}
$nodes = AdminAgentScope::visibleNodesQuery($admin, (int) $siteId)->get();
return ApiResponse::success([
'admin_site_id' => (int) $siteId,
'tree' => AgentNodePresenter::tree($nodes),
]);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Models\AgentNode;
use App\Support\ApiResponse;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\Agent\AgentNodeService;
use App\Support\AdminAgentNodeAccess;
use App\Support\AdminAgentScope;
use App\Support\AgentNodePresenter;
use App\Http\Requests\Admin\AgentNodeUpdateRequest;
final class AgentNodeUpdateController extends Controller
{
public function __invoke(
AgentNodeUpdateRequest $request,
AgentNode $agent_node,
AgentNodeService $service,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$denied = AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node);
if ($denied !== null) {
return $denied;
}
if (! AdminAgentScope::nodeManageableBy($admin, $agent_node)) {
return AdminAgentNodeAccess::denyUnlessNodeVisible($admin, $agent_node)
?? AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $agent_node);
}
if ($agent_node->isRoot() && ! $admin->isSuperAdmin()) {
return AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $agent_node);
}
$before = AgentNodePresenter::item($agent_node);
$node = $service->update($agent_node, $request->validated());
$after = AgentNodePresenter::item($node);
AuditLogger::recordForAdmin(
$admin,
$request,
'system',
'agent_node.update',
'agent_node',
(string) $node->id,
$before,
$after,
);
return ApiResponse::success($after);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Models\AdminRole;
use App\Support\ApiResponse;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\Agent\AgentRoleService;
use App\Support\AgentRoleAuthorization;
use App\Support\AdminRoleApiPresenter;
final class AgentRoleDestroyController extends Controller
{
public function __invoke(
\Illuminate\Http\Request $request,
AdminRole $admin_role,
AgentRoleService $service,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if (! $admin_role->isAgentScoped()) {
abort(404);
}
$denied = AgentRoleAuthorization::denyUnlessRoleManageable($admin, $admin_role);
if ($denied !== null) {
return $denied;
}
$before = AdminRoleApiPresenter::item($admin_role);
$service->destroy($admin_role);
AuditLogger::recordForAdmin(
$admin,
$request,
'agent',
'agent_role.destroy',
'admin_role',
(string) $admin_role->id,
$before,
null,
);
return ApiResponse::success(null);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Models\AdminRole;
use App\Support\ApiResponse;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\Agent\AgentRoleService;
use App\Support\AgentRoleAuthorization;
use App\Support\AdminRoleApiPresenter;
use App\Http\Requests\Admin\AgentRolePermissionSyncRequest;
final class AgentRolePermissionSyncController extends Controller
{
public function __invoke(
AgentRolePermissionSyncRequest $request,
AdminRole $admin_role,
AgentRoleService $service,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if (! $admin_role->isAgentScoped()) {
abort(404);
}
$denied = AgentRoleAuthorization::denyUnlessRoleManageable($admin, $admin_role);
if ($denied !== null) {
return $denied;
}
if ($admin_role->isReadOnlyTemplate()) {
return AgentRoleAuthorization::denyUnlessRoleManageable($admin, $admin_role);
}
$slugs = array_values(array_unique($request->validated('permission_slugs')));
$before = AdminRoleApiPresenter::item($admin_role);
$role = $service->syncPermissions($admin, $admin_role, $slugs);
AuditLogger::recordForAdmin(
$admin,
$request,
'agent',
'agent_role.sync_permissions',
'admin_role',
(string) $role->id,
$before,
AdminRoleApiPresenter::item($role),
);
return ApiResponse::success(AdminRoleApiPresenter::item($role));
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Models\AdminRole;
use App\Support\ApiResponse;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\Agent\AgentRoleService;
use App\Support\AgentRoleAuthorization;
use App\Support\AdminRoleApiPresenter;
use App\Http\Requests\Admin\AgentRoleUpdateRequest;
final class AgentRoleUpdateController extends Controller
{
public function __invoke(
AgentRoleUpdateRequest $request,
AdminRole $admin_role,
AgentRoleService $service,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if (! $admin_role->isAgentScoped()) {
abort(404);
}
$denied = AgentRoleAuthorization::denyUnlessRoleManageable($admin, $admin_role);
if ($denied !== null) {
return $denied;
}
if ($admin_role->isReadOnlyTemplate()) {
return AgentRoleAuthorization::denyUnlessRoleManageable($admin, $admin_role);
}
$before = AdminRoleApiPresenter::item($admin_role);
$role = $service->update($admin_role, $request->validated());
AuditLogger::recordForAdmin(
$admin,
$request,
'agent',
'agent_role.update',
'admin_role',
(string) $role->id,
$before,
AdminRoleApiPresenter::item($role),
);
return ApiResponse::success(AdminRoleApiPresenter::item($role));
}
}

View File

@@ -3,10 +3,12 @@
namespace App\Http\Controllers\Api\V1\Admin\Draw;
use Carbon\Carbon;
use App\Models\AdminUser;
use App\Models\Draw;
use App\Models\TicketItem;
use App\Models\TicketOrder;
use Illuminate\Http\Request;
use App\Support\AdminSiteScope;
use App\Support\AdminApiList;
use App\Services\LotterySettings;
use Illuminate\Http\JsonResponse;
@@ -20,9 +22,13 @@ final class AdminDrawIndexController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$p = AdminApiList::readPaging($request);
$drawNo = trim((string) $request->query('draw_no', ''));
$status = trim((string) $request->query('status', ''));
$agentNodeId = $request->integer('agent_node_id') ?: null;
$q = Draw::query()->orderByDesc('draw_time')->orderByDesc('id');
@@ -39,6 +45,8 @@ final class AdminDrawIndexController extends Controller
$statsByDrawId = $this->aggregateListStats(
$paginator->getCollection()->pluck('id')->map(fn ($id) => (int) $id)->all(),
$admin,
$agentNodeId,
);
return AdminApiList::jsonWith($paginator, fn (Draw $row) => $this->row($row, $statsByDrawId), [
@@ -55,20 +63,22 @@ final class AdminDrawIndexController extends Controller
* @param list<int> $drawIds
* @return array<int, array{total_bet_minor: int, total_payout_minor: int, profit_loss_minor: int}>
*/
private function aggregateListStats(array $drawIds): array
private function aggregateListStats(array $drawIds, AdminUser $admin, ?int $agentNodeId): array
{
if ($drawIds === []) {
return [];
}
$betByDraw = TicketOrder::query()
->whereIn('draw_id', $drawIds)
$betQuery = TicketOrder::query()->whereIn('draw_id', $drawIds);
$this->scopeOrdersToVisiblePlayers($betQuery, $admin, $agentNodeId);
$betByDraw = $betQuery
->groupBy('draw_id')
->selectRaw('draw_id, COALESCE(SUM(total_actual_deduct), 0) AS total_bet')
->pluck('total_bet', 'draw_id');
$payoutRows = TicketItem::query()
->whereIn('draw_id', $drawIds)
$payoutQuery = TicketItem::query()->whereIn('draw_id', $drawIds);
$this->scopeTicketItemsToVisiblePlayers($payoutQuery, $admin, $agentNodeId);
$payoutRows = $payoutQuery
->groupBy('draw_id')
->selectRaw(
'draw_id, COALESCE(SUM(win_amount), 0) AS win, COALESCE(SUM(jackpot_win_amount), 0) AS jackpot',
@@ -91,6 +101,44 @@ final class AdminDrawIndexController extends Controller
return $stats;
}
/**
* @param \Illuminate\Database\Eloquent\Builder<TicketOrder> $query
*/
private function scopeOrdersToVisiblePlayers($query, AdminUser $admin, ?int $agentNodeId): void
{
if ($admin->isSuperAdmin() && ($agentNodeId === null || $agentNodeId <= 0)) {
return;
}
$query->whereHas('player', static function ($playerQuery) use ($admin, $agentNodeId): void {
AdminSiteScope::applyPlayerFilters(
$playerQuery,
$admin,
null,
$agentNodeId !== null && $agentNodeId > 0 ? $agentNodeId : null,
);
});
}
/**
* @param \Illuminate\Database\Eloquent\Builder<TicketItem> $query
*/
private function scopeTicketItemsToVisiblePlayers($query, AdminUser $admin, ?int $agentNodeId): void
{
if ($admin->isSuperAdmin() && ($agentNodeId === null || $agentNodeId <= 0)) {
return;
}
$query->whereHas('player', static function ($playerQuery) use ($admin, $agentNodeId): void {
AdminSiteScope::applyPlayerFilters(
$playerQuery,
$admin,
null,
$agentNodeId !== null && $agentNodeId > 0 ? $agentNodeId : null,
);
});
}
/**
* @param array<int, array{total_bet_minor: int, total_payout_minor: int, profit_loss_minor: int}> $statsByDrawId
* @return array<string, mixed>

View File

@@ -7,6 +7,7 @@ use App\Support\AdminApiList;
use Illuminate\Http\JsonResponse;
use App\Models\JackpotContribution;
use App\Http\Controllers\Controller;
use App\Support\AdminDataScope;
/**
* GET /api/v1/admin/jackpot/contributions Jackpot 蓄水流水。
@@ -15,6 +16,9 @@ final class AdminJackpotContributionIndexController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$p = AdminApiList::readPaging($request);
$drawNo = trim((string) $request->query('draw_no', ''));
@@ -26,6 +30,8 @@ final class AdminJackpotContributionIndexController extends Controller
$q->whereHas('draw', fn ($d) => $d->where('draw_no', 'like', '%'.$drawNo.'%'));
}
AdminDataScope::applyEloquentViaPlayer($q, $admin);
$paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']);
return AdminApiList::json($paginator, fn (JackpotContribution $r) => [

View File

@@ -23,15 +23,20 @@ final class AdminPlayerIndexController extends Controller
$keyword = trim((string) $request->query('keyword', ''));
$status = $request->query('status');
$siteCode = $request->query('site_code');
$agentNodeId = $request->integer('agent_node_id') ?: null;
$q = Player::query()
->with(['wallets' => static fn ($wq) => $wq->orderBy('wallet_type')->orderBy('currency_code')])
->with([
'wallets' => static fn ($wq) => $wq->orderBy('wallet_type')->orderBy('currency_code'),
'agentNode:id,code,name',
])
->orderByDesc('id');
AdminSiteScope::applyPlayerFilters(
$q,
$admin,
is_string($siteCode) ? $siteCode : null,
$agentNodeId,
);
if ($keyword !== '') {

View File

@@ -7,6 +7,7 @@ use App\Lottery\ErrorCode;
use App\Support\ApiMessage;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use App\Support\AdminAgentScope;
use App\Support\AdminSiteScope;
use App\Support\PlayerApiPresenter;
use App\Http\Controllers\Controller;
@@ -34,8 +35,30 @@ final class AdminPlayerStoreController extends Controller
return ApiMessage::errorResponse($request, 'admin.player_already_registered', ErrorCode::ValidationFailed->value, null, 422);
}
$agentNodeId = $admin->isSuperAdmin()
? $this->resolveAgentNodeIdForSuperAdmin($request->validated('agent_node_id'), $siteCode)
: $admin->primaryAgentNodeId();
if ($agentNodeId === null) {
return ApiMessage::errorResponse(
$request,
'admin.player_create_agent_required',
ErrorCode::ValidationFailed->value,
null,
422,
);
}
if (! $admin->isSuperAdmin()) {
$agent = AdminAgentScope::primaryAgentNode($admin);
if ($agent === null || (int) $agentNodeId !== (int) $agent->id) {
return ApiMessage::errorResponse($request, 'admin.player_create_agent_forbidden', ErrorCode::AdminForbidden->value, null, 403);
}
}
$player = Player::query()->create([
'site_code' => $request->validated('site_code'),
'agent_node_id' => $agentNodeId,
'site_player_id' => $request->validated('site_player_id'),
'username' => $request->validated('username'),
'nickname' => $request->validated('nickname'),
@@ -45,4 +68,23 @@ final class AdminPlayerStoreController extends Controller
return ApiResponse::success(PlayerApiPresenter::listItem($player))->setStatusCode(201);
}
private function resolveAgentNodeIdForSuperAdmin(mixed $requested, string $siteCode): ?int
{
if ($requested !== null && (int) $requested > 0) {
return (int) $requested;
}
$siteId = \App\Models\AdminSite::query()->where('code', $siteCode)->value('id');
if ($siteId === null) {
return null;
}
$rootId = \App\Models\AgentNode::query()
->where('admin_site_id', (int) $siteId)
->where('depth', 0)
->value('id');
return $rootId !== null ? (int) $rootId : null;
}
}

View File

@@ -13,6 +13,9 @@ final class AdminReportDailyProfitController extends Controller
{
public function __invoke(AdminReportQueryRequest $request, AdminReportQueryService $service): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$validated = $request->validated();
$p = AdminApiList::readPaging($request);
$range = $service->resolveDateRange($validated);
@@ -22,6 +25,7 @@ final class AdminReportDailyProfitController extends Controller
$range['date_to'],
$p['page'],
$p['perPage'],
$admin,
);
return AdminApiList::json($paginator, static fn (array $row): array => $row);

View File

@@ -13,6 +13,9 @@ final class AdminReportPlayDimensionController extends Controller
{
public function __invoke(AdminReportQueryRequest $request, AdminReportQueryService $service): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$validated = $request->validated();
$p = AdminApiList::readPaging($request);
$range = $service->resolveDateRange($validated);
@@ -24,6 +27,7 @@ final class AdminReportPlayDimensionController extends Controller
$range['date_to'],
$p['page'],
$p['perPage'],
$admin,
);
return AdminApiList::json($paginator, static function (object $row): array {

View File

@@ -13,10 +13,14 @@ final class AdminReportPlayerWinLossController extends Controller
{
public function __invoke(AdminReportQueryRequest $request, AdminReportQueryService $service): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$validated = $request->validated();
$p = AdminApiList::readPaging($request);
$range = $service->resolveDateRange($validated);
$playerId = isset($validated['player_id']) ? (int) $validated['player_id'] : null;
$agentNodeId = isset($validated['agent_node_id']) ? (int) $validated['agent_node_id'] : null;
$paginator = $service->playerWinLossPaginated(
$playerId,
@@ -24,11 +28,16 @@ final class AdminReportPlayerWinLossController extends Controller
$range['date_to'],
$p['page'],
$p['perPage'],
$admin,
$agentNodeId > 0 ? $agentNodeId : null,
);
return AdminApiList::json($paginator, static function (object $row): array {
return [
'player_id' => (int) $row->player_id,
'agent_node_id' => isset($row->agent_node_id) ? (int) $row->agent_node_id : null,
'agent_code' => isset($row->agent_code) ? (string) $row->agent_code : null,
'agent_name' => isset($row->agent_name) ? (string) $row->agent_name : null,
'username' => $row->username !== null ? (string) $row->username : 'player#'.(int) $row->player_id,
'total_bet_minor' => (int) $row->total_bet_minor,
'total_payout_minor' => (int) $row->total_payout_minor,

View File

@@ -13,6 +13,9 @@ final class AdminReportRebateCommissionController extends Controller
{
public function __invoke(AdminReportQueryRequest $request, AdminReportQueryService $service): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$validated = $request->validated();
$p = AdminApiList::readPaging($request);
$range = $service->resolveDateRange($validated);
@@ -24,6 +27,7 @@ final class AdminReportRebateCommissionController extends Controller
$range['date_to'],
$p['page'],
$p['perPage'],
$admin,
);
return AdminApiList::json($paginator, static function (object $row): array {

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\V1\Admin\Reports;
use App\Models\AdminUser;
use App\Models\ReportJob;
use App\Services\Admin\AdminReportJobService;
use App\Services\Admin\AdminReportQueryService;
@@ -29,7 +30,8 @@ final class ReportJobDownloadController
$dateTo,
);
$filename = $label.'_'.$pathSuffix.'.'.$report_job->export_format;
$rows = $service->reportRows((string) $report_job->report_type, $filterJson);
$scopedAdmin = AdminUser::query()->find((int) $report_job->admin_user_id);
$rows = $service->reportRows((string) $report_job->report_type, $filterJson, $scopedAdmin);
if ((string) $report_job->export_format === 'xlsx') {
return $spreadsheetExporter->streamDownload($rows, $filename);

View File

@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Api\V1\Admin\Settlement;
use Illuminate\Http\Request;
use App\Support\AdminApiList;
use App\Support\AdminSiteScope;
use App\Support\AgentNodeApiPresenter;
use App\Models\SettlementBatch;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
@@ -16,15 +18,33 @@ final class AdminSettlementBatchDetailsController extends Controller
{
public function __invoke(Request $request, SettlementBatch $batch): JsonResponse
{
$p = AdminApiList::readPaging($request);
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$paginator = TicketSettlementDetail::query()
$p = AdminApiList::readPaging($request);
$agentNodeId = $request->integer('agent_node_id') ?: null;
$detailQuery = TicketSettlementDetail::query()
->where('settlement_batch_id', $batch->id)
->with([
'ticketItem:id,ticket_no,play_code,player_id',
'ticketItem.player:id,site_code,username,nickname,site_player_id',
'ticketItem.player:id,site_code,username,nickname,site_player_id,agent_node_id',
'ticketItem.player.agentNode:id,code,name',
'ticketItem.order:id,currency_code',
])
]);
if (! $admin->isSuperAdmin() || ($agentNodeId !== null && $agentNodeId > 0)) {
$detailQuery->whereHas('ticketItem.player', static function ($playerQuery) use ($admin, $agentNodeId): void {
AdminSiteScope::applyPlayerFilters(
$playerQuery,
$admin,
null,
$agentNodeId !== null && $agentNodeId > 0 ? $agentNodeId : null,
);
});
}
$paginator = $detailQuery
->orderBy('id')
->paginate($p['perPage'], ['*'], 'page', $p['page']);
@@ -37,6 +57,7 @@ final class AdminSettlementBatchDetailsController extends Controller
return [
'id' => (int) $row->id,
'ticket_item_id' => (int) $row->ticket_item_id,
...AgentNodeApiPresenter::embed($player?->agentNode),
'ticket_no' => $item?->ticket_no,
'play_code' => $item?->play_code,
'currency_code' => $order?->currency_code,

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Settlement;
use Illuminate\Http\Request;
use App\Support\AdminApiList;
use App\Support\AdminSiteScope;
use App\Models\SettlementBatch;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
@@ -16,14 +17,29 @@ final class AdminSettlementBatchIndexController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$p = AdminApiList::readPaging($request);
$drawNo = trim((string) $request->query('draw_no', ''));
$status = trim((string) $request->query('status', ''));
$agentNodeId = $request->integer('agent_node_id') ?: null;
$q = SettlementBatch::query()
->with(['draw:id,draw_no'])
->orderByDesc('id');
if (! $admin->isSuperAdmin() || ($agentNodeId !== null && $agentNodeId > 0)) {
$q->whereHas('details.ticketItem.player', static function ($playerQuery) use ($admin, $agentNodeId): void {
AdminSiteScope::applyPlayerFilters(
$playerQuery,
$admin,
null,
$agentNodeId !== null && $agentNodeId > 0 ? $agentNodeId : null,
);
});
}
if ($drawNo !== '') {
$q->whereHas('draw', fn ($d) => $d->where('draw_no', 'like', '%'.$drawNo.'%'));
}

View File

@@ -9,6 +9,7 @@ use App\Support\ApiResponse;
use App\Support\CurrencyFormatter;
use App\Support\PaginationTrait;
use App\Support\AdminSiteScope;
use App\Support\AgentNodeApiPresenter;
use App\Support\TicketItemListFilters;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -44,7 +45,8 @@ final class AdminTicketItemIndexController extends Controller
->with([
'draw:id,draw_no,business_date',
'order:id,order_no,currency_code,created_at',
'player:id,site_code,site_player_id,username,nickname',
'player:id,site_code,site_player_id,username,nickname,agent_node_id',
'player.agentNode:id,code,name',
])
->orderByDesc('ticket_items.id');
@@ -86,10 +88,14 @@ final class AdminTicketItemIndexController extends Controller
is_string($validated['end_date'] ?? null) ? $validated['end_date'] : null,
);
$agentNodeId = isset($validated['agent_node_id']) ? (int) $validated['agent_node_id'] : null;
AdminSiteScope::applyViaPlayerRelationWithSiteCode(
$query,
$admin,
is_string($validated['site_code'] ?? null) ? $validated['site_code'] : null,
'player',
$agentNodeId > 0 ? $agentNodeId : null,
);
$paginator = $query->paginate(perPage: $perPage, page: $page, columns: ['*']);
@@ -103,6 +109,7 @@ final class AdminTicketItemIndexController extends Controller
return [
'id' => $row->id,
'ticket_no' => $row->ticket_no,
...AgentNodeApiPresenter::embed($row->player?->agentNode),
'player_id' => $row->player_id,
'site_code' => $row->player?->site_code,
'site_player_id' => $row->player?->site_player_id,

View File

@@ -12,7 +12,11 @@ final class AdminRoleIndexController extends Controller
{
public function __invoke(): JsonResponse
{
$roles = AdminRole::query()->orderBy('sort_order')->orderBy('id')->get();
$roles = AdminRole::query()
->where('scope_type', AdminRole::SCOPE_SYSTEM)
->orderBy('sort_order')
->orderBy('id')
->get();
return ApiResponse::success([
'items' => $roles->map(static fn (AdminRole $role): array => AdminRoleApiPresenter::item($role))->values()->all(),

View File

@@ -26,6 +26,9 @@ final class AdminRoleStoreController extends Controller
'status' => $request->validated('status', 1),
'is_system' => false,
'sort_order' => 0,
'scope_type' => AdminRole::SCOPE_SYSTEM,
'owner_agent_id' => null,
'delegated_from_role_id' => null,
]);
$role->syncLegacyPermissionSlugs($permissionSlugs);

View File

@@ -8,6 +8,7 @@ use App\Models\TransferOrder;
use App\Support\PaginationTrait;
use Illuminate\Http\JsonResponse;
use App\Support\AdminSiteScope;
use App\Support\AgentNodeApiPresenter;
use App\Support\CurrencyFormatter;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\TransferOrderListRequest;
@@ -44,7 +45,10 @@ final class TransferOrderListController extends Controller
$page = $this->page($request);
$query = TransferOrder::query()
->with(['player:id,site_code,site_player_id,username,nickname'])
->with([
'player:id,site_code,site_player_id,username,nickname,agent_node_id',
'player.agentNode:id,code,name',
])
->orderByDesc('id');
if (! empty($validated['player_id'])) {
@@ -85,10 +89,14 @@ final class TransferOrderListController extends Controller
}
}
$agentNodeId = isset($validated['agent_node_id']) ? (int) $validated['agent_node_id'] : null;
AdminSiteScope::applyViaPlayerRelationWithSiteCode(
$query,
$admin,
is_string($validated['site_code'] ?? null) ? $validated['site_code'] : null,
'player',
$agentNodeId > 0 ? $agentNodeId : null,
);
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
@@ -119,6 +127,7 @@ final class TransferOrderListController extends Controller
return [
'id' => $o->id,
'transfer_no' => $o->transfer_no,
...AgentNodeApiPresenter::embed($p?->agentNode),
'player_id' => $o->player_id,
'site_code' => $p?->site_code,
'site_player_id' => $p?->site_player_id,

View File

@@ -7,6 +7,7 @@ use App\Support\ApiResponse;
use App\Support\PaginationTrait;
use Illuminate\Http\JsonResponse;
use App\Support\AdminSiteScope;
use App\Support\AgentNodeApiPresenter;
use App\Support\CurrencyFormatter;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\WalletTransactionListRequest;
@@ -43,7 +44,10 @@ final class WalletTransactionListController extends Controller
$page = $this->page($request);
$query = WalletTxn::query()
->with(['player:id,site_code,site_player_id,username,nickname'])
->with([
'player:id,site_code,site_player_id,username,nickname,agent_node_id',
'player.agentNode:id,code,name',
])
->orderByDesc('id');
if (! empty($validated['player_id'])) {
@@ -88,10 +92,14 @@ final class WalletTransactionListController extends Controller
}
}
$agentNodeId = isset($validated['agent_node_id']) ? (int) $validated['agent_node_id'] : null;
AdminSiteScope::applyViaPlayerRelationWithSiteCode(
$query,
$admin,
is_string($validated['site_code'] ?? null) ? $validated['site_code'] : null,
'player',
$agentNodeId > 0 ? $agentNodeId : null,
);
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
@@ -118,6 +126,7 @@ final class WalletTransactionListController extends Controller
return [
'id' => $t->id,
'txn_no' => $t->txn_no,
...AgentNodeApiPresenter::embed($p?->agentNode),
'player_id' => $t->player_id,
'site_code' => $p?->site_code,
'site_player_id' => $p?->site_player_id,

View File

@@ -26,6 +26,7 @@ final class AdminPlayerStoreRequest extends FormRequest
'nickname' => ['nullable', 'string', 'max:128'],
'default_currency' => ['sometimes', 'string', 'max:16', Rule::exists('currencies', 'code')],
'status' => ['sometimes', 'integer', 'in:0,1,2'],
'agent_node_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
];
}

View File

@@ -21,6 +21,7 @@ final class AdminReportQueryRequest extends FormRequest
'date_to' => ['sometimes', 'nullable', 'date_format:Y-m-d', 'after_or_equal:date_from'],
'player_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'play_code' => ['sometimes', 'nullable', 'string', 'max:32'],
'agent_node_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
final class AdminSettingBatchUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'items' => ['required', 'array', 'min:1', 'max:50'],
'items.*.key' => ['required', 'string', 'max:128'],
'items.*.value' => ['present'],
];
}
}

View File

@@ -14,7 +14,7 @@ final class AdminSettingUpdateRequest extends FormRequest
public function rules(): array
{
return [
'value' => ['required'],
'value' => ['present'],
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
final class AgentAdminUserRoleSyncRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'role_ids' => ['required', 'array'],
'role_ids.*' => ['integer', 'exists:admin_roles,id'],
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class AgentAdminUserStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'username' => ['required', 'string', 'max:64', Rule::unique('admin_users', 'username')],
'nickname' => ['required', 'string', 'max:128'],
'email' => ['nullable', 'email', 'max:255', Rule::unique('admin_users', 'email')],
'password' => ['required', 'string', 'min:8', 'max:128'],
'status' => ['sometimes', 'integer', 'in:0,1'],
'role_ids' => ['sometimes', 'array'],
'role_ids.*' => ['integer', 'exists:admin_roles,id'],
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
final class AgentDelegationGrantSyncRequest extends FormRequest
{
public function authorize(): bool
{
return $this->lotteryAdmin() !== null;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'grants' => ['required', 'array'],
'grants.*.menu_action_id' => ['required', 'integer', 'min:1'],
'grants.*.can_delegate' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
final class AgentNodeStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'parent_id' => ['required', 'integer', 'exists:agent_nodes,id'],
'code' => ['required', 'string', 'max:64', 'regex:/^[a-zA-Z0-9_-]+$/'],
'name' => ['required', 'string', 'max:128'],
'status' => ['sometimes', 'integer', 'in:0,1'],
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
final class AgentNodeUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'name' => ['sometimes', 'string', 'max:128'],
'status' => ['sometimes', 'integer', 'in:0,1'],
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
final class AgentRolePermissionSyncRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'permission_slugs' => ['required', 'array'],
'permission_slugs.*' => ['string', 'max:128'],
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests\Admin;
use App\Models\AdminRole;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class AgentRoleStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
$agentNode = $this->route('agent_node');
$ownerId = is_object($agentNode) ? (int) $agentNode->id : (int) $agentNode;
return [
'slug' => [
'required',
'string',
'max:64',
'regex:/^[a-z0-9_\\-]+$/',
Rule::unique('admin_roles', 'slug')->where(
static fn ($query) => $query->where('owner_agent_id', $ownerId),
),
],
'name' => ['required', 'string', 'max:128'],
'description' => ['nullable', 'string', 'max:65535'],
'status' => ['sometimes', 'integer', 'in:0,1'],
'permission_slugs' => ['sometimes', 'array'],
'permission_slugs.*' => ['string', 'max:128'],
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
final class AgentRoleUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'name' => ['sometimes', 'string', 'max:128'],
'description' => ['nullable', 'string', 'max:65535'],
'status' => ['sometimes', 'integer', 'in:0,1'],
];
}
}

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,10 @@ final class AdminRole extends Model
{
public const ROLE_SUPER_ADMIN = 'super_admin';
public const SCOPE_SYSTEM = 'system';
public const SCOPE_AGENT = 'agent';
protected $table = 'admin_roles';
protected static function booted(): void
@@ -30,8 +34,32 @@ final class AdminRole extends Model
'status',
'is_system',
'sort_order',
'owner_agent_id',
'delegated_from_role_id',
'scope_type',
];
protected function casts(): array
{
return [
'owner_agent_id' => 'integer',
'delegated_from_role_id' => 'integer',
'status' => 'integer',
'is_system' => 'boolean',
'sort_order' => 'integer',
];
}
public function isAgentScoped(): bool
{
return $this->scope_type === self::SCOPE_AGENT && $this->owner_agent_id !== null;
}
public function isReadOnlyTemplate(): bool
{
return $this->delegated_from_role_id !== null;
}
/**
* @return BelongsToMany<AdminMenuAction, AdminRole>
*/
@@ -103,6 +131,15 @@ final class AdminRole extends Model
public function assignedUserCount(): int
{
$agentCount = (int) DB::table('admin_user_agent_roles')
->where('role_id', $this->id)
->distinct()
->count('admin_user_id');
if ($this->isAgentScoped()) {
return $agentCount;
}
return (int) DB::table('admin_user_site_roles')
->where('role_id', $this->id)
->distinct()

View File

@@ -78,6 +78,78 @@ final class AdminUser extends Authenticatable
*
* @param list<string> $slugs
*/
/**
* @param list<int> $roleIds
*/
public function syncAgentRoleIds(int $agentNodeId, array $roleIds): void
{
$roleIds = array_values(array_unique(array_map(static fn ($id): int => (int) $id, $roleIds)));
DB::transaction(function () use ($agentNodeId, $roleIds): void {
DB::table('admin_user_agent_roles')
->where('admin_user_id', $this->id)
->where('agent_node_id', $agentNodeId)
->delete();
$now = now();
foreach ($roleIds as $roleId) {
DB::table('admin_user_agent_roles')->insert([
'admin_user_id' => $this->id,
'agent_node_id' => $agentNodeId,
'role_id' => $roleId,
'granted_at' => $now,
]);
}
$siteId = (int) (AgentNode::query()->where('id', $agentNodeId)->value('admin_site_id') ?? 0);
if ($siteId > 0) {
DB::table('admin_user_site_roles')
->where('admin_user_id', $this->id)
->where('site_id', $siteId)
->delete();
foreach ($roleIds as $roleId) {
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $this->id,
'site_id' => $siteId,
'role_id' => $roleId,
'granted_at' => $now,
]);
}
}
});
}
/**
* @return list<string>
*/
private function roleMenuActionPermissionCodes(): array
{
$agentId = $this->primaryAgentNodeId();
if ($agentId !== null) {
$fromAgent = DB::table('admin_user_agent_roles as uar')
->join('admin_role_menu_actions as rma', 'rma.role_id', '=', 'uar.role_id')
->join('admin_menu_actions as ma', 'ma.id', '=', 'rma.menu_action_id')
->where('uar.admin_user_id', $this->id)
->where('uar.agent_node_id', $agentId)
->where('ma.status', 1)
->pluck('ma.permission_code')
->all();
if ($fromAgent !== []) {
return $fromAgent;
}
}
return DB::table('admin_user_site_roles as usr')
->join('admin_role_menu_actions as rma', 'rma.role_id', '=', 'usr.role_id')
->join('admin_menu_actions as ma', 'ma.id', '=', 'rma.menu_action_id')
->where('usr.admin_user_id', $this->id)
->where('ma.status', 1)
->pluck('ma.permission_code')
->all();
}
public function syncRoleSlugsForDefaultSite(array $slugs): void
{
$siteId = self::defaultAdminSiteId();
@@ -114,6 +186,25 @@ final class AdminUser extends Authenticatable
return $this->roles()->where('admin_roles.slug', self::ROLE_SUPER_ADMIN)->exists();
}
public function primaryAgentNodeId(): ?int
{
$id = DB::table('admin_user_agents')
->where('admin_user_id', $this->id)
->value('agent_node_id');
return $id !== null ? (int) $id : null;
}
public function primaryAgentNode(): ?AgentNode
{
$id = $this->primaryAgentNodeId();
if ($id === null) {
return null;
}
return AgentNode::query()->find($id);
}
/**
* 可访问的 admin_sites.id 列表;`null` 表示不限制(超管)。
*
@@ -125,6 +216,11 @@ final class AdminUser extends Authenticatable
return null;
}
$agent = $this->primaryAgentNode();
if ($agent !== null) {
return [(int) $agent->admin_site_id];
}
$ids = DB::table('admin_user_site_roles')
->where('admin_user_id', $this->id)
->distinct()
@@ -193,13 +289,7 @@ final class AdminUser extends Authenticatable
return array_keys($out);
}
$fromRoles = DB::table('admin_user_site_roles as usr')
->join('admin_role_menu_actions as rma', 'rma.role_id', '=', 'usr.role_id')
->join('admin_menu_actions as ma', 'ma.id', '=', 'rma.menu_action_id')
->where('usr.admin_user_id', $this->id)
->where('ma.status', 1)
->pluck('ma.permission_code')
->all();
$fromRoles = $this->roleMenuActionPermissionCodes();
$merged = [];
foreach (array_merge($fromRoles, $this->directMenuActionPermissionCodes()) as $c) {

82
app/Models/AgentNode.php Normal file
View File

@@ -0,0 +1,82 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class AgentNode extends Model
{
protected $table = 'agent_nodes';
protected $fillable = [
'admin_site_id',
'parent_id',
'path',
'depth',
'code',
'name',
'status',
'created_by',
'extra_json',
];
protected function casts(): array
{
return [
'admin_site_id' => 'integer',
'parent_id' => 'integer',
'depth' => 'integer',
'status' => 'integer',
'extra_json' => 'array',
];
}
public function isEnabled(): bool
{
return (int) $this->status === 1;
}
public function isRoot(): bool
{
return $this->parent_id === null || (int) $this->depth === 0;
}
/** @return BelongsTo<AdminSite, AgentNode> */
public function adminSite(): BelongsTo
{
return $this->belongsTo(AdminSite::class, 'admin_site_id');
}
/** @return BelongsTo<AgentNode, AgentNode> */
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
/** @return HasMany<AgentNode, AgentNode> */
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id')->orderBy('code');
}
public function isDescendantOf(self $ancestor): bool
{
if ((int) $this->admin_site_id !== (int) $ancestor->admin_site_id) {
return false;
}
$ancestorPath = (string) $ancestor->path;
if ($ancestorPath === '') {
return false;
}
return str_starts_with((string) $this->path, $ancestorPath);
}
public function isSameOrDescendantOf(self $ancestor): bool
{
return (int) $this->id === (int) $ancestor->id || $this->isDescendantOf($ancestor);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
@@ -12,6 +13,7 @@ final class Player extends Model
{
protected $fillable = [
'site_code',
'agent_node_id',
'site_player_id',
'username',
'nickname',
@@ -23,6 +25,7 @@ final class Player extends Model
protected function casts(): array
{
return [
'agent_node_id' => 'integer',
'last_login_at' => 'datetime',
];
}
@@ -31,4 +34,9 @@ final class Player extends Model
{
return $this->hasMany(PlayerWallet::class);
}
public function agentNode(): BelongsTo
{
return $this->belongsTo(AgentNode::class, 'agent_node_id');
}
}

View File

@@ -45,7 +45,7 @@ final class AdminDashboardAnalyticsBuilder
$dateFrom = $range['date_from'];
$dateTo = $range['date_to'];
$trend = $this->reportQuery->dailyProfitSeriesFilled($dateFrom, $dateTo);
$trend = $this->reportQuery->dailyProfitSeriesFilled($dateFrom, $dateTo, scopedAdmin: $admin);
return [
'period' => $period,
@@ -53,8 +53,8 @@ final class AdminDashboardAnalyticsBuilder
'play_code' => $playCode,
'date_from' => $dateFrom,
'date_to' => $dateTo,
'currency_code' => $this->reportQuery->resolvePeriodCurrencyCode($dateFrom, $dateTo),
'summary' => $this->reportQuery->periodFinanceTotals($dateFrom, $dateTo),
'currency_code' => $this->reportQuery->resolvePeriodCurrencyCode($dateFrom, $dateTo, $admin),
'summary' => $this->reportQuery->periodFinanceTotals($dateFrom, $dateTo, $admin),
'daily_series' => $trend['series'],
'chart_meta' => [
'chart_date_from' => $trend['chart_date_from'],
@@ -66,6 +66,14 @@ final class AdminDashboardAnalyticsBuilder
$dateFrom,
$dateTo,
$playCode,
scopedAdmin: $admin,
),
'agent_breakdown' => $this->reportQuery->agentRankingRows(
$dateFrom,
$dateTo,
$playCode,
limit: 200,
scopedAdmin: $admin,
),
];
}

View File

@@ -13,6 +13,7 @@ use App\Models\SettlementBatch;
use App\Models\DrawResultBatch;
use App\Lottery\DrawResultBatchStatus;
use App\Services\Draw\DrawHallSnapshotBuilder;
use App\Support\AdminDataScope;
/**
* 后台首页仪表盘:聚合大厅快照、当期财务、期号面板、风控摘要、异常转账计数。
@@ -52,11 +53,11 @@ final class AdminDashboardSnapshotBuilder
];
if ($canDraw) {
$this->fillPlatformOverview($out);
$this->fillPlatformOverview($out, $admin);
}
if ($canWallet) {
$out['abnormal_transfer_total'] = $this->abnormalTransferTotal();
$out['abnormal_transfer_total'] = $this->abnormalTransferTotal($admin);
}
if ($hall === null) {
@@ -81,7 +82,7 @@ final class AdminDashboardSnapshotBuilder
];
if ($canDraw) {
$out['finance'] = $this->financeSummary($draw);
$out['finance'] = $this->financeSummary($draw, $admin);
$out['draw'] = $this->drawPanel($draw);
$out['risk'] = $this->riskPanel($draw);
}
@@ -90,10 +91,10 @@ final class AdminDashboardSnapshotBuilder
}
/** @param array<string, mixed> $out */
private function fillPlatformOverview(array &$out): void
private function fillPlatformOverview(array &$out, AdminUser $admin): void
{
$out['today_finance'] = $this->todayFinanceSummary();
$out['lifetime_finance'] = $this->reportQuery->platformLifetimeTotals();
$out['today_finance'] = $this->todayFinanceSummary($admin);
$out['lifetime_finance'] = $this->reportQuery->platformLifetimeTotals($admin);
$out['platform_risk'] = $this->platformRiskSummary();
$out['result_batch_queue'] = $this->resultBatchQueue();
}
@@ -114,11 +115,13 @@ final class AdminDashboardSnapshotBuilder
|| $admin->hasAdminPermission('prd.wallet_reconcile.view_cs');
}
private function abnormalTransferTotal(): int
private function abnormalTransferTotal(AdminUser $admin): int
{
return (int) TransferOrder::query()
->whereIn('status', ['processing', 'failed', 'pending_reconcile'])
->count();
$query = TransferOrder::query()
->whereIn('status', ['processing', 'failed', 'pending_reconcile']);
AdminDataScope::applyEloquentViaPlayer($query, $admin);
return (int) $query->count();
}
/**
@@ -126,10 +129,10 @@ final class AdminDashboardSnapshotBuilder
*
* @return array<string, mixed>
*/
private function todayFinanceSummary(): array
private function todayFinanceSummary(AdminUser $admin): array
{
$today = now()->toDateString();
$rows = $this->reportQuery->dailyProfitRows($today, $today);
$rows = $this->reportQuery->dailyProfitRows($today, $today, $admin);
$row = $rows[0] ?? [
'business_date' => $today,
'total_bet_minor' => 0,
@@ -152,20 +155,23 @@ final class AdminDashboardSnapshotBuilder
}
/** @return array<string, mixed> */
private function financeSummary(Draw $draw): array
private function financeSummary(Draw $draw, AdminUser $admin): array
{
$drawId = (int) $draw->id;
$totalBetMinor = (int) TicketOrder::query()->where('draw_id', $drawId)->sum('total_actual_deduct');
$orderCount = (int) TicketOrder::query()->where('draw_id', $drawId)->count();
$itemCount = (int) TicketItem::query()->where('draw_id', $drawId)->count();
$orderQuery = TicketOrder::query()->where('draw_id', $drawId);
$itemQuery = TicketItem::query()->where('draw_id', $drawId);
AdminDataScope::applyEloquentViaPlayer($orderQuery, $admin);
AdminDataScope::applyEloquentViaPlayer($itemQuery, $admin);
$currencyCode = (string) (TicketOrder::query()
->where('draw_id', $drawId)
->value('currency_code') ?? '');
$totalBetMinor = (int) $orderQuery->sum('total_actual_deduct');
$orderCount = (int) $orderQuery->count();
$itemCount = (int) $itemQuery->count();
$totalWinMinor = (int) TicketItem::query()->where('draw_id', $drawId)->sum('win_amount');
$totalJackpotWinMinor = (int) TicketItem::query()->where('draw_id', $drawId)->sum('jackpot_win_amount');
$currencyCode = (string) ((clone $orderQuery)->value('currency_code') ?? '');
$totalWinMinor = (int) $itemQuery->sum('win_amount');
$totalJackpotWinMinor = (int) (clone $itemQuery)->sum('jackpot_win_amount');
$totalPayoutMinor = $totalWinMinor + $totalJackpotWinMinor;
$approxHouseGrossMinor = $totalBetMinor - $totalPayoutMinor;

View File

@@ -68,9 +68,9 @@ final class AdminReportJobService
/**
* @return list<array<int, string|int|float|null>>
*/
public function reportRows(string $reportType, ?array $filterJson): array
public function reportRows(string $reportType, ?array $filterJson, ?AdminUser $scopedAdmin = null): array
{
return $this->queryService->reportRows($reportType, $filterJson);
return $this->queryService->reportRows($reportType, $filterJson, $scopedAdmin);
}
public function reportLabel(string $reportType): string

View File

@@ -2,7 +2,9 @@
namespace App\Services\Admin;
use App\Models\AdminUser;
use App\Models\AuditLog;
use App\Support\AdminDataScope;
use App\Models\Draw;
use App\Models\RiskPool;
use App\Models\RiskPoolLockLog;
@@ -109,9 +111,9 @@ final class AdminReportQueryService
* business_day_count: int
* }
*/
public function periodFinanceTotals(string $dateFrom, string $dateTo): array
public function periodFinanceTotals(string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array
{
$rows = $this->dailyProfitRows($dateFrom, $dateTo);
$rows = $this->dailyProfitRows($dateFrom, $dateTo, $scopedAdmin);
$totalBet = 0;
$totalPayout = 0;
$totalGross = 0;
@@ -121,9 +123,11 @@ final class AdminReportQueryService
$totalGross += (int) $row['approx_house_gross_minor'];
}
$activity = DB::table('draws as d')
$activityQuery = DB::table('draws as d')
->join('ticket_orders as o', 'o.draw_id', '=', 'd.id')
->whereBetween('d.business_date', [$dateFrom, $dateTo])
->whereBetween('d.business_date', [$dateFrom, $dateTo]);
AdminDataScope::applyToTicketOrdersViaPlayer($activityQuery, $scopedAdmin, 'o');
$activity = $activityQuery
->selectRaw('COUNT(DISTINCT d.id) as draw_count')
->selectRaw('COUNT(DISTINCT d.business_date) as business_day_count')
->first();
@@ -142,7 +146,7 @@ final class AdminReportQueryService
*
* @return list<array<string, mixed>>
*/
public function dailyProfitSeriesFilled(string $dateFrom, string $dateTo, int $maxDays = 90): array
public function dailyProfitSeriesFilled(string $dateFrom, string $dateTo, int $maxDays = 90, ?AdminUser $scopedAdmin = null): array
{
$from = Carbon::parse($dateFrom)->startOfDay();
$to = Carbon::parse($dateTo)->startOfDay();
@@ -156,7 +160,7 @@ final class AdminReportQueryService
$truncated = true;
}
$indexed = collect($this->dailyProfitRows($chartFrom, $chartTo))->keyBy('business_date');
$indexed = collect($this->dailyProfitRows($chartFrom, $chartTo, $scopedAdmin))->keyBy('business_date');
$cursor = Carbon::parse($chartFrom)->startOfDay();
$end = Carbon::parse($chartTo)->startOfDay();
$series = [];
@@ -189,8 +193,9 @@ final class AdminReportQueryService
string $dateTo,
?string $playCode = null,
int $limit = 12,
?AdminUser $scopedAdmin = null,
): array {
return $this->playDimensionBaseQuery($playCode, $dateFrom, $dateTo)
return $this->playDimensionBaseQuery($playCode, $dateFrom, $dateTo, $scopedAdmin)
->orderByDesc('total_bet_minor')
->limit($limit)
->get()
@@ -207,20 +212,78 @@ final class AdminReportQueryService
->all();
}
public function resolvePeriodCurrencyCode(string $dateFrom, string $dateTo): ?string
/**
* 仪表盘「代理排行」:按代理子树聚合投注/派彩/盈亏(与 daily-profit 同口径)。
*
* @return list<array{
* agent_node_id: int,
* agent_code: string,
* agent_name: string,
* total_bet_minor: int,
* total_payout_minor: int,
* approx_house_gross_minor: int
* }>
*/
public function agentRankingRows(
string $dateFrom,
string $dateTo,
?string $playCode = null,
int $limit = 200,
?AdminUser $scopedAdmin = null,
): array {
$query = DB::table('ticket_items as ti')
->join('ticket_orders as o', 'o.id', '=', 'ti.order_id')
->leftJoin('players as p', 'p.id', '=', 'ti.player_id')
->leftJoin('agent_nodes as an', 'an.id', '=', 'p.agent_node_id')
->selectRaw('p.agent_node_id as agent_node_id')
->selectRaw('an.code as agent_code')
->selectRaw('an.name as agent_name')
->selectRaw('SUM(ti.actual_deduct_amount) as total_bet_minor')
->selectRaw('SUM(ti.win_amount + ti.jackpot_win_amount) as total_payout_minor')
->selectRaw('SUM(ti.actual_deduct_amount) - SUM(ti.win_amount + ti.jackpot_win_amount) as approx_house_gross_minor')
->whereDate('o.created_at', '>=', $dateFrom)
->whereDate('o.created_at', '<=', $dateTo)
->whereNotNull('p.agent_node_id')
->groupBy('p.agent_node_id', 'an.code', 'an.name');
if ($playCode !== null && $playCode !== '') {
$query->where('ti.play_code', $playCode);
}
AdminDataScope::applyToPlayersAlias($query, $scopedAdmin, 'p');
return $query
->orderByDesc('total_bet_minor')
->limit($limit)
->get()
->map(static function (object $row): array {
return [
'agent_node_id' => (int) $row->agent_node_id,
'agent_code' => (string) ($row->agent_code ?? ''),
'agent_name' => (string) ($row->agent_name ?? ''),
'total_bet_minor' => (int) $row->total_bet_minor,
'total_payout_minor' => (int) $row->total_payout_minor,
'approx_house_gross_minor' => (int) $row->approx_house_gross_minor,
];
})
->values()
->all();
}
public function resolvePeriodCurrencyCode(string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): ?string
{
$currencyCode = (string) (DB::table('ticket_orders as o')
$currencyQuery = DB::table('ticket_orders as o')
->join('draws as d', 'd.id', '=', 'o.draw_id')
->whereBetween('d.business_date', [$dateFrom, $dateTo])
->orderByDesc('o.id')
->value('o.currency_code') ?? '');
->whereBetween('d.business_date', [$dateFrom, $dateTo]);
AdminDataScope::applyToTicketOrdersViaPlayer($currencyQuery, $scopedAdmin, 'o');
$currencyCode = (string) ($currencyQuery->orderByDesc('o.id')->value('o.currency_code') ?? '');
return $currencyCode !== '' ? $currencyCode : null;
}
public function dailyProfitPaginated(string $dateFrom, string $dateTo, int $page, int $perPage): LengthAwarePaginator
public function dailyProfitPaginated(string $dateFrom, string $dateTo, int $page, int $perPage, ?AdminUser $scopedAdmin = null): LengthAwarePaginator
{
$rows = $this->dailyProfitRows($dateFrom, $dateTo);
$rows = $this->dailyProfitRows($dateFrom, $dateTo, $scopedAdmin);
$total = count($rows);
$offset = max(0, ($page - 1) * $perPage);
$items = array_slice($rows, $offset, $perPage);
@@ -233,14 +296,18 @@ final class AdminReportQueryService
/**
* @return list<array<string, mixed>>
*/
public function dailyProfitRows(string $dateFrom, string $dateTo): array
public function dailyProfitRows(string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array
{
$betSub = DB::table('ticket_orders')
->selectRaw('draw_id, SUM(total_actual_deduct) as total_bet_minor')
->groupBy('draw_id');
$payoutSub = DB::table('ticket_items')
->selectRaw('draw_id, SUM(win_amount + jackpot_win_amount) as total_payout_minor')
->groupBy('draw_id');
$betSub = DB::table('ticket_orders as o')
->selectRaw('o.draw_id, SUM(o.total_actual_deduct) as total_bet_minor')
->groupBy('o.draw_id');
AdminDataScope::applyToTicketOrdersViaPlayer($betSub, $scopedAdmin, 'o');
$payoutSub = DB::table('ticket_items as ti')
->join('ticket_orders as o', 'o.id', '=', 'ti.order_id')
->selectRaw('ti.draw_id, SUM(ti.win_amount + ti.jackpot_win_amount) as total_payout_minor')
->groupBy('ti.draw_id');
AdminDataScope::applyToTicketOrdersViaPlayer($payoutSub, $scopedAdmin, 'o');
return DB::table('draws as d')
->whereBetween('d.business_date', [$dateFrom, $dateTo])
@@ -284,19 +351,26 @@ final class AdminReportQueryService
* date_to: ?string
* }
*/
public function platformLifetimeTotals(): array
public function platformLifetimeTotals(?AdminUser $scopedAdmin = null): array
{
$totalBetMinor = (int) DB::table('ticket_orders')->sum('total_actual_deduct');
$betQuery = DB::table('ticket_orders as o');
AdminDataScope::applyToTicketOrdersViaPlayer($betQuery, $scopedAdmin, 'o');
$totalBetMinor = (int) $betQuery->sum('o.total_actual_deduct');
$payoutAgg = DB::table('ticket_items')
->selectRaw('COALESCE(SUM(win_amount), 0) as win_minor, COALESCE(SUM(jackpot_win_amount), 0) as jackpot_minor')
$payoutQuery = DB::table('ticket_items as ti')
->join('ticket_orders as o', 'o.id', '=', 'ti.order_id');
AdminDataScope::applyToTicketOrdersViaPlayer($payoutQuery, $scopedAdmin, 'o');
$payoutAgg = $payoutQuery
->selectRaw('COALESCE(SUM(ti.win_amount), 0) as win_minor, COALESCE(SUM(ti.jackpot_win_amount), 0) as jackpot_minor')
->first();
$totalWinMinor = (int) ($payoutAgg->win_minor ?? 0);
$totalJackpotMinor = (int) ($payoutAgg->jackpot_minor ?? 0);
$totalPayoutMinor = $totalWinMinor + $totalJackpotMinor;
$activity = DB::table('draws as d')
->join('ticket_orders as o', 'o.draw_id', '=', 'd.id')
$activityQuery = DB::table('draws as d')
->join('ticket_orders as o', 'o.draw_id', '=', 'd.id');
AdminDataScope::applyToTicketOrdersViaPlayer($activityQuery, $scopedAdmin, 'o');
$activity = $activityQuery
->selectRaw('COUNT(DISTINCT d.id) as draw_count')
->selectRaw('COUNT(DISTINCT d.business_date) as business_day_count')
->selectRaw('MIN(d.business_date) as date_from')
@@ -309,7 +383,15 @@ final class AdminReportQueryService
$dateFrom = $this->formatBusinessDateValue($activity?->date_from);
$dateTo = $this->formatBusinessDateValue($activity?->date_to);
$currencyCode = (string) (DB::table('ticket_orders')->orderByDesc('id')->value('currency_code') ?? '');
$currencyQuery = DB::table('ticket_orders as o');
AdminDataScope::applyToTicketOrdersViaPlayer($currencyQuery, $scopedAdmin, 'o');
$currencyCode = (string) ($currencyQuery->orderByDesc('o.id')->value('o.currency_code') ?? '');
$orderCountQuery = DB::table('ticket_orders as o');
AdminDataScope::applyToTicketOrdersViaPlayer($orderCountQuery, $scopedAdmin, 'o');
$itemCountQuery = DB::table('ticket_items as ti')
->join('ticket_orders as o', 'o.id', '=', 'ti.order_id');
AdminDataScope::applyToTicketOrdersViaPlayer($itemCountQuery, $scopedAdmin, 'o');
return [
'currency_code' => $currencyCode !== '' ? $currencyCode : null,
@@ -318,8 +400,8 @@ final class AdminReportQueryService
'total_jackpot_minor' => $totalJackpotMinor,
'total_payout_minor' => $totalPayoutMinor,
'approx_house_gross_minor' => $totalBetMinor - $totalPayoutMinor,
'order_count' => (int) DB::table('ticket_orders')->count(),
'ticket_item_count' => (int) DB::table('ticket_items')->count(),
'order_count' => (int) $orderCountQuery->count(),
'ticket_item_count' => (int) $itemCountQuery->count('ti.id'),
'draw_count' => $drawCount,
'business_day_count' => $businessDayCount,
'date_from' => $dateFrom,
@@ -333,8 +415,10 @@ final class AdminReportQueryService
string $dateTo,
int $page,
int $perPage,
?AdminUser $scopedAdmin = null,
?int $requestedAgentNodeId = null,
): LengthAwarePaginator {
$query = $this->playerWinLossBaseQuery($playerId, $dateFrom, $dateTo);
$query = $this->playerWinLossBaseQuery($playerId, $dateFrom, $dateTo, $scopedAdmin, $requestedAgentNodeId);
return $query->paginate($perPage, ['*'], 'page', $page);
}
@@ -345,8 +429,9 @@ final class AdminReportQueryService
string $dateTo,
int $page,
int $perPage,
?AdminUser $scopedAdmin = null,
): LengthAwarePaginator {
$query = $this->playDimensionBaseQuery($playCode, $dateFrom, $dateTo);
$query = $this->playDimensionBaseQuery($playCode, $dateFrom, $dateTo, $scopedAdmin);
return $query->paginate($perPage, ['*'], 'page', $page);
}
@@ -357,8 +442,9 @@ final class AdminReportQueryService
string $dateTo,
int $page,
int $perPage,
?AdminUser $scopedAdmin = null,
): LengthAwarePaginator {
$query = $this->rebateCommissionBaseQuery($playCode, $dateFrom, $dateTo);
$query = $this->rebateCommissionBaseQuery($playCode, $dateFrom, $dateTo, $scopedAdmin);
return $query->paginate($perPage, ['*'], 'page', $page);
}
@@ -366,21 +452,21 @@ final class AdminReportQueryService
/**
* @return list<array<int, string|int|float|null>>
*/
public function reportRows(string $reportType, ?array $filterJson): array
public function reportRows(string $reportType, ?array $filterJson, ?AdminUser $scopedAdmin = null): array
{
$range = $this->resolveDateRange($filterJson);
$dateFrom = $range['date_from'];
$dateTo = $range['date_to'];
return match ($reportType) {
'draw_profit_summary' => $this->drawProfitExportRows($filterJson),
'daily_profit_summary' => $this->dailyProfitExportRows($dateFrom, $dateTo),
'player_win_loss' => $this->playerWinLossExportRows($filterJson, $dateFrom, $dateTo),
'play_dimension_report' => $this->playDimensionExportRows($filterJson, $dateFrom, $dateTo),
'rebate_commission_report' => $this->rebateCommissionExportRows($filterJson, $dateFrom, $dateTo),
'draw_profit_summary' => $this->drawProfitExportRows($filterJson, $scopedAdmin),
'daily_profit_summary' => $this->dailyProfitExportRows($dateFrom, $dateTo, $scopedAdmin),
'player_win_loss' => $this->playerWinLossExportRows($filterJson, $dateFrom, $dateTo, $scopedAdmin),
'play_dimension_report' => $this->playDimensionExportRows($filterJson, $dateFrom, $dateTo, $scopedAdmin),
'rebate_commission_report' => $this->rebateCommissionExportRows($filterJson, $dateFrom, $dateTo, $scopedAdmin),
'audit_operation_report' => $this->auditExportRows($filterJson, $dateFrom, $dateTo),
'wallet_transfer_report', 'transfer_orders_daily' => $this->transferOrdersExportRows($filterJson, $dateFrom, $dateTo),
'wallet_txns_daily' => $this->walletTxnsExportRows($filterJson, $dateFrom, $dateTo),
'wallet_transfer_report', 'transfer_orders_daily' => $this->transferOrdersExportRows($filterJson, $dateFrom, $dateTo, $scopedAdmin),
'wallet_txns_daily' => $this->walletTxnsExportRows($filterJson, $dateFrom, $dateTo, $scopedAdmin),
'hot_number_risk_report' => $this->hotNumberRiskExportRows($filterJson),
'sold_out_number_report' => $this->soldOutNumberExportRows($filterJson),
default => [
@@ -427,12 +513,12 @@ final class AdminReportQueryService
/**
* @return list<array<int, string|int|float|null>>
*/
private function dailyProfitExportRows(string $dateFrom, string $dateTo): array
private function dailyProfitExportRows(string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array
{
$rows = [
['日期', '下注', '派彩', '盈亏'],
];
foreach ($this->dailyProfitRows($dateFrom, $dateTo) as $row) {
foreach ($this->dailyProfitRows($dateFrom, $dateTo, $scopedAdmin) as $row) {
$rows[] = [
$row['business_date'],
$row['total_bet_minor'],
@@ -447,13 +533,13 @@ final class AdminReportQueryService
/**
* @return list<array<int, string|int|float|null>>
*/
private function playerWinLossExportRows(?array $filterJson, string $dateFrom, string $dateTo): array
private function playerWinLossExportRows(?array $filterJson, string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array
{
$playerId = isset($filterJson['player_id']) ? (int) $filterJson['player_id'] : null;
$rows = [
['玩家ID', '用户名', '下注', '派彩', '净输赢'],
];
$items = $this->playerWinLossBaseQuery($playerId, $dateFrom, $dateTo)->get();
$items = $this->playerWinLossBaseQuery($playerId, $dateFrom, $dateTo, $scopedAdmin)->get();
foreach ($items as $row) {
$rows[] = [
(int) $row->player_id,
@@ -470,13 +556,13 @@ final class AdminReportQueryService
/**
* @return list<array<int, string|int|float|null>>
*/
private function playDimensionExportRows(?array $filterJson, string $dateFrom, string $dateTo): array
private function playDimensionExportRows(?array $filterJson, string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array
{
$playCode = isset($filterJson['play_code']) ? trim((string) $filterJson['play_code']) : null;
$rows = [
['玩法', '维度', '下注', '派彩', '盈亏'],
];
$items = $this->playDimensionBaseQuery($playCode !== '' ? $playCode : null, $dateFrom, $dateTo)->get();
$items = $this->playDimensionBaseQuery($playCode !== '' ? $playCode : null, $dateFrom, $dateTo, $scopedAdmin)->get();
foreach ($items as $row) {
$rows[] = [
(string) $row->play_code,
@@ -493,13 +579,13 @@ final class AdminReportQueryService
/**
* @return list<array<int, string|int|float|null>>
*/
private function rebateCommissionExportRows(?array $filterJson, string $dateFrom, string $dateTo): array
private function rebateCommissionExportRows(?array $filterJson, string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array
{
$playCode = isset($filterJson['play_code']) ? trim((string) $filterJson['play_code']) : null;
$rows = [
['玩法', '回水', '订单数', '注单数'],
];
$items = $this->rebateCommissionBaseQuery($playCode !== '' ? $playCode : null, $dateFrom, $dateTo)->get();
$items = $this->rebateCommissionBaseQuery($playCode !== '' ? $playCode : null, $dateFrom, $dateTo, $scopedAdmin)->get();
foreach ($items as $row) {
$rows[] = [
(string) $row->play_code,
@@ -545,30 +631,43 @@ final class AdminReportQueryService
}
/** @return \Illuminate\Database\Query\Builder */
private function playerWinLossBaseQuery(?int $playerId, string $dateFrom, string $dateTo)
{
private function playerWinLossBaseQuery(
?int $playerId,
string $dateFrom,
string $dateTo,
?AdminUser $scopedAdmin = null,
?int $requestedAgentNodeId = null,
) {
$query = DB::table('ticket_items as ti')
->join('ticket_orders as o', 'o.id', '=', 'ti.order_id')
->leftJoin('players as p', 'p.id', '=', 'ti.player_id')
->leftJoin('agent_nodes as an', 'an.id', '=', 'p.agent_node_id')
->selectRaw('ti.player_id')
->selectRaw('p.username as username')
->selectRaw('p.agent_node_id as agent_node_id')
->selectRaw('an.code as agent_code')
->selectRaw('an.name as agent_name')
->selectRaw('SUM(ti.actual_deduct_amount) as total_bet_minor')
->selectRaw('SUM(ti.win_amount + ti.jackpot_win_amount) as total_payout_minor')
->selectRaw('SUM(ti.actual_deduct_amount) - SUM(ti.win_amount + ti.jackpot_win_amount) as net_win_loss_minor')
->whereDate('o.created_at', '>=', $dateFrom)
->whereDate('o.created_at', '<=', $dateTo)
->groupBy('ti.player_id', 'p.username')
->groupBy('ti.player_id', 'p.username', 'p.agent_node_id', 'an.code', 'an.name')
->orderByDesc('net_win_loss_minor');
if ($playerId !== null && $playerId > 0) {
$query->where('ti.player_id', $playerId);
}
if ($scopedAdmin !== null) {
AdminDataScope::applyToPlayersAlias($query, $scopedAdmin, 'p', $requestedAgentNodeId);
}
return $query;
}
/** @return \Illuminate\Database\Query\Builder */
private function playDimensionBaseQuery(?string $playCode, string $dateFrom, string $dateTo)
private function playDimensionBaseQuery(?string $playCode, string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null)
{
$query = DB::table('ticket_items as ti')
->join('ticket_orders as o', 'o.id', '=', 'ti.order_id')
@@ -587,11 +686,13 @@ final class AdminReportQueryService
$query->where('ti.play_code', $playCode);
}
AdminDataScope::applyToTicketOrdersViaPlayer($query, $scopedAdmin, 'o');
return $query;
}
/** @return \Illuminate\Database\Query\Builder */
private function rebateCommissionBaseQuery(?string $playCode, string $dateFrom, string $dateTo)
private function rebateCommissionBaseQuery(?string $playCode, string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null)
{
$query = DB::table('ticket_items as ti')
->join('ticket_orders as o', 'o.id', '=', 'ti.order_id')
@@ -608,13 +709,15 @@ final class AdminReportQueryService
$query->where('ti.play_code', $playCode);
}
AdminDataScope::applyToTicketOrdersViaPlayer($query, $scopedAdmin, 'o');
return $query;
}
/**
* @return list<array<int, string|int|float|null>>
*/
private function drawProfitExportRows(?array $filterJson): array
private function drawProfitExportRows(?array $filterJson, ?AdminUser $scopedAdmin = null): array
{
$draw = $this->resolveDrawForReport($filterJson);
if ($draw === null) {
@@ -622,12 +725,19 @@ final class AdminReportQueryService
}
$drawId = (int) $draw->id;
$totalBetMinor = (int) TicketOrder::query()->where('draw_id', $drawId)->sum('total_actual_deduct');
$orderCount = (int) TicketOrder::query()->where('draw_id', $drawId)->count();
$itemCount = (int) TicketItem::query()->where('draw_id', $drawId)->count();
$currencyCode = (string) (TicketOrder::query()->where('draw_id', $drawId)->value('currency_code') ?? '');
$totalWinMinor = (int) TicketItem::query()->where('draw_id', $drawId)->sum('win_amount');
$totalJackpotWinMinor = (int) TicketItem::query()->where('draw_id', $drawId)->sum('jackpot_win_amount');
$orderQuery = TicketOrder::query()->where('draw_id', $drawId);
$itemQuery = TicketItem::query()->where('draw_id', $drawId);
if ($scopedAdmin !== null) {
AdminDataScope::applyEloquentViaPlayer($orderQuery, $scopedAdmin);
AdminDataScope::applyEloquentViaPlayer($itemQuery, $scopedAdmin);
}
$totalBetMinor = (int) $orderQuery->sum('total_actual_deduct');
$orderCount = (int) $orderQuery->count();
$itemCount = (int) $itemQuery->count();
$currencyCode = (string) ((clone $orderQuery)->value('currency_code') ?? '');
$totalWinMinor = (int) $itemQuery->sum('win_amount');
$totalJackpotWinMinor = (int) (clone $itemQuery)->sum('jackpot_win_amount');
$totalPayoutMinor = $totalWinMinor + $totalJackpotWinMinor;
$approxHouseGrossMinor = $totalBetMinor - $totalPayoutMinor;
@@ -842,7 +952,7 @@ final class AdminReportQueryService
/**
* @return list<array<int, string|int|float|null>>
*/
private function transferOrdersExportRows(?array $filterJson, string $dateFrom, string $dateTo): array
private function transferOrdersExportRows(?array $filterJson, string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array
{
$rows = [
['转账单号', '玩家ID', '用户名', '昵称', '方向', '币种', '金额', '状态', '外部单号', '失败原因', '创建时间', '完成时间'],
@@ -852,6 +962,10 @@ final class AdminReportQueryService
->with(['player:id,username,nickname'])
->orderByDesc('id');
if ($scopedAdmin !== null) {
AdminDataScope::applyEloquentViaPlayer($query, $scopedAdmin);
}
$playerId = isset($filterJson['player_id']) ? (int) $filterJson['player_id'] : null;
if ($playerId !== null && $playerId > 0) {
$query->where('player_id', $playerId);
@@ -884,7 +998,7 @@ final class AdminReportQueryService
/**
* @return list<array<int, string|int|float|null>>
*/
private function walletTxnsExportRows(?array $filterJson, string $dateFrom, string $dateTo): array
private function walletTxnsExportRows(?array $filterJson, string $dateFrom, string $dateTo, ?AdminUser $scopedAdmin = null): array
{
$rows = [
['流水号', '玩家ID', '用户名', '业务类型', '业务单号', '方向', '金额', '变动前余额', '变动后余额', '状态', '外部单号', '备注', '创建时间'],
@@ -894,6 +1008,10 @@ final class AdminReportQueryService
->with(['player:id,username'])
->orderByDesc('id');
if ($scopedAdmin !== null) {
AdminDataScope::applyEloquentViaPlayer($query, $scopedAdmin);
}
$playerId = isset($filterJson['player_id']) ? (int) $filterJson['player_id'] : null;
if ($playerId !== null && $playerId > 0) {
$query->where('player_id', $playerId);

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Services\Agent;
use App\Models\AdminRole;
use App\Models\AdminUser;
use App\Models\AgentNode;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class AgentAdminUserService
{
/**
* @param array{
* username: string,
* nickname: string,
* email?: ?string,
* password: string,
* status?: int,
* role_ids?: list<int>
* } $payload
*/
public function createUnderAgent(AgentNode $agent, array $payload): AdminUser
{
$roleIds = array_values(array_unique(array_map('intval', $payload['role_ids'] ?? [])));
$this->assertRolesAssignable($agent, $roleIds);
return DB::transaction(function () use ($agent, $payload, $roleIds): AdminUser {
$user = AdminUser::query()->create([
'username' => $payload['username'],
'name' => $payload['nickname'],
'email' => isset($payload['email']) ? trim((string) $payload['email']) : null,
'password' => $payload['password'],
'status' => (int) ($payload['status'] ?? 0),
]);
DB::table('admin_user_agents')->insert([
'admin_user_id' => $user->id,
'agent_node_id' => $agent->id,
'is_primary' => true,
'granted_at' => now(),
]);
$user->syncAgentRoleIds($agent->id, $roleIds);
return $user->fresh();
});
}
/**
* @param list<int> $roleIds
*/
public function syncRoles(AgentNode $agent, AdminUser $user, array $roleIds): AdminUser
{
if ((int) $user->primaryAgentNodeId() !== (int) $agent->id) {
throw ValidationException::withMessages(['user' => ['agent_mismatch']]);
}
$roleIds = array_values(array_unique(array_map('intval', $roleIds)));
$this->assertRolesAssignable($agent, $roleIds);
$user->syncAgentRoleIds($agent->id, $roleIds);
return $user->fresh();
}
/**
* @param list<int> $roleIds
*/
private function assertRolesAssignable(AgentNode $agent, array $roleIds): void
{
if ($roleIds === []) {
return;
}
$validCount = AdminRole::query()
->where('scope_type', AdminRole::SCOPE_AGENT)
->where('owner_agent_id', $agent->id)
->whereIn('id', $roleIds)
->count();
if ($validCount !== count($roleIds)) {
throw ValidationException::withMessages(['role_ids' => ['invalid_for_agent']]);
}
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Services\Agent;
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Support\AgentDelegationAuthorization;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
final class AgentDelegationService
{
/**
* @return list<array{
* menu_action_id: int,
* permission_code: string,
* name: string,
* can_delegate: bool
* }>
*/
public function listForChild(AgentNode $child, ?AdminUser $actor = null): array
{
$rows = DB::table('agent_delegation_grants as g')
->join('admin_menu_actions as ma', 'ma.id', '=', 'g.menu_action_id')
->where('g.child_agent_id', $child->id)
->where('ma.status', 1)
->orderBy('ma.permission_code')
->get(['g.menu_action_id', 'g.can_delegate', 'ma.permission_code', 'ma.name']);
if ($rows->isNotEmpty()) {
return $rows->map(static fn ($row): array => [
'menu_action_id' => (int) $row->menu_action_id,
'permission_code' => (string) $row->permission_code,
'name' => (string) $row->name,
'can_delegate' => (bool) $row->can_delegate,
])->all();
}
if ($actor === null) {
return [];
}
$codes = $actor->effectiveMenuActionPermissionCodes();
if ($codes === []) {
return [];
}
return DB::table('admin_menu_actions')
->where('status', 1)
->whereIn('permission_code', $codes)
->orderBy('permission_code')
->get(['id', 'permission_code', 'name'])
->map(static fn ($row): array => [
'menu_action_id' => (int) $row->id,
'permission_code' => (string) $row->permission_code,
'name' => (string) $row->name,
'can_delegate' => false,
])
->all();
}
/**
* @param list<array{menu_action_id: int, can_delegate?: bool}> $grants
* @return list<array{menu_action_id: int, permission_code: string, name: string, can_delegate: bool}>
*/
public function syncGrants(AdminUser $actor, AgentNode $child, array $grants): array
{
AgentDelegationAuthorization::assertGrantsAllowed($actor, $child, $grants);
$parentId = (int) ($child->parent_id ?? 0);
if ($parentId <= 0) {
throw new \InvalidArgumentException('Child agent must have parent.');
}
$now = Carbon::now();
DB::transaction(function () use ($actor, $child, $grants, $parentId, $now): void {
DB::table('agent_delegation_grants')
->where('child_agent_id', $child->id)
->delete();
foreach ($grants as $grant) {
DB::table('agent_delegation_grants')->insert([
'parent_agent_id' => $parentId,
'child_agent_id' => $child->id,
'menu_action_id' => (int) $grant['menu_action_id'],
'can_delegate' => ! empty($grant['can_delegate']),
'granted_by' => $actor->id,
'granted_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
}
});
return $this->listForChild($child->fresh());
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Services\Agent;
use App\Models\AdminUser;
use App\Models\AgentNode;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class AgentNodeService
{
/**
* @param array{parent_id: int, code: string, name: string, status?: int} $payload
*/
public function createChild(AdminUser $actor, array $payload): AgentNode
{
$parent = AgentNode::query()->findOrFail((int) $payload['parent_id']);
$code = trim((string) $payload['code']);
$name = trim((string) $payload['name']);
$status = (int) ($payload['status'] ?? 1);
if ($code === '' || $name === '') {
throw ValidationException::withMessages([
'code' => ['required'],
'name' => ['required'],
]);
}
if (AgentNode::query()->where('admin_site_id', $parent->admin_site_id)->where('code', $code)->exists()) {
throw ValidationException::withMessages([
'code' => ['unique'],
]);
}
return DB::transaction(function () use ($actor, $parent, $code, $name, $status): AgentNode {
$node = AgentNode::query()->create([
'admin_site_id' => $parent->admin_site_id,
'parent_id' => $parent->id,
'path' => '/',
'depth' => (int) $parent->depth + 1,
'code' => $code,
'name' => $name,
'status' => $status === 0 ? 0 : 1,
'created_by' => $actor->id,
'extra_json' => null,
]);
$node->path = (string) $parent->path.$node->id.'/';
$node->save();
return $node->fresh(['adminSite']);
});
}
/**
* @param array{name?: string, status?: int} $payload
*/
public function update(AgentNode $node, array $payload): AgentNode
{
if (array_key_exists('name', $payload)) {
$name = trim((string) $payload['name']);
if ($name !== '') {
$node->name = $name;
}
}
if (array_key_exists('status', $payload)) {
$node->status = (int) $payload['status'] === 0 ? 0 : 1;
}
$node->save();
return $node->fresh(['adminSite']);
}
public function destroy(AgentNode $node): void
{
DB::transaction(static function () use ($node): void {
$node->delete();
});
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Services\Agent;
use App\Models\AdminRole;
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Support\AgentRoleAuthorization;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class AgentRoleService
{
/**
* @param array{slug: string, name: string, description?: ?string, status?: int, permission_slugs?: list<string>} $payload
*/
public function createForAgent(AdminUser $actor, AgentNode $owner, array $payload): AdminRole
{
AgentRoleAuthorization::assertSlugsForAgentRole(
$actor,
$owner,
array_values(array_unique($payload['permission_slugs'] ?? [])),
);
$slug = trim((string) $payload['slug']);
if (AdminRole::query()
->where('owner_agent_id', $owner->id)
->where('slug', $slug)
->exists()) {
throw ValidationException::withMessages(['slug' => ['unique']]);
}
return DB::transaction(function () use ($payload, $owner, $slug): AdminRole {
$role = AdminRole::query()->create([
'slug' => $slug,
'code' => $slug,
'name' => trim((string) $payload['name']),
'description' => $payload['description'] ?? null,
'status' => (int) ($payload['status'] ?? 1) === 0 ? 0 : 1,
'is_system' => false,
'sort_order' => 0,
'scope_type' => AdminRole::SCOPE_AGENT,
'owner_agent_id' => $owner->id,
'delegated_from_role_id' => null,
]);
$role->syncLegacyPermissionSlugs($payload['permission_slugs'] ?? []);
return $role->fresh();
});
}
/**
* @param array{name?: string, description?: ?string, status?: int} $payload
*/
public function update(AdminRole $role, array $payload): AdminRole
{
if (array_key_exists('name', $payload)) {
$name = trim((string) $payload['name']);
if ($name !== '') {
$role->name = $name;
}
}
if (array_key_exists('description', $payload)) {
$role->description = $payload['description'];
}
if (array_key_exists('status', $payload)) {
$role->status = (int) $payload['status'] === 0 ? 0 : 1;
}
$role->save();
return $role->fresh();
}
/**
* @param list<string> $permissionSlugs
*/
public function syncPermissions(AdminUser $actor, AdminRole $role, array $permissionSlugs): AdminRole
{
$owner = AgentNode::query()->findOrFail((int) $role->owner_agent_id);
AgentRoleAuthorization::assertSlugsForAgentRole($actor, $owner, $permissionSlugs);
$role->syncLegacyPermissionSlugs($permissionSlugs);
return $role->fresh();
}
public function destroy(AdminRole $role): void
{
if ($role->is_system) {
throw ValidationException::withMessages(['role' => ['system_role']]);
}
if ($role->assignedUserCount() > 0) {
throw ValidationException::withMessages(['role' => ['in_use']]);
}
$role->delete();
}
}

View File

@@ -160,6 +160,43 @@ final class LotterySettings
Cache::put(self::cacheKey($key), self::normalizeValue($value), self::cacheTtlSeconds());
}
/**
* 批量写入(管理端一次保存多块配置,避免 N HTTP + 审计)。
*
* @param list<array{key: string, value: mixed}> $items
*/
public static function putMany(array $items): void
{
if ($items === []) {
return;
}
$keys = array_values(array_unique(array_map(
static fn (array $item): string => (string) $item['key'],
$items,
)));
/** @var array<string, LotterySetting> $existing */
$existing = LotterySetting::query()
->whereIn('setting_key', $keys)
->get()
->keyBy('setting_key')
->all();
foreach ($items as $item) {
$key = (string) $item['key'];
$value = $item['value'];
$row = $existing[$key] ?? null;
self::put(
$key,
$value,
$row ? (string) $row->group_name : (explode('.', $key)[0] ?? 'general'),
$row ? $row->description_zh : null,
);
}
}
public static function cacheKey(string $key): string
{
return 'lottery_settings:'.$key;

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Support;
use App\Models\AdminSite;
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Lottery\ErrorCode;
use App\Support\ApiMessage;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
final class AdminAgentNodeAccess
{
public static function resolveAdminSiteId(AdminUser $admin, ?int $requestedSiteId): ?int
{
if ($admin->isSuperAdmin()) {
if ($requestedSiteId !== null && $requestedSiteId > 0) {
return $requestedSiteId;
}
return (int) (AdminSite::query()->where('is_default', true)->value('id')
?? AdminSite::query()->orderBy('id')->value('id'));
}
$actor = AdminAgentScope::primaryAgentNode($admin);
if ($actor === null) {
return null;
}
if ($requestedSiteId !== null && $requestedSiteId > 0 && $requestedSiteId !== (int) $actor->admin_site_id) {
return null;
}
return (int) $actor->admin_site_id;
}
public static function denyUnlessSiteResolved(AdminUser $admin, ?int $siteId): ?JsonResponse
{
if ($siteId !== null && $siteId > 0) {
return null;
}
return ApiMessage::errorResponse(
request(),
'admin.agent_site_access_denied',
ErrorCode::AdminForbidden->value,
null,
403,
);
}
public static function denyUnlessNodeVisible(AdminUser $admin, AgentNode $node): ?JsonResponse
{
if (AdminAgentScope::nodeVisibleTo($admin, $node)) {
return null;
}
return ApiMessage::errorResponse(
request(),
'admin.agent_node_access_denied',
ErrorCode::AdminForbidden->value,
null,
403,
);
}
public static function denyUnlessCanManageParent(AdminUser $admin, AgentNode $parent): ?JsonResponse
{
if (! AdminAgentScope::nodeManageableBy($admin, $parent)) {
return ApiMessage::errorResponse(
request(),
'admin.agent_node_manage_denied',
ErrorCode::AdminForbidden->value,
null,
403,
);
}
if ($parent->isRoot() && ! $admin->isSuperAdmin()) {
return ApiMessage::errorResponse(
request(),
'admin.agent_root_create_denied',
ErrorCode::AdminForbidden->value,
null,
403,
);
}
return null;
}
}

View File

@@ -0,0 +1,167 @@
<?php
namespace App\Support;
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Models\Player;
use Illuminate\Database\Eloquent\Builder;
/**
* 代理子树数据范围P1节点访问P2 起叠加玩家 agent_node_id
*/
final class AdminAgentScope
{
public static function primaryAgentNode(AdminUser $admin): ?AgentNode
{
if ($admin->isSuperAdmin()) {
return null;
}
$agentId = $admin->primaryAgentNodeId();
if ($agentId === null) {
return null;
}
return AgentNode::query()->find($agentId);
}
public static function nodeVisibleTo(AdminUser $admin, AgentNode $node): bool
{
if ($admin->isSuperAdmin()) {
return true;
}
$actor = self::primaryAgentNode($admin);
if ($actor === null) {
return false;
}
return $node->isSameOrDescendantOf($actor);
}
public static function playerAccessible(AdminUser $admin, Player $player): bool
{
if ($admin->isSuperAdmin()) {
return true;
}
$actor = self::primaryAgentNode($admin);
if ($actor === null) {
return false;
}
if ($player->agent_node_id === null) {
return false;
}
$playerAgent = AgentNode::query()->find((int) $player->agent_node_id);
if ($playerAgent === null) {
return false;
}
return $playerAgent->isSameOrDescendantOf($actor);
}
public static function nodeManageableBy(AdminUser $admin, AgentNode $node): bool
{
if ($admin->isSuperAdmin()) {
return true;
}
if (! $admin->hasAdminPermission('prd.agent.manage')) {
return false;
}
return self::nodeVisibleTo($admin, $node);
}
/**
* @return Builder<AgentNode>
*/
public static function visibleNodesQuery(AdminUser $admin, int $adminSiteId): Builder
{
$query = AgentNode::query()
->where('admin_site_id', $adminSiteId)
->orderBy('path');
if ($admin->isSuperAdmin()) {
return $query;
}
$actor = self::primaryAgentNode($admin);
if ($actor === null || (int) $actor->admin_site_id !== $adminSiteId) {
return $query->whereRaw('0 = 1');
}
return $query->where('path', 'like', $actor->path.'%');
}
/**
* 玩家必须落在当前代理子树agent_node_id 必填,由迁移回填根代理)。
*
* @param Builder<Player> $query
*/
public static function applyToPlayerQuery(Builder $query, AdminUser $admin): void
{
if ($admin->isSuperAdmin()) {
return;
}
$actor = self::primaryAgentNode($admin);
if ($actor === null) {
$query->whereRaw('0 = 1');
return;
}
if (! \Illuminate\Support\Facades\Schema::hasColumn('players', 'agent_node_id')) {
return;
}
$subtreeIds = AgentNode::query()
->where('path', 'like', $actor->path.'%')
->pluck('id')
->all();
if ($subtreeIds === []) {
$query->whereRaw('0 = 1');
return;
}
$query->whereIn('agent_node_id', $subtreeIds);
}
/**
* 在已有站点/代理范围上,再按指定节点子树收窄(超管筛选用)。
*
* @param Builder<Player> $query
*/
public static function applyRequestedAgentNodeFilter(Builder $query, AdminUser $admin, int $agentNodeId): void
{
$node = AgentNode::query()->find($agentNodeId);
if ($node === null || ! self::nodeVisibleTo($admin, $node)) {
$query->whereRaw('0 = 1');
return;
}
if (! \Illuminate\Support\Facades\Schema::hasColumn('players', 'agent_node_id')) {
return;
}
$subtreeIds = AgentNode::query()
->where('path', 'like', $node->path.'%')
->pluck('id')
->all();
if ($subtreeIds === []) {
$query->whereRaw('0 = 1');
return;
}
$query->whereIn('agent_node_id', $subtreeIds);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Support;
use App\Models\AdminUser;
use App\Models\AgentNode;
final class AdminAuthProfile
{
@@ -17,9 +18,21 @@ final class AdminAuthProfile
* segment: string,
* label: string,
* href: string,
* nav_group: string,
* platform_only?: bool,
* activeMatchPrefix?: string,
* requiredAny?: list<string>
* }>
* }>,
* agent: ?array{
* id: int,
* admin_site_id: int,
* path: string,
* code: string,
* name: string,
* depth: int
* },
* is_super_admin: bool,
* delegation_ceiling: list<string>
* }
*/
public static function fromAdmin(AdminUser $admin): array
@@ -33,7 +46,34 @@ final class AdminAuthProfile
'nickname' => $fresh->name,
'email' => $fresh->email,
'permissions' => $permissionSlugs,
'navigation' => AdminAuthorizationRegistry::visibleNavigationItems($permissionSlugs),
'navigation' => AdminAuthorizationRegistry::visibleNavigationItems($permissionSlugs, $fresh),
'agent' => self::agentContext($fresh),
'is_super_admin' => $fresh->isSuperAdmin(),
'delegation_ceiling' => AgentDelegationAuthorization::delegationLegacySlugsForAdminUser($fresh),
];
}
/**
* @return array{id: int, admin_site_id: int, path: string, code: string, name: string, depth: int}|null
*/
private static function agentContext(AdminUser $admin): ?array
{
if ($admin->isSuperAdmin()) {
return null;
}
$node = $admin->primaryAgentNode();
if (! $node instanceof AgentNode) {
return null;
}
return [
'id' => (int) $node->id,
'admin_site_id' => (int) $node->admin_site_id,
'path' => (string) $node->path,
'code' => (string) $node->code,
'name' => (string) $node->name,
'depth' => (int) $node->depth,
];
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Support;
use App\Models\AdminUser;
final class AdminAuthorizationRegistry
{
/**
@@ -25,6 +27,13 @@ final class AdminAuthorizationRegistry
['slug' => 'prd.admin_user.manage', 'name' => '管理员列表·可管理', 'nav_segment' => 'admin_users', 'permission_codes' => ['system.admin_user.manage']],
['slug' => 'prd.admin_role.manage', 'name' => '角色管理·可管理', 'nav_segment' => 'admin_roles', 'permission_codes' => ['system.admin_role.manage']],
['slug' => 'prd.agent.view', 'name' => '代理管理·查看', 'nav_segment' => 'agents', 'permission_codes' => ['agent.node.view']],
['slug' => 'prd.agent.manage', 'name' => '代理管理·可管理', 'nav_segment' => 'agents', 'permission_codes' => ['agent.node.manage']],
['slug' => 'prd.agent.role.view', 'name' => '代理角色·查看', 'nav_segment' => 'agents', 'permission_codes' => ['agent.node.view']],
['slug' => 'prd.agent.role.manage', 'name' => '代理角色·可管理', 'nav_segment' => 'agents', 'permission_codes' => ['agent.node.manage']],
['slug' => 'prd.agent.user.view', 'name' => '代理账号·查看', 'nav_segment' => 'agents', 'permission_codes' => ['agent.node.view']],
['slug' => 'prd.agent.user.manage', 'name' => '代理账号·可管理', 'nav_segment' => 'agents', 'permission_codes' => ['agent.node.manage']],
['slug' => 'prd.users.manage', 'name' => '用户管理·可管理', 'nav_segment' => 'players', 'permission_codes' => ['service.players.manage']],
['slug' => 'prd.users.view_finance', 'name' => '用户管理·财务查看', 'nav_segment' => 'players', 'permission_codes' => ['service.players.view', 'service.wallet.view']],
['slug' => 'prd.users.view_cs', 'name' => '用户管理·客服单用户', 'nav_segment' => 'players', 'permission_codes' => ['service.players.view', 'service.tickets.view']],
@@ -97,6 +106,7 @@ final class AdminAuthorizationRegistry
'audit' => '审计日志',
'settings' => '系统设置',
'integration' => '接入站点',
'agents' => '代理管理',
];
return array_map(
@@ -108,13 +118,20 @@ final class AdminAuthorizationRegistry
);
}
/** 侧栏分组顺序(与 {@see navigationDefinitions} 中 nav_group 一致) */
public const NAV_GROUP_ORDER = ['overview', 'agent', 'operations', 'finance', 'rules', 'platform'];
/**
* 后台菜单注册表。前端侧栏与面包屑都消费这里派生的结果。
*
* platform_only仅超管可见全局 RBAC、接入站点、赔率规则等代理账号走代理控制台与子级授权。
*
* @return list<array{
* segment: string,
* label: string,
* href: string,
* nav_group: string,
* platform_only?: bool,
* activeMatchPrefix?: string,
* requiredAny?: list<string>
* }>
@@ -122,30 +139,26 @@ final class AdminAuthorizationRegistry
public static function navigationDefinitions(): array
{
return [
// 总览
['segment' => 'dashboard', 'label' => 'Dashboard', 'href' => '/admin', 'requiredAny' => ['prd.dashboard.view']],
// 日常运营:开奖 → 注单 → 玩家
['segment' => 'draws', 'label' => 'Draws', 'href' => '/admin/draws', 'requiredAny' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.draw_reopen.manage']],
['segment' => 'tickets', 'label' => 'Tickets', 'href' => '/admin/tickets', 'requiredAny' => ['prd.tickets.view']],
['segment' => 'players', 'label' => 'Players', 'href' => '/admin/players', 'requiredAny' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage']],
// 规则与参数
['segment' => 'rules_plays', 'label' => 'Play rules', 'href' => '/admin/rules/plays', 'requiredAny' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']],
['segment' => 'rules_odds', 'label' => 'Odds & rebate', 'href' => '/admin/rules/odds', 'requiredAny' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view']],
['segment' => 'jackpot', 'label' => 'Jackpot', 'href' => '/admin/jackpot', 'activeMatchPrefix' => '/admin/jackpot', 'requiredAny' => ['prd.jackpot.manage', 'prd.jackpot.view']],
['segment' => 'risk_cap', 'label' => 'Risk cap rules', 'href' => '/admin/risk/cap', 'activeMatchPrefix' => '/admin/risk/cap', 'requiredAny' => ['prd.risk_cap.manage', 'prd.risk_cap.view']],
// 资金
['segment' => 'wallet', 'label' => 'Wallet', 'href' => '/admin/wallet/transactions', 'activeMatchPrefix' => '/admin/wallet', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.manage', 'prd.users.view_finance']],
['segment' => 'settlement', 'label' => 'Settlement', 'href' => '/admin/settlement-batches', 'requiredAny' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']],
['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' => AdminPermissionLanguage::requiredAnyPrdSlugs('integration-sites')],
// 权限与系统
['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']],
['segment' => 'risk', 'label' => 'Risk', 'href' => '/admin/risk', 'requiredAny' => ['prd.risk.view', 'prd.risk.manage']],
['segment' => 'audit', 'label' => 'Audit Logs', 'href' => '/admin/audit-logs', 'requiredAny' => ['prd.audit.view']],
['segment' => 'settings', 'label' => 'Settings', 'href' => '/admin/settings', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.currency.manage']],
['segment' => 'dashboard', 'label' => 'Dashboard', 'href' => '/admin', 'nav_group' => 'overview', 'requiredAny' => ['prd.dashboard.view']],
['segment' => 'agents', 'label' => 'Agents', 'href' => '/admin/agents', 'nav_group' => 'agent', 'activeMatchPrefix' => '/admin/agents', 'requiredAny' => ['prd.agent.view', 'prd.agent.manage', 'prd.agent.role.view', 'prd.agent.role.manage', 'prd.agent.user.view', 'prd.agent.user.manage']],
['segment' => 'draws', 'label' => 'Draws', 'href' => '/admin/draws', 'nav_group' => 'operations', 'requiredAny' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.draw_reopen.manage']],
['segment' => 'tickets', 'label' => 'Tickets', 'href' => '/admin/tickets', 'nav_group' => 'operations', 'requiredAny' => ['prd.tickets.view']],
['segment' => 'players', 'label' => 'Players', 'href' => '/admin/players', 'nav_group' => 'operations', 'requiredAny' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage']],
['segment' => 'settlement', 'label' => 'Settlement', 'href' => '/admin/settlement-batches', 'nav_group' => 'operations', 'requiredAny' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']],
['segment' => 'wallet', 'label' => 'Wallet', 'href' => '/admin/wallet/transactions', 'nav_group' => 'finance', 'activeMatchPrefix' => '/admin/wallet', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.manage', 'prd.users.view_finance']],
['segment' => 'reconcile', 'label' => 'Reconcile', 'href' => '/admin/reconcile', 'nav_group' => 'finance', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
['segment' => 'reports', 'label' => 'Reports', 'href' => '/admin/reports', 'nav_group' => 'finance', 'requiredAny' => ['prd.report.view']],
['segment' => 'rules_plays', 'label' => 'Play rules', 'href' => '/admin/rules/plays', 'nav_group' => 'rules', 'platform_only' => true, 'requiredAny' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']],
['segment' => 'rules_odds', 'label' => 'Odds & rebate', 'href' => '/admin/rules/odds', 'nav_group' => 'rules', 'platform_only' => true, 'requiredAny' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view']],
['segment' => 'jackpot', 'label' => 'Jackpot', 'href' => '/admin/jackpot', 'nav_group' => 'rules', 'platform_only' => true, 'activeMatchPrefix' => '/admin/jackpot', 'requiredAny' => ['prd.jackpot.manage', 'prd.jackpot.view']],
['segment' => 'risk_cap', 'label' => 'Risk cap rules', 'href' => '/admin/risk/cap', 'nav_group' => 'rules', 'platform_only' => true, 'activeMatchPrefix' => '/admin/risk/cap', 'requiredAny' => ['prd.risk_cap.manage', 'prd.risk_cap.view']],
['segment' => 'integration', 'label' => 'Integration sites', 'href' => '/admin/config/integration-sites', 'nav_group' => 'platform', 'platform_only' => true, 'activeMatchPrefix' => '/admin/config/integration-sites', 'requiredAny' => AdminPermissionLanguage::requiredAnyPrdSlugs('integration-sites')],
['segment' => 'currencies', 'label' => 'Currencies', 'href' => '/admin/currencies', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.currency.manage']],
['segment' => 'admin_users', 'label' => 'Admin Users', 'href' => '/admin/admin-users', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.admin_user.manage']],
['segment' => 'admin_roles', 'label' => 'Admin Roles', 'href' => '/admin/admin-roles', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.admin_role.manage']],
['segment' => 'audit', 'label' => 'Audit Logs', 'href' => '/admin/audit-logs', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.audit.view']],
['segment' => 'settings', 'label' => 'Settings', 'href' => '/admin/settings', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.currency.manage']],
['segment' => 'risk', 'label' => 'Risk', 'href' => '/admin/risk', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.risk.view', 'prd.risk.manage']],
];
}
@@ -202,6 +215,7 @@ final class AdminAuthorizationRegistry
'dashboard' => ['prd.dashboard.view'],
'admin_users' => ['prd.admin_user.manage'],
'admin_roles' => ['prd.admin_role.manage'],
'agents' => ['prd.agent.view', 'prd.agent.manage', 'prd.agent.role.view', 'prd.agent.role.manage', 'prd.agent.user.view', 'prd.agent.user.manage'],
'players' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage'],
'currencies' => ['prd.currency.manage'],
'wallet' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.view_finance'],
@@ -254,17 +268,24 @@ final class AdminAuthorizationRegistry
* segment: string,
* label: string,
* href: string,
* nav_group: string,
* platform_only?: bool,
* activeMatchPrefix?: string,
* requiredAny?: list<string>
* }>
*/
public static function visibleNavigationItems(array $permissionSlugs): array
public static function visibleNavigationItems(array $permissionSlugs, ?AdminUser $admin = null): array
{
$granted = array_fill_keys($permissionSlugs, true);
$isSuperAdmin = $admin === null || $admin->isSuperAdmin();
return array_values(array_filter(
self::navigationItems(),
static function (array $item) use ($granted): bool {
static function (array $item) use ($granted, $isSuperAdmin): bool {
if (($item['platform_only'] ?? false) && ! $isSuperAdmin) {
return false;
}
$required = $item['requiredAny'] ?? [];
if ($required === []) {
return true;
@@ -366,6 +387,26 @@ final class AdminAuthorizationRegistry
['code' => 'admin.admin-roles.destroy', 'module_code' => 'system', 'name' => '删除角色', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/admin-roles/{admin_role}', 'route_name' => 'api.v1.admin.admin-roles.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_role.manage']],
['code' => 'admin.admin-roles.permissions.sync', 'module_code' => 'system', 'name' => '角色权限同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/admin-roles/{admin_role}/permissions', 'route_name' => 'api.v1.admin.admin-roles.permissions.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_role.manage']],
['code' => 'admin.agent-nodes.tree', 'module_code' => 'agent', 'name' => '代理树', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/tree', 'route_name' => 'api.v1.admin.agent-nodes.tree', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.node.view', 'agent.node.manage']],
['code' => 'admin.agent-nodes.store', 'module_code' => 'agent', 'name' => '创建下级代理', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/agent-nodes', 'route_name' => 'api.v1.admin.agent-nodes.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']],
['code' => 'admin.agent-nodes.show', 'module_code' => 'agent', 'name' => '代理节点详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}', 'route_name' => 'api.v1.admin.agent-nodes.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.node.view', 'agent.node.manage']],
['code' => 'admin.agent-nodes.update', 'module_code' => 'agent', 'name' => '更新代理节点', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}', 'route_name' => 'api.v1.admin.agent-nodes.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']],
['code' => 'admin.agent-nodes.destroy', 'module_code' => 'agent', 'name' => '删除代理节点', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}', 'route_name' => 'api.v1.admin.agent-nodes.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']],
['code' => 'admin.agent-nodes.children', 'module_code' => 'agent', 'name' => '代理直属下级', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/children', 'route_name' => 'api.v1.admin.agent-nodes.children', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.node.view', 'agent.node.manage']],
['code' => 'admin.agent-roles.index', 'module_code' => 'agent', 'name' => '代理角色列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/roles', 'route_name' => 'api.v1.admin.agent-roles.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.node.view', 'agent.node.manage']],
['code' => 'admin.agent-roles.store', 'module_code' => 'agent', 'name' => '创建代理角色', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/roles', 'route_name' => 'api.v1.admin.agent-roles.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']],
['code' => 'admin.agent-roles.update', 'module_code' => 'agent', 'name' => '更新代理角色', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-roles/{admin_role}', 'route_name' => 'api.v1.admin.agent-roles.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']],
['code' => 'admin.agent-roles.permissions.sync', 'module_code' => 'agent', 'name' => '代理角色权限同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-roles/{admin_role}/permissions', 'route_name' => 'api.v1.admin.agent-roles.permissions.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']],
['code' => 'admin.agent-roles.destroy', 'module_code' => 'agent', 'name' => '删除代理角色', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/agent-roles/{admin_role}', 'route_name' => 'api.v1.admin.agent-roles.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']],
['code' => 'admin.agent-admin-users.index', 'module_code' => 'agent', 'name' => '代理账号列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/admin-users', 'route_name' => 'api.v1.admin.agent-admin-users.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.node.view', 'agent.node.manage']],
['code' => 'admin.agent-admin-users.store', 'module_code' => 'agent', 'name' => '创建代理账号', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/admin-users', 'route_name' => 'api.v1.admin.agent-admin-users.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']],
['code' => 'admin.agent-admin-users.roles.sync', 'module_code' => 'agent', 'name' => '代理账号角色同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-admin-users/{admin_user}/roles', 'route_name' => 'api.v1.admin.agent-admin-users.roles.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']],
['code' => 'admin.agent-delegation-grants.index', 'module_code' => 'agent', 'name' => '代理下放上限查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/delegation-grants', 'route_name' => 'api.v1.admin.agent-delegation-grants.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.node.view', 'agent.node.manage']],
['code' => 'admin.agent-delegation-grants.sync', 'module_code' => 'agent', 'name' => '代理下放上限同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/delegation-grants', 'route_name' => 'api.v1.admin.agent-delegation-grants.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']],
['code' => 'admin.play-types.index', 'module_code' => 'config', 'name' => '玩法类型列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/play-types', 'route_name' => 'api.v1.admin.play-types.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view', 'prd.rebate.manage', 'prd.rebate.view']],
['code' => 'admin.play-types.patch', 'module_code' => 'config', 'name' => '玩法类型切换', 'http_method' => 'PATCH', 'uri_pattern' => '/api/v1/admin/play-types/{play_code}', 'route_name' => 'api.v1.admin.play-types.patch', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage']],
['code' => 'admin.config.play-versions.index', 'module_code' => 'config', 'name' => '玩法版本列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/play-versions', 'route_name' => 'api.v1.admin.config.play-versions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']],
@@ -387,6 +428,7 @@ final class AdminAuthorizationRegistry
['code' => 'admin.config.risk-cap-versions.publish', 'module_code' => 'config', 'name' => '发布封顶版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}/publish', 'route_name' => 'api.v1.admin.config.risk-cap-versions.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.risk_cap.manage']],
['code' => 'admin.config.risk-cap-versions.destroy', 'module_code' => 'config', 'name' => '删除封顶版本', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}', 'route_name' => 'api.v1.admin.config.risk-cap-versions.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.risk_cap.manage']],
['code' => 'admin.settings.index', 'module_code' => 'settings', 'name' => '系统设置列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settings', 'route_name' => 'api.v1.admin.settings.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.rebate.manage', 'prd.rebate.view', 'prd.payout.manage']],
['code' => 'admin.settings.batch-update', 'module_code' => 'settings', 'name' => '系统设置批量更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/settings/batch', 'route_name' => 'api.v1.admin.settings.batch-update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.rebate.manage', 'prd.payout.manage']],
['code' => 'admin.settings.update', 'module_code' => 'settings', 'name' => '系统设置更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/settings/{key}', 'route_name' => 'api.v1.admin.settings.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.rebate.manage', 'prd.payout.manage']],
['code' => 'admin.currencies.index', 'module_code' => 'settings', 'name' => '币种列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/currencies', 'route_name' => 'api.v1.admin.currencies.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.currency.manage']],
['code' => 'admin.currencies.store', 'module_code' => 'settings', 'name' => '创建币种', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/currencies', 'route_name' => 'api.v1.admin.currencies.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.currency.manage']],

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Support;
use App\Models\AdminUser;
use App\Models\AgentNode;
use Illuminate\Database\Query\Builder;
/**
* 站点 + 代理子树:用于报表等 Query Builderplayers 表别名)。
*/
final class AdminDataScope
{
/**
* @param Builder<mixed> $query join `players as {alias}`
*/
public static function applyToPlayersAlias(
Builder $query,
AdminUser $admin,
string $alias = 'p',
?int $requestedAgentNodeId = null,
): void {
if ($admin->isSuperAdmin()) {
if ($requestedAgentNodeId !== null && $requestedAgentNodeId > 0) {
self::applyAgentNodeIdOnAlias($query, $admin, $alias, $requestedAgentNodeId);
}
return;
}
$codes = AdminSiteScope::accessibleSiteCodes($admin);
if ($codes !== null) {
if ($codes === []) {
$query->whereRaw('0 = 1');
return;
}
$query->whereIn($alias.'.site_code', $codes);
}
$actor = AdminAgentScope::primaryAgentNode($admin);
if ($actor === null) {
$query->whereRaw('0 = 1');
return;
}
if (! \Illuminate\Support\Facades\Schema::hasColumn('players', 'agent_node_id')) {
return;
}
$subtreeIds = AgentNode::query()
->where('path', 'like', $actor->path.'%')
->pluck('id')
->all();
if ($subtreeIds === []) {
$query->whereRaw('0 = 1');
return;
}
$query->whereIn($alias.'.agent_node_id', $subtreeIds);
if ($requestedAgentNodeId !== null && $requestedAgentNodeId > 0) {
self::applyAgentNodeIdOnAlias($query, $admin, $alias, $requestedAgentNodeId);
}
}
/**
* @param Builder<mixed> $query
*/
private static function applyAgentNodeIdOnAlias(
Builder $query,
AdminUser $admin,
string $alias,
int $agentNodeId,
): void {
$node = AgentNode::query()->find($agentNodeId);
if ($node === null || ! AdminAgentScope::nodeVisibleTo($admin, $node)) {
$query->whereRaw('0 = 1');
return;
}
$subtreeIds = AgentNode::query()
->where('path', 'like', $node->path.'%')
->pluck('id')
->all();
if ($subtreeIds === []) {
$query->whereRaw('0 = 1');
return;
}
$query->whereIn($alias.'.agent_node_id', $subtreeIds);
}
/**
* Eloquent 模型经 player 关联做站点 + 代理子树过滤。
*
* @param \Illuminate\Database\Eloquent\Builder<mixed> $query
*/
public static function applyEloquentViaPlayer(
\Illuminate\Database\Eloquent\Builder $query,
AdminUser $admin,
string $relation = 'player',
): void {
if ($admin->isSuperAdmin()) {
return;
}
$query->whereHas($relation, static function (Builder $playerQuery) use ($admin): void {
AdminSiteScope::applyToPlayerQuery($playerQuery, $admin);
});
}
/**
* 约束 ticket_orders别名 o仅统计可见玩家。
*
* @param Builder<mixed> $query
*/
public static function applyToTicketOrdersViaPlayer(Builder $query, ?AdminUser $admin, string $orderAlias = 'o'): void
{
if ($admin === null || $admin->isSuperAdmin()) {
return;
}
$query->whereExists(function (Builder $sub) use ($admin, $orderAlias): void {
$sub->from('players as scope_p')
->whereColumn('scope_p.id', $orderAlias.'.player_id');
self::applyToPlayersAlias($sub, $admin, 'scope_p');
});
}
}

View File

@@ -17,6 +17,10 @@ final class AdminRoleApiPresenter
'status' => (int) $role->status,
'is_system' => (bool) $role->is_system,
'sort_order' => (int) $role->sort_order,
'scope_type' => (string) ($role->scope_type ?? AdminRole::SCOPE_SYSTEM),
'owner_agent_id' => $role->owner_agent_id !== null ? (int) $role->owner_agent_id : null,
'delegated_from_role_id' => $role->delegated_from_role_id !== null ? (int) $role->delegated_from_role_id : null,
'is_read_only_template' => $role->isReadOnlyTemplate(),
'permission_slugs' => $role->legacyPermissionSlugs(),
'user_count' => $role->assignedUserCount(),
];

View File

@@ -53,7 +53,11 @@ final class AdminSiteScope
public static function playerAccessible(AdminUser $admin, Player $player): bool
{
return self::siteCodeAllowed($admin, (string) $player->site_code);
if (! self::siteCodeAllowed($admin, (string) $player->site_code)) {
return false;
}
return AdminAgentScope::playerAccessible($admin, $player);
}
/**
@@ -63,6 +67,8 @@ final class AdminSiteScope
{
$codes = self::accessibleSiteCodes($admin);
if ($codes === null) {
AdminAgentScope::applyToPlayerQuery($query, $admin);
return;
}
@@ -73,6 +79,7 @@ final class AdminSiteScope
}
$query->whereIn('site_code', $codes);
AdminAgentScope::applyToPlayerQuery($query, $admin);
}
/**
@@ -80,22 +87,28 @@ final class AdminSiteScope
*
* @param Builder<Player> $query
*/
public static function applyPlayerFilters(Builder $query, AdminUser $admin, ?string $requestedSiteCode): void
{
public static function applyPlayerFilters(
Builder $query,
AdminUser $admin,
?string $requestedSiteCode,
?int $requestedAgentNodeId = null,
): void {
self::applyToPlayerQuery($query, $admin);
$siteCode = is_string($requestedSiteCode) ? trim($requestedSiteCode) : '';
if ($siteCode === '') {
return;
if ($siteCode !== '') {
if (! self::siteCodeAllowed($admin, $siteCode)) {
$query->whereRaw('0 = 1');
return;
}
$query->where('site_code', $siteCode);
}
if (! self::siteCodeAllowed($admin, $siteCode)) {
$query->whereRaw('0 = 1');
return;
if ($requestedAgentNodeId !== null && $requestedAgentNodeId > 0) {
AdminAgentScope::applyRequestedAgentNodeFilter($query, $admin, $requestedAgentNodeId);
}
$query->where('site_code', $siteCode);
}
/**
@@ -103,19 +116,12 @@ final class AdminSiteScope
*/
public static function applyViaPlayerRelation(Builder $query, AdminUser $admin, string $relation = 'player'): void
{
$codes = self::accessibleSiteCodes($admin);
if ($codes === null) {
if ($admin->isSuperAdmin()) {
return;
}
if ($codes === []) {
$query->whereRaw('0 = 1');
return;
}
$query->whereHas($relation, static function (Builder $playerQuery) use ($codes): void {
$playerQuery->whereIn('site_code', $codes);
$query->whereHas($relation, static function (Builder $playerQuery) use ($admin): void {
self::applyToPlayerQuery($playerQuery, $admin);
});
}
@@ -127,22 +133,27 @@ final class AdminSiteScope
AdminUser $admin,
?string $requestedSiteCode,
string $relation = 'player',
?int $requestedAgentNodeId = null,
): void {
self::applyViaPlayerRelation($query, $admin, $relation);
if ($admin->isSuperAdmin()) {
$siteCode = is_string($requestedSiteCode) ? trim($requestedSiteCode) : '';
$agentNodeId = $requestedAgentNodeId !== null && $requestedAgentNodeId > 0
? $requestedAgentNodeId
: null;
$siteCode = is_string($requestedSiteCode) ? trim($requestedSiteCode) : '';
if ($siteCode === '') {
return;
}
if ($siteCode === '' && $agentNodeId === null) {
return;
}
if (! self::siteCodeAllowed($admin, $siteCode)) {
$query->whereRaw('0 = 1');
$query->whereHas($relation, static function (Builder $playerQuery) use ($admin, $siteCode, $agentNodeId): void {
self::applyPlayerFilters($playerQuery, $admin, $siteCode !== '' ? $siteCode : null, $agentNodeId);
});
return;
}
$query->whereHas($relation, static function (Builder $playerQuery) use ($siteCode): void {
$playerQuery->where('site_code', $siteCode);
$query->whereHas($relation, static function (Builder $playerQuery) use ($admin, $requestedSiteCode, $requestedAgentNodeId): void {
self::applyPlayerFilters($playerQuery, $admin, $requestedSiteCode, $requestedAgentNodeId);
});
}

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Support;
use App\Models\AdminUser;
use App\Models\AgentNode;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class AgentDelegationAuthorization
{
/**
* @return list<string> menu_action.permission_code
*/
public static function delegationMenuActionCodesForAgent(AgentNode $agent): array
{
if ($agent->isRoot()) {
return DB::table('admin_menu_actions')->where('status', 1)->pluck('permission_code')->all();
}
return DB::table('agent_delegation_grants as g')
->join('admin_menu_actions as ma', 'ma.id', '=', 'g.menu_action_id')
->where('g.child_agent_id', $agent->id)
->where('ma.status', 1)
->pluck('ma.permission_code')
->all();
}
/**
* @return list<string> prd.*
*/
public static function delegationLegacySlugsForAgent(AgentNode $agent): array
{
$codes = self::delegationMenuActionCodesForAgent($agent);
return AdminPermissionBridge::legacySlugsGrantedByMenuActionCodes($codes);
}
/**
* @return list<string> prd.*
*/
public static function delegationLegacySlugsForAdminUser(AdminUser $admin): array
{
if ($admin->isSuperAdmin()) {
return AdminPermissionBridge::allLegacySlugs();
}
$node = $admin->primaryAgentNode();
if ($node === null) {
return [];
}
return self::delegationLegacySlugsForAgent($node);
}
public static function childIsManageableBy(AdminUser $admin, AgentNode $child): bool
{
if ($admin->isSuperAdmin()) {
return true;
}
if (! AdminAgentScope::nodeVisibleTo($admin, $child)) {
return false;
}
$parentId = $child->parent_id;
if ($parentId === null) {
return false;
}
$actor = AdminAgentScope::primaryAgentNode($admin);
if ($actor === null) {
return false;
}
return (int) $parentId === (int) $actor->id
|| AdminAgentScope::nodeManageableBy($admin, AgentNode::query()->find($parentId) ?? $child);
}
/**
* @param list<array{menu_action_id: int, can_delegate?: bool}> $grants
*/
public static function assertGrantsAllowed(AdminUser $actor, AgentNode $child, array $grants): void
{
if ($actor->isSuperAdmin()) {
return;
}
if (! self::childIsManageableBy($actor, $child)) {
throw ValidationException::withMessages(['child_agent_id' => ['not_manageable']]);
}
$actorCodes = $actor->effectiveMenuActionPermissionCodes();
$actorCodeSet = array_fill_keys($actorCodes, true);
$parent = $child->parent;
if ($parent === null && $child->parent_id !== null) {
$parent = AgentNode::query()->find($child->parent_id);
}
$parentCeiling = $parent !== null
? self::delegationMenuActionCodesForAgent($parent)
: $actorCodes;
if ($parent !== null && ! $parent->isRoot() && $parentCeiling === []) {
$parentCeiling = $actorCodes;
}
$parentCeilingSet = array_fill_keys($parentCeiling, true);
foreach ($grants as $grant) {
$actionId = (int) ($grant['menu_action_id'] ?? 0);
$code = DB::table('admin_menu_actions')->where('id', $actionId)->value('permission_code');
if (! is_string($code) || $code === '') {
throw ValidationException::withMessages(['grants' => ['invalid_menu_action']]);
}
if (! isset($actorCodeSet[$code])) {
throw ValidationException::withMessages(['grants' => ['exceeds_actor: '.$code]]);
}
if ($parent !== null && ! $parent->isRoot() && ! isset($parentCeilingSet[$code])) {
throw ValidationException::withMessages(['grants' => ['exceeds_parent_ceiling: '.$code]]);
}
}
}
/**
* @param list<string> $permissionSlugs
*/
public static function assertRoleSlugsWithinAgentCeiling(
AgentNode $ownerAgent,
array $permissionSlugs,
AdminUser $actor,
): void {
$ceiling = self::delegationLegacySlugsForAgent($ownerAgent);
if ($ceiling === []) {
// 尚未配置下放上限:与 P2 一致,仅校验操作者自身权限
AgentRoleAuthorization::assertSlugsWithinActor($actor, $permissionSlugs);
return;
}
$invalid = array_values(array_diff($permissionSlugs, $ceiling));
if ($invalid !== []) {
throw ValidationException::withMessages([
'permission_slugs' => ['exceeds_delegation_ceiling: '.implode(', ', $invalid)],
]);
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Support;
use App\Models\AgentNode;
/** 列表/详情中嵌入的代理节点摘要字段。 */
final class AgentNodeApiPresenter
{
/**
* @return array{
* agent_node_id: ?int,
* agent_code: ?string,
* agent_name: ?string
* }
*/
public static function embed(?AgentNode $node): array
{
if (! $node instanceof AgentNode) {
return [
'agent_node_id' => null,
'agent_code' => null,
'agent_name' => null,
];
}
return [
'agent_node_id' => (int) $node->id,
'agent_code' => (string) $node->code,
'agent_name' => (string) $node->name,
];
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Support;
use App\Models\AgentNode;
final class AgentNodePresenter
{
/**
* @return array{
* id: int,
* admin_site_id: int,
* parent_id: ?int,
* path: string,
* depth: int,
* code: string,
* name: string,
* status: int,
* is_root: bool
* }
*/
public static function item(AgentNode $node): array
{
return [
'id' => (int) $node->id,
'admin_site_id' => (int) $node->admin_site_id,
'parent_id' => $node->parent_id !== null ? (int) $node->parent_id : null,
'path' => (string) $node->path,
'depth' => (int) $node->depth,
'code' => (string) $node->code,
'name' => (string) $node->name,
'status' => (int) $node->status,
'is_root' => $node->isRoot(),
];
}
/**
* @param iterable<AgentNode> $nodes
* @return list<array<string, mixed>>
*/
public static function tree(iterable $nodes): array
{
$items = [];
$byParent = [];
foreach ($nodes as $node) {
$row = self::item($node);
$row['children'] = [];
$items[(int) $node->id] = $row;
$parentKey = $node->parent_id !== null ? (int) $node->parent_id : 0;
$byParent[$parentKey][] = (int) $node->id;
}
$attach = static function (int $id) use (&$attach, &$items, $byParent): array {
$node = $items[$id];
foreach ($byParent[$id] ?? [] as $childId) {
$node['children'][] = $attach($childId);
}
return $node;
};
$ids = array_keys($items);
$rootIds = [];
foreach ($items as $id => $row) {
$parentId = $row['parent_id'];
if ($parentId === null || ! in_array((int) $parentId, $ids, true)) {
$rootIds[] = (int) $id;
}
}
$roots = [];
foreach ($rootIds as $rootId) {
$roots[] = $attach($rootId);
}
return $roots;
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Support;
use App\Models\AdminRole;
use App\Models\AdminUser;
use App\Models\AgentNode;
use Illuminate\Validation\ValidationException;
final class AgentRoleAuthorization
{
public static function roleVisibleTo(AdminUser $admin, AdminRole $role): bool
{
if ($admin->isSuperAdmin()) {
return true;
}
if ($role->scope_type !== AdminRole::SCOPE_AGENT || $role->owner_agent_id === null) {
return false;
}
$owner = AgentNode::query()->find((int) $role->owner_agent_id);
if ($owner === null) {
return false;
}
return AdminAgentScope::nodeVisibleTo($admin, $owner);
}
public static function roleManageableBy(AdminUser $admin, AdminRole $role): bool
{
if ($role->delegated_from_role_id !== null) {
return false;
}
if (! self::roleVisibleTo($admin, $role)) {
return false;
}
return $admin->isSuperAdmin() || $admin->hasAdminPermission('prd.agent.role.manage');
}
/**
* @param list<string> $permissionSlugs
*/
public static function assertSlugsWithinActor(AdminUser $actor, array $permissionSlugs): void
{
if ($actor->isSuperAdmin()) {
return;
}
$allowed = $actor->adminPermissionSlugs();
$invalid = array_values(array_diff($permissionSlugs, $allowed));
if ($invalid !== []) {
throw ValidationException::withMessages([
'permission_slugs' => ['permission_exceeds_actor: '.implode(', ', $invalid)],
]);
}
}
/**
* @param list<string> $permissionSlugs
*/
public static function assertSlugsForAgentRole(
AdminUser $actor,
AgentNode $ownerAgent,
array $permissionSlugs,
): void {
if ($actor->isSuperAdmin()) {
return;
}
self::assertSlugsWithinActor($actor, $permissionSlugs);
AgentDelegationAuthorization::assertRoleSlugsWithinAgentCeiling($ownerAgent, $permissionSlugs, $actor);
}
public static function denyUnlessRoleManageable(AdminUser $admin, AdminRole $role): ?\Illuminate\Http\JsonResponse
{
if (self::roleManageableBy($admin, $role)) {
return null;
}
return ApiMessage::errorResponse(
request(),
'admin.agent_role_manage_denied',
\App\Lottery\ErrorCode::AdminForbidden->value,
null,
403,
);
}
}

View File

@@ -24,8 +24,13 @@ final class PlayerApiPresenter
'status' => (int) $w->status,
])->values()->all();
$agent = $player->relationLoaded('agentNode')
? $player->agentNode
: ($player->agent_node_id ? $player->agentNode()->first() : null);
return [
'id' => (int) $player->id,
...AgentNodeApiPresenter::embed($agent),
'site_code' => $player->site_code,
'site_player_id' => $player->site_player_id,
'username' => $player->username,

View File

@@ -43,6 +43,10 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->api(prepend: [
NegotiateLotteryLocale::class,
]);
$middleware->convertEmptyStringsToNull([
static fn (Request $request): bool => $request->is('api/v1/admin/settings')
|| $request->is('api/v1/admin/settings/*'),
]);
$middleware->alias([
// 玩家端需登录路由使用;解析 Bearer → Player
'lottery.player' => EnsurePlayerApi::class,

View File

@@ -0,0 +1,106 @@
<?php
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use App\Support\AdminAuthorizationRegistry;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private const RESOURCE_CODE = 'admin.settings.batch-update';
private const CLONE_BINDINGS_FROM = 'admin.settings.update';
public function up(): void
{
$now = Carbon::now();
$resource = collect(AdminAuthorizationRegistry::resources())
->first(fn (array $item): bool => $item['code'] === self::RESOURCE_CODE);
if ($resource === null) {
return;
}
$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();
$sourceResourceId = DB::table('admin_api_resources')
->where('code', self::CLONE_BINDINGS_FROM)
->value('id');
if ($sourceResourceId !== null) {
$bindings = DB::table('admin_api_resource_bindings')
->where('api_resource_id', (int) $sourceResourceId)
->get(['menu_action_id']);
foreach ($bindings as $binding) {
DB::table('admin_api_resource_bindings')->insert([
'api_resource_id' => (int) $resourceId,
'menu_action_id' => (int) $binding->menu_action_id,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
if (Schema::hasTable('admin_role_api_resources')) {
$roleResourceRows = DB::table('admin_role_menu_actions as rma')
->join('admin_api_resource_bindings as arb', 'arb.menu_action_id', '=', 'rma.menu_action_id')
->where('arb.api_resource_id', (int) $resourceId)
->select('rma.role_id')
->distinct()
->get();
foreach ($roleResourceRows as $row) {
DB::table('admin_role_api_resources')->updateOrInsert([
'role_id' => (int) $row->role_id,
'api_resource_id' => (int) $resourceId,
], []);
}
}
}
public function down(): void
{
$resourceId = DB::table('admin_api_resources')->where('code', self::RESOURCE_CODE)->value('id');
if ($resourceId === null) {
return;
}
if (Schema::hasTable('admin_role_api_resources')) {
DB::table('admin_role_api_resources')->where('api_resource_id', (int) $resourceId)->delete();
}
DB::table('admin_api_resource_bindings')->where('api_resource_id', (int) $resourceId)->delete();
DB::table('admin_api_resources')->where('id', (int) $resourceId)->delete();
}
};

View File

@@ -0,0 +1,132 @@
<?php
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('agent_nodes', function (Blueprint $table): void {
$table->id();
$table->foreignId('admin_site_id')->constrained('admin_sites')->cascadeOnDelete();
$table->foreignId('parent_id')->nullable()->constrained('agent_nodes')->nullOnDelete();
$table->string('path', 512);
$table->unsignedSmallInteger('depth')->default(0);
$table->string('code', 64);
$table->string('name', 128);
$table->unsignedTinyInteger('status')->default(1)->comment('1=enabled,0=disabled');
$table->foreignId('created_by')->nullable()->constrained('admin_users')->nullOnDelete();
$table->json('extra_json')->nullable();
$table->timestamps();
$table->unique(['admin_site_id', 'code'], 'uk_agent_nodes_site_code');
$table->index(['admin_site_id', 'parent_id'], 'idx_agent_nodes_site_parent');
$table->index('path', 'idx_agent_nodes_path');
});
Schema::create('admin_user_agents', function (Blueprint $table): void {
$table->foreignId('admin_user_id')->primary()->constrained('admin_users')->cascadeOnDelete();
$table->foreignId('agent_node_id')->constrained('agent_nodes')->cascadeOnDelete();
$table->boolean('is_primary')->default(true);
$table->timestamp('granted_at')->nullable();
});
$this->seedRootAgentNodes();
$this->backfillAdminUserAgents();
}
public function down(): void
{
Schema::dropIfExists('admin_user_agents');
Schema::dropIfExists('agent_nodes');
}
private function seedRootAgentNodes(): void
{
$now = Carbon::now();
$sites = DB::table('admin_sites')->orderBy('id')->get(['id', 'code', 'name']);
foreach ($sites as $site) {
if (DB::table('agent_nodes')->where('admin_site_id', (int) $site->id)->where('depth', 0)->exists()) {
continue;
}
$code = 'root-'.(string) $site->code;
$nodeId = DB::table('agent_nodes')->insertGetId([
'admin_site_id' => (int) $site->id,
'parent_id' => null,
'path' => '/',
'depth' => 0,
'code' => $code,
'name' => (string) $site->name,
'status' => 1,
'created_by' => null,
'extra_json' => null,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('agent_nodes')->where('id', $nodeId)->update([
'path' => '/'.$nodeId.'/',
]);
}
}
private function backfillAdminUserAgents(): void
{
$superRoleId = DB::table('admin_roles')->where('slug', 'super_admin')->value('id');
$now = Carbon::now();
$userIds = DB::table('admin_users')->pluck('id');
foreach ($userIds as $userId) {
$userId = (int) $userId;
if (DB::table('admin_user_agents')->where('admin_user_id', $userId)->exists()) {
continue;
}
if ($superRoleId !== null) {
$isSuper = DB::table('admin_user_site_roles')
->where('admin_user_id', $userId)
->where('role_id', (int) $superRoleId)
->exists();
if ($isSuper) {
continue;
}
}
$siteId = DB::table('admin_user_site_roles')
->where('admin_user_id', $userId)
->orderBy('site_id')
->value('site_id');
if ($siteId === null) {
$siteId = DB::table('admin_sites')->where('is_default', true)->value('id')
?? DB::table('admin_sites')->orderBy('id')->value('id');
}
if ($siteId === null) {
continue;
}
$rootId = DB::table('agent_nodes')
->where('admin_site_id', (int) $siteId)
->where('depth', 0)
->value('id');
if ($rootId === null) {
continue;
}
DB::table('admin_user_agents')->insert([
'admin_user_id' => $userId,
'agent_node_id' => (int) $rootId,
'is_primary' => true,
'granted_at' => $now,
]);
}
}
};

View File

@@ -0,0 +1,195 @@
<?php
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use App\Support\AdminAuthorizationRegistry;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
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;
}
$systemMenuId = DB::table('admin_menus')->where('code', 'system')->value('id');
if ($systemMenuId === null) {
$systemMenuId = DB::table('admin_menus')->insertGetId([
'parent_id' => null,
'menu_type' => 'directory',
'code' => 'system',
'name' => '系统',
'path' => null,
'route_name' => null,
'component' => null,
'icon' => null,
'active_menu_code' => null,
'sort_order' => 90,
'is_visible' => true,
'is_cache' => false,
'is_external' => false,
'status' => 1,
'meta_json' => null,
'created_at' => $now,
'updated_at' => $now,
]);
}
$agentMenuId = DB::table('admin_menus')->where('code', 'system.agents')->value('id');
if ($agentMenuId === null) {
$agentMenuId = DB::table('admin_menus')->insertGetId([
'parent_id' => (int) $systemMenuId,
'menu_type' => 'page',
'code' => 'system.agents',
'name' => '代理管理',
'path' => '/admin/agents',
'route_name' => 'admin.agents',
'component' => 'agents/index',
'icon' => null,
'active_menu_code' => null,
'sort_order' => 25,
'is_visible' => true,
'is_cache' => false,
'is_external' => false,
'status' => 1,
'meta_json' => null,
'created_at' => $now,
'updated_at' => $now,
]);
}
foreach ([
['permission_code' => 'agent.node.view', 'action_id' => (int) $viewActionId, 'name' => '代理节点查看'],
['permission_code' => 'agent.node.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) $agentMenuId,
'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.agent-nodes.'),
));
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();
$permissionCodes = $resource['permission_codes'] ?? [];
foreach ($permissionCodes 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,
]);
}
}
if (! Schema::hasTable('admin_role_api_resources')) {
return;
}
$superRoleId = DB::table('admin_roles')->where('slug', 'super_admin')->value('id');
if ($superRoleId === null) {
return;
}
foreach ($resources as $resource) {
$resourceId = DB::table('admin_api_resources')->where('code', $resource['code'])->value('id');
if ($resourceId === null) {
continue;
}
DB::table('admin_role_api_resources')->updateOrInsert([
'role_id' => (int) $superRoleId,
'api_resource_id' => (int) $resourceId,
], []);
}
$menuActionIdList = DB::table('admin_menu_actions')
->whereIn('permission_code', ['agent.node.view', 'agent.node.manage'])
->pluck('id');
foreach ($menuActionIdList as $menuActionId) {
DB::table('admin_role_menu_actions')->updateOrInsert([
'role_id' => (int) $superRoleId,
'menu_action_id' => (int) $menuActionId,
], []);
}
}
public function down(): void
{
$codes = [
'admin.agent-nodes.tree',
'admin.agent-nodes.store',
'admin.agent-nodes.show',
'admin.agent-nodes.update',
'admin.agent-nodes.children',
];
foreach ($codes as $code) {
$resourceId = DB::table('admin_api_resources')->where('code', $code)->value('id');
if ($resourceId === null) {
continue;
}
if (Schema::hasTable('admin_role_api_resources')) {
DB::table('admin_role_api_resources')->where('api_resource_id', (int) $resourceId)->delete();
}
DB::table('admin_api_resource_bindings')->where('api_resource_id', (int) $resourceId)->delete();
DB::table('admin_api_resources')->where('id', (int) $resourceId)->delete();
}
DB::table('admin_menu_actions')
->whereIn('permission_code', ['agent.node.view', 'agent.node.manage'])
->delete();
}
};

View File

@@ -0,0 +1,78 @@
<?php
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('admin_roles', function (Blueprint $table): void {
$table->foreignId('owner_agent_id')->nullable()->after('sort_order')->constrained('agent_nodes')->nullOnDelete();
$table->foreignId('delegated_from_role_id')->nullable()->after('owner_agent_id')->constrained('admin_roles')->nullOnDelete();
$table->string('scope_type', 16)->default('system')->after('delegated_from_role_id');
});
Schema::create('admin_user_agent_roles', function (Blueprint $table): void {
$table->foreignId('admin_user_id')->constrained('admin_users')->cascadeOnDelete();
$table->foreignId('agent_node_id')->constrained('agent_nodes')->cascadeOnDelete();
$table->foreignId('role_id')->constrained('admin_roles')->cascadeOnDelete();
$table->timestamp('granted_at')->nullable();
$table->primary(['admin_user_id', 'agent_node_id', 'role_id'], 'pk_admin_user_agent_roles');
});
Schema::table('players', function (Blueprint $table): void {
$table->foreignId('agent_node_id')->nullable()->after('site_code')->constrained('agent_nodes')->nullOnDelete();
$table->index(['site_code', 'agent_node_id'], 'idx_players_site_agent');
});
DB::table('admin_roles')->update(['scope_type' => 'system']);
$this->backfillAdminUserAgentRoles();
}
public function down(): void
{
Schema::table('players', function (Blueprint $table): void {
$table->dropIndex('idx_players_site_agent');
$table->dropConstrainedForeignId('agent_node_id');
});
Schema::dropIfExists('admin_user_agent_roles');
Schema::table('admin_roles', function (Blueprint $table): void {
$table->dropConstrainedForeignId('delegated_from_role_id');
$table->dropConstrainedForeignId('owner_agent_id');
$table->dropColumn('scope_type');
});
}
private function backfillAdminUserAgentRoles(): void
{
$now = Carbon::now();
$rows = DB::table('admin_user_site_roles as usr')
->join('admin_user_agents as uaa', 'uaa.admin_user_id', '=', 'usr.admin_user_id')
->join('agent_nodes as an', static function ($join): void {
$join->on('an.id', '=', 'uaa.agent_node_id')
->on('an.admin_site_id', '=', 'usr.site_id');
})
->select(['usr.admin_user_id', 'uaa.agent_node_id', 'usr.role_id', 'usr.granted_at'])
->get();
foreach ($rows as $row) {
DB::table('admin_user_agent_roles')->updateOrInsert(
[
'admin_user_id' => (int) $row->admin_user_id,
'agent_node_id' => (int) $row->agent_node_id,
'role_id' => (int) $row->role_id,
],
[
'granted_at' => $row->granted_at ?? $now,
],
);
}
}
};

View File

@@ -0,0 +1,116 @@
<?php
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use App\Support\AdminAuthorizationRegistry;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
/**
* 代理角色/账号 API 绑定到已有 agent.node.view / agent.node.manage 动作(同菜单下 action 唯一)。
*/
return new class extends Migration
{
public function up(): void
{
$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.agent-roles.')
|| str_starts_with((string) $resource['code'], 'admin.agent-admin-users.'),
));
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,
]);
}
}
if (! Schema::hasTable('admin_role_api_resources')) {
return;
}
$superRoleId = DB::table('admin_roles')->where('slug', 'super_admin')->value('id');
if ($superRoleId === null) {
return;
}
foreach ($resources as $resource) {
$resourceId = DB::table('admin_api_resources')->where('code', $resource['code'])->value('id');
if ($resourceId === null) {
continue;
}
DB::table('admin_role_api_resources')->updateOrInsert([
'role_id' => (int) $superRoleId,
'api_resource_id' => (int) $resourceId,
], []);
}
}
public function down(): void
{
$codes = [
'admin.agent-roles.update',
'admin.agent-roles.destroy',
'admin.agent-roles.permissions.sync',
'admin.agent-roles.index',
'admin.agent-roles.store',
'admin.agent-admin-users.index',
'admin.agent-admin-users.store',
'admin.agent-admin-users.roles.sync',
];
foreach ($codes as $code) {
$resourceId = DB::table('admin_api_resources')->where('code', $code)->value('id');
if ($resourceId === null) {
continue;
}
if (Schema::hasTable('admin_role_api_resources')) {
DB::table('admin_role_api_resources')->where('api_resource_id', (int) $resourceId)->delete();
}
DB::table('admin_api_resource_bindings')->where('api_resource_id', (int) $resourceId)->delete();
DB::table('admin_api_resources')->where('id', (int) $resourceId)->delete();
}
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('agent_delegation_grants', function (Blueprint $table): void {
$table->id();
$table->foreignId('parent_agent_id')->constrained('agent_nodes')->cascadeOnDelete();
$table->foreignId('child_agent_id')->constrained('agent_nodes')->cascadeOnDelete();
$table->foreignId('menu_action_id')->constrained('admin_menu_actions')->cascadeOnDelete();
$table->boolean('can_delegate')->default(false);
$table->foreignId('granted_by')->nullable()->constrained('admin_users')->nullOnDelete();
$table->timestamp('granted_at')->nullable();
$table->timestamps();
$table->unique(['child_agent_id', 'menu_action_id'], 'uk_agent_delegation_child_action');
$table->index(['parent_agent_id', 'child_agent_id'], 'idx_agent_delegation_parent_child');
});
}
public function down(): void
{
Schema::dropIfExists('agent_delegation_grants');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* 将未归属玩家挂到对应主站根代理(与 agent_nodes depth=0 1:1)。
*/
return new class extends Migration
{
public function up(): void
{
if (! \Illuminate\Support\Facades\Schema::hasColumn('players', 'agent_node_id')) {
return;
}
$roots = DB::table('agent_nodes as an')
->join('admin_sites as s', 's.id', '=', 'an.admin_site_id')
->where('an.depth', 0)
->get(['an.id as root_id', 's.code as site_code']);
foreach ($roots as $root) {
DB::table('players')
->where('site_code', (string) $root->site_code)
->whereNull('agent_node_id')
->update(['agent_node_id' => (int) $root->root_id]);
}
}
public function down(): void
{
// 不回滚归属,避免误清空业务绑定。
}
};

View File

@@ -19,6 +19,10 @@ return [
'role_cannot_delete_super_admin' => 'Cannot delete the super admin role.',
'role_builtin_cannot_delete' => 'Built-in roles cannot be deleted.',
'role_has_users_cannot_delete' => 'This role still has assigned admins and cannot be deleted.',
'agent_root_delete_denied' => 'Root agent nodes cannot be deleted.',
'agent_node_has_children_cannot_delete' => 'This agent node has child nodes. Delete children first.',
'agent_node_has_users_cannot_delete' => 'This agent node still has bound admin accounts and cannot be deleted.',
'agent_node_has_roles_cannot_delete' => 'This agent node still has bound roles and cannot be deleted.',
'user_cannot_delete_self' => 'Cannot delete your own account.',
'user_cannot_delete_last_super_admin' => 'Cannot delete the last super admin.',
'super_admin_only_for_roles' => 'Only super admins can manage roles.',

View File

@@ -19,6 +19,10 @@ return [
'role_cannot_delete_super_admin' => 'सुपर एडमिन भूमिका मेटाउन मिल्दैन।',
'role_builtin_cannot_delete' => 'बिल्ट-इन भूमिका मेटाउन मिल्दैन।',
'role_has_users_cannot_delete' => 'यो भूमिकामा अझै एडमिन छ, मेटाउन मिल्दैन।',
'agent_root_delete_denied' => 'रुट एजेन्ट नोड मेटाउन मिल्दैन।',
'agent_node_has_children_cannot_delete' => 'यस एजेन्ट नोडमा चाइल्ड नोडहरू छन्, पहिले तिनीहरू हटाउनुहोस्।',
'agent_node_has_users_cannot_delete' => 'यस एजेन्ट नोडमा अझै एडमिन खाता जोडिएको छ, मेटाउन मिल्दैन।',
'agent_node_has_roles_cannot_delete' => 'यस एजेन्ट नोडमा अझै भूमिका जोडिएको छ, मेटाउन मिल्दैन।',
'user_cannot_delete_self' => 'आफ्नै खाता मेटाउन मिल्दैन।',
'user_cannot_delete_last_super_admin' => 'अन्तिम सुपर एडमिन मेटाउन मिल्दैन।',
'super_admin_only_for_roles' => 'भूमिका व्यवस्थापन केवल सुपर एडमिनले गर्न सक्छ।',

View File

@@ -19,6 +19,10 @@ return [
'role_cannot_delete_super_admin' => '不能删除超级管理员角色。',
'role_builtin_cannot_delete' => '系统内置角色不允许删除。',
'role_has_users_cannot_delete' => '该角色下仍有关联管理员,不能删除。',
'agent_root_delete_denied' => '根节点不允许删除。',
'agent_node_has_children_cannot_delete' => '该代理节点存在下级代理,请先清空下级后再删除。',
'agent_node_has_users_cannot_delete' => '该代理节点下仍有关联账号,不能删除。',
'agent_node_has_roles_cannot_delete' => '该代理节点下仍有关联角色,不能删除。',
'user_cannot_delete_self' => '不能删除当前登录账号。',
'user_cannot_delete_last_super_admin' => '不能删除最后一个超级管理员。',
'super_admin_only_for_roles' => '仅超级管理员可管理角色。',

View File

@@ -33,6 +33,7 @@ Route::prefix('v1')->group(function (): void {
require __DIR__.'/api/v1/admin/jackpot.php';
require __DIR__.'/api/v1/admin/config.php';
require __DIR__.'/api/v1/admin/user.php';
require __DIR__.'/api/v1/admin/agent.php';
require __DIR__.'/api/v1/admin/report.php';
});
});

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeTreeController;
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeShowController;
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeStoreController;
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeUpdateController;
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeDestroyController;
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeChildrenController;
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeRoleIndexController;
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeRoleStoreController;
use App\Http\Controllers\Api\V1\Admin\Agent\AgentRoleUpdateController;
use App\Http\Controllers\Api\V1\Admin\Agent\AgentRolePermissionSyncController;
use App\Http\Controllers\Api\V1\Admin\Agent\AgentRoleDestroyController;
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeAdminUserIndexController;
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeAdminUserStoreController;
use App\Http\Controllers\Api\V1\Admin\Agent\AgentAdminUserRoleSyncController;
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeDelegationGrantIndexController;
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeDelegationGrantSyncController;
Route::middleware('admin.api-resource')
->group(function (): void {
Route::get('agent-nodes/tree', AgentNodeTreeController::class)
->name('api.v1.admin.agent-nodes.tree');
Route::post('agent-nodes', AgentNodeStoreController::class)
->name('api.v1.admin.agent-nodes.store');
Route::get('agent-nodes/{agent_node}/roles', AgentNodeRoleIndexController::class)
->name('api.v1.admin.agent-roles.index');
Route::post('agent-nodes/{agent_node}/roles', AgentNodeRoleStoreController::class)
->name('api.v1.admin.agent-roles.store');
Route::get('agent-nodes/{agent_node}/admin-users', AgentNodeAdminUserIndexController::class)
->name('api.v1.admin.agent-admin-users.index');
Route::post('agent-nodes/{agent_node}/admin-users', AgentNodeAdminUserStoreController::class)
->name('api.v1.admin.agent-admin-users.store');
Route::get('agent-nodes/{agent_node}/delegation-grants', AgentNodeDelegationGrantIndexController::class)
->name('api.v1.admin.agent-delegation-grants.index');
Route::put('agent-nodes/{agent_node}/delegation-grants', AgentNodeDelegationGrantSyncController::class)
->name('api.v1.admin.agent-delegation-grants.sync');
Route::get('agent-nodes/{agent_node}', AgentNodeShowController::class)
->name('api.v1.admin.agent-nodes.show');
Route::put('agent-nodes/{agent_node}', AgentNodeUpdateController::class)
->name('api.v1.admin.agent-nodes.update');
Route::delete('agent-nodes/{agent_node}', AgentNodeDestroyController::class)
->name('api.v1.admin.agent-nodes.destroy');
Route::get('agent-nodes/{agent_node}/children', AgentNodeChildrenController::class)
->name('api.v1.admin.agent-nodes.children');
Route::put('agent-roles/{admin_role}', AgentRoleUpdateController::class)
->name('api.v1.admin.agent-roles.update');
Route::put('agent-roles/{admin_role}/permissions', AgentRolePermissionSyncController::class)
->name('api.v1.admin.agent-roles.permissions.sync');
Route::delete('agent-roles/{admin_role}', AgentRoleDestroyController::class)
->name('api.v1.admin.agent-roles.destroy');
Route::put('agent-admin-users/{admin_user}/roles', AgentAdminUserRoleSyncController::class)
->name('api.v1.admin.agent-admin-users.roles.sync');
});

View File

@@ -126,6 +126,8 @@ Route::middleware('admin.api-resource')
->group(function (): void {
Route::get('/', AdminSettingController::class.'@index')
->name('index');
Route::put('batch', AdminSettingController::class.'@batchUpdate')
->name('batch-update');
Route::put('{key}', AdminSettingController::class.'@update')
->name('update');
});

View File

@@ -0,0 +1,192 @@
<?php
use App\Models\AdminUser;
use App\Models\AgentNode;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
function grantDelegationAgentOperator(AdminUser $admin, AgentNode $agent, bool $manage = true): void
{
$now = now();
$roleId = DB::table('admin_roles')->insertGetId([
'slug' => 'agent_ops_'.$admin->id,
'code' => 'agent_ops_'.$admin->id,
'name' => 'Agent Ops',
'description' => null,
'status' => 1,
'is_system' => false,
'sort_order' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
$codes = $manage
? ['agent.node.view', 'agent.node.manage']
: ['agent.node.view'];
$actionIds = DB::table('admin_menu_actions')
->whereIn('permission_code', $codes)
->pluck('id');
foreach ($actionIds as $actionId) {
DB::table('admin_role_menu_actions')->insert([
'role_id' => $roleId,
'menu_action_id' => (int) $actionId,
]);
}
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $admin->id,
'site_id' => (int) $agent->admin_site_id,
'role_id' => $roleId,
'granted_at' => $now,
]);
DB::table('admin_user_agents')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => (int) $agent->id,
'is_primary' => true,
'granted_at' => $now,
]);
}
test('parent agent can sync delegation grants for direct child', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$service = app(\App\Services\Agent\AgentNodeService::class);
$super = AdminUser::query()->create([
'username' => 'deleg_super',
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$parent = $service->createChild($super, [
'parent_id' => $rootId,
'code' => 'deleg-parent',
'name' => 'Deleg Parent',
]);
$child = $service->createChild($super, [
'parent_id' => $parent->id,
'code' => 'deleg-child',
'name' => 'Deleg Child',
]);
$parentAdmin = AdminUser::query()->create([
'username' => 'deleg_parent_admin',
'name' => 'Parent Admin',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantDelegationAgentOperator($parentAdmin, $parent, manage: true);
$viewActionId = (int) DB::table('admin_menu_actions')
->where('permission_code', 'agent.node.view')
->value('id');
$token = $parentAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/agent-nodes/'.$child->id.'/delegation-grants', [
'grants' => [
['menu_action_id' => $viewActionId, 'can_delegate' => true],
],
])
->assertOk()
->assertJsonPath('data.child_agent_id', $child->id)
->assertJsonCount(1, 'data.grants');
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/agent-nodes/'.$child->id.'/delegation-grants')
->assertOk()
->assertJsonPath('data.grants.0.can_delegate', true);
});
test('delegation ceiling blocks role permissions beyond child grants', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$service = app(\App\Services\Agent\AgentNodeService::class);
$super = AdminUser::query()->create([
'username' => 'ceil_super',
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$branch = $service->createChild($super, [
'parent_id' => $rootId,
'code' => 'ceil-branch',
'name' => 'Ceil Branch',
]);
$viewActionId = (int) DB::table('admin_menu_actions')
->where('permission_code', 'agent.node.view')
->value('id');
DB::table('agent_delegation_grants')->insert([
'parent_agent_id' => $rootId,
'child_agent_id' => $branch->id,
'menu_action_id' => $viewActionId,
'can_delegate' => true,
'granted_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$operator = AdminUser::query()->create([
'username' => 'ceil_ops',
'name' => 'Ops',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantDelegationAgentOperator($operator, $branch, manage: true);
$token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/agent-nodes/'.$branch->id.'/roles', [
'slug' => 'ceil_ok',
'name' => 'OK',
'permission_slugs' => ['prd.agent.view'],
])
->assertOk();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/agent-nodes/'.$branch->id.'/roles', [
'slug' => 'ceil_bad',
'name' => 'Bad',
'permission_slugs' => ['prd.dashboard.view'],
])
->assertStatus(422);
});
test('auth me includes delegation ceiling for agent user', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$root = AgentNode::query()->where('admin_site_id', $siteId)->where('depth', 0)->firstOrFail();
$admin = AdminUser::query()->create([
'username' => 'me_deleg',
'name' => 'Me',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantDelegationAgentOperator($admin, $root, manage: false);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/auth/me')
->assertOk()
->assertJsonStructure(['data' => ['admin' => ['delegation_ceiling']]]);
});

View File

@@ -0,0 +1,280 @@
<?php
use App\Models\AdminUser;
use App\Models\AgentNode;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
function agentRootNodeId(int $siteId): int
{
return (int) DB::table('agent_nodes')
->where('admin_site_id', $siteId)
->where('depth', 0)
->value('id');
}
function grantAgentOperatorRole(AdminUser $admin, AgentNode $agent, bool $manage = true): void
{
$now = now();
$roleId = DB::table('admin_roles')->insertGetId([
'slug' => 'agent_ops_'.$admin->id,
'code' => 'agent_ops_'.$admin->id,
'name' => 'Agent Ops',
'description' => null,
'status' => 1,
'is_system' => false,
'sort_order' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
$codes = $manage
? ['agent.node.view', 'agent.node.manage']
: ['agent.node.view'];
$actionIds = DB::table('admin_menu_actions')
->whereIn('permission_code', $codes)
->pluck('id');
foreach ($actionIds as $actionId) {
DB::table('admin_role_menu_actions')->insert([
'role_id' => $roleId,
'menu_action_id' => (int) $actionId,
]);
}
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $admin->id,
'site_id' => (int) $agent->admin_site_id,
'role_id' => $roleId,
'granted_at' => $now,
]);
DB::table('admin_user_agents')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => (int) $agent->id,
'is_primary' => true,
'granted_at' => $now,
]);
}
test('each admin site has exactly one root agent node after migration', function (): void {
$siteIds = DB::table('admin_sites')->pluck('id');
foreach ($siteIds as $siteId) {
expect(
DB::table('agent_nodes')->where('admin_site_id', (int) $siteId)->where('depth', 0)->count()
)->toBe(1);
}
});
test('super admin can load full agent tree', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$admin = AdminUser::query()->create([
'username' => 'agent_super',
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/agent-nodes/tree?admin_site_id='.$siteId)
->assertOk()
->assertJsonPath('code', 0)
->assertJsonStructure(['data' => ['admin_site_id', 'tree']]);
});
test('agent operator only sees own subtree', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = agentRootNodeId($siteId);
$service = app(\App\Services\Agent\AgentNodeService::class);
$super = AdminUser::query()->create([
'username' => 'bootstrap',
'name' => 'Bootstrap',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$nodeA = $service->createChild($super, [
'parent_id' => $rootId,
'code' => 'branch-a',
'name' => 'Branch A',
'status' => 1,
]);
$service->createChild($super, [
'parent_id' => $rootId,
'code' => 'branch-b',
'name' => 'Branch B',
'status' => 1,
]);
$operator = AdminUser::query()->create([
'username' => 'agent_a_ops',
'name' => 'A Ops',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantAgentOperatorRole($operator, $nodeA);
$token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken;
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/agent-nodes/tree');
$response->assertOk();
$tree = $response->json('data.tree');
expect($tree)->toBeArray()->and(count($tree))->toBe(1);
expect($tree[0]['code'])->toBe('branch-a');
expect(collect($tree)->pluck('code'))->not->toContain('branch-b');
});
test('agent operator can create child under own node but not under sibling', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = agentRootNodeId($siteId);
$service = app(\App\Services\Agent\AgentNodeService::class);
$super = AdminUser::query()->create([
'username' => 'bootstrap2',
'name' => 'Bootstrap',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$nodeA = $service->createChild($super, [
'parent_id' => $rootId,
'code' => 'branch-a2',
'name' => 'Branch A',
]);
$nodeB = $service->createChild($super, [
'parent_id' => $rootId,
'code' => 'branch-b2',
'name' => 'Branch B',
]);
$operator = AdminUser::query()->create([
'username' => 'agent_a2_ops',
'name' => 'A Ops',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantAgentOperatorRole($operator, $nodeA);
$token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/agent-nodes', [
'parent_id' => $nodeA->id,
'code' => 'a-child',
'name' => 'A Child',
])
->assertOk()
->assertJsonPath('data.code', 'a-child');
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/agent-nodes', [
'parent_id' => $nodeB->id,
'code' => 'hack-child',
'name' => 'Hack',
])
->assertForbidden();
});
test('auth me returns agent context for bound operator', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = agentRootNodeId($siteId);
$service = app(\App\Services\Agent\AgentNodeService::class);
$super = AdminUser::query()->create([
'username' => 'bootstrap3',
'name' => 'Bootstrap',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$node = $service->createChild($super, [
'parent_id' => $rootId,
'code' => 'me-branch',
'name' => 'Me Branch',
]);
$operator = AdminUser::query()->create([
'username' => 'me_agent_ops',
'name' => 'Ops',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantAgentOperatorRole($operator, $node);
$token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/auth/me')
->assertOk()
->assertJsonPath('data.admin.agent.code', 'me-branch')
->assertJsonPath('data.admin.is_super_admin', false);
});
test('agent node deletion requires leaf node without bindings', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = agentRootNodeId($siteId);
$service = app(\App\Services\Agent\AgentNodeService::class);
$super = AdminUser::query()->create([
'username' => 'delete_agent_super',
'name' => 'Delete Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken;
$nodeWithChild = $service->createChild($super, [
'parent_id' => $rootId,
'code' => 'delete-parent',
'name' => 'Delete Parent',
]);
$service->createChild($super, [
'parent_id' => $nodeWithChild->id,
'code' => 'delete-child',
'name' => 'Delete Child',
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->deleteJson('/api/v1/admin/agent-nodes/'.$rootId)
->assertStatus(422)
->assertJsonPath('msg', __('admin.agent_root_delete_denied'));
$this->withHeader('Authorization', 'Bearer '.$token)
->deleteJson('/api/v1/admin/agent-nodes/'.$nodeWithChild->id)
->assertStatus(422)
->assertJsonPath('msg', __('admin.agent_node_has_children_cannot_delete'));
$leaf = $service->createChild($super, [
'parent_id' => $rootId,
'code' => 'delete-leaf',
'name' => 'Delete Leaf',
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->deleteJson('/api/v1/admin/agent-nodes/'.$leaf->id)
->assertOk()
->assertJsonPath('code', 0);
expect(AgentNode::query()->find($leaf->id))->toBeNull();
});

View File

@@ -0,0 +1,205 @@
<?php
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Models\Player;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
function grantAgentRoleManager(AdminUser $admin, AgentNode $agent): void
{
$now = now();
$roleId = DB::table('admin_roles')->insertGetId([
'slug' => 'agent_mgr_'.$admin->id,
'code' => 'agent_mgr_'.$admin->id,
'name' => 'Agent Manager',
'scope_type' => 'system',
'status' => 1,
'is_system' => false,
'sort_order' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
$codes = ['agent.node.view', 'agent.node.manage', 'service.players.view'];
$actionIds = DB::table('admin_menu_actions')->whereIn('permission_code', $codes)->pluck('id');
foreach ($actionIds as $actionId) {
DB::table('admin_role_menu_actions')->insert([
'role_id' => $roleId,
'menu_action_id' => (int) $actionId,
]);
}
$siteId = (int) $agent->admin_site_id;
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $admin->id,
'site_id' => $siteId,
'role_id' => $roleId,
'granted_at' => $now,
]);
DB::table('admin_user_agents')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => $agent->id,
'is_primary' => true,
'granted_at' => $now,
]);
DB::table('admin_user_agent_roles')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => $agent->id,
'role_id' => $roleId,
'granted_at' => $now,
]);
}
test('agent operator can create role with subset of own permissions', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$service = app(\App\Services\Agent\AgentNodeService::class);
$super = AdminUser::query()->create([
'username' => 'super_role',
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$branch = $service->createChild($super, [
'parent_id' => $rootId,
'code' => 'role-branch',
'name' => 'Role Branch',
]);
$operator = AdminUser::query()->create([
'username' => 'role_ops',
'name' => 'Ops',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantAgentRoleManager($operator, $branch);
$token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/agent-nodes/'.$branch->id.'/roles', [
'slug' => 'branch_cs',
'name' => 'Branch CS',
'permission_slugs' => ['prd.agent.view', 'prd.agent.role.view'],
])
->assertOk()
->assertJsonPath('data.scope_type', 'agent')
->assertJsonPath('data.owner_agent_id', $branch->id);
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/agent-nodes/'.$branch->id.'/roles', [
'slug' => 'branch_hack',
'name' => 'Hack',
'permission_slugs' => ['prd.dashboard.view'],
])
->assertStatus(422);
});
test('agent scoped roles are excluded from global admin roles index', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$service = app(\App\Services\Agent\AgentNodeService::class);
$super = AdminUser::query()->create([
'username' => 'super_idx',
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken;
$branch = $service->createChild($super, [
'parent_id' => $rootId,
'code' => 'idx-branch',
'name' => 'Idx Branch',
]);
DB::table('admin_roles')->insert([
'slug' => 'idx_agent_role',
'code' => 'idx_agent_role',
'name' => 'Idx Agent Role',
'scope_type' => 'agent',
'owner_agent_id' => $branch->id,
'status' => 1,
'is_system' => false,
'sort_order' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$slugs = collect($this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/admin-roles')
->json('data.items'))
->pluck('slug');
expect($slugs)->not->toContain('idx_agent_role');
});
test('players list respects agent subtree when agent_node_id is set', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$service = app(\App\Services\Agent\AgentNodeService::class);
$super = AdminUser::query()->create([
'username' => 'super_player',
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$branchA = $service->createChild($super, ['parent_id' => $rootId, 'code' => 'pa', 'name' => 'A']);
$branchB = $service->createChild($super, ['parent_id' => $rootId, 'code' => 'pb', 'name' => 'B']);
Player::query()->create([
'site_code' => $siteCode,
'agent_node_id' => $branchA->id,
'site_player_id' => 'p-a',
'username' => 'pa',
'nickname' => 'PA',
'default_currency' => 'NPR',
'status' => 0,
]);
Player::query()->create([
'site_code' => $siteCode,
'agent_node_id' => $branchB->id,
'site_player_id' => 'p-b',
'username' => 'pb',
'nickname' => 'PB',
'default_currency' => 'NPR',
'status' => 0,
]);
$operator = AdminUser::query()->create([
'username' => 'player_ops',
'name' => 'Ops',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantAgentRoleManager($operator, $branchA);
$token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken;
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/players?site_code='.$siteCode);
$response->assertOk();
$usernames = collect($response->json('data.items'))->pluck('username');
expect($usernames)->toContain('pa')->not->toContain('pb');
});

View File

@@ -91,7 +91,10 @@ test('admin login returns bearer token when captcha passes validation', function
->assertJsonPath('data.admin.nickname', '测试昵称')
->assertJsonPath('data.admin.navigation.0.segment', 'dashboard')
->assertJsonPath('data.admin.navigation.0.href', '/admin')
->assertJsonPath('data.admin.navigation.1.segment', 'draws')
->assertJsonPath('data.admin.navigation.0.nav_group', 'overview')
->assertJsonPath('data.admin.navigation.1.segment', 'agents')
->assertJsonPath('data.admin.navigation.1.nav_group', 'agent')
->assertJsonPath('data.admin.navigation.2.segment', 'draws')
->assertJsonStructure(['data' => ['token', 'token_type', 'admin' => ['id', 'username', 'nickname', 'email', 'permissions', 'navigation']]]);
$token = $resp->json('data.token');
@@ -103,6 +106,67 @@ test('admin login returns bearer token when captcha passes validation', function
->assertJsonPath('data.scope', 'admin');
});
test('agent operator auth me omits platform-only navigation', function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$admin = AdminUser::query()->create([
'username' => 'agent_nav_ops',
'name' => 'Agent Nav',
'email' => null,
'password' => 'secret-strong',
'status' => 0,
]);
$now = now();
$roleId = DB::table('admin_roles')->insertGetId([
'slug' => 'agent_nav_ops_role',
'code' => 'agent_nav_ops_role',
'name' => 'Agent Nav Ops',
'description' => null,
'status' => 1,
'is_system' => false,
'sort_order' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
$codes = ['agent.node.view', 'service.report.view'];
$actionIds = DB::table('admin_menu_actions')->whereIn('permission_code', $codes)->pluck('id');
foreach ($actionIds as $actionId) {
DB::table('admin_role_menu_actions')->insert([
'role_id' => $roleId,
'menu_action_id' => (int) $actionId,
]);
}
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $admin->id,
'site_id' => $siteId,
'role_id' => $roleId,
'granted_at' => $now,
]);
DB::table('admin_user_agents')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => $rootId,
'is_primary' => true,
'granted_at' => $now,
]);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$segments = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/auth/me')
->assertOk()
->json('data.admin.navigation');
$keys = array_column($segments, 'segment');
expect($keys)->toContain('agents', 'reports')
->and($keys)->not->toContain('admin_users', 'admin_roles', 'settings', 'integration', 'rules_plays');
});
test('admin captcha exposes key and image base64', function () {
$resp = $this->getJson('/api/v1/admin/auth/captcha');

View File

@@ -0,0 +1,149 @@
<?php
use App\Models\AdminUser;
use App\Models\Player;
use App\Models\TransferOrder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
test('orphan players can be backfilled to site root agent', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$playerId = Player::query()->create([
'site_code' => $siteCode,
'site_player_id' => 'orphan-1',
'username' => 'orphan',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
'agent_node_id' => null,
])->id;
DB::table('players')
->where('site_code', $siteCode)
->whereNull('agent_node_id')
->update(['agent_node_id' => $rootId]);
expect((int) Player::query()->find($playerId)?->agent_node_id)->toBe($rootId);
});
test('transfer list excludes players outside agent subtree', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$service = app(\App\Services\Agent\AgentNodeService::class);
$super = AdminUser::query()->create([
'username' => 'bind_super',
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$branch = $service->createChild($super, ['parent_id' => $rootId, 'code' => 'bind-a', 'name' => 'A']);
$other = $service->createChild($super, ['parent_id' => $rootId, 'code' => 'bind-b', 'name' => 'B']);
$inPlayer = Player::query()->create([
'site_code' => $siteCode,
'agent_node_id' => $branch->id,
'site_player_id' => 'bind-in',
'username' => 'bind_in',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$outPlayer = Player::query()->create([
'site_code' => $siteCode,
'agent_node_id' => $other->id,
'site_player_id' => 'bind-out',
'username' => 'bind_out',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
foreach ([$inPlayer, $outPlayer] as $index => $player) {
TransferOrder::query()->create([
'transfer_no' => 'TR-BIND-'.$index,
'player_id' => $player->id,
'direction' => 'in',
'currency_code' => 'NPR',
'amount' => 100,
'status' => 'completed',
'idempotent_key' => 'idem-bind-'.$index,
'external_ref_no' => 'ext-'.$index,
'fail_reason' => null,
'created_at' => now(),
'updated_at' => now(),
'finished_at' => now(),
]);
}
$operator = AdminUser::query()->create([
'username' => 'bind_ops',
'name' => 'Ops',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantAgentOpsForBinding($operator, $branch);
$token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken;
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/wallet/transfer-orders?site_code='.$siteCode);
$response->assertOk();
$playerIds = collect($response->json('data.items'))->pluck('player_id')->map(static fn ($id): int => (int) $id);
expect($playerIds)->toContain($inPlayer->id)->not->toContain($outPlayer->id);
});
function grantAgentOpsForBinding(AdminUser $admin, \App\Models\AgentNode $agent): void
{
$now = now();
$roleId = DB::table('admin_roles')->insertGetId([
'slug' => 'bind_ops_'.$admin->id,
'code' => 'bind_ops_'.$admin->id,
'name' => 'Bind Ops',
'scope_type' => 'system',
'status' => 1,
'is_system' => false,
'sort_order' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
foreach (['agent.node.view', 'service.wallet.view', 'service.reconcile.view'] as $code) {
$actionId = DB::table('admin_menu_actions')->where('permission_code', $code)->value('id');
if ($actionId) {
DB::table('admin_role_menu_actions')->insert([
'role_id' => $roleId,
'menu_action_id' => (int) $actionId,
]);
}
}
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $admin->id,
'site_id' => (int) $agent->admin_site_id,
'role_id' => $roleId,
'granted_at' => $now,
]);
DB::table('admin_user_agents')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => (int) $agent->id,
'is_primary' => true,
'granted_at' => $now,
]);
}

View File

@@ -0,0 +1,184 @@
<?php
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Models\Draw;
use App\Models\Player;
use App\Models\TicketItem;
use App\Models\TicketOrder;
use App\Lottery\DrawStatus;
use App\Services\Admin\AdminReportQueryService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
test('player win loss report excludes players outside agent subtree', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$service = app(\App\Services\Agent\AgentNodeService::class);
$super = AdminUser::query()->create([
'username' => 'rpt_super',
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$branch = $service->createChild($super, [
'parent_id' => $rootId,
'code' => 'rpt-branch',
'name' => 'Report Branch',
]);
$otherBranch = $service->createChild($super, [
'parent_id' => $rootId,
'code' => 'rpt-other',
'name' => 'Other Branch',
]);
$today = now()->toDateString();
$draw = Draw::query()->create([
'draw_no' => 'RPT-'.now()->format('YmdHis'),
'business_date' => $today,
'sequence_no' => 901,
'status' => DrawStatus::Open->value,
'start_time' => now()->subMinute(),
'close_time' => now()->addHour(),
'draw_time' => now()->addHours(2),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$inScope = Player::query()->create([
'site_code' => $siteCode,
'site_player_id' => 'rpt-in',
'username' => 'rpt_in',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
'agent_node_id' => $branch->id,
]);
$outScope = Player::query()->create([
'site_code' => $siteCode,
'site_player_id' => 'rpt-out',
'username' => 'rpt_out',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
'agent_node_id' => $otherBranch->id,
]);
foreach ([$inScope, $outScope] as $index => $player) {
$order = TicketOrder::query()->create([
'order_no' => 'RPT-ORD-'.$index,
'player_id' => $player->id,
'draw_id' => $draw->id,
'currency_code' => 'NPR',
'total_bet_amount' => 1000,
'total_rebate_amount' => 0,
'total_actual_deduct' => 1000,
'total_estimated_payout' => 0,
'status' => 'placed',
'submit_source' => 'h5',
'client_trace_id' => 'rpt-trace-'.$index,
'created_at' => now(),
'updated_at' => now(),
]);
TicketItem::query()->create([
'ticket_no' => 'RPT-TK-'.$index,
'order_id' => $order->id,
'player_id' => $player->id,
'draw_id' => $draw->id,
'original_number' => '1234',
'normalized_number' => '1234',
'play_code' => 'big',
'dimension' => 4,
'digit_slot' => null,
'bet_mode' => 'single',
'unit_bet_amount' => 1000,
'total_bet_amount' => 1000,
'rebate_rate_snapshot' => '0.0000',
'commission_rate_snapshot' => '0.0000',
'actual_deduct_amount' => 1000,
'win_amount' => 0,
'jackpot_win_amount' => 0,
'status' => 'placed',
]);
}
$operator = AdminUser::query()->create([
'username' => 'rpt_ops',
'name' => 'Ops',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantReportAgentManager($operator, $branch);
$query = app(AdminReportQueryService::class);
$scoped = $query->playerWinLossPaginated(null, $today, $today, 1, 20, $operator);
$all = $query->playerWinLossPaginated(null, $today, $today, 1, 20, null);
$scopedUsernames = collect($scoped->items())->pluck('username')->all();
$allUsernames = collect($all->items())->pluck('username')->all();
expect($scopedUsernames)->toContain('rpt_in')->not->toContain('rpt_out')
->and($allUsernames)->toContain('rpt_in', 'rpt_out');
});
function grantReportAgentManager(AdminUser $admin, AgentNode $agent): void
{
$now = now();
$roleId = DB::table('admin_roles')->insertGetId([
'slug' => 'rpt_mgr_'.$admin->id,
'code' => 'rpt_mgr_'.$admin->id,
'name' => 'Report Manager',
'scope_type' => 'system',
'status' => 1,
'is_system' => false,
'sort_order' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
$codes = ['agent.node.view', 'agent.node.manage', 'service.players.view', 'service.report.view'];
$actionIds = DB::table('admin_menu_actions')->whereIn('permission_code', $codes)->pluck('id');
foreach ($actionIds as $actionId) {
DB::table('admin_role_menu_actions')->insert([
'role_id' => $roleId,
'menu_action_id' => (int) $actionId,
]);
}
$siteId = (int) $agent->admin_site_id;
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $admin->id,
'site_id' => $siteId,
'role_id' => $roleId,
'granted_at' => $now,
]);
DB::table('admin_user_agents')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => (int) $agent->id,
'is_primary' => true,
'granted_at' => $now,
]);
DB::table('admin_user_agent_roles')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => (int) $agent->id,
'role_id' => $roleId,
'granted_at' => $now,
]);
}

View File

@@ -0,0 +1,103 @@
<?php
use App\Models\AdminRole;
use App\Models\AdminUser;
use App\Models\LotterySetting;
use App\Services\LotterySettings;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function settingsAdminToken(): string
{
$admin = AdminUser::query()->create([
'username' => 'settings_editor',
'name' => 'Settings Editor',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$role = AdminRole::query()->create([
'slug' => 'settings_role',
'name' => 'Settings Role',
]);
$role->syncLegacyPermissionSlugs(['prd.payout.manage']);
$admin->roles()->sync([
(int) $role->id => [
'site_id' => AdminUser::defaultAdminSiteId(),
'granted_at' => now(),
],
]);
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
test('admin can batch update settings in one request', function (): void {
LotterySettings::put('draw.interval_minutes', 5, 'draw');
LotterySettings::put('draw.cooldown_minutes', 15, 'draw');
$token = settingsAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/settings/batch', [
'items' => [
['key' => 'draw.interval_minutes', 'value' => 10],
['key' => 'draw.cooldown_minutes', 'value' => 20],
],
])
->assertOk()
->assertJsonPath('data.items.0.key', 'draw.cooldown_minutes');
expect(LotterySetting::query()->where('setting_key', 'draw.interval_minutes')->value('value_json'))->toBe(10)
->and(LotterySetting::query()->where('setting_key', 'draw.cooldown_minutes')->value('value_json'))->toBe(20);
});
test('admin settings batch update rejects empty items', function (): void {
$token = settingsAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/settings/batch', ['items' => []])
->assertUnprocessable();
});
test('admin can batch update settings with false and empty string values', function (): void {
LotterySettings::put('settlement.auto_run_on_tick', true, 'settlement');
LotterySettings::put('frontend.play_rules_html_zh', '<div>old</div>', 'frontend');
$token = settingsAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/settings/batch', [
'items' => [
['key' => 'settlement.auto_run_on_tick', 'value' => false],
['key' => 'frontend.play_rules_html_zh', 'value' => ''],
],
])
->assertOk()
->assertJsonPath('data.items.0.key', 'frontend.play_rules_html_zh')
->assertJsonPath('data.items.0.value', '')
->assertJsonPath('data.items.1.key', 'settlement.auto_run_on_tick')
->assertJsonPath('data.items.1.value', false);
expect(LotterySetting::query()->where('setting_key', 'settlement.auto_run_on_tick')->value('value_json'))->toBeFalse()
->and(LotterySetting::query()->where('setting_key', 'frontend.play_rules_html_zh')->value('value_json'))->toBe('');
});
test('admin can update single setting with false value', function (): void {
LotterySettings::put('settlement.apply_rebate_to_payout', true, 'settlement');
$token = settingsAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/settings/settlement.apply_rebate_to_payout', [
'value' => false,
])
->assertOk()
->assertJsonPath('data.key', 'settlement.apply_rebate_to_payout')
->assertJsonPath('data.value', false);
expect(LotterySetting::query()->where('setting_key', 'settlement.apply_rebate_to_payout')->value('value_json'))->toBeFalse();
});

View File

@@ -148,30 +148,32 @@ test('permission catalog groups permissions by admin navigation order', function
expect(array_column($groups, 'key'))->toBe([
'dashboard',
'agents',
'draws',
'tickets',
'players',
'settlement',
'wallet',
'reconcile',
'reports',
'rules_plays',
'rules_odds',
'jackpot',
'risk_cap',
'wallet',
'settlement',
'reconcile',
'reports',
'currencies',
'integration',
'currencies',
'admin_users',
'admin_roles',
'risk',
'audit',
'settings',
'risk',
]);
expect($groups[1]['key'])->toBe('draws');
expect($groups[14]['label'])->toBe('管理列表');
expect(array_column($groups[14]['permissions'], 'slug'))->toBe(['prd.admin_user.manage']);
expect($groups[15]['label'])->toBe('角色管理');
expect(array_column($groups[15]['permissions'], 'slug'))->toBe(['prd.admin_role.manage']);
expect($groups[1]['key'])->toBe('agents');
expect($groups[2]['key'])->toBe('draws');
expect($groups[15]['label'])->toBe('管理列表');
expect(array_column($groups[15]['permissions'], 'slug'))->toBe(['prd.admin_user.manage']);
expect($groups[16]['label'])->toBe('角色管理');
expect(array_column($groups[16]['permissions'], 'slug'))->toBe(['prd.admin_role.manage']);
$groupsByKey = collect($groups)->keyBy('key');
expect(array_column($groupsByKey['tickets']['permissions'], 'slug'))->toBe([

View File

@@ -71,3 +71,16 @@ function grantSuperAdminRole(AdminUser $admin): void
['granted_at' => $now],
);
}
/** 为后台测试账号挂上代理节点(需已存在 agent_nodes / admin_user_agents 表)。 */
function bindAdminUserToAgent(AdminUser $admin, int $agentNodeId): void
{
DB::table('admin_user_agents')->updateOrInsert(
['admin_user_id' => $admin->id],
[
'agent_node_id' => $agentNodeId,
'is_primary' => true,
'granted_at' => now(),
],
);
}