*/ protected array $trackableFields = ['name', 'icon', 'order', 'url']; /** * 다국어 JSON 컬럼 — sub-key dot-path 단위 user_overrides 보존. * * @var array */ protected array $translatableTrackableFields = ['name']; /** @var array 활동 로그 추적 필드 */ public static array $activityLogFields = [ 'url' => ['label_key' => 'activity_log.fields.url', 'type' => 'text'], 'icon' => ['label_key' => 'activity_log.fields.icon', 'type' => 'text'], 'parent_id' => ['label_key' => 'activity_log.fields.parent_id', 'type' => 'number'], 'order' => ['label_key' => 'activity_log.fields.order', 'type' => 'number'], 'is_active' => ['label_key' => 'activity_log.fields.is_active', 'type' => 'boolean'], ]; /** * 테이블명 * * @var string */ protected $table = 'menus'; /** * 기본키 * * @var string */ protected $primaryKey = 'id'; /** * 타임스탬프 사용 여부 * * @var bool */ public $timestamps = true; /** * @var array */ protected $fillable = [ 'name', 'slug', 'url', 'icon', 'parent_id', 'order', 'is_active', 'extension_type', 'extension_identifier', 'user_overrides', 'created_by', ]; /** * 속성 캐스팅 * * @return array */ protected function casts(): array { return [ 'name' => 'array', 'is_active' => 'boolean', 'order' => 'integer', 'parent_id' => 'integer', 'extension_type' => ExtensionOwnerType::class, 'user_overrides' => 'array', ]; } /** * 메뉴가 활성화되어 있는지 확인합니다. * * @return bool 활성화 상태 여부 */ public function isActive(): bool { return $this->is_active; } /** * 메뉴를 생성한 사용자와의 관계를 정의합니다. */ public function creator(): BelongsTo { return $this->belongsTo(User::class, 'created_by'); } /** * 부모 메뉴와의 관계를 정의합니다. */ public function parent(): BelongsTo { return $this->belongsTo(Menu::class, 'parent_id'); } /** * 자식 메뉴들과의 관계를 정의합니다. */ public function children(): HasMany { return $this->hasMany(Menu::class, 'parent_id')->orderBy('order'); } /** * 활성화된 자식 메뉴들과의 관계를 정의합니다. */ public function activeChildren(): HasMany { return $this->children()->where('is_active', true); } /** * 접근 가능한 역할들 (권한 타입 포함) */ public function roles(): BelongsToMany { return $this->belongsToMany(Role::class, 'role_menus') ->withPivot('permission_type') ->withTimestamps(); } /** * 최상위 메뉴들(부모가 없는 메뉴들)을 조회하는 스코프를 정의합니다. */ public function scopeTopLevel(Builder $query): Builder { return $query->whereNull('parent_id')->orderBy('order'); } /** * 활성화된 메뉴들만 조회하는 스코프를 정의합니다. */ public function scopeActive(Builder $query): Builder { return $query->where('is_active', true); } /** * 확장별 메뉴를 조회하는 스코프 * * @param ExtensionOwnerType $type 확장 타입 * @param string|null $identifier 확장 식별자 */ public function scopeByExtension(Builder $query, ExtensionOwnerType $type, ?string $identifier = null): Builder { $query->where('extension_type', $type); if ($identifier !== null) { $query->where('extension_identifier', $identifier); } return $query; } /** * 사용자에게 접근 가능한 메뉴들만 조회하는 스코프 * * 권한 체크 로직: * 1. 코어/사용자 생성 메뉴: 사용자 역할에 read 권한 필요 * 2. 확장 메뉴(module/plugin): 확장이 활성화되어 있고 역할 권한이 있어야 접근 가능 * * admin 역할은 role_menus 피벗에 모든 메뉴가 할당되어 있으므로 * 별도 바이패스 없이 정상적인 쿼리로 동일 결과를 도출합니다. * * @param User $user 접근 권한을 확인할 사용자 */ public function scopeAccessibleBy(Builder $query, User $user): Builder { $userRoleIds = $user->roles()->pluck('roles.id')->toArray(); return $query->where(function (Builder $query) use ($userRoleIds) { // 코어/사용자 생성 메뉴: 사용자 역할에 read 권한 필요 $query->where(function (Builder $q) use ($userRoleIds) { $q->where(function (Builder $coreQ) { $coreQ->whereNull('extension_type') ->orWhere('extension_type', ExtensionOwnerType::Core); }) ->whereHas('roles', function (Builder $roleQ) use ($userRoleIds) { $roleQ->whereIn('roles.id', $userRoleIds) ->where('role_menus.permission_type', MenuPermissionType::Read->value); }); }); })->orWhere(function (Builder $query) use ($userRoleIds) { // 확장 메뉴 (활성화된 모듈/플러그인이면서 역할 권한이 있는 경우) $query->whereIn('extension_type', [ExtensionOwnerType::Module, ExtensionOwnerType::Plugin]) ->where(function (Builder $extQ) { // 모듈 메뉴: modules 테이블에서 활성 상태 확인 $extQ->where(function (Builder $moduleQ) { $moduleQ->where('extension_type', ExtensionOwnerType::Module) ->whereExists(function ($subQuery) { $subQuery->select(DB::raw(1)) ->from('modules') ->whereColumn('modules.identifier', 'menus.extension_identifier') ->where('modules.status', 'active'); }); }) // 플러그인 메뉴: plugins 테이블에서 활성 상태 확인 ->orWhere(function (Builder $pluginQ) { $pluginQ->where('extension_type', ExtensionOwnerType::Plugin) ->whereExists(function ($subQuery) { $subQuery->select(DB::raw(1)) ->from('plugins') ->whereColumn('plugins.identifier', 'menus.extension_identifier') ->where('plugins.status', 'active'); }); }); }) ->whereHas('roles', function (Builder $roleQ) use ($userRoleIds) { $roleQ->whereIn('roles.id', $userRoleIds) ->where('role_menus.permission_type', MenuPermissionType::Read->value); }); }); } /** * 지정된 로케일의 메뉴 이름 반환 * * @param string|null $locale 로케일 * @return string 메뉴 이름 */ public function getLocalizedName(?string $locale = null): string { $locale = $locale ?? app()->getLocale(); if (! is_array($this->name)) { return (string) $this->name; } return $this->name[$locale] ?? $this->name[config('app.fallback_locale')] ?? (! empty($this->name) ? array_values($this->name)[0] : '') ?? ''; } /** * 현재 로케일의 메뉴 이름 반환 */ protected function localizedName(): Attribute { return Attribute::make( get: fn () => $this->getLocalizedName() ); } // ============================================================ // 하위 호환 메서드 (Deprecated) // ============================================================ /** * 메뉴 권한들과의 관계를 정의합니다. * * @deprecated role_menus 피벗 테이블 사용. roles() 메서드를 사용하세요. */ public function permissions(): HasMany { return $this->hasMany(MenuPermission::class); } }