feat: 增强管理员权限与角色管理功能

- 在 SyncAdminAuthorizationCommand 中新增对代理和抽奖菜单操作的同步功能,确保缺失的菜单操作行能够被创建。
- 更新多个控制器中的权限检查逻辑,使用 hasPermissionCode 替代原有的权限验证方式,提升权限管理的灵活性。
- 引入 ApiMessage 统一错误响应格式,确保在权限不足时返回一致的错误信息。
- 更新 AdminRole 和 AdminUser 模型,增强角色与用户的权限管理功能,支持更细粒度的权限控制。
This commit is contained in:
2026-06-03 10:56:36 +08:00
parent 1dcd4716c5
commit 0527c7c392
96 changed files with 2215 additions and 139 deletions

View File

@@ -5,7 +5,9 @@ namespace App\Console\Commands;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Support\AdminAgentPermissionMenuActionSync;
use App\Support\AdminAuthorizationRegistry; use App\Support\AdminAuthorizationRegistry;
use App\Support\AdminDrawPermissionMenuActionSync;
final class SyncAdminAuthorizationCommand extends Command final class SyncAdminAuthorizationCommand extends Command
{ {
@@ -17,6 +19,16 @@ final class SyncAdminAuthorizationCommand extends Command
public function handle(): int public function handle(): int
{ {
$now = Carbon::now(); $now = Carbon::now();
$agentCreated = AdminAgentPermissionMenuActionSync::syncMissing();
if ($agentCreated > 0) {
$this->info(sprintf('Created %d missing agent menu_action row(s).', $agentCreated));
}
$drawCreated = AdminDrawPermissionMenuActionSync::syncMissing();
if ($drawCreated > 0) {
$this->info(sprintf('Created %d missing draw menu_action row(s).', $drawCreated));
}
$menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code'); $menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code');
foreach (AdminAuthorizationRegistry::resources() as $resource) { foreach (AdminAuthorizationRegistry::resources() as $resource) {

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Lottery\ErrorCode;
use App\Models\AdminUser;
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\AgentAdminUserService;
use App\Support\AdminUserApiPresenter;
use App\Support\AgentAdminUserAuthorization;
final class AgentAdminUserDestroyController extends Controller
{
public function __invoke(
\Illuminate\Http\Request $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 = AgentAdminUserAuthorization::denyUnlessUserManageable($admin, $admin_user);
if ($denied !== null) {
return $denied;
}
if ((int) $admin->id === (int) $admin_user->id) {
return ApiMessage::errorResponse(
$request,
'admin.user_cannot_delete_self',
ErrorCode::ValidationFailed->value,
null,
422,
);
}
$before = AdminUserApiPresenter::listItem($admin_user);
$id = (int) $admin_user->id;
$service->destroyUnderAgent($agent, $admin_user);
AuditLogger::recordForAdmin(
$admin,
$request,
'agent',
'agent_admin_user.destroy',
'admin_user',
(string) $id,
$before,
null,
);
return ApiResponse::success(['deleted' => true, 'id' => $id]);
}
}

View File

@@ -8,8 +8,10 @@ use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Services\Agent\AgentAdminUserService; use App\Services\Agent\AgentAdminUserService;
use App\Lottery\ErrorCode;
use App\Support\AdminAgentNodeAccess; use App\Support\AdminAgentNodeAccess;
use App\Support\AdminUserApiPresenter; use App\Support\AdminUserApiPresenter;
use App\Support\ApiMessage;
use App\Http\Requests\Admin\AgentAdminUserRoleSyncRequest; use App\Http\Requests\Admin\AgentAdminUserRoleSyncRequest;
final class AgentAdminUserRoleSyncController extends Controller final class AgentAdminUserRoleSyncController extends Controller
@@ -32,8 +34,14 @@ final class AgentAdminUserRoleSyncController extends Controller
return $denied; return $denied;
} }
if (! $admin->isSuperAdmin() && ! $admin->hasPermissionCode('agent.node.manage')) { if (! $admin->isSuperAdmin() && ! $admin->hasPermissionCode('agent.user.manage')) {
return AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $agent); return ApiMessage::errorResponse(
$request,
'admin.agent_user_manage_denied',
ErrorCode::AdminForbidden->value,
null,
403,
);
} }
$before = AdminUserApiPresenter::listItem($admin_user); $before = AdminUserApiPresenter::listItem($admin_user);

View File

@@ -8,8 +8,10 @@ use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Services\Agent\AgentAdminUserService; use App\Services\Agent\AgentAdminUserService;
use App\Lottery\ErrorCode;
use App\Support\AdminAgentNodeAccess; use App\Support\AdminAgentNodeAccess;
use App\Support\AdminUserApiPresenter; use App\Support\AdminUserApiPresenter;
use App\Support\ApiMessage;
use App\Http\Requests\Admin\AgentAdminUserStoreRequest; use App\Http\Requests\Admin\AgentAdminUserStoreRequest;
final class AgentNodeAdminUserStoreController extends Controller final class AgentNodeAdminUserStoreController extends Controller
@@ -27,8 +29,14 @@ final class AgentNodeAdminUserStoreController extends Controller
return $denied; return $denied;
} }
if (! $admin->isSuperAdmin() && ! $admin->hasPermissionCode('agent.node.manage')) { if (! $admin->isSuperAdmin() && ! $admin->hasPermissionCode('agent.user.manage')) {
return AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $agent_node); return ApiMessage::errorResponse(
$request,
'admin.agent_user_manage_denied',
ErrorCode::AdminForbidden->value,
null,
403,
);
} }
$user = $service->createUnderAgent($agent_node, $request->validated()); $user = $service->createUnderAgent($agent_node, $request->validated());

View File

@@ -47,7 +47,7 @@ final class AgentNodeDestroyController extends Controller
return ApiMessage::errorResponse($request, 'admin.agent_node_has_users_cannot_delete', ErrorCode::ValidationFailed->value, null, 422); 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()) { if ($service->hasBlockingCustomRoles($agent_node)) {
return ApiMessage::errorResponse($request, 'admin.agent_node_has_roles_cannot_delete', ErrorCode::ValidationFailed->value, null, 422); return ApiMessage::errorResponse($request, 'admin.agent_node_has_roles_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);
} }

View File

@@ -8,8 +8,10 @@ use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Services\Agent\AgentRoleService; use App\Services\Agent\AgentRoleService;
use App\Lottery\ErrorCode;
use App\Support\AdminAgentNodeAccess; use App\Support\AdminAgentNodeAccess;
use App\Support\AdminRoleApiPresenter; use App\Support\AdminRoleApiPresenter;
use App\Support\ApiMessage;
use App\Http\Requests\Admin\AgentRoleStoreRequest; use App\Http\Requests\Admin\AgentRoleStoreRequest;
final class AgentNodeRoleStoreController extends Controller final class AgentNodeRoleStoreController extends Controller
@@ -27,8 +29,14 @@ final class AgentNodeRoleStoreController extends Controller
return $denied; return $denied;
} }
if (! $admin->isSuperAdmin() && ! $admin->hasPermissionCode('agent.node.manage')) { if (! $admin->isSuperAdmin() && ! $admin->hasPermissionCode('agent.role.manage')) {
return AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $agent_node); return ApiMessage::errorResponse(
$request,
'admin.agent_role_manage_denied',
ErrorCode::AdminForbidden->value,
null,
403,
);
} }
$role = $service->createForAgent($admin, $agent_node, $request->validated()); $role = $service->createForAgent($admin, $agent_node, $request->validated());

View File

@@ -3,9 +3,9 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class AdminCurrencyStoreRequest extends FormRequest final class AdminCurrencyStoreRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,9 +2,9 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class AdminCurrencyUpdateRequest extends FormRequest final class AdminCurrencyUpdateRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,12 +2,12 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
/** /**
* @see \App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteConnectivityTestController * @see \App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteConnectivityTestController
*/ */
final class AdminIntegrationSiteConnectivityTestRequest extends FormRequest final class AdminIntegrationSiteConnectivityTestRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,11 +2,11 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use App\Rules\WalletApiUrlRule; use App\Rules\WalletApiUrlRule;
final class AdminIntegrationSiteStoreRequest extends FormRequest final class AdminIntegrationSiteStoreRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,10 +2,10 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
use App\Rules\WalletApiUrlRule; use App\Rules\WalletApiUrlRule;
final class AdminIntegrationSiteUpdateRequest extends FormRequest final class AdminIntegrationSiteUpdateRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,14 +2,14 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
/** /**
* 管理员登录请求。 * 管理员登录请求。
* *
* @see LoginController * @see LoginController
*/ */
final class AdminLoginRequest extends FormRequest final class AdminLoginRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
@@ -29,16 +29,4 @@ final class AdminLoginRequest extends FormRequest
]; ];
} }
/**
* @return array<string, string>
*/
public function attributes(): array
{
return [
'account' => 'account',
'password' => 'password',
'captcha_key' => 'captcha_key',
'captcha_code' => 'captcha_code',
];
}
} }

View File

@@ -2,14 +2,14 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
/** /**
* 玩家列表查询请求。 * 玩家列表查询请求。
* *
* @see AdminPlayerIndexController * @see AdminPlayerIndexController
*/ */
final class AdminPlayerIndexRequest extends FormRequest final class AdminPlayerIndexRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -3,14 +3,14 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
/** /**
* 玩家创建请求。 * 玩家创建请求。
* *
* @see AdminPlayerStoreController * @see AdminPlayerStoreController
*/ */
final class AdminPlayerStoreRequest extends FormRequest final class AdminPlayerStoreRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,14 +2,14 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
/** /**
* 管理员查看玩家注单列表请求。 * 管理员查看玩家注单列表请求。
* *
* @see AdminPlayerTicketItemsIndexController * @see AdminPlayerTicketItemsIndexController
*/ */
final class AdminPlayerTicketItemsRequest extends FormRequest final class AdminPlayerTicketItemsRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -3,14 +3,14 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
/** /**
* 玩家更新请求。 * 玩家更新请求。
* *
* @see AdminPlayerUpdateController * @see AdminPlayerUpdateController
*/ */
final class AdminPlayerUpdateRequest extends FormRequest final class AdminPlayerUpdateRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,9 +2,9 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class AdminReportQueryRequest extends FormRequest final class AdminReportQueryRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,9 +2,9 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class AdminRolePermissionSyncRequest extends FormRequest final class AdminRolePermissionSyncRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,9 +2,9 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class AdminRoleStoreRequest extends FormRequest final class AdminRoleStoreRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -3,9 +3,9 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class AdminRoleUpdateRequest extends FormRequest final class AdminRoleUpdateRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -3,9 +3,9 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use App\Models\AdminUser; use App\Models\AdminUser;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class AdminSettingBatchUpdateRequest extends FormRequest final class AdminSettingBatchUpdateRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,9 +2,9 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class AdminSettingIndexRequest extends FormRequest final class AdminSettingIndexRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -3,9 +3,9 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use App\Models\AdminUser; use App\Models\AdminUser;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class AdminSettingUpdateRequest extends FormRequest final class AdminSettingUpdateRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,14 +2,14 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
/** /**
* 管理员用户权限同步请求。 * 管理员用户权限同步请求。
* *
* @see AdminUserPermissionSyncController * @see AdminUserPermissionSyncController
*/ */
final class AdminUserPermissionSyncRequest extends FormRequest final class AdminUserPermissionSyncRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,14 +2,14 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
/** /**
* 管理员用户角色同步请求。 * 管理员用户角色同步请求。
* *
* @see AdminUserRoleSyncController * @see AdminUserRoleSyncController
*/ */
final class AdminUserRoleSyncRequest extends FormRequest final class AdminUserRoleSyncRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -3,14 +3,14 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
/** /**
* 管理员用户创建请求。 * 管理员用户创建请求。
* *
* @see AdminUserStoreController * @see AdminUserStoreController
*/ */
final class AdminUserStoreRequest extends FormRequest final class AdminUserStoreRequest extends ApiFormRequest
{ {
/** /**
* Determine if the user is authorized to make this request. * Determine if the user is authorized to make this request.

View File

@@ -3,14 +3,14 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
/** /**
* 管理员用户更新请求。 * 管理员用户更新请求。
* *
* @see AdminUserUpdateController * @see AdminUserUpdateController
*/ */
final class AdminUserUpdateRequest extends FormRequest final class AdminUserUpdateRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,9 +2,9 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class AgentAdminUserRoleSyncRequest extends FormRequest final class AgentAdminUserRoleSyncRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,10 +2,10 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
final class AgentAdminUserStoreRequest extends FormRequest final class AgentAdminUserStoreRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,9 +2,9 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class AgentDelegationGrantSyncRequest extends FormRequest final class AgentDelegationGrantSyncRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,9 +2,9 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class AgentNodeStoreRequest extends FormRequest final class AgentNodeStoreRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,9 +2,9 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class AgentNodeUpdateRequest extends FormRequest final class AgentNodeUpdateRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,9 +2,9 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class AgentRolePermissionSyncRequest extends FormRequest final class AgentRolePermissionSyncRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -3,10 +3,10 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use App\Models\AdminRole; use App\Models\AdminRole;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
final class AgentRoleStoreRequest extends FormRequest final class AgentRoleStoreRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,9 +2,9 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class AgentRoleUpdateRequest extends FormRequest final class AgentRoleUpdateRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,10 +2,10 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
final class DashboardAnalyticsRequest extends FormRequest final class DashboardAnalyticsRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -3,10 +3,10 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
use App\Services\Draw\DrawPrizeLayout; use App\Services\Draw\DrawPrizeLayout;
final class DrawManualResultBatchStoreRequest extends FormRequest final class DrawManualResultBatchStoreRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
@@ -33,7 +33,7 @@ final class DrawManualResultBatchStoreRequest extends FormRequest
sort($actual); sort($actual);
if ($actual !== $expected) { if ($actual !== $expected) {
$validator->errors()->add('items', 'items must contain the complete 23 draw prize slots.'); $validator->errors()->add('items', __('validation.exact.items_must_contain_23_slots'));
} }
}); });
} }

