feat(admin): 增加玩家密码管理与角色边界

This commit is contained in:
wchino
2026-07-21 21:11:07 +08:00
parent cec2fd7c4f
commit ba54444979
32 changed files with 845 additions and 34 deletions

View File

@@ -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);
}
}

View File

@@ -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(),
]);
}
}

View File

@@ -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(),
]);
}
}

View File

@@ -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));

View File

@@ -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);
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View File

@@ -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)

View File

@@ -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',
];
}

View File

@@ -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,

View 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();
}
}

View File

@@ -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)) {

View File

@@ -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']],

View File

@@ -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'],
];
}
}

View 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();
}
}

View 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();
}
}

View File

@@ -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');
});
}
};

View File

@@ -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
{
// 非法的平台账号代理角色绑定无法可靠还原,回滚时保持清理结果。
}
};

View File

@@ -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.',

View File

@@ -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',
];

View File

@@ -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.',

View File

@@ -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' => 'भूमिका व्यवस्थापन केवल सुपर एडमिनले गर्न सक्छ।',

View File

@@ -168,4 +168,6 @@ return [
'supports_multi_number' => 'बहु-नम्बर समर्थन',
'reserved_rule_json' => 'आरक्षित नियमहरू',
'extra_config_json' => 'अतिरिक्त कन्फिग',
'current_password' => 'हालको पासवर्ड',
'password_confirmation' => 'पासवर्ड पुष्टि',
];

View File

@@ -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' => 'क्रेडिट सीमा तलका एजेन्टहरूलाई पहिले नै आवण्टित क्रेडिट भन्दा कम हुन सक्दैन।',

View File

@@ -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' => '仅超级管理员可管理角色。',

View File

@@ -169,4 +169,6 @@ return [
'can_grant_extra_rebate' => '允许额外回水',
'can_create_child_agent' => '允许创建下级代理',
'can_create_player' => '允许创建玩家',
'current_password' => '当前密码',
'password_confirmation' => '确认密码',
];

View File

@@ -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' => '占成比例必须在 0100 之间。',
'below_allocated' => '代理授信额度不能低于已下发给下级代理与玩家的总额。',

View File

@@ -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)

View File

@@ -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');
});
// 钱包

View 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');
});

View File

@@ -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();
});

View File

@@ -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.');
});