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