View File

@@ -2,9 +2,9 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class DrawReopenRequest extends FormRequest final class DrawReopenRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,9 +2,9 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class DrawStoreRequest extends FormRequest final class DrawStoreRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,9 +2,9 @@
namespace App\Http\Requests\Admin\Jackpot; namespace App\Http\Requests\Admin\Jackpot;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class AdminJackpotPoolAdjustRequest extends FormRequest final class AdminJackpotPoolAdjustRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,14 +2,14 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
/** /**
* 对账任务创建请求。 * 对账任务创建请求。
* *
* @see ReconcileJobStoreController * @see ReconcileJobStoreController
*/ */
final class ReconcileJobStoreRequest extends FormRequest final class ReconcileJobStoreRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,11 +2,11 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
/** @see ReportJobStoreController */ /** @see ReportJobStoreController */
final class ReportJobStoreRequest extends FormRequest final class ReportJobStoreRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,9 +2,9 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class SettlementBatchReviewRequest extends FormRequest final class SettlementBatchReviewRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,9 +2,9 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class SettlementPayoutAdjustmentRequest extends FormRequest final class SettlementPayoutAdjustmentRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,14 +2,14 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
/** /**
* 管理员注单列表查询请求。 * 管理员注单列表查询请求。
* *
* @see AdminTicketItemIndexController * @see AdminTicketItemIndexController
*/ */
final class TicketItemListRequest extends FormRequest final class TicketItemListRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,14 +2,14 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
/** /**
* 转账单列表查询请求。 * 转账单列表查询请求。
* *
* @see TransferOrderListController * @see TransferOrderListController
*/ */
final class TransferOrderListRequest extends FormRequest final class TransferOrderListRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,9 +2,9 @@
namespace App\Http\Requests\Admin\Wallet; namespace App\Http\Requests\Admin\Wallet;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class TransferOrderCompleteCreditRequest extends FormRequest final class TransferOrderCompleteCreditRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,9 +2,9 @@
namespace App\Http\Requests\Admin\Wallet; namespace App\Http\Requests\Admin\Wallet;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class TransferOrderManuallyProcessRequest extends FormRequest final class TransferOrderManuallyProcessRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,9 +2,9 @@
namespace App\Http\Requests\Admin\Wallet; namespace App\Http\Requests\Admin\Wallet;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
final class TransferOrderReverseRequest extends FormRequest final class TransferOrderReverseRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,14 +2,14 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
/** /**
* 钱包流水列表查询请求。 * 钱包流水列表查询请求。
* *
* @see WalletTransactionListController * @see WalletTransactionListController
*/ */
final class WalletTransactionListRequest extends FormRequest final class WalletTransactionListRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* API 表单校验基类:字段中文名等从 lang/validation.php attributes 读取。
*/
abstract class ApiFormRequest extends FormRequest
{
/**
* @return array<string, string>
*/
public function attributes(): array
{
$locale = $this->request->attributes->get('lottery_locale')
?? config('lottery.locales.fallback', 'en');
$labels = trans('validation.attributes', [], (string) $locale);
return is_array($labels) ? $labels : [];
}
}

View File

@@ -2,9 +2,9 @@
namespace App\Http\Requests\Ticket; namespace App\Http\Requests\Ticket;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
abstract class TicketBetRequest extends FormRequest abstract class TicketBetRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -2,12 +2,12 @@
namespace App\Http\Requests\Wallet; namespace App\Http\Requests\Wallet;
use Illuminate\Foundation\Http\FormRequest; use App\Http\Requests\ApiFormRequest;
/** /**
* 转入 / 转出共用请求体:最小货币单位金额、幂等键、可选币种。 * 转入 / 转出共用请求体:最小货币单位金额、幂等键、可选币种。
*/ */
final class WalletTransferRequest extends FormRequest final class WalletTransferRequest extends ApiFormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -4,7 +4,9 @@ namespace App\Models;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Support\AdminPermissionBridge; use App\Support\AdminPermissionBridge;
use App\Support\AdminPermissionInheritance;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\ValidationException;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
final class AdminRole extends Model final class AdminRole extends Model
@@ -106,7 +108,9 @@ final class AdminRole extends Model
*/ */
public function syncLegacyPermissionSlugs(array $slugs): void public function syncLegacyPermissionSlugs(array $slugs): void
{ {
$legacySlugs = AdminPermissionBridge::normalizeCanonicalLegacySlugs($slugs); $legacySlugs = AdminPermissionInheritance::expand(
AdminPermissionBridge::normalizeCanonicalLegacySlugs($slugs),
);
$codes = []; $codes = [];
foreach ($legacySlugs as $slug) { foreach ($legacySlugs as $slug) {
@@ -127,6 +131,17 @@ final class AdminRole extends Model
'menu_action_id' => (int) $mid, 'menu_action_id' => (int) $mid,
]); ]);
} }
$granted = $this->legacyPermissionSlugs();
$missing = array_values(array_diff($legacySlugs, $granted));
if ($missing !== []) {
throw ValidationException::withMessages([
'permission_slugs' => [
'permission_catalog_incomplete: '.implode(', ', $missing)
.' (run: php artisan migrate && php artisan lottery:admin-auth-sync --audit)',
],
]);
}
} }
public function assignedUserCount(): int public function assignedUserCount(): int

View File

@@ -128,7 +128,10 @@ final class AdminUser extends Authenticatable
{ {
$agentId = $this->primaryAgentNodeId(); $agentId = $this->primaryAgentNodeId();
if ($agentId !== null) { if ($agentId !== null) {
return $this->agentRoleMenuActionPermissionCodes($agentId); $fromAgent = $this->agentRoleMenuActionPermissionCodes($agentId);
if ($fromAgent !== []) {
return $fromAgent;
}
} }
return $this->siteRoleMenuActionPermissionCodes(); return $this->siteRoleMenuActionPermissionCodes();
@@ -197,6 +200,11 @@ final class AdminUser extends Authenticatable
'granted_at' => $now, 'granted_at' => $now,
]); ]);
} }
$agentId = $this->primaryAgentNodeId();
if ($agentId !== null) {
$this->syncAgentRoleIds($agentId, array_map('intval', $roleIds));
}
}); });
} }

View File

@@ -14,7 +14,7 @@ final class WalletApiUrlRule implements Rule
public function message(): string public function message(): string
{ {
return 'wallet_api_url 必须是 https 的公开域名根地址,并拒绝 localhost/内网 IP 与带路径/查询的地址。'; return (string) __('validation.custom.wallet_api_url.wallet_api_url');
} }
} }

View File

@@ -82,4 +82,31 @@ final class AgentAdminUserService
throw ValidationException::withMessages(['role_ids' => ['invalid_for_agent']]); throw ValidationException::withMessages(['role_ids' => ['invalid_for_agent']]);
} }
} }
public function destroyUnderAgent(AgentNode $agent, AdminUser $user): void
{
if ((int) $user->primaryAgentNodeId() !== (int) $agent->id) {
throw ValidationException::withMessages(['user' => ['agent_mismatch']]);
}
DB::transaction(static function () use ($agent, $user): void {
DB::table('admin_user_agent_roles')
->where('admin_user_id', $user->id)
->where('agent_node_id', $agent->id)
->delete();
DB::table('admin_user_agents')
->where('admin_user_id', $user->id)
->where('agent_node_id', $agent->id)
->delete();
$siteId = (int) $agent->admin_site_id;
DB::table('admin_user_site_roles')
->where('admin_user_id', $user->id)
->where('site_id', $siteId)
->delete();
$user->delete();
});
}
} }

View File

@@ -2,6 +2,7 @@
namespace App\Services\Agent; namespace App\Services\Agent;
use App\Models\AdminRole;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Models\AgentNode; use App\Models\AgentNode;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -76,7 +77,20 @@ final class AgentNodeService
public function destroy(AgentNode $node): void public function destroy(AgentNode $node): void
{ {
DB::transaction(static function () use ($node): void { DB::transaction(static function () use ($node): void {
AdminRole::query()
->where('owner_agent_id', $node->id)
->whereNotNull('delegated_from_role_id')
->each(static fn (AdminRole $role): bool => (bool) $role->delete());
$node->delete(); $node->delete();
}); });
} }
public function hasBlockingCustomRoles(AgentNode $node): bool
{
return AdminRole::query()
->where('owner_agent_id', $node->id)
->whereNull('delegated_from_role_id')
->exists();
}
} }

View File

