feat(mng): 게시판-메뉴 자동 연동 및 URL 패턴 변경

## 주요 변경사항

### 게시판-메뉴 자동 연동
- 게시판 생성 시 메뉴 자동 생성 (BoardService.createBoardFromTemplate)
- 게시판 삭제 시 연결 메뉴 함께 삭제 (Soft Delete 연동)
- 게시판 복원 시 메뉴 재생성
- 게시판 영구삭제 시 메뉴 영구삭제

### 게시판 메뉴 보호
- MenuService: 게시판 연동 메뉴 수동 수정/삭제 방지
- isBoardMenuUrl(), isBoardMenu(), validateNotBoardUrl() 헬퍼 추가
- 8개 CRUD 메서드에 검증 로직 적용

### URL 패턴 변경
- 시스템 게시판: /system-boards/{code} → /customer-center/{code}
- 테넌트 게시판: /boards/{code} (변경 없음)

### UI 개선
- 메뉴 목록에서 게시판 메뉴 "📋 게시판" 뱃지 표시
- 게시판 메뉴는 수정/삭제 버튼 숨김 (활성/숨김 토글만 허용)
- 삭제된 게시판 행 클릭 시 404 오류 수정

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-27 21:54:48 +09:00
parent b54a04d588
commit cd6cf9746a
10 changed files with 475 additions and 94 deletions

View File

