공개 이슈 gnuboard/g7 이 지목한 병목은 "같은 일을 반복 실행한다" 축이다. 응답·행의 무게를 다룬 · 과 겹치지 않는다. 공통 계약 — 총 건수 상한과 페이지 이동 범위는 별개 결정이다. 묶으면 필요 없이 기능이 깎인다. 총 건수만 상한을 받고(파생 테이블 COUNT), "다음" 이동은 per_page + 1 실측으로 끝까지 열어 둔다. 계산이 불가능해지는 것은 마지막 페이지 번호 하나뿐이며 그 사실은 last_page: null 이 알린다. 최신순처럼 실제 컬럼으로 정렬하는 목록은 커서로 전환해 깊이와 무관하게 일정 속도로 이동한다. 관련도순은 계산값 정렬이라 커서 키로 쓸 수 없어 offset 을 유지한다. 계약의 입구는 표준 paginate 와 같은 폭이어야 한다. 관계·쿼리 빌더를 받지 못하면 그 좁은 만큼이 그대로 운영 500 이 되고, 실제로 관리자 알림 목록에서 그렇게 터졌다. 응답 조립도 컬렉션마다 손으로 하면 형태가 늘어나는 순간 없는 값을 부르거나 새 필드를 흘리므로, 형태 판정을 paginationMeta 한 곳에 모았다. 표준 paginate 응답은 필드 단위로 이전과 동일하다. 요청당 반복 비용 — 훅 구독마다 남기던 로그 400줄, 요청당 스무 번 넘던 설정 파일 재읽기, 이미 캐시된 목록의 DB 재조회를 없앴다. 권한 판정은 요청 스코프 메모를 두어 화면 요소마다 나가던 조회를 한 번으로 줄였고, 크로스 요청 캐시는 두지 않아 권한 변경이 종전처럼 다음 요청에 반영된다. 검색 질의는 활성 엔진이 만든다. 저장소가 구체 엔진을 지목하면 플러그인이 등록한 엔진은 호출될 기회 자체를 잃고 오류 없이 다른 방식으로 동작한다. 해석기를 두어 활성 엔진에 위임하고, 전문검색이 없는 DBMS 의 부분일치 폴백도 드라이버명 하드코딩 대신 선언형 config 로 옮겼다. 총 건수가 잘린 목록에서 순번을 역산하면 0 과 음수가 나온다. 지어내지 않고 null 을 돌려주며, 그 원칙을 last_page 와 동일하게 적용했다.
628 lines
20 KiB
PHP
628 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
|
use App\Contracts\UniqueIdServiceInterface;
|
|
use App\Enums\MenuPermissionType;
|
|
use App\Enums\PermissionType;
|
|
use App\Enums\ScopeType;
|
|
use App\Enums\UserStatus;
|
|
use Database\Factories\UserFactory;
|
|
use Illuminate\Contracts\Translation\HasLocalePreference;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
|
use Illuminate\Notifications\Notifiable;
|
|
use Laravel\Sanctum\HasApiTokens;
|
|
|
|
class User extends Authenticatable implements HasLocalePreference
|
|
{
|
|
/** @use HasFactory<UserFactory> */
|
|
use HasApiTokens, HasFactory, Notifiable;
|
|
|
|
/** @var array<string, array> 활동 로그 추적 필드 */
|
|
public static array $activityLogFields = [
|
|
'name' => ['label_key' => 'activity_log.fields.name', 'type' => 'text'],
|
|
'nickname' => ['label_key' => 'activity_log.fields.nickname', 'type' => 'text'],
|
|
'email' => ['label_key' => 'activity_log.fields.email', 'type' => 'text'],
|
|
'language' => ['label_key' => 'activity_log.fields.language', 'type' => 'text'],
|
|
'timezone' => ['label_key' => 'activity_log.fields.timezone', 'type' => 'text'],
|
|
'country' => ['label_key' => 'activity_log.fields.country', 'type' => 'text'],
|
|
'status' => ['label_key' => 'activity_log.fields.status', 'type' => 'enum', 'enum' => UserStatus::class],
|
|
'is_super' => ['label_key' => 'activity_log.fields.is_super', 'type' => 'boolean'],
|
|
'homepage' => ['label_key' => 'activity_log.fields.homepage', 'type' => 'text'],
|
|
'mobile' => ['label_key' => 'activity_log.fields.mobile', 'type' => 'text'],
|
|
'phone' => ['label_key' => 'activity_log.fields.phone', 'type' => 'text'],
|
|
'zipcode' => ['label_key' => 'activity_log.fields.zipcode', 'type' => 'text'],
|
|
'address' => ['label_key' => 'activity_log.fields.address', 'type' => 'text'],
|
|
'address_detail' => ['label_key' => 'activity_log.fields.address_detail', 'type' => 'text'],
|
|
'bio' => ['label_key' => 'activity_log.fields.bio', 'type' => 'text'],
|
|
'admin_memo' => ['label_key' => 'activity_log.fields.admin_memo', 'type' => 'text'],
|
|
];
|
|
|
|
/**
|
|
* 권한별 effective scope 캐시 (인스턴스 레벨)
|
|
*
|
|
* @var array<string, string|null|false>
|
|
*/
|
|
protected array $effectiveScopeCache = [];
|
|
|
|
/**
|
|
* 보유 권한 부여 내역 캐시 (인스턴스 레벨)
|
|
*
|
|
* 한 요청에서 권한 판정은 수십~수백 번 일어난다 — 레이아웃 노드마다, 목록 행마다,
|
|
* 리소스 필드마다 부른다. 판정마다 DB 에 물으면 노드/행 수에 비례해 쿼리가 늘어난다.
|
|
* 첫 판정에서 `roles.permissions` 를 한 번 적재해 두고 이후로는 배열만 본다.
|
|
*
|
|
* 캐시 수명은 **모델 인스턴스**, 즉 요청 스코프다. 크로스 요청 캐시를 두지 않으므로
|
|
* 권한을 바꾸면 다음 요청부터 즉시 반영된다.
|
|
*
|
|
* 구조: 권한 식별자 ⇒ [{type, scope_type}, ...] (같은 권한을 여러 역할이 주면 여러 건)
|
|
*
|
|
* @var array<string, array<int, array{type: string|null, scope_type: string|null}>>|null
|
|
*/
|
|
protected ?array $permissionGrantsCache = null;
|
|
|
|
/**
|
|
* 테이블명
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $table = 'users';
|
|
|
|
/**
|
|
* 기본키
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $primaryKey = 'id';
|
|
|
|
/**
|
|
* 타임스탬프 사용 여부
|
|
*
|
|
* @var bool
|
|
*/
|
|
public $timestamps = true;
|
|
|
|
/**
|
|
* The attributes that are mass assignable.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
protected $fillable = [
|
|
'uuid',
|
|
'name',
|
|
'nickname',
|
|
'email',
|
|
'password',
|
|
'language',
|
|
'is_super',
|
|
'timezone',
|
|
'country',
|
|
'status',
|
|
'homepage',
|
|
'mobile',
|
|
'phone',
|
|
'zipcode',
|
|
'address',
|
|
'address_detail',
|
|
'signature',
|
|
'bio',
|
|
'avatar',
|
|
'admin_memo',
|
|
'ip_address',
|
|
'last_login_at',
|
|
'withdrawn_at',
|
|
'blocked_at',
|
|
'identity_verified_at',
|
|
'identity_verified_provider',
|
|
'identity_verified_purpose_last',
|
|
'identity_hash',
|
|
'mobile_verified_at',
|
|
'failed_login_attempts',
|
|
'locked_until',
|
|
'locked_permanently',
|
|
'last_failed_login_at',
|
|
];
|
|
|
|
/**
|
|
* The attributes that should be hidden for serialization.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
protected $hidden = [
|
|
'id',
|
|
'password',
|
|
'remember_token',
|
|
];
|
|
|
|
/**
|
|
* Get the attributes that should be cast.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'email_verified_at' => 'datetime',
|
|
'password' => 'hashed',
|
|
'last_login_at' => 'datetime',
|
|
'withdrawn_at' => 'datetime',
|
|
'blocked_at' => 'datetime',
|
|
'identity_verified_at' => 'datetime',
|
|
'mobile_verified_at' => 'datetime',
|
|
'is_super' => 'boolean',
|
|
'failed_login_attempts' => 'integer',
|
|
'locked_until' => 'datetime',
|
|
'locked_permanently' => 'boolean',
|
|
'last_failed_login_at' => 'datetime',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Route Model Binding에 사용할 키 이름을 반환합니다.
|
|
*
|
|
* @return string 라우트 키 이름
|
|
*/
|
|
public function getRouteKeyName(): string
|
|
{
|
|
return 'uuid';
|
|
}
|
|
|
|
/**
|
|
* 알림 발송 시 사용할 수신자 선호 로케일을 반환합니다.
|
|
*
|
|
* 사용자 언어 SSoT 는 users.language 컬럼입니다. 지원 로케일이면 해당 값을,
|
|
* 빈값/미지원 로케일이면 사이트 기본 로케일을 반환합니다. (요청자 locale 폴백 없음)
|
|
*
|
|
* @return string|null 지원 로케일이면 해당 값, 아니면 사이트 기본 로케일
|
|
*/
|
|
public function preferredLocale(): ?string
|
|
{
|
|
$supported = config('app.supported_locales', ['ko', 'en']);
|
|
|
|
return ($this->language && in_array($this->language, $supported, true))
|
|
? $this->language
|
|
: config('app.locale', 'ko');
|
|
}
|
|
|
|
/**
|
|
* 모델 부팅 시 UUID 자동 생성을 등록합니다.
|
|
*/
|
|
protected static function boot(): void
|
|
{
|
|
parent::boot();
|
|
|
|
static::creating(function (self $user) {
|
|
if (empty($user->uuid)) {
|
|
$user->uuid = app(UniqueIdServiceInterface::class)->generateUuid();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 사용자의 약관 동의 이력들과의 관계를 정의합니다.
|
|
*/
|
|
public function consents(): HasMany
|
|
{
|
|
return $this->hasMany(UserConsent::class);
|
|
}
|
|
|
|
/**
|
|
* 사용자가 생성한 모듈들과의 관계를 정의합니다.
|
|
*
|
|
* @return HasMany
|
|
*/
|
|
public function modules()
|
|
{
|
|
return $this->hasMany(Module::class, 'created_by');
|
|
}
|
|
|
|
/**
|
|
* 사용자가 생성한 플러그인들과의 관계를 정의합니다.
|
|
*
|
|
* @return HasMany
|
|
*/
|
|
public function plugins()
|
|
{
|
|
return $this->hasMany(Plugin::class, 'created_by');
|
|
}
|
|
|
|
/**
|
|
* 사용자가 가진 역할들과의 관계를 정의합니다.
|
|
*/
|
|
public function roles(): BelongsToMany
|
|
{
|
|
return $this->belongsToMany(Role::class, 'user_roles')
|
|
->withPivot(['assigned_at', 'assigned_by'])
|
|
->withTimestamps();
|
|
}
|
|
|
|
/**
|
|
* 사용자의 개별 메뉴 권한들과의 관계를 정의합니다.
|
|
*
|
|
* @deprecated role_menus 피벗 테이블 사용. 역할 기반 권한을 사용하세요.
|
|
*/
|
|
public function menuPermissions(): HasMany
|
|
{
|
|
return $this->hasMany(MenuPermission::class);
|
|
}
|
|
|
|
/**
|
|
* 특정 권한을 가지고 있는지 확인합니다.
|
|
*
|
|
* @param string $permission 권한 식별자
|
|
* @param PermissionType|null $type 권한 타입 (null이면 타입 구분 없이 체크)
|
|
*/
|
|
public function hasPermission(string $permission, ?PermissionType $type = null): bool
|
|
{
|
|
$grants = $this->permissionGrants()[$permission] ?? null;
|
|
|
|
if ($grants === null) {
|
|
return false;
|
|
}
|
|
|
|
if ($type === null) {
|
|
return true;
|
|
}
|
|
|
|
foreach ($grants as $grant) {
|
|
if ($grant['type'] === $type->value) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 보유 권한 부여 내역을 반환합니다 (인스턴스 캐시).
|
|
*
|
|
* 역할 → 권한을 한 번만 적재하고, 이후 판정은 전부 이 배열에서 이루어집니다.
|
|
*
|
|
* @return array<string, array<int, array{type: string|null, scope_type: string|null}>> 권한 식별자 ⇒ 부여 내역
|
|
*/
|
|
protected function permissionGrants(): array
|
|
{
|
|
if ($this->permissionGrantsCache !== null) {
|
|
return $this->permissionGrantsCache;
|
|
}
|
|
|
|
$grants = [];
|
|
|
|
foreach ($this->roles()->with('permissions')->get() as $role) {
|
|
foreach ($role->permissions as $permission) {
|
|
$grants[$permission->identifier][] = [
|
|
'type' => $this->enumValue($permission->type),
|
|
'scope_type' => $this->enumValue($permission->pivot->scope_type ?? null),
|
|
];
|
|
}
|
|
}
|
|
|
|
return $this->permissionGrantsCache = $grants;
|
|
}
|
|
|
|
/**
|
|
* Enum 또는 원시 값을 문자열로 정규화합니다.
|
|
*
|
|
* 캐스팅 설정 유무에 따라 Enum 인스턴스와 문자열이 섞여 들어오므로 한 형태로 맞춥니다.
|
|
*
|
|
* @param mixed $value Enum 인스턴스 · 문자열 · null
|
|
* @return string|null 정규화된 문자열 (부재 시 null)
|
|
*/
|
|
private function enumValue(mixed $value): ?string
|
|
{
|
|
if ($value instanceof \BackedEnum) {
|
|
return (string) $value->value;
|
|
}
|
|
|
|
return $value === null ? null : (string) $value;
|
|
}
|
|
|
|
/**
|
|
* 권한 판정 캐시를 비웁니다.
|
|
*
|
|
* 같은 인스턴스에서 역할을 바꾼 직후 다시 판정해야 하는 경우에 호출합니다.
|
|
* (역할 동기화 서비스가 호출하며, 일반 조회 경로에서는 필요하지 않습니다)
|
|
*/
|
|
public function flushPermissionCaches(): void
|
|
{
|
|
$this->permissionGrantsCache = null;
|
|
$this->effectiveScopeCache = [];
|
|
}
|
|
|
|
/**
|
|
* 여러 권한을 가지고 있는지 확인합니다.
|
|
*
|
|
* @param array $permissions 권한 식별자 배열
|
|
* @param bool $requireAll 모든 권한이 필요한지 여부 (true: AND, false: OR)
|
|
* @param PermissionType|null $type 권한 타입 (null이면 타입 구분 없이 체크)
|
|
*/
|
|
public function hasPermissions(array $permissions, bool $requireAll = true, ?PermissionType $type = null): bool
|
|
{
|
|
// 같은 권한 집합을 hasPermission 과 공유한다 — 권한 개수만큼 쿼리가 늘지 않는다.
|
|
// 비교 기준(고유 매칭 수 vs 인자 개수)은 종전과 동일하게 유지한다.
|
|
$matched = 0;
|
|
|
|
foreach (array_unique($permissions) as $permission) {
|
|
if ($this->hasPermission($permission, $type)) {
|
|
$matched++;
|
|
}
|
|
}
|
|
|
|
return $requireAll ? $matched === count($permissions) : $matched > 0;
|
|
}
|
|
|
|
/**
|
|
* 해당 권한에 대한 effective scope를 반환합니다.
|
|
*
|
|
* 사용자가 보유한 역할들의 scope_type을 수집하여 union 정책을 적용합니다.
|
|
* 우선순위: null(전체) > 'role'(소유역할) > 'self'(본인)
|
|
* 인스턴스 레벨 캐싱으로 동일 권한 반복 조회 시 DB 쿼리를 방지합니다.
|
|
*
|
|
* @param string $identifier 권한 식별자
|
|
* @return string|null null(전체), 'role'(소유역할), 'self'(본인)
|
|
*/
|
|
public function getEffectiveScopeForPermission(string $identifier): ?string
|
|
{
|
|
// 캐시 히트 시 즉시 반환 (false = 캐시된 null과 구분)
|
|
if (array_key_exists($identifier, $this->effectiveScopeCache)) {
|
|
return $this->effectiveScopeCache[$identifier];
|
|
}
|
|
|
|
// 같은 권한 집합에서 scope_type 만 뽑는다 — 권한마다 쿼리를 다시 내지 않는다.
|
|
$values = array_column($this->permissionGrants()[$identifier] ?? [], 'scope_type');
|
|
|
|
// 권한 미보유 시 null 반환 (기본값: 전체 접근)
|
|
if ($values === []) {
|
|
return $this->effectiveScopeCache[$identifier] = null;
|
|
}
|
|
|
|
// union 정책: 하나라도 null → 전체 접근
|
|
if (in_array(null, $values, true)) {
|
|
return $this->effectiveScopeCache[$identifier] = null;
|
|
}
|
|
|
|
// 하나라도 'role' → role 적용
|
|
if (in_array(ScopeType::Role->value, $values, true)) {
|
|
return $this->effectiveScopeCache[$identifier] = 'role';
|
|
}
|
|
|
|
// 모두 'self' → self
|
|
return $this->effectiveScopeCache[$identifier] = 'self';
|
|
}
|
|
|
|
/**
|
|
* 특정 역할을 가지고 있는지 확인합니다.
|
|
*
|
|
* @param string $role 역할 식별자
|
|
*/
|
|
public function hasRole(string $role): bool
|
|
{
|
|
return $this->roles()->where('identifier', $role)->exists();
|
|
}
|
|
|
|
/**
|
|
* 여러 역할을 가지고 있는지 확인합니다.
|
|
*
|
|
* @param array $roles 역할 식별자 배열
|
|
* @param bool $requireAll 모든 역할이 필요한지 여부 (true: AND, false: OR)
|
|
*/
|
|
public function hasRoles(array $roles, bool $requireAll = true): bool
|
|
{
|
|
$userRoles = $this->roles()->whereIn('identifier', $roles)->count();
|
|
|
|
return $requireAll ? $userRoles === count($roles) : $userRoles > 0;
|
|
}
|
|
|
|
/**
|
|
* 관리자인지 확인합니다.
|
|
*
|
|
* 사용자가 보유한 권한 중 type='admin'인 권한이 하나라도 있으면 관리자로 판단합니다.
|
|
*
|
|
* @return bool 관리자 여부
|
|
*/
|
|
public function isAdmin(): bool
|
|
{
|
|
// 같은 권한 집합을 재사용한다 — 관리자 판정은 미들웨어·리소스·레이아웃에서 반복 호출된다.
|
|
foreach ($this->permissionGrants() as $grants) {
|
|
foreach ($grants as $grant) {
|
|
if ($grant['type'] === PermissionType::Admin->value) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 슈퍼 관리자인지 확인합니다.
|
|
*
|
|
* 슈퍼 관리자는 삭제할 수 없으며, 다른 관리자의 권한을 관리할 수 있습니다.
|
|
*
|
|
* @return bool 슈퍼 관리자 여부
|
|
*/
|
|
public function isSuperAdmin(): bool
|
|
{
|
|
return $this->is_super === true;
|
|
}
|
|
|
|
/**
|
|
* 슈퍼 관리자만 조회합니다.
|
|
*
|
|
* @param Builder $query
|
|
* @return Builder
|
|
*/
|
|
public function scopeSuperAdmins($query)
|
|
{
|
|
return $query->where('is_super', true);
|
|
}
|
|
|
|
/**
|
|
* 특정 메뉴에 대한 권한을 가지고 있는지 확인합니다.
|
|
*
|
|
* role_menus 피벗 테이블을 통해 역할 기반 권한을 확인합니다.
|
|
*
|
|
* @param int $menuId 메뉴 ID
|
|
* @param MenuPermissionType|string $permissionType 권한 유형 (read, write, delete)
|
|
*/
|
|
public function hasMenuPermission(int $menuId, MenuPermissionType|string $permissionType = 'read'): bool
|
|
{
|
|
$type = $permissionType instanceof MenuPermissionType
|
|
? $permissionType->value
|
|
: $permissionType;
|
|
|
|
// 역할 기반 권한 확인 (role_menus 피벗 테이블)
|
|
return $this->roles()
|
|
->whereHas('menus', function ($query) use ($menuId, $type) {
|
|
$query->where('menus.id', $menuId)
|
|
->wherePivot('permission_type', $type);
|
|
})
|
|
->exists();
|
|
}
|
|
|
|
/**
|
|
* 특정 slug의 메뉴에 대한 접근 권한을 가지고 있는지 확인합니다.
|
|
*
|
|
* role_menus 피벗 테이블을 통해 역할 기반 메뉴 접근 권한을 확인합니다.
|
|
*
|
|
* @param string $slug 메뉴 slug (예: 'admin-users')
|
|
* @param MenuPermissionType|string $permissionType 권한 유형 (read, write, delete)
|
|
* @return bool 메뉴 접근 권한 보유 여부
|
|
*/
|
|
public function hasMenuAccessBySlug(string $slug, MenuPermissionType|string $permissionType = 'read'): bool
|
|
{
|
|
$type = $permissionType instanceof MenuPermissionType
|
|
? $permissionType->value
|
|
: $permissionType;
|
|
|
|
return $this->roles()
|
|
->whereHas('menus', function ($query) use ($slug, $type) {
|
|
$query->where('menus.slug', $slug)
|
|
->where('role_menus.permission_type', $type);
|
|
})
|
|
->exists();
|
|
}
|
|
|
|
/**
|
|
* 사용자가 생성한 메뉴들과의 관계를 정의합니다.
|
|
*
|
|
* @return HasMany
|
|
*/
|
|
public function menus()
|
|
{
|
|
return $this->hasMany(Menu::class, 'created_by');
|
|
}
|
|
|
|
/**
|
|
* 사용자의 시간대를 반환합니다.
|
|
* 설정되지 않은 경우 기본 사용자 시간대를 반환합니다.
|
|
*/
|
|
public function getTimezone(): string
|
|
{
|
|
return $this->timezone ?? config('app.default_user_timezone', 'Asia/Seoul');
|
|
}
|
|
|
|
/**
|
|
* 사용자의 아바타 첨부파일과의 관계를 정의합니다.
|
|
*
|
|
* attachments 테이블의 다형성 관계를 사용합니다.
|
|
* collection='avatar'로 아바타 전용 첨부파일을 구분합니다.
|
|
*/
|
|
public function avatarAttachment(): MorphOne
|
|
{
|
|
return $this->morphOne(Attachment::class, 'attachmentable')
|
|
->where('collection', 'avatar');
|
|
}
|
|
|
|
/**
|
|
* 아바타 이미지 URL을 반환합니다.
|
|
*
|
|
* attachments 테이블의 다형성 관계를 사용하여 아바타를 조회합니다.
|
|
* 관계가 없으면 레거시 avatar 필드를 확인합니다.
|
|
*
|
|
* @return string|null 아바타 URL (없으면 null)
|
|
*/
|
|
public function getAvatarUrl(): ?string
|
|
{
|
|
// 새로운 방식: attachments 테이블 다형성 관계
|
|
$attachment = $this->avatarAttachment;
|
|
if ($attachment) {
|
|
return $attachment->download_url;
|
|
}
|
|
|
|
// 레거시 방식: avatar 필드 (하위 호환)
|
|
if (! empty($this->avatar)) {
|
|
return url('storage/attachments/avatars/'.$this->avatar);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 활성화된 사용자만 조회합니다.
|
|
*/
|
|
public function scopeActive(Builder $query): void
|
|
{
|
|
$query->whereNull('withdrawn_at')
|
|
->where('status', '!=', UserStatus::Withdrawn);
|
|
}
|
|
|
|
/**
|
|
* 탈퇴한 사용자만 조회합니다.
|
|
*/
|
|
public function scopeWithdrawn(Builder $query): void
|
|
{
|
|
$query->whereNotNull('withdrawn_at')
|
|
->where('status', UserStatus::Withdrawn);
|
|
}
|
|
|
|
/**
|
|
* 탈퇴한 사용자인지 확인합니다.
|
|
*
|
|
* @return bool 탈퇴 여부
|
|
*/
|
|
public function isWithdrawn(): bool
|
|
{
|
|
return $this->withdrawn_at !== null && $this->status === UserStatus::Withdrawn->value;
|
|
}
|
|
|
|
/**
|
|
* 사용자를 탈퇴 처리합니다.
|
|
*
|
|
* 이름, 이메일, 닉네임에 suffix를 추가하여 익명화하고,
|
|
* 상태를 'withdrawn'으로 변경하며 탈퇴 일시를 기록합니다.
|
|
*
|
|
* @return bool 저장 성공 여부
|
|
*/
|
|
public function withdraw(): bool
|
|
{
|
|
$now = now();
|
|
$dateSuffix = $now->format('Ymd'); // 예: 20260127
|
|
|
|
// 이름에 suffix 추가 (있는 경우만)
|
|
if ($this->name) {
|
|
$this->name = $this->name.'_탈퇴_'.$dateSuffix;
|
|
}
|
|
|
|
// 이메일에 suffix 추가 (필수)
|
|
$this->email = $this->email.'_deleted_'.$dateSuffix;
|
|
|
|
// 닉네임에 suffix 추가 (있는 경우만, 날짜 없이)
|
|
if ($this->nickname) {
|
|
$this->nickname = $this->nickname.'_탈퇴';
|
|
}
|
|
|
|
// 상태 변경
|
|
$this->status = UserStatus::Withdrawn->value;
|
|
$this->withdrawn_at = $now;
|
|
|
|
return $this->save();
|
|
}
|
|
}
|