@@ -100,7 +100,11 @@ final class AgentRoleService
} }
if ($role->assignedUserCount() > 0) { if ($role->assignedUserCount() > 0) {
throw ValidationException::withMessages(['role' => ['in_use']]); throw ValidationException::withMessages([
'role' => [
__('admin.agent_role_in_use', ['count' => $role->assignedUserCount()]),
],
]);
} }
$role->delete(); $role->delete();

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Support;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* 确保代理「角色 / 账号」拆分后的 menu_action 行存在migrate 未跑或漏跑时 auth-sync 可补救)。
*/
final class AdminAgentPermissionMenuActionSync
{
public static function syncMissing(): int
{
$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 0;
}
$agentMenuId = (int) DB::table('admin_menus')->where('code', 'system.agents')->value('id');
if ($agentMenuId === 0) {
return 0;
}
$rolesMenuId = self::ensureChildMenu($agentMenuId, 'system.agents.roles', '代理角色', $now);
$usersMenuId = self::ensureChildMenu($agentMenuId, 'system.agents.users', '代理账号', $now);
$created = 0;
$created += self::ensureMenuAction((int) $rolesMenuId, (int) $viewActionId, 'agent.role.view', '代理角色查看', $now) ? 1 : 0;
$created += self::ensureMenuAction((int) $rolesMenuId, (int) $manageActionId, 'agent.role.manage', '代理角色管理', $now) ? 1 : 0;
$created += self::ensureMenuAction((int) $usersMenuId, (int) $viewActionId, 'agent.user.view', '代理账号查看', $now) ? 1 : 0;
$created += self::ensureMenuAction((int) $usersMenuId, (int) $manageActionId, 'agent.user.manage', '代理账号管理', $now) ? 1 : 0;
return $created;
}
private static function ensureChildMenu(int $parentId, string $code, string $name, Carbon $now): int
{
$existing = DB::table('admin_menus')->where('code', $code)->value('id');
if ($existing !== null) {
return (int) $existing;
}
return (int) DB::table('admin_menus')->insertGetId([
'parent_id' => $parentId,
'menu_type' => 'button',
'code' => $code,
'name' => $name,
'path' => null,
'route_name' => null,
'component' => null,
'icon' => null,
'active_menu_code' => 'system.agents',
'sort_order' => 0,
'is_visible' => false,
'is_cache' => false,
'is_external' => false,
'status' => 1,
'meta_json' => null,
'created_at' => $now,
'updated_at' => $now,
]);
}
private static function ensureMenuAction(int $menuId, int $actionId, string $permissionCode, string $name, Carbon $now): bool
{
if (DB::table('admin_menu_actions')->where('permission_code', $permissionCode)->exists()) {
return false;
}
DB::table('admin_menu_actions')->insert([
'menu_id' => $menuId,
'action_id' => $actionId,
'permission_code' => $permissionCode,
'name' => $name,
'status' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
return true;
}
}

View File

@@ -29,10 +29,10 @@ final class AdminAuthorizationRegistry
['slug' => 'prd.agent.view', 'name' => '代理管理·查看', 'nav_segment' => 'agents', 'permission_codes' => ['agent.node.view']], ['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.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.view', 'name' => '代理角色·查看', 'nav_segment' => 'agents', 'permission_codes' => ['agent.role.view', 'agent.node.view']],
['slug' => 'prd.agent.role.manage', 'name' => '代理角色·可管理', 'nav_segment' => 'agents', 'permission_codes' => ['agent.node.manage']], ['slug' => 'prd.agent.role.manage', 'name' => '代理角色·可管理', 'nav_segment' => 'agents', 'permission_codes' => ['agent.role.manage']],
['slug' => 'prd.agent.user.view', 'name' => '代理账号·查看', 'nav_segment' => 'agents', 'permission_codes' => ['agent.node.view']], ['slug' => 'prd.agent.user.view', 'name' => '代理账号·查看', 'nav_segment' => 'agents', 'permission_codes' => ['agent.user.view', 'agent.node.view']],
['slug' => 'prd.agent.user.manage', 'name' => '代理账号·可管理', 'nav_segment' => 'agents', 'permission_codes' => ['agent.node.manage']], ['slug' => 'prd.agent.user.manage', 'name' => '代理账号·可管理', 'nav_segment' => 'agents', 'permission_codes' => ['agent.user.manage']],
['slug' => 'prd.users.manage', 'name' => '用户管理·可管理', 'nav_segment' => 'players', 'permission_codes' => ['service.players.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_finance', 'name' => '用户管理·财务查看', 'nav_segment' => 'players', 'permission_codes' => ['service.players.view', 'service.wallet.view']],
@@ -49,9 +49,9 @@ final class AdminAuthorizationRegistry
['slug' => 'prd.wallet_reconcile.view_cs', 'name' => '钱包对账·客服单用户', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.view', 'service.reconcile.view']], ['slug' => 'prd.wallet_reconcile.view_cs', 'name' => '钱包对账·客服单用户', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.view', 'service.reconcile.view']],
['slug' => 'prd.wallet_adjust.manage', 'name' => '补单/冲正·可管理', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.adjust']], ['slug' => 'prd.wallet_adjust.manage', 'name' => '补单/冲正·可管理', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.adjust']],
['slug' => 'prd.draw_result.manage', 'name' => '开奖结果录入·可管理', 'nav_segment' => 'draws', 'permission_codes' => ['draw.results.view', 'draw.review.review', 'draw.review.publish']], ['slug' => 'prd.draw_result.manage', 'name' => '开奖结果录入·可管理', 'nav_segment' => 'draws', 'permission_codes' => ['draw.review.review', 'draw.review.publish']],
['slug' => 'prd.draw_result.view', 'name' => '开奖结果·查看', 'nav_segment' => 'draws', 'permission_codes' => ['draw.results.view']], ['slug' => 'prd.draw_result.view', 'name' => '开奖结果·查看', 'nav_segment' => 'draws', 'permission_codes' => ['draw.results.view']],
['slug' => 'prd.draw_reopen.manage', 'name' => '开奖结果重开·可管理', 'nav_segment' => 'draws', 'permission_codes' => ['draw.review.publish']], ['slug' => 'prd.draw_reopen.manage', 'name' => '开奖结果重开·可管理', 'nav_segment' => 'draws', 'permission_codes' => ['draw.reopen.manage']],
['slug' => 'prd.risk.view', 'name' => '风控中心·查看', 'nav_segment' => 'risk', 'permission_codes' => ['risk.monitor.view']], ['slug' => 'prd.risk.view', 'name' => '风控中心·查看', 'nav_segment' => 'risk', 'permission_codes' => ['risk.monitor.view']],
['slug' => 'prd.risk.manage', 'name' => '风控中心·可管理', 'nav_segment' => 'risk', 'permission_codes' => ['risk.monitor.manage']], ['slug' => 'prd.risk.manage', 'name' => '风控中心·可管理', 'nav_segment' => 'risk', 'permission_codes' => ['risk.monitor.manage']],
@@ -386,22 +386,23 @@ 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.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.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.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', 'agent.role.view', 'agent.role.manage', 'agent.user.view', 'agent.user.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.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.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.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.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-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.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.role.view', 'agent.role.manage', '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.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.role.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.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.role.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.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.role.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-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.role.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.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.user.view', 'agent.user.manage', '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.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.user.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-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.user.manage']],
['code' => 'admin.agent-admin-users.destroy', 'module_code' => 'agent', 'name' => '删除代理账号', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/agent-admin-users/{admin_user}', 'route_name' => 'api.v1.admin.agent-admin-users.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.user.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.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.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']],

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Support;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/** 确保期号「重开」独立 permission_code 存在,避免与「管理」共用 publish 导致勾选无法拆分。 */
final class AdminDrawPermissionMenuActionSync
{
public static function syncMissing(): int
{
if (DB::table('admin_menu_actions')->where('permission_code', 'draw.reopen.manage')->exists()) {
return 0;
}
$menuId = (int) DB::table('admin_menus')->where('code', 'draw.results')->value('id');
$actionId = DB::table('admin_action_catalog')->where('code', 'manage')->value('id');
if ($menuId === 0 || $actionId === null) {
return 0;
}
$now = Carbon::now();
DB::table('admin_menu_actions')->insert([
'menu_id' => $menuId,
'action_id' => (int) $actionId,
'permission_code' => 'draw.reopen.manage',
'name' => '期号重开',
'status' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
return 1;
}
}

View File

@@ -9,8 +9,8 @@ final class AdminPermissionInheritance
*/ */
private const IMPLIED_BY_SLUG = [ private const IMPLIED_BY_SLUG = [
'prd.agent.manage' => ['prd.agent.view'], 'prd.agent.manage' => ['prd.agent.view'],
'prd.agent.role.manage' => ['prd.agent.role.view'], 'prd.agent.role.manage' => ['prd.agent.role.view', 'prd.agent.view'],
'prd.agent.user.manage' => ['prd.agent.user.view'], 'prd.agent.user.manage' => ['prd.agent.user.view', 'prd.agent.view'],
'prd.integration.manage' => ['prd.integration.view'], 'prd.integration.manage' => ['prd.integration.view'],
'prd.wallet_reconcile.manage' => ['prd.wallet_reconcile.view'], 'prd.wallet_reconcile.manage' => ['prd.wallet_reconcile.view'],
'prd.draw_result.manage' => ['prd.draw_result.view'], 'prd.draw_result.manage' => ['prd.draw_result.view'],

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Support;
use App\Models\AdminUser;
use App\Models\AgentNode;
final class AgentAdminUserAuthorization
{
public static function userVisibleTo(AdminUser $admin, AdminUser $target): bool
{
if ($admin->isSuperAdmin()) {
return true;
}
$agent = $target->primaryAgentNode();
if ($agent === null) {
return false;
}
return AdminAgentScope::nodeVisibleTo($admin, $agent);
}
public static function userManageableBy(AdminUser $admin, AdminUser $target): bool
{
if (! self::userVisibleTo($admin, $target)) {
return false;
}
if ($admin->isSuperAdmin()) {
return true;
}
if (! $admin->hasPermissionCode('agent.user.manage')) {
return false;
}
$agent = $target->primaryAgentNode();
return $agent !== null && AdminAgentScope::nodeManageableBy($admin, $agent);
}
public static function denyUnlessUserManageable(AdminUser $admin, AdminUser $target): ?\Illuminate\Http\JsonResponse
{
if (self::userManageableBy($admin, $target)) {
return null;
}
return ApiMessage::errorResponse(
request(),
'admin.agent_user_manage_denied',
\App\Lottery\ErrorCode::AdminForbidden->value,
null,
403,
);
}
}

View File

@@ -37,7 +37,7 @@ final class AgentRoleAuthorization
return false; return false;
} }
return $admin->isSuperAdmin() || $admin->hasPermissionCode('agent.node.manage'); return $admin->isSuperAdmin() || $admin->hasPermissionCode('agent.role.manage');
} }
/** /**

View File

@@ -0,0 +1,286 @@
<?php
namespace App\Support;
/**
* 将校验错误转为当前请求语言下可读文案,并生成适合 toast 的摘要 msg。
*/
final class ApiValidationErrors
{
/**
* @param array<string, array<int, string>|string> $errors
* @return array<string, list<string>>
*/
public static function normalize(array $errors, string $locale): array
{
$out = [];
foreach ($errors as $field => $messages) {
$fieldKey = (string) $field;
$normalized = [];
foreach ((array) $messages as $message) {
$normalized[] = self::present($fieldKey, (string) $message, $locale);
}
$out[$fieldKey] = $normalized;
}
return $out;
}
/**
* @param array<string, list<string>> $normalized
*/
public static function summary(array $normalized, int $maxParts = 3): ?string
{
$parts = [];
foreach ($normalized as $messages) {
foreach ($messages as $message) {
$parts[] = $message;
if (count($parts) >= $maxParts) {
return implode('', $parts).'…';
}
}
}
return $parts === [] ? null : implode('', $parts);
}
private static function present(string $field, string $message, string $locale): string
{
$trimmed = trim($message);
if ($trimmed === '') {
return $message;
}
if (preg_match('/\p{Han}/u', $trimmed) === 1) {
return $trimmed;
}
$exact = self::exactMessage($trimmed, $locale);
if ($exact !== null) {
return $exact;
}
if (preg_match(
'/^(exceeds_actor|exceeds_parent_ceiling|exceeds_delegation_ceiling|permission_exceeds_actor|permission_catalog_incomplete)\s*:\s*(.+)$/u',
$trimmed,
$matches,
) === 1) {
$detail = trim(preg_replace('/\s*\(run:.*$/i', '', $matches[2]) ?? $matches[2]);
$line = trans('validation.business.'.$matches[1], ['detail' => $detail], $locale);
if ($line !== 'validation.business.'.$matches[1]) {
return $line;
}
}
$attribute = self::attributeLabel($field, $locale);
$businessKey = 'validation.business.'.$trimmed;
$businessLine = trans($businessKey, ['attribute' => $attribute], $locale);
if ($businessLine !== $businessKey) {
return $businessLine;
}
$customLine = self::customRuleLine($field, $trimmed, $attribute, $locale);
if ($customLine !== null) {
return $customLine;
}
$ruleLine = self::standardRuleLine($trimmed, $attribute, $locale);
if ($ruleLine !== null) {
return $ruleLine;
}
$humanized = self::humanizeLaravelEnglish($field, $trimmed, $locale, $attribute);
if ($humanized !== null) {
return $humanized;
}
return $trimmed;
}
private static function exactMessage(string $message, string $locale): ?string
{
$map = trans('validation.exact', [], $locale);
if (! is_array($map)) {
return null;
}
return $map[$message] ?? null;
}
private static function customRuleLine(
string $field,
string $rule,
string $attribute,
string $locale,
): ?string {
$candidates = array_values(array_unique([
'validation.custom.'.$field.'.'.$rule,
'validation.custom.'.self::flatFieldName($field).'.'.$rule,
]));
foreach ($candidates as $key) {
$line = trans($key, ['attribute' => $attribute], $locale);
if ($line !== $key) {
return $line;
}
}
return null;
}
private static function standardRuleLine(string $rule, string $attribute, string $locale): ?string
{
if (! preg_match('/^[a-z0-9_.]+$/i', $rule)) {
return null;
}
$key = 'validation.'.$rule;
$line = trans($key, ['attribute' => $attribute], $locale);
return $line !== $key ? $line : null;
}
private static function humanizeLaravelEnglish(
string $field,
string $message,
string $locale,
string $attribute,
): ?string {
if (preg_match('/^The (.+?) field (.+)$/i', $message, $matches) !== 1) {
if (preg_match('/^The (.+?) has already been taken\.?$/i', $message, $taken) === 1) {
$attribute = self::attributeLabelFromEnglish($taken[1], $field, $locale);
return trans('validation.unique', ['attribute' => $attribute], $locale);
}
if (preg_match('/^The selected (.+?) is invalid\.?$/i', $message, $selected) === 1) {
$attribute = self::attributeLabelFromEnglish($selected[1], $field, $locale);
return trans('validation.exists', ['attribute' => $attribute], $locale);
}
return null;
}
$englishName = $matches[1];
$tail = $matches[2];
$attribute = self::attributeLabelFromEnglish($englishName, $field, $locale);
$tailMap = [
'is required' => 'validation.required',
'must be a valid email address' => 'validation.email',
'must be a valid UUID' => 'validation.uuid',
'must be an integer' => 'validation.integer',
'must be a string' => 'validation.string',
'must be a number' => 'validation.numeric',
'must be a valid JSON string' => 'validation.json',
'must be true or false' => 'validation.boolean',
'must be an array' => 'validation.array',
'format is invalid' => null,
'is not allowed' => 'validation.prohibited',
'must be present' => 'validation.present',
];
foreach ($tailMap as $suffix => $ruleKey) {
if (stripos($tail, $suffix) === false) {
continue;
}
if ($ruleKey === null) {
return self::customRuleLine($field, 'regex', $attribute, $locale)
?? trans('validation.regex', ['attribute' => $attribute], $locale);
}
$line = trans($ruleKey, ['attribute' => $attribute], $locale);
return $line !== $ruleKey ? $line : null;
}
if (preg_match('/must be at least (\d+) characters/i', $tail, $min)) {
$customKey = 'validation.custom.'.self::flatFieldName($field).'.min';
$customLine = trans($customKey, ['attribute' => $attribute, 'min' => $min[1]], $locale);
if ($customLine !== $customKey) {
return $customLine;
}
return trans('validation.min.string', ['attribute' => $attribute, 'min' => $min[1]], $locale);
}
if (preg_match('/must not be greater than (\d+) characters/i', $tail, $max)) {
return trans('validation.max.string', ['attribute' => $attribute, 'max' => $max[1]], $locale);
}
if (preg_match('/must contain (\d+) items/i', $tail, $size)) {
return trans('validation.size.array', ['attribute' => $attribute, 'size' => $size[1]], $locale);
}
if (preg_match('/must have at least (\d+) items/i', $tail, $minItems)) {
return trans('validation.min.array', ['attribute' => $attribute, 'min' => $minItems[1]], $locale);
}
if (preg_match('/must not have more than (\d+) items/i', $tail, $maxItems)) {
return trans('validation.max.array', ['attribute' => $attribute, 'max' => $maxItems[1]], $locale);
}
if (preg_match('/must be between ([\d.]+) and ([\d.]+)/i', $tail, $between)) {
return trans('validation.between.numeric', [
'attribute' => $attribute,
'min' => $between[1],
'max' => $between[2],
], $locale);
}
if (preg_match('/must match the format (.+)$/i', $tail, $format)) {
return trans('validation.date_format', [
'attribute' => $attribute,
'format' => trim($format[1]),
], $locale);
}
return null;
}
private static function attributeLabelFromEnglish(string $englishName, string $field, string $locale): string
{
$normalized = strtolower(trim($englishName));
if (preg_match('/^selected (.+)$/i', $normalized, $selected) === 1) {
$normalized = $selected[1];
}
$guess = str_replace(' ', '_', $normalized);
return self::attributeLabel($guess !== '' ? $guess : $field, $locale);
}
private static function attributeLabel(string $field, string $locale): string
{
$candidates = array_values(array_unique([
$field,
self::flatFieldName($field),
preg_replace('/\.\d+\./', '.*.', $field) ?? $field,
preg_replace('/\.\d+\./', '.', $field) ?? $field,
]));
foreach ($candidates as $candidate) {
$key = 'validation.attributes.'.$candidate;
$label = trans($key, [], $locale);
if ($label !== $key) {
return $label;
}
}
return self::flatFieldName($field);
}
private static function flatFieldName(string $field): string
{
if (preg_match('/\.([a-zA-Z0-9_]+)$/', $field, $matches) === 1) {
return $matches[1];
}
return $field;
}
}

View File

@@ -11,6 +11,7 @@
use App\Lottery\ErrorCode; use App\Lottery\ErrorCode;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use App\Support\ApiValidationErrors;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Support\LotteryLocale; use App\Support\LotteryLocale;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
@@ -83,10 +84,15 @@ return Application::configure(basePath: dirname(__DIR__))
return null; return null;
} }
$loc = $locale($request);
$errors = ApiValidationErrors::normalize($e->errors(), $loc);
$msg = ApiValidationErrors::summary($errors)
?? trans('api.validation_failed', [], $loc);
return ApiResponse::error( return ApiResponse::error(
trans('api.validation_failed', [], $locale($request)), $msg,
ErrorCode::ValidationFailed->value, ErrorCode::ValidationFailed->value,
['errors' => $e->errors()], ['errors' => $errors],
422, 422,
); );
}); });

