공개 이슈 gnuboard/g7 이 지목한 병목은 "같은 일을 반복 실행한다" 축이다. 응답·행의 무게를 다룬 · 과 겹치지 않는다. 공통 계약 — 총 건수 상한과 페이지 이동 범위는 별개 결정이다. 묶으면 필요 없이 기능이 깎인다. 총 건수만 상한을 받고(파생 테이블 COUNT), "다음" 이동은 per_page + 1 실측으로 끝까지 열어 둔다. 계산이 불가능해지는 것은 마지막 페이지 번호 하나뿐이며 그 사실은 last_page: null 이 알린다. 최신순처럼 실제 컬럼으로 정렬하는 목록은 커서로 전환해 깊이와 무관하게 일정 속도로 이동한다. 관련도순은 계산값 정렬이라 커서 키로 쓸 수 없어 offset 을 유지한다. 계약의 입구는 표준 paginate 와 같은 폭이어야 한다. 관계·쿼리 빌더를 받지 못하면 그 좁은 만큼이 그대로 운영 500 이 되고, 실제로 관리자 알림 목록에서 그렇게 터졌다. 응답 조립도 컬렉션마다 손으로 하면 형태가 늘어나는 순간 없는 값을 부르거나 새 필드를 흘리므로, 형태 판정을 paginationMeta 한 곳에 모았다. 표준 paginate 응답은 필드 단위로 이전과 동일하다. 요청당 반복 비용 — 훅 구독마다 남기던 로그 400줄, 요청당 스무 번 넘던 설정 파일 재읽기, 이미 캐시된 목록의 DB 재조회를 없앴다. 권한 판정은 요청 스코프 메모를 두어 화면 요소마다 나가던 조회를 한 번으로 줄였고, 크로스 요청 캐시는 두지 않아 권한 변경이 종전처럼 다음 요청에 반영된다. 검색 질의는 활성 엔진이 만든다. 저장소가 구체 엔진을 지목하면 플러그인이 등록한 엔진은 호출될 기회 자체를 잃고 오류 없이 다른 방식으로 동작한다. 해석기를 두어 활성 엔진에 위임하고, 전문검색이 없는 DBMS 의 부분일치 폴백도 드라이버명 하드코딩 대신 선언형 config 로 옮겼다. 총 건수가 잘린 목록에서 순번을 역산하면 0 과 음수가 나온다. 지어내지 않고 null 을 돌려주며, 그 원칙을 last_page 와 동일하게 적용했다.
442 lines
14 KiB
PHP
442 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Repositories;
|
|
|
|
use App\Contracts\Repositories\UserRepositoryInterface;
|
|
use App\Helpers\PermissionHelper;
|
|
use App\Models\User;
|
|
use App\Repositories\Concerns\FiltersByDateRange;
|
|
use App\Repositories\Concerns\HasMultipleSearchFilters;
|
|
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
|
|
use App\Repositories\Concerns\ResolvesSortSpec;
|
|
use App\Support\Query\PaginationLimits;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Support\Carbon;
|
|
use Laravel\Sanctum\PersonalAccessToken;
|
|
|
|
class UserRepository implements UserRepositoryInterface
|
|
{
|
|
use FiltersByDateRange;
|
|
use HasMultipleSearchFilters;
|
|
use PaginatesWithDeferredJoin;
|
|
use ResolvesSortSpec;
|
|
|
|
/**
|
|
* 검색 가능한 필드 목록
|
|
*/
|
|
private const SEARCHABLE_FIELDS = ['name', 'email'];
|
|
|
|
/**
|
|
* 허용 정렬 컬럼 (UserListRequest 와 동일 집합)
|
|
*/
|
|
private const SORTABLE_COLUMNS = ['created_at', 'name', 'email', 'last_login_at'];
|
|
|
|
/**
|
|
* 이메일로 사용자를 찾습니다.
|
|
*
|
|
* @param string $email 찾을 사용자의 이메일
|
|
* @return User|null 찾은 사용자 모델 또는 null
|
|
*/
|
|
public function findByEmail(string $email): ?User
|
|
{
|
|
return User::where('email', $email)->first();
|
|
}
|
|
|
|
/**
|
|
* 새로운 사용자를 생성합니다.
|
|
*
|
|
* @param array $data 사용자 생성 데이터
|
|
* @return User 생성된 사용자 모델
|
|
*/
|
|
public function create(array $data): User
|
|
{
|
|
return User::create($data);
|
|
}
|
|
|
|
/**
|
|
* 기존 사용자를 업데이트합니다.
|
|
*
|
|
* @param User $user 업데이트할 사용자 모델
|
|
* @param array $data 업데이트할 데이터
|
|
* @return bool 업데이트 성공 여부
|
|
*/
|
|
public function update(User $user, array $data): bool
|
|
{
|
|
return $user->update($data);
|
|
}
|
|
|
|
/**
|
|
* 사용자를 삭제합니다.
|
|
*
|
|
* @param User $user 삭제할 사용자 모델
|
|
* @return bool 삭제 성공 여부
|
|
*/
|
|
public function delete(User $user): bool
|
|
{
|
|
return $user->delete();
|
|
}
|
|
|
|
/**
|
|
* 모든 사용자를 조회합니다.
|
|
*
|
|
* @return Collection 사용자 컬렉션
|
|
*/
|
|
public function getAll(): Collection
|
|
{
|
|
return User::all();
|
|
}
|
|
|
|
/**
|
|
* ID로 사용자를 찾습니다.
|
|
*
|
|
* @param int $id 사용자 ID
|
|
* @return User|null 찾은 사용자 모델 또는 null
|
|
*/
|
|
public function findById(int $id): ?User
|
|
{
|
|
return User::find($id);
|
|
}
|
|
|
|
/**
|
|
* 필터링 및 페이지네이션이 적용된 사용자 목록을 조회합니다.
|
|
*
|
|
* @param array $filters 필터 조건 배열
|
|
* @return LengthAwarePaginator 페이지네이션된 사용자 목록
|
|
*/
|
|
public function getPaginatedUsers(array $filters = []): LengthAwarePaginator
|
|
{
|
|
$query = User::query();
|
|
|
|
// 권한 스코프 필터링
|
|
PermissionHelper::applyPermissionScope($query, 'core.users.read');
|
|
|
|
// 검색 조건 적용
|
|
$this->applyFilters($query, $filters);
|
|
|
|
// 정렬 적용 (허용 컬럼 화이트리스트로 해석)
|
|
$sort = $this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'created_at');
|
|
|
|
// 페이지네이션 적용
|
|
$perPage = $filters['per_page'] ?? 15;
|
|
|
|
// 지연 조인: inner 는 id 만 훑고 roles eager loading 은 해당 페이지에만 적용된다
|
|
return $this->paginateWithDeferredJoin(
|
|
query: $query,
|
|
columns: ['*'],
|
|
sort: $sort,
|
|
perPage: $perPage,
|
|
relations: ['roles'],
|
|
// 회원 수가 커져도 목록 첫 화면 비용이 총 건수 COUNT 에 끌려가지 않도록 상한을 건다.
|
|
resultCap: PaginationLimits::resultCap('admin.users'),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 쿼리에 필터 조건을 적용합니다.
|
|
*
|
|
* @param Builder $query Eloquent 쿼리 빌더
|
|
* @param array $filters 적용할 필터 조건 배열
|
|
*/
|
|
private function applyFilters(Builder $query, array $filters): void
|
|
{
|
|
// 다중 검색 조건 적용
|
|
if (! empty($filters['filters']) && is_array($filters['filters'])) {
|
|
$this->applyMultipleSearchFilters($query, $filters['filters'], self::SEARCHABLE_FIELDS);
|
|
}
|
|
|
|
// 날짜 필터
|
|
$this->applyDateFilters($query, $filters);
|
|
}
|
|
|
|
/**
|
|
* 날짜 필터를 적용합니다.
|
|
*
|
|
* @param Builder $query Eloquent 쿼리 빌더
|
|
* @param array $filters 필터 조건 배열
|
|
*/
|
|
private function applyDateFilters(Builder $query, array $filters): void
|
|
{
|
|
// whereDate 는 컬럼에 DATE() 를 씌워 인덱스를 무력화한다 — 범위 조건으로 준다.
|
|
$this->applyDateRangeFilter(
|
|
$query,
|
|
'created_at',
|
|
$filters['start_date'] ?? null,
|
|
$filters['end_date'] ?? null
|
|
);
|
|
|
|
// 기본 날짜 필터 (전체가 아닌 경우)
|
|
if (empty($filters['start_date']) && empty($filters['end_date']) &&
|
|
($filters['date_filter'] ?? 'all') !== 'all') {
|
|
$dateFilter = $filters['date_filter'] ?? 'all';
|
|
|
|
match ($dateFilter) {
|
|
'week' => $query->where('created_at', '>=', now()->subWeek()),
|
|
'month' => $query->where('created_at', '>=', now()->subMonth()),
|
|
default => null,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 사용자 관련 통계 정보를 조회합니다.
|
|
*
|
|
* @return array 사용자 통계 데이터 배열
|
|
*/
|
|
public function getStatistics(): array
|
|
{
|
|
return [
|
|
'total_users' => User::count(),
|
|
'users_this_week' => User::where('created_at', '>=', now()->subWeek())->count(),
|
|
'users_this_month' => User::where('created_at', '>=', now()->subMonth())->count(),
|
|
'users_today' => User::whereDate('created_at', today())->count(),
|
|
'active_users_this_week' => User::where('last_login_at', '>=', now()->subWeek())->count(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 키워드로 사용자를 검색합니다. (이름, 닉네임, 이메일)
|
|
*
|
|
* @param string $keyword 검색할 키워드
|
|
* @return Collection 검색된 사용자 컬렉션
|
|
*/
|
|
public function searchByKeyword(string $keyword): Collection
|
|
{
|
|
$query = User::where(function ($q) use ($keyword) {
|
|
$q->where('name', 'like', "%{$keyword}%")
|
|
->orWhere('nickname', 'like', "%{$keyword}%")
|
|
->orWhere('email', 'like', "%{$keyword}%");
|
|
});
|
|
|
|
// 권한 스코프 필터링
|
|
PermissionHelper::applyPermissionScope($query, 'core.users.read');
|
|
|
|
return $query->get();
|
|
}
|
|
|
|
/**
|
|
* 최근 등록된 사용자들을 조회합니다.
|
|
*
|
|
* @param int $limit 조회할 사용자 수 (기본값: 10)
|
|
* @return Collection 최근 사용자 컬렉션
|
|
*/
|
|
public function getRecentUsers(int $limit = 10): Collection
|
|
{
|
|
return User::orderBy('created_at', 'desc')->limit($limit)->get();
|
|
}
|
|
|
|
/**
|
|
* 언어별 사용자 수를 조회합니다.
|
|
*
|
|
* @return array 언어별 사용자 수 배열
|
|
*/
|
|
public function getUsersByLanguage(): array
|
|
{
|
|
return User::selectRaw('language, count(*) as count')
|
|
->groupBy('language')
|
|
->pluck('count', 'language')
|
|
->toArray();
|
|
}
|
|
|
|
/**
|
|
* UUID 목록으로 사용자들을 조회하고 UUID 키 맵으로 반환합니다.
|
|
*
|
|
* @param array<int, string> $uuids 사용자 UUID 목록
|
|
* @return Collection<string, User> uuid => User 매핑
|
|
*/
|
|
public function findManyByUuidsKeyed(array $uuids): Collection
|
|
{
|
|
return User::whereIn('uuid', $uuids)->get()->keyBy('uuid');
|
|
}
|
|
|
|
/**
|
|
* UUID 목록으로 사용자들을 조회합니다.
|
|
*
|
|
* @param array<int, string> $uuids 사용자 UUID 목록
|
|
* @return Collection<int, User> 조회된 사용자 컬렉션
|
|
*/
|
|
public function findManyByUuids(array $uuids): Collection
|
|
{
|
|
return User::whereIn('uuid', $uuids)->get();
|
|
}
|
|
|
|
/**
|
|
* 슈퍼관리자 1명을 조회합니다.
|
|
*
|
|
* 역할 기반 수신자 해석에서 대상 역할에 사용자가 없을 때 폴백 수신자로 사용합니다.
|
|
*
|
|
* @return User|null 슈퍼관리자 또는 없으면 null
|
|
*/
|
|
public function findSuperAdmin(): ?User
|
|
{
|
|
return User::superAdmins()->first();
|
|
}
|
|
|
|
/**
|
|
* 특정 권한 identifier 를 가진 역할에 소속된 모든 사용자를 조회합니다.
|
|
*
|
|
* "권한을 가진 자" 기준이므로 권한이 회수되면 자동으로 결과에서 제외됩니다.
|
|
*
|
|
* @param string $permissionIdentifier 권한 identifier
|
|
* @return Collection<int, User> 권한 보유 사용자 컬렉션
|
|
*/
|
|
public function findManyByPermissionIdentifier(string $permissionIdentifier): Collection
|
|
{
|
|
return User::query()
|
|
->whereHas('roles.permissions', function ($query) use ($permissionIdentifier) {
|
|
$query->where('permissions.identifier', $permissionIdentifier);
|
|
})
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* 사용자의 연속 로그인 실패 카운터를 1 증가시킵니다.
|
|
*
|
|
* @param User $user 대상 사용자
|
|
* @return int 증가 후 카운트
|
|
*/
|
|
public function incrementFailedAttempts(User $user): int
|
|
{
|
|
$next = (int) ($user->failed_login_attempts ?? 0) + 1;
|
|
|
|
$user->forceFill([
|
|
'failed_login_attempts' => $next,
|
|
'last_failed_login_at' => now(),
|
|
])->save();
|
|
|
|
return $next;
|
|
}
|
|
|
|
/**
|
|
* 사용자의 계정을 지정된 분만큼 잠급니다.
|
|
*
|
|
* `$minutes <= 0` 은 보안 환경설정의 "0 = 무한대" 규약에 따라 영구 잠금으로 처리합니다.
|
|
* 영구 잠금은 해제 시각이 없으므로 `locked_until` 을 NULL 로 두고
|
|
* `locked_permanently` 플래그로 상태를 표현합니다 (NULL 은 이미 "잠금 없음" 을 의미).
|
|
*
|
|
* @param User $user 잠글 사용자
|
|
* @param int $minutes 잠금 유지 시간(분). 0 이하는 무기한
|
|
* @return Carbon|null 잠금 해제 시각 (영구 잠금은 null)
|
|
*/
|
|
public function lockAccount(User $user, int $minutes): ?Carbon
|
|
{
|
|
$permanent = $minutes <= 0;
|
|
$lockedUntil = $permanent ? null : now()->addMinutes($minutes);
|
|
|
|
$user->forceFill([
|
|
'locked_until' => $lockedUntil,
|
|
'locked_permanently' => $permanent,
|
|
'failed_login_attempts' => 0,
|
|
])->save();
|
|
|
|
return $lockedUntil;
|
|
}
|
|
|
|
/**
|
|
* 사용자의 모든 로그인 시도 추적 컬럼을 초기화합니다.
|
|
*
|
|
* 멱등 — 모든 컬럼이 이미 초기 상태면 UPDATE 를 발행하지 않습니다.
|
|
*
|
|
* @param User $user 대상 사용자
|
|
*/
|
|
public function resetLoginAttempts(User $user): void
|
|
{
|
|
$needsReset = ($user->failed_login_attempts ?? 0) > 0
|
|
|| $user->locked_until !== null
|
|
|| (bool) $user->locked_permanently
|
|
|| $user->last_failed_login_at !== null;
|
|
|
|
if (! $needsReset) {
|
|
return;
|
|
}
|
|
|
|
// locked_permanently 를 빠뜨리면 성공 로그인·관리자 해제 후에도 영구 잠금이 잔존한다.
|
|
$user->forceFill([
|
|
'failed_login_attempts' => 0,
|
|
'locked_until' => null,
|
|
'locked_permanently' => false,
|
|
'last_failed_login_at' => null,
|
|
])->save();
|
|
}
|
|
|
|
/**
|
|
* 사용자의 계정이 현재 시점에 잠금 상태인지 판정합니다.
|
|
*
|
|
* @param User $user 대상 사용자
|
|
* @return bool 잠금 여부
|
|
*/
|
|
public function isLocked(User $user): bool
|
|
{
|
|
// 영구 잠금 판정이 먼저 — 해제 시각이 없으므로 NULL 체크에 먼저 걸리면 안 된다.
|
|
if ((bool) $user->locked_permanently) {
|
|
return true;
|
|
}
|
|
|
|
if ($user->locked_until === null) {
|
|
return false;
|
|
}
|
|
|
|
return $user->locked_until->isFuture();
|
|
}
|
|
|
|
/**
|
|
* UUID 로 사용자를 찾습니다.
|
|
*
|
|
* @param string $uuid 사용자 UUID
|
|
* @return User|null 찾은 사용자 모델 또는 null
|
|
*/
|
|
public function findByUuid(string $uuid): ?User
|
|
{
|
|
return User::where('uuid', $uuid)->first();
|
|
}
|
|
|
|
/**
|
|
* UUID 목록에 해당하는 사용자의 정수 ID 배열을 반환합니다.
|
|
*
|
|
* @param array $uuids 사용자 UUID 배열
|
|
* @return array<int, int> 사용자 ID 배열
|
|
*/
|
|
public function getIdsByUuids(array $uuids): array
|
|
{
|
|
if (empty($uuids)) {
|
|
return [];
|
|
}
|
|
|
|
return User::whereIn('uuid', $uuids)->pluck('id')->all();
|
|
}
|
|
|
|
/**
|
|
* 사용자 ID 목록의 지정 컬럼을 일괄 갱신합니다.
|
|
*
|
|
* @param array $ids 사용자 ID 배열
|
|
* @param array $data 갱신할 컬럼 값
|
|
* @return int 갱신된 행 수
|
|
*/
|
|
public function updateManyByIds(array $ids, array $data): int
|
|
{
|
|
if (empty($ids)) {
|
|
return 0;
|
|
}
|
|
|
|
return User::whereIn('id', $ids)->update($data);
|
|
}
|
|
|
|
/**
|
|
* 사용자 ID 목록의 인증 토큰을 모두 삭제합니다.
|
|
*
|
|
* @param array $ids 사용자 ID 배열
|
|
* @return int 삭제된 토큰 수
|
|
*/
|
|
public function deleteTokensByUserIds(array $ids): int
|
|
{
|
|
if (empty($ids)) {
|
|
return 0;
|
|
}
|
|
|
|
return PersonalAccessToken::where('tokenable_type', User::class)
|
|
->whereIn('tokenable_id', $ids)
|
|
->delete();
|
|
}
|
|
}
|