@@ -15,6 +15,50 @@
class MenuService
{
// =========================================================================
// 게시판 메뉴 연동 헬퍼
// =========================================================================
/**
* 게시판 연동 URL 패턴인지 확인
* - /customer-center/* : 시스템 게시판 (고객센터)
* - /boards/* : 테넌트 게시판
*/
public function isBoardMenuUrl(?string $url): bool
{
if (empty($url)) {
return false;
}
return str_starts_with($url, '/customer-center/') || str_starts_with($url, '/boards/');
}
/**
* 게시판 연동 메뉴인지 확인 (Menu 또는 GlobalMenu)
*/
public function isBoardMenu(Menu|GlobalMenu $menu): bool
{
return $this->isBoardMenuUrl($menu->url);
}
/**
* 게시판 메뉴 URL 수동 생성/수정 방지 검증
*
* @throws \InvalidArgumentException 게시판 URL 패턴 사용 시
*/
public function validateNotBoardUrl(?string $url): void
{
if ($this->isBoardMenuUrl($url)) {
throw new \InvalidArgumentException(
'게시판 연동 URL 패턴(/customer-center/*, /boards/*)은 직접 사용할 수 없습니다. 게시판 관리에서 생성해주세요.'
);
}
}
// =========================================================================
// 메뉴 목록 조회
// =========================================================================
/**
* 메뉴 목록 조회 (페이지네이션) - 트리 구조로 정렬
*/
@@ -189,9 +233,14 @@ public function getParentMenus(?int $tenantId = null): Collection
/**
* 메뉴 생성
*
* @throws \InvalidArgumentException 게시판 URL 패턴 사용 시
*/
public function createMenu(array $data): Menu
{
// 게시판 URL 패턴 수동 생성 방지
$this->validateNotBoardUrl($data['url'] ?? null);
$tenantId = session('selected_tenant_id');
// is_active 처리
@@ -221,6 +270,8 @@ public function createMenu(array $data): Menu
/**
* 메뉴 수정
*
* @throws \InvalidArgumentException 게시판 메뉴 수정 또는 게시판 URL 패턴 사용 시
*/
public function updateMenu(int $id, array $data): bool
{
@@ -229,6 +280,16 @@ public function updateMenu(int $id, array $data): bool
return false;
}
// 게시판 연동 메뉴는 수정 불가 (활성/숨김 토글만 허용)
if ($this->isBoardMenu($menu)) {
throw new \InvalidArgumentException(
'게시판 연동 메뉴는 수정할 수 없습니다. 게시판 관리에서 수정해주세요.'
);
}
// 새 URL이 게시판 URL 패턴이면 거부
$this->validateNotBoardUrl($data['url'] ?? null);
// is_active 처리
$data['is_active'] = isset($data['is_active']) && $data['is_active'] == '1';
@@ -256,6 +317,9 @@ public function updateMenu(int $id, array $data): bool
/**
* 메뉴 삭제 (Soft Delete)
*
* @throws \InvalidArgumentException 게시판 메뉴 삭제 시도 시
*/
public function deleteMenu(int $id): bool
{
@@ -264,6 +328,13 @@ public function deleteMenu(int $id): bool
return false;
}
// 게시판 연동 메뉴는 삭제 불가
if ($this->isBoardMenu($menu)) {
throw new \InvalidArgumentException(
'게시판 연동 메뉴는 삭제할 수 없습니다. 게시판 관리에서 삭제해주세요.'
);
}
// 자식 메뉴가 있는 경우 삭제 불가
if ($menu->children()->count() > 0) {
return false;
@@ -292,11 +363,20 @@ public function restoreMenu(int $id): bool
* - 삭제 정보를 archived_records에 저장
*
* @return array{success: bool, message: string, deleted_permissions: array}
*
* @throws \InvalidArgumentException 게시판 메뉴 영구 삭제 시도 시
*/
public function forceDeleteMenu(int $id): array
{
$menu = Menu::withTrashed()->findOrFail($id);
// 게시판 연동 메뉴는 영구 삭제 불가
if ($this->isBoardMenu($menu)) {
throw new \InvalidArgumentException(
'게시판 연동 메뉴는 영구 삭제할 수 없습니다. 게시판 관리에서 삭제해주세요.'
);
}
// 자식 메뉴가 있는 경우 영구 삭제 불가
if ($menu->children()->withTrashed()->count() > 0) {
return [
@@ -600,9 +680,14 @@ public function getGlobalMenus(array $filters = [], int $perPage = 15): LengthAw
/**
* 글로벌 메뉴 생성
*
* @throws \InvalidArgumentException 게시판 URL 패턴 사용 시
*/
public function createGlobalMenu(array $data): GlobalMenu
{
// 게시판 URL 패턴 수동 생성 방지
$this->validateNotBoardUrl($data['url'] ?? null);
// is_active 처리
$data['is_active'] = isset($data['is_active']) && $data['is_active'] == '1';
@@ -622,6 +707,8 @@ public function createGlobalMenu(array $data): GlobalMenu
/**
* 글로벌 메뉴 수정
*
* @throws \InvalidArgumentException 게시판 메뉴 수정 또는 게시판 URL 패턴 사용 시
*/
public function updateGlobalMenu(int $id, array $data): bool
{
@@ -630,6 +717,16 @@ public function updateGlobalMenu(int $id, array $data): bool
return false;
}
// 게시판 연동 메뉴는 수정 불가 (활성/숨김 토글만 허용)
if ($this->isBoardMenu($menu)) {
throw new \InvalidArgumentException(
'게시판 연동 메뉴는 수정할 수 없습니다. 게시판 관리에서 수정해주세요.'
);
}
// 새 URL이 게시판 URL 패턴이면 거부
$this->validateNotBoardUrl($data['url'] ?? null);
// is_active 처리
$data['is_active'] = isset($data['is_active']) && $data['is_active'] == '1';
@@ -654,6 +751,8 @@ public function updateGlobalMenu(int $id, array $data): bool
/**
* 글로벌 메뉴 삭제 (Soft Delete)
*
* @throws \InvalidArgumentException 게시판 연동 메뉴인 경우
*/
public function deleteGlobalMenu(int $id): bool
{
@@ -662,6 +761,13 @@ public function deleteGlobalMenu(int $id): bool
return false;
}
// 게시판 연동 메뉴는 삭제 불가
if ($this->isBoardMenu($menu)) {
throw new \InvalidArgumentException(
'게시판 연동 메뉴는 삭제할 수 없습니다. 게시판 관리에서 삭제해주세요.'
);
}
// 자식 메뉴가 있는 경우 삭제 불가
if ($menu->children()->count() > 0) {
return false;
@@ -687,11 +793,20 @@ public function restoreGlobalMenu(int $id): bool
* - 삭제 정보를 archived_records에 저장
*
* @return array{success: bool, message: string, deleted_permissions: array}
*
* @throws \InvalidArgumentException 게시판 연동 메뉴인 경우
*/
public function forceDeleteGlobalMenu(int $id): array
{
$menu = GlobalMenu::withTrashed()->findOrFail($id);
// 게시판 연동 메뉴는 영구 삭제 불가
if ($this->isBoardMenu($menu)) {
throw new \InvalidArgumentException(
'게시판 연동 메뉴는 영구 삭제할 수 없습니다. 게시판 관리에서 삭제해주세요.'
);
}
// 자식 메뉴가 있는 경우 영구 삭제 불가
if ($menu->children()->withTrashed()->count() > 0) {
return [
@@ -1045,4 +1160,184 @@ public function copyFromGlobal(int $tenantId, array $menuIds): array
];
});
}
// =========================================================================
// 게시판 메뉴 자동 생성
// =========================================================================
/**
* URL로 부모 메뉴 찾기 (우선순위: /customer-center → /boards or /system-boards → null)
*
* @param bool $isSystem 시스템 게시판 여부
* @param int|null $tenantId 테넌트 ID (시스템 게시판이면 null)
*/
public function findParentMenuForBoard(bool $isSystem, ?int $tenantId = null): ?int
{
// 우선순위 URL 목록
$priorityUrls = ['/customer-center'];
if ($isSystem) {
// 시스템 게시판: global_menus에서 찾기
$priorityUrls[] = '/system-boards';
$priorityUrls[] = '/boards';
foreach ($priorityUrls as $url) {
$menu = GlobalMenu::where('url', $url)
->where('is_active', true)
->first();
if ($menu) {
return $menu->id;
}
}
return null; // 최상위로 추가
} else {
// 테넌트 게시판: menus에서 찾기
$priorityUrls[] = '/boards';
foreach ($priorityUrls as $url) {
$query = Menu::where('url', $url)
->where('is_active', true);
if ($tenantId) {
$query->where('tenant_id', $tenantId);
} else {
$query->whereNull('tenant_id');
}
$menu = $query->first();
if ($menu) {
return $menu->id;
}
}
return null; // 최상위로 추가
}
}
/**
* 게시판에 대한 메뉴 자동 생성
*
* @param array $boardData 게시판 정보 (board_code, name, is_system, tenant_id)
* @return GlobalMenu|Menu|null 생성된 메뉴 또는 null
*/
public function createMenuForBoard(array $boardData): GlobalMenu|Menu|null
{
$isSystem = $boardData['is_system'] ?? false;
$tenantId = $boardData['tenant_id'] ?? null;
$boardCode = $boardData['board_code'];
$boardName = $boardData['name'];
// 부모 메뉴 찾기
$parentId = $this->findParentMenuForBoard($isSystem, $tenantId);
if ($isSystem) {
// 시스템 게시판 → global_menus에 추가
$url = '/customer-center/'.$boardCode;
// 중복 체크
$exists = GlobalMenu::where('url', $url)->exists();
if ($exists) {
return null;
}
// 정렬 순서 계산
$maxOrder = GlobalMenu::where('parent_id', $parentId)->max('sort_order') ?? 0;
return GlobalMenu::create([
'parent_id' => $parentId,
'name' => $boardName,
'url' => $url,
'icon' => 'document-text', // 기본 아이콘
'sort_order' => $maxOrder + 1,
'is_active' => true,
'hidden' => false,
'is_external' => false,
]);
} else {
// 테넌트 게시판 → menus에 추가
$url = '/boards/'.$boardCode;
// 중복 체크
$query = Menu::where('url', $url);
if ($tenantId) {
$query->where('tenant_id', $tenantId);
} else {
$query->whereNull('tenant_id');
}
if ($query->exists()) {
return null;
}
// 정렬 순서 계산
$maxOrderQuery = Menu::where('parent_id', $parentId);
if ($tenantId) {
$maxOrderQuery->where('tenant_id', $tenantId);
} else {
$maxOrderQuery->whereNull('tenant_id');
}
$maxOrder = $maxOrderQuery->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' => auth()->id(),
]);
}
}
/**
* 게시판 삭제 시 연결된 메뉴도 삭제
*
* @param string $boardCode 게시판 코드
* @param bool $isSystem 시스템 게시판 여부
* @param int|null $tenantId 테넌트 ID
*/
public function deleteMenuForBoard(string $boardCode, bool $isSystem, ?int $tenantId = null, bool $forceDelete = false): bool
{
if ($isSystem) {
$url = '/customer-center/'.$boardCode;
$menu = $forceDelete
? GlobalMenu::withTrashed()->where('url', $url)->first()
: GlobalMenu::where('url', $url)->first();
if ($menu) {
return $forceDelete ? $menu->forceDelete() : $menu->delete();
}
} else {
$url = '/boards/'.$boardCode;
$query = $forceDelete
? Menu::withTrashed()->where('url', $url)
: Menu::where('url', $url);
if ($tenantId) {
$query->where('tenant_id', $tenantId);
} else {
$query->whereNull('tenant_id');
}
$menu = $query->first();
if ($menu) {
if (! $forceDelete) {
$menu->deleted_by = auth()->id();
$menu->save();
}
return $forceDelete ? $menu->forceDelete() : $menu->delete();
}
}
return false;
}
}