View File

@@ -0,0 +1,177 @@
<?php
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use App\Support\AdminAuthorizationRegistry;
use Illuminate\Database\Migrations\Migration;
/**
* 代理「节点 / 角色 / 账号」查看与管理拆分为独立 permission_code避免只勾一项却获得全部管理能力。
*/
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;
}
$agentMenuId = (int) DB::table('admin_menus')->where('code', 'system.agents')->value('id');
if ($agentMenuId === 0) {
return;
}
$rolesMenuId = $this->ensureChildMenu($agentMenuId, 'system.agents.roles', '代理角色', $now);
$usersMenuId = $this->ensureChildMenu($agentMenuId, 'system.agents.users', '代理账号', $now);
$this->ensureMenuAction((int) $rolesMenuId, (int) $viewActionId, 'agent.role.view', '代理角色查看', $now);
$this->ensureMenuAction((int) $rolesMenuId, (int) $manageActionId, 'agent.role.manage', '代理角色管理', $now);
$this->ensureMenuAction((int) $usersMenuId, (int) $viewActionId, 'agent.user.view', '代理账号查看', $now);
$this->ensureMenuAction((int) $usersMenuId, (int) $manageActionId, 'agent.user.manage', '代理账号管理', $now);
$menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code');
$nodeViewId = $menuActionIds['agent.node.view'] ?? null;
$nodeManageId = $menuActionIds['agent.node.manage'] ?? null;
$roleViewId = $menuActionIds['agent.role.view'] ?? null;
$roleManageId = $menuActionIds['agent.role.manage'] ?? null;
$userViewId = $menuActionIds['agent.user.view'] ?? null;
$userManageId = $menuActionIds['agent.user.manage'] ?? null;
if ($nodeViewId !== null && $roleViewId !== null && $userViewId !== null) {
$roleIdsWithNodeView = DB::table('admin_role_menu_actions')
->where('menu_action_id', (int) $nodeViewId)
->pluck('role_id')
->unique()
->all();
foreach ($roleIdsWithNodeView as $roleId) {
foreach ([$roleViewId, $userViewId] as $actionId) {
$this->attachRoleMenuAction((int) $roleId, (int) $actionId, $now);
}
}
}
if ($nodeManageId !== null && $roleManageId !== null && $userManageId !== null) {
$roleIdsWithNodeManage = DB::table('admin_role_menu_actions')
->where('menu_action_id', (int) $nodeManageId)
->pluck('role_id')
->unique()
->all();
foreach ($roleIdsWithNodeManage as $roleId) {
foreach ([$roleManageId, $userManageId] as $actionId) {
$this->attachRoleMenuAction((int) $roleId, (int) $actionId, $now);
}
}
}
$resources = array_values(array_filter(
AdminAuthorizationRegistry::resources(),
static fn (array $resource): bool => str_starts_with((string) $resource['code'], 'admin.agent-')
));
foreach ($resources as $resource) {
$resourceId = DB::table('admin_api_resources')->where('code', $resource['code'])->value('id');
if ($resourceId === null) {
continue;
}
DB::table('admin_api_resource_bindings')
->where('api_resource_id', (int) $resourceId)
->delete();
foreach ($resource['permission_codes'] ?? [] as $permissionCode) {
$menuActionId = $menuActionIds[$permissionCode] ?? null;
if ($menuActionId === null) {
continue;
}
DB::table('admin_api_resource_bindings')->insert([
'api_resource_id' => (int) $resourceId,
'menu_action_id' => (int) $menuActionId,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
}
private function ensureChildMenu(int $parentId, string $code, string $name, Carbon $now): int
{
$existing = DB::table('admin_menus')->where('code', $code)->value('id');
if ($existing !== null) {
return (int) $existing;
}
return (int) DB::table('admin_menus')->insertGetId([
'parent_id' => $parentId,
'menu_type' => 'button',
'code' => $code,
'name' => $name,
'path' => null,
'route_name' => null,
'component' => null,
'icon' => null,
'active_menu_code' => 'system.agents',
'sort_order' => 0,
'is_visible' => false,
'is_cache' => false,
'is_external' => false,
'status' => 1,
'meta_json' => null,
'created_at' => $now,
'updated_at' => $now,
]);
}
private function ensureMenuAction(int $menuId, int $actionId, string $permissionCode, string $name, Carbon $now): void
{
if (DB::table('admin_menu_actions')->where('permission_code', $permissionCode)->exists()) {
return;
}
DB::table('admin_menu_actions')->insert([
'menu_id' => $menuId,
'action_id' => $actionId,
'permission_code' => $permissionCode,
'name' => $name,
'status' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
}
private function attachRoleMenuAction(int $roleId, int $menuActionId, Carbon $now): void
{
$exists = DB::table('admin_role_menu_actions')
->where('role_id', $roleId)
->where('menu_action_id', $menuActionId)
->exists();
if ($exists) {
return;
}
DB::table('admin_role_menu_actions')->insert([
'role_id' => $roleId,
'menu_action_id' => $menuActionId,
]);
}
public function down(): void
{
$codes = ['agent.role.view', 'agent.role.manage', 'agent.user.view', 'agent.user.manage'];
$actionIds = DB::table('admin_menu_actions')->whereIn('permission_code', $codes)->pluck('id')->all();
if ($actionIds !== []) {
DB::table('admin_role_menu_actions')->whereIn('menu_action_id', $actionIds)->delete();
DB::table('admin_api_resource_bindings')->whereIn('menu_action_id', $actionIds)->delete();
DB::table('admin_menu_actions')->whereIn('permission_code', $codes)->delete();
}
DB::table('admin_menus')->whereIn('code', ['system.agents.roles', 'system.agents.users'])->delete();
}
};

View File

@@ -0,0 +1,87 @@
<?php
use App\Support\AdminAuthorizationRegistry;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/** 补齐代理账号删除 API 资源admin.agent-admin-users.destroy。 */
return new class extends Migration
{
private const RESOURCE_CODE = 'admin.agent-admin-users.destroy';
public function up(): void
{
$resource = collect(AdminAuthorizationRegistry::resources())
->firstWhere('code', self::RESOURCE_CODE);
if ($resource === null) {
return;
}
$now = Carbon::now();
$menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code');
$resourceId = DB::table('admin_api_resources')
->where('code', self::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' => self::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,
]);
}
}
public function down(): void
{
$resourceId = DB::table('admin_api_resources')
->where('code', self::RESOURCE_CODE)
->value('id');
if ($resourceId === null) {
return;
}
DB::table('admin_api_resource_bindings')
->where('api_resource_id', (int) $resourceId)
->delete();
DB::table('admin_api_resources')->where('id', (int) $resourceId)->delete();
}
};

View File

@@ -5,6 +5,8 @@ namespace Database\Seeders;
use App\Models\AdminRole; use App\Models\AdminRole;
use App\Models\AdminUser; use App\Models\AdminUser;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use App\Support\AdminAgentPermissionMenuActionSync;
use App\Support\AdminDrawPermissionMenuActionSync;
use App\Support\AdminPermissionBridge; use App\Support\AdminPermissionBridge;
/** /**
@@ -28,6 +30,9 @@ final class AdminRbacAndUserSeeder extends Seeder
public function run(): void public function run(): void
{ {
AdminAgentPermissionMenuActionSync::syncMissing();
AdminDrawPermissionMenuActionSync::syncMissing();
$super = AdminRole::query()->updateOrCreate( $super = AdminRole::query()->updateOrCreate(
['slug' => AdminUser::ROLE_SUPER_ADMIN], ['slug' => AdminUser::ROLE_SUPER_ADMIN],
['code' => AdminUser::ROLE_SUPER_ADMIN, 'name' => '超级管理员'], ['code' => AdminUser::ROLE_SUPER_ADMIN, 'name' => '超级管理员'],

View File

@@ -23,6 +23,8 @@ return [
'agent_node_has_children_cannot_delete' => 'This agent node has child nodes. Delete children first.', '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_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.', 'agent_node_has_roles_cannot_delete' => 'This agent node still has bound roles and cannot be deleted.',
'agent_role_in_use' => 'This role is still assigned to :count account(s). Unbind them in Accounts before deleting.',
'agent_role_read_only' => 'Read-only template roles cannot be changed or deleted.',
'user_cannot_delete_self' => 'Cannot delete your own account.', 'user_cannot_delete_self' => 'Cannot delete your own account.',
'user_cannot_delete_last_super_admin' => 'Cannot delete the last super admin.', 'user_cannot_delete_last_super_admin' => 'Cannot delete the last super admin.',
'super_admin_only_for_roles' => 'Only super admins can manage roles.', 'super_admin_only_for_roles' => 'Only super admins can manage roles.',

11
lang/en/validation.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
return array_merge(
require __DIR__.'/validation_rules.php',
[
'attributes' => require __DIR__.'/validation_attributes.php',
'custom' => require __DIR__.'/validation_custom.php',
'business' => require __DIR__.'/validation_business.php',
'exact' => require __DIR__.'/validation_exact.php',
],
);

View File

@@ -0,0 +1,62 @@
<?php
return [
'account' => 'account',
'password' => 'password',
'captcha_key' => 'captcha',
'captcha_code' => 'captcha code',
'username' => 'username',
'nickname' => 'nickname',
'name' => 'name',
'code' => 'code',
'slug' => 'slug',
'status' => 'status',
'email' => 'email',
'group' => 'settings group',
'value' => 'value',
'key' => 'key',
'items' => 'items',
'parent_id' => 'parent node',
'role_ids' => 'roles',
'role_slugs' => 'roles',
'permission_slugs' => 'permissions',
'grants' => 'delegation grants',
'child_agent_id' => 'child agent',
'role' => 'role',
'user' => 'user',
'site_code' => 'site code',
'site_player_id' => 'site player ID',
'player_id' => 'player',
'agent_node_id' => 'agent node',
'currency_code' => 'currency',
'currency' => 'currency',
'amount' => 'amount',
'amount_delta' => 'adjustment amount',
'reason' => 'reason',
'remark' => 'remark',
'draw_id' => 'draw',
'draw_no' => 'draw number',
'draw_time' => 'draw time',
'start_time' => 'start time',
'close_time' => 'close time',
'period' => 'period',
'date_from' => 'start date',
'date_to' => 'end date',
'play_code' => 'play',
'page' => 'page',
'per_page' => 'per page',
'lines' => 'bet lines',
'lines.*.number' => 'number',
'lines.*.play_code' => 'play',
'lines.*.amount' => 'stake',
'idempotent_key' => 'idempotency key',
'wallet_api_url' => 'wallet API URL',
'prize_type' => 'prize type',
'prize_index' => 'prize index',
'number_4d' => '4-digit number',
'normalized_number' => 'number',
'items.*.play_code' => 'play',
'items.*.odds_value' => 'odds',
'items.*.display_name' => 'display name',
'report_type' => 'report type',
];

View File

@@ -0,0 +1,16 @@
<?php
return [
'unique' => 'This value already exists. Please choose another.',
'required' => ':attribute is required.',
'system_role' => 'System roles cannot be deleted.',
'agent_mismatch' => 'This user does not belong to the current agent node.',
'invalid_for_agent' => 'One or more roles are not valid for this agent.',
'not_manageable' => 'You cannot manage this child agent.',
'invalid_menu_action' => 'Invalid or unknown permission item.',
'exceeds_actor' => 'These permissions exceed what you may grant: :detail',
'exceeds_parent_ceiling' => 'These permissions exceed the parent delegation ceiling: :detail',
'exceeds_delegation_ceiling' => 'These permissions exceed this node\'s delegation ceiling: :detail',
'permission_exceeds_actor' => 'These permissions exceed what you may grant: :detail',
'permission_catalog_incomplete' => 'Permission catalog is incomplete (missing: :detail). Run migrate and admin-auth-sync.',
];

View File

@@ -0,0 +1,44 @@
<?php
return [
'code' => [
'regex' => 'Code may only contain letters, digits, underscores, and hyphens; site codes must start with a lowercase letter or digit.',
'unique' => 'This code is already in use.',
],
'slug' => [
'regex' => 'Slug may only contain lowercase letters, digits, underscores, and hyphens.',
'unique' => 'This slug is already in use.',
],
'account' => [
'regex' => 'Account may only contain letters, digits, dots, underscores, and hyphens.',
],
'username' => [
'regex' => 'Username may only contain letters, digits, dots, underscores, and hyphens.',
'unique' => 'This username is already in use.',
],
'draw_no' => [
'regex' => 'Draw number must match YYYYMMDD-sequence (e.g. 20260101-001).',
],
'number_4d' => [
'regex' => 'Number must be exactly 4 digits.',
],
'normalized_number' => [
'regex' => 'Number must be exactly 4 digits.',
'size' => 'Number must be exactly 4 digits.',
],
'wallet_api_url' => [
'wallet_api_url' => 'Wallet API URL must be an https public root URL (no localhost, private IP, path, or query).',
],
'amount_delta' => [
'not_in' => 'Adjustment amount cannot be zero.',
],
'password' => [
'min' => 'Password must be at least :min characters.',
],
'reason' => [
'min' => 'Reason must be at least :min characters.',
],
'items' => [
'size' => ':attribute must contain exactly :size items.',
],
];

View File

@@ -0,0 +1,6 @@
<?php
return [
'items must contain the complete 23 draw prize slots.' => 'All 23 draw prize slots must be provided (first, second, third, starter, and consolation).',
'items_must_contain_23_slots' => 'All 23 draw prize slots must be provided (first, second, third, starter, and consolation).',
];

View File

@@ -0,0 +1,54 @@
<?php
return [
'accepted' => 'You must accept :attribute.',
'array' => ':attribute must be a list.',
'boolean' => ':attribute must be true or false.',
'confirmed' => ':attribute confirmation does not match.',
'date' => ':attribute must be a valid date.',
'date_format' => ':attribute must match the format :format.',
'email' => ':attribute must be a valid email address.',
'exists' => 'The selected :attribute is invalid or unavailable.',
'filled' => ':attribute must have a value.',
'in' => 'The selected :attribute is invalid.',
'integer' => ':attribute must be an integer.',
'json' => ':attribute must be valid JSON.',
'max' => [
'array' => ':attribute may not have more than :max items.',
'numeric' => ':attribute may not be greater than :max.',
'string' => ':attribute may not exceed :max characters.',
],
'min' => [
'array' => ':attribute must have at least :min items.',
'numeric' => ':attribute must be at least :min.',
'string' => ':attribute must be at least :min characters.',
],
'not_in' => 'The selected :attribute is invalid.',
'not_regex' => ':attribute format is invalid.',
'numeric' => ':attribute must be a number.',
'present' => ':attribute must be present.',
'prohibited' => ':attribute is not allowed.',
'regex' => ':attribute format is invalid.',
'required' => ':attribute is required.',
'required_if' => ':attribute is required when :other is :value.',
'required_with' => ':attribute is required when :values is present.',
'required_with_all' => ':attribute is required when :values are present.',
'required_without' => ':attribute is required when :values is not present.',
'same' => ':attribute must match :other.',
'size' => [
'array' => ':attribute must contain :size items.',
'numeric' => ':attribute must be :size.',
'string' => ':attribute must be :size characters.',
],
'string' => ':attribute must be text.',
'unique' => ':attribute is already taken.',
'url' => ':attribute must be a valid URL.',
'uuid' => ':attribute must be a valid UUID.',
'between' => [
'array' => ':attribute must have between :min and :max items.',
'numeric' => ':attribute must be between :min and :max.',
'string' => ':attribute must be between :min and :max characters.',
],
'after_or_equal' => ':attribute must be on or after :date.',
'distinct' => ':attribute has a duplicate value.',
];

View File

@@ -23,6 +23,8 @@ return [
'agent_node_has_children_cannot_delete' => 'यस एजेन्ट नोडमा चाइल्ड नोडहरू छन्, पहिले तिनीहरू हटाउनुहोस्।', 'agent_node_has_children_cannot_delete' => 'यस एजेन्ट नोडमा चाइल्ड नोडहरू छन्, पहिले तिनीहरू हटाउनुहोस्।',
'agent_node_has_users_cannot_delete' => 'यस एजेन्ट नोडमा अझै एडमिन खाता जोडिएको छ, मेटाउन मिल्दैन।', 'agent_node_has_users_cannot_delete' => 'यस एजेन्ट नोडमा अझै एडमिन खाता जोडिएको छ, मेटाउन मिल्दैन।',
'agent_node_has_roles_cannot_delete' => 'यस एजेन्ट नोडमा अझै भूमिका जोडिएको छ, मेटाउन मिल्दैन।', 'agent_node_has_roles_cannot_delete' => 'यस एजेन्ट नोडमा अझै भूमिका जोडिएको छ, मेटाउन मिल्दैन।',
'agent_role_in_use' => 'यो भूमिका अझै :count खातामा प्रयोगमा छ। पहिले खाता ट्याबमा हटाउनुहोस्।',
'agent_role_read_only' => 'Read-only टेम्प्लेट भूमिका मेटाउन वा सम्पादन गर्न मिल्दैन।',
'user_cannot_delete_self' => 'आफ्नै खाता मेटाउन मिल्दैन।', 'user_cannot_delete_self' => 'आफ्नै खाता मेटाउन मिल्दैन।',
'user_cannot_delete_last_super_admin' => 'अन्तिम सुपर एडमिन मेटाउन मिल्दैन।', 'user_cannot_delete_last_super_admin' => 'अन्तिम सुपर एडमिन मेटाउन मिल्दैन।',
'super_admin_only_for_roles' => 'भूमिका व्यवस्थापन केवल सुपर एडमिनले गर्न सक्छ।', 'super_admin_only_for_roles' => 'भूमिका व्यवस्थापन केवल सुपर एडमिनले गर्न सक्छ।',

3
lang/ne/validation.php Normal file
View File

@@ -0,0 +1,3 @@
<?php
return require __DIR__.'/../zh/validation.php';

View File

@@ -23,6 +23,8 @@ return [
'agent_node_has_children_cannot_delete' => '该代理节点存在下级代理,请先清空下级后再删除。', 'agent_node_has_children_cannot_delete' => '该代理节点存在下级代理,请先清空下级后再删除。',
'agent_node_has_users_cannot_delete' => '该代理节点下仍有关联账号,不能删除。', 'agent_node_has_users_cannot_delete' => '该代理节点下仍有关联账号,不能删除。',
'agent_node_has_roles_cannot_delete' => '该代理节点下仍有关联角色,不能删除。', 'agent_node_has_roles_cannot_delete' => '该代理节点下仍有关联角色,不能删除。',
'agent_role_in_use' => '该角色仍有 :count 个账号在使用,请先在「账号」里解除绑定后再删除。',
'agent_role_read_only' => '只读模板角色不可删除或修改。',
'user_cannot_delete_self' => '不能删除当前登录账号。', 'user_cannot_delete_self' => '不能删除当前登录账号。',
'user_cannot_delete_last_super_admin' => '不能删除最后一个超级管理员。', 'user_cannot_delete_last_super_admin' => '不能删除最后一个超级管理员。',
'super_admin_only_for_roles' => '仅超级管理员可管理角色。', 'super_admin_only_for_roles' => '仅超级管理员可管理角色。',

11
lang/zh/validation.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
return array_merge(
require __DIR__.'/validation_rules.php',
[
'attributes' => require __DIR__.'/validation_attributes.php',
'custom' => require __DIR__.'/validation_custom.php',
'business' => require __DIR__.'/validation_business.php',
'exact' => require __DIR__.'/validation_exact.php',
],
);

View File

@@ -0,0 +1,162 @@
<?php
/** 全站 API 校验字段中文名FormRequest / 控制器 validate 共用) */
return [
'account' => '登录账号',
'password' => '密码',
'captcha_key' => '验证码',
'captcha_code' => '验证码',
'username' => '用户名',
'nickname' => '昵称',
'name' => '名称',
'code' => '编码',
'slug' => '标识',
'status' => '状态',
'email' => '邮箱',
'group' => '配置分组',
'value' => '配置值',
'key' => '配置键',
'items' => '配置项列表',
'parent_id' => '上级节点',
'role_ids' => '角色',
'role_ids.*' => '角色',
'role_slugs' => '角色',
'role_slugs.*' => '角色',
'permission_slugs' => '权限',
'permission_slugs.*' => '权限项',
'permissions' => '权限',
'permissions.*' => '权限项',
'grants' => '下放权限',
'grants.*.menu_action_id' => '权限项',
'grants.*.can_delegate' => '是否可继续下放',
'child_agent_id' => '下级代理',
'role' => '角色',
'user' => '账号',
'site_id' => '站点',
'site_code' => '站点编码',
'site_player_id' => '主站玩家 ID',
'player_id' => '玩家',
'player_account' => '玩家账号',
'agent_node_id' => '代理节点',
'currency_code' => '币种',
'currency' => '币种',
'amount' => '金额',
'amount_delta' => '调整金额',
'reason' => '原因',
'remark' => '备注',
'draw_id' => '期号 ID',
'draw_no' => '期号',
'draw_time' => '开奖时间',
'start_time' => '开始时间',
'close_time' => '封盘时间',
'business_date' => '业务日期',
'sequence_no' => '流水号',
'period' => '统计周期',
'date_from' => '开始日期',
'date_to' => '结束日期',
'metric' => '指标',
'play_code' => '玩法',
'keyword' => '关键词',
'default_currency' => '默认币种',
'page' => '页码',
'per_page' => '每页条数',
'size' => '每页条数',
'number' => '号码',
'start_date' => '开始日期',
'end_date' => '结束日期',
'transfer_no' => '转账单号',
'external_ref_no' => '外部参考号',
'txn_no' => '流水号',
'biz_type' => '业务类型',
'created_from' => '创建开始日期',
'created_to' => '创建结束日期',
'report_type' => '报表类型',
'export_format' => '导出格式',
'parameters' => '报表参数',
'parameters.date_from' => '开始日期',
'parameters.date_to' => '结束日期',
'parameters.player_id' => '玩家',
'parameters.play_code' => '玩法',
'parameters.operator_id' => '操作员',
'parameters.draw_id' => '期号',
'parameters.draw_no' => '期号',
'parameters.normalized_number' => '号码',
'filter_json' => '筛选条件',
'filter_json.date_from' => '开始日期',
'filter_json.date_to' => '结束日期',
'filter_json.draw_id' => '期号',
'filter_json.draw_no' => '期号',
'filter_json.normalized_number' => '号码',
'reconcile_type' => '对账类型',
'period_start' => '周期开始',
'period_end' => '周期结束',
'decimal_places' => '小数位数',
'is_enabled' => '是否启用',
'is_bettable' => '是否可下注',
'description' => '描述',
'wallet_api_url' => '钱包 API 地址',
'wallet_debit_path' => '扣款路径',
'wallet_credit_path' => '加款路径',
'wallet_balance_path' => '余额查询路径',
'wallet_timeout_seconds' => '请求超时(秒)',
'iframe_allowed_origins' => 'iframe 允许来源',
'iframe_allowed_origins.*' => 'iframe 来源',
'lottery_h5_base_url' => 'H5 基础地址',
'notes' => '备注',
'clone_from_version_id' => '克隆来源版本',
'current_amount' => '当前金额',
'contribution_rate' => '贡献比例',
'trigger_threshold' => '触发阈值',
'payout_rate' => '派彩比例',
'force_trigger_draw_gap' => '强制触发期数间隔',
'min_bet_amount' => '最小下注额',
'combo_trigger_play_codes' => '组合触发玩法',
'combo_trigger_play_codes.*' => '组合触发玩法',
'lines' => '注单明细',
'lines.*.number' => '投注号码',
'lines.*.play_code' => '玩法',
'lines.*.amount' => '下注金额',
'lines.*.digit_slot' => '数位',
'lines.*.dimension' => '维度',
'client_trace_id' => '客户端追踪 ID',
'idempotent_key' => '幂等键',
'expected_config_versions' => '期望配置版本',
'expected_config_versions.play_config_version_no' => '玩法配置版本号',
'expected_config_versions.odds_version_no' => '赔率配置版本号',
'expected_config_versions.risk_cap_version_no' => '封顶配置版本号',
'prize_type' => '奖项类型',
'prize_index' => '奖项序号',
'number_4d' => '四位号码',
'normalized_number' => '号码',
'items.*.key' => '配置键',
'items.*.value' => '配置值',
'items.*.play_code' => '玩法',
'items.*.prize_scope' => '奖项档位',
'items.*.odds_value' => '赔率',
'items.*.rebate_rate' => '返水比例',
'items.*.commission_rate' => '佣金比例',
'items.*.currency_code' => '币种',
'items.*.category' => '分类',
'items.*.dimension' => '维度',
'items.*.bet_mode' => '下注模式',
'items.*.display_name' => '显示名称',
'items.*.display_order' => '排序',
'items.*.min_bet_amount' => '最小下注额',
'items.*.max_bet_amount' => '最大下注额',
'items.*.supports_multi_number' => '是否支持多号',
'items.*.cap_amount' => '封顶金额',
'items.*.cap_type' => '封顶类型',
'items.*.draw_id' => '期号',
'items.*.prize_type' => '奖项类型',
'items.*.prize_index' => '奖项序号',
'items.*.number_4d' => '四位号码',
'items.*.normalized_number' => '号码',
'items.*.side_a_ref' => '对账侧 A 参考',
'items.*.side_b_ref' => '对账侧 B 参考',
'items.*.difference_amount' => '差异金额',
'items.*.status' => '状态',
'sort_order' => '排序',
'supports_multi_number' => '是否支持多号',
'reserved_rule_json' => '预留规则',
'extra_config_json' => '扩展配置',
];

View File

@@ -0,0 +1,17 @@
<?php
/** 业务层 ValidationException 简写键 */
return [
'unique' => '该内容已存在,请更换后重试。',
'required' => ':attribute 不能为空。',
'system_role' => '系统角色不可删除。',
'agent_mismatch' => '该账号不属于当前代理节点。',
'invalid_for_agent' => '所选角色与当前代理不匹配。',
'not_manageable' => '无权管理该下级代理。',
'invalid_menu_action' => '权限项无效或不存在。',
'exceeds_actor' => '下列权限超出您可分配的范围::detail',
'exceeds_parent_ceiling' => '下列权限超出上级允许下放的范围::detail',
'exceeds_delegation_ceiling' => '下列权限超出本节点下放上限::detail',
'permission_exceeds_actor' => '下列权限超出您可分配的范围::detail',
'permission_catalog_incomplete' => '权限目录不完整,缺少::detail。请联系管理员执行 migrate 与 admin-auth-sync。',
];

View File

@@ -0,0 +1,60 @@
<?php
/** 按字段定制的格式/唯一性说明 */
return [
'code' => [
'regex' => '编码只能使用字母、数字、下划线_和连字符-);站点编码须以小写字母或数字开头。',
'unique' => '该编码已被占用,请更换。',
],
'slug' => [
'regex' => '标识只能使用小写字母、数字、下划线和连字符。',
'unique' => '该标识已被占用,请更换。',
],
'account' => [
'regex' => '登录账号只能使用字母、数字、点(.)、下划线和连字符。',
],
'username' => [
'regex' => '用户名只能使用字母、数字、点(.)、下划线和连字符。',
'unique' => '该用户名已被占用,请更换。',
],
'email' => [
'unique' => '该邮箱已被占用,请更换。',
],
'draw_no' => [
'regex' => '期号格式须为 YYYYMMDD-流水号(例如 20260101-001。',
],
'number_4d' => [
'regex' => '号码必须是 4 位数字。',
],
'normalized_number' => [
'regex' => '号码必须是 4 位数字。',
'size' => '号码必须是 4 位数字。',
],
'wallet_api_url' => [
'wallet_api_url' => '钱包 API 地址必须是 https 的公开域名根地址,不能为 localhost、内网 IP且不能带路径或查询参数。',
],
'amount_delta' => [
'not_in' => '调整金额不能为 0。',
],
'password' => [
'min' => '密码至少需要 :min 个字符。',
],
'reason' => [
'min' => '原因至少需要 :min 个字符。',
],
'lines' => [
'max' => '单次最多提交 :max 注。',
'min' => '至少需要提交 :min 注。',
],
'items' => [
'min' => '至少需要 :min 条配置。',
'max' => '最多只能提交 :max 条配置。',
'size' => '必须恰好包含 :size 条配置。',
],
'role_slugs' => [
'min' => '至少须选择一个角色。',
],
'role_ids' => [
'required' => '请选择角色。',
],
];

View File

@@ -0,0 +1,7 @@
<?php
/** 代码中写死的英文校验句 → 中文(按原文精确匹配) */
return [
'items must contain the complete 23 draw prize slots.' => '须提交完整的 23 个开奖奖项(头奖、二奖、三奖、入围、安慰奖各档位)。',
'items_must_contain_23_slots' => '须提交完整的 23 个开奖奖项(头奖、二奖、三奖、入围、安慰奖各档位)。',
];

View File

@@ -0,0 +1,113 @@
<?php
/** Laravel 标准校验规则中文文案 */
return [
'accepted' => '请勾选接受 :attribute。',
'accepted_if' => '当 :other 为 :value 时,必须接受 :attribute。',
'active_url' => ':attribute 必须是有效的网址。',
'after' => ':attribute 必须晚于 :date。',
'after_or_equal' => ':attribute 必须不早于 :date。',
'alpha' => ':attribute 只能包含字母。',
'alpha_dash' => ':attribute 只能包含字母、数字、下划线和连字符。',
'alpha_num' => ':attribute 只能包含字母和数字。',
'array' => ':attribute 必须是列表。',
'ascii' => ':attribute 只能包含半角字母、数字和符号。',
'before' => ':attribute 必须早于 :date。',
'before_or_equal' => ':attribute 必须不晚于 :date。',
'between' => [
'array' => ':attribute 项数须在 :min 到 :max 之间。',
'file' => ':attribute 大小须在 :min 到 :max KB 之间。',
'numeric' => ':attribute 须在 :min 到 :max 之间。',
'string' => ':attribute 长度须在 :min 到 :max 个字符之间。',
],
'boolean' => ':attribute 必须是是或否。',
'confirmed' => ':attribute 两次输入不一致。',
'date' => ':attribute 必须是有效日期。',
'date_equals' => ':attribute 必须等于 :date。',
'date_format' => ':attribute 格式须为 :format。',
'decimal' => ':attribute 须保留 :decimal 位小数。',
'declined' => '必须拒绝 :attribute。',
'different' => ':attribute 与 :other 不能相同。',
'digits' => ':attribute 必须是 :digits 位数字。',
'digits_between' => ':attribute 须在 :min 到 :max 位数字之间。',
'distinct' => ':attribute 存在重复值。',
'email' => ':attribute 必须是有效的邮箱地址。',
'ends_with' => ':attribute 必须以以下之一结尾::values。',
'enum' => '所选的 :attribute 无效。',
'exists' => '所选的 :attribute 不存在或不可用。',
'file' => ':attribute 必须是文件。',
'filled' => ':attribute 不能为空。',
'gt' => [
'array' => ':attribute 项数须多于 :value 个。',
'file' => ':attribute 须大于 :value KB。',
'numeric' => ':attribute 须大于 :value。',
'string' => ':attribute 须多于 :value 个字符。',
],
'gte' => [
'array' => ':attribute 至少须有 :value 项。',
'file' => ':attribute 须不小于 :value KB。',
'numeric' => ':attribute 须不小于 :value。',
'string' => ':attribute 至少须 :value 个字符。',
],
'image' => ':attribute 必须是图片。',
'in' => ':attribute 取值无效。',
'in_array' => ':attribute 不存在于 :other 中。',
'integer' => ':attribute 必须是整数。',
'ip' => ':attribute 必须是有效的 IP 地址。',
'json' => ':attribute 必须是有效的 JSON。',
'lowercase' => ':attribute 必须是小写。',
'lt' => [
'array' => ':attribute 项数须少于 :value 个。',
'file' => ':attribute 须小于 :value KB。',
'numeric' => ':attribute 须小于 :value。',
'string' => ':attribute 须少于 :value 个字符。',
],
'lte' => [
'array' => ':attribute 最多 :value 项。',
'file' => ':attribute 须不大于 :value KB。',
'numeric' => ':attribute 须不大于 :value。',
'string' => ':attribute 最多 :value 个字符。',
],
'max' => [
'array' => ':attribute 最多 :max 项。',
'file' => ':attribute 不能超过 :max KB。',
'numeric' => ':attribute 不能大于 :max。',
'string' => ':attribute 不能超过 :max 个字符。',
],
'min' => [
'array' => ':attribute 至少 :min 项。',
'file' => ':attribute 至少 :min KB。',
'numeric' => ':attribute 不能小于 :min。',
'string' => ':attribute 至少 :min 个字符。',
],
'not_in' => ':attribute 取值无效。',
'not_regex' => ':attribute 格式不正确。',
'numeric' => ':attribute 必须是数字。',
'present' => ':attribute 必须提交(可为空)。',
'prohibited' => ':attribute 不允许提交。',
'prohibited_if' => '当 :other 为 :value 时,不允许提交 :attribute。',
'regex' => ':attribute 格式不正确。',
'required' => ':attribute 不能为空。',
'required_if' => '当 :other 为 :value 时,:attribute 不能为空。',
'required_unless' => '除非 :other 在 :values 中,否则 :attribute 不能为空。',
'required_with' => '当存在 :values 时,:attribute 不能为空。',
'required_with_all' => '当存在 :values 时,:attribute 不能为空。',
'required_without' => '当不存在 :values 时,:attribute 不能为空。',
'required_without_all' => '当 :values 均不存在时,:attribute 不能为空。',
'required_array_keys' => ':attribute 须包含::values。',
'same' => ':attribute 必须与 :other 一致。',
'size' => [
'array' => ':attribute 必须包含 :size 项。',
'file' => ':attribute 必须为 :size KB。',
'numeric' => ':attribute 必须为 :size。',
'string' => ':attribute 必须为 :size 个字符。',
],
'starts_with' => ':attribute 必须以以下之一开头::values。',
'string' => ':attribute 必须是文本。',
'timezone' => ':attribute 必须是有效的时区。',
'unique' => ':attribute 已被占用,请更换。',
'uploaded' => ':attribute 上传失败。',
'uppercase' => ':attribute 必须是大写。',
'url' => ':attribute 必须是有效的网址。',
'uuid' => ':attribute 必须是有效的 UUID。',
];

View File

@@ -15,6 +15,7 @@ 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\AgentNodeAdminUserIndexController;
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeAdminUserStoreController; 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\AgentAdminUserRoleSyncController;
use App\Http\Controllers\Api\V1\Admin\Agent\AgentAdminUserDestroyController;
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeDelegationGrantIndexController; use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeDelegationGrantIndexController;
use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeDelegationGrantSyncController; use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeDelegationGrantSyncController;
@@ -54,4 +55,6 @@ Route::middleware('admin.api-resource')
Route::put('agent-admin-users/{admin_user}/roles', AgentAdminUserRoleSyncController::class) Route::put('agent-admin-users/{admin_user}/roles', AgentAdminUserRoleSyncController::class)
->name('api.v1.admin.agent-admin-users.roles.sync'); ->name('api.v1.admin.agent-admin-users.roles.sync');
Route::delete('agent-admin-users/{admin_user}', AgentAdminUserDestroyController::class)
->name('api.v1.admin.agent-admin-users.destroy');
}); });

