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,