import 'server-only'; import { cache } from 'react'; import { getSessionAccessToken } from '@/lib/auth/dal'; import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; import { buildFileImageUrl } from '@/lib/data/repositories/file-repository'; import type { SystemMenu } from '@/lib/domain/system-menu'; import type { SystemMenuValues } from '@/lib/domain/system-menu-form'; /** * 시스템 메뉴 Repository — `TB_SYS_MENU`. * * ``` * GET /api/v1/mngr/menu/pagination 목록(페이징). 트리라 한 번에 다 받는다 * POST /api/v1/mngr/menu 어노테이션 없음 → form. upMenuId의 부모가 있어야 한다 * PUT /api/v1/mngr/menu/{menuId} @RequestBody(JSON) * DELETE /api/v1/mngr/menu/{menuId} soft delete(DEL_YN) * ``` * * ⚠️ 백엔드 결함(보고함, 미수정) * 1. `DELETE`의 인자에 `@PathVariable`이 없어 경로 값이 바인딩되지 않는다. 경로와 쿼리에 같은 * 값을 함께 실어 보낸다 — 어노테이션이 붙어도 그대로 동작한다. * 2. `GET /{menuId}`는 경로가 `{menuId}`인데 인자가 `@PathVariable String userId`이고 본문도 빈 * VO로 조회한다. 목록에 모든 필드가 있어 이 API는 쓰지 않는다. * 3. 새창여부에 해당하는 컬럼이 없다. 화면에는 시안대로 두되 값을 보내지 않는다(사용자 확정). */ const MENU_PATH = '/api/v1/mngr/menu'; // TODO(백엔드): 전체 조회 모드가 없어 큰 페이지 하나로 받는다. 메뉴가 이보다 많아지면 잘린다. const FETCH_LIMIT = 1000; function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object'; } function readString(source: Record, key: string): string | null { const value = source[key]; return typeof value === 'string' && value.length > 0 ? value : null; } function parseYesNo(value: unknown): boolean { return value === 'Y'; } function toSystemMenu(raw: unknown): SystemMenu[] { if (!isRecord(raw)) { return []; } const id = readString(raw, 'menuId'); if (!id) { return []; } return [ { id, parentId: readString(raw, 'upMenuId'), name: readString(raw, 'menuName') ?? '', idPath: readString(raw, 'menuIdPath'), url: readString(raw, 'menuUrl'), roleName: readString(raw, 'roleNm'), sortOrder: Number.isInteger(Number(raw.sortOrdr)) ? Number(raw.sortOrdr) : 0, isActive: parseYesNo(raw.useYn), isTopActive: parseYesNo(raw.topYn), isDefault: parseYesNo(raw.defaultYn), largeIconId: readString(raw, 'atchFileId'), largeIconName: readString(raw, 'fileNm'), // 미리보기 주소는 서버에서 만든다 — 주소 조립기가 server-only라 화면이 부를 수 없다. largeIconUrl: buildFileImageUrl(readString(raw, 'atchFileId')), smallIconId: readString(raw, 'smallAtchFileId'), smallIconName: readString(raw, 'smallFileNm'), smallIconUrl: buildFileImageUrl(readString(raw, 'smallAtchFileId')), }, ]; } export const fetchSystemMenus = cache(async (): Promise => { const accessToken = await getSessionAccessToken(); const result = await backendFetch(`${MENU_PATH}/pagination`, { method: 'GET', query: { pageIndex: 1, recordCountPerPage: FETCH_LIMIT }, accessToken: accessToken ?? undefined, cache: 'no-store', }); if (!result.ok) { throw new BackendRequestError(result); } const data = result.data; if (!isRecord(data) || !Array.isArray(data.list)) { throw new Error('메뉴 목록 응답의 형식이 올바르지 않습니다.'); } return data.list.flatMap(toSystemMenu); }); /** * 새창여부는 보내지 않는다 — 저장할 컬럼이 없다(파일 상단 ⚠️ 3). * 메뉴권한(roleId)과 메뉴설명(menuExplan)도 화면에서 빠져 보내지 않는다 — update는 동적 * SET(``)이라 기존 값이 유지되고, 신규는 NULL로 들어간다. */ function toWriteParams(values: SystemMenuValues) { return { menuName: values.name, sortOrdr: values.sortOrder, topYn: values.isTopActive ? 'Y' : 'N', menuUrl: values.url, useYn: values.isActive ? 'Y' : 'N', }; } function logFailure( action: string, result: { code: number; message: string }, params: Record ): void { console.error(`[system-menu] ${action} 실패`, { code: result.code, message: result.message, params, }); } /** * 등록 SQL이 만들 MENU_ID를 그대로 흉내 내 본다 — 백엔드 로그를 볼 수 없어 500의 정체를 * 가릴 방법이 이것뿐이다. * * selectKey가 `MAX(menu_id) + 1`을 **형제 안에서만** 구하는데 MENU_ID는 전체 기본키라, * 그 값이 다른 가지에 이미 있으면 중복키로 죽는다. * * TODO(백엔드): selectKey의 `WHERE UP_MENU_ID` 조건이 빠지면 이 진단도 지운다. */ async function logGeneratedIdCollision(parentId: string): Promise { try { const menus = await fetchSystemMenus(); const siblingMax = menus .filter((menu) => menu.parentId === parentId) .reduce((max, menu) => Math.max(max, Number(menu.id) || 0), 0); const generatedId = String(siblingMax + 1); const owner = menus.find((menu) => menu.id === generatedId); console.error('[system-menu] 백엔드가 만들 MENU_ID 예측', { upMenuId: parentId, 형제중최댓값: siblingMax, 만들어질MENU_ID: generatedId, 이미쓰는메뉴: owner ? `${owner.id} ${owner.name}` : '없음', 판정: owner ? '중복키로 실패한다' : '중복은 아니다 — 다른 원인', }); } catch { console.error('[system-menu] MENU_ID 예측 실패 — 목록을 다시 읽지 못했다'); } } export async function createSystemMenu(values: SystemMenuValues): Promise { const accessToken = await getSessionAccessToken(); // 등록에는 지울 아이콘이 없다. 빈 문자열을 보내면 파일 테이블을 가리키는 값이 되어 // 제약에 걸릴 수 있으므로 고른 것이 있을 때만 싣는다. const params = { ...toWriteParams(values), upMenuId: values.parentId, menuIdPath: values.parentIdPath || undefined, // INSERT에 DEFAULT_YN이 들어가는데 화면에는 없는 항목이다. 보내지 않으면 NULL이 박힌다. defaultYn: 'N', ...(values.largeIconId ? { atchFileId: values.largeIconId } : {}), ...(values.smallIconId ? { smallAtchFileId: values.smallIconId } : {}), }; const result = await backendFetch(MENU_PATH, { method: 'POST', form: params, accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, canHaveEmptyBody: true, }); if (!result.ok) { logFailure('등록', result, params); await logGeneratedIdCollision(values.parentId); throw new BackendRequestError(result); } } export async function updateSystemMenu(values: SystemMenuValues): Promise { const accessToken = await getSessionAccessToken(); // 수정은 빈 문자열을 그대로 보낸다 — UPDATE가 null만 건너뛰므로 이래야 아이콘을 지울 수 있다. const params = { ...toWriteParams(values), atchFileId: values.largeIconId, smallAtchFileId: values.smallIconId, }; const result = await backendFetch(`${MENU_PATH}/${values.id}`, { method: 'PUT', body: params, accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, canHaveEmptyBody: true, }); if (!result.ok) { logFailure('수정', result, params); throw new BackendRequestError(result); } } export async function deleteSystemMenu(menuId: string): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch(`${MENU_PATH}/${menuId}`, { method: 'DELETE', query: { menuId }, accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, canHaveEmptyBody: true, }); if (!result.ok) { throw new BackendRequestError(result); } }