feat: 增强管理员功能与数据处理
- 在多个控制器中引入 agent_node_id,以支持基于代理节点的权限和数据过滤。 - 更新 AdminRole 和 AdminUser 模型,新增角色范围和代理节点相关功能,提升角色管理的灵活性。 - 在请求验证中添加 agent_node_id 字段,确保 API 接口支持代理节点的相关操作。 - 优化 LotterySettings 服务,支持批量写入设置,提升配置管理的效率。 - 更新仪表板和报告服务,增强数据统计功能,确保管理员能够获取更全面的统计信息。
This commit is contained in:
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => [
|
||||
|
||||
@@ -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 !== '') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.'%'));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
22
app/Http/Requests/Admin/AdminSettingBatchUpdateRequest.php
Normal file
22
app/Http/Requests/Admin/AdminSettingBatchUpdateRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ final class AdminSettingUpdateRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'value' => ['required'],
|
||||
'value' => ['present'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
22
app/Http/Requests/Admin/AgentAdminUserRoleSyncRequest.php
Normal file
22
app/Http/Requests/Admin/AgentAdminUserRoleSyncRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
28
app/Http/Requests/Admin/AgentAdminUserStoreRequest.php
Normal file
28
app/Http/Requests/Admin/AgentAdminUserStoreRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
25
app/Http/Requests/Admin/AgentDelegationGrantSyncRequest.php
Normal file
25
app/Http/Requests/Admin/AgentDelegationGrantSyncRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Http/Requests/Admin/AgentNodeStoreRequest.php
Normal file
24
app/Http/Requests/Admin/AgentNodeStoreRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
22
app/Http/Requests/Admin/AgentNodeUpdateRequest.php
Normal file
22
app/Http/Requests/Admin/AgentNodeUpdateRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
22
app/Http/Requests/Admin/AgentRolePermissionSyncRequest.php
Normal file
22
app/Http/Requests/Admin/AgentRolePermissionSyncRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
39
app/Http/Requests/Admin/AgentRoleStoreRequest.php
Normal file
39
app/Http/Requests/Admin/AgentRoleStoreRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
23
app/Http/Requests/Admin/AgentRoleUpdateRequest.php
Normal file
23
app/Http/Requests/Admin/AgentRoleUpdateRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
82
app/Models/AgentNode.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
85
app/Services/Agent/AgentAdminUserService.php
Normal file
85
app/Services/Agent/AgentAdminUserService.php
Normal 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']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
98
app/Services/Agent/AgentDelegationService.php
Normal file
98
app/Services/Agent/AgentDelegationService.php
Normal 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());
|
||||
}
|
||||
}
|
||||
82
app/Services/Agent/AgentNodeService.php
Normal file
82
app/Services/Agent/AgentNodeService.php
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
102
app/Services/Agent/AgentRoleService.php
Normal file
102
app/Services/Agent/AgentRoleService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
92
app/Support/AdminAgentNodeAccess.php
Normal file
92
app/Support/AdminAgentNodeAccess.php
Normal 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;
|
||||
}
|
||||
}
|
||||
167
app/Support/AdminAgentScope.php
Normal file
167
app/Support/AdminAgentScope.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']],
|
||||
|
||||
137
app/Support/AdminDataScope.php
Normal file
137
app/Support/AdminDataScope.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
|
||||
/**
|
||||
* 站点 + 代理子树:用于报表等 Query Builder(players 表别名)。
|
||||
*/
|
||||
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');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
151
app/Support/AgentDelegationAuthorization.php
Normal file
151
app/Support/AgentDelegationAuthorization.php
Normal 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)],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
app/Support/AgentNodeApiPresenter.php
Normal file
33
app/Support/AgentNodeApiPresenter.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
79
app/Support/AgentNodePresenter.php
Normal file
79
app/Support/AgentNodePresenter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
91
app/Support/AgentRoleAuthorization.php
Normal file
91
app/Support/AgentRoleAuthorization.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -43,6 +43,10 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
$middleware->api(prepend: [
|
||||
NegotiateLotteryLocale::class,
|
||||
]);
|
||||
$middleware->convertEmptyStringsToNull([
|
||||
static fn (Request $request): bool => $request->is('api/v1/admin/settings')
|
||||
|| $request->is('api/v1/admin/settings/*'),
|
||||
]);
|
||||
$middleware->alias([
|
||||
// 玩家端需登录路由使用;解析 Bearer → Player
|
||||
'lottery.player' => EnsurePlayerApi::class,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Support\AdminAuthorizationRegistry;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private const RESOURCE_CODE = 'admin.settings.batch-update';
|
||||
|
||||
private const CLONE_BINDINGS_FROM = 'admin.settings.update';
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$now = Carbon::now();
|
||||
$resource = collect(AdminAuthorizationRegistry::resources())
|
||||
->first(fn (array $item): bool => $item['code'] === self::RESOURCE_CODE);
|
||||
|
||||
if ($resource === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$resourceId = DB::table('admin_api_resources')
|
||||
->where('code', $resource['code'])
|
||||
->value('id');
|
||||
|
||||
$payload = [
|
||||
'module_code' => $resource['module_code'],
|
||||
'name' => $resource['name'],
|
||||
'http_method' => $resource['http_method'],
|
||||
'uri_pattern' => $resource['uri_pattern'],
|
||||
'route_name' => $resource['route_name'],
|
||||
'auth_mode' => $resource['auth_mode'],
|
||||
'is_audit_required' => $resource['is_audit_required'],
|
||||
'status' => 1,
|
||||
'meta_json' => null,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($resourceId === null) {
|
||||
$resourceId = DB::table('admin_api_resources')->insertGetId($payload + [
|
||||
'code' => $resource['code'],
|
||||
'created_at' => $now,
|
||||
]);
|
||||
} else {
|
||||
DB::table('admin_api_resources')
|
||||
->where('id', (int) $resourceId)
|
||||
->update($payload);
|
||||
}
|
||||
|
||||
DB::table('admin_api_resource_bindings')
|
||||
->where('api_resource_id', (int) $resourceId)
|
||||
->delete();
|
||||
|
||||
$sourceResourceId = DB::table('admin_api_resources')
|
||||
->where('code', self::CLONE_BINDINGS_FROM)
|
||||
->value('id');
|
||||
|
||||
if ($sourceResourceId !== null) {
|
||||
$bindings = DB::table('admin_api_resource_bindings')
|
||||
->where('api_resource_id', (int) $sourceResourceId)
|
||||
->get(['menu_action_id']);
|
||||
|
||||
foreach ($bindings as $binding) {
|
||||
DB::table('admin_api_resource_bindings')->insert([
|
||||
'api_resource_id' => (int) $resourceId,
|
||||
'menu_action_id' => (int) $binding->menu_action_id,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (Schema::hasTable('admin_role_api_resources')) {
|
||||
$roleResourceRows = DB::table('admin_role_menu_actions as rma')
|
||||
->join('admin_api_resource_bindings as arb', 'arb.menu_action_id', '=', 'rma.menu_action_id')
|
||||
->where('arb.api_resource_id', (int) $resourceId)
|
||||
->select('rma.role_id')
|
||||
->distinct()
|
||||
->get();
|
||||
|
||||
foreach ($roleResourceRows as $row) {
|
||||
DB::table('admin_role_api_resources')->updateOrInsert([
|
||||
'role_id' => (int) $row->role_id,
|
||||
'api_resource_id' => (int) $resourceId,
|
||||
], []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$resourceId = DB::table('admin_api_resources')->where('code', self::RESOURCE_CODE)->value('id');
|
||||
if ($resourceId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Schema::hasTable('admin_role_api_resources')) {
|
||||
DB::table('admin_role_api_resources')->where('api_resource_id', (int) $resourceId)->delete();
|
||||
}
|
||||
DB::table('admin_api_resource_bindings')->where('api_resource_id', (int) $resourceId)->delete();
|
||||
DB::table('admin_api_resources')->where('id', (int) $resourceId)->delete();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('agent_nodes', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('admin_site_id')->constrained('admin_sites')->cascadeOnDelete();
|
||||
$table->foreignId('parent_id')->nullable()->constrained('agent_nodes')->nullOnDelete();
|
||||
$table->string('path', 512);
|
||||
$table->unsignedSmallInteger('depth')->default(0);
|
||||
$table->string('code', 64);
|
||||
$table->string('name', 128);
|
||||
$table->unsignedTinyInteger('status')->default(1)->comment('1=enabled,0=disabled');
|
||||
$table->foreignId('created_by')->nullable()->constrained('admin_users')->nullOnDelete();
|
||||
$table->json('extra_json')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['admin_site_id', 'code'], 'uk_agent_nodes_site_code');
|
||||
$table->index(['admin_site_id', 'parent_id'], 'idx_agent_nodes_site_parent');
|
||||
$table->index('path', 'idx_agent_nodes_path');
|
||||
});
|
||||
|
||||
Schema::create('admin_user_agents', function (Blueprint $table): void {
|
||||
$table->foreignId('admin_user_id')->primary()->constrained('admin_users')->cascadeOnDelete();
|
||||
$table->foreignId('agent_node_id')->constrained('agent_nodes')->cascadeOnDelete();
|
||||
$table->boolean('is_primary')->default(true);
|
||||
$table->timestamp('granted_at')->nullable();
|
||||
});
|
||||
|
||||
$this->seedRootAgentNodes();
|
||||
$this->backfillAdminUserAgents();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('admin_user_agents');
|
||||
Schema::dropIfExists('agent_nodes');
|
||||
}
|
||||
|
||||
private function seedRootAgentNodes(): void
|
||||
{
|
||||
$now = Carbon::now();
|
||||
$sites = DB::table('admin_sites')->orderBy('id')->get(['id', 'code', 'name']);
|
||||
|
||||
foreach ($sites as $site) {
|
||||
if (DB::table('agent_nodes')->where('admin_site_id', (int) $site->id)->where('depth', 0)->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$code = 'root-'.(string) $site->code;
|
||||
$nodeId = DB::table('agent_nodes')->insertGetId([
|
||||
'admin_site_id' => (int) $site->id,
|
||||
'parent_id' => null,
|
||||
'path' => '/',
|
||||
'depth' => 0,
|
||||
'code' => $code,
|
||||
'name' => (string) $site->name,
|
||||
'status' => 1,
|
||||
'created_by' => null,
|
||||
'extra_json' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('agent_nodes')->where('id', $nodeId)->update([
|
||||
'path' => '/'.$nodeId.'/',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillAdminUserAgents(): void
|
||||
{
|
||||
$superRoleId = DB::table('admin_roles')->where('slug', 'super_admin')->value('id');
|
||||
$now = Carbon::now();
|
||||
|
||||
$userIds = DB::table('admin_users')->pluck('id');
|
||||
foreach ($userIds as $userId) {
|
||||
$userId = (int) $userId;
|
||||
if (DB::table('admin_user_agents')->where('admin_user_id', $userId)->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($superRoleId !== null) {
|
||||
$isSuper = DB::table('admin_user_site_roles')
|
||||
->where('admin_user_id', $userId)
|
||||
->where('role_id', (int) $superRoleId)
|
||||
->exists();
|
||||
if ($isSuper) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$siteId = DB::table('admin_user_site_roles')
|
||||
->where('admin_user_id', $userId)
|
||||
->orderBy('site_id')
|
||||
->value('site_id');
|
||||
|
||||
if ($siteId === null) {
|
||||
$siteId = DB::table('admin_sites')->where('is_default', true)->value('id')
|
||||
?? DB::table('admin_sites')->orderBy('id')->value('id');
|
||||
}
|
||||
|
||||
if ($siteId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rootId = DB::table('agent_nodes')
|
||||
->where('admin_site_id', (int) $siteId)
|
||||
->where('depth', 0)
|
||||
->value('id');
|
||||
|
||||
if ($rootId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('admin_user_agents')->insert([
|
||||
'admin_user_id' => $userId,
|
||||
'agent_node_id' => (int) $rootId,
|
||||
'is_primary' => true,
|
||||
'granted_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Support\AdminAuthorizationRegistry;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$now = Carbon::now();
|
||||
$viewActionId = DB::table('admin_action_catalog')->where('code', 'view')->value('id');
|
||||
$manageActionId = DB::table('admin_action_catalog')->where('code', 'manage')->value('id');
|
||||
if ($viewActionId === null || $manageActionId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$systemMenuId = DB::table('admin_menus')->where('code', 'system')->value('id');
|
||||
if ($systemMenuId === null) {
|
||||
$systemMenuId = DB::table('admin_menus')->insertGetId([
|
||||
'parent_id' => null,
|
||||
'menu_type' => 'directory',
|
||||
'code' => 'system',
|
||||
'name' => '系统',
|
||||
'path' => null,
|
||||
'route_name' => null,
|
||||
'component' => null,
|
||||
'icon' => null,
|
||||
'active_menu_code' => null,
|
||||
'sort_order' => 90,
|
||||
'is_visible' => true,
|
||||
'is_cache' => false,
|
||||
'is_external' => false,
|
||||
'status' => 1,
|
||||
'meta_json' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$agentMenuId = DB::table('admin_menus')->where('code', 'system.agents')->value('id');
|
||||
if ($agentMenuId === null) {
|
||||
$agentMenuId = DB::table('admin_menus')->insertGetId([
|
||||
'parent_id' => (int) $systemMenuId,
|
||||
'menu_type' => 'page',
|
||||
'code' => 'system.agents',
|
||||
'name' => '代理管理',
|
||||
'path' => '/admin/agents',
|
||||
'route_name' => 'admin.agents',
|
||||
'component' => 'agents/index',
|
||||
'icon' => null,
|
||||
'active_menu_code' => null,
|
||||
'sort_order' => 25,
|
||||
'is_visible' => true,
|
||||
'is_cache' => false,
|
||||
'is_external' => false,
|
||||
'status' => 1,
|
||||
'meta_json' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ([
|
||||
['permission_code' => 'agent.node.view', 'action_id' => (int) $viewActionId, 'name' => '代理节点查看'],
|
||||
['permission_code' => 'agent.node.manage', 'action_id' => (int) $manageActionId, 'name' => '代理节点管理'],
|
||||
] as $row) {
|
||||
if (DB::table('admin_menu_actions')->where('permission_code', $row['permission_code'])->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('admin_menu_actions')->insert([
|
||||
'menu_id' => (int) $agentMenuId,
|
||||
'action_id' => $row['action_id'],
|
||||
'permission_code' => $row['permission_code'],
|
||||
'name' => $row['name'],
|
||||
'status' => 1,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code');
|
||||
$resources = array_values(array_filter(
|
||||
AdminAuthorizationRegistry::resources(),
|
||||
static fn (array $resource): bool => str_starts_with((string) $resource['code'], 'admin.agent-nodes.'),
|
||||
));
|
||||
|
||||
foreach ($resources as $resource) {
|
||||
$resourceId = DB::table('admin_api_resources')->where('code', $resource['code'])->value('id');
|
||||
$payload = [
|
||||
'module_code' => $resource['module_code'],
|
||||
'name' => $resource['name'],
|
||||
'http_method' => $resource['http_method'],
|
||||
'uri_pattern' => $resource['uri_pattern'],
|
||||
'route_name' => $resource['route_name'],
|
||||
'auth_mode' => $resource['auth_mode'],
|
||||
'is_audit_required' => $resource['is_audit_required'],
|
||||
'status' => 1,
|
||||
'meta_json' => null,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($resourceId === null) {
|
||||
$resourceId = DB::table('admin_api_resources')->insertGetId($payload + [
|
||||
'code' => $resource['code'],
|
||||
'created_at' => $now,
|
||||
]);
|
||||
} else {
|
||||
DB::table('admin_api_resources')->where('id', (int) $resourceId)->update($payload);
|
||||
}
|
||||
|
||||
DB::table('admin_api_resource_bindings')
|
||||
->where('api_resource_id', (int) $resourceId)
|
||||
->delete();
|
||||
|
||||
$permissionCodes = $resource['permission_codes'] ?? [];
|
||||
foreach ($permissionCodes as $permissionCode) {
|
||||
$menuActionId = $menuActionIds[$permissionCode] ?? null;
|
||||
if ($menuActionId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('admin_api_resource_bindings')->insert([
|
||||
'api_resource_id' => (int) $resourceId,
|
||||
'menu_action_id' => (int) $menuActionId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('admin_role_api_resources')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$superRoleId = DB::table('admin_roles')->where('slug', 'super_admin')->value('id');
|
||||
if ($superRoleId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($resources as $resource) {
|
||||
$resourceId = DB::table('admin_api_resources')->where('code', $resource['code'])->value('id');
|
||||
if ($resourceId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('admin_role_api_resources')->updateOrInsert([
|
||||
'role_id' => (int) $superRoleId,
|
||||
'api_resource_id' => (int) $resourceId,
|
||||
], []);
|
||||
}
|
||||
|
||||
$menuActionIdList = DB::table('admin_menu_actions')
|
||||
->whereIn('permission_code', ['agent.node.view', 'agent.node.manage'])
|
||||
->pluck('id');
|
||||
|
||||
foreach ($menuActionIdList as $menuActionId) {
|
||||
DB::table('admin_role_menu_actions')->updateOrInsert([
|
||||
'role_id' => (int) $superRoleId,
|
||||
'menu_action_id' => (int) $menuActionId,
|
||||
], []);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$codes = [
|
||||
'admin.agent-nodes.tree',
|
||||
'admin.agent-nodes.store',
|
||||
'admin.agent-nodes.show',
|
||||
'admin.agent-nodes.update',
|
||||
'admin.agent-nodes.children',
|
||||
];
|
||||
|
||||
foreach ($codes as $code) {
|
||||
$resourceId = DB::table('admin_api_resources')->where('code', $code)->value('id');
|
||||
if ($resourceId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Schema::hasTable('admin_role_api_resources')) {
|
||||
DB::table('admin_role_api_resources')->where('api_resource_id', (int) $resourceId)->delete();
|
||||
}
|
||||
DB::table('admin_api_resource_bindings')->where('api_resource_id', (int) $resourceId)->delete();
|
||||
DB::table('admin_api_resources')->where('id', (int) $resourceId)->delete();
|
||||
}
|
||||
|
||||
DB::table('admin_menu_actions')
|
||||
->whereIn('permission_code', ['agent.node.view', 'agent.node.manage'])
|
||||
->delete();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('admin_roles', function (Blueprint $table): void {
|
||||
$table->foreignId('owner_agent_id')->nullable()->after('sort_order')->constrained('agent_nodes')->nullOnDelete();
|
||||
$table->foreignId('delegated_from_role_id')->nullable()->after('owner_agent_id')->constrained('admin_roles')->nullOnDelete();
|
||||
$table->string('scope_type', 16)->default('system')->after('delegated_from_role_id');
|
||||
});
|
||||
|
||||
Schema::create('admin_user_agent_roles', function (Blueprint $table): void {
|
||||
$table->foreignId('admin_user_id')->constrained('admin_users')->cascadeOnDelete();
|
||||
$table->foreignId('agent_node_id')->constrained('agent_nodes')->cascadeOnDelete();
|
||||
$table->foreignId('role_id')->constrained('admin_roles')->cascadeOnDelete();
|
||||
$table->timestamp('granted_at')->nullable();
|
||||
$table->primary(['admin_user_id', 'agent_node_id', 'role_id'], 'pk_admin_user_agent_roles');
|
||||
});
|
||||
|
||||
Schema::table('players', function (Blueprint $table): void {
|
||||
$table->foreignId('agent_node_id')->nullable()->after('site_code')->constrained('agent_nodes')->nullOnDelete();
|
||||
$table->index(['site_code', 'agent_node_id'], 'idx_players_site_agent');
|
||||
});
|
||||
|
||||
DB::table('admin_roles')->update(['scope_type' => 'system']);
|
||||
|
||||
$this->backfillAdminUserAgentRoles();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('players', function (Blueprint $table): void {
|
||||
$table->dropIndex('idx_players_site_agent');
|
||||
$table->dropConstrainedForeignId('agent_node_id');
|
||||
});
|
||||
|
||||
Schema::dropIfExists('admin_user_agent_roles');
|
||||
|
||||
Schema::table('admin_roles', function (Blueprint $table): void {
|
||||
$table->dropConstrainedForeignId('delegated_from_role_id');
|
||||
$table->dropConstrainedForeignId('owner_agent_id');
|
||||
$table->dropColumn('scope_type');
|
||||
});
|
||||
}
|
||||
|
||||
private function backfillAdminUserAgentRoles(): void
|
||||
{
|
||||
$now = Carbon::now();
|
||||
$rows = DB::table('admin_user_site_roles as usr')
|
||||
->join('admin_user_agents as uaa', 'uaa.admin_user_id', '=', 'usr.admin_user_id')
|
||||
->join('agent_nodes as an', static function ($join): void {
|
||||
$join->on('an.id', '=', 'uaa.agent_node_id')
|
||||
->on('an.admin_site_id', '=', 'usr.site_id');
|
||||
})
|
||||
->select(['usr.admin_user_id', 'uaa.agent_node_id', 'usr.role_id', 'usr.granted_at'])
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
DB::table('admin_user_agent_roles')->updateOrInsert(
|
||||
[
|
||||
'admin_user_id' => (int) $row->admin_user_id,
|
||||
'agent_node_id' => (int) $row->agent_node_id,
|
||||
'role_id' => (int) $row->role_id,
|
||||
],
|
||||
[
|
||||
'granted_at' => $row->granted_at ?? $now,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Support\AdminAuthorizationRegistry;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 代理角色/账号 API 绑定到已有 agent.node.view / agent.node.manage 动作(同菜单下 action 唯一)。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$now = Carbon::now();
|
||||
$menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code');
|
||||
|
||||
$resources = array_values(array_filter(
|
||||
AdminAuthorizationRegistry::resources(),
|
||||
static fn (array $resource): bool => str_starts_with((string) $resource['code'], 'admin.agent-roles.')
|
||||
|| str_starts_with((string) $resource['code'], 'admin.agent-admin-users.'),
|
||||
));
|
||||
|
||||
foreach ($resources as $resource) {
|
||||
$resourceId = DB::table('admin_api_resources')->where('code', $resource['code'])->value('id');
|
||||
$payload = [
|
||||
'module_code' => $resource['module_code'],
|
||||
'name' => $resource['name'],
|
||||
'http_method' => $resource['http_method'],
|
||||
'uri_pattern' => $resource['uri_pattern'],
|
||||
'route_name' => $resource['route_name'],
|
||||
'auth_mode' => $resource['auth_mode'],
|
||||
'is_audit_required' => $resource['is_audit_required'],
|
||||
'status' => 1,
|
||||
'meta_json' => null,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($resourceId === null) {
|
||||
$resourceId = DB::table('admin_api_resources')->insertGetId($payload + [
|
||||
'code' => $resource['code'],
|
||||
'created_at' => $now,
|
||||
]);
|
||||
} else {
|
||||
DB::table('admin_api_resources')->where('id', (int) $resourceId)->update($payload);
|
||||
}
|
||||
|
||||
DB::table('admin_api_resource_bindings')
|
||||
->where('api_resource_id', (int) $resourceId)
|
||||
->delete();
|
||||
|
||||
foreach ($resource['permission_codes'] ?? [] as $permissionCode) {
|
||||
$menuActionId = $menuActionIds[$permissionCode] ?? null;
|
||||
if ($menuActionId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('admin_api_resource_bindings')->insert([
|
||||
'api_resource_id' => (int) $resourceId,
|
||||
'menu_action_id' => (int) $menuActionId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('admin_role_api_resources')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$superRoleId = DB::table('admin_roles')->where('slug', 'super_admin')->value('id');
|
||||
if ($superRoleId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($resources as $resource) {
|
||||
$resourceId = DB::table('admin_api_resources')->where('code', $resource['code'])->value('id');
|
||||
if ($resourceId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('admin_role_api_resources')->updateOrInsert([
|
||||
'role_id' => (int) $superRoleId,
|
||||
'api_resource_id' => (int) $resourceId,
|
||||
], []);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$codes = [
|
||||
'admin.agent-roles.update',
|
||||
'admin.agent-roles.destroy',
|
||||
'admin.agent-roles.permissions.sync',
|
||||
'admin.agent-roles.index',
|
||||
'admin.agent-roles.store',
|
||||
'admin.agent-admin-users.index',
|
||||
'admin.agent-admin-users.store',
|
||||
'admin.agent-admin-users.roles.sync',
|
||||
];
|
||||
|
||||
foreach ($codes as $code) {
|
||||
$resourceId = DB::table('admin_api_resources')->where('code', $code)->value('id');
|
||||
if ($resourceId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Schema::hasTable('admin_role_api_resources')) {
|
||||
DB::table('admin_role_api_resources')->where('api_resource_id', (int) $resourceId)->delete();
|
||||
}
|
||||
DB::table('admin_api_resource_bindings')->where('api_resource_id', (int) $resourceId)->delete();
|
||||
DB::table('admin_api_resources')->where('id', (int) $resourceId)->delete();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('agent_delegation_grants', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('parent_agent_id')->constrained('agent_nodes')->cascadeOnDelete();
|
||||
$table->foreignId('child_agent_id')->constrained('agent_nodes')->cascadeOnDelete();
|
||||
$table->foreignId('menu_action_id')->constrained('admin_menu_actions')->cascadeOnDelete();
|
||||
$table->boolean('can_delegate')->default(false);
|
||||
$table->foreignId('granted_by')->nullable()->constrained('admin_users')->nullOnDelete();
|
||||
$table->timestamp('granted_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['child_agent_id', 'menu_action_id'], 'uk_agent_delegation_child_action');
|
||||
$table->index(['parent_agent_id', 'child_agent_id'], 'idx_agent_delegation_parent_child');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('agent_delegation_grants');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 将未归属玩家挂到对应主站根代理(与 agent_nodes depth=0 1:1)。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! \Illuminate\Support\Facades\Schema::hasColumn('players', 'agent_node_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$roots = DB::table('agent_nodes as an')
|
||||
->join('admin_sites as s', 's.id', '=', 'an.admin_site_id')
|
||||
->where('an.depth', 0)
|
||||
->get(['an.id as root_id', 's.code as site_code']);
|
||||
|
||||
foreach ($roots as $root) {
|
||||
DB::table('players')
|
||||
->where('site_code', (string) $root->site_code)
|
||||
->whereNull('agent_node_id')
|
||||
->update(['agent_node_id' => (int) $root->root_id]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// 不回滚归属,避免误清空业务绑定。
|
||||
}
|
||||
};
|
||||
@@ -19,6 +19,10 @@ return [
|
||||
'role_cannot_delete_super_admin' => 'Cannot delete the super admin role.',
|
||||
'role_builtin_cannot_delete' => 'Built-in roles cannot be deleted.',
|
||||
'role_has_users_cannot_delete' => 'This role still has assigned admins and cannot be deleted.',
|
||||
'agent_root_delete_denied' => 'Root agent nodes cannot be deleted.',
|
||||
'agent_node_has_children_cannot_delete' => 'This agent node has child nodes. Delete children first.',
|
||||
'agent_node_has_users_cannot_delete' => 'This agent node still has bound admin accounts and cannot be deleted.',
|
||||
'agent_node_has_roles_cannot_delete' => 'This agent node still has bound roles and cannot be deleted.',
|
||||
'user_cannot_delete_self' => 'Cannot delete your own account.',
|
||||
'user_cannot_delete_last_super_admin' => 'Cannot delete the last super admin.',
|
||||
'super_admin_only_for_roles' => 'Only super admins can manage roles.',
|
||||
|
||||
@@ -19,6 +19,10 @@ return [
|
||||
'role_cannot_delete_super_admin' => 'सुपर एडमिन भूमिका मेटाउन मिल्दैन।',
|
||||
'role_builtin_cannot_delete' => 'बिल्ट-इन भूमिका मेटाउन मिल्दैन।',
|
||||
'role_has_users_cannot_delete' => 'यो भूमिकामा अझै एडमिन छ, मेटाउन मिल्दैन।',
|
||||
'agent_root_delete_denied' => 'रुट एजेन्ट नोड मेटाउन मिल्दैन।',
|
||||
'agent_node_has_children_cannot_delete' => 'यस एजेन्ट नोडमा चाइल्ड नोडहरू छन्, पहिले तिनीहरू हटाउनुहोस्।',
|
||||
'agent_node_has_users_cannot_delete' => 'यस एजेन्ट नोडमा अझै एडमिन खाता जोडिएको छ, मेटाउन मिल्दैन।',
|
||||
'agent_node_has_roles_cannot_delete' => 'यस एजेन्ट नोडमा अझै भूमिका जोडिएको छ, मेटाउन मिल्दैन।',
|
||||
'user_cannot_delete_self' => 'आफ्नै खाता मेटाउन मिल्दैन।',
|
||||
'user_cannot_delete_last_super_admin' => 'अन्तिम सुपर एडमिन मेटाउन मिल्दैन।',
|
||||
'super_admin_only_for_roles' => 'भूमिका व्यवस्थापन केवल सुपर एडमिनले गर्न सक्छ।',
|
||||
|
||||
@@ -19,6 +19,10 @@ return [
|
||||
'role_cannot_delete_super_admin' => '不能删除超级管理员角色。',
|
||||
'role_builtin_cannot_delete' => '系统内置角色不允许删除。',
|
||||
'role_has_users_cannot_delete' => '该角色下仍有关联管理员,不能删除。',
|
||||
'agent_root_delete_denied' => '根节点不允许删除。',
|
||||
'agent_node_has_children_cannot_delete' => '该代理节点存在下级代理,请先清空下级后再删除。',
|
||||
'agent_node_has_users_cannot_delete' => '该代理节点下仍有关联账号,不能删除。',
|
||||
'agent_node_has_roles_cannot_delete' => '该代理节点下仍有关联角色,不能删除。',
|
||||
'user_cannot_delete_self' => '不能删除当前登录账号。',
|
||||
'user_cannot_delete_last_super_admin' => '不能删除最后一个超级管理员。',
|
||||
'super_admin_only_for_roles' => '仅超级管理员可管理角色。',
|
||||
|
||||
@@ -33,6 +33,7 @@ Route::prefix('v1')->group(function (): void {
|
||||
require __DIR__.'/api/v1/admin/jackpot.php';
|
||||
require __DIR__.'/api/v1/admin/config.php';
|
||||
require __DIR__.'/api/v1/admin/user.php';
|
||||
require __DIR__.'/api/v1/admin/agent.php';
|
||||
require __DIR__.'/api/v1/admin/report.php';
|
||||
});
|
||||
});
|
||||
|
||||
57
routes/api/v1/admin/agent.php
Normal file
57
routes/api/v1/admin/agent.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeTreeController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeShowController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeStoreController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeUpdateController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeDestroyController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeChildrenController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeRoleIndexController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeRoleStoreController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Agent\AgentRoleUpdateController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Agent\AgentRolePermissionSyncController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Agent\AgentRoleDestroyController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeAdminUserIndexController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeAdminUserStoreController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Agent\AgentAdminUserRoleSyncController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeDelegationGrantIndexController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeDelegationGrantSyncController;
|
||||
|
||||
Route::middleware('admin.api-resource')
|
||||
->group(function (): void {
|
||||
Route::get('agent-nodes/tree', AgentNodeTreeController::class)
|
||||
->name('api.v1.admin.agent-nodes.tree');
|
||||
Route::post('agent-nodes', AgentNodeStoreController::class)
|
||||
->name('api.v1.admin.agent-nodes.store');
|
||||
Route::get('agent-nodes/{agent_node}/roles', AgentNodeRoleIndexController::class)
|
||||
->name('api.v1.admin.agent-roles.index');
|
||||
Route::post('agent-nodes/{agent_node}/roles', AgentNodeRoleStoreController::class)
|
||||
->name('api.v1.admin.agent-roles.store');
|
||||
Route::get('agent-nodes/{agent_node}/admin-users', AgentNodeAdminUserIndexController::class)
|
||||
->name('api.v1.admin.agent-admin-users.index');
|
||||
Route::post('agent-nodes/{agent_node}/admin-users', AgentNodeAdminUserStoreController::class)
|
||||
->name('api.v1.admin.agent-admin-users.store');
|
||||
Route::get('agent-nodes/{agent_node}/delegation-grants', AgentNodeDelegationGrantIndexController::class)
|
||||
->name('api.v1.admin.agent-delegation-grants.index');
|
||||
Route::put('agent-nodes/{agent_node}/delegation-grants', AgentNodeDelegationGrantSyncController::class)
|
||||
->name('api.v1.admin.agent-delegation-grants.sync');
|
||||
Route::get('agent-nodes/{agent_node}', AgentNodeShowController::class)
|
||||
->name('api.v1.admin.agent-nodes.show');
|
||||
Route::put('agent-nodes/{agent_node}', AgentNodeUpdateController::class)
|
||||
->name('api.v1.admin.agent-nodes.update');
|
||||
Route::delete('agent-nodes/{agent_node}', AgentNodeDestroyController::class)
|
||||
->name('api.v1.admin.agent-nodes.destroy');
|
||||
Route::get('agent-nodes/{agent_node}/children', AgentNodeChildrenController::class)
|
||||
->name('api.v1.admin.agent-nodes.children');
|
||||
|
||||
Route::put('agent-roles/{admin_role}', AgentRoleUpdateController::class)
|
||||
->name('api.v1.admin.agent-roles.update');
|
||||
Route::put('agent-roles/{admin_role}/permissions', AgentRolePermissionSyncController::class)
|
||||
->name('api.v1.admin.agent-roles.permissions.sync');
|
||||
Route::delete('agent-roles/{admin_role}', AgentRoleDestroyController::class)
|
||||
->name('api.v1.admin.agent-roles.destroy');
|
||||
|
||||
Route::put('agent-admin-users/{admin_user}/roles', AgentAdminUserRoleSyncController::class)
|
||||
->name('api.v1.admin.agent-admin-users.roles.sync');
|
||||
});
|
||||
@@ -126,6 +126,8 @@ Route::middleware('admin.api-resource')
|
||||
->group(function (): void {
|
||||
Route::get('/', AdminSettingController::class.'@index')
|
||||
->name('index');
|
||||
Route::put('batch', AdminSettingController::class.'@batchUpdate')
|
||||
->name('batch-update');
|
||||
Route::put('{key}', AdminSettingController::class.'@update')
|
||||
->name('update');
|
||||
});
|
||||
|
||||
192
tests/Feature/AdminAgentDelegationApiTest.php
Normal file
192
tests/Feature/AdminAgentDelegationApiTest.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
});
|
||||
|
||||
function grantDelegationAgentOperator(AdminUser $admin, AgentNode $agent, bool $manage = true): void
|
||||
{
|
||||
$now = now();
|
||||
$roleId = DB::table('admin_roles')->insertGetId([
|
||||
'slug' => 'agent_ops_'.$admin->id,
|
||||
'code' => 'agent_ops_'.$admin->id,
|
||||
'name' => 'Agent Ops',
|
||||
'description' => null,
|
||||
'status' => 1,
|
||||
'is_system' => false,
|
||||
'sort_order' => 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$codes = $manage
|
||||
? ['agent.node.view', 'agent.node.manage']
|
||||
: ['agent.node.view'];
|
||||
|
||||
$actionIds = DB::table('admin_menu_actions')
|
||||
->whereIn('permission_code', $codes)
|
||||
->pluck('id');
|
||||
|
||||
foreach ($actionIds as $actionId) {
|
||||
DB::table('admin_role_menu_actions')->insert([
|
||||
'role_id' => $roleId,
|
||||
'menu_action_id' => (int) $actionId,
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('admin_user_site_roles')->insert([
|
||||
'admin_user_id' => $admin->id,
|
||||
'site_id' => (int) $agent->admin_site_id,
|
||||
'role_id' => $roleId,
|
||||
'granted_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('admin_user_agents')->insert([
|
||||
'admin_user_id' => $admin->id,
|
||||
'agent_node_id' => (int) $agent->id,
|
||||
'is_primary' => true,
|
||||
'granted_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
test('parent agent can sync delegation grants for direct child', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
$service = app(\App\Services\Agent\AgentNodeService::class);
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'deleg_super',
|
||||
'name' => 'Super',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
|
||||
$parent = $service->createChild($super, [
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'deleg-parent',
|
||||
'name' => 'Deleg Parent',
|
||||
]);
|
||||
$child = $service->createChild($super, [
|
||||
'parent_id' => $parent->id,
|
||||
'code' => 'deleg-child',
|
||||
'name' => 'Deleg Child',
|
||||
]);
|
||||
|
||||
$parentAdmin = AdminUser::query()->create([
|
||||
'username' => 'deleg_parent_admin',
|
||||
'name' => 'Parent Admin',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantDelegationAgentOperator($parentAdmin, $parent, manage: true);
|
||||
|
||||
$viewActionId = (int) DB::table('admin_menu_actions')
|
||||
->where('permission_code', 'agent.node.view')
|
||||
->value('id');
|
||||
|
||||
$token = $parentAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/agent-nodes/'.$child->id.'/delegation-grants', [
|
||||
'grants' => [
|
||||
['menu_action_id' => $viewActionId, 'can_delegate' => true],
|
||||
],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.child_agent_id', $child->id)
|
||||
->assertJsonCount(1, 'data.grants');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/agent-nodes/'.$child->id.'/delegation-grants')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.grants.0.can_delegate', true);
|
||||
});
|
||||
|
||||
test('delegation ceiling blocks role permissions beyond child grants', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
$service = app(\App\Services\Agent\AgentNodeService::class);
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'ceil_super',
|
||||
'name' => 'Super',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
|
||||
$branch = $service->createChild($super, [
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'ceil-branch',
|
||||
'name' => 'Ceil Branch',
|
||||
]);
|
||||
|
||||
$viewActionId = (int) DB::table('admin_menu_actions')
|
||||
->where('permission_code', 'agent.node.view')
|
||||
->value('id');
|
||||
|
||||
DB::table('agent_delegation_grants')->insert([
|
||||
'parent_agent_id' => $rootId,
|
||||
'child_agent_id' => $branch->id,
|
||||
'menu_action_id' => $viewActionId,
|
||||
'can_delegate' => true,
|
||||
'granted_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$operator = AdminUser::query()->create([
|
||||
'username' => 'ceil_ops',
|
||||
'name' => 'Ops',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantDelegationAgentOperator($operator, $branch, manage: true);
|
||||
$token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/agent-nodes/'.$branch->id.'/roles', [
|
||||
'slug' => 'ceil_ok',
|
||||
'name' => 'OK',
|
||||
'permission_slugs' => ['prd.agent.view'],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/agent-nodes/'.$branch->id.'/roles', [
|
||||
'slug' => 'ceil_bad',
|
||||
'name' => 'Bad',
|
||||
'permission_slugs' => ['prd.dashboard.view'],
|
||||
])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('auth me includes delegation ceiling for agent user', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$root = AgentNode::query()->where('admin_site_id', $siteId)->where('depth', 0)->firstOrFail();
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'me_deleg',
|
||||
'name' => 'Me',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantDelegationAgentOperator($admin, $root, manage: false);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/auth/me')
|
||||
->assertOk()
|
||||
->assertJsonStructure(['data' => ['admin' => ['delegation_ceiling']]]);
|
||||
});
|
||||
280
tests/Feature/AdminAgentNodeApiTest.php
Normal file
280
tests/Feature/AdminAgentNodeApiTest.php
Normal file
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
});
|
||||
|
||||
function agentRootNodeId(int $siteId): int
|
||||
{
|
||||
return (int) DB::table('agent_nodes')
|
||||
->where('admin_site_id', $siteId)
|
||||
->where('depth', 0)
|
||||
->value('id');
|
||||
}
|
||||
|
||||
function grantAgentOperatorRole(AdminUser $admin, AgentNode $agent, bool $manage = true): void
|
||||
{
|
||||
$now = now();
|
||||
$roleId = DB::table('admin_roles')->insertGetId([
|
||||
'slug' => 'agent_ops_'.$admin->id,
|
||||
'code' => 'agent_ops_'.$admin->id,
|
||||
'name' => 'Agent Ops',
|
||||
'description' => null,
|
||||
'status' => 1,
|
||||
'is_system' => false,
|
||||
'sort_order' => 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$codes = $manage
|
||||
? ['agent.node.view', 'agent.node.manage']
|
||||
: ['agent.node.view'];
|
||||
|
||||
$actionIds = DB::table('admin_menu_actions')
|
||||
->whereIn('permission_code', $codes)
|
||||
->pluck('id');
|
||||
|
||||
foreach ($actionIds as $actionId) {
|
||||
DB::table('admin_role_menu_actions')->insert([
|
||||
'role_id' => $roleId,
|
||||
'menu_action_id' => (int) $actionId,
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('admin_user_site_roles')->insert([
|
||||
'admin_user_id' => $admin->id,
|
||||
'site_id' => (int) $agent->admin_site_id,
|
||||
'role_id' => $roleId,
|
||||
'granted_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('admin_user_agents')->insert([
|
||||
'admin_user_id' => $admin->id,
|
||||
'agent_node_id' => (int) $agent->id,
|
||||
'is_primary' => true,
|
||||
'granted_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
test('each admin site has exactly one root agent node after migration', function (): void {
|
||||
$siteIds = DB::table('admin_sites')->pluck('id');
|
||||
foreach ($siteIds as $siteId) {
|
||||
expect(
|
||||
DB::table('agent_nodes')->where('admin_site_id', (int) $siteId)->where('depth', 0)->count()
|
||||
)->toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('super admin can load full agent tree', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'agent_super',
|
||||
'name' => 'Super',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/agent-nodes/tree?admin_site_id='.$siteId)
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0)
|
||||
->assertJsonStructure(['data' => ['admin_site_id', 'tree']]);
|
||||
});
|
||||
|
||||
test('agent operator only sees own subtree', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = agentRootNodeId($siteId);
|
||||
|
||||
$service = app(\App\Services\Agent\AgentNodeService::class);
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'bootstrap',
|
||||
'name' => 'Bootstrap',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
|
||||
$nodeA = $service->createChild($super, [
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'branch-a',
|
||||
'name' => 'Branch A',
|
||||
'status' => 1,
|
||||
]);
|
||||
$service->createChild($super, [
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'branch-b',
|
||||
'name' => 'Branch B',
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
$operator = AdminUser::query()->create([
|
||||
'username' => 'agent_a_ops',
|
||||
'name' => 'A Ops',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantAgentOperatorRole($operator, $nodeA);
|
||||
|
||||
$token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$response = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/agent-nodes/tree');
|
||||
|
||||
$response->assertOk();
|
||||
$tree = $response->json('data.tree');
|
||||
expect($tree)->toBeArray()->and(count($tree))->toBe(1);
|
||||
expect($tree[0]['code'])->toBe('branch-a');
|
||||
expect(collect($tree)->pluck('code'))->not->toContain('branch-b');
|
||||
});
|
||||
|
||||
test('agent operator can create child under own node but not under sibling', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = agentRootNodeId($siteId);
|
||||
$service = app(\App\Services\Agent\AgentNodeService::class);
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'bootstrap2',
|
||||
'name' => 'Bootstrap',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
|
||||
$nodeA = $service->createChild($super, [
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'branch-a2',
|
||||
'name' => 'Branch A',
|
||||
]);
|
||||
$nodeB = $service->createChild($super, [
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'branch-b2',
|
||||
'name' => 'Branch B',
|
||||
]);
|
||||
|
||||
$operator = AdminUser::query()->create([
|
||||
'username' => 'agent_a2_ops',
|
||||
'name' => 'A Ops',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantAgentOperatorRole($operator, $nodeA);
|
||||
$token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/agent-nodes', [
|
||||
'parent_id' => $nodeA->id,
|
||||
'code' => 'a-child',
|
||||
'name' => 'A Child',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.code', 'a-child');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/agent-nodes', [
|
||||
'parent_id' => $nodeB->id,
|
||||
'code' => 'hack-child',
|
||||
'name' => 'Hack',
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('auth me returns agent context for bound operator', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = agentRootNodeId($siteId);
|
||||
$service = app(\App\Services\Agent\AgentNodeService::class);
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'bootstrap3',
|
||||
'name' => 'Bootstrap',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
|
||||
$node = $service->createChild($super, [
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'me-branch',
|
||||
'name' => 'Me Branch',
|
||||
]);
|
||||
|
||||
$operator = AdminUser::query()->create([
|
||||
'username' => 'me_agent_ops',
|
||||
'name' => 'Ops',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantAgentOperatorRole($operator, $node);
|
||||
$token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/auth/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.admin.agent.code', 'me-branch')
|
||||
->assertJsonPath('data.admin.is_super_admin', false);
|
||||
});
|
||||
|
||||
test('agent node deletion requires leaf node without bindings', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = agentRootNodeId($siteId);
|
||||
$service = app(\App\Services\Agent\AgentNodeService::class);
|
||||
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'delete_agent_super',
|
||||
'name' => 'Delete Super',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
$token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$nodeWithChild = $service->createChild($super, [
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'delete-parent',
|
||||
'name' => 'Delete Parent',
|
||||
]);
|
||||
$service->createChild($super, [
|
||||
'parent_id' => $nodeWithChild->id,
|
||||
'code' => 'delete-child',
|
||||
'name' => 'Delete Child',
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->deleteJson('/api/v1/admin/agent-nodes/'.$rootId)
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('msg', __('admin.agent_root_delete_denied'));
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->deleteJson('/api/v1/admin/agent-nodes/'.$nodeWithChild->id)
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('msg', __('admin.agent_node_has_children_cannot_delete'));
|
||||
|
||||
$leaf = $service->createChild($super, [
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'delete-leaf',
|
||||
'name' => 'Delete Leaf',
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->deleteJson('/api/v1/admin/agent-nodes/'.$leaf->id)
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0);
|
||||
|
||||
expect(AgentNode::query()->find($leaf->id))->toBeNull();
|
||||
});
|
||||
205
tests/Feature/AdminAgentRoleApiTest.php
Normal file
205
tests/Feature/AdminAgentRoleApiTest.php
Normal file
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\Player;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
});
|
||||
|
||||
function grantAgentRoleManager(AdminUser $admin, AgentNode $agent): void
|
||||
{
|
||||
$now = now();
|
||||
$roleId = DB::table('admin_roles')->insertGetId([
|
||||
'slug' => 'agent_mgr_'.$admin->id,
|
||||
'code' => 'agent_mgr_'.$admin->id,
|
||||
'name' => 'Agent Manager',
|
||||
'scope_type' => 'system',
|
||||
'status' => 1,
|
||||
'is_system' => false,
|
||||
'sort_order' => 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$codes = ['agent.node.view', 'agent.node.manage', 'service.players.view'];
|
||||
$actionIds = DB::table('admin_menu_actions')->whereIn('permission_code', $codes)->pluck('id');
|
||||
foreach ($actionIds as $actionId) {
|
||||
DB::table('admin_role_menu_actions')->insert([
|
||||
'role_id' => $roleId,
|
||||
'menu_action_id' => (int) $actionId,
|
||||
]);
|
||||
}
|
||||
|
||||
$siteId = (int) $agent->admin_site_id;
|
||||
DB::table('admin_user_site_roles')->insert([
|
||||
'admin_user_id' => $admin->id,
|
||||
'site_id' => $siteId,
|
||||
'role_id' => $roleId,
|
||||
'granted_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('admin_user_agents')->insert([
|
||||
'admin_user_id' => $admin->id,
|
||||
'agent_node_id' => $agent->id,
|
||||
'is_primary' => true,
|
||||
'granted_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('admin_user_agent_roles')->insert([
|
||||
'admin_user_id' => $admin->id,
|
||||
'agent_node_id' => $agent->id,
|
||||
'role_id' => $roleId,
|
||||
'granted_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
test('agent operator can create role with subset of own permissions', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
$service = app(\App\Services\Agent\AgentNodeService::class);
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'super_role',
|
||||
'name' => 'Super',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
|
||||
$branch = $service->createChild($super, [
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'role-branch',
|
||||
'name' => 'Role Branch',
|
||||
]);
|
||||
|
||||
$operator = AdminUser::query()->create([
|
||||
'username' => 'role_ops',
|
||||
'name' => 'Ops',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantAgentRoleManager($operator, $branch);
|
||||
$token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/agent-nodes/'.$branch->id.'/roles', [
|
||||
'slug' => 'branch_cs',
|
||||
'name' => 'Branch CS',
|
||||
'permission_slugs' => ['prd.agent.view', 'prd.agent.role.view'],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.scope_type', 'agent')
|
||||
->assertJsonPath('data.owner_agent_id', $branch->id);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/agent-nodes/'.$branch->id.'/roles', [
|
||||
'slug' => 'branch_hack',
|
||||
'name' => 'Hack',
|
||||
'permission_slugs' => ['prd.dashboard.view'],
|
||||
])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('agent scoped roles are excluded from global admin roles index', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
$service = app(\App\Services\Agent\AgentNodeService::class);
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'super_idx',
|
||||
'name' => 'Super',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
$token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$branch = $service->createChild($super, [
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'idx-branch',
|
||||
'name' => 'Idx Branch',
|
||||
]);
|
||||
|
||||
DB::table('admin_roles')->insert([
|
||||
'slug' => 'idx_agent_role',
|
||||
'code' => 'idx_agent_role',
|
||||
'name' => 'Idx Agent Role',
|
||||
'scope_type' => 'agent',
|
||||
'owner_agent_id' => $branch->id,
|
||||
'status' => 1,
|
||||
'is_system' => false,
|
||||
'sort_order' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$slugs = collect($this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/admin-roles')
|
||||
->json('data.items'))
|
||||
->pluck('slug');
|
||||
|
||||
expect($slugs)->not->toContain('idx_agent_role');
|
||||
});
|
||||
|
||||
test('players list respects agent subtree when agent_node_id is set', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
$service = app(\App\Services\Agent\AgentNodeService::class);
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'super_player',
|
||||
'name' => 'Super',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
|
||||
$branchA = $service->createChild($super, ['parent_id' => $rootId, 'code' => 'pa', 'name' => 'A']);
|
||||
$branchB = $service->createChild($super, ['parent_id' => $rootId, 'code' => 'pb', 'name' => 'B']);
|
||||
|
||||
Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'agent_node_id' => $branchA->id,
|
||||
'site_player_id' => 'p-a',
|
||||
'username' => 'pa',
|
||||
'nickname' => 'PA',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'agent_node_id' => $branchB->id,
|
||||
'site_player_id' => 'p-b',
|
||||
'username' => 'pb',
|
||||
'nickname' => 'PB',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$operator = AdminUser::query()->create([
|
||||
'username' => 'player_ops',
|
||||
'name' => 'Ops',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantAgentRoleManager($operator, $branchA);
|
||||
|
||||
$token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$response = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/players?site_code='.$siteCode);
|
||||
|
||||
$response->assertOk();
|
||||
$usernames = collect($response->json('data.items'))->pluck('username');
|
||||
expect($usernames)->toContain('pa')->not->toContain('pb');
|
||||
});
|
||||
@@ -91,7 +91,10 @@ test('admin login returns bearer token when captcha passes validation', function
|
||||
->assertJsonPath('data.admin.nickname', '测试昵称')
|
||||
->assertJsonPath('data.admin.navigation.0.segment', 'dashboard')
|
||||
->assertJsonPath('data.admin.navigation.0.href', '/admin')
|
||||
->assertJsonPath('data.admin.navigation.1.segment', 'draws')
|
||||
->assertJsonPath('data.admin.navigation.0.nav_group', 'overview')
|
||||
->assertJsonPath('data.admin.navigation.1.segment', 'agents')
|
||||
->assertJsonPath('data.admin.navigation.1.nav_group', 'agent')
|
||||
->assertJsonPath('data.admin.navigation.2.segment', 'draws')
|
||||
->assertJsonStructure(['data' => ['token', 'token_type', 'admin' => ['id', 'username', 'nickname', 'email', 'permissions', 'navigation']]]);
|
||||
|
||||
$token = $resp->json('data.token');
|
||||
@@ -103,6 +106,67 @@ test('admin login returns bearer token when captcha passes validation', function
|
||||
->assertJsonPath('data.scope', 'admin');
|
||||
});
|
||||
|
||||
test('agent operator auth me omits platform-only navigation', function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'agent_nav_ops',
|
||||
'name' => 'Agent Nav',
|
||||
'email' => null,
|
||||
'password' => 'secret-strong',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$now = now();
|
||||
$roleId = DB::table('admin_roles')->insertGetId([
|
||||
'slug' => 'agent_nav_ops_role',
|
||||
'code' => 'agent_nav_ops_role',
|
||||
'name' => 'Agent Nav Ops',
|
||||
'description' => null,
|
||||
'status' => 1,
|
||||
'is_system' => false,
|
||||
'sort_order' => 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$codes = ['agent.node.view', 'service.report.view'];
|
||||
$actionIds = DB::table('admin_menu_actions')->whereIn('permission_code', $codes)->pluck('id');
|
||||
foreach ($actionIds as $actionId) {
|
||||
DB::table('admin_role_menu_actions')->insert([
|
||||
'role_id' => $roleId,
|
||||
'menu_action_id' => (int) $actionId,
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('admin_user_site_roles')->insert([
|
||||
'admin_user_id' => $admin->id,
|
||||
'site_id' => $siteId,
|
||||
'role_id' => $roleId,
|
||||
'granted_at' => $now,
|
||||
]);
|
||||
DB::table('admin_user_agents')->insert([
|
||||
'admin_user_id' => $admin->id,
|
||||
'agent_node_id' => $rootId,
|
||||
'is_primary' => true,
|
||||
'granted_at' => $now,
|
||||
]);
|
||||
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$segments = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/auth/me')
|
||||
->assertOk()
|
||||
->json('data.admin.navigation');
|
||||
|
||||
$keys = array_column($segments, 'segment');
|
||||
expect($keys)->toContain('agents', 'reports')
|
||||
->and($keys)->not->toContain('admin_users', 'admin_roles', 'settings', 'integration', 'rules_plays');
|
||||
});
|
||||
|
||||
test('admin captcha exposes key and image base64', function () {
|
||||
$resp = $this->getJson('/api/v1/admin/auth/captcha');
|
||||
|
||||
|
||||
149
tests/Feature/AdminPlayerAgentBindingTest.php
Normal file
149
tests/Feature/AdminPlayerAgentBindingTest.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Player;
|
||||
use App\Models\TransferOrder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
});
|
||||
|
||||
test('orphan players can be backfilled to site root agent', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
|
||||
$playerId = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'site_player_id' => 'orphan-1',
|
||||
'username' => 'orphan',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
'agent_node_id' => null,
|
||||
])->id;
|
||||
|
||||
DB::table('players')
|
||||
->where('site_code', $siteCode)
|
||||
->whereNull('agent_node_id')
|
||||
->update(['agent_node_id' => $rootId]);
|
||||
|
||||
expect((int) Player::query()->find($playerId)?->agent_node_id)->toBe($rootId);
|
||||
});
|
||||
|
||||
test('transfer list excludes players outside agent subtree', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
$service = app(\App\Services\Agent\AgentNodeService::class);
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'bind_super',
|
||||
'name' => 'Super',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
|
||||
$branch = $service->createChild($super, ['parent_id' => $rootId, 'code' => 'bind-a', 'name' => 'A']);
|
||||
$other = $service->createChild($super, ['parent_id' => $rootId, 'code' => 'bind-b', 'name' => 'B']);
|
||||
|
||||
$inPlayer = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'agent_node_id' => $branch->id,
|
||||
'site_player_id' => 'bind-in',
|
||||
'username' => 'bind_in',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
$outPlayer = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'agent_node_id' => $other->id,
|
||||
'site_player_id' => 'bind-out',
|
||||
'username' => 'bind_out',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
foreach ([$inPlayer, $outPlayer] as $index => $player) {
|
||||
TransferOrder::query()->create([
|
||||
'transfer_no' => 'TR-BIND-'.$index,
|
||||
'player_id' => $player->id,
|
||||
'direction' => 'in',
|
||||
'currency_code' => 'NPR',
|
||||
'amount' => 100,
|
||||
'status' => 'completed',
|
||||
'idempotent_key' => 'idem-bind-'.$index,
|
||||
'external_ref_no' => 'ext-'.$index,
|
||||
'fail_reason' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$operator = AdminUser::query()->create([
|
||||
'username' => 'bind_ops',
|
||||
'name' => 'Ops',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantAgentOpsForBinding($operator, $branch);
|
||||
|
||||
$token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$response = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/wallet/transfer-orders?site_code='.$siteCode);
|
||||
|
||||
$response->assertOk();
|
||||
$playerIds = collect($response->json('data.items'))->pluck('player_id')->map(static fn ($id): int => (int) $id);
|
||||
expect($playerIds)->toContain($inPlayer->id)->not->toContain($outPlayer->id);
|
||||
});
|
||||
|
||||
function grantAgentOpsForBinding(AdminUser $admin, \App\Models\AgentNode $agent): void
|
||||
{
|
||||
$now = now();
|
||||
$roleId = DB::table('admin_roles')->insertGetId([
|
||||
'slug' => 'bind_ops_'.$admin->id,
|
||||
'code' => 'bind_ops_'.$admin->id,
|
||||
'name' => 'Bind Ops',
|
||||
'scope_type' => 'system',
|
||||
'status' => 1,
|
||||
'is_system' => false,
|
||||
'sort_order' => 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
foreach (['agent.node.view', 'service.wallet.view', 'service.reconcile.view'] as $code) {
|
||||
$actionId = DB::table('admin_menu_actions')->where('permission_code', $code)->value('id');
|
||||
if ($actionId) {
|
||||
DB::table('admin_role_menu_actions')->insert([
|
||||
'role_id' => $roleId,
|
||||
'menu_action_id' => (int) $actionId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
DB::table('admin_user_site_roles')->insert([
|
||||
'admin_user_id' => $admin->id,
|
||||
'site_id' => (int) $agent->admin_site_id,
|
||||
'role_id' => $roleId,
|
||||
'granted_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('admin_user_agents')->insert([
|
||||
'admin_user_id' => $admin->id,
|
||||
'agent_node_id' => (int) $agent->id,
|
||||
'is_primary' => true,
|
||||
'granted_at' => $now,
|
||||
]);
|
||||
}
|
||||
184
tests/Feature/AdminReportAgentScopeTest.php
Normal file
184
tests/Feature/AdminReportAgentScopeTest.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\TicketItem;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Services\Admin\AdminReportQueryService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
});
|
||||
|
||||
test('player win loss report excludes players outside agent subtree', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
$service = app(\App\Services\Agent\AgentNodeService::class);
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'rpt_super',
|
||||
'name' => 'Super',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
|
||||
$branch = $service->createChild($super, [
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'rpt-branch',
|
||||
'name' => 'Report Branch',
|
||||
]);
|
||||
$otherBranch = $service->createChild($super, [
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'rpt-other',
|
||||
'name' => 'Other Branch',
|
||||
]);
|
||||
|
||||
$today = now()->toDateString();
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => 'RPT-'.now()->format('YmdHis'),
|
||||
'business_date' => $today,
|
||||
'sequence_no' => 901,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => now()->subMinute(),
|
||||
'close_time' => now()->addHour(),
|
||||
'draw_time' => now()->addHours(2),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$inScope = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'site_player_id' => 'rpt-in',
|
||||
'username' => 'rpt_in',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
'agent_node_id' => $branch->id,
|
||||
]);
|
||||
$outScope = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'site_player_id' => 'rpt-out',
|
||||
'username' => 'rpt_out',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
'agent_node_id' => $otherBranch->id,
|
||||
]);
|
||||
|
||||
foreach ([$inScope, $outScope] as $index => $player) {
|
||||
$order = TicketOrder::query()->create([
|
||||
'order_no' => 'RPT-ORD-'.$index,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 1000,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => 1000,
|
||||
'total_estimated_payout' => 0,
|
||||
'status' => 'placed',
|
||||
'submit_source' => 'h5',
|
||||
'client_trace_id' => 'rpt-trace-'.$index,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
TicketItem::query()->create([
|
||||
'ticket_no' => 'RPT-TK-'.$index,
|
||||
'order_id' => $order->id,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'original_number' => '1234',
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => 'big',
|
||||
'dimension' => 4,
|
||||
'digit_slot' => null,
|
||||
'bet_mode' => 'single',
|
||||
'unit_bet_amount' => 1000,
|
||||
'total_bet_amount' => 1000,
|
||||
'rebate_rate_snapshot' => '0.0000',
|
||||
'commission_rate_snapshot' => '0.0000',
|
||||
'actual_deduct_amount' => 1000,
|
||||
'win_amount' => 0,
|
||||
'jackpot_win_amount' => 0,
|
||||
'status' => 'placed',
|
||||
]);
|
||||
}
|
||||
|
||||
$operator = AdminUser::query()->create([
|
||||
'username' => 'rpt_ops',
|
||||
'name' => 'Ops',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantReportAgentManager($operator, $branch);
|
||||
|
||||
$query = app(AdminReportQueryService::class);
|
||||
$scoped = $query->playerWinLossPaginated(null, $today, $today, 1, 20, $operator);
|
||||
$all = $query->playerWinLossPaginated(null, $today, $today, 1, 20, null);
|
||||
|
||||
$scopedUsernames = collect($scoped->items())->pluck('username')->all();
|
||||
$allUsernames = collect($all->items())->pluck('username')->all();
|
||||
|
||||
expect($scopedUsernames)->toContain('rpt_in')->not->toContain('rpt_out')
|
||||
->and($allUsernames)->toContain('rpt_in', 'rpt_out');
|
||||
});
|
||||
|
||||
function grantReportAgentManager(AdminUser $admin, AgentNode $agent): void
|
||||
{
|
||||
$now = now();
|
||||
$roleId = DB::table('admin_roles')->insertGetId([
|
||||
'slug' => 'rpt_mgr_'.$admin->id,
|
||||
'code' => 'rpt_mgr_'.$admin->id,
|
||||
'name' => 'Report Manager',
|
||||
'scope_type' => 'system',
|
||||
'status' => 1,
|
||||
'is_system' => false,
|
||||
'sort_order' => 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$codes = ['agent.node.view', 'agent.node.manage', 'service.players.view', 'service.report.view'];
|
||||
$actionIds = DB::table('admin_menu_actions')->whereIn('permission_code', $codes)->pluck('id');
|
||||
foreach ($actionIds as $actionId) {
|
||||
DB::table('admin_role_menu_actions')->insert([
|
||||
'role_id' => $roleId,
|
||||
'menu_action_id' => (int) $actionId,
|
||||
]);
|
||||
}
|
||||
|
||||
$siteId = (int) $agent->admin_site_id;
|
||||
DB::table('admin_user_site_roles')->insert([
|
||||
'admin_user_id' => $admin->id,
|
||||
'site_id' => $siteId,
|
||||
'role_id' => $roleId,
|
||||
'granted_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('admin_user_agents')->insert([
|
||||
'admin_user_id' => $admin->id,
|
||||
'agent_node_id' => (int) $agent->id,
|
||||
'is_primary' => true,
|
||||
'granted_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('admin_user_agent_roles')->insert([
|
||||
'admin_user_id' => $admin->id,
|
||||
'agent_node_id' => (int) $agent->id,
|
||||
'role_id' => $roleId,
|
||||
'granted_at' => $now,
|
||||
]);
|
||||
}
|
||||
103
tests/Feature/AdminSettingBatchUpdateTest.php
Normal file
103
tests/Feature/AdminSettingBatchUpdateTest.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\LotterySetting;
|
||||
use App\Services\LotterySettings;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function settingsAdminToken(): string
|
||||
{
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'settings_editor',
|
||||
'name' => 'Settings Editor',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$role = AdminRole::query()->create([
|
||||
'slug' => 'settings_role',
|
||||
'name' => 'Settings Role',
|
||||
]);
|
||||
$role->syncLegacyPermissionSlugs(['prd.payout.manage']);
|
||||
|
||||
$admin->roles()->sync([
|
||||
(int) $role->id => [
|
||||
'site_id' => AdminUser::defaultAdminSiteId(),
|
||||
'granted_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
}
|
||||
|
||||
test('admin can batch update settings in one request', function (): void {
|
||||
LotterySettings::put('draw.interval_minutes', 5, 'draw');
|
||||
LotterySettings::put('draw.cooldown_minutes', 15, 'draw');
|
||||
|
||||
$token = settingsAdminToken();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/settings/batch', [
|
||||
'items' => [
|
||||
['key' => 'draw.interval_minutes', 'value' => 10],
|
||||
['key' => 'draw.cooldown_minutes', 'value' => 20],
|
||||
],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.items.0.key', 'draw.cooldown_minutes');
|
||||
|
||||
expect(LotterySetting::query()->where('setting_key', 'draw.interval_minutes')->value('value_json'))->toBe(10)
|
||||
->and(LotterySetting::query()->where('setting_key', 'draw.cooldown_minutes')->value('value_json'))->toBe(20);
|
||||
});
|
||||
|
||||
test('admin settings batch update rejects empty items', function (): void {
|
||||
$token = settingsAdminToken();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/settings/batch', ['items' => []])
|
||||
->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('admin can batch update settings with false and empty string values', function (): void {
|
||||
LotterySettings::put('settlement.auto_run_on_tick', true, 'settlement');
|
||||
LotterySettings::put('frontend.play_rules_html_zh', '<div>old</div>', 'frontend');
|
||||
|
||||
$token = settingsAdminToken();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/settings/batch', [
|
||||
'items' => [
|
||||
['key' => 'settlement.auto_run_on_tick', 'value' => false],
|
||||
['key' => 'frontend.play_rules_html_zh', 'value' => ''],
|
||||
],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.items.0.key', 'frontend.play_rules_html_zh')
|
||||
->assertJsonPath('data.items.0.value', '')
|
||||
->assertJsonPath('data.items.1.key', 'settlement.auto_run_on_tick')
|
||||
->assertJsonPath('data.items.1.value', false);
|
||||
|
||||
expect(LotterySetting::query()->where('setting_key', 'settlement.auto_run_on_tick')->value('value_json'))->toBeFalse()
|
||||
->and(LotterySetting::query()->where('setting_key', 'frontend.play_rules_html_zh')->value('value_json'))->toBe('');
|
||||
});
|
||||
|
||||
test('admin can update single setting with false value', function (): void {
|
||||
LotterySettings::put('settlement.apply_rebate_to_payout', true, 'settlement');
|
||||
|
||||
$token = settingsAdminToken();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/settings/settlement.apply_rebate_to_payout', [
|
||||
'value' => false,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.key', 'settlement.apply_rebate_to_payout')
|
||||
->assertJsonPath('data.value', false);
|
||||
|
||||
expect(LotterySetting::query()->where('setting_key', 'settlement.apply_rebate_to_payout')->value('value_json'))->toBeFalse();
|
||||
});
|
||||
@@ -148,30 +148,32 @@ test('permission catalog groups permissions by admin navigation order', function
|
||||
|
||||
expect(array_column($groups, 'key'))->toBe([
|
||||
'dashboard',
|
||||
'agents',
|
||||
'draws',
|
||||
'tickets',
|
||||
'players',
|
||||
'settlement',
|
||||
'wallet',
|
||||
'reconcile',
|
||||
'reports',
|
||||
'rules_plays',
|
||||
'rules_odds',
|
||||
'jackpot',
|
||||
'risk_cap',
|
||||
'wallet',
|
||||
'settlement',
|
||||
'reconcile',
|
||||
'reports',
|
||||
'currencies',
|
||||
'integration',
|
||||
'currencies',
|
||||
'admin_users',
|
||||
'admin_roles',
|
||||
'risk',
|
||||
'audit',
|
||||
'settings',
|
||||
'risk',
|
||||
]);
|
||||
expect($groups[1]['key'])->toBe('draws');
|
||||
expect($groups[14]['label'])->toBe('管理列表');
|
||||
expect(array_column($groups[14]['permissions'], 'slug'))->toBe(['prd.admin_user.manage']);
|
||||
expect($groups[15]['label'])->toBe('角色管理');
|
||||
expect(array_column($groups[15]['permissions'], 'slug'))->toBe(['prd.admin_role.manage']);
|
||||
expect($groups[1]['key'])->toBe('agents');
|
||||
expect($groups[2]['key'])->toBe('draws');
|
||||
expect($groups[15]['label'])->toBe('管理列表');
|
||||
expect(array_column($groups[15]['permissions'], 'slug'))->toBe(['prd.admin_user.manage']);
|
||||
expect($groups[16]['label'])->toBe('角色管理');
|
||||
expect(array_column($groups[16]['permissions'], 'slug'))->toBe(['prd.admin_role.manage']);
|
||||
|
||||
$groupsByKey = collect($groups)->keyBy('key');
|
||||
expect(array_column($groupsByKey['tickets']['permissions'], 'slug'))->toBe([
|
||||
|
||||
@@ -71,3 +71,16 @@ function grantSuperAdminRole(AdminUser $admin): void
|
||||
['granted_at' => $now],
|
||||
);
|
||||
}
|
||||
|
||||
/** 为后台测试账号挂上代理节点(需已存在 agent_nodes / admin_user_agents 表)。 */
|
||||
function bindAdminUserToAgent(AdminUser $admin, int $agentNodeId): void
|
||||
{
|
||||
DB::table('admin_user_agents')->updateOrInsert(
|
||||
['admin_user_id' => $admin->id],
|
||||
[
|
||||
'agent_node_id' => $agentNodeId,
|
||||
'is_primary' => true,
|
||||
'granted_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user