File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
09-17
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<string, unknown> {
return value !== null && typeof value === 'object';
}
function readString(source: Record<string, unknown>, 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<SystemMenu[]> => {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(`${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(`<if test="… != null">`)이라 기존 값이 유지되고, 신규는 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<string, unknown>
): 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<void> {
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<void> {
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<unknown>(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<void> {
const accessToken = await getSessionAccessToken();
// 수정은 빈 문자열을 그대로 보낸다 — UPDATE가 null만 건너뛰므로 이래야 아이콘을 지울 수 있다.
const params = {
...toWriteParams(values),
atchFileId: values.largeIconId,
smallAtchFileId: values.smallIconId,
};
const result = await backendFetch<unknown>(`${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<void> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(`${MENU_PATH}/${menuId}`, {
method: 'DELETE',
query: { menuId },
accessToken: accessToken ?? undefined,
cache: 'no-store',
canHaveNullData: true,
canHaveEmptyBody: true,
});
if (!result.ok) {
throw new BackendRequestError(result);
}
}