string('slug', 150)->nullable()->after('name')->comment('메뉴 슬러그(권한 키)'); } // Soft delete if (! Schema::hasColumn('menus', 'deleted_at')) { $table->softDeletes()->comment('소프트삭제 시각'); } // acted-by (누가 생성/수정/삭제 했는지) if (! Schema::hasColumn('menus', 'created_by')) { $table->unsignedBigInteger('created_by')->nullable()->after('updated_at')->comment('생성자 사용자 ID'); } if (! Schema::hasColumn('menus', 'updated_by')) { $table->unsignedBigInteger('updated_by')->nullable()->after('created_by')->comment('수정자 사용자 ID'); } if (! Schema::hasColumn('menus', 'deleted_by')) { $table->unsignedBigInteger('deleted_by')->nullable()->after('updated_by')->comment('삭제자 사용자 ID'); } // 인덱스/유니크 // slug는 테넌트 범위에서 유니크 보장 $table->unique(['tenant_id', 'slug'], 'menus_tenant_slug_unique'); $table->index(['tenant_id', 'is_active', 'hidden'], 'menus_tenant_active_hidden_idx'); $table->index(['sort_order'], 'menus_sort_idx'); }); /** * 2) 부서 테이블 */ if (! Schema::hasTable('departments')) { Schema::create('departments', function (Blueprint $table) { $table->bigIncrements('id')->comment('PK: 부서 ID'); $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); $table->string('code', 50)->nullable()->comment('부서 코드'); $table->string('name', 100)->comment('부서명'); $table->string('description', 255)->nullable()->comment('설명'); $table->tinyInteger('is_active')->default(1)->comment('활성여부(1=활성,0=비활성)'); $table->integer('sort_order')->default(0)->comment('정렬순서'); $table->timestamps(); $table->softDeletes(); // acted-by $table->unsignedBigInteger('created_by')->nullable()->comment('생성자 사용자 ID'); $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자 사용자 ID'); $table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자 사용자 ID'); // 인덱스 $table->unique(['tenant_id', 'code'], 'dept_tenant_code_unique'); $table->index(['tenant_id', 'name'], 'dept_tenant_name_idx'); $table->index(['tenant_id', 'is_active'], 'dept_tenant_active_idx'); }); } /** * 3) 부서-사용자 매핑 * - 동일 부서-사용자 중복 매핑 방지(tenant_id 포함) */ if (! Schema::hasTable('department_user')) { Schema::create('department_user', function (Blueprint $table) { $table->bigIncrements('id')->comment('PK'); $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); $table->unsignedBigInteger('department_id')->comment('부서 ID'); $table->unsignedBigInteger('user_id')->comment('사용자 ID'); $table->tinyInteger('is_primary')->default(0)->comment('주 부서 여부(1=Y,0=N)'); $table->timestamp('joined_at')->nullable()->comment('부서 배정일'); $table->timestamp('left_at')->nullable()->comment('부서 이탈일'); $table->timestamps(); $table->softDeletes(); // 인덱스/유니크 $table->unique(['tenant_id', 'department_id', 'user_id'], 'dept_user_unique'); $table->index(['tenant_id', 'user_id'], 'dept_user_user_idx'); $table->index(['tenant_id', 'department_id'], 'dept_user_dept_idx'); }); } /** * 4) 부서별 권한 매핑 * - Spatie permissions 테이블의 permission_id와 연결(실 FK는 미사용) * - ALLOW/DENY는 is_allowed로 표현 * - 필요시 메뉴 단위 범위 제한을 위해 menu_id(옵션) */ if (! Schema::hasTable('department_permissions')) { Schema::create('department_permissions', function (Blueprint $table) { $table->bigIncrements('id')->comment('PK'); $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); $table->unsignedBigInteger('department_id')->comment('부서 ID'); $table->unsignedBigInteger('permission_id')->comment('Spatie permissions.id'); $table->unsignedBigInteger('menu_id')->nullable()->comment('메뉴 ID (선택), 특정 메뉴 범위 권한'); $table->tinyInteger('is_allowed')->default(1)->comment('허용여부(1=ALLOW,0=DENY)'); $table->timestamps(); $table->softDeletes(); // 인덱스/유니크 (동일 권한 중복 방지) $table->unique( ['tenant_id', 'department_id', 'permission_id', 'menu_id'], 'dept_perm_unique' ); $table->index(['tenant_id', 'department_id'], 'dept_perm_dept_idx'); $table->index(['tenant_id', 'permission_id'], 'dept_perm_perm_idx'); $table->index(['tenant_id', 'menu_id'], 'dept_perm_menu_idx'); }); } /** * 5) 사용자 퍼미션 오버라이드 (개인 단위 허용/차단) * - 개인 DENY가 최우선 → 해석 레이어에서 우선순위 처리 */ if (! Schema::hasTable('user_permission_overrides')) { Schema::create('user_permission_overrides', function (Blueprint $table) { $table->bigIncrements('id')->comment('PK'); $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); $table->unsignedBigInteger('user_id')->comment('사용자 ID'); $table->unsignedBigInteger('permission_id')->comment('Spatie permissions.id'); $table->unsignedBigInteger('menu_id')->nullable()->comment('메뉴 ID (선택)'); $table->tinyInteger('is_allowed')->default(1)->comment('허용여부(1=ALLOW,0=DENY)'); $table->string('reason', 255)->nullable()->comment('사유/메모'); $table->timestamp('effective_from')->nullable()->comment('효력 시작'); $table->timestamp('effective_to')->nullable()->comment('효력 종료'); $table->timestamps(); $table->softDeletes(); // 중복 방지 $table->unique( ['tenant_id', 'user_id', 'permission_id', 'menu_id'], 'user_perm_override_unique' ); $table->index(['tenant_id', 'user_id'], 'user_perm_user_idx'); $table->index(['tenant_id', 'permission_id'], 'user_perm_perm_idx'); }); } } public function down(): void { // 생성 테이블 역순 드롭 Schema::dropIfExists('user_permission_overrides'); Schema::dropIfExists('department_permissions'); Schema::dropIfExists('department_user'); Schema::dropIfExists('departments'); // MENUS 원복: 추가했던 컬럼/인덱스 제거 Schema::table('menus', function (Blueprint $table) { // 인덱스/유니크 먼저 제거 if ($this->indexExists('menus', 'menus_tenant_slug_unique')) { $table->dropUnique('menus_tenant_slug_unique'); } if ($this->indexExists('menus', 'menus_tenant_active_hidden_idx')) { $table->dropIndex('menus_tenant_active_hidden_idx'); } if ($this->indexExists('menus', 'menus_sort_idx')) { $table->dropIndex('menus_sort_idx'); } // 컬럼 제거 (존재 체크는 직접 불가하므로 try-catch는 생략) if (Schema::hasColumn('menus', 'slug')) { $table->dropColumn('slug'); } if (Schema::hasColumn('menus', 'deleted_at')) { $table->dropSoftDeletes(); } if (Schema::hasColumn('menus', 'deleted_by')) { $table->dropColumn('deleted_by'); } if (Schema::hasColumn('menus', 'updated_by')) { $table->dropColumn('updated_by'); } if (Schema::hasColumn('menus', 'created_by')) { $table->dropColumn('created_by'); } }); } /** * Laravel의 Blueprint에서는 인덱스 존재 체크가 불가하므로 * 스키마 매니저를 직접 조회하는 헬퍼. */ private function indexExists(string $table, string $indexName): bool { try { $connection = Schema::getConnection(); $schemaManager = $connection->getDoctrineSchemaManager(); $doctrineTable = $schemaManager->introspectTable( $connection->getTablePrefix().$table ); foreach ($doctrineTable->getIndexes() as $idx) { if ($idx->getName() === $indexName) { return true; } } } catch (\Throwable $e) { // 무시 } return false; } };