feat(admin): 增加玩家密码管理与角色边界
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Player;
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Support\AdminSiteScope;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Player\PlayerPasswordService;
|
||||
use App\Http\Requests\Admin\AdminPlayerPasswordResetRequest;
|
||||
|
||||
/** PUT /api/v1/admin/players/{player}/password */
|
||||
final class AdminPlayerPasswordResetController extends Controller
|
||||
{
|
||||
public function __invoke(
|
||||
AdminPlayerPasswordResetRequest $request,
|
||||
Player $player,
|
||||
PlayerPasswordService $passwords,
|
||||
): JsonResponse {
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
if ($denied = AdminSiteScope::denyUnlessPlayerAccessible($admin, $player)) {
|
||||
return $denied;
|
||||
}
|
||||
|
||||
$updated = $passwords->reset($player, (string) $request->validated('password'));
|
||||
|
||||
return ApiResponse::success([
|
||||
'password_reset' => true,
|
||||
'player_id' => (int) $updated->id,
|
||||
], request: $request);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,12 @@ namespace App\Http\Controllers\Api\V1\Admin\User;
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Support\AdminAuthorizationRegistry;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AdminRoleUserCounts;
|
||||
use App\Support\PlatformSystemRoles;
|
||||
use App\Support\AdminRoleApiPresenter;
|
||||
use App\Support\AdminAuthorizationRegistry;
|
||||
|
||||
/** GET /api/v1/admin/admin-user-permission-catalog */
|
||||
final class AdminPermissionCatalogController extends Controller
|
||||
@@ -61,12 +62,26 @@ final class AdminPermissionCatalogController extends Controller
|
||||
->where('scope_type', AdminRole::SCOPE_SYSTEM)
|
||||
->orderBy('slug')
|
||||
->get(['id', 'slug', 'name']);
|
||||
$userCounts = AdminRoleUserCounts::forRoleIds($roles->pluck('id'));
|
||||
$presentedRoles = $roles->map(
|
||||
static fn (AdminRole $role): array => AdminRoleApiPresenter::item(
|
||||
$role,
|
||||
$userCounts[(int) $role->id] ?? null,
|
||||
)
|
||||
)->values();
|
||||
|
||||
return ApiResponse::success([
|
||||
'permissions' => $permissions,
|
||||
'permission_menu_groups' => $permissionMenuGroups,
|
||||
'navigation' => AdminAuthorizationRegistry::navigationItems(),
|
||||
'roles' => $roles->map(static fn (AdminRole $role): array => AdminRoleApiPresenter::item($role))->values()->all(),
|
||||
'roles' => $presentedRoles->all(),
|
||||
'assignable_roles' => $presentedRoles
|
||||
->reject(static fn (array $role): bool => in_array($role['slug'], [
|
||||
PlatformSystemRoles::SLUG_AGENT,
|
||||
PlatformSystemRoles::SLUG_SUPER_ADMIN,
|
||||
], true))
|
||||
->values()
|
||||
->all(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ use App\Models\AdminRole;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AdminRoleUserCounts;
|
||||
use App\Support\AdminRoleApiPresenter;
|
||||
|
||||
final class AdminRoleIndexController extends Controller
|
||||
{
|
||||
public function __invoke(): JsonResponse
|
||||
@@ -16,9 +18,15 @@ final class AdminRoleIndexController extends Controller
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
$userCounts = AdminRoleUserCounts::forRoleIds($roles->pluck('id'));
|
||||
|
||||
return ApiResponse::success([
|
||||
'items' => $roles->map(static fn (AdminRole $role): array => AdminRoleApiPresenter::item($role))->values()->all(),
|
||||
'items' => $roles->map(
|
||||
static fn (AdminRole $role): array => AdminRoleApiPresenter::item(
|
||||
$role,
|
||||
$userCounts[(int) $role->id] ?? null,
|
||||
)
|
||||
)->values()->all(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
namespace App\Http\Controllers\Api\V1\Admin\User;
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Support\AdminApiList;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AdminUserApiPresenter;
|
||||
|
||||
@@ -17,6 +17,7 @@ final class AdminUserIndexController extends Controller
|
||||
{
|
||||
$p = AdminApiList::readPaging($request);
|
||||
$keyword = trim((string) $request->query('keyword', ''));
|
||||
$roleSlug = trim((string) $request->query('role_slug', ''));
|
||||
|
||||
$q = AdminUser::query()
|
||||
->with(['roles'])
|
||||
@@ -35,6 +36,12 @@ final class AdminUserIndexController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
if ($roleSlug !== '') {
|
||||
$q->whereHas('roles', static function ($roles) use ($roleSlug): void {
|
||||
$roles->where('admin_roles.slug', $roleSlug);
|
||||
});
|
||||
}
|
||||
|
||||
$paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']);
|
||||
|
||||
return AdminApiList::json($paginator, fn (AdminUser $user): array => AdminUserApiPresenter::listItem($user));
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Player;
|
||||
|
||||
use App\Support\ApiResponse;
|
||||
use App\Services\AuditLogger;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Player\PlayerPasswordService;
|
||||
use App\Http\Requests\Player\PlayerPasswordUpdateRequest;
|
||||
|
||||
/** PUT /api/v1/player/password — 彩票端账号自助修改密码 */
|
||||
final class PlayerPasswordUpdateController extends Controller
|
||||
{
|
||||
public function __invoke(
|
||||
PlayerPasswordUpdateRequest $request,
|
||||
PlayerPasswordService $passwords,
|
||||
): JsonResponse {
|
||||
$player = $request->lotteryPlayer();
|
||||
abort_if($player === null, 500, 'lottery_player missing');
|
||||
|
||||
$updated = $passwords->change(
|
||||
$player,
|
||||
(string) $request->validated('current_password'),
|
||||
(string) $request->validated('password'),
|
||||
);
|
||||
|
||||
AuditLogger::recordForPlayer(
|
||||
$updated,
|
||||
$request,
|
||||
'player_account',
|
||||
'change_password',
|
||||
'player',
|
||||
(string) $updated->id,
|
||||
null,
|
||||
['native_token_version' => (int) $updated->native_token_version],
|
||||
);
|
||||
|
||||
return ApiResponse::success(['password_changed' => true], request: $request);
|
||||
}
|
||||
}
|
||||
23
app/Http/Requests/Admin/AdminPlayerPasswordResetRequest.php
Normal file
23
app/Http/Requests/Admin/AdminPlayerPasswordResetRequest.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
use App\Http\Requests\Admin\Concerns\AdminAccountFieldRules;
|
||||
|
||||
final class AdminPlayerPasswordResetRequest extends ApiFormRequest
|
||||
{
|
||||
use AdminAccountFieldRules;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'password' => [...$this->nativePlayerPasswordRules(required: true), 'confirmed'],
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Http/Requests/Player/PlayerPasswordUpdateRequest.php
Normal file
24
app/Http/Requests/Player/PlayerPasswordUpdateRequest.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Player;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
use App\Http\Requests\Admin\Concerns\AdminAccountFieldRules;
|
||||
|
||||
final class PlayerPasswordUpdateRequest extends ApiFormRequest
|
||||
{
|
||||
use AdminAccountFieldRules;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'current_password' => ['required', 'string', 'max:128'],
|
||||
'password' => [...$this->nativePlayerPasswordRules(required: true), 'confirmed'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -251,6 +251,11 @@ final class AdminUser extends Authenticatable
|
||||
{
|
||||
$slugs = array_values(array_unique($slugs));
|
||||
\App\Support\SuperAdminAccount::assertNotSiteRoleAssignment($slugs);
|
||||
if (in_array(\App\Support\PlatformSystemRoles::SLUG_AGENT, $slugs, true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'role_slugs' => [trans('admin.agent_role_not_assignable_to_platform_account')],
|
||||
]);
|
||||
}
|
||||
|
||||
$roleIds = DB::table('admin_roles')
|
||||
->where('scope_type', AdminRole::SCOPE_SYSTEM)
|
||||
|
||||
@@ -4,8 +4,8 @@ namespace App\Models;
|
||||
|
||||
use App\Support\PlayerAuthSource;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 主站玩家在本地映射账号(表 players),与 SSO JWT 中 site_code + site_player_id 对应。
|
||||
@@ -27,6 +27,7 @@ final class Player extends Model
|
||||
'last_login_at',
|
||||
'login_failed_count',
|
||||
'login_locked_until',
|
||||
'native_token_version',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@@ -40,6 +41,7 @@ final class Player extends Model
|
||||
'last_login_at' => 'datetime',
|
||||
'login_failed_count' => 'integer',
|
||||
'login_locked_until' => 'datetime',
|
||||
'native_token_version' => 'integer',
|
||||
'risk_tags' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
namespace App\Services\Player;
|
||||
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Models\Player;
|
||||
use App\Support\PlayerAuthSource;
|
||||
use Firebase\JWT\JWT;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Models\Player;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\PlayerAuthSource;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use App\Exceptions\PlayerAuthenticationException;
|
||||
|
||||
@@ -103,6 +102,7 @@ final class PlayerNativeAuthService
|
||||
$payload = [
|
||||
$playerIdKey => (int) $player->id,
|
||||
$authKey => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'token_version' => (int) ($player->native_token_version ?? 0),
|
||||
'site_code' => (string) $player->site_code,
|
||||
'iat' => $now,
|
||||
'exp' => $now + $ttl,
|
||||
|
||||
64
app/Services/Player/PlayerPasswordService.php
Normal file
64
app/Services/Player/PlayerPasswordService.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Player;
|
||||
|
||||
use App\Models\Player;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class PlayerPasswordService
|
||||
{
|
||||
public function change(Player $player, string $currentPassword, string $newPassword): Player
|
||||
{
|
||||
return DB::transaction(function () use ($player, $currentPassword, $newPassword): Player {
|
||||
$locked = Player::query()->lockForUpdate()->findOrFail($player->id);
|
||||
$this->assertPasswordManagedLocally($locked);
|
||||
|
||||
if (! is_string($locked->password_hash) || ! Hash::check($currentPassword, $locked->password_hash)) {
|
||||
throw ValidationException::withMessages([
|
||||
'current_password' => ['current_password_invalid'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (Hash::check($newPassword, $locked->password_hash)) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => ['new_password_must_differ'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->persist($locked, $newPassword);
|
||||
});
|
||||
}
|
||||
|
||||
public function reset(Player $player, string $newPassword): Player
|
||||
{
|
||||
return DB::transaction(function () use ($player, $newPassword): Player {
|
||||
$locked = Player::query()->lockForUpdate()->findOrFail($player->id);
|
||||
$this->assertPasswordManagedLocally($locked);
|
||||
|
||||
return $this->persist($locked, $newPassword);
|
||||
});
|
||||
}
|
||||
|
||||
private function assertPasswordManagedLocally(Player $player): void
|
||||
{
|
||||
if (! $player->isLotteryNative()) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => ['native_password_unavailable'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function persist(Player $player, string $newPassword): Player
|
||||
{
|
||||
$player->forceFill([
|
||||
'password_hash' => Hash::make($newPassword),
|
||||
'native_token_version' => (int) $player->native_token_version + 1,
|
||||
'login_failed_count' => 0,
|
||||
'login_locked_until' => null,
|
||||
])->save();
|
||||
|
||||
return $player->refresh();
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,10 @@ use App\Models\Player;
|
||||
use App\Lottery\ErrorCode;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Support\PlayerAuthSource;
|
||||
use App\Support\PlayerAutoRegistrationDefaults;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use App\Support\PlayerTokenAesUnwrap;
|
||||
use Illuminate\Database\QueryException;
|
||||
use App\Support\PlayerAutoRegistrationDefaults;
|
||||
use App\Exceptions\PlayerAuthenticationException;
|
||||
use App\Services\Integration\PartnerSiteConfigResolver;
|
||||
|
||||
@@ -192,6 +192,11 @@ final class PlayerTokenResolver
|
||||
throw new PlayerAuthenticationException('玩家不存在', ErrorCode::PlayerNotRegistered->value);
|
||||
}
|
||||
|
||||
$tokenVersion = (int) data_get($claims, 'token_version', 0);
|
||||
if ($tokenVersion !== (int) ($player->native_token_version ?? 0)) {
|
||||
throw new PlayerAuthenticationException('Token 已因密码变更失效', ErrorCode::PlayerTokenInvalid->value);
|
||||
}
|
||||
|
||||
$player->forceFill(['last_login_at' => now()])->save();
|
||||
|
||||
return $player->refresh();
|
||||
@@ -288,9 +293,6 @@ final class PlayerTokenResolver
|
||||
return is_string($decoded) ? $decoded : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param object $claims
|
||||
*/
|
||||
private function assertNativeJwtTemporalPolicy(object $claims): void
|
||||
{
|
||||
if (! isset($claims->exp) || ! is_numeric($claims->exp)) {
|
||||
|
||||
@@ -550,6 +550,7 @@ final class AdminAuthorizationRegistry
|
||||
['code' => 'admin.players.store', 'module_code' => 'player_service', 'name' => '创建玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players', 'route_name' => 'api.v1.admin.players.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.manage']],
|
||||
['code' => 'admin.players.show', 'module_code' => 'player_service', 'name' => '玩家详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.players.view']],
|
||||
['code' => 'admin.players.update', 'module_code' => 'player_service', 'name' => '更新玩家', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.manage']],
|
||||
['code' => 'admin.players.password.reset', 'module_code' => 'player_service', 'name' => '重置玩家密码', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/players/{player}/password', 'route_name' => 'api.v1.admin.players.password.reset', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.manage']],
|
||||
['code' => 'admin.players.destroy', 'module_code' => 'player_service', 'name' => '删除玩家', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.manage']],
|
||||
['code' => 'admin.players.freeze', 'module_code' => 'player_service', 'name' => '冻结玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players/{player}/freeze', 'route_name' => 'api.v1.admin.players.freeze', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.freeze']],
|
||||
['code' => 'admin.players.unfreeze', 'module_code' => 'player_service', 'name' => '解冻玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players/{player}/unfreeze', 'route_name' => 'api.v1.admin.players.unfreeze', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.freeze']],
|
||||
|
||||
@@ -7,8 +7,25 @@ use App\Models\AdminRole;
|
||||
final class AdminRoleApiPresenter
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public static function item(AdminRole $role): array
|
||||
public static function item(AdminRole $role, ?array $userCounts = null): array
|
||||
{
|
||||
if ($userCounts !== null) {
|
||||
$counts = $userCounts;
|
||||
} elseif (($role->scope_type ?? AdminRole::SCOPE_SYSTEM) === AdminRole::SCOPE_SYSTEM) {
|
||||
$counts = AdminRoleUserCounts::forRoleIds([$role->id])[(int) $role->id] ?? [
|
||||
'user_count' => 0,
|
||||
'platform_user_count' => 0,
|
||||
'agent_user_count' => 0,
|
||||
];
|
||||
} else {
|
||||
$assignedCount = $role->assignedUserCount();
|
||||
$counts = [
|
||||
'user_count' => $assignedCount,
|
||||
'platform_user_count' => 0,
|
||||
'agent_user_count' => $assignedCount,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $role->id,
|
||||
'slug' => $role->slug,
|
||||
@@ -22,7 +39,9 @@ final class AdminRoleApiPresenter
|
||||
'delegated_from_role_id' => $role->delegated_from_role_id !== null ? (int) $role->delegated_from_role_id : null,
|
||||
'is_read_only_template' => $role->isReadOnlyTemplate(),
|
||||
'permission_slugs' => $role->legacyPermissionSlugs(),
|
||||
'user_count' => $role->assignedUserCount(),
|
||||
'user_count' => $counts['user_count'],
|
||||
'platform_user_count' => $counts['platform_user_count'],
|
||||
'agent_user_count' => $counts['agent_user_count'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
46
app/Support/AdminRoleUserCounts.php
Normal file
46
app/Support/AdminRoleUserCounts.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AdminRoleUserCounts
|
||||
{
|
||||
/**
|
||||
* @param iterable<int> $roleIds
|
||||
* @return array<int, array{user_count: int, platform_user_count: int, agent_user_count: int}>
|
||||
*/
|
||||
public static function forRoleIds(iterable $roleIds): array
|
||||
{
|
||||
$ids = collect($roleIds)
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($ids->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table('admin_user_site_roles as usr')
|
||||
->leftJoin('admin_user_agents as uag', 'uag.admin_user_id', '=', 'usr.admin_user_id')
|
||||
->whereIn('usr.role_id', $ids->all())
|
||||
->groupBy('usr.role_id')
|
||||
->selectRaw(
|
||||
'usr.role_id, '
|
||||
.'COUNT(DISTINCT CASE WHEN uag.admin_user_id IS NULL THEN usr.admin_user_id END) AS platform_user_count, '
|
||||
.'COUNT(DISTINCT CASE WHEN uag.admin_user_id IS NOT NULL THEN usr.admin_user_id END) AS agent_user_count'
|
||||
)
|
||||
->get()
|
||||
->mapWithKeys(static function (object $row): array {
|
||||
$platformCount = (int) $row->platform_user_count;
|
||||
$agentCount = (int) $row->agent_user_count;
|
||||
|
||||
return [(int) $row->role_id => [
|
||||
'user_count' => $platformCount + $agentCount,
|
||||
'platform_user_count' => $platformCount,
|
||||
'agent_user_count' => $agentCount,
|
||||
]];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
}
|
||||
30
app/Support/InvalidPlatformAgentRoleCleanup.php
Normal file
30
app/Support/InvalidPlatformAgentRoleCleanup.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class InvalidPlatformAgentRoleCleanup
|
||||
{
|
||||
public static function run(): int
|
||||
{
|
||||
$agentRoleId = DB::table('admin_roles')
|
||||
->where('scope_type', AdminRole::SCOPE_SYSTEM)
|
||||
->where('slug', PlatformSystemRoles::SLUG_AGENT)
|
||||
->value('id');
|
||||
|
||||
if ($agentRoleId === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return DB::table('admin_user_site_roles')
|
||||
->where('role_id', (int) $agentRoleId)
|
||||
->whereNotExists(static function ($query): void {
|
||||
$query->selectRaw('1')
|
||||
->from('admin_user_agents as cleanup_uag')
|
||||
->whereColumn('cleanup_uag.admin_user_id', 'admin_user_site_roles.admin_user_id');
|
||||
})
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('players', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('native_token_version')
|
||||
->default(0)
|
||||
->after('login_locked_until');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('players', function (Blueprint $table): void {
|
||||
$table->dropColumn('native_token_version');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use App\Support\InvalidPlatformAgentRoleCleanup;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
InvalidPlatformAgentRoleCleanup::run();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// 非法的平台账号代理角色绑定无法可靠还原,回滚时保持清理结果。
|
||||
}
|
||||
};
|
||||
@@ -45,6 +45,7 @@ return [
|
||||
'agent_account_managed_in_agents' => 'Agent accounts must be managed in Agent Operations, not in the platform accounts page.',
|
||||
'agent_role_managed_in_agents' => 'Agent roles must be managed in Agent Operations, not in the platform roles page.',
|
||||
'system_roles_only' => 'Platform accounts can only be assigned platform roles.',
|
||||
'agent_role_not_assignable_to_platform_account' => 'The Agent role cannot be assigned to a platform account. Create or bind agent accounts in Agent Management.',
|
||||
'user_cannot_delete_self' => 'Cannot delete your own account.',
|
||||
'user_cannot_delete_last_super_admin' => 'Cannot delete the last super admin.',
|
||||
'super_admin_only_for_roles' => 'Only super admins can manage roles.',
|
||||
|
||||
@@ -168,4 +168,6 @@ return [
|
||||
'supports_multi_number' => 'supports multi-number',
|
||||
'reserved_rule_json' => 'reserved rules',
|
||||
'extra_config_json' => 'extra config',
|
||||
'current_password' => 'current password',
|
||||
'password_confirmation' => 'password confirmation',
|
||||
];
|
||||
|
||||
@@ -23,6 +23,9 @@ return [
|
||||
'agent_profile_required' => 'Configure share rate and credit on the agent profile first.',
|
||||
'parent_profile_required' => 'Configure share rate and credit on the parent agent profile first.',
|
||||
'wallet_player_prohibited' => 'Wallet players cannot have credit limits or rebate settings.',
|
||||
'current_password_invalid' => 'The current password is incorrect.',
|
||||
'new_password_must_differ' => 'The new password must differ from the current password.',
|
||||
'native_password_unavailable' => 'Main-site SSO players do not use a lottery password.',
|
||||
'exceeds_unpaid' => 'Payment amount cannot exceed the unpaid balance on this bill.',
|
||||
'invalid_range' => 'Share rate must be between 0 and 100.',
|
||||
'below_allocated' => 'Credit limit cannot be lower than credit already allocated to sub-agents.',
|
||||
|
||||
@@ -41,6 +41,7 @@ return [
|
||||
'agent_account_managed_in_agents' => 'एजेन्ट खाता प्लेटफर्म खाता पृष्ठबाट होइन, एजेन्ट अपरेसनबाट व्यवस्थापन गर्नुपर्छ।',
|
||||
'agent_role_managed_in_agents' => 'एजेन्ट भूमिका प्लेटफर्म भूमिका पृष्ठबाट होइन, एजेन्ट अपरेसनबाट व्यवस्थापन गर्नुपर्छ।',
|
||||
'system_roles_only' => 'प्लेटफर्म खातामा प्लेटफर्म भूमिका मात्र बाँड्न सकिन्छ।',
|
||||
'agent_role_not_assignable_to_platform_account' => 'प्लेटफर्म खातामा “एजेन्ट” भूमिका दिन मिल्दैन। एजेन्ट व्यवस्थापनमा एजेन्ट खाता सिर्जना वा बाँध्नुहोस्।',
|
||||
'user_cannot_delete_self' => 'आफ्नै खाता मेटाउन मिल्दैन।',
|
||||
'user_cannot_delete_last_super_admin' => 'अन्तिम सुपर एडमिन मेटाउन मिल्दैन।',
|
||||
'super_admin_only_for_roles' => 'भूमिका व्यवस्थापन केवल सुपर एडमिनले गर्न सक्छ।',
|
||||
|
||||
@@ -168,4 +168,6 @@ return [
|
||||
'supports_multi_number' => 'बहु-नम्बर समर्थन',
|
||||
'reserved_rule_json' => 'आरक्षित नियमहरू',
|
||||
'extra_config_json' => 'अतिरिक्त कन्फिग',
|
||||
'current_password' => 'हालको पासवर्ड',
|
||||
'password_confirmation' => 'पासवर्ड पुष्टि',
|
||||
];
|
||||
|
||||
@@ -23,6 +23,9 @@ return [
|
||||
'agent_profile_required' => 'पहिले एजेन्ट प्रोफाइलमा शेयर दर र क्रेडिट कन्फिगर गर्नुहोस्।',
|
||||
'parent_profile_required' => 'पहिले माथिल्लो एजेन्ट प्रोफाइलमा शेयर दर र क्रेडिट कन्फिगर गर्नुहोस्।',
|
||||
'wallet_player_prohibited' => 'वालेट खेलाडीहरूसँग क्रेडिट सीमा वा रिबेट सेटिङ हुन सक्दैन।',
|
||||
'current_password_invalid' => 'हालको पासवर्ड गलत छ।',
|
||||
'new_password_must_differ' => 'नयाँ पासवर्ड हालको पासवर्डभन्दा फरक हुनुपर्छ।',
|
||||
'native_password_unavailable' => 'मुख्य साइट SSO खेलाडीले लटरी पासवर्ड प्रयोग गर्दैन।',
|
||||
'exceeds_unpaid' => 'भुक्तानी रकम यस बिलको बाँकी रकम भन्दा बढी हुन सक्दैन।',
|
||||
'invalid_range' => 'शेयर दर ० र १०० बीचमा हुनुपर्छ।',
|
||||
'below_allocated' => 'क्रेडिट सीमा तलका एजेन्टहरूलाई पहिले नै आवण्टित क्रेडिट भन्दा कम हुन सक्दैन।',
|
||||
|
||||
@@ -45,6 +45,7 @@ return [
|
||||
'agent_account_managed_in_agents' => '代理账号请到「代理经营」中管理,平台账号页不再支持此操作。',
|
||||
'agent_role_managed_in_agents' => '代理角色请到「代理经营」中管理,平台角色页不再支持此操作。',
|
||||
'system_roles_only' => '平台账号只能分配平台角色。',
|
||||
'agent_role_not_assignable_to_platform_account' => '平台账号不能分配“代理”角色;请在“代理管理”中创建或绑定代理账号。',
|
||||
'user_cannot_delete_self' => '不能删除当前登录账号。',
|
||||
'user_cannot_delete_last_super_admin' => '不能删除最后一个超级管理员。',
|
||||
'super_admin_only_for_roles' => '仅超级管理员可管理角色。',
|
||||
|
||||
@@ -169,4 +169,6 @@ return [
|
||||
'can_grant_extra_rebate' => '允许额外回水',
|
||||
'can_create_child_agent' => '允许创建下级代理',
|
||||
'can_create_player' => '允许创建玩家',
|
||||
'current_password' => '当前密码',
|
||||
'password_confirmation' => '确认密码',
|
||||
];
|
||||
|
||||
@@ -24,6 +24,9 @@ return [
|
||||
'exceeds_default_rebate_limit' => '默认玩家回水不能超过本节点回水上限。',
|
||||
'not_allowed' => '当前代理未开放该能力,无法设置。',
|
||||
'wallet_player_prohibited' => '主站钱包玩家不支持授信额度与回水设置。',
|
||||
'current_password_invalid' => '当前密码不正确。',
|
||||
'new_password_must_differ' => '新密码不能与当前密码相同。',
|
||||
'native_password_unavailable' => '主站 SSO 玩家不使用彩票端密码。',
|
||||
'exceeds_unpaid' => '收付金额不能超过账单未结金额。',
|
||||
'invalid_range' => '占成比例必须在 0–100 之间。',
|
||||
'below_allocated' => '代理授信额度不能低于已下发给下级代理与玩家的总额。',
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerShowController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerIndexController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerStoreController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerShowController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Player\PlayerWalletShowController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerFreezeController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerUpdateController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerDestroyController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerFreezeController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerUnfreezeController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Player\PlayerWalletShowController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerPasswordResetController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerTicketItemsIndexController;
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,8 @@ Route::middleware('admin.api-resource')
|
||||
->name('api.v1.admin.players.show');
|
||||
Route::put('players/{player}', AdminPlayerUpdateController::class)
|
||||
->name('api.v1.admin.players.update');
|
||||
Route::put('players/{player}/password', AdminPlayerPasswordResetController::class)
|
||||
->name('api.v1.admin.players.password.reset');
|
||||
Route::post('players/{player}/freeze', AdminPlayerFreezeController::class)
|
||||
->name('api.v1.admin.players.freeze');
|
||||
Route::post('players/{player}/unfreeze', AdminPlayerUnfreezeController::class)
|
||||
|
||||
@@ -6,13 +6,14 @@ use App\Http\Controllers\Api\V1\Wallet\WalletLogsController;
|
||||
use App\Http\Controllers\Api\V1\Ticket\TicketPlaceController;
|
||||
use App\Http\Controllers\Api\V1\Ticket\TicketPreviewController;
|
||||
use App\Http\Controllers\Api\V1\Wallet\WalletBalanceController;
|
||||
use App\Http\Controllers\Api\V1\Wallet\WalletSettlementBillsController;
|
||||
use App\Http\Controllers\Api\V1\Ticket\TicketItemShowController;
|
||||
use App\Http\Controllers\Api\V1\Ticket\TicketItemsIndexController;
|
||||
use App\Http\Controllers\Api\V1\Wallet\WalletTransferInController;
|
||||
use App\Http\Controllers\Api\V1\Ticket\TicketDrawMyMatchController;
|
||||
use App\Http\Controllers\Api\V1\Wallet\WalletTransferOutController;
|
||||
use App\Http\Controllers\Api\V1\Player\PlayerPasswordUpdateController;
|
||||
use App\Http\Controllers\Api\V1\BetProvider\BetProviderIndexController;
|
||||
use App\Http\Controllers\Api\V1\Wallet\WalletSettlementBillsController;
|
||||
|
||||
/**
|
||||
* 玩家端路由(需 middleware lottery.player)。
|
||||
@@ -23,6 +24,7 @@ Route::middleware('lottery.player')->group(function (): void {
|
||||
->name('api.v1.player.')
|
||||
->group(function (): void {
|
||||
Route::get('me', MeController::class)->name('me');
|
||||
Route::put('password', PlayerPasswordUpdateController::class)->name('password.update');
|
||||
});
|
||||
|
||||
// 钱包
|
||||
|
||||
214
tests/Feature/AdminPlatformRoleBoundaryTest.php
Normal file
214
tests/Feature/AdminPlatformRoleBoundaryTest.php
Normal file
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use App\Lottery\ErrorCode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Support\PlatformSystemRoles;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use App\Services\Agent\AgentNodeService;
|
||||
use App\Support\InvalidPlatformAgentRoleCleanup;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function makePlatformRoleBoundaryToken(string $username): string
|
||||
{
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => $username,
|
||||
'name' => 'Role Boundary Manager',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
$role = AdminRole::query()->create([
|
||||
'slug' => 'boundary_'.$username,
|
||||
'name' => 'Boundary Manager',
|
||||
'scope_type' => AdminRole::SCOPE_SYSTEM,
|
||||
]);
|
||||
$role->syncLegacyPermissionSlugs(['prd.admin_user.manage', 'prd.admin_role.manage']);
|
||||
$admin->roles()->sync([
|
||||
(int) $role->id => [
|
||||
'site_id' => AdminUser::requireDefaultAdminSiteId(),
|
||||
'granted_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
}
|
||||
|
||||
function createPlatformRoleBoundaryAgent(string $username): AdminUser
|
||||
{
|
||||
$siteId = AdminUser::requireDefaultAdminSiteId();
|
||||
$root = AgentNode::query()
|
||||
->where('admin_site_id', $siteId)
|
||||
->where('depth', 0)
|
||||
->firstOrFail();
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'super_'.$username,
|
||||
'name' => 'Super',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
|
||||
$node = app(AgentNodeService::class)->createChild($super, [
|
||||
'parent_id' => (int) $root->id,
|
||||
'code' => 'node-'.$username,
|
||||
'name' => 'Agent '.$username,
|
||||
'username' => $username,
|
||||
'password' => 'secret-strong',
|
||||
]);
|
||||
|
||||
$adminUserId = DB::table('admin_user_agents')
|
||||
->where('agent_node_id', $node->id)
|
||||
->where('is_primary', true)
|
||||
->value('admin_user_id');
|
||||
|
||||
return AdminUser::query()->findOrFail((int) $adminUserId);
|
||||
}
|
||||
|
||||
test('permission catalog keeps all roles but only exposes platform assignable roles', function (): void {
|
||||
$token = makePlatformRoleBoundaryToken('catalog_boundary');
|
||||
|
||||
$data = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/admin-user-permission-catalog')
|
||||
->assertOk()
|
||||
->json('data');
|
||||
|
||||
expect(collect($data['roles'])->pluck('slug')->all())
|
||||
->toContain(PlatformSystemRoles::SLUG_AGENT, PlatformSystemRoles::SLUG_SUPER_ADMIN);
|
||||
expect(collect($data['assignable_roles'])->pluck('slug')->all())
|
||||
->not->toContain(PlatformSystemRoles::SLUG_AGENT, PlatformSystemRoles::SLUG_SUPER_ADMIN);
|
||||
});
|
||||
|
||||
test('platform account create and role update reject the agent role', function (): void {
|
||||
$token = makePlatformRoleBoundaryToken('assignment_boundary');
|
||||
$siteId = AdminUser::requireDefaultAdminSiteId();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->withHeader('X-Locale', 'zh')
|
||||
->postJson('/api/v1/admin/admin-users', [
|
||||
'username' => 'illegal_agent_platform',
|
||||
'nickname' => 'Illegal Agent Platform',
|
||||
'email' => null,
|
||||
'password' => 'secret-strong',
|
||||
'status' => 0,
|
||||
'admin_site_id' => $siteId,
|
||||
'role_slugs' => [PlatformSystemRoles::SLUG_AGENT],
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', ErrorCode::ValidationFailed->value)
|
||||
->assertJsonPath('msg', '平台账号不能分配“代理”角色;请在“代理管理”中创建或绑定代理账号。');
|
||||
|
||||
expect(AdminUser::query()->where('username', 'illegal_agent_platform')->exists())->toBeFalse();
|
||||
|
||||
$target = AdminUser::query()->create([
|
||||
'username' => 'platform_role_target',
|
||||
'name' => 'Platform Role Target',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/admin-users/'.$target->id.'/roles', [
|
||||
'admin_site_id' => $siteId,
|
||||
'role_slugs' => [PlatformSystemRoles::SLUG_AGENT],
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', ErrorCode::ValidationFailed->value);
|
||||
});
|
||||
|
||||
test('agent creation keeps its agent role and cleanup removes only unbound platform assignments', function (): void {
|
||||
$agentUser = createPlatformRoleBoundaryAgent('legal_agent_boundary');
|
||||
$agentRole = AdminRole::query()
|
||||
->where('scope_type', AdminRole::SCOPE_SYSTEM)
|
||||
->where('slug', PlatformSystemRoles::SLUG_AGENT)
|
||||
->firstOrFail();
|
||||
$siteId = AdminUser::requireDefaultAdminSiteId();
|
||||
|
||||
expect(DB::table('admin_user_agents')->where('admin_user_id', $agentUser->id)->exists())->toBeTrue();
|
||||
expect(DB::table('admin_user_site_roles')
|
||||
->where('admin_user_id', $agentUser->id)
|
||||
->where('role_id', $agentRole->id)
|
||||
->exists())->toBeTrue();
|
||||
|
||||
$illegalUser = AdminUser::query()->create([
|
||||
'username' => 'unbound_agent_role',
|
||||
'name' => 'Unbound Agent Role',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
DB::table('admin_user_site_roles')->insert([
|
||||
'admin_user_id' => $illegalUser->id,
|
||||
'site_id' => $siteId,
|
||||
'role_id' => $agentRole->id,
|
||||
'granted_at' => now(),
|
||||
]);
|
||||
|
||||
expect(InvalidPlatformAgentRoleCleanup::run())->toBe(1);
|
||||
expect(DB::table('admin_user_site_roles')
|
||||
->where('admin_user_id', $illegalUser->id)
|
||||
->where('role_id', $agentRole->id)
|
||||
->exists())->toBeFalse();
|
||||
expect(DB::table('admin_user_site_roles')
|
||||
->where('admin_user_id', $agentUser->id)
|
||||
->where('role_id', $agentRole->id)
|
||||
->exists())->toBeTrue();
|
||||
expect(AdminUser::query()->whereKey($illegalUser->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('role counts classify platform and agent accounts and role filter never leaks agents', function (): void {
|
||||
$token = makePlatformRoleBoundaryToken('count_boundary');
|
||||
$siteId = AdminUser::requireDefaultAdminSiteId();
|
||||
$role = AdminRole::query()->create([
|
||||
'slug' => 'counted_role',
|
||||
'name' => 'Counted Role',
|
||||
'scope_type' => AdminRole::SCOPE_SYSTEM,
|
||||
]);
|
||||
$platformUser = AdminUser::query()->create([
|
||||
'username' => 'counted_platform',
|
||||
'name' => 'Counted Platform',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
$platformUser->roles()->sync([
|
||||
(int) $role->id => [
|
||||
'site_id' => $siteId,
|
||||
'granted_at' => now(),
|
||||
],
|
||||
]);
|
||||
$agentUser = createPlatformRoleBoundaryAgent('counted_agent');
|
||||
DB::table('admin_user_site_roles')->insert([
|
||||
'admin_user_id' => $agentUser->id,
|
||||
'site_id' => $siteId,
|
||||
'role_id' => $role->id,
|
||||
'granted_at' => now(),
|
||||
]);
|
||||
|
||||
$roleRow = collect($this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/admin-roles')
|
||||
->assertOk()
|
||||
->json('data.items'))
|
||||
->firstWhere('slug', 'counted_role');
|
||||
|
||||
expect($roleRow)
|
||||
->not->toBeNull()
|
||||
->and($roleRow['platform_user_count'])->toBe(1)
|
||||
->and($roleRow['agent_user_count'])->toBe(1)
|
||||
->and($roleRow['user_count'])->toBe(2);
|
||||
|
||||
$items = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/admin-users?role_slug=counted_role')
|
||||
->assertOk()
|
||||
->json('data.items');
|
||||
|
||||
expect(collect($items)->pluck('username')->all())
|
||||
->toBe(['counted_platform'])
|
||||
->not->toContain('counted_agent');
|
||||
});
|
||||
@@ -1,21 +1,28 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AgentProfile;
|
||||
use App\Models\Player;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Models\AgentProfile;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Support\PlayerAuthSource;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Services\Agent\AgentNodeService;
|
||||
use App\Services\Player\PlayerNativeAuthService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
config([
|
||||
'lottery.player_auth.native.secret' => 'test-native-jwt-secret-32bytes!!',
|
||||
'lottery.player_auth.native.ttl_seconds' => 3600,
|
||||
]);
|
||||
$this->seed(CurrencySeeder::class);
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
});
|
||||
@@ -251,7 +258,7 @@ test('agent players list can filter direct players without including downline pl
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
|
||||
$child = app(\App\Services\Agent\AgentNodeService::class)->createChild($super, [
|
||||
$child = app(AgentNodeService::class)->createChild($super, [
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'direct-child',
|
||||
'name' => 'Direct Child',
|
||||
@@ -355,7 +362,7 @@ test('admin cannot change credit player default currency', function (): void {
|
||||
'default_currency' => 'USD',
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value);
|
||||
->assertJsonPath('code', ErrorCode::ValidationFailed->value);
|
||||
|
||||
$this->assertDatabaseHas('players', [
|
||||
'id' => $player->id,
|
||||
@@ -444,14 +451,14 @@ test('wallet player update rejects credit limit and rebate', function (): void {
|
||||
'credit_limit' => 1000,
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value);
|
||||
->assertJsonPath('code', ErrorCode::ValidationFailed->value);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/players/'.$player->id, [
|
||||
'rebate_rate' => 1,
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value);
|
||||
->assertJsonPath('code', ErrorCode::ValidationFailed->value);
|
||||
});
|
||||
|
||||
test('native player create rejects chinese username', function (): void {
|
||||
@@ -467,7 +474,7 @@ test('native player create rejects chinese username', function (): void {
|
||||
'default_currency' => 'NPR',
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value);
|
||||
->assertJsonPath('code', ErrorCode::ValidationFailed->value);
|
||||
});
|
||||
|
||||
test('partial rebate update preserves the other rebate field', function (): void {
|
||||
@@ -555,3 +562,99 @@ test('partial rebate update preserves the other rebate field', function (): void
|
||||
'extra_rebate_rate' => 0.001,
|
||||
]);
|
||||
});
|
||||
|
||||
test('admin can reset native player password and invalidate active tokens', function (): void {
|
||||
$siteCode = DB::table('admin_sites')->where('is_default', true)->value('code');
|
||||
$siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site';
|
||||
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'agent_node_id' => $rootId,
|
||||
'site_player_id' => 'native-admin-reset',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'native_admin_reset',
|
||||
'password_hash' => Hash::make('old-secret'),
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
$oldToken = app(PlayerNativeAuthService::class)->issueToken($player);
|
||||
$adminToken = playerManageAdminToken();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$adminToken)
|
||||
->putJson('/api/v1/admin/players/'.$player->id.'/password', [
|
||||
'password' => 'reset-secret',
|
||||
'password_confirmation' => 'reset-secret',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.password_reset', true)
|
||||
->assertJsonPath('data.player_id', $player->id);
|
||||
|
||||
$player->refresh();
|
||||
expect(Hash::check('reset-secret', (string) $player->password_hash))->toBeTrue()
|
||||
->and($player->native_token_version)->toBe(1);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$oldToken)
|
||||
->getJson('/api/v1/player/me')
|
||||
->assertStatus(401)
|
||||
->assertJsonPath('code', ErrorCode::PlayerTokenInvalid->value);
|
||||
|
||||
$this->assertDatabaseHas('audit_logs', [
|
||||
'operator_type' => 'admin',
|
||||
'module_code' => 'player_service',
|
||||
'action_code' => 'reset',
|
||||
'target_id' => (string) $player->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('admin cannot reset password for sso player', function (): void {
|
||||
$siteCode = DB::table('admin_sites')->where('is_default', true)->value('code');
|
||||
$siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site';
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'site_player_id' => 'sso-admin-reset-blocked',
|
||||
'auth_source' => PlayerAuthSource::MAIN_SITE_SSO,
|
||||
'funding_mode' => PlayerFundingMode::WALLET,
|
||||
'username' => 'sso_admin_reset_blocked',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.playerManageAdminToken())
|
||||
->putJson('/api/v1/admin/players/'.$player->id.'/password', [
|
||||
'password' => 'reset-secret',
|
||||
'password_confirmation' => 'reset-secret',
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('msg', 'Main-site SSO players do not use a lottery password.');
|
||||
});
|
||||
|
||||
test('player view permission cannot reset native player password', function (): void {
|
||||
$siteCode = DB::table('admin_sites')->where('is_default', true)->value('code');
|
||||
$siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site';
|
||||
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'agent_node_id' => $rootId,
|
||||
'site_player_id' => 'native-view-reset-blocked',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'native_view_reset_blocked',
|
||||
'password_hash' => Hash::make('old-secret'),
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
$viewToken = playerPermissionAdminToken('player_password_view_only', ['prd.users.view']);
|
||||
|
||||
playerPermissionRequest($this, $viewToken)
|
||||
->putJson('/api/v1/admin/players/'.$player->id.'/password', [
|
||||
'password' => 'reset-secret',
|
||||
'password_confirmation' => 'reset-secret',
|
||||
])
|
||||
->assertStatus(403);
|
||||
|
||||
expect(Hash::check('old-secret', (string) $player->fresh()->password_hash))->toBeTrue();
|
||||
});
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Lottery\ErrorCode;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Support\PlayerAuthSource;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use App\Services\Player\PlayerNativeAuthService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -257,7 +260,7 @@ test('credit player wallet transfer in is rejected', function (): void {
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$auth = app(\App\Services\Player\PlayerNativeAuthService::class);
|
||||
$auth = app(PlayerNativeAuthService::class);
|
||||
$token = $auth->issueToken($player);
|
||||
|
||||
$response = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
@@ -293,7 +296,7 @@ test('sso wallet player balance does not use credit when site credit mode on', f
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
\App\Models\PlayerWallet::query()->create([
|
||||
PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => 'lottery',
|
||||
'currency_code' => 'NPR',
|
||||
@@ -311,3 +314,108 @@ test('sso wallet player balance does not use credit when site credit mode on', f
|
||||
->assertJsonPath('data.funding_mode', PlayerFundingMode::WALLET)
|
||||
->assertJsonPath('data.available_balance', 12000);
|
||||
});
|
||||
|
||||
test('native player can change password and previous tokens are invalidated', function (): void {
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => (string) $site->code,
|
||||
'agent_node_id' => $rootId,
|
||||
'site_player_id' => 'native:password-change',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'password_change_user',
|
||||
'password_hash' => Hash::make('old-secret'),
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$oldToken = app(PlayerNativeAuthService::class)->issueToken($player);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$oldToken)
|
||||
->putJson('/api/v1/player/password', [
|
||||
'current_password' => 'old-secret',
|
||||
'password' => 'new-secret',
|
||||
'password_confirmation' => 'new-secret',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.password_changed', true);
|
||||
|
||||
$player->refresh();
|
||||
expect(Hash::check('new-secret', (string) $player->password_hash))->toBeTrue()
|
||||
->and($player->native_token_version)->toBe(1);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$oldToken)
|
||||
->getJson('/api/v1/player/me')
|
||||
->assertStatus(401)
|
||||
->assertJsonPath('code', ErrorCode::PlayerTokenInvalid->value);
|
||||
|
||||
$this->postJson('/api/v1/player/auth/login', array_merge([
|
||||
'username' => 'password_change_user',
|
||||
'password' => 'old-secret',
|
||||
], playerLoginCaptcha()))
|
||||
->assertStatus(401)
|
||||
->assertJsonPath('code', ErrorCode::PlayerCredentialsInvalid->value);
|
||||
|
||||
$this->postJson('/api/v1/player/auth/login', array_merge([
|
||||
'username' => 'password_change_user',
|
||||
'password' => 'new-secret',
|
||||
], playerLoginCaptcha()))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.player.id', $player->id);
|
||||
});
|
||||
|
||||
test('native player password change validates current password', function (): void {
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => (string) $site->code,
|
||||
'agent_node_id' => $rootId,
|
||||
'site_player_id' => 'native:password-current',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'password_current_user',
|
||||
'password_hash' => Hash::make('old-secret'),
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$token = app(PlayerNativeAuthService::class)->issueToken($player);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/player/password', [
|
||||
'current_password' => 'wrong-secret',
|
||||
'password' => 'new-secret',
|
||||
'password_confirmation' => 'new-secret',
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('msg', 'The current password is incorrect.');
|
||||
|
||||
expect($player->fresh()->native_token_version)->toBe(0)
|
||||
->and(Hash::check('old-secret', (string) $player->fresh()->password_hash))->toBeTrue();
|
||||
});
|
||||
|
||||
test('sso player cannot use native password management', function (): void {
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => (string) $site->code,
|
||||
'site_player_id' => 'sso-no-password',
|
||||
'auth_source' => PlayerAuthSource::MAIN_SITE_SSO,
|
||||
'funding_mode' => PlayerFundingMode::WALLET,
|
||||
'username' => 'sso_no_password',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->putJson('/api/v1/player/password', [
|
||||
'current_password' => 'old-secret',
|
||||
'password' => 'new-secret',
|
||||
'password_confirmation' => 'new-secret',
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('msg', 'Main-site SSO players do not use a lottery password.');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user