- MenuService에 게시판 메뉴 연동 메서드 추가 - createMenuForBoard(): 게시판 생성 시 /board 하위에 메뉴 자동 추가 - updateMenuForBoard(): 코드/이름 변경 시 메뉴 URL/이름 동기화 - deleteMenuForBoard(): 게시판 삭제 시 메뉴 Soft Delete - restoreMenuForBoard(): 게시판 복원 시 메뉴 복원 - findParentMenuForBoard(): 부모 메뉴 (/board) 찾기 - BoardService에서 테넌트 게시판 CRUD 시 MenuService 호출 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
597 lines
20 KiB
PHP
597 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Commons\Menu;
|
|
use App\Models\Members\User;
|
|
use App\Models\Tenants\Department;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Validator;
|
|
|
|
class MenuService
|
|
{
|
|
protected static function tenantId(): ?int
|
|
{
|
|
return app('tenant_id');
|
|
}
|
|
|
|
protected static function actorId(): ?int
|
|
{
|
|
$uid = app('api_user');
|
|
|
|
return $uid ? (int) $uid : null;
|
|
}
|
|
|
|
/**
|
|
* 메뉴 목록 조회 (사용자 권한 기반 필터링)
|
|
*/
|
|
public static function index(array $params)
|
|
{
|
|
$tenantId = self::tenantId();
|
|
$userId = self::actorId();
|
|
|
|
// 권한이 있는 메뉴 ID 목록 조회
|
|
$allowedMenuIds = self::getAllowedMenuIds($userId, $tenantId);
|
|
|
|
$q = Menu::query()->withShared($tenantId);
|
|
|
|
// 권한 기반 필터링 적용
|
|
if (! empty($allowedMenuIds)) {
|
|
$q->whereIn('id', $allowedMenuIds);
|
|
} else {
|
|
// 권한이 없으면 빈 결과 반환
|
|
$q->whereRaw('1 = 0');
|
|
}
|
|
|
|
if (array_key_exists('parent_id', $params)) {
|
|
$q->where('parent_id', $params['parent_id']);
|
|
}
|
|
if (array_key_exists('is_active', $params)) {
|
|
$q->where('is_active', (int) $params['is_active']);
|
|
}
|
|
if (array_key_exists('hidden', $params)) {
|
|
$q->where('hidden', (int) $params['hidden']);
|
|
}
|
|
|
|
$q->orderBy('parent_id')->orderBy('sort_order');
|
|
|
|
// Builder 그대로 전달해야 쿼리로그/표준응답 형식 유지
|
|
return $q->get();
|
|
}
|
|
|
|
/**
|
|
* 사용자가 접근 가능한 메뉴 ID 목록 조회 (mng UserPermissionService와 동일한 로직)
|
|
*/
|
|
protected static function getAllowedMenuIds(?int $userId, ?int $tenantId): array
|
|
{
|
|
if (! $userId || ! $tenantId) {
|
|
return [];
|
|
}
|
|
|
|
$now = now();
|
|
|
|
// 1. 역할 권한 (user_roles 테이블)
|
|
$rolePermissions = DB::table('user_roles')
|
|
->join('role_has_permissions', 'user_roles.role_id', '=', 'role_has_permissions.role_id')
|
|
->join('permissions', 'role_has_permissions.permission_id', '=', 'permissions.id')
|
|
->where('user_roles.user_id', $userId)
|
|
->where('user_roles.tenant_id', $tenantId)
|
|
->whereNull('user_roles.deleted_at')
|
|
->where('permissions.name', 'like', 'menu:%.view')
|
|
->pluck('permissions.name')
|
|
->toArray();
|
|
|
|
// 2. 부서 권한 (permission_overrides에서 Department 타입, effect=1)
|
|
$deptPermissions = DB::table('department_user')
|
|
->join('permission_overrides', function ($join) use ($now) {
|
|
$join->on('permission_overrides.model_id', '=', 'department_user.department_id')
|
|
->where('permission_overrides.model_type', '=', Department::class)
|
|
->whereNull('permission_overrides.deleted_at')
|
|
->where('permission_overrides.effect', 1)
|
|
->where(function ($q) use ($now) {
|
|
$q->whereNull('permission_overrides.effective_from')
|
|
->orWhere('permission_overrides.effective_from', '<=', $now);
|
|
})
|
|
->where(function ($q) use ($now) {
|
|
$q->whereNull('permission_overrides.effective_to')
|
|
->orWhere('permission_overrides.effective_to', '>=', $now);
|
|
});
|
|
})
|
|
->join('permissions', 'permissions.id', '=', 'permission_overrides.permission_id')
|
|
->whereNull('department_user.deleted_at')
|
|
->where('department_user.user_id', $userId)
|
|
->where('department_user.tenant_id', $tenantId)
|
|
->where('permission_overrides.tenant_id', $tenantId)
|
|
->where('permissions.name', 'like', 'menu:%.view')
|
|
->pluck('permissions.name')
|
|
->toArray();
|
|
|
|
// 3. 개인 ALLOW 권한 (permission_overrides에서 User 타입, effect=1)
|
|
// Note: mng는 App\Models\User를 사용하므로 하드코딩
|
|
$personalAllows = DB::table('permission_overrides')
|
|
->join('permissions', 'permissions.id', '=', 'permission_overrides.permission_id')
|
|
->where('permission_overrides.model_type', 'App\\Models\\User')
|
|
->where('permission_overrides.model_id', $userId)
|
|
->where('permission_overrides.tenant_id', $tenantId)
|
|
->where('permission_overrides.effect', 1)
|
|
->whereNull('permission_overrides.deleted_at')
|
|
->where(function ($q) use ($now) {
|
|
$q->whereNull('permission_overrides.effective_from')
|
|
->orWhere('permission_overrides.effective_from', '<=', $now);
|
|
})
|
|
->where(function ($q) use ($now) {
|
|
$q->whereNull('permission_overrides.effective_to')
|
|
->orWhere('permission_overrides.effective_to', '>=', $now);
|
|
})
|
|
->where('permissions.name', 'like', 'menu:%.view')
|
|
->pluck('permissions.name')
|
|
->toArray();
|
|
|
|
// 4. 개인 DENY 권한 (permission_overrides에서 User 타입, effect=0)
|
|
// Note: mng는 App\Models\User를 사용하므로 하드코딩
|
|
$personalDenies = DB::table('permission_overrides')
|
|
->join('permissions', 'permissions.id', '=', 'permission_overrides.permission_id')
|
|
->where('permission_overrides.model_type', 'App\\Models\\User')
|
|
->where('permission_overrides.model_id', $userId)
|
|
->where('permission_overrides.tenant_id', $tenantId)
|
|
->where('permission_overrides.effect', 0)
|
|
->whereNull('permission_overrides.deleted_at')
|
|
->where(function ($q) use ($now) {
|
|
$q->whereNull('permission_overrides.effective_from')
|
|
->orWhere('permission_overrides.effective_from', '<=', $now);
|
|
})
|
|
->where(function ($q) use ($now) {
|
|
$q->whereNull('permission_overrides.effective_to')
|
|
->orWhere('permission_overrides.effective_to', '>=', $now);
|
|
})
|
|
->where('permissions.name', 'like', 'menu:%.view')
|
|
->pluck('permissions.name')
|
|
->toArray();
|
|
|
|
// 5. 최종 권한 계산: (역할 OR 부서 OR 개인ALLOW) - 개인DENY
|
|
$allAllowed = array_unique(array_merge($rolePermissions, $deptPermissions, $personalAllows));
|
|
$effectivePermissions = array_diff($allAllowed, $personalDenies);
|
|
|
|
// 메뉴 ID 추출
|
|
$allowedMenuIds = [];
|
|
foreach ($effectivePermissions as $permName) {
|
|
if (preg_match('/^menu:(\d+)\.view$/', $permName, $matches)) {
|
|
$allowedMenuIds[] = (int) $matches[1];
|
|
}
|
|
}
|
|
|
|
return $allowedMenuIds;
|
|
}
|
|
|
|
/**
|
|
* 메뉴 단건 조회
|
|
*/
|
|
public static function show(array $params)
|
|
{
|
|
$id = (int) ($params['id'] ?? 0);
|
|
$tenantId = self::tenantId();
|
|
|
|
if (! $id) {
|
|
return ['error' => 'id가 필요합니다.', 'code' => 400];
|
|
}
|
|
|
|
$res = Menu::withShared($tenantId)->find($id);
|
|
if (empty($res['data'])) {
|
|
return ['error' => 'Menu not found', 'code' => 404];
|
|
}
|
|
|
|
return $res;
|
|
}
|
|
|
|
/**
|
|
* 메뉴 생성
|
|
*/
|
|
public static function store(array $params)
|
|
{
|
|
$tenantId = self::tenantId();
|
|
$userId = self::actorId();
|
|
|
|
$v = Validator::make($params, [
|
|
'parent_id' => ['nullable', 'integer'],
|
|
'name' => ['required', 'string', 'max:100'],
|
|
'url' => ['nullable', 'string', 'max:255'],
|
|
'is_active' => ['nullable', 'boolean'],
|
|
'sort_order' => ['nullable', 'integer'],
|
|
'hidden' => ['nullable', 'boolean'],
|
|
'is_external' => ['nullable', 'boolean'],
|
|
'external_url' => ['nullable', 'string', 'max:255'],
|
|
'icon' => ['nullable', 'string', 'max:50'],
|
|
]);
|
|
|
|
if ($v->fails()) {
|
|
return ['error' => $v->errors()->first(), 'code' => 422];
|
|
}
|
|
$data = $v->validated();
|
|
|
|
$menu = new Menu;
|
|
$menu->tenant_id = $tenantId;
|
|
$menu->parent_id = $data['parent_id'] ?? null;
|
|
$menu->name = $data['name'];
|
|
$menu->url = $data['url'] ?? null;
|
|
$menu->is_active = (int) ($data['is_active'] ?? 1);
|
|
$menu->sort_order = (int) ($data['sort_order'] ?? 0);
|
|
$menu->hidden = (int) ($data['hidden'] ?? 0);
|
|
$menu->is_external = (int) ($data['is_external'] ?? 0);
|
|
$menu->external_url = $data['external_url'] ?? null;
|
|
$menu->icon = $data['icon'] ?? null;
|
|
$menu->created_by = $userId;
|
|
$menu->updated_by = $userId;
|
|
$menu->save();
|
|
|
|
// 생성 결과를 그대로 전달
|
|
return $menu->fresh();
|
|
}
|
|
|
|
/**
|
|
* 메뉴 수정
|
|
* - global_menu_id가 있는 테넌트 메뉴 수정 시 is_customized = true 자동 설정
|
|
*/
|
|
public static function update(array $params)
|
|
{
|
|
$id = (int) ($params['id'] ?? 0);
|
|
$tenantId = self::tenantId();
|
|
$userId = self::actorId();
|
|
|
|
if (! $id) {
|
|
return ['error' => 'id가 필요합니다.', 'code' => 400];
|
|
}
|
|
|
|
$v = Validator::make($params, [
|
|
'parent_id' => ['nullable', 'integer'],
|
|
'name' => ['nullable', 'string', 'max:100'],
|
|
'url' => ['nullable', 'string', 'max:255'],
|
|
'is_active' => ['nullable', 'boolean'],
|
|
'sort_order' => ['nullable', 'integer'],
|
|
'hidden' => ['nullable', 'boolean'],
|
|
'is_external' => ['nullable', 'boolean'],
|
|
'external_url' => ['nullable', 'string', 'max:255'],
|
|
'icon' => ['nullable', 'string', 'max:50'],
|
|
]);
|
|
|
|
if ($v->fails()) {
|
|
return ['error' => $v->errors()->first(), 'code' => 422];
|
|
}
|
|
$data = $v->validated();
|
|
|
|
$menu = Menu::withShared($tenantId)->where('id', $id)->first();
|
|
if (! $menu) {
|
|
return ['error' => 'Menu not found', 'code' => 404];
|
|
}
|
|
|
|
$update = Arr::only($data, [
|
|
'parent_id', 'name', 'url', 'is_active', 'sort_order', 'hidden', 'is_external', 'external_url', 'icon',
|
|
]);
|
|
$update = array_filter($update, fn ($v) => ! is_null($v));
|
|
|
|
if (empty($update)) {
|
|
return ['error' => '수정할 데이터가 없습니다.', 'code' => 400];
|
|
}
|
|
|
|
// 글로벌 메뉴에서 복제된 테넌트 메뉴 수정 시 커스터마이징 플래그 설정
|
|
if ($menu->global_menu_id && ! $menu->is_customized) {
|
|
$update['is_customized'] = true;
|
|
}
|
|
|
|
$update['updated_by'] = $userId;
|
|
$menu->fill($update)->save();
|
|
|
|
return $menu->fresh();
|
|
}
|
|
|
|
/**
|
|
* 메뉴 삭제(소프트)
|
|
*/
|
|
public static function destroy(array $params)
|
|
{
|
|
$id = (int) ($params['id'] ?? 0);
|
|
$tenantId = self::tenantId();
|
|
$userId = self::actorId();
|
|
|
|
if (! $id) {
|
|
return ['error' => 'id가 필요합니다.', 'code' => 400];
|
|
}
|
|
|
|
$menu = Menu::withShared($tenantId)->where('id', $id)->first();
|
|
if (! $menu) {
|
|
return ['error' => 'Menu not found', 'code' => 404];
|
|
}
|
|
|
|
$menu->deleted_by = $userId;
|
|
$menu->save();
|
|
$menu->delete();
|
|
|
|
return 'success';
|
|
}
|
|
|
|
/**
|
|
* 정렬 일괄 변경
|
|
* $params = [ ['id'=>10, 'sort_order'=>1], ... ]
|
|
*/
|
|
public static function reorder(array $params)
|
|
{
|
|
if (! is_array($params) || empty($params)) {
|
|
return ['error' => '유효한 정렬 목록이 필요합니다.', 'code' => 422];
|
|
}
|
|
$tenantId = self::tenantId();
|
|
|
|
DB::transaction(function () use ($params, $tenantId) {
|
|
foreach ($params as $it) {
|
|
if (! isset($it['id'], $it['sort_order'])) {
|
|
continue;
|
|
}
|
|
|
|
$menu = Menu::withShared($tenantId)->find((int) $it['id']);
|
|
if ($menu) {
|
|
$menu->sort_order = (int) $it['sort_order'];
|
|
$menu->save();
|
|
}
|
|
}
|
|
});
|
|
|
|
return 'success';
|
|
}
|
|
|
|
/**
|
|
* 상태 토글: is_active / hidden / is_external
|
|
*/
|
|
public static function toggle(array $params)
|
|
{
|
|
$id = (int) ($params['id'] ?? 0);
|
|
$tenantId = self::tenantId();
|
|
$userId = self::actorId();
|
|
|
|
if (! $id) {
|
|
return ['error' => 'id가 필요합니다.', 'code' => 400];
|
|
}
|
|
|
|
$payload = array_filter([
|
|
'is_active' => array_key_exists('is_active', $params) ? (int) $params['is_active'] : null,
|
|
'hidden' => array_key_exists('hidden', $params) ? (int) $params['hidden'] : null,
|
|
'is_external' => array_key_exists('is_external', $params) ? (int) $params['is_external'] : null,
|
|
], fn ($v) => ! is_null($v));
|
|
|
|
if (empty($payload)) {
|
|
return ['error' => '변경할 필드가 없습니다.', 'code' => 422];
|
|
}
|
|
|
|
$menu = Menu::withShared($tenantId)->find($id);
|
|
if (! $menu) {
|
|
return ['error' => 'Menu not found', 'code' => 404];
|
|
}
|
|
|
|
$payload['updated_by'] = $userId;
|
|
$menu->fill($payload)->save();
|
|
|
|
return $menu->fresh();
|
|
}
|
|
|
|
/**
|
|
* 삭제된 메뉴 복원
|
|
*/
|
|
public static function restore(array $params)
|
|
{
|
|
$id = (int) ($params['id'] ?? 0);
|
|
$tenantId = self::tenantId();
|
|
$userId = self::actorId();
|
|
|
|
if (! $id) {
|
|
return ['error' => 'id가 필요합니다.', 'code' => 400];
|
|
}
|
|
|
|
// 삭제된 메뉴 포함하여 조회
|
|
$menu = Menu::withTrashed()
|
|
->withoutGlobalScopes()
|
|
->where(function ($q) use ($tenantId) {
|
|
$q->whereNull('tenant_id')
|
|
->orWhere('tenant_id', $tenantId);
|
|
})
|
|
->where('id', $id)
|
|
->first();
|
|
|
|
if (! $menu) {
|
|
return ['error' => 'Menu not found', 'code' => 404];
|
|
}
|
|
|
|
if (! $menu->trashed()) {
|
|
return ['error' => '삭제되지 않은 메뉴입니다.', 'code' => 400];
|
|
}
|
|
|
|
$menu->restore();
|
|
$menu->deleted_by = null;
|
|
$menu->updated_by = $userId;
|
|
$menu->save();
|
|
|
|
return $menu->fresh();
|
|
}
|
|
|
|
/**
|
|
* 삭제된 메뉴 목록 조회
|
|
*/
|
|
public static function trashedList(array $params = [])
|
|
{
|
|
$tenantId = self::tenantId();
|
|
|
|
return Menu::onlyTrashed()
|
|
->withoutGlobalScopes()
|
|
->where(function ($q) use ($tenantId) {
|
|
$q->whereNull('tenant_id')
|
|
->orWhere('tenant_id', $tenantId);
|
|
})
|
|
->orderBy('deleted_at', 'desc')
|
|
->get();
|
|
}
|
|
|
|
// =========================================================================
|
|
// 게시판 메뉴 연동 메서드 (테넌트 게시판 전용)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 테넌트 게시판용 메뉴 생성
|
|
*
|
|
* @param string $boardCode 게시판 코드
|
|
* @param string $boardName 게시판 이름
|
|
* @param int $tenantId 테넌트 ID
|
|
* @return Menu|null 생성된 메뉴 또는 null (중복 시)
|
|
*/
|
|
public static function createMenuForBoard(string $boardCode, string $boardName, int $tenantId): ?Menu
|
|
{
|
|
$url = '/boards/'.$boardCode;
|
|
$userId = self::actorId();
|
|
|
|
// 중복 체크
|
|
if (Menu::where('url', $url)->where('tenant_id', $tenantId)->exists()) {
|
|
return null;
|
|
}
|
|
|
|
// 부모 메뉴 찾기
|
|
$parentId = self::findParentMenuForBoard($tenantId);
|
|
|
|
// 정렬 순서 계산
|
|
$maxOrder = Menu::where('tenant_id', $tenantId)
|
|
->where('parent_id', $parentId)
|
|
->max('sort_order') ?? 0;
|
|
|
|
return Menu::create([
|
|
'tenant_id' => $tenantId,
|
|
'parent_id' => $parentId,
|
|
'name' => $boardName,
|
|
'url' => $url,
|
|
'icon' => 'document-text',
|
|
'sort_order' => $maxOrder + 1,
|
|
'is_active' => true,
|
|
'hidden' => false,
|
|
'is_external' => false,
|
|
'created_by' => $userId,
|
|
'updated_by' => $userId,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 테넌트 게시판 메뉴의 부모 메뉴 ID 찾기
|
|
* 우선순위: /board → /boards → /customer-center → null (최상위)
|
|
*
|
|
* @param int $tenantId 테넌트 ID
|
|
* @return int|null 부모 메뉴 ID
|
|
*/
|
|
protected static function findParentMenuForBoard(int $tenantId): ?int
|
|
{
|
|
$priorityUrls = ['/board', '/boards', '/customer-center'];
|
|
|
|
foreach ($priorityUrls as $url) {
|
|
$menu = Menu::where('tenant_id', $tenantId)
|
|
->where('url', $url)
|
|
->first();
|
|
|
|
if ($menu) {
|
|
return $menu->id;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 테넌트 게시판용 메뉴 수정 (코드/이름 변경 시)
|
|
*
|
|
* @param string $oldCode 기존 게시판 코드
|
|
* @param string $newCode 새 게시판 코드
|
|
* @param string $newName 새 게시판 이름
|
|
* @param int $tenantId 테넌트 ID
|
|
* @return bool 수정 성공 여부
|
|
*/
|
|
public static function updateMenuForBoard(string $oldCode, string $newCode, string $newName, int $tenantId): bool
|
|
{
|
|
$oldUrl = '/boards/'.$oldCode;
|
|
$newUrl = '/boards/'.$newCode;
|
|
|
|
$menu = Menu::where('url', $oldUrl)
|
|
->where('tenant_id', $tenantId)
|
|
->first();
|
|
|
|
if (! $menu) {
|
|
return false;
|
|
}
|
|
|
|
$menu->url = $newUrl;
|
|
$menu->name = $newName;
|
|
$menu->updated_by = self::actorId();
|
|
$menu->save();
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 테넌트 게시판용 메뉴 삭제 (Soft Delete)
|
|
*
|
|
* @param string $boardCode 게시판 코드
|
|
* @param int $tenantId 테넌트 ID
|
|
* @param bool $forceDelete 영구 삭제 여부
|
|
* @return bool 삭제 성공 여부
|
|
*/
|
|
public static function deleteMenuForBoard(string $boardCode, int $tenantId, bool $forceDelete = false): bool
|
|
{
|
|
$url = '/boards/'.$boardCode;
|
|
|
|
$query = $forceDelete
|
|
? Menu::withTrashed()->where('url', $url)->where('tenant_id', $tenantId)
|
|
: Menu::where('url', $url)->where('tenant_id', $tenantId);
|
|
|
|
$menu = $query->first();
|
|
|
|
if (! $menu) {
|
|
return false;
|
|
}
|
|
|
|
if (! $forceDelete) {
|
|
$menu->deleted_by = self::actorId();
|
|
$menu->save();
|
|
}
|
|
|
|
return $forceDelete ? $menu->forceDelete() : $menu->delete();
|
|
}
|
|
|
|
/**
|
|
* 테넌트 게시판용 메뉴 복원
|
|
*
|
|
* @param string $boardCode 게시판 코드
|
|
* @param string $boardName 게시판 이름
|
|
* @param int $tenantId 테넌트 ID
|
|
* @return bool 복원 성공 여부
|
|
*/
|
|
public static function restoreMenuForBoard(string $boardCode, string $boardName, int $tenantId): bool
|
|
{
|
|
$url = '/boards/'.$boardCode;
|
|
|
|
// 1. soft-deleted 메뉴 확인
|
|
$trashedMenu = Menu::onlyTrashed()
|
|
->where('url', $url)
|
|
->where('tenant_id', $tenantId)
|
|
->first();
|
|
|
|
if ($trashedMenu) {
|
|
$trashedMenu->restore();
|
|
$trashedMenu->deleted_by = null;
|
|
$trashedMenu->updated_by = self::actorId();
|
|
$trashedMenu->save();
|
|
|
|
return true;
|
|
}
|
|
|
|
// 2. 이미 활성 메뉴가 있는지 확인
|
|
if (Menu::where('url', $url)->where('tenant_id', $tenantId)->exists()) {
|
|
return true; // 이미 존재하면 성공으로 처리
|
|
}
|
|
|
|
// 3. 둘 다 없으면 새로 생성
|
|
$newMenu = self::createMenuForBoard($boardCode, $boardName, $tenantId);
|
|
|
|
return $newMenu !== null;
|
|
}
|
|
}
|