View File

@@ -0,0 +1,126 @@
<?php
use App\Models\AdminRole;
use App\Models\AdminUser;
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('agent role can be deleted when no users assigned', 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');
$super = AdminUser::query()->create([
'username' => 'destroy_role_super',
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken;
$branch = app(\App\Services\Agent\AgentNodeService::class)->createChild($super, [
'parent_id' => $rootId,
'code' => 'destroy-role-branch',
'name' => 'Destroy Role Branch',
]);
$role = AdminRole::query()->create([
'slug' => 'deletable_role',
'name' => 'Deletable',
'scope_type' => AdminRole::SCOPE_AGENT,
'owner_agent_id' => $branch->id,
]);
$role->syncLegacyPermissionSlugs(['prd.agent.role.view']);
$this->withHeader('Authorization', 'Bearer '.$token)
->deleteJson('/api/v1/admin/agent-roles/'.$role->id)
->assertOk();
expect(AdminRole::query()->find($role->id))->toBeNull();
});
test('agent role cannot be deleted while assigned to a user', 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');
$super = AdminUser::query()->create([
'username' => 'destroy_role_blocked_super',
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken;
$branch = app(\App\Services\Agent\AgentNodeService::class)->createChild($super, [
'parent_id' => $rootId,
'code' => 'destroy-role-blocked',
'name' => 'Blocked Branch',
]);
$role = AdminRole::query()->create([
'slug' => 'blocked_role',
'name' => 'Blocked',
'scope_type' => AdminRole::SCOPE_AGENT,
'owner_agent_id' => $branch->id,
]);
$role->syncLegacyPermissionSlugs(['prd.agent.role.view']);
$user = app(\App\Services\Agent\AgentAdminUserService::class)->createUnderAgent($branch, [
'username' => 'blocked_user',
'nickname' => 'Blocked User',
'password' => 'secret-strong-2',
'role_ids' => [(int) $role->id],
]);
expect($user->id)->toBeGreaterThan(0);
$this->withHeader('Authorization', 'Bearer '.$token)
->deleteJson('/api/v1/admin/agent-roles/'.$role->id)
->assertStatus(422);
});
test('agent admin user can be deleted under agent node', 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');
$super = AdminUser::query()->create([
'username' => 'destroy_user_super',
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken;
$branch = app(\App\Services\Agent\AgentNodeService::class)->createChild($super, [
'parent_id' => $rootId,
'code' => 'destroy-user-branch',
'name' => 'Destroy User Branch',
]);
$user = app(\App\Services\Agent\AgentAdminUserService::class)->createUnderAgent($branch, [
'username' => 'agent_delete_me',
'nickname' => 'Delete Me',
'password' => 'secret-strong-3',
'role_ids' => [],
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->deleteJson('/api/v1/admin/agent-admin-users/'.$user->id)
->assertOk()
->assertJsonPath('data.deleted', true);
expect(AdminUser::query()->find($user->id))->toBeNull();
expect(DB::table('admin_user_agents')->where('admin_user_id', $user->id)->exists())->toBeFalse();
});

View File

@@ -28,7 +28,14 @@ function grantDelegationAgentOperator(AdminUser $admin, AgentNode $agent, bool $
]); ]);
$codes = $manage $codes = $manage
? ['agent.node.view', 'agent.node.manage'] ? [
'agent.node.view',
'agent.node.manage',
'agent.role.view',
'agent.role.manage',
'agent.user.view',
'agent.user.manage',
]
: ['agent.node.view']; : ['agent.node.view'];
$actionIds = DB::table('admin_menu_actions') $actionIds = DB::table('admin_menu_actions')
@@ -55,6 +62,13 @@ function grantDelegationAgentOperator(AdminUser $admin, AgentNode $agent, bool $
'is_primary' => true, 'is_primary' => true,
'granted_at' => $now, '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,
]);
} }
test('parent agent can sync delegation grants for direct child', function (): void { test('parent agent can sync delegation grants for direct child', function (): void {

View File

@@ -36,7 +36,14 @@ function grantAgentOperatorRole(AdminUser $admin, AgentNode $agent, bool $manage
]); ]);
$codes = $manage $codes = $manage
? ['agent.node.view', 'agent.node.manage'] ? [
'agent.node.view',
'agent.node.manage',
'agent.role.view',
'agent.role.manage',
'agent.user.view',
'agent.user.manage',
]
: ['agent.node.view']; : ['agent.node.view'];
$actionIds = DB::table('admin_menu_actions') $actionIds = DB::table('admin_menu_actions')
@@ -63,6 +70,13 @@ function grantAgentOperatorRole(AdminUser $admin, AgentNode $agent, bool $manage
'is_primary' => true, 'is_primary' => true,
'granted_at' => $now, '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,
]);
} }
test('each admin site has exactly one root agent node after migration', function (): void { test('each admin site has exactly one root agent node after migration', function (): void {

View File

@@ -0,0 +1,244 @@
<?php
use App\Models\AdminRole;
use App\Models\AdminUser;
use Illuminate\Support\Facades\Hash;
use App\Support\AdminAuthProfile;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
test('role with only agent role view slug does not receive manage slugs in profile', function (): void {
$admin = AdminUser::query()->create([
'username' => 'agent_role_view_only',
'name' => 'View Only',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$role = AdminRole::query()->create([
'slug' => 'agent_role_view_only',
'name' => 'Agent role view only',
]);
$role->syncLegacyPermissionSlugs(['prd.agent.role.view']);
$siteId = AdminUser::defaultAdminSiteId();
$admin->roles()->sync([
(int) $role->id => ['site_id' => $siteId, 'granted_at' => now()],
]);
$profile = AdminAuthProfile::fromAdmin($admin->fresh());
expect($profile['permissions'])->toContain('prd.agent.role.view')
->and($profile['permissions'])->toContain('prd.agent.view')
->not->toContain('prd.agent.role.manage')
->not->toContain('prd.agent.user.manage')
->not->toContain('prd.agent.manage');
});
test('role with only agent role manage can create roles but not agent nodes', 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');
$super = AdminUser::query()->create([
'username' => 'granularity_super',
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$service = app(\App\Services\Agent\AgentNodeService::class);
$branch = $service->createChild($super, [
'parent_id' => $rootId,
'code' => 'granularity-branch',
'name' => 'Granularity Branch',
]);
$admin = AdminUser::query()->create([
'username' => 'agent_role_manage_only',
'name' => 'Role Manage',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$role = AdminRole::query()->create([
'slug' => 'agent_role_manage_only',
'name' => 'Agent role manage only',
]);
$role->syncLegacyPermissionSlugs(['prd.agent.role.manage']);
$siteId = (int) $branch->admin_site_id;
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $admin->id,
'site_id' => $siteId,
'role_id' => $role->id,
'granted_at' => now(),
]);
DB::table('admin_user_agents')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => $branch->id,
'is_primary' => true,
'granted_at' => now(),
]);
DB::table('admin_user_agent_roles')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => $branch->id,
'role_id' => $role->id,
'granted_at' => now(),
]);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/agent-nodes/'.$branch->id.'/roles', [
'slug' => 'ops_role_only',
'name' => 'Ops Role',
'permission_slugs' => ['prd.agent.role.view'],
])
->assertOk();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/agent-nodes', [
'parent_id' => $branch->id,
'code' => 'should-fail',
'name' => 'Should Fail',
])
->assertForbidden();
});
test('role with only agent node manage cannot create roles or admin users', 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');
$super = AdminUser::query()->create([
'username' => 'granularity_super_node',
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$service = app(\App\Services\Agent\AgentNodeService::class);
$branch = $service->createChild($super, [
'parent_id' => $rootId,
'code' => 'granularity-node-only',
'name' => 'Node Only Branch',
]);
$admin = AdminUser::query()->create([
'username' => 'agent_node_manage_only',
'name' => 'Node Manage',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$role = AdminRole::query()->create([
'slug' => 'agent_node_manage_only',
'name' => 'Agent node manage only',
]);
$role->syncLegacyPermissionSlugs(['prd.agent.manage']);
$siteId = (int) $branch->admin_site_id;
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $admin->id,
'site_id' => $siteId,
'role_id' => $role->id,
'granted_at' => now(),
]);
DB::table('admin_user_agents')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => $branch->id,
'is_primary' => true,
'granted_at' => now(),
]);
DB::table('admin_user_agent_roles')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => $branch->id,
'role_id' => $role->id,
'granted_at' => now(),
]);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/agent-nodes/'.$branch->id.'/roles', [
'slug' => 'should_fail_role',
'name' => 'Should Fail Role',
'permission_slugs' => ['prd.agent.role.view'],
])
->assertForbidden();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/agent-nodes/'.$branch->id.'/admin-users', [
'username' => 'should_fail_user',
'name' => 'Should Fail User',
'password' => 'secret-strong-2',
'role_ids' => [],
])
->assertForbidden();
});
test('agent-bound admin with only site role bindings still receives manage slugs in profile', 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');
$super = AdminUser::query()->create([
'username' => 'granularity_super_site_fallback',
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$service = app(\App\Services\Agent\AgentNodeService::class);
$branch = $service->createChild($super, [
'parent_id' => $rootId,
'code' => 'site-fallback-branch',
'name' => 'Site Fallback Branch',
]);
$role = AdminRole::query()->create([
'slug' => 'agent_role_manage_site_only',
'name' => 'Agent role manage site bind',
'scope_type' => \App\Models\AdminRole::SCOPE_AGENT,
'owner_agent_id' => $branch->id,
]);
$role->syncLegacyPermissionSlugs(['prd.agent.role.manage']);
$admin = AdminUser::query()->create([
'username' => 'agent_site_roles_only',
'name' => 'Site Roles Only',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
DB::table('admin_user_agents')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => $branch->id,
'is_primary' => true,
'granted_at' => now(),
]);
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $admin->id,
'site_id' => $siteId,
'role_id' => $role->id,
'granted_at' => now(),
]);
$profile = AdminAuthProfile::fromAdmin($admin->fresh());
expect($profile['permissions'])->toContain('prd.agent.role.manage');
expect(DB::table('admin_user_agent_roles')->where('admin_user_id', $admin->id)->count())->toBe(0);
});

