# 메뉴 시스템

> G7의 메뉴 권한 시스템과 메뉴 시더 작성 규칙을 설명합니다.

---

## TL;DR (5초 요약)

```text
1. 구조: User → Role → role_menus 피벗 → Menu
2. 권한: MenuPermissionType (read, write, delete)
3. 모든 메뉴: role_menus에 명시적 역할 권한 필요 (코어/확장 구분 없음)
4. 자동 부여: 메뉴 생성 시 관리자 역할 + 생성자/설치자 역할 자동 부여
5. 시더: Menu::create() + grantAdminRoleToMenus() 패턴
```

---

## 목차

1. [메뉴 권한 아키텍처](#메뉴-권한-아키텍처)
2. [role_menus 피벗 테이블](#role_menus-피벗-테이블)
3. [MenuPermissionType Enum](#menupermissiontype-enum)
4. [Menu 모델의 HasRoleBasedAccess 구현](#menu-모델의-hasrolebasedaccess-구현)
5. [메뉴 시더에서 권한 부여](#메뉴-시더에서-권한-부여)
6. [메뉴 권한 확인 방법](#메뉴-권한-확인-방법)
7. [권한 체계 요약](#권한-체계-요약)

---

## 메뉴 권한 아키텍처

G7에서 메뉴 권한은 **역할 기반(Role-based)**으로 관리됩니다.

```
User → Role → role_menus → Menu
              (pivot)
```

### 코어 메뉴 vs 모듈 메뉴

| 메뉴 유형 | 식별 | 기본 정책 |
|----------|------|----------|
| 코어 메뉴 | `extension_type = 'core'` | role_menus에 명시적 역할 권한 필요 |
| 모듈 메뉴 | `extension_type = 'module'` | 확장 활성 상태 + 명시적 역할 권한 필요 |
| 플러그인 메뉴 | `extension_type = 'plugin'` | 확장 활성 상태 + 명시적 역할 권한 필요 |
| 사용자 생성 메뉴 | `extension_type = NULL` | role_menus에 명시적 역할 권한 필요 |

---

## role_menus 피벗 테이블

### 테이블 구조

```php
Schema::create('role_menus', function (Blueprint $table) {
    $table->id();
    $table->foreignId('role_id')->constrained()->cascadeOnDelete();
    $table->foreignId('menu_id')->constrained()->cascadeOnDelete();
    $table->enum('permission_type', ['read', 'write', 'delete'])->default('read');
    $table->timestamps();

    $table->unique(['role_id', 'menu_id', 'permission_type'], 'uk_role_menu_permission');
});
```

### 설계 원칙

- **레코드 존재 = 권한 허용**: `is_allowed` 컬럼 없이 레코드 존재 여부로 판단
- **복합 유니크 인덱스**: 동일 역할-메뉴에 여러 권한 타입 부여 가능
- **Cascade Delete**: 역할 또는 메뉴 삭제 시 자동 정리

---

## MenuPermissionType Enum

```php
// app/Enums/MenuPermissionType.php

enum MenuPermissionType: string
{
    case Read = 'read';
    case Write = 'write';
    case Delete = 'delete';

    public function label(): string
    {
        return match ($this) {
            self::Read => '읽기/접근',
            self::Write => '수정',
            self::Delete => '삭제',
        };
    }
}
```

### 권한 타입 설명

| 타입 | 값 | 설명 |
|------|-----|------|
| Read | `read` | 메뉴 조회/접근 권한 |
| Write | `write` | 메뉴 관련 데이터 수정 권한 |
| Delete | `delete` | 메뉴 관련 데이터 삭제 권한 |

---

## Menu 모델의 HasRoleBasedAccess 구현

`Menu` 모델은 `HasRoleBasedAccess` 인터페이스를 구현하여 역할 기반 접근 제어를 지원합니다.

### 인터페이스 구현

```php
// app/Models/Menu.php

class Menu extends Model implements HasRoleBasedAccess
{
    /**
     * 접근 가능한 역할들 (권한 타입 포함)
     */
    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class, 'role_menus')
            ->withPivot('permission_type')
            ->withTimestamps();
    }

    /**
     * 특정 권한 타입을 가진 역할들 조회
     */
    public function rolesWithPermission(BackedEnum|string $permissionType): BelongsToMany
    {
        $type = $permissionType instanceof BackedEnum
            ? $permissionType->value
            : $permissionType;

        return $this->belongsToMany(Role::class, 'role_menus')
            ->withPivot('permission_type')
            ->withTimestamps()
            ->wherePivot('permission_type', $type);
    }

    /**
     * 역할 권한이 설정되어 있는지 확인
     */
    public function hasRolePermissions(): bool
    {
        return $this->roles()->exists();
    }

    /**
     * 리소스 소유자의 ID 반환
     */
    public function getOwnerId(): ?int
    {
        return $this->created_by;
    }
}
```

### scopeAccessibleBy 쿼리 스코프

```php
/**
 * 사용자에게 접근 가능한 메뉴들만 조회하는 스코프
 *
 * 권한 체크 로직:
 * 1. 관리자는 모든 메뉴 접근 가능
 * 2. 코어/사용자 생성 메뉴: role_menus에 명시적 역할 권한 필요
 * 3. 모듈/플러그인 메뉴: 확장이 활성화되어 있고 역할 권한이 있어야 접근 가능
 *
 * 메뉴 생성 시 관리자 역할 + 생성자/설치자 역할이 자동 부여되므로
 * role_menus 레코드가 없는 메뉴는 누구에게도 보이지 않습니다.
 */
public function scopeAccessibleBy(Builder $query, User $user): Builder
{
    if ($user->hasRole('admin')) {
        return $query;
    }

    $userRoleIds = $user->roles()->pluck('roles.id')->toArray();

    return $query->where(function (Builder $query) use ($userRoleIds) {
        // 코어/사용자 생성 메뉴: 명시적 역할 권한 필요
        $query->where(function (Builder $q) use ($userRoleIds) {
            $q->where(function ($q2) {
                    $q2->whereNull('extension_type')
                        ->orWhere('extension_type', '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', ['module', 'plugin'])
            ->whereHas('roles', function (Builder $roleQ) use ($userRoleIds) {
                $roleQ->whereIn('roles.id', $userRoleIds)
                    ->where('role_menus.permission_type', MenuPermissionType::Read->value);
            });
    });
}
```

---

## 메뉴 시더에서 권한 부여

### 예시: EcommerceMenuSeeder

```php
<?php

namespace Modules\Sirsoft\Ecommerce\Database\Seeders;

use Illuminate\Database\Seeder;
use App\Models\Menu;
use App\Models\Role;
use App\Models\Module;
use App\Enums\MenuPermissionType;

class EcommerceMenuSeeder extends Seeder
{
    public function run(): void
    {
        $this->command->info('이커머스 메뉴 생성을 시작합니다.');

        $module = Module::where('identifier', 'sirsoft-ecommerce')->first();

        // 메뉴 생성
        $ecommerceMenu = Menu::create([
            'name' => ['ko' => '이커머스', 'en' => 'Ecommerce'],
            'slug' => 'sirsoft-ecommerce',
            'url' => '/admin/sirsoft-ecommerce',
            'icon' => 'fa-shopping-cart',
            'module_id' => $module->id,
        ]);

        // 권한 부여
        $this->grantMenuPermissions($module->id);

        $this->command->info('이커머스 메뉴가 생성되었습니다.');
    }

    /**
     * 역할에 메뉴 권한 부여
     */
    private function grantMenuPermissions(int $moduleId): void
    {
        $adminRole = Role::where('identifier', 'admin')->first();
        $managerRole = Role::where('identifier', 'sirsoft-ecommerce.manager')->first();

        $ecommerceMenus = Menu::where('module_id', $moduleId)->get();

        foreach ($ecommerceMenus as $menu) {
            // 관리자: 모든 권한
            $adminRole->menus()->attach($menu->id, [
                'permission_type' => MenuPermissionType::Read->value,
            ]);
            $adminRole->menus()->attach($menu->id, [
                'permission_type' => MenuPermissionType::Write->value,
            ]);
            $adminRole->menus()->attach($menu->id, [
                'permission_type' => MenuPermissionType::Delete->value,
            ]);

            // 매니저: 읽기/쓰기 권한만
            if ($managerRole) {
                $managerRole->menus()->attach($menu->id, [
                    'permission_type' => MenuPermissionType::Read->value,
                ]);
                $managerRole->menus()->attach($menu->id, [
                    'permission_type' => MenuPermissionType::Write->value,
                ]);
            }
        }

        $this->command->info("역할에 {$ecommerceMenus->count()}개의 메뉴 권한을 부여했습니다.");
    }
}
```

### syncWithoutDetaching 사용 (기존 권한 유지)

```php
// 기존 권한을 유지하면서 새 권한 추가
$role->menus()->syncWithoutDetaching([
    $menuId => ['permission_type' => 'read'],
]);
```

---

## 메뉴 권한 확인 방법

### 1. User 모델에서 확인

```php
// 기본 (read 권한)
$user->hasMenuPermission($menuId);

// 특정 권한 타입
$user->hasMenuPermission($menuId, 'write');
$user->hasMenuPermission($menuId, MenuPermissionType::Delete);
```

### 2. 쿼리 스코프로 필터링

```php
// 사용자가 접근 가능한 메뉴 조회
$accessibleMenus = Menu::accessibleBy($user)->get();

// 계층 구조와 함께 조회
$accessibleMenus = Menu::accessibleBy($user)
    ->whereNull('parent_id')
    ->with(['children' => function ($query) use ($user) {
        $query->accessibleBy($user);
    }])
    ->get();
```

### 3. Role 모델에서 확인

```php
// 역할에 메뉴 권한 부여
$role->menus()->attach($menuId, ['permission_type' => 'read']);

// 역할의 모든 메뉴 조회
$role->menus()->get();

// 특정 권한 타입의 메뉴만 조회
$role->menus()->wherePivot('permission_type', 'write')->get();
```

---

## 권한 체계 요약

### 권한 체크 흐름

```
┌─────────────────────────────────────────────────────────────┐
│  1. 관리자(admin) 역할 확인                                  │
│     → admin이면 모든 메뉴 접근 허용                          │
├─────────────────────────────────────────────────────────────┤
│  2. 코어/사용자 생성 메뉴 (extension_type = 'core' 또는 NULL)│
│     → role_menus에 명시적 역할 권한 필요                    │
│     → 메뉴 생성 시 관리자+생성자 역할 자동 부여             │
├─────────────────────────────────────────────────────────────┤
│  3. 모듈/플러그인 메뉴 (extension_type = 'module'/'plugin') │
│     → 확장이 활성화(active) 상태여야 함                      │
│     → role_menus에 명시적 권한 필요                         │
└─────────────────────────────────────────────────────────────┘
```

### 권한 타입별 정리

| 권한 타입 | 용도 | 예시 |
|----------|------|------|
| `read` | 메뉴 표시, 페이지 접근 | 네비게이션 표시 |
| `write` | 데이터 생성/수정 | 상품 등록, 수정 |
| `delete` | 데이터 삭제 | 상품 삭제 |

### 동적 메뉴 보존

모듈/플러그인이 런타임에 동적으로 생성한 메뉴(예: 게시판 모듈의 개별 게시판 메뉴)는 확장 업데이트 시 자동으로 보존됩니다.

- `cleanupStaleMenus()`/`cleanupStalePermissions()`의 **자동 호출은 폐기**되었습니다
- 정적 메뉴/권한의 추가·수정은 업데이트 시 자동 동기화됩니다
- 정적 메뉴/권한을 **제거**해야 하는 경우, UpgradeStep에서 cleanup helper를 명시적으로 호출하세요
- 상세: [extension-update-system.md](./extension-update-system.md) "정적 메뉴/권한 제거 시 cleanup 사용 예시" 참조

### Deprecated 항목

다음 항목들은 더 이상 사용하지 않습니다:

| 항목 | 대체 방법 |
|------|----------|
| `menu_permissions` 테이블 | `role_menus` 피벗 테이블 |
| `MenuPermission` 모델 | `Role::menus()` 관계 |
| `is_allowed` 컬럼 | 레코드 존재 여부로 판단 |
| `User::menuPermissions()` | `User::hasMenuPermission()` |
| `module_id` 컬럼 | `extension_type` + `extension_identifier` |
| `module_identifier` 컬럼 | `extension_identifier` |

---

## 완전 동기화 (Stale Cleanup)

코어/확장 업그레이드 시 메뉴 동기화는 **완전 동기화** — config/manifest 에서 제거된 메뉴는 DB 에서도 삭제됩니다. 유지되는 메뉴는 사용자 수정(`user_overrides` 컬럼) 에 따라 필드 단위 보존이 적용됩니다.

### 적용 지점

| 경로 | 호출 시점 | cleanup 대상 |
|---|---|---|
| `CoreUpdateService::syncCoreMenus` 말미 | 코어 업데이트 | `core/core` 소유 메뉴 전체 slug |
| `ModuleManager::updateModule` 내 sync 블록 말미 | 모듈 업데이트 | 해당 모듈 소유 메뉴 |

### 핵심 정책

- **row 존재 여부**: config 기준 (user_overrides 무관 삭제)
- **필드 값 (유지 row)**: user_overrides 에 등록된 필드 (`name`, `icon`, `order`, `url`, `is_active`) 보존, 나머지 갱신
- **`is_active`**: config/manifest 정의값 채택 (기본 `true`). `is_active: false` 로 명시 정의된 메뉴는 sync 시에도 비활성 상태 유지. 운영자가 UI 에서 토글한 경우 `user_overrides` 에 `'is_active'` 가 마킹되어 재시드 시 운영자 선택 보존
- **role-menu 매핑**: 메뉴 삭제 시 `role_menus` 피벗은 FK cascade 로 자동 정리
- **user_overrides 검사**: 다국어 컬럼(`name`)은 column-level (`'name'`, legacy) + dot-path sub-key (`'name.ko'` / `'name.en'`, beta.4 도입) 양쪽 호환. 어느 형태로든 마킹되어 있으면 컬럼 전체를 보존 대상으로 간주

### 사용자 수정 기록

`Menu` 모델은 `HasUserOverrides` trait + `$trackableFields = ['name', 'icon', 'order', 'url']` 적용. 사용자가 UI/API 로 수정하면 trait 이 자동으로 user_overrides 에 누적 기록합니다 (mass update 포함).

### Helper (권장)

직접 Model 조작 금지. `ExtensionMenuSyncHelper` 를 통해 sync/cleanup 수행. 자세한 사용법은 [data-sync-helpers.md](../backend/data-sync-helpers.md#3-extensionmenusynchelper) 참조.

---

## 관련 문서

- [permissions.md](./permissions.md) - 권한 시스템 (Role, Permission, 리소스 레벨 권한)
- [module-basics.md](./module-basics.md) - 모듈 기본 구조
- [index.md](./index.md) - 확장 시스템 인덱스
- [../backend/core-config.md](../backend/core-config.md#완전-동기화-원칙) - 완전 동기화 원칙
- [../backend/data-sync-helpers.md](../backend/data-sync-helpers.md) - 데이터 동기화 Helper
- [../backend/user-overrides.md](../backend/user-overrides.md) - 사용자 수정 보존