View File

@@ -28,7 +28,15 @@ function grantAgentRoleManager(AdminUser $admin, AgentNode $agent): void
'updated_at' => $now, 'updated_at' => $now,
]); ]);
$codes = ['agent.node.view', 'agent.node.manage', 'service.players.view']; $codes = [
'agent.node.view',
'agent.node.manage',
'agent.role.view',
'agent.role.manage',
'agent.user.view',
'agent.user.manage',
'service.players.view',
];
$actionIds = DB::table('admin_menu_actions')->whereIn('permission_code', $codes)->pluck('id'); $actionIds = DB::table('admin_menu_actions')->whereIn('permission_code', $codes)->pluck('id');
foreach ($actionIds as $actionId) { foreach ($actionIds as $actionId) {
DB::table('admin_role_menu_actions')->insert([ DB::table('admin_role_menu_actions')->insert([

View File

@@ -0,0 +1,62 @@
<?php
use App\Models\AdminRole;
use App\Models\AdminUser;
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('platform role sync persists agent role and user manage slugs', function (): void {
$super = AdminUser::query()->create([
'username' => 'persist_super',
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken;
$role = AdminRole::query()->create([
'slug' => 'agent_persist_test',
'name' => 'Agent Persist Test',
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/admin-roles/'.$role->id.'/permissions', [
'permission_slugs' => [
'prd.agent.view',
'prd.agent.manage',
'prd.agent.role.view',
'prd.agent.role.manage',
'prd.agent.user.view',
'prd.agent.user.manage',
],
])
->assertOk()
->assertJsonPath('data.permission_slugs', function ($slugs): bool {
$list = is_array($slugs) ? $slugs : [];
return in_array('prd.agent.role.manage', $list, true)
&& in_array('prd.agent.user.manage', $list, true);
});
});
test('sync fails with clear error when agent manage menu actions are missing', function (): void {
DB::table('admin_menu_actions')
->whereIn('permission_code', ['agent.role.manage', 'agent.user.manage'])
->delete();
$role = AdminRole::query()->create([
'slug' => 'agent_missing_catalog',
'name' => 'Missing Catalog',
]);
expect(fn () => $role->syncLegacyPermissionSlugs(['prd.agent.role.manage']))
->toThrow(\Illuminate\Validation\ValidationException::class);
});

View File

@@ -0,0 +1,60 @@
<?php
use App\Models\AdminRole;
use App\Models\AdminUser;
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('role with only draw view slug does not round-trip as manage', function (): void {
$role = AdminRole::query()->create([
'slug' => 'draw_view_only',
'name' => 'Draw view only',
]);
$role->syncLegacyPermissionSlugs(['prd.draw_result.view']);
expect($role->legacyPermissionSlugs())
->toContain('prd.draw_result.view')
->not->toContain('prd.draw_result.manage');
});
test('platform role sync can drop draw manage while keeping view', function (): void {
$super = AdminUser::query()->create([
'username' => 'draw_perm_super',
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken;
$role = AdminRole::query()->create([
'slug' => 'draw_toggle_test',
'name' => 'Draw toggle test',
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/admin-roles/'.$role->id.'/permissions', [
'permission_slugs' => ['prd.draw_result.view', 'prd.draw_result.manage'],
])
->assertOk()
->assertJsonPath('data.permission_slugs', fn ($slugs): bool => in_array('prd.draw_result.manage', $slugs, true));
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/admin-roles/'.$role->id.'/permissions', [
'permission_slugs' => ['prd.draw_result.view'],
])
->assertOk()
->assertJsonPath('data.permission_slugs', function ($slugs): bool {
$list = is_array($slugs) ? $slugs : [];
return in_array('prd.draw_result.view', $list, true)
&& ! in_array('prd.draw_result.manage', $list, true);
});
});

View File

@@ -20,6 +20,8 @@ pest()->extend(TestCase::class)
// ->use(RefreshDatabase::class) // ->use(RefreshDatabase::class)
->in('Feature'); ->in('Feature');
pest()->extend(TestCase::class)->in('Unit');
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Expectations | Expectations

View File

@@ -0,0 +1,52 @@
<?php
use App\Support\ApiValidationErrors;
test('normalizes english regex error for code field in zh', function (): void {
$errors = ApiValidationErrors::normalize(
['code' => ['The code field format is invalid.']],
'zh',
);
expect($errors['code'][0])->toContain('编码只能使用字母、数字');
});
test('normalizes business shorthand unique in zh', function (): void {
$errors = ApiValidationErrors::normalize(['code' => ['unique']], 'zh');
expect($errors['code'][0])->toBe('该内容已存在,请更换后重试。');
});
test('summary joins multiple field errors', function (): void {
$normalized = [
'code' => ['编码格式不正确。'],
'name' => ['名称不能为空。'],
];
expect(ApiValidationErrors::summary($normalized))->toBe('编码格式不正确。;名称不能为空。');
});
test('keeps already localized chinese messages', function (): void {
$message = '最小下注额不能小于 0';
$errors = ApiValidationErrors::normalize(['items.0.min_bet_amount' => [$message]], 'zh');
expect($errors['items.0.min_bet_amount'][0])->toBe($message);
});
test('normalizes english min length for password in zh', function (): void {
$errors = ApiValidationErrors::normalize(
['password' => ['The password field must be at least 8 characters.']],
'zh',
);
expect($errors['password'][0])->toBe('密码至少需要 8 个字符。');
});
test('normalizes exact draw items message in zh', function (): void {
$errors = ApiValidationErrors::normalize(
['items' => ['items must contain the complete 23 draw prize slots.']],
'zh',
);
expect($errors['items'][0])->toContain('23');
});